osx.py
14.0 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
#-----------------------------------------------------------------------------
# Copyright (c) 2014-2021, PyInstaller Development Team.
#
# Distributed under the terms of the GNU General Public License (version 2
# or later) with exception for distributing the bootloader.
#
# The full license is in the file COPYING.txt, distributed with this software.
#
# SPDX-License-Identifier: (GPL-2.0-or-later WITH Bootloader-exception)
#-----------------------------------------------------------------------------
"""
Utils for Mac OS X platform.
"""
import os
import math
import shutil
from PyInstaller.compat import base_prefix, exec_command_all
from macholib.MachO import MachO
from macholib.mach_o import LC_BUILD_VERSION, LC_VERSION_MIN_MACOSX, \
LC_SEGMENT_64, LC_SYMTAB, LC_CODE_SIGNATURE
import PyInstaller.log as logging
logger = logging.getLogger(__name__)
def is_homebrew_env():
"""
Check if Python interpreter was installed via Homebrew command 'brew'.
:return: True if Homebrew else otherwise.
"""
# Python path prefix should start with Homebrew prefix.
env_prefix = get_homebrew_prefix()
if env_prefix and base_prefix.startswith(env_prefix):
return True
return False
def is_macports_env():
"""
Check if Python interpreter was installed via Macports command 'port'.
:return: True if Macports else otherwise.
"""
# Python path prefix should start with Macports prefix.
env_prefix = get_macports_prefix()
if env_prefix and base_prefix.startswith(env_prefix):
return True
return False
def get_homebrew_prefix():
"""
:return: Root path of the Homebrew environment.
"""
prefix = shutil.which('brew')
# Conversion: /usr/local/bin/brew -> /usr/local
prefix = os.path.dirname(os.path.dirname(prefix))
return prefix
def get_macports_prefix():
"""
:return: Root path of the Macports environment.
"""
prefix = shutil.which('port')
# Conversion: /usr/local/bin/port -> /usr/local
prefix = os.path.dirname(os.path.dirname(prefix))
return prefix
def _find_version_cmd(header):
"""
Helper that finds the version command in the given MachO header.
"""
# The SDK version is stored in LC_BUILD_VERSION command (used when
# targeting the latest versions of macOS) or in older LC_VERSION_MIN_MACOSX
# command. Check for presence of either.
version_cmd = [cmd for cmd in header.commands
if cmd[0].cmd in {LC_BUILD_VERSION, LC_VERSION_MIN_MACOSX}]
assert len(version_cmd) == 1, \
"Expected exactly one LC_BUILD_VERSION or " \
"LC_VERSION_MIN_MACOSX command!"
return version_cmd[0]
def get_macos_sdk_version(filename):
"""
Obtain the version of macOS SDK against which the given binary
was built.
NOTE: currently, version is retrieved only from the first arch
slice in the binary.
:return: (major, minor, revision) tuple
"""
binary = MachO(filename)
header = binary.headers[0]
# Find version command using helper
version_cmd = _find_version_cmd(header)
return _hex_triplet(version_cmd[1].sdk)
def _hex_triplet(version):
# Parse SDK version number
major = (version & 0xFF0000) >> 16
minor = (version & 0xFF00) >> 8
revision = (version & 0xFF)
return major, minor, revision
def macosx_version_min(filename: str) -> tuple:
"""Get the -macosx-version-min used to compile a macOS binary.
For fat binaries, the minimum version is selected.
"""
versions = []
for header in MachO(filename).headers:
cmd = _find_version_cmd(header)
if cmd[0].cmd == LC_VERSION_MIN_MACOSX:
versions.append(cmd[1].version)
else:
# macOS >= 10.14 uses LC_BUILD_VERSION instead.
versions.append(cmd[1].minos)
return min(map(_hex_triplet, versions))
def set_macos_sdk_version(filename, major, minor, revision):
"""
Overwrite the macOS SDK version declared in the given binary with
the specified version.
NOTE: currently, only version in the first arch slice is modified.
"""
# Validate values
assert major >= 0 and major <= 255, "Invalid major version value!"
assert minor >= 0 and minor <= 255, "Invalid minor version value!"
assert revision >= 0 and revision <= 255, "Invalid revision value!"
# Open binary
binary = MachO(filename)
header = binary.headers[0]
# Find version command using helper
version_cmd = _find_version_cmd(header)
# Write new SDK version number
version_cmd[1].sdk = major << 16 | minor << 8 | revision
# Write changes back.
with open(binary.filename, 'rb+') as fp:
binary.write(fp)
def fix_exe_for_code_signing(filename):
"""
Fixes the Mach-O headers to make code signing possible.
Code signing on OS X does not work out of the box with embedding
.pkg archive into the executable.
The fix is done this way:
- Make the embedded .pkg archive part of the Mach-O 'String Table'.
'String Table' is at end of the OS X exe file so just change the size
of the table to cover the end of the file.
- Fix the size of the __LINKEDIT segment.
Note: the above fix works only if the single-arch thin executable or
the last arch slice in a multi-arch fat executable is not signed,
because LC_CODE_SIGNATURE comes after LC_SYMTAB, and because modification
of headers invalidates the code signature. On modern arm64 macOS, code
signature is mandatory, and therefore compilers create a dummy
signature when executable is built. In such cases, that signature
needs to be removed before this function is called.
Mach-O format specification:
http://developer.apple.com/documentation/Darwin/Reference/ManPages/man5/Mach-O.5.html
"""
# Estimate the file size after data was appended
file_size = os.path.getsize(filename)
# Take the last available header. A single-arch thin binary contains a
# single slice, while a multi-arch fat binary contains multiple, and we
# need to modify the last one, which is adjacent to the appended data.
executable = MachO(filename)
header = executable.headers[-1]
# Sanity check: ensure the executable slice is not signed (otherwise
# signature's section comes last in the __LINKEDIT segment).
sign_sec = [cmd for cmd in header.commands
if cmd[0].cmd == LC_CODE_SIGNATURE]
assert len(sign_sec) == 0, "Executable contains code signature!"
# Find __LINKEDIT segment by name (16-byte zero padded string)
__LINKEDIT_NAME = b'__LINKEDIT\x00\x00\x00\x00\x00\x00'
linkedit_seg = [cmd for cmd in header.commands
if cmd[0].cmd == LC_SEGMENT_64
and cmd[1].segname == __LINKEDIT_NAME]
assert len(linkedit_seg) == 1, "Expected exactly one __LINKEDIT segment!"
linkedit_seg = linkedit_seg[0][1] # Take the segment command entry
# Find SYMTAB section
symtab_sec = [cmd for cmd in header.commands
if cmd[0].cmd == LC_SYMTAB]
assert len(symtab_sec) == 1, "Expected exactly one SYMTAB section!"
symtab_sec = symtab_sec[0][1] # Take the symtab command entry
# Sanity check; the string table is located at the end of the SYMTAB
# section, which in turn is the last section in the __LINKEDIT segment
assert linkedit_seg.fileoff + linkedit_seg.filesize == \
symtab_sec.stroff + symtab_sec.strsize, "Sanity check failed!"
# Compute the old/declared file size (header.offset is zero for
# single-arch thin binaries)
old_file_size = \
header.offset + linkedit_seg.fileoff + linkedit_seg.filesize
delta = file_size - old_file_size
# Expand the string table in SYMTAB section...
symtab_sec.strsize += delta
# .. as well as its parent __LINEDIT segment
linkedit_seg.filesize += delta
# Compute new vmsize by rounding filesize up to full page size
page_size = (0x4000 if _get_arch_string(header.header).startswith('arm64')
else 0x1000)
linkedit_seg.vmsize = \
math.ceil(linkedit_seg.filesize / page_size) * page_size
# NOTE: according to spec, segments need to be aligned to page
# boundaries: 0x4000 (16 kB) for arm64, 0x1000 (4 kB) for other arches.
# But it seems we can get away without rounding and padding the segment
# file size - perhaps because it is the last one?
# Write changes
with open(filename, 'rb+') as fp:
executable.write(fp)
# In fat binaries, we also need to adjust the fat header. macholib as
# of version 1.14 does not support this, so we need to do it ourselves...
if executable.fat:
from macholib.mach_o import FAT_MAGIC, FAT_MAGIC_64
from macholib.mach_o import fat_header, fat_arch, fat_arch64
with open(filename, 'rb+') as fp:
# Taken from MachO.load_fat() implementation. The fat
# header's signature has already been validated when we
# loaded the file for the first time.
fat = fat_header.from_fileobj(fp)
if fat.magic == FAT_MAGIC:
archs = [fat_arch.from_fileobj(fp)
for i in range(fat.nfat_arch)]
elif fat.magic == FAT_MAGIC_64:
archs = [fat_arch64.from_fileobj(fp)
for i in range(fat.nfat_arch)]
# Adjust the size in the fat header for the last slice
arch = archs[-1]
arch.size = file_size - arch.offset
# Now write the fat headers back to the file
fp.seek(0)
fat.to_fileobj(fp)
for arch in archs:
arch.to_fileobj(fp)
def _get_arch_string(header):
"""
Converts cputype and cpusubtype from mach_o.mach_header_64 into
arch string comparible with lipo/codesign. The list of supported
architectures can be found in man(1) arch.
"""
# NOTE: the constants below are taken from macholib.mach_o
cputype = header.cputype
cpusubtype = header.cpusubtype & 0x0FFFFFFF
if cputype == 0x01000000 | 7:
if cpusubtype == 8:
return 'x86_64h' # 64-bit intel (haswell)
else:
return 'x86_64' # 64-bit intel
elif cputype == 0x01000000 | 12:
if cpusubtype == 2:
return 'arm64e'
else:
return 'arm64'
elif cputype == 7:
return 'i386' # 32-bit intel
assert False, 'Unhandled architecture!'
def get_binary_architectures(filename):
"""
Inspects the given binary and returns tuple (is_fat, archs),
where is_fat is boolean indicating fat/thin binary, and arch is
list of architectures with lipo/codesign compatible names.
"""
executable = MachO(filename)
return bool(executable.fat), [_get_arch_string(hdr.header)
for hdr in executable.headers]
def convert_binary_to_thin_arch(filename, thin_arch):
"""
Convert the given fat binary into thin one with the specified
target architecture.
"""
cmd_args = ['lipo', '-thin', thin_arch, filename, '-output', filename]
retcode, stdout, stderr = exec_command_all(*cmd_args)
if retcode != 0:
logger.warning("lipo command (%r) failed with error code %d!\n"
"stdout: %r\n"
"stderr: %r",
cmd_args, retcode, stdout, stderr)
raise SystemError("lipo failure!")
def binary_to_target_arch(filename, target_arch, display_name=None):
"""
Check that the given binary contains required architecture slice(s)
and convert the fat binary into thin one, if necessary.
"""
if not display_name:
display_name = filename # Same as input file
# Check the binary
is_fat, archs = get_binary_architectures(filename)
if is_fat:
if target_arch == 'universal2':
return # Assume fat binary is universal2; nothing to do
else:
assert target_arch in archs, \
f"{display_name} does not contain slice for {target_arch}!"
# Convert to thin arch
logger.debug("Converting fat binary %s (%s) to thin binary (%s)",
filename, display_name, target_arch)
convert_binary_to_thin_arch(filename, target_arch)
else:
assert target_arch != 'universal2', \
f"{display_name} is not a fat binary!"
assert target_arch in archs, \
f"{display_name} is incompatible with target arch " \
f"{target_arch} (has arch: {archs[0]})!"
return # Nothing to do
def remove_signature_from_binary(filename):
"""
Remove the signature from all architecture slices of the given
binary file using the codesign utility.
"""
logger.debug("Removing signature from file %r", filename)
cmd_args = ['codesign', '--remove', '--all-architectures', filename]
retcode, stdout, stderr = exec_command_all(*cmd_args)
if retcode != 0:
logger.warning("codesign command (%r) failed with error code %d!\n"
"stdout: %r\n"
"stderr: %r",
cmd_args, retcode, stdout, stderr)
raise SystemError("codesign failure!")
def sign_binary(filename, identity=None, entitlements_file=None, deep=False):
"""
Sign the binary using codesign utility. If no identity is provided,
ad-hoc signing is performed.
"""
extra_args = []
if not identity:
identity = '-' # ad-hoc signing
else:
extra_args.append('--options=runtime') # hardened runtime
if entitlements_file:
extra_args.append('--entitlements')
extra_args.append(entitlements_file)
if deep:
extra_args.append('--deep')
logger.debug("Signing file %r", filename)
cmd_args = ['codesign', '-s', identity, '--force', '--all-architectures',
'--timestamp', *extra_args, filename]
retcode, stdout, stderr = exec_command_all(*cmd_args)
if retcode != 0:
logger.warning("codesign command (%r) failed with error code %d!\n"
"stdout: %r\n"
"stderr: %r",
cmd_args, retcode, stdout, stderr)
raise SystemError("codesign failure!")