Skip to content

Commit 9a2f402

Browse files
Add Mach-O universal binary support
A universal binary could not be read at all, so an image shipping for both Intel and Apple Silicon was out of reach even though each slice inside it was already readable. Adds reading and writing of the container, exposing one image per architecture. The header and its slice table are big-endian whatever the architectures inside are, which is the one place the format departs from the image's own byte order, so they are read through explicit big-endian primitives rather than by copying a structure. Each slice is read through a view bounded to it, so a malformed slice cannot reach into its neighbours. Slices are written back at the offsets recorded for them and the gaps between them are left zeroed. Every universal binary examined pads with zeros, and slices are page aligned rather than packed, so the gaps hold nothing worth preserving. - src/LibObjectFile/MachO/MachOFatFile.cs: the container, its slice table and the bounded reads - src/LibObjectFile/MachO/MachOFatSlice.cs: one architecture's placement and image
1 parent 22411ff commit 9a2f402

3 files changed

Lines changed: 327 additions & 0 deletions

File tree

‎src/LibObjectFile.Tests/MachO/MachOSimpleTests.cs‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,35 @@ public void ReadsRelocations()
200200
Assert.AreEqual((MachORelocation.ScatteredMask | (2u << 28) | (1u << 24) | 0x123, 0x4567u), scattered.Encode());
201201
}
202202

