faucet_cli/secrets/
registry.rs1use std::borrow::Cow;
9use std::collections::HashSet;
10use std::io::{self, Write};
11use std::sync::{OnceLock, RwLock};
12
13const MIN_REDACT_LEN: usize = 4;
16
17fn registry() -> &'static RwLock<HashSet<String>> {
18 static REG: OnceLock<RwLock<HashSet<String>>> = OnceLock::new();
19 REG.get_or_init(|| RwLock::new(HashSet::new()))
20}
21
22pub fn register(secret: &str) {
24 if secret.len() >= MIN_REDACT_LEN {
25 registry()
26 .write()
27 .expect("secret registry lock poisoned")
28 .insert(secret.to_owned());
29 }
30}
31
32pub fn redact(input: &str) -> Cow<'_, str> {
34 redact_with(input, |_| "***".to_owned())
35}
36
37pub fn redact_with(input: &str, token: impl Fn(&str) -> String) -> Cow<'_, str> {
44 let reg = registry().read().expect("secret registry lock poisoned");
45 if reg.is_empty() {
46 return Cow::Borrowed(input);
47 }
48 let mut secrets: Vec<&str> = reg.iter().map(String::as_str).collect();
54 secrets.sort_by(|a, b| b.len().cmp(&a.len()).then_with(|| a.cmp(b)));
55 let mut out: Option<String> = None;
56 for secret in secrets {
57 let current = out.as_deref().unwrap_or(input);
58 if current.contains(secret) {
59 out = Some(current.replace(secret, &token(secret)));
60 }
61 }
62 match out {
63 Some(s) => Cow::Owned(s),
64 None => Cow::Borrowed(input),
65 }
66}
67
68fn max_secret_len() -> usize {
71 registry()
72 .read()
73 .expect("secret registry lock poisoned")
74 .iter()
75 .map(String::len)
76 .max()
77 .unwrap_or(0)
78}
79
80pub struct RedactingWriter<W: Write> {
85 inner: W,
86 pending: Vec<u8>,
90}
91
92impl<W: Write> RedactingWriter<W> {
93 pub fn new(inner: W) -> Self {
94 Self {
95 inner,
96 pending: Vec::new(),
97 }
98 }
99}
100
101impl<W: Write> Write for RedactingWriter<W> {
102 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
103 self.pending.extend_from_slice(buf);
104 let keep = max_secret_len().saturating_sub(1);
110 if self.pending.len() > keep {
111 let scrubbed = redact(&String::from_utf8_lossy(&self.pending)).into_owned();
113 let mut split = scrubbed.len().saturating_sub(keep);
114 while split > 0 && !scrubbed.is_char_boundary(split) {
115 split -= 1;
116 }
117 let bytes = scrubbed.as_bytes();
120 self.inner.write_all(&bytes[..split])?;
121 self.pending.clear();
122 self.pending.extend_from_slice(&bytes[split..]);
123 }
124 Ok(buf.len())
127 }
128
129 fn flush(&mut self) -> io::Result<()> {
130 if !self.pending.is_empty() {
131 let scrubbed = redact(&String::from_utf8_lossy(&self.pending)).into_owned();
132 self.inner.write_all(scrubbed.as_bytes())?;
133 self.pending.clear();
134 }
135 self.inner.flush()
136 }
137}
138
139impl<W: Write> Drop for RedactingWriter<W> {
140 fn drop(&mut self) {
141 let _ = self.flush();
144 }
145}
146
147#[cfg(feature = "observability")]
151pub struct RedactingMakeWriter;
152
153#[cfg(feature = "observability")]
154impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for RedactingMakeWriter {
155 type Writer = RedactingWriter<std::io::Stderr>;
156 fn make_writer(&'a self) -> Self::Writer {
157 RedactingWriter::new(std::io::stderr())
158 }
159}
160
161#[cfg(test)]
162mod tests {
163 use super::*;
164 use serial_test::serial;
165
166 fn clear() {
167 registry().write().unwrap().clear();
168 }
169
170 #[test]
171 #[serial]
172 fn redacts_registered_value() {
173 clear();
174 register("supersecrettoken");
175 assert_eq!(
176 redact("Authorization: supersecrettoken"),
177 "Authorization: ***"
178 );
179 }
180
181 #[test]
182 #[serial]
183 fn leaves_unregistered_text_untouched() {
184 clear();
185 register("supersecrettoken");
186 assert_eq!(redact("nothing to see"), "nothing to see");
187 }
188
189 #[test]
190 #[serial]
191 fn does_not_register_short_values() {
192 clear();
193 register("abc"); assert_eq!(redact("abc def"), "abc def");
195 }
196
197 #[test]
198 #[serial]
199 fn redact_handles_overlapping_secrets_longest_first() {
200 clear();
201 register("abcd");
205 register("abcdXYZW");
206 let out = redact("value=abcdXYZW end");
207 assert!(
208 !out.contains("XYZW"),
209 "longer secret partially leaked: {out}"
210 );
211 assert_eq!(out, "value=*** end");
212 }
213
214 #[test]
215 #[serial]
216 fn writer_scrubs_secret_split_across_writes() {
217 clear();
218 register("supersecretvalue");
219 let mut buf: Vec<u8> = Vec::new();
220 {
221 let mut w = RedactingWriter::new(&mut buf);
222 w.write_all(b"token=supersec").unwrap();
224 w.write_all(b"retvalue done").unwrap();
225 w.flush().unwrap();
226 }
227 let out = String::from_utf8(buf).unwrap();
228 assert!(
229 !out.contains("supersecretvalue"),
230 "secret leaked across write boundary: {out}"
231 );
232 assert_eq!(out, "token=*** done");
233 }
234
235 #[test]
236 #[serial]
237 fn writer_scrubs_secret_on_write() {
238 clear();
239 let secret = "hunter2pass";
240 register(secret);
241 let mut buf: Vec<u8> = Vec::new();
242 {
243 let mut w = RedactingWriter::new(&mut buf);
244 write!(w, "token={secret} done").unwrap();
245 w.flush().unwrap();
246 }
247 assert_eq!(String::from_utf8(buf).unwrap(), "token=*** done");
248 }
249}