Skip to content

fix(file)!: keep image holes, map the input bmap ranges, rename bmap options - #158

Merged
JanZachmann merged 125 commits into
omnect:mainfrom
JanZachmann:jz-2026-09-28-keep-image-layout
Nov 11, 2026
Merged

JanZachmann merged 125 commits into
omnect:mainfrom
JanZachmann:jz-2026-09-28-keep-image-layout

Conversation

@JanZachmann

@JanZachmann JanZachmann commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • write_partition compares 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 running fallocate -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 that e2cp or mcopy wrote into free space. fallocate, dd and sync are removed from the Docker image.
  • The image is copied with sparse::copy_file, and a partition is read out of the image with the same sparse copy instead of dd conv=sparse: it finds the data with SEEK_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 .gz and .bz2 are 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.
  • omnect-cli writes the bmap of -b itself: 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, and bmaptool is no longer needed at runtime: -b now works in the Docker image, and bmap-tools is removed from the Debian dependencies and the Docker image. The bmap is copied next to the image after the image was written back.
  • The input bmap is the file given with the new --input-bmap argument (requires -b), else <image>.bmap or the bmap of the uncompressed image next to it (e.g. x.wic.bmap for x.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 if bmaptool would 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.
  • The created bmap is named <output image>.bmap and written next to the output image, e.g. x.wic.bmap for x.wic and x.wic.xz.bmap for x.wic.xz, where bmaptool finds it. So with -p it 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 one bmaptool finds next to it, e.g. x.wic.bmap for -i x.wic.xz -p xz, so no bmap of the old image stays.
  • The output image is the input image, for a compressed image without the compression extension, plus the -p extension, as before. The new -o/--output-image writes it to another path, and the input image stays unchanged. With -p, the -o name must end in an extension of the compression that bmaptool reads (.xz, .bz2, .gzip or .gz); without -p it must not have a compression extension. An existing output image must be a regular file, so -o /dev/sdX or 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 -o is overwritten), writing a bmap that bmaptool would 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.bmap for -i x.wic.xz without -p, also if no bmap exists yet and also with --input-bmap or --no-input-bmap, or for an in-place -i x.wic.xz -p xz while x.wic is next to it), or writing a bmap over the input or the output image (e.g. an image named x.bmap), or writing the output image over the input bmap, or to a path that bmaptool reads as the bmap of another image (e.g. -i x.wic -o x.wic.bmap), or writing an image without -b while <output image>.bmap is next to it, or for a compressed output image the bmap of the uncompressed image, which bmaptool also uses. That bmap would no longer match. So for several commands on one image, use -b on each.
  • The output image and the bmap are written to a new file in the same directory and renamed over the old one, so a failed write, e.g. on a full disk, no longer destroys an image that is changed in place. A symlink is followed, and the old file keeps its permissions. A symlink whose target does not exist yet is followed too. The directory needs write access and space for one more image, which is checked before the command runs; an existing output image in a directory the user cannot write is staged in TMPDIR and copied into it, so an in-place run works as before, and the new file belongs to the user who runs omnect-cli. The image is replaced before its bmap: if the image step fails, the old image keeps its bmap, and if the bmap step fails, the old bmap is removed, and the error names it if it stays. An output image that is a mount point, e.g. a file mounted into a container, cannot be replaced by a rename, so its new content is staged in TMPDIR and copied into it; a rename that fails with EBUSY, EXDEV or EPERM (a sticky dir like /tmp and a file of another user) also falls back to this copy; if that copy fails, the staged file is kept with its new bmap, the old bmap is removed, and the error names both. An old bmap that cannot be removed is named in the error too. With -b such 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 under nohup, 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 a TMPDIR that contains : before it starts, because e2tools split their file arguments there. A container started with --rm removes a file kept in its TMPDIR, so the README asks for a TMPDIR mounted from the host for a mounted image.
  • copy-from-image no longer writes the image back. It only reads, so an error can no longer truncate the source, and for x.wic.xz it no longer leaves x.wic next to it. It reads an uncompressed image directly, without a temp copy, so it refuses an output file of -f that is the input image, also through a hard link, a symlink or an output dir, by device and inode.
  • The partition files are written to their own temp dir. Before, they were named <num>.img next to the image, so an image named e.g. 1.img was overwritten by its partition 1. The temp dirs follow TMPDIR.
  • set-device-certificate and set-edge-ca-certificate write 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.
  • Partitions of a GPT with 4096-byte sectors are read at the right offset; before, the offsets were always counted in 512-byte sectors. A partition that does not fit into the image, e.g. from a corrupt GPT, is refused, so its offset cannot overflow.
  • Known limitations, tracked as follow-up tasks: the rename replaces only the name it writes, so other hard links of the output image keep the old image. On a kernel before 5.8, which does not report mount points in statx, a mounted image file with -b is 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 of bmaptool (.tar.*, .zip), nor .zst, .lz4 and .lzo without -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.bmap for x.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-image accepts an output file without a directory, e.g. out.txt, and checks all output dirs before it reads the image.
  • actix-web and actix-http are updated for RUSTSEC-2026-0446, RUSTSEC-2026-0447 and RUSTSEC-2026-0448.
  • Breaking: --generate-bmap-file is renamed to --output-bmap, so the two options read as a pair. The short option -b stays. With -p the bmap is <output image>.bmap, e.g. x.wic.xz.bmap where releases before wrote x.wic.bmap, unless the image is changed in place and a bmap exists next to it. bmaptool finds the new name for x.wic.xz by itself, but a script that passes --bmap x.wic.bmap must use the new name. The name conflicts above fail where they overwrote files before, or wrote a bmap that bmaptool then used for the wrong image: -i x.wic.xz -b without -o or -p now fails, because the new x.wic.bmap would not match x.wic.xz. -p bzip2 writes <image>.bz2 instead of <image>.bzip2, because bmaptool does not read .bzip2. An input named .bzip2 is still read, but with -p the result is written next to it as .bz2, so a second run on the same input fails because that file exists. -o needs .bz2.
  • roxmltree is added to read the bmap, rustix for SEEK_DATA/SEEK_HOLE and statx, signal-hook to remove temp dirs on a stop signal (its signal-hook-registry was already in the tree through tokio), and libc for sigaction (already in the tree); libfs is removed. The test bmap testfiles/image.wic.bmap is now in git (*.bmap is 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 copy reads 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 -d hid both. The sparse copy uses SEEK_DATA/SEEK_HOLE directly: libfs::next_sparse_segments seeks both files to the same offset, so it cannot read a partition out of the image, and libfs::copy_file copies each data segment with a single copy_file_range call, 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 copy runs 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 copy need 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=sparse turned 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.

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>
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>
@JanZachmann JanZachmann changed the title fix(file): keep the image layout when writing a partition back fix(file): keep holes and map ext4 journals in generated bmaps Sep 28, 2026
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>
@JanZachmann JanZachmann changed the title fix(file): keep holes and map ext4 journals in generated bmaps fix(file): keep image holes and map ext4 journals in bmaps Sep 28, 2026
- 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
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>
@JanZachmann JanZachmann changed the title fix(file): keep image holes and map ext4 journals in bmaps fix(file): keep image holes and keep the input bmap ranges mapped Oct 28, 2026
- 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>
@JanZachmann JanZachmann changed the title fix(file): keep image holes and keep the input bmap ranges mapped fix(file): keep image holes and map the ranges of the input bmap Oct 28, 2026
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>
@JanZachmann JanZachmann changed the title fix(file): keep image holes and map the ranges of the input bmap feat(file)!: keep image holes, map the input bmap ranges, rename bmap options Oct 28, 2026
@JanZachmann JanZachmann changed the title feat(file)!: keep image holes, map the input bmap ranges, rename bmap options fix(file)!: keep image holes, map the input bmap ranges, rename bmap options Oct 28, 2026
Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
@JanZachmann
JanZachmann requested a review from mlilien November 10, 2026 07:27

@mlilien mlilien 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.

Thanks, resolved all but one thread (see my reply there). A few more points inline, nothing blocking.

Comment thread src/file/bmap.rs Resolved
Comment thread src/file/output.rs Resolved
Comment thread README.md Resolved
Comment thread src/file/output.rs Resolved
Comment thread src/file/output.rs Resolved
Comment thread src/file/output.rs Resolved
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>
@JanZachmann
JanZachmann requested a review from mlilien November 10, 2026 08:56

@mlilien mlilien 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.

Thanks, all threads are resolved. One regression from db58abe inline (first comment); the rest are smaller hint and doc issues.

Comment thread src/file/output.rs Resolved
Comment thread src/file/output.rs Resolved
Comment thread src/file/bmap.rs Resolved
Comment thread src/file/output.rs Resolved
Comment thread src/file/output.rs Resolved
Comment thread README.md Resolved
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>
@JanZachmann
JanZachmann requested a review from mlilien November 10, 2026 11:19
Comment thread src/file/signal.rs Resolved
Comment thread src/file/output.rs Resolved
Comment thread src/file/functions.rs Resolved
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>
@JanZachmann
JanZachmann requested a review from mlilien November 10, 2026 13:49

@mlilien mlilien 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.

Thanks, all threads are resolved. One data-loss case for mount-point outputs inline (first comment); the rest are smaller hint and ordering issues.

Comment thread src/file/output.rs Resolved
Comment thread src/file/output.rs Resolved
Comment thread src/file/output.rs Resolved
Comment thread src/file/signal.rs Resolved
Comment thread src/lib.rs Resolved
… 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>
@JanZachmann
JanZachmann requested a review from mlilien November 10, 2026 16:35

@mlilien mlilien 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.

Re-review of 691af1a: the open threads are fixed or tracked, so I resolved them. One new point, inline. I'd fix that one before the merge; the rest is fine as follow-ups.

Comment thread src/lib.rs Resolved
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>
@JanZachmann
JanZachmann requested a review from mlilien November 11, 2026 08:27
Comment thread src/file/output.rs Resolved
@JanZachmann
JanZachmann requested a review from mlilien November 11, 2026 09:36
@JanZachmann
JanZachmann merged commit bb473e3 into omnect:main Nov 11, 2026
3 checks passed
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