1use kernel::B3Hash;
36use serde_json::{Map, Value};
37
38const REDACTED_REALM: &str = "redacted";
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum Marker {
52 Plain,
54 Fingerprinted,
57}
58
59impl Marker {
60 #[must_use]
62 pub fn spell(self, found: &[u8]) -> String {
63 match self {
64 Marker::Plain => format!("secret:{REDACTED_REALM}"),
65 Marker::Fingerprinted => {
66 format!("secret:{REDACTED_REALM}/{}", fingerprint(found))
67 }
68 }
69 }
70}
71
72#[must_use]
77pub fn redact(payload: &Map<String, Value>) -> (Map<String, Value>, u32) {
78 let mut hits = 0;
79 let mut out = Map::new();
80 for (key, value) in payload {
81 out.insert(key.clone(), walk(value, &mut hits));
82 }
83 (out, hits)
84}
85
86fn walk(value: &Value, hits: &mut u32) -> Value {
87 match value {
88 Value::String(text) => {
89 let (replaced, found) = redact_text(text, Marker::Fingerprinted);
90 *hits = hits.saturating_add(found);
91 Value::String(replaced)
92 }
93 Value::Array(items) => Value::Array(items.iter().map(|item| walk(item, hits)).collect()),
94 Value::Object(map) => {
95 let mut out = Map::new();
96 for (key, item) in map {
97 out.insert(key.clone(), walk(item, hits));
98 }
99 Value::Object(out)
100 }
101 Value::Null | Value::Bool(_) | Value::Number(_) => value.clone(),
102 }
103}
104
105#[must_use]
111pub fn redact_text(text: &str, marker: Marker) -> (String, u32) {
112 let bytes = text.as_bytes();
113 let spans = kernel::secret::scan(bytes);
114 if spans.is_empty() {
115 return (text.to_owned(), 0);
116 }
117 let mut out = String::with_capacity(text.len());
118 let mut at = 0usize;
119 let mut hits: u32 = 0;
120 for span in spans {
121 let end = span.start.saturating_add(span.len);
122 if span.start < at || end > bytes.len() {
123 continue;
124 }
125 let Some(before) = bytes
126 .get(at..span.start)
127 .and_then(|b| std::str::from_utf8(b).ok())
128 else {
129 continue;
130 };
131 let Some(found) = bytes.get(span.start..end) else {
132 continue;
133 };
134 out.push_str(before);
135 out.push_str(&marker.spell(found));
136 at = end;
137 hits = hits.saturating_add(1);
138 }
139 if let Some(rest) = bytes.get(at..).and_then(|b| std::str::from_utf8(b).ok()) {
143 out.push_str(rest);
144 }
145 (out, hits)
146}
147
148const FINGERPRINT_HEX: usize = 16;
152
153#[must_use]
162pub fn fingerprint(found: &[u8]) -> String {
163 let digest = B3Hash::digest(found).to_string();
164 digest.get(..FINGERPRINT_HEX).unwrap_or(&digest).to_owned()
165}
166
167#[cfg(test)]
168#[allow(
169 clippy::unwrap_used,
170 clippy::expect_used,
171 clippy::panic,
172 clippy::indexing_slicing,
173 reason = "test code"
174)]
175mod tests {
176 use super::*;
177
178 fn key_shaped() -> String {
181 ["sk", "9fQ2xZ", "7Lm4Rt", "0Bv8Kd", "3Wp6"].join("")
182 }
183
184 #[test]
185 fn a_key_shaped_run_of_bytes_does_not_reach_the_payload() {
186 let key = key_shaped();
187 let mut payload = Map::new();
188 payload.insert(
189 "message".to_owned(),
190 Value::String(format!("the token is {key} and it works")),
191 );
192 let (redacted, hits) = redact(&payload);
193 assert_eq!(hits, 1);
194 let text = redacted
195 .get("message")
196 .and_then(Value::as_str)
197 .unwrap()
198 .to_owned();
199 assert!(!text.contains(&key), "the value itself is gone");
200 assert!(text.contains("the token is "), "the sentence survives");
201 assert!(text.contains("secret:redacted/"));
202 }
203
204 #[test]
205 fn the_same_value_twice_marks_the_same_way_and_two_values_do_not() {
206 let key = key_shaped();
207 let other = ["sk", "2Tg7Yh", "4Nn1Qs", "8Cz5Ud", "6Ke0"].join("");
208 let (first, _) = redact_text(&key, Marker::Fingerprinted);
209 let (again, _) = redact_text(&key, Marker::Fingerprinted);
210 let (different, _) = redact_text(&other, Marker::Fingerprinted);
211 assert_eq!(first, again, "one value, one marker");
212 assert_ne!(first, different, "two values are distinguishable");
213 }
214
215 #[test]
216 fn nested_strings_are_reached_and_ordinary_prose_is_untouched() {
217 let key = key_shaped();
218 let mut inner = Map::new();
219 inner.insert("text".to_owned(), Value::String(key.clone()));
220 let mut payload = Map::new();
221 payload.insert(
222 "content".to_owned(),
223 Value::Array(vec![Value::Object(inner)]),
224 );
225 payload.insert(
226 "prose".to_owned(),
227 Value::String("nothing here looks like a key at all".to_owned()),
228 );
229 payload.insert("count".to_owned(), Value::Number(3.into()));
230 let (redacted, hits) = redact(&payload);
231 assert_eq!(hits, 1);
232 assert_eq!(
233 redacted.get("prose").and_then(Value::as_str),
234 Some("nothing here looks like a key at all"),
235 "prose is not a shape, and a scanner that trimmed it would be unusable"
236 );
237 assert_eq!(redacted.get("count").and_then(Value::as_u64), Some(3));
238 let text = serde_json::to_string(&redacted).unwrap();
239 assert!(!text.contains(&key));
240 }
241
242 #[test]
248 fn a_marker_is_a_reference_a_reader_can_follow_to_an_honest_nothing() {
249 let key = key_shaped();
250 let short = fingerprint(key.as_bytes());
251 assert_eq!(short.len(), 16);
252 let (marked, hits) = redact_text(&key, Marker::Fingerprinted);
253 assert_eq!(hits, 1);
254 assert_eq!(marked, format!("secret:redacted/{short}"));
255 let reference = kernel::SecretRef::parse(&marked).unwrap();
256 assert_eq!(reference.realm(), REDACTED_REALM);
257 assert_eq!(reference.name(), short);
258 }
259
260 #[test]
263 fn a_plain_marker_says_that_something_was_here_and_nothing_more() {
264 let key = key_shaped();
265 let (line, hits) = redact_text(&format!("token {key} used"), Marker::Plain);
266 assert_eq!(hits, 1);
267 assert_eq!(line, "token secret:redacted used");
268 assert!(!line.contains(&fingerprint(key.as_bytes())));
269 let (kept, _) = redact_text(&format!("token {key} used"), Marker::Fingerprinted);
270 assert!(kept.contains(&fingerprint(key.as_bytes())));
271 }
272
273 #[test]
274 fn a_payload_with_nothing_to_hide_comes_back_unchanged() {
275 let mut payload = Map::new();
276 payload.insert(
277 "message".to_owned(),
278 Value::String("measured in metres".to_owned()),
279 );
280 let (redacted, hits) = redact(&payload);
281 assert_eq!(hits, 0);
282 assert_eq!(redacted, payload);
283 }
284}