Skip to content

Commit eae15c1

Browse files
authored
feat(self-packaging #66): fat / universal macOS binary support (#70)
Before this change, `LocateFlapiSectionInBuffer` returned `nullopt` for any input whose magic wasn't `MH_MAGIC_64`. Universal binaries (`FAT_MAGIC` / `FAT_MAGIC_64`) -- the format produced by `lipo -create` and consumed by Homebrew-style formulas that ship both arches -- therefore couldn't be packed or self-inspected. A universal binary is a small big-endian fat header followed by N thin Mach-O slices at distinct file offsets. The `section_64.offset` read inside a slice is relative to the slice's base, not the fat file -- both `OverwriteFlapiSection` (write side) and `LocateBundleInRange` (read side) treat the returned offset as an absolute file offset, so a naive accept-fat patch would have written to the wrong byte address. This PR: - Adds `ReadU32BE` / `ReadU64BE` -- fat headers are big-endian on disk regardless of host endianness. - Adds `ParseFatHeader` (namespace-private). Walks `fat_arch[]` (20-byte records for `FAT_MAGIC/CIGAM`, 32-byte for the `_64` variants). Selection rule: first slice whose cputype matches the host arch (compile-time, via `__aarch64__` / `__x86_64__`), else the first slice. Rejects malformed `nfat_arch` (0 or > 64) and slice extents past EOF. - Splits `LocateFlapiSectionInBuffer` into an outer dispatch + an inner `LocateFlapiSectionAt(buffer, base)` overload. On fat input the outer call parses the fat header, picks a slice, and recurses via the inner overload with the slice's absolute offset as base. The inner overload adds base to whatever the load-cmd walker returns, so callers see an absolute file offset. - `IsMachOMagic` extended to recognise `FAT_MAGIC_64` / `FAT_CIGAM_64` in addition to the 32-bit fat variants it already accepted. - `OverwriteFlapiSection` is unchanged -- the new absolute-offset invariant makes its existing `seekp(file_offset)` land in the correct slice automatically. - Header doc updated to drop the "fat / universal not supported" caveat and document the slice-selection rule. Test fixture: new `BuildFatMachO(slices)` helper wraps any number of `BuildMachO64` outputs with a big-endian fat header + 4 KiB- aligned slice placement. Four new test cases (issue #66 acceptance): - single-slice fat: section located at the absolute offset (slice_offset + intra-slice section offset). - two-slice fat (arm64 + x86_64): parser picks the host-matching slice; deterministic per-host expected offset via `#ifdef`. - two PPC slices (host arch doesn't match either): parser falls back to the first slice and stays deterministic across exotic hosts. - OverwriteFlapiSection round-trip inside a slice: write a 7-byte payload through the located section, verify the bytes land at the returned absolute offset, and confirm a re-locate finds the same section. Test plan: - ctest: 642 / 642 pass (637 previous + 5 new -- 4 fat cases + the round-trip). - pytest test_self_packaging.py + test_self_packaging_http.py: 11 / 11 pass (no regression on the Linux EOCD tail-scan path, which is unaffected by this change). Closes #66.
1 parent 1779bc9 commit eae15c1

3 files changed

Lines changed: 397 additions & 18 deletions

File tree

src/include/macho_bundle.hpp

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,19 @@ bool IsMachOMagic(const std::uint8_t magic_bytes[4]);
2929

3030
// Locate the __FLAPI/__bundle section in a Mach-O file on disk.
3131
// Returns nullopt if:
32-
// - the file isn't a thin (non-fat) Mach-O,
32+
// - the file isn't a 64-bit Mach-O (thin or fat / universal),
3333
// - the file is malformed,
3434
// - the section doesn't exist (e.g., on a non-macOS build).
3535
//
36-
// Fat / universal binaries are currently not supported -- a follow-up
37-
// can iterate slices. macOS releases produced by this repo are thin
38-
// per-architecture, so the gap is acceptable for now.
36+
// Fat / universal binaries (FAT_MAGIC, FAT_MAGIC_64) are supported:
37+
// the parser walks fat_arch[] and picks the slice whose cputype
38+
// matches the host arch (compile-time), or the first slice as a
39+
// deterministic fallback. The returned `file_offset` is absolute
40+
// within the fat file so OverwriteFlapiSection seeks to the right
41+
// place inside the chosen slice.
42+
//
43+
// 32-bit Mach-O (MH_MAGIC / MH_CIGAM) is intentionally rejected --
44+
// no flapi release ships a 32-bit slice today.
3945
std::optional<MachOSection> LocateFlapiSection(const std::filesystem::path& path);
4046

4147
// Overload that scans a buffer instead of opening a file. Used by

src/macho_bundle.cpp

Lines changed: 168 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,31 @@ constexpr std::uint32_t kMachOMagic64 = 0xFEEDFACFu;
3030
constexpr std::uint32_t kMachOCigam64 = 0xCFFAEDFEu;
3131
constexpr std::uint32_t kFatMagic = 0xCAFEBABEu;
3232
constexpr std::uint32_t kFatCigam = 0xBEBAFECAu;
33+
constexpr std::uint32_t kFatMagic64 = 0xCAFEBABFu;
34+
constexpr std::uint32_t kFatCigam64 = 0xBFBAFECAu;
3335

3436
constexpr std::uint32_t kLcSegment = 0x01;
3537
constexpr std::uint32_t kLcSegment64 = 0x19;
3638

39+
// Apple CPU type constants (from <mach/machine.h>). Only the ones we
40+
// care about for slice selection on hosts we actually run on. Marked
41+
// maybe_unused because the host-arch #ifdef below only picks one.
42+
[[maybe_unused]] constexpr std::uint32_t kCpuTypeX86_64 = 0x01000007u;
43+
[[maybe_unused]] constexpr std::uint32_t kCpuTypeArm64 = 0x0100000Cu;
44+
45+
// What slice to prefer when the input is a fat / universal binary.
46+
// Resolved at compile time from the host arch so the recursive
47+
// LocateFlapiSection call picks the slice that matches the binary
48+
// doing the lookup -- which, in the self-packaging case, is the
49+
// binary being looked at. 0 means "no preference; first slice".
50+
#if defined(__aarch64__) || defined(__arm64__)
51+
constexpr std::uint32_t kPreferredCpuType = kCpuTypeArm64;
52+
#elif defined(__x86_64__) || defined(_M_X64)
53+
constexpr std::uint32_t kPreferredCpuType = kCpuTypeX86_64;
54+
#else
55+
constexpr std::uint32_t kPreferredCpuType = 0u;
56+
#endif
57+
3758
std::uint32_t ReadU32LE(const std::uint8_t* p) {
3859
return static_cast<std::uint32_t>(p[0])
3960
| (static_cast<std::uint32_t>(p[1]) << 8)
@@ -46,6 +67,20 @@ std::uint64_t ReadU64LE(const std::uint8_t* p) {
4667
| (static_cast<std::uint64_t>(ReadU32LE(p + 4)) << 32);
4768
}
4869

70+
// Fat header + fat_arch records are stored big-endian on disk
71+
// regardless of host endianness (Apple's universal-binary spec).
72+
std::uint32_t ReadU32BE(const std::uint8_t* p) {
73+
return (static_cast<std::uint32_t>(p[0]) << 24)
74+
| (static_cast<std::uint32_t>(p[1]) << 16)
75+
| (static_cast<std::uint32_t>(p[2]) << 8)
76+
| static_cast<std::uint32_t>(p[3]);
77+
}
78+
79+
std::uint64_t ReadU64BE(const std::uint8_t* p) {
80+
return (static_cast<std::uint64_t>(ReadU32BE(p)) << 32)
81+
| static_cast<std::uint64_t>(ReadU32BE(p + 4));
82+
}
83+
4984
bool NameEquals(const std::uint8_t* fixed, std::size_t cap, const char* expected) {
5085
// Mach-O segment/section names are NUL-padded fixed-length fields.
5186
// We compare up to cap bytes, treating NUL as terminator on the
@@ -177,13 +212,132 @@ std::optional<MachOSection> FindInLoadCommands64(
177212
return std::nullopt;
178213
}
179214

215+
// Parses a fat / universal Mach-O header at buffer[0] and returns the
216+
// absolute file offset of the slice we want to recurse into. The
217+
// caller is responsible for the magic dispatch and for adding the
218+
// returned offset to any per-slice section offsets it computes.
219+
//
220+
// Selection rule: first slice whose cputype matches kPreferredCpuType;
221+
// else the first slice. The fallback gives deterministic behaviour on
222+
// hosts whose arch isn't represented in the file (e.g., a PPC-only
223+
// fat binary inspected on x86_64, or a test fixture built on a host
224+
// arch we don't compile-time match).
225+
//
226+
// Returns nullopt on truncation, an absurd nfat_arch (we cap at 64
227+
// slices -- real universal binaries top out at 4-5), or a slice whose
228+
// declared extent exceeds the buffer.
229+
struct FatSlice {
230+
std::uint64_t file_offset = 0;
231+
std::uint64_t size = 0;
232+
};
233+
234+
std::optional<FatSlice> ParseFatHeader(
235+
const std::vector<std::uint8_t>& buffer,
236+
std::uint32_t magic) {
237+
constexpr std::size_t kFatHeaderSize = 8;
238+
if (buffer.size() < kFatHeaderSize) {
239+
return std::nullopt;
240+
}
241+
const bool is_64 = (magic == kFatMagic64 || magic == kFatCigam64);
242+
const std::size_t arch_size = is_64 ? 32u : 20u;
243+
const std::uint32_t nfat_arch = ReadU32BE(buffer.data() + 4);
244+
constexpr std::uint32_t kMaxSlices = 64u;
245+
if (nfat_arch == 0 || nfat_arch > kMaxSlices) {
246+
return std::nullopt;
247+
}
248+
if (buffer.size() < kFatHeaderSize + nfat_arch * arch_size) {
249+
return std::nullopt;
250+
}
251+
252+
auto read_slice = [&](std::uint32_t i) -> FatSlice {
253+
const std::size_t off = kFatHeaderSize + i * arch_size;
254+
FatSlice s;
255+
// Layout (fat_arch): cputype, cpusubtype, offset, size, align
256+
// Layout (fat_arch_64): cputype, cpusubtype, offset(64), size(64),
257+
// align, reserved
258+
if (is_64) {
259+
s.file_offset = ReadU64BE(buffer.data() + off + 8);
260+
s.size = ReadU64BE(buffer.data() + off + 16);
261+
} else {
262+
s.file_offset = ReadU32BE(buffer.data() + off + 8);
263+
s.size = ReadU32BE(buffer.data() + off + 12);
264+
}
265+
return s;
266+
};
267+
auto read_cputype = [&](std::uint32_t i) -> std::uint32_t {
268+
const std::size_t off = kFatHeaderSize + i * arch_size;
269+
return ReadU32BE(buffer.data() + off);
270+
};
271+
272+
// Pass 1: preferred cputype.
273+
if (kPreferredCpuType != 0) {
274+
for (std::uint32_t i = 0; i < nfat_arch; ++i) {
275+
if (read_cputype(i) == kPreferredCpuType) {
276+
FatSlice s = read_slice(i);
277+
if (s.file_offset > buffer.size() ||
278+
s.size > buffer.size() ||
279+
s.file_offset + s.size > buffer.size()) {
280+
return std::nullopt;
281+
}
282+
return s;
283+
}
284+
}
285+
}
286+
// Pass 2: first slice as fallback.
287+
FatSlice s = read_slice(0);
288+
if (s.file_offset > buffer.size() ||
289+
s.size > buffer.size() ||
290+
s.file_offset + s.size > buffer.size()) {
291+
return std::nullopt;
292+
}
293+
return s;
294+
}
295+
296+
// Inner overload: parse a Mach-O whose first byte lives at
297+
// buffer[base] and produce a section file_offset that is absolute
298+
// within the original (possibly fat-wrapping) buffer.
299+
std::optional<MachOSection> LocateFlapiSectionAt(
300+
const std::vector<std::uint8_t>& buffer,
301+
std::uint64_t base) {
302+
if (base + 32 > buffer.size()) {
303+
return std::nullopt;
304+
}
305+
const std::uint32_t magic = ReadU32LE(buffer.data() + base);
306+
307+
// 32-bit Mach-O: we don't ship 32-bit artifacts; cigam (byte-swapped)
308+
// is also out of scope for this parser. A future PR can extend if a
309+
// legit use-case appears.
310+
if (magic != kMachOMagic64) {
311+
return std::nullopt;
312+
}
313+
314+
// mach_header_64 layout:
315+
// uint32 magic, cputype, cpusubtype, filetype,
316+
// uint32 ncmds, sizeofcmds, flags, reserved
317+
const std::uint32_t ncmds = ReadU32LE(buffer.data() + base + 16);
318+
const std::uint32_t sizeofcmds = ReadU32LE(buffer.data() + base + 20);
319+
320+
auto inner = FindInLoadCommands64(buffer, base, ncmds, sizeofcmds);
321+
if (!inner.has_value()) {
322+
return std::nullopt;
323+
}
324+
// The section's offset field is recorded relative to its slice's
325+
// base in the on-disk Mach-O, so callers further out need the
326+
// absolute file offset. base is the slice's absolute offset in the
327+
// outer (potentially fat) file; adding it gives the absolute byte
328+
// position seek() should land on.
329+
inner->file_offset += base;
330+
return inner;
331+
}
332+
180333
} // namespace
181334

182335
bool IsMachOMagic(const std::uint8_t magic_bytes[4]) {
183336
const std::uint32_t m = ReadU32LE(magic_bytes);
184337
return m == kMachOMagic32 || m == kMachOCigam32 ||
185338
m == kMachOMagic64 || m == kMachOCigam64 ||
186-
m == kFatMagic || m == kFatCigam;
339+
m == kFatMagic || m == kFatCigam ||
340+
m == kFatMagic64 || m == kFatCigam64;
187341
}
188342

189343
std::optional<MachOSection> LocateFlapiSectionInBuffer(
@@ -193,21 +347,21 @@ std::optional<MachOSection> LocateFlapiSectionInBuffer(
193347
}
194348
const std::uint32_t magic = ReadU32LE(buffer.data());
195349

196-
// We only handle 64-bit little-endian Mach-O here. Production
197-
// arm64/x86_64 builds emit this format. Cigam (byte-swapped),
198-
// 32-bit, and fat (universal) are out of scope for the spike --
199-
// documented in the header.
200-
if (magic != kMachOMagic64) {
201-
return std::nullopt;
350+
// Fat / universal binary: pick the slice that matches the host
351+
// arch (or the first slice as a deterministic fallback) and recurse
352+
// into the inner thin Mach-O. The returned file_offset is absolute
353+
// within the fat file, which is what OverwriteFlapiSection and
354+
// LocateBundleInRange both expect.
355+
if (magic == kFatMagic || magic == kFatCigam ||
356+
magic == kFatMagic64 || magic == kFatCigam64) {
357+
auto slice = ParseFatHeader(buffer, magic);
358+
if (!slice.has_value()) {
359+
return std::nullopt;
360+
}
361+
return LocateFlapiSectionAt(buffer, slice->file_offset);
202362
}
203363

204-
// mach_header_64:
205-
// uint32 magic, cputype, cpusubtype, filetype,
206-
// uint32 ncmds, sizeofcmds, flags, reserved
207-
const std::uint32_t ncmds = ReadU32LE(buffer.data() + 16);
208-
const std::uint32_t sizeofcmds = ReadU32LE(buffer.data() + 20);
209-
210-
return FindInLoadCommands64(buffer, /*base=*/0, ncmds, sizeofcmds);
364+
return LocateFlapiSectionAt(buffer, /*base=*/0);
211365
}
212366

213367
std::optional<MachOSection> LocateFlapiSection(const std::filesystem::path& path) {

0 commit comments

Comments
 (0)