123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383 |
- #-----------------------------------------------------------------------------
- # 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!")
|