Skip to content

Commit 33a275e

Browse files
committed
fix(ota): classify deltas from prerequisite metadata
1 parent 2452daf commit 33a275e

6 files changed

Lines changed: 374 additions & 83 deletions

File tree

docs/architecture.md

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -161,14 +161,33 @@ literal, payload-pattern, or other materialization fallback after a JSON operati
161161
architecture must be present, and one successful architecture never hides another architecture's materialization or
162162
split failure. Human stderr is retained only as bounded diagnostic data and does not drive control flow. Protocol and
163163
invocation violations remain exceptions; expected materialization availability is represented by typed outcomes
164-
instead of exceptions.
165-
166-
Only after materialization exhausts its sources without a supported primary DSC does Symx collect `ipsw ota info/ls`
167-
evidence. A pure policy maps successful probe output to `delta`, `recovery`, or `unknown`. `delta` and `recovery`
168-
become typed extraction skip outcomes; `unknown` preserves the unavailable materialization as an extraction failure.
169-
If all macOS candidates are absent, this classifier runs once. The storage runner maps typed success/skip outcomes to
170-
persisted processing states, while exceptions are reserved for failed extraction. Symx does not own OTA cryptex mount
171-
lifecycle.
164+
instead of exceptions. `ipsw` owns the OTA cryptex mount lifecycle.
165+
166+
### Classifying an OTA that has no usable DSC
167+
168+
Symx classifies an OTA only after `ipsw` cannot provide a supported primary DSC. The classifier uses these sources,
169+
in order:
170+
171+
1. **Request metadata:** an OTA requested for the `recovery` platform is a recovery OTA.
172+
2. **ZIP metadata:** Symx reads the root `Info.plist` and validates the fields it needs. A non-empty
173+
`MobileAssetProperties.PrerequisiteBuild` identifies a delta OTA.
174+
3. **AEA metadata:** Symx asks `ipsw` to extract only `Info.plist` into a temporary directory. It accepts only small,
175+
regular files that contain the expected typed metadata.
176+
177+
`ipsw` 3.1.711 does not include `PrerequisiteBuild` in its JSON output. Some AppleArchive versions may also fail to
178+
reconstruct the plist from an AEA. In that case, an AEA-only compatibility fallback reads the single
179+
`PrereqBuild = ...` field from a successful `ipsw ota info` command. Logs record whether classification used an
180+
extracted plist or this fallback.
181+
182+
The classifier follows two safety rules:
183+
184+
- Paths such as `image_patches/` are not evidence that an OTA is a delta. Full cryptex OTAs can contain them.
185+
- Missing, malformed, conflicting, or unavailable metadata produces `unknown`; Symx does not guess from file names or
186+
archive listings.
187+
188+
`delta` and `recovery` are expected skip outcomes that the storage runner persists as terminal states. An `unknown`
189+
result preserves the original unavailable-materialization failure. For macOS, classification runs once only after all
190+
requested architectures are absent.
172191

173192
A GCS extraction request owns its downloaded temporary OTA and removes it after the final macOS materialization
174193
attempt, before restoring split archives for symsort. `ota extract-file` does not own its input and always preserves

docs/operations.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -617,7 +617,7 @@ extract retry, provided the mirrored artifact is still present. `recovery_ota` r
617617

618618
Existing rows are terminal skip states rather than operator emergencies. They record OTAs previously classified as referencing a DSC that the payloadv2 / Apple Archive tooling could not materialize or verify.
619619

620-
The structured adapter does not create this state from a `payload-extract` phase plus payload/BOM inventory alone because that evidence can also accompany transient failures. Those failures now remain `symbol_extraction_failed` and visible in the default failure view until Phase 4 introduces a trusted classifier.
620+
The structured adapter does not create this state from a `payload-extract` phase plus payload/BOM inventory alone because that evidence can also accompany transient failures. Those failures remain `symbol_extraction_failed` and visible in the default failure view. The trusted classifier emits `delta_ota` only when it obtains a non-empty `MobileAssetProperties.PrerequisiteBuild`: directly from a ZIP root `Info.plist`, from a bounded typed `Info.plist` reconstructed from an AEA, or through the documented temporary AEA-only `PrereqBuild` text fallback required by `ipsw` 3.1.711. It does not infer delta status from `image_patches/` or other listing paths. Classification logs identify whether extracted plist metadata or the fallback was used.
621621

622622
After a runner, macOS, `ipsw`, or AppleArchive tooling change, existing rows can be included in a curated admin extract rerun and reset to `mirrored`. They are outside the default failure view, so include `unsupported_ota_payload` in the admin state filter when reviewing them. The same explicit-filter requirement applies when retrying a misclassified `delta_ota` row.
623623

