Skip to main content

termivar_scanner/reporting/
comparison.rs

1//! Offline, display-only comparison of untrusted rendered assessment documents.
2//!
3//! Importing a document does not authenticate its origin, establish equal scan
4//! coverage, or mint runtime evidence, findings, verification, or target authority.
5//! Report-local reference numbers are validated but never used as stable identity.
6
7use std::{
8    collections::{BTreeMap, BTreeSet},
9    error::Error,
10    fmt,
11};
12
13use serde::Serialize;
14use serde_json::Value;
15
16use super::{render_serializable_json, write_markdown_code_span, RenderBuffer, ReportError};
17
18mod html;
19mod import;
20
21#[cfg(feature = "supplied-session-review")]
22pub(super) fn validate_supplied_session_oauth_authorization(
23    value: &Value,
24) -> Result<ImportedSuppliedSessionAudit, ComparisonError> {
25    import::validate_supplied_session_oauth_authorization(value)
26}
27
28/// Apply the same closed feature-independent V6 contract before publication.
29#[cfg(feature = "supplied-session-review")]
30pub(super) fn validate_supplied_session_oauth_refresh(
31    value: &Value,
32) -> Result<ImportedSuppliedSessionAudit, ComparisonError> {
33    import::validate_supplied_session_oauth_refresh(value)
34}
35
36/// The same closed feature-independent V5 contract is checked before publication.
37#[cfg(feature = "supplied-session-review")]
38pub(super) fn validate_supplied_session_oauth_code(
39    value: &Value,
40) -> Result<ImportedSuppliedSessionAudit, ComparisonError> {
41    import::validate_supplied_session_oauth_code(value)
42}
43
44/// Applies the same closed, feature-independent V4 wire validation before publication.
45pub(super) fn validate_supplied_session_renewal(
46    value: &Value,
47) -> Result<ImportedSuppliedSessionAudit, ComparisonError> {
48    import::validate_supplied_session_renewal(value)
49}
50#[cfg(test)]
51#[path = "comparison_tests.rs"]
52mod tests;
53
54/// Versioned display-only comparison document schema.
55pub const COMPARISON_DOCUMENT_SCHEMA: &str = "termivar-report-comparison/v1";
56/// Additive display-only comparison for supplied-session context and coverage.
57pub(super) const SUPPLIED_SESSION_COMPARISON_SCHEMA: &str =
58    "termivar-supplied-session-comparison/v1";
59/// Additive, display-only passive secret-exposure comparison section.
60pub(super) const SECRET_EXPOSURE_COMPARISON_SCHEMA: &str = "termivar-secret-exposure-comparison/v1";
61/// Additive, display-only passive TLS observation comparison section.
62pub(super) const TLS_OBSERVATION_COMPARISON_SCHEMA: &str = "termivar-tls-observation-comparison/v1";
63/// Additive, display-only bounded active TLS negotiation comparison section.
64pub(super) const TLS_NEGOTIATION_REVIEW_COMPARISON_SCHEMA: &str =
65    "termivar-tls-negotiation-review-comparison/v1";
66/// Additive, display-only bounded WebSocket exchange comparison section.
67pub(super) const WEBSOCKET_REVIEW_COMPARISON_SCHEMA: &str =
68    "termivar-websocket-review-comparison/v1";
69pub(super) const WEBSOCKET_SUPPLIED_SESSION_COMPARISON_SCHEMA: &str =
70    "termivar-websocket-review-comparison/v2";
71/// Additive, display-only local JWT policy comparison section.
72pub(super) const JWT_POLICY_REVIEW_COMPARISON_SCHEMA: &str =
73    "termivar-jwt-policy-review-comparison/v1";
74/// Additive, display-only controlled XML external-entity review comparison.
75pub(super) const XML_EXTERNAL_ENTITY_REVIEW_COMPARISON_SCHEMA: &str =
76    "termivar-xml-external-entity-review-comparison/v1";
77/// Additive, display-only benign template-evaluation review comparison.
78pub(super) const TEMPLATE_EVALUATION_REVIEW_COMPARISON_SCHEMA: &str =
79    "termivar-template-evaluation-review-comparison/v1";
80/// Additive, display-only control-reference mapping comparison section.
81pub(super) const CONTROL_REFERENCE_MAPPING_COMPARISON_SCHEMA: &str =
82    "termivar-control-reference-mapping-comparison/v1";
83/// Additive, display-only local reconnaissance snapshot comparison section.
84pub(super) const RECON_SNAPSHOT_IMPORT_COMPARISON_SCHEMA: &str =
85    "termivar-recon-snapshot-import-comparison/v1";
86/// Additive, display-only Cert Spotter provider comparison section.
87pub(super) const RECON_CERTSPOTTER_COMPARISON_SCHEMA: &str =
88    "termivar-recon-certspotter-comparison/v1";
89/// Additive, display-only WordPress comparison section carried by comparison v1.
90pub(super) const WORDPRESS_COMPARISON_SCHEMA_V1: &str = "termivar-wordpress-review-comparison/v1";
91pub(super) const WORDPRESS_COMPARISON_SCHEMA_V2: &str = "termivar-wordpress-review-comparison/v2";
92pub(super) const WORDPRESS_COMPARISON_SCHEMA_V3: &str = "termivar-wordpress-review-comparison/v3";
93pub(super) const WORDPRESS_COMPARISON_SCHEMA_V4: &str = "termivar-wordpress-review-comparison/v4";
94const WORDPRESS_DISCOVERY_OBSERVATION_CAPABILITY: &str =
95    "technology.wordpress-metadata-source-response-observed@1";
96/// Each input is bounded by the existing renderer's byte ceiling.
97pub const MAX_COMPARISON_INPUT_BYTES: usize = super::MAX_RENDERED_REPORT_BYTES;
98
99/// Supported offline comparison encodings.
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
101#[non_exhaustive]
102pub enum ComparisonFormat {
103    /// Inert Markdown, with untrusted text encoded in code spans.
104    Markdown,
105    /// Structured JSON with all four comparison groups.
106    Json,
107    /// Self-contained HTML with an offline readable fallback.
108    Html,
109}
110
111/// Bounded errors that never include input documents, strings, or file paths.
112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113#[non_exhaustive]
114pub enum ComparisonError {
115    /// An input exceeded the existing renderer's byte bound.
116    InputLimitExceeded,
117    /// JSON syntax, duplicate keys, nesting, or structural bounds were invalid.
118    InvalidJson,
119    /// A supported document contained invalid or inconsistent fields.
120    InvalidDocument,
121    /// The document is not a supported completed rendered assessment.
122    UnsupportedDocument,
123    /// Repeated or conflicting stable identities cannot be paired safely.
124    AmbiguousIdentity,
125    /// The complete escaped comparison would exceed its output bound.
126    OutputLimitExceeded,
127    /// A display-only projection could not be encoded.
128    Serialization,
129}
130
131/// Bounded, display-only metadata imported from one supported assessment.
132///
133/// This summary proves only that the supplied bytes satisfy the current
134/// rendered-assessment wire contract. It does not authenticate the producer,
135/// establish target scope, or create runtime evidence or assessment authority.
136/// The fields remain private and this type intentionally implements neither
137/// `Serialize` nor `Deserialize`.
138#[derive(Debug, Clone, PartialEq, Eq)]
139#[non_exhaustive]
140pub struct ImportedAssessmentSummary {
141    schema: String,
142    profile: String,
143    status: String,
144    subject_count: u64,
145    item_count: u64,
146}
147
148impl ImportedAssessmentSummary {
149    /// Returns the exact supported rendered-assessment schema identifier.
150    pub fn schema(&self) -> &str {
151        &self.schema
152    }
153
154    /// Returns the validated profile declared by the imported document.
155    pub fn profile(&self) -> &str {
156        &self.profile
157    }
158
159    /// Returns the validated completion status declared by the document.
160    pub fn status(&self) -> &str {
161        &self.status
162    }
163
164    /// Returns the bounded declared subject count.
165    pub const fn subject_count(&self) -> u64 {
166        self.subject_count
167    }
168
169    /// Returns the bounded declared assessment-item count.
170    pub const fn item_count(&self) -> u64 {
171        self.item_count
172    }
173}
174
175impl fmt::Display for ComparisonError {
176    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
177        formatter.write_str(match self {
178            Self::InputLimitExceeded => "comparison input exceeds the byte limit",
179            Self::InvalidJson => "comparison input is not valid bounded JSON",
180            Self::InvalidDocument => "comparison input has invalid or inconsistent fields",
181            Self::UnsupportedDocument => {
182                "comparison requires supported complete assessment documents"
183            },
184            Self::AmbiguousIdentity => "comparison input contains ambiguous item identities",
185            Self::OutputLimitExceeded => "comparison output exceeds the byte limit",
186            Self::Serialization => "comparison output could not be serialized",
187        })
188    }
189}
190
191impl Error for ComparisonError {}
192
193impl From<ReportError> for ComparisonError {
194    fn from(error: ReportError) -> Self {
195        match error {
196            ReportError::OutputLimitExceeded { .. } => Self::OutputLimitExceeded,
197            ReportError::Serialization => Self::Serialization,
198        }
199    }
200}
201
202/// Compares two complete rendered assessment JSON byte strings, without I/O.
203///
204/// The caller must independently decide that comparing the scopes is appropriate.
205/// The result labels scope as operator-declared: neither parsing nor matching
206/// authenticates the source or establishes equivalent coverage. Imported claim
207/// labels remain unendorsed text. A missing item does not mean fixed or resolved.
208///
209/// Matching uses the existing exact fingerprint and compatible capability ID.
210/// Local reference renumbering and JSON ordering are not changes. All four
211/// groups are mutually exclusive and deterministic; failure returns no partial
212/// output. No authoritative runtime model is deserialized or constructed.
213pub fn compare_reports(
214    before: &[u8],
215    after: &[u8],
216    format: ComparisonFormat,
217) -> Result<String, ComparisonError> {
218    let document = compare_documents(import::parse(before)?, import::parse(after)?)?;
219    render(&document, format, super::MAX_RENDERED_REPORT_BYTES)
220}
221
222/// Imports one complete rendered assessment into a narrow display-only summary.
223///
224/// Parsing uses the same strict byte, nesting, duplicate-key, field, item, and
225/// optional-audit validation as [`compare_reports`]. No authoritative runtime
226/// model is deserialized or constructed, and no raw item or audit content is
227/// exposed to the caller.
228pub fn import_assessment_summary(
229    bytes: &[u8],
230) -> Result<ImportedAssessmentSummary, ComparisonError> {
231    let imported = import::parse(bytes)?;
232    Ok(ImportedAssessmentSummary {
233        schema: imported.metadata.schema,
234        profile: imported.metadata.profile,
235        status: imported.metadata.status,
236        subject_count: imported.metadata.subject_count,
237        item_count: imported.metadata.item_count,
238    })
239}
240
241#[derive(Debug, Serialize)]
242pub(super) struct ComparisonDocument {
243    pub(super) schema: &'static str,
244    pub(super) scope_assurance: &'static str,
245    pub(super) coverage_equivalence: &'static str,
246    pub(super) source_authenticity: &'static str,
247    pub(super) interpretation_limits: [&'static str; 4],
248    pub(super) before: SourceMetadata,
249    pub(super) after: SourceMetadata,
250    #[serde(skip_serializing_if = "Option::is_none")]
251    pub(super) supplied_session_comparison: Option<SuppliedSessionComparison>,
252    #[serde(skip_serializing_if = "Option::is_none")]
253    pub(super) secret_exposure_comparison: Option<SecretExposureComparison>,
254    #[serde(skip_serializing_if = "Option::is_none")]
255    pub(super) tls_observation_comparison: Option<TlsObservationComparison>,
256    #[serde(skip_serializing_if = "Option::is_none")]
257    pub(super) tls_negotiation_review_comparison: Option<TlsNegotiationReviewComparison>,
258    #[serde(skip_serializing_if = "Option::is_none")]
259    pub(super) websocket_review_comparison: Option<WebSocketReviewComparison>,
260    #[serde(skip_serializing_if = "Option::is_none")]
261    pub(super) jwt_policy_review_comparison: Option<JwtPolicyReviewComparison>,
262    #[serde(skip_serializing_if = "Option::is_none")]
263    pub(super) xml_external_entity_review_comparison: Option<XmlExternalEntityReviewComparison>,
264    #[serde(skip_serializing_if = "Option::is_none")]
265    pub(super) template_evaluation_review_comparison: Option<TemplateEvaluationReviewComparison>,
266    #[serde(skip_serializing_if = "Option::is_none")]
267    pub(super) control_reference_mapping_comparison: Option<ControlReferenceMappingComparison>,
268    #[serde(skip_serializing_if = "Option::is_none")]
269    pub(super) recon_snapshot_import_comparison: Option<ReconSnapshotImportComparison>,
270    #[serde(skip_serializing_if = "Option::is_none")]
271    pub(super) recon_certspotter_comparison: Option<ReconCertSpotterComparison>,
272    #[serde(skip_serializing_if = "Option::is_none")]
273    pub(super) wordpress_review_comparison: Option<WordPressReviewComparison>,
274    pub(super) only_in_after: Vec<ComparisonItem>,
275    pub(super) only_in_before: Vec<ComparisonItem>,
276    pub(super) changed: Vec<ComparisonItem>,
277    pub(super) unchanged: Vec<ComparisonItem>,
278}
279
280#[derive(Debug, Serialize)]
281pub(super) struct SuppliedSessionComparison {
282    pub(super) schema: &'static str,
283    pub(super) status: &'static str,
284    #[serde(skip_serializing_if = "Option::is_none")]
285    pub(super) reason: Option<&'static str>,
286    pub(super) scope_assurance: &'static str,
287    pub(super) context: WordPressFacetComparison,
288    pub(super) health_and_coverage: WordPressFacetComparison,
289    pub(super) accounting: WordPressFacetComparison,
290    pub(super) interpretation_limits: [&'static str; 5],
291}
292
293#[derive(Debug, Serialize)]
294pub(super) struct SecretExposureComparison {
295    pub(super) schema: &'static str,
296    pub(super) status: &'static str,
297    #[serde(skip_serializing_if = "Option::is_none")]
298    pub(super) reason: Option<&'static str>,
299    pub(super) methodology: WordPressFacetComparison,
300    pub(super) coverage: WordPressFacetComparison,
301    pub(super) interpretation_limits: [&'static str; 4],
302}
303
304#[derive(Debug, Serialize)]
305pub(super) struct TlsObservationComparison {
306    pub(super) schema: &'static str,
307    pub(super) status: &'static str,
308    #[serde(skip_serializing_if = "Option::is_none")]
309    pub(super) reason: Option<&'static str>,
310    pub(super) methodology: WordPressFacetComparison,
311    pub(super) coverage: WordPressFacetComparison,
312    pub(super) certificate_observations: WordPressFacetComparison,
313    pub(super) interpretation_limits: [&'static str; 5],
314}
315
316#[derive(Debug, Serialize)]
317pub(super) struct TlsNegotiationReviewComparison {
318    pub(super) schema: &'static str,
319    pub(super) status: &'static str,
320    #[serde(skip_serializing_if = "Option::is_none")]
321    pub(super) reason: Option<&'static str>,
322    pub(super) methodology: WordPressFacetComparison,
323    pub(super) coverage: WordPressFacetComparison,
324    pub(super) capability_observations: WordPressFacetComparison,
325    pub(super) interpretation_limits: [&'static str; 6],
326}
327
328#[derive(Debug, Serialize)]
329pub(super) struct WebSocketReviewComparison {
330    pub(super) schema: &'static str,
331    pub(super) status: &'static str,
332    #[serde(skip_serializing_if = "Option::is_none")]
333    pub(super) reason: Option<&'static str>,
334    #[serde(skip_serializing_if = "Option::is_none")]
335    pub(super) supplied_session_context: Option<WordPressFacetComparison>,
336    pub(super) methodology: WordPressFacetComparison,
337    pub(super) coverage: WordPressFacetComparison,
338    pub(super) outcome: WordPressFacetComparison,
339    pub(super) interpretation_limits: [&'static str; 6],
340}
341
342#[derive(Debug, Serialize)]
343pub(super) struct JwtPolicyReviewComparison {
344    pub(super) schema: &'static str,
345    pub(super) status: &'static str,
346    #[serde(skip_serializing_if = "Option::is_none")]
347    pub(super) reason: Option<&'static str>,
348    pub(super) methodology: WordPressFacetComparison,
349    pub(super) coverage: WordPressFacetComparison,
350    pub(super) outcome: WordPressFacetComparison,
351    pub(super) interpretation_limits: [&'static str; 6],
352    #[serde(skip)]
353    pub(super) target_selected: bool,
354}
355
356#[derive(Debug, Serialize)]
357pub(super) struct XmlExternalEntityReviewComparison {
358    pub(super) schema: &'static str,
359    pub(super) status: &'static str,
360    #[serde(skip_serializing_if = "Option::is_none")]
361    pub(super) reason: Option<&'static str>,
362    pub(super) methodology: WordPressFacetComparison,
363    pub(super) coverage: WordPressFacetComparison,
364    pub(super) outcome: WordPressFacetComparison,
365    pub(super) interpretation_limits: [&'static str; 6],
366}
367
368#[derive(Debug, Serialize)]
369pub(super) struct TemplateEvaluationReviewComparison {
370    pub(super) schema: &'static str,
371    pub(super) status: &'static str,
372    #[serde(skip_serializing_if = "Option::is_none")]
373    pub(super) reason: Option<&'static str>,
374    pub(super) methodology: WordPressFacetComparison,
375    pub(super) coverage: WordPressFacetComparison,
376    pub(super) outcome: WordPressFacetComparison,
377    pub(super) interpretation_limits: [&'static str; 6],
378}
379
380#[derive(Debug, Serialize)]
381pub(super) struct ControlReferenceMappingComparison {
382    pub(super) schema: &'static str,
383    pub(super) status: &'static str,
384    #[serde(skip_serializing_if = "Option::is_none")]
385    pub(super) reason: Option<&'static str>,
386    pub(super) methodology: WordPressFacetComparison,
387    pub(super) coverage: WordPressFacetComparison,
388    pub(super) reference_set: WordPressFacetComparison,
389    pub(super) interpretation_limits: [&'static str; 5],
390}
391
392#[derive(Debug, Serialize)]
393pub(super) struct ReconSnapshotImportComparison {
394    pub(super) schema: &'static str,
395    pub(super) status: &'static str,
396    #[serde(skip_serializing_if = "Option::is_none")]
397    pub(super) reason: Option<&'static str>,
398    pub(super) methodology: WordPressFacetComparison,
399    pub(super) provenance_and_sources: WordPressFacetComparison,
400    pub(super) coverage_and_accounting: WordPressFacetComparison,
401    pub(super) hypotheses: WordPressFacetComparison,
402    pub(super) interpretation_limits: [&'static str; 6],
403}
404
405#[derive(Debug, Serialize)]
406pub(super) struct ReconCertSpotterComparison {
407    pub(super) schema: &'static str,
408    pub(super) status: &'static str,
409    #[serde(skip_serializing_if = "Option::is_none")]
410    pub(super) reason: Option<&'static str>,
411    pub(super) provider: WordPressFacetComparison,
412    pub(super) methodology: WordPressFacetComparison,
413    pub(super) coverage: WordPressFacetComparison,
414    pub(super) hypotheses: WordPressFacetComparison,
415    pub(super) interpretation_limits: [&'static str; 6],
416}
417
418#[derive(Debug, Serialize)]
419pub(super) struct WordPressReviewComparison {
420    pub(super) schema: &'static str,
421    pub(super) status: &'static str,
422    #[serde(skip_serializing_if = "Option::is_none")]
423    pub(super) reason: Option<&'static str>,
424    pub(super) scope_assurance: &'static str,
425    pub(super) coverage: WordPressFacetComparison,
426    pub(super) methodology: WordPressFacetComparison,
427    pub(super) provenance: WordPressFacetComparison,
428    #[serde(skip_serializing_if = "Option::is_none")]
429    pub(super) discovery_source_content: Option<WordPressFacetComparison>,
430    #[serde(skip_serializing_if = "Option::is_none")]
431    pub(super) asset_fingerprints: Option<WordPressAssetFingerprintComparison>,
432    pub(super) components: WordPressEntityChanges<WordPressComponentKey>,
433    pub(super) advisories: WordPressEntityChanges<WordPressAdvisoryKey>,
434    pub(super) interpretation_limits: [&'static str; 6],
435}
436
437#[derive(Debug, Serialize)]
438pub(super) struct WordPressAssetFingerprintComparison {
439    pub(super) status: &'static str,
440    #[serde(skip_serializing_if = "Option::is_none")]
441    pub(super) reason: Option<&'static str>,
442    pub(super) methodology: WordPressFacetComparison,
443    pub(super) catalogue: WordPressFacetComparison,
444    pub(super) coverage: WordPressFacetComparison,
445    pub(super) resources: WordPressEntityChanges<WordPressAssetFingerprintResourceKey>,
446    pub(super) components: WordPressEntityChanges<WordPressAssetFingerprintComponentKey>,
447    pub(super) interpretation_limits: [&'static str; 5],
448}
449
450#[derive(Debug, Serialize)]
451pub(super) struct WordPressFacetComparison {
452    pub(super) status: String,
453    pub(super) changed_fields: Vec<String>,
454    pub(super) before: Option<Value>,
455    pub(super) after: Option<Value>,
456    pub(super) note: &'static str,
457}
458
459#[derive(Debug, Serialize)]
460pub(super) struct WordPressEntityChanges<K> {
461    pub(super) paired_unchanged_count: usize,
462    pub(super) paired_changed: Vec<WordPressEntityChange<K>>,
463    pub(super) only_in_before: Vec<WordPressOneSidedEntity<K>>,
464    pub(super) only_in_after: Vec<WordPressOneSidedEntity<K>>,
465}
466
467impl<K> Default for WordPressEntityChanges<K> {
468    fn default() -> Self {
469        Self {
470            paired_unchanged_count: 0,
471            paired_changed: Vec::new(),
472            only_in_before: Vec::new(),
473            only_in_after: Vec::new(),
474        }
475    }
476}
477
478#[derive(Debug, Serialize)]
479pub(super) struct WordPressEntityChange<K> {
480    pub(super) key: K,
481    pub(super) changed_dimensions: Vec<String>,
482    pub(super) before: BTreeMap<String, Value>,
483    pub(super) after: BTreeMap<String, Value>,
484}
485
486#[derive(Debug, Serialize)]
487pub(super) struct WordPressOneSidedEntity<K> {
488    pub(super) key: K,
489    pub(super) content: BTreeMap<String, Value>,
490    pub(super) interpretation: &'static str,
491}
492
493#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
494pub(super) struct WordPressComponentKey {
495    pub(super) kind: String,
496    pub(super) slug: String,
497}
498
499#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
500pub(super) struct WordPressAdvisoryKey {
501    pub(super) source_kind: String,
502    pub(super) source_namespace: String,
503    pub(super) upstream_id: String,
504    pub(super) component: WordPressComponentKey,
505}
506
507#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
508pub(super) struct WordPressAssetFingerprintResourceKey {
509    pub(super) source_namespace: String,
510    pub(super) component: WordPressComponentKey,
511    pub(super) relative_path: String,
512}
513
514#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
515pub(super) struct WordPressAssetFingerprintComponentKey {
516    pub(super) source_namespace: String,
517    pub(super) component: WordPressComponentKey,
518}
519
520#[derive(Debug, Clone, PartialEq, Eq)]
521pub(super) struct ImportedWordPressAssetFingerprintAudit {
522    pub(super) methodology: Value,
523    pub(super) catalogue: Value,
524    pub(super) coverage: Value,
525    pub(super) resources: BTreeMap<WordPressAssetFingerprintResourceKey, BTreeMap<String, Value>>,
526    pub(super) components: BTreeMap<WordPressAssetFingerprintComponentKey, BTreeMap<String, Value>>,
527}
528
529#[derive(Debug, Clone, PartialEq, Eq)]
530pub(super) struct ImportedSuppliedSessionAudit {
531    pub(super) context: Value,
532    pub(super) health_and_coverage: Value,
533    pub(super) accounting: Value,
534    /// Exact value-safe resource/evidence bindings retained only so another
535    /// strict saved-audit reader can prove it refers to the same committed
536    /// supplied-session resources. These values never become runtime authority.
537    pub(super) resources: BTreeMap<String, SuppliedSessionResourceBinding>,
538}
539
540#[derive(Debug, Clone, PartialEq, Eq)]
541pub(super) struct ImportedSecretExposureAudit {
542    pub(super) methodology: Value,
543    pub(super) coverage: Value,
544}
545
546#[derive(Debug, Clone, PartialEq, Eq)]
547pub(super) struct ImportedTlsObservationAudit {
548    pub(super) methodology: Value,
549    pub(super) coverage: Value,
550    pub(super) certificate_observations: Value,
551}
552
553#[derive(Debug, Clone, PartialEq, Eq)]
554pub(super) struct ImportedTlsNegotiationReviewAudit {
555    pub(super) methodology: Value,
556    pub(super) coverage: Value,
557    pub(super) capability_observations: Value,
558}
559
560#[derive(Debug, Clone, PartialEq, Eq)]
561pub(super) struct ImportedWebSocketReviewAudit {
562    pub(super) schema: String,
563    pub(super) supplied_session_identity: Option<Value>,
564    pub(super) supplied_session_context: Option<Value>,
565    pub(super) methodology: Value,
566    pub(super) coverage: Value,
567    pub(super) outcome: Value,
568}
569
570#[derive(Debug, Clone, PartialEq, Eq)]
571pub(super) struct ImportedJwtPolicyReviewAudit {
572    pub(super) schema: String,
573    pub(super) methodology: Value,
574    pub(super) coverage: Value,
575    pub(super) outcome: Value,
576}
577
578#[derive(Debug, Clone, PartialEq, Eq)]
579pub(super) struct ImportedXmlExternalEntityReviewAudit {
580    pub(super) methodology: Value,
581    pub(super) coverage: Value,
582    pub(super) outcome: Value,
583}
584
585#[derive(Debug, Clone, PartialEq, Eq)]
586pub(super) struct ImportedTemplateEvaluationReviewAudit {
587    pub(super) methodology: Value,
588    pub(super) coverage: Value,
589    pub(super) outcome: Value,
590}
591
592#[derive(Debug, Clone, PartialEq, Eq)]
593pub(super) struct ImportedControlReferenceMappingAudit {
594    pub(super) methodology: Value,
595    pub(super) coverage: Value,
596    pub(super) reference_set: Value,
597}
598
599#[derive(Debug, Clone, PartialEq, Eq)]
600pub(super) struct ImportedReconSnapshotAudit {
601    pub(super) methodology: Value,
602    pub(super) provenance_and_sources: Value,
603    pub(super) coverage_and_accounting: Value,
604    pub(super) hypotheses: Value,
605}
606
607#[derive(Debug, Clone, PartialEq, Eq)]
608pub(super) struct ImportedReconCertSpotterAudit {
609    pub(super) provider: Value,
610    pub(super) methodology: Value,
611    pub(super) coverage: Value,
612    pub(super) hypotheses: Value,
613}
614
615#[derive(Debug, Clone, PartialEq, Eq)]
616pub(super) struct SuppliedSessionResourceBinding {
617    pub(super) evidence_reference: Option<String>,
618    pub(super) response_bytes: u64,
619    pub(super) epoch: u64,
620}
621
622#[derive(Debug, Clone, PartialEq, Eq)]
623pub(super) struct ImportedWordPressAudit {
624    /// Opaque deployment-aware application identity; absent from legacy audits.
625    pub(super) application_reference: Option<String>,
626    pub(super) coverage: Value,
627    pub(super) inventory_coverage_recorded: bool,
628    pub(super) methodology: Value,
629    pub(super) provenance: Value,
630    pub(super) discovery_source_content: Option<Value>,
631    /// Present only for discovery audit v4 after its references have been
632    /// cross-linked to the root supplied-session audit.
633    pub(super) supplied_session_context: Option<Value>,
634    pub(super) asset_fingerprints: Option<ImportedWordPressAssetFingerprintAudit>,
635    pub(super) components: BTreeMap<WordPressComponentKey, BTreeMap<String, Value>>,
636    pub(super) advisories: BTreeMap<WordPressAdvisoryKey, BTreeMap<String, Value>>,
637}
638
639#[derive(Debug, Serialize)]
640pub(super) struct SourceMetadata {
641    pub(super) sha256: String,
642    pub(super) schema: String,
643    pub(super) source_schema: String,
644    pub(super) run_schema: String,
645    pub(super) profile_schema: String,
646    pub(super) profile: String,
647    pub(super) status: String,
648    pub(super) subject_count: u64,
649    pub(super) item_count: u64,
650    pub(super) optional_audits: BTreeMap<String, Value>,
651}
652
653#[derive(Debug, Serialize)]
654pub(super) struct ComparisonItem {
655    pub(super) fingerprint: String,
656    pub(super) capability_id: String,
657    pub(super) before: Option<ItemProjection>,
658    pub(super) after: Option<ItemProjection>,
659    pub(super) changed_fields: Vec<&'static str>,
660}
661
662#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
663pub(super) struct ItemProjection {
664    pub(super) title: String,
665    pub(super) category: String,
666    pub(super) disposition: String,
667    pub(super) claim_basis: String,
668    pub(super) severity: Option<String>,
669    pub(super) cwe: Option<String>,
670    pub(super) confidence_ppm: u32,
671    pub(super) redacted_summary: String,
672    pub(super) remediation: RemediationProjection,
673    pub(super) evidence: EvidenceMetadata,
674}
675
676#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
677pub(super) struct RemediationProjection {
678    pub(super) id: String,
679    pub(super) summary: String,
680}
681
682#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
683pub(super) struct EvidenceMetadata {
684    pub(super) evidence_count: u64,
685    pub(super) evidence_reference_count: usize,
686    pub(super) control_reference_count: usize,
687    pub(super) candidate_reference_count: usize,
688    pub(super) case_present: bool,
689    pub(super) outcome_present: bool,
690    pub(super) verification_stage: Option<String>,
691}
692
693struct ImportedDocument {
694    metadata: SourceMetadata,
695    items: BTreeMap<String, ImportedItem>,
696    supplied_session: Option<ImportedSuppliedSessionAudit>,
697    secret_exposure: Option<ImportedSecretExposureAudit>,
698    tls_observation: Option<ImportedTlsObservationAudit>,
699    tls_negotiation_review: Option<ImportedTlsNegotiationReviewAudit>,
700    websocket_review: Option<ImportedWebSocketReviewAudit>,
701    jwt_policy_review: Option<ImportedJwtPolicyReviewAudit>,
702    xml_external_entity_review: Option<ImportedXmlExternalEntityReviewAudit>,
703    template_evaluation_review: Option<ImportedTemplateEvaluationReviewAudit>,
704    control_reference_mapping: Option<ImportedControlReferenceMappingAudit>,
705    recon_snapshot_import: Option<ImportedReconSnapshotAudit>,
706    recon_certspotter: Option<ImportedReconCertSpotterAudit>,
707    wordpress_review: Option<ImportedWordPressAudit>,
708}
709
710struct ImportedItem {
711    capability_id: String,
712    projection: ItemProjection,
713    observation_evidence_references: BTreeSet<String>,
714}
715
716fn compare_documents(
717    before: ImportedDocument,
718    mut after: ImportedDocument,
719) -> Result<ComparisonDocument, ComparisonError> {
720    let block_item_pairing = !supplied_session_context_allows_item_pairing(
721        before.supplied_session.as_ref(),
722        after.supplied_session.as_ref(),
723    );
724    let supplied_session_comparison = compare_supplied_sessions(
725        before.supplied_session.as_ref(),
726        after.supplied_session.as_ref(),
727    );
728    let secret_exposure_comparison = compare_secret_exposure(
729        before.secret_exposure.as_ref(),
730        after.secret_exposure.as_ref(),
731    );
732    let tls_observation_comparison = compare_tls_observation(
733        before.tls_observation.as_ref(),
734        after.tls_observation.as_ref(),
735    );
736    let tls_negotiation_review_comparison = compare_tls_negotiation_review(
737        before.tls_negotiation_review.as_ref(),
738        after.tls_negotiation_review.as_ref(),
739    );
740    let websocket_review_comparison = compare_websocket_review(
741        before.websocket_review.as_ref(),
742        after.websocket_review.as_ref(),
743    );
744    let jwt_policy_review_comparison = compare_jwt_policy_review(
745        before.jwt_policy_review.as_ref(),
746        after.jwt_policy_review.as_ref(),
747    );
748    let xml_external_entity_review_comparison = compare_xml_external_entity_review(
749        before.xml_external_entity_review.as_ref(),
750        after.xml_external_entity_review.as_ref(),
751    );
752    let template_evaluation_review_comparison = compare_template_evaluation_review(
753        before.template_evaluation_review.as_ref(),
754        after.template_evaluation_review.as_ref(),
755    );
756    let control_reference_mapping_comparison = compare_control_reference_mapping(
757        before.control_reference_mapping.as_ref(),
758        after.control_reference_mapping.as_ref(),
759    );
760    let recon_snapshot_import_comparison = compare_recon_snapshot_import(
761        before.recon_snapshot_import.as_ref(),
762        after.recon_snapshot_import.as_ref(),
763    );
764    let recon_certspotter_comparison = compare_recon_certspotter(
765        before.recon_certspotter.as_ref(),
766        after.recon_certspotter.as_ref(),
767    );
768    let wordpress_review_comparison = compare_wordpress_reviews(
769        before.wordpress_review.as_ref(),
770        after.wordpress_review.as_ref(),
771    );
772    let mut document = ComparisonDocument {
773        schema: COMPARISON_DOCUMENT_SCHEMA,
774        scope_assurance: "operator-declared",
775        coverage_equivalence: "not-established",
776        source_authenticity: "not-established-by-parsing",
777        interpretation_limits: [
778            "Imported dispositions and claim bases are untrusted source labels, not endorsed findings.",
779            "Only in before does not mean fixed or resolved.",
780            "Only in after does not establish when an observation first appeared.",
781            "Unchanged means equal compared item projections, not equivalent coverage or security.",
782        ],
783        before: before.metadata,
784        after: after.metadata,
785        supplied_session_comparison,
786        secret_exposure_comparison,
787        tls_observation_comparison,
788        tls_negotiation_review_comparison,
789        websocket_review_comparison,
790        jwt_policy_review_comparison,
791        xml_external_entity_review_comparison,
792        template_evaluation_review_comparison,
793        control_reference_mapping_comparison,
794        recon_snapshot_import_comparison,
795        recon_certspotter_comparison,
796        wordpress_review_comparison,
797        only_in_after: Vec::new(),
798        only_in_before: Vec::new(),
799        changed: Vec::new(),
800        unchanged: Vec::new(),
801    };
802    for (fingerprint, old) in before.items {
803        if !block_item_pairing {
804            if let Some(new) = after.items.remove(&fingerprint) {
805                if old.capability_id != new.capability_id {
806                    return Err(ComparisonError::AmbiguousIdentity);
807                }
808                let changed_fields = changed_fields(&old.projection, &new.projection);
809                let item = ComparisonItem {
810                    fingerprint,
811                    capability_id: old.capability_id,
812                    before: Some(old.projection),
813                    after: Some(new.projection),
814                    changed_fields,
815                };
816                if item.changed_fields.is_empty() {
817                    document.unchanged.push(item);
818                } else {
819                    document.changed.push(item);
820                }
821                continue;
822            }
823        }
824        document.only_in_before.push(ComparisonItem {
825            fingerprint,
826            capability_id: old.capability_id,
827            before: Some(old.projection),
828            after: None,
829            changed_fields: Vec::new(),
830        });
831    }
832    for (fingerprint, new) in after.items {
833        document.only_in_after.push(ComparisonItem {
834            fingerprint,
835            capability_id: new.capability_id,
836            before: None,
837            after: Some(new.projection),
838            changed_fields: Vec::new(),
839        });
840    }
841    Ok(document)
842}
843
844fn supplied_session_context_allows_item_pairing(
845    before: Option<&ImportedSuppliedSessionAudit>,
846    after: Option<&ImportedSuppliedSessionAudit>,
847) -> bool {
848    match (before, after) {
849        (None, None) => true,
850        (Some(before), Some(after)) => before.context == after.context,
851        (Some(_), None) | (None, Some(_)) => false,
852    }
853}
854
855fn compare_supplied_sessions(
856    before: Option<&ImportedSuppliedSessionAudit>,
857    after: Option<&ImportedSuppliedSessionAudit>,
858) -> Option<SuppliedSessionComparison> {
859    if before.is_none() && after.is_none() {
860        return None;
861    }
862    let contains_oauth_methodology = [before, after].into_iter().flatten().any(|audit| {
863        audit
864            .context
865            .get("schema")
866            .and_then(Value::as_str)
867            .is_some_and(|schema| {
868                matches!(
869                    schema,
870                    "security.supplied-session-audit/v6" | "security.supplied-session-audit/v7"
871                )
872            })
873    });
874    let contains_oauth_refresh = [before, after].into_iter().flatten().any(|audit| {
875        audit.context.get("schema").and_then(Value::as_str)
876            == Some("security.supplied-session-audit/v6")
877    });
878    let contains_oauth_authorization = [before, after].into_iter().flatten().any(|audit| {
879        audit.context.get("schema").and_then(Value::as_str)
880            == Some("security.supplied-session-audit/v7")
881    });
882    let (status, reason, context_status, facet_status) = match (before, after) {
883        (Some(before), Some(after)) if before.context == after.context => (
884            "compared_within_same_declared_context",
885            None,
886            "same_declared_context",
887            None,
888        ),
889        (Some(before), Some(after)) => {
890            let application_changed = before.context.get("application_reference")
891                != after.context.get("application_reference");
892            let policy_changed =
893                before.context.get("policy_reference") != after.context.get("policy_reference");
894            let principal_alias_changed =
895                before.context.get("principal_alias") != after.context.get("principal_alias");
896            let epoch_changed =
897                before.context.get("session_epoch") != after.context.get("session_epoch");
898            let methodology_changed = [
899                "schema",
900                "oauth_code_policy",
901                "oauth_refresh_policy",
902                "oauth_authorization_policy",
903            ]
904            .into_iter()
905            .any(|field| before.context.get(field) != after.context.get(field));
906            let reason = if application_changed {
907                "application_scope_changed"
908            } else if policy_changed {
909                "session_policy_changed"
910            } else if principal_alias_changed {
911                "operator_declared_principal_changed"
912            } else if contains_oauth_methodology && methodology_changed {
913                "session_methodology_changed"
914            } else if contains_oauth_methodology && epoch_changed {
915                "session_epoch_changed"
916            } else {
917                "declared_session_context_changed"
918            };
919            ("not_compared", Some(reason), reason, Some("not_comparable"))
920        },
921        (Some(_), None) => (
922            "not_compared",
923            Some("after_audit_missing"),
924            "audit_presence_changed",
925            Some("not_comparable"),
926        ),
927        (None, Some(_)) => (
928            "not_compared",
929            Some("before_audit_missing"),
930            "audit_presence_changed",
931            Some("not_comparable"),
932        ),
933        (None, None) => return None,
934    };
935    let health_status = facet_status.unwrap_or_else(|| {
936        paired_status(
937            before.map(|audit| &audit.health_and_coverage),
938            after.map(|audit| &audit.health_and_coverage),
939        )
940    });
941    let accounting_status = facet_status.unwrap_or_else(|| {
942        paired_status(
943            before.map(|audit| &audit.accounting),
944            after.map(|audit| &audit.accounting),
945        )
946    });
947    Some(SuppliedSessionComparison {
948        schema: SUPPLIED_SESSION_COMPARISON_SCHEMA,
949        status,
950        reason,
951        scope_assurance: "operator_declared",
952        context: facet(
953            before.map(|audit| &audit.context),
954            after.map(|audit| &audit.context),
955            context_status,
956            "Equal opaque references represent the same supplied declarations within these files; they do not authenticate a principal, application, policy source, or cross-run identity.",
957        ),
958        health_and_coverage: facet(
959            before.map(|audit| &audit.health_and_coverage),
960            after.map(|audit| &audit.health_and_coverage),
961            health_status,
962            "Checkpoint and resource coverage differences can result from session loss, interruption, or policy context; they are not installation change, vulnerability change, or remediation evidence.",
963        ),
964        accounting: facet(
965            before.map(|audit| &audit.accounting),
966            after.map(|audit| &audit.accounting),
967            accounting_status,
968            "Counts and bytes describe bounded committed activity in each supplied audit; equality does not establish equivalent authenticated coverage.",
969        ),
970        interpretation_limits: [
971            "The principal reference is a generic V1 slot with operator-declared assurance, not authenticated or cross-run principal identity.",
972            "Policy and application digests identify declared values; they do not authenticate the target, source, account, role, or tenant.",
973            "Different contexts are not paired as target changes, and missing or reduced coverage is not remediation.",
974            "Health checkpoints establish only their recorded predicate at bounded moments, not continuous authentication.",
975            if contains_oauth_authorization {
976                "Selected client-generated authorization is qualified only where callback facts were recorded; it does not authenticate a browser or sender, verify OIDC nonce/identity/signatures, or establish remote PKCE enforcement. A reception reservation is not an outgoing token request."
977            } else if contains_oauth_refresh {
978                "Only the recorded explicitly scheduled renewal was assessed; replacement value inequality does not establish server revocation, replay detection, or identity. No exploit, impact validation, or anonymous fallback was performed."
979            } else {
980                "Exploit execution, impact validation, automatic refresh, and anonymous fallback were not performed by this audit."
981            },
982        ],
983    })
984}
985
986fn compare_secret_exposure(
987    before: Option<&ImportedSecretExposureAudit>,
988    after: Option<&ImportedSecretExposureAudit>,
989) -> Option<SecretExposureComparison> {
990    if before.is_none() && after.is_none() {
991        return None;
992    }
993    let (status, reason) = match (before, after) {
994        (Some(_), Some(_)) => ("compared", None),
995        (Some(_), None) => ("not_comparable", Some("after_audit_missing")),
996        (None, Some(_)) => ("not_comparable", Some("before_audit_missing")),
997        (None, None) => return None,
998    };
999    Some(SecretExposureComparison {
1000        schema: SECRET_EXPOSURE_COMPARISON_SCHEMA,
1001        status,
1002        reason,
1003        methodology: facet(
1004            before.map(|audit| &audit.methodology),
1005            after.map(|audit| &audit.methodology),
1006            paired_status(
1007                before.map(|audit| &audit.methodology),
1008                after.map(|audit| &audit.methodology),
1009            ),
1010            "Catalogue, policy, representation, context, or inspection-limit changes are methodology changes; they do not establish a target change.",
1011        ),
1012        coverage: facet(
1013            before.map(|audit| &audit.coverage),
1014            after.map(|audit| &audit.coverage),
1015            paired_status(
1016                before.map(|audit| &audit.coverage),
1017                after.map(|audit| &audit.coverage),
1018            ),
1019            "Missing or reduced response, byte, outcome, or observation coverage is not remediation, secret absence, or proof of safety.",
1020        ),
1021        interpretation_limits: [
1022            "The fixed detector catalogue reports bounded secret-shaped response observations; it does not validate ownership, permissions, or provider acceptance.",
1023            "No raw matched value or public secret hash is imported into this display-only comparison.",
1024            "A one-sided observation does not establish when exposure began or ended, and disappearance is not remediation.",
1025            "Source authentication, secret validity, exploit execution, and impact validation are not established by this audit.",
1026        ],
1027    })
1028}
1029
1030fn compare_tls_observation(
1031    before: Option<&ImportedTlsObservationAudit>,
1032    after: Option<&ImportedTlsObservationAudit>,
1033) -> Option<TlsObservationComparison> {
1034    if before.is_none() && after.is_none() {
1035        return None;
1036    }
1037    let (status, reason) = match (before, after) {
1038        (Some(_), Some(_)) => ("compared", None),
1039        (Some(_), None) => ("not_comparable", Some("after_audit_missing")),
1040        (None, Some(_)) => ("not_comparable", Some("before_audit_missing")),
1041        (None, None) => return None,
1042    };
1043    Some(TlsObservationComparison {
1044        schema: TLS_OBSERVATION_COMPARISON_SCHEMA,
1045        status,
1046        reason,
1047        methodology: facet(
1048            before.map(|audit| &audit.methodology),
1049            after.map(|audit| &audit.methodology),
1050            paired_status(
1051                before.map(|audit| &audit.methodology),
1052                after.map(|audit| &audit.methodology),
1053            ),
1054            "Policy, target scheme, backend visibility, validation scope, or active-matrix selection changes are methodology changes; they are not vulnerability, hardening, or remediation evidence.",
1055        ),
1056        coverage: facet(
1057            before.map(|audit| &audit.coverage),
1058            after.map(|audit| &audit.coverage),
1059            paired_status(
1060                before.map(|audit| &audit.coverage),
1061                after.map(|audit| &audit.coverage),
1062            ),
1063            "Assessment-request, response, retention, first-observation-time, validity-at-observation, and occurrence-count changes describe observation coverage; fewer observations do not establish a certificate removal or remediation.",
1064        ),
1065        certificate_observations: facet(
1066            before.map(|audit| &audit.certificate_observations),
1067            after.map(|audit| &audit.certificate_observations),
1068            paired_status(
1069                before.map(|audit| &audit.certificate_observations),
1070                after.map(|audit| &audit.certificate_observations),
1071            ),
1072            "Leaf digest, byte length, declared validity bounds, and SAN-count changes are stable certificate-observation differences only; they do not authenticate a source or enumerate TLS support.",
1073        ),
1074        interpretation_limits: [
1075            "The audit describes leaf bytes exposed by successful existing response connections; it does not enumerate all server protocols, cipher suites, certificates, chains, or handshakes.",
1076            "The current backend does not expose negotiated protocol, negotiated cipher suite, ALPN, full chain, connection reuse, session resumption, or resumed-versus-full handshake state.",
1077            "Standard transport validation is scoped to a successful response connection and is not source authentication, a fresh signature check by this report, or worldwide trust.",
1078            "Revocation, AIA, CRL, OCSP, certificate-transparency, and active negotiation retrieval were not performed.",
1079            "A one-sided or changed certificate observation does not establish vulnerability, exploitation, impact, rotation quality, or remediation.",
1080        ],
1081    })
1082}
1083
1084fn compare_tls_negotiation_review(
1085    before: Option<&ImportedTlsNegotiationReviewAudit>,
1086    after: Option<&ImportedTlsNegotiationReviewAudit>,
1087) -> Option<TlsNegotiationReviewComparison> {
1088    if before.is_none() && after.is_none() {
1089        return None;
1090    }
1091    let (status, reason) = match (before, after) {
1092        (Some(_), Some(_)) => ("compared", None),
1093        (Some(_), None) => ("not_comparable", Some("after_audit_missing")),
1094        (None, Some(_)) => ("not_comparable", Some("before_audit_missing")),
1095        (None, None) => return None,
1096    };
1097    Some(TlsNegotiationReviewComparison {
1098        schema: TLS_NEGOTIATION_REVIEW_COMPARISON_SCHEMA,
1099        status,
1100        reason,
1101        methodology: facet(
1102            before.map(|audit| &audit.methodology),
1103            after.map(|audit| &audit.methodology),
1104            paired_status(
1105                before.map(|audit| &audit.methodology),
1106                after.map(|audit| &audit.methodology),
1107            ),
1108            "Policy, fixed client offer profiles, backend availability, target binding, validation trust, or connection-method changes are methodology changes; they are not target vulnerability, hardening, or remediation evidence.",
1109        ),
1110        coverage: facet(
1111            before.map(|audit| &audit.coverage),
1112            after.map(|audit| &audit.coverage),
1113            paired_status(
1114                before.map(|audit| &audit.coverage),
1115                after.map(|audit| &audit.coverage),
1116            ),
1117            "Dispatch, terminal outcome, transport-validation result, and accounted-byte changes describe bounded matrix coverage; a failed or missing handshake does not establish that the server rejected an offered profile.",
1118        ),
1119        capability_observations: facet(
1120            before.map(|audit| &audit.capability_observations),
1121            after.map(|audit| &audit.capability_observations),
1122            paired_status(
1123                before.map(|audit| &audit.capability_observations),
1124                after.map(|audit| &audit.capability_observations),
1125            ),
1126            "Negotiated protocol and cipher-suite changes are observations from the exact successful matrix cells only; they do not enumerate every server-supported protocol or cipher suite.",
1127        ),
1128        interpretation_limits: [
1129            "The matrix contains two backend-supported offer profiles and three explicit client-backend-unsupported rows; it is not a complete protocol or cipher-suite audit.",
1130            "A negotiated cell establishes compatibility only for that offered client profile at that connection attempt; it does not authenticate the source or every supported configuration.",
1131            "A failed, cancelled, budget-limited, or unavailable cell is not evidence that the server does not support that protocol or cipher family.",
1132            "No application request, credential, client certificate, early data, retry, redirect, proxy, or alternate-address fallback was used by this audit.",
1133            "Certificate validation is scoped to the configured trust material and exact selected target; revocation, CT, AIA, CRL, and OCSP retrieval were not performed.",
1134            "A one-sided or changed observation does not establish vulnerability, exploitability, impact, remediation, or production readiness.",
1135        ],
1136    })
1137}
1138
1139fn compare_websocket_review(
1140    before: Option<&ImportedWebSocketReviewAudit>,
1141    after: Option<&ImportedWebSocketReviewAudit>,
1142) -> Option<WebSocketReviewComparison> {
1143    if before.is_none() && after.is_none() {
1144        return None;
1145    }
1146    let supplied_session_semantics = before
1147        .into_iter()
1148        .chain(after)
1149        .any(|audit| audit.schema == "security.websocket-review-audit/v2");
1150    let (status, reason) = match (before, after) {
1151        (Some(before), Some(after)) if before.schema != after.schema => {
1152            ("not_comparable", Some("audit_schema_changed"))
1153        },
1154        (Some(before), Some(after))
1155            if before.schema == "security.websocket-review-audit/v2"
1156                && before.supplied_session_identity != after.supplied_session_identity =>
1157        {
1158            ("not_comparable", Some("supplied_session_context_changed"))
1159        },
1160        (Some(_), Some(_)) => ("compared", None),
1161        (Some(_), None) => ("not_comparable", Some("after_audit_missing")),
1162        (None, Some(_)) => ("not_comparable", Some("before_audit_missing")),
1163        (None, None) => return None,
1164    };
1165    let facet_status = |before: Option<&Value>, after: Option<&Value>| {
1166        if status == "compared" {
1167            paired_status(before, after)
1168        } else {
1169            "not_comparable"
1170        }
1171    };
1172    Some(WebSocketReviewComparison {
1173        schema: if supplied_session_semantics {
1174            WEBSOCKET_SUPPLIED_SESSION_COMPARISON_SCHEMA
1175        } else {
1176            WEBSOCKET_REVIEW_COMPARISON_SCHEMA
1177        },
1178        status,
1179        reason,
1180        supplied_session_context: supplied_session_semantics.then(|| {
1181            facet(
1182                before.and_then(|audit| audit.supplied_session_context.as_ref()),
1183                after.and_then(|audit| audit.supplied_session_context.as_ref()),
1184                facet_status(
1185                    before.and_then(|audit| audit.supplied_session_context.as_ref()),
1186                    after.and_then(|audit| audit.supplied_session_context.as_ref()),
1187                ),
1188                "The declared principal/application/session epoch and the startup/terminal health qualification describe observation context only. A changed or missing health reference does not establish logout, authorization loss, vulnerability, or remediation; continuous authentication remains not established.",
1189            )
1190        }),
1191        methodology: facet(
1192            before.map(|audit| &audit.methodology),
1193            after.map(|audit| &audit.methodology),
1194            facet_status(
1195                before.map(|audit| &audit.methodology),
1196                after.map(|audit| &audit.methodology),
1197            ),
1198            "Policy, opaque endpoint reference, origin mode, subprotocol-selection state, observable-limit declarations, or claim-limit changes are methodology differences. Physical continuation-frame counts remain unavailable and no target security change is established.",
1199        ),
1200        coverage: facet(
1201            before.map(|audit| &audit.coverage),
1202            after.map(|audit| &audit.coverage),
1203            facet_status(
1204                before.map(|audit| &audit.coverage),
1205                after.map(|audit| &audit.coverage),
1206            ),
1207            "Request, connection, message, observable control-event, application-byte, raw-socket transport-byte, and parent-accounted byte counts describe only each bounded exchange. Raw transport reads include Upgrade/framing overhead and WSS encrypted TLS records; reduced or missing coverage is not remediation or rejection evidence.",
1208        ),
1209        outcome: facet(
1210            before.map(|audit| &audit.outcome),
1211            after.map(|audit| &audit.outcome),
1212            facet_status(
1213                before.map(|audit| &audit.outcome),
1214                after.map(|audit| &audit.outcome),
1215            ),
1216            if supplied_session_semantics {
1217                "Terminal state and per-message length/status relationships describe protocol completion independently of supplied-session health qualification. A mismatched response exposes no body-derived reference. These values do not establish authenticated identity, authorization, server state, exploitability, vulnerability, or remediation."
1218            } else {
1219                "Terminal state and per-message length/status relationships describe the configured anonymous exchange only. A mismatched response exposes no body-derived reference. These values do not establish authorization, server state, exploitability, vulnerability, or remediation."
1220            },
1221        ),
1222        interpretation_limits: if supplied_session_semantics {
1223            [
1224                "The comparison retains no payload, URL, path, query, subprotocol value, token, raw error, header, cookie, or credential.",
1225                "Only the configured supplied-session RFC 6455 HTTP/1.1 upgrade exchange was observed; HTTP/2 extended CONNECT was not exercised.",
1226                "Opaque policy, endpoint, principal, checkpoint, and message references are not authenticated identities. Expected-response references commit only to operator-supplied expectations; no mismatch-body digest is retained.",
1227                "Protocol completion and supplied-session health qualification are separate. Even healthy startup and terminal checkpoints do not establish continuous authentication or authorization.",
1228                "Transport bytes use raw socket scope, include HTTP Upgrade and WebSocket framing, and count encrypted TLS records for WSS; they are not application-byte measurements.",
1229                "A schema, declared context, or one-sided audit mismatch is not comparable and establishes no vulnerability, impact, rejection, or remediation.",
1230            ]
1231        } else {
1232            [
1233                "The comparison retains no payload, URL, path, query, subprotocol value, token, raw error, header, cookie, or credential.",
1234                "Only the configured anonymous RFC 6455 HTTP/1.1 upgrade exchange was observed; HTTP/2 extended CONNECT was not exercised.",
1235                "Opaque policy, endpoint, and message references are not authenticated target identities. Expected-response references commit only to operator-supplied expectations; no mismatch-body digest is retained.",
1236                "Transport bytes use raw socket scope, include HTTP Upgrade and WebSocket framing, and count encrypted TLS records for WSS; they are not application-byte measurements.",
1237                "A one-sided audit is not comparable and does not establish introduction, disappearance, rejection, or remediation.",
1238                "No authorization, server-state, vulnerability, exploitability, impact, or source-authentication conclusion is established by this audit.",
1239            ]
1240        },
1241    })
1242}
1243
1244fn compare_jwt_policy_review(
1245    before: Option<&ImportedJwtPolicyReviewAudit>,
1246    after: Option<&ImportedJwtPolicyReviewAudit>,
1247) -> Option<JwtPolicyReviewComparison> {
1248    if before.is_none() && after.is_none() {
1249        return None;
1250    }
1251    let (status, reason) = match (before, after) {
1252        (Some(before), Some(after)) if before.schema == after.schema => ("compared", None),
1253        (Some(_), Some(_)) => ("not_comparable", Some("audit_schema_changed")),
1254        (Some(_), None) => ("not_comparable", Some("after_audit_missing")),
1255        (None, Some(_)) => ("not_comparable", Some("before_audit_missing")),
1256        (None, None) => return None,
1257    };
1258    let target_selected = before
1259        .into_iter()
1260        .chain(after)
1261        .any(|audit| audit.schema == "security.jwt-policy-review-audit/v2");
1262    let methodology_status = if status == "compared" {
1263        paired_status(
1264            before.map(|audit| &audit.methodology),
1265            after.map(|audit| &audit.methodology),
1266        )
1267    } else {
1268        "not_comparable"
1269    };
1270    let coverage_status = if status == "compared" {
1271        paired_status(
1272            before.map(|audit| &audit.coverage),
1273            after.map(|audit| &audit.coverage),
1274        )
1275    } else {
1276        "not_comparable"
1277    };
1278    let outcome_status = if status == "compared" {
1279        paired_status(
1280            before.map(|audit| &audit.outcome),
1281            after.map(|audit| &audit.outcome),
1282        )
1283    } else {
1284        "not_comparable"
1285    };
1286    Some(JwtPolicyReviewComparison {
1287        schema: JWT_POLICY_REVIEW_COMPARISON_SCHEMA,
1288        status,
1289        reason,
1290        methodology: facet(
1291            before.map(|audit| &audit.methodology),
1292            after.map(|audit| &audit.methodology),
1293            methodology_status,
1294            "Declared policy reference/revision, expected-claim selection, clock-skew allowance, or exact public-key-byte identity changes can change interpretation without any target behavior changing.",
1295        ),
1296        coverage: facet(
1297            before.map(|audit| &audit.coverage),
1298            after.map(|audit| &audit.coverage),
1299            coverage_status,
1300            if target_selected {
1301                "The local evaluation instant plus bounded target dispatch, commit, retained-byte, and exact transport-byte accounting describe coverage. Charged chunk overrun can exceed retained bytes; missing or changed coverage is not a target fix, regression, or authorization result."
1302            } else {
1303                "The local evaluation instant and explicit absence of external activity describe bounded review coverage; they do not establish continuous clock accuracy or target acceptance."
1304            },
1305        ),
1306        outcome: facet(
1307            before.map(|audit| &audit.outcome),
1308            after.map(|audit| &audit.outcome),
1309            outcome_status,
1310            if target_selected {
1311                "Local parsing, policy consistency, signature verification, target marker controls, and authorization effect are distinct. A changed marker relationship remains review-level and does not establish a general bypass or impact."
1312            } else {
1313                "Parsing, local policy consistency, and local signature status are separate outcomes. A changed local outcome is not evidence that a target accepted a token or that authorization changed."
1314            },
1315        ),
1316        interpretation_limits: if target_selected {
1317            [
1318                "The compact token, claim names and values, signature bytes, selected marker name, raw resource URL, and verification-key material are not imported into this display-only comparison.",
1319                "The operator policy revision must change when private policy semantics change; public digests and opaque references authenticate neither key, target, policy, nor source.",
1320                "Local parsing does not authenticate claims; local signature verification applies only to the explicitly supplied local public key.",
1321                "A target marker relationship applies only to the selected resource and matched controls; it is not a confirmed bypass, authorization effect, exploitability, or impact.",
1322                "A v1 local-only audit and a v2 target-selected audit are not comparable; the target selection is a methodology and coverage change.",
1323                "A one-sided or changed audit does not establish vulnerability, remediation, or source authenticity.",
1324            ]
1325        } else {
1326            [
1327                "The compact token, claim names and values, signature bytes, and verification-key material are not imported into this display-only comparison.",
1328                "The operator policy revision must change when private policy semantics change; the public-key SHA-256 identifies bytes but authenticates neither key nor source.",
1329                "Local parsing does not authenticate claims; local signature verification applies only to the explicitly supplied local public key.",
1330                "Policy consistency is not target acceptance, authorization, exploitability, or impact.",
1331                "The evaluator performed no target request, token forwarding, or remote-key retrieval.",
1332                "A one-sided or changed audit does not establish vulnerability, remediation, or source authenticity.",
1333            ]
1334        },
1335        target_selected,
1336    })
1337}
1338
1339fn compare_xml_external_entity_review(
1340    before: Option<&ImportedXmlExternalEntityReviewAudit>,
1341    after: Option<&ImportedXmlExternalEntityReviewAudit>,
1342) -> Option<XmlExternalEntityReviewComparison> {
1343    if before.is_none() && after.is_none() {
1344        return None;
1345    }
1346    let (status, reason) = match (before, after) {
1347        (Some(_), Some(_)) => ("compared", None),
1348        (Some(_), None) => ("not_comparable", Some("after_audit_missing")),
1349        (None, Some(_)) => ("not_comparable", Some("before_audit_missing")),
1350        (None, None) => return None,
1351    };
1352    let facet_status = |before: Option<&Value>, after: Option<&Value>| {
1353        if status == "compared" {
1354            paired_status(before, after)
1355        } else {
1356            "not_comparable"
1357        }
1358    };
1359    Some(XmlExternalEntityReviewComparison {
1360        schema: XML_EXTERNAL_ENTITY_REVIEW_COMPARISON_SCHEMA,
1361        status,
1362        reason,
1363        methodology: facet(
1364            before.map(|audit| &audit.methodology),
1365            after.map(|audit| &audit.methodology),
1366            facet_status(
1367                before.map(|audit| &audit.methodology),
1368                after.map(|audit| &audit.methodology),
1369            ),
1370            "The v1 policy identity is a composite binding over private scope and schedule inputs. Policy identity, execution mode, exact method, media type, control model, algorithm, or claim-limit changes are reported as methodology changes; they do not establish a target security change.",
1371        ),
1372        coverage: facet(
1373            before.map(|audit| &audit.coverage),
1374            after.map(|audit| &audit.coverage),
1375            facet_status(
1376                before.map(|audit| &audit.coverage),
1377                after.map(|audit| &audit.coverage),
1378            ),
1379            "Counts, bytes, completeness, callback coverage, distinct-event accounting, cleanup verification, and item projection describe bounded review coverage; reduced or missing coverage is not remediation or parser hardening.",
1380        ),
1381        outcome: facet(
1382            before.map(|audit| &audit.outcome),
1383            after.map(|audit| &audit.outcome),
1384            facet_status(
1385                before.map(|audit| &audit.outcome),
1386                after.map(|audit| &audit.outcome),
1387            ),
1388            "The terminal review outcome is separate from methodology and coverage. A changed outcome does not identify a parser, confirm XXE, establish file access, data exfiltration, internal reachability, impact, or remediation.",
1389        ),
1390        interpretation_limits: [
1391            "The comparison retains no endpoint URL or path, provider origin, callback URL or identifier, XML body, system literal, token, header, cookie, response body, or raw error.",
1392            "The pseudonymous policy digest binds private policy inputs but does not authenticate a source, target, parser, provider, or cross-run deployment identity.",
1393            "A positive outcome means only two distinct correlated external-entity callback events followed the candidate and replay dispatches under a clean no-control-callback model.",
1394            "File read, data exfiltration, internal-network access, parser identity, semantic response effect, and impact validation were not performed or established.",
1395            "A one-sided or incomplete audit is not evidence that behavior appeared, disappeared, was blocked, or was remediated.",
1396            "Equal saved values do not establish equivalent deployment, authorization, provider health, review coverage, or target security.",
1397        ],
1398    })
1399}
1400
1401fn compare_template_evaluation_review(
1402    before: Option<&ImportedTemplateEvaluationReviewAudit>,
1403    after: Option<&ImportedTemplateEvaluationReviewAudit>,
1404) -> Option<TemplateEvaluationReviewComparison> {
1405    if before.is_none() && after.is_none() {
1406        return None;
1407    }
1408    let (status, reason) = match (before, after) {
1409        (Some(_), Some(_)) => ("compared", None),
1410        (Some(_), None) => ("not_comparable", Some("after_audit_missing")),
1411        (None, Some(_)) => ("not_comparable", Some("before_audit_missing")),
1412        (None, None) => return None,
1413    };
1414    let facet_status = |before: Option<&Value>, after: Option<&Value>| {
1415        if status == "compared" {
1416            paired_status(before, after)
1417        } else {
1418            "not_comparable"
1419        }
1420    };
1421    Some(TemplateEvaluationReviewComparison {
1422        schema: TEMPLATE_EVALUATION_REVIEW_COMPARISON_SCHEMA,
1423        status,
1424        reason,
1425        methodology: facet(
1426            before.map(|audit| &audit.methodology),
1427            after.map(|audit| &audit.methodology),
1428            facet_status(
1429                before.map(|audit| &audit.methodology),
1430                after.map(|audit| &audit.methodology),
1431            ),
1432            "Policy, harmless expression family, engine-identity limitation, operation declarations, or claim-limit changes are methodology changes; they do not establish a target security change or template-engine identity.",
1433        ),
1434        coverage: facet(
1435            before.map(|audit| &audit.coverage),
1436            after.map(|audit| &audit.coverage),
1437            facet_status(
1438                before.map(|audit| &audit.coverage),
1439                after.map(|audit| &audit.coverage),
1440            ),
1441            "Selected cases and bounded request/response commitment counts describe review coverage. Reduced, missing, or incomplete coverage is not remediation or parser hardening.",
1442        ),
1443        outcome: facet(
1444            before.map(|audit| &audit.outcome),
1445            after.map(|audit| &audit.outcome),
1446            facet_status(
1447                before.map(|audit| &audit.outcome),
1448                after.map(|audit| &audit.outcome),
1449            ),
1450            "The classified benign-expression outcome and projected-item count are separate from methodology and coverage. A changed outcome does not establish a particular engine, operating-system execution, file access, outbound interaction, impact, vulnerability, or remediation.",
1451        ),
1452        interpretation_limits: [
1453            "The comparison retains no expression, query name or value, URL, response body, template source, engine banner, token, credential, file path, command, or raw error.",
1454            "Candidate-specific evaluation means only that two bounded benign candidate/replay cases differed from their controls under the declared family.",
1455            "The listed family is compatible with Jinja-like semantics but does not authenticate or uniquely identify a template engine.",
1456            "Operating-system execution, file access, outbound interaction, and impact validation were not performed.",
1457            "A one-sided or incomplete audit is not evidence that behavior appeared, disappeared, was blocked, or was remediated.",
1458            "Equal saved values do not establish equivalent deployment, authorization, response coverage, source authenticity, or target security.",
1459        ],
1460    })
1461}
1462
1463fn compare_control_reference_mapping(
1464    before: Option<&ImportedControlReferenceMappingAudit>,
1465    after: Option<&ImportedControlReferenceMappingAudit>,
1466) -> Option<ControlReferenceMappingComparison> {
1467    if before.is_none() && after.is_none() {
1468        return None;
1469    }
1470    let (status, reason) = match (before, after) {
1471        (Some(_), Some(_)) => ("compared", None),
1472        (Some(_), None) => ("not_comparable", Some("after_audit_missing")),
1473        (None, Some(_)) => ("not_comparable", Some("before_audit_missing")),
1474        (None, None) => return None,
1475    };
1476    Some(ControlReferenceMappingComparison {
1477        schema: CONTROL_REFERENCE_MAPPING_COMPARISON_SCHEMA,
1478        status,
1479        reason,
1480        methodology: facet(
1481            before.map(|audit| &audit.methodology),
1482            after.map(|audit| &audit.methodology),
1483            paired_status(
1484                before.map(|audit| &audit.methodology),
1485                after.map(|audit| &audit.methodology),
1486            ),
1487            "Policy, catalogue revision, reviewed-source revision, rights posture, external-activity declaration, or claim-limit changes are methodology changes; they do not establish a target change, vulnerability, remediation, or control result.",
1488        ),
1489        coverage: facet(
1490            before.map(|audit| &audit.coverage),
1491            after.map(|audit| &audit.coverage),
1492            paired_status(
1493                before.map(|audit| &audit.coverage),
1494                after.map(|audit| &audit.coverage),
1495            ),
1496            "Mapped, unmapped, relationship, reference-link, and omission counts describe bounded mapping coverage; fewer references do not establish remediation, control fulfilment, or safety.",
1497        ),
1498        reference_set: facet(
1499            before.map(|audit| &audit.reference_set),
1500            after.map(|audit| &audit.reference_set),
1501            paired_status(
1502                before.map(|audit| &audit.reference_set),
1503                after.map(|audit| &audit.reference_set),
1504            ),
1505            "Catalogue/source revisions and evidence-to-reference relationships are reference-set differences. They are not target observations, introduced vulnerabilities, verified remediation, compliance, or certification.",
1506        ),
1507        interpretation_limits: [
1508            "The mapping is an offline, versioned reference projection over already composed assessment items; it performs no target, provider, or source-retrieval request.",
1509            "Framework identifiers, short titles where rights permit, and original mapping rationales provide technical review context only; they are not copied standards text or a control assessment.",
1510            "A mapping relationship does not establish organizational applicability, control fulfilment, compliance, certification, legal noncompliance, or source authenticity.",
1511            "An unmapped item means only that no exact rule in this finite catalogue matched; absence of a finding or mapping establishes no control outcome.",
1512            "One-sided or changed mapping audits can result from catalogue, source, methodology, coverage, or source-item changes and never by themselves prove target change or remediation.",
1513        ],
1514    })
1515}
1516
1517fn compare_recon_snapshot_import(
1518    before: Option<&ImportedReconSnapshotAudit>,
1519    after: Option<&ImportedReconSnapshotAudit>,
1520) -> Option<ReconSnapshotImportComparison> {
1521    if before.is_none() && after.is_none() {
1522        return None;
1523    }
1524    let (status, reason) = match (before, after) {
1525        (Some(_), Some(_)) => ("compared", None),
1526        (Some(_), None) => ("not_comparable", Some("after_audit_missing")),
1527        (None, Some(_)) => ("not_comparable", Some("before_audit_missing")),
1528        (None, None) => return None,
1529    };
1530    Some(ReconSnapshotImportComparison {
1531        schema: RECON_SNAPSHOT_IMPORT_COMPARISON_SCHEMA,
1532        status,
1533        reason,
1534        methodology: facet(
1535            before.map(|audit| &audit.methodology),
1536            after.map(|audit| &audit.methodology),
1537            paired_status(
1538                before.map(|audit| &audit.methodology),
1539                after.map(|audit| &audit.methodology),
1540            ),
1541            "Parser policy, external-activity declaration, or claim-limit changes are methodology changes; exact-input identity is compared with provenance and sources. None establishes a target, vulnerability, or remediation change.",
1542        ),
1543        provenance_and_sources: facet(
1544            before.map(|audit| &audit.provenance_and_sources),
1545            after.map(|audit| &audit.provenance_and_sources),
1546            paired_status(
1547                before.map(|audit| &audit.provenance_and_sources),
1548                after.map(|audit| &audit.provenance_and_sources),
1549            ),
1550            "Snapshot identity and source declarations are operator-supplied provenance. Differences do not authenticate either source, establish freshness, or authorize scanning a named asset.",
1551        ),
1552        coverage_and_accounting: facet(
1553            before.map(|audit| &audit.coverage_and_accounting),
1554            after.map(|audit| &audit.coverage_and_accounting),
1555            paired_status(
1556                before.map(|audit| &audit.coverage_and_accounting),
1557                after.map(|audit| &audit.coverage_and_accounting),
1558            ),
1559            "Source, record, association, and prepared-index counts describe the bounded imported snapshot only. Equal counts do not prove equal content or complete provider coverage.",
1560        ),
1561        hypotheses: facet(
1562            before.map(|audit| &audit.hypotheses),
1563            after.map(|audit| &audit.hypotheses),
1564            paired_status(
1565                before.map(|audit| &audit.hypotheses),
1566                after.map(|audit| &audit.hypotheses),
1567            ),
1568            "Added, removed, or changed imported hypotheses are source-data differences, not target changes, newly discovered vulnerabilities, verified remediation, ownership, or reachability results.",
1569        ),
1570        interpretation_limits: [
1571            "The imported snapshot is inert local reference material and performed no target request, provider request, archive processing, or decompression.",
1572            "Names, addresses, service-product hints, and reputation labels remain source-qualified hypotheses and never become assessment items or broker authority.",
1573            "Exact-input SHA-256 identifies supplied bytes; it is not a signature, source authentication, rights verification, freshness proof, or completeness proof.",
1574            "A certificate name or historical DNS address does not establish current ownership, authorization, reachability, or tenant association.",
1575            "One-sided audit presence is not comparable and does not establish asset appearance, disappearance, vulnerability, or remediation.",
1576            "Hypothesis differences must be reviewed with their declared sources and limitations; they do not create a scan permit.",
1577        ],
1578    })
1579}
1580
1581fn compare_recon_certspotter(
1582    before: Option<&ImportedReconCertSpotterAudit>,
1583    after: Option<&ImportedReconCertSpotterAudit>,
1584) -> Option<ReconCertSpotterComparison> {
1585    if before.is_none() && after.is_none() {
1586        return None;
1587    }
1588    let (status, reason) = match (before, after) {
1589        (Some(_), Some(_)) => ("compared", None),
1590        (Some(_), None) => ("not_comparable", Some("after_audit_missing")),
1591        (None, Some(_)) => ("not_comparable", Some("before_audit_missing")),
1592        (None, None) => return None,
1593    };
1594    Some(ReconCertSpotterComparison {
1595        schema: RECON_CERTSPOTTER_COMPARISON_SCHEMA,
1596        status,
1597        reason,
1598        provider: facet(
1599            before.map(|audit| &audit.provider),
1600            after.map(|audit| &audit.provider),
1601            paired_status(
1602                before.map(|audit| &audit.provider),
1603                after.map(|audit| &audit.provider),
1604            ),
1605            "Provider identity, effective origin, execution mode, query scope, or opaque policy-reference changes are provider-context differences. They do not authenticate a source, establish ownership, or authorize scanning.",
1606        ),
1607        methodology: facet(
1608            before.map(|audit| &audit.methodology),
1609            after.map(|audit| &audit.methodology),
1610            paired_status(
1611                before.map(|audit| &audit.methodology),
1612                after.map(|audit| &audit.methodology),
1613            ),
1614            "Endpoint, pagination, compiled-bound, no-retry, no-polling, credential, or fixed claim-limit changes are methodology differences, not target changes or security findings.",
1615        ),
1616        coverage: facet(
1617            before.map(|audit| &audit.coverage),
1618            after.map(|audit| &audit.coverage),
1619            paired_status(
1620                before.map(|audit| &audit.coverage),
1621                after.map(|audit| &audit.coverage),
1622            ),
1623            "Terminal state, completeness, cursor, request, byte, issuance, retention, and omission counters describe only each bounded provider collection. Equal counters do not prove equal content, currentness, or complete CT coverage.",
1624        ),
1625        hypotheses: facet(
1626            before.map(|audit| &audit.hypotheses),
1627            after.map(|audit| &audit.hypotheses),
1628            paired_status(
1629                before.map(|audit| &audit.hypotheses),
1630                after.map(|audit| &audit.hypotheses),
1631            ),
1632            "Added, removed, or changed source-qualified names are provider-result differences. They are not findings and do not establish ownership, authentication, currentness, reachability, or scan authority.",
1633        ),
1634        interpretation_limits: [
1635            "The comparison parses saved reports only; it performs no target or provider request and does not authenticate either report or provider response.",
1636            "Cert Spotter names remain source-qualified hypotheses and never become assessment items, findings, broker permits, or target authority.",
1637            "Provider exhaustion means only that an empty page was observed at the bounded cursor; it is not global CT completeness or currentness proof.",
1638            "A one-sided audit or hypothesis does not establish asset appearance, disappearance, ownership, reachability, vulnerability, or remediation.",
1639            "Transport success and TLS validation do not authenticate the provider as the asset owner or validate the returned certificate names.",
1640            "Every compared hypothesis retains the fixed no-authority, no-ownership, no-currentness, and no-source-authentication claim limits.",
1641        ],
1642    })
1643}
1644
1645fn compare_wordpress_reviews(
1646    before: Option<&ImportedWordPressAudit>,
1647    after: Option<&ImportedWordPressAudit>,
1648) -> Option<WordPressReviewComparison> {
1649    if before.is_none() && after.is_none() {
1650        return None;
1651    }
1652    let (status, reason, compare_entities) = wordpress_comparison_scope(before, after);
1653    let coverage = facet(
1654        before.map(|audit| &audit.coverage),
1655        after.map(|audit| &audit.coverage),
1656        match (before, after) {
1657            (Some(left), Some(right)) if left.coverage != right.coverage => "changed",
1658            (Some(left), Some(right))
1659                if left.inventory_coverage_recorded && right.inventory_coverage_recorded =>
1660            {
1661                "same_declared_projection"
1662            },
1663            (Some(_), Some(_)) => "not_established",
1664            _ => "coverage_changed",
1665        },
1666        "Declared inventory and source-selection coverage only; equal values do not establish equivalent security coverage.",
1667    );
1668    let methodology = facet(
1669        before.map(|audit| &audit.methodology),
1670        after.map(|audit| &audit.methodology),
1671        paired_status(before.map(|audit| &audit.methodology), after.map(|audit| &audit.methodology)),
1672        "A methodology change can change interpretation without an installation or advisory changing.",
1673    );
1674    let provenance_status = wordpress_provenance_status(before, after);
1675    let provenance = facet(
1676        before.map(|audit| &audit.provenance),
1677        after.map(|audit| &audit.provenance),
1678        provenance_status,
1679        "Digests identify supplied bytes; they do not authenticate a source or prove collection completeness.",
1680    );
1681    let discovery_source_content = if before
1682        .is_some_and(|audit| audit.discovery_source_content.is_some())
1683        || after.is_some_and(|audit| audit.discovery_source_content.is_some())
1684    {
1685        Some(facet(
1686            before.and_then(|audit| audit.discovery_source_content.as_ref()),
1687            after.and_then(|audit| audit.discovery_source_content.as_ref()),
1688            paired_status(
1689                before.and_then(|audit| audit.discovery_source_content.as_ref()),
1690                after.and_then(|audit| audit.discovery_source_content.as_ref()),
1691            ),
1692            "REST namespace declarations and other non-component metadata are source content; changes do not by themselves establish changed security coverage.",
1693        ))
1694    } else {
1695        None
1696    };
1697    let asset_fingerprints = compare_wordpress_asset_fingerprints(
1698        before.and_then(|audit| audit.asset_fingerprints.as_ref()),
1699        after.and_then(|audit| audit.asset_fingerprints.as_ref()),
1700        status,
1701        reason,
1702        compare_entities,
1703    );
1704    let (components, advisories) =
1705        if let (true, Some(before), Some(after)) = (compare_entities, before, after) {
1706            (
1707                compare_wordpress_entities(&before.components, &after.components),
1708                compare_wordpress_entities(&before.advisories, &after.advisories),
1709            )
1710        } else {
1711            (
1712                WordPressEntityChanges::default(),
1713                WordPressEntityChanges::default(),
1714            )
1715        };
1716    Some(WordPressReviewComparison {
1717        schema: if before.is_some_and(|audit| audit.supplied_session_context.is_some())
1718            || after.is_some_and(|audit| audit.supplied_session_context.is_some())
1719        {
1720            WORDPRESS_COMPARISON_SCHEMA_V4
1721        } else if asset_fingerprints.is_some() {
1722            WORDPRESS_COMPARISON_SCHEMA_V3
1723        } else if discovery_source_content.is_some() {
1724            WORDPRESS_COMPARISON_SCHEMA_V2
1725        } else {
1726            WORDPRESS_COMPARISON_SCHEMA_V1
1727        },
1728        status,
1729        reason,
1730        scope_assurance: "operator-declared",
1731        coverage,
1732        methodology,
1733        provenance,
1734        discovery_source_content,
1735        asset_fingerprints,
1736        components,
1737        advisories,
1738        interpretation_limits: [
1739            "WordPress differences compare validated display-only audit projections; they do not rerun an assessment.",
1740            "One-sided components or advisories do not establish installation, removal, discovery time, or remediation.",
1741            "Simultaneous inventory, source, methodology, and applicability changes are listed without assigning cause.",
1742            "Applicability remains an imported declared-data result, not proof of exploitability or safety.",
1743            "A missing audit or reduced declaration changes coverage; it does not mean all WordPress issues disappeared.",
1744            "Public metadata content is compared separately from collection outcomes; neither dimension authenticates installation state.",
1745        ],
1746    })
1747}
1748
1749fn compare_wordpress_asset_fingerprints(
1750    before: Option<&ImportedWordPressAssetFingerprintAudit>,
1751    after: Option<&ImportedWordPressAssetFingerprintAudit>,
1752    scope_status: &'static str,
1753    scope_reason: Option<&'static str>,
1754    compare_entities: bool,
1755) -> Option<WordPressAssetFingerprintComparison> {
1756    let (status, reason) = match (before, after) {
1757        (Some(_), Some(_)) if compare_entities => ("compared", None),
1758        (Some(_), Some(_)) => (scope_status, scope_reason),
1759        (Some(_), None) => ("not_compared", Some("after_fingerprint_audit_missing")),
1760        (None, Some(_)) => ("not_compared", Some("before_fingerprint_audit_missing")),
1761        (None, None) => return None,
1762    };
1763    let methodology = facet(
1764        before.map(|audit| &audit.methodology),
1765        after.map(|audit| &audit.methodology),
1766        paired_status(
1767            before.map(|audit| &audit.methodology),
1768            after.map(|audit| &audit.methodology),
1769        ),
1770        "Fingerprint methodology changes can change candidate sets without target resource bytes changing.",
1771    );
1772    let catalogue = facet(
1773        before.map(|audit| &audit.catalogue),
1774        after.map(|audit| &audit.catalogue),
1775        wordpress_fingerprint_catalogue_status(
1776            before.map(|audit| &audit.catalogue),
1777            after.map(|audit| &audit.catalogue),
1778        ),
1779        "A changed finite reference set can change candidates without an installed component or target resource changing.",
1780    );
1781    let coverage = facet(
1782        before.map(|audit| &audit.coverage),
1783        after.map(|audit| &audit.coverage),
1784        paired_status(
1785            before.map(|audit| &audit.coverage),
1786            after.map(|audit| &audit.coverage),
1787        ),
1788        "Unavailable or omitted resource coverage is not a component removal, clean result, or remediation.",
1789    );
1790    let (resources, components) = match (before, after, compare_entities) {
1791        (Some(before), Some(after), true) => (
1792            compare_wordpress_entities(&before.resources, &after.resources),
1793            compare_wordpress_entities(&before.components, &after.components),
1794        ),
1795        _ => (
1796            WordPressEntityChanges::default(),
1797            WordPressEntityChanges::default(),
1798        ),
1799    };
1800    Some(WordPressAssetFingerprintComparison {
1801        status,
1802        reason,
1803        methodology,
1804        catalogue,
1805        coverage,
1806        resources,
1807        components,
1808        interpretation_limits: [
1809            "Compared SHA-256 values and byte lengths describe complete admitted response bytes, not whole-package verification.",
1810            "Catalogue candidates are conditional on a finite operator-supplied reference set and are not installed-version evidence.",
1811            "Identical bytes can occur in unlisted releases, copied assets, caches, or custom builds.",
1812            "A one-sided or failed resource observation does not establish component installation, removal, or remediation.",
1813            "Source labels and digests identify supplied data; they do not authenticate its publisher or completeness.",
1814        ],
1815    })
1816}
1817
1818fn wordpress_fingerprint_catalogue_status(
1819    before: Option<&Value>,
1820    after: Option<&Value>,
1821) -> &'static str {
1822    let (Some(before), Some(after)) = (before, after) else {
1823        return "not_comparable";
1824    };
1825    if before == after {
1826        return "unchanged";
1827    }
1828    let (Some(before_fields), Some(after_fields)) = (before.as_object(), after.as_object()) else {
1829        return "changed";
1830    };
1831    if before_fields.get("semantic_sha256") == after_fields.get("semantic_sha256")
1832        && fingerprint_catalogue_without_byte_identity(before)
1833            == fingerprint_catalogue_without_byte_identity(after)
1834    {
1835        "input_bytes_changed_without_reference_semantic_change"
1836    } else {
1837        "changed"
1838    }
1839}
1840
1841fn fingerprint_catalogue_without_byte_identity(value: &Value) -> Value {
1842    let mut value = value.clone();
1843    if let Some(fields) = value.as_object_mut() {
1844        fields.remove("byte_length");
1845        fields.remove("sha256");
1846    }
1847    value
1848}
1849
1850fn wordpress_comparison_scope(
1851    before: Option<&ImportedWordPressAudit>,
1852    after: Option<&ImportedWordPressAudit>,
1853) -> (&'static str, Option<&'static str>, bool) {
1854    let (Some(before), Some(after)) = (before, after) else {
1855        return if before.is_some() {
1856            ("not_compared", Some("after_audit_missing"), false)
1857        } else {
1858            ("not_compared", Some("before_audit_missing"), false)
1859        };
1860    };
1861
1862    let application_scope = match (
1863        before.application_reference.as_deref(),
1864        after.application_reference.as_deref(),
1865    ) {
1866        (Some(before), Some(after)) if before == after => None,
1867        (Some(_), Some(_)) => Some("application_scope_mismatch"),
1868        (None, None) => None,
1869        _ => Some("application_scope_unknown"),
1870    };
1871    if let Some(reason) = application_scope {
1872        return ("not_compared", Some(reason), false);
1873    }
1874    match (
1875        before.supplied_session_context.as_ref(),
1876        after.supplied_session_context.as_ref(),
1877    ) {
1878        (Some(before), Some(after)) if before == after => ("compared", None, true),
1879        (Some(_), Some(_)) => (
1880            "not_compared",
1881            Some("supplied_session_context_mismatch"),
1882            false,
1883        ),
1884        (None, None) => ("compared", None, true),
1885        _ => (
1886            "not_compared",
1887            Some("supplied_session_context_unknown"),
1888            false,
1889        ),
1890    }
1891}
1892
1893fn paired_status(before: Option<&Value>, after: Option<&Value>) -> &'static str {
1894    match (before, after) {
1895        (Some(before), Some(after)) if before == after => "unchanged",
1896        (Some(_), Some(_)) => "changed",
1897        _ => "not_comparable",
1898    }
1899}
1900
1901fn facet(
1902    before: Option<&Value>,
1903    after: Option<&Value>,
1904    status: &str,
1905    note: &'static str,
1906) -> WordPressFacetComparison {
1907    WordPressFacetComparison {
1908        status: status.to_owned(),
1909        changed_fields: changed_value_fields(before, after),
1910        before: before.cloned(),
1911        after: after.cloned(),
1912        note,
1913    }
1914}
1915
1916fn changed_value_fields(before: Option<&Value>, after: Option<&Value>) -> Vec<String> {
1917    let Some(before) = before.and_then(Value::as_object) else {
1918        return if before == after {
1919            Vec::new()
1920        } else {
1921            vec!["audit_presence".to_owned()]
1922        };
1923    };
1924    let Some(after) = after.and_then(Value::as_object) else {
1925        return vec!["audit_presence".to_owned()];
1926    };
1927    before
1928        .keys()
1929        .chain(after.keys())
1930        .collect::<BTreeSet<_>>()
1931        .into_iter()
1932        .filter(|key| before.get(*key) != after.get(*key))
1933        .cloned()
1934        .collect()
1935}
1936
1937fn wordpress_provenance_status(
1938    before: Option<&ImportedWordPressAudit>,
1939    after: Option<&ImportedWordPressAudit>,
1940) -> &'static str {
1941    let (Some(before), Some(after)) = (before, after) else {
1942        return "not_comparable";
1943    };
1944    if before.provenance == after.provenance {
1945        return "unchanged";
1946    }
1947    let before_input = before
1948        .provenance
1949        .get("external_input")
1950        .and_then(Value::as_object);
1951    let after_input = after
1952        .provenance
1953        .get("external_input")
1954        .and_then(Value::as_object);
1955    if let (Some(before_input), Some(after_input)) = (before_input, after_input) {
1956        let same_semantic_digest =
1957            before_input.get("semantic_sha256") == after_input.get("semantic_sha256");
1958        if same_semantic_digest
1959            && before.components == after.components
1960            && before.advisories == after.advisories
1961            && provenance_without_external_byte_identity(&before.provenance)
1962                == provenance_without_external_byte_identity(&after.provenance)
1963        {
1964            return "input_bytes_changed_without_selected_semantic_change";
1965        }
1966        if !same_semantic_digest
1967            && before.components == after.components
1968            && before.advisories == after.advisories
1969        {
1970            return "source_snapshot_semantics_changed_without_selected_record_change";
1971        }
1972    }
1973    if before.components == after.components && before.advisories == after.advisories {
1974        return "input_provenance_changed_semantic_significance_not_established";
1975    }
1976    "changed"
1977}
1978
1979fn provenance_without_external_byte_identity(value: &Value) -> Value {
1980    let mut value = value.clone();
1981    if let Some(input) = value
1982        .get_mut("external_input")
1983        .and_then(Value::as_object_mut)
1984    {
1985        input.remove("byte_length");
1986        input.remove("sha256");
1987    }
1988    value
1989}
1990
1991fn compare_wordpress_entities<K>(
1992    before: &BTreeMap<K, BTreeMap<String, Value>>,
1993    after: &BTreeMap<K, BTreeMap<String, Value>>,
1994) -> WordPressEntityChanges<K>
1995where
1996    K: Clone + Ord,
1997{
1998    let mut result = WordPressEntityChanges::default();
1999    let mut remaining = after.clone();
2000    for (key, before_content) in before {
2001        if let Some(after_content) = remaining.remove(key) {
2002            let changed_dimensions = before_content
2003                .keys()
2004                .chain(after_content.keys())
2005                .collect::<BTreeSet<_>>()
2006                .into_iter()
2007                .filter(|dimension| before_content.get(*dimension) != after_content.get(*dimension))
2008                .cloned()
2009                .collect::<Vec<_>>();
2010            if changed_dimensions.is_empty() {
2011                result.paired_unchanged_count += 1;
2012            } else {
2013                result.paired_changed.push(WordPressEntityChange {
2014                    key: key.clone(),
2015                    changed_dimensions,
2016                    before: before_content.clone(),
2017                    after: after_content,
2018                });
2019            }
2020        } else {
2021            result.only_in_before.push(WordPressOneSidedEntity {
2022                key: key.clone(),
2023                content: before_content.clone(),
2024                interpretation:
2025                    "present_only_in_the_supplied_before_audit_not_verified_remediation",
2026            });
2027        }
2028    }
2029    for (key, content) in remaining {
2030        result.only_in_after.push(WordPressOneSidedEntity {
2031            key,
2032            content,
2033            interpretation: "present_only_in_the_supplied_after_audit_not_verified_newness",
2034        });
2035    }
2036    result
2037}
2038
2039fn changed_fields(before: &ItemProjection, after: &ItemProjection) -> Vec<&'static str> {
2040    [
2041        ("title", before.title != after.title),
2042        ("category", before.category != after.category),
2043        ("disposition", before.disposition != after.disposition),
2044        ("claim_basis", before.claim_basis != after.claim_basis),
2045        ("severity", before.severity != after.severity),
2046        ("cwe", before.cwe != after.cwe),
2047        (
2048            "confidence_ppm",
2049            before.confidence_ppm != after.confidence_ppm,
2050        ),
2051        (
2052            "redacted_summary",
2053            before.redacted_summary != after.redacted_summary,
2054        ),
2055        ("remediation", before.remediation != after.remediation),
2056        ("evidence", before.evidence != after.evidence),
2057    ]
2058    .into_iter()
2059    .filter_map(|(name, changed)| changed.then_some(name))
2060    .collect()
2061}
2062
2063fn render(
2064    document: &ComparisonDocument,
2065    format: ComparisonFormat,
2066    limit: usize,
2067) -> Result<String, ComparisonError> {
2068    match format {
2069        ComparisonFormat::Json => Ok(render_serializable_json(document, limit)?),
2070        ComparisonFormat::Markdown => render_markdown(document, limit),
2071        ComparisonFormat::Html => html::render(document, limit),
2072    }
2073}
2074
2075fn render_markdown(document: &ComparisonDocument, limit: usize) -> Result<String, ComparisonError> {
2076    let mut output = RenderBuffer::new(limit);
2077    output.push_str("# Offline report comparison\n\n")?;
2078    output.push_str(
2079        "Scope is operator-declared. Coverage equivalence and source authenticity are not established. \
2080Imported claims are not endorsed. Only in before does not mean fixed or resolved. \
2081Only in after does not establish when an observation first appeared. \
2082Unchanged means equality of the compared projection, not proof of security.\n\n",
2083    )?;
2084    for (name, source) in [("Before", &document.before), ("After", &document.after)] {
2085        output.push_fmt(format_args!("## {name} source\n\n"))?;
2086        write_source_markdown(&mut output, source)?;
2087    }
2088    if let Some(session) = &document.supplied_session_comparison {
2089        write_supplied_session_comparison_markdown(&mut output, session)?;
2090    }
2091    if let Some(secret_exposure) = &document.secret_exposure_comparison {
2092        write_secret_exposure_comparison_markdown(&mut output, secret_exposure)?;
2093    }
2094    if let Some(tls_observation) = &document.tls_observation_comparison {
2095        write_tls_observation_comparison_markdown(&mut output, tls_observation)?;
2096    }
2097    if let Some(tls_negotiation) = &document.tls_negotiation_review_comparison {
2098        write_tls_negotiation_review_comparison_markdown(&mut output, tls_negotiation)?;
2099    }
2100    if let Some(websocket_review) = &document.websocket_review_comparison {
2101        write_websocket_review_comparison_markdown(&mut output, websocket_review)?;
2102    }
2103    if let Some(jwt_policy_review) = &document.jwt_policy_review_comparison {
2104        write_jwt_policy_review_comparison_markdown(&mut output, jwt_policy_review)?;
2105    }
2106    if let Some(xml_external_entity_review) = &document.xml_external_entity_review_comparison {
2107        write_xml_external_entity_review_comparison_markdown(
2108            &mut output,
2109            xml_external_entity_review,
2110        )?;
2111    }
2112    if let Some(template_evaluation_review) = &document.template_evaluation_review_comparison {
2113        write_template_evaluation_review_comparison_markdown(
2114            &mut output,
2115            template_evaluation_review,
2116        )?;
2117    }
2118    if let Some(control_reference_mapping) = &document.control_reference_mapping_comparison {
2119        write_control_reference_mapping_comparison_markdown(
2120            &mut output,
2121            control_reference_mapping,
2122        )?;
2123    }
2124    if let Some(recon_snapshot) = &document.recon_snapshot_import_comparison {
2125        write_recon_snapshot_import_comparison_markdown(&mut output, recon_snapshot)?;
2126    }
2127    if let Some(recon_certspotter) = &document.recon_certspotter_comparison {
2128        write_recon_certspotter_comparison_markdown(&mut output, recon_certspotter)?;
2129    }
2130    if let Some(wordpress) = &document.wordpress_review_comparison {
2131        write_wordpress_comparison_markdown(&mut output, wordpress)?;
2132    }
2133    for (name, items) in [
2134        ("only_in_after", &document.only_in_after),
2135        ("only_in_before", &document.only_in_before),
2136        ("changed", &document.changed),
2137        ("unchanged", &document.unchanged),
2138    ] {
2139        output.push_fmt(format_args!("## {name} ({})\n\n", items.len()))?;
2140        if items.is_empty() {
2141            output.push_str("No items in this group.\n\n")?;
2142        }
2143        for item in items {
2144            output.push_str("### Item\n\n- Fingerprint: ")?;
2145            write_markdown_code_span(&mut output, &item.fingerprint)?;
2146            output.push_str("\n- Capability: ")?;
2147            write_markdown_code_span(&mut output, &item.capability_id)?;
2148            if !item.changed_fields.is_empty() {
2149                output.push_str("\n- Changed fields: ")?;
2150                write_markdown_code_span(&mut output, &item.changed_fields.join(", "))?;
2151            }
2152            output.push_str("\n\n")?;
2153            if let (Some(before), Some(_)) = (&item.before, &item.after) {
2154                if item.changed_fields.is_empty() {
2155                    output.push_str("#### Shared comparable projection\n\n")?;
2156                    write_projection(&mut output, before)?;
2157                    continue;
2158                }
2159            }
2160            for (name, projection) in [("Before", &item.before), ("After", &item.after)] {
2161                output.push_fmt(format_args!("#### {name}\n\n"))?;
2162                if let Some(projection) = projection {
2163                    write_projection(&mut output, projection)?;
2164                } else {
2165                    output.push_str("Not present in this input.\n\n")?;
2166                }
2167            }
2168        }
2169    }
2170    Ok(output.finish())
2171}
2172
2173fn write_supplied_session_comparison_markdown(
2174    output: &mut RenderBuffer,
2175    comparison: &SuppliedSessionComparison,
2176) -> Result<(), ComparisonError> {
2177    output.push_str("## Supplied session differences\n\n- Schema: ")?;
2178    write_markdown_code_span(output, comparison.schema)?;
2179    output.push_str("\n- Status: ")?;
2180    write_markdown_code_span(output, comparison.status)?;
2181    if let Some(reason) = comparison.reason {
2182        output.push_str("\n- Reason: ")?;
2183        write_markdown_code_span(output, reason)?;
2184    }
2185    output.push_str("\n- Scope assurance: ")?;
2186    write_markdown_code_span(output, comparison.scope_assurance)?;
2187    output.push_str(
2188        "\n\nThis display-only section keeps declared session context separate from health, coverage, and bounded accounting. It does not authenticate a principal or establish target change or remediation.\n\n",
2189    )?;
2190    for (label, facet) in [
2191        ("Declared context", &comparison.context),
2192        ("Health and coverage", &comparison.health_and_coverage),
2193        ("Accounting", &comparison.accounting),
2194    ] {
2195        output.push_fmt(format_args!("### {label}\n\n- Status: "))?;
2196        write_markdown_code_span(output, &facet.status)?;
2197        if !facet.changed_fields.is_empty() {
2198            output.push_str("\n- Changed fields: ")?;
2199            write_markdown_code_span(output, &facet.changed_fields.join(", "))?;
2200        }
2201        output.push_str("\n- Before: ")?;
2202        write_markdown_code_span(output, &display_json(facet.before.as_ref())?)?;
2203        output.push_str("\n- After: ")?;
2204        write_markdown_code_span(output, &display_json(facet.after.as_ref())?)?;
2205        output.push_str("\n- Interpretation: ")?;
2206        write_markdown_code_span(output, facet.note)?;
2207        output.push_str("\n\n")?;
2208    }
2209    output.push_str("### Interpretation limits\n\n")?;
2210    for limit in comparison.interpretation_limits {
2211        output.push_str("- ")?;
2212        write_markdown_code_span(output, limit)?;
2213        output.push_char('\n')?;
2214    }
2215    output.push_char('\n')?;
2216    Ok(())
2217}
2218
2219fn write_source_markdown(
2220    output: &mut RenderBuffer,
2221    source: &SourceMetadata,
2222) -> Result<(), ComparisonError> {
2223    for (name, value) in [
2224        ("sha256", source.sha256.as_str()),
2225        ("schema", source.schema.as_str()),
2226        ("source_schema", source.source_schema.as_str()),
2227        ("run_schema", source.run_schema.as_str()),
2228        ("profile_schema", source.profile_schema.as_str()),
2229        ("profile", source.profile.as_str()),
2230        ("status", source.status.as_str()),
2231    ] {
2232        output.push_str("- ")?;
2233        write_markdown_code_span(output, name)?;
2234        output.push_str(": ")?;
2235        write_markdown_code_span(output, value)?;
2236        output.push_char('\n')?;
2237    }
2238    for (name, value) in [
2239        ("subject_count", source.subject_count),
2240        ("item_count", source.item_count),
2241    ] {
2242        output.push_str("- ")?;
2243        write_markdown_code_span(output, name)?;
2244        output.push_str(": ")?;
2245        write_markdown_code_span(output, &value.to_string())?;
2246        output.push_char('\n')?;
2247    }
2248    for (name, value) in &source.optional_audits {
2249        output.push_str("- Optional audit ")?;
2250        write_markdown_code_span(output, name)?;
2251        output.push_str(": ")?;
2252        if name == "wordpress_review" {
2253            write_markdown_code_span(output, &display_serializable(value)?)?;
2254            output.push_str(" (validated snapshot; semantic differences are shown below)")?;
2255        } else {
2256            write_markdown_code_span(output, &display_serializable(value)?)?;
2257        }
2258        output.push_char('\n')?;
2259    }
2260    output.push_char('\n')?;
2261    Ok(())
2262}
2263
2264fn write_secret_exposure_comparison_markdown(
2265    output: &mut RenderBuffer,
2266    comparison: &SecretExposureComparison,
2267) -> Result<(), ComparisonError> {
2268    output.push_str("## Passive secret-exposure differences\n\n- Schema: ")?;
2269    write_markdown_code_span(output, comparison.schema)?;
2270    output.push_str("\n- Status: ")?;
2271    write_markdown_code_span(output, comparison.status)?;
2272    if let Some(reason) = comparison.reason {
2273        output.push_str("\n- Reason: ")?;
2274        write_markdown_code_span(output, reason)?;
2275    }
2276    output.push_str(
2277        "\n\nThis section compares validated, value-free audit projections. It does not validate a secret, authenticate a source, contact a provider, or establish remediation.\n\n",
2278    )?;
2279    for (label, facet) in [
2280        ("Methodology", &comparison.methodology),
2281        ("Coverage", &comparison.coverage),
2282    ] {
2283        output.push_fmt(format_args!("### Secret exposure {label}\n\n- Status: "))?;
2284        write_markdown_code_span(output, &facet.status)?;
2285        if !facet.changed_fields.is_empty() {
2286            output.push_str("\n- Changed fields: ")?;
2287            write_markdown_code_span(output, &facet.changed_fields.join(", "))?;
2288        }
2289        output.push_str("\n- Before: ")?;
2290        write_markdown_code_span(output, &display_json(facet.before.as_ref())?)?;
2291        output.push_str("\n- After: ")?;
2292        write_markdown_code_span(output, &display_json(facet.after.as_ref())?)?;
2293        output.push_str("\n- Interpretation: ")?;
2294        write_markdown_code_span(output, facet.note)?;
2295        output.push_str("\n\n")?;
2296    }
2297    output.push_str("### Secret exposure interpretation limits\n\n")?;
2298    for limit in comparison.interpretation_limits {
2299        output.push_str("- ")?;
2300        write_markdown_code_span(output, limit)?;
2301        output.push_char('\n')?;
2302    }
2303    output.push_char('\n')?;
2304    Ok(())
2305}
2306
2307fn write_tls_observation_comparison_markdown(
2308    output: &mut RenderBuffer,
2309    comparison: &TlsObservationComparison,
2310) -> Result<(), ComparisonError> {
2311    output.push_str("## Existing-connection TLS observation differences\n\n- Schema: ")?;
2312    write_markdown_code_span(output, comparison.schema)?;
2313    output.push_str("\n- Status: ")?;
2314    write_markdown_code_span(output, comparison.status)?;
2315    if let Some(reason) = comparison.reason {
2316        output.push_str("\n- Reason: ")?;
2317        write_markdown_code_span(output, reason)?;
2318    }
2319    output.push_str(
2320        "\n\nThis section compares validated passive TLS audit projections. It does not enumerate server TLS support, authenticate a source, check revocation, or establish vulnerability or remediation.\n\n",
2321    )?;
2322    for (label, facet) in [
2323        ("Methodology", &comparison.methodology),
2324        ("Coverage", &comparison.coverage),
2325        (
2326            "Certificate observations",
2327            &comparison.certificate_observations,
2328        ),
2329    ] {
2330        output.push_fmt(format_args!("### TLS {label}\n\n- Status: "))?;
2331        write_markdown_code_span(output, &facet.status)?;
2332        if !facet.changed_fields.is_empty() {
2333            output.push_str("\n- Changed fields: ")?;
2334            write_markdown_code_span(output, &facet.changed_fields.join(", "))?;
2335        }
2336        output.push_str("\n- Before: ")?;
2337        write_markdown_code_span(output, &display_json(facet.before.as_ref())?)?;
2338        output.push_str("\n- After: ")?;
2339        write_markdown_code_span(output, &display_json(facet.after.as_ref())?)?;
2340        output.push_str("\n- Interpretation: ")?;
2341        write_markdown_code_span(output, facet.note)?;
2342        output.push_str("\n\n")?;
2343    }
2344    output.push_str("### TLS observation interpretation limits\n\n")?;
2345    for limit in comparison.interpretation_limits {
2346        output.push_str("- ")?;
2347        write_markdown_code_span(output, limit)?;
2348        output.push_char('\n')?;
2349    }
2350    output.push_char('\n')?;
2351    Ok(())
2352}
2353
2354fn write_tls_negotiation_review_comparison_markdown(
2355    output: &mut RenderBuffer,
2356    comparison: &TlsNegotiationReviewComparison,
2357) -> Result<(), ComparisonError> {
2358    output.push_str("## Active TLS negotiation review differences\n\n- Schema: ")?;
2359    write_markdown_code_span(output, comparison.schema)?;
2360    output.push_str("\n- Status: ")?;
2361    write_markdown_code_span(output, comparison.status)?;
2362    if let Some(reason) = comparison.reason {
2363        output.push_str("\n- Reason: ")?;
2364        write_markdown_code_span(output, reason)?;
2365    }
2366    output.push_str(
2367        "\n\nThis section compares a validated finite two-connection TLS offer matrix plus three explicit client-backend-unsupported rows. It does not enumerate all server capabilities, authenticate the source, or establish vulnerability or remediation.\n\n",
2368    )?;
2369    for (label, facet) in [
2370        ("Methodology", &comparison.methodology),
2371        ("Coverage", &comparison.coverage),
2372        (
2373            "Capability observations",
2374            &comparison.capability_observations,
2375        ),
2376    ] {
2377        output.push_fmt(format_args!("### TLS negotiation {label}\n\n- Status: "))?;
2378        write_markdown_code_span(output, &facet.status)?;
2379        if !facet.changed_fields.is_empty() {
2380            output.push_str("\n- Changed fields: ")?;
2381            write_markdown_code_span(output, &facet.changed_fields.join(", "))?;
2382        }
2383        output.push_str("\n- Before: ")?;
2384        write_markdown_code_span(output, &display_json(facet.before.as_ref())?)?;
2385        output.push_str("\n- After: ")?;
2386        write_markdown_code_span(output, &display_json(facet.after.as_ref())?)?;
2387        output.push_str("\n- Interpretation: ")?;
2388        write_markdown_code_span(output, facet.note)?;
2389        output.push_str("\n\n")?;
2390    }
2391    output.push_str("### TLS negotiation interpretation limits\n\n")?;
2392    for limit in comparison.interpretation_limits {
2393        output.push_str("- ")?;
2394        write_markdown_code_span(output, limit)?;
2395        output.push_char('\n')?;
2396    }
2397    output.push_char('\n')?;
2398    Ok(())
2399}
2400
2401fn write_websocket_review_comparison_markdown(
2402    output: &mut RenderBuffer,
2403    comparison: &WebSocketReviewComparison,
2404) -> Result<(), ComparisonError> {
2405    output.push_str("## WebSocket review differences\n\n- Schema: ")?;
2406    write_markdown_code_span(output, comparison.schema)?;
2407    output.push_str("\n- Status: ")?;
2408    write_markdown_code_span(output, comparison.status)?;
2409    if let Some(reason) = comparison.reason {
2410        output.push_str("\n- Reason: ")?;
2411        write_markdown_code_span(output, reason)?;
2412    }
2413    if comparison.supplied_session_context.is_some() {
2414        output.push_str(
2415            "\n\nThis section compares strict, value-free projections of one bounded supplied-session WebSocket exchange. It retains no payload, URL, path, query, subprotocol value, mismatched-response digest, token, credential, or raw error. Protocol completion is distinct from startup/terminal session-health qualification, and continuous authentication remains not established. Raw transport-byte fields include HTTP Upgrade/framing overhead and WSS encrypted TLS records; they are not application bytes. The comparison does not establish authenticated identity, authorization, server state, vulnerability, impact, or remediation.\n\n",
2416        )?;
2417    } else {
2418        output.push_str(
2419            "\n\nThis section compares strict, value-free projections of one bounded anonymous WebSocket exchange. It retains no payload, URL, path, query, subprotocol value, mismatched-response digest, token, or raw error. Raw transport-byte fields include HTTP Upgrade/framing overhead and WSS encrypted TLS records; they are not application bytes. The comparison does not establish authorization, server state, vulnerability, impact, or remediation.\n\n",
2420        )?;
2421    }
2422    if let Some(context) = &comparison.supplied_session_context {
2423        output.push_str("### WebSocket supplied-session context\n\n- Status: ")?;
2424        write_markdown_code_span(output, &context.status)?;
2425        if !context.changed_fields.is_empty() {
2426            output.push_str("\n- Changed fields: ")?;
2427            write_markdown_code_span(output, &context.changed_fields.join(", "))?;
2428        }
2429        output.push_str("\n- Before: ")?;
2430        write_markdown_code_span(output, &display_json(context.before.as_ref())?)?;
2431        output.push_str("\n- After: ")?;
2432        write_markdown_code_span(output, &display_json(context.after.as_ref())?)?;
2433        output.push_str("\n- Interpretation: ")?;
2434        write_markdown_code_span(output, context.note)?;
2435        output.push_str("\n\n")?;
2436    }
2437    for (label, facet) in [
2438        ("Methodology", &comparison.methodology),
2439        ("Coverage", &comparison.coverage),
2440        ("Outcome", &comparison.outcome),
2441    ] {
2442        output.push_fmt(format_args!("### WebSocket {label}\n\n- Status: "))?;
2443        write_markdown_code_span(output, &facet.status)?;
2444        if !facet.changed_fields.is_empty() {
2445            output.push_str("\n- Changed fields: ")?;
2446            write_markdown_code_span(output, &facet.changed_fields.join(", "))?;
2447        }
2448        output.push_str("\n- Before: ")?;
2449        write_markdown_code_span(output, &display_json(facet.before.as_ref())?)?;
2450        output.push_str("\n- After: ")?;
2451        write_markdown_code_span(output, &display_json(facet.after.as_ref())?)?;
2452        output.push_str("\n- Interpretation: ")?;
2453        write_markdown_code_span(output, facet.note)?;
2454        output.push_str("\n\n")?;
2455    }
2456    output.push_str("### WebSocket review interpretation limits\n\n")?;
2457    for limit in comparison.interpretation_limits {
2458        output.push_str("- ")?;
2459        write_markdown_code_span(output, limit)?;
2460        output.push_char('\n')?;
2461    }
2462    output.push_char('\n')?;
2463    Ok(())
2464}
2465
2466fn write_jwt_policy_review_comparison_markdown(
2467    output: &mut RenderBuffer,
2468    comparison: &JwtPolicyReviewComparison,
2469) -> Result<(), ComparisonError> {
2470    if comparison.target_selected {
2471        output.push_str("## JWT policy review differences\n\n- Schema: ")?;
2472    } else {
2473        output.push_str("## Local JWT policy review differences\n\n- Schema: ")?;
2474    }
2475    write_markdown_code_span(output, comparison.schema)?;
2476    output.push_str("\n- Status: ")?;
2477    write_markdown_code_span(output, comparison.status)?;
2478    if let Some(reason) = comparison.reason {
2479        output.push_str("\n- Reason: ")?;
2480        write_markdown_code_span(output, reason)?;
2481    }
2482    if comparison.target_selected {
2483        output.push_str(
2484            "\n\nThis section compares validated, value-free local policy projections and bounded target-control projections. It retains no token, claim, signature, key, selected marker name, or raw resource URL and does not establish a general authentication bypass, authorization effect, vulnerability, impact, or remediation.\n\n",
2485        )?;
2486    } else {
2487        output.push_str(
2488            "\n\nThis section compares validated, value-free local JWT audit projections. It does not expose the token, claims, signature, or key; contact a target; or establish target acceptance, authorization, vulnerability, or remediation.\n\n",
2489        )?;
2490    }
2491    for (label, facet) in [
2492        ("Methodology", &comparison.methodology),
2493        ("Coverage", &comparison.coverage),
2494        ("Outcome", &comparison.outcome),
2495    ] {
2496        output.push_fmt(format_args!("### JWT {label}\n\n- Status: "))?;
2497        write_markdown_code_span(output, &facet.status)?;
2498        if !facet.changed_fields.is_empty() {
2499            output.push_str("\n- Changed fields: ")?;
2500            write_markdown_code_span(output, &facet.changed_fields.join(", "))?;
2501        }
2502        output.push_str("\n- Before: ")?;
2503        write_markdown_code_span(output, &display_json(facet.before.as_ref())?)?;
2504        output.push_str("\n- After: ")?;
2505        write_markdown_code_span(output, &display_json(facet.after.as_ref())?)?;
2506        output.push_str("\n- Interpretation: ")?;
2507        write_markdown_code_span(output, facet.note)?;
2508        output.push_str("\n\n")?;
2509    }
2510    output.push_str("### JWT policy review interpretation limits\n\n")?;
2511    for limit in comparison.interpretation_limits {
2512        output.push_str("- ")?;
2513        write_markdown_code_span(output, limit)?;
2514        output.push_char('\n')?;
2515    }
2516    output.push_char('\n')?;
2517    Ok(())
2518}
2519
2520fn write_xml_external_entity_review_comparison_markdown(
2521    output: &mut RenderBuffer,
2522    comparison: &XmlExternalEntityReviewComparison,
2523) -> Result<(), ComparisonError> {
2524    output.push_str("## Controlled XML external-entity review differences\n\n- Schema: ")?;
2525    write_markdown_code_span(output, comparison.schema)?;
2526    output.push_str("\n- Status: ")?;
2527    write_markdown_code_span(output, comparison.status)?;
2528    if let Some(reason) = comparison.reason {
2529        output.push_str("\n- Reason: ")?;
2530        write_markdown_code_span(output, reason)?;
2531    }
2532    output.push_str(
2533        "\n\nThis section compares validated, value-free projections of one bounded callback-control review. It retains no endpoint, provider, callback, XML, token, path, response-body, or raw-error values. It does not confirm XXE, identify a parser, establish file access, data exfiltration, internal reachability, impact, or remediation.\n\n",
2534    )?;
2535    for (label, facet) in [
2536        ("Methodology", &comparison.methodology),
2537        ("Coverage", &comparison.coverage),
2538        ("Outcome", &comparison.outcome),
2539    ] {
2540        output.push_fmt(format_args!("### XML review {label}\n\n- Status: "))?;
2541        write_markdown_code_span(output, &facet.status)?;
2542        if !facet.changed_fields.is_empty() {
2543            output.push_str("\n- Changed fields: ")?;
2544            write_markdown_code_span(output, &facet.changed_fields.join(", "))?;
2545        }
2546        output.push_str("\n- Before: ")?;
2547        write_markdown_code_span(output, &display_json(facet.before.as_ref())?)?;
2548        output.push_str("\n- After: ")?;
2549        write_markdown_code_span(output, &display_json(facet.after.as_ref())?)?;
2550        output.push_str("\n- Interpretation: ")?;
2551        write_markdown_code_span(output, facet.note)?;
2552        output.push_str("\n\n")?;
2553    }
2554    output.push_str("### XML external-entity review interpretation limits\n\n")?;
2555    for limit in comparison.interpretation_limits {
2556        output.push_str("- ")?;
2557        write_markdown_code_span(output, limit)?;
2558        output.push_char('\n')?;
2559    }
2560    output.push_char('\n')?;
2561    Ok(())
2562}
2563
2564fn write_template_evaluation_review_comparison_markdown(
2565    output: &mut RenderBuffer,
2566    comparison: &TemplateEvaluationReviewComparison,
2567) -> Result<(), ComparisonError> {
2568    output.push_str("## Benign template-evaluation review differences\n\n- Schema: ")?;
2569    write_markdown_code_span(output, comparison.schema)?;
2570    output.push_str("\n- Status: ")?;
2571    write_markdown_code_span(output, comparison.status)?;
2572    if let Some(reason) = comparison.reason {
2573        output.push_str("\n- Reason: ")?;
2574        write_markdown_code_span(output, reason)?;
2575    }
2576    output.push_str(
2577        "\n\nThis section compares validated, value-free projections of two bounded benign expression cases. It retains no expression, query value, response body, template source, engine banner, file path, command, or raw error. It does not identify a template engine or establish operating-system execution, file access, outbound interaction, impact, vulnerability, or remediation.\n\n",
2578    )?;
2579    for (label, facet) in [
2580        ("Methodology", &comparison.methodology),
2581        ("Coverage", &comparison.coverage),
2582        ("Outcome", &comparison.outcome),
2583    ] {
2584        output.push_fmt(format_args!(
2585            "### Template-evaluation {label}\n\n- Status: "
2586        ))?;
2587        write_markdown_code_span(output, &facet.status)?;
2588        if !facet.changed_fields.is_empty() {
2589            output.push_str("\n- Changed fields: ")?;
2590            write_markdown_code_span(output, &facet.changed_fields.join(", "))?;
2591        }
2592        output.push_str("\n- Before: ")?;
2593        write_markdown_code_span(output, &display_json(facet.before.as_ref())?)?;
2594        output.push_str("\n- After: ")?;
2595        write_markdown_code_span(output, &display_json(facet.after.as_ref())?)?;
2596        output.push_str("\n- Interpretation: ")?;
2597        write_markdown_code_span(output, facet.note)?;
2598        output.push_str("\n\n")?;
2599    }
2600    output.push_str("### Template-evaluation interpretation limits\n\n")?;
2601    for limit in comparison.interpretation_limits {
2602        output.push_str("- ")?;
2603        write_markdown_code_span(output, limit)?;
2604        output.push_char('\n')?;
2605    }
2606    output.push_char('\n')?;
2607    Ok(())
2608}
2609
2610fn write_control_reference_mapping_comparison_markdown(
2611    output: &mut RenderBuffer,
2612    comparison: &ControlReferenceMappingComparison,
2613) -> Result<(), ComparisonError> {
2614    output.push_str("## Control-reference mapping differences\n\n- Schema: ")?;
2615    write_markdown_code_span(output, comparison.schema)?;
2616    output.push_str("\n- Status: ")?;
2617    write_markdown_code_span(output, comparison.status)?;
2618    if let Some(reason) = comparison.reason {
2619        output.push_str("\n- Reason: ")?;
2620        write_markdown_code_span(output, reason)?;
2621    }
2622    output.push_str(
2623        "\n\nThis section compares a validated offline reference projection over already composed items. Catalogue, source, coverage, and relationship changes are methodology/reference-set differences, not target changes, remediation, compliance, certification, or legal conclusions.\n\n",
2624    )?;
2625    for (label, facet) in [
2626        ("Methodology", &comparison.methodology),
2627        ("Coverage", &comparison.coverage),
2628        ("Reference set", &comparison.reference_set),
2629    ] {
2630        output.push_fmt(format_args!("### Control-reference {label}\n\n- Status: "))?;
2631        write_markdown_code_span(output, &facet.status)?;
2632        if !facet.changed_fields.is_empty() {
2633            output.push_str("\n- Changed fields: ")?;
2634            write_markdown_code_span(output, &facet.changed_fields.join(", "))?;
2635        }
2636        output.push_str("\n- Before: ")?;
2637        write_markdown_code_span(output, &display_json(facet.before.as_ref())?)?;
2638        output.push_str("\n- After: ")?;
2639        write_markdown_code_span(output, &display_json(facet.after.as_ref())?)?;
2640        output.push_str("\n- Interpretation: ")?;
2641        write_markdown_code_span(output, facet.note)?;
2642        output.push_str("\n\n")?;
2643    }
2644    output.push_str("### Control-reference mapping interpretation limits\n\n")?;
2645    for limit in comparison.interpretation_limits {
2646        output.push_str("- ")?;
2647        write_markdown_code_span(output, limit)?;
2648        output.push_char('\n')?;
2649    }
2650    output.push_char('\n')?;
2651    Ok(())
2652}
2653
2654fn write_recon_snapshot_import_comparison_markdown(
2655    output: &mut RenderBuffer,
2656    comparison: &ReconSnapshotImportComparison,
2657) -> Result<(), ComparisonError> {
2658    output.push_str("## Reconnaissance snapshot import differences\n\n- Schema: ")?;
2659    write_markdown_code_span(output, comparison.schema)?;
2660    output.push_str("\n- Status: ")?;
2661    write_markdown_code_span(output, comparison.status)?;
2662    if let Some(reason) = comparison.reason {
2663        output.push_str("\n- Reason: ")?;
2664        write_markdown_code_span(output, reason)?;
2665    }
2666    output.push_str("\n\nThis section compares validated inert local snapshot projections. Source, coverage, and hypothesis changes are imported-context differences, not target changes, findings, authorization, vulnerability, or remediation.\n\n")?;
2667    for (label, facet) in [
2668        ("Methodology", &comparison.methodology),
2669        ("Provenance and sources", &comparison.provenance_and_sources),
2670        (
2671            "Coverage and accounting",
2672            &comparison.coverage_and_accounting,
2673        ),
2674        ("Hypotheses", &comparison.hypotheses),
2675    ] {
2676        output.push_fmt(format_args!("### Recon snapshot {label}\n\n- Status: "))?;
2677        write_markdown_code_span(output, &facet.status)?;
2678        if !facet.changed_fields.is_empty() {
2679            output.push_str("\n- Changed fields: ")?;
2680            write_markdown_code_span(output, &facet.changed_fields.join(", "))?;
2681        }
2682        output.push_str("\n- Before: ")?;
2683        write_markdown_code_span(output, &display_json(facet.before.as_ref())?)?;
2684        output.push_str("\n- After: ")?;
2685        write_markdown_code_span(output, &display_json(facet.after.as_ref())?)?;
2686        output.push_str("\n- Interpretation: ")?;
2687        write_markdown_code_span(output, facet.note)?;
2688        output.push_str("\n\n")?;
2689    }
2690    output.push_str("### Reconnaissance snapshot interpretation limits\n\n")?;
2691    for limit in comparison.interpretation_limits {
2692        output.push_str("- ")?;
2693        write_markdown_code_span(output, limit)?;
2694        output.push_char('\n')?;
2695    }
2696    output.push_char('\n')?;
2697    Ok(())
2698}
2699
2700fn write_recon_certspotter_comparison_markdown(
2701    output: &mut RenderBuffer,
2702    comparison: &ReconCertSpotterComparison,
2703) -> Result<(), ComparisonError> {
2704    output.push_str("## Cert Spotter provider differences\n\n- Schema: ")?;
2705    write_markdown_code_span(output, comparison.schema)?;
2706    output.push_str("\n- Status: ")?;
2707    write_markdown_code_span(output, comparison.status)?;
2708    if let Some(reason) = comparison.reason {
2709        output.push_str("\n- Reason: ")?;
2710        write_markdown_code_span(output, reason)?;
2711    }
2712    output.push_str("\n\nThis section compares validated bounded provider projections. Provider, methodology, coverage, and source-qualified hypothesis changes are saved-report context differences, not findings, scan authority, source authentication, ownership, currentness, vulnerability, or remediation.\n\n")?;
2713    for (label, facet) in [
2714        ("Provider", &comparison.provider),
2715        ("Methodology", &comparison.methodology),
2716        ("Coverage", &comparison.coverage),
2717        ("Source-qualified hypotheses", &comparison.hypotheses),
2718    ] {
2719        output.push_fmt(format_args!("### Cert Spotter {label}\n\n- Status: "))?;
2720        write_markdown_code_span(output, &facet.status)?;
2721        if !facet.changed_fields.is_empty() {
2722            output.push_str("\n- Changed fields: ")?;
2723            write_markdown_code_span(output, &facet.changed_fields.join(", "))?;
2724        }
2725        output.push_str("\n- Before: ")?;
2726        write_markdown_code_span(output, &display_json(facet.before.as_ref())?)?;
2727        output.push_str("\n- After: ")?;
2728        write_markdown_code_span(output, &display_json(facet.after.as_ref())?)?;
2729        output.push_str("\n- Interpretation: ")?;
2730        write_markdown_code_span(output, facet.note)?;
2731        output.push_str("\n\n")?;
2732    }
2733    output.push_str("### Cert Spotter interpretation limits\n\n")?;
2734    for limit in comparison.interpretation_limits {
2735        output.push_str("- ")?;
2736        write_markdown_code_span(output, limit)?;
2737        output.push_char('\n')?;
2738    }
2739    output.push_char('\n')?;
2740    Ok(())
2741}
2742
2743fn write_wordpress_comparison_markdown(
2744    output: &mut RenderBuffer,
2745    comparison: &WordPressReviewComparison,
2746) -> Result<(), ComparisonError> {
2747    output.push_str("## WordPress review differences\n\n")?;
2748    output.push_str("- Schema: ")?;
2749    write_markdown_code_span(output, comparison.schema)?;
2750    output.push_str("\n- Status: ")?;
2751    write_markdown_code_span(output, comparison.status)?;
2752    if let Some(reason) = comparison.reason {
2753        output.push_str("\n- Reason: ")?;
2754        write_markdown_code_span(output, reason)?;
2755    }
2756    output.push_str("\n- Scope assurance: ")?;
2757    write_markdown_code_span(output, comparison.scope_assurance)?;
2758    output.push_str(
2759        "\n\nThis section compares validated display-only WordPress audit projections. It does not rerun the scan or establish remediation, newness, or causation.\n\n",
2760    )?;
2761    for (label, facet) in [
2762        ("Declared coverage", &comparison.coverage),
2763        ("Methodology", &comparison.methodology),
2764        ("Provenance", &comparison.provenance),
2765    ] {
2766        output.push_fmt(format_args!("### {label}\n\n- Status: "))?;
2767        write_markdown_code_span(output, &facet.status)?;
2768        if !facet.changed_fields.is_empty() {
2769            output.push_str("\n- Changed fields: ")?;
2770            write_markdown_code_span(output, &facet.changed_fields.join(", "))?;
2771        }
2772        output.push_str("\n- Before: ")?;
2773        write_markdown_code_span(output, &display_json(facet.before.as_ref())?)?;
2774        output.push_str("\n- After: ")?;
2775        write_markdown_code_span(output, &display_json(facet.after.as_ref())?)?;
2776        output.push_str("\n- Interpretation: ")?;
2777        write_markdown_code_span(output, facet.note)?;
2778        output.push_str("\n\n")?;
2779    }
2780    if let Some(facet) = &comparison.discovery_source_content {
2781        output.push_str("### Discovery source content\n\n- Status: ")?;
2782        write_markdown_code_span(output, &facet.status)?;
2783        if !facet.changed_fields.is_empty() {
2784            output.push_str("\n- Changed fields: ")?;
2785            write_markdown_code_span(output, &facet.changed_fields.join(", "))?;
2786        }
2787        output.push_str("\n- Before: ")?;
2788        write_markdown_code_span(output, &display_json(facet.before.as_ref())?)?;
2789        output.push_str("\n- After: ")?;
2790        write_markdown_code_span(output, &display_json(facet.after.as_ref())?)?;
2791        output.push_str("\n- Interpretation: ")?;
2792        write_markdown_code_span(output, facet.note)?;
2793        output.push_str("\n\n")?;
2794    }
2795    if let Some(fingerprints) = &comparison.asset_fingerprints {
2796        output.push_str("### Asset fingerprint candidates\n\n- Status: ")?;
2797        write_markdown_code_span(output, fingerprints.status)?;
2798        if let Some(reason) = fingerprints.reason {
2799            output.push_str("\n- Reason: ")?;
2800            write_markdown_code_span(output, reason)?;
2801        }
2802        output.push_str("\n\n")?;
2803        for (label, facet) in [
2804            ("Fingerprint methodology", &fingerprints.methodology),
2805            ("Finite reference catalogue", &fingerprints.catalogue),
2806            ("Resource coverage", &fingerprints.coverage),
2807        ] {
2808            output.push_fmt(format_args!("#### {label}\n\n- Status: "))?;
2809            write_markdown_code_span(output, &facet.status)?;
2810            if !facet.changed_fields.is_empty() {
2811                output.push_str("\n- Changed fields: ")?;
2812                write_markdown_code_span(output, &facet.changed_fields.join(", "))?;
2813            }
2814            output.push_str("\n- Before: ")?;
2815            write_markdown_code_span(output, &display_json(facet.before.as_ref())?)?;
2816            output.push_str("\n- After: ")?;
2817            write_markdown_code_span(output, &display_json(facet.after.as_ref())?)?;
2818            output.push_str("\n- Interpretation: ")?;
2819            write_markdown_code_span(output, facet.note)?;
2820            output.push_str("\n\n")?;
2821        }
2822        write_wordpress_entity_changes_markdown(
2823            output,
2824            "Fingerprint resources",
2825            &fingerprints.resources,
2826        )?;
2827        write_wordpress_entity_changes_markdown(
2828            output,
2829            "Fingerprint candidate sets",
2830            &fingerprints.components,
2831        )?;
2832        output.push_str("#### Fingerprint interpretation limits\n\n")?;
2833        for limit in fingerprints.interpretation_limits {
2834            output.push_str("- ")?;
2835            write_markdown_code_span(output, limit)?;
2836            output.push_char('\n')?;
2837        }
2838        output.push_char('\n')?;
2839    }
2840    write_wordpress_entity_changes_markdown(output, "Components", &comparison.components)?;
2841    write_wordpress_entity_changes_markdown(output, "Advisories", &comparison.advisories)?;
2842    output.push_str("### Interpretation limits\n\n")?;
2843    for limit in comparison.interpretation_limits {
2844        output.push_str("- ")?;
2845        write_markdown_code_span(output, limit)?;
2846        output.push_char('\n')?;
2847    }
2848    output.push_char('\n')?;
2849    Ok(())
2850}
2851
2852fn write_wordpress_entity_changes_markdown<K: Serialize>(
2853    output: &mut RenderBuffer,
2854    label: &str,
2855    changes: &WordPressEntityChanges<K>,
2856) -> Result<(), ComparisonError> {
2857    output.push_fmt(format_args!("### {label}\n\n"))?;
2858    output.push_fmt(format_args!(
2859        "- Paired unchanged: {}\n- Paired changed: {}\n- Only in supplied before audit: {}\n- Only in supplied after audit: {}\n\n",
2860        changes.paired_unchanged_count,
2861        changes.paired_changed.len(),
2862        changes.only_in_before.len(),
2863        changes.only_in_after.len(),
2864    ))?;
2865    for change in &changes.paired_changed {
2866        output.push_str("#### Paired identity with changed content\n\n- Key: ")?;
2867        write_markdown_code_span(output, &display_serializable(&change.key)?)?;
2868        output.push_str("\n- Changed dimensions: ")?;
2869        write_markdown_code_span(output, &change.changed_dimensions.join(", "))?;
2870        output.push_str("\n- Before: ")?;
2871        write_markdown_code_span(output, &display_serializable(&change.before)?)?;
2872        output.push_str("\n- After: ")?;
2873        write_markdown_code_span(output, &display_serializable(&change.after)?)?;
2874        output.push_str("\n\n")?;
2875    }
2876    for (side, entities) in [
2877        ("before", &changes.only_in_before),
2878        ("after", &changes.only_in_after),
2879    ] {
2880        for entity in entities {
2881            output.push_fmt(format_args!(
2882                "#### Present only in the supplied {side} audit\n\n- Key: "
2883            ))?;
2884            write_markdown_code_span(output, &display_serializable(&entity.key)?)?;
2885            output.push_str("\n- Content: ")?;
2886            write_markdown_code_span(output, &display_serializable(&entity.content)?)?;
2887            output.push_str("\n- Interpretation: ")?;
2888            write_markdown_code_span(output, entity.interpretation)?;
2889            output.push_str("\n\n")?;
2890        }
2891    }
2892    Ok(())
2893}
2894
2895fn display_json(value: Option<&Value>) -> Result<String, ComparisonError> {
2896    value.map_or_else(
2897        || Ok("not recorded".to_owned()),
2898        |value| serde_json::to_string(value).map_err(|_| ComparisonError::Serialization),
2899    )
2900}
2901
2902fn display_serializable(value: &impl Serialize) -> Result<String, ComparisonError> {
2903    serde_json::to_string(value).map_err(|_| ComparisonError::Serialization)
2904}
2905
2906fn write_projection(
2907    output: &mut RenderBuffer,
2908    projection: &impl Serialize,
2909) -> Result<(), ComparisonError> {
2910    let value = serde_json::to_value(projection).map_err(|_| ComparisonError::Serialization)?;
2911    let fields = value.as_object().ok_or(ComparisonError::Serialization)?;
2912    for (field, value) in fields {
2913        output.push_str("- ")?;
2914        write_markdown_code_span(output, field)?;
2915        output.push_str(": ")?;
2916        let text = match value {
2917            Value::String(text) => text.clone(),
2918            _ => value.to_string(),
2919        };
2920        write_markdown_code_span(output, &text)?;
2921        output.push_char('\n')?;
2922    }
2923    output.push_char('\n')?;
2924    Ok(())
2925}