use super::journal::Journal;
use crate::cli::screen;
use regex::Regex;
use scope::http::replay::SECRET_PARAMS;
use std::backtrace::Backtrace;
use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
const JOURNAL_TAIL_LINES: usize = 20;
const REDACTED: &str = "<redacted>";
fn redact(line: &str) -> String {
let names = SECRET_PARAMS.join("|");
let query = Regex::new(&format!(r#"(?i)\b({names})=[^&\s",]+"#)).expect("valid regex");
let flag = Regex::new(r#"(?i)(--(?:key|token|api-key|api_key|apikey|access-token|access_token))("\s*,\s*"|\s+|=)[^\s",&]+"#)
.expect("valid regex");
let line = query.replace_all(line, format!("$1={REDACTED}").as_str());
flag.replace_all(&line, format!("$1$2{REDACTED}").as_str())
.into_owned()
}
pub fn write_crash_log(
dir: &Path,
info_text: &str,
journal_tail: &[String],
) -> io::Result<PathBuf> {
fs::create_dir_all(dir)?;
let stamp = chrono::Utc::now().format("%Y%m%d-%H%M%S%.3f");
let path = dir.join(format!("crash-{stamp}-{}.log", std::process::id()));
let mut text = format!(
"{info_text}\n\nBacktrace:\n{}\n",
Backtrace::force_capture()
);
text.push_str(&format!(
"\nInputs below are redacted; review before sharing.\nLast {JOURNAL_TAIL_LINES} journal lines:\n"
));
for line in journal_tail {
text.push_str(&redact(line));
text.push('\n');
}
fs::write(&path, text)?;
Ok(path)
}
fn crash_notice(dir: Option<&Path>, info_text: &str, journal_tail: &[String]) -> String {
match dir
.ok_or_else(|| io::Error::other("no data directory"))
.and_then(|dir| write_crash_log(dir, info_text, journal_tail))
{
Ok(path) => format!("scope crashed; details: {}", path.display()),
Err(e) => format!("scope crashed; the crash log failed: {e}"),
}
}
pub(crate) fn install_panic_hook() {
static INSTALLED: OnceLock<()> = OnceLock::new();
INSTALLED.get_or_init(|| {
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
screen::restore();
let tail = Journal::in_data_dir().tail(JOURNAL_TAIL_LINES);
let dir = scope::paths::data_dir().map(|d| d.join("scope"));
let notice = crash_notice(dir.as_deref(), &info.to_string(), &tail);
let _ = writeln!(io::stderr(), "{notice}");
previous(info);
}));
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_crash_log_has_message_location_and_journal_tail() {
let dir = tempfile::tempdir().unwrap();
let p = write_crash_log(
dir.path(),
"boom at src/x.rs:1:2",
&["{\"input\":\"ctx\"}".to_string()],
)
.unwrap();
let text = std::fs::read_to_string(&p).unwrap();
assert!(
p.file_name()
.unwrap()
.to_string_lossy()
.starts_with("crash-")
);
assert!(text.contains("boom at src/x.rs:1:2") && text.contains("\"input\":\"ctx\""));
assert!(text.contains("Backtrace:"));
assert!(text.contains("Inputs below are redacted; review before sharing."));
}
#[test]
fn test_crash_log_redacts_secrets_in_the_journal_tail() {
let dir = tempfile::tempdir().unwrap();
let tail = [
r#"{"input":"setup --key sk-123 --access-token tokZ7","argv":["scope","setup","--key","sk-456"]}"#
.to_string(),
r#"{"error":"failed: https://x.io/api?chain=eth&apikey=sk3cr3tQ&Token=tok9"}"#.to_string(),
];
let p = write_crash_log(dir.path(), "boom", &tail).unwrap();
let text = std::fs::read_to_string(&p).unwrap();
for secret in ["sk-123", "sk-456", "sk3cr3tQ", "tok9", "tokZ7"] {
assert!(!text.contains(secret), "{secret} leaked");
}
assert!(text.contains("chain=eth") && text.contains("<redacted>"));
}
#[test]
fn test_crash_logs_written_back_to_back_do_not_collide() {
let dir = tempfile::tempdir().unwrap();
let a = write_crash_log(dir.path(), "a", &[]).unwrap();
std::thread::sleep(std::time::Duration::from_millis(2));
let b = write_crash_log(dir.path(), "b", &[]).unwrap();
assert_ne!(a, b);
}
#[test]
fn test_crash_notice_names_the_log_file_that_exists() {
let dir = tempfile::tempdir().unwrap();
let notice = crash_notice(Some(dir.path()), "boom", &[]);
let path = notice
.strip_prefix("scope crashed; details: ")
.expect(¬ice);
assert!(std::fs::read_to_string(path).unwrap().contains("boom"));
}
#[test]
fn test_crash_notice_says_why_no_log_was_written() {
let none = crash_notice(None, "boom", &[]);
assert_eq!(
none,
"scope crashed; the crash log failed: no data directory"
);
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("not-a-dir");
std::fs::write(&file, "x").unwrap();
let blocked = crash_notice(Some(&file), "boom", &[]);
assert!(
blocked.starts_with("scope crashed; the crash log failed: "),
"{blocked}"
);
}
}