Repository navigation
fix(file)!: keep image holes, map the input bmap ranges, rename bmap options - #158
JanZachmann wants to merge 76 commits into
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>
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>
- 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>
A mount point cannot be removed, so with an image and its bmap both mounted into a container, -b failed before the image was written. The copy into the bmap replaces its content anyway. Also clearer wording for the mount point case and the TMPDIR space. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
rename(2) and unlink(2) need write access to the directory, which a container often does not have when only the image is mounted. So a detected mount point is not renamed to or removed, its new content is copied into it. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
|
On the nits in the review of 3da5a4d:
|
mlilien
left a comment
There was a problem hiding this comment.
Thanks for the fixes. The three threads from the last round are resolved; I checked each against 6e9c9e1. Clippy (-D warnings) is clean, and unit and integration tests pass locally.
A few new points inline. One is outside the diff, so here:
src/lib.rs create_image_cert (around line 217): it writes the device certificate and private key under fixed names (device_cert_path.pem, device_key_path.key.pem, the edge_ca_* names) next to the input image and never deletes them. This is older than this PR, but -o makes "one base image, many device outputs" the documented workflow, so it matters more now:
- Two jobs running
identity set-device-certificate -i base.wic.xz -o devA.wicand... -o devB.wicin the same workspace at the same time: B overwrites the files while A is still decompressing, so devA.wic can get B's certificate and key. - With a read-only input directory (
-i /ro/base.wic.xz -o /out/dev.wic), the command fails atfs::write. - A device private key is left next to the base image, even when
check_conflictsrejects the run.
Writing the cert/key into a TempDir would fix all three. Fine as a follow-up if you prefer.
| const OTHER_IMAGE_BMAP: &str = "<bmap><ImageSize>4096</ImageSize><BlockSize>4096</BlockSize>\ | ||
| <BlockMap><Range>0</Range></BlockMap></bmap>"; | ||
|
|
||
| fn bmap_path_of(image: &Path) -> PathBuf { |
There was a problem hiding this comment.
bmap_path_of duplicates the public omnect_cli::file::bmap::bmap_path, and four tests (lines 964, 988, 1035, 1092) still build the path inline with format!("{}.bmap", ...). If the naming rule changes, the tests keep the old one. Using bmap_path everywhere avoids that.
There was a problem hiding this comment.
Done in f918204. bmap_path_of is gone, and all inline format!("{}.bmap") paths, plus one with_extension("bmap"), use bmap::bmap_path.
There was a problem hiding this comment.
Almost: tests/integration_tests.rs:1482 (check_bmap_generation_wic_xz_without_input_bmap) still uses image_path_wic_xz.with_extension("bmap") instead of bmap_path(<output image>).
There was a problem hiding this comment.
Fixed in aaa1c8a. check_bmap_generation_wic_xz_without_input_bmap now uses bmap_path of the output image image.wic.
Checked: cargo fmt --check, cargo clippy --all-targets -- -D warnings, cargo test (108 unit, 38 integration, 1 ignored), cargo audit (only RUSTSEC-2025-0141, bincode via mbrman, not from this change).
The copy into a mounted destination, the copy of an uncompressed input image and the uncompressed output image used the same open, size and copy steps. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
The copy into a mounted destination truncates it first. If the copy or the sync fails, the staged file was removed with its temp dir, so neither the old nor the new content was left. Now its temp dir stays, and the error names the file. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
For a mounted destination the staged file in TMPDIR is copied into the destination and then removed, and the copy is synced. Syncing the staged file too wrote the whole image to disk once more. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
Kernels before 5.8 do not report mount roots, and in a chroot `/` is no mount root. The test now checks `/` only if statx supports the attribute and `/` is a mount point in mountinfo. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
The test bmap had no version and no checksums, so `Bmap::read` rejected it and the tests never reached the image size check. It is now a valid bmap of a one-block image, and the tests check for the size error. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
The tests had their own copy of the naming rule. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
`set-device-certificate` and `set-edge-ca-certificate` wrote them under fixed names next to the input image and did not remove them. Two runs on the same base image could use each other's key, a read-only image directory failed, and the private key stayed next to the image. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
The staged output image is in a hidden dir next to the output image, and a dir in TMPDIR for a mount point. SIGINT, SIGTERM or SIGHUP ended the process without destructors, so a dir with a whole image stayed there. A signal-hook thread now removes the registered dirs and then stops the process with the same signal. The README names the dir for the cases no handler can catch. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
…ount point The copy truncates the destination first. A stop signal during the copy removed the staged file with its registered dir, so neither the old nor the new image was left. The dir is now unregistered before the copy, and the log names the staged file. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
…command The temp dir with the new device certificate and key is registered with the stop signal handler, so Ctrl-C or SIGTERM during the image command does not leave the private key in TMPDIR. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
|
On |
mlilien
left a comment
There was a problem hiding this comment.
Thanks for the fixes. I resolved the previous threads except the bmap_path one (one leftover, see reply). New points inline.
| } | ||
|
|
||
| fn start_handler() { | ||
| let mut signals = match Signals::new(STOP_SIGNALS) { |
There was a problem hiding this comment.
Signals::new installs the handler for SIGHUP, SIGINT and SIGTERM without checking whether a signal is set to SIG_IGN. signal-hook-registry skips the ignored previous action but still runs ours, which removes the dirs and exits via emulate_default_handler.
Example: nohup omnect-cli file copy-to-image -i x.wic.xz -p xz -b ... &, then the SSH session closes. Before this PR SIGHUP was ignored and the command finished. Now a hangup during the long xz compress deletes the staged image and the process exits with 129. Same for background jobs in non-interactive shells, which ignore SIGINT.
Fix: query the current disposition with sigaction(sig, NULL, &old) and skip signals that are SIG_IGN.
There was a problem hiding this comment.
Fixed in acf2cb3. Before it registers the handler, start_handler reads the current disposition of each stop signal with sigaction(sig, NULL, &old) and leaves out the ones set to SIG_IGN. is_ignored_reads_the_disposition covers the check. I ran it under nohup and sent SIGHUP during the -p xz compress: the command finished with exit 0 and wrote the image. Without nohup, SIGHUP still stops it with exit 129 and removes the staging dir. libc is new as a direct dependency; it was already in the tree.
| pub fn commit(mut image: Staged, mut bmap: Option<Staged>) -> Result<()> { | ||
| if let Some(bmap) = bmap.as_ref().filter(|bmap| !bmap.mount_root) { | ||
| // a mount point that was not detected cannot be removed, the copy into it replaces it | ||
| match fs::remove_file(&bmap.dest) { |
There was a problem hiding this comment.
commit removes the existing bmap before the image is replaced. If image.rename() or copy_into_dest() then fails, or a stop signal arrives between the two steps, the old image stays and its bmap is gone.
Example: in-place -i x.wic -b where x.wic.bmap is the Yocto/portal input bmap. Rename fails with EPERM (sticky shared dir, x.wic owned by another user), the mount-point copy fails (ENOSPC/EIO), or Ctrl-C lands between remove_file and rename. x.wic.bmap is deleted, and the next -b run silently maps the whole image, so the zero-range information is lost for good.
Fix: replace the image first and then the bmap, or move the old bmap aside and restore it if the image step fails.
There was a problem hiding this comment.
Fixed in 13dfac7. commit now replaces the image first and then the bmap. If the image step fails, nothing is changed, so the old image keeps its bmap. If the bmap step fails, the old bmap is removed, because it no longer matches. One window is left: a signal or a crash between the two steps leaves the new image with the old bmap. Its range checksums no longer match, so bmaptool copy and the next -b run refuse it, and the error is not silent. commit_keeps_the_old_bmap_when_the_image_rename_fails covers the new case.
| num: partition_num, | ||
| start: entry.starting_lba, | ||
| count: entry.ending_lba - entry.starting_lba + 1, | ||
| start: entry.starting_lba * scale, |
There was a problem hiding this comment.
Partition offsets are now computed in-process without overflow checks: entry.starting_lba * scale here, and partition_info.start * SECTOR_SIZE / image_offset + len in read_partition and write_partition. Before, dd did this arithmetic. There is no [profile] section, so release builds wrap instead of panicking.
With a corrupt or crafted GPT whose CRCs are valid (gptman doesn't check entry LBAs against last_usable_lba) and starting_lba >= 2^55, the offset wraps, the src_offset + len <= src_len check passes, and write_partition writes at the wrapped offset, e.g. over the GPT. Please use checked_mul/checked_add and fail.
There was a problem hiding this comment.
Fixed in 95c0918. get_partition_data computes the sectors with checked_mul and fails unless (start + count) * SECTOR_SIZE fits in u64 and ends inside the image file. This covers GPT and MBR, and the later byte offsets in read_partition and write_partition stay below that end. get_partition_data_rejects_an_overflowing_offset writes a GPT entry at LBA 2^55 with valid CRCs (through GPTHeader::write_into, because GPT::write_into refuses it), and get_partition_data_rejects_a_partition_after_the_image_end covers a cut-off image.
| /// The absolute path of `path` with symlinks resolved. A file that does not exist yet is | ||
| /// resolved through its directory. | ||
| fn resolve(path: &Path) -> Result<PathBuf> { | ||
| if path.exists() { |
There was a problem hiding this comment.
resolve() uses path.exists(), which is false for a dangling symlink. The link path itself then becomes the destination, and Staged::write renames the new file over the link. That contradicts "A symlink is followed". The !output_image.exists() check in check_conflicts also lets a dangling derived output through.
Example: -o out.wic where out.wic -> /data/out.wic doesn't exist yet. The image is staged next to the link and replaces it with a regular file; /data/out.wic is never created. Before this PR, File::create followed the link and created the target.
There was a problem hiding this comment.
Fixed in 626c64f. resolve follows a symlink whose target does not exist yet, also through a chain, with a relative target taken relative to the link, up to the Linux limit of 40 links. In check_conflicts, a dangling derived output now counts as existing. staged_commit_creates_the_target_of_a_dangling_symlink, check_conflicts_rejects_a_dangling_derived_output and resolve_stops_at_a_symlink_loop cover it.
| match source_compression { | ||
| Some(c) => compression::decompress(image_file, c, tmp_dir), | ||
| None => { | ||
| let tmp_image_file = tmp_dir.join( |
There was a problem hiding this comment.
The temp image keeps the input file name in a dir it shares with configure_hostname, which writes hostname and hosts next to the image via get_file_path. The PR fixed the <num>.img collision but not this one. identity set-config -i hosts (or -i hostname) overwrites the temp image, and the next partition read fails with "image is neither valid GPT nor MBR". Older than this PR and only a confusing error, so fine as a follow-up.
There was a problem hiding this comment.
Tracked as a follow-up.
| } | ||
| return Ok(path.to_path_buf()); | ||
| } | ||
| if target.is_some_and(|c| c.matches_extension(image)) { |
There was a problem hiding this comment.
matches_extension still accepts .gzip and .bzip2. So -i x.wic.gzip -p gzip keeps x.wic.gzip, and -o y.wic.bzip2 -p bzip2 is accepted, although the -p output was renamed to .gz/.bz2 because bmaptool reads neither old name. A pipeline with an existing x.wic.gzip running -p gzip -b gets x.wic.gzip plus x.wic.gzip.bmap; bmaptool copy won't decompress it and the flash fails, with no warning. I saw 961f95b makes this deliberate, but maybe at least warn?
There was a problem hiding this comment.
| // run command | ||
| command(&tmp_image_file)?; | ||
|
|
||
| let image = Staged::write(&dest_image_file, |dest| match &target_compression { |
There was a problem hiding this comment.
For uncompressed output, every data block is copied twice: into TMPDIR (temp_image) and again into the staged file (Staged::write → sparse::copy_file). An in-place -i x.wic on an 8 GiB, mostly-data image needs room for two image copies (three for a mount point). Running the command directly on the staged file and then committing would save one copy and the TMPDIR space. Fine as a follow-up.
There was a problem hiding this comment.
Tracked as a follow-up.
| input_bmap: Option<&Path>, | ||
| ) -> Result<()> { | ||
| let in_place = same_file(input_image, output_image)?; | ||
| ensure_writable(output_image)?; |
There was a problem hiding this comment.
check_conflicts checks that the output file is writable, but not its directory, which needs write access for .omnect-cli-* and the rename. For a mount-point output it doesn't check TMPDIR either. So -i /ro/x.wic.xz in a read-only dir decompresses the full image and runs the command before tempdir_in(/ro) fails. An access(dir, W_OK) check here would fail up front.
There was a problem hiding this comment.
Fixed in 7447910. check_conflicts checks with access(W_OK) that the directory where the staged file goes is writable: the directory of the output image and of its bmap, or TMPDIR for a mount point. check_conflicts_rejects_a_read_only_output_dir covers it; it accepts the result when the tests run as root.
|
|
||
| A command that changes an image writes the result to the input image. For a compressed image it writes the uncompressed image next to it, e.g. `my-image.wic` for `my-image.wic.xz`. With `-p` (`--pack-image`) it adds the extension of the compression (`.xz`, `.bz2` or `.gz`), e.g. `my-image.wic.xz` for `my-image.wic`. An image that already has an extension of that compression keeps its name. `-o` (`--output-image`) writes the result to the given path instead, and the input image stays unchanged. Its name must have the extension of the `-p` compression, or no compression extension without `-p`. | ||
|
|
||
| The command fails instead of overwriting an existing file that is not the input image. To overwrite it, name it with `-o`. It also fails if the output image or its bmap exists and is not writable for the user. The output image is written to a new file in its directory and then renamed, so the directory needs write access and space for one more image. Until the rename, the new file is in a hidden `.omnect-cli-*` directory. The command removes it when it ends or is stopped with Ctrl-C, `SIGTERM` or `SIGHUP`. It stays after a `SIGKILL` or a power loss, and you can delete it. A rename cannot replace a mount point, e.g. an image mounted with `docker run -v "$(pwd)/my-image.wic":/source/my-image.wic`, so the new image is staged in `TMPDIR` and copied into it. If this copy fails or is stopped, the new image stays in `TMPDIR`, and the log names it. Mount the directory instead of the image if you use `-b`, because the bmap is written next to the image. The new file belongs to the user who runs `omnect-cli`. `copy-from-image` only reads the image and writes nothing. |
There was a problem hiding this comment.
"stays in TMPDIR" isn't right when the mount point is only found by the EBUSY/EXDEV rename fallback (kernel < 5.8, no STATX_ATTR_MOUNT_ROOT). Then the staged file is in .omnect-cli-* next to the image. The log names the right path, but the README sends people to the wrong place.
There was a problem hiding this comment.
Fixed in 59a6cf9: "If this copy fails or is stopped, the new image stays, and the log names it."
| fn check_bmap_generation_wic_xz_without_input_bmap() { | ||
| let tr = Testrunner::new(function_name!().split("::").last().expect("test name")); | ||
| let image_path_wic_xz = tr.to_pathbuf("testfiles/image.wic.xz"); | ||
| let image_path_bmap = image_path_wic_xz.with_extension("bmap"); |
There was a problem hiding this comment.
Leftover from the bmap_path_of thread: this still uses image_path_wic_xz.with_extension("bmap") instead of bmap_path(<output image>). It only works because stripping .xz and adding .bmap happens to equal bmap_path(image.wic).
signal-hook runs its handler also for a signal set to SIG_IGN. So `nohup` and background jobs of a shell, which ignore SIGHUP or SIGINT, were stopped and lost the staged image. The handler now skips signals that are ignored when it starts. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
`commit` removed the old bmap before it replaced the image. If the image rename or copy failed, or a stop signal came in between, the old image stayed without its bmap, and the next `-b` run mapped the whole image. Now the image is replaced first. If the bmap then fails, the old bmap is removed, because it does not match the new image. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
The partition offsets were computed without overflow checks since dd no longer does this math. A corrupt GPT with valid CRCs and a huge LBA could wrap the byte offset in a release build, and `write_partition` would write at the wrapped offset. Now the sector numbers are computed with checked math, and the partition must end inside the image. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
`resolve` used the link itself when its target was missing, so the staged image replaced the link with a regular file and the target was never created. It now follows the link, also through a chain, up to the Linux limit of 40 links. Such a link also counts as an existing derived output in the conflict check. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
`check_conflicts` checked only the output file. The directory of the output image, or TMPDIR for a mount point, was found not writable only when the staged file was created, after the decompress and the command. Now the check fails up front. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
…reads `-p gzip` and `-p bzip2` kept an input named `.gzip` or `.bzip2` and accepted such a name with `-o`, but `bmaptool` reads only `.gz` and `.bz2`. Now `-o` needs the short name, and such an input is written with it, e.g. `x.wic.gzip` to `x.wic.gz`. The long names are still read as input. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
Only the staged files and the new device key were removed on a stop signal. The decompressed or copied image in TMPDIR and the partition files stayed, also after a handler was installed. `RegisteredTempDir` registers a temp dir on create and unregisters it on drop, and all temp dirs of a command use it. It replaces the two hand-written Drop impls. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
…l cleanup A copy into a mount point that the EBUSY/EXDEV fallback found keeps the new image next to the image, not in TMPDIR, so the README now points to the log. It also names the up-front directory check and the cleanup of the TMPDIR copies. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
Since `-o` needs `.gz` or `.bz2`, `-o x.wic.gzip -p gzip` failed with "has no extension of the -p compression" and suggested `x.wic.gzip.gz`. The error now names the expected extension and suggests the name with the compression extension replaced. The README and the `-o` help list the extensions, and the staging directory error names `--output-image` like the file check. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
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 fails on a truncated or corrupt stream. A compressed image is decompressed straight into the temp dir, without a copy first. 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, or if the bmap has no range checksums. 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.-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.xz,.bz2or.gz, the extension that-pwrites; without-pit must not have a compression extension. A command now fails instead of overwriting an existing file that is not the input image (a path given with-ois overwritten), replacing the bmap of an image that stays (e.g.x.wic.bmapfor-i x.wic.xzwithout-p, also with--input-bmapor--no-input-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.nohup, is not handled.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.<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.--generate-bmap-fileis renamed to--output-bmap, so the two options read as a pair. The short option-bstays. The name conflicts above fail where they overwrote files before.-p bzip2writes<image>.bz2instead of<image>.bzip2, and-p gzipwrites<image>.gzinstead of<image>.gzip;bmaptoolreads neither of the old names. An input named.bzip2or.gzipis still read, but with-pthe result is written next to it as.bz2or.gz, so a second run on the same input fails because that file exists.-oneeds the short name.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.