#![allow(
clippy::unwrap_used,
clippy::expect_used,
reason = "tests assert on known-good values"
)]
use cerberust::middleware::stream::StreamOutput;
use cerberust::{
BanSubstringsScanner, BanTopicsScanner, Direction, MiddlewareChain, Params, PiiScanner,
RegexRule, RegexScanner, RestoreEncoder, RestoreScanner, Scanner, ScannerId, ScannerStack,
SecretScanner, Topic,
};
const MODEL_CARD: &str = "4111 1111 1111 1111";
const AWS_KEY: &str = "AKIAIOSFODNN7EXAMPLE";
const MODEL_IBAN: &str = "GB82 WEST 1234 5698 7654 32";
fn distinctive_prefixes(secret: &str) -> Vec<&str> {
(4..secret.len()).map(|i| &secret[..i]).collect()
}
fn normalize_nonces(s: &str) -> String {
let re = regex::Regex::new(r"_[0-9a-f]{8}\]").unwrap();
re.replace_all(s, "_NONCE]").into_owned()
}
fn output_secret_stack() -> ScannerStack {
let scanners: Vec<Box<dyn Scanner>> = vec![Box::new(
SecretScanner::new().with_direction(Direction::Output),
)];
ScannerStack::new(scanners, true)
}
fn run_chunked(
stack: &mut ScannerStack,
response: &str,
chunk_len: usize,
mut check: impl FnMut(&str),
) -> String {
let mut runner = StreamOutput::new(stack);
let mut emitted = String::new();
let bytes = response.as_bytes();
let mut i = 0;
while i < bytes.len() {
let mut end = (i + chunk_len).min(bytes.len());
while end < bytes.len() && bytes[end] & 0xC0 == 0x80 {
end += 1;
}
let chunk = std::str::from_utf8(&bytes[i..end]).unwrap();
let safe = runner.push(stack, chunk).unwrap();
emitted.push_str(&safe);
check(&emitted);
i = end;
}
let tail = runner.finish(stack).unwrap();
emitted.push_str(&tail);
check(&emitted);
emitted
}
#[test]
fn split_secret_is_never_half_emitted() {
let response = format!("my key is {AWS_KEY} thanks");
let prefixes = distinctive_prefixes(AWS_KEY);
let mut stack = output_secret_stack();
let emitted = run_chunked(&mut stack, &response, 1, |out| {
for p in &prefixes {
assert!(
!out.contains(p),
"leaked secret prefix {p:?} in streamed output {out:?}",
);
}
});
assert!(!emitted.contains(AWS_KEY));
assert!(emitted.contains("[REDACTED_AWS_ACCESS_KEY_1_"));
assert!(emitted.starts_with("my key is "));
assert!(emitted.ends_with(" thanks"));
}
#[test]
fn split_secret_never_leaks_at_any_chunk_size() {
let response = format!("prefix {AWS_KEY} suffix");
let prefixes = distinctive_prefixes(AWS_KEY);
for chunk_len in 1..=7 {
let mut stack = output_secret_stack();
let emitted = run_chunked(&mut stack, &response, chunk_len, |out| {
for p in &prefixes {
assert!(
!out.contains(p),
"leaked {p:?} at chunk_len {chunk_len} in {out:?}",
);
}
});
assert!(emitted.contains("[REDACTED_AWS_ACCESS_KEY_1_"));
assert!(!emitted.contains(AWS_KEY));
}
}
#[test]
fn sentinel_spanning_chunks_restores_fully() {
let scanners: Vec<Box<dyn Scanner>> = vec![
Box::new(PiiScanner::new()),
Box::new(RestoreScanner::for_pii()),
];
let mut stack = ScannerStack::new(scanners, true);
let redacted = stack.run_input("contact alice@example.com now").unwrap();
assert!(redacted.contains("[REDACTED_EMAIL_1_"));
for chunk_len in 1..=6 {
let mut runner = StreamOutput::new(&stack);
let mut emitted = String::new();
let bytes = redacted.as_bytes();
let mut i = 0;
while i < bytes.len() {
let end = (i + chunk_len).min(bytes.len());
let chunk = std::str::from_utf8(&bytes[i..end]).unwrap();
emitted.push_str(&runner.push(&mut stack, chunk).unwrap());
i = end;
}
emitted.push_str(&runner.finish(&mut stack).unwrap());
assert_eq!(
emitted, "contact alice@example.com now",
"restore failed at chunk_len {chunk_len}",
);
assert!(!emitted.contains("[REDACTED_EMAIL"));
}
}
#[test]
fn split_iban_is_fully_redacted_across_chunks() {
let response = format!("send to {MODEL_IBAN} now");
for chunk_len in 1..=7 {
let scanners: Vec<Box<dyn Scanner>> = vec![Box::new(PiiScanner::sensitive_output())];
let mut stack = ScannerStack::new(scanners, true);
let emitted = run_chunked(&mut stack, &response, chunk_len, |out| {
assert!(
!out.contains(MODEL_IBAN),
"leaked full IBAN at chunk_len {chunk_len}: {out:?}",
);
});
assert!(!emitted.contains(MODEL_IBAN));
assert!(emitted.contains("[REDACTED_IBAN_1_"));
assert!(emitted.starts_with("send to "));
assert!(emitted.ends_with(" now"));
}
}
#[test]
fn whole_stream_scanner_buffers_then_passes_clean() {
let scanners: Vec<Box<dyn Scanner>> = vec![Box::new(
BanTopicsScanner::new([Topic::new("violence", ["weapon".to_owned()])])
.with_direction(Direction::Output),
)];
let mut stack = ScannerStack::new(scanners, true);
let mut runner = StreamOutput::new(&stack);
let mut emitted = String::new();
for chunk in ["the weather ", "is nice ", "today"] {
let safe = runner.push(&mut stack, chunk).unwrap();
assert!(safe.is_empty());
emitted.push_str(&safe);
}
assert!(emitted.is_empty());
emitted.push_str(&runner.finish(&mut stack).unwrap());
assert_eq!(emitted, "the weather is nice today");
}
#[test]
fn whole_stream_scanner_blocks_on_banned_response() {
let scanners: Vec<Box<dyn Scanner>> = vec![Box::new(
BanTopicsScanner::new([Topic::new("violence", ["weapon".to_owned()])])
.with_direction(Direction::Output),
)];
let mut stack = ScannerStack::new(scanners, true);
let mut runner = StreamOutput::new(&stack);
for chunk in ["how to ", "build a ", "weapon"] {
assert!(runner.push(&mut stack, chunk).unwrap().is_empty());
}
assert!(runner.finish(&mut stack).is_err());
}
#[test]
fn output_phrase_gate_blocks_phrase_split_across_chunks() {
let phrase = "as an ai language model";
let scanners: Vec<Box<dyn Scanner>> =
vec![Box::new(BanSubstringsScanner::output_phrase_gate([
phrase.to_owned()
]))];
let mut stack = ScannerStack::new(scanners, true);
let mut runner = StreamOutput::new(&stack);
for chunk in ["Sure! As an ", "AI language ", "model, I refuse."] {
assert!(runner.push(&mut stack, chunk).unwrap().is_empty());
}
assert!(runner.finish(&mut stack).is_err());
}
#[test]
fn output_phrase_gate_passes_when_phrase_absent() {
let scanners: Vec<Box<dyn Scanner>> =
vec![Box::new(BanSubstringsScanner::output_phrase_gate([
"forbidden phrase".to_owned(),
]))];
let mut stack = ScannerStack::new(scanners, true);
let mut runner = StreamOutput::new(&stack);
for chunk in ["a perfectly ", "ordinary ", "answer"] {
assert!(runner.push(&mut stack, chunk).unwrap().is_empty());
}
assert_eq!(
runner.finish(&mut stack).unwrap(),
"a perfectly ordinary answer"
);
}
#[test]
fn coexistence_restores_input_pii_and_redacts_model_pii_one_pass() {
let restore_email_only =
RestoreScanner::for_types(ScannerId("native:pii-restore"), ["EMAIL".to_owned()]);
let scanners: Vec<Box<dyn Scanner>> = vec![
Box::new(PiiScanner::new()),
Box::new(restore_email_only),
Box::new(PiiScanner::sensitive_output()),
];
let mut stack = ScannerStack::new(scanners, true);
let redacted = stack.run_input("contact alice@example.com").unwrap();
assert!(redacted.contains("[REDACTED_EMAIL_1_"));
let sentinel = redacted
.split_whitespace()
.find(|t| t.starts_with("[REDACTED_EMAIL_1_"))
.unwrap();
let response = format!("re {sentinel}, your card {MODEL_CARD} is on file");
let unary = stack.run_output(&response).unwrap();
assert!(unary.contains("alice@example.com"));
assert!(!unary.contains(MODEL_CARD));
assert!(unary.contains("[REDACTED_CREDIT_CARD_"));
for chunk_len in 1..=7 {
let mut runner = StreamOutput::new(&stack);
let mut emitted = String::new();
let bytes = response.as_bytes();
let mut i = 0;
while i < bytes.len() {
let mut end = (i + chunk_len).min(bytes.len());
while end < bytes.len() && bytes[end] & 0xC0 == 0x80 {
end += 1;
}
let chunk = std::str::from_utf8(&bytes[i..end]).unwrap();
emitted.push_str(&runner.push(&mut stack, chunk).unwrap());
i = end;
}
emitted.push_str(&runner.finish(&mut stack).unwrap());
assert!(
emitted.contains("alice@example.com"),
"input PII not restored at chunk_len {chunk_len}: {emitted:?}",
);
assert!(
!emitted.contains(MODEL_CARD),
"model card leaked at chunk_len {chunk_len}: {emitted:?}",
);
assert!(
emitted.contains("[REDACTED_CREDIT_CARD_"),
"model card not redacted at chunk_len {chunk_len}: {emitted:?}",
);
}
}
#[test]
fn clean_stream_passes_with_eventually_zero_holdback() {
let mut stack = output_secret_stack();
let response = "the quick brown fox jumps over the lazy dog";
let emitted = run_chunked(&mut stack, response, 3, |_| {});
assert_eq!(emitted, response);
}
#[test]
fn streaming_equals_unary_run_output() {
let response = format!(
"ship {AWS_KEY} to alice@example.com and ghp_{} now",
"a".repeat(36)
);
for chunk_len in 1..=9 {
let mut unary_stack = output_secret_stack();
let unary = unary_stack.run_output(&response).unwrap();
let mut stream_stack = output_secret_stack();
let streamed = run_chunked(&mut stream_stack, &response, chunk_len, |_| {});
assert_eq!(
normalize_nonces(&streamed),
normalize_nonces(&unary),
"stream != unary at chunk_len {chunk_len}",
);
}
}
proptest::proptest! {
#[test]
fn prop_streaming_equals_unary(
head in "[a-z ]{0,12}",
mid in "[a-z ]{0,12}",
tail in "[a-z ]{0,12}",
chunk_len in 1usize..=8,
) {
let response = format!("{head}{AWS_KEY}{mid} alice@example.com {tail}");
let mut unary_stack = output_secret_stack();
let unary = unary_stack.run_output(&response).unwrap();
let mut stream_stack = output_secret_stack();
let streamed = run_chunked(&mut stream_stack, &response, chunk_len, |_| {});
proptest::prop_assert_eq!(normalize_nonces(&streamed), normalize_nonces(&unary));
}
#[test]
fn prop_secret_never_half_emitted(
head in "[a-z ]{0,16}",
tail in "[a-z ]{0,16}",
chunk_len in 1usize..=6,
) {
let response = format!("{head}{AWS_KEY}{tail}");
let prefixes = distinctive_prefixes(AWS_KEY);
let mut stack = output_secret_stack();
let emitted = run_chunked(&mut stack, &response, chunk_len, |_| {});
for p in &prefixes {
proptest::prop_assert!(!emitted.contains(p), "leaked {:?}", p);
}
}
}
#[test]
fn runner_wires_through_the_middleware_chain() {
use cerberust::{Chunk, GuardrailRunner, StreamModel};
struct CharModel(String);
impl StreamModel for CharModel {
fn stream(&self, _params: &Params) -> Box<dyn Iterator<Item = Chunk> + '_> {
Box::new(self.0.chars().map(|c| Ok(c.to_string())))
}
}
let runner = GuardrailRunner::new("native:guard", output_secret_stack());
let chain = MiddlewareChain::new(vec![&runner]);
let model = CharModel(format!("here {AWS_KEY} ok"));
let chunks = chain.stream(Params::new("ignored"), &model).unwrap();
let joined: String = chunks.concat();
assert!(!joined.contains(AWS_KEY));
assert!(joined.contains("[REDACTED_AWS_ACCESS_KEY_1_"));
}
#[test]
fn one_runner_driven_across_independent_frame_calls() {
let scanners: Vec<Box<dyn Scanner>> = vec![
Box::new(PiiScanner::new()),
Box::new(RestoreScanner::for_pii()),
];
let mut stack = ScannerStack::new(scanners, true);
let redacted = stack.run_input("contact alice@example.com now").unwrap();
assert!(redacted.contains("[REDACTED_EMAIL_1_"));
let frames: Vec<String> = {
let bytes = redacted.as_bytes();
bytes
.chunks(4)
.map(|c| String::from_utf8_lossy(c).into_owned())
.collect()
};
let mut runner = StreamOutput::new(&stack);
let mut emitted = String::new();
for frame in &frames {
emitted.push_str(&runner.push(&mut stack, frame).unwrap());
assert!(!stack.ctx().vault.is_empty());
}
emitted.push_str(&runner.finish(&mut stack).unwrap());
assert_eq!(emitted, "contact alice@example.com now");
assert!(!emitted.contains("[REDACTED_EMAIL"));
}
#[test]
fn restore_encoder_json_escapes_only_the_restored_value() {
let value = "a\"b\\c";
let rule = RegexRule::new(®ex::escape(value), "NAME").unwrap();
let scanners: Vec<Box<dyn Scanner>> = vec![
Box::new(RegexScanner::new(vec![rule])),
Box::new(RestoreScanner::for_types(
ScannerId("native:pii-restore"),
["NAME".to_owned()],
)),
];
let mut stack = ScannerStack::new(scanners, true);
let redacted = stack.run_input(&format!("hi {value} bye")).unwrap();
let sentinel = redacted
.split_whitespace()
.find(|t| t.starts_with("[REDACTED_NAME_1_"))
.unwrap()
.to_owned();
let response = format!("{{\"text\":\"{sentinel}\"}}");
let identity_out = stream_collect(&mut stack, &response, 3);
assert_eq!(identity_out, format!("{{\"text\":\"{value}\"}}"));
stack.set_restore_encoder(RestoreEncoder::new(json_escape));
let escaped_out = stream_collect(&mut stack, &response, 3);
assert_eq!(
escaped_out,
format!("{{\"text\":\"{}\"}}", json_escape(value)),
);
assert!(escaped_out.contains("a\\\"b\\\\c"));
}
fn stream_collect(stack: &mut ScannerStack, response: &str, chunk_len: usize) -> String {
run_chunked(stack, response, chunk_len, |_| {})
}
fn json_escape(s: &str) -> String {
s.replace('\\', "\\\\").replace('"', "\\\"")
}