protool 2.0.0__tar.gz → 3.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,26 +1,30 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: protool
3
- Version: 2.0.0
3
+ Version: 3.0.1
4
4
  Summary: A tool for dealing with provisioning profiles
5
- Home-page: https://github.com/Microsoft/protool
6
5
  License: MIT
6
+ License-File: LICENSE
7
7
  Keywords: provisioning,profiles,apple,ios,xcode,mobileprovision
8
8
  Author: Dale Myers
9
9
  Author-email: dalemy@microsoft.com
10
- Requires-Python: >=3.8,<4.0
10
+ Requires-Python: >=3.11,<4.0
11
11
  Classifier: Development Status :: 3 - Alpha
12
12
  Classifier: Environment :: Console
13
13
  Classifier: Environment :: MacOS X
14
14
  Classifier: Intended Audience :: Developers
15
15
  Classifier: License :: OSI Approved :: MIT License
16
16
  Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Classifier: Programming Language :: Python :: 3.10
17
22
  Classifier: Programming Language :: Python :: 3.8
18
23
  Classifier: Programming Language :: Python :: 3.9
19
- Classifier: Programming Language :: Python :: 3.10
20
- Classifier: Programming Language :: Python :: 3.11
21
24
  Classifier: Topic :: Software Development
22
25
  Classifier: Topic :: Utilities
23
- Requires-Dist: pyOpenSSL (>=23.2.0,<24.0.0)
26
+ Requires-Dist: pyOpenSSL (>=25.1.0,<26.0.0)
27
+ Project-URL: Homepage, https://github.com/Microsoft/protool
24
28
  Project-URL: Repository, https://github.com/Microsoft/protool
25
29
  Description-Content-Type: text/markdown
26
30
 
@@ -70,6 +74,13 @@ Alternatively, from the command line:
70
74
  # Get the raw XML (identical to using `security cms -D -i /path/to/profile`)
71
75
  protool decode --profile /path/to/profile
72
76
 
77
+ Custom diff commands (`tool_override` in Python or `--tool` on the command line)
78
+ accept an executable and optional arguments, for example `--tool 'diff -u'`.
79
+ Quote executable paths or arguments containing spaces within the command string,
80
+ for example `--tool '"/path with spaces/diff" -u'`. Commands run directly, without
81
+ a shell: pipes, redirection, environment-variable expansion, and command
82
+ substitution are not supported. Profile paths are always passed as literal arguments.
83
+
73
84
 
74
85
  # Contributing
75
86
 
@@ -44,6 +44,13 @@ Alternatively, from the command line:
44
44
  # Get the raw XML (identical to using `security cms -D -i /path/to/profile`)
45
45
  protool decode --profile /path/to/profile
46
46
 
47
+ Custom diff commands (`tool_override` in Python or `--tool` on the command line)
48
+ accept an executable and optional arguments, for example `--tool 'diff -u'`.
49
+ Quote executable paths or arguments containing spaces within the command string,
50
+ for example `--tool '"/path with spaces/diff" -u'`. Commands run directly, without
51
+ a shell: pipes, redirection, environment-variable expansion, and command
52
+ substitution are not supported. Profile paths are always passed as literal arguments.
53
+
47
54
 
48
55
  # Contributing
49
56
 
