Skip to main content

entracte_lib/
license_redact.rs

1//! Shared license-shape redaction for log lines that might capture a key.
2//!
3//! Both the renderer-error log path and the diagnostics-report tail run
4//! through the same masker so any future change to key formats lands in
5//! one place. The two key shapes we own:
6//!
7//! - Lemon Squeezy: `XXXX-XXXX-XXXX-XXXX...` — 4+ hyphen-separated
8//!   4-char alphanumeric groups.
9//! - Manual: `ENT1-…` — a base64url-no-pad tail of at least ~7 chars,
10//!   so a filename like `ENT1-AB.log` doesn't get clobbered.
11//!
12//! Cheap regex-free scan; not a full secrets scanner.
13
14pub fn redact_license_shapes(input: &str) -> String {
15    let mut out = String::with_capacity(input.len());
16    let bytes = input.as_bytes();
17    let mut i = 0;
18    while i < bytes.len() {
19        if i + 5 <= bytes.len() && &bytes[i..i + 5] == b"ENT1-" {
20            let mut j = i + 5;
21            while j < bytes.len()
22                && (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'-' || bytes[j] == b'_')
23            {
24                j += 1;
25            }
26            if j - i >= 12 {
27                out.push_str("[REDACTED-MANUAL-TOKEN]");
28                i = j;
29                continue;
30            }
31        }
32        if looks_like_ls_key_at(bytes, i) {
33            out.push_str("[REDACTED-LS-KEY]");
34            i = ls_key_end(bytes, i);
35            continue;
36        }
37        out.push(bytes[i] as char);
38        i += 1;
39    }
40    out
41}
42
43fn looks_like_ls_key_at(bytes: &[u8], start: usize) -> bool {
44    if start + 19 > bytes.len() {
45        return false;
46    }
47    if !bytes[start..start + 4]
48        .iter()
49        .all(|b| b.is_ascii_alphanumeric())
50    {
51        return false;
52    }
53    let mut groups = 1;
54    let mut i = start + 4;
55    while groups < 4 && i + 5 <= bytes.len() && bytes[i] == b'-' {
56        if !bytes[i + 1..i + 5]
57            .iter()
58            .all(|b| b.is_ascii_alphanumeric())
59        {
60            return false;
61        }
62        i += 5;
63        groups += 1;
64    }
65    groups >= 4
66}
67
68fn ls_key_end(bytes: &[u8], start: usize) -> usize {
69    let mut i = start + 4;
70    while i + 5 <= bytes.len() && bytes[i] == b'-' && {
71        bytes[i + 1..i + 5]
72            .iter()
73            .all(|b| b.is_ascii_alphanumeric())
74    } {
75        i += 5;
76    }
77    i
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83
84    #[test]
85    fn masks_lemon_squeezy_key() {
86        let got = redact_license_shapes("activated ABCD-1111-2222-3333 ok");
87        assert!(got.contains("[REDACTED-LS-KEY]"), "got: {got}");
88        assert!(!got.contains("ABCD-1111-2222-3333"));
89    }
90
91    #[test]
92    fn masks_manual_token() {
93        let got = redact_license_shapes("token=ENT1-AAAAAAAAAAAA_BBBB done");
94        assert!(got.contains("[REDACTED-MANUAL-TOKEN]"), "got: {got}");
95        assert!(!got.contains("ENT1-AAAAA"));
96    }
97
98    #[test]
99    fn passes_innocent_text_through() {
100        let got = redact_license_shapes("Error: undefined is not a function (foo.js:42)");
101        assert_eq!(got, "Error: undefined is not a function (foo.js:42)");
102    }
103
104    #[test]
105    fn keeps_short_ent1_prefix_intact() {
106        // `ENT1-AB` is shorter than the 12-char minimum and must not be
107        // redacted — otherwise legitimate text that happens to start with
108        // `ENT1-` (e.g. a filename) gets clobbered.
109        let got = redact_license_shapes("filename ENT1-AB.log");
110        assert_eq!(got, "filename ENT1-AB.log");
111    }
112
113    #[test]
114    fn rejects_ls_shape_with_non_alnum_group() {
115        // Five hyphen-separated 4-char groups where one group contains a
116        // non-alphanumeric char must NOT be redacted.
117        let got = redact_license_shapes("ABCD-1111-22!2-3333-4444");
118        assert!(!got.contains("[REDACTED-LS-KEY]"), "got: {got}");
119    }
120
121    #[test]
122    fn extends_past_four_groups() {
123        // A 5-group key gets consumed in one match (not "[REDACTED]-4444").
124        let got = redact_license_shapes("key=ABCD-1111-2222-3333-4444 end");
125        assert!(got.contains("[REDACTED-LS-KEY]"));
126        assert!(!got.contains("4444"));
127        assert!(got.contains("end"));
128    }
129}