xref: /aosp_15_r20/external/angle/build/config/apple/codesign.py (revision 8975f5c5ed3d1c378011245431ada316dfb6f244)
1# Copyright 2016 The Chromium Authors
2# Use of this source code is governed by a BSD-style license that can be
3# found in the LICENSE file.
4
5
6import argparse
7import codecs
8import datetime
9import fnmatch
10import glob
11import json
12import os
13import plistlib
14import shutil
15import subprocess
16import stat
17import sys
18import tempfile
19
20if sys.version_info.major < 3:
21  basestring_compat = basestring
22else:
23  basestring_compat = str
24
25
26def GetProvisioningProfilesDir():
27  """Returns the location of the installed mobile provisioning profiles.
28
29  Returns:
30    The path to the directory containing the installed mobile provisioning
31    profiles as a string.
32  """
33  return os.path.join(
34      os.environ['HOME'], 'Library', 'MobileDevice', 'Provisioning Profiles')
35
36
37def ReadPlistFromString(plist_bytes):
38  """Parse property list from given |plist_bytes|.
39
40    Args:
41      plist_bytes: contents of property list to load. Must be bytes in python 3.
42
43    Returns:
44      The contents of property list as a python object.
45    """
46  if sys.version_info.major == 2:
47    return plistlib.readPlistFromString(plist_bytes)
48  else:
49    return plistlib.loads(plist_bytes)
50
51
52def LoadPlistFile(plist_path):
53  """Loads property list file at |plist_path|.
54
55  Args:
56    plist_path: path to the property list file to load.
57
58  Returns:
59    The content of the property list file as a python object.
60  """
61  if sys.version_info.major == 2:
62    return plistlib.readPlistFromString(
63        subprocess.check_output(
64            ['xcrun', 'plutil', '-convert', 'xml1', '-o', '-', plist_path]))
65  else:
66    with open(plist_path, 'rb') as fp:
67      return plistlib.load(fp)
68
69
70def CreateSymlink(value, location):
71  """Creates symlink with value at location if the target exists."""
72  target = os.path.join(os.path.dirname(location), value)
73  if os.path.exists(location):
74    os.unlink(location)
75  os.symlink(value, location)
76
77
78class Bundle(object):
79  """Wraps a bundle."""
80
81  def __init__(self, bundle_path, platform):
82    """Initializes the Bundle object with data from bundle Info.plist file."""
83    self._path = bundle_path
84    self._kind = Bundle.Kind(platform, os.path.splitext(bundle_path)[-1])
85    self._data = None
86
87  def Load(self):
88    self._data = LoadPlistFile(self.info_plist_path)
89
90  @staticmethod
91  def Kind(platform, extension):
92    if platform in ('iphoneos', 'iphonesimulator'):
93      return 'ios'
94    if platform == 'macosx':
95      if extension == '.framework':
96        return 'mac_framework'
97      return 'mac'
98    if platform in ('watchos', 'watchsimulator'):
99      return 'watchos'
100    raise ValueError('unknown bundle type %s for %s' % (extension, platform))
101
102  @property
103  def kind(self):
104    return self._kind
105
106  @property
107  def path(self):
108    return self._path
109
110  @property
111  def contents_dir(self):
112    if self._kind == 'mac':
113      return os.path.join(self.path, 'Contents')
114    if self._kind == 'mac_framework':
115      return os.path.join(self.path, 'Versions/A')
116    return self.path
117
118  @property
119  def executable_dir(self):
120    if self._kind == 'mac':
121      return os.path.join(self.contents_dir, 'MacOS')
122    return self.contents_dir
123
124  @property
125  def resources_dir(self):
126    if self._kind == 'mac' or self._kind == 'mac_framework':
127      return os.path.join(self.contents_dir, 'Resources')
128    return self.path
129
130  @property
131  def info_plist_path(self):
132    if self._kind == 'mac_framework':
133      return os.path.join(self.resources_dir, 'Info.plist')
134    return os.path.join(self.contents_dir, 'Info.plist')
135
136  @property
137  def signature_dir(self):
138    return os.path.join(self.contents_dir, '_CodeSignature')
139
140  @property
141  def identifier(self):
142    return self._data['CFBundleIdentifier']
143
144  @property
145  def binary_name(self):
146    return self._data['CFBundleExecutable']
147
148  @property
149  def binary_path(self):
150    return os.path.join(self.executable_dir, self.binary_name)
151
152  def Validate(self, expected_mappings):
153    """Checks that keys in the bundle have the expected value.
154
155    Args:
156      expected_mappings: a dictionary of string to object, each mapping will
157      be looked up in the bundle data to check it has the same value (missing
158      values will be ignored)
159
160    Returns:
161      A dictionary of the key with a different value between expected_mappings
162      and the content of the bundle (i.e. errors) so that caller can format the
163      error message. The dictionary will be empty if there are no errors.
164    """
165    errors = {}
166    for key, expected_value in expected_mappings.items():
167      if key in self._data:
168        value = self._data[key]
169        if value != expected_value:
170          errors[key] = (value, expected_value)
171    return errors
172
173
174class ProvisioningProfile(object):
175  """Wraps a mobile provisioning profile file."""
176
177  def __init__(self, provisioning_profile_path):
178    """Initializes the ProvisioningProfile with data from profile file."""
179    self._path = provisioning_profile_path
180    self._data = ReadPlistFromString(
181        subprocess.check_output([
182            'xcrun', 'security', 'cms', '-D', '-u', 'certUsageAnyCA', '-i',
183            provisioning_profile_path
184        ]))
185
186  @property
187  def path(self):
188    return self._path
189
190  @property
191  def team_identifier(self):
192    return self._data.get('TeamIdentifier', [''])[0]
193
194  @property
195  def name(self):
196    return self._data.get('Name', '')
197
198  @property
199  def application_identifier_pattern(self):
200    return self._data.get('Entitlements', {}).get('application-identifier', '')
201
202  @property
203  def application_identifier_prefix(self):
204    return self._data.get('ApplicationIdentifierPrefix', [''])[0]
205
206  @property
207  def entitlements(self):
208    return self._data.get('Entitlements', {})
209
210  @property
211  def expiration_date(self):
212    return self._data.get('ExpirationDate', datetime.datetime.now())
213
214  def ValidToSignBundle(self, bundle_identifier):
215    """Checks whether the provisioning profile can sign bundle_identifier.
216
217    Args:
218      bundle_identifier: the identifier of the bundle that needs to be signed.
219
220    Returns:
221      True if the mobile provisioning profile can be used to sign a bundle
222      with the corresponding bundle_identifier, False otherwise.
223    """
224    return fnmatch.fnmatch(
225        '%s.%s' % (self.application_identifier_prefix, bundle_identifier),
226        self.application_identifier_pattern)
227
228  def Install(self, installation_path):
229    """Copies mobile provisioning profile info to |installation_path|."""
230    shutil.copy2(self.path, installation_path)
231    st = os.stat(installation_path)
232    os.chmod(installation_path, st.st_mode | stat.S_IWUSR)
233
234
235class Entitlements(object):
236  """Wraps an Entitlement plist file."""
237
238  def __init__(self, entitlements_path):
239    """Initializes Entitlements object from entitlement file."""
240    self._path = entitlements_path
241    self._data = LoadPlistFile(self._path)
242
243  @property
244  def path(self):
245    return self._path
246
247  def ExpandVariables(self, substitutions):
248    self._data = self._ExpandVariables(self._data, substitutions)
249
250  def _ExpandVariables(self, data, substitutions):
251    if isinstance(data, basestring_compat):
252      for key, substitution in substitutions.items():
253        data = data.replace('$(%s)' % (key,), substitution)
254      return data
255
256    if isinstance(data, dict):
257      for key, value in data.items():
258        data[key] = self._ExpandVariables(value, substitutions)
259      return data
260
261    if isinstance(data, list):
262      for i, value in enumerate(data):
263        data[i] = self._ExpandVariables(value, substitutions)
264
265    return data
266
267  def LoadDefaults(self, defaults):
268    for key, value in defaults.items():
269      if key not in self._data:
270        self._data[key] = value
271
272  def WriteTo(self, target_path):
273    with open(target_path, 'wb') as fp:
274      if sys.version_info.major == 2:
275        plistlib.writePlist(self._data, fp)
276      else:
277        plistlib.dump(self._data, fp)
278
279
280def FindProvisioningProfile(provisioning_profile_paths, bundle_identifier,
281                            required):
282  """Finds mobile provisioning profile to use to sign bundle.
283
284  Args:
285    bundle_identifier: the identifier of the bundle to sign.
286
287  Returns:
288    The ProvisioningProfile object that can be used to sign the Bundle
289    object or None if no matching provisioning profile was found.
290  """
291  if not provisioning_profile_paths:
292    provisioning_profile_paths = glob.glob(
293        os.path.join(GetProvisioningProfilesDir(), '*.mobileprovision'))
294
295  # Iterate over all installed mobile provisioning profiles and filter those
296  # that can be used to sign the bundle, ignoring expired ones.
297  now = datetime.datetime.now()
298  valid_provisioning_profiles = []
299  one_hour = datetime.timedelta(0, 3600)
300  for provisioning_profile_path in provisioning_profile_paths:
301    provisioning_profile = ProvisioningProfile(provisioning_profile_path)
302    if provisioning_profile.expiration_date - now < one_hour:
303      sys.stderr.write(
304          'Warning: ignoring expired provisioning profile: %s.\n' %
305          provisioning_profile_path)
306      continue
307    if provisioning_profile.ValidToSignBundle(bundle_identifier):
308      valid_provisioning_profiles.append(provisioning_profile)
309
310  if not valid_provisioning_profiles:
311    if required:
312      sys.stderr.write(
313          'Error: no mobile provisioning profile found for "%s" in %s.\n' %
314          (bundle_identifier, provisioning_profile_paths))
315      sys.exit(1)
316    return None
317
318  # Select the most specific mobile provisioning profile, i.e. the one with
319  # the longest application identifier pattern (prefer the one with the latest
320  # expiration date as a secondary criteria).
321  selected_provisioning_profile = max(
322      valid_provisioning_profiles,
323      key=lambda p: (len(p.application_identifier_pattern), p.expiration_date))
324
325  one_week = datetime.timedelta(7)
326  if selected_provisioning_profile.expiration_date - now < 2 * one_week:
327    sys.stderr.write(
328        'Warning: selected provisioning profile will expire soon: %s' %
329        selected_provisioning_profile.path)
330  return selected_provisioning_profile
331
332
333def CodeSignBundle(bundle_path, identity, extra_args):
334  process = subprocess.Popen(
335      ['xcrun', 'codesign', '--force', '--sign', identity, '--timestamp=none'] +
336      list(extra_args) + [bundle_path],
337      stderr=subprocess.PIPE,
338      universal_newlines=True)
339  _, stderr = process.communicate()
340  if process.returncode:
341    sys.stderr.write(stderr)
342    sys.exit(process.returncode)
343  for line in stderr.splitlines():
344    if line.endswith(': replacing existing signature'):
345      # Ignore warning about replacing existing signature as this should only
346      # happen when re-signing system frameworks (and then it is expected).
347      continue
348    sys.stderr.write(line)
349    sys.stderr.write('\n')
350
351
352def InstallSystemFramework(framework_path, bundle_path, args):
353  """Install framework from |framework_path| to |bundle| and code-re-sign it."""
354  installed_framework_path = os.path.join(
355      bundle_path, 'Frameworks', os.path.basename(framework_path))
356
357  if os.path.isfile(framework_path):
358    shutil.copy(framework_path, installed_framework_path)
359  elif os.path.isdir(framework_path):
360    if os.path.exists(installed_framework_path):
361      shutil.rmtree(installed_framework_path)
362    shutil.copytree(framework_path, installed_framework_path)
363
364  CodeSignBundle(installed_framework_path, args.identity,
365      ['--deep', '--preserve-metadata=identifier,entitlements,flags'])
366
367
368def GenerateEntitlements(path, provisioning_profile, bundle_identifier):
369  """Generates an entitlements file.
370
371  Args:
372    path: path to the entitlements template file
373    provisioning_profile: ProvisioningProfile object to use, may be None
374    bundle_identifier: identifier of the bundle to sign.
375  """
376  entitlements = Entitlements(path)
377  if provisioning_profile:
378    entitlements.LoadDefaults(provisioning_profile.entitlements)
379    app_identifier_prefix = \
380      provisioning_profile.application_identifier_prefix + '.'
381  else:
382    app_identifier_prefix = '*.'
383  entitlements.ExpandVariables({
384      'CFBundleIdentifier': bundle_identifier,
385      'AppIdentifierPrefix': app_identifier_prefix,
386  })
387  return entitlements
388
389
390def GenerateBundleInfoPlist(bundle, plist_compiler, partial_plist):
391  """Generates the bundle Info.plist for a list of partial .plist files.
392
393  Args:
394    bundle: a Bundle instance
395    plist_compiler: string, path to the Info.plist compiler
396    partial_plist: list of path to partial .plist files to merge
397  """
398
399  # Filter empty partial .plist files (this happens if an application
400  # does not compile any asset catalog, in which case the partial .plist
401  # file from the asset catalog compilation step is just a stamp file).
402  filtered_partial_plist = []
403  for plist in partial_plist:
404    plist_size = os.stat(plist).st_size
405    if plist_size:
406      filtered_partial_plist.append(plist)
407
408  # Invoke the plist_compiler script. It needs to be a python script.
409  subprocess.check_call([
410      'python3',
411      plist_compiler,
412      'merge',
413      '-f',
414      'binary1',
415      '-o',
416      bundle.info_plist_path,
417  ] + filtered_partial_plist)
418
419
420class Action(object):
421  """Class implementing one action supported by the script."""
422
423  @classmethod
424  def Register(cls, subparsers):
425    parser = subparsers.add_parser(cls.name, help=cls.help)
426    parser.set_defaults(func=cls._Execute)
427    cls._Register(parser)
428
429
430class CodeSignBundleAction(Action):
431  """Class implementing the code-sign-bundle action."""
432
433  name = 'code-sign-bundle'
434  help = 'perform code signature for a bundle'
435
436  @staticmethod
437  def _Register(parser):
438    parser.add_argument(
439        '--entitlements', '-e', dest='entitlements_path',
440        help='path to the entitlements file to use')
441    parser.add_argument(
442        'path', help='path to the iOS bundle to codesign')
443    parser.add_argument(
444        '--identity', '-i', required=True,
445        help='identity to use to codesign')
446    parser.add_argument(
447        '--binary', '-b', required=True,
448        help='path to the iOS bundle binary')
449    parser.add_argument(
450        '--framework', '-F', action='append', default=[], dest='frameworks',
451        help='install and resign system framework')
452    parser.add_argument(
453        '--disable-code-signature', action='store_true', dest='no_signature',
454        help='disable code signature')
455    parser.add_argument(
456        '--disable-embedded-mobileprovision', action='store_false',
457        default=True, dest='embedded_mobileprovision',
458        help='disable finding and embedding mobileprovision')
459    parser.add_argument(
460        '--platform', '-t', required=True,
461        help='platform the signed bundle is targeting')
462    parser.add_argument(
463        '--partial-info-plist', '-p', action='append', default=[],
464        help='path to partial Info.plist to merge to create bundle Info.plist')
465    parser.add_argument(
466        '--plist-compiler-path', '-P', action='store',
467        help='path to the plist compiler script (for --partial-info-plist)')
468    parser.add_argument(
469        '--mobileprovision',
470        '-m',
471        action='append',
472        default=[],
473        dest='mobileprovision_files',
474        help='list of mobileprovision files to use. If empty, uses the files ' +
475        'in $HOME/Library/MobileDevice/Provisioning Profiles')
476    parser.set_defaults(no_signature=False)
477
478  @staticmethod
479  def _Execute(args):
480    if not args.identity:
481      args.identity = '-'
482
483    bundle = Bundle(args.path, args.platform)
484
485    if args.partial_info_plist:
486      GenerateBundleInfoPlist(bundle, args.plist_compiler_path,
487                              args.partial_info_plist)
488
489    # The bundle Info.plist may have been updated by GenerateBundleInfoPlist()
490    # above. Load the bundle information from Info.plist after the modification
491    # have been written to disk.
492    bundle.Load()
493
494    # According to Apple documentation, the application binary must be the same
495    # as the bundle name without the .app suffix. See crbug.com/740476 for more
496    # information on what problem this can cause.
497    #
498    # To prevent this class of error, fail with an error if the binary name is
499    # incorrect in the Info.plist as it is not possible to update the value in
500    # Info.plist at this point (the file has been copied by a different target
501    # and ninja would consider the build dirty if it was updated).
502    #
503    # Also checks that the name of the bundle is correct too (does not cause the
504    # build to be considered dirty, but still terminate the script in case of an
505    # incorrect bundle name).
506    #
507    # Apple documentation is available at:
508    # https://developer.apple.com/library/content/documentation/CoreFoundation/Conceptual/CFBundles/BundleTypes/BundleTypes.html
509    bundle_name = os.path.splitext(os.path.basename(bundle.path))[0]
510    errors = bundle.Validate({
511        'CFBundleName': bundle_name,
512        'CFBundleExecutable': bundle_name,
513    })
514    if errors:
515      for key in sorted(errors):
516        value, expected_value = errors[key]
517        sys.stderr.write('%s: error: %s value incorrect: %s != %s\n' % (
518            bundle.path, key, value, expected_value))
519      sys.stderr.flush()
520      sys.exit(1)
521
522    # Delete existing embedded mobile provisioning.
523    embedded_provisioning_profile = os.path.join(
524        bundle.path, 'embedded.mobileprovision')
525    if os.path.isfile(embedded_provisioning_profile):
526      os.unlink(embedded_provisioning_profile)
527
528    # Delete existing code signature.
529    if os.path.exists(bundle.signature_dir):
530      shutil.rmtree(bundle.signature_dir)
531
532    # Install system frameworks if requested.
533    for framework_path in args.frameworks:
534      InstallSystemFramework(framework_path, args.path, args)
535
536    # Copy main binary into bundle.
537    if not os.path.isdir(bundle.executable_dir):
538      os.makedirs(bundle.executable_dir)
539    shutil.copy(args.binary, bundle.binary_path)
540
541    if bundle.kind == 'mac_framework':
542      # Create Versions/Current -> Versions/A symlink
543      CreateSymlink('A', os.path.join(bundle.path, 'Versions/Current'))
544
545      # Create $binary_name -> Versions/Current/$binary_name symlink
546      CreateSymlink(os.path.join('Versions/Current', bundle.binary_name),
547                    os.path.join(bundle.path, bundle.binary_name))
548
549      # Create optional symlinks.
550      for name in ('Headers', 'Resources', 'Modules'):
551        target = os.path.join(bundle.path, 'Versions/A', name)
552        if os.path.exists(target):
553          CreateSymlink(os.path.join('Versions/Current', name),
554                        os.path.join(bundle.path, name))
555        else:
556          obsolete_path = os.path.join(bundle.path, name)
557          if os.path.exists(obsolete_path):
558            os.unlink(obsolete_path)
559
560    if args.no_signature:
561      return
562
563    codesign_extra_args = []
564
565    if args.embedded_mobileprovision:
566      # Find mobile provisioning profile and embeds it into the bundle (if a
567      # code signing identify has been provided, fails if no valid mobile
568      # provisioning is found).
569      provisioning_profile_required = args.identity != '-'
570      provisioning_profile = FindProvisioningProfile(
571          args.mobileprovision_files, bundle.identifier,
572          provisioning_profile_required)
573      if provisioning_profile and not args.platform.endswith('simulator'):
574        provisioning_profile.Install(embedded_provisioning_profile)
575
576        if args.entitlements_path is not None:
577          temporary_entitlements_file = \
578              tempfile.NamedTemporaryFile(suffix='.xcent')
579          codesign_extra_args.extend(
580              ['--entitlements', temporary_entitlements_file.name])
581
582          entitlements = GenerateEntitlements(
583              args.entitlements_path, provisioning_profile, bundle.identifier)
584          entitlements.WriteTo(temporary_entitlements_file.name)
585
586    CodeSignBundle(bundle.path, args.identity, codesign_extra_args)
587
588
589class CodeSignFileAction(Action):
590  """Class implementing code signature for a single file."""
591
592  name = 'code-sign-file'
593  help = 'code-sign a single file'
594
595  @staticmethod
596  def _Register(parser):
597    parser.add_argument(
598        'path', help='path to the file to codesign')
599    parser.add_argument(
600        '--identity', '-i', required=True,
601        help='identity to use to codesign')
602    parser.add_argument(
603        '--output', '-o',
604        help='if specified copy the file to that location before signing it')
605    parser.set_defaults(sign=True)
606
607  @staticmethod
608  def _Execute(args):
609    if not args.identity:
610      args.identity = '-'
611
612    install_path = args.path
613    if args.output:
614
615      if os.path.isfile(args.output):
616        os.unlink(args.output)
617      elif os.path.isdir(args.output):
618        shutil.rmtree(args.output)
619
620      if os.path.isfile(args.path):
621        shutil.copy(args.path, args.output)
622      elif os.path.isdir(args.path):
623        shutil.copytree(args.path, args.output)
624
625      install_path = args.output
626
627    CodeSignBundle(install_path, args.identity,
628      ['--deep', '--preserve-metadata=identifier,entitlements'])
629
630
631class GenerateEntitlementsAction(Action):
632  """Class implementing the generate-entitlements action."""
633
634  name = 'generate-entitlements'
635  help = 'generate entitlements file'
636
637  @staticmethod
638  def _Register(parser):
639    parser.add_argument(
640        '--entitlements', '-e', dest='entitlements_path',
641        help='path to the entitlements file to use')
642    parser.add_argument(
643        'path', help='path to the entitlements file to generate')
644    parser.add_argument(
645        '--info-plist', '-p', required=True,
646        help='path to the bundle Info.plist')
647    parser.add_argument(
648        '--mobileprovision',
649        '-m',
650        action='append',
651        default=[],
652        dest='mobileprovision_files',
653        help='set of mobileprovision files to use. If empty, uses the files ' +
654        'in $HOME/Library/MobileDevice/Provisioning Profiles')
655
656  @staticmethod
657  def _Execute(args):
658    info_plist = LoadPlistFile(args.info_plist)
659    bundle_identifier = info_plist['CFBundleIdentifier']
660    provisioning_profile = FindProvisioningProfile(args.mobileprovision_files,
661                                                   bundle_identifier, False)
662    entitlements = GenerateEntitlements(
663        args.entitlements_path, provisioning_profile, bundle_identifier)
664    entitlements.WriteTo(args.path)
665
666
667class FindProvisioningProfileAction(Action):
668  """Class implementing the find-codesign-identity action."""
669
670  name = 'find-provisioning-profile'
671  help = 'find provisioning profile for use by Xcode project generator'
672
673  @staticmethod
674  def _Register(parser):
675    parser.add_argument('--bundle-id',
676                        '-b',
677                        required=True,
678                        help='bundle identifier')
679    parser.add_argument(
680        '--mobileprovision',
681        '-m',
682        action='append',
683        default=[],
684        dest='mobileprovision_files',
685        help='set of mobileprovision files to use. If empty, uses the files ' +
686        'in $HOME/Library/MobileDevice/Provisioning Profiles')
687
688  @staticmethod
689  def _Execute(args):
690    provisioning_profile_info = {}
691    provisioning_profile = FindProvisioningProfile(args.mobileprovision_files,
692                                                   args.bundle_id, False)
693    for key in ('team_identifier', 'name'):
694      if provisioning_profile:
695        provisioning_profile_info[key] = getattr(provisioning_profile, key)
696      else:
697        provisioning_profile_info[key] = ''
698    print(json.dumps(provisioning_profile_info))
699
700
701def Main():
702  # Cache this codec so that plistlib can find it. See
703  # https://crbug.com/999461#c12 for more details.
704  codecs.lookup('utf-8')
705
706  parser = argparse.ArgumentParser('codesign iOS bundles')
707  subparsers = parser.add_subparsers()
708
709  actions = [
710      CodeSignBundleAction,
711      CodeSignFileAction,
712      GenerateEntitlementsAction,
713      FindProvisioningProfileAction,
714  ]
715
716  for action in actions:
717    action.Register(subparsers)
718
719  args = parser.parse_args()
720  args.func(args)
721
722
723if __name__ == '__main__':
724  sys.exit(Main())
725