Files
MeshCore/tools/hil/mcfirmware_bench_app_flash.sh
T

390 lines
24 KiB
Bash

#!/usr/bin/env bash
# Application-only 8/16 MiB ESP32-S3 bench role switch on a pinned existing OTA layout. No erase-all,
# partition/OTA/NVS/filesystem write, service stop or direct reset bypass.
# Uses the real mcfirmware session/MAC verification and guarded finish.
set -euo pipefail
umask 077
app_die() { printf 'Application flash refused: %s\n' "$*" >&2; exit 1; }
app_usage() {
printf '%s\n' \
'Usage: mcfirmware_bench_app_flash.sh --port /dev/ttyACM... --usb-path EXACT_BASENAME' \
' --usb-id EXACT_BASENAME --device LABEL --bench-dir PRIVATE_DIRECTORY' \
' --expect-mac XX:XX:XX:XX:XX:XX --image /absolute/APPLICATION.bin --image-sha256 HEX' \
' --backup /absolute/FULL_FLASH.bin --backup-manifest /absolute/FULL_FLASH.bin.json' \
' --backup-sha256 HEX --expect-partition-sha256 HEX' \
' --address ACTIVE_SLOT_ADDRESS --slot-size EXACT_ACTIVE_SLOT_SIZE --allow-write' \
' [--flasher PATH] [--flasher-sha256 HEX]' \
' [--expect-target-id INTEGER --expect-hardware-id EXACT_ENDF_ID]' \
'The exact pinned existing 8/16MiB table and its active OTA app0/app1 slot are required.' \
'The existing complete private backup must match this silicon MAC and full SHA256.' \
'Writes only the selected active slot, verifies its full readback and unchanged table/otadata,' \
'then uses the actual flasher runtime finish. Application health is a separate check.' \
'No original NVS/SPIFFS/otadata restoration is performed by this command.'
}
app_flasher=/home/mikec/meshfirmware/mcfirmware.sh
app_flasher_hash=4da05ba37c2cb8b4a7e455a4aa3ee24f6d8020912d71874c38afd099e98e75b2
app_port='' app_usb_path='' app_usb_id='' app_device='' app_bench='' app_mac=''
app_image='' app_image_hash='' app_backup='' app_backup_manifest='' app_backup_hash=''
app_table_hash='' app_address='' app_slot_size='' app_allow=0 app_active=0
app_target_id='' app_hardware_id=''
while (($#)); do
case "$1" in
--flasher|--flasher-sha256|--port|--usb-path|--usb-id|--device|--bench-dir|--expect-mac|--image|--image-sha256|--backup|--backup-manifest|--backup-sha256|--expect-partition-sha256|--address|--slot-size|--expect-target-id|--expect-hardware-id)
(($# >= 2)) || app_die "Missing value for $1"
case "$1" in
--flasher) app_flasher=$2 ;; --flasher-sha256) app_flasher_hash=$2 ;;
--port) app_port=$2 ;; --usb-path) app_usb_path=$2 ;; --usb-id) app_usb_id=$2 ;;
--device) app_device=$2 ;; --bench-dir) app_bench=$2 ;; --expect-mac) app_mac=$2 ;;
--image) app_image=$2 ;; --image-sha256) app_image_hash=$2 ;;
--backup) app_backup=$2 ;; --backup-manifest) app_backup_manifest=$2 ;;
--backup-sha256) app_backup_hash=$2 ;; --expect-partition-sha256) app_table_hash=$2 ;;
--address) app_address=$2 ;; --slot-size) app_slot_size=$2 ;;
--expect-target-id) app_target_id=$2 ;; --expect-hardware-id) app_hardware_id=$2 ;;
esac
shift 2 ;;
--allow-write) app_allow=1; shift ;;
-h|--help) app_usage; exit 0 ;;
*) app_die "Unknown option: $1" ;;
esac
done
((app_allow)) || app_die 'Explicit --allow-write is required.'
[[ "$app_port" =~ ^/dev/tty(ACM|USB)[0-9]+$ ]] || app_die 'Select one local USB serial tty.'
for app_identity in "$app_usb_path" "$app_usb_id"; do
[[ -n "$app_identity" && "$app_identity" != */* && "$app_identity" != .* ]] \
|| app_die 'Exact USB by-path and by-id basenames are required.'
done
[[ "$app_mac" =~ ^([[:xdigit:]]{12}|([[:xdigit:]]{2}:){5}[[:xdigit:]]{2})$ ]] || app_die 'An explicit silicon MAC is required.'
app_mac=${app_mac//:/}; app_mac=${app_mac,,}
for app_hash in "$app_image_hash" "$app_backup_hash" "$app_table_hash" "$app_flasher_hash"; do
[[ "$app_hash" =~ ^[[:xdigit:]]{64}$ ]] || app_die 'Every expected SHA256 must be explicit and complete.'
done
app_image_hash=${app_image_hash,,}; app_backup_hash=${app_backup_hash,,}
app_table_hash=${app_table_hash,,}; app_flasher_hash=${app_flasher_hash,,}
app_number() {
[[ "$1" =~ ^(0[xX][[:xdigit:]]{1,8}|[0-9]{1,10})$ ]] || app_die 'Slot address/size must be explicit decimal or hexadecimal integers.'
if [[ "$1" == 0[xX]* ]]; then printf '%d\n' "$((16#${1:2}))"; else printf '%d\n' "$((10#$1))"; fi
}
app_address=$(app_number "$app_address")
app_slot_size=$(app_number "$app_slot_size")
if [[ -n "$app_target_id" || -n "$app_hardware_id" ]]; then
[[ -n "$app_target_id" && "$app_hardware_id" =~ ^[A-Za-z0-9_-]{1,32}$ ]] \
|| app_die 'Explicit EndF target and exact hardware identity must be supplied together.'
app_target_id=$(app_number "$app_target_id")
((app_target_id <= 0xffffffff)) || app_die 'EndF target must fit an unsigned 32-bit integer.'
fi
(( app_address >= 0x10000 && app_slot_size > 0 && app_address + app_slot_size <= 0x1000000 )) \
|| app_die 'Requested application slot is outside the supported flash bounds.'
[[ -n "$app_device" && "$app_device" != *$'\n'* && "$app_bench" == /* && -d "$app_bench" ]] \
|| app_die 'Supply a board label and an existing dedicated absolute private bench directory.'
for app_command in awk bash python3 pipx timeout udevadm fuser sudo realpath sha256sum stat readlink cp chmod; do
command -v "$app_command" >/dev/null || app_die "Required command unavailable: $app_command"
done
app_bench=$(realpath -e -- "$app_bench")
[[ "$(stat -Lc '%u' -- "$app_bench")" == "$(id -u)" ]] || app_die 'Bench directory must belong to this user.'
app_permissions=$(stat -Lc '%a' -- "$app_bench")
(( (8#$app_permissions & 077) == 0 )) || app_die 'Bench directory must have no group/other permissions.'
for app_input in "$app_flasher" "$app_image" "$app_backup" "$app_backup_manifest"; do
[[ "$app_input" == /* && -f "$app_input" && -s "$app_input" && ! -L "$app_input" ]] \
|| app_die 'All inputs must be absolute non-symlink regular files.'
done
app_flasher=$(realpath -e -- "$app_flasher")
app_observed=$(sha256sum -- "$app_flasher")
[[ "${app_observed%% *}" == "$app_flasher_hash" ]] || app_die 'The complete flasher differs from the pinned source.'
app_helper="$(dirname -- "$app_flasher")/tools/meshcore_partition_expansion.py"
app_helper_hash=$(sed -n 's/^MESHCORE_PARTITION_EXPANSION_TOOL_SHA256="\([0-9a-f]\{64\}\)"$/\1/p' "$app_flasher")
[[ "$app_helper_hash" =~ ^[0-9a-f]{64}$ && -f "$app_helper" && ! -L "$app_helper" ]] \
|| app_die 'The current flasher must contain its pinned partition/OTA parser.'
app_observed=$(sha256sum -- "$app_helper")
[[ "${app_observed%% *}" == "$app_helper_hash" ]] || app_die 'The partition/OTA parser differs from the flasher pin.'
app_assert_unowned() {
local output='' status=0
if output=$(sudo -n fuser "$1" 2>&1); then app_die 'Selected serial port has an owner; no process will be killed.'; else status=$?; fi
[[ "$status" == 1 && -z "$output" ]] || app_die 'Cannot prove that the selected serial port is unowned.'
}
app_guard_usb() {
local physical selected initial
physical=$(readlink -e -- "/dev/serial/by-path/$app_usb_path") || app_die 'Exact physical USB path is absent.'
selected=$(readlink -e -- "${DEVICE_PORT:-$app_port}") || app_die 'Selected serial endpoint is absent.'
[[ "$selected" == "$physical" && -c "$physical" ]] || app_die 'Selected port and exact physical path disagree.'
if [[ -z "${ESP32_FLASH_EXPECTED_MAC:-}" ]]; then
initial=$(readlink -e -- "/dev/serial/by-id/$app_usb_id") || app_die 'Exact initial USB by-id is absent.'
[[ "$initial" == "$physical" ]] || app_die 'Exact USB by-id and physical path disagree.'
fi
app_assert_unowned "$selected"
}
app_guard_usb
app_state=$(mktemp -d "$app_bench/.mcfirmware-app.XXXXXX")
app_copy="$app_state/application.bin"
cp --reflink=auto -- "$app_image" "$app_copy"
chmod 600 -- "$app_copy"
# This validator uses only pinned, pure image/table/OTA parsing functions from
# the actual flasher helper. It cannot operate a serial port or write firmware.
cat > "$app_state/validate.py" <<'PY'
import hashlib, importlib.util, json, os, pathlib, struct, sys
mode, helper_path, state_dir, *args = sys.argv[1:]
state = pathlib.Path(state_dir)
spec = importlib.util.spec_from_file_location('reviewed_partition_parser', helper_path)
helper = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = helper
spec.loader.exec_module(helper)
def check(value, reason):
if not value:
raise ValueError(reason)
def digest(data):
return hashlib.sha256(data).hexdigest()
def layout(table, capacity):
parts = helper.parse_table(table, capacity)
apps = sorted((part for part in parts if part['type'] == 0), key=lambda part: part['subtype'])
check([part['subtype'] for part in apps] == [0x10, 0x11]
and all(part['flags'] == 0 for part in apps),
'only two unencrypted contiguous OTA application identities are supported')
ota = [part for part in parts if part['type'] == 1 and part['subtype'] == 0]
check(len(ota) == 1 and ota[0]['size'] == 0x2000 and ota[0]['flags'] == 0,
'a unique unencrypted 8192-byte OTA metadata partition is required')
return parts, apps, ota[0]
def selected_slot(parts, apps, otadata, address, size):
active = helper.active_ota_offset(parts, otadata)
slot = next((part for part in apps if part['offset'] == active), None)
check(slot is not None and slot['offset'] == address and slot['size'] == size,
'requested address/size does not match the CRC-verified active OTA slot')
return slot
try:
if mode == 'inputs':
backup_path, manifest_path, expected_backup, expected_image, expected_table, mac, address, slot_size, expected_target, expected_hardware = args
address, slot_size = int(address), int(slot_size)
for path in (backup_path, manifest_path):
metadata = os.stat(path, follow_symlinks=False)
check(metadata.st_uid == os.geteuid() and metadata.st_mode & 0o077 == 0,
'backup inputs must be private and owned by this user')
manifest = helper.safe_json(pathlib.Path(manifest_path).read_bytes())
check(isinstance(manifest, dict), 'backup manifest must be an object')
capacity = manifest.get('flash_bytes')
check(type(capacity) is int and capacity in (0x800000, 0x1000000),
'backup must pin a supported 8/16MiB flash capacity')
backup = pathlib.Path(backup_path).read_bytes()
check(len(backup) == capacity and digest(backup) == expected_backup,
'full backup size/hash mismatch')
check(manifest.get('complete') is True and manifest.get('operation') == 'read-only-flash-backup'
and manifest.get('chip') == 'ESP32-S3' and manifest.get('device_mac') == mac
and manifest.get('offset') == 0 and manifest.get('length') == capacity
and manifest.get('sha256') == expected_backup
and manifest.get('artifact') == pathlib.Path(backup_path).name,
'full backup manifest identity/range/hash mismatch')
table = backup[0x8000:0x9000]
check(digest(table) == expected_table, 'backup partition table does not match its explicit pin')
parts, apps, ota = layout(table, capacity)
backup_ota = backup[ota['offset']:ota['offset'] + ota['size']]
slot = selected_slot(parts, apps, backup_ota, address, slot_size)
image = (state/'application.bin').read_bytes()
check(0 < len(image) <= slot_size and (len(image) + 0xfff) // 0x1000 * 0x1000 <= slot_size
and digest(image) == expected_image, 'application length/erase extent/hash mismatch')
chip, image_capacity = helper.image_header(image)
check(chip == 9 and image_capacity == capacity and image[2] == 2,
'application must declare ESP32-S3, the exact physical capacity, and DIO')
check(struct.unpack_from('<I', image, 32)[0] == 0xabcd5432,
'image has no application descriptor at the first segment')
if expected_target == '0':
# Production Full builds intentionally disable LoRa OTA and stamp
# target zero. The migration parser requires a nonzero target;
# this explicit opt-in still checks every EndF integrity field.
trailer = image[-56:]
check(len(trailer) == 56 and trailer[:4] == b'EndF'
and struct.unpack_from('<I', trailer, 4)[0] == len(image) - 56
and trailer[8:16] == hashlib.sha256(image[:-56]).digest()[:8],
'application EndF length/body hash invalid')
version, target = struct.unpack_from('<II', trailer, 16)
hardware_bytes = expected_hardware.encode('ascii')
check(version != 0 and target == 0
and trailer[24:] == hardware_bytes.ljust(32, b'\0'),
'application EndF version/explicit zero target/exact hardware mismatch')
hardware = expected_hardware
else:
version, target, hardware = helper.endf_identity(image)
check(version and target and hardware, 'application EndF identity invalid')
if expected_target:
check(target == int(expected_target) and hardware == expected_hardware,
'application EndF differs from the explicit target/hardware pins')
(state/'inputs.json').write_text(json.dumps(dict(
image_bytes=len(image), image_sha256=expected_image, backup_sha256=expected_backup,
partition_sha256=expected_table, firmware_body_hash16=image[-48:-40].hex(),
target_id=target, hardware_id=hardware, firmware_version=version, flash_bytes=capacity,
application_address=address, slot_size=slot_size, slot_name=slot['name'],
otadata_address=ota['offset'], otadata_size=ota['size'],
backup_otadata_sha256=digest(backup_ota))))
(state/'backup-table.bin').write_bytes(table)
elif mode == 'probe':
inputs = json.loads((state/'inputs.json').read_text())
capacity, chip = helper.flash_identity((state/'flash-id.log').read_text())
check(capacity == inputs['flash_bytes'] and chip == 9,
'live chip/capacity differs from the pinned full backup')
elif mode == 'live':
expected_table, = args
inputs = json.loads((state/'inputs.json').read_text())
table = (state/'table-before.bin').read_bytes()
check(len(table) == 0x1000 and digest(table) == expected_table
and table == (state/'backup-table.bin').read_bytes(), 'live table differs from the exact pinned backup')
parts, apps, ota = layout(table, inputs['flash_bytes'])
check(ota['offset'] == inputs['otadata_address'] and ota['size'] == inputs['otadata_size'],
'live OTA metadata geometry differs from the pinned backup')
selected_slot(parts, apps, (state/'otadata-before.bin').read_bytes(),
inputs['application_address'], inputs['slot_size'])
elif mode == 'readback':
expected_image, expected_table = args
original = (state/'application.bin').read_bytes()
observed = (state/'application-readback.bin').read_bytes()
check(observed == original and digest(observed) == expected_image, 'hardware app readback hash mismatch')
check((state/'table-after.bin').read_bytes() == (state/'table-before.bin').read_bytes()
and digest((state/'table-after.bin').read_bytes()) == expected_table,
'partition table changed during app-only write')
check((state/'otadata-after.bin').read_bytes() == (state/'otadata-before.bin').read_bytes(),
'OTA metadata changed during app-only write')
else:
raise ValueError('unknown validation mode')
except Exception as error:
# Only fixed validator/parser reasons or exception classes; never raw flash,
# JSON/file contents, paths or credential-bearing operating-system errors.
safe_reason = type(error) is ValueError or type(error) is helper.ExpansionError
print('App validation failed: ' + (str(error) if safe_reason else type(error).__name__), file=sys.stderr)
sys.exit(1)
PY
python3 "$app_state/validate.py" inputs "$app_helper" "$app_state" "$app_backup" "$app_backup_manifest" \
"$app_backup_hash" "$app_image_hash" "$app_table_hash" "$app_mac" "$app_address" "$app_slot_size" \
"$app_target_id" "$app_hardware_id" 2> "$app_state/validation-inputs.log" \
|| { cat "$app_state/validation-inputs.log" >&2; app_die "Offline input validation failed; private evidence: $app_state"; }
read -r app_flash_bytes app_ota_address app_ota_size < <(python3 - "$app_state/inputs.json" <<'PY'
import json, sys
value = json.load(open(sys.argv[1]))
print(value["flash_bytes"], value["otadata_address"], value["otadata_size"])
PY
)
app_bytes=$(stat -Lc '%s' -- "$app_copy")
app_definitions="$app_state/functions.sh"
awk '
{ sub(/\r$/, "") }
/^[A-Za-z_][A-Za-z0-9_]*\(\)[[:space:]]*\{[[:space:]]*$/ { if (capture) exit 2; capture=1; count++ }
capture { print }
capture && /^}[[:space:]]*$/ { capture=0 }
END { if (capture || count < 100) exit 2 }
' "$app_flasher" > "$app_definitions"
bash -n "$app_definitions"
# shellcheck source=/dev/null
source "$app_definitions"
for app_function in esptool_set_variables save_selected_serial_port prepare_esp32_flash_session \
selected_flash_serial_port esp32_record_and_verify_probe_output esp32_verified_destructive_port \
run_esp32_session_esptool esp32_write_after_mode finish_esp32_flash_session; do
declare -F "$app_function" >/dev/null || app_die "Flasher lacks safety function: $app_function"
done
PWD_SCRIPT=$(dirname -- "$app_flasher")
FIRMWARE_ROOT=$app_state DOWNLOAD_DIR="$app_state/downloads"
mkdir -- "$DOWNLOAD_DIR"
DEVICE_PORT=$app_port DEVICE_PORT_FILE="$app_state/selected-port" DEVICE_PORT_NAME_FILE="$app_state/selected-by-id"
DEVICE=$app_device ARCHITECTURE=esp32
ESPTOOL_PIPX_APP='' ESPTOOL_VERSION_OUTPUT='' ESPTOOL_CMD='pipx run --spec esptool esptool'
ESP32_SAFE_BAUD=115200 ESP32_FLASH_EXPECTED_MAC='' ESP32_SESSION_IS_S3=0 ESP32_NATIVE_ROM_READY=0
ESP32_FLASH_SELECTED_BY_ID='' ESP32_FLASH_EXPECTED_SERIAL='' ESP32_FLASH_EXPECTED_PATH_STEM=''
BOOTLOADER_PROBE_PORT='' BOOTLOADER_PROBE_ACTIVE=0 USB_RESET_FAILED=0
USB_RESET_RECOVERED_PORT='' USB_RESET_EXPECTED_IDENTITY=''
LOCKEDSERVICE=None MCFIRMWARE_NO_SUDO=1 MESH_DISABLE_1200_RECOVERY=1
ESP32_PROBE_TIMEOUT_SECONDS=12 NRF52_DFU_REENUMERATE_TIMEOUT_SECONDS=30 NRF52_DFU_REENUMERATE_POLL_SECONDS=0.25
MESHCORE_USB_RESET_TOOL_SHA256=$(sed -n 's/^MESHCORE_USB_RESET_TOOL_SHA256="\([0-9a-f]\{64\}\)"$/\1/p' "$app_flasher")
MESHCORE_ESP32_BOOTLOADER_TOOL_SHA256=$(sed -n 's/^MESHCORE_ESP32_BOOTLOADER_TOOL_SHA256="\([0-9a-f]\{64\}\)"$/\1/p' "$app_flasher")
[[ "$MESHCORE_USB_RESET_TOOL_SHA256" =~ ^[0-9a-f]{64}$ && "$MESHCORE_ESP32_BOOTLOADER_TOOL_SHA256" =~ ^[0-9a-f]{64}$ ]] \
|| app_die 'Current flasher must pin both bundled USB handoff helpers.'
prepare_serial_port_for_flash() { app_assert_unowned "$1"; }
recover_busy_serial_port() { return 1; }
offer_serial_port_recovery() { return 1; }
offer_identity_safe_1200_touch() { return 1; }
manual_reboot_choice() { return 1; }
esp32_recover_interrupted_transport() { return 1; }
esp32_run_chunked_write_recovery() { return 1; }
resolve_meshcore_usb_reset_tool() {
local tool="$PWD_SCRIPT/tools/meshcore_usb_reset.py"
meshcore_usb_reset_tool_hash_matches "$tool" || return 1
printf '%s\n' "$tool"
}
resolve_meshcore_esp32_bootloader_tool() {
local tool="$PWD_SCRIPT/tools/meshcore_esp32_bootloader.py"
resolve_meshcore_usb_reset_tool >/dev/null || return 1
meshcore_esp32_bootloader_tool_hash_matches "$tool" || return 1
printf '%s\n' "$tool"
}
app_notice() {
if ((app_active)); then
printf 'App-only session incomplete. No blind reset/recovery attempted; inspect private state %s.\n' "$app_state" >&2
fi
}
trap app_notice EXIT
esptool_set_variables > "$app_state/esptool-version.log" 2>&1
[[ "$NORESET" == no-reset && "$READFLASH" == read-flash && "$WRITEFLASH" == write-flash ]] \
|| app_die 'The reviewed native-S3 finish requires esptool5 or newer.'
ESP32_OPERATION_BEFORE=$NORESET
app_guard_usb
app_active=1
prepare_esp32_flash_session "$DEVICE_PORT" "$DEVICE" > "$app_state/prepare.log" 2>&1
[[ "$ESP32_SESSION_IS_S3" == 1 && "$ESP32_FLASH_EXPECTED_MAC" == "$app_mac" ]] \
|| app_die 'Prepared silicon is not the explicitly selected ESP32-S3 MAC.'
DEVICE_PORT=$(selected_flash_serial_port "$DEVICE_PORT")
app_guard_usb
run_esp32_session_esptool "$DEVICE_PORT" --after "$NORESET" --baud 115200 "$FLASHID" > "$app_state/flash-id.log" 2>&1
app_probe=$(cat "$app_state/flash-id.log")
esp32_record_and_verify_probe_output "$app_probe" > "$app_state/mac-check.log" 2>&1
[[ "$ESP32_FLASH_EXPECTED_MAC" == "$app_mac" ]] || app_die 'Live chip MAC mismatch.'
python3 "$app_state/validate.py" probe "$app_helper" "$app_state"
app_read() {
local offset=$1 length=$2 output=$3
app_guard_usb
DEVICE_PORT=$(esp32_verified_destructive_port "$DEVICE_PORT" 'app-only pinned readback')
app_guard_usb
run_esp32_session_esptool "$DEVICE_PORT" --after "$NORESET" --baud 115200 \
"$READFLASH" "$offset" "$length" "$output" > "$output.log" 2>&1
}
app_read 0x8000 0x1000 "$app_state/table-before.bin"
app_read "$app_ota_address" "$app_ota_size" "$app_state/otadata-before.bin"
python3 "$app_state/validate.py" live "$app_helper" "$app_state" "$app_table_hash"
app_guard_usb
DEVICE_PORT=$(esp32_verified_destructive_port "$DEVICE_PORT" 'application-only active OTA slot write')
app_guard_usb
[[ "$ESP32_FLASH_EXPECTED_MAC" == "$app_mac" ]] || app_die 'Immediate pre-write silicon identity mismatch.'
app_observed=$(sha256sum -- "$app_copy")
[[ "${app_observed%% *}" == "$app_image_hash" ]] || app_die 'Private application copy changed before write.'
# The sole flash write in this adapter: never erase-all, another slot, table,
# OTA metadata, identity, NVS or filesystem. Keep native S3 in ROM until readback succeeds.
run_esp32_session_esptool "$DEVICE_PORT" --after "$NORESET" --baud 115200 \
"$WRITEFLASH" "$app_address" "$app_copy" > "$app_state/write.log" 2>&1
app_read "$app_address" "$app_bytes" "$app_state/application-readback.bin"
app_read 0x8000 0x1000 "$app_state/table-after.bin"
app_read "$app_ota_address" "$app_ota_size" "$app_state/otadata-after.bin"
python3 "$app_state/validate.py" readback "$app_helper" "$app_state" "$app_image_hash" "$app_table_hash"
app_guard_usb
# UART adapters can require the final harmless probe to carry their normal
# after-mode reset; native USB keeps no-reset until the real guarded finish.
DEVICE_PORT=$(esp32_verified_destructive_port "$DEVICE_PORT" 'application-only verified runtime handoff')
app_guard_usb
[[ "$ESP32_FLASH_EXPECTED_MAC" == "$app_mac" ]] || app_die 'Final runtime handoff silicon identity mismatch.'
app_after=$(esp32_write_after_mode "$DEVICE_PORT")
if [[ "$app_after" != "$NORESET" ]]; then
run_esp32_session_esptool "$DEVICE_PORT" --after "$app_after" --baud 115200 "$READMAC" \
> "$app_state/runtime-reset.log" 2>&1
fi
finish_esp32_flash_session "$DEVICE_PORT" > "$app_state/finish.log" 2>&1
BOOTLOADER_PROBE_ACTIVE=0 BOOTLOADER_PROBE_PORT=''
app_active=0
python3 - "$app_state" "$app_mac" "$app_flasher_hash" <<'PY'
import hashlib, json, pathlib, sys
state, mac, flasher_hash = pathlib.Path(sys.argv[1]), sys.argv[2], sys.argv[3]
result = json.loads((state/'inputs.json').read_text())
result.update(schema_version=1, operation='application-only-flash', complete=True,
device_mac=mac, chip='ESP32-S3',
application_readback_sha256_verified=True, partition_table_unchanged=True,
otadata_unchanged=True, other_flash_ranges_written=False,
live_otadata_sha256=hashlib.sha256((state/'otadata-before.bin').read_bytes()).hexdigest(),
flasher_sha256=flasher_hash,
definitions_sha256=hashlib.sha256((state/'functions.sh').read_bytes()).hexdigest(),
session_finish='real flasher completed; application health requires separate verification')
(state/'result.json').write_text(json.dumps(result, indent=2)+'\n')
print(json.dumps(result, sort_keys=True))
PY
printf 'Private app-only evidence: %s\n' "$app_state"