diff --git a/vm/devices/support/fs/fuse/src/lib.rs b/vm/devices/support/fs/fuse/src/lib.rs index 16bd97807d..72fb3e2150 100644 --- a/vm/devices/support/fs/fuse/src/lib.rs +++ b/vm/devices/support/fs/fuse/src/lib.rs @@ -21,6 +21,7 @@ pub use reply::ReplySender; pub use request::FuseOperation; pub use request::Request; pub use request::RequestReader; +pub use request::check_name; pub use session::Session; pub use session::SessionInfo; @@ -29,6 +30,7 @@ use lx::LxString; use protocol::*; use std::time::Duration; use zerocopy::FromBytes; +use zerocopy::FromZeros; use zerocopy::Immutable; use zerocopy::IntoBytes; use zerocopy::KnownLayout; @@ -436,6 +438,13 @@ impl fuse_entry_out { attr, } } + + pub fn new_dot(ino: u64, mode: u32) -> Self { + let mut entry = Self::new_zeroed(); + entry.attr.ino = ino; + entry.attr.mode = mode; + entry + } } impl fuse_attr_out { diff --git a/vm/devices/support/fs/fuse/src/request.rs b/vm/devices/support/fs/fuse/src/request.rs index eca2d4df72..7e8b40d498 100644 --- a/vm/devices/support/fs/fuse/src/request.rs +++ b/vm/devices/support/fs/fuse/src/request.rs @@ -186,6 +186,23 @@ impl Request { } } +const NAME_MAX: usize = 255; + +pub fn check_name(name: &[u8]) -> lx::Result<()> { + if name.is_empty() + || name == b"." + || name == b".." + || name.contains(&b'/') + || name.contains(&b'\0') + { + return Err(lx::Error::EINVAL); + } + if name.len() > NAME_MAX { + return Err(lx::Error::ENAMETOOLONG); + } + Ok(()) +} + /// Helpers to parse FUSE messages. pub trait RequestReader: io::Read { /// Read until a matching byte is found. @@ -224,19 +241,10 @@ pub trait RequestReader: io::Read { Ok(lx::LxString::from_vec(buffer)) } - /// Maximum length of a file name component (NAME_MAX on Linux). - const NAME_MAX: usize = 255; - /// Read a NULL-terminated string and ensure it's a valid path name component. fn name(&mut self) -> lx::Result { let name = self.string()?; - if name.is_empty() || name == "." || name == ".." || name.as_bytes().contains(&b'/') { - return Err(lx::Error::EINVAL); - } - if name.len() > Self::NAME_MAX { - return Err(lx::Error::ENAMETOOLONG); - } - + check_name(name.as_bytes())?; Ok(name) } } diff --git a/vm/devices/support/fs/lxutil/src/windows/fs.rs b/vm/devices/support/fs/lxutil/src/windows/fs.rs index 5f8cd7c6fe..45d29cc79f 100644 --- a/vm/devices/support/fs/lxutil/src/windows/fs.rs +++ b/vm/devices/support/fs/lxutil/src/windows/fs.rs @@ -295,13 +295,24 @@ pub fn analyze_delete_error(error: lx::Error, file_handle: &OwnedHandle) -> Dele { DeleteErrorAction::ReturnError(error) } else if error.value() == lx::EIO { - // EIO can come from STATUS_CANNOT_DELETE, which for directories means - // the directory is not empty. Check if this is a directory and return - // ENOTEMPTY in that case. - if is_directory(file_handle) { - DeleteErrorAction::ReturnError(lx::Error::ENOTEMPTY) - } else { - DeleteErrorAction::ReturnError(error) + // EIO can come from STATUS_CANNOT_DELETE. Inspect the file's attributes + // to decide how to proceed: + // - a non-empty directory surfaces as STATUS_CANNOT_DELETE, so map it to + // ENOTEMPTY; + // - a read-only file cannot be deleted via the non-POSIX + // FileDispositionInformation path, so clear the read-only attribute and + // retry; + // - anything else (including a genuine I/O failure, or a handle without + // FILE_READ_ATTRIBUTES) is returned as-is so the read-only attribute is + // not disturbed. + match util::query_information_file::(file_handle) { + Ok(info) if info.FileAttributes & W32Fs::FILE_ATTRIBUTE_DIRECTORY.0 != 0 => { + DeleteErrorAction::ReturnError(lx::Error::ENOTEMPTY) + } + Ok(info) if info.FileAttributes & W32Fs::FILE_ATTRIBUTE_READONLY.0 != 0 => { + DeleteErrorAction::TryReadOnlyWorkaround + } + _ => DeleteErrorAction::ReturnError(error), } } else { DeleteErrorAction::TryReadOnlyWorkaround @@ -322,17 +333,6 @@ pub fn delete_file(fs_context: &FsContext, file_handle: &OwnedHandle) -> lx::Res } } -/// Check if a file handle refers to a directory. -fn is_directory(file_handle: &OwnedHandle) -> bool { - if let Ok(info) = - util::query_information_file::(file_handle) - { - info.FileAttributes & W32Fs::FILE_ATTRIBUTE_DIRECTORY.0 != 0 - } else { - false - } -} - pub fn delete_file_core(fs_context: &FsContext, file_handle: &OwnedHandle) -> lx::Result<()> { if fs_context .compatibility_flags diff --git a/vm/devices/support/fs/lxutil/src/windows/mod.rs b/vm/devices/support/fs/lxutil/src/windows/mod.rs index 4231380ab8..082d32d40c 100644 --- a/vm/devices/support/fs/lxutil/src/windows/mod.rs +++ b/vm/devices/support/fs/lxutil/src/windows/mod.rs @@ -405,6 +405,45 @@ impl LxVolume { Default::default(), )?; + // Emulate Linux rename semantics that NTFS enforces natively but FAT/exFAT does not. + if !self + .state + .fs_context + .compatibility_flags + .supports_posix_unlink_rename() + { + let is_dir = |h: &OwnedHandle| -> lx::Result { + Ok( + util::query_information_file::(h)? + .FileAttributes + & W32Fs::FILE_ATTRIBUTE_DIRECTORY.0 + != 0, + ) + }; + let source_is_dir = is_dir(&handle)?; + + // Reject renaming a directory into its own descendant (EINVAL). + if source_is_dir && new_path != path && new_path.starts_with(path) { + return Err(lx::Error::EINVAL); + } + + // Reject cross-type replacement: file-over-directory (ENOTDIR) or directory-over-file (EISDIR). + match self.open_file(new_path, W32Fs::FILE_READ_ATTRIBUTES, Default::default()) { + Ok(target_handle) => { + let target_is_dir = is_dir(&target_handle)?; + if source_is_dir && !target_is_dir { + return Err(lx::Error::ENOTDIR); + } + if !source_is_dir && target_is_dir { + return Err(lx::Error::EISDIR); + } + } + // A missing target is fine; the rename creates it. + Err(err) if err.value() == lx::ENOENT => {} + Err(err) => return Err(err), + } + } + let flags = fs::RenameFlags::default(); let error = match fs::rename(&handle, &self.root, new_path, &self.state.fs_context, flags) { Ok(_) => return Ok(()), @@ -425,7 +464,11 @@ impl LxVolume { .compatibility_flags .supports_posix_unlink_rename() { - match self.open_file(new_path, W32Fs::DELETE, Default::default()) { + match self.open_file( + new_path, + W32Fs::FILE_READ_ATTRIBUTES | W32Fs::DELETE, + Default::default(), + ) { Ok(target_handle) => self.delete_file(&target_handle)?, Err(err) => { // ENOENT means the rename can proceed. @@ -700,6 +743,16 @@ impl LxVolume { assert!(path.is_relative() && !path.as_os_str().is_empty()); self.check_sandbox_enforcement(path)?; + // Handle filesystems that do not support reparse points. + if !self + .state + .fs_context + .compatibility_flags + .supports_reparse_points() + { + return Err(lx::Error::EPERM); + } + // Convert the target to its native Windows format. let win_target = Path::from_lx(target); diff --git a/vm/devices/support/fs/lxutil/src/windows/util.rs b/vm/devices/support/fs/lxutil/src/windows/util.rs index 187012ac24..b94f0db71b 100644 --- a/vm/devices/support/fs/lxutil/src/windows/util.rs +++ b/vm/devices/support/fs/lxutil/src/windows/util.rs @@ -155,29 +155,32 @@ fn get_token_for_access_check() -> lx::Result { let mut client_token_raw = Foundation::HANDLE::default(); let mut duplicate_token_raw = Foundation::HANDLE::default(); // SAFETY: Calling Win32 API as documented. - let result = unsafe { - check_status(FileSystem::NtOpenThreadToken( + // + // N.B. The raw NTSTATUS is inspected directly (rather than going through + // `check_status`) so that `STATUS_NO_TOKEN` can be distinguished from + // other failures. `check_status` maps the NTSTATUS to an `lx::Error` + // errno, which cannot be compared against an NTSTATUS constant. + let status = unsafe { + FileSystem::NtOpenThreadToken( Threading::GetCurrentThread(), Security::TOKEN_QUERY.0 | Security::TOKEN_DUPLICATE.0, true, &mut client_token_raw, - )) + ) }; - if let Err(e) = result { - // Use the process token if there's no token. - if e.value() == Foundation::STATUS_NO_TOKEN.0 { - // SAFETY: Calling Win32 API as documented. - unsafe { - let _ = check_status(FileSystem::NtOpenProcessToken( - Threading::GetCurrentProcess(), - Security::TOKEN_QUERY.0 | Security::TOKEN_DUPLICATE.0, - &mut client_token_raw, - ))?; - } - } else { - return Err(e); + if status == Foundation::STATUS_NO_TOKEN { + // The thread is not impersonating, so use the process token instead. + // SAFETY: Calling Win32 API as documented. + unsafe { + let _ = check_status(FileSystem::NtOpenProcessToken( + Threading::GetCurrentProcess(), + Security::TOKEN_QUERY.0 | Security::TOKEN_DUPLICATE.0, + &mut client_token_raw, + ))?; } + } else { + let _ = check_status(status)?; } // Create the RAII handle now so it gets closed on drop if we need @@ -213,7 +216,10 @@ fn get_token_for_access_check() -> lx::Result { }, EffectiveOnly: false, }; - let mut object_attributes = Wdk::Foundation::OBJECT_ATTRIBUTES::default(); + let mut object_attributes = Wdk::Foundation::OBJECT_ATTRIBUTES { + Length: size_of::() as u32, + ..Default::default() + }; object_attributes.SecurityQualityOfService = ptr::from_ref::(&security_qos).cast(); diff --git a/vm/devices/virtio/virtio_resources/src/lib.rs b/vm/devices/virtio/virtio_resources/src/lib.rs index e6338c1662..29609a74e0 100644 --- a/vm/devices/virtio/virtio_resources/src/lib.rs +++ b/vm/devices/virtio/virtio_resources/src/lib.rs @@ -60,6 +60,23 @@ pub mod fs { SectionFs { root_path: String, }, + /// Expose multiple host folders behind a single device, each as a named + /// child of a synthetic root. Lets one virtio-fs device (one tag, one + /// PCI/MMIO footprint) serve many shares. + Aggregate { + children: Vec, + }, + } + + /// A single host folder exposed as a named child of a [`VirtioFsBackend::Aggregate`]. + #[derive(MeshPayload)] + pub struct VirtioFsAggregateChild { + /// Name of this child's directory under the aggregate's synthetic root; + /// the guest bind-mounts `/` onto the user's + /// target path. + pub name: String, + pub root_path: String, + pub mount_options: String, } impl ResourceId for VirtioFsHandle { diff --git a/vm/devices/virtio/virtiofs/src/aggregate.rs b/vm/devices/virtio/virtiofs/src/aggregate.rs new file mode 100644 index 0000000000..79a5fadbac --- /dev/null +++ b/vm/devices/virtio/virtiofs/src/aggregate.rs @@ -0,0 +1,347 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Aggregate (multi-root) virtio-fs. +//! +//! An aggregate device exposes a synthetic, read-only root directory whose +//! named children are independent host folders sharing a single FUSE +//! superblock. Each child's inode numbers are namespaced per volume (see +//! [`namespace_ino`](crate::inode::namespace_ino)) to avoid cross-volume +//! `st_ino` collisions. This module owns all of the aggregate-only state and +//! the [`VirtioFs`] methods that operate on it; the core (direct-mode) file +//! system lives in the crate root. + +use crate::ATTRIBUTE_TIMEOUT; +use crate::ENTRY_TIMEOUT; +use crate::VirtioFs; +use crate::build_volume; +use crate::inode::VirtioFsInode; +use crate::inode::VirtioFsVolume; +use fuse::DirEntryWriter; +use fuse::check_name; +use fuse::protocol::*; +use lxutil::LxVolumeOptions; +use parking_lot::RwLock; +use std::path::Path; +use std::path::PathBuf; +use std::sync::Arc; +use zerocopy::FromZeros; + +/// Reserved file handle `open_dir` returns for the synthetic aggregate root; +/// `read_dir`/`read_dir_plus`/`release_dir` recognize it and serve the root +/// registry instead of the handle map. `u64::MAX` can't collide with a real +/// handle, since `HandleMap` allocates from 1 upward. +pub(crate) const SYNTHETIC_ROOT_FH: u64 = u64::MAX; + +/// A single host folder exposed as a named child of the synthetic aggregate root. +struct ChildEntry { + /// Name of this child's directory under the synthetic root. Chosen by the + /// caller; the guest bind-mounts `/` onto the user's + /// target path. + name: String, + volume: Arc, +} + +/// Registry of aggregated children for an aggregate-mode [`VirtioFs`]. +struct AggregateRegistry { + entries: Vec, + next_volume_id: u32, + tearing_down: bool, +} + +impl AggregateRegistry { + fn new() -> Self { + // Volume id 0 is reserved for a direct-mode single root, so aggregated + // children start at 1. + Self { + entries: Vec::new(), + next_volume_id: 1, + tearing_down: false, + } + } + + fn check_can_add(&self) -> lx::Result<()> { + if self.tearing_down { + return Err(lx::Error::EAGAIN); + } + Ok(()) + } +} + +/// State that only exists for an aggregate-mode [`VirtioFs`]. +/// +/// When present, node 1 is a synthetic directory whose children are the entries +/// in `registry`. +pub(crate) struct AggregateState { + /// Aggregated children and their lifecycle state. + registry: RwLock, +} + +impl AggregateState { + pub(crate) fn new() -> Self { + Self { + registry: RwLock::new(AggregateRegistry::new()), + } + } +} + +/// Aggregate-mode operations on [`VirtioFs`]. The crate-root `Fuse` +/// implementation dispatches the synthetic-root cases to the `pub(crate)` +/// helpers here. +impl VirtioFs { + /// Expose a host folder as a named child of the synthetic root. + /// + /// Each child carries its own read-only setting (from `mount_options`), so + /// shares under one aggregate device may differ. + /// + /// Only valid in aggregate mode. Returns: + /// - `EINVAL` on a direct-mode file system, or if `name` is empty, + /// reserved (`.`/`..`), or contains `/` or `\0`. + /// - `EAGAIN` if the device has begun tearing down (see + /// [`Self::begin_teardown`]). + /// - `EEXIST` if a child with the same name already exists. + /// - `ENOSPC` if the volume-id space is exhausted (2^32 children). + pub fn add_child( + &self, + name: &str, + root_path: impl AsRef, + mount_options: Option<&LxVolumeOptions>, + ) -> lx::Result<()> { + let Some(aggregate) = self.inner.aggregate() else { + return Err(lx::Error::EINVAL); + }; + + check_name(name.as_bytes())?; + + // Fast-fail before paying for volume construction if the device is + // already tearing down. Re-checked under the lock below to close the + // race with a concurrent `begin_teardown`. + { + aggregate.registry.read().check_can_add()?; + } + + let (volume, readonly) = build_volume(root_path, mount_options)?; + + let mut registry = aggregate.registry.write(); + registry.check_can_add()?; + if registry.entries.iter().any(|e| e.name == name) { + return Err(lx::Error::EEXIST); + } + + let volume_id = registry.next_volume_id; + registry.next_volume_id = volume_id.checked_add(1).ok_or(lx::Error::ENOSPC)?; + registry.entries.push(ChildEntry { + name: name.to_string(), + volume: Arc::new(VirtioFsVolume::new(volume, volume_id, readonly)), + }); + tracing::info!( + name, + volume_id, + child_count = registry.entries.len(), + "added aggregate virtio-fs child" + ); + Ok(()) + } + + /// Signal that the aggregate device has begun tearing down, so + /// [`Self::add_child`] rejects further children with `EAGAIN`. Existing + /// inodes keep being served until the device is dropped. No-op in direct + /// mode. + pub fn begin_teardown(&self) { + if let Some(aggregate) = self.inner.aggregate() { + aggregate.registry.write().tearing_down = true; + } + } + + /// Remove a previously added child by name. + /// + /// In-flight inodes beneath the child remain valid until the guest forgets + /// them (each holds its own volume reference); the name simply stops + /// appearing in the synthetic root. Returns `ENOENT` if no such child exists. + pub fn remove_child(&self, name: &str) -> lx::Result<()> { + let Some(aggregate) = self.inner.aggregate() else { + return Err(lx::Error::EINVAL); + }; + + let mut children = aggregate.registry.write(); + let before = children.entries.len(); + children.entries.retain(|e| e.name != name); + if children.entries.len() == before { + Err(lx::Error::ENOENT) + } else { + Ok(()) + } + } + + /// Returns true if `node_id` refers to the synthetic aggregate root. + pub(crate) fn is_synthetic_root(&self, node_id: u64) -> bool { + self.inner.aggregate().is_some() && node_id == FUSE_ROOT_ID + } + + pub(crate) fn is_synthetic_root_handle(&self, node_id: u64, fh: u64) -> bool { + self.is_synthetic_root(node_id) && fh == SYNTHETIC_ROOT_FH + } + + /// Attributes of the synthetic aggregate root directory. + pub(crate) fn synthetic_root_attr(&self) -> fuse_attr { + let mut attr = fuse_attr::new_zeroed(); + attr.ino = FUSE_ROOT_ID; + attr.mode = lx::S_IFDIR | 0o555; + attr.nlink = self.synthetic_root_nlink(); + attr.blksize = 512; + attr + } + + /// Extended attributes of the synthetic aggregate root directory. + pub(crate) fn synthetic_root_statx(&self, mask: lx::StatExMask) -> fuse_statx { + let mut sx = fuse_statx::new_zeroed(); + let returned_mask = lx::StatExMask::new() + .with_file_type(true) + .with_mode(true) + .with_nlink(true) + .with_ino(true) + .into_bits(); + sx.mask = mask.into_bits() & returned_mask; + sx.mode = (lx::S_IFDIR | 0o555) as u16; + sx.nlink = self.synthetic_root_nlink(); + sx.ino = FUSE_ROOT_ID; + sx.blksize = 512; + sx + } + + fn synthetic_root_nlink(&self) -> u32 { + let child_count = self + .inner + .aggregate() + .map_or(0, |aggregate| aggregate.registry.read().entries.len()); + u32::try_from(child_count) + .unwrap_or(u32::MAX) + .saturating_add(2) + } + + /// Looks up a named child of the synthetic root, returning an entry for the + /// corresponding volume's real root inode. + pub(crate) fn lookup_synthetic_root(&self, name: &lx::LxStr) -> lx::Result { + let Some(aggregate) = self.inner.aggregate() else { + return Err(lx::Error::ENOENT); + }; + let name_bytes = name.as_bytes(); + let volume = { + let children = aggregate.registry.read(); + let entry = children + .entries + .iter() + .find(|e| e.name.as_bytes() == name_bytes) + .ok_or(lx::Error::ENOENT)?; + Arc::clone(&entry.volume) + }; + + self.insert_child_root_entry(volume) + } + + fn insert_child_root_entry(&self, volume: Arc) -> lx::Result { + let (inode, stat) = VirtioFsInode::new(volume, PathBuf::new())?; + let attr = inode.attr_from_stat(&stat); + let (_, node_id) = self.insert_inode(inode); + Ok(fuse_entry_out::new( + node_id, + ENTRY_TIMEOUT, + ATTRIBUTE_TIMEOUT, + attr, + )) + } + + /// Reads the synthetic root directory, listing `.`, `..`, and each child. + pub(crate) fn read_synthetic_root_dir( + &self, + offset: u64, + size: u32, + plus: bool, + ) -> lx::Result> { + let Some(aggregate) = self.inner.aggregate() else { + return Ok(Vec::new()); + }; + let mut buffer = Vec::with_capacity(size as usize); + // `offset` is the cookie of the next entry to emit (0 at start of stream). + // Entry 0 => ".", 1 => "..", 2.. => children[index - 2]. + let mut index = offset; + while let Some(next) = index.checked_add(1) { + let fit = match index { + 0 => self.write_synthetic_dot(&mut buffer, ".", next, plus), + 1 => self.write_synthetic_dot(&mut buffer, "..", next, plus), + n => { + let child = { + let children = aggregate.registry.read(); + children + .entries + .get((n - 2) as usize) + .map(|e| (e.name.clone(), Arc::clone(&e.volume))) + }; + let Some((name, volume)) = child else { + break; + }; + self.write_child_entry(&mut buffer, &name, volume, next, plus)? + } + }; + if !fit { + break; + } + index = next; + } + Ok(buffer) + } + + /// Writes a synthetic `.`/`..` entry. These never carry a real node ID, so + /// the kernel will not issue a forget for them. + fn write_synthetic_dot( + &self, + buffer: &mut Vec, + name: &str, + next_off: u64, + plus: bool, + ) -> bool { + if plus { + if !buffer.check_dir_entry_plus(name) { + return false; + } + let entry = fuse_entry_out::new_dot(FUSE_ROOT_ID, lx::S_IFDIR | 0o555); + buffer.dir_entry_plus(name, next_off, entry) + } else { + buffer.dir_entry(name, FUSE_ROOT_ID, next_off, lx::DT_DIR as u32) + } + } + + /// Writes a directory entry for an aggregated child. + fn write_child_entry( + &self, + buffer: &mut Vec, + name: &str, + volume: Arc, + next_off: u64, + plus: bool, + ) -> lx::Result { + if plus { + if !buffer.check_dir_entry_plus(name) { + return Ok(false); + } + // readdirplus performs a lookup on each entry, incrementing its + // lookup count, so create/insert the root inode here. + let entry = self.insert_child_root_entry(volume)?; + Ok(buffer.dir_entry_plus(name, next_off, entry)) + } else { + // Plain readdir: report the directory using the volume root's + // guest-visible inode number. If the root cannot be queried, use + // the volume id as a stable surrogate. + let raw = volume + .lstat(PathBuf::new()) + .map(|s| s.inode_nr) + .unwrap_or(volume.id() as lx::ino_t); + let ino = volume.map_inode(raw); + Ok(buffer.dir_entry(name, ino, next_off, lx::DT_DIR as u32)) + } + } +} + +#[cfg(test)] +#[path = "aggregate_tests.rs"] +mod tests; diff --git a/vm/devices/virtio/virtiofs/src/aggregate_tests.rs b/vm/devices/virtio/virtiofs/src/aggregate_tests.rs new file mode 100644 index 0000000000..824522517c --- /dev/null +++ b/vm/devices/virtio/virtiofs/src/aggregate_tests.rs @@ -0,0 +1,198 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use super::SYNTHETIC_ROOT_FH; +use crate::VirtioFs; +use crate::inode; +use fuse::protocol::FUSE_ROOT_ID; +use lxutil::LxVolumeOptions; +use std::sync::Arc; +use test_with_tracing::test; + +#[test] +fn aggregate_child_registry() { + let a = tempfile::tempdir().unwrap(); + let b = tempfile::tempdir().unwrap(); + let mut readonly = LxVolumeOptions::default(); + readonly.readonly(true); + let fs = VirtioFs::new_aggregate(); + + assert_eq!(fs.synthetic_root_attr().nlink, 2); + assert_eq!(fs.synthetic_root_statx(lx::StatExMask::new()).nlink, 2); + + fs.add_child("share_a", a.path(), Some(&readonly)).unwrap(); + fs.add_child("share_b", b.path(), None).unwrap(); + + // Duplicate names are rejected. + assert_eq!( + fs.add_child("share_a", a.path(), None).unwrap_err(), + lx::Error::EEXIST + ); + + // Each child gets a distinct, non-zero volume id (0 is reserved for + // direct mode). + { + let aggregate = fs.inner.aggregate().unwrap(); + let children = aggregate.registry.read(); + assert_eq!(children.entries.len(), 2); + assert_ne!(children.entries[0].volume.id(), 0); + assert_ne!( + children.entries[0].volume.id(), + children.entries[1].volume.id() + ); + assert!(children.entries[0].volume.readonly()); + assert!(!children.entries[1].volume.readonly()); + } + assert_eq!(fs.synthetic_root_attr().nlink, 4); + assert_eq!(fs.synthetic_root_statx(lx::StatExMask::new()).nlink, 4); + + // Removal drops only the named child. + fs.remove_child("share_a").unwrap(); + assert_eq!(fs.remove_child("share_a").unwrap_err(), lx::Error::ENOENT); + assert_eq!( + fs.inner.aggregate().unwrap().registry.read().entries.len(), + 1 + ); + assert_eq!(fs.synthetic_root_attr().nlink, 3); + assert_eq!(fs.synthetic_root_statx(lx::StatExMask::new()).nlink, 3); +} + +#[test] +fn aggregate_operations_are_scoped_to_aggregate_mode() { + let aggregate = VirtioFs::new_aggregate(); + assert!(aggregate.is_synthetic_root_handle(FUSE_ROOT_ID, SYNTHETIC_ROOT_FH)); + assert!(!aggregate.is_synthetic_root_handle(FUSE_ROOT_ID + 1, SYNTHETIC_ROOT_FH)); + + let a = tempfile::tempdir().unwrap(); + let fs = VirtioFs::new(a.path(), None).unwrap(); + assert!(!fs.is_synthetic_root_handle(FUSE_ROOT_ID, SYNTHETIC_ROOT_FH)); + assert_eq!( + fs.add_child("x", a.path(), None).unwrap_err(), + lx::Error::EINVAL + ); + assert_eq!(fs.remove_child("x").unwrap_err(), lx::Error::EINVAL); +} + +#[test] +fn add_child_validates_name() { + let root = tempfile::tempdir().unwrap(); + let fs = VirtioFs::new_aggregate(); + + for name in ["", ".", "..", "a/b", "a\0b"] { + assert_eq!( + fs.add_child(name, root.path(), None).unwrap_err(), + lx::Error::EINVAL + ); + } + + fs.add_child(&"a".repeat(255), root.path(), None).unwrap(); + assert_eq!( + fs.add_child(&"b".repeat(256), root.path(), None) + .unwrap_err(), + lx::Error::ENAMETOOLONG + ); +} + +#[test] +fn synthetic_root_node_ids_start_after_root() { + // In aggregate mode the synthetic root occupies FUSE_ROOT_ID, so the + // first real inode inserted must be allocated a higher id. + let a = tempfile::tempdir().unwrap(); + let fs = VirtioFs::new_aggregate(); + fs.add_child("share", a.path(), None).unwrap(); + let entry = fs + .lookup_synthetic_root(lx::LxStr::from_bytes(b"share")) + .unwrap(); + assert!(entry.nodeid > FUSE_ROOT_ID); +} + +#[test] +fn aggregate_children_namespace_inodes() { + // Under the single shared superblock, each aggregated child namespaces its + // inode numbers so that even the largest host inode maps to a value other + // than the identity transform reserved for direct mode (volume id 0). + let root = tempfile::tempdir().unwrap(); + let fs = VirtioFs::new_aggregate(); + fs.add_child("child", root.path(), None).unwrap(); + let volume = { + let children = fs.inner.aggregate().unwrap().registry.read(); + Arc::clone(&children.entries[0].volume) + }; + assert_ne!(volume.map_inode(u64::MAX), u64::MAX); +} + +#[test] +fn inode_namespacing_avoids_cross_volume_collisions() { + // Direct mode (volume id 0) is the identity transform. + assert_eq!(inode::namespace_ino(0, 42), 42); + assert_eq!(inode::namespace_ino(0, u64::MAX), u64::MAX); + + // Namespacing uses the full 64-bit inode space, so even the largest host + // inode numbers map without overflowing or being rejected, and there is no + // limit on the number of volumes. + let _ = inode::namespace_ino(1, u64::MAX); + let _ = inode::namespace_ino(1000, u64::MAX); + + // The transform is a bijection within a volume: distinct host inode numbers + // stay distinct. + assert_ne!(inode::namespace_ino(1, 10), inode::namespace_ino(1, 11)); + + // The same host inode number maps to different values in different volumes, + // reducing cross-volume st_ino collisions under the shared superblock. + assert_ne!(inode::namespace_ino(1, 42), inode::namespace_ino(2, 42)); +} + +#[test] +fn hard_link_rejects_cross_volume_target() { + let first = tempfile::tempdir().unwrap(); + let second = tempfile::tempdir().unwrap(); + std::fs::write(first.path().join("target"), b"data").unwrap(); + + let fs = VirtioFs::new_aggregate(); + fs.add_child("first", first.path(), None).unwrap(); + fs.add_child("second", second.path(), None).unwrap(); + + let first_root = fs + .lookup_synthetic_root(lx::LxStr::from_bytes(b"first")) + .unwrap(); + let second_root = fs + .lookup_synthetic_root(lx::LxStr::from_bytes(b"second")) + .unwrap(); + let target = fs + .lookup_helper( + &fs.get_inode(first_root.nodeid).unwrap(), + lx::LxStr::from_bytes(b"target"), + ) + .unwrap(); + + assert_eq!( + fs.get_inode(second_root.nodeid) + .unwrap() + .link( + lx::LxStr::from_bytes(b"link"), + &fs.get_inode(target.nodeid).unwrap() + ) + .unwrap_err(), + lx::Error::EXDEV + ); + assert!(!second.path().join("link").exists()); +} + +#[test] +fn add_child_rejected_after_teardown() { + let a = tempfile::tempdir().unwrap(); + let fs = VirtioFs::new_aggregate(); + fs.add_child("before", a.path(), None).unwrap(); + + fs.begin_teardown(); + + // Once tearing down, no further children can be added. + assert_eq!( + fs.add_child("after", a.path(), None).unwrap_err(), + lx::Error::EAGAIN + ); + assert_eq!( + fs.inner.aggregate().unwrap().registry.read().entries.len(), + 1 + ); +} diff --git a/vm/devices/virtio/virtiofs/src/file.rs b/vm/devices/virtio/virtiofs/src/file.rs index e8c0a6e43e..93753e6645 100644 --- a/vm/devices/virtio/virtiofs/src/file.rs +++ b/vm/devices/virtio/virtiofs/src/file.rs @@ -11,7 +11,6 @@ use fuse::protocol::fuse_statx; use lxutil::LxFile; use parking_lot::RwLock; use std::sync::Arc; -use zerocopy::FromZeros; /// Implements file callbacks for virtio-fs. pub struct VirtioFsFile { @@ -28,16 +27,21 @@ impl VirtioFsFile { } } + /// The inode backing this open file. + pub fn inode(&self) -> &VirtioFsInode { + &self.inode + } + /// Gets the attributes of the open file. pub fn get_attr(&self) -> lx::Result { let stat = self.file.read().fstat()?.into(); - Ok(util::stat_to_fuse_attr(&stat)) + Ok(self.inode.attr_from_stat(&stat)) } /// Gets the statx details for the open file. pub fn get_statx(&self) -> lx::Result { let statx = self.file.read().fstat()?; - Ok(util::statx_to_fuse_statx(&statx)) + Ok(self.inode.statx_from(&statx)) } /// Sets the attributes of the open file. @@ -74,7 +78,9 @@ impl VirtioFsFile { ) -> lx::Result> { let mut buffer = Vec::with_capacity(size as usize); let mut entry_count: u32 = 0; - let self_inode_nr = self.inode.inode_nr(); + // Report the directory's guest-visible inode number so `.`/`..` agree + // with the number reported by lookup/getattr. + let self_inode_nr = self.inode.guest_inode_nr(); let mut file = self.file.write(); file.read_dir(offset as lx::off_t, |entry| { entry_count += 1; @@ -97,10 +103,7 @@ impl VirtioFsFile { // If readdirplus is being used, do a lookup on all items except the . and .. entries. if plus { let fuse_entry = if entry.name == "." || entry.name == ".." { - let mut e = fuse_entry_out::new_zeroed(); - e.attr.ino = self_inode_nr; - e.attr.mode = (entry.file_type as u32) << 12; - e + fuse_entry_out::new_dot(self_inode_nr, (entry.file_type as u32) << 12) } else { if !buffer.check_dir_entry_plus(&entry.name) { return Ok(false); @@ -130,7 +133,9 @@ impl VirtioFsFile { entry_count -= 1; return Ok(true); } - entry.inode_nr + // Children share this directory's volume, so apply its + // guest inode mapping to match lookup/readdirplus. + self.inode.guest_ino(entry.inode_nr) }; Ok(buffer.dir_entry( diff --git a/vm/devices/virtio/virtiofs/src/inode.rs b/vm/devices/virtio/virtiofs/src/inode.rs index f1e03e8c37..bdb35e3ee8 100644 --- a/vm/devices/virtio/virtiofs/src/inode.rs +++ b/vm/devices/virtio/virtiofs/src/inode.rs @@ -10,34 +10,113 @@ use lxutil::LxCreateOptions; use lxutil::LxVolume; use lxutil::PathBufExt; use parking_lot::RwLock; +use std::ops::Deref; use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::AtomicU64; use std::sync::atomic::Ordering; +/// Multiplier used to spread a volume id across the 64-bit inode space when +/// namespacing inode numbers (see [`namespace_ino`]). It is the 64-bit +/// golden-ratio constant, chosen because it has good bit-mixing properties. +const INO_NAMESPACE_MULTIPLIER: u64 = 0x9E37_79B9_7F4A_7C15; + +/// XORs a per-volume key into a raw host inode number to reduce the likelihood +/// of cross-volume `st_ino` collisions under the shared FUSE superblock. +/// +/// `volume_id == 0` (direct/single-root mode) returns `raw` unchanged. For +/// aggregated volumes the transform is a bijection within each volume +/// (preserving hard-link identity) and uses the full 64-bit inode space, so it +/// never overflows and imposes no limit on the number of volumes. Cross-volume +/// collisions are still possible when two volumes happen to have inode numbers +/// whose XOR equals the difference of their volume keys. +pub(crate) fn namespace_ino(volume_id: u32, raw: lx::ino_t) -> lx::ino_t { + if volume_id == 0 { + return raw; + } + raw ^ u64::from(volume_id).wrapping_mul(INO_NAMESPACE_MULTIPLIER) +} + +pub(crate) struct VirtioFsVolume { + volume: Arc, + id: u32, + readonly: bool, +} + +impl VirtioFsVolume { + pub(crate) fn new(volume: LxVolume, id: u32, readonly: bool) -> Self { + Self { + volume: Arc::new(volume), + id, + readonly, + } + } + + pub(crate) fn id(&self) -> u32 { + self.id + } + + pub(crate) fn readonly(&self) -> bool { + self.readonly + } + + pub(crate) fn map_inode(&self, raw: lx::ino_t) -> lx::ino_t { + namespace_ino(self.id, raw) + } +} + +impl Deref for VirtioFsVolume { + type Target = LxVolume; + + fn deref(&self) -> &Self::Target { + &self.volume + } +} + +/// Key used to deduplicate inodes in the `InodeMap` so that repeated lookups of +/// the same host file return a single, stable FUSE node id. +/// +/// Volumes that report stable inode numbers key by `(volume_id, inode_nr)`. +/// Volumes that recycle inode numbers (FAT/exFAT) cannot trust the inode +/// number as an identity, so they key by `(volume_id, path)` instead — a given +/// path then maps to one node id across lookups even though the host inode +/// number is not reliable. +#[derive(Clone, PartialEq, Eq, Hash)] +pub(crate) enum DedupKey { + /// `(volume_id, host_inode_number)`, for stable-id volumes. + Ino(u32, lx::ino_t), + /// `(volume_id, host_path)`, for volumes that recycle inode numbers. + Path(u32, PathBuf), +} + /// Implements inode callbacks for virtio-fs. pub struct VirtioFsInode { - volume: Arc, + pub(crate) volume: Arc, path: RwLock, lookup_count: AtomicU64, inode_nr: lx::ino_t, + /// This inode's number as reported to the guest: its namespaced inode + /// number under the shared superblock. + guest_inode_nr: lx::ino_t, } impl VirtioFsInode { /// Create a new inode for the specified path. - pub fn new(volume: Arc, path: PathBuf) -> lx::Result<(Self, lx::Stat)> { + pub fn new(volume: Arc, path: PathBuf) -> lx::Result<(Self, lx::Stat)> { let stat = volume.lstat(&path)?; let inode = Self::with_attr(volume, path, &stat); Ok((inode, stat)) } /// Create a new inode for the specified path, with previously retrieved attributes. - pub fn with_attr(volume: Arc, path: PathBuf, stat: &lx::Stat) -> Self { + pub fn with_attr(volume: Arc, path: PathBuf, stat: &lx::Stat) -> Self { + let guest_inode_nr = volume.map_inode(stat.inode_nr); Self { volume, path: RwLock::new(path), lookup_count: AtomicU64::new(1), inode_nr: stat.inode_nr, + guest_inode_nr, } } @@ -48,6 +127,48 @@ impl VirtioFsInode { self.inode_nr } + /// Return the identifier of the aggregated volume this inode belongs to. + pub fn volume_id(&self) -> u32 { + self.volume.id() + } + + /// Whether this inode's volume is read-only. + pub fn readonly(&self) -> bool { + self.volume.readonly() + } + + /// This inode's own number as reported to the guest: its namespaced inode + /// number under the shared superblock. Fixed for the inode's lifetime. + pub(crate) fn guest_inode_nr(&self) -> lx::ino_t { + self.guest_inode_nr + } + + /// Maps a raw host inode number from this inode's volume for guest use. For + /// numbers belonging to *other* inodes in the same volume (e.g. readdir + /// child entries); for this inode's own number use [`Self::guest_inode_nr`]. + pub(crate) fn guest_ino(&self, raw: lx::ino_t) -> lx::ino_t { + self.volume.map_inode(raw) + } + + /// Builds a `fuse_attr` from a stat *of this inode*, reporting its cached + /// guest-visible inode number. + /// + /// `stat` must describe this inode; to report a different inode's + /// attributes (e.g. a hard-link target) call this on that inode. + pub(crate) fn attr_from_stat(&self, stat: &lx::Stat) -> fuse_attr { + let mut attr = util::stat_to_fuse_attr(stat); + attr.ino = self.guest_inode_nr; + attr + } + + /// Builds a `fuse_statx` from a statx *of this inode*, reporting its cached + /// guest-visible inode number. + pub(crate) fn statx_from(&self, statx: &lx::StatEx) -> fuse_statx { + let mut sx = util::statx_to_fuse_statx(statx); + sx.ino = self.guest_inode_nr; + sx + } + /// Increments the lookup count. pub fn lookup(&self, new_path: PathBuf) { self.lookup_count.fetch_add(1, Ordering::AcqRel); @@ -90,20 +211,20 @@ impl VirtioFsInode { pub fn lookup_child(&self, name: &LxStr) -> lx::Result<(VirtioFsInode, fuse_attr)> { let path = self.child_path(name)?; let (inode, stat) = VirtioFsInode::new(Arc::clone(&self.volume), path)?; - let attr = util::stat_to_fuse_attr(&stat); + let attr = inode.attr_from_stat(&stat); Ok((inode, attr)) } /// Retrieves the attributes of this inode. pub fn get_attr(&self) -> lx::Result { let stat = self.volume.lstat(&*self.get_path())?; - Ok(util::stat_to_fuse_attr(&stat)) + Ok(self.attr_from_stat(&stat)) } /// Retrieves the extended attributes of this inode. pub fn get_statx(&self) -> lx::Result { let statx = self.volume.statx(&*self.get_path())?; - Ok(util::statx_to_fuse_statx(&statx)) + Ok(self.statx_from(&statx)) } /// Sets the attributes of this inode. @@ -114,7 +235,7 @@ impl VirtioFsInode { // depending on the attributes being set. Lxutil takes care of that on Windows (and Linux // does it naturally). let stat = self.volume.set_attr_stat(&*self.get_path(), attr)?; - Ok(util::stat_to_fuse_attr(&stat)) + Ok(self.attr_from_stat(&stat)) } /// Opens the inode, creating a file object. @@ -139,7 +260,7 @@ impl VirtioFsInode { let file = self.volume.open(&path, flags, Some(options))?; let stat = file.fstat()?.into(); let inode = Self::with_attr(Arc::clone(&self.volume), path, &stat); - let attr = util::stat_to_fuse_attr(&stat); + let attr = inode.attr_from_stat(&stat); Ok((inode, attr, file)) } @@ -157,7 +278,7 @@ impl VirtioFsInode { .mkdir_stat(&path, LxCreateOptions::new(mode, uid, gid))?; let inode = Self::with_attr(Arc::clone(&self.volume), path, &stat); - let attr = util::stat_to_fuse_attr(&stat); + let attr = inode.attr_from_stat(&stat); Ok((inode, attr)) } @@ -178,7 +299,7 @@ impl VirtioFsInode { )?; let inode = Self::with_attr(Arc::clone(&self.volume), path, &stat); - let attr = util::stat_to_fuse_attr(&stat); + let attr = inode.attr_from_stat(&stat); Ok((inode, attr)) } @@ -198,15 +319,20 @@ impl VirtioFsInode { )?; let inode = Self::with_attr(Arc::clone(&self.volume), path, &stat); - let attr = util::stat_to_fuse_attr(&stat); + let attr = inode.attr_from_stat(&stat); Ok((inode, attr)) } /// Creates a new hard link as a child of this inode. pub fn link(&self, name: &LxStr, target: &VirtioFsInode) -> lx::Result { + if self.volume.id() != target.volume.id() { + return Err(lx::Error::EXDEV); + } let path = self.child_path(name)?; let stat = self.volume.link_stat(&*target.get_path(), path)?; - Ok(util::stat_to_fuse_attr(&stat)) + // The reply describes the shared (target) inode, so namespace via the + // target rather than this directory inode. + Ok(target.attr_from_stat(&stat)) } /// Reads the target of the symbolic link, if this inode is a symbolic link. @@ -274,6 +400,32 @@ impl VirtioFsInode { self.get_path().clone() } + /// The key used to deduplicate this inode in the `InodeMap`, so that + /// repeated lookups of the same host file return one stable FUSE node id. + /// + /// Stable-id volumes key by `(volume_id, inode_nr)`. Volumes that recycle + /// inode numbers (FAT/exFAT) key by `(volume_id, path)` instead, so a given + /// path maps to a single, stable FUSE node id across lookups. This includes + /// the empty path of a volume root, so an aggregate child root keeps one + /// stable node id across repeated lookups rather than allocating a fresh one + /// each time. + pub(crate) fn dedup_key(&self) -> DedupKey { + if self.volume.supports_stable_file_id() { + return DedupKey::Ino(self.volume_id(), self.inode_nr()); + } + DedupKey::Path(self.volume_id(), self.get_path().clone()) + } + + /// The [`DedupKey::Path`] that a child named `name` of this inode would use, + /// for path-keyed (non-stable-id) volumes only. + pub(crate) fn child_path_dedup_key(&self, name: &LxStr) -> Option { + if self.volume.supports_stable_file_id() { + return None; + } + let path = self.child_path(name).ok()?; + Some(DedupKey::Path(self.volume_id(), path)) + } + /// Appends a child name to this inode's path. fn child_path(&self, name: &LxStr) -> lx::Result { // Defense in depth: the FUSE request parser already validates names, diff --git a/vm/devices/virtio/virtiofs/src/lib.rs b/vm/devices/virtio/virtiofs/src/lib.rs index 10f23a142a..0b2337c21a 100644 --- a/vm/devices/virtio/virtiofs/src/lib.rs +++ b/vm/devices/virtio/virtiofs/src/lib.rs @@ -4,6 +4,7 @@ #![expect(missing_docs)] #![cfg(any(windows, target_os = "linux"))] +mod aggregate; mod file; mod inode; #[cfg(test)] @@ -18,10 +19,14 @@ mod virtio_util; #[cfg(windows)] pub use section::SectionFs; +use aggregate::AggregateState; +use aggregate::SYNTHETIC_ROOT_FH; use file::VirtioFsFile; use fuse::protocol::*; use fuse::*; +use inode::DedupKey; use inode::VirtioFsInode; +use inode::VirtioFsVolume; pub use lxutil::LxVolumeOptions; use parking_lot::RwLock; use std::collections::HashMap; @@ -42,11 +47,52 @@ const ATTRIBUTE_TIMEOUT: Duration = Duration::from_millis(1); // update the path. const ENTRY_TIMEOUT: Duration = Duration::from_secs(0); -/// Implementation of the virtio-fs file system. -pub struct VirtioFs { +/// Shared mutable state behind a [`VirtioFs`] handle. +struct VirtioFsInner { inodes: RwLock, files: RwLock>>, - readonly: bool, + mode: VirtioFsMode, +} + +/// Distinguishes a single-share device from a multi-share aggregate. +/// +/// The read-only setting lives on each volume's inodes, not here, so aggregate +/// children can differ (see [`AggregateState`]). +enum VirtioFsMode { + /// Single share: node 1 is a real inode at the volume root. + Direct, + /// Multi-share: node 1 is a synthetic directory whose children are + /// independent host folders. + Aggregate(AggregateState), +} + +impl VirtioFsInner { + /// The aggregate state, or `None` for a direct (single-share) device. + fn aggregate(&self) -> Option<&AggregateState> { + match &self.mode { + VirtioFsMode::Aggregate(state) => Some(state), + VirtioFsMode::Direct => None, + } + } +} + +fn build_volume( + root_path: impl AsRef, + mount_options: Option<&LxVolumeOptions>, +) -> lx::Result<(lxutil::LxVolume, bool)> { + let readonly = mount_options.is_some_and(|options| options.is_readonly()); + let volume = if let Some(mount_options) = mount_options { + mount_options.new_volume(root_path) + } else { + lxutil::LxVolume::new(root_path) + }?; + Ok((volume, readonly)) +} + +/// Implementation of the virtio-fs file system. +#[derive(Clone)] +pub struct VirtioFs { + inner: Arc, } impl Fuse for VirtioFs { @@ -73,10 +119,13 @@ impl Fuse for VirtioFs { fn get_attr(&self, request: &Request, flags: u32, fh: u64) -> lx::Result { let node_id = request.node_id(); // If a file handle is specified, get the attributes from the open file. This is faster on - // Windows and works if the file was deleted. - let attr = if flags & FUSE_GETATTR_FH != 0 { + // Windows and works if the file was deleted. The synthetic root's directory handle has no + // backing file, so fall through to the node-based branch for it. + let attr = if flags & FUSE_GETATTR_FH != 0 && !self.is_synthetic_root_handle(node_id, fh) { let file = self.get_file(fh)?; file.get_attr()? + } else if self.is_synthetic_root(node_id) { + self.synthetic_root_attr() } else { let inode = self.get_inode(node_id)?; inode.get_attr()? @@ -91,14 +140,19 @@ impl Fuse for VirtioFs { fh: u64, getattr_flags: u32, flags: StatxFlags, - _mask: lx::StatExMask, + mask: lx::StatExMask, ) -> lx::Result { let node_id = request.node_id(); // If a file handle is specified, get the attributes from the open file. This is faster on - // Windows and works if the file was deleted. - let statx = if getattr_flags & FUSE_GETATTR_FH != 0 { + // Windows and works if the file was deleted. The synthetic root's directory handle has no + // backing file, so fall through to the node-based branch for it. + let statx = if getattr_flags & FUSE_GETATTR_FH != 0 + && !self.is_synthetic_root_handle(node_id, fh) + { let file = self.get_file(fh)?; file.get_statx()? + } else if self.is_synthetic_root(node_id) { + self.synthetic_root_statx(mask) } else { let inode = self.get_inode(node_id)?; inode.get_statx()? @@ -110,13 +164,17 @@ impl Fuse for VirtioFs { fn set_attr(&self, request: &Request, arg: &fuse_setattr_in) -> lx::Result { let node_id = request.node_id(); + if self.is_synthetic_root(node_id) { + return Err(lx::Error::EROFS); + } + // If a file handle is specified, set the attributes on the open file. This is faster on // Windows and works if the file was deleted. let attr = if arg.valid & FATTR_FH != 0 { let file = self.get_file(arg.fh)?; // Block truncation and other modifications on readonly filesystems if arg.valid & !(FATTR_FH | FATTR_LOCKOWNER) != 0 { - self.check_writable()?; + self.check_writable(file.inode())?; } file.set_attr(arg, request.uid())?; file.get_attr()? @@ -124,7 +182,7 @@ impl Fuse for VirtioFs { let inode = self.get_inode(node_id)?; // Block truncation and other modifications on readonly filesystems if arg.valid & !(FATTR_FH | FATTR_LOCKOWNER) != 0 { - self.check_writable()?; + self.check_writable(&inode)?; } inode.set_attr(arg, request.uid())? }; @@ -133,6 +191,9 @@ impl Fuse for VirtioFs { } fn lookup(&self, request: &Request, name: &lx::LxStr) -> lx::Result { + if self.is_synthetic_root(request.node_id()) { + return self.lookup_synthetic_root(name); + } let inode = self.get_inode(request.node_id())?; self.lookup_helper(&inode, name) } @@ -140,7 +201,7 @@ impl Fuse for VirtioFs { fn forget(&self, node_id: u64, lookup_count: u64) { // This must be done under lock so an inode can't be resurrected between the lookup count // reaching zero and removing it from the list. - let mut inodes = self.inodes.write(); + let mut inodes = self.inner.inodes.write(); if let Some(inode) = inodes.get(node_id) { if inode.forget(node_id, lookup_count) == 0 { tracing::trace!(node_id, "Removing inode"); @@ -165,8 +226,11 @@ impl Fuse for VirtioFs { name: &lx::LxStr, arg: &fuse_create_in, ) -> lx::Result { + if self.is_synthetic_root(request.node_id()) { + return Err(lx::Error::EROFS); + } let inode = self.get_inode(request.node_id())?; - self.check_writable()?; + self.check_writable(&inode)?; let (new_inode, attr, file) = inode.create(name, arg.flags, arg.mode, request.uid(), request.gid())?; @@ -188,8 +252,11 @@ impl Fuse for VirtioFs { name: &lx::LxStr, arg: &fuse_mkdir_in, ) -> lx::Result { + if self.is_synthetic_root(request.node_id()) { + return Err(lx::Error::EROFS); + } let inode = self.get_inode(request.node_id())?; - self.check_writable()?; + self.check_writable(&inode)?; let (new_inode, attr) = inode.mkdir(name, arg.mode, request.uid(), request.gid())?; let (_, node_id) = self.insert_inode(new_inode); Ok(fuse_entry_out::new( @@ -206,8 +273,11 @@ impl Fuse for VirtioFs { name: &lx::LxStr, arg: &fuse_mknod_in, ) -> lx::Result { + if self.is_synthetic_root(request.node_id()) { + return Err(lx::Error::EROFS); + } let inode = self.get_inode(request.node_id())?; - self.check_writable()?; + self.check_writable(&inode)?; let (new_inode, attr) = inode.mknod(name, arg.mode, request.uid(), request.gid(), arg.rdev)?; @@ -226,8 +296,11 @@ impl Fuse for VirtioFs { name: &lx::LxStr, target: &lx::LxStr, ) -> lx::Result { + if self.is_synthetic_root(request.node_id()) { + return Err(lx::Error::EROFS); + } let inode = self.get_inode(request.node_id())?; - self.check_writable()?; + self.check_writable(&inode)?; let (new_inode, attr) = inode.symlink(name, target, request.uid(), request.gid())?; let (_, node_id) = self.insert_inode(new_inode); @@ -240,9 +313,12 @@ impl Fuse for VirtioFs { } fn link(&self, request: &Request, name: &lx::LxStr, target: u64) -> lx::Result { + if self.is_synthetic_root(request.node_id()) { + return Err(lx::Error::EROFS); + } let inode = self.get_inode(request.node_id())?; let target_inode = self.get_inode(target)?; - self.check_writable()?; + self.check_writable(&inode)?; let attr = inode.link(name, &target_inode)?; // Increment the lookup count since we're returning an entry for this inode. @@ -273,7 +349,7 @@ impl Fuse for VirtioFs { fn write(&self, request: &Request, arg: &fuse_write_in, data: &[u8]) -> lx::Result { let file = self.get_file(arg.fh)?; - self.check_writable()?; + self.check_writable(file.inode())?; file.write(data, arg.offset, request.uid()) } @@ -283,21 +359,35 @@ impl Fuse for VirtioFs { } fn open_dir(&self, request: &Request, flags: u32) -> lx::Result { + if self.is_synthetic_root(request.node_id()) { + // The synthetic root has no backing handle; hand out a sentinel that + // read_dir/read_dir_plus/release_dir recognize. + return Ok(fuse_open_out::new(SYNTHETIC_ROOT_FH, 0)); + } // There is no special handling for directories, so just call open. self.open(request, flags) } - fn read_dir(&self, _request: &Request, arg: &fuse_read_in) -> lx::Result> { + fn read_dir(&self, request: &Request, arg: &fuse_read_in) -> lx::Result> { + if self.is_synthetic_root_handle(request.node_id(), arg.fh) { + return self.read_synthetic_root_dir(arg.offset, arg.size, false); + } let file = self.get_file(arg.fh)?; file.read_dir(self, arg.offset, arg.size, false) } - fn read_dir_plus(&self, _request: &Request, arg: &fuse_read_in) -> lx::Result> { + fn read_dir_plus(&self, request: &Request, arg: &fuse_read_in) -> lx::Result> { + if self.is_synthetic_root_handle(request.node_id(), arg.fh) { + return self.read_synthetic_root_dir(arg.offset, arg.size, true); + } let file = self.get_file(arg.fh)?; file.read_dir(self, arg.offset, arg.size, true) } fn release_dir(&self, request: &Request, arg: &fuse_release_in) -> lx::Result<()> { + if self.is_synthetic_root_handle(request.node_id(), arg.fh) { + return Ok(()); + } self.release(request, arg) } @@ -317,13 +407,34 @@ impl Fuse for VirtioFs { new_name: &lx::LxStr, flags: u32, ) -> lx::Result<()> { + if self.is_synthetic_root(request.node_id()) || self.is_synthetic_root(new_dir) { + return Err(lx::Error::EROFS); + } let inode = self.get_inode(request.node_id())?; let new_inode = self.get_inode(new_dir)?; - self.check_writable()?; - inode.rename(name, &new_inode, new_name, flags) + // A rename cannot cross aggregated volume boundaries. + if inode.volume_id() != new_inode.volume_id() { + return Err(lx::Error::EXDEV); + } + self.check_writable(&inode)?; + inode.rename(name, &new_inode, new_name, flags)?; + // A rename doesn't preserve inode identity on path-keyed volumes, so + // evict both the vacated source path and the overwritten destination + // path from the dedup map. + let mut inodes = self.inner.inodes.write(); + if let Some(key) = inode.child_path_dedup_key(name) { + inodes.evict_dedup_key(&key); + } + if let Some(key) = new_inode.child_path_dedup_key(new_name) { + inodes.evict_dedup_key(&key); + } + Ok(()) } fn statfs(&self, request: &Request) -> lx::Result { + if self.is_synthetic_root(request.node_id()) { + return Ok(fuse_kstatfs::new(0, 0, 0, 0, 0, 512, 255, 512)); + } let inode = self.get_inode(request.node_id())?; inode.stat_fs() } @@ -339,6 +450,9 @@ impl Fuse for VirtioFs { } fn get_xattr(&self, request: &Request, name: &lx::LxStr, size: u32) -> lx::Result> { + if self.is_synthetic_root(request.node_id()) { + return Err(lx::Error::ENODATA); + } let inode = self.get_inode(request.node_id())?; let mut value = vec![0u8; size as usize]; let size = inode.get_xattr(name, Some(&mut value))?; @@ -347,6 +461,9 @@ impl Fuse for VirtioFs { } fn get_xattr_size(&self, request: &Request, name: &lx::LxStr) -> lx::Result { + if self.is_synthetic_root(request.node_id()) { + return Err(lx::Error::ENODATA); + } let inode = self.get_inode(request.node_id())?; let size = inode.get_xattr(name, None)?; let size = size.try_into().map_err(|_| lx::Error::E2BIG)?; @@ -360,12 +477,18 @@ impl Fuse for VirtioFs { value: &[u8], flags: u32, ) -> lx::Result<()> { + if self.is_synthetic_root(request.node_id()) { + return Err(lx::Error::EROFS); + } let inode = self.get_inode(request.node_id())?; - self.check_writable()?; + self.check_writable(&inode)?; inode.set_xattr(name, value, flags) } fn list_xattr(&self, request: &Request, size: u32) -> lx::Result> { + if self.is_synthetic_root(request.node_id()) { + return Ok(Vec::new()); + } let inode = self.get_inode(request.node_id())?; let mut list = vec![0u8; size as usize]; let size = inode.list_xattr(Some(&mut list))?; @@ -374,6 +497,9 @@ impl Fuse for VirtioFs { } fn list_xattr_size(&self, request: &Request) -> lx::Result { + if self.is_synthetic_root(request.node_id()) { + return Ok(0); + } let inode = self.get_inode(request.node_id())?; let size = inode.list_xattr(None)?; let size = size.try_into().map_err(|_| lx::Error::E2BIG)?; @@ -381,22 +507,25 @@ impl Fuse for VirtioFs { } fn remove_xattr(&self, request: &Request, name: &lx::LxStr) -> lx::Result<()> { + if self.is_synthetic_root(request.node_id()) { + return Err(lx::Error::EROFS); + } let inode = self.get_inode(request.node_id())?; - self.check_writable()?; + self.check_writable(&inode)?; inode.remove_xattr(name) } fn destroy(&self) { // To get the file system ready for re-mount, clean out any open files and leaked inodes. - self.files.write().clear(); - self.inodes.write().clear(); + self.inner.files.write().clear(); + self.inner.inodes.write().clear(); } } impl VirtioFs { - /// Check if the filesystem is readonly and return EROFS if so. - fn check_writable(&self) -> lx::Result<()> { - if self.readonly { + /// Check if the inode's volume is readonly and return EROFS if so. + fn check_writable(&self, inode: &VirtioFsInode) -> lx::Result<()> { + if inode.readonly() { Err(lx::Error::EROFS) } else { Ok(()) @@ -405,7 +534,7 @@ impl VirtioFs { /// Check whether the open flags are permitted on a read-only filesystem. fn check_open_readonly(&self, inode: &VirtioFsInode, flags: u32) -> lx::Result<()> { - if !self.readonly { + if !inode.readonly() { return Ok(()); } @@ -439,23 +568,37 @@ impl VirtioFs { root_path: impl AsRef, mount_options: Option<&LxVolumeOptions>, ) -> lx::Result { - let readonly = mount_options.is_some_and(|o| o.is_readonly()); - let volume = if let Some(mount_options) = mount_options { - mount_options.new_volume(root_path) - } else { - lxutil::LxVolume::new(root_path) - }?; - let mut inodes = InodeMap::new(volume.supports_stable_file_id()); - let (root_inode, _) = VirtioFsInode::new(Arc::new(volume), PathBuf::new())?; + let (volume, readonly) = build_volume(root_path, mount_options)?; + let mut inodes = InodeMap::new(false); + let volume = Arc::new(VirtioFsVolume::new(volume, 0, readonly)); + let (root_inode, _) = VirtioFsInode::new(volume, PathBuf::new())?; assert!(inodes.insert(root_inode).1 == FUSE_ROOT_ID); Ok(Self { - inodes: RwLock::new(inodes), - files: RwLock::new(HandleMap::new()), - readonly, + inner: Arc::new(VirtioFsInner { + inodes: RwLock::new(inodes), + files: RwLock::new(HandleMap::new()), + mode: VirtioFsMode::Direct, + }), }) } - /// Perform lookup on a specified directory inode. + /// Create a new, empty aggregate virtio-fs. + /// + /// Node 1 is a synthetic, read-only directory; use [`Self::add_child`] to + /// expose host folders as named children, each with its own read-only + /// setting. Children share one superblock, with inode numbers namespaced + /// per volume to avoid cross-volume `st_ino` collisions. + pub fn new_aggregate() -> Self { + Self { + inner: Arc::new(VirtioFsInner { + // `true` enables aggregate mode: node 1 is synthetic (see `InodeMap`). + inodes: RwLock::new(InodeMap::new(true)), + files: RwLock::new(HandleMap::new()), + mode: VirtioFsMode::Aggregate(AggregateState::new()), + }), + } + } + fn lookup_helper(&self, inode: &VirtioFsInode, name: &lx::LxStr) -> lx::Result { let (new_inode, attr) = inode.lookup_child(name)?; let (_, new_inode_nr) = self.insert_inode(new_inode); @@ -469,14 +612,23 @@ impl VirtioFs { /// Removes a file or directory. fn unlink_helper(&self, request: &Request, name: &lx::LxStr, flags: i32) -> lx::Result<()> { + if self.is_synthetic_root(request.node_id()) { + return Err(lx::Error::EROFS); + } let inode = self.get_inode(request.node_id())?; - self.check_writable()?; - inode.unlink(name, flags) + self.check_writable(&inode)?; + inode.unlink(name, flags)?; + // On path-keyed volumes the path is the inode's identity, so evict it + // now; a later create at the same path must not alias the removed inode. + if let Some(key) = inode.child_path_dedup_key(name) { + self.inner.inodes.write().evict_dedup_key(&key); + } + Ok(()) } /// Retrieve the inode with the specified node ID. fn get_inode(&self, node_id: u64) -> lx::Result> { - self.inodes.read().get(node_id).ok_or_else(|| { + self.inner.inodes.read().get(node_id).ok_or_else(|| { tracing::warn!(node_id, "request for unknown inode"); lx::Error::EINVAL }) @@ -487,12 +639,12 @@ impl VirtioFs { /// If the file system supports stable inode numbers and an inode already existed with this /// number, the existing inode is returned, not the passed in one. fn insert_inode(&self, inode: VirtioFsInode) -> (Arc, u64) { - self.inodes.write().insert(inode) + self.inner.inodes.write().insert(inode) } /// Retrieve the file object with the specified file handle. fn get_file(&self, fh: u64) -> lx::Result> { - let files = self.files.read(); + let files = self.inner.files.read(); let file = files.get(fh).ok_or_else(|| { tracing::warn!(fh, "Request for unknown file"); lx::Error::EBADF @@ -503,12 +655,12 @@ impl VirtioFs { /// Insert a new file object, and return the assigned file handle. fn insert_file(&self, file: VirtioFsFile) -> u64 { - self.files.write().insert(Arc::new(file)) + self.inner.files.write().insert(Arc::new(file)) } /// Remove the file with the specified node ID. fn remove_file(&self, fh: u64) { - self.files.write().remove(fh); + self.inner.files.write().remove(fh); } } @@ -575,21 +727,25 @@ impl HandleMap { /// globally unique, whereas inode numbers are per-volume. struct InodeMap { inodes_by_node_id: HandleMap>, - inodes_by_inode_nr: Option, u64)>>, + /// Maps a [`DedupKey`] to the registered inode and its FUSE node id, so + /// repeated lookups of one host file share a single node id. + inodes_by_key: HashMap, u64)>, + /// When true, node 1 is synthetic and not stored in this map, so node IDs + /// are allocated starting at 2 and `clear` does not preserve a real root. + aggregate: bool, } impl InodeMap { /// Create a new `InodeMap`. - pub fn new(supports_stable_file_id: bool) -> Self { - // TODO: Once multiple volumes are supported, the inodes_by_inode_nr map should be per - // volume. + pub fn new(aggregate: bool) -> Self { Self { - inodes_by_node_id: HandleMap::new(), - inodes_by_inode_nr: if supports_stable_file_id { - Some(HashMap::new()) + inodes_by_node_id: if aggregate { + HandleMap::starting_at(FUSE_ROOT_ID + 1) } else { - None + HandleMap::new() }, + inodes_by_key: HashMap::new(), + aggregate, } } @@ -601,52 +757,69 @@ impl InodeMap { /// Insert an inode into the map, returning its node ID. pub fn insert(&mut self, inode: VirtioFsInode) -> (Arc, u64) { - // If stable inode numbers are supported, look for the inode by its number. - if let Some(inodes_by_inode_nr) = self.inodes_by_inode_nr.as_mut() { - match inodes_by_inode_nr.entry(inode.inode_nr()) { - Entry::Occupied(entry) => { - // Inode found; increment its count and return the existing FUSE node ID. - let new_path = inode.clone_path(); - let (inode, node_id) = entry.get(); - inode.lookup(new_path); - return (Arc::clone(inode), *node_id); - } - Entry::Vacant(entry) => { - // Inode not found, so insert it into both maps. - let inode = Arc::new(inode); - let node_id = self.inodes_by_node_id.insert(Arc::clone(&inode)); - entry.insert((Arc::clone(&inode), node_id)); - return (inode, node_id); - } + // Reuse an existing node id for the same host file; see `DedupKey` + // for how each volume type is keyed. + match self.inodes_by_key.entry(inode.dedup_key()) { + Entry::Occupied(entry) => { + // Inode found; increment its count and return the existing FUSE node ID. + let new_path = inode.clone_path(); + let (existing, node_id) = entry.get(); + existing.lookup(new_path); + (Arc::clone(existing), *node_id) + } + Entry::Vacant(entry) => { + // Inode not found, so insert it into both maps. + let inode = Arc::new(inode); + let node_id = self.inodes_by_node_id.insert(Arc::clone(&inode)); + entry.insert((Arc::clone(&inode), node_id)); + (inode, node_id) } } - - // No support for stable inode numbers, so just use node ID. - let inode = Arc::new(inode); - let node_id = self.inodes_by_node_id.insert(Arc::clone(&inode)); - (inode, node_id) } /// Remove an inode with the specified FUSE node ID from the map. pub fn remove(&mut self, node_id: u64) { let inode = self.inodes_by_node_id.remove(node_id).unwrap(); - if let Some(inodes_by_inode_nr) = self.inodes_by_inode_nr.as_mut() { - inodes_by_inode_nr.remove(&inode.inode_nr()); + // Only drop the by-key entry if it still points at THIS node: on + // path-keyed volumes the path may have been repointed to a newer inode + // (via delete+recreate or `evict_dedup_key`), which must not be lost. + if let Entry::Occupied(entry) = self.inodes_by_key.entry(inode.dedup_key()) { + if entry.get().1 == node_id { + entry.remove(); + } + } + } + + /// Detach a [`DedupKey::Path`] entry from its current inode so a later + /// create at that path gets a fresh node id instead of aliasing the + /// removed/renamed file. The inode stays in `inodes_by_node_id` for any + /// live fd or watch. + pub fn evict_dedup_key(&mut self, key: &DedupKey) { + if matches!(key, DedupKey::Path(..)) { + self.inodes_by_key.remove(key); } } /// Clears the map, preserving the root inode. pub fn clear(&mut self) { + if self.aggregate { + // Node 1 is synthetic and not stored here; drop everything and resume + // allocating node IDs after the reserved root id. + self.inodes_by_node_id.clear(); + self.inodes_by_node_id.next_handle = FUSE_ROOT_ID + 1; + self.inodes_by_key.clear(); + return; + } + let root_inode = Arc::clone(self.inodes_by_node_id.get(FUSE_ROOT_ID).unwrap()); self.inodes_by_node_id.clear(); // Re-insert the root inode. assert!(self.inodes_by_node_id.insert(Arc::clone(&root_inode)) == FUSE_ROOT_ID); - // Clear the inode number map if it's supported. - if let Some(inodes_by_inode_nr) = self.inodes_by_inode_nr.as_mut() { - inodes_by_inode_nr.clear(); - inodes_by_inode_nr.insert(root_inode.inode_nr(), (root_inode, FUSE_ROOT_ID)); - } + // Rebuild the dedup map with just the root. + self.inodes_by_key.clear(); + let key = root_inode.dedup_key(); + self.inodes_by_key.insert(key, (root_inode, FUSE_ROOT_ID)); } } diff --git a/vm/devices/virtio/virtiofs/src/resolver.rs b/vm/devices/virtio/virtiofs/src/resolver.rs index 1f0484ece9..4084646a6c 100644 --- a/vm/devices/virtio/virtiofs/src/resolver.rs +++ b/vm/devices/virtio/virtiofs/src/resolver.rs @@ -59,6 +59,24 @@ impl ResolveResource for VirtioFsResolver { VirtioFsBackend::SectionFs { .. } => { anyhow::bail!("section fs not supported on this platform") } + VirtioFsBackend::Aggregate { children } => { + let fs = VirtioFs::new_aggregate(); + for child in children { + fs.add_child( + &child.name, + &child.root_path, + Some(&LxVolumeOptions::from_option_string(&child.mount_options)), + ) + .map_err(|e| { + anyhow::anyhow!( + "failed to add virtiofs aggregate child '{}' (root_path='{}'): {e}", + child.name, + child.root_path + ) + })?; + } + VirtioFsDevice::new(input.driver_source, &resource.tag, fs, 0, None) + } }; Ok(device.into()) }