osx.py 14 KB

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