-
Notifications
You must be signed in to change notification settings - Fork 258
/
plisttool.py
1431 lines (1194 loc) · 52.4 KB
/
plisttool.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# Copyright 2017 The Bazel Authors. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Plist manipulation for Apple packaging rules.
The "defaults" tool provided with OS X is somewhat satisfactory for reading and
writing single values in a plist, but merging whole plists with conflict
detection is not as easy.
This script takes a single argument that points to a file containing the JSON
representation of a "control" structure (similar to the PlMerge tool, which
takes a binary protocol buffer). This control structure is a dictionary with
the following keys:
plists: A list of plists that will be merged. The items in this list may be
strings (which are interpreted as paths), readable file-like objects
containing XML-formatted plist data (for testing), or dictionaries that
are treated as inlined plists. Key-value pairs within the plists in this
list must not conflict (i.e., the same key must not have different values
in different plists) or the tool will raise an error.
forced_plists: A list of plists that will be merged after those in "plists".
Unlike those, collisions between key-value pairs in these plists do not
raise an error; they replace any values from the originals instead. If
multiple plists have the same key, the last one in this list is the one
that will be kept.
output: A string indicating the path to where the merged plist will be
written, or a writable file-like object (for testing).
binary: If true, the output plist file will be written in binary format;
otherwise, it will be written in XML format. This property is ignored if
|output| is not a path.
entitlements_options: A dictionary containing options specific to
entitlements plist files. Omit this key if you are merging or converting
other plists (such as Info.plists or other files). See below for more
details.
info_plist_options: A dictionary containing options specific to Info.plist
files. Omit this key if you are merging or converting general plists
(such as entitlements or other files). See below for more details.
raw_substitutions: A dictionary of string pairs to use for substitutions.
Unlike variable_substitutions, there is now "wrapper" added to the keys
so this can match any *raw* substring in any value in the plist. This
should be used with extreme care.
variable_substitutions: A dictionary of string pairs to use for ${VAR}/$(VAR)
substitutions when processing the plists. All keys/values will get
support for the rfc1034identifier qualifier.
target: The target name, used for warning/error messages.
The info_plist_options dictionary can contain the following keys:
pkginfo: If present, a string that denotes the path to a PkgInfo file that
should be created from the CFBundlePackageType and CFBundleSignature keys
in the final merged plist. (For testing purposes, this may also be a
writable file-like object.)
version_file: If present, a string that denotes the path to the version file
propagated by an `AppleBundleVersionInfo` provider, which contains values
that will be used for the version keys in the Info.plist.
version_keys_required: If True, the tool will error if the merged Info.plist
does not contain both CFBundleShortVersionString and CFBundleVersion.
child_plists: If present, a dictionary containing plists that will be
compared against the final compiled plist for consistency. The keys of
the dictionary are the labels of the targets to which the associated
plists belong. See below for the details of how these are validated.
child_plist_required_values: If present, a dictionary constaining the
entries for key/value pairs a child is required to have. This
dictionary is keyed by the label of the child targets (just like the
`child_plists`), and the valures are a list of key/value pairs. The
key/value pairs are encoded as a list of exactly two items, the key is
actually an array of keys, so it can walk into the child plist.
If info_plist_options is present, validation will be performed on the output
file after merging is complete. If any of the following conditions are not
satisfied, an error will be raised:
* The CFBundleIdentifier and CFBundleShortVersionString values of the
output file will be compared to the child plists for consistency. Child
plists are expected to have the same bundle version string as the parent
and should have bundle IDs that are prefixed by the bundle ID of the
parent.
The entitlements_options dictionary can contain the following keys:
bundle_id: String with the bundle id for the app the entitlements are for.
profile_metadata_file: A string that denotes the path to a provisioning
profiles metadata plist. This is the the subset of data as created by
provisioning_profile_tool.
validation_mode: A string of "error", "warn", or "skip" to control how
entitlements are checked against the provisioning profile's entitlements.
If no value is given, "error" is assumed.
If entitlements_options is present, validation will be performed on the output
file after merging is complete. If any of the following conditions are not
satisfied, an error will be raised:
* The bundle_id provided in the options will be checked against the
`application-identifier` in the entitlements to ensure they are
a match.
* The requested entitlements from the merged file are checked against
those provided by the provisioning profile's supported entitlements.
Some mismatches are only warnings, others are errors. Warnings are
used when the build target may still work (iOS Simulator), but
errors are used where the build result is known not to work (iOS
devices).
"""
# NOTE: Ideally the final plist will always be byte for byte the same, not
# just contain the same data. Xcode, which is built on Foundation's
# non-order-preserving NSDictionary, is harmful to caching because it merges
# keys in arbitrary order. Python dictionaries make no guarantee about an
# iteration order, but appear to have repeatable behavior for the same inputs,
# for the same version of python.
#
# tl;dr; - Rely on Python's dictionary iteration behavior until it becomes
# a problem.
#
# What was considered...
#
# Inputs come in via plist files and json files. Since the inputs can come
# from anything a developer what (i.e. - they could have a genrule and
# failed to have worried about stable outs), the best approach is to ensure
# stabilization during output. One could use python dictionaries until the
# the very end; then iterator over its keys in sorted order, recursively
# copying into an OrderedDict. It has to be recursive to ensure sub
# dictionaries are also stable.
#
# But, plistlib.writePlist doesn't make any promises about how it works, so
# passing it an OrderedDict might or might not work, and could be subject to
# versions of the module. But this output approach is likely the best to
# getting stable outputs.
#
# However... when a binary file is the desired result, plutil is invoked to
# convert the file to binary, and that again makes no promises. So even if
# feed a stable input, the output might not be deterministic when run on
# different machines and/or different macOS versions.
from __future__ import absolute_import
from __future__ import print_function
import copy
import datetime
import json
import plistlib
import re
import subprocess
import sys
# Python 2/3 compatibility setup.
# We don't want to depend on `six`, so we recreate the bits we need here.
_PY3 = sys.version_info[0] == 3
if _PY3:
_string_types = str
_integer_types = int
else:
_string_types = basestring
_integer_types = int, long
# Format strings for errors that are raised, exposed here to the tests
# can validate against them.
CHILD_BUNDLE_ID_MISMATCH_MSG = (
'While processing target "%s"; the CFBundleIdentifier of the child target '
'"%s" should have "%s" as its prefix, but found "%s".'
)
CHILD_BUNDLE_VERSION_MISMATCH_MSG = (
'While processing target "%s"; the %s of the child target "%s" should be '
'the same as its parent\'s version string "%s", but found "%s".'
)
REQUIRED_CHILD_MISSING_MSG = (
'While processing target "%s"; "child_plist_required_values" wanted to '
'check "%s", but it wasn\'t in the the "child_plists".'
)
REQUIRED_CHILD_NOT_PAIR = (
'While processing target "%s"; "child_plist_required_values" for "%s", '
'got something other than a key/value pair: %r'
)
REQUIRED_CHILD_KEYPATH_NOT_FOUND = (
'While processing target "%s"; the Info.plist for child target "%s" '
'should have and entry for "%s" or %r, but does not.'
)
REQUIRED_CHILD_KEYPATH_NOT_MATCHING = (
'While processing target "%s"; the Info.plist for child target "%s" '
'has the wrong value for "%s"; expected %r, but found %r.'
)
MISSING_VERSION_KEY_MSG = (
'Target "%s" is missing %s.'
)
INVALID_VERSION_KEY_VALUE_MSG = (
'Target "%s" has a %s that doesn\'t meet Apple\'s guidelines: "%s". See '
'https://developer.apple.com/library/content/technotes/tn2420/_index.html'
' and '
'https://developer.apple.com/library/content/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html'
)
PLUTIL_CONVERSION_TO_XML_FAILED_MSG = (
'While processing target "%s", plutil failed (%d) to convert "%s" to xml.'
)
CONFLICTING_KEYS_MSG = (
'While processing target "%s"; found key "%s" in two plists with different '
'values: "%s" != "%s"'
)
UNKNOWN_CONTROL_KEYS_MSG = (
'Target "%s" used a control structure has unknown key(s): %s'
)
UNKNOWN_TASK_OPTIONS_KEYS_MSG = (
'Target "%s" used %s that included unknown key(s): %s'
)
INVALID_SUBSTITUTATION_REFERENCE_MSG = (
'In target "%s"; invalid variable reference "%s" while merging '
'plists (key: "%s", value: "%s").'
)
UNKNOWN_SUBSTITUTATION_REFERENCE_MSG = (
'In target "%s"; unknown variable reference "%s" while merging '
'plists (key: "%s", value: "%s").'
)
UNKNOWN_SUBSTITUTION_ADDITION_AppIdentifierPrefix_MSG = (
'This can mean the rule failed to set the "provisioning_profile" ' +
'attribute so the prefix could be extracted.'
)
UNSUPPORTED_SUBSTITUTATION_REFERENCE_IN_KEY_MSG = (
'In target "%s"; variable reference "%s" found in key "%s" merging '
'plists.'
)
INVALID_SUBSTITUTION_VARIABLE_NAME = (
'On target "%s"; invalid variable name for substitutions: "%s".'
)
SUBSTITUTION_VARIABLE_CANT_HAVE_QUALIFIER = (
'On target "%s"; variable name for substitutions can not have a '
'qualifier: "%s".'
)
OVERLAP_IN_SUBSTITUTION_KEYS = (
'Target "%s" has overlap in the from a raw substitution, overlapping '
'keys: "%s" and "%s".'
)
RAW_SUBSTITUTION_KEY_IN_VALUE = (
'Target "%s" has raw substitution key "%s" that appears in the another '
'substitution: "%s" for key "%s".'
)
ENTITLMENTS_BUNDLE_ID_MISMATCH = (
'In target "%s"; the bundle_id ("%s") did not match the id in the '
'entitlements ("%s").'
)
ENTITLEMENTS_PROFILE_HAS_EXPIRED = (
'On target "%s", provisioning profile ExpirationDate ("%s") is in the '
'past.'
)
ENTITLMENTS_TEAM_ID_PROFILE_MISMATCH = (
'In target "%s"; the entitlements "com.apple.developer.team-identifier" '
'("%s") did not match the provisioning profile\'s "%s" ("%s").'
)
ENTITLMENTS_APP_ID_PROFILE_MISMATCH = (
'In target "%s"; the entitlements "application-identifier" ("%s") did not '
'match the value in the provisioning profile ("%s").'
)
ENTITLMENTS_HAS_GROUP_PROFILE_DOES_NOT = (
'Target "%s" uses entitlements with a "%s" key, but the profile does not '
'support use of this key.'
)
ENTITLMENTS_HAS_GROUP_ENTRY_PROFILE_DOES_NOT = (
'Target "%s" uses entitlements "%s" value of "%s", but the profile does '
'not support it (["%s"]).'
)
# All valid keys in the a control structure.
_CONTROL_KEYS = frozenset([
'binary', 'forced_plists', 'entitlements_options', 'info_plist_options',
'output', 'plists', 'raw_substitutions', 'target',
'variable_substitutions',
])
# All valid keys in the info_plist_options control structure.
_INFO_PLIST_OPTIONS_KEYS = frozenset([
'child_plists', 'child_plist_required_values', 'pkginfo', 'version_file',
'version_keys_required',
])
# All valid keys in the entitlements_options control structure.
_ENTITLEMENTS_OPTIONS_KEYS = frozenset([
'bundle_id', 'profile_metadata_file', 'validation_mode',
])
# Two regexes for variable matching/validation.
# VARIABLE_REFERENCE_RE: Matches things that look mostly a
# variable reference.
# VARIABLE_NAME_RE: Is used to match the name from the first regex to
# confirm it is a valid name.
VARIABLE_REFERENCE_RE = re.compile(r'\$(\(|\{)([^\)\}]*)((\)|\})?|$)')
VARIABLE_NAME_RE = re.compile('^([a-zA-Z0-9_]+)(:rfc1034identifier)?$')
# Regex for RFC1034 normalization, see _ConvertToRFC1034()
_RFC1034_RE = re.compile(r'[^0-9A-Za-z.]')
# Info.plist "versioning" keys: CFBundleVersion & CFBundleShortVersionString
#
# Apple's docs are spares and not very specific. The best info seems to be:
# "Core Foundation Keys" -
# https://developer.apple.com/library/content/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# TN2420 - "Version Numbers and Build Numbers" -
# https://developer.apple.com/library/content/technotes/tn2420/_index.html
#
# - For mobile the details seem to be more complete, and the AppStore process
# helps fill in some gaps by failing uploads (or installs) for wrong/missing
# information.
# - As of Xcode 9.0, new projects get a CFBundleVersion of "1" and a
# CFBundleShortVersionString "1.0".
# - So while it isn't ever fully spelled out any place, the Apple Bazel rules
# take the stance that when one key is needed, both should be provided, and
# the formats should match what is outlines in the above two documents and
# the Xcode templates.
# Regexes for the two version keys, some enforcement is handled via the helper
# methods below.
#
# CFBundleVersion:
# - 1-3 segments of numbers and then the "development, alpha, beta, and final
# candidate" bits that are allowed.
# - "Core Foundation Keys" lists a limited on the number of characters in the
# segments, but that doesn't seem to be enforced.
# - Leading zero on numbers are ok.
# - NOTE: "Core Foundation Keys" also says the first segment must have a
# value that is >= 1. That is *not* going to be enforced since using 0.x.y
# very early in a project is common and at this level there is no way to
# really tell if this is a Release/AppStore build or not.
# - NOTE: While the docs all say 3 segments, enterprise builsd (and
# TestFlight?) are perfectly happy with 4 segment, so 4 is allowed.
# - TechNote also lists an 18 characters max
CF_BUNDLE_VERSION_RE = re.compile(
r'^[0-9]+(\.[0-9]+){0,3}((d|a|b|fc)(?P<track_num>[0-9]{1,3}))?$'
)
BUNDLE_VERSION_VALUE_MAX_LENGTH = 18
# CFBundleShortVersionString:
# - 1-3 segments of numbers.
# - The "Core Foundation Keys" does not list any limited on the number of
# characters in the segments.
# - Doesn't say anything about leading zeros, assume still ok.
# - NOTE: While the docs all say 3 segments, enterprise builsd (and
# TestFlight?) are perfectly happy with 4 segment, so 4 is allowed.
# - TechNote also lists an 18 characters max
CF_BUNDLE_SHORT_VERSION_RE = re.compile(
r'^[0-9]+(\.[0-9]+){0,3}$'
)
def plist_from_bytes(byte_content):
if _PY3:
return plistlib.loads(byte_content)
else:
return plistlib.readPlistFromString(byte_content)
def ExtractVariableFromMatch(re_match_obj):
"""Takes a match from VARIABLE_REFERENCE_RE and extracts the variable.
This funciton is exposed to testing.
Args:
re_match_obj: a re.MatchObject
Returns:
The variable name (with qualifier attached) or None if the match wasn't
completely valid.
"""
expected_close = '}' if re_match_obj.group(1) == '{' else ')'
if re_match_obj.group(3) == expected_close:
m = VARIABLE_NAME_RE.match(re_match_obj.group(2))
if m:
return m.group(0)
return None
def IsValidVersionString(s):
"""Checks if the given string is a valid CFBundleVersion
Args:
s: The string to check.
Returns:
True/False based on if the string meets Apple's rules.
"""
if len(s) > BUNDLE_VERSION_VALUE_MAX_LENGTH:
return False
m = CF_BUNDLE_VERSION_RE.match(s)
if not m:
# Didn't match, must be invalid.
return False
# The RE doesn't validate the "development, alpha, beta, and final candidate"
# bits, so that is done manually.
track_num = m.group('track_num')
if track_num:
# Can't start with a zero.
if track_num.startswith('0'):
return False
# Must be <= 255.
if int(track_num) > 255:
return False
return True
def IsValidShortVersionString(s):
"""Checks if the given string is a valid CFBundleShortVersionString
Args:
s: The string to check.
Returns:
True/False based on if the string meets Apple's rules.
"""
if len(s) > BUNDLE_VERSION_VALUE_MAX_LENGTH:
return False
m = CF_BUNDLE_SHORT_VERSION_RE.match(s)
return m is not None
def GetWithKeyPath(a_dict, key_path):
"""Helper to walk a keypath into a dict and return the value.
Remember when walking into lists, they are zero indexed.
Args:
a_dict: The dictionary to walk into.
key_path: A list of keys to walk into the dictionary.
Returns:
The object or None if the keypath can't be walked.
"""
value = a_dict
try:
for key in key_path:
if isinstance(value, (_string_types, _integer_types, float)):
# There are leaf types, can't keep pathing down
return None
value = value[key]
except (IndexError, KeyError, TypeError):
# List index out of range, unknown dict key, passing a string key to a list
return None
return value
def _ConvertToRFC1034(string):
"""Forces the given value into RFC 1034 compliance.
This function replaces any bad characters with '-' as Xcode would in its
plist substitution.
Args:
string: The string to convert.
Returns:
The converted string.
"""
return _RFC1034_RE.sub('-', string)
def _load_json(string_or_file):
"""Helper to load json from a path for file like object.
Args:
string_or_file: If a string, load the JSON from the path. Otherwise assume
it is a file like object and load from it.
Returns:
The object graph loaded.
"""
if isinstance(string_or_file, _string_types):
with open(string_or_file) as f:
return json.load(f)
return json.load(string_or_file)
class PlistToolError(ValueError):
"""Raised for all errors.
Custom ValueError used to allow catching (and logging) just the plisttool
errors.
"""
def __init__(self, msg):
"""Initializes an error with the given message.
Args:
msg: The message for the error.
"""
ValueError.__init__(self, msg)
class SubstitutionEngine(object):
"""Helper that can apply substitutions while copying values."""
def __init__(self, target, variable_substitutions=None, raw_substitutions=None):
"""Initialize a SubstitutionEngine.
Args:
target: Name of the target being built, used in messages/errors.
variable_substitutions: A dictionary of variable names to the values
to use for substitutions.
raw_substitutions: A dictionary of raw names to the values to use for
substitutions.
"""
self._substitutions = {}
self._substitutions_re = None
subs = variable_substitutions or {}
for key, value in subs.items():
m = VARIABLE_NAME_RE.match(key)
if not m:
raise PlistToolError(INVALID_SUBSTITUTION_VARIABLE_NAME % (
target, key))
if m.group(2):
raise PlistToolError(SUBSTITUTION_VARIABLE_CANT_HAVE_QUALIFIER % (
target, key))
value_rfc = _ConvertToRFC1034(value)
for fmt in ('${%s}', '$(%s)'):
self._substitutions[fmt % key] = value
self._substitutions[fmt % (key + ':rfc1034identifier')] = value_rfc
raw_subs = raw_substitutions or {}
for key, value in raw_subs.items():
# Raw keys can't overlap any other key (var or raw).
for existing_key in sorted(self._substitutions.keys()):
if (key in existing_key) or (existing_key in key):
ordered = sorted([key, existing_key])
raise PlistToolError(
OVERLAP_IN_SUBSTITUTION_KEYS % (target, ordered[0], ordered[1]))
self._substitutions[key] = value
# A raw key can't overlap any value.
raw_keys = sorted(raw_subs.keys())
for k, v in sorted(self._substitutions.items()):
for raw_key in raw_keys:
if raw_key in v:
raise PlistToolError(
RAW_SUBSTITUTION_KEY_IN_VALUE % (target, raw_key, v, k))
# Make _substitutions_re.
if self._substitutions:
escaped_keys = [re.escape(x) for x in self._substitutions.keys()]
self._substitutions_re = re.compile('(%s)' % '|'.join(escaped_keys))
def apply_substitutions(self, value):
"""Applies variable substitutions to the given value.
If the value is a string, the text will have the substitutions
applied. If it is an array or dictionary, then the substitutions will
be recursively applied to its members. Otherwise (for booleans or
numbers), the value will remain untouched.
Args:
value: The value with possible variable references to substitute.
Returns:
The value with any variable references substituted with their new
values.
"""
if not self._substitutions_re:
return value
return self._internal_apply_subs(value)
def _internal_apply_subs(self, value):
if isinstance(value, _string_types):
def sub_helper(match_obj):
return self._substitutions[match_obj.group(0)]
return self._substitutions_re.sub(sub_helper, value)
if isinstance(value, dict):
return {k: self._internal_apply_subs(v) for k, v in value.items()}
if isinstance(value, list):
return [self._internal_apply_subs(v) for v in value]
return value
@classmethod
def validate_no_variable_references(self, target, key_name, value, msg_additions=None):
"""Ensures there are no variable references left in value (recursively).
Args:
target: The name of the target for which the plist is being built.
key_name: The name of the key this value is part of.
value: The value to check.
msg_additions: Dictionary of variable names to custom strings to add to the
error messages.
Raises:
PlistToolError: If there is a variable substitution that wasn't resolved.
"""
additions = {}
if msg_additions:
for k, v in msg_additions.items():
additions[k] = v
additions[k + ':rfc1034identifier'] = v
def _helper(key_name, value):
if isinstance(value, _string_types):
m = VARIABLE_REFERENCE_RE.search(value)
if m:
variable_name = ExtractVariableFromMatch(m)
if not variable_name:
# Reference wasn't property formed, raise that issue.
raise PlistToolError(INVALID_SUBSTITUTATION_REFERENCE_MSG % (
target, m.group(0), key_name, value))
err_msg = UNKNOWN_SUBSTITUTATION_REFERENCE_MSG % (
target, m.group(0), key_name, value)
msg_addition = additions.get(variable_name)
if msg_addition:
err_msg = err_msg + ' ' + msg_addition
raise PlistToolError(err_msg)
return
if isinstance(value, dict):
key_prefix = key_name + ':' if key_name else ''
for k, v in value.items():
_helper(key_prefix + k, v)
m = VARIABLE_REFERENCE_RE.search(k)
if m:
raise PlistToolError(
UNSUPPORTED_SUBSTITUTATION_REFERENCE_IN_KEY_MSG % (
target, m.group(0), key_prefix + k))
return
if isinstance(value, list):
for i, v in enumerate(value):
reporting_key = '%s[%d]' % (key_name, i)
_helper(reporting_key, v)
return
# Off we go...
_helper(key_name, value)
class PlistIO(object):
"""Helpers for read/writing plists
These helpers make it easy to use files, streams, or literals without the
callers having to know.
"""
@classmethod
def get_dict(self, p, target):
"""Returns a plist dictionary based on the given object.
This function handles the various input formats for plists in the control
struct that are supported by this tool. Dictionary objects are returned
verbatim; strings are treated as paths to plist files, and anything else
is assumed to be a readable file-like object whose contents are plist data.
Args:
p: The object to interpret as a plist.
target: The name of the target for which the plist is being built.
Returns:
A dictionary containing the values from the plist.
"""
if isinstance(p, dict):
return p
if isinstance(p, _string_types):
with open(p, 'rb') as plist_file:
return self._read_plist(plist_file, p, target)
return self._read_plist(p, '<input>', target)
@classmethod
def _read_plist(self, plist_file, name, target):
"""Reads a plist file and returns its contents as a dictionary.
This method wraps the readPlist method in plistlib by checking the format
of the plist before reading and using plutil to convert it into XML format
first, to support plain text and binary formats as well.
Args:
plist_file: The file-like object containing the plist data.
name: Name to report the file-like object as if it fails xml conversion.
target: The name of the target for which the plist is being built.
Returns:
The contents of the plist file as a dictionary.
"""
plist_contents = plist_file.read()
# Binary plists are easy to identify because they start with 'bplist'. For
# plain text plists, it may be possible to have leading whitespace, but
# well-formed XML should *not* have any whitespace before the XML
# declaration, so we can check that the plist is not XML and let plutil
# handle them the same way.
if not plist_contents.startswith(b'<?xml'):
plutil_process = subprocess.Popen(
['plutil', '-convert', 'xml1', '-o', '-', '--', '-'],
stdout=subprocess.PIPE,
stdin=subprocess.PIPE
)
plist_contents, _ = plutil_process.communicate(plist_contents)
if plutil_process.returncode:
raise PlistToolError(PLUTIL_CONVERSION_TO_XML_FAILED_MSG % (
target, plutil_process.returncode, name))
return plist_from_bytes(plist_contents)
@classmethod
def write(self, plist, path_or_file, binary=False):
"""Writes the given plist to the output file.
This method also converts it to binary format if "binary" is True in the
control struct.
Args:
plist: The plist to write to the output path in the control struct.
path_or_file: The name of file to write or or a file like object to
write into.
binary: If True and path_or_file was a file name, reformat the file
in binary form.
"""
plistlib.writePlist(plist, path_or_file)
if binary and isinstance(path_or_file, _string_types):
subprocess.check_call(['plutil', '-convert', 'binary1', path_or_file])
class PlistToolTask(object):
"""Base for adding subtasks to the plist tool."""
def __init__(self, target, options):
"""Initialize the task.
Args:
target: The name of the target being processed.
options: The dictionary from the control to configure this option.
"""
self.target = target
self.options = options
@classmethod
def control_structure_options_name(self):
"""The name of the dictionary of options for this task.
The options will be a dictionary in the control structre to plisttool.
"""
raise NotImplementedError("Subclass must provide this.")
@classmethod
def options_keys(self):
"""Returns the set of valid keys in the options structure."""
raise NotImplementedError("Subclass must provide this.")
def extra_variable_substitutions(self):
"""Variable substitutions specific to this task to apply to plist merging."""
return {} # Default to nothing for subclasses.
def extra_raw_substitutions(self):
"""Raw substitutions specific to this task to apply to plist merging."""
return {} # Default to nothing for subclasses.
def unknown_variable_message_additions(self):
"""Things to add to unknown variable messages.
The resulting dictionary should be keyed by the variable name.
"""
return {} # Default to nothing for subclasses.
def update_plist(self, out_plist, subs_engine):
"""Update anything needed in in the plist.
Args:
out_plist: The dictionary representing the merged plist so far. This
dictionary will may be modified as the task desires.
subs_engine: A SubstitutionEngine instance to use if needed.
"""
pass # Default to nothing for subclasses
def validate_plist(self, plist):
"""Do any final checks on the resulting plist.
Args:
plist: The dictionary representing final plist, no changes may be
made to the plist. If there are any issues a PlistToolError should
be raised with the problems.
"""
pass # Default to nothing for subclasses
class InfoPlistTask(PlistToolTask):
"""Info.plist specific task when processing"""
@classmethod
def control_structure_options_name(self):
return 'info_plist_options'
@classmethod
def options_keys(self):
return _INFO_PLIST_OPTIONS_KEYS
def update_plist(self, out_plist, subs_engine):
# Pull in the version info propagated by AppleBundleVersionInfo.
version_file = self.options.get('version_file')
if version_file:
version_info = _load_json(version_file)
bundle_version = version_info.get('build_version')
short_version_string = version_info.get('short_version_string')
if bundle_version:
out_plist['CFBundleVersion'] = bundle_version
if short_version_string:
out_plist['CFBundleShortVersionString'] = short_version_string
def validate_plist(self, plist):
if self.options.get('version_keys_required'):
for k in ('CFBundleVersion', 'CFBundleShortVersionString'):
# This also errors if they are there but the empty string or zero.
if not plist.get(k, None):
raise PlistToolError(MISSING_VERSION_KEY_MSG % (self.target, k))
# If the version keys are set, they must be valid (even if they were
# not required).
for k, validator in (
('CFBundleVersion', IsValidVersionString),
('CFBundleShortVersionString', IsValidShortVersionString)):
v = plist.get(k)
if v and not validator(v):
raise PlistToolError(INVALID_VERSION_KEY_VALUE_MSG % (
self.target, k, v))
child_plists = self.options.get('child_plists')
child_plist_required_values = self.options.get(
'child_plist_required_values')
if child_plists:
self._validate_children(
plist, child_plists, child_plist_required_values, self.target)
pkginfo_file = self.options.get('pkginfo')
if pkginfo_file:
if isinstance(pkginfo_file, _string_types):
with open(pkginfo_file, 'wb') as p:
self._write_pkginfo(p, plist)
else:
self._write_pkginfo(pkginfo_file, plist)
@staticmethod
def _validate_children(plist, child_plists, child_required_values, target):
"""Validates a target's plist is consistent with its children.
This function checks each of the given child plists (which are typically
extensions or sub-apps embedded in another application) and fails the build
if there are any issues.
Args:
plist: The final plist of the target being built.
child_plists: The plists of child targets that the target being built
depends on.
child_required_values: Mapping of any key/value pairs to validate in
the children.
target: The name of the target being processed.
Raises:
PlistToolError: if there was an inconsistency between a child target's
plist and the current target's plist, with a message describing what
was incorrect.
"""
if child_required_values is None:
child_required_values = dict()
prefix = plist['CFBundleIdentifier'] + '.'
version = plist.get('CFBundleVersion')
short_version = plist.get('CFBundleShortVersionString')
for label, p in child_plists.items():
child_plist = PlistIO.get_dict(p, target)
child_id = child_plist['CFBundleIdentifier']
if not child_id.startswith(prefix):
raise PlistToolError(CHILD_BUNDLE_ID_MISMATCH_MSG % (
target, label, prefix, child_id))
# - TN2420 calls out CFBundleVersion and CFBundleShortVersionString
# has having to match for watchOS targets.
# https://developer.apple.com/library/content/technotes/tn2420/_index.html
# - The Application Loader (and Xcode) have also given errors for
# iOS Extensions that don't share the same values for the two
# version keys as they parent App. So we enforce this for all
# platforms just to be safe even though it isn't otherwise
# documented.
# https://stackoverflow.com/questions/30441750/use-same-cfbundleversion-and-cfbundleshortversionstring-in-all-targets
child_version = child_plist.get('CFBundleVersion')
if version != child_version:
raise PlistToolError(CHILD_BUNDLE_VERSION_MISMATCH_MSG % (
target, 'CFBundleVersion', label, version, child_version))
child_version = child_plist.get('CFBundleShortVersionString')
if short_version != child_version:
raise PlistToolError(CHILD_BUNDLE_VERSION_MISMATCH_MSG % (
target, 'CFBundleShortVersionString', label, short_version,
child_version))
required_info = child_required_values.get(label, [])
for pair in required_info:
if not isinstance(pair, list) or len(pair) != 2:
raise PlistToolError(REQUIRED_CHILD_NOT_PAIR % (target, label, pair))
[key_path, expected] = pair
value = GetWithKeyPath(child_plist, key_path)
if value is None:
key_path_str = ":".join([str(x) for x in key_path])
raise PlistToolError(REQUIRED_CHILD_KEYPATH_NOT_FOUND % (
target, label, key_path_str, expected))
if value != expected:
key_path_str = ":".join([str(x) for x in key_path])
raise PlistToolError(REQUIRED_CHILD_KEYPATH_NOT_MATCHING % (
target, label, key_path_str, expected, value))
# Make sure there wasn't anything listed in required that wasn't listed
# as a child.
for label in child_required_values.keys():
if label not in child_plists:
raise PlistToolError(REQUIRED_CHILD_MISSING_MSG % (target, label))
@classmethod
def _write_pkginfo(self, pkginfo, plist):
"""Writes a PkgInfo file with contents from the given plist.
Args:
pkginfo: A writable file-like object into which the PkgInfo data will be
written.
plist: The plist containing the bundle package type and signature that
will be written into the PkgInfo.
"""
package_type = self._four_byte_pkginfo_string(
plist.get('CFBundlePackageType'))
signature = self._four_byte_pkginfo_string(
plist.get('CFBundleSignature'))
pkginfo.write(package_type)
pkginfo.write(signature)
@staticmethod
def _four_byte_pkginfo_string(value):
"""Encodes a plist value into four bytes suitable for a PkgInfo file.
Args:
value: The value that is a candidate for the PkgInfo file.
Returns:
If the value is a string that is exactly four bytes long, it is returned;
otherwise, '????' is returned instead.
"""
try:
if not isinstance(value, _string_types):
return b'????'
if isinstance(value, bytes):
value = value.decode('utf-8')
# Based on some experimentation, Xcode appears to use MacRoman encoding
# for the contents of PkgInfo files, so we do the same.
value = value.encode('mac-roman')
return value if len(value) == 4 else b'????'
except (UnicodeDecodeError, UnicodeEncodeError):
# Return the default string if any character set encoding/decoding errors
# occurred.
return b'????'
class EntitlementsTask(PlistToolTask):
"""Entitlements specific task when processing"""
def __init__(self, target, options):
super(EntitlementsTask, self).__init__(target, options)
self._extra_raw_subs = {}
self._extra_var_subs = {}
self._unknown_var_msg_addtions = {}
self._profile_metadata = {}
# Load the metadata so the content can be used for substitutions and
# validations.
profile_metadata_file = self.options.get('profile_metadata_file')
if profile_metadata_file:
self._profile_metadata = PlistIO.get_dict(profile_metadata_file, target)
ver = self._profile_metadata.get('Version')
if ver != 1:
# Just log the message incase something else goes wrong.