Skip to content

Commit 2c5ab62

Browse files
committed
stub, util: Support installing from OTA images
Signed-off-by: Hector Martin <[email protected]>
1 parent 9768e88 commit 2c5ab62

2 files changed

Lines changed: 136 additions & 26 deletions

File tree

src/stub.py

Lines changed: 42 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ def __init__(self, sysinfo, dutil, osinfo):
1818
self.copy_idata = []
1919
self.stub_info = {}
2020
self.pkg = None
21+
self.is_ota = False
2122

2223
def load_ipsw(self, ipsw_info):
2324
self.install_version = ipsw_info.version.split(maxsplit=1)[0]
@@ -27,7 +28,14 @@ def load_ipsw(self, ipsw_info):
2728
if base:
2829
url = base + "/" + os.path.split(url)[-1]
2930

30-
logging.info(f"IPSW URL: {url}")
31+
if not url.endswith(".ipsw"):
32+
self.is_ota = True
33+
34+
if self.is_ota:
35+
logging.info(f"OTA URL: {url}")
36+
else:
37+
logging.info(f"IPSW URL: {url}")
38+
3139
if url.startswith("http"):
3240
p_progress("Downloading macOS OS package info...")
3341
self.ucache = urlcache.URLCache(url)
@@ -146,30 +154,47 @@ def prepare_for_step2(self):
146154
if not os.path.exists(self.iapm_path):
147155
os.replace(self.iapm_dis_path, self.iapm_path)
148156

157+
def path(self, path):
158+
if not self.is_ota:
159+
return path
160+
161+
if path.startswith("BootabilityBundle"):
162+
return os.path.join("AssetData", "Restore", path)
163+
else:
164+
return os.path.join("AssetData", "boot", path)
165+
166+
def open(self, path):
167+
return self.pkg.open(self.path(path))
168+
149169
def install_files(self, cur_os):
150170
logging.info("StubInstaller.install_files()")
151171
logging.info(f"VGID: {self.osi.vgid}")
152172
logging.info(f"OS info: {self.osi}")
153173

154174
p_progress("Beginning stub OS install...")
155-
ipsw = self.pkg
156-
157175
self.get_paths()
158176

159177
logging.info("Parsing metadata...")
160178

161-
sysver = plistlib.load(ipsw.open("SystemVersion.plist"))
162-
manifest = plistlib.load(ipsw.open("BuildManifest.plist"))
163-
bootcaches = plistlib.load(ipsw.open("usr/standalone/bootcaches.plist"))
179+
sysver = plistlib.load(self.open("SystemVersion.plist"))
180+
manifest = plistlib.load(self.open("BuildManifest.plist"))
181+
bootcaches = plistlib.load(self.open("usr/standalone/bootcaches.plist"))
164182
self.flush_progress()
165183

184+
if self.is_ota:
185+
variant = "macOS Customer Software Update"
186+
behavior = "Update"
187+
else:
188+
variant = "macOS Customer"
189+
behavior = "Erase"
190+
166191
self.manifest = manifest
167192
for identity in manifest["BuildIdentities"]:
168193
if (identity["ApBoardID"] != f'0x{self.sysinfo.board_id:02X}' or
169194
identity["ApChipID"] != f'0x{self.sysinfo.chip_id:04X}' or
170195
identity["Info"]["DeviceClass"] != self.sysinfo.device_class or
171-
identity["Info"]["RestoreBehavior"] != "Erase" or
172-
identity["Info"]["Variant"] != "macOS Customer"):
196+
identity["Info"]["RestoreBehavior"] != behavior or
197+
identity["Info"]["Variant"] != variant):
173198
continue
174199
break
175200
else:
@@ -244,12 +269,13 @@ def install_files(self, cur_os):
244269
self.extract_file("BootabilityBundle/Restore/Firmware/Bootability.dmg.trustcache",
245270
os.path.join(restore_bundle, "Bootability/Bootability.trustcache"))
246271

247-
self.extract_tree("Firmware/Manifests/restore/macOS Customer/", restore_bundle)
272+
self.extract_tree(f"Firmware/Manifests/restore/{variant}/", restore_bundle)
248273

