From 0769215d4236f38a27bb10d7f60d28e9cb3c15fb Mon Sep 17 00:00:00 2001 From: Sumedh Alok Sharma Date: Mon, 13 Jul 2026 09:05:58 +0000 Subject: [PATCH] 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 --- block/src/formats/vmdk/descriptor.rs | 250 ++++++----- block/src/formats/vmdk/flat.rs | 637 +++++++++++++++++++++++++++ block/src/formats/vmdk/mod.rs | 1 + vmm/src/seccomp_filters.rs | 1 + 4 files changed, 773 insertions(+), 116 deletions(-) create mode 100644 block/src/formats/vmdk/flat.rs diff --git a/block/src/formats/vmdk/descriptor.rs b/block/src/formats/vmdk/descriptor.rs index 678d679a5..1eac9e531 100644 --- a/block/src/formats/vmdk/descriptor.rs +++ b/block/src/formats/vmdk/descriptor.rs @@ -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: /// ` "" [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, } -/// 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, +pub struct VmdkDescriptor { + pub base_path: String, + pub extents_list: VmdkDescriptorExtents, +} + +impl VmdkDescriptor { + pub fn new(file: &File, path: &Path) -> io::Result { + // 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 { 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::().map_err(|_| { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() == 4 || parts.len() == 5 { + let size_in_sectors = parts[1].parse::().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::().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::().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 { _ => 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 { 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: 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\"") - ); } } diff --git a/block/src/formats/vmdk/flat.rs b/block/src/formats/vmdk/flat.rs new file mode 100644 index 000000000..ab5062b45 --- /dev/null +++ b/block/src/formats/vmdk/flat.rs @@ -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, + /// 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, + // Open handle to the VMDK descriptor file. + descriptor_file: Arc, + // All opened data extents, in virtual-disk order. + extents: Arc>, + 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> { + 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 { + 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 { + 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::(), + ) + }; + 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 { + 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 { + 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 = + 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> { + 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) -> 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, io::Result) { + 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, 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, 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); + } +} diff --git a/block/src/formats/vmdk/mod.rs b/block/src/formats/vmdk/mod.rs index 90493daee..ae6efcf1b 100644 --- a/block/src/formats/vmdk/mod.rs +++ b/block/src/formats/vmdk/mod.rs @@ -8,5 +8,6 @@ //! synchronous, extent-aware I/O. mod descriptor; +mod flat; pub use descriptor::{has_descriptor_header, is_flat_vmdk}; diff --git a/vmm/src/seccomp_filters.rs b/vmm/src/seccomp_filters.rs index b1b18c768..fcbcebffe 100644 --- a/vmm/src/seccomp_filters.rs +++ b/vmm/src/seccomp_filters.rs @@ -722,6 +722,7 @@ fn vmm_thread_rules( #[cfg(target_arch = "x86_64")] (libc::SYS_open, vec![]), (libc::SYS_openat, vec![]), + (libc::SYS_openat2, vec![]), (libc::SYS_pipe2, vec![]), #[cfg(target_arch = "x86_64")] (libc::SYS_poll, vec![]),