1const REDACTED: &str = "[REDACTED]";
14const TRUNCATED: &str = "[TRUNCATED]";
15
16pub const MAX_OPERATIONAL_TEXT_BYTES: usize = 8_192;
18
19pub fn redact_text(input: &str) -> String {
21 redact_text_with_limit(input, MAX_OPERATIONAL_TEXT_BYTES)
22}
23
24pub fn redact_text_with_limit(input: &str, max_bytes: usize) -> String {
26 let max_bytes = max_bytes.max(TRUNCATED.len());
27 if is_text_redacted_and_bounded(input, max_bytes) {
28 return input.to_owned();
29 }
30 let scan_limit = max_bytes.saturating_add(1_024).min(input.len());
31 let scan_end = floor_char_boundary(input, scan_limit);
32 let mut output = input[..scan_end].to_string();
33 for marker in [
34 "authorization:",
35 "bearer ",
36 "token=",
37 "secret=",
38 "password=",
39 "api_key=",
40 "apikey=",
41 ] {
42 output = redact_marker(output, marker);
43 }
44 truncate_text(output, max_bytes, input.len() > scan_end)
45}
46
47pub(crate) fn is_text_redacted_and_bounded(input: &str, max_bytes: usize) -> bool {
49 if input.len() > max_bytes.max(TRUNCATED.len()) {
50 return false;
51 }
52 let mut cursor = 0usize;
53 while cursor < input.len() {
54 let Some(marker_len) = marker_len_at(input.as_bytes(), cursor) else {
55 cursor += 1;
56 continue;
57 };
58 let value_start = cursor + marker_len;
59 let value_end = input[value_start..]
60 .find(is_secret_delimiter)
61 .map(|offset| value_start + offset)
62 .unwrap_or(input.len());
63 if value_end > value_start && &input[value_start..value_end] != REDACTED {
64 return false;
65 }
66 cursor = value_end;
67 }
68 true
69}
70
71fn marker_len_at(input: &[u8], start: usize) -> Option<usize> {
72 let candidates: &[&[u8]] = match input[start].to_ascii_lowercase() {
73 b'a' => &[b"authorization:", b"api_key=", b"apikey="],
74 b'b' => &[b"bearer "],
75 b'p' => &[b"password="],
76 b's' => &[b"secret="],
77 b't' => &[b"token="],
78 _ => return None,
79 };
80 candidates
81 .iter()
82 .find(|candidate| ascii_prefix_eq(&input[start..], candidate))
83 .map(|candidate| candidate.len())
84}
85
86fn ascii_prefix_eq(input: &[u8], expected: &[u8]) -> bool {
87 input.len() >= expected.len()
88 && input[..expected.len()]
89 .iter()
90 .zip(expected)
91 .all(|(actual, expected)| actual.to_ascii_lowercase() == *expected)
92}
93
94fn redact_marker(input: String, marker: &str) -> String {
95 let lowercase = input.to_ascii_lowercase();
96 let Some(first) = lowercase.find(marker) else {
97 return input;
98 };
99 let mut output = String::with_capacity(input.len());
100 let mut cursor = 0usize;
101 let mut next = Some(first);
102
103 while let Some(relative) = next {
104 let marker_start = cursor + relative;
105 let value_start = marker_start + marker.len();
106 output.push_str(&input[cursor..value_start]);
107 let value_end = input[value_start..]
108 .find(is_secret_delimiter)
109 .map(|offset| value_start + offset)
110 .unwrap_or(input.len());
111 if value_end > value_start {
112 output.push_str(REDACTED);
113 }
114 cursor = value_end;
115 if cursor == input.len() {
116 break;
117 }
118 next = lowercase[cursor..].find(marker);
119 }
120
121 output.push_str(&input[cursor..]);
122 output
123}
124
125fn is_secret_delimiter(character: char) -> bool {
126 character.is_whitespace() || matches!(character, ',' | ';' | '&' | '"' | '\'')
127}
128
129fn truncate_text(mut value: String, max_bytes: usize, input_was_truncated: bool) -> String {
130 if !input_was_truncated && value.len() <= max_bytes {
131 return value;
132 }
133 let content_limit = max_bytes.saturating_sub(TRUNCATED.len());
134 let end = floor_char_boundary(&value, content_limit.min(value.len()));
135 value.truncate(end);
136 value.push_str(TRUNCATED);
137 value
138}
139
140fn floor_char_boundary(value: &str, mut index: usize) -> usize {
141 index = index.min(value.len());
142 while index > 0 && !value.is_char_boundary(index) {
143 index -= 1;
144 }
145 index
146}
147
148#[cfg(test)]
149mod tests {
150 use super::{is_text_redacted_and_bounded, redact_text, redact_text_with_limit};
151
152 #[test]
153 fn absent_marker_preserves_the_existing_allocation() {
154 let input = "ordinary 日本語 العربية text".to_owned();
155 let pointer = input.as_ptr();
156 let output = super::redact_marker(input, "token=");
157 assert_eq!(output.as_ptr(), pointer);
158 assert_eq!(output, "ordinary 日本語 العربية text");
159 }
160
161 #[test]
162 fn marker_pass_matches_previous_case_folding_algorithm() {
163 let markers = [
164 "authorization:",
165 "bearer ",
166 "token=",
167 "secret=",
168 "password=",
169 "api_key=",
170 "apikey=",
171 ];
172 let values = [
173 "",
174 "a",
175 "日",
176 "[REDACTED]",
177 "secret=nested",
178 "Bearer.other",
179 "é\u{2003}tail",
180 ];
181 for marker in markers {
182 for value in values {
183 for delimiter in [" ", "\n", ",", ";", "&", "\"", "'", "\u{2003}"] {
184 let input = format!(
185 "日本語 {}{value}{delimiter}{marker}{value}",
186 marker.to_ascii_uppercase()
187 );
188 let mut previous = input.clone();
189 let original = input.clone();
190 let mut current = input;
191 for pass in markers {
192 previous = previous_marker(&previous, pass);
193 current = super::redact_marker(current, pass);
194 assert_eq!(current, previous);
195 }
196 assert_eq!(redact_text(&original), previous);
197 }
198 }
199 }
200 }
201
202 fn previous_marker(input: &str, marker: &str) -> String {
204 let lowercase = input.to_ascii_lowercase();
205 let mut output = String::with_capacity(input.len());
206 let mut cursor = 0;
207 while let Some(relative) = lowercase[cursor..].find(marker) {
208 let value_start = cursor + relative + marker.len();
209 output.push_str(&input[cursor..value_start]);
210 let value_end = input[value_start..]
211 .find(super::is_secret_delimiter)
212 .map_or(input.len(), |offset| value_start + offset);
213 if value_end > value_start {
214 output.push_str(super::REDACTED);
215 }
216 cursor = value_end;
217 if cursor == input.len() {
218 break;
219 }
220 }
221 output.push_str(&input[cursor..]);
222 output
223 }
224
225 #[test]
226 fn redacts_common_credentials_and_preserves_context() {
227 let redacted =
228 redact_text("request token=abc123 bearer xyz789 password=hunter2 status=failed");
229
230 assert_eq!(
231 redacted,
232 "request token=[REDACTED] bearer [REDACTED] password=[REDACTED] status=failed"
233 );
234 }
235
236 #[test]
237 fn redaction_is_case_insensitive() {
238 assert_eq!(
239 redact_text("Authorization:Bearer.secret"),
240 "Authorization:[REDACTED]"
241 );
242 }
243
244 #[test]
245 fn redaction_bounds_text_without_splitting_utf8() {
246 let input = format!("token=secret {}", "é".repeat(100));
247 let output = redact_text_with_limit(&input, 48);
248
249 assert!(output.len() <= 48);
250 assert!(output.contains("[REDACTED]"));
251 assert!(output.ends_with("[TRUNCATED]"));
252 }
253
254 #[test]
255 fn allocation_free_check_matches_redaction_output() {
256 let long = "é".repeat(25);
257 let cases = [
258 ("ordinary Unicode 日本語 العربية", 128),
259 ("token=[REDACTED] status=ok", 128),
260 ("ToKeN=[REDACTED]; bearer ", 128),
261 ("token=secret", 128),
262 ("token=[redacted]", 128),
263 ("authorization:Bearer.secret", 128),
264 ("password=, api_key=[REDACTED]", 128),
265 ("secret=[REDACTED]&apikey=[REDACTED]", 128),
266 (long.as_str(), 48),
267 ];
268
269 for (input, limit) in cases {
270 let expected = previous_text(input, limit);
271 assert_eq!(
272 is_text_redacted_and_bounded(input, limit),
273 expected == input,
274 "input={input:?} limit={limit}"
275 );
276 assert_eq!(redact_text_with_limit(input, limit), expected);
277 }
278 }
279
280 fn previous_text(input: &str, limit: usize) -> String {
281 let limit = limit.max(super::TRUNCATED.len());
282 let end = super::floor_char_boundary(input, limit.saturating_add(1024).min(input.len()));
283 let mut output = input[..end].to_owned();
284 for marker in [
285 "authorization:",
286 "bearer ",
287 "token=",
288 "secret=",
289 "password=",
290 "api_key=",
291 "apikey=",
292 ] {
293 output = previous_marker(&output, marker);
294 }
295 super::truncate_text(output, limit, end < input.len())
296 }
297}