1use secrecy::{ExposeSecret, SecretString};
2
3#[derive(Default)]
4pub struct Redactor<'a> {
5 secrets: Vec<&'a SecretString>,
6}
7
8impl<'a> Redactor<'a> {
9 pub fn with_secret(secret: &'a SecretString) -> Self {
10 Self {
11 secrets: vec![secret],
12 }
13 }
14
15 pub fn and_secret(mut self, secret: &'a SecretString) -> Self {
16 self.secrets.push(secret);
17 self
18 }
19
20 pub fn redact(&self, value: &str) -> String {
21 let mut secrets: Vec<_> = self
22 .secrets
23 .iter()
24 .map(|secret| secret.expose_secret())
25 .filter(|secret| !secret.is_empty())
26 .collect();
27 secrets.sort_unstable_by_key(|secret| std::cmp::Reverse(secret.len()));
28
29 secrets.into_iter().fold(value.to_owned(), |text, secret| {
30 text.replace(secret, "[REDACTED]")
31 })
32 }
33}
34
35#[cfg(test)]
36mod tests {
37 use secrecy::SecretString;
38
39 use super::Redactor;
40
41 #[test]
42 fn redacts_all_occurrences_without_exposing_debug_values() {
43 let secret = SecretString::from("do not print me".to_owned());
44 let redactor = Redactor::with_secret(&secret);
45 let result = redactor.redact("do not print me / do not print me");
46
47 assert_eq!(result, "[REDACTED] / [REDACTED]");
48 assert!(!format!("{secret:?}").contains("do not print me"));
49 }
50
51 #[test]
52 fn redacts_overlapping_secrets_without_leaking_a_suffix() {
53 let password = SecretString::from("123".to_owned());
54 let code = SecretString::from("123456".to_owned());
55 let redactor = Redactor::with_secret(&password).and_secret(&code);
56
57 assert_eq!(
58 redactor.redact("code 123456 was rejected"),
59 "code [REDACTED] was rejected"
60 );
61 }
62}