Skip to main content

running_process_probe/crash/
spool.rs

1//! Fixed-layout crash records shared by the signal handler and daemon.
2//!
3//! The writer side deliberately does not use serde/prost: a fatal-signal
4//! callback may not allocate, lock, format, or retry an I/O operation. The
5//! entire record is prepared ahead of time and emitted with one bounded OS
6//! write. Parsing happens in the daemon, where ordinary Rust is safe again.
7
8use std::fs::{File, OpenOptions};
9use std::io;
10use std::path::{Path, PathBuf};
11use std::time::{SystemTime, UNIX_EPOCH};
12
13/// Override for the owner-private directory containing pending records.
14pub const SPOOL_DIR_ENV: &str = "RUNNING_PROCESS_PROBE_SPOOL_DIR";
15/// Override for durable JSON crash reports written by `rpprobed`.
16pub const REPORT_DIR_ENV: &str = "RUNNING_PROCESS_PROBE_CRASH_DIR";
17
18pub(crate) const MAGIC: &[u8; 8] = b"RPCRASH1";
19pub(crate) const VERSION: u32 = 2;
20/// One write, bounded independently of the crashing application's heap size.
21pub const RECORD_SIZE: usize = 16 * 1024;
22pub(crate) const HEADER_SIZE: usize = 128;
23pub(crate) const TEXT_SIZE: usize = 64;
24pub(crate) const TEXT_FIELDS: usize = 4;
25pub(crate) const MODULE_OFFSET: usize = HEADER_SIZE + TEXT_SIZE * TEXT_FIELDS;
26pub(crate) const MAX_MODULES: usize = 16;
27pub(crate) const MODULE_SIZE: usize = 128;
28pub(crate) const THREAD_OFFSET: usize = MODULE_OFFSET + MAX_MODULES * MODULE_SIZE;
29pub(crate) const MAX_THREADS: usize = 32;
30pub(crate) const MAX_FRAMES: usize = 16;
31pub(crate) const FRAME_SIZE: usize = 16;
32pub(crate) const THREAD_SIZE: usize = 16 + MAX_FRAMES * FRAME_SIZE;
33pub(crate) const RAW_OFFSET: usize = THREAD_OFFSET + MAX_THREADS * THREAD_SIZE;
34pub(crate) const CWD_SIZE: usize = 1024;
35pub(crate) const CWD_OFFSET: usize = RECORD_SIZE - CWD_SIZE;
36pub(crate) const MAX_RAW_CONTEXT: usize = CWD_OFFSET - RAW_OFFSET;
37const V1_MAX_RAW_CONTEXT: usize = RECORD_SIZE - RAW_OFFSET;
38
39const OFF_VERSION: usize = 8;
40const OFF_RECORD_SIZE: usize = 12;
41const OFF_PID: usize = 16;
42const OFF_TID: usize = 24;
43const OFF_FAULT_CODE: usize = 32;
44const OFF_FAULT_ADDRESS: usize = 40;
45const OFF_UNIX_MS: usize = 48;
46const OFF_THREAD_COUNT: usize = 56;
47const OFF_RAW_LEN: usize = 60;
48const OFF_FLAGS: usize = 64;
49const OFF_MODULE_COUNT: usize = 68;
50const OFF_CREATION_TIME_MS: usize = 72;
51const FLAG_TRUNCATED_THREADS: u32 = 1;
52const FLAG_TRUNCATED_CONTEXT: u32 = 2;
53const FLAG_TRUNCATED_MODULES: u32 = 4;
54const KNOWN_FLAGS: u32 = FLAG_TRUNCATED_THREADS | FLAG_TRUNCATED_CONTEXT | FLAG_TRUNCATED_MODULES;
55
56/// Identity copied before the handler is armed.
57#[derive(Clone, Debug, PartialEq, Eq)]
58pub struct CrashMetadata {
59    /// Coarse application class.
60    pub app_class: String,
61    /// Human-readable application name.
62    pub app_name: String,
63    /// Application version.
64    pub app_version: String,
65    /// Optional instance discriminator.
66    pub instance_name: String,
67    /// Process creation/install time, paired with `pid` to guard PID reuse.
68    pub creation_time_ms: u64,
69    /// Working directory captured before entering compromised context.
70    pub cwd: String,
71}
72
73/// Module identity captured before ASLR state disappears.
74#[derive(Clone, Debug, PartialEq, Eq)]
75pub struct CrashModule {
76    /// Canonical path when known, otherwise a stable module name.
77    pub identity: String,
78}
79
80/// One frame expressed as a stable module-relative offset.
81#[derive(Clone, Debug, PartialEq, Eq)]
82pub struct CrashFrame {
83    /// Index into [`RawCrashReport::modules`], or `None` when unattributed.
84    pub module_index: Option<u32>,
85    /// Offset from the module base, or raw address when unattributed.
86    pub relative_address: u64,
87}
88
89/// One pre-captured thread in a crash report.
90#[derive(Clone, Debug, PartialEq, Eq)]
91pub struct CrashThread {
92    /// OS thread id.
93    pub os_tid: u64,
94    /// Unsymbolized, ASLR-stable frames, innermost first.
95    pub frames: Vec<CrashFrame>,
96}
97
98/// Parsed fixed-layout crash report.
99#[derive(Clone, Debug, PartialEq, Eq)]
100pub struct RawCrashReport {
101    /// Process id.
102    pub pid: u32,
103    /// Faulting OS thread id.
104    pub tid: u64,
105    /// Signal or exception code.
106    pub fault_code: i64,
107    /// Best-effort fault address.
108    pub fault_address: u64,
109    /// Wall-clock time of the fatal callback.
110    pub crash_unix_ms: u64,
111    /// Application identity prepared at install time.
112    pub metadata: CrashMetadata,
113    /// Modules referenced by the sampled frames.
114    pub modules: Vec<CrashModule>,
115    /// Most recent all-thread cooperative sample.
116    pub threads: Vec<CrashThread>,
117    /// Raw platform crash context.
118    pub raw_context: Vec<u8>,
119    /// Whether any bounded field was truncated.
120    pub truncated: bool,
121}
122
123/// Default owner-private spool directory.
124pub fn spool_dir() -> PathBuf {
125    std::env::var_os(SPOOL_DIR_ENV)
126        .map(PathBuf::from)
127        .unwrap_or_else(|| default_owner_root().join("probe-spool"))
128}
129
130/// Default durable report directory.
131pub fn report_dir() -> PathBuf {
132    std::env::var_os(REPORT_DIR_ENV)
133        .map(PathBuf::from)
134        .unwrap_or_else(|| default_owner_root().join("probe-crashes"))
135}
136
137#[cfg(unix)]
138fn default_owner_root() -> PathBuf {
139    if let Some(runtime) = std::env::var_os("XDG_RUNTIME_DIR") {
140        let runtime = PathBuf::from(runtime);
141        if runtime.is_absolute() {
142            return runtime.join("running-process");
143        }
144    }
145    // SAFETY: `geteuid` is side-effect-free and always available on Unix.
146    let uid = unsafe { libc::geteuid() };
147    std::env::temp_dir().join(format!("running-process-{uid}"))
148}
149
150#[cfg(not(unix))]
151fn default_owner_root() -> PathBuf {
152    std::env::temp_dir().join("running-process")
153}
154
155pub(crate) fn create_sink(
156    metadata: &CrashMetadata,
157) -> io::Result<(File, PathBuf, [u8; RECORD_SIZE])> {
158    let dir = spool_dir();
159    create_private_dir(&dir)?;
160    let now = unix_ms();
161    let path = dir.join(format!(
162        "pending-{}-{now}-{:016x}.rpcrash",
163        std::process::id(),
164        random_suffix()
165    ));
166
167    let mut options = OpenOptions::new();
168    options.create_new(true).write(true);
169    #[cfg(unix)]
170    {
171        use std::os::unix::fs::OpenOptionsExt as _;
172        options.mode(0o600);
173    }
174    let file = options.open(&path)?;
175    let mut record = [0u8; RECORD_SIZE];
176    initialize(&mut record, metadata);
177    Ok((file, path, record))
178}
179
180fn random_suffix() -> u64 {
181    let mut bytes = [0u8; 8];
182    if getrandom::fill(&mut bytes).is_ok() {
183        u64::from_le_bytes(bytes)
184    } else {
185        unix_ms() ^ u64::from(std::process::id())
186    }
187}
188
189/// Create and verify an owner-only directory.
190pub fn create_private_dir(path: &Path) -> io::Result<()> {
191    #[cfg(unix)]
192    {
193        use std::os::unix::fs::{DirBuilderExt as _, MetadataExt as _};
194        let mut builder = std::fs::DirBuilder::new();
195        builder.recursive(true).mode(0o700);
196        builder.create(path)?;
197        let metadata = std::fs::symlink_metadata(path)?;
198        // SAFETY: `geteuid` is side-effect-free and always available on Unix.
199        let uid = unsafe { libc::geteuid() };
200        if metadata.file_type().is_symlink()
201            || !metadata.is_dir()
202            || metadata.uid() != uid
203            || metadata.mode() & 0o077 != 0
204        {
205            return Err(io::Error::new(
206                io::ErrorKind::PermissionDenied,
207                "crash directory must be a non-symlink owned by this user with mode 0700",
208            ));
209        }
210    }
211    #[cfg(not(unix))]
212    std::fs::create_dir_all(path)?;
213    Ok(())
214}
215
216pub(crate) fn initialize(record: &mut [u8; RECORD_SIZE], metadata: &CrashMetadata) {
217    record.fill(0);
218    record[..8].copy_from_slice(MAGIC);
219    put_u32(record, OFF_VERSION, VERSION);
220    put_u32(record, OFF_RECORD_SIZE, RECORD_SIZE as u32);
221    put_u32(record, OFF_PID, std::process::id());
222    put_metadata(record, metadata);
223}
224
225pub(crate) fn put_metadata(record: &mut [u8; RECORD_SIZE], metadata: &CrashMetadata) {
226    record[HEADER_SIZE..MODULE_OFFSET].fill(0);
227    record[CWD_OFFSET..].fill(0);
228    put_text(record, HEADER_SIZE, &metadata.app_class);
229    put_text(record, HEADER_SIZE + TEXT_SIZE, &metadata.app_name);
230    put_text(record, HEADER_SIZE + TEXT_SIZE * 2, &metadata.app_version);
231    put_text(record, HEADER_SIZE + TEXT_SIZE * 3, &metadata.instance_name);
232    put_u64(record, OFF_CREATION_TIME_MS, metadata.creation_time_ms);
233    put_text_sized(record, CWD_OFFSET, CWD_SIZE, &metadata.cwd);
234}
235
236pub(crate) fn put_sample(
237    record: &mut [u8; RECORD_SIZE],
238    modules: &[CrashModule],
239    threads: &[CrashThread],
240) {
241    record[MODULE_OFFSET..RAW_OFFSET].fill(0);
242    put_u32(record, OFF_FLAGS, 0);
243    let module_count = modules.len().min(MAX_MODULES);
244    put_u32(record, OFF_MODULE_COUNT, module_count as u32);
245    if modules.len() > MAX_MODULES {
246        set_flag(record, FLAG_TRUNCATED_MODULES);
247    }
248    for (index, module) in modules.iter().take(MAX_MODULES).enumerate() {
249        let raw = module.identity.as_bytes();
250        if raw.len() >= MODULE_SIZE {
251            set_flag(record, FLAG_TRUNCATED_MODULES);
252        }
253        put_text_sized(
254            record,
255            MODULE_OFFSET + index * MODULE_SIZE,
256            MODULE_SIZE,
257            &module.identity,
258        );
259    }
260
261    let count = threads.len().min(MAX_THREADS);
262    put_u32(record, OFF_THREAD_COUNT, count as u32);
263    if threads.len() > MAX_THREADS {
264        set_flag(record, FLAG_TRUNCATED_THREADS);
265    }
266    for (index, thread) in threads.iter().take(MAX_THREADS).enumerate() {
267        let offset = THREAD_OFFSET + index * THREAD_SIZE;
268        put_u64(record, offset, thread.os_tid);
269        let frame_count = thread
270            .frames
271            .iter()
272            .filter(|frame| {
273                frame
274                    .module_index
275                    .is_none_or(|module| (module as usize) < module_count)
276            })
277            .count()
278            .min(MAX_FRAMES);
279        put_u32(record, offset + 8, frame_count as u32);
280        if thread.frames.len() > frame_count {
281            set_flag(record, FLAG_TRUNCATED_THREADS);
282        }
283        for (frame_index, frame) in thread
284            .frames
285            .iter()
286            .filter(|frame| {
287                frame
288                    .module_index
289                    .is_none_or(|module| (module as usize) < module_count)
290            })
291            .take(MAX_FRAMES)
292            .enumerate()
293        {
294            let frame_offset = offset + 16 + frame_index * FRAME_SIZE;
295            let module_index = frame.module_index.unwrap_or(u32::MAX);
296            put_u32(record, frame_offset, module_index);
297            put_u64(record, frame_offset + 8, frame.relative_address);
298        }
299    }
300}
301
302/// Populate crash-only fields. Called from the compromised context.
303///
304/// # Safety
305///
306/// `record` must point at a writable [`RECORD_SIZE`] buffer exclusively owned
307/// by the handler for the duration of this call. `raw_context` must be valid
308/// for `raw_len` bytes.
309pub(crate) unsafe fn put_crash(
310    record: *mut u8,
311    tid: u64,
312    fault_code: i64,
313    fault_address: u64,
314    raw_context: *const u8,
315    raw_len: usize,
316) {
317    // SAFETY: guaranteed by the caller; no allocation occurs.
318    let bytes = unsafe { std::slice::from_raw_parts_mut(record, RECORD_SIZE) };
319    put_u64(bytes, OFF_TID, tid);
320    put_i64(bytes, OFF_FAULT_CODE, fault_code);
321    put_u64(bytes, OFF_FAULT_ADDRESS, fault_address);
322    put_u64(bytes, OFF_UNIX_MS, unix_ms_signal_safe());
323    let copied = raw_len.min(MAX_RAW_CONTEXT);
324    put_u32(bytes, OFF_RAW_LEN, copied as u32);
325    if raw_len > MAX_RAW_CONTEXT {
326        set_flag(bytes, FLAG_TRUNCATED_CONTEXT);
327    }
328    if copied != 0 {
329        // SAFETY: both ranges are valid and disjoint by the caller contract.
330        unsafe {
331            std::ptr::copy_nonoverlapping(raw_context, bytes.as_mut_ptr().add(RAW_OFFSET), copied);
332        }
333    }
334}
335
336/// Decode a complete handler record.
337pub fn parse(bytes: &[u8]) -> io::Result<RawCrashReport> {
338    if bytes.len() != RECORD_SIZE || &bytes[..8] != MAGIC {
339        return Err(io::Error::new(
340            io::ErrorKind::InvalidData,
341            "incomplete or invalid crash record",
342        ));
343    }
344    let version = get_u32(bytes, OFF_VERSION);
345    if (version != 1 && version != VERSION)
346        || get_u32(bytes, OFF_RECORD_SIZE) as usize != RECORD_SIZE
347    {
348        return Err(io::Error::new(
349            io::ErrorKind::InvalidData,
350            "unsupported crash record version",
351        ));
352    }
353    let flags = get_u32(bytes, OFF_FLAGS);
354    let module_count = get_u32(bytes, OFF_MODULE_COUNT) as usize;
355    let thread_count = get_u32(bytes, OFF_THREAD_COUNT) as usize;
356    let raw_len = get_u32(bytes, OFF_RAW_LEN) as usize;
357    let max_raw_context = if version == 1 {
358        V1_MAX_RAW_CONTEXT
359    } else {
360        MAX_RAW_CONTEXT
361    };
362    if flags & !KNOWN_FLAGS != 0
363        || module_count > MAX_MODULES
364        || thread_count > MAX_THREADS
365        || raw_len > max_raw_context
366    {
367        return Err(io::Error::new(
368            io::ErrorKind::InvalidData,
369            "out-of-range crash record field",
370        ));
371    }
372    let modules = (0..module_count)
373        .map(|index| CrashModule {
374            identity: get_text_sized(bytes, MODULE_OFFSET + index * MODULE_SIZE, MODULE_SIZE),
375        })
376        .collect::<Vec<_>>();
377    let mut threads = Vec::with_capacity(thread_count);
378    for index in 0..thread_count {
379        let offset = THREAD_OFFSET + index * THREAD_SIZE;
380        let frame_count = get_u32(bytes, offset + 8) as usize;
381        if frame_count > MAX_FRAMES {
382            return Err(io::Error::new(
383                io::ErrorKind::InvalidData,
384                "out-of-range crash frame count",
385            ));
386        }
387        let mut frames = Vec::with_capacity(frame_count);
388        for frame_index in 0..frame_count {
389            let frame_offset = offset + 16 + frame_index * FRAME_SIZE;
390            let raw_module = get_u32(bytes, frame_offset);
391            let module_index = if raw_module == u32::MAX {
392                None
393            } else if (raw_module as usize) < module_count {
394                Some(raw_module)
395            } else {
396                return Err(io::Error::new(
397                    io::ErrorKind::InvalidData,
398                    "crash frame references an unknown module",
399                ));
400            };
401            frames.push(CrashFrame {
402                module_index,
403                relative_address: get_u64(bytes, frame_offset + 8),
404            });
405        }
406        threads.push(CrashThread {
407            os_tid: get_u64(bytes, offset),
408            frames,
409        });
410    }
411    Ok(RawCrashReport {
412        pid: get_u32(bytes, OFF_PID),
413        tid: get_u64(bytes, OFF_TID),
414        fault_code: get_i64(bytes, OFF_FAULT_CODE),
415        fault_address: get_u64(bytes, OFF_FAULT_ADDRESS),
416        crash_unix_ms: get_u64(bytes, OFF_UNIX_MS),
417        metadata: CrashMetadata {
418            app_class: get_text(bytes, HEADER_SIZE),
419            app_name: get_text(bytes, HEADER_SIZE + TEXT_SIZE),
420            app_version: get_text(bytes, HEADER_SIZE + TEXT_SIZE * 2),
421            instance_name: get_text(bytes, HEADER_SIZE + TEXT_SIZE * 3),
422            creation_time_ms: if version == 1 {
423                0
424            } else {
425                get_u64(bytes, OFF_CREATION_TIME_MS)
426            },
427            cwd: if version == 1 {
428                String::new()
429            } else {
430                get_text_sized(bytes, CWD_OFFSET, CWD_SIZE)
431            },
432        },
433        modules,
434        threads,
435        raw_context: bytes[RAW_OFFSET..RAW_OFFSET + raw_len].to_vec(),
436        truncated: flags != 0,
437    })
438}
439
440/// Encode a report outside a compromised context.
441///
442/// The native callback uses the lower-level preallocated writer; this helper
443/// exists for daemon compatibility tests and future spool migrations.
444pub fn encode(report: &RawCrashReport) -> [u8; RECORD_SIZE] {
445    let mut bytes = [0; RECORD_SIZE];
446    initialize(&mut bytes, &report.metadata);
447    put_sample(&mut bytes, &report.modules, &report.threads);
448    // SAFETY: both fixed buffers remain valid for the duration of this call.
449    unsafe {
450        put_crash(
451            bytes.as_mut_ptr(),
452            report.tid,
453            report.fault_code,
454            report.fault_address,
455            report.raw_context.as_ptr(),
456            report.raw_context.len(),
457        );
458    }
459    // Preserve a supplied pid/time for compatibility fixtures.
460    put_u32(&mut bytes, OFF_PID, report.pid);
461    put_u64(&mut bytes, OFF_UNIX_MS, report.crash_unix_ms);
462    bytes
463}
464
465fn unix_ms() -> u64 {
466    SystemTime::now()
467        .duration_since(UNIX_EPOCH)
468        .map(|value| value.as_millis() as u64)
469        .unwrap_or(0)
470}
471
472#[cfg(unix)]
473fn unix_ms_signal_safe() -> u64 {
474    let mut ts = libc::timespec {
475        tv_sec: 0,
476        tv_nsec: 0,
477    };
478    // SAFETY: CLOCK_REALTIME and a valid pointer; clock_gettime is
479    // async-signal-safe on the supported Unix platforms.
480    if unsafe { libc::clock_gettime(libc::CLOCK_REALTIME, &raw mut ts) } == 0 {
481        (ts.tv_sec as u64)
482            .saturating_mul(1_000)
483            .saturating_add((ts.tv_nsec as u64) / 1_000_000)
484    } else {
485        0
486    }
487}
488
489#[cfg(windows)]
490fn unix_ms_signal_safe() -> u64 {
491    use windows_sys::Win32::Foundation::FILETIME;
492    use windows_sys::Win32::System::SystemInformation::GetSystemTimeAsFileTime;
493    let mut ft = FILETIME {
494        dwLowDateTime: 0,
495        dwHighDateTime: 0,
496    };
497    // SAFETY: the OS fills the stack-local FILETIME.
498    unsafe { GetSystemTimeAsFileTime(&raw mut ft) };
499    let ticks = (u64::from(ft.dwHighDateTime) << 32) | u64::from(ft.dwLowDateTime);
500    ticks.saturating_sub(116_444_736_000_000_000) / 10_000
501}
502
503fn put_text(bytes: &mut [u8], offset: usize, value: &str) {
504    put_text_sized(bytes, offset, TEXT_SIZE, value);
505}
506
507fn put_text_sized(bytes: &mut [u8], offset: usize, size: usize, value: &str) {
508    let raw = value.as_bytes();
509    let length = raw.len().min(size - 1);
510    bytes[offset..offset + length].copy_from_slice(&raw[..length]);
511}
512
513fn get_text(bytes: &[u8], offset: usize) -> String {
514    get_text_sized(bytes, offset, TEXT_SIZE)
515}
516
517fn get_text_sized(bytes: &[u8], offset: usize, size: usize) -> String {
518    let slice = &bytes[offset..offset + size];
519    let end = slice.iter().position(|byte| *byte == 0).unwrap_or(size);
520    String::from_utf8_lossy(&slice[..end]).into_owned()
521}
522
523fn set_flag(bytes: &mut [u8], flag: u32) {
524    put_u32(bytes, OFF_FLAGS, get_u32(bytes, OFF_FLAGS) | flag);
525}
526
527fn put_u32(bytes: &mut [u8], offset: usize, value: u32) {
528    bytes[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
529}
530
531fn get_u32(bytes: &[u8], offset: usize) -> u32 {
532    u32::from_le_bytes(bytes[offset..offset + 4].try_into().expect("fixed range"))
533}
534
535fn put_u64(bytes: &mut [u8], offset: usize, value: u64) {
536    bytes[offset..offset + 8].copy_from_slice(&value.to_le_bytes());
537}
538
539fn get_u64(bytes: &[u8], offset: usize) -> u64 {
540    u64::from_le_bytes(bytes[offset..offset + 8].try_into().expect("fixed range"))
541}
542
543fn put_i64(bytes: &mut [u8], offset: usize, value: i64) {
544    bytes[offset..offset + 8].copy_from_slice(&value.to_le_bytes());
545}
546
547fn get_i64(bytes: &[u8], offset: usize) -> i64 {
548    i64::from_le_bytes(bytes[offset..offset + 8].try_into().expect("fixed range"))
549}
550
551#[cfg(test)]
552mod tests {
553    use super::*;
554
555    #[test]
556    fn fixed_record_round_trips_identity_threads_and_context() {
557        let metadata = CrashMetadata {
558            app_class: "compiler".into(),
559            app_name: "worker".into(),
560            app_version: "4.6.4".into(),
561            instance_name: "west".into(),
562            creation_time_ms: 1234,
563            cwd: "/work".into(),
564        };
565        let mut bytes = [0; RECORD_SIZE];
566        initialize(&mut bytes, &metadata);
567        let modules = vec![CrashModule {
568            identity: "/app/worker".into(),
569        }];
570        put_sample(
571            &mut bytes,
572            &modules,
573            &[CrashThread {
574                os_tid: 42,
575                frames: vec![
576                    CrashFrame {
577                        module_index: Some(0),
578                        relative_address: 0x1234,
579                    },
580                    CrashFrame {
581                        module_index: Some(0),
582                        relative_address: 0x5678,
583                    },
584                ],
585            }],
586        );
587        let raw = [1u8, 2, 3, 4];
588        // SAFETY: both fixed buffers remain valid for the call.
589        unsafe {
590            put_crash(bytes.as_mut_ptr(), 42, 11, 0xdead, raw.as_ptr(), raw.len());
591        }
592        let report = parse(&bytes).expect("parse");
593        assert_eq!(report.metadata, metadata);
594        assert_eq!(report.tid, 42);
595        assert_eq!(report.fault_code, 11);
596        assert_eq!(report.fault_address, 0xdead);
597        assert_eq!(report.modules, modules);
598        assert_eq!(report.threads[0].frames[0].relative_address, 0x1234);
599        assert_eq!(report.raw_context, raw);
600    }
601
602    #[test]
603    fn version_one_records_remain_readable_without_new_tags() {
604        let metadata = CrashMetadata {
605            app_class: "legacy".into(),
606            app_name: "worker".into(),
607            app_version: "1".into(),
608            instance_name: String::new(),
609            creation_time_ms: 123,
610            cwd: "/new-layout".into(),
611        };
612        let mut bytes = [0; RECORD_SIZE];
613        initialize(&mut bytes, &metadata);
614        put_u32(&mut bytes, OFF_VERSION, 1);
615        let parsed = parse(&bytes).unwrap();
616        assert_eq!(parsed.metadata.app_class, "legacy");
617        assert_eq!(parsed.metadata.creation_time_ms, 0);
618        assert!(parsed.metadata.cwd.is_empty());
619    }
620
621    #[test]
622    fn partial_record_is_refused() {
623        assert!(parse(&[0; 100]).is_err());
624    }
625
626    #[test]
627    fn excess_threads_and_frames_are_explicitly_truncated() {
628        let metadata = CrashMetadata {
629            app_class: "a".into(),
630            app_name: "b".into(),
631            app_version: "c".into(),
632            instance_name: String::new(),
633            creation_time_ms: 1,
634            cwd: "/test".into(),
635        };
636        let mut bytes = [0; RECORD_SIZE];
637        initialize(&mut bytes, &metadata);
638        let threads = (0..MAX_THREADS + 1)
639            .map(|tid| CrashThread {
640                os_tid: tid as u64,
641                frames: vec![
642                    CrashFrame {
643                        module_index: None,
644                        relative_address: 1,
645                    };
646                    MAX_FRAMES + 1
647                ],
648            })
649            .collect::<Vec<_>>();
650        put_sample(&mut bytes, &[], &threads);
651        let parsed = parse(&bytes).unwrap();
652        assert!(parsed.truncated);
653        assert_eq!(parsed.threads.len(), MAX_THREADS);
654        assert_eq!(parsed.threads[0].frames.len(), MAX_FRAMES);
655    }
656
657    #[test]
658    fn impossible_counts_and_unknown_flags_are_rejected() {
659        let metadata = CrashMetadata {
660            app_class: "a".into(),
661            app_name: "b".into(),
662            app_version: "c".into(),
663            instance_name: String::new(),
664            creation_time_ms: 1,
665            cwd: "/test".into(),
666        };
667        let mut bytes = [0; RECORD_SIZE];
668        initialize(&mut bytes, &metadata);
669        put_u32(&mut bytes, OFF_THREAD_COUNT, (MAX_THREADS + 1) as u32);
670        assert!(parse(&bytes).is_err());
671
672        initialize(&mut bytes, &metadata);
673        put_u32(&mut bytes, OFF_FLAGS, 1 << 31);
674        assert!(parse(&bytes).is_err());
675    }
676
677    #[cfg(unix)]
678    #[test]
679    fn permissive_or_symlinked_spool_directories_are_rejected() {
680        use std::os::unix::fs::{symlink, PermissionsExt as _};
681
682        let root = tempfile::tempdir().unwrap();
683        let permissive = root.path().join("permissive");
684        std::fs::create_dir(&permissive).unwrap();
685        std::fs::set_permissions(&permissive, std::fs::Permissions::from_mode(0o755)).unwrap();
686        assert!(create_private_dir(&permissive).is_err());
687
688        let private = root.path().join("private");
689        std::fs::create_dir(&private).unwrap();
690        std::fs::set_permissions(&private, std::fs::Permissions::from_mode(0o700)).unwrap();
691        let linked = root.path().join("linked");
692        symlink(&private, &linked).unwrap();
693        assert!(create_private_dir(&linked).is_err());
694    }
695}