88 lines
5.3 KiB
Python
Executable File
88 lines
5.3 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Prepare and verify reversible Pi 5 EEPROM configuration files; never flash hardware."""
|
|
import argparse
|
|
import difflib
|
|
import json
|
|
from pathlib import Path
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
from eeprom_inputs import PROJECT,prepare,sha256
|
|
|
|
MANAGED={'BOOT_ORDER','BOOT_UART','NET_INSTALL_ENABLED','NET_INSTALL_AT_POWER_ON','POWER_OFF_ON_HALT','WAIT_FOR_POWER_BUTTON'}
|
|
|
|
def settings(text):
|
|
if len(text.encode())>4076 or '\0' in text:raise ValueError('EEPROM configuration must fit one 4076-byte record without NUL bytes')
|
|
result={}
|
|
for line in text.splitlines():
|
|
if line.lstrip().startswith('#') or '=' not in line:continue
|
|
key,value=line.split('=',1);key=key.strip();value=value.strip()
|
|
if key in MANAGED:
|
|
if key in result:raise ValueError(f'Duplicate managed setting: {key}')
|
|
result[key]=value
|
|
return result
|
|
|
|
def merge(original,profile):
|
|
expected=settings(profile)
|
|
if set(expected)!=MANAGED:raise ValueError('Incomplete EEPROM profile')
|
|
retained=[]
|
|
for line in original.splitlines():
|
|
key=line.split('=',1)[0].strip() if '=' in line and not line.lstrip().startswith('#') else None
|
|
if key not in MANAGED:retained.append(line)
|
|
merged='\n'.join(retained).rstrip()+'\n\n'+profile.strip()+'\n'
|
|
if settings(merged)!=expected:raise ValueError('Merged settings mismatch')
|
|
return merged
|
|
|
|
def main():
|
|
parser=argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument('--profile',choices=['production','development'],default='production')
|
|
parser.add_argument('--base-image',type=Path,help='Regular-file Pi 5 EEPROM image; defaults to the pinned preview firmware')
|
|
parser.add_argument('--current-config',type=Path,help='Saved board configuration to preserve instead of the image defaults')
|
|
parser.add_argument('--output-directory',type=Path,help='New directory; existing paths are refused')
|
|
args=parser.parse_args()
|
|
cache,pin=prepare()
|
|
base=(args.base_image or cache/'pieeprom.bin').resolve(strict=True)
|
|
if not base.is_file() or base.stat().st_size!=2*1024*1024:raise ValueError('Base must be a regular 2 MiB Pi 5 EEPROM image, not a block device')
|
|
base_checksum=sha256(base)
|
|
tool=[sys.executable,str(cache/'rpi-eeprom-config')]
|
|
original=subprocess.check_output([*tool,str(base)]).decode('utf-8')
|
|
if args.current_config:
|
|
if not args.current_config.is_file() or args.current_config.stat().st_size>4076:raise ValueError('Current configuration must be a regular file of at most 4076 bytes')
|
|
original=args.current_config.read_text()
|
|
# Validate bounds but preserve existing duplicates/conditional overrides in
|
|
# the rollback configuration. All managed occurrences are replaced below.
|
|
if len(original.encode())>4076 or '\0' in original:raise ValueError('Invalid original configuration size or NUL byte')
|
|
profile=(PROJECT/'config/eeprom'/f'{args.profile}.conf').read_text()
|
|
configured=merge(original,profile)
|
|
if args.output_directory:
|
|
work=args.output_directory.absolute();work.mkdir(mode=0o700)
|
|
else:work=Path(tempfile.mkdtemp(prefix=f'eeprom-{args.profile}.',dir=PROJECT/'out'))
|
|
shutil.copyfile(base,work/'base.bin')
|
|
if sha256(work/'base.bin')!=base_checksum or sha256(base)!=base_checksum:raise ValueError('Base image changed during preparation')
|
|
shutil.copyfile(cache/'LICENSE',work/'LICENSE')
|
|
(work/'original.conf').write_text(original)
|
|
(work/'configured.conf').write_text(configured)
|
|
subprocess.run([*tool,'--config',str(work/'original.conf'),'--out',str(work/'rollback.bin'),str(work/'base.bin')],check=True)
|
|
subprocess.run([*tool,'--config',str(work/'configured.conf'),'--out',str(work/'configured.bin'),str(work/'base.bin')],check=True)
|
|
readback=subprocess.check_output([*tool,str(work/'configured.bin')]).decode()
|
|
rollback=subprocess.check_output([*tool,str(work/'rollback.bin')]).decode()
|
|
if readback!=configured or rollback!=original:raise ValueError('EEPROM configuration readback mismatch')
|
|
(work/'review.diff').write_text(''.join(difflib.unified_diff(original.splitlines(True),configured.splitlines(True),fromfile='original.conf',tofile='configured.conf')))
|
|
manifest={'format':1,'profile':args.profile,'upstream_commit':pin['commit'],
|
|
'hardware_modified':False,'custom_inputs_provided':bool(args.current_config or args.base_image),
|
|
'input_base_sha256':base_checksum,'settings':settings(configured),
|
|
'files':{p.name:sha256(p) for p in sorted(work.iterdir()) if p.is_file()}}
|
|
(work/'manifest.json').write_text(json.dumps(manifest,indent=2)+'\n')
|
|
if not args.output_directory and not args.current_config and not args.base_image:
|
|
link=PROJECT/'out'/f'eeprom-{args.profile}-latest.next'
|
|
link.symlink_to(work.name)
|
|
link.replace(PROJECT/'out'/f'eeprom-{args.profile}-latest')
|
|
print(f'Prepared and read-back verified: {work}')
|
|
print('No hardware was modified. Review review.diff and retain base.bin, original.conf and rollback.bin.')
|
|
if not args.current_config and not args.base_image:print('This uses pinned preview defaults; it is not a backup of your Pi.')
|
|
|
|
if __name__=='__main__':
|
|
try:main()
|
|
except (OSError,ValueError,subprocess.CalledProcessError) as error:sys.exit(f'ERROR: {error}')
|