appcore_core/
redaction.rs1const 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 let scan_limit = max_bytes.saturating_add(1_024).min(input.len());
28 let scan_end = floor_char_boundary(input, scan_limit);
29 let mut output = input[..scan_end].to_string();
30 for marker in [
31 "authorization:",
32 "bearer ",
33 "token=",
34 "secret=",
35 "password=",
36 "api_key=",
37 "apikey=",
38 ] {
39 output = redact_marker(&output, marker);
40 }
41 truncate_text(output, max_bytes, input.len() > scan_end)
42}
43
44fn redact_marker(input: &str, marker: &str) -> String {
45 let mut output = String::with_capacity(input.len());
46 let lowercase = input.to_ascii_lowercase();
47 let mut cursor = 0usize;
48
49 while let Some(relative) = lowercase[cursor..].find(marker) {
50 let marker_start = cursor + relative;
51 let value_start = marker_start + marker.len();
52 output.push_str(&input[cursor..value_start]);
53 let value_end = input[value_start..]
54 .find(is_secret_delimiter)
55 .map(|offset| value_start + offset)
56 .unwrap_or(input.len());
57 if value_end > value_start {
58 output.push_str(REDACTED);
59 }
60 cursor = value_end;
61 if cursor == input.len() {
62 break;
63 }
64 }
65
66 output.push_str(&input[cursor..]);
67 output
68}
69
70fn is_secret_delimiter(character: char) -> bool {
71 character.is_whitespace() || matches!(character, ',' | ';' | '&' | '"' | '\'')
72}
73
74fn truncate_text(mut value: String, max_bytes: usize, input_was_truncated: bool) -> String {
75 if !input_was_truncated && value.len() <= max_bytes {
76 return value;
77 }
78 let content_limit = max_bytes.saturating_sub(TRUNCATED.len());
79 let end = floor_char_boundary(&value, content_limit.min(value.len()));
80 value.truncate(end);
81 value.push_str(TRUNCATED);
82 value
83}
84
85fn floor_char_boundary(value: &str, mut index: usize) -> usize {
86 index = index.min(value.len());
87 while index > 0 && !value.is_char_boundary(index) {
88 index -= 1;
89 }
90 index
91}
92
93#[cfg(test)]
94mod tests {
95 use super::{redact_text, redact_text_with_limit};
96
97 #[test]
98 fn redacts_common_credentials_and_preserves_context() {
99 let redacted =
100 redact_text("request token=abc123 bearer xyz789 password=hunter2 status=failed");
101
102 assert_eq!(
103 redacted,
104 "request token=[REDACTED] bearer [REDACTED] password=[REDACTED] status=failed"
105 );
106 }
107
108 #[test]
109 fn redaction_is_case_insensitive() {
110 assert_eq!(
111 redact_text("Authorization:Bearer.secret"),
112 "Authorization:[REDACTED]"
113 );
114 }
115
116 #[test]
117 fn redaction_bounds_text_without_splitting_utf8() {
118 let input = format!("token=secret {}", "é".repeat(100));
119 let output = redact_text_with_limit(&input, 48);
120
121 assert!(output.len() <= 48);
122 assert!(output.contains("[REDACTED]"));
123 assert!(output.ends_with("[TRUNCATED]"));
124 }
125}