Skip to content

feat(flash): port flash mode 1 to the Rust initramfs - #27

Merged
JanZachmann merged 80 commits into
omnect:mainfrom
JanZachmann:feat/flash-modes
Sep 30, 2026
Merged

JanZachmann merged 80 commits into
omnect:mainfrom
JanZachmann:feat/flash-modes

Conversation

@JanZachmann

@JanZachmann JanZachmann commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Ports flash mode 1, cloning the running disk onto another block device, from the scripted initramfs to the Rust initramfs, together with the shared flash layer that modes 2 and 3 will build on.

  • src/mode/flash/ holds the trigger keys flash-mode and flash-mode-devpath, the partition-table dump rewrite, in-process replacements for the legacy dd calls, the rootfs unmount, the EFI boot-entry handling and the clone sequence.
  • BootMode gets a Flash variant, dispatched before init_setup: a flash mode clones the whole disk, so the init-setup work on the running disk would not be carried over.
  • The destination is compared with the booted disk by device number, so the booted disk and its partitions are refused under any name. A partition of any other disk is refused too. Both checks run before the first write.
  • A flash mode queued together with a factory reset is refused, and both triggers are cleared.
  • The logger can keep the run in memory. It is written to flash-mode-1.log on the source data partition on success and on failure, because the device powers off afterwards.
  • Raw copies sync the destination, so a write-back error fails the clone.
  • On EFI machines the init mounts efivarfs, creates the new boot entry and only then deletes the old ones, so a failure in between leaves the machine with a boot entry.
  • reformat_ext4 moves to src/filesystem/, so images without factory-reset can use it.

Legacy bugs fixed rather than reproduced: the device wait reported a timeout when the device appeared on the last poll; the DOS extended-container size was wrong on every DOS machine; and the partition-UUID refresh also ran on MBR, which has no per-partition UUIDs, so mode 1 failed on DOS. The U-Boot environment size and offsets are in KB; reading them as bytes would have written 64 bytes of a 64 KB environment.

Reason

The scripted initramfs is replaced module by module. Mode 1 comes first because modes 2 and 3 reuse most of what it adds.

  • flash-mode-1 is in the default feature set, like the legacy script that every image installs.
  • The recipe has to pass the partition and bootloader constants into the build and install e2fsprogs (for e2image) and, on EFI machines, efibootmgr; feat(initramfs): replace the scripted initramfs with omnect-os-init meta-omnect#697 does both. Without the constants, mode 1 fails before the first write.
  • The recipe always builds with gpt, so CI does not run the DOS paths.
  • When flash-mode cannot be read, the device boots normally and a queued factory reset waits for the next boot, since a conflict between the two cannot be ruled out.
  • The clone writes to the source disk only to clear the triggers, to store the run log and, on EFI machines, to rewrite the NVRAM boot entries.
  • Inherited from the legacy script (spec §9.1): the clone keeps the source's disk identifier and most partition and filesystem UUIDs, so a lookup by UUID is ambiguous while both disks are attached.
  • A test pins the clone step order of spec §4.1 through a CloneOps trait, and the EFI order through EfiOps.
  • The partition UUIDs are generated before the first destructive step, from /dev/urandom, so a failing random source is an error and not a panic in PID 1.
  • One EFI boot entry instead of the legacy pair is decided in spec §10.2, but needs an EFI hardware run before it ships. That run also confirms the efivarfs mount.

Mode 2 overwrites etc and data with random data from /dev/urandom, mode 3
discards all their blocks with the BLKDISCARD ioctl. The wipe runs between
the backup and the reformat and never aborts the reset: a failure on one
partition does not skip the other, reformat and restore still run, and the
result is reported as Error with a note in the status error field.

Legacy mode 4 (customer-supplied wipe hook) is not ported; a mode-4 trigger
stays rejected as Invalid.

Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
The note is built from the error's Display, which prefixes "Factory reset
error" - inside the factory-reset result that only repeats the obvious. Take
the inner reason instead.

The overwrite test only checked that the last block changed, so a gap in the
middle of the range would have passed. Check every block.

Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
…an ioctl

Seeking to the end of a block device reports its size, which drops one raw ioctl
request code, its unsafe block and the size_t reasoning that came with it — and it
works on a temp file, so the size path is testable. The remaining BLKDISCARD code
is assembled from named constants.

Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
Taking the device size from a seek made both testable against a temp file, so the
tests now exercise wipe_random itself instead of only its inner loop, including
the error a device it cannot open produces, and that the size path rewinds for the
caller that writes from the start.

