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