Files
chanora/crates/chanora_diagnostics/src/lib.rs
T

877 lines
28 KiB
Rust

//! # `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<Mutex<HashSet<String>>>,
}
impl KnownSecretRegistry {
/// Add a known-secret value. Empty strings are ignored.
pub fn register(&self, secret: impl Into<String>) {
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<Mutex<std::collections::VecDeque<String>>>,
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<String> {
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<Self> {
PttSanitizer::wrap(self)
}
}
impl<S> Layer<S> 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<L> {
inner: L,
}
impl<L> PttSanitizer<L> {
/// 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<S, L> Layer<S> for PttSanitizer<L>
where
S: Subscriber + for<'a> LookupSpan<'a>,
L: Layer<S>,
{
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<String>,
/// 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<Self, DiagnosticsError> {
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"));
}
}