block: Add VMDK descriptor file parser for Flat createType

Adds parser for the Virtual Machine DisK descriptor
file. The parser supports 2 flat/pre-allocated create types,
monolithicFlat & twoGbMaxExtentFlat. The parser marks any other
type as unsupported.

The implementation references VMDK descriptor specification from
https://web.archive.org/web/20240527050638if_/https://www.vmware.com/app/vmdk/?src=vmdk

Signed-off-by: Sumedh Alok Sharma <sumsharma@microsoft.com>
This commit is contained in:
Sumedh Alok Sharma
2026-07-13 07:43:13 +00:00
committed by Wei Liu
parent dcea656a72
commit 7b24354020
3 changed files with 526 additions and 0 deletions

View File

@@ -11,3 +11,4 @@ pub mod qcow;
pub mod raw;
pub mod vhd;
pub mod vhdx;
pub mod vmdk;

View File

@@ -0,0 +1,513 @@
// Copyright © 2026, Microsoft Corporation
//
// SPDX-License-Identifier: Apache-2.0
//! Parser for the flat VMDK text descriptor.
//!
//! A flat VMDK stores its layout in a small text descriptor: a header, a list
//! of `FLAT` extent lines, and a disk database (DDB). Only the `monolithicFlat`
//! and `twoGbMaxExtentFlat` create types are recognized.
use std::collections::HashMap;
use std::fs::File;
use std::io;
use std::os::unix::fs::FileExt;
use std::str::Lines;
use crate::AlignedFile;
const VMDK_DESCRIPTOR_HEADER: &str = "# Disk DescriptorFile";
const VMDK_DESCRIPTOR_EXTENTS: &str = "# Extent description";
const VMDK_DESCRIPTOR_DDB: &str = "# The Disk Data Base";
const VMDK_DESCRIPTOR_DDB_2: &str = "#DDB";
/// Flat VMDK create types.
#[derive(Debug, Default)]
pub enum VMDKDiskType {
#[default]
CreateTypeUnsupported,
MonolithicFlat,
TwoGbMaxExtentFlat,
}
/// Flat VMDK extent line fields.
/// Format of each extent line:
/// `<access> <sectors> <type> "<file>" [offset]`.
#[allow(dead_code)]
#[derive(Debug, Default)]
pub struct VmdkExtentHeader {
pub access: String,
pub size_in_sectors: u64,
pub extent_type: String,
pub filename: String,
pub offset_in_sectors: u64,
}
/// Descriptor header fields.
#[allow(dead_code)]
#[derive(Debug, Default)]
pub struct VmdkDescriptorHeader {
pub version: u32,
pub cid: u32,
pub parent_cid: u32,
pub create_type: VMDKDiskType,
pub parent_filename_hint: String,
}
/// Ordered list of extents.
#[derive(Debug, Default)]
pub struct VmdkDescriptorExtents {
pub extents: Vec<VmdkExtentHeader>,
}
/// Disk database.
#[allow(dead_code)]
#[derive(Debug, Default)]
pub struct VmdkDescriptorDdb {
pub entries: HashMap<String, String>,
}
// Read the whole descriptor into memory through an `AlignedFile` and return it
// as a `String`.
fn read_descriptor(file: &File) -> io::Result<String> {
let aligned = AlignedFile::new(file.try_clone()?, true);
let len = file.metadata()?.len() as usize;
let mut buf = vec![0u8; len];
let mut filled = 0;
while filled < buf.len() {
match aligned.read_at(&mut buf[filled..], filled as u64) {
Ok(0) => break,
Ok(n) => filled += n,
Err(ref e) if e.kind() == io::ErrorKind::Interrupted => continue,
Err(e) => return Err(e),
}
}
buf.truncate(filled);
// A descriptor is ASCII text, so invalid UTF-8 means "not a descriptor".
String::from_utf8(buf).map_err(|_| {
io::Error::new(
io::ErrorKind::InvalidData,
"VMDK descriptor is not valid UTF-8",
)
})
}
pub(crate) fn parse_header<'a>(
lines: &mut Lines<'a>,
) -> io::Result<(VmdkDescriptorHeader, &'a str)> {
let header_line = lines.next().unwrap_or_default();
// Reject actual disk data (or an embedded descriptor, which is
// unsupported): a flat descriptor must start with the header line.
if header_line.trim_end() != VMDK_DESCRIPTOR_HEADER {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("Not a VMDK descriptor file: missing header: {header_line}"),
));
}
let mut header = VmdkDescriptorHeader::default();
let mut last_comment_line = "";
for line in lines.by_ref() {
if line.starts_with('#') {
// End of the header section.
last_comment_line = line;
break;
}
let parts: Vec<&str> = line.split('=').map(|s| s.trim()).collect();
if parts.len() == 2 {
match parts[0] {
"version" => header.version = parts[1].parse().unwrap_or(0),
"CID" => header.cid = u32::from_str_radix(parts[1], 16).unwrap_or(0),
"parentCID" => header.parent_cid = u32::from_str_radix(parts[1], 16).unwrap_or(0),
"createType" => {
// Tools such as qemu-img quote the value, e.g.
// createType="monolithicFlat", strip the quotes.
header.create_type = match parts[1].trim_matches('"') {
"monolithicFlat" => VMDKDiskType::MonolithicFlat,
"twoGbMaxExtentFlat" => VMDKDiskType::TwoGbMaxExtentFlat,
_ => VMDKDiskType::CreateTypeUnsupported,
}
}
"parentFileNameHint" => header.parent_filename_hint = parts[1].to_string(),
_ => {}
}
}
}
Ok((header, last_comment_line))
}
pub(crate) fn parse_extents_and_ddb(
lines: &mut Lines<'_>,
last_comment_line: &str,
) -> io::Result<(VmdkDescriptorExtents, VmdkDescriptorDdb)> {
let mut extents = VmdkDescriptorExtents::default();
let mut ddb = VmdkDescriptorDdb::default();
if last_comment_line != VMDK_DESCRIPTOR_EXTENTS {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"Expected the extents section comment line",
));
}
let mut in_extents_section = true;
for line in lines.by_ref() {
if line.trim().is_empty() {
continue;
}
if line.starts_with('#') {
if line == VMDK_DESCRIPTOR_DDB || line == VMDK_DESCRIPTOR_DDB_2 {
in_extents_section = false;
continue;
}
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"Expected the DDB section comment line",
));
}
if in_extents_section {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() == 4 || parts.len() == 5 {
let size_in_sectors = parts[1].parse::<u64>().map_err(|_| {
io::Error::new(
io::ErrorKind::InvalidData,
format!(
"VMDK extent size '{}' is not a valid sector count",
parts[1]
),
)
})?;
let offset_in_sectors = match parts.get(4) {
Some(offset) => offset.parse::<u64>().map_err(|_| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("VMDK extent offset '{offset}' is not a valid sector count"),
)
})?,
None => 0,
};
let extent = VmdkExtentHeader {
access: parts[0].to_string(),
size_in_sectors,
extent_type: parts[2].to_string(),
filename: parts[3].trim_matches('"').to_string(),
offset_in_sectors,
};
extents.extents.push(extent);
} else {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"Malformed VMDK extent line",
));
}
} else {
let parts: Vec<&str> = line.split('=').map(|s| s.trim()).collect();
if parts.len() == 2 {
ddb.entries
.insert(parts[0].to_string(), parts[1].to_string());
}
}
}
Ok((extents, ddb))
}
/// Returns true when `prefix` begins with the `# Disk DescriptorFile` header.
pub fn has_descriptor_header(prefix: &[u8]) -> bool {
prefix.starts_with(VMDK_DESCRIPTOR_HEADER.as_bytes())
}
/// Returns true for a supported flat VMDK: create type `monolithicFlat` or
/// `twoGbMaxExtentFlat` with only `FLAT` extents.
pub fn is_flat_vmdk(f: &mut File) -> io::Result<bool> {
let content = match read_descriptor(f) {
Ok(content) => content,
Err(e) if e.kind() == io::ErrorKind::InvalidData => return Ok(false),
Err(e) => return Err(e),
};
let mut lines = content.lines();
let (header, last_line) = match parse_header(&mut lines) {
Ok(parsed) => parsed,
Err(e) if e.kind() == io::ErrorKind::InvalidData => return Ok(false),
Err(e) => return Err(e),
};
match header.create_type {
VMDKDiskType::MonolithicFlat | VMDKDiskType::TwoGbMaxExtentFlat => {}
_ => return Ok(false),
}
let extents = match parse_extents_and_ddb(&mut lines, last_line) {
Ok((extents, _ddb)) => extents,
Err(e) if e.kind() == io::ErrorKind::InvalidData => return Ok(false),
Err(e) => return Err(e),
};
for extent in &extents.extents {
if extent.extent_type != "FLAT" {
return Ok(false);
}
}
Ok(true)
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_hdr(input: &str) -> io::Result<(VmdkDescriptorHeader, String)> {
let mut lines = input.lines();
let (header, last) = parse_header(&mut lines)?;
Ok((header, last.to_string()))
}
fn parse_body(
last_comment: &str,
body: &str,
) -> io::Result<(VmdkDescriptorExtents, VmdkDescriptorDdb)> {
let mut lines = body.lines();
parse_extents_and_ddb(&mut lines, last_comment)
}
// Two-stage parse, as `VmdkDescriptor::new` chains it.
fn parse_full(
input: &str,
) -> io::Result<(
VmdkDescriptorHeader,
VmdkDescriptorExtents,
VmdkDescriptorDdb,
)> {
let mut lines = input.lines();
let (header, last) = parse_header(&mut lines)?;
let (extents, ddb) = parse_extents_and_ddb(&mut lines, last)?;
Ok((header, extents, ddb))
}
#[test]
fn single_flat_extent_with_ddb() {
let body: &str = "RW 2097152 FLAT \"disk-flat.vmdk\"\n\
# The Disk Data Base\n\
ddb.adapterType = \"ide\"\n\
ddb.geometry.sectors = \"63\"\n";
let (extents, ddb) = parse_body("# Extent description", body).unwrap();
assert_eq!(extents.extents.len(), 1);
let e = &extents.extents[0];
assert_eq!(e.access, "RW");
assert_eq!(e.size_in_sectors, 2_097_152);
assert_eq!(e.extent_type, "FLAT");
assert_eq!(e.filename, "disk-flat.vmdk");
assert_eq!(
ddb.entries.get("ddb.adapterType").map(String::as_str),
Some("\"ide\"")
);
assert_eq!(
ddb.entries.get("ddb.geometry.sectors").map(String::as_str),
Some("\"63\"")
);
}
#[test]
fn multiple_extents_two_gb_max() {
let body: &str = "RW 4192256 FLAT \"disk-s001.vmdk\"\n\
RW 4192256 FLAT \"disk-s002.vmdk\"\n\
RW 2097152 FLAT \"disk-s003.vmdk\"\n\
# The Disk Data Base\n\
ddb.adapterType = \"lsilogic\"\n";
let (extents, _ddb) = parse_body("# Extent description", body).unwrap();
assert_eq!(extents.extents.len(), 3);
assert_eq!(extents.extents[0].filename, "disk-s001.vmdk");
assert_eq!(extents.extents[2].filename, "disk-s003.vmdk");
assert!(extents.extents.iter().all(|e| e.extent_type == "FLAT"));
}
#[test]
fn extent_line_with_optional_offset_field() {
// 5-field form: <access> <sectors> <type> <file> <offset>
let body: &str = "RW 2097152 FLAT \"disk-flat.vmdk\" 0\n";
let (extents, _ddb) = parse_body("# Extent description", body).unwrap();
assert_eq!(extents.extents.len(), 1);
assert_eq!(extents.extents[0].filename, "disk-flat.vmdk");
}
#[test]
fn extent_access_modes_are_preserved() {
let body: &str = "RDONLY 2097152 FLAT \"ro.vmdk\"\n\
NOACCESS 1048576 FLAT \"noaccess.vmdk\"\n";
let (extents, _ddb) = parse_body("# Extent description", body).unwrap();
assert_eq!(extents.extents.len(), 2);
assert_eq!(extents.extents[0].access, "RDONLY");
assert_eq!(extents.extents[1].access, "NOACCESS");
}
#[test]
fn rejects_wrong_leading_comment() {
let body: &str = "RW 2097152 FLAT \"disk-flat.vmdk\"\n";
// Must be told we are at "# Extent description", anything else errors.
parse_body("# The Disk Data Base", body).unwrap_err();
}
#[test]
fn rejects_malformed_extent_line() {
// Only three fields -> malformed.
let body: &str = "RW 2097152 FLAT\n";
parse_body("# Extent description", body).unwrap_err();
}
#[test]
fn rejects_non_numeric_extent_size() {
// A non-numeric sector count must be rejected, not coerced to 0.
let body: &str = "RW notanumber FLAT \"disk-flat.vmdk\"\n";
parse_body("# Extent description", body).unwrap_err();
}
#[test]
fn rejects_non_numeric_extent_offset() {
// A non-numeric trailing offset must be rejected too.
let body: &str = "RW 2097152 FLAT \"disk-flat.vmdk\" xyz\n";
parse_body("# Extent description", body).unwrap_err();
}
#[test]
fn rejects_unexpected_comment_in_body() {
let body: &str = "RW 2097152 FLAT \"disk-flat.vmdk\"\n\
# Some other comment\n";
parse_body("# Extent description", body).unwrap_err();
}
#[test]
fn skips_blank_line_inside_extent_section() {
// qemu-img emits a blank line between the last extent and the
// "# The Disk Data Base" marker, it must be skipped, not rejected.
let body: &str = "RW 2097152 FLAT \"disk-flat.vmdk\"\n\
\n\
# The Disk Data Base\n";
let (extents, _ddb) = parse_body("# Extent description", body).unwrap();
assert_eq!(extents.extents.len(), 1);
assert_eq!(extents.extents[0].filename, "disk-flat.vmdk");
}
#[test]
fn rejects_missing_descriptor_header() {
let input: &str = "NOT_A_DESCRIPTOR\nversion=1\n";
parse_hdr(input).unwrap_err();
}
#[test]
fn parses_header_fields() {
let input: &str = "# Disk DescriptorFile\n\
version=1\n\
CID=fffffffe\n\
parentCID=ffffffff\n\
createType=monolithicFlat\n\
# Extent description\n";
let (header, last) = parse_hdr(input).unwrap();
assert_eq!(header.version, 1);
assert_eq!(header.cid, 0xffff_fffe);
assert_eq!(header.parent_cid, 0xffff_ffff);
assert!(matches!(header.create_type, VMDKDiskType::MonolithicFlat));
assert_eq!(last, "# Extent description");
}
#[test]
fn parses_quoted_create_type() {
let input: &str = "# Disk DescriptorFile\n\
version=1\n\
createType=\"twoGbMaxExtentFlat\"\n\
# Extent description\n";
let (header, _last) = parse_hdr(input).unwrap();
assert!(matches!(
header.create_type,
VMDKDiskType::TwoGbMaxExtentFlat
));
}
#[test]
fn full_monolithic_flat_descriptor() {
let input: &str = "# Disk DescriptorFile\n\
version=1\n\
createType=monolithicFlat\n\
# Extent description\n\
RW 2097152 FLAT \"disk-flat.vmdk\"\n\
# The Disk Data Base\n\
ddb.adapterType = \"ide\"\n";
let (header, extents, ddb) = parse_full(input).unwrap();
assert!(matches!(header.create_type, VMDKDiskType::MonolithicFlat));
assert_eq!(extents.extents.len(), 1);
assert_eq!(extents.extents[0].access, "RW");
assert_eq!(
ddb.entries.get("ddb.adapterType").map(String::as_str),
Some("\"ide\"")
);
}
#[test]
fn full_two_gb_max_extent_flat_descriptor() {
let input: &str = "# Disk DescriptorFile\n\
version=1\n\
createType=twoGbMaxExtentFlat\n\
# Extent description\n\
RW 4192256 FLAT \"disk-s001.vmdk\"\n\
RW 4192256 FLAT \"disk-s002.vmdk\"\n\
# The Disk Data Base\n";
let (header, extents, _ddb) = parse_full(input).unwrap();
assert!(matches!(
header.create_type,
VMDKDiskType::TwoGbMaxExtentFlat
));
assert_eq!(extents.extents.len(), 2);
}
#[test]
fn full_qemu_style_descriptor() {
// Mirrors a real qemu-img monolithicFlat descriptor: quoted createType,
// blank lines between sections, 5-field extent line with a trailing
// offset, and the "#DDB" marker form.
let input: &str = "# Disk DescriptorFile\n\
version=1\n\
CID=eb2295a4\n\
parentCID=ffffffff\n\
createType=\"monolithicFlat\"\n\
\n\
# Extent description\n\
RW 6291456 FLAT \"t-flat.vmdk\" 0\n\
\n\
# The Disk Data Base\n\
#DDB\n\
\n\
ddb.virtualHWVersion = \"4\"\n\
ddb.adapterType = \"ide\"\n";
let (header, extents, ddb) = parse_full(input).unwrap();
assert!(matches!(header.create_type, VMDKDiskType::MonolithicFlat));
assert_eq!(extents.extents.len(), 1);
assert_eq!(extents.extents[0].access, "RW");
assert_eq!(extents.extents[0].size_in_sectors, 6_291_456);
assert_eq!(extents.extents[0].extent_type, "FLAT");
assert_eq!(extents.extents[0].filename, "t-flat.vmdk");
assert_eq!(
ddb.entries.get("ddb.adapterType").map(String::as_str),
Some("\"ide\"")
);
}
}

View File

@@ -0,0 +1,12 @@
// Copyright 2026 The Cloud Hypervisor Authors. All rights reserved.
//
// SPDX-License-Identifier: Apache-2.0
//! Flat VMDK block backend.
//!
//! Supports the `monolithicFlat` and `twoGbMaxExtentFlat` create types with
//! synchronous, extent-aware I/O.
mod descriptor;
pub use descriptor::{has_descriptor_header, is_flat_vmdk};