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
}
}