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 let reg = registry().read().expect("secret registry lock poisoned");
35 if reg.is_empty() {
36 return Cow::Borrowed(input);
37 }
38 let mut secrets: Vec<&str> = reg.iter().map(String::as_str).collect();
44 secrets.sort_by(|a, b| b.len().cmp(&a.len()).then_with(|| a.cmp(b)));
45 let mut out: Option<String> = None;
46 for secret in secrets {
47 let current = out.as_deref().unwrap_or(input);
48 if current.contains(secret) {
49 out = Some(current.replace(secret, "***"));
50 }
51 }
52 match out {
53 Some(s) => Cow::Owned(s),
54 None => Cow::Borrowed(input),
55 }
56}
57
58fn max_secret_len() -> usize {
61 registry()
62 .read()
63 .expect("secret registry lock poisoned")
64 .iter()
65 .map(String::len)
66 .max()
67 .unwrap_or(0)
68}
69
70pub struct RedactingWriter<W: Write> {
75 inner: W,
76 pending: Vec<u8>,
80}
81
82impl<W: Write> RedactingWriter<W> {
83 pub fn new(inner: W) -> Self {
84 Self {
85 inner,
86 pending: Vec::new(),
87 }
88 }
89}
90
91impl<W: Write> Write for RedactingWriter<W> {
92 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
93 self.pending.extend_from_slice(buf);
94 let keep = max_secret_len().saturating_sub(1);
100 if self.pending.len() > keep {
101 let scrubbed = redact(&String::from_utf8_lossy(&self.pending)).into_owned();
103 let mut split = scrubbed.len().saturating_sub(keep);
104 while split > 0 && !scrubbed.is_char_boundary(split) {
105 split -= 1;
106 }
107 let bytes = scrubbed.as_bytes();
110 self.inner.write_all(&bytes[..split])?;
111 self.pending.clear();
112 self.pending.extend_from_slice(&bytes[split..]);
113 }
114 Ok(buf.len())
117 }
118
119 fn flush(&mut self) -> io::Result<()> {
120 if !self.pending.is_empty() {
121 let scrubbed = redact(&String::from_utf8_lossy(&self.pending)).into_owned();
122 self.inner.write_all(scrubbed.as_bytes())?;
123 self.pending.clear();
124 }
125 self.inner.flush()
126 }
127}
128
129impl<W: Write> Drop for RedactingWriter<W> {
130 fn drop(&mut self) {
131 let _ = self.flush();
134 }
135}
136
137#[cfg(feature = "observability")]
141pub struct RedactingMakeWriter;
142
143#[cfg(feature = "observability")]
144impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for RedactingMakeWriter {
145 type Writer = RedactingWriter<std::io::Stderr>;
146 fn make_writer(&'a self) -> Self::Writer {
147 RedactingWriter::new(std::io::stderr())
148 }
149}
150
151#[cfg(test)]
152mod tests {
153 use super::*;
154 use serial_test::serial;
155
156 fn clear() {
157 registry().write().unwrap().clear();
158 }
159
160 #[test]
161 #[serial]
162 fn redacts_registered_value() {
163 clear();
164 register("supersecrettoken");
165 assert_eq!(
166 redact("Authorization: supersecrettoken"),
167 "Authorization: ***"
168 );
169 }
170
171 #[test]
172 #[serial]
173 fn leaves_unregistered_text_untouched() {
174 clear();
175 register("supersecrettoken");
176 assert_eq!(redact("nothing to see"), "nothing to see");
177 }
178
179 #[test]
180 #[serial]
181 fn does_not_register_short_values() {
182 clear();
183 register("abc"); assert_eq!(redact("abc def"), "abc def");
185 }
186
187 #[test]
188 #[serial]
189 fn redact_handles_overlapping_secrets_longest_first() {
190 clear();
191 register("abcd");
195 register("abcdXYZW");
196 let out = redact("value=abcdXYZW end");
197 assert!(
198 !out.contains("XYZW"),
199 "longer secret partially leaked: {out}"
200 );
201 assert_eq!(out, "value=*** end");
202 }
203
204 #[test]
205 #[serial]
206 fn writer_scrubs_secret_split_across_writes() {
207 clear();
208 register("supersecretvalue");
209 let mut buf: Vec<u8> = Vec::new();
210 {
211 let mut w = RedactingWriter::new(&mut buf);
212 w.write_all(b"token=supersec").unwrap();
214 w.write_all(b"retvalue done").unwrap();
215 w.flush().unwrap();
216 }
217 let out = String::from_utf8(buf).unwrap();
218 assert!(
219 !out.contains("supersecretvalue"),
220 "secret leaked across write boundary: {out}"
221 );
222 assert_eq!(out, "token=*** done");
223 }
224
225 #[test]
226 #[serial]
227 fn writer_scrubs_secret_on_write() {
228 clear();
229 let secret = "hunter2pass";
230 register(secret);
231 let mut buf: Vec<u8> = Vec::new();
232 {
233 let mut w = RedactingWriter::new(&mut buf);
234 write!(w, "token={secret} done").unwrap();
235 w.flush().unwrap();
236 }
237 assert_eq!(String::from_utf8(buf).unwrap(), "token=*** done");
238 }
239}