//! # `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; 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]"; /// 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 line = format!( "[{lvl}] {target}: {msg}{fields}", lvl = event.metadata().level(), 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.iter().any(|b| *b == 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, } 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(), }) } /// 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")); } out.push_str("\n[recent logs]\n"); for line in &self.recent_logs { out.push_str(line); out.push('\n'); } out } } #[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); let mut v2 = PttBanCheckVisitor::default(); for name in [ "scan_code", "virtual_key", "vk", "keysym", "keysym_string", "key_sequence", "key_press_history", "key_timing", ] { 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")); } }