VulpesOS/tundra/droidian/backup-boot.py
OverSu b38dea0597 Publish Vulpes Preview 15 sources for desktop, Android and Tundra
Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
2026-09-30 23:13:03 +02:00

80 lines
3.4 KiB
Python

#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Read the six inventoried Sargo boot partitions; never open a device for writing."""
import datetime
import fcntl
import hashlib
import json
import os
from pathlib import Path
import pwd
import stat
import struct
import subprocess
import tempfile
PARTITIONS = {
'boot_a': 67108864, 'boot_b': 67108864,
'dtbo_a': 8417280, 'dtbo_b': 8417280,
'vbmeta_a': 65536, 'vbmeta_b': 65536,
}
def copy_exact(source, destination, size):
digest = hashlib.sha256()
remaining = size
while remaining:
chunk = source.read(min(1024 * 1024, remaining))
if not chunk:
raise RuntimeError('Partition read ended before its declared size')
destination.write(chunk)
digest.update(chunk)
remaining -= len(chunk)
return digest.hexdigest()
def main():
if os.geteuid() != 0:
raise RuntimeError('Partition reads require sudo in your own terminal')
account = pwd.getpwnam('droidian')
getprop = next((p for p in ('/usr/bin/getprop', '/bin/getprop') if Path(p).exists()), None)
if not getprop:
raise RuntimeError('Cannot establish Android device identity')
device = subprocess.check_output([getprop, 'ro.product.device'], text=True, timeout=10).strip()
compatible = Path('/proc/device-tree/compatible').read_bytes().split(b'\0')
if device != 'sargo' or b'google,b4s4-sdm670' not in compatible:
raise RuntimeError('This recipe only supports the inventoried Sargo adaptation')
os.umask(0o077)
folder = Path(tempfile.mkdtemp(prefix='vulpes-boot-backup-', dir='/var/tmp'))
report = {'recordedAt': datetime.datetime.now(datetime.timezone.utc).isoformat(),
'device': device, 'rawPartitions': True, 'restorationTested': False,
'scope': 'Boot, DTBO and vbmeta only; excludes userdata, vendor and system',
'files': {}}
for name, expected_size in PARTITIONS.items():
path = (Path('/dev/disk/by-partlabel') / name).resolve(strict=True)
fd = os.open(path, os.O_RDONLY | os.O_CLOEXEC | os.O_NOFOLLOW)
with os.fdopen(fd, 'rb') as source:
if not stat.S_ISBLK(os.fstat(source.fileno()).st_mode):
raise RuntimeError('Not a block device: ' + str(path))
size = struct.unpack('Q', fcntl.ioctl(source.fileno(), 0x80081272, bytes(8)))[0]
if size != expected_size:
raise RuntimeError('Partition layout differs from the inventory: ' + name)
target = folder / (name + '.img')
with target.open('xb') as output:
digest = copy_exact(source, output, size)
output.flush()
os.fsync(output.fileno())
with target.open('rb') as saved:
if hashlib.file_digest(saved, 'sha256').hexdigest() != digest:
raise RuntimeError('Backup verification failed: ' + name)
report['files'][target.name] = {'source': str(path), 'bytes': size, 'sha256': digest}
(folder / 'manifest.json').write_text(json.dumps(report, indent=2) + '\n')
# Keep incomplete backups root-only; transfer ownership only after all six pass.
for child in folder.iterdir():
os.chown(child, account.pw_uid, account.pw_gid)
os.chown(folder, account.pw_uid, account.pw_gid)
print('Backup verified:', folder)
if __name__ == '__main__':
main()