use faultbox::{BasicRedactor, Redactor};
fn redactor() -> BasicRedactor {
BasicRedactor::new().home("/nonexistent-home-for-tests")
}
fn assert_no_trace(output: &str, secret: &str) {
assert!(
!output.contains(secret),
"redacted output still carries the credential\n secret: {secret}\n output: {output}"
);
}
#[test]
fn authorization_header_credentials_are_masked_past_the_scheme() {
let r = redactor();
let out = r.redact("request failed: authorization: Bearer sk-live-4242deadbeef4242 rejected");
assert_no_trace(&out, "sk-live-4242deadbeef4242");
assert_eq!(
out, "request failed: authorization: [redacted] rejected",
"the scheme and its credential are one value; the surrounding prose stays readable"
);
let out = r.redact("authorization: Basic YWRhOmh1bnRlcjI=");
assert_no_trace(&out, "YWRhOmh1bnRlcjI=");
}
#[test]
fn quoted_values_are_masked_to_the_closing_quote() {
let r = redactor();
let out = r.redact(r#"Config { password: "hunter 2", port: 5432 }"#);
assert_no_trace(&out, "hunter 2");
assert_no_trace(&out, " 2");
assert_eq!(
out, r#"Config { password: "[redacted]", port: 5432 }"#,
"the quotes delimit the value; the diagnostic field after it survives"
);
let out = r.redact("secret = 'multi word value'");
assert_no_trace(&out, "multi word value");
assert_no_trace(&out, "word value");
}
#[test]
fn a_quote_inside_an_unquoted_value_is_content_not_the_end_of_it() {
let r = redactor();
let out = r.redact("secret=don't-tell-anyone");
assert_no_trace(&out, "t-tell-anyone");
assert_eq!(out, "secret=[redacted]");
}
#[test]
fn quoted_keys_are_recognised() {
let r = redactor();
let out = r.redact(r#"{"api_key":"sk-plain-1234"}"#);
assert_no_trace(&out, "sk-plain-1234");
assert_eq!(out, r#"{"api_key":"[redacted]"}"#);
let out = r.redact(r#"upstream said: {"authorization": "Bearer sk-live-9999"}"#);
assert_no_trace(&out, "sk-live-9999");
}
#[test]
fn whitespace_around_the_separator_does_not_defeat_matching() {
let r = redactor();
let out = r.redact(r#"token = "sk-toml-1234""#);
assert_no_trace(&out, "sk-toml-1234");
assert_eq!(out, r#"token = "[redacted]""#);
let out = r.redact("Authorization : Bearer sk-spaced-1234");
assert_no_trace(&out, "sk-spaced-1234");
}
#[test]
fn composite_values_are_masked_through_their_structure() {
let r = redactor();
let out =
r.redact(r#"LlmConfig { api_key: Some("sk-azure-9f8e7d6c"), endpoint: "https://x" }"#);
assert_no_trace(&out, "sk-azure-9f8e7d6c");
assert_eq!(
out, r#"LlmConfig { api_key: Some("[redacted]"), endpoint: "https://x" }"#,
"the wrapper is structure worth keeping, and a sibling field is not the secret"
);
let out = r.redact(r#"Config { token: Some("sk-opt-1234"), retries: 3 }"#);
assert_no_trace(&out, "sk-opt-1234");
assert_eq!(out, r#"Config { token: Some("[redacted]"), retries: 3 }"#);
let out = r.redact(r#"token: Credentials("ada", "hunter2")"#);
assert_no_trace(&out, "hunter2");
assert_eq!(out, r#"token: Credentials("[redacted]", "[redacted]")"#);
let out = r.redact(r#"token: ["aaa", "bbb"]"#);
assert_no_trace(&out, "bbb");
let out = r.redact(r#"password: Secret { inner: "hunter2", tries: 3 }"#);
assert_no_trace(&out, "hunter2");
assert_eq!(
out, r#"password: Secret { inner: "[redacted]", tries: [redacted] }"#,
"under a key that means credential outright, everything inside is suspect"
);
let out = r.redact(r#"api_key: Some(Secret("hunter2"))"#);
assert_no_trace(&out, "hunter2");
}
#[test]
fn a_composite_under_a_credential_adjacent_key_is_judged_field_by_field() {
let r = redactor();
let out = r.redact(r#"grouping_key: Key { kind: "0x09", password: "hunter2" }"#);
assert_no_trace(&out, "hunter2");
assert_eq!(
out,
r#"grouping_key: Key { kind: "0x09", password: "[redacted]" }"#
);
}
#[test]
fn hyphenated_header_keys_are_recognised() {
let r = redactor();
let out = r.redact("x-api-key: sk-header-1234");
assert_no_trace(&out, "sk-header-1234");
assert_eq!(out, "x-api-key: [redacted]");
let out = r.redact("proxy-authorization: Bearer sk-proxy-1234");
assert_no_trace(&out, "sk-proxy-1234");
}
#[test]
fn credential_bearing_key_suffixes_are_recognised() {
let r = redactor();
let out = r.redact("AZURE_OPENAI_KEY=sk-azure-abcdef123456");
assert_no_trace(&out, "sk-azure-abcdef123456");
assert_eq!(out, "AZURE_OPENAI_KEY=[redacted]");
let out = r.redact("GITHUB_ACCESS_TOKEN=ghp_abcdefghijklmnopqrstuvwxyz0123456789");
assert_no_trace(&out, "ghp_abcdefghijklmnopqrstuvwxyz0123456789");
let out = r.redact("stripe_secret_key=sk_live_abcdefghijklmnop");
assert_no_trace(&out, "sk_live_abcdefghijklmnop");
}
#[test]
fn structural_keys_that_merely_end_in_key_stay_readable() {
let r = redactor();
assert_eq!(r.redact("grouping_key=kind=0x09"), "grouping_key=kind=0x09");
assert_eq!(r.redact("sort_key=page_id"), "sort_key=page_id");
assert_eq!(r.redact("partition_key=7"), "partition_key=7");
}
#[test]
fn json_object_keys_naming_a_secret_are_masked() {
let r = redactor();
let mut v = serde_json::json!({
"authorization": "Bearer sk-json-1234",
"page_id": 828,
"upstream": { "password": "hunter2" },
"headers": [ { "x-api-key": "sk-array-1234" } ],
});
r.redact_json(&mut v);
let rendered = v.to_string();
assert_no_trace(&rendered, "sk-json-1234");
assert_no_trace(&rendered, "hunter2");
assert_no_trace(&rendered, "sk-array-1234");
assert_eq!(v["page_id"], 828, "structural fields survive untouched");
}
#[test]
fn every_address_in_a_list_is_masked() {
let r = redactor();
let out = r.redact("owners ada@example.com,grace@example.com lost the write");
assert_no_trace(&out, "ada@example.com");
assert_no_trace(&out, "grace@example.com");
assert_eq!(out, "owners [email],[email] lost the write");
let out = r.redact("from <ada@example.com>; to <grace@example.com>");
assert_no_trace(&out, "ada@example.com");
assert_no_trace(&out, "grace@example.com");
}
#[test]
fn addresses_delimited_by_other_whitespace_do_not_swallow_the_line() {
let r = redactor();
assert_eq!(
r.redact("caused by:\nada@example.com lost the write"),
"caused by:\n[email] lost the write"
);
assert_eq!(
r.redact("owner\tada@example.com\tstate=open"),
"owner\t[email]\tstate=open"
);
}
#[test]
fn url_query_secrets_end_at_the_parameter_boundary() {
let r = redactor();
let out = r.redact("GET https://api.example.com/v1?api_key=sk-url-1234&page=3 -> 401");
assert_no_trace(&out, "sk-url-1234");
assert_eq!(
out,
"GET https://api.example.com/v1?api_key=[redacted]&page=3 -> 401"
);
}
#[test]
fn plain_assignments_and_lookalike_keys_keep_their_existing_behaviour() {
let r = redactor();
assert_eq!(
r.redact("connect failed token=sk-abc123 retries=3"),
"connect failed token=[redacted] retries=3"
);
assert_eq!(r.redact("broken_tokens=4"), "broken_tokens=4");
assert_eq!(r.redact("page_id=828"), "page_id=828");
assert_eq!(r.redact("user@localhost"), "user@localhost");
}
#[test]
fn an_unquoted_value_is_one_token_and_the_message_survives() {
let r = redactor();
assert_eq!(
r.redact("authorization: header missing"),
"authorization: [redacted] missing"
);
assert_eq!(
r.redact("password: incorrect for user 828"),
"password: [redacted] for user 828"
);
let out = r.redact("password: was sk-live-4242deadbeef4242 all along");
assert_no_trace(&out, "sk-live-4242deadbeef4242");
assert_eq!(out, "password: [redacted] [redacted] all along");
}
#[test]
fn credentials_are_masked_even_with_no_key_in_front_of_them() {
let r = redactor();
let out = r.redact("retrying with sk-live-4242deadbeef4242 after 401");
assert_no_trace(&out, "sk-live-4242deadbeef4242");
assert_eq!(out, "retrying with [redacted] after 401");
let jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dBjftJeZ4CVPmB92K27uhbUJ";
let out = r.redact(&format!("GET /v1/session?t={jwt} -> 401"));
assert_no_trace(&out, jwt);
let out = r
.redact("-----BEGIN RSA PRIVATE KEY-----\nMIIEpQIBAAKCAQEA\n-----END RSA PRIVATE KEY-----");
assert_no_trace(&out, "MIIEpQIBAAKCAQEA");
assert!(
out.contains("-----BEGIN RSA PRIVATE KEY-----"),
"that a key was present is itself diagnostic: {out}"
);
}
#[test]
fn identifiers_that_make_a_report_readable_are_not_mistaken_for_credentials() {
let r = redactor();
for input in [
"build_id=9f8e7d6c5b4a392817069f8e7d6c5b4a39281706",
"fingerprint 5b1e0c9a4f2d8e7b6a3c1d0f9e8b7a6c",
"at 0x00007f9c8b2a4e10 in libstore.so",
"trace_id=4bf92f3577b34da6a3ce929d0e0e4736",
] {
assert_eq!(r.redact(input), input, "{input}");
}
}
#[test]
fn credential_adjacent_keys_mask_on_the_value_not_the_name() {
let r = redactor();
assert_eq!(r.redact("auth: none, retries=3"), "auth: none, retries=3");
assert_eq!(r.redact("auth_attempts=3"), "auth_attempts=3");
let out = r.redact("auth=sk-live-4242deadbeef4242");
assert_no_trace(&out, "sk-live-4242deadbeef4242");
}
#[test]
fn the_home_directory_survives_case_separator_and_boundary_differences() {
let unix = BasicRedactor::new().home("/home/ada");
assert_eq!(
unix.redact("failed to open /home/ada/db/main.db"),
"failed to open ~/db/main.db",
"the path shape a maintainer needs survives; the username does not"
);
assert_eq!(
BasicRedactor::new()
.home("/home/ad")
.redact("/home/adam/db"),
"/home/adam/db"
);
assert_eq!(
unix.redact("spilled to /var/log/ada/store.log"),
"spilled to /var/log/~user/store.log"
);
let windows = BasicRedactor::new().home("C:\\Users\\Ada");
assert_eq!(
windows.redact("open c:/users/ada/db/main.db"),
"open ~/db/main.db"
);
assert_eq!(windows.redact("open C:\\Users\\Ada\\db"), "open ~\\db");
}
#[test]
fn map_keys_made_of_user_data_are_masked() {
let r = redactor();
let mut v = serde_json::json!({
"ada@example.com": { "writes": 3 },
"grace@example.com": { "writes": 4 },
});
r.redact_json(&mut v);
let rendered = v.to_string();
assert_no_trace(&rendered, "ada@example.com");
assert_no_trace(&rendered, "grace@example.com");
assert_eq!(
v.as_object().unwrap().len(),
2,
"colliding masked keys must not silently discard a member"
);
}
#[test]
fn redaction_never_panics_and_always_settles_on_arbitrary_input() {
let r = redactor();
const ALPHABET: &[&str] = &[
"=",
":",
"\"",
"'",
" ",
"\t",
"\n",
",",
";",
"&",
"(",
")",
"[",
"]",
"{",
"}",
"/",
"\\",
"@",
".",
"-",
"_",
"~",
"token",
"password",
"authorization",
"api_key",
"grouping_key",
"auth",
"Bearer",
"Some",
"sk-live-4242deadbeef",
"ada@example.com",
"[redacted]",
"[email]",
"café",
"→",
"日本語",
"0x09",
"828",
"\u{0}",
];
let mut state: u64 = 0x2545_F491_4F6C_DD1D;
let mut next = move || {
state = state
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
(state >> 33) as usize
};
for _ in 0..20_000 {
let mut input = String::new();
for _ in 0..(next() % 24) {
input.push_str(ALPHABET[next() % ALPHABET.len()]);
}
let once = r.redact(&input);
assert_eq!(
r.redact(&once),
once,
"redaction must reach a fixed point, or repeated passes corrupt \
the report: {input:?}"
);
assert!(
!starts_a_word(&once, CREDENTIAL),
"a credential survived: {input:?} -> {once:?}"
);
}
}
const CREDENTIAL: &str = "sk-live-4242deadbeef";
fn starts_a_word(input: &str, credential: &str) -> bool {
input.match_indices(credential).any(|(at, _)| {
at == 0
|| !input[..at]
.chars()
.next_back()
.is_some_and(|c| c.is_ascii_alphanumeric())
})
}
#[test]
fn redacting_an_already_redacted_string_changes_nothing() {
let r = redactor();
for input in [
"request failed: authorization: Bearer sk-live-4242deadbeef4242 rejected",
r#"Config { password: "hunter 2", port: 5432 }"#,
r#"{"api_key":"sk-plain-1234"}"#,
r#"token = "sk-toml-1234""#,
r#"Config { token: Some("sk-opt-1234"), retries: 3 }"#,
"x-api-key: sk-header-1234",
"AZURE_OPENAI_KEY=sk-azure-abcdef123456",
"owners ada@example.com,grace@example.com lost the write",
"GET https://api.example.com/v1?api_key=sk-url-1234&page=3 -> 401",
"connect failed token=sk-abc123 retries=3",
] {
let once = r.redact(input);
assert_eq!(
r.redact(&once),
once,
"re-redacting must not corrupt: {input}"
);
}
}