249274
copied = set()
250275
self.kernel_path = None
251276
for key, val in identity["Manifest"].items():
252-
if key in ("BaseSystem", "OS", "Ap,SystemVolumeCanonicalMetadata"):
277+
if key in ("BaseSystem", "OS", "Ap,SystemVolumeCanonicalMetadata",
278+
"RestoreRamDisk", "RestoreTrustCache"):
253279
continue
254280
if key.startswith("Cryptex"):
255281
continue
@@ -303,8 +329,12 @@ def install_files(self, cur_os):
303329
os.makedirs(basesystem_path, exist_ok=True)
304330

305331
logging.info("Extracting arm64eBaseSystem.dmg")
306-
self.copy_compress(identity["Manifest"]["BaseSystem"]["Info"]["Path"],
307-
os.path.join(basesystem_path, "arm64eBaseSystem.dmg"))
332+
if self.is_ota:
333+
self.copy_recompress("AssetData/payloadv2/basesystem_patches/arm64eBaseSystem.dmg",
334+
os.path.join(basesystem_path, "arm64eBaseSystem.dmg"))
335+
else:
336+
self.copy_compress(identity["Manifest"]["BaseSystem"]["Info"]["Path"],
337+
os.path.join(basesystem_path, "arm64eBaseSystem.dmg"))
308338
self.flush_progress()
309339

310340
p_progress("Wrapping up...")

src/util.py

Lines changed: 94 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# SPDX-License-Identifier: MIT
2-
import re, logging, sys, os, stat, shutil, struct, subprocess, zlib, time
2+
import re, logging, sys, os, stat, shutil, struct, subprocess, zlib, time, hashlib, lzma
33
from ctypes import *
44

55
if sys.platform == 'darwin':
@@ -131,11 +131,54 @@ def input_prompt(*args):
131131
logging.info(f"INPUT: {val!r}")
132132
return val
133133

134+
class PBZX:
135+
def __init__(self, istream, osize):
136+
self.istream = istream
137+
self.osize = osize
138+
self.buf = b""
139+
self.p = 0
140+
self.total_read = 0
141+
142+
hdr = istream.read(12)
143+
magic, blocksize = struct.unpack(">4sQ", hdr)
144+
assert magic == b"pbzx"
145+
146+
def read(self, size):
147+
if (len(self.buf) - self.p) >= size:
148+
d = self.buf[self.p:self.p + size]
149+
self.p += len(d)
150+
return d
151+
152+
bp = [self.buf[self.p:]]
153+
self.p = 0
154+
155+
avail = len(bp[0])
156+
while avail < size and self.total_read < self.osize:
157+
hdr = self.istream.read(16)
158+
if not hdr:
159+
raise Exception("End of compressed data but more expected")
160+
161+
uncompressed_size, compressed_size = struct.unpack(">QQ", hdr)
162+
blk = self.istream.read(compressed_size)
163+
if uncompressed_size != compressed_size:
164+
blk = lzma.decompress(blk, format=lzma.FORMAT_XZ)
165+
bp.append(blk)
166+
avail += len(blk)
167+
self.total_read += len(blk)
168+
169+
self.buf = b"".join(bp)
170+
d = self.buf[self.p:self.p + size]
171+
self.p += len(d)
172+
return d
173+
134174
class PackageInstaller:
135175
def __init__(self):
136176
self.verbose = "-v" in sys.argv
137177
self.printed_progress = False
138178

179+
def path(self, path):
180+
return path
181+
139182
def flush_progress(self):
140183
if self.ucache and self.ucache.flush_progress():
141184
self.printed_progress = False
@@ -145,8 +188,10 @@ def flush_progress(self):
145188
self.printed_progress = False
146189

147190
def extract(self, src, dest):
148-
logging.info(f" {src} -> {dest}/")
149-
self.pkg.extract(src, dest)
191+
dest_path = os.path.join(dest, src)
192+
dest_dir = os.path.split(dest_path)[0]
193+
os.makedirs(dest_dir, exist_ok=True)
194+
self.extract_file(src, dest_path)
150195

