Repository navigation
fix(file)!: keep image holes, map the input bmap ranges, rename bmap options - #158
JanZachmann wants to merge 55 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>
…no-input-bmap omnect-cli derives the output image and bmap names from the input image. A command now fails instead of overwriting an existing file that is not the input image, replacing the input bmap of an image that stays (e.g. x.wic.bmap for x.wic.xz), or leaving a bmap next to the output image that no longer matches it. --output-image chooses the output path, and --no-input-bmap skips a bmap next to the image that does not match. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
…copy Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
Decompress with the multi-stream decoders, so an image made of several streams (e.g. written by pbzip2) is read completely. Decompress straight from the input image into the temp dir, without a copy of the compressed file first. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
Without -b, also fail when the bmap of the uncompressed image is next to a compressed output image, because bmaptool falls back to it. With -b and an output that is not the input image, also refuse to replace the bmap next to the input image when --input-bmap or --no-input-bmap is given. An input bmap that cannot be read gets the same hint as one that does not match. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
bmaptool does not read the .bzip2 extension, so it could not flash an image written with -p bzip2. BREAKING CHANGE: -p bzip2 writes <image>.bz2 instead of <image>.bzip2. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
The name given with -o must have the extension of the -p compression, or no compression extension without -p, so the name matches the content. For a compressed input, strip only the extension of its compression from the derived output name. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
The write-back truncated the output image first, so a failed copy, e.g. on a full disk, destroyed an image that was changed in place. Write the image and the bmap to a new file in the same directory and rename it over the old one. A symlink is followed, and the old file keeps its permissions. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
The temp dir now follows TMPDIR and no longer needs its own tokio runtime to be removed. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
The partition files were named <num>.img in the directory of the image, so an image named e.g. 1.img was overwritten by its own partition 1. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
copy-from-image only reads the image, and the partition files no longer go next to it, so a temp copy is needed only to decompress the image. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
gptman finds a GPT with 512 or 4096-byte sectors, but the partition offsets were always counted in 512-byte sectors. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
…pression `-i x.wic.gz -p gzip` wrote `x.wic.gzip`, and `-i x.wic.bzip2 -p bzip2` wrote `x.wic.bz2`, so a second run failed. gzip images now get `.gz`. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
bmaptool removes one extension of the image at a time until it finds a bmap, so it also uses e.g. `x.bmap` for `x.wic`. The input bmap and the stale bmap check now follow the same rule. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
…ename The output image and its bmap are now staged first: written, synced on the handle that wrote them, and given their permissions. Then the old bmap is removed, the image and the bmap are renamed, and their directories are synced. So a power loss cannot leave an empty or partial image, and a failed bmap rename leaves no bmap that does not match the new image. A compressed image is written into its staged file directly, without a copy in TMPDIR. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
mlilien
left a comment
There was a problem hiding this comment.
A few more points on 3da5a4d. The inline comments are the ones I would look at. The rest are optional nits:
- README, Output image: /tmp also needs space for the uncompressed image plus partition copies, as it did before this PR. That is worth a sentence, since /tmp is often a small tmpfs and an xz image can expand to several GB.
lib.rs:86: for an uncompressed input with an input bmap, the mapped ranges are read once byverify_checksumsand again by the copy to TMPDIR. Checking the checksums during the copy would save one pass. Low priority, because the pass inwrite_bmapis needed anyway (it hashes the modified image).bmap.rs:118/compression.rs:121:bmap_pathandcompressed_pathboth append.extto theOsString. A sharedappend_extension(path, ext)helper would do.Bmap::read: the root element,versionandBmapFileChecksumare not checked. The range sha256 check still rejects a wrong bmap, so this is mainly about refusing a future major version (3.x), as bmaptool does.
rename(2) to a mount point fails with EBUSY, so an in-place command on an image file mounted into a container failed. If the kernel reports the image as a mount root, its new content is staged in TMPDIR, because the directory may not be writable then, and copied into the image. A rename that fails with EBUSY or EXDEV also falls back to the copy. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
A rename needs write access only to the directory, so a read-only image was replaced without an error. The command now fails before it starts if the output image or its bmap exists and is not writable. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
write_bmap writes into any Write, so the bmap no longer goes through a temp file, and nothing is done for it without -b. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
…d paths Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
…ersion Like bmaptool, a bmap needs a version, and a major version above 2 is refused. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
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.
| .metadata() | ||
| .context(format!("cannot get the size of {}", self.file.display()))? | ||
| .len(); | ||
| let dest = OpenOptions::new() |
There was a problem hiding this comment.
copy_into_dest truncates the destination before copying. If sparse::copy_to_empty_file fails partway (I/O error, or no space because the host is btrfs with snapshots and truncating frees nothing), the image on the host is left truncated, and the finished image in TMPDIR is removed when Staged drops. Both old and new image are gone.
I know the copy isn't atomic, same as on main. But keeping the staged file on error (TempDir::keep() and logging its path) would be cheap and saves the result.
| // a file with a zero range that spans whole image blocks | ||
| const ZERO_BLOCKS_FILE_EDGE: usize = 4096; | ||
| const ZERO_BLOCKS_FILE_ZEROS: usize = 64 * 1024; | ||
| const OTHER_IMAGE_BMAP: &str = "<bmap><ImageSize>4096</ImageSize><BlockSize>4096</BlockSize>\ |
There was a problem hiding this comment.
OTHER_IMAGE_BMAP has no version attribute and no ChecksumType. Since 8f08735, check_input_bmap_of_another_image and check_no_input_bmap fail on Bmap::read: no valid version and never reach the image-size check they are meant to test. With version="2.0" added it fails on no sha256 range checksums instead.
The tests still pass because they only match cannot use input bmap / --no-input-bmap. If check_layout broke, nothing would notice. Could the fixture be a valid bmap (version, ChecksumType, range checksums) with only the image size differing?
| } else { | ||
| dir.to_path_buf() | ||
| }; | ||
| let tmp_dir = tempfile::Builder::new() |
There was a problem hiding this comment.
The staged image now lives in a hidden .omnect-cli-* dir next to the output image, and TempDir only removes it on drop. On Ctrl-C or SIGTERM (e.g. a cancelled CI job) during the long compress/copy/fsync, no destructors run, and a multi-GB .omnect-cli-XXXX/image.wic.xz stays in the user's image directory. Before this PR, leftovers ended up in /tmp, which gets cleaned on reboot or is tmpfs.
Maybe a signal handler that removes the staging dir, or at least a note in the README.
| let image = dir.path().join(IMAGE); | ||
| touch(&image); | ||
|
|
||
| assert!(is_mount_root(Path::new("/"))); |
There was a problem hiding this comment.
This asserts that / is a mount root, which depends on the environment. In a plain chroot (pbuilder/sbuild style), / isn't a mount root, and kernels before 5.8 don't report STATX_ATTR_MOUNT_ROOT. Then is_mount_root returns false and the test fails, although the code works (it falls back to the EBUSY/EXDEV path). Maybe skip the assertion when STATX_ATTR_MOUNT_ROOT isn't in stx_attributes_mask.
| Compression::gzip => "gzip", | ||
| Compression::xz { .. } => "xz", | ||
| Compression::bzip2 => &["bz2", "bzip2"], | ||
| Compression::gzip => &["gz", "gzip"], |
There was a problem hiding this comment.
-p gzip now writes <image>.gz instead of <image>.gzip (the old extension() returned "gzip"). The Breaking section only mentions the bzip2 rename. A pipeline that uploads or flashes x.wic.gzip after -p gzip won't find the file after updating. Please add it to the breaking notes.
| .context(format!("cannot set permissions of {}", file.display()))?; | ||
| } | ||
| // the handle that wrote the data also gets the write errors | ||
| handle |
There was a problem hiding this comment.
Staged::write always calls sync_all on the staged file, also when the destination is a mount point and the file is only a TMPDIR copy that gets copied into the destination and deleted right after. With disk-backed TMPDIR, an in-place command on a bind-mounted 8 GiB image forces a full writeback of a throwaway file, and copy_into_dest then syncs the real destination anyway. The sync could be skipped on the copy path.
| } | ||
| } | ||
|
|
||
| fn copy_into_dest(&self) -> Result<()> { |
There was a problem hiding this comment.
copy_into_dest repeats copy_image from lib.rs (open source, get length, sparse::copy_to_empty_file), and the uncompressed branch of temp_image (lib.rs:154) repeats sparse::copy_to_new_file. That makes three copies of the same sequence. Moving copy_image into file::sparse and calling it from here and from temp_image would keep later fixes (error context, permissions) in one place.
| 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.
| - `src/image.rs` — firmware image architecture detection (ARM32/ARM64/x86_64) | ||
| - `src/file/mod.rs` — high-level image operations: identity config, certs, hostname patching | ||
| - `src/file/functions.rs` — partition read/modify/write via dd + e2cp/mcopy | ||
| - `src/file/functions.rs` — sparse partition read, modify via e2cp/mcopy, write back of changed blocks only |
There was a problem hiding this comment.
The module list adds bmap.rs and sparse.rs but not the new src/file/output.rs (output naming, conflict checks, staging, commit).
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.copy_image, 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. The-oname must have the extension of the-pcompression, or no compression extension without-p. 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.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.--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, whichbmaptooldoes not read.SEEK_DATA/SEEK_HOLE; 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.