From 7c99c169baa4e95a50d5cf5f023759cf5d4085a4 Mon Sep 17 00:00:00 2001 From: Anatol Belski Date: Mon, 19 Jan 2026 10:43:16 +0100 Subject: [PATCH] block: qcow: Validate incompatible feature bits Parse the feature name table header extension to provide descriptive error messages when unsupported incompatible features are detected. Currently only the compression bit (bit 3, zstd) is supported. This prevents opening qcow2 images with features that would cause incorrect behavior or data corruption (e.g., dirty bit, corrupt bit, external data file, extended L2 entries). Feature names are defined as follows: 1. The image's feature name table header extension (if present) 2. Hardcoded fallback names for known features 3. Generic "unknown feature bit N" for undefined features Signed-off-by: Anatol Belski Co-developed-by: Philipp Schuster --- Cargo.lock | 1 + block/Cargo.toml | 1 + block/src/qcow/mod.rs | 127 +++++++++++++++++++++++++++++++++++++++--- 3 files changed, 121 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6545d6013..8b3208e29 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -322,6 +322,7 @@ checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" name = "block" version = "0.1.0" dependencies = [ + "bitflags 2.10.0", "byteorder", "crc-any", "flate2", diff --git a/block/Cargo.toml b/block/Cargo.toml index c038b0cd8..70a731a73 100644 --- a/block/Cargo.toml +++ b/block/Cargo.toml @@ -9,6 +9,7 @@ default = [] io_uring = ["dep:io-uring"] [dependencies] +bitflags = { workspace = true } byteorder = { workspace = true } crc-any = "2.5.0" flate2 = "1.1" diff --git a/block/src/qcow/mod.rs b/block/src/qcow/mod.rs index e3aa04349..ae4dd4eed 100644 --- a/block/src/qcow/mod.rs +++ b/block/src/qcow/mod.rs @@ -18,6 +18,7 @@ use std::mem::size_of; use std::os::fd::{AsRawFd, RawFd}; use std::str::{self, FromStr}; +use bitflags::bitflags; use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt}; use libc::{EINVAL, EIO, ENOSPC}; use log::error; @@ -116,6 +117,8 @@ pub enum Error { UnsupportedBackingFileFormat(String), #[error("Unsupported compression type")] UnsupportedCompressionType, + #[error("Unsupported qcow2 feature(s)")] + UnsupportedFeature(#[source] MissingFeatureError), #[error("Unsupported refcount order")] UnsupportedRefcountOrder, #[error("Unsupported version: {0}")] @@ -207,6 +210,76 @@ const COMPRESSION_TYPE_ZSTD: u64 = 1; // zstd const HEADER_EXT_END: u32 = 0x00000000; // Backing file format name (raw, qcow2) const HEADER_EXT_BACKING_FORMAT: u32 = 0xe2792aca; +// Feature name table +const HEADER_EXT_FEATURE_NAME_TABLE: u32 = 0x6803f857; + +// Feature name table entry type incompatible +const FEAT_TYPE_INCOMPATIBLE: u8 = 0; + +bitflags! { + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub struct IncompatFeatures: u64 { + const DIRTY = 1 << 0; + const CORRUPT = 1 << 1; + const DATA_FILE = 1 << 2; + const COMPRESSION = 1 << 3; + const EXTENDED_L2 = 1 << 4; + } +} + +impl IncompatFeatures { + /// Features supported by this implementation. + const SUPPORTED: IncompatFeatures = IncompatFeatures::COMPRESSION; + + /// Get the fallback name for a known feature bit. + fn flag_name(bit: u8) -> Option<&'static str> { + Some(match Self::from_bits_truncate(1u64 << bit) { + Self::DIRTY => "dirty bit", + Self::CORRUPT => "corrupt bit", + Self::DATA_FILE => "external data file", + Self::EXTENDED_L2 => "extended L2 entries", + _ => return None, + }) + } +} + +/// Error type for unsupported incompatible features. +#[derive(Debug, Clone, Error)] +pub struct MissingFeatureError { + /// Unsupported feature bits. + features: IncompatFeatures, + /// Feature name table from the qcow2 image. + feature_names: Vec<(u8, String)>, +} + +impl MissingFeatureError { + fn new(features: IncompatFeatures, feature_names: Vec<(u8, String)>) -> Self { + Self { + features, + feature_names, + } + } +} + +impl Display for MissingFeatureError { + fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { + let names: Vec = (0u8..64) + .filter(|&bit| self.features.bits() & (1u64 << bit) != 0) + .map(|bit| { + // First try the image's feature name table + self.feature_names + .iter() + .find(|(b, _)| *b == bit) + .map(|(_, name)| name.clone()) + // Then try hardcoded fallback names + .or_else(|| IncompatFeatures::flag_name(bit).map(|s| s.to_string())) + // Finally, use generic description + .unwrap_or_else(|| format!("unknown feature bit {bit}")) + }) + .collect(); + write!(f, "Missing features: {}", names.join(", ")) + } +} // The format supports a "header extension area", that crosvm does not use. const QCOW_EMPTY_HEADER_EXTENSION_SIZE: u32 = 8; @@ -289,7 +362,12 @@ pub struct QcowHeader { } impl QcowHeader { - fn read_header_extensions(f: &mut RawFile, header: &mut QcowHeader) -> Result<()> { + /// Read header extensions, optionally collecting feature names for error reporting. + fn read_header_extensions( + f: &mut RawFile, + header: &mut QcowHeader, + mut feature_table: Option<&mut Vec<(u8, String)>>, + ) -> Result<()> { // Extensions start directly after the header f.seek(SeekFrom::Start(header.header_size as u64)) .map_err(Error::ReadingHeader)?; @@ -313,6 +391,21 @@ impl QcowHeader { backing_file.format = Some(format_str.parse()?); } } + HEADER_EXT_FEATURE_NAME_TABLE if feature_table.is_some() => { + const FEATURE_NAME_ENTRY_SIZE: usize = 1 + 1 + 46; // type + bit + name + let mut data = vec![0u8; ext_length as usize]; + f.read_exact(&mut data).map_err(Error::ReadingHeader)?; + let table = feature_table.as_mut().unwrap(); + for entry in data.chunks_exact(FEATURE_NAME_ENTRY_SIZE) { + if entry[0] == FEAT_TYPE_INCOMPATIBLE { + let bit_number = entry[1]; + let name_bytes = &entry[2..]; + let name_len = name_bytes.iter().position(|&b| b == 0).unwrap_or(46); + let name = String::from_utf8_lossy(&name_bytes[..name_len]).to_string(); + table.push((bit_number, name)); + } + } + } _ => { // Skip unknown extension f.seek(SeekFrom::Current(ext_length as i64)) @@ -415,8 +508,26 @@ impl QcowHeader { header.backing_file = Some(BackingFileConfig { path, format: None }); } - if version == 3 && header.header_size > V3_BARE_HEADER_SIZE { - Self::read_header_extensions(f, &mut header)?; + if version == 3 { + // Check for unsupported incompatible features first + let features = IncompatFeatures::from_bits_retain(header.incompatible_features); + let unsupported = features - IncompatFeatures::SUPPORTED; + if !unsupported.is_empty() { + // Read extensions only to get feature names for error reporting + let mut feature_table = Vec::new(); + if header.header_size > V3_BARE_HEADER_SIZE { + let _ = Self::read_header_extensions(f, &mut header, Some(&mut feature_table)); + } + return Err(Error::UnsupportedFeature(MissingFeatureError::new( + unsupported, + feature_table, + ))); + } + + // Features OK, now read extensions normally + if header.header_size > V3_BARE_HEADER_SIZE { + Self::read_header_extensions(f, &mut header, None)?; + } } Ok(header) @@ -2389,7 +2500,7 @@ mod unit_tests { format: None, }); - QcowHeader::read_header_extensions(&mut disk_file, &mut header).unwrap(); + QcowHeader::read_header_extensions(&mut disk_file, &mut header, None).unwrap(); assert_eq!(header.backing_file.as_ref().and_then(|bf| bf.format), None); } @@ -2403,7 +2514,7 @@ mod unit_tests { format: None, }); - QcowHeader::read_header_extensions(&mut disk_file, &mut header).unwrap(); + QcowHeader::read_header_extensions(&mut disk_file, &mut header, None).unwrap(); assert_eq!( header.backing_file.as_ref().and_then(|bf| bf.format), Some(ImageType::Raw) @@ -2420,7 +2531,7 @@ mod unit_tests { format: None, }); - QcowHeader::read_header_extensions(&mut disk_file, &mut header).unwrap(); + QcowHeader::read_header_extensions(&mut disk_file, &mut header, None).unwrap(); assert_eq!( header.backing_file.as_ref().and_then(|bf| bf.format), Some(ImageType::Qcow2) @@ -2437,7 +2548,7 @@ mod unit_tests { format: None, }); - let result = QcowHeader::read_header_extensions(&mut disk_file, &mut header); + let result = QcowHeader::read_header_extensions(&mut disk_file, &mut header, None); assert!(matches!( result.unwrap_err(), Error::UnsupportedBackingFileFormat(_) @@ -2451,7 +2562,7 @@ mod unit_tests { &[0xFF, 0xFE, 0xFD], // invalid UTF-8 ); - let result = QcowHeader::read_header_extensions(&mut disk_file, &mut header); + let result = QcowHeader::read_header_extensions(&mut disk_file, &mut header, None); // Should fail with InvalidBackingFileName error assert!(matches!( result.unwrap_err(),