The progress interval becomes an argument of the overwrite loop. At its 1 GiB
default no test ever crosses it, which left the sync and step arithmetic uncovered.

Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
A trigger that failed to parse only produced a kmsg warning. The boot
continued as Normal, no factory_reset object was written and the trigger kept
its value, so the caller waited for a result that never came and the warning
repeated on every following boot. The on-device tests catch this: they set
broken triggers and expect a status.

The trigger is now carried into factory-reset mode either way. An unusable one
skips the sequence, is cleared like any other, and is reported with the status
the shell implementation used: anything about mode is Invalid, an unusable
preserve is ConfigError. preserve becomes mandatory again - an empty array is
how a caller asks to keep nothing.

The design of the wipe modes describes the reporting as if it already
happened, so it carries a correction.

Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
A file in factory-reset.d without a usable paths array was skipped, and a
broken factory-reset.json was reported as an invalid request. Both are
configuration errors: the caller asked to keep something and the file does
not say what, so skipping it wipes those paths and still reports success.

Every problem in a preserve configuration file now reports ConfigError, which
is what the shell implementation did and what the on-device tests expect.

Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
Deduplication: the same get/as_array/as_str block existed three times after
the trigger parsing was rewritten. It moves into a helper that reports why a
value was not a list of strings, so each caller keeps its own status - a
missing key in the trigger is a different answer than one in a preserve file.
WipeFailed was built at four call sites with the same two fields.

Tests: three of them passed even with the behaviour removed. The overwrite
tests accepted a constant buffer, so mode 2 now checks that two wipes differ
and that a block carries many distinct values; the rejected-trigger tests
built the error themselves instead of walking detect; and the integration
test accepted any mode instead of the one the trigger named. Mode 3 had no
test at all - both of its failure paths work on a regular file. The 32-bit
clamp becomes a function so the 4 GiB case is a table test instead of a
comment. The two new error variants get a recovery-class test, since
classifying either as fatal would block a boot the design says must continue.

Docs: the design of the wipe modes still described the size ioctl that the
seek replaced, and named a constant that was renamed; the README claimed a
value has to be valid json to reach factory-reset mode; the module map had no
factory_reset subtree. The two older factory-reset specs carry a dated
correction, the form this repo uses for superseded statements.

Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
init_setup runs the sync on every boot, and its failure suppresses the
first-boot marker like a failed resize does.

Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
…tion

Every command in the build chapter omitted the partition table, which build.rs
demands, so none of them worked. The overview counted the shell scripts the
binary replaced; the count says nothing once the migration is over.

The startup flow now also names the extra-bootargs update as a reason to
reboot, next to the filesystem check.

Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
The spec was written against d1168de and is brought onto the wipe-modes
base it will merge into. The three plans split the port along the
implementation order the spec pins, one PR each, and record where the
spec meets the changed base: the FactoryResetTrigger enum, the private
partition-number constants, and the error type of the shared
reformat_ext4.

Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
Five defects found reviewing the plans against the runtime:

- adding BootMode::Flash breaks the match in run_init, so the same task
  now adds the arm instead of leaving the tree red until later;
- run_init called detect twice with an unreachable! arm between them. A
  panic in PID 1 is a kernel panic and a reboot loop, so detect is now
  called once and dispatched in one exhaustive match;
- mode 1's log had nowhere to land: the data partition is mounted inside
  normal boot, which a flash mode never reaches, and /sysroot is
  unmounted before the write. Persistence now mounts it itself;
- cargo test does not run tests inside build.rs, so the mode 2 helpers
  move to a file both the build script and the crate include;
- a conflicting trigger and a bad URL both reported themselves as an
  unusable destination device; they get their own variants.

The unmount helper moves into the shared layer, where mode 1 needs it too.

Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
…ry reset

Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
…ppy path

The placeholder Flash arm in run_init always returned ConflictingTriggers,
including for an ordinary flash-mode selection with no factory reset queued.
On a release image that halts forever with flash-mode still set, repeating
on every power cycle, and the error message was false in that case. Add
FlashError::DispatchUnavailable for the real condition, split the trigger
clear into a flash-only helper reused from lib.rs, and clear the flash
trigger before reporting it so a power cycle recovers to a normal boot.