symx/ota/extract.py

Lines changed: 150 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""OTA extraction pipeline: DSC materialization, splitting, and symsort."""
22

33
import logging
4+
import plistlib
45
import re
56
import stat
67
import subprocess
@@ -27,6 +28,7 @@
2728
from symx.model import Arch
2829
from symx.fs import rmdir_if_exists
2930
from symx.tools import dyld_split, symsort as common_symsort
31+
from symx.ota.model.artifact_info import OtaArtifactInfo
3032
from symx.ota.model.ipsw_report import OtaDscReport, OtaDscReportFile
3133
from symx.ota.model.materialization import (
3234
OtaDscMaterializationAttempt,
@@ -63,9 +65,11 @@
6365
r"(?:\./)?(?:System/DriverKit/)?System/Library/(?:dyld|Caches/com\.apple\.dyld)/dyld_shared_cache_[^\s/]+"
6466
)
6567
MAX_IPSW_LISTING_PROBE_OUTPUT_CHARS = 1000
68+
MAX_OTA_INFO_PLIST_BYTES = 4 * 1024 * 1024
6669
AEA_MAGIC = b"AEA1"
6770
_ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-9;]*m")
6871
_LEADING_IPSW_GLYPH_RE = re.compile(r"^\s*[•⨯]\s*")
72+
_PREREQUISITE_BUILD_LINE_RE = re.compile(r"^PrereqBuild\s*=\s*(\S+)\s*$", re.MULTILINE)
6973
IPSW_OTA_DSC_JSON_CONTRACT_RELEASE = "3.1.707"
7074
MACOS_OTA_DSC_ARCHITECTURES = (Arch.ARM64E, Arch.X86_64, Arch.X86_64H)
7175

@@ -293,55 +297,163 @@ def symsort(dsc_split_dirs: list[Path], output_dir: Path, prefix: str, bundle_id
293297
raise OtaExtractError(f"Symsorter failed with {result}")
294298

295299

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",
302306
)
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",
307331
)
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)
308422
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",
313427
)
314428

315429

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+
316446
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":
321449
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:
333451
return OtaClassification.DELTA
334-
335452
return OtaClassification.UNKNOWN
336453

337454

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))
345457

346458

347459
def _parse_ota_dsc_report(
@@ -663,7 +775,7 @@ def _resolve_unavailable_materialization(
663775
unavailable: OtaDscUnavailable,
664776
) -> OtaExtractionSkipped:
665777
if unavailable.exhausted_sources_without_primary:
666-
classification = _classify_ota(request.local_ota)
778+
classification = _classify_ota(request)
667779
if classification == OtaClassification.DELTA:
668780
return OtaExtractionSkipped(reason=OtaExtractionSkipReason.DELTA)
669781
if classification == OtaClassification.RECOVERY:

symx/ota/model/__init__.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -138,12 +138,12 @@ class OtaClassification(StrEnum):
138138

139139
@dataclass(frozen=True)
140140
class OtaClassificationEvidence:
141-
"""Untrusted command results collected for pure OTA classification policy."""
141+
"""Trusted request context and typed artifact metadata used by classification policy."""
142142

143-
info_returncode: int
144-
info_output: str
145-
listing_returncode: int
146-
listing_output: str
143+
platform: str
144+
info_succeeded: bool
145+
prerequisite_build: str | None
146+
metadata_source: str
147147

148148

149149
@dataclass(frozen=True)

symx/ota/model/artifact_info.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
"""Typed subset of the root Info.plist embedded in ZIP OTA artifacts."""
2+
3+
from pydantic import BaseModel, ConfigDict, Field
4+
5+
6+
class OtaMobileAssetProperties(BaseModel):
7+
"""Mobile asset fields used to identify prerequisite/delta OTAs."""
8+
9+
model_config = ConfigDict(extra="ignore")
10+
11+
prerequisite_build: str = Field(default="", alias="PrerequisiteBuild")
12+
13+
14+
class OtaArtifactInfo(BaseModel):
15+
"""Trusted classification fields from an OTA's root Info.plist."""
16+
17+
model_config = ConfigDict(extra="ignore")
18+
19+
mobile_asset_properties: OtaMobileAssetProperties = Field(alias="MobileAssetProperties")
20+
21+
@property
22+
def prerequisite_build(self) -> str | None:
23+
return self.mobile_asset_properties.prerequisite_build or None

0 commit comments

Comments
 (0)