diff --git a/nvx-rs/README.md b/nvx-rs/README.md index 7236ca3b0..b49410126 100644 --- a/nvx-rs/README.md +++ b/nvx-rs/README.md @@ -45,9 +45,9 @@ nvx-rs/ │ ├── error.rs nvx::Error, its ErrorKind, and its stable ErrorCode │ ├── exec/ the execution engine: the streams, credit, and outcome of an execution's two │ │ sides, without I/O, threads, or features, for any framing and host -│ ├── host/ feature `host` (default): the runtime that every launch shares, artifacts, -│ │ launch, network and egress policy files, filesystem, NVXS connection, session, -│ │ process, platform, VM instances, sandbox stores, templates' manifests, +│ ├── host/ feature `host` (default): the runtime that every launch shares, VM directories, +│ │ artifacts, launch, network and egress policy files, filesystem, NVXS connection, +│ │ session, process, platform, VM instances, sandbox stores, templates' manifests, │ │ compatibility keys, store, and leases, outcome reports, file seals, and the │ │ sandbox launch profile of `nvx-rs sandbox`, its distro and scratch bindings, │ │ and its prepared launches; with feature `cli`, the `nvx-rs` CLI @@ -121,6 +121,7 @@ changes only when the module's purpose does. | [`nvx::Hypervisor`](#nvxhypervisor) | KVM, MSHV, or WHP, with its serde form behind the `serde` feature | | [`nvx::exec`](#nvxexec) | The execution engine: the streams, credit, and outcome of an execution's two sides, without I/O, for any framing | | [`nvx::host::runtime`](#nvxhostruntime) | `Runtime`: what every launch shares, the runtime's files and their identity, the hypervisor and its probe, and the launch options | +| [`nvx::host::vm`](#nvxhostvm) | The directory of a VM, private to the current user, with the fixed names of the files and endpoints of its launches | | [`nvx::host::artifacts`](#nvxhostartifacts) | Locating the OpenVMM executable and the guest artifacts that a VM runs, and their runtime identity | | [`nvx::host::launch`](#nvxhostlaunch) | `LaunchSpec` and `VmmLaunch`: the OpenVMM command line of a managed microVM, its checks, and the inputs that it shares with an adapter's launch | | [`nvx::host::network`](#nvxhostnetwork) | The one lowering of a network posture into OpenVMM options, for the CLI and external orchestrators | @@ -400,6 +401,46 @@ differ. The sandbox store records it with each VM, and re-exports its types. `probe_hypervisor` checks that the host has the hypervisor: its device on Linux, and the Windows Hypervisor Platform on Windows. +### `nvx::host::vm` + +The directory of a VM, which its caller creates and owns, one for each VM. +`create_private_dir` creates it private to the current user, and `VmDir::open` +opens an existing one only if it is private: on Linux, it belongs to the user +with mode 0700, which OpenVMM requires of the directory of its control socket; +on Windows, it belongs to the user, and its protected access control list holds +only entries that allow the user access, one of which allows full access, which +the files and directories created in it inherit at every depth. Neither changes +an existing directory: one that others may access fails with `ErrorKind::Io` +rather than being restricted, since they could still hold its files open. +Symbolic links and other reparse points are refused, including paths with +trailing separators or `.` components. + +The library reserves these names in the directory, and every name that starts +with `.openvmm`, which OpenVMM gives its temporary files, such as +`.openvmm.staging-*`. The caller may keep files of its own under other names. + +| Name | What it holds | +| --- | --- | +| `openvmm.log` | OpenVMM's log of a launch | +| `outcome.json` | OpenVMM's outcome report of a launch | +| `control.sock` | The control endpoint at which OpenVMM listens, on Linux | +| `console.sock` | The guest's boot console, on Linux, for a launch that listens on it | +| `restore-ready.sock` | The endpoint at which a restore awaits OpenVMM's restore-ready event, on Linux | +| `openvmm/` | OpenVMM's snapshot files: a capture's destination, and a restore's view of its snapshot | +| `key.bin`, `snapshot.json` | A finished capture's key and description, which form its snapshot with `openvmm/` | +| `capture-image.raw`, `capture-scratch.raw` | The sparse placeholder image and the dormant scratch that a capture VM attaches | + +On Windows, where OpenVMM listens on named pipes, the control and console +endpoints are fresh pipes, `//./pipe/openvmm-microvm-` and 32 random +hexadecimal digits, the prefix that OpenVMM requires of the pipe of a console, +and a restore awaits its event at a fresh `//./pipe/nvx-restore-ready-` pipe. + +`VmOutputs` holds the paths of OpenVMM's log and outcome report, its control +endpoint, and the optional boot-console endpoint. `Capability` holds the +credential that the host presents at a VM's control endpoint: `as_bytes` +returns it, and its `Debug` form hides it. The library never writes it to a +file; a caller that reconnects after a restart keeps it. + ### `nvx::host::artifacts` `Artifacts` (also `nvx::Artifacts`): the OpenVMM executable, guest kernel, and diff --git a/nvx-rs/public-api/linux.txt b/nvx-rs/public-api/linux.txt index 933df01ac..f5d77ad4a 100644 --- a/nvx-rs/public-api/linux.txt +++ b/nvx-rs/public-api/linux.txt @@ -3102,6 +3102,18 @@ field nvx::host::template::TimeContract::tsc_frequency_hz field nvx::host::template::TimeContract::tsc_tolerance_ppm fn nvx::host::template::reattach_clone fn nvx::host::template::status_evidence +mod nvx::host::vm +struct nvx::host::vm::Capability +method nvx::host::vm::Capability::as_bytes +struct nvx::host::vm::VmDir +method nvx::host::vm::VmDir::open +method nvx::host::vm::VmDir::path +struct nvx::host::vm::VmOutputs +field nvx::host::vm::VmOutputs::console +field nvx::host::vm::VmOutputs::endpoint +field nvx::host::vm::VmOutputs::log +field nvx::host::vm::VmOutputs::outcome +fn nvx::host::vm::create_private_dir mod nvx::net struct nvx::net::Cidr method nvx::net::Cidr::contains diff --git a/nvx-rs/public-api/windows.txt b/nvx-rs/public-api/windows.txt index 2de59d619..dd07a9563 100644 --- a/nvx-rs/public-api/windows.txt +++ b/nvx-rs/public-api/windows.txt @@ -2039,6 +2039,18 @@ field nvx::host::template::TimeContract::tsc_frequency_hz field nvx::host::template::TimeContract::tsc_tolerance_ppm fn nvx::host::template::reattach_clone fn nvx::host::template::status_evidence +mod nvx::host::vm +struct nvx::host::vm::Capability +method nvx::host::vm::Capability::as_bytes +struct nvx::host::vm::VmDir +method nvx::host::vm::VmDir::open +method nvx::host::vm::VmDir::path +struct nvx::host::vm::VmOutputs +field nvx::host::vm::VmOutputs::console +field nvx::host::vm::VmOutputs::endpoint +field nvx::host::vm::VmOutputs::log +field nvx::host::vm::VmOutputs::outcome +fn nvx::host::vm::create_private_dir mod nvx::net struct nvx::net::Cidr method nvx::net::Cidr::contains diff --git a/nvx-rs/src/host/mod.rs b/nvx-rs/src/host/mod.rs index 14b7299ee..69e47ff5c 100644 --- a/nvx-rs/src/host/mod.rs +++ b/nvx-rs/src/host/mod.rs @@ -45,6 +45,8 @@ //! and [`sandbox::scratch`] binds and claims a scratch image. A //! [`PreparedLaunch`](sandbox::PreparedLaunch) hands the VMM the files that those checks //! opened, which it inherits through the [`supervisor`]'s allowlist. +//! - [`vm`] holds a caller-owned VM directory, private to the current user, the fixed names of +//! its files and endpoints, and its control credential, which the library never writes. //! - `cli` (feature `cli`) is the `nvx-rs` command-line interface, which runs microVMs and //! sandboxes, looks up and downloads artifacts, and compiles egress policies. //! @@ -90,3 +92,4 @@ mod sys; pub mod template; #[cfg(test)] pub(crate) mod tests; +pub mod vm; diff --git a/nvx-rs/src/host/platform/tests.rs b/nvx-rs/src/host/platform/tests.rs index d44a79b5f..78edf9022 100644 --- a/nvx-rs/src/host/platform/tests.rs +++ b/nvx-rs/src/host/platform/tests.rs @@ -4,6 +4,8 @@ pub(crate) use super::linux::tests::{ }; #[cfg(windows)] pub(crate) use super::windows::tests::set_access; +#[cfg(windows)] +pub(crate) use super::windows::tests::{access, user_sid}; use super::*; #[cfg(any(target_os = "linux", windows))] diff --git a/nvx-rs/src/host/platform/windows/mod.rs b/nvx-rs/src/host/platform/windows/mod.rs index 8f24c3c1d..eef623a5c 100644 --- a/nvx-rs/src/host/platform/windows/mod.rs +++ b/nvx-rs/src/host/platform/windows/mod.rs @@ -34,8 +34,9 @@ pub(crate) use self::interrupt::{interrupted, watch_interrupts}; pub(crate) use self::log_claim::{claim_log, create_log_job, log_claim_held, serves_launch}; pub use self::private::create_private_dir; pub(crate) use self::private::{ - check_owner_only_dir, check_snapshot_dir, check_store_dir, create_owner_only_dir, - create_store_dir, foreign_writer_problem, make_sparse, private_file_problem, rename_no_replace, + check_owner_only_dir, check_snapshot_dir, check_store_dir, check_vm_dir, create_owner_only_dir, + create_store_dir, create_vm_dir, foreign_writer_problem, make_sparse, private_file_problem, + rename_no_replace, }; pub use self::process::process_start_time; pub(crate) use self::process::{ diff --git a/nvx-rs/src/host/platform/windows/private.rs b/nvx-rs/src/host/platform/windows/private.rs index 29ed93fab..04d93414a 100644 --- a/nvx-rs/src/host/platform/windows/private.rs +++ b/nvx-rs/src/host/platform/windows/private.rs @@ -13,10 +13,11 @@ use windows_sys::Win32::Foundation::{ERROR_ALREADY_EXISTS, GENERIC_ALL, GENERIC_ use windows_sys::Win32::Security::{ ACCESS_ALLOWED_ACE, ACE_HEADER, ACL, ACL_REVISION, ACL_SIZE_INFORMATION, AclSizeInformation, AddAccessAllowedAceEx, CONTAINER_INHERIT_ACE, DACL_SECURITY_INFORMATION, EqualSid, GetAce, - GetAclInformation, GetKernelObjectSecurity, GetLengthSid, GetSecurityDescriptorDacl, - GetSecurityDescriptorOwner, GetTokenInformation, InitializeAcl, InitializeSecurityDescriptor, - IsWellKnownSid, OBJECT_INHERIT_ACE, OWNER_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, PSID, - SE_DACL_PROTECTED, SECURITY_ATTRIBUTES, SECURITY_DESCRIPTOR, SetSecurityDescriptorControl, + GetAclInformation, GetKernelObjectSecurity, GetLengthSid, GetSecurityDescriptorControl, + GetSecurityDescriptorDacl, GetSecurityDescriptorOwner, GetTokenInformation, INHERIT_ONLY_ACE, + InitializeAcl, InitializeSecurityDescriptor, IsWellKnownSid, NO_PROPAGATE_INHERIT_ACE, + OBJECT_INHERIT_ACE, OWNER_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, PSID, SE_DACL_PROTECTED, + SECURITY_ATTRIBUTES, SECURITY_DESCRIPTOR, SetSecurityDescriptorControl, SetSecurityDescriptorDacl, SetSecurityDescriptorOwner, TOKEN_QUERY, TOKEN_USER, TokenUser, WinBuiltinAdministratorsSid, WinCreatorOwnerRightsSid, WinCreatorOwnerSid, WinLocalSystemSid, }; @@ -250,6 +251,120 @@ pub(crate) fn check_snapshot_dir(path: &Path) -> io::Result> { plain_dir_problem(path, &CurrentUser::get()?, Required::Private) } +/// Creates an owner-only VM directory, or checks an existing one without changing it. +pub(crate) fn create_vm_dir(path: &Path) -> io::Result> { + create_store_dir(path)?; + check_vm_dir(path) +} + +/// Checks a VM directory's ownership, protected DACL and full, inheritable owner-only access. +/// Unlike a store, a VM directory trusts neither SYSTEM nor the Administrators or owner aliases. +pub(crate) fn check_vm_dir(path: &Path) -> io::Result> { + let directory = OpenOptions::new() + .access_mode(READ_CONTROL) + .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT) + .open(path)?; + let handle = directory.as_raw_handle() as HANDLE; + let attributes = handle_information(handle)?.dwFileAttributes; + if attributes & FILE_ATTRIBUTE_DIRECTORY == 0 || attributes & FILE_ATTRIBUTE_REPARSE_POINT != 0 + { + return Ok(Some("is not a plain directory".to_owned())); + } + let information = OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION; + let mut len = 0; + // SAFETY: an empty buffer asks for the descriptor's length. + unsafe { GetKernelObjectSecurity(handle, information, ptr::null_mut(), 0, &mut len) }; + let mut buffer = vec![0u64; (len as usize).div_ceil(mem::size_of::())]; + let descriptor: PSECURITY_DESCRIPTOR = buffer.as_mut_ptr().cast(); + // SAFETY: the handle has read-control access, and the aligned buffer holds `len` bytes. + if unsafe { GetKernelObjectSecurity(handle, information, descriptor, len, &mut len) } == 0 { + return Err(io::Error::last_os_error()); + } + vm_dir_problem(descriptor, &CurrentUser::get()?) +} + +/// Checks a VM directory's security descriptor against its current-user-only contract. +fn vm_dir_problem( + descriptor: PSECURITY_DESCRIPTOR, + user: &CurrentUser, +) -> io::Result> { + let mut owner = ptr::null_mut(); + let mut present = 0; + let mut acl = ptr::null_mut(); + let mut defaulted = 0; + let mut control = 0; + let mut revision = 0; + // SAFETY: the descriptor and output pointers are valid. The returned pointers borrow it. + let read = unsafe { + GetSecurityDescriptorOwner(descriptor, &mut owner, &mut defaulted) != 0 + && GetSecurityDescriptorDacl(descriptor, &mut present, &mut acl, &mut defaulted) != 0 + && GetSecurityDescriptorControl(descriptor, &mut control, &mut revision) != 0 + }; + if !read { + return Err(io::Error::last_os_error()); + } + // SAFETY: both identifiers are valid. + if owner.is_null() || unsafe { EqualSid(owner, user.sid()) } == 0 { + return Ok(Some("belongs to another user".to_owned())); + } + if present == 0 || acl.is_null() { + return Ok(Some("allows every user".to_owned())); + } + if control & SE_DACL_PROTECTED == 0 { + return Ok(Some( + "inherits access control entries from its parent".to_owned(), + )); + } + // SAFETY: ACL_SIZE_INFORMATION is plain data with a valid all-zero representation. + let mut size: ACL_SIZE_INFORMATION = unsafe { mem::zeroed() }; + // SAFETY: the list is valid, and the output holds an ACL_SIZE_INFORMATION. + if unsafe { + GetAclInformation( + acl, + (&raw mut size).cast(), + mem::size_of::() as u32, + AclSizeInformation, + ) + } == 0 + { + return Err(io::Error::last_os_error()); + } + let inherited = OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE; + let mut full_access = false; + for index in 0..size.AceCount { + let mut ace = ptr::null_mut(); + // SAFETY: the index is within the list, and the output pointer is valid. + if unsafe { GetAce(acl, index, &mut ace) } == 0 { + return Err(io::Error::last_os_error()); + } + // SAFETY: each entry starts with an ACE_HEADER. + let header = unsafe { *ace.cast::() }; + if header.AceType != ACCESS_ALLOWED_ACE_TYPE { + return Ok(Some(format!( + "has an access control entry of type {}, not one that allows access", + header.AceType + ))); + } + let entry = ace.cast::(); + // SAFETY: an allowed ACE holds its mask and its SID from SidStart onward. + let (mask, sid): (u32, PSID) = + unsafe { ((*entry).Mask, (&raw mut (*entry).SidStart).cast()) }; + // SAFETY: both identifiers are valid. + if unsafe { EqualSid(sid, user.sid()) } == 0 { + return Ok(Some("allows others than the current user".to_owned())); + } + let flags = u32::from(header.AceFlags); + full_access |= ((mask & FILE_ALL_ACCESS) == FILE_ALL_ACCESS || mask & GENERIC_ALL != 0) + && flags & inherited == inherited + && flags & (INHERIT_ONLY_ACE | NO_PROPAGATE_INHERIT_ACE) == 0; + } + Ok((!full_access).then(|| { + "does not allow the current user full access that the files and directories created \ + in it inherit" + .to_owned() + })) +} + /// Returns why the file that `file` holds open, the lock, a record, or the capability of a /// sandbox store, is not private to the current user, if it is not. /// diff --git a/nvx-rs/src/host/platform/windows/private/tests.rs b/nvx-rs/src/host/platform/windows/private/tests.rs index e6ce375a5..25270b46b 100644 --- a/nvx-rs/src/host/platform/windows/private/tests.rs +++ b/nvx-rs/src/host/platform/windows/private/tests.rs @@ -35,6 +35,9 @@ fn private_directories_support_long_paths() { .open(directory.join("record")) .unwrap(); assert_eq!(private_file_problem(&file).unwrap(), None); + let vm = directory.join("vm"); + assert_eq!(create_vm_dir(&vm).unwrap(), None); + assert_eq!(check_vm_dir(&vm).unwrap(), None); } } @@ -113,3 +116,182 @@ pub(crate) fn set_access(path: &Path, sddl: &str) -> io::Result<()> { unsafe { LocalFree(descriptor) }; result } + +fn take_wide(text: *mut u16) -> String { + use windows_sys::Win32::Foundation::LocalFree; + + // SAFETY: the conversion returns a readable NUL-terminated string. + let len = (0..) + .take_while(|&index| unsafe { *text.add(index) } != 0) + .count(); + // SAFETY: the string holds `len` units before its NUL. + let string = String::from_utf16(unsafe { std::slice::from_raw_parts(text, len) }).unwrap(); + // SAFETY: the conversion allocated this string, which is no longer used. + unsafe { LocalFree(text.cast()) }; + string +} + +pub(crate) fn user_sid() -> String { + let user = CurrentUser::get().unwrap(); + sddl_sid(user.sid()) +} + +fn sddl_sid(sid: PSID) -> String { + use windows_sys::Win32::Security::Authorization::{ + ConvertSecurityDescriptorToStringSecurityDescriptorW, SDDL_REVISION_1, + }; + + // Use the same SID aliases as `access`'s descriptor snapshots. + // SAFETY: SECURITY_DESCRIPTOR is plain data with a valid all-zero representation. + let mut descriptor: SECURITY_DESCRIPTOR = unsafe { mem::zeroed() }; + let descriptor: PSECURITY_DESCRIPTOR = (&raw mut descriptor).cast(); + // SAFETY: the descriptor and SID are valid, and the SID outlives the conversion. + let initialized = unsafe { + InitializeSecurityDescriptor(descriptor, SECURITY_DESCRIPTOR_REVISION) != 0 + && SetSecurityDescriptorOwner(descriptor, sid, 0) != 0 + }; + assert!(initialized, "{}", io::Error::last_os_error()); + let mut text = ptr::null_mut(); + // SAFETY: the descriptor and output pointer are valid; only its owner is requested. + let converted = unsafe { + ConvertSecurityDescriptorToStringSecurityDescriptorW( + descriptor, + SDDL_REVISION_1, + OWNER_SECURITY_INFORMATION, + &mut text, + ptr::null_mut(), + ) + }; + assert_ne!(converted, 0, "{}", io::Error::last_os_error()); + take_wide(text) + .strip_prefix("O:") + .expect("the descriptor reports its owner") + .to_owned() +} + +#[test] +fn security_identifier_spelling_matches_the_descriptor_snapshot() { + use windows_sys::Win32::Security::{ + CreateWellKnownSid, SECURITY_MAX_SID_SIZE, WinLocalServiceSid, + }; + + for (kind, expected) in [(WinLocalSystemSid, "SY"), (WinLocalServiceSid, "LS")] { + let mut buffer = + vec![0u64; (SECURITY_MAX_SID_SIZE as usize).div_ceil(mem::size_of::())]; + let sid: PSID = buffer.as_mut_ptr().cast(); + let mut length = (buffer.len() * mem::size_of::()) as u32; + // SAFETY: the aligned buffer holds at least SECURITY_MAX_SID_SIZE bytes. + let created = unsafe { CreateWellKnownSid(kind, ptr::null_mut(), sid, &mut length) }; + assert_ne!(created, 0, "{}", io::Error::last_os_error()); + assert_eq!(sddl_sid(sid), expected); + } +} + +pub(crate) fn access(path: &Path) -> io::Result { + use windows_sys::Win32::Security::Authorization::{ + ConvertSecurityDescriptorToStringSecurityDescriptorW, SDDL_REVISION_1, + }; + use windows_sys::Win32::Security::GetFileSecurityW; + + let name = wide_path(path)?; + let information = OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION; + let mut len = 0; + // SAFETY: the path is terminated; an empty buffer requests the descriptor's length. + unsafe { GetFileSecurityW(name.as_ptr(), information, ptr::null_mut(), 0, &mut len) }; + let mut buffer = vec![0u64; (len as usize).div_ceil(mem::size_of::())]; + let descriptor: PSECURITY_DESCRIPTOR = buffer.as_mut_ptr().cast(); + // SAFETY: the path is terminated and the aligned buffer holds `len` bytes. + if unsafe { GetFileSecurityW(name.as_ptr(), information, descriptor, len, &mut len) } == 0 { + return Err(io::Error::last_os_error()); + } + let mut text = ptr::null_mut(); + // SAFETY: the descriptor and output pointers are valid. + let converted = unsafe { + ConvertSecurityDescriptorToStringSecurityDescriptorW( + descriptor, + SDDL_REVISION_1, + information, + &mut text, + ptr::null_mut(), + ) + }; + if converted == 0 { + return Err(io::Error::last_os_error()); + } + Ok(take_wide(text)) +} + +#[test] +fn vm_directories_must_be_owned_protected_and_allow_only_the_current_user_full_inherited_access() { + use windows_sys::Win32::Foundation::LocalFree; + use windows_sys::Win32::Security::Authorization::{ + ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1, + }; + + let user = CurrentUser::get().unwrap(); + let problem = |sddl: &str| { + let text = wide(sddl); + let mut descriptor = ptr::null_mut(); + // SAFETY: the text is terminated and the output pointer is valid. + let converted = unsafe { + ConvertStringSecurityDescriptorToSecurityDescriptorW( + text.as_ptr(), + SDDL_REVISION_1, + &mut descriptor, + ptr::null_mut(), + ) + }; + assert_ne!(converted, 0, "{sddl}: {}", io::Error::last_os_error()); + let problem = vm_dir_problem(descriptor, &user).unwrap(); + // SAFETY: the conversion allocated this descriptor, which is no longer used. + unsafe { LocalFree(descriptor) }; + problem + }; + let sid = user_sid(); + let full = format!("(A;OICI;FA;;;{sid})"); + for private in [ + format!("O:{sid}D:P{full}"), + format!("O:{sid}D:PAI{full}"), + format!("O:{sid}D:P(A;OICI;GA;;;{sid})"), + format!("O:{sid}D:P(A;OICIIO;GR;;;{sid}){full}(A;;FR;;;{sid})"), + ] { + assert_eq!(problem(&private), None, "{private}"); + } + let others = "allows others than the current user"; + let partial = "does not allow the current user full access that the files and directories created in it \ + inherit"; + for (shared, expected) in [ + (format!("O:SYD:P{full}"), "belongs to another user"), + (format!("O:BAD:P{full}"), "belongs to another user"), + (format!("O:{sid}"), "allows every user"), + (format!("O:{sid}D:NO_ACCESS_CONTROL"), "allows every user"), + ( + format!("O:{sid}D:{full}"), + "inherits access control entries from its parent", + ), + ( + format!("O:{sid}D:AI{full}"), + "inherits access control entries from its parent", + ), + (format!("O:{sid}D:P{full}(A;OICI;FA;;;SY)"), others), + (format!("O:{sid}D:P{full}(A;OICI;FA;;;BA)"), others), + (format!("O:{sid}D:P{full}(A;OICIIO;FA;;;CO)"), others), + (format!("O:{sid}D:P{full}(A;;RC;;;OW)"), others), + (format!("O:{sid}D:P{full}(A;;FR;;;WD)"), others), + ( + format!("O:{sid}D:P(D;;WD;;;{sid}){full}"), + "has an access control entry of type 1, not one that allows access", + ), + (format!("O:{sid}D:P"), partial), + // GENERIC_READ's numeric value exceeds FILE_ALL_ACCESS but grants none of its rights. + (format!("O:{sid}D:P(A;OICI;GR;;;{sid})"), partial), + (format!("O:{sid}D:P(A;OICI;0x1f01fe;;;{sid})"), partial), + (format!("O:{sid}D:P(A;;FA;;;{sid})"), partial), + (format!("O:{sid}D:P(A;OI;FA;;;{sid})"), partial), + (format!("O:{sid}D:P(A;CI;FA;;;{sid})"), partial), + (format!("O:{sid}D:P(A;OICIIO;FA;;;{sid})"), partial), + (format!("O:{sid}D:P(A;OICINP;FA;;;{sid})"), partial), + ] { + assert_eq!(problem(&shared).as_deref(), Some(expected), "{shared}"); + } +} diff --git a/nvx-rs/src/host/platform/windows/tests.rs b/nvx-rs/src/host/platform/windows/tests.rs index 971de698a..0f4f9bace 100644 --- a/nvx-rs/src/host/platform/windows/tests.rs +++ b/nvx-rs/src/host/platform/windows/tests.rs @@ -22,6 +22,7 @@ use windows_sys::Win32::System::Threading::{ use super::identity::status_error; use super::log_claim::{local_directory, log_job_id, log_job_name, log_job_processes, object_name}; pub(crate) use super::private::tests::set_access; +pub(crate) use super::private::tests::{access, user_sid}; use super::process::{end_process, wait_step}; use super::spawn::command_line; use super::*; diff --git a/nvx-rs/src/host/vm.rs b/nvx-rs/src/host/vm.rs new file mode 100644 index 000000000..09893b92b --- /dev/null +++ b/nvx-rs/src/host/vm.rs @@ -0,0 +1,217 @@ +//! A caller-owned VM directory, private to the current user, with fixed names for its files and +//! endpoints. The caller may keep files of its own under other names. +//! +//! [`create_private_dir`] creates a directory private to the current user, or checks an existing +//! one, and [`VmDir::open`] opens only one that is private: mode 0700 on Linux, or an owner-only +//! protected access control list on Windows. Neither changes an existing directory, since +//! restricting its permissions would not revoke access through files that others already opened. +//! +//! The library reserves the names documented below, and every name that starts with `.openvmm`, +//! which OpenVMM gives its temporary files. [`Capability`] holds the credential of a VM's control +//! endpoint, which the library never writes to a file. + +use std::fmt; +use std::io; +use std::path::{Path, PathBuf}; + +use crate::host::paths::{absolute, normalized}; +#[cfg(not(windows))] +use crate::host::platform::{check_store_dir as check_dir, create_store_dir as create_dir}; +#[cfg(windows)] +use crate::host::platform::{check_vm_dir as check_dir, create_vm_dir as create_dir}; +use crate::protocol::CAPABILITY_LEN; +use crate::{Error, Result}; + +/// OpenVMM's log of a launch. +const LOG: &str = "openvmm.log"; +/// OpenVMM's outcome report of a launch. +const OUTCOME: &str = "outcome.json"; +/// The control socket on Linux. +const CONTROL: &str = "control.sock"; +/// The guest's boot-console socket on Linux. +const CONSOLE: &str = "console.sock"; +/// The restore-ready socket on Linux. +const RESTORE_READY: &str = "restore-ready.sock"; +/// OpenVMM's snapshot files: a capture's destination and a restore's snapshot view. +#[cfg_attr( + not(test), + expect(dead_code, reason = "SL2.12b and SL2.13 use it (#1102)") +)] +const OPENVMM: &str = "openvmm"; +/// A finished capture's key. +#[cfg_attr( + not(test), + expect(dead_code, reason = "SL2.12b and SL2.14 use it (#1102)") +)] +const KEY: &str = "key.bin"; +/// A finished capture's informational snapshot description. +#[cfg_attr( + not(test), + expect(dead_code, reason = "SL2.12b and SL2.14 use it (#1102)") +)] +const DESCRIPTION: &str = "snapshot.json"; +/// The sparse placeholder image that a capture VM attaches. +#[cfg_attr( + not(test), + expect(dead_code, reason = "SL2.13's capture creates it (#1102)") +)] +const CAPTURE_IMAGE: &str = "capture-image.raw"; +/// The dormant scratch that a capture VM attaches. +#[cfg_attr( + not(test), + expect(dead_code, reason = "SL2.13's capture creates it (#1102)") +)] +const CAPTURE_SCRATCH: &str = "capture-scratch.raw"; +/// The prefix OpenVMM requires of its Windows console pipes. +const OPENVMM_PIPE: &str = "openvmm-microvm"; +/// The prefix of a Windows restore-ready pipe. +const RESTORE_READY_PIPE: &str = "nvx-restore-ready"; + +/// The directory of a VM, which its caller creates and owns, private to the current user. +/// +/// The library reserves the fixed names of its launch and snapshot files and endpoints, and +/// every name that starts with `.openvmm`; the caller may add files under other names. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct VmDir { + /// The directory's absolute path. + path: PathBuf, +} + +impl VmDir { + /// Opens an existing directory private to the current user, as [`create_private_dir`] makes + /// it. A relative path names a directory from the current directory. Changes nothing. + /// + /// Fails with [`ErrorKind::Io`](crate::ErrorKind::Io) if the directory is not private or + /// cannot be read, as when it does not exist. + pub fn open(path: &Path) -> Result { + // A trailing separator or `/.` would make the OS follow a final symbolic link before + // applying the platform's no-follow check. Keep `..` components unresolved. + let path = absolute(&normalized(path)).map_err(|error| io_error("open", path, error))?; + let problem = check_dir(&path).map_err(|error| io_error("open", &path, error))?; + check_privacy(&path, problem)?; + Ok(Self { path }) + } + + /// Returns the directory as an absolute path. + pub fn path(&self) -> &Path { + &self.path + } + + /// Returns a launch's outputs, with a boot-console endpoint if requested. Creates nothing. + #[cfg_attr( + not(test), + expect(dead_code, reason = "SL2.8's vm::create calls it (#1099)") + )] + pub(crate) fn outputs(&self, console: bool) -> Result { + Ok(VmOutputs { + log: self.path.join(LOG), + outcome: self.path.join(OUTCOME), + endpoint: self.endpoint(CONTROL, OPENVMM_PIPE)?, + console: console + .then(|| self.endpoint(CONSOLE, OPENVMM_PIPE)) + .transpose()?, + }) + } + + /// Returns a restore-ready endpoint without listening on it. + #[cfg_attr( + not(test), + expect(dead_code, reason = "SL2.17's ReadyListener calls it (#1099)") + )] + pub(crate) fn restore_ready_endpoint(&self) -> Result { + self.endpoint(RESTORE_READY, RESTORE_READY_PIPE) + } + + /// Returns the Linux socket `name` or a fresh Windows pipe with the prefix `pipe`. + fn endpoint(&self, name: &str, pipe: &str) -> Result { + if cfg!(windows) { + let mut bytes = [0u8; 16]; + getrandom::fill(&mut bytes) + .map_err(|error| Error::io("cannot draw the name of a pipe").with_source(error))?; + let suffix: String = bytes.iter().map(|byte| format!("{byte:02x}")).collect(); + return Ok(format!("//./pipe/{pipe}-{suffix}")); + } + self.path + .join(name) + .into_os_string() + .into_string() + .map_err(|_| { + Error::unsupported(format!( + "the VM directory {} is not valid Unicode, which OpenVMM's endpoints must be", + self.path.display() + )) + }) + } +} + +/// Creates a VM directory private to the current user, or checks an existing one without +/// changing it. The parent must exist. +/// +/// On Linux, a new directory has mode 0700, whatever the umask and its parent's setgid bit. On +/// Windows, it belongs to the user, and its protected access control list allows the user alone +/// full access, which the files and directories created in it inherit. +/// +/// Fails with [`ErrorKind::Io`](crate::ErrorKind::Io) if an existing directory is not private or +/// the directory cannot be created or read. +pub fn create_private_dir(path: &Path) -> Result<()> { + let path = normalized(path); + let problem = create_dir(&path).map_err(|error| io_error("create", &path, error))?; + check_privacy(&path, problem) +} + +/// A launch's output files and endpoints. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct VmOutputs { + /// OpenVMM's log, `openvmm.log` in the VM directory. + pub log: PathBuf, + /// OpenVMM's outcome report, `outcome.json` in the VM directory. + pub outcome: PathBuf, + /// The control endpoint: `control.sock` in the VM directory on Linux, or on Windows a fresh + /// named pipe, `//./pipe/openvmm-microvm-` and 32 random lowercase hexadecimal digits. + pub endpoint: String, + /// The boot-console endpoint, if requested: `console.sock` in the VM directory on Linux, or + /// on Windows another fresh `//./pipe/openvmm-microvm-` pipe. + pub console: Option, +} + +/// The credential that the host presents at a VM's control endpoint. +/// +/// The library never writes it to a file, and its `Debug` form hides it. A caller that must +/// reconnect after a restart keeps the bytes that [`as_bytes`](Self::as_bytes) returns. +pub struct Capability([u8; CAPABILITY_LEN]); + +impl Capability { + /// Returns the credential's bytes. + pub fn as_bytes(&self) -> &[u8; CAPABILITY_LEN] { + &self.0 + } +} + +impl fmt::Debug for Capability { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.debug_struct("Capability").finish_non_exhaustive() + } +} + +/// Retains the host's I/O failure, or classifies an unsupported host as unavailable. +fn io_error(action: &str, path: &Path, error: io::Error) -> Error { + let message = format!("cannot {action} the VM directory {}", path.display()); + if error.kind() == io::ErrorKind::Unsupported { + Error::unavailable(message).with_source(error) + } else { + Error::io(message).with_source(error) + } +} + +/// Reports why an existing VM directory is not private without changing its permissions. +fn check_privacy(path: &Path, problem: Option) -> Result<()> { + problem.map_or(Ok(()), |problem| { + Err(Error::io(format!( + "the VM directory {} {problem}", + path.display() + ))) + }) +} + +#[cfg(test)] +mod tests; diff --git a/nvx-rs/src/host/vm/tests.rs b/nvx-rs/src/host/vm/tests.rs new file mode 100644 index 000000000..c8ed0d350 --- /dev/null +++ b/nvx-rs/src/host/vm/tests.rs @@ -0,0 +1,401 @@ +use std::fs; + +use super::*; +use crate::ErrorKind; + +fn is_fresh_pipe(endpoint: &str, prefix: &str) -> bool { + endpoint + .strip_prefix(&format!("//./pipe/{prefix}-")) + .is_some_and(|digits| { + digits.len() == 32 + && digits + .bytes() + .all(|digit| matches!(digit, b'0'..=b'9' | b'a'..=b'f')) + }) +} + +fn vm_dir() -> (tempfile::TempDir, VmDir) { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("vm"); + create_private_dir(&path).unwrap(); + let vm = VmDir::open(&path).unwrap(); + (directory, vm) +} + +#[cfg(target_os = "linux")] +#[test] +fn a_vm_directory_is_created_with_mode_700_for_the_current_user_whatever_the_umask() { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + use std::thread; + + let directory = tempfile::tempdir().unwrap(); + let mode = |path: &Path| fs::metadata(path).unwrap().permissions().mode() & 0o7777; + let parent = directory.path().join("setgid"); + fs::create_dir(&parent).unwrap(); + fs::set_permissions(&parent, fs::Permissions::from_mode(0o2700)).unwrap(); + let path = parent.join("vm"); + thread::scope(|scope| { + scope + .spawn(|| { + nix::sched::unshare(nix::sched::CloneFlags::CLONE_FS).unwrap(); + nix::sys::stat::umask(nix::sys::stat::Mode::from_bits_truncate(0o277)); + create_private_dir(&path) + }) + .join() + .unwrap() + }) + .unwrap(); + assert_eq!(mode(&path), 0o700); + assert_eq!( + fs::metadata(&path).unwrap().uid(), + nix::unistd::Uid::effective().as_raw() + ); + assert_eq!(VmDir::open(&path).unwrap().path(), path); + create_private_dir(&path).unwrap(); + assert_eq!(mode(&path), 0o700); +} + +#[cfg(target_os = "linux")] +#[test] +fn an_existing_directory_that_is_not_private_is_refused_and_left_unchanged() { + use std::os::unix::fs::{PermissionsExt, symlink}; + + use nix::sys::stat::Mode; + use nix::unistd::Uid; + + use crate::host::platform; + + let directory = tempfile::tempdir().unwrap(); + let mode = |path: &Path| fs::symlink_metadata(path).unwrap().permissions().mode() & 0o7777; + let set_mode = |path: &Path, mode: u32| { + fs::set_permissions(path, fs::Permissions::from_mode(mode)).unwrap(); + }; + let refused = |path: &Path, problem: &str| { + for error in [ + create_private_dir(path).unwrap_err(), + VmDir::open(path).unwrap_err(), + ] { + assert_eq!(error.kind(), ErrorKind::Io, "{error}"); + assert_eq!( + error.to_string(), + format!("the VM directory {} {problem}", path.display()) + ); + } + }; + let path = directory.path().join("vm"); + fs::create_dir(&path).unwrap(); + for shared in [0o750, 0o705, 0o770] { + set_mode(&path, shared); + refused( + &path, + &format!("is accessible to other users (mode {shared:03o})"), + ); + assert_eq!(mode(&path), shared); + } + for other in [0o2700, 0o1700, 0o500] { + set_mode(&path, other); + refused( + &path, + &format!( + "has mode {other:03o}, not the mode 700 that the directory of a control socket \ + must have" + ), + ); + assert_eq!(mode(&path), other); + } + assert_eq!(platform::check_owner_only_dir(&path).unwrap(), None); + assert_eq!( + platform::tests::store_dir_problem(Uid::from_raw(1001), Mode::S_IRWXU, Uid::from_raw(1000)), + Some("belongs to user 1001, not to the current user 1000".to_owned()) + ); + set_mode(&path, 0o700); + let link = directory.path().join("link"); + symlink(&path, &link).unwrap(); + refused(&link, "is not a plain directory"); + let file = directory.path().join("file"); + fs::write(&file, b"").unwrap(); + refused(&file, "is not a plain directory"); +} + +#[cfg(target_os = "linux")] +#[test] +fn trailing_separators_and_current_directory_suffixes_do_not_hide_a_symbolic_link() { + use std::os::unix::fs::{PermissionsExt, symlink}; + + let (directory, vm) = vm_dir(); + let link = directory.path().join("link"); + symlink(vm.path(), &link).unwrap(); + for suffix in ["/", "/.", "/./", "//.//"] { + let suffixed = |path: &Path| { + let mut name = path.as_os_str().to_os_string(); + name.push(suffix); + PathBuf::from(name) + }; + let path = suffixed(&link); + for error in [ + create_private_dir(&path).unwrap_err(), + VmDir::open(&path).unwrap_err(), + ] { + assert_eq!(error.kind(), ErrorKind::Io, "{error}"); + assert_eq!( + error.to_string(), + format!( + "the VM directory {} is not a plain directory", + link.display() + ) + ); + } + let private = suffixed(vm.path()); + create_private_dir(&private).unwrap(); + assert_eq!(VmDir::open(&private).unwrap().path(), vm.path()); + } + assert_eq!( + fs::metadata(vm.path()).unwrap().permissions().mode() & 0o7777, + 0o700 + ); + assert!(fs::symlink_metadata(link).unwrap().file_type().is_symlink()); + assert_eq!(fs::read_dir(vm.path()).unwrap().count(), 0); +} + +#[cfg(windows)] +#[test] +fn a_vm_directory_is_created_owner_only_and_protected_under_a_permissive_parent() { + use crate::host::platform; + + let directory = tempfile::tempdir().unwrap(); + platform::tests::set_access(directory.path(), "D:(A;OICI;FA;;;WD)").unwrap(); + let path = directory.path().join("vm"); + create_private_dir(&path).unwrap(); + let sid = platform::tests::user_sid(); + let private = format!("O:{sid}D:P(A;OICI;FA;;;{sid})"); + assert_eq!(platform::tests::access(&path).unwrap(), private); + assert_eq!(VmDir::open(&path).unwrap().path(), path); + create_private_dir(&path).unwrap(); + assert_eq!(platform::tests::access(&path).unwrap(), private); +} + +#[cfg(windows)] +#[test] +fn existing_vm_directories_that_grant_others_or_inherit_are_refused_and_left_unchanged() { + use crate::host::platform; + + let directory = tempfile::tempdir().unwrap(); + let sid = platform::tests::user_sid(); + let refused = |path: &Path, problem: &str| { + let access = platform::tests::access(path).unwrap(); + for error in [ + create_private_dir(path).unwrap_err(), + VmDir::open(path).unwrap_err(), + ] { + assert_eq!(error.kind(), ErrorKind::Io, "{error}"); + assert_eq!( + error.to_string(), + format!("the VM directory {} {problem}", path.display()) + ); + } + assert_eq!(platform::tests::access(path).unwrap(), access); + }; + let path = directory.path().join("vm"); + create_private_dir(&path).unwrap(); + let full = format!("(A;OICI;FA;;;{sid})"); + for other in ["SY", "BA", "OW"] { + platform::tests::set_access(&path, &format!("D:P{full}(A;OICI;FA;;;{other})")).unwrap(); + assert_eq!(platform::check_store_dir(&path).unwrap(), None, "{other}"); + refused(&path, "allows others than the current user"); + } + platform::tests::set_access(&path, "D:P(A;OICI;FA;;;WD)").unwrap(); + refused(&path, "allows others than the current user"); + for list in [ + format!("D:P(A;OICI;FR;;;{sid})"), + format!("D:P(A;;FA;;;{sid})"), + format!("D:P(A;OICINP;FA;;;{sid})"), + ] { + platform::tests::set_access(&path, &list).unwrap(); + refused( + &path, + "does not allow the current user full access that the files and directories \ + created in it inherit", + ); + } + platform::tests::set_access(&path, &format!("D:P(D;;WO;;;{sid}){full}")).unwrap(); + refused( + &path, + "has an access control entry of type 1, not one that allows access", + ); + platform::tests::set_access(&path, &format!("D:P{full}")).unwrap(); + let inheriting = path.join("inheriting"); + fs::create_dir(&inheriting).unwrap(); + assert_eq!(platform::check_store_dir(&inheriting).unwrap(), None); + let owned = platform::tests::access(&inheriting) + .unwrap() + .starts_with(&format!("O:{sid}D:")); + refused( + &inheriting, + if owned { + "inherits access control entries from its parent" + } else { + "belongs to another user" + }, + ); + let file = directory.path().join("file"); + fs::write(&file, b"").unwrap(); + refused(&file, "is not a plain directory"); +} + +#[test] +fn a_vm_directory_that_cannot_be_opened_or_created_fails_with_the_hosts_error() { + let directory = tempfile::tempdir().unwrap(); + let missing = directory.path().join("missing"); + let relative = Path::new("missing-vm-directory"); + for (error, action, path) in [ + (VmDir::open(&missing).unwrap_err(), "open", missing.clone()), + ( + create_private_dir(&missing.join("vm")).unwrap_err(), + "create", + missing.join("vm"), + ), + ( + VmDir::open(relative).unwrap_err(), + "open", + std::env::current_dir().unwrap().join(relative), + ), + ] { + assert_eq!(error.kind(), ErrorKind::Io, "{error}"); + assert_eq!( + error.to_string(), + format!("cannot {action} the VM directory {}", path.display()) + ); + let source = std::error::Error::source(&error).unwrap(); + let source = source.downcast_ref::().unwrap(); + assert_eq!(source.kind(), io::ErrorKind::NotFound, "{source}"); + } +} + +#[test] +fn outputs_name_the_vm_directorys_files_and_endpoints() { + let (_directory, vm) = vm_dir(); + let outputs = vm.outputs(false).unwrap(); + assert_eq!(outputs.log, vm.path().join("openvmm.log")); + assert_eq!(outputs.outcome, vm.path().join("outcome.json")); + assert_eq!(outputs.console, None); + let with_console = vm.outputs(true).unwrap(); + assert_eq!( + (&with_console.log, &with_console.outcome), + (&outputs.log, &outputs.outcome) + ); + let console = with_console.console.as_deref().unwrap(); + if cfg!(windows) { + for endpoint in [outputs.endpoint.as_str(), &with_console.endpoint, console] { + assert!(is_fresh_pipe(endpoint, "openvmm-microvm"), "{endpoint}"); + } + assert_ne!(outputs.endpoint, with_console.endpoint); + assert_ne!(with_console.endpoint, console); + } else { + let socket = |name: &str| vm.path().join(name).into_os_string().into_string().unwrap(); + assert_eq!(outputs.endpoint, socket("control.sock")); + assert_eq!(with_console.endpoint, outputs.endpoint); + assert_eq!(console, socket("console.sock")); + } +} + +#[cfg(target_os = "linux")] +#[test] +fn the_endpoints_of_a_vm_directory_that_is_not_unicode_are_refused() { + use std::ffi::OsStr; + use std::os::unix::ffi::OsStrExt; + + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join(OsStr::from_bytes(b"vm-\xff")); + create_private_dir(&path).unwrap(); + let vm = VmDir::open(&path).unwrap(); + for error in [ + vm.outputs(false).unwrap_err(), + vm.restore_ready_endpoint().unwrap_err(), + ] { + assert_eq!(error.kind(), ErrorKind::Unsupported, "{error}"); + assert_eq!( + error.to_string(), + format!( + "the VM directory {} is not valid Unicode, which OpenVMM's endpoints must be", + path.display() + ) + ); + } +} + +#[test] +fn a_restore_ready_endpoint_is_the_reserved_socket_or_a_fresh_pipe() { + let (_directory, vm) = vm_dir(); + let first = vm.restore_ready_endpoint().unwrap(); + let second = vm.restore_ready_endpoint().unwrap(); + if cfg!(windows) { + for endpoint in [&first, &second] { + assert!(is_fresh_pipe(endpoint, "nvx-restore-ready"), "{endpoint}"); + } + assert_ne!(first, second); + let error = fs::File::open(first.replace('/', "\\")).unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::NotFound, "{error}"); + } else { + let socket = vm.path().join("restore-ready.sock"); + assert_eq!(first, socket.into_os_string().into_string().unwrap()); + assert_eq!(second, first); + } +} + +#[test] +fn the_reserved_names_are_the_documented_ones_and_leave_the_openvmm_prefix_to_openvmm() { + let names = [ + LOG, + OUTCOME, + CONTROL, + CONSOLE, + RESTORE_READY, + OPENVMM, + KEY, + DESCRIPTION, + CAPTURE_IMAGE, + CAPTURE_SCRATCH, + ]; + assert_eq!( + names, + [ + "openvmm.log", + "outcome.json", + "control.sock", + "console.sock", + "restore-ready.sock", + "openvmm", + "key.bin", + "snapshot.json", + "capture-image.raw", + "capture-scratch.raw", + ] + ); + for name in names { + assert!(!name.starts_with(".openvmm"), "{name}"); + } +} + +#[test] +fn a_capability_gives_its_bytes_and_its_debug_hides_them() { + let bytes: [u8; CAPABILITY_LEN] = std::array::from_fn(|index| u8::try_from(index + 1).unwrap()); + let capability = Capability(bytes); + assert_eq!(capability.as_bytes(), &bytes); + assert_eq!(format!("{capability:?}"), "Capability { .. }"); + assert_eq!(format!("{capability:#?}"), "Capability { .. }"); +} + +#[test] +fn creating_and_opening_a_vm_directory_writes_no_capability_file_or_output() { + let (directory, vm) = vm_dir(); + create_private_dir(vm.path()).unwrap(); + VmDir::open(vm.path()).unwrap(); + vm.outputs(true).unwrap(); + vm.restore_ready_endpoint().unwrap(); + assert_eq!(fs::read_dir(vm.path()).unwrap().count(), 0); + let entries: Vec<_> = fs::read_dir(directory.path()) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .collect(); + assert_eq!(entries, ["vm"]); +} diff --git a/scripts/test-layout-allowlist.json b/scripts/test-layout-allowlist.json index 5f6b123f1..3932bb153 100644 --- a/scripts/test-layout-allowlist.json +++ b/scripts/test-layout-allowlist.json @@ -13,6 +13,7 @@ "nvx-rs/src/host/cli/store/mod.rs": 1, "nvx-rs/src/host/network/address.rs": 2, "nvx-rs/src/host/platform/windows/spawn.rs": 1, - "nvx-rs/src/host/sandbox/plan.rs": 5 + "nvx-rs/src/host/sandbox/plan.rs": 5, + "nvx-rs/src/host/vm.rs": 7 } }