Comparing changes

Choose two branches to see what's changed or to start a new pull request.

base: runtime-hello-parity
compare: trunk
Create pull request
Able to merge. These branches can be automatically merged.
94 commits 412 files changed 2 contributors

Commits on trunk

.docs/sprints/closeout0-9.mdadded
247 lines changed — click to load
@@ -0,0 +1,247 @@
1
+# Sprint 0-9 Closeout Checklist
2
+
3
+Concrete closeout checklist based on the current codebase audit.
4
+
5
+Current conclusion: we are not ready to honestly declare Sprint 10 complete-in-practice yet.
6
+The main blockers are:
7
+
8
+- Sprint 0's tolerated-diff categories are still deferred until afs-ld can emit real linked output for Mach-O-to-Mach-O differential checks.
9
+
10
+## Sprint 10 Gate
11
+
12
+Do not declare "we are on Sprint 10" until all of these are true:
13
+
14
+- [x] Sprint 9 reloc referents are remapped to atom-aware forms.
15
+- [x] Sprint 8 resolution orchestration exists as a real callable stage, not just loose helper APIs.
16
+- [x] `cargo test -p afs-ld` is green after the closeout work.
17
+- [x] `cargo clippy -p afs-ld --all-targets -- -D warnings` is green after the closeout work.
18
+- [x] `README.md` and sprint docs no longer materially misstate the current state of the crate.
19
+
20
+## Recommended Order
21
+
22
+- [x] Close Sprint 9 reloc-to-atom remap first.
23
+- [x] Close Sprint 8 resolution orchestration and option coverage second.
24
+- [x] Close Sprint 6 TBD/SDK search gaps third.
25
+- [x] Close Sprint 4 nested archive support fourth.
26
+- [ ] Finish the deferred Sprint 0 differential-harness tolerance work once afs-ld can emit real output.
27
+
28
+## Cross-Sprint Exit Criteria
29
+
30
+- [ ] Every closeout chunk lands with tests.
31
+- [ ] Every bug fix or behavioral gap gets a regression test.
32
+- [ ] No newly-discovered roadmap/code mismatch is left undocumented.
33
+- [ ] Any user-facing diagnostic we touch stays deterministic and testable.
34
+
35
+## Sprint 0
36
+
37
+Status: closed
38
+
39
+Validated:
40
+
41
+- [x] `afs-ld` exists as its own git submodule in the parent workspace.
42
+- [x] Parent `Cargo.toml` includes `afs-ld` as a workspace member.
43
+- [x] `CLAUDE.md`, `README.md`, crate wiring, and test harness scaffolding exist.
44
+- [x] Reference repos are present under parent `.refs/` (`ld64`, `mold`, `lld`).
45
+- [x] `tests/reader_empty.rs` enforces the empty-invocation CLI contract.
46
+- [x] `tests/diff_harness_sanity.rs` and `tests/diff_harness_finds_critical.rs` exist and pass.
47
+- [x] `cargo clippy -p afs-ld --all-targets -- -D warnings` is currently clean.
48
+
49
+Remaining closeout work:
50
+
51
+- [x] Explicitly downscope Sprint 0 docs so the current diff harness is described as synthetic until end-to-end linking exists.
52
+- [ ] Add tolerated-diff categories once real Mach-O-to-Mach-O comparisons exist.
53
+
54
+## Sprint 1
55
+
56
+Status: closed
57
+
58
+Validated:
59
+
60
+- [x] Mach-O constants are duplicated locally in `src/macho/constants.rs`.
61
+- [x] `MachHeader64` parsing exists and rejects malformed headers.
62
+- [x] Load-command dispatch exists and preserves unknown commands as raw bytes.
63
+- [x] Segment and section-header metadata parsing exists.
64
+- [x] `LC_BUILD_VERSION` and `LC_LINKER_OPTIMIZATION_HINT` decoding exists.
65
+- [x] `--dump` exists through `src/dump.rs` and `src/main.rs`.
66
+- [x] Corpus round-trip tests pass in `tests/reader_corpus_round_trip.rs`.
67
+
68
+Remaining closeout work:
69
+
70
+- [x] Add an `otool -lV` parity test for dumper output shape across the corpus.
71
+- [x] Add a panic-focused malformed-input stress pass beyond the current unit tests so the "no panics on malformed input" claim is defensible.
72
+
73
+## Sprint 2
74
+
75
+Status: closed
76
+
77
+Validated:
78
+
79
+- [x] Section classification exists in `src/section.rs`.
80
+- [x] `InputSection` carries section data and raw relocation bytes.
81
+- [x] `RawNlist` / `InputSymbol` parsing and classification exist in `src/symbol.rs`.
82
+- [x] Common symbols, weak flags, private externs, and indirect aliases are surfaced.
83
+- [x] `StringTable` exists and handles suffix-dedup overlaps.
84
+- [x] `DysymtabCmd` is parsed and exposed through `ObjectFile`.
85
+- [x] `ObjectFile` integrates header, commands, sections, symbols, strings, and dysymtab.
86
+
87
+Remaining closeout work:
88
+
89
+- [x] Add `nm -a` parity tests for symbol view and classification.
90
+- [x] Add `otool -r` parity checks for relocation-offset surfaces promised by Sprint 2, with section/load-command parity covered by the Sprint 1 `otool -lV` gate.
91
+- [x] Add stronger malformed-symbol / malformed-string-table stress coverage if we want the "never panics" bar to be explicit.
92
+
93
+## Sprint 3
94
+
95
+Status: closed enough for current closeout
96
+
97
+Validated:
98
+
99
+- [x] ARM64 relocation constants exist.
100
+- [x] Raw relocation parsing and writing exist.
101
+- [x] Fused `Reloc` form exists.
102
+- [x] `ADDEND` and `SUBTRACTOR + UNSIGNED` pairing is fused in `parse_relocs`.
103
+- [x] Validation logic exists in `validate_relocs`.
104
+- [x] Write-side round-trip support exists.
105
+- [x] Unit coverage is broad and current corpus relocation round-trips pass.
106
+
107
+Remaining closeout work:
108
+
109
+- [x] No audit-blocking work found for Sprint 3.
110
+
111
+## Sprint 4
112
+
113
+Status: closed
114
+
115
+Validated:
116
+
117
+- [x] BSD, SysV, and GNU-thin archive flavors are recognized.
118
+- [x] Archive headers and name decoding are implemented.
119
+- [x] Symbol-index parsing exists for BSD and SysV archives.
120
+- [x] Lazy member fetch exists via `fetch_object_defining`.
121
+- [x] `libarmfortas_rt.a` is exercised by `tests/archive_runtime.rs`.
122
+- [x] Archive dump mode exists via `--dump-archive`.
123
+
124
+Remaining closeout work:
125
+
126
+- [x] Implement one-level nested archive support (`.a` member inside `.a`) and preserve provenance for diagnostics.
127
+- [x] Formally treat `resolve::force_load_archive` / `force_load_all` as the Sprint 4 completion surface and document that surface instead of adding a parallel archive-only helper.
128
+- [x] Add `ar -t` shape/parity coverage for `--dump-archive`.
129
+
130
+## Sprint 5
131
+
132
+Status: partially closed
133
+
134
+Validated:
135
+
136
+- [x] `DylibFile` exists and parses binary `MH_DYLIB`.
137
+- [x] `LC_ID_DYLIB`, dependency dylib commands, ordinals, and rpaths are decoded.
138
+- [x] Export trie decoding exists with cycle/depth protection.
139
+- [x] Real clang-built dylib coverage exists in `tests/dylib_integration.rs`.
140
+- [x] Dylib dump mode exists via `--dump-dylib`.
141
+
142
+Remaining closeout work:
143
+
144
+- [ ] Prove recursive re-export / umbrella lookup behavior with a focused test, not just dependency collection.
145
+- [ ] Confirm the public dylib surface matches what Sprint 5 intended for re-exported symbols, not only direct exports.
146
+
147
+## Sprint 6
148
+
149
+Status: closed
150
+
151
+Validated:
152
+
153
+- [x] The custom YAML subset parser exists in `src/macho/tbd_yaml.rs`.
154
+- [x] TBD schema decoding exists in `src/macho/tbd.rs`.
155
+- [x] `DylibFile::from_tbd` exists and materializes TBDs into the same linker-facing surface.
156
+- [x] Real `libSystem.tbd` smoke/integration coverage exists in `tests/tbd_smoke.rs` and `tests/tbd_integration.rs`.
157
+- [x] TBD dump mode exists via `--dump-tbd`.
158
+
159
+Remaining closeout work:
160
+
161
+- [x] Implement SDK `-syslibroot` library search helpers for `.tbd` / `.dylib`.
162
+- [x] Implement framework search helpers promised by Sprint 6.
163
+- [x] Make target filtering fail loudly when the requested target is not exported, instead of only materializing matching targets when the caller already knows one exists.
164
+- [x] No further audit-blocking work found for Sprint 6 in the current helper/test surface.
165
+
166
+## Sprint 7
167
+
168
+Status: closed
169
+
170
+Validated:
171
+
172
+- [x] `Symbol` sum type exists with the planned major variants.
173
+- [x] `StringInterner`, opaque ids, and `SymbolTable` exist.
174
+- [x] The insertion matrix is heavily unit-tested.
175
+- [x] Weak/strong/common coalescing behavior is covered in unit tests.
176
+- [x] Alias-cycle detection and chain resolution exist.
177
+- [x] Transition logging exists.
178
+
179
+Remaining closeout work:
180
+
181
+- [ ] Add the differential weak-coalescing / duplicate-behavior coverage against system `ld` that Sprint 7 originally called for.
182
+
183
+## Sprint 8
184
+
185
+Status: closed
186
+
187
+Validated:
188
+
189
+- [x] Archive seeding, object seeding, and dylib seeding exist.
190
+- [x] Fixed-point archive fetch draining exists.
191
+- [x] `force_load_archive` and `force_load_all` helpers exist in `src/resolve.rs`.
192
+- [x] Undefined classification exists for `Error`, `Warning`, `Suppress`, and `DynamicLookup`.
193
+- [x] Did-you-mean support exists.
194
+- [x] Duplicate-symbol and undefined-symbol formatting helpers exist.
195
+- [x] Real integration coverage exists for archive pull plus unresolved-symbol reporting.
196
+
197
+Remaining closeout work:
198
+
199
+- [x] Add a real orchestration entrypoint for resolution (`seed -> optional force load -> drain -> classify`) that can be called as a coherent stage.
200
+- [x] Add option/state plumbing for `all_load`, `force_load`, and undefined treatment so resolution is not just a bag of helper APIs.
201
+- [x] Add an archive order-sensitivity test.
202
+- [x] Add dedicated tests for `force_load_archive` and `force_load_all`.
203
+- [x] Add dedicated tests for `UndefinedTreatment::Warning`, `Suppress`, and `DynamicLookup`.
204
+- [x] Add a dedicated test that unresolved weak refs stay accepted regardless of treatment.
205
+- [x] Tighten diagnostics toward the Sprint 8 format by carrying section/offset provenance and aggregate repeated relocation sites when available.
206
+
207
+## Sprint 9
208
+
209
+Status: closed
210
+
211
+Validated:
212
+
213
+- [x] Atom model and atom table exist.
214
+- [x] Section splitting at symbol boundaries exists.
215
+- [x] `.alt_entry` folding exists.
216
+- [x] CString atom splitting exists and is integration-tested.
217
+- [x] Compact-unwind atom splitting and `parent_of` wiring exist.
218
+- [x] Backpatching of `Symbol::Defined { atom }` exists.
219
+- [x] `N_NO_DEAD_STRIP` and weak-def flags are propagated into atom flags.
220
+- [x] Embedded payload addends on symbol-based data relocs are folded into local atom offsets or preserved on external refs.
221
+
222
+Remaining closeout work:
223
+
224
+- [x] Remap relocations from raw section/symbol referents into atom-aware referents.
225
+- [x] Add atom-local relocation storage or an equivalent per-atom relocation view.
226
+- [x] Ensure same-object references point at target atoms, not raw section offsets.
227
+- [x] Add a focused integration test proving a local branch or data reference resolves to the callee/target atom.
228
+- [x] Add a boundary-crossing reloc diagnostic test.
229
+- [x] Confirm no raw section-relative relocation state leaks into Sprint 10 inputs.
230
+- [x] No further audit-blocking work found for Sprint 9 in the current corpus and targeted local-addend probes.
231
+
232
+## Documentation Closeout
233
+
234
+- [x] Update `README.md` so it no longer says the crate is only Sprint 0 scaffolding.
235
+- [x] Refresh sprint docs whose deliverables have been implemented under a different surface than originally planned.
236
+- [x] Keep `CLAUDE.md` as the authority for discipline, but make user-facing docs match the actual code.
237
+
238
+## Verification Commands
239
+
240
+- [x] `cargo test -p afs-ld`
241
+- [x] `cargo clippy -p afs-ld --all-targets -- -D warnings`
242
+- [ ] Focused xcrun-backed checks when touching reader/resolve/atom/TBD/dylib paths:
243
+  - [x] `cargo test -p afs-ld --test reader_corpus_round_trip -- --nocapture`
244
+  - [x] `cargo test -p afs-ld --test resolve_integration -- --nocapture`
245
+  - [x] `cargo test -p afs-ld --test atom_integration -- --nocapture`
246
+  - [x] `cargo test -p afs-ld --test dylib_integration -- --nocapture`
247
+  - [x] `cargo test -p afs-ld --test tbd_integration -- --nocapture`
.docs/sprints/sprint00.mdmodified
25 lines changed — click to load
@@ -88,14 +88,16 @@ pub fn link_both(case: &LinkCase) -> LinkOutputs;
8888
 pub fn diff_macho(ours: &[u8], theirs: &[u8]) -> DiffReport;
8989
 ```
9090
 
91
-`DiffReport` categorizes byte differences as `Tolerated` (UUID, timestamp, temp-path hashes) or `Critical` (anything else). Critical diffs fail the test. `link_both` shells out to `ld` via `xcrun -f ld` so it picks up the active toolchain.
91
+`DiffReport` categorizes byte differences as `Tolerated` (UUID, timestamp, temp-path hashes) or `Critical` (anything else). Critical diffs fail the test.
92
+
93
+Closeout note: the current Sprint 0 surface is intentionally synthetic. `diff_macho` exists and is tested, but `link_both` remains a placeholder until afs-ld can emit real linked output. That means the current harness validates diff categorization logic, not end-to-end linker parity yet.
9294
 
9395
 ### 6. Skeleton CLI and first failing test
9496
 
9597
 - `afs-ld/src/args.rs`: hand-rolled argv parser stub that recognizes `-o`, `-e`, `-arch`, and positional inputs. Unknown flags error loudly with a hint.
9698
 - `afs-ld/tests/reader_empty.rs`: attempts to link `0 inputs → empty output`, expects the diagnostic `"afs-ld: error: no input files"`. Passes today by producing that exact string.
97
-- `afs-ld/tests/diff_harness_sanity.rs`: runs the harness against a known-identical pair (two copies of the same pre-linked binary produced by `xcrun ld`) and expects zero diffs. Passes.
98
-- `afs-ld/tests/diff_harness_finds_critical.rs`: feeds the harness two binaries that differ in a non-tolerated byte range (e.g. different text bytes) and asserts the harness reports `Critical`. Passes.
99
+- `afs-ld/tests/diff_harness_sanity.rs`: exercises the diff surface against two identical synthetic byte slices and expects zero diffs. Passes.
100
+- `afs-ld/tests/diff_harness_finds_critical.rs`: feeds the harness two synthetic binaries that differ in a non-tolerated byte range and asserts the harness reports `Critical`. Passes.
99101
 
100102
 ## Testing Strategy
101103
 
@@ -115,5 +117,5 @@ pub fn diff_macho(ours: &[u8], theirs: &[u8]) -> DiffReport;
115117
 - `armfortas/Cargo.toml` lists `afs-ld` in `[workspace] members`.
116118
 - `afs-ld/CLAUDE.md`, `README.md`, `Cargo.toml`, `src/lib.rs`, `src/main.rs`, `src/args.rs` all committed in the new repo.
117119
 - `.refs/ld64/` and `.refs/mold/` cloned.
118
-- Differential harness runs, correctly reports zero diffs on identical binaries, correctly reports critical diffs on intentionally-different binaries.
120
+- Differential harness substrate runs, correctly reports zero diffs on identical byte slices, correctly reports critical diffs on intentionally-different byte slices.
119121
 - `cargo test --workspace` green.
.docs/sprints/sprint01.mdmodified
12 lines changed — click to load
@@ -6,6 +6,12 @@ Sprint 0 — crate, harness, references in place.
66
 ## Goals
77
 Read a Mach-O relocatable object file: parse the header and every load command afs-as emits. End state: given any `.o` in `afs-as/tests/corpus/`, afs-ld can pretty-print its structure and round-trip-compare it to a golden.
88
 
9
+Closeout note: alongside the original unit coverage, `tests/reader_malformed_stress.rs`
10
+now runs deterministic truncated/header-corruption cases over real corpus-built
11
+objects to defend the "no panics on malformed input" bar, and
12
+`tests/reader_tool_parity.rs` checks the `--dump` load-command surface against
13
+`otool -lV` across the afs-as corpus.
14
+
915
 ## Deliverables
1016
 
1117
 ### 1. Mach-O constants
.docs/sprints/sprint02.mdmodified
13 lines changed — click to load
@@ -6,6 +6,13 @@ Sprint 1 — header + load commands parsed.
66
 ## Goals
77
 Decode section payloads, the symbol table (nlist_64), and the string table. Expose the full section/symbol/string model that later sprints build on.
88
 
9
+Closeout note: `tests/reader_malformed_stress.rs` now also covers malformed
10
+symbol/string-table variants derived from real corpus objects so the reader's
11
+symbol and string surfaces are exercised under targeted bad-input cases, not
12
+just hand-written unit fixtures. `tests/reader_tool_parity.rs` now also checks
13
+symbol classification against `nm -a` and raw relocation tables against
14
+`otool -r` across the afs-as corpus.
15
+
916
 ## Deliverables
1017
 
1118
 ### 1. Section attributes and kinds
.docs/sprints/sprint04.mdmodified
24 lines changed — click to load
@@ -6,6 +6,13 @@ Sprints 1–3 — Mach-O reading complete.
66
 ## Goals
77
 Read static archives (`.a`) including the BSD, System V, and GNU-thin variants. Support lazy member fetching: a member is only parsed when an undefined symbol names it. This is the mechanism by which `libarmfortas_rt.a` gets pulled in.
88
 
9
+Closeout note: the force-load surface landed in the resolver as
10
+`resolve::force_load_archive` / `resolve::force_load_all`, and one-level
11
+nested archives are expanded through the fetched-member path with provenance
12
+chains such as `outer.a(inner.a)(foo.o)`. `--dump-archive` now intentionally
13
+prints the same member listing shape as `ar -t`, and parity is checked
14
+against both generated archives and `libarmfortas_rt.a` when available.
15
+
916
 ## Deliverables
1017
 
1118
 ### 1. Archive format recognizer
@@ -69,7 +76,10 @@ impl<'a> Archive<'a> {
6976
 Returns `None` if the archive does not define `name`. Fetching an archive member memoizes: a second lookup for the same member returns a cached handle. The resolution pass (Sprint 8) is the only caller.
7077
 
7178
 ### 6. `-force_load` / `-all_load` support (semantics, not CLI yet)
72
-Archive has a `force_all(&mut self)` method that pre-fetches every member. Sprint 19 wires the CLI.
79
+Implemented via the resolver-level helpers
80
+`resolve::force_load_archive` / `resolve::force_load_all`, which pre-fetch
81
+archive members against the live linker input registry. Sprint 19 wires the
82
+CLI surface.
7383
 
7484
 ### 7. Archive-of-archives
7585
 Rare but legal: member can be another `.a`. Recurse one level. If a sub-archive defines `name`, the outer `fetch` returns the sub-member's object file and records a provenance chain for diagnostics.
.docs/sprints/sprint08.mdmodified
55 lines changed — click to load
@@ -6,6 +6,11 @@ Sprint 7 — `SymbolTable` with insertion semantics.
66
 ## Goals
77
 Drive the symbol table to a fixed point: every undefined reference either resolves to a Defined (from an object), Common (promoted in BSS), DylibImport (from a dylib/TBD), or raises a clear, actionable diagnostic. `-force_load` / `-all_load` / `-undefined <treatment>` all handled.
88
 
9
+Closeout note: the implemented entrypoint is
10
+`resolve(inputs, table, opts) -> ResolutionReport`. The current library
11
+surface applies archive force-loading as archives are encountered in
12
+command-line order so left-to-right archive behavior stays explicit.
13
+
914
 ## Deliverables
1015
 
1116
 ### 1. Resolution algorithm
@@ -13,13 +18,10 @@ Drive the symbol table to a fixed point: every undefined reference either resolv
1318
 
1419
 ```rust
1520
 pub fn resolve(inputs: &mut Inputs, table: &mut SymbolTable, opts: &LinkOptions)
16
-    -> Result<(), Vec<ResolveError>>
21
+    -> Result<ResolutionReport, ResolutionError>
1722
 {
18
-    seed_table_with_objects_and_dylib_imports(inputs, table, opts);
19
-    if opts.all_load    { force_load_everything(inputs, table); }
20
-    for forced in &opts.force_load { force_load_one(inputs, table, forced); }
21
-    fixed_point_pull_from_archives(inputs, table);
22
-    classify_unresolved(table, opts);
23
+    seed_and_resolve_in_link_order(inputs, table, opts);
24
+    classify_unresolved(table, opts.undefined_treatment);
2325
 }
2426
 ```
2527
 
@@ -43,7 +45,7 @@ Order matters: armfortas's driver currently passes `<objs> <runtime.a> -lSystem`
4345
 ### 4. `-force_load` and `-all_load`
4446
 - `-force_load <archive>`: pull every member of that archive before fixed-point.
4547
 - `-all_load`: pull every member of every archive.
46
-- Both happen before the fixed-point loop so their transitively-pulled symbols feed into the same fixed point.
48
+- In the implemented surface these happen when the named archive is encountered in link order, which preserves left-to-right linker semantics while still feeding the same resolution/classification pipeline.
4749
 
4850
 ### 5. `-undefined <treatment>`
4951
 After the fixed point, any still-Undefined entry is classified by the `-undefined` setting:
@@ -60,8 +62,8 @@ Undefined errors must cite every referrer input, not just one. Output format:
6062
 
6163
 ```
6264
 afs-ld: error: undefined symbol: _afs_print
63
-      referenced by program.o(text section + 0x34)
64
-      referenced by runtime.o(text section + 0x120)
65
+      referenced by program.o(__TEXT,__text + 0x34)
66
+      referenced by runtime.o(__TEXT,__text + 0x120)
6567
       (also via 2 relocations in libarmfortas_rt.a(io.o))
6668
 Hint: did you mean _afs_print_real? (Levenshtein distance 5)
6769
 ```
@@ -71,8 +73,8 @@ Did-you-mean uses a basic Levenshtein-3 search over defined symbols.
7173
 ### 8. Diagnostics for duplicate strong
7274
 ```
7375
 afs-ld: error: duplicate symbol _foo
74
-  defined in: a.o (text + 0x0)
75
-  also in:    b.o (text + 0x0)
76
+  defined in: a.o (__TEXT,__text + 0x0)
77
+  also in:    b.o (__TEXT,__text + 0x0)
7678
 ```
7779
 
7880
 No suggestion — two strong defs is a real ambiguity.
.docs/sprints/sprint19.mdmodified
21 lines changed — click to load
@@ -144,3 +144,21 @@ afs-ld: loading /usr/lib/libSystem.tbd
144144
 - `-why_live` produces a coherent chain on fixtures with dead-strip enabled.
145145
 - Unknown-flag errors include a did-you-mean suggestion.
146146
 - CLI surface passes a snapshot test against the `--help` output.
147
+
148
+## Remaining Flag Slices
149
+- [x] `-exported_symbols_list <file>`
150
+- [x] `-unexported_symbols_list <file>`
151
+- [x] `-exported_symbol <sym>`
152
+- [x] `-unexported_symbol <sym>`
153
+- [x] `-r` / `-bundle` explicit deferred errors
154
+- [x] `-S`
155
+- [x] `-no_uuid`
156
+- [x] `-dead_strip`
157
+- [x] `-icf=safe` / `-icf=none`
158
+- [x] `-fixup_chains` / `-no_fixup_chains`
159
+- [x] `-Wl,<comma-separated>` normalization
160
+- [x] `-map <path>`
161
+- [x] `-t` / `-trace`
162
+- [x] `-v` / `--version`
163
+- [x] `-h` / `--help`
164
+- [x] `-why_live <symbol>`
.docs/sprints/sprint27.mdmodified
21 lines changed — click to load
@@ -42,15 +42,12 @@ For each scenario, compare:
4242
 
4343
 ### 3. Tolerated-diff rules
4444
 
45
-```rust
46
-pub enum ToleratedDiff {
47
-    UuidBytes,
48
-    Timestamp,
49
-    PathHashInString(&'static str),      // e.g. temp path in stabs
50
-    StringTableSuffixDedupVariance,
51
-    CodeSignatureHashes,
52
-}
53
-```
45
+Current Sprint 27 allowlist is intentionally small and explicit:
46
+- UUID load-command bytes.
47
+- Dylib timestamp fields.
48
+- Code-signature load-command/blob bytes.
49
+- Case-specific section-byte ranges declared in `notes.md`.
50
+- String-table length drift within 5% for suffix-dedup variance.
5451
 
5552
 Each tolerance has a precise predicate — no loose "any byte in __LINKEDIT". Unknown diffs fail.
5653
 
.docs/sprints/sprint28.mdmodified
91 lines changed — click to load
@@ -4,13 +4,20 @@
44
 Sprint 27 — correctness gate in place; can freely refactor for speed.
55
 
66
 ## Goals
7
-Make afs-ld fast enough to feel like a production tool. Target: within 2× of Apple `ld`'s wall time on the fortsh link. Mold demonstrates linkers can be very fast; we don't need mold's speed, but we need to not be painful.
7
+Make afs-ld fast enough to feel like a production tool. Sprint 28 establishes
8
+the profiling surface, parallelizes the obvious hot paths, and enforces
9
+hello/runtime-link budgets in CI. The fortsh 2× Apple `ld` gate remains the
10
+production target, but Sprint 29 owns the fortsh fixture and final comparison.
11
+Mold demonstrates linkers can be very fast; we don't need mold's speed, but we
12
+need to not be painful.
813
 
914
 ## Deliverables
1015
 
1116
 ### 1. Baseline profile
1217
 
13
-Profile the fortsh link (Sprint 29 produces the fixture). Categorize wall time:
18
+Profile representative hello-world and runtime-archive links in Sprint 28.
19
+Sprint 29 extends the same profile surface to the fortsh link once the fixture
20
+exists. Categorize wall time:
1421
 
1522
 - Input parsing (Mach-O headers, sections, symbols, relocations).
1623
 - Symbol resolution (hash-map probes, archive lookups).
@@ -37,11 +44,17 @@ One thread per 4 KiB page. SHA-256 is inherently sequential within a page but tr
3744
 
3845
 ### 5. Bump allocator for ephemeral data
3946
 
40
-Parser produces many small allocations (strings, reloc lists, atom descriptors). A per-input arena avoids fragmentation and makes bulk drop free. Implement as `src/arena.rs` — a std-only `Vec<Box<[u8]>>` chunker.
47
+Deferred. The current Sprint 28 profile work did not prove allocation churn is
48
+the next limiting bucket after the parallel parsing/relocation/signature and
49
+string-table clone fixes. If Sprint 29's fortsh profile shows parser allocation
50
+pressure, implement `src/arena.rs` as a std-only `Vec<Box<[u8]>>` chunker.
4151
 
4252
 ### 6. mmap for large inputs
4353
 
44
-`std::fs::File` + `memmap2`? No — memmap2 is an external crate. Use `libc::mmap` via an unsafe `src/mmap.rs` wrapper. Input files are always read-only; mmap saves a read syscall and lets us share parse state across threads cheaply. Fall back to `fs::read` for GNU-thin archive members whose external path doesn't mmap cleanly (rare).
54
+Deferred. Object/archive loading still uses `fs::read`; this keeps the Sprint 28
55
+closeout safe and std-only. If fortsh-sized inputs show file-read overhead as a
56
+real bucket in Sprint 29, add an unsafe `src/mmap.rs` wrapper and keep a
57
+`fs::read` fallback for archive members whose external path cannot be mapped.
4558
 
4659
 ### 7. Symbol-table hash map
4760
 
@@ -49,7 +62,10 @@ Profile shows std `HashMap` is fine for our scale. If not: replace with an open-
4962
 
5063
 ### 8. String interner
5164
 
52
-Single global `StringInterner` shared across inputs. Interning cost: one hash lookup per name. Optimize by batching per-input: each input parses its strings into a local table, then merges into the global interner in one pass.
65
+Deferred. Sprint 28 made the global string table thread-shareable and removed
66
+the cloned string-table offset map during output writing. Per-input local
67
+interners remain a candidate if Sprint 29 identifies symbol seeding as a
68
+fortsh-scale bottleneck.
5369
 
5470
 ### 9. No-alloc hot paths
5571
 
@@ -57,15 +73,16 @@ Reloc application and chain construction should not allocate per-reloc. Prealloc
5773
 
5874
 ### 10. Benchmarks
5975
 
60
-`afs-ld/bench/` (or a `#[bench]` behind `cargo +nightly bench`) with:
61
-- `bench_hello_world`: small, measures startup overhead.
62
-- `bench_runtime_link`: mid, measures symbol-table & reloc-apply.
63
-- `bench_fortsh_link`: large, measures end-to-end throughput.
76
+Sprint 28 uses CI-enforced integration benchmarks in `tests/perf_baseline.rs`:
77
+
78
+- `bench_hello_world_profile_reports_baseline_timings`: small, measures startup overhead.
79
+- `bench_runtime_link_profile_reports_baseline_timings`: mid, measures symbol-table, archive parsing, and reloc-apply.
80
+- `bench_fortsh_link`: deferred to Sprint 29 with the real fortsh fixture.
6481
 
6582
 Budget targets:
6683
 - hello-world: ≤ 20 ms.
6784
 - runtime link: ≤ 150 ms.
68
-- fortsh link: ≤ 2× Apple `ld`'s wall time on the same machine.
85
+- fortsh link: ≤ 2× Apple `ld`'s wall time on the same machine, enforced in Sprint 29.
6986
 
7087
 ### 11. Determinism preserved
7188
 
@@ -73,14 +90,16 @@ Parallelism must not reorder output. Each worker produces a deterministic result
7390
 
7491
 ## Testing Strategy
7592
 
76
-- Benchmarks land as regression gates: nightly CI records throughput; > 10% regression fails.
93
+- Benchmark gate: CI runs `tests/perf_baseline.rs` with hello/runtime budgets on every push and PR.
94
+- Nightly throughput recording and a relative >10% regression gate are deferred until the fortsh fixture lands in Sprint 29.
7795
 - Determinism: 100 parallel runs of the same input, assert byte-identical output every time.
7896
 - Sprint 27 parity must remain green — no correctness regression.
7997
 - Single-threaded fallback (`-j 1`) for debugging.
8098
 
8199
 ## Definition of Done
82100
 
83
-- fortsh link wall time within 2× of `ld`'s.
101
+- hello/runtime performance budgets are enforced in CI.
102
+- fortsh 2× comparison is explicitly handed to Sprint 29 with its fixture.
84103
 - All Sprint 27 scenarios still byte-identical.
85104
 - Determinism bulletproof across parallelism.
86105
 - No external dependencies added.
.github/workflows/parity-matrix.ymladded
47 lines changed — click to load
@@ -0,0 +1,47 @@
1
+name: parity-matrix
2
+
3
+on:
4
+  pull_request:
5
+  push:
6
+    branches:
7
+      - trunk
8
+
9
+permissions:
10
+  contents: read
11
+
12
+jobs:
13
+  parity-matrix:
14
+    runs-on: macos-14
15
+    timeout-minutes: 30
16
+    steps:
17
+      - name: Checkout
18
+        uses: actions/checkout@v4
19
+
20
+      - name: Install Rust
21
+        uses: dtolnay/rust-toolchain@stable
22
+
23
+      - name: Run parity harness proof tests
24
+        run: cargo test --test diff_harness_tolerates_known_linkedit --test parity_harness --test parity_canary -- --nocapture
25
+
26
+      - name: Run determinism gate
27
+        run: cargo test --test determinism -- --nocapture
28
+
29
+      - name: Run performance budget gate
30
+        env:
31
+          AFS_LD_HELLO_BUDGET_MS: "20"
32
+          AFS_LD_RUNTIME_BUDGET_MS: "150"
33
+        run: cargo test --test perf_baseline -- --nocapture
34
+
35
+      - name: Run parity matrix
36
+        env:
37
+          PARITY_MATRIX_ARTIFACT_DIR: ${{ github.workspace }}/parity-matrix-artifacts
38
+          PARITY_MATRIX_MAX_SECONDS: "120"
39
+        run: cargo test --test parity_matrix -- --nocapture
40
+
41
+      - name: Upload parity artifacts
42
+        if: always()
43
+        uses: actions/upload-artifact@v4
44
+        with:
45
+          name: parity-matrix-html
46
+          path: parity-matrix-artifacts
47
+          if-no-files-found: warn
AGENTS.mdadded
349 lines changed — click to load
@@ -0,0 +1,349 @@
1
+# AFS-LD
2
+
3
+Local working guide for agents in `afs-ld`. Keep this file untracked.
4
+`CLAUDE.md` is the tracked, authoritative policy file; this document adds a
5
+reality-checked snapshot of the current implementation so we do not confuse the
6
+roadmap with shipped code.
7
+
8
+## Repository Context
9
+
10
+`afs-ld` is the standalone ARM64 Mach-O linker for the ARMFORTAS toolchain. It
11
+sits beside `afs-as` as a submodule in the `armfortas` workspace and is meant
12
+to replace Apple's `ld` for binaries produced by armfortas.
13
+
14
+The project boundary is intentionally clean:
15
+
16
+- `afs-as` emits `MH_OBJECT`.
17
+- `afs-ld` reads `.o`, `.a`, `.dylib`, and `.tbd`.
18
+- armfortas should eventually hand final linking to `afs-ld` rather than to
19
+  the system linker.
20
+
21
+The project is Mach-O only, macOS only, arm64 only, stdlib only.
22
+
23
+## Definition Of Done
24
+
25
+The real finish line is not "parses some objects" or "links hello world once."
26
+It is parity with Apple's `ld` for the binaries armfortas and fortsh need:
27
+
28
+- arm64 Mach-O executables and dylibs
29
+- static archive linking
30
+- dylib and TBD ingestion
31
+- dyld metadata that works on real macOS systems
32
+- ad-hoc signing so output executes on Apple Silicon
33
+- deterministic output
34
+- enough correctness to link fortsh without ARM-specific workarounds
35
+
36
+## Current Reality
37
+
38
+This repo is ahead of Sprint 0 scaffolding, but it is not yet a full linker.
39
+The roadmap in `.docs/overview.md` and `.docs/sprints/` is broader than the
40
+code that exists today.
41
+
42
+What is implemented now:
43
+
44
+- hand-rolled CLI parsing for a small flag subset plus dump modes
45
+- Mach-O header/load-command/section/symbol/string-table reading
46
+- relocation parsing, fusion, validation, and round-trip support
47
+- archive parsing and lazy member fetch support
48
+- binary dylib parsing and export-trie walking
49
+- TAPI TBD v4 parsing, including the custom YAML subset parser
50
+- linker-side symbol interning, symbol table modeling, and resolution passes
51
+- subsections-via-symbols atomization
52
+- `--dump`, `--dump-archive`, `--dump-dylib`, and `--dump-tbd`
53
+
54
+What is not implemented yet:
55
+
56
+- real `Linker::run` output production
57
+- output layout and Mach-O writing
58
+- dyld metadata synthesis
59
+- code signing
60
+- dead-strip / ICF / thunks
61
+- real differential linking against Apple `ld`
62
+- driver integration with armfortas
63
+- the full `ld`-compatible CLI surface described in Sprint 19
64
+
65
+Important practical note:
66
+
67
+- `src/lib.rs` still returns `LinkError::NotYetImplemented` for real link runs.
68
+- `tests/common/harness.rs::link_both` still panics because full end-to-end
69
+  linker execution has not landed.
70
+- `README.md` still describes the crate as "Sprint 0 scaffolding only," which is
71
+  now too pessimistic for the read-side code but still accurate for the actual
72
+  link-producing path.
73
+
74
+As of 2026-04-15 in this checkout, `cargo test -p afs-ld` is green.
75
+
76
+## Strengths
77
+
78
+- The read-side core is already substantial and well-tested.
79
+- The project has strong bespoke discipline: no `clap`, `serde`, `object`,
80
+  `goblin`, `byteorder`, or other format-parsing shortcuts.
81
+- Raw wire structures are modeled explicitly and usually paired with
82
+  round-trip-oriented tests.
83
+- The type modeling is strong: opaque ids, interned strings, explicit symbol
84
+  states, explicit atom ownership, explicit relocation referents.
85
+- Real-world fixtures are already in play: afs-as corpus objects,
86
+  `libarmfortas_rt.a`, `libSystem.tbd`, and small clang-built dylibs.
87
+- The codebase already separates concerns cleanly enough that writer/layout work
88
+  can land without tearing up the read-side foundation.
89
+- Dump modes make inspection easy and are useful while the full writer does not
90
+  exist yet.
91
+
92
+## Weaknesses And Risk Areas
93
+
94
+- The actual link-producing pipeline does not exist yet, so the hardest parity
95
+  bugs are still ahead of us.
96
+- Some tracked docs are aspirational. `.docs/overview.md` is the intended end
97
+  state, not a guarantee that every listed module already exists.
98
+- `README.md` is stale in the opposite direction: it understates how much
99
+  read-side work has landed.
100
+- The current diagnostics surface is still minimal. `src/diag.rs` only prints
101
+  `afs-ld: error: ...`; the richer caret diagnostics are planned, not present.
102
+- The CLI surface is intentionally tiny right now. Any work that assumes
103
+  `ld`-compatibility must start by checking `src/args.rs`, not by trusting the
104
+  sprint plan.
105
+- Performance characteristics are mostly unknown because the writer, layout, and
106
+  full-link path are not in place yet.
107
+- The differential harness is only half-built: the diff engine exists, but the
108
+  "run both linkers" machinery is not wired.
109
+- Several future modules named in the roadmap do not exist yet:
110
+  `layout.rs`, `driver.rs`, `map.rs`, `gc.rs`, `icf.rs`, `synth/`,
111
+  `macho/writer.rs`, and the code-signing path are all still planned work.
112
+
113
+## Build And Test
114
+
115
+Primary commands:
116
+
117
+```bash
118
+cargo build -p afs-ld
119
+cargo test -p afs-ld
120
+cargo clippy -p afs-ld --all-targets -- -D warnings
121
+```
122
+
123
+Useful targeted commands:
124
+
125
+```bash
126
+cargo test --lib -p afs-ld
127
+cargo test --test reader_corpus_round_trip -p afs-ld
128
+cargo test --test archive_runtime -p afs-ld
129
+cargo test --test dylib_integration -p afs-ld
130
+cargo test --test tbd_integration -p afs-ld
131
+cargo test --test resolve_integration -p afs-ld
132
+cargo test --test atom_integration -p afs-ld
133
+cargo test -p afs-ld -- <substring>
134
+```
135
+
136
+Environment assumptions:
137
+
138
+- macOS on Apple Silicon
139
+- Xcode command-line tools available through `xcrun`
140
+- access to the parent workspace, especially `runtime/` and `.refs/`
141
+
142
+Integration tests already shell out to system tools in a few places. Do not
143
+replace those with fake fixtures if a real toolchain interaction is the thing
144
+being tested.
145
+
146
+## Project Structure
147
+
148
+Actual source tree today:
149
+
150
+```text
151
+afs-ld/
152
+├── CLAUDE.md
153
+├── README.md
154
+├── .docs/
155
+│   ├── overview.md
156
+│   └── sprints/
157
+├── src/
158
+│   ├── archive.rs
159
+│   ├── args.rs
160
+│   ├── atom.rs
161
+│   ├── diag.rs
162
+│   ├── dump.rs
163
+│   ├── input.rs
164
+│   ├── leb.rs
165
+│   ├── lib.rs
166
+│   ├── main.rs
167
+│   ├── resolve.rs
168
+│   ├── section.rs
169
+│   ├── string_table.rs
170
+│   ├── symbol.rs
171
+│   ├── macho/
172
+│   │   ├── constants.rs
173
+│   │   ├── dylib.rs
174
+│   │   ├── exports.rs
175
+│   │   ├── reader.rs
176
+│   │   ├── tbd.rs
177
+│   │   └── tbd_yaml.rs
178
+│   └── reloc/
179
+│       └── mod.rs
180
+└── tests/
181
+    ├── common/harness.rs
182
+    ├── archive_runtime.rs
183
+    ├── atom_integration.rs
184
+    ├── diff_harness_*.rs
185
+    ├── dylib_integration.rs
186
+    ├── reader_*.rs
187
+    ├── resolve_integration.rs
188
+    ├── tbd_*.rs
189
+    └── reader_corpus_round_trip.rs
190
+```
191
+
192
+Planned future modules listed in the docs should be treated as design intent,
193
+not as present-tense implementation.
194
+
195
+## Implemented Pipeline Vs Planned Pipeline
196
+
197
+Implemented today:
198
+
199
+```text
200
+argv
201
+  -> args.rs
202
+  -> dump/read paths
203
+  -> archive/object/dylib/TBD ingestion
204
+  -> symbol/section/reloc decoding
205
+  -> resolve.rs
206
+  -> atom.rs
207
+```
208
+
209
+Current real-link path:
210
+
211
+```text
212
+argv -> args.rs -> Linker::run -> NotYetImplemented
213
+```
214
+
215
+Planned end-to-end pipeline from the roadmap:
216
+
217
+```text
218
+args -> inputs -> resolve -> atomize -> layout -> apply relocs
219
+     -> synth sections -> write -> sign
220
+```
221
+
222
+When you are planning work, always identify which of those stages is real in
223
+this checkout and which stage is still only described in docs.
224
+
225
+## Development Guidance
226
+
227
+### 1. Trust code and tests over roadmap prose
228
+
229
+Read these in order before substantial work:
230
+
231
+1. `CLAUDE.md`
232
+2. `.docs/overview.md`
233
+3. the relevant sprint file in `.docs/sprints/`
234
+4. the actual Rust module you will touch
235
+5. the tests covering that module
236
+
237
+If the docs and the code disagree, treat the code plus tests as the truth about
238
+what exists today, then decide whether the docs need to be refreshed.
239
+
240
+### 2. Keep the bespoke contract intact
241
+
242
+- Stdlib only unless a dependency discussion happens first.
243
+- Do not couple afs-ld to afs-as at a Rust type level.
244
+- Duplicate Mach-O constants locally when needed.
245
+- Do not hide format details behind clever abstractions that erase wire truth.
246
+
247
+### 3. Preserve the wire
248
+
249
+- Keep raw bytes or raw fields accessible when lossless re-emission matters.
250
+- Prefer explicit parse and write pairs for on-disk structures.
251
+- Avoid converting fixed-size or padded wire data into lossy higher-level forms
252
+  unless the raw representation is still available somewhere.
253
+- If a new decoder lands, pair it with tests that prove it round-trips or at
254
+  least preserves the exact bytes relevant to the current stage.
255
+
256
+### 4. Be explicit about incomplete work
257
+
258
+- Hard errors are better than silent wrong answers.
259
+- If something is not implemented, say so directly.
260
+- Do not introduce "temporary" behavior that quietly emits malformed Mach-O.
261
+- Do not soften a missing feature into a no-op unless the flag or structure is
262
+  explicitly intended to be ignored.
263
+
264
+### 5. Exhaustive matches matter
265
+
266
+- Prefer enums for wire forms and linker-side states.
267
+- Avoid catch-all `_` arms in production matches when a new variant should force
268
+  the compiler to help us.
269
+- When adding a new variant, update every relevant match deliberately.
270
+
271
+### 6. Keep dump surfaces useful
272
+
273
+- `--dump*` modes are an active debugging tool, not a side feature.
274
+- When new reader functionality lands, extend the corresponding dump output.
275
+- If you add a new parsed field but the dump cannot show it, the repo loses one
276
+  of its best inspection surfaces.
277
+
278
+### 7. Respect deterministic behavior
279
+
280
+- Avoid nondeterministic iteration when output order matters.
281
+- Avoid timestamps, random ids, or unstable hashing in any future write path.
282
+- When adding diagnostics, keep them stable and testable.
283
+
284
+## Testing Practices
285
+
286
+- Every bug fix gets a regression test.
287
+- New parser behavior should land with unit tests close to the module.
288
+- When touching integration behavior, prefer real fixtures over mocked ones.
289
+- For archive work, look first at `tests/archive_runtime.rs`.
290
+- For dylib and TBD work, look first at `tests/dylib_integration.rs`,
291
+  `tests/tbd_integration.rs`, and `tests/tbd_smoke.rs`.
292
+- For reader invariants, `tests/reader_corpus_round_trip.rs` is a key guardrail.
293
+- For resolution and atomization, `tests/resolve_integration.rs` and
294
+  `tests/atom_integration.rs` should move with the code.
295
+- If you add future write-side functionality, extend the differential harness
296
+  rather than building a parallel ad hoc test path.
297
+
298
+Run focused tests first, then widen:
299
+
300
+- module-local or single integration test while developing
301
+- `cargo test -p afs-ld` before handing work off
302
+- `cargo clippy -p afs-ld --all-targets -- -D warnings` when changing code paths
303
+  broadly enough to justify it
304
+
305
+## Documentation Practices
306
+
307
+- `CLAUDE.md` is policy and development discipline.
308
+- `.docs/overview.md` is the intended architecture and scope.
309
+- `.docs/sprints/` is the staged roadmap.
310
+- `README.md` is user-facing and currently stale relative to the read-side code.
311
+
312
+When a change materially shifts reality, update the tracked docs that are now
313
+misleading. This is especially important in this repo because the roadmap is
314
+ambitious and can otherwise create false assumptions for future work.
315
+
316
+## References
317
+
318
+Use the parent repository's references when you need to confirm Mach-O or linker
319
+behavior instead of inventing from memory:
320
+
321
+- `.refs/llvm/lld/MachO/` for architecture and pass structure
322
+- `.refs/ld64/` for Apple-parity edge cases
323
+- `.refs/mold/` for performance ideas and comparative implementation choices
324
+
325
+Also use Apple's Mach-O and arm64 relocation headers as the numeric source of
326
+truth for constants mirrored in `src/macho/constants.rs`.
327
+
328
+## Working Style For This Repo
329
+
330
+- Prefer small, reviewable changes.
331
+- Keep commit messages terse and imperative.
332
+- Do not mention sprint numbers in commit subjects.
333
+- Avoid monolithic "land the whole linker" changes; the sprint plan is granular
334
+  for a reason.
335
+- Before implementing a planned module from the roadmap, make sure the crate
336
+  actually has the prerequisites the sprint assumed.
337
+- If you are about to say "the docs say this exists," stop and confirm with
338
+  `ls`, `rg`, and the tests.
339
+
340
+## Practical Shortcuts
341
+
342
+- Use `rg --files` and `rg` first; the repo is small enough that this is fast
343
+  and keeps context grounded in the actual tree.
344
+- For current status, start with `src/lib.rs`, `src/main.rs`, `src/args.rs`,
345
+  `tests/common/harness.rs`, and `README.md`.
346
+- For architectural intent, then read `.docs/overview.md` and the relevant
347
+  sprint file.
348
+
349
+That order will save a lot of confusion.
src/args.rsmodified
833 lines changed — click to load
@@ -6,14 +6,77 @@
66
 
77
 use std::path::PathBuf;
88
 
9
-use crate::{LinkOptions, OutputKind};
9
+use crate::resolve::{levenshtein, UndefinedTreatment};
10
+use crate::{FrameworkSpec, IcfMode, LinkOptions, OutputKind, PlatformVersion, ThunkMode};
11
+
12
+const KNOWN_FLAGS: &[&str] = &[
13
+    "-o",
14
+    "-e",
15
+    "-arch",
16
+    "-l",
17
+    "-L",
18
+    "-framework",
19
+    "-weak_framework",
20
+    "-ObjC",
21
+    "-syslibroot",
22
+    "-platform_version",
23
+    "-r",
24
+    "-bundle",
25
+    "-undefined",
26
+    "-rpath",
27
+    "-install_name",
28
+    "-current_version",
29
+    "-compatibility_version",
30
+    "-exported_symbols_list",
31
+    "-unexported_symbols_list",
32
+    "-exported_symbol",
33
+    "-unexported_symbol",
34
+    "-S",
35
+    "-no_uuid",
36
+    "-no_loh",
37
+    "-thunks=none",
38
+    "-thunks=safe",
39
+    "-thunks=all",
40
+    "-dead_strip",
41
+    "-icf=safe",
42
+    "-icf=none",
43
+    "-icf=all",
44
+    "-fixup_chains",
45
+    "-no_fixup_chains",
46
+    "-map",
47
+    "-why_live",
48
+    "-t",
49
+    "-trace",
50
+    "-v",
51
+    "--version",
52
+    "-h",
53
+    "--help",
54
+    "-x",
55
+    "-dylib",
56
+    "-all_load",
57
+    "-force_load",
58
+    "-j",
59
+    "--dump",
60
+    "--dump-archive",
61
+    "--dump-dylib",
62
+    "--dump-tbd",
63
+];
1064
 
1165
 #[derive(Debug)]
1266
 pub enum ArgsError {
1367
     /// A flag that takes an argument was supplied without one.
1468
     MissingValue(String),
69
+    /// A recognized flag got a value we do not accept.
70
+    InvalidValue {
71
+        flag: String,
72
+        value: String,
73
+        expected: String,
74
+    },
1575
     /// An unrecognized flag.
16
-    UnknownFlag(String),
76
+    UnknownFlag {
77
+        flag: String,
78
+        suggestion: Option<String>,
79
+    },
1780
 }
1881
 
1982
 impl std::fmt::Display for ArgsError {
@@ -22,19 +85,85 @@ impl std::fmt::Display for ArgsError {
2285
             ArgsError::MissingValue(flag) => {
2386
                 write!(f, "flag `{flag}` requires a value")
2487
             }
25
-            ArgsError::UnknownFlag(flag) => {
88
+            ArgsError::InvalidValue {
89
+                flag,
90
+                value,
91
+                expected,
92
+            } => {
2693
                 write!(
2794
                     f,
28
-                    "unknown flag `{flag}` (Sprint 19 adds the full `ld` surface)"
95
+                    "flag `{flag}` got invalid value `{value}` (expected {expected})"
2996
                 )
3097
             }
98
+            ArgsError::UnknownFlag { flag, suggestion } => {
99
+                write!(f, "unknown flag `{flag}`")?;
100
+                if let Some(suggestion) = suggestion {
101
+                    write!(f, " (did you mean `{suggestion}`?)")?;
102
+                }
103
+                write!(f, " (Sprint 19 adds the full `ld` surface)")
104
+            }
31105
         }
32106
     }
33107
 }
34108
 
109
+fn unknown_flag(flag: &str) -> ArgsError {
110
+    let suggestion = KNOWN_FLAGS
111
+        .iter()
112
+        .map(|candidate| (levenshtein(flag, candidate), *candidate))
113
+        .filter(|(distance, _)| *distance <= 3)
114
+        .min_by_key(|(distance, candidate)| (*distance, candidate.len()))
115
+        .map(|(_, candidate)| candidate.to_string());
116
+    ArgsError::UnknownFlag {
117
+        flag: flag.to_string(),
118
+        suggestion,
119
+    }
120
+}
121
+
122
+fn parse_version_component(flag: &str, value: &str) -> Result<u32, ArgsError> {
123
+    let mut parts = value.split('.');
124
+    let parse_part = |piece: Option<&str>| -> Result<u32, ArgsError> {
125
+        let raw = piece.unwrap_or("0");
126
+        raw.parse::<u32>().map_err(|_| ArgsError::InvalidValue {
127
+            flag: flag.to_string(),
128
+            value: value.to_string(),
129
+            expected: "version like <major>[.<minor>[.<patch>]]".into(),
130
+        })
131
+    };
132
+    let major = parse_part(parts.next())?;
133
+    let minor = parse_part(parts.next())?;
134
+    let patch = parse_part(parts.next())?;
135
+    if parts.next().is_some() {
136
+        return Err(ArgsError::InvalidValue {
137
+            flag: flag.to_string(),
138
+            value: value.to_string(),
139
+            expected: "version like <major>[.<minor>[.<patch>]]".into(),
140
+        });
141
+    }
142
+    Ok((major << 16) | ((minor & 0xff) << 8) | (patch & 0xff))
143
+}
144
+
145
+fn parse_jobs(value: &str) -> Result<usize, ArgsError> {
146
+    let jobs = value
147
+        .parse::<usize>()
148
+        .map_err(|_| ArgsError::InvalidValue {
149
+            flag: "-j".into(),
150
+            value: value.to_string(),
151
+            expected: "positive integer job count".into(),
152
+        })?;
153
+    if jobs == 0 {
154
+        return Err(ArgsError::InvalidValue {
155
+            flag: "-j".into(),
156
+            value: value.to_string(),
157
+            expected: "positive integer job count".into(),
158
+        });
159
+    }
160
+    Ok(jobs)
161
+}
162
+
35163
 pub fn parse(argv: &[String]) -> Result<LinkOptions, ArgsError> {
164
+    let normalized = normalize_wl(argv);
36165
     let mut opts = LinkOptions::default();
37
-    let mut it = argv.iter();
166
+    let mut it = normalized.iter();
38167
     while let Some(arg) = it.next() {
39168
         match arg.as_str() {
40169
             "-o" => {
@@ -57,12 +186,240 @@ pub fn parse(argv: &[String]) -> Result<LinkOptions, ArgsError> {
57186
                         .clone(),
58187
                 );
59188
             }
189
+            "-l" => {
190
+                opts.library_names.push(
191
+                    it.next()
192
+                        .ok_or_else(|| ArgsError::MissingValue("-l".into()))?
193
+                        .clone(),
194
+                );
195
+            }
196
+            s if s.starts_with("-l") && s.len() > 2 => {
197
+                opts.library_names.push(s[2..].to_string());
198
+            }
199
+            "-L" => {
200
+                opts.search_paths.push(PathBuf::from(
201
+                    it.next()
202
+                        .ok_or_else(|| ArgsError::MissingValue("-L".into()))?,
203
+                ));
204
+            }
205
+            "-framework" => {
206
+                opts.frameworks.push(FrameworkSpec {
207
+                    name: it
208
+                        .next()
209
+                        .ok_or_else(|| ArgsError::MissingValue("-framework".into()))?
210
+                        .clone(),
211
+                    weak: false,
212
+                });
213
+            }
214
+            "-weak_framework" => {
215
+                opts.frameworks.push(FrameworkSpec {
216
+                    name: it
217
+                        .next()
218
+                        .ok_or_else(|| ArgsError::MissingValue("-weak_framework".into()))?
219
+                        .clone(),
220
+                    weak: true,
221
+                });
222
+            }
223
+            "-ObjC" => {
224
+                opts.objc_force_load = true;
225
+            }
226
+            "-syslibroot" => {
227
+                opts.syslibroot =
228
+                    Some(PathBuf::from(it.next().ok_or_else(|| {
229
+                        ArgsError::MissingValue("-syslibroot".into())
230
+                    })?));
231
+            }
232
+            "-platform_version" => {
233
+                let platform = it
234
+                    .next()
235
+                    .ok_or_else(|| ArgsError::MissingValue("-platform_version".into()))?;
236
+                if platform != "macos" {
237
+                    return Err(ArgsError::InvalidValue {
238
+                        flag: "-platform_version".into(),
239
+                        value: platform.clone(),
240
+                        expected: "platform `macos`".into(),
241
+                    });
242
+                }
243
+                let minos_raw = it
244
+                    .next()
245
+                    .ok_or_else(|| ArgsError::MissingValue("-platform_version".into()))?;
246
+                let sdk_raw = it
247
+                    .next()
248
+                    .ok_or_else(|| ArgsError::MissingValue("-platform_version".into()))?;
249
+                opts.platform_version = Some(PlatformVersion {
250
+                    minos: parse_version_component("-platform_version", minos_raw)?,
251
+                    sdk: parse_version_component("-platform_version", sdk_raw)?,
252
+                });
253
+            }
254
+            "-r" => {
255
+                opts.relocatable = true;
256
+            }
257
+            "-bundle" => {
258
+                opts.bundle = true;
259
+            }
260
+            "-undefined" => {
261
+                let value = it
262
+                    .next()
263
+                    .ok_or_else(|| ArgsError::MissingValue("-undefined".into()))?;
264
+                opts.undefined_treatment = match value.as_str() {
265
+                    "error" => UndefinedTreatment::Error,
266
+                    "warning" => UndefinedTreatment::Warning,
267
+                    "suppress" => UndefinedTreatment::Suppress,
268
+                    "dynamic_lookup" => UndefinedTreatment::DynamicLookup,
269
+                    _ => {
270
+                        return Err(ArgsError::InvalidValue {
271
+                            flag: "-undefined".into(),
272
+                            value: value.clone(),
273
+                            expected: "`error`, `warning`, `suppress`, or `dynamic_lookup`".into(),
274
+                        });
275
+                    }
276
+                };
277
+            }
278
+            "-rpath" => {
279
+                opts.rpaths.push(
280
+                    it.next()
281
+                        .ok_or_else(|| ArgsError::MissingValue("-rpath".into()))?
282
+                        .clone(),
283
+                );
284
+            }
285
+            "-install_name" => {
286
+                opts.install_name = Some(
287
+                    it.next()
288
+                        .ok_or_else(|| ArgsError::MissingValue("-install_name".into()))?
289
+                        .clone(),
290
+                );
291
+            }
292
+            "-current_version" => {
293
+                let value = it
294
+                    .next()
295
+                    .ok_or_else(|| ArgsError::MissingValue("-current_version".into()))?;
296
+                opts.current_version = Some(parse_version_component("-current_version", value)?);
297
+            }
298
+            "-compatibility_version" => {
299
+                let value = it
300
+                    .next()
301
+                    .ok_or_else(|| ArgsError::MissingValue("-compatibility_version".into()))?;
302
+                opts.compatibility_version =
303
+                    Some(parse_version_component("-compatibility_version", value)?);
304
+            }
305
+            "-exported_symbols_list" => {
306
+                opts.exported_symbols_lists
307
+                    .push(PathBuf::from(it.next().ok_or_else(|| {
308
+                        ArgsError::MissingValue("-exported_symbols_list".into())
309
+                    })?));
310
+            }
311
+            "-unexported_symbols_list" => {
312
+                opts.unexported_symbols_lists.push(PathBuf::from(
313
+                    it.next().ok_or_else(|| {
314
+                        ArgsError::MissingValue("-unexported_symbols_list".into())
315
+                    })?,
316
+                ));
317
+            }
318
+            "-exported_symbol" => {
319
+                opts.exported_symbols.push(
320
+                    it.next()
321
+                        .ok_or_else(|| ArgsError::MissingValue("-exported_symbol".into()))?
322
+                        .clone(),
323
+                );
324
+            }
325
+            "-unexported_symbol" => {
326
+                opts.unexported_symbols.push(
327
+                    it.next()
328
+                        .ok_or_else(|| ArgsError::MissingValue("-unexported_symbol".into()))?
329
+                        .clone(),
330
+                );
331
+            }
332
+            "-S" => {
333
+                opts.strip_debug = true;
334
+            }
335
+            "-no_uuid" => {
336
+                opts.emit_uuid = false;
337
+            }
338
+            "-no_loh" => {
339
+                opts.no_loh = true;
340
+            }
341
+            s if s.starts_with("-thunks=") => {
342
+                opts.thunks = match s {
343
+                    "-thunks=none" => ThunkMode::None,
344
+                    "-thunks=safe" => ThunkMode::Safe,
345
+                    "-thunks=all" => ThunkMode::All,
346
+                    _ => {
347
+                        let value = s.trim_start_matches("-thunks=").to_string();
348
+                        return Err(ArgsError::InvalidValue {
349
+                            flag: "-thunks".into(),
350
+                            value,
351
+                            expected: "`none`, `safe`, or `all`".into(),
352
+                        });
353
+                    }
354
+                };
355
+            }
356
+            "-dead_strip" => {
357
+                opts.dead_strip = true;
358
+            }
359
+            s if s.starts_with("-icf=") => {
360
+                opts.icf_mode = match s {
361
+                    "-icf=none" => IcfMode::None,
362
+                    "-icf=safe" => IcfMode::Safe,
363
+                    "-icf=all" => IcfMode::All,
364
+                    _ => {
365
+                        let value = s.trim_start_matches("-icf=").to_string();
366
+                        return Err(ArgsError::InvalidValue {
367
+                            flag: "-icf".into(),
368
+                            value,
369
+                            expected: "`safe`, `none`, or `all`".into(),
370
+                        });
371
+                    }
372
+                };
373
+            }
374
+            "-fixup_chains" => {
375
+                opts.fixup_chains = true;
376
+            }
377
+            "-no_fixup_chains" => {
378
+                opts.fixup_chains = false;
379
+            }
380
+            "-map" => {
381
+                opts.map = Some(PathBuf::from(
382
+                    it.next()
383
+                        .ok_or_else(|| ArgsError::MissingValue("-map".into()))?,
384
+                ));
385
+            }
386
+            "-why_live" => {
387
+                opts.why_live.push(
388
+                    it.next()
389
+                        .ok_or_else(|| ArgsError::MissingValue("-why_live".into()))?
390
+                        .clone(),
391
+                );
392
+            }
393
+            "-t" | "-trace" => {
394
+                opts.trace_inputs = true;
395
+            }
396
+            "-v" | "--version" => {
397
+                opts.show_version = true;
398
+            }
399
+            "-h" | "--help" => {
400
+                opts.show_help = true;
401
+            }
60402
             "-x" => {
61403
                 opts.strip_locals = true;
62404
             }
63405
             "-dylib" => {
64406
                 opts.kind = OutputKind::Dylib;
65407
             }
408
+            "-all_load" => {
409
+                opts.all_load = true;
410
+            }
411
+            "-force_load" => {
412
+                opts.force_load_archives
413
+                    .push(PathBuf::from(it.next().ok_or_else(|| {
414
+                        ArgsError::MissingValue("-force_load".into())
415
+                    })?));
416
+            }
417
+            "-j" => {
418
+                let value = it
419
+                    .next()
420
+                    .ok_or_else(|| ArgsError::MissingValue("-j".into()))?;
421
+                opts.jobs = Some(parse_jobs(value)?);
422
+            }
66423
             "--dump" => {
67424
                 opts.dump = Some(PathBuf::from(
68425
                     it.next()
@@ -88,7 +445,7 @@ pub fn parse(argv: &[String]) -> Result<LinkOptions, ArgsError> {
88445
                     })?));
89446
             }
90447
             s if s.starts_with('-') => {
91
-                return Err(ArgsError::UnknownFlag(s.to_string()));
448
+                return Err(unknown_flag(s));
92449
             }
93450
             _ => {
94451
                 opts.inputs.push(PathBuf::from(arg));
@@ -98,6 +455,22 @@ pub fn parse(argv: &[String]) -> Result<LinkOptions, ArgsError> {
98455
     Ok(opts)
99456
 }
100457
 
458
+fn normalize_wl(argv: &[String]) -> Vec<String> {
459
+    let mut out = Vec::with_capacity(argv.len());
460
+    for arg in argv {
461
+        if let Some(rest) = arg.strip_prefix("-Wl,") {
462
+            out.extend(
463
+                rest.split(',')
464
+                    .filter(|piece| !piece.is_empty())
465
+                    .map(ToString::to_string),
466
+            );
467
+        } else {
468
+            out.push(arg.clone());
469
+        }
470
+    }
471
+    out
472
+}
473
+
101474
 #[cfg(test)]
102475
 mod tests {
103476
     use super::*;
@@ -120,12 +493,370 @@ mod tests {
120493
         assert_eq!(opts.kind, OutputKind::Dylib);
121494
     }
122495
 
496
+    #[test]
497
+    fn deferred_output_flags_are_recorded() {
498
+        let opts = parse(&argv(&["-r", "-bundle", "foo.o"])).unwrap();
499
+        assert!(opts.relocatable);
500
+        assert!(opts.bundle);
501
+    }
502
+
123503
     #[test]
124504
     fn strip_locals_flag_is_recorded() {
125505
         let opts = parse(&argv(&["-x", "foo.o"])).unwrap();
126506
         assert!(opts.strip_locals);
127507
     }
128508
 
509
+    #[test]
510
+    fn strip_debug_and_uuid_flags_are_recorded() {
511
+        let opts = parse(&argv(&["-S", "-no_uuid", "foo.o"])).unwrap();
512
+        assert!(opts.strip_debug);
513
+        assert!(!opts.emit_uuid);
514
+    }
515
+
516
+    #[test]
517
+    fn no_loh_flag_is_recorded() {
518
+        let opts = parse(&argv(&["-no_loh", "foo.o"])).unwrap();
519
+        assert!(opts.no_loh);
520
+    }
521
+
522
+    #[test]
523
+    fn dead_strip_icf_and_fixup_chain_flags_are_recorded() {
524
+        let opts = parse(&argv(&[
525
+            "-dead_strip",
526
+            "-thunks=all",
527
+            "-icf=safe",
528
+            "-fixup_chains",
529
+            "-no_fixup_chains",
530
+            "-icf=none",
531
+            "foo.o",
532
+        ]))
533
+        .unwrap();
534
+        assert!(opts.dead_strip);
535
+        assert_eq!(opts.thunks, ThunkMode::All);
536
+        assert_eq!(opts.icf_mode, IcfMode::None);
537
+        assert!(!opts.fixup_chains);
538
+    }
539
+
540
+    #[test]
541
+    fn thunks_flag_rejects_unknown_modes() {
542
+        let err = parse(&argv(&["-thunks=clustered", "main.o"])).unwrap_err();
543
+        assert!(matches!(
544
+            err,
545
+            ArgsError::InvalidValue {
546
+                ref flag,
547
+                ref value,
548
+                ..
549
+            } if flag == "-thunks" && value == "clustered"
550
+        ));
551
+    }
552
+
553
+    #[test]
554
+    fn icf_all_flag_is_recorded() {
555
+        let opts = parse(&argv(&["-icf=all", "foo.o"])).unwrap();
556
+        assert_eq!(opts.icf_mode, IcfMode::All);
557
+    }
558
+
559
+    #[test]
560
+    fn icf_flag_rejects_unknown_modes() {
561
+        let err = parse(&argv(&["-icf=aggressive", "main.o"])).unwrap_err();
562
+        assert!(matches!(
563
+            err,
564
+            ArgsError::InvalidValue {
565
+                ref flag,
566
+                ref value,
567
+                ..
568
+            } if flag == "-icf" && value == "aggressive"
569
+        ));
570
+    }
571
+
572
+    #[test]
573
+    fn l_flag_accepts_separate_value() {
574
+        let opts = parse(&argv(&["-l", "System", "main.o"])).unwrap();
575
+        assert_eq!(opts.library_names, vec!["System".to_string()]);
576
+        assert_eq!(opts.inputs, vec![PathBuf::from("main.o")]);
577
+    }
578
+
579
+    #[test]
580
+    fn l_flag_accepts_joined_value() {
581
+        let opts = parse(&argv(&["-lSystem", "main.o"])).unwrap();
582
+        assert_eq!(opts.library_names, vec!["System".to_string()]);
583
+        assert_eq!(opts.inputs, vec![PathBuf::from("main.o")]);
584
+    }
585
+
586
+    #[test]
587
+    fn search_path_and_syslibroot_flags_are_recorded() {
588
+        let opts = parse(&argv(&["-L", "/tmp/lib", "-syslibroot", "/sdk", "main.o"])).unwrap();
589
+        assert_eq!(opts.search_paths, vec![PathBuf::from("/tmp/lib")]);
590
+        assert_eq!(opts.syslibroot, Some(PathBuf::from("/sdk")));
591
+    }
592
+
593
+    #[test]
594
+    fn framework_flags_are_recorded_in_order() {
595
+        let opts = parse(&argv(&[
596
+            "-framework",
597
+            "Foundation",
598
+            "-weak_framework",
599
+            "Metal",
600
+            "main.o",
601
+        ]))
602
+        .unwrap();
603
+        assert_eq!(
604
+            opts.frameworks,
605
+            vec![
606
+                FrameworkSpec {
607
+                    name: "Foundation".into(),
608
+                    weak: false,
609
+                },
610
+                FrameworkSpec {
611
+                    name: "Metal".into(),
612
+                    weak: true,
613
+                }
614
+            ]
615
+        );
616
+        assert_eq!(opts.inputs, vec![PathBuf::from("main.o")]);
617
+    }
618
+
619
+    #[test]
620
+    fn objc_flag_is_recorded() {
621
+        let opts = parse(&argv(&["-ObjC", "main.o"])).unwrap();
622
+        assert!(opts.objc_force_load);
623
+    }
624
+
625
+    #[test]
626
+    fn platform_version_flag_is_recorded() {
627
+        let opts = parse(&argv(&[
628
+            "-platform_version",
629
+            "macos",
630
+            "13.2.1",
631
+            "14.5",
632
+            "main.o",
633
+        ]))
634
+        .unwrap();
635
+        let platform = opts.platform_version.expect("platform version");
636
+        assert_eq!(platform.minos, (13 << 16) | (2 << 8) | 1);
637
+        assert_eq!(platform.sdk, (14 << 16) | (5 << 8));
638
+    }
639
+
640
+    #[test]
641
+    fn platform_version_rejects_non_macos_platform() {
642
+        let err = parse(&argv(&["-platform_version", "ios", "13.0", "13.0"])).unwrap_err();
643
+        assert!(matches!(
644
+            err,
645
+            ArgsError::InvalidValue {
646
+                ref flag,
647
+                ref value,
648
+                ..
649
+            } if flag == "-platform_version" && value == "ios"
650
+        ));
651
+    }
652
+
653
+    #[test]
654
+    fn platform_version_rejects_bad_version() {
655
+        let err = parse(&argv(&["-platform_version", "macos", "13.bad", "14.0"])).unwrap_err();
656
+        assert!(matches!(
657
+            err,
658
+            ArgsError::InvalidValue {
659
+                ref flag,
660
+                ref value,
661
+                ..
662
+            } if flag == "-platform_version" && value == "13.bad"
663
+        ));
664
+    }
665
+
666
+    #[test]
667
+    fn undefined_flag_records_dynamic_lookup() {
668
+        let opts = parse(&argv(&["-undefined", "dynamic_lookup", "main.o"])).unwrap();
669
+        assert_eq!(opts.undefined_treatment, UndefinedTreatment::DynamicLookup);
670
+    }
671
+
672
+    #[test]
673
+    fn undefined_flag_records_warning_and_suppress() {
674
+        let warning = parse(&argv(&["-undefined", "warning", "main.o"])).unwrap();
675
+        assert_eq!(warning.undefined_treatment, UndefinedTreatment::Warning);
676
+
677
+        let suppress = parse(&argv(&["-undefined", "suppress", "main.o"])).unwrap();
678
+        assert_eq!(suppress.undefined_treatment, UndefinedTreatment::Suppress);
679
+    }
680
+
681
+    #[test]
682
+    fn undefined_flag_rejects_unknown_modes() {
683
+        let err = parse(&argv(&["-undefined", "bogus", "main.o"])).unwrap_err();
684
+        assert!(matches!(
685
+            err,
686
+            ArgsError::InvalidValue {
687
+                ref flag,
688
+                ref value,
689
+                ..
690
+            } if flag == "-undefined" && value == "bogus"
691
+        ));
692
+    }
693
+
694
+    #[test]
695
+    fn dylib_metadata_flags_are_recorded() {
696
+        let opts = parse(&argv(&[
697
+            "-rpath",
698
+            "@loader_path/../lib",
699
+            "-install_name",
700
+            "@rpath/libdemo.dylib",
701
+            "-current_version",
702
+            "2.3.4",
703
+            "-compatibility_version",
704
+            "1.2",
705
+            "main.o",
706
+        ]))
707
+        .unwrap();
708
+        assert_eq!(opts.rpaths, vec!["@loader_path/../lib".to_string()]);
709
+        assert_eq!(opts.install_name.as_deref(), Some("@rpath/libdemo.dylib"));
710
+        assert_eq!(opts.current_version, Some((2 << 16) | (3 << 8) | 4));
711
+        assert_eq!(opts.compatibility_version, Some((1 << 16) | (2 << 8)));
712
+    }
713
+
714
+    #[test]
715
+    fn export_visibility_flags_are_recorded() {
716
+        let opts = parse(&argv(&[
717
+            "-exported_symbols_list",
718
+            "exports.txt",
719
+            "-unexported_symbols_list",
720
+            "hidden.txt",
721
+            "-exported_symbol",
722
+            "_keep",
723
+            "-unexported_symbol",
724
+            "_drop",
725
+            "main.o",
726
+        ]))
727
+        .unwrap();
728
+        assert_eq!(
729
+            opts.exported_symbols_lists,
730
+            vec![PathBuf::from("exports.txt")]
731
+        );
732
+        assert_eq!(
733
+            opts.unexported_symbols_lists,
734
+            vec![PathBuf::from("hidden.txt")]
735
+        );
736
+        assert_eq!(opts.exported_symbols, vec!["_keep".to_string()]);
737
+        assert_eq!(opts.unexported_symbols, vec!["_drop".to_string()]);
738
+    }
739
+
740
+    #[test]
741
+    fn map_flag_is_recorded() {
742
+        let opts = parse(&argv(&["-map", "link.map", "main.o"])).unwrap();
743
+        assert_eq!(opts.map.as_deref(), Some(std::path::Path::new("link.map")));
744
+    }
745
+
746
+    #[test]
747
+    fn wl_normalizes_map_like_direct_flag() {
748
+        let opts = parse(&argv(&["-Wl,-map,link.map", "main.o"])).unwrap();
749
+        assert_eq!(opts.map.as_deref(), Some(std::path::Path::new("link.map")));
750
+        assert_eq!(opts.inputs, vec![PathBuf::from("main.o")]);
751
+    }
752
+
753
+    #[test]
754
+    fn trace_flags_are_recorded() {
755
+        let opts = parse(&argv(&["-trace", "main.o"])).unwrap();
756
+        assert!(opts.trace_inputs);
757
+        let opts = parse(&argv(&["-t", "main.o"])).unwrap();
758
+        assert!(opts.trace_inputs);
759
+    }
760
+
761
+    #[test]
762
+    fn why_live_flag_accumulates_symbols() {
763
+        let opts = parse(&argv(&[
764
+            "-why_live",
765
+            "_helper",
766
+            "-why_live",
767
+            "_leaf",
768
+            "main.o",
769
+        ]))
770
+        .unwrap();
771
+        assert_eq!(
772
+            opts.why_live,
773
+            vec!["_helper".to_string(), "_leaf".to_string()]
774
+        );
775
+    }
776
+
777
+    #[test]
778
+    fn help_and_version_flags_are_recorded() {
779
+        let opts = parse(&argv(&["--help"])).unwrap();
780
+        assert!(opts.show_help);
781
+        let opts = parse(&argv(&["-h"])).unwrap();
782
+        assert!(opts.show_help);
783
+        let opts = parse(&argv(&["--version"])).unwrap();
784
+        assert!(opts.show_version);
785
+        let opts = parse(&argv(&["-v"])).unwrap();
786
+        assert!(opts.show_version);
787
+    }
788
+
789
+    #[test]
790
+    fn all_load_flag_is_recorded() {
791
+        let opts = parse(&argv(&["-all_load", "libfoo.a"])).unwrap();
792
+        assert!(opts.all_load);
793
+        assert_eq!(opts.inputs, vec![PathBuf::from("libfoo.a")]);
794
+    }
795
+
796
+    #[test]
797
+    fn force_load_flag_accumulates_archive_paths() {
798
+        let opts = parse(&argv(&[
799
+            "-force_load",
800
+            "liba.a",
801
+            "-force_load",
802
+            "libb.a",
803
+            "main.o",
804
+        ]))
805
+        .unwrap();
806
+        assert_eq!(
807
+            opts.force_load_archives,
808
+            vec![PathBuf::from("liba.a"), PathBuf::from("libb.a")]
809
+        );
810
+        assert_eq!(opts.inputs, vec![PathBuf::from("main.o")]);
811
+    }
812
+
813
+    #[test]
814
+    fn jobs_flag_records_positive_worker_limit() {
815
+        let opts = parse(&argv(&["-j", "1", "main.o"])).unwrap();
816
+        assert_eq!(opts.jobs, Some(1));
817
+        assert_eq!(opts.inputs, vec![PathBuf::from("main.o")]);
818
+    }
819
+
820
+    #[test]
821
+    fn jobs_flag_rejects_zero_or_non_numeric_values() {
822
+        let err = parse(&argv(&["-j", "0"])).unwrap_err();
823
+        assert!(matches!(
824
+            err,
825
+            ArgsError::InvalidValue {
826
+                ref flag,
827
+                ref value,
828
+                ..
829
+            } if flag == "-j" && value == "0"
830
+        ));
831
+        let err = parse(&argv(&["-j", "many"])).unwrap_err();
832
+        assert!(matches!(
833
+            err,
834
+            ArgsError::InvalidValue {
835
+                ref flag,
836
+                ref value,
837
+                ..
838
+            } if flag == "-j" && value == "many"
839
+        ));
840
+    }
841
+
842
+    #[test]
843
+    fn missing_jobs_value_errors() {
844
+        let err = parse(&argv(&["-j"])).unwrap_err();
845
+        assert!(matches!(err, ArgsError::MissingValue(ref f) if f == "-j"));
846
+    }
847
+
848
+    #[test]
849
+    fn missing_force_load_value_errors() {
850
+        let err = parse(&argv(&["-force_load"])).unwrap_err();
851
+        assert!(matches!(err, ArgsError::MissingValue(ref f) if f == "-force_load"));
852
+    }
853
+
854
+    #[test]
855
+    fn missing_l_value_errors() {
856
+        let err = parse(&argv(&["-l"])).unwrap_err();
857
+        assert!(matches!(err, ArgsError::MissingValue(ref f) if f == "-l"));
858
+    }
859
+
129860
     #[test]
130861
     fn missing_output_value_errors() {
131862
         let err = parse(&argv(&["-o"])).unwrap_err();
@@ -135,7 +866,25 @@ mod tests {
135866
     #[test]
136867
     fn unknown_flag_errors() {
137868
         let err = parse(&argv(&["-nonsense"])).unwrap_err();
138
-        assert!(matches!(err, ArgsError::UnknownFlag(ref f) if f == "-nonsense"));
869
+        assert!(matches!(
870
+            err,
871
+            ArgsError::UnknownFlag {
872
+                ref flag,
873
+                suggestion: None
874
+            } if flag == "-nonsense"
875
+        ));
876
+    }
877
+
878
+    #[test]
879
+    fn unknown_flag_suggests_nearby_match() {
880
+        let err = parse(&argv(&["-all_lod"])).unwrap_err();
881
+        assert!(matches!(
882
+            err,
883
+            ArgsError::UnknownFlag {
884
+                ref flag,
885
+                suggestion: Some(ref suggestion)
886
+            } if flag == "-all_lod" && suggestion == "-all_load"
887
+        ));
139888
     }
140889
 
141890
     #[test]
src/atom.rsmodified
177 lines changed — click to load
@@ -297,9 +297,10 @@ pub fn atomize_object(
297297
         );
298298
     }
299299
 
300
-    // Post-pass: wire `parent_of` for every `__compact_unwind` atom to the
301
-    // function atom that its `function_start` reloc references.
300
+    // Post-pass: wire metadata atoms to the function atoms whose lifetime
301
+    // they track, so dead-strip can prune unwind surfaces precisely.
302302
     link_unwind_parents(input_id, obj, table, &out);
303
+    link_eh_frame_parents(input_id, obj, table, &out);
303304
 
304305
     out
305306
 }
@@ -486,6 +487,11 @@ fn atomize_regular_section(
486487
         return;
487488
     }
488489
 
490
+    if atom_section == AtomSection::EhFrame {
491
+        atomize_eh_frame(input_id, section_idx, sect, atom_section, table, out);
492
+        return;
493
+    }
494
+
489495
     // With subsections_via_symbols and at least one split point, walk the
490496
     // sorted symbols and emit one atom per non-alt_entry boundary.
491497
     if syms.is_empty() {
@@ -768,6 +774,154 @@ fn atomize_compact_unwind(
768774
     }
769775
 }
770776
 
777
+/// Split `__eh_frame` into DWARF CFI records so dead-strip can retain only
778
+/// the live FDEs and their shared CIEs.
779
+fn atomize_eh_frame(
780
+    input_id: InputId,
781
+    section_idx: u8,
782
+    sect: &InputSection,
783
+    atom_section: AtomSection,
784
+    table: &mut AtomTable,
785
+    out: &mut ObjectAtomization,
786
+) {
787
+    let mut offset = 0usize;
788
+    while offset < sect.data.len() {
789
+        let Some(size) = eh_frame_record_size(&sect.data, offset) else {
790
+            let atom = build_section_atom(input_id, section_idx, sect, atom_section);
791
+            let id = table.push(atom);
792
+            out.atoms.push(id);
793
+            return;
794
+        };
795
+
796
+        let end = (offset + size).min(sect.data.len());
797
+        let atom = Atom {
798
+            id: AtomId(0),
799
+            origin: input_id,
800
+            input_section: section_idx,
801
+            section: atom_section,
802
+            input_offset: offset as u32,
803
+            size: (end - offset) as u32,
804
+            align_pow2: (sect.align_pow2 as u8).min(2),
805
+            owner: None,
806
+            alt_entries: Vec::new(),
807
+            data: sect.data[offset..end].to_vec(),
808
+            flags: AtomFlags::default(),
809
+            parent_of: None,
810
+        };
811
+        let id = table.push(atom);
812
+        out.atoms.push(id);
813
+        offset = end;
814
+    }
815
+}
816
+
817
+fn eh_frame_record_size(data: &[u8], offset: usize) -> Option<usize> {
818
+    let length_end = offset.checked_add(4)?;
819
+    let length_bytes: [u8; 4] = data.get(offset..length_end)?.try_into().ok()?;
820
+    let length = u32::from_le_bytes(length_bytes);
821
+    if length == 0 {
822
+        return Some(4);
823
+    }
824
+    if length == u32::MAX {
825
+        return None;
826
+    }
827
+    let size = 4usize.checked_add(length as usize)?;
828
+    (offset + size <= data.len()).then_some(size)
829
+}
830
+
831
+fn eh_frame_cie_pointer(atom: &Atom) -> Option<u32> {
832
+    (atom.section == AtomSection::EhFrame && atom.data.len() >= 8).then(|| {
833
+        let mut buf = [0u8; 4];
834
+        buf.copy_from_slice(&atom.data[4..8]);
835
+        u32::from_le_bytes(buf)
836
+    })
837
+}
838
+
839
+fn resolve_function_parent(
840
+    obj: &ObjectFile,
841
+    atom: &Atom,
842
+    reloc: crate::reloc::Reloc,
843
+    atom_index: &HashMap<(u8, u32), AtomId>,
844
+    field_offset: usize,
845
+) -> Option<AtomId> {
846
+    match reloc.referent {
847
+        Referent::Section(sect_idx) => {
848
+            let end = field_offset.checked_add(8)?;
849
+            let mut buf = [0u8; 8];
850
+            buf.copy_from_slice(atom.data.get(field_offset..end)?);
851
+            let target_offset = u64::from_le_bytes(buf) as u32;
852
+            atom_index.get(&(sect_idx, target_offset)).copied()
853
+        }
854
+        Referent::Symbol(sym_idx) => {
855
+            let input_sym = obj.symbols.get(sym_idx as usize)?;
856
+            (input_sym.kind() == SymKind::Sect)
857
+                .then(|| {
858
+                    let target_offset = input_sym.value().saturating_sub(
859
+                        obj.sections
860
+                            .get(input_sym.sect_idx().saturating_sub(1) as usize)
861
+                            .map(|section| section.addr)
862
+                            .unwrap_or(0),
863
+                    ) as u32;
864
+                    atom_index
865
+                        .get(&(input_sym.sect_idx(), target_offset))
866
+                        .copied()
867
+                })
868
+                .flatten()
869
+        }
870
+    }
871
+}
872
+
873
+fn link_eh_frame_parents(
874
+    input_id: InputId,
875
+    obj: &ObjectFile,
876
+    table: &mut AtomTable,
877
+    out: &ObjectAtomization,
878
+) {
879
+    let Some((eh_idx_zero, eh_sect)) = obj
880
+        .sections
881
+        .iter()
882
+        .enumerate()
883
+        .find(|(_, s)| s.kind == SectionKind::EhFrame)
884
+    else {
885
+        return;
886
+    };
887
+    let eh_idx_one = (eh_idx_zero + 1) as u8;
888
+
889
+    let raws = match parse_raw_relocs(&eh_sect.raw_relocs, 0, eh_sect.nreloc) {
890
+        Ok(r) => r,
891
+        Err(_) => return,
892
+    };
893
+    let fused = match parse_relocs(&raws) {
894
+        Ok(f) => f,
895
+        Err(_) => return,
896
+    };
897
+
898
+    let mut atom_index: HashMap<(u8, u32), AtomId> = HashMap::new();
899
+    for id in &out.atoms {
900
+        let a = table.get(*id);
901
+        atom_index.insert((a.input_section, a.input_offset), *id);
902
+    }
903
+
904
+    for id in &out.atoms {
905
+        let atom = table.get(*id);
906
+        if atom.input_section != eh_idx_one {
907
+            continue;
908
+        }
909
+        let Some(cie_pointer) = eh_frame_cie_pointer(atom) else {
910
+            continue;
911
+        };
912
+        if cie_pointer == 0 {
913
+            continue;
914
+        }
915
+        let Some(reloc) = fused.iter().find(|r| r.offset == atom.input_offset + 8) else {
916
+            continue;
917
+        };
918
+        if let Some(parent_id) = resolve_function_parent(obj, atom, *reloc, &atom_index, 8) {
919
+            table.get_mut(*id).parent_of = Some(parent_id);
920
+        }
921
+    }
922
+    let _ = input_id;
923
+}
924
+
771925
 fn atomize_zerofill(
772926
     input_id: InputId,
773927
     section_idx: u8,
src/diag.rsmodified
15 lines changed — click to load
@@ -16,3 +16,15 @@ pub fn error_verbatim(msg: &str) {
1616
     let mut h = stderr.lock();
1717
     let _ = writeln!(h, "{msg}");
1818
 }
19
+
20
+pub fn warning(msg: &str) {
21
+    let stderr = std::io::stderr();
22
+    let mut h = stderr.lock();
23
+    let _ = writeln!(h, "afs-ld: warning: {msg}");
24
+}
25
+
26
+pub fn warning_verbatim(msg: &str) {
27
+    let stderr = std::io::stderr();
28
+    let mut h = stderr.lock();
29
+    let _ = writeln!(h, "{msg}");
30
+}
src/icf.rsadded
506 lines changed — click to load
@@ -0,0 +1,506 @@
1
+use std::collections::{HashMap, HashSet};
2
+use std::fmt;
3
+
4
+use crate::atom::{Atom, AtomFlags, AtomSection, AtomTable};
5
+use crate::layout::LayoutInput;
6
+use crate::reloc::{parse_raw_relocs, parse_relocs, Referent, Reloc, RelocKind, RelocLength};
7
+use crate::resolve::{AtomId, InputId, Symbol, SymbolId, SymbolTable};
8
+
9
+#[derive(Debug, Clone, Default)]
10
+pub struct IcfPlan {
11
+    kept_atoms: HashSet<AtomId>,
12
+    redirects: HashMap<AtomId, AtomId>,
13
+}
14
+
15
+#[derive(Debug, Clone, PartialEq, Eq)]
16
+pub struct FoldedSymbol {
17
+    pub name: String,
18
+    pub winner: String,
19
+    pub file_index: usize,
20
+}
21
+
22
+impl IcfPlan {
23
+    pub fn kept_atoms(&self) -> &HashSet<AtomId> {
24
+        &self.kept_atoms
25
+    }
26
+
27
+    pub fn redirects(&self) -> &HashMap<AtomId, AtomId> {
28
+        &self.redirects
29
+    }
30
+
31
+    pub fn folded_symbols(
32
+        &self,
33
+        atom_table: &AtomTable,
34
+        sym_table: &SymbolTable,
35
+        layout_inputs: &[LayoutInput<'_>],
36
+    ) -> Vec<FoldedSymbol> {
37
+        let file_index_by_input: HashMap<InputId, usize> = layout_inputs
38
+            .iter()
39
+            .enumerate()
40
+            .map(|(idx, input)| (input.id, idx + 1))
41
+            .collect();
42
+        let mut out = Vec::new();
43
+        for (&loser, &winner) in &self.redirects {
44
+            let winner = canonical_atom(winner, &self.redirects);
45
+            let Some(winner_symbol) = representative_symbol(atom_table.get(winner)) else {
46
+                continue;
47
+            };
48
+            let winner_name = symbol_name(sym_table, winner_symbol);
49
+            for symbol_id in atom_symbols(atom_table.get(loser)) {
50
+                let Symbol::Defined { origin, .. } = sym_table.get(symbol_id) else {
51
+                    continue;
52
+                };
53
+                let name = symbol_name(sym_table, symbol_id);
54
+                if name == winner_name {
55
+                    continue;
56
+                }
57
+                out.push(FoldedSymbol {
58
+                    name,
59
+                    winner: winner_name.clone(),
60
+                    file_index: file_index_by_input.get(origin).copied().unwrap_or(0),
61
+                });
62
+            }
63
+        }
64
+        out.sort_by(|lhs, rhs| {
65
+            lhs.name
66
+                .cmp(&rhs.name)
67
+                .then_with(|| lhs.winner.cmp(&rhs.winner))
68
+                .then_with(|| lhs.file_index.cmp(&rhs.file_index))
69
+        });
70
+        out.dedup_by(|lhs, rhs| {
71
+            lhs.name == rhs.name && lhs.winner == rhs.winner && lhs.file_index == rhs.file_index
72
+        });
73
+        out
74
+    }
75
+}
76
+
77
+#[derive(Debug, Clone)]
78
+pub struct IcfError(String);
79
+
80
+impl fmt::Display for IcfError {
81
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
82
+        write!(f, "ICF error: {}", self.0)
83
+    }
84
+}
85
+
86
+impl std::error::Error for IcfError {}
87
+
88
+pub fn fold_safe(
89
+    layout_inputs: &[LayoutInput<'_>],
90
+    atom_table: &mut AtomTable,
91
+    sym_table: &mut SymbolTable,
92
+    live_atoms: Option<&HashSet<AtomId>>,
93
+) -> Result<IcfPlan, IcfError> {
94
+    let resolved_by_name = resolved_symbol_map(sym_table);
95
+    let reloc_cache = reloc_cache(layout_inputs)?;
96
+
97
+    mark_address_taken(
98
+        layout_inputs,
99
+        atom_table,
100
+        sym_table,
101
+        &resolved_by_name,
102
+        &reloc_cache,
103
+    );
104
+
105
+    let mut kept_atoms = live_atoms.cloned().unwrap_or_else(|| {
106
+        atom_table
107
+            .iter()
108
+            .map(|(atom_id, _)| atom_id)
109
+            .collect::<HashSet<_>>()
110
+    });
111
+    let mut redirects = HashMap::new();
112
+
113
+    let order_by_input: HashMap<InputId, (usize, Option<u32>)> = layout_inputs
114
+        .iter()
115
+        .map(|input| (input.id, (input.load_order, input.archive_member_offset)))
116
+        .collect();
117
+
118
+    loop {
119
+        let mut buckets: HashMap<FoldKey, Vec<AtomId>> = HashMap::new();
120
+        for (atom_id, atom) in atom_table.iter() {
121
+            if !kept_atoms.contains(&atom_id) {
122
+                continue;
123
+            }
124
+            if !is_foldable_atom(atom, sym_table) {
125
+                continue;
126
+            }
127
+            let relocs = reloc_cache
128
+                .get(&(atom.origin, atom.input_section))
129
+                .map(Vec::as_slice)
130
+                .unwrap_or(&[]);
131
+            let Some(reloc_sig) = reloc_signature_for_atom(
132
+                atom,
133
+                relocs,
134
+                layout_inputs,
135
+                sym_table,
136
+                &resolved_by_name,
137
+                &redirects,
138
+            ) else {
139
+                continue;
140
+            };
141
+            buckets
142
+                .entry(FoldKey::from_atom(atom, reloc_sig))
143
+                .or_default()
144
+                .push(atom_id);
145
+        }
146
+
147
+        let mut changed = false;
148
+        for atom_ids in buckets.into_values() {
149
+            if atom_ids.len() < 2 {
150
+                continue;
151
+            }
152
+            let winner = *atom_ids
153
+                .iter()
154
+                .min_by_key(|atom_id| {
155
+                    fold_order_key(atom_table.get(**atom_id), &order_by_input, **atom_id)
156
+                })
157
+                .expect("bucket is non-empty");
158
+            for loser in atom_ids {
159
+                if loser == winner {
160
+                    continue;
161
+                }
162
+                redirects.insert(loser, winner);
163
+                kept_atoms.remove(&loser);
164
+                rebind_folded_symbols(sym_table, atom_table.get(loser), winner);
165
+                changed = true;
166
+            }
167
+        }
168
+
169
+        if !changed {
170
+            break;
171
+        }
172
+    }
173
+
174
+    rebind_symbols_to_canonical_winners(sym_table, &redirects);
175
+
176
+    Ok(IcfPlan {
177
+        kept_atoms,
178
+        redirects,
179
+    })
180
+}
181
+
182
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
183
+struct FoldKey {
184
+    section: AtomSection,
185
+    size: u32,
186
+    align_pow2: u8,
187
+    flags: u32,
188
+    data: Vec<u8>,
189
+    relocs: Vec<FoldReloc>,
190
+}
191
+
192
+impl FoldKey {
193
+    fn from_atom(atom: &Atom, relocs: Vec<FoldReloc>) -> Self {
194
+        Self {
195
+            section: atom.section,
196
+            size: atom.size,
197
+            align_pow2: atom.align_pow2,
198
+            flags: atom.flags.bits() & !AtomFlags::ADDRESS_TAKEN,
199
+            data: atom.data.clone(),
200
+            relocs,
201
+        }
202
+    }
203
+}
204
+
205
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
206
+struct FoldReloc {
207
+    offset: u32,
208
+    kind: RelocKind,
209
+    length: RelocLength,
210
+    pcrel: bool,
211
+    referent: FoldReferent,
212
+    addend: i64,
213
+    subtrahend: Option<FoldReferent>,
214
+}
215
+
216
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
217
+enum FoldReferent {
218
+    Atom(AtomId),
219
+    Symbol(SymbolId),
220
+    Section(u8),
221
+}
222
+
223
+fn fold_order_key(
224
+    atom: &Atom,
225
+    order_by_input: &HashMap<InputId, (usize, Option<u32>)>,
226
+    atom_id: AtomId,
227
+) -> (usize, u32, u32, u32) {
228
+    let (load_order, archive_member_offset) = order_by_input
229
+        .get(&atom.origin)
230
+        .copied()
231
+        .unwrap_or((usize::MAX, None));
232
+    (
233
+        load_order,
234
+        archive_member_offset.unwrap_or(0),
235
+        atom.input_offset,
236
+        atom_id.0,
237
+    )
238
+}
239
+
240
+fn is_foldable_atom(atom: &Atom, sym_table: &SymbolTable) -> bool {
241
+    if !matches!(
242
+        atom.section,
243
+        AtomSection::Text
244
+            | AtomSection::ConstData
245
+            | AtomSection::CStringLiterals
246
+            | AtomSection::Literal16
247
+    ) {
248
+        return false;
249
+    }
250
+    if atom.flags.has(AtomFlags::NO_DEAD_STRIP) || atom.flags.has(AtomFlags::ADDRESS_TAKEN) {
251
+        return false;
252
+    }
253
+    !matches!(
254
+        atom.owner.map(|owner| sym_table.get(owner)),
255
+        Some(Symbol::Defined {
256
+            private_extern: false,
257
+            ..
258
+        })
259
+    )
260
+}
261
+
262
+fn rebind_folded_symbols(sym_table: &mut SymbolTable, atom: &Atom, winner: AtomId) {
263
+    if let Some(owner) = atom.owner {
264
+        let value = match sym_table.get(owner) {
265
+            Symbol::Defined { value, .. } => *value,
266
+            _ => 0,
267
+        };
268
+        sym_table.bind_atom(owner, winner, value);
269
+    }
270
+    for alt in &atom.alt_entries {
271
+        let value = match sym_table.get(alt.symbol) {
272
+            Symbol::Defined { value, .. } => *value,
273
+            _ => alt.offset_within_atom as u64,
274
+        };
275
+        sym_table.bind_atom(alt.symbol, winner, value);
276
+    }
277
+}
278
+
279
+fn rebind_symbols_to_canonical_winners(
280
+    sym_table: &mut SymbolTable,
281
+    redirects: &HashMap<AtomId, AtomId>,
282
+) {
283
+    let updates: Vec<(SymbolId, AtomId, u64)> = sym_table
284
+        .iter()
285
+        .filter_map(|(symbol_id, symbol)| match symbol {
286
+            Symbol::Defined { atom, value, .. } if atom.0 != 0 => {
287
+                let canonical = canonical_atom(*atom, redirects);
288
+                (canonical != *atom).then_some((symbol_id, canonical, *value))
289
+            }
290
+            _ => None,
291
+        })
292
+        .collect();
293
+    for (symbol_id, atom, value) in updates {
294
+        sym_table.bind_atom(symbol_id, atom, value);
295
+    }
296
+}
297
+
298
+fn resolved_symbol_map(sym_table: &SymbolTable) -> HashMap<String, SymbolId> {
299
+    let mut out = HashMap::new();
300
+    for (symbol_id, symbol) in sym_table.iter() {
301
+        out.insert(
302
+            sym_table.interner.resolve(symbol.name()).to_string(),
303
+            symbol_id,
304
+        );
305
+    }
306
+    out
307
+}
308
+
309
+fn reloc_cache(
310
+    layout_inputs: &[LayoutInput<'_>],
311
+) -> Result<HashMap<(InputId, u8), Vec<Reloc>>, IcfError> {
312
+    let mut out = HashMap::new();
313
+    for input in layout_inputs {
314
+        for (section_idx_zero, section) in input.object.sections.iter().enumerate() {
315
+            if section.raw_relocs.is_empty() {
316
+                continue;
317
+            }
318
+            let raws = parse_raw_relocs(&section.raw_relocs, 0, section.nreloc)
319
+                .map_err(|err| IcfError(format!("{}: {err}", input.object.path.display())))?;
320
+            let relocs = parse_relocs(&raws)
321
+                .map_err(|err| IcfError(format!("{}: {err}", input.object.path.display())))?;
322
+            out.insert((input.id, (section_idx_zero + 1) as u8), relocs);
323
+        }
324
+    }
325
+    Ok(out)
326
+}
327
+
328
+#[allow(clippy::too_many_arguments)]
329
+fn mark_address_taken(
330
+    layout_inputs: &[LayoutInput<'_>],
331
+    atom_table: &mut AtomTable,
332
+    sym_table: &SymbolTable,
333
+    resolved_by_name: &HashMap<String, SymbolId>,
334
+    reloc_cache: &HashMap<(InputId, u8), Vec<Reloc>>,
335
+) {
336
+    for input in layout_inputs {
337
+        for (section_idx_zero, _section) in input.object.sections.iter().enumerate() {
338
+            let input_section = (section_idx_zero + 1) as u8;
339
+            let Some(relocs) = reloc_cache.get(&(input.id, input_section)) else {
340
+                continue;
341
+            };
342
+            for reloc in relocs {
343
+                if !marks_address_taken(reloc.kind) {
344
+                    continue;
345
+                }
346
+                for target_atom in target_atoms_for_reloc(
347
+                    input.object,
348
+                    reloc.referent,
349
+                    sym_table,
350
+                    resolved_by_name,
351
+                ) {
352
+                    atom_table
353
+                        .get_mut(target_atom)
354
+                        .flags
355
+                        .set(AtomFlags::ADDRESS_TAKEN);
356
+                }
357
+            }
358
+        }
359
+    }
360
+}
361
+
362
+fn marks_address_taken(kind: RelocKind) -> bool {
363
+    matches!(
364
+        kind,
365
+        RelocKind::Unsigned
366
+            | RelocKind::Page21
367
+            | RelocKind::PageOff12
368
+            | RelocKind::PointerToGot
369
+            | RelocKind::GotLoadPage21
370
+            | RelocKind::GotLoadPageOff12
371
+            | RelocKind::TlvpLoadPage21
372
+            | RelocKind::TlvpLoadPageOff12
373
+    )
374
+}
375
+
376
+fn relocs_for_atom<'a>(relocs: &'a [Reloc], atom: &Atom) -> impl Iterator<Item = Reloc> + 'a {
377
+    let start = atom.input_offset;
378
+    let end = atom.input_offset.saturating_add(atom.size);
379
+    relocs.iter().copied().filter(move |reloc| {
380
+        let reloc_end = reloc
381
+            .offset
382
+            .saturating_add(reloc.length.byte_width() as u32);
383
+        reloc.offset >= start && reloc_end <= end
384
+    })
385
+}
386
+
387
+fn reloc_signature_for_atom(
388
+    atom: &Atom,
389
+    relocs: &[Reloc],
390
+    layout_inputs: &[LayoutInput<'_>],
391
+    sym_table: &SymbolTable,
392
+    resolved_by_name: &HashMap<String, SymbolId>,
393
+    redirects: &HashMap<AtomId, AtomId>,
394
+) -> Option<Vec<FoldReloc>> {
395
+    let objects_by_input: HashMap<InputId, &crate::input::ObjectFile> = layout_inputs
396
+        .iter()
397
+        .map(|input| (input.id, input.object))
398
+        .collect();
399
+    let object = objects_by_input.get(&atom.origin)?;
400
+    relocs_for_atom(relocs, atom)
401
+        .map(|reloc| {
402
+            Some(FoldReloc {
403
+                offset: reloc.offset.saturating_sub(atom.input_offset),
404
+                kind: reloc.kind,
405
+                length: reloc.length,
406
+                pcrel: reloc.pcrel,
407
+                referent: normalize_referent(
408
+                    object,
409
+                    reloc.referent,
410
+                    sym_table,
411
+                    resolved_by_name,
412
+                    redirects,
413
+                )?,
414
+                addend: reloc.addend,
415
+                subtrahend: match reloc.subtrahend {
416
+                    Some(referent) => Some(normalize_referent(
417
+                        object,
418
+                        referent,
419
+                        sym_table,
420
+                        resolved_by_name,
421
+                        redirects,
422
+                    )?),
423
+                    None => None,
424
+                },
425
+            })
426
+        })
427
+        .collect()
428
+}
429
+
430
+fn normalize_referent(
431
+    object: &crate::input::ObjectFile,
432
+    referent: Referent,
433
+    sym_table: &SymbolTable,
434
+    resolved_by_name: &HashMap<String, SymbolId>,
435
+    redirects: &HashMap<AtomId, AtomId>,
436
+) -> Option<FoldReferent> {
437
+    match referent {
438
+        Referent::Symbol(sym_idx) => {
439
+            let input_sym = object.symbols.get(sym_idx as usize)?;
440
+            let name = object.symbol_name(input_sym).ok()?;
441
+            let &symbol_id = resolved_by_name.get(name)?;
442
+            match sym_table.get(symbol_id) {
443
+                Symbol::Defined { atom, .. } if atom.0 != 0 => {
444
+                    Some(FoldReferent::Atom(canonical_atom(*atom, redirects)))
445
+                }
446
+                _ => Some(FoldReferent::Symbol(symbol_id)),
447
+            }
448
+        }
449
+        Referent::Section(section) => Some(FoldReferent::Section(section)),
450
+    }
451
+}
452
+
453
+fn canonical_atom(atom_id: AtomId, redirects: &HashMap<AtomId, AtomId>) -> AtomId {
454
+    let mut current = atom_id;
455
+    while let Some(&next) = redirects.get(&current) {
456
+        if next == current {
457
+            break;
458
+        }
459
+        current = next;
460
+    }
461
+    current
462
+}
463
+
464
+fn representative_symbol(atom: &Atom) -> Option<SymbolId> {
465
+    atom.owner
466
+        .or_else(|| atom.alt_entries.first().map(|alt| alt.symbol))
467
+}
468
+
469
+fn atom_symbols(atom: &Atom) -> impl Iterator<Item = SymbolId> + '_ {
470
+    atom.owner
471
+        .into_iter()
472
+        .chain(atom.alt_entries.iter().map(|alt| alt.symbol))
473
+}
474
+
475
+fn symbol_name(sym_table: &SymbolTable, symbol_id: SymbolId) -> String {
476
+    sym_table
477
+        .interner
478
+        .resolve(sym_table.get(symbol_id).name())
479
+        .to_string()
480
+}
481
+
482
+fn target_atoms_for_reloc(
483
+    object: &crate::input::ObjectFile,
484
+    referent: Referent,
485
+    sym_table: &SymbolTable,
486
+    resolved_by_name: &HashMap<String, SymbolId>,
487
+) -> Vec<AtomId> {
488
+    match referent {
489
+        Referent::Symbol(sym_idx) => {
490
+            let Some(input_sym) = object.symbols.get(sym_idx as usize) else {
491
+                return Vec::new();
492
+            };
493
+            let Some(name) = object.symbol_name(input_sym).ok() else {
494
+                return Vec::new();
495
+            };
496
+            let Some(&symbol_id) = resolved_by_name.get(name) else {
497
+                return Vec::new();
498
+            };
499
+            match sym_table.get(symbol_id) {
500
+                Symbol::Defined { atom, .. } if atom.0 != 0 => vec![*atom],
501
+                _ => Vec::new(),
502
+            }
503
+        }
504
+        Referent::Section(_) => Vec::new(),
505
+    }
506
+}
src/input.rsmodified
192 lines changed — click to load
@@ -7,6 +7,7 @@
77
 
88
 use std::path::PathBuf;
99
 
10
+use crate::loh::{parse_loh_blob, LohEntry};
1011
 use crate::macho::constants::LC_DATA_IN_CODE;
1112
 use crate::macho::reader::{
1213
     parse_commands, parse_header, DysymtabCmd, LinkEditDataCmd, LoadCommand, MachHeader64,
@@ -28,6 +29,7 @@ pub struct ObjectFile {
2829
     pub strings: StringTable,
2930
     pub symtab: Option<SymtabCmd>,
3031
     pub dysymtab: Option<DysymtabCmd>,
32
+    pub loh: Vec<LohEntry>,
3133
     pub data_in_code: Vec<DataInCodeEntry>,
3234
 }
3335
 
@@ -90,6 +92,7 @@ impl ObjectFile {
9092
             ),
9193
             None => (Vec::new(), StringTable::from_bytes(Vec::new())),
9294
         };
95
+        let loh = parse_loh(&commands, file_bytes)?;
9396
         let data_in_code = parse_data_in_code(&commands, file_bytes)?;
9497
 
9598
         Ok(ObjectFile {
@@ -101,6 +104,7 @@ impl ObjectFile {
101104
             strings,
102105
             symtab,
103106
             dysymtab,
107
+            loh,
104108
             data_in_code,
105109
         })
106110
     }
@@ -132,6 +136,32 @@ impl ObjectFile {
132136
     }
133137
 }
134138
 
139
+fn parse_loh(commands: &[LoadCommand], file_bytes: &[u8]) -> Result<Vec<LohEntry>, ReadError> {
140
+    let mut out = Vec::new();
141
+    for command in commands {
142
+        let LoadCommand::LinkerOptimizationHint(linkedit) = command else {
143
+            continue;
144
+        };
145
+        let start = linkedit.dataoff as usize;
146
+        let end = start
147
+            .checked_add(linkedit.datasize as usize)
148
+            .ok_or(ReadError::Truncated {
149
+                need: usize::MAX,
150
+                have: file_bytes.len(),
151
+                context: "LC_LINKER_OPTIMIZATION_HINT payload (offset + size overflows)",
152
+            })?;
153
+        if end > file_bytes.len() {
154
+            return Err(ReadError::Truncated {
155
+                need: end,
156
+                have: file_bytes.len(),
157
+                context: "LC_LINKER_OPTIMIZATION_HINT payload",
158
+            });
159
+        }
160
+        out.extend(parse_loh_blob(&file_bytes[start..end])?);
161
+    }
162
+    Ok(out)
163
+}
164
+
135165
 fn parse_data_in_code(
136166
     commands: &[LoadCommand],
137167
     file_bytes: &[u8],
@@ -182,6 +212,7 @@ pub fn header_and_cmds_end(header: &MachHeader64) -> usize {
182212
 #[cfg(test)]
183213
 mod tests {
184214
     use super::*;
215
+    use crate::loh::{write_loh_blob, LOH_ARM64_ADRP_ADD};
185216
     use crate::macho::constants::*;
186217
     use crate::macho::reader::{
187218
         write_commands, write_header, LinkEditDataCmd, LoadCommand, Section64Header, Segment64,
@@ -389,6 +420,99 @@ mod tests {
389420
         image
390421
     }
391422
 
423
+    fn synth_image_with_loh() -> Vec<u8> {
424
+        let text_sect = Section64Header {
425
+            sectname: name16("__text"),
426
+            segname: name16("__TEXT"),
427
+            addr: 0,
428
+            size: 8,
429
+            offset: 0,
430
+            align: 2,
431
+            reloff: 0,
432
+            nreloc: 0,
433
+            flags: S_ATTR_PURE_INSTRUCTIONS | S_ATTR_SOME_INSTRUCTIONS,
434
+            reserved1: 0,
435
+            reserved2: 0,
436
+            reserved3: 0,
437
+        };
438
+        let seg = Segment64 {
439
+            segname: name16(""),
440
+            vmaddr: 0,
441
+            vmsize: 8,
442
+            fileoff: 0,
443
+            filesize: 8,
444
+            maxprot: 7,
445
+            initprot: 7,
446
+            flags: 0,
447
+            sections: vec![text_sect],
448
+        };
449
+        let strtab = b"\0_main\0";
450
+        let nsyms = 1u32;
451
+        let sym = RawNlist {
452
+            strx: 1,
453
+            n_type: N_SECT | N_EXT,
454
+            n_sect: 1,
455
+            n_desc: 0,
456
+            n_value: 0,
457
+        };
458
+        let loh_blob = write_loh_blob(&[LohEntry {
459
+            kind: LOH_ARM64_ADRP_ADD,
460
+            args: vec![0, 4],
461
+        }]);
462
+        let hdr_size = HEADER_SIZE;
463
+        let seg_size = seg.wire_size() as usize;
464
+        let loh_size = LinkEditDataCmd::WIRE_SIZE as usize;
465
+        let symtab_size = SymtabCmd::WIRE_SIZE as usize;
466
+        let sizeofcmds = (seg_size + loh_size + symtab_size) as u32;
467
+
468
+        let section_offset = (hdr_size + sizeofcmds as usize) as u32;
469
+        let loh_off = section_offset + 8;
470
+        let symoff = loh_off + loh_blob.len() as u32;
471
+        let stroff = symoff + NLIST_SIZE as u32 * nsyms;
472
+        let seg = Segment64 {
473
+            sections: vec![Section64Header {
474
+                offset: section_offset,
475
+                ..seg.sections[0]
476
+            }],
477
+            fileoff: section_offset as u64,
478
+            ..seg
479
+        };
480
+        let header = MachHeader64 {
481
+            magic: MH_MAGIC_64,
482
+            cputype: CPU_TYPE_ARM64,
483
+            cpusubtype: 0,
484
+            filetype: MH_OBJECT,
485
+            ncmds: 3,
486
+            sizeofcmds,
487
+            flags: MH_SUBSECTIONS_VIA_SYMBOLS,
488
+            reserved: 0,
489
+        };
490
+        let symtab_cmd = SymtabCmd {
491
+            symoff,
492
+            nsyms,
493
+            stroff,
494
+            strsize: strtab.len() as u32,
495
+        };
496
+        let loh_cmd = LoadCommand::LinkerOptimizationHint(LinkEditDataCmd {
497
+            dataoff: loh_off,
498
+            datasize: loh_blob.len() as u32,
499
+        });
500
+
501
+        let mut image = Vec::new();
502
+        write_header(&header, &mut image);
503
+        let cmds = vec![
504
+            LoadCommand::Segment64(seg),
505
+            loh_cmd,
506
+            LoadCommand::Symtab(symtab_cmd),
507
+        ];
508
+        write_commands(&cmds, &mut image);
509
+        image.extend_from_slice(&[0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 0x11, 0x22]);
510
+        image.extend_from_slice(&loh_blob);
511
+        sym.write(&mut image);
512
+        image.extend_from_slice(strtab);
513
+        image
514
+    }
515
+
392516
     #[test]
393517
     fn parse_synth_object_end_to_end() {
394518
         let image = synth_image();
@@ -425,6 +549,19 @@ mod tests {
425549
         );
426550
     }
427551
 
552
+    #[test]
553
+    fn parse_preserves_loh_entries() {
554
+        let image = synth_image_with_loh();
555
+        let obj = ObjectFile::parse("/tmp/synth-loh.o", &image).unwrap();
556
+        assert_eq!(
557
+            obj.loh,
558
+            vec![LohEntry {
559
+                kind: LOH_ARM64_ADRP_ADD,
560
+                args: vec![0, 4],
561
+            }]
562
+        );
563
+    }
564
+
428565
     #[test]
429566
     fn indirect_target_name_resolves() {
430567
         // Build a minimal strtab with "\0_alias\0_target\0" and a RawNlist
@@ -448,6 +585,7 @@ mod tests {
448585
             strings: strtab,
449586
             symtab: None,
450587
             dysymtab: None,
588
+            loh: Vec::new(),
451589
             data_in_code: Vec::new(),
452590
         };
453591
         let alias = InputSymbol::from_raw(RawNlist {
src/layout.rsmodified
278 lines changed — click to load
@@ -3,12 +3,12 @@
33
 //! Groups atoms into output sections, orders them deterministically, and
44
 //! assigns segment VM/file ranges once the final Mach-O header size is known.
55
 
6
-use std::collections::HashMap;
6
+use std::collections::{HashMap, HashSet};
77
 
88
 use crate::atom::AtomTable;
99
 use crate::input::ObjectFile;
1010
 use crate::macho::constants::SG_READ_ONLY;
11
-use crate::resolve::InputId;
11
+use crate::resolve::{AtomId, InputId};
1212
 use crate::section::{
1313
     is_zerofill, InputSection, OutputAtom, OutputSection, OutputSectionId, OutputSegment, Prot,
1414
 };
@@ -48,6 +48,24 @@ struct SectionKey {
4848
     name: String,
4949
 }
5050
 
51
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
52
+pub enum ExtraSectionAnchor {
53
+    AfterSection { segment: String, name: String },
54
+    AfterAtom(AtomId),
55
+}
56
+
57
+#[derive(Debug, Clone, PartialEq, Eq)]
58
+pub struct ExtraOutputSection {
59
+    pub after_section: Option<ExtraSectionAnchor>,
60
+    pub section: OutputSection,
61
+}
62
+
63
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64
+pub struct ExtraLayoutSections<'a> {
65
+    pub extra_sections: &'a [ExtraOutputSection],
66
+    pub split_after_atoms: &'a [AtomId],
67
+}
68
+
5169
 fn output_section_key(input_section: &InputSection) -> SectionKey {
5270
     match (
5371
         input_section.segname.as_str(),
@@ -75,7 +93,7 @@ impl Layout {
7593
         atoms: &AtomTable,
7694
         header_size: u64,
7795
     ) -> Self {
78
-        Self::build_with_synthetics(kind, inputs, atoms, header_size, None)
96
+        Self::build_with_synthetics_filtered(kind, inputs, atoms, header_size, None, None)
7997
     }
8098
 
8199
     pub fn build_with_synthetics(
@@ -84,6 +102,51 @@ impl Layout {
84102
         atoms: &AtomTable,
85103
         header_size: u64,
86104
         synthetic_plan: Option<&SyntheticPlan>,
105
+    ) -> Self {
106
+        Self::build_with_synthetics_and_extra_filtered(
107
+            kind,
108
+            inputs,
109
+            atoms,
110
+            header_size,
111
+            synthetic_plan,
112
+            None,
113
+            ExtraLayoutSections {
114
+                extra_sections: &[],
115
+                split_after_atoms: &[],
116
+            },
117
+        )
118
+    }
119
+
120
+    pub fn build_with_synthetics_filtered(
121
+        kind: OutputKind,
122
+        inputs: &[LayoutInput<'_>],
123
+        atoms: &AtomTable,
124
+        header_size: u64,
125
+        synthetic_plan: Option<&SyntheticPlan>,
126
+        live_atoms: Option<&HashSet<AtomId>>,
127
+    ) -> Self {
128
+        Self::build_with_synthetics_and_extra_filtered(
129
+            kind,
130
+            inputs,
131
+            atoms,
132
+            header_size,
133
+            synthetic_plan,
134
+            live_atoms,
135
+            ExtraLayoutSections {
136
+                extra_sections: &[],
137
+                split_after_atoms: &[],
138
+            },
139
+        )
140
+    }
141
+
142
+    pub fn build_with_synthetics_and_extra_filtered(
143
+        kind: OutputKind,
144
+        inputs: &[LayoutInput<'_>],
145
+        atoms: &AtomTable,
146
+        header_size: u64,
147
+        synthetic_plan: Option<&SyntheticPlan>,
148
+        live_atoms: Option<&HashSet<AtomId>>,
149
+        extra_layout: ExtraLayoutSections<'_>,
87150
     ) -> Self {
88151
         let input_map: HashMap<InputId, LayoutInput<'_>> =
89152
             inputs.iter().map(|input| (input.id, *input)).collect();
@@ -92,6 +155,9 @@ impl Layout {
92155
         let mut section_index: HashMap<SectionKey, usize> = HashMap::new();
93156
 
94157
         for (atom_id, atom) in atoms.iter() {
158
+            if live_atoms.is_some_and(|live_atoms| !live_atoms.contains(&atom_id)) {
159
+                continue;
160
+            }
95161
             let input = input_map
96162
                 .get(&atom.origin)
97163
                 .unwrap_or_else(|| panic!("missing object for input {:?}", atom.origin));
@@ -159,7 +225,6 @@ impl Layout {
159225
                 }
160226
             }
161227
         }
162
-
163228
         sections.sort_by(|a, b| {
164229
             segment_rank(kind, &a.segment)
165230
                 .cmp(&segment_rank(kind, &b.segment))
@@ -193,7 +258,12 @@ impl Layout {
193258
                     .then_with(|| lhs.input_offset.cmp(&rhs.input_offset))
194259
                     .then_with(|| a.atom.cmp(&b.atom))
195260
             });
261
+        }
196262
 
263
+        split_sections_after_atoms(&mut sections, extra_layout.split_after_atoms);
264
+        insert_extra_sections(&mut sections, extra_layout.extra_sections);
265
+
266
+        for section in &mut sections {
197267
             let mut size = 0u64;
198268
             for placed in &mut section.atoms {
199269
                 let atom = atoms.get(placed.atom);
@@ -386,6 +456,90 @@ impl Layout {
386456
     }
387457
 }
388458
 
459
+fn split_sections_after_atoms(sections: &mut Vec<OutputSection>, split_after_atoms: &[AtomId]) {
460
+    if split_after_atoms.is_empty() {
461
+        return;
462
+    }
463
+    let split_points: HashSet<AtomId> = split_after_atoms.iter().copied().collect();
464
+    let mut out = Vec::with_capacity(sections.len());
465
+    for mut section in std::mem::take(sections) {
466
+        if section.atoms.len() < 2 || !section.synthetic_data.is_empty() {
467
+            out.push(section);
468
+            continue;
469
+        }
470
+        if !section
471
+            .atoms
472
+            .iter()
473
+            .any(|placed| split_points.contains(&placed.atom))
474
+        {
475
+            out.push(section);
476
+            continue;
477
+        }
478
+        let atoms = std::mem::take(&mut section.atoms);
479
+        let last_idx = atoms.len().saturating_sub(1);
480
+        let mut current = split_section_template(&section);
481
+        for (idx, placed) in atoms.into_iter().enumerate() {
482
+            let split_here = split_points.contains(&placed.atom) && idx != last_idx;
483
+            current.atoms.push(placed);
484
+            if split_here {
485
+                out.push(current);
486
+                current = split_section_template(&section);
487
+            }
488
+        }
489
+        out.push(current);
490
+    }
491
+    *sections = out;
492
+}
493
+
494
+fn split_section_template(section: &OutputSection) -> OutputSection {
495
+    OutputSection {
496
+        segment: section.segment.clone(),
497
+        name: section.name.clone(),
498
+        kind: section.kind,
499
+        align_pow2: section.align_pow2,
500
+        flags: section.flags,
501
+        reserved1: section.reserved1,
502
+        reserved2: section.reserved2,
503
+        reserved3: section.reserved3,
504
+        atoms: Vec::new(),
505
+        synthetic_offset: 0,
506
+        synthetic_data: Vec::new(),
507
+        addr: 0,
508
+        size: 0,
509
+        file_off: 0,
510
+    }
511
+}
512
+
513
+fn insert_extra_sections(sections: &mut Vec<OutputSection>, extra_sections: &[ExtraOutputSection]) {
514
+    for extra in extra_sections {
515
+        let section = extra.section.clone();
516
+        if let Some(anchor) = &extra.after_section {
517
+            let insert_at = sections
518
+                .iter()
519
+                .rposition(|candidate| match anchor {
520
+                    ExtraSectionAnchor::AfterSection { segment, name } => {
521
+                        candidate.segment == *segment && candidate.name == *name
522
+                    }
523
+                    ExtraSectionAnchor::AfterAtom(atom_id) => candidate
524
+                        .atoms
525
+                        .last()
526
+                        .map(|placed| placed.atom == *atom_id)
527
+                        .unwrap_or(false),
528
+                })
529
+                .map(|idx| idx + 1)
530
+                .unwrap_or_else(|| {
531
+                    panic!(
532
+                        "missing anchor {:?} for synthetic section {},{}",
533
+                        anchor, section.segment, section.name
534
+                    )
535
+                });
536
+            sections.insert(insert_at, section);
537
+        } else {
538
+            sections.push(section);
539
+        }
540
+    }
541
+}
542
+
389543
 fn merge_synthetic_section(existing: &mut OutputSection, synthetic: OutputSection) {
390544
     debug_assert_eq!(existing.segment, synthetic.segment);
391545
     debug_assert_eq!(existing.name, synthetic.name);
@@ -488,6 +642,7 @@ fn section_rank(segment: &str, section: &str) -> usize {
488642
     let order: &[&str] = match segment {
489643
         "__TEXT" => &[
490644
             "__text",
645
+            "__thunks",
491646
             "__stubs",
492647
             "__stub_helper",
493648
             "__cstring",
@@ -654,6 +809,7 @@ mod tests {
654809
             strings: crate::string_table::StringTable::from_bytes(vec![0]),
655810
             symtab: None,
656811
             dysymtab: None,
812
+            loh: Vec::new(),
657813
             data_in_code: Vec::new(),
658814
         };
659815
 
@@ -741,6 +897,7 @@ mod tests {
741897
             strings: crate::string_table::StringTable::from_bytes(vec![0]),
742898
             symtab: None,
743899
             dysymtab: None,
900
+            loh: Vec::new(),
744901
             data_in_code: Vec::new(),
745902
         };
746903
 
@@ -803,6 +960,7 @@ mod tests {
803960
             strings: crate::string_table::StringTable::from_bytes(vec![0]),
804961
             symtab: None,
805962
             dysymtab: None,
963
+            loh: Vec::new(),
806964
             data_in_code: Vec::new(),
807965
         };
808966
 
@@ -861,6 +1019,7 @@ mod tests {
8611019
             strings: crate::string_table::StringTable::from_bytes(vec![0]),
8621020
             symtab: None,
8631021
             dysymtab: None,
1022
+            loh: Vec::new(),
8641023
             data_in_code: Vec::new(),
8651024
         };
8661025
 
@@ -933,6 +1092,7 @@ mod tests {
9331092
             strings: crate::string_table::StringTable::from_bytes(vec![0]),
9341093
             symtab: None,
9351094
             dysymtab: None,
1095
+            loh: Vec::new(),
9361096
             data_in_code: Vec::new(),
9371097
         };
9381098
 
@@ -994,6 +1154,7 @@ mod tests {
9941154
             strings: crate::string_table::StringTable::from_bytes(vec![0]),
9951155
             symtab: None,
9961156
             dysymtab: None,
1157
+            loh: Vec::new(),
9971158
             data_in_code: Vec::new(),
9981159
         };
9991160
 
@@ -1129,6 +1290,7 @@ mod tests {
11291290
             strings: crate::string_table::StringTable::from_bytes(vec![0]),
11301291
             symtab: None,
11311292
             dysymtab: None,
1293
+            loh: Vec::new(),
11321294
             data_in_code: Vec::new(),
11331295
         };
11341296
 
@@ -1216,6 +1378,7 @@ mod tests {
12161378
             strings: crate::string_table::StringTable::from_bytes(vec![0]),
12171379
             symtab: None,
12181380
             dysymtab: None,
1381
+            loh: Vec::new(),
12191382
             data_in_code: Vec::new(),
12201383
         };
12211384
 
src/lib.rsmodified
1190 lines changed — click to load
@@ -9,9 +9,12 @@ pub mod args;
99
 pub mod atom;
1010
 pub mod diag;
1111
 pub mod dump;
12
+pub mod icf;
1213
 pub mod input;
1314
 pub mod layout;
1415
 pub mod leb;
16
+pub mod link_map;
17
+pub mod loh;
1518
 pub mod macho;
1619
 pub mod reloc;
1720
 pub mod resolve;
@@ -19,23 +22,36 @@ pub mod section;
1922
 pub mod string_table;
2023
 pub mod symbol;
2124
 pub mod synth;
25
+pub mod why_live;
2226
 
2327
 use std::os::unix::fs::PermissionsExt;
2428
 use std::path::PathBuf;
25
-use std::{fs, io};
29
+use std::sync::{mpsc, Arc, Mutex};
30
+use std::thread;
31
+use std::time::{Duration, Instant};
32
+use std::{collections::VecDeque, fs, io};
2633
 
34
+use archive::Archive;
2735
 use atom::{atomize_object, backpatch_symbol_atoms, AtomTable};
28
-use layout::{Layout, LayoutInput};
36
+use icf::IcfError;
37
+use input::ObjectFile;
38
+use layout::{ExtraLayoutSections, Layout, LayoutInput};
2939
 use macho::dylib::{DylibDependency, DylibFile, DylibLoadKind};
3040
 use macho::reader::ReadError;
31
-use macho::tbd::{parse_tbd, parse_version, Arch, Platform, Target};
41
+use macho::tbd::{
42
+    parse_tbd_for_target, parse_tbd_metadata_for_target, parse_version, Arch, Platform, Target,
43
+};
3244
 use reloc::arm64::RelocError;
3345
 use resolve::{
34
-    classify_unresolved, drain_fetches, format_duplicate_diagnostic, format_undefined_diagnostic,
35
-    seed_all, DylibLoadMeta, InputAddError, Inputs, Symbol, SymbolTable, UndefinedTreatment,
46
+    classify_unresolved, drain_fetches, find_archive_by_path, force_load_all, force_load_archive,
47
+    format_duplicate_diagnostic, format_undefined_diagnostic, format_undefined_warning_diagnostic,
48
+    seed_all, DrainReport, DylibLoadMeta, InputAddError, InputId, Inputs, Symbol, SymbolTable,
49
+    UndefinedTreatment,
3650
 };
51
+use symbol::SymKind;
3752
 
3853
 const DEFAULT_TBD_VERSION: u32 = 1 << 16;
54
+const THUNK_PLAN_MAX_ITERATIONS: usize = 16;
3955
 
4056
 /// What kind of Mach-O file the linker is producing.
4157
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -44,14 +60,72 @@ pub enum OutputKind {
4460
     Dylib,
4561
 }
4662
 
63
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64
+pub enum IcfMode {
65
+    None,
66
+    Safe,
67
+    All,
68
+}
69
+
70
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71
+pub enum ThunkMode {
72
+    None,
73
+    Safe,
74
+    All,
75
+}
76
+
77
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78
+pub struct PlatformVersion {
79
+    pub minos: u32,
80
+    pub sdk: u32,
81
+}
82
+
83
+#[derive(Debug, Clone, PartialEq, Eq)]
84
+pub struct FrameworkSpec {
85
+    pub name: String,
86
+    pub weak: bool,
87
+}
88
+
4789
 /// User-facing linker configuration, populated by the CLI parser.
4890
 #[derive(Debug, Clone)]
4991
 pub struct LinkOptions {
5092
     pub inputs: Vec<PathBuf>,
93
+    pub library_names: Vec<String>,
94
+    pub frameworks: Vec<FrameworkSpec>,
95
+    pub search_paths: Vec<PathBuf>,
96
+    pub syslibroot: Option<PathBuf>,
97
+    pub platform_version: Option<PlatformVersion>,
98
+    pub undefined_treatment: UndefinedTreatment,
99
+    pub rpaths: Vec<String>,
100
+    pub install_name: Option<String>,
101
+    pub current_version: Option<u32>,
102
+    pub compatibility_version: Option<u32>,
103
+    pub exported_symbols_lists: Vec<PathBuf>,
104
+    pub unexported_symbols_lists: Vec<PathBuf>,
105
+    pub exported_symbols: Vec<String>,
106
+    pub unexported_symbols: Vec<String>,
107
+    pub map: Option<PathBuf>,
108
+    pub why_live: Vec<String>,
109
+    pub trace_inputs: bool,
110
+    pub show_version: bool,
111
+    pub show_help: bool,
51112
     pub output: Option<PathBuf>,
52113
     pub entry: Option<String>,
53114
     pub arch: Option<String>,
115
+    pub relocatable: bool,
116
+    pub bundle: bool,
117
+    pub objc_force_load: bool,
54118
     pub strip_locals: bool,
119
+    pub strip_debug: bool,
120
+    pub emit_uuid: bool,
121
+    pub dead_strip: bool,
122
+    pub no_loh: bool,
123
+    pub icf_mode: IcfMode,
124
+    pub thunks: ThunkMode,
125
+    pub fixup_chains: bool,
126
+    pub all_load: bool,
127
+    pub force_load_archives: Vec<PathBuf>,
128
+    pub jobs: Option<usize>,
55129
     pub kind: OutputKind,
56130
     /// When set, afs-ld operates in dump mode and prints the given file's
57131
     /// header + load commands instead of linking.
@@ -68,10 +142,42 @@ impl Default for LinkOptions {
68142
     fn default() -> Self {
69143
         Self {
70144
             inputs: Vec::new(),
145
+            library_names: Vec::new(),
146
+            frameworks: Vec::new(),
147
+            search_paths: Vec::new(),
148
+            syslibroot: None,
149
+            platform_version: None,
150
+            undefined_treatment: UndefinedTreatment::Error,
151
+            rpaths: Vec::new(),
152
+            install_name: None,
153
+            current_version: None,
154
+            compatibility_version: None,
155
+            exported_symbols_lists: Vec::new(),
156
+            unexported_symbols_lists: Vec::new(),
157
+            exported_symbols: Vec::new(),
158
+            unexported_symbols: Vec::new(),
159
+            map: None,
160
+            why_live: Vec::new(),
161
+            trace_inputs: false,
162
+            show_version: false,
163
+            show_help: false,
71164
             output: None,
72165
             entry: None,
73166
             arch: None,
167
+            relocatable: false,
168
+            bundle: false,
169
+            objc_force_load: false,
74170
             strip_locals: false,
171
+            strip_debug: false,
172
+            emit_uuid: true,
173
+            dead_strip: false,
174
+            no_loh: false,
175
+            icf_mode: IcfMode::None,
176
+            thunks: ThunkMode::Safe,
177
+            fixup_chains: false,
178
+            all_load: false,
179
+            force_load_archives: Vec::new(),
180
+            jobs: None,
75181
             kind: OutputKind::Executable,
76182
             dump: None,
77183
             dump_archive: None,
@@ -81,6 +187,18 @@ impl Default for LinkOptions {
81187
     }
82188
 }
83189
 
190
+impl LinkOptions {
191
+    pub fn parallel_jobs(&self) -> usize {
192
+        self.jobs
193
+            .unwrap_or_else(|| {
194
+                thread::available_parallelism()
195
+                    .map(usize::from)
196
+                    .unwrap_or(1)
197
+            })
198
+            .max(1)
199
+    }
200
+}
201
+
84202
 #[derive(Debug)]
85203
 pub enum LinkError {
86204
     /// No input files were provided on the command line.
@@ -94,11 +212,93 @@ pub enum LinkError {
94212
     Reloc(RelocError),
95213
     Synth(synth::SynthError),
96214
     Unwind(synth::unwind::UnwindError),
215
+    Icf(IcfError),
216
+    Loh(loh::LohError),
97217
     DuplicateSymbols(String),
98218
     UndefinedSymbols(String),
99219
     UnsupportedArch(String),
100220
     NoTbdDocument(PathBuf),
101221
     EntrySymbolNotFound(String),
222
+    ForceLoadNotArchive(PathBuf),
223
+    LibraryNotFound(String),
224
+    FrameworkNotFound(String),
225
+    ThunkPlanningDidNotConverge,
226
+    WhyLive(String),
227
+    UnsupportedOption(String),
228
+}
229
+
230
+#[derive(Debug, Clone, Default, PartialEq, Eq)]
231
+pub struct LinkPhaseTimings {
232
+    pub input_parsing: Duration,
233
+    pub input_read: Duration,
234
+    pub input_object_parse: Duration,
235
+    pub input_archive_parse: Duration,
236
+    pub input_dylib_parse: Duration,
237
+    pub input_tbd_decode: Duration,
238
+    pub input_tbd_materialize: Duration,
239
+    pub input_reloc_parse: Duration,
240
+    pub symbol_resolution: Duration,
241
+    pub atomization: Duration,
242
+    pub layout: Duration,
243
+    pub layout_entry_lookup: Duration,
244
+    pub layout_dead_strip: Duration,
245
+    pub layout_icf: Duration,
246
+    pub layout_synthetic_plan: Duration,
247
+    pub layout_build: Duration,
248
+    pub layout_thunk_plan: Duration,
249
+    pub synth_sections: Duration,
250
+    pub synth_linkedit_finalize: Duration,
251
+    pub synth_linkedit_symbol_plan: Duration,
252
+    pub synth_linkedit_symbol_plan_locals: Duration,
253
+    pub synth_linkedit_symbol_plan_globals: Duration,
254
+    pub synth_linkedit_symbol_plan_strtab: Duration,
255
+    pub synth_linkedit_dyld_info: Duration,
256
+    pub synth_linkedit_dyld_bind: Duration,
257
+    pub synth_linkedit_dyld_rebase: Duration,
258
+    pub synth_linkedit_dyld_export: Duration,
259
+    pub synth_linkedit_metadata_tables: Duration,
260
+    pub synth_linkedit_code_signature: Duration,
261
+    pub synth_unwind: Duration,
262
+    pub reloc_apply: Duration,
263
+    pub write_output: Duration,
264
+}
265
+
266
+impl LinkPhaseTimings {
267
+    pub fn accounted_total(&self) -> Duration {
268
+        self.input_parsing
269
+            + self.symbol_resolution
270
+            + self.atomization
271
+            + self.layout
272
+            + self.synth_sections
273
+            + self.reloc_apply
274
+            + self.write_output
275
+    }
276
+
277
+    fn add_input_load(&mut self, timings: InputLoadTimings) {
278
+        self.input_read += timings.read;
279
+        self.input_object_parse += timings.object_parse;
280
+        self.input_archive_parse += timings.archive_parse;
281
+        self.input_dylib_parse += timings.dylib_parse;
282
+        self.input_tbd_decode += timings.tbd_decode;
283
+        self.input_tbd_materialize += timings.tbd_materialize;
284
+    }
285
+}
286
+
287
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
288
+struct InputLoadTimings {
289
+    read: Duration,
290
+    object_parse: Duration,
291
+    archive_parse: Duration,
292
+    dylib_parse: Duration,
293
+    tbd_decode: Duration,
294
+    tbd_materialize: Duration,
295
+}
296
+
297
+#[derive(Debug, Clone, PartialEq, Eq)]
298
+pub struct LinkProfile {
299
+    pub output: PathBuf,
300
+    pub phases: LinkPhaseTimings,
301
+    pub total_wall: Duration,
102302
 }
103303
 
104304
 impl std::fmt::Display for LinkError {
@@ -114,6 +314,8 @@ impl std::fmt::Display for LinkError {
114314
             LinkError::Reloc(e) => write!(f, "{e}"),
115315
             LinkError::Synth(e) => write!(f, "{e}"),
116316
             LinkError::Unwind(e) => write!(f, "{e}"),
317
+            LinkError::Icf(e) => write!(f, "{e}"),
318
+            LinkError::Loh(e) => write!(f, "{e}"),
117319
             LinkError::DuplicateSymbols(msg) | LinkError::UndefinedSymbols(msg) => {
118320
                 write!(f, "{msg}")
119321
             }
@@ -126,6 +328,24 @@ impl std::fmt::Display for LinkError {
126328
             LinkError::EntrySymbolNotFound(name) => {
127329
                 write!(f, "entry symbol `{name}` was not found in linked objects")
128330
             }
331
+            LinkError::ForceLoadNotArchive(path) => {
332
+                write!(
333
+                    f,
334
+                    "{}: -force_load requires a path that is also present as an archive input",
335
+                    path.display()
336
+                )
337
+            }
338
+            LinkError::LibraryNotFound(name) => {
339
+                write!(f, "unable to find library `{name}`")
340
+            }
341
+            LinkError::FrameworkNotFound(name) => {
342
+                write!(f, "unable to find framework `{name}`")
343
+            }
344
+            LinkError::ThunkPlanningDidNotConverge => {
345
+                write!(f, "thunk planning did not converge")
346
+            }
347
+            LinkError::WhyLive(msg) => write!(f, "{msg}"),
348
+            LinkError::UnsupportedOption(msg) => write!(f, "{msg}"),
129349
         }
130350
     }
131351
 }
@@ -192,15 +412,54 @@ impl From<synth::unwind::UnwindError> for LinkError {
192412
     }
193413
 }
194414
 
415
+impl From<IcfError> for LinkError {
416
+    fn from(value: IcfError) -> Self {
417
+        LinkError::Icf(value)
418
+    }
419
+}
420
+
421
+impl From<loh::LohError> for LinkError {
422
+    fn from(value: loh::LohError) -> Self {
423
+        LinkError::Loh(value)
424
+    }
425
+}
426
+
195427
 /// The linker itself. Sprint 0 only validates that inputs exist; later sprints
196428
 /// grow this into the full pipeline described in `.docs/overview.md`.
197429
 pub struct Linker;
198430
 
199431
 impl Linker {
200432
     pub fn run(opts: &LinkOptions) -> Result<(), LinkError> {
201
-        if opts.inputs.is_empty() {
433
+        Self::run_profiled(opts).map(|_| ())
434
+    }
435
+
436
+    pub fn run_profiled(opts: &LinkOptions) -> Result<LinkProfile, LinkError> {
437
+        let overall_started = Instant::now();
438
+        let mut phases = LinkPhaseTimings::default();
439
+        if opts.relocatable {
440
+            return Err(LinkError::UnsupportedOption(
441
+                "`-r` relocatable output is not yet supported".into(),
442
+            ));
443
+        }
444
+        if opts.bundle {
445
+            return Err(LinkError::UnsupportedOption(
446
+                "`-bundle` output is not yet supported".into(),
447
+            ));
448
+        }
449
+        if opts.fixup_chains {
450
+            return Err(LinkError::UnsupportedOption(
451
+                "`-fixup_chains` is not yet supported".into(),
452
+            ));
453
+        }
454
+        if opts.icf_mode == IcfMode::All {
455
+            return Err(LinkError::UnsupportedOption(
456
+                "`-icf=all` is not yet supported; use `-icf=safe` or `-icf=none`".into(),
457
+            ));
458
+        }
459
+        if opts.inputs.is_empty() && opts.library_names.is_empty() && opts.frameworks.is_empty() {
202460
             return Err(LinkError::NoInputs);
203461
         }
462
+        let parallel_jobs = opts.parallel_jobs();
204463
 
205464
         if let Some(arch) = &opts.arch {
206465
             if arch != "arm64" {
@@ -208,12 +467,83 @@ impl Linker {
208467
             }
209468
         }
210469
 
470
+        if opts.strip_debug {
471
+            crate::diag::warning(
472
+                "`-S` requested, but afs-ld does not currently emit debug symbols",
473
+            );
474
+        }
475
+        if opts.objc_force_load {
476
+            crate::diag::warning(
477
+                "`-ObjC` requested, but afs-ld does not yet scan Objective-C archive metadata; the flag currently has no effect",
478
+            );
479
+        }
480
+        if opts.no_loh {
481
+            crate::diag::warning(
482
+                "`-no_loh` requested, but afs-ld currently matches Apple ld by omitting final-output LOH; the flag has no effect",
483
+            );
484
+        }
485
+
486
+        let mut load_paths = Vec::new();
487
+        let mut positional_dylibs = Vec::new();
488
+        for path in &opts.inputs {
489
+            match path.extension().and_then(|ext| ext.to_str()) {
490
+                Some("dylib" | "tbd") => positional_dylibs.push(path.clone()),
491
+                _ => load_paths.push(path.clone()),
492
+            }
493
+        }
494
+        let mut dylib_load_kinds = std::collections::HashMap::new();
495
+        for name in &opts.library_names {
496
+            let path = resolve_library_input(opts, name)?;
497
+            dylib_load_kinds.insert(path.clone(), DylibLoadKind::Normal);
498
+            load_paths.push(path);
499
+        }
500
+        for framework in &opts.frameworks {
501
+            let path = resolve_framework_input(opts, &framework.name)?;
502
+            dylib_load_kinds.insert(
503
+                path.clone(),
504
+                if framework.weak {
505
+                    DylibLoadKind::Weak
506
+                } else {
507
+                    DylibLoadKind::Normal
508
+                },
509
+            );
510
+            load_paths.push(path);
511
+        }
512
+        load_paths.extend(positional_dylibs);
513
+
211514
         let mut inputs = Inputs::new();
212
-        for (load_order, path) in opts.inputs.iter().enumerate() {
213
-            register_input(&mut inputs, path, load_order)?;
515
+        let mut deferred_dylibs = Vec::new();
516
+        let mut initial_loads = Vec::new();
517
+        let phase_started = Instant::now();
518
+        for (load_order, path) in load_paths.iter().enumerate() {
519
+            if matches!(
520
+                path.extension().and_then(|ext| ext.to_str()),
521
+                Some("dylib" | "tbd")
522
+            ) {
523
+                deferred_dylibs.push((load_order, path.clone()));
524
+                continue;
525
+            }
526
+            if opts.trace_inputs {
527
+                eprintln!("afs-ld: loading {}", path.display());
528
+            }
529
+            initial_loads.push((load_order, path.clone()));
530
+        }
531
+        for loaded in load_initial_inputs(initial_loads, parallel_jobs)? {
532
+            let timings = register_loaded_initial_input(&mut inputs, loaded);
533
+            phases.add_input_load(timings);
214534
         }
535
+        let include_tbd_exports = inputs_may_need_dylib_exports(&inputs)?;
536
+        for (load_order, path) in &deferred_dylibs {
537
+            if opts.trace_inputs {
538
+                eprintln!("afs-ld: loading {}", path.display());
539
+            }
540
+            let timings = register_input(&mut inputs, path, *load_order, include_tbd_exports)?;
541
+            phases.add_input_load(timings);
542
+        }
543
+        phases.input_parsing = phase_started.elapsed();
215544
 
216545
         let mut sym_table = SymbolTable::new();
546
+        let phase_started = Instant::now();
217547
         let seed_report = seed_all(&inputs, &mut sym_table)?;
218548
         if seed_report.has_errors() {
219549
             let mut msg = String::new();
@@ -223,7 +553,51 @@ impl Linker {
223553
             return Err(LinkError::DuplicateSymbols(msg));
224554
         }
225555
 
226
-        let drain_report = drain_fetches(&mut inputs, &mut sym_table, seed_report.pending_fetches)?;
556
+        let mut force_report = DrainReport::default();
557
+        if opts.all_load {
558
+            force_load_all(
559
+                &mut inputs,
560
+                &mut sym_table,
561
+                &mut force_report,
562
+                parallel_jobs,
563
+            )?;
564
+        }
565
+        for archive_path in &opts.force_load_archives {
566
+            let Some(archive_id) = find_archive_by_path(&inputs, archive_path) else {
567
+                return Err(LinkError::ForceLoadNotArchive(archive_path.clone()));
568
+            };
569
+            force_load_archive(
570
+                &mut inputs,
571
+                &mut sym_table,
572
+                archive_id,
573
+                &mut force_report,
574
+                parallel_jobs,
575
+            )?;
576
+        }
577
+        if opts.trace_inputs {
578
+            for path in &force_report.loaded_paths {
579
+                eprintln!("afs-ld: loading {}", path.display());
580
+            }
581
+        }
582
+        if !force_report.duplicates.is_empty() {
583
+            let mut msg = String::new();
584
+            for err in &force_report.duplicates {
585
+                msg.push_str(&format_duplicate_diagnostic(&sym_table, &inputs, err));
586
+            }
587
+            return Err(LinkError::DuplicateSymbols(msg));
588
+        }
589
+
590
+        let drain_report = drain_fetches(
591
+            &mut inputs,
592
+            &mut sym_table,
593
+            seed_report.pending_fetches,
594
+            parallel_jobs,
595
+        )?;
596
+        if opts.trace_inputs {
597
+            for path in &drain_report.loaded_paths {
598
+                eprintln!("afs-ld: loading {}", path.display());
599
+            }
600
+        }
227601
         if !drain_report.duplicates.is_empty() {
228602
             let mut msg = String::new();
229603
             for err in &drain_report.duplicates {
@@ -232,8 +606,9 @@ impl Linker {
232606
             return Err(LinkError::DuplicateSymbols(msg));
233607
         }
234608
         let mut referrers = seed_report.referrers.clone();
609
+        referrers.extend_from(&force_report.referrers);
235610
         referrers.extend_from(&drain_report.referrers);
236
-        let unresolved = classify_unresolved(&mut sym_table, UndefinedTreatment::Error);
611
+        let unresolved = classify_unresolved(&mut sym_table, opts.undefined_treatment);
237612
         if !unresolved.errors.is_empty() {
238613
             return Err(LinkError::UndefinedSymbols(format_undefined_diagnostic(
239614
                 &sym_table,
@@ -242,22 +617,27 @@ impl Linker {
242617
                 &unresolved.errors,
243618
             )));
244619
         }
620
+        if !unresolved.warnings.is_empty() {
621
+            crate::diag::warning_verbatim(&format_undefined_warning_diagnostic(
622
+                &sym_table,
623
+                &inputs,
624
+                &referrers,
625
+                &unresolved.warnings,
626
+            ));
627
+        }
628
+        phases.symbol_resolution = phase_started.elapsed();
245629
 
246630
         let mut atom_table = AtomTable::new();
247631
         let mut objects = Vec::new();
632
+        let phase_started = Instant::now();
248633
         for idx in 0..inputs.objects.len() {
249634
             let input_id = resolve::InputId(idx as u32);
250635
             let obj = inputs.object_file(input_id)?;
251
-            let atomization = atomize_object(input_id, &obj, &mut atom_table);
252
-            backpatch_symbol_atoms(
253
-                &atomization,
254
-                input_id,
255
-                &obj,
256
-                &mut sym_table,
257
-                &mut atom_table,
258
-            );
636
+            let atomization = atomize_object(input_id, obj, &mut atom_table);
637
+            backpatch_symbol_atoms(&atomization, input_id, obj, &mut sym_table, &mut atom_table);
259638
             objects.push((input_id, obj));
260639
         }
640
+        phases.atomization = phase_started.elapsed();
261641
 
262642
         let layout_inputs: Vec<LayoutInput<'_>> = objects
263643
             .iter()
@@ -278,43 +658,167 @@ impl Linker {
278658
                 continue;
279659
             }
280660
             dylib_loads.push(DylibDependency {
281
-                kind: DylibLoadKind::Normal,
661
+                kind: dylib_load_kinds
662
+                    .get(&dylib.path)
663
+                    .copied()
664
+                    .unwrap_or(DylibLoadKind::Normal),
282665
                 install_name: dylib.load_install_name.clone(),
283666
                 current_version: dylib.load_current_version,
284667
                 compatibility_version: dylib.load_compatibility_version,
285668
                 ordinal: dylib.ordinal,
286669
             });
287670
         }
288
-        let synthetic_plan = synth::SyntheticPlan::build(
671
+        let phase_started = Instant::now();
672
+        let parsed_relocs = macho::writer::build_parsed_reloc_cache(&layout_inputs)?;
673
+        let elapsed = phase_started.elapsed();
674
+        phases.input_reloc_parse += elapsed;
675
+        phases.input_parsing += elapsed;
676
+        let layout_started = Instant::now();
677
+        let phase_started = Instant::now();
678
+        let entry_symbol = find_entry_symbol_id(opts, &sym_table)?;
679
+        phases.layout_entry_lookup = phase_started.elapsed();
680
+        let phase_started = Instant::now();
681
+        let dead_strip = opts.dead_strip.then(|| {
682
+            why_live::DeadStripAnalysis::build(
683
+                opts,
684
+                &layout_inputs,
685
+                &atom_table,
686
+                &sym_table,
687
+                entry_symbol,
688
+            )
689
+        });
690
+        phases.layout_dead_strip = phase_started.elapsed();
691
+        let phase_started = Instant::now();
692
+        let icf = (opts.icf_mode == IcfMode::Safe)
693
+            .then(|| {
694
+                icf::fold_safe(
695
+                    &layout_inputs,
696
+                    &mut atom_table,
697
+                    &mut sym_table,
698
+                    dead_strip.as_ref().map(|analysis| analysis.live_atoms()),
699
+                )
700
+            })
701
+            .transpose()?;
702
+        phases.layout_icf = phase_started.elapsed();
703
+        let kept_atoms = if let Some(icf) = &icf {
704
+            Some(icf.kept_atoms())
705
+        } else {
706
+            dead_strip.as_ref().map(|analysis| analysis.live_atoms())
707
+        };
708
+        let phase_started = Instant::now();
709
+        let synthetic_plan = synth::SyntheticPlan::build_filtered_with_relocs(
289710
             &layout_inputs,
290711
             &atom_table,
291712
             &mut sym_table,
292713
             &inputs.dylibs,
714
+            kept_atoms,
715
+            &parsed_relocs,
293716
         )?;
294
-        let mut layout = Layout::build_with_synthetics(
717
+        phases.layout_synthetic_plan = phase_started.elapsed();
718
+        let icf_redirects = icf.as_ref().map(|plan| plan.redirects());
719
+        let phase_started = Instant::now();
720
+        let mut layout = Layout::build_with_synthetics_filtered(
295721
             opts.kind,
296722
             &layout_inputs,
297723
             &atom_table,
298724
             0,
299725
             Some(&synthetic_plan),
726
+            kept_atoms,
300727
         );
728
+        phases.layout_build += phase_started.elapsed();
729
+        let mut thunk_plan = None;
730
+        let mut thunk_converged = false;
731
+        for _ in 0..THUNK_PLAN_MAX_ITERATIONS {
732
+            let phase_started = Instant::now();
733
+            let next_plan = reloc::arm64::plan_thunks(
734
+                opts,
735
+                reloc::arm64::ThunkPlanningContext {
736
+                    layout: &layout,
737
+                    inputs: &layout_inputs,
738
+                    atoms: &atom_table,
739
+                    sym_table: &sym_table,
740
+                    synthetic_plan: Some(&synthetic_plan),
741
+                    icf_redirects,
742
+                    parsed_relocs: &parsed_relocs,
743
+                },
744
+            )?;
745
+            phases.layout_thunk_plan += phase_started.elapsed();
746
+            if next_plan == thunk_plan {
747
+                thunk_converged = true;
748
+                break;
749
+            }
750
+            let extra_sections = next_plan
751
+                .as_ref()
752
+                .map_or_else(Vec::new, |plan| plan.output_sections());
753
+            let split_after_atoms = next_plan
754
+                .as_ref()
755
+                .map_or_else(Vec::new, |plan| plan.split_after_atoms());
756
+            let phase_started = Instant::now();
757
+            layout = Layout::build_with_synthetics_and_extra_filtered(
758
+                opts.kind,
759
+                &layout_inputs,
760
+                &atom_table,
761
+                0,
762
+                Some(&synthetic_plan),
763
+                kept_atoms,
764
+                ExtraLayoutSections {
765
+                    extra_sections: &extra_sections,
766
+                    split_after_atoms: &split_after_atoms,
767
+                },
768
+            );
769
+            phases.layout_build += phase_started.elapsed();
770
+            thunk_plan = next_plan;
771
+        }
772
+        if !thunk_converged {
773
+            return Err(LinkError::ThunkPlanningDidNotConverge);
774
+        }
775
+        phases.layout = layout_started.elapsed();
301776
         let linkedit_context = macho::writer::LinkEditContext {
302777
             layout_inputs: &layout_inputs,
303778
             atom_table: &atom_table,
304779
             sym_table: &sym_table,
305780
             synthetic_plan: &synthetic_plan,
781
+            icf_redirects,
782
+            parsed_relocs: &parsed_relocs,
306783
         };
784
+        let phase_started = Instant::now();
307785
         let mut linkedit = None;
786
+        let mut synth_linkedit_finalize = Duration::ZERO;
787
+        let mut synth_linkedit_symbol_plan = Duration::ZERO;
788
+        let mut synth_linkedit_symbol_plan_locals = Duration::ZERO;
789
+        let mut synth_linkedit_symbol_plan_globals = Duration::ZERO;
790
+        let mut synth_linkedit_symbol_plan_strtab = Duration::ZERO;
791
+        let mut synth_linkedit_dyld_info = Duration::ZERO;
792
+        let mut synth_linkedit_dyld_bind = Duration::ZERO;
793
+        let mut synth_linkedit_dyld_rebase = Duration::ZERO;
794
+        let mut synth_linkedit_dyld_export = Duration::ZERO;
795
+        let mut synth_linkedit_metadata_tables = Duration::ZERO;
796
+        let mut synth_linkedit_code_signature = Duration::ZERO;
797
+        let mut synth_unwind = Duration::ZERO;
308798
         for _ in 0..4 {
309
-            let (next_layout, next_linkedit) = macho::writer::finalize_layout_with_linkedit(
310
-                &layout,
311
-                opts.kind,
312
-                opts,
313
-                &dylib_loads,
314
-                linkedit_context,
315
-            )?;
799
+            let phase_started = Instant::now();
800
+            let (next_layout, next_linkedit, linkedit_timings) =
801
+                macho::writer::finalize_layout_with_linkedit(
802
+                    &layout,
803
+                    opts.kind,
804
+                    opts,
805
+                    &dylib_loads,
806
+                    linkedit_context,
807
+                )?;
808
+            synth_linkedit_finalize += phase_started.elapsed();
809
+            synth_linkedit_symbol_plan += linkedit_timings.symbol_plan;
810
+            synth_linkedit_symbol_plan_locals += linkedit_timings.symbol_plan_locals;
811
+            synth_linkedit_symbol_plan_globals += linkedit_timings.symbol_plan_globals;
812
+            synth_linkedit_symbol_plan_strtab += linkedit_timings.symbol_plan_strtab;
813
+            synth_linkedit_dyld_info += linkedit_timings.dyld_info;
814
+            synth_linkedit_dyld_bind += linkedit_timings.dyld_bind;
815
+            synth_linkedit_dyld_rebase += linkedit_timings.dyld_rebase;
816
+            synth_linkedit_dyld_export += linkedit_timings.dyld_export;
817
+            synth_linkedit_metadata_tables += linkedit_timings.metadata_tables;
818
+            synth_linkedit_code_signature += linkedit_timings.code_signature;
316819
             layout = next_layout;
317820
             linkedit = Some(next_linkedit);
821
+            let phase_started = Instant::now();
318822
             let changed = synth::unwind::synthesize(
319823
                 &mut layout,
320824
                 &layout_inputs,
@@ -322,20 +826,61 @@ impl Linker {
322826
                 &sym_table,
323827
                 &synthetic_plan,
324828
             )?;
829
+            synth_unwind += phase_started.elapsed();
325830
             if !changed {
326831
                 break;
327832
             }
328833
         }
329834
         let linkedit = linkedit.expect("finalize loop always runs at least once");
835
+        phases.synth_linkedit_finalize = synth_linkedit_finalize;
836
+        phases.synth_linkedit_symbol_plan = synth_linkedit_symbol_plan;
837
+        phases.synth_linkedit_symbol_plan_locals = synth_linkedit_symbol_plan_locals;
838
+        phases.synth_linkedit_symbol_plan_globals = synth_linkedit_symbol_plan_globals;
839
+        phases.synth_linkedit_symbol_plan_strtab = synth_linkedit_symbol_plan_strtab;
840
+        phases.synth_linkedit_dyld_info = synth_linkedit_dyld_info;
841
+        phases.synth_linkedit_dyld_bind = synth_linkedit_dyld_bind;
842
+        phases.synth_linkedit_dyld_rebase = synth_linkedit_dyld_rebase;
843
+        phases.synth_linkedit_dyld_export = synth_linkedit_dyld_export;
844
+        phases.synth_linkedit_metadata_tables = synth_linkedit_metadata_tables;
845
+        phases.synth_linkedit_code_signature = synth_linkedit_code_signature;
846
+        phases.synth_unwind = synth_unwind;
847
+        phases.synth_sections = phase_started.elapsed();
848
+        let phase_started = Instant::now();
330849
         reloc::arm64::apply_layout(
331850
             &mut layout,
332851
             &layout_inputs,
333852
             &atom_table,
334853
             &sym_table,
335
-            Some(&synthetic_plan),
336
-            &linkedit,
854
+            reloc::arm64::ApplyLayoutPlan {
855
+                synthetic_plan: Some(&synthetic_plan),
856
+                thunk_plan: thunk_plan.as_ref(),
857
+                linkedit: &linkedit,
858
+                icf_redirects,
859
+                parsed_relocs: &parsed_relocs,
860
+                parallel_jobs,
861
+            },
337862
         )?;
863
+        phases.reloc_apply = phase_started.elapsed();
864
+        let folded_symbols = icf
865
+            .as_ref()
866
+            .map(|plan| plan.folded_symbols(&atom_table, &sym_table, &layout_inputs))
867
+            .unwrap_or_default();
868
+
869
+        if let Some(report) = why_live::format_explanations(
870
+            opts,
871
+            &layout_inputs,
872
+            &atom_table,
873
+            &sym_table,
874
+            entry_symbol,
875
+            dead_strip.as_ref(),
876
+            &folded_symbols,
877
+        )
878
+        .map_err(LinkError::WhyLive)?
879
+        {
880
+            print!("{report}");
881
+        }
338882
 
883
+        let phase_started = Instant::now();
339884
         let mut image = Vec::new();
340885
         let entry_point = resolve_entry_point(opts, &sym_table)?;
341886
         macho::writer::write_finalized_with_linkedit(
@@ -349,14 +894,93 @@ impl Linker {
349894
         )?;
350895
         let output = default_output_path(opts);
351896
         fs::write(&output, image)?;
897
+        if let Some(map_path) = &opts.map {
898
+            let dead_stripped = dead_strip
899
+                .as_ref()
900
+                .map(|analysis| {
901
+                    analysis.dead_stripped_symbols(&atom_table, &sym_table, &layout_inputs)
902
+                })
903
+                .unwrap_or_default();
904
+            link_map::write_link_map(
905
+                map_path,
906
+                opts,
907
+                &layout,
908
+                &layout_inputs,
909
+                &linkedit,
910
+                &folded_symbols,
911
+                &dead_stripped,
912
+            )?;
913
+        }
352914
         if opts.kind == OutputKind::Executable {
353915
             let mut perms = fs::metadata(&output)?.permissions();
354916
             let mode = perms.mode();
355917
             perms.set_mode(mode | ((mode & 0o444) >> 2));
356918
             fs::set_permissions(&output, perms)?;
357919
         }
358
-        Ok(())
920
+        phases.write_output = phase_started.elapsed();
921
+        Ok(LinkProfile {
922
+            output,
923
+            phases,
924
+            total_wall: overall_started.elapsed(),
925
+        })
926
+    }
927
+}
928
+
929
+fn resolve_library_input(opts: &LinkOptions, name: &str) -> Result<PathBuf, LinkError> {
930
+    let mut search_dirs = Vec::new();
931
+    for dir in &opts.search_paths {
932
+        search_dirs.push(dir.clone());
933
+        if let Some(root) = &opts.syslibroot {
934
+            if let Ok(stripped) = dir.strip_prefix("/") {
935
+                search_dirs.push(root.join(stripped));
936
+            }
937
+        }
938
+    }
939
+    if let Some(root) = &opts.syslibroot {
940
+        search_dirs.push(root.join("usr/lib"));
941
+    } else {
942
+        search_dirs.push(PathBuf::from("/usr/lib"));
943
+    }
944
+
945
+    let candidates = [
946
+        format!("lib{name}.tbd"),
947
+        format!("lib{name}.dylib"),
948
+        format!("lib{name}.a"),
949
+    ];
950
+    for dir in search_dirs {
951
+        for candidate in &candidates {
952
+            let path = dir.join(candidate);
953
+            if path.is_file() {
954
+                return Ok(path);
955
+            }
956
+        }
957
+    }
958
+    Err(LinkError::LibraryNotFound(name.to_string()))
959
+}
960
+
961
+fn resolve_framework_input(opts: &LinkOptions, name: &str) -> Result<PathBuf, LinkError> {
962
+    let mut roots = Vec::new();
963
+    if let Some(root) = &opts.syslibroot {
964
+        roots.push(root.join("System/Library/Frameworks"));
965
+        roots.push(root.join("Library/Frameworks"));
966
+    } else {
967
+        roots.push(PathBuf::from("/System/Library/Frameworks"));
968
+        roots.push(PathBuf::from("/Library/Frameworks"));
969
+    }
970
+
971
+    for root in roots {
972
+        let framework_dir = root.join(format!("{name}.framework"));
973
+        for candidate in [
974
+            framework_dir.join(format!("{name}.tbd")),
975
+            framework_dir.join(name),
976
+        ] {
977
+            if candidate.is_file() {
978
+                return Ok(candidate);
979
+            }
980
+        }
359981
     }
982
+
983
+    Err(LinkError::FrameworkNotFound(name.to_string()))
360984
 }
361985
 
362986
 fn default_output_path(opts: &LinkOptions) -> PathBuf {
@@ -365,30 +989,221 @@ fn default_output_path(opts: &LinkOptions) -> PathBuf {
365989
         .unwrap_or_else(|| PathBuf::from("a.out"))
366990
 }
367991
 
992
+struct LoadedObjectInput {
993
+    path: PathBuf,
994
+    load_order: usize,
995
+    bytes: Vec<u8>,
996
+    parsed: ObjectFile,
997
+    timings: InputLoadTimings,
998
+}
999
+
1000
+struct LoadedArchiveInput {
1001
+    path: PathBuf,
1002
+    load_order: usize,
1003
+    bytes: Vec<u8>,
1004
+    timings: InputLoadTimings,
1005
+}
1006
+
1007
+enum LoadedInitialInput {
1008
+    Object(Box<LoadedObjectInput>),
1009
+    Archive(LoadedArchiveInput),
1010
+}
1011
+
1012
+impl LoadedInitialInput {
1013
+    fn load_order(&self) -> usize {
1014
+        match self {
1015
+            LoadedInitialInput::Object(input) => input.load_order,
1016
+            LoadedInitialInput::Archive(input) => input.load_order,
1017
+        }
1018
+    }
1019
+}
1020
+
1021
+struct InitialLoadError {
1022
+    load_order: usize,
1023
+    error: LinkError,
1024
+}
1025
+
1026
+fn load_initial_inputs(
1027
+    loads: Vec<(usize, PathBuf)>,
1028
+    parallel_jobs: usize,
1029
+) -> Result<Vec<LoadedInitialInput>, LinkError> {
1030
+    let mut results = Vec::new();
1031
+    let mut object_jobs = Vec::new();
1032
+    for (load_order, path) in loads {
1033
+        if matches!(path.extension().and_then(|ext| ext.to_str()), Some("a")) {
1034
+            results.push(load_archive_input(path, load_order));
1035
+        } else {
1036
+            object_jobs.push((load_order, path));
1037
+        }
1038
+    }
1039
+    results.extend(load_objects_parallel(object_jobs, parallel_jobs));
1040
+    results.sort_by_key(|result| match result {
1041
+        Ok(input) => input.load_order(),
1042
+        Err(error) => error.load_order,
1043
+    });
1044
+
1045
+    let mut loaded = Vec::with_capacity(results.len());
1046
+    for result in results {
1047
+        match result {
1048
+            Ok(input) => loaded.push(input),
1049
+            Err(error) => return Err(error.error),
1050
+        }
1051
+    }
1052
+    Ok(loaded)
1053
+}
1054
+
1055
+fn load_objects_parallel(
1056
+    jobs: Vec<(usize, PathBuf)>,
1057
+    parallel_jobs: usize,
1058
+) -> Vec<Result<LoadedInitialInput, InitialLoadError>> {
1059
+    if jobs.is_empty() {
1060
+        return Vec::new();
1061
+    }
1062
+    let job_count = parallel_jobs.max(1).min(jobs.len()).max(1);
1063
+    if job_count == 1 {
1064
+        return jobs
1065
+            .into_iter()
1066
+            .map(|(load_order, path)| load_object_input(path, load_order))
1067
+            .collect();
1068
+    }
1069
+
1070
+    let queue = Arc::new(Mutex::new(VecDeque::from(jobs)));
1071
+    let (tx, rx) = mpsc::channel();
1072
+    thread::scope(|scope| {
1073
+        for _ in 0..job_count {
1074
+            let queue = Arc::clone(&queue);
1075
+            let tx = tx.clone();
1076
+            scope.spawn(move || loop {
1077
+                let Some((load_order, path)) = queue
1078
+                    .lock()
1079
+                    .expect("input load queue mutex poisoned")
1080
+                    .pop_front()
1081
+                else {
1082
+                    break;
1083
+                };
1084
+                tx.send(load_object_input(path, load_order))
1085
+                    .expect("input load receiver should stay live");
1086
+            });
1087
+        }
1088
+        drop(tx);
1089
+        rx.into_iter().collect()
1090
+    })
1091
+}
1092
+
1093
+fn load_object_input(
1094
+    path: PathBuf,
1095
+    load_order: usize,
1096
+) -> Result<LoadedInitialInput, InitialLoadError> {
1097
+    let mut timings = InputLoadTimings::default();
1098
+    let phase_started = Instant::now();
1099
+    let bytes = fs::read(&path).map_err(|error| InitialLoadError {
1100
+        load_order,
1101
+        error: LinkError::Io(error),
1102
+    })?;
1103
+    timings.read = phase_started.elapsed();
1104
+
1105
+    let phase_started = Instant::now();
1106
+    let parsed = ObjectFile::parse(&path, &bytes).map_err(|error| InitialLoadError {
1107
+        load_order,
1108
+        error: LinkError::from(error),
1109
+    })?;
1110
+    timings.object_parse = phase_started.elapsed();
1111
+
1112
+    Ok(LoadedInitialInput::Object(Box::new(LoadedObjectInput {
1113
+        path,
1114
+        load_order,
1115
+        bytes,
1116
+        parsed,
1117
+        timings,
1118
+    })))
1119
+}
1120
+
1121
+fn load_archive_input(
1122
+    path: PathBuf,
1123
+    load_order: usize,
1124
+) -> Result<LoadedInitialInput, InitialLoadError> {
1125
+    let mut timings = InputLoadTimings::default();
1126
+    let phase_started = Instant::now();
1127
+    let bytes = fs::read(&path).map_err(|error| InitialLoadError {
1128
+        load_order,
1129
+        error: LinkError::Io(error),
1130
+    })?;
1131
+    timings.read = phase_started.elapsed();
1132
+
1133
+    let phase_started = Instant::now();
1134
+    Archive::open(&path, &bytes).map_err(|error| InitialLoadError {
1135
+        load_order,
1136
+        error: LinkError::from(InputAddError::from(error)),
1137
+    })?;
1138
+    timings.archive_parse = phase_started.elapsed();
1139
+
1140
+    Ok(LoadedInitialInput::Archive(LoadedArchiveInput {
1141
+        path,
1142
+        load_order,
1143
+        bytes,
1144
+        timings,
1145
+    }))
1146
+}
1147
+
1148
+fn register_loaded_initial_input(
1149
+    inputs: &mut Inputs,
1150
+    loaded: LoadedInitialInput,
1151
+) -> InputLoadTimings {
1152
+    match loaded {
1153
+        LoadedInitialInput::Object(input) => {
1154
+            inputs.add_parsed_object(input.path, input.bytes, input.parsed, input.load_order);
1155
+            input.timings
1156
+        }
1157
+        LoadedInitialInput::Archive(input) => {
1158
+            inputs.add_validated_archive(input.path, input.bytes, input.load_order);
1159
+            input.timings
1160
+        }
1161
+    }
1162
+}
1163
+
3681164
 fn register_input(
3691165
     inputs: &mut Inputs,
3701166
     path: &std::path::Path,
3711167
     load_order: usize,
372
-) -> Result<(), LinkError> {
1168
+    include_tbd_exports: bool,
1169
+) -> Result<InputLoadTimings, LinkError> {
1170
+    let mut timings = InputLoadTimings::default();
1171
+    let phase_started = Instant::now();
3731172
     let bytes = fs::read(path)?;
1173
+    timings.read = phase_started.elapsed();
3741174
     match path.extension().and_then(|ext| ext.to_str()) {
3751175
         Some("a") => {
1176
+            let phase_started = Instant::now();
3761177
             let _ = inputs.add_archive(path.to_path_buf(), bytes, load_order)?;
1178
+            timings.archive_parse = phase_started.elapsed();
3771179
         }
3781180
         Some("dylib") => {
1181
+            let phase_started = Instant::now();
3791182
             let _ = inputs.add_dylib(path.to_path_buf(), bytes)?;
1183
+            timings.dylib_parse = phase_started.elapsed();
3801184
         }
3811185
         Some("tbd") => {
1186
+            let phase_started = Instant::now();
3821187
             let text = std::str::from_utf8(&bytes).map_err(|e| {
3831188
                 LinkError::Tbd(macho::tbd::TbdError::Schema {
3841189
                     msg: format!("TBD input is not UTF-8: {e}"),
3851190
                 })
3861191
             })?;
387
-            let docs = parse_tbd(text)?;
3881192
             let target = Target {
3891193
                 arch: Arch::Arm64,
3901194
                 platform: Platform::MacOs,
3911195
             };
1196
+            let docs = if include_tbd_exports {
1197
+                parse_tbd_for_target(text, &target)?
1198
+            } else {
1199
+                parse_tbd_metadata_for_target(text, &target)?
1200
+            };
1201
+            timings.tbd_decode = phase_started.elapsed();
1202
+
1203
+            let phase_started = Instant::now();
1204
+            if docs.is_empty() {
1205
+                return Err(LinkError::NoTbdDocument(path.to_path_buf()));
1206
+            }
3921207
             let canonical = docs
3931208
                 .iter()
3941209
                 .find(|doc| doc.parent_umbrella.is_empty())
@@ -407,31 +1222,62 @@ fn register_input(
4071222
                     .unwrap_or(DEFAULT_TBD_VERSION),
4081223
                 ordinal: inputs.next_dylib_ordinal(),
4091224
             };
410
-            let mut loaded = false;
411
-            for doc in docs
412
-                .iter()
413
-                .filter(|doc| doc.targets.iter().any(|t| t.matches_requested(&target)))
414
-            {
1225
+            for doc in &docs {
4151226
                 let file = DylibFile::from_tbd(path, doc, &target);
4161227
                 let _ =
4171228
                     inputs.add_dylib_from_file_with_meta(path.to_path_buf(), file, load.clone());
418
-                loaded = true;
419
-            }
420
-            if !loaded {
421
-                return Err(LinkError::NoTbdDocument(path.to_path_buf()));
4221229
             }
1230
+            timings.tbd_materialize = phase_started.elapsed();
4231231
         }
4241232
         _ => {
1233
+            let phase_started = Instant::now();
4251234
             let _ = inputs.add_object(path.to_path_buf(), bytes, load_order)?;
1235
+            timings.object_parse = phase_started.elapsed();
1236
+        }
1237
+    }
1238
+    Ok(timings)
1239
+}
1240
+
1241
+fn inputs_may_need_dylib_exports(inputs: &Inputs) -> Result<bool, LinkError> {
1242
+    if !inputs.archives.is_empty() {
1243
+        return Ok(true);
1244
+    }
1245
+    for i in 0..inputs.objects.len() {
1246
+        let input_id = InputId(i as u32);
1247
+        let object = inputs.object_file(input_id)?;
1248
+        if object.symbols.iter().any(|sym| {
1249
+            sym.stab_kind().is_none()
1250
+                && (sym.is_ext() || sym.is_private_ext())
1251
+                && sym.kind() == SymKind::Undef
1252
+                && !sym.is_common()
1253
+        }) {
1254
+            return Ok(true);
4261255
         }
4271256
     }
428
-    Ok(())
1257
+    Ok(false)
4291258
 }
4301259
 
4311260
 fn resolve_entry_point(
4321261
     opts: &LinkOptions,
4331262
     sym_table: &SymbolTable,
4341263
 ) -> Result<Option<macho::writer::EntryPoint>, LinkError> {
1264
+    let Some(symbol_id) = find_entry_symbol_id(opts, sym_table)? else {
1265
+        return Ok(None);
1266
+    };
1267
+    let Symbol::Defined { atom, value, .. } = sym_table.get(symbol_id) else {
1268
+        let name = sym_table.interner.resolve(sym_table.get(symbol_id).name());
1269
+        return Err(LinkError::EntrySymbolNotFound(name.to_string()));
1270
+    };
1271
+    Ok(Some(macho::writer::EntryPoint {
1272
+        atom: *atom,
1273
+        atom_value: *value,
1274
+    }))
1275
+}
1276
+
1277
+fn find_entry_symbol_id(
1278
+    opts: &LinkOptions,
1279
+    sym_table: &SymbolTable,
1280
+) -> Result<Option<resolve::SymbolId>, LinkError> {
4351281
     let name = if let Some(name) = &opts.entry {
4361282
         name.as_str()
4371283
     } else if opts.kind == OutputKind::Executable {
@@ -451,13 +1297,7 @@ fn resolve_entry_point(
4511297
     else {
4521298
         return Err(LinkError::EntrySymbolNotFound(name.to_string()));
4531299
     };
454
-    let Symbol::Defined { atom, value, .. } = sym_table.get(symbol_id) else {
455
-        return Err(LinkError::EntrySymbolNotFound(name.to_string()));
456
-    };
457
-    Ok(Some(macho::writer::EntryPoint {
458
-        atom: *atom,
459
-        atom_value: *value,
460
-    }))
1300
+    Ok(Some(symbol_id))
4611301
 }
4621302
 
4631303
 fn symbol_defined(sym_table: &SymbolTable, name: &str) -> bool {
src/loh.rsadded
552 lines changed — click to load
@@ -0,0 +1,552 @@
1
+//! ARM64 Linker Optimization Hints (LOH).
2
+//!
3
+//! `LC_LINKER_OPTIMIZATION_HINT` stores a ULEB128 stream of `(kind, argc,
4
+//! args...)` records. The args are file offsets of the participating
5
+//! instructions.
6
+
7
+use std::collections::HashSet;
8
+use std::fmt;
9
+
10
+use crate::layout::Layout;
11
+use crate::leb::{read_uleb, write_uleb};
12
+use crate::macho::reader::ReadError;
13
+use crate::macho::writer::LinkEditPlan;
14
+
15
+pub const LOH_ARM64_ADRP_LDR: u32 = 2;
16
+pub const LOH_ARM64_ADRP_LDR_GOT_LDR: u32 = 4;
17
+pub const LOH_ARM64_ADRP_ADD: u32 = 7;
18
+pub const LOH_ARM64_ADRP_LDR_GOT: u32 = 8;
19
+const NOP: u32 = 0xd503_201f;
20
+
21
+#[derive(Debug, Clone, PartialEq, Eq)]
22
+pub struct LohEntry {
23
+    pub kind: u32,
24
+    pub args: Vec<u32>,
25
+}
26
+
27
+#[derive(Debug, Clone, PartialEq, Eq)]
28
+pub struct LohError(String);
29
+
30
+impl fmt::Display for LohError {
31
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
32
+        write!(f, "LOH relaxation error: {}", self.0)
33
+    }
34
+}
35
+
36
+impl std::error::Error for LohError {}
37
+
38
+impl From<ReadError> for LohError {
39
+    fn from(value: ReadError) -> Self {
40
+        Self(value.to_string())
41
+    }
42
+}
43
+
44
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45
+struct LocatedWord {
46
+    section_idx: usize,
47
+    atom_idx: usize,
48
+    word_off: usize,
49
+    addr: u64,
50
+    insn: u32,
51
+}
52
+
53
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54
+enum LiteralLoadKind {
55
+    W,
56
+    X,
57
+    S,
58
+    D,
59
+    Q,
60
+}
61
+
62
+pub fn parse_loh_blob(bytes: &[u8]) -> Result<Vec<LohEntry>, ReadError> {
63
+    let mut out = Vec::new();
64
+    let mut cursor = 0usize;
65
+    while cursor < bytes.len() {
66
+        if bytes[cursor..].iter().all(|&byte| byte == 0) {
67
+            break;
68
+        }
69
+        let at_offset = cursor as u32;
70
+        let (kind, used) = read_uleb(&bytes[cursor..])?;
71
+        cursor += used;
72
+        let (argc, used) = read_uleb(&bytes[cursor..])?;
73
+        cursor += used;
74
+        let kind = u32::try_from(kind).map_err(|_| ReadError::BadRelocation {
75
+            at_offset,
76
+            reason: "LOH kind overflows u32",
77
+        })?;
78
+        let argc = usize::try_from(argc).map_err(|_| ReadError::BadRelocation {
79
+            at_offset,
80
+            reason: "LOH argcount overflows usize",
81
+        })?;
82
+        let mut args = Vec::with_capacity(argc);
83
+        for _ in 0..argc {
84
+            let (arg, used) = read_uleb(&bytes[cursor..])?;
85
+            cursor += used;
86
+            args.push(u32::try_from(arg).map_err(|_| ReadError::BadRelocation {
87
+                at_offset,
88
+                reason: "LOH arg overflows u32",
89
+            })?);
90
+        }
91
+        out.push(LohEntry { kind, args });
92
+    }
93
+    Ok(out)
94
+}
95
+
96
+pub fn write_loh_blob(entries: &[LohEntry]) -> Vec<u8> {
97
+    let mut out = Vec::new();
98
+    for entry in entries {
99
+        write_uleb(entry.kind as u64, &mut out);
100
+        write_uleb(entry.args.len() as u64, &mut out);
101
+        for &arg in &entry.args {
102
+            write_uleb(arg as u64, &mut out);
103
+        }
104
+    }
105
+    out
106
+}
107
+
108
+pub fn relax_layout(
109
+    layout: &mut Layout,
110
+    linkedit: &LinkEditPlan,
111
+    enabled: bool,
112
+) -> Result<(), LohError> {
113
+    if !enabled || linkedit.loh.is_none() || linkedit.loh_bytes().is_empty() {
114
+        return Ok(());
115
+    }
116
+
117
+    let mut entries = parse_loh_blob(linkedit.loh_bytes())?;
118
+    entries.sort_by(|lhs, rhs| {
119
+        rhs.args
120
+            .len()
121
+            .cmp(&lhs.args.len())
122
+            .then_with(|| lhs.args.first().cmp(&rhs.args.first()))
123
+            .then_with(|| lhs.kind.cmp(&rhs.kind))
124
+    });
125
+    let mut rewritten = HashSet::new();
126
+    for entry in entries {
127
+        match entry.kind {
128
+            LOH_ARM64_ADRP_LDR => relax_adrp_ldr(layout, &entry, &mut rewritten)?,
129
+            LOH_ARM64_ADRP_LDR_GOT_LDR => relax_adrp_ldr_got_ldr(layout, &entry, &mut rewritten)?,
130
+            LOH_ARM64_ADRP_ADD => relax_adrp_add(layout, &entry, &mut rewritten)?,
131
+            LOH_ARM64_ADRP_LDR_GOT => relax_adrp_ldr_got(layout, &entry, &mut rewritten)?,
132
+            _ => {}
133
+        }
134
+    }
135
+    Ok(())
136
+}
137
+
138
+fn relax_adrp_add(
139
+    layout: &mut Layout,
140
+    entry: &LohEntry,
141
+    rewritten: &mut HashSet<u64>,
142
+) -> Result<(), LohError> {
143
+    if entry.args.len() != 2 {
144
+        return Ok(());
145
+    }
146
+    let adrp_off = entry.args[0] as u64;
147
+    let add_off = entry.args[1] as u64;
148
+    if !claim_offsets(rewritten, &[adrp_off, add_off]) {
149
+        return Ok(());
150
+    }
151
+    let adrp = locate_word(layout, adrp_off)?;
152
+    let add = locate_word(layout, add_off)?;
153
+    let Some(target) = decode_adrp_add_target(adrp.insn, add.insn, adrp.addr) else {
154
+        return Ok(());
155
+    };
156
+    let dest = (add.insn & 0x1f) as u8;
157
+    let Some(adr) = encode_adr(target, adrp.addr, dest) else {
158
+        return Ok(());
159
+    };
160
+    write_word(layout, adrp, adr)?;
161
+    write_word(layout, add, NOP)?;
162
+    Ok(())
163
+}
164
+
165
+fn relax_adrp_ldr(
166
+    layout: &mut Layout,
167
+    entry: &LohEntry,
168
+    rewritten: &mut HashSet<u64>,
169
+) -> Result<(), LohError> {
170
+    if entry.args.len() != 2 {
171
+        return Ok(());
172
+    }
173
+    let adrp_off = entry.args[0] as u64;
174
+    let ldr_off = entry.args[1] as u64;
175
+    if !claim_offsets(rewritten, &[adrp_off, ldr_off]) {
176
+        return Ok(());
177
+    }
178
+    let adrp = locate_word(layout, adrp_off)?;
179
+    let ldr = locate_word(layout, ldr_off)?;
180
+    let Some(target) = decode_adrp_ldr_target(adrp.insn, ldr.insn, adrp.addr) else {
181
+        return Ok(());
182
+    };
183
+    let Some(literal) = encode_ldr_literal(ldr.insn, target, ldr.addr) else {
184
+        return Ok(());
185
+    };
186
+    write_word(layout, adrp, NOP)?;
187
+    write_word(layout, ldr, literal)?;
188
+    Ok(())
189
+}
190
+
191
+fn relax_adrp_ldr_got(
192
+    layout: &mut Layout,
193
+    entry: &LohEntry,
194
+    rewritten: &mut HashSet<u64>,
195
+) -> Result<(), LohError> {
196
+    if entry.args.len() != 2 {
197
+        return Ok(());
198
+    }
199
+    let adrp_off = entry.args[0] as u64;
200
+    let ldr_off = entry.args[1] as u64;
201
+    if !claim_offsets(rewritten, &[adrp_off, ldr_off]) {
202
+        return Ok(());
203
+    }
204
+    let adrp = locate_word(layout, adrp_off)?;
205
+    let ldr = locate_word(layout, ldr_off)?;
206
+    let Some(got_slot_addr) = decode_adrp_ldr_target(adrp.insn, ldr.insn, adrp.addr) else {
207
+        return Ok(());
208
+    };
209
+    if pageoff_load_kind(ldr.insn) != Some(LiteralLoadKind::X) {
210
+        return Ok(());
211
+    }
212
+    let Some(local_target) = read_u64_at_addr(layout, got_slot_addr) else {
213
+        return Ok(());
214
+    };
215
+    if !points_into_output(layout, local_target) {
216
+        return Ok(());
217
+    }
218
+    let dest = (ldr.insn & 0x1f) as u8;
219
+    let Some(adr) = encode_adr(local_target, adrp.addr, dest) else {
220
+        return Ok(());
221
+    };
222
+    write_word(layout, adrp, adr)?;
223
+    write_word(layout, ldr, NOP)?;
224
+    Ok(())
225
+}
226
+
227
+fn relax_adrp_ldr_got_ldr(
228
+    layout: &mut Layout,
229
+    entry: &LohEntry,
230
+    rewritten: &mut HashSet<u64>,
231
+) -> Result<(), LohError> {
232
+    if entry.args.len() != 3 {
233
+        return Ok(());
234
+    }
235
+    let adrp_off = entry.args[0] as u64;
236
+    let got_ldr_off = entry.args[1] as u64;
237
+    let final_ldr_off = entry.args[2] as u64;
238
+    if !claim_offsets(rewritten, &[adrp_off, got_ldr_off, final_ldr_off]) {
239
+        return Ok(());
240
+    }
241
+    let adrp = locate_word(layout, adrp_off)?;
242
+    let got_ldr = locate_word(layout, got_ldr_off)?;
243
+    let final_ldr = locate_word(layout, final_ldr_off)?;
244
+    let Some(got_slot_addr) = decode_adrp_ldr_target(adrp.insn, got_ldr.insn, adrp.addr) else {
245
+        return Ok(());
246
+    };
247
+    if pageoff_load_kind(got_ldr.insn) != Some(LiteralLoadKind::X) {
248
+        return Ok(());
249
+    }
250
+    let got_dest = (got_ldr.insn & 0x1f) as u8;
251
+    if load_base_reg(final_ldr.insn) != Some(got_dest) {
252
+        return Ok(());
253
+    }
254
+    let Some(local_target) = read_u64_at_addr(layout, got_slot_addr) else {
255
+        return Ok(());
256
+    };
257
+    if !points_into_output(layout, local_target) {
258
+        return Ok(());
259
+    }
260
+    let Some(adr) = encode_adr(local_target, adrp.addr, got_dest) else {
261
+        return Ok(());
262
+    };
263
+    write_word(layout, adrp, adr)?;
264
+    write_word(layout, got_ldr, NOP)?;
265
+    Ok(())
266
+}
267
+
268
+fn claim_offsets(rewritten: &mut HashSet<u64>, offsets: &[u64]) -> bool {
269
+    if offsets.iter().any(|offset| rewritten.contains(offset)) {
270
+        return false;
271
+    }
272
+    rewritten.extend(offsets.iter().copied());
273
+    true
274
+}
275
+
276
+fn locate_word(layout: &Layout, file_offset: u64) -> Result<LocatedWord, LohError> {
277
+    for (section_idx, section) in layout.sections.iter().enumerate() {
278
+        for (atom_idx, atom) in section.atoms.iter().enumerate() {
279
+            let start = section.file_off + atom.offset;
280
+            let end = start + atom.data.len() as u64;
281
+            if !(start <= file_offset && file_offset + 4 <= end) {
282
+                continue;
283
+            }
284
+            let word_off = (file_offset - start) as usize;
285
+            let bytes = atom.data.get(word_off..word_off + 4).ok_or_else(|| {
286
+                LohError(format!(
287
+                    "instruction read OOB at file offset 0x{file_offset:x}"
288
+                ))
289
+            })?;
290
+            return Ok(LocatedWord {
291
+                section_idx,
292
+                atom_idx,
293
+                word_off,
294
+                addr: section.addr + atom.offset + word_off as u64,
295
+                insn: u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]),
296
+            });
297
+        }
298
+    }
299
+    Err(LohError(format!(
300
+        "LOH instruction offset 0x{file_offset:x} did not resolve to an output atom"
301
+    )))
302
+}
303
+
304
+fn write_word(layout: &mut Layout, word: LocatedWord, insn: u32) -> Result<(), LohError> {
305
+    let atom = &mut layout.sections[word.section_idx].atoms[word.atom_idx];
306
+    let bytes = atom
307
+        .data
308
+        .get_mut(word.word_off..word.word_off + 4)
309
+        .ok_or_else(|| {
310
+            LohError(format!(
311
+                "instruction write OOB at section {} atom {} word {}",
312
+                word.section_idx, word.atom_idx, word.word_off
313
+            ))
314
+        })?;
315
+    bytes.copy_from_slice(&insn.to_le_bytes());
316
+    Ok(())
317
+}
318
+
319
+fn decode_adrp_add_target(adrp: u32, add: u32, place: u64) -> Option<u64> {
320
+    if !is_adrp(adrp) || !is_add_imm_64(add) {
321
+        return None;
322
+    }
323
+    let rd = (adrp & 0x1f) as u8;
324
+    let add_rd = (add & 0x1f) as u8;
325
+    let add_rn = ((add >> 5) & 0x1f) as u8;
326
+    if rd == 31 || add_rd != rd || add_rn != rd {
327
+        return None;
328
+    }
329
+    let adrp_immlo = ((adrp >> 29) & 0x3) as i64;
330
+    let adrp_immhi = ((adrp >> 5) & 0x7ffff) as i64;
331
+    let adrp_pages = sign_extend_21((adrp_immhi << 2) | adrp_immlo);
332
+    let adrp_base = ((place as i64) & !0xfff) + (adrp_pages << 12);
333
+    let low = ((add >> 10) & 0xfff) as u64;
334
+    Some((adrp_base as u64) + low)
335
+}
336
+
337
+fn decode_adrp_ldr_target(adrp: u32, ldr: u32, place: u64) -> Option<u64> {
338
+    let _kind = pageoff_load_kind(ldr)?;
339
+    let base = ((ldr >> 5) & 0x1f) as u8;
340
+    let adrp_reg = (adrp & 0x1f) as u8;
341
+    if adrp_reg == 31 || base != adrp_reg {
342
+        return None;
343
+    }
344
+    let adrp_immlo = ((adrp >> 29) & 0x3) as i64;
345
+    let adrp_immhi = ((adrp >> 5) & 0x7ffff) as i64;
346
+    let adrp_pages = sign_extend_21((adrp_immhi << 2) | adrp_immlo);
347
+    let adrp_base = ((place as i64) & !0xfff) + (adrp_pages << 12);
348
+    let shift = pageoff_shift(ldr);
349
+    let low = (((ldr >> 10) & 0xfff) as u64) << shift;
350
+    Some((adrp_base as u64) + low)
351
+}
352
+
353
+fn encode_adr(target: u64, place: u64, reg: u8) -> Option<u32> {
354
+    if reg == 31 {
355
+        return None;
356
+    }
357
+    let delta = (target as i64).wrapping_sub(place as i64);
358
+    if !fits_signed(delta, 21) {
359
+        return None;
360
+    }
361
+    let encoded = (delta as u32) & 0x1f_ffff;
362
+    let immlo = encoded & 0x3;
363
+    let immhi = (encoded >> 2) & 0x7ffff;
364
+    Some(0x1000_0000 | (immlo << 29) | (immhi << 5) | reg as u32)
365
+}
366
+
367
+fn encode_ldr_literal(insn: u32, target: u64, place: u64) -> Option<u32> {
368
+    let kind = pageoff_load_kind(insn)?;
369
+    let delta = (target as i64).wrapping_sub(place as i64);
370
+    if delta & 0b11 != 0 {
371
+        return None;
372
+    }
373
+    let imm = delta >> 2;
374
+    if !fits_signed(imm, 19) {
375
+        return None;
376
+    }
377
+    let encoded = (imm as u32) & 0x7ffff;
378
+    let rt = insn & 0x1f;
379
+    let base = match kind {
380
+        LiteralLoadKind::W => 0x1800_0000,
381
+        LiteralLoadKind::X => 0x5800_0000,
382
+        LiteralLoadKind::S => 0x1c00_0000,
383
+        LiteralLoadKind::D => 0x5c00_0000,
384
+        LiteralLoadKind::Q => 0x9c00_0000,
385
+    };
386
+    Some(base | (encoded << 5) | rt)
387
+}
388
+
389
+fn is_adrp(insn: u32) -> bool {
390
+    (insn & 0x9f00_0000) == 0x9000_0000
391
+}
392
+
393
+fn is_add_imm_64(insn: u32) -> bool {
394
+    (insn & 0xffc0_0000) == 0x9100_0000
395
+}
396
+
397
+fn pageoff_load_kind(insn: u32) -> Option<LiteralLoadKind> {
398
+    match insn & 0xffc0_0000 {
399
+        0xb940_0000 => Some(LiteralLoadKind::W),
400
+        0xf940_0000 => Some(LiteralLoadKind::X),
401
+        0xbd40_0000 => Some(LiteralLoadKind::S),
402
+        0xfd40_0000 => Some(LiteralLoadKind::D),
403
+        0x3dc0_0000 => Some(LiteralLoadKind::Q),
404
+        _ => None,
405
+    }
406
+}
407
+
408
+fn load_base_reg(insn: u32) -> Option<u8> {
409
+    match insn & 0xffc0_0000 {
410
+        0xb940_0000 | 0xf940_0000 | 0xbd40_0000 | 0xfd40_0000 | 0x3dc0_0000 | 0x7940_0000
411
+        | 0x3940_0000 => Some(((insn >> 5) & 0x1f) as u8),
412
+        _ => None,
413
+    }
414
+}
415
+
416
+fn pageoff_shift(insn: u32) -> u64 {
417
+    if is_simd_fp_pageoff(insn) {
418
+        let size = ((insn >> 30) & 0b11) as u64;
419
+        let opc = ((insn >> 22) & 0b11) as u64;
420
+        if size == 0 && (opc & 0b10) != 0 {
421
+            4
422
+        } else {
423
+            size
424
+        }
425
+    } else {
426
+        ((insn >> 30) & 0b11) as u64
427
+    }
428
+}
429
+
430
+fn is_simd_fp_pageoff(insn: u32) -> bool {
431
+    ((insn >> 24) & 0b111) == 0b101
432
+}
433
+
434
+fn points_into_output(layout: &Layout, addr: u64) -> bool {
435
+    layout
436
+        .sections
437
+        .iter()
438
+        .any(|section| section.addr <= addr && addr < section.addr + section.size)
439
+}
440
+
441
+fn read_u64_at_addr(layout: &Layout, addr: u64) -> Option<u64> {
442
+    let bytes = read_bytes_at_addr(layout, addr, 8)?;
443
+    Some(u64::from_le_bytes(bytes.try_into().ok()?))
444
+}
445
+
446
+fn read_bytes_at_addr(layout: &Layout, addr: u64, len: usize) -> Option<Vec<u8>> {
447
+    for section in &layout.sections {
448
+        for atom in &section.atoms {
449
+            let start = section.addr + atom.offset;
450
+            let end = start + atom.data.len() as u64;
451
+            if start <= addr && addr + len as u64 <= end {
452
+                let word_off = (addr - start) as usize;
453
+                return Some(atom.data.get(word_off..word_off + len)?.to_vec());
454
+            }
455
+        }
456
+        if !section.synthetic_data.is_empty() {
457
+            let start = section.addr + section.synthetic_offset;
458
+            let end = start + section.synthetic_data.len() as u64;
459
+            if start <= addr && addr + len as u64 <= end {
460
+                let word_off = (addr - start) as usize;
461
+                return Some(
462
+                    section
463
+                        .synthetic_data
464
+                        .get(word_off..word_off + len)?
465
+                        .to_vec(),
466
+                );
467
+            }
468
+        }
469
+    }
470
+    None
471
+}
472
+
473
+fn fits_signed(value: i64, bits: u32) -> bool {
474
+    let min = -(1i64 << (bits - 1));
475
+    let max = (1i64 << (bits - 1)) - 1;
476
+    (min..=max).contains(&value)
477
+}
478
+
479
+fn sign_extend_21(value: i64) -> i64 {
480
+    if value & (1 << 20) != 0 {
481
+        value | !0x1f_ffff
482
+    } else {
483
+        value
484
+    }
485
+}
486
+
487
+#[cfg(test)]
488
+mod tests {
489
+    use super::*;
490
+
491
+    #[test]
492
+    fn loh_blob_round_trips() {
493
+        let entries = vec![
494
+            LohEntry {
495
+                kind: LOH_ARM64_ADRP_ADD,
496
+                args: vec![0, 4],
497
+            },
498
+            LohEntry {
499
+                kind: LOH_ARM64_ADRP_LDR_GOT_LDR,
500
+                args: vec![8, 12, 16],
501
+            },
502
+        ];
503
+        let blob = write_loh_blob(&entries);
504
+        assert_eq!(parse_loh_blob(&blob).unwrap(), entries);
505
+    }
506
+
507
+    #[test]
508
+    fn loh_blob_ignores_trailing_zero_padding() {
509
+        let mut blob = write_loh_blob(&[LohEntry {
510
+            kind: LOH_ARM64_ADRP_ADD,
511
+            args: vec![0, 4],
512
+        }]);
513
+        while !blob.len().is_multiple_of(8) {
514
+            blob.push(0);
515
+        }
516
+        assert_eq!(
517
+            parse_loh_blob(&blob).unwrap(),
518
+            vec![LohEntry {
519
+                kind: LOH_ARM64_ADRP_ADD,
520
+                args: vec![0, 4],
521
+            }]
522
+        );
523
+    }
524
+
525
+    #[test]
526
+    fn encode_adr_round_trips_small_delta() {
527
+        let place = 0x1_0000_1000;
528
+        let target = place + 0x48;
529
+        let adr = encode_adr(target, place, 9).unwrap();
530
+        assert_eq!(adr & 0x1f, 9);
531
+        let immlo = ((adr >> 29) & 0x3) as i64;
532
+        let immhi = ((adr >> 5) & 0x7ffff) as i64;
533
+        let delta = sign_extend_21((immhi << 2) | immlo);
534
+        assert_eq!(place.wrapping_add_signed(delta), target);
535
+    }
536
+
537
+    #[test]
538
+    fn encode_ldr_literal_round_trips_x_load() {
539
+        let place = 0x1_0000_2004;
540
+        let target = place + 0x1fc;
541
+        let insn = 0xf940_0005u32;
542
+        let literal = encode_ldr_literal(insn, target, place).unwrap();
543
+        assert_eq!(literal & 0x1f, 5);
544
+        let imm = ((literal >> 5) & 0x7ffff) as i64;
545
+        let delta = if imm & (1 << 18) != 0 {
546
+            (imm | !0x7ffff) << 2
547
+        } else {
548
+            imm << 2
549
+        };
550
+        assert_eq!(place.wrapping_add_signed(delta), target);
551
+    }
552
+}
src/macho/dylib.rsmodified
15 lines changed — click to load
@@ -38,6 +38,15 @@ impl DylibLoadKind {
3838
             _ => None,
3939
         }
4040
     }
41
+
42
+    pub fn load_cmd(self) -> u32 {
43
+        match self {
44
+            DylibLoadKind::Normal => LC_LOAD_DYLIB,
45
+            DylibLoadKind::Weak => LC_LOAD_WEAK_DYLIB,
46
+            DylibLoadKind::Reexport => LC_REEXPORT_DYLIB,
47
+            DylibLoadKind::Upward => LC_LOAD_UPWARD_DYLIB,
48
+        }
49
+    }
4150
 }
4251
 
4352
 /// One dylib this file depends on. Ordinals match the two-level namespace
src/macho/tbd.rsmodified
846 lines changed — click to load
@@ -123,17 +123,636 @@ pub fn parse_tbd(input: &str) -> Result<Vec<Tbd>, TbdError> {
123123
     let docs = parse_documents(input)?;
124124
     let mut out = Vec::with_capacity(docs.len());
125125
     for d in docs {
126
-        out.push(decode_document(&d)?);
126
+        out.push(decode_document(d)?);
127127
     }
128128
     Ok(out)
129129
 }
130130
 
131
-fn decode_document(doc: &Document) -> Result<Tbd, TbdError> {
132
-    let m = doc
133
-        .root
134
-        .as_mapping()
135
-        .ok_or_else(|| schema("top level of a TBD document must be a mapping"))?;
131
+/// Parse a TBD for the linker hot path, keeping only documents and scoped
132
+/// symbol lists that can satisfy `target`.
133
+///
134
+/// The fast path handles Apple's emitted TAPI v4 shape directly and avoids
135
+/// constructing the generic YAML `Value` tree for the thousands of symbols in
136
+/// libSystem.tbd. If it sees a shape outside that subset, it falls back to the
137
+/// generic decoder and applies the same target filter afterward.
138
+pub fn parse_tbd_for_target(input: &str, target: &Target) -> Result<Vec<Tbd>, TbdError> {
139
+    match parse_tbd_for_target_direct(input, target, true) {
140
+        Ok(docs) => Ok(docs),
141
+        Err(_) => {
142
+            let docs = parse_tbd(input)?;
143
+            Ok(filter_docs_for_target(docs, target))
144
+        }
145
+    }
146
+}
147
+
148
+/// Parse only load-command-relevant TBD metadata for `target`.
149
+///
150
+/// This is used for links that have no unresolved dylib symbols; emitting the
151
+/// requested `LC_LOAD_DYLIB` does not require materializing libSystem's full
152
+/// export surface.
153
+pub fn parse_tbd_metadata_for_target(input: &str, target: &Target) -> Result<Vec<Tbd>, TbdError> {
154
+    match parse_tbd_for_target_direct(input, target, false) {
155
+        Ok(docs) => Ok(docs),
156
+        Err(_) => {
157
+            let docs = parse_tbd(input)?;
158
+            Ok(filter_docs_for_target_metadata(docs, target))
159
+        }
160
+    }
161
+}
162
+
163
+fn filter_docs_for_target(mut docs: Vec<Tbd>, target: &Target) -> Vec<Tbd> {
164
+    docs.retain(|doc| targets_match(&doc.targets, target));
165
+    for doc in &mut docs {
166
+        doc.parent_umbrella
167
+            .retain(|scoped| targets_match(&scoped.targets, target));
168
+        doc.allowable_clients
169
+            .retain(|scoped| targets_match(&scoped.targets, target));
170
+        doc.reexported_libraries
171
+            .retain(|scoped| targets_match(&scoped.targets, target));
172
+        doc.exports
173
+            .retain(|scoped| targets_match(&scoped.targets, target));
174
+        doc.reexports
175
+            .retain(|scoped| targets_match(&scoped.targets, target));
176
+    }
177
+    docs
178
+}
136179
 
180
+fn filter_docs_for_target_metadata(docs: Vec<Tbd>, target: &Target) -> Vec<Tbd> {
181
+    let mut docs = filter_docs_for_target(docs, target);
182
+    for doc in &mut docs {
183
+        doc.reexported_libraries.clear();
184
+        doc.exports.clear();
185
+        doc.reexports.clear();
186
+    }
187
+    docs
188
+}
189
+
190
+fn parse_tbd_for_target_direct(
191
+    input: &str,
192
+    target: &Target,
193
+    include_exports: bool,
194
+) -> Result<Vec<Tbd>, TbdError> {
195
+    let lines: Vec<&str> = input.lines().collect();
196
+    let mut docs = Vec::new();
197
+    let mut i = 0usize;
198
+    while i < lines.len() {
199
+        let Some(trimmed) = direct_trimmed(lines[i]) else {
200
+            i += 1;
201
+            continue;
202
+        };
203
+        if trimmed.starts_with("%YAML") || trimmed.starts_with("...") {
204
+            i += 1;
205
+            continue;
206
+        }
207
+        if trimmed.starts_with("---") {
208
+            i += 1;
209
+        }
210
+
211
+        let (doc, next) = parse_direct_document(&lines, i, target, include_exports)?;
212
+        i = next;
213
+        if doc.install_name.is_empty() && doc.targets.is_empty() {
214
+            continue;
215
+        }
216
+        if targets_match(&doc.targets, target) {
217
+            docs.push(doc);
218
+        }
219
+    }
220
+    Ok(docs)
221
+}
222
+
223
+fn parse_direct_document(
224
+    lines: &[&str],
225
+    mut i: usize,
226
+    target: &Target,
227
+    include_exports: bool,
228
+) -> Result<(Tbd, usize), TbdError> {
229
+    let mut tbd = Tbd::default();
230
+    while i < lines.len() {
231
+        let Some(trimmed) = direct_trimmed(lines[i]) else {
232
+            i += 1;
233
+            continue;
234
+        };
235
+        if trimmed.starts_with("---") || trimmed.starts_with("...") {
236
+            break;
237
+        }
238
+        if direct_indent(lines[i]) != 0 {
239
+            return Err(schema("unexpected nested TBD line at document root"));
240
+        }
241
+        let (key, rest) =
242
+            direct_key_value(trimmed).ok_or_else(|| schema("expected top-level TBD key"))?;
243
+        match key {
244
+            "tbd-version" => {
245
+                tbd.version = parse_direct_scalar(rest)
246
+                    .parse()
247
+                    .map_err(|_| schema(&format!("tbd-version must parse as a u32: {rest:?}")))?;
248
+                i += 1;
249
+            }
250
+            "targets" => {
251
+                let (targets, next) = parse_direct_targets(lines, i, rest)?;
252
+                tbd.targets = targets;
253
+                i = next;
254
+            }
255
+            "install-name" => {
256
+                tbd.install_name = parse_direct_scalar(rest);
257
+                i += 1;
258
+            }
259
+            "current-version" => {
260
+                tbd.current_version = Some(parse_direct_scalar(rest));
261
+                i += 1;
262
+            }
263
+            "compatibility-version" => {
264
+                tbd.compatibility_version = Some(parse_direct_scalar(rest));
265
+                i += 1;
266
+            }
267
+            "parent-umbrella" => {
268
+                let (value, next) = parse_direct_scoped_scalars(lines, i + 1, target, "umbrella")?;
269
+                tbd.parent_umbrella = value;
270
+                i = next;
271
+            }
272
+            "allowable-clients" => {
273
+                let (value, next) = parse_direct_scoped_lists(lines, i + 1, target, "clients")?;
274
+                tbd.allowable_clients = value;
275
+                i = next;
276
+            }
277
+            "reexported-libraries" if include_exports => {
278
+                let (value, next) = parse_direct_scoped_lists(lines, i + 1, target, "libraries")?;
279
+                tbd.reexported_libraries = value;
280
+                i = next;
281
+            }
282
+            "reexported-libraries" => {
283
+                i = skip_direct_value(lines, i + 1);
284
+            }
285
+            "exports" if include_exports => {
286
+                let (value, next) = parse_direct_scoped_symbols(lines, i + 1, target)?;
287
+                tbd.exports = value;
288
+                i = next;
289
+            }
290
+            "exports" => {
291
+                i = skip_direct_value(lines, i + 1);
292
+            }
293
+            "reexports" if include_exports => {
294
+                let (value, next) = parse_direct_scoped_symbols(lines, i + 1, target)?;
295
+                tbd.reexports = value;
296
+                i = next;
297
+            }
298
+            "reexports" => {
299
+                i = skip_direct_value(lines, i + 1);
300
+            }
301
+            _ => {
302
+                i = skip_direct_value(lines, i + 1);
303
+            }
304
+        }
305
+    }
306
+
307
+    if !tbd.install_name.is_empty() || !tbd.targets.is_empty() {
308
+        if tbd.install_name.is_empty() {
309
+            return Err(schema("TBD document missing required 'install-name'"));
310
+        }
311
+        if tbd.targets.is_empty() {
312
+            return Err(schema("TBD document missing required 'targets'"));
313
+        }
314
+    }
315
+    Ok((tbd, i))
316
+}
317
+
318
+fn parse_direct_scoped_scalars(
319
+    lines: &[&str],
320
+    mut i: usize,
321
+    target: &Target,
322
+    value_key: &str,
323
+) -> Result<(Vec<Scoped<String>>, usize), TbdError> {
324
+    let mut out = Vec::new();
325
+    let ctx = DirectCtx { lines, target };
326
+    while let Some(entry) = direct_entry_start(lines, i) {
327
+        let mut state = DirectScopeState::default();
328
+        let mut value = None;
329
+        let (key, rest) = entry?;
330
+        apply_direct_scalar_pair((key, rest), ctx, &mut i, &mut state, value_key, &mut value)?;
331
+        while let Some((key, rest)) = direct_nested_pair(lines, i) {
332
+            apply_direct_scalar_pair((key, rest), ctx, &mut i, &mut state, value_key, &mut value)?;
333
+        }
334
+        if state.include == Some(true) {
335
+            out.push(Scoped {
336
+                targets: state
337
+                    .targets
338
+                    .ok_or_else(|| schema("missing required key \"targets\""))?,
339
+                value: value.unwrap_or_default(),
340
+            });
341
+        }
342
+    }
343
+    Ok((out, i))
344
+}
345
+
346
+type DirectScopedList = Vec<Scoped<Vec<String>>>;
347
+type DirectScopedListResult = Result<(DirectScopedList, usize), TbdError>;
348
+
349
+#[derive(Clone, Copy)]
350
+struct DirectCtx<'a, 't> {
351
+    lines: &'a [&'a str],
352
+    target: &'t Target,
353
+}
354
+
355
+#[derive(Default)]
356
+struct DirectScopeState {
357
+    targets: Option<Vec<Target>>,
358
+    include: Option<bool>,
359
+}
360
+
361
+fn parse_direct_scoped_lists(
362
+    lines: &[&str],
363
+    mut i: usize,
364
+    target: &Target,
365
+    value_key: &str,
366
+) -> DirectScopedListResult {
367
+    let mut out = Vec::new();
368
+    let ctx = DirectCtx { lines, target };
369
+    while let Some(entry) = direct_entry_start(lines, i) {
370
+        let mut state = DirectScopeState::default();
371
+        let mut value = Vec::new();
372
+        let (key, rest) = entry?;
373
+        apply_direct_list_pair((key, rest), ctx, &mut i, &mut state, value_key, &mut value)?;
374
+        while let Some((key, rest)) = direct_nested_pair(lines, i) {
375
+            apply_direct_list_pair((key, rest), ctx, &mut i, &mut state, value_key, &mut value)?;
376
+        }
377
+        if state.include == Some(true) {
378
+            out.push(Scoped {
379
+                targets: state
380
+                    .targets
381
+                    .ok_or_else(|| schema("missing required key \"targets\""))?,
382
+                value,
383
+            });
384
+        }
385
+    }
386
+    Ok((out, i))
387
+}
388
+
389
+fn parse_direct_scoped_symbols(
390
+    lines: &[&str],
391
+    mut i: usize,
392
+    target: &Target,
393
+) -> Result<(Vec<Scoped<SymbolLists>>, usize), TbdError> {
394
+    let mut out = Vec::new();
395
+    let ctx = DirectCtx { lines, target };
396
+    while let Some(entry) = direct_entry_start(lines, i) {
397
+        let mut state = DirectScopeState::default();
398
+        let mut lists = SymbolLists::default();
399
+        let (key, rest) = entry?;
400
+        apply_direct_symbol_pair((key, rest), ctx, &mut i, &mut state, &mut lists)?;
401
+        while let Some((key, rest)) = direct_nested_pair(lines, i) {
402
+            apply_direct_symbol_pair((key, rest), ctx, &mut i, &mut state, &mut lists)?;
403
+        }
404
+        if state.include == Some(true) {
405
+            out.push(Scoped {
406
+                targets: state
407
+                    .targets
408
+                    .ok_or_else(|| schema("missing required key \"targets\""))?,
409
+                value: lists,
410
+            });
411
+        }
412
+    }
413
+    Ok((out, i))
414
+}
415
+
416
+fn apply_direct_scalar_pair(
417
+    pair: (&str, &str),
418
+    ctx: DirectCtx<'_, '_>,
419
+    i: &mut usize,
420
+    state: &mut DirectScopeState,
421
+    value_key: &str,
422
+    value: &mut Option<String>,
423
+) -> Result<(), TbdError> {
424
+    let (key, rest) = pair;
425
+    if key == "targets" {
426
+        let (parsed, next) = parse_direct_targets(ctx.lines, *i, rest)?;
427
+        state.include = Some(targets_match(&parsed, ctx.target));
428
+        state.targets = Some(parsed);
429
+        *i = next;
430
+    } else if key == value_key {
431
+        if state.include != Some(false) {
432
+            *value = Some(parse_direct_scalar(rest));
433
+        }
434
+        *i += 1;
435
+    } else {
436
+        *i = skip_direct_inline_value(ctx.lines, *i, rest)?;
437
+    }
438
+    Ok(())
439
+}
440
+
441
+fn apply_direct_list_pair(
442
+    pair: (&str, &str),
443
+    ctx: DirectCtx<'_, '_>,
444
+    i: &mut usize,
445
+    state: &mut DirectScopeState,
446
+    value_key: &str,
447
+    value: &mut Vec<String>,
448
+) -> Result<(), TbdError> {
449
+    let (key, rest) = pair;
450
+    if key == "targets" {
451
+        let (parsed, next) = parse_direct_targets(ctx.lines, *i, rest)?;
452
+        state.include = Some(targets_match(&parsed, ctx.target));
453
+        state.targets = Some(parsed);
454
+        *i = next;
455
+    } else if key == value_key {
456
+        if state.include == Some(false) {
457
+            *i = skip_direct_flow(ctx.lines, *i, rest)?;
458
+        } else {
459
+            let (parsed, next) = parse_direct_string_list(ctx.lines, *i, rest)?;
460
+            *value = parsed;
461
+            *i = next;
462
+        }
463
+    } else {
464
+        *i = skip_direct_inline_value(ctx.lines, *i, rest)?;
465
+    }
466
+    Ok(())
467
+}
468
+
469
+fn apply_direct_symbol_pair(
470
+    pair: (&str, &str),
471
+    ctx: DirectCtx<'_, '_>,
472
+    i: &mut usize,
473
+    state: &mut DirectScopeState,
474
+    lists: &mut SymbolLists,
475
+) -> Result<(), TbdError> {
476
+    let (key, rest) = pair;
477
+    if key == "targets" {
478
+        let (parsed, next) = parse_direct_targets(ctx.lines, *i, rest)?;
479
+        state.include = Some(targets_match(&parsed, ctx.target));
480
+        state.targets = Some(parsed);
481
+        *i = next;
482
+        return Ok(());
483
+    }
484
+
485
+    let slot = match key {
486
+        "symbols" => Some(&mut lists.symbols),
487
+        "weak-symbols" => Some(&mut lists.weak_symbols),
488
+        "thread-local-symbols" => Some(&mut lists.thread_local_symbols),
489
+        "objc-classes" => Some(&mut lists.objc_classes),
490
+        "objc-eh-types" => Some(&mut lists.objc_eh_types),
491
+        "objc-ivars" => Some(&mut lists.objc_ivars),
492
+        _ => None,
493
+    };
494
+    if let Some(slot) = slot {
495
+        if state.include == Some(false) {
496
+            *i = skip_direct_flow(ctx.lines, *i, rest)?;
497
+        } else {
498
+            let (parsed, next) = parse_direct_string_list(ctx.lines, *i, rest)?;
499
+            *slot = parsed;
500
+            *i = next;
501
+        }
502
+    } else {
503
+        *i = skip_direct_inline_value(ctx.lines, *i, rest)?;
504
+    }
505
+    Ok(())
506
+}
507
+
508
+type DirectEntry<'a> = Result<(&'a str, &'a str), TbdError>;
509
+
510
+fn direct_entry_start<'a>(lines: &'a [&str], i: usize) -> Option<DirectEntry<'a>> {
511
+    let trimmed = direct_trimmed(lines.get(i)?)?;
512
+    if trimmed.starts_with("---") || trimmed.starts_with("...") || direct_indent(lines[i]) == 0 {
513
+        return None;
514
+    }
515
+    if direct_indent(lines[i]) != 2 || !trimmed.starts_with('-') {
516
+        return Some(Err(schema("expected scoped TBD entry")));
517
+    }
518
+    let rest = trimmed.strip_prefix('-').unwrap_or("").trim_start();
519
+    if rest.is_empty() {
520
+        return Some(Err(schema("empty scoped TBD entries are not supported")));
521
+    }
522
+    Some(direct_key_value(rest).ok_or_else(|| schema("expected scoped TBD key")))
523
+}
524
+
525
+fn direct_nested_pair<'a>(lines: &'a [&str], i: usize) -> Option<(&'a str, &'a str)> {
526
+    let trimmed = direct_trimmed(lines.get(i)?)?;
527
+    if trimmed.starts_with("---") || trimmed.starts_with("...") {
528
+        return None;
529
+    }
530
+    let indent = direct_indent(lines[i]);
531
+    if indent <= 2 {
532
+        return None;
533
+    }
534
+    direct_key_value(trimmed)
535
+}
536
+
537
+fn parse_direct_targets(
538
+    lines: &[&str],
539
+    i: usize,
540
+    rest: &str,
541
+) -> Result<(Vec<Target>, usize), TbdError> {
542
+    let (items, next) = parse_direct_string_list(lines, i, rest)?;
543
+    let mut targets = Vec::with_capacity(items.len());
544
+    for item in items {
545
+        targets.push(parse_target(&item)?);
546
+    }
547
+    Ok((targets, next))
548
+}
549
+
550
+fn parse_direct_string_list(
551
+    lines: &[&str],
552
+    i: usize,
553
+    rest: &str,
554
+) -> Result<(Vec<String>, usize), TbdError> {
555
+    let (flow, next) = collect_direct_flow(lines, i, rest)?;
556
+    Ok((split_direct_flow_scalars(&flow)?, next))
557
+}
558
+
559
+fn collect_direct_flow(lines: &[&str], i: usize, rest: &str) -> Result<(String, usize), TbdError> {
560
+    let start_line = i + 1;
561
+    let mut flow = rest.trim().to_string();
562
+    if !flow.starts_with('[') {
563
+        return Err(schema("expected a flow sequence"));
564
+    }
565
+    let mut next_i = if direct_trimmed(lines.get(i).copied().unwrap_or_default())
566
+        .map(|line| line.contains(rest.trim()))
567
+        .unwrap_or(false)
568
+    {
569
+        i + 1
570
+    } else {
571
+        i
572
+    };
573
+    while direct_flow_unbalanced(&flow) {
574
+        let Some(next) = lines.get(next_i).and_then(|line| direct_trimmed(line)) else {
575
+            return Err(schema(&format!(
576
+                "unterminated flow sequence from line {} near line {}: {:?}",
577
+                start_line,
578
+                next_i + 1,
579
+                flow
580
+            )));
581
+        };
582
+        if next.starts_with("---") || next.starts_with("...") {
583
+            return Err(schema(&format!(
584
+                "unterminated flow sequence from line {} before line {}: {:?}",
585
+                start_line,
586
+                next_i + 1,
587
+                flow
588
+            )));
589
+        }
590
+        flow.push(' ');
591
+        flow.push_str(next);
592
+        next_i += 1;
593
+    }
594
+    if !flow.ends_with(']') {
595
+        return Err(schema("flow sequence must end with ']'"));
596
+    }
597
+    Ok((flow, next_i))
598
+}
599
+
600
+fn skip_direct_flow(lines: &[&str], i: usize, rest: &str) -> Result<usize, TbdError> {
601
+    collect_direct_flow(lines, i, rest).map(|(_, next)| next)
602
+}
603
+
604
+fn skip_direct_inline_value(lines: &[&str], i: usize, rest: &str) -> Result<usize, TbdError> {
605
+    if rest.trim_start().starts_with('[') {
606
+        skip_direct_flow(lines, i, rest)
607
+    } else {
608
+        Ok(i + 1)
609
+    }
610
+}
611
+
612
+fn skip_direct_value(lines: &[&str], mut i: usize) -> usize {
613
+    while i < lines.len() {
614
+        let Some(trimmed) = direct_trimmed(lines[i]) else {
615
+            i += 1;
616
+            continue;
617
+        };
618
+        if trimmed.starts_with("---") || trimmed.starts_with("...") || direct_indent(lines[i]) == 0
619
+        {
620
+            break;
621
+        }
622
+        i += 1;
623
+    }
624
+    i
625
+}
626
+
627
+fn split_direct_flow_scalars(flow: &str) -> Result<Vec<String>, TbdError> {
628
+    let inner = flow
629
+        .strip_prefix('[')
630
+        .and_then(|s| s.strip_suffix(']'))
631
+        .ok_or_else(|| schema("flow sequence must be bracketed"))?;
632
+    let bytes = inner.as_bytes();
633
+    let mut out = Vec::new();
634
+    let mut in_single = false;
635
+    let mut in_double = false;
636
+    let mut depth = 0i32;
637
+    let mut start = 0usize;
638
+    let mut i = 0usize;
639
+    while i < bytes.len() {
640
+        let b = bytes[i];
641
+        match b {
642
+            b'\'' if !in_double => in_single = !in_single,
643
+            b'"' if !in_single => in_double = !in_double,
644
+            b'\\' if in_double && i + 1 < bytes.len() => {
645
+                i += 2;
646
+                continue;
647
+            }
648
+            b'[' | b'{' if !in_single && !in_double => depth += 1,
649
+            b']' | b'}' if !in_single && !in_double => depth -= 1,
650
+            b',' if !in_single && !in_double && depth == 0 => {
651
+                push_direct_flow_scalar(&mut out, &inner[start..i]);
652
+                start = i + 1;
653
+            }
654
+            _ => {}
655
+        }
656
+        i += 1;
657
+    }
658
+    if start <= inner.len() {
659
+        push_direct_flow_scalar(&mut out, &inner[start..]);
660
+    }
661
+    Ok(out)
662
+}
663
+
664
+fn push_direct_flow_scalar(out: &mut Vec<String>, item: &str) {
665
+    let item = item.trim();
666
+    if !item.is_empty() {
667
+        out.push(parse_direct_scalar(item));
668
+    }
669
+}
670
+
671
+fn parse_direct_scalar(raw: &str) -> String {
672
+    let raw = raw.trim();
673
+    if raw.len() >= 2 && raw.starts_with('\'') && raw.ends_with('\'') {
674
+        raw[1..raw.len() - 1].replace("''", "'")
675
+    } else if raw.len() >= 2 && raw.starts_with('"') && raw.ends_with('"') {
676
+        parse_direct_double_quoted(&raw[1..raw.len() - 1])
677
+    } else {
678
+        raw.to_string()
679
+    }
680
+}
681
+
682
+fn parse_direct_double_quoted(raw: &str) -> String {
683
+    let bytes = raw.as_bytes();
684
+    let mut out = String::with_capacity(raw.len());
685
+    let mut i = 0usize;
686
+    while i < bytes.len() {
687
+        if bytes[i] == b'\\' && i + 1 < bytes.len() {
688
+            let ch = match bytes[i + 1] {
689
+                b'n' => '\n',
690
+                b'r' => '\r',
691
+                b't' => '\t',
692
+                b'"' => '"',
693
+                b'\\' => '\\',
694
+                other => other as char,
695
+            };
696
+            out.push(ch);
697
+            i += 2;
698
+        } else {
699
+            out.push(bytes[i] as char);
700
+            i += 1;
701
+        }
702
+    }
703
+    out
704
+}
705
+
706
+fn direct_key_value(s: &str) -> Option<(&str, &str)> {
707
+    let (key, rest) = s.split_once(':')?;
708
+    Some((key.trim(), rest.trim_start()))
709
+}
710
+
711
+fn direct_trimmed(line: &str) -> Option<&str> {
712
+    let trimmed = line.trim();
713
+    if trimmed.is_empty() || trimmed.starts_with('#') {
714
+        None
715
+    } else {
716
+        Some(trimmed)
717
+    }
718
+}
719
+
720
+fn direct_indent(line: &str) -> usize {
721
+    line.bytes().take_while(|b| *b == b' ').count()
722
+}
723
+
724
+fn direct_flow_unbalanced(s: &str) -> bool {
725
+    let mut depth = 0i32;
726
+    let mut in_single = false;
727
+    let mut in_double = false;
728
+    let bytes = s.as_bytes();
729
+    let mut i = 0usize;
730
+    while i < bytes.len() {
731
+        let b = bytes[i];
732
+        match b {
733
+            b'\'' if !in_double => in_single = !in_single,
734
+            b'"' if !in_single => in_double = !in_double,
735
+            b'\\' if in_double && i + 1 < bytes.len() => {
736
+                i += 2;
737
+                continue;
738
+            }
739
+            b'[' | b'{' if !in_single && !in_double => depth += 1,
740
+            b']' | b'}' if !in_single && !in_double => depth -= 1,
741
+            _ => {}
742
+        }
743
+        i += 1;
744
+    }
745
+    depth != 0
746
+}
747
+
748
+fn targets_match(targets: &[Target], target: &Target) -> bool {
749
+    targets.iter().any(|t| t.matches_requested(target))
750
+}
751
+
752
+fn decode_document(doc: Document) -> Result<Tbd, TbdError> {
753
+    let Value::Mapping(m) = doc.root else {
754
+        return Err(schema("top level of a TBD document must be a mapping"));
755
+    };
137756
     let mut tbd = Tbd::default();
138757
     for (k, v) in m {
139758
         match k.as_str() {
@@ -170,16 +789,13 @@ fn decode_document(doc: &Document) -> Result<Tbd, TbdError> {
170789
     Ok(tbd)
171790
 }
172791
 
173
-fn decode_target_list(v: &Value) -> Result<Vec<Target>, TbdError> {
174
-    let seq = v
175
-        .as_sequence()
176
-        .ok_or_else(|| schema("'targets' must be a sequence"))?;
792
+fn decode_target_list(v: Value) -> Result<Vec<Target>, TbdError> {
793
+    let Value::Sequence(seq) = v else {
794
+        return Err(schema("'targets' must be a sequence"));
795
+    };
177796
     let mut out = Vec::with_capacity(seq.len());
178797
     for item in seq {
179
-        let s = item
180
-            .as_str()
181
-            .ok_or_else(|| schema("target must be a scalar"))?;
182
-        out.push(parse_target(s)?);
798
+        out.push(parse_target(&scalar_string(item, "target")?)?);
183799
     }
184800
     Ok(out)
185801
 }
@@ -208,17 +824,28 @@ fn parse_target(s: &str) -> Result<Target, TbdError> {
208824
     Ok(Target { arch, platform })
209825
 }
210826
 
211
-fn decode_scoped_umbrella(v: &Value) -> Result<Vec<Scoped<String>>, TbdError> {
212
-    let seq = v
213
-        .as_sequence()
214
-        .ok_or_else(|| schema("'parent-umbrella' must be a sequence of scoped mappings"))?;
827
+fn decode_scoped_umbrella(v: Value) -> Result<Vec<Scoped<String>>, TbdError> {
828
+    let Value::Sequence(seq) = v else {
829
+        return Err(schema(
830
+            "'parent-umbrella' must be a sequence of scoped mappings",
831
+        ));
832
+    };
215833
     let mut out = Vec::with_capacity(seq.len());
216834
     for item in seq {
217
-        let m = item
218
-            .as_mapping()
219
-            .ok_or_else(|| schema("parent-umbrella entry must be a mapping"))?;
220
-        let targets = lookup_required(m, "targets").and_then(decode_target_list)?;
221
-        let umbrella = lookup_required(m, "umbrella").and_then(|v| scalar_string(v, "umbrella"))?;
835
+        let Value::Mapping(m) = item else {
836
+            return Err(schema("parent-umbrella entry must be a mapping"));
837
+        };
838
+        let mut targets = None;
839
+        let mut umbrella = None;
840
+        for (k, v) in m {
841
+            match k.as_str() {
842
+                "targets" => targets = Some(decode_target_list(v)?),
843
+                "umbrella" => umbrella = Some(scalar_string(v, "umbrella")?),
844
+                _ => {}
845
+            }
846
+        }
847
+        let targets = targets.ok_or_else(|| schema("missing required key \"targets\""))?;
848
+        let umbrella = umbrella.ok_or_else(|| schema("missing required key \"umbrella\""))?;
222849
         out.push(Scoped {
223850
             targets,
224851
             value: umbrella,
@@ -228,41 +855,46 @@ fn decode_scoped_umbrella(v: &Value) -> Result<Vec<Scoped<String>>, TbdError> {
228855
 }
229856
 
230857
 fn decode_scoped_string_list(
231
-    v: &Value,
858
+    v: Value,
232859
     inner_key: &str,
233860
 ) -> Result<Vec<Scoped<Vec<String>>>, TbdError> {
234
-    let seq = v
235
-        .as_sequence()
236
-        .ok_or_else(|| schema("expected a sequence of scoped mappings"))?;
861
+    let Value::Sequence(seq) = v else {
862
+        return Err(schema("expected a sequence of scoped mappings"));
863
+    };
237864
     let mut out = Vec::with_capacity(seq.len());
238865
     for item in seq {
239
-        let m = item
240
-            .as_mapping()
241
-            .ok_or_else(|| schema("scoped entry must be a mapping"))?;
242
-        let targets = lookup_required(m, "targets").and_then(decode_target_list)?;
243
-        let value = match m.iter().find(|(k, _)| k == inner_key) {
244
-            Some((_, v)) => decode_string_list(v, inner_key)?,
245
-            None => Vec::new(),
866
+        let Value::Mapping(m) = item else {
867
+            return Err(schema("scoped entry must be a mapping"));
246868
         };
869
+        let mut targets = None;
870
+        let mut value = Vec::new();
871
+        for (k, v) in m {
872
+            if k == "targets" {
873
+                targets = Some(decode_target_list(v)?);
874
+            } else if k == inner_key {
875
+                value = decode_string_list(v, inner_key)?;
876
+            }
877
+        }
878
+        let targets = targets.ok_or_else(|| schema("missing required key \"targets\""))?;
247879
         out.push(Scoped { targets, value });
248880
     }
249881
     Ok(out)
250882
 }
251883
 
252
-fn decode_scoped_symbols(v: &Value) -> Result<Vec<Scoped<SymbolLists>>, TbdError> {
253
-    let seq = v
254
-        .as_sequence()
255
-        .ok_or_else(|| schema("'exports'/'reexports' must be a sequence"))?;
884
+fn decode_scoped_symbols(v: Value) -> Result<Vec<Scoped<SymbolLists>>, TbdError> {
885
+    let Value::Sequence(seq) = v else {
886
+        return Err(schema("'exports'/'reexports' must be a sequence"));
887
+    };
256888
     let mut out = Vec::with_capacity(seq.len());
257889
     for item in seq {
258
-        let m = item
259
-            .as_mapping()
260
-            .ok_or_else(|| schema("exports entry must be a mapping"))?;
261
-        let targets = lookup_required(m, "targets").and_then(decode_target_list)?;
890
+        let Value::Mapping(m) = item else {
891
+            return Err(schema("exports entry must be a mapping"));
892
+        };
893
+        let mut targets = None;
262894
         let mut lists = SymbolLists::default();
263895
         for (k, v) in m {
264896
             match k.as_str() {
265
-                "targets" => {}
897
+                "targets" => targets = Some(decode_target_list(v)?),
266898
                 "symbols" => lists.symbols = decode_string_list(v, "symbols")?,
267899
                 "weak-symbols" => lists.weak_symbols = decode_string_list(v, "weak-symbols")?,
268900
                 "thread-local-symbols" => {
@@ -274,6 +906,7 @@ fn decode_scoped_symbols(v: &Value) -> Result<Vec<Scoped<SymbolLists>>, TbdError
274906
                 _ => {} // ignore unknown inner keys
275907
             }
276908
         }
909
+        let targets = targets.ok_or_else(|| schema("missing required key \"targets\""))?;
277910
         out.push(Scoped {
278911
             targets,
279912
             value: lists,
@@ -282,7 +915,7 @@ fn decode_scoped_symbols(v: &Value) -> Result<Vec<Scoped<SymbolLists>>, TbdError
282915
     Ok(out)
283916
 }
284917
 
285
-fn decode_string_list(v: &Value, context: &str) -> Result<Vec<String>, TbdError> {
918
+fn decode_string_list(v: Value, context: &str) -> Result<Vec<String>, TbdError> {
286919
     match v {
287920
         Value::Sequence(items) => {
288921
             let mut out = Vec::with_capacity(items.len());
@@ -296,24 +929,15 @@ fn decode_string_list(v: &Value, context: &str) -> Result<Vec<String>, TbdError>
296929
     }
297930
 }
298931
 
299
-fn lookup_required<'a>(m: &'a [(String, Value)], key: &str) -> Result<&'a Value, TbdError> {
300
-    m.iter()
301
-        .find(|(k, _)| k == key)
302
-        .map(|(_, v)| v)
303
-        .ok_or_else(|| schema(&format!("missing required key {key:?}")))
304
-}
305
-
306
-fn scalar_u32(v: &Value, context: &str) -> Result<u32, TbdError> {
307
-    let s = v
308
-        .as_str()
309
-        .ok_or_else(|| schema(&format!("{context} must be a scalar")))?;
932
+fn scalar_u32(v: Value, context: &str) -> Result<u32, TbdError> {
933
+    let s = scalar_string(v, context)?;
310934
     s.parse()
311935
         .map_err(|_| schema(&format!("{context} must parse as a u32: {s:?}")))
312936
 }
313937
 
314
-fn scalar_string(v: &Value, context: &str) -> Result<String, TbdError> {
938
+fn scalar_string(v: Value, context: &str) -> Result<String, TbdError> {
315939
     match v {
316
-        Value::Scalar(s) => Ok(s.clone()),
940
+        Value::Scalar(s) => Ok(s),
317941
         _ => Err(schema(&format!("{context} must be a scalar"))),
318942
     }
319943
 }
@@ -373,6 +997,13 @@ impl Target {
373997
 mod tests {
374998
     use super::*;
375999
 
1000
+    fn arm64_macos() -> Target {
1001
+        Target {
1002
+            arch: Arch::Arm64,
1003
+            platform: Platform::MacOs,
1004
+        }
1005
+    }
1006
+
3761007
     #[test]
3771008
     fn parses_minimal_tbd_v4() {
3781009
         let src = "--- !tapi-tbd\n\
@@ -455,6 +1086,24 @@ mod tests {
4551086
         assert_eq!(tbd.parent_umbrella[0].value, "System");
4561087
     }
4571088
 
1089
+    #[test]
1090
+    fn target_fast_path_keeps_matching_multiline_exports() {
1091
+        let src = "--- !tapi-tbd\n\
1092
+                   tbd-version: 4\n\
1093
+                   targets: [ x86_64-macos, arm64e-macos ]\n\
1094
+                   install-name: '/usr/lib/libfoo.dylib'\n\
1095
+                   exports:\n\
1096
+                   \x20 - targets: [ x86_64-macos ]\n\
1097
+                   \x20   symbols: [ _x86_only ]\n\
1098
+                   \x20 - targets: [ x86_64-macos, arm64e-macos ]\n\
1099
+                   \x20   symbols: [ _arm_one,\n\
1100
+                   \x20              _arm_two ]\n";
1101
+        let docs = parse_tbd_for_target(src, &arm64_macos()).unwrap();
1102
+        assert_eq!(docs.len(), 1);
1103
+        assert_eq!(docs[0].exports.len(), 1);
1104
+        assert_eq!(docs[0].exports[0].value.symbols, ["_arm_one", "_arm_two"]);
1105
+    }
1106
+
4581107
     #[test]
4591108
     fn unknown_keys_are_tolerated() {
4601109
         let src = "--- !tapi-tbd\n\
src/macho/tbd_yaml.rsmodified
112 lines changed — click to load
@@ -185,6 +185,9 @@ fn strip_trailing_ws(s: &str) -> &str {
185185
 /// quoted content untouched (otherwise single-quoted paths like
186186
 /// `'/usr/lib/#funny'` would be mangled — unlikely but sound).
187187
 fn strip_eol_comment(s: &mut String) {
188
+    if !s.as_bytes().contains(&b'#') {
189
+        return;
190
+    }
188191
     let bytes = s.as_bytes();
189192
     let mut in_single = false;
190193
     let mut in_double = false;
@@ -198,15 +201,15 @@ fn strip_eol_comment(s: &mut String) {
198201
                 i += 2;
199202
                 continue;
200203
             }
201
-            b'#' if !in_single && !in_double => {
202
-                // A comment must be preceded by whitespace or be at BOL.
203
-                if i == 0 || bytes[i - 1] == b' ' || bytes[i - 1] == b'\t' {
204
-                    s.truncate(i);
205
-                    // Trim trailing whitespace left by the strip.
206
-                    let trimmed_len = s.trim_end().len();
207
-                    s.truncate(trimmed_len);
208
-                    return;
209
-                }
204
+            b'#' if !in_single
205
+                && !in_double
206
+                && (i == 0 || bytes[i - 1] == b' ' || bytes[i - 1] == b'\t') =>
207
+            {
208
+                s.truncate(i);
209
+                // Trim trailing whitespace left by the strip.
210
+                let trimmed_len = s.trim_end().len();
211
+                s.truncate(trimmed_len);
212
+                return;
210213
             }
211214
             _ => {}
212215
         }
@@ -215,6 +218,13 @@ fn strip_eol_comment(s: &mut String) {
215218
 }
216219
 
217220
 fn flow_unbalanced(s: &str) -> bool {
221
+    if !s
222
+        .as_bytes()
223
+        .iter()
224
+        .any(|b| matches!(b, b'[' | b']' | b'{' | b'}'))
225
+    {
226
+        return false;
227
+    }
218228
     let mut depth = 0i32;
219229
     let mut in_single = false;
220230
     let mut in_double = false;
@@ -426,11 +436,12 @@ fn find_top_level_mapping_colon(s: &str) -> Option<usize> {
426436
             }
427437
             b'[' | b'{' if !in_single && !in_double => depth += 1,
428438
             b']' | b'}' if !in_single && !in_double => depth -= 1,
429
-            b':' if !in_single && !in_double && depth == 0 => {
430
-                // Must be followed by whitespace or end of line per YAML rules.
431
-                if i + 1 == bytes.len() || bytes[i + 1] == b' ' || bytes[i + 1] == b'\t' {
432
-                    return Some(i);
433
-                }
439
+            b':' if !in_single
440
+                && !in_double
441
+                && depth == 0
442
+                && (i + 1 == bytes.len() || bytes[i + 1] == b' ' || bytes[i + 1] == b'\t') =>
443
+            {
444
+                return Some(i);
434445
             }
435446
             _ => {}
436447
         }
@@ -481,21 +492,23 @@ fn parse_flow_sequence(s: &str, line: usize, col: usize) -> Result<Value, YamlEr
481492
         });
482493
     }
483494
     let inner = &s[1..s.len() - 1];
484
-    let items = split_flow_items(inner);
485
-    let mut out = Vec::with_capacity(items.len());
486
-    for piece in items {
495
+    let mut out = Vec::new();
496
+    split_flow_items(inner, |piece| {
487497
         let piece = piece.trim();
488498
         if piece.is_empty() {
489
-            continue;
499
+            return Ok(());
490500
         }
491501
         // Recursive: flow sequences can hold scalars or further flow sequences.
492502
         out.push(parse_inline_value(piece, line, col)?);
493
-    }
503
+        Ok(())
504
+    })?;
494505
     Ok(Value::Sequence(out))
495506
 }
496507
 
497
-fn split_flow_items(s: &str) -> Vec<&str> {
498
-    let mut parts = Vec::new();
508
+fn split_flow_items(
509
+    s: &str,
510
+    mut visit: impl FnMut(&str) -> Result<(), YamlError>,
511
+) -> Result<(), YamlError> {
499512
     let bytes = s.as_bytes();
500513
     let mut in_single = false;
501514
     let mut in_double = false;
@@ -514,7 +527,7 @@ fn split_flow_items(s: &str) -> Vec<&str> {
514527
             b'[' | b'{' if !in_single && !in_double => depth += 1,
515528
             b']' | b'}' if !in_single && !in_double => depth -= 1,
516529
             b',' if !in_single && !in_double && depth == 0 => {
517
-                parts.push(&s[start..i]);
530
+                visit(&s[start..i])?;
518531
                 start = i + 1;
519532
             }
520533
             _ => {}
@@ -522,9 +535,9 @@ fn split_flow_items(s: &str) -> Vec<&str> {
522535
         i += 1;
523536
     }
524537
     if start <= s.len() {
525
-        parts.push(&s[start..]);
538
+        visit(&s[start..])?;
526539
     }
527
-    parts
540
+    Ok(())
528541
 }
529542
 
530543
 fn parse_single_quoted(s: &str, line: usize, col: usize) -> Result<String, YamlError> {
src/macho/writer.rsmodified
1900 lines changed — click to load
@@ -4,7 +4,9 @@
44
 
55
 use std::collections::HashMap;
66
 use std::fmt;
7
+use std::fs;
78
 use std::path::PathBuf;
9
+use std::time::Duration;
810
 
911
 use crate::atom::AtomTable;
1012
 use crate::input::{DataInCodeEntry, ObjectFile};
@@ -15,14 +17,17 @@ use crate::macho::dylib::DylibDependency;
1517
 use crate::macho::exports::{ExportEntry, ExportKind};
1618
 use crate::macho::reader::{
1719
     write_commands, write_header, BuildTool, BuildVersionCmd, DyldInfoCmd, DylibCmd, DysymtabCmd,
18
-    LinkEditDataCmd, LoadCommand, MachHeader64, Section64Header, Segment64, SymtabCmd, HEADER_SIZE,
20
+    LinkEditDataCmd, LoadCommand, MachHeader64, RpathCmd, Section64Header, Segment64, SymtabCmd,
21
+    HEADER_SIZE,
1922
 };
20
-use crate::reloc::{parse_raw_relocs, parse_relocs, Referent, Reloc, RelocKind, RelocLength};
21
-use crate::resolve::InputId;
23
+use crate::reloc::{
24
+    parse_raw_relocs, parse_relocs, ParsedRelocCache, Referent, Reloc, RelocKind, RelocLength,
25
+};
26
+use crate::resolve::{AtomId, InputId};
2227
 use crate::resolve::{Symbol, SymbolId, SymbolTable};
2328
 use crate::section::is_executable;
2429
 use crate::string_table::StringTableBuilder;
25
-use crate::symbol::{write_nlist_table, InputSymbol, RawNlist, SymKind};
30
+use crate::symbol::{write_nlist_table, InputSymbol, RawNlist, SymKind, NLIST_SIZE};
2631
 use crate::synth::tlv::THREAD_VARIABLE_DESCRIPTOR_SIZE;
2732
 use crate::synth::{
2833
     code_sig::CodeSignaturePlan,
@@ -46,6 +51,45 @@ pub struct LinkEditContext<'a> {
4651
     pub atom_table: &'a AtomTable,
4752
     pub sym_table: &'a SymbolTable,
4853
     pub synthetic_plan: &'a SyntheticPlan,
54
+    pub icf_redirects: Option<&'a HashMap<crate::resolve::AtomId, crate::resolve::AtomId>>,
55
+    pub parsed_relocs: &'a ParsedRelocCache,
56
+}
57
+
58
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
59
+pub struct LinkEditBuildTimings {
60
+    pub symbol_plan: Duration,
61
+    pub symbol_plan_locals: Duration,
62
+    pub symbol_plan_globals: Duration,
63
+    pub symbol_plan_strtab: Duration,
64
+    pub dyld_info: Duration,
65
+    pub dyld_bind: Duration,
66
+    pub dyld_rebase: Duration,
67
+    pub dyld_export: Duration,
68
+    pub metadata_tables: Duration,
69
+    pub code_signature: Duration,
70
+}
71
+
72
+impl std::ops::AddAssign for LinkEditBuildTimings {
73
+    fn add_assign(&mut self, rhs: Self) {
74
+        self.symbol_plan += rhs.symbol_plan;
75
+        self.symbol_plan_locals += rhs.symbol_plan_locals;
76
+        self.symbol_plan_globals += rhs.symbol_plan_globals;
77
+        self.symbol_plan_strtab += rhs.symbol_plan_strtab;
78
+        self.dyld_info += rhs.dyld_info;
79
+        self.dyld_bind += rhs.dyld_bind;
80
+        self.dyld_rebase += rhs.dyld_rebase;
81
+        self.dyld_export += rhs.dyld_export;
82
+        self.metadata_tables += rhs.metadata_tables;
83
+        self.code_signature += rhs.code_signature;
84
+    }
85
+}
86
+
87
+#[derive(Debug, Clone, PartialEq, Eq)]
88
+pub struct LinkMapSymbol {
89
+    pub name: String,
90
+    pub addr: u64,
91
+    pub size: u64,
92
+    pub file_index: usize,
4993
 }
5094
 
5195
 #[derive(Debug)]
@@ -60,7 +104,9 @@ pub enum WriteError {
60104
     ImportSymbolMissing(SymbolId),
61105
     ImportSymbolWrongKind(SymbolId),
62106
     MalformedRelocations(PathBuf, u8, String),
107
+    MalformedLoh(PathBuf, String),
63108
     MalformedDataInCode(PathBuf, String),
109
+    SymbolListRead(PathBuf, String),
64110
 }
65111
 
66112
 impl fmt::Display for WriteError {
@@ -113,6 +159,13 @@ impl fmt::Display for WriteError {
113159
                 path.display(),
114160
                 section
115161
             ),
162
+            WriteError::MalformedLoh(path, detail) => {
163
+                write!(
164
+                    f,
165
+                    "failed to remap LC_LINKER_OPTIMIZATION_HINT in {}: {detail}",
166
+                    path.display()
167
+                )
168
+            }
116169
             WriteError::MalformedDataInCode(path, detail) => {
117170
                 write!(
118171
                     f,
@@ -120,6 +173,13 @@ impl fmt::Display for WriteError {
120173
                     path.display()
121174
                 )
122175
             }
176
+            WriteError::SymbolListRead(path, detail) => {
177
+                write!(
178
+                    f,
179
+                    "{}: unable to read symbol list: {detail}",
180
+                    path.display()
181
+                )
182
+            }
123183
         }
124184
     }
125185
 }
@@ -162,40 +222,87 @@ pub fn finalize_layout_with_linkedit(
162222
     opts: &LinkOptions,
163223
     dylibs: &[DylibDependency],
164224
     context: LinkEditContext<'_>,
165
-) -> Result<(Layout, LinkEditPlan), WriteError> {
225
+) -> Result<(Layout, LinkEditPlan, LinkEditBuildTimings), WriteError> {
166226
     finalize_with_linkedit(layout, kind, opts, dylibs, Some(LinkEditInputs(context)))
167227
 }
168228
 
229
+pub fn build_parsed_reloc_cache(
230
+    inputs: &[LayoutInput<'_>],
231
+) -> Result<ParsedRelocCache, WriteError> {
232
+    let mut cache = HashMap::new();
233
+    for input in inputs {
234
+        for (sect_idx, section) in input.object.sections.iter().enumerate() {
235
+            if section.raw_relocs.is_empty() {
236
+                continue;
237
+            }
238
+            let section_idx = (sect_idx + 1) as u8;
239
+            let raws = parse_raw_relocs(&section.raw_relocs, 0, section.nreloc).map_err(|err| {
240
+                WriteError::MalformedRelocations(
241
+                    input.object.path.clone(),
242
+                    section_idx,
243
+                    err.to_string(),
244
+                )
245
+            })?;
246
+            let relocs = parse_relocs(&raws).map_err(|err| {
247
+                WriteError::MalformedRelocations(
248
+                    input.object.path.clone(),
249
+                    section_idx,
250
+                    err.to_string(),
251
+                )
252
+            })?;
253
+            cache.insert((input.id, section_idx), relocs);
254
+        }
255
+    }
256
+    Ok(cache)
257
+}
258
+
169259
 fn finalize_with_linkedit(
170260
     layout: &Layout,
171261
     kind: OutputKind,
172262
     opts: &LinkOptions,
173263
     dylibs: &[DylibDependency],
174264
     inputs: Option<LinkEditInputs<'_>>,
175
-) -> Result<(Layout, LinkEditPlan), WriteError> {
265
+) -> Result<(Layout, LinkEditPlan, LinkEditBuildTimings), WriteError> {
176266
     let mut layout = layout.clone();
177
-    let mut linkedit = build_linkedit_plan(&layout, kind, opts, inputs)?;
267
+    let (mut linkedit, mut timings) = build_linkedit_plan_profiled(&layout, kind, opts, inputs)?;
178268
     apply_indirect_starts(&mut layout, &linkedit);
179269
     let header_size = estimate_header_size(&layout, kind, opts, dylibs, &linkedit);
180270
     layout.relayout(header_size);
181271
 
182
-    linkedit = build_linkedit_plan(&layout, kind, opts, inputs)?;
183
-    apply_indirect_starts(&mut layout, &linkedit);
184
-    let sizeofcmds: u32 = build_commands(&layout, kind, opts, None, dylibs, &linkedit)?
185
-        .iter()
186
-        .map(LoadCommand::cmdsize)
187
-        .sum();
188
-    let header_size = HEADER_SIZE as u64 + sizeofcmds as u64;
189
-    layout.relayout(header_size);
190
-    linkedit = build_linkedit_plan(&layout, kind, opts, inputs)?;
272
+    let (next_linkedit, next_timings) = build_linkedit_plan_profiled(&layout, kind, opts, inputs)?;
273
+    linkedit = next_linkedit;
274
+    timings += next_timings;
191275
     apply_indirect_starts(&mut layout, &linkedit);
276
+    let exact_header_size =
277
+        HEADER_SIZE as u64 + exact_sizeofcmds(&layout, kind, opts, dylibs, &linkedit)? as u64;
278
+    if exact_header_size != header_size {
279
+        layout.relayout(exact_header_size);
280
+        let (next_linkedit, next_timings) =
281
+            build_linkedit_plan_profiled(&layout, kind, opts, inputs)?;
282
+        linkedit = next_linkedit;
283
+        timings += next_timings;
284
+        apply_indirect_starts(&mut layout, &linkedit);
285
+    }
192286
 
193287
     let linkedit_seg = layout
194288
         .segment_mut("__LINKEDIT")
195289
         .ok_or(WriteError::MissingSegment("__LINKEDIT"))?;
196290
     linkedit_seg.file_size = linkedit.total_size().max(1);
197291
     linkedit_seg.vm_size = align_up(linkedit.total_size().max(1), PAGE_SIZE);
198
-    Ok((layout, linkedit))
292
+    Ok((layout, linkedit, timings))
293
+}
294
+
295
+fn exact_sizeofcmds(
296
+    layout: &Layout,
297
+    kind: OutputKind,
298
+    opts: &LinkOptions,
299
+    dylibs: &[DylibDependency],
300
+    linkedit: &LinkEditPlan,
301
+) -> Result<u32, WriteError> {
302
+    Ok(build_commands(layout, kind, opts, None, dylibs, linkedit)?
303
+        .iter()
304
+        .map(LoadCommand::cmdsize)
305
+        .sum())
199306
 }
200307
 
201308
 pub fn write_finalized_with_dylibs(
@@ -270,6 +377,7 @@ pub fn write_finalized_with_linkedit(
270377
     let weak_bind_off = linkedit_plan.dyld_info.weak_bind_off as usize;
271378
     let lazy_bind_off = linkedit_plan.dyld_info.lazy_bind_off as usize;
272379
     let export_off = linkedit_plan.dyld_info.export_off as usize;
380
+    let loh_off = linkedit_plan.loh.map(|loh| loh.dataoff as usize);
273381
     let function_starts_off = linkedit_plan.function_starts.dataoff as usize;
274382
     let data_in_code_off = linkedit_plan.data_in_code.dataoff as usize;
275383
     let stroff = linkedit_plan.symtab.stroff as usize;
@@ -301,6 +409,12 @@ pub fn write_finalized_with_linkedit(
301409
         let end = export_off + linkedit_plan.export_bytes.len();
302410
         out[export_off..end].copy_from_slice(&linkedit_plan.export_bytes);
303411
     }
412
+    if let Some(loh_off) = loh_off {
413
+        if !linkedit_plan.loh_bytes.is_empty() {
414
+            let end = loh_off + linkedit_plan.loh_bytes.len();
415
+            out[loh_off..end].copy_from_slice(&linkedit_plan.loh_bytes);
416
+        }
417
+    }
304418
     if !linkedit_plan.function_starts_bytes.is_empty() {
305419
         let end = function_starts_off + linkedit_plan.function_starts_bytes.len();
306420
         out[function_starts_off..end].copy_from_slice(&linkedit_plan.function_starts_bytes);
@@ -313,7 +427,7 @@ pub fn write_finalized_with_linkedit(
313427
     out[stroff..end].copy_from_slice(&linkedit_plan.strtab_bytes);
314428
     if let Some(code_signature) = &linkedit_plan.code_signature {
315429
         let start = code_signature.dataoff as usize;
316
-        let bytes = code_signature.build(&out[..start]);
430
+        let bytes = code_signature.build_with_jobs(&out[..start], opts.parallel_jobs());
317431
         let end = start + bytes.len();
318432
         out[start..end].copy_from_slice(&bytes);
319433
     }
@@ -343,43 +457,35 @@ fn build_commands(
343457
             commands.push(LoadCommand::Symtab(linkedit.symtab));
344458
             commands.push(LoadCommand::Dysymtab(linkedit.dysymtab));
345459
             commands.push(raw_dylinker_command("/usr/lib/dyld"));
346
-            commands.push(raw_uuid_command(stable_uuid(layout, kind)));
347
-            commands.push(LoadCommand::BuildVersion(BuildVersionCmd {
348
-                platform: PLATFORM_MACOS,
349
-                minos: pack_version(11, 0, 0),
350
-                sdk: pack_version(11, 0, 0),
351
-                tools: vec![BuildTool {
352
-                    tool: 3,
353
-                    version: pack_version(0, 1, 0),
354
-                }],
355
-            }));
460
+            if opts.emit_uuid {
461
+                commands.push(raw_uuid_command(stable_uuid(layout, kind)));
462
+            }
463
+            commands.push(LoadCommand::BuildVersion(build_version_command(opts)));
356464
             commands.push(raw_source_version_command(0));
357465
             commands.push(raw_entry_point(resolve_entryoff(layout, entry_point)?, 0));
358466
         }
359467
         OutputKind::Dylib => {
360
-            commands.push(LoadCommand::BuildVersion(BuildVersionCmd {
361
-                platform: PLATFORM_MACOS,
362
-                minos: pack_version(11, 0, 0),
363
-                sdk: pack_version(11, 0, 0),
364
-                tools: vec![BuildTool {
365
-                    tool: 3,
366
-                    version: pack_version(0, 1, 0),
367
-                }],
368
-            }));
369
-            commands.push(raw_uuid_command(stable_uuid(layout, kind)));
370468
             commands.push(LoadCommand::Dylib(DylibCmd {
371469
                 cmd: LC_ID_DYLIB,
372470
                 name: dylib_install_name(opts),
373471
                 timestamp: 2,
374
-                current_version: pack_version(1, 0, 0),
375
-                compatibility_version: pack_version(1, 0, 0),
472
+                current_version: dylib_current_version(opts),
473
+                compatibility_version: dylib_compatibility_version(opts),
376474
             }));
475
+            commands.push(LoadCommand::DyldInfoOnly(linkedit.dyld_info));
476
+            commands.push(LoadCommand::Symtab(linkedit.symtab));
477
+            commands.push(LoadCommand::Dysymtab(linkedit.dysymtab));
478
+            if opts.emit_uuid {
479
+                commands.push(raw_uuid_command(stable_uuid(layout, kind)));
480
+            }
481
+            commands.push(LoadCommand::BuildVersion(build_version_command(opts)));
482
+            commands.push(raw_source_version_command(0));
377483
         }
378484
     }
379485
 
380486
     for dylib in dylibs {
381487
         commands.push(LoadCommand::Dylib(DylibCmd {
382
-            cmd: LC_LOAD_DYLIB,
488
+            cmd: dylib.kind.load_cmd(),
383489
             name: dylib.install_name.clone(),
384490
             timestamp: 2,
385491
             current_version: dylib.current_version,
@@ -387,6 +493,19 @@ fn build_commands(
387493
         }));
388494
     }
389495
 
496
+    for rpath in &opts.rpaths {
497
+        commands.push(LoadCommand::Rpath(RpathCmd {
498
+            path: rpath.clone(),
499
+        }));
500
+    }
501
+
502
+    if let Some(loh) = linkedit.loh {
503
+        commands.push(raw_linkedit_command(
504
+            LC_LINKER_OPTIMIZATION_HINT,
505
+            loh.dataoff,
506
+            loh.datasize,
507
+        ));
508
+    }
390509
     commands.push(raw_linkedit_command(
391510
         LC_FUNCTION_STARTS,
392511
         linkedit.function_starts.dataoff,
@@ -406,12 +525,6 @@ fn build_commands(
406525
     } else {
407526
         commands.push(raw_linkedit_command(LC_CODE_SIGNATURE, 0, 0));
408527
     }
409
-    if kind == OutputKind::Dylib {
410
-        commands.push(LoadCommand::DyldInfoOnly(linkedit.dyld_info));
411
-        commands.push(LoadCommand::Symtab(linkedit.symtab));
412
-        commands.push(LoadCommand::Dysymtab(linkedit.dysymtab));
413
-    }
414
-
415528
     Ok(commands)
416529
 }
417530
 
@@ -420,41 +533,43 @@ fn estimate_header_size(
420533
     kind: OutputKind,
421534
     opts: &LinkOptions,
422535
     dylibs: &[DylibDependency],
423
-    _linkedit: &LinkEditPlan,
536
+    linkedit: &LinkEditPlan,
424537
 ) -> u64 {
425538
     let mut size = HEADER_SIZE as u64;
426539
     for segment in &layout.segments {
427540
         size += (8 + 64 + 80 * segment.sections.len()) as u64;
428541
     }
429
-    size += BuildVersionCmd {
430
-        platform: PLATFORM_MACOS,
431
-        minos: pack_version(11, 0, 0),
432
-        sdk: pack_version(11, 0, 0),
433
-        tools: vec![BuildTool {
434
-            tool: 3,
435
-            version: pack_version(0, 1, 0),
436
-        }],
542
+    size += build_version_command(opts).wire_size() as u64;
543
+    if opts.emit_uuid {
544
+        size += 24;
437545
     }
438
-    .wire_size() as u64;
439
-    size += 24;
440546
     size += match kind {
441547
         OutputKind::Executable => {
442548
             raw_dylinker_command("/usr/lib/dyld").cmdsize() as u64
443549
                 + 24
444550
                 + raw_source_version_command(0).cmdsize() as u64
445551
         }
446
-        OutputKind::Dylib => DylibCmd {
447
-            cmd: LC_ID_DYLIB,
448
-            name: dylib_install_name(opts),
449
-            timestamp: 2,
450
-            current_version: pack_version(1, 0, 0),
451
-            compatibility_version: pack_version(1, 0, 0),
552
+        OutputKind::Dylib => {
553
+            DylibCmd {
554
+                cmd: LC_ID_DYLIB,
555
+                name: dylib_install_name(opts),
556
+                timestamp: 2,
557
+                current_version: dylib_current_version(opts),
558
+                compatibility_version: dylib_compatibility_version(opts),
559
+            }
560
+            .wire_size() as u64
561
+                + raw_source_version_command(0).cmdsize() as u64
452562
         }
453
-        .wire_size() as u64,
454563
     };
564
+    for rpath in &opts.rpaths {
565
+        size += RpathCmd {
566
+            path: rpath.clone(),
567
+        }
568
+        .wire_size() as u64;
569
+    }
455570
     for dylib in dylibs {
456571
         size += DylibCmd {
457
-            cmd: LC_LOAD_DYLIB,
572
+            cmd: dylib.kind.load_cmd(),
458573
             name: dylib.install_name.clone(),
459574
             timestamp: 2,
460575
             current_version: dylib.current_version,
@@ -465,6 +580,9 @@ fn estimate_header_size(
465580
     size += SymtabCmd::WIRE_SIZE as u64;
466581
     size += DysymtabCmd::WIRE_SIZE as u64;
467582
     size += 16 * 3;
583
+    if linkedit.loh.is_some() {
584
+        size += 16;
585
+    }
468586
     size += DyldInfoCmd::WIRE_SIZE as u64;
469587
     size
470588
 }
@@ -570,6 +688,22 @@ fn raw_linkedit_command(cmd: u32, dataoff: u32, datasize: u32) -> LoadCommand {
570688
     }
571689
 }
572690
 
691
+fn build_version_command(opts: &LinkOptions) -> BuildVersionCmd {
692
+    let platform = opts.platform_version.unwrap_or(crate::PlatformVersion {
693
+        minos: pack_version(11, 0, 0),
694
+        sdk: pack_version(11, 0, 0),
695
+    });
696
+    BuildVersionCmd {
697
+        platform: PLATFORM_MACOS,
698
+        minos: platform.minos,
699
+        sdk: platform.sdk,
700
+        tools: vec![BuildTool {
701
+            tool: 3,
702
+            version: pack_version(0, 1, 0),
703
+        }],
704
+    }
705
+}
706
+
573707
 fn stable_uuid(layout: &Layout, kind: OutputKind) -> [u8; 16] {
574708
     fn mix(state: &mut u64, bytes: &[u8]) {
575709
         for byte in bytes {
@@ -627,6 +761,9 @@ fn header_flags(layout: &Layout, kind: OutputKind) -> u32 {
627761
 }
628762
 
629763
 fn dylib_install_name(opts: &LinkOptions) -> String {
764
+    if let Some(name) = &opts.install_name {
765
+        return name.clone();
766
+    }
630767
     if let Some(path) = &opts.output {
631768
         if let Some(name) = path.file_name().and_then(|name| name.to_str()) {
632769
             return format!("@rpath/{name}");
@@ -636,12 +773,23 @@ fn dylib_install_name(opts: &LinkOptions) -> String {
636773
     "@rpath/a.out.dylib".to_string()
637774
 }
638775
 
776
+fn dylib_current_version(opts: &LinkOptions) -> u32 {
777
+    opts.current_version
778
+        .unwrap_or_else(|| pack_version(1, 0, 0))
779
+}
780
+
781
+fn dylib_compatibility_version(opts: &LinkOptions) -> u32 {
782
+    opts.compatibility_version
783
+        .unwrap_or_else(|| pack_version(1, 0, 0))
784
+}
785
+
639786
 #[derive(Debug, Clone, PartialEq, Eq)]
640787
 pub struct LinkEditPlan {
641788
     base_off: u32,
642789
     pub symtab: SymtabCmd,
643790
     pub dysymtab: DysymtabCmd,
644791
     pub dyld_info: DyldInfoCmd,
792
+    pub loh: Option<LinkEditDataCmd>,
645793
     pub function_starts: LinkEditDataCmd,
646794
     pub data_in_code: LinkEditDataCmd,
647795
     pub symtab_bytes: Vec<u8>,
@@ -651,12 +799,14 @@ pub struct LinkEditPlan {
651799
     weak_bind_bytes: Vec<u8>,
652800
     lazy_bind_bytes: Vec<u8>,
653801
     export_bytes: Vec<u8>,
802
+    loh_bytes: Vec<u8>,
654803
     function_starts_bytes: Vec<u8>,
655804
     data_in_code_bytes: Vec<u8>,
656805
     pub strtab_bytes: Vec<u8>,
657806
     code_signature: Option<CodeSignaturePlan>,
658807
     indirect_starts: HashMap<(String, String), u32>,
659808
     lazy_bind_offsets: HashMap<SymbolId, u32>,
809
+    pub map_symbols: Vec<LinkMapSymbol>,
660810
 }
661811
 
662812
 impl LinkEditPlan {
@@ -678,6 +828,10 @@ impl LinkEditPlan {
678828
     pub fn lazy_bind_offset(&self, symbol: SymbolId) -> Option<u32> {
679829
         self.lazy_bind_offsets.get(&symbol).copied()
680830
     }
831
+
832
+    pub fn loh_bytes(&self) -> &[u8] {
833
+        &self.loh_bytes
834
+    }
681835
 }
682836
 
683837
 fn build_linkedit_plan(
@@ -686,6 +840,16 @@ fn build_linkedit_plan(
686840
     opts: &LinkOptions,
687841
     inputs: Option<LinkEditInputs<'_>>,
688842
 ) -> Result<LinkEditPlan, WriteError> {
843
+    build_linkedit_plan_profiled(layout, kind, opts, inputs).map(|(plan, _)| plan)
844
+}
845
+
846
+fn build_linkedit_plan_profiled(
847
+    layout: &Layout,
848
+    kind: OutputKind,
849
+    opts: &LinkOptions,
850
+    inputs: Option<LinkEditInputs<'_>>,
851
+) -> Result<(LinkEditPlan, LinkEditBuildTimings), WriteError> {
852
+    let mut timings = LinkEditBuildTimings::default();
689853
     let linkedit = layout
690854
         .segment("__LINKEDIT")
691855
         .cloned()
@@ -693,54 +857,77 @@ fn build_linkedit_plan(
693857
     let base_off = u32_fit(linkedit.file_off, "linkedit file offset")?;
694858
 
695859
     let Some(inputs) = inputs else {
696
-        return Ok(LinkEditPlan {
697
-            base_off,
698
-            symtab: SymtabCmd {
699
-                symoff: base_off,
700
-                nsyms: 0,
701
-                stroff: base_off,
702
-                strsize: 8,
703
-            },
704
-            dysymtab: DysymtabCmd::default(),
705
-            dyld_info: DyldInfoCmd::default(),
706
-            function_starts: LinkEditDataCmd {
707
-                dataoff: base_off,
708
-                datasize: 0,
709
-            },
710
-            data_in_code: LinkEditDataCmd {
711
-                dataoff: base_off,
712
-                datasize: 0,
860
+        let phase_started = std::time::Instant::now();
861
+        let code_signature = Some(build_code_signature(
862
+            layout,
863
+            kind,
864
+            opts,
865
+            base_off as u64 + 8,
866
+        )?);
867
+        timings.code_signature += phase_started.elapsed();
868
+        return Ok((
869
+            LinkEditPlan {
870
+                base_off,
871
+                symtab: SymtabCmd {
872
+                    symoff: base_off,
873
+                    nsyms: 0,
874
+                    stroff: base_off,
875
+                    strsize: 8,
876
+                },
877
+                dysymtab: DysymtabCmd::default(),
878
+                dyld_info: DyldInfoCmd::default(),
879
+                loh: None,
880
+                function_starts: LinkEditDataCmd {
881
+                    dataoff: base_off,
882
+                    datasize: 0,
883
+                },
884
+                data_in_code: LinkEditDataCmd {
885
+                    dataoff: base_off,
886
+                    datasize: 0,
887
+                },
888
+                symtab_bytes: Vec::new(),
889
+                indirect_bytes: Vec::new(),
890
+                rebase_bytes: Vec::new(),
891
+                bind_bytes: Vec::new(),
892
+                weak_bind_bytes: Vec::new(),
893
+                lazy_bind_bytes: Vec::new(),
894
+                export_bytes: Vec::new(),
895
+                loh_bytes: Vec::new(),
896
+                function_starts_bytes: Vec::new(),
897
+                data_in_code_bytes: Vec::new(),
898
+                strtab_bytes: vec![0; 8],
899
+                code_signature,
900
+                indirect_starts: HashMap::new(),
901
+                lazy_bind_offsets: HashMap::new(),
902
+                map_symbols: Vec::new(),
713903
             },
714
-            symtab_bytes: Vec::new(),
715
-            indirect_bytes: Vec::new(),
716
-            rebase_bytes: Vec::new(),
717
-            bind_bytes: Vec::new(),
718
-            weak_bind_bytes: Vec::new(),
719
-            lazy_bind_bytes: Vec::new(),
720
-            export_bytes: Vec::new(),
721
-            function_starts_bytes: Vec::new(),
722
-            data_in_code_bytes: Vec::new(),
723
-            strtab_bytes: vec![0; 8],
724
-            code_signature: Some(build_code_signature(
725
-                layout,
726
-                kind,
727
-                opts,
728
-                base_off as u64 + 8,
729
-            )?),
730
-            indirect_starts: HashMap::new(),
731
-            lazy_bind_offsets: HashMap::new(),
732
-        });
904
+            timings,
905
+        ));
733906
     };
734907
     let sym_table = inputs.0.sym_table;
735908
     let synthetic_plan = inputs.0.synthetic_plan;
736909
 
910
+    let phase_started = std::time::Instant::now();
737911
     let imports = collect_imports(sym_table, synthetic_plan)?;
738912
     let import_lookup: HashMap<SymbolId, &ImportSymbolRecord> = imports
739913
         .iter()
740914
         .map(|record| (record.symbol, record))
741915
         .collect();
742
-    let symbol_plan = build_output_symbols(layout, kind, opts.strip_locals, inputs, &imports)?;
743
-    let mut symtab_bytes = Vec::new();
916
+    let visibility = SymbolVisibilityPolicy::from_opts(opts)?;
917
+    let (symbol_plan, symbol_plan_timings) = build_output_symbols_profiled(
918
+        layout,
919
+        kind,
920
+        opts.dead_strip,
921
+        opts.strip_locals,
922
+        &visibility,
923
+        inputs,
924
+        &imports,
925
+    )?;
926
+    timings.symbol_plan += phase_started.elapsed();
927
+    timings.symbol_plan_locals += symbol_plan_timings.locals;
928
+    timings.symbol_plan_globals += symbol_plan_timings.globals;
929
+    timings.symbol_plan_strtab += symbol_plan_timings.strtab;
930
+    let mut symtab_bytes = Vec::with_capacity(symbol_plan.symbols.len() * NLIST_SIZE);
744931
     write_nlist_table(&symbol_plan.symbols, &mut symtab_bytes);
745932
 
746933
     let mut indirect_symbols = Vec::new();
@@ -775,16 +962,37 @@ fn build_linkedit_plan(
775962
         indirect_bytes.extend_from_slice(&index.to_le_bytes());
776963
     }
777964
 
965
+    let dyld_started = std::time::Instant::now();
966
+    let phase_started = std::time::Instant::now();
778967
     let bind_streams = build_bind_streams(layout, synthetic_plan, &import_lookup)?;
779
-    let rebase_bytes = pad_dyld_info_stream(build_rebase_stream(layout, synthetic_plan, inputs)?);
780968
     let bind_bytes = pad_dyld_info_stream(bind_streams.bind);
781969
     let weak_bind_bytes = pad_dyld_info_stream(bind_streams.weak_bind);
782970
     let lazy_bind_bytes = pad_dyld_info_stream(bind_streams.lazy_bind);
971
+    timings.dyld_bind += phase_started.elapsed();
972
+    let phase_started = std::time::Instant::now();
973
+    let rebase_bytes = pad_dyld_info_stream(build_rebase_stream(layout, synthetic_plan, inputs)?);
974
+    timings.dyld_rebase += phase_started.elapsed();
975
+    let phase_started = std::time::Instant::now();
783976
     let export_bytes = pad_dyld_info_stream(build_export_trie(&symbol_plan.exports));
977
+    timings.dyld_export += phase_started.elapsed();
978
+    timings.dyld_info += dyld_started.elapsed();
979
+
980
+    let phase_started = std::time::Instant::now();
981
+    let loh_bytes = build_loh(
982
+        layout,
983
+        inputs.0.layout_inputs,
984
+        inputs.0.atom_table,
985
+        inputs.0.icf_redirects,
986
+    )?;
784987
     let function_starts_bytes =
785988
         build_function_starts(layout, inputs.0.layout_inputs, inputs.0.atom_table)?;
786
-    let data_in_code_bytes =
787
-        build_data_in_code(layout, inputs.0.layout_inputs, inputs.0.atom_table)?;
989
+    let data_in_code_bytes = build_data_in_code(
990
+        layout,
991
+        inputs.0.layout_inputs,
992
+        inputs.0.atom_table,
993
+        inputs.0.icf_redirects,
994
+    )?;
995
+    timings.metadata_tables += phase_started.elapsed();
788996
 
789997
     let mut cursor = base_off as u64;
790998
     let rebase_off = place_optional_block(&mut cursor, rebase_bytes.len(), "rebase stream offset")?;
@@ -800,6 +1008,7 @@ fn build_linkedit_plan(
8001008
         "lazy bind stream offset",
8011009
     )?;
8021010
     let export_off = place_optional_block(&mut cursor, export_bytes.len(), "export trie offset")?;
1011
+    let loh = place_optional_linkedit_data_block(&mut cursor, loh_bytes.len(), "LOH offset")?;
8031012
     let function_starts = place_linkedit_data_block(
8041013
         &mut cursor,
8051014
         function_starts_bytes.len(),
@@ -819,47 +1028,56 @@ fn build_linkedit_plan(
8191028
         "string table offset",
8201029
     )?;
8211030
     let regular_end = stroff as u64 + symbol_plan.strtab_bytes.len() as u64;
822
-    Ok(LinkEditPlan {
823
-        base_off,
824
-        symtab: SymtabCmd {
825
-            symoff,
826
-            nsyms: symbol_plan.symbols.len() as u32,
827
-            stroff,
828
-            strsize: symbol_plan.strtab_bytes.len() as u32,
829
-        },
830
-        dysymtab: DysymtabCmd {
831
-            indirectsymoff,
832
-            nindirectsyms: indirect_symbols.len() as u32,
833
-            ..symbol_plan.dysymtab
834
-        },
835
-        dyld_info: DyldInfoCmd {
836
-            rebase_off,
837
-            rebase_size: rebase_bytes.len() as u32,
838
-            bind_off: bindoff,
839
-            bind_size: bind_bytes.len() as u32,
840
-            weak_bind_off,
841
-            weak_bind_size: weak_bind_bytes.len() as u32,
842
-            lazy_bind_off,
843
-            lazy_bind_size: lazy_bind_bytes.len() as u32,
844
-            export_off,
845
-            export_size: export_bytes.len() as u32,
1031
+    let phase_started = std::time::Instant::now();
1032
+    let code_signature = Some(build_code_signature(layout, kind, opts, regular_end)?);
1033
+    timings.code_signature += phase_started.elapsed();
1034
+    Ok((
1035
+        LinkEditPlan {
1036
+            base_off,
1037
+            symtab: SymtabCmd {
1038
+                symoff,
1039
+                nsyms: symbol_plan.symbols.len() as u32,
1040
+                stroff,
1041
+                strsize: symbol_plan.strtab_bytes.len() as u32,
1042
+            },
1043
+            dysymtab: DysymtabCmd {
1044
+                indirectsymoff,
1045
+                nindirectsyms: indirect_symbols.len() as u32,
1046
+                ..symbol_plan.dysymtab
1047
+            },
1048
+            dyld_info: DyldInfoCmd {
1049
+                rebase_off,
1050
+                rebase_size: rebase_bytes.len() as u32,
1051
+                bind_off: bindoff,
1052
+                bind_size: bind_bytes.len() as u32,
1053
+                weak_bind_off,
1054
+                weak_bind_size: weak_bind_bytes.len() as u32,
1055
+                lazy_bind_off,
1056
+                lazy_bind_size: lazy_bind_bytes.len() as u32,
1057
+                export_off,
1058
+                export_size: export_bytes.len() as u32,
1059
+            },
1060
+            loh,
1061
+            function_starts,
1062
+            data_in_code,
1063
+            symtab_bytes,
1064
+            indirect_bytes,
1065
+            rebase_bytes,
1066
+            bind_bytes,
1067
+            weak_bind_bytes,
1068
+            lazy_bind_bytes,
1069
+            export_bytes,
1070
+            loh_bytes,
1071
+            function_starts_bytes,
1072
+            data_in_code_bytes,
1073
+            strtab_bytes: symbol_plan.strtab_bytes,
1074
+            code_signature,
1075
+            indirect_starts,
1076
+            lazy_bind_offsets: bind_streams.lazy_offsets,
1077
+            map_symbols: symbol_plan.map_symbols,
8461078
         },
847
-        function_starts,
848
-        data_in_code,
849
-        symtab_bytes,
850
-        indirect_bytes,
851
-        rebase_bytes,
852
-        bind_bytes,
853
-        weak_bind_bytes,
854
-        lazy_bind_bytes,
855
-        export_bytes,
856
-        function_starts_bytes,
857
-        data_in_code_bytes,
858
-        strtab_bytes: symbol_plan.strtab_bytes,
859
-        code_signature: Some(build_code_signature(layout, kind, opts, regular_end)?),
860
-        indirect_starts,
861
-        lazy_bind_offsets: bind_streams.lazy_offsets,
862
-    })
1079
+        timings,
1080
+    ))
8631081
 }
8641082
 
8651083
 fn build_code_signature(
@@ -909,11 +1127,51 @@ struct OutputSymbolSpec {
9091127
     n_sect: u8,
9101128
     n_desc: u16,
9111129
     n_value: u64,
1130
+    size: u64,
1131
+    file_index: usize,
1132
+}
1133
+
1134
+#[derive(Debug, Clone)]
1135
+struct SymbolVisibilityPolicy {
1136
+    exported: Vec<String>,
1137
+    unexported: Vec<String>,
1138
+}
1139
+
1140
+impl SymbolVisibilityPolicy {
1141
+    fn from_opts(opts: &LinkOptions) -> Result<Self, WriteError> {
1142
+        let mut exported = opts.exported_symbols.clone();
1143
+        let mut unexported = opts.unexported_symbols.clone();
1144
+        for path in &opts.exported_symbols_lists {
1145
+            exported.extend(read_symbol_patterns(path)?);
1146
+        }
1147
+        for path in &opts.unexported_symbols_lists {
1148
+            unexported.extend(read_symbol_patterns(path)?);
1149
+        }
1150
+        Ok(Self {
1151
+            exported,
1152
+            unexported,
1153
+        })
1154
+    }
1155
+
1156
+    fn hides(&self, name: &str) -> bool {
1157
+        if !self.exported.is_empty()
1158
+            && !self
1159
+                .exported
1160
+                .iter()
1161
+                .any(|pattern| wildcard_matches(pattern, name))
1162
+        {
1163
+            return true;
1164
+        }
1165
+        self.unexported
1166
+            .iter()
1167
+            .any(|pattern| wildcard_matches(pattern, name))
1168
+    }
9121169
 }
9131170
 
9141171
 #[derive(Debug, Clone)]
9151172
 struct SymbolTablePlan {
9161173
     symbols: Vec<InputSymbol>,
1174
+    map_symbols: Vec<LinkMapSymbol>,
9171175
     strtab_bytes: Vec<u8>,
9181176
     symbol_indices: HashMap<SymbolId, u32>,
9191177
     exports: Vec<ExportEntry>,
@@ -988,36 +1246,13 @@ fn collect_rebase_sites(
9881246
         synthetic_plan,
9891247
         inputs.0.sym_table,
9901248
     )?);
991
-    let mut reloc_cache: HashMap<(InputId, u8), Vec<Reloc>> = HashMap::new();
9921249
     let input_map: HashMap<InputId, &ObjectFile> = inputs
9931250
         .0
9941251
         .layout_inputs
9951252
         .iter()
9961253
         .map(|input| (input.id, input.object))
9971254
         .collect();
998
-
999
-    for input in inputs.0.layout_inputs {
1000
-        for (sect_idx, section) in input.object.sections.iter().enumerate() {
1001
-            if section.raw_relocs.is_empty() {
1002
-                continue;
1003
-            }
1004
-            let raws = parse_raw_relocs(&section.raw_relocs, 0, section.nreloc).map_err(|err| {
1005
-                WriteError::MalformedRelocations(
1006
-                    input.object.path.clone(),
1007
-                    (sect_idx + 1) as u8,
1008
-                    err.to_string(),
1009
-                )
1010
-            })?;
1011
-            let relocs = parse_relocs(&raws).map_err(|err| {
1012
-                WriteError::MalformedRelocations(
1013
-                    input.object.path.clone(),
1014
-                    (sect_idx + 1) as u8,
1015
-                    err.to_string(),
1016
-                )
1017
-            })?;
1018
-            reloc_cache.insert((input.id, (sect_idx + 1) as u8), relocs);
1019
-        }
1020
-    }
1255
+    let symbol_name_index = build_symbol_name_index(inputs.0.sym_table);
10211256
 
10221257
     for section in &layout.sections {
10231258
         if !matches!(section.segment.as_str(), "__DATA" | "__DATA_CONST") {
@@ -1035,12 +1270,14 @@ fn collect_rebase_sites(
10351270
             let Some(obj) = input_map.get(&atom.origin).copied() else {
10361271
                 continue;
10371272
             };
1038
-            let relocs = reloc_cache
1273
+            let relocs = inputs
1274
+                .0
1275
+                .parsed_relocs
10391276
                 .get(&(atom.origin, atom.input_section))
10401277
                 .map(Vec::as_slice)
10411278
                 .unwrap_or(&[]);
10421279
             for reloc in relocs_for_rebase(relocs, atom) {
1043
-                if !reloc_needs_rebase(obj, reloc, inputs.0.sym_table) {
1280
+                if !reloc_needs_rebase(obj, reloc, inputs.0.sym_table, &symbol_name_index) {
10441281
                     continue;
10451282
                 }
10461283
                 let local_offset = reloc.offset.saturating_sub(atom.input_offset) as u64;
@@ -1125,7 +1362,12 @@ fn relocs_for_rebase<'a>(
11251362
     })
11261363
 }
11271364
 
1128
-fn reloc_needs_rebase(obj: &ObjectFile, reloc: Reloc, sym_table: &SymbolTable) -> bool {
1365
+fn reloc_needs_rebase(
1366
+    obj: &ObjectFile,
1367
+    reloc: Reloc,
1368
+    sym_table: &SymbolTable,
1369
+    symbol_name_index: &HashMap<String, SymbolId>,
1370
+) -> bool {
11291371
     if reloc.kind != RelocKind::Unsigned
11301372
         || reloc.length != RelocLength::Quad
11311373
         || reloc.pcrel
@@ -1140,7 +1382,7 @@ fn reloc_needs_rebase(obj: &ObjectFile, reloc: Reloc, sym_table: &SymbolTable) -
11401382
             let Some(input_sym) = obj.symbols.get(sym_idx as usize) else {
11411383
                 return false;
11421384
             };
1143
-            match symbol_referent_id(obj, reloc.referent, sym_table) {
1385
+            match symbol_referent_id(obj, reloc.referent, symbol_name_index) {
11441386
                 Some(symbol_id) => match sym_table.get(symbol_id) {
11451387
                     Symbol::DylibImport { .. } => false,
11461388
                     Symbol::Defined { atom, .. } => atom.0 != 0,
@@ -1153,20 +1395,29 @@ fn reloc_needs_rebase(obj: &ObjectFile, reloc: Reloc, sym_table: &SymbolTable) -
11531395
     }
11541396
 }
11551397
 
1398
+fn build_symbol_name_index(sym_table: &SymbolTable) -> HashMap<String, SymbolId> {
1399
+    sym_table
1400
+        .iter()
1401
+        .map(|(symbol_id, symbol)| {
1402
+            (
1403
+                sym_table.interner.resolve(symbol.name()).to_string(),
1404
+                symbol_id,
1405
+            )
1406
+        })
1407
+        .collect()
1408
+}
1409
+
11561410
 fn symbol_referent_id(
11571411
     obj: &ObjectFile,
11581412
     referent: Referent,
1159
-    sym_table: &SymbolTable,
1413
+    symbol_name_index: &HashMap<String, SymbolId>,
11601414
 ) -> Option<SymbolId> {
11611415
     let Referent::Symbol(sym_idx) = referent else {
11621416
         return None;
11631417
     };
11641418
     let input_sym = obj.symbols.get(sym_idx as usize)?;
11651419
     let name = obj.symbol_name(input_sym).ok()?;
1166
-    let (symbol_id, _) = sym_table
1167
-        .iter()
1168
-        .find(|(_, symbol)| sym_table.interner.resolve(symbol.name()) == name)?;
1169
-    Some(symbol_id)
1420
+    symbol_name_index.get(name).copied()
11701421
 }
11711422
 
11721423
 fn build_function_starts(
@@ -1178,10 +1429,7 @@ fn build_function_starts(
11781429
         .segment("__TEXT")
11791430
         .ok_or(WriteError::MissingSegment("__TEXT"))?
11801431
         .vm_addr;
1181
-    let input_map: HashMap<InputId, &ObjectFile> = inputs
1182
-        .iter()
1183
-        .map(|input| (input.id, input.object))
1184
-        .collect();
1432
+    let symbol_offsets = build_function_start_symbol_index(inputs);
11851433
     let mut starts = Vec::new();
11861434
 
11871435
     for section in &layout.sections {
@@ -1196,36 +1444,19 @@ fn build_function_starts(
11961444
                     section.addr + placed.offset + alt.offset_within_atom as u64 - image_base,
11971445
                 );
11981446
             }
1199
-            let Some(object) = input_map.get(&atom.origin) else {
1200
-                continue;
1201
-            };
1202
-            let Some(input_section) = object
1203
-                .sections
1204
-                .get((atom.input_section as usize).saturating_sub(1))
1447
+            let Some(section_symbols) = symbol_offsets.get(&(atom.origin, atom.input_section))
12051448
             else {
12061449
                 continue;
12071450
             };
1208
-            let atom_start = input_section.addr + atom.input_offset as u64;
1451
+            let atom_start = atom.input_offset as u64;
12091452
             let atom_end = atom_start + atom.size as u64;
1210
-            for input_sym in &object.symbols {
1211
-                if input_sym.stab_kind().is_some()
1212
-                    || input_sym.kind() != SymKind::Sect
1213
-                    || input_sym.alt_entry()
1214
-                    || input_sym.sect_idx() != atom.input_section
1215
-                {
1216
-                    continue;
1217
-                }
1218
-                let Ok(name) = object.symbol_name(input_sym) else {
1219
-                    continue;
1220
-                };
1221
-                if is_assembler_temporary_symbol(name) {
1222
-                    continue;
1223
-                }
1224
-                let value = input_sym.value();
1225
-                if !(atom_start < value && value < atom_end) {
1226
-                    continue;
1227
-                }
1228
-                starts.push(section.addr + placed.offset + (value - atom_start) - image_base);
1453
+            let start_idx = section_symbols.partition_point(|&offset| offset <= atom_start);
1454
+            let end_idx = section_symbols.partition_point(|&offset| offset < atom_end);
1455
+            if start_idx >= end_idx {
1456
+                continue;
1457
+            }
1458
+            for &offset in &section_symbols[start_idx..end_idx] {
1459
+                starts.push(section.addr + placed.offset + (offset - atom_start) - image_base);
12291460
             }
12301461
         }
12311462
     }
@@ -1249,10 +1480,44 @@ fn build_function_starts(
12491480
     Ok(out)
12501481
 }
12511482
 
1483
+type FunctionStartSymbolIndex = HashMap<(InputId, u8), Vec<u64>>;
1484
+
1485
+fn build_function_start_symbol_index(inputs: &[LayoutInput<'_>]) -> FunctionStartSymbolIndex {
1486
+    let mut out: FunctionStartSymbolIndex = HashMap::new();
1487
+    for input in inputs {
1488
+        for input_sym in &input.object.symbols {
1489
+            if input_sym.stab_kind().is_some()
1490
+                || input_sym.kind() != SymKind::Sect
1491
+                || input_sym.alt_entry()
1492
+            {
1493
+                continue;
1494
+            }
1495
+            let Ok(name) = input.object.symbol_name(input_sym) else {
1496
+                continue;
1497
+            };
1498
+            if is_assembler_temporary_symbol(name) {
1499
+                continue;
1500
+            }
1501
+            let Some(section) = input.object.section_for_symbol(input_sym) else {
1502
+                continue;
1503
+            };
1504
+            out.entry((input.id, input_sym.sect_idx()))
1505
+                .or_default()
1506
+                .push(input_sym.value().saturating_sub(section.addr));
1507
+        }
1508
+    }
1509
+    for offsets in out.values_mut() {
1510
+        offsets.sort_unstable();
1511
+        offsets.dedup();
1512
+    }
1513
+    out
1514
+}
1515
+
12521516
 fn build_data_in_code(
12531517
     layout: &Layout,
12541518
     inputs: &[LayoutInput<'_>],
12551519
     atom_table: &AtomTable,
1520
+    icf_redirects: Option<&HashMap<crate::resolve::AtomId, crate::resolve::AtomId>>,
12561521
 ) -> Result<Vec<u8>, WriteError> {
12571522
     #[derive(Clone, Copy)]
12581523
     struct RemappedEntry {
@@ -1264,14 +1529,14 @@ fn build_data_in_code(
12641529
     }
12651530
 
12661531
     let atoms_by_input_section = atom_table.by_input_section();
1532
+    let atom_ranges = build_atom_range_index(atom_table, &atoms_by_input_section, icf_redirects);
12671533
     let mut remapped = Vec::new();
12681534
     for (input_order, input) in inputs.iter().enumerate() {
12691535
         for (input_entry_index, entry) in input.object.data_in_code.iter().copied().enumerate() {
12701536
             let (section_index, section_relative) =
12711537
                 remap_data_in_code_to_section(input.object, entry)?;
12721538
             let (atom_id, atom_delta) = find_containing_atom_range(
1273
-                atom_table,
1274
-                &atoms_by_input_section,
1539
+                &atom_ranges,
12751540
                 input.id,
12761541
                 section_index,
12771542
                 section_relative,
@@ -1321,6 +1586,17 @@ fn build_data_in_code(
13211586
     Ok(out)
13221587
 }
13231588
 
1589
+fn build_loh(
1590
+    _layout: &Layout,
1591
+    _inputs: &[LayoutInput<'_>],
1592
+    _atom_table: &AtomTable,
1593
+    _icf_redirects: Option<&HashMap<crate::resolve::AtomId, crate::resolve::AtomId>>,
1594
+) -> Result<Vec<u8>, WriteError> {
1595
+    // Current Apple ld omits LC_LINKER_OPTIMIZATION_HINT from final linked
1596
+    // executables and dylibs on our parity corpus, so we do the same.
1597
+    Ok(Vec::new())
1598
+}
1599
+
13241600
 fn remap_data_in_code_to_section(
13251601
     object: &ObjectFile,
13261602
     entry: DataInCodeEntry,
@@ -1424,17 +1700,40 @@ fn collect_imports(
14241700
     Ok(out)
14251701
 }
14261702
 
1427
-fn build_output_symbols(
1703
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1704
+struct SymbolPlanBuildTimings {
1705
+    locals: Duration,
1706
+    globals: Duration,
1707
+    strtab: Duration,
1708
+}
1709
+
1710
+fn build_output_symbols_profiled(
14281711
     layout: &Layout,
14291712
     kind: OutputKind,
1713
+    dead_strip: bool,
14301714
     strip_locals: bool,
1715
+    visibility: &SymbolVisibilityPolicy,
14311716
     inputs: LinkEditInputs<'_>,
14321717
     imports: &[ImportSymbolRecord],
1433
-) -> Result<SymbolTablePlan, WriteError> {
1718
+) -> Result<(SymbolTablePlan, SymbolPlanBuildTimings), WriteError> {
14341719
     let sym_table = inputs.0.sym_table;
14351720
     let atom_sections = atom_section_ordinals(layout);
1721
+    let atom_addrs = atom_addresses(layout);
14361722
     let atoms_by_input_section = inputs.0.atom_table.by_input_section();
1723
+    let atom_ranges = build_atom_range_index(
1724
+        inputs.0.atom_table,
1725
+        &atoms_by_input_section,
1726
+        inputs.0.icf_redirects,
1727
+    );
1728
+    let file_index_by_input: HashMap<InputId, usize> = inputs
1729
+        .0
1730
+        .layout_inputs
1731
+        .iter()
1732
+        .enumerate()
1733
+        .map(|(idx, input)| (input.id, idx + 1))
1734
+        .collect();
14371735
     let image_base = layout.segment("__TEXT").map(|seg| seg.vm_addr).unwrap_or(0);
1736
+    let mut timings = SymbolPlanBuildTimings::default();
14381737
     let mut locals = Vec::new();
14391738
     let mut external_defineds = Vec::new();
14401739
     let mut undefineds = Vec::with_capacity(imports.len());
@@ -1444,34 +1743,51 @@ fn build_output_symbols(
14441743
             .segment("__TEXT")
14451744
             .ok_or(WriteError::MissingSegment("__TEXT"))?
14461745
             .vm_addr;
1447
-        external_defineds.push(OutputSymbolSpec {
1746
+        let hide_header = visibility.hides("__mh_execute_header");
1747
+        let header_partition = if hide_header {
1748
+            OutputSymbolPartition::Local
1749
+        } else {
1750
+            OutputSymbolPartition::ExternalDefined
1751
+        };
1752
+        let header_type = defined_symbol_type(hide_header);
1753
+        let target = if hide_header {
1754
+            &mut locals
1755
+        } else {
1756
+            &mut external_defineds
1757
+        };
1758
+        target.push(OutputSymbolSpec {
14481759
             symbol: None,
14491760
             name: "__mh_execute_header".to_string(),
1450
-            partition: OutputSymbolPartition::ExternalDefined,
1451
-            n_type: N_SECT | N_EXT,
1761
+            partition: header_partition,
1762
+            n_type: header_type,
14521763
             n_sect: 1,
14531764
             n_desc: REFERENCED_DYNAMICALLY,
14541765
             n_value: text_vmaddr,
1766
+            size: 0,
1767
+            file_index: 0,
14551768
         });
14561769
     }
14571770
 
1771
+    let phase_started = std::time::Instant::now();
14581772
     for input in inputs.0.layout_inputs {
1459
-        collect_local_symbols(
1460
-            layout,
1461
-            inputs.0.atom_table,
1462
-            &atoms_by_input_section,
1463
-            &atom_sections,
1464
-            input.id,
1465
-            input.object,
1466
-            &mut locals,
1467
-        )?;
1773
+        let ctx = LocalSymbolContext {
1774
+            atom_table: inputs.0.atom_table,
1775
+            atom_ranges: &atom_ranges,
1776
+            atom_sections: &atom_sections,
1777
+            atom_addrs: &atom_addrs,
1778
+            input_id: input.id,
1779
+            file_index: file_index_by_input[&input.id],
1780
+        };
1781
+        collect_local_symbols(&ctx, input.object, &mut locals)?;
14681782
     }
14691783
     collect_synthetic_local_symbols(layout, inputs.0.synthetic_plan, &mut locals)?;
1470
-    sort_local_symbols(&mut locals);
1784
+    timings.locals += phase_started.elapsed();
14711785
 
1786
+    let phase_started = std::time::Instant::now();
14721787
     for (symbol_id, symbol) in sym_table.iter() {
14731788
         let Symbol::Defined {
14741789
             name,
1790
+            origin,
14751791
             atom,
14761792
             value,
14771793
             weak,
@@ -1486,16 +1802,30 @@ fn build_output_symbols(
14861802
             continue;
14871803
         }
14881804
         let name = sym_table.interner.resolve(*name).to_string();
1805
+        let hidden = visibility.hides(&name);
14891806
         let (n_type, n_sect, n_value) = if atom.0 == 0 {
1490
-            (absolute_symbol_type(*private_extern), NO_SECT, *value)
1807
+            (absolute_symbol_type(hidden), NO_SECT, *value)
14911808
         } else {
1492
-            let addr = layout
1493
-                .atom_addr(*atom)
1494
-                .ok_or(WriteError::DefinedSymbolAtomMissing(symbol_id, *atom))?;
1809
+            let Some(addr) = atom_addrs.get(atom).copied() else {
1810
+                if dead_strip {
1811
+                    continue;
1812
+                }
1813
+                return Err(WriteError::DefinedSymbolAtomMissing(symbol_id, *atom));
1814
+            };
14951815
             let sect = *atom_sections
14961816
                 .get(atom)
14971817
                 .ok_or(WriteError::DefinedSymbolSectionMissing(symbol_id, *atom))?;
1498
-            (defined_symbol_type(*private_extern), sect, addr + *value)
1818
+            (defined_symbol_type(hidden), sect, addr + *value)
1819
+        };
1820
+        let size = if atom.0 == 0 {
1821
+            0
1822
+        } else {
1823
+            inputs
1824
+                .0
1825
+                .atom_table
1826
+                .get(*atom)
1827
+                .size
1828
+                .saturating_sub(*value as u32) as u64
14991829
         };
15001830
         let mut n_desc = 0;
15011831
         if *weak {
@@ -1504,17 +1834,30 @@ fn build_output_symbols(
15041834
         if *no_dead_strip {
15051835
             n_desc |= N_NO_DEAD_STRIP;
15061836
         }
1507
-        external_defineds.push(OutputSymbolSpec {
1837
+        let partition = if hidden {
1838
+            OutputSymbolPartition::Local
1839
+        } else {
1840
+            OutputSymbolPartition::ExternalDefined
1841
+        };
1842
+        let target = if hidden {
1843
+            &mut locals
1844
+        } else {
1845
+            &mut external_defineds
1846
+        };
1847
+        target.push(OutputSymbolSpec {
15081848
             symbol: Some(symbol_id),
15091849
             name,
1510
-            partition: OutputSymbolPartition::ExternalDefined,
1850
+            partition,
15111851
             n_type,
15121852
             n_sect,
15131853
             n_desc,
15141854
             n_value,
1855
+            size,
1856
+            file_index: file_index_by_input.get(origin).copied().unwrap_or(0),
15151857
         });
15161858
     }
15171859
 
1860
+    sort_local_symbols(&mut locals);
15181861
     external_defineds.sort_by(|lhs, rhs| lhs.name.cmp(&rhs.name));
15191862
     for import in imports {
15201863
         let mut n_desc = import.ordinal << 8;
@@ -1529,9 +1872,12 @@ fn build_output_symbols(
15291872
             n_sect: NO_SECT,
15301873
             n_desc,
15311874
             n_value: 0,
1875
+            size: 0,
1876
+            file_index: 0,
15321877
         });
15331878
     }
15341879
     undefineds.sort_by(|lhs, rhs| lhs.name.cmp(&rhs.name));
1880
+    timings.globals += phase_started.elapsed();
15351881
 
15361882
     let exports = if matches!(kind, OutputKind::Dylib | OutputKind::Executable) {
15371883
         external_defineds
@@ -1553,6 +1899,9 @@ fn build_output_symbols(
15531899
     };
15541900
 
15551901
     let local_count = if strip_locals { 0 } else { locals.len() };
1902
+    let external_defined_count = external_defineds.len();
1903
+    let undefined_count = undefineds.len();
1904
+    let phase_started = std::time::Instant::now();
15561905
     let mut specs = Vec::with_capacity(local_count + external_defineds.len() + undefineds.len());
15571906
     if !strip_locals {
15581907
         specs.extend(locals);
@@ -1560,31 +1909,23 @@ fn build_output_symbols(
15601909
     specs.extend(external_defineds);
15611910
     specs.extend(undefineds);
15621911
 
1563
-    let mut strtab = StringTableBuilder::new();
1564
-    for spec in &specs {
1565
-        strtab.insert(&spec.name);
1566
-    }
1567
-    let (strtab_bytes, strx_by_name) = strtab.finish();
1568
-
1569
-    let nlocalsym = specs
1570
-        .iter()
1571
-        .filter(|spec| spec.partition == OutputSymbolPartition::Local)
1572
-        .count() as u32;
1573
-    let nextdefsym = specs
1574
-        .iter()
1575
-        .filter(|spec| spec.partition == OutputSymbolPartition::ExternalDefined)
1576
-        .count() as u32;
1577
-    let nundefsym = specs
1578
-        .iter()
1579
-        .filter(|spec| spec.partition == OutputSymbolPartition::Undefined)
1580
-        .count() as u32;
1912
+    let (strtab_bytes, strx_by_spec) =
1913
+        StringTableBuilder::build_with_name_offsets(specs.iter().map(|spec| spec.name.as_str()));
15811914
 
15821915
     let mut symbols = Vec::with_capacity(specs.len());
1583
-    let mut symbol_indices = HashMap::new();
1916
+    let mut symbol_indices = HashMap::with_capacity(specs.len());
1917
+    let map_symbols = specs
1918
+        .iter()
1919
+        .filter(|spec| spec.partition != OutputSymbolPartition::Undefined)
1920
+        .map(|spec| LinkMapSymbol {
1921
+            name: spec.name.clone(),
1922
+            addr: spec.n_value,
1923
+            size: spec.size,
1924
+            file_index: spec.file_index,
1925
+        })
1926
+        .collect();
15841927
     for (idx, spec) in specs.into_iter().enumerate() {
1585
-        let strx = *strx_by_name
1586
-            .get(&spec.name)
1587
-            .expect("string table offset missing for output symbol");
1928
+        let strx = strx_by_spec[idx];
15881929
         symbols.push(InputSymbol::from_raw(RawNlist {
15891930
             strx,
15901931
             n_type: spec.n_type,
@@ -1596,22 +1937,27 @@ fn build_output_symbols(
15961937
             symbol_indices.insert(symbol, idx as u32);
15971938
         }
15981939
     }
1599
-
1600
-    Ok(SymbolTablePlan {
1601
-        symbols,
1602
-        strtab_bytes,
1603
-        symbol_indices,
1604
-        exports,
1605
-        dysymtab: DysymtabCmd {
1606
-            ilocalsym: 0,
1607
-            nlocalsym,
1608
-            iextdefsym: nlocalsym,
1609
-            nextdefsym,
1610
-            iundefsym: nlocalsym + nextdefsym,
1611
-            nundefsym,
1612
-            ..DysymtabCmd::default()
1940
+    timings.strtab += phase_started.elapsed();
1941
+
1942
+    Ok((
1943
+        SymbolTablePlan {
1944
+            symbols,
1945
+            map_symbols,
1946
+            strtab_bytes,
1947
+            symbol_indices,
1948
+            exports,
1949
+            dysymtab: DysymtabCmd {
1950
+                ilocalsym: 0,
1951
+                nlocalsym: local_count as u32,
1952
+                iextdefsym: local_count as u32,
1953
+                nextdefsym: external_defined_count as u32,
1954
+                iundefsym: (local_count + external_defined_count) as u32,
1955
+                nundefsym: undefined_count as u32,
1956
+                ..DysymtabCmd::default()
1957
+            },
16131958
         },
1614
-    })
1959
+        timings,
1960
+    ))
16151961
 }
16161962
 
16171963
 fn sort_local_symbols(locals: &mut [OutputSymbolSpec]) {
@@ -1650,16 +1996,14 @@ fn collect_synthetic_local_symbols(
16501996
         n_sect: u8::try_from(section_index + 1).expect("section index should fit in n_sect"),
16511997
         n_desc: 0,
16521998
         n_value: section.addr + section.synthetic_offset,
1999
+        size: 8,
2000
+        file_index: 0,
16532001
     });
16542002
     Ok(())
16552003
 }
16562004
 
16572005
 fn collect_local_symbols(
1658
-    layout: &Layout,
1659
-    atom_table: &AtomTable,
1660
-    atoms_by_input_section: &HashMap<(InputId, u8), Vec<crate::resolve::AtomId>>,
1661
-    atom_sections: &HashMap<crate::resolve::AtomId, u8>,
1662
-    input_id: InputId,
2006
+    ctx: &LocalSymbolContext<'_>,
16632007
     object: &ObjectFile,
16642008
     out: &mut Vec<OutputSymbolSpec>,
16652009
 ) -> Result<(), WriteError> {
@@ -1681,28 +2025,18 @@ fn collect_local_symbols(
16812025
                     .expect("section symbol without section");
16822026
                 let offset = input_sym.value().saturating_sub(section.addr) as u32;
16832027
                 let (atom_id, delta) = find_containing_atom(
1684
-                    atom_table,
1685
-                    atoms_by_input_section,
1686
-                    input_id,
2028
+                    ctx.atom_ranges,
2029
+                    ctx.input_id,
16872030
                     input_sym.sect_idx(),
16882031
                     offset,
16892032
                 )
16902033
                 .ok_or(WriteError::MissingSegment("__UNKNOWN"))?;
1691
-                let addr =
1692
-                    layout
1693
-                        .atom_addr(atom_id)
1694
-                        .ok_or(WriteError::DefinedSymbolAtomMissing(
1695
-                            SymbolId(u32::MAX),
1696
-                            atom_id,
1697
-                        ))?
1698
-                        + delta as u64;
1699
-                let n_sect =
1700
-                    *atom_sections
1701
-                        .get(&atom_id)
1702
-                        .ok_or(WriteError::DefinedSymbolSectionMissing(
1703
-                            SymbolId(u32::MAX),
1704
-                            atom_id,
1705
-                        ))?;
2034
+                let addr = ctx.atom_addrs.get(&atom_id).copied().ok_or(
2035
+                    WriteError::DefinedSymbolAtomMissing(SymbolId(u32::MAX), atom_id),
2036
+                )? + delta as u64;
2037
+                let n_sect = *ctx.atom_sections.get(&atom_id).ok_or(
2038
+                    WriteError::DefinedSymbolSectionMissing(SymbolId(u32::MAX), atom_id),
2039
+                )?;
17062040
                 out.push(OutputSymbolSpec {
17072041
                     symbol: None,
17082042
                     name,
@@ -1711,6 +2045,8 @@ fn collect_local_symbols(
17112045
                     n_sect,
17122046
                     n_desc: input_sym.raw.n_desc,
17132047
                     n_value: addr,
2048
+                    size: ctx.atom_table.get(atom_id).size.saturating_sub(delta) as u64,
2049
+                    file_index: ctx.file_index,
17142050
                 });
17152051
             }
17162052
             SymKind::Abs => {
@@ -1722,6 +2058,8 @@ fn collect_local_symbols(
17222058
                     n_sect: NO_SECT,
17232059
                     n_desc: input_sym.raw.n_desc,
17242060
                     n_value: input_sym.value(),
2061
+                    size: 0,
2062
+                    file_index: ctx.file_index,
17252063
                 });
17262064
             }
17272065
             SymKind::Undef | SymKind::Indirect => {}
@@ -1730,46 +2068,92 @@ fn collect_local_symbols(
17302068
     Ok(())
17312069
 }
17322070
 
2071
+struct LocalSymbolContext<'a> {
2072
+    atom_table: &'a AtomTable,
2073
+    atom_ranges: &'a AtomRangeIndex,
2074
+    atom_sections: &'a HashMap<crate::resolve::AtomId, u8>,
2075
+    atom_addrs: &'a HashMap<crate::resolve::AtomId, u64>,
2076
+    input_id: InputId,
2077
+    file_index: usize,
2078
+}
2079
+
2080
+#[derive(Debug, Clone, Copy)]
2081
+struct AtomRange {
2082
+    atom: crate::resolve::AtomId,
2083
+    start: u32,
2084
+    end: u32,
2085
+}
2086
+
2087
+type AtomRangeIndex = HashMap<(InputId, u8), Vec<AtomRange>>;
2088
+
17332089
 fn is_assembler_temporary_symbol(name: &str) -> bool {
17342090
     name.starts_with('L') || name.starts_with("ltmp")
17352091
 }
17362092
 
1737
-fn find_containing_atom(
2093
+fn build_atom_range_index(
17382094
     atom_table: &AtomTable,
17392095
     atoms_by_input_section: &HashMap<(InputId, u8), Vec<crate::resolve::AtomId>>,
2096
+    icf_redirects: Option<&HashMap<crate::resolve::AtomId, crate::resolve::AtomId>>,
2097
+) -> AtomRangeIndex {
2098
+    let mut out = HashMap::with_capacity(atoms_by_input_section.len());
2099
+    for (&key, ids) in atoms_by_input_section {
2100
+        let mut ranges = Vec::with_capacity(ids.len());
2101
+        for atom_id in ids {
2102
+            let atom = atom_table.get(*atom_id);
2103
+            ranges.push(AtomRange {
2104
+                atom: canonical_atom(*atom_id, icf_redirects),
2105
+                start: atom.input_offset,
2106
+                end: atom.input_offset.saturating_add(atom.size),
2107
+            });
2108
+        }
2109
+        ranges.sort_by(|lhs, rhs| {
2110
+            lhs.start
2111
+                .cmp(&rhs.start)
2112
+                .then_with(|| lhs.end.cmp(&rhs.end))
2113
+        });
2114
+        out.insert(key, ranges);
2115
+    }
2116
+    out
2117
+}
2118
+
2119
+fn find_containing_atom(
2120
+    atom_ranges: &AtomRangeIndex,
17402121
     input_id: InputId,
17412122
     input_section: u8,
17422123
     offset: u32,
17432124
 ) -> Option<(crate::resolve::AtomId, u32)> {
1744
-    find_containing_atom_range(
1745
-        atom_table,
1746
-        atoms_by_input_section,
1747
-        input_id,
1748
-        input_section,
1749
-        offset,
1750
-        1,
1751
-    )
2125
+    find_containing_atom_range(atom_ranges, input_id, input_section, offset, 1)
17522126
 }
17532127
 
17542128
 fn find_containing_atom_range(
1755
-    atom_table: &AtomTable,
1756
-    atoms_by_input_section: &HashMap<(InputId, u8), Vec<crate::resolve::AtomId>>,
2129
+    atom_ranges: &AtomRangeIndex,
17572130
     input_id: InputId,
17582131
     input_section: u8,
17592132
     offset: u32,
17602133
     len: u32,
17612134
 ) -> Option<(crate::resolve::AtomId, u32)> {
1762
-    atoms_by_input_section
1763
-        .get(&(input_id, input_section))
1764
-        .and_then(|ids| {
1765
-            ids.iter().find_map(|atom_id| {
1766
-                let atom = atom_table.get(*atom_id);
1767
-                let start = atom.input_offset;
1768
-                let end = atom.input_offset.saturating_add(atom.size);
1769
-                let range_end = offset.checked_add(len)?;
1770
-                (start <= offset && range_end <= end).then_some((*atom_id, offset - start))
1771
-            })
1772
-        })
2135
+    let ranges = atom_ranges.get(&(input_id, input_section))?;
2136
+    let range_end = offset.checked_add(len)?;
2137
+    let idx = ranges.partition_point(|range| range.start <= offset);
2138
+    let range = idx.checked_sub(1).and_then(|idx| ranges.get(idx))?;
2139
+    (range.start <= offset && range_end <= range.end).then_some((range.atom, offset - range.start))
2140
+}
2141
+
2142
+fn canonical_atom(
2143
+    atom_id: crate::resolve::AtomId,
2144
+    redirects: Option<&HashMap<crate::resolve::AtomId, crate::resolve::AtomId>>,
2145
+) -> crate::resolve::AtomId {
2146
+    let Some(redirects) = redirects else {
2147
+        return atom_id;
2148
+    };
2149
+    let mut current = atom_id;
2150
+    while let Some(&next) = redirects.get(&current) {
2151
+        if next == current {
2152
+            break;
2153
+        }
2154
+        current = next;
2155
+    }
2156
+    current
17732157
 }
17742158
 
17752159
 fn input_symbol_type(input_sym: &InputSymbol) -> u8 {
@@ -1798,6 +2182,16 @@ fn atom_section_ordinals(layout: &Layout) -> HashMap<crate::resolve::AtomId, u8>
17982182
     out
17992183
 }
18002184
 
2185
+fn atom_addresses(layout: &Layout) -> HashMap<AtomId, u64> {
2186
+    let mut out = HashMap::new();
2187
+    for section in &layout.sections {
2188
+        for placed in &section.atoms {
2189
+            out.insert(placed.atom, section.addr + placed.offset);
2190
+        }
2191
+    }
2192
+    out
2193
+}
2194
+
18012195
 fn export_symbol_flags(layout: &Layout, n_desc: u16, n_type: u8, n_sect: u8) -> u64 {
18022196
     let mut flags = 0u64;
18032197
     if n_desc & N_WEAK_DEF != 0 {
@@ -1848,19 +2242,61 @@ fn section_is_thread_local(layout: &Layout, n_sect: u8) -> bool {
18482242
 }
18492243
 
18502244
 fn defined_symbol_type(private_extern: bool) -> u8 {
1851
-    let mut n_type = N_SECT | N_EXT;
18522245
     if private_extern {
1853
-        n_type |= N_PEXT;
2246
+        N_SECT | N_PEXT
2247
+    } else {
2248
+        N_SECT | N_EXT
18542249
     }
1855
-    n_type
18562250
 }
18572251
 
18582252
 fn absolute_symbol_type(private_extern: bool) -> u8 {
1859
-    let mut n_type = N_ABS | N_EXT;
18602253
     if private_extern {
1861
-        n_type |= N_PEXT;
2254
+        N_ABS | N_PEXT
2255
+    } else {
2256
+        N_ABS | N_EXT
18622257
     }
1863
-    n_type
2258
+}
2259
+
2260
+fn read_symbol_patterns(path: &PathBuf) -> Result<Vec<String>, WriteError> {
2261
+    let contents = fs::read_to_string(path)
2262
+        .map_err(|err| WriteError::SymbolListRead(path.clone(), err.to_string()))?;
2263
+    Ok(contents
2264
+        .lines()
2265
+        .map(str::trim)
2266
+        .filter(|line| !line.is_empty())
2267
+        .map(ToString::to_string)
2268
+        .collect())
2269
+}
2270
+
2271
+fn wildcard_matches(pattern: &str, value: &str) -> bool {
2272
+    let pattern = pattern.as_bytes();
2273
+    let value = value.as_bytes();
2274
+    let mut p = 0usize;
2275
+    let mut v = 0usize;
2276
+    let mut star = None;
2277
+    let mut backtrack = 0usize;
2278
+
2279
+    while v < value.len() {
2280
+        if p < pattern.len() && (pattern[p] == b'?' || pattern[p] == value[v]) {
2281
+            p += 1;
2282
+            v += 1;
2283
+        } else if p < pattern.len() && pattern[p] == b'*' {
2284
+            star = Some(p);
2285
+            p += 1;
2286
+            backtrack = v;
2287
+        } else if let Some(star_idx) = star {
2288
+            p = star_idx + 1;
2289
+            backtrack += 1;
2290
+            v = backtrack;
2291
+        } else {
2292
+            return false;
2293
+        }
2294
+    }
2295
+
2296
+    while p < pattern.len() && pattern[p] == b'*' {
2297
+        p += 1;
2298
+    }
2299
+    p == pattern.len()
18642300
 }
18652301
 
18662302
 fn place_optional_block(
@@ -1899,6 +2335,17 @@ fn place_linkedit_data_block(
18992335
     })
19002336
 }
19012337
 
2338
+fn place_optional_linkedit_data_block(
2339
+    cursor: &mut u64,
2340
+    size: usize,
2341
+    context: &'static str,
2342
+) -> Result<Option<LinkEditDataCmd>, WriteError> {
2343
+    if size == 0 {
2344
+        return Ok(None);
2345
+    }
2346
+    Ok(Some(place_linkedit_data_block(cursor, size, context)?))
2347
+}
2348
+
19022349
 fn push_indirect_section(
19032350
     indirect_symbols: &mut Vec<u32>,
19042351
     indirect_starts: &mut HashMap<(String, String), u32>,
@@ -1940,6 +2387,7 @@ fn build_bind_streams(
19402387
     let weak_bind = Vec::new();
19412388
     let mut lazy_bind = OpcodeStream::new();
19422389
     let mut lazy_offsets = HashMap::new();
2390
+    let layout_index = BindLayoutIndex::build(layout)?;
19432391
 
19442392
     if let Some(tlv_bootstrap) = synthetic_plan.tlv_bootstrap_symbol {
19452393
         let segment_index = segment_index(layout, "__DATA")?;
@@ -2006,29 +2454,21 @@ fn build_bind_streams(
20062454
             .get(&entry.symbol)
20072455
             .copied()
20082456
             .ok_or(WriteError::ImportSymbolMissing(entry.symbol))?;
2009
-        let atom_addr = layout
2010
-            .atom_addr(entry.atom)
2457
+        let placement = layout_index
2458
+            .atoms
2459
+            .get(&entry.atom)
20112460
             .ok_or(WriteError::DirectBindAtomMissing(entry.atom))?;
2012
-        let section = layout
2013
-            .sections
2014
-            .iter()
2015
-            .find(|section| section.atoms.iter().any(|placed| placed.atom == entry.atom))
2016
-            .ok_or(WriteError::DirectBindSectionMissing(entry.atom))?;
2017
-        if section.segment == "__DATA" && section.name == "__thread_vars" {
2461
+        if placement.is_thread_vars {
20182462
             // `__thread_vars` starts are emitted through the dedicated
20192463
             // `__tlv_bootstrap` pass above. Descriptor tails are rewritten to
20202464
             // template offsets before write, so any generic direct bind landing
20212465
             // back in this section is stale and would override the TLV bind.
20222466
             continue;
20232467
         }
2024
-        let segment_index = segment_index(layout, &section.segment)?;
2025
-        let segment = layout
2026
-            .segment(&section.segment)
2027
-            .ok_or(WriteError::MissingSegment("__UNKNOWN"))?;
2028
-        let slot_addr = atom_addr + entry.atom_offset as u64;
2468
+        let slot_addr = placement.addr + entry.atom_offset as u64;
20292469
         bind_specs.push(BindRecordSpec {
2030
-            segment_index,
2031
-            segment_offset: slot_addr - segment.vm_addr,
2470
+            segment_index: placement.segment_index,
2471
+            segment_offset: slot_addr - placement.segment_vm_addr,
20322472
             ordinal: import.ordinal,
20332473
             name: &import.name,
20342474
             weak_import: import.weak_import,
@@ -2077,6 +2517,59 @@ fn build_bind_streams(
20772517
     })
20782518
 }
20792519
 
2520
+struct BindLayoutIndex {
2521
+    atoms: HashMap<AtomId, BindAtomPlacement>,
2522
+}
2523
+
2524
+#[derive(Clone, Copy)]
2525
+struct BindAtomPlacement {
2526
+    addr: u64,
2527
+    segment_index: u8,
2528
+    segment_vm_addr: u64,
2529
+    is_thread_vars: bool,
2530
+}
2531
+
2532
+impl BindLayoutIndex {
2533
+    fn build(layout: &Layout) -> Result<Self, WriteError> {
2534
+        let mut segment_meta = HashMap::with_capacity(layout.segments.len());
2535
+        for (idx, segment) in layout.segments.iter().enumerate() {
2536
+            segment_meta.insert(
2537
+                segment.name.as_str(),
2538
+                (
2539
+                    u8::try_from(idx).map_err(|_| WriteError::OffsetTooLarge("segment index"))?,
2540
+                    segment.vm_addr,
2541
+                ),
2542
+            );
2543
+        }
2544
+        let atom_count: usize = layout
2545
+            .sections
2546
+            .iter()
2547
+            .map(|section| section.atoms.len())
2548
+            .sum();
2549
+        let mut atoms = HashMap::with_capacity(atom_count);
2550
+        for section in &layout.sections {
2551
+            let Some((segment_index, segment_vm_addr)) =
2552
+                segment_meta.get(section.segment.as_str()).copied()
2553
+            else {
2554
+                continue;
2555
+            };
2556
+            let is_thread_vars = section.segment == "__DATA" && section.name == "__thread_vars";
2557
+            for placed in &section.atoms {
2558
+                atoms.insert(
2559
+                    placed.atom,
2560
+                    BindAtomPlacement {
2561
+                        addr: section.addr + placed.offset,
2562
+                        segment_index,
2563
+                        segment_vm_addr,
2564
+                        is_thread_vars,
2565
+                    },
2566
+                );
2567
+            }
2568
+        }
2569
+        Ok(Self { atoms })
2570
+    }
2571
+}
2572
+
20802573
 fn segment_index(layout: &Layout, name: &str) -> Result<u8, WriteError> {
20812574
     let idx = layout
20822575
         .segments
@@ -2162,12 +2655,15 @@ fn u32_fit(value: u64, what: &'static str) -> Result<u32, WriteError> {
21622655
 #[cfg(test)]
21632656
 mod tests {
21642657
     use crate::atom::{AltEntry, Atom, AtomFlags, AtomSection, AtomTable};
2658
+    use crate::input::ObjectFile;
21652659
     use crate::layout::{Layout, PAGE_SIZE};
21662660
     use crate::leb::read_uleb;
2661
+    use crate::macho::reader::MachHeader64;
21672662
     use crate::resolve::{AtomId, InputId, SymbolId};
21682663
     use crate::section::{
2169
-        OutputAtom, OutputSection, OutputSectionId, OutputSegment, Prot, SectionKind,
2664
+        InputSection, OutputAtom, OutputSection, OutputSectionId, OutputSegment, Prot, SectionKind,
21702665
     };
2666
+    use crate::string_table::StringTable;
21712667
 
21722668
     use super::*;
21732669
 
@@ -2430,6 +2926,160 @@ mod tests {
24302926
         );
24312927
     }
24322928
 
2929
+    #[test]
2930
+    fn function_starts_index_uses_only_interior_named_entries() {
2931
+        let mut atoms = AtomTable::new();
2932
+        let atom_id = atoms.push(Atom {
2933
+            id: AtomId(0),
2934
+            origin: InputId(1),
2935
+            input_section: 1,
2936
+            section: AtomSection::Text,
2937
+            input_offset: 0,
2938
+            size: 16,
2939
+            align_pow2: 2,
2940
+            owner: None,
2941
+            alt_entries: Vec::new(),
2942
+            data: vec![0; 16],
2943
+            flags: AtomFlags::NONE,
2944
+            parent_of: None,
2945
+        });
2946
+        let zero_atom_id = atoms.push(Atom {
2947
+            id: AtomId(0),
2948
+            origin: InputId(1),
2949
+            input_section: 1,
2950
+            section: AtomSection::Text,
2951
+            input_offset: 16,
2952
+            size: 0,
2953
+            align_pow2: 2,
2954
+            owner: None,
2955
+            alt_entries: Vec::new(),
2956
+            data: Vec::new(),
2957
+            flags: AtomFlags::NONE,
2958
+            parent_of: None,
2959
+        });
2960
+        let object = object_with_text_symbols(&[
2961
+            ("_start", 0x1000, 0),
2962
+            ("Ltmp0", 0x1004, 0),
2963
+            ("_middle", 0x1008, 0),
2964
+            ("_alt", 0x100c, N_ALT_ENTRY),
2965
+            ("_end", 0x1010, 0),
2966
+        ]);
2967
+        let inputs = [LayoutInput {
2968
+            id: InputId(1),
2969
+            object: &object,
2970
+            load_order: 0,
2971
+            archive_member_offset: None,
2972
+        }];
2973
+        let layout = Layout {
2974
+            kind: OutputKind::Executable,
2975
+            segments: vec![OutputSegment {
2976
+                name: "__TEXT".into(),
2977
+                sections: vec![OutputSectionId(0)],
2978
+                vm_addr: 0x1_0000_0000,
2979
+                vm_size: 0x4000,
2980
+                file_off: 0,
2981
+                file_size: 0x4000,
2982
+                init_prot: Prot::READ_EXECUTE,
2983
+                max_prot: Prot::READ_EXECUTE,
2984
+                flags: 0,
2985
+            }],
2986
+            sections: vec![OutputSection {
2987
+                segment: "__TEXT".into(),
2988
+                name: "__text".into(),
2989
+                kind: SectionKind::Text,
2990
+                align_pow2: 2,
2991
+                flags: 0,
2992
+                reserved1: 0,
2993
+                reserved2: 0,
2994
+                reserved3: 0,
2995
+                atoms: vec![
2996
+                    OutputAtom {
2997
+                        atom: atom_id,
2998
+                        offset: 0,
2999
+                        size: 16,
3000
+                        data: vec![0; 16],
3001
+                    },
3002
+                    OutputAtom {
3003
+                        atom: zero_atom_id,
3004
+                        offset: 16,
3005
+                        size: 0,
3006
+                        data: Vec::new(),
3007
+                    },
3008
+                ],
3009
+                synthetic_offset: 0,
3010
+                synthetic_data: Vec::new(),
3011
+                addr: 0x1_0000_1000,
3012
+                size: 16,
3013
+                file_off: 0x1000,
3014
+            }],
3015
+        };
3016
+
3017
+        let blob = build_function_starts(&layout, &inputs, &atoms).unwrap();
3018
+        assert_eq!(
3019
+            decode_function_starts_blob(&blob),
3020
+            vec![0x1000, 0x1008, 0x1010]
3021
+        );
3022
+    }
3023
+
3024
+    fn object_with_text_symbols(symbols: &[(&str, u64, u16)]) -> ObjectFile {
3025
+        let mut strings = vec![0];
3026
+        let mut strx = Vec::new();
3027
+        for (name, _, _) in symbols {
3028
+            strx.push(strings.len() as u32);
3029
+            strings.extend_from_slice(name.as_bytes());
3030
+            strings.push(0);
3031
+        }
3032
+        ObjectFile {
3033
+            path: "function-starts-index.o".into(),
3034
+            header: MachHeader64 {
3035
+                magic: MH_MAGIC_64,
3036
+                cputype: CPU_TYPE_ARM64,
3037
+                cpusubtype: CPU_SUBTYPE_ARM64_ALL,
3038
+                filetype: MH_OBJECT,
3039
+                ncmds: 0,
3040
+                sizeofcmds: 0,
3041
+                flags: 0,
3042
+                reserved: 0,
3043
+            },
3044
+            commands: Vec::new(),
3045
+            sections: vec![InputSection {
3046
+                segname: "__TEXT".into(),
3047
+                sectname: "__text".into(),
3048
+                kind: SectionKind::Text,
3049
+                addr: 0x1000,
3050
+                size: 16,
3051
+                align_pow2: 2,
3052
+                flags: 0,
3053
+                offset: 0,
3054
+                reloff: 0,
3055
+                nreloc: 0,
3056
+                reserved1: 0,
3057
+                reserved2: 0,
3058
+                reserved3: 0,
3059
+                data: vec![0; 16],
3060
+                raw_relocs: Vec::new(),
3061
+            }],
3062
+            symbols: symbols
3063
+                .iter()
3064
+                .zip(strx)
3065
+                .map(|((_, value, desc), strx)| {
3066
+                    InputSymbol::from_raw(RawNlist {
3067
+                        strx,
3068
+                        n_type: N_SECT,
3069
+                        n_sect: 1,
3070
+                        n_desc: *desc,
3071
+                        n_value: *value,
3072
+                    })
3073
+                })
3074
+                .collect(),
3075
+            strings: StringTable::from_bytes(strings),
3076
+            symtab: None,
3077
+            dysymtab: None,
3078
+            loh: Vec::new(),
3079
+            data_in_code: Vec::new(),
3080
+        }
3081
+    }
3082
+
24333083
     #[test]
24343084
     fn containing_atom_lookup_reuses_precomputed_section_index() {
24353085
         let mut atoms = AtomTable::new();
@@ -2463,12 +3113,13 @@ mod tests {
24633113
         });
24643114
 
24653115
         let by_input_section = atoms.by_input_section();
3116
+        let atom_ranges = build_atom_range_index(&atoms, &by_input_section, None);
24663117
         assert_eq!(
2467
-            find_containing_atom(&atoms, &by_input_section, InputId(7), 3, 4),
3118
+            find_containing_atom(&atom_ranges, InputId(7), 3, 4),
24683119
             Some((first, 4))
24693120
         );
24703121
         assert_eq!(
2471
-            find_containing_atom_range(&atoms, &by_input_section, InputId(7), 3, 10, 2),
3122
+            find_containing_atom_range(&atom_ranges, InputId(7), 3, 10, 2),
24723123
             Some((second, 2))
24733124
         );
24743125
     }
src/main.rsmodified
77 lines changed — click to load
@@ -2,6 +2,61 @@ use std::process::ExitCode;
22
 
33
 use afs_ld::{args, diag, dump, LinkError, Linker};
44
 
5
+fn usage() -> &'static str {
6
+    "\
7
+Usage: afs-ld [options] <inputs...>
8
+
9
+Options:
10
+  -o <path>                       Write output to <path>
11
+  -dylib                          Emit a dylib instead of an executable
12
+  -e <symbol>                     Set the entry symbol
13
+  -arch arm64                     Select the arm64 target
14
+  -map <path>                     Emit text link map
15
+  -why_live <symbol>              Print a reachability chain for <symbol>
16
+  -l<name> / -l <name>            Search for library
17
+  -L <dir>                        Add library search path
18
+  -framework <name>               Link framework
19
+  -weak_framework <name>          Link weak framework
20
+  -ObjC                           Objective-C archive loading mode (currently a no-op warning)
21
+  -syslibroot <path>              Prefix SDK search roots
22
+  -platform_version macos <min> <sdk>
23
+                                  Set LC_BUILD_VERSION payload
24
+  -r                              Relocatable output (deferred; errors)
25
+  -bundle                         Bundle output (deferred; errors)
26
+  -undefined <error|warning|suppress|dynamic_lookup>
27
+                                  Control unresolved-symbol treatment
28
+  -rpath <path>                   Add LC_RPATH
29
+  -install_name <path>            Override dylib install name
30
+  -current_version <v>            Override dylib current version
31
+  -compatibility_version <v>      Override dylib compatibility version
32
+  -exported_symbols_list <file>   Export only symbols matching file patterns
33
+  -unexported_symbols_list <file> Hide symbols matching file patterns
34
+  -exported_symbol <sym>          Export one symbol/pattern
35
+  -unexported_symbol <sym>        Hide one symbol/pattern
36
+  -x                              Strip local symbols
37
+  -S                              Strip debug symbols (currently a no-op warning)
38
+  -no_uuid                        Omit LC_UUID
39
+  -no_loh                         Accepted for compatibility (currently warns; no effect)
40
+  -thunks=<none|safe|all>         Configure branch thunks
41
+  -dead_strip                     Dead-strip unreferenced code/data
42
+  -icf=safe | -icf=none | -icf=all
43
+                                  Configure identical code folding (`all` currently errors)
44
+  -fixup_chains | -no_fixup_chains
45
+                                  Select chained fixups vs classic dyld info
46
+  -all_load                       Force-load every archive member
47
+  -force_load <archive>           Force-load one archive
48
+  -j <jobs>                       Limit parallel worker jobs (`1` disables parallelism)
49
+  -Wl,<arg,arg,...>               Normalize comma-separated driver flags
50
+  --dump <path>                   Dump a Mach-O file summary
51
+  --dump-archive <path>           Dump an archive summary
52
+  --dump-dylib <path>             Dump a dylib summary
53
+  --dump-tbd <path>               Dump a TBD summary
54
+  -t, -trace                      Print input paths as they are loaded
55
+  -h, --help                      Show this help
56
+  -v, --version                   Show afs-ld version
57
+"
58
+}
59
+
560
 fn main() -> ExitCode {
661
     let argv: Vec<String> = std::env::args().collect();
762
 
@@ -13,6 +68,16 @@ fn main() -> ExitCode {
1368
         }
1469
     };
1570
 
71
+    if opts.show_help {
72
+        print!("{}", usage());
73
+        return ExitCode::SUCCESS;
74
+    }
75
+
76
+    if opts.show_version {
77
+        println!("afs-ld {}", env!("CARGO_PKG_VERSION"));
78
+        return ExitCode::SUCCESS;
79
+    }
80
+
1681
     if let Some(path) = &opts.dump {
1782
         return match dump::dump_file(path) {
1883
             Ok(()) => ExitCode::SUCCESS,
src/reloc/arm64.rsmodified
1463 lines changed — click to load
@@ -1,17 +1,20 @@
11
 use std::collections::HashMap;
22
 use std::fmt;
33
 use std::path::PathBuf;
4
+use std::thread;
45
 
5
-use crate::atom::{Atom, AtomTable};
6
+use crate::atom::{Atom, AtomSection, AtomTable};
67
 use crate::input::ObjectFile;
7
-use crate::layout::{Layout, LayoutInput};
8
+use crate::layout::{ExtraOutputSection, ExtraSectionAnchor, Layout, LayoutInput};
89
 use crate::macho::writer::LinkEditPlan;
9
-use crate::reloc::{parse_raw_relocs, parse_relocs, Referent, Reloc, RelocKind, RelocLength};
10
+use crate::reloc::{ParsedRelocCache, Referent, Reloc, RelocKind, RelocLength};
1011
 use crate::resolve::{InputId, Symbol, SymbolId, SymbolTable};
12
+use crate::section::{OutputAtom, OutputSection, SectionKind};
1113
 use crate::symbol::{InputSymbol, SymKind};
1214
 use crate::synth::stubs::{STUB_HELPER_ENTRY_SIZE, STUB_HELPER_HEADER_SIZE, STUB_SIZE};
1315
 use crate::synth::tlv::THREAD_VARIABLE_DESCRIPTOR_SIZE;
1416
 use crate::synth::SyntheticPlan;
17
+use crate::{LinkOptions, ThunkMode};
1518
 
1619
 #[derive(Debug, Clone, PartialEq, Eq)]
1720
 pub struct RelocError {
@@ -42,6 +45,7 @@ impl std::error::Error for RelocError {}
4245
 
4346
 struct ResolveView<'a> {
4447
     sym_table: &'a SymbolTable,
48
+    symbol_name_index: &'a HashMap<String, SymbolId>,
4549
     atom_table: &'a AtomTable,
4650
     atom_addrs: &'a HashMap<crate::resolve::AtomId, u64>,
4751
     atoms_by_input_section: &'a HashMap<(InputId, u8), Vec<crate::resolve::AtomId>>,
@@ -52,6 +56,7 @@ struct ResolveView<'a> {
5256
     stub_helper_entry_addrs: &'a HashMap<SymbolId, u64>,
5357
     stub_helper_header_addr: Option<u64>,
5458
     dyld_private_addr: Option<u64>,
59
+    icf_redirects: Option<&'a HashMap<crate::resolve::AtomId, crate::resolve::AtomId>>,
5560
 }
5661
 
5762
 struct SyntheticAddressMaps {
@@ -63,54 +68,165 @@ struct SyntheticAddressMaps {
6368
     dyld_private_addr: Option<u64>,
6469
 }
6570
 
71
+pub struct ApplyLayoutPlan<'a> {
72
+    pub synthetic_plan: Option<&'a SyntheticPlan>,
73
+    pub thunk_plan: Option<&'a ThunkPlan>,
74
+    pub linkedit: &'a LinkEditPlan,
75
+    pub icf_redirects: Option<&'a HashMap<crate::resolve::AtomId, crate::resolve::AtomId>>,
76
+    pub parsed_relocs: &'a ParsedRelocCache,
77
+    pub parallel_jobs: usize,
78
+}
79
+
80
+struct InputSectionResolveCtx<'a> {
81
+    obj: &'a ObjectFile,
82
+    atom: &'a Atom,
83
+    kind: RelocKind,
84
+    referent: &'a str,
85
+}
86
+
87
+struct RegularRelocContext<'a> {
88
+    input_map: &'a HashMap<InputId, &'a ObjectFile>,
89
+    atoms: &'a AtomTable,
90
+    resolve: &'a ResolveView<'a>,
91
+    thunk_plan: Option<&'a ThunkPlan>,
92
+    thunk_addrs: Option<&'a HashMap<usize, u64>>,
93
+    parsed_relocs: &'a ParsedRelocCache,
94
+}
95
+
96
+const THUNK_SIZE: u64 = 12;
97
+const BR_X16: u32 = 0xd61f_0200;
98
+const BRANCH26_MAX_FORWARD_DELTA_BYTES: u64 = ((1u64 << 25) - 1) * 4;
99
+
100
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
101
+enum BranchTargetKey {
102
+    Symbol(SymbolId),
103
+    Stub(SymbolId),
104
+    InputSectionOffset {
105
+        origin: InputId,
106
+        input_section: u8,
107
+        input_offset: u32,
108
+    },
109
+}
110
+
111
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
112
+struct ThunkBucketKey {
113
+    island: usize,
114
+    target: BranchTargetKey,
115
+}
116
+
117
+#[derive(Debug, Clone, PartialEq, Eq)]
118
+struct ThunkIsland {
119
+    segment: String,
120
+    after_atom: crate::resolve::AtomId,
121
+}
122
+
123
+#[derive(Debug, Clone, PartialEq, Eq)]
124
+struct ThunkEntry {
125
+    island: usize,
126
+    slot_in_island: usize,
127
+    target: BranchTargetKey,
128
+}
129
+
130
+#[derive(Debug, Clone, PartialEq, Eq, Default)]
131
+pub struct ThunkPlan {
132
+    redirects: HashMap<(crate::resolve::AtomId, u32), usize>,
133
+    islands: Vec<ThunkIsland>,
134
+    entries: Vec<ThunkEntry>,
135
+}
136
+
137
+impl ThunkPlan {
138
+    pub fn split_after_atoms(&self) -> Vec<crate::resolve::AtomId> {
139
+        self.islands
140
+            .iter()
141
+            .map(|island| island.after_atom)
142
+            .collect()
143
+    }
144
+
145
+    pub fn output_sections(&self) -> Vec<ExtraOutputSection> {
146
+        if self.entries.is_empty() {
147
+            return Vec::new();
148
+        }
149
+        let mut counts = vec![0usize; self.islands.len()];
150
+        for entry in &self.entries {
151
+            counts[entry.island] += 1;
152
+        }
153
+        let mut sections = Vec::new();
154
+        for (island, island_desc) in self.islands.iter().enumerate() {
155
+            let count = counts[island];
156
+            if count == 0 {
157
+                continue;
158
+            }
159
+            sections.push(ExtraOutputSection {
160
+                after_section: Some(ExtraSectionAnchor::AfterAtom(island_desc.after_atom)),
161
+                section: OutputSection {
162
+                    segment: island_desc.segment.clone(),
163
+                    name: "__thunks".into(),
164
+                    kind: SectionKind::Text,
165
+                    align_pow2: 2,
166
+                    flags: crate::macho::constants::S_REGULAR
167
+                        | crate::macho::constants::S_ATTR_PURE_INSTRUCTIONS
168
+                        | crate::macho::constants::S_ATTR_SOME_INSTRUCTIONS,
169
+                    reserved1: 0,
170
+                    reserved2: 0,
171
+                    reserved3: 0,
172
+                    atoms: Vec::new(),
173
+                    synthetic_offset: 0,
174
+                    synthetic_data: vec![0; count * THUNK_SIZE as usize],
175
+                    addr: 0,
176
+                    size: (count as u64) * THUNK_SIZE,
177
+                    file_off: 0,
178
+                },
179
+            });
180
+        }
181
+        sections
182
+    }
183
+
184
+    fn redirect_for(&self, atom: crate::resolve::AtomId, atom_offset: u32) -> Option<usize> {
185
+        self.redirects.get(&(atom, atom_offset)).copied()
186
+    }
187
+
188
+    fn thunk_addrs(&self, layout: &Layout) -> HashMap<usize, u64> {
189
+        let bases: HashMap<_, _> = self
190
+            .islands
191
+            .iter()
192
+            .enumerate()
193
+            .filter_map(|(island_idx, island)| {
194
+                find_thunk_section_index(layout, island)
195
+                    .map(|section_idx| (island_idx, layout.sections[section_idx].addr))
196
+            })
197
+            .collect();
198
+        self.entries
199
+            .iter()
200
+            .enumerate()
201
+            .filter_map(|(index, entry)| {
202
+                bases
203
+                    .get(&entry.island)
204
+                    .copied()
205
+                    .map(|base| (index, base + (entry.slot_in_island as u64) * THUNK_SIZE))
206
+            })
207
+            .collect()
208
+    }
209
+}
210
+
66211
 pub fn apply_layout(
67212
     layout: &mut Layout,
68213
     inputs: &[LayoutInput<'_>],
69214
     atoms: &AtomTable,
70215
     sym_table: &SymbolTable,
71
-    synthetic_plan: Option<&SyntheticPlan>,
72
-    linkedit: &LinkEditPlan,
216
+    plan: ApplyLayoutPlan<'_>,
73217
 ) -> Result<(), RelocError> {
74218
     let input_map: HashMap<InputId, &ObjectFile> = inputs
75219
         .iter()
76220
         .map(|input| (input.id, input.object))
77221
         .collect();
78
-    let mut reloc_cache: HashMap<(InputId, u8), Vec<Reloc>> = HashMap::new();
79
-    for input in inputs {
80
-        for (sect_idx, section) in input.object.sections.iter().enumerate() {
81
-            let relocs = if section.nreloc == 0 {
82
-                Vec::new()
83
-            } else {
84
-                let raws =
85
-                    parse_raw_relocs(&section.raw_relocs, 0, section.nreloc).map_err(|err| {
86
-                        RelocError {
87
-                            input: input.object.path.clone(),
88
-                            atom: crate::resolve::AtomId(0),
89
-                            atom_offset: 0,
90
-                            kind: RelocKind::Unsigned,
91
-                            referent: format!("section {},{}", section.segname, section.sectname),
92
-                            detail: err.to_string(),
93
-                        }
94
-                    })?;
95
-                parse_relocs(&raws).map_err(|err| RelocError {
96
-                    input: input.object.path.clone(),
97
-                    atom: crate::resolve::AtomId(0),
98
-                    atom_offset: 0,
99
-                    kind: RelocKind::Unsigned,
100
-                    referent: format!("section {},{}", section.segname, section.sectname),
101
-                    detail: err.to_string(),
102
-                })?
103
-            };
104
-            reloc_cache.insert((input.id, (sect_idx + 1) as u8), relocs);
105
-        }
106
-    }
107
-
108222
     let atom_addrs = atom_address_map(layout);
109223
     let atoms_by_input_section = atoms.by_input_section();
110224
     let section_addrs = input_section_address_map(layout, atoms);
111
-    let synth_addrs = synthetic_address_maps(layout, synthetic_plan);
225
+    let synth_addrs = synthetic_address_maps(layout, plan.synthetic_plan);
226
+    let symbol_name_index = build_symbol_name_index(sym_table);
112227
     let resolve = ResolveView {
113228
         sym_table,
229
+        symbol_name_index: &symbol_name_index,
114230
         atom_table: atoms,
115231
         atom_addrs: &atom_addrs,
116232
         atoms_by_input_section: &atoms_by_input_section,
@@ -121,49 +237,176 @@ pub fn apply_layout(
121237
         stub_helper_entry_addrs: &synth_addrs.stub_helper_entry_addrs,
122238
         stub_helper_header_addr: synth_addrs.stub_helper_header_addr,
123239
         dyld_private_addr: synth_addrs.dyld_private_addr,
240
+        icf_redirects: plan.icf_redirects,
124241
     };
242
+    let thunk_addrs = plan
243
+        .thunk_plan
244
+        .map(|thunk_plan| thunk_plan.thunk_addrs(layout));
245
+
246
+    let regular_ctx = RegularRelocContext {
247
+        input_map: &input_map,
248
+        atoms,
249
+        resolve: &resolve,
250
+        thunk_plan: plan.thunk_plan,
251
+        thunk_addrs: thunk_addrs.as_ref(),
252
+        parsed_relocs: plan.parsed_relocs,
253
+    };
254
+    apply_regular_relocs(layout, &regular_ctx, plan.parallel_jobs)?;
125255
 
126
-    for out_section in &mut layout.sections {
127
-        for placed in &mut out_section.atoms {
128
-            let atom = atoms.get(placed.atom);
129
-            if atom.size == 0 || placed.data.is_empty() {
130
-                continue;
131
-            }
132
-            let obj = input_map.get(&atom.origin).ok_or_else(|| {
133
-                reloc_error(
134
-                    atom,
135
-                    &PathBuf::from("<missing object>"),
136
-                    0,
137
-                    RelocKind::Unsigned,
138
-                    "object",
139
-                    "missing parsed object".to_string(),
140
-                )
141
-            })?;
142
-            let relocs = reloc_cache
143
-                .get(&(atom.origin, atom.input_section))
144
-                .map(Vec::as_slice)
145
-                .unwrap_or(&[]);
146
-            for reloc in relocs_for_atom(relocs, atom) {
147
-                apply_one(&mut placed.data, atom, obj, reloc, &resolve)?;
148
-            }
149
-        }
256
+    if let Some(thunk_plan) = plan.thunk_plan {
257
+        synthesize_thunk_section(layout, thunk_plan, &resolve)?;
150258
     }
151259
 
152
-    if let Some(plan) = synthetic_plan {
260
+    if let Some(synthetic_plan) = plan.synthetic_plan {
153261
         synthesize_thread_variable_section(
154262
             layout,
155
-            plan,
263
+            synthetic_plan,
156264
             atoms,
157265
             &input_map,
158
-            &reloc_cache,
266
+            plan.parsed_relocs,
159267
             &resolve,
160268
         )?;
161
-        synthesize_got_section(layout, plan, &resolve)?;
162
-        synthesize_stub_section(layout, plan, &resolve)?;
163
-        synthesize_lazy_pointer_section(layout, plan, &resolve)?;
164
-        synthesize_stub_helper_section(layout, plan, &resolve, linkedit)?;
269
+        synthesize_got_section(layout, synthetic_plan, &resolve)?;
270
+        synthesize_stub_section(layout, synthetic_plan, &resolve)?;
271
+        synthesize_lazy_pointer_section(layout, synthetic_plan, &resolve)?;
272
+        synthesize_stub_helper_section(layout, synthetic_plan, &resolve, plan.linkedit)?;
273
+    }
274
+
275
+    Ok(())
276
+}
277
+
278
+fn apply_regular_relocs(
279
+    layout: &mut Layout,
280
+    ctx: &RegularRelocContext<'_>,
281
+    parallel_jobs: usize,
282
+) -> Result<(), RelocError> {
283
+    let parallel_jobs = parallel_jobs.max(1);
284
+    for out_section in &mut layout.sections {
285
+        let atom_count = out_section.atoms.len();
286
+        if parallel_jobs == 1 || atom_count < 2 {
287
+            apply_regular_atom_chunk(&mut out_section.atoms, ctx)?;
288
+            continue;
289
+        }
290
+
291
+        let job_count = parallel_jobs.min(atom_count).max(1);
292
+        let chunk_size = atom_count.div_ceil(job_count);
293
+        thread::scope(|scope| {
294
+            let mut handles = Vec::new();
295
+            for chunk in out_section.atoms.chunks_mut(chunk_size) {
296
+                handles.push(scope.spawn(move || apply_regular_atom_chunk(chunk, ctx)));
297
+            }
298
+            for handle in handles {
299
+                handle.join().expect("relocation worker panicked")?;
300
+            }
301
+            Ok::<(), RelocError>(())
302
+        })?;
303
+    }
304
+    Ok(())
305
+}
306
+
307
+fn apply_regular_atom_chunk(
308
+    placed_atoms: &mut [OutputAtom],
309
+    ctx: &RegularRelocContext<'_>,
310
+) -> Result<(), RelocError> {
311
+    for placed in placed_atoms {
312
+        let atom = ctx.atoms.get(placed.atom);
313
+        if atom.size == 0 || placed.data.is_empty() {
314
+            continue;
315
+        }
316
+        let obj = ctx.input_map.get(&atom.origin).ok_or_else(|| {
317
+            reloc_error(
318
+                atom,
319
+                &PathBuf::from("<missing object>"),
320
+                0,
321
+                RelocKind::Unsigned,
322
+                "object",
323
+                "missing parsed object".to_string(),
324
+            )
325
+        })?;
326
+        patch_eh_frame_cie_pointer(&mut placed.data, atom, ctx.resolve)?;
327
+        let relocs = ctx
328
+            .parsed_relocs
329
+            .get(&(atom.origin, atom.input_section))
330
+            .map(Vec::as_slice)
331
+            .unwrap_or(&[]);
332
+        for reloc in relocs_for_atom(relocs, atom) {
333
+            apply_one(
334
+                &mut placed.data,
335
+                atom,
336
+                obj,
337
+                reloc,
338
+                ctx.resolve,
339
+                ctx.thunk_plan,
340
+                ctx.thunk_addrs,
341
+            )?;
342
+        }
343
+    }
344
+    Ok(())
345
+}
346
+
347
+fn patch_eh_frame_cie_pointer(
348
+    bytes: &mut [u8],
349
+    atom: &Atom,
350
+    resolve: &ResolveView<'_>,
351
+) -> Result<(), RelocError> {
352
+    if atom.section != AtomSection::EhFrame || bytes.len() < 8 {
353
+        return Ok(());
354
+    }
355
+    let mut buf = [0u8; 4];
356
+    buf.copy_from_slice(&bytes[4..8]);
357
+    let cie_delta = u32::from_le_bytes(buf);
358
+    if cie_delta == 0 {
359
+        return Ok(());
165360
     }
166361
 
362
+    let cie_offset = atom
363
+        .input_offset
364
+        .checked_add(4)
365
+        .and_then(|value| value.checked_sub(cie_delta))
366
+        .ok_or_else(|| {
367
+            reloc_error(
368
+                atom,
369
+                &PathBuf::from("<eh_frame>"),
370
+                4,
371
+                RelocKind::Unsigned,
372
+                "__eh_frame CIE pointer",
373
+                "invalid CIE back-pointer".to_string(),
374
+            )
375
+        })?;
376
+    let cie_atom = resolve
377
+        .atoms_by_input_section
378
+        .get(&(atom.origin, atom.input_section))
379
+        .and_then(|atom_ids| {
380
+            atom_ids.iter().find_map(|atom_id| {
381
+                let candidate = resolve.atom_table.get(*atom_id);
382
+                let start = candidate.input_offset;
383
+                let end = candidate.input_offset.saturating_add(candidate.size);
384
+                (start <= cie_offset && cie_offset < end).then_some(*atom_id)
385
+            })
386
+        })
387
+        .and_then(|atom_id| resolve.atom_addrs.get(&atom_id).copied())
388
+        .ok_or_else(|| {
389
+            reloc_error(
390
+                atom,
391
+                &PathBuf::from("<eh_frame>"),
392
+                4,
393
+                RelocKind::Unsigned,
394
+                "__eh_frame CIE pointer",
395
+                "eh_frame CIE atom is missing from the final layout".to_string(),
396
+            )
397
+        })?;
398
+    let fde_field = resolve.atom_addrs.get(&atom.id).copied().ok_or_else(|| {
399
+        reloc_error(
400
+            atom,
401
+            &PathBuf::from("<eh_frame>"),
402
+            4,
403
+            RelocKind::Unsigned,
404
+            "__eh_frame CIE pointer",
405
+            "eh_frame atom is missing a final address".to_string(),
406
+        )
407
+    })? + 4;
408
+    let rewritten = fde_field.wrapping_sub(cie_atom) as u32;
409
+    bytes[4..8].copy_from_slice(&rewritten.to_le_bytes());
167410
     Ok(())
168411
 }
169412
 
@@ -198,6 +441,16 @@ fn input_section_address_map(layout: &Layout, atoms: &AtomTable) -> HashMap<(Inp
198441
     out
199442
 }
200443
 
444
+fn atom_output_segment_map(layout: &Layout) -> HashMap<crate::resolve::AtomId, String> {
445
+    let mut out = HashMap::new();
446
+    for section in &layout.sections {
447
+        for placed in &section.atoms {
448
+            out.insert(placed.atom, section.segment.clone());
449
+        }
450
+    }
451
+    out
452
+}
453
+
201454
 fn synthetic_address_maps(
202455
     layout: &Layout,
203456
     synthetic_plan: Option<&SyntheticPlan>,
@@ -284,12 +537,153 @@ fn synthetic_address_maps(
284537
     }
285538
 }
286539
 
540
+pub struct ThunkPlanningContext<'a> {
541
+    pub layout: &'a Layout,
542
+    pub inputs: &'a [LayoutInput<'a>],
543
+    pub atoms: &'a AtomTable,
544
+    pub sym_table: &'a SymbolTable,
545
+    pub synthetic_plan: Option<&'a SyntheticPlan>,
546
+    pub icf_redirects: Option<&'a HashMap<crate::resolve::AtomId, crate::resolve::AtomId>>,
547
+    pub parsed_relocs: &'a ParsedRelocCache,
548
+}
549
+
550
+pub fn plan_thunks(
551
+    opts: &LinkOptions,
552
+    ctx: ThunkPlanningContext<'_>,
553
+) -> Result<Option<ThunkPlan>, RelocError> {
554
+    if opts.thunks == ThunkMode::None {
555
+        return Ok(None);
556
+    }
557
+
558
+    let ThunkPlanningContext {
559
+        layout,
560
+        inputs,
561
+        atoms,
562
+        sym_table,
563
+        synthetic_plan,
564
+        icf_redirects,
565
+        parsed_relocs,
566
+    } = ctx;
567
+
568
+    if opts.thunks == ThunkMode::Safe && layout_fits_branch26_span(layout) {
569
+        return Ok(None);
570
+    }
571
+
572
+    let input_map: HashMap<InputId, &ObjectFile> = inputs
573
+        .iter()
574
+        .map(|input| (input.id, input.object))
575
+        .collect();
576
+    let atom_addrs = atom_address_map(layout);
577
+    let atom_segments = atom_output_segment_map(layout);
578
+    let atoms_by_input_section = atoms.by_input_section();
579
+    let section_addrs = input_section_address_map(layout, atoms);
580
+    let synth_addrs = synthetic_address_maps(layout, synthetic_plan);
581
+    let symbol_name_index = build_symbol_name_index(sym_table);
582
+    let resolve = ResolveView {
583
+        sym_table,
584
+        symbol_name_index: &symbol_name_index,
585
+        atom_table: atoms,
586
+        atom_addrs: &atom_addrs,
587
+        atoms_by_input_section: &atoms_by_input_section,
588
+        section_addrs: &section_addrs,
589
+        stub_addrs: &synth_addrs.stub_addrs,
590
+        got_addrs: &synth_addrs.got_addrs,
591
+        lazy_pointer_addrs: &synth_addrs.lazy_pointer_addrs,
592
+        stub_helper_entry_addrs: &synth_addrs.stub_helper_entry_addrs,
593
+        stub_helper_header_addr: synth_addrs.stub_helper_header_addr,
594
+        dyld_private_addr: synth_addrs.dyld_private_addr,
595
+        icf_redirects,
596
+    };
597
+
598
+    let mut redirects = HashMap::new();
599
+    let mut island_index: HashMap<crate::resolve::AtomId, usize> = HashMap::new();
600
+    let mut index: HashMap<ThunkBucketKey, usize> = HashMap::new();
601
+    let mut islands: Vec<ThunkIsland> = Vec::new();
602
+    let mut entries: Vec<ThunkEntry> = Vec::new();
603
+    for (atom_id, atom) in atoms.iter() {
604
+        let Some(obj) = input_map.get(&atom.origin) else {
605
+            continue;
606
+        };
607
+        let relocs = parsed_relocs
608
+            .get(&(atom.origin, atom.input_section))
609
+            .map(Vec::as_slice)
610
+            .unwrap_or(&[]);
611
+        for reloc in relocs_for_atom(relocs, atom) {
612
+            if reloc.kind != RelocKind::Branch26 {
613
+                continue;
614
+            }
615
+            let local_offset = reloc.offset.saturating_sub(atom.input_offset);
616
+            let Some(place) = resolve.atom_addrs.get(&atom.id).copied() else {
617
+                continue;
618
+            };
619
+            let Some(caller_segment) = atom_segments.get(&atom.id).cloned() else {
620
+                continue;
621
+            };
622
+            let place = place + local_offset as u64;
623
+            let target_key = resolve_branch_target_key(obj, atom, reloc, &resolve)?;
624
+            let target = resolve_branch_target_from_key(obj, atom, reloc, target_key, &resolve)?;
625
+            let needs_thunk = match opts.thunks {
626
+                ThunkMode::None => false,
627
+                ThunkMode::Safe => !branch26_in_range(place, target),
628
+                ThunkMode::All => true,
629
+            };
630
+            if !needs_thunk {
631
+                continue;
632
+            }
633
+            let island = if let Some(&existing) = island_index.get(&atom_id) {
634
+                existing
635
+            } else {
636
+                let next = islands.len();
637
+                islands.push(ThunkIsland {
638
+                    segment: caller_segment.clone(),
639
+                    after_atom: atom_id,
640
+                });
641
+                island_index.insert(atom_id, next);
642
+                next
643
+            };
644
+            let bucket_key = ThunkBucketKey {
645
+                island,
646
+                target: target_key,
647
+            };
648
+            let thunk_index = if let Some(&existing) = index.get(&bucket_key) {
649
+                existing
650
+            } else {
651
+                let next = entries.len();
652
+                let slot_in_island = entries
653
+                    .iter()
654
+                    .filter(|entry| entry.island == island)
655
+                    .count();
656
+                entries.push(ThunkEntry {
657
+                    island,
658
+                    slot_in_island,
659
+                    target: target_key,
660
+                });
661
+                index.insert(bucket_key, next);
662
+                next
663
+            };
664
+            redirects.insert((atom_id, local_offset), thunk_index);
665
+        }
666
+    }
667
+
668
+    if entries.is_empty() {
669
+        Ok(None)
670
+    } else {
671
+        Ok(Some(ThunkPlan {
672
+            redirects,
673
+            islands,
674
+            entries,
675
+        }))
676
+    }
677
+}
678
+
287679
 fn apply_one(
288680
     bytes: &mut [u8],
289681
     atom: &Atom,
290682
     obj: &ObjectFile,
291683
     reloc: Reloc,
292684
     resolve: &ResolveView<'_>,
685
+    thunk_plan: Option<&ThunkPlan>,
686
+    thunk_addrs: Option<&HashMap<usize, u64>>,
293687
 ) -> Result<(), RelocError> {
294688
     let local_offset = reloc.offset.checked_sub(atom.input_offset).ok_or_else(|| {
295689
         reloc_error(
@@ -313,7 +707,7 @@ fn apply_one(
313707
     })? + local_offset as u64;
314708
     match reloc.kind {
315709
         RelocKind::Unsigned => {
316
-            if dylib_import_symbol_id(obj, reloc.referent, resolve.sym_table).is_some() {
710
+            if dylib_import_symbol_id(obj, reloc.referent, resolve).is_some() {
317711
                 if direct_import_bind_supported(reloc) {
318712
                     clear_direct_import_slot(bytes, atom, obj, local_offset, reloc)
319713
                 } else {
@@ -347,15 +741,29 @@ fn apply_one(
347741
             resolve_referent(obj, atom, reloc.kind, reloc.referent, resolve)?,
348742
             resolve,
349743
         ),
350
-        RelocKind::Branch26 => patch_branch26(
351
-            bytes,
352
-            atom,
353
-            obj,
354
-            local_offset,
355
-            reloc,
356
-            place,
357
-            resolve_branch_target(obj, atom, reloc, resolve)?,
358
-        ),
744
+        RelocKind::Branch26 => {
745
+            let target = if let Some(plan) = thunk_plan {
746
+                if let Some(index) = plan.redirect_for(atom.id, local_offset) {
747
+                    thunk_addrs
748
+                        .and_then(|addrs| addrs.get(&index).copied())
749
+                        .ok_or_else(|| {
750
+                            reloc_error(
751
+                                atom,
752
+                                &obj.path,
753
+                                local_offset,
754
+                                reloc.kind,
755
+                                &describe_referent(obj, reloc.referent),
756
+                                "thunk section missing final address".to_string(),
757
+                            )
758
+                        })?
759
+                } else {
760
+                    resolve_branch_target(obj, atom, reloc, resolve)?
761
+                }
762
+            } else {
763
+                resolve_branch_target(obj, atom, reloc, resolve)?
764
+            };
765
+            patch_branch26(bytes, atom, obj, local_offset, reloc, place, target)
766
+        }
359767
         RelocKind::Page21 => patch_page21(
360768
             bytes,
361769
             atom,
@@ -417,7 +825,7 @@ fn apply_one(
417825
         ),
418826
         RelocKind::TlvpLoadPageOff12 => {
419827
             let target = resolve_tlvp_pageoff_target(obj, atom, reloc, resolve)?;
420
-            if dylib_import_symbol_id(obj, reloc.referent, resolve.sym_table).is_some() {
828
+            if dylib_import_symbol_id(obj, reloc.referent, resolve).is_some() {
421829
                 patch_pageoff12(bytes, atom, obj, local_offset, reloc, target)
422830
             } else {
423831
                 patch_tlvp_pageoff12(bytes, atom, obj, local_offset, reloc, target)
@@ -432,19 +840,276 @@ fn resolve_branch_target(
432840
     reloc: Reloc,
433841
     resolve: &ResolveView<'_>,
434842
 ) -> Result<u64, RelocError> {
435
-    if let Some(symbol_id) = dylib_import_symbol_id(obj, reloc.referent, resolve.sym_table) {
436
-        return resolve.stub_addrs.get(&symbol_id).copied().ok_or_else(|| {
437
-            reloc_error(
843
+    let key = resolve_branch_target_key(obj, atom, reloc, resolve)?;
844
+    resolve_branch_target_from_key(obj, atom, reloc, key, resolve)
845
+}
846
+
847
+fn resolve_branch_target_key(
848
+    obj: &ObjectFile,
849
+    atom: &Atom,
850
+    reloc: Reloc,
851
+    resolve: &ResolveView<'_>,
852
+) -> Result<BranchTargetKey, RelocError> {
853
+    if let Some(symbol_id) = dylib_import_symbol_id(obj, reloc.referent, resolve) {
854
+        return Ok(BranchTargetKey::Stub(symbol_id));
855
+    }
856
+    match reloc.referent {
857
+        Referent::Section(section_idx) => Ok(BranchTargetKey::InputSectionOffset {
858
+            origin: atom.origin,
859
+            input_section: section_idx,
860
+            input_offset: 0,
861
+        }),
862
+        Referent::Symbol(sym_idx) => {
863
+            let input_sym = obj.symbols.get(sym_idx as usize).ok_or_else(|| {
864
+                reloc_error(
865
+                    atom,
866
+                    &obj.path,
867
+                    0,
868
+                    reloc.kind,
869
+                    &format!("symbol #{sym_idx}"),
870
+                    "symbol index is out of range".to_string(),
871
+                )
872
+            })?;
873
+            if let Ok(name) = obj.symbol_name(input_sym) {
874
+                if let Some(symbol_id) = resolve.symbol_name_index.get(name).copied() {
875
+                    return Ok(BranchTargetKey::Symbol(symbol_id));
876
+                }
877
+            }
878
+            match input_sym.kind() {
879
+                SymKind::Sect => {
880
+                    let section = obj.section_for_symbol(input_sym).ok_or_else(|| {
881
+                        reloc_error(
882
+                            atom,
883
+                            &obj.path,
884
+                            0,
885
+                            reloc.kind,
886
+                            &describe_input_symbol(obj, input_sym),
887
+                            "section-backed symbol did not resolve to an input section".to_string(),
888
+                        )
889
+                    })?;
890
+                    Ok(BranchTargetKey::InputSectionOffset {
891
+                        origin: atom.origin,
892
+                        input_section: input_sym.sect_idx(),
893
+                        input_offset: input_sym.value().saturating_sub(section.addr) as u32,
894
+                    })
895
+                }
896
+                SymKind::Abs => Err(reloc_error(
897
+                    atom,
898
+                    &obj.path,
899
+                    0,
900
+                    reloc.kind,
901
+                    &describe_input_symbol(obj, input_sym),
902
+                    "absolute BRANCH26 targets are not supported".to_string(),
903
+                )),
904
+                SymKind::Undef => Err(reloc_error(
905
+                    atom,
906
+                    &obj.path,
907
+                    0,
908
+                    reloc.kind,
909
+                    &describe_input_symbol(obj, input_sym),
910
+                    "symbol remained undefined at relocation time".to_string(),
911
+                )),
912
+                SymKind::Indirect => Err(reloc_error(
913
+                    atom,
914
+                    &obj.path,
915
+                    0,
916
+                    reloc.kind,
917
+                    &describe_input_symbol(obj, input_sym),
918
+                    "indirect BRANCH26 targets are not yet supported".to_string(),
919
+                )),
920
+            }
921
+        }
922
+    }
923
+}
924
+
925
+fn resolve_branch_target_from_key(
926
+    obj: &ObjectFile,
927
+    atom: &Atom,
928
+    reloc: Reloc,
929
+    key: BranchTargetKey,
930
+    resolve: &ResolveView<'_>,
931
+) -> Result<u64, RelocError> {
932
+    match key {
933
+        BranchTargetKey::Symbol(symbol_id) => match resolve.sym_table.get(symbol_id) {
934
+            Symbol::Defined {
935
+                atom: target_atom,
936
+                value,
937
+                ..
938
+            } => resolve
939
+                .atom_addrs
940
+                .get(&canonical_atom(*target_atom, resolve.icf_redirects))
941
+                .copied()
942
+                .map(|addr| addr + *value)
943
+                .ok_or_else(|| {
944
+                    reloc_error(
945
+                        atom,
946
+                        &obj.path,
947
+                        reloc.offset.saturating_sub(atom.input_offset),
948
+                        reloc.kind,
949
+                        &describe_referent(obj, reloc.referent),
950
+                        "target atom missing final address".to_string(),
951
+                    )
952
+                }),
953
+            Symbol::DylibImport { .. } => Err(reloc_error(
438954
                 atom,
439955
                 &obj.path,
440956
                 reloc.offset.saturating_sub(atom.input_offset),
441957
                 reloc.kind,
442958
                 &describe_referent(obj, reloc.referent),
443959
                 "dylib import is missing synthetic stub".to_string(),
444
-            )
445
-        });
960
+            )),
961
+            other => Err(reloc_error(
962
+                atom,
963
+                &obj.path,
964
+                reloc.offset.saturating_sub(atom.input_offset),
965
+                reloc.kind,
966
+                &describe_referent(obj, reloc.referent),
967
+                format!("symbol resolved to unsupported state {:?}", other.kind()),
968
+            )),
969
+        },
970
+        BranchTargetKey::Stub(symbol_id) => {
971
+            resolve.stub_addrs.get(&symbol_id).copied().ok_or_else(|| {
972
+                reloc_error(
973
+                    atom,
974
+                    &obj.path,
975
+                    reloc.offset.saturating_sub(atom.input_offset),
976
+                    reloc.kind,
977
+                    &describe_referent(obj, reloc.referent),
978
+                    "dylib import is missing synthetic stub".to_string(),
979
+                )
980
+            })
981
+        }
982
+        BranchTargetKey::InputSectionOffset {
983
+            origin,
984
+            input_section,
985
+            input_offset,
986
+        } => resolve_input_section_offset(
987
+            origin,
988
+            input_section,
989
+            input_offset,
990
+            InputSectionResolveCtx {
991
+                obj,
992
+                atom,
993
+                kind: reloc.kind,
994
+                referent: &describe_referent(obj, reloc.referent),
995
+            },
996
+            resolve,
997
+        ),
446998
     }
447
-    resolve_referent(obj, atom, reloc.kind, reloc.referent, resolve)
999
+}
1000
+
1001
+fn branch26_in_range(place: u64, target: u64) -> bool {
1002
+    let delta = target.wrapping_sub(place) as i64;
1003
+    delta & 0b11 == 0 && fits_signed(delta >> 2, 26)
1004
+}
1005
+
1006
+fn layout_fits_branch26_span(layout: &Layout) -> bool {
1007
+    let mut min_addr = u64::MAX;
1008
+    let mut max_addr = 0u64;
1009
+    for section in &layout.sections {
1010
+        if section.segment == "__LINKEDIT" || section.size == 0 {
1011
+            continue;
1012
+        }
1013
+        min_addr = min_addr.min(section.addr);
1014
+        max_addr = max_addr.max(section.addr.saturating_add(section.size));
1015
+    }
1016
+    min_addr == u64::MAX || max_addr.saturating_sub(min_addr) <= BRANCH26_MAX_FORWARD_DELTA_BYTES
1017
+}
1018
+
1019
+fn synthesize_thunk_section(
1020
+    layout: &mut Layout,
1021
+    plan: &ThunkPlan,
1022
+    resolve: &ResolveView<'_>,
1023
+) -> Result<(), RelocError> {
1024
+    let mut counts: HashMap<usize, usize> = HashMap::new();
1025
+    for entry in &plan.entries {
1026
+        *counts.entry(entry.island).or_insert(0usize) += 1;
1027
+    }
1028
+    for (island_idx, island) in plan.islands.iter().enumerate() {
1029
+        let expected_len = counts.get(&island_idx).copied().unwrap_or(0) * THUNK_SIZE as usize;
1030
+        let section_idx = find_thunk_section_index(layout, island).ok_or_else(|| RelocError {
1031
+            input: PathBuf::from("<synthetic thunks>"),
1032
+            atom: crate::resolve::AtomId(0),
1033
+            atom_offset: (island_idx as u32) * THUNK_SIZE as u32,
1034
+            kind: RelocKind::Branch26,
1035
+            referent: "thunk section".to_string(),
1036
+            detail: format!(
1037
+                "missing thunk section for island after {},{}",
1038
+                island.segment, island.after_atom.0
1039
+            ),
1040
+        })?;
1041
+        let section = &mut layout.sections[section_idx];
1042
+        if section.synthetic_data.len() != expected_len {
1043
+            section.synthetic_data.resize(expected_len, 0);
1044
+        }
1045
+    }
1046
+    for (idx, entry) in plan.entries.iter().enumerate() {
1047
+        let island = &plan.islands[entry.island];
1048
+        let section_idx = find_thunk_section_index(layout, island).ok_or_else(|| RelocError {
1049
+            input: PathBuf::from("<synthetic thunks>"),
1050
+            atom: crate::resolve::AtomId(0),
1051
+            atom_offset: (idx as u32) * THUNK_SIZE as u32,
1052
+            kind: RelocKind::Branch26,
1053
+            referent: "thunk section".to_string(),
1054
+            detail: format!(
1055
+                "missing thunk section for island after {},{}",
1056
+                island.segment, island.after_atom.0
1057
+            ),
1058
+        })?;
1059
+        let section = &mut layout.sections[section_idx];
1060
+        let thunk_addr = section.addr + (entry.slot_in_island as u64) * THUNK_SIZE;
1061
+        let target = match entry.target {
1062
+            BranchTargetKey::Symbol(symbol_id) => match resolve.sym_table.get(symbol_id) {
1063
+                Symbol::Defined { atom, value, .. } => resolve
1064
+                    .atom_addrs
1065
+                    .get(&canonical_atom(*atom, resolve.icf_redirects))
1066
+                    .copied()
1067
+                    .map(|addr| addr + *value),
1068
+                _ => None,
1069
+            },
1070
+            BranchTargetKey::Stub(symbol_id) => resolve.stub_addrs.get(&symbol_id).copied(),
1071
+            BranchTargetKey::InputSectionOffset {
1072
+                origin,
1073
+                input_section,
1074
+                input_offset,
1075
+            } => resolve_input_section_offset_simple(origin, input_section, input_offset, resolve),
1076
+        }
1077
+        .ok_or_else(|| RelocError {
1078
+            input: PathBuf::from("<synthetic thunks>"),
1079
+            atom: crate::resolve::AtomId(0),
1080
+            atom_offset: (idx as u32) * THUNK_SIZE as u32,
1081
+            kind: RelocKind::Branch26,
1082
+            referent: "thunk target".to_string(),
1083
+            detail: "missing final target address".to_string(),
1084
+        })?;
1085
+        let adrp = encode_adrp_reg(16, thunk_addr, target, "thunk target")?;
1086
+        let add = encode_add_x_reg_pageoff(16, target, "thunk target")?;
1087
+        let start = entry.slot_in_island * THUNK_SIZE as usize;
1088
+        section.synthetic_data[start..start + 4].copy_from_slice(&adrp.to_le_bytes());
1089
+        section.synthetic_data[start + 4..start + 8].copy_from_slice(&add.to_le_bytes());
1090
+        section.synthetic_data[start + 8..start + 12].copy_from_slice(&BR_X16.to_le_bytes());
1091
+    }
1092
+    Ok(())
1093
+}
1094
+
1095
+fn find_thunk_section_index(layout: &Layout, island: &ThunkIsland) -> Option<usize> {
1096
+    layout
1097
+        .sections
1098
+        .iter()
1099
+        .enumerate()
1100
+        .skip(1)
1101
+        .find_map(|(idx, section)| {
1102
+            let prev = &layout.sections[idx - 1];
1103
+            (prev.segment == island.segment
1104
+                && prev
1105
+                    .atoms
1106
+                    .last()
1107
+                    .map(|placed| placed.atom == island.after_atom)
1108
+                    .unwrap_or(false)
1109
+                && section.segment == island.segment
1110
+                && section.name == "__thunks")
1111
+                .then_some(idx)
1112
+        })
4481113
 }
4491114
 
4501115
 fn resolve_got_target(
@@ -453,7 +1118,7 @@ fn resolve_got_target(
4531118
     reloc: Reloc,
4541119
     resolve: &ResolveView<'_>,
4551120
 ) -> Result<u64, RelocError> {
456
-    let Some(symbol_id) = symbol_referent_id(obj, reloc.referent, resolve.sym_table) else {
1121
+    let Some(symbol_id) = symbol_referent_id(obj, reloc.referent, resolve) else {
4571122
         return Err(reloc_error(
4581123
             atom,
4591124
             &obj.path,
@@ -476,9 +1141,14 @@ fn resolve_got_target(
4761141
 }
4771142
 
4781143
 fn got_reloc_relaxes_locally(obj: &ObjectFile, reloc: Reloc, resolve: &ResolveView<'_>) -> bool {
479
-    symbol_referent_id(obj, reloc.referent, resolve.sym_table)
480
-        .map(|symbol_id| !matches!(resolve.sym_table.get(symbol_id), Symbol::DylibImport { .. }))
481
-        .unwrap_or(false)
1144
+    match symbol_referent_id(obj, reloc.referent, resolve) {
1145
+        Some(symbol_id) => match resolve.sym_table.get(symbol_id) {
1146
+            Symbol::DylibImport { .. } => false,
1147
+            Symbol::Defined { .. } => true,
1148
+            _ => true,
1149
+        },
1150
+        None => true,
1151
+    }
4821152
 }
4831153
 
4841154
 fn resolve_tlvp_target(
@@ -487,7 +1157,7 @@ fn resolve_tlvp_target(
4871157
     reloc: Reloc,
4881158
     resolve: &ResolveView<'_>,
4891159
 ) -> Result<u64, RelocError> {
490
-    if dylib_import_symbol_id(obj, reloc.referent, resolve.sym_table).is_some() {
1160
+    if dylib_import_symbol_id(obj, reloc.referent, resolve).is_some() {
4911161
         return resolve_got_target(obj, atom, reloc, resolve);
4921162
     }
4931163
     resolve_referent(obj, atom, reloc.kind, reloc.referent, resolve)
@@ -499,7 +1169,7 @@ fn resolve_tlvp_pageoff_target(
4991169
     reloc: Reloc,
5001170
     resolve: &ResolveView<'_>,
5011171
 ) -> Result<u64, RelocError> {
502
-    if dylib_import_symbol_id(obj, reloc.referent, resolve.sym_table).is_some() {
1172
+    if dylib_import_symbol_id(obj, reloc.referent, resolve).is_some() {
5031173
         return resolve_got_target(obj, atom, reloc, resolve);
5041174
     }
5051175
     resolve_referent(obj, atom, reloc.kind, reloc.referent, resolve)
@@ -552,12 +1222,15 @@ fn resolve_symbol_referent(
5521222
     })?;
5531223
 
5541224
     if let Ok(name) = obj.symbol_name(input_sym) {
555
-        if let Some((_, symbol)) = resolve
556
-            .sym_table
557
-            .iter()
558
-            .find(|(_, symbol)| resolve.sym_table.interner.resolve(symbol.name()) == name)
559
-        {
560
-            return resolve_global_symbol(obj, atom, kind, name, symbol, resolve);
1225
+        if let Some(symbol_id) = resolve.symbol_name_index.get(name).copied() {
1226
+            return resolve_global_symbol(
1227
+                obj,
1228
+                atom,
1229
+                kind,
1230
+                name,
1231
+                resolve.sym_table.get(symbol_id),
1232
+                resolve,
1233
+            );
5611234
         }
5621235
     }
5631236
 
@@ -567,27 +1240,35 @@ fn resolve_symbol_referent(
5671240
 fn dylib_import_symbol_id(
5681241
     obj: &ObjectFile,
5691242
     referent: Referent,
570
-    sym_table: &SymbolTable,
1243
+    resolve: &ResolveView<'_>,
5711244
 ) -> Option<SymbolId> {
572
-    let symbol_id = symbol_referent_id(obj, referent, sym_table)?;
573
-    matches!(sym_table.get(symbol_id), Symbol::DylibImport { .. }).then_some(symbol_id)
1245
+    let symbol_id = symbol_referent_id(obj, referent, resolve)?;
1246
+    matches!(resolve.sym_table.get(symbol_id), Symbol::DylibImport { .. }).then_some(symbol_id)
5741247
 }
5751248
 
5761249
 fn symbol_referent_id(
5771250
     obj: &ObjectFile,
5781251
     referent: Referent,
579
-    sym_table: &SymbolTable,
1252
+    resolve: &ResolveView<'_>,
5801253
 ) -> Option<SymbolId> {
5811254
     let Referent::Symbol(sym_idx) = referent else {
5821255
         return None;
5831256
     };
5841257
     let input_sym = obj.symbols.get(sym_idx as usize)?;
5851258
     let name = obj.symbol_name(input_sym).ok()?;
586
-    let (symbol_id, symbol) = sym_table
1259
+    resolve.symbol_name_index.get(name).copied()
1260
+}
1261
+
1262
+fn build_symbol_name_index(sym_table: &SymbolTable) -> HashMap<String, SymbolId> {
1263
+    sym_table
5871264
         .iter()
588
-        .find(|(_, symbol)| sym_table.interner.resolve(symbol.name()) == name)?;
589
-    let _ = symbol;
590
-    Some(symbol_id)
1265
+        .map(|(symbol_id, symbol)| {
1266
+            (
1267
+                sym_table.interner.resolve(symbol.name()).to_string(),
1268
+                symbol_id,
1269
+            )
1270
+        })
1271
+        .collect()
5911272
 }
5921273
 
5931274
 fn resolve_global_symbol(
@@ -672,12 +1353,14 @@ fn resolve_input_symbol_at_origin(
6721353
             let section_offset = input_sym.value().saturating_sub(section.addr) as u32;
6731354
             resolve_input_section_offset(
6741355
                 origin,
675
-                obj,
676
-                atom,
677
-                kind,
6781356
                 input_sym.sect_idx(),
6791357
                 section_offset,
680
-                &describe_input_symbol(obj, input_sym),
1358
+                InputSectionResolveCtx {
1359
+                    obj,
1360
+                    atom,
1361
+                    kind,
1362
+                    referent: &describe_input_symbol(obj, input_sym),
1363
+                },
6811364
                 resolve,
6821365
             )
6831366
         }
@@ -702,12 +1385,9 @@ fn resolve_input_symbol_at_origin(
7021385
 
7031386
 fn resolve_input_section_offset(
7041387
     origin: InputId,
705
-    obj: &ObjectFile,
706
-    atom: &Atom,
707
-    kind: RelocKind,
7081388
     input_section: u8,
7091389
     input_offset: u32,
710
-    referent: &str,
1390
+    ctx: InputSectionResolveCtx<'_>,
7111391
     resolve: &ResolveView<'_>,
7121392
 ) -> Result<u64, RelocError> {
7131393
     if let Some(atom_ids) = resolve.atoms_by_input_section.get(&(origin, input_section)) {
@@ -723,17 +1403,18 @@ fn resolve_input_section_offset(
7231403
                 None
7241404
             }
7251405
         }) {
1406
+            let target_atom = canonical_atom(target_atom, resolve.icf_redirects);
7261407
             let atom_addr = resolve
7271408
                 .atom_addrs
7281409
                 .get(&target_atom)
7291410
                 .copied()
7301411
                 .ok_or_else(|| {
7311412
                     reloc_error(
732
-                        atom,
733
-                        &obj.path,
1413
+                        ctx.atom,
1414
+                        &ctx.obj.path,
7341415
                         0,
735
-                        kind,
736
-                        referent,
1416
+                        ctx.kind,
1417
+                        ctx.referent,
7371418
                         "section-backed symbol's containing atom is missing a final address"
7381419
                             .to_string(),
7391420
                     )
@@ -748,17 +1429,68 @@ fn resolve_input_section_offset(
7481429
         .copied()
7491430
         .ok_or_else(|| {
7501431
             reloc_error(
751
-                atom,
752
-                &obj.path,
1432
+                ctx.atom,
1433
+                &ctx.obj.path,
7531434
                 0,
754
-                kind,
755
-                referent,
1435
+                ctx.kind,
1436
+                ctx.referent,
7561437
                 "section-backed symbol's output section is missing".to_string(),
7571438
             )
7581439
         })?;
7591440
     Ok(section_addr + input_offset as u64)
7601441
 }
7611442
 
1443
+fn resolve_input_section_offset_simple(
1444
+    origin: InputId,
1445
+    input_section: u8,
1446
+    input_offset: u32,
1447
+    resolve: &ResolveView<'_>,
1448
+) -> Option<u64> {
1449
+    if let Some(atom_ids) = resolve.atoms_by_input_section.get(&(origin, input_section)) {
1450
+        if let Some((target_atom, delta)) = atom_ids.iter().find_map(|atom_id| {
1451
+            let candidate = resolve.atom_table.get(*atom_id);
1452
+            let start = candidate.input_offset;
1453
+            let end = candidate.input_offset.saturating_add(candidate.size);
1454
+            if start <= input_offset && input_offset < end {
1455
+                Some((*atom_id, input_offset - start))
1456
+            } else if input_offset == end {
1457
+                Some((*atom_id, candidate.size))
1458
+            } else {
1459
+                None
1460
+            }
1461
+        }) {
1462
+            let target_atom = canonical_atom(target_atom, resolve.icf_redirects);
1463
+            return resolve
1464
+                .atom_addrs
1465
+                .get(&target_atom)
1466
+                .copied()
1467
+                .map(|addr| addr + delta as u64);
1468
+        }
1469
+    }
1470
+    resolve
1471
+        .section_addrs
1472
+        .get(&(origin, input_section))
1473
+        .copied()
1474
+        .map(|section_addr| section_addr + input_offset as u64)
1475
+}
1476
+
1477
+fn canonical_atom(
1478
+    atom_id: crate::resolve::AtomId,
1479
+    redirects: Option<&HashMap<crate::resolve::AtomId, crate::resolve::AtomId>>,
1480
+) -> crate::resolve::AtomId {
1481
+    let Some(redirects) = redirects else {
1482
+        return atom_id;
1483
+    };
1484
+    let mut current = atom_id;
1485
+    while let Some(&next) = redirects.get(&current) {
1486
+        if next == current {
1487
+            break;
1488
+        }
1489
+        current = next;
1490
+    }
1491
+    current
1492
+}
1493
+
7621494
 fn patch_unsigned(
7631495
     bytes: &mut [u8],
7641496
     atom: &Atom,
@@ -1917,6 +2649,21 @@ fn reloc_error(
19172649
 #[cfg(test)]
19182650
 mod tests {
19192651
     use super::*;
2652
+    use std::path::PathBuf;
2653
+
2654
+    use crate::atom::{AtomFlags, AtomSection};
2655
+    use crate::input::ObjectFile;
2656
+    use crate::macho::constants::{
2657
+        CPU_SUBTYPE_ARM64_ALL, CPU_TYPE_ARM64, MH_MAGIC_64, MH_OBJECT, N_EXT, N_SECT,
2658
+        S_ATTR_PURE_INSTRUCTIONS, S_ATTR_SOME_INSTRUCTIONS, S_REGULAR,
2659
+    };
2660
+    use crate::macho::reader::MachHeader64;
2661
+    use crate::reloc::{write_raw_relocs, write_relocs};
2662
+    use crate::resolve::{InsertOutcome, Symbol, SymbolTable};
2663
+    use crate::section::InputSection;
2664
+    use crate::string_table::StringTable;
2665
+    use crate::symbol::{InputSymbol, RawNlist};
2666
+    use crate::OutputKind;
19202667
 
19212668
     #[test]
19222669
     fn branch26_patches_low_bits() {
@@ -1979,4 +2726,265 @@ mod tests {
19792726
         assert!(!fits_signed(1 << 25, 26));
19802727
         assert!(!fits_signed(-(1 << 25) - 1, 26));
19812728
     }
2729
+
2730
+    #[test]
2731
+    fn branch26_span_fast_path_rejects_only_large_non_linkedit_images() {
2732
+        let small = Layout {
2733
+            kind: OutputKind::Executable,
2734
+            segments: Vec::new(),
2735
+            sections: vec![
2736
+                output_section("__TEXT", "__text", 0x1_0000_0000, 0x100),
2737
+                output_section("__DATA", "__data", 0x1_0001_0000, 0x100),
2738
+                output_section("__LINKEDIT", "__linkedit", 0x1_8000_0000, 0x1000),
2739
+            ],
2740
+        };
2741
+        assert!(layout_fits_branch26_span(&small));
2742
+
2743
+        let large = Layout {
2744
+            kind: OutputKind::Executable,
2745
+            segments: Vec::new(),
2746
+            sections: vec![
2747
+                output_section("__TEXT", "__text", 0x1_0000_0000, 0x100),
2748
+                output_section(
2749
+                    "__DATA",
2750
+                    "__data",
2751
+                    0x1_0000_0000 + BRANCH26_MAX_FORWARD_DELTA_BYTES + 1,
2752
+                    0x100,
2753
+                ),
2754
+            ],
2755
+        };
2756
+        assert!(!layout_fits_branch26_span(&large));
2757
+    }
2758
+
2759
+    #[test]
2760
+    fn thunk_plan_splits_monolithic_text_section_into_multiple_islands() {
2761
+        let gap = 0x0900_0000u32;
2762
+        let caller2_offset = 4 + gap;
2763
+        let target_offset = 8 + gap * 2;
2764
+        let raw_relocs = branch26_raw_relocs(&[0, caller2_offset]);
2765
+        let object = thunk_test_object(raw_relocs, target_offset as u64, target_offset as u64 + 4);
2766
+
2767
+        let mut atoms = AtomTable::new();
2768
+        let caller1 = atoms.push(test_atom(0, 4));
2769
+        atoms.push(test_atom(4, gap));
2770
+        let caller2 = atoms.push(test_atom(caller2_offset, 4));
2771
+        atoms.push(test_atom(caller2_offset + 4, gap));
2772
+        let target = atoms.push(test_atom(target_offset, 4));
2773
+
2774
+        let mut sym_table = SymbolTable::new();
2775
+        let target_name = sym_table.intern("_target");
2776
+        let insert = sym_table
2777
+            .insert(Symbol::Defined {
2778
+                name: target_name,
2779
+                origin: crate::resolve::InputId(0),
2780
+                atom: target,
2781
+                value: 0,
2782
+                weak: false,
2783
+                private_extern: false,
2784
+                no_dead_strip: false,
2785
+            })
2786
+            .unwrap();
2787
+        assert!(matches!(insert, InsertOutcome::Inserted(_)));
2788
+
2789
+        let inputs = [LayoutInput {
2790
+            id: crate::resolve::InputId(0),
2791
+            object: &object,
2792
+            load_order: 0,
2793
+            archive_member_offset: None,
2794
+        }];
2795
+        let opts = LinkOptions {
2796
+            kind: OutputKind::Executable,
2797
+            ..LinkOptions::default()
2798
+        };
2799
+        let base_layout = Layout::build(OutputKind::Executable, &inputs, &atoms, 0);
2800
+        let parsed_relocs = crate::macho::writer::build_parsed_reloc_cache(&inputs).unwrap();
2801
+        let plan = plan_thunks(
2802
+            &opts,
2803
+            ThunkPlanningContext {
2804
+                layout: &base_layout,
2805
+                inputs: &inputs,
2806
+                atoms: &atoms,
2807
+                sym_table: &sym_table,
2808
+                synthetic_plan: None,
2809
+                icf_redirects: None,
2810
+                parsed_relocs: &parsed_relocs,
2811
+            },
2812
+        )
2813
+        .unwrap()
2814
+        .unwrap();
2815
+
2816
+        assert_eq!(
2817
+            plan.redirect_for(caller1, 0),
2818
+            Some(0),
2819
+            "expected first caller to use its own thunk"
2820
+        );
2821
+        assert_eq!(
2822
+            plan.redirect_for(caller2, 0),
2823
+            Some(1),
2824
+            "expected second caller to use its own thunk"
2825
+        );
2826
+
2827
+        let rebuilt = Layout::build_with_synthetics_and_extra_filtered(
2828
+            OutputKind::Executable,
2829
+            &inputs,
2830
+            &atoms,
2831
+            0,
2832
+            None,
2833
+            None,
2834
+            crate::layout::ExtraLayoutSections {
2835
+                extra_sections: &plan.output_sections(),
2836
+                split_after_atoms: &plan.split_after_atoms(),
2837
+            },
2838
+        );
2839
+        let text_section_count = rebuilt
2840
+            .sections
2841
+            .iter()
2842
+            .filter(|section| section.segment == "__TEXT" && section.name == "__text")
2843
+            .count();
2844
+        let thunk_section_count = rebuilt
2845
+            .sections
2846
+            .iter()
2847
+            .filter(|section| section.segment == "__TEXT" && section.name == "__thunks")
2848
+            .count();
2849
+        assert_eq!(
2850
+            text_section_count, 3,
2851
+            "expected the monolithic text section to split around the two caller atoms"
2852
+        );
2853
+        assert_eq!(
2854
+            thunk_section_count, 2,
2855
+            "expected one thunk island per far caller atom"
2856
+        );
2857
+        let text_sequence: Vec<_> = rebuilt
2858
+            .sections
2859
+            .iter()
2860
+            .filter(|section| section.segment == "__TEXT")
2861
+            .map(|section| section.name.as_str())
2862
+            .collect();
2863
+        assert_eq!(
2864
+            text_sequence,
2865
+            vec!["__text", "__thunks", "__text", "__thunks", "__text"]
2866
+        );
2867
+
2868
+        let replan = plan_thunks(
2869
+            &opts,
2870
+            ThunkPlanningContext {
2871
+                layout: &rebuilt,
2872
+                inputs: &inputs,
2873
+                atoms: &atoms,
2874
+                sym_table: &sym_table,
2875
+                synthetic_plan: None,
2876
+                icf_redirects: None,
2877
+                parsed_relocs: &parsed_relocs,
2878
+            },
2879
+        )
2880
+        .unwrap()
2881
+        .unwrap();
2882
+        assert_eq!(
2883
+            replan, plan,
2884
+            "expected thunk planning to converge once the intra-section islands exist"
2885
+        );
2886
+    }
2887
+
2888
+    fn branch26_raw_relocs(offsets: &[u32]) -> Vec<u8> {
2889
+        let relocs: Vec<_> = offsets
2890
+            .iter()
2891
+            .copied()
2892
+            .map(|offset| crate::reloc::Reloc {
2893
+                offset,
2894
+                kind: RelocKind::Branch26,
2895
+                length: RelocLength::Word,
2896
+                pcrel: true,
2897
+                referent: Referent::Symbol(0),
2898
+                addend: 0,
2899
+                subtrahend: None,
2900
+            })
2901
+            .collect();
2902
+        let raws = write_relocs(&relocs).unwrap();
2903
+        let mut out = Vec::new();
2904
+        write_raw_relocs(&raws, &mut out);
2905
+        out
2906
+    }
2907
+
2908
+    fn output_section(segment: &str, name: &str, addr: u64, size: u64) -> OutputSection {
2909
+        OutputSection {
2910
+            segment: segment.into(),
2911
+            name: name.into(),
2912
+            kind: SectionKind::Text,
2913
+            align_pow2: 2,
2914
+            flags: 0,
2915
+            reserved1: 0,
2916
+            reserved2: 0,
2917
+            reserved3: 0,
2918
+            atoms: Vec::new(),
2919
+            synthetic_offset: 0,
2920
+            synthetic_data: Vec::new(),
2921
+            addr,
2922
+            size,
2923
+            file_off: 0,
2924
+        }
2925
+    }
2926
+
2927
+    fn thunk_test_object(raw_relocs: Vec<u8>, target_offset: u64, section_size: u64) -> ObjectFile {
2928
+        let strings = b"\0_target\0".to_vec();
2929
+        ObjectFile {
2930
+            path: PathBuf::from("/tmp/thunk-plan.o"),
2931
+            header: MachHeader64 {
2932
+                magic: MH_MAGIC_64,
2933
+                cputype: CPU_TYPE_ARM64,
2934
+                cpusubtype: CPU_SUBTYPE_ARM64_ALL,
2935
+                filetype: MH_OBJECT,
2936
+                ncmds: 0,
2937
+                sizeofcmds: 0,
2938
+                flags: 0,
2939
+                reserved: 0,
2940
+            },
2941
+            commands: Vec::new(),
2942
+            sections: vec![InputSection {
2943
+                segname: "__TEXT".into(),
2944
+                sectname: "__text".into(),
2945
+                kind: crate::section::SectionKind::Text,
2946
+                addr: 0,
2947
+                size: section_size,
2948
+                align_pow2: 2,
2949
+                flags: S_REGULAR | S_ATTR_PURE_INSTRUCTIONS | S_ATTR_SOME_INSTRUCTIONS,
2950
+                offset: 0,
2951
+                reloff: 0,
2952
+                nreloc: (raw_relocs.len() / 8) as u32,
2953
+                reserved1: 0,
2954
+                reserved2: 0,
2955
+                reserved3: 0,
2956
+                data: Vec::new(),
2957
+                raw_relocs,
2958
+            }],
2959
+            symbols: vec![InputSymbol::from_raw(RawNlist {
2960
+                strx: 1,
2961
+                n_type: N_SECT | N_EXT,
2962
+                n_sect: 1,
2963
+                n_desc: 0,
2964
+                n_value: target_offset,
2965
+            })],
2966
+            strings: StringTable::from_bytes(strings),
2967
+            symtab: None,
2968
+            dysymtab: None,
2969
+            loh: Vec::new(),
2970
+            data_in_code: Vec::new(),
2971
+        }
2972
+    }
2973
+
2974
+    fn test_atom(input_offset: u32, size: u32) -> Atom {
2975
+        Atom {
2976
+            id: crate::resolve::AtomId(0),
2977
+            origin: crate::resolve::InputId(0),
2978
+            input_section: 1,
2979
+            section: AtomSection::Text,
2980
+            input_offset,
2981
+            size,
2982
+            align_pow2: 2,
2983
+            owner: None,
2984
+            alt_entries: Vec::new(),
2985
+            data: Vec::new(),
2986
+            flags: AtomFlags::NONE,
2987
+            parent_of: None,
2988
+        }
2989
+    }
19822990
 }
src/reloc/mod.rsmodified
35 lines changed — click to load
@@ -17,8 +17,11 @@
1717
 
1818
 pub mod arm64;
1919
 
20
+use std::collections::HashMap;
21
+
2022
 use crate::macho::constants::*;
2123
 use crate::macho::reader::{u32_le, ReadError};
24
+use crate::resolve::InputId;
2225
 
2326
 /// Size of one `relocation_info` on the wire.
2427
 pub const RAW_RELOC_SIZE: usize = 8;
@@ -114,7 +117,7 @@ pub fn write_raw_relocs(relocs: &[RawRelocation], out: &mut Vec<u8>) {
114117
 // Fused Reloc form. Sprint 11's reloc-application pass consumes this.
115118
 // ---------------------------------------------------------------------------
116119
 
117
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
120
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
118121
 pub enum RelocKind {
119122
     Unsigned,
120123
     Branch26,
@@ -132,7 +135,7 @@ pub enum RelocKind {
132135
     Subtractor,
133136
 }
134137
 
135
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
138
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
136139
 #[repr(u8)]
137140
 pub enum RelocLength {
138141
     Byte = 0,
@@ -185,6 +188,8 @@ pub struct Reloc {
185188
     pub subtrahend: Option<Referent>,
186189
 }
187190
 
191
+pub type ParsedRelocCache = HashMap<(InputId, u8), Vec<Reloc>>;
192
+
188193
 fn referent_from(raw: &RawRelocation) -> Result<Referent, ReadError> {
189194
     if raw.r_extern {
190195
         Ok(Referent::Symbol(raw.r_symbolnum))
src/resolve.rsmodified
594 lines changed — click to load
@@ -15,9 +15,10 @@
1515
 //! happened (inserted / replaced / kept / pending archive fetch), and they
1616
 //! drive the outer loop.
1717
 
18
-use std::collections::HashMap;
19
-use std::path::PathBuf;
20
-use std::rc::Rc;
18
+use std::collections::{HashMap, HashSet, VecDeque};
19
+use std::path::{Path, PathBuf};
20
+use std::sync::{mpsc, Arc, Mutex};
21
+use std::thread;
2122
 
2223
 use crate::archive::{Archive, ArchiveError};
2324
 use crate::input::ObjectFile;
@@ -41,8 +42,8 @@ impl Istr {
4142
 
4243
 #[derive(Debug, Default)]
4344
 pub struct StringInterner {
44
-    strings: Vec<Rc<str>>,
45
-    index: HashMap<Rc<str>, u32>,
45
+    strings: Vec<Arc<str>>,
46
+    index: HashMap<Arc<str>, u32>,
4647
 }
4748
 
4849
 impl StringInterner {
@@ -51,18 +52,22 @@ impl StringInterner {
5152
     }
5253
 
5354
     /// Intern `s`, returning the existing handle when the string was already
54
-    /// seen. Allocates at most one `Rc<str>` per unique name.
55
+    /// seen. Allocates at most one `Arc<str>` per unique name.
5556
     pub fn intern(&mut self, s: &str) -> Istr {
5657
         if let Some(&i) = self.index.get(s) {
5758
             return Istr(i);
5859
         }
59
-        let rc: Rc<str> = Rc::from(s);
60
+        let rc: Arc<str> = Arc::from(s);
6061
         let id = self.strings.len() as u32;
6162
         self.strings.push(rc.clone());
6263
         self.index.insert(rc, id);
6364
         Istr(id)
6465
     }
6566
 
67
+    pub fn get(&self, s: &str) -> Option<Istr> {
68
+        self.index.get(s).copied().map(Istr)
69
+    }
70
+
6671
     pub fn resolve(&self, i: Istr) -> &str {
6772
         &self.strings[i.0 as usize]
6873
     }
@@ -136,10 +141,11 @@ pub struct ObjectInput {
136141
     pub path: PathBuf,
137142
     pub load_order: usize,
138143
     pub archive_member_offset: Option<u32>,
139
-    /// Raw bytes; `ObjectFile::parse` re-runs cheaply against this on
140
-    /// demand. We don't cache a parsed view because `ObjectFile` copies
141
-    /// the fields it needs on construction, so re-parse is idempotent.
144
+    /// Raw bytes retained for diagnostics and future low-level readers.
142145
     pub bytes: Vec<u8>,
146
+    /// Parsed object view. It owns section/relocation/string-table buffers,
147
+    /// so it is safe to build off-thread and then borrow during the link.
148
+    pub parsed: ObjectFile,
143149
 }
144150
 
145151
 #[derive(Debug)]
@@ -151,7 +157,7 @@ pub struct ArchiveInput {
151157
     /// the fixed-point loop from re-ingesting the same object twice —
152158
     /// important both for correctness (no duplicate-strong errors from
153159
     /// our own symbols) and for keeping transitions deterministic.
154
-    pub fetched: std::collections::HashSet<u32>,
160
+    pub fetched: HashSet<u32>,
155161
 }
156162
 
157163
 #[derive(Debug)]
@@ -235,15 +241,26 @@ impl Inputs {
235241
         load_order: usize,
236242
     ) -> Result<InputId, InputAddError> {
237243
         // Validate now — we'd rather catch a bad object at the add site.
238
-        ObjectFile::parse(&path, &bytes)?;
244
+        let parsed = ObjectFile::parse(&path, &bytes)?;
245
+        Ok(self.add_parsed_object(path, bytes, parsed, load_order))
246
+    }
247
+
248
+    pub fn add_parsed_object(
249
+        &mut self,
250
+        path: PathBuf,
251
+        bytes: Vec<u8>,
252
+        parsed: ObjectFile,
253
+        load_order: usize,
254
+    ) -> InputId {
239255
         let id = InputId(self.objects.len() as u32);
240256
         self.objects.push(ObjectInput {
241257
             path,
242258
             load_order,
243259
             archive_member_offset: None,
244260
             bytes,
261
+            parsed,
245262
         });
246
-        Ok(id)
263
+        id
247264
     }
248265
 
249266
     /// Register an `.a` file.
@@ -254,6 +271,15 @@ impl Inputs {
254271
         load_order: usize,
255272
     ) -> Result<ArchiveId, InputAddError> {
256273
         Archive::open(&path, &bytes)?; // validate
274
+        Ok(self.add_validated_archive(path, bytes, load_order))
275
+    }
276
+
277
+    pub fn add_validated_archive(
278
+        &mut self,
279
+        path: PathBuf,
280
+        bytes: Vec<u8>,
281
+        load_order: usize,
282
+    ) -> ArchiveId {
257283
         let id = ArchiveId(self.archives.len() as u32);
258284
         self.archives.push(ArchiveInput {
259285
             path,
@@ -261,7 +287,7 @@ impl Inputs {
261287
             bytes,
262288
             fetched: std::collections::HashSet::new(),
263289
         });
264
-        Ok(id)
290
+        id
265291
     }
266292
 
267293
     /// Register a `.dylib`. TBD-backed dylibs go through
@@ -340,11 +366,10 @@ impl Inputs {
340366
         &self.dylibs[id.0 as usize]
341367
     }
342368
 
343
-    /// Parse an `ObjectFile` view of a registered object. Fast — `ObjectFile`
344
-    /// owns its buffers, so this is just the Mach-O walk cost.
345
-    pub fn object_file(&self, id: InputId) -> Result<ObjectFile, ReadError> {
369
+    /// Borrow a parsed `ObjectFile` view of a registered object.
370
+    pub fn object_file(&self, id: InputId) -> Result<&ObjectFile, ReadError> {
346371
         let o = &self.objects[id.0 as usize];
347
-        ObjectFile::parse(&o.path, &o.bytes)
372
+        Ok(&o.parsed)
348373
     }
349374
 
350375
     /// Open an `Archive` view, borrowing from the registry's bytes for the
@@ -554,6 +579,10 @@ impl SymbolTable {
554579
         self.by_name.get(&name).copied()
555580
     }
556581
 
582
+    pub fn lookup_str(&self, name: &str) -> Option<SymbolId> {
583
+        self.interner.get(name).and_then(|name| self.lookup(name))
584
+    }
585
+
557586
     pub fn get(&self, id: SymbolId) -> &Symbol {
558587
         &self.symbols[id.0 as usize]
559588
     }
@@ -1135,50 +1164,171 @@ impl From<SeedError> for FetchError {
11351164
 #[derive(Debug, Default)]
11361165
 pub struct DrainReport {
11371166
     pub fetched_members: usize,
1167
+    pub loaded_paths: Vec<PathBuf>,
11381168
     pub duplicates: Vec<InsertError>,
11391169
     pub referrers: ReferrerLog,
11401170
 }
11411171
 
1172
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1173
+struct ArchiveMemberKey {
1174
+    archive: ArchiveId,
1175
+    member: MemberId,
1176
+}
1177
+
1178
+#[derive(Clone, Copy)]
1179
+struct ArchiveMemberLoadJob<'a> {
1180
+    index: usize,
1181
+    key: ArchiveMemberKey,
1182
+    archive_path: &'a Path,
1183
+    archive_bytes: &'a [u8],
1184
+    archive_load_order: usize,
1185
+}
1186
+
1187
+struct LoadedArchiveMember {
1188
+    key: ArchiveMemberKey,
1189
+    archive_load_order: usize,
1190
+    logical_path: PathBuf,
1191
+    bytes: Vec<u8>,
1192
+    parsed: ObjectFile,
1193
+}
1194
+
1195
+fn make_archive_member_jobs<'a>(
1196
+    inputs: &'a Inputs,
1197
+    keys: Vec<ArchiveMemberKey>,
1198
+) -> Vec<ArchiveMemberLoadJob<'a>> {
1199
+    keys.into_iter()
1200
+        .enumerate()
1201
+        .map(|(index, key)| {
1202
+            let archive = &inputs.archives[key.archive.0 as usize];
1203
+            ArchiveMemberLoadJob {
1204
+                index,
1205
+                key,
1206
+                archive_path: &archive.path,
1207
+                archive_bytes: &archive.bytes,
1208
+                archive_load_order: archive.load_order,
1209
+            }
1210
+        })
1211
+        .collect()
1212
+}
1213
+
1214
+fn load_archive_members_parallel(
1215
+    inputs: &Inputs,
1216
+    keys: Vec<ArchiveMemberKey>,
1217
+    parallel_jobs: usize,
1218
+) -> Vec<(ArchiveMemberKey, Result<LoadedArchiveMember, FetchError>)> {
1219
+    let jobs = make_archive_member_jobs(inputs, keys);
1220
+    if jobs.is_empty() {
1221
+        return Vec::new();
1222
+    }
1223
+    let job_count = parallel_jobs.max(1).min(jobs.len()).max(1);
1224
+    if job_count == 1 {
1225
+        return jobs
1226
+            .into_iter()
1227
+            .map(load_archive_member_job)
1228
+            .map(|(_, key, result)| (key, result))
1229
+            .collect();
1230
+    }
1231
+
1232
+    let queue = Arc::new(Mutex::new(VecDeque::from(jobs)));
1233
+    let (tx, rx) = mpsc::channel();
1234
+    let mut results = thread::scope(|scope| {
1235
+        for _ in 0..job_count {
1236
+            let queue = Arc::clone(&queue);
1237
+            let tx = tx.clone();
1238
+            scope.spawn(move || loop {
1239
+                let Some(job) = queue
1240
+                    .lock()
1241
+                    .expect("archive member load queue mutex poisoned")
1242
+                    .pop_front()
1243
+                else {
1244
+                    break;
1245
+                };
1246
+                tx.send(load_archive_member_job(job))
1247
+                    .expect("archive member load receiver should stay live");
1248
+            });
1249
+        }
1250
+        drop(tx);
1251
+        rx.into_iter().collect::<Vec<_>>()
1252
+    });
1253
+    results.sort_by_key(|(index, _, _)| *index);
1254
+    results
1255
+        .into_iter()
1256
+        .map(|(_, key, result)| (key, result))
1257
+        .collect()
1258
+}
1259
+
1260
+fn load_archive_member_job(
1261
+    job: ArchiveMemberLoadJob<'_>,
1262
+) -> (
1263
+    usize,
1264
+    ArchiveMemberKey,
1265
+    Result<LoadedArchiveMember, FetchError>,
1266
+) {
1267
+    let result = (|| {
1268
+        let archive = Archive::open(job.archive_path, job.archive_bytes)?;
1269
+        let member =
1270
+            archive
1271
+                .member_at_offset(job.key.member.0)
1272
+                .ok_or(FetchError::MemberNotFound {
1273
+                    archive: job.key.archive,
1274
+                    member: job.key.member,
1275
+                })?;
1276
+        let logical_path =
1277
+            PathBuf::from(format!("{}({})", job.archive_path.display(), member.name));
1278
+        let bytes = member.body.to_vec();
1279
+        let parsed = ObjectFile::parse(&logical_path, &bytes)?;
1280
+        Ok(LoadedArchiveMember {
1281
+            key: job.key,
1282
+            archive_load_order: job.archive_load_order,
1283
+            logical_path,
1284
+            bytes,
1285
+            parsed,
1286
+        })
1287
+    })();
1288
+    (job.index, job.key, result)
1289
+}
1290
+
1291
+fn archive_member_key(pending: PendingFetch) -> ArchiveMemberKey {
1292
+    ArchiveMemberKey {
1293
+        archive: pending.archive,
1294
+        member: pending.member,
1295
+    }
1296
+}
1297
+
1298
+fn archive_member_is_fetched(inputs: &Inputs, key: ArchiveMemberKey) -> bool {
1299
+    inputs.archives[key.archive.0 as usize]
1300
+        .fetched
1301
+        .contains(&key.member.0)
1302
+}
1303
+
11421304
 /// Shared ingest: copy one archive member's body into a fresh
11431305
 /// `ObjectInput`, mark it fetched, and seed its symbols. Callers either
11441306
 /// respond to a demand-driven `PendingFetch` or force-pull the member.
1145
-fn ingest_member_bytes(
1307
+fn ingest_loaded_member(
11461308
     inputs: &mut Inputs,
11471309
     table: &mut SymbolTable,
1148
-    archive_id: ArchiveId,
1149
-    member_id: MemberId,
1310
+    loaded: LoadedArchiveMember,
11501311
     report: &mut DrainReport,
11511312
 ) -> Result<Vec<PendingFetch>, FetchError> {
1152
-    let archive_load_order = inputs.archives[archive_id.0 as usize].load_order;
1153
-    let ai = &inputs.archives[archive_id.0 as usize];
1154
-    if ai.fetched.contains(&member_id.0) {
1313
+    if archive_member_is_fetched(inputs, loaded.key) {
11551314
         return Ok(Vec::new());
11561315
     }
11571316
 
1158
-    // Extract owned data before mutating the registry.
1159
-    let (logical_path, member_bytes) = {
1160
-        let archive = Archive::open(&ai.path, &ai.bytes)?;
1161
-        let member = archive
1162
-            .member_at_offset(member_id.0)
1163
-            .ok_or(FetchError::MemberNotFound {
1164
-                archive: archive_id,
1165
-                member: member_id,
1166
-            })?;
1167
-        let logical = format!("{}({})", ai.path.display(), member.name);
1168
-        (logical, member.body.to_vec())
1169
-    };
1170
-
1171
-    inputs.archives[archive_id.0 as usize]
1317
+    inputs.archives[loaded.key.archive.0 as usize]
11721318
         .fetched
1173
-        .insert(member_id.0);
1319
+        .insert(loaded.key.member.0);
11741320
     let input_id = InputId(inputs.objects.len() as u32);
11751321
     inputs.objects.push(ObjectInput {
1176
-        path: PathBuf::from(logical_path),
1177
-        load_order: archive_load_order,
1178
-        archive_member_offset: Some(member_id.0),
1179
-        bytes: member_bytes,
1322
+        path: loaded.logical_path,
1323
+        load_order: loaded.archive_load_order,
1324
+        archive_member_offset: Some(loaded.key.member.0),
1325
+        bytes: loaded.bytes,
1326
+        parsed: loaded.parsed,
11801327
     });
11811328
     report.fetched_members += 1;
1329
+    report
1330
+        .loaded_paths
1331
+        .push(inputs.objects[input_id.0 as usize].path.clone());
11821332
 
11831333
     let mut sub_report = SeedReport::default();
11841334
     seed_object(inputs, input_id, table, &mut sub_report)?;
@@ -1187,6 +1337,24 @@ fn ingest_member_bytes(
11871337
     Ok(sub_report.pending_fetches)
11881338
 }
11891339
 
1340
+fn load_and_ingest_member(
1341
+    inputs: &mut Inputs,
1342
+    table: &mut SymbolTable,
1343
+    key: ArchiveMemberKey,
1344
+    report: &mut DrainReport,
1345
+    parallel_jobs: usize,
1346
+) -> Result<Vec<PendingFetch>, FetchError> {
1347
+    if archive_member_is_fetched(inputs, key) {
1348
+        return Ok(Vec::new());
1349
+    }
1350
+    let loaded = load_archive_members_parallel(inputs, vec![key], parallel_jobs)
1351
+        .into_iter()
1352
+        .next()
1353
+        .expect("single archive member load should produce one result")
1354
+        .1?;
1355
+    ingest_loaded_member(inputs, table, loaded, report)
1356
+}
1357
+
11901358
 /// Pull `pending`'s member only if the symbol slot is still a
11911359
 /// `LazyArchive` (i.e., a strong Defined hasn't superseded it). Returns
11921360
 /// any new `PendingFetch` entries triggered by the inserted member.
@@ -1195,12 +1363,19 @@ fn fetch_and_ingest_one(
11951363
     table: &mut SymbolTable,
11961364
     pending: PendingFetch,
11971365
     report: &mut DrainReport,
1366
+    parallel_jobs: usize,
11981367
 ) -> Result<Vec<PendingFetch>, FetchError> {
11991368
     let slot_is_still_lazy = matches!(table.get(pending.id), Symbol::LazyArchive { .. });
12001369
     if !slot_is_still_lazy {
12011370
         return Ok(Vec::new());
12021371
     }
1203
-    ingest_member_bytes(inputs, table, pending.archive, pending.member, report)
1372
+    load_and_ingest_member(
1373
+        inputs,
1374
+        table,
1375
+        archive_member_key(pending),
1376
+        report,
1377
+        parallel_jobs,
1378
+    )
12041379
 }
12051380
 
12061381
 /// Pull every member of one archive (bypasses demand tracking). Respects
@@ -1211,6 +1386,7 @@ pub fn force_load_archive(
12111386
     table: &mut SymbolTable,
12121387
     archive_id: ArchiveId,
12131388
     report: &mut DrainReport,
1389
+    parallel_jobs: usize,
12141390
 ) -> Result<(), FetchError> {
12151391
     let member_offsets: Vec<u32> = {
12161392
         let ai = &inputs.archives[archive_id.0 as usize];
@@ -1220,13 +1396,20 @@ pub fn force_load_archive(
12201396
             .map(|m| m.header_offset as u32)
12211397
             .collect()
12221398
     };
1399
+    let keys = member_offsets
1400
+        .into_iter()
1401
+        .map(|offset| ArchiveMemberKey {
1402
+            archive: archive_id,
1403
+            member: MemberId(offset),
1404
+        })
1405
+        .collect();
12231406
     let mut queue: Vec<PendingFetch> = Vec::new();
1224
-    for offset in member_offsets {
1225
-        let new = ingest_member_bytes(inputs, table, archive_id, MemberId(offset), report)?;
1407
+    for (_, loaded) in load_archive_members_parallel(inputs, keys, parallel_jobs) {
1408
+        let new = ingest_loaded_member(inputs, table, loaded?, report)?;
12261409
         queue.extend(new);
12271410
     }
12281411
     while let Some(p) = queue.pop() {
1229
-        let new = fetch_and_ingest_one(inputs, table, p, report)?;
1412
+        let new = fetch_and_ingest_one(inputs, table, p, report, parallel_jobs)?;
12301413
         queue.extend(new);
12311414
     }
12321415
     Ok(())
@@ -1238,9 +1421,10 @@ pub fn force_load_all(
12381421
     inputs: &mut Inputs,
12391422
     table: &mut SymbolTable,
12401423
     report: &mut DrainReport,
1424
+    parallel_jobs: usize,
12411425
 ) -> Result<(), FetchError> {
12421426
     for i in 0..inputs.archives.len() {
1243
-        force_load_archive(inputs, table, ArchiveId(i as u32), report)?;
1427
+        force_load_archive(inputs, table, ArchiveId(i as u32), report, parallel_jobs)?;
12441428
     }
12451429
     Ok(())
12461430
 }
@@ -1292,9 +1476,11 @@ impl DylibId {
12921476
 pub struct ClassificationReport {
12931477
     /// Strong undefineds that triggered errors under `Error` treatment.
12941478
     pub errors: Vec<Unresolved>,
1295
-    /// Strong undefineds that produced warnings under `Warning` treatment.
1479
+    /// Strong undefineds that produced warnings under `Warning` treatment and
1480
+    /// were promoted to flat-lookup imports for final emission.
12961481
     pub warnings: Vec<Unresolved>,
1297
-    /// Strong undefineds that were silently accepted under `Suppress`.
1482
+    /// Strong undefineds that were silently accepted under `Suppress` and
1483
+    /// were promoted to flat-lookup imports for final emission.
12981484
     pub suppressed: Vec<Unresolved>,
12991485
     /// Undefineds promoted to flat-lookup DylibImport entries.
13001486
     pub promoted_to_dynamic: Vec<SymbolId>,
@@ -1368,16 +1554,17 @@ pub fn did_you_mean(table: &SymbolTable, query: &str, budget: usize, max: usize)
13681554
 /// Format the full undefined-symbol diagnostic block, one entry per
13691555
 /// unresolved name: the error line, every referrer, and an optional
13701556
 /// did-you-mean hint.
1371
-pub fn format_undefined_diagnostic(
1557
+fn format_undefined_diagnostic_with_level(
13721558
     table: &SymbolTable,
13731559
     inputs: &Inputs,
13741560
     referrers: &ReferrerLog,
13751561
     unresolved: &[Unresolved],
1562
+    level: &str,
13761563
 ) -> String {
13771564
     let mut out = String::new();
13781565
     for u in unresolved {
13791566
         let name = table.interner.resolve(u.name);
1380
-        out.push_str(&format!("afs-ld: error: undefined symbol: {name}\n"));
1567
+        out.push_str(&format!("afs-ld: {level}: undefined symbol: {name}\n"));
13811568
         for origin in referrers.get(u.name) {
13821569
             if let Some(oi) = inputs.objects.get(origin.0 as usize) {
13831570
                 out.push_str(&format!("      referenced by {}\n", oi.path.display()));
@@ -1398,6 +1585,24 @@ pub fn format_undefined_diagnostic(
13981585
     out
13991586
 }
14001587
 
1588
+pub fn format_undefined_diagnostic(
1589
+    table: &SymbolTable,
1590
+    inputs: &Inputs,
1591
+    referrers: &ReferrerLog,
1592
+    unresolved: &[Unresolved],
1593
+) -> String {
1594
+    format_undefined_diagnostic_with_level(table, inputs, referrers, unresolved, "error")
1595
+}
1596
+
1597
+pub fn format_undefined_warning_diagnostic(
1598
+    table: &SymbolTable,
1599
+    inputs: &Inputs,
1600
+    referrers: &ReferrerLog,
1601
+    unresolved: &[Unresolved],
1602
+) -> String {
1603
+    format_undefined_diagnostic_with_level(table, inputs, referrers, unresolved, "warning")
1604
+}
1605
+
14011606
 /// Format a `DuplicateStrong` insertion error for user consumption. Needs
14021607
 /// the incumbent symbol (from the table) plus the losing second symbol
14031608
 /// carried in the error itself.
@@ -1438,6 +1643,21 @@ pub fn classify_unresolved(
14381643
 ) -> ClassificationReport {
14391644
     let mut report = ClassificationReport::default();
14401645
 
1646
+    fn promote_to_flat_lookup(table: &mut SymbolTable, id: SymbolId, name: Istr) {
1647
+        table.symbols[id.0 as usize] = Symbol::DylibImport {
1648
+            name,
1649
+            dylib: DylibId::INVALID,
1650
+            ordinal: FLAT_LOOKUP_ORDINAL,
1651
+            weak_import: true,
1652
+        };
1653
+        table.transitions.push(Transition {
1654
+            id,
1655
+            from: SymbolKindTag::Undefined,
1656
+            to: SymbolKindTag::DylibImport,
1657
+            cause: TransitionCause::Replaced,
1658
+        });
1659
+    }
1660
+
14411661
     // Collect undefineds before mutating — avoids double-borrow grief.
14421662
     let undefs: Vec<(SymbolId, Istr, bool)> = table
14431663
         .iter()
@@ -1458,23 +1678,16 @@ pub fn classify_unresolved(
14581678
             }
14591679
             UndefinedTreatment::Warning => {
14601680
                 report.warnings.push(Unresolved { name, id });
1681
+                promote_to_flat_lookup(table, id, name);
1682
+                report.promoted_to_dynamic.push(id);
14611683
             }
14621684
             UndefinedTreatment::Suppress => {
14631685
                 report.suppressed.push(Unresolved { name, id });
1686
+                promote_to_flat_lookup(table, id, name);
1687
+                report.promoted_to_dynamic.push(id);
14641688
             }
14651689
             UndefinedTreatment::DynamicLookup => {
1466
-                table.symbols[id.0 as usize] = Symbol::DylibImport {
1467
-                    name,
1468
-                    dylib: DylibId::INVALID,
1469
-                    ordinal: FLAT_LOOKUP_ORDINAL,
1470
-                    weak_import: true,
1471
-                };
1472
-                table.transitions.push(Transition {
1473
-                    id,
1474
-                    from: SymbolKindTag::Undefined,
1475
-                    to: SymbolKindTag::DylibImport,
1476
-                    cause: TransitionCause::Replaced,
1477
-                });
1690
+                promote_to_flat_lookup(table, id, name);
14781691
                 report.promoted_to_dynamic.push(id);
14791692
             }
14801693
         }
@@ -1490,16 +1703,63 @@ pub fn drain_fetches(
14901703
     inputs: &mut Inputs,
14911704
     table: &mut SymbolTable,
14921705
     initial: Vec<PendingFetch>,
1706
+    parallel_jobs: usize,
14931707
 ) -> Result<DrainReport, FetchError> {
14941708
     let mut queue = initial;
1709
+    let mut prepared = HashMap::new();
14951710
     let mut report = DrainReport::default();
14961711
     while let Some(p) = queue.pop() {
1497
-        let new_pending = fetch_and_ingest_one(inputs, table, p, &mut report)?;
1712
+        let key = archive_member_key(p);
1713
+        let slot_is_still_lazy = matches!(table.get(p.id), Symbol::LazyArchive { .. });
1714
+        if !slot_is_still_lazy || archive_member_is_fetched(inputs, key) {
1715
+            prepared.remove(&key);
1716
+            continue;
1717
+        }
1718
+        // Parse siblings ahead of time, but only ingest the current stack
1719
+        // entry after re-checking its lazy slot. This keeps member order stable.
1720
+        if !prepared.contains_key(&key) {
1721
+            preparse_pending_fetches(inputs, table, p, &queue, &mut prepared, parallel_jobs);
1722
+        }
1723
+        let Some(loaded) = prepared.remove(&key) else {
1724
+            continue;
1725
+        };
1726
+        let loaded = loaded?;
1727
+        let slot_is_still_lazy = matches!(table.get(p.id), Symbol::LazyArchive { .. });
1728
+        if !slot_is_still_lazy || archive_member_is_fetched(inputs, key) {
1729
+            continue;
1730
+        }
1731
+        let new_pending = ingest_loaded_member(inputs, table, loaded, &mut report)?;
14981732
         queue.extend(new_pending);
14991733
     }
15001734
     Ok(report)
15011735
 }
15021736
 
1737
+fn preparse_pending_fetches(
1738
+    inputs: &Inputs,
1739
+    table: &SymbolTable,
1740
+    current: PendingFetch,
1741
+    queue: &[PendingFetch],
1742
+    prepared: &mut HashMap<ArchiveMemberKey, Result<LoadedArchiveMember, FetchError>>,
1743
+    parallel_jobs: usize,
1744
+) {
1745
+    let mut seen = HashSet::new();
1746
+    let mut keys = Vec::new();
1747
+    for pending in std::iter::once(&current).chain(queue.iter().rev()) {
1748
+        let key = archive_member_key(*pending);
1749
+        if prepared.contains_key(&key)
1750
+            || archive_member_is_fetched(inputs, key)
1751
+            || !matches!(table.get(pending.id), Symbol::LazyArchive { .. })
1752
+            || !seen.insert(key)
1753
+        {
1754
+            continue;
1755
+        }
1756
+        keys.push(key);
1757
+    }
1758
+    for (key, result) in load_archive_members_parallel(inputs, keys, parallel_jobs) {
1759
+        prepared.insert(key, result);
1760
+    }
1761
+}
1762
+
15031763
 /// Turn a wire-form `InputSymbol` into a resolver-side `Symbol`. Returns
15041764
 /// `None` for kinds the resolver does not track (currently: aliases with
15051765
 /// unresolved target strx — Sprint 8's resolver defers those for now).
src/string_table.rsmodified
140 lines changed — click to load
@@ -98,10 +98,22 @@ impl StringTable {
9898
 
9999
 #[derive(Debug, Clone, Default, PartialEq, Eq)]
100100
 pub struct StringTableBuilder {
101
-    roots: Vec<(String, u32)>,
101
+    roots: Vec<RootString>,
102102
     offsets: HashMap<String, u32>,
103103
 }
104104
 
105
+#[derive(Debug, Clone, PartialEq, Eq)]
106
+struct RootString {
107
+    name: String,
108
+    offset: u32,
109
+}
110
+
111
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112
+struct BorrowedRootString<'a> {
113
+    name: &'a str,
114
+    offset: u32,
115
+}
116
+
105117
 impl StringTableBuilder {
106118
     pub fn new() -> Self {
107119
         Self::default()
@@ -111,6 +123,41 @@ impl StringTableBuilder {
111123
         self.offsets.entry(name.to_string()).or_insert(0);
112124
     }
113125
 
126
+    pub fn build_with_name_offsets<'a, I>(names: I) -> (Vec<u8>, Vec<u32>)
127
+    where
128
+        I: IntoIterator<Item = &'a str>,
129
+    {
130
+        let mut entries: Vec<_> = names
131
+            .into_iter()
132
+            .enumerate()
133
+            .map(|(index, name)| (name, index))
134
+            .collect();
135
+        let mut offsets = vec![0; entries.len()];
136
+        entries.sort_by(|(lhs, lhs_index), (rhs, rhs_index)| {
137
+            reverse_suffix_order(lhs, rhs).then_with(|| lhs_index.cmp(rhs_index))
138
+        });
139
+
140
+        let mut raw = vec![0u8];
141
+        let mut roots = Vec::new();
142
+        for (name, index) in entries {
143
+            if let Some(offset) = find_borrowed_suffix_offset(&roots, name) {
144
+                offsets[index] = offset;
145
+                continue;
146
+            }
147
+
148
+            let offset = raw.len() as u32;
149
+            raw.extend_from_slice(name.as_bytes());
150
+            raw.push(0);
151
+            roots.push(BorrowedRootString { name, offset });
152
+            offsets[index] = offset;
153
+        }
154
+
155
+        while !raw.len().is_multiple_of(8) {
156
+            raw.push(0);
157
+        }
158
+        (raw, offsets)
159
+    }
160
+
114161
     pub fn finish(mut self) -> (Vec<u8>, HashMap<String, u32>) {
115162
         let mut names: Vec<String> = self.offsets.keys().cloned().collect();
116163
         names.sort_by(|lhs, rhs| reverse_suffix_order(lhs, rhs));
@@ -125,7 +172,10 @@ impl StringTableBuilder {
125172
             let offset = raw.len() as u32;
126173
             raw.extend_from_slice(name.as_bytes());
127174
             raw.push(0);
128
-            self.roots.push((name.clone(), offset));
175
+            self.roots.push(RootString {
176
+                name: name.clone(),
177
+                offset,
178
+            });
129179
             self.offsets.insert(name, offset);
130180
         }
131181
 
@@ -136,14 +186,26 @@ impl StringTableBuilder {
136186
     }
137187
 
138188
     fn find_suffix_offset(&self, name: &str) -> Option<u32> {
139
-        self.roots.iter().find_map(|(existing, offset)| {
140
-            if existing.ends_with(name) {
141
-                Some(*offset + (existing.len() - name.len()) as u32)
142
-            } else {
143
-                None
144
-            }
145
-        })
189
+        if name.is_empty() {
190
+            return Some(0);
191
+        }
192
+        let insert_at = self
193
+            .roots
194
+            .partition_point(|root| reverse_suffix_order(&root.name, name).is_lt());
195
+        let existing = self.roots.get(insert_at.checked_sub(1)?)?;
196
+        (existing.name.len() >= name.len() && existing.name.ends_with(name))
197
+            .then(|| existing.offset + (existing.name.len() - name.len()) as u32)
198
+    }
199
+}
200
+
201
+fn find_borrowed_suffix_offset(roots: &[BorrowedRootString<'_>], name: &str) -> Option<u32> {
202
+    if name.is_empty() {
203
+        return Some(0);
146204
     }
205
+    let insert_at = roots.partition_point(|root| reverse_suffix_order(root.name, name).is_lt());
206
+    let existing = roots.get(insert_at.checked_sub(1)?)?;
207
+    (existing.name.len() >= name.len() && existing.name.ends_with(name))
208
+        .then(|| existing.offset + (existing.name.len() - name.len()) as u32)
147209
 }
148210
 
149211
 fn reverse_suffix_order(lhs: &str, rhs: &str) -> std::cmp::Ordering {
@@ -253,4 +315,32 @@ mod tests {
253315
         assert_eq!(array, afs + 4);
254316
         assert_eq!(table.as_bytes().len() % 8, 0);
255317
     }
318
+
319
+    #[test]
320
+    fn builder_ignores_same_last_byte_non_suffix_names() {
321
+        let mut builder = StringTableBuilder::new();
322
+        builder.insert("_alpha");
323
+        builder.insert("_beta");
324
+
325
+        let (bytes, offsets) = builder.finish();
326
+        let table = StringTable::from_bytes(bytes);
327
+
328
+        assert_eq!(table.get(offsets["_alpha"]).unwrap(), "_alpha");
329
+        assert_eq!(table.get(offsets["_beta"]).unwrap(), "_beta");
330
+    }
331
+
332
+    #[test]
333
+    fn builder_returns_offsets_in_input_order_without_cloning_keys() {
334
+        let names = ["_helper", "_afs_helper", "_helper", ""];
335
+        let (bytes, offsets) = StringTableBuilder::build_with_name_offsets(names);
336
+        let table = StringTable::from_bytes(bytes);
337
+
338
+        assert_eq!(table.get(offsets[0]).unwrap(), "_helper");
339
+        assert_eq!(table.get(offsets[1]).unwrap(), "_afs_helper");
340
+        assert_eq!(table.get(offsets[2]).unwrap(), "_helper");
341
+        assert_eq!(table.get(offsets[3]).unwrap(), "");
342
+        assert_eq!(offsets[0], offsets[2]);
343
+        assert_eq!(offsets[0], offsets[1] + 4);
344
+        assert_eq!(table.as_bytes().len() % 8, 0);
345
+    }
256346
 }
src/synth/code_sig.rsmodified
100 lines changed — click to load
@@ -1,3 +1,5 @@
1
+use std::thread;
2
+
13
 use crate::layout::Layout;
24
 use crate::section::is_executable;
35
 use crate::LinkOptions;
@@ -52,6 +54,10 @@ impl CodeSignaturePlan {
5254
     }
5355
 
5456
     pub fn build(&self, signed_prefix: &[u8]) -> Vec<u8> {
57
+        self.build_with_jobs(signed_prefix, 1)
58
+    }
59
+
60
+    pub fn build_with_jobs(&self, signed_prefix: &[u8], parallel_jobs: usize) -> Vec<u8> {
5561
         debug_assert_eq!(signed_prefix.len(), self.code_limit as usize);
5662
 
5763
         let code_slots = code_slots(self.code_limit as usize);
@@ -92,8 +98,8 @@ impl CodeSignaturePlan {
9298
 
9399
         out.extend_from_slice(self.identifier.as_bytes());
94100
         out.push(0);
95
-        for page in signed_prefix.chunks(PAGE_SIZE) {
96
-            out.extend_from_slice(&sha256(page));
101
+        for hash in page_hashes(signed_prefix, parallel_jobs) {
102
+            out.extend_from_slice(&hash);
97103
         }
98104
         out.resize(padded_len, 0);
99105
         out
@@ -149,6 +155,33 @@ fn code_slots(code_limit: usize) -> usize {
149155
     }
150156
 }
151157
 
158
+fn page_hashes(data: &[u8], parallel_jobs: usize) -> Vec<[u8; 32]> {
159
+    let page_count = code_slots(data.len());
160
+    if page_count == 0 {
161
+        return Vec::new();
162
+    }
163
+    let parallel_jobs = parallel_jobs.max(1).min(page_count);
164
+    if parallel_jobs == 1 || page_count < 2 {
165
+        return data.chunks(PAGE_SIZE).map(sha256).collect();
166
+    }
167
+
168
+    let chunk_pages = page_count.div_ceil(parallel_jobs);
169
+    let chunk_bytes = PAGE_SIZE * chunk_pages;
170
+    thread::scope(|scope| {
171
+        let mut handles = Vec::new();
172
+        for chunk in data.chunks(chunk_bytes) {
173
+            handles
174
+                .push(scope.spawn(move || chunk.chunks(PAGE_SIZE).map(sha256).collect::<Vec<_>>()));
175
+        }
176
+
177
+        let mut hashes = Vec::with_capacity(page_count);
178
+        for handle in handles {
179
+            hashes.extend(handle.join().expect("code-signature hash worker panicked"));
180
+        }
181
+        hashes
182
+    })
183
+}
184
+
152185
 fn push_be_u32(out: &mut Vec<u8>, value: u32) {
153186
     out.extend_from_slice(&value.to_be_bytes());
154187
 }
@@ -336,4 +369,42 @@ mod tests {
336369
         assert_eq!(read_be_u32(&blob, 52), 16_512);
337370
         assert_eq!(&blob[108..114], b"apple\0");
338371
     }
372
+
373
+    #[test]
374
+    fn parallel_page_hashes_preserve_serial_order() {
375
+        let mut bytes = Vec::with_capacity(PAGE_SIZE * 9 + 123);
376
+        for index in 0..PAGE_SIZE * 9 + 123 {
377
+            bytes.push((index.wrapping_mul(37).wrapping_add(19) & 0xff) as u8);
378
+        }
379
+
380
+        let serial = page_hashes(&bytes, 1);
381
+        let parallel = page_hashes(&bytes, 4);
382
+        assert_eq!(parallel, serial);
383
+        assert_eq!(parallel.len(), 10);
384
+    }
385
+
386
+    #[test]
387
+    fn parallel_code_signature_matches_single_worker() {
388
+        let opts = LinkOptions {
389
+            output: Some("parallel".into()),
390
+            ..LinkOptions::default()
391
+        };
392
+        let code_limit = PAGE_SIZE * 11 + 777;
393
+        let plan = CodeSignaturePlan::new(
394
+            &Layout::empty(crate::OutputKind::Executable, 0),
395
+            &opts,
396
+            code_limit as u64,
397
+            true,
398
+        )
399
+        .unwrap();
400
+        let mut signed_prefix = Vec::with_capacity(code_limit);
401
+        for index in 0..code_limit {
402
+            signed_prefix.push((index.wrapping_mul(13).wrapping_add(index / 7) & 0xff) as u8);
403
+        }
404
+
405
+        assert_eq!(
406
+            plan.build_with_jobs(&signed_prefix, 8),
407
+            plan.build_with_jobs(&signed_prefix, 1)
408
+        );
409
+    }
339410
 }
src/synth/dyld_info.rsmodified
252 lines changed — click to load
@@ -1,5 +1,3 @@
1
-use std::collections::BTreeMap;
2
-
31
 use crate::leb::{write_sleb, write_uleb};
42
 use crate::macho::constants::{
53
     BIND_IMMEDIATE_MASK, BIND_OPCODE_ADD_ADDR_ULEB, BIND_OPCODE_DO_BIND,
@@ -81,25 +79,9 @@ struct BindState {
8179
     pointer_type_set: bool,
8280
 }
8381
 
84
-#[derive(Debug, Clone, Default)]
85
-struct TrieNode {
86
-    terminal: Option<ExportEntry>,
87
-    children: BTreeMap<u8, TrieNode>,
88
-}
89
-
90
-impl TrieNode {
91
-    fn insert(&mut self, name: &str, entry: ExportEntry) {
92
-        let mut node = self;
93
-        for byte in name.bytes() {
94
-            node = node.children.entry(byte).or_default();
95
-        }
96
-        node.terminal = Some(entry);
97
-    }
98
-}
99
-
10082
 #[derive(Debug, Clone)]
10183
 struct FlatTrieNode {
102
-    terminal: Option<ExportEntry>,
84
+    terminal_payload: Vec<u8>,
10385
     children: Vec<(String, usize)>,
10486
 }
10587
 
@@ -108,17 +90,11 @@ pub fn build_export_trie(entries: &[ExportEntry]) -> Vec<u8> {
10890
         return Vec::new();
10991
     }
11092
 
111
-    let mut sorted = entries.to_vec();
93
+    let mut sorted: Vec<&ExportEntry> = entries.iter().collect();
11294
     sorted.sort_by(|lhs, rhs| lhs.name.cmp(&rhs.name));
11395
 
114
-    let mut root = TrieNode::default();
115
-    for entry in sorted {
116
-        let name = entry.name.clone();
117
-        root.insert(&name, entry);
118
-    }
119
-
12096
     let mut nodes = Vec::new();
121
-    flatten_trie(&root, &mut nodes);
97
+    flatten_sorted_export_trie(&sorted, 0, &mut nodes);
12298
 
12399
     let mut offsets = vec![0usize; nodes.len()];
124100
     loop {
@@ -149,42 +125,67 @@ pub fn build_export_trie(entries: &[ExportEntry]) -> Vec<u8> {
149125
     out
150126
 }
151127
 
152
-fn flatten_trie(node: &TrieNode, flat: &mut Vec<FlatTrieNode>) -> usize {
128
+fn flatten_sorted_export_trie(
129
+    entries: &[&ExportEntry],
130
+    prefix_len: usize,
131
+    flat: &mut Vec<FlatTrieNode>,
132
+) -> usize {
153133
     let id = flat.len();
134
+    let mut entry_idx = 0usize;
135
+    let mut terminal = None;
136
+    while entries
137
+        .get(entry_idx)
138
+        .is_some_and(|entry| entry.name.len() == prefix_len)
139
+    {
140
+        terminal = Some(entries[entry_idx]);
141
+        entry_idx += 1;
142
+    }
143
+
154144
     flat.push(FlatTrieNode {
155
-        terminal: node.terminal.clone(),
145
+        terminal_payload: terminal_payload(terminal),
156146
         children: Vec::new(),
157147
     });
158148
 
159
-    let mut children = Vec::with_capacity(node.children.len());
160
-    for (&edge, child) in &node.children {
161
-        let (label, child_id) = flatten_edge(edge, child, flat);
149
+    let mut children = Vec::new();
150
+    while entry_idx < entries.len() {
151
+        let edge = entries[entry_idx].name.as_bytes()[prefix_len];
152
+        let group_start = entry_idx;
153
+        entry_idx += 1;
154
+        while entry_idx < entries.len() && entries[entry_idx].name.as_bytes()[prefix_len] == edge {
155
+            entry_idx += 1;
156
+        }
157
+        let group = &entries[group_start..entry_idx];
158
+        let label_end = common_prefix_len(group, prefix_len + 1);
159
+        let label = String::from_utf8(group[0].name.as_bytes()[prefix_len..label_end].to_vec())
160
+            .expect("export labels should stay UTF-8");
161
+        let child_id = flatten_sorted_export_trie(group, label_end, flat);
162162
         children.push((label, child_id));
163163
     }
164164
     flat[id].children = children;
165165
     id
166166
 }
167167
 
168
-fn flatten_edge(first: u8, child: &TrieNode, flat: &mut Vec<FlatTrieNode>) -> (String, usize) {
169
-    let mut label = vec![first];
170
-    let mut node = child;
171
-    while node.terminal.is_none() && node.children.len() == 1 {
172
-        let (&next, next_child) = node
173
-            .children
174
-            .iter()
175
-            .next()
176
-            .expect("single-child trie node should expose one edge");
177
-        label.push(next);
178
-        node = next_child;
179
-    }
180
-    let label = String::from_utf8(label).expect("export labels should stay UTF-8");
181
-    let child_id = flatten_trie(node, flat);
182
-    (label, child_id)
168
+fn common_prefix_len(entries: &[&ExportEntry], start: usize) -> usize {
169
+    let first = entries
170
+        .first()
171
+        .expect("export trie child groups should be non-empty")
172
+        .name
173
+        .as_bytes();
174
+    let mut len = first.len();
175
+    for entry in &entries[1..] {
176
+        let bytes = entry.name.as_bytes();
177
+        len = len.min(bytes.len());
178
+        let mut idx = start;
179
+        while idx < len && first[idx] == bytes[idx] {
180
+            idx += 1;
181
+        }
182
+        len = idx;
183
+    }
184
+    len
183185
 }
184186
 
185187
 fn trie_node_size(node: &FlatTrieNode, offsets: &[usize]) -> usize {
186
-    let terminal = terminal_payload(node.terminal.as_ref());
187
-    let mut size = uleb_size(terminal.len() as u64) + terminal.len() + 1;
188
+    let mut size = uleb_size(node.terminal_payload.len() as u64) + node.terminal_payload.len() + 1;
188189
     for (edge, child) in &node.children {
189190
         size += edge.len() + 1 + uleb_size(offsets[*child] as u64);
190191
     }
@@ -192,10 +193,9 @@ fn trie_node_size(node: &FlatTrieNode, offsets: &[usize]) -> usize {
192193
 }
193194
 
194195
 fn emit_trie_node(node: &FlatTrieNode, offsets: &[usize], out: &mut Vec<u8>) {
195
-    let terminal = terminal_payload(node.terminal.as_ref());
196196
     let mut stream = OpcodeStream::new();
197
-    stream.uleb(terminal.len() as u64);
198
-    stream.bytes(&terminal);
197
+    stream.uleb(node.terminal_payload.len() as u64);
198
+    stream.bytes(&node.terminal_payload);
199199
     stream
200200
         .byte(u8::try_from(node.children.len()).expect("export trie node fanout should fit in u8"));
201201
     for (edge, child) in &node.children {
@@ -252,7 +252,7 @@ pub fn emit_rebase_run(out: &mut OpcodeStream, count: usize) {
252252
 pub fn emit_bind_records(specs: &[BindRecordSpec<'_>]) -> Vec<u8> {
253253
     let mut out = OpcodeStream::new();
254254
     let mut state = BindState::default();
255
-    let mut current_symbol: Option<String> = None;
255
+    let mut current_symbol: Option<&str> = None;
256256
 
257257
     let mut idx = 0usize;
258258
     while idx < specs.len() {
@@ -262,14 +262,12 @@ pub fn emit_bind_records(specs: &[BindRecordSpec<'_>]) -> Vec<u8> {
262262
             state.ordinal = Some(spec.ordinal);
263263
         }
264264
 
265
-        if current_symbol.as_deref() != Some(spec.name)
266
-            || state.weak_import != Some(spec.weak_import)
267
-        {
265
+        if current_symbol != Some(spec.name) || state.weak_import != Some(spec.weak_import) {
268266
             out.byte(
269267
                 BIND_OPCODE_SET_SYMBOL_TRAILING_FLAGS_IMM | bind_symbol_flags(spec.weak_import),
270268
             );
271269
             out.string(spec.name);
272
-            current_symbol = Some(spec.name.to_string());
270
+            current_symbol = Some(spec.name);
273271
             state.weak_import = Some(spec.weak_import);
274272
         }
275273
 
@@ -307,12 +305,18 @@ pub fn emit_bind_records(specs: &[BindRecordSpec<'_>]) -> Vec<u8> {
307305
         let run_len = bind_run_len(specs, idx);
308306
         if run_len > 1 {
309307
             let stride = specs[idx + 1].segment_offset - spec.segment_offset;
310
-            let skip = stride - 8;
311
-            out.byte(BIND_OPCODE_DO_BIND_ULEB_TIMES_SKIPPING_ULEB);
312
-            out.uleb(run_len as u64);
313
-            out.uleb(skip);
314
-            state.next_segment_offset = Some(spec.segment_offset + (run_len as u64) * stride);
315
-            idx += run_len;
308
+            if run_len == 2 && stride == 8 {
309
+                out.byte(BIND_OPCODE_DO_BIND);
310
+                state.next_segment_offset = Some(spec.segment_offset + 8);
311
+                idx += 1;
312
+            } else {
313
+                let skip = stride - 8;
314
+                out.byte(BIND_OPCODE_DO_BIND_ULEB_TIMES_SKIPPING_ULEB);
315
+                out.uleb(run_len as u64);
316
+                out.uleb(skip);
317
+                state.next_segment_offset = Some(spec.segment_offset + (run_len as u64) * stride);
318
+                idx += run_len;
319
+            }
316320
         } else {
317321
             out.byte(BIND_OPCODE_DO_BIND);
318322
             state.next_segment_offset = Some(spec.segment_offset + 8);
@@ -620,4 +624,53 @@ mod tests {
620624
         assert_eq!(stream[idx + 2], 0x10);
621625
         assert_eq!(stream.last().copied(), Some(0));
622626
     }
627
+
628
+    #[test]
629
+    fn bind_encoder_keeps_adjacent_pair_uncompressed_for_apple_parity() {
630
+        let stream = emit_bind_records(&[
631
+            BindRecordSpec {
632
+                segment_index: 2,
633
+                segment_offset: 0,
634
+                ordinal: 2,
635
+                name: "_ext_data",
636
+                weak_import: false,
637
+                addend: 0,
638
+                terminate: false,
639
+            },
640
+            BindRecordSpec {
641
+                segment_index: 2,
642
+                segment_offset: 8,
643
+                ordinal: 2,
644
+                name: "_ext_data",
645
+                weak_import: false,
646
+                addend: 0,
647
+                terminate: true,
648
+            },
649
+        ]);
650
+
651
+        assert!(!stream.contains(&BIND_OPCODE_DO_BIND_ULEB_TIMES_SKIPPING_ULEB));
652
+        assert_eq!(
653
+            stream,
654
+            vec![
655
+                BIND_OPCODE_SET_DYLIB_ORDINAL_IMM | 2,
656
+                BIND_OPCODE_SET_SYMBOL_TRAILING_FLAGS_IMM,
657
+                b'_',
658
+                b'e',
659
+                b'x',
660
+                b't',
661
+                b'_',
662
+                b'd',
663
+                b'a',
664
+                b't',
665
+                b'a',
666
+                0,
667
+                BIND_OPCODE_SET_TYPE_IMM | BIND_TYPE_POINTER,
668
+                BIND_OPCODE_SET_SEGMENT_AND_OFFSET_ULEB | 2,
669
+                0,
670
+                BIND_OPCODE_DO_BIND,
671
+                BIND_OPCODE_DO_BIND,
672
+                0,
673
+            ]
674
+        );
675
+    }
623676
 }
src/synth/mod.rsmodified
343 lines changed — click to load
@@ -5,7 +5,7 @@ pub mod stubs;
55
 pub mod tlv;
66
 pub mod unwind;
77
 
8
-use std::collections::HashMap;
8
+use std::collections::{HashMap, HashSet};
99
 use std::fmt;
1010
 use std::path::PathBuf;
1111
 
@@ -16,7 +16,9 @@ use crate::macho::constants::{
1616
     S_ATTR_PURE_INSTRUCTIONS, S_ATTR_SOME_INSTRUCTIONS, S_LAZY_SYMBOL_POINTERS,
1717
     S_NON_LAZY_SYMBOL_POINTERS, S_REGULAR, S_SYMBOL_STUBS, S_THREAD_LOCAL_VARIABLE_POINTERS,
1818
 };
19
-use crate::reloc::{parse_raw_relocs, parse_relocs, Referent, Reloc, RelocKind, RelocLength};
19
+use crate::reloc::{
20
+    parse_raw_relocs, parse_relocs, ParsedRelocCache, Referent, Reloc, RelocKind, RelocLength,
21
+};
2022
 use crate::resolve::{
2123
     AtomId, DylibId, DylibInput, InputId, InsertOutcome, Symbol, SymbolId, SymbolTable,
2224
 };
@@ -82,37 +84,35 @@ impl SyntheticPlan {
8284
         atoms: &AtomTable,
8385
         sym_table: &mut SymbolTable,
8486
         dylibs: &[DylibInput],
87
+    ) -> Result<Self, SynthError> {
88
+        Self::build_filtered(inputs, atoms, sym_table, dylibs, None)
89
+    }
90
+
91
+    pub fn build_filtered(
92
+        inputs: &[LayoutInput<'_>],
93
+        atoms: &AtomTable,
94
+        sym_table: &mut SymbolTable,
95
+        dylibs: &[DylibInput],
96
+        live_atoms: Option<&HashSet<AtomId>>,
97
+    ) -> Result<Self, SynthError> {
98
+        let reloc_cache = build_synthetic_reloc_cache(inputs)?;
99
+        Self::build_filtered_with_relocs(inputs, atoms, sym_table, dylibs, live_atoms, &reloc_cache)
100
+    }
101
+
102
+    pub fn build_filtered_with_relocs(
103
+        inputs: &[LayoutInput<'_>],
104
+        atoms: &AtomTable,
105
+        sym_table: &mut SymbolTable,
106
+        dylibs: &[DylibInput],
107
+        live_atoms: Option<&HashSet<AtomId>>,
108
+        parsed_relocs: &ParsedRelocCache,
85109
     ) -> Result<Self, SynthError> {
86110
         let input_map: HashMap<InputId, &ObjectFile> = inputs
87111
             .iter()
88112
             .map(|input| (input.id, input.object))
89113
             .collect();
90
-        let mut reloc_cache: HashMap<(InputId, u8), Vec<Reloc>> = HashMap::new();
91
-        for input in inputs {
92
-            for (sect_idx, section) in input.object.sections.iter().enumerate() {
93
-                let relocs = if section.nreloc == 0 {
94
-                    Vec::new()
95
-                } else {
96
-                    let raws = parse_raw_relocs(&section.raw_relocs, 0, section.nreloc).map_err(
97
-                        |err| SynthError {
98
-                            input: input.object.path.clone(),
99
-                            atom: crate::resolve::AtomId(0),
100
-                            reloc_offset: 0,
101
-                            kind: RelocKind::Unsigned,
102
-                            detail: err.to_string(),
103
-                        },
104
-                    )?;
105
-                    parse_relocs(&raws).map_err(|err| SynthError {
106
-                        input: input.object.path.clone(),
107
-                        atom: crate::resolve::AtomId(0),
108
-                        reloc_offset: 0,
109
-                        kind: RelocKind::Unsigned,
110
-                        detail: err.to_string(),
111
-                    })?
112
-                };
113
-                reloc_cache.insert((input.id, (sect_idx + 1) as u8), relocs);
114
-            }
115
-        }
114
+        let input_symbol_index = build_input_symbol_index(inputs, sym_table, parsed_relocs);
115
+        let reloc_index = build_sorted_reloc_index(parsed_relocs);
116116
 
117117
         let mut got = GotSection::default();
118118
         let mut stubs = StubsSection::default();
@@ -121,6 +121,9 @@ impl SyntheticPlan {
121121
         let mut direct_binds = Vec::new();
122122
 
123123
         for (atom_id, atom) in atoms.iter() {
124
+            if live_atoms.is_some_and(|live_atoms| !live_atoms.contains(&atom_id)) {
125
+                continue;
126
+            }
124127
             let obj = input_map.get(&atom.origin).ok_or_else(|| SynthError {
125128
                 input: PathBuf::from("<missing object>"),
126129
                 atom: atom_id,
@@ -128,16 +131,19 @@ impl SyntheticPlan {
128131
                 kind: RelocKind::Unsigned,
129132
                 detail: "missing parsed object".to_string(),
130133
             })?;
131
-            let relocs = reloc_cache
134
+            let relocs = reloc_index
132135
                 .get(&(atom.origin, atom.input_section))
133136
                 .map(Vec::as_slice)
134137
                 .unwrap_or(&[]);
138
+            let input_symbols = input_symbol_index.get(&atom.origin).map(Vec::as_slice);
135139
             for reloc in relocs_for_atom(relocs, atom) {
136140
                 if atom.section == AtomSection::CompactUnwind
137141
                     && reloc.kind == RelocKind::Unsigned
138142
                     && reloc.offset == atom.input_offset + COMPACT_UNWIND_PERSONALITY_FIELD_OFFSET
139143
                 {
140
-                    if let Some(symbol_id) = dylib_import_referent(obj, reloc.referent, sym_table) {
144
+                    if let Some(symbol_id) =
145
+                        dylib_import_referent(obj, reloc.referent, sym_table, input_symbols)
146
+                    {
141147
                         got.intern(symbol_id, dylib_import_is_weak(sym_table, symbol_id));
142148
                     }
143149
                     continue;
@@ -150,7 +156,8 @@ impl SyntheticPlan {
150156
                         if matches!(atom.section, AtomSection::ThreadLocalVariables) {
151157
                             continue;
152158
                         }
153
-                        let Some(symbol_id) = dylib_import_referent(obj, reloc.referent, sym_table)
159
+                        let Some(symbol_id) =
160
+                            dylib_import_referent(obj, reloc.referent, sym_table, input_symbols)
154161
                         else {
155162
                             continue;
156163
                         };
@@ -166,32 +173,28 @@ impl SyntheticPlan {
166173
                     RelocKind::GotLoadPage21
167174
                     | RelocKind::GotLoadPageOff12
168175
                     | RelocKind::PointerToGot => {
169
-                        if matches!(
170
-                            reloc.kind,
171
-                            RelocKind::GotLoadPage21 | RelocKind::GotLoadPageOff12
172
-                        ) {
173
-                            let Some(symbol_id) =
174
-                                dylib_import_referent(obj, reloc.referent, sym_table)
175
-                            else {
176
-                                continue;
177
-                            };
178
-                            got.intern(symbol_id, dylib_import_is_weak(sym_table, symbol_id));
179
-                            continue;
180
-                        }
181
-                        let Some(symbol_id) = symbol_referent_id(obj, reloc.referent, sym_table)
176
+                        let Some(symbol_id) =
177
+                            symbol_referent_id(obj, reloc.referent, sym_table, input_symbols)
182178
                         else {
183179
                             continue;
184180
                         };
185
-                        if matches!(reloc.kind, RelocKind::PointerToGot)
186
-                            && matches!(sym_table.get(symbol_id), Symbol::DylibImport { .. })
187
-                        {
181
+                        let needs_slot = match reloc.kind {
182
+                            RelocKind::PointerToGot => {
183
+                                !matches!(sym_table.get(symbol_id), Symbol::LazyArchive { .. })
184
+                            }
185
+                            RelocKind::GotLoadPage21 | RelocKind::GotLoadPageOff12 => {
186
+                                got_page_symbol_needs_slot(sym_table, atoms, symbol_id)
187
+                            }
188
+                            _ => false,
189
+                        };
190
+                        if needs_slot {
188191
                             got.intern(symbol_id, dylib_import_is_weak(sym_table, symbol_id));
189192
                             continue;
190193
                         }
191
-                        got.intern(symbol_id, dylib_import_is_weak(sym_table, symbol_id));
192194
                     }
193195
                     RelocKind::Branch26 => {
194
-                        let Some(symbol_id) = dylib_import_referent(obj, reloc.referent, sym_table)
196
+                        let Some(symbol_id) =
197
+                            dylib_import_referent(obj, reloc.referent, sym_table, input_symbols)
195198
                         else {
196199
                             continue;
197200
                         };
@@ -207,7 +210,8 @@ impl SyntheticPlan {
207210
                         );
208211
                     }
209212
                     RelocKind::TlvpLoadPage21 | RelocKind::TlvpLoadPageOff12 => {
210
-                        if let Some(symbol_id) = symbol_referent_id(obj, reloc.referent, sym_table)
213
+                        if let Some(symbol_id) =
214
+                            symbol_referent_id(obj, reloc.referent, sym_table, input_symbols)
211215
                         {
212216
                             if tlv_symbol_needs_got(sym_table, symbol_id) {
213217
                                 got.intern(symbol_id, dylib_import_is_weak(sym_table, symbol_id));
@@ -417,12 +421,99 @@ fn sort_symbol_indexed_entries<T, F>(
417421
     }
418422
 }
419423
 
424
+fn build_synthetic_reloc_cache(inputs: &[LayoutInput<'_>]) -> Result<ParsedRelocCache, SynthError> {
425
+    let mut reloc_cache = ParsedRelocCache::new();
426
+    for input in inputs {
427
+        for (sect_idx, section) in input.object.sections.iter().enumerate() {
428
+            let relocs = if section.nreloc == 0 {
429
+                Vec::new()
430
+            } else {
431
+                let raws =
432
+                    parse_raw_relocs(&section.raw_relocs, 0, section.nreloc).map_err(|err| {
433
+                        SynthError {
434
+                            input: input.object.path.clone(),
435
+                            atom: crate::resolve::AtomId(0),
436
+                            reloc_offset: 0,
437
+                            kind: RelocKind::Unsigned,
438
+                            detail: err.to_string(),
439
+                        }
440
+                    })?;
441
+                parse_relocs(&raws).map_err(|err| SynthError {
442
+                    input: input.object.path.clone(),
443
+                    atom: crate::resolve::AtomId(0),
444
+                    reloc_offset: 0,
445
+                    kind: RelocKind::Unsigned,
446
+                    detail: err.to_string(),
447
+                })?
448
+            };
449
+            reloc_cache.insert((input.id, (sect_idx + 1) as u8), relocs);
450
+        }
451
+    }
452
+    Ok(reloc_cache)
453
+}
454
+
455
+fn build_input_symbol_index(
456
+    inputs: &[LayoutInput<'_>],
457
+    sym_table: &SymbolTable,
458
+    parsed_relocs: &ParsedRelocCache,
459
+) -> HashMap<InputId, Vec<Option<SymbolId>>> {
460
+    let mut referenced = HashMap::<InputId, HashSet<u32>>::new();
461
+    for ((input_id, _), relocs) in parsed_relocs {
462
+        let referenced = referenced.entry(*input_id).or_default();
463
+        for reloc in relocs {
464
+            if let Referent::Symbol(sym_idx) = reloc.referent {
465
+                referenced.insert(sym_idx);
466
+            }
467
+            if let Some(Referent::Symbol(sym_idx)) = reloc.subtrahend {
468
+                referenced.insert(sym_idx);
469
+            }
470
+        }
471
+    }
472
+
473
+    let mut index = HashMap::new();
474
+    for input in inputs {
475
+        let mut symbols = vec![None; input.object.symbols.len()];
476
+        if let Some(referenced) = referenced.get(&input.id) {
477
+            for sym_idx in referenced {
478
+                let Some(input_sym) = input.object.symbols.get(*sym_idx as usize) else {
479
+                    continue;
480
+                };
481
+                let Some(slot) = symbols.get_mut(*sym_idx as usize) else {
482
+                    continue;
483
+                };
484
+                *slot = input
485
+                    .object
486
+                    .symbol_name(input_sym)
487
+                    .ok()
488
+                    .and_then(|name| sym_table.lookup_str(name));
489
+            }
490
+        }
491
+        index.insert(input.id, symbols);
492
+    }
493
+    index
494
+}
495
+
496
+fn build_sorted_reloc_index(parsed_relocs: &ParsedRelocCache) -> ParsedRelocCache {
497
+    let mut index = ParsedRelocCache::new();
498
+    for (key, relocs) in parsed_relocs {
499
+        if relocs.is_empty() {
500
+            continue;
501
+        }
502
+        let mut sorted = relocs.clone();
503
+        sorted.sort_by_key(|reloc| reloc.offset);
504
+        index.insert(*key, sorted);
505
+    }
506
+    index
507
+}
508
+
420509
 fn relocs_for_atom<'a>(relocs: &'a [Reloc], atom: &Atom) -> impl Iterator<Item = Reloc> + 'a {
421510
     let start = atom.input_offset;
422511
     let end = atom.input_offset + atom.size;
423
-    relocs.iter().copied().filter(move |reloc| {
512
+    let first = relocs.partition_point(|reloc| reloc.offset < start);
513
+    let last = relocs.partition_point(|reloc| reloc.offset < end);
514
+    relocs[first..last].iter().copied().filter(move |reloc| {
424515
         let reloc_end = reloc.offset + reloc.width_for_planning();
425
-        reloc.offset >= start && reloc_end <= end
516
+        reloc_end <= end
426517
     })
427518
 }
428519
 
@@ -448,8 +539,9 @@ fn dylib_import_referent(
448539
     obj: &ObjectFile,
449540
     referent: Referent,
450541
     sym_table: &SymbolTable,
542
+    input_symbols: Option<&[Option<SymbolId>]>,
451543
 ) -> Option<SymbolId> {
452
-    let symbol_id = symbol_referent_id(obj, referent, sym_table)?;
544
+    let symbol_id = symbol_referent_id(obj, referent, sym_table, input_symbols)?;
453545
     matches!(sym_table.get(symbol_id), Symbol::DylibImport { .. }).then_some(symbol_id)
454546
 }
455547
 
@@ -457,10 +549,14 @@ fn symbol_referent_id(
457549
     obj: &ObjectFile,
458550
     referent: Referent,
459551
     sym_table: &SymbolTable,
552
+    input_symbols: Option<&[Option<SymbolId>]>,
460553
 ) -> Option<SymbolId> {
461554
     let Referent::Symbol(sym_idx) = referent else {
462555
         return None;
463556
     };
557
+    if let Some(symbols) = input_symbols {
558
+        return symbols.get(sym_idx as usize).copied().flatten();
559
+    }
464560
     let input_sym = obj.symbols.get(sym_idx as usize)?;
465561
     let name = obj.symbol_name(input_sym).ok()?;
466562
     let (symbol_id, _) = sym_table
@@ -490,6 +586,24 @@ fn tlv_symbol_needs_got(sym_table: &SymbolTable, symbol_id: SymbolId) -> bool {
490586
     matches!(sym_table.get(symbol_id), Symbol::DylibImport { .. })
491587
 }
492588
 
589
+fn got_page_symbol_needs_slot(
590
+    sym_table: &SymbolTable,
591
+    atoms: &AtomTable,
592
+    symbol_id: SymbolId,
593
+) -> bool {
594
+    match sym_table.get(symbol_id) {
595
+        Symbol::DylibImport { .. } => true,
596
+        Symbol::Defined {
597
+            atom,
598
+            private_extern,
599
+            ..
600
+        } => {
601
+            atom.0 != 0 && !*private_extern && matches!(atoms.get(*atom).section, AtomSection::Data)
602
+        }
603
+        _ => false,
604
+    }
605
+}
606
+
493607
 fn direct_import_bind_supported(reloc: Reloc) -> bool {
494608
     matches!(reloc.length, RelocLength::Quad) && !reloc.pcrel && reloc.subtrahend.is_none()
495609
 }
@@ -1178,6 +1292,7 @@ mod tests {
11781292
             strings: StringTable::from_bytes(strings),
11791293
             symtab: None,
11801294
             dysymtab: None,
1295
+            loh: Vec::new(),
11811296
             data_in_code: Vec::new(),
11821297
         }
11831298
     }
@@ -1263,6 +1378,7 @@ mod tests {
12631378
             strings: StringTable::from_bytes(strings),
12641379
             symtab: None,
12651380
             dysymtab: None,
1381
+            loh: Vec::new(),
12661382
             data_in_code: Vec::new(),
12671383
         }
12681384
     }
@@ -1312,6 +1428,7 @@ mod tests {
13121428
             strings: StringTable::from_bytes(strings),
13131429
             symtab: None,
13141430
             dysymtab: None,
1431
+            loh: Vec::new(),
13151432
             data_in_code: Vec::new(),
13161433
         }
13171434
     }
src/synth/unwind.rsmodified
103 lines changed — click to load
@@ -1,4 +1,4 @@
1
-use std::collections::HashMap;
1
+use std::collections::{HashMap, HashSet};
22
 use std::fmt;
33
 use std::path::PathBuf;
44
 
@@ -158,11 +158,13 @@ pub fn synthesize(
158158
         atom: AtomId(0),
159159
         detail: err.to_string(),
160160
     })?;
161
-    validate_serialized_unwind_info(&bytes, &records).map_err(|err| UnwindError {
162
-        input: PathBuf::from("<synthetic unwind>"),
163
-        atom: AtomId(0),
164
-        detail: err.to_string(),
165
-    })?;
161
+    if should_validate_serialized_unwind_info() {
162
+        validate_serialized_unwind_info(&bytes, &records).map_err(|err| UnwindError {
163
+            input: PathBuf::from("<synthetic unwind>"),
164
+            atom: AtomId(0),
165
+            detail: err.to_string(),
166
+        })?;
167
+    }
166168
     let section_changed = upsert_unwind_info_section(layout, bytes);
167169
     if changed || section_changed {
168170
         prune_empty_segments(layout);
@@ -170,6 +172,10 @@ pub fn synthesize(
170172
     Ok(changed || section_changed)
171173
 }
172174
 
175
+fn should_validate_serialized_unwind_info() -> bool {
176
+    std::env::var_os("AFS_LD_VALIDATE_UNWIND_INFO").is_some()
177
+}
178
+
173179
 fn collect_records(
174180
     layout: &Layout,
175181
     inputs: &[LayoutInput<'_>],
@@ -185,26 +191,42 @@ fn collect_records(
185191
         .iter()
186192
         .map(|input| (input.id, input.object))
187193
         .collect();
194
+    let compact_unwind_sections: HashSet<(InputId, u8)> = atoms
195
+        .iter()
196
+        .filter(|(_, atom)| atom.section == AtomSection::CompactUnwind)
197
+        .map(|(_, atom)| (atom.origin, atom.input_section))
198
+        .collect();
188199
     let mut reloc_cache: HashMap<(InputId, u8), Vec<Reloc>> = HashMap::new();
189
-    for input in inputs {
190
-        for (section_idx, section) in input.object.sections.iter().enumerate() {
191
-            if section.nreloc == 0 {
192
-                continue;
193
-            }
194
-            let raws = parse_raw_relocs(&section.raw_relocs, 0, section.nreloc).map_err(|err| {
195
-                UnwindError {
196
-                    input: input.object.path.clone(),
197
-                    atom: AtomId(0),
198
-                    detail: err.to_string(),
199
-                }
200
-            })?;
201
-            let relocs = parse_relocs(&raws).map_err(|err| UnwindError {
202
-                input: input.object.path.clone(),
200
+    for (input_id, section_idx) in compact_unwind_sections {
201
+        let obj = input_map.get(&input_id).ok_or_else(|| UnwindError {
202
+            input: PathBuf::from("<missing object>"),
203
+            atom: AtomId(0),
204
+            detail: "missing parsed object".to_string(),
205
+        })?;
206
+        let section = obj
207
+            .sections
208
+            .get((section_idx as usize).saturating_sub(1))
209
+            .ok_or_else(|| UnwindError {
210
+                input: obj.path.clone(),
203211
                 atom: AtomId(0),
204
-                detail: err.to_string(),
212
+                detail: format!("compact-unwind section {} is out of range", section_idx),
205213
             })?;
206
-            reloc_cache.insert((input.id, (section_idx + 1) as u8), relocs);
214
+        if section.nreloc == 0 {
215
+            continue;
207216
         }
217
+        let raws = parse_raw_relocs(&section.raw_relocs, 0, section.nreloc).map_err(|err| {
218
+            UnwindError {
219
+                input: obj.path.clone(),
220
+                atom: AtomId(0),
221
+                detail: err.to_string(),
222
+            }
223
+        })?;
224
+        let relocs = parse_relocs(&raws).map_err(|err| UnwindError {
225
+            input: obj.path.clone(),
226
+            atom: AtomId(0),
227
+            detail: err.to_string(),
228
+        })?;
229
+        reloc_cache.insert((input_id, section_idx), relocs);
208230
     }
209231
 
210232
     let mut records = Vec::new();
@@ -212,6 +234,12 @@ fn collect_records(
212234
         if atom.section != AtomSection::CompactUnwind {
213235
             continue;
214236
         }
237
+        if atom
238
+            .parent_of
239
+            .is_some_and(|parent| layout.atom_addr(parent).is_none())
240
+        {
241
+            continue;
242
+        }
215243
         let Some(obj) = input_map.get(&atom.origin) else {
216244
             return Err(UnwindError {
217245
                 input: PathBuf::from("<missing object>"),
src/why_live.rsadded
838 lines changed — click to load
@@ -0,0 +1,838 @@
1
+use std::collections::{HashMap, HashSet, VecDeque};
2
+use std::fmt::Write as _;
3
+
4
+use crate::atom::{Atom, AtomSection, AtomTable};
5
+use crate::icf::FoldedSymbol;
6
+use crate::input::ObjectFile;
7
+use crate::layout::LayoutInput;
8
+use crate::reloc::{parse_raw_relocs, parse_relocs, Referent};
9
+use crate::resolve::{AtomId, InputId, Symbol, SymbolId, SymbolTable};
10
+use crate::{LinkOptions, OutputKind};
11
+
12
+#[derive(Debug, Clone)]
13
+enum RootReason {
14
+    Entry(String),
15
+    NoDeadStrip,
16
+    ExportedDylib,
17
+}
18
+
19
+impl RootReason {
20
+    fn describe(&self, symbol: &str) -> String {
21
+        match self {
22
+            RootReason::Entry(entry) => format!("{symbol} is in -e {entry} (GC root)"),
23
+            RootReason::NoDeadStrip => format!("{symbol} is marked N_NO_DEAD_STRIP (GC root)"),
24
+            RootReason::ExportedDylib => format!("{symbol} is exported from the dylib (GC root)"),
25
+        }
26
+    }
27
+}
28
+
29
+#[derive(Debug, Clone)]
30
+enum LiveCause {
31
+    Root(RootReason),
32
+    ReferencedBy(AtomId),
33
+    ParentOf(AtomId),
34
+}
35
+
36
+#[derive(Debug, Clone, PartialEq, Eq)]
37
+pub struct DeadStrippedSymbol {
38
+    pub name: String,
39
+    pub file_index: usize,
40
+}
41
+
42
+#[derive(Debug, Clone)]
43
+pub struct DeadStripAnalysis {
44
+    live_atoms: HashSet<AtomId>,
45
+    causes: HashMap<AtomId, LiveCause>,
46
+    resolved_by_name: HashMap<String, SymbolId>,
47
+    atom_symbols: HashMap<AtomId, Vec<SymbolId>>,
48
+}
49
+
50
+impl DeadStripAnalysis {
51
+    pub fn build(
52
+        opts: &LinkOptions,
53
+        layout_inputs: &[LayoutInput<'_>],
54
+        atom_table: &AtomTable,
55
+        sym_table: &SymbolTable,
56
+        entry_symbol: Option<SymbolId>,
57
+    ) -> Self {
58
+        let resolved_by_name = resolved_symbol_map(sym_table);
59
+        let atom_symbols = atom_symbol_sets(atom_table);
60
+        let roots = root_atoms(opts, atom_table, sym_table, entry_symbol);
61
+        let forward_edges =
62
+            build_forward_edges(layout_inputs, atom_table, sym_table, &resolved_by_name);
63
+        let parent_edges = parent_edges(atom_table);
64
+
65
+        let mut live_atoms = HashSet::new();
66
+        let mut causes = HashMap::new();
67
+        let mut worklist = VecDeque::new();
68
+        for (atom_id, reason) in roots {
69
+            if live_atoms.insert(atom_id) {
70
+                causes.insert(atom_id, LiveCause::Root(reason));
71
+                worklist.push_back(atom_id);
72
+            }
73
+        }
74
+
75
+        while let Some(atom_id) = worklist.pop_front() {
76
+            if let Some(children) = parent_edges.get(&atom_id) {
77
+                for &child in children {
78
+                    if live_atoms.insert(child) {
79
+                        causes.insert(child, LiveCause::ParentOf(atom_id));
80
+                        worklist.push_back(child);
81
+                    }
82
+                }
83
+            }
84
+            if let Some(targets) = forward_edges.get(&atom_id) {
85
+                for &target in targets {
86
+                    if live_atoms.insert(target) {
87
+                        causes.insert(target, LiveCause::ReferencedBy(atom_id));
88
+                        worklist.push_back(target);
89
+                    }
90
+                }
91
+            }
92
+        }
93
+
94
+        Self {
95
+            live_atoms,
96
+            causes,
97
+            resolved_by_name,
98
+            atom_symbols,
99
+        }
100
+    }
101
+
102
+    pub fn live_atoms(&self) -> &HashSet<AtomId> {
103
+        &self.live_atoms
104
+    }
105
+
106
+    pub fn dead_stripped_symbols(
107
+        &self,
108
+        atom_table: &AtomTable,
109
+        sym_table: &SymbolTable,
110
+        layout_inputs: &[LayoutInput<'_>],
111
+    ) -> Vec<DeadStrippedSymbol> {
112
+        let file_index_by_input: HashMap<InputId, usize> = layout_inputs
113
+            .iter()
114
+            .enumerate()
115
+            .map(|(idx, input)| (input.id, idx + 1))
116
+            .collect();
117
+        let mut out = Vec::new();
118
+        for (atom_id, _atom) in atom_table.iter() {
119
+            if self.live_atoms.contains(&atom_id) {
120
+                continue;
121
+            }
122
+            let Some(symbols) = self.atom_symbols.get(&atom_id) else {
123
+                continue;
124
+            };
125
+            for &symbol_id in symbols {
126
+                let Symbol::Defined { origin, .. } = sym_table.get(symbol_id) else {
127
+                    continue;
128
+                };
129
+                out.push(DeadStrippedSymbol {
130
+                    name: self.symbol_name(sym_table, symbol_id),
131
+                    file_index: file_index_by_input.get(origin).copied().unwrap_or(0),
132
+                });
133
+            }
134
+        }
135
+        out.sort_by(|lhs, rhs| {
136
+            lhs.name
137
+                .cmp(&rhs.name)
138
+                .then(lhs.file_index.cmp(&rhs.file_index))
139
+        });
140
+        out.dedup_by(|lhs, rhs| lhs.name == rhs.name && lhs.file_index == rhs.file_index);
141
+        out
142
+    }
143
+
144
+    fn symbol_name(&self, sym_table: &SymbolTable, symbol_id: SymbolId) -> String {
145
+        sym_table
146
+            .interner
147
+            .resolve(sym_table.get(symbol_id).name())
148
+            .to_string()
149
+    }
150
+
151
+    fn atom_name(&self, sym_table: &SymbolTable, atom_id: AtomId) -> String {
152
+        self.atom_symbols
153
+            .get(&atom_id)
154
+            .and_then(|symbols| symbols.first().copied())
155
+            .map(|symbol_id| self.symbol_name(sym_table, symbol_id))
156
+            .unwrap_or_else(|| format!("<atom {:?}>", atom_id))
157
+    }
158
+
159
+    fn is_live_symbol(&self, sym_table: &SymbolTable, symbol_id: SymbolId) -> bool {
160
+        match sym_table.get(symbol_id) {
161
+            Symbol::Defined { atom, .. } if atom.0 != 0 => self.live_atoms.contains(atom),
162
+            _ => false,
163
+        }
164
+    }
165
+
166
+    fn format_symbol_explanation(
167
+        &self,
168
+        sym_table: &SymbolTable,
169
+        requested_symbol: SymbolId,
170
+    ) -> String {
171
+        let requested_name = self.symbol_name(sym_table, requested_symbol);
172
+        if !self.is_live_symbol(sym_table, requested_symbol) {
173
+            return format!("{requested_name} is not live (dead-stripped)\n");
174
+        }
175
+
176
+        let Symbol::Defined { atom, .. } = sym_table.get(requested_symbol) else {
177
+            return format!("{requested_name} is not backed by a dead-strip eligible input atom\n");
178
+        };
179
+        let mut out = String::new();
180
+        writeln!(&mut out, "{requested_name} is live because:").unwrap();
181
+
182
+        let mut cursor = *atom;
183
+        let mut first = true;
184
+        loop {
185
+            let Some(cause) = self.causes.get(&cursor).cloned() else {
186
+                writeln!(
187
+                    &mut out,
188
+                    "  no reachability chain from a known GC root was found"
189
+                )
190
+                .unwrap();
191
+                break;
192
+            };
193
+            match cause {
194
+                LiveCause::Root(reason) => {
195
+                    let name = if first {
196
+                        requested_name.as_str()
197
+                    } else {
198
+                        &self.atom_name(sym_table, cursor)
199
+                    };
200
+                    writeln!(&mut out, "  {}", reason.describe(name)).unwrap();
201
+                    break;
202
+                }
203
+                LiveCause::ReferencedBy(parent) => {
204
+                    let child_name = if first {
205
+                        requested_name.as_str()
206
+                    } else {
207
+                        &self.atom_name(sym_table, cursor)
208
+                    };
209
+                    writeln!(
210
+                        &mut out,
211
+                        "  {child_name} is reachable from {}",
212
+                        self.atom_name(sym_table, parent)
213
+                    )
214
+                    .unwrap();
215
+                    cursor = parent;
216
+                }
217
+                LiveCause::ParentOf(parent) => {
218
+                    let child_name = if first {
219
+                        requested_name.as_str()
220
+                    } else {
221
+                        &self.atom_name(sym_table, cursor)
222
+                    };
223
+                    writeln!(
224
+                        &mut out,
225
+                        "  {child_name} is reachable via unwind parent from {}",
226
+                        self.atom_name(sym_table, parent)
227
+                    )
228
+                    .unwrap();
229
+                    cursor = parent;
230
+                }
231
+            }
232
+            first = false;
233
+        }
234
+
235
+        out
236
+    }
237
+}
238
+
239
+pub fn format_explanations(
240
+    opts: &LinkOptions,
241
+    layout_inputs: &[LayoutInput<'_>],
242
+    atom_table: &AtomTable,
243
+    sym_table: &SymbolTable,
244
+    entry_symbol: Option<SymbolId>,
245
+    dead_strip: Option<&DeadStripAnalysis>,
246
+    folded_symbols: &[FoldedSymbol],
247
+) -> Result<Option<String>, String> {
248
+    if opts.why_live.is_empty() {
249
+        return Ok(None);
250
+    }
251
+
252
+    let folded_by_name: HashMap<&str, &str> = folded_symbols
253
+        .iter()
254
+        .map(|symbol| (symbol.name.as_str(), symbol.winner.as_str()))
255
+        .collect();
256
+
257
+    if let Some(dead_strip) = dead_strip {
258
+        let mut out = String::new();
259
+        for (idx, requested) in opts.why_live.iter().enumerate() {
260
+            let winner = folded_by_name
261
+                .get(requested.as_str())
262
+                .copied()
263
+                .unwrap_or(requested.as_str());
264
+            let Some(&target) = dead_strip.resolved_by_name.get(winner) else {
265
+                return Err(format!("`-why_live` symbol `{requested}` was not found"));
266
+            };
267
+            if idx > 0 {
268
+                out.push('\n');
269
+            }
270
+            if winner != requested {
271
+                writeln!(&mut out, "{requested} was folded to {winner} by -icf=safe").unwrap();
272
+            }
273
+            out.push_str(&dead_strip.format_symbol_explanation(sym_table, target));
274
+        }
275
+        return Ok(Some(out));
276
+    }
277
+
278
+    let graph = WhyLiveGraph::build(opts, layout_inputs, atom_table, sym_table, entry_symbol);
279
+    let mut out = String::new();
280
+    for (idx, requested) in opts.why_live.iter().enumerate() {
281
+        let winner = folded_by_name
282
+            .get(requested.as_str())
283
+            .copied()
284
+            .unwrap_or(requested.as_str());
285
+        let Some(&target) = graph.resolved_by_name.get(winner) else {
286
+            return Err(format!("`-why_live` symbol `{requested}` was not found"));
287
+        };
288
+        if idx > 0 {
289
+            out.push('\n');
290
+        }
291
+        if winner != requested {
292
+            writeln!(&mut out, "{requested} was folded to {winner} by -icf=safe").unwrap();
293
+        }
294
+        let target_name = graph.symbol_name(target);
295
+        writeln!(&mut out, "{target_name} is live because:").unwrap();
296
+        writeln!(
297
+            &mut out,
298
+            "  note: -dead_strip was not requested; showing the current reachability roots"
299
+        )
300
+        .unwrap();
301
+        if let Some(reason) = graph.roots.get(&target) {
302
+            writeln!(&mut out, "  {}", reason.describe(&target_name)).unwrap();
303
+            continue;
304
+        }
305
+        if let Some(path) = graph.find_chain(target) {
306
+            for pair in path.windows(2).rev() {
307
+                let parent = graph.symbol_name(pair[0]);
308
+                let child = graph.symbol_name(pair[1]);
309
+                writeln!(&mut out, "  {child} is reachable from {parent}").unwrap();
310
+            }
311
+            let root = path[0];
312
+            let root_name = graph.symbol_name(root);
313
+            writeln!(
314
+                &mut out,
315
+                "  {}",
316
+                graph
317
+                    .roots
318
+                    .get(&root)
319
+                    .expect("root path starts from a root")
320
+                    .describe(&root_name)
321
+            )
322
+            .unwrap();
323
+        } else {
324
+            writeln!(
325
+                &mut out,
326
+                "  no reachability chain from a known GC root was found; linked inputs are currently retained as loaded"
327
+            )
328
+            .unwrap();
329
+        }
330
+    }
331
+    Ok(Some(out))
332
+}
333
+
334
+struct WhyLiveGraph<'a> {
335
+    sym_table: &'a SymbolTable,
336
+    resolved_by_name: HashMap<String, SymbolId>,
337
+    roots: HashMap<SymbolId, RootReason>,
338
+    reverse_edges: HashMap<SymbolId, Vec<SymbolId>>,
339
+}
340
+
341
+impl<'a> WhyLiveGraph<'a> {
342
+    fn build(
343
+        opts: &LinkOptions,
344
+        layout_inputs: &[LayoutInput<'_>],
345
+        atom_table: &AtomTable,
346
+        sym_table: &'a SymbolTable,
347
+        entry_symbol: Option<SymbolId>,
348
+    ) -> Self {
349
+        let resolved_by_name = resolved_symbol_map(sym_table);
350
+        let roots = root_symbols(opts, sym_table, entry_symbol);
351
+        let reverse_edges = build_reverse_edges(layout_inputs, atom_table, &resolved_by_name);
352
+        Self {
353
+            sym_table,
354
+            resolved_by_name,
355
+            roots,
356
+            reverse_edges,
357
+        }
358
+    }
359
+
360
+    fn symbol_name(&self, symbol_id: SymbolId) -> String {
361
+        self.sym_table
362
+            .interner
363
+            .resolve(self.sym_table.get(symbol_id).name())
364
+            .to_string()
365
+    }
366
+
367
+    fn find_chain(&self, target: SymbolId) -> Option<Vec<SymbolId>> {
368
+        let mut queue = VecDeque::from([target]);
369
+        let mut seen = HashSet::from([target]);
370
+        let mut next_toward_target = HashMap::<SymbolId, SymbolId>::new();
371
+
372
+        while let Some(current) = queue.pop_front() {
373
+            let Some(predecessors) = self.reverse_edges.get(&current) else {
374
+                continue;
375
+            };
376
+            for &pred in predecessors {
377
+                if !seen.insert(pred) {
378
+                    continue;
379
+                }
380
+                next_toward_target.insert(pred, current);
381
+                if self.roots.contains_key(&pred) {
382
+                    let mut path = vec![pred];
383
+                    let mut cursor = pred;
384
+                    while let Some(&next) = next_toward_target.get(&cursor) {
385
+                        path.push(next);
386
+                        if next == target {
387
+                            break;
388
+                        }
389
+                        cursor = next;
390
+                    }
391
+                    return Some(path);
392
+                }
393
+                queue.push_back(pred);
394
+            }
395
+        }
396
+
397
+        None
398
+    }
399
+}
400
+
401
+fn resolved_symbol_map(sym_table: &SymbolTable) -> HashMap<String, SymbolId> {
402
+    let mut out = HashMap::new();
403
+    for (symbol_id, symbol) in sym_table.iter() {
404
+        let name = sym_table.interner.resolve(symbol.name()).to_string();
405
+        let resolved = match symbol {
406
+            Symbol::Alias { name, .. } => sym_table
407
+                .resolve_chain(*name)
408
+                .map(|(resolved_id, _)| resolved_id)
409
+                .unwrap_or(symbol_id),
410
+            _ => symbol_id,
411
+        };
412
+        out.insert(name, resolved);
413
+    }
414
+    out
415
+}
416
+
417
+fn root_symbols(
418
+    opts: &LinkOptions,
419
+    sym_table: &SymbolTable,
420
+    entry_symbol: Option<SymbolId>,
421
+) -> HashMap<SymbolId, RootReason> {
422
+    let mut roots = HashMap::new();
423
+    if let Some(entry_symbol) = entry_symbol {
424
+        roots.insert(
425
+            entry_symbol,
426
+            RootReason::Entry(symbol_name(sym_table, entry_symbol)),
427
+        );
428
+    }
429
+    for (symbol_id, symbol) in sym_table.iter() {
430
+        match symbol {
431
+            Symbol::Defined {
432
+                no_dead_strip: true,
433
+                ..
434
+            } => {
435
+                roots.entry(symbol_id).or_insert(RootReason::NoDeadStrip);
436
+            }
437
+            Symbol::Defined {
438
+                private_extern: false,
439
+                ..
440
+            } if opts.kind == OutputKind::Dylib => {
441
+                roots.entry(symbol_id).or_insert(RootReason::ExportedDylib);
442
+            }
443
+            _ => {}
444
+        }
445
+    }
446
+    roots
447
+}
448
+
449
+fn root_atoms(
450
+    opts: &LinkOptions,
451
+    atom_table: &AtomTable,
452
+    sym_table: &SymbolTable,
453
+    entry_symbol: Option<SymbolId>,
454
+) -> HashMap<AtomId, RootReason> {
455
+    let mut roots = HashMap::new();
456
+    if let Some(entry_symbol) = entry_symbol {
457
+        if let Symbol::Defined { atom, .. } = sym_table.get(entry_symbol) {
458
+            if atom.0 != 0 {
459
+                roots.insert(
460
+                    *atom,
461
+                    RootReason::Entry(symbol_name(sym_table, entry_symbol)),
462
+                );
463
+            }
464
+        }
465
+    }
466
+
467
+    for (atom_id, atom) in atom_table.iter() {
468
+        if atom.flags.has(crate::atom::AtomFlags::NO_DEAD_STRIP) {
469
+            roots.entry(atom_id).or_insert(RootReason::NoDeadStrip);
470
+        }
471
+    }
472
+
473
+    for (_, symbol) in sym_table.iter() {
474
+        match symbol {
475
+            Symbol::Defined {
476
+                atom,
477
+                no_dead_strip: true,
478
+                ..
479
+            } if atom.0 != 0 => {
480
+                roots.entry(*atom).or_insert(RootReason::NoDeadStrip);
481
+            }
482
+            Symbol::Defined {
483
+                atom,
484
+                private_extern: false,
485
+                ..
486
+            } if opts.kind == OutputKind::Dylib && atom.0 != 0 => {
487
+                roots.entry(*atom).or_insert(RootReason::ExportedDylib);
488
+            }
489
+            _ => {}
490
+        }
491
+    }
492
+
493
+    roots
494
+}
495
+
496
+fn build_reverse_edges(
497
+    layout_inputs: &[LayoutInput<'_>],
498
+    atom_table: &AtomTable,
499
+    resolved_by_name: &HashMap<String, SymbolId>,
500
+) -> HashMap<SymbolId, Vec<SymbolId>> {
501
+    let atoms_by_input_section = atom_table.by_input_section();
502
+    let atom_symbols = atom_symbol_sets(atom_table);
503
+    let mut edge_set = HashSet::<(SymbolId, SymbolId)>::new();
504
+
505
+    for input in layout_inputs {
506
+        for (section_idx_zero, section) in input.object.sections.iter().enumerate() {
507
+            if section.raw_relocs.is_empty() {
508
+                continue;
509
+            }
510
+            let Ok(raws) = parse_raw_relocs(&section.raw_relocs, 0, section.nreloc) else {
511
+                continue;
512
+            };
513
+            let Ok(relocs) = parse_relocs(&raws) else {
514
+                continue;
515
+            };
516
+            let input_section = (section_idx_zero + 1) as u8;
517
+            for reloc in relocs {
518
+                let Some(source_atom) = find_atom_for_offset(
519
+                    atom_table,
520
+                    &atoms_by_input_section,
521
+                    input.id,
522
+                    input_section,
523
+                    reloc.offset,
524
+                ) else {
525
+                    continue;
526
+                };
527
+                let Some(source_symbols) = atom_symbols.get(&source_atom) else {
528
+                    continue;
529
+                };
530
+                let Some(target_symbols) =
531
+                    target_symbols_for_reloc(input.object, reloc.referent, resolved_by_name)
532
+                else {
533
+                    continue;
534
+                };
535
+                for &source in source_symbols {
536
+                    for &target in &target_symbols {
537
+                        if source != target {
538
+                            edge_set.insert((target, source));
539
+                        }
540
+                    }
541
+                }
542
+            }
543
+        }
544
+    }
545
+
546
+    let mut reverse_edges = HashMap::<SymbolId, Vec<SymbolId>>::new();
547
+    for (target, source) in edge_set {
548
+        reverse_edges.entry(target).or_default().push(source);
549
+    }
550
+    for predecessors in reverse_edges.values_mut() {
551
+        predecessors.sort_by_key(|sid| sid.0);
552
+    }
553
+    reverse_edges
554
+}
555
+
556
+fn build_forward_edges(
557
+    layout_inputs: &[LayoutInput<'_>],
558
+    atom_table: &AtomTable,
559
+    sym_table: &SymbolTable,
560
+    resolved_by_name: &HashMap<String, SymbolId>,
561
+) -> HashMap<AtomId, Vec<AtomId>> {
562
+    let atoms_by_input_section = atom_table.by_input_section();
563
+    let mut edge_set = HashSet::<(AtomId, AtomId)>::new();
564
+
565
+    for input in layout_inputs {
566
+        for (section_idx_zero, section) in input.object.sections.iter().enumerate() {
567
+            if section.raw_relocs.is_empty() {
568
+                continue;
569
+            }
570
+            let Ok(raws) = parse_raw_relocs(&section.raw_relocs, 0, section.nreloc) else {
571
+                continue;
572
+            };
573
+            let Ok(relocs) = parse_relocs(&raws) else {
574
+                continue;
575
+            };
576
+            let input_section = (section_idx_zero + 1) as u8;
577
+            for reloc in relocs {
578
+                let Some(source_atom) = find_atom_for_offset(
579
+                    atom_table,
580
+                    &atoms_by_input_section,
581
+                    input.id,
582
+                    input_section,
583
+                    reloc.offset,
584
+                ) else {
585
+                    continue;
586
+                };
587
+                for target_atom in target_atoms_for_reloc(
588
+                    input.id,
589
+                    input.object,
590
+                    atom_table.get(source_atom),
591
+                    reloc,
592
+                    reloc.referent,
593
+                    reloc.subtrahend,
594
+                    atom_table,
595
+                    sym_table,
596
+                    resolved_by_name,
597
+                    &atoms_by_input_section,
598
+                ) {
599
+                    if source_atom != target_atom {
600
+                        edge_set.insert((source_atom, target_atom));
601
+                    }
602
+                }
603
+            }
604
+        }
605
+    }
606
+
607
+    for (atom_id, atom) in atom_table.iter() {
608
+        if atom.section != AtomSection::EhFrame {
609
+            continue;
610
+        }
611
+        let Some(cie_atom) = eh_frame_cie_atom(atom_table, &atoms_by_input_section, atom) else {
612
+            continue;
613
+        };
614
+        if atom_id != cie_atom {
615
+            edge_set.insert((atom_id, cie_atom));
616
+        }
617
+    }
618
+
619
+    let mut forward_edges = HashMap::<AtomId, Vec<AtomId>>::new();
620
+    for (source, target) in edge_set {
621
+        forward_edges.entry(source).or_default().push(target);
622
+    }
623
+    for targets in forward_edges.values_mut() {
624
+        targets.sort_by_key(|aid| aid.0);
625
+    }
626
+    forward_edges
627
+}
628
+
629
+fn parent_edges(atom_table: &AtomTable) -> HashMap<AtomId, Vec<AtomId>> {
630
+    let mut out = HashMap::<AtomId, Vec<AtomId>>::new();
631
+    for (atom_id, atom) in atom_table.iter() {
632
+        if let Some(parent) = atom.parent_of {
633
+            out.entry(parent).or_default().push(atom_id);
634
+        }
635
+    }
636
+    for children in out.values_mut() {
637
+        children.sort_by_key(|aid| aid.0);
638
+    }
639
+    out
640
+}
641
+
642
+fn atom_symbol_sets(atom_table: &AtomTable) -> HashMap<crate::resolve::AtomId, Vec<SymbolId>> {
643
+    let mut out = HashMap::new();
644
+    for (atom_id, atom) in atom_table.iter() {
645
+        let mut symbols = Vec::new();
646
+        if let Some(owner) = atom.owner {
647
+            symbols.push(owner);
648
+        }
649
+        for alt in &atom.alt_entries {
650
+            symbols.push(alt.symbol);
651
+        }
652
+        symbols.sort_by_key(|sid| sid.0);
653
+        symbols.dedup();
654
+        if !symbols.is_empty() {
655
+            out.insert(atom_id, symbols);
656
+        }
657
+    }
658
+    out
659
+}
660
+
661
+fn find_atom_for_offset(
662
+    atom_table: &AtomTable,
663
+    atoms_by_input_section: &HashMap<(InputId, u8), Vec<AtomId>>,
664
+    input_id: InputId,
665
+    input_section: u8,
666
+    offset: u32,
667
+) -> Option<AtomId> {
668
+    atoms_by_input_section
669
+        .get(&(input_id, input_section))
670
+        .and_then(|ids| {
671
+            ids.iter().find_map(|atom_id| {
672
+                let atom = atom_table.get(*atom_id);
673
+                let start = atom.input_offset;
674
+                let end = atom.input_offset.saturating_add(atom.size);
675
+                (start <= offset && offset < end).then_some(*atom_id)
676
+            })
677
+        })
678
+}
679
+
680
+#[allow(clippy::too_many_arguments)]
681
+fn target_atoms_for_reloc(
682
+    input_id: InputId,
683
+    object: &ObjectFile,
684
+    source_atom: &Atom,
685
+    reloc: crate::reloc::Reloc,
686
+    referent: Referent,
687
+    subtrahend: Option<Referent>,
688
+    atom_table: &AtomTable,
689
+    sym_table: &SymbolTable,
690
+    resolved_by_name: &HashMap<String, SymbolId>,
691
+    atoms_by_input_section: &HashMap<(InputId, u8), Vec<AtomId>>,
692
+) -> Vec<AtomId> {
693
+    let mut out = referent_atoms(
694
+        input_id,
695
+        object,
696
+        source_atom,
697
+        reloc,
698
+        referent,
699
+        atom_table,
700
+        sym_table,
701
+        resolved_by_name,
702
+        atoms_by_input_section,
703
+    );
704
+    if let Some(subtrahend) = subtrahend {
705
+        out.extend(referent_atoms(
706
+            input_id,
707
+            object,
708
+            source_atom,
709
+            reloc,
710
+            subtrahend,
711
+            atom_table,
712
+            sym_table,
713
+            resolved_by_name,
714
+            atoms_by_input_section,
715
+        ));
716
+    }
717
+    out.sort_by_key(|aid| aid.0);
718
+    out.dedup();
719
+    out
720
+}
721
+
722
+#[allow(clippy::too_many_arguments)]
723
+fn referent_atoms(
724
+    input_id: InputId,
725
+    object: &ObjectFile,
726
+    source_atom: &Atom,
727
+    reloc: crate::reloc::Reloc,
728
+    referent: Referent,
729
+    atom_table: &AtomTable,
730
+    sym_table: &SymbolTable,
731
+    resolved_by_name: &HashMap<String, SymbolId>,
732
+    atoms_by_input_section: &HashMap<(InputId, u8), Vec<AtomId>>,
733
+) -> Vec<AtomId> {
734
+    match referent {
735
+        Referent::Symbol(symbol_index) => {
736
+            let Some(input_sym) = object.symbols.get(symbol_index as usize) else {
737
+                return Vec::new();
738
+            };
739
+            let Some(name) = object.symbol_name(input_sym).ok() else {
740
+                return Vec::new();
741
+            };
742
+            let Some(&symbol_id) = resolved_by_name.get(name) else {
743
+                return Vec::new();
744
+            };
745
+            match sym_table.get(symbol_id) {
746
+                Symbol::Defined { atom, .. } if atom.0 != 0 => vec![*atom],
747
+                _ => Vec::new(),
748
+            }
749
+        }
750
+        Referent::Section(section_index) => {
751
+            if let Some(atom_id) = section_referent_atom(
752
+                input_id,
753
+                source_atom,
754
+                reloc,
755
+                section_index,
756
+                atom_table,
757
+                atoms_by_input_section,
758
+            ) {
759
+                vec![atom_id]
760
+            } else {
761
+                atoms_by_input_section
762
+                    .get(&(input_id, section_index))
763
+                    .cloned()
764
+                    .unwrap_or_default()
765
+            }
766
+        }
767
+    }
768
+}
769
+
770
+fn section_referent_atom(
771
+    input_id: InputId,
772
+    source_atom: &Atom,
773
+    reloc: crate::reloc::Reloc,
774
+    section_index: u8,
775
+    atom_table: &AtomTable,
776
+    atoms_by_input_section: &HashMap<(InputId, u8), Vec<AtomId>>,
777
+) -> Option<AtomId> {
778
+    if source_atom.section == AtomSection::CompactUnwind
779
+        && reloc.offset == source_atom.input_offset
780
+        && source_atom.data.len() >= 8
781
+    {
782
+        let mut buf = [0u8; 8];
783
+        buf.copy_from_slice(&source_atom.data[..8]);
784
+        let target_offset = u64::from_le_bytes(buf) as u32;
785
+        return find_atom_for_offset(
786
+            atom_table,
787
+            atoms_by_input_section,
788
+            input_id,
789
+            section_index,
790
+            target_offset,
791
+        );
792
+    }
793
+    None
794
+}
795
+
796
+fn eh_frame_cie_atom(
797
+    atom_table: &AtomTable,
798
+    atoms_by_input_section: &HashMap<(InputId, u8), Vec<AtomId>>,
799
+    atom: &Atom,
800
+) -> Option<AtomId> {
801
+    if atom.section != AtomSection::EhFrame || atom.data.len() < 8 {
802
+        return None;
803
+    }
804
+    let mut buf = [0u8; 4];
805
+    buf.copy_from_slice(&atom.data[4..8]);
806
+    let cie_delta = u32::from_le_bytes(buf);
807
+    if cie_delta == 0 {
808
+        return None;
809
+    }
810
+    let cie_offset = atom.input_offset.checked_add(4)?.checked_sub(cie_delta)?;
811
+    find_atom_for_offset(
812
+        atom_table,
813
+        atoms_by_input_section,
814
+        atom.origin,
815
+        atom.input_section,
816
+        cie_offset,
817
+    )
818
+}
819
+
820
+fn target_symbols_for_reloc(
821
+    object: &ObjectFile,
822
+    referent: Referent,
823
+    resolved_by_name: &HashMap<String, SymbolId>,
824
+) -> Option<Vec<SymbolId>> {
825
+    let Referent::Symbol(symbol_index) = referent else {
826
+        return None;
827
+    };
828
+    let input_sym = object.symbols.get(symbol_index as usize)?;
829
+    let name = object.symbol_name(input_sym).ok()?;
830
+    resolved_by_name.get(name).copied().map(|sid| vec![sid])
831
+}
832
+
833
+fn symbol_name(sym_table: &SymbolTable, symbol_id: SymbolId) -> String {
834
+    sym_table
835
+        .interner
836
+        .resolve(sym_table.get(symbol_id).name())
837
+        .to_string()
838
+}
tests/atom_integration.rsmodified
24 lines changed — click to load
@@ -107,14 +107,8 @@ fn atomize_splits_text_at_symbol_boundaries_and_backpatches_symbols() {
107107
     // Atomize + back-patch.
108108
     let obj = inputs.object_file(input_id).unwrap();
109109
     let mut atom_table = AtomTable::new();
110
-    let atomization = atomize_object(input_id, &obj, &mut atom_table);
111
-    backpatch_symbol_atoms(
112
-        &atomization,
113
-        input_id,
114
-        &obj,
115
-        &mut sym_table,
116
-        &mut atom_table,
117
-    );
110
+    let atomization = atomize_object(input_id, obj, &mut atom_table);
111
+    backpatch_symbol_atoms(&atomization, input_id, obj, &mut sym_table, &mut atom_table);
118112
 
119113
     // At least one atom per defined function plus one for data_global.
120114
     assert!(
@@ -209,7 +203,7 @@ fn atomize_cstring_splits_at_null_terminators() {
209203
     let _ = seed_all(&inputs, &mut sym_table).expect("seed_all");
210204
     let obj = inputs.object_file(input_id).unwrap();
211205
     let mut atom_table = AtomTable::new();
212
-    let _atomization = atomize_object(input_id, &obj, &mut atom_table);
206
+    let _atomization = atomize_object(input_id, obj, &mut atom_table);
213207
 
214208
     let cstring_atoms: Vec<_> = atom_table
215209
         .iter()
tests/cli_diagnostics.rsmodified
927 lines changed — click to load
@@ -2,6 +2,11 @@ use std::fs;
22
 use std::path::PathBuf;
33
 use std::process::Command;
44
 
5
+use afs_ld::macho::constants::LC_UUID;
6
+use afs_ld::macho::reader::{parse_commands, parse_header, LoadCommand};
7
+
8
+const EXPECTED_HELP: &str = include_str!("snapshots/help.txt");
9
+
510
 fn have_xcrun() -> bool {
611
     Command::new("xcrun")
712
         .arg("-f")
@@ -11,6 +16,28 @@ fn have_xcrun() -> bool {
1116
         .unwrap_or(false)
1217
 }
1318
 
19
+fn sdk_path() -> Option<String> {
20
+    let out = Command::new("xcrun")
21
+        .args(["--sdk", "macosx", "--show-sdk-path"])
22
+        .output()
23
+        .ok()?;
24
+    if !out.status.success() {
25
+        return None;
26
+    }
27
+    Some(String::from_utf8_lossy(&out.stdout).trim().to_string())
28
+}
29
+
30
+fn minimal_main_src() -> &'static str {
31
+    r#"
32
+        .section __TEXT,__text,regular,pure_instructions
33
+        .globl _main
34
+        _main:
35
+            mov w0, #0
36
+            ret
37
+        .subsections_via_symbols
38
+    "#
39
+}
40
+
1441
 fn assemble(src: &str, out: &PathBuf) -> Result<(), String> {
1542
     let tmp = std::env::temp_dir().join(format!(
1643
         "afs-ld-cli-diag-{}-{}.s",
@@ -39,6 +66,511 @@ fn scratch(name: &str) -> PathBuf {
3966
     std::env::temp_dir().join(format!("afs-ld-cli-diag-{}-{name}", std::process::id()))
4067
 }
4168
 
69
+fn assemble_minimal_main(name: &str) -> Result<PathBuf, String> {
70
+    let obj = scratch(name);
71
+    assemble(minimal_main_src(), &obj)?;
72
+    Ok(obj)
73
+}
74
+
75
+fn assert_flag_errors(flag: &str, expected: &str, name: &str) {
76
+    if !have_xcrun() {
77
+        eprintln!("skipping: xcrun as unavailable");
78
+        return;
79
+    }
80
+    let exe = env!("CARGO_BIN_EXE_afs-ld");
81
+    let obj = match assemble_minimal_main(&format!("{name}.o")) {
82
+        Ok(obj) => obj,
83
+        Err(e) => {
84
+            eprintln!("skipping: assemble failed: {e}");
85
+            return;
86
+        }
87
+    };
88
+    let out_path = scratch(&format!("{name}.out"));
89
+    let out = Command::new(exe)
90
+        .arg(flag)
91
+        .arg("-o")
92
+        .arg(&out_path)
93
+        .arg(&obj)
94
+        .output()
95
+        .expect("afs-ld should run");
96
+    assert!(!out.status.success(), "{flag} should fail");
97
+    let stderr = String::from_utf8_lossy(&out.stderr);
98
+    assert!(
99
+        stderr.contains(expected),
100
+        "missing expected `{expected}` in stderr:\n{stderr}"
101
+    );
102
+    let _ = fs::remove_file(obj);
103
+    let _ = fs::remove_file(out_path);
104
+}
105
+
106
+fn archive(objects: &[&PathBuf], out: &PathBuf) -> Result<(), String> {
107
+    let output = Command::new("libtool")
108
+        .arg("-static")
109
+        .arg("-o")
110
+        .arg(out)
111
+        .args(objects)
112
+        .output()
113
+        .map_err(|e| format!("spawn libtool: {e}"))?;
114
+    if !output.status.success() {
115
+        return Err(format!(
116
+            "libtool failed: {}",
117
+            String::from_utf8_lossy(&output.stderr)
118
+        ));
119
+    }
120
+    Ok(())
121
+}
122
+
123
+fn nm_defined_names(path: &PathBuf) -> Result<Vec<String>, String> {
124
+    let output = Command::new("xcrun")
125
+        .args(["nm", "-gj"])
126
+        .arg(path)
127
+        .output()
128
+        .map_err(|e| format!("spawn xcrun nm: {e}"))?;
129
+    if !output.status.success() {
130
+        return Err(format!(
131
+            "xcrun nm failed: {}",
132
+            String::from_utf8_lossy(&output.stderr)
133
+        ));
134
+    }
135
+    Ok(String::from_utf8_lossy(&output.stdout)
136
+        .lines()
137
+        .map(str::trim)
138
+        .filter(|line| !line.is_empty())
139
+        .map(ToOwned::to_owned)
140
+        .collect())
141
+}
142
+
143
+#[test]
144
+fn help_flag_prints_usage_and_exits_successfully() {
145
+    let exe = env!("CARGO_BIN_EXE_afs-ld");
146
+    let out = Command::new(exe)
147
+        .arg("--help")
148
+        .output()
149
+        .expect("afs-ld should run");
150
+    assert!(out.status.success(), "help should succeed");
151
+    let stdout = String::from_utf8_lossy(&out.stdout);
152
+    assert_eq!(stdout.as_ref(), EXPECTED_HELP);
153
+    assert!(
154
+        out.stderr.is_empty(),
155
+        "help should not write to stderr:\n{}",
156
+        String::from_utf8_lossy(&out.stderr)
157
+    );
158
+}
159
+
160
+#[test]
161
+fn version_flag_prints_version_and_exits_successfully() {
162
+    let exe = env!("CARGO_BIN_EXE_afs-ld");
163
+    let out = Command::new(exe)
164
+        .arg("--version")
165
+        .output()
166
+        .expect("afs-ld should run");
167
+    assert!(out.status.success(), "version should succeed");
168
+    let stdout = String::from_utf8_lossy(&out.stdout);
169
+    assert_eq!(
170
+        stdout.trim(),
171
+        format!("afs-ld {}", env!("CARGO_PKG_VERSION"))
172
+    );
173
+}
174
+
175
+#[test]
176
+fn no_uuid_flag_omits_uuid_load_command() {
177
+    if !have_xcrun() {
178
+        eprintln!("skipping: xcrun as unavailable");
179
+        return;
180
+    }
181
+
182
+    let exe = env!("CARGO_BIN_EXE_afs-ld");
183
+    let obj = match assemble_minimal_main("no-uuid-main.o") {
184
+        Ok(obj) => obj,
185
+        Err(e) => {
186
+            eprintln!("skipping: assemble failed: {e}");
187
+            return;
188
+        }
189
+    };
190
+    let out_path = scratch("no-uuid.out");
191
+    let out = Command::new(exe)
192
+        .arg("-no_uuid")
193
+        .arg("-o")
194
+        .arg(&out_path)
195
+        .arg(&obj)
196
+        .output()
197
+        .expect("afs-ld should run");
198
+    assert!(
199
+        out.status.success(),
200
+        "-no_uuid link should succeed:\nstderr:\n{}",
201
+        String::from_utf8_lossy(&out.stderr)
202
+    );
203
+
204
+    let bytes = fs::read(&out_path).expect("read linked output");
205
+    let header = parse_header(&bytes).expect("parse header");
206
+    let commands = parse_commands(&header, &bytes).expect("parse commands");
207
+    assert!(
208
+        commands.iter().all(|cmd| match cmd {
209
+            LoadCommand::Raw { cmd, .. } => *cmd != LC_UUID,
210
+            _ => true,
211
+        }),
212
+        "expected -no_uuid output to omit LC_UUID"
213
+    );
214
+
215
+    let _ = fs::remove_file(obj);
216
+    let _ = fs::remove_file(out_path);
217
+}
218
+
219
+#[test]
220
+fn no_loh_flag_warns_but_links_successfully() {
221
+    if !have_xcrun() {
222
+        eprintln!("skipping: xcrun as unavailable");
223
+        return;
224
+    }
225
+
226
+    let exe = env!("CARGO_BIN_EXE_afs-ld");
227
+    let obj = match assemble_minimal_main("no-loh-main.o") {
228
+        Ok(obj) => obj,
229
+        Err(e) => {
230
+            eprintln!("skipping: assemble failed: {e}");
231
+            return;
232
+        }
233
+    };
234
+    let out_path = scratch("no-loh.out");
235
+    let out = Command::new(exe)
236
+        .arg("-no_loh")
237
+        .arg("-o")
238
+        .arg(&out_path)
239
+        .arg(&obj)
240
+        .output()
241
+        .expect("afs-ld should run");
242
+    assert!(
243
+        out.status.success(),
244
+        "-no_loh link should succeed:\nstderr:\n{}",
245
+        String::from_utf8_lossy(&out.stderr)
246
+    );
247
+    let stderr = String::from_utf8_lossy(&out.stderr);
248
+    assert!(
249
+        stderr.contains("afs-ld: warning: `-no_loh` requested"),
250
+        "expected -no_loh warning:\n{stderr}"
251
+    );
252
+    assert!(
253
+        out_path.is_file(),
254
+        "expected -no_loh link to produce {}",
255
+        out_path.display()
256
+    );
257
+
258
+    let _ = fs::remove_file(obj);
259
+    let _ = fs::remove_file(out_path);
260
+}
261
+
262
+#[test]
263
+fn strip_debug_flag_warns_but_links_successfully() {
264
+    if !have_xcrun() {
265
+        eprintln!("skipping: xcrun as unavailable");
266
+        return;
267
+    }
268
+
269
+    let exe = env!("CARGO_BIN_EXE_afs-ld");
270
+    let obj = match assemble_minimal_main("strip-debug-main.o") {
271
+        Ok(obj) => obj,
272
+        Err(e) => {
273
+            eprintln!("skipping: assemble failed: {e}");
274
+            return;
275
+        }
276
+    };
277
+    let out_path = scratch("strip-debug.out");
278
+    let out = Command::new(exe)
279
+        .arg("-S")
280
+        .arg("-o")
281
+        .arg(&out_path)
282
+        .arg(&obj)
283
+        .output()
284
+        .expect("afs-ld should run");
285
+    assert!(
286
+        out.status.success(),
287
+        "-S link should succeed:\nstderr:\n{}",
288
+        String::from_utf8_lossy(&out.stderr)
289
+    );
290
+    let stderr = String::from_utf8_lossy(&out.stderr);
291
+    assert!(
292
+        stderr.contains("afs-ld: warning: `-S` requested"),
293
+        "expected -S warning:\n{stderr}"
294
+    );
295
+
296
+    let _ = fs::remove_file(obj);
297
+    let _ = fs::remove_file(out_path);
298
+}
299
+
300
+#[test]
301
+fn objc_flag_warns_but_links_successfully() {
302
+    if !have_xcrun() {
303
+        eprintln!("skipping: xcrun as unavailable");
304
+        return;
305
+    }
306
+
307
+    let exe = env!("CARGO_BIN_EXE_afs-ld");
308
+    let obj = match assemble_minimal_main("objc-main.o") {
309
+        Ok(obj) => obj,
310
+        Err(e) => {
311
+            eprintln!("skipping: assemble failed: {e}");
312
+            return;
313
+        }
314
+    };
315
+    let out_path = scratch("objc.out");
316
+    let out = Command::new(exe)
317
+        .arg("-ObjC")
318
+        .arg("-o")
319
+        .arg(&out_path)
320
+        .arg(&obj)
321
+        .output()
322
+        .expect("afs-ld should run");
323
+    assert!(
324
+        out.status.success(),
325
+        "-ObjC link should succeed:\nstderr:\n{}",
326
+        String::from_utf8_lossy(&out.stderr)
327
+    );
328
+    let stderr = String::from_utf8_lossy(&out.stderr);
329
+    assert!(
330
+        stderr.contains("afs-ld: warning: `-ObjC` requested"),
331
+        "expected -ObjC warning:\n{stderr}"
332
+    );
333
+
334
+    let _ = fs::remove_file(obj);
335
+    let _ = fs::remove_file(out_path);
336
+}
337
+
338
+#[test]
339
+fn relocatable_flag_errors_loudly() {
340
+    assert_flag_errors(
341
+        "-r",
342
+        "`-r` relocatable output is not yet supported",
343
+        "relocatable",
344
+    );
345
+}
346
+
347
+#[test]
348
+fn bundle_flag_errors_loudly() {
349
+    assert_flag_errors("-bundle", "`-bundle` output is not yet supported", "bundle");
350
+}
351
+
352
+#[test]
353
+fn dead_strip_removes_unreferenced_symbols_and_reports_why_live() {
354
+    if !have_xcrun() {
355
+        eprintln!("skipping: xcrun as unavailable");
356
+        return;
357
+    }
358
+
359
+    let exe = env!("CARGO_BIN_EXE_afs-ld");
360
+    let main_obj = scratch("dead-strip-main.o");
361
+    let helper_obj = scratch("dead-strip-helper.o");
362
+    let unused_obj = scratch("dead-strip-unused.o");
363
+    let out_path = scratch("dead-strip.out");
364
+    let main_src = r#"
365
+        .section __TEXT,__text,regular,pure_instructions
366
+        .globl _main
367
+        _main:
368
+            bl _helper
369
+            mov w0, #0
370
+            ret
371
+        .subsections_via_symbols
372
+    "#;
373
+    let helper_src = r#"
374
+        .section __TEXT,__text,regular,pure_instructions
375
+        .globl _helper
376
+        _helper:
377
+            ret
378
+        .subsections_via_symbols
379
+    "#;
380
+    let unused_src = r#"
381
+        .section __TEXT,__text,regular,pure_instructions
382
+        .globl _unused
383
+        _unused:
384
+            ret
385
+        .subsections_via_symbols
386
+    "#;
387
+    if let Err(e) = assemble(main_src, &main_obj) {
388
+        eprintln!("skipping: assemble failed: {e}");
389
+        return;
390
+    }
391
+    if let Err(e) = assemble(helper_src, &helper_obj) {
392
+        eprintln!("skipping: assemble failed: {e}");
393
+        let _ = fs::remove_file(main_obj);
394
+        return;
395
+    }
396
+    if let Err(e) = assemble(unused_src, &unused_obj) {
397
+        eprintln!("skipping: assemble failed: {e}");
398
+        let _ = fs::remove_file(main_obj);
399
+        let _ = fs::remove_file(helper_obj);
400
+        return;
401
+    }
402
+
403
+    let out = Command::new(exe)
404
+        .arg("-dead_strip")
405
+        .arg("-why_live")
406
+        .arg("_helper")
407
+        .arg("-why_live")
408
+        .arg("_unused")
409
+        .arg("-o")
410
+        .arg(&out_path)
411
+        .arg(&main_obj)
412
+        .arg(&helper_obj)
413
+        .arg(&unused_obj)
414
+        .output()
415
+        .expect("afs-ld should run");
416
+    assert!(
417
+        out.status.success(),
418
+        "-dead_strip link should succeed:\nstderr:\n{}",
419
+        String::from_utf8_lossy(&out.stderr)
420
+    );
421
+    let stdout = String::from_utf8_lossy(&out.stdout);
422
+    assert!(stdout.contains("_helper is live because:"));
423
+    assert!(stdout.contains("_helper is reachable from _main"));
424
+    assert!(stdout.contains("_main is in -e _main (GC root)"));
425
+    assert!(stdout.contains("_unused is not live (dead-stripped)"));
426
+
427
+    let symbols = match nm_defined_names(&out_path) {
428
+        Ok(symbols) => symbols,
429
+        Err(e) => {
430
+            panic!("nm failed: {e}");
431
+        }
432
+    };
433
+    assert!(symbols.contains(&"_main".to_string()));
434
+    assert!(symbols.contains(&"_helper".to_string()));
435
+    assert!(
436
+        !symbols.contains(&"_unused".to_string()),
437
+        "dead-stripped symbol still present:\n{}",
438
+        symbols.join("\n")
439
+    );
440
+
441
+    let _ = fs::remove_file(main_obj);
442
+    let _ = fs::remove_file(helper_obj);
443
+    let _ = fs::remove_file(unused_obj);
444
+    let _ = fs::remove_file(out_path);
445
+}
446
+
447
+#[test]
448
+fn dead_strip_keeps_no_dead_strip_roots() {
449
+    if !have_xcrun() {
450
+        eprintln!("skipping: xcrun as unavailable");
451
+        return;
452
+    }
453
+
454
+    let exe = env!("CARGO_BIN_EXE_afs-ld");
455
+    let obj = scratch("dead-strip-no-dead-strip.o");
456
+    let out_path = scratch("dead-strip-no-dead-strip.out");
457
+    let src = r#"
458
+        .section __TEXT,__text,regular,pure_instructions
459
+        .globl _main
460
+        _main:
461
+            mov w0, #0
462
+            ret
463
+
464
+        .globl _keep
465
+        _keep:
466
+            ret
467
+        .desc _keep, 0x20
468
+
469
+        .globl _drop
470
+        _drop:
471
+            ret
472
+        .subsections_via_symbols
473
+    "#;
474
+    if let Err(e) = assemble(src, &obj) {
475
+        eprintln!("skipping: assemble failed: {e}");
476
+        return;
477
+    }
478
+
479
+    let out = Command::new(exe)
480
+        .arg("-dead_strip")
481
+        .arg("-why_live")
482
+        .arg("_keep")
483
+        .arg("-why_live")
484
+        .arg("_drop")
485
+        .arg("-o")
486
+        .arg(&out_path)
487
+        .arg(&obj)
488
+        .output()
489
+        .expect("afs-ld should run");
490
+    assert!(
491
+        out.status.success(),
492
+        "-dead_strip link should succeed:\nstderr:\n{}",
493
+        String::from_utf8_lossy(&out.stderr)
494
+    );
495
+    let stdout = String::from_utf8_lossy(&out.stdout);
496
+    assert!(stdout.contains("_keep is live because:"));
497
+    assert!(stdout.contains("_keep is marked N_NO_DEAD_STRIP (GC root)"));
498
+    assert!(stdout.contains("_drop is not live (dead-stripped)"));
499
+
500
+    let symbols = match nm_defined_names(&out_path) {
501
+        Ok(symbols) => symbols,
502
+        Err(e) => {
503
+            panic!("nm failed: {e}");
504
+        }
505
+    };
506
+    assert!(symbols.contains(&"_main".to_string()));
507
+    assert!(symbols.contains(&"_keep".to_string()));
508
+    assert!(
509
+        !symbols.contains(&"_drop".to_string()),
510
+        "dead-stripped symbol still present:\n{}",
511
+        symbols.join("\n")
512
+    );
513
+
514
+    let _ = fs::remove_file(obj);
515
+    let _ = fs::remove_file(out_path);
516
+}
517
+
518
+#[test]
519
+fn icf_safe_flag_links_successfully() {
520
+    if !have_xcrun() {
521
+        eprintln!("skipping: xcrun as unavailable");
522
+        return;
523
+    }
524
+
525
+    let exe = env!("CARGO_BIN_EXE_afs-ld");
526
+    let obj = match assemble_minimal_main("icf-safe-main.o") {
527
+        Ok(obj) => obj,
528
+        Err(e) => {
529
+            eprintln!("skipping: assemble failed: {e}");
530
+            return;
531
+        }
532
+    };
533
+    let out_path = scratch("icf-safe.out");
534
+    let out = Command::new(exe)
535
+        .arg("-icf=safe")
536
+        .arg("-o")
537
+        .arg(&out_path)
538
+        .arg(&obj)
539
+        .output()
540
+        .expect("afs-ld should run");
541
+    assert!(
542
+        out.status.success(),
543
+        "-icf=safe link should succeed:\nstderr:\n{}",
544
+        String::from_utf8_lossy(&out.stderr)
545
+    );
546
+    assert!(
547
+        out_path.is_file(),
548
+        "expected -icf=safe link to produce {}",
549
+        out_path.display()
550
+    );
551
+
552
+    let _ = fs::remove_file(obj);
553
+    let _ = fs::remove_file(out_path);
554
+}
555
+
556
+#[test]
557
+fn icf_all_flag_errors_loudly() {
558
+    assert_flag_errors(
559
+        "-icf=all",
560
+        "`-icf=all` is not yet supported; use `-icf=safe` or `-icf=none`",
561
+        "icf-all",
562
+    );
563
+}
564
+
565
+#[test]
566
+fn fixup_chains_flag_errors_loudly() {
567
+    assert_flag_errors(
568
+        "-fixup_chains",
569
+        "`-fixup_chains` is not yet supported",
570
+        "fixup-chains",
571
+    );
572
+}
573
+
42574
 #[test]
43575
 fn undefined_symbol_diagnostic_is_not_double_prefixed() {
44576
     if !have_xcrun() {
@@ -79,3 +611,377 @@ fn undefined_symbol_diagnostic_is_not_double_prefixed() {
79611
 
80612
     let _ = fs::remove_file(obj);
81613
 }
614
+
615
+#[test]
616
+fn undefined_warning_mode_links_and_warns_once() {
617
+    if !have_xcrun() {
618
+        eprintln!("skipping: xcrun as unavailable");
619
+        return;
620
+    }
621
+    let Some(sdk) = sdk_path() else {
622
+        eprintln!("skipping: xcrun --show-sdk-path unavailable");
623
+        return;
624
+    };
625
+
626
+    let exe = env!("CARGO_BIN_EXE_afs-ld");
627
+    let obj = scratch("missing-warning.o");
628
+    let src = r#"
629
+        .section __TEXT,__text,regular,pure_instructions
630
+        .globl _main
631
+        _main:
632
+            bl _missing
633
+            ret
634
+        .subsections_via_symbols
635
+    "#;
636
+    if let Err(e) = assemble(src, &obj) {
637
+        eprintln!("skipping: assemble failed: {e}");
638
+        return;
639
+    }
640
+
641
+    let out_path = scratch("missing-warning.out");
642
+    let out = Command::new(exe)
643
+        .arg("-undefined")
644
+        .arg("warning")
645
+        .arg("-syslibroot")
646
+        .arg(&sdk)
647
+        .arg("-lSystem")
648
+        .arg("-o")
649
+        .arg(&out_path)
650
+        .arg(&obj)
651
+        .output()
652
+        .expect("afs-ld should run");
653
+    assert!(
654
+        out.status.success(),
655
+        "-undefined warning should link successfully:\nstderr:\n{}",
656
+        String::from_utf8_lossy(&out.stderr)
657
+    );
658
+    let stderr = String::from_utf8_lossy(&out.stderr);
659
+    assert!(
660
+        stderr.contains("afs-ld: warning: undefined symbol: _missing"),
661
+        "missing expected undefined-symbol warning:\n{stderr}"
662
+    );
663
+    assert!(
664
+        !stderr.contains("afs-ld: warning: afs-ld: warning:"),
665
+        "warning diagnostic was double-prefixed:\n{stderr}"
666
+    );
667
+    assert!(out_path.exists(), "expected linked output to be written");
668
+
669
+    let _ = fs::remove_file(obj);
670
+    let _ = fs::remove_file(out_path);
671
+}
672
+
673
+#[test]
674
+fn undefined_suppress_mode_links_silently() {
675
+    if !have_xcrun() {
676
+        eprintln!("skipping: xcrun as unavailable");
677
+        return;
678
+    }
679
+    let Some(sdk) = sdk_path() else {
680
+        eprintln!("skipping: xcrun --show-sdk-path unavailable");
681
+        return;
682
+    };
683
+
684
+    let exe = env!("CARGO_BIN_EXE_afs-ld");
685
+    let obj = scratch("missing-suppress.o");
686
+    let src = r#"
687
+        .section __TEXT,__text,regular,pure_instructions
688
+        .globl _main
689
+        _main:
690
+            bl _missing
691
+            ret
692
+        .subsections_via_symbols
693
+    "#;
694
+    if let Err(e) = assemble(src, &obj) {
695
+        eprintln!("skipping: assemble failed: {e}");
696
+        return;
697
+    }
698
+
699
+    let out_path = scratch("missing-suppress.out");
700
+    let out = Command::new(exe)
701
+        .arg("-undefined")
702
+        .arg("suppress")
703
+        .arg("-syslibroot")
704
+        .arg(&sdk)
705
+        .arg("-lSystem")
706
+        .arg("-o")
707
+        .arg(&out_path)
708
+        .arg(&obj)
709
+        .output()
710
+        .expect("afs-ld should run");
711
+    assert!(
712
+        out.status.success(),
713
+        "-undefined suppress should link successfully:\nstderr:\n{}",
714
+        String::from_utf8_lossy(&out.stderr)
715
+    );
716
+    let stderr = String::from_utf8_lossy(&out.stderr);
717
+    assert!(
718
+        !stderr.contains("undefined symbol: _missing"),
719
+        "expected -undefined suppress to omit undefined diagnostic:\n{stderr}"
720
+    );
721
+    assert!(out_path.exists(), "expected linked output to be written");
722
+
723
+    let _ = fs::remove_file(obj);
724
+    let _ = fs::remove_file(out_path);
725
+}
726
+
727
+#[test]
728
+fn trace_flag_prints_loaded_inputs_and_archive_members() {
729
+    if !have_xcrun() {
730
+        eprintln!("skipping: xcrun as unavailable");
731
+        return;
732
+    }
733
+
734
+    let exe = env!("CARGO_BIN_EXE_afs-ld");
735
+    let main_obj = scratch("trace-main.o");
736
+    let helper_obj = scratch("trace-helper.o");
737
+    let archive_path = scratch("libtracehelpers.a");
738
+    let out_path = scratch("trace.out");
739
+    let main_src = r#"
740
+        .section __TEXT,__text,regular,pure_instructions
741
+        .globl _main
742
+        _main:
743
+            bl _helper
744
+            mov w0, #0
745
+            ret
746
+        .subsections_via_symbols
747
+    "#;
748
+    let helper_src = r#"
749
+        .section __TEXT,__text,regular,pure_instructions
750
+        .globl _helper
751
+        _helper:
752
+            ret
753
+        .subsections_via_symbols
754
+    "#;
755
+    if let Err(e) = assemble(main_src, &main_obj) {
756
+        eprintln!("skipping: assemble failed: {e}");
757
+        return;
758
+    }
759
+    if let Err(e) = assemble(helper_src, &helper_obj) {
760
+        eprintln!("skipping: assemble failed: {e}");
761
+        return;
762
+    }
763
+    if let Err(e) = archive(&[&helper_obj], &archive_path) {
764
+        eprintln!("skipping: archive failed: {e}");
765
+        let _ = fs::remove_file(main_obj);
766
+        let _ = fs::remove_file(helper_obj);
767
+        return;
768
+    }
769
+
770
+    let out = Command::new(exe)
771
+        .arg("-t")
772
+        .arg("-o")
773
+        .arg(&out_path)
774
+        .arg(&main_obj)
775
+        .arg(&archive_path)
776
+        .output()
777
+        .expect("afs-ld should run");
778
+    assert!(
779
+        out.status.success(),
780
+        "trace link should succeed:\nstderr:\n{}",
781
+        String::from_utf8_lossy(&out.stderr)
782
+    );
783
+    let stderr = String::from_utf8_lossy(&out.stderr);
784
+    assert!(
785
+        stderr.contains(&format!("afs-ld: loading {}", main_obj.display())),
786
+        "missing main object trace:\n{stderr}"
787
+    );
788
+    assert!(
789
+        stderr.contains(&format!("afs-ld: loading {}", archive_path.display())),
790
+        "missing archive trace:\n{stderr}"
791
+    );
792
+    assert!(
793
+        stderr.contains("libtracehelpers.a("),
794
+        "missing fetched archive member trace:\n{stderr}"
795
+    );
796
+
797
+    let _ = fs::remove_file(main_obj);
798
+    let _ = fs::remove_file(helper_obj);
799
+    let _ = fs::remove_file(archive_path);
800
+    let _ = fs::remove_file(out_path);
801
+}
802
+
803
+#[test]
804
+fn why_live_reports_root_entry_symbol() {
805
+    if !have_xcrun() {
806
+        eprintln!("skipping: xcrun as unavailable");
807
+        return;
808
+    }
809
+
810
+    let exe = env!("CARGO_BIN_EXE_afs-ld");
811
+    let main_obj = scratch("why-live-root-main.o");
812
+    let out_path = scratch("why-live-root.out");
813
+    let src = r#"
814
+        .section __TEXT,__text,regular,pure_instructions
815
+        .globl _main
816
+        _main:
817
+            mov w0, #0
818
+            ret
819
+        .subsections_via_symbols
820
+    "#;
821
+    if let Err(e) = assemble(src, &main_obj) {
822
+        eprintln!("skipping: assemble failed: {e}");
823
+        return;
824
+    }
825
+
826
+    let out = Command::new(exe)
827
+        .arg("-why_live")
828
+        .arg("_main")
829
+        .arg("-o")
830
+        .arg(&out_path)
831
+        .arg(&main_obj)
832
+        .output()
833
+        .expect("afs-ld should run");
834
+    assert!(
835
+        out.status.success(),
836
+        "why_live link should succeed:\nstderr:\n{}",
837
+        String::from_utf8_lossy(&out.stderr)
838
+    );
839
+    let stdout = String::from_utf8_lossy(&out.stdout);
840
+    assert!(stdout.contains("_main is live because:"));
841
+    assert!(stdout.contains("-dead_strip was not requested"));
842
+    assert!(stdout.contains("_main is in -e _main (GC root)"));
843
+
844
+    let _ = fs::remove_file(main_obj);
845
+    let _ = fs::remove_file(out_path);
846
+}
847
+
848
+#[test]
849
+fn why_live_reports_transitive_symbol_chain() {
850
+    if !have_xcrun() {
851
+        eprintln!("skipping: xcrun as unavailable");
852
+        return;
853
+    }
854
+
855
+    let exe = env!("CARGO_BIN_EXE_afs-ld");
856
+    let main_obj = scratch("why-live-main.o");
857
+    let helper_obj = scratch("why-live-helper.o");
858
+    let leaf_obj = scratch("why-live-leaf.o");
859
+    let out_path = scratch("why-live.out");
860
+    let main_src = r#"
861
+        .section __TEXT,__text,regular,pure_instructions
862
+        .globl _main
863
+        _main:
864
+            bl _helper
865
+            mov w0, #0
866
+            ret
867
+        .subsections_via_symbols
868
+    "#;
869
+    let helper_src = r#"
870
+        .section __TEXT,__text,regular,pure_instructions
871
+        .globl _helper
872
+        _helper:
873
+            bl _leaf
874
+            ret
875
+        .subsections_via_symbols
876
+    "#;
877
+    let leaf_src = r#"
878
+        .section __TEXT,__text,regular,pure_instructions
879
+        .globl _leaf
880
+        _leaf:
881
+            ret
882
+        .subsections_via_symbols
883
+    "#;
884
+    if let Err(e) = assemble(main_src, &main_obj) {
885
+        eprintln!("skipping: assemble failed: {e}");
886
+        return;
887
+    }
888
+    if let Err(e) = assemble(helper_src, &helper_obj) {
889
+        eprintln!("skipping: assemble failed: {e}");
890
+        let _ = fs::remove_file(main_obj);
891
+        return;
892
+    }
893
+    if let Err(e) = assemble(leaf_src, &leaf_obj) {
894
+        eprintln!("skipping: assemble failed: {e}");
895
+        let _ = fs::remove_file(main_obj);
896
+        let _ = fs::remove_file(helper_obj);
897
+        return;
898
+    }
899
+
900
+    let out = Command::new(exe)
901
+        .arg("-why_live")
902
+        .arg("_leaf")
903
+        .arg("-o")
904
+        .arg(&out_path)
905
+        .arg(&main_obj)
906
+        .arg(&helper_obj)
907
+        .arg(&leaf_obj)
908
+        .output()
909
+        .expect("afs-ld should run");
910
+    assert!(
911
+        out.status.success(),
912
+        "why_live link should succeed:\nstderr:\n{}",
913
+        String::from_utf8_lossy(&out.stderr)
914
+    );
915
+    let stdout = String::from_utf8_lossy(&out.stdout);
916
+    assert!(stdout.contains("_leaf is live because:"));
917
+    assert!(stdout.contains("-dead_strip was not requested"));
918
+    assert!(stdout.contains("_leaf is reachable from _helper"));
919
+    assert!(stdout.contains("_helper is reachable from _main"));
920
+    assert!(stdout.contains("_main is in -e _main (GC root)"));
921
+
922
+    let _ = fs::remove_file(main_obj);
923
+    let _ = fs::remove_file(helper_obj);
924
+    let _ = fs::remove_file(leaf_obj);
925
+    let _ = fs::remove_file(out_path);
926
+}
927
+
928
+#[test]
929
+fn why_live_reports_folded_symbol_winner_chain() {
930
+    if !have_xcrun() {
931
+        eprintln!("skipping: xcrun as unavailable");
932
+        return;
933
+    }
934
+
935
+    let exe = env!("CARGO_BIN_EXE_afs-ld");
936
+    let obj = scratch("why-live-folded.o");
937
+    let out_path = scratch("why-live-folded.out");
938
+    let src = r#"
939
+        .section __TEXT,__text,regular,pure_instructions
940
+        .globl _main
941
+        _main:
942
+            stp x29, x30, [sp, #-16]!
943
+            mov x29, sp
944
+            bl _helper1
945
+            bl _helper2
946
+            mov w0, #0
947
+            ldp x29, x30, [sp], #16
948
+            ret
949
+
950
+        .private_extern _helper1
951
+        _helper1:
952
+            mov w0, #0
953
+            ret
954
+
955
+        .private_extern _helper2
956
+        _helper2:
957
+            mov w0, #0
958
+            ret
959
+        .subsections_via_symbols
960
+    "#;
961
+    if let Err(e) = assemble(src, &obj) {
962
+        eprintln!("skipping: assemble failed: {e}");
963
+        return;
964
+    }
965
+
966
+    let out = Command::new(exe)
967
+        .arg("-icf=safe")
968
+        .arg("-why_live")
969
+        .arg("_helper2")
970
+        .arg("-o")
971
+        .arg(&out_path)
972
+        .arg(&obj)
973
+        .output()
974
+        .expect("afs-ld should run");
975
+    assert!(
976
+        out.status.success(),
977
+        "why_live folded-symbol link should succeed:\nstderr:\n{}",
978
+        String::from_utf8_lossy(&out.stderr)
979
+    );
980
+    let stdout = String::from_utf8_lossy(&out.stdout);
981
+    assert!(stdout.contains("_helper2 was folded to _helper1 by -icf=safe"));
982
+    assert!(stdout.contains("_helper1 is live because:"));
983
+    assert!(stdout.contains("_helper1 is reachable from _main"));
984
+
985
+    let _ = fs::remove_file(obj);
986
+    let _ = fs::remove_file(out_path);
987
+}
tests/common/harness.rsmodified
2621 lines changed — click to load
@@ -1,28 +1,139 @@
1
-//! Differential harness: compare afs-ld output against Apple `ld` output.
1
+//! Differential harness shared by parity-oriented integration tests.
22
 //!
3
-//! Sprint 0 lands the diffing surface. The `link_both` function that actually
4
-//! shells out to both linkers arrives once afs-ld can produce a real binary
5
-//! (Sprint 18). Until then, tests exercise `diff_macho` directly against
6
-//! synthesized byte slices.
3
+//! The early scaffold only diffed arbitrary byte slices. Sprint 27 starts
4
+//! turning it into a real Apple-`ld` matrix harness with a tiny corpus, basic
5
+//! tolerated-diff rules, and reusable link/runtime helpers.
76
 
87
 #![allow(dead_code)]
98
 
10
-use std::path::PathBuf;
9
+use std::collections::{BTreeMap, HashSet};
10
+use std::fs;
11
+use std::path::{Path, PathBuf};
12
+use std::process::{Command, Stdio};
13
+use std::thread;
14
+use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
1115
 
16
+use afs_ld::leb::{read_sleb, read_uleb};
17
+use afs_ld::macho::constants::{
18
+    BIND_IMMEDIATE_MASK, BIND_OPCODE_ADD_ADDR_ULEB, BIND_OPCODE_DONE, BIND_OPCODE_DO_BIND,
19
+    BIND_OPCODE_DO_BIND_ADD_ADDR_IMM_SCALED, BIND_OPCODE_DO_BIND_ADD_ADDR_ULEB,
20
+    BIND_OPCODE_DO_BIND_ULEB_TIMES_SKIPPING_ULEB, BIND_OPCODE_MASK, BIND_OPCODE_SET_ADDEND_SLEB,
21
+    BIND_OPCODE_SET_DYLIB_ORDINAL_IMM, BIND_OPCODE_SET_DYLIB_ORDINAL_ULEB,
22
+    BIND_OPCODE_SET_DYLIB_SPECIAL_IMM, BIND_OPCODE_SET_SEGMENT_AND_OFFSET_ULEB,
23
+    BIND_OPCODE_SET_SYMBOL_TRAILING_FLAGS_IMM, BIND_OPCODE_SET_TYPE_IMM,
24
+    BIND_SYMBOL_FLAGS_WEAK_IMPORT, BIND_TYPE_POINTER, INDIRECT_SYMBOL_ABS, INDIRECT_SYMBOL_LOCAL,
25
+    LC_BUILD_VERSION, LC_CODE_SIGNATURE, LC_DATA_IN_CODE, LC_DYLD_CHAINED_FIXUPS,
26
+    LC_DYLD_EXPORTS_TRIE, LC_DYLD_INFO_ONLY, LC_DYSYMTAB, LC_FUNCTION_STARTS, LC_ID_DYLIB,
27
+    LC_LOAD_DYLIB, LC_LOAD_UPWARD_DYLIB, LC_LOAD_WEAK_DYLIB, LC_REEXPORT_DYLIB, LC_SEGMENT_64,
28
+    LC_SYMTAB, LC_UUID, N_TYPE, N_UNDF,
29
+};
30
+use afs_ld::macho::dylib::DylibFile;
31
+use afs_ld::macho::exports::ExportKind;
32
+use afs_ld::macho::reader::{
33
+    parse_commands, parse_header, u32_le, BuildVersionCmd, DyldInfoCmd, LoadCommand,
34
+    Section64Header,
35
+};
36
+use afs_ld::string_table::StringTable;
37
+use afs_ld::symbol::{parse_nlist_table, SymKind};
38
+use afs_ld::synth::stubs::{STUB_HELPER_ENTRY_SIZE, STUB_HELPER_HEADER_SIZE};
39
+use afs_ld::synth::unwind::decode_unwind_info;
40
+
41
+#[derive(Debug, Clone)]
1242
 pub struct LinkCase {
13
-    pub name: &'static str,
43
+    pub name: String,
44
+    pub dir: PathBuf,
1445
     pub inputs: Vec<PathBuf>,
1546
     pub args: Vec<String>,
47
+    pub section_checks: Vec<(String, String)>,
48
+    pub absent_sections: Vec<(String, String)>,
49
+    pub page_ref_checks: Vec<PageRefCheck>,
50
+    pub command_checks: Vec<CommandCheck>,
51
+    artifacts: Vec<ArtifactSpec>,
52
+    pub ignored_load_commands: Vec<u32>,
53
+    pub absent_load_commands: Vec<u32>,
54
+    pub runtime_args: Vec<String>,
55
+    pub notes: Option<String>,
56
+    pub case_tolerances: Vec<CaseTolerance>,
57
+}
58
+
59
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60
+pub enum CommandCheck {
61
+    BuildVersion,
62
+    LoadDylibNames,
63
+    ExportRecords,
64
+    SymbolRecordMap,
65
+    IndirectSymbolIdentities,
66
+    SymbolPartitionNames,
67
+    StringTableNearParity,
68
+    FunctionStarts,
69
+    NormalizedFunctionStarts,
70
+    DataInCode,
71
+    DataInCodeIfPresent,
72
+    RebasedUnwindBytes,
73
+    DyldInfoRebase,
74
+    DyldInfoBind,
75
+    DyldInfoWeakBind,
76
+    DyldInfoLazyBind,
77
+}
78
+
79
+#[derive(Debug, Clone, PartialEq, Eq)]
80
+pub struct PageRefCheck {
81
+    pub segname: String,
82
+    pub sectname: String,
83
+    pub site_offset: u64,
84
+    pub kind: PageRefKind,
85
+    pub symbol: String,
86
+}
87
+
88
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89
+pub enum PageRefKind {
90
+    Add,
91
+    Load,
92
+}
93
+
94
+#[derive(Debug, Clone, PartialEq, Eq)]
95
+pub struct CaseTolerance {
96
+    pub region: ToleranceRegion,
97
+    pub reason: String,
98
+}
99
+
100
+#[derive(Debug, Clone, PartialEq, Eq)]
101
+pub enum ToleranceRegion {
102
+    SectionBytes {
103
+        segname: String,
104
+        sectname: Option<String>,
105
+        start: usize,
106
+        end_inclusive: usize,
107
+    },
16108
 }
17109
 
110
+#[derive(Debug, Clone, PartialEq, Eq)]
111
+struct ArtifactSpec {
112
+    src_name: String,
113
+    out_name: String,
114
+    kind: ArtifactKind,
115
+    dep_name: Option<String>,
116
+}
117
+
118
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119
+enum ArtifactKind {
120
+    Dylib,
121
+    Archive,
122
+    ReexportDylib,
123
+}
124
+
125
+type SymbolPartitions = (Vec<String>, Vec<String>, Vec<String>);
126
+
18127
 pub struct LinkOutputs {
19128
     pub ours: Vec<u8>,
20129
     pub theirs: Vec<u8>,
130
+    pub our_path: PathBuf,
131
+    pub their_path: PathBuf,
21132
 }
22133
 
23134
 #[derive(Debug, Clone, PartialEq, Eq)]
24135
 pub enum DiffCategory {
25
-    /// A diff we expect: UUID bytes, timestamps, hash-backed temp paths, etc.
136
+    /// A diff we expect: UUID bytes, code-signature hashes, etc.
26137
     Tolerated(&'static str),
27138
     /// Anything else. Fails the parity test.
28139
     Critical,
@@ -48,9 +159,902 @@ impl DiffReport {
48159
     }
49160
 }
50161
 
51
-/// Byte-level diff between two Mach-O images. Sprint 0 treats every byte diff
52
-/// as Critical; later sprints layer in the tolerated-diff predicates (UUID,
53
-/// timestamp, code-signature hashes, string-table suffix-dedup variance).
162
+#[derive(Debug, Clone, PartialEq, Eq)]
163
+pub struct ProgramOutput {
164
+    pub exit_code: Option<i32>,
165
+    pub stdout: Vec<u8>,
166
+    pub stderr: Vec<u8>,
167
+}
168
+
169
+type NormalizedBuildVersion = (u32, u32, u32, Vec<u32>);
170
+
171
+pub fn have_xcrun() -> bool {
172
+    Command::new("xcrun")
173
+        .arg("-f")
174
+        .arg("as")
175
+        .output()
176
+        .map(|o| o.status.success())
177
+        .unwrap_or(false)
178
+}
179
+
180
+pub fn have_xcrun_tool(tool: &str) -> bool {
181
+    Command::new("xcrun")
182
+        .arg("-f")
183
+        .arg(tool)
184
+        .output()
185
+        .map(|o| o.status.success())
186
+        .unwrap_or(false)
187
+}
188
+
189
+pub fn have_tool(tool: &str) -> bool {
190
+    Command::new(tool)
191
+        .arg("--version")
192
+        .output()
193
+        .map(|o| o.status.success() || !o.stderr.is_empty())
194
+        .unwrap_or(false)
195
+}
196
+
197
+pub fn sdk_path() -> Option<String> {
198
+    let out = Command::new("xcrun")
199
+        .args(["--sdk", "macosx", "--show-sdk-path"])
200
+        .output()
201
+        .ok()?;
202
+    if !out.status.success() {
203
+        return None;
204
+    }
205
+    Some(String::from_utf8_lossy(&out.stdout).trim().to_string())
206
+}
207
+
208
+pub fn sdk_version() -> Option<String> {
209
+    let out = Command::new("xcrun")
210
+        .args(["--sdk", "macosx", "--show-sdk-version"])
211
+        .output()
212
+        .ok()?;
213
+    if !out.status.success() {
214
+        return None;
215
+    }
216
+    Some(String::from_utf8_lossy(&out.stdout).trim().to_string())
217
+}
218
+
219
+pub fn scratch(name: &str) -> PathBuf {
220
+    std::env::temp_dir().join(format!("afs-ld-parity-{}-{name}", std::process::id()))
221
+}
222
+
223
+pub fn assemble(src: &str, out: &PathBuf) -> Result<(), String> {
224
+    let tmp = out.with_extension("s");
225
+    fs::write(&tmp, src).map_err(|e| format!("write {}: {e}", tmp.display()))?;
226
+    let output = Command::new("xcrun")
227
+        .args(["--sdk", "macosx", "as", "-arch", "arm64"])
228
+        .arg(&tmp)
229
+        .arg("-o")
230
+        .arg(out)
231
+        .output()
232
+        .map_err(|e| format!("spawn xcrun as: {e}"))?;
233
+    let _ = fs::remove_file(&tmp);
234
+    if !output.status.success() {
235
+        return Err(format!(
236
+            "xcrun as failed: {}",
237
+            String::from_utf8_lossy(&output.stderr)
238
+        ));
239
+    }
240
+    Ok(())
241
+}
242
+
243
+pub fn compile_c(src: &str, out: &PathBuf) -> Result<(), String> {
244
+    let tmp = out.with_extension("c");
245
+    fs::write(&tmp, src).map_err(|e| format!("write {}: {e}", tmp.display()))?;
246
+    let output = Command::new("xcrun")
247
+        .args(["--sdk", "macosx", "clang", "-arch", "arm64", "-c"])
248
+        .arg(&tmp)
249
+        .arg("-o")
250
+        .arg(out)
251
+        .output()
252
+        .map_err(|e| format!("spawn xcrun clang: {e}"))?;
253
+    let _ = fs::remove_file(&tmp);
254
+    if !output.status.success() {
255
+        return Err(format!(
256
+            "xcrun clang failed: {}",
257
+            String::from_utf8_lossy(&output.stderr)
258
+        ));
259
+    }
260
+    Ok(())
261
+}
262
+
263
+fn compile_dylib_c(src: &str, out: &PathBuf) -> Result<(), String> {
264
+    let tmp = out.with_extension("c");
265
+    fs::write(&tmp, src).map_err(|e| format!("write {}: {e}", tmp.display()))?;
266
+    let install_name = out.to_string_lossy().to_string();
267
+    let output = Command::new("xcrun")
268
+        .args(["--sdk", "macosx", "clang", "-arch", "arm64", "-dynamiclib"])
269
+        .arg(&tmp)
270
+        .arg(format!("-Wl,-install_name,{install_name}"))
271
+        .arg("-o")
272
+        .arg(out)
273
+        .output()
274
+        .map_err(|e| format!("spawn xcrun clang dylib: {e}"))?;
275
+    let _ = fs::remove_file(&tmp);
276
+    if !output.status.success() {
277
+        return Err(format!(
278
+            "xcrun clang dylib failed: {}",
279
+            String::from_utf8_lossy(&output.stderr)
280
+        ));
281
+    }
282
+    Ok(())
283
+}
284
+
285
+fn compile_archive_c(src: &str, out: &PathBuf) -> Result<(), String> {
286
+    let obj = out.with_extension("o");
287
+    compile_c(src, &obj)?;
288
+    let output = Command::new("libtool")
289
+        .args(["-static", "-o"])
290
+        .arg(out)
291
+        .arg(&obj)
292
+        .output()
293
+        .map_err(|e| format!("spawn libtool archive: {e}"))?;
294
+    let _ = fs::remove_file(&obj);
295
+    if !output.status.success() {
296
+        return Err(format!(
297
+            "libtool archive failed: {}",
298
+            String::from_utf8_lossy(&output.stderr)
299
+        ));
300
+    }
301
+    Ok(())
302
+}
303
+
304
+fn compile_reexport_dylib_c(src: &str, out: &PathBuf, dep: &Path) -> Result<(), String> {
305
+    let tmp = out.with_extension("c");
306
+    fs::write(&tmp, src).map_err(|e| format!("write {}: {e}", tmp.display()))?;
307
+    let install_name = out.to_string_lossy().to_string();
308
+    let output = Command::new("xcrun")
309
+        .args(["--sdk", "macosx", "clang", "-arch", "arm64", "-dynamiclib"])
310
+        .arg(&tmp)
311
+        .arg(format!("-Wl,-install_name,{install_name}"))
312
+        .arg(format!("-Wl,-reexport_library,{}", dep.display()))
313
+        .arg("-o")
314
+        .arg(out)
315
+        .output()
316
+        .map_err(|e| format!("spawn xcrun clang reexport dylib: {e}"))?;
317
+    let _ = fs::remove_file(&tmp);
318
+    if !output.status.success() {
319
+        return Err(format!(
320
+            "xcrun clang reexport dylib failed: {}",
321
+            String::from_utf8_lossy(&output.stderr)
322
+        ));
323
+    }
324
+    Ok(())
325
+}
326
+
327
+pub fn load_corpus(root: &Path) -> Result<Vec<LinkCase>, String> {
328
+    let mut cases = Vec::new();
329
+    let entries =
330
+        fs::read_dir(root).map_err(|e| format!("read parity corpus {}: {e}", root.display()))?;
331
+    for entry in entries {
332
+        let entry = entry.map_err(|e| format!("read parity corpus entry: {e}"))?;
333
+        let path = entry.path();
334
+        if !path.is_dir() {
335
+            continue;
336
+        }
337
+
338
+        let name = path
339
+            .file_name()
340
+            .and_then(|s| s.to_str())
341
+            .ok_or_else(|| format!("invalid UTF-8 case directory {}", path.display()))?
342
+            .to_string();
343
+        let inputs_dir = path.join("inputs");
344
+        let mut inputs = Vec::new();
345
+        let input_entries = fs::read_dir(&inputs_dir)
346
+            .map_err(|e| format!("read inputs for {}: {e}", path.display()))?;
347
+        for input in input_entries {
348
+            let input = input.map_err(|e| format!("read input entry for {}: {e}", name))?;
349
+            let input_path = input.path();
350
+            match input_path.extension().and_then(|s| s.to_str()) {
351
+                Some("s") | Some("c") | Some("o") | Some("a") | Some("tbd") => {
352
+                    inputs.push(input_path)
353
+                }
354
+                _ => {}
355
+            }
356
+        }
357
+        inputs.sort();
358
+        if inputs.is_empty() {
359
+            return Err(format!(
360
+                "parity corpus case {} has no supported source inputs",
361
+                path.display()
362
+            ));
363
+        }
364
+
365
+        let args = read_tokens(&path.join("args.txt"))?;
366
+        let section_checks = read_sections(&path.join("sections.txt"))?;
367
+        let absent_sections = read_sections_if_present(&path.join("absent_sections.txt"))?;
368
+        let page_ref_checks = read_page_refs(&path.join("page_refs.txt"))?;
369
+        let command_checks = read_command_checks(&path.join("command_checks.txt"))?;
370
+        let artifacts = read_artifacts(&path.join("artifacts.txt"))?;
371
+        let artifact_srcs: HashSet<&str> = artifacts
372
+            .iter()
373
+            .map(|artifact| artifact.src_name.as_str())
374
+            .collect();
375
+        inputs.retain(|input| {
376
+            input
377
+                .file_name()
378
+                .and_then(|s| s.to_str())
379
+                .map(|name| !artifact_srcs.contains(name))
380
+                .unwrap_or(true)
381
+        });
382
+        let ignored_load_commands =
383
+            read_load_command_names(&path.join("ignored_load_commands.txt"))?;
384
+        let absent_load_commands = read_load_command_names(&path.join("absent_load_commands.txt"))?;
385
+        let runtime_args = read_tokens_if_present(&path.join("runtime.txt"))?;
386
+        let notes = fs::read_to_string(path.join("notes.md")).ok();
387
+        let case_tolerances = parse_case_tolerances(notes.as_deref())?;
388
+
389
+        cases.push(LinkCase {
390
+            name,
391
+            dir: path,
392
+            inputs,
393
+            args,
394
+            section_checks,
395
+            absent_sections,
396
+            page_ref_checks,
397
+            command_checks,
398
+            artifacts,
399
+            ignored_load_commands,
400
+            absent_load_commands,
401
+            runtime_args,
402
+            notes,
403
+            case_tolerances,
404
+        });
405
+    }
406
+
407
+    cases.sort_by(|a, b| a.name.cmp(&b.name));
408
+    Ok(cases)
409
+}
410
+
411
+pub fn link_both(case: &LinkCase) -> Result<LinkOutputs, String> {
412
+    let sdk = sdk_path().ok_or_else(|| "xcrun --show-sdk-path unavailable".to_string())?;
413
+    let sdk_ver =
414
+        sdk_version().ok_or_else(|| "xcrun --show-sdk-version unavailable".to_string())?;
415
+    let work_dir = unique_temp_dir(&case.name)?;
416
+    let mut compiled: BTreeMap<String, PathBuf> = BTreeMap::new();
417
+    let mut sidecars: BTreeMap<String, PathBuf> = BTreeMap::new();
418
+    let mut artifacts: BTreeMap<String, PathBuf> = BTreeMap::new();
419
+    for input in &case.inputs {
420
+        let stem = input
421
+            .file_stem()
422
+            .and_then(|s| s.to_str())
423
+            .ok_or_else(|| format!("invalid input stem {}", input.display()))?;
424
+        match input.extension().and_then(|s| s.to_str()) {
425
+            Some("s") => {
426
+                let src = fs::read_to_string(input)
427
+                    .map_err(|e| format!("read parity input {}: {e}", input.display()))?;
428
+                let obj = work_dir.join(format!("{stem}.o"));
429
+                assemble(&src, &obj)?;
430
+                compiled.insert(format!("{stem}.o"), obj);
431
+            }
432
+            Some("c") => {
433
+                let src = fs::read_to_string(input)
434
+                    .map_err(|e| format!("read parity input {}: {e}", input.display()))?;
435
+                let obj = work_dir.join(format!("{stem}.o"));
436
+                compile_c(&src, &obj)?;
437
+                compiled.insert(format!("{stem}.o"), obj);
438
+            }
439
+            Some("o") | Some("a") | Some("tbd") => {
440
+                let copied = work_dir.join(
441
+                    input
442
+                        .file_name()
443
+                        .ok_or_else(|| format!("invalid input file name {}", input.display()))?,
444
+                );
445
+                fs::copy(input, &copied).map_err(|e| {
446
+                    format!(
447
+                        "copy parity input {} -> {}: {e}",
448
+                        input.display(),
449
+                        copied.display()
450
+                    )
451
+                })?;
452
+                compiled.insert(
453
+                    input
454
+                        .file_name()
455
+                        .and_then(|s| s.to_str())
456
+                        .ok_or_else(|| format!("invalid UTF-8 input file {}", input.display()))?
457
+                        .to_string(),
458
+                    copied,
459
+                );
460
+            }
461
+            other => {
462
+                return Err(format!(
463
+                    "unsupported parity input extension {:?} for {}",
464
+                    other,
465
+                    input.display()
466
+                ));
467
+            }
468
+        }
469
+    }
470
+    let files_dir = case.dir.join("files");
471
+    if files_dir.is_dir() {
472
+        for entry in fs::read_dir(&files_dir)
473
+            .map_err(|e| format!("read sidecar files for {}: {e}", case.name))?
474
+        {
475
+            let entry = entry.map_err(|e| format!("read sidecar entry for {}: {e}", case.name))?;
476
+            let src = entry.path();
477
+            if !src.is_file() {
478
+                continue;
479
+            }
480
+            let name = src
481
+                .file_name()
482
+                .and_then(|s| s.to_str())
483
+                .ok_or_else(|| format!("invalid sidecar file name {}", src.display()))?
484
+                .to_string();
485
+            let dst = work_dir.join(&name);
486
+            fs::copy(&src, &dst)
487
+                .map_err(|e| format!("copy sidecar {} -> {}: {e}", src.display(), dst.display()))?;
488
+            sidecars.insert(name, dst);
489
+        }
490
+    }
491
+    for artifact in &case.artifacts {
492
+        let src = case.dir.join("inputs").join(&artifact.src_name);
493
+        let src_contents = fs::read_to_string(&src)
494
+            .map_err(|e| format!("read artifact src {}: {e}", src.display()))?;
495
+        let out = work_dir.join(&artifact.out_name);
496
+        match artifact.kind {
497
+            ArtifactKind::Dylib => compile_dylib_c(&src_contents, &out)?,
498
+            ArtifactKind::Archive => compile_archive_c(&src_contents, &out)?,
499
+            ArtifactKind::ReexportDylib => {
500
+                let dep_name = artifact.dep_name.as_ref().ok_or_else(|| {
501
+                    format!(
502
+                        "missing reexport dependency for artifact {}",
503
+                        artifact.out_name
504
+                    )
505
+                })?;
506
+                let dep = artifacts
507
+                    .get(dep_name)
508
+                    .ok_or_else(|| format!("unknown reexport dependency `{dep_name}`"))?;
509
+                compile_reexport_dylib_c(&src_contents, &out, dep)?;
510
+            }
511
+        }
512
+        artifacts.insert(artifact.out_name.clone(), out);
513
+    }
514
+
515
+    let suffix = if case.args.iter().any(|arg| arg == "-dylib") {
516
+        "dylib"
517
+    } else {
518
+        "out"
519
+    };
520
+    let our_path = work_dir.join(format!("ours.{suffix}"));
521
+    let their_path = work_dir.join(format!("apple.{suffix}"));
522
+
523
+    let our_args = expand_args(
524
+        &case.args, &compiled, &sidecars, &artifacts, &our_path, &sdk, &sdk_ver,
525
+    )?;
526
+    let their_args = expand_args(
527
+        &case.args,
528
+        &compiled,
529
+        &sidecars,
530
+        &artifacts,
531
+        &their_path,
532
+        &sdk,
533
+        &sdk_ver,
534
+    )?;
535
+
536
+    let our_output = Command::new(env!("CARGO_BIN_EXE_afs-ld"))
537
+        .args(&our_args)
538
+        .output()
539
+        .map_err(|e| format!("spawn afs-ld: {e}"))?;
540
+    if !our_output.status.success() {
541
+        return Err(format!(
542
+            "afs-ld failed for {}:\n{}",
543
+            case.name,
544
+            String::from_utf8_lossy(&our_output.stderr)
545
+        ));
546
+    }
547
+
548
+    let their_output = Command::new("xcrun")
549
+        .arg("ld")
550
+        .args(&their_args)
551
+        .output()
552
+        .map_err(|e| format!("spawn xcrun ld: {e}"))?;
553
+    if !their_output.status.success() {
554
+        return Err(format!(
555
+            "Apple ld failed for {}:\n{}",
556
+            case.name,
557
+            String::from_utf8_lossy(&their_output.stderr)
558
+        ));
559
+    }
560
+
561
+    let ours = fs::read(&our_path)
562
+        .map_err(|e| format!("read afs-ld output {}: {e}", our_path.display()))?;
563
+    let theirs = fs::read(&their_path)
564
+        .map_err(|e| format!("read Apple ld output {}: {e}", their_path.display()))?;
565
+
566
+    Ok(LinkOutputs {
567
+        ours,
568
+        theirs,
569
+        our_path,
570
+        their_path,
571
+    })
572
+}
573
+
574
+pub fn command_ids(bytes: &[u8]) -> Result<Vec<u32>, String> {
575
+    let header = parse_header(bytes).map_err(|e| format!("parse header: {e}"))?;
576
+    let commands = parse_commands(&header, bytes).map_err(|e| format!("parse commands: {e}"))?;
577
+    Ok(commands
578
+        .into_iter()
579
+        .map(|cmd| match cmd {
580
+            LoadCommand::Segment64(_) => LC_SEGMENT_64,
581
+            LoadCommand::Symtab(_) => LC_SYMTAB,
582
+            LoadCommand::Dysymtab(_) => LC_DYSYMTAB,
583
+            LoadCommand::BuildVersion(_) => LC_BUILD_VERSION,
584
+            LoadCommand::DyldInfoOnly(_) => LC_DYLD_INFO_ONLY,
585
+            LoadCommand::DyldChainedFixups(_) => LC_DYLD_CHAINED_FIXUPS,
586
+            LoadCommand::DyldExportsTrie(_) => LC_DYLD_EXPORTS_TRIE,
587
+            LoadCommand::Dylib(d) => d.cmd,
588
+            LoadCommand::Raw { cmd, .. } => cmd,
589
+            other => panic!("unexpected load command in command_ids helper: {other:?}"),
590
+        })
591
+        .collect())
592
+}
593
+
594
+pub fn compare_command_ids(ours: &[u8], theirs: &[u8], ignored: &[u32]) -> Result<(), String> {
595
+    let our_ids: Vec<u32> = command_ids(ours)?
596
+        .into_iter()
597
+        .filter(|cmd| !ignored.contains(cmd))
598
+        .collect();
599
+    let their_ids: Vec<u32> = command_ids(theirs)?
600
+        .into_iter()
601
+        .filter(|cmd| !ignored.contains(cmd))
602
+        .collect();
603
+    if our_ids != their_ids {
604
+        return Err(format!(
605
+            "load-command ids differ:\nours:   {our_ids:#x?}\ntheirs: {their_ids:#x?}"
606
+        ));
607
+    }
608
+    Ok(())
609
+}
610
+
611
+pub fn compare_command_details(
612
+    ours: &[u8],
613
+    theirs: &[u8],
614
+    checks: &[CommandCheck],
615
+) -> Result<(), String> {
616
+    for check in checks {
617
+        match check {
618
+            CommandCheck::BuildVersion => {
619
+                let ours = normalized_build_version(ours)?;
620
+                let theirs = normalized_build_version(theirs)?;
621
+                if ours != theirs {
622
+                    return Err(format!(
623
+                        "LC_BUILD_VERSION diverged:\nours:   {ours:#?}\ntheirs: {theirs:#?}"
624
+                    ));
625
+                }
626
+            }
627
+            CommandCheck::LoadDylibNames => {
628
+                let ours = load_dylib_names(ours)?;
629
+                let theirs = load_dylib_names(theirs)?;
630
+                if ours != theirs {
631
+                    return Err(format!(
632
+                        "LC_LOAD_DYLIB names diverged:\nours:   {ours:#?}\ntheirs: {theirs:#?}"
633
+                    ));
634
+                }
635
+            }
636
+            CommandCheck::ExportRecords => {
637
+                let ours = canonical_export_records(ours)?;
638
+                let theirs = canonical_export_records(theirs)?;
639
+                if ours != theirs {
640
+                    return Err(format!(
641
+                        "canonical export records diverged:\nours:   {ours:#?}\ntheirs: {theirs:#?}"
642
+                    ));
643
+                }
644
+            }
645
+            CommandCheck::SymbolRecordMap => {
646
+                let ours = canonical_symbol_record_map(ours)?;
647
+                let theirs = canonical_symbol_record_map(theirs)?;
648
+                if ours != theirs {
649
+                    return Err(format!(
650
+                        "canonical symbol record map diverged:\nours:   {ours:#?}\ntheirs: {theirs:#?}"
651
+                    ));
652
+                }
653
+            }
654
+            CommandCheck::IndirectSymbolIdentities => {
655
+                let ours = indirect_symbol_identities(ours)?;
656
+                let theirs = indirect_symbol_identities(theirs)?;
657
+                if ours != theirs {
658
+                    return Err(format!(
659
+                        "indirect symbol identities diverged:\nours:   {ours:#?}\ntheirs: {theirs:#?}"
660
+                    ));
661
+                }
662
+            }
663
+            CommandCheck::SymbolPartitionNames => {
664
+                let ours = symbol_partition_names(ours)?;
665
+                let theirs = symbol_partition_names(theirs)?;
666
+                if ours != theirs {
667
+                    return Err(format!(
668
+                        "symbol partition names diverged:\nours:   {ours:#?}\ntheirs: {theirs:#?}"
669
+                    ));
670
+                }
671
+            }
672
+            CommandCheck::StringTableNearParity => {
673
+                let our_len = effective_string_table_len(ours)?;
674
+                let their_len = effective_string_table_len(theirs)?;
675
+                if !string_table_within_five_percent(our_len, their_len) {
676
+                    return Err(format!(
677
+                        "string table length drifted too far from Apple ld: ours={} theirs={}",
678
+                        our_len, their_len
679
+                    ));
680
+                }
681
+            }
682
+            CommandCheck::FunctionStarts => {
683
+                let ours = decode_function_starts(ours)?;
684
+                let theirs = decode_function_starts(theirs)?;
685
+                if ours != theirs {
686
+                    return Err(format!(
687
+                        "function starts diverged:\nours:   {ours:#?}\ntheirs: {theirs:#?}"
688
+                    ));
689
+                }
690
+            }
691
+            CommandCheck::NormalizedFunctionStarts => {
692
+                let ours = normalize_function_start_offsets(&decode_function_starts(ours)?);
693
+                let theirs = normalize_function_start_offsets(&decode_function_starts(theirs)?);
694
+                if ours != theirs {
695
+                    return Err(format!(
696
+                        "normalized function starts diverged:\nours:   {ours:#?}\ntheirs: {theirs:#?}"
697
+                    ));
698
+                }
699
+            }
700
+            CommandCheck::DataInCode => {
701
+                let ours = canonical_data_in_code(ours)?;
702
+                let theirs = canonical_data_in_code(theirs)?;
703
+                if ours != theirs {
704
+                    return Err(format!(
705
+                        "canonical data-in-code records diverged:\nours:   {ours:#?}\ntheirs: {theirs:#?}"
706
+                    ));
707
+                }
708
+            }
709
+            CommandCheck::DataInCodeIfPresent => {
710
+                let ours = canonical_data_in_code(ours)?;
711
+                let theirs = canonical_data_in_code(theirs)?;
712
+                if !ours.is_empty() && !theirs.is_empty() && ours != theirs {
713
+                    return Err(format!(
714
+                        "canonical data-in-code records diverged:\nours:   {ours:#?}\ntheirs: {theirs:#?}"
715
+                    ));
716
+                }
717
+            }
718
+            CommandCheck::RebasedUnwindBytes => {
719
+                let ours = rebased_unwind_bytes(ours)?;
720
+                let theirs = rebased_unwind_bytes(theirs)?;
721
+                if ours != theirs {
722
+                    return Err("rebased unwind bytes diverged".to_string());
723
+                }
724
+            }
725
+            CommandCheck::DyldInfoRebase => {
726
+                let ours = dyld_info_stream(ours, DyldInfoStreamKind::Rebase)?;
727
+                let theirs = dyld_info_stream(theirs, DyldInfoStreamKind::Rebase)?;
728
+                if ours != theirs {
729
+                    return Err("rebase stream diverged".to_string());
730
+                }
731
+            }
732
+            CommandCheck::DyldInfoBind => {
733
+                let ours = canonical_bind_records(ours, DyldInfoStreamKind::Bind)?;
734
+                let theirs = canonical_bind_records(theirs, DyldInfoStreamKind::Bind)?;
735
+                if ours != theirs {
736
+                    return Err(format!(
737
+                        "bind stream diverged:\nours:   {ours:#?}\ntheirs: {theirs:#?}"
738
+                    ));
739
+                }
740
+            }
741
+            CommandCheck::DyldInfoWeakBind => {
742
+                let ours = canonical_bind_records(ours, DyldInfoStreamKind::WeakBind)?;
743
+                let theirs = canonical_bind_records(theirs, DyldInfoStreamKind::WeakBind)?;
744
+                if ours != theirs {
745
+                    return Err(format!(
746
+                        "weak-bind stream diverged:\nours:   {ours:#?}\ntheirs: {theirs:#?}"
747
+                    ));
748
+                }
749
+            }
750
+            CommandCheck::DyldInfoLazyBind => {
751
+                let ours = canonical_bind_records(ours, DyldInfoStreamKind::LazyBind)?;
752
+                let theirs = canonical_bind_records(theirs, DyldInfoStreamKind::LazyBind)?;
753
+                if ours != theirs {
754
+                    return Err(format!(
755
+                        "lazy-bind stream diverged:\nours:   {ours:#?}\ntheirs: {theirs:#?}"
756
+                    ));
757
+                }
758
+            }
759
+        }
760
+    }
761
+    Ok(())
762
+}
763
+
764
+pub fn ensure_absent_load_commands(
765
+    bytes: &[u8],
766
+    commands: &[u32],
767
+    side: &str,
768
+) -> Result<(), String> {
769
+    let ids = command_ids(bytes)?;
770
+    for command in commands {
771
+        if ids.contains(command) {
772
+            return Err(format!(
773
+                "{side} unexpectedly emitted {}",
774
+                load_command_name(*command)
775
+            ));
776
+        }
777
+    }
778
+    Ok(())
779
+}
780
+
781
+pub fn ensure_absent_sections(
782
+    bytes: &[u8],
783
+    sections: &[(String, String)],
784
+    side: &str,
785
+) -> Result<(), String> {
786
+    for (segname, sectname) in sections {
787
+        if output_section(bytes, segname, sectname).is_some() {
788
+            return Err(format!(
789
+                "{side} unexpectedly emitted section {segname},{sectname}"
790
+            ));
791
+        }
792
+    }
793
+    Ok(())
794
+}
795
+
796
+pub fn output_section(bytes: &[u8], segname: &str, sectname: &str) -> Option<(u64, Vec<u8>)> {
797
+    let header = parse_header(bytes).ok()?;
798
+    let commands = parse_commands(&header, bytes).ok()?;
799
+    for cmd in commands {
800
+        if let LoadCommand::Segment64(seg) = cmd {
801
+            for section in seg.sections {
802
+                if section.segname_str() == segname && section.sectname_str() == sectname {
803
+                    let data = if section.offset == 0 {
804
+                        Vec::new()
805
+                    } else {
806
+                        let start = section.offset as usize;
807
+                        let end = start + section.size as usize;
808
+                        bytes.get(start..end)?.to_vec()
809
+                    };
810
+                    return Some((section.addr, data));
811
+                }
812
+            }
813
+        }
814
+    }
815
+    None
816
+}
817
+
818
+fn output_section_header(bytes: &[u8], segname: &str, sectname: &str) -> Option<Section64Header> {
819
+    let header = parse_header(bytes).ok()?;
820
+    let commands = parse_commands(&header, bytes).ok()?;
821
+    for cmd in commands {
822
+        if let LoadCommand::Segment64(seg) = cmd {
823
+            for section in seg.sections {
824
+                if section.segname_str() == segname && section.sectname_str() == sectname {
825
+                    return Some(section);
826
+                }
827
+            }
828
+        }
829
+    }
830
+    None
831
+}
832
+
833
+fn segment_vmaddr(bytes: &[u8], segname: &str) -> Option<u64> {
834
+    let header = parse_header(bytes).ok()?;
835
+    let commands = parse_commands(&header, bytes).ok()?;
836
+    for cmd in commands {
837
+        if let LoadCommand::Segment64(seg) = cmd {
838
+            if seg.segname_str() == segname {
839
+                return Some(seg.vmaddr);
840
+            }
841
+        }
842
+    }
843
+    None
844
+}
845
+
846
+pub fn compare_sections(
847
+    ours: &[u8],
848
+    theirs: &[u8],
849
+    sections: &[(String, String)],
850
+    case_tolerances: &[CaseTolerance],
851
+) -> Result<(), String> {
852
+    for (segname, sectname) in sections {
853
+        if segname == "__TEXT" && sectname == "__stubs" {
854
+            let ours = canonical_stub_targets(ours)?;
855
+            let theirs = canonical_stub_targets(theirs)?;
856
+            if ours != theirs {
857
+                return Err(format!(
858
+                    "canonical stub targets diverged:\nours:   {ours:#?}\ntheirs: {theirs:#?}"
859
+                ));
860
+            }
861
+            continue;
862
+        }
863
+        if segname == "__TEXT" && sectname == "__stub_helper" {
864
+            let ours = canonical_stub_helper(ours)?;
865
+            let theirs = canonical_stub_helper(theirs)?;
866
+            if ours != theirs {
867
+                return Err(format!(
868
+                    "canonical stub helper surface diverged:\nours:   {ours:#?}\ntheirs: {theirs:#?}"
869
+                ));
870
+            }
871
+            continue;
872
+        }
873
+        let (_, our_bytes) = output_section(ours, segname, sectname)
874
+            .ok_or_else(|| format!("missing section {segname},{sectname} in afs-ld output"))?;
875
+        let (_, their_bytes) = output_section(theirs, segname, sectname)
876
+            .ok_or_else(|| format!("missing section {segname},{sectname} in Apple output"))?;
877
+        let diff = apply_section_tolerances(
878
+            diff_macho(&our_bytes, &their_bytes),
879
+            segname,
880
+            sectname,
881
+            case_tolerances,
882
+        );
883
+        if !diff.is_clean() {
884
+            return Err(format!(
885
+                "section bytes differ for {segname},{sectname}: {:#?}",
886
+                diff.critical
887
+            ));
888
+        }
889
+    }
890
+    Ok(())
891
+}
892
+
893
+pub fn compare_page_refs(
894
+    ours: &[u8],
895
+    theirs: &[u8],
896
+    checks: &[PageRefCheck],
897
+) -> Result<(), String> {
898
+    if checks.is_empty() {
899
+        return Ok(());
900
+    }
901
+    let our_symbols = symbol_values(ours)?;
902
+    let their_symbols = symbol_values(theirs)?;
903
+    for check in checks {
904
+        let (our_addr, our_bytes) = output_section(ours, &check.segname, &check.sectname)
905
+            .ok_or_else(|| {
906
+                format!(
907
+                    "missing section {},{} in afs-ld output",
908
+                    check.segname, check.sectname
909
+                )
910
+            })?;
911
+        let (their_addr, their_bytes) = output_section(theirs, &check.segname, &check.sectname)
912
+            .ok_or_else(|| {
913
+                format!(
914
+                    "missing section {},{} in Apple output",
915
+                    check.segname, check.sectname
916
+                )
917
+            })?;
918
+        let our_target =
919
+            decode_page_reference(&our_bytes, our_addr, check.site_offset, check.kind)?;
920
+        let their_target =
921
+            decode_page_reference(&their_bytes, their_addr, check.site_offset, check.kind)?;
922
+        let expected_ours = resolve_page_ref_expectation(ours, &our_symbols, &check.symbol)?;
923
+        let expected_theirs = resolve_page_ref_expectation(theirs, &their_symbols, &check.symbol)?;
924
+        if our_target != expected_ours || their_target != expected_theirs {
925
+            return Err(format!(
926
+                "page ref {},{}+0x{:x} -> {} diverged: ours=0x{:x} expected=0x{:x}; theirs=0x{:x} expected=0x{:x}",
927
+                check.segname,
928
+                check.sectname,
929
+                check.site_offset,
930
+                check.symbol,
931
+                our_target,
932
+                expected_ours,
933
+                their_target,
934
+                expected_theirs,
935
+            ));
936
+        }
937
+    }
938
+    Ok(())
939
+}
940
+
941
+fn resolve_page_ref_expectation(
942
+    bytes: &[u8],
943
+    symbols: &BTreeMap<String, u64>,
944
+    reference: &str,
945
+) -> Result<u64, String> {
946
+    if let Some(spec) = reference.strip_prefix("@SECTION:") {
947
+        let (section_spec, addend) = if let Some((section_spec, addend)) = spec.rsplit_once('+') {
948
+            (section_spec, parse_u64(addend)?)
949
+        } else {
950
+            (spec, 0)
951
+        };
952
+        let (segname, sectname) = section_spec
953
+            .split_once(',')
954
+            .ok_or_else(|| format!("invalid @SECTION page-ref target `{reference}`"))?;
955
+        let (addr, data) = output_section(bytes, segname, sectname)
956
+            .ok_or_else(|| format!("missing section {segname},{sectname} in output"))?;
957
+        if addend > data.len() as u64 {
958
+            return Err(format!(
959
+                "@SECTION target `{reference}` exceeds section size {}",
960
+                data.len()
961
+            ));
962
+        }
963
+        return Ok(addr + addend);
964
+    }
965
+    symbols
966
+        .get(reference)
967
+        .copied()
968
+        .ok_or_else(|| format!("missing symbol {reference} in output"))
969
+}
970
+
971
+pub fn run_program(path: &Path, args: &[String]) -> Result<ProgramOutput, String> {
972
+    let runtime_timeout = runtime_timeout();
973
+
974
+    let mut child = Command::new(path)
975
+        .args(args)
976
+        .stdout(Stdio::piped())
977
+        .stderr(Stdio::piped())
978
+        .spawn()
979
+        .map_err(|e| format!("run {}: {e}", path.display()))?;
980
+    let started = Instant::now();
981
+    loop {
982
+        if child
983
+            .try_wait()
984
+            .map_err(|e| format!("wait for {}: {e}", path.display()))?
985
+            .is_some()
986
+        {
987
+            let output = child
988
+                .wait_with_output()
989
+                .map_err(|e| format!("collect output from {}: {e}", path.display()))?;
990
+            return Ok(ProgramOutput {
991
+                exit_code: output.status.code(),
992
+                stdout: output.stdout,
993
+                stderr: output.stderr,
994
+            });
995
+        }
996
+        if started.elapsed() >= runtime_timeout {
997
+            let _ = child.kill();
998
+            let output = child
999
+                .wait_with_output()
1000
+                .map_err(|e| format!("collect timed-out output from {}: {e}", path.display()))?;
1001
+            return Err(format!(
1002
+                "run {} timed out after {:?}: exit={:?} stdout={:?} stderr={:?}",
1003
+                path.display(),
1004
+                runtime_timeout,
1005
+                output.status.code(),
1006
+                String::from_utf8_lossy(&output.stdout),
1007
+                String::from_utf8_lossy(&output.stderr)
1008
+            ));
1009
+        }
1010
+        thread::sleep(Duration::from_millis(5));
1011
+    }
1012
+}
1013
+
1014
+pub fn compare_runtime(our_path: &Path, their_path: &Path, args: &[String]) -> Result<(), String> {
1015
+    let our_path = our_path.to_path_buf();
1016
+    let their_path = their_path.to_path_buf();
1017
+    let their_args = args.to_vec();
1018
+    let ours = thread::scope(|scope| {
1019
+        let theirs = scope.spawn(|| run_program(&their_path, &their_args));
1020
+        let ours = run_program(&our_path, args);
1021
+        let theirs = theirs
1022
+            .join()
1023
+            .map_err(|_| "Apple runtime worker panicked".to_string())?;
1024
+        Ok::<_, String>((ours, theirs))
1025
+    })?;
1026
+    let (ours, theirs) = ours;
1027
+    let ours = ours?;
1028
+    let theirs = theirs?;
1029
+    if ours != theirs {
1030
+        return Err(format!(
1031
+            "runtime differs:\nours: exit={:?} stdout={:?} stderr={:?}\ntheirs: exit={:?} stdout={:?} stderr={:?}",
1032
+            ours.exit_code,
1033
+            String::from_utf8_lossy(&ours.stdout),
1034
+            String::from_utf8_lossy(&ours.stderr),
1035
+            theirs.exit_code,
1036
+            String::from_utf8_lossy(&theirs.stdout),
1037
+            String::from_utf8_lossy(&theirs.stderr),
1038
+        ));
1039
+    }
1040
+    Ok(())
1041
+}
1042
+
1043
+fn runtime_timeout() -> Duration {
1044
+    const DEFAULT_RUNTIME_TIMEOUT_SECS: u64 = 120;
1045
+
1046
+    std::env::var("PARITY_RUNTIME_TIMEOUT_SECONDS")
1047
+        .ok()
1048
+        .and_then(|value| value.parse::<u64>().ok())
1049
+        .map(Duration::from_secs)
1050
+        .unwrap_or_else(|| Duration::from_secs(DEFAULT_RUNTIME_TIMEOUT_SECS))
1051
+}
1052
+
1053
+/// Byte-level diff between two Mach-O images or section byte slices.
1054
+///
1055
+/// Sprint 27 starts tolerating a very small allowlist: UUID bytes, dylib
1056
+/// timestamp fields, and code-signature command/blob bytes at matching
1057
+/// offsets. Unknown diffs remain critical.
541058
 pub fn diff_macho(ours: &[u8], theirs: &[u8]) -> DiffReport {
551059
     let mut report = DiffReport::default();
561060
 
@@ -68,29 +1072,1557 @@ pub fn diff_macho(ours: &[u8], theirs: &[u8]) -> DiffReport {
681072
         return report;
691073
     }
701074
 
1075
+    let our_mask = tolerated_mask(ours);
1076
+    let their_mask = tolerated_mask(theirs);
1077
+
711078
     let mut i = 0;
721079
     while i < ours.len() {
73
-        if ours[i] != theirs[i] {
74
-            let start = i;
75
-            while i < ours.len() && ours[i] != theirs[i] {
76
-                i += 1;
1080
+        if ours[i] == theirs[i] {
1081
+            i += 1;
1082
+            continue;
1083
+        }
1084
+
1085
+        let tolerated_reason = match (our_mask[i], their_mask[i]) {
1086
+            (Some(left), Some(right)) if left == right => Some(left),
1087
+            _ => None,
1088
+        };
1089
+        let start = i;
1090
+        i += 1;
1091
+        while i < ours.len() && ours[i] != theirs[i] {
1092
+            let same_category = match tolerated_reason {
1093
+                Some(reason) => matches!(
1094
+                    (our_mask[i], their_mask[i]),
1095
+                    (Some(left), Some(right)) if left == reason && right == reason
1096
+                ),
1097
+                None => !matches!(
1098
+                    (our_mask[i], their_mask[i]),
1099
+                    (Some(left), Some(right)) if left == right
1100
+                ),
1101
+            };
1102
+            if !same_category {
1103
+                break;
771104
             }
1105
+            i += 1;
1106
+        }
1107
+
1108
+        let len = i - start;
1109
+        if let Some(reason) = tolerated_reason {
1110
+            report.tolerated.push(DiffChunk {
1111
+                offset: start,
1112
+                len,
1113
+                reason: reason.to_string(),
1114
+                category: DiffCategory::Tolerated(reason),
1115
+            });
1116
+        } else {
781117
             report.critical.push(DiffChunk {
791118
                 offset: start,
80
-                len: i - start,
81
-                reason: format!("{} byte(s) differ starting at 0x{start:x}", i - start),
1119
+                len,
1120
+                reason: format!("{} byte(s) differ starting at 0x{start:x}", len),
821121
                 category: DiffCategory::Critical,
831122
             });
84
-        } else {
85
-            i += 1;
861123
         }
871124
     }
881125
 
891126
     report
901127
 }
911128
 
92
-/// Placeholder for the full linker-spawning contract. Sprint 18 wires this to
93
-/// real invocations of afs-ld and the system `ld` via `xcrun -f ld`.
94
-pub fn link_both(_case: &LinkCase) -> LinkOutputs {
95
-    panic!("link_both is not implemented until Sprint 18 (hello-world milestone)");
1129
+pub fn parse_case_tolerances(notes: Option<&str>) -> Result<Vec<CaseTolerance>, String> {
1130
+    let Some(notes) = notes else {
1131
+        return Ok(Vec::new());
1132
+    };
1133
+
1134
+    let mut tolerances = Vec::new();
1135
+    let mut in_block = false;
1136
+    for raw_line in notes.lines() {
1137
+        let line = raw_line.trim();
1138
+        if line.is_empty() {
1139
+            continue;
1140
+        }
1141
+        if line == "tolerated:" {
1142
+            in_block = true;
1143
+            continue;
1144
+        }
1145
+        if !in_block {
1146
+            continue;
1147
+        }
1148
+        if !line.starts_with("- region:") {
1149
+            // Stop once the simple tolerated block ends.
1150
+            if !line.starts_with('#') && !raw_line.starts_with(' ') && !raw_line.starts_with('\t') {
1151
+                break;
1152
+            }
1153
+            continue;
1154
+        }
1155
+        tolerances.push(parse_case_tolerance_line(line)?);
1156
+    }
1157
+    Ok(tolerances)
1158
+}
1159
+
1160
+pub fn apply_section_tolerances(
1161
+    mut diff: DiffReport,
1162
+    segname: &str,
1163
+    sectname: &str,
1164
+    case_tolerances: &[CaseTolerance],
1165
+) -> DiffReport {
1166
+    if diff.critical.is_empty() || case_tolerances.is_empty() {
1167
+        return diff;
1168
+    }
1169
+
1170
+    let mut remaining = Vec::new();
1171
+    for chunk in diff.critical.drain(..) {
1172
+        let tolerated = case_tolerances
1173
+            .iter()
1174
+            .find(|tol| tolerance_covers_chunk(tol, segname, sectname, chunk.offset, chunk.len));
1175
+        if let Some(tol) = tolerated {
1176
+            diff.tolerated.push(DiffChunk {
1177
+                offset: chunk.offset,
1178
+                len: chunk.len,
1179
+                reason: tol.reason.clone(),
1180
+                category: DiffCategory::Tolerated("case-note"),
1181
+            });
1182
+        } else {
1183
+            remaining.push(chunk);
1184
+        }
1185
+    }
1186
+    diff.critical = remaining;
1187
+    diff
1188
+}
1189
+
1190
+fn unique_temp_dir(case_name: &str) -> Result<PathBuf, String> {
1191
+    let stamp = SystemTime::now()
1192
+        .duration_since(UNIX_EPOCH)
1193
+        .map_err(|e| format!("clock error: {e}"))?
1194
+        .as_nanos();
1195
+    let safe_name = case_name.replace(['/', ' '], "-");
1196
+    let dir = std::env::temp_dir().join(format!(
1197
+        "afs-ld-parity-{}-{safe_name}-{stamp}",
1198
+        std::process::id()
1199
+    ));
1200
+    fs::create_dir_all(&dir).map_err(|e| format!("create {}: {e}", dir.display()))?;
1201
+    Ok(dir)
1202
+}
1203
+
1204
+fn parse_case_tolerance_line(line: &str) -> Result<CaseTolerance, String> {
1205
+    let rest = line
1206
+        .strip_prefix("- region:")
1207
+        .ok_or_else(|| format!("invalid tolerance line `{line}`"))?
1208
+        .trim();
1209
+    let (before_reason, reason_part) = rest
1210
+        .split_once(" reason:")
1211
+        .ok_or_else(|| format!("missing `reason:` in tolerance line `{line}`"))?;
1212
+    let (region_part, bytes_part) = before_reason
1213
+        .split_once(" bytes ")
1214
+        .ok_or_else(|| format!("missing `bytes` range in tolerance line `{line}`"))?;
1215
+    let reason = reason_part.trim().trim_matches('"').to_string();
1216
+    if reason.is_empty() {
1217
+        return Err(format!("empty tolerance reason in `{line}`"));
1218
+    }
1219
+    let (start, end_inclusive) = parse_tolerance_range(bytes_part.trim())?;
1220
+    let region_token = region_part.trim();
1221
+    let (segname, sectname) = match region_token.split_once(',') {
1222
+        Some((segname, sectname)) => (
1223
+            segname.trim().to_string(),
1224
+            Some(sectname.trim().to_string()),
1225
+        ),
1226
+        None => (region_token.to_string(), None),
1227
+    };
1228
+    if segname.is_empty() {
1229
+        return Err(format!("empty tolerance region in `{line}`"));
1230
+    }
1231
+    Ok(CaseTolerance {
1232
+        region: ToleranceRegion::SectionBytes {
1233
+            segname,
1234
+            sectname,
1235
+            start,
1236
+            end_inclusive,
1237
+        },
1238
+        reason,
1239
+    })
1240
+}
1241
+
1242
+fn parse_tolerance_range(range: &str) -> Result<(usize, usize), String> {
1243
+    let (start, end) = range
1244
+        .split_once('-')
1245
+        .ok_or_else(|| format!("invalid tolerance range `{range}`"))?;
1246
+    let start = parse_usize(start.trim())?;
1247
+    let end = parse_usize(end.trim())?;
1248
+    if end < start {
1249
+        return Err(format!("tolerance range end before start in `{range}`"));
1250
+    }
1251
+    Ok((start, end))
1252
+}
1253
+
1254
+fn parse_usize(token: &str) -> Result<usize, String> {
1255
+    if let Some(rest) = token.strip_prefix("0x") {
1256
+        usize::from_str_radix(rest, 16).map_err(|e| format!("parse usize `{token}`: {e}"))
1257
+    } else {
1258
+        token
1259
+            .parse::<usize>()
1260
+            .map_err(|e| format!("parse usize `{token}`: {e}"))
1261
+    }
1262
+}
1263
+
1264
+fn tolerance_covers_chunk(
1265
+    tolerance: &CaseTolerance,
1266
+    segname: &str,
1267
+    sectname: &str,
1268
+    offset: usize,
1269
+    len: usize,
1270
+) -> bool {
1271
+    match &tolerance.region {
1272
+        ToleranceRegion::SectionBytes {
1273
+            segname: expected_seg,
1274
+            sectname: expected_sect,
1275
+            start,
1276
+            end_inclusive,
1277
+        } => {
1278
+            if expected_seg != segname {
1279
+                return false;
1280
+            }
1281
+            if let Some(expected_sect) = expected_sect {
1282
+                if expected_sect != sectname {
1283
+                    return false;
1284
+                }
1285
+            }
1286
+            let end = offset.saturating_add(len.saturating_sub(1));
1287
+            offset >= *start && end <= *end_inclusive
1288
+        }
1289
+    }
1290
+}
1291
+
1292
+fn read_tokens(path: &Path) -> Result<Vec<String>, String> {
1293
+    let contents = fs::read_to_string(path).map_err(|e| format!("read {}: {e}", path.display()))?;
1294
+    Ok(contents
1295
+        .lines()
1296
+        .map(str::trim)
1297
+        .filter(|line| !line.is_empty() && !line.starts_with('#'))
1298
+        .map(ToOwned::to_owned)
1299
+        .collect())
1300
+}
1301
+
1302
+fn read_tokens_if_present(path: &Path) -> Result<Vec<String>, String> {
1303
+    if path.exists() {
1304
+        read_tokens(path)
1305
+    } else {
1306
+        Ok(Vec::new())
1307
+    }
1308
+}
1309
+
1310
+fn read_sections(path: &Path) -> Result<Vec<(String, String)>, String> {
1311
+    let mut sections = Vec::new();
1312
+    for line in read_tokens(path)? {
1313
+        let mut parts = line.split_whitespace();
1314
+        let segname = parts
1315
+            .next()
1316
+            .ok_or_else(|| format!("missing segment name in {}", path.display()))?;
1317
+        let sectname = parts
1318
+            .next()
1319
+            .ok_or_else(|| format!("missing section name in {}", path.display()))?;
1320
+        if parts.next().is_some() {
1321
+            return Err(format!(
1322
+                "too many fields in section spec `{line}` from {}",
1323
+                path.display()
1324
+            ));
1325
+        }
1326
+        sections.push((segname.to_string(), sectname.to_string()));
1327
+    }
1328
+    Ok(sections)
1329
+}
1330
+
1331
+fn read_sections_if_present(path: &Path) -> Result<Vec<(String, String)>, String> {
1332
+    if path.exists() {
1333
+        read_sections(path)
1334
+    } else {
1335
+        Ok(Vec::new())
1336
+    }
1337
+}
1338
+
1339
+fn read_load_command_names(path: &Path) -> Result<Vec<u32>, String> {
1340
+    if !path.exists() {
1341
+        return Ok(Vec::new());
1342
+    }
1343
+    let mut commands = Vec::new();
1344
+    for line in read_tokens(path)? {
1345
+        commands.push(parse_load_command_name(&line)?);
1346
+    }
1347
+    Ok(commands)
1348
+}
1349
+
1350
+fn read_command_checks(path: &Path) -> Result<Vec<CommandCheck>, String> {
1351
+    if !path.exists() {
1352
+        return Ok(Vec::new());
1353
+    }
1354
+    let mut checks = Vec::new();
1355
+    for line in read_tokens(path)? {
1356
+        checks.push(parse_command_check(&line)?);
1357
+    }
1358
+    Ok(checks)
1359
+}
1360
+
1361
+fn read_page_refs(path: &Path) -> Result<Vec<PageRefCheck>, String> {
1362
+    if !path.exists() {
1363
+        return Ok(Vec::new());
1364
+    }
1365
+    let mut checks = Vec::new();
1366
+    for line in read_tokens(path)? {
1367
+        let mut parts = line.split_whitespace();
1368
+        let segname = parts
1369
+            .next()
1370
+            .ok_or_else(|| format!("missing segment name in {}", path.display()))?;
1371
+        let sectname = parts
1372
+            .next()
1373
+            .ok_or_else(|| format!("missing section name in {}", path.display()))?;
1374
+        let site_offset = parts
1375
+            .next()
1376
+            .ok_or_else(|| format!("missing site offset in {}", path.display()))?;
1377
+        let kind = parts
1378
+            .next()
1379
+            .ok_or_else(|| format!("missing page-ref kind in {}", path.display()))?;
1380
+        let symbol = parts
1381
+            .next()
1382
+            .ok_or_else(|| format!("missing symbol name in {}", path.display()))?;
1383
+        if parts.next().is_some() {
1384
+            return Err(format!(
1385
+                "too many fields in page-ref spec `{line}` from {}",
1386
+                path.display()
1387
+            ));
1388
+        }
1389
+        checks.push(PageRefCheck {
1390
+            segname: segname.to_string(),
1391
+            sectname: sectname.to_string(),
1392
+            site_offset: parse_u64(site_offset)?,
1393
+            kind: parse_page_ref_kind(kind)?,
1394
+            symbol: symbol.to_string(),
1395
+        });
1396
+    }
1397
+    Ok(checks)
1398
+}
1399
+
1400
+fn read_artifacts(path: &Path) -> Result<Vec<ArtifactSpec>, String> {
1401
+    if !path.exists() {
1402
+        return Ok(Vec::new());
1403
+    }
1404
+    let mut specs = Vec::new();
1405
+    for line in read_tokens(path)? {
1406
+        let mut parts = line.split_whitespace();
1407
+        let kind = parts
1408
+            .next()
1409
+            .ok_or_else(|| format!("missing artifact kind in {}", path.display()))?;
1410
+        let src_name = parts
1411
+            .next()
1412
+            .ok_or_else(|| format!("missing artifact src in {}", path.display()))?;
1413
+        let out_name = parts
1414
+            .next()
1415
+            .ok_or_else(|| format!("missing artifact output in {}", path.display()))?;
1416
+        let dep_name = parts.next().map(str::to_string);
1417
+        if parts.next().is_some() {
1418
+            return Err(format!(
1419
+                "too many fields in artifact spec `{line}` from {}",
1420
+                path.display()
1421
+            ));
1422
+        }
1423
+        let (kind, dep_name) = match kind {
1424
+            "clang_dylib" => {
1425
+                if dep_name.is_some() {
1426
+                    return Err(format!(
1427
+                        "clang_dylib takes exactly 3 fields in {}",
1428
+                        path.display()
1429
+                    ));
1430
+                }
1431
+                (ArtifactKind::Dylib, None)
1432
+            }
1433
+            "clang_archive" => {
1434
+                if dep_name.is_some() {
1435
+                    return Err(format!(
1436
+                        "clang_archive takes exactly 3 fields in {}",
1437
+                        path.display()
1438
+                    ));
1439
+                }
1440
+                (ArtifactKind::Archive, None)
1441
+            }
1442
+            "clang_reexport_dylib" => {
1443
+                let dep_name = dep_name.ok_or_else(|| {
1444
+                    format!(
1445
+                        "clang_reexport_dylib needs a dependency artifact in {}",
1446
+                        path.display()
1447
+                    )
1448
+                })?;
1449
+                (ArtifactKind::ReexportDylib, Some(dep_name))
1450
+            }
1451
+            other => return Err(format!("unknown artifact kind `{other}`")),
1452
+        };
1453
+        specs.push(ArtifactSpec {
1454
+            src_name: src_name.to_string(),
1455
+            out_name: out_name.to_string(),
1456
+            kind,
1457
+            dep_name,
1458
+        });
1459
+    }
1460
+    Ok(specs)
1461
+}
1462
+
1463
+fn parse_command_check(name: &str) -> Result<CommandCheck, String> {
1464
+    match name {
1465
+        "build_version" => Ok(CommandCheck::BuildVersion),
1466
+        "load_dylib_names" => Ok(CommandCheck::LoadDylibNames),
1467
+        "export_records" => Ok(CommandCheck::ExportRecords),
1468
+        "symbol_record_map" => Ok(CommandCheck::SymbolRecordMap),
1469
+        "indirect_symbol_identities" => Ok(CommandCheck::IndirectSymbolIdentities),
1470
+        "symbol_partition_names" => Ok(CommandCheck::SymbolPartitionNames),
1471
+        "string_table_near_parity" => Ok(CommandCheck::StringTableNearParity),
1472
+        "function_starts" => Ok(CommandCheck::FunctionStarts),
1473
+        "normalized_function_starts" => Ok(CommandCheck::NormalizedFunctionStarts),
1474
+        "data_in_code" => Ok(CommandCheck::DataInCode),
1475
+        "data_in_code_if_present" => Ok(CommandCheck::DataInCodeIfPresent),
1476
+        "rebased_unwind_bytes" => Ok(CommandCheck::RebasedUnwindBytes),
1477
+        "dyld_info_rebase" => Ok(CommandCheck::DyldInfoRebase),
1478
+        "dyld_info_bind" => Ok(CommandCheck::DyldInfoBind),
1479
+        "dyld_info_weak_bind" => Ok(CommandCheck::DyldInfoWeakBind),
1480
+        "dyld_info_lazy_bind" => Ok(CommandCheck::DyldInfoLazyBind),
1481
+        other => Err(format!("unknown command check `{other}`")),
1482
+    }
1483
+}
1484
+
1485
+fn parse_page_ref_kind(kind: &str) -> Result<PageRefKind, String> {
1486
+    match kind {
1487
+        "add" => Ok(PageRefKind::Add),
1488
+        "load" => Ok(PageRefKind::Load),
1489
+        other => Err(format!("unknown page-ref kind `{other}`")),
1490
+    }
1491
+}
1492
+
1493
+fn parse_load_command_name(name: &str) -> Result<u32, String> {
1494
+    match name {
1495
+        "LC_SEGMENT_64" => Ok(LC_SEGMENT_64),
1496
+        "LC_LOAD_DYLIB" => Ok(LC_LOAD_DYLIB),
1497
+        "LC_UUID" => Ok(LC_UUID),
1498
+        "LC_CODE_SIGNATURE" => Ok(LC_CODE_SIGNATURE),
1499
+        "LC_LINKER_OPTIMIZATION_HINT" => Ok(afs_ld::macho::constants::LC_LINKER_OPTIMIZATION_HINT),
1500
+        other => Err(format!("unknown load command name `{other}`")),
1501
+    }
1502
+}
1503
+
1504
+fn load_command_name(cmd: u32) -> &'static str {
1505
+    match cmd {
1506
+        LC_SEGMENT_64 => "LC_SEGMENT_64",
1507
+        LC_LOAD_DYLIB => "LC_LOAD_DYLIB",
1508
+        LC_UUID => "LC_UUID",
1509
+        LC_CODE_SIGNATURE => "LC_CODE_SIGNATURE",
1510
+        afs_ld::macho::constants::LC_LINKER_OPTIMIZATION_HINT => "LC_LINKER_OPTIMIZATION_HINT",
1511
+        _ => "unknown load command",
1512
+    }
1513
+}
1514
+
1515
+fn parse_u64(value: &str) -> Result<u64, String> {
1516
+    if let Some(hex) = value.strip_prefix("0x") {
1517
+        u64::from_str_radix(hex, 16).map_err(|e| format!("parse hex `{value}`: {e}"))
1518
+    } else {
1519
+        value
1520
+            .parse::<u64>()
1521
+            .map_err(|e| format!("parse integer `{value}`: {e}"))
1522
+    }
1523
+}
1524
+
1525
+fn expand_args(
1526
+    args: &[String],
1527
+    compiled: &BTreeMap<String, PathBuf>,
1528
+    sidecars: &BTreeMap<String, PathBuf>,
1529
+    artifacts: &BTreeMap<String, PathBuf>,
1530
+    out: &Path,
1531
+    sdk: &str,
1532
+    sdk_ver: &str,
1533
+) -> Result<Vec<String>, String> {
1534
+    let mut expanded = Vec::with_capacity(args.len());
1535
+    for arg in args {
1536
+        if arg == "@OUT@" {
1537
+            expanded.push(out.to_string_lossy().to_string());
1538
+            continue;
1539
+        }
1540
+        if arg == "@SDK_PATH@" {
1541
+            expanded.push(sdk.to_string());
1542
+            continue;
1543
+        }
1544
+        if arg == "@SDK_VERSION@" {
1545
+            expanded.push(sdk_ver.to_string());
1546
+            continue;
1547
+        }
1548
+        if let Some(rel) = arg
1549
+            .strip_prefix("@SDK_TBD:")
1550
+            .and_then(|rest| rest.strip_suffix('@'))
1551
+        {
1552
+            expanded.push(Path::new(sdk).join(rel).to_string_lossy().to_string());
1553
+            continue;
1554
+        }
1555
+        if let Some(name) = arg
1556
+            .strip_prefix("@INPUT:")
1557
+            .and_then(|rest| rest.strip_suffix('@'))
1558
+        {
1559
+            let input = compiled
1560
+                .get(name)
1561
+                .ok_or_else(|| format!("unknown parity input placeholder `{name}`"))?;
1562
+            expanded.push(input.to_string_lossy().to_string());
1563
+            continue;
1564
+        }
1565
+        if let Some(name) = arg
1566
+            .strip_prefix("@FILE:")
1567
+            .and_then(|rest| rest.strip_suffix('@'))
1568
+        {
1569
+            let file = sidecars
1570
+                .get(name)
1571
+                .ok_or_else(|| format!("unknown parity sidecar placeholder `{name}`"))?;
1572
+            expanded.push(file.to_string_lossy().to_string());
1573
+            continue;
1574
+        }
1575
+        if let Some(name) = arg
1576
+            .strip_prefix("@ARTIFACT:")
1577
+            .and_then(|rest| rest.strip_suffix('@'))
1578
+        {
1579
+            let artifact = artifacts
1580
+                .get(name)
1581
+                .ok_or_else(|| format!("unknown parity artifact placeholder `{name}`"))?;
1582
+            expanded.push(artifact.to_string_lossy().to_string());
1583
+            continue;
1584
+        }
1585
+        expanded.push(arg.clone());
1586
+    }
1587
+    Ok(expanded)
1588
+}
1589
+
1590
+fn tolerated_mask(bytes: &[u8]) -> Vec<Option<&'static str>> {
1591
+    let mut mask = vec![None; bytes.len()];
1592
+    let Ok(header) = parse_header(bytes) else {
1593
+        return mask;
1594
+    };
1595
+    let cmd_base = 32usize;
1596
+    let Ok(cmd_limit) = cmd_base.checked_add(header.sizeofcmds as usize).ok_or(()) else {
1597
+        return mask;
1598
+    };
1599
+    if cmd_limit > bytes.len() {
1600
+        return mask;
1601
+    }
1602
+
1603
+    let mut cursor = cmd_base;
1604
+    for _ in 0..header.ncmds {
1605
+        if cursor + 8 > cmd_limit {
1606
+            break;
1607
+        }
1608
+        let cmd = u32_le(&bytes[cursor..cursor + 4]);
1609
+        let cmdsize = u32_le(&bytes[cursor + 4..cursor + 8]) as usize;
1610
+        if cmdsize < 8 || cursor + cmdsize > cmd_limit {
1611
+            break;
1612
+        }
1613
+        match cmd {
1614
+            LC_UUID => mark_range(&mut mask, cursor, cursor + cmdsize, "UUID bytes"),
1615
+            LC_CODE_SIGNATURE => {
1616
+                mark_range(
1617
+                    &mut mask,
1618
+                    cursor,
1619
+                    cursor + cmdsize,
1620
+                    "code-signature load command",
1621
+                );
1622
+                if cmdsize >= 16 {
1623
+                    let dataoff = u32_le(&bytes[cursor + 8..cursor + 12]) as usize;
1624
+                    let datasize = u32_le(&bytes[cursor + 12..cursor + 16]) as usize;
1625
+                    if let Some(end) = dataoff.checked_add(datasize) {
1626
+                        if end <= bytes.len() {
1627
+                            mark_range(&mut mask, dataoff, end, "code-signature hashes");
1628
+                        }
1629
+                    }
1630
+                }
1631
+            }
1632
+            LC_ID_DYLIB | LC_LOAD_DYLIB | LC_LOAD_WEAK_DYLIB | LC_REEXPORT_DYLIB
1633
+            | LC_LOAD_UPWARD_DYLIB
1634
+                if cmdsize >= 16 =>
1635
+            {
1636
+                mark_range(&mut mask, cursor + 12, cursor + 16, "dylib timestamp");
1637
+            }
1638
+            _ => {}
1639
+        }
1640
+        cursor += cmdsize;
1641
+    }
1642
+
1643
+    mask
1644
+}
1645
+
1646
+fn mark_range(mask: &mut [Option<&'static str>], start: usize, end: usize, reason: &'static str) {
1647
+    let start = start.min(mask.len());
1648
+    let end = end.min(mask.len());
1649
+    for slot in &mut mask[start..end] {
1650
+        *slot = Some(reason);
1651
+    }
1652
+}
1653
+
1654
+fn build_version_command(bytes: &[u8]) -> Result<Option<BuildVersionCmd>, String> {
1655
+    let header = parse_header(bytes).map_err(|e| e.to_string())?;
1656
+    let commands = parse_commands(&header, bytes).map_err(|e| e.to_string())?;
1657
+    Ok(commands.into_iter().find_map(|cmd| match cmd {
1658
+        LoadCommand::BuildVersion(cmd) => Some(cmd),
1659
+        _ => None,
1660
+    }))
1661
+}
1662
+
1663
+fn normalized_build_version(bytes: &[u8]) -> Result<Option<NormalizedBuildVersion>, String> {
1664
+    Ok(build_version_command(bytes)?.map(|cmd| {
1665
+        (
1666
+            cmd.platform,
1667
+            cmd.minos,
1668
+            cmd.sdk,
1669
+            cmd.tools.into_iter().map(|tool| tool.tool).collect(),
1670
+        )
1671
+    }))
1672
+}
1673
+
1674
+fn load_dylib_names(bytes: &[u8]) -> Result<Vec<String>, String> {
1675
+    let header = parse_header(bytes).map_err(|e| e.to_string())?;
1676
+    let commands = parse_commands(&header, bytes).map_err(|e| e.to_string())?;
1677
+    Ok(commands
1678
+        .into_iter()
1679
+        .filter_map(|cmd| match cmd {
1680
+            LoadCommand::Dylib(cmd) if cmd.cmd == LC_LOAD_DYLIB => Some(cmd.name),
1681
+            _ => None,
1682
+        })
1683
+        .collect())
1684
+}
1685
+
1686
+#[derive(Debug, Clone, PartialEq, Eq)]
1687
+struct CanonicalSymbolRecord {
1688
+    name: String,
1689
+    n_type: u8,
1690
+    section: Option<(String, String)>,
1691
+    n_desc: u16,
1692
+    value: u64,
1693
+}
1694
+
1695
+#[derive(Debug, Clone, PartialEq, Eq)]
1696
+enum CanonicalExportKind {
1697
+    Regular(u64),
1698
+    ThreadLocal(u64),
1699
+    Absolute(u64),
1700
+    Reexport { ordinal: u32, imported_name: String },
1701
+    StubAndResolver { stub: u64, resolver: u64 },
1702
+}
1703
+
1704
+#[derive(Debug, Clone, PartialEq, Eq)]
1705
+struct CanonicalExportRecord {
1706
+    name: String,
1707
+    flags: u64,
1708
+    kind: CanonicalExportKind,
1709
+}
1710
+
1711
+fn canonical_symbol_record_map(
1712
+    bytes: &[u8],
1713
+) -> Result<BTreeMap<String, CanonicalSymbolRecord>, String> {
1714
+    Ok(canonical_symbol_records(bytes)?
1715
+        .into_iter()
1716
+        .map(|record| (record.name.clone(), record))
1717
+        .collect())
1718
+}
1719
+
1720
+fn canonical_symbol_records(bytes: &[u8]) -> Result<Vec<CanonicalSymbolRecord>, String> {
1721
+    let (symtab, _) = symtab_and_dysymtab(bytes)?;
1722
+    let symbols =
1723
+        parse_nlist_table(bytes, symtab.symoff, symtab.nsyms).map_err(|e| e.to_string())?;
1724
+    let strings =
1725
+        StringTable::from_file(bytes, symtab.stroff, symtab.strsize).map_err(|e| e.to_string())?;
1726
+    let sections = section_regions(bytes)?;
1727
+    Ok(symbols
1728
+        .iter()
1729
+        .map(|symbol| {
1730
+            let (section, value) = if symbol.kind() == SymKind::Sect && symbol.sect_idx() != 0 {
1731
+                let section = &sections[symbol.sect_idx() as usize - 1];
1732
+                let value = if symbol.value() >= section.addr {
1733
+                    symbol.value() - section.addr
1734
+                } else {
1735
+                    symbol.value()
1736
+                };
1737
+                (
1738
+                    Some((section.segname.clone(), section.sectname.clone())),
1739
+                    value,
1740
+                )
1741
+            } else {
1742
+                (None, symbol.value())
1743
+            };
1744
+            CanonicalSymbolRecord {
1745
+                name: strings.get(symbol.strx()).unwrap().to_string(),
1746
+                n_type: symbol.raw.n_type,
1747
+                section,
1748
+                n_desc: symbol.raw.n_desc,
1749
+                value,
1750
+            }
1751
+        })
1752
+        .filter(|record| !is_optional_dyld_stub_binder_record(record))
1753
+        .collect())
1754
+}
1755
+
1756
+fn is_optional_dyld_stub_binder_record(record: &CanonicalSymbolRecord) -> bool {
1757
+    record.name == "dyld_stub_binder"
1758
+        && (record.n_type & N_TYPE) == N_UNDF
1759
+        && record.section.is_none()
1760
+}
1761
+
1762
+fn canonical_export_records(bytes: &[u8]) -> Result<Vec<CanonicalExportRecord>, String> {
1763
+    let dylib = DylibFile::parse("/tmp/canonical.dylib", bytes).map_err(|e| e.to_string())?;
1764
+    let symbol_values: BTreeMap<String, u64> = canonical_symbol_records(bytes)?
1765
+        .into_iter()
1766
+        .map(|record| (record.name, record.value))
1767
+        .collect();
1768
+    let mut out = dylib
1769
+        .exports
1770
+        .entries()
1771
+        .map_err(|e| e.to_string())?
1772
+        .into_iter()
1773
+        .map(|entry| {
1774
+            let kind = match entry.kind {
1775
+                ExportKind::Regular { .. } => {
1776
+                    CanonicalExportKind::Regular(*symbol_values.get(&entry.name).unwrap())
1777
+                }
1778
+                ExportKind::ThreadLocal { .. } => {
1779
+                    CanonicalExportKind::ThreadLocal(*symbol_values.get(&entry.name).unwrap())
1780
+                }
1781
+                ExportKind::Absolute { .. } => {
1782
+                    CanonicalExportKind::Absolute(*symbol_values.get(&entry.name).unwrap())
1783
+                }
1784
+                ExportKind::Reexport {
1785
+                    ordinal,
1786
+                    imported_name,
1787
+                } => CanonicalExportKind::Reexport {
1788
+                    ordinal,
1789
+                    imported_name,
1790
+                },
1791
+                ExportKind::StubAndResolver { stub, resolver } => {
1792
+                    CanonicalExportKind::StubAndResolver { stub, resolver }
1793
+                }
1794
+            };
1795
+            CanonicalExportRecord {
1796
+                name: entry.name,
1797
+                flags: entry.flags,
1798
+                kind,
1799
+            }
1800
+        })
1801
+        .collect::<Vec<_>>();
1802
+    out.sort_by(|lhs, rhs| lhs.name.cmp(&rhs.name));
1803
+    Ok(out)
1804
+}
1805
+
1806
+fn symbol_partition_names(bytes: &[u8]) -> Result<SymbolPartitions, String> {
1807
+    let (symtab, dysymtab) = symtab_and_dysymtab(bytes)?;
1808
+    let symbols =
1809
+        parse_nlist_table(bytes, symtab.symoff, symtab.nsyms).map_err(|e| e.to_string())?;
1810
+    let strings =
1811
+        StringTable::from_file(bytes, symtab.stroff, symtab.strsize).map_err(|e| e.to_string())?;
1812
+    let names_for = |start: u32, count: u32| -> Vec<String> {
1813
+        symbols[start as usize..(start + count) as usize]
1814
+            .iter()
1815
+            .map(|symbol| strings.get(symbol.strx()).unwrap().to_string())
1816
+            .collect()
1817
+    };
1818
+    Ok((
1819
+        names_for(dysymtab.ilocalsym, dysymtab.nlocalsym),
1820
+        names_for(dysymtab.iextdefsym, dysymtab.nextdefsym),
1821
+        names_for(dysymtab.iundefsym, dysymtab.nundefsym)
1822
+            .into_iter()
1823
+            .filter(|name| name != "dyld_stub_binder")
1824
+            .collect(),
1825
+    ))
1826
+}
1827
+
1828
+fn has_optional_dyld_stub_binder(bytes: &[u8]) -> Result<bool, String> {
1829
+    let (symtab, _) = symtab_and_dysymtab(bytes)?;
1830
+    let symbols =
1831
+        parse_nlist_table(bytes, symtab.symoff, symtab.nsyms).map_err(|e| e.to_string())?;
1832
+    let strings =
1833
+        StringTable::from_file(bytes, symtab.stroff, symtab.strsize).map_err(|e| e.to_string())?;
1834
+    Ok(symbols.iter().any(|symbol| {
1835
+        strings
1836
+            .get(symbol.strx())
1837
+            .map(|name| {
1838
+                name == "dyld_stub_binder"
1839
+                    && (symbol.raw.n_type & N_TYPE) == N_UNDF
1840
+                    && symbol.raw.n_sect == 0
1841
+            })
1842
+            .unwrap_or(false)
1843
+    }))
1844
+}
1845
+
1846
+fn raw_string_table(bytes: &[u8]) -> Result<Vec<u8>, String> {
1847
+    let (symtab, _) = symtab_and_dysymtab(bytes)?;
1848
+    let start = symtab.stroff as usize;
1849
+    let end = start + symtab.strsize as usize;
1850
+    Ok(bytes[start..end].to_vec())
1851
+}
1852
+
1853
+fn effective_string_table_len(bytes: &[u8]) -> Result<usize, String> {
1854
+    let mut len = raw_string_table(bytes)?.len();
1855
+    if has_optional_dyld_stub_binder(bytes)? {
1856
+        len = len.saturating_sub("dyld_stub_binder".len() + 1);
1857
+    }
1858
+    Ok(len)
1859
+}
1860
+
1861
+pub fn string_table_within_five_percent(ours: usize, theirs: usize) -> bool {
1862
+    let delta = ours.abs_diff(theirs);
1863
+    delta * 20 <= theirs
1864
+}
1865
+
1866
+fn indirect_symbol_table(bytes: &[u8]) -> Result<Vec<u32>, String> {
1867
+    let (_, dysymtab) = symtab_and_dysymtab(bytes)?;
1868
+    if dysymtab.nindirectsyms == 0 {
1869
+        return Ok(Vec::new());
1870
+    }
1871
+    let start = dysymtab.indirectsymoff as usize;
1872
+    let end = start + dysymtab.nindirectsyms as usize * 4;
1873
+    Ok(bytes[start..end]
1874
+        .chunks_exact(4)
1875
+        .map(|chunk| u32::from_le_bytes(chunk.try_into().unwrap()))
1876
+        .collect())
1877
+}
1878
+
1879
+fn indirect_symbol_identities(bytes: &[u8]) -> Result<Vec<String>, String> {
1880
+    let (symtab, _) = symtab_and_dysymtab(bytes)?;
1881
+    let symbols =
1882
+        parse_nlist_table(bytes, symtab.symoff, symtab.nsyms).map_err(|e| e.to_string())?;
1883
+    let strings =
1884
+        StringTable::from_file(bytes, symtab.stroff, symtab.strsize).map_err(|e| e.to_string())?;
1885
+    Ok(indirect_symbol_table(bytes)?
1886
+        .into_iter()
1887
+        .map(|index| {
1888
+            if index & INDIRECT_SYMBOL_LOCAL != 0 {
1889
+                if index & INDIRECT_SYMBOL_ABS != 0 {
1890
+                    "<LOCAL|ABS>".to_string()
1891
+                } else {
1892
+                    "<LOCAL>".to_string()
1893
+                }
1894
+            } else if index & INDIRECT_SYMBOL_ABS != 0 {
1895
+                "<ABS>".to_string()
1896
+            } else {
1897
+                let symbol = &symbols[index as usize];
1898
+                strings.get(symbol.strx()).unwrap().to_string()
1899
+            }
1900
+        })
1901
+        .collect())
1902
+}
1903
+
1904
+fn raw_linkedit_data_cmd(bytes: &[u8], expected_cmd: u32) -> Result<(u32, u32), String> {
1905
+    let header = parse_header(bytes).map_err(|e| e.to_string())?;
1906
+    let commands = parse_commands(&header, bytes).map_err(|e| e.to_string())?;
1907
+    for cmd in commands {
1908
+        match cmd {
1909
+            LoadCommand::Raw { cmd, data, .. } if cmd == expected_cmd => {
1910
+                return Ok((u32_le(&data[0..4]), u32_le(&data[4..8])));
1911
+            }
1912
+            LoadCommand::LinkerOptimizationHint(linkedit)
1913
+                if expected_cmd == afs_ld::macho::constants::LC_LINKER_OPTIMIZATION_HINT =>
1914
+            {
1915
+                return Ok((linkedit.dataoff, linkedit.datasize));
1916
+            }
1917
+            _ => {}
1918
+        }
1919
+    }
1920
+    Err(format!("missing raw linkedit command 0x{expected_cmd:x}"))
1921
+}
1922
+
1923
+fn linkedit_payload(bytes: &[u8], cmd: u32) -> Result<Vec<u8>, String> {
1924
+    let (dataoff, datasize) = raw_linkedit_data_cmd(bytes, cmd)?;
1925
+    if datasize == 0 {
1926
+        return Ok(Vec::new());
1927
+    }
1928
+    Ok(bytes[dataoff as usize..(dataoff + datasize) as usize].to_vec())
1929
+}
1930
+
1931
+fn decode_function_starts(bytes: &[u8]) -> Result<Vec<u64>, String> {
1932
+    let payload = linkedit_payload(bytes, LC_FUNCTION_STARTS)?;
1933
+    let mut offsets = Vec::new();
1934
+    let mut cursor = 0usize;
1935
+    let mut current = 0u64;
1936
+    while cursor < payload.len() {
1937
+        let (delta, used) = read_uleb(&payload[cursor..]).map_err(|e| e.to_string())?;
1938
+        cursor += used;
1939
+        if delta == 0 {
1940
+            break;
1941
+        }
1942
+        current += delta;
1943
+        offsets.push(current);
1944
+    }
1945
+    Ok(offsets)
1946
+}
1947
+
1948
+fn normalize_function_start_offsets(starts: &[u64]) -> Vec<u64> {
1949
+    let Some(&base) = starts.first() else {
1950
+        return Vec::new();
1951
+    };
1952
+    starts.iter().map(|offset| offset - base).collect()
1953
+}
1954
+
1955
+#[derive(Debug, Clone, PartialEq, Eq)]
1956
+struct DataInCodeRecord {
1957
+    offset: u32,
1958
+    length: u16,
1959
+    kind: u16,
1960
+}
1961
+
1962
+fn decode_data_in_code(bytes: &[u8]) -> Result<Vec<DataInCodeRecord>, String> {
1963
+    let payload = linkedit_payload(bytes, LC_DATA_IN_CODE)?;
1964
+    Ok(payload
1965
+        .chunks_exact(8)
1966
+        .map(|chunk| DataInCodeRecord {
1967
+            offset: u32::from_le_bytes(chunk[0..4].try_into().unwrap()),
1968
+            length: u16::from_le_bytes(chunk[4..6].try_into().unwrap()),
1969
+            kind: u16::from_le_bytes(chunk[6..8].try_into().unwrap()),
1970
+        })
1971
+        .collect())
1972
+}
1973
+
1974
+fn canonical_data_in_code(bytes: &[u8]) -> Result<Vec<DataInCodeRecord>, String> {
1975
+    let text = output_section_header(bytes, "__TEXT", "__text")
1976
+        .ok_or_else(|| "missing __TEXT,__text section".to_string())?;
1977
+    Ok(decode_data_in_code(bytes)?
1978
+        .into_iter()
1979
+        .map(|record| DataInCodeRecord {
1980
+            offset: record.offset - text.offset,
1981
+            length: record.length,
1982
+            kind: record.kind,
1983
+        })
1984
+        .collect())
1985
+}
1986
+
1987
+#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
1988
+enum CanonicalBindLocation {
1989
+    Section {
1990
+        segname: String,
1991
+        sectname: String,
1992
+        offset: u64,
1993
+    },
1994
+    Segment {
1995
+        segment_index: u8,
1996
+        segment_offset: u64,
1997
+    },
1998
+}
1999
+
2000
+#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
2001
+struct CanonicalBindRecord {
2002
+    location: CanonicalBindLocation,
2003
+    ordinal: i32,
2004
+    symbol: String,
2005
+    weak_import: bool,
2006
+    bind_type: u8,
2007
+    addend: i64,
2008
+}
2009
+
2010
+fn canonical_bind_records(
2011
+    bytes: &[u8],
2012
+    kind: DyldInfoStreamKind,
2013
+) -> Result<Vec<CanonicalBindRecord>, String> {
2014
+    let stream = dyld_info_stream(bytes, kind)?;
2015
+    let mut cursor = 0usize;
2016
+    let mut segment_index = 0u8;
2017
+    let mut segment_offset = 0u64;
2018
+    let mut ordinal = 0i32;
2019
+    let mut symbol = String::new();
2020
+    let mut weak_import = false;
2021
+    let mut bind_type = BIND_TYPE_POINTER;
2022
+    let mut addend = 0i64;
2023
+    let mut out = Vec::new();
2024
+
2025
+    while cursor < stream.len() {
2026
+        let byte = stream[cursor];
2027
+        cursor += 1;
2028
+        let opcode = byte & BIND_OPCODE_MASK;
2029
+        let imm = byte & BIND_IMMEDIATE_MASK;
2030
+        match opcode {
2031
+            BIND_OPCODE_DONE => break,
2032
+            BIND_OPCODE_SET_DYLIB_ORDINAL_IMM => ordinal = imm as i32,
2033
+            BIND_OPCODE_SET_DYLIB_ORDINAL_ULEB => {
2034
+                let (value, used) =
2035
+                    read_uleb(&stream[cursor..]).map_err(|e| format!("bind uleb: {e}"))?;
2036
+                cursor += used;
2037
+                ordinal = value as i32;
2038
+            }
2039
+            BIND_OPCODE_SET_DYLIB_SPECIAL_IMM => {
2040
+                ordinal = if imm == 0 {
2041
+                    0
2042
+                } else {
2043
+                    (((imm as i8) << 4) >> 4) as i32
2044
+                };
2045
+            }
2046
+            BIND_OPCODE_SET_SYMBOL_TRAILING_FLAGS_IMM => {
2047
+                let (value, used) = read_c_string(&stream[cursor..])?;
2048
+                cursor += used;
2049
+                symbol = value;
2050
+                weak_import = (imm & BIND_SYMBOL_FLAGS_WEAK_IMPORT) != 0;
2051
+            }
2052
+            BIND_OPCODE_SET_TYPE_IMM => bind_type = imm,
2053
+            BIND_OPCODE_SET_ADDEND_SLEB => {
2054
+                let (value, used) =
2055
+                    read_sleb(&stream[cursor..]).map_err(|e| format!("bind sleb: {e}"))?;
2056
+                cursor += used;
2057
+                addend = value;
2058
+            }
2059
+            BIND_OPCODE_SET_SEGMENT_AND_OFFSET_ULEB => {
2060
+                let (value, used) =
2061
+                    read_uleb(&stream[cursor..]).map_err(|e| format!("bind uleb: {e}"))?;
2062
+                cursor += used;
2063
+                segment_index = imm;
2064
+                segment_offset = value;
2065
+            }
2066
+            BIND_OPCODE_ADD_ADDR_ULEB => {
2067
+                let (value, used) =
2068
+                    read_uleb(&stream[cursor..]).map_err(|e| format!("bind uleb: {e}"))?;
2069
+                cursor += used;
2070
+                segment_offset += value;
2071
+            }
2072
+            BIND_OPCODE_DO_BIND => {
2073
+                out.push(CanonicalBindRecord {
2074
+                    location: canonical_bind_location(bytes, segment_index, segment_offset)?,
2075
+                    ordinal,
2076
+                    symbol: symbol.clone(),
2077
+                    weak_import,
2078
+                    bind_type,
2079
+                    addend,
2080
+                });
2081
+                segment_offset += 8;
2082
+            }
2083
+            BIND_OPCODE_DO_BIND_ADD_ADDR_ULEB => {
2084
+                let (value, used) =
2085
+                    read_uleb(&stream[cursor..]).map_err(|e| format!("bind uleb: {e}"))?;
2086
+                cursor += used;
2087
+                out.push(CanonicalBindRecord {
2088
+                    location: canonical_bind_location(bytes, segment_index, segment_offset)?,
2089
+                    ordinal,
2090
+                    symbol: symbol.clone(),
2091
+                    weak_import,
2092
+                    bind_type,
2093
+                    addend,
2094
+                });
2095
+                segment_offset += 8 + value;
2096
+            }
2097
+            BIND_OPCODE_DO_BIND_ADD_ADDR_IMM_SCALED => {
2098
+                out.push(CanonicalBindRecord {
2099
+                    location: canonical_bind_location(bytes, segment_index, segment_offset)?,
2100
+                    ordinal,
2101
+                    symbol: symbol.clone(),
2102
+                    weak_import,
2103
+                    bind_type,
2104
+                    addend,
2105
+                });
2106
+                segment_offset += 8 + (imm as u64) * 8;
2107
+            }
2108
+            BIND_OPCODE_DO_BIND_ULEB_TIMES_SKIPPING_ULEB => {
2109
+                let (count, count_used) =
2110
+                    read_uleb(&stream[cursor..]).map_err(|e| format!("bind uleb: {e}"))?;
2111
+                cursor += count_used;
2112
+                let (skip, skip_used) =
2113
+                    read_uleb(&stream[cursor..]).map_err(|e| format!("bind uleb: {e}"))?;
2114
+                cursor += skip_used;
2115
+                for _ in 0..count {
2116
+                    out.push(CanonicalBindRecord {
2117
+                        location: canonical_bind_location(bytes, segment_index, segment_offset)?,
2118
+                        ordinal,
2119
+                        symbol: symbol.clone(),
2120
+                        weak_import,
2121
+                        bind_type,
2122
+                        addend,
2123
+                    });
2124
+                    segment_offset += 8 + skip;
2125
+                }
2126
+            }
2127
+            other => return Err(format!("unsupported bind opcode 0x{other:02x}")),
2128
+        }
2129
+    }
2130
+
2131
+    normalize_bind_section_offsets(&mut out);
2132
+    out.sort();
2133
+    Ok(out)
2134
+}
2135
+
2136
+fn normalize_bind_section_offsets(records: &mut [CanonicalBindRecord]) {
2137
+    let mut next_offsets: BTreeMap<(String, String), u64> = BTreeMap::new();
2138
+    records.sort();
2139
+    for record in records.iter_mut() {
2140
+        let CanonicalBindLocation::Section {
2141
+            segname,
2142
+            sectname,
2143
+            offset,
2144
+        } = &mut record.location
2145
+        else {
2146
+            continue;
2147
+        };
2148
+        let next = next_offsets
2149
+            .entry((segname.clone(), sectname.clone()))
2150
+            .or_insert(0);
2151
+        *offset = *next;
2152
+        *next += 8;
2153
+    }
2154
+}
2155
+
2156
+fn rebased_unwind_bytes(bytes: &[u8]) -> Result<Vec<u8>, String> {
2157
+    let header_base = segment_vmaddr(bytes, "__TEXT").unwrap_or(0);
2158
+    let text_base = output_section(bytes, "__TEXT", "__text")
2159
+        .ok_or_else(|| "missing __TEXT,__text section".to_string())?
2160
+        .0
2161
+        - header_base;
2162
+    let got_range = output_section(bytes, "__DATA_CONST", "__got")
2163
+        .map(|(addr, data)| (addr - header_base, addr - header_base + data.len() as u64));
2164
+    let lsda_base =
2165
+        output_section(bytes, "__TEXT", "__gcc_except_tab").map(|(addr, _)| addr - header_base);
2166
+    let (_, unwind) = output_section(bytes, "__TEXT", "__unwind_info")
2167
+        .ok_or_else(|| "missing __TEXT,__unwind_info section".to_string())?;
2168
+    let mut out = unwind;
2169
+    if out.len() < 28 {
2170
+        return Ok(out);
2171
+    }
2172
+
2173
+    let personalities_offset = u32_le(&out[12..16]) as usize;
2174
+    let personalities_count = u32_le(&out[16..20]) as usize;
2175
+    let indices_offset = u32_le(&out[20..24]) as usize;
2176
+    let indices_count = u32_le(&out[24..28]) as usize;
2177
+
2178
+    for idx in 0..personalities_count {
2179
+        let off = personalities_offset + idx * 4;
2180
+        let value = u32_le(&out[off..off + 4]) as u64;
2181
+        let rebased = if let Some((got_start, got_end)) = got_range {
2182
+            if got_start <= value && value < got_end {
2183
+                value - got_start
2184
+            } else if value >= text_base {
2185
+                value - text_base
2186
+            } else {
2187
+                value
2188
+            }
2189
+        } else if value >= text_base {
2190
+            value - text_base
2191
+        } else {
2192
+            value
2193
+        };
2194
+        out[off..off + 4].copy_from_slice(&(rebased as u32).to_le_bytes());
2195
+    }
2196
+
2197
+    let mut lsda_offsets = Vec::with_capacity(indices_count);
2198
+    for idx in 0..indices_count {
2199
+        let entry_off = indices_offset + idx * 12;
2200
+        let function_offset = u32_le(&out[entry_off..entry_off + 4]) as u64;
2201
+        let rebased = function_offset.saturating_sub(text_base);
2202
+        out[entry_off..entry_off + 4].copy_from_slice(&(rebased as u32).to_le_bytes());
2203
+        lsda_offsets.push(u32_le(&out[entry_off + 8..entry_off + 12]) as usize);
2204
+    }
2205
+
2206
+    if let (Some(lsda_base), Some(&start), Some(&end)) =
2207
+        (lsda_base, lsda_offsets.first(), lsda_offsets.last())
2208
+    {
2209
+        let mut entry_off = start;
2210
+        while entry_off < end {
2211
+            let function_offset = u32_le(&out[entry_off..entry_off + 4]) as u64;
2212
+            let lsda_offset = u32_le(&out[entry_off + 4..entry_off + 8]) as u64;
2213
+            out[entry_off..entry_off + 4]
2214
+                .copy_from_slice(&(function_offset.saturating_sub(text_base) as u32).to_le_bytes());
2215
+            out[entry_off + 4..entry_off + 8]
2216
+                .copy_from_slice(&(lsda_offset.saturating_sub(lsda_base) as u32).to_le_bytes());
2217
+            entry_off += 8;
2218
+        }
2219
+    }
2220
+
2221
+    let _ = decode_unwind_info(&out).map_err(|e| format!("decode unwind info: {e}"))?;
2222
+    Ok(out)
2223
+}
2224
+
2225
+fn symtab_and_dysymtab(
2226
+    bytes: &[u8],
2227
+) -> Result<
2228
+    (
2229
+        afs_ld::macho::reader::SymtabCmd,
2230
+        afs_ld::macho::reader::DysymtabCmd,
2231
+    ),
2232
+    String,
2233
+> {
2234
+    let header = parse_header(bytes).map_err(|e| e.to_string())?;
2235
+    let commands = parse_commands(&header, bytes).map_err(|e| e.to_string())?;
2236
+    let mut symtab = None;
2237
+    let mut dysymtab = None;
2238
+    for cmd in commands {
2239
+        match cmd {
2240
+            LoadCommand::Symtab(cmd) => symtab = Some(cmd),
2241
+            LoadCommand::Dysymtab(cmd) => dysymtab = Some(cmd),
2242
+            _ => {}
2243
+        }
2244
+    }
2245
+    Ok((
2246
+        symtab.ok_or_else(|| "missing LC_SYMTAB".to_string())?,
2247
+        dysymtab.ok_or_else(|| "missing LC_DYSYMTAB".to_string())?,
2248
+    ))
2249
+}
2250
+
2251
+fn section_addrs(bytes: &[u8]) -> Result<Vec<u64>, String> {
2252
+    Ok(section_regions(bytes)?
2253
+        .into_iter()
2254
+        .map(|section| section.addr)
2255
+        .collect())
2256
+}
2257
+
2258
+#[derive(Debug, Clone)]
2259
+struct SegmentRegion {
2260
+    index: u8,
2261
+    segname: String,
2262
+    vmaddr: u64,
2263
+    vmsize: u64,
2264
+}
2265
+
2266
+#[derive(Debug, Clone)]
2267
+struct SectionRegion {
2268
+    segment_index: u8,
2269
+    segname: String,
2270
+    sectname: String,
2271
+    addr: u64,
2272
+    size: u64,
2273
+}
2274
+
2275
+#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
2276
+struct CanonicalSectionLocation {
2277
+    segname: String,
2278
+    sectname: String,
2279
+    offset: u64,
2280
+}
2281
+
2282
+fn segment_regions(bytes: &[u8]) -> Result<Vec<SegmentRegion>, String> {
2283
+    let header = parse_header(bytes).map_err(|e| e.to_string())?;
2284
+    let commands = parse_commands(&header, bytes).map_err(|e| e.to_string())?;
2285
+    let mut out = Vec::new();
2286
+    let mut index = 0u8;
2287
+    for cmd in commands {
2288
+        if let LoadCommand::Segment64(seg) = cmd {
2289
+            out.push(SegmentRegion {
2290
+                index,
2291
+                segname: seg.segname_str().to_string(),
2292
+                vmaddr: seg.vmaddr,
2293
+                vmsize: seg.vmsize,
2294
+            });
2295
+            index = index.saturating_add(1);
2296
+        }
2297
+    }
2298
+    Ok(out)
2299
+}
2300
+
2301
+fn section_regions(bytes: &[u8]) -> Result<Vec<SectionRegion>, String> {
2302
+    let header = parse_header(bytes).map_err(|e| e.to_string())?;
2303
+    let commands = parse_commands(&header, bytes).map_err(|e| e.to_string())?;
2304
+    let mut out = Vec::new();
2305
+    let mut segment_index = 0u8;
2306
+    for cmd in commands {
2307
+        if let LoadCommand::Segment64(seg) = cmd {
2308
+            for section in seg.sections {
2309
+                out.push(SectionRegion {
2310
+                    segment_index,
2311
+                    segname: section.segname_str().to_string(),
2312
+                    sectname: section.sectname_str().to_string(),
2313
+                    addr: section.addr,
2314
+                    size: section.size,
2315
+                });
2316
+            }
2317
+            segment_index = segment_index.saturating_add(1);
2318
+        }
2319
+    }
2320
+    Ok(out)
2321
+}
2322
+
2323
+fn canonical_bind_location(
2324
+    bytes: &[u8],
2325
+    segment_index: u8,
2326
+    segment_offset: u64,
2327
+) -> Result<CanonicalBindLocation, String> {
2328
+    let segments = segment_regions(bytes)?;
2329
+    let sections = section_regions(bytes)?;
2330
+    let Some(segment) = segments
2331
+        .iter()
2332
+        .find(|segment| segment.index == segment_index)
2333
+    else {
2334
+        return Ok(CanonicalBindLocation::Segment {
2335
+            segment_index,
2336
+            segment_offset,
2337
+        });
2338
+    };
2339
+    if segment_offset >= segment.vmsize {
2340
+        return Ok(CanonicalBindLocation::Segment {
2341
+            segment_index,
2342
+            segment_offset,
2343
+        });
2344
+    }
2345
+    let addr = segment.vmaddr + segment_offset;
2346
+    if let Some(section) = sections.iter().find(|section| {
2347
+        section.segment_index == segment_index
2348
+            && section.addr <= addr
2349
+            && addr < section.addr + section.size
2350
+    }) {
2351
+        return Ok(CanonicalBindLocation::Section {
2352
+            segname: section.segname.clone(),
2353
+            sectname: section.sectname.clone(),
2354
+            offset: addr - section.addr,
2355
+        });
2356
+    }
2357
+    Ok(CanonicalBindLocation::Segment {
2358
+        segment_index,
2359
+        segment_offset,
2360
+    })
2361
+}
2362
+
2363
+fn canonical_section_location(bytes: &[u8], addr: u64) -> Result<CanonicalSectionLocation, String> {
2364
+    let sections = section_regions(bytes)?;
2365
+    let section = sections
2366
+        .into_iter()
2367
+        .find(|section| section.addr <= addr && addr < section.addr + section.size)
2368
+        .ok_or_else(|| format!("address 0x{addr:x} is not inside any output section"))?;
2369
+    Ok(CanonicalSectionLocation {
2370
+        segname: section.segname,
2371
+        sectname: section.sectname,
2372
+        offset: addr - section.addr,
2373
+    })
2374
+}
2375
+
2376
+#[derive(Clone, Copy)]
2377
+enum DyldInfoStreamKind {
2378
+    Rebase,
2379
+    Bind,
2380
+    WeakBind,
2381
+    LazyBind,
2382
+}
2383
+
2384
+fn dyld_info_command(bytes: &[u8]) -> Result<DyldInfoCmd, String> {
2385
+    let header = parse_header(bytes).map_err(|e| e.to_string())?;
2386
+    let commands = parse_commands(&header, bytes).map_err(|e| e.to_string())?;
2387
+    commands
2388
+        .into_iter()
2389
+        .find_map(|cmd| match cmd {
2390
+            LoadCommand::DyldInfoOnly(cmd) => Some(cmd),
2391
+            _ => None,
2392
+        })
2393
+        .ok_or_else(|| "missing LC_DYLD_INFO_ONLY".to_string())
2394
+}
2395
+
2396
+fn dyld_info_stream(bytes: &[u8], kind: DyldInfoStreamKind) -> Result<Vec<u8>, String> {
2397
+    let dyld_info = dyld_info_command(bytes)?;
2398
+    let (off, size) = match kind {
2399
+        DyldInfoStreamKind::Rebase => (dyld_info.rebase_off, dyld_info.rebase_size),
2400
+        DyldInfoStreamKind::Bind => (dyld_info.bind_off, dyld_info.bind_size),
2401
+        DyldInfoStreamKind::WeakBind => (dyld_info.weak_bind_off, dyld_info.weak_bind_size),
2402
+        DyldInfoStreamKind::LazyBind => (dyld_info.lazy_bind_off, dyld_info.lazy_bind_size),
2403
+    };
2404
+    if size == 0 {
2405
+        return Ok(Vec::new());
2406
+    }
2407
+    let start = off as usize;
2408
+    let end = start + size as usize;
2409
+    bytes
2410
+        .get(start..end)
2411
+        .map(|slice| slice.to_vec())
2412
+        .ok_or_else(|| "dyld-info stream out of bounds".to_string())
2413
+}
2414
+
2415
+fn read_c_string(bytes: &[u8]) -> Result<(String, usize), String> {
2416
+    let end = bytes
2417
+        .iter()
2418
+        .position(|byte| *byte == 0)
2419
+        .ok_or_else(|| "unterminated C string".to_string())?;
2420
+    let value = std::str::from_utf8(&bytes[..end])
2421
+        .map_err(|e| format!("utf-8 in C string: {e}"))?
2422
+        .to_string();
2423
+    Ok((value, end + 1))
2424
+}
2425
+
2426
+fn canonical_stub_targets(bytes: &[u8]) -> Result<Vec<u64>, String> {
2427
+    let header = output_section_header(bytes, "__TEXT", "__stubs")
2428
+        .ok_or_else(|| "missing __TEXT,__stubs section".to_string())?;
2429
+    let (section_addr, section_bytes) = output_section(bytes, "__TEXT", "__stubs")
2430
+        .ok_or_else(|| "missing __TEXT,__stubs section".to_string())?;
2431
+    if section_bytes.is_empty() {
2432
+        return Ok(Vec::new());
2433
+    }
2434
+    let stub_size = usize::try_from(header.reserved2)
2435
+        .ok()
2436
+        .filter(|size| *size > 0)
2437
+        .unwrap_or(12);
2438
+    if section_bytes.len() % stub_size != 0 {
2439
+        return Err(format!(
2440
+            "__TEXT,__stubs size {} is not a multiple of stub size {}",
2441
+            section_bytes.len(),
2442
+            stub_size
2443
+        ));
2444
+    }
2445
+    let mut out = Vec::new();
2446
+    for (idx, chunk) in section_bytes.chunks_exact(stub_size).enumerate() {
2447
+        out.push(decode_stub_target(
2448
+            chunk,
2449
+            section_addr + (idx * stub_size) as u64,
2450
+        )?);
2451
+    }
2452
+    Ok(out)
2453
+}
2454
+
2455
+#[derive(Debug, Clone, PartialEq, Eq)]
2456
+struct CanonicalStubHelper {
2457
+    dyld_private: CanonicalSectionLocation,
2458
+    binder_got: CanonicalSectionLocation,
2459
+    lazy_bind_offsets: Vec<u32>,
2460
+}
2461
+
2462
+fn canonical_stub_helper(bytes: &[u8]) -> Result<CanonicalStubHelper, String> {
2463
+    let (section_addr, section_bytes) = output_section(bytes, "__TEXT", "__stub_helper")
2464
+        .ok_or_else(|| "missing __TEXT,__stub_helper section".to_string())?;
2465
+    if section_bytes.len() < STUB_HELPER_HEADER_SIZE as usize {
2466
+        return Err(format!(
2467
+            "__TEXT,__stub_helper is too small for header: {} < {}",
2468
+            section_bytes.len(),
2469
+            STUB_HELPER_HEADER_SIZE
2470
+        ));
2471
+    }
2472
+    let dyld_private_target =
2473
+        decode_page_reference(&section_bytes, section_addr, 0, PageRefKind::Add)?;
2474
+    let binder_got_target =
2475
+        decode_page_reference(&section_bytes, section_addr, 12, PageRefKind::Load)?;
2476
+    let dyld_private = canonical_section_location(bytes, dyld_private_target)?;
2477
+    let binder_got = canonical_section_location(bytes, binder_got_target)?;
2478
+
2479
+    let entry_bytes = &section_bytes[STUB_HELPER_HEADER_SIZE as usize..];
2480
+    if entry_bytes.len() % STUB_HELPER_ENTRY_SIZE as usize != 0 {
2481
+        return Err(format!(
2482
+            "__TEXT,__stub_helper entries {} are not a multiple of {}",
2483
+            entry_bytes.len(),
2484
+            STUB_HELPER_ENTRY_SIZE
2485
+        ));
2486
+    }
2487
+
2488
+    let mut lazy_bind_offsets = Vec::new();
2489
+    for (idx, chunk) in entry_bytes
2490
+        .chunks_exact(STUB_HELPER_ENTRY_SIZE as usize)
2491
+        .enumerate()
2492
+    {
2493
+        let entry_addr = section_addr
2494
+            + STUB_HELPER_HEADER_SIZE as u64
2495
+            + (idx as u64) * STUB_HELPER_ENTRY_SIZE as u64;
2496
+        let ldr = read_insn(chunk, 0)?;
2497
+        if ldr != 0x1800_0050 {
2498
+            return Err(format!(
2499
+                "stub helper entry at 0x{entry_addr:x} does not start with LDR literal"
2500
+            ));
2501
+        }
2502
+        let branch = read_insn(chunk, 4)?;
2503
+        let branch_target = decode_branch26_target(branch, entry_addr + 4)?;
2504
+        if branch_target != section_addr {
2505
+            return Err(format!(
2506
+                "stub helper entry at 0x{entry_addr:x} branches to 0x{branch_target:x}, expected header 0x{section_addr:x}"
2507
+            ));
2508
+        }
2509
+        lazy_bind_offsets.push(u32_le(&chunk[8..12]));
2510
+    }
2511
+
2512
+    Ok(CanonicalStubHelper {
2513
+        dyld_private,
2514
+        binder_got,
2515
+        lazy_bind_offsets,
2516
+    })
2517
+}
2518
+
2519
+fn decode_stub_target(bytes: &[u8], stub_addr: u64) -> Result<u64, String> {
2520
+    let adrp = read_insn(bytes, 0)?;
2521
+    let ldr = read_insn(bytes, 4)?;
2522
+    let br = read_insn(bytes, 8)?;
2523
+    if (adrp & 0x9f00_0000) != 0x9000_0000 {
2524
+        return Err(format!("stub at 0x{stub_addr:x} does not start with ADRP"));
2525
+    }
2526
+    if (ldr & 0xffc0_0000) != 0xf940_0000 {
2527
+        return Err(format!(
2528
+            "stub at 0x{stub_addr:x} does not use LDR (unsigned)"
2529
+        ));
2530
+    }
2531
+    if (br & 0xffff_fc1f) != 0xd61f_0000 {
2532
+        return Err(format!("stub at 0x{stub_addr:x} does not end with BR"));
2533
+    }
2534
+    let adrp_reg = (adrp & 0x1f) as u8;
2535
+    let ldr_base = ((ldr >> 5) & 0x1f) as u8;
2536
+    let ldr_reg = (ldr & 0x1f) as u8;
2537
+    let br_reg = ((br >> 5) & 0x1f) as u8;
2538
+    if adrp_reg != ldr_base || adrp_reg != ldr_reg || adrp_reg != br_reg {
2539
+        return Err(format!(
2540
+            "stub at 0x{stub_addr:x} uses inconsistent scratch regs: adrp=x{adrp_reg}, ldr base=x{ldr_base}, ldr rt=x{ldr_reg}, br=x{br_reg}"
2541
+        ));
2542
+    }
2543
+    let adrp_immlo = ((adrp >> 29) & 0x3) as i64;
2544
+    let adrp_immhi = ((adrp >> 5) & 0x7ffff) as i64;
2545
+    let adrp_pages = sign_extend_21((adrp_immhi << 2) | adrp_immlo);
2546
+    let adrp_base = ((stub_addr as i64) & !0xfff) + (adrp_pages << 12);
2547
+    let scaled = ((ldr >> 10) & 0xfff) as u64;
2548
+    Ok((adrp_base as u64) + scaled * 8)
2549
+}
2550
+
2551
+fn decode_branch26_target(insn: u32, place: u64) -> Result<u64, String> {
2552
+    if (insn & 0xfc00_0000) != 0x1400_0000 {
2553
+        return Err(format!(
2554
+            "instruction 0x{insn:08x} at 0x{place:x} is not a B/BL branch26"
2555
+        ));
2556
+    }
2557
+    let imm26 = sign_extend_26((insn & 0x03ff_ffff) as i64);
2558
+    Ok(((place as i64) + (imm26 << 2)) as u64)
2559
+}
2560
+
2561
+fn sign_extend_26(value: i64) -> i64 {
2562
+    let shift = 64 - 26;
2563
+    (value << shift) >> shift
2564
+}
2565
+
2566
+fn symbol_values(bytes: &[u8]) -> Result<BTreeMap<String, u64>, String> {
2567
+    let header = parse_header(bytes).map_err(|e| e.to_string())?;
2568
+    let commands = parse_commands(&header, bytes).map_err(|e| e.to_string())?;
2569
+    let symtab = commands
2570
+        .iter()
2571
+        .find_map(|cmd| match cmd {
2572
+            LoadCommand::Symtab(cmd) => Some(*cmd),
2573
+            _ => None,
2574
+        })
2575
+        .ok_or_else(|| "missing LC_SYMTAB".to_string())?;
2576
+    let symbols =
2577
+        parse_nlist_table(bytes, symtab.symoff, symtab.nsyms).map_err(|e| e.to_string())?;
2578
+    let strings =
2579
+        StringTable::from_file(bytes, symtab.stroff, symtab.strsize).map_err(|e| e.to_string())?;
2580
+    let mut out = BTreeMap::new();
2581
+    for symbol in symbols {
2582
+        let Ok(name) = strings.get(symbol.strx()) else {
2583
+            continue;
2584
+        };
2585
+        out.insert(name.to_string(), symbol.value());
2586
+    }
2587
+    Ok(out)
2588
+}
2589
+
2590
+fn decode_page_reference(
2591
+    bytes: &[u8],
2592
+    section_addr: u64,
2593
+    site_offset: u64,
2594
+    kind: PageRefKind,
2595
+) -> Result<u64, String> {
2596
+    let start = site_offset as usize;
2597
+    let adrp = read_insn(bytes, start)?;
2598
+    let second = read_insn(bytes, start + 4)?;
2599
+    let place = section_addr + site_offset;
2600
+    let adrp_immlo = ((adrp >> 29) & 0x3) as i64;
2601
+    let adrp_immhi = ((adrp >> 5) & 0x7ffff) as i64;
2602
+    let adrp_pages = sign_extend_21((adrp_immhi << 2) | adrp_immlo);
2603
+    let adrp_base = ((place as i64) & !0xfff) + (adrp_pages << 12);
2604
+    let low = match kind {
2605
+        PageRefKind::Add => ((second >> 10) & 0xfff) as u64,
2606
+        PageRefKind::Load => {
2607
+            let shift = ((second >> 30) & 0b11) as u64;
2608
+            (((second >> 10) & 0xfff) as u64) << shift
2609
+        }
2610
+    };
2611
+    Ok((adrp_base as u64) + low)
2612
+}
2613
+
2614
+fn read_insn(bytes: &[u8], start: usize) -> Result<u32, String> {
2615
+    let end = start + 4;
2616
+    let slice = bytes
2617
+        .get(start..end)
2618
+        .ok_or_else(|| format!("instruction read OOB at 0x{start:x}"))?;
2619
+    Ok(u32::from_le_bytes([slice[0], slice[1], slice[2], slice[3]]))
2620
+}
2621
+
2622
+fn sign_extend_21(value: i64) -> i64 {
2623
+    if value & (1 << 20) != 0 {
2624
+        value | !0x1f_ffff
2625
+    } else {
2626
+        value
2627
+    }
962628
 }
tests/determinism.rsadded
348 lines changed — click to load
@@ -0,0 +1,348 @@
1
+//! Sprint 28 determinism guardrails.
2
+//!
3
+//! Parallel speedups are only safe if they never perturb the final image. This
4
+//! test repeatedly links a multi-object executable and requires byte-identical
5
+//! output across concurrent runs.
6
+
7
+mod common;
8
+
9
+use std::collections::VecDeque;
10
+use std::fs;
11
+use std::path::{Path, PathBuf};
12
+use std::process::Command;
13
+use std::sync::{Arc, Mutex};
14
+use std::thread;
15
+use std::time::{SystemTime, UNIX_EPOCH};
16
+
17
+use afs_ld::{LinkOptions, Linker, OutputKind};
18
+use common::harness::{assemble, have_xcrun, have_xcrun_tool};
19
+
20
+const DEFAULT_RUNS: usize = 100;
21
+
22
+#[test]
23
+fn repeated_parallel_links_are_byte_identical() {
24
+    if !have_xcrun() || !have_xcrun_tool("as") {
25
+        eprintln!("skipping: xcrun as unavailable");
26
+        return;
27
+    }
28
+
29
+    let root = unique_temp_dir("determinism").expect("create determinism temp dir");
30
+    let main_obj = root.join("main.o");
31
+    assemble(
32
+        "\
33
+        .section __TEXT,__text,regular,pure_instructions\n\
34
+        .globl _main\n\
35
+        _main:\n\
36
+            bl _helper\n\
37
+            adrp x8, _value@GOTPAGE\n\
38
+            ldr x8, [x8, _value@GOTPAGEOFF]\n\
39
+            ldr w0, [x8]\n\
40
+            ret\n\
41
+\n\
42
+        .subsections_via_symbols\n",
43
+        &main_obj,
44
+    )
45
+    .expect("assemble determinism main fixture");
46
+    let helper_obj = root.join("helper.o");
47
+    assemble(
48
+        "\
49
+        .section __TEXT,__text,regular,pure_instructions\n\
50
+        .globl _helper\n\
51
+        _helper:\n\
52
+            ret\n\
53
+\n\
54
+        .subsections_via_symbols\n",
55
+        &helper_obj,
56
+    )
57
+    .expect("assemble determinism helper fixture");
58
+    let data_obj = root.join("data.o");
59
+    assemble(
60
+        "\
61
+        .section __DATA,__data\n\
62
+        .globl _value\n\
63
+        .p2align 2\n\
64
+        _value:\n\
65
+            .long 7\n\
66
+\n\
67
+        .subsections_via_symbols\n",
68
+        &data_obj,
69
+    )
70
+    .expect("assemble determinism data fixture");
71
+
72
+    let inputs = vec![main_obj, helper_obj, data_obj];
73
+    assert_repeated_links_identical(inputs, &root, "objects");
74
+
75
+    let _ = fs::remove_dir_all(root);
76
+}
77
+
78
+#[test]
79
+fn repeated_parallel_archive_fetches_are_byte_identical() {
80
+    if !have_xcrun() || !have_xcrun_tool("as") {
81
+        eprintln!("skipping: xcrun as unavailable");
82
+        return;
83
+    }
84
+
85
+    let root = unique_temp_dir("archive-determinism").expect("create archive determinism temp dir");
86
+    let main_obj = root.join("main.o");
87
+    assemble(
88
+        "\
89
+        .section __TEXT,__text,regular,pure_instructions\n\
90
+        .globl _main\n\
91
+        _main:\n\
92
+            bl _helper_a\n\
93
+            bl _helper_b\n\
94
+            mov w0, #0\n\
95
+            ret\n\
96
+\n\
97
+        .subsections_via_symbols\n",
98
+        &main_obj,
99
+    )
100
+    .expect("assemble archive determinism main fixture");
101
+    let helper_a_obj = root.join("helper_a.o");
102
+    assemble(
103
+        "\
104
+        .section __TEXT,__text,regular,pure_instructions\n\
105
+        .globl _helper_a\n\
106
+        _helper_a:\n\
107
+            ret\n\
108
+\n\
109
+        .subsections_via_symbols\n",
110
+        &helper_a_obj,
111
+    )
112
+    .expect("assemble archive determinism helper_a fixture");
113
+    let helper_b_obj = root.join("helper_b.o");
114
+    assemble(
115
+        "\
116
+        .section __TEXT,__text,regular,pure_instructions\n\
117
+        .globl _helper_b\n\
118
+        _helper_b:\n\
119
+            ret\n\
120
+\n\
121
+        .subsections_via_symbols\n",
122
+        &helper_b_obj,
123
+    )
124
+    .expect("assemble archive determinism helper_b fixture");
125
+    let unused_obj = root.join("unused.o");
126
+    assemble(
127
+        "\
128
+        .section __TEXT,__text,regular,pure_instructions\n\
129
+        .globl _unused\n\
130
+        _unused:\n\
131
+            ret\n\
132
+\n\
133
+        .subsections_via_symbols\n",
134
+        &unused_obj,
135
+    )
136
+    .expect("assemble archive determinism unused fixture");
137
+
138
+    let archive_path = root.join("libhelpers.a");
139
+    if let Err(error) = archive(&[helper_a_obj, helper_b_obj, unused_obj], &archive_path) {
140
+        eprintln!("skipping: archive failed: {error}");
141
+        let _ = fs::remove_dir_all(root);
142
+        return;
143
+    }
144
+
145
+    assert_repeated_links_identical(vec![main_obj, archive_path], &root, "archive");
146
+
147
+    let _ = fs::remove_dir_all(root);
148
+}
149
+
150
+#[test]
151
+fn relocation_workers_match_single_worker_for_many_atoms() {
152
+    if !have_xcrun() || !have_xcrun_tool("as") {
153
+        eprintln!("skipping: xcrun as unavailable");
154
+        return;
155
+    }
156
+
157
+    let root = unique_temp_dir("reloc-workers").expect("create relocation worker temp dir");
158
+    let text_obj = root.join("text.o");
159
+    let data_obj = root.join("data.o");
160
+
161
+    let mut asm = String::from(
162
+        "\
163
+        .section __TEXT,__text,regular,pure_instructions\n\
164
+        .globl _main\n\
165
+        _main:\n",
166
+    );
167
+    for index in 0..64 {
168
+        asm.push_str(&format!("            bl _helper_{index}\n"));
169
+    }
170
+    asm.push_str(
171
+        "\
172
+            adrp x8, _value@GOTPAGE\n\
173
+            ldr x8, [x8, _value@GOTPAGEOFF]\n\
174
+            ldr w0, [x8]\n\
175
+            ret\n\
176
+\n",
177
+    );
178
+    for index in 0..64 {
179
+        asm.push_str(&format!(
180
+            "\
181
+        .globl _helper_{index}\n\
182
+        _helper_{index}:\n\
183
+            adrp x9, _value@GOTPAGE\n\
184
+            ldr x9, [x9, _value@GOTPAGEOFF]\n\
185
+            ldr w9, [x9]\n\
186
+            ret\n\
187
+\n"
188
+        ));
189
+    }
190
+    asm.push_str("        .subsections_via_symbols\n");
191
+
192
+    assemble(&asm, &text_obj).expect("assemble relocation worker text fixture");
193
+    assemble(
194
+        "\
195
+        .section __DATA,__data\n\
196
+        .globl _value\n\
197
+        .p2align 2\n\
198
+        _value:\n\
199
+            .long 11\n\
200
+\n\
201
+        .subsections_via_symbols\n",
202
+        &data_obj,
203
+    )
204
+    .expect("assemble relocation worker data fixture");
205
+
206
+    let inputs = vec![text_obj, data_obj];
207
+    let serial =
208
+        link_once_with_jobs(&inputs, &root, "reloc-workers-serial", Some(1)).expect("serial link");
209
+    let parallel = link_once_with_jobs(&inputs, &root, "reloc-workers-parallel", Some(8))
210
+        .expect("parallel link");
211
+    assert_eq!(
212
+        parallel, serial,
213
+        "parallel relocation workers changed final output bytes"
214
+    );
215
+
216
+    let _ = fs::remove_dir_all(root);
217
+}
218
+
219
+fn assert_repeated_links_identical(inputs: Vec<PathBuf>, root: &Path, label: &str) {
220
+    let baseline = link_once(&inputs, root, &format!("{label}-baseline"))
221
+        .expect("baseline deterministic link");
222
+    let serial = link_once_with_jobs(&inputs, root, &format!("{label}-serial"), Some(1))
223
+        .expect("single-worker deterministic link");
224
+    assert_eq!(
225
+        serial, baseline,
226
+        "{label}: single-worker link differed from default parallel link"
227
+    );
228
+    let run_count = determinism_run_count();
229
+    let jobs = determinism_jobs(run_count);
230
+    let queue = Arc::new(Mutex::new((0..run_count).collect::<VecDeque<_>>()));
231
+    let errors = Arc::new(Mutex::new(Vec::new()));
232
+
233
+    thread::scope(|scope| {
234
+        for _ in 0..jobs {
235
+            let queue = Arc::clone(&queue);
236
+            let errors = Arc::clone(&errors);
237
+            let baseline = baseline.clone();
238
+            let inputs = inputs.clone();
239
+            scope.spawn(move || loop {
240
+                let Some(index) = queue
241
+                    .lock()
242
+                    .expect("determinism queue mutex poisoned")
243
+                    .pop_front()
244
+                else {
245
+                    break;
246
+                };
247
+                match link_once(&inputs, root, &format!("{label}-run-{index:03}")) {
248
+                    Ok(bytes) if bytes == baseline => {}
249
+                    Ok(bytes) => errors
250
+                        .lock()
251
+                        .expect("determinism errors mutex poisoned")
252
+                        .push(format!(
253
+                            "run {index} differed: baseline={} bytes, output={} bytes",
254
+                            baseline.len(),
255
+                            bytes.len()
256
+                        )),
257
+                    Err(error) => errors
258
+                        .lock()
259
+                        .expect("determinism errors mutex poisoned")
260
+                        .push(format!("run {index} failed: {error}")),
261
+                }
262
+            });
263
+        }
264
+    });
265
+
266
+    let errors = errors
267
+        .lock()
268
+        .expect("determinism errors mutex poisoned")
269
+        .clone();
270
+    assert!(
271
+        errors.is_empty(),
272
+        "parallel deterministic links diverged:\n{}",
273
+        errors.join("\n")
274
+    );
275
+}
276
+
277
+fn link_once(inputs: &[PathBuf], root: &Path, run_name: &str) -> Result<Vec<u8>, String> {
278
+    link_once_with_jobs(inputs, root, run_name, None)
279
+}
280
+
281
+fn link_once_with_jobs(
282
+    inputs: &[PathBuf],
283
+    root: &Path,
284
+    run_name: &str,
285
+    jobs: Option<usize>,
286
+) -> Result<Vec<u8>, String> {
287
+    let dir = root.join(run_name);
288
+    fs::create_dir_all(&dir).map_err(|e| format!("create {}: {e}", dir.display()))?;
289
+    let out = dir.join("deterministic.out");
290
+    let opts = LinkOptions {
291
+        inputs: inputs.to_vec(),
292
+        output: Some(out.clone()),
293
+        kind: OutputKind::Executable,
294
+        jobs,
295
+        ..LinkOptions::default()
296
+    };
297
+    Linker::run(&opts).map_err(|e| format!("link {}: {e}", out.display()))?;
298
+    fs::read(&out).map_err(|e| format!("read {}: {e}", out.display()))
299
+}
300
+
301
+fn archive(objects: &[PathBuf], out: &Path) -> Result<(), String> {
302
+    let output = Command::new("libtool")
303
+        .arg("-static")
304
+        .arg("-o")
305
+        .arg(out)
306
+        .args(objects)
307
+        .output()
308
+        .map_err(|e| format!("spawn libtool: {e}"))?;
309
+    if !output.status.success() {
310
+        return Err(format!(
311
+            "libtool failed: {}",
312
+            String::from_utf8_lossy(&output.stderr)
313
+        ));
314
+    }
315
+    Ok(())
316
+}
317
+
318
+fn determinism_run_count() -> usize {
319
+    std::env::var("AFS_LD_DETERMINISM_RUNS")
320
+        .ok()
321
+        .and_then(|raw| raw.parse::<usize>().ok())
322
+        .filter(|runs| *runs > 0)
323
+        .unwrap_or(DEFAULT_RUNS)
324
+}
325
+
326
+fn determinism_jobs(run_count: usize) -> usize {
327
+    std::env::var("AFS_LD_DETERMINISM_JOBS")
328
+        .ok()
329
+        .and_then(|raw| raw.parse::<usize>().ok())
330
+        .filter(|jobs| *jobs > 0)
331
+        .unwrap_or_else(|| {
332
+            thread::available_parallelism()
333
+                .map(usize::from)
334
+                .unwrap_or(1)
335
+        })
336
+        .min(run_count)
337
+        .max(1)
338
+}
339
+
340
+fn unique_temp_dir(name: &str) -> Result<PathBuf, String> {
341
+    let stamp = SystemTime::now()
342
+        .duration_since(UNIX_EPOCH)
343
+        .map_err(|e| format!("clock error: {e}"))?
344
+        .as_nanos();
345
+    let dir = std::env::temp_dir().join(format!("afs-ld-{name}-{}-{stamp}", std::process::id()));
346
+    fs::create_dir_all(&dir).map_err(|e| format!("create {}: {e}", dir.display()))?;
347
+    Ok(dir)
348
+}
tests/diff_harness_tolerates_known_linkedit.rsadded
96 lines changed — click to load
@@ -0,0 +1,96 @@
1
+//! Tolerated-diff proof points for the parity harness.
2
+
3
+mod common;
4
+
5
+use common::harness::diff_macho;
6
+
7
+const MH_MAGIC_64: u32 = 0xFEEDFACF;
8
+const CPU_TYPE_ARM64: u32 = 0x0100_000C;
9
+const MH_EXECUTE: u32 = 2;
10
+const LC_ID_DYLIB: u32 = 0x0D;
11
+const LC_UUID: u32 = 0x1B;
12
+const LC_CODE_SIGNATURE: u32 = 0x1D;
13
+
14
+#[test]
15
+fn differing_uuid_bytes_are_tolerated() {
16
+    let ours = synth_uuid_image([0x11; 16]);
17
+    let theirs = synth_uuid_image([0x22; 16]);
18
+    let report = diff_macho(&ours, &theirs);
19
+    assert!(
20
+        report.is_clean(),
21
+        "UUID-only diff should be tolerated: {report:#?}"
22
+    );
23
+    assert_eq!(report.tolerated.len(), 1);
24
+}
25
+
26
+#[test]
27
+fn differing_code_signature_blob_bytes_are_tolerated() {
28
+    let ours = synth_code_signature_image([0xAA; 8]);
29
+    let theirs = synth_code_signature_image([0xBB; 8]);
30
+    let report = diff_macho(&ours, &theirs);
31
+    assert!(
32
+        report.is_clean(),
33
+        "code-signature-only diff should be tolerated: {report:#?}"
34
+    );
35
+    assert_eq!(report.tolerated.len(), 1);
36
+}
37
+
38
+#[test]
39
+fn differing_dylib_timestamps_are_tolerated() {
40
+    let ours = synth_dylib_image(2);
41
+    let theirs = synth_dylib_image(7);
42
+    let report = diff_macho(&ours, &theirs);
43
+    assert!(
44
+        report.is_clean(),
45
+        "dylib timestamp-only diff should be tolerated: {report:#?}"
46
+    );
47
+    assert_eq!(report.tolerated.len(), 1);
48
+}
49
+
50
+fn synth_uuid_image(uuid: [u8; 16]) -> Vec<u8> {
51
+    let mut out = Vec::new();
52
+    push_header(&mut out, 1, 24);
53
+    out.extend_from_slice(&LC_UUID.to_le_bytes());
54
+    out.extend_from_slice(&24u32.to_le_bytes());
55
+    out.extend_from_slice(&uuid);
56
+    out
57
+}
58
+
59
+fn synth_code_signature_image(blob: [u8; 8]) -> Vec<u8> {
60
+    let mut out = Vec::new();
61
+    let dataoff = 0x40u32;
62
+    let datasize = blob.len() as u32;
63
+    push_header(&mut out, 1, 16);
64
+    out.extend_from_slice(&LC_CODE_SIGNATURE.to_le_bytes());
65
+    out.extend_from_slice(&16u32.to_le_bytes());
66
+    out.extend_from_slice(&dataoff.to_le_bytes());
67
+    out.extend_from_slice(&datasize.to_le_bytes());
68
+    out.resize(dataoff as usize, 0);
69
+    out.extend_from_slice(&blob);
70
+    out
71
+}
72
+
73
+fn synth_dylib_image(timestamp: u32) -> Vec<u8> {
74
+    let mut out = Vec::new();
75
+    let cmdsize = 32u32;
76
+    push_header(&mut out, 1, cmdsize);
77
+    out.extend_from_slice(&LC_ID_DYLIB.to_le_bytes());
78
+    out.extend_from_slice(&cmdsize.to_le_bytes());
79
+    out.extend_from_slice(&24u32.to_le_bytes());
80
+    out.extend_from_slice(&timestamp.to_le_bytes());
81
+    out.extend_from_slice(&0u32.to_le_bytes());
82
+    out.extend_from_slice(&0u32.to_le_bytes());
83
+    out.extend_from_slice(b"x\0\0\0\0\0\0\0");
84
+    out
85
+}
86
+
87
+fn push_header(out: &mut Vec<u8>, ncmds: u32, sizeofcmds: u32) {
88
+    out.extend_from_slice(&MH_MAGIC_64.to_le_bytes());
89
+    out.extend_from_slice(&CPU_TYPE_ARM64.to_le_bytes());
90
+    out.extend_from_slice(&0u32.to_le_bytes());
91
+    out.extend_from_slice(&MH_EXECUTE.to_le_bytes());
92
+    out.extend_from_slice(&ncmds.to_le_bytes());
93
+    out.extend_from_slice(&sizeofcmds.to_le_bytes());
94
+    out.extend_from_slice(&0u32.to_le_bytes());
95
+    out.extend_from_slice(&0u32.to_le_bytes());
96
+}
tests/linker_run.rsmodified
8520 lines changed — click to load
@@ -18,7 +18,7 @@ use afs_ld::macho::constants::{
1818
     BIND_OPCODE_SET_SYMBOL_TRAILING_FLAGS_IMM, BIND_OPCODE_SET_TYPE_IMM,
1919
     BIND_SYMBOL_FLAGS_WEAK_IMPORT, DICE_KIND_JUMP_TABLE32, INDIRECT_SYMBOL_ABS,
2020
     INDIRECT_SYMBOL_LOCAL, LC_BUILD_VERSION, LC_DATA_IN_CODE, LC_DYLD_INFO_ONLY, LC_DYSYMTAB,
21
-    LC_FUNCTION_STARTS, LC_SEGMENT_64, LC_SYMTAB,
21
+    LC_FUNCTION_STARTS, LC_LINKER_OPTIMIZATION_HINT, LC_SEGMENT_64, LC_SYMTAB, N_PEXT,
2222
     REBASE_IMMEDIATE_MASK, REBASE_OPCODE_ADD_ADDR_IMM_SCALED, REBASE_OPCODE_ADD_ADDR_ULEB,
2323
     REBASE_OPCODE_DONE, REBASE_OPCODE_DO_REBASE_ADD_ADDR_ULEB, REBASE_OPCODE_DO_REBASE_IMM_TIMES,
2424
     REBASE_OPCODE_DO_REBASE_ULEB_TIMES, REBASE_OPCODE_DO_REBASE_ULEB_TIMES_SKIPPING_ULEB,
@@ -31,7 +31,7 @@ use afs_ld::macho::reader::{parse_commands, parse_header, u32_le, LoadCommand, S
3131
 use afs_ld::string_table::StringTable;
3232
 use afs_ld::symbol::{parse_nlist_table, SymKind};
3333
 use afs_ld::synth::unwind::decode_unwind_info;
34
-use afs_ld::{LinkError, LinkOptions, Linker, OutputKind};
34
+use afs_ld::{FrameworkSpec, LinkError, LinkOptions, Linker, OutputKind};
3535
 use common::harness::diff_macho;
3636
 
3737
 fn have_xcrun() -> bool {
@@ -220,6 +220,36 @@ fn output_section(bytes: &[u8], segname: &str, sectname: &str) -> Option<(u64, V
220220
     None
221221
 }
222222
 
223
+fn output_sections(bytes: &[u8], segname: &str, sectname: &str) -> Vec<(u64, Vec<u8>)> {
224
+    let Ok(header) = parse_header(bytes) else {
225
+        return Vec::new();
226
+    };
227
+    let Ok(commands) = parse_commands(&header, bytes) else {
228
+        return Vec::new();
229
+    };
230
+    let mut matches = Vec::new();
231
+    for cmd in commands {
232
+        if let LoadCommand::Segment64(seg) = cmd {
233
+            for section in seg.sections {
234
+                if section.segname_str() == segname && section.sectname_str() == sectname {
235
+                    let data = if section.offset == 0 {
236
+                        Vec::new()
237
+                    } else {
238
+                        let start = section.offset as usize;
239
+                        let end = start + section.size as usize;
240
+                        let Some(bytes) = bytes.get(start..end) else {
241
+                            continue;
242
+                        };
243
+                        bytes.to_vec()
244
+                    };
245
+                    matches.push((section.addr, data));
246
+                }
247
+            }
248
+        }
249
+    }
250
+    matches
251
+}
252
+
223253
 fn output_section_header(bytes: &[u8], segname: &str, sectname: &str) -> Option<Section64Header> {
224254
     let header = parse_header(bytes).ok()?;
225255
     let commands = parse_commands(&header, bytes).ok()?;
@@ -372,6 +402,13 @@ fn canonical_symbol_records(bytes: &[u8]) -> Vec<CanonicalSymbolRecord> {
372402
         .collect()
373403
 }
374404
 
405
+fn canonical_symbol_record_map(bytes: &[u8]) -> HashMap<String, CanonicalSymbolRecord> {
406
+    canonical_symbol_records(bytes)
407
+        .into_iter()
408
+        .map(|record| (record.name.clone(), record))
409
+        .collect()
410
+}
411
+
375412
 #[derive(Debug, Clone, PartialEq, Eq)]
376413
 enum CanonicalExportKind {
377414
     Regular(u64),
@@ -509,10 +546,16 @@ fn raw_linkedit_data_cmd(bytes: &[u8], expected_cmd: u32) -> (u32, u32) {
509546
     let header = parse_header(bytes).unwrap();
510547
     let commands = parse_commands(&header, bytes).unwrap();
511548
     for cmd in commands {
512
-        if let LoadCommand::Raw { cmd, data, .. } = cmd {
513
-            if cmd == expected_cmd {
549
+        match cmd {
550
+            LoadCommand::Raw { cmd, data, .. } if cmd == expected_cmd => {
514551
                 return (u32_le(&data[0..4]), u32_le(&data[4..8]));
515552
             }
553
+            LoadCommand::LinkerOptimizationHint(linkedit)
554
+                if expected_cmd == LC_LINKER_OPTIMIZATION_HINT =>
555
+            {
556
+                return (linkedit.dataoff, linkedit.datasize);
557
+            }
558
+            _ => {}
516559
         }
517560
     }
518561
     panic!("missing raw linkedit command 0x{expected_cmd:x}");
@@ -765,6 +808,18 @@ fn canonical_data_in_code(bytes: &[u8]) -> Vec<DataInCodeRecord> {
765808
         .collect()
766809
 }
767810
 
811
+fn has_loh_command(bytes: &[u8]) -> bool {
812
+    let header = parse_header(bytes).unwrap();
813
+    parse_commands(&header, bytes)
814
+        .unwrap()
815
+        .into_iter()
816
+        .any(|cmd| match cmd {
817
+            LoadCommand::LinkerOptimizationHint(_) => true,
818
+            LoadCommand::Raw { cmd, .. } => cmd == LC_LINKER_OPTIMIZATION_HINT,
819
+            _ => false,
820
+        })
821
+}
822
+
768823
 fn assert_strtab_within_five_percent(ours: &[u8], apple: &[u8]) {
769824
     let delta = ours.len().abs_diff(apple.len());
770825
     assert!(
@@ -1789,6 +1844,21 @@ fn read_insn(bytes: &[u8], start: usize) -> Result<u32, String> {
17891844
     Ok(u32::from_le_bytes([slice[0], slice[1], slice[2], slice[3]]))
17901845
 }
17911846
 
1847
+fn is_adrp(insn: u32) -> bool {
1848
+    (insn & 0x9f00_0000) == 0x9000_0000
1849
+}
1850
+
1851
+fn is_add_imm_64(insn: u32) -> bool {
1852
+    (insn & 0xffc0_0000) == 0x9100_0000
1853
+}
1854
+
1855
+fn is_ldr_literal(insn: u32) -> bool {
1856
+    matches!(
1857
+        insn & 0xff00_0000,
1858
+        0x1800_0000 | 0x5800_0000 | 0x1c00_0000 | 0x5c00_0000 | 0x9c00_0000
1859
+    )
1860
+}
1861
+
17921862
 fn sign_extend_26(value: i64) -> i64 {
17931863
     if value & (1 << 25) != 0 {
17941864
         value | !0x03ff_ffff
@@ -1911,6 +1981,304 @@ fn linker_run_emits_non_empty_executable_from_real_object() {
19111981
     let _ = fs::remove_file(apple_out);
19121982
 }
19131983
 
1984
+#[test]
1985
+fn linker_run_loh_executable_surfaces_match_apple_ld() {
1986
+    if !have_xcrun() || !have_xcrun_tool("ld") {
1987
+        eprintln!("skipping: xcrun as/ld unavailable");
1988
+        return;
1989
+    }
1990
+    let Some(sdk) = sdk_path() else {
1991
+        eprintln!("skipping: xcrun --show-sdk-path unavailable");
1992
+        return;
1993
+    };
1994
+    let Some(sdk_ver) = sdk_version() else {
1995
+        eprintln!("skipping: xcrun --show-sdk-version unavailable");
1996
+        return;
1997
+    };
1998
+
1999
+    let cases = [
2000
+        (
2001
+            "loh-apple-adrp-add-exec",
2002
+            r#"
2003
+                .section __TEXT,__text,regular,pure_instructions
2004
+                .globl _main
2005
+                .globl _target
2006
+                _main:
2007
+                Lloh0:
2008
+                    adrp x0, _target@PAGE
2009
+                Lloh1:
2010
+                    add x0, x0, _target@PAGEOFF
2011
+                    mov w0, #0
2012
+                    ret
2013
+                _target:
2014
+                    ret
2015
+                .loh AdrpAdd Lloh0, Lloh1
2016
+                .subsections_via_symbols
2017
+            "#,
2018
+        ),
2019
+        (
2020
+            "loh-apple-adrp-ldr-exec",
2021
+            r#"
2022
+                .section __TEXT,__text,regular,pure_instructions
2023
+                .globl _main
2024
+                .globl _target
2025
+                _main:
2026
+                Lloh0:
2027
+                    adrp x0, _target@PAGE
2028
+                Lloh1:
2029
+                    ldr x1, [x0, _target@PAGEOFF]
2030
+                    mov w0, #0
2031
+                    ret
2032
+                    .p2align 3
2033
+                _target:
2034
+                    .quad 0x1122334455667788
2035
+                .loh AdrpLdr Lloh0, Lloh1
2036
+                .subsections_via_symbols
2037
+            "#,
2038
+        ),
2039
+        (
2040
+            "loh-apple-adrp-ldr-got-ldr-exec",
2041
+            r#"
2042
+                .section __TEXT,__text,regular,pure_instructions
2043
+                .globl _main
2044
+                .globl _value
2045
+                _main:
2046
+                Lloh0:
2047
+                    adrp x8, _value@GOTPAGE
2048
+                Lloh1:
2049
+                    ldr x8, [x8, _value@GOTPAGEOFF]
2050
+                Lloh2:
2051
+                    ldr w0, [x8]
2052
+                    ret
2053
+
2054
+                .section __DATA,__data
2055
+                .p2align 2
2056
+                _value:
2057
+                    .long 7
2058
+                .loh AdrpLdrGotLdr Lloh0, Lloh1, Lloh2
2059
+                .subsections_via_symbols
2060
+            "#,
2061
+        ),
2062
+    ];
2063
+
2064
+    for (name, src) in cases {
2065
+        let obj = scratch(&format!("{name}.o"));
2066
+        let our_out = scratch(&format!("{name}-ours.out"));
2067
+        let apple_out = scratch(&format!("{name}-apple.out"));
2068
+        if let Err(e) = assemble(src, &obj) {
2069
+            eprintln!("skipping: assemble failed: {e}");
2070
+            let _ = fs::remove_file(obj);
2071
+            let _ = fs::remove_file(our_out);
2072
+            let _ = fs::remove_file(apple_out);
2073
+            return;
2074
+        }
2075
+
2076
+        let opts = LinkOptions {
2077
+            inputs: vec![obj.clone()],
2078
+            output: Some(our_out.clone()),
2079
+            kind: OutputKind::Executable,
2080
+            ..LinkOptions::default()
2081
+        };
2082
+        Linker::run(&opts).unwrap();
2083
+        apple_link_classic_lazy(&obj, &apple_out, "_main", &sdk, &sdk_ver).unwrap();
2084
+
2085
+        let our_bytes = fs::read(&our_out).unwrap();
2086
+        let apple_bytes = fs::read(&apple_out).unwrap();
2087
+        let (our_text_addr, our_text) = output_section(&our_bytes, "__TEXT", "__text").unwrap();
2088
+        let (apple_text_addr, apple_text) =
2089
+            output_section(&apple_bytes, "__TEXT", "__text").unwrap();
2090
+        assert_eq!(
2091
+            our_text.len(),
2092
+            apple_text.len(),
2093
+            "{name} text length drifted from Apple ld"
2094
+        );
2095
+        assert!(
2096
+            !has_loh_command(&our_bytes),
2097
+            "{name} should omit LC_LINKER_OPTIMIZATION_HINT"
2098
+        );
2099
+        assert!(
2100
+            !has_loh_command(&apple_bytes),
2101
+            "{name} Apple peer unexpectedly emitted LC_LINKER_OPTIMIZATION_HINT"
2102
+        );
2103
+        match name {
2104
+            "loh-apple-adrp-add-exec" => {
2105
+                let our_target = symbol_values(&our_bytes)["_target"];
2106
+                let apple_target = symbol_values(&apple_bytes)["_target"];
2107
+                let our_first = read_insn(&our_text, 0).unwrap();
2108
+                let our_second = read_insn(&our_text, 4).unwrap();
2109
+                let apple_first = read_insn(&apple_text, 0).unwrap();
2110
+                let apple_second = read_insn(&apple_text, 4).unwrap();
2111
+                assert!(is_adrp(our_first), "{name} should keep ADRP");
2112
+                assert!(is_add_imm_64(our_second), "{name} should keep ADD");
2113
+                assert!(is_adrp(apple_first), "{name} Apple peer should keep ADRP");
2114
+                assert!(
2115
+                    is_add_imm_64(apple_second),
2116
+                    "{name} Apple peer should keep ADD"
2117
+                );
2118
+                assert_eq!(
2119
+                    decode_page_reference(&our_text, our_text_addr, 0, &PageRefKind::Add).unwrap(),
2120
+                    our_target
2121
+                );
2122
+                assert_eq!(
2123
+                    decode_page_reference(&apple_text, apple_text_addr, 0, &PageRefKind::Add)
2124
+                        .unwrap(),
2125
+                    apple_target
2126
+                );
2127
+            }
2128
+            "loh-apple-adrp-ldr-exec" => {
2129
+                let our_target = symbol_values(&our_bytes)["_target"];
2130
+                let apple_target = symbol_values(&apple_bytes)["_target"];
2131
+                let our_first = read_insn(&our_text, 0).unwrap();
2132
+                let our_second = read_insn(&our_text, 4).unwrap();
2133
+                let apple_first = read_insn(&apple_text, 0).unwrap();
2134
+                let apple_second = read_insn(&apple_text, 4).unwrap();
2135
+                assert!(is_adrp(our_first), "{name} should keep ADRP");
2136
+                assert!(
2137
+                    !is_ldr_literal(our_second),
2138
+                    "{name} should keep pageoff LDR"
2139
+                );
2140
+                assert!(is_adrp(apple_first), "{name} Apple peer should keep ADRP");
2141
+                assert!(
2142
+                    !is_ldr_literal(apple_second),
2143
+                    "{name} Apple peer should keep pageoff LDR"
2144
+                );
2145
+                assert_eq!(
2146
+                    decode_page_reference(&our_text, our_text_addr, 0, &PageRefKind::Load).unwrap(),
2147
+                    our_target
2148
+                );
2149
+                assert_eq!(
2150
+                    decode_page_reference(&apple_text, apple_text_addr, 0, &PageRefKind::Load)
2151
+                        .unwrap(),
2152
+                    apple_target
2153
+                );
2154
+            }
2155
+            "loh-apple-adrp-ldr-got-ldr-exec" => {
2156
+                let our_target = symbol_values(&our_bytes)["_value"];
2157
+                let apple_target = symbol_values(&apple_bytes)["_value"];
2158
+                let our_first = read_insn(&our_text, 0).unwrap();
2159
+                let our_second = read_insn(&our_text, 4).unwrap();
2160
+                let our_third = read_insn(&our_text, 8).unwrap();
2161
+                let apple_first = read_insn(&apple_text, 0).unwrap();
2162
+                let apple_second = read_insn(&apple_text, 4).unwrap();
2163
+                let apple_third = read_insn(&apple_text, 8).unwrap();
2164
+                assert!(is_adrp(our_first), "{name} should keep ADRP");
2165
+                assert!(
2166
+                    is_add_imm_64(our_second),
2167
+                    "{name} should keep GOT-resolved ADD"
2168
+                );
2169
+                assert!(is_adrp(apple_first), "{name} Apple peer should keep ADRP");
2170
+                assert!(
2171
+                    is_add_imm_64(apple_second),
2172
+                    "{name} Apple peer should keep GOT-resolved ADD"
2173
+                );
2174
+                assert_eq!(our_third, apple_third, "{name} final load drifted");
2175
+                assert_eq!(
2176
+                    decode_page_reference(&our_text, our_text_addr, 0, &PageRefKind::Add).unwrap(),
2177
+                    our_target
2178
+                );
2179
+                assert_eq!(
2180
+                    decode_page_reference(&apple_text, apple_text_addr, 0, &PageRefKind::Add)
2181
+                        .unwrap(),
2182
+                    apple_target
2183
+                );
2184
+            }
2185
+            _ => unreachable!("unexpected LOH parity case"),
2186
+        }
2187
+
2188
+        let _ = fs::remove_file(obj);
2189
+        let _ = fs::remove_file(our_out);
2190
+        let _ = fs::remove_file(apple_out);
2191
+    }
2192
+}
2193
+
2194
+#[test]
2195
+fn linker_run_loh_dylib_surfaces_match_apple_ld() {
2196
+    if !have_xcrun() || !have_xcrun_tool("ld") {
2197
+        eprintln!("skipping: xcrun as/ld unavailable");
2198
+        return;
2199
+    }
2200
+    let Some(sdk) = sdk_path() else {
2201
+        eprintln!("skipping: xcrun --show-sdk-path unavailable");
2202
+        return;
2203
+    };
2204
+    let Some(sdk_ver) = sdk_version() else {
2205
+        eprintln!("skipping: xcrun --show-sdk-version unavailable");
2206
+        return;
2207
+    };
2208
+
2209
+    let obj = scratch("loh-apple-dylib.o");
2210
+    let our_out = scratch("loh-apple-ours.dylib");
2211
+    let apple_out = scratch("loh-apple-apple.dylib");
2212
+    let install_name = "@rpath/liblohprobe.dylib";
2213
+    let src = r#"
2214
+        .section __TEXT,__text,regular,pure_instructions
2215
+        .globl _loh_probe
2216
+        .globl _target
2217
+        _loh_probe:
2218
+        Lloh0:
2219
+            adrp x0, _target@PAGE
2220
+        Lloh1:
2221
+            add x0, x0, _target@PAGEOFF
2222
+            ret
2223
+        _target:
2224
+            ret
2225
+        .loh AdrpAdd Lloh0, Lloh1
2226
+        .subsections_via_symbols
2227
+    "#;
2228
+    if let Err(e) = assemble(src, &obj) {
2229
+        eprintln!("skipping: assemble failed: {e}");
2230
+        return;
2231
+    }
2232
+
2233
+    let opts = LinkOptions {
2234
+        inputs: vec![obj.clone()],
2235
+        output: Some(our_out.clone()),
2236
+        kind: OutputKind::Dylib,
2237
+        install_name: Some(install_name.into()),
2238
+        ..LinkOptions::default()
2239
+    };
2240
+    Linker::run(&opts).unwrap();
2241
+    apple_link_dylib_classic(&obj, &apple_out, install_name, &sdk, &sdk_ver).unwrap();
2242
+
2243
+    let our_bytes = fs::read(&our_out).unwrap();
2244
+    let apple_bytes = fs::read(&apple_out).unwrap();
2245
+    let (our_text_addr, our_text) = output_section(&our_bytes, "__TEXT", "__text").unwrap();
2246
+    let (apple_text_addr, apple_text) = output_section(&apple_bytes, "__TEXT", "__text").unwrap();
2247
+    let our_target = symbol_values(&our_bytes)["_target"];
2248
+    let apple_target = symbol_values(&apple_bytes)["_target"];
2249
+    let our_first = read_insn(&our_text, 0).unwrap();
2250
+    let our_second = read_insn(&our_text, 4).unwrap();
2251
+    let apple_first = read_insn(&apple_text, 0).unwrap();
2252
+    let apple_second = read_insn(&apple_text, 4).unwrap();
2253
+    assert!(is_adrp(our_first), "dylib LOH should keep ADRP");
2254
+    assert!(is_add_imm_64(our_second), "dylib LOH should keep ADD");
2255
+    assert!(is_adrp(apple_first), "Apple dylib LOH should keep ADRP");
2256
+    assert!(
2257
+        is_add_imm_64(apple_second),
2258
+        "Apple dylib LOH should keep ADD"
2259
+    );
2260
+    assert_eq!(
2261
+        decode_page_reference(&our_text, our_text_addr, 0, &PageRefKind::Add).unwrap(),
2262
+        our_target
2263
+    );
2264
+    assert_eq!(
2265
+        decode_page_reference(&apple_text, apple_text_addr, 0, &PageRefKind::Add).unwrap(),
2266
+        apple_target
2267
+    );
2268
+    assert!(
2269
+        !has_loh_command(&our_bytes),
2270
+        "dylib output should omit LC_LINKER_OPTIMIZATION_HINT"
2271
+    );
2272
+    assert!(
2273
+        !has_loh_command(&apple_bytes),
2274
+        "Apple dylib peer unexpectedly emitted LC_LINKER_OPTIMIZATION_HINT"
2275
+    );
2276
+
2277
+    let _ = fs::remove_file(obj);
2278
+    let _ = fs::remove_file(our_out);
2279
+    let _ = fs::remove_file(apple_out);
2280
+}
2281
+
19142282
 #[test]
19152283
 fn linker_run_emits_minimal_dylib_from_real_object() {
19162284
     if !have_xcrun() {
@@ -1971,58 +2339,376 @@ fn linker_run_emits_minimal_dylib_from_real_object() {
19712339
 }
19722340
 
19732341
 #[test]
1974
-fn dylib_export_surfaces_match_apple_ld() {
1975
-    if !have_xcrun() || !have_xcrun_tool("ld") {
1976
-        eprintln!("skipping: xcrun as/ld unavailable");
2342
+fn linker_run_uses_dylib_identity_flags() {
2343
+    if !have_xcrun() {
2344
+        eprintln!("skipping: xcrun as unavailable");
19772345
         return;
19782346
     }
1979
-    let Some(sdk) = sdk_path() else {
1980
-        eprintln!("skipping: xcrun --show-sdk-path unavailable");
1981
-        return;
1982
-    };
1983
-    let Some(sdk_ver) = sdk_version() else {
1984
-        eprintln!("skipping: xcrun --show-sdk-version unavailable");
2347
+
2348
+    let obj = scratch("libmeta.o");
2349
+    let out = scratch("libmeta.dylib");
2350
+    let src = r#"
2351
+        .section __TEXT,__text,regular,pure_instructions
2352
+        .globl _exported
2353
+        _exported:
2354
+            ret
2355
+        .subsections_via_symbols
2356
+    "#;
2357
+    if let Err(e) = assemble(src, &obj) {
2358
+        eprintln!("skipping: assemble failed: {e}");
19852359
         return;
1986
-    };
2360
+    }
19872361
 
1988
-    let case = ExportParityCase {
1989
-        name: "export-parity",
1990
-        src: r#"
1991
-            .section __TEXT,__text,regular,pure_instructions
1992
-            .globl _exported
1993
-            _exported:
1994
-                ret
1995
-            .subsections_via_symbols
1996
-        "#,
2362
+    let opts = LinkOptions {
2363
+        inputs: vec![obj.clone()],
2364
+        output: Some(out.clone()),
2365
+        kind: OutputKind::Dylib,
2366
+        install_name: Some("@rpath/libmeta_custom.dylib".into()),
2367
+        current_version: Some((2 << 16) | (3 << 8) | 4),
2368
+        compatibility_version: Some((1 << 16) | (5 << 8)),
2369
+        rpaths: vec!["@loader_path/../lib".into()],
2370
+        ..LinkOptions::default()
19972371
     };
1998
-    assert_dylib_export_case_matches_apple_ld(&case, &sdk, &sdk_ver).unwrap();
2372
+    Linker::run(&opts).unwrap();
2373
+
2374
+    let bytes = fs::read(&out).unwrap();
2375
+    let header = parse_header(&bytes).unwrap();
2376
+    let commands = parse_commands(&header, &bytes).unwrap();
2377
+    let id_dylib = commands
2378
+        .iter()
2379
+        .find_map(|cmd| match cmd {
2380
+            LoadCommand::Dylib(cmd) if cmd.cmd == afs_ld::macho::constants::LC_ID_DYLIB => {
2381
+                Some(cmd.clone())
2382
+            }
2383
+            _ => None,
2384
+        })
2385
+        .expect("missing LC_ID_DYLIB");
2386
+    assert_eq!(id_dylib.name, "@rpath/libmeta_custom.dylib");
2387
+    assert_eq!(id_dylib.current_version, (2 << 16) | (3 << 8) | 4);
2388
+    assert_eq!(id_dylib.compatibility_version, (1 << 16) | (5 << 8));
2389
+    assert!(commands
2390
+        .iter()
2391
+        .any(|cmd| matches!(cmd, LoadCommand::Rpath(r) if r.path == "@loader_path/../lib")));
2392
+
2393
+    let _ = fs::remove_file(obj);
2394
+    let _ = fs::remove_file(out);
19992395
 }
20002396
 
20012397
 #[test]
2002
-fn dylib_export_surfaces_match_apple_ld_with_shared_prefixes() {
2003
-    if !have_xcrun() || !have_xcrun_tool("ld") {
2004
-        eprintln!("skipping: xcrun as/ld unavailable");
2398
+fn linker_run_honors_exported_symbol_filters_like_ld() {
2399
+    if !have_xcrun() {
2400
+        eprintln!("skipping: xcrun as unavailable");
20052401
         return;
20062402
     }
2007
-    let Some(sdk) = sdk_path() else {
2008
-        eprintln!("skipping: xcrun --show-sdk-path unavailable");
2009
-        return;
2010
-    };
2011
-    let Some(sdk_ver) = sdk_version() else {
2012
-        eprintln!("skipping: xcrun --show-sdk-version unavailable");
2403
+
2404
+    let obj = scratch("export-filter.o");
2405
+    let our_out = scratch("export-filter-ours.dylib");
2406
+    let apple_out = scratch("export-filter-apple.dylib");
2407
+    let list_path = scratch("export-filter-exports.txt");
2408
+    let src = r#"
2409
+        .section __TEXT,__text,regular,pure_instructions
2410
+        .globl _alpha
2411
+        .globl _beta
2412
+        .globl _gamma
2413
+        _alpha:
2414
+            ret
2415
+        _beta:
2416
+            ret
2417
+        _gamma:
2418
+            ret
2419
+        .subsections_via_symbols
2420
+    "#;
2421
+    if let Err(e) = assemble(src, &obj) {
2422
+        eprintln!("skipping: assemble failed: {e}");
20132423
         return;
2424
+    }
2425
+    fs::write(&list_path, "_bet?\n").unwrap();
2426
+
2427
+    let opts = LinkOptions {
2428
+        inputs: vec![obj.clone()],
2429
+        output: Some(our_out.clone()),
2430
+        kind: OutputKind::Dylib,
2431
+        exported_symbols: vec!["_alpha".into()],
2432
+        exported_symbols_lists: vec![list_path.clone()],
2433
+        ..LinkOptions::default()
20142434
     };
2435
+    Linker::run(&opts).unwrap();
20152436
 
2016
-    let case = ExportParityCase {
2017
-        name: "export-prefix-parity",
2018
-        src: r#"
2019
-            .section __TEXT,__text,regular,pure_instructions
2020
-            .globl _alpha
2021
-            _alpha:
2022
-                ret
2023
-            .globl _alphabet
2024
-            _alphabet:
2025
-                ret
2437
+    let apple = Command::new("xcrun")
2438
+        .args(["clang", "-arch", "arm64", "-dynamiclib"])
2439
+        .arg(&obj)
2440
+        .arg("-o")
2441
+        .arg(&apple_out)
2442
+        .arg("-Wl,-exported_symbol,_alpha")
2443
+        .arg(format!(
2444
+            "-Wl,-exported_symbols_list,{}",
2445
+            list_path.display()
2446
+        ))
2447
+        .output()
2448
+        .unwrap();
2449
+    assert!(
2450
+        apple.status.success(),
2451
+        "xcrun ld failed: {}",
2452
+        String::from_utf8_lossy(&apple.stderr)
2453
+    );
2454
+
2455
+    let our_bytes = fs::read(&our_out).unwrap();
2456
+    let apple_bytes = fs::read(&apple_out).unwrap();
2457
+    assert_eq!(
2458
+        canonical_export_records(&our_bytes),
2459
+        canonical_export_records(&apple_bytes)
2460
+    );
2461
+    assert_eq!(
2462
+        dyld_info_export_names(&our_bytes).unwrap(),
2463
+        vec!["_alpha".to_string(), "_beta".to_string()]
2464
+    );
2465
+    assert_eq!(
2466
+        canonical_symbol_record_map(&our_bytes),
2467
+        canonical_symbol_record_map(&apple_bytes)
2468
+    );
2469
+
2470
+    let our_symbols = canonical_symbol_record_map(&our_bytes);
2471
+    let gamma = our_symbols.get("_gamma").expect("missing _gamma");
2472
+    assert_ne!(
2473
+        gamma.n_type & N_PEXT,
2474
+        0,
2475
+        "expected _gamma to be private extern"
2476
+    );
2477
+
2478
+    let _ = fs::remove_file(obj);
2479
+    let _ = fs::remove_file(our_out);
2480
+    let _ = fs::remove_file(apple_out);
2481
+    let _ = fs::remove_file(list_path);
2482
+}
2483
+
2484
+#[test]
2485
+fn linker_run_honors_unexported_symbol_filters_like_ld() {
2486
+    if !have_xcrun() {
2487
+        eprintln!("skipping: xcrun as unavailable");
2488
+        return;
2489
+    }
2490
+
2491
+    let obj = scratch("unexport-filter.o");
2492
+    let our_out = scratch("unexport-filter-ours.dylib");
2493
+    let apple_out = scratch("unexport-filter-apple.dylib");
2494
+    let list_path = scratch("unexport-filter-hidden.txt");
2495
+    let src = r#"
2496
+        .section __TEXT,__text,regular,pure_instructions
2497
+        .globl _alpha
2498
+        .globl _beta
2499
+        .globl _gamma
2500
+        _alpha:
2501
+            ret
2502
+        _beta:
2503
+            ret
2504
+        _gamma:
2505
+            ret
2506
+        .subsections_via_symbols
2507
+    "#;
2508
+    if let Err(e) = assemble(src, &obj) {
2509
+        eprintln!("skipping: assemble failed: {e}");
2510
+        return;
2511
+    }
2512
+    fs::write(&list_path, "_bet?\n").unwrap();
2513
+
2514
+    let opts = LinkOptions {
2515
+        inputs: vec![obj.clone()],
2516
+        output: Some(our_out.clone()),
2517
+        kind: OutputKind::Dylib,
2518
+        unexported_symbols: vec!["_gamma".into()],
2519
+        unexported_symbols_lists: vec![list_path.clone()],
2520
+        ..LinkOptions::default()
2521
+    };
2522
+    Linker::run(&opts).unwrap();
2523
+
2524
+    let apple = Command::new("xcrun")
2525
+        .args(["clang", "-arch", "arm64", "-dynamiclib"])
2526
+        .arg(&obj)
2527
+        .arg("-o")
2528
+        .arg(&apple_out)
2529
+        .arg("-Wl,-unexported_symbol,_gamma")
2530
+        .arg(format!(
2531
+            "-Wl,-unexported_symbols_list,{}",
2532
+            list_path.display()
2533
+        ))
2534
+        .output()
2535
+        .unwrap();
2536
+    assert!(
2537
+        apple.status.success(),
2538
+        "xcrun ld failed: {}",
2539
+        String::from_utf8_lossy(&apple.stderr)
2540
+    );
2541
+
2542
+    let our_bytes = fs::read(&our_out).unwrap();
2543
+    let apple_bytes = fs::read(&apple_out).unwrap();
2544
+    assert_eq!(
2545
+        canonical_export_records(&our_bytes),
2546
+        canonical_export_records(&apple_bytes)
2547
+    );
2548
+    assert_eq!(
2549
+        dyld_info_export_names(&our_bytes).unwrap(),
2550
+        vec!["_alpha".to_string()]
2551
+    );
2552
+    assert_eq!(
2553
+        canonical_symbol_record_map(&our_bytes),
2554
+        canonical_symbol_record_map(&apple_bytes)
2555
+    );
2556
+
2557
+    let our_symbols = canonical_symbol_record_map(&our_bytes);
2558
+    for name in ["_beta", "_gamma"] {
2559
+        let record = our_symbols
2560
+            .get(name)
2561
+            .unwrap_or_else(|| panic!("missing {name}"));
2562
+        assert_ne!(
2563
+            record.n_type & N_PEXT,
2564
+            0,
2565
+            "expected {name} to be private extern"
2566
+        );
2567
+    }
2568
+
2569
+    let _ = fs::remove_file(obj);
2570
+    let _ = fs::remove_file(our_out);
2571
+    let _ = fs::remove_file(apple_out);
2572
+    let _ = fs::remove_file(list_path);
2573
+}
2574
+
2575
+#[test]
2576
+fn linker_run_loads_minimal_dylib_via_dlopen() {
2577
+    if !have_xcrun() || !have_tool("codesign") {
2578
+        eprintln!("skipping: xcrun clang/as or codesign unavailable");
2579
+        return;
2580
+    }
2581
+
2582
+    let obj = scratch("libfoo_add.o");
2583
+    let out = scratch("libfoo_add.dylib");
2584
+    let caller_src = scratch("libfoo_add-caller.c");
2585
+    let caller = scratch("libfoo_add-caller.out");
2586
+    let src = r#"
2587
+        .section __TEXT,__text,regular,pure_instructions
2588
+        .globl _foo_add
2589
+        _foo_add:
2590
+            add w0, w0, w1
2591
+            ret
2592
+        .subsections_via_symbols
2593
+    "#;
2594
+    if let Err(e) = assemble(src, &obj) {
2595
+        eprintln!("skipping: assemble failed: {e}");
2596
+        return;
2597
+    }
2598
+
2599
+    let opts = LinkOptions {
2600
+        inputs: vec![obj.clone()],
2601
+        output: Some(out.clone()),
2602
+        kind: OutputKind::Dylib,
2603
+        ..LinkOptions::default()
2604
+    };
2605
+    Linker::run(&opts).unwrap();
2606
+
2607
+    let verify = Command::new("codesign")
2608
+        .arg("-v")
2609
+        .arg(&out)
2610
+        .output()
2611
+        .unwrap();
2612
+    assert!(
2613
+        verify.status.success(),
2614
+        "codesign verify failed: {}",
2615
+        String::from_utf8_lossy(&verify.stderr)
2616
+    );
2617
+
2618
+    fs::write(
2619
+        &caller_src,
2620
+        r#"
2621
+            #include <dlfcn.h>
2622
+            typedef int (*foo_add_fn)(int, int);
2623
+            int main(int argc, char **argv) {
2624
+                if (argc != 2) return 10;
2625
+                void *handle = dlopen(argv[1], RTLD_NOW);
2626
+                if (!handle) return 11;
2627
+                foo_add_fn fn = (foo_add_fn)dlsym(handle, "foo_add");
2628
+                if (!fn) return 12;
2629
+                int value = fn(2, 3);
2630
+                dlclose(handle);
2631
+                return value == 5 ? 0 : 1;
2632
+            }
2633
+        "#,
2634
+    )
2635
+    .unwrap();
2636
+
2637
+    let output = Command::new("xcrun")
2638
+        .args(["--sdk", "macosx", "clang", "-arch", "arm64"])
2639
+        .arg(&caller_src)
2640
+        .arg("-o")
2641
+        .arg(&caller)
2642
+        .output()
2643
+        .unwrap();
2644
+    assert!(
2645
+        output.status.success(),
2646
+        "xcrun clang caller failed: {}",
2647
+        String::from_utf8_lossy(&output.stderr)
2648
+    );
2649
+
2650
+    let status = Command::new(&caller).arg(&out).status().unwrap();
2651
+    assert_eq!(status.code(), Some(0), "expected dlopen caller to exit 0");
2652
+
2653
+    let _ = fs::remove_file(obj);
2654
+    let _ = fs::remove_file(out);
2655
+    let _ = fs::remove_file(caller_src);
2656
+    let _ = fs::remove_file(caller);
2657
+}
2658
+
2659
+#[test]
2660
+fn dylib_export_surfaces_match_apple_ld() {
2661
+    if !have_xcrun() || !have_xcrun_tool("ld") {
2662
+        eprintln!("skipping: xcrun as/ld unavailable");
2663
+        return;
2664
+    }
2665
+    let Some(sdk) = sdk_path() else {
2666
+        eprintln!("skipping: xcrun --show-sdk-path unavailable");
2667
+        return;
2668
+    };
2669
+    let Some(sdk_ver) = sdk_version() else {
2670
+        eprintln!("skipping: xcrun --show-sdk-version unavailable");
2671
+        return;
2672
+    };
2673
+
2674
+    let case = ExportParityCase {
2675
+        name: "export-parity",
2676
+        src: r#"
2677
+            .section __TEXT,__text,regular,pure_instructions
2678
+            .globl _exported
2679
+            _exported:
2680
+                ret
2681
+            .subsections_via_symbols
2682
+        "#,
2683
+    };
2684
+    assert_dylib_export_case_matches_apple_ld(&case, &sdk, &sdk_ver).unwrap();
2685
+}
2686
+
2687
+#[test]
2688
+fn dylib_export_surfaces_match_apple_ld_with_shared_prefixes() {
2689
+    if !have_xcrun() || !have_xcrun_tool("ld") {
2690
+        eprintln!("skipping: xcrun as/ld unavailable");
2691
+        return;
2692
+    }
2693
+    let Some(sdk) = sdk_path() else {
2694
+        eprintln!("skipping: xcrun --show-sdk-path unavailable");
2695
+        return;
2696
+    };
2697
+    let Some(sdk_ver) = sdk_version() else {
2698
+        eprintln!("skipping: xcrun --show-sdk-version unavailable");
2699
+        return;
2700
+    };
2701
+
2702
+    let case = ExportParityCase {
2703
+        name: "export-prefix-parity",
2704
+        src: r#"
2705
+            .section __TEXT,__text,regular,pure_instructions
2706
+            .globl _alpha
2707
+            _alpha:
2708
+                ret
2709
+            .globl _alphabet
2710
+            _alphabet:
2711
+                ret
20262712
             .globl _alphanumeric
20272713
             _alphanumeric:
20282714
                 ret
@@ -2199,6 +2885,57 @@ fn linker_run_reports_unresolved_symbol() {
21992885
     let _ = fs::remove_file(obj);
22002886
 }
22012887
 
2888
+#[test]
2889
+fn linker_run_promotes_unresolved_symbol_to_dynamic_lookup() {
2890
+    if !have_xcrun() {
2891
+        eprintln!("skipping: xcrun as unavailable");
2892
+        return;
2893
+    }
2894
+
2895
+    let obj = scratch("missing-dynamic.o");
2896
+    let out = scratch("missing-dynamic.out");
2897
+    let src = r#"
2898
+        .section __TEXT,__text,regular,pure_instructions
2899
+        .globl _main
2900
+        _main:
2901
+            mov w0, #0
2902
+            ret
2903
+        .section __DATA,__data
2904
+        .p2align 3
2905
+        .globl _missing_slot
2906
+        _missing_slot:
2907
+            .quad _missing
2908
+        .subsections_via_symbols
2909
+    "#;
2910
+    if let Err(e) = assemble(src, &obj) {
2911
+        eprintln!("skipping: assemble failed: {e}");
2912
+        return;
2913
+    }
2914
+
2915
+    let opts = LinkOptions {
2916
+        inputs: vec![obj.clone()],
2917
+        output: Some(out.clone()),
2918
+        kind: OutputKind::Executable,
2919
+        undefined_treatment: afs_ld::resolve::UndefinedTreatment::DynamicLookup,
2920
+        ..LinkOptions::default()
2921
+    };
2922
+    Linker::run(&opts).unwrap();
2923
+
2924
+    let bytes = fs::read(&out).unwrap();
2925
+    let bind_records = decode_bind_records(&bytes, false).unwrap();
2926
+    assert!(
2927
+        bind_records
2928
+            .iter()
2929
+            .any(|record| record.symbol == "_missing" && record.ordinal == 0xFFFE),
2930
+        "expected flat-lookup bind for _missing, got {bind_records:?}"
2931
+    );
2932
+    let (_, _, undefs) = symbol_partition_names(&bytes);
2933
+    assert_eq!(undefs, vec!["_missing".to_string()]);
2934
+
2935
+    let _ = fs::remove_file(obj);
2936
+    let _ = fs::remove_file(out);
2937
+}
2938
+
22022939
 #[test]
22032940
 fn linker_run_reports_duplicate_from_fetched_archive_member() {
22042941
     if !have_xcrun() {
@@ -2348,13 +3085,13 @@ fn fetched_archive_member_undefined_reports_member_referrer() {
23483085
 }
23493086
 
23503087
 #[test]
2351
-fn linker_run_carries_tbd_inputs_into_load_commands() {
2352
-    if !have_xcrun() {
2353
-        eprintln!("skipping: xcrun unavailable");
3088
+fn linker_run_all_load_pulls_entry_from_archive() {
3089
+    if !have_xcrun() || !have_tool("codesign") {
3090
+        eprintln!("skipping: xcrun as or codesign unavailable");
23543091
         return;
23553092
     }
23563093
     let Some(sdk) = sdk_path() else {
2357
-        eprintln!("skipping: xcrun --show-sdk-path unavailable");
3094
+        eprintln!("skipping: no macOS SDK path");
23583095
         return;
23593096
     };
23603097
     let tbd = PathBuf::from(format!("{sdk}/usr/lib/libSystem.tbd"));
@@ -2363,102 +3100,160 @@ fn linker_run_carries_tbd_inputs_into_load_commands() {
23633100
         return;
23643101
     }
23653102
 
2366
-    let obj = scratch("tbd-main.o");
2367
-    let out = scratch("tbd-a.out");
3103
+    let member_obj = scratch("all-load-main.o");
3104
+    let archive = scratch("all-load-main.a");
3105
+    let out = scratch("all-load-main.out");
23683106
     let src = r#"
23693107
         .section __TEXT,__text,regular,pure_instructions
23703108
         .globl _main
23713109
         _main:
2372
-            mov x0, #0
3110
+            mov w0, #7
23733111
             ret
23743112
         .subsections_via_symbols
23753113
     "#;
2376
-    if let Err(e) = assemble(src, &obj) {
3114
+    if let Err(e) = assemble(src, &member_obj) {
23773115
         eprintln!("skipping: assemble failed: {e}");
23783116
         return;
23793117
     }
3118
+    let ar = Command::new("ar")
3119
+        .arg("rcs")
3120
+        .arg(&archive)
3121
+        .arg(&member_obj)
3122
+        .output()
3123
+        .unwrap();
3124
+    if !ar.status.success() {
3125
+        eprintln!(
3126
+            "skipping: ar failed: {}",
3127
+            String::from_utf8_lossy(&ar.stderr)
3128
+        );
3129
+        return;
3130
+    }
23803131
 
23813132
     let opts = LinkOptions {
2382
-        inputs: vec![obj.clone(), tbd.clone()],
3133
+        inputs: vec![archive.clone(), tbd],
23833134
         output: Some(out.clone()),
23843135
         kind: OutputKind::Executable,
3136
+        all_load: true,
23853137
         ..LinkOptions::default()
23863138
     };
23873139
     Linker::run(&opts).unwrap();
23883140
 
2389
-    let bytes = fs::read(&out).unwrap();
2390
-    let header = parse_header(&bytes).unwrap();
2391
-    let commands = parse_commands(&header, &bytes).unwrap();
2392
-    assert!(
2393
-        commands.iter().any(|cmd| matches!(
2394
-            cmd,
2395
-            LoadCommand::Dylib(d) if d.cmd == afs_ld::macho::constants::LC_LOAD_DYLIB
2396
-        )),
2397
-        "expected at least one LC_LOAD_DYLIB in output"
3141
+    let verify = Command::new("codesign")
3142
+        .arg("-v")
3143
+        .arg(&out)
3144
+        .output()
3145
+        .unwrap();
3146
+    assert!(
3147
+        verify.status.success(),
3148
+        "codesign verify failed: {}",
3149
+        String::from_utf8_lossy(&verify.stderr)
3150
+    );
3151
+    let status = Command::new(&out).status().unwrap();
3152
+    assert_eq!(
3153
+        status.code(),
3154
+        Some(7),
3155
+        "expected all-load executable to exit 7"
23983156
     );
23993157
 
2400
-    let _ = fs::remove_file(obj);
3158
+    let _ = fs::remove_file(member_obj);
3159
+    let _ = fs::remove_file(archive);
24013160
     let _ = fs::remove_file(out);
24023161
 }
24033162
 
24043163
 #[test]
2405
-fn linker_run_handles_non_standard_segment_without_panicking() {
2406
-    if !have_xcrun() {
2407
-        eprintln!("skipping: xcrun as unavailable");
3164
+fn linker_run_force_load_pulls_entry_from_archive() {
3165
+    if !have_xcrun() || !have_tool("codesign") {
3166
+        eprintln!("skipping: xcrun as or codesign unavailable");
3167
+        return;
3168
+    }
3169
+    let Some(sdk) = sdk_path() else {
3170
+        eprintln!("skipping: no macOS SDK path");
3171
+        return;
3172
+    };
3173
+    let tbd = PathBuf::from(format!("{sdk}/usr/lib/libSystem.tbd"));
3174
+    if !tbd.exists() {
3175
+        eprintln!("skipping: no libSystem.tbd at {}", tbd.display());
24083176
         return;
24093177
     }
24103178
 
2411
-    let obj = scratch("custom-segment.o");
2412
-    let out = scratch("custom-segment.out");
3179
+    let member_obj = scratch("force-load-main.o");
3180
+    let archive = scratch("force-load-main.a");
3181
+    let out = scratch("force-load-main.out");
24133182
     let src = r#"
2414
-        .section __FOO,__bar
2415
-        .globl _custom
2416
-        _custom:
2417
-            .quad 1
3183
+        .section __TEXT,__text,regular,pure_instructions
3184
+        .globl _main
3185
+        _main:
3186
+            mov w0, #9
3187
+            ret
24183188
         .subsections_via_symbols
24193189
     "#;
2420
-    if let Err(e) = assemble(src, &obj) {
3190
+    if let Err(e) = assemble(src, &member_obj) {
24213191
         eprintln!("skipping: assemble failed: {e}");
24223192
         return;
24233193
     }
3194
+    let ar = Command::new("ar")
3195
+        .arg("rcs")
3196
+        .arg(&archive)
3197
+        .arg(&member_obj)
3198
+        .output()
3199
+        .unwrap();
3200
+    if !ar.status.success() {
3201
+        eprintln!(
3202
+            "skipping: ar failed: {}",
3203
+            String::from_utf8_lossy(&ar.stderr)
3204
+        );
3205
+        return;
3206
+    }
24243207
 
24253208
     let opts = LinkOptions {
2426
-        inputs: vec![obj.clone()],
3209
+        inputs: vec![archive.clone(), tbd],
24273210
         output: Some(out.clone()),
24283211
         kind: OutputKind::Executable,
3212
+        force_load_archives: vec![archive.clone()],
24293213
         ..LinkOptions::default()
24303214
     };
24313215
     Linker::run(&opts).unwrap();
24323216
 
2433
-    let bytes = fs::read(&out).unwrap();
2434
-    let header = parse_header(&bytes).unwrap();
2435
-    let commands = parse_commands(&header, &bytes).unwrap();
2436
-    assert!(commands.iter().any(|cmd| match cmd {
2437
-        LoadCommand::Segment64(seg) => seg.segname_str() == "__FOO",
2438
-        _ => false,
2439
-    }));
3217
+    let verify = Command::new("codesign")
3218
+        .arg("-v")
3219
+        .arg(&out)
3220
+        .output()
3221
+        .unwrap();
3222
+    assert!(
3223
+        verify.status.success(),
3224
+        "codesign verify failed: {}",
3225
+        String::from_utf8_lossy(&verify.stderr)
3226
+    );
3227
+    let status = Command::new(&out).status().unwrap();
3228
+    assert_eq!(
3229
+        status.code(),
3230
+        Some(9),
3231
+        "expected force-load executable to exit 9"
3232
+    );
24403233
 
2441
-    let _ = fs::remove_file(obj);
3234
+    let _ = fs::remove_file(member_obj);
3235
+    let _ = fs::remove_file(archive);
24423236
     let _ = fs::remove_file(out);
24433237
 }
24443238
 
24453239
 #[test]
2446
-fn linker_run_uses_requested_entry_symbol() {
2447
-    if !have_xcrun() {
2448
-        eprintln!("skipping: xcrun as unavailable");
3240
+fn linker_run_resolves_lsystem_via_syslibroot() {
3241
+    if !have_xcrun() || !have_tool("codesign") {
3242
+        eprintln!("skipping: xcrun as or codesign unavailable");
24493243
         return;
24503244
     }
3245
+    let Some(sdk) = sdk_path() else {
3246
+        eprintln!("skipping: no macOS SDK path");
3247
+        return;
3248
+    };
24513249
 
2452
-    let obj = scratch("entry.o");
2453
-    let out = scratch("entry.out");
3250
+    let obj = scratch("lsystem-main.o");
3251
+    let out = scratch("lsystem-main.out");
24543252
     let src = r#"
24553253
         .section __TEXT,__text,regular,pure_instructions
24563254
         .globl _main
24573255
         _main:
2458
-            ret
2459
-        .globl _alt
2460
-        _alt:
2461
-            mov x0, #1
3256
+            mov w0, #0
24623257
             ret
24633258
         .subsections_via_symbols
24643259
     "#;
@@ -2469,41 +3264,37 @@ fn linker_run_uses_requested_entry_symbol() {
24693264
 
24703265
     let opts = LinkOptions {
24713266
         inputs: vec![obj.clone()],
3267
+        library_names: vec!["System".into()],
3268
+        syslibroot: Some(PathBuf::from(&sdk)),
24723269
         output: Some(out.clone()),
2473
-        entry: Some("_alt".into()),
24743270
         kind: OutputKind::Executable,
24753271
         ..LinkOptions::default()
24763272
     };
24773273
     Linker::run(&opts).unwrap();
24783274
 
24793275
     let bytes = fs::read(&out).unwrap();
2480
-    let header = parse_header(&bytes).unwrap();
2481
-    let commands = parse_commands(&header, &bytes).unwrap();
2482
-    let mut text_offset = None;
2483
-    let mut main_entryoff = None;
2484
-    for cmd in commands {
2485
-        match cmd {
2486
-            LoadCommand::Segment64(seg) => {
2487
-                for section in seg.sections {
2488
-                    if section.sectname_str() == "__text" {
2489
-                        text_offset = Some(section.offset as u64);
2490
-                    }
2491
-                }
2492
-            }
2493
-            LoadCommand::Raw { cmd, data, .. } if cmd == afs_ld::macho::constants::LC_MAIN => {
2494
-                let mut buf = [0u8; 8];
2495
-                buf.copy_from_slice(&data[0..8]);
2496
-                main_entryoff = Some(u64::from_le_bytes(buf));
2497
-            }
2498
-            _ => {}
2499
-        }
2500
-    }
2501
-
2502
-    let text_offset = text_offset.expect("text section offset");
2503
-    let main_entryoff = main_entryoff.expect("LC_MAIN entryoff");
3276
+    let dylibs = load_dylib_names(&bytes).unwrap();
25043277
     assert!(
2505
-        main_entryoff > text_offset,
2506
-        "expected custom entry to land after start of __text: text={text_offset}, entry={main_entryoff}"
3278
+        dylibs
3279
+            .iter()
3280
+            .any(|name| name == "/usr/lib/libSystem.B.dylib"),
3281
+        "expected libSystem load command, got {dylibs:?}"
3282
+    );
3283
+    let verify = Command::new("codesign")
3284
+        .arg("-v")
3285
+        .arg(&out)
3286
+        .output()
3287
+        .unwrap();
3288
+    assert!(
3289
+        verify.status.success(),
3290
+        "codesign verify failed: {}",
3291
+        String::from_utf8_lossy(&verify.stderr)
3292
+    );
3293
+    let status = Command::new(&out).status().unwrap();
3294
+    assert_eq!(
3295
+        status.code(),
3296
+        Some(0),
3297
+        "expected executable linked via -lSystem to exit 0"
25073298
     );
25083299
 
25093300
     let _ = fs::remove_file(obj);
@@ -2511,20 +3302,27 @@ fn linker_run_uses_requested_entry_symbol() {
25113302
 }
25123303
 
25133304
 #[test]
2514
-fn linker_run_defaults_entry_to_main_symbol() {
3305
+fn linker_run_resolves_framework_via_syslibroot() {
25153306
     if !have_xcrun() {
2516
-        eprintln!("skipping: xcrun as unavailable");
3307
+        eprintln!("skipping: xcrun unavailable");
3308
+        return;
3309
+    }
3310
+    let Some(sdk) = sdk_path() else {
3311
+        eprintln!("skipping: xcrun --show-sdk-path unavailable");
3312
+        return;
3313
+    };
3314
+    let metal = PathBuf::from(format!(
3315
+        "{sdk}/System/Library/Frameworks/Metal.framework/Metal.tbd"
3316
+    ));
3317
+    if !metal.exists() {
3318
+        eprintln!("skipping: no Metal.tbd at {}", metal.display());
25173319
         return;
25183320
     }
25193321
 
2520
-    let obj = scratch("default-entry.o");
2521
-    let out = scratch("default-entry.out");
3322
+    let obj = scratch("framework-main.o");
3323
+    let out = scratch("framework-main.out");
25223324
     let src = r#"
25233325
         .section __TEXT,__text,regular,pure_instructions
2524
-        .globl _helper
2525
-        _helper:
2526
-            mov w0, #7
2527
-            ret
25283326
         .globl _main
25293327
         _main:
25303328
             mov w0, #0
@@ -2538,53 +3336,57 @@ fn linker_run_defaults_entry_to_main_symbol() {
25383336
 
25393337
     let opts = LinkOptions {
25403338
         inputs: vec![obj.clone()],
3339
+        frameworks: vec![FrameworkSpec {
3340
+            name: "Metal".into(),
3341
+            weak: false,
3342
+        }],
3343
+        syslibroot: Some(PathBuf::from(&sdk)),
25413344
         output: Some(out.clone()),
25423345
         kind: OutputKind::Executable,
25433346
         ..LinkOptions::default()
25443347
     };
25453348
     Linker::run(&opts).unwrap();
25463349
 
2547
-    let status = Command::new(&out).status().unwrap();
2548
-    assert_eq!(
2549
-        status.code(),
2550
-        Some(0),
2551
-        "default executable entry should prefer _main over the first text atom"
2552
-    );
3350
+    let bytes = fs::read(&out).unwrap();
3351
+    let header = parse_header(&bytes).unwrap();
3352
+    let commands = parse_commands(&header, &bytes).unwrap();
3353
+    assert!(commands.iter().any(|cmd| matches!(
3354
+        cmd,
3355
+        LoadCommand::Dylib(d)
3356
+            if d.cmd == afs_ld::macho::constants::LC_LOAD_DYLIB
3357
+                && d.name.contains("Metal.framework")
3358
+    )));
25533359
 
25543360
     let _ = fs::remove_file(obj);
25553361
     let _ = fs::remove_file(out);
25563362
 }
25573363
 
25583364
 #[test]
2559
-fn linker_run_applies_core_arm64_relocations() {
3365
+fn linker_run_resolves_weak_framework_via_syslibroot() {
25603366
     if !have_xcrun() {
2561
-        eprintln!("skipping: xcrun as unavailable");
3367
+        eprintln!("skipping: xcrun unavailable");
3368
+        return;
3369
+    }
3370
+    let Some(sdk) = sdk_path() else {
3371
+        eprintln!("skipping: xcrun --show-sdk-path unavailable");
3372
+        return;
3373
+    };
3374
+    let metal = PathBuf::from(format!(
3375
+        "{sdk}/System/Library/Frameworks/Metal.framework/Metal.tbd"
3376
+    ));
3377
+    if !metal.exists() {
3378
+        eprintln!("skipping: no Metal.tbd at {}", metal.display());
25623379
         return;
25633380
     }
25643381
 
2565
-    let obj = scratch("relocs.o");
2566
-    let out = scratch("relocs.out");
3382
+    let obj = scratch("weak-framework-main.o");
3383
+    let out = scratch("weak-framework-main.out");
25673384
     let src = r#"
25683385
         .section __TEXT,__text,regular,pure_instructions
25693386
         .globl _main
2570
-        .globl _helper
25713387
         _main:
2572
-            adrp x0, _target@PAGE
2573
-            add x0, x0, _target@PAGEOFF
2574
-            bl _helper
2575
-            ret
2576
-        _helper:
3388
+            mov w0, #0
25773389
             ret
2578
-
2579
-        .section __DATA,__data
2580
-        .p2align 3
2581
-        _target:
2582
-            .quad _helper
2583
-
2584
-        .section __TEXT,__const
2585
-        .p2align 3
2586
-        _delta:
2587
-            .quad _helper - _main
25883390
         .subsections_via_symbols
25893391
     "#;
25903392
     if let Err(e) = assemble(src, &obj) {
@@ -2594,6 +3396,11 @@ fn linker_run_applies_core_arm64_relocations() {
25943396
 
25953397
     let opts = LinkOptions {
25963398
         inputs: vec![obj.clone()],
3399
+        frameworks: vec![FrameworkSpec {
3400
+            name: "Metal".into(),
3401
+            weak: true,
3402
+        }],
3403
+        syslibroot: Some(PathBuf::from(&sdk)),
25973404
         output: Some(out.clone()),
25983405
         kind: OutputKind::Executable,
25993406
         ..LinkOptions::default()
@@ -2601,74 +3408,34 @@ fn linker_run_applies_core_arm64_relocations() {
26013408
     Linker::run(&opts).unwrap();
26023409
 
26033410
     let bytes = fs::read(&out).unwrap();
2604
-    let (text_addr, text) = output_section(&bytes, "__TEXT", "__text").expect("text section");
2605
-    let (data_addr, data) = output_section(&bytes, "__DATA", "__data").expect("data section");
2606
-    let (_, cdata) = output_section(&bytes, "__TEXT", "__const").expect("const section");
2607
-
2608
-    let adrp = u32::from_le_bytes(text[0..4].try_into().unwrap());
2609
-    let add = u32::from_le_bytes(text[4..8].try_into().unwrap());
2610
-    let branch = u32::from_le_bytes(text[8..12].try_into().unwrap());
2611
-    let data_ptr = u64::from_le_bytes(data[0..8].try_into().unwrap());
2612
-    let delta = u64::from_le_bytes(cdata[0..8].try_into().unwrap());
2613
-
2614
-    let adrp_immlo = ((adrp >> 29) & 0x3) as i64;
2615
-    let adrp_immhi = ((adrp >> 5) & 0x7ffff) as i64;
2616
-    let adrp_pages = sign_extend_21((adrp_immhi << 2) | adrp_immlo);
2617
-    let adrp_base = ((text_addr as i64) & !0xfff) + (adrp_pages << 12);
2618
-    let add_imm = ((add >> 10) & 0xfff) as u64;
2619
-    let reconstructed_target = (adrp_base as u64) + add_imm;
2620
-
2621
-    assert_eq!(
2622
-        reconstructed_target, data_addr,
2623
-        "ADRP+ADD should resolve _target"
2624
-    );
2625
-    assert_eq!(
2626
-        branch & 0x03ff_ffff,
2627
-        0x2,
2628
-        "BL should branch forward 8 bytes"
2629
-    );
2630
-    assert_eq!(
2631
-        data_ptr,
2632
-        text_addr + 16,
2633
-        ".quad _helper should point at helper"
2634
-    );
2635
-    assert_eq!(delta, 16, "_helper - _main should fold through SUBTRACTOR");
3411
+    let header = parse_header(&bytes).unwrap();
3412
+    let commands = parse_commands(&header, &bytes).unwrap();
3413
+    assert!(commands.iter().any(|cmd| matches!(
3414
+        cmd,
3415
+        LoadCommand::Dylib(d)
3416
+            if d.cmd == afs_ld::macho::constants::LC_LOAD_WEAK_DYLIB
3417
+                && d.name.contains("Metal.framework")
3418
+    )));
26363419
 
26373420
     let _ = fs::remove_file(obj);
26383421
     let _ = fs::remove_file(out);
26393422
 }
26403423
 
2641
-fn sign_extend_21(value: i64) -> i64 {
2642
-    if value & (1 << 20) != 0 {
2643
-        value | !0x1f_ffff
2644
-    } else {
2645
-        value
2646
-    }
2647
-}
2648
-
26493424
 #[test]
2650
-fn linker_run_applies_scaled_pageoff12_for_ldr_x() {
3425
+fn linker_run_uses_platform_version_for_build_command() {
26513426
     if !have_xcrun() {
26523427
         eprintln!("skipping: xcrun as unavailable");
26533428
         return;
26543429
     }
26553430
 
2656
-    let obj = scratch("scaled-ldr.o");
2657
-    let out = scratch("scaled-ldr.out");
3431
+    let obj = scratch("platform-version.o");
3432
+    let out = scratch("platform-version.out");
26583433
     let src = r#"
26593434
         .section __TEXT,__text,regular,pure_instructions
26603435
         .globl _main
26613436
         _main:
2662
-            adrp x0, _target@PAGE
2663
-            ldr x1, [x0, _target@PAGEOFF]
3437
+            mov w0, #0
26643438
             ret
2665
-
2666
-        .section __DATA,__data
2667
-        .space 0x3f8
2668
-        .p2align 3
2669
-        .globl _target
2670
-        _target:
2671
-            .quad 0x1122334455667788
26723439
         .subsections_via_symbols
26733440
     "#;
26743441
     if let Err(e) = assemble(src, &obj) {
@@ -2680,397 +3447,2674 @@ fn linker_run_applies_scaled_pageoff12_for_ldr_x() {
26803447
         inputs: vec![obj.clone()],
26813448
         output: Some(out.clone()),
26823449
         kind: OutputKind::Executable,
3450
+        platform_version: Some(afs_ld::PlatformVersion {
3451
+            minos: (13 << 16) | (2 << 8) | 1,
3452
+            sdk: (14 << 16) | (5 << 8),
3453
+        }),
26833454
         ..LinkOptions::default()
26843455
     };
26853456
     Linker::run(&opts).unwrap();
26863457
 
26873458
     let bytes = fs::read(&out).unwrap();
2688
-    let (text_addr, text) = output_section(&bytes, "__TEXT", "__text").expect("text section");
2689
-    let (data_addr, data) = output_section(&bytes, "__DATA", "__data").expect("data section");
3459
+    let header = parse_header(&bytes).unwrap();
3460
+    let commands = parse_commands(&header, &bytes).unwrap();
3461
+    let build = commands
3462
+        .into_iter()
3463
+        .find_map(|cmd| match cmd {
3464
+            LoadCommand::BuildVersion(cmd) => Some(cmd),
3465
+            _ => None,
3466
+        })
3467
+        .expect("missing LC_BUILD_VERSION");
3468
+    assert_eq!(build.minos, (13 << 16) | (2 << 8) | 1);
3469
+    assert_eq!(build.sdk, (14 << 16) | (5 << 8));
26903470
 
2691
-    let adrp = u32::from_le_bytes(text[0..4].try_into().unwrap());
2692
-    let ldr = u32::from_le_bytes(text[4..8].try_into().unwrap());
2693
-    let adrp_immlo = ((adrp >> 29) & 0x3) as i64;
2694
-    let adrp_immhi = ((adrp >> 5) & 0x7ffff) as i64;
2695
-    let adrp_pages = sign_extend_21((adrp_immhi << 2) | adrp_immlo);
2696
-    let adrp_base = ((text_addr as i64) & !0xfff) + (adrp_pages << 12);
2697
-    let ldr_shift = ((ldr >> 30) & 0b11) as u64;
2698
-    let ldr_imm = ((ldr >> 10) & 0xfff) as u64;
2699
-    let reconstructed_target = (adrp_base as u64) + (ldr_imm << ldr_shift);
3471
+    let _ = fs::remove_file(obj);
3472
+    let _ = fs::remove_file(out);
3473
+}
27003474
 
2701
-    assert_eq!(ldr_shift, 3, "expected 64-bit LDR scale");
2702
-    assert_eq!(ldr_imm, 0x7f, "scaled imm12 should store 0x3f8 >> 3");
2703
-    assert_eq!(reconstructed_target, data_addr + 0x3f8);
2704
-    assert_eq!(
2705
-        u64::from_le_bytes(data[0x3f8..0x400].try_into().unwrap()),
2706
-        0x1122334455667788
2707
-    );
3475
+#[test]
3476
+fn linker_run_emits_rpath_command() {
3477
+    if !have_xcrun() {
3478
+        eprintln!("skipping: xcrun as unavailable");
3479
+        return;
3480
+    }
3481
+
3482
+    let obj = scratch("rpath-main.o");
3483
+    let out = scratch("rpath-main.out");
3484
+    let src = r#"
3485
+        .section __TEXT,__text,regular,pure_instructions
3486
+        .globl _main
3487
+        _main:
3488
+            mov w0, #0
3489
+            ret
3490
+        .subsections_via_symbols
3491
+    "#;
3492
+    if let Err(e) = assemble(src, &obj) {
3493
+        eprintln!("skipping: assemble failed: {e}");
3494
+        return;
3495
+    }
3496
+
3497
+    let opts = LinkOptions {
3498
+        inputs: vec![obj.clone()],
3499
+        output: Some(out.clone()),
3500
+        kind: OutputKind::Executable,
3501
+        rpaths: vec!["@loader_path/../Frameworks".into()],
3502
+        ..LinkOptions::default()
3503
+    };
3504
+    Linker::run(&opts).unwrap();
3505
+
3506
+    let bytes = fs::read(&out).unwrap();
3507
+    let header = parse_header(&bytes).unwrap();
3508
+    let commands = parse_commands(&header, &bytes).unwrap();
3509
+    let rpaths: Vec<String> = commands
3510
+        .into_iter()
3511
+        .filter_map(|cmd| match cmd {
3512
+            LoadCommand::Rpath(cmd) => Some(cmd.path),
3513
+            _ => None,
3514
+        })
3515
+        .collect();
3516
+    assert_eq!(rpaths, vec!["@loader_path/../Frameworks".to_string()]);
27083517
 
27093518
     let _ = fs::remove_file(obj);
27103519
     let _ = fs::remove_file(out);
27113520
 }
27123521
 
27133522
 #[test]
2714
-fn relocated_sections_match_apple_ld_across_fixture_matrix() {
2715
-    if !have_xcrun() || !have_xcrun_tool("ld") {
2716
-        eprintln!("skipping: xcrun as/ld unavailable");
3523
+fn linker_run_emits_map_file() {
3524
+    if !have_xcrun() {
3525
+        eprintln!("skipping: xcrun as unavailable");
27173526
         return;
27183527
     }
2719
-    let Some(sdk) = sdk_path() else {
2720
-        eprintln!("skipping: xcrun --show-sdk-path unavailable");
3528
+
3529
+    let obj = scratch("map-main.o");
3530
+    let out = scratch("map-main.out");
3531
+    let map = scratch("map-main.map");
3532
+    let src = r#"
3533
+        .section __TEXT,__text,regular,pure_instructions
3534
+        .globl _main
3535
+        _main:
3536
+            mov w0, #0
3537
+            ret
3538
+        .subsections_via_symbols
3539
+    "#;
3540
+    if let Err(e) = assemble(src, &obj) {
3541
+        eprintln!("skipping: assemble failed: {e}");
27213542
         return;
3543
+    }
3544
+
3545
+    let opts = LinkOptions {
3546
+        inputs: vec![obj.clone()],
3547
+        output: Some(out.clone()),
3548
+        map: Some(map.clone()),
3549
+        kind: OutputKind::Executable,
3550
+        ..LinkOptions::default()
27223551
     };
2723
-    let Some(sdk_ver) = sdk_version() else {
2724
-        eprintln!("skipping: xcrun --show-sdk-version unavailable");
3552
+    Linker::run(&opts).unwrap();
3553
+
3554
+    let map_text = fs::read_to_string(&map).unwrap();
3555
+    assert!(map_text.contains("# Path:"));
3556
+    assert!(map_text.contains("# Object files:"));
3557
+    assert!(map_text.contains("linker synthesized"));
3558
+    assert!(map_text.contains(&obj.display().to_string()));
3559
+    assert!(map_text.contains("# Sections:"));
3560
+    assert!(map_text.contains("__TEXT"));
3561
+    assert!(map_text.contains("__text"));
3562
+    assert!(map_text.contains("# Symbols:"));
3563
+    assert!(map_text.contains("_main"));
3564
+    assert!(map_text.contains("# Dead stripped:"));
3565
+
3566
+    let _ = fs::remove_file(obj);
3567
+    let _ = fs::remove_file(out);
3568
+    let _ = fs::remove_file(map);
3569
+}
3570
+
3571
+#[test]
3572
+fn linker_run_map_lists_dead_stripped_symbols() {
3573
+    if !have_xcrun() {
3574
+        eprintln!("skipping: xcrun as unavailable");
3575
+        return;
3576
+    }
3577
+
3578
+    let main_obj = scratch("map-dead-main.o");
3579
+    let helper_obj = scratch("map-dead-helper.o");
3580
+    let unused_obj = scratch("map-dead-unused.o");
3581
+    let out = scratch("map-dead.out");
3582
+    let map = scratch("map-dead.map");
3583
+    let main_src = r#"
3584
+        .section __TEXT,__text,regular,pure_instructions
3585
+        .globl _main
3586
+        _main:
3587
+            bl _helper
3588
+            mov w0, #0
3589
+            ret
3590
+        .subsections_via_symbols
3591
+    "#;
3592
+    let helper_src = r#"
3593
+        .section __TEXT,__text,regular,pure_instructions
3594
+        .globl _helper
3595
+        _helper:
3596
+            ret
3597
+        .subsections_via_symbols
3598
+    "#;
3599
+    let unused_src = r#"
3600
+        .section __TEXT,__text,regular,pure_instructions
3601
+        .globl _unused
3602
+        _unused:
3603
+            ret
3604
+        .subsections_via_symbols
3605
+    "#;
3606
+    if let Err(e) = assemble(main_src, &main_obj) {
3607
+        eprintln!("skipping: assemble failed: {e}");
27253608
         return;
3609
+    }
3610
+    if let Err(e) = assemble(helper_src, &helper_obj) {
3611
+        eprintln!("skipping: assemble failed: {e}");
3612
+        let _ = fs::remove_file(main_obj);
3613
+        return;
3614
+    }
3615
+    if let Err(e) = assemble(unused_src, &unused_obj) {
3616
+        eprintln!("skipping: assemble failed: {e}");
3617
+        let _ = fs::remove_file(main_obj);
3618
+        let _ = fs::remove_file(helper_obj);
3619
+        return;
3620
+    }
3621
+
3622
+    let opts = LinkOptions {
3623
+        inputs: vec![main_obj.clone(), helper_obj.clone(), unused_obj.clone()],
3624
+        output: Some(out.clone()),
3625
+        map: Some(map.clone()),
3626
+        dead_strip: true,
3627
+        kind: OutputKind::Executable,
3628
+        ..LinkOptions::default()
27263629
     };
2727
-    const TEXT: SectionCase = SectionCase {
2728
-        segname: "__TEXT",
2729
-        sectname: "__text",
3630
+    Linker::run(&opts).unwrap();
3631
+
3632
+    let map_text = fs::read_to_string(&map).unwrap();
3633
+    let dead_stripped_idx = map_text.find("# Dead stripped:").unwrap();
3634
+    let dead_stripped = &map_text[dead_stripped_idx..];
3635
+    assert!(dead_stripped.contains("_unused"));
3636
+    assert!(!dead_stripped.contains("_helper"));
3637
+
3638
+    let _ = fs::remove_file(main_obj);
3639
+    let _ = fs::remove_file(helper_obj);
3640
+    let _ = fs::remove_file(unused_obj);
3641
+    let _ = fs::remove_file(out);
3642
+    let _ = fs::remove_file(map);
3643
+}
3644
+
3645
+#[test]
3646
+fn linker_run_map_lists_folded_symbols_under_icf_safe() {
3647
+    if !have_xcrun() {
3648
+        eprintln!("skipping: xcrun as unavailable");
3649
+        return;
3650
+    }
3651
+
3652
+    let obj = scratch("map-icf-folded.o");
3653
+    let out = scratch("map-icf-folded.out");
3654
+    let map = scratch("map-icf-folded.map");
3655
+    let src = r#"
3656
+        .section __TEXT,__text,regular,pure_instructions
3657
+        .globl _main
3658
+        _main:
3659
+            bl _helper1
3660
+            bl _helper2
3661
+            mov w0, #0
3662
+            ret
3663
+
3664
+        .private_extern _helper1
3665
+        _helper1:
3666
+            mov w0, #7
3667
+            ret
3668
+
3669
+        .private_extern _helper2
3670
+        _helper2:
3671
+            mov w0, #7
3672
+            ret
3673
+        .subsections_via_symbols
3674
+    "#;
3675
+    if let Err(e) = assemble(src, &obj) {
3676
+        eprintln!("skipping: assemble failed: {e}");
3677
+        return;
3678
+    }
3679
+
3680
+    let opts = LinkOptions {
3681
+        inputs: vec![obj.clone()],
3682
+        output: Some(out.clone()),
3683
+        map: Some(map.clone()),
3684
+        icf_mode: afs_ld::IcfMode::Safe,
3685
+        kind: OutputKind::Executable,
3686
+        ..LinkOptions::default()
27303687
     };
2731
-    const CONST: SectionCase = SectionCase {
2732
-        segname: "__TEXT",
2733
-        sectname: "__const",
3688
+    Linker::run(&opts).unwrap();
3689
+
3690
+    let map_text = fs::read_to_string(&map).unwrap();
3691
+    let folded_idx = map_text.find("# Folded symbols:").unwrap();
3692
+    let folded = &map_text[folded_idx..];
3693
+    assert!(folded.contains("_helper2 folded to _helper1"));
3694
+
3695
+    let _ = fs::remove_file(obj);
3696
+    let _ = fs::remove_file(out);
3697
+    let _ = fs::remove_file(map);
3698
+}
3699
+
3700
+#[test]
3701
+fn linker_run_carries_tbd_inputs_into_load_commands() {
3702
+    if !have_xcrun() {
3703
+        eprintln!("skipping: xcrun unavailable");
3704
+        return;
3705
+    }
3706
+    let Some(sdk) = sdk_path() else {
3707
+        eprintln!("skipping: xcrun --show-sdk-path unavailable");
3708
+        return;
27343709
     };
3710
+    let tbd = PathBuf::from(format!("{sdk}/usr/lib/libSystem.tbd"));
3711
+    if !tbd.exists() {
3712
+        eprintln!("skipping: no libSystem.tbd at {}", tbd.display());
3713
+        return;
3714
+    }
27353715
 
2736
-    let cases = [
2737
-        ParityCase {
2738
-            name: "branch-forward",
2739
-            src: r#"
2740
-                .section __TEXT,__text,regular,pure_instructions
2741
-                .globl _main
2742
-                _main:
2743
-                    bl _helper
2744
-                    ret
2745
-                _helper:
2746
-                    ret
2747
-                .subsections_via_symbols
2748
-            "#,
2749
-            check: ParityCheck::ExactSections(&[TEXT]),
2750
-        },
2751
-        ParityCase {
2752
-            name: "branch-backward",
2753
-            src: r#"
2754
-                .section __TEXT,__text,regular,pure_instructions
2755
-                .globl _helper
2756
-                _helper:
2757
-                    ret
2758
-                .globl _main
2759
-                _main:
2760
-                    bl _helper
2761
-                    ret
2762
-                .subsections_via_symbols
2763
-            "#,
2764
-            check: ParityCheck::ExactSections(&[TEXT]),
2765
-        },
2766
-        ParityCase {
2767
-            name: "adrp-add-intra-text-forward",
2768
-            src: r#"
2769
-                .section __TEXT,__text,regular,pure_instructions
2770
-                .globl _main
2771
-                _main:
2772
-                    adrp x0, _target@PAGE
2773
-                    add x0, x0, _target@PAGEOFF
2774
-                    ret
2775
-                .space 0x4ff4
2776
-                _target:
2777
-                    .quad 0
2778
-                .subsections_via_symbols
2779
-            "#,
2780
-            check: ParityCheck::PageRef {
2781
-                section: TEXT,
2782
-                site_offset: 0,
2783
-                target_offset: 0x5000,
2784
-                kind: PageRefKind::Add,
2785
-            },
2786
-        },
2787
-        ParityCase {
2788
-            name: "adrp-add-intra-text-backward",
2789
-            src: r#"
2790
-                .section __TEXT,__text,regular,pure_instructions
2791
-                _target:
2792
-                    .quad 0x55
2793
-                .space 0x4ff8
2794
-                .globl _main
2795
-                _main:
3716
+    let obj = scratch("tbd-main.o");
3717
+    let out = scratch("tbd-a.out");
3718
+    let src = r#"
3719
+        .section __TEXT,__text,regular,pure_instructions
3720
+        .globl _main
3721
+        _main:
3722
+            mov x0, #0
3723
+            ret
3724
+        .subsections_via_symbols
3725
+    "#;
3726
+    if let Err(e) = assemble(src, &obj) {
3727
+        eprintln!("skipping: assemble failed: {e}");
3728
+        return;
3729
+    }
3730
+
3731
+    let opts = LinkOptions {
3732
+        inputs: vec![obj.clone(), tbd.clone()],
3733
+        output: Some(out.clone()),
3734
+        kind: OutputKind::Executable,
3735
+        ..LinkOptions::default()
3736
+    };
3737
+    Linker::run(&opts).unwrap();
3738
+
3739
+    let bytes = fs::read(&out).unwrap();
3740
+    let header = parse_header(&bytes).unwrap();
3741
+    let commands = parse_commands(&header, &bytes).unwrap();
3742
+    assert!(
3743
+        commands.iter().any(|cmd| matches!(
3744
+            cmd,
3745
+            LoadCommand::Dylib(d) if d.cmd == afs_ld::macho::constants::LC_LOAD_DYLIB
3746
+        )),
3747
+        "expected at least one LC_LOAD_DYLIB in output"
3748
+    );
3749
+
3750
+    let _ = fs::remove_file(obj);
3751
+    let _ = fs::remove_file(out);
3752
+}
3753
+
3754
+#[test]
3755
+fn linker_run_handles_non_standard_segment_without_panicking() {
3756
+    if !have_xcrun() {
3757
+        eprintln!("skipping: xcrun as unavailable");
3758
+        return;
3759
+    }
3760
+
3761
+    let obj = scratch("custom-segment.o");
3762
+    let out = scratch("custom-segment.out");
3763
+    let src = r#"
3764
+        .section __FOO,__bar
3765
+        .globl _custom
3766
+        _custom:
3767
+            .quad 1
3768
+        .subsections_via_symbols
3769
+    "#;
3770
+    if let Err(e) = assemble(src, &obj) {
3771
+        eprintln!("skipping: assemble failed: {e}");
3772
+        return;
3773
+    }
3774
+
3775
+    let opts = LinkOptions {
3776
+        inputs: vec![obj.clone()],
3777
+        output: Some(out.clone()),
3778
+        kind: OutputKind::Executable,
3779
+        ..LinkOptions::default()
3780
+    };
3781
+    Linker::run(&opts).unwrap();
3782
+
3783
+    let bytes = fs::read(&out).unwrap();
3784
+    let header = parse_header(&bytes).unwrap();
3785
+    let commands = parse_commands(&header, &bytes).unwrap();
3786
+    assert!(commands.iter().any(|cmd| match cmd {
3787
+        LoadCommand::Segment64(seg) => seg.segname_str() == "__FOO",
3788
+        _ => false,
3789
+    }));
3790
+
3791
+    let _ = fs::remove_file(obj);
3792
+    let _ = fs::remove_file(out);
3793
+}
3794
+
3795
+#[test]
3796
+fn linker_run_uses_requested_entry_symbol() {
3797
+    if !have_xcrun() {
3798
+        eprintln!("skipping: xcrun as unavailable");
3799
+        return;
3800
+    }
3801
+
3802
+    let obj = scratch("entry.o");
3803
+    let out = scratch("entry.out");
3804
+    let src = r#"
3805
+        .section __TEXT,__text,regular,pure_instructions
3806
+        .globl _main
3807
+        _main:
3808
+            ret
3809
+        .globl _alt
3810
+        _alt:
3811
+            mov x0, #1
3812
+            ret
3813
+        .subsections_via_symbols
3814
+    "#;
3815
+    if let Err(e) = assemble(src, &obj) {
3816
+        eprintln!("skipping: assemble failed: {e}");
3817
+        return;
3818
+    }
3819
+
3820
+    let opts = LinkOptions {
3821
+        inputs: vec![obj.clone()],
3822
+        output: Some(out.clone()),
3823
+        entry: Some("_alt".into()),
3824
+        kind: OutputKind::Executable,
3825
+        ..LinkOptions::default()
3826
+    };
3827
+    Linker::run(&opts).unwrap();
3828
+
3829
+    let bytes = fs::read(&out).unwrap();
3830
+    let header = parse_header(&bytes).unwrap();
3831
+    let commands = parse_commands(&header, &bytes).unwrap();
3832
+    let mut text_offset = None;
3833
+    let mut main_entryoff = None;
3834
+    for cmd in commands {
3835
+        match cmd {
3836
+            LoadCommand::Segment64(seg) => {
3837
+                for section in seg.sections {
3838
+                    if section.sectname_str() == "__text" {
3839
+                        text_offset = Some(section.offset as u64);
3840
+                    }
3841
+                }
3842
+            }
3843
+            LoadCommand::Raw { cmd, data, .. } if cmd == afs_ld::macho::constants::LC_MAIN => {
3844
+                let mut buf = [0u8; 8];
3845
+                buf.copy_from_slice(&data[0..8]);
3846
+                main_entryoff = Some(u64::from_le_bytes(buf));
3847
+            }
3848
+            _ => {}
3849
+        }
3850
+    }
3851
+
3852
+    let text_offset = text_offset.expect("text section offset");
3853
+    let main_entryoff = main_entryoff.expect("LC_MAIN entryoff");
3854
+    assert!(
3855
+        main_entryoff > text_offset,
3856
+        "expected custom entry to land after start of __text: text={text_offset}, entry={main_entryoff}"
3857
+    );
3858
+
3859
+    let _ = fs::remove_file(obj);
3860
+    let _ = fs::remove_file(out);
3861
+}
3862
+
3863
+#[test]
3864
+fn linker_run_defaults_entry_to_main_symbol() {
3865
+    if !have_xcrun() {
3866
+        eprintln!("skipping: xcrun as unavailable");
3867
+        return;
3868
+    }
3869
+
3870
+    let obj = scratch("default-entry.o");
3871
+    let out = scratch("default-entry.out");
3872
+    let src = r#"
3873
+        .section __TEXT,__text,regular,pure_instructions
3874
+        .globl _helper
3875
+        _helper:
3876
+            mov w0, #7
3877
+            ret
3878
+        .globl _main
3879
+        _main:
3880
+            mov w0, #0
3881
+            ret
3882
+        .subsections_via_symbols
3883
+    "#;
3884
+    if let Err(e) = assemble(src, &obj) {
3885
+        eprintln!("skipping: assemble failed: {e}");
3886
+        return;
3887
+    }
3888
+
3889
+    let opts = LinkOptions {
3890
+        inputs: vec![obj.clone()],
3891
+        output: Some(out.clone()),
3892
+        kind: OutputKind::Executable,
3893
+        ..LinkOptions::default()
3894
+    };
3895
+    Linker::run(&opts).unwrap();
3896
+
3897
+    let status = Command::new(&out).status().unwrap();
3898
+    assert_eq!(
3899
+        status.code(),
3900
+        Some(0),
3901
+        "default executable entry should prefer _main over the first text atom"
3902
+    );
3903
+
3904
+    let _ = fs::remove_file(obj);
3905
+    let _ = fs::remove_file(out);
3906
+}
3907
+
3908
+#[test]
3909
+fn linker_run_applies_core_arm64_relocations() {
3910
+    if !have_xcrun() {
3911
+        eprintln!("skipping: xcrun as unavailable");
3912
+        return;
3913
+    }
3914
+
3915
+    let obj = scratch("relocs.o");
3916
+    let out = scratch("relocs.out");
3917
+    let src = r#"
3918
+        .section __TEXT,__text,regular,pure_instructions
3919
+        .globl _main
3920
+        .globl _helper
3921
+        _main:
3922
+            adrp x0, _target@PAGE
3923
+            add x0, x0, _target@PAGEOFF
3924
+            bl _helper
3925
+            ret
3926
+        _helper:
3927
+            ret
3928
+
3929
+        .section __DATA,__data
3930
+        .p2align 3
3931
+        _target:
3932
+            .quad _helper
3933
+
3934
+        .section __TEXT,__const
3935
+        .p2align 3
3936
+        _delta:
3937
+            .quad _helper - _main
3938
+        .subsections_via_symbols
3939
+    "#;
3940
+    if let Err(e) = assemble(src, &obj) {
3941
+        eprintln!("skipping: assemble failed: {e}");
3942
+        return;
3943
+    }
3944
+
3945
+    let opts = LinkOptions {
3946
+        inputs: vec![obj.clone()],
3947
+        output: Some(out.clone()),
3948
+        kind: OutputKind::Executable,
3949
+        ..LinkOptions::default()
3950
+    };
3951
+    Linker::run(&opts).unwrap();
3952
+
3953
+    let bytes = fs::read(&out).unwrap();
3954
+    let (text_addr, text) = output_section(&bytes, "__TEXT", "__text").expect("text section");
3955
+    let (data_addr, data) = output_section(&bytes, "__DATA", "__data").expect("data section");
3956
+    let (_, cdata) = output_section(&bytes, "__TEXT", "__const").expect("const section");
3957
+
3958
+    let adrp = u32::from_le_bytes(text[0..4].try_into().unwrap());
3959
+    let add = u32::from_le_bytes(text[4..8].try_into().unwrap());
3960
+    let branch = u32::from_le_bytes(text[8..12].try_into().unwrap());
3961
+    let data_ptr = u64::from_le_bytes(data[0..8].try_into().unwrap());
3962
+    let delta = u64::from_le_bytes(cdata[0..8].try_into().unwrap());
3963
+
3964
+    let adrp_immlo = ((adrp >> 29) & 0x3) as i64;
3965
+    let adrp_immhi = ((adrp >> 5) & 0x7ffff) as i64;
3966
+    let adrp_pages = sign_extend_21((adrp_immhi << 2) | adrp_immlo);
3967
+    let adrp_base = ((text_addr as i64) & !0xfff) + (adrp_pages << 12);
3968
+    let add_imm = ((add >> 10) & 0xfff) as u64;
3969
+    let reconstructed_target = (adrp_base as u64) + add_imm;
3970
+
3971
+    assert_eq!(
3972
+        reconstructed_target, data_addr,
3973
+        "ADRP+ADD should resolve _target"
3974
+    );
3975
+    assert_eq!(
3976
+        branch & 0x03ff_ffff,
3977
+        0x2,
3978
+        "BL should branch forward 8 bytes"
3979
+    );
3980
+    assert_eq!(
3981
+        data_ptr,
3982
+        text_addr + 16,
3983
+        ".quad _helper should point at helper"
3984
+    );
3985
+    assert_eq!(delta, 16, "_helper - _main should fold through SUBTRACTOR");
3986
+
3987
+    let _ = fs::remove_file(obj);
3988
+    let _ = fs::remove_file(out);
3989
+}
3990
+
3991
+fn sign_extend_21(value: i64) -> i64 {
3992
+    if value & (1 << 20) != 0 {
3993
+        value | !0x1f_ffff
3994
+    } else {
3995
+        value
3996
+    }
3997
+}
3998
+
3999
+#[test]
4000
+fn linker_run_applies_scaled_pageoff12_for_ldr_x() {
4001
+    if !have_xcrun() {
4002
+        eprintln!("skipping: xcrun as unavailable");
4003
+        return;
4004
+    }
4005
+
4006
+    let obj = scratch("scaled-ldr.o");
4007
+    let out = scratch("scaled-ldr.out");
4008
+    let src = r#"
4009
+        .section __TEXT,__text,regular,pure_instructions
4010
+        .globl _main
4011
+        _main:
4012
+            adrp x0, _target@PAGE
4013
+            ldr x1, [x0, _target@PAGEOFF]
4014
+            ret
4015
+
4016
+        .section __DATA,__data
4017
+        .space 0x3f8
4018
+        .p2align 3
4019
+        .globl _target
4020
+        _target:
4021
+            .quad 0x1122334455667788
4022
+        .subsections_via_symbols
4023
+    "#;
4024
+    if let Err(e) = assemble(src, &obj) {
4025
+        eprintln!("skipping: assemble failed: {e}");
4026
+        return;
4027
+    }
4028
+
4029
+    let opts = LinkOptions {
4030
+        inputs: vec![obj.clone()],
4031
+        output: Some(out.clone()),
4032
+        kind: OutputKind::Executable,
4033
+        ..LinkOptions::default()
4034
+    };
4035
+    Linker::run(&opts).unwrap();
4036
+
4037
+    let bytes = fs::read(&out).unwrap();
4038
+    let (text_addr, text) = output_section(&bytes, "__TEXT", "__text").expect("text section");
4039
+    let (data_addr, data) = output_section(&bytes, "__DATA", "__data").expect("data section");
4040
+
4041
+    let adrp = u32::from_le_bytes(text[0..4].try_into().unwrap());
4042
+    let ldr = u32::from_le_bytes(text[4..8].try_into().unwrap());
4043
+    let adrp_immlo = ((adrp >> 29) & 0x3) as i64;
4044
+    let adrp_immhi = ((adrp >> 5) & 0x7ffff) as i64;
4045
+    let adrp_pages = sign_extend_21((adrp_immhi << 2) | adrp_immlo);
4046
+    let adrp_base = ((text_addr as i64) & !0xfff) + (adrp_pages << 12);
4047
+    let ldr_shift = ((ldr >> 30) & 0b11) as u64;
4048
+    let ldr_imm = ((ldr >> 10) & 0xfff) as u64;
4049
+    let reconstructed_target = (adrp_base as u64) + (ldr_imm << ldr_shift);
4050
+
4051
+    assert_eq!(ldr_shift, 3, "expected 64-bit LDR scale");
4052
+    assert_eq!(ldr_imm, 0x7f, "scaled imm12 should store 0x3f8 >> 3");
4053
+    assert_eq!(reconstructed_target, data_addr + 0x3f8);
4054
+    assert_eq!(
4055
+        u64::from_le_bytes(data[0x3f8..0x400].try_into().unwrap()),
4056
+        0x1122334455667788
4057
+    );
4058
+
4059
+    let _ = fs::remove_file(obj);
4060
+    let _ = fs::remove_file(out);
4061
+}
4062
+
4063
+#[test]
4064
+fn relocated_sections_match_apple_ld_across_fixture_matrix() {
4065
+    if !have_xcrun() || !have_xcrun_tool("ld") {
4066
+        eprintln!("skipping: xcrun as/ld unavailable");
4067
+        return;
4068
+    }
4069
+    let Some(sdk) = sdk_path() else {
4070
+        eprintln!("skipping: xcrun --show-sdk-path unavailable");
4071
+        return;
4072
+    };
4073
+    let Some(sdk_ver) = sdk_version() else {
4074
+        eprintln!("skipping: xcrun --show-sdk-version unavailable");
4075
+        return;
4076
+    };
4077
+    const TEXT: SectionCase = SectionCase {
4078
+        segname: "__TEXT",
4079
+        sectname: "__text",
4080
+    };
4081
+    const CONST: SectionCase = SectionCase {
4082
+        segname: "__TEXT",
4083
+        sectname: "__const",
4084
+    };
4085
+
4086
+    let cases = [
4087
+        ParityCase {
4088
+            name: "branch-forward",
4089
+            src: r#"
4090
+                .section __TEXT,__text,regular,pure_instructions
4091
+                .globl _main
4092
+                _main:
4093
+                    bl _helper
4094
+                    ret
4095
+                _helper:
4096
+                    ret
4097
+                .subsections_via_symbols
4098
+            "#,
4099
+            check: ParityCheck::ExactSections(&[TEXT]),
4100
+        },
4101
+        ParityCase {
4102
+            name: "branch-backward",
4103
+            src: r#"
4104
+                .section __TEXT,__text,regular,pure_instructions
4105
+                .globl _helper
4106
+                _helper:
4107
+                    ret
4108
+                .globl _main
4109
+                _main:
4110
+                    bl _helper
4111
+                    ret
4112
+                .subsections_via_symbols
4113
+            "#,
4114
+            check: ParityCheck::ExactSections(&[TEXT]),
4115
+        },
4116
+        ParityCase {
4117
+            name: "adrp-add-intra-text-forward",
4118
+            src: r#"
4119
+                .section __TEXT,__text,regular,pure_instructions
4120
+                .globl _main
4121
+                _main:
4122
+                    adrp x0, _target@PAGE
4123
+                    add x0, x0, _target@PAGEOFF
4124
+                    ret
4125
+                .space 0x4ff4
4126
+                _target:
4127
+                    .quad 0
4128
+                .subsections_via_symbols
4129
+            "#,
4130
+            check: ParityCheck::PageRef {
4131
+                section: TEXT,
4132
+                site_offset: 0,
4133
+                target_offset: 0x5000,
4134
+                kind: PageRefKind::Add,
4135
+            },
4136
+        },
4137
+        ParityCase {
4138
+            name: "adrp-add-intra-text-backward",
4139
+            src: r#"
4140
+                .section __TEXT,__text,regular,pure_instructions
4141
+                _target:
4142
+                    .quad 0x55
4143
+                .space 0x4ff8
4144
+                .globl _main
4145
+                _main:
4146
+                    adrp x0, _target@PAGE
4147
+                    add x0, x0, _target@PAGEOFF
4148
+                    ret
4149
+                .subsections_via_symbols
4150
+            "#,
4151
+            check: ParityCheck::PageRef {
4152
+                section: TEXT,
4153
+                site_offset: 0x5000,
4154
+                target_offset: 0,
4155
+                kind: PageRefKind::Add,
4156
+            },
4157
+        },
4158
+        ParityCase {
4159
+            name: "adrp-ldr-x-intra-text",
4160
+            src: r#"
4161
+                .section __TEXT,__text,regular,pure_instructions
4162
+                .globl _main
4163
+                _main:
4164
+                    adrp x0, _target@PAGE
4165
+                    ldr x1, [x0, _target@PAGEOFF]
4166
+                    ret
4167
+                .space 0x3f4
4168
+                _target:
4169
+                    .quad 0x1122334455667788
4170
+                .subsections_via_symbols
4171
+            "#,
4172
+            check: ParityCheck::PageRef {
4173
+                section: TEXT,
4174
+                site_offset: 0,
4175
+                target_offset: 0x400,
4176
+                kind: PageRefKind::Load,
4177
+            },
4178
+        },
4179
+        ParityCase {
4180
+            name: "adrp-ldr-w-intra-text",
4181
+            src: r#"
4182
+                .section __TEXT,__text,regular,pure_instructions
4183
+                .globl _main
4184
+                _main:
4185
+                    adrp x0, _target@PAGE
4186
+                    ldr w1, [x0, _target@PAGEOFF]
4187
+                    ret
4188
+                .space 0x2f4
4189
+                _target:
4190
+                    .long 0x11223344
4191
+                .subsections_via_symbols
4192
+            "#,
4193
+            check: ParityCheck::PageRef {
4194
+                section: TEXT,
4195
+                site_offset: 0,
4196
+                target_offset: 0x300,
4197
+                kind: PageRefKind::Load,
4198
+            },
4199
+        },
4200
+        ParityCase {
4201
+            name: "adrp-ldrh-intra-text",
4202
+            src: r#"
4203
+                .section __TEXT,__text,regular,pure_instructions
4204
+                .globl _main
4205
+                _main:
4206
+                    adrp x0, _target@PAGE
4207
+                    ldrh w1, [x0, _target@PAGEOFF]
4208
+                    ret
4209
+                .space 0x1f4
4210
+                _target:
4211
+                    .hword 0x3344
4212
+                .subsections_via_symbols
4213
+            "#,
4214
+            check: ParityCheck::PageRef {
4215
+                section: TEXT,
4216
+                site_offset: 0,
4217
+                target_offset: 0x200,
4218
+                kind: PageRefKind::Load,
4219
+            },
4220
+        },
4221
+        ParityCase {
4222
+            name: "adrp-ldrb-intra-text",
4223
+            src: r#"
4224
+                .section __TEXT,__text,regular,pure_instructions
4225
+                .globl _main
4226
+                _main:
4227
+                    adrp x0, _target@PAGE
4228
+                    ldrb w1, [x0, _target@PAGEOFF]
4229
+                    ret
4230
+                .space 0xf4
4231
+                _target:
4232
+                    .byte 0x44
4233
+                .subsections_via_symbols
4234
+            "#,
4235
+            check: ParityCheck::PageRef {
4236
+                section: TEXT,
4237
+                site_offset: 0,
4238
+                target_offset: 0x100,
4239
+                kind: PageRefKind::Load,
4240
+            },
4241
+        },
4242
+        ParityCase {
4243
+            name: "mixed-branch-adrp-text",
4244
+            src: r#"
4245
+                .section __TEXT,__text,regular,pure_instructions
4246
+                .globl _main
4247
+                .globl _helper
4248
+                _main:
27964249
                     adrp x0, _target@PAGE
27974250
                     add x0, x0, _target@PAGEOFF
4251
+                    bl _helper
4252
+                    ret
4253
+                _helper:
4254
+                    ret
4255
+                .space 0xff0
4256
+                _target:
4257
+                    .quad 0x99
4258
+                .subsections_via_symbols
4259
+            "#,
4260
+            check: ParityCheck::PageRef {
4261
+                section: TEXT,
4262
+                site_offset: 0,
4263
+                target_offset: 0x1004,
4264
+                kind: PageRefKind::Add,
4265
+            },
4266
+        },
4267
+        ParityCase {
4268
+            name: "subtractor-positive",
4269
+            src: r#"
4270
+                .section __TEXT,__text,regular,pure_instructions
4271
+                .globl _helper
4272
+                _helper:
4273
+                    ret
4274
+                .globl _main
4275
+                _main:
4276
+                    bl _helper
4277
+                    ret
4278
+                .section __TEXT,__const
4279
+                .p2align 3
4280
+                _delta:
4281
+                    .quad _helper - _main
4282
+                .subsections_via_symbols
4283
+            "#,
4284
+            check: ParityCheck::ExactSections(&[CONST]),
4285
+        },
4286
+        ParityCase {
4287
+            name: "subtractor-negative",
4288
+            src: r#"
4289
+                .section __TEXT,__text,regular,pure_instructions
4290
+                .globl _helper
4291
+                _helper:
4292
+                    ret
4293
+                .globl _main
4294
+                _main:
4295
+                    ret
4296
+                .section __TEXT,__const
4297
+                .p2align 3
4298
+                _delta:
4299
+                    .quad _main - _helper
4300
+                .subsections_via_symbols
4301
+            "#,
4302
+            check: ParityCheck::ExactSections(&[CONST]),
4303
+        },
4304
+        ParityCase {
4305
+            name: "branch-and-subtractor",
4306
+            src: r#"
4307
+                .section __TEXT,__text,regular,pure_instructions
4308
+                .globl _helper
4309
+                _helper:
4310
+                    ret
4311
+                .globl _main
4312
+                _main:
4313
+                    bl _helper
4314
+                    ret
4315
+                .section __TEXT,__const
4316
+                .p2align 3
4317
+                _delta:
4318
+                    .quad _main - _helper
4319
+                .subsections_via_symbols
4320
+            "#,
4321
+            check: ParityCheck::ExactSections(&[TEXT, CONST]),
4322
+        },
4323
+    ];
4324
+
4325
+    let mut failures = Vec::new();
4326
+    for case in &cases {
4327
+        if let Err(err) = assert_case_matches_apple_ld(case, &sdk, &sdk_ver) {
4328
+            failures.push(err);
4329
+        }
4330
+    }
4331
+
4332
+    assert!(
4333
+        failures.is_empty(),
4334
+        "Apple ld parity failures ({} cases):\n{}",
4335
+        failures.len(),
4336
+        failures.join("\n\n")
4337
+    );
4338
+}
4339
+
4340
+#[test]
4341
+fn linker_run_thunks_none_rejects_out_of_range_branch26() {
4342
+    if !have_xcrun() {
4343
+        eprintln!("skipping: xcrun as unavailable");
4344
+        return;
4345
+    }
4346
+
4347
+    let obj = scratch("branch26-range.o");
4348
+    let out = scratch("branch26-range.out");
4349
+    let src = r#"
4350
+        .section __TEXT,__text,regular,pure_instructions
4351
+        .globl _main
4352
+        _main:
4353
+            stp x29, x30, [sp, #-16]!
4354
+            mov x29, sp
4355
+            bl _helper
4356
+            ldp x29, x30, [sp], #16
4357
+            ret
4358
+
4359
+        .zerofill __DATA,__bss,_gap,0x9000000,0
4360
+
4361
+        .section __FAR,__text,regular,pure_instructions
4362
+        .globl _helper
4363
+        _helper:
4364
+            ret
4365
+        .subsections_via_symbols
4366
+    "#;
4367
+    if let Err(e) = assemble(src, &obj) {
4368
+        eprintln!("skipping: assemble failed: {e}");
4369
+        return;
4370
+    }
4371
+
4372
+    let opts = LinkOptions {
4373
+        inputs: vec![obj.clone()],
4374
+        output: Some(out),
4375
+        kind: OutputKind::Executable,
4376
+        thunks: afs_ld::ThunkMode::None,
4377
+        ..LinkOptions::default()
4378
+    };
4379
+    let err = Linker::run(&opts).unwrap_err();
4380
+    match err {
4381
+        LinkError::Reloc(err) => {
4382
+            let msg = err.to_string();
4383
+            assert!(msg.contains("Branch26"), "{msg}");
4384
+            assert!(msg.contains("out of BRANCH26 range"), "{msg}");
4385
+            assert!(msg.contains("_helper"), "{msg}");
4386
+        }
4387
+        other => panic!("expected Reloc error, got {other:?}"),
4388
+    }
4389
+
4390
+    let _ = fs::remove_file(obj);
4391
+}
4392
+
4393
+#[test]
4394
+fn linker_run_inserts_thunk_for_out_of_range_branch26() {
4395
+    if !have_xcrun() || !have_tool("codesign") {
4396
+        eprintln!("skipping: xcrun or codesign unavailable");
4397
+        return;
4398
+    }
4399
+
4400
+    let obj = scratch("branch26-thunk.o");
4401
+    let out = scratch("branch26-thunk.out");
4402
+    let src = r#"
4403
+        .section __TEXT,__text,regular,pure_instructions
4404
+        .globl _main
4405
+        _main:
4406
+            stp x29, x30, [sp, #-16]!
4407
+            mov x29, sp
4408
+            bl _helper
4409
+            ldp x29, x30, [sp], #16
4410
+            ret
4411
+
4412
+        .zerofill __DATA,__bss,_gap,0x9000000,0
4413
+
4414
+        .section __FAR,__text,regular,pure_instructions
4415
+        .globl _helper
4416
+        _helper:
4417
+            mov w0, #0
4418
+            ret
4419
+        .subsections_via_symbols
4420
+    "#;
4421
+    if let Err(e) = assemble(src, &obj) {
4422
+        eprintln!("skipping: assemble failed: {e}");
4423
+        return;
4424
+    }
4425
+
4426
+    let opts = LinkOptions {
4427
+        inputs: vec![obj.clone()],
4428
+        output: Some(out.clone()),
4429
+        kind: OutputKind::Executable,
4430
+        ..LinkOptions::default()
4431
+    };
4432
+    Linker::run(&opts).unwrap();
4433
+
4434
+    let bytes = fs::read(&out).unwrap();
4435
+    let (text_addr, text) = output_section(&bytes, "__TEXT", "__text").unwrap();
4436
+    let (thunks_addr, thunks) = output_section(&bytes, "__TEXT", "__thunks").unwrap();
4437
+    assert_eq!(thunks.len(), 12, "expected one synthetic thunk");
4438
+    assert_eq!(
4439
+        decode_branch_target(&text, text_addr, 8).unwrap(),
4440
+        thunks_addr,
4441
+        "expected _main BL to target __thunks"
4442
+    );
4443
+    assert!(is_adrp(read_insn(&thunks, 0).unwrap()));
4444
+    assert!(is_add_imm_64(read_insn(&thunks, 4).unwrap()));
4445
+    assert_eq!(read_insn(&thunks, 8).unwrap(), 0xd61f_0200);
4446
+
4447
+    let verify = Command::new("codesign")
4448
+        .arg("-v")
4449
+        .arg(&out)
4450
+        .output()
4451
+        .unwrap();
4452
+    assert!(
4453
+        verify.status.success(),
4454
+        "codesign verify failed: {}",
4455
+        String::from_utf8_lossy(&verify.stderr)
4456
+    );
4457
+    let status = Command::new(&out).status().unwrap();
4458
+    assert_eq!(
4459
+        status.code(),
4460
+        Some(0),
4461
+        "expected thunked executable to exit 0"
4462
+    );
4463
+
4464
+    let _ = fs::remove_file(obj);
4465
+    let _ = fs::remove_file(out);
4466
+}
4467
+
4468
+#[test]
4469
+fn linker_run_safe_thunks_do_not_grow_small_programs() {
4470
+    if !have_xcrun() {
4471
+        eprintln!("skipping: xcrun as unavailable");
4472
+        return;
4473
+    }
4474
+
4475
+    let obj = scratch("branch26-small.o");
4476
+    let out = scratch("branch26-small.out");
4477
+    let src = r#"
4478
+        .section __TEXT,__text,regular,pure_instructions
4479
+        .globl _main
4480
+        _main:
4481
+            bl _helper
4482
+            ret
4483
+
4484
+        _helper:
4485
+            mov w0, #0
4486
+            ret
4487
+        .subsections_via_symbols
4488
+    "#;
4489
+    if let Err(e) = assemble(src, &obj) {
4490
+        eprintln!("skipping: assemble failed: {e}");
4491
+        return;
4492
+    }
4493
+
4494
+    let opts = LinkOptions {
4495
+        inputs: vec![obj.clone()],
4496
+        output: Some(out.clone()),
4497
+        kind: OutputKind::Executable,
4498
+        ..LinkOptions::default()
4499
+    };
4500
+    Linker::run(&opts).unwrap();
4501
+
4502
+    assert!(
4503
+        output_section(&fs::read(&out).unwrap(), "__TEXT", "__thunks").is_none(),
4504
+        "small in-range program should not gain __thunks"
4505
+    );
4506
+
4507
+    let _ = fs::remove_file(obj);
4508
+    let _ = fs::remove_file(out);
4509
+}
4510
+
4511
+#[test]
4512
+fn linker_run_thunks_all_forces_shared_thunk_for_in_range_calls() {
4513
+    if !have_xcrun() || !have_tool("codesign") {
4514
+        eprintln!("skipping: xcrun or codesign unavailable");
4515
+        return;
4516
+    }
4517
+
4518
+    let obj = scratch("branch26-thunks-all.o");
4519
+    let out = scratch("branch26-thunks-all.out");
4520
+    let src = r#"
4521
+        .section __TEXT,__text,regular,pure_instructions
4522
+        .globl _main
4523
+        _main:
4524
+            stp x29, x30, [sp, #-16]!
4525
+            mov x29, sp
4526
+            bl _helper
4527
+            bl _helper
4528
+            ldp x29, x30, [sp], #16
4529
+            ret
4530
+
4531
+        _helper:
4532
+            mov w0, #0
4533
+            ret
4534
+        .subsections_via_symbols
4535
+    "#;
4536
+    if let Err(e) = assemble(src, &obj) {
4537
+        eprintln!("skipping: assemble failed: {e}");
4538
+        return;
4539
+    }
4540
+
4541
+    let opts = LinkOptions {
4542
+        inputs: vec![obj.clone()],
4543
+        output: Some(out.clone()),
4544
+        kind: OutputKind::Executable,
4545
+        thunks: afs_ld::ThunkMode::All,
4546
+        ..LinkOptions::default()
4547
+    };
4548
+    Linker::run(&opts).unwrap();
4549
+
4550
+    let bytes = fs::read(&out).unwrap();
4551
+    let (text_addr, text) = output_section(&bytes, "__TEXT", "__text").unwrap();
4552
+    let (thunks_addr, thunks) = output_section(&bytes, "__TEXT", "__thunks").unwrap();
4553
+    assert_eq!(
4554
+        thunks.len(),
4555
+        12,
4556
+        "expected both in-range calls to share one forced thunk"
4557
+    );
4558
+    assert_eq!(
4559
+        decode_branch_target(&text, text_addr, 8).unwrap(),
4560
+        thunks_addr,
4561
+        "expected first BL to route through __thunks under -thunks=all"
4562
+    );
4563
+    assert_eq!(
4564
+        decode_branch_target(&text, text_addr, 12).unwrap(),
4565
+        thunks_addr,
4566
+        "expected second BL to share the same thunk target"
4567
+    );
4568
+
4569
+    let verify = Command::new("codesign")
4570
+        .arg("-v")
4571
+        .arg(&out)
4572
+        .output()
4573
+        .unwrap();
4574
+    assert!(
4575
+        verify.status.success(),
4576
+        "codesign verify failed: {}",
4577
+        String::from_utf8_lossy(&verify.stderr)
4578
+    );
4579
+    let status = Command::new(&out).status().unwrap();
4580
+    assert_eq!(
4581
+        status.code(),
4582
+        Some(0),
4583
+        "expected -thunks=all executable to exit 0"
4584
+    );
4585
+
4586
+    let _ = fs::remove_file(obj);
4587
+    let _ = fs::remove_file(out);
4588
+}
4589
+
4590
+#[test]
4591
+fn linker_run_places_thunks_in_caller_segment() {
4592
+    if !have_xcrun() || !have_tool("codesign") {
4593
+        eprintln!("skipping: xcrun or codesign unavailable");
4594
+        return;
4595
+    }
4596
+
4597
+    let obj = scratch("branch26-custom-segment-thunk.o");
4598
+    let out = scratch("branch26-custom-segment-thunk.out");
4599
+    let src = r#"
4600
+        .section __TEXT,__text,regular,pure_instructions
4601
+        .globl _helper
4602
+        _helper:
4603
+            mov w0, #0
4604
+            ret
4605
+
4606
+        .zerofill __DATA,__bss,_gap,0x9000000,0
4607
+
4608
+        .section __FARCALL,__text,regular,pure_instructions
4609
+        .globl _main
4610
+        _main:
4611
+            stp x29, x30, [sp, #-16]!
4612
+            mov x29, sp
4613
+            bl _helper
4614
+            ldp x29, x30, [sp], #16
4615
+            ret
4616
+        .subsections_via_symbols
4617
+    "#;
4618
+    if let Err(e) = assemble(src, &obj) {
4619
+        eprintln!("skipping: assemble failed: {e}");
4620
+        return;
4621
+    }
4622
+
4623
+    let opts = LinkOptions {
4624
+        inputs: vec![obj.clone()],
4625
+        output: Some(out.clone()),
4626
+        kind: OutputKind::Executable,
4627
+        ..LinkOptions::default()
4628
+    };
4629
+    Linker::run(&opts).unwrap();
4630
+
4631
+    let bytes = fs::read(&out).unwrap();
4632
+    assert!(
4633
+        output_section(&bytes, "__TEXT", "__thunks").is_none(),
4634
+        "expected no __TEXT thunk section for custom-segment caller"
4635
+    );
4636
+    let (text_addr, text) = output_section(&bytes, "__FARCALL", "__text").unwrap();
4637
+    let (thunks_addr, thunks) = output_section(&bytes, "__FARCALL", "__thunks").unwrap();
4638
+    assert_eq!(thunks.len(), 12, "expected one custom-segment thunk");
4639
+    assert_eq!(
4640
+        decode_branch_target(&text, text_addr, 8).unwrap(),
4641
+        thunks_addr,
4642
+        "expected custom-segment BL to target custom-segment thunk"
4643
+    );
4644
+
4645
+    let verify = Command::new("codesign")
4646
+        .arg("-v")
4647
+        .arg(&out)
4648
+        .output()
4649
+        .unwrap();
4650
+    assert!(
4651
+        verify.status.success(),
4652
+        "codesign verify failed: {}",
4653
+        String::from_utf8_lossy(&verify.stderr)
4654
+    );
4655
+
4656
+    let _ = fs::remove_file(obj);
4657
+    let _ = fs::remove_file(out);
4658
+}
4659
+
4660
+#[test]
4661
+fn linker_run_replans_thunks_until_layout_converges() {
4662
+    if !have_xcrun() {
4663
+        eprintln!("skipping: xcrun as unavailable");
4664
+        return;
4665
+    }
4666
+
4667
+    let obj = scratch("branch26-thunk-fixed-point.o");
4668
+    let out = scratch("branch26-thunk-fixed-point.out");
4669
+    let src = r#"
4670
+        .section __TEXT,__text,regular,pure_instructions
4671
+        .globl _main
4672
+        _main:
4673
+            bl _overflow
4674
+            bl _borderline
4675
+            mov w0, #0
4676
+            ret
4677
+
4678
+        .zerofill __TEXT,__apad,_gap,0x7ffffec,2
4679
+
4680
+        .section __TEXT,__late,regular,pure_instructions
4681
+        .globl _borderline
4682
+        _borderline:
4683
+            ret
4684
+
4685
+        .globl _overflow
4686
+        _overflow:
4687
+            ret
4688
+        .subsections_via_symbols
4689
+    "#;
4690
+    if let Err(e) = assemble(src, &obj) {
4691
+        eprintln!("skipping: assemble failed: {e}");
4692
+        return;
4693
+    }
4694
+
4695
+    let opts = LinkOptions {
4696
+        inputs: vec![obj.clone()],
4697
+        output: Some(out.clone()),
4698
+        kind: OutputKind::Executable,
4699
+        ..LinkOptions::default()
4700
+    };
4701
+    Linker::run(&opts).unwrap();
4702
+
4703
+    let bytes = fs::read(&out).unwrap();
4704
+    let (text_addr, text) = output_section(&bytes, "__TEXT", "__text").unwrap();
4705
+    let (thunks_addr, thunks) = output_section(&bytes, "__TEXT", "__thunks").unwrap();
4706
+    assert_eq!(
4707
+        thunks.len(),
4708
+        24,
4709
+        "expected two thunks after fixed-point replanning"
4710
+    );
4711
+    let mut actual_targets = [
4712
+        decode_branch_target(&text, text_addr, 0).unwrap(),
4713
+        decode_branch_target(&text, text_addr, 4).unwrap(),
4714
+    ];
4715
+    actual_targets.sort_unstable();
4716
+    assert_eq!(
4717
+        actual_targets,
4718
+        [thunks_addr, thunks_addr + 12],
4719
+        "expected both branches to redirect through the two thunk slots"
4720
+    );
4721
+
4722
+    let _ = fs::remove_file(obj);
4723
+    let _ = fs::remove_file(out);
4724
+}
4725
+
4726
+#[test]
4727
+fn linker_run_uses_multiple_thunk_islands_within_text_segment() {
4728
+    if !have_xcrun() || !have_tool("codesign") {
4729
+        eprintln!("skipping: xcrun or codesign unavailable");
4730
+        return;
4731
+    }
4732
+
4733
+    let obj = scratch("branch26-multi-island.o");
4734
+    let out = scratch("branch26-multi-island.out");
4735
+    let src = r#"
4736
+        .section __TEXT,__text,regular,pure_instructions
4737
+        .globl _main
4738
+        _main:
4739
+            bl _midcaller
4740
+            mov w0, #0
4741
+            ret
4742
+
4743
+        .zerofill __TEXT,__apad1,_gap1,0x9000000,2
4744
+
4745
+        .section __TEXT,__bmid,regular,pure_instructions
4746
+        .globl _midcaller
4747
+        _midcaller:
4748
+            stp x29, x30, [sp, #-16]!
4749
+            mov x29, sp
4750
+            bl _helper
4751
+            ldp x29, x30, [sp], #16
4752
+            ret
4753
+
4754
+        .zerofill __TEXT,__cpad2,_gap2,0x9000000,2
4755
+
4756
+        .section __TEXT,__dlate,regular,pure_instructions
4757
+        .globl _helper
4758
+        _helper:
4759
+            ret
4760
+        .subsections_via_symbols
4761
+    "#;
4762
+    if let Err(e) = assemble(src, &obj) {
4763
+        eprintln!("skipping: assemble failed: {e}");
4764
+        return;
4765
+    }
4766
+
4767
+    let opts = LinkOptions {
4768
+        inputs: vec![obj.clone()],
4769
+        output: Some(out.clone()),
4770
+        kind: OutputKind::Executable,
4771
+        ..LinkOptions::default()
4772
+    };
4773
+    Linker::run(&opts).unwrap();
4774
+
4775
+    let bytes = fs::read(&out).unwrap();
4776
+    let thunk_sections = output_sections(&bytes, "__TEXT", "__thunks");
4777
+    assert_eq!(
4778
+        thunk_sections.len(),
4779
+        2,
4780
+        "expected one thunk island after __text and one after __mid"
4781
+    );
4782
+    assert!(
4783
+        thunk_sections.iter().all(|(_, bytes)| bytes.len() == 12),
4784
+        "expected one thunk per island"
4785
+    );
4786
+
4787
+    let (text_addr, text) = output_section(&bytes, "__TEXT", "__text").unwrap();
4788
+    let (mid_addr, mid) = output_section(&bytes, "__TEXT", "__bmid").unwrap();
4789
+    let mut actual_targets = [
4790
+        decode_branch_target(&text, text_addr, 0).unwrap(),
4791
+        decode_branch_target(&mid, mid_addr, 8).unwrap(),
4792
+    ];
4793
+    actual_targets.sort_unstable();
4794
+    let mut expected_targets = [thunk_sections[0].0, thunk_sections[1].0];
4795
+    expected_targets.sort_unstable();
4796
+    assert_eq!(
4797
+        actual_targets, expected_targets,
4798
+        "expected the two call sites to route through the two thunk islands"
4799
+    );
4800
+
4801
+    let verify = Command::new("codesign")
4802
+        .arg("-v")
4803
+        .arg(&out)
4804
+        .output()
4805
+        .unwrap();
4806
+    assert!(
4807
+        verify.status.success(),
4808
+        "codesign verify failed: {}",
4809
+        String::from_utf8_lossy(&verify.stderr)
4810
+    );
4811
+
4812
+    let _ = fs::remove_file(obj);
4813
+    let _ = fs::remove_file(out);
4814
+}
4815
+
4816
+#[test]
4817
+fn linker_run_routes_dylib_imports_through_synthetic_sections() {
4818
+    if !have_xcrun() {
4819
+        eprintln!("skipping: xcrun unavailable");
4820
+        return;
4821
+    }
4822
+    let Some(sdk) = sdk_path() else {
4823
+        eprintln!("skipping: xcrun --show-sdk-path unavailable");
4824
+        return;
4825
+    };
4826
+    let tbd = PathBuf::from(format!("{sdk}/usr/lib/libSystem.tbd"));
4827
+    if !tbd.exists() {
4828
+        eprintln!("skipping: no libSystem.tbd at {}", tbd.display());
4829
+        return;
4830
+    }
4831
+
4832
+    let obj = scratch("import-reloc.o");
4833
+    let out = scratch("import-reloc.out");
4834
+    let src = r#"
4835
+        .section __TEXT,__text,regular,pure_instructions
4836
+        .globl _main
4837
+        _main:
4838
+            adrp x0, _write@GOTPAGE
4839
+            ldr x0, [x0, _write@GOTPAGEOFF]
4840
+            bl _write
4841
+            ret
4842
+        .subsections_via_symbols
4843
+    "#;
4844
+    if let Err(e) = assemble(src, &obj) {
4845
+        eprintln!("skipping: assemble failed: {e}");
4846
+        return;
4847
+    }
4848
+
4849
+    let opts = LinkOptions {
4850
+        inputs: vec![obj.clone(), tbd.clone()],
4851
+        output: Some(out.clone()),
4852
+        kind: OutputKind::Executable,
4853
+        ..LinkOptions::default()
4854
+    };
4855
+    Linker::run(&opts).unwrap();
4856
+
4857
+    let bytes = fs::read(&out).unwrap();
4858
+    let header = parse_header(&bytes).unwrap();
4859
+    let commands = parse_commands(&header, &bytes).unwrap();
4860
+    let (text_addr, text) = output_section(&bytes, "__TEXT", "__text").unwrap();
4861
+    let (stubs_addr, stubs) = output_section(&bytes, "__TEXT", "__stubs").unwrap();
4862
+    let (helper_addr, helper) = output_section(&bytes, "__TEXT", "__stub_helper").unwrap();
4863
+    let (got_addr, got) = output_section(&bytes, "__DATA_CONST", "__got").unwrap();
4864
+    let (lazy_addr, lazy) = output_section(&bytes, "__DATA", "__la_symbol_ptr").unwrap();
4865
+    let (dyld_private_addr, _) = output_section(&bytes, "__DATA", "__data").unwrap();
4866
+    let stubs_hdr = output_section_header(&bytes, "__TEXT", "__stubs").unwrap();
4867
+    let got_hdr = output_section_header(&bytes, "__DATA_CONST", "__got").unwrap();
4868
+    let lazy_hdr = output_section_header(&bytes, "__DATA", "__la_symbol_ptr").unwrap();
4869
+
4870
+    let symtab = commands
4871
+        .iter()
4872
+        .find_map(|cmd| match cmd {
4873
+            LoadCommand::Symtab(cmd) => Some(*cmd),
4874
+            _ => None,
4875
+        })
4876
+        .unwrap();
4877
+    let dysymtab = commands
4878
+        .iter()
4879
+        .find_map(|cmd| match cmd {
4880
+            LoadCommand::Dysymtab(cmd) => Some(*cmd),
4881
+            _ => None,
4882
+        })
4883
+        .unwrap();
4884
+    let dyld_info = commands
4885
+        .iter()
4886
+        .find_map(|cmd| match cmd {
4887
+            LoadCommand::DyldInfoOnly(cmd) => Some(*cmd),
4888
+            _ => None,
4889
+        })
4890
+        .unwrap();
4891
+    let libsystem_load = commands
4892
+        .iter()
4893
+        .find_map(|cmd| match cmd {
4894
+            LoadCommand::Dylib(cmd)
4895
+                if cmd.cmd == afs_ld::macho::constants::LC_LOAD_DYLIB
4896
+                    && cmd.name == "/usr/lib/libSystem.B.dylib" =>
4897
+            {
4898
+                Some(cmd.clone())
4899
+            }
4900
+            _ => None,
4901
+        })
4902
+        .unwrap();
4903
+    let symbols = parse_nlist_table(&bytes, symtab.symoff, symtab.nsyms).unwrap();
4904
+    let strings = StringTable::from_file(&bytes, symtab.stroff, symtab.strsize).unwrap();
4905
+    let symbol_names: Vec<&str> = symbols
4906
+        .iter()
4907
+        .map(|symbol| strings.get(symbol.strx()).unwrap())
4908
+        .collect();
4909
+
4910
+    assert_eq!(got.len(), 16);
4911
+    assert_eq!(stubs.len(), 12);
4912
+    assert_eq!(helper.len(), 36);
4913
+    assert_eq!(lazy.len(), 8);
4914
+    assert_eq!(symtab.nsyms, 5);
4915
+    assert_eq!(dysymtab.nlocalsym, 1);
4916
+    assert_eq!(dysymtab.nextdefsym, 2);
4917
+    assert_eq!(dysymtab.nundefsym, 2);
4918
+    assert_eq!(dysymtab.nindirectsyms, 4);
4919
+    assert_eq!(stubs_hdr.reserved1, 0);
4920
+    assert_eq!(got_hdr.reserved1, 1);
4921
+    assert_eq!(lazy_hdr.reserved1, 3);
4922
+    assert_eq!(stubs_hdr.reserved2, 12);
4923
+    assert!(libsystem_load.current_version >= (1 << 16));
4924
+    assert_eq!(libsystem_load.compatibility_version, 1 << 16);
4925
+    assert!(dyld_info.rebase_size > 0);
4926
+    assert!(dyld_info.bind_size > 0);
4927
+    assert!(dyld_info.lazy_bind_size > 0);
4928
+    assert_eq!(
4929
+        decode_page_reference(&text, text_addr, 0, &PageRefKind::Load).unwrap(),
4930
+        got_addr
4931
+    );
4932
+    assert_eq!(
4933
+        decode_branch_target(&text, text_addr, 8).unwrap(),
4934
+        stubs_addr
4935
+    );
4936
+    assert_eq!(
4937
+        decode_page_reference(&stubs, stubs_addr, 0, &PageRefKind::Load).unwrap(),
4938
+        lazy_addr
4939
+    );
4940
+    assert_eq!(read_insn(&stubs, 8).unwrap(), 0xd61f0200);
4941
+    assert_eq!(
4942
+        u64::from_le_bytes(lazy[0..8].try_into().unwrap()),
4943
+        helper_addr + 24
4944
+    );
4945
+    assert_eq!(
4946
+        decode_page_reference(&helper, helper_addr, 0, &PageRefKind::Add).unwrap(),
4947
+        dyld_private_addr
4948
+    );
4949
+    assert_eq!(
4950
+        decode_page_reference(&helper, helper_addr, 12, &PageRefKind::Load).unwrap(),
4951
+        got_addr + 8
4952
+    );
4953
+    assert_eq!(read_insn(&helper, 20).unwrap(), 0xd61f0200);
4954
+    assert_eq!(read_insn(&helper, 24).unwrap(), 0x1800_0050);
4955
+    assert_eq!(
4956
+        decode_branch_target(&helper, helper_addr, 28).unwrap(),
4957
+        helper_addr
4958
+    );
4959
+    assert_eq!(u32::from_le_bytes(helper[32..36].try_into().unwrap()), 0);
4960
+    let (locals, extdefs, undefs) = symbol_partition_names(&bytes);
4961
+    assert_eq!(locals, vec!["__dyld_private".to_string()]);
4962
+    assert_eq!(
4963
+        extdefs,
4964
+        vec!["__mh_execute_header".to_string(), "_main".to_string()]
4965
+    );
4966
+    assert_eq!(
4967
+        undefs,
4968
+        vec!["_write".to_string(), "dyld_stub_binder".to_string()]
4969
+    );
4970
+    assert!(symbol_names.contains(&"__dyld_private"));
4971
+    assert!(symbols[dysymtab.iundefsym as usize..]
4972
+        .iter()
4973
+        .all(|symbol| symbol.kind() == SymKind::Undef));
4974
+    assert!(symbols[dysymtab.iundefsym as usize..]
4975
+        .iter()
4976
+        .all(|symbol| symbol.library_ordinal().unwrap() > 0));
4977
+    assert!(symbol_names.contains(&"_write"));
4978
+    assert!(symbol_names.contains(&"dyld_stub_binder"));
4979
+
4980
+    let _ = fs::remove_file(out);
4981
+    let _ = fs::remove_file(obj);
4982
+}
4983
+
4984
+#[test]
4985
+fn synthetic_import_surfaces_match_apple_ld_classic_lazy_model() {
4986
+    if !have_xcrun() || !have_xcrun_tool("ld") {
4987
+        eprintln!("skipping: xcrun as/ld unavailable");
4988
+        return;
4989
+    }
4990
+    let Some(sdk) = sdk_path() else {
4991
+        eprintln!("skipping: xcrun --show-sdk-path unavailable");
4992
+        return;
4993
+    };
4994
+    let Some(sdk_ver) = sdk_version() else {
4995
+        eprintln!("skipping: xcrun --show-sdk-version unavailable");
4996
+        return;
4997
+    };
4998
+    let tbd = PathBuf::from(format!("{sdk}/usr/lib/libSystem.tbd"));
4999
+    if !tbd.exists() {
5000
+        eprintln!("skipping: no libSystem.tbd at {}", tbd.display());
5001
+        return;
5002
+    }
5003
+
5004
+    let obj = scratch("import-parity.o");
5005
+    let our_out = scratch("import-parity-ours.out");
5006
+    let apple_out = scratch("import-parity-apple.out");
5007
+    let src = r#"
5008
+        .section __TEXT,__text,regular,pure_instructions
5009
+        .globl _main
5010
+        _main:
5011
+            adrp x0, _write@GOTPAGE
5012
+            ldr x0, [x0, _write@GOTPAGEOFF]
5013
+            bl _write
5014
+            ret
5015
+        .subsections_via_symbols
5016
+    "#;
5017
+    if let Err(e) = assemble(src, &obj) {
5018
+        eprintln!("skipping: assemble failed: {e}");
5019
+        return;
5020
+    }
5021
+
5022
+    let opts = LinkOptions {
5023
+        inputs: vec![obj.clone(), tbd],
5024
+        output: Some(our_out.clone()),
5025
+        kind: OutputKind::Executable,
5026
+        ..LinkOptions::default()
5027
+    };
5028
+    Linker::run(&opts).unwrap();
5029
+    apple_link_classic_lazy(&obj, &apple_out, "_main", &sdk, &sdk_ver).unwrap();
5030
+
5031
+    let our_bytes = fs::read(&our_out).unwrap();
5032
+    let apple_bytes = fs::read(&apple_out).unwrap();
5033
+
5034
+    for (segname, sectname) in [("__TEXT", "__stubs"), ("__TEXT", "__stub_helper")] {
5035
+        let (_, ours) = output_section(&our_bytes, segname, sectname).unwrap();
5036
+        let (_, apple) = output_section(&apple_bytes, segname, sectname).unwrap();
5037
+        let diff = diff_macho(&ours, &apple);
5038
+        assert!(
5039
+            diff.is_clean(),
5040
+            "{segname},{sectname} diverged from Apple ld: {:#?}",
5041
+            diff.critical
5042
+        );
5043
+    }
5044
+
5045
+    let (our_helper_addr, _) = output_section(&our_bytes, "__TEXT", "__stub_helper").unwrap();
5046
+    let (apple_helper_addr, _) = output_section(&apple_bytes, "__TEXT", "__stub_helper").unwrap();
5047
+    let (_, our_lazy) = output_section(&our_bytes, "__DATA", "__la_symbol_ptr").unwrap();
5048
+    let (_, apple_lazy) = output_section(&apple_bytes, "__DATA", "__la_symbol_ptr").unwrap();
5049
+    assert_eq!(
5050
+        u64::from_le_bytes(our_lazy[0..8].try_into().unwrap()) - our_helper_addr,
5051
+        24
5052
+    );
5053
+    assert_eq!(
5054
+        u64::from_le_bytes(apple_lazy[0..8].try_into().unwrap()) - apple_helper_addr,
5055
+        24
5056
+    );
5057
+
5058
+    assert_eq!(
5059
+        load_dylib_names(&our_bytes).unwrap(),
5060
+        load_dylib_names(&apple_bytes).unwrap()
5061
+    );
5062
+    assert_eq!(
5063
+        segment_flags(&our_bytes, "__DATA_CONST"),
5064
+        Some(SG_READ_ONLY)
5065
+    );
5066
+    assert_eq!(
5067
+        segment_flags(&our_bytes, "__DATA_CONST"),
5068
+        segment_flags(&apple_bytes, "__DATA_CONST")
5069
+    );
5070
+
5071
+    let our_rebases = decode_rebase_records(&our_bytes).unwrap();
5072
+    let apple_rebases = decode_rebase_records(&apple_bytes).unwrap();
5073
+    assert!(our_rebases
5074
+        .iter()
5075
+        .all(|record| record.rebase_type == REBASE_TYPE_POINTER));
5076
+    assert_eq!(our_rebases, apple_rebases);
5077
+    assert_eq!(
5078
+        decode_bind_records(&our_bytes, false).unwrap(),
5079
+        decode_bind_records(&apple_bytes, false).unwrap()
5080
+    );
5081
+    assert_eq!(
5082
+        dyld_info_stream(&our_bytes, DyldInfoStreamKind::WeakBind).unwrap(),
5083
+        dyld_info_stream(&apple_bytes, DyldInfoStreamKind::WeakBind).unwrap()
5084
+    );
5085
+    assert_eq!(
5086
+        decode_bind_records(&our_bytes, true).unwrap(),
5087
+        decode_bind_records(&apple_bytes, true).unwrap()
5088
+    );
5089
+    assert_eq!(
5090
+        canonical_lazy_bind_stream(&our_bytes).unwrap(),
5091
+        canonical_lazy_bind_stream(&apple_bytes).unwrap()
5092
+    );
5093
+    assert_eq!(
5094
+        indirect_symbol_table(&our_bytes),
5095
+        indirect_symbol_table(&apple_bytes)
5096
+    );
5097
+
5098
+    let _ = fs::remove_file(apple_out);
5099
+    let _ = fs::remove_file(our_out);
5100
+    let _ = fs::remove_file(obj);
5101
+}
5102
+
5103
+#[test]
5104
+fn classic_lazy_surfaces_match_apple_ld_across_fixture_matrix() {
5105
+    if !have_xcrun() || !have_xcrun_tool("ld") {
5106
+        eprintln!("skipping: xcrun as/ld unavailable");
5107
+        return;
5108
+    }
5109
+    let Some(sdk) = sdk_path() else {
5110
+        eprintln!("skipping: xcrun --show-sdk-path unavailable");
5111
+        return;
5112
+    };
5113
+    let Some(sdk_ver) = sdk_version() else {
5114
+        eprintln!("skipping: xcrun --show-sdk-version unavailable");
5115
+        return;
5116
+    };
5117
+
5118
+    let cases = [
5119
+        ClassicLazyParityCase {
5120
+            name: "single-got-and-call",
5121
+            src: r#"
5122
+                .section __TEXT,__text,regular,pure_instructions
5123
+                .globl _main
5124
+                _main:
5125
+                    adrp x0, _write@GOTPAGE
5126
+                    ldr x0, [x0, _write@GOTPAGEOFF]
5127
+                    bl _write
27985128
                     ret
27995129
                 .subsections_via_symbols
28005130
             "#,
2801
-            check: ParityCheck::PageRef {
2802
-                section: TEXT,
2803
-                site_offset: 0x5000,
2804
-                target_offset: 0,
2805
-                kind: PageRefKind::Add,
2806
-            },
28075131
         },
2808
-        ParityCase {
2809
-            name: "adrp-ldr-x-intra-text",
5132
+        ClassicLazyParityCase {
5133
+            name: "batched-got-and-calls",
28105134
             src: r#"
28115135
                 .section __TEXT,__text,regular,pure_instructions
28125136
                 .globl _main
28135137
                 _main:
2814
-                    adrp x0, _target@PAGE
2815
-                    ldr x1, [x0, _target@PAGEOFF]
5138
+                    adrp x0, _write@GOTPAGE
5139
+                    ldr x0, [x0, _write@GOTPAGEOFF]
5140
+                    bl _write
5141
+                    adrp x1, _close@GOTPAGE
5142
+                    ldr x1, [x1, _close@GOTPAGEOFF]
5143
+                    bl _close
5144
+                    adrp x2, _read@GOTPAGE
5145
+                    ldr x2, [x2, _read@GOTPAGEOFF]
5146
+                    bl _read
28165147
                     ret
2817
-                .space 0x3f4
2818
-                _target:
2819
-                    .quad 0x1122334455667788
28205148
                 .subsections_via_symbols
28215149
             "#,
2822
-            check: ParityCheck::PageRef {
2823
-                section: TEXT,
2824
-                site_offset: 0,
2825
-                target_offset: 0x400,
2826
-                kind: PageRefKind::Load,
2827
-            },
28285150
         },
2829
-        ParityCase {
2830
-            name: "adrp-ldr-w-intra-text",
5151
+        ClassicLazyParityCase {
5152
+            name: "branch-only-calls",
28315153
             src: r#"
28325154
                 .section __TEXT,__text,regular,pure_instructions
28335155
                 .globl _main
28345156
                 _main:
2835
-                    adrp x0, _target@PAGE
2836
-                    ldr w1, [x0, _target@PAGEOFF]
5157
+                    bl _write
5158
+                    bl _close
5159
+                    bl _read
28375160
                     ret
2838
-                .space 0x2f4
2839
-                _target:
2840
-                    .long 0x11223344
28415161
                 .subsections_via_symbols
28425162
             "#,
2843
-            check: ParityCheck::PageRef {
2844
-                section: TEXT,
2845
-                site_offset: 0,
2846
-                target_offset: 0x300,
2847
-                kind: PageRefKind::Load,
2848
-            },
28495163
         },
2850
-        ParityCase {
2851
-            name: "adrp-ldrh-intra-text",
5164
+        ClassicLazyParityCase {
5165
+            name: "deduped-import",
28525166
             src: r#"
28535167
                 .section __TEXT,__text,regular,pure_instructions
28545168
                 .globl _main
28555169
                 _main:
2856
-                    adrp x0, _target@PAGE
2857
-                    ldrh w1, [x0, _target@PAGEOFF]
5170
+                    adrp x0, _write@GOTPAGE
5171
+                    ldr x0, [x0, _write@GOTPAGEOFF]
5172
+                    bl _write
5173
+                    bl _write
5174
+                    adrp x1, _write@GOTPAGE
5175
+                    ldr x1, [x1, _write@GOTPAGEOFF]
28585176
                     ret
2859
-                .space 0x1f4
2860
-                _target:
2861
-                    .hword 0x3344
28625177
                 .subsections_via_symbols
28635178
             "#,
2864
-            check: ParityCheck::PageRef {
2865
-                section: TEXT,
2866
-                site_offset: 0,
2867
-                target_offset: 0x200,
2868
-                kind: PageRefKind::Load,
2869
-            },
2870
-        },
2871
-        ParityCase {
2872
-            name: "adrp-ldrb-intra-text",
2873
-            src: r#"
2874
-                .section __TEXT,__text,regular,pure_instructions
2875
-                .globl _main
2876
-                _main:
2877
-                    adrp x0, _target@PAGE
2878
-                    ldrb w1, [x0, _target@PAGEOFF]
2879
-                    ret
2880
-                .space 0xf4
2881
-                _target:
2882
-                    .byte 0x44
2883
-                .subsections_via_symbols
5179
+        },
5180
+    ];
5181
+
5182
+    let mut failures = Vec::new();
5183
+    for case in &cases {
5184
+        if let Err(err) = assert_classic_lazy_case_matches_apple_ld(case, &sdk, &sdk_ver) {
5185
+            failures.push(err);
5186
+        }
5187
+    }
5188
+
5189
+    assert!(
5190
+        failures.is_empty(),
5191
+        "Apple ld classic-lazy parity failures ({} cases):\n{}",
5192
+        failures.len(),
5193
+        failures.join("\n\n")
5194
+    );
5195
+}
5196
+
5197
+#[test]
5198
+fn linker_run_binds_direct_dylib_import_pointers() {
5199
+    if !have_xcrun() || !have_tool("codesign") {
5200
+        eprintln!("skipping: xcrun clang or codesign unavailable");
5201
+        return;
5202
+    }
5203
+    let Some(sdk) = sdk_path() else {
5204
+        eprintln!("skipping: xcrun --show-sdk-path unavailable");
5205
+        return;
5206
+    };
5207
+    let Some(sdk_ver) = sdk_version() else {
5208
+        eprintln!("skipping: xcrun --show-sdk-version unavailable");
5209
+        return;
5210
+    };
5211
+    let tbd = PathBuf::from(format!("{sdk}/usr/lib/libSystem.tbd"));
5212
+    if !tbd.exists() {
5213
+        eprintln!("skipping: no libSystem.tbd at {}", tbd.display());
5214
+        return;
5215
+    }
5216
+
5217
+    let dylib_src = r#"
5218
+        int ext_data = 5;
5219
+    "#;
5220
+    let direct_case = DirectBindParityCase {
5221
+        name: "direct-data",
5222
+        dylib_src,
5223
+        main_src: r#"
5224
+            extern int ext_data;
5225
+            int *p = &ext_data;
5226
+            int main(void) { return *p == 5 ? 0 : 1; }
5227
+        "#,
5228
+    };
5229
+    if let Err(e) = assert_direct_bind_case_matches_apple_ld(&direct_case, &sdk, &sdk_ver) {
5230
+        panic!("{e}");
5231
+    }
5232
+
5233
+    let dylib = scratch("direct-data.dylib");
5234
+    let obj = scratch("direct-data.o");
5235
+    let our_out = scratch("direct-data-ours.out");
5236
+
5237
+    if let Err(e) = compile_dylib_c(dylib_src, &dylib) {
5238
+        eprintln!("skipping: dylib compile failed: {e}");
5239
+        return;
5240
+    }
5241
+
5242
+    let main_src = r#"
5243
+        extern int ext_data;
5244
+        int *p = &ext_data;
5245
+        int main(void) { return *p == 5 ? 0 : 1; }
5246
+    "#;
5247
+    if let Err(e) = compile_c(main_src, &obj) {
5248
+        eprintln!("skipping: compile failed: {e}");
5249
+        return;
5250
+    }
5251
+
5252
+    let opts = LinkOptions {
5253
+        inputs: vec![obj.clone(), tbd.clone(), dylib.clone()],
5254
+        output: Some(our_out.clone()),
5255
+        kind: OutputKind::Executable,
5256
+        ..LinkOptions::default()
5257
+    };
5258
+    Linker::run(&opts).unwrap();
5259
+    let our_bytes = fs::read(&our_out).unwrap();
5260
+    let binds = decode_bind_records(&our_bytes, false).unwrap();
5261
+    assert!(
5262
+        binds.iter().any(|record| {
5263
+            record.segment == "__DATA"
5264
+                && record.section == "__data"
5265
+                && record.section_offset == 0
5266
+                && record.symbol == "_ext_data"
5267
+        }),
5268
+        "missing direct bind for imported data: {binds:#?}"
5269
+    );
5270
+    let verify = Command::new("codesign")
5271
+        .arg("-v")
5272
+        .arg(&our_out)
5273
+        .output()
5274
+        .unwrap();
5275
+    assert!(
5276
+        verify.status.success(),
5277
+        "codesign verify failed: {}",
5278
+        String::from_utf8_lossy(&verify.stderr)
5279
+    );
5280
+    let status = Command::new(&our_out).status().unwrap();
5281
+    assert_eq!(
5282
+        status.code(),
5283
+        Some(0),
5284
+        "expected direct-import pointer executable to exit 0"
5285
+    );
5286
+
5287
+    let _ = fs::remove_file(dylib);
5288
+    let _ = fs::remove_file(obj);
5289
+    let _ = fs::remove_file(our_out);
5290
+}
5291
+
5292
+#[test]
5293
+fn direct_bind_surfaces_match_apple_ld_across_fixture_matrix() {
5294
+    if !have_xcrun() || !have_tool("codesign") {
5295
+        eprintln!("skipping: xcrun clang or codesign unavailable");
5296
+        return;
5297
+    }
5298
+    let Some(sdk) = sdk_path() else {
5299
+        eprintln!("skipping: xcrun --show-sdk-path unavailable");
5300
+        return;
5301
+    };
5302
+    let Some(sdk_ver) = sdk_version() else {
5303
+        eprintln!("skipping: xcrun --show-sdk-version unavailable");
5304
+        return;
5305
+    };
5306
+
5307
+    let cases = [
5308
+        DirectBindParityCase {
5309
+            name: "direct-multi-data",
5310
+            dylib_src: r#"
5311
+                int ext_data = 5;
5312
+                int more_data = 9;
5313
+            "#,
5314
+            main_src: r#"
5315
+                extern int ext_data;
5316
+                extern int more_data;
5317
+                int *p = &ext_data;
5318
+                int *q = &more_data;
5319
+                int main(void) { return (*p == 5 && *q == 9) ? 0 : 1; }
28845320
             "#,
2885
-            check: ParityCheck::PageRef {
2886
-                section: TEXT,
2887
-                site_offset: 0,
2888
-                target_offset: 0x100,
2889
-                kind: PageRefKind::Load,
2890
-            },
28915321
         },
2892
-        ParityCase {
2893
-            name: "mixed-branch-adrp-text",
2894
-            src: r#"
2895
-                .section __TEXT,__text,regular,pure_instructions
2896
-                .globl _main
2897
-                .globl _helper
2898
-                _main:
2899
-                    adrp x0, _target@PAGE
2900
-                    add x0, x0, _target@PAGEOFF
2901
-                    bl _helper
2902
-                    ret
2903
-                _helper:
2904
-                    ret
2905
-                .space 0xff0
2906
-                _target:
2907
-                    .quad 0x99
2908
-                .subsections_via_symbols
5322
+        DirectBindParityCase {
5323
+            name: "direct-and-call-mixed",
5324
+            dylib_src: r#"
5325
+                int ext_data = 5;
5326
+                int ext_fn(void) { return ext_data + 1; }
29095327
             "#,
2910
-            check: ParityCheck::PageRef {
2911
-                section: TEXT,
2912
-                site_offset: 0,
2913
-                target_offset: 0x1004,
2914
-                kind: PageRefKind::Add,
2915
-            },
2916
-        },
2917
-        ParityCase {
2918
-            name: "subtractor-positive",
2919
-            src: r#"
2920
-                .section __TEXT,__text,regular,pure_instructions
2921
-                .globl _helper
2922
-                _helper:
2923
-                    ret
2924
-                .globl _main
2925
-                _main:
2926
-                    bl _helper
2927
-                    ret
2928
-                .section __TEXT,__const
2929
-                .p2align 3
2930
-                _delta:
2931
-                    .quad _helper - _main
2932
-                .subsections_via_symbols
5328
+            main_src: r#"
5329
+                extern int ext_data;
5330
+                extern int ext_fn(void);
5331
+                int *p = &ext_data;
5332
+                int main(void) { return *p + ext_fn() == 11 ? 0 : 1; }
29335333
             "#,
2934
-            check: ParityCheck::ExactSections(&[CONST]),
29355334
         },
2936
-        ParityCase {
2937
-            name: "subtractor-negative",
2938
-            src: r#"
2939
-                .section __TEXT,__text,regular,pure_instructions
2940
-                .globl _helper
2941
-                _helper:
2942
-                    ret
2943
-                .globl _main
2944
-                _main:
2945
-                    ret
2946
-                .section __TEXT,__const
2947
-                .p2align 3
2948
-                _delta:
2949
-                    .quad _main - _helper
2950
-                .subsections_via_symbols
5335
+        DirectBindParityCase {
5336
+            name: "direct-deduped",
5337
+            dylib_src: r#"
5338
+                int ext_data = 5;
29515339
             "#,
2952
-            check: ParityCheck::ExactSections(&[CONST]),
2953
-        },
2954
-        ParityCase {
2955
-            name: "branch-and-subtractor",
2956
-            src: r#"
2957
-                .section __TEXT,__text,regular,pure_instructions
2958
-                .globl _helper
2959
-                _helper:
2960
-                    ret
2961
-                .globl _main
2962
-                _main:
2963
-                    bl _helper
2964
-                    ret
2965
-                .section __TEXT,__const
2966
-                .p2align 3
2967
-                _delta:
2968
-                    .quad _main - _helper
2969
-                .subsections_via_symbols
5340
+            main_src: r#"
5341
+                extern int ext_data;
5342
+                int *p = &ext_data;
5343
+                int *q = &ext_data;
5344
+                int main(void) { return (*p == 5 && *q == 5) ? 0 : 1; }
29705345
             "#,
2971
-            check: ParityCheck::ExactSections(&[TEXT, CONST]),
29725346
         },
29735347
     ];
29745348
 
2975
-    let mut failures = Vec::new();
2976
-    for case in &cases {
2977
-        if let Err(err) = assert_case_matches_apple_ld(case, &sdk, &sdk_ver) {
2978
-            failures.push(err);
2979
-        }
5349
+    let mut failures = Vec::new();
5350
+    for case in &cases {
5351
+        if let Err(err) = assert_direct_bind_case_matches_apple_ld(case, &sdk, &sdk_ver) {
5352
+            failures.push(err);
5353
+        }
5354
+    }
5355
+
5356
+    assert!(
5357
+        failures.is_empty(),
5358
+        "Apple ld direct-bind parity failures ({} cases):\n{}",
5359
+        failures.len(),
5360
+        failures.join("\n\n")
5361
+    );
5362
+}
5363
+
5364
+#[test]
5365
+fn linker_run_rebases_local_absolute_pointers_like_ld() {
5366
+    if !have_xcrun() {
5367
+        eprintln!("skipping: xcrun unavailable");
5368
+        return;
5369
+    }
5370
+    let Some(sdk) = sdk_path() else {
5371
+        eprintln!("skipping: xcrun --show-sdk-path unavailable");
5372
+        return;
5373
+    };
5374
+    let Some(sdk_ver) = sdk_version() else {
5375
+        eprintln!("skipping: xcrun --show-sdk-version unavailable");
5376
+        return;
5377
+    };
5378
+    let tbd = PathBuf::from(format!("{sdk}/usr/lib/libSystem.tbd"));
5379
+    if !tbd.exists() {
5380
+        eprintln!("skipping: no libSystem.tbd at {}", tbd.display());
5381
+        return;
5382
+    }
5383
+
5384
+    let obj = scratch("local-rebase.o");
5385
+    let our_out = scratch("local-rebase-ours.out");
5386
+    let apple_out = scratch("local-rebase-apple.out");
5387
+    let src = r#"
5388
+        int ext = 7;
5389
+        int *p = &ext;
5390
+        int main(void) { return *p == 7 ? 0 : 1; }
5391
+    "#;
5392
+    if let Err(e) = compile_c(src, &obj) {
5393
+        eprintln!("skipping: clang compile failed: {e}");
5394
+        return;
5395
+    }
5396
+
5397
+    let opts = LinkOptions {
5398
+        inputs: vec![obj.clone(), tbd],
5399
+        output: Some(our_out.clone()),
5400
+        kind: OutputKind::Executable,
5401
+        ..LinkOptions::default()
5402
+    };
5403
+    Linker::run(&opts).unwrap();
5404
+    apple_link_classic_lazy(&obj, &apple_out, "_main", &sdk, &sdk_ver).unwrap();
5405
+
5406
+    let our_bytes = fs::read(&our_out).unwrap();
5407
+    let apple_bytes = fs::read(&apple_out).unwrap();
5408
+    assert!(!dyld_info_stream(&our_bytes, DyldInfoStreamKind::Rebase)
5409
+        .unwrap()
5410
+        .is_empty());
5411
+    assert_eq!(
5412
+        dyld_info_stream(&our_bytes, DyldInfoStreamKind::Rebase).unwrap(),
5413
+        dyld_info_stream(&apple_bytes, DyldInfoStreamKind::Rebase).unwrap()
5414
+    );
5415
+    assert_eq!(
5416
+        decode_rebase_records(&our_bytes).unwrap(),
5417
+        decode_rebase_records(&apple_bytes).unwrap()
5418
+    );
5419
+
5420
+    let our_status = Command::new(&our_out).status().unwrap();
5421
+    let apple_status = Command::new(&apple_out).status().unwrap();
5422
+    assert_eq!(our_status.code(), Some(0));
5423
+    assert_eq!(apple_status.code(), Some(0));
5424
+
5425
+    let _ = fs::remove_file(obj);
5426
+    let _ = fs::remove_file(our_out);
5427
+    let _ = fs::remove_file(apple_out);
5428
+}
5429
+
5430
+#[test]
5431
+fn linker_run_routes_local_got_loads_through_rebased_slots() {
5432
+    if !have_xcrun() || !have_tool("codesign") {
5433
+        eprintln!("skipping: xcrun or codesign unavailable");
5434
+        return;
5435
+    }
5436
+    let Some(sdk) = sdk_path() else {
5437
+        eprintln!("skipping: xcrun --show-sdk-path unavailable");
5438
+        return;
5439
+    };
5440
+    let Some(sdk_ver) = sdk_version() else {
5441
+        eprintln!("skipping: xcrun --show-sdk-version unavailable");
5442
+        return;
5443
+    };
5444
+    let tbd = PathBuf::from(format!("{sdk}/usr/lib/libSystem.tbd"));
5445
+    if !tbd.exists() {
5446
+        eprintln!("skipping: no libSystem.tbd at {}", tbd.display());
5447
+        return;
5448
+    }
5449
+
5450
+    let obj = scratch("local-got.o");
5451
+    let our_out = scratch("local-got-ours.out");
5452
+    let apple_out = scratch("local-got-apple.out");
5453
+    let src = r#"
5454
+        .section __TEXT,__text,regular,pure_instructions
5455
+        .globl _main
5456
+        _main:
5457
+            adrp x8, _value@GOTPAGE
5458
+            ldr x8, [x8, _value@GOTPAGEOFF]
5459
+            ldr w0, [x8]
5460
+            ret
5461
+
5462
+        .section __DATA,__data
5463
+        .globl _value
5464
+        .p2align 2
5465
+        _value:
5466
+            .long 7
5467
+        .subsections_via_symbols
5468
+    "#;
5469
+    if let Err(e) = assemble(src, &obj) {
5470
+        eprintln!("skipping: assemble failed: {e}");
5471
+        return;
5472
+    }
5473
+
5474
+    let opts = LinkOptions {
5475
+        inputs: vec![obj.clone(), tbd.clone()],
5476
+        output: Some(our_out.clone()),
5477
+        kind: OutputKind::Executable,
5478
+        ..LinkOptions::default()
5479
+    };
5480
+    Linker::run(&opts).unwrap();
5481
+    apple_link_classic_lazy(&obj, &apple_out, "_main", &sdk, &sdk_ver).unwrap();
5482
+
5483
+    let our_bytes = fs::read(&our_out).unwrap();
5484
+    let apple_bytes = fs::read(&apple_out).unwrap();
5485
+    let our_binds = decode_bind_records(&our_bytes, false).unwrap();
5486
+    let apple_binds = decode_bind_records(&apple_bytes, false).unwrap();
5487
+    assert_eq!(our_binds, apple_binds);
5488
+    assert!(
5489
+        our_binds.iter().all(|record| record.symbol != "_value"),
5490
+        "local GOT target should not be emitted as a dylib bind: {our_binds:#?}"
5491
+    );
5492
+    assert_eq!(
5493
+        output_section(&our_bytes, "__DATA_CONST", "__got")
5494
+            .expect("missing __got section")
5495
+            .1
5496
+            .len(),
5497
+        8
5498
+    );
5499
+    let verify = Command::new("codesign")
5500
+        .arg("-v")
5501
+        .arg(&our_out)
5502
+        .output()
5503
+        .unwrap();
5504
+    assert!(
5505
+        verify.status.success(),
5506
+        "codesign verify failed: {}",
5507
+        String::from_utf8_lossy(&verify.stderr)
5508
+    );
5509
+    let status = Command::new(&our_out).status().unwrap();
5510
+    assert_eq!(
5511
+        status.code(),
5512
+        Some(7),
5513
+        "expected local GOT executable to exit 7"
5514
+    );
5515
+
5516
+    let _ = fs::remove_file(obj);
5517
+    let _ = fs::remove_file(our_out);
5518
+    let _ = fs::remove_file(apple_out);
5519
+}
5520
+
5521
+#[test]
5522
+fn linker_run_dead_strip_prunes_synthetic_import_sections() {
5523
+    if !have_xcrun() || !have_tool("codesign") {
5524
+        eprintln!("skipping: xcrun or codesign unavailable");
5525
+        return;
5526
+    }
5527
+    let Some(sdk) = sdk_path() else {
5528
+        eprintln!("skipping: xcrun --show-sdk-path unavailable");
5529
+        return;
5530
+    };
5531
+    let Some(sdk_ver) = sdk_version() else {
5532
+        eprintln!("skipping: xcrun --show-sdk-version unavailable");
5533
+        return;
5534
+    };
5535
+    let tbd = PathBuf::from(format!("{sdk}/usr/lib/libSystem.tbd"));
5536
+    if !tbd.exists() {
5537
+        eprintln!("skipping: no libSystem.tbd at {}", tbd.display());
5538
+        return;
5539
+    }
5540
+
5541
+    let obj = scratch("dead-strip-import.o");
5542
+    let our_out = scratch("dead-strip-import-ours.out");
5543
+    let apple_out = scratch("dead-strip-import-apple.out");
5544
+    let src = r#"
5545
+        .section __TEXT,__text,regular,pure_instructions
5546
+        .globl _main
5547
+        _main:
5548
+            mov w0, #0
5549
+            ret
5550
+
5551
+        .globl _unused
5552
+        _unused:
5553
+            bl _puts
5554
+            mov w0, #0
5555
+            ret
5556
+        .subsections_via_symbols
5557
+    "#;
5558
+    if let Err(e) = assemble(src, &obj) {
5559
+        eprintln!("skipping: assemble failed: {e}");
5560
+        return;
5561
+    }
5562
+
5563
+    let opts = LinkOptions {
5564
+        inputs: vec![obj.clone(), tbd.clone()],
5565
+        output: Some(our_out.clone()),
5566
+        dead_strip: true,
5567
+        kind: OutputKind::Executable,
5568
+        ..LinkOptions::default()
5569
+    };
5570
+    Linker::run(&opts).unwrap();
5571
+    apple_link_with_args(
5572
+        &obj,
5573
+        &apple_out,
5574
+        "_main",
5575
+        &sdk,
5576
+        &sdk_ver,
5577
+        &["-dead_strip", "-no_fixup_chains"],
5578
+    )
5579
+    .unwrap();
5580
+
5581
+    let our_bytes = fs::read(&our_out).unwrap();
5582
+    let apple_bytes = fs::read(&apple_out).unwrap();
5583
+    for (segname, sectname) in [
5584
+        ("__TEXT", "__stubs"),
5585
+        ("__TEXT", "__stub_helper"),
5586
+        ("__DATA", "__la_symbol_ptr"),
5587
+        ("__DATA_CONST", "__got"),
5588
+    ] {
5589
+        assert!(
5590
+            output_section(&our_bytes, segname, sectname).is_none(),
5591
+            "unexpected synthetic section {segname},{sectname} in our output"
5592
+        );
5593
+        assert!(
5594
+            output_section(&apple_bytes, segname, sectname).is_none(),
5595
+            "unexpected synthetic section {segname},{sectname} in apple output"
5596
+        );
5597
+    }
5598
+    assert!(decode_bind_records(&our_bytes, false).unwrap().is_empty());
5599
+    assert_eq!(
5600
+        decode_bind_records(&our_bytes, false).unwrap(),
5601
+        decode_bind_records(&apple_bytes, false).unwrap()
5602
+    );
5603
+
5604
+    let verify = Command::new("codesign")
5605
+        .arg("-v")
5606
+        .arg(&our_out)
5607
+        .output()
5608
+        .unwrap();
5609
+    assert!(
5610
+        verify.status.success(),
5611
+        "codesign verify failed: {}",
5612
+        String::from_utf8_lossy(&verify.stderr)
5613
+    );
5614
+    let status = Command::new(&our_out).status().unwrap();
5615
+    assert_eq!(status.code(), Some(0));
5616
+
5617
+    let _ = fs::remove_file(obj);
5618
+    let _ = fs::remove_file(our_out);
5619
+    let _ = fs::remove_file(apple_out);
5620
+}
5621
+
5622
+#[test]
5623
+fn linker_run_relaxes_hidden_got_loads_like_apple_ld() {
5624
+    if !have_xcrun() || !have_tool("codesign") {
5625
+        eprintln!("skipping: xcrun or codesign unavailable");
5626
+        return;
5627
+    }
5628
+    let Some(sdk) = sdk_path() else {
5629
+        eprintln!("skipping: xcrun --show-sdk-path unavailable");
5630
+        return;
5631
+    };
5632
+    let Some(sdk_ver) = sdk_version() else {
5633
+        eprintln!("skipping: xcrun --show-sdk-version unavailable");
5634
+        return;
5635
+    };
5636
+    let tbd = PathBuf::from(format!("{sdk}/usr/lib/libSystem.tbd"));
5637
+    if !tbd.exists() {
5638
+        eprintln!("skipping: no libSystem.tbd at {}", tbd.display());
5639
+        return;
5640
+    }
5641
+
5642
+    let obj = scratch("hidden-got.o");
5643
+    let our_out = scratch("hidden-got-ours.out");
5644
+    let apple_out = scratch("hidden-got-apple.out");
5645
+    let src = r#"
5646
+        .section __TEXT,__text,regular,pure_instructions
5647
+        .globl _main
5648
+        _main:
5649
+            adrp x8, _value@GOTPAGE
5650
+            ldr x8, [x8, _value@GOTPAGEOFF]
5651
+            ldr w0, [x8]
5652
+            ret
5653
+
5654
+        .private_extern _value
5655
+        .section __DATA,__data
5656
+        .p2align 2
5657
+        _value:
5658
+            .long 7
5659
+        .subsections_via_symbols
5660
+    "#;
5661
+    if let Err(e) = assemble(src, &obj) {
5662
+        eprintln!("skipping: assemble failed: {e}");
5663
+        return;
5664
+    }
5665
+
5666
+    let opts = LinkOptions {
5667
+        inputs: vec![obj.clone(), tbd.clone()],
5668
+        output: Some(our_out.clone()),
5669
+        kind: OutputKind::Executable,
5670
+        ..LinkOptions::default()
5671
+    };
5672
+    Linker::run(&opts).unwrap();
5673
+    apple_link_classic_lazy(&obj, &apple_out, "_main", &sdk, &sdk_ver).unwrap();
5674
+
5675
+    let our_bytes = fs::read(&our_out).unwrap();
5676
+    let apple_bytes = fs::read(&apple_out).unwrap();
5677
+    let (our_text_addr, our_text) = output_section(&our_bytes, "__TEXT", "__text").unwrap();
5678
+    let (apple_text_addr, apple_text) = output_section(&apple_bytes, "__TEXT", "__text").unwrap();
5679
+    assert_eq!(
5680
+        decode_page_reference(&our_text, our_text_addr, 0, &PageRefKind::Add).unwrap(),
5681
+        decode_page_reference(&apple_text, apple_text_addr, 0, &PageRefKind::Add).unwrap()
5682
+    );
5683
+    assert_eq!(our_text, apple_text);
5684
+    assert!(output_section(&our_bytes, "__DATA_CONST", "__got").is_none());
5685
+    assert!(output_section(&apple_bytes, "__DATA_CONST", "__got").is_none());
5686
+
5687
+    let verify = Command::new("codesign")
5688
+        .arg("-v")
5689
+        .arg(&our_out)
5690
+        .output()
5691
+        .unwrap();
5692
+    assert!(
5693
+        verify.status.success(),
5694
+        "codesign verify failed: {}",
5695
+        String::from_utf8_lossy(&verify.stderr)
5696
+    );
5697
+    let status = Command::new(&our_out).status().unwrap();
5698
+    assert_eq!(
5699
+        status.code(),
5700
+        Some(7),
5701
+        "expected hidden GOT executable to exit 7"
5702
+    );
5703
+
5704
+    let _ = fs::remove_file(obj);
5705
+    let _ = fs::remove_file(our_out);
5706
+    let _ = fs::remove_file(apple_out);
5707
+}
5708
+
5709
+#[test]
5710
+fn linker_run_partitions_symtab_like_ld() {
5711
+    if !have_xcrun() {
5712
+        eprintln!("skipping: xcrun unavailable");
5713
+        return;
5714
+    }
5715
+
5716
+    let dylib = scratch("symtab-partition.dylib");
5717
+    let obj = scratch("symtab-partition.o");
5718
+    let our_out = scratch("symtab-partition-ours.out");
5719
+    let apple_out = scratch("symtab-partition-apple.out");
5720
+
5721
+    let dylib_src = r#"
5722
+        int ext_data = 5;
5723
+    "#;
5724
+    if let Err(e) = compile_dylib_c(dylib_src, &dylib) {
5725
+        eprintln!("skipping: dylib compile failed: {e}");
5726
+        return;
5727
+    }
5728
+
5729
+    let asm = r#"
5730
+        .text
5731
+        .private_extern _hidden
5732
+        .globl _visible
5733
+        .globl _main
5734
+        .p2align 2
5735
+    _local:
5736
+        ret
5737
+    _hidden:
5738
+        ret
5739
+    _visible:
5740
+        ret
5741
+    _main:
5742
+        ret
5743
+
5744
+        .data
5745
+        .quad _ext_data
5746
+        .subsections_via_symbols
5747
+    "#;
5748
+    if let Err(e) = assemble(asm, &obj) {
5749
+        eprintln!("skipping: assemble failed: {e}");
5750
+        return;
5751
+    }
5752
+
5753
+    let opts = LinkOptions {
5754
+        inputs: vec![obj.clone(), dylib.clone()],
5755
+        output: Some(our_out.clone()),
5756
+        kind: OutputKind::Executable,
5757
+        ..LinkOptions::default()
5758
+    };
5759
+    Linker::run(&opts).unwrap();
5760
+
5761
+    let apple = Command::new("xcrun")
5762
+        .args(["ld", "-arch", "arm64", "-e", "_main", "-o"])
5763
+        .arg(&apple_out)
5764
+        .arg(&obj)
5765
+        .arg(&dylib)
5766
+        .output()
5767
+        .unwrap();
5768
+    assert!(
5769
+        apple.status.success(),
5770
+        "xcrun ld failed: {}",
5771
+        String::from_utf8_lossy(&apple.stderr)
5772
+    );
5773
+
5774
+    let our_bytes = fs::read(&our_out).unwrap();
5775
+    let apple_bytes = fs::read(&apple_out).unwrap();
5776
+    let (our_symtab, our_dysymtab) = symtab_and_dysymtab(&our_bytes);
5777
+    let (apple_symtab, apple_dysymtab) = symtab_and_dysymtab(&apple_bytes);
5778
+
5779
+    assert_eq!(our_symtab.nsyms, apple_symtab.nsyms);
5780
+    assert_eq!(our_dysymtab.ilocalsym, apple_dysymtab.ilocalsym);
5781
+    assert_eq!(our_dysymtab.nlocalsym, apple_dysymtab.nlocalsym);
5782
+    assert_eq!(our_dysymtab.iextdefsym, apple_dysymtab.iextdefsym);
5783
+    assert_eq!(our_dysymtab.nextdefsym, apple_dysymtab.nextdefsym);
5784
+    assert_eq!(our_dysymtab.iundefsym, apple_dysymtab.iundefsym);
5785
+    assert_eq!(our_dysymtab.nundefsym, apple_dysymtab.nundefsym);
5786
+    assert_eq!(
5787
+        canonical_symbol_records(&our_bytes),
5788
+        canonical_symbol_records(&apple_bytes)
5789
+    );
5790
+    assert_strtab_within_five_percent(
5791
+        &raw_string_table(&our_bytes),
5792
+        &raw_string_table(&apple_bytes),
5793
+    );
5794
+
5795
+    assert_eq!(
5796
+        symbol_partition_names(&our_bytes),
5797
+        symbol_partition_names(&apple_bytes)
5798
+    );
5799
+
5800
+    let _ = fs::remove_file(dylib);
5801
+    let _ = fs::remove_file(obj);
5802
+    let _ = fs::remove_file(our_out);
5803
+    let _ = fs::remove_file(apple_out);
5804
+}
5805
+
5806
+#[test]
5807
+fn linker_run_strips_locals_with_x_like_ld() {
5808
+    if !have_xcrun() {
5809
+        eprintln!("skipping: xcrun unavailable");
5810
+        return;
5811
+    }
5812
+
5813
+    let dylib = scratch("symtab-strip.dylib");
5814
+    let obj = scratch("symtab-strip.o");
5815
+    let our_out = scratch("symtab-strip-ours.out");
5816
+    let apple_out = scratch("symtab-strip-apple.out");
5817
+
5818
+    let dylib_src = r#"
5819
+        int ext_data = 5;
5820
+    "#;
5821
+    if let Err(e) = compile_dylib_c(dylib_src, &dylib) {
5822
+        eprintln!("skipping: dylib compile failed: {e}");
5823
+        return;
5824
+    }
5825
+
5826
+    let asm = r#"
5827
+        .text
5828
+        .private_extern _hidden
5829
+        .globl _visible
5830
+        .globl _main
5831
+        .p2align 2
5832
+    _local:
5833
+        ret
5834
+    _hidden:
5835
+        ret
5836
+    _visible:
5837
+        ret
5838
+    _main:
5839
+        ret
5840
+
5841
+        .data
5842
+        .quad _ext_data
5843
+        .subsections_via_symbols
5844
+    "#;
5845
+    if let Err(e) = assemble(asm, &obj) {
5846
+        eprintln!("skipping: assemble failed: {e}");
5847
+        return;
29805848
     }
29815849
 
5850
+    let opts = LinkOptions {
5851
+        inputs: vec![obj.clone(), dylib.clone()],
5852
+        output: Some(our_out.clone()),
5853
+        kind: OutputKind::Executable,
5854
+        strip_locals: true,
5855
+        ..LinkOptions::default()
5856
+    };
5857
+    Linker::run(&opts).unwrap();
5858
+
5859
+    let apple = Command::new("xcrun")
5860
+        .args(["ld", "-arch", "arm64", "-x", "-e", "_main", "-o"])
5861
+        .arg(&apple_out)
5862
+        .arg(&obj)
5863
+        .arg(&dylib)
5864
+        .output()
5865
+        .unwrap();
29825866
     assert!(
2983
-        failures.is_empty(),
2984
-        "Apple ld parity failures ({} cases):\n{}",
2985
-        failures.len(),
2986
-        failures.join("\n\n")
5867
+        apple.status.success(),
5868
+        "xcrun ld failed: {}",
5869
+        String::from_utf8_lossy(&apple.stderr)
5870
+    );
5871
+
5872
+    let our_bytes = fs::read(&our_out).unwrap();
5873
+    let apple_bytes = fs::read(&apple_out).unwrap();
5874
+    let (our_symtab, our_dysymtab) = symtab_and_dysymtab(&our_bytes);
5875
+    let (apple_symtab, apple_dysymtab) = symtab_and_dysymtab(&apple_bytes);
5876
+
5877
+    assert_eq!(our_symtab.nsyms, apple_symtab.nsyms);
5878
+    assert_eq!(our_dysymtab.ilocalsym, apple_dysymtab.ilocalsym);
5879
+    assert_eq!(our_dysymtab.nlocalsym, apple_dysymtab.nlocalsym);
5880
+    assert_eq!(our_dysymtab.iextdefsym, apple_dysymtab.iextdefsym);
5881
+    assert_eq!(our_dysymtab.nextdefsym, apple_dysymtab.nextdefsym);
5882
+    assert_eq!(our_dysymtab.iundefsym, apple_dysymtab.iundefsym);
5883
+    assert_eq!(our_dysymtab.nundefsym, apple_dysymtab.nundefsym);
5884
+    assert_eq!(
5885
+        canonical_symbol_records(&our_bytes),
5886
+        canonical_symbol_records(&apple_bytes)
5887
+    );
5888
+
5889
+    let (locals, extdefs, undefs) = symbol_partition_names(&our_bytes);
5890
+    assert!(locals.is_empty());
5891
+    assert_eq!(
5892
+        extdefs,
5893
+        vec![
5894
+            "__mh_execute_header".to_string(),
5895
+            "_main".to_string(),
5896
+            "_visible".to_string()
5897
+        ]
5898
+    );
5899
+    assert_eq!(undefs, vec!["_ext_data".to_string()]);
5900
+
5901
+    let _ = fs::remove_file(dylib);
5902
+    let _ = fs::remove_file(obj);
5903
+    let _ = fs::remove_file(our_out);
5904
+    let _ = fs::remove_file(apple_out);
5905
+}
5906
+
5907
+#[test]
5908
+fn linker_run_emits_leaf_unwind_info_like_ld() {
5909
+    if !have_xcrun() {
5910
+        eprintln!("skipping: xcrun unavailable");
5911
+        return;
5912
+    }
5913
+    let Some(sdk) = sdk_path() else {
5914
+        eprintln!("skipping: xcrun --show-sdk-path unavailable");
5915
+        return;
5916
+    };
5917
+    let Some(sdk_ver) = sdk_version() else {
5918
+        eprintln!("skipping: xcrun --show-sdk-version unavailable");
5919
+        return;
5920
+    };
5921
+
5922
+    let obj = scratch("unwind-leaf.o");
5923
+    let our_out = scratch("unwind-leaf-ours.out");
5924
+    let apple_out = scratch("unwind-leaf-apple.out");
5925
+    let src = r#"
5926
+        int main(void) {
5927
+            return 0;
5928
+        }
5929
+    "#;
5930
+    if let Err(e) = compile_c(src, &obj) {
5931
+        eprintln!("skipping: clang compile failed: {e}");
5932
+        return;
5933
+    }
5934
+
5935
+    let opts = LinkOptions {
5936
+        inputs: vec![obj.clone()],
5937
+        output: Some(our_out.clone()),
5938
+        kind: OutputKind::Executable,
5939
+        ..LinkOptions::default()
5940
+    };
5941
+    Linker::run(&opts).unwrap();
5942
+    apple_link(&obj, &apple_out, "_main", &sdk, &sdk_ver).unwrap();
5943
+
5944
+    let our_bytes = fs::read(&our_out).unwrap();
5945
+    let apple_bytes = fs::read(&apple_out).unwrap();
5946
+    assert_eq!(
5947
+        rebased_unwind_bytes(&our_bytes),
5948
+        rebased_unwind_bytes(&apple_bytes)
5949
+    );
5950
+    assert!(output_section(&our_bytes, "__LD", "__compact_unwind").is_none());
5951
+
5952
+    let _ = fs::remove_file(obj);
5953
+    let _ = fs::remove_file(our_out);
5954
+    let _ = fs::remove_file(apple_out);
5955
+}
5956
+
5957
+#[test]
5958
+fn linker_run_emits_multi_function_unwind_info_like_ld() {
5959
+    if !have_xcrun() {
5960
+        eprintln!("skipping: xcrun unavailable");
5961
+        return;
5962
+    }
5963
+    let Some(sdk) = sdk_path() else {
5964
+        eprintln!("skipping: xcrun --show-sdk-path unavailable");
5965
+        return;
5966
+    };
5967
+    let Some(sdk_ver) = sdk_version() else {
5968
+        eprintln!("skipping: xcrun --show-sdk-version unavailable");
5969
+        return;
5970
+    };
5971
+
5972
+    let obj = scratch("unwind-mixed.o");
5973
+    let our_out = scratch("unwind-mixed-ours.out");
5974
+    let apple_out = scratch("unwind-mixed-apple.out");
5975
+    let src = r#"
5976
+        int helper(void) {
5977
+            return 1;
5978
+        }
5979
+
5980
+        int main(void) {
5981
+            return helper();
5982
+        }
5983
+    "#;
5984
+    if let Err(e) = compile_c(src, &obj) {
5985
+        eprintln!("skipping: clang compile failed: {e}");
5986
+        return;
5987
+    }
5988
+
5989
+    let opts = LinkOptions {
5990
+        inputs: vec![obj.clone()],
5991
+        output: Some(our_out.clone()),
5992
+        kind: OutputKind::Executable,
5993
+        ..LinkOptions::default()
5994
+    };
5995
+    Linker::run(&opts).unwrap();
5996
+    apple_link(&obj, &apple_out, "_main", &sdk, &sdk_ver).unwrap();
5997
+
5998
+    let our_bytes = fs::read(&our_out).unwrap();
5999
+    let apple_bytes = fs::read(&apple_out).unwrap();
6000
+    assert_eq!(
6001
+        rebased_unwind_bytes(&our_bytes),
6002
+        rebased_unwind_bytes(&apple_bytes)
29876003
     );
6004
+    assert!(output_section(&our_bytes, "__LD", "__compact_unwind").is_none());
6005
+
6006
+    let _ = fs::remove_file(obj);
6007
+    let _ = fs::remove_file(our_out);
6008
+    let _ = fs::remove_file(apple_out);
29886009
 }
29896010
 
29906011
 #[test]
2991
-fn linker_run_rejects_out_of_range_branch26() {
6012
+fn linker_run_dead_strip_prunes_unused_unwind_records_like_ld() {
29926013
     if !have_xcrun() {
2993
-        eprintln!("skipping: xcrun as unavailable");
6014
+        eprintln!("skipping: xcrun unavailable");
29946015
         return;
29956016
     }
6017
+    let Some(sdk) = sdk_path() else {
6018
+        eprintln!("skipping: xcrun --show-sdk-path unavailable");
6019
+        return;
6020
+    };
6021
+    let Some(sdk_ver) = sdk_version() else {
6022
+        eprintln!("skipping: xcrun --show-sdk-version unavailable");
6023
+        return;
6024
+    };
29966025
 
2997
-    let obj = scratch("branch26-range.o");
2998
-    let out = scratch("branch26-range.out");
6026
+    let obj = scratch("unwind-dead-strip.o");
6027
+    let our_out = scratch("unwind-dead-strip-ours.out");
6028
+    let apple_out = scratch("unwind-dead-strip-apple.out");
29996029
     let src = r#"
3000
-        .section __TEXT,__text,regular,pure_instructions
3001
-        .globl _main
3002
-        _main:
3003
-            bl _helper
3004
-            ret
6030
+        int helper(void) {
6031
+            return 1;
6032
+        }
30056033
 
3006
-        .zerofill __DATA,__bss,_gap,0x9000000,0
6034
+        int unused(void) {
6035
+            return 2;
6036
+        }
30076037
 
3008
-        .section __FAR,__text,regular,pure_instructions
3009
-        .globl _helper
3010
-        _helper:
3011
-            ret
3012
-        .subsections_via_symbols
6038
+        int main(void) {
6039
+            return helper();
6040
+        }
30136041
     "#;
3014
-    if let Err(e) = assemble(src, &obj) {
3015
-        eprintln!("skipping: assemble failed: {e}");
6042
+    if let Err(e) = compile_c(src, &obj) {
6043
+        eprintln!("skipping: clang compile failed: {e}");
30166044
         return;
30176045
     }
30186046
 
30196047
     let opts = LinkOptions {
30206048
         inputs: vec![obj.clone()],
3021
-        output: Some(out),
6049
+        output: Some(our_out.clone()),
30226050
         kind: OutputKind::Executable,
6051
+        dead_strip: true,
30236052
         ..LinkOptions::default()
30246053
     };
3025
-    let err = Linker::run(&opts).unwrap_err();
3026
-    match err {
3027
-        LinkError::Reloc(err) => {
3028
-            let msg = err.to_string();
3029
-            assert!(msg.contains("Branch26"), "{msg}");
3030
-            assert!(msg.contains("out of BRANCH26 range"), "{msg}");
3031
-            assert!(msg.contains("_helper"), "{msg}");
3032
-        }
3033
-        other => panic!("expected Reloc error, got {other:?}"),
3034
-    }
6054
+    Linker::run(&opts).unwrap();
6055
+    apple_link_with_args(&obj, &apple_out, "_main", &sdk, &sdk_ver, &["-dead_strip"]).unwrap();
6056
+
6057
+    let our_bytes = fs::read(&our_out).unwrap();
6058
+    let apple_bytes = fs::read(&apple_out).unwrap();
6059
+    let (_, our_unwind) = output_section(&our_bytes, "__TEXT", "__unwind_info").unwrap();
6060
+    let (_, apple_unwind) = output_section(&apple_bytes, "__TEXT", "__unwind_info").unwrap();
6061
+    let our_decoded = decode_unwind_info(&our_unwind).unwrap();
6062
+    let apple_decoded = decode_unwind_info(&apple_unwind).unwrap();
6063
+    let normalize = |records: &[afs_ld::synth::unwind::DecodedUnwindRecord]| {
6064
+        let base = records
6065
+            .first()
6066
+            .map(|record| record.function_offset)
6067
+            .unwrap_or(0);
6068
+        records
6069
+            .iter()
6070
+            .map(|record| (record.function_offset - base, record.encoding))
6071
+            .collect::<Vec<_>>()
6072
+    };
6073
+    assert_eq!(
6074
+        normalize(&our_decoded.records),
6075
+        normalize(&apple_decoded.records)
6076
+    );
6077
+    assert_eq!(our_decoded.records.len(), 2);
30356078
 
30366079
     let _ = fs::remove_file(obj);
6080
+    let _ = fs::remove_file(our_out);
6081
+    let _ = fs::remove_file(apple_out);
30376082
 }
30386083
 
30396084
 #[test]
3040
-fn linker_run_routes_dylib_imports_through_synthetic_sections() {
6085
+fn linker_run_handles_large_unwind_function_gaps() {
30416086
     if !have_xcrun() {
30426087
         eprintln!("skipping: xcrun unavailable");
30436088
         return;
30446089
     }
3045
-    let Some(sdk) = sdk_path() else {
3046
-        eprintln!("skipping: xcrun --show-sdk-path unavailable");
3047
-        return;
3048
-    };
3049
-    let tbd = PathBuf::from(format!("{sdk}/usr/lib/libSystem.tbd"));
3050
-    if !tbd.exists() {
3051
-        eprintln!("skipping: no libSystem.tbd at {}", tbd.display());
3052
-        return;
3053
-    }
30546090
 
3055
-    let obj = scratch("import-reloc.o");
3056
-    let out = scratch("import-reloc.out");
3057
-    let src = r#"
3058
-        .section __TEXT,__text,regular,pure_instructions
6091
+    let obj = scratch("unwind-gap.o");
6092
+    let out = scratch("unwind-gap-ours.out");
6093
+    let asm = r#"
6094
+        .text
30596095
         .globl _main
3060
-        _main:
3061
-            adrp x0, _write@GOTPAGE
3062
-            ldr x0, [x0, _write@GOTPAGEOFF]
3063
-            bl _write
3064
-            ret
6096
+        .p2align 2
6097
+    _main:
6098
+        .cfi_startproc
6099
+        bl _helper
6100
+        ret
6101
+        .cfi_endproc
6102
+        .space 0x1000010
6103
+        .globl _helper
6104
+        .p2align 2
6105
+    _helper:
6106
+        .cfi_startproc
6107
+        ret
6108
+        .cfi_endproc
30656109
         .subsections_via_symbols
30666110
     "#;
3067
-    if let Err(e) = assemble(src, &obj) {
6111
+    if let Err(e) = assemble(asm, &obj) {
30686112
         eprintln!("skipping: assemble failed: {e}");
30696113
         return;
30706114
     }
30716115
 
30726116
     let opts = LinkOptions {
3073
-        inputs: vec![obj.clone(), tbd.clone()],
6117
+        inputs: vec![obj.clone()],
30746118
         output: Some(out.clone()),
30756119
         kind: OutputKind::Executable,
30766120
         ..LinkOptions::default()
@@ -3078,136 +6122,24 @@ fn linker_run_routes_dylib_imports_through_synthetic_sections() {
30786122
     Linker::run(&opts).unwrap();
30796123
 
30806124
     let bytes = fs::read(&out).unwrap();
3081
-    let header = parse_header(&bytes).unwrap();
3082
-    let commands = parse_commands(&header, &bytes).unwrap();
3083
-    let (text_addr, text) = output_section(&bytes, "__TEXT", "__text").unwrap();
3084
-    let (stubs_addr, stubs) = output_section(&bytes, "__TEXT", "__stubs").unwrap();
3085
-    let (helper_addr, helper) = output_section(&bytes, "__TEXT", "__stub_helper").unwrap();
3086
-    let (got_addr, got) = output_section(&bytes, "__DATA_CONST", "__got").unwrap();
3087
-    let (lazy_addr, lazy) = output_section(&bytes, "__DATA", "__la_symbol_ptr").unwrap();
3088
-    let (dyld_private_addr, _) = output_section(&bytes, "__DATA", "__data").unwrap();
3089
-    let stubs_hdr = output_section_header(&bytes, "__TEXT", "__stubs").unwrap();
3090
-    let got_hdr = output_section_header(&bytes, "__DATA_CONST", "__got").unwrap();
3091
-    let lazy_hdr = output_section_header(&bytes, "__DATA", "__la_symbol_ptr").unwrap();
3092
-
3093
-    let symtab = commands
3094
-        .iter()
3095
-        .find_map(|cmd| match cmd {
3096
-            LoadCommand::Symtab(cmd) => Some(*cmd),
3097
-            _ => None,
3098
-        })
3099
-        .unwrap();
3100
-    let dysymtab = commands
3101
-        .iter()
3102
-        .find_map(|cmd| match cmd {
3103
-            LoadCommand::Dysymtab(cmd) => Some(*cmd),
3104
-            _ => None,
3105
-        })
3106
-        .unwrap();
3107
-    let dyld_info = commands
3108
-        .iter()
3109
-        .find_map(|cmd| match cmd {
3110
-            LoadCommand::DyldInfoOnly(cmd) => Some(*cmd),
3111
-            _ => None,
3112
-        })
3113
-        .unwrap();
3114
-    let libsystem_load = commands
3115
-        .iter()
3116
-        .find_map(|cmd| match cmd {
3117
-            LoadCommand::Dylib(cmd)
3118
-                if cmd.cmd == afs_ld::macho::constants::LC_LOAD_DYLIB
3119
-                    && cmd.name == "/usr/lib/libSystem.B.dylib" =>
3120
-            {
3121
-                Some(cmd.clone())
3122
-            }
3123
-            _ => None,
3124
-        })
3125
-        .unwrap();
3126
-    let symbols = parse_nlist_table(&bytes, symtab.symoff, symtab.nsyms).unwrap();
3127
-    let strings = StringTable::from_file(&bytes, symtab.stroff, symtab.strsize).unwrap();
3128
-    let symbol_names: Vec<&str> = symbols
3129
-        .iter()
3130
-        .map(|symbol| strings.get(symbol.strx()).unwrap())
3131
-        .collect();
3132
-
3133
-    assert_eq!(got.len(), 16);
3134
-    assert_eq!(stubs.len(), 12);
3135
-    assert_eq!(helper.len(), 36);
3136
-    assert_eq!(lazy.len(), 8);
3137
-    assert_eq!(symtab.nsyms, 5);
3138
-    assert_eq!(dysymtab.nlocalsym, 1);
3139
-    assert_eq!(dysymtab.nextdefsym, 2);
3140
-    assert_eq!(dysymtab.nundefsym, 2);
3141
-    assert_eq!(dysymtab.nindirectsyms, 4);
3142
-    assert_eq!(stubs_hdr.reserved1, 0);
3143
-    assert_eq!(got_hdr.reserved1, 1);
3144
-    assert_eq!(lazy_hdr.reserved1, 3);
3145
-    assert_eq!(stubs_hdr.reserved2, 12);
3146
-    assert!(libsystem_load.current_version >= (1 << 16));
3147
-    assert_eq!(libsystem_load.compatibility_version, 1 << 16);
3148
-    assert!(dyld_info.rebase_size > 0);
3149
-    assert!(dyld_info.bind_size > 0);
3150
-    assert!(dyld_info.lazy_bind_size > 0);
3151
-    assert_eq!(
3152
-        decode_page_reference(&text, text_addr, 0, &PageRefKind::Load).unwrap(),
3153
-        got_addr
3154
-    );
3155
-    assert_eq!(
3156
-        decode_branch_target(&text, text_addr, 8).unwrap(),
3157
-        stubs_addr
3158
-    );
3159
-    assert_eq!(
3160
-        decode_page_reference(&stubs, stubs_addr, 0, &PageRefKind::Load).unwrap(),
3161
-        lazy_addr
3162
-    );
3163
-    assert_eq!(read_insn(&stubs, 8).unwrap(), 0xd61f0200);
3164
-    assert_eq!(
3165
-        u64::from_le_bytes(lazy[0..8].try_into().unwrap()),
3166
-        helper_addr + 24
3167
-    );
3168
-    assert_eq!(
3169
-        decode_page_reference(&helper, helper_addr, 0, &PageRefKind::Add).unwrap(),
3170
-        dyld_private_addr
3171
-    );
3172
-    assert_eq!(
3173
-        decode_page_reference(&helper, helper_addr, 12, &PageRefKind::Load).unwrap(),
3174
-        got_addr + 8
3175
-    );
3176
-    assert_eq!(read_insn(&helper, 20).unwrap(), 0xd61f0200);
3177
-    assert_eq!(read_insn(&helper, 24).unwrap(), 0x1800_0050);
3178
-    assert_eq!(
3179
-        decode_branch_target(&helper, helper_addr, 28).unwrap(),
3180
-        helper_addr
3181
-    );
3182
-    assert_eq!(u32::from_le_bytes(helper[32..36].try_into().unwrap()), 0);
3183
-    let (locals, extdefs, undefs) = symbol_partition_names(&bytes);
3184
-    assert_eq!(locals, vec!["__dyld_private".to_string()]);
3185
-    assert_eq!(
3186
-        extdefs,
3187
-        vec!["__mh_execute_header".to_string(), "_main".to_string()]
3188
-    );
3189
-    assert_eq!(
3190
-        undefs,
3191
-        vec!["_write".to_string(), "dyld_stub_binder".to_string()]
6125
+    let (_, unwind) = output_section(&bytes, "__TEXT", "__unwind_info").unwrap();
6126
+    let decoded = decode_unwind_info(&unwind).unwrap();
6127
+    assert!(
6128
+        decoded
6129
+            .records
6130
+            .windows(2)
6131
+            .all(|pair| pair[0].function_offset < pair[1].function_offset),
6132
+        "expected strictly ascending unwind records after large-gap pagination"
31926133
     );
3193
-    assert!(symbol_names.contains(&"__dyld_private"));
3194
-    assert!(symbols[dysymtab.iundefsym as usize..]
3195
-        .iter()
3196
-        .all(|symbol| symbol.kind() == SymKind::Undef));
3197
-    assert!(symbols[dysymtab.iundefsym as usize..]
3198
-        .iter()
3199
-        .all(|symbol| symbol.library_ordinal().unwrap() > 0));
3200
-    assert!(symbol_names.contains(&"_write"));
3201
-    assert!(symbol_names.contains(&"dyld_stub_binder"));
32026134
 
3203
-    let _ = fs::remove_file(out);
32046135
     let _ = fs::remove_file(obj);
6136
+    let _ = fs::remove_file(out);
32056137
 }
32066138
 
32076139
 #[test]
3208
-fn synthetic_import_surfaces_match_apple_ld_classic_lazy_model() {
3209
-    if !have_xcrun() || !have_xcrun_tool("ld") {
3210
-        eprintln!("skipping: xcrun as/ld unavailable");
6140
+fn linker_run_preserves_eh_frame_like_ld() {
6141
+    if !have_xcrun() || !have_xcrun_tool("dwarfdump") {
6142
+        eprintln!("skipping: xcrun dwarfdump unavailable");
32116143
         return;
32126144
     }
32136145
     let Some(sdk) = sdk_path() else {
@@ -3218,115 +6150,82 @@ fn synthetic_import_surfaces_match_apple_ld_classic_lazy_model() {
32186150
         eprintln!("skipping: xcrun --show-sdk-version unavailable");
32196151
         return;
32206152
     };
3221
-    let tbd = PathBuf::from(format!("{sdk}/usr/lib/libSystem.tbd"));
3222
-    if !tbd.exists() {
3223
-        eprintln!("skipping: no libSystem.tbd at {}", tbd.display());
3224
-        return;
3225
-    }
32266153
 
3227
-    let obj = scratch("import-parity.o");
3228
-    let our_out = scratch("import-parity-ours.out");
3229
-    let apple_out = scratch("import-parity-apple.out");
3230
-    let src = r#"
3231
-        .section __TEXT,__text,regular,pure_instructions
3232
-        .globl _main
3233
-        _main:
3234
-            adrp x0, _write@GOTPAGE
3235
-            ldr x0, [x0, _write@GOTPAGEOFF]
3236
-            bl _write
3237
-            ret
6154
+    let obj = scratch("eh-frame.o");
6155
+    let our_out = scratch("eh-frame-ours.out");
6156
+    let apple_out = scratch("eh-frame-apple.out");
6157
+    let asm = r#"
6158
+        .text
6159
+        .globl _main
6160
+        .p2align 2
6161
+    _main:
6162
+        .cfi_startproc
6163
+        sub sp, sp, #16
6164
+        .cfi_def_cfa_offset 16
6165
+        str x30, [sp, #8]
6166
+        .cfi_offset w30, -8
6167
+        bl _helper
6168
+        ldr x30, [sp, #8]
6169
+        add sp, sp, #16
6170
+        ret
6171
+        .cfi_endproc
6172
+
6173
+        .globl _helper
6174
+        .p2align 2
6175
+    _helper:
6176
+        .cfi_startproc
6177
+        ret
6178
+        .cfi_endproc
32386179
         .subsections_via_symbols
32396180
     "#;
3240
-    if let Err(e) = assemble(src, &obj) {
6181
+    if let Err(e) = assemble(asm, &obj) {
32416182
         eprintln!("skipping: assemble failed: {e}");
32426183
         return;
32436184
     }
32446185
 
32456186
     let opts = LinkOptions {
3246
-        inputs: vec![obj.clone(), tbd],
6187
+        inputs: vec![obj.clone()],
32476188
         output: Some(our_out.clone()),
32486189
         kind: OutputKind::Executable,
32496190
         ..LinkOptions::default()
32506191
     };
32516192
     Linker::run(&opts).unwrap();
3252
-    apple_link_classic_lazy(&obj, &apple_out, "_main", &sdk, &sdk_ver).unwrap();
6193
+    apple_link(&obj, &apple_out, "_main", &sdk, &sdk_ver).unwrap();
32536194
 
32546195
     let our_bytes = fs::read(&our_out).unwrap();
32556196
     let apple_bytes = fs::read(&apple_out).unwrap();
3256
-
3257
-    for (segname, sectname) in [("__TEXT", "__stubs"), ("__TEXT", "__stub_helper")] {
3258
-        let (_, ours) = output_section(&our_bytes, segname, sectname).unwrap();
3259
-        let (_, apple) = output_section(&apple_bytes, segname, sectname).unwrap();
3260
-        let diff = diff_macho(&ours, &apple);
3261
-        assert!(
3262
-            diff.is_clean(),
3263
-            "{segname},{sectname} diverged from Apple ld: {:#?}",
3264
-            diff.critical
3265
-        );
3266
-    }
3267
-
3268
-    let (our_helper_addr, _) = output_section(&our_bytes, "__TEXT", "__stub_helper").unwrap();
3269
-    let (apple_helper_addr, _) = output_section(&apple_bytes, "__TEXT", "__stub_helper").unwrap();
3270
-    let (_, our_lazy) = output_section(&our_bytes, "__DATA", "__la_symbol_ptr").unwrap();
3271
-    let (_, apple_lazy) = output_section(&apple_bytes, "__DATA", "__la_symbol_ptr").unwrap();
3272
-    assert_eq!(
3273
-        u64::from_le_bytes(our_lazy[0..8].try_into().unwrap()) - our_helper_addr,
3274
-        24
3275
-    );
3276
-    assert_eq!(
3277
-        u64::from_le_bytes(apple_lazy[0..8].try_into().unwrap()) - apple_helper_addr,
3278
-        24
3279
-    );
3280
-
3281
-    assert_eq!(
3282
-        load_dylib_names(&our_bytes).unwrap(),
3283
-        load_dylib_names(&apple_bytes).unwrap()
3284
-    );
3285
-    assert_eq!(
3286
-        segment_flags(&our_bytes, "__DATA_CONST"),
3287
-        Some(SG_READ_ONLY)
3288
-    );
3289
-    assert_eq!(
3290
-        segment_flags(&our_bytes, "__DATA_CONST"),
3291
-        segment_flags(&apple_bytes, "__DATA_CONST")
3292
-    );
3293
-
3294
-    let our_rebases = decode_rebase_records(&our_bytes).unwrap();
3295
-    let apple_rebases = decode_rebase_records(&apple_bytes).unwrap();
3296
-    assert!(our_rebases
3297
-        .iter()
3298
-        .all(|record| record.rebase_type == REBASE_TYPE_POINTER));
3299
-    assert_eq!(our_rebases, apple_rebases);
3300
-    assert_eq!(
3301
-        decode_bind_records(&our_bytes, false).unwrap(),
3302
-        decode_bind_records(&apple_bytes, false).unwrap()
3303
-    );
3304
-    assert_eq!(
3305
-        dyld_info_stream(&our_bytes, DyldInfoStreamKind::WeakBind).unwrap(),
3306
-        dyld_info_stream(&apple_bytes, DyldInfoStreamKind::WeakBind).unwrap()
3307
-    );
3308
-    assert_eq!(
3309
-        decode_bind_records(&our_bytes, true).unwrap(),
3310
-        decode_bind_records(&apple_bytes, true).unwrap()
3311
-    );
3312
-    assert_eq!(
3313
-        canonical_lazy_bind_stream(&our_bytes).unwrap(),
3314
-        canonical_lazy_bind_stream(&apple_bytes).unwrap()
3315
-    );
6197
+    assert!(output_section(&our_bytes, "__TEXT", "__eh_frame").is_some());
33166198
     assert_eq!(
3317
-        indirect_symbol_table(&our_bytes),
3318
-        indirect_symbol_table(&apple_bytes)
6199
+        output_section(&our_bytes, "__TEXT", "__eh_frame")
6200
+            .unwrap()
6201
+            .1
6202
+            .len(),
6203
+        output_section(&apple_bytes, "__TEXT", "__eh_frame")
6204
+            .unwrap()
6205
+            .1
6206
+            .len()
33196207
     );
6208
+    let our_dump = normalized_eh_frame_dump(
6209
+        &our_out,
6210
+        output_section(&our_bytes, "__TEXT", "__text").unwrap().0,
6211
+    )
6212
+    .unwrap();
6213
+    let apple_dump = normalized_eh_frame_dump(
6214
+        &apple_out,
6215
+        output_section(&apple_bytes, "__TEXT", "__text").unwrap().0,
6216
+    )
6217
+    .unwrap();
6218
+    assert_eq!(our_dump, apple_dump);
33206219
 
3321
-    let _ = fs::remove_file(apple_out);
3322
-    let _ = fs::remove_file(our_out);
33236220
     let _ = fs::remove_file(obj);
6221
+    let _ = fs::remove_file(our_out);
6222
+    let _ = fs::remove_file(apple_out);
33246223
 }
33256224
 
33266225
 #[test]
3327
-fn classic_lazy_surfaces_match_apple_ld_across_fixture_matrix() {
3328
-    if !have_xcrun() || !have_xcrun_tool("ld") {
3329
-        eprintln!("skipping: xcrun as/ld unavailable");
6226
+fn linker_run_dead_strip_preserves_pruned_eh_frame_like_ld() {
6227
+    if !have_xcrun() || !have_xcrun_tool("dwarfdump") {
6228
+        eprintln!("skipping: xcrun dwarfdump unavailable");
33306229
         return;
33316230
     }
33326231
     let Some(sdk) = sdk_path() else {
@@ -3338,89 +6237,101 @@ fn classic_lazy_surfaces_match_apple_ld_across_fixture_matrix() {
33386237
         return;
33396238
     };
33406239
 
3341
-    let cases = [
3342
-        ClassicLazyParityCase {
3343
-            name: "single-got-and-call",
3344
-            src: r#"
3345
-                .section __TEXT,__text,regular,pure_instructions
3346
-                .globl _main
3347
-                _main:
3348
-                    adrp x0, _write@GOTPAGE
3349
-                    ldr x0, [x0, _write@GOTPAGEOFF]
3350
-                    bl _write
3351
-                    ret
3352
-                .subsections_via_symbols
3353
-            "#,
3354
-        },
3355
-        ClassicLazyParityCase {
3356
-            name: "batched-got-and-calls",
3357
-            src: r#"
3358
-                .section __TEXT,__text,regular,pure_instructions
3359
-                .globl _main
3360
-                _main:
3361
-                    adrp x0, _write@GOTPAGE
3362
-                    ldr x0, [x0, _write@GOTPAGEOFF]
3363
-                    bl _write
3364
-                    adrp x1, _close@GOTPAGE
3365
-                    ldr x1, [x1, _close@GOTPAGEOFF]
3366
-                    bl _close
3367
-                    adrp x2, _read@GOTPAGE
3368
-                    ldr x2, [x2, _read@GOTPAGEOFF]
3369
-                    bl _read
3370
-                    ret
3371
-                .subsections_via_symbols
3372
-            "#,
3373
-        },
3374
-        ClassicLazyParityCase {
3375
-            name: "branch-only-calls",
3376
-            src: r#"
3377
-                .section __TEXT,__text,regular,pure_instructions
3378
-                .globl _main
3379
-                _main:
3380
-                    bl _write
3381
-                    bl _close
3382
-                    bl _read
3383
-                    ret
3384
-                .subsections_via_symbols
3385
-            "#,
3386
-        },
3387
-        ClassicLazyParityCase {
3388
-            name: "deduped-import",
3389
-            src: r#"
3390
-                .section __TEXT,__text,regular,pure_instructions
3391
-                .globl _main
3392
-                _main:
3393
-                    adrp x0, _write@GOTPAGE
3394
-                    ldr x0, [x0, _write@GOTPAGEOFF]
3395
-                    bl _write
3396
-                    bl _write
3397
-                    adrp x1, _write@GOTPAGE
3398
-                    ldr x1, [x1, _write@GOTPAGEOFF]
3399
-                    ret
3400
-                .subsections_via_symbols
3401
-            "#,
3402
-        },
3403
-    ];
6240
+    let obj = scratch("eh-frame-dead-strip.o");
6241
+    let our_out = scratch("eh-frame-dead-strip-ours.out");
6242
+    let apple_out = scratch("eh-frame-dead-strip-apple.out");
6243
+    let asm = r#"
6244
+        .text
6245
+        .globl _main
6246
+        .p2align 2
6247
+    _main:
6248
+        .cfi_startproc
6249
+        sub sp, sp, #16
6250
+        .cfi_def_cfa_offset 16
6251
+        str x30, [sp, #8]
6252
+        .cfi_offset w30, -8
6253
+        bl _helper
6254
+        ldr x30, [sp, #8]
6255
+        add sp, sp, #16
6256
+        ret
6257
+        .cfi_endproc
34046258
 
3405
-    let mut failures = Vec::new();
3406
-    for case in &cases {
3407
-        if let Err(err) = assert_classic_lazy_case_matches_apple_ld(case, &sdk, &sdk_ver) {
3408
-            failures.push(err);
3409
-        }
6259
+        .globl _helper
6260
+        .p2align 2
6261
+    _helper:
6262
+        .cfi_startproc
6263
+        sub sp, sp, #16
6264
+        .cfi_def_cfa_offset 16
6265
+        str x30, [sp, #8]
6266
+        .cfi_offset w30, -8
6267
+        ldr x30, [sp, #8]
6268
+        add sp, sp, #16
6269
+        ret
6270
+        .cfi_endproc
6271
+
6272
+        .globl _unused
6273
+        .p2align 2
6274
+    _unused:
6275
+        .cfi_startproc
6276
+        sub sp, sp, #16
6277
+        .cfi_def_cfa_offset 16
6278
+        str x30, [sp, #8]
6279
+        .cfi_offset w30, -8
6280
+        ldr x30, [sp, #8]
6281
+        add sp, sp, #16
6282
+        ret
6283
+        .cfi_endproc
6284
+        .subsections_via_symbols
6285
+    "#;
6286
+    if let Err(e) = assemble(asm, &obj) {
6287
+        eprintln!("skipping: assemble failed: {e}");
6288
+        return;
34106289
     }
34116290
 
3412
-    assert!(
3413
-        failures.is_empty(),
3414
-        "Apple ld classic-lazy parity failures ({} cases):\n{}",
3415
-        failures.len(),
3416
-        failures.join("\n\n")
6291
+    let opts = LinkOptions {
6292
+        inputs: vec![obj.clone()],
6293
+        output: Some(our_out.clone()),
6294
+        kind: OutputKind::Executable,
6295
+        dead_strip: true,
6296
+        ..LinkOptions::default()
6297
+    };
6298
+    Linker::run(&opts).unwrap();
6299
+    apple_link_with_args(&obj, &apple_out, "_main", &sdk, &sdk_ver, &["-dead_strip"]).unwrap();
6300
+
6301
+    let our_bytes = fs::read(&our_out).unwrap();
6302
+    let apple_bytes = fs::read(&apple_out).unwrap();
6303
+    assert!(output_section(&our_bytes, "__TEXT", "__eh_frame").is_some());
6304
+    assert_eq!(
6305
+        output_section(&our_bytes, "__TEXT", "__eh_frame")
6306
+            .unwrap()
6307
+            .1
6308
+            .len(),
6309
+        output_section(&apple_bytes, "__TEXT", "__eh_frame")
6310
+            .unwrap()
6311
+            .1
6312
+            .len()
34176313
     );
6314
+    let our_dump = normalized_eh_frame_dump(
6315
+        &our_out,
6316
+        output_section(&our_bytes, "__TEXT", "__text").unwrap().0,
6317
+    )
6318
+    .unwrap();
6319
+    let apple_dump = normalized_eh_frame_dump(
6320
+        &apple_out,
6321
+        output_section(&apple_bytes, "__TEXT", "__text").unwrap().0,
6322
+    )
6323
+    .unwrap();
6324
+    assert_eq!(our_dump, apple_dump);
6325
+
6326
+    let _ = fs::remove_file(obj);
6327
+    let _ = fs::remove_file(our_out);
6328
+    let _ = fs::remove_file(apple_out);
34186329
 }
34196330
 
34206331
 #[test]
3421
-fn linker_run_binds_direct_dylib_import_pointers() {
3422
-    if !have_xcrun() || !have_tool("codesign") {
3423
-        eprintln!("skipping: xcrun clang or codesign unavailable");
6332
+fn linker_run_emits_backtrace_metadata_like_apple_ld() {
6333
+    if !have_xcrun() {
6334
+        eprintln!("skipping: xcrun unavailable");
34246335
         return;
34256336
     }
34266337
     let Some(sdk) = sdk_path() else {
@@ -3437,85 +6348,139 @@ fn linker_run_binds_direct_dylib_import_pointers() {
34376348
         return;
34386349
     }
34396350
 
3440
-    let dylib_src = r#"
3441
-        int ext_data = 5;
6351
+    let obj = scratch("unwind-backtrace.o");
6352
+    let our_out = scratch("unwind-backtrace-ours.out");
6353
+    let apple_out = scratch("unwind-backtrace-apple.out");
6354
+    let src = r#"
6355
+        #include <unwind.h>
6356
+
6357
+        static _Unwind_Reason_Code cb(struct _Unwind_Context* ctx, void* arg) {
6358
+            (void)ctx;
6359
+            int* count = (int*)arg;
6360
+            (*count)++;
6361
+            return *count >= 8 ? _URC_END_OF_STACK : _URC_NO_REASON;
6362
+        }
6363
+
6364
+        __attribute__((noinline)) int helper(void) {
6365
+            int count = 0;
6366
+            _Unwind_Backtrace(cb, &count);
6367
+            return count;
6368
+        }
6369
+
6370
+        int main(void) {
6371
+            return helper() > 1 ? 0 : 1;
6372
+        }
34426373
     "#;
3443
-    let direct_case = DirectBindParityCase {
3444
-        name: "direct-data",
3445
-        dylib_src,
3446
-        main_src: r#"
3447
-            extern int ext_data;
3448
-            int *p = &ext_data;
3449
-            int main(void) { return *p == 5 ? 0 : 1; }
3450
-        "#,
3451
-    };
3452
-    if let Err(e) = assert_direct_bind_case_matches_apple_ld(&direct_case, &sdk, &sdk_ver) {
3453
-        panic!("{e}");
6374
+    if let Err(e) = compile_c(src, &obj) {
6375
+        eprintln!("skipping: clang compile failed: {e}");
6376
+        return;
34546377
     }
34556378
 
3456
-    let dylib = scratch("direct-data.dylib");
3457
-    let obj = scratch("direct-data.o");
3458
-    let our_out = scratch("direct-data-ours.out");
6379
+    let opts = LinkOptions {
6380
+        inputs: vec![obj.clone(), tbd],
6381
+        output: Some(our_out.clone()),
6382
+        kind: OutputKind::Executable,
6383
+        ..LinkOptions::default()
6384
+    };
6385
+    Linker::run(&opts).unwrap();
6386
+    apple_link_classic_lazy(&obj, &apple_out, "_main", &sdk, &sdk_ver).unwrap();
34596387
 
3460
-    if let Err(e) = compile_dylib_c(dylib_src, &dylib) {
3461
-        eprintln!("skipping: dylib compile failed: {e}");
6388
+    let our_bytes = fs::read(&our_out).unwrap();
6389
+    let apple_bytes = fs::read(&apple_out).unwrap();
6390
+    assert_eq!(
6391
+        rebased_unwind_bytes(&our_bytes),
6392
+        rebased_unwind_bytes(&apple_bytes)
6393
+    );
6394
+    assert_eq!(
6395
+        normalize_function_start_offsets(&decode_function_starts(&our_bytes)),
6396
+        normalize_function_start_offsets(&decode_function_starts(&apple_bytes))
6397
+    );
6398
+
6399
+    let _ = fs::remove_file(obj);
6400
+    let _ = fs::remove_file(our_out);
6401
+    let _ = fs::remove_file(apple_out);
6402
+}
6403
+
6404
+#[test]
6405
+fn linker_run_preserves_exception_unwind_metadata_like_apple_ld() {
6406
+    if !have_xcrun() || !have_xcrun_tool("clang++") || !have_tool("codesign") {
6407
+        eprintln!("skipping: xcrun clang++ or codesign unavailable");
34626408
         return;
34636409
     }
34646410
 
3465
-    let main_src = r#"
3466
-        extern int ext_data;
3467
-        int *p = &ext_data;
3468
-        int main(void) { return *p == 5 ? 0 : 1; }
6411
+    let Some(sdk) = sdk_path() else {
6412
+        eprintln!("skipping: xcrun --show-sdk-path unavailable");
6413
+        return;
6414
+    };
6415
+    let libsystem = PathBuf::from(format!("{sdk}/usr/lib/libSystem.tbd"));
6416
+    let libcxx = PathBuf::from(format!("{sdk}/usr/lib/libc++.tbd"));
6417
+    if !libsystem.exists() {
6418
+        eprintln!("skipping: no libSystem.tbd at {}", libsystem.display());
6419
+        return;
6420
+    }
6421
+    if !libcxx.exists() {
6422
+        eprintln!("skipping: no libc++.tbd at {}", libcxx.display());
6423
+        return;
6424
+    }
6425
+
6426
+    let obj = scratch("cxx-exc.o");
6427
+    let our_out = scratch("cxx-exc-ours.out");
6428
+    let apple_out = scratch("cxx-exc-apple.out");
6429
+    let src = r#"
6430
+        int helper() { throw 7; }
6431
+        int main() {
6432
+            try { return helper(); }
6433
+            catch (...) { return 42; }
6434
+        }
34696435
     "#;
3470
-    if let Err(e) = compile_c(main_src, &obj) {
3471
-        eprintln!("skipping: compile failed: {e}");
6436
+    if let Err(e) = compile_cxx(src, &obj) {
6437
+        eprintln!("skipping: clang++ compile failed: {e}");
34726438
         return;
34736439
     }
34746440
 
34756441
     let opts = LinkOptions {
3476
-        inputs: vec![obj.clone(), tbd.clone(), dylib.clone()],
6442
+        inputs: vec![obj.clone(), libcxx.clone(), libsystem.clone()],
34776443
         output: Some(our_out.clone()),
34786444
         kind: OutputKind::Executable,
34796445
         ..LinkOptions::default()
34806446
     };
34816447
     Linker::run(&opts).unwrap();
6448
+    apple_link_cxx_classic(&obj, &apple_out).unwrap();
6449
+
34826450
     let our_bytes = fs::read(&our_out).unwrap();
3483
-    let binds = decode_bind_records(&our_bytes, false).unwrap();
3484
-    assert!(
3485
-        binds.iter().any(|record| {
3486
-            record.segment == "__DATA"
3487
-                && record.section == "__data"
3488
-                && record.section_offset == 0
3489
-                && record.symbol == "_ext_data"
3490
-        }),
3491
-        "missing direct bind for imported data: {binds:#?}"
6451
+    let apple_bytes = fs::read(&apple_out).unwrap();
6452
+    assert_eq!(
6453
+        decode_bind_records(&our_bytes, false).unwrap(),
6454
+        decode_bind_records(&apple_bytes, false).unwrap()
34926455
     );
3493
-    let verify = Command::new("codesign")
3494
-        .arg("-v")
3495
-        .arg(&our_out)
3496
-        .output()
3497
-        .unwrap();
3498
-    assert!(
3499
-        verify.status.success(),
3500
-        "codesign verify failed: {}",
3501
-        String::from_utf8_lossy(&verify.stderr)
6456
+    assert_eq!(
6457
+        decode_bind_records(&our_bytes, true).unwrap(),
6458
+        decode_bind_records(&apple_bytes, true).unwrap()
35026459
     );
3503
-    let status = Command::new(&our_out).status().unwrap();
35046460
     assert_eq!(
3505
-        status.code(),
3506
-        Some(0),
3507
-        "expected direct-import pointer executable to exit 0"
6461
+        canonical_lazy_bind_stream(&our_bytes).unwrap(),
6462
+        canonical_lazy_bind_stream(&apple_bytes).unwrap()
35086463
     );
6464
+    let our_decoded = canonical_unwind_info(&our_bytes);
6465
+    let apple_decoded = canonical_unwind_info(&apple_bytes);
6466
+    assert_eq!(our_decoded, apple_decoded);
6467
+    assert_eq!(our_decoded.personalities.len(), 1);
6468
+    assert_eq!(our_decoded.lsdas.len(), 1);
6469
+    assert!(output_section(&our_bytes, "__TEXT", "__gcc_except_tab").is_some());
6470
+    let our_status = Command::new(&our_out).status().unwrap();
6471
+    let apple_status = Command::new(&apple_out).status().unwrap();
6472
+    assert_eq!(our_status.code(), Some(42));
6473
+    assert_eq!(apple_status.code(), Some(42));
35096474
 
3510
-    let _ = fs::remove_file(dylib);
35116475
     let _ = fs::remove_file(obj);
35126476
     let _ = fs::remove_file(our_out);
6477
+    let _ = fs::remove_file(apple_out);
35136478
 }
35146479
 
35156480
 #[test]
3516
-fn direct_bind_surfaces_match_apple_ld_across_fixture_matrix() {
3517
-    if !have_xcrun() || !have_tool("codesign") {
3518
-        eprintln!("skipping: xcrun clang or codesign unavailable");
6481
+fn linker_run_resolves_backtrace_symbols_at_runtime() {
6482
+    if !have_xcrun() {
6483
+        eprintln!("skipping: xcrun unavailable");
35196484
         return;
35206485
     }
35216486
     let Some(sdk) = sdk_path() else {
@@ -3526,66 +6491,86 @@ fn direct_bind_surfaces_match_apple_ld_across_fixture_matrix() {
35266491
         eprintln!("skipping: xcrun --show-sdk-version unavailable");
35276492
         return;
35286493
     };
6494
+    let tbd = PathBuf::from(format!("{sdk}/usr/lib/libSystem.tbd"));
6495
+    if !tbd.exists() {
6496
+        eprintln!("skipping: no libSystem.tbd at {}", tbd.display());
6497
+        return;
6498
+    }
35296499
 
3530
-    let cases = [
3531
-        DirectBindParityCase {
3532
-            name: "direct-multi-data",
3533
-            dylib_src: r#"
3534
-                int ext_data = 5;
3535
-                int more_data = 9;
3536
-            "#,
3537
-            main_src: r#"
3538
-                extern int ext_data;
3539
-                extern int more_data;
3540
-                int *p = &ext_data;
3541
-                int *q = &more_data;
3542
-                int main(void) { return (*p == 5 && *q == 9) ? 0 : 1; }
3543
-            "#,
3544
-        },
3545
-        DirectBindParityCase {
3546
-            name: "direct-and-call-mixed",
3547
-            dylib_src: r#"
3548
-                int ext_data = 5;
3549
-                int ext_fn(void) { return ext_data + 1; }
3550
-            "#,
3551
-            main_src: r#"
3552
-                extern int ext_data;
3553
-                extern int ext_fn(void);
3554
-                int *p = &ext_data;
3555
-                int main(void) { return *p + ext_fn() == 11 ? 0 : 1; }
3556
-            "#,
3557
-        },
3558
-        DirectBindParityCase {
3559
-            name: "direct-deduped",
3560
-            dylib_src: r#"
3561
-                int ext_data = 5;
3562
-            "#,
3563
-            main_src: r#"
3564
-                extern int ext_data;
3565
-                int *p = &ext_data;
3566
-                int *q = &ext_data;
3567
-                int main(void) { return (*p == 5 && *q == 5) ? 0 : 1; }
3568
-            "#,
3569
-        },
3570
-    ];
6500
+    let obj = scratch("execinfo-backtrace.o");
6501
+    let our_out = scratch("execinfo-backtrace-ours.out");
6502
+    let apple_out = scratch("execinfo-backtrace-apple.out");
6503
+    let src = r#"
6504
+        #include <execinfo.h>
6505
+        #include <stdio.h>
6506
+        #include <stdlib.h>
6507
+        #include <string.h>
35716508
 
3572
-    let mut failures = Vec::new();
3573
-    for case in &cases {
3574
-        if let Err(err) = assert_direct_bind_case_matches_apple_ld(case, &sdk, &sdk_ver) {
3575
-            failures.push(err);
6509
+        __attribute__((noinline)) int helper(void) {
6510
+            void *frames[8];
6511
+            int n = backtrace(frames, 8);
6512
+            char **syms = backtrace_symbols(frames, n);
6513
+            int saw_helper = 0;
6514
+            int saw_main = 0;
6515
+            if (!syms) return 2;
6516
+            for (int i = 0; i < n; i++) {
6517
+                puts(syms[i]);
6518
+                saw_helper |= strstr(syms[i], "helper") != NULL;
6519
+                saw_main |= strstr(syms[i], "main") != NULL;
6520
+            }
6521
+            free(syms);
6522
+            return (saw_helper && saw_main) ? 0 : 1;
6523
+        }
6524
+
6525
+        int main(void) {
6526
+            return helper();
35766527
         }
6528
+    "#;
6529
+    if let Err(e) = compile_c(src, &obj) {
6530
+        eprintln!("skipping: clang compile failed: {e}");
6531
+        return;
35776532
     }
35786533
 
6534
+    let opts = LinkOptions {
6535
+        inputs: vec![obj.clone(), tbd],
6536
+        output: Some(our_out.clone()),
6537
+        kind: OutputKind::Executable,
6538
+        ..LinkOptions::default()
6539
+    };
6540
+    Linker::run(&opts).unwrap();
6541
+    apple_link_classic_lazy(&obj, &apple_out, "_main", &sdk, &sdk_ver).unwrap();
6542
+
6543
+    let our_output = Command::new(&our_out).output().unwrap();
6544
+    let apple_output = Command::new(&apple_out).output().unwrap();
6545
+    let our_stdout = String::from_utf8_lossy(&our_output.stdout);
6546
+    let apple_stdout = String::from_utf8_lossy(&apple_output.stdout);
6547
+
6548
+    assert_eq!(our_output.status.code(), Some(0));
6549
+    assert_eq!(apple_output.status.code(), Some(0));
35796550
     assert!(
3580
-        failures.is_empty(),
3581
-        "Apple ld direct-bind parity failures ({} cases):\n{}",
3582
-        failures.len(),
3583
-        failures.join("\n\n")
6551
+        our_stdout.contains("helper"),
6552
+        "expected helper in output: {our_stdout}"
6553
+    );
6554
+    assert!(
6555
+        our_stdout.contains("main"),
6556
+        "expected main in output: {our_stdout}"
6557
+    );
6558
+    assert!(
6559
+        apple_stdout.contains("helper"),
6560
+        "expected helper in apple output: {apple_stdout}"
6561
+    );
6562
+    assert!(
6563
+        apple_stdout.contains("main"),
6564
+        "expected main in apple output: {apple_stdout}"
35846565
     );
6566
+
6567
+    let _ = fs::remove_file(obj);
6568
+    let _ = fs::remove_file(our_out);
6569
+    let _ = fs::remove_file(apple_out);
35856570
 }
35866571
 
35876572
 #[test]
3588
-fn linker_run_rebases_local_absolute_pointers_like_ld() {
6573
+fn linker_run_emits_function_starts_like_ld() {
35896574
     if !have_xcrun() {
35906575
         eprintln!("skipping: xcrun unavailable");
35916576
         return;
@@ -3604,16 +6589,22 @@ fn linker_run_rebases_local_absolute_pointers_like_ld() {
36046589
         return;
36056590
     }
36066591
 
3607
-    let obj = scratch("local-rebase.o");
3608
-    let our_out = scratch("local-rebase-ours.out");
3609
-    let apple_out = scratch("local-rebase-apple.out");
3610
-    let src = r#"
3611
-        int ext = 7;
3612
-        int *p = &ext;
3613
-        int main(void) { return *p == 7 ? 0 : 1; }
6592
+    let obj = scratch("function-starts.o");
6593
+    let our_out = scratch("function-starts-ours.out");
6594
+    let apple_out = scratch("function-starts-apple.out");
6595
+    let asm = r#"
6596
+        .section __TEXT,__text,regular,pure_instructions
6597
+        .globl _main
6598
+        .p2align 2
6599
+    _main:
6600
+        adrp x0, _write@GOTPAGE
6601
+        ldr x0, [x0, _write@GOTPAGEOFF]
6602
+        bl _write
6603
+        ret
6604
+        .subsections_via_symbols
36146605
     "#;
3615
-    if let Err(e) = compile_c(src, &obj) {
3616
-        eprintln!("skipping: clang compile failed: {e}");
6606
+    if let Err(e) = assemble(asm, &obj) {
6607
+        eprintln!("skipping: assemble failed: {e}");
36176608
         return;
36186609
     }
36196610
 
@@ -3628,22 +6619,34 @@ fn linker_run_rebases_local_absolute_pointers_like_ld() {
36286619
 
36296620
     let our_bytes = fs::read(&our_out).unwrap();
36306621
     let apple_bytes = fs::read(&apple_out).unwrap();
3631
-    assert!(!dyld_info_stream(&our_bytes, DyldInfoStreamKind::Rebase)
3632
-        .unwrap()
3633
-        .is_empty());
6622
+    let our_fstarts = raw_linkedit_data_cmd(&our_bytes, LC_FUNCTION_STARTS);
6623
+    let apple_fstarts = raw_linkedit_data_cmd(&apple_bytes, LC_FUNCTION_STARTS);
6624
+    assert_ne!(our_fstarts.0, 0);
6625
+    assert_eq!(our_fstarts.1, apple_fstarts.1);
6626
+    assert_eq!(our_fstarts.1, 8);
6627
+    assert!(output_section(&our_bytes, "__TEXT", "__stubs").is_some());
6628
+    assert!(output_section(&our_bytes, "__TEXT", "__stub_helper").is_some());
6629
+    assert_eq!(decode_function_starts(&our_bytes).len(), 1);
6630
+    assert_eq!(decode_function_starts(&apple_bytes).len(), 1);
6631
+    let our_text_addr = output_section(&our_bytes, "__TEXT", "__text").unwrap().0;
6632
+    let apple_text_addr = output_section(&apple_bytes, "__TEXT", "__text").unwrap().0;
6633
+    let our_text_base = segment_vmaddr(&our_bytes, "__TEXT").unwrap();
6634
+    let apple_text_base = segment_vmaddr(&apple_bytes, "__TEXT").unwrap();
36346635
     assert_eq!(
3635
-        dyld_info_stream(&our_bytes, DyldInfoStreamKind::Rebase).unwrap(),
3636
-        dyld_info_stream(&apple_bytes, DyldInfoStreamKind::Rebase).unwrap()
6636
+        decode_function_starts(&our_bytes),
6637
+        vec![our_text_addr - our_text_base]
36376638
     );
36386639
     assert_eq!(
3639
-        decode_rebase_records(&our_bytes).unwrap(),
3640
-        decode_rebase_records(&apple_bytes).unwrap()
6640
+        decode_function_starts(&apple_bytes),
6641
+        vec![apple_text_addr - apple_text_base]
36416642
     );
36426643
 
3643
-    let our_status = Command::new(&our_out).status().unwrap();
3644
-    let apple_status = Command::new(&apple_out).status().unwrap();
3645
-    assert_eq!(our_status.code(), Some(0));
3646
-    assert_eq!(apple_status.code(), Some(0));
6644
+    let our_dic = raw_linkedit_data_cmd(&our_bytes, LC_DATA_IN_CODE);
6645
+    let apple_dic = raw_linkedit_data_cmd(&apple_bytes, LC_DATA_IN_CODE);
6646
+    assert_ne!(our_dic.0, 0);
6647
+    assert_eq!(our_dic.1, apple_dic.1);
6648
+    assert_eq!(our_dic.0, our_fstarts.0 + our_fstarts.1);
6649
+    assert_eq!(apple_dic.0, apple_fstarts.0 + apple_fstarts.1);
36476650
 
36486651
     let _ = fs::remove_file(obj);
36496652
     let _ = fs::remove_file(our_out);
@@ -3651,9 +6654,9 @@ fn linker_run_rebases_local_absolute_pointers_like_ld() {
36516654
 }
36526655
 
36536656
 #[test]
3654
-fn linker_run_routes_local_got_loads_through_rebased_slots() {
3655
-    if !have_xcrun() || !have_tool("codesign") {
3656
-        eprintln!("skipping: xcrun or codesign unavailable");
6657
+fn linker_run_emits_function_starts_for_other_text_sections_like_ld() {
6658
+    if !have_xcrun() {
6659
+        eprintln!("skipping: xcrun unavailable");
36576660
         return;
36586661
     }
36596662
     let Some(sdk) = sdk_path() else {
@@ -3664,76 +6667,54 @@ fn linker_run_routes_local_got_loads_through_rebased_slots() {
36646667
         eprintln!("skipping: xcrun --show-sdk-version unavailable");
36656668
         return;
36666669
     };
3667
-    let tbd = PathBuf::from(format!("{sdk}/usr/lib/libSystem.tbd"));
3668
-    if !tbd.exists() {
3669
-        eprintln!("skipping: no libSystem.tbd at {}", tbd.display());
3670
-        return;
3671
-    }
36726670
 
3673
-    let obj = scratch("local-got.o");
3674
-    let our_out = scratch("local-got-ours.out");
3675
-    let apple_out = scratch("local-got-apple.out");
3676
-    let src = r#"
6671
+    let obj = scratch("function-starts-textcoal.o");
6672
+    let our_out = scratch("function-starts-textcoal-ours.out");
6673
+    let apple_out = scratch("function-starts-textcoal-apple.out");
6674
+    let asm = r#"
36776675
         .section __TEXT,__text,regular,pure_instructions
36786676
         .globl _main
3679
-        _main:
3680
-            adrp x8, _value@GOTPAGE
3681
-            ldr x8, [x8, _value@GOTPAGEOFF]
3682
-            ldr w0, [x8]
3683
-            ret
6677
+        .p2align 2
6678
+    _main:
6679
+        ret
36846680
 
3685
-        .section __DATA,__data
3686
-        .globl _value
6681
+        .section __TEXT,__textcoal_nt,regular,pure_instructions
6682
+        .globl _helper
36876683
         .p2align 2
3688
-        _value:
3689
-            .long 7
6684
+    _helper:
6685
+        ret
36906686
         .subsections_via_symbols
36916687
     "#;
3692
-    if let Err(e) = assemble(src, &obj) {
6688
+    if let Err(e) = assemble(asm, &obj) {
36936689
         eprintln!("skipping: assemble failed: {e}");
36946690
         return;
36956691
     }
36966692
 
36976693
     let opts = LinkOptions {
3698
-        inputs: vec![obj.clone(), tbd.clone()],
6694
+        inputs: vec![obj.clone()],
36996695
         output: Some(our_out.clone()),
37006696
         kind: OutputKind::Executable,
37016697
         ..LinkOptions::default()
37026698
     };
37036699
     Linker::run(&opts).unwrap();
3704
-    apple_link_classic_lazy(&obj, &apple_out, "_main", &sdk, &sdk_ver).unwrap();
6700
+    apple_link(&obj, &apple_out, "_main", &sdk, &sdk_ver).unwrap();
37056701
 
37066702
     let our_bytes = fs::read(&our_out).unwrap();
37076703
     let apple_bytes = fs::read(&apple_out).unwrap();
3708
-    let our_binds = decode_bind_records(&our_bytes, false).unwrap();
3709
-    let apple_binds = decode_bind_records(&apple_bytes, false).unwrap();
3710
-    assert_eq!(our_binds, apple_binds);
3711
-    assert!(
3712
-        our_binds.iter().all(|record| record.symbol != "_value"),
3713
-        "local GOT target should not be emitted as a dylib bind: {our_binds:#?}"
3714
-    );
3715
-    assert_eq!(
3716
-        output_section(&our_bytes, "__DATA_CONST", "__got")
3717
-            .expect("missing __got section")
3718
-            .1
3719
-            .len(),
3720
-        8
3721
-    );
3722
-    let verify = Command::new("codesign")
3723
-        .arg("-v")
3724
-        .arg(&our_out)
3725
-        .output()
3726
-        .unwrap();
3727
-    assert!(
3728
-        verify.status.success(),
3729
-        "codesign verify failed: {}",
3730
-        String::from_utf8_lossy(&verify.stderr)
3731
-    );
3732
-    let status = Command::new(&our_out).status().unwrap();
6704
+    assert_eq!(decode_function_starts(&our_bytes).len(), 2);
6705
+    assert_eq!(decode_function_starts(&apple_bytes).len(), 2);
6706
+
6707
+    let our_text_addr = output_section(&our_bytes, "__TEXT", "__text").unwrap().0;
6708
+    let our_textcoal_addr = output_section(&our_bytes, "__TEXT", "__textcoal_nt")
6709
+        .unwrap()
6710
+        .0;
6711
+    let our_text_base = segment_vmaddr(&our_bytes, "__TEXT").unwrap();
37336712
     assert_eq!(
3734
-        status.code(),
3735
-        Some(7),
3736
-        "expected local GOT executable to exit 7"
6713
+        decode_function_starts(&our_bytes),
6714
+        vec![
6715
+            our_text_addr - our_text_base,
6716
+            our_textcoal_addr - our_text_base
6717
+        ]
37376718
     );
37386719
 
37396720
     let _ = fs::remove_file(obj);
@@ -3742,9 +6723,9 @@ fn linker_run_routes_local_got_loads_through_rebased_slots() {
37426723
 }
37436724
 
37446725
 #[test]
3745
-fn linker_run_relaxes_hidden_got_loads_like_apple_ld() {
3746
-    if !have_xcrun() || !have_tool("codesign") {
3747
-        eprintln!("skipping: xcrun or codesign unavailable");
6726
+fn linker_run_remaps_data_in_code_like_ld() {
6727
+    if !have_xcrun() {
6728
+        eprintln!("skipping: xcrun unavailable");
37486729
         return;
37496730
     }
37506731
     let Some(sdk) = sdk_path() else {
@@ -3761,66 +6742,66 @@ fn linker_run_relaxes_hidden_got_loads_like_apple_ld() {
37616742
         return;
37626743
     }
37636744
 
3764
-    let obj = scratch("hidden-got.o");
3765
-    let our_out = scratch("hidden-got-ours.out");
3766
-    let apple_out = scratch("hidden-got-apple.out");
3767
-    let src = r#"
3768
-        .section __TEXT,__text,regular,pure_instructions
6745
+    let obj = scratch("data-in-code.o");
6746
+    let our_out = scratch("data-in-code-ours.out");
6747
+    let apple_out = scratch("data-in-code-apple.out");
6748
+    let asm = r#"
6749
+        .text
37696750
         .globl _main
3770
-        _main:
3771
-            adrp x8, _value@GOTPAGE
3772
-            ldr x8, [x8, _value@GOTPAGEOFF]
3773
-            ldr w0, [x8]
3774
-            ret
3775
-
3776
-        .private_extern _value
3777
-        .section __DATA,__data
37786751
         .p2align 2
3779
-        _value:
3780
-            .long 7
6752
+    _main:
6753
+        mov w0, #0
6754
+        b Ldispatch
6755
+        .p2align 2
6756
+    Ltable:
6757
+        .data_region jt32
6758
+        .long Lcase0-Ltable
6759
+        .long Lcase1-Ltable
6760
+        .end_data_region
6761
+    Ldispatch:
6762
+        cmp w0, #0
6763
+        b.eq Lcase0
6764
+        b Lcase1
6765
+    Lcase0:
6766
+        mov w0, #1
6767
+        ret
6768
+    Lcase1:
6769
+        mov w0, #2
6770
+        ret
37816771
         .subsections_via_symbols
37826772
     "#;
3783
-    if let Err(e) = assemble(src, &obj) {
6773
+    if let Err(e) = assemble(asm, &obj) {
37846774
         eprintln!("skipping: assemble failed: {e}");
37856775
         return;
37866776
     }
37876777
 
37886778
     let opts = LinkOptions {
3789
-        inputs: vec![obj.clone(), tbd.clone()],
6779
+        inputs: vec![obj.clone(), tbd],
37906780
         output: Some(our_out.clone()),
37916781
         kind: OutputKind::Executable,
3792
-        ..LinkOptions::default()
3793
-    };
3794
-    Linker::run(&opts).unwrap();
3795
-    apple_link_classic_lazy(&obj, &apple_out, "_main", &sdk, &sdk_ver).unwrap();
3796
-
3797
-    let our_bytes = fs::read(&our_out).unwrap();
3798
-    let apple_bytes = fs::read(&apple_out).unwrap();
3799
-    let (our_text_addr, our_text) = output_section(&our_bytes, "__TEXT", "__text").unwrap();
3800
-    let (apple_text_addr, apple_text) = output_section(&apple_bytes, "__TEXT", "__text").unwrap();
3801
-    assert_eq!(
3802
-        decode_page_reference(&our_text, our_text_addr, 0, &PageRefKind::Add).unwrap(),
3803
-        decode_page_reference(&apple_text, apple_text_addr, 0, &PageRefKind::Add).unwrap()
3804
-    );
3805
-    assert_eq!(our_text, apple_text);
3806
-    assert!(output_section(&our_bytes, "__DATA_CONST", "__got").is_none());
3807
-    assert!(output_section(&apple_bytes, "__DATA_CONST", "__got").is_none());
3808
-
3809
-    let verify = Command::new("codesign")
3810
-        .arg("-v")
3811
-        .arg(&our_out)
3812
-        .output()
3813
-        .unwrap();
3814
-    assert!(
3815
-        verify.status.success(),
3816
-        "codesign verify failed: {}",
3817
-        String::from_utf8_lossy(&verify.stderr)
6782
+        ..LinkOptions::default()
6783
+    };
6784
+    Linker::run(&opts).unwrap();
6785
+    apple_link(&obj, &apple_out, "_main", &sdk, &sdk_ver).unwrap();
6786
+
6787
+    let our_bytes = fs::read(&our_out).unwrap();
6788
+    let apple_bytes = fs::read(&apple_out).unwrap();
6789
+    let our_dic = raw_linkedit_data_cmd(&our_bytes, LC_DATA_IN_CODE);
6790
+    let apple_dic = raw_linkedit_data_cmd(&apple_bytes, LC_DATA_IN_CODE);
6791
+    assert_ne!(our_dic.1, 0);
6792
+    assert_eq!(our_dic.1, apple_dic.1);
6793
+    assert_eq!(decode_data_in_code(&our_bytes).len(), 1);
6794
+    assert_eq!(
6795
+        canonical_data_in_code(&our_bytes),
6796
+        canonical_data_in_code(&apple_bytes)
38186797
     );
3819
-    let status = Command::new(&our_out).status().unwrap();
38206798
     assert_eq!(
3821
-        status.code(),
3822
-        Some(7),
3823
-        "expected hidden GOT executable to exit 7"
6799
+        canonical_data_in_code(&our_bytes),
6800
+        vec![DataInCodeRecord {
6801
+            offset: 8,
6802
+            length: 8,
6803
+            kind: DICE_KIND_JUMP_TABLE32,
6804
+        }]
38246805
     );
38256806
 
38266807
     let _ = fs::remove_file(obj);
@@ -3829,42 +6810,52 @@ fn linker_run_relaxes_hidden_got_loads_like_apple_ld() {
38296810
 }
38306811
 
38316812
 #[test]
3832
-fn linker_run_partitions_symtab_like_ld() {
6813
+fn linker_run_remaps_data_in_code_in_later_text_section_like_ld() {
38336814
     if !have_xcrun() {
38346815
         eprintln!("skipping: xcrun unavailable");
38356816
         return;
38366817
     }
3837
-
3838
-    let dylib = scratch("symtab-partition.dylib");
3839
-    let obj = scratch("symtab-partition.o");
3840
-    let our_out = scratch("symtab-partition-ours.out");
3841
-    let apple_out = scratch("symtab-partition-apple.out");
3842
-
3843
-    let dylib_src = r#"
3844
-        int ext_data = 5;
3845
-    "#;
3846
-    if let Err(e) = compile_dylib_c(dylib_src, &dylib) {
3847
-        eprintln!("skipping: dylib compile failed: {e}");
6818
+    let Some(sdk) = sdk_path() else {
6819
+        eprintln!("skipping: xcrun --show-sdk-path unavailable");
6820
+        return;
6821
+    };
6822
+    let Some(sdk_ver) = sdk_version() else {
6823
+        eprintln!("skipping: xcrun --show-sdk-version unavailable");
6824
+        return;
6825
+    };
6826
+    let tbd = PathBuf::from(format!("{sdk}/usr/lib/libSystem.tbd"));
6827
+    if !tbd.exists() {
6828
+        eprintln!("skipping: no libSystem.tbd at {}", tbd.display());
38486829
         return;
38496830
     }
38506831
 
6832
+    let obj = scratch("data-in-code-late.o");
6833
+    let our_out = scratch("data-in-code-late-ours.out");
6834
+    let apple_out = scratch("data-in-code-late-apple.out");
38516835
     let asm = r#"
38526836
         .text
3853
-        .private_extern _hidden
3854
-        .globl _visible
38556837
         .globl _main
38566838
         .p2align 2
3857
-    _local:
6839
+    _main:
38586840
         ret
3859
-    _hidden:
6841
+
6842
+        .section __TEXT,__text2,regular,pure_instructions
6843
+        .globl _helper
6844
+        .p2align 2
6845
+    _helper:
6846
+        b Ldispatch
6847
+        .p2align 2
6848
+    Ltable:
6849
+        .data_region jt32
6850
+        .long Lcase0-Ltable
6851
+        .long Lcase1-Ltable
6852
+        .end_data_region
6853
+    Ldispatch:
38606854
         ret
3861
-    _visible:
6855
+    Lcase0:
38626856
         ret
3863
-    _main:
6857
+    Lcase1:
38646858
         ret
3865
-
3866
-        .data
3867
-        .quad _ext_data
38686859
         .subsections_via_symbols
38696860
     "#;
38706861
     if let Err(e) = assemble(asm, &obj) {
@@ -3873,95 +6864,85 @@ fn linker_run_partitions_symtab_like_ld() {
38736864
     }
38746865
 
38756866
     let opts = LinkOptions {
3876
-        inputs: vec![obj.clone(), dylib.clone()],
6867
+        inputs: vec![obj.clone(), tbd],
38776868
         output: Some(our_out.clone()),
38786869
         kind: OutputKind::Executable,
38796870
         ..LinkOptions::default()
38806871
     };
38816872
     Linker::run(&opts).unwrap();
3882
-
3883
-    let apple = Command::new("xcrun")
3884
-        .args(["ld", "-arch", "arm64", "-e", "_main", "-o"])
3885
-        .arg(&apple_out)
3886
-        .arg(&obj)
3887
-        .arg(&dylib)
3888
-        .output()
3889
-        .unwrap();
3890
-    assert!(
3891
-        apple.status.success(),
3892
-        "xcrun ld failed: {}",
3893
-        String::from_utf8_lossy(&apple.stderr)
3894
-    );
6873
+    apple_link(&obj, &apple_out, "_main", &sdk, &sdk_ver).unwrap();
38956874
 
38966875
     let our_bytes = fs::read(&our_out).unwrap();
38976876
     let apple_bytes = fs::read(&apple_out).unwrap();
3898
-    let (our_symtab, our_dysymtab) = symtab_and_dysymtab(&our_bytes);
3899
-    let (apple_symtab, apple_dysymtab) = symtab_and_dysymtab(&apple_bytes);
3900
-
3901
-    assert_eq!(our_symtab.nsyms, apple_symtab.nsyms);
3902
-    assert_eq!(our_dysymtab.ilocalsym, apple_dysymtab.ilocalsym);
3903
-    assert_eq!(our_dysymtab.nlocalsym, apple_dysymtab.nlocalsym);
3904
-    assert_eq!(our_dysymtab.iextdefsym, apple_dysymtab.iextdefsym);
3905
-    assert_eq!(our_dysymtab.nextdefsym, apple_dysymtab.nextdefsym);
3906
-    assert_eq!(our_dysymtab.iundefsym, apple_dysymtab.iundefsym);
3907
-    assert_eq!(our_dysymtab.nundefsym, apple_dysymtab.nundefsym);
39086877
     assert_eq!(
3909
-        canonical_symbol_records(&our_bytes),
3910
-        canonical_symbol_records(&apple_bytes)
3911
-    );
3912
-    assert_strtab_within_five_percent(
3913
-        &raw_string_table(&our_bytes),
3914
-        &raw_string_table(&apple_bytes),
6878
+        canonical_data_in_code(&our_bytes),
6879
+        canonical_data_in_code(&apple_bytes)
39156880
     );
3916
-
39176881
     assert_eq!(
3918
-        symbol_partition_names(&our_bytes),
3919
-        symbol_partition_names(&apple_bytes)
6882
+        canonical_data_in_code(&our_bytes),
6883
+        vec![DataInCodeRecord {
6884
+            offset: 8,
6885
+            length: 8,
6886
+            kind: DICE_KIND_JUMP_TABLE32,
6887
+        }]
39206888
     );
39216889
 
3922
-    let _ = fs::remove_file(dylib);
39236890
     let _ = fs::remove_file(obj);
39246891
     let _ = fs::remove_file(our_out);
39256892
     let _ = fs::remove_file(apple_out);
39266893
 }
39276894
 
39286895
 #[test]
3929
-fn linker_run_strips_locals_with_x_like_ld() {
6896
+fn linker_run_remaps_data_in_code_after_large_first_text_section_like_ld() {
39306897
     if !have_xcrun() {
39316898
         eprintln!("skipping: xcrun unavailable");
39326899
         return;
39336900
     }
3934
-
3935
-    let dylib = scratch("symtab-strip.dylib");
3936
-    let obj = scratch("symtab-strip.o");
3937
-    let our_out = scratch("symtab-strip-ours.out");
3938
-    let apple_out = scratch("symtab-strip-apple.out");
3939
-
3940
-    let dylib_src = r#"
3941
-        int ext_data = 5;
3942
-    "#;
3943
-    if let Err(e) = compile_dylib_c(dylib_src, &dylib) {
3944
-        eprintln!("skipping: dylib compile failed: {e}");
6901
+    let Some(sdk) = sdk_path() else {
6902
+        eprintln!("skipping: xcrun --show-sdk-path unavailable");
6903
+        return;
6904
+    };
6905
+    let Some(sdk_ver) = sdk_version() else {
6906
+        eprintln!("skipping: xcrun --show-sdk-version unavailable");
6907
+        return;
6908
+    };
6909
+    let tbd = PathBuf::from(format!("{sdk}/usr/lib/libSystem.tbd"));
6910
+    if !tbd.exists() {
6911
+        eprintln!("skipping: no libSystem.tbd at {}", tbd.display());
39456912
         return;
39466913
     }
39476914
 
6915
+    let obj = scratch("data-in-code-large-first.o");
6916
+    let our_out = scratch("data-in-code-large-first-ours.out");
6917
+    let apple_out = scratch("data-in-code-large-first-apple.out");
39486918
     let asm = r#"
39496919
         .text
3950
-        .private_extern _hidden
3951
-        .globl _visible
39526920
         .globl _main
39536921
         .p2align 2
3954
-    _local:
6922
+    _main:
6923
+        nop
6924
+        nop
6925
+        nop
6926
+        nop
6927
+        nop
39556928
         ret
3956
-    _hidden:
6929
+
6930
+        .section __TEXT,__text2,regular,pure_instructions
6931
+        .globl _helper
6932
+    _helper:
6933
+        b Ldispatch
6934
+        .p2align 2
6935
+    Ltable:
6936
+        .data_region jt32
6937
+        .long Lcase0-Ltable
6938
+        .long Lcase1-Ltable
6939
+        .end_data_region
6940
+    Ldispatch:
39576941
         ret
3958
-    _visible:
6942
+    Lcase0:
39596943
         ret
3960
-    _main:
6944
+    Lcase1:
39616945
         ret
3962
-
3963
-        .data
3964
-        .quad _ext_data
39656946
         .subsections_via_symbols
39666947
     "#;
39676948
     if let Err(e) = assemble(asm, &obj) {
@@ -3970,92 +6951,79 @@ fn linker_run_strips_locals_with_x_like_ld() {
39706951
     }
39716952
 
39726953
     let opts = LinkOptions {
3973
-        inputs: vec![obj.clone(), dylib.clone()],
6954
+        inputs: vec![obj.clone(), tbd],
39746955
         output: Some(our_out.clone()),
39756956
         kind: OutputKind::Executable,
3976
-        strip_locals: true,
39776957
         ..LinkOptions::default()
39786958
     };
39796959
     Linker::run(&opts).unwrap();
3980
-
3981
-    let apple = Command::new("xcrun")
3982
-        .args(["ld", "-arch", "arm64", "-x", "-e", "_main", "-o"])
3983
-        .arg(&apple_out)
3984
-        .arg(&obj)
3985
-        .arg(&dylib)
3986
-        .output()
3987
-        .unwrap();
3988
-    assert!(
3989
-        apple.status.success(),
3990
-        "xcrun ld failed: {}",
3991
-        String::from_utf8_lossy(&apple.stderr)
3992
-    );
6960
+    apple_link(&obj, &apple_out, "_main", &sdk, &sdk_ver).unwrap();
39936961
 
39946962
     let our_bytes = fs::read(&our_out).unwrap();
39956963
     let apple_bytes = fs::read(&apple_out).unwrap();
3996
-    let (our_symtab, our_dysymtab) = symtab_and_dysymtab(&our_bytes);
3997
-    let (apple_symtab, apple_dysymtab) = symtab_and_dysymtab(&apple_bytes);
3998
-
3999
-    assert_eq!(our_symtab.nsyms, apple_symtab.nsyms);
4000
-    assert_eq!(our_dysymtab.ilocalsym, apple_dysymtab.ilocalsym);
4001
-    assert_eq!(our_dysymtab.nlocalsym, apple_dysymtab.nlocalsym);
4002
-    assert_eq!(our_dysymtab.iextdefsym, apple_dysymtab.iextdefsym);
4003
-    assert_eq!(our_dysymtab.nextdefsym, apple_dysymtab.nextdefsym);
4004
-    assert_eq!(our_dysymtab.iundefsym, apple_dysymtab.iundefsym);
4005
-    assert_eq!(our_dysymtab.nundefsym, apple_dysymtab.nundefsym);
40066964
     assert_eq!(
4007
-        canonical_symbol_records(&our_bytes),
4008
-        canonical_symbol_records(&apple_bytes)
6965
+        canonical_data_in_code(&our_bytes),
6966
+        canonical_data_in_code(&apple_bytes)
40096967
     );
4010
-
4011
-    let (locals, extdefs, undefs) = symbol_partition_names(&our_bytes);
4012
-    assert!(locals.is_empty());
40136968
     assert_eq!(
4014
-        extdefs,
4015
-        vec![
4016
-            "__mh_execute_header".to_string(),
4017
-            "_main".to_string(),
4018
-            "_visible".to_string()
4019
-        ]
6969
+        canonical_data_in_code(&our_bytes),
6970
+        vec![DataInCodeRecord {
6971
+            offset: 28,
6972
+            length: 8,
6973
+            kind: DICE_KIND_JUMP_TABLE32,
6974
+        }]
40206975
     );
4021
-    assert_eq!(undefs, vec!["_ext_data".to_string()]);
40226976
 
4023
-    let _ = fs::remove_file(dylib);
40246977
     let _ = fs::remove_file(obj);
40256978
     let _ = fs::remove_file(our_out);
40266979
     let _ = fs::remove_file(apple_out);
40276980
 }
40286981
 
40296982
 #[test]
4030
-fn linker_run_emits_leaf_unwind_info_like_ld() {
6983
+fn linker_run_dedups_output_strtab_like_ld() {
40316984
     if !have_xcrun() {
40326985
         eprintln!("skipping: xcrun unavailable");
40336986
         return;
40346987
     }
4035
-    let Some(sdk) = sdk_path() else {
4036
-        eprintln!("skipping: xcrun --show-sdk-path unavailable");
4037
-        return;
4038
-    };
4039
-    let Some(sdk_ver) = sdk_version() else {
4040
-        eprintln!("skipping: xcrun --show-sdk-version unavailable");
4041
-        return;
4042
-    };
4043
-
4044
-    let obj = scratch("unwind-leaf.o");
4045
-    let our_out = scratch("unwind-leaf-ours.out");
4046
-    let apple_out = scratch("unwind-leaf-apple.out");
4047
-    let src = r#"
4048
-        int main(void) {
4049
-            return 0;
4050
-        }
4051
-    "#;
4052
-    if let Err(e) = compile_c(src, &obj) {
4053
-        eprintln!("skipping: clang compile failed: {e}");
6988
+    let Some(sdk) = sdk_path() else {
6989
+        eprintln!("skipping: xcrun --show-sdk-path unavailable");
6990
+        return;
6991
+    };
6992
+    let Some(sdk_ver) = sdk_version() else {
6993
+        eprintln!("skipping: xcrun --show-sdk-version unavailable");
6994
+        return;
6995
+    };
6996
+    let tbd = PathBuf::from(format!("{sdk}/usr/lib/libSystem.tbd"));
6997
+    if !tbd.exists() {
6998
+        eprintln!("skipping: no libSystem.tbd at {}", tbd.display());
6999
+        return;
7000
+    }
7001
+
7002
+    let obj = scratch("strtab-dedup.o");
7003
+    let our_out = scratch("strtab-dedup-ours.out");
7004
+    let apple_out = scratch("strtab-dedup-apple.out");
7005
+    let mut asm =
7006
+        String::from("        .text\n        .globl _afs_array_sum\n        .globl _main\n");
7007
+    for idx in 0..20 {
7008
+        let symbol = format!("_pad_symbol_{idx:02}");
7009
+        asm.push_str(&format!("        .globl {symbol}\n"));
7010
+    }
7011
+    asm.push_str("        .p2align 2\n");
7012
+    asm.push_str("    _array_sum:\n        ret\n");
7013
+    asm.push_str("    _afs_array_sum:\n        ret\n");
7014
+    for idx in 0..20 {
7015
+        let symbol = format!("_pad_symbol_{idx:02}");
7016
+        asm.push_str(&format!("    {symbol}:\n        ret\n"));
7017
+    }
7018
+    asm.push_str("    _main:\n        bl _afs_array_sum\n        ret\n");
7019
+    asm.push_str("        .subsections_via_symbols\n");
7020
+    if let Err(e) = assemble(&asm, &obj) {
7021
+        eprintln!("skipping: assemble failed: {e}");
40547022
         return;
40557023
     }
40567024
 
40577025
     let opts = LinkOptions {
4058
-        inputs: vec![obj.clone()],
7026
+        inputs: vec![obj.clone(), tbd],
40597027
         output: Some(our_out.clone()),
40607028
         kind: OutputKind::Executable,
40617029
         ..LinkOptions::default()
@@ -4066,10 +7034,21 @@ fn linker_run_emits_leaf_unwind_info_like_ld() {
40667034
     let our_bytes = fs::read(&our_out).unwrap();
40677035
     let apple_bytes = fs::read(&apple_out).unwrap();
40687036
     assert_eq!(
4069
-        rebased_unwind_bytes(&our_bytes),
4070
-        rebased_unwind_bytes(&apple_bytes)
7037
+        canonical_symbol_records(&our_bytes),
7038
+        canonical_symbol_records(&apple_bytes)
7039
+    );
7040
+    let our_strtab = raw_string_table(&our_bytes);
7041
+    let apple_strtab = raw_string_table(&apple_bytes);
7042
+    assert_strtab_within_five_percent(&our_strtab, &apple_strtab);
7043
+    assert!(
7044
+        our_strtab.len() <= apple_strtab.len(),
7045
+        "suffix dedup should not grow the output string table: ours={} apple={}",
7046
+        our_strtab.len(),
7047
+        apple_strtab.len()
40717048
     );
4072
-    assert!(output_section(&our_bytes, "__LD", "__compact_unwind").is_none());
7049
+
7050
+    let offsets = symbol_name_offsets(&our_bytes);
7051
+    assert_eq!(offsets["_array_sum"], offsets["_afs_array_sum"] + 4);
40737052
 
40747053
     let _ = fs::remove_file(obj);
40757054
     let _ = fs::remove_file(our_out);
@@ -4077,93 +7056,109 @@ fn linker_run_emits_leaf_unwind_info_like_ld() {
40777056
 }
40787057
 
40797058
 #[test]
4080
-fn linker_run_emits_multi_function_unwind_info_like_ld() {
4081
-    if !have_xcrun() {
4082
-        eprintln!("skipping: xcrun unavailable");
7059
+fn linker_run_launches_with_classic_lazy_dylib_import() {
7060
+    if !have_xcrun() || !have_tool("codesign") {
7061
+        eprintln!("skipping: xcrun clang or codesign unavailable");
40837062
         return;
40847063
     }
40857064
     let Some(sdk) = sdk_path() else {
40867065
         eprintln!("skipping: xcrun --show-sdk-path unavailable");
40877066
         return;
40887067
     };
4089
-    let Some(sdk_ver) = sdk_version() else {
4090
-        eprintln!("skipping: xcrun --show-sdk-version unavailable");
7068
+    let tbd = PathBuf::from(format!("{sdk}/usr/lib/libSystem.tbd"));
7069
+    if !tbd.exists() {
7070
+        eprintln!("skipping: no libSystem.tbd at {}", tbd.display());
40917071
         return;
4092
-    };
7072
+    }
40937073
 
4094
-    let obj = scratch("unwind-mixed.o");
4095
-    let our_out = scratch("unwind-mixed-ours.out");
4096
-    let apple_out = scratch("unwind-mixed-apple.out");
4097
-    let src = r#"
4098
-        int helper(void) {
4099
-            return 1;
4100
-        }
7074
+    let dylib = scratch("lazy-runtime.dylib");
7075
+    let obj = scratch("lazy-runtime.o");
7076
+    let out = scratch("lazy-runtime.out");
41017077
 
4102
-        int main(void) {
4103
-            return helper();
4104
-        }
7078
+    let dylib_src = r#"
7079
+        int ext_fn(void) { return 7; }
41057080
     "#;
4106
-    if let Err(e) = compile_c(src, &obj) {
4107
-        eprintln!("skipping: clang compile failed: {e}");
7081
+    if let Err(e) = compile_dylib_c(dylib_src, &dylib) {
7082
+        eprintln!("skipping: dylib compile failed: {e}");
7083
+        return;
7084
+    }
7085
+
7086
+    let main_src = r#"
7087
+        int ext_fn(void);
7088
+        int main(void) { return ext_fn() == 7 ? 0 : 1; }
7089
+    "#;
7090
+    if let Err(e) = compile_c(main_src, &obj) {
7091
+        eprintln!("skipping: compile failed: {e}");
41087092
         return;
41097093
     }
41107094
 
41117095
     let opts = LinkOptions {
4112
-        inputs: vec![obj.clone()],
4113
-        output: Some(our_out.clone()),
7096
+        inputs: vec![obj.clone(), tbd, dylib.clone()],
7097
+        output: Some(out.clone()),
41147098
         kind: OutputKind::Executable,
41157099
         ..LinkOptions::default()
41167100
     };
41177101
     Linker::run(&opts).unwrap();
4118
-    apple_link(&obj, &apple_out, "_main", &sdk, &sdk_ver).unwrap();
41197102
 
4120
-    let our_bytes = fs::read(&our_out).unwrap();
4121
-    let apple_bytes = fs::read(&apple_out).unwrap();
7103
+    let verify = Command::new("codesign")
7104
+        .arg("-v")
7105
+        .arg(&out)
7106
+        .output()
7107
+        .unwrap();
7108
+    assert!(
7109
+        verify.status.success(),
7110
+        "codesign verify failed: {}",
7111
+        String::from_utf8_lossy(&verify.stderr)
7112
+    );
7113
+    let status = Command::new(&out).status().unwrap();
41227114
     assert_eq!(
4123
-        rebased_unwind_bytes(&our_bytes),
4124
-        rebased_unwind_bytes(&apple_bytes)
7115
+        status.code(),
7116
+        Some(0),
7117
+        "expected dylib-import executable to exit 0"
41257118
     );
4126
-    assert!(output_section(&our_bytes, "__LD", "__compact_unwind").is_none());
41277119
 
7120
+    let _ = fs::remove_file(dylib);
41287121
     let _ = fs::remove_file(obj);
4129
-    let _ = fs::remove_file(our_out);
4130
-    let _ = fs::remove_file(apple_out);
7122
+    let _ = fs::remove_file(out);
41317123
 }
41327124
 
41337125
 #[test]
4134
-fn linker_run_handles_large_unwind_function_gaps() {
4135
-    if !have_xcrun() {
4136
-        eprintln!("skipping: xcrun unavailable");
7126
+fn linker_run_handles_local_tlv_descriptors() {
7127
+    if !have_xcrun() || !have_tool("codesign") {
7128
+        eprintln!("skipping: xcrun clang or codesign unavailable");
7129
+        return;
7130
+    }
7131
+    let Some(sdk) = sdk_path() else {
7132
+        eprintln!("skipping: xcrun --show-sdk-path unavailable");
7133
+        return;
7134
+    };
7135
+    let tbd = PathBuf::from(format!("{sdk}/usr/lib/libSystem.tbd"));
7136
+    if !tbd.exists() {
7137
+        eprintln!("skipping: no libSystem.tbd at {}", tbd.display());
41377138
         return;
41387139
     }
41397140
 
4140
-    let obj = scratch("unwind-gap.o");
4141
-    let out = scratch("unwind-gap-ours.out");
4142
-    let asm = r#"
4143
-        .text
4144
-        .globl _main
4145
-        .p2align 2
4146
-    _main:
4147
-        .cfi_startproc
4148
-        bl _helper
4149
-        ret
4150
-        .cfi_endproc
4151
-        .space 0x1000010
4152
-        .globl _helper
4153
-        .p2align 2
4154
-    _helper:
4155
-        .cfi_startproc
4156
-        ret
4157
-        .cfi_endproc
4158
-        .subsections_via_symbols
7141
+    let obj = scratch("tlvp-local.o");
7142
+    let out = scratch("tlvp-local.out");
7143
+    let src = r#"
7144
+        __thread long tls_a = 7;
7145
+        __thread long tls_b;
7146
+
7147
+        static long tls_sum(void) {
7148
+            return tls_a + tls_b;
7149
+        }
7150
+
7151
+        int main(void) {
7152
+            return tls_sum() == 7 ? 0 : 1;
7153
+        }
41597154
     "#;
4160
-    if let Err(e) = assemble(asm, &obj) {
4161
-        eprintln!("skipping: assemble failed: {e}");
7155
+    if let Err(e) = compile_c(src, &obj) {
7156
+        eprintln!("skipping: compile failed: {e}");
41627157
         return;
41637158
     }
41647159
 
41657160
     let opts = LinkOptions {
4166
-        inputs: vec![obj.clone()],
7161
+        inputs: vec![obj.clone(), tbd],
41677162
         output: Some(out.clone()),
41687163
         kind: OutputKind::Executable,
41697164
         ..LinkOptions::default()
@@ -4171,118 +7166,214 @@ fn linker_run_handles_large_unwind_function_gaps() {
41717166
     Linker::run(&opts).unwrap();
41727167
 
41737168
     let bytes = fs::read(&out).unwrap();
4174
-    let (_, unwind) = output_section(&bytes, "__TEXT", "__unwind_info").unwrap();
4175
-    let decoded = decode_unwind_info(&unwind).unwrap();
7169
+    let (_, thread_vars) = output_section(&bytes, "__DATA", "__thread_vars").unwrap();
7170
+    let (_, thread_data) = output_section(&bytes, "__DATA", "__thread_data").unwrap();
7171
+    assert!(output_section(&bytes, "__DATA", "__thread_ptrs").is_none());
7172
+    assert_eq!(thread_vars.len(), 48);
7173
+    assert_eq!(thread_data.len(), 8);
7174
+    assert_eq!(
7175
+        u64::from_le_bytes(thread_vars[16..24].try_into().unwrap()),
7176
+        0
7177
+    );
7178
+    assert_eq!(
7179
+        u64::from_le_bytes(thread_vars[40..48].try_into().unwrap()),
7180
+        8
7181
+    );
7182
+
7183
+    let binds = decode_bind_records(&bytes, false).unwrap();
7184
+    let mut tlv_binds: Vec<_> = binds
7185
+        .into_iter()
7186
+        .filter(|record| record.section == "__thread_vars" && record.symbol == "__tlv_bootstrap")
7187
+        .collect();
7188
+    tlv_binds.sort_by_key(|record| record.section_offset);
7189
+    assert_eq!(tlv_binds.len(), 2);
7190
+    assert_eq!(tlv_binds[0].section_offset, 0);
7191
+    assert_eq!(tlv_binds[1].section_offset, 24);
7192
+
7193
+    let header = parse_header(&bytes).unwrap();
7194
+    let commands = parse_commands(&header, &bytes).unwrap();
7195
+    let symtab = commands
7196
+        .iter()
7197
+        .find_map(|cmd| match cmd {
7198
+            LoadCommand::Symtab(cmd) => Some(*cmd),
7199
+            _ => None,
7200
+        })
7201
+        .unwrap();
7202
+    let symbols = parse_nlist_table(&bytes, symtab.symoff, symtab.nsyms).unwrap();
7203
+    let strings = StringTable::from_file(&bytes, symtab.stroff, symtab.strsize).unwrap();
7204
+    let symbol_names: Vec<&str> = symbols
7205
+        .iter()
7206
+        .map(|symbol| strings.get(symbol.strx()).unwrap())
7207
+        .collect();
7208
+    assert!(symbol_names.contains(&"__tlv_bootstrap"));
7209
+
7210
+    let verify = Command::new("codesign")
7211
+        .arg("-v")
7212
+        .arg(&out)
7213
+        .output()
7214
+        .unwrap();
41767215
     assert!(
4177
-        decoded
4178
-            .records
4179
-            .windows(2)
4180
-            .all(|pair| pair[0].function_offset < pair[1].function_offset),
4181
-        "expected strictly ascending unwind records after large-gap pagination"
7216
+        verify.status.success(),
7217
+        "codesign verify failed: {}",
7218
+        String::from_utf8_lossy(&verify.stderr)
41827219
     );
7220
+    let status = Command::new(&out).status().unwrap();
7221
+    assert_eq!(status.code(), Some(0), "expected TLV executable to exit 0");
41837222
 
41847223
     let _ = fs::remove_file(obj);
41857224
     let _ = fs::remove_file(out);
41867225
 }
41877226
 
41887227
 #[test]
4189
-fn linker_run_preserves_eh_frame_like_ld() {
4190
-    if !have_xcrun() || !have_xcrun_tool("dwarfdump") {
4191
-        eprintln!("skipping: xcrun dwarfdump unavailable");
7228
+fn linker_run_routes_imported_tlv_through_got() {
7229
+    if !have_xcrun() || !have_tool("codesign") {
7230
+        eprintln!("skipping: xcrun clang or codesign unavailable");
7231
+        return;
7232
+    }
7233
+    let Some(sdk) = sdk_path() else {
7234
+        eprintln!("skipping: xcrun --show-sdk-path unavailable");
7235
+        return;
7236
+    };
7237
+    let Some(sdk_ver) = sdk_version() else {
7238
+        eprintln!("skipping: xcrun --show-sdk-version unavailable");
7239
+        return;
7240
+    };
7241
+    let tbd = PathBuf::from(format!("{sdk}/usr/lib/libSystem.tbd"));
7242
+    if !tbd.exists() {
7243
+        eprintln!("skipping: no libSystem.tbd at {}", tbd.display());
7244
+        return;
7245
+    }
7246
+
7247
+    let dylib = scratch("libtlvprobe.dylib");
7248
+    let obj = scratch("imported-tlv.o");
7249
+    let our_out = scratch("imported-tlv-ours.out");
7250
+    let apple_out = scratch("imported-tlv-apple.out");
7251
+
7252
+    let dylib_src = r#"
7253
+        __thread long ext_tls = 5;
7254
+        long read_lib_tls(void) { return ext_tls; }
7255
+    "#;
7256
+    if let Err(e) = compile_dylib_c(dylib_src, &dylib) {
7257
+        eprintln!("skipping: dylib compile failed: {e}");
41927258
         return;
41937259
     }
4194
-    let Some(sdk) = sdk_path() else {
4195
-        eprintln!("skipping: xcrun --show-sdk-path unavailable");
4196
-        return;
4197
-    };
4198
-    let Some(sdk_ver) = sdk_version() else {
4199
-        eprintln!("skipping: xcrun --show-sdk-version unavailable");
4200
-        return;
4201
-    };
4202
-
4203
-    let obj = scratch("eh-frame.o");
4204
-    let our_out = scratch("eh-frame-ours.out");
4205
-    let apple_out = scratch("eh-frame-apple.out");
4206
-    let asm = r#"
4207
-        .text
4208
-        .globl _main
4209
-        .p2align 2
4210
-    _main:
4211
-        .cfi_startproc
4212
-        sub sp, sp, #16
4213
-        .cfi_def_cfa_offset 16
4214
-        str x30, [sp, #8]
4215
-        .cfi_offset w30, -8
4216
-        bl _helper
4217
-        ldr x30, [sp, #8]
4218
-        add sp, sp, #16
4219
-        ret
4220
-        .cfi_endproc
42217260
 
4222
-        .globl _helper
4223
-        .p2align 2
4224
-    _helper:
4225
-        .cfi_startproc
4226
-        ret
4227
-        .cfi_endproc
4228
-        .subsections_via_symbols
7261
+    let main_src = r#"
7262
+        extern __thread long ext_tls;
7263
+        int main(void) { return ext_tls == 5 ? 0 : 1; }
42297264
     "#;
4230
-    if let Err(e) = assemble(asm, &obj) {
4231
-        eprintln!("skipping: assemble failed: {e}");
7265
+    if let Err(e) = compile_c(main_src, &obj) {
7266
+        eprintln!("skipping: compile failed: {e}");
42327267
         return;
42337268
     }
42347269
 
42357270
     let opts = LinkOptions {
4236
-        inputs: vec![obj.clone()],
7271
+        inputs: vec![obj.clone(), tbd.clone(), dylib.clone()],
42377272
         output: Some(our_out.clone()),
42387273
         kind: OutputKind::Executable,
42397274
         ..LinkOptions::default()
42407275
     };
42417276
     Linker::run(&opts).unwrap();
4242
-    apple_link(&obj, &apple_out, "_main", &sdk, &sdk_ver).unwrap();
7277
+
7278
+    let apple = Command::new("xcrun")
7279
+        .args([
7280
+            "ld",
7281
+            "-arch",
7282
+            "arm64",
7283
+            "-platform_version",
7284
+            "macos",
7285
+            &sdk_ver,
7286
+            &sdk_ver,
7287
+            "-syslibroot",
7288
+            &sdk,
7289
+            "-no_fixup_chains",
7290
+            "-lSystem",
7291
+            "-e",
7292
+            "_main",
7293
+            "-o",
7294
+        ])
7295
+        .arg(&apple_out)
7296
+        .arg(&obj)
7297
+        .arg(&dylib)
7298
+        .output()
7299
+        .unwrap();
7300
+    assert!(
7301
+        apple.status.success(),
7302
+        "xcrun ld failed: {}",
7303
+        String::from_utf8_lossy(&apple.stderr)
7304
+    );
42437305
 
42447306
     let our_bytes = fs::read(&our_out).unwrap();
42457307
     let apple_bytes = fs::read(&apple_out).unwrap();
4246
-    assert!(output_section(&our_bytes, "__TEXT", "__eh_frame").is_some());
7308
+    let (our_text_addr, our_text) = output_section(&our_bytes, "__TEXT", "__text").unwrap();
7309
+    let (apple_text_addr, apple_text) = output_section(&apple_bytes, "__TEXT", "__text").unwrap();
7310
+    let (our_got_addr, our_got) = output_section(&our_bytes, "__DATA_CONST", "__got").unwrap();
7311
+    let (apple_got_addr, apple_got) =
7312
+        output_section(&apple_bytes, "__DATA_CONST", "__got").unwrap();
7313
+
7314
+    assert!(output_section(&our_bytes, "__DATA", "__thread_ptrs").is_none());
7315
+    assert!(output_section(&apple_bytes, "__DATA", "__thread_ptrs").is_none());
7316
+    assert_eq!(our_got.len(), 8);
7317
+    assert_eq!(our_got, apple_got);
42477318
     assert_eq!(
4248
-        output_section(&our_bytes, "__TEXT", "__eh_frame")
4249
-            .unwrap()
4250
-            .1
4251
-            .len(),
4252
-        output_section(&apple_bytes, "__TEXT", "__eh_frame")
4253
-            .unwrap()
4254
-            .1
4255
-            .len()
7319
+        decode_page_reference(&our_text, our_text_addr, 20, &PageRefKind::Load).unwrap(),
7320
+        our_got_addr
7321
+    );
7322
+    assert_eq!(
7323
+        decode_page_reference(&apple_text, apple_text_addr, 20, &PageRefKind::Load).unwrap(),
7324
+        apple_got_addr
7325
+    );
7326
+    assert_eq!(our_text, apple_text);
7327
+    assert_eq!(read_insn(&our_text, 24).unwrap(), 0xf9400000);
7328
+    assert_eq!(read_insn(&our_text, 28).unwrap(), 0xf9400008);
7329
+    assert_eq!(read_insn(&our_text, 32).unwrap(), 0xd63f0100);
7330
+    assert_eq!(
7331
+        decode_bind_records(&our_bytes, false).unwrap(),
7332
+        decode_bind_records(&apple_bytes, false).unwrap()
7333
+    );
7334
+    assert_eq!(
7335
+        load_dylib_names(&our_bytes).unwrap(),
7336
+        load_dylib_names(&apple_bytes).unwrap()
7337
+    );
7338
+    let verify = Command::new("codesign")
7339
+        .arg("-v")
7340
+        .arg(&our_out)
7341
+        .output()
7342
+        .unwrap();
7343
+    assert!(
7344
+        verify.status.success(),
7345
+        "codesign verify failed: {}",
7346
+        String::from_utf8_lossy(&verify.stderr)
7347
+    );
7348
+    let status = Command::new(&our_out).status().unwrap();
7349
+    assert_eq!(
7350
+        status.code(),
7351
+        Some(0),
7352
+        "expected imported TLV executable to exit 0"
42567353
     );
4257
-    let our_dump = normalized_eh_frame_dump(
4258
-        &our_out,
4259
-        output_section(&our_bytes, "__TEXT", "__text").unwrap().0,
4260
-    )
4261
-    .unwrap();
4262
-    let apple_dump = normalized_eh_frame_dump(
4263
-        &apple_out,
4264
-        output_section(&apple_bytes, "__TEXT", "__text").unwrap().0,
4265
-    )
4266
-    .unwrap();
4267
-    assert_eq!(our_dump, apple_dump);
42687354
 
7355
+    let _ = fs::remove_file(dylib);
42697356
     let _ = fs::remove_file(obj);
42707357
     let _ = fs::remove_file(our_out);
42717358
     let _ = fs::remove_file(apple_out);
42727359
 }
42737360
 
42747361
 #[test]
4275
-fn linker_run_emits_backtrace_metadata_like_apple_ld() {
4276
-    if !have_xcrun() {
4277
-        eprintln!("skipping: xcrun unavailable");
7362
+fn linker_run_preserves_runtime_tlv_descriptor_offsets() {
7363
+    if !have_xcrun() || !have_tool("codesign") {
7364
+        eprintln!("skipping: xcrun or codesign unavailable");
42787365
         return;
42797366
     }
7367
+    let Some(runtime) = find_runtime_archive() else {
7368
+        eprintln!("skipping: libarmfortas_rt.a not built");
7369
+        return;
7370
+    };
42807371
     let Some(sdk) = sdk_path() else {
4281
-        eprintln!("skipping: xcrun --show-sdk-path unavailable");
7372
+        eprintln!("skipping: no macOS SDK path");
42827373
         return;
42837374
     };
42847375
     let Some(sdk_ver) = sdk_version() else {
4285
-        eprintln!("skipping: xcrun --show-sdk-version unavailable");
7376
+        eprintln!("skipping: no macOS SDK version");
42867377
         return;
42877378
     };
42887379
     let tbd = PathBuf::from(format!("{sdk}/usr/lib/libSystem.tbd"));
@@ -4291,147 +7382,183 @@ fn linker_run_emits_backtrace_metadata_like_apple_ld() {
42917382
         return;
42927383
     }
42937384
 
4294
-    let obj = scratch("unwind-backtrace.o");
4295
-    let our_out = scratch("unwind-backtrace-ours.out");
4296
-    let apple_out = scratch("unwind-backtrace-apple.out");
7385
+    let obj = scratch("runtime-hello.o");
7386
+    let out = scratch("runtime-hello.out");
7387
+    let apple_out = scratch("runtime-hello-apple.out");
42977388
     let src = r#"
4298
-        #include <unwind.h>
4299
-
4300
-        static _Unwind_Reason_Code cb(struct _Unwind_Context* ctx, void* arg) {
4301
-            (void)ctx;
4302
-            int* count = (int*)arg;
4303
-            (*count)++;
4304
-            return *count >= 8 ? _URC_END_OF_STACK : _URC_NO_REASON;
4305
-        }
4306
-
4307
-        __attribute__((noinline)) int helper(void) {
4308
-            int count = 0;
4309
-            _Unwind_Backtrace(cb, &count);
4310
-            return count;
4311
-        }
7389
+        extern void afs_program_init(void);
7390
+        extern void afs_program_finalize(void);
7391
+        extern void afs_write_string(int, const char *, long);
7392
+        extern void afs_write_newline(int);
43127393
 
43137394
         int main(void) {
4314
-            return helper() > 1 ? 0 : 1;
7395
+            afs_program_init();
7396
+            afs_write_string(6, "Hello, World!", 13);
7397
+            afs_write_newline(6);
7398
+            afs_program_finalize();
7399
+            return 0;
43157400
         }
43167401
     "#;
43177402
     if let Err(e) = compile_c(src, &obj) {
4318
-        eprintln!("skipping: clang compile failed: {e}");
7403
+        eprintln!("skipping: compile failed: {e}");
43197404
         return;
43207405
     }
43217406
 
43227407
     let opts = LinkOptions {
4323
-        inputs: vec![obj.clone(), tbd],
4324
-        output: Some(our_out.clone()),
7408
+        inputs: vec![obj.clone(), runtime.clone(), tbd],
7409
+        output: Some(out.clone()),
43257410
         kind: OutputKind::Executable,
43267411
         ..LinkOptions::default()
43277412
     };
43287413
     Linker::run(&opts).unwrap();
4329
-    apple_link_classic_lazy(&obj, &apple_out, "_main", &sdk, &sdk_ver).unwrap();
43307414
 
4331
-    let our_bytes = fs::read(&our_out).unwrap();
7415
+    let apple = Command::new("xcrun")
7416
+        .args([
7417
+            "ld",
7418
+            "-arch",
7419
+            "arm64",
7420
+            "-platform_version",
7421
+            "macos",
7422
+            &sdk_ver,
7423
+            &sdk_ver,
7424
+            "-syslibroot",
7425
+            &sdk,
7426
+            "-lSystem",
7427
+            "-e",
7428
+            "_main",
7429
+            "-no_fixup_chains",
7430
+            "-o",
7431
+        ])
7432
+        .arg(&apple_out)
7433
+        .arg(&obj)
7434
+        .arg(&runtime)
7435
+        .output()
7436
+        .unwrap();
7437
+    assert!(
7438
+        apple.status.success(),
7439
+        "xcrun ld failed: {}",
7440
+        String::from_utf8_lossy(&apple.stderr)
7441
+    );
7442
+
7443
+    let verify = Command::new("codesign")
7444
+        .arg("-v")
7445
+        .arg(&out)
7446
+        .output()
7447
+        .unwrap();
7448
+    assert!(
7449
+        verify.status.success(),
7450
+        "codesign verify failed: {}",
7451
+        String::from_utf8_lossy(&verify.stderr)
7452
+    );
7453
+
7454
+    let bytes = fs::read(&out).unwrap();
43327455
     let apple_bytes = fs::read(&apple_out).unwrap();
7456
+    assert!(
7457
+        output_section(&bytes, "__DATA_CONST", "__const").is_some(),
7458
+        "runtime hello should promote file-backed __const data into __DATA_CONST"
7459
+    );
7460
+    assert!(
7461
+        output_section(&bytes, "__DATA", "__const").is_none(),
7462
+        "runtime hello should not leave file-backed __const data in __DATA"
7463
+    );
7464
+    let (thread_vars_addr, thread_vars) =
7465
+        output_section(&bytes, "__DATA", "__thread_vars").unwrap();
7466
+    let (thread_data_addr, _) = output_section(&bytes, "__DATA", "__thread_data").unwrap();
7467
+    let symbols = symbol_values(&bytes);
7468
+    let tlv_binds: Vec<_> = decode_bind_records(&bytes, false)
7469
+        .unwrap()
7470
+        .into_iter()
7471
+        .filter(|record| record.section == "__thread_vars")
7472
+        .collect();
7473
+    assert_eq!(
7474
+        tlv_binds.len(),
7475
+        thread_vars.len() / 24,
7476
+        "every TLV descriptor should carry exactly one bootstrap bind"
7477
+    );
7478
+    assert!(tlv_binds
7479
+        .iter()
7480
+        .all(|record| record.symbol == "__tlv_bootstrap"));
7481
+
7482
+    assert_eq!(
7483
+        decode_bind_records(&bytes, false).unwrap(),
7484
+        decode_bind_records(&apple_bytes, false).unwrap(),
7485
+        "runtime hello bind records diverged from Apple ld"
7486
+    );
43337487
     assert_eq!(
4334
-        rebased_unwind_bytes(&our_bytes),
4335
-        rebased_unwind_bytes(&apple_bytes)
7488
+        decode_bind_records(&bytes, true).unwrap(),
7489
+        decode_bind_records(&apple_bytes, true).unwrap(),
7490
+        "runtime hello lazy-bind records diverged from Apple ld"
43367491
     );
43377492
     assert_eq!(
4338
-        normalize_function_start_offsets(&decode_function_starts(&our_bytes)),
4339
-        normalize_function_start_offsets(&decode_function_starts(&apple_bytes))
7493
+        decode_rebase_records(&bytes).unwrap(),
7494
+        decode_rebase_records(&apple_bytes).unwrap(),
7495
+        "runtime hello rebase records diverged from Apple ld"
7496
+    );
7497
+    assert_eq!(
7498
+        indirect_symbol_identities(&bytes),
7499
+        indirect_symbol_identities(&apple_bytes),
7500
+        "runtime hello indirect symbol identities diverged from Apple ld"
43407501
     );
43417502
 
4342
-    let _ = fs::remove_file(obj);
4343
-    let _ = fs::remove_file(our_out);
4344
-    let _ = fs::remove_file(apple_out);
4345
-}
4346
-
4347
-#[test]
4348
-fn linker_run_preserves_exception_unwind_metadata_like_apple_ld() {
4349
-    if !have_xcrun() || !have_xcrun_tool("clang++") || !have_tool("codesign") {
4350
-        eprintln!("skipping: xcrun clang++ or codesign unavailable");
4351
-        return;
4352
-    }
4353
-
4354
-    let Some(sdk) = sdk_path() else {
4355
-        eprintln!("skipping: xcrun --show-sdk-path unavailable");
4356
-        return;
4357
-    };
4358
-    let libsystem = PathBuf::from(format!("{sdk}/usr/lib/libSystem.tbd"));
4359
-    let libcxx = PathBuf::from(format!("{sdk}/usr/lib/libc++.tbd"));
4360
-    if !libsystem.exists() {
4361
-        eprintln!("skipping: no libSystem.tbd at {}", libsystem.display());
4362
-        return;
4363
-    }
4364
-    if !libcxx.exists() {
4365
-        eprintln!("skipping: no libc++.tbd at {}", libcxx.display());
4366
-        return;
4367
-    }
4368
-
4369
-    let obj = scratch("cxx-exc.o");
4370
-    let our_out = scratch("cxx-exc-ours.out");
4371
-    let apple_out = scratch("cxx-exc-apple.out");
4372
-    let src = r#"
4373
-        int helper() { throw 7; }
4374
-        int main() {
4375
-            try { return helper(); }
4376
-            catch (...) { return 42; }
4377
-        }
4378
-    "#;
4379
-    if let Err(e) = compile_cxx(src, &obj) {
4380
-        eprintln!("skipping: clang++ compile failed: {e}");
4381
-        return;
7503
+    for (name, descriptor_addr) in symbols.iter().filter(|(name, value)| {
7504
+        !name.ends_with("$tlv$init")
7505
+            && **value >= thread_vars_addr
7506
+            && **value < thread_vars_addr + thread_vars.len() as u64
7507
+    }) {
7508
+        let init_name = format!("{name}$tlv$init");
7509
+        let Some(init_addr) = symbols.get(&init_name) else {
7510
+            continue;
7511
+        };
7512
+        let offset = (*descriptor_addr - thread_vars_addr) as usize;
7513
+        let actual = u64::from_le_bytes(thread_vars[offset + 16..offset + 24].try_into().unwrap());
7514
+        let expected = init_addr - thread_data_addr;
7515
+        assert_eq!(
7516
+            actual, expected,
7517
+            "TLV descriptor {} should point at {} via template offset",
7518
+            name, init_name
7519
+        );
43827520
     }
43837521
 
4384
-    let opts = LinkOptions {
4385
-        inputs: vec![obj.clone(), libcxx.clone(), libsystem.clone()],
4386
-        output: Some(our_out.clone()),
4387
-        kind: OutputKind::Executable,
4388
-        ..LinkOptions::default()
4389
-    };
4390
-    Linker::run(&opts).unwrap();
4391
-    apple_link_cxx_classic(&obj, &apple_out).unwrap();
4392
-
4393
-    let our_bytes = fs::read(&our_out).unwrap();
4394
-    let apple_bytes = fs::read(&apple_out).unwrap();
7522
+    let output = Command::new(&out).output().unwrap();
7523
+    let apple_output = Command::new(&apple_out).output().unwrap();
43957524
     assert_eq!(
4396
-        decode_bind_records(&our_bytes, false).unwrap(),
4397
-        decode_bind_records(&apple_bytes, false).unwrap()
7525
+        output.status.code(),
7526
+        Some(0),
7527
+        "expected runtime hello executable to exit 0, stderr={}",
7528
+        String::from_utf8_lossy(&output.stderr)
43987529
     );
43997530
     assert_eq!(
4400
-        decode_bind_records(&our_bytes, true).unwrap(),
4401
-        decode_bind_records(&apple_bytes, true).unwrap()
7531
+        apple_output.status.code(),
7532
+        Some(0),
7533
+        "expected Apple-linked runtime hello executable to exit 0, stderr={}",
7534
+        String::from_utf8_lossy(&apple_output.stderr)
44027535
     );
44037536
     assert_eq!(
4404
-        canonical_lazy_bind_stream(&our_bytes).unwrap(),
4405
-        canonical_lazy_bind_stream(&apple_bytes).unwrap()
7537
+        String::from_utf8_lossy(&output.stdout),
7538
+        String::from_utf8_lossy(&apple_output.stdout)
44067539
     );
4407
-    let our_decoded = canonical_unwind_info(&our_bytes);
4408
-    let apple_decoded = canonical_unwind_info(&apple_bytes);
4409
-    assert_eq!(our_decoded, apple_decoded);
4410
-    assert_eq!(our_decoded.personalities.len(), 1);
4411
-    assert_eq!(our_decoded.lsdas.len(), 1);
4412
-    assert!(output_section(&our_bytes, "__TEXT", "__gcc_except_tab").is_some());
4413
-    let our_status = Command::new(&our_out).status().unwrap();
4414
-    let apple_status = Command::new(&apple_out).status().unwrap();
4415
-    assert_eq!(our_status.code(), Some(42));
4416
-    assert_eq!(apple_status.code(), Some(42));
44177540
 
44187541
     let _ = fs::remove_file(obj);
4419
-    let _ = fs::remove_file(our_out);
7542
+    let _ = fs::remove_file(out);
44207543
     let _ = fs::remove_file(apple_out);
44217544
 }
44227545
 
44237546
 #[test]
4424
-fn linker_run_resolves_backtrace_symbols_at_runtime() {
4425
-    if !have_xcrun() {
4426
-        eprintln!("skipping: xcrun unavailable");
7547
+fn linker_run_rebases_runtime_init_metadata_like_apple_ld() {
7548
+    if !have_xcrun() || !have_tool("codesign") {
7549
+        eprintln!("skipping: xcrun or codesign unavailable");
44277550
         return;
44287551
     }
7552
+    let Some(runtime) = find_runtime_archive() else {
7553
+        eprintln!("skipping: libarmfortas_rt.a not built");
7554
+        return;
7555
+    };
44297556
     let Some(sdk) = sdk_path() else {
4430
-        eprintln!("skipping: xcrun --show-sdk-path unavailable");
7557
+        eprintln!("skipping: no macOS SDK path");
44317558
         return;
44327559
     };
44337560
     let Some(sdk_ver) = sdk_version() else {
4434
-        eprintln!("skipping: xcrun --show-sdk-version unavailable");
7561
+        eprintln!("skipping: no macOS SDK version");
44357562
         return;
44367563
     };
44377564
     let tbd = PathBuf::from(format!("{sdk}/usr/lib/libSystem.tbd"));
@@ -4440,1161 +7567,1090 @@ fn linker_run_resolves_backtrace_symbols_at_runtime() {
44407567
         return;
44417568
     }
44427569
 
4443
-    let obj = scratch("execinfo-backtrace.o");
4444
-    let our_out = scratch("execinfo-backtrace-ours.out");
4445
-    let apple_out = scratch("execinfo-backtrace-apple.out");
7570
+    let obj = scratch("runtime-init-only.o");
7571
+    let our_out = scratch("runtime-init-only-ours.out");
7572
+    let apple_out = scratch("runtime-init-only-apple.out");
44467573
     let src = r#"
4447
-        #include <execinfo.h>
4448
-        #include <stdio.h>
4449
-        #include <stdlib.h>
4450
-        #include <string.h>
4451
-
4452
-        __attribute__((noinline)) int helper(void) {
4453
-            void *frames[8];
4454
-            int n = backtrace(frames, 8);
4455
-            char **syms = backtrace_symbols(frames, n);
4456
-            int saw_helper = 0;
4457
-            int saw_main = 0;
4458
-            if (!syms) return 2;
4459
-            for (int i = 0; i < n; i++) {
4460
-                puts(syms[i]);
4461
-                saw_helper |= strstr(syms[i], "helper") != NULL;
4462
-                saw_main |= strstr(syms[i], "main") != NULL;
4463
-            }
4464
-            free(syms);
4465
-            return (saw_helper && saw_main) ? 0 : 1;
4466
-        }
7574
+        extern void afs_program_init(void);
44677575
 
44687576
         int main(void) {
4469
-            return helper();
7577
+            afs_program_init();
7578
+            return 0;
44707579
         }
44717580
     "#;
44727581
     if let Err(e) = compile_c(src, &obj) {
4473
-        eprintln!("skipping: clang compile failed: {e}");
7582
+        eprintln!("skipping: compile failed: {e}");
44747583
         return;
44757584
     }
44767585
 
44777586
     let opts = LinkOptions {
4478
-        inputs: vec![obj.clone(), tbd],
7587
+        inputs: vec![obj.clone(), runtime.clone(), tbd],
44797588
         output: Some(our_out.clone()),
44807589
         kind: OutputKind::Executable,
44817590
         ..LinkOptions::default()
44827591
     };
44837592
     Linker::run(&opts).unwrap();
4484
-    apple_link_classic_lazy(&obj, &apple_out, "_main", &sdk, &sdk_ver).unwrap();
4485
-
4486
-    let our_output = Command::new(&our_out).output().unwrap();
4487
-    let apple_output = Command::new(&apple_out).output().unwrap();
4488
-    let our_stdout = String::from_utf8_lossy(&our_output.stdout);
4489
-    let apple_stdout = String::from_utf8_lossy(&apple_output.stdout);
44907593
 
4491
-    assert_eq!(our_output.status.code(), Some(0));
4492
-    assert_eq!(apple_output.status.code(), Some(0));
4493
-    assert!(
4494
-        our_stdout.contains("helper"),
4495
-        "expected helper in output: {our_stdout}"
4496
-    );
7594
+    let apple = Command::new("xcrun")
7595
+        .args([
7596
+            "ld",
7597
+            "-arch",
7598
+            "arm64",
7599
+            "-platform_version",
7600
+            "macos",
7601
+            &sdk_ver,
7602
+            &sdk_ver,
7603
+            "-syslibroot",
7604
+            &sdk,
7605
+            "-lSystem",
7606
+            "-e",
7607
+            "_main",
7608
+            "-no_fixup_chains",
7609
+            "-o",
7610
+        ])
7611
+        .arg(&apple_out)
7612
+        .arg(&obj)
7613
+        .arg(&runtime)
7614
+        .output()
7615
+        .unwrap();
44977616
     assert!(
4498
-        our_stdout.contains("main"),
4499
-        "expected main in output: {our_stdout}"
7617
+        apple.status.success(),
7618
+        "xcrun ld failed: {}",
7619
+        String::from_utf8_lossy(&apple.stderr)
45007620
     );
4501
-    assert!(
4502
-        apple_stdout.contains("helper"),
4503
-        "expected helper in apple output: {apple_stdout}"
7621
+
7622
+    let our_bytes = fs::read(&our_out).unwrap();
7623
+    let apple_bytes = fs::read(&apple_out).unwrap();
7624
+    let our_rebases = decode_rebase_records(&our_bytes).unwrap();
7625
+    let apple_rebases = decode_rebase_records(&apple_bytes).unwrap();
7626
+    assert_eq!(
7627
+        our_rebases
7628
+            .iter()
7629
+            .filter(|record| record.section == "__const")
7630
+            .count(),
7631
+        apple_rebases
7632
+            .iter()
7633
+            .filter(|record| record.section == "__const")
7634
+            .count(),
7635
+        "runtime init const rebases diverged from Apple ld"
45047636
     );
4505
-    assert!(
4506
-        apple_stdout.contains("main"),
4507
-        "expected main in apple output: {apple_stdout}"
7637
+    assert_eq!(
7638
+        our_rebases
7639
+            .iter()
7640
+            .filter(|record| record.section == "__la_symbol_ptr")
7641
+            .count(),
7642
+        apple_rebases
7643
+            .iter()
7644
+            .filter(|record| record.section == "__la_symbol_ptr")
7645
+            .count(),
7646
+        "runtime init lazy-pointer rebases diverged from Apple ld"
45087647
     );
45097648
 
4510
-    let _ = fs::remove_file(obj);
4511
-    let _ = fs::remove_file(our_out);
4512
-    let _ = fs::remove_file(apple_out);
4513
-}
7649
+    let our_status = Command::new(&our_out).status().unwrap();
7650
+    let apple_status = Command::new(&apple_out).status().unwrap();
7651
+    assert_eq!(our_status.code(), Some(0));
7652
+    assert_eq!(apple_status.code(), Some(0));
45147653
 
4515
-#[test]
4516
-fn linker_run_emits_function_starts_like_ld() {
4517
-    if !have_xcrun() {
4518
-        eprintln!("skipping: xcrun unavailable");
4519
-        return;
4520
-    }
4521
-    let Some(sdk) = sdk_path() else {
4522
-        eprintln!("skipping: xcrun --show-sdk-path unavailable");
4523
-        return;
4524
-    };
4525
-    let Some(sdk_ver) = sdk_version() else {
4526
-        eprintln!("skipping: xcrun --show-sdk-version unavailable");
7654
+    let _ = fs::remove_file(obj);
7655
+    let _ = fs::remove_file(our_out);
7656
+    let _ = fs::remove_file(apple_out);
7657
+}
7658
+
7659
+#[test]
7660
+fn linker_run_icf_safe_folds_identical_private_text() {
7661
+    if !have_xcrun() {
7662
+        eprintln!("skipping: xcrun unavailable");
45277663
         return;
45287664
     };
4529
-    let tbd = PathBuf::from(format!("{sdk}/usr/lib/libSystem.tbd"));
4530
-    if !tbd.exists() {
4531
-        eprintln!("skipping: no libSystem.tbd at {}", tbd.display());
4532
-        return;
4533
-    }
45347665
 
4535
-    let obj = scratch("function-starts.o");
4536
-    let our_out = scratch("function-starts-ours.out");
4537
-    let apple_out = scratch("function-starts-apple.out");
4538
-    let asm = r#"
7666
+    let obj = scratch("icf-fold.o");
7667
+    let baseline_out = scratch("icf-fold-baseline.out");
7668
+    let our_out = scratch("icf-fold-ours.out");
7669
+    let src = r#"
45397670
         .section __TEXT,__text,regular,pure_instructions
45407671
         .globl _main
4541
-        .p2align 2
4542
-    _main:
4543
-        adrp x0, _write@GOTPAGE
4544
-        ldr x0, [x0, _write@GOTPAGEOFF]
4545
-        bl _write
4546
-        ret
7672
+        _main:
7673
+            stp x29, x30, [sp, #-16]!
7674
+            mov x29, sp
7675
+            bl _helper1
7676
+            bl _helper2
7677
+            ldp x29, x30, [sp], #16
7678
+            ret
7679
+
7680
+        .private_extern _helper1
7681
+        _helper1:
7682
+            mov w0, #7
7683
+            ret
7684
+
7685
+        .private_extern _helper2
7686
+        _helper2:
7687
+            mov w0, #7
7688
+            ret
45477689
         .subsections_via_symbols
45487690
     "#;
4549
-    if let Err(e) = assemble(asm, &obj) {
7691
+    if let Err(e) = assemble(src, &obj) {
45507692
         eprintln!("skipping: assemble failed: {e}");
45517693
         return;
45527694
     }
45537695
 
7696
+    let baseline_opts = LinkOptions {
7697
+        inputs: vec![obj.clone()],
7698
+        output: Some(baseline_out.clone()),
7699
+        kind: OutputKind::Executable,
7700
+        ..LinkOptions::default()
7701
+    };
7702
+    Linker::run(&baseline_opts).unwrap();
7703
+
45547704
     let opts = LinkOptions {
4555
-        inputs: vec![obj.clone(), tbd],
7705
+        inputs: vec![obj.clone()],
45567706
         output: Some(our_out.clone()),
45577707
         kind: OutputKind::Executable,
7708
+        icf_mode: afs_ld::IcfMode::Safe,
45587709
         ..LinkOptions::default()
45597710
     };
45607711
     Linker::run(&opts).unwrap();
4561
-    apple_link_classic_lazy(&obj, &apple_out, "_main", &sdk, &sdk_ver).unwrap();
45627712
 
7713
+    let baseline_bytes = fs::read(&baseline_out).unwrap();
45637714
     let our_bytes = fs::read(&our_out).unwrap();
4564
-    let apple_bytes = fs::read(&apple_out).unwrap();
4565
-    let our_fstarts = raw_linkedit_data_cmd(&our_bytes, LC_FUNCTION_STARTS);
4566
-    let apple_fstarts = raw_linkedit_data_cmd(&apple_bytes, LC_FUNCTION_STARTS);
4567
-    assert_ne!(our_fstarts.0, 0);
4568
-    assert_eq!(our_fstarts.1, apple_fstarts.1);
4569
-    assert_eq!(our_fstarts.1, 8);
4570
-    assert!(output_section(&our_bytes, "__TEXT", "__stubs").is_some());
4571
-    assert!(output_section(&our_bytes, "__TEXT", "__stub_helper").is_some());
4572
-    assert_eq!(decode_function_starts(&our_bytes).len(), 1);
4573
-    assert_eq!(decode_function_starts(&apple_bytes).len(), 1);
4574
-    let our_text_addr = output_section(&our_bytes, "__TEXT", "__text").unwrap().0;
4575
-    let apple_text_addr = output_section(&apple_bytes, "__TEXT", "__text").unwrap().0;
4576
-    let our_text_base = segment_vmaddr(&our_bytes, "__TEXT").unwrap();
4577
-    let apple_text_base = segment_vmaddr(&apple_bytes, "__TEXT").unwrap();
7715
+    let baseline_symbols = symbol_values(&baseline_bytes);
7716
+    let our_symbols = symbol_values(&our_bytes);
7717
+    let baseline_text = output_section(&baseline_bytes, "__TEXT", "__text")
7718
+        .unwrap()
7719
+        .1;
7720
+    let our_text = output_section(&our_bytes, "__TEXT", "__text").unwrap().1;
7721
+
45787722
     assert_eq!(
4579
-        decode_function_starts(&our_bytes),
4580
-        vec![our_text_addr - our_text_base]
7723
+        our_symbols.get("_helper1"),
7724
+        our_symbols.get("_helper2"),
7725
+        "expected afs-ld -icf=safe to coalesce identical private text atoms"
7726
+    );
7727
+    assert_ne!(
7728
+        baseline_symbols.get("_helper1"),
7729
+        baseline_symbols.get("_helper2"),
7730
+        "expected baseline link to keep identical helpers separate"
45817731
     );
45827732
     assert_eq!(
4583
-        decode_function_starts(&apple_bytes),
4584
-        vec![apple_text_addr - apple_text_base]
7733
+        Command::new(&our_out).status().unwrap().code(),
7734
+        Some(7),
7735
+        "folded executable should preserve runtime behavior"
7736
+    );
7737
+    assert!(
7738
+        our_text.len() < baseline_text.len(),
7739
+        "expected -icf=safe to reduce text size on identical helpers"
45857740
     );
4586
-
4587
-    let our_dic = raw_linkedit_data_cmd(&our_bytes, LC_DATA_IN_CODE);
4588
-    let apple_dic = raw_linkedit_data_cmd(&apple_bytes, LC_DATA_IN_CODE);
4589
-    assert_ne!(our_dic.0, 0);
4590
-    assert_eq!(our_dic.1, apple_dic.1);
4591
-    assert_eq!(our_dic.0, our_fstarts.0 + our_fstarts.1);
4592
-    assert_eq!(apple_dic.0, apple_fstarts.0 + apple_fstarts.1);
45937741
 
45947742
     let _ = fs::remove_file(obj);
7743
+    let _ = fs::remove_file(baseline_out);
45957744
     let _ = fs::remove_file(our_out);
4596
-    let _ = fs::remove_file(apple_out);
45977745
 }
45987746
 
45997747
 #[test]
4600
-fn linker_run_emits_function_starts_for_other_text_sections_like_ld() {
7748
+fn linker_run_icf_safe_keeps_address_taken_functions_distinct() {
46017749
     if !have_xcrun() {
46027750
         eprintln!("skipping: xcrun unavailable");
46037751
         return;
4604
-    }
4605
-    let Some(sdk) = sdk_path() else {
4606
-        eprintln!("skipping: xcrun --show-sdk-path unavailable");
4607
-        return;
4608
-    };
4609
-    let Some(sdk_ver) = sdk_version() else {
4610
-        eprintln!("skipping: xcrun --show-sdk-version unavailable");
4611
-        return;
46127752
     };
46137753
 
4614
-    let obj = scratch("function-starts-textcoal.o");
4615
-    let our_out = scratch("function-starts-textcoal-ours.out");
4616
-    let apple_out = scratch("function-starts-textcoal-apple.out");
4617
-    let asm = r#"
7754
+    let obj = scratch("icf-address-taken.o");
7755
+    let baseline_out = scratch("icf-address-taken-baseline.out");
7756
+    let our_out = scratch("icf-address-taken-ours.out");
7757
+    let src = r#"
46187758
         .section __TEXT,__text,regular,pure_instructions
46197759
         .globl _main
4620
-        .p2align 2
4621
-    _main:
4622
-        ret
7760
+        _main:
7761
+            stp x29, x30, [sp, #-16]!
7762
+            mov x29, sp
7763
+            bl _helper1
7764
+            bl _helper2
7765
+            ldp x29, x30, [sp], #16
7766
+            ret
46237767
 
4624
-        .section __TEXT,__textcoal_nt,regular,pure_instructions
4625
-        .globl _helper
4626
-        .p2align 2
4627
-    _helper:
4628
-        ret
7768
+        .private_extern _helper1
7769
+        _helper1:
7770
+            mov w0, #7
7771
+            ret
7772
+
7773
+        .private_extern _helper2
7774
+        _helper2:
7775
+            mov w0, #7
7776
+            ret
7777
+
7778
+        .section __DATA,__const
7779
+        .p2align 3
7780
+        _ptrs:
7781
+            .quad _helper1
7782
+            .quad _helper2
46297783
         .subsections_via_symbols
46307784
     "#;
4631
-    if let Err(e) = assemble(asm, &obj) {
7785
+    if let Err(e) = assemble(src, &obj) {
46327786
         eprintln!("skipping: assemble failed: {e}");
46337787
         return;
46347788
     }
46357789
 
7790
+    let baseline_opts = LinkOptions {
7791
+        inputs: vec![obj.clone()],
7792
+        output: Some(baseline_out.clone()),
7793
+        kind: OutputKind::Executable,
7794
+        ..LinkOptions::default()
7795
+    };
7796
+    Linker::run(&baseline_opts).unwrap();
7797
+
46367798
     let opts = LinkOptions {
46377799
         inputs: vec![obj.clone()],
46387800
         output: Some(our_out.clone()),
46397801
         kind: OutputKind::Executable,
7802
+        icf_mode: afs_ld::IcfMode::Safe,
46407803
         ..LinkOptions::default()
46417804
     };
46427805
     Linker::run(&opts).unwrap();
4643
-    apple_link(&obj, &apple_out, "_main", &sdk, &sdk_ver).unwrap();
46447806
 
7807
+    let baseline_bytes = fs::read(&baseline_out).unwrap();
46457808
     let our_bytes = fs::read(&our_out).unwrap();
4646
-    let apple_bytes = fs::read(&apple_out).unwrap();
4647
-    assert_eq!(decode_function_starts(&our_bytes).len(), 2);
4648
-    assert_eq!(decode_function_starts(&apple_bytes).len(), 2);
4649
-
4650
-    let our_text_addr = output_section(&our_bytes, "__TEXT", "__text").unwrap().0;
4651
-    let our_textcoal_addr = output_section(&our_bytes, "__TEXT", "__textcoal_nt")
7809
+    let baseline_symbols = symbol_values(&baseline_bytes);
7810
+    let our_symbols = symbol_values(&our_bytes);
7811
+    let baseline_text = output_section(&baseline_bytes, "__TEXT", "__text")
46527812
         .unwrap()
4653
-        .0;
4654
-    let our_text_base = segment_vmaddr(&our_bytes, "__TEXT").unwrap();
7813
+        .1;
7814
+    let our_text = output_section(&our_bytes, "__TEXT", "__text").unwrap().1;
7815
+
7816
+    assert_ne!(
7817
+        our_symbols.get("_helper1"),
7818
+        our_symbols.get("_helper2"),
7819
+        "address-taken helpers should not be folded by afs-ld -icf=safe"
7820
+    );
7821
+    assert_ne!(
7822
+        baseline_symbols.get("_helper1"),
7823
+        baseline_symbols.get("_helper2"),
7824
+        "baseline link should keep address-taken helpers separate"
7825
+    );
46557826
     assert_eq!(
4656
-        decode_function_starts(&our_bytes),
4657
-        vec![
4658
-            our_text_addr - our_text_base,
4659
-            our_textcoal_addr - our_text_base
4660
-        ]
7827
+        Command::new(&our_out).status().unwrap().code(),
7828
+        Some(7),
7829
+        "address-taken executable should preserve runtime behavior"
7830
+    );
7831
+    assert_eq!(
7832
+        our_text.len(),
7833
+        baseline_text.len(),
7834
+        "address-taken helpers should not shrink under -icf=safe"
46617835
     );
46627836
 
46637837
     let _ = fs::remove_file(obj);
7838
+    let _ = fs::remove_file(baseline_out);
46647839
     let _ = fs::remove_file(our_out);
4665
-    let _ = fs::remove_file(apple_out);
46667840
 }
46677841
 
46687842
 #[test]
4669
-fn linker_run_remaps_data_in_code_like_ld() {
7843
+fn linker_run_icf_safe_keeps_adrp_add_address_taken_functions_distinct() {
46707844
     if !have_xcrun() {
46717845
         eprintln!("skipping: xcrun unavailable");
46727846
         return;
4673
-    }
4674
-    let Some(sdk) = sdk_path() else {
4675
-        eprintln!("skipping: xcrun --show-sdk-path unavailable");
4676
-        return;
4677
-    };
4678
-    let Some(sdk_ver) = sdk_version() else {
4679
-        eprintln!("skipping: xcrun --show-sdk-version unavailable");
4680
-        return;
46817847
     };
4682
-    let tbd = PathBuf::from(format!("{sdk}/usr/lib/libSystem.tbd"));
4683
-    if !tbd.exists() {
4684
-        eprintln!("skipping: no libSystem.tbd at {}", tbd.display());
4685
-        return;
4686
-    }
46877848
 
4688
-    let obj = scratch("data-in-code.o");
4689
-    let our_out = scratch("data-in-code-ours.out");
4690
-    let apple_out = scratch("data-in-code-apple.out");
4691
-    let asm = r#"
4692
-        .text
7849
+    let obj = scratch("icf-adrp-address-taken.o");
7850
+    let baseline_out = scratch("icf-adrp-address-taken-baseline.out");
7851
+    let our_out = scratch("icf-adrp-address-taken-ours.out");
7852
+    let src = r#"
7853
+        .section __TEXT,__text,regular,pure_instructions
46937854
         .globl _main
4694
-        .p2align 2
4695
-    _main:
4696
-        mov w0, #0
4697
-        b Ldispatch
4698
-        .p2align 2
4699
-    Ltable:
4700
-        .data_region jt32
4701
-        .long Lcase0-Ltable
4702
-        .long Lcase1-Ltable
4703
-        .end_data_region
4704
-    Ldispatch:
4705
-        cmp w0, #0
4706
-        b.eq Lcase0
4707
-        b Lcase1
4708
-    Lcase0:
4709
-        mov w0, #1
4710
-        ret
4711
-    Lcase1:
4712
-        mov w0, #2
4713
-        ret
7855
+        _main:
7856
+            stp x29, x30, [sp, #-16]!
7857
+            mov x29, sp
7858
+            adrp x10, _helper1@PAGE
7859
+            add x10, x10, _helper1@PAGEOFF
7860
+            adrp x11, _helper2@PAGE
7861
+            add x11, x11, _helper2@PAGEOFF
7862
+            cmp x10, x11
7863
+            b.ne 1f
7864
+            mov w0, #1
7865
+            ldp x29, x30, [sp], #16
7866
+            ret
7867
+        1:
7868
+            mov w0, #0
7869
+            ldp x29, x30, [sp], #16
7870
+            ret
7871
+
7872
+        .private_extern _helper1
7873
+        _helper1:
7874
+            mov w0, #7
7875
+            ret
7876
+
7877
+        .private_extern _helper2
7878
+        _helper2:
7879
+            mov w0, #7
7880
+            ret
47147881
         .subsections_via_symbols
47157882
     "#;
4716
-    if let Err(e) = assemble(asm, &obj) {
7883
+    if let Err(e) = assemble(src, &obj) {
47177884
         eprintln!("skipping: assemble failed: {e}");
47187885
         return;
47197886
     }
47207887
 
7888
+    let baseline_opts = LinkOptions {
7889
+        inputs: vec![obj.clone()],
7890
+        output: Some(baseline_out.clone()),
7891
+        kind: OutputKind::Executable,
7892
+        ..LinkOptions::default()
7893
+    };
7894
+    Linker::run(&baseline_opts).unwrap();
7895
+
47217896
     let opts = LinkOptions {
4722
-        inputs: vec![obj.clone(), tbd],
7897
+        inputs: vec![obj.clone()],
47237898
         output: Some(our_out.clone()),
47247899
         kind: OutputKind::Executable,
7900
+        icf_mode: afs_ld::IcfMode::Safe,
47257901
         ..LinkOptions::default()
47267902
     };
47277903
     Linker::run(&opts).unwrap();
4728
-    apple_link(&obj, &apple_out, "_main", &sdk, &sdk_ver).unwrap();
47297904
 
7905
+    let baseline_bytes = fs::read(&baseline_out).unwrap();
47307906
     let our_bytes = fs::read(&our_out).unwrap();
4731
-    let apple_bytes = fs::read(&apple_out).unwrap();
4732
-    let our_dic = raw_linkedit_data_cmd(&our_bytes, LC_DATA_IN_CODE);
4733
-    let apple_dic = raw_linkedit_data_cmd(&apple_bytes, LC_DATA_IN_CODE);
4734
-    assert_ne!(our_dic.1, 0);
4735
-    assert_eq!(our_dic.1, apple_dic.1);
4736
-    assert_eq!(decode_data_in_code(&our_bytes).len(), 1);
7907
+    let baseline_symbols = symbol_values(&baseline_bytes);
7908
+    let our_symbols = symbol_values(&our_bytes);
7909
+    let baseline_text = output_section(&baseline_bytes, "__TEXT", "__text")
7910
+        .unwrap()
7911
+        .1;
7912
+    let our_text = output_section(&our_bytes, "__TEXT", "__text").unwrap().1;
7913
+
7914
+    assert_ne!(
7915
+        our_symbols.get("_helper1"),
7916
+        our_symbols.get("_helper2"),
7917
+        "adrp/add address-taken helpers should not be folded by afs-ld -icf=safe"
7918
+    );
7919
+    assert_ne!(
7920
+        baseline_symbols.get("_helper1"),
7921
+        baseline_symbols.get("_helper2"),
7922
+        "baseline link should keep adrp/add address-taken helpers separate"
7923
+    );
47377924
     assert_eq!(
4738
-        canonical_data_in_code(&our_bytes),
4739
-        canonical_data_in_code(&apple_bytes)
7925
+        Command::new(&our_out).status().unwrap().code(),
7926
+        Some(0),
7927
+        "adrp/add address-taken executable should preserve pointer inequality"
47407928
     );
47417929
     assert_eq!(
4742
-        canonical_data_in_code(&our_bytes),
4743
-        vec![DataInCodeRecord {
4744
-            offset: 8,
4745
-            length: 8,
4746
-            kind: DICE_KIND_JUMP_TABLE32,
4747
-        }]
7930
+        our_text.len(),
7931
+        baseline_text.len(),
7932
+        "adrp/add address-taken helpers should not shrink under -icf=safe"
47487933
     );
47497934
 
47507935
     let _ = fs::remove_file(obj);
7936
+    let _ = fs::remove_file(baseline_out);
47517937
     let _ = fs::remove_file(our_out);
4752
-    let _ = fs::remove_file(apple_out);
47537938
 }
47547939
 
47557940
 #[test]
4756
-fn linker_run_remaps_data_in_code_in_later_text_section_like_ld() {
7941
+fn linker_run_icf_safe_folds_matching_branch_relocs() {
47577942
     if !have_xcrun() {
47587943
         eprintln!("skipping: xcrun unavailable");
47597944
         return;
4760
-    }
4761
-    let Some(sdk) = sdk_path() else {
4762
-        eprintln!("skipping: xcrun --show-sdk-path unavailable");
4763
-        return;
4764
-    };
4765
-    let Some(sdk_ver) = sdk_version() else {
4766
-        eprintln!("skipping: xcrun --show-sdk-version unavailable");
4767
-        return;
47687945
     };
4769
-    let tbd = PathBuf::from(format!("{sdk}/usr/lib/libSystem.tbd"));
4770
-    if !tbd.exists() {
4771
-        eprintln!("skipping: no libSystem.tbd at {}", tbd.display());
4772
-        return;
4773
-    }
4774
-
4775
-    let obj = scratch("data-in-code-late.o");
4776
-    let our_out = scratch("data-in-code-late-ours.out");
4777
-    let apple_out = scratch("data-in-code-late-apple.out");
4778
-    let asm = r#"
4779
-        .text
4780
-        .globl _main
4781
-        .p2align 2
4782
-    _main:
4783
-        ret
47847946
 
4785
-        .section __TEXT,__text2,regular,pure_instructions
4786
-        .globl _helper
4787
-        .p2align 2
4788
-    _helper:
4789
-        b Ldispatch
4790
-        .p2align 2
4791
-    Ltable:
4792
-        .data_region jt32
4793
-        .long Lcase0-Ltable
4794
-        .long Lcase1-Ltable
4795
-        .end_data_region
4796
-    Ldispatch:
4797
-        ret
4798
-    Lcase0:
4799
-        ret
4800
-    Lcase1:
4801
-        ret
7947
+    let obj = scratch("icf-branch-match.o");
7948
+    let baseline_out = scratch("icf-branch-match-baseline.out");
7949
+    let our_out = scratch("icf-branch-match-ours.out");
7950
+    let src = r#"
7951
+        .section __TEXT,__text,regular,pure_instructions
7952
+        .globl _main
7953
+        _main:
7954
+            stp x29, x30, [sp, #-32]!
7955
+            mov x29, sp
7956
+            bl _wrapper1
7957
+            str w0, [sp, #16]
7958
+            bl _wrapper2
7959
+            ldr w8, [sp, #16]
7960
+            add w0, w8, w0
7961
+            ldp x29, x30, [sp], #32
7962
+            ret
7963
+
7964
+        .private_extern _wrapper1
7965
+        _wrapper1:
7966
+            b _leaf
7967
+
7968
+        .private_extern _wrapper2
7969
+        _wrapper2:
7970
+            b _leaf
7971
+
7972
+        .private_extern _leaf
7973
+        _leaf:
7974
+            mov w0, #5
7975
+            ret
48027976
         .subsections_via_symbols
48037977
     "#;
4804
-    if let Err(e) = assemble(asm, &obj) {
7978
+    if let Err(e) = assemble(src, &obj) {
48057979
         eprintln!("skipping: assemble failed: {e}");
48067980
         return;
48077981
     }
48087982
 
7983
+    let baseline_opts = LinkOptions {
7984
+        inputs: vec![obj.clone()],
7985
+        output: Some(baseline_out.clone()),
7986
+        kind: OutputKind::Executable,
7987
+        ..LinkOptions::default()
7988
+    };
7989
+    Linker::run(&baseline_opts).unwrap();
7990
+
48097991
     let opts = LinkOptions {
4810
-        inputs: vec![obj.clone(), tbd],
7992
+        inputs: vec![obj.clone()],
48117993
         output: Some(our_out.clone()),
48127994
         kind: OutputKind::Executable,
7995
+        icf_mode: afs_ld::IcfMode::Safe,
48137996
         ..LinkOptions::default()
48147997
     };
48157998
     Linker::run(&opts).unwrap();
4816
-    apple_link(&obj, &apple_out, "_main", &sdk, &sdk_ver).unwrap();
48177999
 
8000
+    let baseline_bytes = fs::read(&baseline_out).unwrap();
48188001
     let our_bytes = fs::read(&our_out).unwrap();
4819
-    let apple_bytes = fs::read(&apple_out).unwrap();
8002
+    let baseline_symbols = symbol_values(&baseline_bytes);
8003
+    let our_symbols = symbol_values(&our_bytes);
8004
+    let baseline_text = output_section(&baseline_bytes, "__TEXT", "__text")
8005
+        .unwrap()
8006
+        .1;
8007
+    let our_text = output_section(&our_bytes, "__TEXT", "__text").unwrap().1;
8008
+
8009
+    assert_ne!(
8010
+        baseline_symbols.get("_wrapper1"),
8011
+        baseline_symbols.get("_wrapper2"),
8012
+        "baseline link should keep identical wrappers separate"
8013
+    );
48208014
     assert_eq!(
4821
-        canonical_data_in_code(&our_bytes),
4822
-        canonical_data_in_code(&apple_bytes)
8015
+        our_symbols.get("_wrapper1"),
8016
+        our_symbols.get("_wrapper2"),
8017
+        "matching branch relocations should fold under -icf=safe"
48238018
     );
48248019
     assert_eq!(
4825
-        canonical_data_in_code(&our_bytes),
4826
-        vec![DataInCodeRecord {
4827
-            offset: 8,
4828
-            length: 8,
4829
-            kind: DICE_KIND_JUMP_TABLE32,
4830
-        }]
8020
+        Command::new(&our_out).status().unwrap().code(),
8021
+        Some(10),
8022
+        "folded branch-reloc executable should preserve runtime behavior"
8023
+    );
8024
+    assert!(
8025
+        our_text.len() < baseline_text.len(),
8026
+        "expected matching branch-reloc wrappers to shrink under -icf=safe"
48318027
     );
48328028
 
48338029
     let _ = fs::remove_file(obj);
8030
+    let _ = fs::remove_file(baseline_out);
48348031
     let _ = fs::remove_file(our_out);
4835
-    let _ = fs::remove_file(apple_out);
48368032
 }
48378033
 
48388034
 #[test]
4839
-fn linker_run_remaps_data_in_code_after_large_first_text_section_like_ld() {
8035
+fn linker_run_icf_safe_keeps_distinct_branch_targets_unfolded() {
48408036
     if !have_xcrun() {
48418037
         eprintln!("skipping: xcrun unavailable");
48428038
         return;
4843
-    }
4844
-    let Some(sdk) = sdk_path() else {
4845
-        eprintln!("skipping: xcrun --show-sdk-path unavailable");
4846
-        return;
4847
-    };
4848
-    let Some(sdk_ver) = sdk_version() else {
4849
-        eprintln!("skipping: xcrun --show-sdk-version unavailable");
4850
-        return;
48518039
     };
4852
-    let tbd = PathBuf::from(format!("{sdk}/usr/lib/libSystem.tbd"));
4853
-    if !tbd.exists() {
4854
-        eprintln!("skipping: no libSystem.tbd at {}", tbd.display());
4855
-        return;
4856
-    }
48578040
 
4858
-    let obj = scratch("data-in-code-large-first.o");
4859
-    let our_out = scratch("data-in-code-large-first-ours.out");
4860
-    let apple_out = scratch("data-in-code-large-first-apple.out");
4861
-    let asm = r#"
4862
-        .text
8041
+    let obj = scratch("icf-branch-distinct.o");
8042
+    let baseline_out = scratch("icf-branch-distinct-baseline.out");
8043
+    let our_out = scratch("icf-branch-distinct-ours.out");
8044
+    let src = r#"
8045
+        .section __TEXT,__text,regular,pure_instructions
48638046
         .globl _main
4864
-        .p2align 2
4865
-    _main:
4866
-        nop
4867
-        nop
4868
-        nop
4869
-        nop
4870
-        nop
4871
-        ret
8047
+        _main:
8048
+            stp x29, x30, [sp, #-32]!
8049
+            mov x29, sp
8050
+            bl _wrapper1
8051
+            str w0, [sp, #16]
8052
+            bl _wrapper2
8053
+            ldr w8, [sp, #16]
8054
+            add w0, w8, w0
8055
+            ldp x29, x30, [sp], #32
8056
+            ret
48728057
 
4873
-        .section __TEXT,__text2,regular,pure_instructions
4874
-        .globl _helper
4875
-    _helper:
4876
-        b Ldispatch
4877
-        .p2align 2
4878
-    Ltable:
4879
-        .data_region jt32
4880
-        .long Lcase0-Ltable
4881
-        .long Lcase1-Ltable
4882
-        .end_data_region
4883
-    Ldispatch:
4884
-        ret
4885
-    Lcase0:
4886
-        ret
4887
-    Lcase1:
4888
-        ret
8058
+        .private_extern _wrapper1
8059
+        _wrapper1:
8060
+            b _leaf1
8061
+
8062
+        .private_extern _wrapper2
8063
+        _wrapper2:
8064
+            b _leaf2
8065
+
8066
+        .private_extern _leaf1
8067
+        _leaf1:
8068
+            mov w0, #3
8069
+            ret
8070
+
8071
+        .private_extern _leaf2
8072
+        _leaf2:
8073
+            mov w0, #5
8074
+            ret
48898075
         .subsections_via_symbols
48908076
     "#;
4891
-    if let Err(e) = assemble(asm, &obj) {
8077
+    if let Err(e) = assemble(src, &obj) {
48928078
         eprintln!("skipping: assemble failed: {e}");
48938079
         return;
48948080
     }
48958081
 
8082
+    let baseline_opts = LinkOptions {
8083
+        inputs: vec![obj.clone()],
8084
+        output: Some(baseline_out.clone()),
8085
+        kind: OutputKind::Executable,
8086
+        ..LinkOptions::default()
8087
+    };
8088
+    Linker::run(&baseline_opts).unwrap();
8089
+
48968090
     let opts = LinkOptions {
4897
-        inputs: vec![obj.clone(), tbd],
8091
+        inputs: vec![obj.clone()],
48988092
         output: Some(our_out.clone()),
48998093
         kind: OutputKind::Executable,
8094
+        icf_mode: afs_ld::IcfMode::Safe,
49008095
         ..LinkOptions::default()
49018096
     };
49028097
     Linker::run(&opts).unwrap();
4903
-    apple_link(&obj, &apple_out, "_main", &sdk, &sdk_ver).unwrap();
49048098
 
8099
+    let baseline_bytes = fs::read(&baseline_out).unwrap();
49058100
     let our_bytes = fs::read(&our_out).unwrap();
4906
-    let apple_bytes = fs::read(&apple_out).unwrap();
8101
+    let baseline_symbols = symbol_values(&baseline_bytes);
8102
+    let our_symbols = symbol_values(&our_bytes);
8103
+    let baseline_text = output_section(&baseline_bytes, "__TEXT", "__text")
8104
+        .unwrap()
8105
+        .1;
8106
+    let our_text = output_section(&our_bytes, "__TEXT", "__text").unwrap().1;
8107
+
8108
+    assert_ne!(
8109
+        baseline_symbols.get("_wrapper1"),
8110
+        baseline_symbols.get("_wrapper2"),
8111
+        "baseline link should keep distinct wrappers separate"
8112
+    );
8113
+    assert_ne!(
8114
+        our_symbols.get("_wrapper1"),
8115
+        our_symbols.get("_wrapper2"),
8116
+        "wrappers targeting different leaves must not fold under -icf=safe"
8117
+    );
49078118
     assert_eq!(
4908
-        canonical_data_in_code(&our_bytes),
4909
-        canonical_data_in_code(&apple_bytes)
8119
+        Command::new(&our_out).status().unwrap().code(),
8120
+        Some(8),
8121
+        "distinct branch-target executable should preserve runtime behavior"
49108122
     );
49118123
     assert_eq!(
4912
-        canonical_data_in_code(&our_bytes),
4913
-        vec![DataInCodeRecord {
4914
-            offset: 28,
4915
-            length: 8,
4916
-            kind: DICE_KIND_JUMP_TABLE32,
4917
-        }]
8124
+        our_text.len(),
8125
+        baseline_text.len(),
8126
+        "distinct branch-target wrappers should not shrink under -icf=safe"
49188127
     );
49198128
 
49208129
     let _ = fs::remove_file(obj);
8130
+    let _ = fs::remove_file(baseline_out);
49218131
     let _ = fs::remove_file(our_out);
4922
-    let _ = fs::remove_file(apple_out);
49238132
 }
49248133
 
49258134
 #[test]
4926
-fn linker_run_dedups_output_strtab_like_ld() {
8135
+fn linker_run_icf_safe_folds_identical_private_const_data() {
49278136
     if !have_xcrun() {
49288137
         eprintln!("skipping: xcrun unavailable");
49298138
         return;
4930
-    }
4931
-    let Some(sdk) = sdk_path() else {
4932
-        eprintln!("skipping: xcrun --show-sdk-path unavailable");
4933
-        return;
4934
-    };
4935
-    let Some(sdk_ver) = sdk_version() else {
4936
-        eprintln!("skipping: xcrun --show-sdk-version unavailable");
4937
-        return;
49388139
     };
4939
-    let tbd = PathBuf::from(format!("{sdk}/usr/lib/libSystem.tbd"));
4940
-    if !tbd.exists() {
4941
-        eprintln!("skipping: no libSystem.tbd at {}", tbd.display());
4942
-        return;
4943
-    }
49448140
 
4945
-    let obj = scratch("strtab-dedup.o");
4946
-    let our_out = scratch("strtab-dedup-ours.out");
4947
-    let apple_out = scratch("strtab-dedup-apple.out");
4948
-    let mut asm =
4949
-        String::from("        .text\n        .globl _afs_array_sum\n        .globl _main\n");
4950
-    for idx in 0..20 {
4951
-        let symbol = format!("_pad_symbol_{idx:02}");
4952
-        asm.push_str(&format!("        .globl {symbol}\n"));
4953
-    }
4954
-    asm.push_str("        .p2align 2\n");
4955
-    asm.push_str("    _array_sum:\n        ret\n");
4956
-    asm.push_str("    _afs_array_sum:\n        ret\n");
4957
-    for idx in 0..20 {
4958
-        let symbol = format!("_pad_symbol_{idx:02}");
4959
-        asm.push_str(&format!("    {symbol}:\n        ret\n"));
4960
-    }
4961
-    asm.push_str("    _main:\n        bl _afs_array_sum\n        ret\n");
4962
-    asm.push_str("        .subsections_via_symbols\n");
4963
-    if let Err(e) = assemble(&asm, &obj) {
8141
+    let obj = scratch("icf-const-fold.o");
8142
+    let baseline_out = scratch("icf-const-fold-baseline.out");
8143
+    let our_out = scratch("icf-const-fold-ours.out");
8144
+    let src = r#"
8145
+        .section __TEXT,__text,regular,pure_instructions
8146
+        .globl _main
8147
+        _main:
8148
+            mov w0, #0
8149
+            ret
8150
+
8151
+        .section __TEXT,__const
8152
+        .p2align 3
8153
+        .private_extern _const1
8154
+        _const1:
8155
+            .quad 0x1122334455667788
8156
+        .p2align 3
8157
+        .private_extern _const2
8158
+        _const2:
8159
+            .quad 0x1122334455667788
8160
+        .subsections_via_symbols
8161
+    "#;
8162
+    if let Err(e) = assemble(src, &obj) {
49648163
         eprintln!("skipping: assemble failed: {e}");
49658164
         return;
49668165
     }
49678166
 
8167
+    let baseline_opts = LinkOptions {
8168
+        inputs: vec![obj.clone()],
8169
+        output: Some(baseline_out.clone()),
8170
+        kind: OutputKind::Executable,
8171
+        ..LinkOptions::default()
8172
+    };
8173
+    Linker::run(&baseline_opts).unwrap();
8174
+
49688175
     let opts = LinkOptions {
4969
-        inputs: vec![obj.clone(), tbd],
8176
+        inputs: vec![obj.clone()],
49708177
         output: Some(our_out.clone()),
49718178
         kind: OutputKind::Executable,
8179
+        icf_mode: afs_ld::IcfMode::Safe,
49728180
         ..LinkOptions::default()
49738181
     };
49748182
     Linker::run(&opts).unwrap();
4975
-    apple_link(&obj, &apple_out, "_main", &sdk, &sdk_ver).unwrap();
49768183
 
8184
+    let baseline_bytes = fs::read(&baseline_out).unwrap();
49778185
     let our_bytes = fs::read(&our_out).unwrap();
4978
-    let apple_bytes = fs::read(&apple_out).unwrap();
8186
+    let baseline_symbols = symbol_values(&baseline_bytes);
8187
+    let our_symbols = symbol_values(&our_bytes);
8188
+    let baseline_const = output_section(&baseline_bytes, "__TEXT", "__const")
8189
+        .unwrap()
8190
+        .1;
8191
+    let our_const = output_section(&our_bytes, "__TEXT", "__const").unwrap().1;
8192
+
8193
+    assert_ne!(
8194
+        baseline_symbols.get("_const1"),
8195
+        baseline_symbols.get("_const2"),
8196
+        "baseline link should keep identical private const atoms separate"
8197
+    );
49798198
     assert_eq!(
4980
-        canonical_symbol_records(&our_bytes),
4981
-        canonical_symbol_records(&apple_bytes)
8199
+        our_symbols.get("_const1"),
8200
+        our_symbols.get("_const2"),
8201
+        "expected afs-ld -icf=safe to coalesce identical private const atoms"
49828202
     );
4983
-    let our_strtab = raw_string_table(&our_bytes);
4984
-    let apple_strtab = raw_string_table(&apple_bytes);
4985
-    assert_strtab_within_five_percent(&our_strtab, &apple_strtab);
49868203
     assert!(
4987
-        our_strtab.len() <= apple_strtab.len(),
4988
-        "suffix dedup should not grow the output string table: ours={} apple={}",
4989
-        our_strtab.len(),
4990
-        apple_strtab.len()
8204
+        our_const.len() < baseline_const.len(),
8205
+        "expected -icf=safe to reduce const section size on identical atoms"
49918206
     );
49928207
 
4993
-    let offsets = symbol_name_offsets(&our_bytes);
4994
-    assert_eq!(offsets["_array_sum"], offsets["_afs_array_sum"] + 4);
4995
-
49968208
     let _ = fs::remove_file(obj);
8209
+    let _ = fs::remove_file(baseline_out);
49978210
     let _ = fs::remove_file(our_out);
4998
-    let _ = fs::remove_file(apple_out);
49998211
 }
50008212
 
50018213
 #[test]
5002
-fn linker_run_launches_with_classic_lazy_dylib_import() {
5003
-    if !have_xcrun() || !have_tool("codesign") {
5004
-        eprintln!("skipping: xcrun clang or codesign unavailable");
5005
-        return;
5006
-    }
5007
-    let Some(sdk) = sdk_path() else {
5008
-        eprintln!("skipping: xcrun --show-sdk-path unavailable");
8214
+fn linker_run_icf_safe_folds_identical_private_cstrings() {
8215
+    if !have_xcrun() {
8216
+        eprintln!("skipping: xcrun unavailable");
50098217
         return;
50108218
     };
5011
-    let tbd = PathBuf::from(format!("{sdk}/usr/lib/libSystem.tbd"));
5012
-    if !tbd.exists() {
5013
-        eprintln!("skipping: no libSystem.tbd at {}", tbd.display());
5014
-        return;
5015
-    }
50168219
 
5017
-    let dylib = scratch("lazy-runtime.dylib");
5018
-    let obj = scratch("lazy-runtime.o");
5019
-    let out = scratch("lazy-runtime.out");
8220
+    let obj = scratch("icf-cstring-fold.o");
8221
+    let baseline_out = scratch("icf-cstring-fold-baseline.out");
8222
+    let our_out = scratch("icf-cstring-fold-ours.out");
8223
+    let src = r#"
8224
+        .section __TEXT,__text,regular,pure_instructions
8225
+        .globl _main
8226
+        _main:
8227
+            mov w0, #0
8228
+            ret
50208229
 
5021
-    let dylib_src = r#"
5022
-        int ext_fn(void) { return 7; }
8230
+        .section __TEXT,__cstring,cstring_literals
8231
+        .private_extern _str1
8232
+        _str1:
8233
+            .asciz "fold me"
8234
+        .private_extern _str2
8235
+        _str2:
8236
+            .asciz "fold me"
8237
+        .subsections_via_symbols
50238238
     "#;
5024
-    if let Err(e) = compile_dylib_c(dylib_src, &dylib) {
5025
-        eprintln!("skipping: dylib compile failed: {e}");
8239
+    if let Err(e) = assemble(src, &obj) {
8240
+        eprintln!("skipping: assemble failed: {e}");
50268241
         return;
50278242
     }
50288243
 
5029
-    let main_src = r#"
5030
-        int ext_fn(void);
5031
-        int main(void) { return ext_fn() == 7 ? 0 : 1; }
5032
-    "#;
5033
-    if let Err(e) = compile_c(main_src, &obj) {
5034
-        eprintln!("skipping: compile failed: {e}");
5035
-        return;
5036
-    }
8244
+    let baseline_opts = LinkOptions {
8245
+        inputs: vec![obj.clone()],
8246
+        output: Some(baseline_out.clone()),
8247
+        kind: OutputKind::Executable,
8248
+        ..LinkOptions::default()
8249
+    };
8250
+    Linker::run(&baseline_opts).unwrap();
50378251
 
50388252
     let opts = LinkOptions {
5039
-        inputs: vec![obj.clone(), tbd, dylib.clone()],
5040
-        output: Some(out.clone()),
8253
+        inputs: vec![obj.clone()],
8254
+        output: Some(our_out.clone()),
50418255
         kind: OutputKind::Executable,
8256
+        icf_mode: afs_ld::IcfMode::Safe,
50428257
         ..LinkOptions::default()
50438258
     };
50448259
     Linker::run(&opts).unwrap();
50458260
 
5046
-    let verify = Command::new("codesign")
5047
-        .arg("-v")
5048
-        .arg(&out)
5049
-        .output()
5050
-        .unwrap();
5051
-    assert!(
5052
-        verify.status.success(),
5053
-        "codesign verify failed: {}",
5054
-        String::from_utf8_lossy(&verify.stderr)
8261
+    let baseline_bytes = fs::read(&baseline_out).unwrap();
8262
+    let our_bytes = fs::read(&our_out).unwrap();
8263
+    let baseline_symbols = symbol_values(&baseline_bytes);
8264
+    let our_symbols = symbol_values(&our_bytes);
8265
+    let baseline_cstrings = output_section(&baseline_bytes, "__TEXT", "__cstring")
8266
+        .unwrap()
8267
+        .1;
8268
+    let our_cstrings = output_section(&our_bytes, "__TEXT", "__cstring").unwrap().1;
8269
+
8270
+    assert_ne!(
8271
+        baseline_symbols.get("_str1"),
8272
+        baseline_symbols.get("_str2"),
8273
+        "baseline link should keep identical private cstrings separate"
50558274
     );
5056
-    let status = Command::new(&out).status().unwrap();
50578275
     assert_eq!(
5058
-        status.code(),
5059
-        Some(0),
5060
-        "expected dylib-import executable to exit 0"
8276
+        our_symbols.get("_str1"),
8277
+        our_symbols.get("_str2"),
8278
+        "expected afs-ld -icf=safe to coalesce identical private cstrings"
8279
+    );
8280
+    assert!(
8281
+        our_cstrings.len() < baseline_cstrings.len(),
8282
+        "expected -icf=safe to reduce cstring section size on identical literals"
50618283
     );
50628284
 
5063
-    let _ = fs::remove_file(dylib);
50648285
     let _ = fs::remove_file(obj);
5065
-    let _ = fs::remove_file(out);
8286
+    let _ = fs::remove_file(baseline_out);
8287
+    let _ = fs::remove_file(our_out);
50668288
 }
50678289
 
5068
-#[test]
5069
-fn linker_run_handles_local_tlv_descriptors() {
5070
-    if !have_xcrun() || !have_tool("codesign") {
5071
-        eprintln!("skipping: xcrun clang or codesign unavailable");
5072
-        return;
5073
-    }
5074
-    let Some(sdk) = sdk_path() else {
5075
-        eprintln!("skipping: xcrun --show-sdk-path unavailable");
8290
+#[test]
8291
+fn linker_run_icf_safe_folds_identical_private_literal16() {
8292
+    if !have_xcrun() {
8293
+        eprintln!("skipping: xcrun unavailable");
50768294
         return;
50778295
     };
5078
-    let tbd = PathBuf::from(format!("{sdk}/usr/lib/libSystem.tbd"));
5079
-    if !tbd.exists() {
5080
-        eprintln!("skipping: no libSystem.tbd at {}", tbd.display());
5081
-        return;
5082
-    }
50838296
 
5084
-    let obj = scratch("tlvp-local.o");
5085
-    let out = scratch("tlvp-local.out");
8297
+    let obj = scratch("icf-literal16-fold.o");
8298
+    let baseline_out = scratch("icf-literal16-fold-baseline.out");
8299
+    let our_out = scratch("icf-literal16-fold-ours.out");
50868300
     let src = r#"
5087
-        __thread long tls_a = 7;
5088
-        __thread long tls_b;
5089
-
5090
-        static long tls_sum(void) {
5091
-            return tls_a + tls_b;
5092
-        }
8301
+        .section __TEXT,__text,regular,pure_instructions
8302
+        .globl _main
8303
+        _main:
8304
+            mov w0, #0
8305
+            ret
50938306
 
5094
-        int main(void) {
5095
-            return tls_sum() == 7 ? 0 : 1;
5096
-        }
8307
+        .section __TEXT,__literal16,16byte_literals
8308
+        .private_extern _lit1
8309
+        _lit1:
8310
+            .quad 0x1122334455667788
8311
+            .quad 0x99aabbccddeeff00
8312
+        .private_extern _lit2
8313
+        _lit2:
8314
+            .quad 0x1122334455667788
8315
+            .quad 0x99aabbccddeeff00
8316
+        .subsections_via_symbols
50978317
     "#;
5098
-    if let Err(e) = compile_c(src, &obj) {
5099
-        eprintln!("skipping: compile failed: {e}");
8318
+    if let Err(e) = assemble(src, &obj) {
8319
+        eprintln!("skipping: assemble failed: {e}");
51008320
         return;
51018321
     }
51028322
 
8323
+    let baseline_opts = LinkOptions {
8324
+        inputs: vec![obj.clone()],
8325
+        output: Some(baseline_out.clone()),
8326
+        kind: OutputKind::Executable,
8327
+        ..LinkOptions::default()
8328
+    };
8329
+    Linker::run(&baseline_opts).unwrap();
8330
+
51038331
     let opts = LinkOptions {
5104
-        inputs: vec![obj.clone(), tbd],
5105
-        output: Some(out.clone()),
8332
+        inputs: vec![obj.clone()],
8333
+        output: Some(our_out.clone()),
51068334
         kind: OutputKind::Executable,
8335
+        icf_mode: afs_ld::IcfMode::Safe,
51078336
         ..LinkOptions::default()
51088337
     };
51098338
     Linker::run(&opts).unwrap();
51108339
 
5111
-    let bytes = fs::read(&out).unwrap();
5112
-    let (_, thread_vars) = output_section(&bytes, "__DATA", "__thread_vars").unwrap();
5113
-    let (_, thread_data) = output_section(&bytes, "__DATA", "__thread_data").unwrap();
5114
-    assert!(output_section(&bytes, "__DATA", "__thread_ptrs").is_none());
5115
-    assert_eq!(thread_vars.len(), 48);
5116
-    assert_eq!(thread_data.len(), 8);
5117
-    assert_eq!(
5118
-        u64::from_le_bytes(thread_vars[16..24].try_into().unwrap()),
5119
-        0
8340
+    let baseline_bytes = fs::read(&baseline_out).unwrap();
8341
+    let our_bytes = fs::read(&our_out).unwrap();
8342
+    let baseline_symbols = symbol_values(&baseline_bytes);
8343
+    let our_symbols = symbol_values(&our_bytes);
8344
+    let baseline_literals = output_section(&baseline_bytes, "__TEXT", "__literal16")
8345
+        .unwrap()
8346
+        .1;
8347
+    let our_literals = output_section(&our_bytes, "__TEXT", "__literal16")
8348
+        .unwrap()
8349
+        .1;
8350
+
8351
+    assert_ne!(
8352
+        baseline_symbols.get("_lit1"),
8353
+        baseline_symbols.get("_lit2"),
8354
+        "baseline link should keep identical private literal16 atoms separate"
51208355
     );
51218356
     assert_eq!(
5122
-        u64::from_le_bytes(thread_vars[40..48].try_into().unwrap()),
5123
-        8
8357
+        our_symbols.get("_lit1"),
8358
+        our_symbols.get("_lit2"),
8359
+        "expected afs-ld -icf=safe to coalesce identical private literal16 atoms"
51248360
     );
5125
-
5126
-    let binds = decode_bind_records(&bytes, false).unwrap();
5127
-    let mut tlv_binds: Vec<_> = binds
5128
-        .into_iter()
5129
-        .filter(|record| record.section == "__thread_vars" && record.symbol == "__tlv_bootstrap")
5130
-        .collect();
5131
-    tlv_binds.sort_by_key(|record| record.section_offset);
5132
-    assert_eq!(tlv_binds.len(), 2);
5133
-    assert_eq!(tlv_binds[0].section_offset, 0);
5134
-    assert_eq!(tlv_binds[1].section_offset, 24);
5135
-
5136
-    let header = parse_header(&bytes).unwrap();
5137
-    let commands = parse_commands(&header, &bytes).unwrap();
5138
-    let symtab = commands
5139
-        .iter()
5140
-        .find_map(|cmd| match cmd {
5141
-            LoadCommand::Symtab(cmd) => Some(*cmd),
5142
-            _ => None,
5143
-        })
5144
-        .unwrap();
5145
-    let symbols = parse_nlist_table(&bytes, symtab.symoff, symtab.nsyms).unwrap();
5146
-    let strings = StringTable::from_file(&bytes, symtab.stroff, symtab.strsize).unwrap();
5147
-    let symbol_names: Vec<&str> = symbols
5148
-        .iter()
5149
-        .map(|symbol| strings.get(symbol.strx()).unwrap())
5150
-        .collect();
5151
-    assert!(symbol_names.contains(&"__tlv_bootstrap"));
5152
-
5153
-    let verify = Command::new("codesign")
5154
-        .arg("-v")
5155
-        .arg(&out)
5156
-        .output()
5157
-        .unwrap();
51588361
     assert!(
5159
-        verify.status.success(),
5160
-        "codesign verify failed: {}",
5161
-        String::from_utf8_lossy(&verify.stderr)
8362
+        our_literals.len() < baseline_literals.len(),
8363
+        "expected -icf=safe to reduce literal16 section size on identical atoms"
51628364
     );
5163
-    let status = Command::new(&out).status().unwrap();
5164
-    assert_eq!(status.code(), Some(0), "expected TLV executable to exit 0");
51658365
 
51668366
     let _ = fs::remove_file(obj);
5167
-    let _ = fs::remove_file(out);
8367
+    let _ = fs::remove_file(baseline_out);
8368
+    let _ = fs::remove_file(our_out);
51688369
 }
51698370
 
51708371
 #[test]
5171
-fn linker_run_routes_imported_tlv_through_got() {
5172
-    if !have_xcrun() || !have_tool("codesign") {
5173
-        eprintln!("skipping: xcrun clang or codesign unavailable");
5174
-        return;
5175
-    }
5176
-    let Some(sdk) = sdk_path() else {
5177
-        eprintln!("skipping: xcrun --show-sdk-path unavailable");
5178
-        return;
5179
-    };
5180
-    let Some(sdk_ver) = sdk_version() else {
5181
-        eprintln!("skipping: xcrun --show-sdk-version unavailable");
8372
+fn linker_run_icf_safe_folds_identical_private_data_const_atoms() {
8373
+    if !have_xcrun() {
8374
+        eprintln!("skipping: xcrun unavailable");
51828375
         return;
51838376
     };
5184
-    let tbd = PathBuf::from(format!("{sdk}/usr/lib/libSystem.tbd"));
5185
-    if !tbd.exists() {
5186
-        eprintln!("skipping: no libSystem.tbd at {}", tbd.display());
5187
-        return;
5188
-    }
51898377
 
5190
-    let dylib = scratch("libtlvprobe.dylib");
5191
-    let obj = scratch("imported-tlv.o");
5192
-    let our_out = scratch("imported-tlv-ours.out");
5193
-    let apple_out = scratch("imported-tlv-apple.out");
8378
+    let obj = scratch("icf-data-const-fold.o");
8379
+    let baseline_out = scratch("icf-data-const-fold-baseline.out");
8380
+    let our_out = scratch("icf-data-const-fold-ours.out");
8381
+    let src = r#"
8382
+        .section __TEXT,__text,regular,pure_instructions
8383
+        .globl _main
8384
+        _main:
8385
+            mov w0, #0
8386
+            ret
51948387
 
5195
-    let dylib_src = r#"
5196
-        __thread long ext_tls = 5;
5197
-        long read_lib_tls(void) { return ext_tls; }
8388
+        .section __DATA_CONST,__const
8389
+        .p2align 3
8390
+        .private_extern _const1
8391
+        _const1:
8392
+            .quad 0x0123456789abcdef
8393
+        .p2align 3
8394
+        .private_extern _const2
8395
+        _const2:
8396
+            .quad 0x0123456789abcdef
8397
+        .subsections_via_symbols
51988398
     "#;
5199
-    if let Err(e) = compile_dylib_c(dylib_src, &dylib) {
5200
-        eprintln!("skipping: dylib compile failed: {e}");
8399
+    if let Err(e) = assemble(src, &obj) {
8400
+        eprintln!("skipping: assemble failed: {e}");
52018401
         return;
52028402
     }
52038403
 
5204
-    let main_src = r#"
5205
-        extern __thread long ext_tls;
5206
-        int main(void) { return ext_tls == 5 ? 0 : 1; }
5207
-    "#;
5208
-    if let Err(e) = compile_c(main_src, &obj) {
5209
-        eprintln!("skipping: compile failed: {e}");
5210
-        return;
5211
-    }
8404
+    let baseline_opts = LinkOptions {
8405
+        inputs: vec![obj.clone()],
8406
+        output: Some(baseline_out.clone()),
8407
+        kind: OutputKind::Executable,
8408
+        ..LinkOptions::default()
8409
+    };
8410
+    Linker::run(&baseline_opts).unwrap();
52128411
 
52138412
     let opts = LinkOptions {
5214
-        inputs: vec![obj.clone(), tbd.clone(), dylib.clone()],
8413
+        inputs: vec![obj.clone()],
52158414
         output: Some(our_out.clone()),
52168415
         kind: OutputKind::Executable,
8416
+        icf_mode: afs_ld::IcfMode::Safe,
52178417
         ..LinkOptions::default()
52188418
     };
52198419
     Linker::run(&opts).unwrap();
52208420
 
5221
-    let apple = Command::new("xcrun")
5222
-        .args([
5223
-            "ld",
5224
-            "-arch",
5225
-            "arm64",
5226
-            "-platform_version",
5227
-            "macos",
5228
-            &sdk_ver,
5229
-            &sdk_ver,
5230
-            "-syslibroot",
5231
-            &sdk,
5232
-            "-no_fixup_chains",
5233
-            "-lSystem",
5234
-            "-e",
5235
-            "_main",
5236
-            "-o",
5237
-        ])
5238
-        .arg(&apple_out)
5239
-        .arg(&obj)
5240
-        .arg(&dylib)
5241
-        .output()
5242
-        .unwrap();
5243
-    assert!(
5244
-        apple.status.success(),
5245
-        "xcrun ld failed: {}",
5246
-        String::from_utf8_lossy(&apple.stderr)
5247
-    );
5248
-
8421
+    let baseline_bytes = fs::read(&baseline_out).unwrap();
52498422
     let our_bytes = fs::read(&our_out).unwrap();
5250
-    let apple_bytes = fs::read(&apple_out).unwrap();
5251
-    let (our_text_addr, our_text) = output_section(&our_bytes, "__TEXT", "__text").unwrap();
5252
-    let (apple_text_addr, apple_text) = output_section(&apple_bytes, "__TEXT", "__text").unwrap();
5253
-    let (our_got_addr, our_got) = output_section(&our_bytes, "__DATA_CONST", "__got").unwrap();
5254
-    let (apple_got_addr, apple_got) =
5255
-        output_section(&apple_bytes, "__DATA_CONST", "__got").unwrap();
8423
+    let baseline_symbols = symbol_values(&baseline_bytes);
8424
+    let our_symbols = symbol_values(&our_bytes);
8425
+    let baseline_const = output_section(&baseline_bytes, "__DATA_CONST", "__const")
8426
+        .unwrap()
8427
+        .1;
8428
+    let our_const = output_section(&our_bytes, "__DATA_CONST", "__const")
8429
+        .unwrap()
8430
+        .1;
52568431
 
5257
-    assert!(output_section(&our_bytes, "__DATA", "__thread_ptrs").is_none());
5258
-    assert!(output_section(&apple_bytes, "__DATA", "__thread_ptrs").is_none());
5259
-    assert_eq!(our_got.len(), 8);
5260
-    assert_eq!(our_got, apple_got);
5261
-    assert_eq!(
5262
-        decode_page_reference(&our_text, our_text_addr, 20, &PageRefKind::Load).unwrap(),
5263
-        our_got_addr
5264
-    );
5265
-    assert_eq!(
5266
-        decode_page_reference(&apple_text, apple_text_addr, 20, &PageRefKind::Load).unwrap(),
5267
-        apple_got_addr
5268
-    );
5269
-    assert_eq!(our_text, apple_text);
5270
-    assert_eq!(read_insn(&our_text, 24).unwrap(), 0xf9400000);
5271
-    assert_eq!(read_insn(&our_text, 28).unwrap(), 0xf9400008);
5272
-    assert_eq!(read_insn(&our_text, 32).unwrap(), 0xd63f0100);
5273
-    assert_eq!(
5274
-        decode_bind_records(&our_bytes, false).unwrap(),
5275
-        decode_bind_records(&apple_bytes, false).unwrap()
8432
+    assert_ne!(
8433
+        baseline_symbols.get("_const1"),
8434
+        baseline_symbols.get("_const2"),
8435
+        "baseline link should keep identical private __DATA_CONST atoms separate"
52768436
     );
52778437
     assert_eq!(
5278
-        load_dylib_names(&our_bytes).unwrap(),
5279
-        load_dylib_names(&apple_bytes).unwrap()
8438
+        our_symbols.get("_const1"),
8439
+        our_symbols.get("_const2"),
8440
+        "expected afs-ld -icf=safe to coalesce identical private __DATA_CONST atoms"
52808441
     );
5281
-    let verify = Command::new("codesign")
5282
-        .arg("-v")
5283
-        .arg(&our_out)
5284
-        .output()
5285
-        .unwrap();
52868442
     assert!(
5287
-        verify.status.success(),
5288
-        "codesign verify failed: {}",
5289
-        String::from_utf8_lossy(&verify.stderr)
5290
-    );
5291
-    let status = Command::new(&our_out).status().unwrap();
5292
-    assert_eq!(
5293
-        status.code(),
5294
-        Some(0),
5295
-        "expected imported TLV executable to exit 0"
8443
+        our_const.len() < baseline_const.len(),
8444
+        "expected -icf=safe to reduce __DATA_CONST,__const size on identical atoms"
52968445
     );
52978446
 
5298
-    let _ = fs::remove_file(dylib);
52998447
     let _ = fs::remove_file(obj);
8448
+    let _ = fs::remove_file(baseline_out);
53008449
     let _ = fs::remove_file(our_out);
5301
-    let _ = fs::remove_file(apple_out);
53028450
 }
53038451
 
53048452
 #[test]
5305
-fn linker_run_preserves_runtime_tlv_descriptor_offsets() {
5306
-    if !have_xcrun() || !have_tool("codesign") {
5307
-        eprintln!("skipping: xcrun or codesign unavailable");
5308
-        return;
5309
-    }
5310
-    let Some(runtime) = find_runtime_archive() else {
5311
-        eprintln!("skipping: libarmfortas_rt.a not built");
5312
-        return;
5313
-    };
5314
-    let Some(sdk) = sdk_path() else {
5315
-        eprintln!("skipping: no macOS SDK path");
5316
-        return;
5317
-    };
5318
-    let Some(sdk_ver) = sdk_version() else {
5319
-        eprintln!("skipping: no macOS SDK version");
8453
+fn linker_run_icf_safe_reaches_fixed_point_through_folded_targets() {
8454
+    if !have_xcrun() {
8455
+        eprintln!("skipping: xcrun unavailable");
53208456
         return;
53218457
     };
5322
-    let tbd = PathBuf::from(format!("{sdk}/usr/lib/libSystem.tbd"));
5323
-    if !tbd.exists() {
5324
-        eprintln!("skipping: no libSystem.tbd at {}", tbd.display());
5325
-        return;
5326
-    }
53278458
 
5328
-    let obj = scratch("runtime-hello.o");
5329
-    let out = scratch("runtime-hello.out");
5330
-    let apple_out = scratch("runtime-hello-apple.out");
8459
+    let obj = scratch("icf-fixed-point.o");
8460
+    let baseline_out = scratch("icf-fixed-point-baseline.out");
8461
+    let our_out = scratch("icf-fixed-point-ours.out");
53318462
     let src = r#"
5332
-        extern void afs_program_init(void);
5333
-        extern void afs_program_finalize(void);
5334
-        extern void afs_write_string(int, const char *, long);
5335
-        extern void afs_write_newline(int);
8463
+        .section __TEXT,__text,regular,pure_instructions
8464
+        .globl _main
8465
+        _main:
8466
+            stp x29, x30, [sp, #-32]!
8467
+            mov x29, sp
8468
+            bl _wrapper1
8469
+            str w0, [sp, #16]
8470
+            bl _wrapper2
8471
+            ldr w8, [sp, #16]
8472
+            add w0, w8, w0
8473
+            ldp x29, x30, [sp], #32
8474
+            ret
8475
+
8476
+        .private_extern _wrapper1
8477
+        _wrapper1:
8478
+            b _leaf1
8479
+
8480
+        .private_extern _wrapper2
8481
+        _wrapper2:
8482
+            b _leaf2
8483
+
8484
+        .private_extern _leaf1
8485
+        _leaf1:
8486
+            mov w0, #6
8487
+            ret
53368488
 
5337
-        int main(void) {
5338
-            afs_program_init();
5339
-            afs_write_string(6, "Hello, World!", 13);
5340
-            afs_write_newline(6);
5341
-            afs_program_finalize();
5342
-            return 0;
5343
-        }
8489
+        .private_extern _leaf2
8490
+        _leaf2:
8491
+            mov w0, #6
8492
+            ret
8493
+        .subsections_via_symbols
53448494
     "#;
5345
-    if let Err(e) = compile_c(src, &obj) {
5346
-        eprintln!("skipping: compile failed: {e}");
8495
+    if let Err(e) = assemble(src, &obj) {
8496
+        eprintln!("skipping: assemble failed: {e}");
53478497
         return;
53488498
     }
53498499
 
8500
+    let baseline_opts = LinkOptions {
8501
+        inputs: vec![obj.clone()],
8502
+        output: Some(baseline_out.clone()),
8503
+        kind: OutputKind::Executable,
8504
+        ..LinkOptions::default()
8505
+    };
8506
+    Linker::run(&baseline_opts).unwrap();
8507
+
53508508
     let opts = LinkOptions {
5351
-        inputs: vec![obj.clone(), runtime.clone(), tbd],
5352
-        output: Some(out.clone()),
8509
+        inputs: vec![obj.clone()],
8510
+        output: Some(our_out.clone()),
53538511
         kind: OutputKind::Executable,
8512
+        icf_mode: afs_ld::IcfMode::Safe,
53548513
         ..LinkOptions::default()
53558514
     };
53568515
     Linker::run(&opts).unwrap();
53578516
 
5358
-    let apple = Command::new("xcrun")
5359
-        .args([
5360
-            "ld",
5361
-            "-arch",
5362
-            "arm64",
5363
-            "-platform_version",
5364
-            "macos",
5365
-            &sdk_ver,
5366
-            &sdk_ver,
5367
-            "-syslibroot",
5368
-            &sdk,
5369
-            "-lSystem",
5370
-            "-e",
5371
-            "_main",
5372
-            "-no_fixup_chains",
5373
-            "-o",
5374
-        ])
5375
-        .arg(&apple_out)
5376
-        .arg(&obj)
5377
-        .arg(&runtime)
5378
-        .output()
5379
-        .unwrap();
5380
-    assert!(
5381
-        apple.status.success(),
5382
-        "xcrun ld failed: {}",
5383
-        String::from_utf8_lossy(&apple.stderr)
5384
-    );
5385
-
5386
-    let verify = Command::new("codesign")
5387
-        .arg("-v")
5388
-        .arg(&out)
5389
-        .output()
5390
-        .unwrap();
5391
-    assert!(
5392
-        verify.status.success(),
5393
-        "codesign verify failed: {}",
5394
-        String::from_utf8_lossy(&verify.stderr)
5395
-    );
5396
-
5397
-    let bytes = fs::read(&out).unwrap();
5398
-    let apple_bytes = fs::read(&apple_out).unwrap();
5399
-    assert!(
5400
-        output_section(&bytes, "__DATA_CONST", "__const").is_some(),
5401
-        "runtime hello should promote file-backed __const data into __DATA_CONST"
5402
-    );
5403
-    assert!(
5404
-        output_section(&bytes, "__DATA", "__const").is_none(),
5405
-        "runtime hello should not leave file-backed __const data in __DATA"
5406
-    );
5407
-    let (thread_vars_addr, thread_vars) =
5408
-        output_section(&bytes, "__DATA", "__thread_vars").unwrap();
5409
-    let (thread_data_addr, _) = output_section(&bytes, "__DATA", "__thread_data").unwrap();
5410
-    let symbols = symbol_values(&bytes);
5411
-    let tlv_binds: Vec<_> = decode_bind_records(&bytes, false)
8517
+    let baseline_bytes = fs::read(&baseline_out).unwrap();
8518
+    let our_bytes = fs::read(&our_out).unwrap();
8519
+    let baseline_symbols = symbol_values(&baseline_bytes);
8520
+    let our_symbols = symbol_values(&our_bytes);
8521
+    let baseline_text = output_section(&baseline_bytes, "__TEXT", "__text")
54128522
         .unwrap()
5413
-        .into_iter()
5414
-        .filter(|record| record.section == "__thread_vars")
5415
-        .collect();
5416
-    assert_eq!(
5417
-        tlv_binds.len(),
5418
-        thread_vars.len() / 24,
5419
-        "every TLV descriptor should carry exactly one bootstrap bind"
5420
-    );
5421
-    assert!(tlv_binds
5422
-        .iter()
5423
-        .all(|record| record.symbol == "__tlv_bootstrap"));
8523
+        .1;
8524
+    let our_text = output_section(&our_bytes, "__TEXT", "__text").unwrap().1;
54248525
 
5425
-    assert_eq!(
5426
-        decode_bind_records(&bytes, false).unwrap(),
5427
-        decode_bind_records(&apple_bytes, false).unwrap(),
5428
-        "runtime hello bind records diverged from Apple ld"
5429
-    );
5430
-    assert_eq!(
5431
-        decode_bind_records(&bytes, true).unwrap(),
5432
-        decode_bind_records(&apple_bytes, true).unwrap(),
5433
-        "runtime hello lazy-bind records diverged from Apple ld"
8526
+    assert_ne!(
8527
+        baseline_symbols.get("_leaf1"),
8528
+        baseline_symbols.get("_leaf2"),
8529
+        "baseline link should keep equivalent leaves separate"
54348530
     );
5435
-    assert_eq!(
5436
-        decode_rebase_records(&bytes).unwrap(),
5437
-        decode_rebase_records(&apple_bytes).unwrap(),
5438
-        "runtime hello rebase records diverged from Apple ld"
8531
+    assert_ne!(
8532
+        baseline_symbols.get("_wrapper1"),
8533
+        baseline_symbols.get("_wrapper2"),
8534
+        "baseline link should keep wrappers separate"
54398535
     );
54408536
     assert_eq!(
5441
-        indirect_symbol_identities(&bytes),
5442
-        indirect_symbol_identities(&apple_bytes),
5443
-        "runtime hello indirect symbol identities diverged from Apple ld"
8537
+        our_symbols.get("_leaf1"),
8538
+        our_symbols.get("_leaf2"),
8539
+        "equivalent leaves should fold under -icf=safe"
54448540
     );
5445
-
5446
-    for (name, descriptor_addr) in symbols.iter().filter(|(name, value)| {
5447
-        !name.ends_with("$tlv$init")
5448
-            && **value >= thread_vars_addr
5449
-            && **value < thread_vars_addr + thread_vars.len() as u64
5450
-    }) {
5451
-        let init_name = format!("{name}$tlv$init");
5452
-        let Some(init_addr) = symbols.get(&init_name) else {
5453
-            continue;
5454
-        };
5455
-        let offset = (*descriptor_addr - thread_vars_addr) as usize;
5456
-        let actual = u64::from_le_bytes(thread_vars[offset + 16..offset + 24].try_into().unwrap());
5457
-        let expected = init_addr - thread_data_addr;
5458
-        assert_eq!(
5459
-            actual, expected,
5460
-            "TLV descriptor {} should point at {} via template offset",
5461
-            name, init_name
5462
-        );
5463
-    }
5464
-
5465
-    let output = Command::new(&out).output().unwrap();
5466
-    let apple_output = Command::new(&apple_out).output().unwrap();
54678541
     assert_eq!(
5468
-        output.status.code(),
5469
-        Some(0),
5470
-        "expected runtime hello executable to exit 0, stderr={}",
5471
-        String::from_utf8_lossy(&output.stderr)
8542
+        our_symbols.get("_wrapper1"),
8543
+        our_symbols.get("_wrapper2"),
8544
+        "wrappers should fold once their targets converge to the same winner"
54728545
     );
54738546
     assert_eq!(
5474
-        apple_output.status.code(),
5475
-        Some(0),
5476
-        "expected Apple-linked runtime hello executable to exit 0, stderr={}",
5477
-        String::from_utf8_lossy(&apple_output.stderr)
8547
+        Command::new(&our_out).status().unwrap().code(),
8548
+        Some(12),
8549
+        "fixed-point folded executable should preserve runtime behavior"
54788550
     );
5479
-    assert_eq!(
5480
-        String::from_utf8_lossy(&output.stdout),
5481
-        String::from_utf8_lossy(&apple_output.stdout)
8551
+    assert!(
8552
+        our_text.len() < baseline_text.len(),
8553
+        "expected fixed-point folding to reduce text size"
54828554
     );
54838555
 
54848556
     let _ = fs::remove_file(obj);
5485
-    let _ = fs::remove_file(out);
5486
-    let _ = fs::remove_file(apple_out);
8557
+    let _ = fs::remove_file(baseline_out);
8558
+    let _ = fs::remove_file(our_out);
54878559
 }
54888560
 
54898561
 #[test]
5490
-fn linker_run_rebases_runtime_init_metadata_like_apple_ld() {
5491
-    if !have_xcrun() || !have_tool("codesign") {
5492
-        eprintln!("skipping: xcrun or codesign unavailable");
5493
-        return;
5494
-    }
5495
-    let Some(runtime) = find_runtime_archive() else {
5496
-        eprintln!("skipping: libarmfortas_rt.a not built");
5497
-        return;
5498
-    };
5499
-    let Some(sdk) = sdk_path() else {
5500
-        eprintln!("skipping: no macOS SDK path");
8562
+fn linker_run_icf_safe_prefers_earlier_input_order_winner() {
8563
+    if !have_xcrun() {
8564
+        eprintln!("skipping: xcrun unavailable");
55018565
         return;
55028566
     };
5503
-    let Some(sdk_ver) = sdk_version() else {
5504
-        eprintln!("skipping: no macOS SDK version");
8567
+
8568
+    let main_obj = scratch("icf-order-main.o");
8569
+    let first_obj = scratch("icf-order-first.o");
8570
+    let second_obj = scratch("icf-order-second.o");
8571
+    let our_out = scratch("icf-order-ours.out");
8572
+    let map = scratch("icf-order.map");
8573
+    let main_src = r#"
8574
+        .section __TEXT,__text,regular,pure_instructions
8575
+        .globl _main
8576
+        _main:
8577
+            stp x29, x30, [sp, #-32]!
8578
+            mov x29, sp
8579
+            bl _helper_a
8580
+            str w0, [sp, #16]
8581
+            bl _helper_b
8582
+            ldr w8, [sp, #16]
8583
+            add w0, w8, w0
8584
+            ldp x29, x30, [sp], #32
8585
+            ret
8586
+        .subsections_via_symbols
8587
+    "#;
8588
+    let helper_a_src = r#"
8589
+        .section __TEXT,__text,regular,pure_instructions
8590
+        .private_extern _helper_a
8591
+        .globl _helper_a
8592
+        _helper_a:
8593
+            mov w0, #4
8594
+            ret
8595
+        .subsections_via_symbols
8596
+    "#;
8597
+    let helper_b_src = r#"
8598
+        .section __TEXT,__text,regular,pure_instructions
8599
+        .private_extern _helper_b
8600
+        .globl _helper_b
8601
+        _helper_b:
8602
+            mov w0, #4
8603
+            ret
8604
+        .subsections_via_symbols
8605
+    "#;
8606
+    if let Err(e) = assemble(main_src, &main_obj) {
8607
+        eprintln!("skipping: assemble failed: {e}");
55058608
         return;
5506
-    };
5507
-    let tbd = PathBuf::from(format!("{sdk}/usr/lib/libSystem.tbd"));
5508
-    if !tbd.exists() {
5509
-        eprintln!("skipping: no libSystem.tbd at {}", tbd.display());
8609
+    }
8610
+    if let Err(e) = assemble(helper_a_src, &first_obj) {
8611
+        eprintln!("skipping: assemble failed: {e}");
8612
+        let _ = fs::remove_file(main_obj);
55108613
         return;
55118614
     }
5512
-
5513
-    let obj = scratch("runtime-init-only.o");
5514
-    let our_out = scratch("runtime-init-only-ours.out");
5515
-    let apple_out = scratch("runtime-init-only-apple.out");
5516
-    let src = r#"
5517
-        extern void afs_program_init(void);
5518
-
5519
-        int main(void) {
5520
-            afs_program_init();
5521
-            return 0;
5522
-        }
5523
-    "#;
5524
-    if let Err(e) = compile_c(src, &obj) {
5525
-        eprintln!("skipping: compile failed: {e}");
8615
+    if let Err(e) = assemble(helper_b_src, &second_obj) {
8616
+        eprintln!("skipping: assemble failed: {e}");
8617
+        let _ = fs::remove_file(main_obj);
8618
+        let _ = fs::remove_file(first_obj);
55268619
         return;
55278620
     }
55288621
 
55298622
     let opts = LinkOptions {
5530
-        inputs: vec![obj.clone(), runtime.clone(), tbd],
8623
+        inputs: vec![main_obj.clone(), first_obj.clone(), second_obj.clone()],
55318624
         output: Some(our_out.clone()),
8625
+        map: Some(map.clone()),
55328626
         kind: OutputKind::Executable,
8627
+        icf_mode: afs_ld::IcfMode::Safe,
55338628
         ..LinkOptions::default()
55348629
     };
55358630
     Linker::run(&opts).unwrap();
55368631
 
5537
-    let apple = Command::new("xcrun")
5538
-        .args([
5539
-            "ld",
5540
-            "-arch",
5541
-            "arm64",
5542
-            "-platform_version",
5543
-            "macos",
5544
-            &sdk_ver,
5545
-            &sdk_ver,
5546
-            "-syslibroot",
5547
-            &sdk,
5548
-            "-lSystem",
5549
-            "-e",
5550
-            "_main",
5551
-            "-no_fixup_chains",
5552
-            "-o",
5553
-        ])
5554
-        .arg(&apple_out)
5555
-        .arg(&obj)
5556
-        .arg(&runtime)
5557
-        .output()
5558
-        .unwrap();
5559
-    assert!(
5560
-        apple.status.success(),
5561
-        "xcrun ld failed: {}",
5562
-        String::from_utf8_lossy(&apple.stderr)
5563
-    );
5564
-
55658632
     let our_bytes = fs::read(&our_out).unwrap();
5566
-    let apple_bytes = fs::read(&apple_out).unwrap();
5567
-    let our_rebases = decode_rebase_records(&our_bytes).unwrap();
5568
-    let apple_rebases = decode_rebase_records(&apple_bytes).unwrap();
8633
+    let our_symbols = symbol_values(&our_bytes);
8634
+    let map_text = fs::read_to_string(&map).unwrap();
8635
+
55698636
     assert_eq!(
5570
-        our_rebases
5571
-            .iter()
5572
-            .filter(|record| record.section == "__const")
5573
-            .count(),
5574
-        apple_rebases
5575
-            .iter()
5576
-            .filter(|record| record.section == "__const")
5577
-            .count(),
5578
-        "runtime init const rebases diverged from Apple ld"
8637
+        our_symbols.get("_helper_a"),
8638
+        our_symbols.get("_helper_b"),
8639
+        "equivalent helpers should fold to the same winner"
8640
+    );
8641
+    assert!(
8642
+        map_text.contains("_helper_b folded to _helper_a"),
8643
+        "earlier input should win safe-ICF ties:\n{map_text}"
55798644
     );
55808645
     assert_eq!(
5581
-        our_rebases
5582
-            .iter()
5583
-            .filter(|record| record.section == "__la_symbol_ptr")
5584
-            .count(),
5585
-        apple_rebases
5586
-            .iter()
5587
-            .filter(|record| record.section == "__la_symbol_ptr")
5588
-            .count(),
5589
-        "runtime init lazy-pointer rebases diverged from Apple ld"
8646
+        Command::new(&our_out).status().unwrap().code(),
8647
+        Some(8),
8648
+        "input-order folded executable should preserve runtime behavior"
55908649
     );
55918650
 
5592
-    let our_status = Command::new(&our_out).status().unwrap();
5593
-    let apple_status = Command::new(&apple_out).status().unwrap();
5594
-    assert_eq!(our_status.code(), Some(0));
5595
-    assert_eq!(apple_status.code(), Some(0));
5596
-
5597
-    let _ = fs::remove_file(obj);
8651
+    let _ = fs::remove_file(main_obj);
8652
+    let _ = fs::remove_file(first_obj);
8653
+    let _ = fs::remove_file(second_obj);
55988654
     let _ = fs::remove_file(our_out);
5599
-    let _ = fs::remove_file(apple_out);
8655
+    let _ = fs::remove_file(map);
56008656
 }
tests/linker_write_integration.rsadded
377 lines changed — click to load
@@ -0,0 +1,377 @@
1
+use std::fs;
2
+use std::path::{Path, PathBuf};
3
+use std::process::Command;
4
+
5
+use afs_ld::macho::constants::{LC_LOAD_DYLIB, LC_SOURCE_VERSION, LC_UUID, MH_DYLIB, MH_EXECUTE};
6
+use afs_ld::macho::reader::{parse_commands, parse_header, LoadCommand, Section64Header};
7
+
8
+fn have_xcrun() -> bool {
9
+    Command::new("xcrun")
10
+        .arg("-f")
11
+        .arg("as")
12
+        .output()
13
+        .map(|o| o.status.success())
14
+        .unwrap_or(false)
15
+}
16
+
17
+fn have_tool(name: &str) -> bool {
18
+    Command::new(name)
19
+        .arg("-h")
20
+        .output()
21
+        .map(|_| true)
22
+        .unwrap_or(false)
23
+}
24
+
25
+fn have_clang() -> bool {
26
+    Command::new("xcrun")
27
+        .arg("-f")
28
+        .arg("clang")
29
+        .output()
30
+        .map(|o| o.status.success())
31
+        .unwrap_or(false)
32
+}
33
+
34
+fn assemble(src_text: &str, out: &Path) -> Result<(), String> {
35
+    let tmp = std::env::temp_dir().join(format!(
36
+        "afs-ld-link-write-{}-{}.s",
37
+        std::process::id(),
38
+        out.file_stem().and_then(|s| s.to_str()).unwrap_or("t")
39
+    ));
40
+    fs::write(&tmp, src_text).map_err(|e| format!("write .s: {e}"))?;
41
+    let status = Command::new("xcrun")
42
+        .args(["--sdk", "macosx", "as", "-arch", "arm64"])
43
+        .arg(&tmp)
44
+        .arg("-o")
45
+        .arg(out)
46
+        .output()
47
+        .map_err(|e| format!("spawn xcrun as: {e}"))?;
48
+    let _ = fs::remove_file(&tmp);
49
+    if !status.status.success() {
50
+        return Err(format!(
51
+            "xcrun as failed: {}",
52
+            String::from_utf8_lossy(&status.stderr)
53
+        ));
54
+    }
55
+    Ok(())
56
+}
57
+
58
+fn build_test_dylib(src: &str, out: &Path, install_name: &str) -> Result<(), String> {
59
+    let mut child = Command::new("xcrun")
60
+        .args([
61
+            "--sdk", "macosx", "clang", "-x", "c", "-arch", "arm64", "-shared", "-o",
62
+        ])
63
+        .arg(out)
64
+        .arg("-install_name")
65
+        .arg(install_name)
66
+        .arg("-")
67
+        .stdin(std::process::Stdio::piped())
68
+        .stdout(std::process::Stdio::piped())
69
+        .stderr(std::process::Stdio::piped())
70
+        .spawn()
71
+        .map_err(|e| format!("spawn clang: {e}"))?;
72
+    use std::io::Write;
73
+    child
74
+        .stdin
75
+        .as_mut()
76
+        .unwrap()
77
+        .write_all(src.as_bytes())
78
+        .map_err(|e| format!("write clang stdin: {e}"))?;
79
+    let out = child.wait_with_output().map_err(|e| format!("wait: {e}"))?;
80
+    if !out.status.success() {
81
+        return Err(format!(
82
+            "clang failed: {}",
83
+            String::from_utf8_lossy(&out.stderr)
84
+        ));
85
+    }
86
+    Ok(())
87
+}
88
+
89
+fn scratch(name: &str) -> PathBuf {
90
+    std::env::temp_dir().join(format!("afs-ld-link-write-{}-{name}", std::process::id()))
91
+}
92
+
93
+fn link_with_afs_ld(args: &[&str]) -> Result<(), String> {
94
+    let exe = env!("CARGO_BIN_EXE_afs-ld");
95
+    let out = Command::new(exe)
96
+        .args(args)
97
+        .output()
98
+        .map_err(|e| format!("spawn afs-ld: {e}"))?;
99
+    if !out.status.success() {
100
+        return Err(format!(
101
+            "afs-ld failed: {}\n{}",
102
+            out.status,
103
+            String::from_utf8_lossy(&out.stderr)
104
+        ));
105
+    }
106
+    Ok(())
107
+}
108
+
109
+fn section<'a>(cmds: &'a [LoadCommand], seg: &str, sect: &str) -> &'a Section64Header {
110
+    cmds.iter()
111
+        .find_map(|cmd| match cmd {
112
+            LoadCommand::Segment64(segment) => segment
113
+                .sections
114
+                .iter()
115
+                .find(|s| s.segname_str() == seg && s.sectname_str() == sect),
116
+            _ => None,
117
+        })
118
+        .unwrap_or_else(|| panic!("missing section {seg},{sect}"))
119
+}
120
+
121
+fn run_otool_lv(path: &Path) -> Result<String, String> {
122
+    let out = Command::new("otool")
123
+        .arg("-lV")
124
+        .arg(path)
125
+        .output()
126
+        .map_err(|e| format!("spawn otool -lV: {e}"))?;
127
+    if !out.status.success() {
128
+        return Err(format!(
129
+            "otool -lV failed: {}",
130
+            String::from_utf8_lossy(&out.stderr)
131
+        ));
132
+    }
133
+    Ok(String::from_utf8_lossy(&out.stdout).into_owned())
134
+}
135
+
136
+fn fixture_source() -> &'static str {
137
+    r#"
138
+        .section __TEXT,__text,regular,pure_instructions
139
+        .globl _main
140
+        .p2align 2
141
+        _main:
142
+            ret
143
+
144
+        .section __TEXT,__cstring,cstring_literals
145
+        _lit:
146
+            .asciz "hi"
147
+
148
+        .section __DATA,__data
149
+        .globl _num
150
+        .p2align 3
151
+        _num:
152
+            .quad 0x1122334455667788
153
+    "#
154
+}
155
+
156
+#[test]
157
+fn linker_writes_executable_with_real_section_bytes() {
158
+    if !have_xcrun() {
159
+        eprintln!("skipping: xcrun as unavailable");
160
+        return;
161
+    }
162
+
163
+    let obj = scratch("fixture.o");
164
+    let out = scratch("linked-exec");
165
+    if let Err(e) = assemble(fixture_source(), &obj) {
166
+        eprintln!("skipping: assemble failed: {e}");
167
+        return;
168
+    }
169
+    link_with_afs_ld(&[obj.to_str().unwrap(), "-o", out.to_str().unwrap()])
170
+        .expect("link executable");
171
+
172
+    let bytes = fs::read(&out).expect("read executable");
173
+    let hdr = parse_header(&bytes).expect("parse header");
174
+    assert_eq!(hdr.filetype, MH_EXECUTE);
175
+    let cmds = parse_commands(&hdr, &bytes).expect("parse commands");
176
+
177
+    let text = section(&cmds, "__TEXT", "__text");
178
+    assert_eq!(
179
+        &bytes[text.offset as usize..text.offset as usize + text.size as usize],
180
+        &[0xc0, 0x03, 0x5f, 0xd6]
181
+    );
182
+
183
+    let cstring = section(&cmds, "__TEXT", "__cstring");
184
+    assert_eq!(
185
+        &bytes[cstring.offset as usize..cstring.offset as usize + cstring.size as usize],
186
+        b"hi\0"
187
+    );
188
+
189
+    let data = section(&cmds, "__DATA", "__data");
190
+    assert_eq!(
191
+        &bytes[data.offset as usize..data.offset as usize + data.size as usize],
192
+        &0x1122_3344_5566_7788u64.to_le_bytes()
193
+    );
194
+
195
+    if have_tool("otool") {
196
+        let dump = run_otool_lv(&out).expect("otool -lV");
197
+        assert!(dump.contains("segname __TEXT"));
198
+        assert!(dump.contains("sectname __cstring"));
199
+        assert!(dump.contains("segname __DATA"));
200
+    }
201
+
202
+    let _ = fs::remove_file(&obj);
203
+    let _ = fs::remove_file(&out);
204
+}
205
+
206
+#[test]
207
+fn linker_writes_dylib_with_real_text_section() {
208
+    if !have_xcrun() {
209
+        eprintln!("skipping: xcrun as unavailable");
210
+        return;
211
+    }
212
+
213
+    let obj = scratch("fixture-dylib.o");
214
+    let out = scratch("libfixture.dylib");
215
+    if let Err(e) = assemble(fixture_source(), &obj) {
216
+        eprintln!("skipping: assemble failed: {e}");
217
+        return;
218
+    }
219
+    link_with_afs_ld(&["-dylib", obj.to_str().unwrap(), "-o", out.to_str().unwrap()])
220
+        .expect("link dylib");
221
+
222
+    let bytes = fs::read(&out).expect("read dylib");
223
+    let hdr = parse_header(&bytes).expect("parse header");
224
+    assert_eq!(hdr.filetype, MH_DYLIB);
225
+    let cmds = parse_commands(&hdr, &bytes).expect("parse commands");
226
+
227
+    let text = section(&cmds, "__TEXT", "__text");
228
+    assert_eq!(
229
+        &bytes[text.offset as usize..text.offset as usize + text.size as usize],
230
+        &[0xc0, 0x03, 0x5f, 0xd6]
231
+    );
232
+
233
+    let _ = fs::remove_file(&obj);
234
+    let _ = fs::remove_file(&out);
235
+}
236
+
237
+#[test]
238
+fn linker_emits_load_dylib_for_direct_dependency_input() {
239
+    if !have_xcrun() || !have_clang() {
240
+        eprintln!("skipping: xcrun as / clang unavailable");
241
+        return;
242
+    }
243
+
244
+    let obj = scratch("dep-main.o");
245
+    let dep = scratch("libdep.dylib");
246
+    let out = scratch("dep-linked");
247
+    if let Err(e) = assemble(
248
+        r#"
249
+            .section __TEXT,__text,regular,pure_instructions
250
+            .globl _main
251
+            _main:
252
+                ret
253
+        "#,
254
+        &obj,
255
+    ) {
256
+        eprintln!("skipping: assemble failed: {e}");
257
+        return;
258
+    }
259
+    if let Err(e) = build_test_dylib(
260
+        "int afsld_dep(void) { return 7; }\n",
261
+        &dep,
262
+        "@rpath/libafslddep.dylib",
263
+    ) {
264
+        eprintln!("skipping: clang failed: {e}");
265
+        return;
266
+    }
267
+
268
+    link_with_afs_ld(&[
269
+        obj.to_str().unwrap(),
270
+        dep.to_str().unwrap(),
271
+        "-o",
272
+        out.to_str().unwrap(),
273
+    ])
274
+    .expect("link executable with dylib");
275
+
276
+    let bytes = fs::read(&out).expect("read executable");
277
+    let hdr = parse_header(&bytes).expect("parse header");
278
+    let cmds = parse_commands(&hdr, &bytes).expect("parse commands");
279
+    assert!(cmds.iter().any(|cmd| {
280
+        matches!(
281
+            cmd,
282
+            LoadCommand::Dylib(d)
283
+                if d.cmd == LC_LOAD_DYLIB && d.name == "@rpath/libafslddep.dylib"
284
+        )
285
+    }));
286
+
287
+    let _ = fs::remove_file(&obj);
288
+    let _ = fs::remove_file(&dep);
289
+    let _ = fs::remove_file(&out);
290
+}
291
+
292
+#[test]
293
+fn linker_emits_rpath_load_command() {
294
+    if !have_xcrun() {
295
+        eprintln!("skipping: xcrun as unavailable");
296
+        return;
297
+    }
298
+
299
+    let obj = scratch("rpath-main.o");
300
+    let out = scratch("rpath-linked");
301
+    if let Err(e) = assemble(
302
+        r#"
303
+            .section __TEXT,__text,regular,pure_instructions
304
+            .globl _main
305
+            _main:
306
+                ret
307
+        "#,
308
+        &obj,
309
+    ) {
310
+        eprintln!("skipping: assemble failed: {e}");
311
+        return;
312
+    }
313
+
314
+    link_with_afs_ld(&[
315
+        obj.to_str().unwrap(),
316
+        "-rpath",
317
+        "@executable_path/../lib",
318
+        "-o",
319
+        out.to_str().unwrap(),
320
+    ])
321
+    .expect("link executable with rpath");
322
+
323
+    let bytes = fs::read(&out).expect("read executable");
324
+    let hdr = parse_header(&bytes).expect("parse header");
325
+    let cmds = parse_commands(&hdr, &bytes).expect("parse commands");
326
+    assert!(cmds.iter().any(|cmd| {
327
+        matches!(
328
+            cmd,
329
+            LoadCommand::Rpath(r) if r.path == "@executable_path/../lib"
330
+        )
331
+    }));
332
+
333
+    let _ = fs::remove_file(&obj);
334
+    let _ = fs::remove_file(&out);
335
+}
336
+
337
+#[test]
338
+fn linker_emits_uuid_and_source_version_commands() {
339
+    if !have_xcrun() {
340
+        eprintln!("skipping: xcrun as unavailable");
341
+        return;
342
+    }
343
+
344
+    let obj = scratch("uuid-main.o");
345
+    let out = scratch("uuid-linked");
346
+    if let Err(e) = assemble(
347
+        r#"
348
+            .section __TEXT,__text,regular,pure_instructions
349
+            .globl _main
350
+            _main:
351
+                ret
352
+        "#,
353
+        &obj,
354
+    ) {
355
+        eprintln!("skipping: assemble failed: {e}");
356
+        return;
357
+    }
358
+
359
+    link_with_afs_ld(&[obj.to_str().unwrap(), "-o", out.to_str().unwrap()])
360
+        .expect("link executable with metadata");
361
+
362
+    let bytes = fs::read(&out).expect("read executable");
363
+    let hdr = parse_header(&bytes).expect("parse header");
364
+    let ids: Vec<u32> = parse_commands(&hdr, &bytes)
365
+        .expect("parse commands")
366
+        .into_iter()
367
+        .map(|cmd| match cmd {
368
+            LoadCommand::Raw { cmd, .. } => cmd,
369
+            other => other.cmd(),
370
+        })
371
+        .collect();
372
+    assert!(ids.contains(&LC_UUID));
373
+    assert!(ids.contains(&LC_SOURCE_VERSION));
374
+
375
+    let _ = fs::remove_file(&obj);
376
+    let _ = fs::remove_file(&out);
377
+}
tests/load_command_parity.rsadded
356 lines changed — click to load
@@ -0,0 +1,356 @@
1
+use std::fs;
2
+use std::path::{Path, PathBuf};
3
+use std::process::Command;
4
+
5
+use afs_ld::macho::constants::*;
6
+use afs_ld::macho::reader::{parse_commands, parse_header, LoadCommand};
7
+
8
+fn have_xcrun() -> bool {
9
+    Command::new("xcrun")
10
+        .arg("-f")
11
+        .arg("as")
12
+        .output()
13
+        .map(|o| o.status.success())
14
+        .unwrap_or(false)
15
+}
16
+
17
+fn have_clang() -> bool {
18
+    Command::new("xcrun")
19
+        .arg("-f")
20
+        .arg("clang")
21
+        .output()
22
+        .map(|o| o.status.success())
23
+        .unwrap_or(false)
24
+}
25
+
26
+fn have_ld() -> bool {
27
+    Command::new("xcrun")
28
+        .arg("-f")
29
+        .arg("ld")
30
+        .output()
31
+        .map(|o| o.status.success())
32
+        .unwrap_or(false)
33
+}
34
+
35
+fn sdk_path() -> Option<String> {
36
+    Command::new("xcrun")
37
+        .args(["--sdk", "macosx", "--show-sdk-path"])
38
+        .output()
39
+        .ok()
40
+        .filter(|out| out.status.success())
41
+        .and_then(|out| String::from_utf8(out.stdout).ok())
42
+        .map(|text| text.trim().to_string())
43
+        .filter(|text| !text.is_empty())
44
+}
45
+
46
+fn scratch(name: &str) -> PathBuf {
47
+    std::env::temp_dir().join(format!("afs-ld-load-order-{}-{name}", std::process::id()))
48
+}
49
+
50
+fn assemble(src_text: &str, out: &Path) -> Result<(), String> {
51
+    let tmp = std::env::temp_dir().join(format!(
52
+        "afs-ld-load-order-{}-{}.s",
53
+        std::process::id(),
54
+        out.file_stem().and_then(|s| s.to_str()).unwrap_or("t")
55
+    ));
56
+    fs::write(&tmp, src_text).map_err(|e| format!("write .s: {e}"))?;
57
+    let status = Command::new("xcrun")
58
+        .args(["--sdk", "macosx", "as", "-arch", "arm64"])
59
+        .arg(&tmp)
60
+        .arg("-o")
61
+        .arg(out)
62
+        .output()
63
+        .map_err(|e| format!("spawn xcrun as: {e}"))?;
64
+    let _ = fs::remove_file(&tmp);
65
+    if !status.status.success() {
66
+        return Err(format!(
67
+            "xcrun as failed: {}",
68
+            String::from_utf8_lossy(&status.stderr)
69
+        ));
70
+    }
71
+    Ok(())
72
+}
73
+
74
+fn build_test_dylib(src: &str, out: &Path, install_name: &str) -> Result<(), String> {
75
+    let mut child = Command::new("xcrun")
76
+        .args([
77
+            "--sdk", "macosx", "clang", "-x", "c", "-arch", "arm64", "-shared", "-o",
78
+        ])
79
+        .arg(out)
80
+        .arg("-install_name")
81
+        .arg(install_name)
82
+        .arg("-")
83
+        .stdin(std::process::Stdio::piped())
84
+        .stdout(std::process::Stdio::piped())
85
+        .stderr(std::process::Stdio::piped())
86
+        .spawn()
87
+        .map_err(|e| format!("spawn clang: {e}"))?;
88
+    use std::io::Write;
89
+    child
90
+        .stdin
91
+        .as_mut()
92
+        .unwrap()
93
+        .write_all(src.as_bytes())
94
+        .map_err(|e| format!("write clang stdin: {e}"))?;
95
+    let out = child.wait_with_output().map_err(|e| format!("wait: {e}"))?;
96
+    if !out.status.success() {
97
+        return Err(format!(
98
+            "clang failed: {}",
99
+            String::from_utf8_lossy(&out.stderr)
100
+        ));
101
+    }
102
+    Ok(())
103
+}
104
+
105
+fn link_with_afs_ld(args: &[&str]) -> Result<(), String> {
106
+    let exe = env!("CARGO_BIN_EXE_afs-ld");
107
+    let out = Command::new(exe)
108
+        .args(args)
109
+        .output()
110
+        .map_err(|e| format!("spawn afs-ld: {e}"))?;
111
+    if !out.status.success() {
112
+        return Err(format!(
113
+            "afs-ld failed: {}\n{}",
114
+            out.status,
115
+            String::from_utf8_lossy(&out.stderr)
116
+        ));
117
+    }
118
+    Ok(())
119
+}
120
+
121
+fn command_ids(path: &Path) -> Vec<u32> {
122
+    let bytes = fs::read(path).expect("read mach-o");
123
+    let hdr = parse_header(&bytes).expect("parse header");
124
+    parse_commands(&hdr, &bytes)
125
+        .expect("parse commands")
126
+        .into_iter()
127
+        .map(|cmd| match cmd {
128
+            LoadCommand::Raw { cmd, .. } => cmd,
129
+            other => other.cmd(),
130
+        })
131
+        .collect()
132
+}
133
+
134
+fn normalize(ids: &[u32]) -> Vec<&'static str> {
135
+    let mut out = Vec::new();
136
+    for token in ids.iter().filter_map(|cmd| match *cmd {
137
+        LC_SEGMENT_64 => Some("SEGMENT"),
138
+        LC_DYLD_INFO_ONLY | LC_DYLD_CHAINED_FIXUPS | LC_DYLD_EXPORTS_TRIE => Some("FIXUPS"),
139
+        LC_SYMTAB => Some("SYMTAB"),
140
+        LC_DYSYMTAB => Some("DYSYMTAB"),
141
+        LC_LOAD_DYLINKER => Some("LOAD_DYLINKER"),
142
+        LC_UUID => Some("UUID"),
143
+        LC_BUILD_VERSION => Some("BUILD_VERSION"),
144
+        LC_SOURCE_VERSION => Some("SOURCE_VERSION"),
145
+        LC_MAIN => Some("MAIN"),
146
+        LC_ID_DYLIB => Some("ID_DYLIB"),
147
+        LC_LOAD_DYLIB => Some("LOAD_DYLIB"),
148
+        LC_RPATH => Some("RPATH"),
149
+        LC_FUNCTION_STARTS => Some("FUNCTION_STARTS"),
150
+        LC_DATA_IN_CODE => Some("DATA_IN_CODE"),
151
+        LC_CODE_SIGNATURE => Some("CODE_SIGNATURE"),
152
+        _ => None,
153
+    }) {
154
+        if out.last().copied() == Some(token) && token == "FIXUPS" {
155
+            continue;
156
+        }
157
+        out.push(token);
158
+    }
159
+    out
160
+}
161
+
162
+#[test]
163
+fn executable_load_command_order_matches_apple_for_common_surface() {
164
+    if !have_xcrun() || !have_ld() {
165
+        eprintln!("skipping: xcrun as / ld unavailable");
166
+        return;
167
+    }
168
+    let Some(sdk) = sdk_path() else {
169
+        eprintln!("skipping: xcrun --show-sdk-path unavailable");
170
+        return;
171
+    };
172
+
173
+    let obj = scratch("main.o");
174
+    let ours = scratch("ours-exec");
175
+    let theirs = scratch("apple-exec");
176
+    assemble(
177
+        r#"
178
+            .section __TEXT,__text,regular,pure_instructions
179
+            .globl _main
180
+            _main:
181
+                ret
182
+        "#,
183
+        &obj,
184
+    )
185
+    .expect("assemble");
186
+    link_with_afs_ld(&[
187
+        "-syslibroot",
188
+        &sdk,
189
+        "-lSystem",
190
+        obj.to_str().unwrap(),
191
+        "-o",
192
+        ours.to_str().unwrap(),
193
+    ])
194
+    .expect("afs-ld");
195
+    let status = Command::new("xcrun")
196
+        .args([
197
+            "ld",
198
+            "-arch",
199
+            "arm64",
200
+            "-syslibroot",
201
+            &sdk,
202
+            "-lSystem",
203
+            "-e",
204
+            "_main",
205
+            "-o",
206
+        ])
207
+        .arg(&theirs)
208
+        .arg(&obj)
209
+        .status()
210
+        .expect("spawn ld");
211
+    assert!(status.success(), "ld link failed");
212
+
213
+    assert_eq!(
214
+        normalize(&command_ids(&ours)),
215
+        normalize(&command_ids(&theirs))
216
+    );
217
+
218
+    let _ = fs::remove_file(&obj);
219
+    let _ = fs::remove_file(&ours);
220
+    let _ = fs::remove_file(&theirs);
221
+}
222
+
223
+#[test]
224
+fn dylib_load_command_order_matches_apple_for_common_surface() {
225
+    if !have_xcrun() || !have_ld() {
226
+        eprintln!("skipping: xcrun as / ld unavailable");
227
+        return;
228
+    }
229
+    let Some(sdk) = sdk_path() else {
230
+        eprintln!("skipping: xcrun --show-sdk-path unavailable");
231
+        return;
232
+    };
233
+
234
+    let obj = scratch("lib.o");
235
+    let ours = scratch("ours.dylib");
236
+    let theirs = scratch("apple.dylib");
237
+    assemble(
238
+        r#"
239
+            .section __TEXT,__text,regular,pure_instructions
240
+            .globl _answer
241
+            _answer:
242
+                ret
243
+        "#,
244
+        &obj,
245
+    )
246
+    .expect("assemble");
247
+    link_with_afs_ld(&[
248
+        "-dylib",
249
+        "-syslibroot",
250
+        &sdk,
251
+        "-lSystem",
252
+        obj.to_str().unwrap(),
253
+        "-o",
254
+        ours.to_str().unwrap(),
255
+    ])
256
+    .expect("afs-ld dylib");
257
+    let status = Command::new("xcrun")
258
+        .args([
259
+            "ld",
260
+            "-dylib",
261
+            "-arch",
262
+            "arm64",
263
+            "-syslibroot",
264
+            &sdk,
265
+            "-lSystem",
266
+            "-install_name",
267
+            "@rpath/libparity.dylib",
268
+            "-o",
269
+        ])
270
+        .arg(&theirs)
271
+        .arg(&obj)
272
+        .status()
273
+        .expect("spawn ld");
274
+    assert!(status.success(), "ld dylib link failed");
275
+
276
+    assert_eq!(
277
+        normalize(&command_ids(&ours)),
278
+        normalize(&command_ids(&theirs))
279
+    );
280
+
281
+    let _ = fs::remove_file(&obj);
282
+    let _ = fs::remove_file(&ours);
283
+    let _ = fs::remove_file(&theirs);
284
+}
285
+
286
+#[test]
287
+fn executable_load_command_order_with_dependency_and_rpath_matches_common_surface() {
288
+    if !have_xcrun() || !have_clang() || !have_ld() {
289
+        eprintln!("skipping: xcrun as / clang / ld unavailable");
290
+        return;
291
+    }
292
+    let Some(sdk) = sdk_path() else {
293
+        eprintln!("skipping: xcrun --show-sdk-path unavailable");
294
+        return;
295
+    };
296
+
297
+    let obj = scratch("dep-main.o");
298
+    let dep = scratch("dep.dylib");
299
+    let ours = scratch("ours-dep");
300
+    let theirs = scratch("apple-dep");
301
+    assemble(
302
+        r#"
303
+            .section __TEXT,__text,regular,pure_instructions
304
+            .globl _main
305
+            _main:
306
+                ret
307
+        "#,
308
+        &obj,
309
+    )
310
+    .expect("assemble");
311
+    build_test_dylib("int dep(void) { return 1; }\n", &dep, "@rpath/libdep.dylib")
312
+        .expect("build dylib");
313
+    link_with_afs_ld(&[
314
+        "-syslibroot",
315
+        &sdk,
316
+        "-lSystem",
317
+        obj.to_str().unwrap(),
318
+        dep.to_str().unwrap(),
319
+        "-rpath",
320
+        "@executable_path/../lib",
321
+        "-o",
322
+        ours.to_str().unwrap(),
323
+    ])
324
+    .expect("afs-ld with dep");
325
+
326
+    let status = Command::new("xcrun")
327
+        .args([
328
+            "ld",
329
+            "-arch",
330
+            "arm64",
331
+            "-syslibroot",
332
+            &sdk,
333
+            "-lSystem",
334
+            "-e",
335
+            "_main",
336
+            "-o",
337
+        ])
338
+        .arg(&theirs)
339
+        .arg(&obj)
340
+        .arg(&dep)
341
+        .arg("-rpath")
342
+        .arg("@executable_path/../lib")
343
+        .status()
344
+        .expect("spawn ld");
345
+    assert!(status.success(), "ld dep link failed");
346
+
347
+    assert_eq!(
348
+        normalize(&command_ids(&ours)),
349
+        normalize(&command_ids(&theirs))
350
+    );
351
+
352
+    let _ = fs::remove_file(&obj);
353
+    let _ = fs::remove_file(&dep);
354
+    let _ = fs::remove_file(&ours);
355
+    let _ = fs::remove_file(&theirs);
356
+}
tests/parity_canary.rsadded
43 lines changed — click to load
@@ -0,0 +1,43 @@
1
+//! Intentional-regression guardrails for Sprint 27.
2
+
3
+mod common;
4
+
5
+use std::path::PathBuf;
6
+
7
+use common::harness::{
8
+    diff_macho, have_xcrun, have_xcrun_tool, link_both, load_corpus, output_section,
9
+};
10
+
11
+#[test]
12
+fn mutated_text_byte_is_not_tolerated() {
13
+    if !have_xcrun() || !have_xcrun_tool("ld") {
14
+        eprintln!("skipping: xcrun as/ld unavailable");
15
+        return;
16
+    }
17
+
18
+    let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
19
+        .join("tests")
20
+        .join("parity_corpus");
21
+    let case = load_corpus(&root)
22
+        .expect("load parity corpus")
23
+        .into_iter()
24
+        .find(|case| case.name == "hello_classic")
25
+        .expect("hello_classic parity case");
26
+
27
+    let outputs = link_both(&case).expect("link hello_classic with both linkers");
28
+    let (_, mut our_text) =
29
+        output_section(&outputs.ours, "__TEXT", "__text").expect("afs-ld __TEXT,__text");
30
+    let (_, their_text) =
31
+        output_section(&outputs.theirs, "__TEXT", "__text").expect("Apple __TEXT,__text");
32
+
33
+    our_text[0] ^= 0x1;
34
+    let report = diff_macho(&our_text, &their_text);
35
+    assert!(
36
+        !report.is_clean(),
37
+        "mutated text byte should be reported as critical: {report:#?}"
38
+    );
39
+    assert!(
40
+        !report.critical.is_empty(),
41
+        "expected at least one critical diff after mutation: {report:#?}"
42
+    );
43
+}
tests/parity_corpus/archive_order_exec/args.txtadded
17 lines changed — click to load
@@ -0,0 +1,17 @@
1
+-arch
2
+arm64
3
+-platform_version
4
+macos
5
+@SDK_VERSION@
6
+@SDK_VERSION@
7
+-syslibroot
8
+@SDK_PATH@
9
+-lSystem
10
+-e
11
+_main
12
+-no_fixup_chains
13
+-o
14
+@OUT@
15
+@INPUT:main.o@
16
+@ARTIFACT:liba.a@
17
+@ARTIFACT:libb.a@
tests/parity_corpus/archive_order_exec/artifacts.txtadded
2 lines changed — click to load
@@ -0,0 +1,2 @@
1
+clang_archive a.c liba.a
2
+clang_archive b.c libb.a
tests/parity_corpus/archive_order_exec/command_checks.txtadded
2 lines changed — click to load
@@ -0,0 +1,2 @@
1
+build_version
2
+load_dylib_names
tests/parity_corpus/archive_order_exec/inputs/a.cadded
5 lines changed — click to load
@@ -0,0 +1,5 @@
1
+int mid(void);
2
+
3
+int top(void) {
4
+    return mid();
5
+}
tests/parity_corpus/archive_order_exec/inputs/b.cadded
3 lines changed — click to load
@@ -0,0 +1,3 @@
1
+int mid(void) {
2
+    return 17;
3
+}
tests/parity_corpus/archive_order_exec/inputs/main.cadded
5 lines changed — click to load
@@ -0,0 +1,5 @@
1
+int top(void);
2
+
3
+int main(void) {
4
+    return top() == 17 ? 0 : 1;
5
+}
tests/parity_corpus/archive_order_exec/notes.mdadded
3 lines changed — click to load
@@ -0,0 +1,3 @@
1
+Multi-archive resolution parity case. `main` pulls `top` from the first archive,
2
+and that object in turn requires `mid` from the second archive, so archive
3
+fetch order must match Apple `ld`.
tests/parity_corpus/archive_order_exec/runtime.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+
tests/parity_corpus/archive_order_exec/sections.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+__TEXT __text
tests/parity_corpus/backtrace_metadata_exec/args.txtadded
15 lines changed — click to load
@@ -0,0 +1,15 @@
1
+-arch
2
+arm64
3
+-platform_version
4
+macos
5
+@SDK_VERSION@
6
+@SDK_VERSION@
7
+-syslibroot
8
+@SDK_PATH@
9
+-lSystem
10
+-no_fixup_chains
11
+-e
12
+_main
13
+-o
14
+@OUT@
15
+@INPUT:main.o@
tests/parity_corpus/backtrace_metadata_exec/command_checks.txtadded
4 lines changed — click to load
@@ -0,0 +1,4 @@
1
+build_version
2
+load_dylib_names
3
+rebased_unwind_bytes
4
+normalized_function_starts
tests/parity_corpus/backtrace_metadata_exec/ignored_load_commands.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+LC_LOAD_DYLIB
tests/parity_corpus/backtrace_metadata_exec/inputs/main.cadded
18 lines changed — click to load
@@ -0,0 +1,18 @@
1
+#include <unwind.h>
2
+
3
+static _Unwind_Reason_Code cb(struct _Unwind_Context* ctx, void* arg) {
4
+    (void)ctx;
5
+    int* count = (int*)arg;
6
+    (*count)++;
7
+    return *count >= 8 ? _URC_END_OF_STACK : _URC_NO_REASON;
8
+}
9
+
10
+__attribute__((noinline)) int helper(void) {
11
+    int count = 0;
12
+    _Unwind_Backtrace(cb, &count);
13
+    return count;
14
+}
15
+
16
+int main(void) {
17
+    return helper() > 1 ? 0 : 1;
18
+}
tests/parity_corpus/backtrace_metadata_exec/notes.mdadded
3 lines changed — click to load
@@ -0,0 +1,3 @@
1
+Backtrace-metadata parity case lifted from the existing Apple `ld` probe. This
2
+keeps the Sprint 27 corpus honest about unwind bytes and normalized function
3
+starts for a real `_Unwind_Backtrace` executable.
tests/parity_corpus/backtrace_metadata_exec/sections.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+
tests/parity_corpus/classic_lazy_batched_got_calls/args.txtadded
15 lines changed — click to load
@@ -0,0 +1,15 @@
1
+-arch
2
+arm64
3
+-platform_version
4
+macos
5
+@SDK_VERSION@
6
+@SDK_VERSION@
7
+-syslibroot
8
+@SDK_PATH@
9
+-lSystem
10
+-e
11
+_main
12
+-no_fixup_chains
13
+-o
14
+@OUT@
15
+@INPUT:main.o@
tests/parity_corpus/classic_lazy_batched_got_calls/command_checks.txtadded
6 lines changed — click to load
@@ -0,0 +1,6 @@
1
+build_version
2
+load_dylib_names
3
+dyld_info_rebase
4
+dyld_info_bind
5
+dyld_info_weak_bind
6
+dyld_info_lazy_bind
tests/parity_corpus/classic_lazy_batched_got_calls/ignored_load_commands.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+LC_LOAD_DYLIB
tests/parity_corpus/classic_lazy_batched_got_calls/inputs/main.sadded
14 lines changed — click to load
@@ -0,0 +1,14 @@
1
+        .section __TEXT,__text,regular,pure_instructions
2
+        .globl _main
3
+        _main:
4
+            adrp x0, _write@GOTPAGE
5
+            ldr x0, [x0, _write@GOTPAGEOFF]
6
+            bl _write
7
+            adrp x1, _close@GOTPAGE
8
+            ldr x1, [x1, _close@GOTPAGEOFF]
9
+            bl _close
10
+            adrp x2, _read@GOTPAGE
11
+            ldr x2, [x2, _read@GOTPAGEOFF]
12
+            bl _read
13
+            ret
14
+        .subsections_via_symbols
tests/parity_corpus/classic_lazy_batched_got_calls/notes.mdadded
4 lines changed — click to load
@@ -0,0 +1,4 @@
1
+Classic lazy-binding multi-import parity case lifted from the existing
2
+`linker_run` matrix. This extends the Sprint 27 corpus around multiple distinct
3
+lazy imports in one executable while keeping the Apple-parity dyld-info and
4
+stub-surface checks in the dedicated harness.
tests/parity_corpus/classic_lazy_batched_got_calls/sections.txtadded
2 lines changed — click to load
@@ -0,0 +1,2 @@
1
+__TEXT __stubs
2
+__TEXT __stub_helper
tests/parity_corpus/classic_lazy_branch_calls/args.txtadded
15 lines changed — click to load
@@ -0,0 +1,15 @@
1
+-arch
2
+arm64
3
+-platform_version
4
+macos
5
+@SDK_VERSION@
6
+@SDK_VERSION@
7
+-syslibroot
8
+@SDK_PATH@
9
+-lSystem
10
+-e
11
+_main
12
+-no_fixup_chains
13
+-o
14
+@OUT@
15
+@INPUT:main.o@
tests/parity_corpus/classic_lazy_branch_calls/command_checks.txtadded
2 lines changed — click to load
@@ -0,0 +1,2 @@
1
+build_version
2
+load_dylib_names
tests/parity_corpus/classic_lazy_branch_calls/ignored_load_commands.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+LC_LOAD_DYLIB
tests/parity_corpus/classic_lazy_branch_calls/inputs/main.sadded
8 lines changed — click to load
@@ -0,0 +1,8 @@
1
+.section __TEXT,__text,regular,pure_instructions
2
+.globl _main
3
+_main:
4
+    bl _write
5
+    bl _close
6
+    bl _read
7
+    ret
8
+.subsections_via_symbols
tests/parity_corpus/classic_lazy_branch_calls/notes.mdadded
4 lines changed — click to load
@@ -0,0 +1,4 @@
1
+Classic lazy-binding executable parity case pulled from the existing
2
+`linker_run` matrix. This verifies the dedicated Sprint 27 harness can compare
3
+import-stub surfaces and dylib load-command contents, not just minimal hello
4
+worlds.
tests/parity_corpus/classic_lazy_branch_calls/sections.txtadded
2 lines changed — click to load
@@ -0,0 +1,2 @@
1
+__TEXT __stubs
2
+__TEXT __stub_helper
tests/parity_corpus/classic_lazy_branch_only_calls/args.txtadded
15 lines changed — click to load
@@ -0,0 +1,15 @@
1
+-arch
2
+arm64
3
+-platform_version
4
+macos
5
+@SDK_VERSION@
6
+@SDK_VERSION@
7
+-syslibroot
8
+@SDK_PATH@
9
+-lSystem
10
+-e
11
+_main
12
+-no_fixup_chains
13
+-o
14
+@OUT@
15
+@INPUT:main.o@
tests/parity_corpus/classic_lazy_branch_only_calls/command_checks.txtadded
6 lines changed — click to load
@@ -0,0 +1,6 @@
1
+build_version
2
+load_dylib_names
3
+dyld_info_rebase
4
+dyld_info_bind
5
+dyld_info_weak_bind
6
+dyld_info_lazy_bind
tests/parity_corpus/classic_lazy_branch_only_calls/ignored_load_commands.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+LC_LOAD_DYLIB
tests/parity_corpus/classic_lazy_branch_only_calls/inputs/main.sadded
8 lines changed — click to load
@@ -0,0 +1,8 @@
1
+        .section __TEXT,__text,regular,pure_instructions
2
+        .globl _main
3
+        _main:
4
+            bl _write
5
+            bl _close
6
+            bl _read
7
+            ret
8
+        .subsections_via_symbols
tests/parity_corpus/classic_lazy_branch_only_calls/notes.mdadded
4 lines changed — click to load
@@ -0,0 +1,4 @@
1
+Classic lazy-binding branch-only parity case lifted from the existing
2
+`linker_run` matrix. This rounds out the Sprint 27 classic-lazy corpus by
3
+covering pure branch-driven imports without explicit GOT loads, while keeping
4
+the Apple-parity dyld-info and stub-surface checks in the dedicated harness.
tests/parity_corpus/classic_lazy_branch_only_calls/sections.txtadded
2 lines changed — click to load
@@ -0,0 +1,2 @@
1
+__TEXT __stubs
2
+__TEXT __stub_helper
tests/parity_corpus/classic_lazy_deduped_import/args.txtadded
15 lines changed — click to load
@@ -0,0 +1,15 @@
1
+-arch
2
+arm64
3
+-platform_version
4
+macos
5
+@SDK_VERSION@
6
+@SDK_VERSION@
7
+-syslibroot
8
+@SDK_PATH@
9
+-lSystem
10
+-e
11
+_main
12
+-no_fixup_chains
13
+-o
14
+@OUT@
15
+@INPUT:main.o@
tests/parity_corpus/classic_lazy_deduped_import/command_checks.txtadded
6 lines changed — click to load
@@ -0,0 +1,6 @@
1
+build_version
2
+load_dylib_names
3
+dyld_info_rebase
4
+dyld_info_bind
5
+dyld_info_weak_bind
6
+dyld_info_lazy_bind
tests/parity_corpus/classic_lazy_deduped_import/ignored_load_commands.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+LC_LOAD_DYLIB
tests/parity_corpus/classic_lazy_deduped_import/inputs/main.sadded
11 lines changed — click to load
@@ -0,0 +1,11 @@
1
+        .section __TEXT,__text,regular,pure_instructions
2
+        .globl _main
3
+        _main:
4
+            adrp x0, _write@GOTPAGE
5
+            ldr x0, [x0, _write@GOTPAGEOFF]
6
+            bl _write
7
+            bl _write
8
+            adrp x1, _write@GOTPAGE
9
+            ldr x1, [x1, _write@GOTPAGEOFF]
10
+            ret
11
+        .subsections_via_symbols
tests/parity_corpus/classic_lazy_deduped_import/notes.mdadded
4 lines changed — click to load
@@ -0,0 +1,4 @@
1
+Classic lazy-binding dedupe parity case lifted from the existing `linker_run`
2
+matrix. This strengthens the Sprint 27 corpus around repeated imports of the
3
+same symbol, while still comparing the Apple-parity dyld-info streams and stub
4
+surfaces instead of only command IDs.
tests/parity_corpus/classic_lazy_deduped_import/sections.txtadded
2 lines changed — click to load
@@ -0,0 +1,2 @@
1
+__TEXT __stubs
2
+__TEXT __stub_helper
tests/parity_corpus/common_symbol_promotion_exec/args.txtadded
17 lines changed — click to load
@@ -0,0 +1,17 @@
1
+-arch
2
+arm64
3
+-platform_version
4
+macos
5
+@SDK_VERSION@
6
+@SDK_VERSION@
7
+-syslibroot
8
+@SDK_PATH@
9
+-lSystem
10
+-e
11
+_main
12
+-no_fixup_chains
13
+-o
14
+@OUT@
15
+@INPUT:main.o@
16
+@INPUT:a.o@
17
+@INPUT:b.o@
tests/parity_corpus/common_symbol_promotion_exec/command_checks.txtadded
2 lines changed — click to load
@@ -0,0 +1,2 @@
1
+build_version
2
+load_dylib_names
tests/parity_corpus/common_symbol_promotion_exec/ignored_load_commands.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+LC_SEGMENT_64
tests/parity_corpus/common_symbol_promotion_exec/inputs/a.sadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+.comm _shared,8,3
tests/parity_corpus/common_symbol_promotion_exec/inputs/b.sadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+.comm _shared,8,3
tests/parity_corpus/common_symbol_promotion_exec/inputs/main.sadded
6 lines changed — click to load
@@ -0,0 +1,6 @@
1
+.text
2
+.globl _main
3
+.p2align 2
4
+_main:
5
+    mov w0, #0
6
+    ret
tests/parity_corpus/common_symbol_promotion_exec/notes.mdadded
3 lines changed — click to load
@@ -0,0 +1,3 @@
1
+Common-symbol promotion parity case. Two objects contribute the same `.comm`
2
+symbol, and the linked executable should promote that storage into the final
3
+image and record it the same way Apple `ld` does.
tests/parity_corpus/common_symbol_promotion_exec/runtime.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+
tests/parity_corpus/common_symbol_promotion_exec/sections.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+__TEXT __text
tests/parity_corpus/data_in_code_exec/args.txtadded
15 lines changed — click to load
@@ -0,0 +1,15 @@
1
+-arch
2
+arm64
3
+-platform_version
4
+macos
5
+@SDK_VERSION@
6
+@SDK_VERSION@
7
+-syslibroot
8
+@SDK_PATH@
9
+-lSystem
10
+-no_fixup_chains
11
+-e
12
+_main
13
+-o
14
+@OUT@
15
+@INPUT:main.o@
tests/parity_corpus/data_in_code_exec/command_checks.txtadded
3 lines changed — click to load
@@ -0,0 +1,3 @@
1
+build_version
2
+load_dylib_names
3
+data_in_code_if_present
tests/parity_corpus/data_in_code_exec/ignored_load_commands.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+LC_LOAD_DYLIB
tests/parity_corpus/data_in_code_exec/inputs/main.sadded
23 lines changed — click to load
@@ -0,0 +1,23 @@
1
+.text
2
+.globl _main
3
+.p2align 2
4
+_main:
5
+    mov w0, #0
6
+    b Ldispatch
7
+    .p2align 2
8
+Ltable:
9
+    .data_region jt32
10
+    .long Lcase0-Ltable
11
+    .long Lcase1-Ltable
12
+    .end_data_region
13
+Ldispatch:
14
+    cmp w0, #0
15
+    b.eq Lcase0
16
+    b Lcase1
17
+Lcase0:
18
+    mov w0, #1
19
+    ret
20
+Lcase1:
21
+    mov w0, #2
22
+    ret
23
+.subsections_via_symbols
tests/parity_corpus/data_in_code_exec/notes.mdadded
3 lines changed — click to load
@@ -0,0 +1,3 @@
1
+Data-in-code parity case lifted from the existing Apple `ld` probe. This keeps
2
+the Sprint 27 corpus honest about remapping a jump-table record in the primary
3
+text section.
tests/parity_corpus/data_in_code_exec/sections.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+
tests/parity_corpus/data_in_code_large_first_exec/args.txtadded
15 lines changed — click to load
@@ -0,0 +1,15 @@
1
+-arch
2
+arm64
3
+-platform_version
4
+macos
5
+@SDK_VERSION@
6
+@SDK_VERSION@
7
+-syslibroot
8
+@SDK_PATH@
9
+-lSystem
10
+-no_fixup_chains
11
+-e
12
+_main
13
+-o
14
+@OUT@
15
+@INPUT:main.o@
tests/parity_corpus/data_in_code_large_first_exec/command_checks.txtadded
3 lines changed — click to load
@@ -0,0 +1,3 @@
1
+build_version
2
+load_dylib_names
3
+data_in_code_if_present
tests/parity_corpus/data_in_code_large_first_exec/ignored_load_commands.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+LC_LOAD_DYLIB
tests/parity_corpus/data_in_code_large_first_exec/inputs/main.sadded
28 lines changed — click to load
@@ -0,0 +1,28 @@
1
+.text
2
+.globl _main
3
+.p2align 2
4
+_main:
5
+    nop
6
+    nop
7
+    nop
8
+    nop
9
+    nop
10
+    ret
11
+
12
+.section __TEXT,__text2,regular,pure_instructions
13
+.globl _helper
14
+_helper:
15
+    b Ldispatch
16
+    .p2align 2
17
+Ltable:
18
+    .data_region jt32
19
+    .long Lcase0-Ltable
20
+    .long Lcase1-Ltable
21
+    .end_data_region
22
+Ldispatch:
23
+    ret
24
+Lcase0:
25
+    ret
26
+Lcase1:
27
+    ret
28
+.subsections_via_symbols
tests/parity_corpus/data_in_code_large_first_exec/notes.mdadded
3 lines changed — click to load
@@ -0,0 +1,3 @@
1
+Data-in-code parity case lifted from the existing Apple `ld` probe with a large
2
+first text section. This keeps the Sprint 27 corpus honest about remapping
3
+later jump-table records after earlier text growth.
tests/parity_corpus/data_in_code_large_first_exec/sections.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+
tests/parity_corpus/data_in_code_late_exec/args.txtadded
15 lines changed — click to load
@@ -0,0 +1,15 @@
1
+-arch
2
+arm64
3
+-platform_version
4
+macos
5
+@SDK_VERSION@
6
+@SDK_VERSION@
7
+-syslibroot
8
+@SDK_PATH@
9
+-lSystem
10
+-no_fixup_chains
11
+-e
12
+_main
13
+-o
14
+@OUT@
15
+@INPUT:main.o@
tests/parity_corpus/data_in_code_late_exec/command_checks.txtadded
3 lines changed — click to load
@@ -0,0 +1,3 @@
1
+build_version
2
+load_dylib_names
3
+data_in_code_if_present
tests/parity_corpus/data_in_code_late_exec/ignored_load_commands.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+LC_LOAD_DYLIB
tests/parity_corpus/data_in_code_late_exec/inputs/main.sadded
24 lines changed — click to load
@@ -0,0 +1,24 @@
1
+.text
2
+.globl _main
3
+.p2align 2
4
+_main:
5
+    ret
6
+
7
+.section __TEXT,__text2,regular,pure_instructions
8
+.globl _helper
9
+.p2align 2
10
+_helper:
11
+    b Ldispatch
12
+    .p2align 2
13
+Ltable:
14
+    .data_region jt32
15
+    .long Lcase0-Ltable
16
+    .long Lcase1-Ltable
17
+    .end_data_region
18
+Ldispatch:
19
+    ret
20
+Lcase0:
21
+    ret
22
+Lcase1:
23
+    ret
24
+.subsections_via_symbols
tests/parity_corpus/data_in_code_late_exec/notes.mdadded
3 lines changed — click to load
@@ -0,0 +1,3 @@
1
+Data-in-code parity case lifted from the existing Apple `ld` probe for a later
2
+text section. This keeps the Sprint 27 corpus honest about remapping jump-table
3
+records outside the primary text section.
tests/parity_corpus/data_in_code_late_exec/sections.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+
tests/parity_corpus/dead_strip_import_exec/absent_sections.txtadded
4 lines changed — click to load
@@ -0,0 +1,4 @@
1
+__TEXT __stubs
2
+__TEXT __stub_helper
3
+__DATA __la_symbol_ptr
4
+__DATA_CONST __got
tests/parity_corpus/dead_strip_import_exec/args.txtadded
16 lines changed — click to load
@@ -0,0 +1,16 @@
1
+-arch
2
+arm64
3
+-platform_version
4
+macos
5
+@SDK_VERSION@
6
+@SDK_VERSION@
7
+-syslibroot
8
+@SDK_PATH@
9
+-lSystem
10
+-e
11
+_main
12
+-dead_strip
13
+-no_fixup_chains
14
+-o
15
+@OUT@
16
+@INPUT:main.o@
tests/parity_corpus/dead_strip_import_exec/command_checks.txtadded
3 lines changed — click to load
@@ -0,0 +1,3 @@
1
+build_version
2
+load_dylib_names
3
+dyld_info_bind
tests/parity_corpus/dead_strip_import_exec/inputs/main.sadded
12 lines changed — click to load
@@ -0,0 +1,12 @@
1
+.section __TEXT,__text,regular,pure_instructions
2
+.globl _main
3
+_main:
4
+    mov w0, #0
5
+    ret
6
+
7
+.globl _unused
8
+_unused:
9
+    bl _puts
10
+    mov w0, #0
11
+    ret
12
+.subsections_via_symbols
tests/parity_corpus/dead_strip_import_exec/notes.mdadded
3 lines changed — click to load
@@ -0,0 +1,3 @@
1
+Dead-strip synthetic-import parity case lifted from the Sprint 23 closeout
2
+work. This keeps the Sprint 27 corpus honest about pruning unused synthetic
3
+import sections when the importing atom is dead-stripped.
tests/parity_corpus/dead_strip_import_exec/runtime.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+
tests/parity_corpus/dead_strip_import_exec/sections.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+
tests/parity_corpus/direct_bind_data/args.txtadded
16 lines changed — click to load
@@ -0,0 +1,16 @@
1
+-arch
2
+arm64
3
+-platform_version
4
+macos
5
+@SDK_VERSION@
6
+@SDK_VERSION@
7
+-syslibroot
8
+@SDK_PATH@
9
+-no_fixup_chains
10
+-e
11
+_main
12
+-o
13
+@OUT@
14
+@INPUT:main.o@
15
+@SDK_TBD:usr/lib/libSystem.tbd@
16
+@ARTIFACT:libdirect.dylib@
tests/parity_corpus/direct_bind_data/artifacts.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+clang_dylib libdirect.c libdirect.dylib
tests/parity_corpus/direct_bind_data/command_checks.txtadded
6 lines changed — click to load
@@ -0,0 +1,6 @@
1
+build_version
2
+load_dylib_names
3
+dyld_info_rebase
4
+dyld_info_bind
5
+dyld_info_weak_bind
6
+dyld_info_lazy_bind
tests/parity_corpus/direct_bind_data/inputs/libdirect.cadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+int ext_data = 5;
tests/parity_corpus/direct_bind_data/inputs/main.cadded
3 lines changed — click to load
@@ -0,0 +1,3 @@
1
+extern int ext_data;
2
+int *p = &ext_data;
3
+int main(void) { return *p == 5 ? 0 : 1; }
tests/parity_corpus/direct_bind_data/notes.mdadded
3 lines changed — click to load
@@ -0,0 +1,3 @@
1
+Direct-bind executable parity case lifted from the standalone data-import probe.
2
+This keeps the Sprint 27 corpus honest about direct imported-data pointers even
3
+when there is no paired imported function call.
tests/parity_corpus/direct_bind_data/runtime.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+
tests/parity_corpus/direct_bind_data/sections.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+
tests/parity_corpus/direct_bind_deduped/args.txtadded
16 lines changed — click to load
@@ -0,0 +1,16 @@
1
+-arch
2
+arm64
3
+-platform_version
4
+macos
5
+@SDK_VERSION@
6
+@SDK_VERSION@
7
+-syslibroot
8
+@SDK_PATH@
9
+-no_fixup_chains
10
+-e
11
+_main
12
+-o
13
+@OUT@
14
+@INPUT:main.o@
15
+@SDK_TBD:usr/lib/libSystem.tbd@
16
+@ARTIFACT:libdirect.dylib@
tests/parity_corpus/direct_bind_deduped/artifacts.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+clang_dylib libdirect.c libdirect.dylib
tests/parity_corpus/direct_bind_deduped/command_checks.txtadded
6 lines changed — click to load
@@ -0,0 +1,6 @@
1
+build_version
2
+load_dylib_names
3
+dyld_info_rebase
4
+dyld_info_bind
5
+dyld_info_weak_bind
6
+dyld_info_lazy_bind
tests/parity_corpus/direct_bind_deduped/inputs/libdirect.cadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+int ext_data = 5;
tests/parity_corpus/direct_bind_deduped/inputs/main.cadded
4 lines changed — click to load
@@ -0,0 +1,4 @@
1
+extern int ext_data;
2
+int *p = &ext_data;
3
+int *q = &ext_data;
4
+int main(void) { return (*p == 5 && *q == 5) ? 0 : 1; }
tests/parity_corpus/direct_bind_deduped/notes.mdadded
3 lines changed — click to load
@@ -0,0 +1,3 @@
1
+Direct-bind executable parity case lifted from the direct-bind fixture matrix.
2
+This keeps the Sprint 27 corpus honest about repeated direct references to the
3
+same imported data symbol.
tests/parity_corpus/direct_bind_deduped/runtime.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+
tests/parity_corpus/direct_bind_deduped/sections.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+
tests/parity_corpus/direct_bind_mixed/args.txtadded
16 lines changed — click to load
@@ -0,0 +1,16 @@
1
+-arch
2
+arm64
3
+-platform_version
4
+macos
5
+@SDK_VERSION@
6
+@SDK_VERSION@
7
+-syslibroot
8
+@SDK_PATH@
9
+-no_fixup_chains
10
+-e
11
+_main
12
+-o
13
+@OUT@
14
+@INPUT:main.o@
15
+@SDK_TBD:usr/lib/libSystem.tbd@
16
+@ARTIFACT:libdirect.dylib@
tests/parity_corpus/direct_bind_mixed/artifacts.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+clang_dylib libdirect.c libdirect.dylib
tests/parity_corpus/direct_bind_mixed/command_checks.txtadded
6 lines changed — click to load
@@ -0,0 +1,6 @@
1
+build_version
2
+load_dylib_names
3
+dyld_info_rebase
4
+dyld_info_bind
5
+dyld_info_weak_bind
6
+dyld_info_lazy_bind
tests/parity_corpus/direct_bind_mixed/ignored_load_commands.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+
tests/parity_corpus/direct_bind_mixed/inputs/libdirect.cadded
2 lines changed — click to load
@@ -0,0 +1,2 @@
1
+int ext_data = 5;
2
+int ext_fn(void) { return ext_data + 1; }
tests/parity_corpus/direct_bind_mixed/inputs/main.cadded
4 lines changed — click to load
@@ -0,0 +1,4 @@
1
+extern int ext_data;
2
+extern int ext_fn(void);
3
+int *p = &ext_data;
4
+int main(void) { return *p + ext_fn() == 11 ? 0 : 1; }
tests/parity_corpus/direct_bind_mixed/notes.mdadded
3 lines changed — click to load
@@ -0,0 +1,3 @@
1
+Direct-bind executable parity case lifted from the existing fixture matrix.
2
+This extends the Sprint 27 corpus to handle compiled dylib side artifacts and
3
+checks the classic dyld-info streams Apple `ld` emits for direct imports.
tests/parity_corpus/direct_bind_mixed/runtime.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+
tests/parity_corpus/direct_bind_mixed/sections.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+
tests/parity_corpus/direct_bind_multi_data/args.txtadded
16 lines changed — click to load
@@ -0,0 +1,16 @@
1
+-arch
2
+arm64
3
+-platform_version
4
+macos
5
+@SDK_VERSION@
6
+@SDK_VERSION@
7
+-syslibroot
8
+@SDK_PATH@
9
+-no_fixup_chains
10
+-e
11
+_main
12
+-o
13
+@OUT@
14
+@INPUT:main.o@
15
+@SDK_TBD:usr/lib/libSystem.tbd@
16
+@ARTIFACT:libdirect.dylib@
tests/parity_corpus/direct_bind_multi_data/artifacts.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+clang_dylib libdirect.c libdirect.dylib
tests/parity_corpus/direct_bind_multi_data/command_checks.txtadded
6 lines changed — click to load
@@ -0,0 +1,6 @@
1
+build_version
2
+load_dylib_names
3
+dyld_info_rebase
4
+dyld_info_bind
5
+dyld_info_weak_bind
6
+dyld_info_lazy_bind
tests/parity_corpus/direct_bind_multi_data/inputs/libdirect.cadded
2 lines changed — click to load
@@ -0,0 +1,2 @@
1
+int ext_data = 5;
2
+int more_data = 9;
tests/parity_corpus/direct_bind_multi_data/inputs/main.cadded
5 lines changed — click to load
@@ -0,0 +1,5 @@
1
+extern int ext_data;
2
+extern int more_data;
3
+int *p = &ext_data;
4
+int *q = &more_data;
5
+int main(void) { return (*p == 5 && *q == 9) ? 0 : 1; }
tests/parity_corpus/direct_bind_multi_data/notes.mdadded
3 lines changed — click to load
@@ -0,0 +1,3 @@
1
+Direct-bind executable parity case lifted from the direct-bind fixture matrix.
2
+This keeps the Sprint 27 corpus honest about multiple imported data pointers in
3
+one executable.
tests/parity_corpus/direct_bind_multi_data/runtime.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+
tests/parity_corpus/direct_bind_multi_data/sections.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+
tests/parity_corpus/export_bss_dylib/args.txtadded
16 lines changed — click to load
@@ -0,0 +1,16 @@
1
+-dylib
2
+-arch
3
+arm64
4
+-platform_version
5
+macos
6
+@SDK_VERSION@
7
+@SDK_VERSION@
8
+-syslibroot
9
+@SDK_PATH@
10
+-lSystem
11
+-install_name
12
+@rpath/export-bss.dylib
13
+-no_fixup_chains
14
+-o
15
+@OUT@
16
+@INPUT:main.o@
tests/parity_corpus/export_bss_dylib/command_checks.txtadded
3 lines changed — click to load
@@ -0,0 +1,3 @@
1
+build_version
2
+export_records
3
+symbol_record_map
tests/parity_corpus/export_bss_dylib/ignored_load_commands.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+
tests/parity_corpus/export_bss_dylib/inputs/main.sadded
6 lines changed — click to load
@@ -0,0 +1,6 @@
1
+        .section __TEXT,__text,regular,pure_instructions
2
+        .globl _touch
3
+        _touch:
4
+            ret
5
+        .zerofill __DATA,__bss,_global_bss,16,3
6
+        .subsections_via_symbols
tests/parity_corpus/export_bss_dylib/notes.mdadded
4 lines changed — click to load
@@ -0,0 +1,4 @@
1
+Exported-BSS dylib parity case lifted from the existing `linker_run` export
2
+matrix. This broadens the Sprint 27 corpus into zerofill/export-trie behavior,
3
+so the dedicated parity harness checks Apple agreement when an exported symbol
4
+lives in `__DATA,__bss` instead of only text or initialized data.
tests/parity_corpus/export_bss_dylib/sections.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+
tests/parity_corpus/export_filter_dylib/args.txtadded
20 lines changed — click to load
@@ -0,0 +1,20 @@
1
+-dylib
2
+-arch
3
+arm64
4
+-platform_version
5
+macos
6
+@SDK_VERSION@
7
+@SDK_VERSION@
8
+-syslibroot
9
+@SDK_PATH@
10
+-lSystem
11
+-install_name
12
+@rpath/export-filter.dylib
13
+-no_fixup_chains
14
+-exported_symbol
15
+_alpha
16
+-exported_symbols_list
17
+@FILE:exports.txt@
18
+-o
19
+@OUT@
20
+@INPUT:main.o@
tests/parity_corpus/export_filter_dylib/command_checks.txtadded
3 lines changed — click to load
@@ -0,0 +1,3 @@
1
+build_version
2
+export_records
3
+symbol_record_map
tests/parity_corpus/export_filter_dylib/files/exports.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+_bet?
tests/parity_corpus/export_filter_dylib/ignored_load_commands.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+
tests/parity_corpus/export_filter_dylib/inputs/main.sadded
11 lines changed — click to load
@@ -0,0 +1,11 @@
1
+.section __TEXT,__text,regular,pure_instructions
2
+.globl _alpha
3
+.globl _beta
4
+.globl _gamma
5
+_alpha:
6
+    ret
7
+_beta:
8
+    ret
9
+_gamma:
10
+    ret
11
+.subsections_via_symbols
tests/parity_corpus/export_filter_dylib/notes.mdadded
4 lines changed — click to load
@@ -0,0 +1,4 @@
1
+Export-filter dylib parity case lifted from the existing `linker_run` coverage.
2
+This extends the Sprint 27 corpus to handle sidecar list files and verifies
3
+that exported-symbol filtering matches Apple `ld` in both the export trie and
4
+the final symtab surface.
tests/parity_corpus/export_filter_dylib/sections.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+
tests/parity_corpus/export_ordering_dylib/args.txtadded
16 lines changed — click to load
@@ -0,0 +1,16 @@
1
+-dylib
2
+-arch
3
+arm64
4
+-platform_version
5
+macos
6
+@SDK_VERSION@
7
+@SDK_VERSION@
8
+-syslibroot
9
+@SDK_PATH@
10
+-lSystem
11
+-install_name
12
+@rpath/export-ordering.dylib
13
+-no_fixup_chains
14
+-o
15
+@OUT@
16
+@INPUT:main.o@
tests/parity_corpus/export_ordering_dylib/command_checks.txtadded
3 lines changed — click to load
@@ -0,0 +1,3 @@
1
+build_version
2
+export_records
3
+symbol_record_map
tests/parity_corpus/export_ordering_dylib/ignored_load_commands.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+
tests/parity_corpus/export_ordering_dylib/inputs/main.sadded
11 lines changed — click to load
@@ -0,0 +1,11 @@
1
+        .section __TEXT,__text,regular,pure_instructions
2
+        .globl _zeta
3
+        _zeta:
4
+            ret
5
+        .globl _alpha
6
+        _alpha:
7
+            ret
8
+        .globl _middle
9
+        _middle:
10
+            ret
11
+        .subsections_via_symbols
tests/parity_corpus/export_ordering_dylib/notes.mdadded
4 lines changed — click to load
@@ -0,0 +1,4 @@
1
+Export-ordering dylib parity case lifted from the existing `linker_run`
2
+export matrix. This gives the Sprint 27 corpus a direct check that export trie
3
+and final symtab ordering semantics stay in Apple-parity agreement even when
4
+symbols are declared out of lexical order.
tests/parity_corpus/export_ordering_dylib/sections.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+
tests/parity_corpus/export_prefix_fanout_dylib/args.txtadded
16 lines changed — click to load
@@ -0,0 +1,16 @@
1
+-dylib
2
+-arch
3
+arm64
4
+-platform_version
5
+macos
6
+@SDK_VERSION@
7
+@SDK_VERSION@
8
+-syslibroot
9
+@SDK_PATH@
10
+-lSystem
11
+-install_name
12
+@rpath/export-prefix-fanout.dylib
13
+-no_fixup_chains
14
+-o
15
+@OUT@
16
+@INPUT:main.o@
tests/parity_corpus/export_prefix_fanout_dylib/command_checks.txtadded
3 lines changed — click to load
@@ -0,0 +1,3 @@
1
+build_version
2
+export_records
3
+symbol_record_map
tests/parity_corpus/export_prefix_fanout_dylib/ignored_load_commands.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+
tests/parity_corpus/export_prefix_fanout_dylib/inputs/main.sadded
14 lines changed — click to load
@@ -0,0 +1,14 @@
1
+        .section __TEXT,__text,regular,pure_instructions
2
+        .globl _pre
3
+        _pre:
4
+            ret
5
+        .globl _prefix
6
+        _prefix:
7
+            ret
8
+        .globl _prefix_long
9
+        _prefix_long:
10
+            ret
11
+        .globl _prefix_lone
12
+        _prefix_lone:
13
+            ret
14
+        .subsections_via_symbols
tests/parity_corpus/export_prefix_fanout_dylib/notes.mdadded
4 lines changed — click to load
@@ -0,0 +1,4 @@
1
+Shared-prefix dylib export parity case lifted from the existing `linker_run`
2
+export matrix. This gives the Sprint 27 corpus a real export-trie fanout shape
3
+where multiple exported symbols share a common prefix but diverge later,
4
+without relying on export filtering to create the prefix structure.
tests/parity_corpus/export_prefix_fanout_dylib/sections.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+
tests/parity_corpus/export_shared_data_prefix_dylib/args.txtadded
16 lines changed — click to load
@@ -0,0 +1,16 @@
1
+-dylib
2
+-arch
3
+arm64
4
+-platform_version
5
+macos
6
+@SDK_VERSION@
7
+@SDK_VERSION@
8
+-syslibroot
9
+@SDK_PATH@
10
+-lSystem
11
+-install_name
12
+@rpath/export-shared-data-prefix.dylib
13
+-no_fixup_chains
14
+-o
15
+@OUT@
16
+@INPUT:main.o@
tests/parity_corpus/export_shared_data_prefix_dylib/command_checks.txtadded
3 lines changed — click to load
@@ -0,0 +1,3 @@
1
+build_version
2
+export_records
3
+symbol_record_map
tests/parity_corpus/export_shared_data_prefix_dylib/inputs/main.sadded
12 lines changed — click to load
@@ -0,0 +1,12 @@
1
+        .section __DATA,__data
2
+        .p2align 3
3
+        .globl _alpha_data
4
+        _alpha_data:
5
+            .quad 1
6
+        .globl _alphabet_data
7
+        _alphabet_data:
8
+            .quad 2
9
+        .globl _alphanumeric_data
10
+        _alphanumeric_data:
11
+            .quad 3
12
+        .subsections_via_symbols
tests/parity_corpus/export_shared_data_prefix_dylib/notes.mdadded
4 lines changed — click to load
@@ -0,0 +1,4 @@
1
+Shared-prefix data-only dylib export parity case lifted from the existing
2
+`linker_run` export matrix. This closes the remaining obvious export-matrix gap
3
+by checking Apple parity when all exported symbols live in `__DATA,__data` and
4
+share a long common prefix.
tests/parity_corpus/export_shared_data_prefix_dylib/sections.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+
tests/parity_corpus/export_text_const_dylib/args.txtadded
16 lines changed — click to load
@@ -0,0 +1,16 @@
1
+-dylib
2
+-arch
3
+arm64
4
+-platform_version
5
+macos
6
+@SDK_VERSION@
7
+@SDK_VERSION@
8
+-syslibroot
9
+@SDK_PATH@
10
+-lSystem
11
+-install_name
12
+@rpath/export-text-const.dylib
13
+-no_fixup_chains
14
+-o
15
+@OUT@
16
+@INPUT:main.o@
tests/parity_corpus/export_text_const_dylib/command_checks.txtadded
3 lines changed — click to load
@@ -0,0 +1,3 @@
1
+build_version
2
+export_records
3
+symbol_record_map
tests/parity_corpus/export_text_const_dylib/ignored_load_commands.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+
tests/parity_corpus/export_text_const_dylib/inputs/main.sadded
10 lines changed — click to load
@@ -0,0 +1,10 @@
1
+        .section __TEXT,__text,regular,pure_instructions
2
+        .globl _entry
3
+        _entry:
4
+            ret
5
+        .section __TEXT,__const
6
+        .p2align 3
7
+        .globl _ro_value
8
+        _ro_value:
9
+            .quad 0xfeedface
10
+        .subsections_via_symbols
tests/parity_corpus/export_text_const_dylib/notes.mdadded
4 lines changed — click to load
@@ -0,0 +1,4 @@
1
+Mixed text-plus-const dylib export parity case lifted from the existing
2
+`linker_run` export matrix. This expands the Sprint 27 corpus to cover export
3
+surfaces where one exported symbol lives in `__TEXT,__const` rather than only
4
+code, initialized data, or BSS.
tests/parity_corpus/export_text_const_dylib/sections.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+
tests/parity_corpus/export_text_data_dylib/args.txtadded
16 lines changed — click to load
@@ -0,0 +1,16 @@
1
+-dylib
2
+-arch
3
+arm64
4
+-platform_version
5
+macos
6
+@SDK_VERSION@
7
+@SDK_VERSION@
8
+-syslibroot
9
+@SDK_PATH@
10
+-lSystem
11
+-install_name
12
+@rpath/export-text-data.dylib
13
+-no_fixup_chains
14
+-o
15
+@OUT@
16
+@INPUT:main.o@
tests/parity_corpus/export_text_data_dylib/command_checks.txtadded
3 lines changed — click to load
@@ -0,0 +1,3 @@
1
+build_version
2
+export_records
3
+symbol_record_map
tests/parity_corpus/export_text_data_dylib/ignored_load_commands.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+
tests/parity_corpus/export_text_data_dylib/inputs/main.sadded
13 lines changed — click to load
@@ -0,0 +1,13 @@
1
+        .section __TEXT,__text,regular,pure_instructions
2
+        .globl _code_symbol
3
+        _code_symbol:
4
+            ret
5
+        .section __DATA,__data
6
+        .p2align 3
7
+        .globl _data_symbol
8
+        _data_symbol:
9
+            .quad 0x1234
10
+        .globl _more_data
11
+        _more_data:
12
+            .long 7
13
+        .subsections_via_symbols
tests/parity_corpus/export_text_data_dylib/notes.mdadded
4 lines changed — click to load
@@ -0,0 +1,4 @@
1
+Mixed text-plus-data dylib export parity case lifted from the existing
2
+`linker_run` export matrix. This broadens the Sprint 27 corpus beyond
3
+text-only dylibs and checks that both the export trie and final symtab stay in
4
+Apple-parity agreement when exported symbols live in different sections.
tests/parity_corpus/export_text_data_dylib/sections.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+
tests/parity_corpus/function_starts_exec/args.txtadded
15 lines changed — click to load
@@ -0,0 +1,15 @@
1
+-arch
2
+arm64
3
+-platform_version
4
+macos
5
+@SDK_VERSION@
6
+@SDK_VERSION@
7
+-syslibroot
8
+@SDK_PATH@
9
+-lSystem
10
+-no_fixup_chains
11
+-e
12
+_main
13
+-o
14
+@OUT@
15
+@INPUT:main.o@
tests/parity_corpus/function_starts_exec/command_checks.txtadded
4 lines changed — click to load
@@ -0,0 +1,4 @@
1
+build_version
2
+load_dylib_names
3
+normalized_function_starts
4
+data_in_code_if_present
tests/parity_corpus/function_starts_exec/ignored_load_commands.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+LC_LOAD_DYLIB
tests/parity_corpus/function_starts_exec/inputs/main.sadded
9 lines changed — click to load
@@ -0,0 +1,9 @@
1
+.section __TEXT,__text,regular,pure_instructions
2
+.globl _main
3
+.p2align 2
4
+_main:
5
+    adrp x0, _write@GOTPAGE
6
+    ldr x0, [x0, _write@GOTPAGEOFF]
7
+    bl _write
8
+    ret
9
+.subsections_via_symbols
tests/parity_corpus/function_starts_exec/notes.mdadded
3 lines changed — click to load
@@ -0,0 +1,3 @@
1
+Function-starts parity case lifted from the existing Apple `ld` probe. This
2
+keeps the Sprint 27 corpus honest about `LC_FUNCTION_STARTS` and the adjacent
3
+data-in-code payload for a classic lazy-link executable.
tests/parity_corpus/function_starts_exec/sections.txtadded
2 lines changed — click to load
@@ -0,0 +1,2 @@
1
+__TEXT __stubs
2
+__TEXT __stub_helper
tests/parity_corpus/function_starts_textcoal_exec/args.txtadded
15 lines changed — click to load
@@ -0,0 +1,15 @@
1
+-arch
2
+arm64
3
+-platform_version
4
+macos
5
+@SDK_VERSION@
6
+@SDK_VERSION@
7
+-syslibroot
8
+@SDK_PATH@
9
+-lSystem
10
+-no_fixup_chains
11
+-e
12
+_main
13
+-o
14
+@OUT@
15
+@INPUT:main.o@
tests/parity_corpus/function_starts_textcoal_exec/command_checks.txtadded
3 lines changed — click to load
@@ -0,0 +1,3 @@
1
+build_version
2
+load_dylib_names
3
+normalized_function_starts
tests/parity_corpus/function_starts_textcoal_exec/ignored_load_commands.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+LC_LOAD_DYLIB
tests/parity_corpus/function_starts_textcoal_exec/inputs/main.sadded
12 lines changed — click to load
@@ -0,0 +1,12 @@
1
+.section __TEXT,__text,regular,pure_instructions
2
+.globl _main
3
+.p2align 2
4
+_main:
5
+    ret
6
+
7
+.section __TEXT,__textcoal_nt,regular,pure_instructions
8
+.globl _helper
9
+.p2align 2
10
+_helper:
11
+    ret
12
+.subsections_via_symbols
tests/parity_corpus/function_starts_textcoal_exec/notes.mdadded
3 lines changed — click to load
@@ -0,0 +1,3 @@
1
+Function-starts parity case lifted from the existing Apple `ld` probe for a
2
+second text section. This keeps the Sprint 27 corpus honest about recording
3
+starts outside the primary `__TEXT,__text` section too.
tests/parity_corpus/function_starts_textcoal_exec/sections.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+
tests/parity_corpus/hello_classic/args.txtadded
15 lines changed — click to load
@@ -0,0 +1,15 @@
1
+-arch
2
+arm64
3
+-platform_version
4
+macos
5
+@SDK_VERSION@
6
+@SDK_VERSION@
7
+-syslibroot
8
+@SDK_PATH@
9
+-lSystem
10
+-e
11
+_main
12
+-no_fixup_chains
13
+-o
14
+@OUT@
15
+@INPUT:main.o@
tests/parity_corpus/hello_classic/ignored_load_commands.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+LC_LOAD_DYLIB
tests/parity_corpus/hello_classic/inputs/main.sadded
6 lines changed — click to load
@@ -0,0 +1,6 @@
1
+.section __TEXT,__text,regular,pure_instructions
2
+.globl _main
3
+_main:
4
+    mov x0, #0
5
+    ret
6
+.subsections_via_symbols
tests/parity_corpus/hello_classic/notes.mdadded
3 lines changed — click to load
@@ -0,0 +1,3 @@
1
+Classic executable hello-world seed case for the Sprint 27 differential
2
+matrix. This starts the reusable on-disk corpus with the simplest runnable
3
+shape we already expect to match Apple `ld`.
tests/parity_corpus/hello_classic/runtime.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+
tests/parity_corpus/hello_classic/sections.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+__TEXT __text
tests/parity_corpus/hello_dead_strip/args.txtadded
16 lines changed — click to load
@@ -0,0 +1,16 @@
1
+-arch
2
+arm64
3
+-platform_version
4
+macos
5
+@SDK_VERSION@
6
+@SDK_VERSION@
7
+-syslibroot
8
+@SDK_PATH@
9
+-lSystem
10
+-e
11
+_main
12
+-no_fixup_chains
13
+-dead_strip
14
+-o
15
+@OUT@
16
+@INPUT:main.o@
tests/parity_corpus/hello_dead_strip/ignored_load_commands.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+LC_LOAD_DYLIB
tests/parity_corpus/hello_dead_strip/inputs/main.sadded
6 lines changed — click to load
@@ -0,0 +1,6 @@
1
+.section __TEXT,__text,regular,pure_instructions
2
+.globl _main
3
+_main:
4
+    mov x0, #0
5
+    ret
6
+.subsections_via_symbols
tests/parity_corpus/hello_dead_strip/notes.mdadded
3 lines changed — click to load
@@ -0,0 +1,3 @@
1
+Dead-strip executable seed case for the Sprint 27 differential matrix. This
2
+exercises the new corpus path on a real post-Sprint-23 behavior knob without
3
+dragging in a large multi-object fixture yet.
tests/parity_corpus/hello_dead_strip/runtime.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+
tests/parity_corpus/hello_dead_strip/sections.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+__TEXT __text
tests/parity_corpus/hidden_got_exec/absent_sections.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+__DATA_CONST __got
tests/parity_corpus/hidden_got_exec/args.txtadded
15 lines changed — click to load
@@ -0,0 +1,15 @@
1
+-arch
2
+arm64
3
+-platform_version
4
+macos
5
+@SDK_VERSION@
6
+@SDK_VERSION@
7
+-syslibroot
8
+@SDK_PATH@
9
+-lSystem
10
+-e
11
+_main
12
+-no_fixup_chains
13
+-o
14
+@OUT@
15
+@INPUT:main.o@
tests/parity_corpus/hidden_got_exec/command_checks.txtadded
2 lines changed — click to load
@@ -0,0 +1,2 @@
1
+build_version
2
+load_dylib_names
tests/parity_corpus/hidden_got_exec/inputs/main.sadded
14 lines changed — click to load
@@ -0,0 +1,14 @@
1
+.section __TEXT,__text,regular,pure_instructions
2
+.globl _main
3
+_main:
4
+    adrp x8, _value@GOTPAGE
5
+    ldr x8, [x8, _value@GOTPAGEOFF]
6
+    ldr w0, [x8]
7
+    ret
8
+
9
+.private_extern _value
10
+.section __DATA,__data
11
+.p2align 2
12
+_value:
13
+    .long 7
14
+.subsections_via_symbols
tests/parity_corpus/hidden_got_exec/notes.mdadded
3 lines changed — click to load
@@ -0,0 +1,3 @@
1
+Hidden-GOT relaxation parity case lifted from the existing Apple `ld` probe.
2
+This keeps the Sprint 27 corpus honest about relaxing hidden GOT references to
3
+direct page references and omitting the final `__got` section.
tests/parity_corpus/hidden_got_exec/page_refs.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+__TEXT __text 0 add _value
tests/parity_corpus/hidden_got_exec/runtime.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+
tests/parity_corpus/hidden_got_exec/sections.txtadded
0 lines changed — click to load
tests/parity_corpus/imported_tlv_exec/absent_sections.txtadded
0 lines changed — click to load
tests/parity_corpus/imported_tlv_exec/args.txtadded
16 lines changed — click to load
@@ -0,0 +1,16 @@
1
+-arch
2
+arm64
3
+-platform_version
4
+macos
5
+@SDK_VERSION@
6
+@SDK_VERSION@
7
+-syslibroot
8
+@SDK_PATH@
9
+-no_fixup_chains
10
+-lSystem
11
+-e
12
+_main
13
+-o
14
+@OUT@
15
+@INPUT:main.o@
16
+@ARTIFACT:libtlvprobe.dylib@
tests/parity_corpus/imported_tlv_exec/artifacts.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+clang_dylib libtlvprobe.c libtlvprobe.dylib
tests/parity_corpus/imported_tlv_exec/command_checks.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+build_version
tests/parity_corpus/imported_tlv_exec/ignored_load_commands.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+LC_LOAD_DYLIB
tests/parity_corpus/imported_tlv_exec/inputs/libtlvprobe.cadded
2 lines changed — click to load
@@ -0,0 +1,2 @@
1
+__thread long ext_tls = 5;
2
+long read_lib_tls(void) { return ext_tls; }
tests/parity_corpus/imported_tlv_exec/inputs/main.cadded
2 lines changed — click to load
@@ -0,0 +1,2 @@
1
+extern __thread long ext_tls;
2
+int main(void) { return ext_tls == 5 ? 0 : 1; }
tests/parity_corpus/imported_tlv_exec/notes.mdadded
3 lines changed — click to load
@@ -0,0 +1,3 @@
1
+Imported TLV executable parity case lifted from the existing Apple `ld` probe.
2
+This keeps the Sprint 27 corpus honest about routing imported TLV access
3
+through the GOT while matching Apple in text bytes, GOT contents, and runtime.
tests/parity_corpus/imported_tlv_exec/page_refs.txtadded
0 lines changed — click to load
tests/parity_corpus/imported_tlv_exec/runtime.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+
tests/parity_corpus/imported_tlv_exec/sections.txtadded
0 lines changed — click to load
tests/parity_corpus/local_got_exec/args.txtadded
15 lines changed — click to load
@@ -0,0 +1,15 @@
1
+-arch
2
+arm64
3
+-platform_version
4
+macos
5
+@SDK_VERSION@
6
+@SDK_VERSION@
7
+-syslibroot
8
+@SDK_PATH@
9
+-lSystem
10
+-e
11
+_main
12
+-no_fixup_chains
13
+-o
14
+@OUT@
15
+@INPUT:main.o@
tests/parity_corpus/local_got_exec/command_checks.txtadded
3 lines changed — click to load
@@ -0,0 +1,3 @@
1
+build_version
2
+load_dylib_names
3
+dyld_info_bind
tests/parity_corpus/local_got_exec/ignored_load_commands.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+LC_SEGMENT_64
tests/parity_corpus/local_got_exec/inputs/main.sadded
14 lines changed — click to load
@@ -0,0 +1,14 @@
1
+.section __TEXT,__text,regular,pure_instructions
2
+.globl _main
3
+_main:
4
+    adrp x8, _value@GOTPAGE
5
+    ldr x8, [x8, _value@GOTPAGEOFF]
6
+    ldr w0, [x8]
7
+    ret
8
+
9
+.section __DATA,__data
10
+.globl _value
11
+.p2align 2
12
+_value:
13
+    .long 7
14
+.subsections_via_symbols
tests/parity_corpus/local_got_exec/notes.mdadded
3 lines changed — click to load
@@ -0,0 +1,3 @@
1
+Local GOT executable parity case lifted from the existing Apple `ld` probe.
2
+This keeps the Sprint 27 corpus honest about classic local-GOT routing through
3
+rebased slots while preserving runtime behavior.
tests/parity_corpus/local_got_exec/runtime.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+
tests/parity_corpus/local_got_exec/sections.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+
tests/parity_corpus/local_rebase_exec/args.txtadded
15 lines changed — click to load
@@ -0,0 +1,15 @@
1
+-arch
2
+arm64
3
+-platform_version
4
+macos
5
+@SDK_VERSION@
6
+@SDK_VERSION@
7
+-syslibroot
8
+@SDK_PATH@
9
+-lSystem
10
+-e
11
+_main
12
+-no_fixup_chains
13
+-o
14
+@OUT@
15
+@INPUT:main.o@
tests/parity_corpus/local_rebase_exec/command_checks.txtadded
3 lines changed — click to load
@@ -0,0 +1,3 @@
1
+build_version
2
+load_dylib_names
3
+dyld_info_rebase
tests/parity_corpus/local_rebase_exec/inputs/main.cadded
3 lines changed — click to load
@@ -0,0 +1,3 @@
1
+int ext = 7;
2
+int *p = &ext;
3
+int main(void) { return *p == 7 ? 0 : 1; }
tests/parity_corpus/local_rebase_exec/notes.mdadded
3 lines changed — click to load
@@ -0,0 +1,3 @@
1
+Local absolute-pointer executable parity case lifted from the existing Apple
2
+`ld` probe. This keeps the Sprint 27 corpus honest about local pointer rebases
3
+and the resulting runtime behavior.
tests/parity_corpus/local_rebase_exec/runtime.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+
tests/parity_corpus/local_rebase_exec/sections.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+
tests/parity_corpus/loh_adrp_add_dylib/absent_load_commands.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+LC_LINKER_OPTIMIZATION_HINT
tests/parity_corpus/loh_adrp_add_dylib/args.txtadded
16 lines changed — click to load
@@ -0,0 +1,16 @@
1
+-dylib
2
+-arch
3
+arm64
4
+-platform_version
5
+macos
6
+@SDK_VERSION@
7
+@SDK_VERSION@
8
+-syslibroot
9
+@SDK_PATH@
10
+-lSystem
11
+-install_name
12
+@rpath/liblohprobe.dylib
13
+-no_fixup_chains
14
+-o
15
+@OUT@
16
+@INPUT:main.o@
tests/parity_corpus/loh_adrp_add_dylib/command_checks.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+build_version
tests/parity_corpus/loh_adrp_add_dylib/inputs/main.sadded
13 lines changed — click to load
@@ -0,0 +1,13 @@
1
+.section __TEXT,__text,regular,pure_instructions
2
+.globl _loh_probe
3
+.globl _target
4
+_loh_probe:
5
+Lloh0:
6
+    adrp x0, _target@PAGE
7
+Lloh1:
8
+    add x0, x0, _target@PAGEOFF
9
+    ret
10
+_target:
11
+    ret
12
+.loh AdrpAdd Lloh0, Lloh1
13
+.subsections_via_symbols
tests/parity_corpus/loh_adrp_add_dylib/notes.mdadded
4 lines changed — click to load
@@ -0,0 +1,4 @@
1
+LOH Apple-parity dylib case lifted from the Sprint 25 parity probes. This
2
+keeps the dedicated matrix honest about the current Apple `ld` behavior for
3
+dylib outputs too: preserve `ADRP+ADD` text and omit
4
+`LC_LINKER_OPTIMIZATION_HINT`.
tests/parity_corpus/loh_adrp_add_dylib/page_refs.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+__TEXT __text 0 add _target
tests/parity_corpus/loh_adrp_add_dylib/sections.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+
tests/parity_corpus/loh_adrp_add_exec/absent_load_commands.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+LC_LINKER_OPTIMIZATION_HINT
tests/parity_corpus/loh_adrp_add_exec/args.txtadded
15 lines changed — click to load
@@ -0,0 +1,15 @@
1
+-arch
2
+arm64
3
+-platform_version
4
+macos
5
+@SDK_VERSION@
6
+@SDK_VERSION@
7
+-syslibroot
8
+@SDK_PATH@
9
+-lSystem
10
+-e
11
+_main
12
+-no_fixup_chains
13
+-o
14
+@OUT@
15
+@INPUT:main.o@
tests/parity_corpus/loh_adrp_add_exec/command_checks.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+build_version
tests/parity_corpus/loh_adrp_add_exec/ignored_load_commands.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+LC_LOAD_DYLIB
tests/parity_corpus/loh_adrp_add_exec/inputs/main.sadded
14 lines changed — click to load
@@ -0,0 +1,14 @@
1
+.section __TEXT,__text,regular,pure_instructions
2
+.globl _main
3
+.globl _target
4
+_main:
5
+Lloh0:
6
+    adrp x0, _target@PAGE
7
+Lloh1:
8
+    add x0, x0, _target@PAGEOFF
9
+    mov w0, #0
10
+    ret
11
+_target:
12
+    ret
13
+.loh AdrpAdd Lloh0, Lloh1
14
+.subsections_via_symbols
tests/parity_corpus/loh_adrp_add_exec/notes.mdadded
4 lines changed — click to load
@@ -0,0 +1,4 @@
1
+LOH Apple-parity executable case pulled from the Sprint 25 parity probes. This
2
+keeps the dedicated matrix honest about the agreed Apple `ld` direction:
3
+preserve `ADRP+ADD` text and omit `LC_LINKER_OPTIMIZATION_HINT` from the final
4
+binary.
tests/parity_corpus/loh_adrp_add_exec/page_refs.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+__TEXT __text 0 add _target
tests/parity_corpus/loh_adrp_add_exec/runtime.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+
tests/parity_corpus/loh_adrp_add_exec/sections.txtadded
0 lines changed — click to load
tests/parity_corpus/loh_adrp_ldr_exec/absent_load_commands.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+LC_LINKER_OPTIMIZATION_HINT
tests/parity_corpus/loh_adrp_ldr_exec/args.txtadded
15 lines changed — click to load
@@ -0,0 +1,15 @@
1
+-arch
2
+arm64
3
+-platform_version
4
+macos
5
+@SDK_VERSION@
6
+@SDK_VERSION@
7
+-syslibroot
8
+@SDK_PATH@
9
+-lSystem
10
+-e
11
+_main
12
+-no_fixup_chains
13
+-o
14
+@OUT@
15
+@INPUT:main.o@
tests/parity_corpus/loh_adrp_ldr_exec/command_checks.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+build_version
tests/parity_corpus/loh_adrp_ldr_exec/ignored_load_commands.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+LC_LOAD_DYLIB
tests/parity_corpus/loh_adrp_ldr_exec/inputs/main.sadded
15 lines changed — click to load
@@ -0,0 +1,15 @@
1
+.section __TEXT,__text,regular,pure_instructions
2
+.globl _main
3
+.globl _target
4
+_main:
5
+Lloh0:
6
+    adrp x0, _target@PAGE
7
+Lloh1:
8
+    ldr x1, [x0, _target@PAGEOFF]
9
+    mov w0, #0
10
+    ret
11
+    .p2align 3
12
+_target:
13
+    .quad 0x1122334455667788
14
+.loh AdrpLdr Lloh0, Lloh1
15
+.subsections_via_symbols
tests/parity_corpus/loh_adrp_ldr_exec/notes.mdadded
4 lines changed — click to load
@@ -0,0 +1,4 @@
1
+LOH Apple-parity executable case lifted from the Sprint 25 parity probes.
2
+This keeps the dedicated matrix honest about the current Apple `ld` behavior:
3
+preserve `ADRP+LDR` text and omit `LC_LINKER_OPTIMIZATION_HINT` from the final
4
+binary.
tests/parity_corpus/loh_adrp_ldr_exec/page_refs.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+__TEXT __text 0 load _target
tests/parity_corpus/loh_adrp_ldr_exec/runtime.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+
tests/parity_corpus/loh_adrp_ldr_exec/sections.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+
tests/parity_corpus/loh_adrp_ldr_got_ldr_exec/absent_load_commands.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+LC_LINKER_OPTIMIZATION_HINT
tests/parity_corpus/loh_adrp_ldr_got_ldr_exec/args.txtadded
15 lines changed — click to load
@@ -0,0 +1,15 @@
1
+-arch
2
+arm64
3
+-platform_version
4
+macos
5
+@SDK_VERSION@
6
+@SDK_VERSION@
7
+-syslibroot
8
+@SDK_PATH@
9
+-lSystem
10
+-e
11
+_main
12
+-no_fixup_chains
13
+-o
14
+@OUT@
15
+@INPUT:main.o@
tests/parity_corpus/loh_adrp_ldr_got_ldr_exec/command_checks.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+build_version
tests/parity_corpus/loh_adrp_ldr_got_ldr_exec/ignored_load_commands.txtadded
2 lines changed — click to load
@@ -0,0 +1,2 @@
1
+LC_SEGMENT_64
2
+LC_LOAD_DYLIB
tests/parity_corpus/loh_adrp_ldr_got_ldr_exec/inputs/main.sadded
18 lines changed — click to load
@@ -0,0 +1,18 @@
1
+.section __TEXT,__text,regular,pure_instructions
2
+.globl _main
3
+.globl _value
4
+_main:
5
+Lloh0:
6
+    adrp x8, _value@GOTPAGE
7
+Lloh1:
8
+    ldr x8, [x8, _value@GOTPAGEOFF]
9
+Lloh2:
10
+    ldr w0, [x8]
11
+    ret
12
+
13
+.section __DATA,__data
14
+.p2align 2
15
+_value:
16
+    .long 7
17
+.loh AdrpLdrGotLdr Lloh0, Lloh1, Lloh2
18
+.subsections_via_symbols
tests/parity_corpus/loh_adrp_ldr_got_ldr_exec/notes.mdadded
4 lines changed — click to load
@@ -0,0 +1,4 @@
1
+LOH Apple-parity executable case lifted from the Sprint 25 parity probes.
2
+This keeps the dedicated matrix honest about the agreed Apple direction for
3
+local GOT-resolved `AdrpLdrGotLdr`: preserve the resolved `ADRP+ADD+LDR` shape
4
+and omit `LC_LINKER_OPTIMIZATION_HINT`.
tests/parity_corpus/loh_adrp_ldr_got_ldr_exec/page_refs.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+__TEXT __text 0 add _value
tests/parity_corpus/loh_adrp_ldr_got_ldr_exec/runtime.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+
tests/parity_corpus/loh_adrp_ldr_got_ldr_exec/sections.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+
tests/parity_corpus/reexport_chain_exec/args.txtadded
16 lines changed — click to load
@@ -0,0 +1,16 @@
1
+-arch
2
+arm64
3
+-platform_version
4
+macos
5
+@SDK_VERSION@
6
+@SDK_VERSION@
7
+-syslibroot
8
+@SDK_PATH@
9
+-lSystem
10
+-e
11
+_main
12
+-no_fixup_chains
13
+-o
14
+@OUT@
15
+@INPUT:main.o@
16
+@ARTIFACT:libmid.dylib@
tests/parity_corpus/reexport_chain_exec/artifacts.txtadded
2 lines changed — click to load
@@ -0,0 +1,2 @@
1
+clang_dylib leaf.c libleaf.dylib
2
+clang_reexport_dylib mid.c libmid.dylib libleaf.dylib
tests/parity_corpus/reexport_chain_exec/command_checks.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+build_version
tests/parity_corpus/reexport_chain_exec/inputs/leaf.cadded
3 lines changed — click to load
@@ -0,0 +1,3 @@
1
+int leaf_value(void) {
2
+    return 29;
3
+}
tests/parity_corpus/reexport_chain_exec/inputs/main.cadded
5 lines changed — click to load
@@ -0,0 +1,5 @@
1
+int mid_anchor(void);
2
+
3
+int main(void) {
4
+    return mid_anchor() == 0 ? 0 : 1;
5
+}
tests/parity_corpus/reexport_chain_exec/inputs/mid.cadded
3 lines changed — click to load
@@ -0,0 +1,3 @@
1
+int mid_anchor(void) {
2
+    return 0;
3
+}
tests/parity_corpus/reexport_chain_exec/notes.mdadded
4 lines changed — click to load
@@ -0,0 +1,4 @@
1
+Dylib reexport-chain parity case. The executable links only the middle dylib,
2
+which itself reexports a leaf dylib. The executable resolves a direct symbol
3
+from the middle dylib while carrying the same dependency-chain surface Apple
4
+`ld` produces.
tests/parity_corpus/reexport_chain_exec/runtime.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+
tests/parity_corpus/reexport_chain_exec/sections.txtadded
2 lines changed — click to load
@@ -0,0 +1,2 @@
1
+__TEXT __text
2
+__TEXT __stubs
tests/parity_corpus/reloc_adrp_add_backward/args.txtadded
15 lines changed — click to load
@@ -0,0 +1,15 @@
1
+-arch
2
+arm64
3
+-platform_version
4
+macos
5
+@SDK_VERSION@
6
+@SDK_VERSION@
7
+-syslibroot
8
+@SDK_PATH@
9
+-lSystem
10
+-no_fixup_chains
11
+-e
12
+_main
13
+-o
14
+@OUT@
15
+@INPUT:main.o@
tests/parity_corpus/reloc_adrp_add_backward/inputs/main.sadded
10 lines changed — click to load
@@ -0,0 +1,10 @@
1
+        .section __TEXT,__text,regular,pure_instructions
2
+        _target:
3
+            .quad 0x55
4
+        .space 0x4ff8
5
+        .globl _main
6
+        _main:
7
+            adrp x0, _target@PAGE
8
+            add x0, x0, _target@PAGEOFF
9
+            ret
10
+        .subsections_via_symbols
tests/parity_corpus/reloc_adrp_add_backward/notes.mdadded
3 lines changed — click to load
@@ -0,0 +1,3 @@
1
+Backward `adrp/add` page-reference parity case lifted from the legacy
2
+relocation matrix. This keeps the shared corpus honest about negative-offset
3
+page references as well as forward ones.
tests/parity_corpus/reloc_adrp_add_backward/page_refs.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+__TEXT __text 0x5000 add _target
tests/parity_corpus/reloc_adrp_add_backward/sections.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+
tests/parity_corpus/reloc_adrp_add_forward/args.txtadded
15 lines changed — click to load
@@ -0,0 +1,15 @@
1
+-arch
2
+arm64
3
+-platform_version
4
+macos
5
+@SDK_VERSION@
6
+@SDK_VERSION@
7
+-syslibroot
8
+@SDK_PATH@
9
+-lSystem
10
+-no_fixup_chains
11
+-e
12
+_main
13
+-o
14
+@OUT@
15
+@INPUT:main.o@
tests/parity_corpus/reloc_adrp_add_forward/inputs/main.sadded
10 lines changed — click to load
@@ -0,0 +1,10 @@
1
+        .section __TEXT,__text,regular,pure_instructions
2
+        .globl _main
3
+        _main:
4
+            adrp x0, _target@PAGE
5
+            add x0, x0, _target@PAGEOFF
6
+            ret
7
+        .space 0x4ff4
8
+        _target:
9
+            .quad 0
10
+        .subsections_via_symbols
tests/parity_corpus/reloc_adrp_add_forward/notes.mdadded
3 lines changed — click to load
@@ -0,0 +1,3 @@
1
+Forward `adrp/add` page-reference parity case lifted from the legacy relocation
2
+matrix. This checks that the shared Sprint 27 corpus can validate semantic page
3
+references, not just exact section bytes.
tests/parity_corpus/reloc_adrp_add_forward/page_refs.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+__TEXT __text 0 add _target
tests/parity_corpus/reloc_adrp_add_forward/sections.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+
tests/parity_corpus/reloc_adrp_ldr_w/args.txtadded
15 lines changed — click to load
@@ -0,0 +1,15 @@
1
+-arch
2
+arm64
3
+-platform_version
4
+macos
5
+@SDK_VERSION@
6
+@SDK_VERSION@
7
+-syslibroot
8
+@SDK_PATH@
9
+-lSystem
10
+-no_fixup_chains
11
+-e
12
+_main
13
+-o
14
+@OUT@
15
+@INPUT:main.o@
tests/parity_corpus/reloc_adrp_ldr_w/inputs/main.sadded
10 lines changed — click to load
@@ -0,0 +1,10 @@
1
+        .section __TEXT,__text,regular,pure_instructions
2
+        .globl _main
3
+        _main:
4
+            adrp x0, _target@PAGE
5
+            ldr w1, [x0, _target@PAGEOFF]
6
+            ret
7
+        .space 0x2f4
8
+        _target:
9
+            .long 0x11223344
10
+        .subsections_via_symbols
tests/parity_corpus/reloc_adrp_ldr_w/notes.mdadded
3 lines changed — click to load
@@ -0,0 +1,3 @@
1
+`adrp/ldr w` page-reference parity case lifted from the legacy relocation
2
+matrix. This keeps the shared Sprint 27 corpus growing across narrower load
3
+widths, not just 64-bit loads.
tests/parity_corpus/reloc_adrp_ldr_w/page_refs.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+__TEXT __text 0 load _target
tests/parity_corpus/reloc_adrp_ldr_w/sections.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+
tests/parity_corpus/reloc_adrp_ldr_x/args.txtadded
15 lines changed — click to load
@@ -0,0 +1,15 @@
1
+-arch
2
+arm64
3
+-platform_version
4
+macos
5
+@SDK_VERSION@
6
+@SDK_VERSION@
7
+-syslibroot
8
+@SDK_PATH@
9
+-lSystem
10
+-no_fixup_chains
11
+-e
12
+_main
13
+-o
14
+@OUT@
15
+@INPUT:main.o@
tests/parity_corpus/reloc_adrp_ldr_x/inputs/main.sadded
10 lines changed — click to load
@@ -0,0 +1,10 @@
1
+        .section __TEXT,__text,regular,pure_instructions
2
+        .globl _main
3
+        _main:
4
+            adrp x0, _target@PAGE
5
+            ldr x1, [x0, _target@PAGEOFF]
6
+            ret
7
+        .space 0x3f4
8
+        _target:
9
+            .quad 0x1122334455667788
10
+        .subsections_via_symbols
tests/parity_corpus/reloc_adrp_ldr_x/notes.mdadded
3 lines changed — click to load
@@ -0,0 +1,3 @@
1
+`adrp/ldr x` page-reference parity case lifted from the legacy relocation
2
+matrix. This expands the shared Sprint 27 corpus from branch and add-based
3
+relocations into load-addressing semantics too.
tests/parity_corpus/reloc_adrp_ldr_x/page_refs.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+__TEXT __text 0 load _target
tests/parity_corpus/reloc_adrp_ldr_x/sections.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+
tests/parity_corpus/reloc_adrp_ldrb/args.txtadded
15 lines changed — click to load
@@ -0,0 +1,15 @@
1
+-arch
2
+arm64
3
+-platform_version
4
+macos
5
+@SDK_VERSION@
6
+@SDK_VERSION@
7
+-syslibroot
8
+@SDK_PATH@
9
+-lSystem
10
+-no_fixup_chains
11
+-e
12
+_main
13
+-o
14
+@OUT@
15
+@INPUT:main.o@
tests/parity_corpus/reloc_adrp_ldrb/inputs/main.sadded
10 lines changed — click to load
@@ -0,0 +1,10 @@
1
+        .section __TEXT,__text,regular,pure_instructions
2
+        .globl _main
3
+        _main:
4
+            adrp x0, _target@PAGE
5
+            ldrb w1, [x0, _target@PAGEOFF]
6
+            ret
7
+        .space 0xf4
8
+        _target:
9
+            .byte 0x44
10
+        .subsections_via_symbols
tests/parity_corpus/reloc_adrp_ldrb/notes.mdadded
3 lines changed — click to load
@@ -0,0 +1,3 @@
1
+`adrp/ldrb` page-reference parity case lifted from the legacy relocation
2
+matrix. This completes the byte-width side of the current page-reference corpus
3
+coverage.
tests/parity_corpus/reloc_adrp_ldrb/page_refs.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+__TEXT __text 0 load _target
tests/parity_corpus/reloc_adrp_ldrb/sections.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+
tests/parity_corpus/reloc_adrp_ldrh/args.txtadded
15 lines changed — click to load
@@ -0,0 +1,15 @@
1
+-arch
2
+arm64
3
+-platform_version
4
+macos
5
+@SDK_VERSION@
6
+@SDK_VERSION@
7
+-syslibroot
8
+@SDK_PATH@
9
+-lSystem
10
+-no_fixup_chains
11
+-e
12
+_main
13
+-o
14
+@OUT@
15
+@INPUT:main.o@
tests/parity_corpus/reloc_adrp_ldrh/inputs/main.sadded
10 lines changed — click to load
@@ -0,0 +1,10 @@
1
+        .section __TEXT,__text,regular,pure_instructions
2
+        .globl _main
3
+        _main:
4
+            adrp x0, _target@PAGE
5
+            ldrh w1, [x0, _target@PAGEOFF]
6
+            ret
7
+        .space 0x1f4
8
+        _target:
9
+            .hword 0x3344
10
+        .subsections_via_symbols
tests/parity_corpus/reloc_adrp_ldrh/notes.mdadded
3 lines changed — click to load
@@ -0,0 +1,3 @@
1
+`adrp/ldrh` page-reference parity case lifted from the legacy relocation
2
+matrix. This keeps the corpus honest for halfword loads too, not only word and
3
+doubleword forms.
tests/parity_corpus/reloc_adrp_ldrh/page_refs.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+__TEXT __text 0 load _target
tests/parity_corpus/reloc_adrp_ldrh/sections.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+
tests/parity_corpus/reloc_branch_and_subtractor/args.txtadded
15 lines changed — click to load
@@ -0,0 +1,15 @@
1
+-arch
2
+arm64
3
+-platform_version
4
+macos
5
+@SDK_VERSION@
6
+@SDK_VERSION@
7
+-syslibroot
8
+@SDK_PATH@
9
+-lSystem
10
+-no_fixup_chains
11
+-e
12
+_main
13
+-o
14
+@OUT@
15
+@INPUT:main.o@
tests/parity_corpus/reloc_branch_and_subtractor/inputs/main.sadded
13 lines changed — click to load
@@ -0,0 +1,13 @@
1
+        .section __TEXT,__text,regular,pure_instructions
2
+        .globl _helper
3
+        _helper:
4
+            ret
5
+        .globl _main
6
+        _main:
7
+            bl _helper
8
+            ret
9
+        .section __TEXT,__const
10
+        .p2align 3
11
+        _delta:
12
+            .quad _main - _helper
13
+        .subsections_via_symbols
tests/parity_corpus/reloc_branch_and_subtractor/notes.mdadded
3 lines changed — click to load
@@ -0,0 +1,3 @@
1
+Combined branch plus subtractor relocation parity case lifted from the legacy
2
+matrix. This finishes the current subtractor trio in the shared Sprint 27
3
+corpus and checks both `__TEXT,__text` and `__TEXT,__const` parity together.
tests/parity_corpus/reloc_branch_and_subtractor/sections.txtadded
2 lines changed — click to load
@@ -0,0 +1,2 @@
1
+__TEXT __text
2
+__TEXT __const
tests/parity_corpus/reloc_branch_backward/args.txtadded
15 lines changed — click to load
@@ -0,0 +1,15 @@
1
+-arch
2
+arm64
3
+-platform_version
4
+macos
5
+@SDK_VERSION@
6
+@SDK_VERSION@
7
+-syslibroot
8
+@SDK_PATH@
9
+-lSystem
10
+-no_fixup_chains
11
+-e
12
+_main
13
+-o
14
+@OUT@
15
+@INPUT:main.o@
tests/parity_corpus/reloc_branch_backward/inputs/main.sadded
9 lines changed — click to load
@@ -0,0 +1,9 @@
1
+        .section __TEXT,__text,regular,pure_instructions
2
+        .globl _helper
3
+        _helper:
4
+            ret
5
+        .globl _main
6
+        _main:
7
+            bl _helper
8
+            ret
9
+        .subsections_via_symbols
tests/parity_corpus/reloc_branch_backward/notes.mdadded
4 lines changed — click to load
@@ -0,0 +1,4 @@
1
+Backward branch relocation parity case lifted from the existing
2
+`relocated_sections_match_apple_ld_across_fixture_matrix` coverage. This keeps
3
+the shared Sprint 27 corpus growing on the relocation side with a simple exact
4
+`__TEXT,__text` byte-parity check after reloc application.
tests/parity_corpus/reloc_branch_backward/sections.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+__TEXT __text
tests/parity_corpus/reloc_branch_forward/args.txtadded
15 lines changed — click to load
@@ -0,0 +1,15 @@
1
+-arch
2
+arm64
3
+-platform_version
4
+macos
5
+@SDK_VERSION@
6
+@SDK_VERSION@
7
+-syslibroot
8
+@SDK_PATH@
9
+-lSystem
10
+-no_fixup_chains
11
+-e
12
+_main
13
+-o
14
+@OUT@
15
+@INPUT:main.o@
tests/parity_corpus/reloc_branch_forward/ignored_load_commands.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+
tests/parity_corpus/reloc_branch_forward/inputs/main.sadded
8 lines changed — click to load
@@ -0,0 +1,8 @@
1
+        .section __TEXT,__text,regular,pure_instructions
2
+        .globl _main
3
+        _main:
4
+            bl _helper
5
+            ret
6
+        _helper:
7
+            ret
8
+        .subsections_via_symbols
tests/parity_corpus/reloc_branch_forward/notes.mdadded
5 lines changed — click to load
@@ -0,0 +1,5 @@
1
+Forward branch relocation parity case lifted from the existing
2
+`relocated_sections_match_apple_ld_across_fixture_matrix` coverage. This is the
3
+first explicit relocation-matrix migration into the Sprint 27 corpus and keeps
4
+the check intentionally simple: exact `__TEXT,__text` byte parity after reloc
5
+application.
tests/parity_corpus/reloc_branch_forward/sections.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+__TEXT __text
tests/parity_corpus/reloc_mixed_branch_adrp_text/args.txtadded
15 lines changed — click to load
@@ -0,0 +1,15 @@
1
+-arch
2
+arm64
3
+-platform_version
4
+macos
5
+@SDK_VERSION@
6
+@SDK_VERSION@
7
+-syslibroot
8
+@SDK_PATH@
9
+-lSystem
10
+-no_fixup_chains
11
+-e
12
+_main
13
+-o
14
+@OUT@
15
+@INPUT:main.o@
tests/parity_corpus/reloc_mixed_branch_adrp_text/inputs/main.sadded
14 lines changed — click to load
@@ -0,0 +1,14 @@
1
+        .section __TEXT,__text,regular,pure_instructions
2
+        .globl _main
3
+        .globl _helper
4
+        _main:
5
+            adrp x0, _target@PAGE
6
+            add x0, x0, _target@PAGEOFF
7
+            bl _helper
8
+            ret
9
+        _helper:
10
+            ret
11
+        .space 0xff0
12
+        _target:
13
+            .quad 0x99
14
+        .subsections_via_symbols
tests/parity_corpus/reloc_mixed_branch_adrp_text/notes.mdadded
3 lines changed — click to load
@@ -0,0 +1,3 @@
1
+Mixed branch-plus-`adrp/add` parity case lifted from the legacy relocation
2
+matrix. This gives the shared Sprint 27 corpus one executable that exercises
3
+both branch and page-reference relocation semantics together.
tests/parity_corpus/reloc_mixed_branch_adrp_text/page_refs.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+__TEXT __text 0 add _target
tests/parity_corpus/reloc_mixed_branch_adrp_text/sections.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+
tests/parity_corpus/reloc_subtractor_negative/args.txtadded
15 lines changed — click to load
@@ -0,0 +1,15 @@
1
+-arch
2
+arm64
3
+-platform_version
4
+macos
5
+@SDK_VERSION@
6
+@SDK_VERSION@
7
+-syslibroot
8
+@SDK_PATH@
9
+-lSystem
10
+-no_fixup_chains
11
+-e
12
+_main
13
+-o
14
+@OUT@
15
+@INPUT:main.o@
tests/parity_corpus/reloc_subtractor_negative/inputs/main.sadded
12 lines changed — click to load
@@ -0,0 +1,12 @@
1
+        .section __TEXT,__text,regular,pure_instructions
2
+        .globl _helper
3
+        _helper:
4
+            ret
5
+        .globl _main
6
+        _main:
7
+            ret
8
+        .section __TEXT,__const
9
+        .p2align 3
10
+        _delta:
11
+            .quad _main - _helper
12
+        .subsections_via_symbols
tests/parity_corpus/reloc_subtractor_negative/notes.mdadded
3 lines changed — click to load
@@ -0,0 +1,3 @@
1
+Negative subtractor relocation parity case lifted from the legacy relocation
2
+matrix. This keeps the corpus honest on signed subtractor results as well as
3
+positive ones.
tests/parity_corpus/reloc_subtractor_negative/sections.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+__TEXT __const
tests/parity_corpus/reloc_subtractor_positive/args.txtadded
15 lines changed — click to load
@@ -0,0 +1,15 @@
1
+-arch
2
+arm64
3
+-platform_version
4
+macos
5
+@SDK_VERSION@
6
+@SDK_VERSION@
7
+-syslibroot
8
+@SDK_PATH@
9
+-lSystem
10
+-no_fixup_chains
11
+-e
12
+_main
13
+-o
14
+@OUT@
15
+@INPUT:main.o@
tests/parity_corpus/reloc_subtractor_positive/inputs/main.sadded
13 lines changed — click to load
@@ -0,0 +1,13 @@
1
+        .section __TEXT,__text,regular,pure_instructions
2
+        .globl _helper
3
+        _helper:
4
+            ret
5
+        .globl _main
6
+        _main:
7
+            bl _helper
8
+            ret
9
+        .section __TEXT,__const
10
+        .p2align 3
11
+        _delta:
12
+            .quad _helper - _main
13
+        .subsections_via_symbols
tests/parity_corpus/reloc_subtractor_positive/notes.mdadded
3 lines changed — click to load
@@ -0,0 +1,3 @@
1
+Positive subtractor relocation parity case lifted from the legacy relocation
2
+matrix. This extends the shared corpus beyond code relocs into absolute
3
+subtractor expressions materialized in `__TEXT,__const`.
tests/parity_corpus/reloc_subtractor_positive/sections.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+__TEXT __const
tests/parity_corpus/runtime_fortran_three_func_exec/args.txtadded
16 lines changed — click to load
@@ -0,0 +1,16 @@
1
+-arch
2
+arm64
3
+-platform_version
4
+macos
5
+@SDK_VERSION@
6
+@SDK_VERSION@
7
+-syslibroot
8
+@SDK_PATH@
9
+-lSystem
10
+-e
11
+_main
12
+-no_fixup_chains
13
+-o
14
+@OUT@
15
+@INPUT:main.o@
16
+@FILE:libarmfortas_rt.a@
tests/parity_corpus/runtime_fortran_three_func_exec/command_checks.txtadded
2 lines changed — click to load
@@ -0,0 +1,2 @@
1
+build_version
2
+load_dylib_names
tests/parity_corpus/runtime_fortran_three_func_exec/files/libarmfortas_rt.aadded
Binary file changed.
tests/parity_corpus/runtime_fortran_three_func_exec/files/source.f90added
18 lines changed — click to load
@@ -0,0 +1,18 @@
1
+program main
2
+  integer :: v
3
+  v = add3(7)
4
+contains
5
+  integer function add3(x)
6
+    integer, intent(in) :: x
7
+    add3 = twice(x) + one()
8
+  end function add3
9
+
10
+  integer function twice(x)
11
+    integer, intent(in) :: x
12
+    twice = x + x
13
+  end function twice
14
+
15
+  integer function one()
16
+    one = 8
17
+  end function one
18
+end program main
tests/parity_corpus/runtime_fortran_three_func_exec/ignored_load_commands.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+LC_SEGMENT_64
tests/parity_corpus/runtime_fortran_three_func_exec/inputs/main.oadded
Binary file changed.
tests/parity_corpus/runtime_fortran_three_func_exec/notes.mdadded
4 lines changed — click to load
@@ -0,0 +1,4 @@
1
+Minimal runtime parity case for a three-function Fortran program. The input
2
+object is compiled with `armfortas`, and the sidecar `libarmfortas_rt.a` is a
3
+real archive copied from the runtime build so this scenario stays standalone
4
+inside the `afs-ld` repo.
tests/parity_corpus/runtime_fortran_three_func_exec/runtime.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+
tests/parity_corpus/runtime_fortran_three_func_exec/sections.txtadded
0 lines changed — click to load
tests/parity_corpus/strip_locals_exec/args.txtadded
17 lines changed — click to load
@@ -0,0 +1,17 @@
1
+-arch
2
+arm64
3
+-platform_version
4
+macos
5
+@SDK_VERSION@
6
+@SDK_VERSION@
7
+-syslibroot
8
+@SDK_PATH@
9
+-lSystem
10
+-x
11
+-no_fixup_chains
12
+-e
13
+_main
14
+-o
15
+@OUT@
16
+@INPUT:main.o@
17
+@ARTIFACT:libsymtab.dylib@
tests/parity_corpus/strip_locals_exec/artifacts.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+clang_dylib libsymtab.c libsymtab.dylib
tests/parity_corpus/strip_locals_exec/command_checks.txtadded
4 lines changed — click to load
@@ -0,0 +1,4 @@
1
+load_dylib_names
2
+symbol_record_map
3
+symbol_partition_names
4
+string_table_near_parity
tests/parity_corpus/strip_locals_exec/inputs/libsymtab.cadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+int ext_data = 5;
tests/parity_corpus/strip_locals_exec/inputs/main.sadded
17 lines changed — click to load
@@ -0,0 +1,17 @@
1
+.text
2
+.private_extern _hidden
3
+.globl _visible
4
+.globl _main
5
+.p2align 2
6
+_local:
7
+    ret
8
+_hidden:
9
+    ret
10
+_visible:
11
+    ret
12
+_main:
13
+    ret
14
+
15
+.data
16
+.quad _ext_data
17
+.subsections_via_symbols
tests/parity_corpus/strip_locals_exec/notes.mdadded
3 lines changed — click to load
@@ -0,0 +1,3 @@
1
+`-x` symtab parity case lifted from the existing Apple `ld` probe. This keeps
2
+the Sprint 27 corpus honest about stripping locals while preserving the
3
+remaining external and undefined symbol partitions.
tests/parity_corpus/strip_locals_exec/sections.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+
tests/parity_corpus/symtab_partition_exec/args.txtadded
16 lines changed — click to load
@@ -0,0 +1,16 @@
1
+-arch
2
+arm64
3
+-platform_version
4
+macos
5
+@SDK_VERSION@
6
+@SDK_VERSION@
7
+-syslibroot
8
+@SDK_PATH@
9
+-lSystem
10
+-no_fixup_chains
11
+-e
12
+_main
13
+-o
14
+@OUT@
15
+@INPUT:main.o@
16
+@ARTIFACT:libsymtab.dylib@
tests/parity_corpus/symtab_partition_exec/artifacts.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+clang_dylib libsymtab.c libsymtab.dylib
tests/parity_corpus/symtab_partition_exec/command_checks.txtadded
4 lines changed — click to load
@@ -0,0 +1,4 @@
1
+load_dylib_names
2
+symbol_record_map
3
+symbol_partition_names
4
+string_table_near_parity
tests/parity_corpus/symtab_partition_exec/inputs/libsymtab.cadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+int ext_data = 5;
tests/parity_corpus/symtab_partition_exec/inputs/main.sadded
17 lines changed — click to load
@@ -0,0 +1,17 @@
1
+.text
2
+.private_extern _hidden
3
+.globl _visible
4
+.globl _main
5
+.p2align 2
6
+_local:
7
+    ret
8
+_hidden:
9
+    ret
10
+_visible:
11
+    ret
12
+_main:
13
+    ret
14
+
15
+.data
16
+.quad _ext_data
17
+.subsections_via_symbols
tests/parity_corpus/symtab_partition_exec/notes.mdadded
3 lines changed — click to load
@@ -0,0 +1,3 @@
1
+Symtab partition parity case lifted from the existing Apple `ld` probe. This
2
+keeps the Sprint 27 corpus honest about local/extdef/undef partitioning and
3
+string-table size staying near Apple `ld` on a dylib-import executable.
tests/parity_corpus/symtab_partition_exec/sections.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+
tests/parity_corpus/synthetic_import_classic_lazy/args.txtadded
15 lines changed — click to load
@@ -0,0 +1,15 @@
1
+-arch
2
+arm64
3
+-platform_version
4
+macos
5
+@SDK_VERSION@
6
+@SDK_VERSION@
7
+-syslibroot
8
+@SDK_PATH@
9
+-lSystem
10
+-e
11
+_main
12
+-no_fixup_chains
13
+-o
14
+@OUT@
15
+@INPUT:main.o@
tests/parity_corpus/synthetic_import_classic_lazy/command_checks.txtadded
7 lines changed — click to load
@@ -0,0 +1,7 @@
1
+build_version
2
+load_dylib_names
3
+indirect_symbol_identities
4
+dyld_info_rebase
5
+dyld_info_bind
6
+dyld_info_weak_bind
7
+dyld_info_lazy_bind
tests/parity_corpus/synthetic_import_classic_lazy/ignored_load_commands.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+LC_LOAD_DYLIB
tests/parity_corpus/synthetic_import_classic_lazy/inputs/main.sadded
8 lines changed — click to load
@@ -0,0 +1,8 @@
1
+.section __TEXT,__text,regular,pure_instructions
2
+.globl _main
3
+_main:
4
+    adrp x0, _write@GOTPAGE
5
+    ldr x0, [x0, _write@GOTPAGEOFF]
6
+    bl _write
7
+    ret
8
+.subsections_via_symbols
tests/parity_corpus/synthetic_import_classic_lazy/notes.mdadded
3 lines changed — click to load
@@ -0,0 +1,3 @@
1
+Synthetic-import classic-lazy parity case lifted from the dedicated Apple `ld`
2
+probe. This keeps the Sprint 27 corpus honest about stub surfaces, dyld-info
3
+streams, and indirect-symbol identities for the synthetic import path.
tests/parity_corpus/synthetic_import_classic_lazy/sections.txtadded
2 lines changed — click to load
@@ -0,0 +1,2 @@
1
+__TEXT __stubs
2
+__TEXT __stub_helper
tests/parity_corpus/unexport_filter_dylib/args.txtadded
18 lines changed — click to load
@@ -0,0 +1,18 @@
1
+-dylib
2
+-arch
3
+arm64
4
+-platform_version
5
+macos
6
+@SDK_VERSION@
7
+@SDK_VERSION@
8
+-syslibroot
9
+@SDK_PATH@
10
+-lSystem
11
+-no_fixup_chains
12
+-unexported_symbol
13
+_gamma
14
+-unexported_symbols_list
15
+@FILE:hidden.txt@
16
+-o
17
+@OUT@
18
+@INPUT:main.o@
tests/parity_corpus/unexport_filter_dylib/command_checks.txtadded
3 lines changed — click to load
@@ -0,0 +1,3 @@
1
+build_version
2
+export_records
3
+symbol_record_map
tests/parity_corpus/unexport_filter_dylib/files/hidden.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+_bet?
tests/parity_corpus/unexport_filter_dylib/inputs/main.sadded
11 lines changed — click to load
@@ -0,0 +1,11 @@
1
+.section __TEXT,__text,regular,pure_instructions
2
+.globl _alpha
3
+.globl _beta
4
+.globl _gamma
5
+_alpha:
6
+    ret
7
+_beta:
8
+    ret
9
+_gamma:
10
+    ret
11
+.subsections_via_symbols
tests/parity_corpus/unexport_filter_dylib/notes.mdadded
4 lines changed — click to load
@@ -0,0 +1,4 @@
1
+Unexport-filter dylib parity case lifted from the existing `linker_run`
2
+coverage. This keeps the Sprint 27 corpus honest about `-unexported_symbol`
3
+and `-unexported_symbols_list` matching Apple `ld` in both the export trie and
4
+the final symtab surface.
tests/parity_corpus/unexport_filter_dylib/sections.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+
tests/parity_corpus/unwind_leaf_exec/absent_sections.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+__LD __compact_unwind
tests/parity_corpus/unwind_leaf_exec/args.txtadded
15 lines changed — click to load
@@ -0,0 +1,15 @@
1
+-arch
2
+arm64
3
+-platform_version
4
+macos
5
+@SDK_VERSION@
6
+@SDK_VERSION@
7
+-syslibroot
8
+@SDK_PATH@
9
+-lSystem
10
+-no_fixup_chains
11
+-e
12
+_main
13
+-o
14
+@OUT@
15
+@INPUT:main.o@
tests/parity_corpus/unwind_leaf_exec/command_checks.txtadded
3 lines changed — click to load
@@ -0,0 +1,3 @@
1
+build_version
2
+load_dylib_names
3
+rebased_unwind_bytes
tests/parity_corpus/unwind_leaf_exec/ignored_load_commands.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+LC_LOAD_DYLIB
tests/parity_corpus/unwind_leaf_exec/inputs/main.cadded
3 lines changed — click to load
@@ -0,0 +1,3 @@
1
+int main(void) {
2
+    return 0;
3
+}
tests/parity_corpus/unwind_leaf_exec/notes.mdadded
3 lines changed — click to load
@@ -0,0 +1,3 @@
1
+Leaf unwind-info parity case lifted from the existing Apple `ld` probe. This
2
+keeps the Sprint 27 corpus honest about rebased `__unwind_info` bytes for the
3
+smallest executable shape.
tests/parity_corpus/unwind_leaf_exec/sections.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+
tests/parity_corpus/unwind_multi_exec/absent_sections.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+__LD __compact_unwind
tests/parity_corpus/unwind_multi_exec/args.txtadded
15 lines changed — click to load
@@ -0,0 +1,15 @@
1
+-arch
2
+arm64
3
+-platform_version
4
+macos
5
+@SDK_VERSION@
6
+@SDK_VERSION@
7
+-syslibroot
8
+@SDK_PATH@
9
+-lSystem
10
+-no_fixup_chains
11
+-e
12
+_main
13
+-o
14
+@OUT@
15
+@INPUT:main.o@
tests/parity_corpus/unwind_multi_exec/command_checks.txtadded
3 lines changed — click to load
@@ -0,0 +1,3 @@
1
+build_version
2
+load_dylib_names
3
+rebased_unwind_bytes
tests/parity_corpus/unwind_multi_exec/ignored_load_commands.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+LC_LOAD_DYLIB
tests/parity_corpus/unwind_multi_exec/inputs/main.cadded
7 lines changed — click to load
@@ -0,0 +1,7 @@
1
+int helper(void) {
2
+    return 1;
3
+}
4
+
5
+int main(void) {
6
+    return helper();
7
+}
tests/parity_corpus/unwind_multi_exec/notes.mdadded
3 lines changed — click to load
@@ -0,0 +1,3 @@
1
+Multi-function unwind-info parity case lifted from the existing Apple `ld`
2
+probe. This keeps the Sprint 27 corpus honest about rebased `__unwind_info`
3
+bytes when more than one function contributes unwind metadata.
tests/parity_corpus/unwind_multi_exec/sections.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+
tests/parity_corpus/weak_def_coalescing_exec/args.txtadded
17 lines changed — click to load
@@ -0,0 +1,17 @@
1
+-arch
2
+arm64
3
+-platform_version
4
+macos
5
+@SDK_VERSION@
6
+@SDK_VERSION@
7
+-syslibroot
8
+@SDK_PATH@
9
+-lSystem
10
+-e
11
+_main
12
+-no_fixup_chains
13
+-o
14
+@OUT@
15
+@INPUT:main.o@
16
+@ARTIFACT:libweak_a.dylib@
17
+@ARTIFACT:libweak_b.dylib@
tests/parity_corpus/weak_def_coalescing_exec/artifacts.txtadded
2 lines changed — click to load
@@ -0,0 +1,2 @@
1
+clang_dylib weak_a.c libweak_a.dylib
2
+clang_dylib weak_b.c libweak_b.dylib
tests/parity_corpus/weak_def_coalescing_exec/command_checks.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+build_version
tests/parity_corpus/weak_def_coalescing_exec/ignored_load_commands.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+LC_SEGMENT_64
tests/parity_corpus/weak_def_coalescing_exec/inputs/main.cadded
5 lines changed — click to load
@@ -0,0 +1,5 @@
1
+int shared(void);
2
+
3
+int main(void) {
4
+    return shared() == 17 ? 0 : 1;
5
+}
tests/parity_corpus/weak_def_coalescing_exec/inputs/weak_a.cadded
3 lines changed — click to load
@@ -0,0 +1,3 @@
1
+__attribute__((weak)) int shared(void) {
2
+    return 17;
3
+}
tests/parity_corpus/weak_def_coalescing_exec/inputs/weak_b.cadded
3 lines changed — click to load
@@ -0,0 +1,3 @@
1
+__attribute__((weak)) int shared(void) {
2
+    return 23;
3
+}
tests/parity_corpus/weak_def_coalescing_exec/notes.mdadded
3 lines changed — click to load
@@ -0,0 +1,3 @@
1
+Weak-definition coalescing parity case. The executable links two dylibs that
2
+both export the same weak definition, and the final image plus runtime
3
+resolution should follow the same winner Apple `ld` chooses.
tests/parity_corpus/weak_def_coalescing_exec/runtime.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+
tests/parity_corpus/weak_def_coalescing_exec/sections.txtadded
1 lines changed — click to load
@@ -0,0 +1,1 @@
1
+__TEXT __text
tests/parity_harness.rsadded
66 lines changed — click to load
@@ -0,0 +1,66 @@
1
+//! Focused tests for Sprint 27 harness glue.
2
+
3
+mod common;
4
+
5
+use common::harness::{
6
+    apply_section_tolerances, diff_macho, parse_case_tolerances, string_table_within_five_percent,
7
+};
8
+
9
+#[test]
10
+fn notes_tolerance_block_parses_section_range() {
11
+    let notes = r#"
12
+tolerated:
13
+  - region: __TEXT,__text bytes 0x1-0x3 reason: "known padding drift"
14
+"#;
15
+    let tolerances = parse_case_tolerances(Some(notes)).expect("parse case tolerances");
16
+    assert_eq!(tolerances.len(), 1);
17
+    assert_eq!(tolerances[0].reason, "known padding drift");
18
+}
19
+
20
+#[test]
21
+fn notes_tolerance_can_hide_section_byte_diff() {
22
+    let notes = r#"
23
+tolerated:
24
+  - region: __TEXT,__text bytes 0x1-0x1 reason: "known one-byte drift"
25
+"#;
26
+    let tolerances = parse_case_tolerances(Some(notes)).expect("parse case tolerances");
27
+    let diff = diff_macho(b"abc", b"adc");
28
+    let filtered = apply_section_tolerances(diff, "__TEXT", "__text", &tolerances);
29
+    assert!(
30
+        filtered.is_clean(),
31
+        "expected tolerance to absorb diff: {filtered:#?}"
32
+    );
33
+    assert_eq!(filtered.tolerated.len(), 1);
34
+}
35
+
36
+#[test]
37
+fn notes_tolerance_does_not_hide_other_sections() {
38
+    let notes = r#"
39
+tolerated:
40
+  - region: __TEXT,__text bytes 0x1-0x1 reason: "known one-byte drift"
41
+"#;
42
+    let tolerances = parse_case_tolerances(Some(notes)).expect("parse case tolerances");
43
+    let diff = diff_macho(b"abc", b"adc");
44
+    let filtered = apply_section_tolerances(diff, "__DATA", "__data", &tolerances);
45
+    assert!(
46
+        !filtered.is_clean(),
47
+        "unexpectedly tolerated unrelated diff: {filtered:#?}"
48
+    );
49
+    assert_eq!(filtered.critical.len(), 1);
50
+}
51
+
52
+#[test]
53
+fn string_table_near_parity_accepts_small_suffix_dedup_drift() {
54
+    assert!(
55
+        string_table_within_five_percent(101, 100),
56
+        "1% string-table drift should stay within the Sprint 27 allowance"
57
+    );
58
+}
59
+
60
+#[test]
61
+fn string_table_near_parity_rejects_large_suffix_dedup_drift() {
62
+    assert!(
63
+        !string_table_within_five_percent(120, 100),
64
+        "20% string-table drift should fail the Sprint 27 allowance"
65
+    );
66
+}
tests/parity_matrix.rsadded
458 lines changed — click to load
@@ -0,0 +1,458 @@
1
+//! Differential parity matrix against Apple `ld`.
2
+//!
3
+//! Sprint 27 starts with a tiny executable-only corpus so the reusable harness,
4
+//! on-disk case format, and runtime parity path all exist before we scale up to
5
+//! the full corpus promised by the sprint doc.
6
+
7
+mod common;
8
+
9
+use std::fs;
10
+use std::path::{Path, PathBuf};
11
+use std::sync::{mpsc, Arc, Mutex};
12
+use std::thread;
13
+use std::time::{Duration, Instant};
14
+
15
+use common::harness::{
16
+    compare_command_details, compare_command_ids, compare_page_refs, compare_runtime,
17
+    compare_sections, ensure_absent_load_commands, ensure_absent_sections, have_xcrun,
18
+    have_xcrun_tool, link_both, load_corpus, LinkCase,
19
+};
20
+
21
+#[test]
22
+fn parity_corpus() {
23
+    if !have_xcrun() || !have_xcrun_tool("ld") {
24
+        eprintln!("skipping: xcrun as/ld unavailable");
25
+        return;
26
+    }
27
+    let started = Instant::now();
28
+
29
+    let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
30
+        .join("tests")
31
+        .join("parity_corpus");
32
+    let cases = load_corpus(&root).expect("load parity corpus");
33
+    assert!(
34
+        cases.len() >= 50,
35
+        "expected at least 50 parity corpus cases under {}, found {}",
36
+        root.display(),
37
+        cases.len()
38
+    );
39
+
40
+    let artifact_dir = std::env::var_os("PARITY_MATRIX_ARTIFACT_DIR").map(PathBuf::from);
41
+    if let Some(dir) = artifact_dir.as_ref() {
42
+        fs::create_dir_all(dir).expect("create parity artifact dir");
43
+    }
44
+
45
+    let mut failures = Vec::new();
46
+    let case_reports = run_cases(cases);
47
+
48
+    for (case, report) in &case_reports {
49
+        if let Some(dir) = artifact_dir.as_ref() {
50
+            write_case_artifact(dir, case, report).expect("write case artifact");
51
+        }
52
+        if let Some(error) = report.error_message(&case.name) {
53
+            eprintln!("parity failure:\n{error}\n");
54
+            failures.push(error);
55
+        }
56
+    }
57
+    print_timing_summary(started.elapsed(), &case_reports);
58
+
59
+    if let Some(dir) = artifact_dir.as_ref() {
60
+        write_index_artifact(dir, &case_reports).expect("write parity index");
61
+    }
62
+
63
+    assert!(
64
+        failures.is_empty(),
65
+        "Parity matrix failures ({} cases):\n{}",
66
+        failures.len(),
67
+        failures.join("\n\n")
68
+    );
69
+
70
+    if let Some(limit) = parity_matrix_time_limit() {
71
+        let elapsed = started.elapsed();
72
+        assert!(
73
+            elapsed <= limit,
74
+            "parity matrix exceeded scale budget: {:?} > {:?}",
75
+            elapsed,
76
+            limit
77
+        );
78
+    }
79
+}
80
+
81
+fn run_cases(cases: Vec<LinkCase>) -> Vec<(LinkCase, CaseReport)> {
82
+    let job_count = parity_matrix_jobs(cases.len());
83
+    if job_count <= 1 || cases.len() <= 1 {
84
+        return cases
85
+            .into_iter()
86
+            .map(|case| {
87
+                let report = run_case(&case);
88
+                (case, report)
89
+            })
90
+            .collect();
91
+    }
92
+
93
+    let queue = Arc::new(Mutex::new(cases.into_iter().enumerate()));
94
+    let (tx, rx) = mpsc::channel();
95
+    thread::scope(|scope| {
96
+        for _ in 0..job_count {
97
+            let queue = Arc::clone(&queue);
98
+            let tx = tx.clone();
99
+            scope.spawn(move || loop {
100
+                let Some((index, case)) = queue
101
+                    .lock()
102
+                    .expect("parity case queue mutex poisoned")
103
+                    .next()
104
+                else {
105
+                    break;
106
+                };
107
+                let report = run_case(&case);
108
+                tx.send((index, case, report))
109
+                    .expect("parity result receiver should stay live");
110
+            });
111
+        }
112
+        drop(tx);
113
+        let mut reports: Vec<_> = rx.into_iter().collect();
114
+        reports.sort_by_key(|(index, _, _)| *index);
115
+        reports
116
+            .into_iter()
117
+            .map(|(_, case, report)| (case, report))
118
+            .collect()
119
+    })
120
+}
121
+
122
+#[derive(Debug)]
123
+struct CaseStep {
124
+    name: &'static str,
125
+    duration: Duration,
126
+    error: Option<String>,
127
+}
128
+
129
+#[derive(Debug, Default)]
130
+struct CaseReport {
131
+    steps: Vec<CaseStep>,
132
+    elapsed: Duration,
133
+}
134
+
135
+impl CaseReport {
136
+    fn push(&mut self, name: &'static str, result: Result<(), String>) -> bool {
137
+        self.push_timed(name, Duration::ZERO, result)
138
+    }
139
+
140
+    fn push_timed(
141
+        &mut self,
142
+        name: &'static str,
143
+        duration: Duration,
144
+        result: Result<(), String>,
145
+    ) -> bool {
146
+        match result {
147
+            Ok(()) => {
148
+                self.steps.push(CaseStep {
149
+                    name,
150
+                    duration,
151
+                    error: None,
152
+                });
153
+                true
154
+            }
155
+            Err(error) => {
156
+                self.steps.push(CaseStep {
157
+                    name,
158
+                    duration,
159
+                    error: Some(error),
160
+                });
161
+                false
162
+            }
163
+        }
164
+    }
165
+
166
+    fn measure<F>(&mut self, name: &'static str, action: F) -> bool
167
+    where
168
+        F: FnOnce() -> Result<(), String>,
169
+    {
170
+        let started = Instant::now();
171
+        let result = action();
172
+        self.push_timed(name, started.elapsed(), result)
173
+    }
174
+
175
+    fn finish(&mut self, elapsed: Duration) {
176
+        self.elapsed = elapsed;
177
+    }
178
+
179
+    fn passed(&self) -> bool {
180
+        self.steps.iter().all(|step| step.error.is_none())
181
+    }
182
+
183
+    fn slowest_step(&self) -> Option<&CaseStep> {
184
+        self.steps.iter().max_by_key(|step| step.duration)
185
+    }
186
+
187
+    fn error_message(&self, case_name: &str) -> Option<String> {
188
+        self.steps.iter().find_map(|step| {
189
+            step.error
190
+                .as_ref()
191
+                .map(|error| format!("[{case_name}] {} failed:\n{}", step.name, error))
192
+        })
193
+    }
194
+}
195
+
196
+#[test]
197
+fn case_report_error_message_includes_case_name() {
198
+    let mut report = CaseReport::default();
199
+    report.push("section parity", Err("stub bytes differ".into()));
200
+    assert_eq!(
201
+        report.error_message("classic_lazy_branch_only_calls"),
202
+        Some(
203
+            "[classic_lazy_branch_only_calls] section parity failed:\nstub bytes differ"
204
+                .to_string()
205
+        )
206
+    );
207
+}
208
+
209
+fn run_case(case: &LinkCase) -> CaseReport {
210
+    let case_started = Instant::now();
211
+    let mut report = CaseReport::default();
212
+    let link_started = Instant::now();
213
+    let outputs = match link_both(case) {
214
+        Ok(outputs) => {
215
+            report.push_timed("link", link_started.elapsed(), Ok(()));
216
+            outputs
217
+        }
218
+        Err(error) => {
219
+            report.push_timed(
220
+                "link",
221
+                link_started.elapsed(),
222
+                Err(format!(
223
+                    "failed to link parity case from {}:\n{}",
224
+                    case.dir.display(),
225
+                    error
226
+                )),
227
+            );
228
+            return finish_case(report, case_started);
229
+        }
230
+    };
231
+
232
+    if !report.measure("load-command ids", || {
233
+        compare_command_ids(&outputs.ours, &outputs.theirs, &case.ignored_load_commands)
234
+    }) {
235
+        return finish_case(report, case_started);
236
+    }
237
+    if !report.measure("command details", || {
238
+        compare_command_details(&outputs.ours, &outputs.theirs, &case.command_checks)
239
+    }) {
240
+        return finish_case(report, case_started);
241
+    }
242
+    if !report.measure("afs-ld absent commands", || {
243
+        ensure_absent_load_commands(&outputs.ours, &case.absent_load_commands, "afs-ld")
244
+    }) {
245
+        return finish_case(report, case_started);
246
+    }
247
+    if !report.measure("Apple absent commands", || {
248
+        ensure_absent_load_commands(&outputs.theirs, &case.absent_load_commands, "Apple ld")
249
+    }) {
250
+        return finish_case(report, case_started);
251
+    }
252
+    if !report.measure("afs-ld absent sections", || {
253
+        ensure_absent_sections(&outputs.ours, &case.absent_sections, "afs-ld")
254
+    }) {
255
+        return finish_case(report, case_started);
256
+    }
257
+    if !report.measure("Apple absent sections", || {
258
+        ensure_absent_sections(&outputs.theirs, &case.absent_sections, "Apple ld")
259
+    }) {
260
+        return finish_case(report, case_started);
261
+    }
262
+    if !report.measure("section parity", || {
263
+        compare_sections(
264
+            &outputs.ours,
265
+            &outputs.theirs,
266
+            &case.section_checks,
267
+            &case.case_tolerances,
268
+        )
269
+    }) {
270
+        return finish_case(report, case_started);
271
+    }
272
+    if !report.measure("page-ref parity", || {
273
+        compare_page_refs(&outputs.ours, &outputs.theirs, &case.page_ref_checks)
274
+    }) {
275
+        return finish_case(report, case_started);
276
+    }
277
+    if !case.runtime_args.is_empty() || case.dir.join("runtime.txt").exists() {
278
+        report.measure("runtime parity", || {
279
+            compare_runtime(&outputs.our_path, &outputs.their_path, &case.runtime_args)
280
+        });
281
+    }
282
+
283
+    finish_case(report, case_started)
284
+}
285
+
286
+fn finish_case(mut report: CaseReport, started: Instant) -> CaseReport {
287
+    report.finish(started.elapsed());
288
+    report
289
+}
290
+
291
+fn write_case_artifact(dir: &Path, case: &LinkCase, report: &CaseReport) -> Result<(), String> {
292
+    let path = dir.join(format!("{}.html", slug(&case.name)));
293
+    let mut html = String::new();
294
+    html.push_str("<!doctype html><html><head><meta charset=\"utf-8\">");
295
+    html.push_str(&format!(
296
+        "<title>{}</title><style>body{{font-family:ui-monospace,Menlo,monospace;padding:2rem;}} .ok{{color:#0a0;}} .fail{{color:#a00;}} pre{{background:#f6f8fa;padding:1rem;white-space:pre-wrap;}}</style></head><body>",
297
+        escape_html(&case.name)
298
+    ));
299
+    html.push_str(&format!("<h1>{}</h1>", escape_html(&case.name)));
300
+    html.push_str(&format!(
301
+        "<p>Status: <strong class=\"{}\">{}</strong></p>",
302
+        if report.passed() { "ok" } else { "fail" },
303
+        if report.passed() { "PASS" } else { "FAIL" }
304
+    ));
305
+    html.push_str(&format!(
306
+        "<p>Total: <strong>{}</strong></p>",
307
+        format_duration(report.elapsed)
308
+    ));
309
+    html.push_str("<h2>Steps</h2><ul>");
310
+    for step in &report.steps {
311
+        match &step.error {
312
+            None => html.push_str(&format!(
313
+                "<li><span class=\"ok\">PASS</span> {} <span class=\"time\">{}</span></li>",
314
+                escape_html(step.name),
315
+                format_duration(step.duration)
316
+            )),
317
+            Some(error) => html.push_str(&format!(
318
+                "<li><span class=\"fail\">FAIL</span> {} <span class=\"time\">{}</span><pre>{}</pre></li>",
319
+                escape_html(step.name),
320
+                format_duration(step.duration),
321
+                escape_html(error)
322
+            )),
323
+        }
324
+    }
325
+    html.push_str("</ul>");
326
+    html.push_str("<h2>Args</h2><pre>");
327
+    html.push_str(&escape_html(&case.args.join("\n")));
328
+    html.push_str("</pre>");
329
+    if let Some(notes) = &case.notes {
330
+        html.push_str("<h2>Notes</h2><pre>");
331
+        html.push_str(&escape_html(notes));
332
+        html.push_str("</pre>");
333
+    }
334
+    html.push_str("</body></html>");
335
+    fs::write(&path, html).map_err(|e| format!("write {}: {e}", path.display()))
336
+}
337
+
338
+fn write_index_artifact(dir: &Path, cases: &[(LinkCase, CaseReport)]) -> Result<(), String> {
339
+    let mut html = String::new();
340
+    html.push_str("<!doctype html><html><head><meta charset=\"utf-8\">");
341
+    html.push_str("<title>Parity Matrix</title><style>body{font-family:ui-monospace,Menlo,monospace;padding:2rem;} .ok{color:#0a0;} .fail{color:#a00;} .time{color:#57606a;} table{border-collapse:collapse;margin:1rem 0;} td,th{border:1px solid #d0d7de;padding:.35rem .6rem;text-align:left;}</style></head><body>");
342
+    html.push_str("<h1>Parity Matrix</h1>");
343
+    html.push_str("<h2>Slowest Cases</h2><table><thead><tr><th>Case</th><th>Total</th><th>Slowest Step</th></tr></thead><tbody>");
344
+    for (case, report) in slowest_cases(cases, 10) {
345
+        let slowest = report
346
+            .slowest_step()
347
+            .map(|step| format!("{} {}", step.name, format_duration(step.duration)))
348
+            .unwrap_or_else(|| "n/a".to_string());
349
+        html.push_str(&format!(
350
+            "<tr><td><a href=\"{}.html\">{}</a></td><td>{}</td><td>{}</td></tr>",
351
+            slug(&case.name),
352
+            escape_html(&case.name),
353
+            format_duration(report.elapsed),
354
+            escape_html(&slowest)
355
+        ));
356
+    }
357
+    html.push_str("</tbody></table><h2>Cases</h2><ul>");
358
+    for (case, report) in cases {
359
+        let slug = slug(&case.name);
360
+        html.push_str(&format!(
361
+            "<li><a href=\"{}.html\">{}</a> <strong class=\"{}\">{}</strong> <span class=\"time\">{}</span></li>",
362
+            slug,
363
+            escape_html(&case.name),
364
+            if report.passed() { "ok" } else { "fail" },
365
+            if report.passed() { "PASS" } else { "FAIL" },
366
+            format_duration(report.elapsed)
367
+        ));
368
+    }
369
+    html.push_str("</ul></body></html>");
370
+    let path = dir.join("index.html");
371
+    fs::write(&path, html).map_err(|e| format!("write {}: {e}", path.display()))
372
+}
373
+
374
+fn print_timing_summary(elapsed: Duration, cases: &[(LinkCase, CaseReport)]) {
375
+    eprintln!(
376
+        "parity matrix timing: {} case(s) in {}",
377
+        cases.len(),
378
+        format_duration(elapsed)
379
+    );
380
+    for (case, report) in slowest_cases(cases, 10) {
381
+        let slowest = report
382
+            .slowest_step()
383
+            .map(|step| {
384
+                format!(
385
+                    "; slowest step: {} {}",
386
+                    step.name,
387
+                    format_duration(step.duration)
388
+                )
389
+            })
390
+            .unwrap_or_default();
391
+        eprintln!(
392
+            "  {:>9} {}{}",
393
+            format_duration(report.elapsed),
394
+            case.name,
395
+            slowest
396
+        );
397
+    }
398
+}
399
+
400
+fn slowest_cases(cases: &[(LinkCase, CaseReport)], limit: usize) -> Vec<(&LinkCase, &CaseReport)> {
401
+    let mut timed: Vec<_> = cases.iter().map(|(case, report)| (case, report)).collect();
402
+    timed.sort_by(|a, b| {
403
+        b.1.elapsed
404
+            .cmp(&a.1.elapsed)
405
+            .then_with(|| a.0.name.cmp(&b.0.name))
406
+    });
407
+    timed.truncate(limit);
408
+    timed
409
+}
410
+
411
+fn slug(name: &str) -> String {
412
+    name.chars()
413
+        .map(|ch| {
414
+            if ch.is_ascii_alphanumeric() {
415
+                ch.to_ascii_lowercase()
416
+            } else {
417
+                '-'
418
+            }
419
+        })
420
+        .collect()
421
+}
422
+
423
+fn parity_matrix_time_limit() -> Option<Duration> {
424
+    let raw = std::env::var("PARITY_MATRIX_MAX_SECONDS").ok()?;
425
+    let seconds = raw.parse::<u64>().ok()?;
426
+    Some(Duration::from_secs(seconds))
427
+}
428
+
429
+fn parity_matrix_jobs(case_count: usize) -> usize {
430
+    if case_count == 0 {
431
+        return 1;
432
+    }
433
+    let requested = std::env::var("PARITY_MATRIX_JOBS")
434
+        .ok()
435
+        .and_then(|raw| raw.parse::<usize>().ok())
436
+        .filter(|jobs| *jobs > 0)
437
+        .unwrap_or_else(|| {
438
+            thread::available_parallelism()
439
+                .map(usize::from)
440
+                .unwrap_or(1)
441
+        });
442
+    requested.min(case_count).max(1)
443
+}
444
+
445
+fn format_duration(duration: Duration) -> String {
446
+    let millis = duration.as_secs_f64() * 1000.0;
447
+    if millis >= 1000.0 {
448
+        format!("{:.2}s", duration.as_secs_f64())
449
+    } else {
450
+        format!("{millis:.1}ms")
451
+    }
452
+}
453
+
454
+fn escape_html(text: &str) -> String {
455
+    text.replace('&', "&amp;")
456
+        .replace('<', "&lt;")
457
+        .replace('>', "&gt;")
458
+}
tests/perf_baseline.rsadded
289 lines changed — click to load
@@ -0,0 +1,289 @@
1
+use std::fs;
2
+use std::path::{Path, PathBuf};
3
+use std::process::Command;
4
+use std::time::Duration;
5
+
6
+mod common;
7
+
8
+use afs_ld::{LinkOptions, LinkProfile, Linker};
9
+use common::harness::{
10
+    assemble, have_tool, have_xcrun, have_xcrun_tool, scratch, sdk_path, sdk_version,
11
+};
12
+
13
+fn find_runtime_archive() -> Option<PathBuf> {
14
+    let workspace = Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
15
+    for profile in ["debug", "release"] {
16
+        let candidate = workspace
17
+            .join("target")
18
+            .join(profile)
19
+            .join("libarmfortas_rt.a");
20
+        if candidate.is_file() {
21
+            return Some(candidate);
22
+        }
23
+    }
24
+    None
25
+}
26
+
27
+fn runtime_archive_fixture() -> Result<PathBuf, String> {
28
+    if let Some(runtime) = find_runtime_archive() {
29
+        return Ok(runtime);
30
+    }
31
+    build_synthetic_runtime_archive()
32
+}
33
+
34
+fn build_synthetic_runtime_archive() -> Result<PathBuf, String> {
35
+    if !have_tool("libtool") {
36
+        return Err("libtool unavailable".into());
37
+    }
38
+
39
+    let members = [
40
+        ("init", "_afs_program_init"),
41
+        ("finalize", "_afs_program_finalize"),
42
+        ("write_i32", "_afs_write_i32"),
43
+        ("write_f64", "_afs_write_f64"),
44
+        ("write_newline", "_afs_write_newline"),
45
+        ("read_i32", "_afs_read_i32"),
46
+        ("alloc", "_afs_alloc"),
47
+        ("dealloc", "_afs_dealloc"),
48
+        ("bounds_check", "_afs_bounds_check"),
49
+        ("stop", "_afs_stop"),
50
+        ("date_and_time", "_afs_date_and_time"),
51
+        ("cpu_time", "_afs_cpu_time"),
52
+        ("random_seed", "_afs_random_seed"),
53
+        ("random_number", "_afs_random_number"),
54
+        ("open_unit", "_afs_open_unit"),
55
+        ("close_unit", "_afs_close_unit"),
56
+    ];
57
+    let mut objects = Vec::with_capacity(members.len());
58
+    for (stem, symbol) in members {
59
+        let obj = scratch(&format!("perf-runtime-{stem}.o"));
60
+        let src = format!(
61
+            "\
62
+            .text\n\
63
+            .globl {symbol}\n\
64
+            .p2align 2\n\
65
+            {symbol}:\n\
66
+                ret\n\
67
+            .subsections_via_symbols\n",
68
+        );
69
+        assemble(&src, &obj)?;
70
+        objects.push(obj);
71
+    }
72
+
73
+    let archive = scratch("libafs-perf-runtime.a");
74
+    let _ = fs::remove_file(&archive);
75
+    let output = Command::new("libtool")
76
+        .args(["-static", "-o"])
77
+        .arg(&archive)
78
+        .args(&objects)
79
+        .output()
80
+        .map_err(|e| format!("spawn libtool archive: {e}"))?;
81
+    if !output.status.success() {
82
+        return Err(format!(
83
+            "libtool archive failed: {}",
84
+            String::from_utf8_lossy(&output.stderr)
85
+        ));
86
+    }
87
+    Ok(archive)
88
+}
89
+
90
+fn executable_opts(inputs: Vec<PathBuf>, output: PathBuf) -> LinkOptions {
91
+    LinkOptions {
92
+        inputs,
93
+        output: Some(output),
94
+        syslibroot: sdk_path().map(PathBuf::from),
95
+        platform_version: sdk_version().map(|v| {
96
+            let parsed = afs_ld::macho::tbd::parse_version(&v);
97
+            afs_ld::PlatformVersion {
98
+                minos: parsed,
99
+                sdk: parsed,
100
+            }
101
+        }),
102
+        library_names: vec!["System".into()],
103
+        ..LinkOptions::default()
104
+    }
105
+}
106
+
107
+fn assert_profile_basics(name: &str, profile: &LinkProfile) {
108
+    eprintln!(
109
+        "{name}: total={:?} parse={:?} resolve={:?} atomize={:?} layout={:?} (entry={:?} dead={:?} icf={:?} synth_plan={:?} build={:?} thunks={:?}) synth={:?} (linkedit={:?}: symbols={:?} [locals={:?} globals={:?} strtab={:?}] dyld={:?} [bind={:?} rebase={:?} export={:?}] metadata={:?} codesig={:?}; unwind={:?}) reloc={:?} write={:?}",
110
+        profile.total_wall,
111
+        profile.phases.input_parsing,
112
+        profile.phases.symbol_resolution,
113
+        profile.phases.atomization,
114
+        profile.phases.layout,
115
+        profile.phases.layout_entry_lookup,
116
+        profile.phases.layout_dead_strip,
117
+        profile.phases.layout_icf,
118
+        profile.phases.layout_synthetic_plan,
119
+        profile.phases.layout_build,
120
+        profile.phases.layout_thunk_plan,
121
+        profile.phases.synth_sections,
122
+        profile.phases.synth_linkedit_finalize,
123
+        profile.phases.synth_linkedit_symbol_plan,
124
+        profile.phases.synth_linkedit_symbol_plan_locals,
125
+        profile.phases.synth_linkedit_symbol_plan_globals,
126
+        profile.phases.synth_linkedit_symbol_plan_strtab,
127
+        profile.phases.synth_linkedit_dyld_info,
128
+        profile.phases.synth_linkedit_dyld_bind,
129
+        profile.phases.synth_linkedit_dyld_rebase,
130
+        profile.phases.synth_linkedit_dyld_export,
131
+        profile.phases.synth_linkedit_metadata_tables,
132
+        profile.phases.synth_linkedit_code_signature,
133
+        profile.phases.synth_unwind,
134
+        profile.phases.reloc_apply,
135
+        profile.phases.write_output,
136
+    );
137
+    eprintln!(
138
+        "{name}: input read={:?} object={:?} archive={:?} dylib={:?} tbd_decode={:?} tbd_materialize={:?} reloc_cache={:?}",
139
+        profile.phases.input_read,
140
+        profile.phases.input_object_parse,
141
+        profile.phases.input_archive_parse,
142
+        profile.phases.input_dylib_parse,
143
+        profile.phases.input_tbd_decode,
144
+        profile.phases.input_tbd_materialize,
145
+        profile.phases.input_reloc_parse,
146
+    );
147
+    assert!(profile.output.is_file(), "{name}: output file missing");
148
+    assert!(
149
+        profile.total_wall >= profile.phases.accounted_total(),
150
+        "{name}: accounted phases exceeded total wall time"
151
+    );
152
+    assert!(
153
+        profile.phases.accounted_total() > Duration::ZERO,
154
+        "{name}: all phase timings were zero"
155
+    );
156
+    let input_subphase_total = profile.phases.input_read
157
+        + profile.phases.input_object_parse
158
+        + profile.phases.input_archive_parse
159
+        + profile.phases.input_dylib_parse
160
+        + profile.phases.input_tbd_decode
161
+        + profile.phases.input_tbd_materialize
162
+        + profile.phases.input_reloc_parse;
163
+    // Input subphases are summed worker-time once object parsing is parallel,
164
+    // so they can legitimately exceed the wall-clock input parsing bucket.
165
+    assert!(
166
+        input_subphase_total > Duration::ZERO,
167
+        "{name}: all input subphase timings were zero"
168
+    );
169
+    assert!(
170
+        profile.phases.layout
171
+            >= profile.phases.layout_entry_lookup
172
+                + profile.phases.layout_dead_strip
173
+                + profile.phases.layout_icf
174
+                + profile.phases.layout_synthetic_plan
175
+                + profile.phases.layout_build
176
+                + profile.phases.layout_thunk_plan,
177
+        "{name}: layout subphases exceeded layout total"
178
+    );
179
+    assert!(
180
+        profile.phases.synth_sections
181
+            >= profile.phases.synth_linkedit_finalize + profile.phases.synth_unwind,
182
+        "{name}: synth subphases exceeded synth total"
183
+    );
184
+    assert!(
185
+        profile.phases.synth_linkedit_finalize
186
+            >= profile.phases.synth_linkedit_symbol_plan
187
+                + profile.phases.synth_linkedit_dyld_info
188
+                + profile.phases.synth_linkedit_metadata_tables
189
+                + profile.phases.synth_linkedit_code_signature,
190
+        "{name}: linkedit subphases exceeded linkedit total"
191
+    );
192
+    assert!(
193
+        profile.phases.synth_linkedit_symbol_plan
194
+            >= profile.phases.synth_linkedit_symbol_plan_locals
195
+                + profile.phases.synth_linkedit_symbol_plan_globals
196
+                + profile.phases.synth_linkedit_symbol_plan_strtab,
197
+        "{name}: symbol-plan subphases exceeded symbol-plan total"
198
+    );
199
+    assert!(
200
+        profile.phases.synth_linkedit_dyld_info
201
+            >= profile.phases.synth_linkedit_dyld_bind
202
+                + profile.phases.synth_linkedit_dyld_rebase
203
+                + profile.phases.synth_linkedit_dyld_export,
204
+        "{name}: dyld-info subphases exceeded dyld-info total"
205
+    );
206
+}
207
+
208
+#[test]
209
+fn bench_hello_world_profile_reports_baseline_timings() {
210
+    if !have_xcrun() || !have_xcrun_tool("ld") {
211
+        eprintln!("skipping: xcrun as/ld unavailable");
212
+        return;
213
+    }
214
+
215
+    let obj = scratch("perf-hello.o");
216
+    let out = scratch("perf-hello.out");
217
+    assemble(
218
+        "\
219
+        .text\n\
220
+        .globl _main\n\
221
+        .p2align 2\n\
222
+        _main:\n\
223
+            mov w0, #0\n\
224
+            ret\n",
225
+        &obj,
226
+    )
227
+    .expect("assemble hello");
228
+
229
+    let profile = Linker::run_profiled(&executable_opts(vec![obj], out)).expect("profile hello");
230
+    assert_profile_basics("hello", &profile);
231
+
232
+    if let Ok(limit_ms) = std::env::var("AFS_LD_HELLO_BUDGET_MS") {
233
+        let limit = Duration::from_millis(limit_ms.parse().expect("parse hello budget"));
234
+        assert!(
235
+            profile.total_wall <= limit,
236
+            "hello baseline exceeded budget: {:?} > {:?}",
237
+            profile.total_wall,
238
+            limit
239
+        );
240
+    }
241
+}
242
+
243
+#[test]
244
+fn bench_runtime_link_profile_reports_baseline_timings() {
245
+    if !have_xcrun() || !have_xcrun_tool("ld") {
246
+        eprintln!("skipping: xcrun as/ld unavailable");
247
+        return;
248
+    }
249
+    let runtime = match runtime_archive_fixture() {
250
+        Ok(runtime) => runtime,
251
+        Err(reason) => {
252
+            eprintln!("skipping: {reason}");
253
+            return;
254
+        }
255
+    };
256
+
257
+    let obj = scratch("perf-runtime.o");
258
+    let out = scratch("perf-runtime.out");
259
+    assemble(
260
+        "\
261
+        .text\n\
262
+        .globl _main\n\
263
+        .p2align 2\n\
264
+        _main:\n\
265
+            stp x29, x30, [sp, #-16]!\n\
266
+            mov x29, sp\n\
267
+            bl _afs_program_init\n\
268
+            bl _afs_program_finalize\n\
269
+            mov w0, #0\n\
270
+            ldp x29, x30, [sp], #16\n\
271
+            ret\n",
272
+        &obj,
273
+    )
274
+    .expect("assemble runtime");
275
+
276
+    let profile = Linker::run_profiled(&executable_opts(vec![obj, runtime], out))
277
+        .expect("profile runtime link");
278
+    assert_profile_basics("runtime", &profile);
279
+
280
+    if let Ok(limit_ms) = std::env::var("AFS_LD_RUNTIME_BUDGET_MS") {
281
+        let limit = Duration::from_millis(limit_ms.parse().expect("parse runtime budget"));
282
+        assert!(
283
+            profile.total_wall <= limit,
284
+            "runtime baseline exceeded budget: {:?} > {:?}",
285
+            profile.total_wall,
286
+            limit
287
+        );
288
+    }
289
+}
tests/resolve_integration.rsmodified
10 lines changed — click to load
@@ -164,8 +164,8 @@ fn resolve_pipeline_pulls_archive_member_and_flags_missing() {
164164
         "unexpected duplicates in seeding: {:?}",
165165
         seed_report.duplicates
166166
     );
167
-    let drain_report =
168
-        drain_fetches(&mut inputs, &mut table, seed_report.pending_fetches).expect("drain_fetches");
167
+    let drain_report = drain_fetches(&mut inputs, &mut table, seed_report.pending_fetches, 1)
168
+        .expect("drain_fetches");
169169
     assert!(
170170
         drain_report.fetched_members >= 1,
171171
         "expected at least one archive member fetched; got {}",
tests/snapshots/help.txtadded
50 lines changed — click to load
@@ -0,0 +1,50 @@
1
+Usage: afs-ld [options] <inputs...>
2
+
3
+Options:
4
+  -o <path>                       Write output to <path>
5
+  -dylib                          Emit a dylib instead of an executable
6
+  -e <symbol>                     Set the entry symbol
7
+  -arch arm64                     Select the arm64 target
8
+  -map <path>                     Emit text link map
9
+  -why_live <symbol>              Print a reachability chain for <symbol>
10
+  -l<name> / -l <name>            Search for library
11
+  -L <dir>                        Add library search path
12
+  -framework <name>               Link framework
13
+  -weak_framework <name>          Link weak framework
14
+  -ObjC                           Objective-C archive loading mode (currently a no-op warning)
15
+  -syslibroot <path>              Prefix SDK search roots
16
+  -platform_version macos <min> <sdk>
17
+                                  Set LC_BUILD_VERSION payload
18
+  -r                              Relocatable output (deferred; errors)
19
+  -bundle                         Bundle output (deferred; errors)
20
+  -undefined <error|warning|suppress|dynamic_lookup>
21
+                                  Control unresolved-symbol treatment
22
+  -rpath <path>                   Add LC_RPATH
23
+  -install_name <path>            Override dylib install name
24
+  -current_version <v>            Override dylib current version
25
+  -compatibility_version <v>      Override dylib compatibility version
26
+  -exported_symbols_list <file>   Export only symbols matching file patterns
27
+  -unexported_symbols_list <file> Hide symbols matching file patterns
28
+  -exported_symbol <sym>          Export one symbol/pattern
29
+  -unexported_symbol <sym>        Hide one symbol/pattern
30
+  -x                              Strip local symbols
31
+  -S                              Strip debug symbols (currently a no-op warning)
32
+  -no_uuid                        Omit LC_UUID
33
+  -no_loh                         Accepted for compatibility (currently warns; no effect)
34
+  -thunks=<none|safe|all>         Configure branch thunks
35
+  -dead_strip                     Dead-strip unreferenced code/data
36
+  -icf=safe | -icf=none | -icf=all
37
+                                  Configure identical code folding (`all` currently errors)
38
+  -fixup_chains | -no_fixup_chains
39
+                                  Select chained fixups vs classic dyld info
40
+  -all_load                       Force-load every archive member
41
+  -force_load <archive>           Force-load one archive
42
+  -j <jobs>                       Limit parallel worker jobs (`1` disables parallelism)
43
+  -Wl,<arg,arg,...>               Normalize comma-separated driver flags
44
+  --dump <path>                   Dump a Mach-O file summary
45
+  --dump-archive <path>           Dump an archive summary
46
+  --dump-dylib <path>             Dump a dylib summary
47
+  --dump-tbd <path>               Dump a TBD summary
48
+  -t, -trace                      Print input paths as they are loaded
49
+  -h, --help                      Show this help
50
+  -v, --version                   Show afs-ld version
tests/tbd_integration.rsmodified
51 lines changed — click to load
@@ -20,7 +20,7 @@
2020
 //! Skipped if `xcrun` or `libSystem.tbd` aren't present.
2121
 
2222
 use afs_ld::macho::dylib::{DylibFile, DylibLoadKind};
23
-use afs_ld::macho::tbd::{parse_tbd, Arch, Platform, Target};
23
+use afs_ld::macho::tbd::{parse_tbd, parse_tbd_for_target, Arch, Platform, Target};
2424
 
2525
 fn sdk_path() -> Option<String> {
2626
     let out = std::process::Command::new("xcrun")
@@ -54,6 +54,12 @@ fn libsystem_tbd_materializes_into_dylib_file() {
5454
         arch: Arch::Arm64,
5555
         platform: Platform::MacOs,
5656
     };
57
+    let fast_docs = parse_tbd_for_target(&src, &target)
58
+        .unwrap_or_else(|e| panic!("libSystem.tbd fast path failed to parse: {e}"));
59
+    assert!(
60
+        !fast_docs.is_empty(),
61
+        "fast path did not keep any arm64-compatible documents"
62
+    );
5763
     let dy = DylibFile::from_tbd(&path, main, &target);
5864
 
5965
     assert_eq!(dy.install_name, "/usr/lib/libSystem.B.dylib");
@@ -106,6 +112,31 @@ fn libsystem_tbd_materializes_into_dylib_file() {
106112
         "_free not found anywhere in libSystem's TBD re-export chain"
107113
     );
108114
 
115
+    let mut fast_found = std::collections::HashSet::<&str>::new();
116
+    for doc in &fast_docs {
117
+        let sub = DylibFile::from_tbd(&path, doc, &target);
118
+        for entry in sub.exports.entries().unwrap() {
119
+            match entry.name.as_str() {
120
+                "_atexit" => {
121
+                    fast_found.insert("_atexit");
122
+                }
123
+                "_write" => {
124
+                    fast_found.insert("_write");
125
+                }
126
+                "__Unwind_Backtrace" => {
127
+                    fast_found.insert("__Unwind_Backtrace");
128
+                }
129
+                _ => {}
130
+            }
131
+        }
132
+    }
133
+    for expected in ["_atexit", "_write", "__Unwind_Backtrace"] {
134
+        assert!(
135
+            fast_found.contains(expected),
136
+            "{expected} not found by libSystem fast path; got {fast_found:?}"
137
+        );
138
+    }
139
+
109140
     // libSystem re-exports most actual libc symbols (malloc, free, etc.) from
110141
     // sub-dylibs. They come from the `reexported-libraries`, not from
111142
     // libSystem's own exports. Confirm we captured the chain.
tests/writer_smoke.rsadded
116 lines changed — click to load
@@ -0,0 +1,116 @@
1
+use std::fs;
2
+use std::path::{Path, PathBuf};
3
+use std::process::Command;
4
+
5
+use afs_ld::layout::Layout;
6
+use afs_ld::macho::constants::{LC_ID_DYLIB, MH_DYLIB, MH_EXECUTE};
7
+use afs_ld::macho::reader::{parse_commands, parse_header, LoadCommand};
8
+use afs_ld::macho::writer::write;
9
+use afs_ld::{LinkOptions, OutputKind};
10
+
11
+fn have_tool(name: &str) -> bool {
12
+    Command::new(name)
13
+        .arg("-h")
14
+        .output()
15
+        .map(|_| true)
16
+        .unwrap_or(false)
17
+}
18
+
19
+fn scratch(name: &str) -> PathBuf {
20
+    std::env::temp_dir().join(format!("afs-ld-writer-{}-{name}", std::process::id()))
21
+}
22
+
23
+fn write_temp(name: &str, bytes: &[u8]) -> PathBuf {
24
+    let path = scratch(name);
25
+    fs::write(&path, bytes).expect("write temp mach-o");
26
+    path
27
+}
28
+
29
+fn run_otool_lv(path: &Path) -> Result<String, String> {
30
+    let out = Command::new("otool")
31
+        .arg("-lV")
32
+        .arg(path)
33
+        .output()
34
+        .map_err(|e| format!("spawn otool -lV: {e}"))?;
35
+    if !out.status.success() {
36
+        return Err(format!(
37
+            "otool -lV failed: {}",
38
+            String::from_utf8_lossy(&out.stderr)
39
+        ));
40
+    }
41
+    Ok(String::from_utf8_lossy(&out.stdout).into_owned())
42
+}
43
+
44
+fn run_file(path: &Path) -> Result<String, String> {
45
+    let out = Command::new("file")
46
+        .arg(path)
47
+        .output()
48
+        .map_err(|e| format!("spawn file: {e}"))?;
49
+    if !out.status.success() {
50
+        return Err(format!(
51
+            "file failed: {}",
52
+            String::from_utf8_lossy(&out.stderr)
53
+        ));
54
+    }
55
+    Ok(String::from_utf8_lossy(&out.stdout).into_owned())
56
+}
57
+
58
+#[test]
59
+fn empty_executable_writer_emits_parseable_macho() {
60
+    let layout = Layout::empty(OutputKind::Executable, 0);
61
+    let opts = LinkOptions::default();
62
+    let mut bytes = Vec::new();
63
+    write(&layout, OutputKind::Executable, &opts, &mut bytes).expect("write executable");
64
+
65
+    let hdr = parse_header(&bytes).expect("header parses");
66
+    assert_eq!(hdr.filetype, MH_EXECUTE);
67
+    let cmds = parse_commands(&hdr, &bytes).expect("commands parse");
68
+    assert!(
69
+        cmds.iter()
70
+            .any(|cmd| matches!(cmd, LoadCommand::Segment64(seg) if seg.segname_str() == "__TEXT"))
71
+    );
72
+
73
+    let path = write_temp("empty-exec", &bytes);
74
+    if have_tool("otool") {
75
+        let dump = run_otool_lv(&path).expect("otool -lV");
76
+        assert!(dump.contains("LC_MAIN"));
77
+        assert!(dump.contains("segname __TEXT"));
78
+    }
79
+    if have_tool("file") {
80
+        let desc = run_file(&path).expect("file");
81
+        assert!(desc.contains("Mach-O 64-bit executable arm64"));
82
+    }
83
+    let _ = fs::remove_file(path);
84
+}
85
+
86
+#[test]
87
+fn empty_dylib_writer_emits_parseable_macho() {
88
+    let layout = Layout::empty(OutputKind::Dylib, 0);
89
+    let mut opts = LinkOptions {
90
+        kind: OutputKind::Dylib,
91
+        ..LinkOptions::default()
92
+    };
93
+    opts.output = Some(PathBuf::from("libempty.dylib"));
94
+
95
+    let mut bytes = Vec::new();
96
+    write(&layout, OutputKind::Dylib, &opts, &mut bytes).expect("write dylib");
97
+
98
+    let hdr = parse_header(&bytes).expect("header parses");
99
+    assert_eq!(hdr.filetype, MH_DYLIB);
100
+    let cmds = parse_commands(&hdr, &bytes).expect("commands parse");
101
+    assert!(cmds.iter().any(|cmd| {
102
+        matches!(cmd, LoadCommand::Dylib(d) if d.cmd == LC_ID_DYLIB && d.name == "@rpath/libempty.dylib")
103
+    }));
104
+
105
+    let path = write_temp("empty-dylib.dylib", &bytes);
106
+    if have_tool("otool") {
107
+        let dump = run_otool_lv(&path).expect("otool -lV");
108
+        assert!(dump.contains("LC_ID_DYLIB"));
109
+        assert!(dump.contains("@rpath/libempty.dylib"));
110
+    }
111
+    if have_tool("file") {
112
+        let desc = run_file(&path).expect("file");
113
+        assert!(desc.contains("dynamically linked shared library arm64"));
114
+    }
115
+    let _ = fs::remove_file(path);
116
+}
Diff truncated: 412 files; expand each to load its hunks.