use std::process::{ExitStatus, Stdio};
use std::time::Duration;
use bugwarden::tracing_fields::CappedFields;
use tracing_subscriber::layer::SubscriberExt as _;
use tracing_subscriber::util::SubscriberInitExt as _;
use tracing_subscriber::EnvFilter;
const CHILD_VAR: &str = "BUGWARDEN_PANIC_HOOK_CHILD";
const CHILD_TEST: &str = "the_hook_reports_every_panic_on_fd_2_and_never_the_payload";
const PANIC_LINE: &str = "a thread panicked; the payload is not logged";
const HOOK_TARGET: &str = "bugwarden::panic_hook";
const NAMED_THREAD: &str = "bw-panic-probe";
const UNNAMED_THREAD: &str = "<unnamed>";
const FIRST_MARKER: &str = "SECRET-4f21ab-first-payload";
const SECOND_MARKER: &str = "SECRET-9c07de-second-payload";
const QUIET_FILTER: &str = "error";
const VISIBLE_FILTER: &str = "error,bugwarden::panic_hook=warn";
const CHILD_BUDGET: Duration = Duration::from_secs(20);
struct Run {
stdout: String,
stderr: String,
status: ExitStatus,
}
impl Run {
fn panic_lines(&self) -> Vec<&str> {
self.stderr
.lines()
.filter(|line| line.contains(PANIC_LINE))
.collect()
}
fn assert_no_payload(&self) {
for marker in [FIRST_MARKER, SECOND_MARKER] {
assert!(
!self.stderr.contains(marker),
"a payload reached the child's stderr: {}",
excerpt(&self.stderr)
);
assert!(
!self.stdout.contains(marker),
"a payload reached the child's stdout: {}",
excerpt(&self.stdout)
);
}
}
}
fn excerpt(stream: &str) -> String {
stream.chars().take(2_000).collect()
}
#[track_caller]
fn field<'a>(line: &'a str, name: &str) -> &'a str {
let value = line
.split_once(&format!("{name}="))
.unwrap_or_else(|| panic!("the line must carry a {name} field: {line}"))
.1;
value.split_whitespace().next().unwrap_or(value)
}
#[track_caller]
fn assert_hook_line(line: &str, level: &str, thread: &str) {
assert!(
line.contains(&format!(" {level} ")),
"the line must be at {level}: {line}"
);
assert!(
line.contains(&format!("{HOOK_TARGET}:")),
"the line must carry the hook's own target, which is what a \
per-target directive addresses: {line}"
);
assert!(
field(line, "location").contains("tests/panic_hook.rs:"),
"location must name the PANIC's site, not the hook's: {line}"
);
assert_eq!(
field(line, "thread"),
thread,
"thread must carry the spawner's choice: {line}"
);
}
async fn run_child(rust_log: Option<&str>) -> Run {
let exe = std::env::current_exe().expect("a test binary knows its own path");
let mut cmd = tokio::process::Command::new(exe);
cmd.args(["--exact", CHILD_TEST, "--nocapture"])
.env(CHILD_VAR, "1")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
match rust_log {
Some(filter) => cmd.env("RUST_LOG", filter),
None => cmd.env_remove("RUST_LOG"),
};
let output = tokio::time::timeout(CHILD_BUDGET, cmd.output())
.await
.expect("the child must exit within its budget")
.expect("the child must start");
let run = Run {
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
status: output.status,
};
assert!(
run.stdout.contains("1 passed"),
"the child must have run exactly {CHILD_TEST}: {} {}",
excerpt(&run.stdout),
excerpt(&run.stderr)
);
run
}
fn child() {
tracing_subscriber::registry()
.with(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
.with(
tracing_subscriber::fmt::layer()
.fmt_fields(CappedFields)
.with_writer(std::io::stderr)
.with_ansi(false),
)
.init();
let previous = std::panic::take_hook();
bugwarden::panic_hook::install();
let payload = FIRST_MARKER.to_string();
let first = std::thread::Builder::new()
.name(NAMED_THREAD.to_owned())
.spawn(move || panic!("{payload}"))
.expect("the probe thread must spawn")
.join();
let payload = SECOND_MARKER.to_string();
let second = std::thread::spawn(move || panic!("{payload}")).join();
std::panic::set_hook(previous);
assert!(first.is_err(), "the first probe thread must have panicked");
assert!(
second.is_err(),
"the second probe thread must have panicked"
);
}
#[tokio::test]
async fn the_hook_reports_every_panic_on_fd_2_and_never_the_payload() {
if std::env::var_os(CHILD_VAR).is_some() {
return child();
}
let run = run_child(None).await;
let lines = run.panic_lines();
assert_eq!(
lines.len(),
2,
"two panics, two lines — one event each: {}",
excerpt(&run.stderr)
);
assert_hook_line(lines[0], "ERROR", NAMED_THREAD);
assert_hook_line(lines[1], "WARN", UNNAMED_THREAD);
assert!(
!lines[1].contains("ERROR"),
"every panic after the first is a WARN: {}",
lines[1]
);
assert_ne!(
field(lines[0], "location"),
field(lines[1], "location"),
"each line carries its OWN panic's site: {}",
excerpt(&run.stderr)
);
run.assert_no_payload();
assert!(
run.status.success(),
"the child must exit cleanly once both panics are recovered: {:?}",
run.status
);
let quiet = run_child(Some(QUIET_FILTER)).await;
let lines = quiet.panic_lines();
assert_eq!(
lines.len(),
1,
"at {QUIET_FILTER} only the first panic is reported: {}",
excerpt(&quiet.stderr)
);
assert_hook_line(lines[0], "ERROR", NAMED_THREAD);
quiet.assert_no_payload();
let visible = run_child(Some(VISIBLE_FILTER)).await;
let lines = visible.panic_lines();
assert_eq!(
lines.len(),
2,
"{VISIBLE_FILTER} must restore the WARN line: {}",
excerpt(&visible.stderr)
);
assert_hook_line(lines[0], "ERROR", NAMED_THREAD);
assert_hook_line(lines[1], "WARN", UNNAMED_THREAD);
visible.assert_no_payload();
}