From 648debc6a3f00aa7622a79b0eed770447b83e9d2 Mon Sep 17 00:00:00 2001 From: Benny Zlotnik Date: Mon, 14 Sep 2026 12:01:40 +0300 Subject: [PATCH] fix: layer detection did not correctly detect +gzip suffixes Signed-off-by: Benny Zlotnik --- src/fls/block_writer.rs | 4 +- src/fls/oci/from_oci.rs | 12 ++- src/fls/oci/manifest.rs | 201 +++++++++++++++++++++++++++++++++++++--- 3 files changed, 198 insertions(+), 19 deletions(-) diff --git a/src/fls/block_writer.rs b/src/fls/block_writer.rs index 5c8203e..0fc38a5 100644 --- a/src/fls/block_writer.rs +++ b/src/fls/block_writer.rs @@ -600,8 +600,8 @@ fn write_fill_pattern(writer: &mut BlockWriter, pattern: &[u8; 4], bytes: u64) - // Create a 4KB buffer filled with the pattern (matches ALIGNMENT) const FILL_BUFFER_SIZE: usize = 4096; let mut buffer = [0u8; FILL_BUFFER_SIZE]; - for chunk in buffer.chunks_exact_mut(4) { - chunk.copy_from_slice(pattern); + for chunk in buffer.as_chunks_mut::<4>().0 { + *chunk = *pattern; } let mut remaining = bytes; diff --git a/src/fls/oci/from_oci.rs b/src/fls/oci/from_oci.rs index 0d27a4c..a543dbb 100644 --- a/src/fls/oci/from_oci.rs +++ b/src/fls/oci/from_oci.rs @@ -875,14 +875,15 @@ pub async fn extract_files_by_annotations_with_overrides_to_dir( Ok(Some(partition_files)) } +/// Ensure that the layer compression is supported by fls. fn ensure_supported_layer_compression( compression: LayerCompression, media_type: &str, ) -> Result<(), Box> { match compression { - LayerCompression::None | LayerCompression::Gzip => Ok(()), + LayerCompression::None | LayerCompression::Gzip | LayerCompression::Xz => Ok(()), other => Err(format!( - "Unsupported OCI layer compression {:?} (media type: {}). Supported: uncompressed, gzip", + "Unsupported OCI layer compression {:?} (media type: {}). Supported: uncompressed, gzip, xz", other, media_type ) .into()), @@ -2456,6 +2457,13 @@ fn extract_tar_archive_from_stream( LayerCompression::Zstd => { return Err("Zstd layer compression is not supported yet".to_string()); } + LayerCompression::Xz => { + if debug { + eprintln!("[DEBUG] Layer is XZ compressed (manifest), will be decompressed during tar extraction"); + } + // XZ decompression happens in extract_tar_stream_impl via magic byte detection + Box::new(reader) + } LayerCompression::None => { // When manifest says no compression, use content detection result match compression_type { diff --git a/src/fls/oci/manifest.rs b/src/fls/oci/manifest.rs index 60b8299..69cadf4 100644 --- a/src/fls/oci/manifest.rs +++ b/src/fls/oci/manifest.rs @@ -14,7 +14,9 @@ pub mod media_types { "application/vnd.docker.distribution.manifest.list.v2+json"; // Layer media types + #[cfg(test)] pub const OCI_LAYER_GZIP: &str = "application/vnd.oci.image.layer.v1.tar+gzip"; + #[cfg(test)] pub const OCI_LAYER_ZSTD: &str = "application/vnd.oci.image.layer.v1.tar+zstd"; pub const DOCKER_LAYER: &str = "application/vnd.docker.image.rootfs.diff.tar.gzip"; } @@ -152,7 +154,12 @@ impl Manifest { // If artifactType is set, find the layer matching it if let Some(ref artifact_type) = m.artifact_type { - if let Some(layer) = m.layers.iter().find(|l| l.media_type == *artifact_type) { + let expected_base = split_media_type(artifact_type).0; + if let Some(layer) = m.layers.iter().find(|l| { + FlashableArtifact::is_flashable(&l.media_type) + && (l.media_type == *artifact_type + || split_media_type(&l.media_type).0 == expected_base) + }) { return Ok(layer); } } @@ -207,16 +214,31 @@ impl ImageIndex { } } +/// Split a media type into its base media type and optional structured syntax compression suffix. +fn split_media_type(media_type: &str) -> (&str, Option<&str>) { + if media_type == media_types::DOCKER_LAYER { + (media_type, Some("gzip")) + } else if let Some((base, suffix)) = media_type.split_once('+') { + (base, Some(suffix)) + } else { + (media_type, None) + } +} + impl Descriptor { /// Check if this is a gzip-compressed layer pub fn is_gzip_layer(&self) -> bool { - self.media_type == media_types::OCI_LAYER_GZIP - || self.media_type == media_types::DOCKER_LAYER + self.compression() == LayerCompression::Gzip } /// Check if this is a zstd-compressed layer pub fn is_zstd_layer(&self) -> bool { - self.media_type == media_types::OCI_LAYER_ZSTD + self.compression() == LayerCompression::Zstd + } + + /// Check if this is an xz-compressed layer + pub fn is_xz_layer(&self) -> bool { + self.compression() == LayerCompression::Xz } #[allow(dead_code)] @@ -226,26 +248,26 @@ impl Descriptor { /// Get compression type pub fn compression(&self) -> LayerCompression { - if self.is_gzip_layer() { - LayerCompression::Gzip - } else if self.is_zstd_layer() { - LayerCompression::Zstd - } else { - LayerCompression::None + match split_media_type(&self.media_type).1 { + Some("gzip") => LayerCompression::Gzip, + Some("zstd") => LayerCompression::Zstd, + Some("xz") => LayerCompression::Xz, + _ => LayerCompression::None, } } } /// Layer compression type -#[derive(Debug, Clone, Copy, PartialEq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LayerCompression { None, Gzip, + Xz, Zstd, } /// Flashable disk image artifact types -#[derive(Debug, Clone, Copy, PartialEq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum FlashableArtifact { DiskRaw, DiskQcow2, @@ -258,11 +280,18 @@ impl FlashableArtifact { "application/vnd.embedded.disk", ]; + /// Parse a [`FlashableArtifact`] from an OCI layer media type string, validating supported format and compression. pub fn from_media_type(media_type: &str) -> Option { - let suffix = Self::MEDIA_TYPE_PREFIXES + let (base, suffix) = split_media_type(media_type); + if let Some(s) = suffix { + if !matches!(s, "gzip" | "zstd" | "xz") { + return None; + } + } + let format = Self::MEDIA_TYPE_PREFIXES .iter() - .find_map(|prefix| media_type.strip_prefix(prefix))?; - match suffix { + .find_map(|prefix| base.strip_prefix(prefix))?; + match format { ".raw" => Some(Self::DiskRaw), ".qcow2" => Some(Self::DiskQcow2), ".simg" => Some(Self::DiskSimg), @@ -270,6 +299,7 @@ impl FlashableArtifact { } } + /// Return the standard file extension / format suffix for this artifact type (e.g., ".raw"). pub fn format_suffix(&self) -> &'static str { match self { Self::DiskRaw => ".raw", @@ -278,10 +308,12 @@ impl FlashableArtifact { } } + /// Check whether the given media type corresponds to a supported flashable artifact. pub fn is_flashable(media_type: &str) -> bool { Self::from_media_type(media_type).is_some() } + /// Return all supported uncompressed media type strings for flashable artifacts. pub fn supported_types() -> Vec { let mut types = Vec::new(); for prefix in Self::MEDIA_TYPE_PREFIXES { @@ -298,6 +330,7 @@ impl From for crate::fls::compression::Compression { match layer_compression { LayerCompression::None => crate::fls::compression::Compression::None, LayerCompression::Gzip => crate::fls::compression::Compression::Gzip, + LayerCompression::Xz => crate::fls::compression::Compression::Xz, LayerCompression::Zstd => crate::fls::compression::Compression::Zstd, } } @@ -561,5 +594,143 @@ mod tests { ); } } + + // Compressed variants + assert_eq!( + FlashableArtifact::from_media_type("application/vnd.automotive.disk.raw+gzip"), + Some(FlashableArtifact::DiskRaw) + ); + assert_eq!( + FlashableArtifact::from_media_type("application/vnd.automotive.disk.raw+zstd"), + Some(FlashableArtifact::DiskRaw) + ); + assert_eq!( + FlashableArtifact::from_media_type("application/vnd.automotive.disk.raw+xz"), + Some(FlashableArtifact::DiskRaw) + ); + assert_eq!( + FlashableArtifact::from_media_type("application/vnd.embedded.disk.qcow2+gzip"), + Some(FlashableArtifact::DiskQcow2) + ); + assert_eq!( + FlashableArtifact::from_media_type("application/vnd.embedded.disk.simg+zstd"), + Some(FlashableArtifact::DiskSimg) + ); + + // Unrecognized compression suffixes are rejected + assert_eq!( + FlashableArtifact::from_media_type("application/vnd.automotive.disk.raw+unknown"), + None + ); + assert_eq!( + FlashableArtifact::from_media_type("application/vnd.automotive.disk.raw+tar"), + None + ); + } + + #[test] + fn test_descriptor_compression() { + let make_desc = |media_type: &str| Descriptor { + media_type: media_type.to_string(), + digest: "sha256:123".to_string(), + size: 100, + annotations: None, + platform: None, + }; + + let raw = make_desc("application/vnd.automotive.disk.raw"); + assert_eq!(raw.compression(), LayerCompression::None); + assert!(!raw.is_gzip_layer()); + assert!(!raw.is_zstd_layer()); + + let raw_gz = make_desc("application/vnd.automotive.disk.raw+gzip"); + assert_eq!(raw_gz.compression(), LayerCompression::Gzip); + assert!(raw_gz.is_gzip_layer()); + assert!(!raw_gz.is_zstd_layer()); + + let raw_zstd = make_desc("application/vnd.automotive.disk.raw+zstd"); + assert_eq!(raw_zstd.compression(), LayerCompression::Zstd); + assert!(!raw_zstd.is_gzip_layer()); + assert!(raw_zstd.is_zstd_layer()); + assert!(!raw_zstd.is_xz_layer()); + + let raw_xz = make_desc("application/vnd.automotive.disk.raw+xz"); + assert_eq!(raw_xz.compression(), LayerCompression::Xz); + assert!(!raw_xz.is_gzip_layer()); + assert!(!raw_xz.is_zstd_layer()); + assert!(raw_xz.is_xz_layer()); + + let oci_gz = make_desc(media_types::OCI_LAYER_GZIP); + assert_eq!(oci_gz.compression(), LayerCompression::Gzip); + assert!(oci_gz.is_gzip_layer()); + assert!(!oci_gz.is_zstd_layer()); + + let oci_zstd = make_desc(media_types::OCI_LAYER_ZSTD); + assert_eq!(oci_zstd.compression(), LayerCompression::Zstd); + assert!(!oci_zstd.is_gzip_layer()); + assert!(oci_zstd.is_zstd_layer()); + + let docker = make_desc(media_types::DOCKER_LAYER); + assert_eq!(docker.compression(), LayerCompression::Gzip); + assert!(docker.is_gzip_layer()); + assert!(!docker.is_zstd_layer()); + } + + #[test] + fn test_artifact_type_selection_with_compression() { + let json = r#"{ + "schemaVersion": 2, + "artifactType": "application/vnd.automotive.disk.qcow2", + "config": { + "mediaType": "application/vnd.oci.image.config.v1+json", + "digest": "sha256:config123", + "size": 100 + }, + "layers": [ + { + "mediaType": "application/vnd.automotive.disk.raw+gzip", + "digest": "sha256:disk_raw", + "size": 1000 + }, + { + "mediaType": "application/vnd.automotive.disk.qcow2+gzip", + "digest": "sha256:disk_qcow2", + "size": 9999 + } + ] + }"#; + let manifest = Manifest::parse(json.as_bytes(), None).unwrap(); + let layer = manifest.get_single_layer().unwrap(); + assert_eq!(layer.digest, "sha256:disk_qcow2"); + assert_eq!(layer.compression(), LayerCompression::Gzip); + } + + #[test] + fn test_unsupported_layer_preceding_supported_layer_with_same_base() { + let json = r#"{ + "schemaVersion": 2, + "artifactType": "application/vnd.automotive.disk.raw", + "config": { + "mediaType": "application/vnd.oci.image.config.v1+json", + "digest": "sha256:config123", + "size": 100 + }, + "layers": [ + { + "mediaType": "application/vnd.automotive.disk.raw+unknown", + "digest": "sha256:unsupported_raw", + "size": 1000 + }, + { + "mediaType": "application/vnd.automotive.disk.raw+gzip", + "digest": "sha256:supported_raw", + "size": 2000 + } + ] + }"#; + let manifest = Manifest::parse(json.as_bytes(), None).unwrap(); + let layer = manifest.get_single_layer().unwrap(); + assert_eq!(layer.digest, "sha256:supported_raw"); + assert_eq!(layer.compression(), LayerCompression::Gzip); } }