Comparing changes

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

base: repo-cleanup
compare: trunk
Create pull request
Able to merge. These branches can be automatically merged.
33 commits 43 files changed 2 contributors

Commits on trunk

.docs/sprints/closeout0-9.mdadded
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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/sprint28.mdmodified
@@ -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.ymlmodified
@@ -23,6 +23,15 @@ jobs:
2323
       - name: Run parity harness proof tests
2424
         run: cargo test --test diff_harness_tolerates_known_linkedit --test parity_harness --test parity_canary -- --nocapture
2525
 
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
+
2635
       - name: Run parity matrix
2736
         env:
2837
           PARITY_MATRIX_ARTIFACT_DIR: ${{ github.workspace }}/parity-matrix-artifacts
AGENTS.mdadded
@@ -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
@@ -55,6 +55,7 @@ const KNOWN_FLAGS: &[&str] = &[
5555
     "-dylib",
5656
     "-all_load",
5757
     "-force_load",
58
+    "-j",
5859
     "--dump",
5960
     "--dump-archive",
6061
     "--dump-dylib",
@@ -141,6 +142,24 @@ fn parse_version_component(flag: &str, value: &str) -> Result<u32, ArgsError> {
141142
     Ok((major << 16) | ((minor & 0xff) << 8) | (patch & 0xff))
142143
 }
143144
 
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
+
144163
 pub fn parse(argv: &[String]) -> Result<LinkOptions, ArgsError> {
145164
     let normalized = normalize_wl(argv);
146165
     let mut opts = LinkOptions::default();
@@ -395,6 +414,12 @@ pub fn parse(argv: &[String]) -> Result<LinkOptions, ArgsError> {
395414
                         ArgsError::MissingValue("-force_load".into())
396415
                     })?));
397416
             }
