1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
//! Crash-signal record formatting (#352).
//!
//! `opencrabs-ops` was killed by SIGBUS and logged nothing, because the daemon
//! installs no signal handler: a fatal fault signal goes to the kernel's
//! default disposition and the process dies silently. The fix records the
//! decisive facts before the process dies — signal, `si_code`, `si_addr`,
//! faulting instruction pointer, pid/tid.
//!
//! These tests pin the **pure formatter core**, which is why it exists as a
//! separate layer from the handler: a signal handler may not allocate or lock,
//! so it cannot be exercised by ordinary tests. The handler that *fills* these
//! structures is covered by the re-exec integration test below it.
use crate::logging::crash::{
BACKTRACE_BUF_LEN, CrashFacts, HEADER_BUF_LEN, MAX_FRAMES, render_backtrace, render_header,
si_code_name, signal_name,
};
/// A truncated-mmap SIGBUS — the memory-pressure shape from #352.
fn bus_objerr() -> CrashFacts {
CrashFacts {
signo: 7,
si_code: 3,
si_addr: 0x7ffe1234,
rip: 0x55a1b2c3,
pid: 1234,
tid: 1235,
base: 0x55a10000,
}
}
/// A null-pointer SIGSEGV, for contrast.
fn segv_misaligned() -> CrashFacts {
CrashFacts {
signo: 11,
si_code: 1,
si_addr: 0x0,
rip: 0xdeadbeef,
pid: 99,
tid: 100,
base: 0x55a10000,
}
}
fn render_to_string(facts: &CrashFacts) -> String {
let mut buf = [0u8; HEADER_BUF_LEN];
let n = render_header(facts, &mut buf);
String::from_utf8(buf[..n].to_vec()).expect("header record is valid UTF-8")
}
#[test]
fn header_record_for_bus_objerr_is_byte_exact() {
assert_eq!(
render_to_string(&bus_objerr()),
"CRASH signo=7(SIGBUS) si_code=3(BUS_OBJERR) si_addr=0x7ffe1234 \
rip=0x55a1b2c3 base=0x55a10000 pid=1234 tid=1235\n"
);
}
#[test]
fn header_record_for_segv_is_byte_exact() {
assert_eq!(
render_to_string(&segv_misaligned()),
"CRASH signo=11(SIGSEGV) si_code=1(SEGV_MAPERR) si_addr=0x0 \
rip=0xdeadbeef base=0x55a10000 pid=99 tid=100\n"
);
}
/// The whole point of the decoder: `si_code` is only meaningful *relative to
/// its signal*. Code `1` names four different faults under four signals, and a
/// signal-blind table would report the same name for all of them.
#[test]
fn si_code_decoding_is_signal_specific() {
assert_eq!(si_code_name(7, 1), "BUS_ADRALN");
assert_eq!(si_code_name(11, 1), "SEGV_MAPERR");
assert_eq!(si_code_name(4, 1), "ILL_ILLOPC");
assert_eq!(si_code_name(8, 1), "FPE_INTDIV");
}
/// The decisive discrimination for #352: `BUS_OBJERR`/`BUS_ADRERR` are the
/// truncated-mmap shapes, `BUS_MCEERR_*` is a hardware machine check, and
/// `BUS_ADRALN` is a misaligned access. They must never collapse into one name.
#[test]
fn sigbus_fault_classes_stay_distinct() {
assert_eq!(si_code_name(7, 2), "BUS_ADRERR");
assert_eq!(si_code_name(7, 3), "BUS_OBJERR");
assert_eq!(si_code_name(7, 4), "BUS_MCEERR_AR");
assert_eq!(si_code_name(7, 5), "BUS_MCEERR_AO");
}
/// Sender codes are shared by every signal and are signed, so the renderer
/// must not drop the sign.
#[test]
fn sender_si_codes_decode_with_their_sign() {
assert_eq!(si_code_name(6, -6), "SI_TKILL");
assert_eq!(si_code_name(7, 0x80), "SI_KERNEL");
assert_eq!(si_code_name(11, 0), "SI_USER");
let mut facts = segv_misaligned();
facts.signo = 6;
facts.si_code = -6;
let record = render_to_string(&facts);
assert!(
record.contains("si_code=-6(SI_TKILL)"),
"negative si_code lost its sign: {record}"
);
}
#[test]
fn unknown_codes_degrade_to_unknown_rather_than_lying() {
assert_eq!(si_code_name(7, 99), "UNKNOWN");
assert_eq!(si_code_name(1234, 5678), "UNKNOWN");
assert_eq!(signal_name(1234), "UNKNOWN");
}
#[test]
fn signal_names_cover_the_installed_set() {
assert_eq!(signal_name(4), "SIGILL");
assert_eq!(signal_name(6), "SIGABRT");
assert_eq!(signal_name(7), "SIGBUS");
assert_eq!(signal_name(8), "SIGFPE");
assert_eq!(signal_name(11), "SIGSEGV");
}
/// The record is written from a fixed stack buffer, so a short buffer must
/// truncate rather than panic or overrun. Every prefix length is exercised, and
/// each truncated render must be a genuine prefix of the full one, filling the
/// buffer to capacity — a crash log that stops early for no reason is bytes the
/// next debugger never gets.
#[test]
fn header_render_truncates_safely_on_any_buffer_length() {
let facts = bus_objerr();
let mut full = [0u8; HEADER_BUF_LEN];
let full_len = render_header(&facts, &mut full);
for cap in 0..full_len {
let mut small = vec![0u8; cap];
let n = render_header(&facts, &mut small);
assert!(n <= cap, "wrote {n} bytes into a {cap}-byte buffer");
assert_eq!(n, cap, "truncated render left {n} of {cap} bytes unwritten");
assert_eq!(
&small[..n],
&full[..n],
"truncated render is not a prefix at cap={cap}"
);
}
}
/// Frames are bounded by `MAX_FRAMES` so the scratch buffer can never overflow,
/// and each frame carries both the absolute address and the base-relative
/// offset — the offset being the stable fingerprint in a stripped binary.
#[test]
fn backtrace_is_capped_and_base_relative() {
let offsets = vec![0x55a1b2c3usize; MAX_FRAMES + 10];
let mut buf = [0u8; BACKTRACE_BUF_LEN];
let n = render_backtrace(0x55a10000, &offsets, &mut buf);
let text = std::str::from_utf8(&buf[..n]).expect("backtrace block is valid UTF-8");
assert_eq!(text.lines().count(), MAX_FRAMES);
assert!(
text.contains("bt[63]"),
"last frame within the cap is missing"
);
assert!(
!text.contains("bt[64]"),
"frame beyond the cap was rendered"
);
assert!(
text.starts_with("CRASH bt[0] rel=+0xb2c3 abs=0x55a1b2c3\n"),
"first frame is not base-relative: {text}"
);
}
/// A frame below the executable base (a libc frame) must render as a negative
/// offset, not as a huge positive one.
#[test]
fn frames_below_base_render_negative() {
let offsets = [0x55a10000usize, 0x55a0f000];
let mut buf = [0u8; BACKTRACE_BUF_LEN];
let n = render_backtrace(0x55a10000, &offsets, &mut buf);
let text = std::str::from_utf8(&buf[..n]).expect("backtrace block is valid UTF-8");
assert_eq!(
text,
"CRASH bt[0] rel=+0x0 abs=0x55a10000\nCRASH bt[1] rel=-0x1000 abs=0x55a0f000\n"
);
}
/// No frames means no bytes — an empty block must not emit a stray newline.
#[test]
fn empty_backtrace_renders_nothing() {
let mut buf = [0u8; BACKTRACE_BUF_LEN];
assert_eq!(render_backtrace(0x55a10000, &[], &mut buf), 0);
}
// ---------------------------------------------------------------------------
// Re-exec integration test — the handler, on a real process
// ---------------------------------------------------------------------------
//
// The handler cannot be exercised in-process: it terminates the process by
// design. So the parent test re-executes this same test binary with a filter
// that selects one `#[ignore]`d probe, and that child installs the handler and
// faults for real.
//
// Two properties are asserted, and the first is the whole point of the feature:
// the child dies by the TRUE signal rather than a clean exit (a handler that
// swallowed the fault would exit 0), and the decisive record reached both
// `crash.log` and stderr.
//
// Gated to x86_64 Linux because that is the only target with a real handler —
// elsewhere `install_crash_handler` returns `ErrorKind::Unsupported`, so the
// probe would panic and exit cleanly instead of dying by signal.
/// Env marker set on the re-executed child, so the probe knows it is the child.
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
const PROBE_ENV: &str = "OC_CRASH_PROBE_CHILD";
/// Libtest filter that selects the probe alone in the child process.
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
const PROBE_TEST: &str = "crash_probe_child";
/// Touch a page of a mapping whose backing file has been truncated to zero.
///
/// This is the #352 shape: `SIGBUS`/`BUS_ADRERR` carrying a real `si_addr`,
/// rather than a raised signal that leaves sender credentials in the union.
/// Returns `false` when the mapping cannot be set up, so the caller can fall
/// back to a raised `SIGBUS` instead of passing silently.
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
fn fault_via_truncated_mmap(dir: &std::path::Path) -> bool {
use std::os::fd::AsRawFd;
let path = dir.join("truncated-mmap.bin");
let Ok(file) = std::fs::File::create(&path) else {
return false;
};
if file.set_len(4096).is_err() {
return false;
}
let ptr = unsafe {
libc::mmap(
core::ptr::null_mut(),
4096,
libc::PROT_READ | libc::PROT_WRITE,
libc::MAP_SHARED,
file.as_raw_fd(),
0,
)
};
if ptr == libc::MAP_FAILED {
return false;
}
if file.set_len(0).is_err() {
return false;
}
// Reading past the now zero-length backing file faults. `read_volatile`
// cannot be optimised away, unlike a plain read whose value is unused.
let _ = unsafe { core::ptr::read_volatile(ptr.cast::<u8>()) };
true
}
/// The child half: install the handler, then fault.
///
/// Ignored by default because it terminates the process by signal. It acts only
/// when the parent re-execs the test binary with [`PROBE_ENV`] set.
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
#[test]
#[ignore = "terminates the process by signal; run as a child by the re-exec test"]
fn crash_probe_child() {
if std::env::var_os(PROBE_ENV).is_none() {
return;
}
crate::logging::install_crash_handler().expect("crash handler installs");
let dir = std::env::var_os("DEBUG_LOGS_LOCATION")
.map(std::path::PathBuf::from)
.expect("the parent points DEBUG_LOGS_LOCATION at a temp dir");
if !fault_via_truncated_mmap(&dir) {
// No mapping: a raised SIGBUS still exercises the handler, its record
// and the re-raise. Only the fault address is lost.
unsafe { libc::raise(libc::SIGBUS) };
}
// Reachable only if the handler returned instead of dying — the exact
// failure this feature exists to prevent. A clean exit fails the parent.
std::process::exit(0);
}
/// The parent half: re-exec the probe and assert on how it died.
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
#[test]
fn child_crash_records_and_dies_by_true_signal() {
use std::os::unix::process::ExitStatusExt;
if std::env::var_os(PROBE_ENV).is_some() {
// We are the child, running the whole binary under a probe filter.
return;
}
let exe = std::env::current_exe().expect("test binary path");
let home = tempfile::tempdir().expect("temp dir");
let output = std::process::Command::new(exe)
.arg(PROBE_TEST)
.args(["--ignored", "--nocapture", "--test-threads=1"])
.env(PROBE_ENV, "1")
.env("DEBUG_LOGS_LOCATION", home.path())
.output()
.expect("child test binary runs");
let stderr = String::from_utf8_lossy(&output.stderr);
// (a) Anti-masking. A clean exit would mean the handler swallowed the fault
// — the one thing it must never do. A zero-test child also lands here.
assert_eq!(
output.status.signal(),
Some(libc::SIGBUS),
"child must die by SIGBUS, not exit cleanly (status={:?})\n--- child stderr ---\n{stderr}",
output.status
);
// (b) The decisive record reached the crash log beside the temp log dir.
let log_path = home.path().join("crash.log");
let log = std::fs::read_to_string(&log_path).unwrap_or_else(|e| {
panic!(
"crash.log missing at {}: {e}\n--- child stderr ---\n{stderr}",
log_path.display()
)
});
assert!(
log.contains("CRASH signo=7(SIGBUS)"),
"crash.log lacks the decisive header:\n{log}"
);
// (c) The same record reached stderr, so a host with an unwritable log dir
// still sees the fault in whatever captured the daemon's stderr.
assert!(
stderr.contains("CRASH signo=7(SIGBUS)"),
"child stderr lacks the decisive header:\n{stderr}"
);
}