151196
def fdcopy(self, sfd, dfd, size=None):
152197
BLOCK = 16 * 1024 * 1024
@@ -171,10 +216,24 @@ def fdcopy(self, sfd, dfd, size=None):
171216
sys.stdout.write("\033[3G100.00% ")
172217
sys.stdout.flush()
173218

219+
def copy_recompress(self, src, path):
220+
# For BXDIFF50 stuff in OTA images
221+
bxstream = self.pkg.open(src)
222+
assert bxstream.read(8) == b"BXDIFF50"
223+
bxstream.read(8)
224+
size, csize, zxsize = struct.unpack("<3Q", bxstream.read(24))
225+
assert csize == 0
226+
sha1 = bxstream.read(20)
227+
istream = PBZX(bxstream, size)
228+
self.stream_compress(istream, size, path, sha1=sha1)
229+
174230
def copy_compress(self, src, path):
175231
info = self.pkg.getinfo(src)
176232
size = info.file_size
177233
istream = self.pkg.open(src)
234+
self.stream_compress(istream, size, path, crc=info.CRC)
235+
236+
def stream_compress(self, istream, size, path, crc=None, sha1=None):
178237
with open(path, 'wb'):
179238
pass
180239
num_chunks = (size + CHUNK_SIZE - 1) // CHUNK_SIZE
@@ -212,20 +271,39 @@ def copy_compress(self, src, path):
212271
"66706D630C000000" + "".join(f"{((size >> 8*i) & 0xff):02x}" for i in range(8)),
213272
path], check=True)
214273
os.chflags(path, stat.UF_COMPRESSED)
215-
crc = 0
216-
with open(path, 'rb') as result_file:
217-
while 1:
218-
data = result_file.read(CHUNK_SIZE)
219-
if len(data) == 0:
220-
break
221-
crc = zlib.crc32(data, crc)
222-
if crc != info.CRC:
223-
raise Exception('Internal error: failed to compress file: crc mismatch')
274+
275+
if sha1 is not None:
276+
sha = hashlib.sha1()
277+
with open(path, 'rb') as result_file:
278+
while 1:
279+
data = result_file.read(CHUNK_SIZE)
280+
if len(data) == 0:
281+
break
282+
sha.update(data)
283+
if sha.digest() != sha1:
284+
raise Exception('Internal error: failed to recompress file: SHA1 mismatch')
285+
elif crc is not None:
286+
calc_crc = 0
287+
with open(path, 'rb') as result_file:
288+
while 1:
289+
data = result_file.read(CHUNK_SIZE)
290+
if len(data) == 0:
291+
break
292+
calc_crc = zlib.crc32(data, calc_crc)
293+
if crc != calc_crc:
294+
raise Exception('Internal error: failed to compress file: crc mismatch')
295+
else:
296+
raise Exception("No checksum available")
224297

225298
sys.stdout.write("\033[3G100.00% ")
226299
sys.stdout.flush()
227300

228-
def extract_file(self, src, dest, optional=True):
301+
def extract_file(self, src, dest, optional=False):
302+
src = self.path(src)
303+
self._extract_file(src, dest, optional)
304+
305+
def _extract_file(self, src, dest, optional=False):
306+
logging.info(f" {src} -> {dest}")
229307
try:
230308
info = self.pkg.getinfo(src)
231309
with self.pkg.open(src) as sfd, \
@@ -235,10 +313,12 @@ def extract_file(self, src, dest, optional=True):
235313
except KeyError:
236314
if not optional:
237315
raise
316+
logging.info(f" (SKIPPED)")
238317
if self.verbose:
239318
self.flush_progress()
240319

241320
def extract_tree(self, src, dest):
321+
src = self.path(src)
242322
if src[-1] != "/":
243323
src += "/"
244324
logging.info(f" {src}* -> {dest}")
@@ -264,7 +344,7 @@ def extract_tree(self, src, dest):
264344
os.unlink(destpath)
265345
os.symlink(link, destpath)
266346
else:
267-
self.extract_file(name, destpath)
347+
self._extract_file(name, destpath)
268348

269349
if self.verbose:
270350
self.flush_progress()

0 commit comments

Comments
 (0)