blob: 15d25a78df5da4de486595119f7ee02aa68235fa [file] [log] [blame]
# 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())