From 673e0af755e7cfaff7238dc07b9e576a0e92456f Mon Sep 17 00:00:00 2001 From: G36maid Date: Thu, 22 Oct 2026 04:20:49 +0800 Subject: [PATCH 1/8] freebsd: add platform module and process inspection --- build.rs | 4 + src/freebsd/mod.rs | 18 + src/freebsd/process_inspection/mod.rs | 829 ++++++++++++++++++ .../process_inspection/process_reader.rs | 43 + src/freebsd/process_inspection/regs.rs | 35 + src/freebsd/ptrace_io.rs | 58 ++ src/freebsd/vm_permissions.rs | 14 + src/lib.rs | 4 + src/process_reader.rs | 3 + 9 files changed, 1008 insertions(+) create mode 100644 src/freebsd/mod.rs create mode 100644 src/freebsd/process_inspection/mod.rs create mode 100644 src/freebsd/process_inspection/process_reader.rs create mode 100644 src/freebsd/process_inspection/regs.rs create mode 100644 src/freebsd/ptrace_io.rs create mode 100644 src/freebsd/vm_permissions.rs diff --git a/build.rs b/build.rs index 23efce46..28082381 100644 --- a/build.rs +++ b/build.rs @@ -2,4 +2,8 @@ fn main() { if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") { println!("cargo:rustc-link-lib=dylib=dbghelp"); } + // kinfo_getvmmap() is provided by libutil + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("freebsd") { + println!("cargo:rustc-link-lib=dylib=util"); + } } diff --git a/src/freebsd/mod.rs b/src/freebsd/mod.rs new file mode 100644 index 00000000..79783048 --- /dev/null +++ b/src/freebsd/mod.rs @@ -0,0 +1,18 @@ +//! FreeBSD implementation of minidump generation. +//! +//! All process state required to write a minidump is gathered through the +//! [`ProcessInspector`] trait: down-stream modules (thread info, maps, +//! module readers, minidump streams) never invoke `ptrace`/`sysctl` +//! directly. +//! +//! [`ProcessInspector`]: process_inspection::ProcessInspector + +pub mod process_inspection; + +pub mod vm_permissions; + +mod ptrace_io; + +pub use process_inspection::{local, process_reader}; + +pub type Pid = i32; diff --git a/src/freebsd/process_inspection/mod.rs b/src/freebsd/process_inspection/mod.rs new file mode 100644 index 00000000..99eb9767 --- /dev/null +++ b/src/freebsd/process_inspection/mod.rs @@ -0,0 +1,829 @@ +//! FreeBSD process inspection. +//! +//! All process state required to write a minidump is gathered through the +//! [`ProcessInspector`] trait: down-stream modules (thread info, maps, +//! module readers, minidump streams) never invoke `ptrace`/`sysctl` +//! directly. + +use std::{ + cell::Cell, + ffi::{CStr, OsString}, + fmt, io, + mem::MaybeUninit, + os::unix::ffi::OsStringExt, + time::{Duration, Instant}, +}; + +use crate::freebsd::vm_permissions::VmPermissions; + +/// A single entry of a process's virtual memory map. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VmMapEntry { + /// The start address of the mapping (inclusive). + pub start_address: u64, + /// The end address of the mapping (exclusive). + pub end_address: u64, + /// The offset of the mapping within the mapped file, if any. + pub offset: u64, + /// The permissions of the mapping. + pub permissions: VmPermissions, + /// The pathname of the mapped file, if the mapping is file-backed. + pub file_path: Option, +} + +/// Converts a kernel-provided fixed-size path buffer into an `OsString`. +fn c_path_to_option(buf: &[u8]) -> Option { + let len = buf.iter().position(|&b| b == 0).unwrap_or(buf.len()); + if len == 0 { + return None; + } + Some(OsString::from_vec(buf[..len].to_vec())) +} + +pub mod process_reader; +pub mod regs; + +pub use process_reader::{CopyFromProcessError, ProcessReader}; +pub use regs::{FpRegs, GenRegs}; + +/// Timeout for the process to report its stop after `PT_ATTACH`. +const ATTACH_TIMEOUT: Duration = Duration::from_secs(5); + +/// Interval between `waitpid` polls while waiting for a ptrace stop. +const ATTACH_POLL_INTERVAL: Duration = Duration::from_millis(1); + +/// `KVME_TYPE_GUARD` from FreeBSD's `sys/user.h` (not yet exposed by the +/// `libc` crate): an `mmap(MAP_GUARD)` mapping that faults on any access. +const KVME_TYPE_GUARD: libc::c_int = 9; + +// `kve_version` is not validated here: `kinfo_getvmmap` (libutil) assembles +// the entries and normalizes them, the version check is only needed by raw +// `KERN_PROC_VMMAP` sysctl consumers. + +pub(crate) type Result = std::result::Result; + +/// Errors that can occur while inspecting a process. +#[derive(Debug, thiserror::Error, serde::Serialize)] +pub enum Error { + #[error("failed to attach to process {pid}")] + Attach { + pid: libc::pid_t, + #[source] + #[serde(serialize_with = "crate::serializers::serialize_io_error")] + source: io::Error, + }, + #[error("failed to detach from process {pid}")] + Detach { + pid: libc::pid_t, + #[source] + #[serde(serialize_with = "crate::serializers::serialize_io_error")] + source: io::Error, + }, + #[error("failed to stop process {pid}")] + StopProcess { + pid: libc::pid_t, + #[source] + #[serde(serialize_with = "crate::serializers::serialize_io_error")] + source: io::Error, + }, + #[error("failed to continue process {pid}")] + ContinueProcess { + pid: libc::pid_t, + #[source] + #[serde(serialize_with = "crate::serializers::serialize_io_error")] + source: io::Error, + }, + #[error("failed to suspend thread {tid}")] + SuspendThread { + tid: libc::pid_t, + #[source] + #[serde(serialize_with = "crate::serializers::serialize_io_error")] + source: io::Error, + }, + #[error("failed to resume thread {tid}")] + ResumeThread { + tid: libc::pid_t, + #[source] + #[serde(serialize_with = "crate::serializers::serialize_io_error")] + source: io::Error, + }, + #[error("waitpid on process {pid} failed")] + WaitPid { + pid: libc::pid_t, + #[source] + #[serde(serialize_with = "crate::serializers::serialize_io_error")] + source: io::Error, + }, + #[error("got an unexpected waitpid status ({status:#x}) for process {pid}")] + UnexpectedWaitStatus { + pid: libc::pid_t, + status: libc::c_int, + }, + #[error("failed to get general-purpose registers for thread {tid}")] + GetGenRegs { + tid: libc::pid_t, + #[source] + #[serde(serialize_with = "crate::serializers::serialize_io_error")] + source: io::Error, + }, + #[error("failed to get floating-point registers for thread {tid}")] + GetFpRegs { + tid: libc::pid_t, + #[source] + #[serde(serialize_with = "crate::serializers::serialize_io_error")] + source: io::Error, + }, + #[error("failed to enumerate threads of process {pid}")] + GetThreadList { + pid: libc::pid_t, + #[source] + #[serde(serialize_with = "crate::serializers::serialize_io_error")] + source: io::Error, + }, + #[error("failed to read the thread names of process {pid}")] + GetThreadNames { + pid: libc::pid_t, + #[source] + #[serde(serialize_with = "crate::serializers::serialize_io_error")] + source: io::Error, + }, + #[error("failed to read the auxv of process {pid}")] + GetAuxv { + pid: libc::pid_t, + #[source] + #[serde(serialize_with = "crate::serializers::serialize_io_error")] + source: io::Error, + }, + #[error("failed to read the memory map of process {pid}")] + GetVmMap { + pid: libc::pid_t, + #[source] + #[serde(serialize_with = "crate::serializers::serialize_io_error")] + source: io::Error, + }, +} + +/// Provides access to the state of a process running on the local machine. +/// +/// Down-stream consumers (thread info, maps/module readers, the minidump +/// streams) always go through this trait instead of issuing `ptrace` or +/// `sysctl` requests themselves. +/// +/// All methods that inspect process state (memory, registers, threads) +/// require the process to have been attached via [`attach`](Self::attach) +/// first. +pub trait ProcessInspector: fmt::Debug { + fn process_reader<'a>(&'a self) -> ProcessReader<'a>; + + fn pid(&self) -> Result; + + /// Attaches to the process with `PT_ATTACH` and waits for it to stop. + /// Attaching also stops the process. + fn attach(&self) -> Result<()>; + + /// Detaches from the process with `PT_DETACH`, resuming it. + fn detach(&self) -> Result<()>; + + /// Reads memory at `address` into `buffer`, returning the number of bytes + /// read (which may be less than `buffer.len()` on a partial read). + fn read_at( + &self, + address: usize, + buffer: &mut [u8], + ) -> std::result::Result; + + /// Stops the whole process without establishing a trace. + fn stop_process(&self) -> Result<()>; + + /// Resumes a process stopped with [`stop_process`](Self::stop_process). + fn continue_process(&self) -> Result<()>; + + /// Suspends a single thread (light-weight process). + fn suspend_thread(&self, tid: libc::pid_t) -> Result<()>; + + /// Resumes a thread suspended with + /// [`suspend_thread`](Self::suspend_thread). + fn resume_thread(&self, tid: libc::pid_t) -> Result<()>; + + /// Returns the ids of all threads (light-weight processes). + fn get_thread_list(&self) -> Result>; + + /// Returns the names of all threads in one go (single `sysctl`). + /// + /// The name is the per-thread name set via `pthread_set_name_np`, + /// falling back to the process command name. + fn get_thread_names(&self) -> Result>; + + fn get_gen_regs(&self, tid: libc::pid_t) -> Result; + + fn get_fp_regs(&self, tid: libc::pid_t) -> Result; + + /// Returns the raw auxiliary vector (`Elf_AuxInfo` pairs), or an empty + /// vector if the process has no auxv entry. + fn get_auxv(&self) -> Result>; + + /// Returns the virtual memory map of the process, with guard + /// (`mmap(MAP_GUARD)`) and dead (being-torn-down) mappings filtered out. + fn get_vm_map(&self) -> Result>; +} + +/// Creates a [`ProcessInspector`] for a process running on the local machine. +pub fn local(pid: libc::pid_t) -> Box { + Box::new(LocalInspector::new(pid)) +} + +/// Local, `ptrace`-backed [`ProcessInspector`]. +/// +/// Every FreeBSD ptrace request — including `PT_IO` — requires an +/// established trace relationship, so [`attach`](ProcessInspector::attach) +/// and [`detach`](ProcessInspector::detach) are part of the inspection +/// lifecycle. +#[derive(Debug)] +struct LocalInspector { + pid: libc::pid_t, + attached: Cell, +} + +impl LocalInspector { + fn new(pid: libc::pid_t) -> Self { + Self { + pid, + attached: Cell::new(false), + } + } + + /// Issues a ptrace request that takes no address argument. + fn ptrace(request: libc::c_int, id: libc::pid_t) -> io::Result<()> { + // SAFETY: `PT_ATTACH`/`PT_DETACH` interpret the `data` argument as a + // signal number, 0 here, meaning "no signal to deliver"; for + // `PT_SUSPEND`/`PT_RESUME` the kernel ignores it. + if unsafe { libc::ptrace(request, id, std::ptr::null_mut(), 0) } == -1 { + Err(io::Error::last_os_error()) + } else { + Ok(()) + } + } + + /// Returns the process state (`ki_stat`) of `pid`, or `None` if the + /// process no longer exists (the sysctl succeeds with an empty table + /// for exited-but-not-reaped and gone pids). + fn process_state(pid: libc::pid_t) -> io::Result> { + let mib = [libc::CTL_KERN, libc::KERN_PROC, libc::KERN_PROC_PID, pid]; + let mut kproc: libc::kinfo_proc = unsafe { std::mem::zeroed() }; + let mut len = std::mem::size_of::(); + // SAFETY: the out-buffer and size describe one valid `kinfo_proc` + // slot. + if unsafe { + libc::sysctl( + mib.as_ptr(), + mib.len() as libc::c_uint, + (&mut kproc as *mut _) as *mut libc::c_void, + &mut len, + std::ptr::null(), + 0, + ) + } != 0 + { + return Err(io::Error::last_os_error()); + } + if len < std::mem::size_of::() { + return Ok(None); + } + Ok(Some(kproc.ki_stat)) + } + + /// Waits until `pid` reports a stop, polling with `WNOHANG` until + /// [`ATTACH_TIMEOUT`] expires. + fn wait_for_stop(pid: libc::pid_t) -> Result<()> { + let deadline = Instant::now() + ATTACH_TIMEOUT; + let mut status: libc::c_int = 0; + loop { + // SAFETY: `status` is a valid out-pointer for waitpid and + // `WNOHANG` makes the call non-blocking. + let ret = unsafe { libc::waitpid(pid, &mut status, libc::WNOHANG) }; + if ret == -1 { + let err = io::Error::last_os_error(); + if err.raw_os_error() == Some(libc::EINTR) { + continue; + } + // Waiting on a process that is not our child fails with + // ECHILD. Poll the process state instead so we don't race + // a still-running process. + if err.raw_os_error() == Some(libc::ECHILD) { + loop { + match Self::process_state(pid) { + Ok(Some(s)) if s == libc::SSTOP => return Ok(()), + // Exited or gone: nothing to wait for. + Ok(None | Some(libc::SZOMB)) => return Ok(()), + Ok(_) => (), + Err(_) => return Ok(()), // best effort + } + if Instant::now() > deadline { + return Ok(()); // best effort: proceed anyway + } + std::thread::sleep(ATTACH_POLL_INTERVAL); + } + } + return Err(Error::WaitPid { pid, source: err }); + } + if ret == pid { + if libc::WIFSTOPPED(status) { + return Ok(()); + } + // The process exited or was signaled instead of stopping. + return Err(Error::UnexpectedWaitStatus { pid, status }); + } + + if Instant::now() > deadline { + return Err(Error::WaitPid { + pid, + source: io::Error::new( + io::ErrorKind::TimedOut, + "timed out waiting for ptrace stop", + ), + }); + } + std::thread::sleep(ATTACH_POLL_INTERVAL); + } + } +} + +impl ProcessInspector for LocalInspector { + fn process_reader<'a>(&'a self) -> ProcessReader<'a> { + ProcessReader::new(self) + } + + fn pid(&self) -> Result { + Ok(self.pid) + } + + fn attach(&self) -> Result<()> { + if self.attached.get() { + return Ok(()); + } + + Self::ptrace(libc::PT_ATTACH, self.pid).map_err(|source| Error::Attach { + pid: self.pid, + source, + })?; + + if let Err(err) = Self::wait_for_stop(self.pid) { + // Best-effort cleanup so we don't leave the process traced. + let _ = Self::ptrace(libc::PT_DETACH, self.pid); + return Err(err); + } + + self.attached.set(true); + Ok(()) + } + + fn detach(&self) -> Result<()> { + if !self.attached.get() { + return Ok(()); + } + + match Self::ptrace(libc::PT_DETACH, self.pid) { + Ok(()) => { + self.attached.set(false); + Ok(()) + } + Err(source) => { + // ESRCH means the process is gone, nothing to detach from. + if source.raw_os_error() == Some(libc::ESRCH) { + self.attached.set(false); + Ok(()) + } else { + Err(Error::Detach { + pid: self.pid, + source, + }) + } + } + } + } + + fn read_at( + &self, + address: usize, + buffer: &mut [u8], + ) -> std::result::Result { + crate::freebsd::ptrace_io::read(self.pid, address, buffer).map_err(|source| { + CopyFromProcessError { + child: self.pid, + src: address, + length: buffer.len(), + source, + } + }) + } + + fn stop_process(&self) -> Result<()> { + // SAFETY: kill only sends the signal, the return value is checked. + if unsafe { libc::kill(self.pid, libc::SIGSTOP) } == -1 { + return Err(Error::StopProcess { + pid: self.pid, + source: io::Error::last_os_error(), + }); + } + + Self::wait_for_stop(self.pid) + } + + fn continue_process(&self) -> Result<()> { + // SAFETY: kill only sends the signal, the return value is checked. + if unsafe { libc::kill(self.pid, libc::SIGCONT) } == -1 { + return Err(Error::ContinueProcess { + pid: self.pid, + source: io::Error::last_os_error(), + }); + } + Ok(()) + } + + fn suspend_thread(&self, tid: libc::pid_t) -> Result<()> { + Self::ptrace(libc::PT_SUSPEND, tid).map_err(|source| Error::SuspendThread { tid, source }) + } + + fn resume_thread(&self, tid: libc::pid_t) -> Result<()> { + Self::ptrace(libc::PT_RESUME, tid).map_err(|source| Error::ResumeThread { tid, source }) + } + + fn get_thread_list(&self) -> Result> { + // SAFETY: PT_GETNUMLWPS takes no address argument and returns the + // number of LWPs of the traced process, or -1 on error. + let num_lwps = + unsafe { libc::ptrace(libc::PT_GETNUMLWPS, self.pid, std::ptr::null_mut(), 0) }; + if num_lwps == -1 { + return Err(Error::GetThreadList { + pid: self.pid, + source: io::Error::last_os_error(), + }); + } + + let count = num_lwps as usize; + let mut tids = vec![0 as libc::pid_t; count]; + + // SAFETY: PT_GETLWPLIST fills the provided buffer with thread ids of + // the traced process, writing at most `count` entries. We provide a + // valid buffer of exactly that capacity. + let filled = unsafe { + libc::ptrace( + libc::PT_GETLWPLIST, + self.pid, + tids.as_mut_ptr().cast(), + count as libc::c_int, + ) + }; + if filled == -1 { + return Err(Error::GetThreadList { + pid: self.pid, + source: io::Error::last_os_error(), + }); + } + + tids.truncate(filled as usize); + Ok(tids) + } + + fn get_gen_regs(&self, tid: libc::pid_t) -> Result { + let mut regs = MaybeUninit::::uninit(); + // SAFETY: PT_GETREGS fills the provided buffer with the thread's + // registers on success, fully initializing it. + let ret = unsafe { libc::ptrace(libc::PT_GETREGS, tid, regs.as_mut_ptr().cast(), 0) }; + if ret == -1 { + return Err(Error::GetGenRegs { + tid, + source: io::Error::last_os_error(), + }); + } + // SAFETY: ptrace returned success, so the kernel has fully + // initialized the register struct. + Ok(GenRegs(unsafe { regs.assume_init() })) + } + + fn get_fp_regs(&self, tid: libc::pid_t) -> Result { + let mut fpregs = MaybeUninit::::uninit(); + // SAFETY: PT_GETFPREGS fills the provided buffer with the thread's + // floating-point registers on success, fully initializing it. + let ret = unsafe { libc::ptrace(libc::PT_GETFPREGS, tid, fpregs.as_mut_ptr().cast(), 0) }; + if ret == -1 { + return Err(Error::GetFpRegs { + tid, + source: io::Error::last_os_error(), + }); + } + // SAFETY: ptrace returned success, so the kernel has fully + // initialized the register struct. + Ok(FpRegs(unsafe { fpregs.assume_init() })) + } + + fn get_auxv(&self) -> Result> { + let mib = [ + libc::CTL_KERN, + libc::KERN_PROC, + libc::KERN_PROC_AUXV, + self.pid, + ]; + + let mut len = 0usize; + // SAFETY: sysctl is a well-defined kernel interface. We pass a valid + // mib, a null output buffer with a size pointer to query the required + // buffer size, and no new value for a read-only query. + if unsafe { + libc::sysctl( + mib.as_ptr(), + mib.len() as libc::c_uint, + std::ptr::null_mut(), + &mut len, + std::ptr::null(), + 0, + ) + } != 0 + { + return Err(Error::GetAuxv { + pid: self.pid, + source: io::Error::last_os_error(), + }); + } + + if len == 0 { + // The process has no auxv entry. + return Ok(Vec::new()); + } + + let mut buffer = vec![0u8; len]; + // SAFETY: the buffer has the size reported by the kernel and is + // valid for writes of `len` bytes. + if unsafe { + libc::sysctl( + mib.as_ptr(), + mib.len() as libc::c_uint, + buffer.as_mut_ptr().cast(), + &mut len, + std::ptr::null(), + 0, + ) + } != 0 + { + return Err(Error::GetAuxv { + pid: self.pid, + source: io::Error::last_os_error(), + }); + } + + buffer.truncate(len); + Ok(buffer) + } + + fn get_thread_names(&self) -> Result> { + let mib = [ + libc::CTL_KERN, + libc::KERN_PROC, + libc::KERN_PROC_PID | libc::KERN_PROC_INC_THREAD, + self.pid, + ]; + + let mut len = 0usize; + // SAFETY: sysctl size-query with a valid mib, null output and a size + // pointer. + let ret = unsafe { + libc::sysctl( + mib.as_ptr(), + mib.len() as libc::c_uint, + std::ptr::null_mut(), + &mut len, + std::ptr::null(), + 0, + ) + }; + if ret != 0 || len == 0 { + return Err(Error::GetThreadNames { + pid: self.pid, + source: io::Error::last_os_error(), + }); + } + + let mut buffer = vec![0u8; len]; + + // SAFETY: the buffer has the kernel-reported size; the kernel fills + // it with one `kinfo_proc` per thread on success and updates `len` + // to the number of bytes written. + let ret = unsafe { + libc::sysctl( + mib.as_ptr(), + mib.len() as libc::c_uint, + buffer.as_mut_ptr().cast(), + &mut len, + std::ptr::null(), + 0, + ) + }; + if ret != 0 { + return Err(Error::GetThreadNames { + pid: self.pid, + source: io::Error::last_os_error(), + }); + } + + // Use the length reported by the second call: the thread list may + // have shrunk between the two sysctls. + let entry_size = std::mem::size_of::(); + let count = len / entry_size; + + let mut names = Vec::with_capacity(count); + for i in 0..count { + // SAFETY: `read_unaligned` sidesteps the (u8-buffer) alignment + // requirement; `i` is in bounds per the kernel-reported count. + let kp = unsafe { + buffer + .as_ptr() + .add(i * entry_size) + .cast::() + .read_unaligned() + }; + // SAFETY: the kernel guarantees that `ki_tdname` and `ki_comm` + // contain null-terminated strings. + let name = unsafe { CStr::from_ptr(kp.ki_tdname.as_ptr()) }; + let name = if name.is_empty() { + // SAFETY: same invariant as `ki_tdname`. + unsafe { CStr::from_ptr(kp.ki_comm.as_ptr()) } + } else { + name + }; + if !name.is_empty() { + names.push((kp.ki_tid, name.to_string_lossy().into_owned())); + } + } + + Ok(names) + } + + fn get_vm_map(&self) -> Result> { + let mut count: libc::c_int = 0; + + // SAFETY: kinfo_getvmmap is a well-defined libutil function that + // returns a malloc-allocated array of `kinfo_vmentry` entries on + // success or NULL on failure. The slice is copied into a Vec before + // the allocation is freed. + let entries = unsafe { + let ptr = libc::kinfo_getvmmap(self.pid, &mut count); + if ptr.is_null() { + // libutil doesn't guarantee errno is set on every failure + // path, so don't report a possibly stale errno. + return Err(Error::GetVmMap { + pid: self.pid, + source: io::Error::other("kinfo_getvmmap failed"), + }); + } + let slice = std::slice::from_raw_parts(ptr, count as usize); + let entries = slice.to_vec(); + libc::free(ptr.cast()); + entries + }; + + Ok(vm_entries_to_map(entries)) + } +} + +/// Detaches on drop so a discarded inspector can never leave the target +/// process traced and stopped. +impl Drop for LocalInspector { + fn drop(&mut self) { + if self.attached.get() + && let Err(e) = Self::ptrace(libc::PT_DETACH, self.pid) + { + // ESRCH: the process is gone, nothing to detach from. + if e.raw_os_error() != Some(libc::ESRCH) { + log::error!("failed to detach from process {}: {e}", self.pid); + } + } + } +} + +/// Converts `kinfo_vmentry` records into neutral [`VmMapEntry`]s, dropping +/// guard and dead mappings. +fn vm_entries_to_map(entries: Vec) -> Vec { + entries + .into_iter() + .filter(|entry| { + // Guard mappings (`mmap(MAP_GUARD)`) fault on any access and + // dead entries describe mappings being torn down: neither is + // real memory, so they never surface as mappings. + entry.kve_type != KVME_TYPE_GUARD && entry.kve_type != libc::KVME_TYPE_DEAD + }) + .map(|entry| { + let mut permissions = VmPermissions::empty(); + if entry.kve_protection & libc::KVME_PROT_READ != 0 { + permissions |= VmPermissions::READ; + } + if entry.kve_protection & libc::KVME_PROT_WRITE != 0 { + permissions |= VmPermissions::WRITE; + } + if entry.kve_protection & libc::KVME_PROT_EXEC != 0 { + permissions |= VmPermissions::EXECUTE; + } + if entry.kve_flags & libc::KVME_FLAG_COW != 0 { + permissions |= VmPermissions::PRIVATE; + } + + VmMapEntry { + start_address: entry.kve_start, + end_address: entry.kve_end, + offset: entry.kve_offset, + permissions, + file_path: { + // libc declares kve_path as a nested [[c_char; 32]; 32] + // to work around array trait-impl limits, flatten it + // back into a byte slice. + let path = entry.kve_path.as_flattened(); + // SAFETY: `c_char` and `u8` have the same layout. + let path = unsafe { + std::slice::from_raw_parts(path.as_ptr().cast::(), path.len()) + }; + c_path_to_option(path) + }, + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn local_inspector_reports_pid() { + let inspector = local(std::process::id() as libc::pid_t); + assert_eq!(inspector.pid().unwrap(), std::process::id() as libc::pid_t); + } +} + +#[cfg(test)] +mod vm_map_tests { + use super::*; + + fn entry( + start: u64, + end: u64, + kve_type: libc::c_int, + protection: libc::c_int, + ) -> libc::kinfo_vmentry { + // SAFETY (test-only): `kinfo_vmentry` is a plain repr(C) struct of + // integers and byte arrays; zero-initialization is a valid state. + let mut e: libc::kinfo_vmentry = unsafe { std::mem::zeroed() }; + e.kve_start = start; + e.kve_end = end; + e.kve_type = kve_type; + e.kve_protection = protection; + e + } + + #[test] + fn guard_mappings_are_dropped() { + // Realistic pthread stack layout: + // 4K guard | 1M guard (stack pool) | RW stack | RW anonymous + let entries = vec![ + entry(0x1000, 0x2000, KVME_TYPE_GUARD, 0), + entry(0x2000, 0x100_2000, KVME_TYPE_GUARD, 0), + entry( + 0x100_2000, + 0x102_2000, + libc::KVME_TYPE_SWAP, + libc::KVME_PROT_READ | libc::KVME_PROT_WRITE, + ), + entry( + 0x102_2000, + 0x103_2000, + libc::KVME_TYPE_DEFAULT, + libc::KVME_PROT_READ, + ), + ]; + + let map = vm_entries_to_map(entries); + assert_eq!(map.len(), 2, "guard entries must be filtered out"); + + let stack = &map[0]; + assert!(stack.permissions.contains(VmPermissions::READ)); + assert!(stack.permissions.contains(VmPermissions::WRITE)); + assert!(stack.file_path.is_none()); + + // No mapping covers the guard region anymore. + assert!( + map.iter() + .all(|m| !(m.start_address <= 0x1800 && 0x1800 < m.end_address)) + ); + } + + #[test] + fn cow_flag_maps_to_private() { + let mut e = entry( + 0x1000, + 0x2000, + libc::KVME_TYPE_SWAP, + libc::KVME_PROT_READ | libc::KVME_PROT_WRITE, + ); + e.kve_flags = libc::KVME_FLAG_COW; + let map = vm_entries_to_map(vec![e]); + assert!(map[0].permissions.contains(VmPermissions::PRIVATE)); + } +} diff --git a/src/freebsd/process_inspection/process_reader.rs b/src/freebsd/process_inspection/process_reader.rs new file mode 100644 index 00000000..7d2f0ea3 --- /dev/null +++ b/src/freebsd/process_inspection/process_reader.rs @@ -0,0 +1,43 @@ +//! Process memory reading on FreeBSD. +//! +//! A [`ProcessReader`] is obtained from a +//! [`ProcessInspector`](super::ProcessInspector) and routes every read through +//! it; this type never issues syscalls itself. + +use super::ProcessInspector; +use crate::serializers::serialize_io_error; + +/// A handle to a process, which on FreeBSD is simply the process id. +pub type ProcessHandle = libc::pid_t; + +/// Error returned when reading memory from a traced process fails. +#[derive(Debug, thiserror::Error, serde::Serialize)] +#[error("copy from process {child} failed (source {src}, length {length})")] +pub struct CopyFromProcessError { + pub child: ProcessHandle, + pub src: usize, + pub length: usize, + #[source] + #[serde(serialize_with = "serialize_io_error")] + pub source: std::io::Error, +} + +/// Reads memory from a process. +#[derive(Debug)] +pub struct ProcessReader<'a>(&'a dyn ProcessInspector); + +impl<'a> ProcessReader<'a> { + pub(crate) fn new(inspector: &'a dyn ProcessInspector) -> Self { + Self(inspector) + } + + /// Reads memory from the process into the given buffer. + /// + /// The process must have been attached to the inspector beforehand, see + /// [`ProcessInspector::attach`](super::ProcessInspector::attach). + /// + /// Returns the number of bytes read. + pub fn read(&self, src: usize, dst: &mut [u8]) -> Result { + self.0.read_at(src, dst) + } +} diff --git a/src/freebsd/process_inspection/regs.rs b/src/freebsd/process_inspection/regs.rs new file mode 100644 index 00000000..daaf8329 --- /dev/null +++ b/src/freebsd/process_inspection/regs.rs @@ -0,0 +1,35 @@ +//! Register types returned by [`ProcessInspector`](super::ProcessInspector). +//! +//! These wrap the raw `libc` register structures (exposed as a public tuple +//! field) so the inspector interface stays stable if the underlying +//! representation changes. + +use std::fmt; + +cfg_if::cfg_if! { + if #[cfg(target_arch = "x86_64")] { + /// General-purpose registers, as returned by `PT_GETREGS`. + #[derive(Clone, Copy)] + pub struct GenRegs(pub libc::reg); + + /// Floating-point registers, as returned by `PT_GETFPREGS`. + #[derive(Clone, Copy)] + pub struct FpRegs(pub libc::fpreg); + } +} + +impl fmt::Debug for GenRegs { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + // Delegate to libc's Debug impl. + self.0.fmt(f) + } +} + +impl fmt::Debug for FpRegs { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + // Delegate to libc's Debug impl. The floating-point register save + // area contains kernel-provided FXSAVE data which may include + // padding bytes, but that only affects Debug output, not soundness. + self.0.fmt(f) + } +} diff --git a/src/freebsd/ptrace_io.rs b/src/freebsd/ptrace_io.rs new file mode 100644 index 00000000..6e24d938 --- /dev/null +++ b/src/freebsd/ptrace_io.rs @@ -0,0 +1,58 @@ +//! Helpers for FreeBSD's `ptrace(PT_IO)` request. + +use std::io; + +/// `PIOD_READ_D` from FreeBSD's `sys/ptrace.h`. +const PIOD_READ_D: libc::c_int = 1; + +/// `struct ptrace_io_desc` from `sys/ptrace.h`, manually defined because the +/// `libc` crate does not expose it. +#[repr(C)] +struct PtraceIoDesc { + piod_op: libc::c_int, + piod_offs: *mut libc::c_void, + piod_addr: *mut libc::c_void, + piod_len: libc::size_t, +} + +// ABI guards: `PT_IO` silently corrupts the request if this layout drifts +// from the kernel's `struct ptrace_io_desc`. +const _: () = assert!(std::mem::size_of::() == 32); +const _: () = assert!(std::mem::offset_of!(PtraceIoDesc, piod_op) == 0); +const _: () = assert!(std::mem::offset_of!(PtraceIoDesc, piod_offs) == 8); +const _: () = assert!(std::mem::offset_of!(PtraceIoDesc, piod_addr) == 16); +const _: () = assert!(std::mem::offset_of!(PtraceIoDesc, piod_len) == 24); + +/// Reads `buffer.len()` bytes at `address` from the traced process `pid`. +/// +/// The caller must be attached to `pid`. Returns the number of bytes actually +/// read, which may be less on a partial transfer. +pub(super) fn read(pid: libc::pid_t, address: usize, buffer: &mut [u8]) -> io::Result { + if buffer.is_empty() { + return Ok(0); + } + + let mut desc = PtraceIoDesc { + piod_op: PIOD_READ_D, + piod_offs: address as *mut libc::c_void, + piod_addr: buffer.as_mut_ptr().cast(), + piod_len: buffer.len(), + }; + + // SAFETY: `desc` describes a read of `buffer.len()` bytes into `buffer`, + // and `pid` is a process we are attached to. + let result = unsafe { + libc::ptrace( + libc::PT_IO, + pid, + &mut desc as *mut _ as *mut libc::c_char, + 0, + ) + }; + + if result == -1 { + return Err(io::Error::last_os_error()); + } + + Ok(desc.piod_len) +} diff --git a/src/freebsd/vm_permissions.rs b/src/freebsd/vm_permissions.rs new file mode 100644 index 00000000..d045ab89 --- /dev/null +++ b/src/freebsd/vm_permissions.rs @@ -0,0 +1,14 @@ +bitflags::bitflags! { + /// Memory protection and sharing permissions for a memory mapping. + #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] + pub struct VmPermissions: u32 { + /// Read permission + const READ = 0b0001; + /// Write permission + const WRITE = 0b0010; + /// Execute permission + const EXECUTE = 0b0100; + /// Private mapping (copy-on-write) + const PRIVATE = 0b1000; + } +} diff --git a/src/lib.rs b/src/lib.rs index 9e843c3a..7f1275f8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -26,6 +26,10 @@ cfg_if::cfg_if! { mod mac; pub use mac::*; + } else if #[cfg(target_os = "freebsd")] { + mod freebsd; + + pub use freebsd::*; } } diff --git a/src/process_reader.rs b/src/process_reader.rs index 706017df..9fc5161b 100644 --- a/src/process_reader.rs +++ b/src/process_reader.rs @@ -7,6 +7,9 @@ pub use crate::windows::process_reader::*; #[cfg(target_os = "macos")] pub use crate::mac::process_reader::*; +#[cfg(target_os = "freebsd")] +pub use crate::freebsd::process_reader::*; + use std::{ffi::CString, mem::MaybeUninit}; impl ProcessReader<'_> { From f5a3d7c91153d695ba30f816ae83c69f745b5b39 Mon Sep 17 00:00:00 2001 From: G36maid Date: Thu, 22 Oct 2026 04:21:00 +0800 Subject: [PATCH 2/8] freebsd: add deny target and README status entries --- README.md | 12 ++++++------ deny.toml | 1 + 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 95bd183c..629b5c41 100644 --- a/README.md +++ b/README.md @@ -119,9 +119,9 @@ fn write_minidump(crash_context: crash_context::CrashContext) { - ⭕️ Unimplemented, but could be implemented in the future - ❌ Unimplemented, and unlikely to ever be implemented -| Arch | unknown-linux-gnu | unknown-linux-musl | linux-android | pc-windows-msvc | apple-darwin | apple-ios | ------------ | ----------------- | ------------------ | ------------- | --------------- | ------------ | --------- | -`x86_64` | ✅ | ✅ | ⚠️ | ✅ | ✅ | ⭕️ | -`i686` | ✅ | ✅ | ❌ | ⭕️ | ❌ | ❌ | -`arm` | ⚠️ | ⚠️ | ⚠️ | ⭕️ | ❌ | ❌ | -`aarch64` | ⚠️ | ⚠️ | ⚠️ | ⭕️ | ✅ | ⭕️ | +| Arch | unknown-linux-gnu | unknown-linux-musl | linux-android | pc-windows-msvc | apple-darwin | apple-ios | unknown-freebsd | +----------- | ----------------- | ------------------ | ------------- | --------------- | ------------ | --------- | ---------------- | +`x86_64` | ✅ | ✅ | ⚠️ | ✅ | ✅ | ⭕️ | ✅ | +`i686` | ✅ | ✅ | ❌ | ⭕️ | ❌ | ❌ | ⭕️ | +`arm` | ⚠️ | ⚠️ | ⚠️ | ⭕️ | ❌ | ❌ | ❌ | +`aarch64` | ⚠️ | ⚠️ | ⚠️ | ⭕️ | ✅ | ⭕️ | ⭕️ | diff --git a/deny.toml b/deny.toml index 860fed11..5bf5a558 100644 --- a/deny.toml +++ b/deny.toml @@ -7,6 +7,7 @@ targets = [ "aarch64-apple-darwin", "aarch64-linux-android", "x86_64-linux-android", + "x86_64-unknown-freebsd", ] [advisories] From 6fd17511201f93bfe2d1d31d9cf34a563335521d Mon Sep 17 00:00:00 2001 From: G36maid Date: Thu, 22 Oct 2026 04:21:01 +0800 Subject: [PATCH 3/8] freebsd: add thread info and auxv via process inspector --- src/freebsd/app_memory.rs | 9 + src/freebsd/auxv.rs | 134 +++ src/freebsd/crash_context/mod.rs | 27 + src/freebsd/crash_context/x86_64.rs | 66 ++ src/freebsd/dumper_cpu_info/mod.rs | 56 ++ src/freebsd/dumper_cpu_info/x86_64.rs | 158 ++++ src/freebsd/maps_reader.rs | 210 +++++ .../minidump_writer/app_memory_stream.rs | 29 + src/freebsd/minidump_writer/errors.rs | 153 ++++ .../minidump_writer/exception_stream.rs | 56 ++ .../minidump_writer/handle_data_stream.rs | 105 +++ src/freebsd/minidump_writer/mappings.rs | 117 +++ .../memory_info_list_stream.rs | 73 ++ .../minidump_writer/memory_list_stream.rs | 32 + src/freebsd/minidump_writer/mod.rs | 812 ++++++++++++++++++ .../minidump_writer/systeminfo_stream.rs | 43 + .../minidump_writer/thread_list_stream.rs | 267 ++++++ .../minidump_writer/thread_names_stream.rs | 50 ++ src/freebsd/mod.rs | 9 + src/freebsd/module_reader.rs | 609 +++++++++++++ src/freebsd/thread_info/mod.rs | 30 + src/freebsd/thread_info/x86_64.rs | 78 ++ src/freebsd/thread_info/x86_64_regs.rs | 134 +++ 23 files changed, 3257 insertions(+) create mode 100644 src/freebsd/app_memory.rs create mode 100644 src/freebsd/auxv.rs create mode 100644 src/freebsd/crash_context/mod.rs create mode 100644 src/freebsd/crash_context/x86_64.rs create mode 100644 src/freebsd/dumper_cpu_info/mod.rs create mode 100644 src/freebsd/dumper_cpu_info/x86_64.rs create mode 100644 src/freebsd/maps_reader.rs create mode 100644 src/freebsd/minidump_writer/app_memory_stream.rs create mode 100644 src/freebsd/minidump_writer/errors.rs create mode 100644 src/freebsd/minidump_writer/exception_stream.rs create mode 100644 src/freebsd/minidump_writer/handle_data_stream.rs create mode 100644 src/freebsd/minidump_writer/mappings.rs create mode 100644 src/freebsd/minidump_writer/memory_info_list_stream.rs create mode 100644 src/freebsd/minidump_writer/memory_list_stream.rs create mode 100644 src/freebsd/minidump_writer/mod.rs create mode 100644 src/freebsd/minidump_writer/systeminfo_stream.rs create mode 100644 src/freebsd/minidump_writer/thread_list_stream.rs create mode 100644 src/freebsd/minidump_writer/thread_names_stream.rs create mode 100644 src/freebsd/module_reader.rs create mode 100644 src/freebsd/thread_info/mod.rs create mode 100644 src/freebsd/thread_info/x86_64.rs create mode 100644 src/freebsd/thread_info/x86_64_regs.rs diff --git a/src/freebsd/app_memory.rs b/src/freebsd/app_memory.rs new file mode 100644 index 00000000..80e77d5b --- /dev/null +++ b/src/freebsd/app_memory.rs @@ -0,0 +1,9 @@ +// These entries store a list of memory regions that the client wants included +// in the minidump. +#[derive(Debug, Default, PartialEq, Eq)] +pub struct AppMemory { + pub ptr: usize, + pub length: usize, +} + +pub type AppMemoryList = Vec; diff --git a/src/freebsd/auxv.rs b/src/freebsd/auxv.rs new file mode 100644 index 00000000..0be44d93 --- /dev/null +++ b/src/freebsd/auxv.rs @@ -0,0 +1,134 @@ +//! Auxiliary vector handling on FreeBSD. +//! +//! The auxv of the inspected process is obtained through the +//! [`ProcessInspector`](crate::freebsd::process_inspection::ProcessInspector) +//! (backed by the `KERN_PROC_AUXV` sysctl in the local implementation). + +use {crate::freebsd::process_inspection::ProcessInspector, thiserror::Error}; + +#[cfg(target_pointer_width = "32")] +pub type AuxvType = u32; +#[cfg(target_pointer_width = "64")] +pub type AuxvType = u64; + +mod consts { + use super::AuxvType; + pub const AT_PHDR: AuxvType = libc::AT_PHDR as AuxvType; + pub const AT_PHNUM: AuxvType = libc::AT_PHNUM as AuxvType; + pub const AT_ENTRY: AuxvType = libc::AT_ENTRY as AuxvType; + pub const AT_BASE: AuxvType = libc::AT_BASE as AuxvType; + pub const AT_NULL: AuxvType = libc::AT_NULL as AuxvType; + pub const AT_EXECPATH: AuxvType = libc::AT_EXECPATH as AuxvType; +} + +#[repr(C)] +#[derive(Clone, Debug, Default)] +pub struct DirectAuxvDumpInfo { + pub program_header_count: AuxvType, + pub program_header_address: AuxvType, + pub base_address: AuxvType, + pub entry_address: AuxvType, +} + +impl From for AuxvDumpInfo { + fn from(f: DirectAuxvDumpInfo) -> AuxvDumpInfo { + AuxvDumpInfo { + program_header_count: (f.program_header_count > 0).then_some(f.program_header_count), + program_header_address: (f.program_header_address > 0) + .then_some(f.program_header_address), + base_address: (f.base_address > 0).then_some(f.base_address), + entry_address: (f.entry_address > 0).then_some(f.entry_address), + exec_path_address: None, + } + } +} + +#[derive(Debug, Default)] +pub struct AuxvDumpInfo { + program_header_count: Option, + program_header_address: Option, + base_address: Option, + entry_address: Option, + /// Address of the executable's path string (FreeBSD-specific + /// `AT_EXECPATH`), which survives deletion of the underlying file. + exec_path_address: Option, +} + +// The parsing below assumes the 64-bit `Elf_AuxInfo` layout. +const _: () = assert!(std::mem::size_of::() == 8); + +impl AuxvDumpInfo { + /// Fills in missing entries by reading the auxv of the inspected process. + pub fn try_filling_missing_info( + &mut self, + process_inspector: &dyn ProcessInspector, + ) -> Result<(), AuxvError> { + if self.is_complete() { + return Ok(()); + } + + let auxv_data = process_inspector.get_auxv().map_err(AuxvError::ReadError)?; + + if auxv_data.is_empty() { + return Err(AuxvError::NoAuxvEntryFound); + } + + // Each `Elf_AuxInfo` entry is { int a_type; pad; u64 a_un; }. + let (pairs, _) = auxv_data.as_chunks::<16>(); + for pair in pairs { + let key = u32::from_ne_bytes(pair[..4].try_into().expect("fixed chunk")); + let value = AuxvType::from_ne_bytes(pair[8..].try_into().expect("fixed chunk")); + let key = AuxvType::from(key); + + if key == consts::AT_NULL { + break; + } + + let dest_field = match key { + consts::AT_PHNUM => &mut self.program_header_count, + consts::AT_PHDR => &mut self.program_header_address, + consts::AT_BASE => &mut self.base_address, + consts::AT_ENTRY => &mut self.entry_address, + consts::AT_EXECPATH => &mut self.exec_path_address, + _ => continue, + }; + + if dest_field.is_none() { + *dest_field = Some(value); + } + } + + Ok(()) + } + + pub fn get_program_header_count(&self) -> Option { + self.program_header_count + } + + pub fn get_program_header_address(&self) -> Option { + self.program_header_address + } + + pub fn get_entry_address(&self) -> Option { + self.entry_address + } + + /// Address of the NUL-terminated executable path string (`AT_EXECPATH`). + pub fn get_exec_path_address(&self) -> Option { + self.exec_path_address + } + + pub fn is_complete(&self) -> bool { + self.program_header_count.is_some() + && self.program_header_address.is_some() + && self.entry_address.is_some() + } +} + +#[derive(Debug, Error, serde::Serialize)] +pub enum AuxvError { + #[error("failed to read the auxv of the process")] + ReadError(#[source] crate::freebsd::process_inspection::Error), + #[error("no auxv entry found for the process")] + NoAuxvEntryFound, +} diff --git a/src/freebsd/crash_context/mod.rs b/src/freebsd/crash_context/mod.rs new file mode 100644 index 00000000..48b14ead --- /dev/null +++ b/src/freebsd/crash_context/mod.rs @@ -0,0 +1,27 @@ +//! Crash context for FreeBSD. +//! +//! Defines the signal and context structures directly with `libc` types. + +#[derive(Clone, Copy)] +pub struct CrashContext { + pub siginfo: libc::siginfo_t, + pub ucontext: libc::ucontext_t, + pub pid: i32, + pub tid: i32, +} + +impl std::fmt::Debug for CrashContext { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CrashContext") + .field("siginfo", &"") + .field("pid", &self.pid) + .field("tid", &self.tid) + .finish_non_exhaustive() + } +} + +cfg_if::cfg_if! { + if #[cfg(target_arch = "x86_64")] { + mod x86_64; + } +} diff --git a/src/freebsd/crash_context/x86_64.rs b/src/freebsd/crash_context/x86_64.rs new file mode 100644 index 00000000..2229b668 --- /dev/null +++ b/src/freebsd/crash_context/x86_64.rs @@ -0,0 +1,66 @@ +use { + super::CrashContext, + crate::{ + freebsd::thread_info::x86_64_regs::copy_float_save_to_context, minidump_cpu::RawContextCPU, + minidump_format::format, + }, +}; + +impl CrashContext { + pub fn get_instruction_pointer(&self) -> usize { + self.ucontext.uc_mcontext.mc_rip as usize + } + + pub fn get_stack_pointer(&self) -> usize { + self.ucontext.uc_mcontext.mc_rsp as usize + } + + pub fn fill_cpu_context(&self, out: &mut RawContextCPU) { + let mc = &self.ucontext.uc_mcontext; + + *out = RawContextCPU { + context_flags: format::ContextFlagsAmd64::CONTEXT_AMD64_FULL.bits() + | format::ContextFlagsAmd64::CONTEXT_AMD64_SEGMENTS.bits(), + cs: mc.mc_cs as u16, + fs: mc.mc_fs, + gs: mc.mc_gs, + es: mc.mc_es, + ds: mc.mc_ds, + ss: mc.mc_ss as u16, + eflags: mc.mc_rflags as u32, + rax: mc.mc_rax as u64, + rcx: mc.mc_rcx as u64, + rdx: mc.mc_rdx as u64, + rbx: mc.mc_rbx as u64, + rsp: mc.mc_rsp as u64, + rbp: mc.mc_rbp as u64, + rsi: mc.mc_rsi as u64, + rdi: mc.mc_rdi as u64, + r8: mc.mc_r8 as u64, + r9: mc.mc_r9 as u64, + r10: mc.mc_r10 as u64, + r11: mc.mc_r11 as u64, + r12: mc.mc_r12 as u64, + r13: mc.mc_r13 as u64, + r14: mc.mc_r14 as u64, + r15: mc.mc_r15 as u64, + rip: mc.mc_rip as u64, + ..Default::default() + }; + + // Even when the process uses XSAVE, the kernel reports + // `_MC_FPFMT_XMM` for signal contexts and `mc_fpstate` carries an + // FXSAVE-compatible image (extended state lives in `mc_xfpustate`). + if mc.mc_fpformat == libc::_MC_FPFMT_XMM { + // SAFETY: FreeBSD stores the signal-time XMM/FXSAVE payload in + // mc_fpstate when mc_fpformat identifies that format. + let bytes = unsafe { + std::slice::from_raw_parts( + mc.mc_fpstate.as_ptr() as *const u8, + std::mem::size_of_val(&mc.mc_fpstate), + ) + }; + copy_float_save_to_context(out, bytes); + } + } +} diff --git a/src/freebsd/dumper_cpu_info/mod.rs b/src/freebsd/dumper_cpu_info/mod.rs new file mode 100644 index 00000000..0ce22814 --- /dev/null +++ b/src/freebsd/dumper_cpu_info/mod.rs @@ -0,0 +1,56 @@ +use { + crate::{minidump_format::PlatformId, serializers::*}, + std::mem::MaybeUninit, +}; + +cfg_if::cfg_if! { + if #[cfg(target_arch = "x86_64")] { + pub mod x86_64; + pub use x86_64 as imp; + } +} + +pub use imp::write_cpu_information; + +#[derive(Debug, thiserror::Error, serde::Serialize)] +pub enum CpuInfoError { + #[error("Failed to read CPU information")] + ReadError( + #[source] + #[serde(serialize_with = "serialize_io_error")] + std::io::Error, + ), + #[error("Failed to parse CPU information")] + ParseError, +} + +pub fn os_information() -> (PlatformId, String) { + // The minidump format has no FreeBSD-specific platform id; use Linux as + // the generic Unix-like identifier, like other minidump tools do. + let platform_id = PlatformId::Linux; + + let mut uname_info = MaybeUninit::::zeroed(); + // SAFETY: uname fills the whole buffer with null-terminated strings on + // success, making assume_init valid. + let info = unsafe { + if libc::uname(uname_info.as_mut_ptr()) != 0 { + return (platform_id, "FreeBSD ".to_owned()); + } + uname_info.assume_init() + }; + + let os = c_char_array_to_str(&info.sysname).unwrap_or("FreeBSD"); + let release = c_char_array_to_str(&info.release).unwrap_or("unknown"); + let version = c_char_array_to_str(&info.version).unwrap_or("unknown"); + let machine = c_char_array_to_str(&info.machine).unwrap_or("unknown"); + + (platform_id, format!("{os} {release} {version} {machine}")) +} + +fn c_char_array_to_str(arr: &[libc::c_char]) -> Option<&str> { + let len = arr.iter().position(|&c| c == 0).unwrap_or(arr.len()); + // SAFETY: `c_char` and `u8` have the same layout; `from_utf8` validates + // the bytes afterwards. + let bytes = unsafe { std::slice::from_raw_parts(arr.as_ptr().cast::(), len) }; + std::str::from_utf8(bytes).ok() +} diff --git a/src/freebsd/dumper_cpu_info/x86_64.rs b/src/freebsd/dumper_cpu_info/x86_64.rs new file mode 100644 index 00000000..84b6aabf --- /dev/null +++ b/src/freebsd/dumper_cpu_info/x86_64.rs @@ -0,0 +1,158 @@ +use {super::CpuInfoError, crate::minidump_format::*, std::io}; + +type Result = std::result::Result; + +pub fn write_cpu_information(sys_info: &mut MDRawSystemInfo) -> Result<()> { + sys_info.processor_architecture = MDCPUArchitecture::PROCESSOR_ARCHITECTURE_AMD64 as u16; + + // `MDRawSystemInfo` only has a u8 slot; systems with more than 255 + // CPUs (HPC boxes) report the maximum. + sys_info.number_of_processors = u8::try_from(read_hw_ncpu()?).unwrap_or(u8::MAX); + + let cpu_model = read_hw_model()?; + let (vendor_id, family, model, stepping) = parse_cpu_model(&cpu_model); + + if !vendor_id.is_empty() { + // `cpu.data` only holds three u32s. + let vendor_id = + &vendor_id.as_bytes()[..vendor_id.len().min(3 * std::mem::size_of::())]; + sys_info.cpu.data[..vendor_id.len()].copy_from_slice(vendor_id); + } + + sys_info.processor_level = family as u16; + sys_info.processor_revision = ((model << 8) | stepping) as u16; + + Ok(()) +} + +/// `sysctl(hw.ncpu)` +fn read_hw_ncpu() -> Result { + let mib = [libc::CTL_HW, libc::HW_NCPU]; + let mut ncpu: i32 = 0; + let mut len = std::mem::size_of::() as libc::size_t; + + // SAFETY: the out-pointer and size describe a valid `i32` slot. + if unsafe { + libc::sysctl( + mib.as_ptr(), + mib.len() as libc::c_uint, + &mut ncpu as *mut i32 as *mut libc::c_void, + &mut len, + std::ptr::null(), + 0, + ) + } != 0 + { + return Err(CpuInfoError::ReadError(io::Error::last_os_error())); + } + + Ok(ncpu) +} + +/// `sysctl(hw.model)`, the human-readable CPU description. +fn read_hw_model() -> Result { + let mib = [libc::CTL_HW, libc::HW_MODEL]; + let mut len = 0; + + // SAFETY: the size-query call only writes `len`. + unsafe { + if libc::sysctl( + mib.as_ptr(), + mib.len() as libc::c_uint, + std::ptr::null_mut(), + &mut len, + std::ptr::null(), + 0, + ) != 0 + { + return Err(CpuInfoError::ReadError(io::Error::last_os_error())); + } + } + + if len == 0 { + return Ok(String::from("Unknown")); + } + + // hw.model is a NUL-terminated string. + let mut buffer = vec![0u8; len]; + // SAFETY: the buffer has the size reported by the kernel. + if unsafe { + libc::sysctl( + mib.as_ptr(), + mib.len() as libc::c_uint, + buffer.as_mut_ptr() as *mut libc::c_void, + &mut len, + std::ptr::null(), + 0, + ) + } != 0 + { + return Err(CpuInfoError::ReadError(io::Error::last_os_error())); + } + + let len = buffer.iter().position(|&b| b == 0).unwrap_or(len); + buffer.truncate(len); + + String::from_utf8(buffer).map_err(|_| CpuInfoError::ParseError) +} + +/// Maps the `hw.model` description to a CPUID vendor string, and reads +/// family/model/stepping from the CPUID instruction. +fn parse_cpu_model(model: &str) -> (&'static str, u32, u32, u32) { + const VENDORS: &[(&str, &str)] = &[ + ("intel", "GenuineIntel"), + ("amd", "AuthenticAMD"), + ("hygon", "HygonGenuine"), + ("centaur", "CentaurHauls"), + ("via", "CentaurHauls"), + ("zhaoxin", "Shanghai"), + ]; + + let model_lower = model.to_lowercase(); + let vendor = VENDORS + .iter() + .find_map(|(needle, vendor)| model_lower.contains(needle).then_some(*vendor)) + .unwrap_or(""); + + (vendor, family(), model_number(), stepping()) +} + +fn cpuid1_eax() -> u32 { + // CPUID leaf 1 only reads processor identification data. + // (`__cpuid` became a safe intrinsic in newer rustc versions; the + // unsafe block keeps this compiling on the MSRV.) + use std::arch::x86_64::__cpuid; + #[allow(unused_unsafe)] + unsafe { + __cpuid(1).eax + } +} + +fn stepping() -> u32 { + cpuid1_eax() & 0xF +} + +fn model_number() -> u32 { + let eax = cpuid1_eax(); + let model = (eax >> 4) & 0xF; + let family = (eax >> 8) & 0xF; + let ext_model = (eax >> 16) & 0xF; + + if family == 0xF || family == 0x6 { + (ext_model << 4) + model + } else { + model + } +} + +fn family() -> u32 { + let eax = cpuid1_eax(); + let family = (eax >> 8) & 0xF; + let ext_family = (eax >> 20) & 0xFF; + + if family == 0xF { + ext_family + family + } else { + family + } +} diff --git a/src/freebsd/maps_reader.rs b/src/freebsd/maps_reader.rs new file mode 100644 index 00000000..c6b33b4f --- /dev/null +++ b/src/freebsd/maps_reader.rs @@ -0,0 +1,210 @@ +//! Memory mapping enumeration on FreeBSD. +//! +//! The mapping list is obtained through the +//! [`ProcessInspector`](crate::freebsd::process_inspection::ProcessInspector) +//! (backed by `kinfo_getvmmap` in the local implementation). + +use { + super::{ + process_inspection::{self, ProcessInspector}, + vm_permissions::VmPermissions, + }, + std::{ffi::OsString, os::unix::ffi::OsStrExt}, +}; + +type Result = std::result::Result; + +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct SystemMappingInfo { + pub start_address: usize, + pub end_address: usize, +} + +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct MappingInfo { + pub start_address: usize, + pub size: usize, + pub system_mapping_info: SystemMappingInfo, + pub offset: usize, + pub permissions: VmPermissions, + pub name: Option, +} + +/// A user-supplied mapping, with an optional build id used for its CV record. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MappingEntry { + pub info: MappingInfo, + pub identifier: Option>, +} + +impl From for MappingEntry { + fn from(info: MappingInfo) -> Self { + Self { + info, + identifier: None, + } + } +} + +#[derive(Debug, thiserror::Error, serde::Serialize)] +pub enum MapsReaderError { + #[error("failed to read memory mappings")] + ReadError(#[source] process_inspection::Error), +} + +impl MappingInfo { + /// Enumerates the memory mappings of the inspected process. + /// + /// Adjacent mappings of the same file are merged into a single entry, + /// assuming they're a single library mapped by the dynamic linker. + pub fn from_inspector(process_inspector: &dyn ProcessInspector) -> Result> { + let entries = process_inspector + .get_vm_map() + .map_err(MapsReaderError::ReadError)?; + + let mut infos: Vec = Vec::new(); + + for entry in &entries { + let start_address = entry.start_address as usize; + let end_address = entry.end_address as usize; + let offset = entry.offset as usize; + let name = entry.file_path.clone(); + + // Merge adjacent mappings of the same file into a single entry, + // assuming they're a single library mapped by the dynamic linker. + if let Some(prev) = infos.last_mut() + && start_address == prev.end_address() + && name.is_some() + && name == prev.name + { + prev.system_mapping_info.end_address = end_address; + prev.size = end_address - prev.start_address; + prev.permissions |= entry.permissions; + continue; + } + // Also merge address ranges the linker reserved but a loaded + // library didn't use: these appear as an anonymous private + // mapping with no access flags directly following an executable + // file mapping. + if let Some(prev) = infos.last_mut() + && start_address == prev.end_address() + && prev.is_executable() + && prev.name_is_path() + && (offset == 0 || offset == prev.end_address()) + && entry.permissions == VmPermissions::PRIVATE + { + prev.system_mapping_info.end_address = end_address; + prev.size = end_address - prev.start_address; + continue; + } + // Unused reserved ranges can also appear inside a library: fold a + // single empty page sandwiched between two mappings of the same + // file into the surrounding module. + if infos.len() >= 2 + && let [.., prev_prev, prev] = &mut infos[..] + && prev_prev.name_is_path() + && prev_prev.end_address() == prev.start_address + && prev.is_empty_page() + && prev.end_address() == start_address + && name == prev_prev.name + { + prev_prev.system_mapping_info.end_address = end_address; + prev_prev.size = end_address - prev_prev.start_address; + prev_prev.permissions |= entry.permissions; + infos.pop(); + continue; + } + + infos.push(MappingInfo { + start_address, + size: end_address - start_address, + system_mapping_info: SystemMappingInfo { + start_address, + end_address, + }, + offset, + permissions: entry.permissions, + name, + }); + } + + Ok(infos) + } + + pub fn end_address(&self) -> usize { + self.start_address + self.size + } + + pub fn is_interesting(&self) -> bool { + self.name.is_some() && (self.offset == 0 || self.is_executable()) && self.size >= 4096 + } + + pub fn contains_address(&self, address: usize) -> bool { + self.system_mapping_info.start_address <= address + && address < self.system_mapping_info.end_address + } + + pub fn is_executable(&self) -> bool { + self.permissions.contains(VmPermissions::EXECUTE) + } + + /// An anonymous, unaccessible page, as reserved (but unused) by the + /// dynamic linker. + pub fn is_empty_page(&self) -> bool { + self.offset == 0 && self.permissions == VmPermissions::PRIVATE && self.name.is_none() + } + + pub fn name_is_path(&self) -> bool { + self.name + .as_ref() + .is_some_and(|n| n.as_bytes().contains(&b'/')) + } + + pub fn stack_has_pointer_to_mapping(&self, stack_copy: &[u8], sp_offset: usize) -> bool { + let word_size = std::mem::size_of::(); + if stack_copy.len() < word_size { + return false; + } + + let mut offset = (sp_offset + word_size - 1) & !(word_size - 1); + while offset + word_size <= stack_copy.len() { + let word = usize::from_ne_bytes( + stack_copy[offset..offset + word_size] + .try_into() + .expect("length checked above"), + ); + if self.contains_address(word) { + return true; + } + offset += word_size; + } + + false + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stack_has_pointer_to_mapping_respects_stack_offset() { + let mapping = MappingInfo { + start_address: 0x1000, + size: 0x1000, + system_mapping_info: SystemMappingInfo { + start_address: 0x1000, + end_address: 0x2000, + }, + ..Default::default() + }; + + let word_size = std::mem::size_of::(); + let mut stack = vec![0u8; word_size * 3]; + stack[word_size..word_size * 2].copy_from_slice(&0x1800usize.to_ne_bytes()); + stack[word_size * 2..word_size * 3].copy_from_slice(&0x3000usize.to_ne_bytes()); + + assert!(mapping.stack_has_pointer_to_mapping(&stack, 1)); + assert!(!mapping.stack_has_pointer_to_mapping(&stack, word_size * 2)); + } +} diff --git a/src/freebsd/minidump_writer/app_memory_stream.rs b/src/freebsd/minidump_writer/app_memory_stream.rs new file mode 100644 index 00000000..4570bd85 --- /dev/null +++ b/src/freebsd/minidump_writer/app_memory_stream.rs @@ -0,0 +1,29 @@ +use {super::*, crate::mem_writer::MemoryArrayWriter}; + +#[derive(Debug, thiserror::Error, serde::Serialize)] +pub enum SectionAppMemoryError { + #[error("Failed to copy memory from process")] + CopyFromProcessError(#[from] super::super::process_inspection::CopyFromProcessError), + #[error("Failed to write to memory")] + MemoryWriterError(#[from] crate::mem_writer::MemoryWriterError), +} + +impl MinidumpWriter { + pub fn write_app_memory(&mut self, buffer: &mut DumpBuf) -> Result<(), SectionAppMemoryError> { + for app_memory in &self.app_memory { + let data_copy = Self::copy_from_process( + self.process_inspector.as_ref(), + app_memory.ptr, + app_memory.length, + )?; + + let section = MemoryArrayWriter::write_bytes(buffer, &data_copy); + let desc = MDMemoryDescriptor { + start_of_memory_range: app_memory.ptr as u64, + memory: section.location(), + }; + self.memory_blocks.push(desc); + } + Ok(()) + } +} diff --git a/src/freebsd/minidump_writer/errors.rs b/src/freebsd/minidump_writer/errors.rs new file mode 100644 index 00000000..63960eb1 --- /dev/null +++ b/src/freebsd/minidump_writer/errors.rs @@ -0,0 +1,153 @@ +use { + super::super::{ + Pid, auxv::AuxvError, maps_reader::MapsReaderError, module_reader::ModuleReaderError, + }, + super::{ + app_memory_stream::SectionAppMemoryError, exception_stream::SectionExceptionStreamError, + handle_data_stream::SectionHandleDataStreamError, mappings::SectionMappingsError, + memory_info_list_stream::SectionMemInfoListError, memory_list_stream::SectionMemListError, + systeminfo_stream::SectionSystemInfoError, thread_list_stream::SectionThreadListError, + thread_names_stream::SectionThreadNamesError, + }, + crate::{ + dir_section::FileWriterError, mem_writer::MemoryWriterError, + serializers::serialize_generic_error, serializers::serialize_io_error, + }, + error_graph::ErrorList, + thiserror::Error, +}; + +#[derive(Debug, Error, serde::Serialize)] +pub enum WriterError { + #[error("Error during init phase")] + InitError(#[from] InitError), + #[error("Failed to write to memory")] + MemoryWriterError(#[from] MemoryWriterError), + #[error("Failed to write to file")] + FileWriterError(#[from] FileWriterError), + #[error("Failed to get current timestamp when writing header of minidump")] + SystemTimeError( + #[from] + #[serde(serialize_with = "serialize_generic_error")] + std::time::SystemTimeError, + ), + #[error("Failed to continue process")] + ContinueProcessError(#[from] ContinueProcessError), + #[error("Failed when writing section ThreadList")] + SectionThreadListError(#[from] SectionThreadListError), + #[error("Failed when writing section Mappings")] + SectionMappingsError(#[from] SectionMappingsError), + #[error("Failed when writing section MemList")] + SectionMemListError(#[from] SectionMemListError), + #[error("Failed when writing section ExceptionStream")] + SectionExceptionStreamError(#[from] SectionExceptionStreamError), + #[error("Failed when writing section AppMemory")] + SectionAppMemoryError(#[from] SectionAppMemoryError), + #[error("Failed when writing section SystemInfo")] + SectionSystemInfoError(#[from] SectionSystemInfoError), + #[error("Failed when writing section MemoryInfoList")] + SectionMemInfoListError(#[from] SectionMemInfoListError), + #[error("Failed when writing section ThreadNameList")] + SectionThreadNamesError(#[from] SectionThreadNamesError), + #[error("Failed when writing section HandleDataStream")] + SectionHandleDataStreamError(#[from] SectionHandleDataStreamError), + #[error("Errors occurred while initializing")] + InitErrors(#[source] ErrorList), + #[error("Errors occurred while resuming threads")] + ResumeThreadsErrors(#[source] ErrorList), + #[error("Errors occurred while writing the thread list")] + WriteThreadListErrors(#[source] ErrorList), + #[error("Errors occurred while writing system info")] + WriteSystemInfoErrors(#[source] ErrorList), + #[error("Failed writing handle data stream")] + WriteHandleDataStreamFailed(#[source] SectionHandleDataStreamError), + #[error("Failed to convert soft error list to JSON")] + ConvertToJsonFailed( + #[source] + #[serde(skip)] + serde_json::Error, + ), + #[error("Skipped thread {0} due to invalid stack pointer")] + DetachSkippedThread(Pid), + #[error("No mapping for stack pointer found")] + NoStackPointerMapping, + #[error("Mapping has no ELF base")] + NotAModuleMapping, + #[error("Could not read value from module")] + ModuleReaderError(#[from] ModuleReaderError), + #[error("Failed integer conversion")] + TryFromIntError( + #[from] + #[serde(skip)] + std::num::TryFromIntError, + ), + #[error("Failed slice conversion")] + TryFromSliceError( + #[from] + #[serde(skip)] + std::array::TryFromSliceError, + ), + #[error("Maps reader error")] + MapsReaderError(#[from] MapsReaderError), + #[error("failed to suspend thread")] + SuspendThreadFailed(#[source] super::super::process_inspection::Error), + #[error("failed to resume thread")] + ResumeThreadFailed(#[source] super::super::process_inspection::Error), +} + +#[derive(Debug, Error, serde::Serialize)] +pub enum InitError { + #[error("Failed to stop the target process")] + StopProcessFailed(#[from] StopProcessError), + #[error("IO error for file {0}")] + IOError( + String, + #[source] + #[serde(serialize_with = "serialize_io_error")] + std::io::Error, + ), + #[error("Ptrace does not function within the same process")] + CannotPtraceSameProcess, + #[error("failed to enumerate threads")] + ThreadEnumFailed(#[source] super::super::process_inspection::Error), + #[error("Failed filling missing Auxv info")] + FillMissingAuxvInfoFailed(#[source] AuxvError), + #[error("Failed to enumerate threads")] + EnumerateThreadsFailed(#[source] Box), + #[error("Failed to aggregate process mappings")] + AggregateMappingsFailed(#[source] MapsReaderError), + #[error("Failed to enumerate process mappings")] + EnumerateMappingsFailed(#[source] Box), + #[error("Errors occurred while suspending threads")] + SuspendThreadsErrors(#[source] ErrorList), + #[error("No threads left to suspend out of {0}")] + SuspendNoThreadsLeft(usize), + #[error("Crash thread does not reference principal mapping")] + PrincipalMappingNotReferenced, + #[error("Failed to read the page size")] + PageSizeError( + #[source] + #[serde(serialize_with = "serialize_io_error")] + std::io::Error, + ), +} + +#[derive(Debug, Error, serde::Serialize)] +pub enum StopProcessError { + #[error("Failed to stop the process")] + Stop( + #[source] + #[serde(serialize_with = "serialize_io_error")] + std::io::Error, + ), +} + +#[derive(Debug, Error, serde::Serialize)] +pub enum ContinueProcessError { + #[error("Failed to continue the process")] + Continue( + #[source] + #[serde(serialize_with = "serialize_io_error")] + std::io::Error, + ), +} diff --git a/src/freebsd/minidump_writer/exception_stream.rs b/src/freebsd/minidump_writer/exception_stream.rs new file mode 100644 index 00000000..dbca0fe6 --- /dev/null +++ b/src/freebsd/minidump_writer/exception_stream.rs @@ -0,0 +1,56 @@ +use {super::*, crate::mem_writer::MemoryWriter, minidump_common::errors::ExceptionCodeLinux}; + +#[derive(Debug, thiserror::Error, serde::Serialize)] +pub enum SectionExceptionStreamError { + #[error("Failed to write to memory")] + MemoryWriterError(#[from] crate::mem_writer::MemoryWriterError), +} + +impl MinidumpWriter { + pub fn write_exception_stream( + &mut self, + buffer: &mut DumpBuf, + ) -> Result { + let exception = if let Some(context) = &self.crash_context { + MDException { + exception_code: context.siginfo.si_signo as u32, + exception_flags: context.siginfo.si_code as u32, + exception_address: context.siginfo.si_addr as u64, + ..Default::default() + } + } else { + let addr = match &self.crashing_thread_context { + CrashingThreadContext::CrashContextPlusAddress((_, addr)) => *addr, + _ => 0, + }; + MDException { + exception_code: ExceptionCodeLinux::DUMP_REQUESTED as u32, + exception_address: addr as u64, + ..Default::default() + } + }; + + let thread_context = match &self.crashing_thread_context { + CrashingThreadContext::CrashContext(ctx) + | CrashingThreadContext::CrashContextPlusAddress((ctx, _)) => *ctx, + CrashingThreadContext::None => MDLocationDescriptor { + data_size: 0, + rva: 0, + }, + }; + + let stream = MDRawExceptionStream { + thread_id: self.blamed_thread as u32, + exception_record: exception, + __align: 0, + thread_context, + }; + let exc = MemoryWriter::alloc_with_val(buffer, stream)?; + let dirent = MDRawDirectory { + stream_type: MDStreamType::ExceptionStream as u32, + location: exc.location(), + }; + + Ok(dirent) + } +} diff --git a/src/freebsd/minidump_writer/handle_data_stream.rs b/src/freebsd/minidump_writer/handle_data_stream.rs new file mode 100644 index 00000000..fca30b8a --- /dev/null +++ b/src/freebsd/minidump_writer/handle_data_stream.rs @@ -0,0 +1,105 @@ +use { + super::*, + crate::{ + mem_writer::{ + MemoryArrayWriter, MemoryWriter, MemoryWriterError, write_string_to_location, + }, + serializers::serialize_io_error, + }, + std::{ + ffi::{CString, OsString}, + fs::{self, DirEntry}, + mem, + os::unix::prelude::OsStrExt, + path::{Path, PathBuf}, + }, +}; + +fn file_stat(path: &Path) -> Option { + let c_path = CString::new(path.as_os_str().as_bytes()).ok()?; + // SAFETY: libc::stat is POD with no invalid bit patterns, so zeroed() + // produces a valid default instance. + let mut stat = unsafe { mem::zeroed::() }; + // SAFETY: stat is a well-defined system call. We provide a valid + // null-terminated C string path and a valid stat buffer. + let result = unsafe { libc::stat(c_path.as_ptr(), &mut stat) }; + if result == 0 { Some(stat) } else { None } +} + +fn filename_to_fd(filename: &OsString) -> Option { + let filename = filename.to_string_lossy(); + filename.parse::().ok() +} + +fn direntry_to_descriptor(buffer: &mut DumpBuf, entry: &DirEntry) -> Option { + let handle = filename_to_fd(&entry.file_name())?; + let realpath = fs::read_link(entry.path()).ok()?; + let path_rva = write_string_to_location(buffer, realpath.to_string_lossy().as_ref()).ok()?; + let stat = file_stat(&entry.path())?; + + Some(MDRawHandleDescriptor { + handle, + type_name_rva: 0, + object_name_rva: path_rva.rva, + attributes: stat.st_mode as u32, + granted_access: 0, + handle_count: 0, + pointer_count: 0, + }) +} + +#[derive(Debug, thiserror::Error, serde::Serialize)] +pub enum SectionHandleDataStreamError { + #[error("Failed to access file")] + IOError( + #[from] + #[serde(serialize_with = "serialize_io_error")] + std::io::Error, + ), + #[error("Failed to write to memory")] + MemoryWriterError(#[from] MemoryWriterError), + #[error("Failed integer conversion")] + TryFromIntError( + #[from] + #[serde(skip)] + std::num::TryFromIntError, + ), +} + +impl MinidumpWriter { + pub fn write_handle_data_stream( + &mut self, + buffer: &mut DumpBuf, + ) -> Result { + let proc_fd_path = PathBuf::from(format!("/proc/{}/fd", self.process_id)); + + let proc_fd_iter = fs::read_dir(&proc_fd_path)?; + + let descriptors: Vec<_> = proc_fd_iter + .filter_map(|entry| entry.ok()) + .filter_map(|entry| direntry_to_descriptor(buffer, &entry)) + .collect(); + let number_of_descriptors = descriptors.len() as u32; + + let stream_header = MemoryWriter::::alloc_with_val( + buffer, + MDRawHandleDataStream { + size_of_header: mem::size_of::() as u32, + size_of_descriptor: mem::size_of::() as u32, + number_of_descriptors, + reserved: 0, + }, + )?; + + let mut dirent = MDRawDirectory { + stream_type: MDStreamType::HandleDataStream as u32, + location: stream_header.location(), + }; + + let descriptor_list = + MemoryArrayWriter::::alloc_from_iter(buffer, descriptors)?; + + dirent.location.data_size += descriptor_list.location().data_size; + Ok(dirent) + } +} diff --git a/src/freebsd/minidump_writer/mappings.rs b/src/freebsd/minidump_writer/mappings.rs new file mode 100644 index 00000000..b42f9f06 --- /dev/null +++ b/src/freebsd/minidump_writer/mappings.rs @@ -0,0 +1,117 @@ +use { + super::*, + crate::{ + freebsd::maps_reader::MappingInfo, + mem_writer::{MemoryArrayWriter, MemoryWriter, write_string_to_location}, + module_reader::{BuildId, ReadFromModule}, + }, +}; + +#[derive(Debug, thiserror::Error, serde::Serialize)] +pub enum SectionMappingsError { + #[error("Failed to write to memory")] + MemoryWriterError(#[from] crate::mem_writer::MemoryWriterError), +} + +impl MinidumpWriter { + pub fn write_mappings( + &self, + buffer: &mut DumpBuf, + ) -> Result { + let mut modules = Vec::new(); + + for (map_idx, mapping) in self.mappings.iter().enumerate() { + let contained_in_user_list = self.user_mapping_list.iter().any(|user| { + mapping.start_address >= user.info.start_address + && mapping.end_address() <= user.info.end_address() + }); + if !mapping.is_interesting() || contained_in_user_list { + continue; + } + + let BuildId(identifier) = self + .from_process_memory_for_index(map_idx) + .or_else(|e| { + if let Some(path) = &mapping.name + && std::path::Path::new(path).exists() + { + return BuildId::read_from_file(std::path::Path::new(path)) + .map_err(errors::WriterError::ModuleReaderError); + } + Err(e) + }) + .unwrap_or_else(|_| BuildId(Vec::new())); + + if identifier.is_empty() || identifier.iter().all(|&x| x == 0) { + continue; + } + + modules.push(fill_raw_module(buffer, mapping, &identifier)?); + } + + for user in &self.user_mapping_list { + let module = fill_raw_module( + buffer, + &user.info, + user.identifier.as_deref().unwrap_or(&[]), + )?; + modules.push(module); + } + + let list_header = MemoryWriter::::alloc_with_val(buffer, modules.len() as u32)?; + + let mut dirent = MDRawDirectory { + stream_type: MDStreamType::ModuleListStream as u32, + location: list_header.location(), + }; + + if !modules.is_empty() { + let mapping_list = MemoryArrayWriter::::alloc_from_iter(buffer, modules)?; + dirent.location.data_size += mapping_list.location().data_size; + } + + Ok(dirent) + } +} + +fn fill_raw_module( + buffer: &mut DumpBuf, + mapping: &MappingInfo, + identifier: &[u8], +) -> Result { + let cv_record = if identifier.is_empty() { + Default::default() + } else { + let cv_signature = crate::minidump_format::format::CvSignature::Elf as u32; + let array_size = std::mem::size_of_val(&cv_signature) + identifier.len(); + + let mut sig_section = MemoryArrayWriter::::alloc_array(buffer, array_size)?; + for (index, val) in cv_signature + .to_ne_bytes() + .iter() + .chain(identifier.iter()) + .enumerate() + { + sig_section.set_value_at(buffer, *val, index)?; + } + sig_section.location() + }; + + let name_header = if let Some(name) = &mapping.name { + write_string_to_location(buffer, &name.to_string_lossy())? + } else { + MDLocationDescriptor { + data_size: 0, + rva: 0, + } + }; + + Ok(MDRawModule { + base_of_image: mapping.start_address as u64, + // Truncation is safe: real-world images are far below u32::MAX. + size_of_image: mapping.size as u32, + cv_record, + module_name_rva: name_header.rva, + ..Default::default() + }) +} diff --git a/src/freebsd/minidump_writer/memory_info_list_stream.rs b/src/freebsd/minidump_writer/memory_info_list_stream.rs new file mode 100644 index 00000000..5bc67ae5 --- /dev/null +++ b/src/freebsd/minidump_writer/memory_info_list_stream.rs @@ -0,0 +1,73 @@ +use { + super::*, + crate::{freebsd::vm_permissions::VmPermissions, mem_writer::MemoryWriterError}, + minidump_common::format::{MemoryProtection, MemoryState, MemoryType}, +}; + +#[derive(Debug, thiserror::Error, serde::Serialize)] +pub enum SectionMemInfoListError { + #[error("Failed to write to memory")] + MemoryWriterError(#[from] MemoryWriterError), +} + +impl MinidumpWriter { + pub fn write_memory_info_list_stream( + &mut self, + buffer: &mut DumpBuf, + ) -> Result { + let list_header = MemoryWriter::alloc_with_val( + buffer, + MDMemoryInfoList { + size_of_header: std::mem::size_of::() as u32, + size_of_entry: std::mem::size_of::() as u32, + number_of_entries: self.mappings.len() as u64, + }, + )?; + + let mut dirent = MDRawDirectory { + stream_type: MDStreamType::MemoryInfoListStream as u32, + location: list_header.location(), + }; + + let block_list = MemoryArrayWriter::::alloc_from_iter( + buffer, + self.mappings.iter().map(|mm| { + let base = mm.start_address as u64; + let end = mm.end_address() as u64; + MDMemoryInfo { + base_address: base, + allocation_base: base, + allocation_protection: get_memory_protection(mm.permissions).bits(), + __alignment1: 0, + region_size: end - base, + state: MemoryState::MEM_COMMIT.bits(), + protection: get_memory_protection(mm.permissions).bits(), + _type: if mm.permissions.contains(VmPermissions::PRIVATE) { + MemoryType::MEM_PRIVATE + } else { + MemoryType::MEM_MAPPED + } + .bits(), + __alignment2: 0, + } + }), + )?; + + dirent.location.data_size += block_list.location().data_size; + Ok(dirent) + } +} + +fn get_memory_protection(permissions: VmPermissions) -> MemoryProtection { + let read = permissions.contains(VmPermissions::READ); + let write = permissions.contains(VmPermissions::WRITE); + let exec = permissions.contains(VmPermissions::EXECUTE); + match (read, write, exec) { + (false, false, false) => MemoryProtection::PAGE_NOACCESS, + (false, false, true) => MemoryProtection::PAGE_EXECUTE, + (true, false, false) => MemoryProtection::PAGE_READONLY, + (true, false, true) => MemoryProtection::PAGE_EXECUTE_READ, + (true | false, true, false) => MemoryProtection::PAGE_READWRITE, + (true | false, true, true) => MemoryProtection::PAGE_EXECUTE_READWRITE, + } +} diff --git a/src/freebsd/minidump_writer/memory_list_stream.rs b/src/freebsd/minidump_writer/memory_list_stream.rs new file mode 100644 index 00000000..1b5e26b9 --- /dev/null +++ b/src/freebsd/minidump_writer/memory_list_stream.rs @@ -0,0 +1,32 @@ +use { + super::*, + crate::mem_writer::{MemoryArrayWriter, MemoryWriter}, +}; + +#[derive(Debug, thiserror::Error, serde::Serialize)] +pub enum SectionMemListError { + #[error("Failed to write to memory")] + MemoryWriterError(#[from] crate::mem_writer::MemoryWriterError), +} + +impl MinidumpWriter { + pub fn write_memory_list_stream( + &mut self, + buffer: &mut DumpBuf, + ) -> Result { + let list_header = + MemoryWriter::::alloc_with_val(buffer, self.memory_blocks.len() as u32)?; + + let mut dirent = MDRawDirectory { + stream_type: MDStreamType::MemoryListStream as u32, + location: list_header.location(), + }; + + let block_list = + MemoryArrayWriter::::alloc_from_array(buffer, &self.memory_blocks)?; + + dirent.location.data_size += block_list.location().data_size; + + Ok(dirent) + } +} diff --git a/src/freebsd/minidump_writer/mod.rs b/src/freebsd/minidump_writer/mod.rs new file mode 100644 index 00000000..4cebd8c7 --- /dev/null +++ b/src/freebsd/minidump_writer/mod.rs @@ -0,0 +1,812 @@ +use { + super::{ + Pid, + auxv::AuxvDumpInfo, + crash_context::CrashContext, + maps_reader::{MappingEntry, MappingInfo}, + process_inspection::{self, ProcessInspector, process_reader::CopyFromProcessError}, + thread_info::{ThreadInfo, ThreadInfoError}, + vm_permissions::VmPermissions, + }, + crate::{ + dir_section::{DirSection, DumpBuf}, + mem_writer::{Buffer, MemoryArrayWriter, MemoryWriter}, + minidump_format::*, + }, + error_graph::{ErrorList, WriteErrorList}, + std::io::{Seek, Write}, +}; + +pub mod app_memory_stream; +pub mod errors; +pub mod exception_stream; +pub mod handle_data_stream; +pub mod mappings; +pub mod memory_info_list_stream; +pub mod memory_list_stream; +pub mod systeminfo_stream; +pub mod thread_list_stream; +pub mod thread_names_stream; + +pub use self::errors::{ContinueProcessError, InitError, StopProcessError, WriterError}; +pub use super::auxv::{AuxvType, DirectAuxvDumpInfo}; + +pub type AppMemoryList = Vec; + +#[derive(Debug)] +pub struct MinidumpWriterConfig { + process_id: Pid, + blamed_thread: Pid, + minidump_size_limit: Option, + skip_stacks_if_mapping_unreferenced: bool, + principal_mapping_address: Option, + user_mapping_list: Vec, + app_memory: AppMemoryList, + sanitize_stack: bool, + crash_context: Option, + direct_auxv_dump_info: Option, + process_inspector: Box, +} + +#[derive(Debug)] +pub struct MinidumpWriter { + pub process_id: Pid, + process_attached: bool, + threads_suspended: bool, + pub threads: Vec, + pub auxv: AuxvDumpInfo, + pub mappings: Vec, + pub page_size: usize, + pub sanitize_stack: bool, + pub minidump_size_limit: Option, + pub user_mapping_list: Vec, + pub crashing_thread_context: CrashingThreadContext, + pub skip_stacks_if_mapping_unreferenced: bool, + pub principal_mapping_address: Option, + pub principal_mapping: Option, + pub blamed_thread: Pid, + pub crash_context: Option, + pub app_memory: AppMemoryList, + pub memory_blocks: Vec, + pub process_inspector: Box, +} + +#[derive(Debug, Clone)] +pub struct Thread { + pub tid: Pid, + pub name: Option, +} + +#[derive(Debug, Default)] +pub enum CrashingThreadContext { + #[default] + None, + CrashContext(MDLocationDescriptor), + CrashContextPlusAddress((MDLocationDescriptor, usize)), +} + +impl MinidumpWriterConfig { + pub fn new(process_id: Pid, blamed_thread: Pid) -> Self { + Self { + process_id, + blamed_thread, + minidump_size_limit: Default::default(), + skip_stacks_if_mapping_unreferenced: Default::default(), + principal_mapping_address: Default::default(), + user_mapping_list: Default::default(), + app_memory: Default::default(), + sanitize_stack: Default::default(), + crash_context: Default::default(), + direct_auxv_dump_info: Default::default(), + process_inspector: process_inspection::local(process_id), + } + } + + pub fn set_minidump_size_limit(&mut self, limit: u64) -> &mut Self { + self.minidump_size_limit = Some(limit); + self + } + + pub fn set_crash_context(&mut self, crash_context: CrashContext) -> &mut Self { + self.crash_context = Some(crash_context); + self + } + + pub fn set_user_mapping_list(&mut self, list: Vec) -> &mut Self { + self.user_mapping_list = list; + self + } + + pub fn set_principal_mapping_address(&mut self, addr: usize) -> &mut Self { + self.principal_mapping_address = Some(addr); + self + } + + pub fn set_app_memory(&mut self, app_memory: AppMemoryList) -> &mut Self { + self.app_memory = app_memory; + self + } + + pub fn skip_stacks_if_mapping_unreferenced(&mut self) -> &mut Self { + self.skip_stacks_if_mapping_unreferenced = true; + self + } + + pub fn sanitize_stack(&mut self) -> &mut Self { + self.sanitize_stack = true; + self + } + + pub fn set_direct_auxv_dump_info(&mut self, info: DirectAuxvDumpInfo) -> &mut Self { + self.direct_auxv_dump_info = Some(info); + self + } + + pub fn write(self, destination: &mut (impl Write + Seek)) -> Result, WriterError> { + let mut soft_errors = ErrorList::default(); + let mut writer = self.build(); + writer.init(soft_errors.subwriter(WriterError::InitErrors))?; + let mut buffer = Buffer::with_capacity(0); + writer.write_dump(&mut buffer, destination, soft_errors)?; + Ok(buffer.into()) + } + + pub fn build_for_testing( + self, + soft_errors: impl WriteErrorList, + ) -> Result { + let mut writer = self.build(); + writer.init(soft_errors)?; + Ok(writer) + } + + fn build(self) -> MinidumpWriter { + let auxv = self + .direct_auxv_dump_info + .map(AuxvDumpInfo::from) + .unwrap_or_default(); + + MinidumpWriter { + process_id: self.process_id, + process_attached: Default::default(), + threads_suspended: Default::default(), + threads: Default::default(), + auxv, + mappings: Default::default(), + page_size: Default::default(), + sanitize_stack: self.sanitize_stack, + minidump_size_limit: self.minidump_size_limit, + user_mapping_list: self.user_mapping_list, + crashing_thread_context: Default::default(), + skip_stacks_if_mapping_unreferenced: self.skip_stacks_if_mapping_unreferenced, + principal_mapping_address: self.principal_mapping_address, + principal_mapping: Default::default(), + blamed_thread: self.blamed_thread, + crash_context: self.crash_context, + app_memory: self.app_memory, + memory_blocks: Default::default(), + process_inspector: self.process_inspector, + } + } +} + +impl MinidumpWriter { + fn init(&mut self, mut soft_errors: impl WriteErrorList) -> Result<(), InitError> { + if self.process_id == std::process::id() as i32 { + return Err(InitError::CannotPtraceSameProcess); + } + + if let Err(e) = self.attach_process() { + soft_errors.push(InitError::IOError( + format!("Failed to attach to PID {}", self.process_id), + e, + )); + // Fall back to a plain stop so non-ptrace data collection can + // still proceed where possible. + if let Err(e) = self.stop_process() { + soft_errors.push(InitError::StopProcessFailed(e)); + } + } + + if let Err(e) = self + .auxv + .try_filling_missing_info(self.process_inspector.as_ref()) + { + soft_errors.push(InitError::FillMissingAuxvInfoFailed(e)); + } + + if let Err(e) = self.enumerate_threads() { + soft_errors.push(InitError::EnumerateThreadsFailed(Box::new(e))); + } + + if let Err(e) = self.enumerate_mappings() { + soft_errors.push(InitError::EnumerateMappingsFailed(Box::new(e))); + } + + // SAFETY: sysconf is a well-defined POSIX function. + let page_size_raw = unsafe { libc::sysconf(libc::_SC_PAGESIZE) }; + if page_size_raw <= 0 { + return Err(InitError::PageSizeError(std::io::Error::other( + "sysconf(_SC_PAGESIZE) failed", + ))); + } + self.page_size = page_size_raw as usize; + + let threads_count = self.threads.len(); + self.suspend_threads(soft_errors.subwriter(InitError::SuspendThreadsErrors)); + + if self.threads.is_empty() { + soft_errors.push(InitError::SuspendNoThreadsLeft(threads_count)); + } + + if self.skip_stacks_if_mapping_unreferenced { + if let Some(address) = self.principal_mapping_address { + self.principal_mapping = self.find_mapping(address).cloned(); + } + if !self.crash_thread_references_principal_mapping() { + soft_errors.push(InitError::PrincipalMappingNotReferenced); + } + } + + Ok(()) + } + + fn enumerate_threads(&mut self) -> Result<(), InitError> { + let tids = self + .process_inspector + .get_thread_list() + .map_err(InitError::ThreadEnumFailed)?; + + // One sysctl for all names; a missing name is fine. + let names: std::collections::HashMap = self + .process_inspector + .get_thread_names() + .map(|names| names.into_iter().collect()) + .unwrap_or_default(); + + for tid in tids { + let name = names.get(&tid).cloned(); + self.threads.push(Thread { tid, name }); + } + + Ok(()) + } + + fn enumerate_mappings(&mut self) -> Result<(), InitError> { + self.mappings = MappingInfo::from_inspector(self.process_inspector.as_ref()) + .map_err(InitError::AggregateMappingsFailed)?; + + self.name_main_executable(); + + // Move the mapping containing the entry point to the front so it is + // recognized as the main module. + if let Some(entry) = self.auxv.get_entry_address() + && let Some(idx) = self + .mappings + .iter() + .position(|m| m.contains_address(entry as usize)) + { + self.mappings.swap(0, idx); + } + + Ok(()) + } + + /// Names the main executable's mapping. + /// + /// `kinfo_getvmmap` reports no path for mappings whose backing file has + /// been deleted. For the main executable the name can be recovered from + /// the `AT_EXECPATH` auxv entry, which points to a path string that still + /// lives in the process's memory. + fn name_main_executable(&mut self) { + let Some(phdr) = self.auxv.get_program_header_address() else { + return; + }; + let Some(idx) = self + .mappings + .iter() + .position(|m| m.contains_address(phdr as usize)) + else { + return; + }; + if self.mappings[idx].name.is_some() { + return; + } + let Some(path_addr) = self.auxv.get_exec_path_address() else { + return; + }; + + let reader = self.process_inspector.process_reader(); + if let Ok(path) = reader.copy_nul_terminated_string(path_addr as usize) { + self.mappings[idx].name = Some(std::ffi::OsString::from( + path.to_string_lossy().into_owned(), + )); + } + } + + /// Stops the process via the inspector (SIGSTOP + wait) so that we can + /// get a consistent state. + fn stop_process(&mut self) -> Result<(), StopProcessError> { + self.process_inspector + .stop_process() + .map_err(|e| StopProcessError::Stop(std::io::Error::other(e))) + } + + /// Resumes a process stopped with [`stop_process`](Self::stop_process). + fn continue_process(&mut self) -> Result<(), ContinueProcessError> { + self.process_inspector + .continue_process() + .map_err(|e| ContinueProcessError::Continue(std::io::Error::other(e))) + } + + /// Attaches to the process with `PT_ATTACH`, which also stops it. + /// + /// All further inspection (`PT_IO` reads, registers, thread enumeration) + /// requires an established trace relationship. + fn attach_process(&mut self) -> std::io::Result<()> { + self.process_inspector + .attach() + .map_err(std::io::Error::other)?; + self.process_attached = true; + Ok(()) + } + + /// Suspends a thread and returns an error if its register state is not + /// useful for a dump (registers unreadable, or a zeroed stack pointer); + /// the caller then drops it from the thread list. + fn suspend_thread( + process_inspector: &dyn ProcessInspector, + tid: Pid, + ) -> Result<(), WriterError> { + process_inspector + .suspend_thread(tid) + .map_err(WriterError::SuspendThreadFailed)?; + + #[cfg(target_arch = "x86_64")] + { + let skip_thread = match process_inspector.get_gen_regs(tid) { + Ok(regs) => regs.0.r_rsp == 0, + Err(_) => true, + }; + if skip_thread { + process_inspector + .resume_thread(tid) + .map_err(WriterError::ResumeThreadFailed)?; + return Err(WriterError::DetachSkippedThread(tid)); + } + } + + Ok(()) + } + + /// Resumes a suspended thread with `PT_RESUME`. + fn resume_thread( + process_inspector: &dyn ProcessInspector, + tid: Pid, + ) -> Result<(), WriterError> { + process_inspector + .resume_thread(tid) + .map_err(WriterError::ResumeThreadFailed) + } + + fn suspend_threads(&mut self, mut soft_errors: impl WriteErrorList) { + // Iterate over all threads and try to suspend them. If a thread + // disappeared before we could suspend it, or its register state is + // not useful, it is OK to silently drop it from the minidump. + self.threads.retain(|x| { + match Self::suspend_thread(self.process_inspector.as_ref(), x.tid) { + Ok(()) => true, + Err(e) => { + soft_errors.push(e); + false + } + } + }); + self.threads_suspended = true; + } + + fn resume_threads(&mut self, mut soft_errors: impl WriteErrorList) { + if self.threads_suspended { + for thread in &self.threads { + if let Err(e) = Self::resume_thread(self.process_inspector.as_ref(), thread.tid) { + soft_errors.push(e); + } + } + } + self.threads_suspended = false; + } + + /// Copies a block of bytes from the target process, returning the heap + /// allocated copy. + pub fn copy_from_process( + process_inspector: &dyn ProcessInspector, + src: usize, + length: usize, + ) -> Result, CopyFromProcessError> { + let length = std::num::NonZeroUsize::new(length).ok_or_else(|| CopyFromProcessError { + child: process_inspector.pid().unwrap_or_default(), + src, + length, + source: std::io::Error::other("length is zero"), + })?; + + let mem = process_inspector.process_reader(); + mem.read_to_vec(src, length) + } + + /// Copies up to `length` bytes from the target process, stopping at the + /// first page that can't be read (typically a guard page). Fails only if + /// nothing could be read at all. + fn read_until_guard( + process_inspector: &dyn ProcessInspector, + page_size: usize, + src: usize, + length: usize, + ) -> Result, CopyFromProcessError> { + let mem = process_inspector.process_reader(); + let mut output = vec![0u8; length]; + let mut offset = 0; + while offset < length { + let chunk = (length - offset).min(page_size - (src + offset) % page_size); + if let Err(e) = mem.read_all(src + offset, &mut output[offset..offset + chunk]) { + if offset == 0 { + return Err(e); + } + break; + } + offset += chunk; + } + + output.truncate(offset); + Ok(output) + } + + pub fn get_thread_info_by_index( + &self, + index: usize, + soft_errors: impl WriteErrorList, + ) -> Result { + if index >= self.threads.len() { + return Err(ThreadInfoError::IndexOutOfBounds(index, self.threads.len())); + } + ThreadInfo::create( + self.process_inspector.as_ref(), + self.threads[index].tid, + soft_errors, + ) + } + + pub fn find_mapping(&self, address: usize) -> Option<&MappingInfo> { + self.mappings + .iter() + .find(|map| address >= map.start_address && address - map.start_address < map.size) + } + + fn may_be_stack(mapping: Option<&MappingInfo>) -> bool { + mapping.is_some_and(|m| { + m.permissions + .intersects(VmPermissions::READ | VmPermissions::WRITE) + }) + } + + pub fn get_stack_info(&self, int_stack_pointer: usize) -> Result<(usize, usize), WriterError> { + // Round the stack pointer to the nearest page, this will cause us to + // capture data below the stack pointer which might still be relevant. + let mut stack_pointer = int_stack_pointer & !(self.page_size - 1); + let mut mapping = self.find_mapping(stack_pointer); + + // If the stack pointer doesn't land in a stack-looking mapping (e.g. + // it is inside a guard region on stack overflow), scan page by page + // upwards, but give up after 1 MiB. + let max_scan_addr = stack_pointer.saturating_add(1024 * 1024); + + while !Self::may_be_stack(mapping) && stack_pointer <= max_scan_addr { + stack_pointer += self.page_size; + mapping = self.find_mapping(stack_pointer); + } + + mapping + .map(|mapping| { + let valid_stack_pointer = if mapping.contains_address(stack_pointer) { + stack_pointer + } else { + mapping.start_address + }; + let stack_len = mapping.size - (valid_stack_pointer - mapping.start_address); + (valid_stack_pointer, stack_len) + }) + .ok_or(WriterError::NoStackPointerMapping) + } + + pub fn sanitize_stack_copy( + &self, + stack_copy: &mut [u8], + stack_pointer: usize, + sp_offset: usize, + ) -> Result<(), WriterError> { + // We optimize the search for containing mappings in three ways: + // 1) We expect that pointers into the stack mapping will be common, so + // we cache that address range. + // 2) The last referenced mapping is a reasonable predictor for the next + // referenced mapping, so we test that first. + // 3) We precompute a bitfield based upon bits 32:32-n of the start and + // stop addresses, and use that to short circuit any values that can + // not be pointers. (n=11) + let defaced; + #[cfg(target_pointer_width = "64")] + { + defaced = 0x0defaced0defacedusize.to_ne_bytes(); + } + #[cfg(target_pointer_width = "32")] + { + defaced = 0x0defacedusize.to_ne_bytes(); + }; + + let test_bits = 11; + let array_size: usize = 1 << (test_bits - 3); + let array_mask = array_size - 1; + let shift = 32 - test_bits; + let stack_mapping = self.find_mapping(stack_pointer); + let mut last_hit_mapping: Option<&MappingInfo> = None; + // The magnitude below which integers are considered to be 'small', and + // not constitute a PII risk. These are included to avoid eliding + // useful register values. + let small_int_magnitude: isize = 4096; + + let mut could_hit_mapping = vec![0u8; array_size]; + // Initialize the bitfield such that if the (pointer >> shift)'th bit, + // modulo the bitfield size, is not set then there does not exist a + // mapping in mappings that would contain that pointer. + for mapping in &self.mappings { + if !mapping.is_executable() { + continue; + } + let mut start = mapping.start_address; + let mut end = start + mapping.size; + start >>= shift; + end >>= shift; + for bit in start..=end { + could_hit_mapping[(bit >> 3) & array_mask] |= 1 << (bit & 7); + } + } + + // Zero memory that is below the current stack pointer. + let offset = + (sp_offset + std::mem::size_of::() - 1) & !(std::mem::size_of::() - 1); + // A truncated copy (guard page in the middle of the stack) can be + // shorter than the rounded offset; never index past the data. + let offset = offset.min(stack_copy.len()); + for x in &mut stack_copy[0..offset] { + *x = 0; + } + let (chunks, _remainder) = + stack_copy[offset..].as_chunks_mut::<{ std::mem::size_of::() }>(); + + // Apply sanitization over each complete pointer-aligned word in the + // stack. + for sp in chunks { + let word: [u8; std::mem::size_of::()] = + sp.as_slice().try_into().expect("complete chunk"); + let addr = usize::from_ne_bytes(word); + let addr_signed = isize::from_ne_bytes(word); + + if addr <= small_int_magnitude as usize && addr_signed >= -small_int_magnitude { + continue; + } + + if let Some(stack_map) = stack_mapping + && stack_map.contains_address(addr) + { + continue; + } + if let Some(last_hit) = last_hit_mapping + && last_hit.contains_address(addr) + { + continue; + } + + let test = addr >> shift; + if (could_hit_mapping[(test >> 3) & array_mask] & (1 << (test & 7)) != 0) + && let Some(hit_mapping) = self.find_mapping(addr) + && hit_mapping.is_executable() + { + last_hit_mapping = Some(hit_mapping); + continue; + } + + sp.copy_from_slice(&defaced); + } + + // Zero any partial word at the top of the stack, if alignment is such + // that that is required. + for sp in _remainder { + *sp = 0; + } + Ok(()) + } + + fn crash_thread_references_principal_mapping(&self) -> bool { + let principal_mapping = match self.principal_mapping.as_ref() { + Some(m) => m, + None => return false, + }; + + let crash_context = match self.crash_context.as_ref() { + Some(ctx) => ctx, + None => return true, + }; + + let instruction_pointer = crash_context.get_instruction_pointer(); + if principal_mapping.contains_address(instruction_pointer) { + return true; + } + + let stack_pointer = crash_context.get_stack_pointer(); + + if let Ok((valid_sp, stack_len)) = self.get_stack_info(stack_pointer) + && let Ok(stack_copy) = + Self::copy_from_process(self.process_inspector.as_ref(), valid_sp, stack_len) + { + let sp_offset = stack_pointer.saturating_sub(valid_sp); + return principal_mapping.stack_has_pointer_to_mapping(&stack_copy, sp_offset); + } + + false + } + + /// Reads a type implementing [`ReadFromModule`] from the process memory + /// backing the mapping at `idx`. + pub fn from_process_memory_for_index( + &self, + idx: usize, + ) -> Result { + assert!(idx < self.mappings.len()); + self.from_process_memory_for_mapping(&self.mappings[idx]) + } + + /// Returns the address where the ELF header of the file backing + /// `mapping` is mapped, or `None` if it isn't (e.g. an anonymous mapping). + /// + /// A file's first mapping has `offset == 0` and its start address is the + /// ELF base. Subsequent mappings of the same file (`offset != 0`) must + /// read the ELF header from that first mapping; if it is not visible + /// (e.g. unmapped), fall back to `start - offset`. + fn elf_base_for_mapping(&self, mapping: &MappingInfo) -> Option { + if mapping.offset == 0 { + return Some(mapping.start_address); + } + let name = mapping.name.as_ref()?; + Some( + self.mappings + .iter() + .find(|m| m.offset == 0 && m.name.as_ref() == Some(name)) + .map_or_else( + || mapping.start_address.saturating_sub(mapping.offset), + |base| base.start_address, + ), + ) + } + + /// Reads a type implementing [`ReadFromModule`] from the process memory + /// backing the given mapping. + pub fn from_process_memory_for_mapping( + &self, + mapping: &MappingInfo, + ) -> Result { + let elf_base = self + .elf_base_for_mapping(mapping) + .ok_or(WriterError::NotAModuleMapping)?; + let reader = self.process_inspector.process_reader(); + Ok(T::read_from_module( + super::module_reader::ModuleMemory::from_process(&reader, elf_base), + )?) + } + + fn write_dump( + &mut self, + buffer: &mut DumpBuf, + destination: &mut (impl Write + Seek), + mut soft_errors: ErrorList, + ) -> Result<(), WriterError> { + let num_writers = 9u32; + + let mut header_section = MemoryWriter::::alloc(buffer)?; + + let mut dir_section = DirSection::new(buffer, num_writers, destination)?; + + let header = MDRawHeader { + signature: MD_HEADER_SIGNATURE, + version: MD_HEADER_VERSION, + stream_count: num_writers, + stream_directory_rva: dir_section.position(), + checksum: 0, + time_date_stamp: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_secs() as u32, + flags: 0, + }; + header_section.set_value(buffer, header)?; + + dir_section.write_to_file(buffer, None)?; + + let dirent = self.write_thread_list_stream( + buffer, + soft_errors.subwriter(WriterError::WriteThreadListErrors), + )?; + dir_section.write_to_file(buffer, Some(dirent))?; + + let dirent = self.write_mappings(buffer)?; + dir_section.write_to_file(buffer, Some(dirent))?; + + self.write_app_memory(buffer)?; + dir_section.write_to_file(buffer, None)?; + + let dirent = self.write_memory_list_stream(buffer)?; + dir_section.write_to_file(buffer, Some(dirent))?; + + let dirent = self.write_exception_stream(buffer)?; + dir_section.write_to_file(buffer, Some(dirent))?; + + let dirent = systeminfo_stream::write_systeminfo_stream( + buffer, + soft_errors.subwriter(WriterError::WriteSystemInfoErrors), + )?; + dir_section.write_to_file(buffer, Some(dirent))?; + + let dirent = self.write_thread_names_stream(buffer)?; + dir_section.write_to_file(buffer, Some(dirent))?; + + let dirent = match self.write_handle_data_stream(buffer) { + Ok(dirent) => dirent, + Err(e) => { + soft_errors.push(WriterError::WriteHandleDataStreamFailed(e)); + Default::default() + } + }; + dir_section.write_to_file(buffer, Some(dirent))?; + + let dirent = self.write_memory_info_list_stream(buffer)?; + dir_section.write_to_file(buffer, Some(dirent))?; + + let dirent = write_soft_errors(buffer, &soft_errors)?; + dir_section.write_to_file(buffer, Some(dirent))?; + + Ok(()) + } +} + +fn write_soft_errors( + buffer: &mut DumpBuf, + soft_errors: &ErrorList, +) -> Result { + // A serialization failure must not fail the whole dump; emit an empty + // stream instead (the errors are also still visible via the log). + let soft_errors_json_str = match serde_json::to_string_pretty(soft_errors) { + Ok(json) => json, + Err(e) => { + log::error!("failed to serialize soft errors: {e}"); + return Ok(MDRawDirectory { + stream_type: MDStreamType::MozSoftErrors as u32, + location: Default::default(), + }); + } + }; + let section = MemoryArrayWriter::write_bytes(buffer, soft_errors_json_str.as_bytes()); + Ok(MDRawDirectory { + stream_type: MDStreamType::MozSoftErrors as u32, + location: section.location(), + }) +} + +impl Drop for MinidumpWriter { + fn drop(&mut self) { + // Always try to resume all threads (e.g. in case of error) + self.resume_threads(error_graph::strategy::DontCare); + // Always allow the process to run again. + if self.process_attached { + self.process_attached = false; + let _ = self.process_inspector.detach(); + } + let _ = self.continue_process(); + } +} diff --git a/src/freebsd/minidump_writer/systeminfo_stream.rs b/src/freebsd/minidump_writer/systeminfo_stream.rs new file mode 100644 index 00000000..c3083913 --- /dev/null +++ b/src/freebsd/minidump_writer/systeminfo_stream.rs @@ -0,0 +1,43 @@ +use { + super::*, + crate::{ + freebsd::dumper_cpu_info, + mem_writer::{MemoryWriterError, write_string_to_location}, + }, + error_graph::WriteErrorList, +}; + +#[derive(Debug, thiserror::Error, serde::Serialize)] +pub enum SectionSystemInfoError { + #[error("Failed to write to memory")] + MemoryWriterError(#[from] MemoryWriterError), + #[error("Failed trying to write CPU information")] + WriteCpuInformationFailed(#[source] dumper_cpu_info::CpuInfoError), +} + +pub fn write_systeminfo_stream( + buffer: &mut DumpBuf, + mut soft_errors: impl WriteErrorList, +) -> Result { + let mut info_section = MemoryWriter::::alloc(buffer)?; + let dirent = MDRawDirectory { + stream_type: MDStreamType::SystemInfoStream as u32, + location: info_section.location(), + }; + + let (platform_id, os_version) = dumper_cpu_info::os_information(); + let os_version_loc = write_string_to_location(buffer, &os_version)?; + + // SAFETY: MDRawSystemInfo is a POD struct with no invalid bit patterns. + // zeroed() produces a valid default instance where all fields are 0/false/empty. + let mut info = unsafe { std::mem::zeroed::() }; + info.platform_id = platform_id as u32; + info.csd_version_rva = os_version_loc.rva; + + if let Err(e) = dumper_cpu_info::write_cpu_information(&mut info) { + soft_errors.push(SectionSystemInfoError::WriteCpuInformationFailed(e)); + } + + info_section.set_value(buffer, info)?; + Ok(dirent) +} diff --git a/src/freebsd/minidump_writer/thread_list_stream.rs b/src/freebsd/minidump_writer/thread_list_stream.rs new file mode 100644 index 00000000..04ff5319 --- /dev/null +++ b/src/freebsd/minidump_writer/thread_list_stream.rs @@ -0,0 +1,267 @@ +use { + super::*, + crate::{ + freebsd::{process_inspection::CopyFromProcessError, thread_info::ThreadInfoError}, + mem_writer::{MemoryArrayWriter, MemoryWriter}, + minidump_cpu::RawContextCPU, + }, + error_graph::WriteErrorList, + std::cmp::min, +}; + +const LIMIT_AVERAGE_THREAD_STACK_LENGTH: usize = 8 * 1024; +const LIMIT_BASE_THREAD_COUNT: usize = 20; +const LIMIT_MAX_EXTRA_THREAD_STACK_LEN: usize = 2 * 1024; +const LIMIT_MINIDUMP_FUDGE_FACTOR: u64 = 64 * 1024; + +#[derive(Debug, thiserror::Error, serde::Serialize)] +pub enum SectionThreadListError { + #[error("Failed to get thread info")] + ThreadInfoError(#[from] ThreadInfoError), + #[error("Failed to copy from process")] + CopyFromProcessError(#[from] CopyFromProcessError), + #[error("Failed to write to memory")] + MemoryWriterError(#[from] crate::mem_writer::MemoryWriterError), + #[error("Failed to sanitize stack copy")] + SanitizeStackCopyFailed(#[source] Box), + #[error("Failed to get stack info")] + GetStackInfoFailed(#[source] Box), + #[error("Non-essential thread info errors")] + ThreadInfoSoftErrors(#[source] error_graph::ErrorList), +} + +impl MinidumpWriter { + pub fn write_thread_list_stream( + &mut self, + buffer: &mut DumpBuf, + mut soft_errors: impl WriteErrorList, + ) -> Result { + let num_threads = self.threads.len(); + + let list_header = MemoryWriter::::alloc_with_val(buffer, num_threads as u32)?; + + let mut dirent = MDRawDirectory { + stream_type: MDStreamType::ThreadListStream as u32, + location: list_header.location(), + }; + + let mut thread_list = MemoryArrayWriter::::alloc_array(buffer, num_threads)?; + dirent.location.data_size += thread_list.location().data_size; + + // When the size limit is in danger of being exceeded, cap the stack + // size of threads beyond the base count. + let mut extra_thread_stack_len = usize::MAX; + if let Some(minidump_size_limit) = self.minidump_size_limit { + let estimated_total_stack_size = + (num_threads * LIMIT_AVERAGE_THREAD_STACK_LENGTH) as u64; + let estimated_minidump_size = + buffer.position() + estimated_total_stack_size + LIMIT_MINIDUMP_FUDGE_FACTOR; + if estimated_minidump_size > minidump_size_limit { + extra_thread_stack_len = LIMIT_MAX_EXTRA_THREAD_STACK_LEN; + } + } + + for idx in 0..num_threads { + let tid = self.threads[idx].tid; + let mut thread = MDRawThread { + thread_id: tid as u32, + suspend_count: 0, + priority_class: 0, + priority: 0, + teb: 0, + stack: MDMemoryDescriptor::default(), + thread_context: MDLocationDescriptor::default(), + }; + + if let Some(crash_context) = self + .crash_context + .as_ref() + .filter(|_| tid == self.blamed_thread) + { + // We have different information for the crashing thread: its + // actual state would be somewhere inside the signal handler, + // which is deeply unhelpful. + let instruction_ptr = crash_context.get_instruction_pointer(); + let stack_pointer = crash_context.get_stack_pointer(); + + // Fill the register context first so the `crash_context` + // borrow ends before the `&mut self` calls below. + let mut cpu = RawContextCPU::default(); + crash_context.fill_cpu_context(&mut cpu); + + self.write_thread_stack( + buffer, + &mut thread, + instruction_ptr, + stack_pointer, + usize::MAX, + &mut soft_errors, + )?; + self.write_crashing_thread_ip_memory(buffer, instruction_ptr, &mut soft_errors)?; + + let cpu_section = MemoryWriter::alloc_with_val(buffer, cpu)?; + thread.thread_context = cpu_section.location(); + self.crashing_thread_context = + CrashingThreadContext::CrashContext(cpu_section.location()); + } else { + let info = self.get_thread_info_by_index( + idx, + soft_errors.subwriter(SectionThreadListError::ThreadInfoSoftErrors), + )?; + let instruction_ptr = info.get_instruction_pointer(); + let max_stack_len = if idx >= LIMIT_BASE_THREAD_COUNT { + extra_thread_stack_len + } else { + usize::MAX + }; + + self.write_thread_stack( + buffer, + &mut thread, + instruction_ptr, + info.get_stack_pointer(), + max_stack_len, + &mut soft_errors, + )?; + + let mut cpu = RawContextCPU::default(); + info.fill_cpu_context(&mut cpu); + let cpu_section = MemoryWriter::alloc_with_val(buffer, cpu)?; + thread.thread_context = cpu_section.location(); + + // Without a crash context the blamed thread's registers are + // the best guess for the crash location. + if tid == self.blamed_thread { + self.crashing_thread_context = CrashingThreadContext::CrashContextPlusAddress( + (cpu_section.location(), instruction_ptr), + ); + } + } + + thread_list.set_value_at(buffer, thread, idx)?; + } + + Ok(dirent) + } + + /// Copies 256 bytes around the crashing instruction pointer into the + /// dump: stack walkers rely on this to resolve the faulting instruction. + fn write_crashing_thread_ip_memory( + &mut self, + buffer: &mut DumpBuf, + instruction_ptr: usize, + soft_errors: &mut impl WriteErrorList, + ) -> Result<(), SectionThreadListError> { + let Some(mapping) = self.find_mapping(instruction_ptr) else { + return Ok(()); + }; + + // Try to get 128 bytes before and after the IP, bounded by the + // mapping it's contained within. + let start = mapping + .start_address + .max(instruction_ptr.saturating_sub(128)); + let end = (mapping.start_address + mapping.size).min(instruction_ptr + 128); + + let mut ip_memory = MDMemoryDescriptor { + start_of_memory_range: start as u64, + memory: Default::default(), + }; + ip_memory.memory.data_size = (end - start) as u32; + + let memory_copy = match Self::copy_from_process( + self.process_inspector.as_ref(), + start, + ip_memory.memory.data_size as usize, + ) { + Ok(copy) => copy, + Err(e) => { + soft_errors.push(SectionThreadListError::CopyFromProcessError(e)); + return Ok(()); + } + }; + let section = MemoryArrayWriter::alloc_from_array(buffer, &memory_copy)?; + ip_memory.memory = section.location(); + self.memory_blocks.push(ip_memory); + + Ok(()) + } + + /// Copies the stack of a thread into the dump, sanitizing it if enabled, + /// and fills in `thread.stack`. + /// + /// Failures to locate or copy the stack are soft: the stack is simply + /// omitted from the dump and recorded in the soft-error list. + fn write_thread_stack( + &mut self, + buffer: &mut DumpBuf, + thread: &mut MDRawThread, + instruction_ptr: usize, + stack_pointer: usize, + max_stack_len: usize, + soft_errors: &mut impl WriteErrorList, + ) -> Result<(), SectionThreadListError> { + let stack_info = self.get_stack_info(stack_pointer); + let (valid_sp, stack_len) = match stack_info { + Ok(x) => x, + Err(e) => { + soft_errors.push(SectionThreadListError::GetStackInfoFailed(Box::new(e))); + return Ok(()); + } + }; + let stack_len = min(stack_len, max_stack_len); + + let mut stack_copy = match Self::read_until_guard( + self.process_inspector.as_ref(), + self.page_size, + valid_sp, + stack_len, + ) { + Ok(copy) => copy, + Err(e) => { + soft_errors.push(SectionThreadListError::CopyFromProcessError(e)); + return Ok(()); + } + }; + + let sp_offset = stack_pointer.saturating_sub(valid_sp); + if self.should_skip_stack_for_principal(instruction_ptr, &stack_copy, sp_offset) { + return Ok(()); + } + + if self.sanitize_stack { + self.sanitize_stack_copy(&mut stack_copy, stack_pointer, sp_offset) + .map_err(|e| SectionThreadListError::SanitizeStackCopyFailed(Box::new(e)))?; + } + + thread.stack = MDMemoryDescriptor { + start_of_memory_range: valid_sp as u64, + memory: MDLocationDescriptor { + data_size: stack_copy.len() as u32, + rva: buffer.position() as u32, + }, + }; + buffer.write_all(&stack_copy); + self.memory_blocks.push(thread.stack); + + Ok(()) + } + + fn should_skip_stack_for_principal( + &self, + instruction_ptr: usize, + stack_copy: &[u8], + sp_offset: usize, + ) -> bool { + if !self.skip_stacks_if_mapping_unreferenced { + return false; + } + + let Some(principal_mapping) = &self.principal_mapping else { + return true; + }; + + !principal_mapping.contains_address(instruction_ptr) + && !principal_mapping.stack_has_pointer_to_mapping(stack_copy, sp_offset) + } +} diff --git a/src/freebsd/minidump_writer/thread_names_stream.rs b/src/freebsd/minidump_writer/thread_names_stream.rs new file mode 100644 index 00000000..f4e79325 --- /dev/null +++ b/src/freebsd/minidump_writer/thread_names_stream.rs @@ -0,0 +1,50 @@ +use { + super::*, + crate::mem_writer::{MemoryWriterError, write_string_to_location}, +}; + +#[derive(Debug, thiserror::Error, serde::Serialize)] +pub enum SectionThreadNamesError { + #[error("Failed integer conversion")] + TryFromIntError( + #[from] + #[serde(skip)] + std::num::TryFromIntError, + ), + #[error("Failed to write to memory")] + MemoryWriterError(#[from] MemoryWriterError), +} + +impl MinidumpWriter { + pub fn write_thread_names_stream( + &self, + buffer: &mut DumpBuf, + ) -> Result { + let num_threads = self.threads.iter().filter(|t| t.name.is_some()).count(); + + let list_header = MemoryWriter::::alloc_with_val(buffer, num_threads as u32)?; + + let mut dirent = MDRawDirectory { + stream_type: MDStreamType::ThreadNamesStream as u32, + location: list_header.location(), + }; + + let mut thread_list = + MemoryArrayWriter::::alloc_array(buffer, num_threads)?; + dirent.location.data_size += thread_list.location().data_size; + + let mut idx = 0; + for item in &self.threads { + if let Some(name) = &item.name { + let pos = write_string_to_location(buffer, name)?; + let thread = MDRawThreadName { + thread_id: item.tid as u32, + thread_name_rva: pos.rva.into(), + }; + thread_list.set_value_at(buffer, thread, idx)?; + idx += 1; + } + } + Ok(dirent) + } +} diff --git a/src/freebsd/mod.rs b/src/freebsd/mod.rs index 79783048..b3c4b3f2 100644 --- a/src/freebsd/mod.rs +++ b/src/freebsd/mod.rs @@ -7,12 +7,21 @@ //! //! [`ProcessInspector`]: process_inspection::ProcessInspector +// `WriterError` is large and clippy doesn't like that, but not a huge deal atm +#![allow(clippy::result_large_err)] + pub mod process_inspection; +pub mod app_memory; +pub mod auxv; +pub mod crash_context; +pub mod dumper_cpu_info; +pub mod thread_info; pub mod vm_permissions; mod ptrace_io; +pub use crate::freebsd::crash_context::CrashContext; pub use process_inspection::{local, process_reader}; pub type Pid = i32; diff --git a/src/freebsd/module_reader.rs b/src/freebsd/module_reader.rs new file mode 100644 index 00000000..baf85385 --- /dev/null +++ b/src/freebsd/module_reader.rs @@ -0,0 +1,609 @@ +//! ELF module reading on FreeBSD (build id / SONAME extraction). + +use { + crate::{ + minidump_format::GUID, + module_reader::{ModuleMemoryReadError, ProcessModuleMemoryReader, ReadError}, + process_reader::ProcessReader, + serializers::*, + }, + goblin::{ + container::{Container, Ctx, Endian}, + elf, + }, + std::{borrow::Cow, ffi::CStr}, +}; + +type Error = ModuleReaderError; + +const NOTE_SECTION_NAME: &[u8] = b".note.gnu.build-id\0"; + +#[derive(Debug, thiserror::Error, serde::Serialize)] +pub enum ModuleReaderError { + #[error("failed to read module file ({path}): {error}")] + MapFile { + path: std::path::PathBuf, + #[source] + #[serde(serialize_with = "serialize_io_error")] + error: std::io::Error, + }, + #[error(transparent)] + ReadModuleMemory(#[from] ModuleMemoryReadError), + #[error("failed to parse ELF memory: {0}")] + Parsing( + #[from] + #[serde(serialize_with = "serialize_goblin_error")] + goblin::error::Error, + ), + #[error("no build id notes in program headers")] + NoProgramHeaderNote, + #[error("no string table available to locate note sections")] + NoStrTab, + #[error("no build id note sections")] + NoSectionNote, + #[error("the ELF data contains no program headers")] + NoProgramHeaders, + #[error("the ELF data contains no sections")] + NoSections, + #[error("the ELF data does not have a .text section from which to generate a build id")] + NoTextSection, + #[error( + "failed to calculate build id\n\ + ... from program headers: {program_headers}\n\ + ... from sections: {section}\n\ + ... from the text section: {generated}" + )] + NoBuildId { + program_headers: Box, + section: Box, + generated: Box, + }, + #[error("no dynamic string table section")] + NoDynStrSection, + #[error("a string in the strtab did not have a terminating nul byte")] + StrTabNoNulByte, + #[error("no SONAME found in dynamic linking information")] + NoSoNameEntry, + #[error("not safe to open mapping {}: device nodes are never opened", .0.to_string_lossy())] + NotSafeToOpenMapping(std::ffi::OsString), + #[error("no dynamic linking information section")] + NoDynamicSection, + #[error( + "failed to retrieve soname\n\ + ... from program headers: {program_headers}\n\ + ... from sections: {section}" + )] + NoSoName { + program_headers: Box, + section: Box, + }, +} + +/// Serialize [goblin::error::Error] +pub(crate) fn serialize_goblin_error( + error: &goblin::error::Error, + serializer: S, +) -> Result { + serialize_generic_error(error, serializer) +} + +/// A view of a module's memory, either file-backed or process-backed. +pub enum ModuleMemory<'a> { + /// The contents of a file (e.g. an `mmap` of the module on disk). + Slice(&'a [u8]), + /// A range of the inspected process's address space. + Process { + reader: ProcessModuleMemoryReader<'a>, + start_address: u64, + }, +} + +impl<'buf> From<&'buf [u8]> for ModuleMemory<'buf> { + fn from(value: &'buf [u8]) -> Self { + Self::Slice(value) + } +} + +impl<'a> ModuleMemory<'a> { + pub fn from_process(reader: &'a ProcessReader<'a>, start_address: usize) -> Self { + Self::Process { + reader: ProcessModuleMemoryReader::new(reader, start_address), + start_address: start_address as u64, + } + } + + #[inline] + pub fn read(&self, offset: u64, length: u64) -> Result, ModuleMemoryReadError> { + let (start_address, inner) = match self { + Self::Slice(data) => { + let inner = (|| { + let start = usize::try_from(offset).map_err(|_| ReadError::OutOfBounds)?; + let end = usize::try_from(length) + .ok() + .and_then(|len| start.checked_add(len)) + .filter(|end| *end <= data.len()) + .ok_or(ReadError::OutOfBounds)?; + data.get(start..end) + .map(Cow::Borrowed) + .ok_or(ReadError::OutOfBounds) + })(); + (None, inner) + } + Self::Process { + reader, + start_address, + } => ( + Some(*start_address), + reader.read(offset, length).map_err(|e| e.error), + ), + }; + + inner.map_err(|error| ModuleMemoryReadError { + start_address, + offset, + length, + error, + }) + } + + pub fn absolute_to_relative(&self, addr: u64) -> Option { + match self { + Self::Slice(_) => Some(addr), + Self::Process { start_address, .. } => addr.checked_sub(*start_address), + } + } + + pub fn relative_to_absolute(&self, addr: u64) -> Option { + match self { + Self::Slice(_) => Some(addr), + Self::Process { start_address, .. } => start_address.checked_add(addr), + } + } + + pub fn is_process_memory(&self) -> bool { + matches!(self, Self::Process { .. }) + } +} + +impl ModuleMemory<'_> { + pub fn read_from_module(self) -> Result { + T::read_from_module(self) + } +} + +#[inline] +fn is_executable_section(header: &elf::SectionHeader) -> bool { + header.sh_type == elf::section_header::SHT_PROGBITS + && header.sh_flags & u64::from(elf::section_header::SHF_ALLOC) != 0 + && header.sh_flags & u64::from(elf::section_header::SHF_EXECINSTR) != 0 +} + +/// Return bytes to use as a build id, computed by hashing the given data. +/// +/// This provides `size_of::` bytes to keep identifiers produced by this function compatible +/// with other build ids. +fn build_id_from_bytes(data: &[u8]) -> Vec { + data.chunks(std::mem::size_of::()).fold( + vec![0u8; std::mem::size_of::()], + |mut bytes, chunk| { + bytes + .iter_mut() + .zip(chunk.iter()) + .for_each(|(b, c)| *b ^= *c); + bytes + }, + ) +} + +// `name` should be null-terminated +fn section_header_with_name<'sc>( + section_headers: &'sc elf::SectionHeaders, + strtab_index: usize, + name: &[u8], + module_memory: &ModuleMemory<'_>, +) -> Result, Error> { + let strtab_section_header = section_headers + .get(strtab_index) + .and_then(|hdr| (hdr.sh_type == elf::section_header::SHT_STRTAB).then_some(hdr)) + .ok_or(Error::NoStrTab)?; + + for header in section_headers { + let sh_name = header.sh_name as u64; + if sh_name >= strtab_section_header.sh_size { + log::warn!("invalid sh_name offset for {name:?}"); + continue; + } + if sh_name + name.len() as u64 >= strtab_section_header.sh_size { + continue; + } + let n = module_memory.read(strtab_section_header.sh_offset + sh_name, name.len() as u64)?; + if name == &*n { + return Ok(Some(header)); + } + } + Ok(None) +} + +/// Types which can be read from ModuleMemory. +pub trait ReadFromModule: Sized { + fn read_from_module(module_memory: ModuleMemory<'_>) -> Result; + + fn read_from_file(path: &std::path::Path) -> Result { + // Never open device nodes through a name learned from the target + // process: their semantics are undefined and may hang the dumper. + if path.starts_with("/dev/") { + return Err(Error::NotSafeToOpenMapping(path.as_os_str().to_os_string())); + } + + let map = std::fs::File::open(path) + .and_then(|file| { + // SAFETY: mmap creates a read-only mapping of the file. The kernel + // validates the parameters and the file descriptor is valid. + unsafe { memmap2::Mmap::map(&file) } + }) + .map_err(|error| Error::MapFile { + path: path.to_owned(), + error, + })?; + Self::read_from_module(ModuleMemory::Slice(&map)) + } +} + +/// The module build id. +pub struct BuildId(pub Vec); + +impl ReadFromModule for BuildId { + fn read_from_module(module_memory: ModuleMemory<'_>) -> Result { + let reader = ModuleReader::new(module_memory)?; + let program_headers = match reader.build_id_from_program_headers() { + Ok(v) => return Ok(BuildId(v)), + Err(e) => Box::new(e), + }; + let section = match reader.build_id_from_section() { + Ok(v) => return Ok(BuildId(v)), + Err(e) => Box::new(e), + }; + let generated = match reader.build_id_generate_from_text() { + Ok(v) => return Ok(BuildId(v)), + Err(e) => Box::new(e), + }; + Err(Error::NoBuildId { + program_headers, + section, + generated, + }) + } +} + +struct DynIter<'a> { + data: &'a [u8], + offset: usize, + ctx: Ctx, +} + +impl<'a> DynIter<'a> { + pub fn new(data: &'a [u8], ctx: Ctx) -> Self { + DynIter { + data, + offset: 0, + ctx, + } + } +} + +impl Iterator for DynIter<'_> { + type Item = Result; + + fn next(&mut self) -> Option { + use scroll::Pread; + let dyn_: elf::dynamic::Dyn = match self.data.gread_with(&mut self.offset, self.ctx) { + Ok(v) => v, + Err(e) => return Some(Err(e.into())), + }; + if dyn_.d_tag == elf::dynamic::DT_NULL { + None + } else { + Some(Ok(dyn_)) + } + } +} + +/// The module SONAME. +#[derive(Default, Clone, Debug)] +pub struct SoName(pub String); + +impl ReadFromModule for SoName { + fn read_from_module(module_memory: ModuleMemory<'_>) -> Result { + let reader = ModuleReader::new(module_memory)?; + let program_headers = match reader.soname_from_program_headers() { + Ok(v) => return Ok(SoName(v)), + Err(e) => Box::new(e), + }; + let section = match reader.soname_from_sections() { + Ok(v) => return Ok(SoName(v)), + Err(e) => Box::new(e), + }; + Err(Error::NoSoName { + program_headers, + section, + }) + } +} + +pub struct ModuleReader<'buf> { + module_memory: ModuleMemory<'buf>, + header: elf::Header, + context: Ctx, +} + +impl<'buf> ModuleReader<'buf> { + pub fn new(module_memory: ModuleMemory<'buf>) -> Result { + let header_size = elf::Header::size(Ctx::new(Container::Big, Endian::default())); + let header_data = module_memory.read(0, header_size as u64)?; + let header = elf::Elf::parse_header(&header_data)?; + let context = Ctx::new(header.container()?, header.endianness()?); + + Ok(Self { + module_memory, + header, + context, + }) + } + + /// Read the SONAME using program headers to locate dynamic library information. + pub fn soname_from_program_headers(&self) -> Result { + let program_headers = self.read_program_headers()?; + + let dynamic_segment_header = program_headers + .iter() + .find(|h| h.p_type == elf::program_header::PT_DYNAMIC) + .ok_or(Error::NoDynamicSection)?; + + let dynamic_section = self.read_segment(dynamic_segment_header)?; + + let mut soname_strtab_offset = None; + let mut strtab_addr = None; + let mut strtab_size = None; + for dyn_ in DynIter::new(&dynamic_section, self.context) { + let dyn_ = dyn_?; + match dyn_.d_tag { + elf::dynamic::DT_SONAME => soname_strtab_offset = Some(dyn_.d_val), + elf::dynamic::DT_STRTAB => strtab_addr = Some(dyn_.d_val), + elf::dynamic::DT_STRSZ => strtab_size = Some(dyn_.d_val), + _ => (), + } + } + + match (strtab_addr, strtab_size, soname_strtab_offset) { + (None, _, _) | (_, None, _) => Err(Error::NoDynStrSection), + (_, _, None) => Err(Error::NoSoNameEntry), + (Some(addr), Some(size), Some(offset)) => { + if offset < size { + self.read_name_from_strtab( + self.module_memory + .absolute_to_relative(addr) + .unwrap_or(addr), + size, + offset, + ) + } else { + log::warn!("soname strtab offset ({offset}) exceeds strtab size ({size})"); + Err(Error::NoSoNameEntry) + } + } + } + } + + /// Read the SONAME using section headers to locate dynamic library information. + pub fn soname_from_sections(&self) -> Result { + let section_headers = self.read_section_headers()?; + + let dynamic_section_header = section_headers + .iter() + .find(|h| h.sh_type == elf::section_header::SHT_DYNAMIC) + .ok_or(Error::NoDynamicSection)?; + + let dynstr_section_header = + match section_headers.get(dynamic_section_header.sh_link as usize) { + Some(header) if header.sh_type == elf::section_header::SHT_STRTAB => header, + _ => section_header_with_name( + §ion_headers, + self.header.e_shstrndx as usize, + b".dynstr\0", + &self.module_memory, + )? + .ok_or(Error::NoDynStrSection)?, + }; + + let dynamic_section = self.module_memory.read( + self.section_offset(dynamic_section_header), + dynamic_section_header.sh_size, + )?; + + for dyn_ in DynIter::new(&dynamic_section, self.context) { + let dyn_ = dyn_?; + if dyn_.d_tag == elf::dynamic::DT_SONAME { + let name_offset = dyn_.d_val; + if name_offset < dynstr_section_header.sh_size { + return self.read_name_from_strtab( + self.section_offset(dynstr_section_header), + dynstr_section_header.sh_size, + name_offset, + ); + } else { + log::warn!( + "soname offset ({name_offset}) exceeds dynstr section size ({})", + dynstr_section_header.sh_size + ); + } + } + } + + Err(Error::NoSoNameEntry) + } + + /// Read the build id from a program header note. + pub fn build_id_from_program_headers(&self) -> Result, Error> { + let program_headers = self.read_program_headers()?; + for header in program_headers { + if header.p_type != elf::program_header::PT_NOTE { + continue; + } + // `ModuleMemory::read` offsets are relative to the module start, + // so the file offset is the right coordinate in both modes (the + // note segment lives in the first `PT_LOAD`, where file offset + // and relative virtual address coincide). + if let Ok(Some(result)) = + self.find_build_id_note(header.p_offset, header.p_filesz, header.p_align) + { + return Ok(result); + } + } + Err(Error::NoProgramHeaderNote) + } + + /// Read the build id from a notes section. + pub fn build_id_from_section(&self) -> Result, Error> { + let section_headers = self.read_section_headers()?; + + let header = section_header_with_name( + §ion_headers, + self.header.e_shstrndx as usize, + NOTE_SECTION_NAME, + &self.module_memory, + )? + .ok_or(Error::NoSectionNote)?; + + match self.find_build_id_note(header.sh_offset, header.sh_size, header.sh_addralign) { + Ok(Some(v)) => Ok(v), + Ok(None) => Err(Error::NoSectionNote), + Err(e) => Err(e), + } + } + + /// Generate a build id by hashing the first page of the text section. + pub fn build_id_generate_from_text(&self) -> Result, Error> { + let Some(text_header) = self + .read_section_headers()? + .into_iter() + .find(is_executable_section) + else { + return Err(Error::NoTextSection); + }; + + let len = std::cmp::min(4096, text_header.sh_size); + let text_data = self.module_memory.read(text_header.sh_offset, len)?; + Ok(build_id_from_bytes(&text_data)) + } + + fn read_segment(&self, header: &elf::ProgramHeader) -> Result, Error> { + let (offset, size) = if self.module_memory.is_process_memory() { + (header.p_vaddr, header.p_memsz) + } else { + (header.p_offset, header.p_filesz) + }; + + self.module_memory.read(offset, size).map_err(|e| e.into()) + } + + fn read_name_from_strtab( + &self, + strtab_offset: u64, + strtab_size: u64, + name_offset: u64, + ) -> Result { + assert!(name_offset < strtab_size); + let name = self + .module_memory + .read(strtab_offset + name_offset, strtab_size - name_offset)?; + CStr::from_bytes_until_nul(&name) + .map(|s| s.to_string_lossy().into_owned()) + .map_err(|_| Error::StrTabNoNulByte) + } + + fn section_offset(&self, header: &elf::SectionHeader) -> u64 { + if self.module_memory.is_process_memory() { + header.sh_addr + } else { + header.sh_offset + } + } + + fn read_program_headers(&self) -> Result { + if self.header.e_phoff == 0 { + return Err(Error::NoProgramHeaders); + } + let program_headers_data = self.module_memory.read( + self.header.e_phoff, + self.header.e_phentsize as u64 * self.header.e_phnum as u64, + )?; + let program_headers = elf::ProgramHeader::parse( + &program_headers_data, + 0, + self.header.e_phnum as usize, + self.context, + )?; + Ok(program_headers) + } + + fn read_section_headers(&self) -> Result { + if self.header.e_shoff == 0 { + return Err(Error::NoSections); + } + + let section_headers_data = self.module_memory.read( + self.header.e_shoff, + self.header.e_shentsize as u64 * self.header.e_shnum as u64, + )?; + let section_headers = elf::SectionHeader::parse_from( + §ion_headers_data, + 0, + self.header.e_shnum as usize, + self.context, + )?; + Ok(section_headers) + } + + fn find_build_id_note( + &self, + offset: u64, + size: u64, + alignment: u64, + ) -> Result>, Error> { + self.find_note( + offset, + size, + alignment, + elf::note::NT_GNU_BUILD_ID, + 0, + "GNU", + ) + } + + fn find_note( + &self, + offset: u64, + size: u64, + alignment: u64, + note_type: u32, + note_min_size: usize, + note_name: &str, + ) -> Result>, Error> { + let notes = self.module_memory.read(offset, size)?; + for note in (elf::note::NoteDataIterator { + data: ¬es, + size: size as usize, + offset: 0, + ctx: (alignment as usize, self.context), + }) { + let Ok(note) = note else { break }; + if note.name == note_name + && note.n_type == note_type + && note.desc.len() >= note_min_size + { + return Ok(Some(note.desc.to_owned())); + } + } + Ok(None) + } +} diff --git a/src/freebsd/thread_info/mod.rs b/src/freebsd/thread_info/mod.rs new file mode 100644 index 00000000..90f89179 --- /dev/null +++ b/src/freebsd/thread_info/mod.rs @@ -0,0 +1,30 @@ +//! Thread enumeration and per-thread state on FreeBSD. +//! +//! All process access goes through the +//! [`ProcessInspector`](crate::freebsd::process_inspection::ProcessInspector); +//! this module never issues `ptrace`/`sysctl` requests itself. + +use super::process_inspection; + +type Result = std::result::Result; + +#[derive(thiserror::Error, Debug, serde::Serialize)] +pub enum ThreadInfoError { + #[error("Index out of bounds: Got {0}, only have {1}")] + IndexOutOfBounds(usize, usize), + #[error("thread enumeration failed")] + ThreadEnumFailed(#[source] process_inspection::Error), + #[error("failed to get general-purpose registers")] + GetGenRegsFailed(#[source] process_inspection::Error), + #[error("failed to get floating-point registers")] + GetFpRegsFailed(#[source] process_inspection::Error), +} + +cfg_if::cfg_if! { + if #[cfg(target_arch = "x86_64")] { + mod x86_64; + pub mod x86_64_regs; + + pub type ThreadInfo = x86_64::ThreadInfoX86; + } +} diff --git a/src/freebsd/thread_info/x86_64.rs b/src/freebsd/thread_info/x86_64.rs new file mode 100644 index 00000000..1a21d97e --- /dev/null +++ b/src/freebsd/thread_info/x86_64.rs @@ -0,0 +1,78 @@ +//! x86_64 thread state. + +use { + super::{ + Result, ThreadInfoError, + x86_64_regs::{Fpregs, Reg, apply_fpregs_to_context, reg_to_minidump_context}, + }, + crate::{ + freebsd::{ + Pid, + process_inspection::{FpRegs, GenRegs, ProcessInspector}, + }, + minidump_cpu::RawContextCPU, + }, + error_graph::WriteErrorList, +}; + +pub struct ThreadInfoX86 { + pub tid: Pid, + pub stack_pointer: usize, + pub registers: GenRegs, + pub fpregs: Option, +} + +impl ThreadInfoX86 { + /// Gathers the state of thread `tid` of the inspected process. + /// + /// A failure to read the floating-point registers is only recorded as a + /// soft error (the general-purpose registers are required, the + /// floating-point ones are not). + pub fn create( + process_inspector: &dyn ProcessInspector, + tid: Pid, + mut soft_errors: impl WriteErrorList, + ) -> Result { + let registers = process_inspector + .get_gen_regs(tid) + .map_err(ThreadInfoError::GetGenRegsFailed)?; + + let fpregs = match process_inspector + .get_fp_regs(tid) + .map_err(ThreadInfoError::GetFpRegsFailed) + { + Ok(regs) => Some(regs), + Err(e) => { + soft_errors.push(e); + None + } + }; + + let stack_pointer = registers.0.r_rsp as usize; + + Ok(Self { + tid, + stack_pointer, + registers, + fpregs, + }) + } + + pub fn get_stack_pointer(&self) -> usize { + self.stack_pointer + } + + pub fn get_instruction_pointer(&self) -> usize { + self.registers.0.r_rip as usize + } + + /// Fills `out` with the thread's register state in minidump format. + pub fn fill_cpu_context(&self, out: &mut RawContextCPU) { + let reg: &Reg = &self.registers.0; + *out = reg_to_minidump_context(reg); + if let Some(fpregs) = &self.fpregs { + let fpregs: &Fpregs = &fpregs.0; + apply_fpregs_to_context(out, fpregs); + } + } +} diff --git a/src/freebsd/thread_info/x86_64_regs.rs b/src/freebsd/thread_info/x86_64_regs.rs new file mode 100644 index 00000000..2a72e85c --- /dev/null +++ b/src/freebsd/thread_info/x86_64_regs.rs @@ -0,0 +1,134 @@ +use crate::{minidump_cpu::RawContextCPU, minidump_format::format}; + +pub type Reg = libc::reg; +pub type Fpregs = libc::fpreg; + +pub fn reg_to_minidump_context(reg: &Reg) -> RawContextCPU { + RawContextCPU { + context_flags: format::ContextFlagsAmd64::CONTEXT_AMD64_FULL.bits() + | format::ContextFlagsAmd64::CONTEXT_AMD64_SEGMENTS.bits(), + rax: reg.r_rax as u64, + rcx: reg.r_rcx as u64, + rdx: reg.r_rdx as u64, + rbx: reg.r_rbx as u64, + rsp: reg.r_rsp as u64, + rbp: reg.r_rbp as u64, + rsi: reg.r_rsi as u64, + rdi: reg.r_rdi as u64, + r8: reg.r_r8 as u64, + r9: reg.r_r9 as u64, + r10: reg.r_r10 as u64, + r11: reg.r_r11 as u64, + r12: reg.r_r12 as u64, + r13: reg.r_r13 as u64, + r14: reg.r_r14 as u64, + r15: reg.r_r15 as u64, + rip: reg.r_rip as u64, + eflags: reg.r_rflags as u32, + cs: reg.r_cs as u16, + ds: reg.r_ds, + es: reg.r_es, + fs: reg.r_fs, + gs: reg.r_gs, + ss: reg.r_ss as u16, + ..Default::default() + } +} + +pub fn apply_fpregs_to_context(context: &mut RawContextCPU, fpregs: &Fpregs) { + // SAFETY: `libc::fpreg` is FreeBSD's PT_GETFPREGS ABI payload. It is a + // plain C register-save area, and copying its initialized bytes preserves + // the kernel-provided FXSAVE-compatible data verbatim. + let bytes = unsafe { + std::slice::from_raw_parts( + fpregs as *const Fpregs as *const u8, + std::mem::size_of::(), + ) + }; + copy_float_save_to_context(context, bytes); +} + +/// Copies a raw FXSAVE image into the context's `float_save` area. +/// +/// The AMD64 minidump `float_save` is Windows' `XMM_SAVE_AREA32`; the copy +/// is verbatim, so offsets 8–23 (FIP/FDP) and the trailing reserved area +/// don't strictly match the Windows layout. The MXCSR, x87 and XMM data +/// (offset 24 onwards) — what stack walkers consume — is accurate. +pub fn copy_float_save_to_context(context: &mut RawContextCPU, bytes: &[u8]) { + let len = std::cmp::min(context.float_save.len(), bytes.len()); + context.float_save[..len].copy_from_slice(&bytes[..len]); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_reg_abi_size() { + assert_eq!(std::mem::size_of::(), 176); + } + + #[test] + fn test_fpregs_abi_size() { + assert_eq!(std::mem::size_of::(), 512); + } + + #[test] + fn test_reg_conversion() { + let reg = Reg { + r_r15: 1, + r_r14: 2, + r_r13: 3, + r_r12: 4, + r_r11: 5, + r_r10: 6, + r_r9: 7, + r_r8: 8, + r_rdi: 9, + r_rsi: 10, + r_rbp: 11, + r_rbx: 12, + r_rdx: 13, + r_rcx: 14, + r_rax: 15, + r_trapno: 16, + r_fs: 17, + r_gs: 18, + r_err: 19, + r_es: 20, + r_ds: 21, + r_rip: 22, + r_cs: 23, + r_rflags: 24, + r_rsp: 25, + r_ss: 26, + }; + + let context = reg_to_minidump_context(®); + + assert_eq!(context.r15, 1); + assert_eq!(context.r14, 2); + assert_eq!(context.r13, 3); + assert_eq!(context.r12, 4); + assert_eq!(context.r11, 5); + assert_eq!(context.r10, 6); + assert_eq!(context.r9, 7); + assert_eq!(context.r8, 8); + assert_eq!(context.rdi, 9); + assert_eq!(context.rsi, 10); + assert_eq!(context.rbp, 11); + assert_eq!(context.rbx, 12); + assert_eq!(context.rdx, 13); + assert_eq!(context.rcx, 14); + assert_eq!(context.rax, 15); + assert_eq!(context.fs, 17); + assert_eq!(context.gs, 18); + assert_eq!(context.es, 20); + assert_eq!(context.ds, 21); + assert_eq!(context.rip, 22); + assert_eq!(context.cs, 23); + assert_eq!(context.eflags, 24); + assert_eq!(context.rsp, 25); + assert_eq!(context.ss, 26); + } +} From 2412d231b4030d8396be92a91f424ac250e85d2d Mon Sep 17 00:00:00 2001 From: G36maid Date: Thu, 22 Oct 2026 04:21:10 +0800 Subject: [PATCH 4/8] freebsd: add maps and module readers --- src/freebsd/mod.rs | 2 ++ src/module_reader.rs | 3 +++ 2 files changed, 5 insertions(+) diff --git a/src/freebsd/mod.rs b/src/freebsd/mod.rs index b3c4b3f2..6ba0e860 100644 --- a/src/freebsd/mod.rs +++ b/src/freebsd/mod.rs @@ -16,6 +16,8 @@ pub mod app_memory; pub mod auxv; pub mod crash_context; pub mod dumper_cpu_info; +pub mod maps_reader; +pub mod module_reader; pub mod thread_info; pub mod vm_permissions; diff --git a/src/module_reader.rs b/src/module_reader.rs index 0215b623..9797eb78 100644 --- a/src/module_reader.rs +++ b/src/module_reader.rs @@ -13,6 +13,9 @@ pub use crate::windows::module_reader::*; #[cfg(target_os = "macos")] pub use crate::mac::module_reader::*; +#[cfg(target_os = "freebsd")] +pub use crate::freebsd::module_reader::*; + pub struct ProcessModuleMemoryReader<'a> { pub(super) reader: &'a ProcessReader<'a>, pub(super) start_address: u64, From e9158ffeabba6cfec1ab9f26cdab9bbc3e3c15f4 Mon Sep 17 00:00:00 2001 From: G36maid Date: Thu, 22 Oct 2026 04:21:56 +0800 Subject: [PATCH 5/8] freebsd: add minidump writer streams --- src/freebsd/mod.rs | 1 + src/mem_writer.rs | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/freebsd/mod.rs b/src/freebsd/mod.rs index 6ba0e860..41b28d57 100644 --- a/src/freebsd/mod.rs +++ b/src/freebsd/mod.rs @@ -17,6 +17,7 @@ pub mod auxv; pub mod crash_context; pub mod dumper_cpu_info; pub mod maps_reader; +pub mod minidump_writer; pub mod module_reader; pub mod thread_info; pub mod vm_permissions; diff --git a/src/mem_writer.rs b/src/mem_writer.rs index ae38c79b..4b1eed82 100644 --- a/src/mem_writer.rs +++ b/src/mem_writer.rs @@ -163,7 +163,7 @@ pub struct MemoryArrayWriter { phantom: std::marker::PhantomData, } -#[cfg(any(target_os = "linux", target_os = "android"))] +#[cfg(any(target_os = "linux", target_os = "android", target_os = "freebsd"))] impl MemoryArrayWriter { #[inline] pub fn write_bytes(buffer: &mut Buffer, slice: &[u8]) -> Self { From 41b7f24c8f4dae32fd55c18c958d11fa92f583a7 Mon Sep 17 00:00:00 2001 From: G36maid Date: Thu, 22 Oct 2026 04:22:04 +0800 Subject: [PATCH 6/8] ci: test FreeBSD 14.4 and 15.0 --- .cargo/config.toml | 5 +++++ .github/workflows/ci.yml | 29 +++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/.cargo/config.toml b/.cargo/config.toml index a5bdfea5..879788e0 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -20,3 +20,8 @@ linker = "aarch64-linux-android30-clang" # By default the linker _doesn't_ generate a build-id, however we want one for our tests. rustflags = ["-C", "link-args=-Wl,--build-id=sha1"] runner = [".cargo/android-runner.sh"] + +# The FreeBSD linker doesn't generate a build-id by default; the tests rely +# on one being present. +[target.x86_64-unknown-freebsd] +rustflags = ["-C", "link-args=-Wl,--build-id=sha1"] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4c1757a9..29dc667a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -150,6 +150,35 @@ jobs: cargo test --target x86_64-linux-android cargo clippy --all-features --all-targets --target x86_64-linux-android -- -D warnings + test-freebsd: + name: Test (FreeBSD ${{ matrix.release }} x86_64) + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + include: + - release: "14.4" + - release: "15.0" + steps: + - uses: actions/checkout@v4 + - name: Test on FreeBSD + uses: vmactions/freebsd-vm@v1 + with: + release: ${{ matrix.release }} + usesh: true + prepare: | + fetch -qo /tmp/rustup-init https://static.rust-lang.org/rustup/dist/x86_64-unknown-freebsd/rustup-init + chmod +x /tmp/rustup-init + /tmp/rustup-init -y --profile minimal -c clippy -c rustfmt --default-toolchain 1.90.0 + run: | + . "$HOME/.cargo/env" + sysctl security.bsd.unprivileged_proc_debug=1 + cargo fmt --all -- --check + cargo test --lib + cargo test --test freebsd_minidump_writer + cargo test --test freebsd_module_reader + cargo clippy --all-features --all-targets -- -D warnings + deny-check: runs-on: ubuntu-24.04 steps: From 92dfaf030fe5bab211d6a1a3a5ea44bf394d59ff Mon Sep 17 00:00:00 2001 From: G36maid Date: Thu, 22 Oct 2026 04:22:05 +0800 Subject: [PATCH 7/8] freebsd: add integration tests --- src/bin/test.rs | 171 +++++++ tests/freebsd_minidump_writer.rs | 804 +++++++++++++++++++++++++++++++ tests/freebsd_module_reader.rs | 61 +++ 3 files changed, 1036 insertions(+) create mode 100644 tests/freebsd_minidump_writer.rs create mode 100644 tests/freebsd_module_reader.rs diff --git a/src/bin/test.rs b/src/bin/test.rs index 92128c54..b7506fcb 100644 --- a/src/bin/test.rs +++ b/src/bin/test.rs @@ -556,6 +556,174 @@ mod linux { } } +#[cfg(target_os = "freebsd")] +mod freebsd { + use super::*; + + /// Signals readiness on stdout and parks forever. + fn wait_forever() -> ! { + println!("1"); + loop { + std::thread::park(); + } + } + + fn spawn_and_wait(num: usize) -> Result<()> { + // One less than the requested amount, as the main thread counts as well + for _ in 1..num { + std::thread::spawn(|| { + println!("1"); + loop { + std::thread::park(); + } + }); + } + println!("1"); + loop { + std::thread::park(); + } + } + + fn spawn_name_wait(num: usize) -> Result<()> { + for id in 1..num { + std::thread::Builder::new() + .name(format!("thread_{id}")) + .spawn(move || { + let cname = std::ffi::CString::new(format!("thread_{id}")) + .expect("thread name contained null"); + // SAFETY: pthread_set_name_np takes a valid pthread_t from + // pthread_self() and a valid null-terminated C string from + // CString which lives for the duration of the call. + unsafe { + libc::pthread_set_name_np(libc::pthread_self(), cname.as_ptr()); + } + wait_forever(); + })?; + } + println!("1"); + loop { + std::thread::park(); + } + } + + fn spawn_mmap_wait() -> Result<()> { + let page_size = unsafe { libc::sysconf(libc::_SC_PAGESIZE) as usize }; + let mapped_mem = unsafe { + libc::mmap( + std::ptr::null_mut(), + page_size, + libc::PROT_READ | libc::PROT_WRITE, + libc::MAP_PRIVATE | libc::MAP_ANON, + -1, + 0, + ) + }; + if mapped_mem == libc::MAP_FAILED { + return Err("mmap failed".into()); + } + + println!("{} {}", mapped_mem as usize, page_size); + wait_forever() + } + + /// Spawns a grandchild running `spawn_and_wait` and prints its pid. + fn spawn_spawn_wait() -> Result<()> { + let exe = std::env::current_exe()?; + let mut grandchild = std::process::Command::new(exe) + .arg("spawn_and_wait") + .arg("1") + .stdout(std::process::Stdio::piped()) + .spawn()?; + let pid = grandchild.id(); + println!("{pid}"); + grandchild.wait()?; + Ok(()) + } + + fn spawn_alloc_wait() -> Result<()> { + let page_size = unsafe { libc::sysconf(libc::_SC_PAGESIZE) as usize }; + + let mut values = Vec::::with_capacity(page_size); + for idx in 0..page_size { + values.push((idx % 255) as u8); + } + + println!("{:p} {}", values.as_ptr(), page_size); + wait_forever() + } + + fn create_files_wait(num: usize) -> Result<()> { + use std::{fmt::Write, io::Read}; + + let mut file_array = Vec::::with_capacity(num); + + let mut rand = std::fs::File::open("/dev/urandom").expect("failed to open /dev/urandom"); + const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + + let mut root = std::env::temp_dir(); + root.push("minidump-writer"); + + if !root.exists() { + std::fs::create_dir_all(&root).expect("failed to create $TMP/minidump-writer dir"); + } + + let mut rand_indices = [0u8; 6]; + + for id in 0..num { + let mut name = String::new(); + name.push_str("test_file"); + + rand.read_exact(&mut rand_indices) + .expect("failed to read /dev/urandom"); + + for index in rand_indices { + name.push(CHARS[index as usize % CHARS.len()] as char); + } + + write!(&mut name, "{id}").unwrap(); + + let path = root.join(name); + file_array.push(std::fs::File::create(&path).expect("failed to create path")); + println!("1"); + } + // Keep the files open (and referenced) until the dump is taken. + println!("1"); + loop { + std::thread::park(); + println!("{}", file_array.len()); + } + } + + pub(super) fn real_main(args: Vec) -> Result<()> { + match args.len() { + 1 => match args[0].as_ref() { + "nop" => Ok(()), + "setup" => Ok(()), + "spawn_mmap_wait" => spawn_mmap_wait(), + "spawn_alloc_wait" => spawn_alloc_wait(), + "spawn_spawn_wait" => spawn_spawn_wait(), + _ => Err("Len 1: Unknown test option".into()), + }, + 2 => match args[0].as_ref() { + "spawn_and_wait" => { + let num_of_threads: usize = args[1].parse().unwrap(); + spawn_and_wait(num_of_threads) + } + "spawn_name_wait" => { + let num_of_threads: usize = args[1].parse().unwrap(); + spawn_name_wait(num_of_threads) + } + "create_files_wait" => { + let num_of_files: usize = args[1].parse().unwrap(); + create_files_wait(num_of_files) + } + _ => Err(format!("Len 2: Unknown test option: {}", args[0]).into()), + }, + _ => Err("Unknown test option".into()), + } + } +} + #[cfg(target_os = "windows")] mod windows { use super::*; @@ -668,11 +836,14 @@ fn main() -> Result<()> { cfg_if::cfg_if! { if #[cfg(any(target_os = "linux", target_os = "android"))] { linux::real_main(args) + } else if #[cfg(target_os = "freebsd")] { + freebsd::real_main(args) } else if #[cfg(target_os = "windows")] { windows::real_main(args) } else if #[cfg(target_os = "macos")] { mac::real_main(args) } else { + let _ = args; unimplemented!(); } } diff --git a/tests/freebsd_minidump_writer.rs b/tests/freebsd_minidump_writer.rs new file mode 100644 index 00000000..cd0856cb --- /dev/null +++ b/tests/freebsd_minidump_writer.rs @@ -0,0 +1,804 @@ +#![cfg(target_os = "freebsd")] + +use { + common::*, + minidump::*, + minidump_common::errors::ExceptionCodeLinux, + minidump_writer::{ + CrashContext, Pid, app_memory::AppMemory, auxv::AuxvDumpInfo, + minidump_writer::MinidumpWriterConfig, + }, + serde_json::Value, + std::io::{BufRead, BufReader}, + std::process::Stdio, +}; + +mod common; + +#[derive(Debug, PartialEq)] +enum Context { + With, + Without, +} + +impl Context { + pub fn minidump_writer(&self, pid: Pid) -> MinidumpWriterConfig { + let main_lwp = get_main_thread_lwp(pid).unwrap_or(pid); + let mut mw = MinidumpWriterConfig::new(pid, main_lwp); + if self == &Context::With { + let mut crash_context = get_crash_context(main_lwp, pid); + crash_context.siginfo.si_signo = libc::SIGSEGV; + mw.set_crash_context(crash_context); + } + mw + } +} + +/// Queries the main thread's LWP ID for a process using sysctl. +/// On FreeBSD, thread IDs (LWP IDs) are not the same as the process PID. +/// The main thread's ki_tid is returned if found; otherwise falls back to PID. +fn get_main_thread_lwp(pid: Pid) -> Option { + let mib = [ + libc::CTL_KERN, + libc::KERN_PROC, + libc::KERN_PROC_PID | libc::KERN_PROC_INC_THREAD, + pid, + ]; + let mut len: usize = 0; + let res = unsafe { + libc::sysctl( + mib.as_ptr(), + mib.len() as libc::c_uint, + std::ptr::null_mut(), + &mut len, + std::ptr::null(), + 0, + ) + }; + if res != 0 || len == 0 { + return None; + } + let count = len / std::mem::size_of::(); + let mut buffer = vec![0u8; len]; + let res = unsafe { + libc::sysctl( + mib.as_ptr(), + mib.len() as libc::c_uint, + buffer.as_mut_ptr() as *mut libc::c_void, + &mut len, + std::ptr::null(), + 0, + ) + }; + if res != 0 || count == 0 { + return None; + } + let kprocs = buffer.as_ptr() as *const libc::kinfo_proc; + unsafe { Some((*kprocs).ki_tid) } +} + +fn get_crash_context(tid: Pid, pid: Pid) -> CrashContext { + let mut ucontext: libc::ucontext_t = unsafe { std::mem::zeroed() }; + // SAFETY getcontext is not in the libc crate, so call it via raw FFI. + unsafe { + unsafe extern "C" { + #[allow(improper_ctypes)] + fn getcontext(ucp: *mut libc::ucontext_t) -> libc::c_int; + } + let ret = getcontext(&mut ucontext); + assert_eq!(ret, 0, "getcontext failed"); + } + CrashContext { + siginfo: unsafe { std::mem::zeroed() }, + ucontext, + pid, + tid, + } +} + +#[test] +fn auxv_sysctl_fills_elf_entries() { + let mut auxv = AuxvDumpInfo::default(); + auxv.try_filling_missing_info(minidump_writer::local(std::process::id() as Pid).as_ref()) + .expect("failed to read auxv for current process"); + + assert!(auxv.get_program_header_count().unwrap_or_default() > 0); + assert!(auxv.get_program_header_address().unwrap_or_default() > 0); + assert!(auxv.get_entry_address().unwrap_or_default() > 0); +} + +fn contains_json_string(value: &Value, expected: &str) -> bool { + match value { + Value::String(actual) => actual == expected, + Value::Array(items) => items + .iter() + .any(|item| contains_json_string(item, expected)), + Value::Object(entries) => entries + .values() + .any(|item| contains_json_string(item, expected)), + _ => false, + } +} + +#[test] +fn skip_stacks_if_mapping_unreferenced_omits_thread_stacks() { + let mut child = start_child_and_wait_for_threads(1); + let pid = child.id() as i32; + let main_lwp = get_main_thread_lwp(pid).unwrap_or(pid); + + let mut tmpfile = tempfile::Builder::new() + .prefix("skip_stacks_if_mapping_unreferenced") + .tempfile() + .unwrap(); + + let mut tmp = MinidumpWriterConfig::new(pid, main_lwp); + tmp.skip_stacks_if_mapping_unreferenced() + .set_principal_mapping_address(usize::MAX); + tmp.write(&mut tmpfile).expect("Could not write minidump"); + child.kill().expect("Failed to kill process"); + let _waitres = child.wait().expect("Failed to wait for child"); + + let dump = Minidump::read_path(tmpfile.path()).expect("Failed to read minidump"); + let soft_errors = read_minidump_soft_errors_or_panic(&dump); + assert!(contains_json_string( + &soft_errors, + "PrincipalMappingNotReferenced" + )); + + let threads: MinidumpThreadList = dump.get_stream().expect("Couldn't find MinidumpThreadList"); + assert!(!threads.threads.is_empty(), "Expected at least one thread"); + assert!( + threads + .threads + .iter() + .all(|thread| thread.raw.stack.memory.data_size == 0), + "Expected all thread stacks to be omitted" + ); +} + +macro_rules! contextual_test { + ( $(#[$attr:meta])? fn $name:ident ($ctx:ident : Context) $body:block ) => { + mod $name { + use super::*; + + fn test($ctx: Context) $body + + #[test] + $(#[$attr])? + fn without_context() { + test(Context::Without) + } + + #[test] + $(#[$attr])? + fn with_context() { + test(Context::With) + } + } + } +} + +contextual_test! { + fn write_dump(context: Context) { + let num_of_threads = 3; + let mut child = start_child_and_wait_for_threads(num_of_threads); + let pid = child.id() as i32; + + let mut tmpfile = tempfile::Builder::new() + .prefix("write_dump") + .tempfile() + .unwrap(); + + let tmp = context.minidump_writer(pid); + let in_memory_buffer = tmp.write(&mut tmpfile).expect("Could not write minidump"); + child.kill().expect("Failed to kill process"); + + let _waitres = child.wait().expect("Failed to wait for child"); + + let meta = std::fs::metadata(tmpfile.path()).expect("Couldn't get metadata for tempfile"); + assert!(meta.len() > 0); + + let mem_slice = std::fs::read(tmpfile.path()).expect("Failed to read minidump"); + assert_eq!(mem_slice.len(), in_memory_buffer.len()); + assert_eq!(mem_slice, in_memory_buffer); + + let dump = Minidump::read_path(tmpfile.path()).expect("Failed to read minidump"); + let _: MinidumpModuleList = dump.get_stream().expect("Couldn't find MinidumpModuleList"); + let thread_list: MinidumpThreadList = dump.get_stream().expect("Couldn't find MinidumpThreadList"); + assert!(!thread_list.threads.is_empty()); + let _: MinidumpMemoryList = dump.get_stream().expect("Couldn't find MinidumpMemoryList"); + let _: MinidumpSystemInfo = dump.get_stream().expect("Couldn't find MinidumpSystemInfo"); + let exception: MinidumpException = dump.get_stream().expect("Couldn't find MinidumpException"); + let thread_ids: Vec = thread_list.threads.iter().map(|t| t.raw.thread_id).collect(); + assert!( + thread_ids.contains(&exception.raw.thread_id), + "Exception thread_id {} must exist in thread list {:?}", + exception.raw.thread_id, + thread_ids + ); + + // The exception code reflects the dump mode: DUMP_REQUESTED without a + // crash context, the context's signal number otherwise. + let expected_code = if matches!(context, Context::With) { + libc::SIGSEGV as u32 + } else { + ExceptionCodeLinux::DUMP_REQUESTED as u32 + }; + assert_eq!(exception.raw.exception_record.exception_code, expected_code); + + // Every module must be symbolizable. + let modules: MinidumpModuleList = dump.get_stream().expect("Couldn't find MinidumpModuleList"); + assert!(!modules.iter().any(|m| m.debug_identifier().is_none())); + } +} + +contextual_test! { + fn write_with_additional_memory(context: Context) { + let mut child = start_child_and_return(&["spawn_alloc_wait"]); + let pid = child.id() as i32; + + let mut tmpfile = tempfile::Builder::new() + .prefix("additional_memory") + .tempfile() + .unwrap(); + + let mut f = BufReader::new(child.stdout.as_mut().expect("Can't open stdout")); + let mut buf = String::new(); + let _ = f.read_line(&mut buf).expect("Couldn't read address provided by child"); + let mut output = buf.split_whitespace(); + // The helper prints the pointer with `{:p}` (i.e. `0x`-prefixed hex). + let memory_addr: usize = usize::from_str_radix( + output.next().unwrap().trim_start_matches("0x"), + 16, + ) + .expect("unable to parse memory_addr"); + let memory_size: usize = output.next().unwrap().parse().expect("unable to parse memory_size"); + + let mut tmp = context.minidump_writer(pid); + + let app_memory = AppMemory { + ptr: memory_addr, + length: memory_size, + }; + tmp.set_app_memory(vec![app_memory]); + + tmp.write(&mut tmpfile).expect("Could not write minidump"); + child.kill().expect("Failed to kill process"); + let _waitres = child.wait().expect("Failed to wait for child"); + + let dump = Minidump::read_path(tmpfile.path()).expect("Failed to read minidump"); + let section: MinidumpMemoryList = dump.get_stream().expect("Couldn't find MinidumpMemoryList"); + let region = section + .memory_at_address(memory_addr as u64) + .expect("Couldn't find memory region"); + + assert_eq!(region.base_address, memory_addr as u64); + assert_eq!(region.size, memory_size as u64); + } +} + +contextual_test! { + fn named_threads(context: Context) { + let num_of_threads = 3; + let mut child = start_child_and_wait_for_named_threads(num_of_threads); + let pid = child.id() as i32; + + let mut tmpfile = tempfile::Builder::new() + .prefix("named_threads") + .tempfile() + .unwrap(); + + let tmp = context.minidump_writer(pid); + tmp.write(&mut tmpfile).expect("Could not write minidump"); + child.kill().expect("Failed to kill process"); + let _waitres = child.wait().expect("Failed to wait for child"); + + let dump = Minidump::read_path(tmpfile.path()).expect("Failed to read minidump"); + + let threads: MinidumpThreadList = dump.get_stream().expect("Couldn't find MinidumpThreadList"); + let thread_names: MinidumpThreadNames = dump.get_stream().expect("Couldn't find MinidumpThreadNames"); + + let thread_ids: Vec<_> = threads.threads.iter().map(|t| t.raw.thread_id).collect(); + // The helper names spawned threads `thread_1` .. `thread_{n-1}`; the + // main thread falls back to the process command name. + for id in 1..num_of_threads { + let tid_name = format!("thread_{id}"); + let named = thread_ids.iter().any(|tid| { + thread_names + .get_name(*tid) + .is_some_and(|n| n.as_ref() == tid_name.as_str()) + }); + assert!(named, "expected a thread named {tid_name}"); + } + } +} + +contextual_test! { + fn file_descriptors(context: Context) { + let num_of_files = 5; + let _num_of_threads = num_of_files + 1; + let mut child = start_child_and_wait_for_create_files(num_of_files); + let pid = child.id() as i32; + + let mut tmpfile = tempfile::Builder::new() + .prefix("file_descriptors") + .tempfile() + .unwrap(); + + let tmp = context.minidump_writer(pid); + tmp.write(&mut tmpfile).expect("Could not write minidump"); + child.kill().expect("Failed to kill process"); + let _waitres = child.wait().expect("Failed to wait for child"); + + let dump = Minidump::read_path(tmpfile.path()).expect("Failed to read minidump"); + + // Handle data stream requires procfs with /proc//fd support + let proc_fd_exists = std::path::Path::new(&format!("/proc/{}/fd", pid)).exists(); + match dump.get_stream::() { + Ok(handle_data) if proc_fd_exists => { + assert!( + !handle_data.handles.is_empty(), + "Expected file descriptors in handle data stream" + ); + // stdin/stdout/stderr plus the created test files. + assert!( + handle_data.handles.len() >= num_of_files + 3, + "expected at least {} handles, got {}", + num_of_files + 3, + handle_data.handles.len() + ); + } + Err(_) if !proc_fd_exists => {} + Err(e) => panic!("Unexpected error reading handle data stream: {e}"), + Ok(_) => {} + } + } +} + +contextual_test! { + fn memory_info_list_stream(context: Context) { + let num_of_threads = 1; + let mut child = start_child_and_wait_for_threads(num_of_threads); + let pid = child.id() as i32; + + let mut tmpfile = tempfile::Builder::new() + .prefix("memory_info_list") + .tempfile() + .unwrap(); + + let tmp = context.minidump_writer(pid); + tmp.write(&mut tmpfile).expect("Could not write minidump"); + child.kill().expect("Failed to kill process"); + let _waitres = child.wait().expect("Failed to wait for child"); + + let dump = Minidump::read_path(tmpfile.path()).expect("Failed to read minidump"); + let mem_info_list: MinidumpMemoryInfoList = dump.get_stream().expect("Couldn't find MinidumpMemoryInfoList"); + assert!(mem_info_list.iter().count() > 0, "Expected memory info entries"); + } +} + +#[test] +fn minidump_size_limit() { + // More than LIMIT_BASE_THREAD_COUNT (20) threads so the stack-size + // cap actually kicks in for the extra threads. + let num_of_threads = 40; + let mut child = start_child_and_wait_for_threads(num_of_threads); + let pid = child.id() as i32; + + let mut total_normal_stack_size = 0u64; + let normal_file_size; + + // First, write a minidump with no size limit. + { + let mut tmpfile = tempfile::Builder::new() + .prefix("no_limit") + .tempfile() + .unwrap(); + + let tmp = MinidumpWriterConfig::new(pid, pid); + tmp.write(&mut tmpfile).expect("Could not write minidump"); + normal_file_size = std::fs::metadata(tmpfile.path()).unwrap().len(); + + let dump = Minidump::read_path(tmpfile.path()).expect("Failed to read minidump"); + let threads: MinidumpThreadList = + dump.get_stream().expect("Couldn't find MinidumpThreadList"); + for thread in threads.threads { + assert!(thread.raw.stack.memory.data_size > 0); + total_normal_stack_size += u64::from(thread.raw.stack.memory.data_size); + } + } + + // Second, write a minidump with a size limit small enough to be + // triggered. + { + const LIMIT_AVERAGE_THREAD_STACK_LENGTH: u64 = 8 * 1024; + let mut minidump_size_limit = LIMIT_AVERAGE_THREAD_STACK_LENGTH * 40; + if normal_file_size < minidump_size_limit { + minidump_size_limit = normal_file_size; + } + + let mut tmpfile = tempfile::Builder::new() + .prefix("with_limit") + .tempfile() + .unwrap(); + + let mut tmp = MinidumpWriterConfig::new(pid, pid); + tmp.set_minidump_size_limit(minidump_size_limit); + tmp.write(&mut tmpfile).expect("Could not write minidump"); + let limit_size = std::fs::metadata(tmpfile.path()).unwrap().len(); + + // If this fails because it's the same size, the size-limit logic + // didn't kick in like it was supposed to. + assert!( + limit_size < normal_file_size, + "limited ({limit_size}) must be smaller than unlimited ({normal_file_size})" + ); + + let mut total_limit_stack_size = 0u64; + let dump = Minidump::read_path(tmpfile.path()).expect("Failed to read minidump"); + let threads: MinidumpThreadList = + dump.get_stream().expect("Couldn't find MinidumpThreadList"); + for thread in threads.threads { + assert!(thread.raw.stack.memory.data_size > 0); + total_limit_stack_size += u64::from(thread.raw.stack.memory.data_size); + } + + // The stacks must shrink by at least 1KB per extra thread. + const LIMIT_BASE_THREAD_COUNT: usize = 20; + const MIN_PER_EXTRA_THREAD_STACK_REDUCTION: u64 = 1024; + let min_reduction = + (40 - LIMIT_BASE_THREAD_COUNT) as u64 * MIN_PER_EXTRA_THREAD_STACK_REDUCTION; + assert!( + total_limit_stack_size < total_normal_stack_size - min_reduction, + "stacks barely shrank: {total_limit_stack_size} vs {total_normal_stack_size}" + ); + } + + child.kill().expect("Failed to kill process"); + let _waitres = child.wait().expect("Failed to wait for child"); +} + +contextual_test! { + fn sanitized_stacks(context: Context) { + let num_of_threads = 1; + let mut child = start_child_and_wait_for_threads(num_of_threads); + let pid = child.id() as i32; + + let mut tmpfile = tempfile::Builder::new() + .prefix("sanitized_stacks") + .tempfile() + .unwrap(); + + let mut tmp = context.minidump_writer(pid); + tmp.sanitize_stack(); + tmp.write(&mut tmpfile).expect("Could not write minidump"); + child.kill().expect("Failed to kill process"); + let _waitres = child.wait().expect("Failed to wait for child"); + + let dump = Minidump::read_path(tmpfile.path()).expect("Failed to read minidump"); + let _: MinidumpThreadList = dump.get_stream().expect("Couldn't find MinidumpThreadList"); + let _: MinidumpMemoryList = dump.get_stream().expect("Couldn't find MinidumpMemoryList"); + + assert!(std::fs::metadata(tmpfile.path()).unwrap().len() > 0); + } +} + +/// The main module must be read from process memory even when the binary has +/// been deleted from disk: the build id in the dump has to match the one +/// computed from the file before deletion. +#[test] +fn with_deleted_binary() { + let num_of_threads = 1; + let binary_copy_dir = tempfile::Builder::new() + .prefix("deleted_binary") + .tempdir() + .unwrap(); + let binary_copy = binary_copy_dir.as_ref().join("binary_copy"); + + std::fs::copy(std::env!("CARGO_BIN_EXE_test"), &binary_copy).expect("Failed to copy binary"); + let mem_slice = std::fs::read(&binary_copy).expect("Failed to read binary"); + + let mut child = std::process::Command::new(&binary_copy) + .arg("spawn_and_wait") + .arg(num_of_threads.to_string()) + .stdout(Stdio::piped()) + .spawn() + .expect("failed to execute child"); + wait_for_threads(&mut child, num_of_threads); + + let pid = child.id() as i32; + + let mut build_id = + ::read_from_module( + minidump_writer::module_reader::ModuleMemory::Slice(&mem_slice), + ) + .expect("Failed to get build_id") + .0; + + std::fs::remove_file(&binary_copy).expect("Failed to remove binary"); + + let mut tmpfile = tempfile::Builder::new() + .prefix("deleted_binary") + .tempfile() + .unwrap(); + + let main_lwp = get_main_thread_lwp(pid).unwrap_or(pid); + MinidumpWriterConfig::new(pid, main_lwp) + .write(&mut tmpfile) + .expect("Could not write minidump"); + + child.kill().expect("Failed to kill process"); + let _waitres = child.wait().expect("Failed to wait for child"); + + let dump = Minidump::read_path(tmpfile.path()).expect("Failed to read minidump"); + let module_list: MinidumpModuleList = dump + .get_stream() + .expect("Couldn't find stream MinidumpModuleList"); + + let main_module = module_list + .main_module() + .expect("Could not get main module"); + let did = main_module + .debug_identifier() + .expect("expected value debug id"); + let did_uuid = did.uuid(); + let uuid = did_uuid.as_bytes(); + + // Swap the first three fields to match the minidump GUID layout. + if cfg!(target_endian = "little") { + build_id[..4].reverse(); + build_id[4..6].reverse(); + build_id[6..8].reverse(); + } + + // The build id can be shorter than a UUID (e.g. LLD's xxhash ids); + // the rest is zero-padded. + build_id.resize(16, 0); + + assert_eq!(uuid.as_slice(), &build_id); + + // The 'age' appendix is always 0 on non-windows targets. + assert_eq!(did.appendix(), 0); +} + +/// Dumping our own process is refused: ptrace doesn't work on itself. +#[test] +fn cannot_ptrace_same_process() { + let own_pid = std::process::id() as i32; + let mut tmpfile = tempfile::Builder::new() + .prefix("same_process") + .tempfile() + .unwrap(); + + let result = MinidumpWriterConfig::new(own_pid, own_pid).write(&mut tmpfile); + let err = result.expect_err("expected an error for self-dump"); + let json = serde_json::to_value(&err).expect("serializable"); + assert!( + json.to_string().contains("CannotPtraceSameProcess"), + "unexpected error: {err:#?}" + ); +} + +/// Module list properties: non-empty, sane bases/sizes and names. +#[test] +fn module_list_properties() { + let mut child = start_child_and_wait_for_threads(1); + let pid = child.id() as i32; + + let mut tmpfile = tempfile::Builder::new() + .prefix("module_list_props") + .tempfile() + .unwrap(); + MinidumpWriterConfig::new(pid, get_main_thread_lwp(pid).unwrap_or(pid)) + .write(&mut tmpfile) + .expect("Could not write minidump"); + child.kill().expect("Failed to kill process"); + let _waitres = child.wait().expect("Failed to wait for child"); + + let dump = Minidump::read_path(tmpfile.path()).expect("Failed to read minidump"); + let modules: MinidumpModuleList = dump.get_stream().expect("Couldn't find MinidumpModuleList"); + + let all = modules.iter().collect::>(); + assert!(!all.is_empty()); + assert!(modules.main_module().is_some()); + for module in &all { + assert!( + module.size() > 0, + "module {:?} has zero size", + module.code_file() + ); + assert!( + !module.code_file().is_empty(), + "module at {:#x} has no name", + module.base_address() + ); + } +} + +/// With a crash context, ~256 bytes around the crashing instruction pointer +/// must be present in the memory list. +#[test] +fn crash_thread_ip_memory() { + let mut child = start_child_and_wait_for_threads(1); + let pid = child.id() as i32; + let main_lwp = get_main_thread_lwp(pid).unwrap_or(pid); + + // Grab a valid instruction pointer of the child from its auxv entry + // point, then let go before the writer attaches. + let entry_point = { + let inspector = minidump_writer::local(pid); + inspector.attach().expect("attach"); + let mut auxv = minidump_writer::auxv::AuxvDumpInfo::default(); + auxv.try_filling_missing_info(inspector.as_ref()) + .expect("auxv"); + let entry = auxv.get_entry_address().expect("AT_ENTRY") as usize; + inspector.detach().expect("detach"); + entry + }; + + let mut crash_context = get_crash_context(main_lwp, pid); + crash_context.ucontext.uc_mcontext.mc_rip = entry_point as i64; + + let mut tmpfile = tempfile::Builder::new() + .prefix("crash_ip_mem") + .tempfile() + .unwrap(); + let mut cfg = MinidumpWriterConfig::new(pid, main_lwp); + cfg.set_crash_context(crash_context); + cfg.write(&mut tmpfile).expect("Could not write minidump"); + child.kill().expect("Failed to kill process"); + let _waitres = child.wait().expect("Failed to wait for child"); + + let dump = Minidump::read_path(tmpfile.path()).expect("Failed to read minidump"); + let memory: MinidumpMemoryList = dump.get_stream().expect("Couldn't find MinidumpMemoryList"); + let region = memory + .memory_at_address(entry_point as u64) + .expect("memory around the crashing IP"); + assert!( + region.size > 0 && region.size <= 256, + "unexpected size {}", + region.size + ); +} + +/// Dumping a process that is not our own child exercises the ECHILD wait +/// path (polling `ki_stat` instead of `waitpid`). +#[test] +fn write_dump_for_non_child() { + let mut child = start_child_and_return(&["spawn_spawn_wait"]); + let _pid = child.id() as i32; + + let mut f = BufReader::new(child.stdout.as_mut().expect("Can't open stdout")); + let mut buf = String::new(); + let _ = f.read_line(&mut buf).expect("Couldn't read grandchild pid"); + let grandchild_pid: i32 = buf.trim().parse().expect("pid"); + std::thread::sleep(std::time::Duration::from_millis(500)); + + let mut tmpfile = tempfile::Builder::new() + .prefix("non_child") + .tempfile() + .unwrap(); + MinidumpWriterConfig::new(grandchild_pid, grandchild_pid) + .write(&mut tmpfile) + .expect("Could not write minidump"); + + // Don't leak the parked grandchild on development machines. + unsafe { + libc::kill(grandchild_pid, libc::SIGKILL); + } + + let dump = Minidump::read_path(tmpfile.path()).expect("Failed to read minidump"); + let threads: MinidumpThreadList = dump.get_stream().expect("Couldn't find MinidumpThreadList"); + assert!(!threads.threads.is_empty()); + + child.kill().expect("Failed to kill process"); + let _waitres = child.wait().expect("Failed to wait for child"); +} + +/// A stack pointer inside an unreadable region must scan upwards to the next +/// RW mapping (e.g. a guard region on stack overflow). +#[test] +fn stack_info_scan_skips_unreadable_regions() { + let mut child = start_child_and_wait_for_threads(1); + let pid = child.id() as i32; + + let mut writer = MinidumpWriterConfig::new(pid, pid) + .build_for_testing(error_graph::strategy::DontCare) + .expect("init"); + let page = writer.page_size.max(1); + + let base = 0x1_0000; + let mk = |start: usize, size: usize, perms| minidump_writer::maps_reader::MappingInfo { + start_address: start, + size, + system_mapping_info: minidump_writer::maps_reader::SystemMappingInfo { + start_address: start, + end_address: start + size, + }, + offset: 0, + permissions: perms, + name: None, + }; + writer.mappings = vec![ + // No-access region containing the stack pointer. + mk( + base, + page, + minidump_writer::vm_permissions::VmPermissions::empty(), + ), + // The actual stack, two pages further up. + mk( + base + 2 * page, + 8 * page, + minidump_writer::vm_permissions::VmPermissions::READ + | minidump_writer::vm_permissions::VmPermissions::WRITE, + ), + ]; + + // sp inside the no-access mapping: `find_mapping` finds it but + // `may_be_stack` rejects it, so the scan moves up to the RW stack. + let (valid_sp, len) = writer + .get_stack_info(base) + .expect("stack info via upward scan"); + assert_eq!(valid_sp, base + 2 * page); + assert_eq!(len, 8 * page); + + drop(writer); + child.kill().expect("Failed to kill process"); + let _waitres = child.wait().expect("Failed to wait for child"); +} + +/// User-supplied mappings must be written with their identifier. +#[test] +fn user_mapping_list_identifier() { + let mut child = start_child_and_wait_for_threads(1); + let pid = child.id() as i32; + + let mut tmpfile = tempfile::Builder::new() + .prefix("user_mapping") + .tempfile() + .unwrap(); + + let identifier: Vec = (0..16u8).collect(); + let entry = minidump_writer::maps_reader::MappingEntry { + info: minidump_writer::maps_reader::MappingInfo { + start_address: 0x1000, + size: 0x2000, + system_mapping_info: minidump_writer::maps_reader::SystemMappingInfo { + start_address: 0x1000, + end_address: 0x3000, + }, + offset: 0, + permissions: minidump_writer::vm_permissions::VmPermissions::READ + | minidump_writer::vm_permissions::VmPermissions::WRITE, + name: Some("fake_user_mapping".into()), + }, + identifier: Some(identifier.clone()), + }; + + let mut cfg = MinidumpWriterConfig::new(pid, pid); + cfg.set_user_mapping_list(vec![entry]); + cfg.write(&mut tmpfile).expect("Could not write minidump"); + child.kill().expect("Failed to kill process"); + let _waitres = child.wait().expect("Failed to wait for child"); + + let dump = Minidump::read_path(tmpfile.path()).expect("Failed to read minidump"); + let modules: MinidumpModuleList = dump.get_stream().expect("Couldn't find MinidumpModuleList"); + + let module = modules + .iter() + .find(|m| m.code_file().contains("fake_user_mapping")) + .expect("user module in list"); + let did = module.debug_identifier().expect("identifier present"); + + // The CV record identifier is reinterpreted as a GUID (first three + // fields byte-swapped on little-endian). + let mut expected = identifier; + if cfg!(target_endian = "little") { + expected[..4].reverse(); + expected[4..6].reverse(); + expected[6..8].reverse(); + } + assert_eq!(did.uuid().as_bytes(), expected.as_slice()); + assert_eq!(did.appendix(), 0); +} diff --git a/tests/freebsd_module_reader.rs b/tests/freebsd_module_reader.rs new file mode 100644 index 00000000..4a85b935 --- /dev/null +++ b/tests/freebsd_module_reader.rs @@ -0,0 +1,61 @@ +#![cfg(target_os = "freebsd")] + +use minidump_writer::module_reader::*; + +/// This is a small (but valid) 64-bit little-endian elf executable with the following layout: +/// * ELF header +/// * program header: text segment +/// * program header: note +/// * program header: dynamic +/// * section header: null +/// * section header: .text +/// * section header: .note.gnu.build-id +/// * section header: .shstrtab +/// * section header: .dynamic +/// * section header: .dynstr +/// * note header (build id note) +/// * shstrtab +/// * dynamic (SONAME/STRTAB/STRSZ) +/// * dynstr (SONAME string = libfoo.so.1) +/// * program (calls exit(0)) +const TINY_ELF: &[u8] = include_bytes!("tiny.elf"); + +#[test] +fn build_id_program_headers() { + let reader = ModuleReader::new(TINY_ELF.into()).unwrap(); + let id = reader.build_id_from_program_headers().unwrap(); + assert_eq!(id, &[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]); +} + +#[test] +fn build_id_section() { + let reader = ModuleReader::new(TINY_ELF.into()).unwrap(); + let id = reader.build_id_from_section().unwrap(); + assert_eq!(id, &[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]); +} + +#[test] +fn build_id_text_hash() { + let reader = ModuleReader::new(TINY_ELF.into()).unwrap(); + let id = reader.build_id_generate_from_text().unwrap(); + assert_eq!( + id, + &[ + 0x6a, 0x3c, 0x58, 0x31, 0xff, 0x0f, 0x05, 0, 0, 0, 0, 0, 0, 0, 0, 0 + ] + ); +} + +#[test] +fn soname_program_headers() { + let reader = ModuleReader::new(TINY_ELF.into()).unwrap(); + let soname = reader.soname_from_program_headers().unwrap(); + assert_eq!(soname, "libfoo.so.1"); +} + +#[test] +fn soname_section() { + let reader = ModuleReader::new(TINY_ELF.into()).unwrap(); + let soname = reader.soname_from_sections().unwrap(); + assert_eq!(soname, "libfoo.so.1"); +} From 30a14a94419b4f5f536bcae2e4b42b8583badbc4 Mon Sep 17 00:00:00 2001 From: G36maid Date: Thu, 22 Oct 2026 04:22:05 +0800 Subject: [PATCH 8/8] freebsd: add FreeBSD support changelog entry --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f37788b5..6e475aeb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] - ReleaseDate +### Added +- [PR#183](https://github.com/rust-minidump/minidump-writer/pull/183) added FreeBSD support (x86_64): process inspection via `ptrace`(`PT_IO`, `PT_GETREGS`, `PT_SUSPEND`), maps via `kinfo_getvmmap`, ELF module/build-id/soname reading, and the full minidump writer stream set, resolving [#162](https://github.com/rust-minidump/minidump-writer/issues/162). + ## [0.13.0] - 2026-07-20 ### Changed - [PR#174](https://github.com/rust-minidump/minidump-writer/pull/174) and [PR#175](https://github.com/rust-minidump/minidump-writer/pull/175) refactored `src/linux/thread_info`.