//! Structured diagnostic bundle and the redaction pass over it. //! //! Mirrors `docs/security/diagnostic-redaction-audit-report.md` §5 //! "Export Bundle Contents". Fields default to safe values; sensitive //! categories are excluded by default. use serde::{Deserialize, Serialize}; use crate::redactor::Redactor; use crate::REDACTION_MARKER; /// Raw bundle assembled by the application before redaction. The /// caller MUST pass this through `Redactor::redact_bundle` before any /// disk write or user-visible export. #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct DiagnosticBundle { pub app_version: String, pub build_number: String, pub platform_info: String, pub connection_state: String, pub server_address: Option, pub channel_tree: Vec, pub chat_history: Vec, pub log_lines: Vec, pub audio_device_names: Vec, /// Free-form extras keyed by field name. Sensitive field names /// (matched against `RedactionPolicy::redact_field_substrings`) /// are redacted wholesale; everything else flows through /// `redact_text` for value-level scrubbing. pub extras: std::collections::BTreeMap, } /// Final, redacted bundle suitable for export. Produced by /// `Redactor::redact_bundle`. The shape is intentionally the same /// type so callers can re-serialize as JSON without conversion. pub type RedactedBundle = DiagnosticBundle; impl Redactor { /// Apply the redaction policy to `bundle`, returning a copy safe /// for export. pub fn redact_bundle(&self, bundle: &DiagnosticBundle) -> RedactedBundle { let policy = self.policy(); let chat_history = if policy.include_chat { bundle .chat_history .iter() .map(|m| self.redact_text(m)) .collect() } else { // Chat is excluded by default (audit report §5 / REDACT-TC-004). Vec::new() }; let channel_tree = if policy.include_channel_tree { bundle .channel_tree .iter() .map(|c| self.redact_text(c)) .collect() } else { Vec::new() }; let mut redacted_extras = std::collections::BTreeMap::new(); for (k, v) in &bundle.extras { if self.is_sensitive_field(k) { redacted_extras.insert(k.clone(), REDACTION_MARKER.to_string()); } else { redacted_extras.insert(k.clone(), self.redact_text(v)); } } RedactedBundle { app_version: bundle.app_version.clone(), build_number: bundle.build_number.clone(), // Platform info gets a soft scrub (paths, usernames). platform_info: self.redact_text(&bundle.platform_info), connection_state: self.redact_text(&bundle.connection_state), server_address: bundle.server_address.as_ref().map(|s| self.redact_text(s)), channel_tree, chat_history, log_lines: bundle .log_lines .iter() .map(|l| self.redact_text(l)) .collect(), audio_device_names: bundle .audio_device_names .iter() .map(|d| self.redact_text(d)) .collect(), extras: redacted_extras, } } }