203+
/// <summary>
204+
/// A universal binary stores its header big-endian whatever the architectures inside are,
205+
/// which is the one place the format departs from the image's own byte order.
206+
/// </summary>
207+
[TestMethod]
208+
public void ReadsAndWritesAUniversalBinary()
209+
{
210+
var original = File.ReadAllBytes(GetFile("helloworld_fat"));
211+
212+
using var input = new MemoryStream(original);
213+
Assert.IsTrue(MachOFatFile.IsFat(input));
214+
Assert.IsFalse(MachOFile.IsMachO(input), "a universal binary is not itself a Mach-O image");
215+
216+
var fat = MachOFatFile.Read(input);
217+
Assert.IsFalse(fat.Is64BitOffsets);
218+
CollectionAssert.AreEqual(new[] { MachOCpuType.X86_64, MachOCpuType.Arm64 }, fat.Slices.Select(s => s.CpuType).ToArray());
219+
220+
foreach (var slice in fat.Slices)
221+
{
222+
Assert.IsNotNull(slice.File);
223+
Assert.AreEqual(slice.CpuType, slice.File.CpuType, "the slice table has to agree with the image it points at");
224+
Assert.AreEqual(0ul, slice.FileOffset % slice.Alignment, "a slice is mapped directly, so it has to be page aligned");
225+
}
226+
227+
var output = new MemoryStream();
228+
fat.Write(output);
229+
ByteArrayAssert.AreEqual(original, output.ToArray(), "Invalid binary diff for helloworld_fat after read -> write");
230+
}
231+
203232
[TestMethod]
204233
public void RejectsANonMachOStream()
205234
{
Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
// Copyright (c) Alexandre Mutel. All rights reserved.
2+
// This file is licensed under the BSD-Clause 2 license.
3+
// See the license.txt file in the project root for more information.
4+
5+
using System;
6+
using System.Buffers.Binary;
7+
using System.Collections.Generic;
8+
using System.Diagnostics.CodeAnalysis;
9+
using System.IO;
10+
using System.Linq;
11+
using LibObjectFile.Diagnostics;
12+
using LibObjectFile.IO;
13+
14+
namespace LibObjectFile.MachO;
15+
16+
/// <summary>
17+
/// A universal binary, holding one <see cref="MachOFile"/> per architecture.
18+
/// </summary>
19+
/// <remarks>
20+
/// The header and its slice table are stored big-endian whatever the architectures inside are,
21+
/// which is the one place the format does not follow the image's own byte order.
22+
/// <para>
23+
/// Slices are aligned to the page size of their architecture so the loader can map one straight
24+
/// out of the containing file, which is why a universal binary is larger than its slices put
25+
/// together.
26+
/// </para>
27+
/// </remarks>
28+
public sealed class MachOFatFile
29+
{
30+
/// <summary>
31+
/// Gets the slices of this universal binary, in the order the header lists them.
32+
/// </summary>
33+
public List<MachOFatSlice> Slices { get; } = [];
34+
35+
/// <summary>
36+
/// Gets or sets whether slice offsets are stored as 64-bit values, which is needed once a
37+
/// slice starts beyond 4GB.
38+
/// </summary>
39+
public bool Is64BitOffsets { get; set; }
40+
41+
/// <summary>
42+
/// Checks whether a stream starts with a universal binary magic, without consuming it.
43+
/// </summary>
44+
/// <param name="stream">The stream to inspect.</param>
45+
/// <returns><c>true</c> if the stream starts with a universal binary magic.</returns>
46+
/// <exception cref="ArgumentNullException"><paramref name="stream"/> is null.</exception>
47+
public static bool IsFat(Stream stream)
48+
{
49+
ArgumentNullException.ThrowIfNull(stream);
50+
51+
var position = stream.Position;
52+
try
53+
{
54+
Span<byte> magic = stackalloc byte[4];
55+
if (stream.Read(magic) != 4) return false;
56+
var value = BinaryPrimitives.ReadUInt32BigEndian(magic);
57+
return value is MachOMagic.FatMagic or MachOMagic.FatMagic64;
58+
}
59+
finally
60+
{
61+
stream.Position = position;
62+
}
63+
}
64+
65+
/// <summary>
66+
/// Reads a universal binary from a stream.
67+
/// </summary>
68+
/// <param name="stream">The stream positioned at the start of the file.</param>
69+
/// <param name="options">Options controlling how each slice is read, or null for the defaults.</param>
70+
/// <returns>The universal binary read.</returns>
71+
/// <exception cref="ArgumentNullException"><paramref name="stream"/> is null.</exception>
72+
/// <exception cref="ObjectFileException">The stream does not contain a readable universal binary.</exception>
73+
public static MachOFatFile Read(Stream stream, MachOReaderOptions? options = null)
74+
{
75+
if (!TryRead(stream, out var file, out var diagnostics, options))
76+
{
77+
throw new ObjectFileException($"Unexpected error while reading the Mach-O universal binary", diagnostics);
78+
}
79+
return file;
80+
}
81+
82+
/// <summary>
83+
/// Tries to read a universal binary from a stream.
84+
/// </summary>
85+
/// <param name="stream">The stream positioned at the start of the file.</param>
86+
/// <param name="file">The universal binary read, if reading succeeded.</param>
87+
/// <param name="diagnostics">The diagnostics collected, if reading failed.</param>
88+
/// <param name="options">Options controlling how each slice is read, or null for the defaults.</param>
89+
/// <returns><c>true</c> if the file was read; otherwise <c>false</c>.</returns>
90+
/// <exception cref="ArgumentNullException"><paramref name="stream"/> is null.</exception>
91+
public static bool TryRead(Stream stream, [NotNullWhen(true)] out MachOFatFile? file, [NotNullWhen(false)] out DiagnosticBag? diagnostics, MachOReaderOptions? options = null)
92+
{
93+
ArgumentNullException.ThrowIfNull(stream);
94+
95+
var bag = new DiagnosticBag();
96+
file = new MachOFatFile();
97+
diagnostics = bag;
98+
99+
var basePosition = stream.Position;
100+
Span<byte> header = stackalloc byte[8];
101+
stream.Position = basePosition;
102+
if (stream.Read(header) != 8)
103+
{
104+
bag.Error(DiagnosticId.MACHO_ERR_InvalidFatHeader, "The stream is too short to hold a universal binary header");
105+
file = null;
106+
return false;
107+
}
108+
109+
var magic = BinaryPrimitives.ReadUInt32BigEndian(header);
110+
if (magic is not (MachOMagic.FatMagic or MachOMagic.FatMagic64))
111+
{
112+
bag.Error(DiagnosticId.MACHO_ERR_InvalidFatHeader, $"Invalid universal binary magic 0x{magic:X8}");
113+
file = null;
114+
return false;
115+
}
116+
117+
file.Is64BitOffsets = magic == MachOMagic.FatMagic64;
118+
var count = BinaryPrimitives.ReadUInt32BigEndian(header.Slice(4));
119+
120+
var entrySize = file.Is64BitOffsets ? 32 : 20;
121+
var entries = new byte[count * (uint)entrySize];
122+
try
123+
{
124+
stream.ReadExactly(entries);
125+
}
126+
catch (EndOfStreamException)
127+
{
128+
bag.Error(DiagnosticId.MACHO_ERR_InvalidFatHeader, $"The universal binary header claims {count} architectures, which do not fit in the file");
129+
file = null;
130+
return false;
131+
}
132+
133+
for (var i = 0; i < count; i++)
134+
{
135+
var entry = entries.AsSpan(i * entrySize);
136+
var slice = new MachOFatSlice
137+
{
138+
CpuType = (MachOCpuType)BinaryPrimitives.ReadUInt32BigEndian(entry),
139+
CpuSubType = BinaryPrimitives.ReadUInt32BigEndian(entry.Slice(4)),
140+
};
141+
142+
if (file.Is64BitOffsets)
143+
{
144+
slice.FileOffset = BinaryPrimitives.ReadUInt64BigEndian(entry.Slice(8));
145+
slice.Size = BinaryPrimitives.ReadUInt64BigEndian(entry.Slice(16));
146+
slice.AlignLog2 = BinaryPrimitives.ReadUInt32BigEndian(entry.Slice(24));
147+
}
148+
else
149+
{
150+
slice.FileOffset = BinaryPrimitives.ReadUInt32BigEndian(entry.Slice(8));
151+
slice.Size = BinaryPrimitives.ReadUInt32BigEndian(entry.Slice(12));
152+
slice.AlignLog2 = BinaryPrimitives.ReadUInt32BigEndian(entry.Slice(16));
153+
}
154+
155+
if (slice.FileOffset + slice.Size > (ulong)stream.Length)
156+
{
157+
bag.Error(DiagnosticId.MACHO_ERR_InvalidFatArchRange, $"Slice {i} spans [0x{slice.FileOffset:X}, 0x{slice.FileOffset + slice.Size:X}) which extends past the end of the file");
158+
file = null;
159+
return false;
160+
}
161+
162+
// Each slice is a complete image, so it is read through a view bounded to the slice.
163+
// Handing it the whole stream would let a malformed slice read its neighbours.
164+
var sliceStream = new SubStream(stream, basePosition + (long)slice.FileOffset, (long)slice.Size);
165+
if (!MachOFile.TryRead(sliceStream, out var sliceFile, out var sliceDiagnostics, options))
166+
{
167+
foreach (var message in sliceDiagnostics.Messages)
168+
{
169+
bag.Log(message);
170+
}
171+
file = null;
172+
return false;
173+
}
174+
175+
slice.File = sliceFile;
176+
file.Slices.Add(slice);
177+
}
178+
179+
diagnostics = null;
180+
return true;
181+
}
182+
183+
/// <summary>
184+
/// Writes this universal binary to a stream.
185+
/// </summary>
186+
/// <param name="stream">The stream to write to.</param>
187+
/// <exception cref="ArgumentNullException"><paramref name="stream"/> is null.</exception>
188+
/// <exception cref="ObjectFileException">A slice could not be written.</exception>
189+
/// <remarks>
190+
/// Slices are written at the offsets already recorded for them, and the space between them
191+
/// is left zeroed. Every universal binary examined pads with zeros, and a slice is aligned
192+
/// rather than packed, so there is nothing meaningful in the gaps to preserve.
193+
/// </remarks>
194+
public void Write(Stream stream)
195+
{
196+
ArgumentNullException.ThrowIfNull(stream);
197+
198+
var basePosition = stream.Position;
199+
var entrySize = Is64BitOffsets ? 32 : 20;
200+
201+
var header = new byte[8 + Slices.Count * entrySize];
202+
BinaryPrimitives.WriteUInt32BigEndian(header, Is64BitOffsets ? MachOMagic.FatMagic64 : MachOMagic.FatMagic);
203+
BinaryPrimitives.WriteUInt32BigEndian(header.AsSpan(4), (uint)Slices.Count);
204+
205+
for (var i = 0; i < Slices.Count; i++)
206+
{
207+
var slice = Slices[i];
208+
var entry = header.AsSpan(8 + i * entrySize);
209+
BinaryPrimitives.WriteUInt32BigEndian(entry, (uint)slice.CpuType);
210+
BinaryPrimitives.WriteUInt32BigEndian(entry.Slice(4), slice.CpuSubType);
211+
212+
if (Is64BitOffsets)
213+
{
214+
BinaryPrimitives.WriteUInt64BigEndian(entry.Slice(8), slice.FileOffset);
215+
BinaryPrimitives.WriteUInt64BigEndian(entry.Slice(16), slice.Size);
216+
BinaryPrimitives.WriteUInt32BigEndian(entry.Slice(24), slice.AlignLog2);
217+
}
218+
else
219+
{
220+
BinaryPrimitives.WriteUInt32BigEndian(entry.Slice(8), (uint)slice.FileOffset);
221+
BinaryPrimitives.WriteUInt32BigEndian(entry.Slice(12), (uint)slice.Size);
222+
BinaryPrimitives.WriteUInt32BigEndian(entry.Slice(16), slice.AlignLog2);
223+
}
224+
}
225+
226+
stream.Write(header);
227+
228+
foreach (var slice in Slices)
229+
{
230+
if (slice.File is null) continue;
231+
stream.Position = basePosition + (long)slice.FileOffset;
232+
slice.File.Write(stream);
233+
}
234+
235+
var end = Slices.Count == 0 ? 0 : Slices.Max(s => (long)(s.FileOffset + s.Size));
236+
if (stream.Length < basePosition + end)
237+
{
238+
stream.SetLength(basePosition + end);
239+
}
240+
}
241+
242+
/// <inheritdoc />
243+
public override string ToString() => $"{nameof(MachOFatFile)} {{ Slices = {Slices.Count} }}";
244+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
// Copyright (c) Alexandre Mutel. All rights reserved.
2+
// This file is licensed under the BSD-Clause 2 license.
3+
// See the license.txt file in the project root for more information.
4+
5+
using System;
6+
7+
namespace LibObjectFile.MachO;
8+
9+
/// <summary>
10+
/// One architecture's image inside a <see cref="MachOFatFile"/>.
11+
/// </summary>
12+
public sealed class MachOFatSlice
13+
{
14+
/// <summary>
15+
/// Gets or sets the CPU architecture of this slice. It repeats what the slice's own header
16+
/// says, so that a loader can pick a slice without parsing it.
17+
/// </summary>
18+
public MachOCpuType CpuType { get; set; }
19+
20+
/// <summary>
21+
/// Gets or sets the CPU subtype of this slice.
22+
/// </summary>
23+
public uint CpuSubType { get; set; }
24+
25+
/// <summary>
26+
/// Gets or sets the alignment of this slice as a power of two. It is the page size of the
27+
/// architecture, because the slice is mapped directly out of the containing file.
28+
/// </summary>
29+
public uint AlignLog2 { get; set; }
30+
31+
/// <summary>
32+
/// Gets or sets the offset of this slice in the containing file.
33+
/// </summary>
34+
public ulong FileOffset { get; set; }
35+
36+
/// <summary>
37+
/// Gets or sets the size of this slice in the containing file.
38+
/// </summary>
39+
public ulong Size { get; set; }
40+
41+
/// <summary>
42+
/// Gets or sets the image this slice holds.
43+
/// </summary>
44+
public MachOFile? File { get; set; }
45+
46+
/// <summary>
47+
/// Gets the alignment of this slice in bytes.
48+
/// </summary>
49+
public ulong Alignment => 1ul << (int)AlignLog2;
50+
51+
/// <inheritdoc />
52+
public override string ToString()
53+
=> $"{nameof(MachOFatSlice)} {{ {CpuType}, FileOffset = 0x{FileOffset:X}, Size = 0x{Size:X}, Align = 2^{AlignLog2} }}";
54+
}

0 commit comments

Comments
 (0)