| # Copyright 2016 The Chromium Authors. All rights reserved. |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| |
| from __future__ import print_function |
| |
| import argparse |
| import codecs |
| import datetime |
| import fnmatch |
| import glob |
| import json |
| import os |
| import plistlib |
| import shutil |
| import subprocess |
| import sys |
| import tempfile |
| |
| if sys.version_info.major < 3: |
| basestring_compat = basestring |
| else: |
| basestring_compat = str |
| |
| |
| def GetProvisioningProfilesDir(): |
| """Returns the location of the installed mobile provisioning profiles. |
| |
| Returns: |
| The path to the directory containing the installed mobile provisioning |
| profiles as a string. |
| """ |
| return os.path.join( |
| os.environ['HOME'], 'Library', 'MobileDevice', 'Provisioning Profiles') |
| |
| |
| def ReadPlistFromString(plist_bytes): |
| """Parse property list from given |plist_bytes|. |
| |
| Args: |
| plist_bytes: contents of property list to load. Must be bytes in python 3. |
| |
| Returns: |
| The contents of property list as a python object. |
| """ |
| if sys.version_info.major == 2: |
| return plistlib.readPlistFromString(plist_bytes) |
| else: |
| return plistlib.loads(plist_bytes) |
| |
| |
| def LoadPlistFile(plist_path): |
| """Loads property list file at |plist_path|. |
| |
| Args: |
| plist_path: path to the property list file to load. |
| |
| Returns: |
| The content of the property list file as a python object. |
| """ |
| if sys.version_info.major == 2: |
| return plistlib.readPlistFromString( |
| subprocess.check_output( |
| ['xcrun', 'plutil', '-convert', 'xml1', '-o', '-', plist_path])) |
| else: |
| with open(plist_path, 'rb') as fp: |
| return plistlib.load(fp) |
| |
| |
| def CreateSymlink(value, location): |
| """Creates symlink with value at location if the target exists.""" |
| target = os.path.join(os.path.dirname(location), value) |
| if os.path.exists(location): |
| os.unlink(location) |
| os.symlink(value, location) |
| |
| |
| class Bundle(object): |
| """Wraps a bundle.""" |
| |
| def __init__(self, bundle_path, platform): |
| """Initializes the Bundle object with data from bundle Info.plist file.""" |
| self._path = bundle_path |
| self._kind = Bundle.Kind(platform, os.path.splitext(bundle_path)[-1]) |
| self._data = None |
| |
| def Load(self): |
| self._data = LoadPlistFile(self.info_plist_path) |
| |
| @staticmethod |
| def Kind(platform, extension): |
| if platform == 'iphonesimulator' or platform == 'iphoneos': |
| return 'ios' |
| if platform == 'macosx': |
| if extension == '.framework': |
| return 'mac_framework' |
| return 'mac' |
| raise ValueError('unknown bundle type %s for %s' % (extension, platform)) |
| |
| @property |
| def kind(self): |
| return self._kind |
| |
| @property |
| def path(self): |
| return self._path |
| |
| @property |
| def contents_dir(self): |
| if self._kind == 'mac': |
| return os.path.join(self.path, 'Contents') |
| if self._kind == 'mac_framework': |
| return os.path.join(self.path, 'Versions/A') |
| return self.path |
| |
| @property |
| def executable_dir(self): |
| if self._kind == 'mac': |
| return os.path.join(self.contents_dir, 'MacOS') |
| return self.contents_dir |
| |
| @property |
| def resources_dir(self): |
| if self._kind == 'mac' or self._kind == 'mac_framework': |
| return os.path.join(self.contents_dir, 'Resources') |
| return self.path |
| |
| @property |
| def info_plist_path(self): |
| if self._kind == 'mac_framework': |
| return os.path.join(self.resources_dir, 'Info.plist') |
| return os.path.join(self.contents_dir, 'Info.plist') |
| |
| @property |
| def signature_dir(self): |
| return os.path.join(self.contents_dir, '_CodeSignature') |
| |
| @property |
| def identifier(self): |
| return self._data['CFBundleIdentifier'] |
| |
| @property |
| def binary_name(self): |
| return self._data['CFBundleExecutable'] |
| |
| @property |
| def binary_path(self): |
| return os.path.join(self.executable_dir, self.binary_name) |
| |
| def Validate(self, expected_mappings): |
| """Checks that keys in the bundle have the expected value. |
| |
| Args: |
| expected_mappings: a dictionary of string to object, each mapping will |
| be looked up in the bundle data to check it has the same value (missing |
| values will be ignored) |
| |
| Returns: |
| A dictionary of the key with a different value between expected_mappings |
| and the content of the bundle (i.e. errors) so that caller can format the |
| error message. The dictionary will be empty if there are no errors. |
| """ |
| errors = {} |
| for key, expected_value in expected_mappings.items(): |
| if key in self._data: |
| value = self._data[key] |
| if value != expected_value: |
| errors[key] = (value, expected_value) |
| return errors |
| |
| |
| class ProvisioningProfile(object): |
| """Wraps a mobile provisioning profile file.""" |
| |
| def __init__(self, provisioning_profile_path): |
| """Initializes the ProvisioningProfile with data from profile file.""" |
| self._path = provisioning_profile_path |
| self._data = ReadPlistFromString( |
| subprocess.check_output([ |
| 'xcrun', 'security', 'cms', '-D', '-u', 'certUsageAnyCA', '-i', |
| provisioning_profile_path |
| ])) |
| |
| @property |
| def path(self): |
| return self._path |
| |
| @property |
| def team_identifier(self): |
| return self._data.get('TeamIdentifier', [''])[0] |
| |
| @property |
| def name(self): |
| return self._data.get('Name', '') |
| |
| @property |
| def application_identifier_pattern(self): |
| return self._data.get('Entitlements', {}).get('application-identifier', '') |
| |
| @property |
| def application_identifier_prefix(self): |
| return self._data.get('ApplicationIdentifierPrefix', [''])[0] |
| |
| @property |
| def entitlements(self): |
| return self._data.get('Entitlements', {}) |
| |
| @property |
| def expiration_date(self): |
| return self._data.get('ExpirationDate', datetime.datetime.now()) |
| |
| def ValidToSignBundle(self, bundle_identifier): |
| """Checks whether the provisioning profile can sign bundle_identifier. |
| |
| Args: |
| bundle_identifier: the identifier of the bundle that needs to be signed. |
| |
| Returns: |
| True if the mobile provisioning profile can be used to sign a bundle |
| with the corresponding bundle_identifier, False otherwise. |
| """ |
| return fnmatch.fnmatch( |
| '%s.%s' % (self.application_identifier_prefix, bundle_identifier), |
| self.application_identifier_pattern) |
| |
| def Install(self, installation_path): |
| """Copies mobile provisioning profile info to |installation_path|.""" |
| shutil.copy2(self.path, installation_path) |
| |
| |
| class Entitlements(object): |
| """Wraps an Entitlement plist file.""" |
| |
| def __init__(self, entitlements_path): |
| """Initializes Entitlements object from entitlement file.""" |
| self._path = entitlements_path |
| self._data = LoadPlistFile(self._path) |
| |
| @property |
| def path(self): |
| return self._path |
| |
| def ExpandVariables(self, substitutions): |
| self._data = self._ExpandVariables(self._data, substitutions) |
| |
| def _ExpandVariables(self, data, substitutions): |
| if isinstance(data, basestring_compat): |
| for key, substitution in substitutions.items(): |
| data = data.replace('$(%s)' % (key,), substitution) |
| return data |
| |
| if isinstance(data, dict): |
| for key, value in data.items(): |
| data[key] = self._ExpandVariables(value, substitutions) |
| return data |
| |
| if isinstance(data, list): |
| for i, value in enumerate(data): |
| data[i] = self._ExpandVariables(value, substitutions) |
| |
| return data |
| |
| def LoadDefaults(self, defaults): |
| for key, value in defaults.items(): |
| if key not in self._data: |
| self._data[key] = value |
| |
| def WriteTo(self, target_path): |
| with open(target_path, 'wb') as fp: |
| if sys.version_info.major == 2: |
| plistlib.writePlist(self._data, fp) |
| else: |
| plistlib.dump(self._data, fp) |
| |
| |
| def FindProvisioningProfile(bundle_identifier, required): |
| """Finds mobile provisioning profile to use to sign bundle. |
| |
| Args: |
| bundle_identifier: the identifier of the bundle to sign. |
| |
| Returns: |
| The ProvisioningProfile object that can be used to sign the Bundle |
| object or None if no matching provisioning profile was found. |
| """ |
| provisioning_profile_paths = glob.glob( |
| os.path.join(GetProvisioningProfilesDir(), '*.mobileprovision')) |
| |
| # Iterate over all installed mobile provisioning profiles and filter those |
| # that can be used to sign the bundle, ignoring expired ones. |
| now = datetime.datetime.now() |
| valid_provisioning_profiles = [] |
| one_hour = datetime.timedelta(0, 3600) |
| for provisioning_profile_path in provisioning_profile_paths: |
| provisioning_profile = ProvisioningProfile(provisioning_profile_path) |
| if provisioning_profile.expiration_date - now < one_hour: |
| sys.stderr.write( |
| 'Warning: ignoring expired provisioning profile: %s.\n' % |
| provisioning_profile_path) |
| continue |
| if provisioning_profile.ValidToSignBundle(bundle_identifier): |
| valid_provisioning_profiles.append(provisioning_profile) |
| |
| if not valid_provisioning_profiles: |
| if required: |
| sys.stderr.write( |
| 'Error: no mobile provisioning profile found for "%s".\n' % |
| bundle_identifier) |
| sys.exit(1) |
| return None |
| |
| # Select the most specific mobile provisioning profile, i.e. the one with |
| # the longest application identifier pattern (prefer the one with the latest |
| # expiration date as a secondary criteria). |
| selected_provisioning_profile = max( |
| valid_provisioning_profiles, |
| key=lambda p: (len(p.application_identifier_pattern), p.expiration_date)) |
| |
| one_week = datetime.timedelta(7) |
| if selected_provisioning_profile.expiration_date - now < 2 * one_week: |
| sys.stderr.write( |
| 'Warning: selected provisioning profile will expire soon: %s' % |
| selected_provisioning_profile.path) |
| return selected_provisioning_profile |
| |
| |
| def CodeSignBundle(bundle_path, identity, extra_args): |
| process = subprocess.Popen( |
| ['xcrun', 'codesign', '--force', '--sign', identity, '--timestamp=none'] + |
| list(extra_args) + [bundle_path], |
| stderr=subprocess.PIPE, |
| universal_newlines=True) |
| _, stderr = process.communicate() |
| if process.returncode: |
| sys.stderr.write(stderr) |
| sys.exit(process.returncode) |
| for line in stderr.splitlines(): |
| if line.endswith(': replacing existing signature'): |
| # Ignore warning about replacing existing signature as this should only |
| # happen when re-signing system frameworks (and then it is expected). |
| continue |
| sys.stderr.write(line) |
| sys.stderr.write('\n') |
| |
| |
| def InstallSystemFramework(framework_path, bundle_path, args): |
| """Install framework from |framework_path| to |bundle| and code-re-sign it.""" |
| installed_framework_path = os.path.join( |
| bundle_path, 'Frameworks', os.path.basename(framework_path)) |
| |
| if os.path.isfile(framework_path): |
| shutil.copy(framework_path, installed_framework_path) |
| elif os.path.isdir(framework_path): |
| if os.path.exists(installed_framework_path): |
| shutil.rmtree(installed_framework_path) |
| shutil.copytree(framework_path, installed_framework_path) |
| |
| CodeSignBundle(installed_framework_path, args.identity, |
| ['--deep', '--preserve-metadata=identifier,entitlements,flags']) |
| |
| |
| def GenerateEntitlements(path, provisioning_profile, bundle_identifier): |
| """Generates an entitlements file. |
| |
| Args: |
| path: path to the entitlements template file |
| provisioning_profile: ProvisioningProfile object to use, may be None |
| bundle_identifier: identifier of the bundle to sign. |
| """ |
| entitlements = Entitlements(path) |
| if provisioning_profile: |
| entitlements.LoadDefaults(provisioning_profile.entitlements) |
| app_identifier_prefix = \ |
| provisioning_profile.application_identifier_prefix + '.' |
| else: |
| app_identifier_prefix = '*.' |
| entitlements.ExpandVariables({ |
| 'CFBundleIdentifier': bundle_identifier, |
| 'AppIdentifierPrefix': app_identifier_prefix, |
| }) |
| return entitlements |
| |
| |
| def GenerateBundleInfoPlist(bundle, plist_compiler, partial_plist): |
| """Generates the bundle Info.plist for a list of partial .plist files. |
| |
| Args: |
| bundle: a Bundle instance |
| plist_compiler: string, path to the Info.plist compiler |
| partial_plist: list of path to partial .plist files to merge |
| """ |
| |
| # Filter empty partial .plist files (this happens if an application |
| # does not compile any asset catalog, in which case the partial .plist |
| # file from the asset catalog compilation step is just a stamp file). |
| filtered_partial_plist = [] |
| for plist in partial_plist: |
| plist_size = os.stat(plist).st_size |
| if plist_size: |
| filtered_partial_plist.append(plist) |
| |
| # Invoke the plist_compiler script. It needs to be a python script. |
| subprocess.check_call([ |
| 'python', |
| plist_compiler, |
| 'merge', |
| '-f', |
| 'binary1', |
| '-o', |
| bundle.info_plist_path, |
| ] + filtered_partial_plist) |
| |
| |
| class Action(object): |
| """Class implementing one action supported by the script.""" |
| |
| @classmethod |
| def Register(cls, subparsers): |
| parser = subparsers.add_parser(cls.name, help=cls.help) |
| parser.set_defaults(func=cls._Execute) |
| cls._Register(parser) |
| |
| |
| class CodeSignBundleAction(Action): |
| """Class implementing the code-sign-bundle action.""" |
| |
| name = 'code-sign-bundle' |
| help = 'perform code signature for a bundle' |
| |
| @staticmethod |
| def _Register(parser): |
| parser.add_argument( |
| '--entitlements', '-e', dest='entitlements_path', |
| help='path to the entitlements file to use') |
| parser.add_argument( |
| 'path', help='path to the iOS bundle to codesign') |
| parser.add_argument( |
| '--identity', '-i', required=True, |
| help='identity to use to codesign') |
| parser.add_argument( |
| '--binary', '-b', required=True, |
| help='path to the iOS bundle binary') |
| parser.add_argument( |
| '--framework', '-F', action='append', default=[], dest='frameworks', |
| help='install and resign system framework') |
| parser.add_argument( |
| '--disable-code-signature', action='store_true', dest='no_signature', |
| help='disable code signature') |
| parser.add_argument( |
| '--disable-embedded-mobileprovision', action='store_false', |
| default=True, dest='embedded_mobileprovision', |
| help='disable finding and embedding mobileprovision') |
| parser.add_argument( |
| '--platform', '-t', required=True, |
| help='platform the signed bundle is targeting') |
| parser.add_argument( |
| '--partial-info-plist', '-p', action='append', default=[], |
| help='path to partial Info.plist to merge to create bundle Info.plist') |
| parser.add_argument( |
| '--plist-compiler-path', '-P', action='store', |
| help='path to the plist compiler script (for --partial-info-plist)') |
| parser.set_defaults(no_signature=False) |
| |
| @staticmethod |
| def _Execute(args): |
| if not args.identity: |
| args.identity = '-' |
| |
| bundle = Bundle(args.path, args.platform) |
| |
| if args.partial_info_plist: |
| GenerateBundleInfoPlist(bundle, args.plist_compiler_path, |
| args.partial_info_plist) |
| |
| # The bundle Info.plist may have been updated by GenerateBundleInfoPlist() |
| # above. Load the bundle information from Info.plist after the modification |
| # have been written to disk. |
| bundle.Load() |
| |
| # According to Apple documentation, the application binary must be the same |
| # as the bundle name without the .app suffix. See crbug.com/740476 for more |
| # information on what problem this can cause. |
| # |
| # To prevent this class of error, fail with an error if the binary name is |
| # incorrect in the Info.plist as it is not possible to update the value in |
| # Info.plist at this point (the file has been copied by a different target |
| # and ninja would consider the build dirty if it was updated). |
| # |
| # Also checks that the name of the bundle is correct too (does not cause the |
| # build to be considered dirty, but still terminate the script in case of an |
| # incorrect bundle name). |
| # |
| # Apple documentation is available at: |
| # https://developer.apple.com/library/content/documentation/CoreFoundation/Conceptual/CFBundles/BundleTypes/BundleTypes.html |
| bundle_name = os.path.splitext(os.path.basename(bundle.path))[0] |
| errors = bundle.Validate({ |
| 'CFBundleName': bundle_name, |
| 'CFBundleExecutable': bundle_name, |
| }) |
| if errors: |
| for key in sorted(errors): |
| value, expected_value = errors[key] |
| sys.stderr.write('%s: error: %s value incorrect: %s != %s\n' % ( |
| bundle.path, key, value, expected_value)) |
| sys.stderr.flush() |
| sys.exit(1) |
| |
| # Delete existing embedded mobile provisioning. |
| embedded_provisioning_profile = os.path.join( |
| bundle.path, 'embedded.mobileprovision') |
| if os.path.isfile(embedded_provisioning_profile): |
| os.unlink(embedded_provisioning_profile) |
| |
| # Delete existing code signature. |
| if os.path.exists(bundle.signature_dir): |
| shutil.rmtree(bundle.signature_dir) |
| |
| # Install system frameworks if requested. |
| for framework_path in args.frameworks: |
| InstallSystemFramework(framework_path, args.path, args) |
| |
| # Copy main binary into bundle. |
| if not os.path.isdir(bundle.executable_dir): |
| os.makedirs(bundle.executable_dir) |
| shutil.copy(args.binary, bundle.binary_path) |
| |
| if bundle.kind == 'mac_framework': |
| # Create Versions/Current -> Versions/A symlink |
| CreateSymlink('A', os.path.join(bundle.path, 'Versions/Current')) |
| |
| # Create $binary_name -> Versions/Current/$binary_name symlink |
| CreateSymlink(os.path.join('Versions/Current', bundle.binary_name), |
| os.path.join(bundle.path, bundle.binary_name)) |
| |
| # Create optional symlinks. |
| for name in ('Headers', 'Resources', 'Modules'): |
| target = os.path.join(bundle.path, 'Versions/A', name) |
| if os.path.exists(target): |
| CreateSymlink(os.path.join('Versions/Current', name), |
| os.path.join(bundle.path, name)) |
| else: |
| obsolete_path = os.path.join(bundle.path, name) |
| if os.path.exists(obsolete_path): |
| os.unlink(obsolete_path) |
| |
| if args.no_signature: |
| return |
| |
| codesign_extra_args = [] |
| |
| if args.embedded_mobileprovision: |
| # Find mobile provisioning profile and embeds it into the bundle (if a |
| # code signing identify has been provided, fails if no valid mobile |
| # provisioning is found). |
| provisioning_profile_required = args.identity != '-' |
| provisioning_profile = FindProvisioningProfile( |
| bundle.identifier, provisioning_profile_required) |
| if provisioning_profile and args.platform != 'iphonesimulator': |
| provisioning_profile.Install(embedded_provisioning_profile) |
| |
| if args.entitlements_path is not None: |
| temporary_entitlements_file = \ |
| tempfile.NamedTemporaryFile(suffix='.xcent') |
| codesign_extra_args.extend( |
| ['--entitlements', temporary_entitlements_file.name]) |
| |
| entitlements = GenerateEntitlements( |
| args.entitlements_path, provisioning_profile, bundle.identifier) |
| entitlements.WriteTo(temporary_entitlements_file.name) |
| |
| CodeSignBundle(bundle.path, args.identity, codesign_extra_args) |
| |
| |
| class CodeSignFileAction(Action): |
| """Class implementing code signature for a single file.""" |
| |
| name = 'code-sign-file' |
| help = 'code-sign a single file' |
| |
| @staticmethod |
| def _Register(parser): |
| parser.add_argument( |
| 'path', help='path to the file to codesign') |
| parser.add_argument( |
| '--identity', '-i', required=True, |
| help='identity to use to codesign') |
| parser.add_argument( |
| '--output', '-o', |
| help='if specified copy the file to that location before signing it') |
| parser.set_defaults(sign=True) |
| |
| @staticmethod |
| def _Execute(args): |
| if not args.identity: |
| args.identity = '-' |
| |
| install_path = args.path |
| if args.output: |
| |
| if os.path.isfile(args.output): |
| os.unlink(args.output) |
| elif os.path.isdir(args.output): |
| shutil.rmtree(args.output) |
| |
| if os.path.isfile(args.path): |
| shutil.copy(args.path, args.output) |
| elif os.path.isdir(args.path): |
| shutil.copytree(args.path, args.output) |
| |
| install_path = args.output |
| |
| CodeSignBundle(install_path, args.identity, |
| ['--deep', '--preserve-metadata=identifier,entitlements']) |
| |
| |
| class GenerateEntitlementsAction(Action): |
| """Class implementing the generate-entitlements action.""" |
| |
| name = 'generate-entitlements' |
| help = 'generate entitlements file' |
| |
| @staticmethod |
| def _Register(parser): |
| parser.add_argument( |
| '--entitlements', '-e', dest='entitlements_path', |
| help='path to the entitlements file to use') |
| parser.add_argument( |
| 'path', help='path to the entitlements file to generate') |
| parser.add_argument( |
| '--info-plist', '-p', required=True, |
| help='path to the bundle Info.plist') |
| |
| @staticmethod |
| def _Execute(args): |
| info_plist = LoadPlistFile(args.info_plist) |
| bundle_identifier = info_plist['CFBundleIdentifier'] |
| provisioning_profile = FindProvisioningProfile(bundle_identifier, False) |
| entitlements = GenerateEntitlements( |
| args.entitlements_path, provisioning_profile, bundle_identifier) |
| entitlements.WriteTo(args.path) |
| |
| |
| class FindProvisioningProfileAction(Action): |
| """Class implementing the find-codesign-identity action.""" |
| |
| name = 'find-provisioning-profile' |
| help = 'find provisioning profile for use by Xcode project generator' |
| |
| @staticmethod |
| def _Register(parser): |
| parser.add_argument('--bundle-id', |
| '-b', |
| required=True, |
| help='bundle identifier') |
| |
| @staticmethod |
| def _Execute(args): |
| provisioning_profile_info = {} |
| provisioning_profile = FindProvisioningProfile(args.bundle_id, False) |
| for key in ('team_identifier', 'name'): |
| if provisioning_profile: |
| provisioning_profile_info[key] = getattr(provisioning_profile, key) |
| else: |
| provisioning_profile_info[key] = '' |
| print(json.dumps(provisioning_profile_info)) |
| |
| |
| def Main(): |
| # Cache this codec so that plistlib can find it. See |
| # https://crbug.com/999461#c12 for more details. |
| codecs.lookup('utf-8') |
| |
| parser = argparse.ArgumentParser('codesign iOS bundles') |
| subparsers = parser.add_subparsers() |
| |
| actions = [ |
| CodeSignBundleAction, |
| CodeSignFileAction, |
| GenerateEntitlementsAction, |
| FindProvisioningProfileAction, |
| ] |
| |
| for action in actions: |
| action.Register(subparsers) |
| |
| args = parser.parse_args() |
| args.func(args) |
| |
| |
| if __name__ == '__main__': |
| sys.exit(Main()) |