Also make the factory-reset read failure during the conflict check degrade
to Normal like every other read error in detect, restore the doc comment's
explanation of FactoryResetTrigger::Rejected, and strengthen the flash
detect tests to assert the untouched/cleared trigger sets and the
fall-through to factory-reset handling.

Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
Move reformat_ext4 out of the factory-reset module into filesystem so
flash mode 1 can use it too; that mode ships in images built without
factory-reset. The shared helper returns FilesystemError::FormatFailed
instead of FactoryResetError::ReformatFailed, so the factory-reset call
site wraps the error back to keep its ContinueDegraded recovery class
(a factory reset that cannot reformat degrades the boot, it does not
stop it).

Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
flash-mode-1 has no caller for reformat_ext4 yet, so gating the shared
module on any(factory-reset, flash-mode-1) leaves it dead code and
fails -D warnings whenever flash-mode-1 is enabled without
factory-reset, including the default feature set. Narrow both the
module declaration and the re-export to factory-reset until the
flash-mode-1 caller lands and needs the wider gate.

Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
The existing regression guard only asserted the recovery_class mapping
in error.rs, never calling reformat_ext4, RealReformatOps::reformat,
or the map_err wrap this task added, so it would pass unchanged if the
wrap were missing or wrong. Add a test that drives the real call path
with an unformattable device and checks the error comes back as
FactoryResetError::ReformatFailed, not the shared helper's raw
FilesystemError.

Also bring the reformat_ext4 call site's import in line with the
file's own convention (imported alongside the other filesystem::
symbols, not fully-qualified inline), and note reformatting in
filesystem/mod.rs's module doc now that the helper lives there.

Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
…scale

Guard the last-lba and extended-container size computations with checked
add/sub so a malformed dump errors instead of wrapping into a near-u64::MAX
partition size. Require sector-size: 512 alongside unit: sectors, since the
KB-to-sector conversion silently mis-scales on any other sector size. Fix
three size assertions that would still pass with a wrong scale factor by
anchoring them on the field boundary, and remove a panicking expect() from
the non-test parse path.

Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
A write that returns Ok(0) with bytes still outstanding looped forever
instead of failing, unlike the mirrored read-side check. Since this
runs as PID 1, a returned error reaches the existing fatal-error path;
a silent hang does not.

Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
Add MACHINE_FEATURES to Config, read from the initramfs-local
/etc/os-release, since flash modes unmount /sysroot before EFI handling
needs it.

Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>

@JoergZeidler JoergZeidler left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ran fmt, clippy for eight feature combinations (grub,gpt and uboot,dos with test-utils, with factory-reset, with factory-reset,resize-data,release-image, and --no-default-features for both), tests for ten combinations (229 to 429 tests each, all green), cargo audit, and the armv7 clippy from the README: all green. --no-default-features --features grub,gpt,flash-mode,test-utils fails -D warnings, which the README already calls unsupported.

Checked against the legacy flash-mode-1 and common-sh in the meta-omnect history: the three bug claims in section 9 hold, the dump rewrite formulas and the dd replacements match, and the KB units of all five build constants match the .wks files and omnect_conv_size_param. uboot-env.bin is exactly UBOOT_ENV_SIZE KB (mkenvimage -s), so the Some(size) copy length is right.

Labels on every comment: High, Medium, Low; "blocks merge" where I would not merge without it. Nothing is Critical: no finding writes the source disk.

Blocks merge:

  • clone.rs:229 a partition of another disk is accepted, and sfdisk writes into it before the check fails.
  • rawio.rs:102 no fsync; a write-back error on the destination is dropped and the clone reported good.
  • efi.rs:16 efivarfs is never mounted; the EFI path needs a hardware run or an explicit mount before this ships (spec 10.2 already says so for the entry count).
  • clone.rs:469 the clone sequence has no ordering test and no ops trait; same gap as #25 finding 7.

Points without a diff line:

  • The PR text says /sysroot; the rootfs is at /rootfs. See the comment on config/mod.rs:90.
  • meta-omnect#697 exports the five constants, installs e2fsprogs, util-linux-sfdisk and efibootmgr on efi, and keeps flash-mode-1 in the default set. Its recipe hardcodes gpt, so every DOS path here is host-tested only, as the PR text says. Its SRC_URI still points at the fork branch.
  • Spec, top: No **Status:** line; every other spec in the directory has one.
  • Spec, section 7: "logs progress while waiting": the wait logs once now.
  • Spec, section 6: One more deviation for section 9: the loader is passed as \EFI\BOOT\bootx64.efi; the legacy script's unquoted \\\\EFI\\\\BOOT reached efibootmgr as \\EFI\\BOOT\\bootx64.efi. Whether the firmware treats both the same I could not verify.
  • Not verified: efivarfs on the EFI machines; that the target sfdisk emits sector-size: (util-linux 2.34 or later; an older one fails every clone at verify_sector_scale); that e2image -p reaches the console through inherited PID 1 stdio; where the feature combinations run in CI.

