#![allow(unused_variables)] #![allow(dead_code)]
use crate::heuristics::is_dependency_path;
use capstone::arch::BuildsCapstone;
use capstone::{Capstone, Insn, arch};
use dashmap::DashMap;
use gimli::{
AttributeValue, ColumnType, DebuggingInformationEntry, Dwarf, EndianSlice, Reader,
RunTimeEndian, SectionId, Unit,
};
use goblin::Object;
use goblin::archive::Archive;
use goblin::container::{Container, Ctx, Endian};
use goblin::mach::segment::SectionData;
use goblin::mach::segment::{Section, Segment};
use goblin::mach::symbols::N_OSO;
use goblin::mach::{Mach, MachO};
use ouroboros::self_referencing;
use rayon::prelude::*;
use regex::Regex;
use rustc_demangle::demangle;
use std::borrow::Cow;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::{fs, io};
type DwarfReader<'a> = EndianSlice<'a, RunTimeEndian>;
type SourceLocation = (Option<String>, Option<u32>, Option<u32>);
type DwarfFunctionResult = (Vec<FunctionInfo>, Vec<FunctionInfo>, StringTables);
pub fn matches_crate_pattern(file_path: &str, crate_pattern: &str) -> bool {
matches_crate_pattern_validated(file_path, crate_pattern, None)
}
pub fn matches_crate_pattern_validated(
file_path: &str,
crate_pattern: &str,
valid_files: Option<&ValidSourceFiles>,
) -> bool {
let matches = crate_pattern
.split('|')
.any(|pattern| !pattern.is_empty() && file_path.contains(pattern));
if !matches {
return false;
}
if let Some(valid) = valid_files {
if valid.needs_validation(crate_pattern) {
return valid.contains(file_path);
}
}
if is_dependency_path(file_path) {
return false;
}
true
}
#[derive(Debug, Default)]
pub struct ValidSourceFiles {
files: std::collections::HashSet<String>,
project_root: Option<std::path::PathBuf>,
}
impl ValidSourceFiles {
pub fn from_project_root(project_root: &std::path::Path) -> Self {
let mut files = std::collections::HashSet::new();
Self::scan_project_directory(project_root, project_root, &mut files);
let canonical_root = std::fs::canonicalize(project_root).ok();
Self {
files,
project_root: canonical_root,
}
}
fn scan_project_directory(
project_root: &std::path::Path,
dir: &std::path::Path,
files: &mut std::collections::HashSet<String>,
) {
const EXCLUDED_DIRS: &[&str] = &[
"target",
".git",
".hg",
".svn",
"node_modules",
".cargo",
".rustup",
"examples",
"tests",
"benches",
];
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.filter_map(|e| e.ok()) {
let path = entry.path();
let name = entry.file_name();
let name_str = name.to_string_lossy();
if path.is_file() && name_str.ends_with(".rs") && name_str != "build.rs" {
if let Ok(rel_path) = path.strip_prefix(project_root) {
files.insert(rel_path.to_string_lossy().to_string());
}
} else if path.is_dir()
&& !name_str.starts_with('.')
&& !EXCLUDED_DIRS.contains(&name_str.as_ref())
{
Self::scan_project_directory(project_root, &path, files);
}
}
}
fn needs_validation(&self, pattern: &str) -> bool {
pattern == "src/" && !self.files.is_empty()
}
fn contains(&self, file_path: &str) -> bool {
if self.files.contains(file_path) {
return true;
}
if let Some(stripped) = file_path.strip_prefix("./") {
if self.files.contains(stripped) {
return true;
}
}
for valid_file in &self.files {
let suffix = format!("/{}", valid_file);
if file_path.ends_with(&suffix) {
return true;
}
}
if let Some(project_root) = &self.project_root {
let file_path_buf = std::path::Path::new(file_path);
if file_path_buf.is_absolute() {
if let Ok(canonical_file) = std::fs::canonicalize(file_path_buf) {
if let Ok(relative) = canonical_file.strip_prefix(project_root) {
let rel_str = relative.to_string_lossy();
return self.files.contains(rel_str.as_ref());
}
}
}
}
false
}
}
#[allow(clippy::large_enum_variant)]
pub enum SymbolTable<'a> {
MachO(Mach<'a>),
Archive(Archive<'a>),
}
#[derive(Debug, Clone)]
pub struct CrateLineEntry {
pub address: u64,
pub line: u32,
pub column: Option<u32>,
}
#[derive(Debug)]
pub struct CrateLineTable {
entries: Vec<CrateLineEntry>,
}
impl CrateLineTable {
pub fn build<R: Reader>(dwarf: &Dwarf<R>, crate_src_path: &str) -> Result<Self, gimli::Error> {
let mut entries = Vec::new();
let mut units = dwarf.units();
while let Some(header) = units.next()? {
let unit = dwarf.unit(header)?;
if let Some(program) = &unit.line_program {
let mut rows = program.clone().rows();
while let Some((header, row)) = rows.next_row()? {
if let Some(file_entry) = row.file(header) {
let file_name = dwarf
.attr_string(&unit, file_entry.path_name())?
.to_string_lossy()?
.into_owned();
let full_path = if let Some(dir) = file_entry.directory(header) {
let dir_str = dwarf
.attr_string(&unit, dir)?
.to_string_lossy()?
.into_owned();
if dir_str.is_empty() {
file_name
} else {
format!("{}/{}", dir_str, file_name)
}
} else {
file_name
};
if matches_crate_pattern(&full_path, crate_src_path)
&& let Some(line) = row.line()
{
let column = match row.column() {
ColumnType::LeftEdge => None,
ColumnType::Column(c) => Some(c.get() as u32),
};
entries.push(CrateLineEntry {
address: row.address(),
line: line.get() as u32,
column,
});
}
}
}
}
}
entries.sort_by_key(|e| e.address);
Ok(Self { entries })
}
pub fn get_line(&self, func_start: u64, call_site_addr: u64) -> (Option<u32>, Option<u32>) {
let start_idx = self.entries.partition_point(|e| e.address < func_start);
let end_idx = self
.entries
.partition_point(|e| e.address <= call_site_addr);
if end_idx > start_idx {
let entry = &self.entries[end_idx - 1];
(Some(entry.line), entry.column)
} else {
(None, None)
}
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
#[derive(Debug, Clone)]
pub struct FullLineEntry {
pub address: u64,
file_id: u32,
pub line: u32,
pub column: Option<u32>,
}
#[derive(Debug)]
pub struct FullLineTable {
entries: Vec<FullLineEntry>,
file_pool: Vec<String>,
}
impl FullLineTable {
pub fn build<R: Reader>(dwarf: &Dwarf<R>) -> Result<Self, gimli::Error> {
let mut entries = Vec::new();
let mut file_pool = Vec::new();
let mut file_to_id: ahash::AHashMap<String, u32> = ahash::AHashMap::new();
let mut units = dwarf.units();
while let Some(header) = units.next()? {
let unit = dwarf.unit(header)?;
if let Some(program) = &unit.line_program {
let mut rows = program.clone().rows();
while let Some((header, row)) = rows.next_row()? {
if let Some(file_entry) = row.file(header) {
let file_name = dwarf
.attr_string(&unit, file_entry.path_name())?
.to_string_lossy()?
.into_owned();
let full_path = if let Some(dir) = file_entry.directory(header) {
let dir_str = dwarf
.attr_string(&unit, dir)?
.to_string_lossy()?
.into_owned();
if dir_str.is_empty() {
file_name
} else {
format!("{}/{}", dir_str, file_name)
}
} else {
file_name
};
let file_id = if let Some(&id) = file_to_id.get(&full_path) {
id
} else {
let id = file_pool.len() as u32;
file_pool.push(full_path.clone());
file_to_id.insert(full_path, id);
id
};
let line = row.line().map(|l| l.get() as u32).unwrap_or(0);
let column = match row.column() {
ColumnType::LeftEdge => None,
ColumnType::Column(c) => Some(c.get() as u32),
};
entries.push(FullLineEntry {
address: row.address(),
file_id,
line,
column,
});
}
}
}
}
entries.sort_by_key(|e| e.address);
Ok(Self { entries, file_pool })
}
pub fn get_source_location(&self, addr: u64) -> SourceLocation {
let idx = self.entries.partition_point(|e| e.address <= addr);
if idx > 0 {
let target_addr = self.entries[idx - 1].address;
let first_idx = self.entries[..idx].partition_point(|e| e.address < target_addr);
let entry = &self.entries[first_idx];
let file = self.file_pool.get(entry.file_id as usize).cloned();
(file, Some(entry.line), entry.column)
} else {
(None, None, None)
}
}
pub fn get_nearest_crate_line(
&self,
addr: u64,
func_start: u64,
func_end: u64,
func_start_line: Option<u32>,
crate_src_path: &str,
) -> (Option<u32>, Option<u32>) {
let end_idx = self.entries.partition_point(|e| e.address <= addr);
if end_idx == 0 {
return (None, None);
}
for i in (0..end_idx).rev() {
let entry = &self.entries[i];
if entry.address < func_start {
break;
}
if let Some(file) = self.file_pool.get(entry.file_id as usize) {
if matches_crate_pattern(file, crate_src_path) && entry.line > 0 {
if func_start_line.is_some_and(|fl| entry.line == fl) {
break; }
return (Some(entry.line), entry.column);
}
}
}
for i in end_idx..self.entries.len() {
let entry = &self.entries[i];
if entry.address >= func_end {
break;
}
if let Some(file) = self.file_pool.get(entry.file_id as usize) {
if matches_crate_pattern(file, crate_src_path) && entry.line > 0 {
if let Some(func_line) = func_start_line {
if entry.line > func_line + 1 {
return (Some(entry.line - 1), None);
}
}
return (Some(entry.line), entry.column);
}
}
}
(None, None)
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn build_both<R: Reader>(
dwarf: &Dwarf<R>,
crate_src_path: &str,
) -> Result<(CrateLineTable, FullLineTable), gimli::Error> {
let mut crate_entries = Vec::new();
let mut full_entries = Vec::new();
let mut file_pool = Vec::new();
let mut file_to_id: ahash::AHashMap<String, u32> = ahash::AHashMap::new();
let mut units = dwarf.units();
while let Some(header) = units.next()? {
let unit = dwarf.unit(header)?;
if let Some(program) = &unit.line_program {
let mut rows = program.clone().rows();
let mut last_crate_line: Option<(u32, Option<u32>)> = None;
let mut last_was_crate = false;
while let Some((header, row)) = rows.next_row()? {
if let Some(file_entry) = row.file(header) {
let file_name = dwarf
.attr_string(&unit, file_entry.path_name())?
.to_string_lossy()?
.into_owned();
let full_path = if let Some(dir) = file_entry.directory(header) {
let dir_str = dwarf
.attr_string(&unit, dir)?
.to_string_lossy()?
.into_owned();
if dir_str.is_empty() {
file_name
} else {
format!("{}/{}", dir_str, file_name)
}
} else {
file_name
};
let is_crate_match = matches_crate_pattern(&full_path, crate_src_path);
let file_id = if let Some(&id) = file_to_id.get(&full_path) {
id
} else {
let id = file_pool.len() as u32;
file_pool.push(full_path.clone());
file_to_id.insert(full_path, id);
id
};
let line = row.line().map(|l| l.get() as u32).unwrap_or(0);
let column = match row.column() {
ColumnType::LeftEdge => None,
ColumnType::Column(c) => Some(c.get() as u32),
};
full_entries.push(FullLineEntry {
address: row.address(),
file_id,
line,
column,
});
if is_crate_match && line > 0 {
crate_entries.push(CrateLineEntry {
address: row.address(),
line,
column,
});
last_crate_line = Some((line, column));
last_was_crate = true;
} else {
if last_was_crate {
if let Some((prev_line, prev_col)) = last_crate_line {
crate_entries.push(CrateLineEntry {
address: row.address(),
line: prev_line,
column: prev_col,
});
}
}
last_was_crate = false;
}
}
}
}
}
crate_entries.par_sort_by_key(|e| e.address);
full_entries.sort_by_key(|e| e.address);
Ok((
CrateLineTable {
entries: crate_entries,
},
FullLineTable {
entries: full_entries,
file_pool,
},
))
}
}
#[derive(Debug, Default)]
pub struct StringTables {
names: Vec<String>,
name_map: HashMap<String, u32>,
files: Vec<String>,
file_map: HashMap<String, u32>,
}
impl StringTables {
pub fn new() -> Self {
Self::default()
}
pub fn intern_name(&mut self, name: String) -> u32 {
if let Some(&idx) = self.name_map.get(&name) {
idx
} else {
let idx = self.names.len() as u32;
self.name_map.insert(name.clone(), idx);
self.names.push(name);
idx
}
}
pub fn intern_file(&mut self, file: Option<String>) -> u32 {
match file {
None => 0,
Some(f) => {
if let Some(&idx) = self.file_map.get(&f) {
idx + 1 } else {
let idx = self.files.len() as u32;
self.file_map.insert(f.clone(), idx);
self.files.push(f);
idx + 1 }
}
}
}
#[inline]
pub fn get_name(&self, idx: u32) -> &str {
&self.names[idx as usize]
}
#[inline]
pub fn get_file(&self, idx: u32) -> Option<&str> {
if idx == 0 {
None
} else {
Some(&self.files[(idx - 1) as usize])
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct FunctionInfo {
pub start_address: u64,
pub end_address: u64,
pub name_idx: u32,
pub file_idx: u32,
pub line: u32,
}
const INLINED_BUCKET_SHIFT: u32 = 12; const INLINED_BUCKET_SIZE: u64 = 1 << INLINED_BUCKET_SHIFT;
#[derive(Debug)]
pub struct FunctionIndex {
functions: Vec<FunctionInfo>,
inlined: Vec<FunctionInfo>,
inlined_buckets: HashMap<u64, Vec<usize>>,
strings: StringTables,
}
impl FunctionIndex {
#[inline]
fn bucket_id(addr: u64) -> u64 {
addr >> INLINED_BUCKET_SHIFT
}
pub fn new(mut functions: Vec<FunctionInfo>, strings: StringTables) -> Self {
functions.sort_by_key(|f| f.start_address);
Self {
functions,
inlined: Vec::new(),
inlined_buckets: HashMap::new(),
strings,
}
}
pub fn new_with_inlined(
mut functions: Vec<FunctionInfo>,
inlined: Vec<FunctionInfo>,
strings: StringTables,
) -> Self {
functions.sort_by_key(|f| f.start_address);
let mut inlined_buckets: HashMap<u64, Vec<usize>> = HashMap::new();
for (idx, func) in inlined.iter().enumerate() {
let start_bucket = Self::bucket_id(func.start_address);
let end_bucket = Self::bucket_id(func.end_address.saturating_sub(1));
for bucket in start_bucket..=end_bucket {
inlined_buckets.entry(bucket).or_default().push(idx);
}
}
Self {
functions,
inlined,
inlined_buckets,
strings,
}
}
#[inline]
pub fn get_name(&self, func: &FunctionInfo) -> &str {
self.strings.get_name(func.name_idx)
}
#[inline]
pub fn get_file(&self, func: &FunctionInfo) -> Option<&str> {
self.strings.get_file(func.file_idx)
}
#[inline]
pub fn get_line(&self, func: &FunctionInfo) -> Option<u32> {
if func.line == 0 {
None
} else {
Some(func.line)
}
}
pub fn strings(&self) -> &StringTables {
&self.strings
}
#[inline]
pub fn find_containing(&self, addr: u64) -> Option<&FunctionInfo> {
if self.functions.is_empty() {
return None;
}
let idx = match self
.functions
.binary_search_by_key(&addr, |f| f.start_address)
{
Ok(i) => i, Err(0) => return None, Err(i) => i - 1, };
let func = &self.functions[idx];
if addr >= func.start_address && addr < func.end_address {
Some(func)
} else {
None
}
}
#[inline]
pub fn find_function_name(&self, addr: u64) -> Option<&str> {
if let Some(inlined) = self.find_in_inlined(addr) {
return Some(self.strings.get_name(inlined.name_idx));
}
self.find_containing(addr)
.map(|f| self.strings.get_name(f.name_idx))
}
#[inline]
fn find_in_inlined(&self, addr: u64) -> Option<&FunctionInfo> {
let bucket = Self::bucket_id(addr);
let indices = self.inlined_buckets.get(&bucket)?;
let mut best: Option<&FunctionInfo> = None;
let mut best_size: u64 = u64::MAX;
for &idx in indices {
let func = &self.inlined[idx];
if addr >= func.start_address && addr < func.end_address {
let size = func.end_address - func.start_address;
if size < best_size {
best = Some(func);
best_size = size;
}
}
}
best
}
pub fn functions(&self) -> &[FunctionInfo] {
&self.functions
}
}
#[derive(Debug, Clone)]
pub struct CallerInfo<'a> {
pub caller_name: Cow<'a, str>,
pub caller_start_address: u64,
pub caller_file: Option<String>,
pub call_site_addr: u64,
pub file: Option<String>,
pub line: Option<u32>,
pub column: Option<u32>,
}
#[self_referencing]
pub struct DSymInfo {
pub debug_buffer: Vec<u8>,
#[borrows(debug_buffer)]
#[covariant]
pub debug_macho: Mach<'this>,
}
#[derive(Debug)]
pub struct ObjectFileInfo {
pub path: PathBuf,
pub buffer: Vec<u8>,
pub addr_map: HashMap<u64, u64>,
}
pub struct DebugMapInfo {
pub object_files: Vec<ObjectFileInfo>,
}
pub enum DebugInfo {
Embedded,
DSym(Box<DSymInfo>),
DebugMap(Box<DebugMapInfo>),
None,
}
impl DebugInfo {
pub fn debug_macho(&self) -> Option<&Mach<'_>> {
match self {
DebugInfo::DSym(info) => Some(info.borrow_debug_macho()),
_ => None,
}
}
pub fn debug_buffer(&self) -> Option<&[u8]> {
match self {
DebugInfo::DSym(info) => Some(info.borrow_debug_buffer()),
_ => None,
}
}
}
pub fn read_symbols(buffer: &'_ [u8]) -> io::Result<SymbolTable<'_>> {
match Object::parse(buffer).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))? {
Object::Mach(mach) => Ok(SymbolTable::MachO(mach)),
Object::Archive(archive) => Ok(SymbolTable::Archive(archive)),
Object::Elf(_) => Err(io::Error::new(
io::ErrorKind::InvalidData,
"ELF format not supported (macOS only)",
)),
Object::PE(_) => Err(io::Error::new(
io::ErrorKind::InvalidData,
"PE format not supported (macOS only)",
)),
Object::COFF(_) => Err(io::Error::new(
io::ErrorKind::InvalidData,
"COFF format not supported (macOS only)",
)),
_ => Err(io::Error::new(
io::ErrorKind::InvalidData,
"Unknown binary format",
)),
}
}
pub(crate) fn has_dwarf_info(macho: &MachO) -> bool {
for segment in macho.segments.iter() {
if let Ok(name) = segment.name()
&& name == "__DWARF"
{
return true;
}
if let Ok(sections) = segment.sections() {
for (section, _) in sections {
if let Ok(name) = section.name()
&& name.starts_with("__debug_")
{
return true;
}
}
}
}
false
}
pub fn find_symbol_containing(
macho: &MachO,
pattern: &str,
) -> Result<Option<(String, String)>, regex::Error> {
let regex = Regex::new(pattern)?;
let symbols = match macho.symbols.as_ref() {
Some(s) => s,
None => return Ok(None),
};
for (sym_name, _) in symbols.iter().flatten() {
let stripped = sym_name.strip_prefix("_").unwrap_or(sym_name);
let demangled = format!("{:#}", demangle(stripped));
if regex.is_match(&demangled) {
return Ok(Some((sym_name.to_string(), demangled)));
}
}
Ok(None)
}
pub fn find_all_symbols_matching(
macho: &MachO,
patterns: &[&str],
) -> Result<Vec<(String, String)>, regex::Error> {
let regexes: Vec<Regex> = patterns
.iter()
.map(|p| Regex::new(p))
.collect::<Result<Vec<_>, _>>()?;
let mut results = Vec::new();
let symbols = match macho.symbols.as_ref() {
Some(s) => s,
None => return Ok(results),
};
for (sym_name, _) in symbols.iter().flatten() {
let stripped = sym_name.strip_prefix("_").unwrap_or(sym_name);
let demangled = format!("{:#}", demangle(stripped));
for regex in ®exes {
if regex.is_match(&demangled) {
results.push((sym_name.to_string(), demangled.clone()));
break; }
}
}
Ok(results)
}
pub fn find_symbol_address(macho: &MachO, name: &str) -> Option<u64> {
let symbols = macho.symbols.as_ref()?;
for symbol in symbols.iter() {
if let Ok((sym_name, nlist)) = symbol
&& sym_name == name
&& !nlist.is_undefined()
&& nlist.n_value != 0
{
return Some(nlist.n_value);
}
}
None
}
fn get_text_section<'a>(macho: &MachO, buffer: &'a [u8]) -> Option<(u64, &'a [u8])> {
get_section_by_name(macho, buffer, "__text")
}
fn get_section_by_name<'a>(macho: &MachO, buffer: &'a [u8], name: &str) -> Option<(u64, &'a [u8])> {
for segment in &macho.segments {
for (section, section_data) in segment.sections().unwrap() {
if section.name().unwrap() == name {
let offset = section.offset as usize;
let size = section.size as usize;
return Some((section.addr, &buffer[offset..offset + size]));
}
}
}
None
}
fn find_segment<'a>(macho: &'a MachO, segment_name: &str) -> Option<&'a Segment<'a>> {
for segment in macho.segments.iter() {
if let Ok(name) = segment.name()
&& name == segment_name
{
return Some(segment);
}
}
None
}
struct SymbolEntry {
address: u64,
mangled: String,
demangled: std::sync::OnceLock<String>,
}
impl SymbolEntry {
fn new(address: u64, mangled: String) -> Self {
Self {
address,
mangled,
demangled: std::sync::OnceLock::new(),
}
}
fn demangled(&self) -> &str {
self.demangled
.get_or_init(|| format!("{:#}", demangle(&self.mangled)))
}
}
impl std::fmt::Debug for SymbolEntry {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SymbolEntry")
.field("address", &self.address)
.field("mangled", &self.mangled)
.field("demangled", &self.demangled.get())
.finish()
}
}
#[derive(Debug)]
pub struct SymbolIndex {
entries: Vec<SymbolEntry>,
}
impl SymbolIndex {
pub fn new(macho: &MachO) -> Option<Self> {
use rayon::prelude::*;
let symbols = macho.symbols.as_ref()?;
let raw_symbols: Vec<(u64, &str)> = symbols
.iter()
.filter_map(|s| s.ok())
.filter(|(name, nlist)| !nlist.is_undefined() && !name.is_empty())
.map(|(name, nlist)| (nlist.n_value, name))
.collect();
let mut entries: Vec<SymbolEntry> = raw_symbols
.par_iter()
.map(|(addr, name)| {
let stripped = name.strip_prefix("_").unwrap_or(name);
SymbolEntry::new(*addr, stripped.to_string())
})
.collect();
entries.par_sort_by_key(|e| e.address);
Some(Self { entries })
}
pub fn find_containing(&self, addr: u64) -> Option<(u64, &str)> {
match self.entries.binary_search_by_key(&addr, |e| e.address) {
Ok(i) => Some((self.entries[i].address, self.entries[i].demangled())),
Err(0) => None, Err(i) => Some((self.entries[i - 1].address, self.entries[i - 1].demangled())),
}
}
}
fn find_sections<'a>(macho: &'a MachO, section_name: &str) -> Vec<(Section, SectionData<'a>)> {
macho
.segments
.iter()
.filter_map(|segment| segment.sections().ok())
.flatten()
.filter_map(move |(section, data)| {
if section.name().unwrap() == section_name {
Some((section, data))
} else {
None
}
})
.collect()
}
pub struct CallGraph<'a> {
edges: HashMap<u64, Vec<CallerInfo<'a>>>,
}
struct InsnData {
address: u64,
call_target: Option<u64>,
}
const ARM64_INSN_SIZE: usize = 4;
const MIN_CHUNK_SIZE: usize = 64 * 1024;
const BL_MASK: u32 = 0xFC000000;
const BL_OPCODE: u32 = 0x94000000;
const B_MASK: u32 = 0xFC000000;
const B_OPCODE: u32 = 0x14000000;
fn decode_branch_target(insn_bytes: u32, pc: u64) -> u64 {
let imm26 = insn_bytes & 0x03FFFFFF;
let offset = ((imm26 as i32) << 6) >> 4;
(pc as i64 + offset as i64) as u64
}
fn parallel_disassemble_arm64(text_data: &[u8], text_addr: u64) -> Vec<InsnData> {
let num_threads = rayon::current_num_threads();
let ideal_chunk_size = text_data.len() / num_threads;
let chunk_size = if ideal_chunk_size < MIN_CHUNK_SIZE {
text_data.len()
} else {
(ideal_chunk_size / ARM64_INSN_SIZE) * ARM64_INSN_SIZE
};
if chunk_size >= text_data.len() {
return scan_branch_instructions(text_data, text_addr);
}
let chunks: Vec<(usize, &[u8], u64)> = text_data
.chunks(chunk_size)
.enumerate()
.map(|(i, chunk)| {
let chunk_addr = text_addr + (i * chunk_size) as u64;
(i, chunk, chunk_addr)
})
.collect();
let results: Vec<Vec<InsnData>> = chunks
.par_iter()
.map(|(_i, chunk, chunk_addr)| scan_branch_instructions(chunk, *chunk_addr))
.collect();
results.into_iter().flatten().collect()
}
fn scan_branch_instructions(data: &[u8], base_addr: u64) -> Vec<InsnData> {
data.chunks_exact(ARM64_INSN_SIZE)
.enumerate()
.filter_map(|(i, bytes)| {
let insn = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
let is_bl = (insn & BL_MASK) == BL_OPCODE;
let is_b = (insn & B_MASK) == B_OPCODE;
if is_bl || is_b {
let pc = base_addr + (i * ARM64_INSN_SIZE) as u64;
let target = decode_branch_target(insn, pc);
Some(InsnData {
address: pc,
call_target: Some(target),
})
} else {
None
}
})
.collect()
}
#[allow(dead_code)]
fn sequential_disassemble_arm64(text_data: &[u8], text_addr: u64) -> Vec<InsnData> {
let Ok(cs) = Capstone::new()
.arm64()
.mode(arch::arm64::ArchMode::Arm)
.build()
else {
eprintln!("Warning: failed to initialize Capstone disassembler");
return Vec::new();
};
let Ok(instructions) = cs.disasm_all(text_data, text_addr) else {
eprintln!("Warning: disassembly failed for text section at {text_addr:#x}");
return Vec::new();
};
instructions
.iter()
.filter_map(|insn| {
let mnemonic = insn.mnemonic();
if mnemonic == Some("bl") || mnemonic == Some("b") {
let operand = insn.op_str()?;
let addr_str = operand.trim_start_matches("#0x");
let call_target = u64::from_str_radix(addr_str, 16).ok();
Some(InsnData {
address: insn.address(),
call_target,
})
} else {
None
}
})
.collect()
}
impl<'a> CallGraph<'a> {
pub fn build(
macho: &MachO,
buffer: &[u8],
symbol_index: Option<&'a SymbolIndex>,
) -> Result<Self, Box<dyn std::error::Error>> {
let Some((text_addr, text_data)) = get_text_section(macho, buffer) else {
return Ok(Self {
edges: HashMap::new(),
});
};
#[cfg(target_arch = "aarch64")]
let insn_data = parallel_disassemble_arm64(text_data, text_addr);
#[cfg(not(target_arch = "aarch64"))]
let insn_data = sequential_disassemble_arm64(text_data, text_addr);
let edges: DashMap<u64, Vec<CallerInfo<'a>>> = DashMap::new();
insn_data.par_iter().for_each(|data| {
if let Some(call_target) = data.call_target
&& let Some((func_addr, func_name)) =
symbol_index.and_then(|idx| idx.find_containing(data.address))
{
edges.entry(call_target).or_default().push(CallerInfo {
caller_name: Cow::Borrowed(func_name), caller_start_address: func_addr,
caller_file: None,
call_site_addr: data.address,
file: None,
line: None,
column: None,
});
}
});
let mut edges: HashMap<u64, Vec<CallerInfo<'a>>> = edges.into_iter().collect();
for callers in edges.values_mut() {
callers.sort_by_key(|c| c.call_site_addr);
}
Ok(Self { edges })
}
#[allow(dead_code)]
pub fn build_sequential(
macho: &MachO,
buffer: &[u8],
symbol_index: Option<&'a SymbolIndex>,
) -> Result<Self, Box<dyn std::error::Error>> {
let mut edges: HashMap<u64, Vec<CallerInfo<'a>>> = HashMap::new();
let Some((text_addr, text_data)) = get_text_section(macho, buffer) else {
return Ok(Self { edges });
};
let cs = Capstone::new()
.arm64()
.mode(arch::arm64::ArchMode::Arm)
.build()?;
let instructions = cs
.disasm_all(text_data, text_addr)
.map_err(|e| format!("Disassembly failed: {e}"))?;
for instruction in instructions.iter() {
if let Some((call_target, caller_info)) =
process_instruction_basic(symbol_index, instruction)
{
edges.entry(call_target).or_default().push(caller_info);
}
}
Ok(Self { edges })
}
pub fn build_with_debug_info(
binary_macho: &MachO,
binary_buffer: &[u8],
debug_macho: &MachO,
debug_buffer: &[u8],
crate_src_path: Option<&str>,
show_timings: bool,
symbol_index: Option<&'a SymbolIndex>,
) -> Result<Self, Box<dyn std::error::Error>> {
use std::time::Instant;
let step = Instant::now();
let (functions, inlined, strings) = get_functions_from_dwarf(debug_macho, debug_buffer)?;
let num_functions = functions.len();
let num_inlined = inlined.len();
let function_index = FunctionIndex::new_with_inlined(functions, inlined, strings);
if show_timings {
eprintln!(
" [cg timing] get_functions_from_dwarf: {:?} ({} functions, {} inlined)",
step.elapsed(),
num_functions,
num_inlined
);
}
let step = Instant::now();
let dwarf = load_dwarf_sections(debug_macho, debug_buffer)?;
if show_timings {
eprintln!(" [cg timing] load_dwarf_sections: {:?}", step.elapsed());
}
let Some((text_addr, text_data)) = get_text_section(binary_macho, binary_buffer) else {
return Ok(Self {
edges: HashMap::new(),
});
};
if show_timings {
eprintln!(
" [cg timing] text section size: {} bytes",
text_data.len()
);
}
let step = Instant::now();
#[cfg(target_arch = "aarch64")]
let insn_data = parallel_disassemble_arm64(text_data, text_addr);
#[cfg(not(target_arch = "aarch64"))]
let insn_data = sequential_disassemble_arm64(text_data, text_addr);
if show_timings {
eprintln!(
" [cg timing] scan for branch instructions: {:?} ({} found)",
step.elapsed(),
insn_data.len()
);
}
let step = Instant::now();
let (crate_line_table, full_line_table) = if let Some(path) = crate_src_path {
let (crate_table, full_table) = FullLineTable::build_both(&dwarf, path)?;
(Some(crate_table), full_table)
} else {
(None, FullLineTable::build(&dwarf)?)
};
if show_timings {
eprintln!(
" [cg timing] build line tables: {:?} (crate: {} entries, full: {} entries)",
step.elapsed(),
crate_line_table.as_ref().map(|t| t.len()).unwrap_or(0),
full_line_table.len()
);
}
let step = Instant::now();
let edges: DashMap<u64, Vec<CallerInfo<'a>>> = DashMap::new();
insn_data.par_iter().for_each(|data| {
if let Some(call_target) = data.call_target
&& let Some((target, caller_info)) = process_instruction_data_with_crate_table(
data,
&function_index,
crate_src_path,
&full_line_table,
crate_line_table.as_ref(),
symbol_index,
)
{
edges.entry(target).or_default().push(caller_info);
}
});
if show_timings {
eprintln!(" [cg timing] process instructions: {:?}", step.elapsed(),);
}
let mut edges: HashMap<u64, Vec<CallerInfo<'a>>> = edges.into_iter().collect();
for callers in edges.values_mut() {
callers.sort_by_key(|c| c.call_site_addr);
}
Ok(Self { edges })
}
pub fn get_callers(&self, target_addr: u64) -> &[CallerInfo<'a>] {
self.edges
.get(&target_addr)
.map(|v| v.as_slice())
.unwrap_or(&[])
}
pub fn empty() -> Self {
Self {
edges: HashMap::new(),
}
}
}
const ARM64_RELOC_BRANCH26: u8 = 2;
#[derive(Debug, Clone)]
struct ObjectLineEntry {
address: u64,
file: String,
line: u32,
column: Option<u32>,
}
struct ObjectLineTable {
entries: Vec<ObjectLineEntry>,
}
impl ObjectLineTable {
fn build<R: Reader>(dwarf: &Dwarf<R>) -> Result<Self, gimli::Error> {
let mut entries = Vec::new();
let mut units = dwarf.units();
while let Some(header) = units.next()? {
let unit = dwarf.unit(header)?;
if let Some(program) = &unit.line_program {
let header = program.header();
let mut file_paths: Vec<String> = Vec::new();
file_paths.push(String::new());
for file_entry in header.file_names() {
let file_name = dwarf
.attr_string(&unit, file_entry.path_name())?
.to_string_lossy()?
.into_owned();
let full_path = if let Some(dir) = file_entry.directory(header) {
let dir_str = dwarf
.attr_string(&unit, dir)?
.to_string_lossy()?
.into_owned();
if dir_str.is_empty() {
file_name
} else {
format!("{}/{}", dir_str, file_name)
}
} else {
file_name
};
file_paths.push(full_path);
}
let mut rows = program.clone().rows();
while let Some((_, row)) = rows.next_row()? {
let file_idx = row.file_index() as usize;
if let Some(line) = row.line() {
if file_idx < file_paths.len() && file_idx > 0 {
let column = match row.column() {
ColumnType::LeftEdge => None,
ColumnType::Column(c) => Some(c.get() as u32),
};
entries.push(ObjectLineEntry {
address: row.address(),
file: file_paths[file_idx].clone(),
line: line.get() as u32,
column,
});
}
}
}
}
}
entries.sort_by_key(|e| e.address);
Ok(Self { entries })
}
fn lookup(&self, address: u64) -> Option<(Option<String>, Option<u32>, Option<u32>)> {
if self.entries.is_empty() {
return None;
}
let idx = match self.entries.binary_search_by_key(&address, |e| e.address) {
Ok(i) => i,
Err(0) => return None, Err(i) => i - 1, };
let entry = &self.entries[idx];
Some((Some(entry.file.clone()), Some(entry.line), entry.column))
}
fn get_crate_line_in_range(
&self,
func_start: u64,
call_site_addr: u64,
crate_src_path: &str,
) -> Option<(String, u32, Option<u32>)> {
if self.entries.is_empty() {
return None;
}
let start_idx = self.entries.partition_point(|e| e.address < func_start);
let end_idx = self
.entries
.partition_point(|e| e.address <= call_site_addr);
for i in (start_idx..end_idx).rev() {
let entry = &self.entries[i];
if matches_crate_pattern(&entry.file, crate_src_path) {
return Some((entry.file.clone(), entry.line, entry.column));
}
}
None
}
}
pub struct LibraryCallGraph {
edges: HashMap<String, Vec<CallerInfo<'static>>>,
}
impl LibraryCallGraph {
pub fn build_from_object(
macho: &MachO,
buffer: &[u8],
crate_src_path: Option<&str>,
) -> Result<Self, Box<dyn std::error::Error>> {
let mut edges: HashMap<String, Vec<CallerInfo<'static>>> = HashMap::new();
let symbols: Vec<(String, u64)> = macho
.symbols
.as_ref()
.map(|s| {
s.iter()
.filter_map(|sym| {
let (name, nlist) = sym.ok()?;
Some((name.to_string(), nlist.n_value))
})
.collect()
})
.unwrap_or_default();
let symbol_index = SymbolIndex::new(macho);
let dwarf = load_dwarf_sections(macho, buffer).ok();
let line_lookup = dwarf.as_ref().and_then(|d| ObjectLineTable::build(d).ok());
let container = if macho.is_64 {
Container::Big
} else {
Container::Little
};
let endian = if macho.little_endian {
Endian::Little
} else {
Endian::Big
};
let ctx = Ctx::new(container, endian);
for segment in macho.segments.iter() {
if let Ok(sections) = segment.sections() {
for (section, _data) in sections {
let section_name = section.name().unwrap_or("");
if section_name != "__text" {
continue;
}
let section_addr = section.addr;
for reloc in section.iter_relocations(buffer, ctx) {
let Ok(reloc_info) = reloc else {
continue;
};
if reloc_info.r_type() != ARM64_RELOC_BRANCH26 {
continue;
}
if !reloc_info.is_extern() {
continue;
}
let sym_index = reloc_info.r_symbolnum();
let Some((target_sym_name, _)) = symbols.get(sym_index) else {
continue;
};
let call_site_addr = section_addr + reloc_info.r_address as u64;
let Some((func_addr, func_name)) = symbol_index
.as_ref()
.and_then(|idx| idx.find_containing(call_site_addr))
else {
continue;
};
let func_name = func_name.to_string();
let target_demangled = {
let stripped =
target_sym_name.strip_prefix("_").unwrap_or(target_sym_name);
format!("{:#}", demangle(stripped))
};
let (file, line, column) = line_lookup
.as_ref()
.and_then(|lt| lt.lookup(call_site_addr))
.unwrap_or((None, None, None));
let (file, line, column) = if file
.as_ref()
.is_some_and(|f| is_dependency_path(f))
{
if let Some(crate_path) = crate_src_path
&& let Some(lt) = line_lookup.as_ref()
&& let Some((crate_file, crate_line, crate_col)) = lt
.get_crate_line_in_range(func_addr, call_site_addr, crate_path)
{
(Some(crate_file), Some(crate_line), crate_col)
} else {
line_lookup
.as_ref()
.and_then(|lt| lt.lookup(func_addr))
.unwrap_or((None, None, None))
}
} else {
(file, line, column)
};
edges.entry(target_demangled).or_default().push(CallerInfo {
caller_name: Cow::Owned(func_name),
caller_start_address: func_addr,
caller_file: file.clone(),
call_site_addr,
file,
line,
column,
});
}
}
}
}
Ok(Self { edges })
}
pub fn merge(&mut self, other: Self) {
for (target, callers) in other.edges {
self.edges.entry(target).or_default().extend(callers);
}
}
pub fn get_callers(&self, symbol_name: &str) -> Vec<CallerInfo<'static>> {
self.edges.get(symbol_name).cloned().unwrap_or_default()
}
pub fn get_callers_matching(&self, pattern: &Regex) -> Vec<(&str, &[CallerInfo<'static>])> {
self.edges
.iter()
.filter(|(name, _)| pattern.is_match(name))
.map(|(name, callers)| (name.as_str(), callers.as_slice()))
.collect()
}
pub fn target_symbols(&self) -> impl Iterator<Item = &str> {
self.edges.keys().map(|s| s.as_str())
}
pub fn is_empty(&self) -> bool {
self.edges.is_empty()
}
pub fn empty() -> Self {
Self {
edges: HashMap::new(),
}
}
}
fn process_instruction_basic<'a>(
symbol_index: Option<&'a SymbolIndex>,
instruction: &Insn,
) -> Option<(u64, CallerInfo<'a>)> {
let mnemonic = instruction.mnemonic();
if mnemonic != Some("bl") && mnemonic != Some("b") {
return None;
}
let operand = instruction.op_str()?;
let addr_str = operand.trim_start_matches("#0x");
let call_target = u64::from_str_radix(addr_str, 16).ok()?;
let (func_addr, func_name) =
symbol_index.and_then(|idx| idx.find_containing(instruction.address()))?;
Some((
call_target,
CallerInfo {
caller_name: Cow::Borrowed(func_name),
caller_start_address: func_addr,
caller_file: None,
call_site_addr: instruction.address(),
file: None,
line: None,
column: None,
},
))
}
fn process_instruction_data_with_crate_table<'a>(
data: &InsnData,
function_index: &FunctionIndex,
crate_src_path: Option<&str>,
full_line_table: &FullLineTable,
crate_line_table: Option<&CrateLineTable>,
symbol_index: Option<&'a SymbolIndex>,
) -> Option<(u64, CallerInfo<'a>)> {
let call_target = data.call_target?;
if let Some(func) = function_index.find_containing(data.address) {
let func_file = function_index.get_file(func).map(|s| s.to_string());
let func_line = function_index.get_line(func);
let caller_file = func_file.clone();
let (file, mut line, mut column) = match (func_file, func_line) {
(Some(f), Some(l)) => {
(Some(f), Some(l), None)
}
(func_file, func_line) => {
let (line_file, line_line, line_column) =
full_line_table.get_source_location(func.start_address);
(
func_file.or(line_file),
func_line.or(line_line),
line_column,
)
}
};
let file_in_crate = file.as_ref().is_some_and(|f| {
crate_src_path.is_some_and(|crate_path| matches_crate_pattern(f, crate_path))
});
if file_in_crate {
if let Some(table) = crate_line_table {
let (crate_line, crate_column) = table.get_line(func.start_address, data.address);
if crate_line.is_some() {
if crate_line == func_line {
if let Some(crate_path) = crate_src_path {
let (full_line, full_column) = full_line_table.get_nearest_crate_line(
data.address,
func.start_address,
func.end_address,
func_line,
crate_path,
);
if full_line.is_some() && full_line != func_line {
line = full_line;
column = full_column;
} else {
line = crate_line;
column = crate_column;
}
} else {
line = crate_line;
column = crate_column;
}
} else {
line = crate_line;
column = crate_column;
}
}
}
}
let func_name = function_index.get_name(func);
let display_name: Cow<'a, str> = if file_in_crate {
Cow::Owned(
function_index
.find_function_name(data.address)
.map(|s| {
let stripped = s.strip_prefix('_').unwrap_or(s);
format!("{:#}", demangle(stripped))
})
.unwrap_or_else(|| func_name.to_string()),
)
} else {
Cow::Owned(func_name.to_string())
};
Some((
call_target,
CallerInfo {
caller_name: display_name,
caller_start_address: func.start_address,
caller_file,
call_site_addr: data.address,
file,
line,
column,
},
))
} else if let Some((func_addr, func_name)) =
symbol_index.and_then(|idx| idx.find_containing(data.address))
{
Some((
call_target,
CallerInfo {
caller_name: Cow::Borrowed(func_name), caller_start_address: func_addr,
caller_file: None,
call_site_addr: data.address,
file: None,
line: None,
column: None,
},
))
} else {
None
}
}
pub(crate) fn find_callers(
macho: &MachO,
buffer: &[u8],
target_addr: u64,
) -> Result<Vec<CallerInfo<'static>>, Box<dyn std::error::Error>> {
let mut callers = Vec::new();
let Some((text_addr, text_data)) = get_text_section(macho, buffer) else {
return Ok(callers);
};
let cs = Capstone::new()
.arm64()
.mode(arch::arm64::ArchMode::Arm)
.build()?;
let Ok(instructions) = cs.disasm_all(text_data, text_addr) else {
return Ok(callers);
};
let symbol_index = SymbolIndex::new(macho);
for instruction in instructions.iter() {
let mnemonic = instruction.mnemonic();
if (mnemonic == Some("bl") || mnemonic == Some("b"))
&& let Some(operand) = instruction.op_str()
{
let addr_str = operand.trim_start_matches("#0x");
if let Ok(call_target) = u64::from_str_radix(addr_str, 16)
&& call_target == target_addr
&& let Some((func_addr, func_name)) = symbol_index
.as_ref()
.and_then(|idx| idx.find_containing(instruction.address()))
{
callers.push(CallerInfo {
caller_name: Cow::Owned(func_name.to_string()),
caller_start_address: func_addr,
caller_file: None,
call_site_addr: instruction.address(),
file: None,
line: None,
column: None,
});
}
}
}
Ok(callers)
}
fn load_dwarf_sections<'a>(
macho: &'a MachO,
buffer: &'a [u8],
) -> Result<Dwarf<DwarfReader<'a>>, gimli::Error> {
let endian = if macho.little_endian {
RunTimeEndian::Little
} else {
RunTimeEndian::Big
};
let find_section = |name: &str| -> Option<&'a [u8]> {
for segment in macho.segments.iter() {
if let Ok(sections) = segment.sections() {
for (section, _) in sections {
if let Ok(sect_name) = section.name() {
let macho_name = format!("__{}", &name[1..]);
if sect_name == macho_name {
let start = section.offset as usize;
let end = start + section.size as usize;
return Some(&buffer[start..end]);
}
}
}
}
}
None
};
let load_section = |id: SectionId| -> Result<DwarfReader<'a>, gimli::Error> {
let data = find_section(id.name()).unwrap_or(&[]);
Ok(EndianSlice::new(data, endian))
};
Dwarf::load(&load_section)
}
struct ParsedFunctionInfo {
name: String,
start_address: u64,
end_address: u64,
file: Option<String>,
line: Option<u32>,
}
pub fn get_functions_from_dwarf<'a>(
macho: &'a MachO,
buffer: &'a [u8],
) -> Result<DwarfFunctionResult, Box<dyn std::error::Error>> {
let dwarf = load_dwarf_sections(macho, buffer)?;
let mut headers = Vec::new();
let mut units_iter = dwarf.units();
while let Some(header) = units_iter.next()? {
headers.push(header);
}
let results: Vec<_> = headers
.into_par_iter()
.filter_map(|header| {
let unit = dwarf.unit(header).ok()?;
let mut funcs = Vec::new();
let mut inl = Vec::new();
let mut entries = unit.entries();
while let Some((_, entry)) = entries.next_dfs().ok()? {
match entry.tag() {
gimli::DW_TAG_subprogram => {
if let Ok(Some(func)) = parse_function_die(&dwarf, &unit, entry) {
funcs.push(func);
}
}
gimli::DW_TAG_inlined_subroutine => {
if let Ok(Some(func)) = parse_inlined_subroutine(&dwarf, &unit, entry) {
inl.push(func);
}
}
_ => {}
}
}
Some((funcs, inl))
})
.collect();
let mut strings = StringTables::new();
let mut functions = Vec::new();
let mut inlined = Vec::new();
for (funcs, inl) in results {
for parsed in funcs {
functions.push(FunctionInfo {
start_address: parsed.start_address,
end_address: parsed.end_address,
name_idx: strings.intern_name(parsed.name),
file_idx: strings.intern_file(parsed.file),
line: parsed.line.unwrap_or(0),
});
}
for parsed in inl {
inlined.push(FunctionInfo {
start_address: parsed.start_address,
end_address: parsed.end_address,
name_idx: strings.intern_name(parsed.name),
file_idx: strings.intern_file(parsed.file),
line: parsed.line.unwrap_or(0),
});
}
}
Ok((functions, inlined, strings))
}
fn parse_function_die<R: Reader>(
dwarf: &Dwarf<R>,
unit: &Unit<R>,
entry: &DebuggingInformationEntry<R>,
) -> Result<Option<ParsedFunctionInfo>, gimli::Error> {
let mut name: Option<String> = None;
let mut has_linkage_name = false;
let mut low_pc: Option<u64> = None;
let mut high_pc: Option<u64> = None;
let mut high_pc_is_offset = false;
let mut file: Option<String> = None;
let mut line: Option<u32> = None;
let mut specification: Option<gimli::UnitOffset<R::Offset>> = None;
let mut attrs = entry.attrs();
while let Some(attr) = attrs.next()? {
match attr.name() {
gimli::DW_AT_name => {
if !has_linkage_name {
if let Ok(s) = dwarf.attr_string(unit, attr.value()) {
name = Some(s.to_string_lossy()?.into_owned());
}
}
}
gimli::DW_AT_linkage_name | gimli::DW_AT_MIPS_linkage_name => {
if let Ok(s) = dwarf.attr_string(unit, attr.value()) {
let mangled = s.to_string_lossy()?.into_owned();
let stripped = mangled.strip_prefix('_').unwrap_or(&mangled);
name = Some(format!("{:#}", demangle(stripped)));
has_linkage_name = true;
}
}
gimli::DW_AT_low_pc => {
if let AttributeValue::Addr(addr) = attr.value() {
low_pc = Some(addr);
}
}
gimli::DW_AT_high_pc => match attr.value() {
AttributeValue::Addr(addr) => {
high_pc = Some(addr);
}
AttributeValue::Udata(offset) => {
high_pc = Some(offset);
high_pc_is_offset = true;
}
_ => {}
},
gimli::DW_AT_decl_file => {
if let AttributeValue::FileIndex(idx) = attr.value() {
file = resolve_decl_file(dwarf, unit, idx)?;
}
}
gimli::DW_AT_decl_line => {
if let AttributeValue::Udata(l) = attr.value() {
line = Some(l as u32);
}
}
gimli::DW_AT_specification => {
if let AttributeValue::UnitRef(offset) = attr.value() {
specification = Some(offset);
}
}
_ => {}
}
}
if let Some(spec_offset) = specification {
if name.is_none() || file.is_none() || line.is_none() {
let (spec_name, spec_file, spec_line) =
resolve_specification(dwarf, unit, spec_offset)?;
if name.is_none() {
name = spec_name;
}
if file.is_none() {
file = spec_file;
}
if line.is_none() {
line = spec_line;
}
}
}
let high_pc = match (low_pc, high_pc, high_pc_is_offset) {
(Some(low), Some(high), true) => Some(low + high),
(_, high, false) => high,
_ => None,
};
match (name, low_pc, high_pc) {
(Some(name), Some(low_pc), Some(high_pc)) => Ok(Some(ParsedFunctionInfo {
name,
start_address: low_pc,
end_address: high_pc,
file,
line,
})),
_ => Ok(None),
}
}
fn parse_inlined_subroutine<R: Reader<Offset = usize>>(
dwarf: &Dwarf<R>,
unit: &Unit<R>,
entry: &DebuggingInformationEntry<R>,
) -> Result<Option<ParsedFunctionInfo>, gimli::Error> {
let mut abstract_origin: Option<gimli::UnitOffset<usize>> = None;
let mut low_pc: Option<u64> = None;
let mut high_pc: Option<u64> = None;
let mut high_pc_is_offset = false;
let mut ranges_attr: Option<AttributeValue<R>> = None;
let mut attrs = entry.attrs();
while let Some(attr) = attrs.next()? {
match attr.name() {
gimli::DW_AT_abstract_origin => {
if let AttributeValue::UnitRef(offset) = attr.value() {
abstract_origin = Some(offset);
}
}
gimli::DW_AT_low_pc => {
if let AttributeValue::Addr(addr) = attr.value() {
low_pc = Some(addr);
}
}
gimli::DW_AT_high_pc => match attr.value() {
AttributeValue::Addr(addr) => {
high_pc = Some(addr);
}
AttributeValue::Udata(offset) => {
high_pc = Some(offset);
high_pc_is_offset = true;
}
_ => {}
},
gimli::DW_AT_ranges => {
ranges_attr = Some(attr.value());
}
_ => {}
}
}
let high_pc = match (low_pc, high_pc, high_pc_is_offset) {
(Some(low), Some(high), true) => Some(low + high),
(_, high, false) => high,
_ => None,
};
let (final_low_pc, final_high_pc) = if low_pc.is_some() && high_pc.is_some() {
(low_pc, high_pc)
} else if let Some(ranges_value) = ranges_attr {
if let Ok(Some(mut ranges)) = dwarf.attr_ranges(unit, ranges_value) {
if let Ok(Some(range)) = ranges.next() {
(Some(range.begin), Some(range.end))
} else {
(None, None)
}
} else {
(None, None)
}
} else {
(None, None)
};
let name = if let Some(offset) = abstract_origin {
resolve_abstract_origin_name(dwarf, unit, offset)?
} else {
None
};
match (name, final_low_pc, final_high_pc) {
(Some(name), Some(low_pc), Some(high_pc)) => Ok(Some(ParsedFunctionInfo {
name,
start_address: low_pc,
end_address: high_pc,
file: None,
line: None,
})),
_ => Ok(None),
}
}
fn resolve_abstract_origin_name<R: Reader>(
dwarf: &Dwarf<R>,
unit: &Unit<R>,
offset: gimli::UnitOffset<R::Offset>,
) -> Result<Option<String>, gimli::Error> {
let entry = unit.entry(offset)?;
let mut name: Option<String> = None;
let mut attrs = entry.attrs();
while let Some(attr) = attrs.next()? {
match attr.name() {
gimli::DW_AT_linkage_name | gimli::DW_AT_MIPS_linkage_name => {
if let Ok(s) = dwarf.attr_string(unit, attr.value()) {
let mangled = s.to_string_lossy()?.into_owned();
let stripped = mangled.strip_prefix('_').unwrap_or(&mangled);
name = Some(format!("{:#}", demangle(stripped)));
}
}
gimli::DW_AT_name => {
if name.is_none() {
if let Ok(s) = dwarf.attr_string(unit, attr.value()) {
name = Some(s.to_string_lossy()?.into_owned());
}
}
}
_ => {}
}
}
Ok(name)
}
fn resolve_decl_file<R: Reader>(
dwarf: &Dwarf<R>,
unit: &Unit<R>,
file_idx: u64,
) -> Result<Option<String>, gimli::Error> {
let Some(line_program) = &unit.line_program else {
return Ok(None);
};
let Some(file_entry) = line_program.header().file(file_idx) else {
return Ok(None);
};
let file_str = dwarf.attr_string(unit, file_entry.path_name())?;
let file_name = file_str.to_string_lossy()?.into_owned();
if let Some(dir) = file_entry.directory(line_program.header()) {
let dir_str = dwarf.attr_string(unit, dir.clone())?;
let dir_name = dir_str.to_string_lossy()?;
if !dir_name.is_empty() {
return Ok(Some(format!("{dir_name}/{file_name}")));
}
}
Ok(Some(file_name))
}
type SpecificationResult = (Option<String>, Option<String>, Option<u32>);
fn resolve_specification<R: Reader>(
dwarf: &Dwarf<R>,
unit: &Unit<R>,
offset: gimli::UnitOffset<R::Offset>,
) -> Result<SpecificationResult, gimli::Error> {
let entry = unit.entry(offset)?;
let mut name: Option<String> = None;
let mut file: Option<String> = None;
let mut line: Option<u32> = None;
let mut attrs = entry.attrs();
while let Some(attr) = attrs.next()? {
match attr.name() {
gimli::DW_AT_linkage_name | gimli::DW_AT_MIPS_linkage_name => {
if let Ok(s) = dwarf.attr_string(unit, attr.value()) {
let mangled = s.to_string_lossy()?.into_owned();
let stripped = mangled.strip_prefix('_').unwrap_or(&mangled);
name = Some(format!("{:#}", demangle(stripped)));
}
}
gimli::DW_AT_name => {
if name.is_none() {
if let Ok(s) = dwarf.attr_string(unit, attr.value()) {
name = Some(s.to_string_lossy()?.into_owned());
}
}
}
gimli::DW_AT_decl_file => {
if let AttributeValue::FileIndex(idx) = attr.value() {
file = resolve_decl_file(dwarf, unit, idx)?;
}
}
gimli::DW_AT_decl_line => {
if let AttributeValue::Udata(l) = attr.value() {
line = Some(l as u32);
}
}
_ => {}
}
}
Ok((name, file, line))
}
pub fn find_callers_with_debug_info(
binary_macho: &MachO,
binary_buffer: &[u8],
debug_macho: &MachO,
debug_buffer: &[u8],
target_addr: u64,
crate_src_path: Option<&str>,
) -> Result<Vec<CallerInfo<'static>>, Box<dyn std::error::Error>> {
let (functions, inlined, strings) = get_functions_from_dwarf(debug_macho, debug_buffer)?;
let function_index = FunctionIndex::new_with_inlined(functions, inlined, strings);
let dwarf = load_dwarf_sections(debug_macho, debug_buffer)?;
let full_line_table = FullLineTable::build(&dwarf)?;
let mut callers = Vec::new();
let Some((text_addr, text_data)) = get_text_section(binary_macho, binary_buffer) else {
return Ok(callers);
};
let cs = Capstone::new()
.arm64()
.mode(arch::arm64::ArchMode::Arm)
.build()?;
let instructions = cs.disasm_all(text_data, text_addr)?;
let symbol_index = SymbolIndex::new(binary_macho);
for instruction in instructions.iter() {
let mnemonic = instruction.mnemonic();
if (mnemonic == Some("bl") || mnemonic == Some("b"))
&& let Some(operand) = instruction.op_str()
{
let addr_str = operand.trim_start_matches("#0x");
if let Ok(call_target) = u64::from_str_radix(addr_str, 16)
&& call_target == target_addr
{
if let Some(func) = function_index.find_containing(instruction.address()) {
let (line_file, line_line, line_column) =
full_line_table.get_source_location(func.start_address);
let decl_file = function_index.get_file(func).map(|s| s.to_string());
let decl_line = function_index.get_line(func);
let file = decl_file.clone().or(line_file);
let mut line = decl_line.or(line_line);
let mut column = line_column;
if let (Some(f), Some(crate_path)) = (&file, crate_src_path)
&& f.contains(crate_path)
&& let Ok(Some((crate_line, crate_column))) = get_crate_line_at_address(
&dwarf,
func.start_address,
instruction.address(),
crate_path,
)
{
line = Some(crate_line);
column = crate_column;
}
let func_name = function_index.get_name(func);
let display_name = function_index
.find_function_name(instruction.address())
.map(|s| {
let stripped = s.strip_prefix('_').unwrap_or(s);
format!("{:#}", demangle(stripped))
})
.unwrap_or_else(|| func_name.to_string());
callers.push(CallerInfo {
caller_name: Cow::Owned(display_name),
caller_start_address: func.start_address,
caller_file: decl_file,
call_site_addr: instruction.address(),
file,
line,
column,
});
} else if let Some((func_addr, func_name)) = symbol_index
.as_ref()
.and_then(|idx| idx.find_containing(instruction.address()))
{
let (file, line, column) = full_line_table.get_source_location(func_addr);
callers.push(CallerInfo {
caller_name: Cow::Owned(func_name.to_string()),
caller_start_address: func_addr,
caller_file: None,
call_site_addr: instruction.address(),
file,
line,
column,
});
}
}
}
}
Ok(callers)
}
fn get_crate_line_at_address<R: Reader>(
dwarf: &Dwarf<R>,
func_start: u64,
call_site_addr: u64,
crate_src_path: &str,
) -> Result<Option<(u32, Option<u32>)>, gimli::Error> {
let mut units = dwarf.units();
let mut best_line: Option<u32> = None;
let mut best_column: Option<u32> = None;
let mut best_addr: u64 = 0;
while let Some(header) = units.next()? {
let unit = dwarf.unit(header)?;
if let Some(program) = &unit.line_program {
let mut rows = program.clone().rows();
while let Some((header, row)) = rows.next_row()? {
let addr = row.address();
if addr >= func_start
&& addr <= call_site_addr
&& let Some(file_entry) = row.file(header)
{
let file_name = dwarf
.attr_string(&unit, file_entry.path_name())?
.to_string_lossy()?
.into_owned();
let full_path = if let Some(dir) = file_entry.directory(header) {
let dir_str = dwarf
.attr_string(&unit, dir)?
.to_string_lossy()?
.into_owned();
if dir_str.is_empty() {
file_name
} else {
format!("{}/{}", dir_str, file_name)
}
} else {
file_name
};
if matches_crate_pattern(&full_path, crate_src_path)
&& let Some(line) = row.line()
&& addr >= best_addr
{
best_addr = addr;
best_line = Some(line.get() as u32);
best_column = match row.column() {
ColumnType::LeftEdge => None,
ColumnType::Column(c) => Some(c.get() as u32),
};
}
}
}
}
}
Ok(best_line.map(|l| (l, best_column)))
}
pub fn find_dsym(binary_path: &Path) -> Option<PathBuf> {
let dsym_bundle = binary_path.with_extension("dSYM");
if dsym_bundle.exists() {
let binary_name = binary_path.file_name()?;
let dwarf_path = dsym_bundle
.join("Contents")
.join("Resources")
.join("DWARF")
.join(binary_name);
if dwarf_path.exists() {
return Some(dwarf_path);
}
}
None
}
pub fn has_dwarf_sections(macho: &MachO) -> bool {
for segment in macho.segments.iter() {
if let Ok(sects) = segment.sections() {
for (section, _) in sects {
if let Ok(name) = section.name()
&& name.starts_with("__debug_")
{
return true;
}
}
}
}
false
}
fn get_oso_paths(macho: &MachO) -> Vec<PathBuf> {
let mut paths = Vec::new();
if let Some(symbols) = &macho.symbols {
for (name, nlist) in symbols.iter().flatten() {
if nlist.n_type == N_OSO && !name.is_empty() {
paths.push(PathBuf::from(name));
}
}
}
paths.sort();
paths.dedup();
paths
}
fn build_addr_translation_map(binary_macho: &MachO, obj_macho: &MachO) -> HashMap<u64, u64> {
let mut addr_map = HashMap::new();
let Some(binary_symbols) = &binary_macho.symbols else {
return addr_map;
};
let Some(obj_symbols) = &obj_macho.symbols else {
return addr_map;
};
let mut binary_sym_addrs: HashMap<String, u64> = HashMap::new();
for (name, nlist) in binary_symbols.iter().flatten() {
if nlist.n_value > 0 && !name.is_empty() {
binary_sym_addrs.insert(name.to_string(), nlist.n_value);
}
}
for (name, nlist) in obj_symbols.iter().flatten() {
if nlist.n_value > 0
&& !name.is_empty()
&& let Some(&binary_addr) = binary_sym_addrs.get(name)
{
addr_map.insert(nlist.n_value, binary_addr);
}
}
addr_map
}
pub fn find_callers_with_debug_map(
binary_macho: &MachO,
binary_buffer: &[u8],
debug_map: &DebugMapInfo,
target_addr: u64,
_crate_src_path: Option<&str>,
) -> Result<Vec<CallerInfo<'static>>, Box<dyn std::error::Error>> {
let mut callers = find_callers(binary_macho, binary_buffer, target_addr)?;
let mut func_source_map: HashMap<String, (Option<String>, Option<u32>)> = HashMap::new();
for obj_info in &debug_map.object_files {
let Ok(Object::Mach(Mach::Binary(obj_macho))) = Object::parse(&obj_info.buffer) else {
continue;
};
let Ok((functions, _inlined, strings)) =
get_functions_from_dwarf(&obj_macho, &obj_info.buffer)
else {
continue;
};
for func in functions {
let file = strings.get_file(func.file_idx);
if file.is_some() {
let name = strings.get_name(func.name_idx);
let line = if func.line == 0 {
None
} else {
Some(func.line)
};
func_source_map.insert(name.to_string(), (file.map(|s| s.to_string()), line));
let stripped = name.strip_prefix("_").unwrap_or(name);
let demangled = format!("{:#}", demangle(stripped));
if demangled != name {
func_source_map.insert(demangled, (file.map(|s| s.to_string()), line));
}
}
}
}
for caller in &mut callers {
if let Some((file, line)) = func_source_map.get(caller.caller_name.as_ref()) {
caller.caller_file = file.clone();
caller.file = file.clone();
caller.line = *line;
}
}
Ok(callers)
}
fn load_debug_map(macho: &MachO, quiet: bool) -> Option<DebugMapInfo> {
let oso_paths = get_oso_paths(macho);
if oso_paths.is_empty() {
return None;
}
let mut object_files = Vec::new();
let mut loaded_count = 0;
for path in oso_paths {
if !path.exists() {
continue;
}
let Ok(buffer) = fs::read(&path) else {
continue;
};
let Ok(Object::Mach(Mach::Binary(obj_macho))) = Object::parse(&buffer) else {
continue;
};
if !has_dwarf_sections(&obj_macho) {
continue;
}
let addr_map = build_addr_translation_map(macho, &obj_macho);
object_files.push(ObjectFileInfo {
path,
buffer,
addr_map,
});
loaded_count += 1;
}
if object_files.is_empty() {
return None;
}
if !quiet {
println!(
"Using debug map: loaded {} object files with DWARF",
loaded_count
);
}
Some(DebugMapInfo { object_files })
}
fn is_dsym_stale(binary_path: &Path, dsym_path: &Path) -> bool {
let binary_modified = match fs::metadata(binary_path).and_then(|m| m.modified()) {
Ok(t) => t,
Err(_) => return false, };
let dsym_modified = match fs::metadata(dsym_path).and_then(|m| m.modified()) {
Ok(t) => t,
Err(_) => return true, };
binary_modified > dsym_modified
}
pub fn load_debug_info(macho: &MachO, binary_path: &Path, quiet: bool) -> DebugInfo {
let file_name = binary_path.file_name().unwrap().to_str().unwrap();
let file_stem = binary_path.file_stem().unwrap().to_str().unwrap();
let dsym_base = binary_path.parent().unwrap_or(Path::new("."));
let dsym_paths = [
dsym_base
.join(format!("{}.dSYM", file_stem))
.join("Contents/Resources/DWARF")
.join(file_name),
dsym_base
.join(format!("{}.dSYM", file_stem))
.join("Contents/Resources/DWARF")
.join(file_stem),
binary_path
.with_extension("dSYM")
.join("Contents/Resources/DWARF")
.join(file_name),
];
for dsym_path in &dsym_paths {
if dsym_path.exists() {
let dsym_stale = is_dsym_stale(binary_path, dsym_path);
if dsym_stale {
if !quiet {
println!(" dSYM is stale, will regenerate");
}
} else {
if !quiet {
println!(" Using .dSYM bundle for debug info");
}
let debug_buffer = fs::read(dsym_path).unwrap();
let dsym_info = DSymInfoBuilder {
debug_buffer,
debug_macho_builder: |buf: &Vec<u8>| Mach::parse(buf).unwrap(),
}
.build();
return DebugInfo::DSym(Box::new(dsym_info));
}
}
}
if has_dwarf_sections(macho) {
if !quiet {
println!(" Using embedded DWARF debugging info");
}
return DebugInfo::Embedded;
}
if let Some(dsym_info) = auto_generate_dsym(binary_path, quiet) {
return DebugInfo::DSym(Box::new(dsym_info));
}
if let Some(debug_map) = load_debug_map(macho, quiet) {
return DebugInfo::DebugMap(Box::new(debug_map));
}
if !quiet {
println!(" No debug info found (no dSYM, embedded DWARF, or debug map)");
println!(
"Tip: Install dsymutil or run 'dsymutil {}' to generate debug symbols",
binary_path.display()
);
}
DebugInfo::None
}
fn auto_generate_dsym(binary_path: &Path, quiet: bool) -> Option<DSymInfo> {
use std::process::Command;
let dsym_path = binary_path.with_extension("dSYM");
let status = Command::new("dsymutil")
.arg(binary_path)
.arg("-o")
.arg(&dsym_path)
.status()
.ok()?;
if !status.success() {
return None;
}
let file_name = binary_path.file_name()?.to_str()?;
let file_stem = binary_path.file_stem()?.to_str()?;
let dwarf_paths = [
dsym_path.join("Contents/Resources/DWARF").join(file_name),
dsym_path.join("Contents/Resources/DWARF").join(file_stem),
];
for dwarf_path in &dwarf_paths {
if dwarf_path.exists() {
if !quiet {
println!(" Generated .dSYM bundle for debug info");
}
let debug_buffer = fs::read(dwarf_path).ok()?;
let dsym_info = DSymInfoBuilder {
debug_buffer,
debug_macho_builder: |buf: &Vec<u8>| Mach::parse(buf).unwrap(),
}
.build();
return Some(dsym_info);
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_matches_crate_pattern_simple() {
assert!(matches_crate_pattern("src/main.rs", "src/"));
assert!(matches_crate_pattern("src/lib.rs", "src/"));
assert!(matches_crate_pattern("src/module/mod.rs", "src/"));
}
#[test]
fn test_matches_crate_pattern_no_match() {
assert!(!matches_crate_pattern("tests/test.rs", "src/"));
assert!(!matches_crate_pattern("examples/ex.rs", "src/"));
}
#[test]
fn test_matches_crate_pattern_multi_pattern() {
let pattern = "flowc/src/|flowr/src/";
assert!(matches_crate_pattern("flowc/src/main.rs", pattern));
assert!(matches_crate_pattern("flowr/src/lib.rs", pattern));
assert!(!matches_crate_pattern("other/src/lib.rs", pattern));
}
#[test]
fn test_matches_crate_pattern_workspace() {
let pattern = "crate_a/src/|crate_b/src/";
assert!(matches_crate_pattern("crate_a/src/lib.rs", pattern));
assert!(matches_crate_pattern("crate_b/src/main.rs", pattern));
assert!(!matches_crate_pattern("crate_c/src/lib.rs", pattern));
}
#[test]
fn test_matches_crate_pattern_empty_pattern() {
assert!(!matches_crate_pattern("src/main.rs", ""));
assert!(matches_crate_pattern("src/main.rs", "|src/|"));
}
#[test]
fn test_valid_source_files_needs_validation() {
let empty_valid = ValidSourceFiles::default();
assert!(!empty_valid.needs_validation("src/"));
let mut files = std::collections::HashSet::new();
files.insert("src/main.rs".to_string());
let valid = ValidSourceFiles {
files,
project_root: None,
};
assert!(valid.needs_validation("src/"));
assert!(!valid.needs_validation("flowc/src/|flowr/src/"));
assert!(!valid.needs_validation("my_crate/src/"));
}
#[test]
fn test_valid_source_files_contains() {
let mut files = std::collections::HashSet::new();
files.insert("src/main.rs".to_string());
files.insert("src/lib.rs".to_string());
let valid = ValidSourceFiles {
files,
project_root: None,
};
assert!(valid.contains("src/main.rs"));
assert!(valid.contains("src/lib.rs"));
assert!(!valid.contains("src/other.rs"));
}
#[test]
fn test_matches_crate_pattern_validated_with_allowlist() {
let mut files = std::collections::HashSet::new();
files.insert("src/main.rs".to_string());
files.insert("src/module/mod.rs".to_string());
let valid = ValidSourceFiles {
files,
project_root: Some(std::path::PathBuf::from("/project")),
};
assert!(matches_crate_pattern_validated(
"src/main.rs",
"src/",
Some(&valid)
));
assert!(matches_crate_pattern_validated(
"src/module/mod.rs",
"src/",
Some(&valid)
));
assert!(!matches_crate_pattern_validated(
"src/other.rs",
"src/",
Some(&valid)
));
}
#[test]
fn test_decode_branch_target_forward() {
let insn = 0x94000001_u32; let target = decode_branch_target(insn, 0x1000);
assert_eq!(target, 0x1004);
}
#[test]
fn test_decode_branch_target_backward() {
let pc = 0x2000_u64;
let insn = 0x97FFFFFF_u32; let target = decode_branch_target(insn, pc);
assert_eq!(target, pc.wrapping_sub(4));
}
#[test]
fn test_decode_branch_target_zero_offset() {
let insn = 0x94000000_u32;
let target = decode_branch_target(insn, 0x1000);
assert_eq!(target, 0x1000);
}
#[test]
fn test_is_dsym_stale_binary_newer() {
use std::fs;
use std::thread;
use std::time::Duration;
let temp_dir = std::env::temp_dir().join("jonesy_test_dsym_stale");
let _ = fs::create_dir_all(&temp_dir);
let binary_path = temp_dir.join("test_binary");
let dsym_path = temp_dir.join("test_binary.dSYM");
fs::write(&dsym_path, "fake dsym").unwrap();
thread::sleep(Duration::from_millis(50));
fs::write(&binary_path, "fake binary").unwrap();
assert!(
is_dsym_stale(&binary_path, &dsym_path),
"dSYM should be stale when binary is newer"
);
let _ = fs::remove_dir_all(&temp_dir);
}
#[test]
fn test_is_dsym_stale_dsym_newer() {
use std::fs;
use std::thread;
use std::time::Duration;
let temp_dir = std::env::temp_dir().join("jonesy_test_dsym_fresh");
let _ = fs::create_dir_all(&temp_dir);
let binary_path = temp_dir.join("test_binary");
let dsym_path = temp_dir.join("test_binary.dSYM");
fs::write(&binary_path, "fake binary").unwrap();
thread::sleep(Duration::from_millis(50));
fs::write(&dsym_path, "fake dsym").unwrap();
assert!(
!is_dsym_stale(&binary_path, &dsym_path),
"dSYM should not be stale when dSYM is newer"
);
let _ = fs::remove_dir_all(&temp_dir);
}
#[test]
fn test_is_dsym_stale_binary_not_found() {
use std::fs;
let temp_dir = std::env::temp_dir().join("jonesy_test_dsym_no_binary");
let _ = fs::create_dir_all(&temp_dir);
let binary_path = temp_dir.join("nonexistent_binary");
let dsym_path = temp_dir.join("test.dSYM");
fs::write(&dsym_path, "fake dsym").unwrap();
assert!(
!is_dsym_stale(&binary_path, &dsym_path),
"Should return false when binary doesn't exist"
);
let _ = fs::remove_dir_all(&temp_dir);
}
#[test]
fn test_is_dsym_stale_dsym_not_found() {
use std::fs;
let temp_dir = std::env::temp_dir().join("jonesy_test_dsym_no_dsym");
let _ = fs::create_dir_all(&temp_dir);
let binary_path = temp_dir.join("test_binary");
let dsym_path = temp_dir.join("nonexistent.dSYM");
fs::write(&binary_path, "fake binary").unwrap();
assert!(
is_dsym_stale(&binary_path, &dsym_path),
"Should return true when dSYM doesn't exist"
);
let _ = fs::remove_dir_all(&temp_dir);
}
}