use std::fs::{File, OpenOptions};
use std::io;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
pub const SPOOL_DIR_ENV: &str = "RUNNING_PROCESS_PROBE_SPOOL_DIR";
pub const REPORT_DIR_ENV: &str = "RUNNING_PROCESS_PROBE_CRASH_DIR";
pub(crate) const MAGIC: &[u8; 8] = b"RPCRASH1";
pub(crate) const VERSION: u32 = 2;
pub const RECORD_SIZE: usize = 16 * 1024;
pub(crate) const HEADER_SIZE: usize = 128;
pub(crate) const TEXT_SIZE: usize = 64;
pub(crate) const TEXT_FIELDS: usize = 4;
pub(crate) const MODULE_OFFSET: usize = HEADER_SIZE + TEXT_SIZE * TEXT_FIELDS;
pub(crate) const MAX_MODULES: usize = 16;
pub(crate) const MODULE_SIZE: usize = 128;
pub(crate) const THREAD_OFFSET: usize = MODULE_OFFSET + MAX_MODULES * MODULE_SIZE;
pub(crate) const MAX_THREADS: usize = 32;
pub(crate) const MAX_FRAMES: usize = 16;
pub(crate) const FRAME_SIZE: usize = 16;
pub(crate) const THREAD_SIZE: usize = 16 + MAX_FRAMES * FRAME_SIZE;
pub(crate) const RAW_OFFSET: usize = THREAD_OFFSET + MAX_THREADS * THREAD_SIZE;
pub(crate) const CWD_SIZE: usize = 1024;
pub(crate) const CWD_OFFSET: usize = RECORD_SIZE - CWD_SIZE;
pub(crate) const MAX_RAW_CONTEXT: usize = CWD_OFFSET - RAW_OFFSET;
const V1_MAX_RAW_CONTEXT: usize = RECORD_SIZE - RAW_OFFSET;
const OFF_VERSION: usize = 8;
const OFF_RECORD_SIZE: usize = 12;
const OFF_PID: usize = 16;
const OFF_TID: usize = 24;
const OFF_FAULT_CODE: usize = 32;
const OFF_FAULT_ADDRESS: usize = 40;
const OFF_UNIX_MS: usize = 48;
const OFF_THREAD_COUNT: usize = 56;
const OFF_RAW_LEN: usize = 60;
const OFF_FLAGS: usize = 64;
const OFF_MODULE_COUNT: usize = 68;
const OFF_CREATION_TIME_MS: usize = 72;
const FLAG_TRUNCATED_THREADS: u32 = 1;
const FLAG_TRUNCATED_CONTEXT: u32 = 2;
const FLAG_TRUNCATED_MODULES: u32 = 4;
const KNOWN_FLAGS: u32 = FLAG_TRUNCATED_THREADS | FLAG_TRUNCATED_CONTEXT | FLAG_TRUNCATED_MODULES;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CrashMetadata {
pub app_class: String,
pub app_name: String,
pub app_version: String,
pub instance_name: String,
pub creation_time_ms: u64,
pub cwd: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CrashModule {
pub identity: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CrashFrame {
pub module_index: Option<u32>,
pub relative_address: u64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CrashThread {
pub os_tid: u64,
pub frames: Vec<CrashFrame>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RawCrashReport {
pub pid: u32,
pub tid: u64,
pub fault_code: i64,
pub fault_address: u64,
pub crash_unix_ms: u64,
pub metadata: CrashMetadata,
pub modules: Vec<CrashModule>,
pub threads: Vec<CrashThread>,
pub raw_context: Vec<u8>,
pub truncated: bool,
}
pub fn spool_dir() -> PathBuf {
std::env::var_os(SPOOL_DIR_ENV)
.map(PathBuf::from)
.unwrap_or_else(|| default_owner_root().join("probe-spool"))
}
pub fn report_dir() -> PathBuf {
std::env::var_os(REPORT_DIR_ENV)
.map(PathBuf::from)
.unwrap_or_else(|| default_owner_root().join("probe-crashes"))
}
#[cfg(unix)]
fn default_owner_root() -> PathBuf {
if let Some(runtime) = std::env::var_os("XDG_RUNTIME_DIR") {
let runtime = PathBuf::from(runtime);
if runtime.is_absolute() {
return runtime.join("running-process");
}
}
let uid = unsafe { libc::geteuid() };
std::env::temp_dir().join(format!("running-process-{uid}"))
}
#[cfg(not(unix))]
fn default_owner_root() -> PathBuf {
std::env::temp_dir().join("running-process")
}
pub(crate) fn create_sink(
metadata: &CrashMetadata,
) -> io::Result<(File, PathBuf, [u8; RECORD_SIZE])> {
let dir = spool_dir();
create_private_dir(&dir)?;
let now = unix_ms();
let path = dir.join(format!(
"pending-{}-{now}-{:016x}.rpcrash",
std::process::id(),
random_suffix()
));
let mut options = OpenOptions::new();
options.create_new(true).write(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt as _;
options.mode(0o600);
}
let file = options.open(&path)?;
let mut record = [0u8; RECORD_SIZE];
initialize(&mut record, metadata);
Ok((file, path, record))
}
fn random_suffix() -> u64 {
let mut bytes = [0u8; 8];
if getrandom::fill(&mut bytes).is_ok() {
u64::from_le_bytes(bytes)
} else {
unix_ms() ^ u64::from(std::process::id())
}
}
pub fn create_private_dir(path: &Path) -> io::Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::{DirBuilderExt as _, MetadataExt as _};
let mut builder = std::fs::DirBuilder::new();
builder.recursive(true).mode(0o700);
builder.create(path)?;
let metadata = std::fs::symlink_metadata(path)?;
let uid = unsafe { libc::geteuid() };
if metadata.file_type().is_symlink()
|| !metadata.is_dir()
|| metadata.uid() != uid
|| metadata.mode() & 0o077 != 0
{
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"crash directory must be a non-symlink owned by this user with mode 0700",
));
}
}
#[cfg(not(unix))]
std::fs::create_dir_all(path)?;
Ok(())
}
pub(crate) fn initialize(record: &mut [u8; RECORD_SIZE], metadata: &CrashMetadata) {
record.fill(0);
record[..8].copy_from_slice(MAGIC);
put_u32(record, OFF_VERSION, VERSION);
put_u32(record, OFF_RECORD_SIZE, RECORD_SIZE as u32);
put_u32(record, OFF_PID, std::process::id());
put_metadata(record, metadata);
}
pub(crate) fn put_metadata(record: &mut [u8; RECORD_SIZE], metadata: &CrashMetadata) {
record[HEADER_SIZE..MODULE_OFFSET].fill(0);
record[CWD_OFFSET..].fill(0);
put_text(record, HEADER_SIZE, &metadata.app_class);
put_text(record, HEADER_SIZE + TEXT_SIZE, &metadata.app_name);
put_text(record, HEADER_SIZE + TEXT_SIZE * 2, &metadata.app_version);
put_text(record, HEADER_SIZE + TEXT_SIZE * 3, &metadata.instance_name);
put_u64(record, OFF_CREATION_TIME_MS, metadata.creation_time_ms);
put_text_sized(record, CWD_OFFSET, CWD_SIZE, &metadata.cwd);
}
pub(crate) fn put_sample(
record: &mut [u8; RECORD_SIZE],
modules: &[CrashModule],
threads: &[CrashThread],
) {
record[MODULE_OFFSET..RAW_OFFSET].fill(0);
put_u32(record, OFF_FLAGS, 0);
let module_count = modules.len().min(MAX_MODULES);
put_u32(record, OFF_MODULE_COUNT, module_count as u32);
if modules.len() > MAX_MODULES {
set_flag(record, FLAG_TRUNCATED_MODULES);
}
for (index, module) in modules.iter().take(MAX_MODULES).enumerate() {
let raw = module.identity.as_bytes();
if raw.len() >= MODULE_SIZE {
set_flag(record, FLAG_TRUNCATED_MODULES);
}
put_text_sized(
record,
MODULE_OFFSET + index * MODULE_SIZE,
MODULE_SIZE,
&module.identity,
);
}
let count = threads.len().min(MAX_THREADS);
put_u32(record, OFF_THREAD_COUNT, count as u32);
if threads.len() > MAX_THREADS {
set_flag(record, FLAG_TRUNCATED_THREADS);
}
for (index, thread) in threads.iter().take(MAX_THREADS).enumerate() {
let offset = THREAD_OFFSET + index * THREAD_SIZE;
put_u64(record, offset, thread.os_tid);
let frame_count = thread
.frames
.iter()
.filter(|frame| {
frame
.module_index
.is_none_or(|module| (module as usize) < module_count)
})
.count()
.min(MAX_FRAMES);
put_u32(record, offset + 8, frame_count as u32);
if thread.frames.len() > frame_count {
set_flag(record, FLAG_TRUNCATED_THREADS);
}
for (frame_index, frame) in thread
.frames
.iter()
.filter(|frame| {
frame
.module_index
.is_none_or(|module| (module as usize) < module_count)
})
.take(MAX_FRAMES)
.enumerate()
{
let frame_offset = offset + 16 + frame_index * FRAME_SIZE;
let module_index = frame.module_index.unwrap_or(u32::MAX);
put_u32(record, frame_offset, module_index);
put_u64(record, frame_offset + 8, frame.relative_address);
}
}
}
pub(crate) unsafe fn put_crash(
record: *mut u8,
tid: u64,
fault_code: i64,
fault_address: u64,
raw_context: *const u8,
raw_len: usize,
) {
let bytes = unsafe { std::slice::from_raw_parts_mut(record, RECORD_SIZE) };
put_u64(bytes, OFF_TID, tid);
put_i64(bytes, OFF_FAULT_CODE, fault_code);
put_u64(bytes, OFF_FAULT_ADDRESS, fault_address);
put_u64(bytes, OFF_UNIX_MS, unix_ms_signal_safe());
let copied = raw_len.min(MAX_RAW_CONTEXT);
put_u32(bytes, OFF_RAW_LEN, copied as u32);
if raw_len > MAX_RAW_CONTEXT {
set_flag(bytes, FLAG_TRUNCATED_CONTEXT);
}
if copied != 0 {
unsafe {
std::ptr::copy_nonoverlapping(raw_context, bytes.as_mut_ptr().add(RAW_OFFSET), copied);
}
}
}
pub fn parse(bytes: &[u8]) -> io::Result<RawCrashReport> {
if bytes.len() != RECORD_SIZE || &bytes[..8] != MAGIC {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"incomplete or invalid crash record",
));
}
let version = get_u32(bytes, OFF_VERSION);
if (version != 1 && version != VERSION)
|| get_u32(bytes, OFF_RECORD_SIZE) as usize != RECORD_SIZE
{
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"unsupported crash record version",
));
}
let flags = get_u32(bytes, OFF_FLAGS);
let module_count = get_u32(bytes, OFF_MODULE_COUNT) as usize;
let thread_count = get_u32(bytes, OFF_THREAD_COUNT) as usize;
let raw_len = get_u32(bytes, OFF_RAW_LEN) as usize;
let max_raw_context = if version == 1 {
V1_MAX_RAW_CONTEXT
} else {
MAX_RAW_CONTEXT
};
if flags & !KNOWN_FLAGS != 0
|| module_count > MAX_MODULES
|| thread_count > MAX_THREADS
|| raw_len > max_raw_context
{
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"out-of-range crash record field",
));
}
let modules = (0..module_count)
.map(|index| CrashModule {
identity: get_text_sized(bytes, MODULE_OFFSET + index * MODULE_SIZE, MODULE_SIZE),
})
.collect::<Vec<_>>();
let mut threads = Vec::with_capacity(thread_count);
for index in 0..thread_count {
let offset = THREAD_OFFSET + index * THREAD_SIZE;
let frame_count = get_u32(bytes, offset + 8) as usize;
if frame_count > MAX_FRAMES {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"out-of-range crash frame count",
));
}
let mut frames = Vec::with_capacity(frame_count);
for frame_index in 0..frame_count {
let frame_offset = offset + 16 + frame_index * FRAME_SIZE;
let raw_module = get_u32(bytes, frame_offset);
let module_index = if raw_module == u32::MAX {
None
} else if (raw_module as usize) < module_count {
Some(raw_module)
} else {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"crash frame references an unknown module",
));
};
frames.push(CrashFrame {
module_index,
relative_address: get_u64(bytes, frame_offset + 8),
});
}
threads.push(CrashThread {
os_tid: get_u64(bytes, offset),
frames,
});
}
Ok(RawCrashReport {
pid: get_u32(bytes, OFF_PID),
tid: get_u64(bytes, OFF_TID),
fault_code: get_i64(bytes, OFF_FAULT_CODE),
fault_address: get_u64(bytes, OFF_FAULT_ADDRESS),
crash_unix_ms: get_u64(bytes, OFF_UNIX_MS),
metadata: CrashMetadata {
app_class: get_text(bytes, HEADER_SIZE),
app_name: get_text(bytes, HEADER_SIZE + TEXT_SIZE),
app_version: get_text(bytes, HEADER_SIZE + TEXT_SIZE * 2),
instance_name: get_text(bytes, HEADER_SIZE + TEXT_SIZE * 3),
creation_time_ms: if version == 1 {
0
} else {
get_u64(bytes, OFF_CREATION_TIME_MS)
},
cwd: if version == 1 {
String::new()
} else {
get_text_sized(bytes, CWD_OFFSET, CWD_SIZE)
},
},
modules,
threads,
raw_context: bytes[RAW_OFFSET..RAW_OFFSET + raw_len].to_vec(),
truncated: flags != 0,
})
}
pub fn encode(report: &RawCrashReport) -> [u8; RECORD_SIZE] {
let mut bytes = [0; RECORD_SIZE];
initialize(&mut bytes, &report.metadata);
put_sample(&mut bytes, &report.modules, &report.threads);
unsafe {
put_crash(
bytes.as_mut_ptr(),
report.tid,
report.fault_code,
report.fault_address,
report.raw_context.as_ptr(),
report.raw_context.len(),
);
}
put_u32(&mut bytes, OFF_PID, report.pid);
put_u64(&mut bytes, OFF_UNIX_MS, report.crash_unix_ms);
bytes
}
fn unix_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|value| value.as_millis() as u64)
.unwrap_or(0)
}
#[cfg(unix)]
fn unix_ms_signal_safe() -> u64 {
let mut ts = libc::timespec {
tv_sec: 0,
tv_nsec: 0,
};
if unsafe { libc::clock_gettime(libc::CLOCK_REALTIME, &raw mut ts) } == 0 {
(ts.tv_sec as u64)
.saturating_mul(1_000)
.saturating_add((ts.tv_nsec as u64) / 1_000_000)
} else {
0
}
}
#[cfg(windows)]
fn unix_ms_signal_safe() -> u64 {
use windows_sys::Win32::Foundation::FILETIME;
use windows_sys::Win32::System::SystemInformation::GetSystemTimeAsFileTime;
let mut ft = FILETIME {
dwLowDateTime: 0,
dwHighDateTime: 0,
};
unsafe { GetSystemTimeAsFileTime(&raw mut ft) };
let ticks = (u64::from(ft.dwHighDateTime) << 32) | u64::from(ft.dwLowDateTime);
ticks.saturating_sub(116_444_736_000_000_000) / 10_000
}
fn put_text(bytes: &mut [u8], offset: usize, value: &str) {
put_text_sized(bytes, offset, TEXT_SIZE, value);
}
fn put_text_sized(bytes: &mut [u8], offset: usize, size: usize, value: &str) {
let raw = value.as_bytes();
let length = raw.len().min(size - 1);
bytes[offset..offset + length].copy_from_slice(&raw[..length]);
}
fn get_text(bytes: &[u8], offset: usize) -> String {
get_text_sized(bytes, offset, TEXT_SIZE)
}
fn get_text_sized(bytes: &[u8], offset: usize, size: usize) -> String {
let slice = &bytes[offset..offset + size];
let end = slice.iter().position(|byte| *byte == 0).unwrap_or(size);
String::from_utf8_lossy(&slice[..end]).into_owned()
}
fn set_flag(bytes: &mut [u8], flag: u32) {
put_u32(bytes, OFF_FLAGS, get_u32(bytes, OFF_FLAGS) | flag);
}
fn put_u32(bytes: &mut [u8], offset: usize, value: u32) {
bytes[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
}
fn get_u32(bytes: &[u8], offset: usize) -> u32 {
u32::from_le_bytes(bytes[offset..offset + 4].try_into().expect("fixed range"))
}
fn put_u64(bytes: &mut [u8], offset: usize, value: u64) {
bytes[offset..offset + 8].copy_from_slice(&value.to_le_bytes());
}
fn get_u64(bytes: &[u8], offset: usize) -> u64 {
u64::from_le_bytes(bytes[offset..offset + 8].try_into().expect("fixed range"))
}
fn put_i64(bytes: &mut [u8], offset: usize, value: i64) {
bytes[offset..offset + 8].copy_from_slice(&value.to_le_bytes());
}
fn get_i64(bytes: &[u8], offset: usize) -> i64 {
i64::from_le_bytes(bytes[offset..offset + 8].try_into().expect("fixed range"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fixed_record_round_trips_identity_threads_and_context() {
let metadata = CrashMetadata {
app_class: "compiler".into(),
app_name: "worker".into(),
app_version: "4.6.4".into(),
instance_name: "west".into(),
creation_time_ms: 1234,
cwd: "/work".into(),
};
let mut bytes = [0; RECORD_SIZE];
initialize(&mut bytes, &metadata);
let modules = vec![CrashModule {
identity: "/app/worker".into(),
}];
put_sample(
&mut bytes,
&modules,
&[CrashThread {
os_tid: 42,
frames: vec![
CrashFrame {
module_index: Some(0),
relative_address: 0x1234,
},
CrashFrame {
module_index: Some(0),
relative_address: 0x5678,
},
],
}],
);
let raw = [1u8, 2, 3, 4];
unsafe {
put_crash(bytes.as_mut_ptr(), 42, 11, 0xdead, raw.as_ptr(), raw.len());
}
let report = parse(&bytes).expect("parse");
assert_eq!(report.metadata, metadata);
assert_eq!(report.tid, 42);
assert_eq!(report.fault_code, 11);
assert_eq!(report.fault_address, 0xdead);
assert_eq!(report.modules, modules);
assert_eq!(report.threads[0].frames[0].relative_address, 0x1234);
assert_eq!(report.raw_context, raw);
}
#[test]
fn version_one_records_remain_readable_without_new_tags() {
let metadata = CrashMetadata {
app_class: "legacy".into(),
app_name: "worker".into(),
app_version: "1".into(),
instance_name: String::new(),
creation_time_ms: 123,
cwd: "/new-layout".into(),
};
let mut bytes = [0; RECORD_SIZE];
initialize(&mut bytes, &metadata);
put_u32(&mut bytes, OFF_VERSION, 1);
let parsed = parse(&bytes).unwrap();
assert_eq!(parsed.metadata.app_class, "legacy");
assert_eq!(parsed.metadata.creation_time_ms, 0);
assert!(parsed.metadata.cwd.is_empty());
}
#[test]
fn partial_record_is_refused() {
assert!(parse(&[0; 100]).is_err());
}
#[test]
fn excess_threads_and_frames_are_explicitly_truncated() {
let metadata = CrashMetadata {
app_class: "a".into(),
app_name: "b".into(),
app_version: "c".into(),
instance_name: String::new(),
creation_time_ms: 1,
cwd: "/test".into(),
};
let mut bytes = [0; RECORD_SIZE];
initialize(&mut bytes, &metadata);
let threads = (0..MAX_THREADS + 1)
.map(|tid| CrashThread {
os_tid: tid as u64,
frames: vec![
CrashFrame {
module_index: None,
relative_address: 1,
};
MAX_FRAMES + 1
],
})
.collect::<Vec<_>>();
put_sample(&mut bytes, &[], &threads);
let parsed = parse(&bytes).unwrap();
assert!(parsed.truncated);
assert_eq!(parsed.threads.len(), MAX_THREADS);
assert_eq!(parsed.threads[0].frames.len(), MAX_FRAMES);
}
#[test]
fn impossible_counts_and_unknown_flags_are_rejected() {
let metadata = CrashMetadata {
app_class: "a".into(),
app_name: "b".into(),
app_version: "c".into(),
instance_name: String::new(),
creation_time_ms: 1,
cwd: "/test".into(),
};
let mut bytes = [0; RECORD_SIZE];
initialize(&mut bytes, &metadata);
put_u32(&mut bytes, OFF_THREAD_COUNT, (MAX_THREADS + 1) as u32);
assert!(parse(&bytes).is_err());
initialize(&mut bytes, &metadata);
put_u32(&mut bytes, OFF_FLAGS, 1 << 31);
assert!(parse(&bytes).is_err());
}
#[cfg(unix)]
#[test]
fn permissive_or_symlinked_spool_directories_are_rejected() {
use std::os::unix::fs::{symlink, PermissionsExt as _};
let root = tempfile::tempdir().unwrap();
let permissive = root.path().join("permissive");
std::fs::create_dir(&permissive).unwrap();
std::fs::set_permissions(&permissive, std::fs::Permissions::from_mode(0o755)).unwrap();
assert!(create_private_dir(&permissive).is_err());
let private = root.path().join("private");
std::fs::create_dir(&private).unwrap();
std::fs::set_permissions(&private, std::fs::Permissions::from_mode(0o700)).unwrap();
let linked = root.path().join("linked");
symlink(&private, &linked).unwrap();
assert!(create_private_dir(&linked).is_err());
}
}