@@ -0,0 +1,326 @@
1
+ #!/usr/bin/env python3
2
+
3
+ """A utility for dealing with provisioning profiles"""
4
+
5
+ from enum import Enum
6
+
7
+ import copy
8
+ import datetime
9
+ import os
10
+ import plistlib
11
+ import shlex
12
+ import subprocess
13
+ import sys
14
+ import tempfile
15
+ from typing import Any, cast
16
+ from OpenSSL import crypto
17
+
18
+
19
+ def _extract_certificate_properties(cert: crypto.X509) -> dict[str, Any]:
20
+ """Extract key properties from an X509 certificate."""
21
+ subject = cert.get_subject()
22
+ issuer = cert.get_issuer()
23
+
24
+ # Helper to safely get X509Name components
25
+ def get_component(name_obj, component: str) -> str | None:
26
+ try:
27
+ return getattr(name_obj, component, None)
28
+ except AttributeError:
29
+ return None
30
+
31
+ # Parse ASN.1 TIME to ISO 8601 format
32
+ def parse_asn1_time(time_bytes: bytes | None) -> str | None:
33
+ if time_bytes is None:
34
+ return None
35
+ time_str = time_bytes.decode("utf-8")
36
+ # ASN.1 format: YYYYMMDDhhmmssZ
37
+ # Convert to ISO 8601: YYYY-MM-DDThh:mm:ssZ
38
+ try:
39
+ return f"{time_str[0:4]}-{time_str[4:6]}-{time_str[6:8]}T{time_str[8:10]}:{time_str[10:12]}:{time_str[12:14]}Z"
40
+ except (IndexError, ValueError):
41
+ return time_str # Return as-is if parsing fails
42
+
43
+ return {
44
+ "CommonName": get_component(subject, "CN"),
45
+ "Organization": get_component(subject, "O"),
46
+ "OrganizationalUnit": get_component(subject, "OU"),
47
+ "Country": get_component(subject, "C"),
48
+ "IssuerCommonName": get_component(issuer, "CN"),
49
+ "IssuerOrganization": get_component(issuer, "O"),
50
+ "SerialNumber": str(
51
+ cert.get_serial_number() # Other libs struggle with large integers
52
+ ),
53
+ "NotBefore": parse_asn1_time(cert.get_notBefore()),
54
+ "NotAfter": parse_asn1_time(cert.get_notAfter()),
55
+ "SignatureAlgorithm": (
56
+ cert.get_signature_algorithm().decode("utf-8")
57
+ if cert.get_signature_algorithm()
58
+ else None
59
+ ),
60
+ "Fingerprint": (
61
+ cert.digest("sha256").decode("utf-8") if hasattr(cert, "digest") else None
62
+ ),
63
+ }
64
+
65
+
66
+ class ProvisioningType(Enum):
67
+ """Enum representing the type of provisioning profile."""
68
+
69
+ IOS_DEVELOPMENT = 1
70
+ APP_STORE_DISTRIBUTION = 3
71
+ AD_HOC_DISTRIBUTION = 5
72
+ ENTERPRISE_DISTRIBUTION = 7
73
+
74
+
75
+ # pylint: disable=too-many-instance-attributes
76
+ class ProvisioningProfile:
77
+ """Represents a provisioning profile."""
78
+
79
+ file_path: str
80
+ file_name: str
81
+ xml: str
82
+ _contents: dict[str, Any]
83
+ _decode_certificates: bool
84
+
85
+ app_id_name: str | None
86
+ application_identifier_prefix: str | None
87
+ creation_date: datetime.datetime | None
88
+ platform: list[str] | None
89
+ entitlements: dict[str, Any]
90
+ expiration_date: datetime.datetime | None
91
+ name: str | None
92
+ team_identifier: list[str] | None
93
+ team_name: str | None
94
+ time_to_live: int | None
95
+ uuid: str | None
96
+ version: int | None
97
+ provisioned_devices: list[str] | None
98
+ provisions_all_devices: bool | None
99
+
100
+ @property
101
+ def profile_type(self) -> ProvisioningType:
102
+ """Determine the profile type from the various values in the profile."""
103
+ if self.provisions_all_devices:
104
+ return ProvisioningType.ENTERPRISE_DISTRIBUTION
105
+
106
+ if not self.entitlements.get("get-task-allow") and self.provisioned_devices:
107
+ return ProvisioningType.AD_HOC_DISTRIBUTION
108
+
109
+ if not self.entitlements.get("get-task-allow") and not self.provisioned_devices:
110
+ return ProvisioningType.APP_STORE_DISTRIBUTION
111
+
112
+ if self.entitlements.get("get-task-allow") and self.provisioned_devices:
113
+ return ProvisioningType.IOS_DEVELOPMENT
114
+
115
+ raise Exception("Unable to determine provisioning profile type")
116
+
117
+ def developer_certificates(self) -> list[crypto.X509]:
118
+ """Returns developer certificates as a list of PyOpenSSL X509."""
119
+ dev_certs: list[crypto.X509] = []
120
+ raw_cert_items: list[bytes] = cast(
121
+ list[bytes], self._contents.get("DeveloperCertificates", [])
122
+ )
123
+
124
+ for cert_item in raw_cert_items:
125
+ loaded_cert: crypto.X509 = crypto.load_certificate(
126
+ crypto.FILETYPE_ASN1, cert_item
127
+ )
128
+ dev_certs.append(loaded_cert)
129
+
130
+ return dev_certs
131
+
132
+ def __init__(
133
+ self,
134
+ file_path: str,
135
+ *,
136
+ sort_keys: bool = True,
137
+ decode_certificates: bool = False,
138
+ ) -> None:
139
+ self.file_path = os.path.abspath(file_path)
140
+ self.file_name = os.path.basename(self.file_path)
141
+ self._decode_certificates = decode_certificates
142
+ self.load_from_disk(sort_keys=sort_keys)
143
+
144
+ def load_from_disk(self, *, sort_keys: bool = True) -> None:
145
+ """Load the provisioning profile details from disk and parse them."""
146
+ self.xml = self._get_xml()
147
+ self._contents = plistlib.loads(self.xml.encode())
148
+
149
+ if sort_keys:
150
+ self.xml = plistlib.dumps(self._contents, sort_keys=True).decode("utf-8")
151
+
152
+ self._parse_contents()
153
+
154
+ # If we decoded certificates, we need to regenerate the XML to include them
155
+ if self._decode_certificates:
156
+ contents_copy = copy.deepcopy(self._contents)
157
+ del contents_copy["DeveloperCertificates"]
158
+ del contents_copy["DER-Encoded-Profile"]
159
+ self.xml = plistlib.dumps(contents_copy, sort_keys=sort_keys).decode(
160
+ "utf-8"
161
+ )
162
+
163
+ def contents(self) -> dict[str, Any]:
164
+ """Return a copy of the content dict."""
165
+ return copy.deepcopy(self._contents)
166
+
167
+ def _parse_contents(self) -> None:
168
+ """Parse the contents of the profile."""
169
+ self.app_id_name = self._contents.get("AppIDName")
170
+ self.application_identifier_prefix = self._contents.get(
171
+ "ApplicationIdentifierPrefix"
172
+ )
173
+ self.creation_date = self._contents.get("CreationDate")
174
+ self.platform = self._contents.get("Platform")
175
+ self.entitlements = self._contents.get("Entitlements", {})
176
+ self.expiration_date = self._contents.get("ExpirationDate")
177
+ self.name = self._contents.get("Name")
178
+ self.team_identifier = self._contents.get("TeamIdentifier")
179
+ self.team_name = self._contents.get("TeamName")
180
+ self.time_to_live = self._contents.get("TimeToLive")
181
+ self.uuid = self._contents.get("UUID")
182
+ self.version = self._contents.get("Version")
183
+ self.provisioned_devices = self._contents.get("ProvisionedDevices")
184
+ self.provisions_all_devices = self._contents.get("ProvisionsAllDevices", False)
185
+
186
+ # Decode certificates if requested
187
+ if self._decode_certificates:
188
+ decoded_certs: list[dict[str, Any]] = []
189
+ for cert in self.developer_certificates():
190
+ try:
191
+ decoded_certs.append(_extract_certificate_properties(cert))
192
+ except Exception as ex:
193
+ # Log error but continue with other certificates
194
+ print(
195
+ f"Warning: Failed to decode certificate: {ex}", file=sys.stderr
196
+ )
197
+ self._contents["DecodedDeveloperCertificates"] = decoded_certs
198
+
199
+ def _get_xml(self) -> str:
200
+ """Load the XML contents of a provisioning profile."""
201
+ if not os.path.exists(self.file_path):
202
+ raise Exception(f"File does not exist: {self.file_path}")
203
+
204
+ security_cmd = ["security", "cms", "-D", "-i", self.file_path]
205
+ return subprocess.check_output(
206
+ security_cmd, universal_newlines=True, stderr=subprocess.DEVNULL
207
+ ).strip()
208
+
209
+
210
+ # pylint: enable=too-many-instance-attributes
211
+
212
+
213
+ def profiles(profiles_dir: str | None = None) -> list[ProvisioningProfile]:
214
+ """Returns a list of all currently installed provisioning profiles."""
215
+ if profiles_dir:
216
+ dir_path = os.path.expanduser(profiles_dir)
217
+ else:
218
+ user_path = os.path.expanduser("~")
219
+ dir_path = os.path.join(
220
+ user_path, "Library", "MobileDevice", "Provisioning Profiles"
221
+ )
222
+
223
+ all_profiles: list[ProvisioningProfile] = []
224
+ for profile in os.listdir(dir_path):
225
+ full_path = os.path.join(dir_path, profile)
226
+ _, ext = os.path.splitext(full_path)
227
+ if ext == ".mobileprovision":
228
+ provisioning_profile = ProvisioningProfile(full_path)
229
+ all_profiles.append(provisioning_profile)
230
+
231
+ return all_profiles
232
+
233
+
234
+ def diff(
235
+ a_path: str,
236
+ b_path: str,
237
+ *,
238
+ sort_keys: bool = True,
239
+ ignore_keys: list[str] | None = None,
240
+ tool_override: str | None = None,
241
+ ) -> str:
242
+ """Diff two provisioning profiles without shell expansion of tool_override."""
243
+
244
+ # pylint: disable=too-many-locals
245
+
246
+ if tool_override is None:
247
+ diff_command = ["opendiff"]
248
+ else:
249
+ diff_command = shlex.split(tool_override)
250
+
251
+ if not diff_command or not diff_command[0]:
252
+ raise ValueError("Diff command must include an executable")
253
+
254
+ profile_a = ProvisioningProfile(a_path, sort_keys=sort_keys)
255
+ profile_b = ProvisioningProfile(b_path, sort_keys=sort_keys)
256
+
257
+ if ignore_keys is None:
258
+ a_xml = profile_a.xml
259
+ b_xml = profile_b.xml
260
+ else:
261
+ a_dict = profile_a.contents()
262
+ b_dict = profile_b.contents()
263
+
264
+ for key in ignore_keys:
265
+ try:
266
+ del a_dict[key]
267
+ except KeyError:
268
+ pass
269
+ try:
270
+ del b_dict[key]
271
+ except KeyError:
272
+ pass
273
+
274
+ a_xml = plistlib.dumps(a_dict).decode("utf-8")
275
+ b_xml = plistlib.dumps(b_dict).decode("utf-8")
276
+
277
+ with tempfile.TemporaryDirectory() as temp_dir:
278
+ a_temp_path = os.path.join(temp_dir, profile_a.file_name)
279
+ b_temp_path = os.path.join(temp_dir, profile_b.file_name)
280
+
281
+ with open(a_temp_path, "w", encoding="utf-8") as temp_profile:
282
+ temp_profile.write(a_xml)
283
+
284
+ with open(b_temp_path, "w", encoding="utf-8") as temp_profile:
285
+ temp_profile.write(b_xml)
286
+
287
+ diff_command.extend([a_temp_path, b_temp_path])
288
+
289
+ try:
290
+ diff_contents = subprocess.check_output(
291
+ diff_command, universal_newlines=True
292
+ ).strip()
293
+ except subprocess.CalledProcessError as ex:
294
+ # Diff tools usually return a non-0 exit code if there are differences,
295
+ # so we just swallow this error
296
+ diff_contents = ex.output
297
+
298
+ return diff_contents
299
+
300
+
301
+ def value_for_key(profile_path: str, key: str) -> Any | None:
302
+ """Return the value for a given key"""
303
+
304
+ profile = ProvisioningProfile(profile_path)
305
+
306
+ try:
307
+ value = profile.contents()[key]
308
+ return value
309
+ except KeyError:
310
+ return None
311
+
312
+
313
+ def decode(profile_path: str, xml: bool = True, *, decode_certificates: bool = False):
314
+ """Decode a profile, returning as a dictionary if xml is set to False."""
315
+
316
+ profile = ProvisioningProfile(profile_path, decode_certificates=decode_certificates)
317
+
318
+ if xml:
319
+ return profile.xml
320
+
321
+ return profile.contents()
322
+
323
+
324
+ if __name__ == "__main__":
325
+ print("This should only be used as a module.")
326
+ sys.exit(1)
@@ -94,7 +94,9 @@ def _handle_read(args: argparse.Namespace) -> int:
94
94
  def _handle_decode(args: argparse.Namespace) -> int:
