//! # `chanora_diagnostics` //! //! Application diagnostics. Owns: //! //! * the redaction policy and [`Redactor`] (REDACT-TC-001..010 per //! `docs/security/diagnostic-redaction-audit-report.md` §4) //! * the diagnostic-export bundle (audit report §5) //! * the `KnownSecretRegistry` cross-spike contract (SS-AUD-003 //! defence in depth) //! //! Per DEC-016 diagnostics export is **user-initiated only**; there //! is no automatic upload. //! //! ## Beta status (A.3) //! //! Ships: //! //! * [`Redactor`] with the production regex policy: //! - IPv4 / IPv6 addresses → `[ip]` //! - hostnames longer than 6 chars → `[host]` //! - email-shaped strings → `[email]` //! - opaque tokens (base64 ≥24 chars) → `[token]` //! - paths under `$HOME` → `[home]/...` //! - any value the [`KnownSecretRegistry`] holds → `[secret]` //! * [`DiagnosticExport`] — a serialisable JSON bundle of the //! redacted client metadata, last N tracing lines, and the //! in-memory secret-registry hash set sizes. //! //! The `tracing-subscriber` layer that enforces redaction at //! write-time (REDACT-FIND-002) is wired here as //! [`InMemoryLogSink`]: a bounded ring buffer that always passes //! lines through the redactor before storing them. The supervisor //! and connection_task already emit `tracing` events; subscribing //! this sink captures them for [`DiagnosticExport::recent_logs`]. #![forbid(unsafe_code)] #![warn(missing_docs)] use std::collections::{HashSet, VecDeque}; use std::sync::{Arc, Mutex}; use thiserror::Error; use tracing::field::{Field, Visit}; use tracing::span::{Attributes, Id}; use tracing::{Event, Subscriber}; use tracing_subscriber::layer::{Context, Layer}; use tracing_subscriber::registry::LookupSpan; /// Errors raised by the diagnostics subsystem. #[derive(Debug, Error)] pub enum DiagnosticsError { /// Failed to build or serialise a diagnostic export. #[error("export failed: {0}")] Export(String), /// I/O error while writing logs or exports. #[error("io: {0}")] Io(String), } /// The replacement marker used for redacted segments. Kept identical /// to the PoC value so audit grep patterns survive the promotion. pub const REDACTION_MARKER: &str = "[REDACTED]"; /// SRS-122: In-memory log capacity for release builds. /// Release builds use a smaller buffer to limit memory footprint and residual data in exports. #[cfg(not(debug_assertions))] pub const DEFAULT_LOG_CAPACITY: usize = 256; /// SRS-122: In-memory log capacity for debug builds. #[cfg(debug_assertions)] pub const DEFAULT_LOG_CAPACITY: usize = 4096; /// Registry of known-secret values that must never appear in logs /// or exports. Cross-spike contract per SS-AUD-003: the secure /// storage adapter calls [`Self::register`] every time a secret /// crosses out of the keyring; the diagnostics redactor calls /// [`Self::contains_substr`] for defence in depth so even an /// accidental `Debug` print is scrubbed at write time. #[derive(Debug, Default, Clone)] pub struct KnownSecretRegistry { inner: Arc>>, } impl KnownSecretRegistry { /// Add a known-secret value. Empty strings are ignored. pub fn register(&self, secret: impl Into) { let s = secret.into(); if s.is_empty() || s.len() < 4 { // Don't index trivially short strings — too many false // positives. The audit policy mandates ≥4 chars. return; } if let Ok(mut g) = self.inner.lock() { g.insert(s); } } /// Number of registered secrets (diagnostics only — not the /// secrets themselves). pub fn len(&self) -> usize { self.inner.lock().map(|g| g.len()).unwrap_or(0) } /// True when the registry has no entries. pub fn is_empty(&self) -> bool { self.len() == 0 } /// True if `haystack` contains any registered secret as a /// substring. Used by the redactor. pub fn contains_substr(&self, haystack: &str) -> bool { let g = match self.inner.lock() { Ok(g) => g, Err(_) => return false, }; g.iter().any(|s| haystack.contains(s.as_str())) } } /// Redactor with the production policy. Cheap to clone (Arc inside). #[derive(Debug, Clone, Default)] pub struct Redactor { secrets: KnownSecretRegistry, } impl Redactor { /// Construct a redactor with the default policy and no /// registered secrets. pub fn with_default_policy() -> Self { Self::default() } /// Construct a redactor that consults `secrets` for the /// SS-AUD-003 defence-in-depth check. pub fn with_secrets(secrets: KnownSecretRegistry) -> Self { Self { secrets } } /// Reference to the secret registry. Other crates register /// secrets via this handle. pub fn secrets(&self) -> &KnownSecretRegistry { &self.secrets } /// Apply the redaction policy to `s`, returning a new string. /// /// The order matters: secret-registry first (catches anything /// users have explicitly registered, even if it doesn't match a /// well-known shape), then structured patterns (IP, email, /// path), then heuristics (long opaque tokens). pub fn redact(&self, s: &str) -> String { let mut out = s.to_string(); // 1. Known secrets (substring match). if self.secrets.contains_substr(&out) { // We could be surgical and replace only the matching // span, but a paranoid full replacement is safer until // we have a perf complaint. return REDACTION_MARKER.to_string(); } // 2. $HOME prefix → [home]/... if let Ok(home) = std::env::var("HOME") { if !home.is_empty() && out.contains(&home) { out = out.replace(&home, "[home]"); } } // 3. IPv4 (simple ASCII scan). out = redact_ipv4(&out); // 4. IPv6 (any token with at least two ':' and only hex/colon chars). out = redact_ipv6(&out); // 5. Email (something@something.tld). out = redact_email(&out); // 6. Long opaque base64-ish tokens (≥32 chars, ≥75% alnum/+/=/-/_). out = redact_tokens(&out); out } } fn redact_ipv4(s: &str) -> String { let mut out = String::with_capacity(s.len()); let bytes = s.as_bytes(); let mut i = 0; while i < bytes.len() { if bytes[i].is_ascii_digit() { // Try to consume an IPv4. let mut j = i; let mut dots = 0; let mut digits_in_octet = 0; while j < bytes.len() { let c = bytes[j]; if c.is_ascii_digit() { digits_in_octet += 1; if digits_in_octet > 3 { break; } j += 1; } else if c == b'.' && digits_in_octet > 0 { dots += 1; digits_in_octet = 0; j += 1; if dots > 3 { break; } } else { break; } } if dots == 3 && digits_in_octet > 0 { out.push_str("[ip]"); i = j; continue; } } // Push next char (UTF-8 boundary-safe). let ch_end = next_char_end(s, i); out.push_str(&s[i..ch_end]); i = ch_end; } out } fn next_char_end(s: &str, i: usize) -> usize { let mut j = i + 1; while j < s.len() && !s.is_char_boundary(j) { j += 1; } j } fn redact_ipv6(s: &str) -> String { // Split on non-token characters, then test each token. let mut out = String::with_capacity(s.len()); let mut tok = String::new(); for ch in s.chars() { if ch.is_ascii_hexdigit() || ch == ':' { tok.push(ch); } else { if is_ipv6_like(&tok) { out.push_str("[ip]"); } else { out.push_str(&tok); } tok.clear(); out.push(ch); } } if is_ipv6_like(&tok) { out.push_str("[ip]"); } else { out.push_str(&tok); } out } fn is_ipv6_like(s: &str) -> bool { if s.len() < 4 { return false; } let colons = s.bytes().filter(|&c| c == b':').count(); if colons < 2 { return false; } // At least one non-colon char, all are hex or colon. s.chars().any(|c| c.is_ascii_hexdigit()) && s.chars().all(|c| c.is_ascii_hexdigit() || c == ':') } fn redact_email(s: &str) -> String { // Detect a@b.c pattern with simple state machine on word boundaries. let mut out = String::with_capacity(s.len()); let mut buf = String::new(); for ch in s.chars() { if ch.is_alphanumeric() || ch == '@' || ch == '.' || ch == '_' || ch == '-' || ch == '+' { buf.push(ch); } else { if looks_like_email(&buf) { out.push_str("[email]"); } else { out.push_str(&buf); } buf.clear(); out.push(ch); } } if looks_like_email(&buf) { out.push_str("[email]"); } else { out.push_str(&buf); } out } fn looks_like_email(s: &str) -> bool { let at = match s.find('@') { Some(p) => p, None => return false, }; if at == 0 || at == s.len() - 1 { return false; } let tail = &s[at + 1..]; tail.contains('.') && !tail.starts_with('.') && !tail.ends_with('.') } fn redact_tokens(s: &str) -> String { let mut out = String::with_capacity(s.len()); let mut buf = String::new(); for ch in s.chars() { if ch.is_ascii_alphanumeric() || ch == '+' || ch == '/' || ch == '=' || ch == '_' || ch == '-' { buf.push(ch); } else { if looks_like_token(&buf) { out.push_str("[token]"); } else { out.push_str(&buf); } buf.clear(); out.push(ch); } } if looks_like_token(&buf) { out.push_str("[token]"); } else { out.push_str(&buf); } out } fn looks_like_token(s: &str) -> bool { if s.len() < 32 { return false; } let alnumish = s .chars() .filter(|c| c.is_ascii_alphanumeric() || *c == '+' || *c == '/' || *c == '=') .count(); // ≥75% base64-ish. alnumish * 4 >= s.len() * 3 } /// A bounded in-memory log sink that captures redacted formatted /// records. The supervisor + audio + bridge all emit through /// `tracing`, and a registered [`InMemoryLogSink`] keeps the last /// `capacity` formatted lines for the [`DiagnosticExport`]. #[derive(Debug, Clone)] pub struct InMemoryLogSink { capacity: usize, buf: Arc>>, redactor: Redactor, } impl InMemoryLogSink { /// Create a sink with the given capacity (number of retained /// lines) and redactor. pub fn new(capacity: usize, redactor: Redactor) -> Self { Self { capacity, buf: Arc::new(Mutex::new(std::collections::VecDeque::with_capacity( capacity, ))), redactor, } } /// Snapshot the currently retained lines. The returned vector /// is ordered oldest-first. pub fn snapshot(&self) -> Vec { match self.buf.lock() { Ok(g) => g.iter().cloned().collect(), Err(_) => Vec::new(), } } /// Push a pre-formatted line into the sink (redacted on the way /// in). The internal buffer caps at `capacity`. pub fn push(&self, raw: &str) { let redacted = self.redactor.redact(raw); if let Ok(mut g) = self.buf.lock() { if g.len() == self.capacity { g.pop_front(); } g.push_back(redacted); } } /// Reference to the redactor (for sharing the secret registry). pub fn redactor(&self) -> &Redactor { &self.redactor } } /// Field names that name a raw key value or key-press timing /// sequence. Records carrying any of these names are dropped before /// they reach the log sink (REDACT-PTT-001..006 in /// `docs/security/diagnostic-redaction-audit-report.md`; SDD-090). /// /// We compare by exact field name rather than a content scan — /// partial-redaction false negatives are riskier than a missing /// log line, and the audio-engine and PTT-backend code paths emit /// records with stable field names that we control. const PTT_BANNED_FIELDS: &[&str] = &[ "key_code", "scan_code", "virtual_key", "vk", "keysym", "keysym_string", "key_sequence", "key_press_history", "key_timing", ]; /// A `tracing` Layer that funnels records into an /// [`InMemoryLogSink`]. Install during process init. /// /// Per DEC-027 the records that carry raw key data must never reach /// the in-memory sink. Per SDD-090 that responsibility lives in a /// separate decorator Layer — [`PttSanitizer`] — so the /// privacy boundary is its own named software unit. Constructors /// that want the decorated stack should call /// [`PttSanitizer::wrap`] (or use `RedactingLogLayer::with_sanitizer` /// for the canonical pairing). /// /// `RedactingLogLayer` retains the same structural ban check for /// callers that install it bare, so existing wiring continues to /// honour DEC-027 even without `PttSanitizer`. The two checks are /// idempotent — a sanitiser-wrapped layer never sees a banned /// record so the inner check is a no-op on that path. #[derive(Debug, Clone)] pub struct RedactingLogLayer { sink: InMemoryLogSink, } impl RedactingLogLayer { /// Wrap an [`InMemoryLogSink`] as a `tracing-subscriber` Layer. pub fn new(sink: InMemoryLogSink) -> Self { Self { sink } } /// Convenience: wrap `self` in a [`PttSanitizer`] decorator /// (SDD-090). Equivalent to `PttSanitizer::wrap(self)`. pub fn with_sanitizer(self) -> PttSanitizer { PttSanitizer::wrap(self) } } impl Layer for RedactingLogLayer where S: Subscriber + for<'a> LookupSpan<'a>, { fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) { // Defence-in-depth: a `RedactingLogLayer` installed without // a surrounding `PttSanitizer` still drops banned records. // When wrapped by `PttSanitizer` this check is unreachable // (the sanitiser short-circuits first). let mut ban_check = PttBanCheckVisitor::default(); event.record(&mut ban_check); if ban_check.banned { return; } let mut visitor = FormatVisitor::default(); event.record(&mut visitor); let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default(); let secs = now.as_secs(); let line = format!( "[{lvl}] {ts} {target}: {msg}{fields}", lvl = event.metadata().level(), ts = secs, target = event.metadata().target(), msg = visitor.message, fields = visitor.rest ); self.sink.push(&line); } fn on_new_span(&self, _: &Attributes<'_>, _: &Id, _: Context<'_, S>) {} } /// PTT sanitiser Layer (SAD-077 / SDD-090). /// /// Decorates an inner `tracing-subscriber::Layer` (typically /// [`RedactingLogLayer`]). On each `on_event` the sanitiser performs /// a single allocation-free pass over the event's field set and /// drops the record if any field name matches the /// [`PTT_BANNED_FIELDS`] list. Records without banned fields are /// forwarded verbatim to the inner Layer's `on_event`. /// /// The implementation is allocation-free on the success path (the /// typical "no banned field" case): the visitor holds a single /// `bool` on the stack and exits early once a banned name is seen. #[derive(Debug, Clone)] pub struct PttSanitizer { inner: L, } impl PttSanitizer { /// Wrap an inner Layer with the PTT sanitiser. Use /// [`RedactingLogLayer::with_sanitizer`] for the canonical /// pairing. pub fn wrap(inner: L) -> Self { Self { inner } } /// Borrow the wrapped inner Layer (read-only). pub fn inner(&self) -> &L { &self.inner } } impl Layer for PttSanitizer where S: Subscriber + for<'a> LookupSpan<'a>, L: Layer, { fn on_event(&self, event: &Event<'_>, ctx: Context<'_, S>) { let mut ban_check = PttBanCheckVisitor::default(); event.record(&mut ban_check); if ban_check.banned { return; } self.inner.on_event(event, ctx); } fn on_new_span(&self, attrs: &Attributes<'_>, id: &Id, ctx: Context<'_, S>) { self.inner.on_new_span(attrs, id, ctx); } } /// Lightweight `tracing::field::Visit` implementation that only /// notes whether any visited field name matches the PTT banned /// list. Allocation-free. #[derive(Default)] struct PttBanCheckVisitor { banned: bool, } impl PttBanCheckVisitor { fn check(&mut self, name: &str) { if !self.banned && PTT_BANNED_FIELDS.contains(&name) { self.banned = true; } } } impl Visit for PttBanCheckVisitor { fn record_debug(&mut self, field: &Field, _value: &dyn std::fmt::Debug) { self.check(field.name()); } fn record_str(&mut self, field: &Field, _value: &str) { self.check(field.name()); } fn record_i64(&mut self, field: &Field, _value: i64) { self.check(field.name()); } fn record_u64(&mut self, field: &Field, _value: u64) { self.check(field.name()); } fn record_bool(&mut self, field: &Field, _value: bool) { self.check(field.name()); } fn record_f64(&mut self, field: &Field, _value: f64) { self.check(field.name()); } fn record_i128(&mut self, field: &Field, _value: i128) { self.check(field.name()); } fn record_u128(&mut self, field: &Field, _value: u128) { self.check(field.name()); } } #[derive(Default)] struct FormatVisitor { message: String, rest: String, } impl Visit for FormatVisitor { fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) { if field.name() == "message" { self.message = format!("{value:?}"); } else { use std::fmt::Write; let _ = write!(self.rest, " {}={:?}", field.name(), value); } } fn record_str(&mut self, field: &Field, value: &str) { if field.name() == "message" { self.message = value.to_string(); } else { use std::fmt::Write; let _ = write!(self.rest, " {}={}", field.name(), value); } } } /// Serialisable export bundle. All strings inside have already been /// passed through the redactor. #[derive(Debug, Clone)] pub struct DiagnosticExport { /// Free-form client metadata (build version, platform, ...). pub metadata: Vec<(String, String)>, /// Last N tracing lines, redacted. pub recent_logs: Vec, /// Number of currently registered known-secret values. The /// values themselves are *not* exported. pub known_secret_count: usize, /// SDD-116 item 3: Android voice-audio diagnostics YAML /// fragment, or `None` on non-Android targets / before a voice /// session has opened. The producing crate guarantees this /// fragment contains only device-side technical scalars per /// SDD-090 (no PII, no permission state, no server identity). pub android_audio: Option, /// SRS-100: Network connectivity diagnostics summary. pub network_info: Option, /// SRS-097: Protocol event recording trace. pub protocol_events: Vec, } impl DiagnosticExport { /// Build an export from a sink and free-form metadata. pub fn from_sink( sink: &InMemoryLogSink, metadata: Vec<(String, String)>, ) -> Result { Ok(Self { metadata, recent_logs: sink.snapshot(), known_secret_count: sink.redactor().secrets().len(), android_audio: None, network_info: None, protocol_events: Vec::new(), }) } /// Attach an Android voice-audio diagnostics YAML fragment /// (SDD-112 item 10 / SDD-113 item 7 / SDD-116 item 3). The /// caller is responsible for ensuring the fragment is already /// sanitised per SDD-090; the diagnostics bundle does not /// re-redact this string. pub fn with_android_audio(mut self, yaml_fragment: Option) -> Self { self.android_audio = yaml_fragment; self } /// Attach a network connectivity diagnostics fragment (SRS-100). pub fn with_network_info(mut self, info: Option) -> Self { self.network_info = info; self } /// Attach protocol event trace (SRS-097). pub fn with_protocol_events(mut self, events: Vec) -> Self { self.protocol_events = events; self } /// Render as a plaintext blob suitable for `Share` / `Copy`. /// The output is multi-line UTF-8, redacted. pub fn to_text(&self) -> String { let mut out = String::new(); out.push_str("=== Chanora diagnostic export ===\n"); out.push_str(&format!( "known_secret_count: {}\n", self.known_secret_count )); out.push_str("\n[metadata]\n"); for (k, v) in &self.metadata { out.push_str(&format!("{k}: {v}\n")); } if let Some(yaml) = &self.android_audio { // SDD-116 item 3 verification-matrix section. out.push_str("\n[audio.android]\n"); out.push_str(yaml); } if let Some(info) = &self.network_info { out.push_str("\n[network]\n"); out.push_str(info); } if !self.protocol_events.is_empty() { out.push_str("\n[protocol events]\n"); for ev in &self.protocol_events { out.push_str(ev); out.push('\n'); } } out.push_str("\n[recent logs]\n"); for line in &self.recent_logs { out.push_str(line); out.push('\n'); } out } } /// SRS-097/098: Ring-buffer recorder of protocol-level events /// (connect, disconnect, snapshot changes, channel joins) for /// diagnostic export and state-sync replay verification. #[derive(Debug, Clone)] pub struct ProtocolEventRecorder { events: VecDeque, capacity: usize, } impl ProtocolEventRecorder { /// Create a recorder with the given ring-buffer capacity. pub fn new(capacity: usize) -> Self { Self { events: VecDeque::with_capacity(capacity), capacity, } } fn push(&mut self, ts: &str, kind: &str, detail: &str) { if self.capacity == 0 { return; } let s = format!("[{ts}] {kind}: {detail}"); if self.events.len() >= self.capacity { self.events.pop_front(); } self.events.push_back(s); } /// Record a successful connection. pub fn record_connected(&mut self, server_name: &str) { self.push("connect", "connected", server_name); } /// Record a graceful or forced disconnect. pub fn record_disconnected(&mut self, reason: &str) { self.push("disconnect", "disconnected", reason); } /// Record a reconnect attempt. pub fn record_reconnecting(&mut self, attempt: u64, delay_secs: u64) { self.push( "reconnect", "reconnecting", &format!("attempt={attempt} delay={delay_secs}s"), ); } /// Record a snapshot tree change. pub fn record_snapshot_changed(&mut self, channels: usize, clients: usize) { self.push( "snapshot", "changed", &format!("channels={channels} clients={clients}"), ); } /// Record a channel join event. pub fn record_channel_join(&mut self, channel_id: u64, channel_name: &str) { self.push( "join", "channel_joined", &format!("id={channel_id} name={channel_name}"), ); } /// Record a platform lifecycle transition (SRS-138). pub fn record_lifecycle(&mut self, state: &str) { self.push("lifecycle", state, ""); } /// Drain all recorded events and reset the buffer. pub fn drain(&mut self) -> Vec { self.events.drain(..).collect() } /// Snapshot all recorded events without clearing the buffer. pub fn snapshot(&self) -> Vec { self.events.iter().cloned().collect() } } impl Default for ProtocolEventRecorder { fn default() -> Self { Self::new(256) } } #[cfg(test)] mod tests { use super::*; #[test] fn marker_matches_poc() { assert_eq!(REDACTION_MARKER, "[REDACTED]"); } #[test] fn redacts_ipv4() { let r = Redactor::default(); assert_eq!( r.redact("connect to 192.168.1.1:9987"), "connect to [ip]:9987" ); } #[test] fn redacts_ipv6() { let r = Redactor::default(); let out = r.redact("addr=2001:db8::1 port=9987"); assert!(out.contains("[ip]"), "got {out}"); assert!(!out.contains("2001")); } #[test] fn redacts_email() { let r = Redactor::default(); assert_eq!(r.redact("user alice@example.com bug"), "user [email] bug"); } #[test] fn redacts_long_token() { let r = Redactor::default(); let token = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA"; let out = r.redact(&format!("key={token} done")); assert!(out.contains("[token]"), "got {out}"); assert!(!out.contains("MIIBIj")); } #[test] fn redacts_known_secret() { let secrets = KnownSecretRegistry::default(); secrets.register("supersecret123"); let r = Redactor::with_secrets(secrets); let out = r.redact("hello supersecret123 world"); assert_eq!(out, REDACTION_MARKER); } #[test] fn ring_buffer_caps() { let sink = InMemoryLogSink::new(3, Redactor::default()); for i in 0..10 { sink.push(&format!("line {i}")); } let snap = sink.snapshot(); assert_eq!(snap.len(), 3); assert_eq!(snap[0], "line 7"); assert_eq!(snap[2], "line 9"); } #[test] fn registry_skips_tiny_secrets() { let r = KnownSecretRegistry::default(); r.register(""); r.register("ab"); r.register("longenough"); assert_eq!(r.len(), 1); } #[test] fn export_to_text_is_redacted() { let secrets = KnownSecretRegistry::default(); secrets.register("mypassword"); let r = Redactor::with_secrets(secrets); let sink = InMemoryLogSink::new(8, r); sink.push("user logged in with mypassword"); sink.push("connect to 10.0.0.1"); let exp = DiagnosticExport::from_sink(&sink, vec![("ver".into(), "0.3.0".into())]).unwrap(); let txt = exp.to_text(); assert!(!txt.contains("mypassword")); assert!(!txt.contains("10.0.0.1")); assert!(txt.contains("[ip]") || txt.contains(REDACTION_MARKER)); } #[test] fn ptt_ban_check_visitor_flags_banned_fields() { // Direct test of the visitor (we don't spin up a full // tracing subscriber for this). let mut v = PttBanCheckVisitor::default(); v.check("backend_id"); // safe assert!(!v.banned); v.check("key_code"); // banned assert!(v.banned); for name in [ "scan_code", "virtual_key", "vk", "keysym", "keysym_string", "key_sequence", "key_press_history", "key_timing", ] { let mut v2 = PttBanCheckVisitor::default(); v2.check(name); assert!(v2.banned, "expected {name} to be banned"); } // Allowed PTT fields stay safe. let mut v3 = PttBanCheckVisitor::default(); for name in ["capability_level", "backend_id", "bound_input_class"] { v3.check(name); } assert!(!v3.banned); } #[test] fn ptt_banned_list_is_non_empty_and_stable() { // Lightweight regression catch: the audit document // REDACT-PTT-001..006 enumerates these exact names. assert!(PTT_BANNED_FIELDS.contains(&"key_code")); assert!(PTT_BANNED_FIELDS.contains(&"scan_code")); assert!(PTT_BANNED_FIELDS.contains(&"virtual_key")); assert!(PTT_BANNED_FIELDS.contains(&"keysym")); assert!(PTT_BANNED_FIELDS.contains(&"key_sequence")); // No accidental additions of safe field names. for safe in ["capability_level", "backend_id", "bound_input_class"] { assert!(!PTT_BANNED_FIELDS.contains(&safe)); } } #[test] fn ptt_sanitizer_drops_banned_records_before_inner_layer() { // End-to-end test through a real tracing subscriber: a // sanitiser-wrapped RedactingLogLayer must drop records // that name banned fields and must forward records that // do not. Uses `with_default` so the subscriber is scoped // to the closure (no global state mutation across tests). use tracing_subscriber::layer::SubscriberExt; let secrets = KnownSecretRegistry::default(); let redactor = Redactor::with_secrets(secrets); let sink = InMemoryLogSink::new(16, redactor); let inner = RedactingLogLayer::new(sink.clone()); let sanitised = PttSanitizer::wrap(inner); let subscriber = tracing_subscriber::registry().with(sanitised); tracing::subscriber::with_default(subscriber, || { tracing::info!(key_code = 42, "banned record must drop"); tracing::info!(backend_id = "linux-portal", "safe record must pass"); }); let exported = DiagnosticExport::from_sink(&sink, vec![("k".into(), "v".into())]).unwrap(); let text = exported.to_text(); assert!( !text.contains("banned record must drop"), "sanitiser must have dropped the banned record" ); assert!( text.contains("safe record must pass"), "sanitiser must forward the safe record to the inner layer" ); } #[test] fn banned_field_f64_is_caught() { use tracing_subscriber::layer::SubscriberExt; let secrets = KnownSecretRegistry::default(); let redactor = Redactor::with_secrets(secrets); let sink = InMemoryLogSink::new(16, redactor); let inner = RedactingLogLayer::new(sink.clone()); let sanitised = PttSanitizer::wrap(inner); let subscriber = tracing_subscriber::registry().with(sanitised); tracing::subscriber::with_default(subscriber, || { tracing::info!(key_code = 42.5_f64, "f64 banned record must drop"); tracing::info!(backend_id = "linux-portal", "safe record must pass"); }); let exported = DiagnosticExport::from_sink(&sink, vec![("k".into(), "v".into())]).unwrap(); let text = exported.to_text(); assert!(!text.contains("f64 banned record must drop")); assert!(text.contains("safe record must pass")); } #[test] fn banned_field_i128_is_caught() { use tracing_subscriber::layer::SubscriberExt; let secrets = KnownSecretRegistry::default(); let redactor = Redactor::with_secrets(secrets); let sink = InMemoryLogSink::new(16, redactor); let inner = RedactingLogLayer::new(sink.clone()); let sanitised = PttSanitizer::wrap(inner); let subscriber = tracing_subscriber::registry().with(sanitised); tracing::subscriber::with_default(subscriber, || { tracing::info!(scan_code = 42_i128, "i128 banned record must drop"); tracing::info!(backend_id = "linux-portal", "safe record must pass"); }); let exported = DiagnosticExport::from_sink(&sink, vec![("k".into(), "v".into())]).unwrap(); let text = exported.to_text(); assert!(!text.contains("i128 banned record must drop")); assert!(text.contains("safe record must pass")); } #[test] fn banned_field_u128_is_caught() { use tracing_subscriber::layer::SubscriberExt; let secrets = KnownSecretRegistry::default(); let redactor = Redactor::with_secrets(secrets); let sink = InMemoryLogSink::new(16, redactor); let inner = RedactingLogLayer::new(sink.clone()); let sanitised = PttSanitizer::wrap(inner); let subscriber = tracing_subscriber::registry().with(sanitised); tracing::subscriber::with_default(subscriber, || { tracing::info!(virtual_key = 42_u128, "u128 banned record must drop"); tracing::info!(backend_id = "linux-portal", "safe record must pass"); }); let exported = DiagnosticExport::from_sink(&sink, vec![("k".into(), "v".into())]).unwrap(); let text = exported.to_text(); assert!(!text.contains("u128 banned record must drop")); assert!(text.contains("safe record must pass")); } /// SWE4-UV-056 — `DiagnosticExport` with `android_audio` renders the /// `[audio.android]` section AFTER `[metadata]` and BEFORE /// `[recent logs]`, with the YAML fragment immediately following /// the header byte-for-byte (the bundle does not re-redact per /// SDD-090 trust boundary). #[test] fn android_audio_renders_between_metadata_and_logs() { let secrets = KnownSecretRegistry::default(); let redactor = Redactor::with_secrets(secrets); let sink = InMemoryLogSink::new(16, redactor); let yaml = "achieved:\n performance_mode: LowLatency\n sample_rate_hz: 48000\n"; let exported = DiagnosticExport::from_sink(&sink, vec![("build".into(), "test".into())]) .unwrap() .with_android_audio(Some(yaml.to_string())); let text = exported.to_text(); let metadata_pos = text.find("[metadata]").expect("metadata section"); let android_pos = text.find("[audio.android]").expect("android section"); let logs_pos = text.find("[recent logs]").expect("logs section"); assert!( metadata_pos < android_pos, "[metadata] must come before [audio.android]" ); assert!( android_pos < logs_pos, "[audio.android] must come before [recent logs]" ); // The YAML fragment must follow the header byte-for-byte // (no re-redaction, no reformatting — SDD-090 trust boundary). let header = "[audio.android]\n"; let header_start = text.find(header).expect("audio.android header"); let after_header = &text[header_start + header.len()..]; assert!( after_header.starts_with(yaml), "YAML fragment must follow [audio.android] header verbatim" ); } /// SWE4-UV-057 — `DiagnosticExport` with default `android_audio = /// None` omits the `[audio.android]` header entirely (negative /// test). Also verifies idempotence of explicit /// `with_android_audio(None)`. #[test] fn android_audio_absent_omits_section() { let secrets = KnownSecretRegistry::default(); let redactor = Redactor::with_secrets(secrets); let sink = InMemoryLogSink::new(16, redactor); // Default — never call with_android_audio. let exported_default = DiagnosticExport::from_sink(&sink, vec![("k".into(), "v".into())]).unwrap(); let text_default = exported_default.to_text(); assert!( !text_default.contains("[audio.android]"), "default export must omit [audio.android] section" ); // Explicit None — idempotent with default. let exported_explicit = DiagnosticExport::from_sink(&sink, vec![("k".into(), "v".into())]) .unwrap() .with_android_audio(None); let text_explicit = exported_explicit.to_text(); assert!( !text_explicit.contains("[audio.android]"), "explicit with_android_audio(None) must also omit the section" ); } #[test] fn protocol_event_snapshot_does_not_drain_buffer() { let mut recorder = ProtocolEventRecorder::new(4); recorder.record_connected("Server"); let first = recorder.snapshot(); let second = recorder.snapshot(); let drained = recorder.drain(); assert_eq!(first, second); assert_eq!(drained, first); } #[test] fn protocol_event_zero_capacity_drops_events() { let mut recorder = ProtocolEventRecorder::new(0); recorder.record_connected("Server"); assert!(recorder.snapshot().is_empty()); assert!(recorder.drain().is_empty()); } }