#!/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','maintenance'],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}')