scope_cli/cli/tui/
crash.rs1use 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
18const JOURNAL_TAIL_LINES: usize = 20;
20
21const REDACTED: &str = "<redacted>";
22
23fn 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
36pub 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
67fn 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
80pub(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 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(¬ice);
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 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}