Skip to main content

codehelion_artifact/
macho.rs

1//! Mach-O implementation of the codehelion artifact backend boundary.
2//!
3//! The backend reads bytes through the safe `object` API and never maps or
4//! executes the inspected artifact. It deliberately records only container
5//! facts; DWARF and dSYM source locations remain a correlation-layer concern.
6
7use std::collections::HashMap;
8
9use crate::native::{
10    collect_sections, collect_text_symbols, collect_undefined_imports, symbol_fingerprint,
11};
12use crate::x86::X86_NORMALIZATION_VERSION;
13use crate::{
14    ArtifactBackend, ArtifactCapabilities, ArtifactError, ArtifactFingerprint, ArtifactFormat,
15    ArtifactIr,
16};
17use object::Object;
18use object::read::macho::{FatArch, MachOFatFile32, MachOFatFile64};
19
20#[cfg(test)]
21use crate::ArtifactImportKind;
22
23/// Parser backend for Mach-O executable and relocatable objects.
24#[derive(Debug, Default, Clone, Copy)]
25pub struct MachOBackend;
26
27/// Version of the shared x86 instruction-shape normalization representation.
28pub const MACHO_NORMALIZATION_VERSION: &str = X86_NORMALIZATION_VERSION;
29
30impl ArtifactBackend for MachOBackend {
31    fn format(&self) -> ArtifactFormat {
32        ArtifactFormat::MachO
33    }
34
35    fn detects(&self, bytes: &[u8]) -> bool {
36        matches!(
37            bytes.get(..4),
38            Some(
39                [0xfe, 0xed, 0xfa, 0xce | 0xcf]
40                    | [0xce | 0xcf, 0xfa, 0xed, 0xfe]
41                    | [0xca, 0xfe, 0xba, 0xbe | 0xbf]
42            )
43        )
44    }
45
46    fn parse(&self, bytes: &[u8]) -> Result<ArtifactIr, ArtifactError> {
47        self.parse_with_architecture(bytes, None, None)
48    }
49
50    fn capabilities(&self) -> ArtifactCapabilities {
51        ArtifactCapabilities {
52            symbols: true,
53            call_graph: false,
54            source_mapping: false,
55            debug_info_unreadable: false,
56            normalized_duplicates: false,
57            independent_data_segments: false,
58            relocations: true,
59            data_segments: true,
60        }
61    }
62}
63
64impl MachOBackend {
65    /// Parse a Mach-O artifact with an optional matching dSYM DWARF companion.
66    ///
67    /// The companion must be a Mach-O debug image bearing exactly the same
68    /// `LC_UUID`. A dSYM's inner `Contents/Resources/DWARF/<name>` file is the
69    /// debug image passed here; neither it nor the inspected artifact is run.
70    ///
71    /// # Errors
72    ///
73    /// Returns an error when either container is malformed or a supplied
74    /// companion lacks the inspected image's UUID.
75    pub fn parse_with_debug_companion(
76        &self,
77        bytes: &[u8],
78        debug_companion: Option<&[u8]>,
79    ) -> Result<ArtifactIr, ArtifactError> {
80        self.parse_with_architecture(bytes, debug_companion, None)
81    }
82
83    /// Parse a Mach-O artifact while selecting one universal-binary architecture.
84    ///
85    /// A multi-slice input requires `architecture`; selecting a slice by file
86    /// order would make equivalent comparison commands inspect different code.
87    ///
88    /// # Errors
89    ///
90    /// Returns an error when the requested architecture is absent, or when a
91    /// universal input supplies more than one slice without a selection.
92    pub fn parse_with_architecture(
93        &self,
94        bytes: &[u8],
95        debug_companion: Option<&[u8]>,
96        architecture: Option<&str>,
97    ) -> Result<ArtifactIr, ArtifactError> {
98        if !self.detects(bytes) {
99            return Err(ArtifactError::WrongFormat {
100                expected: ArtifactFormat::MachO,
101            });
102        }
103        let selection = mach_o_slice(bytes, architecture)?;
104        let artifact = selection.bytes;
105        let offset = selection.offset;
106        let file = object::File::parse(artifact).map_err(|error| malformed(error.to_string()))?;
107        if file.format() != object::BinaryFormat::MachO {
108            return Err(ArtifactError::WrongFormat {
109                expected: ArtifactFormat::MachO,
110            });
111        }
112        if let Some(requested) = architecture
113            && !architecture_selector_matches(architecture_name(file.architecture()), requested)
114        {
115            return Err(malformed(format!(
116                "Mach-O architecture is {}, not requested {requested}",
117                architecture_name(file.architecture())
118            )));
119        }
120        let debug_file = debug_companion
121            .map(|companion| {
122                let companion = mach_o_slice(companion, architecture)?;
123                let companion = object::File::parse(companion.bytes)
124                    .map_err(|error| malformed(error.to_string()))?;
125                if companion.format() != object::BinaryFormat::MachO
126                    || !matching_uuid(&file, &companion)
127                {
128                    return Err(malformed(
129                        "external debug companion does not have the artifact's Mach-O UUID"
130                            .to_owned(),
131                    ));
132                }
133                Ok(companion)
134            })
135            .transpose()?;
136        let selected_architecture = if selection.architecture == "unknown" {
137            architecture_name(file.architecture()).to_owned()
138        } else {
139            selection.architecture
140        };
141        let mut ir = ArtifactIr::empty(ArtifactFormat::MachO, bytes);
142        ir.architecture = Some(selected_architecture);
143        ir.skipped_architectures = selection.skipped_architectures;
144        collect_sections(&file, &mut ir).map_err(|error| malformed(error.to_string()))?;
145        collect_undefined_imports(file.symbols(), &mut ir);
146        let collected_addresses = collect_symbols(&file, &mut ir)?;
147        let symbol_addresses = if ir.symbols.is_empty() {
148            infer_text_regions(&file, &mut ir)?
149        } else {
150            collected_addresses
151        };
152        crate::dwarf::attach_dwarf_frames(
153            debug_file.as_ref().unwrap_or(&file),
154            &symbol_addresses,
155            &mut ir,
156        );
157        shift_file_offsets(&mut ir, offset);
158        ir.capabilities = ArtifactCapabilities {
159            symbols: !ir.symbols.is_empty(),
160            call_graph: false,
161            source_mapping: !ir.source_mappings.is_empty(),
162            debug_info_unreadable: ir.capabilities.debug_info_unreadable,
163            normalized_duplicates: crate::x86::supports_normalized_duplicates(file.architecture()),
164            independent_data_segments: false,
165            relocations: !ir.relocations.is_empty(),
166            data_segments: !ir.data_segments.is_empty(),
167        };
168        Ok(ir)
169    }
170}
171
172struct MachOSlice<'a> {
173    bytes: &'a [u8],
174    offset: u64,
175    architecture: String,
176    skipped_architectures: Vec<String>,
177}
178
179fn mach_o_slice<'a>(
180    bytes: &'a [u8],
181    requested_architecture: Option<&str>,
182) -> Result<MachOSlice<'a>, ArtifactError> {
183    match object::FileKind::parse(bytes).map_err(|error| malformed(error.to_string()))? {
184        object::FileKind::MachO32 | object::FileKind::MachO64 => Ok(MachOSlice {
185            bytes,
186            offset: 0,
187            architecture: "unknown".to_owned(),
188            skipped_architectures: Vec::new(),
189        }),
190        object::FileKind::MachOFat32 => {
191            let fat = MachOFatFile32::parse(bytes).map_err(|error| malformed(error.to_string()))?;
192            fat_slice_with_architecture(&fat, bytes, requested_architecture)
193        }
194        object::FileKind::MachOFat64 => {
195            let fat = MachOFatFile64::parse(bytes).map_err(|error| malformed(error.to_string()))?;
196            fat_slice_with_architecture(&fat, bytes, requested_architecture)
197        }
198        _ => Err(ArtifactError::WrongFormat {
199            expected: ArtifactFormat::MachO,
200        }),
201    }
202}
203
204fn fat_slice_with_architecture<'a, Fat: FatArch>(
205    fat: &object::read::macho::MachOFatFile<'a, Fat>,
206    bytes: &'a [u8],
207    requested_architecture: Option<&str>,
208) -> Result<MachOSlice<'a>, ArtifactError> {
209    let arches = fat.arches();
210    let available = fat_architecture_labels(arches);
211    let selected_index = match (arches, requested_architecture) {
212        ([], _) => {
213            return Err(malformed(
214                "fat Mach-O has no architecture slices".to_owned(),
215            ));
216        }
217        ([_], None) => 0,
218        (_, Some(requested)) => {
219            let matches: Vec<_> = available
220                .iter()
221                .enumerate()
222                .filter_map(|(index, name)| (name == requested).then_some(index))
223                .collect();
224            match matches.as_slice() {
225                [index] => *index,
226                [] => {
227                    return Err(malformed(format!(
228                        "fat Mach-O has no {requested} slice (available: {})",
229                        available.join(", ")
230                    )));
231                }
232                _ => {
233                    return Err(malformed(
234                        "fat Mach-O architecture selector is ambiguous".to_owned(),
235                    ));
236                }
237            }
238        }
239        (_, None) => {
240            return Err(malformed(format!(
241                "fat Mach-O has multiple architecture slices (available: {}); select one with --arch",
242                available.join(", ")
243            )));
244        }
245    };
246    let arch = &arches[selected_index];
247    let (offset, _) = arch.file_range();
248    let slice = arch
249        .data(bytes)
250        .map_err(|error| malformed(error.to_string()))?;
251    let selected = available[selected_index].clone();
252    let skipped_architectures = available
253        .into_iter()
254        .filter(|available| available != &selected)
255        .collect();
256    Ok(MachOSlice {
257        bytes: slice,
258        offset,
259        architecture: selected,
260        skipped_architectures,
261    })
262}
263
264fn architecture_selector_matches(architecture: &str, selector: &str) -> bool {
265    selector.split_once(':').map_or(selector, |(base, _)| base) == architecture
266}
267
268fn fat_architecture_labels<Fat: FatArch>(arches: &[Fat]) -> Vec<String> {
269    let bases: Vec<_> = arches
270        .iter()
271        .map(|arch| architecture_name(arch.architecture()))
272        .collect();
273    arches
274        .iter()
275        .zip(&bases)
276        .map(|(arch, base)| {
277            if bases.iter().filter(|other| *other == base).count() == 1 {
278                (*base).to_owned()
279            } else {
280                format!("{base}:{}", arch.cpusubtype())
281            }
282        })
283        .collect()
284}
285
286const fn architecture_name(architecture: object::Architecture) -> &'static str {
287    match architecture {
288        object::Architecture::Aarch64 => "aarch64",
289        object::Architecture::Arm => "arm",
290        object::Architecture::I386 => "i386",
291        object::Architecture::X86_64 => "x86_64",
292        object::Architecture::PowerPc => "powerpc",
293        object::Architecture::PowerPc64 => "powerpc64",
294        _ => "unknown",
295    }
296}
297
298fn shift_file_offsets(ir: &mut ArtifactIr, offset: u64) {
299    if offset == 0 {
300        return;
301    }
302    for section in &mut ir.sections {
303        section.offset = section.offset.saturating_add(offset);
304    }
305    for segment in &mut ir.data_segments {
306        segment.offset = segment.offset.saturating_add(offset);
307    }
308    for symbol in &mut ir.symbols {
309        symbol.offset = symbol.offset.saturating_add(offset);
310    }
311    for relocation in &mut ir.relocations {
312        relocation.offset = relocation.offset.saturating_add(offset);
313    }
314}
315
316fn matching_uuid(artifact: &object::File<'_>, companion: &object::File<'_>) -> bool {
317    matches!(
318        (artifact.mach_uuid(), companion.mach_uuid()),
319        (Ok(Some(artifact)), Ok(Some(companion))) if artifact == companion
320    )
321}
322
323fn collect_symbols(
324    file: &object::File<'_>,
325    ir: &mut ArtifactIr,
326) -> Result<HashMap<ArtifactFingerprint, (u64, u64)>, ArtifactError> {
327    collect_text_symbols(file, ir)
328        .map(|ranges| {
329            ranges
330                .into_iter()
331                .map(|range| (range.fingerprint, (range.address, range.size)))
332                .collect()
333        })
334        .map_err(|error| malformed(error.to_string()))
335}
336/// Represent each text section when a stripped Mach-O has no symbol table.
337///
338/// The inferred region is explicitly marked and uses only the section's bytes;
339/// it lets release artifacts remain analyzable without manufacturing names or
340/// source locations.
341fn infer_text_regions(
342    file: &object::File<'_>,
343    ir: &mut ArtifactIr,
344) -> Result<HashMap<ArtifactFingerprint, (u64, u64)>, ArtifactError> {
345    let ranges = crate::native::infer_text_regions(file, ir, |section, normalized, data| {
346        symbol_fingerprint(None, section, normalized, data)
347    })
348    .map_err(|error| malformed(error.to_string()))?;
349    Ok(ranges
350        .into_iter()
351        .map(|(fingerprint, address, size)| (fingerprint, (address, size)))
352        .collect())
353}
354
355const fn malformed(message: String) -> ArtifactError {
356    ArtifactError::Malformed {
357        format: ArtifactFormat::MachO,
358        message,
359    }
360}
361
362#[cfg(test)]
363mod tests {
364    #![allow(clippy::expect_used, clippy::unwrap_used)]
365
366    use super::*;
367    use object::write::{Object as WriteObject, StandardSection, Symbol, SymbolSection};
368    use object::{Architecture, BinaryFormat, Endianness, SymbolFlags, SymbolKind, SymbolScope};
369    use proptest::prelude::*;
370
371    fn macho_fixture() -> Vec<u8> {
372        let mut object = WriteObject::new(
373            BinaryFormat::MachO,
374            Architecture::X86_64,
375            Endianness::Little,
376        );
377        let text = object.section_id(StandardSection::Text);
378        let offset = object.append_section_data(text, &[0x90, 0xc3], 1);
379        object.add_symbol(Symbol {
380            name: b"render".to_vec(),
381            value: offset,
382            size: 2,
383            kind: SymbolKind::Text,
384            scope: SymbolScope::Dynamic,
385            weak: false,
386            section: SymbolSection::Section(text),
387            flags: SymbolFlags::None,
388        });
389        object.write().expect("write Mach-O fixture")
390    }
391
392    fn macho_fixture_without_symbols() -> Vec<u8> {
393        let mut object = WriteObject::new(
394            BinaryFormat::MachO,
395            Architecture::X86_64,
396            Endianness::Little,
397        );
398        let text = object.section_id(StandardSection::Text);
399        object.append_section_data(text, &[0x90, 0xc3], 1);
400        object.write().expect("write symbol-free Mach-O fixture")
401    }
402
403    fn macho_zero_sized_alias_fixture() -> Vec<u8> {
404        let mut object = WriteObject::new(
405            BinaryFormat::MachO,
406            Architecture::X86_64,
407            Endianness::Little,
408        );
409        let text = object.section_id(StandardSection::Text);
410        let offset = object.append_section_data(text, &[0x90, 0xc3], 1);
411        for (name, size) in [(b"implementation".as_slice(), 2), (b"alias", 0)] {
412            object.add_symbol(Symbol {
413                name: name.to_vec(),
414                value: offset,
415                size,
416                kind: SymbolKind::Text,
417                scope: SymbolScope::Dynamic,
418                weak: false,
419                section: SymbolSection::Section(text),
420                flags: SymbolFlags::None,
421            });
422        }
423        object
424            .write()
425            .expect("write zero-sized Mach-O alias fixture")
426    }
427
428    fn macho_undefined_import_fixture() -> Vec<u8> {
429        let mut object = WriteObject::new(
430            BinaryFormat::MachO,
431            Architecture::X86_64,
432            Endianness::Little,
433        );
434        object.add_symbol(Symbol {
435            name: b"_external_call".to_vec(),
436            value: 0,
437            size: 0,
438            kind: SymbolKind::Text,
439            scope: SymbolScope::Dynamic,
440            weak: false,
441            section: SymbolSection::Undefined,
442            flags: SymbolFlags::None,
443        });
444        object
445            .write()
446            .expect("write Mach-O undefined import fixture")
447    }
448
449    fn fat_macho_fixture() -> Vec<u8> {
450        let inner = macho_fixture();
451        let offset = 256_u32;
452        let mut bytes = Vec::new();
453        bytes.extend([0xca, 0xfe, 0xba, 0xbe]);
454        bytes.extend(1_u32.to_be_bytes());
455        bytes.extend(0x0100_0007_u32.to_be_bytes());
456        bytes.extend(3_u32.to_be_bytes());
457        bytes.extend(offset.to_be_bytes());
458        bytes.extend(
459            u32::try_from(inner.len())
460                .expect("fixture slice length fits")
461                .to_be_bytes(),
462        );
463        bytes.extend(8_u32.to_be_bytes());
464        bytes.resize(offset as usize, 0);
465        bytes.extend(inner);
466        bytes
467    }
468
469    fn universal_macho_fixture() -> Vec<u8> {
470        let x86_64 = macho_fixture();
471        let aarch64 = {
472            let mut object = WriteObject::new(
473                BinaryFormat::MachO,
474                Architecture::Aarch64,
475                Endianness::Little,
476            );
477            let text = object.section_id(StandardSection::Text);
478            let offset = object.append_section_data(text, &[0x1f, 0x20, 0x03, 0xd5], 4);
479            object.add_symbol(Symbol {
480                name: b"render_arm64".to_vec(),
481                value: offset,
482                size: 4,
483                kind: SymbolKind::Text,
484                scope: SymbolScope::Dynamic,
485                weak: false,
486                section: SymbolSection::Section(text),
487                flags: SymbolFlags::None,
488            });
489            object.write().expect("write AArch64 Mach-O fixture")
490        };
491        let x86_64_offset = 256_u32;
492        let aarch64_offset = 512_u32;
493        let mut bytes = Vec::new();
494        bytes.extend([0xca, 0xfe, 0xba, 0xbe]);
495        bytes.extend(2_u32.to_be_bytes());
496        for (cpu_type, cpu_subtype, offset, inner) in [
497            (0x0100_0007_u32, 3_u32, x86_64_offset, &x86_64),
498            (0x0100_000c_u32, 0_u32, aarch64_offset, &aarch64),
499        ] {
500            bytes.extend(cpu_type.to_be_bytes());
501            bytes.extend(cpu_subtype.to_be_bytes());
502            bytes.extend(offset.to_be_bytes());
503            bytes.extend(
504                u32::try_from(inner.len())
505                    .expect("fixture slice length fits")
506                    .to_be_bytes(),
507            );
508            bytes.extend(8_u32.to_be_bytes());
509        }
510        bytes.resize(x86_64_offset as usize, 0);
511        bytes.extend(x86_64);
512        bytes.resize(aarch64_offset as usize, 0);
513        bytes.extend(aarch64);
514        bytes
515    }
516
517    #[test]
518    fn parses_a_macho_function_without_executing_it() {
519        let ir = MachOBackend
520            .parse(&macho_fixture())
521            .expect("parse Mach-O fixture");
522        assert_eq!(ir.format, ArtifactFormat::MachO);
523        assert_eq!(ir.symbols.len(), 1, "{ir:#?}");
524        assert!(ir.capabilities.symbols);
525        assert_eq!(ir.symbols[0].name.as_deref(), Some("_render"));
526        assert_eq!(ir.symbols[0].code, vec![0x90, 0xc3]);
527        assert_eq!(
528            ir.symbols[0]
529                .normalized
530                .as_ref()
531                .map(|value| value.version.as_str()),
532            Some(MACHO_NORMALIZATION_VERSION)
533        );
534    }
535
536    #[test]
537    fn symbol_free_macho_keeps_an_explicitly_inferred_text_region() {
538        let ir = MachOBackend
539            .parse(&macho_fixture_without_symbols())
540            .expect("parse symbol-free Mach-O fixture");
541        assert_eq!(ir.symbols.len(), 1, "{ir:#?}");
542        assert!(ir.capabilities.symbols);
543        assert!(ir.symbols[0].size_inferred);
544        assert_eq!(ir.symbols[0].name, None);
545        assert_eq!(ir.symbols[0].code, vec![0x90, 0xc3]);
546    }
547
548    #[test]
549    fn zero_sized_macho_alias_is_retained_without_claiming_implementation_bytes() {
550        let ir = MachOBackend
551            .parse(&macho_zero_sized_alias_fixture())
552            .expect("parse zero-sized Mach-O alias fixture");
553        let at_start: Vec<_> = ir
554            .symbols
555            .iter()
556            .filter(|symbol| symbol.offset == ir.symbols[0].offset)
557            .collect();
558        assert_eq!(at_start.len(), 2, "{ir:#?}");
559        assert_eq!(
560            at_start
561                .iter()
562                .filter(|symbol| symbol.code.is_empty())
563                .count(),
564            1,
565            "{ir:#?}"
566        );
567        assert_eq!(
568            at_start
569                .iter()
570                .filter(|symbol| symbol.code == [0x90, 0xc3])
571                .count(),
572            1,
573            "{ir:#?}"
574        );
575        let alias = at_start
576            .iter()
577            .find(|symbol| symbol.code.is_empty())
578            .expect("empty alias record");
579        assert!(alias.size_inferred);
580        assert_eq!(alias.size, 0);
581    }
582
583    #[test]
584    fn records_undefined_macho_symbols_as_imports() {
585        let ir = MachOBackend
586            .parse(&macho_undefined_import_fixture())
587            .expect("parse Mach-O undefined import fixture");
588        assert_eq!(ir.imports.len(), 1, "{ir:#?}");
589        assert_eq!(ir.imports[0].name.as_deref(), Some("__external_call"));
590        assert_eq!(ir.imports[0].kind, ArtifactImportKind::Function);
591    }
592
593    #[test]
594    fn parses_a_fat_macho_slice_without_losing_outer_identity() {
595        let bytes = fat_macho_fixture();
596        assert!(MachOBackend.detects(&bytes));
597        let ir = MachOBackend
598            .parse(&bytes)
599            .expect("fat Mach-O fixture parses");
600        assert_eq!(ir.observed_bytes, bytes.len() as u64);
601        assert_eq!(
602            ir.fingerprint,
603            ArtifactFingerprint::from_content("artifact", &bytes)
604        );
605        assert_eq!(ir.symbols.len(), 1, "{ir:#?}");
606        assert!(ir.symbols[0].offset >= 256);
607    }
608
609    #[test]
610    fn universal_macho_requires_an_explicit_architecture_selection() {
611        let bytes = universal_macho_fixture();
612        let error = MachOBackend
613            .parse(&bytes)
614            .expect_err("multi-slice input is ambiguous");
615        assert!(error.to_string().contains("--arch"), "{error}");
616        assert!(error.to_string().contains("x86_64"), "{error}");
617        assert!(error.to_string().contains("aarch64"), "{error}");
618    }
619
620    #[test]
621    fn universal_macho_records_the_selected_and_skipped_architectures() {
622        let bytes = universal_macho_fixture();
623        let ir = MachOBackend
624            .parse_with_architecture(&bytes, None, Some("aarch64"))
625            .expect("explicit AArch64 slice parses");
626        assert_eq!(ir.architecture.as_deref(), Some("aarch64"));
627        assert_eq!(ir.skipped_architectures, ["x86_64"]);
628        assert_eq!(ir.symbols[0].name.as_deref(), Some("_render_arm64"));
629        assert!(ir.symbols[0].offset >= 512);
630    }
631
632    #[test]
633    fn universal_macho_rejects_a_missing_architecture_selection() {
634        let error = MachOBackend
635            .parse_with_architecture(&universal_macho_fixture(), None, Some("i386"))
636            .expect_err("unavailable slice is rejected");
637        assert!(error.to_string().contains("no i386 slice"), "{error}");
638    }
639
640    #[test]
641    fn thin_macho_rejects_an_architecture_that_does_not_match_its_header() {
642        let error = MachOBackend
643            .parse_with_architecture(&macho_fixture(), None, Some("aarch64"))
644            .expect_err("thin architecture mismatch is rejected");
645        assert!(
646            error.to_string().contains("not requested aarch64"),
647            "{error}"
648        );
649    }
650
651    #[test]
652    fn other_bytes_do_not_claim_the_backend() {
653        assert!(!MachOBackend.detects(b"not an object"));
654        assert!(matches!(
655            MachOBackend.parse(b"not an object"),
656            Err(ArtifactError::WrongFormat { .. })
657        ));
658    }
659
660    proptest::proptest! {
661        #[test]
662        fn arbitrary_bytes_never_panic(bytes in proptest::collection::vec(any::<u8>(), 0..4096)) {
663            let _ = MachOBackend.parse(&bytes);
664        }
665    }
666}