Repository navigation
fix(file)!: keep image holes, map the input bmap ranges, rename bmap options - #158
Merged
JanZachmann merged 125 commits intoNov 11, 2026
Merged
Conversation
write_partition ran `fallocate -d` on the whole image, which turned every zero-filled block into a hole. A bmap created afterwards with `-b` then skipped those blocks, e.g. ext4 journals that the image deliberately writes as zeros, so a bmap-based flash left the old journal data on the device. Write only the blocks that differ from the image instead. Unchanged blocks keep their allocation, and a block that changed to all zeros is now written too (`dd conv=sparse` skipped it and kept the old content). Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
- all direct dependencies to their latest version, omnect-crypto to 0.5.0 (create_cert_and_key no longer takes the unused extensions argument; the extensions of a certificate without CSR are the same) - actix-web without default features: the OAuth callback server only serves plain HTTP on localhost, and the http2 feature pulled in h2 0.3 (RUSTSEC-2026-0258, no fixed 0.3 release) - flate2 selects its rust_backend itself instead of relying on actix-web's compression features to enable one Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
JanZachmann
requested review from
HarryWaschkeit
and removed request for
HarryWaschkeit
September 28, 2026 12:03
JanZachmann
marked this pull request as draft
September 28, 2026 13:50
libfs was built with `default-features = false`, which also drops its `use_linux` feature. Without it libfs copies byte by byte, so every image omnect-cli works on came back fully allocated, and a bmap created with `-b` mapped the whole image. The `fallocate -d` that write_partition used to run hid this. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
A fresh ext4 journal is all zeros, and a sparse image, e.g. one that xz decompressed into a file, holds it as holes. A bmap then skips the journal, so a bmap-based flash leaves the journal of the previous installation on the device, and after a power cut its replay can apply an old transaction to the new filesystem. Before creating the bmap, rewrite the journal blocks of every ext4 partition with their own content. Holes become allocated zero blocks and all other blocks stay unchanged. debugfs lists the journal blocks, so the package now depends on e2fsprogs. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
libfs::copy_file copies each data segment with one copy_file_range call and ignores a short copy, so a segment over 2 GiB was cut off without error. copy_image keeps the holes and loops until each segment is copied. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
- fall back to /usr/sbin/debugfs when debugfs is not in PATH - reject an invalid s_log_block_size before it is used as a shift - skip partitions whose superblock is past the end of the image - skip the MBR extended container entry - share SECTOR_SIZE between partition and functions Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
The deb package depends on e2fsprogs for debugfs. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
Decompress wrote every zero block as data, so a bmap of a compressed input mapped the whole image. The decompressed file now seeks over zero blocks and keeps them as holes. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
The ext4 journal lookup is only used for bmap generation, so it moves with generate_bmap_file from functions.rs to bmap.rs. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
- a bmap flash onto a device with old content now overwrites the journal - an invalid s_log_block_size is rejected - a partition that starts past the end of the image is skipped - the MBR extended container entry is not returned as a partition Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
…image-layout Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
JanZachmann
marked this pull request as ready for review
October 27, 2026 10:05
- return decoder errors at the end of the stream - truncate the destination before a sparse decompress - write runs of blocks in one call in SparseWriter - find e2fsprogs tools in sbin, reuse get_partitions, share the image block size Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
Sparse decompress turns every zero block into a hole, and a bmap created from it skips them: the GPT entries, FAT sectors of boot and ext4 journals and metadata. A bmap-based flash then keeps what an earlier installation left there. Take the bmap of the input image (next to it, or --bmap) and write the zero blocks of its ranges as data. Without an input bmap, every block is written. This replaces the ext4 journal handling, so debugfs and e2fsprogs are no longer needed. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
- widen bmap ranges to whole 4 KiB blocks, so a hole is not missed when the bmap block size is not a multiple of 4096 - copy-from-image does not read or enforce an input bmap - read the input bmap before the image is copied, and name it in errors - finish the compress stream, so a failed trailer write is an error - revert the unused partition table split - test a flash onto old content and a bmap of another image - README: describe where the created bmap goes and when it maps the whole image Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
The short option -b stays. BREAKING CHANGE: --generate-bmap-file is now --output-bmap. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
mlilien
reviewed
Nov 10, 2026
mlilien
left a comment
Contributor
There was a problem hiding this comment.
Thanks, resolved all but one thread (see my reply there). A few more points inline, nothing blocking.
For an image named x.bmap, the hint to pass --input-bmap or --no-input-bmap came first, although the output bmap check refused both. That check now runs last. A bmap that the output image would replace or become gets one message with the right wording for both cases. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
A file passed as bmap by mistake, e.g. a compressed image, was read into memory as a whole before the parse failed. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
mlilien
reviewed
Nov 10, 2026
The write permission checks now run after the name checks, so "make it writable" does not come first for a file that another check refuses. Without -b, a stale bmap points to --output-image only if -b would replace that bmap; else -b writes <output image>.bmap, which bmaptool finds first. An output that would replace the input bmap, and an input bmap that is the input image, get their own messages, which also fit an explicit --input-bmap. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
A large file that is not UTF-8, e.g. a compressed image, failed with a UTF-8 error after 64 MiB were read, so the size message never showed. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
A small compressed image passed as bmap failed with a bare UTF-8 error. The file is now read as bytes up to the size limit, which also covers a device or a pipe, for which stat reports the size 0. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
mlilien
reviewed
Nov 10, 2026
As PID 1, e.g. in the Docker image, the process ignores the stop signal it raises itself after the cleanup, and the abort that follows ends in SIGSEGV, so a container stopped during a command exited with 139. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
The partition files are in TMPDIR, and e2tools split <file>:<path> at the first ':', so a TMPDIR with ':' failed with a misleading error after the image was already decompressed. The check runs when the first temp dir is created. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
mlilien
reviewed
Nov 10, 2026
mlilien
left a comment
Contributor
There was a problem hiding this comment.
Thanks, all threads are resolved. One data-loss case for mount-point outputs inline (first comment); the rest are smaller hint and ordering issues.
… starts As PID 1 the process ignores a stop signal until the handler is installed, which happened only with the first temp dir, after the checks and the checksum pass of the input bmap. The TMPDIR check ran there too. Both now run when an image command starts. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
The hint for a bmap that would not match the output image named one remedy, chosen by a check of which bmap -b writes. That check did not cover every case, so the hint could name -b while -b was then refused. The hint now names both options. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
copy-from-image reads an uncompressed image in place, so an output file of -f that is the image overwrote it. Such an output is refused before the command starts, by device and inode, so a hard link, a symlink, or the file of the same name in an output dir counts too. The output dirs are checked before a compressed image is decompressed, and the e2cp result check looks at the file that e2cp writes. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
mlilien
reviewed
Nov 11, 2026
mlilien
approved these changes
Nov 11, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
write_partitioncompares the partition file with the image in 4 KiB blocks, only where one of them holds data, and writes only the blocks that differ, instead of runningfallocate -d. It also writes a block that is data in the partition file but a hole in the image, e.g. a zero block of a file thate2cpormcopywrote into free space.fallocate,ddandsyncare removed from the Docker image.sparse::copy_file, and a partition is read out of the image with the same sparse copy instead ofdd conv=sparse: it finds the data withSEEK_DATA/SEEK_HOLE, keeps the holes and copies each data range in a loop. Decompressing an image keeps zero blocks as holes, reads a file made of several streams (e.g. from pbzip2) and ignores zero padding after the last one, and fails on a truncated or corrupt stream. A compressed image is decompressed straight into the temp dir, without a copy first. A symlinked image, e.g. the Yocto deploy link, is detected by its target, so a compressed one is no longer read as uncompressed. Images named.gzand.bz2are decompressed too; before, the output file had the name of the source, so creating it emptied the source. Compressing fails when the end of the stream cannot be written.-bitself: it maps the ranges of the input bmap, or the whole image without one, plus every block the image file stores as data, with a sha256 per range. No zeros are written into the image, so it stays sparse, andbmaptoolis no longer needed at runtime:-bnow works in the Docker image, andbmap-toolsis removed from the Debian dependencies and the Docker image. The bmap is copied next to the image after the image was written back.--input-bmapargument (requires-b), else<image>.bmapor the bmap of the uncompressed image next to it (e.g.x.wic.bmapforx.wic.xz, as the Yocto build creates it). It is only read with-b. The command fails if the input bmap is for an image of another size, if a range does not match its sha256 checksum, if the bmap has no range checksums, if the file is larger than 64 MiB or not UTF-8, or ifbmaptoolwould find the input image itself as its bmap (an image named e.g.x.bmap). The new--no-input-bmap(requires-b) skips it, and the bmap then maps the whole image. The input bmap of an uncompressed image is checked before the image is copied.<output image>.bmapand written next to the output image, e.g.x.wic.bmapforx.wicandx.wic.xz.bmapforx.wic.xz, wherebmaptoolfinds it. So with-pit no longer replaces the bmap of the input image, which stays unchanged. An image changed in place keeps the name of its bmap: the new bmap replaces the onebmaptoolfinds next to it, e.g.x.wic.bmapfor-i x.wic.xz -p xz, so no bmap of the old image stays.-pextension, as before. The new-o/--output-imagewrites it to another path, and the input image stays unchanged. With-p, the-oname must end in an extension of the compression thatbmaptoolreads (.xz,.bz2,.gzipor.gz); without-pit must not have a compression extension. An existing output image must be a regular file, so-o /dev/sdXor a directory is refused before the command runs. A command now fails instead of overwriting an existing file that is not the input image (a path given with-ois overwritten), writing a bmap thatbmaptoolwould also use for an image that stays, i.e.<name>or<name>.<compression>next to it without another bmap that comes first, also named after the target of a symlinked bmap (e.g.x.wic.bmapfor-i x.wic.xzwithout-p, also if no bmap exists yet and also with--input-bmapor--no-input-bmap, or for an in-place-i x.wic.xz -p xzwhilex.wicis next to it), or writing a bmap over the input or the output image (e.g. an image namedx.bmap), or writing the output image over the input bmap, or to a path thatbmaptoolreads as the bmap of another image (e.g.-i x.wic -o x.wic.bmap), or writing an image without-bwhile<output image>.bmapis next to it, or for a compressed output image the bmap of the uncompressed image, whichbmaptoolalso uses. That bmap would no longer match. So for several commands on one image, use-bon each.-bsuch a mounted image fails, because the bmap would be written next to the mount point and not next to the image. All temp dirs are created with mode 0700, because they hold images and keys. Ctrl-C, SIGTERM or SIGHUP removes every temp dir of a command before the process stops; a signal that is ignored, e.g. SIGHUP undernohup, is not handled. The handler starts with each image command, because as PID 1, e.g. in the Docker image, the process ignores a stop signal without a handler. As PID 1 the process then exits with 128 plus the signal number, because it ignores a signal it raises itself. An image command refuses aTMPDIRthat contains:before it starts, because e2tools split their file arguments there. A container started with--rmremoves a file kept in itsTMPDIR, so the README asks for aTMPDIRmounted from the host for a mounted image.copy-from-imageno longer writes the image back. It only reads, so an error can no longer truncate the source, and forx.wic.xzit no longer leavesx.wicnext to it. It reads an uncompressed image directly, without a temp copy, so it refuses an output file of-fthat is the input image, also through a hard link, a symlink or an output dir, by device and inode.<num>.imgnext to the image, so an image named e.g.1.imgwas overwritten by its partition 1. The temp dirs followTMPDIR.set-device-certificateandset-edge-ca-certificatewrite the new certificate and key to a temp dir instead of under fixed names next to the input image, so two runs on one base image no longer share them, a read-only image directory works, and no private key is left there.-bis not detected, and the bmap is written next to the mount point. The created bmap maps every block that the image file stores as data, so for an image that is not sparse it maps the whole image, also with an input bmap. For a partition that does not start on a 4 KiB boundary, a block at the edge of each data run is written back, so the image is less sparse. In both cases the image content is correct. An uncompressed output image is copied twice, into TMPDIR and into the staged file. A stop signal between the image and the bmap step keeps the old bmap next to the new image. The output name checks do not know the archive extensions ofbmaptool(.tar.*,.zip), nor.zst,.lz4and.lzowithout-p. For a symlinked image, the bmap is looked up only next to the link name, so the bmap next to the target is not found and does not match after an in-place change. A bmap with fewer extensions than an image, e.g.x.bmapforx.wic.xz, is not checked as the bmap of an image that stays, because only<name>and<name>.<compression>count as images of<name>.bmap.copy-from-imageaccepts an output file without a directory, e.g.out.txt, and checks all output dirs before it reads the image.actix-webandactix-httpare updated for RUSTSEC-2026-0446, RUSTSEC-2026-0447 and RUSTSEC-2026-0448.--generate-bmap-fileis renamed to--output-bmap, so the two options read as a pair. The short option-bstays. With-pthe bmap is<output image>.bmap, e.g.x.wic.xz.bmapwhere releases before wrotex.wic.bmap, unless the image is changed in place and a bmap exists next to it.bmaptoolfinds the new name forx.wic.xzby itself, but a script that passes--bmap x.wic.bmapmust use the new name. The name conflicts above fail where they overwrote files before, or wrote a bmap thatbmaptoolthen used for the wrong image:-i x.wic.xz -bwithout-oor-pnow fails, because the newx.wic.bmapwould not matchx.wic.xz.-p bzip2writes<image>.bz2instead of<image>.bzip2, becausebmaptooldoes not read.bzip2. An input named.bzip2is still read, but with-pthe result is written next to it as.bz2, so a second run on the same input fails because that file exists.-oneeds.bz2.SEEK_DATA/SEEK_HOLEand statx, signal-hook to remove temp dirs on a stop signal (itssignal-hook-registrywas already in the tree through tokio), and libc forsigaction(already in the tree); libfs is removed. The test bmaptestfiles/image.wic.bmapis now in git (*.bmapis in.gitignore), with checksums that match the test images. The version is bumped to 0.29.0, and ctutils to 0.4.5 for RUSTSEC-2026-0385.Reason
A sparse image, e.g. one that xz decompressed into a file, or one after
fallocate -d, has holes wherever the image is zero, and a bmap skips them. Some of these zeros must still be written: a bmap-based flash otherwise keeps what an earlier installation left there. An old ext4 journal can be replayed after a power cut and apply an old transaction to the new filesystem; old GPT entries break the entry array CRC; old FAT sectors and directory entries show up as used clusters or stale files. On an rpi4-64 image, mapping only the ext4 journals still drops 188 blocks of the Yocto bmap: GPT primary and backup entries, 69 blocks of boot, and 16–24 blocks on every ext4 partition.The Yocto bmap maps all of these ranges, because the build image holds real data there. Keeping its ranges mapped covers every format without knowing about file systems. From a sparse image alone, omnect-cli cannot tell such zero blocks from free space, so without an input bmap it maps the whole image.
bmaptool copyreads a mapped range with plain reads, so a hole in such a range is written as zeros: the zeros only have to be in the bmap, not allocated in the image file. This replaces the image-side approach discussed in omnect/meta-omnect#701.With
default-features = false, libfs made byte copies, so every image came back fully allocated, and the decompress wrote zero blocks as data.fallocate -dhid both. The sparse copy usesSEEK_DATA/SEEK_HOLEdirectly:libfs::next_sparse_segmentsseeks both files to the same offset, so it cannot read a partition out of the image, andlibfs::copy_filecopies each data segment with a singlecopy_file_rangecall, which moves at most 2 GiB, and ignores a short copy.All omnect partitions have a fixed size, so builds of one machine have the same image size, and the bmap of another build (e.g. of an older download next to a new image) passes a size check. The sha256 of each range tells the builds apart;
bmaptool copyruns the same check on flash.omnect-cli derives every output name from the input name and finds the input bmap by name, so names can collide. omnect-cli runs in CI scripts, where nobody reads a warning, so each collision fails with an error that names the option to resolve it. A counter in the output name would not work: a script and the later
bmaptool copyneed a fixed name.Writing only changed blocks also fixes a gap of the old
dd conv=notrunc,sparse: a block that changed to all zeros was skipped and kept its old content; now it is written because it differs. In the same way,dd conv=sparseturned every zero block of a partition into a hole, so a zero block of a copied file that landed in free space stayed a hole in the image, and a bmap flash kept the old content there.