diff --git a/Cargo.lock b/Cargo.lock index 17ebbfc..dbf0ed1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -190,7 +190,7 @@ checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] name = "delink-aarch64" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "capstone", @@ -203,7 +203,7 @@ dependencies = [ [[package]] name = "delink-arch" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "delink-core", @@ -214,22 +214,24 @@ dependencies = [ [[package]] name = "delink-cli" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "clap", "delink-core", "delink-emit", + "delink-macho", "delink-pe", "memmap2", "object", + "serde_json", "tracing", "tracing-subscriber", ] [[package]] name = "delink-core" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "gimli", @@ -241,7 +243,7 @@ dependencies = [ [[package]] name = "delink-emit" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "delink-aarch64", @@ -254,9 +256,23 @@ dependencies = [ "tracing", ] +[[package]] +name = "delink-macho" +version = "0.2.0" +dependencies = [ + "anyhow", + "delink-x86", + "gimli", + "object", + "rayon", + "serde", + "thiserror", + "tracing", +] + [[package]] name = "delink-pe" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "delink-x86", @@ -270,7 +286,7 @@ dependencies = [ [[package]] name = "delink-x86" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "iced-x86", @@ -279,7 +295,7 @@ dependencies = [ [[package]] name = "delink-x86_64" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "iced-x86", @@ -389,6 +405,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + [[package]] name = "js-sys" version = "0.3.99" @@ -560,6 +582,49 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04c565b551bafbef4157586fa379538366e4385d42082f255bfd96e4fe8519da" +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -793,3 +858,9 @@ checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ "windows-link", ] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index c44de9f..411a5b9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,11 +8,12 @@ members = [ "crates/delink-x86_64", "crates/delink-x86", "crates/delink-pe", + "crates/delink-macho", "crates/delink-cli", ] [workspace.package] -version = "0.1.0" +version = "0.2.0" edition = "2021" rust-version = "1.80" license = "MIT OR Apache-2.0" @@ -21,10 +22,12 @@ repository = "https://github.com/HaydnTrigg/delink" [workspace.dependencies] anyhow = "1" thiserror = "2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } memmap2 = "0.9" -object = { version = "0.39.1", default-features = false, features = ["read", "write", "elf", "pe", "coff", "std"] } +object = { version = "0.39.1", default-features = false, features = ["read", "write", "elf", "pe", "coff", "macho", "std"] } gimli = { version = "0.33.0", default-features = false, features = ["read", "std"] } addr2line = { version = "0.24", default-features = false, features = ["std", "loader"] } capstone = "0.14.0" @@ -41,6 +44,7 @@ delink-emit = { path = "crates/delink-emit" } delink-x86_64 = { path = "crates/delink-x86_64" } delink-x86 = { path = "crates/delink-x86" } delink-pe = { path = "crates/delink-pe" } +delink-macho = { path = "crates/delink-macho" } [profile.release-lto] inherits = "release" diff --git a/README.md b/README.md index cdb8995..f852321 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,12 @@ [Build Status]: https://github.com/HaydnTrigg/delink/actions/workflows/build.yaml/badge.svg [actions]: https://github.com/HaydnTrigg/delink/actions -A splitting tool for decompilation projects for Shared Object (.so) files with DWARF and Windows Executables with PDB's (.exe/.pdb). +A splitting tool for decompilation projects. + +## Supported Formats: +- Shared Object (.so) files with DWARF +- Mach-O STABS and SYMTAB +- Windows PE with PDB's (.exe/.pdb). ## Building diff --git a/crates/delink-cli/Cargo.toml b/crates/delink-cli/Cargo.toml index 8541c18..5029f84 100644 --- a/crates/delink-cli/Cargo.toml +++ b/crates/delink-cli/Cargo.toml @@ -13,10 +13,12 @@ path = "src/main.rs" [dependencies] anyhow.workspace = true clap.workspace = true +serde_json.workspace = true tracing.workspace = true tracing-subscriber.workspace = true delink-core.workspace = true delink-emit.workspace = true delink-pe.workspace = true +delink-macho.workspace = true memmap2.workspace = true object.workspace = true diff --git a/crates/delink-cli/src/main.rs b/crates/delink-cli/src/main.rs index 168fb87..cf0ca0f 100644 --- a/crates/delink-cli/src/main.rs +++ b/crates/delink-cli/src/main.rs @@ -103,6 +103,51 @@ enum Cmd { #[arg(short, long)] outdir: PathBuf, }, + + // ----------------------------------------------------------------------- + // Mach-O subcommands + // ----------------------------------------------------------------------- + /// Inspect a Mach-O binary: print sections and DWARF compilation units. + MachoInspect { + /// Path to the Mach-O executable or dylib. + input: PathBuf, + }, + + /// List Mach-O DWARF compilation units sorted by .text size. + MachoListCus { + input: PathBuf, + #[arg(long, default_value = "")] + contains: String, + #[arg(long, default_value_t = 20)] + limit: usize, + }, + + /// Split a Mach-O binary into one `.o` per function (symtab-driven) plus `__shared_data.o`. + /// + /// On the first run a `symtab.json` is generated in the output directory + /// listing every N_SECT function symbol with its raw symbol-table fields and + /// a `cu` field naming the output `.o` file. Edit the `cu` values to group + /// functions and re-run with `--symtab` to produce the merged files. + MachoSplit { + /// Path to the Mach-O executable or dylib. + input: PathBuf, + /// Output directory for the `.o` files. + #[arg(short, long)] + outdir: PathBuf, + /// Path to an existing `symtab.json` to control function → file grouping. + /// If omitted a default symtab (one function per file) is created and + /// written to `/symtab.json`. + #[arg(long)] + symtab: Option, + /// Emit standard ELF ET_REL objects instead of Mach-O objects. + /// + /// Useful when targeting a Linux/ELF toolchain with a Mach-O input. + /// i386 input: PC-relative calls become `R_386_PC32` relocations. + /// `__DATA,__data` → `.data`, `__DATA,__const` → `.rodata`, + /// `__DATA,__bss` → `.bss`. + #[arg(long)] + emit_elf: bool, + }, } fn main() -> Result<()> { @@ -147,6 +192,18 @@ fn main() -> Result<()> { limit, } => cmd_pe_list_cus(&input, &pdb, &contains, limit), Cmd::PeSplit { input, pdb, outdir } => cmd_pe_split(&input, &pdb, &outdir), + Cmd::MachoInspect { input } => cmd_macho_inspect(&input), + Cmd::MachoListCus { + input, + contains, + limit, + } => cmd_macho_list_cus(&input, &contains, limit), + Cmd::MachoSplit { + input, + outdir, + symtab, + emit_elf, + } => cmd_macho_split(&input, &outdir, symtab.as_deref(), emit_elf), } } @@ -527,6 +584,353 @@ fn cmd_pe_list_cus(exe_path: &Path, pdb_path: &Path, contains: &str, limit: usiz Ok(()) } +// --------------------------------------------------------------------------- +// Mach-O subcommands +// --------------------------------------------------------------------------- + +fn load_macho_context(path: &Path) -> Result { + let data = std::fs::read(path).with_context(|| format!("read {}", path.display()))?; + tracing::info!("loaded Mach-O ({} bytes)", data.len()); + delink_macho::load_macho(&data).with_context(|| format!("load {}", path.display())) +} + +fn cmd_macho_inspect(path: &Path) -> Result<()> { + let ctx = load_macho_context(path)?; + + println!("Mach-O arch={:?}", ctx.arch); + println!("\nSECTIONS"); + println!( + " {:<20} {:<12} {:>16} {:>12} flags", + "segment", "name", "addr", "size" + ); + for s in &ctx.sections { + println!( + " {:<20} {:<12} {:#016x} {:>12} 0x{:08x}", + s.segment, s.name, s.addr, s.size, s.flags + ); + } + + println!("\nDWARF compilation units: {}", ctx.cu_index.units.len()); + let total_funcs: usize = ctx.cu_index.units.iter().map(|u| u.functions.len()).sum(); + println!(" total functions: {}", total_funcs); + + Ok(()) +} + +fn cmd_macho_list_cus(path: &Path, contains: &str, limit: usize) -> Result<()> { + let ctx = load_macho_context(path)?; + + let mut rows: Vec<_> = ctx + .cu_index + .units + .iter() + .filter(|u| u.name.contains(contains)) + .map(|u| (u.text_size(), u.functions.len(), u.name.clone())) + .collect(); + rows.sort_by_key(|(b, _, _)| *b); + + println!("{:>10} {:>6} name", "text bytes", "funcs"); + for (bytes, funcs, name) in rows.iter().take(limit) { + println!("{:>10} {:>6} {}", bytes, funcs, name); + } + Ok(()) +} + +fn cmd_macho_split( + path: &Path, + outdir: &Path, + symtab_arg: Option<&Path>, + emit_as_elf: bool, +) -> Result<()> { + let data = std::fs::read(path).with_context(|| format!("read {}", path.display()))?; + tracing::info!("loaded Mach-O ({} bytes)", data.len()); + + let ctx = + delink_macho::load_macho(&data).with_context(|| format!("load {}", path.display()))?; + + let input_path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); + let arch_str = format!("{:?}", ctx.arch); + + std::fs::create_dir_all(outdir).with_context(|| format!("create {}", outdir.display()))?; + + // ------------------------------------------------------------------ + // Choose split strategy: + // • --symtab provided → always symtab-driven (user override) + // • DWARF / STABS → use the CU index from debug info directly + // • Symtab fallback → generate a flat per-symbol symtab.json + // ------------------------------------------------------------------ + let use_debug_info = symtab_arg.is_none() + && matches!( + ctx.cu_index.source, + delink_macho::DebugInfoSource::Dwarf | delink_macho::DebugInfoSource::Stabs + ); + + let outcomes: Vec; + let mut manifest = serde_json::Map::new(); + + if use_debug_info { + // DWARF / STABS path — split by the CU index built from debug info. + tracing::info!( + "splitting {} CUs (from {:?}) in parallel", + ctx.cu_index + .units + .iter() + .filter(|u| u.functions.iter().any(|f| f.size > 0)) + .count(), + ctx.cu_index.source, + ); + + // Write a symtab.json derived from the CU index so the user can + // inspect (and re-run with --symtab to customise) the grouping. + let symtab_for_ref = delink_macho::symtab_json::generate_from_cu_index(&ctx.cu_index); + let symtab_out = outdir.join("symtab.json"); + let symtab_json_str = + serde_json::to_string_pretty(&symtab_for_ref).context("serialize symtab")?; + std::fs::write(&symtab_out, &symtab_json_str) + .with_context(|| format!("write {}", symtab_out.display()))?; + tracing::info!("symtab → {}", symtab_out.display()); + + outcomes = delink_macho::emit::split_all_macho(&ctx, outdir, emit_as_elf)?; + + // Build manifest from cu_index (no SymtabInfo available here). + for o in &outcomes { + let file_name = o + .file + .file_name() + .unwrap_or_default() + .to_string_lossy() + .into_owned(); + + let functions_json: Vec<_> = ctx + .cu_index + .units + .iter() + .find(|u| u.id == o.cu_id) + .map(|cu| { + let mut fns: Vec<_> = cu.functions.iter().filter(|f| f.size > 0).collect(); + fns.sort_by_key(|f| f.addr); + fns.iter() + .map(|f| { + serde_json::json!({ + "name": f.symbol_name(), + "addr": f.addr, + "size": f.size, + "external": f.external, + }) + }) + .collect() + }) + .unwrap_or_default(); + + let emit_json = match &o.result { + Ok(s) => serde_json::json!({ + "text_bytes": s.text_bytes, + "instructions": s.instructions, + "local_symbols": s.local_symbols, + "undef_symbols": s.undef_symbols, + "relocations": s.relocations, + "unresolved_calls": s.unresolved_calls, + }), + Err(_) => serde_json::Value::Null, + }; + let error_json = match &o.result { + Ok(_) => serde_json::Value::Null, + Err(e) => serde_json::Value::String(e.clone()), + }; + + manifest.insert( + file_name, + serde_json::json!({ + "input_path": input_path.to_string_lossy(), + "output_path": o.file.canonicalize().unwrap_or_else(|_| o.file.clone()).to_string_lossy(), + "arch": arch_str, + "functions": functions_json, + "emit": emit_json, + "error": error_json, + }), + ); + } + } else { + // Symtab-driven path (no debug info, or --symtab override). + let symtab: delink_macho::symtab_json::SymtabJson = if let Some(sp) = symtab_arg { + let raw = std::fs::read_to_string(sp) + .with_context(|| format!("read symtab {}", sp.display()))?; + serde_json::from_str(&raw).with_context(|| format!("parse symtab {}", sp.display()))? + } else { + delink_macho::symtab_json::generate(&data).context("generate symtab")? + }; + + let n_syms: usize = symtab.values().map(|v| v.len()).sum(); + tracing::info!("symtab: {} symbols → {} output files", n_syms, symtab.len()); + + let symtab_out = outdir.join("symtab.json"); + let symtab_json_str = serde_json::to_string_pretty(&symtab).context("serialize symtab")?; + std::fs::write(&symtab_out, &symtab_json_str) + .with_context(|| format!("write {}", symtab_out.display()))?; + tracing::info!("symtab → {}", symtab_out.display()); + + let lookup = + delink_macho::symtab_json::build_lookup(&data).context("build symtab lookup")?; + + outcomes = + delink_macho::emit::split_by_symtab(&ctx, &symtab, &lookup, outdir, emit_as_elf)?; + + // Build manifest using rich SymtabInfo. + for o in &outcomes { + let file_name = o + .file + .file_name() + .unwrap_or_default() + .to_string_lossy() + .into_owned(); + + let empty: Vec = vec![]; + let names = symtab.get(o.cu_name.as_str()).unwrap_or(&empty); + let mut resolved: Vec<_> = names + .iter() + .filter_map(|name| lookup.get(name.as_str()).map(|info| (name, info))) + .collect(); + resolved.sort_by_key(|(_, info)| info.addr); + + let functions_json: Vec<_> = resolved + .iter() + .map(|(name, info)| { + serde_json::json!({ + "name": name, + "addr": info.addr, + "size": info.size, + "n_type": info.n_type, + "n_sect": info.n_sect, + "n_desc": info.n_desc, + "external": info.external, + "private_external": info.private_external, + }) + }) + .collect(); + + let emit_json = match &o.result { + Ok(s) => serde_json::json!({ + "text_bytes": s.text_bytes, + "instructions": s.instructions, + "local_symbols": s.local_symbols, + "undef_symbols": s.undef_symbols, + "relocations": s.relocations, + "unresolved_calls": s.unresolved_calls, + }), + Err(_) => serde_json::Value::Null, + }; + let error_json = match &o.result { + Ok(_) => serde_json::Value::Null, + Err(e) => serde_json::Value::String(e.clone()), + }; + + manifest.insert( + file_name, + serde_json::json!({ + "input_path": input_path.to_string_lossy(), + "output_path": o.file.canonicalize().unwrap_or_else(|_| o.file.clone()).to_string_lossy(), + "arch": arch_str, + "functions": functions_json, + "emit": emit_json, + "error": error_json, + }), + ); + } + } + + // ------------------------------------------------------------------ + // Shared data + // ------------------------------------------------------------------ + let shared = outdir.join("__shared_data.o"); + tracing::info!("emitting shared data → {}", shared.display()); + let shared_stats = if emit_as_elf { + delink_macho::emit::emit_elf_shared(&ctx, &shared)? + } else { + delink_macho::emit::emit_macho_shared(&ctx, &shared)? + }; + + // Shared data manifest entry. + let shared_vars: Vec<_> = ctx + .symbols + .variables + .iter() + .map(|(addr, v)| { + serde_json::json!({ + "name": v.symbol_name(), + "demangled": v.name, + "addr": addr, + "external": v.external, + }) + }) + .collect(); + let shared_name = shared + .file_name() + .unwrap_or_default() + .to_string_lossy() + .into_owned(); + manifest.insert( + shared_name, + serde_json::json!({ + "input_path": input_path.to_string_lossy(), + "output_path": shared.canonicalize().unwrap_or_else(|_| shared.clone()).to_string_lossy(), + "arch": arch_str, + "functions": [], + "variables": shared_vars, + "emit": { + "data_bytes": shared_stats.data_bytes, + "const_bytes": shared_stats.const_bytes, + "bss_bytes": shared_stats.bss_bytes, + }, + "error": null, + }), + ); + + let manifest_path = outdir.join("manifest.json"); + let json_str = serde_json::to_string_pretty(&serde_json::Value::Object(manifest)) + .context("serialize manifest")?; + std::fs::write(&manifest_path, json_str) + .with_context(|| format!("write {}", manifest_path.display()))?; + tracing::info!("manifest → {}", manifest_path.display()); + + // ------------------------------------------------------------------ + // Summary + // ------------------------------------------------------------------ + let mut total = delink_macho::EmitStats::default(); + let mut failures = 0usize; + for o in &outcomes { + match &o.result { + Ok(s) => { + total.text_bytes += s.text_bytes; + total.local_symbols += s.local_symbols; + total.undef_symbols += s.undef_symbols; + total.relocations += s.relocations; + total.unresolved_calls += s.unresolved_calls; + total.instructions += s.instructions; + } + Err(e) => { + failures += 1; + tracing::warn!(cu = %o.cu_name, error = %e, "emit failed"); + } + } + } + + println!( + "macho-split complete: {} files ({} failed)\n {} bytes .text, {} instructions\n {} local + {} undef symbols\n {} relocs ({} unresolved calls)\n shared: data={} const={} bss={}", + outcomes.len().saturating_sub(failures), + failures, + total.text_bytes, + total.instructions, + total.local_symbols, + total.undef_symbols, + total.relocations, + total.unresolved_calls, + shared_stats.data_bytes, + shared_stats.const_bytes, + shared_stats.bss_bytes, + ); + Ok(()) +} + fn cmd_pe_split(exe_path: &Path, pdb_path: &Path, outdir: &Path) -> Result<()> { let pe = load_pe_context(exe_path, pdb_path)?; diff --git a/crates/delink-macho/Cargo.toml b/crates/delink-macho/Cargo.toml new file mode 100644 index 0000000..6d8e231 --- /dev/null +++ b/crates/delink-macho/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "delink-macho" +edition.workspace = true +version.workspace = true +license.workspace = true + +[dependencies] +anyhow = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } +object = { workspace = true } +gimli = { workspace = true } +rayon = { workspace = true } +serde = { workspace = true } +delink-x86 = { workspace = true } diff --git a/crates/delink-macho/src/cu.rs b/crates/delink-macho/src/cu.rs new file mode 100644 index 0000000..330751a --- /dev/null +++ b/crates/delink-macho/src/cu.rs @@ -0,0 +1,333 @@ +//! DWARF-based compilation unit indexing for Mach-O binaries. +//! +//! Mirrors the logic in `delink-core::cu` but adapted to work with a generic +//! `gimli::Dwarf` handle rather than an ELF-specific `Binary<'a>`. + +use anyhow::{anyhow, Result}; +use gimli::{AttributeValue, DebuggingInformationEntry, Dwarf, EndianSlice, LittleEndian, Unit}; +use std::ops::Range; + +type Slice<'a> = EndianSlice<'a, LittleEndian>; + +#[derive(Debug, Clone)] +pub struct MachoFunction { + /// Demangled or source name. + pub name: String, + /// Mangled / linkage name (preferred as the symbol name in .o files). + pub linkage_name: Option, + pub addr: u64, + pub size: u64, + pub external: bool, +} + +impl MachoFunction { + pub fn symbol_name(&self) -> &str { + self.linkage_name.as_deref().unwrap_or(&self.name) + } +} + +#[derive(Debug, Clone)] +pub struct MachoVariable { + pub name: String, + pub linkage_name: Option, + pub addr: u64, + pub external: bool, +} + +impl MachoVariable { + pub fn symbol_name(&self) -> &str { + self.linkage_name.as_deref().unwrap_or(&self.name) + } +} + +#[derive(Debug, Clone)] +pub struct MachoCompilationUnit { + pub id: usize, + pub name: String, + pub comp_dir: Option, + /// Path to the original object file, as recorded in STABS N_OSO or DWARF DW_AT_comp_dir. + pub oso_path: Option, + pub ranges: Vec>, + pub functions: Vec, + pub variables: Vec, +} + +impl MachoCompilationUnit { + pub fn text_size(&self) -> u64 { + self.functions.iter().map(|f| f.size).sum() + } +} + +/// How the CU index was built — determines which split strategy to use. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DebugInfoSource { + /// DWARF `__debug_info` section. + Dwarf, + /// STABS symbols in LC_SYMTAB. + Stabs, + /// Raw N_SECT symbols only (no debug info present). + Symtab, +} + +pub struct MachoCuIndex { + pub units: Vec, + pub source: DebugInfoSource, +} + +pub fn build_cu_index(dwarf: &Dwarf>) -> Result { + let mut units = Vec::new(); + let mut cu_id = 0usize; + let mut headers = dwarf.units(); + + while let Some(header) = headers.next().map_err(|e| anyhow!("DWARF units: {e}"))? { + let unit = dwarf.unit(header).map_err(|e| anyhow!("DWARF unit: {e}"))?; + if let Some(cu) = build_unit(dwarf, &unit, cu_id)? { + units.push(cu); + cu_id += 1; + } + } + + Ok(MachoCuIndex { + units, + source: DebugInfoSource::Dwarf, + }) +} + +fn build_unit( + dwarf: &Dwarf>, + unit: &Unit>, + id: usize, +) -> Result> { + let mut entries = unit.entries(); + let Some(root) = entries.next_dfs().map_err(|e| anyhow!("DIE tree: {e}"))? else { + return Ok(None); + }; + if root.tag() != gimli::DW_TAG_compile_unit { + return Ok(None); + } + + let name = + attr_string(dwarf, unit, root, gimli::DW_AT_name)?.unwrap_or_else(|| "".into()); + let comp_dir = attr_string(dwarf, unit, root, gimli::DW_AT_comp_dir)?; + + let mut ranges = Vec::new(); + let mut range_iter = dwarf + .unit_ranges(unit) + .map_err(|e| anyhow!("unit ranges: {e}"))?; + while let Some(r) = range_iter.next().map_err(|e| anyhow!("range entry: {e}"))? { + if r.begin < r.end { + ranges.push(r.begin..r.end); + } + } + + let mut functions = Vec::new(); + let mut variables = Vec::new(); + + let mut entries = unit.entries(); + while let Some(entry) = entries.next_dfs().map_err(|e| anyhow!("DIE entry: {e}"))? { + match entry.tag() { + gimli::DW_TAG_subprogram => { + if let Some(f) = extract_function(dwarf, unit, entry)? { + functions.push(f); + } + } + gimli::DW_TAG_variable => { + if let Some(v) = extract_variable(dwarf, unit, entry)? { + variables.push(v); + } + } + _ => {} + } + } + + if functions.is_empty() && variables.is_empty() && ranges.is_empty() { + return Ok(None); + } + + Ok(Some(MachoCompilationUnit { + id, + name, + comp_dir, + oso_path: None, + ranges, + functions, + variables, + })) +} + +fn extract_function( + dwarf: &Dwarf>, + unit: &Unit>, + entry: &DebuggingInformationEntry>, +) -> Result> { + // Skip abstract/inline-only entries. + if entry.attr_value(gimli::DW_AT_inline).is_some() + && entry.attr_value(gimli::DW_AT_low_pc).is_none() + { + return Ok(None); + } + + let Some(addr) = attr_address(dwarf, unit, entry, gimli::DW_AT_low_pc)? else { + return Ok(None); + }; + let size = match entry.attr_value(gimli::DW_AT_high_pc) { + Some(AttributeValue::Addr(end)) => end.saturating_sub(addr), + Some(AttributeValue::Udata(s)) => s, + Some(AttributeValue::Data1(s)) => s as u64, + Some(AttributeValue::Data2(s)) => s as u64, + Some(AttributeValue::Data4(s)) => s as u64, + Some(AttributeValue::Data8(s)) => s, + _ => 0, + }; + + let (name, linkage_name, external) = resolve_names(dwarf, unit, entry)?; + let name = name.unwrap_or_else(|| "".into()); + + Ok(Some(MachoFunction { + name, + linkage_name, + addr, + size, + external, + })) +} + +fn extract_variable( + dwarf: &Dwarf>, + unit: &Unit>, + entry: &DebuggingInformationEntry>, +) -> Result> { + let Some(addr) = variable_address(dwarf, unit, entry)? else { + return Ok(None); + }; + let name = + attr_string(dwarf, unit, entry, gimli::DW_AT_name)?.unwrap_or_else(|| "".into()); + let linkage_name = attr_string(dwarf, unit, entry, gimli::DW_AT_linkage_name)?.or(attr_string( + dwarf, + unit, + entry, + gimli::DW_AT_MIPS_linkage_name, + )?); + let external = matches!( + entry.attr_value(gimli::DW_AT_external), + Some(AttributeValue::Flag(true)) + ); + Ok(Some(MachoVariable { + name, + linkage_name, + addr, + external, + })) +} + +fn variable_address( + dwarf: &Dwarf>, + unit: &Unit>, + entry: &DebuggingInformationEntry>, +) -> Result> { + let Some(attr) = entry.attr_value(gimli::DW_AT_location) else { + return Ok(None); + }; + let expr = match attr { + AttributeValue::Exprloc(e) => e, + _ => return Ok(None), + }; + let mut ops = expr.operations(unit.encoding()); + match ops.next().map_err(|e| anyhow!("DWARF op: {e}"))? { + Some(gimli::Operation::Address { address }) => Ok(Some(address)), + Some(gimli::Operation::AddressIndex { index }) => Ok(Some( + dwarf.address(unit, index).map_err(|e| anyhow!("{e}"))?, + )), + _ => Ok(None), + } +} + +/// Walk DW_AT_specification / DW_AT_abstract_origin to find name + linkage_name. +fn resolve_names( + dwarf: &Dwarf>, + unit: &Unit>, + entry: &DebuggingInformationEntry>, +) -> Result<(Option, Option, bool)> { + let mut name = attr_string(dwarf, unit, entry, gimli::DW_AT_name)?; + let mut linkage = attr_string(dwarf, unit, entry, gimli::DW_AT_linkage_name)?.or(attr_string( + dwarf, + unit, + entry, + gimli::DW_AT_MIPS_linkage_name, + )?); + let mut external = matches!( + entry.attr_value(gimli::DW_AT_external), + Some(AttributeValue::Flag(true)) + ); + + let mut ref_off = match entry.attr_value(gimli::DW_AT_specification) { + Some(AttributeValue::UnitRef(o)) => Some(o), + _ => match entry.attr_value(gimli::DW_AT_abstract_origin) { + Some(AttributeValue::UnitRef(o)) => Some(o), + _ => None, + }, + }; + + let mut hops = 0usize; + while let Some(off) = ref_off { + if hops > 4 { + break; + } + hops += 1; + let Ok(ref_entry) = unit.entry(off) else { + break; + }; + if name.is_none() { + name = attr_string(dwarf, unit, &ref_entry, gimli::DW_AT_name)?; + } + if linkage.is_none() { + linkage = attr_string(dwarf, unit, &ref_entry, gimli::DW_AT_linkage_name)?.or( + attr_string(dwarf, unit, &ref_entry, gimli::DW_AT_MIPS_linkage_name)?, + ); + } + if !external { + external = matches!( + ref_entry.attr_value(gimli::DW_AT_external), + Some(AttributeValue::Flag(true)) + ); + } + ref_off = match ref_entry.attr_value(gimli::DW_AT_specification) { + Some(AttributeValue::UnitRef(o)) => Some(o), + _ => match ref_entry.attr_value(gimli::DW_AT_abstract_origin) { + Some(AttributeValue::UnitRef(o)) => Some(o), + _ => None, + }, + }; + } + + Ok((name, linkage, external)) +} + +fn attr_string( + dwarf: &Dwarf>, + unit: &Unit>, + entry: &DebuggingInformationEntry>, + name: gimli::DwAt, +) -> Result> { + let Some(attr) = entry.attr(name) else { + return Ok(None); + }; + let val = attr.value(); + let s = dwarf.attr_string(unit, val).map_err(|e| anyhow!("{e}"))?; + Ok(Some(s.to_string_lossy().into_owned())) +} + +fn attr_address( + dwarf: &Dwarf>, + unit: &Unit>, + entry: &DebuggingInformationEntry>, + name: gimli::DwAt, +) -> Result> { + match entry.attr_value(name) { + Some(AttributeValue::Addr(a)) => Ok(Some(a)), + Some(AttributeValue::DebugAddrIndex(i)) => { + Ok(Some(dwarf.address(unit, i).map_err(|e| anyhow!("{e}"))?)) + } + _ => Ok(None), + } +} diff --git a/crates/delink-macho/src/emit.rs b/crates/delink-macho/src/emit.rs new file mode 100644 index 0000000..82ed4ac --- /dev/null +++ b/crates/delink-macho/src/emit.rs @@ -0,0 +1,767 @@ +//! Mach-O relocatable `.o` emitter for Mach-O compilation units. +//! +//! Produces one Mach-O `.o` per `MachoCompilationUnit` (code only) plus a +//! single `__shared_data.o` carrying `__DATA,__data` / `__DATA,__const` / +//! `__DATA,__bss`. +//! +//! Currently handles 32-bit i386 only (GENERIC_RELOC_VANILLA). + +use anyhow::{anyhow, Context, Result}; +use object::write::{Object, Relocation, Symbol, SymbolId, SymbolSection}; +use object::{ + Architecture, BinaryFormat, Endianness, RelocationFlags, SectionKind, SymbolFlags, SymbolKind, + SymbolScope, +}; +use rayon::prelude::*; +use std::collections::HashMap; +use std::path::Path; + +use crate::cu::{MachoCompilationUnit, MachoFunction}; +use crate::symbols::{SYM_BSS_START, SYM_CONST_START, SYM_DATA_START}; +use crate::{MachoArch, MachoContext}; + +// Mach-O i386 relocation constants (GENERIC_RELOC_VANILLA = 0). +const GENERIC_RELOC_VANILLA: u8 = object::macho::GENERIC_RELOC_VANILLA; +// r_length = 2 → 4-byte field (2^2 = 4). +const R_LENGTH_4: u8 = 2; + +// ELF addend adjustment: R_386_PC32 uses S+A−P where P is the field start, so +// we subtract 4 (the field width) to account for x86's next-instruction-relative +// branch target convention (same adjustment as the COFF emitter). +const REL32_FIELD_BYTES: i64 = 4; + +// Symbol names used in the ELF shared-data object (match delink-core conventions). +const ELF_SYM_DATA_START: &[u8] = b"__delink_data_start"; +const ELF_SYM_RODATA_START: &[u8] = b"__delink_rodata_start"; +const ELF_SYM_BSS_START: &[u8] = b"__delink_bss_start"; + +#[derive(Debug, Default)] +pub struct EmitStats { + pub text_bytes: u64, + pub local_symbols: usize, + pub undef_symbols: usize, + pub relocations: usize, + pub unresolved_calls: usize, + pub instructions: usize, +} + +#[derive(Debug, Default)] +pub struct SharedDataStats { + pub data_bytes: u64, + pub const_bytes: u64, + pub bss_bytes: u64, +} + +#[derive(Debug)] +pub struct CuOutcome { + pub cu_id: usize, + pub cu_name: String, + pub file: std::path::PathBuf, + pub result: std::result::Result, +} + +// --------------------------------------------------------------------------- +// Per-CU emit +// --------------------------------------------------------------------------- + +pub fn emit_macho_cu( + ctx: &MachoContext, + cu: &MachoCompilationUnit, + out_path: &Path, +) -> Result { + let text_section = ctx + .text_section() + .ok_or_else(|| anyhow!("binary has no __TEXT,__text section"))?; + + let mut live: Vec<&MachoFunction> = cu + .functions + .iter() + .filter(|f| f.size > 0 && text_section.contains_addr(f.addr)) + .collect(); + + if live.is_empty() { + return Err(anyhow!("CU '{}' has no functions inside __text", cu.name)); + } + live.sort_by_key(|f| f.addr); + + let (macho_arch, endianness) = arch_to_object(ctx.arch); + let mut obj = Object::new(BinaryFormat::MachO, macho_arch, endianness); + let mut local_syms: HashMap = HashMap::new(); + let mut undef_cache: HashMap = HashMap::new(); + + let text_sid = obj.add_section(b"__TEXT".to_vec(), b"__text".to_vec(), SectionKind::Text); + + let mut stats = EmitStats::default(); + let recover_x86 = ctx.arch == MachoArch::X86; + + for f in &live { + let fn_start = (f.addr - text_section.addr) as usize; + let fn_end = fn_start + f.size as usize; + if fn_end > text_section.data.len() { + tracing::warn!( + "function '{}' at {:#x} extends past __text; skipping", + f.name, + f.addr + ); + continue; + } + let mut fn_bytes = text_section.data[fn_start..fn_end].to_vec(); + stats.text_bytes += f.size; + + // For x86 we recover pc-relative relocations from the instruction stream. + // For other architectures (PPC etc.) we emit raw bytes without synthetic + // relocations — the bytes are correct but non-relocatable. + let relocs_to_emit: Vec = if recover_x86 { + let recovery = delink_x86::recover(&fn_bytes, f.addr, f.size, &ctx.symbols) + .with_context(|| format!("recover relocs for '{}' at {:#x}", f.name, f.addr))?; + stats.instructions += recovery.diag.instructions; + stats.unresolved_calls += recovery.diag.calls_unresolved; + // Zero the rel32 displacement fields before appending. + for r in &recovery.relocs { + let off = r.offset as usize; + if off + 4 <= fn_bytes.len() { + fn_bytes[off..off + 4].fill(0); + } + } + recovery.relocs + } else { + vec![] + }; + + let fn_offset = obj.append_section_data(text_sid, &fn_bytes, 4); + + // Emit the function symbol. + let scope = if f.external { + SymbolScope::Dynamic + } else { + SymbolScope::Compilation + }; + let sym_name = sanitize_symbol_name(f.symbol_name()); + let sym_id = obj.add_symbol(Symbol { + name: sym_name.clone(), + value: fn_offset, + size: f.size, + kind: SymbolKind::Text, + scope, + weak: false, + section: SymbolSection::Section(text_sid), + flags: SymbolFlags::None, + }); + local_syms.insert(f.symbol_name().to_string(), sym_id); + + // Emit variable labels that fall inside this function. + for (var_va, var) in ctx.symbols.variables.range(f.addr..f.addr + f.size) { + if *var_va == f.addr { + continue; + } + let label_scope = if var.external { + SymbolScope::Dynamic + } else { + SymbolScope::Compilation + }; + let label_id = obj.add_symbol(Symbol { + name: sanitize_symbol_name(var.symbol_name()), + value: fn_offset + (var_va - f.addr), + size: 0, + kind: SymbolKind::Label, + scope: label_scope, + weak: false, + section: SymbolSection::Section(text_sid), + flags: SymbolFlags::None, + }); + local_syms.insert(var.symbol_name().to_string(), label_id); + } + + // Emit synthetic relocations (x86 only). + for r in &relocs_to_emit { + let sym_id = resolve_symbol(&mut obj, &local_syms, &mut undef_cache, &r.target); + obj.add_relocation( + text_sid, + Relocation { + offset: fn_offset + r.offset, + symbol: sym_id, + // Mach-O i386 uses implicit addends; the object crate adds + // `addend` to whatever is already in the section bytes. + // Since we zeroed the field above, embedded = addend. + addend: r.addend, + flags: RelocationFlags::MachO { + r_type: GENERIC_RELOC_VANILLA, + r_pcrel: true, + r_length: R_LENGTH_4, + }, + }, + ) + .with_context(|| format!("add reloc at {:#x} → '{}'", f.addr + r.offset, r.target))?; + stats.relocations += 1; + } + } + + stats.local_symbols = local_syms.len(); + stats.undef_symbols = undef_cache.len(); + + let bytes = obj.write().context("serialize Mach-O object")?; + write_file(out_path, &bytes)?; + Ok(stats) +} + +// --------------------------------------------------------------------------- +// Per-CU emit — ELF output +// --------------------------------------------------------------------------- + +/// Like [`emit_macho_cu`] but writes a standard ET_REL ELF object. +/// +/// Sections use ELF naming (`.text`). For i386 input the synthesised +/// PC-relative relocations are emitted as `R_386_PC32`. Other +/// architectures produce raw bytes without synthetic relocations (same +/// limitation as the Mach-O path). +pub fn emit_elf_cu( + ctx: &MachoContext, + cu: &MachoCompilationUnit, + out_path: &Path, +) -> Result { + let text_section = ctx + .text_section() + .ok_or_else(|| anyhow!("binary has no __TEXT,__text section"))?; + + let mut live: Vec<&MachoFunction> = cu + .functions + .iter() + .filter(|f| f.size > 0 && text_section.contains_addr(f.addr)) + .collect(); + + if live.is_empty() { + return Err(anyhow!("CU '{}' has no functions inside __text", cu.name)); + } + live.sort_by_key(|f| f.addr); + + let (elf_arch, endianness) = arch_to_object(ctx.arch); + let mut obj = Object::new(BinaryFormat::Elf, elf_arch, endianness); + let mut local_syms: HashMap = HashMap::new(); + let mut undef_cache: HashMap = HashMap::new(); + + let text_sid = obj.add_section(Vec::new(), b".text".to_vec(), SectionKind::Text); + + let mut stats = EmitStats::default(); + let recover_x86 = ctx.arch == MachoArch::X86; + + for f in &live { + let fn_start = (f.addr - text_section.addr) as usize; + let fn_end = fn_start + f.size as usize; + if fn_end > text_section.data.len() { + tracing::warn!( + "function '{}' at {:#x} extends past __text; skipping", + f.name, + f.addr + ); + continue; + } + let mut fn_bytes = text_section.data[fn_start..fn_end].to_vec(); + stats.text_bytes += f.size; + + let relocs_to_emit: Vec = if recover_x86 { + let recovery = delink_x86::recover(&fn_bytes, f.addr, f.size, &ctx.symbols) + .with_context(|| format!("recover relocs for '{}' at {:#x}", f.name, f.addr))?; + stats.instructions += recovery.diag.instructions; + stats.unresolved_calls += recovery.diag.calls_unresolved; + for r in &recovery.relocs { + let off = r.offset as usize; + if off + 4 <= fn_bytes.len() { + fn_bytes[off..off + 4].fill(0); + } + } + recovery.relocs + } else { + vec![] + }; + + let fn_offset = obj.append_section_data(text_sid, &fn_bytes, 4); + + let scope = if f.external { + SymbolScope::Dynamic + } else { + SymbolScope::Compilation + }; + let sym_name = sanitize_symbol_name(f.symbol_name()); + let sym_id = obj.add_symbol(Symbol { + name: sym_name.clone(), + value: fn_offset, + size: f.size, + kind: SymbolKind::Text, + scope, + weak: false, + section: SymbolSection::Section(text_sid), + flags: SymbolFlags::None, + }); + local_syms.insert(f.symbol_name().to_string(), sym_id); + + for (var_va, var) in ctx.symbols.variables.range(f.addr..f.addr + f.size) { + if *var_va == f.addr { + continue; + } + let label_scope = if var.external { + SymbolScope::Dynamic + } else { + SymbolScope::Compilation + }; + let label_id = obj.add_symbol(Symbol { + name: sanitize_symbol_name(var.symbol_name()), + value: fn_offset + (var_va - f.addr), + size: 0, + kind: SymbolKind::Label, + scope: label_scope, + weak: false, + section: SymbolSection::Section(text_sid), + flags: SymbolFlags::None, + }); + local_syms.insert(var.symbol_name().to_string(), label_id); + } + + for r in &relocs_to_emit { + let sym_id = resolve_symbol(&mut obj, &local_syms, &mut undef_cache, &r.target); + obj.add_relocation( + text_sid, + Relocation { + offset: fn_offset + r.offset, + symbol: sym_id, + addend: r.addend - REL32_FIELD_BYTES, + flags: RelocationFlags::Elf { + r_type: object::elf::R_386_PC32, + }, + }, + ) + .with_context(|| format!("add reloc at {:#x} → '{}'", f.addr + r.offset, r.target))?; + stats.relocations += 1; + } + } + + stats.local_symbols = local_syms.len(); + stats.undef_symbols = undef_cache.len(); + + let bytes = obj.write().context("serialize ELF object")?; + write_file(out_path, &bytes)?; + Ok(stats) +} + +// --------------------------------------------------------------------------- +// Shared data emit +// --------------------------------------------------------------------------- + +pub fn emit_macho_shared(ctx: &MachoContext, out_path: &Path) -> Result { + let (macho_arch, endianness) = arch_to_object(ctx.arch); + let mut obj = Object::new(BinaryFormat::MachO, macho_arch, endianness); + let mut stats = SharedDataStats::default(); + + // Track (section_id, section_va, section_size) for each data section added. + let mut data_slots: Vec<(object::write::SectionId, u64, u64)> = Vec::new(); + + if let Some(s) = ctx + .sections + .iter() + .find(|s| s.segment == "__DATA" && s.name == "__data") + { + let sid = obj.add_section(b"__DATA".to_vec(), b"__data".to_vec(), SectionKind::Data); + obj.append_section_data(sid, &s.data, 16); + obj.add_symbol(Symbol { + name: SYM_DATA_START.as_bytes().to_vec(), + value: 0, + size: 0, + kind: SymbolKind::Data, + scope: SymbolScope::Dynamic, + weak: false, + section: SymbolSection::Section(sid), + flags: SymbolFlags::None, + }); + data_slots.push((sid, s.addr, s.size)); + stats.data_bytes = s.size; + } + + if let Some(s) = ctx + .sections + .iter() + .find(|s| s.segment == "__DATA" && s.name == "__const") + { + let sid = obj.add_section( + b"__DATA".to_vec(), + b"__const".to_vec(), + SectionKind::ReadOnlyData, + ); + obj.append_section_data(sid, &s.data, 16); + obj.add_symbol(Symbol { + name: SYM_CONST_START.as_bytes().to_vec(), + value: 0, + size: 0, + kind: SymbolKind::Data, + scope: SymbolScope::Dynamic, + weak: false, + section: SymbolSection::Section(sid), + flags: SymbolFlags::None, + }); + data_slots.push((sid, s.addr, s.size)); + stats.const_bytes = s.size; + } + + if let Some(s) = ctx + .sections + .iter() + .find(|s| s.segment == "__DATA" && s.name == "__bss") + { + let sid = obj.add_section( + b"__DATA".to_vec(), + b"__bss".to_vec(), + SectionKind::UninitializedData, + ); + obj.section_mut(sid).append_bss(s.size, 16); + obj.add_symbol(Symbol { + name: SYM_BSS_START.as_bytes().to_vec(), + value: 0, + size: 0, + kind: SymbolKind::Data, + scope: SymbolScope::Dynamic, + weak: false, + section: SymbolSection::Section(sid), + flags: SymbolFlags::None, + }); + data_slots.push((sid, s.addr, s.size)); + stats.bss_bytes = s.size; + } + + // Emit named variables into whichever data section contains their address. + for (var_va, var) in &ctx.symbols.variables { + for &(sid, sec_addr, sec_size) in &data_slots { + if *var_va >= sec_addr && *var_va < sec_addr + sec_size { + let offset = var_va - sec_addr; + let scope = if var.external { + SymbolScope::Dynamic + } else { + SymbolScope::Compilation + }; + obj.add_symbol(Symbol { + name: sanitize_symbol_name(var.symbol_name()), + value: offset, + size: 0, + kind: SymbolKind::Data, + scope, + weak: false, + section: SymbolSection::Section(sid), + flags: SymbolFlags::None, + }); + break; + } + } + } + + let bytes = obj.write().context("serialize shared Mach-O object")?; + write_file(out_path, &bytes)?; + Ok(stats) +} + +// --------------------------------------------------------------------------- +// Shared data emit — ELF output +// --------------------------------------------------------------------------- + +/// Like [`emit_macho_shared`] but writes standard ET_REL ELF sections. +/// +/// Section mapping: +/// - `__DATA,__data` → `.data` with `__delink_data_start` +/// - `__DATA,__const` → `.rodata` with `__delink_rodata_start` +/// - `__DATA,__bss` → `.bss` with `__delink_bss_start` +pub fn emit_elf_shared(ctx: &MachoContext, out_path: &Path) -> Result { + let (elf_arch, endianness) = arch_to_object(ctx.arch); + let mut obj = Object::new(BinaryFormat::Elf, elf_arch, endianness); + let mut stats = SharedDataStats::default(); + + let mut data_slots: Vec<(object::write::SectionId, u64, u64)> = Vec::new(); + + if let Some(s) = ctx + .sections + .iter() + .find(|s| s.segment == "__DATA" && s.name == "__data") + { + let sid = obj.add_section(Vec::new(), b".data".to_vec(), SectionKind::Data); + obj.append_section_data(sid, &s.data, 16); + obj.add_symbol(Symbol { + name: ELF_SYM_DATA_START.to_vec(), + value: 0, + size: 0, + kind: SymbolKind::Data, + scope: SymbolScope::Dynamic, + weak: false, + section: SymbolSection::Section(sid), + flags: SymbolFlags::None, + }); + data_slots.push((sid, s.addr, s.size)); + stats.data_bytes = s.size; + } + + if let Some(s) = ctx + .sections + .iter() + .find(|s| s.segment == "__DATA" && s.name == "__const") + { + let sid = obj.add_section(Vec::new(), b".rodata".to_vec(), SectionKind::ReadOnlyData); + obj.append_section_data(sid, &s.data, 16); + obj.add_symbol(Symbol { + name: ELF_SYM_RODATA_START.to_vec(), + value: 0, + size: 0, + kind: SymbolKind::Data, + scope: SymbolScope::Dynamic, + weak: false, + section: SymbolSection::Section(sid), + flags: SymbolFlags::None, + }); + data_slots.push((sid, s.addr, s.size)); + stats.const_bytes = s.size; + } + + if let Some(s) = ctx + .sections + .iter() + .find(|s| s.segment == "__DATA" && s.name == "__bss") + { + let sid = obj.add_section(Vec::new(), b".bss".to_vec(), SectionKind::UninitializedData); + obj.section_mut(sid).append_bss(s.size, 16); + obj.add_symbol(Symbol { + name: ELF_SYM_BSS_START.to_vec(), + value: 0, + size: 0, + kind: SymbolKind::Data, + scope: SymbolScope::Dynamic, + weak: false, + section: SymbolSection::Section(sid), + flags: SymbolFlags::None, + }); + data_slots.push((sid, s.addr, s.size)); + stats.bss_bytes = s.size; + } + + for (var_va, var) in &ctx.symbols.variables { + for &(sid, sec_addr, sec_size) in &data_slots { + if *var_va >= sec_addr && *var_va < sec_addr + sec_size { + let offset = var_va - sec_addr; + let scope = if var.external { + SymbolScope::Dynamic + } else { + SymbolScope::Compilation + }; + obj.add_symbol(Symbol { + name: sanitize_symbol_name(var.symbol_name()), + value: offset, + size: 0, + kind: SymbolKind::Data, + scope, + weak: false, + section: SymbolSection::Section(sid), + flags: SymbolFlags::None, + }); + break; + } + } + } + + let bytes = obj.write().context("serialize shared ELF object")?; + write_file(out_path, &bytes)?; + Ok(stats) +} + +// --------------------------------------------------------------------------- +// Symtab-driven split +// --------------------------------------------------------------------------- + +/// Split driven by a user-editable `SymtabJson`. +/// +/// Functions that share the same `cu` field are emitted into one object file +/// whose name is exactly that `cu` value. The output path for each file is +/// `out_dir/`. +pub fn split_by_symtab( + ctx: &MachoContext, + symtab: &crate::symtab_json::SymtabJson, + lookup: &crate::symtab_json::SymtabLookup, + out_dir: &Path, + emit_as_elf: bool, +) -> Result> { + use crate::cu::{MachoCompilationUnit, MachoFunction}; + + std::fs::create_dir_all(out_dir).with_context(|| format!("create {}", out_dir.display()))?; + + let group_vec: Vec<(&String, &Vec)> = symtab.iter().collect(); + + let outcomes: Vec = group_vec + .par_iter() + .enumerate() + .map(|(id, (cu_filename, names))| { + let mut resolved: Vec<(String, u64, u64, bool)> = names + .iter() + .filter_map(|name| { + lookup + .get(name.as_str()) + .map(|info| (name.clone(), info.addr, info.size, info.external)) + }) + .collect(); + resolved.sort_by_key(|(_, addr, _, _)| *addr); + + let functions: Vec = resolved + .iter() + .map(|(name, addr, size, external)| MachoFunction { + name: name.clone(), + linkage_name: Some(name.clone()), + addr: *addr, + size: *size, + external: *external, + }) + .collect(); + + let ranges = functions + .iter() + .filter(|f| f.size > 0) + .map(|f| f.addr..f.addr + f.size) + .collect(); + + let cu = MachoCompilationUnit { + id, + name: cu_filename + .strip_suffix(".o") + .unwrap_or(cu_filename) + .to_string(), + comp_dir: None, + oso_path: None, + ranges, + functions, + variables: vec![], + }; + + // Use the cu field value directly as the output filename. + let file = out_dir.join(cu_filename); + let result = if emit_as_elf { + emit_elf_cu(ctx, &cu, &file) + } else { + emit_macho_cu(ctx, &cu, &file) + } + .map_err(|e| format!("{e:#}")); + CuOutcome { + cu_id: id, + cu_name: cu_filename.to_string(), + file, + result, + } + }) + .collect(); + + Ok(outcomes) +} + +// --------------------------------------------------------------------------- +// Parallel split (DWARF/STABS CU-based) +// --------------------------------------------------------------------------- + +pub fn split_all_macho( + ctx: &MachoContext, + out_dir: &Path, + emit_as_elf: bool, +) -> Result> { + std::fs::create_dir_all(out_dir).with_context(|| format!("create {}", out_dir.display()))?; + + let outcomes: Vec = ctx + .cu_index + .units + .par_iter() + .filter(|cu| cu.functions.iter().any(|f| f.size > 0)) + .map(|cu| { + let stem = sanitize_file_stem(&cu.name); + let file = out_dir.join(format!("{:04}_{stem}.o", cu.id)); + let result = if emit_as_elf { + emit_elf_cu(ctx, cu, &file) + } else { + emit_macho_cu(ctx, cu, &file) + } + .map_err(|e| format!("{e:#}")); + CuOutcome { + cu_id: cu.id, + cu_name: cu.name.clone(), + file, + result, + } + }) + .collect(); + + Ok(outcomes) +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn resolve_symbol( + obj: &mut Object, + local: &HashMap, + undef: &mut HashMap, + name: &str, +) -> SymbolId { + if let Some(id) = local.get(name) { + return *id; + } + resolve_or_add_undef(obj, undef, name) +} + +fn resolve_or_add_undef( + obj: &mut Object, + undef: &mut HashMap, + name: &str, +) -> SymbolId { + if let Some(id) = undef.get(name) { + return *id; + } + let id = obj.add_symbol(Symbol { + name: sanitize_symbol_name(name), + value: 0, + size: 0, + kind: SymbolKind::Unknown, + scope: SymbolScope::Dynamic, + weak: false, + section: SymbolSection::Undefined, + flags: SymbolFlags::None, + }); + undef.insert(name.to_string(), id); + id +} + +fn sanitize_symbol_name(name: &str) -> Vec { + if name.is_empty() { + return b"".to_vec(); + } + name.as_bytes().to_vec() +} + +fn sanitize_file_stem(name: &str) -> String { + let basename = name.rsplit(['/', '\\']).next().unwrap_or(name); + let stem = match basename.rfind('.') { + Some(i) => &basename[..i], + None => basename, + }; + // Replace characters that are invalid in filenames. + stem.chars() + .map(|c| { + if c.is_alphanumeric() || c == '_' || c == '-' { + c + } else { + '_' + } + }) + .collect() +} + +fn write_file(path: &Path, bytes: &[u8]) -> Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).ok(); + } + std::fs::write(path, bytes).with_context(|| format!("write {}", path.display())) +} + +fn arch_to_object(arch: MachoArch) -> (Architecture, Endianness) { + match arch { + MachoArch::X86 => (Architecture::I386, Endianness::Little), + MachoArch::X86_64 => (Architecture::X86_64, Endianness::Little), + MachoArch::PPC => (Architecture::PowerPc, Endianness::Big), + MachoArch::PPC64 => (Architecture::PowerPc64, Endianness::Big), + } +} diff --git a/crates/delink-macho/src/lib.rs b/crates/delink-macho/src/lib.rs new file mode 100644 index 0000000..f955d0d --- /dev/null +++ b/crates/delink-macho/src/lib.rs @@ -0,0 +1,363 @@ +//! Mach-O (macOS/Darwin) binary loader and compilation-unit splitter. +//! +//! Supports 32-bit i386 Mach-O executables with DWARF debug information. +//! Reads DWARF to identify compilation units, synthesises relocations via +//! the x86 recovery pass, and emits one Mach-O relocatable `.o` per CU. + +use anyhow::{anyhow, Context, Result}; +use gimli::{EndianSlice, LittleEndian}; +use object::{Object as _, ObjectSection as _, SectionFlags}; +use std::collections::HashMap; + +pub mod cu; +pub mod emit; +pub mod stabs; +pub mod symbols; +pub mod symtab_json; +pub mod symtab_split; + +pub use cu::{DebugInfoSource, MachoCompilationUnit, MachoCuIndex, MachoFunction, MachoVariable}; +pub use emit::{CuOutcome, EmitStats, SharedDataStats}; +pub use symbols::MachoGlobalSymbols; + +type DwarfSlice<'a> = EndianSlice<'a, LittleEndian>; +type Dwarf<'a> = gimli::Dwarf>; + +/// Target architecture of the loaded Mach-O binary. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MachoArch { + /// 32-bit x86 / i386. + X86, + /// 64-bit x86-64. + X86_64, + /// 32-bit PowerPC (big-endian). + PPC, + /// 64-bit PowerPC (big-endian). + PPC64, +} + +/// A single Mach-O section with its raw bytes and metadata. +#[derive(Debug, Clone)] +pub struct MachoSection { + /// Segment name, e.g. `"__TEXT"` or `"__DATA"`. + pub segment: String, + /// Section name within the segment, e.g. `"__text"` or `"__data"`. + pub name: String, + /// Virtual memory address. + pub addr: u64, + /// Size in bytes (may be larger than `data.len()` for BSS). + pub size: u64, + /// Raw section bytes; empty for BSS-style sections. + pub data: Vec, + /// Section flags from the Mach-O header (`section_32.flags`). + pub flags: u32, +} + +impl MachoSection { + pub fn contains_addr(&self, addr: u64) -> bool { + addr >= self.addr && addr < self.addr + self.size + } + + pub fn data_at_addr(&self, addr: u64, len: usize) -> Option<&[u8]> { + if addr < self.addr { + return None; + } + let off = (addr - self.addr) as usize; + let end = off.checked_add(len)?; + self.data.get(off..end) + } +} + +/// All parsed data from a Mach-O binary. +pub struct MachoContext { + pub arch: MachoArch, + pub little_endian: bool, + pub sections: Vec, + pub cu_index: MachoCuIndex, + pub symbols: MachoGlobalSymbols, +} + +impl MachoContext { + pub fn section_for_addr(&self, addr: u64) -> Option<&MachoSection> { + self.sections.iter().find(|s| s.contains_addr(addr)) + } + + /// Returns the `__TEXT,__text` code section. + pub fn text_section(&self) -> Option<&MachoSection> { + self.sections + .iter() + .find(|s| s.segment == "__TEXT" && s.name == "__text") + } +} + +/// Load a Mach-O binary from raw bytes and index its DWARF. +pub fn load_macho(data: &[u8]) -> Result { + let file = object::File::parse(data).context("parse Mach-O")?; + + let arch = match file.architecture() { + object::Architecture::I386 => MachoArch::X86, + object::Architecture::X86_64 => MachoArch::X86_64, + object::Architecture::PowerPc => MachoArch::PPC, + object::Architecture::PowerPc64 => MachoArch::PPC64, + other => return Err(anyhow!("unsupported Mach-O architecture: {:?}", other)), + }; + + let little_endian = file.is_little_endian(); + + let sections = parse_sections(&file)?; + let stubs = parse_stubs(data).unwrap_or_else(|e| { + tracing::warn!("stub parsing failed (ignoring): {e:#}"); + HashMap::new() + }); + tracing::info!("parsed {} stubs from __symbol_stub", stubs.len()); + + let cu_index = { + let dwarf = load_dwarf(data, &file)?; + let idx = cu::build_cu_index(&dwarf)?; + if !idx.units.is_empty() { + idx + } else { + tracing::info!("no DWARF CUs found; falling back to STABS"); + let stabs_idx = stabs::build_cu_index_from_stabs(data, little_endian)?; + if !stabs_idx.units.is_empty() { + stabs_idx + } else { + tracing::info!("no STABS CUs found; falling back to symtab-based split"); + symtab_split::build_cu_index_from_symtab(data)? + } + } + }; + + let symbols = MachoGlobalSymbols::build(&cu_index, &stubs, §ions); + + Ok(MachoContext { + arch, + little_endian, + sections, + cu_index, + symbols, + }) +} + +// --------------------------------------------------------------------------- +// Section parsing +// --------------------------------------------------------------------------- + +fn parse_sections(file: &object::File<'_>) -> Result> { + let mut sections = Vec::new(); + for section in file.sections() { + let name = section.name().unwrap_or("").to_string(); + let segment = section + .segment_name() + .ok() + .flatten() + .unwrap_or("") + .to_string(); + let addr = section.address(); + let size = section.size(); + let data = section.data().unwrap_or(&[]).to_vec(); + let flags = match section.flags() { + SectionFlags::MachO { flags } => flags, + _ => 0, + }; + sections.push(MachoSection { + segment, + name, + addr, + size, + data, + flags, + }); + } + Ok(sections) +} + +// --------------------------------------------------------------------------- +// DWARF loading +// --------------------------------------------------------------------------- + +fn load_dwarf<'a>(_data: &'a [u8], file: &object::File<'a>) -> Result> { + let load_section = |id: gimli::SectionId| -> std::result::Result, gimli::Error> { + // Map ELF-style ".debug_info" → Mach-O "__debug_info" + let elf_name = id.name(); + let macho_name = format!("__{}", &elf_name[1..]); + let section_data: &'a [u8] = match file.section_by_name(&macho_name) { + Some(s) => s.data().unwrap_or(b""), + None => b"", + }; + Ok(EndianSlice::new(section_data, LittleEndian)) + }; + gimli::Dwarf::load(load_section).map_err(|e| anyhow!("load DWARF: {e}")) +} + +// --------------------------------------------------------------------------- +// Stub parsing (LC_SYMTAB + LC_DYSYMTAB → stub_addr → symbol_name) +// --------------------------------------------------------------------------- + +/// Parse the Mach-O symbol stubs, returning `stub_va → external_symbol_name`. +/// +/// Handles 32-bit i386 Mach-O only; returns an empty map for 64-bit or on +/// any parse error so that callers can degrade gracefully. +pub fn parse_stubs(data: &[u8]) -> Result> { + if data.len() < 4 { + return Ok(HashMap::new()); + } + let magic = u32::from_le_bytes(data[..4].try_into().unwrap()); + if magic == 0xFEED_FACE { + parse_stubs_32(data) + } else { + // 64-bit or fat binary — not implemented yet + Ok(HashMap::new()) + } +} + +fn parse_stubs_32(data: &[u8]) -> Result> { + // mach_header (32-bit) layout: + // magic(4) cputype(4) cpusubtype(4) filetype(4) ncmds(4) sizeofcmds(4) flags(4) + // total = 28 bytes + if data.len() < 28 { + return Ok(HashMap::new()); + } + let ncmds = u32::from_le_bytes(data[16..20].try_into().unwrap()) as usize; + let sizeofcmds = u32::from_le_bytes(data[20..24].try_into().unwrap()) as usize; + + const LC_SYMTAB: u32 = 0x2; + const LC_DYSYMTAB: u32 = 0xB; + const LC_SEGMENT: u32 = 0x1; + const S_SYMBOL_STUBS: u32 = 0x8; + const SECTION_TYPE: u32 = 0xFF; + const SEGMENT_CMD32_SIZE: usize = 56; + const SECTION32_SIZE: usize = 68; + + let cmds_start = 28usize; + let cmds_end = (cmds_start + sizeofcmds).min(data.len()); + + let mut symtab_off = 0u32; + let mut nsyms = 0u32; + let mut stroff = 0u32; + let mut indirectsymoff = 0u32; + let mut nindirectsyms = 0u32; + // (vmaddr, vmsize, first_indirect_idx, stub_size_bytes) + let mut stub_secs: Vec<(u64, u64, u32, u32)> = Vec::new(); + + let mut pos = cmds_start; + let mut count = 0; + while pos + 8 <= cmds_end && count < ncmds { + let cmd = u32::from_le_bytes(data[pos..pos + 4].try_into().unwrap()); + let cmdsize = u32::from_le_bytes(data[pos + 4..pos + 8].try_into().unwrap()) as usize; + if cmdsize < 8 || pos + cmdsize > cmds_end { + break; + } + + match cmd { + LC_SYMTAB => { + // symoff(4) nsyms(4) stroff(4) strsize(4) — starts at pos+8 + if cmdsize >= 24 { + symtab_off = u32::from_le_bytes(data[pos + 8..pos + 12].try_into().unwrap()); + nsyms = u32::from_le_bytes(data[pos + 12..pos + 16].try_into().unwrap()); + stroff = u32::from_le_bytes(data[pos + 16..pos + 20].try_into().unwrap()); + } + } + LC_DYSYMTAB => { + // indirectsymoff is at byte offset 56 within the command + if cmdsize >= 64 { + indirectsymoff = + u32::from_le_bytes(data[pos + 56..pos + 60].try_into().unwrap()); + nindirectsyms = + u32::from_le_bytes(data[pos + 60..pos + 64].try_into().unwrap()); + } + } + LC_SEGMENT if cmdsize >= SEGMENT_CMD32_SIZE => { + // nsects at offset 48 within segment_command_32 + let nsects = + u32::from_le_bytes(data[pos + 48..pos + 52].try_into().unwrap()) as usize; + for i in 0..nsects { + let sec_base = pos + SEGMENT_CMD32_SIZE + i * SECTION32_SIZE; + if sec_base + SECTION32_SIZE > cmds_end { + break; + } + let sec = &data[sec_base..sec_base + SECTION32_SIZE]; + // section_32: addr[32..36] size[36..40] flags[56..60] + // reserved1[60..64] reserved2[64..68] + let flags = u32::from_le_bytes(sec[56..60].try_into().unwrap()); + if flags & SECTION_TYPE == S_SYMBOL_STUBS { + let addr = u32::from_le_bytes(sec[32..36].try_into().unwrap()) as u64; + let size = u32::from_le_bytes(sec[36..40].try_into().unwrap()) as u64; + let reserved1 = u32::from_le_bytes(sec[60..64].try_into().unwrap()); + let reserved2 = u32::from_le_bytes(sec[64..68].try_into().unwrap()); + if reserved2 > 0 && size > 0 { + stub_secs.push((addr, size, reserved1, reserved2)); + } + } + } + } + _ => {} + } + + pos += cmdsize; + count += 1; + } + + // Read symbol table (nlist, 12 bytes per entry: strx(4) type(1) sect(1) desc(2) value(4)) + let sym_entry_size: usize = 12; + let sym_end = symtab_off as usize + nsyms as usize * sym_entry_size; + if sym_end > data.len() { + return Ok(HashMap::new()); + } + let sym_data = &data[symtab_off as usize..sym_end]; + let str_data = if (stroff as usize) < data.len() { + &data[stroff as usize..] + } else { + &[] + }; + + // Read indirect symbol table (u32 array) + let indir_end = indirectsymoff as usize + nindirectsyms as usize * 4; + let indir_data = if indir_end <= data.len() { + &data[indirectsymoff as usize..indir_end] + } else { + &[] + }; + + let read_sym_name = |sym_idx: u32| -> Option { + if sym_idx >= nsyms { + return None; + } + let off = sym_idx as usize * sym_entry_size; + let n_strx = u32::from_le_bytes(sym_data[off..off + 4].try_into().ok()?) as usize; + if n_strx >= str_data.len() { + return None; + } + let end = str_data[n_strx..].iter().position(|&b| b == 0).unwrap_or(0); + let s = String::from_utf8_lossy(&str_data[n_strx..n_strx + end]).into_owned(); + if s.is_empty() { + None + } else { + Some(s) + } + }; + + let mut map = HashMap::new(); + for (addr, size, first_idx, stub_size) in &stub_secs { + let n_stubs = (size / *stub_size as u64) as u32; + for i in 0..n_stubs { + let stub_addr = addr + i as u64 * *stub_size as u64; + let indir_idx = first_idx + i; + let entry_off = indir_idx as usize * 4; + if entry_off + 4 > indir_data.len() { + break; + } + let sym_idx = + u32::from_le_bytes(indir_data[entry_off..entry_off + 4].try_into().unwrap()); + // INDIRECT_SYMBOL_LOCAL (0x80000000) and INDIRECT_SYMBOL_ABS (0x40000000) — skip + if sym_idx >= 0x4000_0000 { + continue; + } + if let Some(name) = read_sym_name(sym_idx) { + map.insert(stub_addr, name); + } + } + } + + Ok(map) +} diff --git a/crates/delink-macho/src/stabs.rs b/crates/delink-macho/src/stabs.rs new file mode 100644 index 0000000..d399660 --- /dev/null +++ b/crates/delink-macho/src/stabs.rs @@ -0,0 +1,281 @@ +//! STABS symbol-table parser for Mach-O binaries. +//! +//! Reconstructs compilation units from LC_SYMTAB STABS entries when the +//! binary has no `__DWARF` segment. Produces the same `MachoCuIndex` +//! types as the DWARF path so the rest of the pipeline is unaffected. +//! +//! Supports both little-endian (x86/x86-64) and big-endian (PPC/PPC64) +//! Mach-O binaries. + +use anyhow::Result; + +use crate::cu::{MachoCompilationUnit, MachoCuIndex, MachoFunction, MachoVariable}; + +// STABS n_type codes (even values ≥ 0x20 are STABS; odd = regular sym flags). +const N_GSYM: u8 = 0x20; // global data symbol (value=0; addr in symtab) +const N_FUN: u8 = 0x24; // function start (value=addr) / end (name="", value=size) +const N_STSYM: u8 = 0x26; // file-scope static initialized data (value=addr) +const N_LCSYM: u8 = 0x28; // file-scope static .bss data (value=addr) +const N_SO: u8 = 0x64; // source file directory (1st) / filename (2nd) / end ("") +const N_OSO: u8 = 0x66; // object file path (Apple extension) + +// nlist_32 layout: strx(4) type(1) sect(1) desc(2) value(4) = 12 bytes +struct Nl { + strx: u32, + ntype: u8, + value: u32, +} + +pub fn build_cu_index_from_stabs(data: &[u8], little_endian: bool) -> Result { + let (syms, str_data) = match find_symtab(data, little_endian) { + Some(v) => v, + None => { + return Ok(MachoCuIndex { + units: vec![], + source: crate::cu::DebugInfoSource::Stabs, + }) + } + }; + + let read_str = |strx: u32| -> String { + let strx = strx as usize; + if strx >= str_data.len() { + return String::new(); + } + let end = str_data[strx..].iter().position(|&b| b == 0).unwrap_or(0); + String::from_utf8_lossy(&str_data[strx..strx + end]).into_owned() + }; + + let mut units = Vec::new(); + let mut cu_id = 0usize; + let mut i = 0usize; + + while i < syms.len() { + // Scan forward to the next N_SO that starts a CU. + if syms[i].ntype != N_SO { + i += 1; + continue; + } + let so1 = read_str(syms[i].strx); + if so1.is_empty() { + // End-of-CU marker; skip and keep looking. + i += 1; + continue; + } + i += 1; + + // Determine directory + filename. + let (comp_dir, cu_name) = + if i < syms.len() && syms[i].ntype == N_SO && !read_str(syms[i].strx).is_empty() { + let so2 = read_str(syms[i].strx); + i += 1; + (Some(so1), so2) + } else { + (None, so1) + }; + + // Capture optional N_OSO (original object file / archive member path). + let oso_path = if i < syms.len() && syms[i].ntype == N_OSO { + let s = read_str(syms[i].strx); + i += 1; + if s.is_empty() { + None + } else { + Some(s) + } + } else { + None + }; + + // Collect functions and variables until the next N_SO. + let mut functions: Vec = Vec::new(); + let mut variables: Vec = Vec::new(); + let mut open_fun: Option<(String, u64)> = None; + + while i < syms.len() { + let sym = &syms[i]; + match sym.ntype { + N_SO => break, // CU boundary — do NOT advance i; outer loop handles it. + N_FUN => { + let name = read_str(sym.strx); + if name.is_empty() { + // Closing record: value = size of the function. + if let Some((fname, faddr)) = open_fun.take() { + let size = sym.value as u64; + functions.push(make_fn(fname, faddr, size)); + } + } else { + // Opening record: close any previous unclosed function first. + if let Some((fname, faddr)) = open_fun.take() { + let size = (sym.value as u64).saturating_sub(faddr); + functions.push(make_fn(fname, faddr, size)); + } + open_fun = Some((stabs_name(&name), sym.value as u64)); + } + } + N_STSYM | N_LCSYM => { + let addr = sym.value as u64; + let raw = read_str(sym.strx); + let name = stabs_name(&raw); + if addr != 0 && !name.is_empty() { + variables.push(MachoVariable { + name: name.clone(), + linkage_name: Some(name), + addr, + external: false, + }); + } + } + N_GSYM => { + // Global data symbol — address is 0 in STABS; skip for now. + } + _ => {} + } + i += 1; + } + + // Close any function whose end record was missing. + if let Some((fname, faddr)) = open_fun.take() { + if faddr != 0 { + functions.push(make_fn(fname, faddr, 0)); + } + } + + if functions.is_empty() && variables.is_empty() { + continue; + } + + let ranges = functions + .iter() + .filter(|f| f.size > 0) + .map(|f| f.addr..f.addr + f.size) + .collect(); + + units.push(MachoCompilationUnit { + id: cu_id, + name: cu_name, + comp_dir, + oso_path, + ranges, + functions, + variables, + }); + cu_id += 1; + } + + tracing::info!("STABS: parsed {} compilation units", units.len()); + Ok(MachoCuIndex { + units, + source: crate::cu::DebugInfoSource::Stabs, + }) +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn find_symtab(data: &[u8], le: bool) -> Option<(Vec, &[u8])> { + if data.len() < 28 { + return None; + } + + let r32 = |off: usize| -> Option { + let b: [u8; 4] = data.get(off..off + 4)?.try_into().ok()?; + Some(if le { + u32::from_le_bytes(b) + } else { + u32::from_be_bytes(b) + }) + }; + + let ncmds = r32(16)? as usize; + let sizeofcmds = r32(20)? as usize; + let cmds_end = (28 + sizeofcmds).min(data.len()); + + let mut symtab_off = 0u32; + let mut nsyms = 0u32; + let mut stroff = 0u32; + + let mut pos = 28usize; + let mut count = 0; + while pos + 8 <= cmds_end && count < ncmds { + let cmd = r32(pos)?; + let cmdsize = r32(pos + 4)? as usize; + if cmd == 0x2 && cmdsize >= 24 { + symtab_off = r32(pos + 8)?; + nsyms = r32(pos + 12)?; + stroff = r32(pos + 16)?; + break; + } + if cmdsize == 0 { + break; + } + pos += cmdsize; + count += 1; + } + + if nsyms == 0 { + return None; + } + + let sym_start = symtab_off as usize; + let sym_end = sym_start.checked_add(nsyms as usize * 12)?; + if sym_end > data.len() { + return None; + } + + let sym_data = &data[sym_start..sym_end]; + let str_data = if (stroff as usize) < data.len() { + &data[stroff as usize..] + } else { + &[] + }; + + let r32_chunk = |chunk: &[u8], off: usize| -> u32 { + let b: [u8; 4] = chunk[off..off + 4].try_into().unwrap(); + if le { + u32::from_le_bytes(b) + } else { + u32::from_be_bytes(b) + } + }; + + let mut syms = Vec::with_capacity(nsyms as usize); + for chunk in sym_data.chunks_exact(12) { + let ntype = chunk[4]; + // Only keep STABS entries (even values ≥ 0x20, no N_EXT / N_PEXT bits). + if ntype < 0x20 || ntype & 1 != 0 { + syms.push(Nl { + strx: 0, + ntype, + value: 0, + }); + continue; + } + syms.push(Nl { + strx: r32_chunk(chunk, 0), + ntype, + value: r32_chunk(chunk, 8), + }); + } + + Some((syms, str_data)) +} + +fn make_fn(name: String, addr: u64, size: u64) -> MachoFunction { + MachoFunction { + name: name.clone(), + linkage_name: Some(name), + addr, + size, + external: true, + } +} + +/// Strip STABS type descriptor suffix (`:F(0,1)` → keep only up to `:`). +fn stabs_name(raw: &str) -> String { + match raw.find(':') { + Some(i) => raw[..i].to_string(), + None => raw.to_string(), + } +} diff --git a/crates/delink-macho/src/symbols.rs b/crates/delink-macho/src/symbols.rs new file mode 100644 index 0000000..2b92a58 --- /dev/null +++ b/crates/delink-macho/src/symbols.rs @@ -0,0 +1,131 @@ +//! Global symbol resolver for Mach-O / x86 relocation recovery. + +use crate::cu::{MachoCuIndex, MachoFunction, MachoVariable}; +use crate::MachoSection; +use std::collections::{BTreeMap, HashMap}; +use std::ops::Range; + +pub struct MachoGlobalSymbols { + /// VA → function descriptor (from DWARF). + pub functions: BTreeMap, + /// VA → data variable descriptor (from DWARF). + pub variables: BTreeMap, + /// Stub VA → external symbol name (from LC_DYSYMTAB). + pub stubs: HashMap, + pub text_range: Option>, + pub data_range: Option>, + pub const_range: Option>, + pub bss_range: Option>, + pub cstring_range: Option>, +} + +pub const SYM_DATA_START: &str = "__delink_macho_data_start"; +pub const SYM_CONST_START: &str = "__delink_macho_const_start"; +pub const SYM_BSS_START: &str = "__delink_macho_bss_start"; + +impl MachoGlobalSymbols { + pub fn build( + cu_index: &MachoCuIndex, + stubs: &HashMap, + sections: &[MachoSection], + ) -> Self { + let mut functions: BTreeMap = BTreeMap::new(); + let mut variables: BTreeMap = BTreeMap::new(); + + for cu in &cu_index.units { + for f in &cu.functions { + if f.size > 0 { + functions.entry(f.addr).or_insert_with(|| f.clone()); + } + } + for v in &cu.variables { + if v.addr != 0 { + variables.entry(v.addr).or_insert_with(|| v.clone()); + } + } + } + + let section_range = |seg: &str, name: &str| -> Option> { + sections + .iter() + .find(|s| s.segment == seg && s.name == name) + .map(|s| s.addr..s.addr + s.size) + }; + + Self { + functions, + variables, + stubs: stubs.clone(), + text_range: section_range("__TEXT", "__text"), + data_range: section_range("__DATA", "__data"), + const_range: section_range("__DATA", "__const"), + bss_range: section_range("__DATA", "__bss"), + cstring_range: section_range("__TEXT", "__cstring"), + } + } + + /// Resolve a code target VA (call / jmp destination) to `(symbol_name, addend)`. + pub fn resolve_code(&self, va: u64) -> Option<(String, i64)> { + if let Some(f) = self.functions.get(&va) { + return Some((f.symbol_name().to_string(), 0)); + } + if let Some((start, f)) = self.functions.range(..=va).next_back() { + if va < *start + f.size { + return Some((f.symbol_name().to_string(), (va - *start) as i64)); + } + } + if let Some(name) = self.stubs.get(&va) { + return Some((name.clone(), 0)); + } + None + } + + /// Resolve a data reference VA to `(symbol_name, addend)`. + pub fn resolve_data(&self, va: u64) -> Option<(String, i64)> { + if let Some(name) = self.stubs.get(&va) { + return Some((name.clone(), 0)); + } + if let Some(v) = self.variables.get(&va) { + return Some((v.symbol_name().to_string(), 0)); + } + if let Some(f) = self.functions.get(&va) { + return Some((f.symbol_name().to_string(), 0)); + } + if let Some((start, f)) = self.functions.range(..=va).next_back() { + if va < *start + f.size { + return Some((f.symbol_name().to_string(), (va - *start) as i64)); + } + } + self.section_relative(va) + } + + fn section_relative(&self, va: u64) -> Option<(String, i64)> { + let check = |range: &Option>, sym: &'static str| { + range.as_ref().and_then(|r| { + if r.contains(&va) { + Some((sym.to_string(), (va - r.start) as i64)) + } else { + None + } + }) + }; + check(&self.data_range, SYM_DATA_START) + .or_else(|| check(&self.const_range, SYM_CONST_START)) + .or_else(|| check(&self.bss_range, SYM_BSS_START)) + } + + pub fn in_text(&self, va: u64) -> bool { + self.text_range.as_ref().is_some_and(|r| r.contains(&va)) + } +} + +// Implement the x86 SymbolResolver trait so the recovery module can use us. +impl delink_x86::recover::SymbolResolver for MachoGlobalSymbols { + fn resolve_code(&self, va: u64) -> Option<(String, i64)> { + MachoGlobalSymbols::resolve_code(self, va) + } + + fn resolve_data(&self, va: u64) -> Option<(String, i64)> { + MachoGlobalSymbols::resolve_data(self, va) + } +} diff --git a/crates/delink-macho/src/symtab_json.rs b/crates/delink-macho/src/symtab_json.rs new file mode 100644 index 0000000..56252a0 --- /dev/null +++ b/crates/delink-macho/src/symtab_json.rs @@ -0,0 +1,414 @@ +//! User-editable symtab.json: maps output `.o` filenames to arrays of symbol +//! names. The default JSON puts each function in its own file. Edit the +//! arrays to group symbols, rename keys to rename output files, then re-run +//! with `--symtab `. All metadata is re-read from the binary at split +//! time via `build_lookup`. + +use anyhow::Result; +use std::collections::{BTreeMap, HashMap}; + +// nlist n_type bit masks +const N_EXT: u8 = 0x01; +const N_PEXT: u8 = 0x10; +const N_TYPE_MASK: u8 = 0x0e; +const N_STAB_MASK: u8 = 0xe0; +const N_SECT_VAL: u8 = 0x0e; + +// Load-command IDs +const LC_SYMTAB: u32 = 0x2; +const LC_SEGMENT: u32 = 0x1; + +const SEGMENT_CMD32_SIZE: usize = 56; +const SECTION32_SIZE: usize = 68; + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +/// The editable symtab JSON: output-filename → array of symbol names. +/// +/// Rename a key to rename the output file. Move symbol names between arrays +/// to group multiple functions into the same `.o`. +pub type SymtabJson = BTreeMap>; + +/// Rich per-symbol metadata resolved from the binary at split time. +#[derive(Debug, Clone)] +pub struct SymtabInfo { + pub addr: u64, + pub size: u64, + pub n_type: String, + pub n_sect: u8, + pub n_desc: Vec, + pub external: bool, + pub private_external: bool, +} + +/// Symbol name → metadata, built from the binary's LC_SYMTAB. +pub type SymtabLookup = HashMap; + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/// Build a default `SymtabJson` from the binary: one symbol name per output file. +pub fn generate(data: &[u8]) -> Result { + let mut json: SymtabJson = BTreeMap::new(); + for sym in parse_text_symbols(data) { + let cu = format!("{}.o", sanitize_filename(&sym.name)); + json.entry(cu).or_default().push(sym.name); + } + Ok(json) +} + +/// Build a `SymtabJson` from a DWARF/STABS `MachoCuIndex`, grouping functions +/// by their CU. The output filename matches the stem used by `split_all_macho`. +pub fn generate_from_cu_index(cu_index: &crate::cu::MachoCuIndex) -> SymtabJson { + let mut json: SymtabJson = BTreeMap::new(); + for cu in &cu_index.units { + let stem = { + let basename = cu.name.rsplit(['/', '\\']).next().unwrap_or(&cu.name); + let stem = match basename.rfind('.') { + Some(i) => &basename[..i], + None => basename, + }; + stem.chars() + .map(|c| { + if c.is_alphanumeric() || c == '_' || c == '-' { + c + } else { + '_' + } + }) + .collect::() + }; + let filename = format!("{:04}_{stem}.o", cu.id); + let names: Vec = cu + .functions + .iter() + .filter(|f| f.size > 0) + .map(|f| f.symbol_name().to_string()) + .collect(); + if !names.is_empty() { + json.insert(filename, names); + } + } + json +} + +/// Build a `SymtabLookup` (name → metadata) from the binary's symbol table. +/// +/// Includes all N_SECT symbols in `__TEXT,__text` — the same set that +/// `generate` uses for the default JSON. +pub fn build_lookup(data: &[u8]) -> Result { + let mut map = HashMap::new(); + for sym in parse_text_symbols(data) { + map.insert( + sym.name, + SymtabInfo { + addr: sym.addr, + size: sym.size, + n_type: decode_n_type(sym.n_type_raw), + n_sect: sym.n_sect, + n_desc: decode_n_desc(sym.n_desc_raw), + external: sym.n_type_raw & N_EXT != 0, + private_external: sym.n_type_raw & N_PEXT != 0, + }, + ); + } + Ok(map) +} + +// --------------------------------------------------------------------------- +// Internal parsing +// --------------------------------------------------------------------------- + +struct ParsedSym { + name: String, + addr: u64, + size: u64, + n_type_raw: u8, + n_sect: u8, + n_desc_raw: u16, +} + +fn parse_text_symbols(data: &[u8]) -> Vec { + let le = detect_little_endian(data); + let Some(Symtab { + sym_data, + str_data, + text_sect_indices, + }) = parse_symtab_raw(data, le) + else { + return Vec::new(); + }; + + let read_str = |strx: u32| -> String { + let strx = strx as usize; + if strx >= str_data.len() { + return String::new(); + } + let end = str_data[strx..].iter().position(|&b| b == 0).unwrap_or(0); + String::from_utf8_lossy(&str_data[strx..strx + end]).into_owned() + }; + + struct Raw { + name: String, + addr: u64, + n_type: u8, + n_sect: u8, + n_desc: u16, + } + let mut raw: Vec = Vec::new(); + + for chunk in sym_data.chunks_exact(12) { + let n_type = chunk[4]; + let n_sect = chunk[5]; + if n_type & N_STAB_MASK != 0 { + continue; + } + if n_type & N_TYPE_MASK != N_SECT_VAL { + continue; + } + if !text_sect_indices.contains(&n_sect) { + continue; + } + let strx = r32_chunk(chunk, 0, le); + let n_desc = r16_chunk(chunk, 6, le); + let addr = r32_chunk(chunk, 8, le) as u64; + let name = read_str(strx); + if name.is_empty() { + continue; + } + raw.push(Raw { + name, + addr, + n_type, + n_sect, + n_desc, + }); + } + + raw.sort_by_key(|s| (s.addr, s.name.clone())); + raw.dedup_by_key(|s| s.addr); + + let addrs: Vec = raw.iter().map(|s| s.addr).collect(); + let sizes: Vec = addrs + .windows(2) + .map(|w| w[1] - w[0]) + .chain(std::iter::once(0u64)) + .collect(); + + raw.into_iter() + .zip(sizes) + .map(|(s, size)| ParsedSym { + name: s.name, + addr: s.addr, + size, + n_type_raw: s.n_type, + n_sect: s.n_sect, + n_desc_raw: s.n_desc, + }) + .collect() +} + +struct Symtab<'a> { + sym_data: &'a [u8], + str_data: &'a [u8], + text_sect_indices: std::collections::HashSet, +} + +fn parse_symtab_raw(data: &[u8], le: bool) -> Option> { + if data.len() < 28 { + return None; + } + + let r32 = |off: usize| -> Option { + let b: [u8; 4] = data.get(off..off + 4)?.try_into().ok()?; + Some(if le { + u32::from_le_bytes(b) + } else { + u32::from_be_bytes(b) + }) + }; + + let ncmds = r32(16)? as usize; + let sizeofcmds = r32(20)? as usize; + let cmds_end = (28 + sizeofcmds).min(data.len()); + + let mut symtab_off = 0u32; + let mut nsyms = 0u32; + let mut stroff = 0u32; + let mut text_sect_indices: std::collections::HashSet = std::collections::HashSet::new(); + let mut sect_idx: u8 = 1; + + let mut pos = 28usize; + let mut count = 0; + while pos + 8 <= cmds_end && count < ncmds { + let cmd = r32(pos)?; + let cmdsize = r32(pos + 4)? as usize; + if cmdsize == 0 { + break; + } + + match cmd { + LC_SYMTAB if cmdsize >= 24 => { + symtab_off = r32(pos + 8)?; + nsyms = r32(pos + 12)?; + stroff = r32(pos + 16)?; + } + LC_SEGMENT if cmdsize >= SEGMENT_CMD32_SIZE => { + let nsects = r32(pos + 48)? as usize; + for i in 0..nsects { + let sb = pos + SEGMENT_CMD32_SIZE + i * SECTION32_SIZE; + if sb + SECTION32_SIZE > cmds_end { + break; + } + let sec_name = cstr(&data[sb..sb + 16]); + let seg_name = cstr(&data[sb + 16..sb + 32]); + if seg_name == "__TEXT" && sec_name == "__text" { + text_sect_indices.insert(sect_idx); + } + sect_idx = sect_idx.saturating_add(1); + } + } + _ => {} + } + pos += cmdsize; + count += 1; + } + + if nsyms == 0 { + return None; + } + let sym_start = symtab_off as usize; + let sym_end = sym_start.checked_add(nsyms as usize * 12)?; + if sym_end > data.len() { + return None; + } + let str_start = stroff as usize; + let str_data = if str_start < data.len() { + &data[str_start..] + } else { + &[] + }; + + Some(Symtab { + sym_data: &data[sym_start..sym_end], + str_data, + text_sect_indices, + }) +} + +pub fn detect_little_endian(data: &[u8]) -> bool { + if data.len() < 4 { + return true; + } + let magic = u32::from_le_bytes(data[..4].try_into().unwrap()); + matches!(magic, 0xFEED_FACE | 0xFEED_FACF) +} + +fn r32_chunk(chunk: &[u8], off: usize, le: bool) -> u32 { + let b: [u8; 4] = chunk[off..off + 4].try_into().unwrap(); + if le { + u32::from_le_bytes(b) + } else { + u32::from_be_bytes(b) + } +} + +fn r16_chunk(chunk: &[u8], off: usize, le: bool) -> u16 { + let b: [u8; 2] = chunk[off..off + 2].try_into().unwrap(); + if le { + u16::from_le_bytes(b) + } else { + u16::from_be_bytes(b) + } +} + +fn cstr(bytes: &[u8]) -> String { + let end = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len()); + String::from_utf8_lossy(&bytes[..end]).into_owned() +} + +fn decode_n_type(n_type: u8) -> String { + if n_type & N_STAB_MASK != 0 { + return format!("N_STAB(0x{n_type:02x})"); + } + let base = match n_type & N_TYPE_MASK { + 0x00 => "N_UNDF", + 0x02 => "N_ABS", + 0x0a => "N_INDR", + 0x0c => "N_PBUD", + 0x0e => "N_SECT", + other => return format!("0x{other:02x}"), + }; + let mut s = base.to_string(); + if n_type & N_PEXT != 0 { + s.push_str("|N_PEXT"); + } + if n_type & N_EXT != 0 { + s.push_str("|N_EXT"); + } + s +} + +fn decode_n_desc(n_desc: u16) -> Vec { + let mut flags: Vec = Vec::new(); + match n_desc & 0x000f { + 0 => {} + 1 => flags.push("REF_LAZY".into()), + 2 => flags.push("REF_DEFINED".into()), + 3 => flags.push("REF_PRIVATE_DEFINED".into()), + 4 => flags.push("REF_PRIVATE_UNDEF_NON_LAZY".into()), + 5 => flags.push("REF_PRIVATE_UNDEF_LAZY".into()), + n => flags.push(format!("REF(0x{n:x})")), + } + if n_desc & 0x0010 != 0 { + flags.push("REFERENCED_DYNAMICALLY".into()); + } + if n_desc & 0x0020 != 0 { + flags.push("N_NO_DEAD_STRIP".into()); + } + if n_desc & 0x0040 != 0 { + flags.push("N_DESC_DISCARDED".into()); + } + if n_desc & 0x0080 != 0 { + flags.push("N_WEAK_REF".into()); + } + if n_desc & 0x0100 != 0 { + flags.push("N_WEAK_DEF".into()); + } + if n_desc & 0x0200 != 0 { + flags.push("N_REF_TO_WEAK".into()); + } + if n_desc & 0x0800 != 0 { + flags.push("N_ARM_THUMB_DEF".into()); + } + if n_desc & 0x1000 != 0 { + flags.push("N_SYMBOL_RESOLVER".into()); + } + if n_desc & 0x2000 != 0 { + flags.push("N_ALT_ENTRY".into()); + } + flags +} + +fn sanitize_filename(name: &str) -> String { + let s: String = name + .chars() + .map(|c| { + if c.is_alphanumeric() || matches!(c, '_' | '-' | '.') { + c + } else { + '_' + } + }) + .collect(); + let trimmed = s.trim_start_matches(['.', '_']); + let truncated = &trimmed[..trimmed.len().min(200)]; + if truncated.is_empty() { + "unknown".to_string() + } else { + truncated.to_string() + } +} diff --git a/crates/delink-macho/src/symtab_split.rs b/crates/delink-macho/src/symtab_split.rs new file mode 100644 index 0000000..4326b07 --- /dev/null +++ b/crates/delink-macho/src/symtab_split.rs @@ -0,0 +1,122 @@ +//! Symtab-based CU reconstruction for stripped Mach-O binaries. +//! +//! Used when neither DWARF nor STABS debug info is present. Reads the +//! regular `LC_SYMTAB` N_SECT symbols, filters to those in the `__text` +//! section, sorts them by address, and groups them into synthetic compilation +//! units of a fixed size so the rest of the pipeline still works. + +use anyhow::Result; +use object::{Object as _, ObjectSection as _, ObjectSymbol as _, SymbolKind}; + +use crate::cu::{MachoCompilationUnit, MachoCuIndex, MachoFunction}; + +/// Maximum number of functions bundled into one synthetic CU. +/// +/// Keeping this value moderate avoids producing tens-of-thousands of tiny +/// files while still giving reasonable granularity. +const BATCH_SIZE: usize = 100; + +pub fn build_cu_index_from_symtab(data: &[u8]) -> Result { + let file = object::File::parse(data)?; + + // Find the __TEXT,__text address range so we can filter to code symbols. + let text_range = file + .sections() + .find(|s| { + s.segment_name().ok().flatten() == Some("__TEXT") && s.name().ok() == Some("__text") + }) + .map(|s| s.address()..s.address() + s.size()); + + // Also consider __coalesced_text (templates / inlined code deduplicated by the linker). + let coal_range = file + .sections() + .find(|s| { + s.segment_name().ok().flatten() == Some("__TEXT") + && s.name().ok() == Some("__coalesced_text") + }) + .map(|s| s.address()..s.address() + s.size()); + + // Collect all function-like symbols that lie in a text section. + let mut fns: Vec<(u64, String)> = file + .symbols() + .filter(|sym| { + // Keep defined, named symbols that look like functions. + if sym.is_undefined() || sym.kind() == SymbolKind::Unknown { + return false; + } + let addr = sym.address(); + let in_text = text_range.as_ref().is_some_and(|r| r.contains(&addr)); + let in_coal = coal_range.as_ref().is_some_and(|r| r.contains(&addr)); + if !in_text && !in_coal { + return false; + } + let name = sym.name().unwrap_or("").trim(); + !name.is_empty() + }) + .map(|sym| (sym.address(), sym.name().unwrap_or("").to_string())) + .collect(); + + if fns.is_empty() { + tracing::warn!("symtab fallback: no text symbols found"); + return Ok(MachoCuIndex { + units: vec![], + source: crate::cu::DebugInfoSource::Symtab, + }); + } + + fns.sort_by_key(|(addr, _)| *addr); + fns.dedup_by_key(|(addr, _)| *addr); + tracing::info!( + "symtab fallback: {} text symbols → {} synthetic CUs", + fns.len(), + fns.len().div_ceil(BATCH_SIZE) + ); + + // Compute function sizes from adjacent symbol addresses. + let addrs: Vec = fns.iter().map(|(a, _)| *a).collect(); + let sizes: Vec = addrs + .windows(2) + .map(|w| w[1] - w[0]) + .chain(std::iter::once(0u64)) + .collect(); + + // Group into batches and build CUs. + let mut units = Vec::new(); + for (batch_idx, batch) in fns.chunks(BATCH_SIZE).enumerate() { + let batch_start = batch_idx * BATCH_SIZE; + let functions: Vec = batch + .iter() + .enumerate() + .map(|(i, (addr, name))| MachoFunction { + name: name.clone(), + linkage_name: Some(name.clone()), + addr: *addr, + size: sizes[batch_start + i], + external: true, + }) + .collect(); + + let ranges = functions + .iter() + .filter(|f| f.size > 0) + .map(|f| f.addr..f.addr + f.size) + .collect(); + + let cu_name = functions[0].name.clone(); + + units.push(MachoCompilationUnit { + id: batch_idx, + name: cu_name, + comp_dir: None, + oso_path: None, + ranges, + functions, + variables: vec![], + }); + } + + Ok(MachoCuIndex { + units, + source: crate::cu::DebugInfoSource::Symtab, + }) +}