sysupgrade: verify the rootfs before flashing the kernel, and bound the verify-mount#2220
Conversation
…he mount do_update_rootfs() loop-mounts the new rootfs to read its SoC stamp and version before writing it. Three problems with that check, all in the failure path: 1. It runs too late. The kernel is flashed first and the rootfs is verified afterwards, so a rootfs that fails verification is only discovered once the kernel has already been committed. The device is left half-upgraded — new kernel, old rootfs — and does not recover on its own. 2. It can hang forever. The mount does not merely fail on a rootfs the running kernel cannot read; it can block indefinitely. Nothing is printed between the mount and the flash write, so a wedged mount is indistinguishable from a dead tool: the upgrade stops with no error and no timeout, and takes any GUI driving it down with it. Reported as a flashing tool "stuck at 60% forever", which is exactly the kernel-done/rootfs-about-to-start boundary. 3. It is fatal when it need not be. An unmountable rootfs is not a defect in the image — it means the RUNNING kernel lacks the squashfs decompressor the NEW image uses (commonly XZ), a property of the kernel being replaced and irrelevant the moment the new one boots. The mount is only a pre-check; flashcp writes the partition raw and never needs it. Aborting rejects an image that would have flashed correctly. So: split verification out of the write into verify_rootfs(), run it before the first flashcp, bound the mount with `timeout`, and warn rather than die when the image cannot be mounted. The SoC check is preserved. Mounting the rootfs is how its /etc/hostname is read to confirm the image matches the SoC, so it is not dropped lightly. When the mount fails but a kernel is flashed in the same run, the uImage header carries the SoC and is validated with no mount at all, so that case stays covered. A rootfs-only flash of an unmountable image has no SoC evidence, and writing an unverified rootfs for a foreign SoC bricks the device — that case still refuses, before anything is written, and says how to proceed. Net effect: a rootfs problem now costs nothing instead of stranding the device half-flashed, and the upgrade can no longer hang with no output. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RLuRBpBJzbd6Uba5U7u7Qy
|
Gentle bump on this one — CI is green and it still applies cleanly. I appreciate everyone's time is thin, so I'll keep this to what's new since I opened it rather than restating the PR. This is still actively biting people. As of yesterday (15 July), the peer-to-peer advice being passed around for flashing an SSC338Q air unit is to give up on the tooling entirely and do it by hand: That's a user independently arriving at "bypass The related client-side fix landed. OpenIPC/companion#190 was merged into
The change is 68/9 on a single file, keeps the SoC check intact (mount-fail + rootfs-only still dies, but before any write), and I validated it with I notice the repo already has a shell-test pattern ( Also glad to split this into smaller commits, or to drop (3) and land only the "verify before you write" and timeout parts if the warn-instead-of-die change is the contentious one. The half-flash on failure is the piece I'd most like to see fixed regardless of what happens to the rest. |
widgetii
left a comment
There was a problem hiding this comment.
Reviewed against master — the core claims check out, and this also quietly fixes an unreported instance of the same half-flash bug in the combined path (master's do_update_firmware flashed the FIT kernel before the rootfs check could reject; now verify_rootfs runs right after the split). Details inline; summary:
- Must-fix: the new
diemessage advises--skip_soc, which is not an option (line 138). - Should-fix: "SoC is validated from the kernel image" does not hold for FIT kernels or ingenic/rockchip (lines 130-140) — still net-better than master there, but the message overpromises.
- Minor:
-ron a combined-image platform refuses while--urlproceeds on identical evidence (line 186); plus diagnosability/output nits.
Verified along the way: CONFIG_TIMEOUT=y in the main busybox config, so the applet is present on real firmware (only the initramfs config lacks it, where sysupgrade doesn't run; the command -v fallback covers it); busybox 1.36 accepts the positional timeout SECS syntax; rootfs_version/exit_update wiring is sound on every reachable path; same-version skip and -f/force semantics are preserved; loop/mountpoint cleanup is equal-or-better than the old umount && rm && losetup -d chain; self_update's grep scr_version | head -1 still resolves line 3 first.
One ask beyond the inline notes: please commit the 12-case harness. .github/workflows/shell-tests.yml already runs .github/scripts/test_load_hisilicon.sh, so there is exact precedent — this failure matrix (especially the "kernel was never written" assertions) is the kind that regresses silently.
| if [ "1" != "$update_kernel" ] && [ "1" != "$skip_soc" ]; then | ||
| losetup -d "$loop" 2>/dev/null | ||
| rm -rf "$y" | ||
| die "Cannot verify the SoC of an unmountable rootfs. Flash the matching kernel in the same run, or pass --skip_soc." |
There was a problem hiding this comment.
--skip_soc is not an option. The parser accepts -f | --force_all | --force_md5 | --force_soc | --force_ver (line 547); --skip_soc falls through to the *) case → Unknown option → exit. A user following this advice mid-recovery gets rejected by the tool that sent them there. Should read --force_soc (or -f). Same typo in the PR description.
| # The SoC is also stamped in the uImage header and validated with no mount | ||
| # at all, so a kernel flashed in this same run still guards against a | ||
| # wrong-SoC image. Flashing the rootfs alone leaves no SoC evidence — and | ||
| # writing an unverified rootfs for a foreign SoC bricks the device, so that | ||
| # is the one case worth refusing. | ||
| if [ "1" != "$update_kernel" ] && [ "1" != "$skip_soc" ]; then | ||
| losetup -d "$loop" 2>/dev/null | ||
| rm -rf "$y" | ||
| die "Cannot verify the SoC of an unmountable rootfs. Flash the matching kernel in the same run, or pass --skip_soc." | ||
| fi | ||
| echo_c 33 "Skipping the rootfs version/SoC pre-check; SoC is validated from the kernel image." |
There was a problem hiding this comment.
This reassurance doesn't hold everywhere:
- FIT kernels (
d00dfeed— exactly the combined cv6xx/hi3519dv500 imagesdo_update_firmwarefeeds intoverify_rootfs):do_update_kernelskips the uImage SoC probe (lines 75-76), and its own comment says it relies on "the rootfs hostname SoC check" — i.e. the check that just failed here. - ingenic / rockchip:
check_socis skipped for those vendors (line 79).
In those cases this branch proceeds with no SoC validation at all. To be fair, that is still net equal-or-better than master — the old code flashed that same unverified kernel first and only then died — so not a regression. But the message and the comment above overpromise, and the next reader will reason from them.
Minimal fix: reword to "validated from the kernel image where possible" and note the FIT/ingenic/rockchip exception. Tighter option: probe the kernel file here — if it carries no probeable uImage SoC (FIT magic, excluded vendor), treat it like the rootfs-only case and refuse unless forced. Trade-off: that would refuse a legitimate manifest-downloaded cv6xx upgrade, where the artifact name (firmware.bin.$model) already pins the model — so if you tighten, maybe only for local --kernel=/--rootfs= files (remote_update unset).
| echo "Split combined image: FIT ${fitsz}B -> kernel, remainder -> rootfs" | ||
| dd if="$x" bs=65536 count="$blocks" of=/tmp/uImage.$model 2>/dev/null | ||
| dd if="$x" bs=65536 skip="$blocks" of=/tmp/rootfs.squashfs.$model 2>/dev/null | ||
| verify_rootfs "/tmp/rootfs.squashfs.$model" |
There was a problem hiding this comment.
sysupgrade -r on a combined-image platform reaches here with update_kernel unset, so an unmountable rootfs takes the refuse-branch — "Flash the matching kernel in the same run" — even though this function unconditionally flashes the kernel on the very next line. Meanwhile --url/--channel (which set both flags) proceed on identical SoC evidence. Same evidence, opposite outcomes, and the advice is a no-op here.
Suggest making it explicit, e.g. verify_rootfs "/tmp/rootfs.squashfs.$model" kernel_follows with verify_rootfs treating $2 like update_kernel=1. (Avoid the update_kernel=1 verify_rootfs env-prefix idiom — in POSIX sh, assignments prefixed to a function call persist in the caller.)
| # it down too. Never let the pre-flight check outlive the flash it guards. | ||
| mount_rootfs() { | ||
| if command -v timeout >/dev/null 2>&1; then | ||
| timeout "$mount_wait" mount "$1" "$2" 2>/dev/null |
There was a problem hiding this comment.
Two notes, neither blocking:
CONFIG_TIMEOUT=yis set ingeneral/package/busybox/busybox.config, so the applet exists on real firmware (only the initramfs config lacks it, where sysupgrade doesn't run) — the fallback is the right belt-and-braces.timeoutsends TERM and busybox then waits to reap the child, so a mount wedged in uninterruptible sleep (D state) survives it and the hang remains. The realistic failures — missing decompressor → fast error, slow loop probe — are bounded, so best-effort is fine; just soften "Never let the pre-flight check outlive the flash" in the comment above, since this can't fully guarantee that.
| local loop= | ||
| rootfs_version= | ||
|
|
||
| if mkdir -p "$y" && loop=$(losetup -f) && losetup "$loop" "$x" 2>/dev/null \ |
There was a problem hiding this comment.
The 2>/dev/null here (and inside mount_rootfs) discards the actual error text the old code surfaced, and the warn block then guesses "likely lacks the squashfs decompressor" — which also masks loop-device exhaustion, a truncated download, etc. Consider capturing it (err=$(mount ... 2>&1)) and echoing it in the warn block; on a bricked-upgrade support thread, that one line is the difference between a diagnosis and a shrug.
Also consider -t squashfs -o ro for the verify-mount — it skips kernel fs autoprobing of a potentially garbage file.
| # This is a PRE-FLIGHT CHECK ONLY: the image is written with flashcp, which writes | ||
| # the partition raw and never needs the mount. So it must run BEFORE anything is | ||
| # written to flash, and it must not reject an image that would have flashed fine. | ||
| verify_rootfs() { |
There was a problem hiding this comment.
Nit: the verify output (SoC OK, New version, going to update) now prints before the Kernel section with no header of its own. A small echo_c 33 "\nVerify" + echo "Verifying rootfs from $x" here would keep tool transcripts self-explanatory — and it restores the "last line before a stall" breadcrumb this PR's description complains about: if the bounded mount does stall for the full mount_wait, the transcript currently goes silent at the previous section again.
Follow-up to the review on OpenIPC#2220. Must-fix: the refusal advised --force_soc's non-existent twin --skip_soc, which the parser rejects as an unknown option — sending a user mid-recovery to a flag that aborts the tool. It now advises --force_soc. The "SoC is validated from the kernel image" reassurance was not true everywhere, so rather than only rewording it, weigh the evidence: soc_guarded_by_kernel() asks whether a kernel flashed in this same run actually carries a SoC check_soc can read with no mount. A FIT image does not (do_update_kernel skips the probe), and check_soc is skipped outright for ingenic/rockchip. With no witness, a downloaded artifact is still pinned to $model by the name it must have to be found, while a hand-picked --rootfs= file is pinned by nothing — so only that last case refuses, and it refuses before any write. Every path here is equal-or-better than master, which flashed the kernel first and died afterwards regardless. do_update_firmware now passes kernel_follows, since it flashes the kernel unconditionally on the next line: `sysupgrade -r` on a combined-image platform reached the refusal with update_kernel unset and was told to "flash the matching kernel in the same run", which was already happening. It now weighs the same evidence as --url/--channel on identical facts. Also: surface the real mount error instead of discarding it and guessing at the cause; mount -t squashfs -o ro to skip fs autoprobing of a possibly garbage file; announce the verify step so a stall has a last line to stop at; and soften the timeout comment, which cannot bound a D-state mount. test_sysupgrade.sh covers the matrix, wired into shell-tests.yml next to test_load_hisilicon.sh. Every failure case asserts the kernel was never written. Verified against master, where 13 checks fail and the flash log shows uImage already on /dev/mtd2 at the point of death — the half-flash — and the hang case runs unbounded where the fix stops at mount_wait. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J6zJtNZz7e5E9YfGEkFFmh
The review pointed out that master's do_update_firmware carried the same half-flash as the split path — the FIT was written before the rootfs check could reject it — which nothing here exercised: no test built a firmware.bin.$model, so image_combined was never set and do_update_firmware never ran. The path the reviewer found the extra bug in was the one path the suite did not touch. Adds make_combined() (FIT + rootfs in one blob, split on the FIT totalsize) and make_archive(), and three cases: the combined split flashes both, the rootfs is verified before the FIT is written, and a foreign SoC refuses without writing. Against master the last two go red with `flashcp -v .../uImage.ssc338q /dev/mtd2` already in the flash log — the combined half-flash, reproduced. 22 checks; master now fails 15 of them. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J6zJtNZz7e5E9YfGEkFFmh
|
Thank you — this is a genuinely useful review. The independent verification (busybox
"SoC is validated from the kernel image" (lines 130-140). You're right, and I took the tighter option rather than rewording — the error was in the code, not just the prose. I'd assumed "a kernel is being flashed" implies "the SoC gets checked", which is exactly what your FIT/ingenic/rockchip note disproves. New
Scoped to
Discarded error text (line 116). Captured and printed — including Verify header (line 107). Added. The harnessCommitted as The green run isn't the interesting number. Pointing it at master — That includes the combined-path bug you found. Your note exposed a real gap: nothing built a One caveat worth stating plainly: an earlier revision of this harness passed everything while a sed rule was quietly blanking every version read, which turned the same-version test green for the wrong reason. I only caught it by running it against master. So I'd treat the red run as the evidence the file is worth having, and I'd rather you trust that than the 22/22. Full master-vs-PR matrix is in the PR description. Happy to split either commit if you'd prefer it in smaller pieces, and if the warn-instead-of-die part is still contentious I'm glad to drop it and land only "verify before you write" + the timeout — the half-flash is the piece I care about most. |
widgetii
left a comment
There was a problem hiding this comment.
Approving. Verified the follow-up independently rather than re-reading it: ran the committed harness on a second machine — PR head passes clean, and pointed at master it puts up the same 15 red with flashcp .../uImage.ssc338q /dev/mtd2 already in the flash log at each point of death. The half-flash is reproduced, not asserted, and the combined-path gap found in review is now covered by its own failing-on-master test.
soc_guarded_by_kernel() is the right resolution — it mirrors do_update_kernel's actual skips instead of assuming them, and the remote_update scoping keeps name-pinned downloads working while refusing only the genuinely unguarded hand-picked-file case, before anything is written. No need to split the commits, and warn-instead-of-die should stay: it's properly guarded now, and no path is worse than master.
Problem
do_update_rootfs()loop-mounts the new rootfs to read its SoC stamp and version before writing it. The check itself is reasonable — but its failure path has three problems, and together they can strand a device.1. It runs too late — a failure leaves the device half-flashed
The kernel is flashed first, and the rootfs is verified after:
So a rootfs that fails verification is only discovered once the kernel has already been committed. The device reboots with a new kernel on an old rootfs and does not recover by itself. The user sees a flash that "didn't take", with no obvious reason.
2. It can hang forever
The mount does not always fail on a rootfs the running kernel cannot read — it can block indefinitely. And there is no output at all between the
echoand the flash write:Everything in that condition is silent, so a wedged mount is indistinguishable from a dead tool: the upgrade stops with no error, no timeout, and no way to tell what happened. Any GUI driving sysupgrade goes down with it.
We hit this on an SSC338Q air unit. Verbatim, from the flashing tool's log:
It never emitted another byte. Note
do_update_kernel()has no mount and goes straight toflashcp, which is exactly why the kernel writes fine and streams progress while the rootfs produces nothing — the asymmetry is structural.3. It is fatal when it need not be
die "Unable to mount $y!"rejects the flash. But an unmountable rootfs is usually not a defect in the image — it means the running kernel lacks the squashfs decompressor the new image uses (commonly XZ). That is a property of the kernel being replaced, and it stops mattering the moment the new kernel boots. The mount is only a pre-check:flashcpwrites the partition raw and never needs it. So a perfectly good image is refused on a criterion that doesn't apply to it.Fix
Split verification out of the write into
verify_rootfs(), and:flashcp. A rootfs problem now costs nothing — nothing has been written, and the device stays on its working firmware. This alone removes the half-flashed state.timeout(falling back to a baremountwhere the applet is absent), so the pre-flight check can never outlive the flash it guards.diewhen the image cannot be mounted, and letflashcpwrite it.The SoC check is preserved
Mounting the rootfs is how
/etc/hostnameis read to confirm the image matches the SoC, so it isn't dropped lightly. When the mount fails,soc_guarded_by_kernel()asks whether the run has any other SoC witness:check_socvalidates it with no mount at all. Warn and proceed.-k/-r/--url/--channel/--archive) → the artifact is pinned to$modelby the name it must have to be found at all (rootfs.squashfs.$model, md5-verified). Warn and proceed.--rootfs=file → nothing pins it, and writing an unverified rootfs for a foreign SoC bricks the device. This case still refuses — before anything is written — and tells the user to flash the matching kernel in the same run or pass--force_soc.Note that "a kernel is being flashed" is not by itself a SoC witness, which the first version of this PR assumed: a FIT kernel carries no SoC field (
do_update_kernelskips the probe), andcheck_socis skipped outright for ingenic/rockchip. Those now count as no-witness rather than being waved through. Every path is equal-or-better than master, which flashed that same unverified kernel first and only then died.Same-version skip,
--skip_ver,--force_socand the combined-image path all behave as before (do_update_firmwareverifies before its split write too, passingkernel_followssince it flashes the kernel unconditionally straight after).Testing
sh -nanddash -nparse clean. The behavioural harness is committed as.github/scripts/test_sysupgrade.shand wired into the existingshell-tests.ymlnext totest_load_hisilicon.sh. It runs the real script against a stubbed device — no QEMU, no root, a few seconds — and every failure case asserts the kernel was never written.19/19 pass on this branch. More usefully, pointing it at master (
SRC=<master copy> bash .github/scripts/test_sysupgrade.sh) puts 13 checks red, and the flash log in each showsflashcp -v .../uImage.ssc338q /dev/mtd2already recorded at the point of death — the half-flash, reproduced:mount_wait--force_socscr_versionbumped to 1.0.54.