From b93206ab3804792116ac4aae0451aa0f70892e8d Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Thu, 5 Nov 2026 11:10:00 -0800 Subject: [PATCH] host: give each VM a private directory with fixed names Add caller-owned VM directories, fixed output names and redacted control capabilities without creating launch outputs or persisting credentials. Require current-user ownership and mode 0700 on Linux, or a protected owner-only DACL on Windows, without changing existing directories. Use the crate's Result and report a non-private directory as ErrorKind::Io. Normalize trailing separators and dot components before no-follow checks so a final symbolic link is refused, while preserving parent components. Keep the existing store privacy policies unchanged. Compare Windows ACL snapshots using the same canonical SDDL SID spellings as the descriptor serializer, including well-known aliases. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d334d041-d2f2-47db-be92-5564ff14f2d7 --- nvx-rs/README.md | 47 +- nvx-rs/public-api/linux.txt | 12 + nvx-rs/public-api/windows.txt | 12 + nvx-rs/src/host/mod.rs | 3 + nvx-rs/src/host/platform/tests.rs | 2 + nvx-rs/src/host/platform/windows/mod.rs | 5 +- nvx-rs/src/host/platform/windows/private.rs | 123 +++++- .../host/platform/windows/private/tests.rs | 182 ++++++++ nvx-rs/src/host/platform/windows/tests.rs | 1 + nvx-rs/src/host/vm.rs | 217 ++++++++++ nvx-rs/src/host/vm/tests.rs | 401 ++++++++++++++++++ scripts/test-layout-allowlist.json | 3 +- 12 files changed, 998 insertions(+), 10 deletions(-) create mode 100644 nvx-rs/src/host/vm.rs create mode 100644 nvx-rs/src/host/vm/tests.rs 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 } }