Use a bounded uzlib-derived block encoder with the existing strict tinf decoder, qualify infrastructure roles, budget temporary RAM, and cache running-image metadata. Add self-serve CLI and policy, USB host-loss sleep grace, hardware test harnesses, regression tests, and CI coverage.
564 lines
29 KiB
Python
564 lines
29 KiB
Python
#!/usr/bin/env python3
|
|
"""Qualified two-V4 USB host-reboot evidence; this tool NEVER reboots the host.
|
|
|
|
Run prepare after the OTA test to restore saved primary radio settings, disable
|
|
radio2/logging, enable powersaving, and reboot the two exact radios. Wait more
|
|
than three minutes before probe. After the operator reboots the Pi, probe again
|
|
to a NEW report, then compare the two probes offline.
|
|
|
|
All actions require both physical USB serials and the expected running-image
|
|
hash. Preparation sends each setting once, with a bounded acknowledgement
|
|
deadline. Only normal USB CLI commands are used; no bootloader or flash access.
|
|
Private keys are retained solely as SHA-256 digests through the OTA helper.
|
|
Final reports are owner-readable only and new paths are required.
|
|
OTA autofetch/autoinstall must already be off. Comparison assumes the Pi wall
|
|
clock remains stable across reboot; retain an independent operator timestamp
|
|
if clock synchronization or a large clock correction is suspected.
|
|
"""
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import math
|
|
import os
|
|
from pathlib import Path
|
|
import platform
|
|
import re
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
|
|
from esp32_self_serve_ota import (
|
|
HEX16, Report, TestFailure, V4Device, normalized_serial, parse_self,
|
|
parse_slot, require, snapshot, utc, value_reply,
|
|
)
|
|
|
|
|
|
KIND = "esp32_usb_host_warm_reboot"
|
|
CORE_KEYS = {"battery_mv", "uptime_secs", "errors", "queue_len"}
|
|
RADIO_DIAG = re.compile(
|
|
r"state=(\d+)\((RX|IDLE|TX_WAIT|INT_READY|\?)\), recv=(\d+), sent=(\d+), "
|
|
r"errors=(\d+), err_flags=(\d+), outbound=(yes|no), last_rx=(\d+)s ago"
|
|
)
|
|
RADIO2_OFF = re.compile(
|
|
r"off; RX=\d+,\d+ TX=\d+,\d+ switches=\d+ errors=\d+ "
|
|
r"max=\d+us preamble=\d+,\d+"
|
|
)
|
|
MAX_REPORT_BYTES = 4 * 1024 * 1024
|
|
|
|
|
|
class EvidenceReport(Report):
|
|
"""Reuse atomic owner-only reporting, adding no-clobber creation/sealing."""
|
|
|
|
def __init__(self, path, args):
|
|
self.created = False
|
|
self.sealed = False
|
|
super().__init__(path, args)
|
|
self.data.update(
|
|
evidence_kind=KIND, firmware_transport="none; USB CLI evidence only",
|
|
host_reboot_performed_by_tool=False, minimum_board_age_seconds=args.min_age,
|
|
uptime_tolerance_seconds=args.uptime_tolerance,
|
|
)
|
|
self.save()
|
|
|
|
def save(self):
|
|
require(not self.sealed, "A finalized evidence report cannot be modified")
|
|
if self.created:
|
|
return super().save()
|
|
fd, temporary = tempfile.mkstemp(prefix=self.path.name + ".", suffix=".tmp",
|
|
dir=self.path.parent)
|
|
temporary = Path(temporary)
|
|
try:
|
|
with os.fdopen(fd, "w", encoding="utf-8") as stream:
|
|
json.dump(self.data, stream, indent=2)
|
|
stream.write("\n")
|
|
stream.flush()
|
|
os.fsync(stream.fileno())
|
|
# Atomic no-clobber: a simultaneous creator must not be overwritten.
|
|
os.link(temporary, self.path)
|
|
self.created = True
|
|
except FileExistsError:
|
|
raise TestFailure("Report path already exists; select a new report") from None
|
|
finally:
|
|
temporary.unlink(missing_ok=True)
|
|
|
|
def seal(self):
|
|
self.data["finished_utc"] = utc()
|
|
self.save()
|
|
os.chmod(self.path, 0o400)
|
|
self.sealed = True
|
|
|
|
|
|
def parse_core(text):
|
|
try:
|
|
data = json.loads(text)
|
|
except (ValueError, TypeError):
|
|
raise TestFailure("stats-core is not complete JSON") from None
|
|
require(isinstance(data, dict) and set(data) == CORE_KEYS,
|
|
"stats-core has unexpected fields")
|
|
require(all(type(value) is int and 0 <= value <= 0xFFFFFFFF
|
|
for value in data.values()), "stats-core has invalid integer fields")
|
|
return data
|
|
|
|
|
|
def parse_radio_diag(text):
|
|
match = RADIO_DIAG.fullmatch(text)
|
|
require(match is not None, "stats-radio-diag has incomplete or unexpected fields")
|
|
values = [int(match[index]) for index in (1, 3, 4, 5, 6, 8)]
|
|
require(all(value <= 0xFFFFFFFF for value in values),
|
|
"stats-radio-diag integer exceeded its bound")
|
|
return dict(zip(("state", "recv", "sent", "errors", "err_flags", "last_rx_secs"),
|
|
values), state_name=match[2], outbound=match[7] == "yes")
|
|
|
|
|
|
def require_quiet(core, diag):
|
|
require(core["queue_len"] == 0, "Radio has a nonempty outbound queue")
|
|
require(diag["state"] == 1 and diag["state_name"] == "RX" and not diag["outbound"],
|
|
"Radio is not in idle receive with no outbound packet")
|
|
|
|
|
|
def require_idle(device, timeout=5):
|
|
text = value_reply(device.command("ota status", timeout=timeout))
|
|
require(" | no download | " in text, device.name + ": OTA session is not idle")
|
|
|
|
|
|
def wait_quiet(devices, timeout=30):
|
|
"""Wait out short beacons, never wait through or mutate an active download."""
|
|
deadline = time.monotonic() + timeout
|
|
while True:
|
|
samples = {}
|
|
for device in devices:
|
|
def query(command):
|
|
remaining = deadline - time.monotonic()
|
|
require(remaining > 0, "Bounded idle-receive observation timed out")
|
|
return value_reply(device.command(command, timeout=min(5, remaining)))
|
|
|
|
status = query("ota status")
|
|
require(" | no download | " in status, device.name + ": OTA session is not idle")
|
|
started = time.time()
|
|
core = parse_core(query("stats-core"))
|
|
finished = time.time()
|
|
diag = parse_radio_diag(query("stats-radio-diag"))
|
|
samples[device.name] = {
|
|
"core": core, "radio_diag": diag,
|
|
"observed_unix_seconds": (started + finished) / 2,
|
|
"sample_uncertainty_seconds": (finished - started) / 2,
|
|
}
|
|
if all(item["core"]["queue_len"] == 0 and item["radio_diag"]["state"] == 1
|
|
and item["radio_diag"]["state_name"] == "RX"
|
|
and not item["radio_diag"]["outbound"] for item in samples.values()):
|
|
return samples
|
|
remaining = deadline - time.monotonic()
|
|
require(remaining > 0, "Bounded idle-receive observation timed out")
|
|
time.sleep(min(.5, remaining))
|
|
|
|
|
|
def logging_state(device):
|
|
text = value_reply(device.command("get usb.logging"))
|
|
if text == "Error: unknown setting: usb.logging":
|
|
return {"available": False, "enabled": False}
|
|
require(text in ("on", "off"), device.name + ": USB logging state is unavailable")
|
|
return {"available": True, "enabled": text == "on"}
|
|
|
|
|
|
def manual_ota_policy(device):
|
|
text = value_reply(device.command("ota config"))
|
|
values = {}
|
|
for key in ("autofetch", "autoinstall"):
|
|
found = re.findall(r"\b" + key + r"=([a-z]+)\b", text)
|
|
require(len(found) == 1 and found[0] == "off",
|
|
device.name + ": automatic OTA policy must already be off")
|
|
values[key] = found[0]
|
|
return values
|
|
|
|
|
|
def verified_device(device, expected_hash):
|
|
device.connect()
|
|
info = parse_self(device.command("ota self"))
|
|
require(info["body_hash16"] == expected_hash,
|
|
device.name + ": running firmware does not match the expected hash")
|
|
require(info["image_bytes"] > info["body_bytes"] > 0,
|
|
device.name + ": running image geometry is invalid")
|
|
slot = parse_slot(device.command("ota dev apply slot"))
|
|
require(info["image_bytes"] <= slot["size_bytes"], "Image does not fit the inactive slot")
|
|
return {"identity": dict(device.identity), "self": info, "inactive_slot": slot,
|
|
"ota_policy": manual_ota_policy(device)}
|
|
|
|
|
|
def native_usb_state(device, root=Path("/")):
|
|
endpoint = Path(device.endpoint).name
|
|
require(re.fullmatch(r"ttyACM\d+", endpoint) is not None,
|
|
"Qualified V4 is not a Linux native USB serial endpoint")
|
|
tty = root / "sys/class/tty" / endpoint / "device"
|
|
resolved = tty.resolve(strict=True)
|
|
parents = (resolved,) + tuple(resolved.parents)
|
|
for parent in parents:
|
|
if not all((parent / field).is_file()
|
|
for field in ("idVendor", "idProduct", "serial", "speed")):
|
|
continue
|
|
serial = normalized_serial((parent / "serial").read_text(encoding="ascii").strip())
|
|
require(serial == device.serial_number, "Native USB sysfs serial does not match")
|
|
vid = (parent / "idVendor").read_text(encoding="ascii").strip().lower()
|
|
pid = (parent / "idProduct").read_text(encoding="ascii").strip().lower()
|
|
require(vid == "303a" and pid == "1001",
|
|
"Qualified V4 is not the expected native USB/JTAG interface")
|
|
speed = (parent / "speed").read_text(encoding="ascii").strip()
|
|
require(speed in ("12", "12.0"), "Native USB link is not full-speed 12 Mbps")
|
|
return {"usb_serial": serial, "speed_mbps": 12, "sysfs_path": str(parent),
|
|
"vid": vid, "pid": pid}
|
|
raise TestFailure("Native USB device attributes were not found in sysfs")
|
|
|
|
|
|
def pi_state(root=Path("/")):
|
|
boot_id = (root / "proc/sys/kernel/random/boot_id").read_text(encoding="ascii").strip()
|
|
require(re.fullmatch(r"[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}",
|
|
boot_id) is not None, "Pi boot identity is invalid")
|
|
uptime = float((root / "proc/uptime").read_text(encoding="ascii").split()[0])
|
|
require(math.isfinite(uptime) and uptime >= 0, "Pi uptime is invalid")
|
|
cmdline = (root / "proc/cmdline").read_text(encoding="ascii").strip()
|
|
speed_tokens = [word for word in cmdline.split() if word.startswith("dwc_otg.speed=")]
|
|
require(speed_tokens == ["dwc_otg.speed=1"], "Pi USB 1.1 host configuration is not retained")
|
|
model = (root / "proc/device-tree/model").read_bytes().rstrip(b"\0").decode("ascii")
|
|
require("Raspberry Pi" in model and len(model) <= 128, "Host is not a qualified Raspberry Pi")
|
|
return {"boot_id": boot_id.lower(), "uptime_seconds": uptime, "model": model,
|
|
"kernel": platform.release(), "dwc_otg_speed": 1,
|
|
# Do not copy arbitrary kernel arguments (which may contain secrets).
|
|
"cmdline_sha256": hashlib.sha256(cmdline.encode("ascii")).hexdigest()}
|
|
|
|
|
|
def capture_device(device, expected_hash, min_age):
|
|
result = verified_device(device, expected_hash)
|
|
require_idle(device)
|
|
result["snapshot"] = snapshot(device)
|
|
state = {key: value_reply(device.command("get " + key))
|
|
for key in ("radio", "radio2", "tempradio", "tempradio2", "powersaving",
|
|
"radio2.status")}
|
|
require(re.fullmatch(r"[0-9.]+,[0-9.]+,\d+,\d+", state["radio"]) is not None,
|
|
"Saved primary radio tuple is invalid")
|
|
require(state["radio2"] == "off" and RADIO2_OFF.fullmatch(state["radio2.status"]),
|
|
device.name + ": radio2 is not disabled in both saved and current state")
|
|
require(state["tempradio"] == "off" and state["tempradio2"] == "off",
|
|
device.name + ": a temporary radio profile is still active or pending")
|
|
require(state["powersaving"] == "on", device.name + ": powersaving is not enabled")
|
|
state["usb.logging"] = logging_state(device)
|
|
require(not state["usb.logging"]["enabled"], device.name + ": USB logging remains active")
|
|
# Firmware exposes saved primary settings, not an instantaneous tuple.
|
|
state["primary_current_inferred_from"] = "saved tuple; no temporary profile; radio2 off; RX"
|
|
result["radio_state"] = state
|
|
powerlog = value_reply(device.command("powerlog"))
|
|
match = re.fullmatch(r"Last reset reason: ([ -~]{1,100})", powerlog)
|
|
require(match is not None, "powerlog lacks a bounded reset reason")
|
|
result["reset_reason"] = match[1]
|
|
sample = wait_quiet([device])[device.name]
|
|
core, diag = sample["core"], sample["radio_diag"]
|
|
require(core["uptime_secs"] > min_age,
|
|
device.name + ": board is still within the post-boot sleep grace period")
|
|
require_quiet(core, diag)
|
|
# Check OTA again after all telemetry so an intervening download fails closed.
|
|
require_idle(device)
|
|
require(manual_ota_policy(device) == result["ota_policy"],
|
|
"OTA policy changed during the probe")
|
|
result.update(sample, ota_idle=True, usb=native_usb_state(device))
|
|
return result
|
|
|
|
|
|
def temporary_profiles(device, deadline=None):
|
|
state = {}
|
|
for key in ("tempradio", "tempradio2"):
|
|
remaining = 5 if deadline is None else deadline - time.monotonic()
|
|
require(remaining > 0, "Temporary profile restoration timed out")
|
|
value = value_reply(device.command("get " + key, timeout=min(5, remaining)))
|
|
# Secondary replies append timing recommendations. Retain only the
|
|
# bounded profile fields, never arbitrary diagnostic/log tails.
|
|
if key == "tempradio2":
|
|
value = value.split(";", 1)[0]
|
|
pattern = (r"[0-9.]+,[0-9.]+,\d+,\d+,\d+" if key == "tempradio"
|
|
else r"[0-9.]+,[0-9.]+,\d+,\d+,(?:rx|rxtx),\d+(?: \(auto\))?")
|
|
require(value == "off" or len(value) <= 100 and re.fullmatch(pattern, value),
|
|
"Temporary radio state has invalid profile fields")
|
|
state[key] = value
|
|
return state
|
|
|
|
|
|
def wait_no_temporary_profiles(devices, timeout=30):
|
|
deadline = time.monotonic() + timeout
|
|
while True:
|
|
states = {}
|
|
for device in devices:
|
|
remaining = deadline - time.monotonic()
|
|
require(remaining > 0, "Temporary profile restoration timed out")
|
|
require_idle(device, timeout=min(5, remaining))
|
|
states[device.name] = temporary_profiles(device, deadline)
|
|
if all(all(value == "off" for value in state.values()) for state in states.values()):
|
|
return states
|
|
remaining = deadline - time.monotonic()
|
|
require(remaining > 0, "Temporary profile restoration timed out")
|
|
time.sleep(min(.5, remaining))
|
|
|
|
|
|
def normalized_snapshot_differences(raw, saved, primary_was_temporary, name):
|
|
require(all(raw[key] == saved[key] for key in ("private_key_sha256", "public_key", "acl")),
|
|
name + ": temporary restoration changed identity or ACL")
|
|
old, new = raw["settings"], saved["settings"]
|
|
require(set(old) == set(new), name + ": normalized saved-setting fields changed")
|
|
differences = {}
|
|
for key, value in old.items():
|
|
if value == new[key]:
|
|
continue
|
|
# These getters intentionally report runtime timing overrides, not
|
|
# persisted prefs. Restoring normal radio exposes the saved values;
|
|
# never set an assumed advert interval to make a comparison pass.
|
|
masks = {"advert.interval": ("60", 510), "flood.advert.interval": ("3", 255)}
|
|
expected = masks.get(key)
|
|
require(primary_was_temporary and expected is not None and value == expected[0],
|
|
name + ": temporary restoration changed an unrelated saved setting")
|
|
require(re.fullmatch(r"0|[1-9]\d*", new[key]) is not None
|
|
and int(new[key]) <= expected[1],
|
|
name + ": normalized advert interval is invalid")
|
|
differences[key] = {"live_before": value, "saved_after_normalradio": new[key],
|
|
"reason": "documented temporary radio timing getter override"}
|
|
return differences
|
|
|
|
|
|
def prepare_devices(devices, expected_hash, report):
|
|
before = {}
|
|
# Qualify BOTH radios before issuing the first write to either.
|
|
for device in devices:
|
|
before[device.name] = verified_device(device, expected_hash)
|
|
require_idle(device)
|
|
before[device.name]["usb"] = native_usb_state(device)
|
|
before[device.name]["temporary_profiles"] = temporary_profiles(device)
|
|
before[device.name]["snapshot"] = snapshot(device)
|
|
before[device.name]["snapshot_interpretation"] = (
|
|
"raw live CLI getters; advert timing may be temporarily overridden")
|
|
before[device.name]["logging"] = logging_state(device)
|
|
report.data["devices"] = {name: {"before_prepare": value} for name, value in before.items()}
|
|
report.save()
|
|
# Both devices must be quiet in one bounded observation round, after the
|
|
# potentially long ACL snapshots and before the first mutation.
|
|
quiet = wait_quiet(devices)
|
|
for name, sample in quiet.items():
|
|
report.data["devices"][name]["before_prepare"].update(sample)
|
|
report.save()
|
|
for device in devices:
|
|
for peer in devices:
|
|
require_idle(peer) # Refuse a newly active session before this group.
|
|
manual_ota_policy(peer)
|
|
# Clear only runtime overlays first. Snapshot getters for advert
|
|
# intervals otherwise report the temporary policy rather than prefs.
|
|
for command in ("normalradio", "set tempradio2 off"):
|
|
require(device.checked(command, timeout=30).startswith("OK"),
|
|
device.name + ": temporary restoration was not acknowledged")
|
|
report.event(device.name + ": temporary restoration acknowledged once")
|
|
restored = wait_no_temporary_profiles(devices)
|
|
normalized = {}
|
|
for device in devices:
|
|
require_idle(device)
|
|
manual_ota_policy(device)
|
|
saved = snapshot(device)
|
|
entry = report.data["devices"][device.name]
|
|
entry["normalized_saved_snapshot"] = saved
|
|
entry["temporary_profiles_after_normalization"] = restored[device.name]
|
|
report.save() # Preserve observed values even if invariant checks fail.
|
|
entry["normalization_differences"] = normalized_snapshot_differences(
|
|
before[device.name]["snapshot"], saved,
|
|
before[device.name]["temporary_profiles"]["tempradio"] != "off", device.name)
|
|
normalized[device.name] = saved
|
|
report.save()
|
|
for device in devices:
|
|
for peer in devices:
|
|
require_idle(peer)
|
|
manual_ota_policy(peer)
|
|
commands = ["set radio2 off", "set powersaving on"]
|
|
if before[device.name]["logging"]["available"]:
|
|
commands.append("set usb.logging off")
|
|
for command in commands:
|
|
require(device.checked(command, timeout=30).startswith("OK"),
|
|
device.name + ": preparation setting was not acknowledged")
|
|
report.event(device.name + ": idle-test settings acknowledged once")
|
|
time.sleep(6) # Existing commands persist settings; allow pending/lazy commits.
|
|
for device in devices:
|
|
for peer in devices:
|
|
require_idle(peer)
|
|
manual_ota_policy(peer)
|
|
device.reboot(expected_hash)
|
|
after = verified_device(device, expected_hash)
|
|
require_idle(device)
|
|
after["snapshot"] = snapshot(device)
|
|
old, new = normalized[device.name], after["snapshot"]
|
|
require(all(old[key] == new[key] for key in ("private_key_sha256", "public_key", "acl")),
|
|
device.name + ": preparation changed identity or ACL")
|
|
require(set(old["settings"]) == set(new["settings"])
|
|
and all(value == new["settings"].get(key)
|
|
for key, value in old["settings"].items() if key != "radio2"),
|
|
device.name + ": preparation changed an unrelated saved setting")
|
|
require(new["settings"]["radio2"] == {"mode": "off"},
|
|
device.name + ": radio2 off was not persisted")
|
|
require(all(value_reply(device.command("get " + key)) == expected
|
|
for key, expected in (("tempradio", "off"), ("tempradio2", "off"),
|
|
("powersaving", "on"))),
|
|
device.name + ": idle-test settings did not survive reboot")
|
|
require(not logging_state(device)["enabled"],
|
|
device.name + ": USB logging was not disabled after reboot")
|
|
report.data["devices"][device.name]["after_prepare"] = after
|
|
report.save()
|
|
report.data["board_reboots_intentionally_performed"] = True
|
|
report.event("Both radios rebooted; wait over 180 seconds, then create a pre-host-reboot probe")
|
|
|
|
|
|
def validate_probe(data, expected_hash, serials):
|
|
require(isinstance(data, dict) and data.get("schema") == 1
|
|
and data.get("evidence_kind") == KIND and data.get("action") == "probe"
|
|
and data.get("result") == "PASS", "Input is not a successful reboot probe")
|
|
require(data.get("expected_body_hash16") == expected_hash, "Probe expected hash differs")
|
|
require(data.get("host_reboot_performed_by_tool") is False, "Probe host action is unexpected")
|
|
require(data["pi"]["dwc_otg_speed"] == 1, "Probe host speed differs")
|
|
require(set(data["devices"]) == {"A", "B"}, "Probe does not contain exactly both radios")
|
|
for name, serial in zip(("A", "B"), serials):
|
|
item = data["devices"][name]
|
|
ident = item["identity"]
|
|
require(ident["usb_serial"] == serial and ident["vid"] == 0x303A
|
|
and ident["pid"] == 0x1001 and ident["role"].lower() == "repeater"
|
|
and re.search(r"Heltec\s+V4\b", ident["board"], re.I),
|
|
"Probe physical identity or role differs")
|
|
require(item["self"]["body_hash16"] == expected_hash
|
|
and item["usb"]["usb_serial"] == serial and item["usb"]["speed_mbps"] == 12,
|
|
"Probe image or native USB identity differs")
|
|
require(item["ota_idle"] is True, "Probe OTA session was not idle")
|
|
require(item["ota_policy"] == {"autofetch": "off", "autoinstall": "off"},
|
|
"Probe automatic OTA policy was not disabled")
|
|
core = parse_core(json.dumps(item["core"]))
|
|
require(core["uptime_secs"] > max(180, data["minimum_board_age_seconds"]),
|
|
"Probe was captured during the boot grace period")
|
|
require_quiet(core, item["radio_diag"])
|
|
state = item["radio_state"]
|
|
require(state["radio2"] == "off" and RADIO2_OFF.fullmatch(state["radio2.status"])
|
|
and state["tempradio"] == "off" and state["tempradio2"] == "off"
|
|
and state["powersaving"] == "on"
|
|
and state["usb.logging"]["enabled"] is False, "Probe sleep inhibitors are present")
|
|
require(re.fullmatch(r"[0-9a-f]{64}", item["snapshot"]["private_key_sha256"]) is not None,
|
|
"Probe identity digest is malformed")
|
|
require(math.isfinite(item["observed_unix_seconds"])
|
|
and 0 <= item["sample_uncertainty_seconds"] <= 5,
|
|
"Probe observation timestamp is invalid")
|
|
|
|
|
|
def load_probe(path, expected_hash, serials):
|
|
source = Path(path)
|
|
require(source.is_file() and source.stat().st_size <= MAX_REPORT_BYTES,
|
|
"Probe report is absent or exceeded its size bound")
|
|
with source.open(encoding="utf-8") as stream:
|
|
data = json.load(stream)
|
|
validate_probe(data, expected_hash, serials)
|
|
return data
|
|
|
|
|
|
def compare_probes(before, after, tolerance):
|
|
require(before["pi"]["boot_id"] != after["pi"]["boot_id"],
|
|
"Pi boot identity did not change; a host reboot was not demonstrated")
|
|
require(before["pi"]["dwc_otg_speed"] == after["pi"]["dwc_otg_speed"] == 1
|
|
and before["pi"]["cmdline_sha256"] == after["pi"]["cmdline_sha256"],
|
|
"Pi USB host configuration changed")
|
|
require(before["pi"]["model"] == after["pi"]["model"]
|
|
and before["pi"]["kernel"] == after["pi"]["kernel"], "Pi hardware/kernel changed")
|
|
compared = {}
|
|
for name in ("A", "B"):
|
|
old, new = before["devices"][name], after["devices"][name]
|
|
require(all(old["identity"][key] == new["identity"][key]
|
|
for key in ("usb_serial", "vid", "pid", "board", "role")),
|
|
name + ": physical radio identity changed")
|
|
require(all(old[key] == new[key]
|
|
for key in ("self", "inactive_slot", "snapshot", "reset_reason", "ota_policy")),
|
|
name + ": firmware, A/B slot, identity, saved settings or reset reason changed")
|
|
require(all(old["radio_state"][key] == new["radio_state"][key]
|
|
for key in ("radio", "radio2", "tempradio", "tempradio2",
|
|
"powersaving", "usb.logging")),
|
|
name + ": saved/current radio or logging settings changed")
|
|
elapsed = new["observed_unix_seconds"] - old["observed_unix_seconds"]
|
|
uptime_gain = new["core"]["uptime_secs"] - old["core"]["uptime_secs"]
|
|
require(math.isfinite(elapsed) and elapsed > 0 and uptime_gain > 0,
|
|
name + ": board uptime did not continue across the host reboot")
|
|
uncertainty = old["sample_uncertainty_seconds"] + new["sample_uncertainty_seconds"]
|
|
require(abs(uptime_gain - elapsed) <= tolerance + uncertainty,
|
|
name + ": board uptime gain is inconsistent with the elapsed wall time")
|
|
compared[name] = {"usb_serial": new["identity"]["usb_serial"],
|
|
"body_hash16": new["self"]["body_hash16"],
|
|
"wall_interval_seconds": elapsed, "board_uptime_gain_seconds": uptime_gain,
|
|
"sample_uncertainty_seconds": uncertainty,
|
|
"uptime_tolerance_seconds": tolerance, "board_reboot_observed": False,
|
|
"identity_settings_slots_unchanged": True, "native_usb_speed_mbps": 12}
|
|
return {"pi_boot_id_before": before["pi"]["boot_id"], "pi_boot_id_after": after["pi"]["boot_id"],
|
|
"devices": compared, "wall_clock_stability_required": True,
|
|
"interpretation": "Exact USB CLI recovered with uptime-consistent board continuity"}
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("action", choices=("prepare", "probe", "compare"))
|
|
parser.add_argument("--serial-a", required=True)
|
|
parser.add_argument("--serial-b", required=True)
|
|
parser.add_argument("--expected-body-hash16", required=True)
|
|
parser.add_argument("--report", required=True, help="new owner-only evidence report path")
|
|
parser.add_argument("--min-age", type=int, default=180)
|
|
parser.add_argument("--uptime-tolerance", type=float, default=10)
|
|
parser.add_argument("--before", help="successful pre-host-reboot probe report")
|
|
parser.add_argument("--after", help="successful post-host-reboot probe report")
|
|
args = parser.parse_args()
|
|
serials = tuple(normalized_serial(value) for value in (args.serial_a, args.serial_b))
|
|
if not all(re.fullmatch(r"[0-9A-F]{12}", serial) for serial in serials) or serials[0] == serials[1]:
|
|
parser.error("two distinct complete 12-hex physical USB serials are required")
|
|
if not HEX16.fullmatch(args.expected_body_hash16):
|
|
parser.error("expected image body hash must contain 16 hex digits")
|
|
if args.min_age < 180 or not math.isfinite(args.uptime_tolerance) or not 1 <= args.uptime_tolerance <= 60:
|
|
parser.error("minimum age must be at least 180s; uptime tolerance must be 1..60s")
|
|
if args.action == "compare":
|
|
if not args.before or not args.after or Path(args.before).resolve() == Path(args.after).resolve():
|
|
parser.error("compare needs two different successful probe paths")
|
|
elif args.before or args.after:
|
|
parser.error("before/after paths are only valid for compare")
|
|
args.expected_body_hash16 = args.expected_body_hash16.upper()
|
|
args.rounds, args.timeout, args.poll_interval = 0, 30, 0
|
|
report, devices = None, []
|
|
try:
|
|
report = EvidenceReport(args.report, args)
|
|
if args.action == "compare":
|
|
before = load_probe(args.before, args.expected_body_hash16, serials)
|
|
after = load_probe(args.after, args.expected_body_hash16, serials)
|
|
report.data["comparison"] = compare_probes(before, after, args.uptime_tolerance)
|
|
else:
|
|
report.data["pi"] = pi_state()
|
|
devices = [V4Device(name, serial) for name, serial in zip(("A", "B"), serials)]
|
|
if args.action == "prepare":
|
|
prepare_devices(devices, args.expected_body_hash16, report)
|
|
else:
|
|
for device in devices:
|
|
report.data["devices"][device.name] = capture_device(
|
|
device, args.expected_body_hash16, args.min_age)
|
|
report.save()
|
|
validate_probe(dict(report.data, result="PASS"), args.expected_body_hash16, serials)
|
|
require(pi_state()["boot_id"] == report.data["pi"]["boot_id"],
|
|
"Host rebooted while a probe/preparation was being captured")
|
|
report.data["result"] = "PREPARED" if args.action == "prepare" else "PASS"
|
|
report.event("Reboot evidence " + report.data["result"])
|
|
report.seal()
|
|
return 0
|
|
except (Exception, KeyboardInterrupt) as error:
|
|
# Never print unexpected exception text, which may contain raw serial data.
|
|
reason = str(error) if isinstance(error, TestFailure) else type(error).__name__
|
|
print("Reboot evidence failed: " + reason, file=sys.stderr)
|
|
if report is not None and not report.sealed:
|
|
report.data.update(result="ABORTED" if isinstance(error, KeyboardInterrupt) else "FAIL",
|
|
failure=reason)
|
|
report.seal()
|
|
return 130 if isinstance(error, KeyboardInterrupt) else 1
|
|
finally:
|
|
for device in devices:
|
|
try:
|
|
device.close()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|