417
+            "-j" => {
418
+                let value = it
419
+                    .next()
420
+                    .ok_or_else(|| ArgsError::MissingValue("-j".into()))?;
421
+                opts.jobs = Some(parse_jobs(value)?);
422
+            }
398423
             "--dump" => {
399424
                 opts.dump = Some(PathBuf::from(
400425
                     it.next()
@@ -785,6 +810,41 @@ mod tests {
785810
         assert_eq!(opts.inputs, vec![PathBuf::from("main.o")]);
786811
     }
787812
 
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
+
788848
     #[test]
789849
     fn missing_force_load_value_errors() {
790850
         let err = parse(&argv(&["-force_load"])).unwrap_err();
src/lib.rsmodified
@@ -26,22 +26,29 @@ pub mod why_live;
2626
 
2727
 use std::os::unix::fs::PermissionsExt;
2828
 use std::path::PathBuf;
29
+use std::sync::{mpsc, Arc, Mutex};
30
+use std::thread;
2931
 use std::time::{Duration, Instant};
30
-use std::{fs, io};
32
+use std::{collections::VecDeque, fs, io};
3133
 
34
+use archive::Archive;
3235
 use atom::{atomize_object, backpatch_symbol_atoms, AtomTable};
3336
 use icf::IcfError;
37
+use input::ObjectFile;
3438
 use layout::{ExtraLayoutSections, Layout, LayoutInput};
3539
 use macho::dylib::{DylibDependency, DylibFile, DylibLoadKind};
3640
 use macho::reader::ReadError;
37
-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
+};
3844
 use reloc::arm64::RelocError;
3945
 use resolve::{
4046
     classify_unresolved, drain_fetches, find_archive_by_path, force_load_all, force_load_archive,
4147
     format_duplicate_diagnostic, format_undefined_diagnostic, format_undefined_warning_diagnostic,
42
-    seed_all, DrainReport, DylibLoadMeta, InputAddError, Inputs, Symbol, SymbolTable,
48
+    seed_all, DrainReport, DylibLoadMeta, InputAddError, InputId, Inputs, Symbol, SymbolTable,
4349
     UndefinedTreatment,
4450
 };
51
+use symbol::SymKind;
4552
 
4653
 const DEFAULT_TBD_VERSION: u32 = 1 << 16;
4754
 const THUNK_PLAN_MAX_ITERATIONS: usize = 16;
@@ -118,6 +125,7 @@ pub struct LinkOptions {
118125
     pub fixup_chains: bool,
119126
     pub all_load: bool,
120127
     pub force_load_archives: Vec<PathBuf>,
128
+    pub jobs: Option<usize>,
121129
     pub kind: OutputKind,
122130
     /// When set, afs-ld operates in dump mode and prints the given file's
123131
     /// header + load commands instead of linking.
@@ -169,6 +177,7 @@ impl Default for LinkOptions {
169177
             fixup_chains: false,
170178
             all_load: false,
171179
             force_load_archives: Vec::new(),
180
+            jobs: None,
172181
             kind: OutputKind::Executable,
173182
             dump: None,
174183
             dump_archive: None,
@@ -178,6 +187,18 @@ impl Default for LinkOptions {
178187
     }
179188
 }
180189
 
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
+
181202
 #[derive(Debug)]
182203
 pub enum LinkError {
183204
     /// No input files were provided on the command line.
@@ -209,9 +230,22 @@ pub enum LinkError {
209230
 #[derive(Debug, Clone, Default, PartialEq, Eq)]
210231
 pub struct LinkPhaseTimings {
211232
     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,
212240
     pub symbol_resolution: Duration,
213241
     pub atomization: Duration,
214242
     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,
215249
     pub synth_sections: Duration,
216250
     pub synth_linkedit_finalize: Duration,
217251
     pub synth_linkedit_symbol_plan: Duration,
@@ -219,6 +253,9 @@ pub struct LinkPhaseTimings {
219253
     pub synth_linkedit_symbol_plan_globals: Duration,
220254
     pub synth_linkedit_symbol_plan_strtab: Duration,
221255
     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,
222259
     pub synth_linkedit_metadata_tables: Duration,
223260
     pub synth_linkedit_code_signature: Duration,
224261
     pub synth_unwind: Duration,
@@ -236,6 +273,25 @@ impl LinkPhaseTimings {
236273
             + self.reloc_apply
237274
             + self.write_output
238275
     }
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,
239295
 }
240296
 
241297
 #[derive(Debug, Clone, PartialEq, Eq)]
@@ -403,6 +459,7 @@ impl Linker {
403459
         if opts.inputs.is_empty() && opts.library_names.is_empty() && opts.frameworks.is_empty() {
404460
             return Err(LinkError::NoInputs);
405461
         }
462
+        let parallel_jobs = opts.parallel_jobs();
406463
 
407464
         if let Some(arch) = &opts.arch {
408465
             if arch != "arm64" {
@@ -426,7 +483,14 @@ impl Linker {
426483
             );
427484
         }
428485
 
429
-        let mut load_paths = opts.inputs.clone();
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
+        }
430494
         let mut dylib_load_kinds = std::collections::HashMap::new();
431495
         for name in &opts.library_names {
432496
             let path = resolve_library_input(opts, name)?;
@@ -445,14 +509,36 @@ impl Linker {
445509
             );
446510
             load_paths.push(path);
447511
         }
512
+        load_paths.extend(positional_dylibs);
448513
 
449514
         let mut inputs = Inputs::new();
515
+        let mut deferred_dylibs = Vec::new();
516
+        let mut initial_loads = Vec::new();
450517
         let phase_started = Instant::now();
451518
         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
+            }
452526
             if opts.trace_inputs {
453527
                 eprintln!("afs-ld: loading {}", path.display());
454528
             }
455
-            register_input(&mut inputs, path, load_order)?;
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);
534
+        }
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);
456542
         }
457543
         phases.input_parsing = phase_started.elapsed();
458544
 
@@ -469,13 +555,24 @@ impl Linker {
469555
 
470556
         let mut force_report = DrainReport::default();
471557
         if opts.all_load {
472
-            force_load_all(&mut inputs, &mut sym_table, &mut force_report)?;
558
+            force_load_all(
559
+                &mut inputs,
560
+                &mut sym_table,
561
+                &mut force_report,
562
+                parallel_jobs,
563
+            )?;
473564
         }
474565
         for archive_path in &opts.force_load_archives {
475566
             let Some(archive_id) = find_archive_by_path(&inputs, archive_path) else {
476567
                 return Err(LinkError::ForceLoadNotArchive(archive_path.clone()));
477568
             };
478
-            force_load_archive(&mut inputs, &mut sym_table, archive_id, &mut force_report)?;
569
+            force_load_archive(
570
+                &mut inputs,
571
+                &mut sym_table,
572
+                archive_id,
573
+                &mut force_report,
574
+                parallel_jobs,
575
+            )?;
479576
         }
480577
         if opts.trace_inputs {
481578
             for path in &force_report.loaded_paths {
@@ -490,7 +587,12 @@ impl Linker {
490587
             return Err(LinkError::DuplicateSymbols(msg));
491588
         }
492589
 
493
-        let drain_report = drain_fetches(&mut inputs, &mut sym_table, seed_report.pending_fetches)?;
590
+        let drain_report = drain_fetches(
591
+            &mut inputs,
592
+            &mut sym_table,
593
+            seed_report.pending_fetches,
594
+            parallel_jobs,
595
+        )?;
494596
         if opts.trace_inputs {
495597
             for path in &drain_report.loaded_paths {
496598
                 eprintln!("afs-ld: loading {}", path.display());
@@ -531,14 +633,8 @@ impl Linker {
531633
         for idx in 0..inputs.objects.len() {
532634
             let input_id = resolve::InputId(idx as u32);
533635
             let obj = inputs.object_file(input_id)?;
534
-            let atomization = atomize_object(input_id, &obj, &mut atom_table);
535
-            backpatch_symbol_atoms(
536
-                &atomization,
537
-                input_id,
538
-                &obj,
539
-                &mut sym_table,
540
-                &mut atom_table,
541
-            );
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);
542638
             objects.push((input_id, obj));
543639
         }
544640
         phases.atomization = phase_started.elapsed();
@@ -574,9 +670,14 @@ impl Linker {
574670
         }
575671
         let phase_started = Instant::now();
576672
         let parsed_relocs = macho::writer::build_parsed_reloc_cache(&layout_inputs)?;
577
-        phases.input_parsing += phase_started.elapsed();
673
+        let elapsed = phase_started.elapsed();
674
+        phases.input_reloc_parse += elapsed;
675
+        phases.input_parsing += elapsed;
676
+        let layout_started = Instant::now();
578677
         let phase_started = Instant::now();
579678
         let entry_symbol = find_entry_symbol_id(opts, &sym_table)?;
679
+        phases.layout_entry_lookup = phase_started.elapsed();
680
+        let phase_started = Instant::now();
580681
         let dead_strip = opts.dead_strip.then(|| {
581682
             why_live::DeadStripAnalysis::build(
582683
                 opts,
@@ -586,6 +687,8 @@ impl Linker {
586687
                 entry_symbol,
587688
             )
588689
         });
690
+        phases.layout_dead_strip = phase_started.elapsed();
691
+        let phase_started = Instant::now();
589692
         let icf = (opts.icf_mode == IcfMode::Safe)
590693
             .then(|| {
591694
                 icf::fold_safe(
@@ -596,19 +699,24 @@ impl Linker {
596699
                 )
597700
             })
598701
             .transpose()?;
702
+        phases.layout_icf = phase_started.elapsed();
599703
         let kept_atoms = if let Some(icf) = &icf {
600704
             Some(icf.kept_atoms())
601705
         } else {
602706
             dead_strip.as_ref().map(|analysis| analysis.live_atoms())
603707
         };
604
-        let synthetic_plan = synth::SyntheticPlan::build_filtered(
708
+        let phase_started = Instant::now();
709
+        let synthetic_plan = synth::SyntheticPlan::build_filtered_with_relocs(
605710
             &layout_inputs,
606711
             &atom_table,
607712
             &mut sym_table,
608713
             &inputs.dylibs,
609714
             kept_atoms,
715
+            &parsed_relocs,
610716
         )?;
717
+        phases.layout_synthetic_plan = phase_started.elapsed();
611718
         let icf_redirects = icf.as_ref().map(|plan| plan.redirects());
719
+        let phase_started = Instant::now();
612720
         let mut layout = Layout::build_with_synthetics_filtered(
613721
             opts.kind,
614722
             &layout_inputs,
@@ -617,18 +725,24 @@ impl Linker {
617725
             Some(&synthetic_plan),
618726
             kept_atoms,
619727
         );
728
+        phases.layout_build += phase_started.elapsed();
620729
         let mut thunk_plan = None;
621730
         let mut thunk_converged = false;
622731
         for _ in 0..THUNK_PLAN_MAX_ITERATIONS {
732
+            let phase_started = Instant::now();
623733
             let next_plan = reloc::arm64::plan_thunks(
624734
                 opts,
625
-                &layout,
626
-                &layout_inputs,
627
-                &atom_table,
628
-                &sym_table,
629
-                Some(&synthetic_plan),
630
-                icf_redirects,
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
+                },
631744
             )?;
745
+            phases.layout_thunk_plan += phase_started.elapsed();
632746
             if next_plan == thunk_plan {
633747
                 thunk_converged = true;
634748
                 break;
@@ -639,6 +753,7 @@ impl Linker {
639753
             let split_after_atoms = next_plan
640754
                 .as_ref()
641755
                 .map_or_else(Vec::new, |plan| plan.split_after_atoms());
756
+            let phase_started = Instant::now();
642757
             layout = Layout::build_with_synthetics_and_extra_filtered(
643758
                 opts.kind,
644759
                 &layout_inputs,
@@ -651,12 +766,13 @@ impl Linker {
651766
                     split_after_atoms: &split_after_atoms,
652767
                 },
653768
             );
769
+            phases.layout_build += phase_started.elapsed();
654770
             thunk_plan = next_plan;
655771
         }
656772
         if !thunk_converged {
657773
             return Err(LinkError::ThunkPlanningDidNotConverge);
658774
         }
659
-        phases.layout = phase_started.elapsed();
775
+        phases.layout = layout_started.elapsed();
660776
         let linkedit_context = macho::writer::LinkEditContext {
661777
             layout_inputs: &layout_inputs,
662778
             atom_table: &atom_table,
@@ -673,6 +789,9 @@ impl Linker {
673789
         let mut synth_linkedit_symbol_plan_globals = Duration::ZERO;
674790
         let mut synth_linkedit_symbol_plan_strtab = Duration::ZERO;
675791
         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;
676795
         let mut synth_linkedit_metadata_tables = Duration::ZERO;
677796
         let mut synth_linkedit_code_signature = Duration::ZERO;
678797
         let mut synth_unwind = Duration::ZERO;
@@ -692,6 +811,9 @@ impl Linker {
692811
             synth_linkedit_symbol_plan_globals += linkedit_timings.symbol_plan_globals;
693812
             synth_linkedit_symbol_plan_strtab += linkedit_timings.symbol_plan_strtab;
694813
             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;
695817
             synth_linkedit_metadata_tables += linkedit_timings.metadata_tables;
696818
             synth_linkedit_code_signature += linkedit_timings.code_signature;
697819
             layout = next_layout;
@@ -716,6 +838,9 @@ impl Linker {
716838
         phases.synth_linkedit_symbol_plan_globals = synth_linkedit_symbol_plan_globals;
717839
         phases.synth_linkedit_symbol_plan_strtab = synth_linkedit_symbol_plan_strtab;
718840
         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;
719844
         phases.synth_linkedit_metadata_tables = synth_linkedit_metadata_tables;
720845
         phases.synth_linkedit_code_signature = synth_linkedit_code_signature;
721846
         phases.synth_unwind = synth_unwind;
@@ -731,6 +856,8 @@ impl Linker {
731856
                 thunk_plan: thunk_plan.as_ref(),
732857
                 linkedit: &linkedit,
733858
                 icf_redirects,
859
+                parsed_relocs: &parsed_relocs,
860
+                parallel_jobs,
734861
             },
735862
         )?;
736863
         phases.reloc_apply = phase_started.elapsed();
@@ -862,30 +989,221 @@ fn default_output_path(opts: &LinkOptions) -> PathBuf {
862989
         .unwrap_or_else(|| PathBuf::from("a.out"))
863990
 }
864991
 
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
+
8651164
 fn register_input(
8661165
     inputs: &mut Inputs,
8671166
     path: &std::path::Path,
8681167
     load_order: usize,
869
-) -> Result<(), LinkError> {
1168
+    include_tbd_exports: bool,
1169
+) -> Result<InputLoadTimings, LinkError> {
1170
+    let mut timings = InputLoadTimings::default();
1171
+    let phase_started = Instant::now();
8701172
     let bytes = fs::read(path)?;
1173
+    timings.read = phase_started.elapsed();
8711174
     match path.extension().and_then(|ext| ext.to_str()) {
8721175
         Some("a") => {
1176
+            let phase_started = Instant::now();
8731177
             let _ = inputs.add_archive(path.to_path_buf(), bytes, load_order)?;
1178
+            timings.archive_parse = phase_started.elapsed();
8741179
         }
8751180
         Some("dylib") => {
1181
+            let phase_started = Instant::now();
8761182
             let _ = inputs.add_dylib(path.to_path_buf(), bytes)?;
1183
+            timings.dylib_parse = phase_started.elapsed();
8771184
         }
8781185
         Some("tbd") => {
1186
+            let phase_started = Instant::now();
8791187
             let text = std::str::from_utf8(&bytes).map_err(|e| {
8801188
                 LinkError::Tbd(macho::tbd::TbdError::Schema {
8811189
                     msg: format!("TBD input is not UTF-8: {e}"),
8821190
                 })
8831191
             })?;
884
-            let docs = parse_tbd(text)?;
8851192
             let target = Target {
8861193
                 arch: Arch::Arm64,
8871194
                 platform: Platform::MacOs,
8881195
             };
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
+            }
8891207
             let canonical = docs
8901208
                 .iter()
8911209
                 .find(|doc| doc.parent_umbrella.is_empty())
@@ -904,25 +1222,39 @@ fn register_input(
9041222
                     .unwrap_or(DEFAULT_TBD_VERSION),
9051223
                 ordinal: inputs.next_dylib_ordinal(),
9061224
             };
907
-            let mut loaded = false;
908
-            for doc in docs
909
-                .iter()
910
-                .filter(|doc| doc.targets.iter().any(|t| t.matches_requested(&target)))
911
-            {
1225
+            for doc in &docs {
9121226
                 let file = DylibFile::from_tbd(path, doc, &target);
9131227
                 let _ =
9141228
                     inputs.add_dylib_from_file_with_meta(path.to_path_buf(), file, load.clone());
915
-                loaded = true;
916
-            }
917
-            if !loaded {
918
-                return Err(LinkError::NoTbdDocument(path.to_path_buf()));
9191229
             }
1230
+            timings.tbd_materialize = phase_started.elapsed();
9201231
         }
9211232
         _ => {
1233
+            let phase_started = Instant::now();
9221234
             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);
9231255
         }
9241256
     }
925
-    Ok(())
1257
+    Ok(false)
9261258
 }
9271259
 
9281260
 fn resolve_entry_point(
src/macho/tbd.rsmodified
@@ -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
@@ -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
@@ -20,12 +20,14 @@ use crate::macho::reader::{
2020
     LinkEditDataCmd, LoadCommand, MachHeader64, RpathCmd, Section64Header, Segment64, SymtabCmd,
2121
     HEADER_SIZE,
2222
 };
23
-use crate::reloc::{parse_raw_relocs, parse_relocs, Referent, Reloc, RelocKind, RelocLength};
24
-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};
2527
 use crate::resolve::{Symbol, SymbolId, SymbolTable};
2628
 use crate::section::is_executable;
2729
 use crate::string_table::StringTableBuilder;
28
-use crate::symbol::{write_nlist_table, InputSymbol, RawNlist, SymKind};
30
+use crate::symbol::{write_nlist_table, InputSymbol, RawNlist, SymKind, NLIST_SIZE};
2931
 use crate::synth::tlv::THREAD_VARIABLE_DESCRIPTOR_SIZE;
3032
 use crate::synth::{
3133
     code_sig::CodeSignaturePlan,
@@ -53,8 +55,6 @@ pub struct LinkEditContext<'a> {
5355
     pub parsed_relocs: &'a ParsedRelocCache,
5456
 }
5557
 
56
-pub type ParsedRelocCache = HashMap<(InputId, u8), Vec<Reloc>>;
57
-
5858
 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
5959
 pub struct LinkEditBuildTimings {
6060
     pub symbol_plan: Duration,
@@ -62,6 +62,9 @@ pub struct LinkEditBuildTimings {
6262
     pub symbol_plan_globals: Duration,
6363
     pub symbol_plan_strtab: Duration,
6464
     pub dyld_info: Duration,
65
+    pub dyld_bind: Duration,
66
+    pub dyld_rebase: Duration,
67
+    pub dyld_export: Duration,
6568
     pub metadata_tables: Duration,
6669
     pub code_signature: Duration,
6770
 }
@@ -73,6 +76,9 @@ impl std::ops::AddAssign for LinkEditBuildTimings {
7376
         self.symbol_plan_globals += rhs.symbol_plan_globals;
7477
         self.symbol_plan_strtab += rhs.symbol_plan_strtab;
7578
         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;
7682
         self.metadata_tables += rhs.metadata_tables;
7783
         self.code_signature += rhs.code_signature;
7884
     }
@@ -421,7 +427,7 @@ pub fn write_finalized_with_linkedit(
421427
     out[stroff..end].copy_from_slice(&linkedit_plan.strtab_bytes);
422428
     if let Some(code_signature) = &linkedit_plan.code_signature {
423429
         let start = code_signature.dataoff as usize;
424
-        let bytes = code_signature.build(&out[..start]);
430
+        let bytes = code_signature.build_with_jobs(&out[..start], opts.parallel_jobs());
425431
         let end = start + bytes.len();
426432
         out[start..end].copy_from_slice(&bytes);
427433
     }
@@ -477,12 +483,6 @@ fn build_commands(
477483
         }
478484
     }
479485
 
480
-    for rpath in &opts.rpaths {
481
-        commands.push(LoadCommand::Rpath(RpathCmd {
482
-            path: rpath.clone(),
483
-        }));
484
-    }
485
-
486486
     for dylib in dylibs {
487487
         commands.push(LoadCommand::Dylib(DylibCmd {
488488
             cmd: dylib.kind.load_cmd(),
@@ -493,6 +493,12 @@ fn build_commands(
493493
         }));
494494
     }
495495
 
496
+    for rpath in &opts.rpaths {
497
+        commands.push(LoadCommand::Rpath(RpathCmd {
498
+            path: rpath.clone(),
499
+        }));
500
+    }
501
+
496502
     if let Some(loh) = linkedit.loh {
497503
         commands.push(raw_linkedit_command(
498504
             LC_LINKER_OPTIMIZATION_HINT,
@@ -921,7 +927,7 @@ fn build_linkedit_plan_profiled(
921927
     timings.symbol_plan_locals += symbol_plan_timings.locals;
922928
     timings.symbol_plan_globals += symbol_plan_timings.globals;
923929
     timings.symbol_plan_strtab += symbol_plan_timings.strtab;
924
-    let mut symtab_bytes = Vec::new();
930
+    let mut symtab_bytes = Vec::with_capacity(symbol_plan.symbols.len() * NLIST_SIZE);
925931
     write_nlist_table(&symbol_plan.symbols, &mut symtab_bytes);
926932
 
927933
     let mut indirect_symbols = Vec::new();
@@ -956,14 +962,20 @@ fn build_linkedit_plan_profiled(
956962
         indirect_bytes.extend_from_slice(&index.to_le_bytes());
957963
     }
958964
 
965
+    let dyld_started = std::time::Instant::now();
959966
     let phase_started = std::time::Instant::now();
960967
     let bind_streams = build_bind_streams(layout, synthetic_plan, &import_lookup)?;
961
-    let rebase_bytes = pad_dyld_info_stream(build_rebase_stream(layout, synthetic_plan, inputs)?);
962968
     let bind_bytes = pad_dyld_info_stream(bind_streams.bind);
963969
     let weak_bind_bytes = pad_dyld_info_stream(bind_streams.weak_bind);
964970
     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();
965976
     let export_bytes = pad_dyld_info_stream(build_export_trie(&symbol_plan.exports));
966
-    timings.dyld_info += phase_started.elapsed();
977
+    timings.dyld_export += phase_started.elapsed();
978
+    timings.dyld_info += dyld_started.elapsed();
967979
 
968980
     let phase_started = std::time::Instant::now();
969981
     let loh_bytes = build_loh(
@@ -1417,10 +1429,7 @@ fn build_function_starts(
14171429
         .segment("__TEXT")
14181430
         .ok_or(WriteError::MissingSegment("__TEXT"))?
14191431
         .vm_addr;
1420
-    let input_map: HashMap<InputId, &ObjectFile> = inputs
1421
-        .iter()
1422
-        .map(|input| (input.id, input.object))
1423
-        .collect();
1432
+    let symbol_offsets = build_function_start_symbol_index(inputs);
14241433
     let mut starts = Vec::new();
14251434
 
14261435
     for section in &layout.sections {
@@ -1435,36 +1444,19 @@ fn build_function_starts(
14351444
                     section.addr + placed.offset + alt.offset_within_atom as u64 - image_base,
14361445
                 );
14371446
             }
1438
-            let Some(object) = input_map.get(&atom.origin) else {
1439
-                continue;
1440
-            };
1441
-            let Some(input_section) = object
1442
-                .sections
1443
-                .get((atom.input_section as usize).saturating_sub(1))
1447
+            let Some(section_symbols) = symbol_offsets.get(&(atom.origin, atom.input_section))
14441448
             else {
14451449
                 continue;
14461450
             };
1447
-            let atom_start = input_section.addr + atom.input_offset as u64;
1451
+            let atom_start = atom.input_offset as u64;
14481452
             let atom_end = atom_start + atom.size as u64;
1449
-            for input_sym in &object.symbols {
1450
-                if input_sym.stab_kind().is_some()
1451
-                    || input_sym.kind() != SymKind::Sect
1452
-                    || input_sym.alt_entry()
1453
-                    || input_sym.sect_idx() != atom.input_section
1454
-                {
1455
-                    continue;
1456
-                }
1457
-                let Ok(name) = object.symbol_name(input_sym) else {
1458
-                    continue;
1459
-                };
1460
-                if is_assembler_temporary_symbol(name) {
1461
-                    continue;
1462
-                }
1463
-                let value = input_sym.value();
1464
-                if !(atom_start < value && value < atom_end) {
1465
-                    continue;
1466
-                }
1467
-                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);
14681460
             }
14691461
         }
14701462
     }
@@ -1488,6 +1480,39 @@ fn build_function_starts(
14881480
     Ok(out)
14891481
 }
14901482
 
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
+
14911516
 fn build_data_in_code(
14921517
     layout: &Layout,
14931518
     inputs: &[LayoutInput<'_>],
@@ -1693,6 +1718,7 @@ fn build_output_symbols_profiled(
16931718
 ) -> Result<(SymbolTablePlan, SymbolPlanBuildTimings), WriteError> {
16941719
     let sym_table = inputs.0.sym_table;
16951720
     let atom_sections = atom_section_ordinals(layout);
1721
+    let atom_addrs = atom_addresses(layout);
16961722
     let atoms_by_input_section = inputs.0.atom_table.by_input_section();
16971723
     let atom_ranges = build_atom_range_index(
16981724
         inputs.0.atom_table,
@@ -1748,10 +1774,11 @@ fn build_output_symbols_profiled(
17481774
             atom_table: inputs.0.atom_table,
17491775
             atom_ranges: &atom_ranges,
17501776
             atom_sections: &atom_sections,
1777
+            atom_addrs: &atom_addrs,
17511778
             input_id: input.id,
17521779
             file_index: file_index_by_input[&input.id],
17531780
         };
1754
-        collect_local_symbols(layout, &ctx, input.object, &mut locals)?;
1781
+        collect_local_symbols(&ctx, input.object, &mut locals)?;
17551782
     }
17561783
     collect_synthetic_local_symbols(layout, inputs.0.synthetic_plan, &mut locals)?;
17571784
     timings.locals += phase_started.elapsed();
@@ -1779,12 +1806,12 @@ fn build_output_symbols_profiled(
17791806
         let (n_type, n_sect, n_value) = if atom.0 == 0 {
17801807
             (absolute_symbol_type(hidden), NO_SECT, *value)
17811808
         } else {
1782
-            if dead_strip && layout.atom_addr(*atom).is_none() {
1783
-                continue;
1784
-            }
1785
-            let addr = layout
1786
-                .atom_addr(*atom)
1787
-                .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
+            };
17881815
             let sect = *atom_sections
17891816
                 .get(atom)
17901817
                 .ok_or(WriteError::DefinedSymbolSectionMissing(symbol_id, *atom))?;
@@ -1871,8 +1898,10 @@ fn build_output_symbols_profiled(
18711898
         Vec::new()
18721899
     };
18731900
 
1874
-    let phase_started = std::time::Instant::now();
18751901
     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();
18761905
     let mut specs = Vec::with_capacity(local_count + external_defineds.len() + undefineds.len());
18771906
     if !strip_locals {
18781907
         specs.extend(locals);
@@ -1880,27 +1909,11 @@ fn build_output_symbols_profiled(
18801909
     specs.extend(external_defineds);
18811910
     specs.extend(undefineds);
18821911
 
1883
-    let mut strtab = StringTableBuilder::new();
1884
-    for spec in &specs {
1885
-        strtab.insert(&spec.name);
1886
-    }
1887
-    let (strtab_bytes, strx_by_name) = strtab.finish();
1888
-
1889
-    let nlocalsym = specs
1890
-        .iter()
1891
-        .filter(|spec| spec.partition == OutputSymbolPartition::Local)
1892
-        .count() as u32;
1893
-    let nextdefsym = specs
1894
-        .iter()
1895
-        .filter(|spec| spec.partition == OutputSymbolPartition::ExternalDefined)
1896
-        .count() as u32;
1897
-    let nundefsym = specs
1898
-        .iter()
1899
-        .filter(|spec| spec.partition == OutputSymbolPartition::Undefined)
1900
-        .count() as u32;
1912
+    let (strtab_bytes, strx_by_spec) =
1913
+        StringTableBuilder::build_with_name_offsets(specs.iter().map(|spec| spec.name.as_str()));
19011914
 
19021915
     let mut symbols = Vec::with_capacity(specs.len());
1903
-    let mut symbol_indices = HashMap::new();
1916
+    let mut symbol_indices = HashMap::with_capacity(specs.len());
19041917
     let map_symbols = specs
19051918
         .iter()
19061919
         .filter(|spec| spec.partition != OutputSymbolPartition::Undefined)
@@ -1912,9 +1925,7 @@ fn build_output_symbols_profiled(
19121925
         })
19131926
         .collect();
19141927
     for (idx, spec) in specs.into_iter().enumerate() {
1915
-        let strx = *strx_by_name
1916
-            .get(&spec.name)
1917
-            .expect("string table offset missing for output symbol");
1928
+        let strx = strx_by_spec[idx];
19181929
         symbols.push(InputSymbol::from_raw(RawNlist {
19191930
             strx,
19201931
             n_type: spec.n_type,
@@ -1937,11 +1948,11 @@ fn build_output_symbols_profiled(
19371948
             exports,
19381949
             dysymtab: DysymtabCmd {
19391950
                 ilocalsym: 0,
1940
-                nlocalsym,
1941
-                iextdefsym: nlocalsym,
1942
-                nextdefsym,
1943
-                iundefsym: nlocalsym + nextdefsym,
1944
-                nundefsym,
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,
19451956
                 ..DysymtabCmd::default()
19461957
             },
19471958
         },
@@ -1992,7 +2003,6 @@ fn collect_synthetic_local_symbols(
19922003
 }
19932004
 
19942005
 fn collect_local_symbols(
1995
-    layout: &Layout,
19962006
     ctx: &LocalSymbolContext<'_>,
19972007
     object: &ObjectFile,
19982008
     out: &mut Vec<OutputSymbolSpec>,
@@ -2021,14 +2031,9 @@ fn collect_local_symbols(
20212031
                     offset,
20222032
                 )
20232033
                 .ok_or(WriteError::MissingSegment("__UNKNOWN"))?;
2024
-                let addr =
2025
-                    layout
2026
-                        .atom_addr(atom_id)
2027
-                        .ok_or(WriteError::DefinedSymbolAtomMissing(
2028
-                            SymbolId(u32::MAX),
2029
-                            atom_id,
2030
-                        ))?
2031
-                        + delta as u64;
2034
+                let addr = ctx.atom_addrs.get(&atom_id).copied().ok_or(
2035
+                    WriteError::DefinedSymbolAtomMissing(SymbolId(u32::MAX), atom_id),
2036
+                )? + delta as u64;
20322037
                 let n_sect = *ctx.atom_sections.get(&atom_id).ok_or(
20332038
                     WriteError::DefinedSymbolSectionMissing(SymbolId(u32::MAX), atom_id),
20342039
                 )?;
@@ -2067,6 +2072,7 @@ struct LocalSymbolContext<'a> {
20672072
     atom_table: &'a AtomTable,
20682073
     atom_ranges: &'a AtomRangeIndex,
20692074
     atom_sections: &'a HashMap<crate::resolve::AtomId, u8>,
2075
+    atom_addrs: &'a HashMap<crate::resolve::AtomId, u64>,
20702076
     input_id: InputId,
20712077
     file_index: usize,
20722078
 }
@@ -2176,6 +2182,16 @@ fn atom_section_ordinals(layout: &Layout) -> HashMap<crate::resolve::AtomId, u8>
21762182
     out
21772183
 }
21782184
 
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
+
21792195
 fn export_symbol_flags(layout: &Layout, n_desc: u16, n_type: u8, n_sect: u8) -> u64 {
21802196
     let mut flags = 0u64;
21812197
     if n_desc & N_WEAK_DEF != 0 {
@@ -2371,6 +2387,7 @@ fn build_bind_streams(
23712387
     let weak_bind = Vec::new();
23722388
     let mut lazy_bind = OpcodeStream::new();
23732389
     let mut lazy_offsets = HashMap::new();
2390
+    let layout_index = BindLayoutIndex::build(layout)?;
23742391
 
23752392
     if let Some(tlv_bootstrap) = synthetic_plan.tlv_bootstrap_symbol {
23762393
         let segment_index = segment_index(layout, "__DATA")?;
@@ -2437,29 +2454,21 @@ fn build_bind_streams(
24372454
             .get(&entry.symbol)
24382455
             .copied()
24392456
             .ok_or(WriteError::ImportSymbolMissing(entry.symbol))?;
2440
-        let atom_addr = layout
2441
-            .atom_addr(entry.atom)
2457
+        let placement = layout_index
2458
+            .atoms
2459
+            .get(&entry.atom)
24422460
             .ok_or(WriteError::DirectBindAtomMissing(entry.atom))?;
2443
-        let section = layout
2444
-            .sections
2445
-            .iter()
2446
-            .find(|section| section.atoms.iter().any(|placed| placed.atom == entry.atom))
2447
-            .ok_or(WriteError::DirectBindSectionMissing(entry.atom))?;
2448
-        if section.segment == "__DATA" && section.name == "__thread_vars" {
2461
+        if placement.is_thread_vars {
24492462
             // `__thread_vars` starts are emitted through the dedicated
24502463
             // `__tlv_bootstrap` pass above. Descriptor tails are rewritten to
24512464
             // template offsets before write, so any generic direct bind landing
24522465
             // back in this section is stale and would override the TLV bind.
24532466
             continue;
24542467
         }
2455
-        let segment_index = segment_index(layout, &section.segment)?;
2456
-        let segment = layout
2457
-            .segment(&section.segment)
2458
-            .ok_or(WriteError::MissingSegment("__UNKNOWN"))?;
2459
-        let slot_addr = atom_addr + entry.atom_offset as u64;
2468
+        let slot_addr = placement.addr + entry.atom_offset as u64;
24602469
         bind_specs.push(BindRecordSpec {
2461
-            segment_index,
2462
-            segment_offset: slot_addr - segment.vm_addr,
2470
+            segment_index: placement.segment_index,
2471
+            segment_offset: slot_addr - placement.segment_vm_addr,
24632472
             ordinal: import.ordinal,
24642473
             name: &import.name,
24652474
             weak_import: import.weak_import,
@@ -2508,6 +2517,59 @@ fn build_bind_streams(
25082517
     })
25092518
 }
25102519
 
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
+
25112573
 fn segment_index(layout: &Layout, name: &str) -> Result<u8, WriteError> {
25122574
     let idx = layout
25132575
         .segments
@@ -2593,12 +2655,15 @@ fn u32_fit(value: u64, what: &'static str) -> Result<u32, WriteError> {
25932655
 #[cfg(test)]
25942656
 mod tests {
25952657
     use crate::atom::{AltEntry, Atom, AtomFlags, AtomSection, AtomTable};
2658
+    use crate::input::ObjectFile;
25962659
     use crate::layout::{Layout, PAGE_SIZE};
25972660
     use crate::leb::read_uleb;
2661
+    use crate::macho::reader::MachHeader64;
25982662
     use crate::resolve::{AtomId, InputId, SymbolId};
25992663
     use crate::section::{
2600
-        OutputAtom, OutputSection, OutputSectionId, OutputSegment, Prot, SectionKind,
2664
+        InputSection, OutputAtom, OutputSection, OutputSectionId, OutputSegment, Prot, SectionKind,
26012665
     };
2666
+    use crate::string_table::StringTable;
26022667
 
26032668
     use super::*;
26042669
 
@@ -2861,6 +2926,160 @@ mod tests {
28612926
         );
28622927
     }
28632928
 
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
+
28643083
     #[test]
28653084
     fn containing_atom_lookup_reuses_precomputed_section_index() {
28663085
         let mut atoms = AtomTable::new();
src/main.rsmodified
@@ -45,6 +45,7 @@ Options:
4545
                                   Select chained fixups vs classic dyld info
4646
   -all_load                       Force-load every archive member
4747
   -force_load <archive>           Force-load one archive
48
+  -j <jobs>                       Limit parallel worker jobs (`1` disables parallelism)
4849
   -Wl,<arg,arg,...>               Normalize comma-separated driver flags
4950
   --dump <path>                   Dump a Mach-O file summary
5051
   --dump-archive <path>           Dump an archive summary
src/reloc/arm64.rsmodified
@@ -1,14 +1,15 @@
11
 use std::collections::HashMap;
22
 use std::fmt;
33
 use std::path::PathBuf;
4
+use std::thread;
45
 
56
 use crate::atom::{Atom, AtomSection, AtomTable};
67
 use crate::input::ObjectFile;
78
 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};
11
-use crate::section::{OutputSection, SectionKind};
12
+use crate::section::{OutputAtom, OutputSection, SectionKind};
1213
 use crate::symbol::{InputSymbol, SymKind};
1314
 use crate::synth::stubs::{STUB_HELPER_ENTRY_SIZE, STUB_HELPER_HEADER_SIZE, STUB_SIZE};
1415
 use crate::synth::tlv::THREAD_VARIABLE_DESCRIPTOR_SIZE;
@@ -72,6 +73,8 @@ pub struct ApplyLayoutPlan<'a> {
7273
     pub thunk_plan: Option<&'a ThunkPlan>,
7374
     pub linkedit: &'a LinkEditPlan,
7475
     pub icf_redirects: Option<&'a HashMap<crate::resolve::AtomId, crate::resolve::AtomId>>,
76
+    pub parsed_relocs: &'a ParsedRelocCache,
77
+    pub parallel_jobs: usize,
7578
 }
7679
 
7780
 struct InputSectionResolveCtx<'a> {
@@ -81,8 +84,18 @@ struct InputSectionResolveCtx<'a> {
8184
     referent: &'a str,
8285
 }
8386
 
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
+
8496
 const THUNK_SIZE: u64 = 12;
8597
 const BR_X16: u32 = 0xd61f_0200;
98
+const BRANCH26_MAX_FORWARD_DELTA_BYTES: u64 = ((1u64 << 25) - 1) * 4;
8699
 
87100
 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
88101
 enum BranchTargetKey {
@@ -206,36 +219,6 @@ pub fn apply_layout(
206219
         .iter()
207220
         .map(|input| (input.id, input.object))
208221
         .collect();
209
-    let mut reloc_cache: HashMap<(InputId, u8), Vec<Reloc>> = HashMap::new();
210
-    for input in inputs {
211
-        for (sect_idx, section) in input.object.sections.iter().enumerate() {
212
-            let relocs = if section.nreloc == 0 {
213
-                Vec::new()
214
-            } else {
215
-                let raws =
216
-                    parse_raw_relocs(&section.raw_relocs, 0, section.nreloc).map_err(|err| {
217
-                        RelocError {
218
-                            input: input.object.path.clone(),
219
-                            atom: crate::resolve::AtomId(0),
220
-                            atom_offset: 0,
221
-                            kind: RelocKind::Unsigned,
222
-                            referent: format!("section {},{}", section.segname, section.sectname),
223
-                            detail: err.to_string(),
224
-                        }
225
-                    })?;
226
-                parse_relocs(&raws).map_err(|err| RelocError {
227
-                    input: input.object.path.clone(),
228
-                    atom: crate::resolve::AtomId(0),
229
-                    atom_offset: 0,
230
-                    kind: RelocKind::Unsigned,
231
-                    referent: format!("section {},{}", section.segname, section.sectname),
232
-                    detail: err.to_string(),
233
-                })?
234
-            };
235
-            reloc_cache.insert((input.id, (sect_idx + 1) as u8), relocs);
236
-        }
237
-    }
238
-
239222
     let atom_addrs = atom_address_map(layout);
240223
     let atoms_by_input_section = atoms.by_input_section();
241224
     let section_addrs = input_section_address_map(layout, atoms);
@@ -260,40 +243,15 @@ pub fn apply_layout(
260243
         .thunk_plan
261244
         .map(|thunk_plan| thunk_plan.thunk_addrs(layout));
262245
 
263
-    for out_section in &mut layout.sections {
264
-        for placed in &mut out_section.atoms {
265
-            let atom = atoms.get(placed.atom);
266
-            if atom.size == 0 || placed.data.is_empty() {
267
-                continue;
268
-            }
269
-            let obj = input_map.get(&atom.origin).ok_or_else(|| {
270
-                reloc_error(
271
-                    atom,
272
-                    &PathBuf::from("<missing object>"),
273
-                    0,
274
-                    RelocKind::Unsigned,
275
-                    "object",
276
-                    "missing parsed object".to_string(),
277
-                )
278
-            })?;
279
-            patch_eh_frame_cie_pointer(&mut placed.data, atom, &resolve)?;
280
-            let relocs = reloc_cache
281
-                .get(&(atom.origin, atom.input_section))
282
-                .map(Vec::as_slice)
283
-                .unwrap_or(&[]);
284
-            for reloc in relocs_for_atom(relocs, atom) {
285
-                apply_one(
286
-                    &mut placed.data,
287
-                    atom,
288
-                    obj,
289
-                    reloc,
290
-                    &resolve,
291
-                    plan.thunk_plan,
292
-                    thunk_addrs.as_ref(),
293
-                )?;
294
-            }
295
-        }
296
-    }
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)?;
297255
 
298256
     if let Some(thunk_plan) = plan.thunk_plan {
299257
         synthesize_thunk_section(layout, thunk_plan, &resolve)?;
@@ -305,7 +263,7 @@ pub fn apply_layout(
305263
             synthetic_plan,
306264
             atoms,
307265
             &input_map,
308
-            &reloc_cache,
266
+            plan.parsed_relocs,
309267
             &resolve,
310268
         )?;
311269
         synthesize_got_section(layout, synthetic_plan, &resolve)?;
@@ -317,6 +275,75 @@ pub fn apply_layout(
317275
     Ok(())
318276
 }
319277
 
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
+
320347
 fn patch_eh_frame_cie_pointer(
321348
     bytes: &mut [u8],
322349
     atom: &Atom,
@@ -510,53 +537,42 @@ fn synthetic_address_maps(
510537
     }
511538
 }
512539
 
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
+
513550
 pub fn plan_thunks(
514551
     opts: &LinkOptions,
515
-    layout: &Layout,
516
-    inputs: &[LayoutInput<'_>],
517
-    atoms: &AtomTable,
518
-    sym_table: &SymbolTable,
519
-    synthetic_plan: Option<&SyntheticPlan>,
520
-    icf_redirects: Option<&HashMap<crate::resolve::AtomId, crate::resolve::AtomId>>,
552
+    ctx: ThunkPlanningContext<'_>,
521553
 ) -> Result<Option<ThunkPlan>, RelocError> {
522554
     if opts.thunks == ThunkMode::None {
523555
         return Ok(None);
524556
     }
525557
 
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
+
526572
     let input_map: HashMap<InputId, &ObjectFile> = inputs
527573
         .iter()
528574
         .map(|input| (input.id, input.object))
529575
         .collect();
530
-    let mut reloc_cache: HashMap<(InputId, u8), Vec<Reloc>> = HashMap::new();
531
-    for input in inputs {
532
-        for (sect_idx, section) in input.object.sections.iter().enumerate() {
533
-            let relocs = if section.nreloc == 0 {
534
-                Vec::new()
535
-            } else {
536
-                let raws =
537
-                    parse_raw_relocs(&section.raw_relocs, 0, section.nreloc).map_err(|err| {
538
-                        RelocError {
539
-                            input: input.object.path.clone(),
540
-                            atom: crate::resolve::AtomId(0),
541
-                            atom_offset: 0,
542
-                            kind: RelocKind::Unsigned,
543
-                            referent: format!("section {},{}", section.segname, section.sectname),
544
-                            detail: err.to_string(),
545
-                        }
546
-                    })?;
547
-                parse_relocs(&raws).map_err(|err| RelocError {
548
-                    input: input.object.path.clone(),
549
-                    atom: crate::resolve::AtomId(0),
550
-                    atom_offset: 0,
551
-                    kind: RelocKind::Unsigned,
552
-                    referent: format!("section {},{}", section.segname, section.sectname),
553
-                    detail: err.to_string(),
554
-                })?
555
-            };
556
-            reloc_cache.insert((input.id, (sect_idx + 1) as u8), relocs);
557
-        }
558
-    }
559
-
560576
     let atom_addrs = atom_address_map(layout);
561577
     let atom_segments = atom_output_segment_map(layout);
562578
     let atoms_by_input_section = atoms.by_input_section();
@@ -588,7 +604,7 @@ pub fn plan_thunks(
588604
         let Some(obj) = input_map.get(&atom.origin) else {
589605
             continue;
590606
         };
591
-        let relocs = reloc_cache
607
+        let relocs = parsed_relocs
592608
             .get(&(atom.origin, atom.input_section))
593609
             .map(Vec::as_slice)
594610
             .unwrap_or(&[]);
@@ -987,6 +1003,19 @@ fn branch26_in_range(place: u64, target: u64) -> bool {
9871003
     delta & 0b11 == 0 && fits_signed(delta >> 2, 26)
9881004
 }
9891005
 
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
+
9901019
 fn synthesize_thunk_section(
9911020
     layout: &mut Layout,
9921021
     plan: &ThunkPlan,
@@ -2698,6 +2727,35 @@ mod tests {
26982727
         assert!(!fits_signed(-(1 << 25) - 1, 26));
26992728
     }
27002729
 
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
+
27012759
     #[test]
27022760
     fn thunk_plan_splits_monolithic_text_section_into_multiple_islands() {
27032761
         let gap = 0x0900_0000u32;
@@ -2739,9 +2797,21 @@ mod tests {
27392797
             ..LinkOptions::default()
27402798
         };
27412799
         let base_layout = Layout::build(OutputKind::Executable, &inputs, &atoms, 0);
2742
-        let plan = plan_thunks(&opts, &base_layout, &inputs, &atoms, &sym_table, None, None)
2743
-            .unwrap()
2744
-            .unwrap();
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();
27452815
 
27462816
         assert_eq!(
27472817
             plan.redirect_for(caller1, 0),
@@ -2795,9 +2865,20 @@ mod tests {
27952865
             vec!["__text", "__thunks", "__text", "__thunks", "__text"]
27962866
         );
27972867
 
2798
-        let replan = plan_thunks(&opts, &rebuilt, &inputs, &atoms, &sym_table, None, None)
2799
-            .unwrap()
2800
-            .unwrap();
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();
28012882
         assert_eq!(
28022883
             replan, plan,
28032884
             "expected thunk planning to converge once the intra-section islands exist"
@@ -2824,6 +2905,25 @@ mod tests {
28242905
         out
28252906
     }
28262907
 
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
+
28272927
     fn thunk_test_object(raw_relocs: Vec<u8>, target_offset: u64, section_size: u64) -> ObjectFile {
28282928
         let strings = b"\0_target\0".to_vec();
28292929
         ObjectFile {
src/reloc/mod.rsmodified
@@ -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;
@@ -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
@@ -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
     }
@@ -1140,44 +1169,161 @@ pub struct DrainReport {
11401169
     pub referrers: ReferrerLog,
11411170
 }
11421171
 
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
+
11431304
 /// Shared ingest: copy one archive member's body into a fresh
11441305
 /// `ObjectInput`, mark it fetched, and seed its symbols. Callers either
11451306
 /// respond to a demand-driven `PendingFetch` or force-pull the member.
1146
-fn ingest_member_bytes(
1307
+fn ingest_loaded_member(
11471308
     inputs: &mut Inputs,
11481309
     table: &mut SymbolTable,
1149
-    archive_id: ArchiveId,
1150
-    member_id: MemberId,
1310
+    loaded: LoadedArchiveMember,
11511311
     report: &mut DrainReport,
11521312
 ) -> Result<Vec<PendingFetch>, FetchError> {
1153
-    let archive_load_order = inputs.archives[archive_id.0 as usize].load_order;
1154
-    let ai = &inputs.archives[archive_id.0 as usize];
1155
-    if ai.fetched.contains(&member_id.0) {
1313
+    if archive_member_is_fetched(inputs, loaded.key) {
11561314
         return Ok(Vec::new());
11571315
     }
11581316
 
1159
-    // Extract owned data before mutating the registry.
1160
-    let (logical_path, member_bytes) = {
1161
-        let archive = Archive::open(&ai.path, &ai.bytes)?;
1162
-        let member = archive
1163
-            .member_at_offset(member_id.0)
1164
-            .ok_or(FetchError::MemberNotFound {
1165
-                archive: archive_id,
1166
-                member: member_id,
1167
-            })?;
1168
-        let logical = format!("{}({})", ai.path.display(), member.name);
1169
-        (logical, member.body.to_vec())
1170
-    };
1171
-
1172
-    inputs.archives[archive_id.0 as usize]
1317
+    inputs.archives[loaded.key.archive.0 as usize]
11731318
         .fetched
1174
-        .insert(member_id.0);
1319
+        .insert(loaded.key.member.0);
11751320
     let input_id = InputId(inputs.objects.len() as u32);
11761321
     inputs.objects.push(ObjectInput {
1177
-        path: PathBuf::from(logical_path),
1178
-        load_order: archive_load_order,
1179
-        archive_member_offset: Some(member_id.0),
1180
-        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,
11811327
     });
11821328
     report.fetched_members += 1;
11831329
     report
@@ -1191,6 +1337,24 @@ fn ingest_member_bytes(
11911337
     Ok(sub_report.pending_fetches)
11921338
 }
11931339
 
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
+
11941358
 /// Pull `pending`'s member only if the symbol slot is still a
11951359
 /// `LazyArchive` (i.e., a strong Defined hasn't superseded it). Returns
11961360
 /// any new `PendingFetch` entries triggered by the inserted member.
@@ -1199,12 +1363,19 @@ fn fetch_and_ingest_one(
11991363
     table: &mut SymbolTable,
12001364
     pending: PendingFetch,
12011365
     report: &mut DrainReport,
1366
+    parallel_jobs: usize,
12021367
 ) -> Result<Vec<PendingFetch>, FetchError> {
12031368
     let slot_is_still_lazy = matches!(table.get(pending.id), Symbol::LazyArchive { .. });
12041369
     if !slot_is_still_lazy {
12051370
         return Ok(Vec::new());
12061371
     }
1207
-    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
+    )
12081379
 }
12091380
 
12101381
 /// Pull every member of one archive (bypasses demand tracking). Respects
@@ -1215,6 +1386,7 @@ pub fn force_load_archive(
12151386
     table: &mut SymbolTable,
12161387
     archive_id: ArchiveId,
12171388
     report: &mut DrainReport,
1389
+    parallel_jobs: usize,
12181390
 ) -> Result<(), FetchError> {
12191391
     let member_offsets: Vec<u32> = {
12201392
         let ai = &inputs.archives[archive_id.0 as usize];
@@ -1224,13 +1396,20 @@ pub fn force_load_archive(
12241396
             .map(|m| m.header_offset as u32)
12251397
             .collect()
12261398
     };
1399
+    let keys = member_offsets
1400
+        .into_iter()
1401
+        .map(|offset| ArchiveMemberKey {
1402
+            archive: archive_id,
1403
+            member: MemberId(offset),
1404
+        })
1405
+        .collect();
12271406
     let mut queue: Vec<PendingFetch> = Vec::new();
1228
-    for offset in member_offsets {
1229
-        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)?;
12301409
         queue.extend(new);
12311410
     }
12321411
     while let Some(p) = queue.pop() {
1233
-        let new = fetch_and_ingest_one(inputs, table, p, report)?;
1412
+        let new = fetch_and_ingest_one(inputs, table, p, report, parallel_jobs)?;
12341413
         queue.extend(new);
12351414
     }
12361415
     Ok(())
@@ -1242,9 +1421,10 @@ pub fn force_load_all(
12421421
     inputs: &mut Inputs,
12431422
     table: &mut SymbolTable,
12441423
     report: &mut DrainReport,
1424
+    parallel_jobs: usize,
12451425
 ) -> Result<(), FetchError> {
12461426
     for i in 0..inputs.archives.len() {
1247
-        force_load_archive(inputs, table, ArchiveId(i as u32), report)?;
1427
+        force_load_archive(inputs, table, ArchiveId(i as u32), report, parallel_jobs)?;
12481428
     }
12491429
     Ok(())
12501430
 }
@@ -1523,16 +1703,63 @@ pub fn drain_fetches(
15231703
     inputs: &mut Inputs,
15241704
     table: &mut SymbolTable,
15251705
     initial: Vec<PendingFetch>,
1706
+    parallel_jobs: usize,
15261707
 ) -> Result<DrainReport, FetchError> {
15271708
     let mut queue = initial;
1709
+    let mut prepared = HashMap::new();
15281710
     let mut report = DrainReport::default();
15291711
     while let Some(p) = queue.pop() {
1530
-        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)?;
15311732
         queue.extend(new_pending);
15321733
     }
15331734
     Ok(report)
15341735
 }
15351736
 
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
+
15361763
 /// Turn a wire-form `InputSymbol` into a resolver-side `Symbol`. Returns
15371764
 /// `None` for kinds the resolver does not track (currently: aliases with
15381765
 /// unresolved target strx — Sprint 8's resolver defers those for now).
src/string_table.rsmodified
@@ -99,7 +99,6 @@ impl StringTable {
9999
 #[derive(Debug, Clone, Default, PartialEq, Eq)]
100100
 pub struct StringTableBuilder {
101101
     roots: Vec<RootString>,
102
-    roots_by_last_byte: HashMap<u8, Vec<usize>>,
103102
     offsets: HashMap<String, u32>,
104103
 }
105104
 
@@ -109,6 +108,12 @@ struct RootString {
109108
     offset: u32,
110109
 }
111110
 
111
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112
+struct BorrowedRootString<'a> {
113
+    name: &'a str,
114
+    offset: u32,
115
+}
116
+
112117
 impl StringTableBuilder {
113118
     pub fn new() -> Self {
114119
         Self::default()
@@ -118,6 +123,41 @@ impl StringTableBuilder {
118123
         self.offsets.entry(name.to_string()).or_insert(0);
119124
     }
120125
 
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
+
121161
     pub fn finish(mut self) -> (Vec<u8>, HashMap<String, u32>) {
122162
         let mut names: Vec<String> = self.offsets.keys().cloned().collect();
123163
         names.sort_by(|lhs, rhs| reverse_suffix_order(lhs, rhs));
@@ -132,17 +172,10 @@ impl StringTableBuilder {
132172
             let offset = raw.len() as u32;
133173
             raw.extend_from_slice(name.as_bytes());
134174
             raw.push(0);
135
-            let root_index = self.roots.len();
136175
             self.roots.push(RootString {
137176
                 name: name.clone(),
138177
                 offset,
139178
             });
140
-            if let Some(&last_byte) = name.as_bytes().last() {
141
-                self.roots_by_last_byte
142
-                    .entry(last_byte)
143
-                    .or_default()
144
-                    .push(root_index);
145
-            }
146179
             self.offsets.insert(name, offset);
147180
         }
148181
 
@@ -153,16 +186,26 @@ impl StringTableBuilder {
153186
     }
154187
 
155188
     fn find_suffix_offset(&self, name: &str) -> Option<u32> {
156
-        let last_byte = *name.as_bytes().last()?;
157
-        self.roots_by_last_byte
158
-            .get(&last_byte)?
159
-            .iter()
160
-            .find_map(|&idx| {
161
-                let existing = &self.roots[idx];
162
-                (existing.name.len() >= name.len() && existing.name.ends_with(name))
163
-                    .then(|| existing.offset + (existing.name.len() - name.len()) as u32)
164
-            })
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);
165204
     }
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)
166209
 }
167210
 
168211
 fn reverse_suffix_order(lhs: &str, rhs: &str) -> std::cmp::Ordering {
@@ -285,4 +328,19 @@ mod tests {
285328
         assert_eq!(table.get(offsets["_alpha"]).unwrap(), "_alpha");
286329
         assert_eq!(table.get(offsets["_beta"]).unwrap(), "_beta");
287330
     }
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
+    }
288346
 }
src/synth/code_sig.rsmodified
@@ -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
@@ -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
 
src/synth/mod.rsmodified
@@ -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
 };
@@ -92,37 +94,25 @@ impl SyntheticPlan {
9294
         sym_table: &mut SymbolTable,
9395
         dylibs: &[DylibInput],
9496
         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,
95109
     ) -> Result<Self, SynthError> {
96110
         let input_map: HashMap<InputId, &ObjectFile> = inputs
97111
             .iter()
98112
             .map(|input| (input.id, input.object))
99113
             .collect();
100
-        let mut reloc_cache: HashMap<(InputId, u8), Vec<Reloc>> = HashMap::new();
101
-        for input in inputs {
102
-            for (sect_idx, section) in input.object.sections.iter().enumerate() {
103
-                let relocs = if section.nreloc == 0 {
104
-                    Vec::new()
105
-                } else {
106
-                    let raws = parse_raw_relocs(&section.raw_relocs, 0, section.nreloc).map_err(
107
-                        |err| SynthError {
108
-                            input: input.object.path.clone(),
109
-                            atom: crate::resolve::AtomId(0),
110
-                            reloc_offset: 0,
111
-                            kind: RelocKind::Unsigned,
112
-                            detail: err.to_string(),
113
-                        },
114
-                    )?;
115
-                    parse_relocs(&raws).map_err(|err| SynthError {
116
-                        input: input.object.path.clone(),
117
-                        atom: crate::resolve::AtomId(0),
118
-                        reloc_offset: 0,
119
-                        kind: RelocKind::Unsigned,
120
-                        detail: err.to_string(),
121
-                    })?
122
-                };
123
-                reloc_cache.insert((input.id, (sect_idx + 1) as u8), relocs);
124
-            }
125
-        }
114
+        let input_symbol_index = build_input_symbol_index(inputs, sym_table, parsed_relocs);
115
+        let reloc_index = build_sorted_reloc_index(parsed_relocs);
126116
 
127117
         let mut got = GotSection::default();
128118
         let mut stubs = StubsSection::default();
@@ -141,16 +131,19 @@ impl SyntheticPlan {
141131
                 kind: RelocKind::Unsigned,
142132
                 detail: "missing parsed object".to_string(),
143133
             })?;
144
-            let relocs = reloc_cache
134
+            let relocs = reloc_index
145135
                 .get(&(atom.origin, atom.input_section))
146136
                 .map(Vec::as_slice)
147137
                 .unwrap_or(&[]);
138
+            let input_symbols = input_symbol_index.get(&atom.origin).map(Vec::as_slice);
148139
             for reloc in relocs_for_atom(relocs, atom) {
149140
                 if atom.section == AtomSection::CompactUnwind
150141
                     && reloc.kind == RelocKind::Unsigned
151142
                     && reloc.offset == atom.input_offset + COMPACT_UNWIND_PERSONALITY_FIELD_OFFSET
152143
                 {
153
-                    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
+                    {
154147
                         got.intern(symbol_id, dylib_import_is_weak(sym_table, symbol_id));
155148
                     }
156149
                     continue;
@@ -163,7 +156,8 @@ impl SyntheticPlan {
163156
                         if matches!(atom.section, AtomSection::ThreadLocalVariables) {
164157
                             continue;
165158
                         }
166
-                        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)
167161
                         else {
168162
                             continue;
169163
                         };
@@ -179,7 +173,8 @@ impl SyntheticPlan {
179173
                     RelocKind::GotLoadPage21
180174
                     | RelocKind::GotLoadPageOff12
181175
                     | RelocKind::PointerToGot => {
182
-                        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)
183178
                         else {
184179
                             continue;
185180
                         };
@@ -198,7 +193,8 @@ impl SyntheticPlan {
198193
                         }
199194
                     }
200195
                     RelocKind::Branch26 => {
201
-                        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)
202198
                         else {
203199
                             continue;
204200
                         };
@@ -214,7 +210,8 @@ impl SyntheticPlan {
214210
                         );
215211
                     }
216212
                     RelocKind::TlvpLoadPage21 | RelocKind::TlvpLoadPageOff12 => {
217
-                        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)
218215
                         {
219216
                             if tlv_symbol_needs_got(sym_table, symbol_id) {
220217
                                 got.intern(symbol_id, dylib_import_is_weak(sym_table, symbol_id));
@@ -424,12 +421,99 @@ fn sort_symbol_indexed_entries<T, F>(
424421
     }
425422
 }
426423
 
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
+
427509
 fn relocs_for_atom<'a>(relocs: &'a [Reloc], atom: &Atom) -> impl Iterator<Item = Reloc> + 'a {
428510
     let start = atom.input_offset;
429511
     let end = atom.input_offset + atom.size;
430
-    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| {
431515
         let reloc_end = reloc.offset + reloc.width_for_planning();
432
-        reloc.offset >= start && reloc_end <= end
516
+        reloc_end <= end
433517
     })
434518
 }
435519
 
@@ -455,8 +539,9 @@ fn dylib_import_referent(
455539
     obj: &ObjectFile,
456540
     referent: Referent,
457541
     sym_table: &SymbolTable,
542
+    input_symbols: Option<&[Option<SymbolId>]>,
458543
 ) -> Option<SymbolId> {
459
-    let symbol_id = symbol_referent_id(obj, referent, sym_table)?;
544
+    let symbol_id = symbol_referent_id(obj, referent, sym_table, input_symbols)?;
460545
     matches!(sym_table.get(symbol_id), Symbol::DylibImport { .. }).then_some(symbol_id)
461546
 }
462547
 
@@ -464,10 +549,14 @@ fn symbol_referent_id(
464549
     obj: &ObjectFile,
465550
     referent: Referent,
466551
     sym_table: &SymbolTable,
552
+    input_symbols: Option<&[Option<SymbolId>]>,
467553
 ) -> Option<SymbolId> {
468554
     let Referent::Symbol(sym_idx) = referent else {
469555
         return None;
470556
     };
557
+    if let Some(symbols) = input_symbols {
558
+        return symbols.get(sym_idx as usize).copied().flatten();
559
+    }
471560
     let input_sym = obj.symbols.get(sym_idx as usize)?;
472561
     let name = obj.symbol_name(input_sym).ok()?;
473562
     let (symbol_id, _) = sym_table
tests/atom_integration.rsmodified
@@ -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/common/harness.rsmodified
@@ -9,15 +9,23 @@
99
 use std::collections::{BTreeMap, HashSet};
1010
 use std::fs;
1111
 use std::path::{Path, PathBuf};
12
-use std::process::Command;
13
-use std::time::{SystemTime, UNIX_EPOCH};
12
+use std::process::{Command, Stdio};
13
+use std::thread;
14
+use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
1415
 
15
-use afs_ld::leb::read_uleb;
16
+use afs_ld::leb::{read_sleb, read_uleb};
1617
 use afs_ld::macho::constants::{
17
-    INDIRECT_SYMBOL_ABS, INDIRECT_SYMBOL_LOCAL, LC_BUILD_VERSION, LC_CODE_SIGNATURE,
18
-    LC_DATA_IN_CODE, LC_DYLD_CHAINED_FIXUPS, LC_DYLD_EXPORTS_TRIE, LC_DYLD_INFO_ONLY, LC_DYSYMTAB,
19
-    LC_FUNCTION_STARTS, LC_ID_DYLIB, LC_LOAD_DYLIB, LC_LOAD_UPWARD_DYLIB, LC_LOAD_WEAK_DYLIB,
20
-    LC_REEXPORT_DYLIB, LC_SEGMENT_64, LC_SYMTAB, LC_UUID,
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,
2129
 };
2230
 use afs_ld::macho::dylib::DylibFile;
2331
 use afs_ld::macho::exports::ExportKind;
@@ -27,6 +35,7 @@ use afs_ld::macho::reader::{
2735
 };
2836
 use afs_ld::string_table::StringTable;
2937
 use afs_ld::symbol::{parse_nlist_table, SymKind};
38
+use afs_ld::synth::stubs::{STUB_HELPER_ENTRY_SIZE, STUB_HELPER_HEADER_SIZE};
3039
 use afs_ld::synth::unwind::decode_unwind_info;
3140
 
3241
 #[derive(Debug, Clone)]
@@ -59,6 +68,7 @@ pub enum CommandCheck {
5968
     FunctionStarts,
6069
     NormalizedFunctionStarts,
6170
     DataInCode,
71
+    DataInCodeIfPresent,
6272
     RebasedUnwindBytes,
6373
     DyldInfoRebase,
6474
     DyldInfoBind,
@@ -107,11 +117,13 @@ struct ArtifactSpec {
107117
 
108118
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
109119
 enum ArtifactKind {
110
-    ClangDylib,
111
-    ClangArchive,
112
-    ClangReexportDylib,
120
+    Dylib,
121
+    Archive,
122
+    ReexportDylib,
113123
 }
114124
 
125
+type SymbolPartitions = (Vec<String>, Vec<String>, Vec<String>);
126
+
115127
 pub struct LinkOutputs {
116128
     pub ours: Vec<u8>,
117129
     pub theirs: Vec<u8>,
@@ -209,11 +221,7 @@ pub fn scratch(name: &str) -> PathBuf {
209221
 }
210222
 
211223
 pub fn assemble(src: &str, out: &PathBuf) -> Result<(), String> {
212
-    let tmp = std::env::temp_dir().join(format!(
213
-        "afs-ld-parity-{}-{}.s",
214
-        std::process::id(),
215
-        out.file_stem().and_then(|s| s.to_str()).unwrap_or("t")
216
-    ));
224
+    let tmp = out.with_extension("s");
217225
     fs::write(&tmp, src).map_err(|e| format!("write {}: {e}", tmp.display()))?;
218226
     let output = Command::new("xcrun")
219227
         .args(["--sdk", "macosx", "as", "-arch", "arm64"])
@@ -233,11 +241,7 @@ pub fn assemble(src: &str, out: &PathBuf) -> Result<(), String> {
233241
 }
234242
 
235243
 pub fn compile_c(src: &str, out: &PathBuf) -> Result<(), String> {
236
-    let tmp = std::env::temp_dir().join(format!(
237
-        "afs-ld-parity-{}-{}.c",
238
-        std::process::id(),
239
-        out.file_stem().and_then(|s| s.to_str()).unwrap_or("t")
240
-    ));
244
+    let tmp = out.with_extension("c");
241245
     fs::write(&tmp, src).map_err(|e| format!("write {}: {e}", tmp.display()))?;
242246
     let output = Command::new("xcrun")
243247
         .args(["--sdk", "macosx", "clang", "-arch", "arm64", "-c"])
@@ -257,11 +261,7 @@ pub fn compile_c(src: &str, out: &PathBuf) -> Result<(), String> {
257261
 }
258262
 
259263
 fn compile_dylib_c(src: &str, out: &PathBuf) -> Result<(), String> {
260
-    let tmp = std::env::temp_dir().join(format!(
261
-        "afs-ld-parity-{}-{}.c",
262
-        std::process::id(),
263
-        out.file_stem().and_then(|s| s.to_str()).unwrap_or("lib")
264
-    ));
264
+    let tmp = out.with_extension("c");
265265
     fs::write(&tmp, src).map_err(|e| format!("write {}: {e}", tmp.display()))?;
266266
     let install_name = out.to_string_lossy().to_string();
267267
     let output = Command::new("xcrun")
@@ -302,13 +302,7 @@ fn compile_archive_c(src: &str, out: &PathBuf) -> Result<(), String> {
302302
 }
303303
 
304304
 fn compile_reexport_dylib_c(src: &str, out: &PathBuf, dep: &Path) -> Result<(), String> {
305
-    let tmp = std::env::temp_dir().join(format!(
306
-        "afs-ld-parity-{}-{}.c",
307
-        std::process::id(),
308
-        out.file_stem()
309
-            .and_then(|s| s.to_str())
310
-            .unwrap_or("reexport")
311
-    ));
305
+    let tmp = out.with_extension("c");
312306
     fs::write(&tmp, src).map_err(|e| format!("write {}: {e}", tmp.display()))?;
313307
     let install_name = out.to_string_lossy().to_string();
314308
     let output = Command::new("xcrun")
@@ -500,9 +494,9 @@ pub fn link_both(case: &LinkCase) -> Result<LinkOutputs, String> {
500494
             .map_err(|e| format!("read artifact src {}: {e}", src.display()))?;
501495
         let out = work_dir.join(&artifact.out_name);
502496
         match artifact.kind {
503
-            ArtifactKind::ClangDylib => compile_dylib_c(&src_contents, &out)?,
504
-            ArtifactKind::ClangArchive => compile_archive_c(&src_contents, &out)?,
505
-            ArtifactKind::ClangReexportDylib => {
497
+            ArtifactKind::Dylib => compile_dylib_c(&src_contents, &out)?,
498
+            ArtifactKind::Archive => compile_archive_c(&src_contents, &out)?,
499
+            ArtifactKind::ReexportDylib => {
506500
                 let dep_name = artifact.dep_name.as_ref().ok_or_else(|| {
507501
                     format!(
508502
                         "missing reexport dependency for artifact {}",
@@ -676,8 +670,8 @@ pub fn compare_command_details(
676670
                 }
677671
             }
678672
             CommandCheck::StringTableNearParity => {
679
-                let our_len = raw_string_table(ours)?.len();
680
-                let their_len = raw_string_table(theirs)?.len();
673
+                let our_len = effective_string_table_len(ours)?;
674
+                let their_len = effective_string_table_len(theirs)?;
681675
                 if !string_table_within_five_percent(our_len, their_len) {
682676
                     return Err(format!(
683677
                         "string table length drifted too far from Apple ld: ours={} theirs={}",
@@ -712,6 +706,15 @@ pub fn compare_command_details(
712706
                     ));
713707
                 }
714708
             }
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
+            }
715718
             CommandCheck::RebasedUnwindBytes => {
716719
                 let ours = rebased_unwind_bytes(ours)?;
717720
                 let theirs = rebased_unwind_bytes(theirs)?;
@@ -727,24 +730,30 @@ pub fn compare_command_details(
727730
                 }
728731
             }
729732
             CommandCheck::DyldInfoBind => {
730
-                let ours = dyld_info_stream(ours, DyldInfoStreamKind::Bind)?;
731
-                let theirs = dyld_info_stream(theirs, DyldInfoStreamKind::Bind)?;
733
+                let ours = canonical_bind_records(ours, DyldInfoStreamKind::Bind)?;
734
+                let theirs = canonical_bind_records(theirs, DyldInfoStreamKind::Bind)?;
732735
                 if ours != theirs {
733
-                    return Err("bind stream diverged".to_string());
736
+                    return Err(format!(
737
+                        "bind stream diverged:\nours:   {ours:#?}\ntheirs: {theirs:#?}"
738
+                    ));
734739
                 }
735740
             }
736741
             CommandCheck::DyldInfoWeakBind => {
737
-                let ours = dyld_info_stream(ours, DyldInfoStreamKind::WeakBind)?;
738
-                let theirs = dyld_info_stream(theirs, DyldInfoStreamKind::WeakBind)?;
742
+                let ours = canonical_bind_records(ours, DyldInfoStreamKind::WeakBind)?;
743
+                let theirs = canonical_bind_records(theirs, DyldInfoStreamKind::WeakBind)?;
739744
                 if ours != theirs {
740
-                    return Err("weak-bind stream diverged".to_string());
745
+                    return Err(format!(
746
+                        "weak-bind stream diverged:\nours:   {ours:#?}\ntheirs: {theirs:#?}"
747
+                    ));
741748
                 }
742749
             }
743750
             CommandCheck::DyldInfoLazyBind => {
744
-                let ours = dyld_info_stream(ours, DyldInfoStreamKind::LazyBind)?;
745
-                let theirs = dyld_info_stream(theirs, DyldInfoStreamKind::LazyBind)?;
751
+                let ours = canonical_bind_records(ours, DyldInfoStreamKind::LazyBind)?;
752
+                let theirs = canonical_bind_records(theirs, DyldInfoStreamKind::LazyBind)?;
746753
                 if ours != theirs {
747
-                    return Err("lazy-bind stream diverged".to_string());
754
+                    return Err(format!(
755
+                        "lazy-bind stream diverged:\nours:   {ours:#?}\ntheirs: {theirs:#?}"
756
+                    ));
748757
                 }
749758
             }
750759
         }
@@ -841,6 +850,26 @@ pub fn compare_sections(
841850
     case_tolerances: &[CaseTolerance],
842851
 ) -> Result<(), String> {
843852
     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
+        }
844873
         let (_, our_bytes) = output_section(ours, segname, sectname)
845874
             .ok_or_else(|| format!("missing section {segname},{sectname} in afs-ld output"))?;
846875
         let (_, their_bytes) = output_section(theirs, segname, sectname)
@@ -890,12 +919,8 @@ pub fn compare_page_refs(
890919
             decode_page_reference(&our_bytes, our_addr, check.site_offset, check.kind)?;
891920
         let their_target =
892921
             decode_page_reference(&their_bytes, their_addr, check.site_offset, check.kind)?;
893
-        let expected_ours = *our_symbols
894
-            .get(&check.symbol)
895
-            .ok_or_else(|| format!("missing symbol {} in afs-ld output", check.symbol))?;
896
-        let expected_theirs = *their_symbols
897
-            .get(&check.symbol)
898
-            .ok_or_else(|| format!("missing symbol {} in Apple output", check.symbol))?;
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)?;
899924
         if our_target != expected_ours || their_target != expected_theirs {
900925
             return Err(format!(
901926
                 "page ref {},{}+0x{:x} -> {} diverged: ours=0x{:x} expected=0x{:x}; theirs=0x{:x} expected=0x{:x}",
@@ -913,21 +938,94 @@ pub fn compare_page_refs(
913938
     Ok(())
914939
 }
915940
 
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
+
916971
 pub fn run_program(path: &Path, args: &[String]) -> Result<ProgramOutput, String> {
917
-    let output = Command::new(path)
972
+    let runtime_timeout = runtime_timeout();
973
+
974
+    let mut child = Command::new(path)
918975
         .args(args)
919
-        .output()
976
+        .stdout(Stdio::piped())
977
+        .stderr(Stdio::piped())
978
+        .spawn()
920979
         .map_err(|e| format!("run {}: {e}", path.display()))?;
921
-    Ok(ProgramOutput {
922
-        exit_code: output.status.code(),
923
-        stdout: output.stdout,
924
-        stderr: output.stderr,
925
-    })
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
+    }
9261012
 }
9271013
 
9281014
 pub fn compare_runtime(our_path: &Path, their_path: &Path, args: &[String]) -> Result<(), String> {
929
-    let ours = run_program(our_path, args)?;
930
-    let theirs = run_program(their_path, args)?;
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?;
9311029
     if ours != theirs {
9321030
         return Err(format!(
9331031
             "runtime differs:\nours: exit={:?} stdout={:?} stderr={:?}\ntheirs: exit={:?} stdout={:?} stderr={:?}",
@@ -942,6 +1040,16 @@ pub fn compare_runtime(our_path: &Path, their_path: &Path, args: &[String]) -> R
9421040
     Ok(())
9431041
 }
9441042
 
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
+
9451053
 /// Byte-level diff between two Mach-O images or section byte slices.
9461054
 ///
9471055
 /// Sprint 27 starts tolerating a very small allowlist: UUID bytes, dylib
@@ -1320,7 +1428,7 @@ fn read_artifacts(path: &Path) -> Result<Vec<ArtifactSpec>, String> {
13201428
                         path.display()
13211429
                     ));
13221430
                 }
1323
-                (ArtifactKind::ClangDylib, None)
1431
+                (ArtifactKind::Dylib, None)
13241432
             }
13251433
             "clang_archive" => {
13261434
                 if dep_name.is_some() {
@@ -1329,7 +1437,7 @@ fn read_artifacts(path: &Path) -> Result<Vec<ArtifactSpec>, String> {
13291437
                         path.display()
13301438
                     ));
13311439
                 }
1332
-                (ArtifactKind::ClangArchive, None)
1440
+                (ArtifactKind::Archive, None)
13331441
             }
13341442
             "clang_reexport_dylib" => {
13351443
                 let dep_name = dep_name.ok_or_else(|| {
@@ -1338,7 +1446,7 @@ fn read_artifacts(path: &Path) -> Result<Vec<ArtifactSpec>, String> {
13381446
                         path.display()
13391447
                     )
13401448
                 })?;
1341
-                (ArtifactKind::ClangReexportDylib, Some(dep_name))
1449
+                (ArtifactKind::ReexportDylib, Some(dep_name))
13421450
             }
13431451
             other => return Err(format!("unknown artifact kind `{other}`")),
13441452
         };
@@ -1364,6 +1472,7 @@ fn parse_command_check(name: &str) -> Result<CommandCheck, String> {
13641472
         "function_starts" => Ok(CommandCheck::FunctionStarts),
13651473
         "normalized_function_starts" => Ok(CommandCheck::NormalizedFunctionStarts),
13661474
         "data_in_code" => Ok(CommandCheck::DataInCode),
1475
+        "data_in_code_if_present" => Ok(CommandCheck::DataInCodeIfPresent),
13671476
         "rebased_unwind_bytes" => Ok(CommandCheck::RebasedUnwindBytes),
13681477
         "dyld_info_rebase" => Ok(CommandCheck::DyldInfoRebase),
13691478
         "dyld_info_bind" => Ok(CommandCheck::DyldInfoBind),
@@ -1521,10 +1630,10 @@ fn tolerated_mask(bytes: &[u8]) -> Vec<Option<&'static str>> {
15211630
                 }
15221631
             }
15231632
             LC_ID_DYLIB | LC_LOAD_DYLIB | LC_LOAD_WEAK_DYLIB | LC_REEXPORT_DYLIB
1524
-            | LC_LOAD_UPWARD_DYLIB => {
1525
-                if cmdsize >= 16 {
1526
-                    mark_range(&mut mask, cursor + 12, cursor + 16, "dylib timestamp");
1527
-                }
1633
+            | LC_LOAD_UPWARD_DYLIB
1634
+                if cmdsize >= 16 =>
1635
+            {
1636
+                mark_range(&mut mask, cursor + 12, cursor + 16, "dylib timestamp");
15281637
             }
15291638
             _ => {}
15301639
         }
@@ -1578,7 +1687,7 @@ fn load_dylib_names(bytes: &[u8]) -> Result<Vec<String>, String> {
15781687
 struct CanonicalSymbolRecord {
15791688
     name: String,
15801689
     n_type: u8,
1581
-    n_sect: u8,
1690
+    section: Option<(String, String)>,
15821691
     n_desc: u16,
15831692
     value: u64,
15841693
 }
@@ -1614,31 +1723,42 @@ fn canonical_symbol_records(bytes: &[u8]) -> Result<Vec<CanonicalSymbolRecord>,
16141723
         parse_nlist_table(bytes, symtab.symoff, symtab.nsyms).map_err(|e| e.to_string())?;
16151724
     let strings =
16161725
         StringTable::from_file(bytes, symtab.stroff, symtab.strsize).map_err(|e| e.to_string())?;
1617
-    let section_addrs = section_addrs(bytes)?;
1726
+    let sections = section_regions(bytes)?;
16181727
     Ok(symbols
16191728
         .iter()
16201729
         .map(|symbol| {
1621
-            let value = if symbol.kind() == SymKind::Sect && symbol.sect_idx() != 0 {
1622
-                let section_addr = section_addrs[symbol.sect_idx() as usize - 1];
1623
-                if symbol.value() >= section_addr {
1624
-                    symbol.value() - section_addr
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
16251734
                 } else {
16261735
                     symbol.value()
1627
-                }
1736
+                };
1737
+                (
1738
+                    Some((section.segname.clone(), section.sectname.clone())),
1739
+                    value,
1740
+                )
16281741
             } else {
1629
-                symbol.value()
1742
+                (None, symbol.value())
16301743
             };
16311744
             CanonicalSymbolRecord {
16321745
                 name: strings.get(symbol.strx()).unwrap().to_string(),
16331746
                 n_type: symbol.raw.n_type,
1634
-                n_sect: symbol.raw.n_sect,
1747
+                section,
16351748
                 n_desc: symbol.raw.n_desc,
16361749
                 value,
16371750
             }
16381751
         })
1752
+        .filter(|record| !is_optional_dyld_stub_binder_record(record))
16391753
         .collect())
16401754
 }
16411755
 
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
+
16421762
 fn canonical_export_records(bytes: &[u8]) -> Result<Vec<CanonicalExportRecord>, String> {
16431763
     let dylib = DylibFile::parse("/tmp/canonical.dylib", bytes).map_err(|e| e.to_string())?;
16441764
     let symbol_values: BTreeMap<String, u64> = canonical_symbol_records(bytes)?
@@ -1683,7 +1803,7 @@ fn canonical_export_records(bytes: &[u8]) -> Result<Vec<CanonicalExportRecord>,
16831803
     Ok(out)
16841804
 }
16851805
 
1686
-fn symbol_partition_names(bytes: &[u8]) -> Result<(Vec<String>, Vec<String>, Vec<String>), String> {
1806
+fn symbol_partition_names(bytes: &[u8]) -> Result<SymbolPartitions, String> {
16871807
     let (symtab, dysymtab) = symtab_and_dysymtab(bytes)?;
16881808
     let symbols =
16891809
         parse_nlist_table(bytes, symtab.symoff, symtab.nsyms).map_err(|e| e.to_string())?;
@@ -1698,10 +1818,31 @@ fn symbol_partition_names(bytes: &[u8]) -> Result<(Vec<String>, Vec<String>, Vec
16981818
     Ok((
16991819
         names_for(dysymtab.ilocalsym, dysymtab.nlocalsym),
17001820
         names_for(dysymtab.iextdefsym, dysymtab.nextdefsym),
1701
-        names_for(dysymtab.iundefsym, dysymtab.nundefsym),
1821
+        names_for(dysymtab.iundefsym, dysymtab.nundefsym)
1822
+            .into_iter()
1823
+            .filter(|name| name != "dyld_stub_binder")
1824
+            .collect(),
17021825
     ))
17031826
 }
17041827
 
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
+
17051846
 fn raw_string_table(bytes: &[u8]) -> Result<Vec<u8>, String> {
17061847
     let (symtab, _) = symtab_and_dysymtab(bytes)?;
17071848
     let start = symtab.stroff as usize;
@@ -1709,6 +1850,14 @@ fn raw_string_table(bytes: &[u8]) -> Result<Vec<u8>, String> {
17091850
     Ok(bytes[start..end].to_vec())
17101851
 }
17111852
 
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
+
17121861
 pub fn string_table_within_five_percent(ours: usize, theirs: usize) -> bool {
17131862
     let delta = ours.abs_diff(theirs);
17141863
     delta * 20 <= theirs
@@ -1835,6 +1984,175 @@ fn canonical_data_in_code(bytes: &[u8]) -> Result<Vec<DataInCodeRecord>, String>
18351984
         .collect())
18361985
 }
18371986
 
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
+
18382156
 fn rebased_unwind_bytes(bytes: &[u8]) -> Result<Vec<u8>, String> {
18392157
     let header_base = segment_vmaddr(bytes, "__TEXT").unwrap_or(0);
18402158
     let text_base = output_section(bytes, "__TEXT", "__text")
@@ -1931,19 +2249,130 @@ fn symtab_and_dysymtab(
19312249
 }
19322250
 
19332251
 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> {
19342302
     let header = parse_header(bytes).map_err(|e| e.to_string())?;
19352303
     let commands = parse_commands(&header, bytes).map_err(|e| e.to_string())?;
19362304
     let mut out = Vec::new();
2305
+    let mut segment_index = 0u8;
19372306
     for cmd in commands {
19382307
         if let LoadCommand::Segment64(seg) = cmd {
19392308
             for section in seg.sections {
1940
-                out.push(section.addr);
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
+                });
19412316
             }
2317
+            segment_index = segment_index.saturating_add(1);
19422318
         }
19432319
     }
19442320
     Ok(out)
19452321
 }
19462322
 
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
+
19472376
 #[derive(Clone, Copy)]
19482377
 enum DyldInfoStreamKind {
19492378
     Rebase,
@@ -1983,6 +2412,157 @@ fn dyld_info_stream(bytes: &[u8], kind: DyldInfoStreamKind) -> Result<Vec<u8>, S
19832412
         .ok_or_else(|| "dyld-info stream out of bounds".to_string())
19842413
 }
19852414
 
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
+
19862566
 fn symbol_values(bytes: &[u8]) -> Result<BTreeMap<String, u64>, String> {
19872567
     let header = parse_header(bytes).map_err(|e| e.to_string())?;
19882568
     let commands = parse_commands(&header, bytes).map_err(|e| e.to_string())?;
tests/determinism.rsadded
@@ -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/linker_write_integration.rsadded
@@ -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
@@ -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_corpus/data_in_code_exec/command_checks.txtmodified
@@ -1,3 +1,3 @@
11
 build_version
22
 load_dylib_names
3
-data_in_code
3
+data_in_code_if_present
tests/parity_corpus/data_in_code_large_first_exec/command_checks.txtmodified
@@ -1,3 +1,3 @@
11
 build_version
22
 load_dylib_names
3
-data_in_code
3
+data_in_code_if_present
tests/parity_corpus/data_in_code_late_exec/command_checks.txtmodified
@@ -1,3 +1,3 @@
11
 build_version
22
 load_dylib_names
3
-data_in_code
3
+data_in_code_if_present
tests/parity_corpus/function_starts_exec/command_checks.txtmodified
@@ -1,4 +1,4 @@
11
 build_version
22
 load_dylib_names
33
 normalized_function_starts
4
-data_in_code
4
+data_in_code_if_present
tests/parity_corpus/hidden_got_exec/sections.txtmodified
@@ -1,1 +0,0 @@
1
-__TEXT __text
tests/parity_corpus/imported_tlv_exec/absent_sections.txtmodified
@@ -1,1 +0,0 @@
1
-__DATA __thread_ptrs
tests/parity_corpus/imported_tlv_exec/page_refs.txtadded
tests/parity_corpus/imported_tlv_exec/sections.txtmodified
@@ -1,2 +0,0 @@
1
-__TEXT __text
2
-__DATA_CONST __got
tests/parity_corpus/strip_locals_exec/args.txtmodified
@@ -1,5 +1,12 @@
11
 -arch
22
 arm64
3
+-platform_version
4
+macos
5
+@SDK_VERSION@
6
+@SDK_VERSION@
7
+-syslibroot
8
+@SDK_PATH@
9
+-lSystem
310
 -x
411
 -no_fixup_chains
512
 -e
tests/parity_corpus/symtab_partition_exec/args.txtmodified
@@ -1,5 +1,12 @@
11
 -arch
22
 arm64
3
+-platform_version
4
+macos
5
+@SDK_VERSION@
6
+@SDK_VERSION@
7
+-syslibroot
8
+@SDK_PATH@
9
+-lSystem
310
 -no_fixup_chains
411
 -e
512
 _main
tests/parity_matrix.rsmodified
@@ -8,6 +8,8 @@ mod common;
88
 
99
 use std::fs;
1010
 use std::path::{Path, PathBuf};
11
+use std::sync::{mpsc, Arc, Mutex};
12
+use std::thread;
1113
 use std::time::{Duration, Instant};
1214
 
1315
 use common::harness::{
@@ -40,19 +42,19 @@ fn parity_corpus() {
4042
         fs::create_dir_all(dir).expect("create parity artifact dir");
4143
     }
4244
 
43
-    let mut case_reports = Vec::new();
4445
     let mut failures = Vec::new();
46
+    let case_reports = run_cases(cases);
4547
 
46
-    for case in cases {
47
-        let report = run_case(&case);
48
+    for (case, report) in &case_reports {
4849
         if let Some(dir) = artifact_dir.as_ref() {
49
-            write_case_artifact(dir, &case, &report).expect("write case artifact");
50
+            write_case_artifact(dir, case, report).expect("write case artifact");
5051
         }
51
-        if let Some(error) = report.error_message() {
52
+        if let Some(error) = report.error_message(&case.name) {
53
+            eprintln!("parity failure:\n{error}\n");
5254
             failures.push(error);
5355
         }
54
-        case_reports.push((case, report));
5556
     }
57
+    print_timing_summary(started.elapsed(), &case_reports);
5658
 
5759
     if let Some(dir) = artifact_dir.as_ref() {
5860
         write_index_artifact(dir, &case_reports).expect("write parity index");
@@ -76,27 +78,84 @@ fn parity_corpus() {
7678
     }
7779
 }
7880
 
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
+
79122
 #[derive(Debug)]
80123
 struct CaseStep {
81124
     name: &'static str,
125
+    duration: Duration,
82126
     error: Option<String>,
83127
 }
84128
 
85129
 #[derive(Debug, Default)]
86130
 struct CaseReport {
87131
     steps: Vec<CaseStep>,
132
+    elapsed: Duration,
88133
 }
89134
 
90135
 impl CaseReport {
91136
     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 {
92146
         match result {
93147
             Ok(()) => {
94
-                self.steps.push(CaseStep { name, error: None });
148
+                self.steps.push(CaseStep {
149
+                    name,
150
+                    duration,
151
+                    error: None,
152
+                });
95153
                 true
96154
             }
97155
             Err(error) => {
98156
                 self.steps.push(CaseStep {
99157
                     name,
158
+                    duration,
100159
                     error: Some(error),
101160
                 });
102161
                 false
@@ -104,99 +163,128 @@ impl CaseReport {
104163
         }
105164
     }
106165
 
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
+
107179
     fn passed(&self) -> bool {
108180
         self.steps.iter().all(|step| step.error.is_none())
109181
     }
110182
 
111
-    fn error_message(&self) -> Option<String> {
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> {
112188
         self.steps.iter().find_map(|step| {
113189
             step.error
114190
                 .as_ref()
115
-                .map(|error| format!("{} failed:\n{}", step.name, error))
191
+                .map(|error| format!("[{case_name}] {} failed:\n{}", step.name, error))
116192
         })
117193
     }
118194
 }
119195
 
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
+
120209
 fn run_case(case: &LinkCase) -> CaseReport {
210
+    let case_started = Instant::now();
121211
     let mut report = CaseReport::default();
212
+    let link_started = Instant::now();
122213
     let outputs = match link_both(case) {
123214
         Ok(outputs) => {
124
-            report.push("link", Ok(()));
215
+            report.push_timed("link", link_started.elapsed(), Ok(()));
125216
             outputs
126217
         }
127218
         Err(error) => {
128
-            report.push(
219
+            report.push_timed(
129220
                 "link",
221
+                link_started.elapsed(),
130222
                 Err(format!(
131223
                     "failed to link parity case from {}:\n{}",
132224
                     case.dir.display(),
133225
                     error
134226
                 )),
135227
             );
136
-            return report;
228
+            return finish_case(report, case_started);
137229
         }
138230
     };
139231
 
140
-    if !report.push(
141
-        "load-command ids",
142
-        compare_command_ids(&outputs.ours, &outputs.theirs, &case.ignored_load_commands),
143
-    ) {
144
-        return report;
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);
145236
     }
146
-    if !report.push(
147
-        "command details",
148
-        compare_command_details(&outputs.ours, &outputs.theirs, &case.command_checks),
149
-    ) {
150
-        return report;
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);
151241
     }
152
-    if !report.push(
153
-        "afs-ld absent commands",
154
-        ensure_absent_load_commands(&outputs.ours, &case.absent_load_commands, "afs-ld"),
155
-    ) {
156
-        return report;
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);
157246
     }
158
-    if !report.push(
159
-        "Apple absent commands",
160
-        ensure_absent_load_commands(&outputs.theirs, &case.absent_load_commands, "Apple ld"),
161
-    ) {
162
-        return report;
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);
163251
     }
164
-    if !report.push(
165
-        "afs-ld absent sections",
166
-        ensure_absent_sections(&outputs.ours, &case.absent_sections, "afs-ld"),
167
-    ) {
168
-        return report;
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);
169256
     }
170
-    if !report.push(
171
-        "Apple absent sections",
172
-        ensure_absent_sections(&outputs.theirs, &case.absent_sections, "Apple ld"),
173
-    ) {
174
-        return report;
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);
175261
     }
176
-    if !report.push(
177
-        "section parity",
262
+    if !report.measure("section parity", || {
178263
         compare_sections(
179264
             &outputs.ours,
180265
             &outputs.theirs,
181266
             &case.section_checks,
182267
             &case.case_tolerances,
183
-        ),
184
-    ) {
185
-        return report;
268
+        )
269
+    }) {
270
+        return finish_case(report, case_started);
186271
     }
187
-    if !report.push(
188
-        "page-ref parity",
189
-        compare_page_refs(&outputs.ours, &outputs.theirs, &case.page_ref_checks),
190
-    ) {
191
-        return report;
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);
192276
     }
193277
     if !case.runtime_args.is_empty() || case.dir.join("runtime.txt").exists() {
194
-        report.push(
195
-            "runtime parity",
196
-            compare_runtime(&outputs.our_path, &outputs.their_path, &case.runtime_args),
197
-        );
278
+        report.measure("runtime parity", || {
279
+            compare_runtime(&outputs.our_path, &outputs.their_path, &case.runtime_args)
280
+        });
198281
     }
199282
 
283
+    finish_case(report, case_started)
284
+}
285
+
286
+fn finish_case(mut report: CaseReport, started: Instant) -> CaseReport {
287
+    report.finish(started.elapsed());
200288
     report
201289
 }
202290
 
@@ -214,16 +302,22 @@ fn write_case_artifact(dir: &Path, case: &LinkCase, report: &CaseReport) -> Resu
214302
         if report.passed() { "ok" } else { "fail" },
215303
         if report.passed() { "PASS" } else { "FAIL" }
216304
     ));
305
+    html.push_str(&format!(
306
+        "<p>Total: <strong>{}</strong></p>",
307
+        format_duration(report.elapsed)
308
+    ));
217309
     html.push_str("<h2>Steps</h2><ul>");
218310
     for step in &report.steps {
219311
         match &step.error {
220312
             None => html.push_str(&format!(
221
-                "<li><span class=\"ok\">PASS</span> {}</li>",
222
-                escape_html(step.name)
313
+                "<li><span class=\"ok\">PASS</span> {} <span class=\"time\">{}</span></li>",
314
+                escape_html(step.name),
315
+                format_duration(step.duration)
223316
             )),
224317
             Some(error) => html.push_str(&format!(
225
-                "<li><span class=\"fail\">FAIL</span> {}<pre>{}</pre></li>",
318
+                "<li><span class=\"fail\">FAIL</span> {} <span class=\"time\">{}</span><pre>{}</pre></li>",
226319
                 escape_html(step.name),
320
+                format_duration(step.duration),
227321
                 escape_html(error)
228322
             )),
229323
         }
@@ -244,16 +338,32 @@ fn write_case_artifact(dir: &Path, case: &LinkCase, report: &CaseReport) -> Resu
244338
 fn write_index_artifact(dir: &Path, cases: &[(LinkCase, CaseReport)]) -> Result<(), String> {
245339
     let mut html = String::new();
246340
     html.push_str("<!doctype html><html><head><meta charset=\"utf-8\">");
247
-    html.push_str("<title>Parity Matrix</title><style>body{font-family:ui-monospace,Menlo,monospace;padding:2rem;} .ok{color:#0a0;} .fail{color:#a00;}</style></head><body>");
248
-    html.push_str("<h1>Parity Matrix</h1><ul>");
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>");
249358
     for (case, report) in cases {
250359
         let slug = slug(&case.name);
251360
         html.push_str(&format!(
252
-            "<li><a href=\"{}.html\">{}</a> <strong class=\"{}\">{}</strong></li>",
361
+            "<li><a href=\"{}.html\">{}</a> <strong class=\"{}\">{}</strong> <span class=\"time\">{}</span></li>",
253362
             slug,
254363
             escape_html(&case.name),
255364
             if report.passed() { "ok" } else { "fail" },
256
-            if report.passed() { "PASS" } else { "FAIL" }
365
+            if report.passed() { "PASS" } else { "FAIL" },
366
+            format_duration(report.elapsed)
257367
         ));
258368
     }
259369
     html.push_str("</ul></body></html>");
@@ -261,6 +371,43 @@ fn write_index_artifact(dir: &Path, cases: &[(LinkCase, CaseReport)]) -> Result<
261371
     fs::write(&path, html).map_err(|e| format!("write {}: {e}", path.display()))
262372
 }
263373
 
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
+
264411
 fn slug(name: &str) -> String {
265412
     name.chars()
266413
         .map(|ch| {
@@ -279,6 +426,31 @@ fn parity_matrix_time_limit() -> Option<Duration> {
279426
     Some(Duration::from_secs(seconds))
280427
 }
281428
 
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
+
282454
 fn escape_html(text: &str) -> String {
283455
     text.replace('&', "&amp;")
284456
         .replace('<', "&lt;")
tests/perf_baseline.rsmodified
@@ -1,10 +1,14 @@
1
+use std::fs;
12
 use std::path::{Path, PathBuf};
3
+use std::process::Command;
24
 use std::time::Duration;
35
 
46
 mod common;
57
 
68
 use afs_ld::{LinkOptions, LinkProfile, Linker};
7
-use common::harness::{assemble, have_xcrun, have_xcrun_tool, scratch, sdk_path, sdk_version};
9
+use common::harness::{
10
+    assemble, have_tool, have_xcrun, have_xcrun_tool, scratch, sdk_path, sdk_version,
11
+};
812
 
913
 fn find_runtime_archive() -> Option<PathBuf> {
1014
     let workspace = Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
@@ -20,6 +24,69 @@ fn find_runtime_archive() -> Option<PathBuf> {
2024
     None
2125
 }
2226
 
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
+
2390
 fn executable_opts(inputs: Vec<PathBuf>, output: PathBuf) -> LinkOptions {
2491
     LinkOptions {
2592
         inputs,
@@ -39,12 +106,18 @@ fn executable_opts(inputs: Vec<PathBuf>, output: PathBuf) -> LinkOptions {
39106
 
40107
 fn assert_profile_basics(name: &str, profile: &LinkProfile) {
41108
     eprintln!(
42
-        "{name}: total={:?} parse={:?} resolve={:?} atomize={:?} layout={:?} synth={:?} (linkedit={:?}: symbols={:?} [locals={:?} globals={:?} strtab={:?}] dyld={:?} metadata={:?} codesig={:?}; unwind={:?}) reloc={:?} write={:?}",
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={:?}",
43110
         profile.total_wall,
44111
         profile.phases.input_parsing,
45112
         profile.phases.symbol_resolution,
46113
         profile.phases.atomization,
47114
         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,
48121
         profile.phases.synth_sections,
49122
         profile.phases.synth_linkedit_finalize,
50123
         profile.phases.synth_linkedit_symbol_plan,
@@ -52,12 +125,25 @@ fn assert_profile_basics(name: &str, profile: &LinkProfile) {
52125
         profile.phases.synth_linkedit_symbol_plan_globals,
53126
         profile.phases.synth_linkedit_symbol_plan_strtab,
54127
         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,
55131
         profile.phases.synth_linkedit_metadata_tables,
56132
         profile.phases.synth_linkedit_code_signature,
57133
         profile.phases.synth_unwind,
58134
         profile.phases.reloc_apply,
59135
         profile.phases.write_output,
60136
     );
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
+    );
61147
     assert!(profile.output.is_file(), "{name}: output file missing");
62148
     assert!(
63149
         profile.total_wall >= profile.phases.accounted_total(),
@@ -67,6 +153,29 @@ fn assert_profile_basics(name: &str, profile: &LinkProfile) {
67153
         profile.phases.accounted_total() > Duration::ZERO,
68154
         "{name}: all phase timings were zero"
69155
     );
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
+    );
70179
     assert!(
71180
         profile.phases.synth_sections
72181
             >= profile.phases.synth_linkedit_finalize + profile.phases.synth_unwind,
@@ -87,10 +196,17 @@ fn assert_profile_basics(name: &str, profile: &LinkProfile) {
87196
                 + profile.phases.synth_linkedit_symbol_plan_strtab,
88197
         "{name}: symbol-plan subphases exceeded symbol-plan total"
89198
     );
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
+    );
90206
 }
91207
 
92208
 #[test]
93
-fn hello_world_profile_reports_baseline_timings() {
209
+fn bench_hello_world_profile_reports_baseline_timings() {
94210
     if !have_xcrun() || !have_xcrun_tool("ld") {
95211
         eprintln!("skipping: xcrun as/ld unavailable");
96212
         return;
@@ -125,14 +241,17 @@ fn hello_world_profile_reports_baseline_timings() {
125241
 }
126242
 
127243
 #[test]
128
-fn runtime_link_profile_reports_baseline_timings() {
244
+fn bench_runtime_link_profile_reports_baseline_timings() {
129245
     if !have_xcrun() || !have_xcrun_tool("ld") {
130246
         eprintln!("skipping: xcrun as/ld unavailable");
131247
         return;
132248
     }
133
-    let Some(runtime) = find_runtime_archive() else {
134
-        eprintln!("skipping: libarmfortas_rt.a not built");
135
-        return;
249
+    let runtime = match runtime_archive_fixture() {
250
+        Ok(runtime) => runtime,
251
+        Err(reason) => {
252
+            eprintln!("skipping: {reason}");
253
+            return;
254
+        }
136255
     };
137256
 
138257
     let obj = scratch("perf-runtime.o");
tests/resolve_integration.rsmodified
@@ -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.txtmodified
@@ -39,6 +39,7 @@ Options:
3939
                                   Select chained fixups vs classic dyld info
4040
   -all_load                       Force-load every archive member
4141
   -force_load <archive>           Force-load one archive
42
+  -j <jobs>                       Limit parallel worker jobs (`1` disables parallelism)
4243
   -Wl,<arg,arg,...>               Normalize comma-separated driver flags
4344
   --dump <path>                   Dump a Mach-O file summary
4445
   --dump-archive <path>           Dump an archive summary
tests/tbd_integration.rsmodified
@@ -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
@@ -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
+}