From c7b3189f5e5307728da0612bc6ebb852c5d64dfd Mon Sep 17 00:00:00 2001 From: Benny Zlotnik Date: Wed, 18 Mar 2026 20:05:02 +0200 Subject: [PATCH 1/3] check the layer media type before flashing To avoid flashing non disk layers we need to check the media type Signed-off-by: Benny Zlotnik Assisted-by: claude-sonnet-4.6 --- src/fls/oci/manifest.rs | 37 ++++++++++++++++++++++++++++++------- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/src/fls/oci/manifest.rs b/src/fls/oci/manifest.rs index ffd0295..4cfe64b 100644 --- a/src/fls/oci/manifest.rs +++ b/src/fls/oci/manifest.rs @@ -64,6 +64,10 @@ pub struct ImageManifest { #[serde(default)] pub media_type: Option, + /// Artifact type (OCI v1.1) - indicates the primary content type + #[serde(default)] + pub artifact_type: Option, + /// Config blob descriptor pub config: Descriptor, @@ -135,14 +139,33 @@ impl Manifest { match self { Manifest::Image(ref m) => { if m.layers.is_empty() { - Err("Manifest has no layers".to_string()) - } else if m.layers.len() > 1 { - // For multi-layer images, we take the last layer - // which typically contains the actual content - Ok(m.layers.last().unwrap()) - } else { - Ok(&m.layers[0]) + return Err("Manifest has no layers".to_string()); + } + + if m.layers.len() == 1 { + return Ok(&m.layers[0]); } + + // 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) { + return Ok(layer); + } + } + + // Fall back to the first disk image layer + if let Some(layer) = m + .layers + .iter() + .find(|l| l.media_type.starts_with("application/vnd.automotive.disk")) + { + return Ok(layer); + } + + Err(format!( + "No disk image layer found among {} layers", + m.layers.len() + )) } Manifest::Index(_) => Err( "Cannot get layer from manifest index - need to resolve platform first".to_string(), From e42613473984ae17cd8e721a4159f46646f560e9 Mon Sep 17 00:00:00 2001 From: Benny Zlotnik Date: Wed, 8 Apr 2026 09:19:08 +0000 Subject: [PATCH 2/3] Validate flashable media type in flash_from_oci path Address review feedback: add flashable media type validation (application/vnd.automotive.disk.*) in the flash_from_oci function, which is the correct place for flash-specific checks. Keep get_single_layer() generic since it is also used by the extraction path where any media type is valid. For multi-layer manifests, get_single_layer() prefers artifact_type matches and falls back to the first disk image layer. Add unit tests for: artifact_type selection, single layer (any type) returned, single flashable layer acceptance, and no-match error. Co-Authored-By: Claude Opus 4.6 --- src/fls/oci/from_oci.rs | 13 +++++ src/fls/oci/manifest.rs | 106 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+) diff --git a/src/fls/oci/from_oci.rs b/src/fls/oci/from_oci.rs index b37f8a7..4ab101d 100644 --- a/src/fls/oci/from_oci.rs +++ b/src/fls/oci/from_oci.rs @@ -1811,6 +1811,19 @@ pub async fn flash_from_oci( // Get the layer to download let layer = manifest.get_single_layer()?; + + // Validate that the selected layer is a flashable disk image + if !layer + .media_type + .starts_with("application/vnd.automotive.disk") + { + return Err(format!( + "Layer media type '{}' is not a flashable disk image", + layer.media_type + ) + .into()); + } + let layer_size = layer.size; let compression = layer.compression(); diff --git a/src/fls/oci/manifest.rs b/src/fls/oci/manifest.rs index 4cfe64b..cda0d70 100644 --- a/src/fls/oci/manifest.rs +++ b/src/fls/oci/manifest.rs @@ -279,6 +279,112 @@ mod tests { } } + #[test] + fn test_single_flashable_layer() { + let json = r#"{ + "schemaVersion": 2, + "config": { + "mediaType": "application/vnd.oci.image.config.v1+json", + "digest": "sha256:config123", + "size": 100 + }, + "layers": [ + { + "mediaType": "application/vnd.automotive.disk.raw", + "digest": "sha256:disk123", + "size": 9999 + } + ] + }"#; + let manifest = Manifest::parse(json.as_bytes(), None).unwrap(); + let layer = manifest.get_single_layer().unwrap(); + assert_eq!(layer.digest, "sha256:disk123"); + } + + #[test] + fn test_single_non_disk_layer_returned() { + // Single-layer manifests return the layer regardless of media type; + // callers (e.g. flash_from_oci) are responsible for validating + // flashable media types when appropriate. + let json = r#"{ + "schemaVersion": 2, + "config": { + "mediaType": "application/vnd.oci.image.config.v1+json", + "digest": "sha256:config123", + "size": 100 + }, + "layers": [ + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", + "digest": "sha256:layer123", + "size": 5678 + } + ] + }"#; + let manifest = Manifest::parse(json.as_bytes(), None).unwrap(); + let layer = manifest.get_single_layer().unwrap(); + assert_eq!(layer.digest, "sha256:layer123"); + } + + #[test] + fn test_artifact_type_selection() { + 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.oci.image.layer.v1.tar+gzip", + "digest": "sha256:layer1", + "size": 1000 + }, + { + "mediaType": "application/vnd.automotive.disk.raw", + "digest": "sha256:disk1", + "size": 9999 + } + ] + }"#; + let manifest = Manifest::parse(json.as_bytes(), None).unwrap(); + let layer = manifest.get_single_layer().unwrap(); + assert_eq!(layer.digest, "sha256:disk1"); + } + + #[test] + fn test_no_flashable_layer_error() { + let json = r#"{ + "schemaVersion": 2, + "config": { + "mediaType": "application/vnd.oci.image.config.v1+json", + "digest": "sha256:config123", + "size": 100 + }, + "layers": [ + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", + "digest": "sha256:layer1", + "size": 1000 + }, + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+zstd", + "digest": "sha256:layer2", + "size": 2000 + } + ] + }"#; + let manifest = Manifest::parse(json.as_bytes(), None).unwrap(); + let err = manifest.get_single_layer().unwrap_err(); + assert!( + err.contains("No disk image layer found"), + "Expected no-match error, got: {}", + err + ); + } + #[test] fn test_parse_index() { let json = r#"{ From e3f85ba8398c5b195e59a0b7a0fcf8f270d122c1 Mon Sep 17 00:00:00 2001 From: Benny Zlotnik Date: Sun, 12 Apr 2026 12:55:58 +0300 Subject: [PATCH 3/3] Improve artifact type test coverage in get_single_layer Use two distinct flashable layers so artifactType selection is distinguishable from the fallback path. Add a regression test for when artifactType matches no layer to verify fallback to disk media type. Signed-off-by: Benny Zlotnik Assisted-by: claude-sonnet-4.6 --- src/fls/oci/manifest.rs | 41 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/src/fls/oci/manifest.rs b/src/fls/oci/manifest.rs index cda0d70..5e67cb8 100644 --- a/src/fls/oci/manifest.rs +++ b/src/fls/oci/manifest.rs @@ -134,7 +134,11 @@ impl Manifest { } /// Get the single layer from an image manifest - /// Returns error if there are no layers or multiple layers + /// + /// Returns: + /// - the only layer for single-layer manifests + /// - for multi-layer manifests: artifactType match first, otherwise first automotive disk layer + /// - error when no suitable layer is found pub fn get_single_layer(&self) -> Result<&Descriptor, String> { match self { Manifest::Image(ref m) => { @@ -328,9 +332,40 @@ mod tests { #[test] fn test_artifact_type_selection() { + // Two flashable layers: artifactType must pick the qcow2 layer, + // NOT the raw layer which appears first (and would be chosen by the fallback). let json = r#"{ "schemaVersion": 2, - "artifactType": "application/vnd.automotive.disk.raw", + "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", + "digest": "sha256:disk_raw", + "size": 1000 + }, + { + "mediaType": "application/vnd.automotive.disk.qcow2", + "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"); + } + + #[test] + fn test_artifact_type_no_match_falls_back_to_disk_layer() { + // artifactType doesn't match any layer — should fall back to the first disk layer + let json = r#"{ + "schemaVersion": 2, + "artifactType": "application/vnd.unknown.type", "config": { "mediaType": "application/vnd.oci.image.config.v1+json", "digest": "sha256:config123", @@ -339,7 +374,7 @@ mod tests { "layers": [ { "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", - "digest": "sha256:layer1", + "digest": "sha256:tar_layer", "size": 1000 }, {