95
95
  """Handle the decode sub command."""
96
96
  try:
97
- print(protool.decode(args.profile))
97
+ print(
98
+ protool.decode(args.profile, decode_certificates=args.decode_certificates)
99
+ )
98
100
  except Exception as ex:
99
101
  print(f"Could not decode: {ex}", file=sys.stderr)
100
102
  return 1
@@ -129,7 +131,7 @@ def _handle_arguments() -> int:
129
131
  dest="tool",
130
132
  action="store",
131
133
  default=None,
132
- help="Specify a diff command to use. It should take two file paths as the final two arguments. Defaults to opendiff", # pylint: disable=line-too-long
134
+ help="Specify a diff executable with optional quoted arguments (no shell expansion). It should take two file paths as the final two arguments. Defaults to opendiff", # pylint: disable=line-too-long
133
135
  )
134
136
 
135
137
  diff_parser.add_argument(
@@ -175,7 +177,7 @@ def _handle_arguments() -> int:
175
177
  dest="tool",
176
178
  action="store",
177
179
  default=None,
178
- help="Specify a diff command to use. It should take two file paths as the final two arguments. Defaults to opendiff", # pylint: disable=line-too-long
180
+ help="Specify a diff executable with optional quoted arguments (no shell expansion). It should take two file paths as the final two arguments. Defaults to opendiff", # pylint: disable=line-too-long
179
181
  )
