Skip to main content

scope_cli/cli/tui/
crash.rs

1//! # Crash log
2//!
3//! A panic in the TUI writes a crash log and tells the user where it is.
4//! The hook first gives the terminal back (`screen::restore`, which does
5//! nothing when the screen is already back), so the notice is readable,
6//! then calls the previous hook.
7
8use super::journal::Journal;
9use crate::cli::screen;
10use regex::Regex;
11use scope::http::replay::SECRET_PARAMS;
12use std::backtrace::Backtrace;
13use std::fs;
14use std::io::{self, Write};
15use std::path::{Path, PathBuf};
16use std::sync::OnceLock;
17
18/// How many journal lines the crash log keeps.
19const JOURNAL_TAIL_LINES: usize = 20;
20
21const REDACTED: &str = "<redacted>";
22
23/// Replaces secret values in one journal line: URL query values for the
24/// replay `SECRET_PARAMS`, and the argument after `--key`, `--token` or
25/// `--api-key` (plain or inside a JSON argv array).
26fn redact(line: &str) -> String {
27    let names = SECRET_PARAMS.join("|");
28    let query = Regex::new(&format!(r#"(?i)\b({names})=[^&\s",]+"#)).expect("valid regex");
29    let flag = Regex::new(r#"(?i)(--(?:key|token|api-key|api_key|apikey|access-token|access_token))("\s*,\s*"|\s+|=)[^\s",&]+"#)
30        .expect("valid regex");
31    let line = query.replace_all(line, format!("$1={REDACTED}").as_str());
32    flag.replace_all(&line, format!("$1$2{REDACTED}").as_str())
33        .into_owned()
34}
35
36/// Writes `crash-<timestamp>.log` in `dir`: the panic message and
37/// location, a backtrace, and the redacted journal tail. The name has
38/// milliseconds and the process id, so two crashes do not overwrite each
39/// other. Returns the file path.
40///
41/// # Errors
42///
43/// Returns the file system error.
44pub fn write_crash_log(
45    dir: &Path,
46    info_text: &str,
47    journal_tail: &[String],
48) -> io::Result<PathBuf> {
49    fs::create_dir_all(dir)?;
50    let stamp = chrono::Utc::now().format("%Y%m%d-%H%M%S%.3f");
51    let path = dir.join(format!("crash-{stamp}-{}.log", std::process::id()));
52    let mut text = format!(
53        "{info_text}\n\nBacktrace:\n{}\n",
54        Backtrace::force_capture()
55    );
56    text.push_str(&format!(
57        "\nInputs below are redacted; review before sharing.\nLast {JOURNAL_TAIL_LINES} journal lines:\n"
58    ));
59    for line in journal_tail {
60        text.push_str(&redact(line));
61        text.push('\n');
62    }
63    fs::write(&path, text)?;
64    Ok(path)
65}
66
67/// Writes the crash log and returns the line that tells the user where it
68/// is, or why it could not be written. `dir` is `None` when the platform
69/// has no data directory.
70fn crash_notice(dir: Option<&Path>, info_text: &str, journal_tail: &[String]) -> String {
71    match dir
72        .ok_or_else(|| io::Error::other("no data directory"))
73        .and_then(|dir| write_crash_log(dir, info_text, journal_tail))
74    {
75        Ok(path) => format!("scope crashed; details: {}", path.display()),
76        Err(e) => format!("scope crashed; the crash log failed: {e}"),
77    }
78}
79
80/// Installs the crash-log panic hook. Only the first call has an effect.
81pub(crate) fn install_panic_hook() {
82    static INSTALLED: OnceLock<()> = OnceLock::new();
83    INSTALLED.get_or_init(|| {
84        let previous = std::panic::take_hook();
85        std::panic::set_hook(Box::new(move |info| {
86            screen::restore();
87            let tail = Journal::in_data_dir().tail(JOURNAL_TAIL_LINES);
88            let dir = Some(scope::paths::state_dir());
89            let notice = crash_notice(dir.as_deref(), &info.to_string(), &tail);
90            // `writeln!`, not `eprintln!`: a closed stderr must not panic
91            // inside the hook, which would skip the previous hook.
92            let _ = writeln!(io::stderr(), "{notice}");
93            previous(info);
94        }));
95    });
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101
102    #[test]
103    fn test_crash_log_has_message_location_and_journal_tail() {
104        let dir = tempfile::tempdir().unwrap();
105        let p = write_crash_log(
106            dir.path(),
107            "boom at src/x.rs:1:2",
108            &["{\"input\":\"ctx\"}".to_string()],
109        )
110        .unwrap();
111        let text = std::fs::read_to_string(&p).unwrap();
112        assert!(
113            p.file_name()
114                .unwrap()
115                .to_string_lossy()
116                .starts_with("crash-")
117        );
118        assert!(text.contains("boom at src/x.rs:1:2") && text.contains("\"input\":\"ctx\""));
119        assert!(text.contains("Backtrace:"));
120        assert!(text.contains("Inputs below are redacted; review before sharing."));
121    }
122
123    #[test]
124    fn test_crash_log_redacts_secrets_in_the_journal_tail() {
125        let dir = tempfile::tempdir().unwrap();
126        let tail = [
127            r#"{"input":"setup --key sk-123 --access-token tokZ7","argv":["scope","setup","--key","sk-456"]}"#
128                .to_string(),
129            r#"{"error":"failed: https://x.io/api?chain=eth&apikey=sk3cr3tQ&Token=tok9"}"#.to_string(),
130        ];
131        let p = write_crash_log(dir.path(), "boom", &tail).unwrap();
132        let text = std::fs::read_to_string(&p).unwrap();
133        for secret in ["sk-123", "sk-456", "sk3cr3tQ", "tok9", "tokZ7"] {
134            assert!(!text.contains(secret), "{secret} leaked");
135        }
136        assert!(text.contains("chain=eth") && text.contains("<redacted>"));
137    }
138
139    #[test]
140    fn test_crash_logs_written_back_to_back_do_not_collide() {
141        let dir = tempfile::tempdir().unwrap();
142        let a = write_crash_log(dir.path(), "a", &[]).unwrap();
143        std::thread::sleep(std::time::Duration::from_millis(2));
144        let b = write_crash_log(dir.path(), "b", &[]).unwrap();
145        assert_ne!(a, b);
146    }
147
148    #[test]
149    fn test_crash_notice_names_the_log_file_that_exists() {
150        let dir = tempfile::tempdir().unwrap();
151        let notice = crash_notice(Some(dir.path()), "boom", &[]);
152        let path = notice
153            .strip_prefix("scope crashed; details: ")
154            .expect(&notice);
155        assert!(std::fs::read_to_string(path).unwrap().contains("boom"));
156    }
157
158    #[test]
159    fn test_crash_notice_says_why_no_log_was_written() {
160        // The user must learn that no log exists, and why.
161        let none = crash_notice(None, "boom", &[]);
162        assert_eq!(
163            none,
164            "scope crashed; the crash log failed: no data directory"
165        );
166        let dir = tempfile::tempdir().unwrap();
167        let file = dir.path().join("not-a-dir");
168        std::fs::write(&file, "x").unwrap();
169        let blocked = crash_notice(Some(&file), "boom", &[]);
170        assert!(
171            blocked.starts_with("scope crashed; the crash log failed: "),
172            "{blocked}"
173        );
174    }
175}