//! # `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());
    }
}

#[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"
        );
    }
}