180
182
 
181
183
  gitdiff_parser.add_argument(
@@ -227,6 +229,15 @@ def _handle_arguments() -> int:
227
229
  help="The profile to read the value from",
228
230
  )
229
231
 
232
+ decode_parser.add_argument(
233
+ "-d",
234
+ "--decode-certificates",
235
+ dest="decode_certificates",
236
+ action="store_true",
237
+ default=False,
238
+ help="Decode and extract certificate properties",
239
+ )
240
+
230
241
  decode_parser.set_defaults(subcommand="decode")
231
242
 
232
243
  args = parser.parse_args()
@@ -1,20 +1,25 @@
1
1
  [tool.poetry]
2
2
  name = "protool"
3
- version = "2.0.0"
3
+ version = "3.0.1"
4
4
  description = "A tool for dealing with provisioning profiles"
5
5
 
6
6
  license = "MIT"
7
7
 
8
- authors = [
9
- "Dale Myers <dalemy@microsoft.com>"
10
- ]
8
+ authors = ["Dale Myers <dalemy@microsoft.com>"]
11
9
 
12
10
  readme = 'README.md'
13
11
 
14
12
  repository = "https://github.com/Microsoft/protool"
15
13
  homepage = "https://github.com/Microsoft/protool"
16
14
 
17
- keywords = ['provisioning', 'profiles', 'apple', 'ios', 'xcode', 'mobileprovision']
15
+ keywords = [
16
+ 'provisioning',
17
+ 'profiles',
18
+ 'apple',
19
+ 'ios',
20
+ 'xcode',
21
+ 'mobileprovision',
22
+ ]
18
23
 
