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 <anbelski@linux.microsoft.com>
Co-developed-by: Philipp Schuster <philipp.schuster@cyberus-technology.de>
This commit is contained in:
Anatol Belski 2026-01-19 10:43:16 +01:00 committed by Bo Chen
parent eaafe426a6
commit 7c99c169ba
3 changed files with 121 additions and 8 deletions

1
Cargo.lock generated
View file

@ -322,6 +322,7 @@ checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
name = "block"
version = "0.1.0"
dependencies = [
"bitflags 2.10.0",
"byteorder",
"crc-any",
"flate2",

View file

@ -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"

View file

@ -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 <http://github.com/facebook/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<String> = (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(),