block: Add flat VMDK extent layout

Defines and implements the layout of a VMDK extent, the region
of storage that is used by the virtual disk. Each line in the
descriptor file's extent section describes one extent.

Signed-off-by: Sumedh Alok Sharma <sumsharma@microsoft.com>
This commit is contained in:
Sumedh Alok Sharma
2026-07-13 09:05:58 +00:00
committed by Wei Liu
parent 7b24354020
commit 0769215d42
4 changed files with 773 additions and 116 deletions

View File

@@ -8,10 +8,10 @@
//! 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::path::Path;
use std::str::Lines;
use crate::AlignedFile;
@@ -33,7 +33,6 @@ pub enum VMDKDiskType {
/// 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,
@@ -44,14 +43,9 @@ pub struct VmdkExtentHeader {
}
/// 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.
@@ -59,11 +53,49 @@ pub struct VmdkDescriptorHeader {
pub struct VmdkDescriptorExtents {
pub extents: Vec<VmdkExtentHeader>,
}
/// Disk database.
#[allow(dead_code)]
/// Parsed flat VMDK descriptor
///
/// extents_list: ordered extent list
/// base_path: descriptor file's parent directory
#[derive(Debug, Default)]
pub struct VmdkDescriptorDdb {
pub entries: HashMap<String, String>,
pub struct VmdkDescriptor {
pub base_path: String,
pub extents_list: VmdkDescriptorExtents,
}
impl VmdkDescriptor {
pub fn new(file: &File, path: &Path) -> io::Result<Self> {
// The descriptor's directory anchors the relative extent filenames.
let base_path = path
.parent()
.ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
"Cannot retrieve parent directory of the file",
)
})?
.to_string_lossy()
.to_string();
// Valid descriptor file must be much larger than 4 bytes.
if file.metadata()?.len() < 4 {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"Invalid VMDK descriptor file: file is empty or too small",
));
}
let content = read_descriptor(file)?;
let mut lines = content.lines();
let (_header, last_line) = parse_header(&mut lines)?;
let extents_list = parse_extents(&mut lines, last_line)?;
Ok(Self {
base_path,
extents_list,
})
}
}
// Read the whole descriptor into memory through an `AlignedFile` and return it
@@ -115,35 +147,24 @@ pub(crate) fn parse_header<'a>(
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(),
_ => {}
}
if parts.len() == 2 && parts[0] == "createType" {
// Tools such as qemu-img quote the value, strip quotes for comparison.
header.create_type = match parts[1].trim_matches('"') {
"monolithicFlat" => VMDKDiskType::MonolithicFlat,
"twoGbMaxExtentFlat" => VMDKDiskType::TwoGbMaxExtentFlat,
_ => VMDKDiskType::CreateTypeUnsupported,
};
}
}
Ok((header, last_comment_line))
}
pub(crate) fn parse_extents_and_ddb(
pub(crate) fn parse_extents(
lines: &mut Lines<'_>,
last_comment_line: &str,
) -> io::Result<(VmdkDescriptorExtents, VmdkDescriptorDdb)> {
) -> io::Result<VmdkDescriptorExtents> {
let mut extents = VmdkDescriptorExtents::default();
let mut ddb = VmdkDescriptorDdb::default();
if last_comment_line != VMDK_DESCRIPTOR_EXTENTS {
return Err(io::Error::new(
@@ -152,66 +173,55 @@ pub(crate) fn parse_extents_and_ddb(
));
}
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;
break;
}
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(|_| {
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 size '{}' is not a valid sector count",
parts[1]
),
format!("VMDK extent offset '{offset}' is not a valid sector count"),
)
})?;
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",
));
}
})?,
None => 0,
};
extents.extents.push(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,
});
} 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());
}
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"Malformed VMDK extent line",
));
}
}
Ok((extents, ddb))
Ok(extents)
}
/// Returns true when `prefix` begins with the `# Disk DescriptorFile` header.
@@ -240,8 +250,8 @@ pub fn is_flat_vmdk(f: &mut File) -> io::Result<bool> {
_ => return Ok(false),
}
let extents = match parse_extents_and_ddb(&mut lines, last_line) {
Ok((extents, _ddb)) => extents,
let extents = match parse_extents(&mut lines, last_line) {
Ok(extents) => extents,
Err(e) if e.kind() == io::ErrorKind::InvalidData => return Ok(false),
Err(e) => return Err(e),
};
@@ -265,26 +275,54 @@ mod tests {
Ok((header, last.to_string()))
}
fn parse_body(
last_comment: &str,
body: &str,
) -> io::Result<(VmdkDescriptorExtents, VmdkDescriptorDdb)> {
fn parse_body(last_comment: &str, body: &str) -> io::Result<VmdkDescriptorExtents> {
let mut lines = body.lines();
parse_extents_and_ddb(&mut lines, last_comment)
parse_extents(&mut lines, last_comment)
}
// Two-stage parse, as `VmdkDescriptor::new` chains it.
fn parse_full(
input: &str,
) -> io::Result<(
VmdkDescriptorHeader,
VmdkDescriptorExtents,
VmdkDescriptorDdb,
)> {
fn parse_full(input: &str) -> io::Result<(VmdkDescriptorHeader, VmdkDescriptorExtents)> {
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))
let extents = parse_extents(&mut lines, last)?;
Ok((header, extents))
}
#[test]
fn new_is_unaffected_by_shared_file_offset() {
use std::io::{Read, Seek, SeekFrom, Write};
use vmm_sys_util::tempfile::TempFile;
// A minimal but complete monolithicFlat descriptor.
let descriptor_text = "# 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 tmp = TempFile::new().unwrap();
let mut file: &File = tmp.as_file();
file.write_all(descriptor_text.as_bytes()).unwrap();
// Advance the shared OS file offset off zero, mimicking an earlier
// image-type probe. `&File` is `Copy`, so this moves the same fd's
// offset that `VmdkDescriptor::new` will see.
file.seek(SeekFrom::Start(0)).unwrap();
let mut scratch = [0u8; 8];
file.read_exact(&mut scratch).unwrap();
assert_ne!(file.stream_position().unwrap(), 0);
// `read_descriptor` reads positionally (anchored at offset 0), so the
// advanced offset must not affect the parse.
let descriptor = VmdkDescriptor::new(file, tmp.as_path()).unwrap();
assert_eq!(descriptor.extents_list.extents.len(), 1);
assert_eq!(
descriptor.extents_list.extents[0].filename,
"disk-flat.vmdk"
);
}
#[test]
@@ -294,7 +332,7 @@ mod tests {
ddb.adapterType = \"ide\"\n\
ddb.geometry.sectors = \"63\"\n";
let (extents, ddb) = parse_body("# Extent description", body).unwrap();
let extents = parse_body("# Extent description", body).unwrap();
assert_eq!(extents.extents.len(), 1);
let e = &extents.extents[0];
@@ -302,15 +340,6 @@ mod tests {
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]
@@ -321,7 +350,7 @@ mod tests {
# The Disk Data Base\n\
ddb.adapterType = \"lsilogic\"\n";
let (extents, _ddb) = parse_body("# Extent description", body).unwrap();
let extents = parse_body("# Extent description", body).unwrap();
assert_eq!(extents.extents.len(), 3);
assert_eq!(extents.extents[0].filename, "disk-s001.vmdk");
@@ -334,7 +363,7 @@ mod tests {
// 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();
let extents = parse_body("# Extent description", body).unwrap();
assert_eq!(extents.extents.len(), 1);
assert_eq!(extents.extents[0].filename, "disk-flat.vmdk");
@@ -345,7 +374,7 @@ mod tests {
let body: &str = "RDONLY 2097152 FLAT \"ro.vmdk\"\n\
NOACCESS 1048576 FLAT \"noaccess.vmdk\"\n";
let (extents, _ddb) = parse_body("# Extent description", body).unwrap();
let extents = parse_body("# Extent description", body).unwrap();
assert_eq!(extents.extents.len(), 2);
assert_eq!(extents.extents[0].access, "RDONLY");
@@ -394,7 +423,7 @@ mod tests {
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();
let extents = parse_body("# Extent description", body).unwrap();
assert_eq!(extents.extents.len(), 1);
assert_eq!(extents.extents[0].filename, "disk-flat.vmdk");
}
@@ -416,9 +445,6 @@ mod tests {
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");
}
@@ -447,15 +473,11 @@ mod tests {
# The Disk Data Base\n\
ddb.adapterType = \"ide\"\n";
let (header, extents, ddb) = parse_full(input).unwrap();
let (header, extents) = 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]
@@ -468,7 +490,7 @@ mod tests {
RW 4192256 FLAT \"disk-s002.vmdk\"\n\
# The Disk Data Base\n";
let (header, extents, _ddb) = parse_full(input).unwrap();
let (header, extents) = parse_full(input).unwrap();
assert!(matches!(
header.create_type,
@@ -497,7 +519,7 @@ mod tests {
ddb.virtualHWVersion = \"4\"\n\
ddb.adapterType = \"ide\"\n";
let (header, extents, ddb) = parse_full(input).unwrap();
let (header, extents) = parse_full(input).unwrap();
assert!(matches!(header.create_type, VMDKDiskType::MonolithicFlat));
assert_eq!(extents.extents.len(), 1);
@@ -505,9 +527,5 @@ mod tests {
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,637 @@
// Copyright © 2026, Microsoft Corporation
//
// SPDX-License-Identifier: Apache-2.0
//! Flat VMDK extent layout: opens the data extents referenced by the
//! descriptor and maps the virtual disk onto them.
#![allow(dead_code)]
use std::ffi::{CString, OsStr};
use std::fs::{File, OpenOptions};
use std::io;
use std::os::unix::ffi::OsStrExt;
use std::os::unix::fs::OpenOptionsExt;
use std::os::unix::io::{AsRawFd, FromRawFd, RawFd};
use std::path::{Component, Path};
use std::sync::Arc;
use log::warn;
use crate::formats::vmdk::descriptor::VmdkDescriptor;
use crate::{AlignedFile, DiskTopology};
const VMDK_SECTOR_SIZE: u64 = 512;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ExtentAccess {
/// "RW": readable and writable.
ReadWrite,
/// "RDONLY": readable only, writes must be rejected.
ReadOnly,
/// "NOACCESS": cannot be accessed, reads and writes must be rejected.
NoAccess,
}
/// A single Flat VMDK extent
///
/// `twoGbMaxExtentFlat` images concatenate several of these to form the full
/// virtual disk, `monolithicFlat` images have exactly one.
#[derive(Debug)]
pub(crate) struct VmdkExtent {
/// Open, alignment-aware handle to this extent's data file. `None` for
/// `NoAccess` extents, which are never opened because they cannot be
/// accessed.
///
/// The handle is wrapped in an [`AlignedFile`]
pub file: Option<AlignedFile>,
/// Access mode declared for this extent in the descriptor.
pub access: ExtentAccess,
/// First virtual-disk offset (in bytes) backed by this extent.
pub virtual_start: u64,
/// Length (in bytes) of the virtual-disk range backed by this extent.
pub length: u64,
/// Starting offset (in bytes) within the backing file for this extent.
/// Non-zero when several extents reference the same file at growing
/// offsets (e.g. a >2GB file split under `twoGbMaxExtentFlat`).
pub file_base_offset: u64,
}
#[derive(Debug)]
pub struct FlatVmdk {
descriptor: Arc<VmdkDescriptor>,
// Open handle to the VMDK descriptor file.
descriptor_file: Arc<File>,
// All opened data extents, in virtual-disk order.
extents: Arc<Vec<VmdkExtent>>,
size: u64,
}
#[repr(C)]
struct OpenHow {
flags: u64,
mode: u64,
resolve: u64,
}
// Splits an untrusted extent `filename` into its `Normal` path components for
// the fallback walk, rejecting any `..`/`.` traversal.
fn extent_components(filename: &str) -> io::Result<Vec<&OsStr>> {
let mut components = Vec::new();
for component in Path::new(filename).components() {
match component {
Component::Normal(name) => components.push(name),
Component::RootDir => {}
_ => {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"VMDK extent filename '{filename}' must not contain '..' or '.' path \
components"
),
));
}
}
}
if components.is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("VMDK extent filename '{filename}' is empty"),
));
}
Ok(components)
}
// Opens a single VMDK data extent for the descriptor whose directory is
// base_path.
//
// The extent name may be relative to the descriptor or an absolute path. The
// only difference between the two is:
// - relative -> colocated with descriptor file
// - absolute -> the filesystem root
// The symlink policy rejects the final component if it is a symlink (O_NOFOLLOW).
//
// Resolution prefers openat2(2). On kernels without it (< 5.6, ENOSYS) or
// where it is blocked (EPERM, e.g. a seccomp filter), it falls back to a
// per-component openat walk.
fn open_extent(
base_path: &str,
filename: &str,
writable: bool,
direct: bool,
) -> io::Result<AlignedFile> {
let anchor = if Path::new(filename).is_absolute() {
"/"
} else {
base_path
};
let dir = OpenOptions::new()
.read(true)
.custom_flags(libc::O_DIRECTORY | libc::O_CLOEXEC)
.open(anchor)?;
match open_extent_openat2(dir.as_raw_fd(), filename, writable, direct) {
Ok(file) => Ok(AlignedFile::new(file, direct)),
Err(e) if matches!(e.raw_os_error(), Some(libc::ENOSYS) | Some(libc::EPERM)) => {
let components = extent_components(filename)?;
open_extent_walk(dir, &components, writable, direct)
}
Err(e) => Err(e),
}
}
fn open_extent_openat2(
dir_fd: RawFd,
filename: &str,
writable: bool,
direct: bool,
) -> io::Result<File> {
let cname = CString::new(filename).map_err(|_| {
io::Error::new(
io::ErrorKind::InvalidData,
"VMDK extent filename contains an interior NUL byte",
)
})?;
let access = if writable {
libc::O_RDWR
} else {
libc::O_RDONLY
};
let mut flags = access | libc::O_CLOEXEC | libc::O_NOFOLLOW;
if direct {
flags |= libc::O_DIRECT;
}
let how = OpenHow {
flags: flags as u64,
mode: 0,
resolve: 0,
};
// SAFETY: FFI syscall. `cname` is NUL-terminated and outlives the call,
// `how` is a correctly sized `open_how` passed by pointer, and `dir_fd` is a
// valid directory fd.
let ret = unsafe {
libc::syscall(
libc::SYS_openat2,
dir_fd,
cname.as_ptr(),
&how as *const OpenHow,
size_of::<OpenHow>(),
)
};
if ret < 0 {
return Err(io::Error::last_os_error());
}
// SAFETY: `openat2` returned a fresh descriptor we now own exclusively.
Ok(unsafe { File::from_raw_fd(ret as RawFd) })
}
fn open_extent_walk(
mut dir: File,
components: &[&OsStr],
writable: bool,
direct: bool,
) -> io::Result<AlignedFile> {
let last = components.len() - 1;
for (i, name) in components.iter().enumerate() {
let cname = CString::new(name.as_bytes()).map_err(|_| {
io::Error::new(
io::ErrorKind::InvalidData,
"VMDK extent filename contains an interior NUL byte",
)
})?;
let flags = if i < last {
libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC
} else {
// Final component: the extent file, opened with the declared access
// and cache mode, and O_NOFOLLOW so it may not be a symlink either.
let access = if writable {
libc::O_RDWR
} else {
libc::O_RDONLY
};
let mut flags = access | libc::O_NOFOLLOW | libc::O_CLOEXEC;
if direct {
flags |= libc::O_DIRECT;
}
flags
};
// SAFETY: `dir` is a valid open directory fd and `cname` is a
// NUL-terminated C string that outlives the call.
let fd = unsafe { libc::openat(dir.as_raw_fd(), cname.as_ptr(), flags) };
if fd < 0 {
return Err(io::Error::last_os_error());
}
// SAFETY: `fd` is a freshly opened descriptor we now own exclusively.
let opened = unsafe { File::from_raw_fd(fd) };
if i < last {
// Reassignment drops the previous directory `File`, closing that fd.
dir = opened;
} else {
return Ok(AlignedFile::new(opened, direct));
}
}
unreachable!("extent_components guarantees at least one component")
}
// Builds the error returned when a sector count/offset from the (untrusted)
// descriptor, scaled to bytes, does not fit in a u64.
fn overflow_error(what: &str) -> io::Error {
io::Error::new(
io::ErrorKind::InvalidData,
format!("VMDK {what} overflows a 64-bit byte count"),
)
}
impl FlatVmdk {
/// Opens a flat VMDK image from its already-open descriptor file.
pub fn new(file: File, path: &Path, direct: bool) -> io::Result<Self> {
let descriptor = VmdkDescriptor::new(&file, path)?;
if descriptor.extents_list.extents.is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"VMDK descriptor lists no extents",
));
}
// Open every data extent and record the virtual-disk byte range it
// backs.
let mut extents: Vec<VmdkExtent> =
Vec::with_capacity(descriptor.extents_list.extents.len());
let mut virtual_start: u64 = 0;
for extent in &descriptor.extents_list.extents {
// A flat extent is a fixed, pre-allocated region, a zero-sector
// extent would back an empty virtual range that can never be read
// or written
if extent.size_in_sectors == 0 {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"VMDK flat extent has zero size",
));
}
let length = extent
.size_in_sectors
.checked_mul(VMDK_SECTOR_SIZE)
.ok_or_else(|| overflow_error("extent size"))?;
let file_base_offset = extent
.offset_in_sectors
.checked_mul(VMDK_SECTOR_SIZE)
.ok_or_else(|| overflow_error("extent file offset"))?;
file_base_offset
.checked_add(length)
.ok_or_else(|| overflow_error("extent file range"))?;
// Open the backing file using exactly the access declared for this
// extent. The VMDK spec defines three values:
// "RW" -> read + write
// "RDONLY" -> read only
// "NOACCESS" -> not accessible, do not open the file at all
let (access, extent_file) = match extent.access.as_str() {
"RW" => {
let f = open_extent(&descriptor.base_path, &extent.filename, true, direct)?;
(ExtentAccess::ReadWrite, Some(f))
}
"RDONLY" => {
let f = open_extent(&descriptor.base_path, &extent.filename, false, direct)?;
(ExtentAccess::ReadOnly, Some(f))
}
"NOACCESS" => (ExtentAccess::NoAccess, None),
other => {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("unsupported VMDK extent access mode '{other}'"),
));
}
};
extents.push(VmdkExtent {
file: extent_file,
access,
virtual_start,
length,
file_base_offset,
});
virtual_start = virtual_start
.checked_add(length)
.ok_or_else(|| overflow_error("total virtual size"))?;
}
// The virtual disk size is the end offset of the last extent.
let total_disk_size = virtual_start;
Ok(Self {
descriptor: Arc::new(descriptor),
descriptor_file: Arc::new(file),
extents: Arc::new(extents),
size: total_disk_size,
})
}
pub fn virtual_block_size(&self) -> u64 {
self.size
}
/// Shared handle to the opened data extents, used to build the I/O worker.
pub fn extents(&self) -> Arc<Vec<VmdkExtent>> {
Arc::clone(&self.extents)
}
/// Host allocation size: the sum of every opened extent file's size.
/// `NoAccess` extents contribute 0 to the total.
pub fn physical_block_size(&self) -> u64 {
self.extents
.iter()
.map(|extent| {
extent
.file
.as_ref()
.and_then(|f| f.metadata().ok())
.map_or(0, |m| m.len())
})
.sum()
}
/// Sector/cluster geometry reported to the guest.
pub fn topology(&self) -> DiskTopology {
self.extents
.iter()
.find_map(|extent| extent.file.as_ref())
.map(|f| {
DiskTopology::probe(f.file()).unwrap_or_else(|_| {
warn!("Unable to get VMDK extent topology. Using default topology");
DiskTopology::default()
})
})
.unwrap_or_default()
}
}
// Expose the descriptor file's fd as the disk's representative fd.
impl AsRawFd for FlatVmdk {
fn as_raw_fd(&self) -> RawFd {
self.descriptor_file.as_raw_fd()
}
}
impl Clone for FlatVmdk {
fn clone(&self) -> Self {
Self {
descriptor: Arc::clone(&self.descriptor),
descriptor_file: Arc::clone(&self.descriptor_file),
extents: Arc::clone(&self.extents),
size: self.size,
}
}
}
#[cfg(test)]
mod tests {
use std::fs;
use super::*;
#[test]
fn extent_components_allows_bare_name() {
// The common flat-VMDK case: a single co-located extent file.
let comps = extent_components("disk-flat.vmdk").unwrap();
assert_eq!(comps, [OsStr::new("disk-flat.vmdk")]);
}
#[test]
fn extent_components_rejects_traversal_and_empty() {
// `..`/`.` traversal and empty names are refused. (An absolute path is
// decomposed into its Normal components, the leading `/` is skipped and
// the caller anchors the walk at the filesystem root.)
extent_components("../../etc/passwd").unwrap_err();
extent_components("sub/../../escape").unwrap_err();
extent_components("extent-1.vmdk/../../").unwrap_err();
extent_components("./s001.vmdk").unwrap_err();
extent_components("").unwrap_err();
}
#[test]
fn extent_components_decomposes_absolute_path() {
// A leading `/` is skipped, the remaining Normal components are walked
// from the filesystem root by the caller.
let comps = extent_components("/var/lib/layer.erofs").unwrap();
assert_eq!(
comps,
[
OsStr::new("var"),
OsStr::new("lib"),
OsStr::new("layer.erofs")
]
);
}
// Opens `path` as a directory anchor fd, mirroring how `open_extent` opens
// its anchor.
fn open_dir(path: &Path) -> File {
OpenOptions::new()
.read(true)
.custom_flags(libc::O_DIRECTORY | libc::O_CLOEXEC)
.open(path)
.unwrap()
}
// Returns false when `openat2(2)` is unavailable.
fn openat2_available(res: &io::Result<File>) -> bool {
!matches!(
res.as_ref().err().and_then(|e| e.raw_os_error()),
Some(libc::ENOSYS) | Some(libc::EPERM)
)
}
// Opens the same anchor + `filename` with BOTH extent-open implementations
// so a single scenario asserts they behave identically:
fn open_both(
base_path: &Path,
filename: &str,
writable: bool,
direct: bool,
) -> (io::Result<File>, io::Result<AlignedFile>) {
let anchor: &Path = if Path::new(filename).is_absolute() {
Path::new("/")
} else {
base_path
};
let openat2_dir = open_dir(anchor);
let openat2_res = open_extent_openat2(openat2_dir.as_raw_fd(), filename, writable, direct);
let walk_dir = open_dir(anchor);
let walk_res = match extent_components(filename) {
Ok(components) => open_extent_walk(walk_dir, &components, writable, direct),
Err(e) => Err(e),
};
(openat2_res, walk_res)
}
// Asserts openat2.
fn check_openat2(res: &io::Result<File>, expect_ok: bool) {
if !openat2_available(res) {
return;
}
assert_eq!(
res.is_ok(),
expect_ok,
"openat2 result did not match expectation (expected ok = {expect_ok})"
);
}
// Asserts the per-component walk result.
fn check_walk(res: &io::Result<AlignedFile>, expect_ok: bool) {
assert_eq!(
res.is_ok(),
expect_ok,
"walk result did not match expectation (expected ok = {expect_ok})"
);
}
#[test]
fn open_extent_opens_regular_file() {
use vmm_sys_util::tempdir::TempDir;
let dir = TempDir::new_with_prefix("/tmp/vmdk-regular-test").unwrap();
let base = dir.as_path();
fs::write(base.join("disk-flat.vmdk"), b"data").unwrap();
let (openat2_res, walk_res) = open_both(base, "disk-flat.vmdk", false, false);
check_openat2(&openat2_res, true);
check_walk(&walk_res, true);
}
#[test]
fn open_extent_opens_file_in_subdirectory() {
use vmm_sys_util::tempdir::TempDir;
// A relative sub-path resolves beneath the descriptor directory.
let dir = TempDir::new_with_prefix("/tmp/vmdk-subdir-test").unwrap();
let base = dir.as_path();
fs::create_dir(base.join("extents")).unwrap();
fs::write(base.join("extents").join("s001.vmdk"), b"data").unwrap();
let (openat2_res, walk_res) = open_both(base, "extents/s001.vmdk", false, false);
check_openat2(&openat2_res, true);
check_walk(&walk_res, true);
}
#[test]
fn open_extent_opens_absolute_path_within_descriptor_dir() {
use vmm_sys_util::tempdir::TempDir;
let dir = TempDir::new_with_prefix("/tmp/vmdk-abs-in-test").unwrap();
let base = dir.as_path();
fs::write(base.join("gpt_meta_head.img"), b"data").unwrap();
let abs = base.join("gpt_meta_head.img");
let (openat2_res, walk_res) = open_both(base, abs.to_str().unwrap(), false, false);
check_openat2(&openat2_res, true);
check_walk(&walk_res, true);
}
#[test]
fn open_extent_opens_absolute_path_outside_descriptor_dir() {
use vmm_sys_util::tempdir::TempDir;
let desc_dir = TempDir::new_with_prefix("/tmp/vmdk-desc-test").unwrap();
let layer_dir = TempDir::new_with_prefix("/tmp/vmdk-layer-test").unwrap();
fs::write(layer_dir.as_path().join("layer.erofs"), b"data").unwrap();
let abs = layer_dir.as_path().join("layer.erofs");
let (openat2_res, walk_res) =
open_both(desc_dir.as_path(), abs.to_str().unwrap(), false, false);
check_openat2(&openat2_res, true);
check_walk(&walk_res, true);
}
#[test]
fn open_extent_rejects_symlinked_final_component_relative() {
use std::os::unix::fs::symlink;
use vmm_sys_util::tempdir::TempDir;
// A bare-named extent that is actually a symlink to a file the guest
// must never reach. Both implementations refuse it via O_NOFOLLOW.
let dir = TempDir::new_with_prefix("/tmp/vmdk-symlink-test").unwrap();
let base = dir.as_path();
let target = base.join("target-secret");
fs::write(&target, b"secret").unwrap();
symlink(&target, base.join("disk-flat.vmdk")).unwrap();
let (openat2_res, walk_res) = open_both(base, "disk-flat.vmdk", true, false);
check_openat2(&openat2_res, false);
check_walk(&walk_res, false);
}
#[test]
fn open_extent_rejects_symlinked_final_component_absolute() {
use std::os::unix::fs::symlink;
use vmm_sys_util::tempdir::TempDir;
// Even for absolute paths, the extent file itself may not be a symlink.
let dir = TempDir::new_with_prefix("/tmp/vmdk-abs-finalsym-test").unwrap();
let base = dir.as_path();
let target = base.join("target-secret");
fs::write(&target, b"secret").unwrap();
let link = base.join("extent-link.vmdk");
symlink(&target, &link).unwrap();
let (openat2_res, walk_res) = open_both(base, link.to_str().unwrap(), true, false);
check_openat2(&openat2_res, false);
check_walk(&walk_res, false);
}
#[test]
fn open_extent_follows_symlinked_intermediate_directory_relative() {
use std::os::unix::fs::symlink;
use vmm_sys_util::tempdir::TempDir;
// A relative path may traverse a symlinked intermediate directory
// (only the final component is guarded).
let real = TempDir::new_with_prefix("/tmp/vmdk-rel-real-test").unwrap();
fs::write(real.as_path().join("s001.vmdk"), b"data").unwrap();
let dir = TempDir::new_with_prefix("/tmp/vmdk-rel-symdir-test").unwrap();
let base = dir.as_path();
symlink(real.as_path(), base.join("sub")).unwrap();
let (openat2_res, walk_res) = open_both(base, "sub/s001.vmdk", false, false);
check_openat2(&openat2_res, true);
check_walk(&walk_res, true);
}
#[test]
fn open_extent_follows_symlinked_intermediate_directory_absolute() {
use std::os::unix::fs::symlink;
use vmm_sys_util::tempdir::TempDir;
// An absolute path may likewise traverse a symlinked intermediate
// directory (common in container deployments).
let real = TempDir::new_with_prefix("/tmp/vmdk-abs-realdir-test").unwrap();
fs::write(real.as_path().join("layer.erofs"), b"data").unwrap();
let dir = TempDir::new_with_prefix("/tmp/vmdk-abs-linkdir-test").unwrap();
let base = dir.as_path();
symlink(real.as_path(), base.join("link")).unwrap();
let via_symlink = base.join("link").join("layer.erofs");
let (openat2_res, walk_res) = open_both(base, via_symlink.to_str().unwrap(), false, false);
check_openat2(&openat2_res, true);
check_walk(&walk_res, true);
}
}

View File

@@ -8,5 +8,6 @@
//! synchronous, extent-aware I/O.
mod descriptor;
mod flat;
pub use descriptor::{has_descriptor_header, is_flat_vmdk};