Skip to main content

hennery_kernel/
redact.rs

1//! Shared first-party token scanner. Assemble logical text before scanning;
2//! arbitrary provider secrets and encodings are outside this shape contract.
3
4pub const TOKEN_PREFIXES: &[&str] = &["hnry_session_", "hnry_activity_", "hnry_lifecycle_", "hnry_plugin_"];
5pub const REDACTED: &str = "hnry_session_<redacted>";
6
7pub fn text(text: &str) -> Option<String> {
8    let bytes = text.as_bytes();
9    let mut out: Option<String> = None;
10    let mut copied = 0;
11    let mut i = 0;
12    while i < bytes.len() {
13        if let Some(prefix) = TOKEN_PREFIXES.iter().find(|prefix| {
14            bytes
15                .get(i..i + prefix.len())
16                .is_some_and(|s| s.eq_ignore_ascii_case(prefix.as_bytes()))
17        }) {
18            let digits = bytes[i + prefix.len()..]
19                .iter()
20                .take_while(|b| b.is_ascii_hexdigit())
21                .count();
22            if digits >= 8 {
23                let end = i + prefix.len() + digits;
24                let buffer = out.get_or_insert_with(|| String::with_capacity(text.len()));
25                buffer.push_str(&text[copied..i]);
26                buffer.push_str(prefix);
27                buffer.push_str("<redacted>");
28                copied = end;
29                i = end;
30                continue;
31            }
32        }
33        i += 1;
34    }
35    out.map(|mut buffer| {
36        buffer.push_str(&text[copied..]);
37        buffer
38    })
39}
40
41pub fn value(value: &mut serde_json::Value) -> bool {
42    use serde_json::Value;
43    match value {
44        Value::String(s) => {
45            if let Some(redacted) = text(s) {
46                *s = redacted;
47                true
48            } else {
49                false
50            }
51        }
52        Value::Array(items) => {
53            let mut changed = false;
54            for item in items {
55                changed |= self::value(item);
56            }
57            changed
58        }
59        Value::Object(map) => {
60            let mut changed = false;
61            let keys: Vec<_> = map
62                .keys()
63                .filter_map(|key| text(key).map(|r| (key.clone(), r)))
64                .collect();
65            for (key, replacement) in keys {
66                if let Some(mut entry) = map.remove(&key) {
67                    self::value(&mut entry);
68                    map.insert(replacement, entry);
69                    changed = true;
70                }
71            }
72            for entry in map.values_mut() {
73                changed |= self::value(entry);
74            }
75            changed
76        }
77        _ => false,
78    }
79}
80
81pub struct Assembly {
82    text: String,
83    cap: usize,
84    truncated: bool,
85}
86pub struct Assembled {
87    pub text: String,
88    pub truncated: bool,
89}
90
91impl Assembly {
92    pub fn new(cap: usize) -> Self {
93        Self {
94            text: String::new(),
95            cap,
96            truncated: false,
97        }
98    }
99    pub fn push(&mut self, chunk: &str) {
100        let remaining = self.cap.saturating_sub(self.text.len());
101        let mut end = remaining.min(chunk.len());
102        while !chunk.is_char_boundary(end) {
103            end -= 1;
104        }
105        self.text.push_str(&chunk[..end]);
106        self.truncated |= end < chunk.len();
107    }
108    pub fn finish(mut self, cap: usize) -> Assembled {
109        // At an assembly cap the last run may be only part of a prefix or
110        // secret. Drop it conservatively, even below the scanner's threshold.
111        if self.truncated {
112            let lower = self.text.to_ascii_lowercase();
113            for i in 0..lower.len() {
114                if !self.text.is_char_boundary(i) {
115                    continue;
116                }
117                let tail = &lower[i..];
118                if TOKEN_PREFIXES
119                    .iter()
120                    .any(|p| p.starts_with(tail) || tail.starts_with(p))
121                {
122                    self.text.truncate(i);
123                    break;
124                }
125            }
126        }
127        let mut text = text(&self.text).unwrap_or(self.text);
128        if text.len() > cap {
129            let mut end = cap;
130            while !text.is_char_boundary(end) {
131                end -= 1;
132            }
133            text.truncate(end);
134            self.truncated = true;
135        }
136        Assembled {
137            text,
138            truncated: self.truncated,
139        }
140    }
141}