use std::borrow::Cow;
use once_cell::sync::Lazy;
use regex::{Regex, RegexSet};
const REDACTED: &str = "[REDACTED]";
struct SecretPattern {
pattern: &'static str,
regex: Regex,
}
static SECRET_PATTERNS: Lazy<Vec<SecretPattern>> = Lazy::new(|| {
vec![
SecretPattern {
pattern: r"\bAKIA[0-9A-Z]{16}\b",
regex: Regex::new(r"\bAKIA[0-9A-Z]{16}\b").expect("aws access key regex"),
},
SecretPattern {
pattern: r#"(?i)aws(.{0,20})?(secret|access)?[_-]?key\s*[:=]\s*['"]?[A-Za-z0-9/+=]{40}['"]?"#,
regex: Regex::new(
r#"(?i)aws(.{0,20})?(secret|access)?[_-]?key\s*[:=]\s*['"]?[A-Za-z0-9/+=]{40}['"]?"#,
)
.expect("aws secret regex"),
},
SecretPattern {
pattern: r"\bgh[pousr]_[A-Za-z0-9]{36}\b",
regex: Regex::new(r"\bgh[pousr]_[A-Za-z0-9]{36}\b").expect("github pat regex"),
},
SecretPattern {
pattern: r"\bsk-[A-Za-z0-9]{20,}\b",
regex: Regex::new(r"\bsk-[A-Za-z0-9]{20,}\b").expect("openai key regex"),
},
SecretPattern {
pattern: r"\bsk-ant-[A-Za-z0-9]{20,}\b",
regex: Regex::new(r"\bsk-ant-[A-Za-z0-9]{20,}\b").expect("anthropic key regex"),
},
SecretPattern {
pattern: r"(?i)Bearer\s+[A-Za-z0-9_\-.]{20,}",
regex: Regex::new(r"(?i)Bearer\s+[A-Za-z0-9_\-.]{20,}").expect("bearer token regex"),
},
SecretPattern {
pattern: r"\beyJ[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+\b",
regex: Regex::new(r"\beyJ[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+\b")
.expect("jwt regex"),
},
SecretPattern {
pattern: r"-----BEGIN (?:RSA|EC|DSA|OPENSSH|PGP) PRIVATE KEY-----",
regex: Regex::new(r"-----BEGIN (?:RSA|EC|DSA|OPENSSH|PGP) PRIVATE KEY-----")
.expect("private key regex"),
},
SecretPattern {
pattern: r"(?i)\b(postgres|postgresql|mysql|mongodb|redis)://[^\s]{8,}",
regex: Regex::new(
r"(?i)\b(postgres|postgresql|mysql|mongodb|redis)://[^\s]{8,}",
)
.expect("db url regex"),
},
SecretPattern {
pattern: r#"(?i)(api[_-]?key|api[_-]?secret|auth[_-]?token|access[_-]?token|secret[_-]?key|password|passwd)\s*[:=]\s*['"]?[A-Za-z0-9_\-/+=]{8,}['"]?"#,
regex: Regex::new(
r#"(?i)(api[_-]?key|api[_-]?secret|auth[_-]?token|access[_-]?token|secret[_-]?key|password|passwd)\s*[:=]\s*['"]?[A-Za-z0-9_\-/+=]{8,}['"]?"#,
)
.expect("generic api key regex"),
},
SecretPattern {
pattern: r"\bxox[bpsar]-[A-Za-z0-9\-]{10,}",
regex: Regex::new(r"\bxox[bpsar]-[A-Za-z0-9\-]{10,}").expect("slack token regex"),
},
SecretPattern {
pattern: r"\b[spr]k_live_[A-Za-z0-9]{20,}",
regex: Regex::new(r"\b[spr]k_live_[A-Za-z0-9]{20,}").expect("stripe key regex"),
},
]
});
static SECRET_REGEX_SET: Lazy<RegexSet> = Lazy::new(|| {
RegexSet::new(SECRET_PATTERNS.iter().map(|pattern| pattern.pattern)).expect("secret regex set")
});
pub fn redact_text(input: &str) -> Cow<'_, str> {
let matches = SECRET_REGEX_SET.matches(input);
if !matches.matched_any() {
return Cow::Borrowed(input);
}
let mut output = Cow::Borrowed(input);
for idx in matches.iter() {
let replaced = SECRET_PATTERNS[idx]
.regex
.replace_all(output.as_ref(), REDACTED);
if let Cow::Owned(redacted) = replaced {
output = Cow::Owned(redacted);
}
}
output
}
pub fn redact_json(value: &serde_json::Value) -> serde_json::Value {
match value {
serde_json::Value::String(s) => {
let redacted = redact_text(s).into_owned();
serde_json::Value::String(redacted)
}
serde_json::Value::Array(arr) => {
serde_json::Value::Array(arr.iter().map(redact_json).collect())
}
serde_json::Value::Object(obj) => {
let mut new_obj = serde_json::Map::new();
for (k, v) in obj {
let redacted_key = redact_text(k).into_owned();
new_obj.insert(redacted_key, redact_json(v));
}
serde_json::Value::Object(new_obj)
}
other => other.clone(),
}
}
#[doc(hidden)]
pub fn fuzz_redact_json_with_memoizing_redactor(
value: &serde_json::Value,
capacity: usize,
) -> serde_json::Value {
MemoizingRedactor::with_capacity(capacity.clamp(1, 1024)).redact_json(value)
}
pub fn redaction_enabled() -> bool {
match dotenvy::var("CASS_REDACT_SECRETS") {
Ok(val) => !matches!(val.as_str(), "0" | "false" | "off" | "no"),
Err(_) => true,
}
}
pub fn redaction_algorithm_fingerprint() -> String {
static FINGERPRINT: Lazy<String> = Lazy::new(|| {
let mut hasher = blake3::Hasher::new();
for pattern in SECRET_PATTERNS.iter() {
hasher.update(pattern.pattern.as_bytes());
hasher.update(&[0]);
}
hasher.update(REDACTED.as_bytes());
format!("redact-v1:{}", hasher.finalize().to_hex())
});
FINGERPRINT.clone()
}
#[allow(dead_code)]
pub(crate) struct MemoizingRedactor {
text_cache: crate::indexer::memoization::ContentAddressedMemoCache<String>,
algorithm_fingerprint: String,
}
#[allow(dead_code)]
impl MemoizingRedactor {
pub(crate) const DEFAULT_CAPACITY: usize = 4096;
pub(crate) fn with_capacity(capacity: usize) -> Self {
Self {
text_cache: crate::indexer::memoization::ContentAddressedMemoCache::with_capacity(
capacity,
),
algorithm_fingerprint: redaction_algorithm_fingerprint(),
}
}
pub(crate) fn new() -> Self {
Self::with_capacity(Self::DEFAULT_CAPACITY)
}
pub(crate) fn algorithm_fingerprint(&self) -> &str {
&self.algorithm_fingerprint
}
pub(crate) fn stats(&self) -> &crate::indexer::memoization::MemoCacheStats {
self.text_cache.stats()
}
pub(crate) fn redact_text(&mut self, input: &str) -> String {
let (output, _audit) = self.redact_text_with_audit(input);
output
}
pub(crate) fn redact_text_with_audit(
&mut self,
input: &str,
) -> (
String,
Vec<crate::indexer::memoization::MemoCacheAuditRecord>,
) {
if input.is_empty() {
return (String::new(), Vec::new());
}
let key = self.key_for(input);
let (lookup, lookup_audit) = self.text_cache.get_with_audit(&key);
Self::trace_audit(&lookup_audit);
match lookup {
crate::indexer::memoization::MemoLookup::Hit { value } => (value, vec![lookup_audit]),
crate::indexer::memoization::MemoLookup::Quarantined { reason } => {
tracing::warn!(
quarantine_reason = %reason,
algorithm = %self.algorithm_fingerprint,
"redaction memo entry is quarantined; falling back to direct regex pass"
);
let redacted = redact_text(input).into_owned();
(redacted, vec![lookup_audit])
}
crate::indexer::memoization::MemoLookup::Miss => {
let redacted = redact_text(input).into_owned();
let insert_audit = self.text_cache.insert_with_audit(key, redacted.clone());
Self::trace_audit(&insert_audit);
(redacted, vec![lookup_audit, insert_audit])
}
}
}
pub(crate) fn invalidate(&mut self, input: &str) -> bool {
if input.is_empty() {
return false;
}
let key = self.key_for(input);
let audit = self.text_cache.invalidate_with_audit(&key);
Self::trace_audit(&audit);
audit.changed
}
pub(crate) fn quarantine(&mut self, input: &str, reason: impl Into<String>) {
if input.is_empty() {
return;
}
let key = self.key_for(input);
let audit = self.text_cache.quarantine_with_audit(key, reason);
Self::trace_audit(&audit);
}
fn trace_audit(audit: &crate::indexer::memoization::MemoCacheAuditRecord) {
use crate::indexer::memoization::MemoCacheEvent;
match audit.event {
MemoCacheEvent::Hit => tracing::trace!(
target: "cass::redact::memo",
algorithm = %audit.key.algorithm,
stats = ?audit.stats,
"redact memo hit"
),
MemoCacheEvent::Miss => tracing::debug!(
target: "cass::redact::memo",
algorithm = %audit.key.algorithm,
stats = ?audit.stats,
"redact memo miss"
),
MemoCacheEvent::Insert => tracing::debug!(
target: "cass::redact::memo",
algorithm = %audit.key.algorithm,
live_entries = audit.stats.live_entries,
"redact memo insert"
),
MemoCacheEvent::Evict { ref reason } => tracing::info!(
target: "cass::redact::memo",
evict_reason = ?reason,
live_entries = audit.stats.live_entries,
evictions_capacity = audit.stats.evictions_capacity,
"redact memo eviction"
),
MemoCacheEvent::Invalidate => tracing::warn!(
target: "cass::redact::memo",
changed = audit.changed,
live_entries = audit.stats.live_entries,
invalidations = audit.stats.invalidations,
"redact memo invalidate"
),
MemoCacheEvent::Quarantine { ref reason } => tracing::warn!(
target: "cass::redact::memo",
quarantine_reason = %reason,
quarantined_entries = audit.quarantined_entries,
"redact memo quarantine"
),
}
}
pub(crate) fn redact_json(&mut self, value: &serde_json::Value) -> serde_json::Value {
match value {
serde_json::Value::String(s) => serde_json::Value::String(self.redact_text(s)),
serde_json::Value::Array(arr) => {
serde_json::Value::Array(arr.iter().map(|v| self.redact_json(v)).collect())
}
serde_json::Value::Object(obj) => {
let mut new_obj = serde_json::Map::with_capacity(obj.len());
for (k, v) in obj {
let redacted_key = self.redact_text(k);
new_obj.insert(redacted_key, self.redact_json(v));
}
serde_json::Value::Object(new_obj)
}
other => other.clone(),
}
}
fn key_for(&self, input: &str) -> crate::indexer::memoization::MemoKey {
let mut hasher = blake3::Hasher::new();
hasher.update(input.as_bytes());
let content_hash = crate::indexer::memoization::MemoContentHash::from_bytes(
hasher.finalize().as_bytes().to_vec(),
);
crate::indexer::memoization::MemoKey::new(
content_hash,
"redact_text",
self.algorithm_fingerprint.clone(),
)
}
}
impl Default for MemoizingRedactor {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use serial_test::serial;
#[test]
fn redacts_openai_key() {
let input = "my key is sk-ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij";
let output = redact_text(input);
assert_eq!(output, "my key is [REDACTED]");
assert!(!output.contains("sk-ABCDE"));
}
#[test]
fn redacts_anthropic_key() {
let input = "sk-ant-ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij";
let output = redact_text(input);
assert_eq!(output, "[REDACTED]");
}
#[test]
fn redacts_github_pat() {
let input = "token ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij";
let output = redact_text(input);
assert_eq!(output, "token [REDACTED]");
}
#[test]
fn redacts_bearer_token() {
let input = "Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.payload.signature";
let output = redact_text(input);
assert!(!output.contains("eyJhbGci"));
}
#[test]
fn redacts_aws_access_key() {
let input = "AKIAIOSFODNN7EXAMPLE";
let output = redact_text(input);
assert_eq!(output, "[REDACTED]");
}
#[test]
fn redacts_private_key_header() {
let input = "-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAK...";
let output = redact_text(input);
assert!(output.starts_with("[REDACTED]"));
}
#[test]
fn redacts_generic_api_key_assignment() {
let input = "api_key=abcdefgh12345678";
let output = redact_text(input);
assert_eq!(output, "[REDACTED]");
}
#[test]
fn redacts_database_url() {
let input = "DATABASE_URL=postgres://user:pass@host:5432/db";
let output = redact_text(input);
assert!(!output.contains("user:pass"));
}
#[test]
fn redacts_stripe_key() {
let input = format!("{}_{}", "sk_live", "AAAABBBBCCCCDDDDEEEEFFFFGGGG");
let output = redact_text(&input);
assert_eq!(output, "[REDACTED]");
}
#[test]
fn redacts_slack_token() {
let input = "xoxb-123456789-abcdefghij";
let output = redact_text(input);
assert_eq!(output, "[REDACTED]");
}
#[test]
fn leaves_normal_text_unchanged() {
let input = "Hello, this is a normal message about code review.";
let output = redact_text(input);
assert_eq!(output, input);
assert!(
matches!(output, Cow::Borrowed(_)),
"no-secret path should not allocate"
);
}
#[test]
fn leaves_short_tokens_unchanged() {
let input = "sk-abc";
let output = redact_text(input);
assert_eq!(output, input);
}
#[test]
fn redacts_json_string_values() {
let input = json!({
"tool_result": "Response contains sk-ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij",
"safe": "no secrets here",
"number": 42
});
let output = redact_json(&input);
assert_eq!(output["tool_result"], json!("Response contains [REDACTED]"));
assert_eq!(output["safe"], json!("no secrets here"));
assert_eq!(output["number"], json!(42));
}
#[test]
fn redacts_nested_json() {
let input = json!({
"outer": {
"inner": "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij"
},
"array": ["safe", "sk-ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij"]
});
let output = redact_json(&input);
assert_eq!(output["outer"]["inner"], json!("[REDACTED]"));
assert_eq!(output["array"][0], json!("safe"));
assert_eq!(output["array"][1], json!("[REDACTED]"));
}
#[test]
#[serial]
fn redaction_enabled_default() {
unsafe { std::env::remove_var("CASS_REDACT_SECRETS") };
assert!(redaction_enabled());
}
#[test]
#[serial]
fn redaction_can_be_disabled() {
unsafe { std::env::set_var("CASS_REDACT_SECRETS", "0") };
assert!(!redaction_enabled());
unsafe { std::env::set_var("CASS_REDACT_SECRETS", "false") };
assert!(!redaction_enabled());
unsafe { std::env::remove_var("CASS_REDACT_SECRETS") };
}
#[test]
fn multiple_secrets_in_one_string() {
let input = "key1=sk-ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij and key2=ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij";
let output = redact_text(input);
assert!(!output.contains("sk-ABCDE"));
assert!(!output.contains("ghp_ABCDE"));
assert_eq!(output.matches("[REDACTED]").count(), 2);
assert!(
matches!(output, Cow::Owned(_)),
"matched secret path should return owned redacted text"
);
}
#[test]
fn memoizing_redactor_matches_uncached_for_arbitrary_input() {
fn safe_prefix(s: &str, max_bytes: usize) -> &str {
let mut end = s.len().min(max_bytes);
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
&s[..end]
}
let twenty_kib_unicode = "🔐abc".repeat(2_048);
let inputs: &[&str] = &[
"",
"no secrets here, just prose",
"my key is sk-ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij",
"sk-ant-ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij followed by AKIAABCDEFGHIJKLMNOP",
"Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.payload.signature",
"ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij and another ghp_ZYXWVUTSRQPONMLKJIHGFEDCBA0123456789",
"🔐 user pasted sk-ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij from 测试",
&twenty_kib_unicode,
&"a".repeat(10_000),
];
let mut redactor = MemoizingRedactor::with_capacity(64);
for input in inputs {
let uncached = redact_text(input).into_owned();
let memoized_first = redactor.redact_text(input);
let memoized_second = redactor.redact_text(input);
assert_eq!(
uncached,
memoized_first,
"memoized first call must match legacy uncached redact_text for input prefix: {:?}",
safe_prefix(input, 64)
);
assert_eq!(
uncached,
memoized_second,
"memoized second call must match legacy uncached for input prefix: {:?}",
safe_prefix(input, 64)
);
}
}
#[test]
fn memoizing_redactor_reuses_cache_for_repeated_content() {
let mut redactor = MemoizingRedactor::with_capacity(16);
let payload = "boilerplate assistant prompt: please help with sk-ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij";
let _ = redactor.redact_text("");
let _ = redactor.redact_text(payload);
let _ = redactor.redact_text(payload);
let _ = redactor.redact_text(payload);
let stats = redactor.stats();
assert_eq!(stats.misses, 1, "first call must be a cache miss");
assert_eq!(
stats.hits, 2,
"subsequent identical calls must be cache hits"
);
assert_eq!(stats.inserts, 1, "exactly one redacted result inserted");
}
#[test]
fn memoizing_redactor_keys_isolate_by_algorithm_fingerprint() {
let fingerprint = redaction_algorithm_fingerprint();
assert!(
fingerprint.starts_with("redact-v1:"),
"fingerprint must carry an explicit version epoch, got: {fingerprint}"
);
let hex_part = fingerprint.strip_prefix("redact-v1:").unwrap();
assert_eq!(
hex_part.len(),
64,
"fingerprint hash must be a 64-char blake3 hex digest"
);
assert_eq!(fingerprint, redaction_algorithm_fingerprint());
let r1 = MemoizingRedactor::new();
let r2 = MemoizingRedactor::new();
assert_eq!(r1.algorithm_fingerprint(), r2.algorithm_fingerprint());
}
#[test]
fn memoizing_redactor_redact_json_matches_uncached_for_nested_shapes() {
let value = json!({
"session": {
"auth": "Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.payload.signature",
"history": [
"no secret",
"ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij",
{"key": "value", "leak": "sk-ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij"},
null,
42,
true,
],
"metadata": {
"leaked_field": "sk-ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij",
"safe_field": "noop",
},
},
"version": 7,
});
let uncached = redact_json(&value);
let memoized = MemoizingRedactor::new().redact_json(&value);
assert_eq!(
uncached, memoized,
"memoizing redact_json must match legacy redact_json byte-for-byte"
);
}
#[test]
fn memoizing_redactor_redact_json_reuses_repeated_keys_and_values() {
let repeated_secret =
"Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.payload.signature";
let repeated_note = "same assistant boilerplate without secrets";
let value = json!({
"events": [
{"token": repeated_secret, "note": repeated_note},
{"token": repeated_secret, "note": repeated_note},
{"token": repeated_secret, "note": repeated_note},
],
"footer": repeated_note,
});
let uncached = redact_json(&value);
let mut redactor = MemoizingRedactor::with_capacity(32);
let memoized = redactor.redact_json(&value);
assert_eq!(
uncached, memoized,
"memoized JSON redaction must preserve legacy output exactly"
);
assert!(
!memoized.to_string().contains("eyJhbGci"),
"memoized JSON redaction must still remove repeated secrets"
);
let stats = redactor.stats();
assert_eq!(
stats.misses, 6,
"first occurrences of root keys, repeated child keys, and scalar values should miss once"
);
assert_eq!(
stats.inserts, 6,
"each distinct JSON key/value string should be inserted once"
);
assert_eq!(
stats.hits, 9,
"repeated child keys and repeated scalar values should hit the memo cache"
);
}
#[test]
#[serial]
fn memoizing_redactor_empty_input_skips_cache() {
let mut redactor = MemoizingRedactor::with_capacity(8);
let _ = redactor.redact_text("");
let _ = redactor.redact_text("");
let _ = redactor.redact_text("");
let stats = redactor.stats();
assert_eq!(stats.misses, 0, "empty input must not count as miss");
assert_eq!(stats.hits, 0, "empty input must not count as hit");
assert_eq!(stats.inserts, 0, "empty input must not insert into cache");
}
#[test]
fn memoizing_redactor_with_audit_emits_lookup_and_insert_records() {
use crate::indexer::memoization::{MemoCacheEvent, MemoCacheOperation};
let mut redactor = MemoizingRedactor::with_capacity(8);
let payload =
"Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.payload.signature";
let (first_output, first_audit) = redactor.redact_text_with_audit(payload);
assert!(!first_output.contains("eyJhbGci"));
assert_eq!(
first_audit.len(),
2,
"first call must emit a lookup audit + an insert audit"
);
assert!(matches!(
first_audit[0].operation,
MemoCacheOperation::Lookup
));
assert!(matches!(first_audit[0].event, MemoCacheEvent::Miss));
assert!(matches!(
first_audit[1].operation,
MemoCacheOperation::Insert
));
assert!(matches!(first_audit[1].event, MemoCacheEvent::Insert));
assert_eq!(first_audit[1].stats.live_entries, 1);
let (second_output, second_audit) = redactor.redact_text_with_audit(payload);
assert_eq!(first_output, second_output);
assert_eq!(
second_audit.len(),
1,
"second call must emit only the lookup audit (cache hit)"
);
assert!(matches!(second_audit[0].event, MemoCacheEvent::Hit));
assert_eq!(second_audit[0].stats.hits, 1);
for record in first_audit.iter().chain(second_audit.iter()) {
assert_eq!(record.key.algorithm, "redact_text");
assert!(record.key.algorithm_version.starts_with("redact-v1:"));
}
}
#[test]
fn memoizing_redactor_invalidate_drops_cached_entry() {
let mut redactor = MemoizingRedactor::with_capacity(8);
let payload = "no secret here, just a sentence";
let _ = redactor.redact_text(payload);
assert_eq!(redactor.stats().inserts, 1);
assert_eq!(redactor.stats().misses, 1);
let _ = redactor.redact_text(payload);
assert_eq!(redactor.stats().hits, 1);
assert!(
redactor.invalidate(payload),
"invalidate must return true when an entry was removed"
);
assert_eq!(redactor.stats().invalidations, 1);
assert!(
!redactor.invalidate(payload),
"second invalidate must be a no-op"
);
assert_eq!(redactor.stats().invalidations, 1);
assert!(
!redactor.invalidate(""),
"invalidating empty input must be a no-op"
);
let _ = redactor.redact_text(payload);
assert_eq!(
redactor.stats().misses,
2,
"post-invalidate call must register as a miss"
);
assert_eq!(redactor.stats().hits, 1, "hits counter must not regress");
}
#[test]
fn memoizing_redactor_quarantined_entries_fall_through_to_direct_redaction() {
use crate::indexer::memoization::{MemoCacheEvent, MemoCacheOperation};
let mut redactor = MemoizingRedactor::with_capacity(8);
let payload =
"user=admin password=hunter2hunter2 token=ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij";
let _ = redactor.redact_text(payload);
let _ = redactor.redact_text(payload);
assert_eq!(redactor.stats().hits, 1);
redactor.quarantine(payload, "telemetry: poisoned redaction signal");
assert_eq!(redactor.stats().quarantined, 1);
let (output, audit) = redactor.redact_text_with_audit(payload);
assert!(
!output.contains("ghp_ABCDE"),
"post-quarantine redaction must still scrub secrets via direct regex pass"
);
assert!(
!output.contains("password=hunter2hunter2"),
"post-quarantine redaction must scrub generic password assignments"
);
assert_eq!(
audit.len(),
1,
"quarantine fallthrough emits the lookup audit only (no insert)"
);
assert!(matches!(audit[0].operation, MemoCacheOperation::Lookup));
assert!(matches!(audit[0].event, MemoCacheEvent::Quarantine { .. }));
redactor.quarantine(payload, "telemetry: poisoned redaction signal");
assert_eq!(
redactor.stats().quarantined,
1,
"re-quarantining the same key with the same reason must not double-count"
);
redactor.quarantine("", "ignored");
assert_eq!(redactor.stats().quarantined, 1);
}
}