Accept native SD cards for internal settings alongside legacy NVMe, reject USB ancestry and ambiguous disks, and preserve read-only boot loading with explicit recovery writes. Require built-in MMC drivers, validate cached kernel configuration, and provide SD-only, SD-first and USB-first EEPROM profiles. Expose typed create and inspect commands through fds-flash, reuse the existing cartridge creation code, and document the optional e2fsprogs package dependency. Extend storage, EEPROM, flashing and hardware-test coverage and advance the wiki reference to the published SD guide. Validation: rootfs checks for CLI/development, boot matrix, internal storage, EEPROM, flash, workstation, emulator, make check and wiki checks passed. The full internal suite passed on an unchanged rerun after one unexplained VM shutdown stall. Standalone init-test was blocked by its unavailable pinned upstream kernel. Physical Pi checks remain pending.
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','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}')
|