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    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}