Skip to content

Commit 46f02c9

Browse files
feat(handler): add Apple Archive (AA01) handler
Adds a handler for Apple Archive format (magic AA01). Parses the tag-length-value field stream and decompresses DATA fields with LZFSE, falling back to raw bytes when decompression fails. Supports path, symlink, extended attribute, and type fields. New dependency: lzfse.
1 parent 49c3f4c commit 46f02c9

7 files changed

Lines changed: 191 additions & 0 deletions

File tree

docs/handlers.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
| [`AIROHA BT FIRMWARE`](#airoha-bt-firmware) | ARCHIVE | :octicons-alert-fill-12: |
66
| [`ANDROID EROFS`](#android-erofs) | FILESYSTEM | :octicons-check-16: |
77
| [`ANDROID SPARSE`](#android-sparse) | FILESYSTEM | :octicons-check-16: |
8+
| [`APPLE ARCHIVE`](#apple-archive) | ARCHIVE | :octicons-alert-fill-12: |
89
| [`AR`](#ar) | ARCHIVE | :octicons-check-16: |
910
| [`ARC`](#arc) | ARCHIVE | :octicons-check-16: |
1011
| [`ARJ`](#arj) | ARCHIVE | :octicons-check-16: |
@@ -170,6 +171,27 @@
170171

171172
- [Android Sparse Image Format Documentation](https://formats.kaitai.io/android_sparse/){ target="_blank" }
172173
- [simg2img Tool](https://github.com/anestisb/android-simg2img){ target="_blank" }
174+
## Apple Archive
175+
176+
!!! warning "Partially supported"
177+
178+
=== "Description"
179+
180+
Apple Archive is Apple's proprietary archive format introduced with macOS Big Sur, used for distributing macOS software updates and installers. Files begin with the AA01 magic and contain field-tagged entries encoding paths, LZFSE-compressed data blobs, symbolic links, and extended attributes.
181+
182+
---
183+
184+
- **Handler type:** Archive
185+
- **Vendor:** Apple
186+
187+
=== "References"
188+
189+
- [Apple Archive - Apple Developer Documentation](https://developer.apple.com/documentation/applearchive){ target="_blank" }
190+
191+
=== "Limitations"
192+
193+
- Only PATP/DATA/LNKP/XATA/TYP1 field tags are handled; other tags are silently skipped
194+
- Symlink targets are logged but not created in the output directory
173195
## AR
174196

175197
!!! success "Fully supported"

python/unblob/handlers/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from .archive import (
2020
zip as ziparchive,
2121
)
22+
from .archive.apple import applearchive
2223
from .archive.autel import ecc
2324
from .archive.dlink import alpha_encimg, deafbead, encrpted_img, fpkg, shrs
2425
from .archive.engeniustech import engenius
@@ -120,6 +121,7 @@
120121
ar.ARHandler,
121122
arc.ARCHandler,
122123
arj.ARJHandler,
124+
applearchive.AppleArchiveHandler,
123125
cab.CABHandler,
124126
msi.MsiHandler,
125127
tar.TarUstarHandler,

python/unblob/handlers/archive/apple/__init__.py

Whitespace-only changes.
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import io
2+
from pathlib import Path
3+
4+
import lzfse
5+
from structlog import get_logger
6+
7+
from unblob.file_utils import (
8+
Endian,
9+
File,
10+
FileSystem,
11+
convert_int8,
12+
convert_int64,
13+
)
14+
from unblob.models import (
15+
Extractor,
16+
ExtractResult,
17+
Handler,
18+
HandlerDoc,
19+
HandlerType,
20+
HexString,
21+
Reference,
22+
ValidChunk,
23+
)
24+
25+
logger = get_logger()
26+
27+
28+
def _field_patp(
29+
file: File, _fs: FileSystem, current_path: str | None
30+
) -> tuple[str | None, bool]:
31+
# PATP: Path Property (1-byte length + string)
32+
len_bytes = file.read(1)
33+
if not len_bytes:
34+
return current_path, False
35+
length = convert_int8(len_bytes, Endian.LITTLE)
36+
return file.read(length).decode("utf-8", errors="ignore"), True
37+
38+
39+
def _field_data(
40+
file: File, fs: FileSystem, current_path: str | None
41+
) -> tuple[str | None, bool]:
42+
# DATA: Data Property (8-byte size + LZFSE/Raw blob)
43+
size_bytes = file.read(8)
44+
if not size_bytes:
45+
return current_path, False
46+
blob_size = convert_int64(size_bytes, Endian.LITTLE)
47+
compressed_data = file.read(blob_size)
48+
if current_path:
49+
try:
50+
fs.write_bytes(Path(current_path), lzfse.decompress(compressed_data))
51+
except Exception:
52+
fs.write_bytes(Path(current_path), compressed_data)
53+
return current_path, True
54+
55+
56+
def _field_lnkp(
57+
file: File, _fs: FileSystem, current_path: str | None
58+
) -> tuple[str | None, bool]:
59+
# LNKP: Symbolic Link Property (1-byte length + string)
60+
len_bytes = file.read(1)
61+
if not len_bytes:
62+
return current_path, True
63+
length = convert_int8(len_bytes, Endian.LITTLE)
64+
target = file.read(length).decode("utf-8", errors="ignore")
65+
if current_path:
66+
logger.debug("atlas symlink found", source=current_path, target=target)
67+
return current_path, True
68+
69+
70+
def _field_xata(
71+
file: File, _fs: FileSystem, current_path: str | None
72+
) -> tuple[str | None, bool]:
73+
# XATA: Extended Attributes (skip or parse CRC)
74+
len_bytes = file.read(1)
75+
if not len_bytes:
76+
return current_path, True
77+
length = convert_int8(len_bytes, Endian.LITTLE)
78+
current_pos = file.tell()
79+
file.seek(0, io.SEEK_END)
80+
file_size = file.tell()
81+
file.seek(current_pos)
82+
skip_bytes = length + 4
83+
if current_pos + skip_bytes > file_size:
84+
logger.warning("Invalid XATA field length, stopping parse")
85+
return current_path, False
86+
file.seek(skip_bytes, io.SEEK_CUR)
87+
return current_path, True
88+
89+
90+
def _field_typ1(
91+
file: File, _fs: FileSystem, current_path: str | None
92+
) -> tuple[str | None, bool]:
93+
# TYP1: Entry Type (1 byte)
94+
file.seek(1, io.SEEK_CUR)
95+
return current_path, True
96+
97+
98+
_FIELD_HANDLERS = {
99+
"PATP": _field_patp,
100+
"DATA": _field_data,
101+
"LNKP": _field_lnkp,
102+
"XATA": _field_xata,
103+
"TYP1": _field_typ1,
104+
}
105+
106+
107+
class AppleArchiveExtractor(Extractor):
108+
def extract(self, inpath: Path, outdir: Path) -> ExtractResult:
109+
fs = FileSystem(outdir)
110+
111+
with File.from_path(inpath) as file:
112+
magic = file.read(4)
113+
if magic != b"AA01":
114+
return ExtractResult(reports=[])
115+
116+
current_path: str | None = None
117+
118+
while True:
119+
field_tag = file.read(4)
120+
if len(field_tag) < 4:
121+
break
122+
tag = field_tag.decode("ascii", errors="ignore")
123+
handler = _FIELD_HANDLERS.get(tag)
124+
if handler is None:
125+
continue
126+
current_path, ok = handler(file, fs, current_path)
127+
if not ok:
128+
break
129+
130+
return ExtractResult(reports=fs.problems)
131+
132+
133+
class AppleArchiveHandler(Handler):
134+
NAME = "apple_archive"
135+
PATTERNS = [HexString("41 41 30 31")] # "AA01"
136+
EXTRACTOR = AppleArchiveExtractor()
137+
138+
DOC = HandlerDoc(
139+
name="Apple Archive",
140+
description="Apple Archive is Apple's proprietary archive format introduced with macOS Big Sur, used for distributing macOS software updates and installers. Files begin with the AA01 magic and contain field-tagged entries encoding paths, LZFSE-compressed data blobs, symbolic links, and extended attributes.",
141+
handler_type=HandlerType.ARCHIVE,
142+
vendor="Apple",
143+
references=[
144+
Reference(
145+
title="Apple Archive - Apple Developer Documentation",
146+
url="https://developer.apple.com/documentation/applearchive",
147+
),
148+
],
149+
limitations=[
150+
"Only PATP/DATA/LNKP/XATA/TYP1 field tags are handled; other tags are silently skipped",
151+
"Symlink targets are logged but not created in the output directory",
152+
],
153+
)
154+
155+
def calculate_chunk(self, file: File, start_offset: int) -> ValidChunk | None:
156+
file.seek(0, io.SEEK_END)
157+
end_offset = file.tell()
158+
return ValidChunk(start_offset=start_offset, end_offset=end_offset)
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
version https://git-lfs.github.com/spec/v1
2+
oid sha256:799bc248bb9059d9242b718bbb5fce67e131448465c4b6357eb4e16a9863ed9b
3+
size 178
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
version https://git-lfs.github.com/spec/v1
2+
oid sha256:f28158d29de02fe86511384bbd3496ed90e5c81d9d9ade42c224e8be654d1692
3+
size 120
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
version https://git-lfs.github.com/spec/v1
2+
oid sha256:b0af5bd266e065093097b08f784ac5a048dd2812259482f43df3741db2de4773
3+
size 200

0 commit comments

Comments
 (0)