Signed-off-by: Joerg Zeidler 62105035+JoergZeidler@users.noreply.github.com

Comment thread src/mode/flash/clone.rs Resolved
Comment thread src/mode/flash/rawio.rs Resolved
Comment thread src/mode/flash/efi.rs Resolved
Comment thread src/mode/flash/efi.rs Resolved
Comment thread src/mode/flash/mod.rs Resolved
Comment thread src/mode/flash/mod.rs Resolved
Comment thread src/mode/mod.rs Resolved
Comment thread src/partition/layout.rs Resolved
Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
…he old ones

Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
…ding

Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
Validate the build constants once, in bytes, before anything is written.
Generate the partition UUIDs before the first destructive step, from
/dev/urandom instead of a panicking call. Keep the last lines of the
e2image output in its error while still forwarding it to the console.

Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
The log writer is injected, so a test sees that a failed run writes a log
carrying its error. The devpath is an enum, the env key is a BootEnvKey,
and flash-mode and flash-mode-devpath are both trimmed. The integration
test keeps only the case the spec assigns to it.

Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
… operation

Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
Also test the factory-reset reformat error mapping without running mkfs.

Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
…ents

Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
…acy does

Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>

@JoergZeidler JoergZeidler left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round 2 on the 12 new commits. Every reply checked against the code; all four merge blockers are fixed as described:

  • a partition of another disk is refused before sfdisk (REASON_NOT_A_WHOLE_DISK, pinned by a_partition_of_another_disk_is_refused);
  • copy_range syncs the destination and reports the write-back error;
  • efivarfs is mounted before efibootmgr, and the new entry is created before the old ones are deleted, pinned by the_new_entry_is_created_before_the_old_ones_are_deleted;
  • clone_with runs on a CloneOps trait and the_clone_runs_its_steps_in_the_spec_order pins the full sequence for all four bootloader x table combinations, plus the two stop-early cases.

Also verified: the run log is written on failure and carries the error (run_and_persist with a log writer), the kmsg hook is tested through a real KmsgLogger on a temp file, required_constants takes the values, the UUIDs come from /dev/urandom before the rootfs is unmounted, e2image output goes through one pipe to the console and the last lines reach the error, /rootfs in spec, plans, project-context and code comments, the spec status line, and the Devpath enum with BootEnvKey in the error. 56 of 57 threads resolved; the devpath-trim one stays open as Low with a note, and the rebase point is withdrawn (squash-only repo).

Machine checks on the new head: fmt, clippy for eight feature combinations, tests for ten combinations (231 to 443 tests each), cargo audit, armv7 clippy: all green.

Two Low points inline, nothing that holds the merge. From my side merge-ready. The EFI path is still without a hardware run; spec 10.2 keeps that as the shipping condition, now covering the efivarfs mount and the loader-path spelling too.

Signed-off-by: Joerg Zeidler 62105035+JoergZeidler@users.noreply.github.com

Comment thread src/mode/flash/clone.rs Resolved
Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>

@JoergZeidler JoergZeidler left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round 3, head 6a629f0: the devpath is trimmed again with parse_devpath_trims_surrounding_whitespace, the plan no longer names the v4 feature, and the 8.2 row covers the queued factory reset. The second URANDOM_PATH stays, the feature-gate reason holds. All four remaining threads resolved.

fmt, clippy (grub,gpt and uboot,dos with factory-reset,test-utils) and tests for four combinations (431 / 420 / 300 / 443) on the new head: green.

Nothing open from my side. The EFI hardware run stays the shipping condition from spec 10.2.

Signed-off-by: Joerg Zeidler 62105035+JoergZeidler@users.noreply.github.com

@JoergZeidler
JoergZeidler self-requested a review September 28, 2026 12:58
@JanZachmann
JanZachmann requested a review from mlilien September 28, 2026 13:08
Comment thread src/mode/flash/efi.rs Resolved
Comment thread src/config/mod.rs Resolved
The recipe selects grub exactly for machines with efi in MACHINE_FEATURES,
so the runtime read of /etc/os-release has no other use and is removed.

Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
@JanZachmann
JanZachmann requested a review from mlilien September 30, 2026 09:02
@JanZachmann
JanZachmann merged commit ff292e9 into omnect:main Sep 30, 2026
@JanZachmann
JanZachmann deleted the feat/flash-modes branch October 23, 2026 06:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants