The next installed run boots with the mirror-slab executable 758535d9 installed and must replace it with SnapMetal's ADRENO_SETTINGS_ICONS build (lenient, ADRENO_PIPELINE, runtime v7: AdrenoMetal 378ff525, CDHash c76a4677) from single-user mode. installed_fresh: the trust additions become the settings-icons set's (the mirror-slab set's 46 rows plus c76a4677: cdc784df, payload 247598bc); the mirror-slab set stays as ADDITIONS_MIRRORSLAB for rollback, and every earlier CDHash stays trusted, so --restore still boots. deploy-guest-plugin: the executable's base is the mirror-slab 758535d9 and its payload the 36,670-byte AdrenoMetal-758535d9.bsdiff; the runtime and CodeResources are already the installed (wsabort) ones and are skipped. The set, build/current-assets/metal-stage-settingsicons-20261024, is a local (ignored) input: AdrenoMetal, AdrenoMetal-758535d9.bsdiff, trust-additions.json, trustcache.payload and SET.json. Evidence: the prepare-time trust check (configure_guest, then trust_cache_spec(ADDITIONS).load(FIRMWARE) against TRUST_PAYLOAD and TRUST_SHA) passes for the installed, -input and -input-touchpad profiles, for this set and the mirror-slab rollback; tools/test-deploy-guest-plugin.py PASS 21 checks (install, again, restore, refuse) over the console stand-in with 3% loss.
367 lines
24 KiB
Python
Executable File
367 lines
24 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
# SPDX-License-Identifier: MIT
|
|
"""Install AdrenoMetal bundle files from the guest's own console shell.
|
|
|
|
docs/ADRENO-DEPTH-STENCIL-20261017.md (deployment); the tool and its checks: docs/ADRENO-WINDOWSERVER-TARGETS-20261017.md
|
|
(Deployment). The guest writes its own Data volume, as every installed run does (--ans-writable); the host only types
|
|
into the console (tools/guest-console.py's protocol, one owner for the whole sequence). Since 2026-10-19
|
|
(docs/USERSPACE-GRAPHICS-20261019.md, compute runtime) a set of bundle files changes together: the executable, the
|
|
compiler runtime (a bsdiff of the 70 MB dylib, 632 KB to 1 MB, tools/bsdiff.c) and CodeResources (sent whole). Every new
|
|
Mach-O's CDHash is in the attempt's trust cache beside the old ones.
|
|
|
|
python3 tools/deploy-guest-plugin.py --device udp://en6 --launch-record build/PREFIX-early-stop-launch.json \\
|
|
--record SCRATCH/deploy.json [--continue-boot] [--restore]
|
|
|
|
Steps; every one is recorded, and any mismatch stops before the next write:
|
|
1. a shell answers on the console (retried: in single-user mode launchd starts it early)
|
|
2. the Data volume is mounted, by the single-user banner's steps when it is not
|
|
3. each installed file is its base or already the new one; anything else refuses before any write; all new: 8
|
|
4. for each file still at its base, its patch (or the whole file) is typed as base64 in short printf commands, one
|
|
numbered file per chunk, each command acknowledged; one listing of every chunk's SHA-256 finds any chunk not held
|
|
intact, which is resent (up to ROUNDS rounds); the joined text and the decoded bytes must match their SHA-256
|
|
5. bspatch makes each new file beside /var/tmp; its SHA-256 must be the new one
|
|
6. each installed file is copied to /var/db/Snapintosh/backup (SHA-256 checked), once
|
|
7. install, the executable last: each copied into place under a temporary name with the installed file's owner and
|
|
mode, then renamed over it; every installed SHA-256 (and each Mach-O's CDHash) must be the new one
|
|
8. sync; with --continue-boot, `exit` (single-user mode continues to multi-user)
|
|
--restore puts each file's backup back where it differs from its base (steps 1-2, the backups' SHA-256, copy, rename).
|
|
--guest-root and --no-mount exist for the offline test (tools/test-deploy-guest-plugin.py).
|
|
"""
|
|
import argparse
|
|
import base64
|
|
import hashlib
|
|
import importlib.util
|
|
import json
|
|
from pathlib import Path
|
|
import shlex
|
|
import sys
|
|
import time
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
# 2026-10-19 compute runtime set (docs/USERSPACE-GRAPHICS-20261019.md): v78 installed executable 7d3c8333 over the
|
|
# dockicons binary; the runtime dylib 2aca7392 and CodeResources a8f894d4 are unchanged since 2026-09-28 (metal-stage).
|
|
# Earlier sets stay in build/current-assets (metal-stage-graphics-20261019, -dockicons-20261017, ...) and every
|
|
# earlier CDHash stays trusted, so --restore (or an older set) still boots.
|
|
# v81 set (CoreImage compute shapes, lines): only the executable changes, over v79's 35e359e6; the runtime and
|
|
# CodeResources are v79's, already installed (their rows are skipped as new).
|
|
# v84 set (aligned IOSurface shadows, CoreImage fill): only the executable changed, over v81's 7660b284.
|
|
# v86 set (RenderBox icons): runtime v3 (air2ir3 pointer phis), its CodeResources and the executable, each over
|
|
# what v84 installed (runtime 9f33052e and CodeResources b48dd5ff from the compute set, executable bcd73081).
|
|
COMPUTE = ROOT / 'build/current-assets/metal-stage-compute-20261019'
|
|
CI = ROOT / 'build/current-assets/metal-stage-ci-20261019'
|
|
# v88 set (RenderBox custom effects): runtime v4, its CodeResources and the resealed executable over v86's icons set.
|
|
SHADOW = ROOT / 'build/current-assets/metal-stage-shadow-20261019'
|
|
ICONS = ROOT / 'build/current-assets/metal-stage-icons-20261019'
|
|
ASSETS = ROOT / 'build/current-assets/metal-stage-effects-20261019'
|
|
# Liquid Glass / corners set (docs/USERSPACE-GRAPHICS-20261019.md, "CoreAnimation's generic fallback"): only the
|
|
# executable changes, over the effects set's 00632e9d; the runtime and CodeResources are the effects set's, already
|
|
# installed (their rows are skipped as new).
|
|
GLASS = ROOT / 'build/current-assets/metal-stage-glass-20261019'
|
|
# 16-bit derivatives set (docs/USERSPACE-GRAPHICS-20261019.md, "Fix: derivatives of 16-bit values at 32 bits"): runtime
|
|
# v5 over the effects set's v4, its CodeResources, and the executable resealed for it over the glass set's b3c554ee.
|
|
DERIV = ROOT / 'build/current-assets/metal-stage-deriv-20261019'
|
|
# Mip levels set (docs/USERSPACE-GRAPHICS-20261019.md, "Fix: mip levels the GPU can sample"): only the executable
|
|
# changes, over the deriv set's 5624188c; runtime v5 and its CodeResources are the deriv set's, already installed.
|
|
MIPLEVELS = ROOT / 'build/current-assets/metal-stage-miplevels-20261019'
|
|
# Framebuffer fetch set (docs/USERSPACE-GRAPHICS-20261019.md, "Window corner wedges"): only the executable changes, over
|
|
# the mip levels set's 4a4fc437 (isFramebufferReadSupported YES); runtime v5 and its CodeResources stay installed.
|
|
FBFETCH = ROOT / 'build/current-assets/metal-stage-fbfetch-20261019'
|
|
# Compile speed set (docs/USERSPACE-GRAPHICS-20261019.md, "Speed: shader compile time"): runtime v6 over the deriv set's
|
|
# v5 (identical code, faster compiles), its CodeResources, and the framebuffer-fetch executable resealed for it.
|
|
COMPILESPEED = ROOT / 'build/current-assets/metal-stage-compilespeed-20261019'
|
|
# Speed batch (docs/USERSPACE-GRAPHICS-20261019.md, "Speed batch"): only the executable changes, over the compile
|
|
# speed set's d13c7a7b (the display copy cost in the stats line); runtime v6 and CodeResources stay installed.
|
|
PACING = ROOT / 'build/current-assets/metal-stage-pacing-20261019'
|
|
# UBO staging set (docs/USERSPACE-GRAPHICS-20261019.md, "Speed: CoreAnimation uniforms staged by the kilobyte"): only
|
|
# the executable changes, over the pacing set's 05439b9a; runtime v6 and CodeResources stay installed.
|
|
UBO = ROOT / 'build/current-assets/metal-stage-ubo-20261019'
|
|
# Drag speed set (docs/PLUGIN-DRAG-SPEED-20261020.md: per-program draw plans, staging arena pool, single-copy packages):
|
|
# only the executable changes, over the UBO set's 3365e015; runtime v6 and CodeResources stay installed.
|
|
DRAGSPEED = ROOT / 'build/current-assets/metal-stage-dragspeed-20261020'
|
|
# WindowServer abort set (docs/WINDOWSERVER-APL-ABORT-20261020.md): runtime v7 over the compile speed set's v6 (AIR 2.2
|
|
# texture reads, every call operand bounds-checked), its CodeResources, and the executable (CoreAnimation's APL filter
|
|
# off) resealed for it over the drag speed set's 1df76024.
|
|
WSABORT = ROOT / 'build/current-assets/metal-stage-wsabort-20261020'
|
|
# Arena trim set (docs/PLUGIN-ARENA-TRIM-20261021.md: UBO staging arenas that give back GPU address space): only the
|
|
# executable changes, over the WindowServer abort set's 52ed694b; runtime v7 and its CodeResources stay installed.
|
|
ARENATRIM = ROOT / 'build/current-assets/metal-stage-arenatrim-20261021'
|
|
# Combined set (agents/present-sync + agents/gfx-correctness + agents/turnip-audit: commitAndWaitUntilSubmitted waits for
|
|
# the kernel submission, GPU mipmap generation, integer render targets and textures, BR-thread blit/compute passes,
|
|
# render passes split across packages): only the executable changes, over the arena trim set's 496e79db; runtime v7 and
|
|
# its CodeResources stay installed.
|
|
COMBINED = ROOT / 'build/current-assets/metal-stage-combined-20261021'
|
|
# gpu-perf set (agents/gpu-perf: unaligned constant buffers read from a per-command-buffer mirror copied once before the
|
|
# first pass that needs it, instead of a CP_MEMCPY with CCU/UCHE flushes and two WFIs inside the pass per binding; GPU
|
|
# work counters in the diagnostics stats line): only the executable changes, over the combined set's 043dbf2d; runtime
|
|
# v7 and its CodeResources stay installed.
|
|
GPUPERF = ROOT / 'build/current-assets/metal-stage-gpuperf-20261021'
|
|
# Pipeline set (agents/gpu-pipeline: gpu-perf's source plus gpu-pipeline-plugin.patch built with -DADRENO_PIPELINE=1;
|
|
# IOSurface shadows wait for the command buffers using them on the GPU): only the executable changes, over gpu-perf's
|
|
# 9cd9a4c6; runtime v7 and its CodeResources stay installed.
|
|
PIPELINE = ROOT / 'build/current-assets/metal-stage-pipeline-20261021'
|
|
# Pipeline2 set (agents/gpu-pipeline: the pipeline source plus gpu-perf's blit-chunk-over-gpuperf.patch, buffer copies
|
|
# as CP_MEMCPYs of at most 1 MiB): only the executable changes, over gpu-perf's 9cd9a4c6 (what the guest runs);
|
|
# runtime v7 and its CodeResources stay installed.
|
|
PIPELINE2 = ROOT / 'build/current-assets/metal-stage-pipeline2-20261021'
|
|
# Mirror-slab set (agents/gpu-perf FAILURE-MODES.md R: UBO mirror regions from a few 1 MiB slabs allocated once per device
|
|
# and never released, instead of staging arenas that the pool frees after 300 ms idle and the next frame allocates
|
|
# again; b12v16's SPTM UPDATE_DISJOINT_MULTIPAGE x13): only the executable changes, over pipeline2's 8f712c36; runtime v7
|
|
# and its CodeResources stay installed.
|
|
MIRRORSLAB = ROOT / 'build/current-assets/metal-stage-mirrorslab-20261021'
|
|
# Settings-icons set (SnapMetal ADRENO_SETTINGS_ICONS, built with ADRENO_LENIENT=1 ADRENO_PIPELINE=1 and runtime v7:
|
|
# CoreImage compute kernels that sample sRGB and grey+alpha images, iconservicesagent's icns, device and document icons
|
|
# inside System Settings panes; multi-input kernels only when the kext publishes AdrenoIBComputeSamplers): only the
|
|
# executable changes, over the mirror-slab 758535d9; runtime v7 and its CodeResources stay installed.
|
|
SETTINGSICONS = ROOT / 'build/current-assets/metal-stage-settingsicons-20261024'
|
|
PREVIOUS = ASSETS
|
|
ORIGINAL = ROOT / 'build/current-assets/metal-stage/AdrenoMetal.bundle/Contents'
|
|
CONTENTS = 'System/Volumes/Data/Library/GPUBundles/AdrenoMetal.bundle/Contents'
|
|
FILES = [ # installed in this order: the executable last
|
|
dict(name='libAdrenoCompiler.dylib', path=f'{CONTENTS}/Frameworks/libAdrenoCompiler.dylib',
|
|
base=COMPILESPEED / 'libAdrenoCompiler.dylib', new=WSABORT / 'libAdrenoCompiler.dylib',
|
|
patch=WSABORT / 'libAdrenoCompiler-059fcc6e.bsdiff', cdhash='ab4ccbd4806696e30cbe8ca6f8ff271e89cea7cd'),
|
|
dict(name='CodeResources', path=f'{CONTENTS}/_CodeSignature/CodeResources',
|
|
base=COMPILESPEED / 'CodeResources', new=WSABORT / 'CodeResources', patch=None, cdhash=None),
|
|
dict(name='AdrenoMetal', path=f'{CONTENTS}/MacOS/AdrenoMetal', base=MIRRORSLAB / 'AdrenoMetal',
|
|
new=SETTINGSICONS / 'AdrenoMetal', patch=SETTINGSICONS / 'AdrenoMetal-758535d9.bsdiff',
|
|
cdhash='c76a4677c74623d78f51f578cc4f2978ea73ff26'),
|
|
]
|
|
PLUGIN, BASE, NEW, NEW_CDHASH = FILES[-1]['path'], FILES[-1]['base'], FILES[-1]['new'], FILES[-1]['cdhash']
|
|
WORK = 'System/Volumes/Data/private/var/tmp/snapintosh-plugin'
|
|
BACKUP = 'System/Volumes/Data/private/var/db/Snapintosh/backup'
|
|
MOUNT = '/sbin/mount -P 1; /usr/libexec/init_data_protection; /sbin/mount -P 2'
|
|
CHUNK = 512 # base64 characters per command: the whole line stays far below MAX_CANON (1024)
|
|
ROUNDS = 3 # resend rounds for chunks the guest does not hold intact
|
|
sha = lambda data: hashlib.sha256(data).hexdigest()
|
|
|
|
spec = importlib.util.spec_from_file_location('guest_console', ROOT / 'tools/guest-console.py')
|
|
assert spec and spec.loader
|
|
gc = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(gc)
|
|
|
|
|
|
class Refused(Exception):
|
|
pass
|
|
|
|
|
|
class Shell:
|
|
def __init__(self, args):
|
|
session = gc.session_of(argparse.Namespace(session=args.session, launch_record=args.launch_record))
|
|
self.link = gc.Link(device=args.device, target=args.target)
|
|
self.console = gc.Console(self.link, session, int(args.owner, 16), False, args.wait_ms / 1000, None, None)
|
|
self.poll_s, self.steps = args.poll_ms / 1000, []
|
|
|
|
def run(self, step, command, timeout=60, ok=(0,)):
|
|
started = time.monotonic()
|
|
result = gc.run_command(self.console, command, timeout, self.poll_s, False)
|
|
row: dict = dict(step=step, command=command if len(command) < 300 else command[:120] + '...',
|
|
seconds=round(time.monotonic() - started, 3))
|
|
if result is None:
|
|
row.update(status=None, error=(self.console.error or {}).get('status_text', 'no end marker'))
|
|
self.steps.append(row)
|
|
raise Refused(f'{step}: no answer ({row["error"]})')
|
|
status, text = result
|
|
row.update(status=status, output=text.decode('utf-8', 'replace')[-2000:])
|
|
if step != 'chunk':
|
|
self.steps.append(row)
|
|
if status not in ok:
|
|
raise Refused(f'{step}: exit status {status}: {row["output"][-300:]}')
|
|
return text.decode('utf-8', 'replace')
|
|
|
|
def digest(self, step, path):
|
|
text = self.run(step, f'shasum -a 256 {shlex.quote(path)}')
|
|
words = [line.split()[0] for line in text.splitlines() if line.strip().endswith(path)]
|
|
if len(words) != 1:
|
|
raise Refused(f'{step}: no digest for {path}: {text[-300:]}')
|
|
return words[0]
|
|
|
|
def type_exit(self):
|
|
self.console.type(b'exit\r')
|
|
deadline = time.monotonic() + 5
|
|
while self.console.pending and time.monotonic() < deadline:
|
|
self.console.exchange()
|
|
|
|
|
|
def wait_for_shell(shell, attempts, timeout):
|
|
for n in range(attempts):
|
|
try:
|
|
if 'snapintosh-ready' in shell.run('shell', 'echo snapintosh-ready', timeout=timeout):
|
|
return n + 1
|
|
except Refused:
|
|
if shell.console.error:
|
|
raise
|
|
raise Refused(f'no shell answered in {attempts} attempts')
|
|
|
|
|
|
def mount_data(shell, args):
|
|
if args.no_mount:
|
|
return 'skipped (--no-mount)'
|
|
mounted = shell.run('data-check', "/sbin/mount | grep -c ' on /System/Volumes/Data ' || true")
|
|
if mounted.strip().splitlines()[-1:] == ['0']:
|
|
shell.run('data-mount', MOUNT, timeout=180)
|
|
mounted = shell.run('data-check', "/sbin/mount | grep -c ' on /System/Volumes/Data ' || true")
|
|
if mounted.strip().splitlines()[-1:] != ['1']:
|
|
raise Refused(f'the Data volume is not mounted: {mounted[-200:]}')
|
|
return 'mounted'
|
|
|
|
|
|
def transfer(shell, args, work, f):
|
|
"""One file's payload, typed as base64 chunks, joined, decoded and (for a patch) applied: work/NAME.new."""
|
|
d = f'{work}/{f["name"]}'
|
|
text = f['text']
|
|
shell.run('work', f'mkdir -p {shlex.quote(d)}/chunks')
|
|
chunks = {f'c.{n:05d}': text[at:at + CHUNK] for n, at in enumerate(range(0, len(text), CHUNK))}
|
|
todo, rounds, started = list(chunks), [], time.monotonic()
|
|
for round_ in range(ROUNDS):
|
|
for name in todo:
|
|
if round_ == 0 and f'{f["name"]}/{name}' in args.test_skip_chunk:
|
|
continue # the offline test's stand-in for a lost chunk
|
|
shell.run('chunk', f"printf '%s' '{chunks[name]}' > {shlex.quote(d)}/chunks/{name}", timeout=30)
|
|
# Listings of at most 50 chunks (about 3.7 KB each): one listing of 1,646 (122 KB) outran the console's
|
|
# ring and lost lines on the stand-in although every chunk was intact.
|
|
held = {}
|
|
for group in sorted({(name[:5], name[5] < '5') for name in todo}):
|
|
pattern = f'{group[0]}[{"0-4" if group[1] else "5-9"}]?'
|
|
listing = shell.run('chunk-list', f'cd {shlex.quote(d)}/chunks && shasum -a 256 {pattern} 2>/dev/null',
|
|
timeout=60, ok=(0, 1))
|
|
held.update({words[1]: words[0] for words in (line.split() for line in listing.splitlines())
|
|
if len(words) == 2 and words[1] in chunks})
|
|
todo = [name for name in todo if held.get(name) != sha(chunks[name].encode())]
|
|
rounds.append(dict(resend=len(todo), names=todo[:20]))
|
|
if not todo:
|
|
break
|
|
if todo:
|
|
raise Refused(f'{f["name"]}: {len(todo)} chunks still not intact after {ROUNDS} rounds: {todo[:10]}')
|
|
shell.run('join', f'cd {shlex.quote(d)}/chunks && cat c.* > {shlex.quote(d)}/payload.b64', timeout=120)
|
|
if shell.digest('base64', f'{d}/payload.b64') != sha(text.encode()):
|
|
raise Refused(f'{f["name"]}: the typed base64 differs')
|
|
shell.run('decode', f'base64 -D -i {shlex.quote(d)}/payload.b64 -o {shlex.quote(d)}/payload')
|
|
if shell.digest('payload', f'{d}/payload') != f['payload_sha256']:
|
|
raise Refused(f'{f["name"]}: the decoded payload differs')
|
|
if f['patch']:
|
|
shell.run('bspatch', f'bspatch {shlex.quote(f["path"])} {shlex.quote(work)}/{f["name"]}.new {shlex.quote(d)}/payload',
|
|
timeout=600)
|
|
else:
|
|
shell.run('copy', f'cp {shlex.quote(d)}/payload {shlex.quote(work)}/{f["name"]}.new')
|
|
if shell.digest('candidate', f'{work}/{f["name"]}.new') != f['new_sha256']:
|
|
raise Refused(f'{f["name"]}: the new file differs')
|
|
return dict(name=f['name'], chunks=len(chunks), rounds=rounds, seconds=round(time.monotonic() - started, 1))
|
|
|
|
|
|
def main(argv=None):
|
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
where = ap.add_mutually_exclusive_group(required=True)
|
|
where.add_argument('--device')
|
|
where.add_argument('--target', help='host:port of a stand-in (the offline test)')
|
|
ap.add_argument('--launch-record')
|
|
ap.add_argument('--session')
|
|
ap.add_argument('--record', type=Path, required=True)
|
|
ap.add_argument('--owner', default='5eedc0de51', help='hex console owner, fixed for the whole sequence')
|
|
ap.add_argument('--continue-boot', action='store_true', help='type exit after the install (single-user mode)')
|
|
ap.add_argument('--restore', action='store_true', help='put the backed-up binary back')
|
|
ap.add_argument('--guest-root', default='/', help='prefix of every guest path (the offline test)')
|
|
ap.add_argument('--no-mount', action='store_true', help='skip the Data volume step (the offline test)')
|
|
ap.add_argument('--attempts', type=int, default=30)
|
|
ap.add_argument('--poll-ms', type=int, default=50)
|
|
ap.add_argument('--wait-ms', type=int, default=150)
|
|
ap.add_argument('--test-skip-chunk', action='append', default=[], help=argparse.SUPPRESS)
|
|
args = ap.parse_args(argv)
|
|
if args.record.exists():
|
|
print(f'{args.record} exists', file=sys.stderr)
|
|
return 64
|
|
root = args.guest_root.rstrip('/') + '/'
|
|
work, backup = root + WORK, root + BACKUP
|
|
files = []
|
|
for f in FILES:
|
|
base, new = f['base'].read_bytes(), f['new'].read_bytes()
|
|
payload = f['patch'].read_bytes() if f['patch'] else new
|
|
files.append(dict(name=f['name'], path=root + f['path'], base_sha256=sha(base), new_sha256=sha(new),
|
|
payload_sha256=sha(payload), payload_bytes=len(payload), patch=bool(f['patch']),
|
|
text=base64.b64encode(payload).decode(), cdhash=f['cdhash'],
|
|
backup=f'{backup}/{f["name"]}-{sha(base)[:8]}'))
|
|
exe = files[-1]
|
|
record: dict = dict(schema='snapintosh-deploy-guest-plugin-v2', started=time.time(), restore=args.restore,
|
|
base_sha256=exe['base_sha256'], new_sha256=exe['new_sha256'], new_cdhash=exe['cdhash'],
|
|
plugin=exe['path'], backup=exe['backup'], status='RUNNING',
|
|
files=[{k: v for k, v in f.items() if k != 'text'} for f in files])
|
|
shell = Shell(args)
|
|
try:
|
|
record['shell_attempts'] = wait_for_shell(shell, args.attempts, 20)
|
|
record['data'] = mount_data(shell, args)
|
|
for f, row in zip(files, record['files']):
|
|
f['installed'] = row['installed_before'] = shell.digest('installed', f['path'])
|
|
record['installed_before'] = exe['installed']
|
|
if args.restore:
|
|
for f in files:
|
|
if f['installed'] == f['base_sha256']:
|
|
continue
|
|
if shell.digest('backup', f['backup']) != f['base_sha256']:
|
|
raise Refused(f'the backup of {f["name"]} is not its base')
|
|
shell.run('restore', f'cp -p {shlex.quote(f["backup"])} {shlex.quote(f["path"])}.restore && '
|
|
f'mv -f {shlex.quote(f["path"])}.restore {shlex.quote(f["path"])}')
|
|
else:
|
|
other = [f['name'] for f in files if f['installed'] not in (f['base_sha256'], f['new_sha256'])]
|
|
if other:
|
|
raise Refused(f'installed {", ".join(other)} neither the base nor the new one; nothing written')
|
|
todo = [f for f in files if f['installed'] == f['base_sha256']]
|
|
record['already_installed'] = not todo
|
|
if todo:
|
|
shell.run('work', f'rm -rf {shlex.quote(work)} && mkdir -p {shlex.quote(work)}')
|
|
started, record['transfers'] = time.monotonic(), []
|
|
for f in todo:
|
|
record['transfers'].append(transfer(shell, args, work, f))
|
|
record['transfer_seconds'] = round(time.monotonic() - started, 1)
|
|
record['chunks'] = sum(t['chunks'] for t in record['transfers'])
|
|
record['rounds'] = record['transfers'][-1]['rounds'] if record['transfers'] else []
|
|
for f in todo:
|
|
shell.run('backup', f'mkdir -p {shlex.quote(backup)} && (test -e {shlex.quote(f["backup"])} || '
|
|
f'cp -p {shlex.quote(f["path"])} {shlex.quote(f["backup"])})')
|
|
if shell.digest('backup', f['backup']) != f['base_sha256']:
|
|
raise Refused(f'the backup of {f["name"]} is not its base')
|
|
for f in todo:
|
|
owner_mode = shell.run('stat', f"stat -f '%u:%g %Lp' {shlex.quote(f['path'])}").split()
|
|
if len(owner_mode) < 2:
|
|
raise Refused(f'no owner and mode for {f["path"]}')
|
|
f['owner_mode'] = owner_mode[-2:]
|
|
record['owner_mode'] = exe.get('owner_mode')
|
|
for f in todo:
|
|
user_group, mode = f['owner_mode']
|
|
temp = f'{f["path"]}.snapintosh-new'
|
|
shell.run('install', f'cp {shlex.quote(work)}/{f["name"]}.new {shlex.quote(temp)} && '
|
|
f'chown {user_group} {shlex.quote(temp)} && chmod {mode} {shlex.quote(temp)} && '
|
|
f'mv -f {shlex.quote(temp)} {shlex.quote(f["path"])}')
|
|
for f, row in zip(files, record['files']):
|
|
row['installed_after'] = shell.digest('installed', f['path'])
|
|
want = f['base_sha256'] if args.restore else f['new_sha256']
|
|
if row['installed_after'] != want:
|
|
raise Refused(f'installed {f["name"]} is {row["installed_after"]}, expected {want}')
|
|
if not args.restore and f['cdhash']:
|
|
cd = shell.run('cdhash', f"codesign -dvvv {shlex.quote(f['path'])} 2>&1 | grep '^CDHash='")
|
|
row['installed_cdhash'] = cd.strip().splitlines()[-1].split('=', 1)[-1] if cd.strip() else None
|
|
if row['installed_cdhash'] != f['cdhash']:
|
|
raise Refused(f'installed {f["name"]} CDHash {row["installed_cdhash"]}, expected {f["cdhash"]}')
|
|
record['installed_after'] = record['files'][-1]['installed_after']
|
|
record['installed_cdhash'] = record['files'][-1].get('installed_cdhash')
|
|
shell.run('sync', 'sync')
|
|
record['status'] = 'RESTORED' if args.restore else 'INSTALLED'
|
|
if args.continue_boot:
|
|
shell.type_exit()
|
|
record['continued_boot'] = True
|
|
return 0
|
|
except Refused as error:
|
|
record['status'] = 'REFUSED'
|
|
record['error'] = str(error)
|
|
return 1
|
|
finally:
|
|
record['steps'] = shell.steps
|
|
record['console'] = shell.console.record() if hasattr(shell.console, 'record') else None
|
|
record['finished'] = time.time()
|
|
args.record.write_text(json.dumps(record, indent=2, sort_keys=True))
|
|
print(json.dumps(dict(status=record['status'], error=record.get('error'), record=str(args.record),
|
|
installed_after=record.get('installed_after'),
|
|
transfer_seconds=record.get('transfer_seconds'))))
|
|
|
|
|
|
if __name__ == '__main__':
|
|
sys.exit(main())
|