diff --git a/crates/osutils/src/blkid.rs b/crates/osutils/src/blkid.rs index 95aa176256..a139652133 100644 --- a/crates/osutils/src/blkid.rs +++ b/crates/osutils/src/blkid.rs @@ -36,6 +36,15 @@ pub fn get_partition_label(device_path: impl AsRef) -> Result) -> Result { + run(device_path, "LABEL") +} + #[cfg(feature = "functional-test")] #[cfg_attr(not(test), allow(unused_imports, dead_code))] mod functional_test { diff --git a/crates/osutils/src/dependencies.rs b/crates/osutils/src/dependencies.rs index 9d683890f4..61b4b6d445 100644 --- a/crates/osutils/src/dependencies.rs +++ b/crates/osutils/src/dependencies.rs @@ -99,6 +99,7 @@ pub enum Dependency { Efivar, Efibootmgr, Eject, + Fatlabel, Findmnt, #[strum(serialize = "grub2-mkconfig")] Grub2Mkconfig, diff --git a/crates/osutils/src/fatlabel.rs b/crates/osutils/src/fatlabel.rs new file mode 100644 index 0000000000..d6c36873b6 --- /dev/null +++ b/crates/osutils/src/fatlabel.rs @@ -0,0 +1,92 @@ +//! Thin wrapper around `fatlabel`, which reads and writes the volume label of a +//! FAT filesystem. +//! +//! `mkfs.vfat` can only set a label at creation time, via `-n`, so this is the +//! way to label a FAT filesystem that already exists. + +use std::path::Path; + +use anyhow::{ensure, Error}; + +use crate::dependencies::Dependency; + +/// Maximum length of a FAT volume label, in characters, as enforced by +/// `fatlabel` itself. +pub const MAX_LABEL_LENGTH: usize = 11; + +/// Sets the volume label of the FAT filesystem at `device_path`. +/// +/// The device may be mounted; the label is written to the boot sector and +/// survives the filesystem being unmounted. +pub fn set_label(device_path: impl AsRef, label: impl AsRef) -> Result<(), Error> { + let device_path = device_path.as_ref(); + let label = label.as_ref(); + + ensure!( + label.chars().count() <= MAX_LABEL_LENGTH, + "FAT volume label '{label}' is longer than the {MAX_LABEL_LENGTH} characters a FAT \ + filesystem can hold" + ); + + Dependency::Fatlabel + .cmd() + .arg(device_path) + .arg(label) + .run_and_check() + .map_err(Error::from) +} + +#[cfg(feature = "functional-test")] +#[cfg_attr(not(test), allow(unused_imports, dead_code))] +mod functional_test { + use super::*; + + use pytest_gen::functional_test; + + use crate::{blkid, filesystems::MkfsFileSystemType, mkfs}; + + #[functional_test(feature = "helpers")] + fn test_set_label() { + let device = Path::new("/dev/sda1"); + mkfs::run(device, MkfsFileSystemType::Vfat).unwrap(); + + set_label(device, "TESTLABEL").unwrap(); + assert_eq!(blkid::get_filesystem_label(device).unwrap(), "TESTLABEL"); + } + + #[functional_test(feature = "helpers", negative = true)] + fn test_set_label_too_long() { + set_label(Path::new("/dev/sda1"), "THIS-LABEL-IS-TOO-LONG").unwrap_err(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_set_label_rejects_overlong_label() { + // Rejected before invoking fatlabel, so this needs no filesystem. + let err = set_label(Path::new("/dev/null"), "THIS-LABEL-IS-TOO-LONG").unwrap_err(); + assert!( + err.to_string().contains("longer than"), + "got: {}", + err.to_string() + ); + } + + #[test] + fn test_max_label_length_is_accepted_by_the_length_check() { + // 11 characters, e.g. the conventional ESP label, must not be rejected + // by the length check. (The call itself will fail on /dev/null, which + // is not a FAT filesystem, so only the check is exercised here.) + let label = "EFI-SYSTEM!"; + assert_eq!(label.chars().count(), MAX_LABEL_LENGTH); + let err = set_label(Path::new("/dev/null"), label).unwrap_err(); + assert!( + !err.to_string().contains("longer than"), + "length check should have passed, got: {}", + err.to_string() + ); + } +} diff --git a/crates/osutils/src/lib.rs b/crates/osutils/src/lib.rs index ff86525793..48a28b9d4e 100644 --- a/crates/osutils/src/lib.rs +++ b/crates/osutils/src/lib.rs @@ -10,6 +10,7 @@ pub mod efibootmgr; pub mod efivar; pub mod encryption; pub mod exe; +pub mod fatlabel; pub mod files; pub mod filesystems; pub mod findmnt; diff --git a/crates/osutils/src/veritysetup.rs b/crates/osutils/src/veritysetup.rs index 4219cd0366..51107aa686 100644 --- a/crates/osutils/src/veritysetup.rs +++ b/crates/osutils/src/veritysetup.rs @@ -37,6 +37,12 @@ pub struct VerityDevice { data_device_path: PathBuf, hash_device_path: PathBuf, root_hash: String, + + /// Byte offset of the verity superblock inside the hash device. + /// + /// Set for *inline* verity, where the hash tree is stored inside the data + /// device itself, so `data_device_path == hash_device_path`. + hash_offset: Option, } impl VerityDevice { @@ -53,9 +59,17 @@ impl VerityDevice { data_device_path: data_device_path.into(), hash_device_path: hash_device_path.into(), root_hash: root_hash.into(), + hash_offset: None, } } + /// Sets the byte offset at which the verity hash tree starts, for images + /// that store the hash tree inline in the data device. + pub fn with_hash_offset(mut self, hash_offset: Option) -> Self { + self.hash_offset = hash_offset; + self + } + /// Will attempt to open the device with a signature file and verify it. pub fn open_with_signature(&self, signature_file: impl AsRef) -> Result<(), Error> { open_with_signature( @@ -63,6 +77,7 @@ impl VerityDevice { &self.data_device_path, &self.hash_device_path, &self.root_hash, + self.hash_offset, signature_file, )?; @@ -76,6 +91,7 @@ impl VerityDevice { &self.data_device_path, &self.hash_device_path, &self.root_hash, + self.hash_offset, )?; self.validate_or_close(EXPECTED_VERITY_DEVICE_STATUS) @@ -191,12 +207,14 @@ pub fn open( data_device_path: impl AsRef, hash_device_path: impl AsRef, root_hash: impl AsRef, + hash_offset: Option, ) -> Result<(), Error> { open_inner( name, data_device_path, hash_device_path, root_hash, + hash_offset, None::<&Path>, ) } @@ -207,6 +225,7 @@ fn open_with_signature( data_device_path: impl AsRef, hash_device_path: impl AsRef, root_hash: impl AsRef, + hash_offset: Option, signature_file: impl AsRef, ) -> Result<(), Error> { open_inner( @@ -214,6 +233,7 @@ fn open_with_signature( data_device_path, hash_device_path, root_hash, + hash_offset, Some(signature_file), ) } @@ -224,6 +244,7 @@ fn open_inner( data_device_path: impl AsRef, hash_device_path: impl AsRef, root_hash: impl AsRef, + hash_offset: Option, signature_file: Option>, ) -> Result<(), Error> { let mut cmd = Dependency::Veritysetup.cmd(); @@ -234,6 +255,12 @@ fn open_inner( .arg(root_hash.as_ref()) .arg("--verbose"); + // Inline verity: the hash tree lives inside the data device at this byte + // offset, so the device is passed as both the data and the hash device. + if let Some(hash_offset) = hash_offset { + cmd.arg(format!("--hash-offset={hash_offset}")); + } + // If a signature file is provided, add it to the command. if let Some(signature_file) = signature_file { let mut arg = OsString::from("--root-hash-signature="); @@ -263,9 +290,16 @@ pub fn open_with_guard( data_device_path: impl AsRef, hash_device_path: impl AsRef, root_hash: impl AsRef, + hash_offset: Option, ) -> Result { let device_name = name.as_ref(); - open(device_name, data_device_path, hash_device_path, root_hash)?; + open( + device_name, + data_device_path, + hash_device_path, + root_hash, + hash_offset, + )?; Ok(VerityDeviceGuard::new(device_name.to_owned())) } diff --git a/crates/trident/src/engine/boot/mod.rs b/crates/trident/src/engine/boot/mod.rs index 19c4c4bfb0..891b9b66c0 100644 --- a/crates/trident/src/engine/boot/mod.rs +++ b/crates/trident/src/engine/boot/mod.rs @@ -16,6 +16,11 @@ pub mod uki; pub(crate) const ESP_EXTRACTION_DIRECTORY: &str = VAR_TMP_PATH; +/// Mode to create [`ESP_EXTRACTION_DIRECTORY`] with when the OS image does not +/// ship it. `/var/tmp` is world-writable with the sticky bit set on a +/// conventional system, and systemd-tmpfiles expects to find it that way. +pub(crate) const ESP_EXTRACTION_DIRECTORY_MODE: u32 = 0o1777; + #[derive(Default, Debug)] pub(super) struct BootSubsystem; impl Subsystem for BootSubsystem { diff --git a/crates/trident/src/engine/boot/uki.rs b/crates/trident/src/engine/boot/uki.rs index 8813f10a13..078e9ea626 100644 --- a/crates/trident/src/engine/boot/uki.rs +++ b/crates/trident/src/engine/boot/uki.rs @@ -6,7 +6,7 @@ use std::{ use anyhow::{anyhow, ensure, Context, Error}; use const_format::formatcp; -use log::{debug, trace}; +use log::{debug, trace, warn}; use procfs::sys::kernel::Version; use osutils::path::join_relative; @@ -546,38 +546,54 @@ const SLOT_A_ADDON_FILENAME: &str = "slot-a.addon.efi"; const SLOT_B_ADDON_FILENAME: &str = "slot-b.addon.efi"; /// After staging the UKI, activate the correct verity addon for the target -/// A/B volume. ACL images ship with slot-A active by default and include -/// templates for both slots in `acl/uki-addons/` on the ESP image. +/// A/B volume. ACL images that use per-slot verity addons ship with slot-A +/// active by default and include templates for both slots in +/// `acl/uki-addons/` on the ESP image. /// /// The template is copied verbatim (no rename) into the staged addon /// directory as `slot-a.addon.efi` or `slot-b.addon.efi`, matching its /// source filename. /// -/// This is ACL-specific: if no verity addon templates exist on the image -/// (i.e. a non-ACL image), this function is a silent no-op. However, if -/// templates exist but the selected slot's template is missing, an error -/// is returned to prevent booting with the wrong slot's PARTUUIDs. +/// A per-slot addon exists to carry its slot's verity data/hash PARTUUID pair. +/// An image whose verity is *inline* has no such pair — the hash tree lives in +/// the data partition, so both refer to the same device — and there is nothing +/// for an addon to select. `verity_is_inline` therefore makes this a no-op. +/// +/// Otherwise the selected slot's template is required, and its absence is an +/// error rather than a skip, to prevent booting with the wrong slot's +/// PARTUUIDs. pub fn activate_verity_addon_for_target_volume( image_esp_mount: &Path, mount_point: &Path, esp_mount_path: &Path, target_volume: AbVolumeSelection, + verity_is_inline: bool, ) -> Result<(), Error> { let template_dir = image_esp_mount.join(ACL_ADDON_TEMPLATES_DIR); - if !template_dir.exists() { - // Image does not use PARTUUID-based verity addons (non-ACL or older ACL). - trace!( - "No verity addon template directory at '{}', skipping", - template_dir.display() - ); - return Ok(()); - } let (template_name, other_slot_addon_name) = match target_volume { AbVolumeSelection::VolumeA => (SLOT_A_ADDON_FILENAME, SLOT_B_ADDON_FILENAME), AbVolumeSelection::VolumeB => (SLOT_B_ADDON_FILENAME, SLOT_A_ADDON_FILENAME), }; + if verity_is_inline { + // Shipping per-slot templates alongside inline verity is contradictory: + // the templates cannot describe anything the image actually does. Say + // so, but keep going, since there is genuinely nothing to activate. + if template_dir.join(SLOT_A_ADDON_FILENAME).exists() + || template_dir.join(SLOT_B_ADDON_FILENAME).exists() + { + warn!( + "Image uses inline dm-verity but ships per-slot verity addon templates in '{}'. \ + The templates do not apply to inline verity and will be ignored.", + template_dir.display() + ); + } + + trace!("Verity is inline, so there is no per-slot verity addon to activate"); + return Ok(()); + } + let template_path = template_dir.join(template_name); ensure!( template_path.exists(), @@ -1220,6 +1236,7 @@ mod tests { mount_point.path(), Path::new(DEFAULT_ESP_MOUNT_POINT_PATH), AbVolumeSelection::VolumeA, + false, ) .unwrap(); @@ -1247,6 +1264,7 @@ mod tests { mount_point.path(), Path::new(DEFAULT_ESP_MOUNT_POINT_PATH), AbVolumeSelection::VolumeB, + false, ) .unwrap(); @@ -1255,7 +1273,10 @@ mod tests { assert_eq!(fs::read(&active).unwrap(), b"verity-b-content"); } - /// No template directory at all → silent no-op (backward compat with non-ACL). + /// No template directory at all, with inline verity → silent no-op. + /// + /// The non-inline counterpart is an error, covered by + /// [`test_activate_verity_addon_non_inline_requires_template`]. #[test] fn test_activate_verity_addon_no_template_dir() { let image_esp = tempdir().unwrap(); @@ -1270,6 +1291,7 @@ mod tests { mount_point.path(), Path::new(DEFAULT_ESP_MOUNT_POINT_PATH), AbVolumeSelection::VolumeA, + true, ) .unwrap(); @@ -1297,6 +1319,7 @@ mod tests { mount_point.path(), Path::new(DEFAULT_ESP_MOUNT_POINT_PATH), AbVolumeSelection::VolumeB, + false, ); assert!(result.is_err()); @@ -1309,6 +1332,107 @@ mod tests { ); } + /// Inline verity makes activation a no-op, whatever the addon directory holds. + /// + /// A per-slot addon carries its slot's verity data/hash PARTUUID pair, and + /// inline verity has no such pair. Published ACL images are inline and ship + /// only non-verity addons (first boot, fips, kdump) in the shared + /// `acl/uki-addons/` directory, so keying the no-op on that directory + /// existing made every one of them fail to install. + #[test] + fn test_activate_verity_addon_inline_verity_is_noop() { + let image_esp = tempdir().unwrap(); + let template_dir = image_esp.path().join(ACL_ADDON_TEMPLATES_DIR); + fs::create_dir_all(&template_dir).unwrap(); + for addon in ["firstboot.addon.efi", "fips.addon.efi", "kdump.addon.efi"] { + fs::write(template_dir.join(addon), b"addon").unwrap(); + } + + let mount_point = tempdir().unwrap(); + prepare_esp_for_uki(mount_point.path(), Path::new(DEFAULT_ESP_MOUNT_POINT_PATH)).unwrap(); + + for target_volume in [AbVolumeSelection::VolumeA, AbVolumeSelection::VolumeB] { + activate_verity_addon_for_target_volume( + image_esp.path(), + mount_point.path(), + Path::new(DEFAULT_ESP_MOUNT_POINT_PATH), + target_volume, + true, + ) + .unwrap(); + } + + // Nothing was activated, and the non-verity addons were left alone. + let staged_addon_dir = + join_relative(mount_point.path(), Path::new(DEFAULT_ESP_MOUNT_POINT_PATH)) + .join(UKI_DIRECTORY) + .join(TMP_UKI_ADDON_DIR_NAME); + assert!(!staged_addon_dir.join(SLOT_A_ADDON_FILENAME).exists()); + assert!(!staged_addon_dir.join(SLOT_B_ADDON_FILENAME).exists()); + assert!(template_dir.join("firstboot.addon.efi").exists()); + } + + /// Inline verity is still a no-op if the image contradicts itself by also + /// shipping per-slot templates, which cannot describe an inline layout. + #[test] + fn test_activate_verity_addon_inline_verity_ignores_templates() { + let image_esp = tempdir().unwrap(); + setup_image_with_verity_templates(image_esp.path()); + + let mount_point = tempdir().unwrap(); + prepare_esp_for_uki(mount_point.path(), Path::new(DEFAULT_ESP_MOUNT_POINT_PATH)).unwrap(); + + activate_verity_addon_for_target_volume( + image_esp.path(), + mount_point.path(), + Path::new(DEFAULT_ESP_MOUNT_POINT_PATH), + AbVolumeSelection::VolumeA, + true, + ) + .unwrap(); + + let staged_addon_dir = + join_relative(mount_point.path(), Path::new(DEFAULT_ESP_MOUNT_POINT_PATH)) + .join(UKI_DIRECTORY) + .join(TMP_UKI_ADDON_DIR_NAME); + assert!( + !staged_addon_dir.join(SLOT_A_ADDON_FILENAME).exists() + && !staged_addon_dir.join(SLOT_B_ADDON_FILENAME).exists(), + "no addon should be activated for inline verity" + ); + } + + /// With a separate hash partition, a missing template for the selected slot + /// is an error even if the directory holds other addons: the image is meant + /// to carry per-slot PARTUUIDs and shipping none is a packaging fault, not + /// an exemption. + #[test] + fn test_activate_verity_addon_non_inline_requires_template() { + let image_esp = tempdir().unwrap(); + let template_dir = image_esp.path().join(ACL_ADDON_TEMPLATES_DIR); + fs::create_dir_all(&template_dir).unwrap(); + fs::write(template_dir.join("firstboot.addon.efi"), b"addon").unwrap(); + + let mount_point = tempdir().unwrap(); + prepare_esp_for_uki(mount_point.path(), Path::new(DEFAULT_ESP_MOUNT_POINT_PATH)).unwrap(); + + let result = activate_verity_addon_for_target_volume( + image_esp.path(), + mount_point.path(), + Path::new(DEFAULT_ESP_MOUNT_POINT_PATH), + AbVolumeSelection::VolumeA, + false, + ); + + assert!( + result + .unwrap_err() + .to_string() + .contains(SLOT_A_ADDON_FILENAME), + "error should name the missing template" + ); + } + /// Creates the staged addon dir when templates exist but no addon dir was staged. #[test] fn test_activate_verity_addon_creates_addon_dir() { @@ -1324,6 +1448,7 @@ mod tests { mount_point.path(), Path::new(DEFAULT_ESP_MOUNT_POINT_PATH), AbVolumeSelection::VolumeB, + false, ) .unwrap(); @@ -1362,6 +1487,7 @@ mod tests { mount_point.path(), Path::new(DEFAULT_ESP_MOUNT_POINT_PATH), AbVolumeSelection::VolumeA, + false, ) .unwrap(); @@ -1405,6 +1531,7 @@ mod tests { mount_point.path(), Path::new(DEFAULT_ESP_MOUNT_POINT_PATH), AbVolumeSelection::VolumeB, + false, ) .unwrap(); diff --git a/crates/trident/src/engine/storage/image.rs b/crates/trident/src/engine/storage/image.rs index 235f7f4507..464b25dff4 100644 --- a/crates/trident/src/engine/storage/image.rs +++ b/crates/trident/src/engine/storage/image.rs @@ -111,15 +111,20 @@ pub(super) fn deploy_images(ctx: &EngineContext) -> Result<(), TridentError> { FileSystemResize::NoResize, ), ); - combined_images.insert( - image_file_verity.hash_image_file.path.clone(), - ( - verity_device.hash_device_id.clone(), - metric_label, - &image_file_verity.hash_image_file, - FileSystemResize::NoResize, - ), - ); + // For inline verity the hash tree is already part of the data image + // we just queued, so writing that image lands both at once and + // there is no separate hash image to deploy. + if !verity_device.is_inline() { + combined_images.insert( + image_file_verity.hash_image_file.path.clone(), + ( + verity_device.hash_device_id.clone(), + metric_label, + &image_file_verity.hash_image_file, + FileSystemResize::NoResize, + ), + ); + } } else { // For non-verity devices, we can deploy the image directly. diff --git a/crates/trident/src/engine/storage/verity.rs b/crates/trident/src/engine/storage/verity.rs index c6540e1b88..30648bc134 100644 --- a/crates/trident/src/engine/storage/verity.rs +++ b/crates/trident/src/engine/storage/verity.rs @@ -35,8 +35,17 @@ pub(crate) fn get_updated_device_name(device_name: &str) -> String { format!("{device_name}_new") } -/// Get the root-verity root hash. -fn get_root_verity_root_hash(ctx: &EngineContext) -> Result { +/// Verity facts that come from the OS image rather than the Host Configuration. +#[derive(Debug)] +struct VerityImageInfo { + roothash: String, + + /// Byte offset of the hash tree, for images that store it inline. + hash_offset: Option, +} + +/// Get the root-verity information from the OS image. +fn get_root_verity_info(ctx: &EngineContext) -> Result { // Extract information from the OS image. let Some(os_img) = ctx.image.as_ref() else { bail!("Image is not available"); @@ -51,11 +60,14 @@ fn get_root_verity_root_hash(ctx: &EngineContext) -> Result { bail!("Root filesystem in OS image is not verity enabled"); }; - Ok(verity.roothash.clone()) + Ok(VerityImageInfo { + roothash: verity.roothash.clone(), + hash_offset: verity.hash_offset, + }) } -/// Gets the usr-verity root hash. -fn get_usr_verity_root_hash(ctx: &EngineContext) -> Result { +/// Gets the usr-verity information from the OS image. +fn get_usr_verity_info(ctx: &EngineContext) -> Result { // Extract information from the OS image. let Some(os_img) = ctx.image.as_ref() else { bail!("Image is not available"); @@ -71,7 +83,10 @@ fn get_usr_verity_root_hash(ctx: &EngineContext) -> Result { bail!("usr filesystem in OS image is not verity enabled"); }; - Ok(verity.roothash.clone()) + Ok(VerityImageInfo { + roothash: verity.roothash.clone(), + hash_offset: verity.hash_offset, + }) } /// Setup verity devices. @@ -89,20 +104,20 @@ pub(super) fn setup_verity_devices(ctx: &EngineContext) -> Result<(), Error> { let (data_dev, hash_dev) = get_verity_device_paths(ctx, verity_device)?; let update_name = get_updated_device_name(&verity_device.name); - let root_hash = if ctx.storage_graph.root_fs_is_verity() { + let verity_info = if ctx.storage_graph.root_fs_is_verity() { debug!( "Setting up verity device '{}' for root filesystem", verity_device.id ); - get_root_verity_root_hash(ctx)? + get_root_verity_info(ctx)? } else if ctx.storage_graph.usr_fs_is_verity() { debug!( "Setting up verity device '{}' for usr filesystem", verity_device.id ); - get_usr_verity_root_hash(ctx)? + get_usr_verity_info(ctx)? } else { bail!( "Verity device '{}' is not on a supported filesystem.", @@ -110,8 +125,19 @@ pub(super) fn setup_verity_devices(ctx: &EngineContext) -> Result<(), Error> { ); }; + // Inline verity has no hash device of its own, so the OS image must tell us + // where inside the data device the hash tree starts. + if verity_device.is_inline() && verity_info.hash_offset.is_none() { + bail!( + "Verity device '{}' has no hash device, but the OS image does not provide a hash \ + offset for it.", + verity_device.name + ); + } + // Create the internal representation of the verity device. - let verity_dev = VerityDeviceUtils::new(update_name, data_dev, hash_dev, root_hash); + let verity_dev = VerityDeviceUtils::new(update_name, data_dev, hash_dev, verity_info.roothash) + .with_hash_offset(verity_info.hash_offset); // Check internal parameters for verity signatures. if let Some(signature_file_map) = ctx @@ -315,6 +341,8 @@ pub fn get_verity_device_paths( verity_device.data_device_id ))?; + // For inline verity this resolves to the same path as the data device, + // which is exactly what `veritysetup` expects alongside `--hash-offset`. let verity_hash_path = ctx .get_block_device_path(&verity_device.hash_device_id) .context(format!( @@ -457,7 +485,7 @@ mod tests { } #[test] - fn test_get_usr_verity_root_hash() { + fn test_get_usr_verity_info() { let expected_root_hash = "sample-roothash"; let mut mock = MockOsImage::new().with_image(MockImage::new( USR_MOUNT_POINT_PATH, @@ -472,7 +500,7 @@ mod tests { }; assert_eq!( - get_usr_verity_root_hash(&as_ctx(&mock)).unwrap(), + get_usr_verity_info(&as_ctx(&mock)).unwrap().roothash, expected_root_hash, "Root hash does not match expected" ); @@ -480,9 +508,7 @@ mod tests { // test failure when root filesystem is not verity enabled mock.images[0].verity = None; assert_eq!( - get_usr_verity_root_hash(&as_ctx(&mock)) - .unwrap_err() - .to_string(), + get_usr_verity_info(&as_ctx(&mock)).unwrap_err().to_string(), "usr filesystem in OS image is not verity enabled", "Got unexpected error" ); @@ -490,16 +516,14 @@ mod tests { // test failure when root filesystem is not found mock.images.clear(); assert_eq!( - get_usr_verity_root_hash(&as_ctx(&mock)) - .unwrap_err() - .to_string(), + get_usr_verity_info(&as_ctx(&mock)).unwrap_err().to_string(), "Failed to get usr filesystem from OS image", "Got unexpected error" ); } #[test] - fn test_get_root_verity_root_hash() { + fn test_get_root_verity_info() { let expected_root_hash = "sample-roothash"; let mut mock = MockOsImage::new().with_image(MockImage::new( ROOT_MOUNT_POINT_PATH, @@ -514,7 +538,7 @@ mod tests { }; assert_eq!( - get_root_verity_root_hash(&as_ctx(&mock)).unwrap(), + get_root_verity_info(&as_ctx(&mock)).unwrap().roothash, expected_root_hash, "Root hash does not match expected" ); @@ -522,7 +546,7 @@ mod tests { // test failure when root filesystem is not verity enabled mock.images[0].verity = None; assert_eq!( - get_root_verity_root_hash(&as_ctx(&mock)) + get_root_verity_info(&as_ctx(&mock)) .unwrap_err() .to_string(), "Root filesystem in OS image is not verity enabled", @@ -532,7 +556,7 @@ mod tests { // test failure when root filesystem is not found mock.images.clear(); assert_eq!( - get_root_verity_root_hash(&as_ctx(&mock)) + get_root_verity_info(&as_ctx(&mock)) .unwrap_err() .to_string(), "Failed to get root filesystem from OS image", diff --git a/crates/trident/src/osimage/cosi/derived_hc.rs b/crates/trident/src/osimage/cosi/derived_hc.rs index 1c9a8671fd..9367c6e1f4 100644 --- a/crates/trident/src/osimage/cosi/derived_hc.rs +++ b/crates/trident/src/osimage/cosi/derived_hc.rs @@ -4,7 +4,7 @@ use std::{ }; use anyhow::{bail, ensure, Context, Error}; -use log::warn; +use log::{debug, warn}; use sysdefs::partition_types::DiscoverablePartitionType; use url::Url; @@ -86,15 +86,56 @@ pub(super) fn derive_host_configuration_inner( if let Some(verity_device) = filesystem_metadata.verity.as_ref() { // This partition has verity, so we need to derive the verity device from it. - // First, get the id of the hash partition. - let hash_partition_id = partition_ids_by_file - .get(verity_device.file.path.as_path()) - .with_context(|| { - format!( - "Failed to find hash partition for verity device: {}", - verity_device.file.path.display() - ) - })?; + // Inline verity stores the hash tree inside the data partition + // itself, so the verity metadata points at the very same image as + // the filesystem, and the data partition doubles as the hash + // device. The offset at which the hash tree starts is a property of + // the OS image, so it is read from the image metadata at servicing + // time rather than recorded in the Host Configuration. + let inline = verity_device.file.path == part.image_file.path; + + let hash_partition_id = if inline { + // Fail early and clearly, rather than at `veritysetup open` time. + ensure!( + verity_device.hash_offset.is_some(), + "Filesystem '{}' uses inline dm-verity (its verity hash image is the same \ + image as its data), but the COSI metadata provides no 'hashOffset', so \ + Trident cannot locate the hash tree.", + filesystem_metadata.mount_point.display() + ); + + debug!( + "Filesystem '{}' uses inline dm-verity", + filesystem_metadata.mount_point.display() + ); + + partition_id.clone() + } else { + // A hash offset only has meaning when the hash tree shares the + // data image. Producers emit the two together, so an offset + // alongside a distinct hash image is contradictory; warn rather + // than silently ignoring it, since the offset would otherwise + // be dropped and the device set up as if it were not there. + if verity_device.hash_offset.is_some() { + warn!( + "Filesystem '{}' has a verity hash image distinct from its data image, but \ + the COSI metadata also provides a 'hashOffset'. The offset only applies to \ + inline verity and will be ignored.", + filesystem_metadata.mount_point.display() + ); + } + + // Otherwise, get the id of the hash partition. + partition_ids_by_file + .get(verity_device.file.path.as_path()) + .with_context(|| { + format!( + "Failed to find hash partition for verity device: {}", + verity_device.file.path.display() + ) + })? + .clone() + }; let verity_id = verity_id_gen.next_id(); @@ -114,7 +155,7 @@ pub(super) fn derive_host_configuration_inner( id: verity_id.clone(), name: verity_name, data_device_id: partition_id.clone(), - hash_device_id: hash_partition_id.clone(), + hash_device_id: hash_partition_id, corruption_option: Default::default(), }); @@ -246,6 +287,9 @@ mod tests { }, }; + /// The `usr` partition type GUID used by ACL (inherited from Flatcar). + const ACL_USR_PARTITION_TYPE_GUID: &str = "5dfbf5f4-2848-4bac-aa5e-0d9a20b745a6"; + /// Creates a mock GPT disk in memory with the specified partitions. /// /// Returns a tuple of (gpt_region_raw, disk_size, lba_size) where @@ -726,6 +770,7 @@ mod tests { verity: Some(VerityMetadata { file: sample_image_file("images/root-hash.img.zst"), roothash: "abcd1234".to_string(), + hash_offset: None, }), }; @@ -739,6 +784,7 @@ mod tests { verity: Some(VerityMetadata { file: sample_image_file("images/usr-hash.img.zst"), roothash: "efgh5678".to_string(), + hash_offset: None, }), }; @@ -875,6 +921,7 @@ mod tests { verity: Some(VerityMetadata { file: sample_image_file("images/var-hash.img.zst"), roothash: "badhash".to_string(), + hash_offset: None, }), }; @@ -911,6 +958,209 @@ mod tests { ); } + /// Tests [`derive_host_configuration_inner`] with *inline* dm-verity, as + /// used by Azure Container Linux (ACL): the hash tree lives inside the very + /// same partition as the data, at a byte offset, instead of in a dedicated + /// hash partition. + /// + /// COSI expresses that by pointing `verity.image` at the *same* image file + /// as the filesystem itself and adding a `hashOffset`. The derived verity + /// device therefore has no hash device of its own, and carries the offset + /// so that `veritysetup open` can be given `--hash-offset`. + /// + /// This is the shape a real `trident grpc-client stream-disk ` + /// produces. + #[test] + fn test_derive_host_configuration_inner_inline_verity() { + // Mirrors the ACL base image layout: EFI-SYSTEM, USR-A, USR-B, OEM, + // ROOT. USR-B has no filesystem entry in the COSI metadata; it is the + // idle A/B slot. + // ACL's /usr partitions carry the Flatcar `usr` type GUID, which is not + // one of the types normally permitted behind a verity *hash* device + // reference. Using it here keeps the fixture honest. + let acl_usr = gpt::partition_types::Type { + guid: Uuid::parse_str(ACL_USR_PARTITION_TYPE_GUID).unwrap(), + os: gpt::partition_types::OperatingSystem::Linux, + }; + + let (raw_gpt, disk_size, lba_size) = create_mock_gpt_disk_typed(&[ + ("EFI-SYSTEM", 64 * 1024, gpt::partition_types::EFI), + ("USR-A", 256 * 1024, acl_usr.clone()), + ("USR-B", 256 * 1024, acl_usr), + ("OEM", 64 * 1024, gpt::partition_types::LINUX_FS), + ("ROOT", 128 * 1024, gpt::partition_types::LINUX_FS), + ]); + + let disk_info = DiskInfo { + size: disk_size, + lba_size, + partition_table_type: PartitionTableType::Gpt, + gpt_regions: vec![ + GptDiskRegion { + image: sample_image_file("gpt_primary.zst"), + region_type: GptRegionType::PrimaryGpt, + }, + GptDiskRegion { + image: sample_image_file("images/acl_1.raw.zst"), + region_type: GptRegionType::Partition { number: 1 }, + }, + GptDiskRegion { + image: sample_image_file("images/acl_2.raw.zst"), + region_type: GptRegionType::Partition { number: 2 }, + }, + GptDiskRegion { + image: sample_image_file("images/acl_3.raw.zst"), + region_type: GptRegionType::Partition { number: 3 }, + }, + GptDiskRegion { + image: sample_image_file("images/acl_4.raw.zst"), + region_type: GptRegionType::Partition { number: 4 }, + }, + GptDiskRegion { + image: sample_image_file("images/acl_5.raw.zst"), + region_type: GptRegionType::Partition { number: 5 }, + }, + ], + }; + + // The /usr filesystem carries its own verity hash tree inline: the + // verity image path is identical to the filesystem image path. + let usr_image = Image { + file: sample_image_file("images/acl_2.raw.zst"), + mount_point: PathBuf::from("/usr"), + fs_type: OsImageFileSystemType::Ext4, + fs_uuid: OsUuid::Uuid(Uuid::new_v4()), + part_type: DiscoverablePartitionType::LinuxGeneric, + verity: Some(VerityMetadata { + file: sample_image_file("images/acl_2.raw.zst"), + roothash: "270ed371044dd0be4429a3945b1defa6a4cf202aa1308220c1ff40ea20cfb9c5" + .to_string(), + hash_offset: Some(1065345024), + }), + }; + + let metadata = CosiMetadata { + version: KnownMetadataVersion::V1_2.as_version(), + id: Some(Uuid::new_v4()), + os_arch: SystemArchitecture::Amd64, + os_release: OsRelease::default(), + os_packages: None, + images: vec![ + sample_esp_image("images/acl_1.raw.zst", "/boot"), + usr_image, + sample_image("images/acl_4.raw.zst", "/oem"), + sample_image("images/acl_5.raw.zst", "/"), + ], + bootloader: None, + disk: Some(disk_info), + compression: None, + }; + + let cosi = create_test_cosi(metadata, Some(raw_gpt)); + let hc = derive_host_configuration_inner( + &cosi.source, + &cosi.metadata_sha384, + "/dev/sda", + &cosi.metadata.images, + cosi.partitioning_info.as_ref().unwrap(), + ) + .unwrap(); + + // The verity device has no hash device of its own; it points only at + // the data partition and carries the offset of the inline hash tree. + assert_eq!(hc.storage.verity.len(), 1); + assert_eq!(hc.storage.verity[0].name, USR_VERITY_DEVICE_NAME); + assert_eq!(hc.storage.verity[0].data_device_id, "partition-2"); + assert_eq!( + hc.storage.verity[0].hash_device_id, "partition-2", + "inline verity uses the data partition as its own hash device" + ); + assert!(hc.storage.verity[0].is_inline()); + + // The storage graph accepts a verity device with a single reference. + hc.storage.build_graph().unwrap(); + + // /usr is mounted read-only on top of the verity device. + let usr_fs = hc + .storage + .filesystems + .iter() + .find(|fs| fs.mount_point_path() == Some(Path::new(USR_MOUNT_POINT_PATH))) + .unwrap(); + assert_eq!(usr_fs.device_id, Some(hc.storage.verity[0].id.clone())); + assert!(usr_fs.is_read_only()); + } + + /// Tests that inline verity without a `hashOffset` anywhere is rejected, + /// rather than silently producing a verity device that cannot be opened. + #[test] + fn test_derive_host_configuration_inner_inline_verity_missing_offset() { + let (raw_gpt, disk_size, lba_size) = create_mock_gpt_disk_typed(&[ + ("EFI-SYSTEM", 64 * 1024, gpt::partition_types::EFI), + ("USR-A", 256 * 1024, gpt::partition_types::LINUX_FS), + ]); + + let disk_info = DiskInfo { + size: disk_size, + lba_size, + partition_table_type: PartitionTableType::Gpt, + gpt_regions: vec![ + GptDiskRegion { + image: sample_image_file("gpt_primary.zst"), + region_type: GptRegionType::PrimaryGpt, + }, + GptDiskRegion { + image: sample_image_file("images/acl_1.raw.zst"), + region_type: GptRegionType::Partition { number: 1 }, + }, + GptDiskRegion { + image: sample_image_file("images/acl_2.raw.zst"), + region_type: GptRegionType::Partition { number: 2 }, + }, + ], + }; + + let usr_image = Image { + file: sample_image_file("images/acl_2.raw.zst"), + mount_point: PathBuf::from("/usr"), + fs_type: OsImageFileSystemType::Ext4, + fs_uuid: OsUuid::Uuid(Uuid::new_v4()), + part_type: DiscoverablePartitionType::LinuxGeneric, + verity: Some(VerityMetadata { + file: sample_image_file("images/acl_2.raw.zst"), + roothash: "270ed371".to_string(), + hash_offset: None, + }), + }; + + let metadata = CosiMetadata { + version: KnownMetadataVersion::V1_2.as_version(), + id: Some(Uuid::new_v4()), + os_arch: SystemArchitecture::Amd64, + os_release: OsRelease::default(), + os_packages: None, + images: vec![sample_esp_image("images/acl_1.raw.zst", "/boot"), usr_image], + bootloader: None, + disk: Some(disk_info), + compression: None, + }; + + let cosi = create_test_cosi(metadata, Some(raw_gpt)); + let err = derive_host_configuration_inner( + &cosi.source, + &cosi.metadata_sha384, + "/dev/sda", + &cosi.metadata.images, + cosi.partitioning_info.as_ref().unwrap(), + ) + .unwrap_err(); + + assert!( + format!("{err:#}").contains("hashOffset"), + "error should call out the missing hashOffset: {err:#}" + ); + } + /// Tests [`derive_host_configuration_inner`] with multiple ESP partitions. /// /// When two ESP partitions with mounted filesystems are present, the first diff --git a/crates/trident/src/osimage/cosi/metadata.rs b/crates/trident/src/osimage/cosi/metadata.rs index dad49ff561..eb33c1de13 100644 --- a/crates/trident/src/osimage/cosi/metadata.rs +++ b/crates/trident/src/osimage/cosi/metadata.rs @@ -266,6 +266,14 @@ pub(crate) struct VerityMetadata { pub file: ImageFile, pub roothash: String, + + /// Byte offset of the verity superblock inside [`file`](Self::file). + /// + /// Present for *inline* verity, where the hash tree is stored inside the + /// data image itself; in that case `file` points at the same image as the + /// filesystem it protects. + #[serde(default)] + pub hash_offset: Option, } #[derive(Debug, Deserialize, Clone, Eq, PartialEq)] diff --git a/crates/trident/src/osimage/cosi/mod.rs b/crates/trident/src/osimage/cosi/mod.rs index e831451d3b..3ad39a6fd5 100644 --- a/crates/trident/src/osimage/cosi/mod.rs +++ b/crates/trident/src/osimage/cosi/mod.rs @@ -532,6 +532,7 @@ fn cosi_image_to_os_image_filesystem(image: &metadata::Image) -> OsImageFileSyst path: verity.file.path, }, roothash: verity.roothash, + hash_offset: verity.hash_offset, }), } } @@ -1157,6 +1158,7 @@ mod tests { let root_hash = "some-root-hash-1234"; let verity_data = "some data"; cosi_img.verity = Some(VerityMetadata { + hash_offset: None, file: ImageFile { path: PathBuf::from("some/verity/path"), compressed_size: verity_data.len() as u64, diff --git a/crates/trident/src/osimage/mock.rs b/crates/trident/src/osimage/mock.rs index 5c9714cb6f..00906139a2 100644 --- a/crates/trident/src/osimage/mock.rs +++ b/crates/trident/src/osimage/mock.rs @@ -107,6 +107,9 @@ pub struct MockImage { #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct MockVerity { pub roothash: String, + + /// Byte offset of the hash tree for inline verity images. + pub hash_offset: Option, } fn mock_os_image_file() -> OsImageFile { @@ -170,6 +173,7 @@ impl MockOsImage { part_type: esp_img.part_type, image_file: mock_os_image_file(), verity: esp_img.verity.as_ref().map(|verity| OsImageVerityHash { + hash_offset: verity.hash_offset, roothash: verity.roothash.clone(), hash_image_file: mock_os_image_file(), }), @@ -191,6 +195,7 @@ impl MockOsImage { part_type: image.part_type, image_file: mock_os_image_file(), verity: image.verity.as_ref().map(|verity| OsImageVerityHash { + hash_offset: verity.hash_offset, roothash: verity.roothash.clone(), hash_image_file: mock_os_image_file(), }), @@ -244,6 +249,7 @@ impl MockImage { part_type, fs_uuid: OsUuid::Uuid(Uuid::new_v4()), verity: roothash.map(|roothash| MockVerity { + hash_offset: None, roothash: roothash.as_ref().to_owned(), }), } diff --git a/crates/trident/src/osimage/mod.rs b/crates/trident/src/osimage/mod.rs index 4d8f9229db..58d2c1a224 100644 --- a/crates/trident/src/osimage/mod.rs +++ b/crates/trident/src/osimage/mod.rs @@ -337,6 +337,14 @@ pub struct GptPartitionInfo { pub struct OsImageVerityHash { pub roothash: String, pub hash_image_file: OsImageFile, + + /// Byte offset of the verity superblock within the hash image. + /// + /// Set for *inline* verity, where the hash tree is stored inside the data + /// image itself, so `hash_image_file` is the same image as the filesystem's + /// own. Like [`roothash`](Self::roothash), this is a property of the OS + /// image rather than of the Host Configuration. + pub hash_offset: Option, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, Eq, PartialEq)] diff --git a/crates/trident/src/subsystems/esp.rs b/crates/trident/src/subsystems/esp.rs index a6ab3569ed..18d612c3fa 100644 --- a/crates/trident/src/subsystems/esp.rs +++ b/crates/trident/src/subsystems/esp.rs @@ -2,6 +2,7 @@ use std::{ fs, io::Read, ops::ControlFlow, + os::unix::fs::PermissionsExt, path::{Path, PathBuf}, }; @@ -11,13 +12,15 @@ use reqwest::Url; use tempfile::{NamedTempFile, TempDir}; use osutils::{ + blkid, bootloaders::{BOOT_EFI, GRUB_EFI, GRUB_NOPREFIX_EFI}, + fatlabel, files, filesystems::MountFileSystemType, mount::{self, MountGuard}, path, }; use trident_api::{ - config::UefiFallbackMode, + config::{UefiFallbackMode, VerityDevice}, constants::{ internal_params::DISABLE_GRUB_NOPREFIX_CHECK, EFI_DEFAULT_BIN_DIRECTORY, EFI_DEFAULT_BIN_RELATIVE_PATH, ESP_EFI_DIRECTORY, GRUB2_CONFIG_FILENAME, @@ -29,7 +32,7 @@ use trident_api::{ use crate::{ engine::{ - boot::{self, uki, ESP_EXTRACTION_DIRECTORY}, + boot::{self, uki, ESP_EXTRACTION_DIRECTORY, ESP_EXTRACTION_DIRECTORY_MODE}, EngineContext, Subsystem, }, io_utils::{ @@ -117,7 +120,7 @@ fn deploy_esp(ctx: &EngineContext, mount_point: &Path) -> Result<(), TridentErro // `/ESP_EXTRACTION_DIRECTORY`. This location is generally // guaranteed to be writable and backed by a real block device, so we don't // have to store a potentially large ESP image in memory. - let esp_extraction_dir = path::join_relative(mount_point, ESP_EXTRACTION_DIRECTORY); + let esp_extraction_dir = ensure_esp_extraction_dir(mount_point)?; // Get the threshold and interval for reporting slow streaming speed from // the context, to be used in the ReadMonitor while streaming images to the @@ -168,6 +171,7 @@ fn deploy_esp(ctx: &EngineContext, mount_point: &Path) -> Result<(), TridentErro ControlFlow::Break( copy_file_artifacts(temp_file.path(), ctx, mount_point) + .and_then(|()| preserve_esp_filesystem_label(temp_file.path(), ctx)) .structured(ServicingError::DeployESPImages) .message("Failed to load raw image"), ) @@ -182,6 +186,81 @@ fn deploy_esp(ctx: &EngineContext, mount_point: &Path) -> Result<(), TridentErro Ok(()) } +/// Returns the directory under `mount_point` to stage the ESP image in, +/// creating it if the OS image does not ship it. +/// +/// The staging directory is deliberately on the newly written root rather than +/// in memory, since an ESP image can be large. It is not guaranteed to exist +/// there, though: an immutable OS image may ship a root filesystem with no +/// `/var` at all and leave systemd-tmpfiles to populate it on first boot. In +/// that case create it with the mode `/var/tmp` is expected to have, so that we +/// neither fail the deployment nor leave the installed system with a +/// wrongly-permissioned directory. +fn ensure_esp_extraction_dir(mount_point: &Path) -> Result { + let esp_extraction_dir = path::join_relative(mount_point, ESP_EXTRACTION_DIRECTORY); + + if !esp_extraction_dir.exists() { + debug!( + "Creating ESP staging directory '{}'", + esp_extraction_dir.display() + ); + + files::create_dirs(&esp_extraction_dir) + .structured(ServicingError::DeployESPImages) + .message("Failed to create the ESP staging directory")?; + + fs::set_permissions( + &esp_extraction_dir, + fs::Permissions::from_mode(ESP_EXTRACTION_DIRECTORY_MODE), + ) + .structured(ServicingError::DeployESPImages) + .message("Failed to set permissions on the ESP staging directory")?; + } + + Ok(esp_extraction_dir) +} + +/// Copies the ESP image's filesystem label onto the ESP Trident created. +/// +/// Every other filesystem from the OS image is written to its partition +/// verbatim, so its label arrives with the rest of the bits. The ESP is the +/// exception: it is deployed file by file onto a filesystem Trident makes +/// itself, and `mkfs` gives that filesystem no label. Copying the label across +/// leaves the ESP in the state it would have been in had it been written +/// wholesale, which matters because images refer to it that way — ACL's initrd +/// looks the ESP up as `/dev/disk/by-label/EFI-SYSTEM`. +/// +/// An image whose ESP has no label leaves the new filesystem unlabelled too, +/// which is still faithful to the image. +fn preserve_esp_filesystem_label(esp_image_path: &Path, ctx: &EngineContext) -> Result<(), Error> { + let label = blkid::get_filesystem_label(esp_image_path).unwrap_or_default(); + if label.is_empty() { + debug!("ESP image has no filesystem label to preserve"); + return Ok(()); + } + + let (esp_device_id, _) = ctx + .spec + .storage + .esp_filesystem() + .context("Failed to find the ESP filesystem in the Host Configuration")?; + + let esp_device_path = ctx + .get_block_device_path(esp_device_id) + .with_context(|| format!("Failed to find the path of ESP device '{esp_device_id}'"))?; + + debug!( + "Applying ESP image's filesystem label '{label}' to '{}'", + esp_device_path.display() + ); + fatlabel::set_label(&esp_device_path, &label).with_context(|| { + format!( + "Failed to set filesystem label '{label}' on ESP device '{}'", + esp_device_path.display() + ) + }) +} + /// Takes in a reader to the raw zstd-compressed ESP image and decompresses it /// into a temporary file under `//`. /// Returns a tuple containing the temporary file and the computed hash (SHA256 @@ -303,15 +382,22 @@ fn copy_file_artifacts( // For ACL A/B images, activate the verity addon matching the target slot. // The image ships with slot A's addon active; this swaps it when updating - // to slot B (or confirms slot A for clean installs). Non-ACL images have - // no template directory and this is a no-op. + // to slot B (or confirms slot A for clean installs). Images using inline + // verity have no per-slot PARTUUID pair, so this is a no-op for them. if ctx.image_distro().is_acl() { if let Some(target_volume) = ctx.get_ab_update_volume() { + // A per-slot addon carries its slot's verity data/hash PARTUUID + // pair, which only exists when the hash tree has a partition of + // its own. An empty verity list is vacuously inline: there is no + // pair either way. + let verity_is_inline = ctx.spec.storage.verity.iter().all(VerityDevice::is_inline); + uki::activate_verity_addon_for_target_volume( temp_mount_dir, mount_point, &ctx.esp_mount_path, target_volume, + verity_is_inline, )?; // ACL images ship the first-boot addon pre-populated in the @@ -1507,4 +1593,53 @@ mod tests { ) ); } + + /// Tests that [`ensure_esp_extraction_dir`] creates the staging directory + /// when the OS image does not ship one. + /// + /// Immutable images such as Azure Container Linux ship a root filesystem + /// with no `/var` at all, leaving systemd-tmpfiles to populate it on first + /// boot. Staging the ESP image used to fail against such an image, after + /// the disk had already been repartitioned. + #[test] + fn test_ensure_esp_extraction_dir_creates_missing_dir() { + let newroot = TempDir::new().unwrap(); + + // A root filesystem with no /var whatsoever, as ACL ships. + assert!(!newroot.path().join("var").exists()); + + let dir = ensure_esp_extraction_dir(newroot.path()).unwrap(); + + assert!(dir.is_dir(), "staging directory should have been created"); + assert_eq!(dir, newroot.path().join("var/tmp")); + assert_eq!( + fs::metadata(&dir).unwrap().permissions().mode() & 0o7777, + ESP_EXTRACTION_DIRECTORY_MODE, + "staging directory should be created as sticky and world-writable, \ + like a conventional /var/tmp" + ); + + // A temporary file can actually be created there, which is what the + // deployment goes on to do. + NamedTempFile::new_in(&dir).unwrap(); + } + + /// Tests that [`ensure_esp_extraction_dir`] leaves an existing staging + /// directory alone, rather than changing the permissions an image chose. + #[test] + fn test_ensure_esp_extraction_dir_preserves_existing_dir() { + let newroot = TempDir::new().unwrap(); + let existing = newroot.path().join("var/tmp"); + fs::create_dir_all(&existing).unwrap(); + fs::set_permissions(&existing, fs::Permissions::from_mode(0o755)).unwrap(); + + let dir = ensure_esp_extraction_dir(newroot.path()).unwrap(); + + assert_eq!(dir, existing); + assert_eq!( + fs::metadata(&dir).unwrap().permissions().mode() & 0o7777, + 0o755, + "an existing staging directory should be left untouched" + ); + } } diff --git a/crates/trident/src/subsystems/storage/mod.rs b/crates/trident/src/subsystems/storage/mod.rs index 6000a6fe0a..a1f72a9d3a 100644 --- a/crates/trident/src/subsystems/storage/mod.rs +++ b/crates/trident/src/subsystems/storage/mod.rs @@ -183,11 +183,15 @@ impl Subsystem for StorageSubsystem { return Ok(()); } - fstab::generate_fstab(ctx, Path::new(fstab::DEFAULT_FSTAB_PATH)).structured( - ServicingError::GenerateFstab { - fstab_path: fstab::DEFAULT_FSTAB_PATH.to_string(), - }, - )?; + if should_generate_fstab(ctx)? { + fstab::generate_fstab(ctx, Path::new(fstab::DEFAULT_FSTAB_PATH)).structured( + ServicingError::GenerateFstab { + fstab_path: fstab::DEFAULT_FSTAB_PATH.to_string(), + }, + )?; + } else { + debug!("Skipping fstab generation because UKI usr-verity is in use"); + } // TODO: Update /etc/repart.d directly for the matching disk, derive it from where the root // is located @@ -203,6 +207,24 @@ impl Subsystem for StorageSubsystem { } } +/// Returns whether an fstab should be written for this image. +/// +/// A UKI image using dm-verity takes its mount topology from the signed kernel +/// command line rather than from fstab: the verity device, its data and hash +/// devices, and the root device are all named there, and the image ships units +/// for whatever else it mounts. Writing an fstab would at best duplicate that, +/// and at worst override it, since `systemd-fstab-generator` emits units into +/// `/run/systemd/generator`, which takes precedence over the units an image +/// ships in `/usr/lib/systemd/system`. +/// +/// Root-verity images never reach this: they skip storage configuration +/// entirely, having no writable root to configure. A usr-verity image does have +/// a writable root, so only fstab is skipped and the rest of the configuration +/// still applies. +fn should_generate_fstab(ctx: &EngineContext) -> Result { + Ok(!(ctx.is_uki()? && ctx.storage_graph.usr_fs_is_verity())) +} + #[cfg(test)] mod tests { use super::*; @@ -219,9 +241,10 @@ mod tests { use osutils::encryption; use trident_api::{ config::{ - AbUpdate, Disk as DiskConfig, Encryption, FileSystem, HostConfiguration, MountPoint, - Partition as PartitionConfig, PartitionSize, PartitionType, Raid, RaidLevel, - SoftwareRaidArray, Storage as StorageConfig, + AbUpdate, Disk as DiskConfig, Encryption, FileSystem, FileSystemSource, + HostConfiguration, MountOptions, MountPoint, Partition as PartitionConfig, + PartitionSize, PartitionTableType, PartitionType, Raid, RaidLevel, SoftwareRaidArray, + Storage as StorageConfig, VerityDevice, }, error::ErrorKind, }; @@ -236,6 +259,121 @@ mod tests { } } + /// Builds a context whose `/usr` is on a verity device, optionally inline. + /// + /// Inline verity (data device == hash device) is the shape Azure Container + /// Linux uses; the separate-hash form is the conventional one. + fn usr_verity_ctx(is_uki: bool, inline: bool) -> EngineContext { + let mut partitions = vec![ + PartitionConfig { + id: "esp".into(), + partition_type: PartitionType::Esp, + size: PartitionSize::from_str("100M").unwrap(), + uuid: None, + label: None, + }, + PartitionConfig { + id: "root".into(), + partition_type: PartitionType::Root, + size: PartitionSize::from_str("10G").unwrap(), + uuid: None, + label: None, + }, + PartitionConfig { + id: "usr-data".into(), + partition_type: PartitionType::Usr, + size: PartitionSize::from_str("1G").unwrap(), + uuid: None, + label: None, + }, + ]; + if !inline { + partitions.push(PartitionConfig { + id: "usr-hash".into(), + partition_type: PartitionType::UsrVerity, + size: PartitionSize::from_str("100M").unwrap(), + uuid: None, + label: None, + }); + } + + EngineContext { + is_uki: Some(is_uki), + ..Default::default() + } + .with_spec(HostConfiguration { + storage: StorageConfig { + disks: vec![DiskConfig { + id: "os".into(), + device: PathBuf::from("/dev/disk/by-bus/foobar"), + partition_table_type: PartitionTableType::Gpt, + partitions, + ..Default::default() + }], + verity: vec![VerityDevice { + id: "usr".into(), + name: "usr".into(), + data_device_id: "usr-data".into(), + hash_device_id: if inline { "usr-data" } else { "usr-hash" }.into(), + ..Default::default() + }], + filesystems: vec![ + FileSystem { + device_id: Some("esp".into()), + source: FileSystemSource::Image, + mount_point: Some("/boot/efi".into()), + is_esp: true, + }, + FileSystem { + device_id: Some("root".into()), + source: FileSystemSource::Image, + mount_point: Some("/".into()), + is_esp: false, + }, + FileSystem { + device_id: Some("usr".into()), + source: FileSystemSource::Image, + mount_point: Some(MountPoint { + path: PathBuf::from("/usr"), + options: MountOptions::defaults().with("ro"), + }), + is_esp: false, + }, + ], + ..Default::default() + }, + ..Default::default() + }) + } + + /// A UKI image with usr-verity takes its mounts from the kernel command + /// line, so no fstab is written. This is the Azure Container Linux case, + /// whose root image ships no `/etc` for an fstab to be written into, and + /// whose `/etc` is an overlay whose lower layer holds a deliberately empty + /// fstab. + #[test] + fn test_should_not_generate_fstab_for_uki_usr_verity() { + for inline in [true, false] { + assert!( + !should_generate_fstab(&usr_verity_ctx(true, inline)).unwrap(), + "UKI usr-verity should not get an fstab (inline: {inline})" + ); + } + } + + /// Usr-verity without UKI still gets an fstab: the mounts are not coming + /// from a signed kernel command line in that case. + #[test] + fn test_should_generate_fstab_for_non_uki_usr_verity() { + assert!(should_generate_fstab(&usr_verity_ctx(false, true)).unwrap()); + } + + /// An ordinary image gets an fstab. + #[test] + fn test_should_generate_fstab_without_verity() { + assert!(should_generate_fstab(&get_ctx()).unwrap()); + } + // Create a temporary recovery key file. The file will be deleted once // the object returned is out of scope and dropped. pub fn get_recovery_key_file() -> NamedTempFile { diff --git a/crates/trident/src/subsystems/storage/osimage.rs b/crates/trident/src/subsystems/storage/osimage.rs index 976b3719f0..339433eac2 100644 --- a/crates/trident/src/subsystems/storage/osimage.rs +++ b/crates/trident/src/subsystems/storage/osimage.rs @@ -456,7 +456,16 @@ fn validate_esp(os_image: &OsImage, ctx: &EngineContext) -> Result<(), TridentEr } let Some(available_space) = ctx.filesystem_block_device_size(ESP_EXTRACTION_DIRECTORY) else { - warn!("Failed to check if there is enough space available on '{ESP_EXTRACTION_DIRECTORY}' to copy ESP image."); + // Most commonly the backing partition is sized to grow into the + // remaining disk space, so its size is not known ahead of servicing. + // Skip the check rather than fail on it, but say so: it is a check that + // did not happen, not a check that passed. + warn!( + "Cannot check whether there is enough space to copy the ESP image into \ + '{ESP_EXTRACTION_DIRECTORY}': the size of its backing block device is not known, which \ + is expected when the partition is configured to grow. Skipping the check; servicing may \ + still fail later if the space is insufficient." + ); return Ok(()); }; @@ -609,6 +618,13 @@ fn validate_hash_filesystem_blkdev_fit( fs_mount_point.display() ))?; + // Inline verity keeps its hash tree inside the data partition, so there is + // no separate hash device to size-check; the data image size check already + // covers the whole partition image, hash tree included. + if verity_device.is_inline() { + return Ok(()); + } + // Get the size of the block device configured for the verity hash let Some(blkdev_hash_size) = graph.block_device_size(&verity_device.hash_device_id) else { debug!( @@ -848,6 +864,7 @@ mod tests { fs_uuid: OsUuid::Uuid(Uuid::new_v4()), part_type: DiscoverablePartitionType::Root, verity: Some(MockVerity { + hash_offset: None, roothash: "mock-roothash".to_string(), }), }, @@ -897,6 +914,7 @@ mod tests { fs_uuid: OsUuid::Uuid(Uuid::new_v4()), part_type: DiscoverablePartitionType::Esp, verity: Some(MockVerity { + hash_offset: None, roothash: "mock-hash".to_string(), }), }, @@ -906,6 +924,7 @@ mod tests { fs_uuid: OsUuid::Uuid(Uuid::new_v4()), part_type: DiscoverablePartitionType::Root, verity: Some(MockVerity { + hash_offset: None, roothash: "mock-roothash".to_string(), }), }, @@ -1456,6 +1475,7 @@ mod tests { part_type: DiscoverablePartitionType::LinuxGeneric, verity: roothash.map(|h| MockVerity { roothash: h.to_string(), + hash_offset: None, }), }], is_uki: false, diff --git a/crates/trident_api/src/config/host/storage/storage_graph/builder/partition.rs b/crates/trident_api/src/config/host/storage/storage_graph/builder/partition.rs index 98ff59b4c6..045f5de55e 100644 --- a/crates/trident_api/src/config/host/storage/storage_graph/builder/partition.rs +++ b/crates/trident_api/src/config/host/storage/storage_graph/builder/partition.rs @@ -272,14 +272,14 @@ pub(super) fn check_verity_partition_types( ), })?; - let hash_device_idx = + // Inline verity keeps its hash tree inside the data partition, so there + // is no separate hash device reference and no hash partition type to + // cross-check against the data partition type. + let Some(hash_device_idx) = graph::find_special_reference(graph, idx, SpecialReferenceKind::VerityHashDevice) - .ok_or_else(|| StorageGraphBuildError::InternalError { - body: format!( - "Verity device '{}' does not have a hash device reference.", - dev.name - ), - })?; + else { + continue; + }; // Get the partition types of the data and hash devices. let data_dev_partition_type = *explore_tree_partitions( diff --git a/crates/trident_api/src/config/host/storage/storage_graph/node.rs b/crates/trident_api/src/config/host/storage/storage_graph/node.rs index 2c87cca640..d6eb347e91 100644 --- a/crates/trident_api/src/config/host/storage/storage_graph/node.rs +++ b/crates/trident_api/src/config/host/storage/storage_graph/node.rs @@ -144,16 +144,24 @@ impl StorageGraphNode { vec![StorageReference::new_regular(&encrypted_volume.device_id)] } HostConfigBlockDevice::VerityDevice(verity_device) => { - vec![ - StorageReference::new_special( - SpecialReferenceKind::VerityDataDevice, - &verity_device.data_device_id, - ), - StorageReference::new_special( + let mut refs = vec![StorageReference::new_special( + SpecialReferenceKind::VerityDataDevice, + &verity_device.data_device_id, + )]; + + // With *inline* verity the hash tree lives inside the data + // device, so the configuration names the same device for + // both roles. The graph models that as the single device it + // is, rather than as a redundant second edge to the same + // node. + if !verity_device.is_inline() { + refs.push(StorageReference::new_special( SpecialReferenceKind::VerityHashDevice, &verity_device.hash_device_id, - ), - ] + )); + } + + refs } }, Self::FileSystem(fs) => fs diff --git a/crates/trident_api/src/config/host/storage/storage_graph/rules/mod.rs b/crates/trident_api/src/config/host/storage/storage_graph/rules/mod.rs index e975ac61be..d5cc537c09 100644 --- a/crates/trident_api/src/config/host/storage/storage_graph/rules/mod.rs +++ b/crates/trident_api/src/config/host/storage/storage_graph/rules/mod.rs @@ -161,7 +161,9 @@ impl BlkDevReferrerKind { Self::RaidArray => ValidCardinality::new_at_least(2), Self::ABVolume => ValidCardinality::new_exact(2), Self::EncryptedVolume => ValidCardinality::new_exact(1), - Self::VerityDevice => ValidCardinality::new_exact(2), + // Two devices normally (data + hash), but only one for inline + // verity, where the data device is also the hash device. + Self::VerityDevice => ValidCardinality::new_range(1, 2), Self::Swap => ValidCardinality::new_exact(1), Self::FileSystemNew => ValidCardinality::new_at_most(1), diff --git a/crates/trident_api/src/config/host/storage/verity.rs b/crates/trident_api/src/config/host/storage/verity.rs index 5819876c54..38a7761d5b 100644 --- a/crates/trident_api/src/config/host/storage/verity.rs +++ b/crates/trident_api/src/config/host/storage/verity.rs @@ -64,6 +64,12 @@ pub enum VerityCorruptionOption { } impl VerityDevice { + /// Returns whether this device stores its hash tree inline, inside the data + /// device, rather than in a dedicated hash partition. + pub fn is_inline(&self) -> bool { + self.data_device_id == self.hash_device_id + } + /// Returns the path where this verity device will be mounted at runtime. pub fn device_path(&self) -> PathBuf { Path::new(DEV_MAPPER_PATH).join(&self.name) diff --git a/docs/Reference/Composable-OS-Image.md b/docs/Reference/Composable-OS-Image.md index 1f27b2adb2..e2d6443543 100644 --- a/docs/Reference/Composable-OS-Image.md +++ b/docs/Reference/Composable-OS-Image.md @@ -286,10 +286,25 @@ _Notes:_ The `VerityConfig` object contains information required to set up a verity device on top of a data device. -| Field | Type | Added in | Required | Description | -| ---------- | ------------------------------ | -------- | --------------- | --------------------------------------------------------- | -| `image` | [ImageFile](#imagefile-object) | 1.0 | Yes (since 1.0) | Details of the hash partition image file in the tar file. | -| `roothash` | string | 1.0 | Yes (since 1.0) | Verity root hash. | +| Field | Type | Added in | Required | Description | +| ------------ | ------------------------------ | -------- | ---------------- | --------------------------------------------------------- | +| `image` | [ImageFile](#imagefile-object) | 1.0 | Yes (since 1.0) | Details of the hash partition image file in the tar file. [1] | +| `roothash` | string | 1.0 | Yes (since 1.0) | Verity root hash. | +| `hashOffset` | number | 1.2 | Conditionally[2] | Byte offset at which the verity hash tree starts inside `image`. [2] | + +_Notes:_ + +- **[1]** The hash tree is normally written to a partition of its own, in which + case `image` refers to that partition's image and is distinct from the + `image` of the filesystem this object belongs to. For *inline* verity the + hash tree is instead stored inside the data partition itself, so `image` + refers to the **same** image file as the filesystem's own `image`, and the + two MUST have identical values for all fields. +- **[2]** `hashOffset` MUST be specified for inline verity, i.e. whenever + `image` refers to the same image file as the filesystem's own `image`. It + gives the byte offset, from the start of the uncompressed image, at which + the verity superblock begins. It MUST be omitted OR set to `null` when the + hash tree occupies a partition of its own. ##### `ImageFile` Object @@ -628,12 +643,85 @@ making them invalid JSON. They are provided for illustration purposes only. } ``` +##### Inline Verity Image + +An image where the verity hash tree is stored inside the data partition instead +of in a partition of its own. The `verity.image` field refers to the *same* +image file as the filesystem's own `image`, and `hashOffset` says where inside +it the hash tree begins. + +```json +{ + "version": "1.2", + "osArch": "x86_64", + "images": [ + { + "image": { + "path": "images/usr.rawzst", + "compressedSize": 299718933, + "uncompressedSize": 1073741824, + "sha384": "fd2992ee573ab5314deeb8f1a467bdf48f5d9a530f1f1988b105e29bc1d82ba6a156f2d1ed4ae8ed4dd60784bec1c6b0" + }, + "mountPoint": "/usr", + "fsType": "ext4", + "fsUuid": "695a5ef2-6e5d-4f0d-b819-dae630122b8f", + "partType": "8484680c-9521-48c6-9c11-b0720656f69e", // <-- /usr amd64/x86_64 DPS GUID + "verity": { + "image": { + // <-- Same image file as the filesystem above: the hash + // tree lives inside the data partition. + "path": "images/usr.rawzst", + "compressedSize": 299718933, + "uncompressedSize": 1073741824, + "sha384": "fd2992ee573ab5314deeb8f1a467bdf48f5d9a530f1f1988b105e29bc1d82ba6a156f2d1ed4ae8ed4dd60784bec1c6b0" + }, + "roothash": "270ed371044dd0be4429a3945b1defa6a4cf202aa1308220c1ff40ea20cfb9c5", + "hashOffset": 1065345024 // <-- Hash tree starts here + } + }, + // More images... + ], + "osRelease": "NAME=\"Microsoft Azure Linux\"\nVERSION=\"3.0.20240824\"\nID=azurelinux\nVERSION_ID=\"3.0\"\nPRETTY_NAME=\"Microsoft Azure Linux 3.0\"\nANSI_COLOR=\"1;34\"\nHOME_URL=\"https://aka.ms/azurelinux\"\nBUG_REPORT_URL=\"https://aka.ms/azurelinux\"\nSUPPORT_URL=\"https://aka.ms/azurelinux\"\n", + "bootloader": { + "type": "systemd-boot", + "systemdBoot": { + "entries": [ + { + "type": "uki-standalone", + "path": "/boot/EFI/Linux/azurelinux-uki.efi", + // The same offset appears in the kernel command line, so + // that systemd can open the device at boot. + "cmdline": "mount.usr=/dev/mapper/usr systemd.verity_usr_options=hash-offset=1065345024,panic-on-corruption usrhash=270ed371044dd0be4429a3945b1defa6a4cf202aa1308220c1ff40ea20cfb9c5", + "kernel": "6.6.78.1-3.azl3" + } + ] + } + }, + "osPackages": [ + // Packages... + ], + "disk": { + "size": 1073741824, + "type": "gpt", + "lbaSize": 512, + "gptRegions": [ + // Regions... + ] + }, + "compression": { + "maxWindowLog": 27 + } +} +``` + ## Changelog ### Revision 1.2 - Added `disk` field to the root object. - Added `compression` field to the root object. +- Added `hashOffset` field to the `VerityConfig` object, to describe verity + images whose hash tree is stored inline in the data partition. - COSI now ships the GPT data as a binary blob. - Added `cosi-marker` file as the first entry in the tar file. diff --git a/docs/Reference/Composable-OS-Image/cosi-metadata-v1.2.schema.json b/docs/Reference/Composable-OS-Image/cosi-metadata-v1.2.schema.json index e1ed2bf45b..882b8055d1 100644 --- a/docs/Reference/Composable-OS-Image/cosi-metadata-v1.2.schema.json +++ b/docs/Reference/Composable-OS-Image/cosi-metadata-v1.2.schema.json @@ -89,12 +89,17 @@ "required": ["image", "roothash"], "properties": { "image": { - "description": "Details of the hash partition image file in the tar file.", + "description": "Details of the hash partition image file in the tar file. For inline verity the hash tree lives inside the data partition, so this refers to the same image file as the filesystem's own image.", "$ref": "#/$defs/ImageFile" }, "roothash": { "description": "Verity root hash.", "type": "string" + }, + "hashOffset": { + "description": "Byte offset at which the verity hash tree starts inside the image. MUST be specified for inline verity, where the hash tree is stored inside the data partition itself; otherwise omitted or null.", + "type": ["integer", "null"], + "minimum": 0 } } }, diff --git a/docs/Reference/Host-Configuration/Storage-Rules.md b/docs/Reference/Host-Configuration/Storage-Rules.md index e633b7e817..0dea8a577b 100644 --- a/docs/Reference/Host-Configuration/Storage-Rules.md +++ b/docs/Reference/Host-Configuration/Storage-Rules.md @@ -85,7 +85,7 @@ shows valid reference counts for each referrer type. | raid-array | 2 | ∞ | | ab-volume | 2 | 2 | | encrypted-volume | 1 | 1 | -| verity-device | 2 | 2 | +| verity-device | 1 | 2 | | swap-device | 1 | 1 | | filesystem-new | 0 | 1 | | filesystem-image | 1 | 1 | diff --git a/tests/cosi/metadata_samples/v1.2/invalid/negative-hash-offset.json b/tests/cosi/metadata_samples/v1.2/invalid/negative-hash-offset.json new file mode 100644 index 0000000000..1073b6a03a --- /dev/null +++ b/tests/cosi/metadata_samples/v1.2/invalid/negative-hash-offset.json @@ -0,0 +1,125 @@ +{ + "version": "1.2", + "osArch": "x86_64", + "images": [ + { + "image": { + "path": "images/esp.rawzst", + "compressedSize": 839345, + "uncompressedSize": 8388608, + "sha384": "2decc64a828dbbb76779731cd4afd3b86cc4ad0af06f4afe594e72e62e33e520a6649719fe43f09f11d518e485eae0db" + }, + "mountPoint": "/boot", + "fsType": "vfat", + "fsUuid": "A1B2-C3D4", + "partType": "c12a7328-f81f-11d2-ba4b-00a0c93ec93b", + "verity": null + }, + { + "image": { + "path": "images/usr.rawzst", + "compressedSize": 299718933, + "uncompressedSize": 1073741824, + "sha384": "fd2992ee573ab5314deeb8f1a467bdf48f5d9a530f1f1988b105e29bc1d82ba6a156f2d1ed4ae8ed4dd60784bec1c6b0" + }, + "mountPoint": "/usr", + "fsType": "ext4", + "fsUuid": "695a5ef2-6e5d-4f0d-b819-dae630122b8f", + "partType": "8484680c-9521-48c6-9c11-b0720656f69e", + "verity": { + "image": { + "path": "images/usr.rawzst", + "compressedSize": 299718933, + "uncompressedSize": 1073741824, + "sha384": "fd2992ee573ab5314deeb8f1a467bdf48f5d9a530f1f1988b105e29bc1d82ba6a156f2d1ed4ae8ed4dd60784bec1c6b0" + }, + "roothash": "270ed371044dd0be4429a3945b1defa6a4cf202aa1308220c1ff40ea20cfb9c5", + "hashOffset": -1 + } + }, + { + "image": { + "path": "images/root.rawzst", + "compressedSize": 2930305, + "uncompressedSize": 286777344, + "sha384": "482a777838afb87abed487bd054f4f2cebf6416586cd11f0c3c6e4bd37090f50039ffa45032ea0732e1b586cfb1e1af4" + }, + "mountPoint": "/", + "fsType": "ext4", + "fsUuid": "05ce50c7-885b-4544-b4ba-79471420655e", + "partType": "4f68bce3-e8cd-4db1-96e7-fbcaf984b709", + "verity": null + } + ], + "osRelease": "NAME=\"Microsoft Azure Linux\"\nVERSION=\"3.0.20240824\"\nID=azurelinux\nVERSION_ID=\"3.0\"\nPRETTY_NAME=\"Microsoft Azure Linux 3.0\"\nANSI_COLOR=\"1;34\"\nHOME_URL=\"https://aka.ms/azurelinux\"\nBUG_REPORT_URL=\"https://aka.ms/azurelinux\"\nSUPPORT_URL=\"https://aka.ms/azurelinux\"\n", + "bootloader": { + "type": "systemd-boot", + "systemdBoot": { + "entries": [ + { + "type": "uki-standalone", + "path": "/boot/EFI/Linux/azurelinux-uki.efi", + "cmdline": "mount.usr=/dev/mapper/usr mount.usrflags=ro systemd.verity_usr_data=PARTUUID=7130c94a-213a-4e5a-8e26-6cce9662f132 systemd.verity_usr_hash=PARTUUID=7130c94a-213a-4e5a-8e26-6cce9662f132 systemd.verity_usr_options=hash-offset=1065345024,panic-on-corruption usrhash=270ed371044dd0be4429a3945b1defa6a4cf202aa1308220c1ff40ea20cfb9c5 root=LABEL=ROOT rootflags=rw", + "kernel": "6.6.78.1-3.azl3" + } + ] + } + }, + "osPackages": [ + { + "name": "systemd", + "version": "255", + "release": "20.azl3", + "arch": "x86_64" + } + ], + "disk": { + "size": 1073741824, + "type": "gpt", + "lbaSize": 512, + "gptRegions": [ + { + "image": { + "path": "images/primary-gpt.rawzst", + "compressedSize": 16384, + "uncompressedSize": 32768, + "sha384": "a3f5c6e2b4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7" + }, + "type": "primary-gpt" + }, + { + "image": { + "path": "images/esp.rawzst", + "compressedSize": 839345, + "uncompressedSize": 8388608, + "sha384": "2decc64a828dbbb76779731cd4afd3b86cc4ad0af06f4afe594e72e62e33e520a6649719fe43f09f11d518e485eae0db" + }, + "type": "partition", + "number": 1 + }, + { + "image": { + "path": "images/usr.rawzst", + "compressedSize": 299718933, + "uncompressedSize": 1073741824, + "sha384": "fd2992ee573ab5314deeb8f1a467bdf48f5d9a530f1f1988b105e29bc1d82ba6a156f2d1ed4ae8ed4dd60784bec1c6b0" + }, + "type": "partition", + "number": 2 + }, + { + "image": { + "path": "images/root.rawzst", + "compressedSize": 2930305, + "uncompressedSize": 286777344, + "sha384": "482a777838afb87abed487bd054f4f2cebf6416586cd11f0c3c6e4bd37090f50039ffa45032ea0732e1b586cfb1e1af4" + }, + "type": "partition", + "number": 3 + } + ] + }, + "compression": { + "maxWindowLog": 27 + } +} diff --git a/tests/cosi/metadata_samples/v1.2/valid/inline-verity-usr.json b/tests/cosi/metadata_samples/v1.2/valid/inline-verity-usr.json new file mode 100644 index 0000000000..66b5a2e303 --- /dev/null +++ b/tests/cosi/metadata_samples/v1.2/valid/inline-verity-usr.json @@ -0,0 +1,125 @@ +{ + "version": "1.2", + "osArch": "x86_64", + "images": [ + { + "image": { + "path": "images/esp.rawzst", + "compressedSize": 839345, + "uncompressedSize": 8388608, + "sha384": "2decc64a828dbbb76779731cd4afd3b86cc4ad0af06f4afe594e72e62e33e520a6649719fe43f09f11d518e485eae0db" + }, + "mountPoint": "/boot", + "fsType": "vfat", + "fsUuid": "A1B2-C3D4", + "partType": "c12a7328-f81f-11d2-ba4b-00a0c93ec93b", + "verity": null + }, + { + "image": { + "path": "images/usr.rawzst", + "compressedSize": 299718933, + "uncompressedSize": 1073741824, + "sha384": "fd2992ee573ab5314deeb8f1a467bdf48f5d9a530f1f1988b105e29bc1d82ba6a156f2d1ed4ae8ed4dd60784bec1c6b0" + }, + "mountPoint": "/usr", + "fsType": "ext4", + "fsUuid": "695a5ef2-6e5d-4f0d-b819-dae630122b8f", + "partType": "8484680c-9521-48c6-9c11-b0720656f69e", + "verity": { + "image": { + "path": "images/usr.rawzst", + "compressedSize": 299718933, + "uncompressedSize": 1073741824, + "sha384": "fd2992ee573ab5314deeb8f1a467bdf48f5d9a530f1f1988b105e29bc1d82ba6a156f2d1ed4ae8ed4dd60784bec1c6b0" + }, + "roothash": "270ed371044dd0be4429a3945b1defa6a4cf202aa1308220c1ff40ea20cfb9c5", + "hashOffset": 1065345024 + } + }, + { + "image": { + "path": "images/root.rawzst", + "compressedSize": 2930305, + "uncompressedSize": 286777344, + "sha384": "482a777838afb87abed487bd054f4f2cebf6416586cd11f0c3c6e4bd37090f50039ffa45032ea0732e1b586cfb1e1af4" + }, + "mountPoint": "/", + "fsType": "ext4", + "fsUuid": "05ce50c7-885b-4544-b4ba-79471420655e", + "partType": "4f68bce3-e8cd-4db1-96e7-fbcaf984b709", + "verity": null + } + ], + "osRelease": "NAME=\"Microsoft Azure Linux\"\nVERSION=\"3.0.20240824\"\nID=azurelinux\nVERSION_ID=\"3.0\"\nPRETTY_NAME=\"Microsoft Azure Linux 3.0\"\nANSI_COLOR=\"1;34\"\nHOME_URL=\"https://aka.ms/azurelinux\"\nBUG_REPORT_URL=\"https://aka.ms/azurelinux\"\nSUPPORT_URL=\"https://aka.ms/azurelinux\"\n", + "bootloader": { + "type": "systemd-boot", + "systemdBoot": { + "entries": [ + { + "type": "uki-standalone", + "path": "/boot/EFI/Linux/azurelinux-uki.efi", + "cmdline": "mount.usr=/dev/mapper/usr mount.usrflags=ro systemd.verity_usr_data=PARTUUID=7130c94a-213a-4e5a-8e26-6cce9662f132 systemd.verity_usr_hash=PARTUUID=7130c94a-213a-4e5a-8e26-6cce9662f132 systemd.verity_usr_options=hash-offset=1065345024,panic-on-corruption usrhash=270ed371044dd0be4429a3945b1defa6a4cf202aa1308220c1ff40ea20cfb9c5 root=LABEL=ROOT rootflags=rw", + "kernel": "6.6.78.1-3.azl3" + } + ] + } + }, + "osPackages": [ + { + "name": "systemd", + "version": "255", + "release": "20.azl3", + "arch": "x86_64" + } + ], + "disk": { + "size": 1073741824, + "type": "gpt", + "lbaSize": 512, + "gptRegions": [ + { + "image": { + "path": "images/primary-gpt.rawzst", + "compressedSize": 16384, + "uncompressedSize": 32768, + "sha384": "a3f5c6e2b4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7" + }, + "type": "primary-gpt" + }, + { + "image": { + "path": "images/esp.rawzst", + "compressedSize": 839345, + "uncompressedSize": 8388608, + "sha384": "2decc64a828dbbb76779731cd4afd3b86cc4ad0af06f4afe594e72e62e33e520a6649719fe43f09f11d518e485eae0db" + }, + "type": "partition", + "number": 1 + }, + { + "image": { + "path": "images/usr.rawzst", + "compressedSize": 299718933, + "uncompressedSize": 1073741824, + "sha384": "fd2992ee573ab5314deeb8f1a467bdf48f5d9a530f1f1988b105e29bc1d82ba6a156f2d1ed4ae8ed4dd60784bec1c6b0" + }, + "type": "partition", + "number": 2 + }, + { + "image": { + "path": "images/root.rawzst", + "compressedSize": 2930305, + "uncompressedSize": 286777344, + "sha384": "482a777838afb87abed487bd054f4f2cebf6416586cd11f0c3c6e4bd37090f50039ffa45032ea0732e1b586cfb1e1af4" + }, + "type": "partition", + "number": 3 + } + ] + }, + "compression": { + "maxWindowLog": 27 + } +}