From 1c94c5e9f2f4edb8158734dc71e41f92fb6c0bd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcus=20Sch=C3=A4fer?= Date: Sun, 23 Aug 2026 20:42:17 +0200 Subject: [PATCH 1/2] Refactor firecracker checksum validation So far the --kis-image pull method downloaded a tar file and only verified the checksum of the root filesystem image inside the tarball. However the tarball also contains initrd and kernel files and the kiwi kis build also provides an overall checksum file for the entire archive. This commit refactors the code such that the entire tar file is matched against its checksum. The appstore has been already updated to provide these checksum files along with the actual kis tar files. This Fixes #103 --- doc/flake-ctl-firecracker-pull.rst | 37 ++++- flake-ctl/src/cli.rs | 5 +- flake-ctl/src/defaults.rs | 6 + flake-ctl/src/firecracker.rs | 215 ++++++++++++++++++++++++++--- 4 files changed, 242 insertions(+), 21 deletions(-) diff --git a/doc/flake-ctl-firecracker-pull.rst b/doc/flake-ctl-firecracker-pull.rst index 49c4772..be9e6de 100644 --- a/doc/flake-ctl-firecracker-pull.rst +++ b/doc/flake-ctl-firecracker-pull.rst @@ -46,13 +46,44 @@ and shows a file structure like in the following example ├── kernel └── rootfs +UPDATE CHECK +------------ + +An image pulled with the `--kis-image` option takes part in a +checksum based update check. The archive given to `--kis-image` +must be accompanied by a checksum file at the same location which +is named like the archive plus a `.sha256` suffix. For an archive +named `foo.tar.xz` the checksum file is expected at +`foo.tar.xz.sha256`. If no such file exists the pull is rejected +with an error. + +The checksum is used to verify the downloaded archive and it is +stored along with the image as `source_checksum`. Pulling an image +under a name that already exists in the registry then behaves as +follows: + +* The checksum file is fetched and compared against the + `source_checksum` record of the registered image. If both match, + the image is up to date. Nothing is downloaded, nothing in the + registry is touched and the command succeeds. + +* If the checksums differ, the latest version of the image is + fetched and replaces the image in the registry. + +An image registered by a `--rootfs`/`--kernel` pull provides no +such reference and is therefore not update checked. Pulling into +an existing name stays an error for those images unless `--force` +is given. + OPTIONS ------- --force Force pulling the image even if it already exists This will wipe - existing data for the provided identifier + existing data for the provided identifier. The image is fetched + from scratch, no update check against an existing image of the + same name is done --initrd @@ -67,7 +98,9 @@ OPTIONS Firecracker image built by KIWI as kis image type to pull into local image store. This means the file behind KIS_IMAGE is expected to be a tarball containing the KIS - components; rootfs-image, kernel and optional initrd + components; rootfs-image, kernel and optional initrd. + A checksum file named like KIS_IMAGE plus a '.sha256' suffix + must be available at the same location, see UPDATE CHECK --name diff --git a/flake-ctl/src/cli.rs b/flake-ctl/src/cli.rs index d84aec5..8461be8 100644 --- a/flake-ctl/src/cli.rs +++ b/flake-ctl/src/cli.rs @@ -122,7 +122,10 @@ pub enum Firecracker { name: String, /// Firecracker image built by KIWI as kis image type - /// to pull into local image store + /// to pull into local image store. A checksum file + /// named like the image plus a '.sha256' suffix must + /// exist next to it. It verifies the download and is + /// used to check an already pulled image for an update #[clap(long)] kis_image: Option, diff --git a/flake-ctl/src/defaults.rs b/flake-ctl/src/defaults.rs index 9ffd305..1d873e5 100644 --- a/flake-ctl/src/defaults.rs +++ b/flake-ctl/src/defaults.rs @@ -50,6 +50,12 @@ pub const FIRECRACKER_ROOTFS_NAME:&str = "rootfs"; pub const FIRECRACKER_CHECKSUM_NAME:&str = "checksum"; +// Name of the file that keeps the checksum record fetched from +// the image source. A pull of an image that is already in the +// registry compares against this record to find out whether the +// registered image is still up to date +pub const FIRECRACKER_SOURCE_CHECKSUM_NAME:&str = + "source_checksum"; pub const FIRECRACKER_SCI:&str = "/usr/lib/flake-pilot/sci"; // Name of the podman setup directory inside of the flakes diff --git a/flake-ctl/src/firecracker.rs b/flake-ctl/src/firecracker.rs index a9c783d..587cbb2 100644 --- a/flake-ctl/src/firecracker.rs +++ b/flake-ctl/src/firecracker.rs @@ -324,14 +324,42 @@ pub async fn pull_kis_image( built KIS image type. This means the file behind uri is expected to be a tarball containing the KIS components; rootfs-image, kernel and optional initrd + + The archive must be accompanied by a checksum file of the + same name plus a '.sha256' suffix. That checksum verifies + the download and it is kept with the image such that a + later pull of the same name can tell whether the image in + the registry is still up to date. An image which is up to + date is not fetched again and does not fail the pull !*/ let mut result = 255; let image_dir = get_image_dir(name, usermode); + let uri = uri.unwrap(); info!("Fetching KIS image..."); - if ! pull_new(name, force, usermode) { - return result + let pull_state = match pull_init(name, force, usermode) { + Some(pull_state) => pull_state, + None => return result + }; + + // Fetch the checksum record that belongs to the archive. + // Without it neither the download can be verified nor can + // an existing image be checked for an update + let source_checksum = match fetch_source_checksum(uri).await { + Some(source_checksum) => source_checksum, + None => return result + }; + + // An image of that name is already in the registry. Compare + // it against the checksum of its origin to find out whether + // there is anything to update + if pull_state == PullState::Update { + if image_is_current(&image_dir, &source_checksum) { + info!("Image '{name}' is up to date"); + return 0 + } + info!("Image '{name}' is out of date, pulling latest version..."); } match tempdir() { @@ -344,7 +372,7 @@ pub async fn pull_kis_image( // Download... match fs::create_dir_all(&work_dir) { Ok(_) => { - match send_request(uri.unwrap()).await { + match send_request(uri).await { Ok(response) => { result = response.status().as_u16().into(); match fetch_file(response, &kis_tar).await { @@ -357,8 +385,7 @@ pub async fn pull_kis_image( }, Err(error) => { error!( - "Request to '{}' failed with: {}", - uri.unwrap(), error + "Request to '{}' failed with: {}", uri, error ); return result } @@ -372,6 +399,14 @@ pub async fn pull_kis_image( } } + // Verify the archive against the checksum record + // fetched from the server + info!("Verifying archive checksum..."); + if ! verify_checksum(&kis_tar, &source_checksum) { + error!("Archive checksum verification failed"); + return result + } + // Unpack and Rename... info!("Unpacking..."); let mut tar = Command::new("tar"); @@ -434,7 +469,19 @@ pub async fn pull_kis_image( return result } - // Move to final firecracker image store + // Keep the checksum of the origin with the image. It is + // the reference for the update check of a later pull + if ! write_source_checksum(&work_dir, &source_checksum) { + return result + } + + // Move to final firecracker image store. An outdated + // image of the same name gets replaced + if pull_state == PullState::Update + && ! remove_image_dir(&image_dir) + { + return result + } if ! mv(&work_dir, &image_dir, registry_user(usermode)) { return result } @@ -449,6 +496,94 @@ pub async fn pull_kis_image( result } +async fn fetch_source_checksum(uri: &String) -> Option { + /*! + Fetch the checksum record that belongs to the given image URI + + The record is expected at the same location as the image under + the name of the image plus a '.sha256' suffix. An image which + does not provide it cannot be verified and cannot take part in + the update check and is therefore rejected + !*/ + let checksum_uri = format!("{uri}.sha256"); + info!("Fetching checksum {checksum_uri}..."); + let response = match send_request(&checksum_uri).await { + Ok(response) => response, + Err(error) => { + error!("Request to '{checksum_uri}' failed with: {error}"); + error!( + "The image is expected to provide a checksum file named \ + like the image plus a '.sha256' suffix" + ); + return None + } + }; + let checksum_record = match response.text().await { + Ok(checksum_record) => checksum_record, + Err(error) => { + error!("Failed to read '{checksum_uri}': {error}"); + return None + } + }; + if checksum_value(&checksum_record).is_none() { + error!("Checksum file '{checksum_uri}' provides no checksum"); + return None + } + Some(checksum_record) +} + +pub fn image_is_current(image_dir: &str, source_checksum: &str) -> bool { + /*! + Check the image in the registry against the checksum of its origin + + An image without a stored record, e.g one that was pulled before + the record was written, is never considered current. Such an image + gets pulled again and by that receives the record needed for the + next update check + !*/ + let record_file = format!( + "{}/{}", image_dir, defaults::FIRECRACKER_SOURCE_CHECKSUM_NAME + ); + let stored_checksum = match fs::read_to_string(&record_file) { + Ok(stored_checksum) => stored_checksum, + Err(error) => { + info!("No checksum record at '{record_file}': {error}"); + return false + } + }; + match (checksum_value(&stored_checksum), checksum_value(source_checksum)) { + (Some(stored_sum), Some(source_sum)) => stored_sum == source_sum, + _ => false + } +} + +pub fn write_source_checksum(image_dir: &str, source_checksum: &str) -> bool { + /*! + Store the checksum of the image origin along with the image data + !*/ + let record_file = format!( + "{}/{}", image_dir, defaults::FIRECRACKER_SOURCE_CHECKSUM_NAME + ); + match fs::write(&record_file, source_checksum) { + Ok(_) => true, + Err(error) => { + error!("Failed to write '{record_file}': {error}"); + false + } + } +} + +fn checksum_value(checksum_record: &str) -> Option<&str> { + /*! + Provide the plain checksum of a checksum record + + Records come in the sha256sum format ' ' as well as + in the kiwi format ' '. Only the + checksum itself is of interest to compare two records + !*/ + checksum_record.split_whitespace().next() +} + pub fn verify_checksum(image: &str, checksum_record: &str) -> bool { /*! Verify the given image against the sha256 checksum record @@ -652,28 +787,72 @@ pub fn umount(mount_point: &str, user: &str) -> bool { } -pub fn pull_new(name: &str, force: bool, usermode: bool) -> bool { +#[derive(Debug, PartialEq)] +pub enum PullState { + /// No image of that name in the registry, fetch and register it + New, + /// An image of that name is in the registry. Only fetch and + /// register it again if its origin has changed + Update +} + +pub fn pull_init(name: &str, force: bool, usermode: bool) -> Option { /*! - Initialize new pull + Initialize pull and tell whether it registers a new image or + updates an existing one + + With force an existing image is deleted upfront which turns + the pull into a pull from scratch. None is returned if the + registry could not be prepared for the pull !*/ if ! init_toplevel_image_dir(&get_registry_dir(usermode)) { - return false + return None } let image_dir = get_image_dir(name, usermode); if force && Path::new(&image_dir).exists() { - match fs::remove_dir_all(&image_dir) { - Ok(_) => { }, - Err(error) => { - error!("Error removing directory {image_dir}: {error}"); - return false - } + if ! remove_image_dir(&image_dir) { + return None } + return Some(PullState::New) } if Path::new(&image_dir).exists() { - error!("Image directory '{image_dir}' already exists"); - return false + return Some(PullState::Update) + } + Some(PullState::New) +} + +pub fn pull_new(name: &str, force: bool, usermode: bool) -> bool { + /*! + Initialize new pull + + Used for pulls that provide no way to tell an existing image + apart from its origin. For those an already existing image + is an error + !*/ + match pull_init(name, force, usermode) { + Some(PullState::New) => true, + Some(PullState::Update) => { + error!( + "Image directory '{}' already exists", + get_image_dir(name, usermode) + ); + false + }, + None => false + } +} + +pub fn remove_image_dir(image_dir: &str) -> bool { + /*! + Delete the given image directory from the registry + !*/ + match fs::remove_dir_all(image_dir) { + Ok(_) => true, + Err(error) => { + error!("Error removing directory {image_dir}: {error}"); + false + } } - true } pub fn purge_vm(vm: &str, usermode: bool) { From 7d7d7f50754d5ef0fff44b3b9e8c2243c881c0eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcus=20Sch=C3=A4fer?= Date: Sun, 23 Aug 2026 20:52:31 +0200 Subject: [PATCH 2/2] Drop extra rootfs image checksum test The image is verified against the checksum record fetched from its origin. This Fixes #103 --- doc/flake-ctl-firecracker-pull.rst | 3 +++ flake-ctl/src/defaults.rs | 2 -- flake-ctl/src/firecracker.rs | 35 +++++++++++------------------- 3 files changed, 16 insertions(+), 24 deletions(-) diff --git a/doc/flake-ctl-firecracker-pull.rst b/doc/flake-ctl-firecracker-pull.rst index be9e6de..56db85d 100644 --- a/doc/flake-ctl-firecracker-pull.rst +++ b/doc/flake-ctl-firecracker-pull.rst @@ -46,6 +46,9 @@ and shows a file structure like in the following example ├── kernel └── rootfs +An image pulled with `--kis-image` carries an additional +`source_checksum` file, see UPDATE CHECK below. + UPDATE CHECK ------------ diff --git a/flake-ctl/src/defaults.rs b/flake-ctl/src/defaults.rs index 1d873e5..38bfc06 100644 --- a/flake-ctl/src/defaults.rs +++ b/flake-ctl/src/defaults.rs @@ -48,8 +48,6 @@ pub const FIRECRACKER_KERNEL_NAME:&str = "kernel"; pub const FIRECRACKER_ROOTFS_NAME:&str = "rootfs"; -pub const FIRECRACKER_CHECKSUM_NAME:&str = - "checksum"; // Name of the file that keeps the checksum record fetched from // the image source. A pull of an image that is already in the // registry compares against this record to find out whether the diff --git a/flake-ctl/src/firecracker.rs b/flake-ctl/src/firecracker.rs index 587cbb2..2e0f955 100644 --- a/flake-ctl/src/firecracker.rs +++ b/flake-ctl/src/firecracker.rs @@ -421,15 +421,14 @@ pub async fn pull_kis_image( return result } } - let mut kis_ok = 3; + let mut kis_ok = 2; for path in fs::read_dir(&work_dir).unwrap() { let path = path.unwrap().path(); let extension = path.extension().unwrap(); if extension == OsStr::new("sha256") { - fs::rename(&path, format!("{}/{}", - work_dir, defaults::FIRECRACKER_CHECKSUM_NAME - )).unwrap(); - kis_ok -= 1; + fs::remove_file(&path).unwrap(); + // unused, the image is verified against the + // checksum record fetched from its origin } else if extension == OsStr::new("append") { fs::remove_file(&path).unwrap(); // unused @@ -455,20 +454,6 @@ pub async fn pull_kis_image( return result } - // Verify the rootfs image against the checksum record - // that came with the archive - info!("Verifying image checksum..."); - let image_checksum = fs::read_to_string( - format!("{}/{}", work_dir, defaults::FIRECRACKER_CHECKSUM_NAME) - ).unwrap_or_default().trim().to_string(); - if ! verify_checksum( - &format!("{}/{}", work_dir, defaults::FIRECRACKER_ROOTFS_NAME), - &image_checksum - ) { - error!("Image checksum verification failed"); - return result - } - // Keep the checksum of the origin with the image. It is // the reference for the update check of a later pull if ! write_source_checksum(&work_dir, &source_checksum) { @@ -586,13 +571,19 @@ fn checksum_value(checksum_record: &str) -> Option<&str> { pub fn verify_checksum(image: &str, checksum_record: &str) -> bool { /*! - Verify the given image against the sha256 checksum record - shipped inside of the KIS archive + Verify the given file against the sha256 checksum record + fetched from the location the file was downloaded from + + A record in the sha256sum format has the layout: - The record is created by kiwi and has the format: + + + A record created by kiwi has the layout: + and covers only the first blocks * blocksize bytes of the file + A record which cannot be read or a missing checksum program is reported but does not fail the operation. A checksum which does not match the image does