Skip to content

Commit caba1ee

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 caba1ee

4 files changed

Lines changed: 194 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: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
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+
APPLE_ARCHIVE_C_DEFINITIONS = r"""
28+
typedef struct field_header {
29+
char tag[4];
30+
uint8_t length;
31+
} field_header_t;
32+
33+
typedef struct data_field {
34+
char tag[4];
35+
uint64_t size;
36+
} data_field_t;
37+
"""
38+
39+
40+
def _field_patp(
41+
file: File, _fs: FileSystem, current_path: str | None
42+
) -> tuple[str | None, bool]:
43+
# PATP: Path Property (1-byte length + string)
44+
len_bytes = file.read(1)
45+
if not len_bytes:
46+
return current_path, False
47+
length = convert_int8(len_bytes, Endian.LITTLE)
48+
return file.read(length).decode("utf-8", errors="ignore"), True
49+
50+
51+
def _field_data(
52+
file: File, fs: FileSystem, current_path: str | None
53+
) -> tuple[str | None, bool]:
54+
# DATA: Data Property (8-byte size + LZFSE/Raw blob)
55+
size_bytes = file.read(8)
56+
if not size_bytes:
57+
return current_path, False
58+
blob_size = convert_int64(size_bytes, Endian.LITTLE)
59+
compressed_data = file.read(blob_size)
60+
if current_path:
61+
try:
62+
fs.write_bytes(Path(current_path), lzfse.decompress(compressed_data))
63+
except Exception:
64+
fs.write_bytes(Path(current_path), compressed_data)
65+
return current_path, True
66+
67+
68+
def _field_lnkp(
69+
file: File, _fs: FileSystem, current_path: str | None
70+
) -> tuple[str | None, bool]:
71+
# LNKP: Symbolic Link Property (1-byte length + string)
72+
len_bytes = file.read(1)
73+
if not len_bytes:
74+
return current_path, True
75+
length = convert_int8(len_bytes, Endian.LITTLE)
76+
target = file.read(length).decode("utf-8", errors="ignore")
77+
if current_path:
78+
logger.debug("atlas symlink found", source=current_path, target=target)
79+
return current_path, True
80+
81+
82+
def _field_xata(
83+
file: File, _fs: FileSystem, current_path: str | None
84+
) -> tuple[str | None, bool]:
85+
# XATA: Extended Attributes (skip or parse CRC)
86+
len_bytes = file.read(1)
87+
if not len_bytes:
88+
return current_path, True
89+
length = convert_int8(len_bytes, Endian.LITTLE)
90+
current_pos = file.tell()
91+
file.seek(0, io.SEEK_END)
92+
file_size = file.tell()
93+
file.seek(current_pos)
94+
skip_bytes = length + 4
95+
if current_pos + skip_bytes > file_size:
96+
logger.warning("Invalid XATA field length, stopping parse")
97+
return current_path, False
98+
file.seek(skip_bytes, io.SEEK_CUR)
99+
return current_path, True
100+
101+
102+
def _field_typ1(
103+
file: File, _fs: FileSystem, current_path: str | None
104+
) -> tuple[str | None, bool]:
105+
# TYP1: Entry Type (1 byte)
106+
file.seek(1, io.SEEK_CUR)
107+
return current_path, True
108+
109+
110+
_FIELD_HANDLERS = {
111+
"PATP": _field_patp,
112+
"DATA": _field_data,
113+
"LNKP": _field_lnkp,
114+
"XATA": _field_xata,
115+
"TYP1": _field_typ1,
116+
}
117+
118+
119+
class AppleArchiveExtractor(Extractor):
120+
def extract(self, inpath: Path, outdir: Path) -> ExtractResult:
121+
fs = FileSystem(outdir)
122+
123+
with File.from_path(inpath) as file:
124+
magic = file.read(4)
125+
if magic != b"AA01":
126+
return ExtractResult(reports=[])
127+
128+
current_path: str | None = None
129+
130+
while True:
131+
field_tag = file.read(4)
132+
if len(field_tag) < 4:
133+
break
134+
tag = field_tag.decode("ascii", errors="ignore")
135+
handler = _FIELD_HANDLERS.get(tag)
136+
if handler is None:
137+
continue
138+
current_path, ok = handler(file, fs, current_path)
139+
if not ok:
140+
break
141+
142+
return ExtractResult(reports=fs.problems)
143+
144+
145+
class AppleArchiveHandler(Handler):
146+
NAME = "apple_archive"
147+
PATTERNS = [HexString("41 41 30 31")] # "AA01"
148+
EXTRACTOR = AppleArchiveExtractor()
149+
150+
DOC = HandlerDoc(
151+
name="Apple Archive",
152+
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.",
153+
handler_type=HandlerType.ARCHIVE,
154+
vendor="Apple",
155+
references=[
156+
Reference(
157+
title="Apple Archive - Apple Developer Documentation",
158+
url="https://developer.apple.com/documentation/applearchive",
159+
),
160+
],
161+
limitations=[
162+
"Only PATP/DATA/LNKP/XATA/TYP1 field tags are handled; other tags are silently skipped",
163+
"Symlink targets are logged but not created in the output directory",
164+
],
165+
)
166+
167+
def calculate_chunk(self, file: File, start_offset: int) -> ValidChunk | None:
168+
file.seek(0, io.SEEK_END)
169+
end_offset = file.tell()
170+
return ValidChunk(start_offset=start_offset, end_offset=end_offset)

0 commit comments

Comments
 (0)