19
24
  classifiers = [
20
25
  'Development Status :: 3 - Alpha',
@@ -27,23 +32,23 @@ classifiers = [
27
32
  'Programming Language :: Python :: 3.10',
28
33
  'Programming Language :: Python :: 3.11',
29
34
  'Topic :: Software Development',
30
- 'Topic :: Utilities'
35
+ 'Topic :: Utilities',
31
36
  ]
32
37
 
33
38
  [tool.poetry.scripts]
34
39
  protool = 'protool:command_line.run'
35
40
 
36
41
  [tool.poetry.dependencies]
37
- python = "^3.8"
38
- pyOpenSSL ="^23.2.0"
42
+ python = "^3.11"
43
+ pyOpenSSL = "^25.1.0"
39
44
 
40
45
  [tool.poetry.dev-dependencies]
41
- black = "^23.7.0"
42
- mypy = "^1.4.1"
43
- pylint = "^2.17.5"
44
- pytest = "^7.4.0"
45
- pytest-cov = "^4.1.0"
46
- types-pyOpenSSL = "^23.2.0.2"
46
+ black = "^26.1.0"
47
+ mypy = "^1.19.1"
48
+ pylint = "^4.0.4"
49
+ pytest = "^9.0.3"
50
+ pytest-cov = "^7.0.0"
51
+ types-pyOpenSSL = "^24.1.0.20240722"
47
52
 
48
53
  [build-system]
49
54
  requires = ["poetry-core"]
@@ -1,251 +0,0 @@
1
- #!/usr/bin/env python3
2
-
3
- """A utility for dealing with provisioning profiles"""
4
-
5
- from enum import Enum
6
-
7
- import copy
8
- import datetime
9
- import os
10
- import plistlib
11
- import shutil
12
- import subprocess
13
- import sys
14
- import tempfile
15
- from typing import Any, cast, Dict, List, Optional
16
- from OpenSSL import crypto
17
-
18
-
19
- class ProvisioningType(Enum):
20
- """Enum representing the type of provisioning profile."""
21
-
22
- IOS_DEVELOPMENT = 1
23
- APP_STORE_DISTRIBUTION = 3
24
- AD_HOC_DISTRIBUTION = 5
25
- ENTERPRISE_DISTRIBUTION = 7
26
-
27
-
28
- # pylint: disable=too-many-instance-attributes
29
- class ProvisioningProfile:
30
- """Represents a provisioning profile."""
31
-
32
- file_path: str
33
- file_name: str
34
- xml: str
35
- _contents: Dict[str, Any]
36
-
37
- app_id_name: Optional[str]
38
- application_identifier_prefix: Optional[str]
39
- creation_date: Optional[datetime.datetime]
40
- platform: Optional[List[str]]
41
- entitlements: Dict[str, Any]
42
- expiration_date: Optional[datetime.datetime]
43
- name: Optional[str]
44
- team_identifier: Optional[List[str]]
45
- team_name: Optional[str]
46
- time_to_live: Optional[int]
47
- uuid: Optional[str]
48
- version: Optional[int]
49
- provisioned_devices: Optional[List[str]]
50
- provisions_all_devices: Optional[bool]
51
-
52
- @property
53
- def profile_type(self) -> ProvisioningType:
54
- """Determine the profile type from the various values in the profile."""
55
- if self.provisions_all_devices:
56
- return ProvisioningType.ENTERPRISE_DISTRIBUTION
57
-
58
- if not self.entitlements.get("get-task-allow") and self.provisioned_devices:
59
- return ProvisioningType.AD_HOC_DISTRIBUTION
60
-
61
- if not self.entitlements.get("get-task-allow") and not self.provisioned_devices:
62
- return ProvisioningType.APP_STORE_DISTRIBUTION
63
-
64
- if self.entitlements.get("get-task-allow") and self.provisioned_devices:
65
- return ProvisioningType.IOS_DEVELOPMENT
66
-
67
- raise Exception("Unable to determine provisioning profile type")
68
-
69
- def developer_certificates(self) -> List[crypto.X509]:
70
- """Returns developer certificates as a list of PyOpenSSL X509."""
71
- dev_certs: List[crypto.X509] = []
72
- raw_cert_items: List[str] = cast(
73
- List[str], self._contents.get("DeveloperCertificates", [])
74
- )
75
-
76
- for cert_item in raw_cert_items:
77
- loaded_cert: crypto.X509 = crypto.load_certificate(
78
- crypto.FILETYPE_ASN1, cert_item.encode()
79
- )
80
- dev_certs.append(loaded_cert)
81
-
82
- return dev_certs
83
-
84
- def __init__(self, file_path: str, *, sort_keys: bool = True) -> None:
85
- self.file_path = os.path.abspath(file_path)
86
- self.file_name = os.path.basename(self.file_path)
87
- self.load_from_disk(sort_keys=sort_keys)
88
-
89
- def load_from_disk(self, *, sort_keys: bool = True) -> None:
90
- """Load the provisioning profile details from disk and parse them."""
91
- self.xml = self._get_xml()
92
- self._contents = plistlib.loads(self.xml.encode())
93
-
94
- if sort_keys:
95
- self.xml = plistlib.dumps(self._contents, sort_keys=True).decode("utf-8")
96
-
97
- self._parse_contents()
98
-
99
- def contents(self) -> Dict[str, Any]:
100
- """Return a copy of the content dict."""
101
- return copy.deepcopy(self._contents)
102
-
103
- def _parse_contents(self) -> None:
104
- """Parse the contents of the profile."""
105
- self.app_id_name = self._contents.get("AppIDName")
106
- self.application_identifier_prefix = self._contents.get(
107
- "ApplicationIdentifierPrefix"
108
- )
109
- self.creation_date = self._contents.get("CreationDate")
110
- self.platform = self._contents.get("Platform")
111
- self.entitlements = self._contents.get("Entitlements", {})
112
- self.expiration_date = self._contents.get("ExpirationDate")
113
- self.name = self._contents.get("Name")
114
- self.team_identifier = self._contents.get("TeamIdentifier")
115
- self.team_name = self._contents.get("TeamName")
116
- self.time_to_live = self._contents.get("TimeToLive")
117
- self.uuid = self._contents.get("UUID")
118
- self.version = self._contents.get("Version")
119
- self.provisioned_devices = self._contents.get("ProvisionedDevices")
120
- self.provisions_all_devices = self._contents.get("ProvisionsAllDevices", False)
121
-
122
- def _get_xml(self) -> str:
123
- """Load the XML contents of a provisioning profile."""
124
- if not os.path.exists(self.file_path):
125
- raise Exception(f"File does not exist: {self.file_path}")
126
-
127
- security_cmd = f'security cms -D -i "{self.file_path}" 2> /dev/null'
128
- return subprocess.check_output(
129
- security_cmd, universal_newlines=True, shell=True
130
- ).strip()
131
-
132
-
133
- # pylint: enable=too-many-instance-attributes
134
-
135
-
136
- def profiles(profiles_dir: Optional[str] = None) -> List[ProvisioningProfile]:
137
- """Returns a list of all currently installed provisioning profiles."""
138
- if profiles_dir:
139
- dir_path = os.path.expanduser(profiles_dir)
140
- else:
141
- user_path = os.path.expanduser("~")
142
- dir_path = os.path.join(
143
- user_path, "Library", "MobileDevice", "Provisioning Profiles"
144
- )
145
-
146
- all_profiles = []
147
- for profile in os.listdir(dir_path):
148
- full_path = os.path.join(dir_path, profile)
149
- _, ext = os.path.splitext(full_path)
150
- if ext == ".mobileprovision":
151
- provisioning_profile = ProvisioningProfile(full_path)
152
- all_profiles.append(provisioning_profile)
153
-
154
- return all_profiles
155
-
156
-
157
- def diff(
158
- a_path: str,
159
- b_path: str,
160
- *,
161
- sort_keys: bool = True,
162
- ignore_keys: Optional[List[str]] = None,
163
- tool_override: Optional[str] = None,
164
- ) -> str:
165
- """Diff two provisioning profiles."""
166
-
167
- # pylint: disable=too-many-locals
168
-
169
- if tool_override is None:
170
- diff_tool = "opendiff"
171
- else:
172
- diff_tool = tool_override
173
-
174
- profile_a = ProvisioningProfile(a_path, sort_keys=sort_keys)
175
- profile_b = ProvisioningProfile(b_path, sort_keys=sort_keys)
176
-
177
- if ignore_keys is None:
178
- a_xml = profile_a.xml
179
- b_xml = profile_b.xml
180
- else:
181
- a_dict = profile_a.contents()
182
- b_dict = profile_b.contents()
183
-
184
- for key in ignore_keys:
185
- try:
186
- del a_dict[key]
187
- except KeyError:
188
- pass
189
- try:
190
- del b_dict[key]
191
- except KeyError:
192
- pass
193
-
194
- a_xml = plistlib.dumps(a_dict).decode("utf-8")
195
- b_xml = plistlib.dumps(b_dict).decode("utf-8")
196
-
197
- temp_dir = tempfile.mkdtemp()
198
-
199
- a_temp_path = os.path.join(temp_dir, profile_a.file_name)
200
- b_temp_path = os.path.join(temp_dir, profile_b.file_name)
201
-
202
- with open(a_temp_path, "w", encoding="utf-8") as temp_profile:
203
- temp_profile.write(a_xml)
204
-
205
- with open(b_temp_path, "w", encoding="utf-8") as temp_profile:
206
- temp_profile.write(b_xml)
207
-
208
- # We deliberately don't wrap the tool so that arguments work as well
209
- diff_command = f'{diff_tool} "{a_temp_path}" "{b_temp_path}"'
210
-
211
- try:
212
- diff_contents = subprocess.check_output(
213
- diff_command, universal_newlines=True, shell=True
214
- ).strip()
215
- except subprocess.CalledProcessError as ex:
216
- # Diff tools usually return a non-0 exit code if there are differences,
217
- # so we just swallow this error
218
- diff_contents = ex.output
219
-
220
- # Cleanup
221
- shutil.rmtree(temp_dir)
222
-
223
- return diff_contents
224
-
225
-
226
- def value_for_key(profile_path: str, key: str) -> Optional[Any]:
227
- """Return the value for a given key"""
228
-
229
- profile = ProvisioningProfile(profile_path)
230
-
231
- try:
232
- value = profile.contents()[key]
233
- return value
234
- except KeyError:
235
- return None
236
-
237
-
238
- def decode(profile_path: str, xml: bool = True):
239
- """Decode a profile, returning as a dictionary if xml is set to False."""
240
-
241
- profile = ProvisioningProfile(profile_path)
242
-
243
- if xml:
244
- return profile.xml
245
-
246
- return profile.contents()
247
-
248
-
249
- if __name__ == "__main__":
250
- print("This should only be used as a module.")
251
- sys.exit(1)
File without changes