From 0ca02042e269d053a88023b9dc5136187bf5b00d Mon Sep 17 00:00:00 2001 From: Paco Date: Thu, 27 Aug 2026 13:57:11 -0700 Subject: [PATCH 1/8] engineering: support inline dm-verity in COSI-derived Host Configurations Azure Container Linux images use *inline* dm-verity: the hash tree lives inside the same partition as the data, at a byte offset, rather than in a dedicated hash partition. `trident grpc-client stream-disk` could not install such an image. Derivation resolved the verity hash partition purely by image path. With inline verity the verity entry points at the same image as the filesystem, so it produced a VerityDevice whose data and hash devices were the same partition, which the storage graph then rejected: Derived Host Configuration is invalid: Referrer 'verity-1' of kind 'verity-device' references target 'partition-2' more than once Trident also had nowhere to record where the hash tree began, and never passed an offset to `veritysetup open`. Changes: - Read `hashOffset` from COSI verity metadata. Image Customizer already emits this field for inline layouts (see its `cosiapi` package), so this only consumes what the producer declares; the COSI spec and schema are unchanged. - Carry the offset on the OS image next to the root hash. Like the root hash, it is a property of the image rather than of the Host Configuration, so it is read from image metadata at servicing time and does not appear in the Host Configuration or Host Status. - Allow a verity device to name the same partition as both its data and hash device. `hashDeviceId` remains required, so the Host Configuration schema is unchanged apart from documentation. The storage graph models the inline case as the single device it is, rather than as a second reference to the same node, which leaves the duplicate-target and referrer-sharing invariants untouched. Verity referrer cardinality becomes 1..=2, and the hash partition type cross-check is skipped when there is no distinct hash partition. - Pass `--hash-offset` to `veritysetup open`, so an inline-verity device is actually opened and verified during servicing. Derivation fails with a clear error naming `hashOffset` when an image uses inline verity but does not say where the hash tree starts, rather than producing a device that cannot be opened. Tested against a real ACL image end to end: derivation, graph construction and full Host Configuration validation pass; `stream-disk` writes all five partitions; verity activates and reports `verified`; and the installed system boots with systemd activating dm-verity on /usr from the UKI command line. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/osutils/src/veritysetup.rs | 36 ++- crates/trident/src/engine/storage/image.rs | 22 +- crates/trident/src/engine/storage/verity.rs | 68 +++-- crates/trident/src/osimage/cosi/derived_hc.rs | 258 +++++++++++++++++- crates/trident/src/osimage/cosi/metadata.rs | 8 + crates/trident/src/osimage/cosi/mod.rs | 2 + crates/trident/src/osimage/mock.rs | 6 + crates/trident/src/osimage/mod.rs | 8 + .../trident/src/subsystems/storage/osimage.rs | 11 + .../schemas/host-config-schema.json | 2 +- .../storage_graph/builder/partition.rs | 14 +- .../config/host/storage/storage_graph/node.rs | 24 +- .../host/storage/storage_graph/rules/mod.rs | 4 +- .../src/config/host/storage/verity.rs | 12 + .../API-Reference/VerityDevice.md | 2 + .../Host-Configuration/Storage-Rules.md | 2 +- 16 files changed, 419 insertions(+), 60 deletions(-) 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/storage/image.rs b/crates/trident/src/engine/storage/image.rs index 15680a0031..2ea2d3a33f 100644 --- a/crates/trident/src/engine/storage/image.rs +++ b/crates/trident/src/engine/storage/image.rs @@ -108,14 +108,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(), - &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(), + &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..1fd1186e06 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,42 @@ 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 { + // 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 +141,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 +273,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 +756,7 @@ mod tests { verity: Some(VerityMetadata { file: sample_image_file("images/root-hash.img.zst"), roothash: "abcd1234".to_string(), + hash_offset: None, }), }; @@ -739,6 +770,7 @@ mod tests { verity: Some(VerityMetadata { file: sample_image_file("images/usr-hash.img.zst"), roothash: "efgh5678".to_string(), + hash_offset: None, }), }; @@ -875,6 +907,7 @@ mod tests { verity: Some(VerityMetadata { file: sample_image_file("images/var-hash.img.zst"), roothash: "badhash".to_string(), + hash_offset: None, }), }; @@ -911,6 +944,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/storage/osimage.rs b/crates/trident/src/subsystems/storage/osimage.rs index 976b3719f0..93a1e1c49d 100644 --- a/crates/trident/src/subsystems/storage/osimage.rs +++ b/crates/trident/src/subsystems/storage/osimage.rs @@ -609,6 +609,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 +855,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 +905,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 +915,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 +1466,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/schemas/host-config-schema.json b/crates/trident_api/schemas/host-config-schema.json index 0ccf65581b..86be198028 100644 --- a/crates/trident_api/schemas/host-config-schema.json +++ b/crates/trident_api/schemas/host-config-schema.json @@ -1805,7 +1805,7 @@ "format": "Block Device ID" }, "hashDeviceId": { - "description": "The ID of the partition to use as the verity hash partition.", + "description": "The ID of the partition to use as the verity hash partition.\n\nFor inline verity, where the image stores the hash tree inside the data partition itself, this names the same partition as the data device. The offset at which the hash tree begins is a property of the OS image rather than of this configuration, and is read from the image metadata at servicing time, like the root hash.", "type": "string", "format": "Block Device ID" }, 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..8df4890cec 100644 --- a/crates/trident_api/src/config/host/storage/verity.rs +++ b/crates/trident_api/src/config/host/storage/verity.rs @@ -28,6 +28,12 @@ pub struct VerityDevice { pub data_device_id: BlockDeviceId, /// The ID of the partition to use as the verity hash partition. + /// + /// For inline verity, where the image stores the hash tree inside the data + /// partition itself, this names the same partition as the data device. The + /// offset at which the hash tree begins is a property of the OS image + /// rather than of this configuration, and is read from the image metadata + /// at servicing time, like the root hash. #[cfg_attr(feature = "schemars", schemars(schema_with = "block_device_id_schema"))] pub hash_device_id: BlockDeviceId, @@ -64,6 +70,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/Host-Configuration/API-Reference/VerityDevice.md b/docs/Reference/Host-Configuration/API-Reference/VerityDevice.md index 6d4f2c69b1..f30a0839a2 100644 --- a/docs/Reference/Host-Configuration/API-Reference/VerityDevice.md +++ b/docs/Reference/Host-Configuration/API-Reference/VerityDevice.md @@ -27,6 +27,8 @@ The ID of the partition to use as the verity data partition. The ID of the partition to use as the verity hash partition. +For inline verity, where the image stores the hash tree inside the data partition itself, this names the same partition as the data device. The offset at which the hash tree begins is a property of the OS image rather than of this configuration, and is read from the image metadata at servicing time, like the root hash. + | Characteristic | Value | | -------------- | ----------------- | | Type | `string` | 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 | From 485ebb560939e836311a410fda7124e69542066a Mon Sep 17 00:00:00 2001 From: Paco Date: Thu, 27 Aug 2026 14:27:45 -0700 Subject: [PATCH 2/8] docs: do not document inline dm-verity in the public API reference Inline dm-verity is not an officially supported configuration, so the public Host Configuration reference should not describe it. Reverts the `hashDeviceId` doc comment to its original wording. The generated `VerityDevice.md` and `host-config-schema.json` are now byte-identical to main, so this change makes no alteration to Trident's documented public API surface. Support itself is unaffected: inline verity is still derived, opened with `--hash-offset`, and verified. The behaviour is simply not advertised. The only remaining generated-doc difference is the verity-device row of the referrer cardinality table in Storage-Rules.md (2..2 becomes 1..2). That table is generated from the cardinality rule itself and states a reference count without mentioning inline verity; suppressing it would mean reverting the rule and reintroducing the graph invariant exemptions this design deliberately avoids. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/trident_api/schemas/host-config-schema.json | 2 +- crates/trident_api/src/config/host/storage/verity.rs | 6 ------ .../Host-Configuration/API-Reference/VerityDevice.md | 2 -- 3 files changed, 1 insertion(+), 9 deletions(-) diff --git a/crates/trident_api/schemas/host-config-schema.json b/crates/trident_api/schemas/host-config-schema.json index 86be198028..0ccf65581b 100644 --- a/crates/trident_api/schemas/host-config-schema.json +++ b/crates/trident_api/schemas/host-config-schema.json @@ -1805,7 +1805,7 @@ "format": "Block Device ID" }, "hashDeviceId": { - "description": "The ID of the partition to use as the verity hash partition.\n\nFor inline verity, where the image stores the hash tree inside the data partition itself, this names the same partition as the data device. The offset at which the hash tree begins is a property of the OS image rather than of this configuration, and is read from the image metadata at servicing time, like the root hash.", + "description": "The ID of the partition to use as the verity hash partition.", "type": "string", "format": "Block Device ID" }, diff --git a/crates/trident_api/src/config/host/storage/verity.rs b/crates/trident_api/src/config/host/storage/verity.rs index 8df4890cec..38a7761d5b 100644 --- a/crates/trident_api/src/config/host/storage/verity.rs +++ b/crates/trident_api/src/config/host/storage/verity.rs @@ -28,12 +28,6 @@ pub struct VerityDevice { pub data_device_id: BlockDeviceId, /// The ID of the partition to use as the verity hash partition. - /// - /// For inline verity, where the image stores the hash tree inside the data - /// partition itself, this names the same partition as the data device. The - /// offset at which the hash tree begins is a property of the OS image - /// rather than of this configuration, and is read from the image metadata - /// at servicing time, like the root hash. #[cfg_attr(feature = "schemars", schemars(schema_with = "block_device_id_schema"))] pub hash_device_id: BlockDeviceId, diff --git a/docs/Reference/Host-Configuration/API-Reference/VerityDevice.md b/docs/Reference/Host-Configuration/API-Reference/VerityDevice.md index f30a0839a2..6d4f2c69b1 100644 --- a/docs/Reference/Host-Configuration/API-Reference/VerityDevice.md +++ b/docs/Reference/Host-Configuration/API-Reference/VerityDevice.md @@ -27,8 +27,6 @@ The ID of the partition to use as the verity data partition. The ID of the partition to use as the verity hash partition. -For inline verity, where the image stores the hash tree inside the data partition itself, this names the same partition as the data device. The offset at which the hash tree begins is a property of the OS image rather than of this configuration, and is read from the image metadata at servicing time, like the root hash. - | Characteristic | Value | | -------------- | ----------------- | | Type | `string` | From 961a189356e161d5d5f1d2ec5a1a6e9f952991c9 Mon Sep 17 00:00:00 2001 From: Paco Date: Fri, 28 Aug 2026 11:57:21 -0700 Subject: [PATCH 3/8] docs(cosi): document the `hashOffset` field of `VerityConfig` Image Customizer has emitted `hashOffset` since azure-linux-image-tools#702 ("Add support for inline verity"), where it is declared in its `cosiapi` package and written only for images whose verity hash tree is stored inside the data partition. COSI 1.2 documents describe the field nowhere, so the spec has been behind its producer. Document it: add `hashOffset` to the `VerityConfig` table, note that the `image` field refers to the same image as the filesystem's own for inline verity, add a sample, and record the field in the 1.2 changelog. The addition is descriptive rather than a format change. `hashOffset` is optional and absent for the ordinary separate-hash-partition layout, so every existing COSI remains valid and consumers that ignore the field remain correct. Extends the schema gate with an inline-verity sample under tests/cosi/metadata_samples/v1.2/valid/, plus a negative sample asserting the offset cannot be negative. Verified with check-jsonschema, as the CI workflow does; also confirmed the metadata of a real inline-verity image validates against the updated schema. Note this documents the COSI format Trident consumes. It does not present inline verity as a supported Host Configuration: the Host Configuration reference and its schema are unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/Reference/Composable-OS-Image.md | 96 +++++++++++++- .../cosi-metadata-v1.2.schema.json | 7 +- .../v1.2/invalid/negative-hash-offset.json | 125 ++++++++++++++++++ .../v1.2/valid/inline-verity-usr.json | 125 ++++++++++++++++++ 4 files changed, 348 insertions(+), 5 deletions(-) create mode 100644 tests/cosi/metadata_samples/v1.2/invalid/negative-hash-offset.json create mode 100644 tests/cosi/metadata_samples/v1.2/valid/inline-verity-usr.json 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/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 + } +} From 6c63116a58e6d63a4c1d92ec270d82092b541ae0 Mon Sep 17 00:00:00 2001 From: Paco Date: Mon, 31 Aug 2026 14:53:55 -0700 Subject: [PATCH 4/8] bug: create the ESP staging directory when the OS image omits it `trident install` could not install an Azure Container Linux image. After partitioning the disk and writing the usr, oem and root filesystems, it failed in Provision: Failed to perform file-based deployment of ESP images Failed to create a temporary file File-based ESP deployment stages the image through `/var/tmp`, whose comment claims the location "is generally guaranteed to be writable and backed by a real block device". That does not hold for an immutable image: ACL's root filesystem ships only `usr`, `oem`, `boot` and `lost+found` plus a few symlinks, with no `/var` at all, and leaves systemd-tmpfiles to populate it on first boot. `NamedTempFile` then has no directory to create into, and servicing fails after the disk has already been repartitioned. Create the directory when it is missing, with the sticky, world-writable mode a conventional `/var/tmp` has, so systemd-tmpfiles finds what it expects on first boot. An existing directory is left untouched, so images that do ship one keep whatever mode they chose. The path computation and creation move into `ensure_esp_extraction_dir` so both cases can be tested. `stream-disk` was unaffected, as it writes the ESP partition image directly rather than staging it through a file. Also reword the neighbouring space check. It reported "Failed to check if there is enough space available" whenever the backing device size was unknown, which reads like a malfunction; the usual cause is simply a partition configured to grow, whose size is not known before servicing. Say that instead, and note the check was skipped rather than passed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/trident/src/engine/boot/mod.rs | 5 ++ crates/trident/src/subsystems/esp.rs | 89 ++++++++++++++++++- .../trident/src/subsystems/storage/osimage.rs | 11 ++- 3 files changed, 102 insertions(+), 3 deletions(-) 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/subsystems/esp.rs b/crates/trident/src/subsystems/esp.rs index 831003f9bb..9a7792afbd 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}, }; @@ -12,6 +13,7 @@ use tempfile::{NamedTempFile, TempDir}; use osutils::{ bootloaders::{BOOT_EFI, GRUB_EFI, GRUB_NOPREFIX_EFI}, + files, filesystems::MountFileSystemType, mount::{self, MountGuard}, path, @@ -29,7 +31,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 +119,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 @@ -180,6 +182,40 @@ 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) +} + /// 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 @@ -1505,4 +1541,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/osimage.rs b/crates/trident/src/subsystems/storage/osimage.rs index 93a1e1c49d..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(()); }; From 8da7273294ab3e716262da21ab676b9020c43311 Mon Sep 17 00:00:00 2001 From: Paco Date: Mon, 31 Aug 2026 15:17:26 -0700 Subject: [PATCH 5/8] bug: only require a verity addon template when the image ships them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Installing any currently published Azure Container Linux image failed during Provision: Verity addon template 'verity-a.addon.efi' not found in '.../acl/uki-addons' — cannot activate VolumeA `activate_verity_addon_for_target_volume` decided whether an image uses PARTUUID-based verity addons by testing whether `acl/uki-addons/` exists. That directory is shared, though: it also holds the first boot, fips and kdump addons, as its own doc comment says. Published ACL images ship those three and no verity templates, so the check passed and the function went on to demand a template the image never had. Key the decision on the templates themselves instead. If the selected slot's template is absent, the image only fails when the *other* slot's template is present, since that is what shows the image really does use per-slot verity addons and that booting would otherwise pick up the wrong slot's PARTUUIDs. When neither is present the image simply does not use them and there is nothing to activate, which is the behaviour the doc comment already described. The safety property is unchanged, and its test with it: shipping verity-a while installing to slot B is still an error. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/trident/src/engine/boot/uki.rs | 97 ++++++++++++++++++++------- 1 file changed, 74 insertions(+), 23 deletions(-) diff --git a/crates/trident/src/engine/boot/uki.rs b/crates/trident/src/engine/boot/uki.rs index 851835af4b..2711f7857a 100644 --- a/crates/trident/src/engine/boot/uki.rs +++ b/crates/trident/src/engine/boot/uki.rs @@ -537,14 +537,19 @@ const ACL_ADDON_TEMPLATES_DIR: &str = "acl/uki-addons"; /// Filename of the active verity addon placed in the UKI's `.extra.d/` directory. const VERITY_ADDON_FILENAME: &str = "verity.addon.efi"; +/// Filenames of the per-slot verity addon templates. +const VERITY_ADDON_TEMPLATE_A: &str = "verity-a.addon.efi"; +const VERITY_ADDON_TEMPLATE_B: &str = "verity-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 PARTUUID-based verity addons ship with +/// slot-A active by default and include templates for both slots in +/// `acl/uki-addons/` on the ESP image. /// -/// 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. +/// This is ACL-specific, and optional even there: if the image ships no verity +/// addon templates, this function is a silent no-op. However, if it ships the +/// other slot's template but not the selected slot's, an error is returned to +/// prevent booting with the wrong slot's PARTUUIDs. pub fn activate_verity_addon_for_target_volume( image_esp_mount: &Path, mount_point: &Path, @@ -552,29 +557,37 @@ pub fn activate_verity_addon_for_target_volume( target_volume: AbVolumeSelection, ) -> 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). + + let (template_name, other_template_name) = match target_volume { + AbVolumeSelection::VolumeA => (VERITY_ADDON_TEMPLATE_A, VERITY_ADDON_TEMPLATE_B), + AbVolumeSelection::VolumeB => (VERITY_ADDON_TEMPLATE_B, VERITY_ADDON_TEMPLATE_A), + }; + + let template_path = template_dir.join(template_name); + if !template_path.exists() { + // The template directory is shared with addons that have nothing to do + // with verity (first boot, fips, kdump), so its mere presence does not + // mean the image uses per-slot verity addons. What does mean that is + // the other slot's template being there: in that case a missing + // template for *this* slot would leave the UKI carrying the wrong + // slot's PARTUUIDs, so refuse. Otherwise the image simply does not use + // them, and there is nothing to activate. + ensure!( + !template_dir.join(other_template_name).exists(), + "Verity addon template '{}' not found in '{}', but '{}' is present — cannot activate {:?}", + template_name, + template_dir.display(), + other_template_name, + target_volume + ); + trace!( - "No verity addon template directory at '{}', skipping", + "Image ships no verity addon templates in '{}', skipping", template_dir.display() ); return Ok(()); } - let template_name = match target_volume { - AbVolumeSelection::VolumeA => "verity-a.addon.efi", - AbVolumeSelection::VolumeB => "verity-b.addon.efi", - }; - - let template_path = template_dir.join(template_name); - ensure!( - template_path.exists(), - "Verity addon template '{}' not found in '{}' — cannot activate {:?}", - template_name, - template_dir.display(), - target_volume - ); - let staging_addon_dir = join_relative(mount_point, esp_mount_path) .join(UKI_DIRECTORY) .join(TMP_UKI_ADDON_DIR_NAME); @@ -1280,6 +1293,44 @@ mod tests { ); } + /// An image whose addon directory holds only non-verity addons is a no-op. + /// + /// `acl/uki-addons/` is shared: ACL images ship first-boot, fips and kdump + /// addons there whether or not they use per-slot verity addons. Published + /// ACL images currently ship exactly those three and no verity templates, + /// so keying the no-op on the directory existing made every such image fail + /// to install. + #[test] + fn test_activate_verity_addon_non_verity_addons_only() { + 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, + ) + .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(VERITY_ADDON_FILENAME).exists()); + assert!(template_dir.join("firstboot.addon.efi").exists()); + } + /// Creates the staged addon dir when templates exist but no addon dir was staged. #[test] fn test_activate_verity_addon_creates_addon_dir() { From 13009c33fb2cc425c8a7a9d5813a36ad18e4a9ca Mon Sep 17 00:00:00 2001 From: Paco Date: Mon, 31 Aug 2026 15:27:32 -0700 Subject: [PATCH 6/8] bug: gate verity addon activation on verity being inline Follow-up to 8da72732, which keyed the no-op on neither per-slot verity addon template being present. That test is a proxy: it infers an image's intent from a missing file, so an image that should ship per-slot addons but shipped none by mistake looks exactly like one that legitimately does not use them, and would be activated silently rather than rejected. Gate on the structural reason the exemption is safe instead. 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, since the hash tree lives in the data partition and both refer to one device. There is nothing for an addon to select, so activation does not apply. That is a property of the image that can be established positively rather than read out of an absence. With a separate hash partition the check stays strict, including when the image ships no templates at all: such an image is meant to carry per-slot PARTUUIDs, and shipping none is a packaging fault rather than an exemption. That is the case ACL is moving towards, and where the error should keep firing. Inline-ness comes from `VerityDevice::is_inline`, the same predicate the rest of the inline verity support keys on, so the two stay consistent. An image that both uses inline verity and ships per-slot templates is contradictory; it warns and continues, since there is still nothing to activate. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/trident/src/engine/boot/uki.rs | 155 +++++++++++++++++++------- crates/trident/src/subsystems/esp.rs | 13 ++- 2 files changed, 126 insertions(+), 42 deletions(-) diff --git a/crates/trident/src/engine/boot/uki.rs b/crates/trident/src/engine/boot/uki.rs index 2711f7857a..421a5f3c1c 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; @@ -542,52 +542,59 @@ const VERITY_ADDON_TEMPLATE_A: &str = "verity-a.addon.efi"; const VERITY_ADDON_TEMPLATE_B: &str = "verity-b.addon.efi"; /// After staging the UKI, activate the correct verity addon for the target -/// A/B volume. ACL images that use PARTUUID-based verity addons ship with -/// slot-A active by default and include templates for both slots in +/// 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. /// -/// This is ACL-specific, and optional even there: if the image ships no verity -/// addon templates, this function is a silent no-op. However, if it ships the -/// other slot's template but not the selected slot's, 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); - let (template_name, other_template_name) = match target_volume { - AbVolumeSelection::VolumeA => (VERITY_ADDON_TEMPLATE_A, VERITY_ADDON_TEMPLATE_B), - AbVolumeSelection::VolumeB => (VERITY_ADDON_TEMPLATE_B, VERITY_ADDON_TEMPLATE_A), + let template_name = match target_volume { + AbVolumeSelection::VolumeA => VERITY_ADDON_TEMPLATE_A, + AbVolumeSelection::VolumeB => VERITY_ADDON_TEMPLATE_B, }; - let template_path = template_dir.join(template_name); - if !template_path.exists() { - // The template directory is shared with addons that have nothing to do - // with verity (first boot, fips, kdump), so its mere presence does not - // mean the image uses per-slot verity addons. What does mean that is - // the other slot's template being there: in that case a missing - // template for *this* slot would leave the UKI carrying the wrong - // slot's PARTUUIDs, so refuse. Otherwise the image simply does not use - // them, and there is nothing to activate. - ensure!( - !template_dir.join(other_template_name).exists(), - "Verity addon template '{}' not found in '{}', but '{}' is present — cannot activate {:?}", - template_name, - template_dir.display(), - other_template_name, - target_volume - ); + 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(VERITY_ADDON_TEMPLATE_A).exists() + || template_dir.join(VERITY_ADDON_TEMPLATE_B).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!( - "Image ships no verity addon templates in '{}', skipping", - 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(), + "Verity addon template '{}' not found in '{}' — cannot activate {:?}", + template_name, + template_dir.display(), + target_volume + ); + let staging_addon_dir = join_relative(mount_point, esp_mount_path) .join(UKI_DIRECTORY) .join(TMP_UKI_ADDON_DIR_NAME); @@ -1204,6 +1211,7 @@ mod tests { mount_point.path(), Path::new(DEFAULT_ESP_MOUNT_POINT_PATH), AbVolumeSelection::VolumeA, + false, ) .unwrap(); @@ -1231,6 +1239,7 @@ mod tests { mount_point.path(), Path::new(DEFAULT_ESP_MOUNT_POINT_PATH), AbVolumeSelection::VolumeB, + false, ) .unwrap(); @@ -1239,7 +1248,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(); @@ -1254,6 +1266,7 @@ mod tests { mount_point.path(), Path::new(DEFAULT_ESP_MOUNT_POINT_PATH), AbVolumeSelection::VolumeA, + true, ) .unwrap(); @@ -1281,6 +1294,7 @@ mod tests { mount_point.path(), Path::new(DEFAULT_ESP_MOUNT_POINT_PATH), AbVolumeSelection::VolumeB, + false, ); assert!(result.is_err()); @@ -1293,15 +1307,15 @@ mod tests { ); } - /// An image whose addon directory holds only non-verity addons is a no-op. + /// Inline verity makes activation a no-op, whatever the addon directory holds. /// - /// `acl/uki-addons/` is shared: ACL images ship first-boot, fips and kdump - /// addons there whether or not they use per-slot verity addons. Published - /// ACL images currently ship exactly those three and no verity templates, - /// so keying the no-op on the directory existing made every such image fail - /// to install. + /// 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_non_verity_addons_only() { + 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(); @@ -1318,6 +1332,7 @@ mod tests { mount_point.path(), Path::new(DEFAULT_ESP_MOUNT_POINT_PATH), target_volume, + true, ) .unwrap(); } @@ -1331,6 +1346,66 @@ mod tests { 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(VERITY_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(VERITY_ADDON_TEMPLATE_A), + "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() { @@ -1346,6 +1421,7 @@ mod tests { mount_point.path(), Path::new(DEFAULT_ESP_MOUNT_POINT_PATH), AbVolumeSelection::VolumeB, + false, ) .unwrap(); @@ -1384,6 +1460,7 @@ mod tests { mount_point.path(), Path::new(DEFAULT_ESP_MOUNT_POINT_PATH), AbVolumeSelection::VolumeA, + false, ) .unwrap(); diff --git a/crates/trident/src/subsystems/esp.rs b/crates/trident/src/subsystems/esp.rs index 9a7792afbd..612ae10fb5 100644 --- a/crates/trident/src/subsystems/esp.rs +++ b/crates/trident/src/subsystems/esp.rs @@ -19,7 +19,7 @@ use osutils::{ 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, @@ -337,15 +337,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 From 22e75c4479e353cacdb5b1456208eeaffe9424c4 Mon Sep 17 00:00:00 2001 From: Paco Date: Mon, 31 Aug 2026 15:52:33 -0700 Subject: [PATCH 7/8] bug: do not write fstab for UKI images using usr-verity Installing an Azure Container Linux image failed in Configure: Failed to generate fstab at path '/etc/fstab' Trident already skips storage configuration for UKI images using root-verity, on the reasoning that such an image takes its mount topology from the signed kernel command line rather than from fstab. ACL is the same case with the verity on /usr instead of /, but the guard tested only root-verity, so it did not apply. Writing the fstab would have been wrong even where it succeeded. ACL assembles /etc as an overlay, with the factory /usr/share/distro/etc as the lower layer and the ROOT partition's /etc as the upper, so a file written there wins over the image's own. The factory fstab is deliberately empty, every mount ACL needs already comes from the command line (/usr and /) or a shipped unit (/boot and /oem), and systemd-fstab-generator writes into /run/systemd/generator, which takes precedence over /usr/lib/systemd/system. A generated fstab would therefore have shadowed the units the image ships rather than merely duplicating them. Skip fstab generation for these images. Only fstab is skipped, not the whole step as in the root-verity case: a usr-verity image still has a writable root, so encryption and RAID configuration remain applicable. The mechanism was confirmed against the shipping ACL image, whose initrd mounts the overlay in dracut module 99setup-root and creates the ROOT /etc and workdir immediately beforehand. The absence of /etc in the image is therefore expected rather than a packaging fault, and not something for Trident to repair. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/trident/src/subsystems/storage/mod.rs | 154 ++++++++++++++++++- 1 file changed, 146 insertions(+), 8 deletions(-) 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 { From 386c9213b0868ff90bce03cbdc542eb2b7d16763 Mon Sep 17 00:00:00 2001 From: Paco Date: Mon, 31 Aug 2026 18:53:14 -0700 Subject: [PATCH 8/8] engineering: warn on a hash offset given with a separate hash image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A hash offset only means anything when the hash tree shares the data image. Producers emit the two together — Image Customizer sets `hashOffset` only for inline verity, and gives a distinct image path otherwise — so an offset accompanying a distinct hash image is contradictory. Derivation ignored the offset in that case, which would have set the device up as though it had never been given, and failed later at `veritysetup open` with nothing pointing at the cause. Warn instead. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/trident/src/osimage/cosi/derived_hc.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/crates/trident/src/osimage/cosi/derived_hc.rs b/crates/trident/src/osimage/cosi/derived_hc.rs index 1fd1186e06..9367c6e1f4 100644 --- a/crates/trident/src/osimage/cosi/derived_hc.rs +++ b/crates/trident/src/osimage/cosi/derived_hc.rs @@ -111,6 +111,20 @@ pub(super) fn derive_host_configuration_inner( 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())