Skip to main content

entracte_lib/
renderer_log.rs

1//! Renderer-side error reporter. The web error boundary calls this
2//! from `componentDidCatch` so the crash lands in the same rotating
3//! log file as everything else, instead of vanishing into the webview
4//! devtools console where no end user ever looks.
5
6/// Per-field cap. The renderer can be compromised (supporter custom
7/// CSS is a real injection surface), so we treat the message/stack as
8/// attacker-controlled and refuse to log more than ~8 KiB per field.
9/// Real stack traces are well under this; a 4 GiB stack on a tight
10/// loop is a disk-fill DoS.
11const MAX_FIELD_BYTES: usize = 8 * 1024;
12
13#[tauri::command]
14pub fn report_renderer_error(
15    message: String,
16    stack: Option<String>,
17    component_stack: Option<String>,
18) {
19    let message = redact_and_truncate(&message);
20    let stack = stack
21        .as_deref()
22        .map(redact_and_truncate)
23        .unwrap_or_else(|| "<none>".to_string());
24    let component_stack = component_stack
25        .as_deref()
26        .map(redact_and_truncate)
27        .unwrap_or_else(|| "<none>".to_string());
28    log::error!("renderer: {message} | stack={stack} | component_stack={component_stack}");
29}
30
31/// Bound the field at `MAX_FIELD_BYTES` *and* strip anything that
32/// looks like a Lemon Squeezy licence key or a manual `ENT1-…` token
33/// — the renderer should never have these in scope, but a stack trace
34/// that captured local variables (or a user CSS that embedded one)
35/// would otherwise leak the secret into the log file.
36fn redact_and_truncate(input: &str) -> String {
37    let mut redacted = crate::license_redact::redact_license_shapes(input);
38    if redacted.len() > MAX_FIELD_BYTES {
39        let mut cut = MAX_FIELD_BYTES;
40        while cut > 0 && !redacted.is_char_boundary(cut) {
41            cut -= 1;
42        }
43        redacted.truncate(cut);
44        redacted.push_str("…[truncated]");
45    }
46    redacted
47}
48
49#[cfg(test)]
50mod tests {
51    use super::*;
52
53    #[test]
54    fn report_renderer_error_accepts_full_args() {
55        report_renderer_error(
56            "boom".to_string(),
57            Some("at foo (a.js:1)".to_string()),
58            Some("in <App/>".to_string()),
59        );
60    }
61
62    #[test]
63    fn report_renderer_error_accepts_missing_optionals() {
64        report_renderer_error("boom".to_string(), None, None);
65    }
66
67    #[test]
68    fn redact_and_truncate_caps_oversized_input() {
69        let huge = "x".repeat(MAX_FIELD_BYTES * 2);
70        let got = redact_and_truncate(&huge);
71        assert!(got.len() < MAX_FIELD_BYTES * 2);
72        assert!(got.ends_with("…[truncated]"));
73    }
74
75    #[test]
76    fn redact_and_truncate_masks_keys_before_truncating() {
77        // Sanity check that the truncation path still routes through the
78        // shared masker — a key in the first 8 KiB must come out redacted.
79        let got = redact_and_truncate("oops ABCD-1111-2222-3333 in scope");
80        assert!(got.contains("[REDACTED-LS-KEY]"));
81        assert!(!got.contains("ABCD-1111-2222-3333"));
82    }
83
84    #[test]
85    fn redact_and_truncate_respects_char_boundary() {
86        // Use a multi-byte UTF-8 codepoint (3 bytes) right at the cap edge.
87        let mut s = "a".repeat(MAX_FIELD_BYTES - 1);
88        s.push('日'); // 3 bytes — pushes us 2 bytes past the cap
89        s.push_str("more");
90        let got = redact_and_truncate(&s);
91        assert!(got.ends_with("…[truncated]"));
92        // Should not have sliced mid-codepoint (no panic, valid UTF-8).
93        let _ = std::str::from_utf8(got.as_bytes()).expect("must be valid utf-8");
94    }
95}