const DEFAULT_HEAD_LEN: usize = 4;
const REDACTED: &str = "[REDACTED]";
const MIN_SCRUBBABLE_SECRET_CHARS: usize = 8;
pub fn redact_secret(secret: &str) -> String {
let byte_len = secret.len();
if secret.chars().count() <= DEFAULT_HEAD_LEN {
return format!("…({byte_len} chars)");
}
let head: String = secret.chars().take(DEFAULT_HEAD_LEN).collect();
format!("{head}…({byte_len} chars)")
}
pub fn scrub_secrets<S: AsRef<str>>(text: &str, secrets: &[S]) -> String {
let mut needles: Vec<&str> = secrets
.iter()
.map(AsRef::as_ref)
.filter(|s| s.chars().count() >= MIN_SCRUBBABLE_SECRET_CHARS)
.collect();
needles.sort_unstable_by_key(|s| std::cmp::Reverse(s.len()));
let mut out = text.to_string();
for needle in needles {
if out.contains(needle) {
out = out.replace(needle, REDACTED);
}
}
out
}
pub fn resolved_secret_values() -> Vec<String> {
super::load_env_local_once();
let store = super::default_store();
super::registered_providers()
.iter()
.filter_map(|(provider, _)| super::resolve_key_with(provider, store.as_ref()))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn contract_redact_secret_never_echoes_a_short_secret() {
for n in 0..=12usize {
let secret: String = "abcdefghijkl".chars().take(n).collect();
let out = redact_secret(&secret);
assert!(
out.contains(&format!("({} chars)", secret.len())),
"n={n}: byte length must be reported, got {out}"
);
if n <= DEFAULT_HEAD_LEN {
assert_eq!(out, format!("…({} chars)", secret.len()), "n={n}");
} else {
let head: String = secret.chars().take(DEFAULT_HEAD_LEN).collect();
assert!(out.starts_with(&head), "n={n}");
let disclosed: String = secret.chars().take(DEFAULT_HEAD_LEN + 1).collect();
assert!(
!out.contains(&disclosed),
"n={n}: disclosed more than the head, got {out}"
);
}
}
let multi = "héllo-wörld-secret";
let out = redact_secret(multi);
assert!(out.contains(&format!("({} chars)", multi.len())));
}
#[test]
fn contract_scrub_secrets_removes_every_qualifying_needle() {
let long = "sk-abcdefghijklmnop"; let prefix = "sk-abcdefgh";
let text = format!("first {long} then {long} again");
let out = scrub_secrets(&text, &[long]);
assert!(!out.contains(long), "every occurrence must go: {out}");
assert_eq!(out.matches(REDACTED).count(), 2);
let out = scrub_secrets(long, &[prefix, long]);
assert!(!out.contains(prefix), "prefix leaked: {out}");
assert!(
!out.contains("ijklmnop"),
"tail of the longer secret leaked: {out}"
);
let short = "abc";
assert!(short.chars().count() < MIN_SCRUBBABLE_SECRET_CHARS);
let diagnostic = "connection to abc-host refused";
assert_eq!(scrub_secrets(diagnostic, &[short, ""]), diagnostic);
assert_eq!(scrub_secrets(diagnostic, &[long]), diagnostic);
assert_eq!(scrub_secrets(diagnostic, &[] as &[&str]), diagnostic);
}
#[test]
fn redact_secret_masks_tail() {
let r = redact_secret("AbCd1234EfGh5678IjKl9012"); assert!(r.starts_with("AbCd"));
assert!(r.contains('…'));
assert!(r.contains("chars"));
assert!(!r.contains("9012"), "tail must be masked: {r}");
}
#[test]
fn redact_secret_handles_short_input() {
assert_eq!(redact_secret(""), "…(0 chars)");
assert_eq!(redact_secret("ab"), "…(2 chars)");
}
#[test]
fn redact_secret_short_inputs_table() {
let cases: &[(&str, &str)] = &[
("", "…(0 chars)"),
("a", "…(1 chars)"),
("ab", "…(2 chars)"),
("abc", "…(3 chars)"),
("abcd", "…(4 chars)"),
("abcde", "abcd…(5 chars)"),
];
for (input, expected) in cases {
assert_eq!(
redact_secret(input),
*expected,
"mismatch for input {input:?}"
);
}
}
#[test]
fn scrub_secrets_removes_every_occurrence() {
let key = "sk-or-verysecret1234"; let msg = format!("inference API error 400: bad key {key}, retry without {key}");
let scrubbed = scrub_secrets(&msg, &[key]);
assert!(!scrubbed.contains(key), "leaked: {scrubbed}");
assert_eq!(scrubbed.matches(REDACTED).count(), 2);
}
#[test]
fn scrub_secrets_removes_multiple_distinct_secrets() {
let openrouter = "sk-or-v1-aaaaaaaaaaaaaaaa"; let anthropic = "sk-ant-api03-bbbbbbbbbbbb"; let oauth = "sk-ant-oat01-cccccccccccc"; let text = format!(
"auth failed for {openrouter}\nfallback {anthropic} rejected\nand {oauth} expired"
);
let scrubbed = scrub_secrets(
&text,
&[openrouter.to_string(), anthropic.into(), oauth.into()],
);
for leaked in [openrouter, anthropic, oauth] {
assert!(!scrubbed.contains(leaked), "leaked {leaked}: {scrubbed}");
}
assert_eq!(scrubbed.matches(REDACTED).count(), 3);
assert!(scrubbed.contains("auth failed for"), "{scrubbed}");
}
#[test]
fn scrub_secrets_ignores_empty_and_short_values() {
let msg = "no `.trusty-agents/agents/` found in /.";
assert_eq!(scrub_secrets(msg, &[""]), msg);
assert_eq!(scrub_secrets(msg, &["a"]), msg);
assert_eq!(scrub_secrets(msg, &["nt"]), msg);
assert_eq!(scrub_secrets(msg, &["found"]), msg);
assert_eq!(scrub_secrets("xx1234567 yy", &["1234567"]), "xx1234567 yy");
assert_eq!(
scrub_secrets("xx12345678 yy", &["12345678"]),
"xx[REDACTED] yy"
);
let real = "sk-or-v1-realsecret0001"; let text = format!("bad key {real}");
assert_eq!(scrub_secrets(&text, &["", "x", real]), "bad key [REDACTED]");
}
#[test]
fn scrub_secrets_prefers_the_longest_overlapping_secret() {
let short = "sk-ant-prefix000"; let long = format!("{short}-with-a-longer-tail");
let text = format!("rejected {long}");
let scrubbed = scrub_secrets(&text, &[short.to_string(), long.clone()]);
assert_eq!(scrubbed, "rejected [REDACTED]");
assert!(!scrubbed.contains("-with-a-longer-tail"), "{scrubbed}");
}
#[test]
fn scrub_secrets_is_noop_when_nothing_matches() {
let msg = "Error: no `.trusty-agents/agents/` found in /.";
let empty: &[&str] = &[];
assert_eq!(scrub_secrets(msg, empty), msg);
assert_eq!(scrub_secrets(msg, &["sk-or-v1-notpresent"]), msg);
}
#[test]
#[serial_test::serial(dotenv_credential_env)]
fn resolved_secret_values_are_scrubbable_by_scrub_secrets() {
let values = resolved_secret_values();
assert!(
values.len() <= super::super::registered_providers().len(),
"cannot resolve more secrets than there are registered providers"
);
for v in &values {
assert!(!v.is_empty(), "the resolver never yields an empty value");
}
let probe: Vec<String> = values.into_iter().collect();
let text = probe
.iter()
.map(|v| format!("saw {v}"))
.collect::<Vec<_>>()
.join("\n");
let scrubbed = scrub_secrets(&text, &probe);
for v in &probe {
if v.chars().count() >= MIN_SCRUBBABLE_SECRET_CHARS {
assert!(!scrubbed.contains(v.as_str()), "leaked a resolved value");
}
}
}
}