|
1 | 1 | """OTA extraction pipeline: DSC materialization, splitting, and symsort.""" |
2 | 2 |
|
3 | 3 | import logging |
| 4 | +import plistlib |
4 | 5 | import re |
5 | 6 | import stat |
6 | 7 | import subprocess |
|
27 | 28 | from symx.model import Arch |
28 | 29 | from symx.fs import rmdir_if_exists |
29 | 30 | from symx.tools import dyld_split, symsort as common_symsort |
| 31 | +from symx.ota.model.artifact_info import OtaArtifactInfo |
30 | 32 | from symx.ota.model.ipsw_report import OtaDscReport, OtaDscReportFile |
31 | 33 | from symx.ota.model.materialization import ( |
32 | 34 | OtaDscMaterializationAttempt, |
|
63 | 65 | r"(?:\./)?(?:System/DriverKit/)?System/Library/(?:dyld|Caches/com\.apple\.dyld)/dyld_shared_cache_[^\s/]+" |
64 | 66 | ) |
65 | 67 | MAX_IPSW_LISTING_PROBE_OUTPUT_CHARS = 1000 |
| 68 | +MAX_OTA_INFO_PLIST_BYTES = 4 * 1024 * 1024 |
66 | 69 | AEA_MAGIC = b"AEA1" |
67 | 70 | _ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-9;]*m") |
68 | 71 | _LEADING_IPSW_GLYPH_RE = re.compile(r"^\s*[•⨯]\s*") |
| 72 | +_PREREQUISITE_BUILD_LINE_RE = re.compile(r"^PrereqBuild\s*=\s*(\S+)\s*$", re.MULTILINE) |
69 | 73 | IPSW_OTA_DSC_JSON_CONTRACT_RELEASE = "3.1.707" |
70 | 74 | MACOS_OTA_DSC_ARCHITECTURES = (Arch.ARM64E, Arch.X86_64, Arch.X86_64H) |
71 | 75 |
|
@@ -293,55 +297,163 @@ def symsort(dsc_split_dirs: list[Path], output_dir: Path, prefix: str, bundle_id |
293 | 297 | raise OtaExtractError(f"Symsorter failed with {result}") |
294 | 298 |
|
295 | 299 |
|
296 | | -def _collect_ota_classification_evidence(artifact: Path) -> OtaClassificationEvidence: |
297 | | - """Collect best-effort evidence only after materialization finds no usable DSC.""" |
298 | | - info_result = subprocess.run( |
299 | | - ["ipsw", "ota", "info", str(artifact)], |
300 | | - capture_output=True, |
301 | | - text=True, |
| 300 | +def _unavailable_classification_evidence(platform: str) -> OtaClassificationEvidence: |
| 301 | + return OtaClassificationEvidence( |
| 302 | + platform=platform, |
| 303 | + info_succeeded=False, |
| 304 | + prerequisite_build=None, |
| 305 | + metadata_source="unavailable", |
302 | 306 | ) |
303 | | - ls_result = subprocess.run( |
304 | | - ["ipsw", "ota", "ls", str(artifact)], |
305 | | - capture_output=True, |
306 | | - text=True, |
| 307 | + |
| 308 | + |
| 309 | +def _parse_ota_info_plist(data: bytes) -> OtaArtifactInfo: |
| 310 | + if len(data) > MAX_OTA_INFO_PLIST_BYTES: |
| 311 | + raise ValueError(f"root Info.plist is too large: {len(data)} bytes") |
| 312 | + return OtaArtifactInfo.model_validate(plistlib.loads(data)) |
| 313 | + |
| 314 | + |
| 315 | +def _read_zip_ota_classification_evidence(request: OtaExtractionRequest) -> OtaClassificationEvidence: |
| 316 | + try: |
| 317 | + with zipfile.ZipFile(request.local_ota) as archive: |
| 318 | + info_entry = archive.getinfo("Info.plist") |
| 319 | + if info_entry.file_size > MAX_OTA_INFO_PLIST_BYTES: |
| 320 | + raise ValueError(f"root Info.plist is too large: {info_entry.file_size} bytes") |
| 321 | + info = _parse_ota_info_plist(archive.read(info_entry)) |
| 322 | + except (KeyError, OSError, ValueError, plistlib.InvalidFileException, ValidationError, zipfile.BadZipFile) as error: |
| 323 | + logger.warning("Could not read trusted OTA metadata from %s: %s", request.local_ota, error) |
| 324 | + return _unavailable_classification_evidence(request.platform) |
| 325 | + |
| 326 | + return OtaClassificationEvidence( |
| 327 | + platform=request.platform, |
| 328 | + info_succeeded=True, |
| 329 | + prerequisite_build=info.prerequisite_build, |
| 330 | + metadata_source="zip-info-plist", |
307 | 331 | ) |
| 332 | + |
| 333 | + |
| 334 | +def _extract_aea_ota_info(request: OtaExtractionRequest) -> OtaClassificationEvidence | None: |
| 335 | + """Try to reconstruct a root Info.plist from an AEA without materializing symbols.""" |
| 336 | + with tempfile.TemporaryDirectory(suffix="_ota_info") as output_dir: |
| 337 | + command = [ |
| 338 | + "ipsw", |
| 339 | + "--no-color", |
| 340 | + "ota", |
| 341 | + "extract", |
| 342 | + str(request.local_ota), |
| 343 | + "--pattern", |
| 344 | + r"^Info\.plist$", |
| 345 | + "--confirm", |
| 346 | + "--flat", |
| 347 | + "--output", |
| 348 | + output_dir, |
| 349 | + ] |
| 350 | + try: |
| 351 | + result = subprocess.run(command, stdin=subprocess.DEVNULL, capture_output=True) |
| 352 | + except OSError as error: |
| 353 | + logger.warning("Could not invoke AEA OTA metadata extractor for %s: %s", request.local_ota, error) |
| 354 | + return None |
| 355 | + |
| 356 | + parsed: list[OtaArtifactInfo] = [] |
| 357 | + output_root = Path(output_dir).resolve() |
| 358 | + for candidate in Path(output_dir).rglob("Info.plist"): |
| 359 | + try: |
| 360 | + resolved = candidate.resolve(strict=True) |
| 361 | + if not resolved.is_relative_to(output_root): |
| 362 | + continue |
| 363 | + mode = candidate.stat(follow_symlinks=False).st_mode |
| 364 | + if not stat.S_ISREG(mode) or candidate.stat().st_size > MAX_OTA_INFO_PLIST_BYTES: |
| 365 | + continue |
| 366 | + parsed.append(_parse_ota_info_plist(candidate.read_bytes())) |
| 367 | + except (OSError, ValueError, plistlib.InvalidFileException, ValidationError): |
| 368 | + continue |
| 369 | + |
| 370 | + prerequisite_builds = {info.prerequisite_build for info in parsed} |
| 371 | + if len(prerequisite_builds) == 1: |
| 372 | + prerequisite_build = prerequisite_builds.pop() |
| 373 | + logger.info( |
| 374 | + "Read AEA OTA classification metadata from extracted Info.plist for %s (extract exit %d)", |
| 375 | + request.local_ota, |
| 376 | + result.returncode, |
| 377 | + ) |
| 378 | + return OtaClassificationEvidence( |
| 379 | + platform=request.platform, |
| 380 | + info_succeeded=True, |
| 381 | + prerequisite_build=prerequisite_build, |
| 382 | + metadata_source="aea-extracted-info-plist", |
| 383 | + ) |
| 384 | + if parsed: |
| 385 | + logger.warning( |
| 386 | + "AEA OTA metadata extraction returned conflicting Info.plist files for %s", request.local_ota |
| 387 | + ) |
| 388 | + else: |
| 389 | + logger.info( |
| 390 | + "AEA OTA metadata extraction did not return a usable root Info.plist for %s (exit %d): %s", |
| 391 | + request.local_ota, |
| 392 | + result.returncode, |
| 393 | + truncate_text(result.stderr) or "<empty stderr>", |
| 394 | + ) |
| 395 | + return None |
| 396 | + |
| 397 | + |
| 398 | +def _read_aea_info_text_fallback(request: OtaExtractionRequest) -> OtaClassificationEvidence: |
| 399 | + """Temporary fallback until structured ipsw AEA metadata includes PrerequisiteBuild.""" |
| 400 | + command = ["ipsw", "--no-color", "ota", "info", str(request.local_ota)] |
| 401 | + try: |
| 402 | + result = subprocess.run(command, capture_output=True, text=True) |
| 403 | + except OSError as error: |
| 404 | + logger.warning("Could not invoke AEA OTA metadata fallback for %s: %s", request.local_ota, error) |
| 405 | + return _unavailable_classification_evidence(request.platform) |
| 406 | + if result.returncode != 0: |
| 407 | + logger.warning( |
| 408 | + "Could not inspect AEA OTA metadata for %s (exit %d): %s", |
| 409 | + request.local_ota, |
| 410 | + result.returncode, |
| 411 | + truncate_text(result.stderr) or "<empty stderr>", |
| 412 | + ) |
| 413 | + return _unavailable_classification_evidence(request.platform) |
| 414 | + |
| 415 | + prerequisite_builds = set(_PREREQUISITE_BUILD_LINE_RE.findall(result.stdout + "\n" + result.stderr)) |
| 416 | + if len(prerequisite_builds) > 1: |
| 417 | + logger.warning("AEA OTA metadata fallback returned conflicting prerequisite builds for %s", request.local_ota) |
| 418 | + return _unavailable_classification_evidence(request.platform) |
| 419 | + |
| 420 | + prerequisite_build = next(iter(prerequisite_builds), None) |
| 421 | + logger.info("Read AEA OTA classification metadata from ipsw info text fallback for %s", request.local_ota) |
308 | 422 | return OtaClassificationEvidence( |
309 | | - info_returncode=info_result.returncode, |
310 | | - info_output=info_result.stdout + info_result.stderr, |
311 | | - listing_returncode=ls_result.returncode, |
312 | | - listing_output=ls_result.stdout + ls_result.stderr, |
| 423 | + platform=request.platform, |
| 424 | + info_succeeded=True, |
| 425 | + prerequisite_build=prerequisite_build, |
| 426 | + metadata_source="ipsw-info-text-fallback", |
313 | 427 | ) |
314 | 428 |
|
315 | 429 |
|
| 430 | +def _collect_ota_classification_evidence(request: OtaExtractionRequest) -> OtaClassificationEvidence: |
| 431 | + """Read artifact metadata only after materialization finds no usable DSC.""" |
| 432 | + if request.platform == "recovery": |
| 433 | + return OtaClassificationEvidence( |
| 434 | + platform=request.platform, |
| 435 | + info_succeeded=True, |
| 436 | + prerequisite_build=None, |
| 437 | + metadata_source="request-platform", |
| 438 | + ) |
| 439 | + if zipfile.is_zipfile(request.local_ota): |
| 440 | + return _read_zip_ota_classification_evidence(request) |
| 441 | + if _ota_is_aea_archive(request.local_ota): |
| 442 | + return _extract_aea_ota_info(request) or _read_aea_info_text_fallback(request) |
| 443 | + return _unavailable_classification_evidence(request.platform) |
| 444 | + |
| 445 | + |
316 | 446 | def _classify_ota_evidence(evidence: OtaClassificationEvidence) -> OtaClassification: |
317 | | - """Apply pure classification policy to collected OTA metadata and listing output.""" |
318 | | - if evidence.info_returncode == 0 and ( |
319 | | - "Darwin Recovery" in evidence.info_output or "RecoveryOSUpdate" in evidence.info_output |
320 | | - ): |
| 447 | + """Apply pure policy to trusted request context and typed artifact metadata.""" |
| 448 | + if evidence.platform == "recovery": |
321 | 449 | return OtaClassification.RECOVERY |
322 | | - |
323 | | - # High-confidence delta indicators: |
324 | | - # - image_patches/: newer-style delta OTAs (e.g. iPad) |
325 | | - # - payloadv2/patches/System/Library/Caches/com.apple.dyld/: older-style deltas (e.g. Apple TV) |
326 | | - # where the DSC itself is a binary diff |
327 | | - # Note: app_patches/ alone is not sufficient: full OTAs (e.g. watchOS, visionOS) can also |
328 | | - # contain app_patches/ alongside a full system image with a DSC. |
329 | | - if evidence.listing_returncode == 0 and ( |
330 | | - "image_patches/" in evidence.listing_output |
331 | | - or "payloadv2/patches/System/Library/Caches/com.apple.dyld/" in evidence.listing_output |
332 | | - ): |
| 450 | + if evidence.info_succeeded and evidence.prerequisite_build: |
333 | 451 | return OtaClassification.DELTA |
334 | | - |
335 | 452 | return OtaClassification.UNKNOWN |
336 | 453 |
|
337 | 454 |
|
338 | | -def _classify_ota(artifact: Path) -> OtaClassification: |
339 | | - try: |
340 | | - evidence = _collect_ota_classification_evidence(artifact) |
341 | | - except OSError as error: |
342 | | - logger.warning("Could not collect OTA classification evidence for %s: %s", artifact, error) |
343 | | - return OtaClassification.UNKNOWN |
344 | | - return _classify_ota_evidence(evidence) |
| 455 | +def _classify_ota(request: OtaExtractionRequest) -> OtaClassification: |
| 456 | + return _classify_ota_evidence(_collect_ota_classification_evidence(request)) |
345 | 457 |
|
346 | 458 |
|
347 | 459 | def _parse_ota_dsc_report( |
@@ -663,7 +775,7 @@ def _resolve_unavailable_materialization( |
663 | 775 | unavailable: OtaDscUnavailable, |
664 | 776 | ) -> OtaExtractionSkipped: |
665 | 777 | if unavailable.exhausted_sources_without_primary: |
666 | | - classification = _classify_ota(request.local_ota) |
| 778 | + classification = _classify_ota(request) |
667 | 779 | if classification == OtaClassification.DELTA: |
668 | 780 | return OtaExtractionSkipped(reason=OtaExtractionSkipReason.DELTA) |
669 | 781 | if classification == OtaClassification.RECOVERY: |
|
0 commit comments