Skip to main content

appcore_core/
redaction.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: redaction.rs
4//    ##   ## ##   ##    P: AppCore-Runtime
5//         ## ##
6//                       C: 2026/07/21 10:48:21 by dnettoRaw
7//    ##   ## ##   ##    U: 2026/07/23 23:50:45 by dnettoRaw
8//      ###########      S: 1.0.1-rc.8
9// =============================================================================
10
11//! Conservative redaction for runtime logs, audit records, and diagnostics.
12
13const REDACTED: &str = "[REDACTED]";
14const TRUNCATED: &str = "[TRUNCATED]";
15
16/// Maximum UTF-8 bytes retained from one log, audit, error, or diagnostic text.
17pub const MAX_OPERATIONAL_TEXT_BYTES: usize = 8_192;
18
19/// Redacts common credential forms without attempting to parse business payloads.
20pub fn redact_text(input: &str) -> String {
21    redact_text_with_limit(input, MAX_OPERATIONAL_TEXT_BYTES)
22}
23
24/// Redacts credential markers and bounds the resulting UTF-8 text.
25pub 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
47/// Reports whether redaction and bounding would preserve the text byte-for-byte.
48pub(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    // Preserved reference implementation: production no longer allocates lowercase copies.
203    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}