diff --git a/nvx-rs/README.md b/nvx-rs/README.md index 7e63dc63c..32c414eb6 100644 --- a/nvx-rs/README.md +++ b/nvx-rs/README.md @@ -47,10 +47,10 @@ nvx-rs/ │ │ sides, without I/O, threads, or features, for any framing and host │ ├── host/ feature `host` (default): artifacts, launch, network and egress policy files, │ │ filesystem, NVXS connection, session, process, platform, VM instances, -│ │ sandbox stores, templates' manifests and compatibility keys, 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 +│ │ sandbox stores, templates' manifests, compatibility keys, and store, 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 │ ├── testing/ feature `testing`: the fake OpenVMM │ ├── guest/ feature `guest`: the programs that run in Linux guests, the guest's NVXS │ │ connection, the libraries through which a guest init or agent opens the @@ -96,7 +96,7 @@ nvx-rs/ | `nvx::host::nvxs` | `HostConnection`: the host's role of the NVXS session over a platform transport, on which `nvx::host::session` and other host applications run their protocols. It attaches with the launch capability, waiting while the broker asks it to, sends application bytes in as many DATA records as they take, and returns each DATA payload from the guest, with deadlines; a receive that times out keeps a partial record | | `nvx::host::process` | Verified process references. A `ProcessRef` names one process by its ID, its start time, and the `BootId` of the host boot during which it started. `Process::open` checks a reference and holds its process through a pidfd on Linux or a process handle on Windows, whether or not the caller started it, so that waits sleep until the process exits and termination never reaches a process that reuses its ID. A reference from an earlier boot names an exited process, and a process whose identity this user cannot check, such as another user's, makes `Process::open` fail rather than report an exit. Linux hosts without pidfds poll the reference instead, and cannot terminate the process | | `nvx::host::supervisor` | The supervisor of a sandbox's VMM, beneath the CLI, `Instance`, and external orchestrators, which keep the configuration, the authorization, and the application protocol. `launch` runs the store's launch steps under the sandbox's lock: it writes the journal's marker, spawns OpenVMM with the claimed log, records its `ProcessRef` right after, and returns a `Starting` launch that the caller commits once the VMM serves its control endpoint, or abandons. A launch that fails never leaves an unrecorded VMM running. `VmmCommand::inherit` lists the descriptors (Linux) or handles (Windows) that the VMM inherits under their numbers, besides its standard streams, and it inherits nothing else of the caller's: on Linux, they must be close-on-exec, a flag that only the VMM's own process clears; on Windows, they join the VMM's explicit handle list. `Vmm::stop` runs one graceful step of the caller's and waits for the VMM's exit event until a deadline, and takes a bounded forced step through the VMM's verified identity only on request. `Vmm::terminate` takes that forced step alone, within the caller's bound, for a VM whose guest the caller must not ask anything, such as one of another runtime identity. `reattach` recovers an interrupted launch, and holds the recorded VMM again after the orchestrator restarts within the same host boot: `Live`, `Mismatched` with another runtime identity, `Exited`, or `Indeterminate` | -| `nvx::host::template` | The metadata of templates, captures of a microVM from which independent clones are restored. `TemplateManifest::read` reads the `manifest.bin` that OpenVMM wrote for a capture, at most `MAX_MANIFEST_LEN` bytes of a regular file that is not a link or other reparse point, and refuses another snapshot format version, an unknown, mistyped, or repeated field, truncated bytes, and a capture that is not a template's: a platform-tier capture for clones, with a read-only `distro` whose identity is unbound, a fresh scratch, and the dormant filesystem slot. `CaptureClass` holds the invariant inputs of a capture, known before it: the runtime, the guest's build, the backend, the requested CPU profile, the processors and RAM, the geometries of the disks, the network, and the adapter's other `Invariants`. `TemplateKey::new` checks that the capture made the machine that its class requested, and derives the template's compatibility key, from metadata alone: the runtime and contract versions through which its clones are restored, and the backend, CPU profile and effective CPUID, topology, rates, resources, kernel command line, disks, and devices of its machine. A capture's own metadata, such as its time anchor and host, stays out of the key. A key and a class each have one canonical, versioned encoding, whose domain-separated SHA-256 digest is its identity, a `KeyIdentity`, which converts to and from the `nvx::activation::Identity` by which a clone's activation record names its template's key, or a `ClassIdentity`; the [conformance vectors](tests/vectors/README.md) of version 1, `tests/vectors/template-key-v1.txt`, pin the key's encoding and identity | +| `nvx::host::template` | The metadata of templates, captures of a microVM from which independent clones are restored. `TemplateManifest::read` reads the `manifest.bin` that OpenVMM wrote for a capture, at most `MAX_MANIFEST_LEN` bytes of a regular file that is not a link or other reparse point, and refuses another snapshot format version, an unknown, mistyped, or repeated field, truncated bytes, and a capture that is not a template's: a platform-tier capture for clones, with a read-only `distro` whose identity is unbound, a fresh scratch, and the dormant filesystem slot. `CaptureClass` holds the invariant inputs of a capture, known before it: the runtime, the guest's build, the backend, the requested CPU profile, the processors and RAM, the geometries of the disks, the network, and the adapter's other `Invariants`. `TemplateKey::new` checks that the capture made the machine that its class requested, and derives the template's compatibility key, from metadata alone: the runtime and contract versions through which its clones are restored, and the backend, CPU profile and effective CPUID, topology, rates, resources, kernel command line, disks, and devices of its machine. A capture's own metadata, such as its time anchor and host, stays out of the key. A key and a class each have one canonical, versioned encoding, whose domain-separated SHA-256 digest is its identity, a `KeyIdentity`, which converts to and from the `nvx::activation::Identity` by which a clone's activation record names its template's key, or a `ClassIdentity`; the [conformance vectors](tests/vectors/README.md) of version 1, `tests/vectors/template-key-v1.txt`, pin the key's encoding and identity. `Store` keeps the generations of templates that a host published, apart from every sandbox's store, at a location that the adapter chooses: a private directory with an identity of its own, a `StoreId`, the immutable payload of each generation, its key's canonical encoding beside the snapshot that OpenVMM published, and the versioned `Index` of them, which lists each generation with the identities of its key and capture class and its own metadata, such as its time contract. `Store::create` creates a store, or opens the one that its directory holds, crash-safely and once between concurrent creations, and refuses a directory with foreign entries; `Store::open` opens one. Its records are JSON in one format, replaced atomically, and records of another format fail rather than being migrated. Its directories must be private to the current user, and it refuses links and, on Windows, other reparse points among its directories and files. Four namespaces stay apart: the `nvx::activation::StoreGeneration`, the host's publication ID, which the index assigns in the order of publication and never twice, and by which a clone's activation record names its template; the bound-block `StorageGeneration` of a capture, which belongs to the storage owner; the time lineage counter of the time contract; and the VM generation ID, which the store never records | | `nvx::host::seal` | `FileSeal`: what the host reports about an open file from its metadata alone, without reading it: the platform scheme, the device or volume serial number, the file ID, the kind, the length, the modification time, and on Linux the change time, with a versioned serde form. A seal detects accidental change, not tampering; see [Distro bindings](#distro-bindings) | | `nvx::host::session`, `platform`, `report` | The authenticated control session, which carries NVX's control protocol, `NVXC`, one frame per DATA record of a `HostConnection`; Linux and Windows primitives; and OpenVMM's outcome report | | `nvx::host::sandbox` | The launch profile of `nvx-rs sandbox`, with native input validation: `SandboxLaunch`, with the distro `SandboxLayer`, an EROFS layer or a GPT disk, the one layer that a sandbox takes, the ext4 scratch disk, the entrypoint, arguments, hostname, workload identity, and `SandboxSettings` resource limits (`Option` for memory and `Option` for processes), and the `SandboxPlan` that it makes of them. `plan`: `SandboxPlan`, the typed plan of a cold layered launch, which is persistable in a versioned serde form: one read-only `distro` block device, an EROFS filesystem or a GPT disk with the identity that the guest expects of it, if any, an upper, the ext4 scratch disk or RAM, the launch settings, the `ExportPlan` of its live shares, and optionally a `SandboxNetwork`, the guest network and its typed `NetworkPolicy`, whose proxy's URL as the guest sees it the plan reports. The plan of an adapter that boots its own guest, `SandboxPlan::adapter`, holds no launch settings and no export plan. A plan knows only the `distro` and `scratch` `BlockRole`s, and gives, for every combination, its `--microvm-sandbox-block` arguments, with `--microvm-workload-identity` when it has launch settings, with no scratch block device for a RAM upper, and those of its network, which `nvx::host::network` lowers, and its kernel command line, with the boot tokens of `nvx::protocol::root`, within the 1024-byte budget that OpenVMM leaves it, refusing one whose root tokens the guest's parser refuses. Both deliver the live shares as the managed lifecycle does: the arguments export them at `nvx::host::filesystem::GUEST_EXPORT`, the command line announces the number of their mappings with `nvx_maps=`, and `mapping_table` gives the ordered table that `start` sends once the guest agent is ready; the CLI's one-shot `run` still renders them as boot tokens. An adapter's plan renders the caller's tokens before those of its root, and refuses tokens that its root or OpenVMM's shares own. `mount`: `SandboxMount` requests of live shares of host directories and files, their guest targets and the reserved ones, their access policies and owner, and the checks of their host paths; `plan_mounts`, which plans them through `nvx::host::filesystem` for the container root, and `boot_shares`, which renders a plan as the OpenVMM arguments and boot tokens of one share or of the aggregate of several, as the one-shot lifecycle delivers them. `options`: the checks that `nvx-rs sandbox` and `nvx-rs run` apply to their options: the attribution of `--mount-deny`, `--mount-allow`, and `--mount-write` to shares, the systemd entrypoint check and the explicit verification of the distro layer, the targets of `nvx-rs run` shares, and the managed exec environment; see [Sandbox input contract](#sandbox-input-contract). `distro`: the `DistroBinding` of a distro layer, an EROFS layer or a GPT disk, which explicit verification makes or a staging caller imports, and its check from metadata alone, which returns the layer open; see [Distro bindings](#distro-bindings). `scratch`: the `ScratchBinding` of a scratch image, its identity, kind, and length, and its check from metadata alone, which claims the image for one sandbox at a time and returns it open. `prepared`: `PreparedLaunch`, the launch of a plan with the checked files of its block devices, which renders the plan's OpenVMM arguments and kernel command line, naming the checked files as `/proc/self/fd/N` on Linux and by their final paths on Windows, and lists the descriptors that the VMM inherits through `VmmCommand::inherit`; see [Checked handoff](#checked-handoff) | diff --git a/nvx-rs/src/host/mod.rs b/nvx-rs/src/host/mod.rs index 6b20a9d90..46c687282 100644 --- a/nvx-rs/src/host/mod.rs +++ b/nvx-rs/src/host/mod.rs @@ -23,7 +23,8 @@ //! - [`template`] holds the metadata of templates, captures from which clones are restored: the //! compatibility key of a template, [`TemplateKey`](template::TemplateKey), which it derives //! from the [`CaptureClass`](template::CaptureClass) of the capture and the capture's -//! [`TemplateManifest`](template::TemplateManifest). +//! [`TemplateManifest`](template::TemplateManifest), and the [`Store`](template::Store) of the +//! generations of templates that a host published. //! - [`session`] speaks NVX's control protocol, `NVXC`, over [`nvxs`], which runs the host's role //! of the NVXS session for it and for any other application protocol, over a platform //! transport. [`platform`] holds the primitives of Linux and Windows hosts. Other hosts compile diff --git a/nvx-rs/src/host/platform/linux.rs b/nvx-rs/src/host/platform/linux.rs index 3448a5563..e00618520 100644 --- a/nvx-rs/src/host/platform/linux.rs +++ b/nvx-rs/src/host/platform/linux.rs @@ -386,21 +386,40 @@ pub fn create_private_dir(path: &Path) -> io::Result<()> { /// changed: others could have placed records in it, or still hold its files open, which a change /// of its mode would not revoke. pub(crate) fn create_store_dir(path: &Path) -> io::Result> { - match DirBuilder::new().mode(STORE_DIR_MODE).create(path) { + create_dir_of_mode(path, STORE_DIR_MODE)?; + check_store_dir(path) +} + +/// Creates the directory `path` with mode 0700, or checks that an existing one is private to the +/// current user, as [`check_owner_only_dir`] does, and returns why it is not, if it is not. +/// +/// A new directory takes the setgid bit of its parent, and loses the bits that the umask clears, +/// even the current user's permission to read it, so its mode is set again. An existing directory +/// is never changed: others could have placed files in it, or still hold them open, which a +/// change of its mode would not revoke. +pub(crate) fn create_owner_only_dir(path: &Path) -> io::Result> { + create_dir_of_mode(path, PRIVATE_DIR_MODE)?; + check_owner_only_dir(path) +} + +/// Creates the directory `path`, whose parent must exist, with exactly the mode `mode`, unless it +/// exists already, which leaves it as it is. +fn create_dir_of_mode(path: &Path, mode: u32) -> io::Result<()> { + match DirBuilder::new().mode(mode).create(path) { Ok(()) => { if let Some(directory) = open_plain_dir(path)? { // A descriptor that only locates the directory cannot change its mode itself, but // its link in /proc names exactly the directory that it opened. fs::set_permissions( Path::new("/proc/self/fd").join(directory.as_raw_fd().to_string()), - Permissions::from_mode(STORE_DIR_MODE), + Permissions::from_mode(mode), )?; } + Ok(()) } - Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {} - Err(error) => return Err(error), + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => Ok(()), + Err(error) => Err(error), } - check_store_dir(path) } /// Returns why the directory `path` cannot hold a sandbox store, if it cannot: unless it is @@ -418,11 +437,40 @@ pub(crate) fn check_store_dir(path: &Path) -> io::Result> { )) } +/// Returns why the directory `path` is not private to the current user, if it is not: unless it +/// is a directory, not a link to one, that belongs to the user and grants no one else any access, +/// which [`create_owner_only_dir`] makes it. +pub(crate) fn check_owner_only_dir(path: &Path) -> io::Result> { + let Some(directory) = open_plain_dir(path)? else { + return Ok(Some("is not a plain directory".to_owned())); + }; + let metadata = directory.metadata()?; + Ok(owner_only_problem( + metadata.uid(), + metadata.mode(), + sys::effective_ids().0, + )) +} + /// Returns why a directory of a sandbox store, which the user `uid` owns with the mode `mode`, is /// not private to the user `euid`, if it is not: it must belong to that user, grant no access to /// anyone else, and have mode 0700, which OpenVMM requires of the directory of its control /// socket. pub(crate) fn store_dir_problem(uid: u32, mode: u32, euid: u32) -> Option { + let mode = mode & PERMISSION_BITS; + owner_only_problem(uid, mode, euid).or_else(|| { + (mode != STORE_DIR_MODE).then(|| { + format!( + "has mode {mode:03o}, not the mode 700 that the directory of a control socket \ + must have" + ) + }) + }) +} + +/// Returns why a directory that the user `uid` owns with the mode `mode` is not private to the +/// user `euid`, if it is not: it must belong to that user, and grant no access to anyone else. +pub(crate) fn owner_only_problem(uid: u32, mode: u32, euid: u32) -> Option { let mode = mode & PERMISSION_BITS; if uid != euid { Some(format!( @@ -430,10 +478,6 @@ pub(crate) fn store_dir_problem(uid: u32, mode: u32, euid: u32) -> Option io::Result> { Err(unsupported()) } +/// Fails: this host cannot check who may access a directory. +pub(crate) fn create_owner_only_dir(_path: &Path) -> io::Result> { + Err(unsupported()) +} + +/// Fails: this host cannot check who may access a directory. +pub(crate) fn check_owner_only_dir(_path: &Path) -> io::Result> { + Err(unsupported()) +} + pub(crate) fn private_file_problem(_file: &File) -> io::Result> { Err(unsupported()) } diff --git a/nvx-rs/src/host/platform/windows.rs b/nvx-rs/src/host/platform/windows.rs index d91244b46..4ac98774a 100644 --- a/nvx-rs/src/host/platform/windows.rs +++ b/nvx-rs/src/host/platform/windows.rs @@ -1000,6 +1000,21 @@ pub(crate) fn check_store_dir(path: &Path) -> io::Result> { store_dir_problem(path, &CurrentUser::get()?) } +/// Creates the directory `path` private to the current user, or checks that an existing one is, +/// as [`create_store_dir`] does for the directory of a sandbox store. +pub(crate) fn create_owner_only_dir(path: &Path) -> io::Result> { + create_store_dir(path) +} + +/// Returns why the directory `path` is not private to the current user, if it is not, as +/// [`check_store_dir`] does for the directory of a sandbox store: unless it is a directory, not a +/// link or any other reparse point, that belongs to the user, or to SYSTEM or the Administrators +/// group, whose access control list allows no one else, and passes entries on to the files +/// created in it. +pub(crate) fn check_owner_only_dir(path: &Path) -> io::Result> { + check_store_dir(path) +} + /// Returns why the directory `path` cannot hold a sandbox store of `user`, if it cannot. fn store_dir_problem(path: &Path, user: &CurrentUser) -> io::Result> { let directory = OpenOptions::new() diff --git a/nvx-rs/src/host/store/files.rs b/nvx-rs/src/host/store/files.rs index 42a0c18a1..1e9a34be6 100644 --- a/nvx-rs/src/host/store/files.rs +++ b/nvx-rs/src/host/store/files.rs @@ -1,4 +1,7 @@ //! The private files of the store: their creation, atomic replacement, reading, and removal. +//! +//! The template store, [`template::Store`](crate::host::template::Store), keeps its own records +//! with the same lock files, atomic replacements, and removals. use std::fs::{self, File, OpenOptions}; use std::io::{self, Read, Write}; @@ -65,7 +68,7 @@ pub(super) fn create_new(path: &Path) -> io::Result { /// may have cleared even the current user's permission to write it, whether this or another call /// created it, so a lock file of the current user that the user can open for writing no more is /// given its mode again. -pub(super) fn open_lock(path: &Path) -> io::Result { +pub(crate) fn open_lock(path: &Path) -> io::Result { let open = || { let mut options = private_options(); options.read(cfg!(windows)).create(true).truncate(false); @@ -133,7 +136,7 @@ pub(super) fn open_lock(path: &Path) -> io::Result { /// The contents go to a fresh temporary file beside `path`, which is flushed to disk and then /// renamed over it, so a reader sees the old contents or the new ones, and a crash leaves at most /// the temporary file. -pub(super) fn write_private(path: &Path, contents: &[u8]) -> Result<()> { +pub(crate) fn write_private(path: &Path, contents: &[u8]) -> Result<()> { let mut suffix = [0u8; 8]; getrandom::fill(&mut suffix) .map_err(|error| Error::other("cannot name a temporary file").with_source(error))?; @@ -198,7 +201,7 @@ pub(super) fn read_private(path: &Path, description: &str) -> Result Result<()> { +pub(crate) fn remove_if_present(path: &Path) -> Result<()> { match fs::remove_file(path) { Err(error) if error.kind() != io::ErrorKind::NotFound => { Err(io_error(format!("cannot remove {}", path.display()))(error)) diff --git a/nvx-rs/src/host/store/mod.rs b/nvx-rs/src/host/store/mod.rs index f6e8b023a..2aac7346f 100644 --- a/nvx-rs/src/host/store/mod.rs +++ b/nvx-rs/src/host/store/mod.rs @@ -211,7 +211,7 @@ //! only while the path still names it, which it checks before each attempt. mod caller; -mod files; +pub(crate) mod files; mod identity; mod lifecycle; mod record; diff --git a/nvx-rs/src/host/template/files.rs b/nvx-rs/src/host/template/files.rs new file mode 100644 index 000000000..dd7ac8c9a --- /dev/null +++ b/nvx-rs/src/host/template/files.rs @@ -0,0 +1,70 @@ +//! The files that the template modules read: regular files that are not links, which they read +//! within a bound. + +use std::fs::{File, OpenOptions}; +use std::io::{self, Read}; +use std::path::Path; + +use crate::host::filesystem; + +/// Returns the error of a file that is a link, or not a regular file. +fn not_regular() -> io::Error { + io::Error::other("it is a link, or not a regular file") +} + +/// Opens the file at `path` for reading, without following a link, or on Windows any other +/// reparse point, if it is a regular file. +/// +/// The open does not wait, as it would on Unix for a FIFO without a writer: a file that is not a +/// regular file is refused before anything is read from it. +pub(super) fn open_regular(path: &Path) -> io::Result { + let mut options = OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + + // Reads of a regular file do not depend on `O_NONBLOCK`. + options + .custom_flags((nix::fcntl::OFlag::O_NOFOLLOW | nix::fcntl::OFlag::O_NONBLOCK).bits()); + } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + + /// `FILE_FLAG_OPEN_REPARSE_POINT`, which opens a link as itself. + const OPEN_REPARSE_POINT: u32 = 0x0020_0000; + options.custom_flags(OPEN_REPARSE_POINT); + } + let file = match options.open(path) { + Ok(file) => file, + // `O_NOFOLLOW` refuses a link, and an open that does not wait refuses a socket. + #[cfg(unix)] + Err(error) + if [nix::errno::Errno::ELOOP, nix::errno::Errno::ENXIO] + .into_iter() + .any(|errno| error.raw_os_error() == Some(errno as i32)) => + { + return Err(not_regular()); + } + Err(error) => return Err(error), + }; + let metadata = file.metadata()?; + if !metadata.file_type().is_file() || filesystem::is_reparse_point(&metadata) { + return Err(not_regular()); + } + Ok(file) +} + +/// Reads `file` to its end, failing if it holds more than `limit` bytes, the most that `what` may +/// hold, without reading more than one byte past the limit. +pub(super) fn read_at_most(file: File, limit: u64, what: &str) -> io::Result> { + let mut bytes = Vec::new(); + file.take(limit + 1).read_to_end(&mut bytes)?; + if bytes.len() as u64 > limit { + return Err(io::Error::other(format!( + "it holds more than the {limit} bytes that {what} may hold" + ))); + } + Ok(bytes) +} diff --git a/nvx-rs/src/host/template/manifest.rs b/nvx-rs/src/host/template/manifest.rs index 1388443e5..28c1c99fa 100644 --- a/nvx-rs/src/host/template/manifest.rs +++ b/nvx-rs/src/host/template/manifest.rs @@ -7,18 +7,17 @@ //! it as authoritative, and so does a template's [compatibility key](super::TemplateKey), which //! projects its machine. [`TemplateManifest`] describes what the reader refuses. -use std::fs::{File, OpenOptions}; -use std::io::{self, Read}; +use std::io; use std::net::{Ipv4Addr, Ipv6Addr}; use std::path::Path; use std::time::{Duration, SystemTime}; use sha2::{Digest, Sha256}; +use super::files; use super::wire::{Field, Message, WireError}; use crate::contract; use crate::error::{Error, ErrorCode, Result}; -use crate::host::filesystem; use crate::host::process::BootId; use crate::hypervisor::Hypervisor; @@ -135,7 +134,7 @@ impl HostClock { } /// Parses the spelling of a clock in a manifest. - fn parse(text: &str) -> Option { + pub(super) fn parse(text: &str) -> Option { match text { "linux-boottime" => Some(Self::LinuxBoottime), "windows-interrupt-time" => Some(Self::WindowsInterruptTime), @@ -707,13 +706,15 @@ impl TemplateManifest { pub fn read(snapshot: &Path) -> Result { let path = snapshot.join(MANIFEST_NAME); let description = format!("the snapshot manifest {}", path.display()); - let bytes = read_bounded(&path).map_err(|error| { - let code = match error.kind() { - io::ErrorKind::NotFound => ErrorCode::NotFound, - _ => ErrorCode::Io, - }; - Error::new(code, format!("cannot read {description}")).with_source(error) - })?; + let bytes = files::open_regular(&path) + .and_then(|file| files::read_at_most(file, MAX_MANIFEST_LEN, "a manifest")) + .map_err(|error| { + let code = match error.kind() { + io::ErrorKind::NotFound => ErrorCode::NotFound, + _ => ErrorCode::Io, + }; + Error::new(code, format!("cannot read {description}")).with_source(error) + })?; Self::parse_described(&bytes, &description) } @@ -836,62 +837,6 @@ impl TemplateManifest { } } -/// Opens the file at `path` without following a link, or on Windows any other reparse point, and -/// reads it whole, if it is a regular file of at most [`MAX_MANIFEST_LEN`] bytes. -fn read_bounded(path: &Path) -> io::Result> { - let not_regular = || io::Error::other("it is a link, or not a regular file"); - let mut options = OpenOptions::new(); - options.read(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - - // A FIFO is refused below, before anything is read from it, so the open must not wait for - // a writer. - options - .custom_flags((nix::fcntl::OFlag::O_NOFOLLOW | nix::fcntl::OFlag::O_NONBLOCK).bits()); - } - #[cfg(windows)] - { - use std::os::windows::fs::OpenOptionsExt; - - /// `FILE_FLAG_OPEN_REPARSE_POINT`, which opens a link as itself. - const OPEN_REPARSE_POINT: u32 = 0x0020_0000; - options.custom_flags(OPEN_REPARSE_POINT); - } - let file = match options.open(path) { - Ok(file) => file, - // `O_NOFOLLOW` refuses a link, and an open that does not wait refuses a socket. - #[cfg(unix)] - Err(error) - if [nix::errno::Errno::ELOOP, nix::errno::Errno::ENXIO] - .into_iter() - .any(|errno| error.raw_os_error() == Some(errno as i32)) => - { - return Err(not_regular()); - } - Err(error) => return Err(error), - }; - let metadata = file.metadata()?; - if !metadata.file_type().is_file() || filesystem::is_reparse_point(&metadata) { - return Err(not_regular()); - } - read_at_most(file, MAX_MANIFEST_LEN) -} - -/// Reads `file` to its end, failing if it holds more than `limit` bytes, without reading more -/// than one byte past the limit. -fn read_at_most(file: File, limit: u64) -> io::Result> { - let mut bytes = Vec::new(); - file.take(limit + 1).read_to_end(&mut bytes)?; - if bytes.len() as u64 > limit { - return Err(io::Error::other(format!( - "it holds more than the {limit} bytes that a manifest may hold" - ))); - } - Ok(bytes) -} - /// Why a manifest is refused: one of another format, or one that is not a template's. #[derive(Debug, Clone, PartialEq, Eq)] enum Problem { diff --git a/nvx-rs/src/host/template/mod.rs b/nvx-rs/src/host/template/mod.rs index 320462985..c7eceaa42 100644 --- a/nvx-rs/src/host/template/mod.rs +++ b/nvx-rs/src/host/template/mod.rs @@ -1,5 +1,6 @@ -//! Templates: captures of a microVM from which independent clones are restored, and the metadata -//! that decides which clones a template can serve, and on which hosts. +//! Templates: captures of a microVM from which independent clones are restored, the metadata +//! that decides which clones a template can serve, and on which hosts, and the store that keeps +//! them. //! //! A template is a snapshot that OpenVMM captured at its platform tier, after it consumed the //! invariant configuration of a microVM alone, for clones: a VM booted with a placeholder @@ -15,6 +16,10 @@ //! contract, and refuses one that is not a template's. //! - [`TemplateKey::new`] derives the key of a template from its class and its manifest, after it //! checks that the capture made the machine that its class requested. +//! - [`Store`] holds the generations of templates that a host published, apart from every +//! sandbox's store: their payloads, which never change, and the versioned [`Index`] of them, +//! which names each by its [`StoreGeneration`](crate::activation::StoreGeneration), at a +//! location that the adapter chooses. //! //! # Canonical encoding //! @@ -36,8 +41,10 @@ //! `tests/vectors/template-key-v1.txt` fix version 1 of a key's encoding. mod canonical; +mod files; mod key; mod manifest; +mod store; #[cfg(test)] mod test_support; mod wire; @@ -46,3 +53,7 @@ pub use self::key::{CaptureClass, ClassIdentity, Invariants, KeyIdentity, Templa pub use self::manifest::{ BlockGeometry, HostClock, MANIFEST_NAME, MAX_MANIFEST_LEN, TemplateManifest, TimeContract, }; +pub use self::store::{ + GENERATIONS_NAME, Generation, INDEX_LOCK_NAME, INDEX_NAME, Index, KEY_NAME, MAX_GENERATIONS, + SNAPSHOT_NAME, STORE_FORMAT, STORE_RECORD_NAME, StorageGeneration, Store, StoreId, +}; diff --git a/nvx-rs/src/host/template/store.rs b/nvx-rs/src/host/template/store.rs new file mode 100644 index 000000000..4272e0ac4 --- /dev/null +++ b/nvx-rs/src/host/template/store.rs @@ -0,0 +1,1090 @@ +//! The store of templates, [`Store`]: its directory, its records, and the namespaces of its +//! generations. + +#[cfg(test)] +mod tests; + +use std::fmt; +use std::fs::{self, File, TryLockError}; +use std::io; +use std::num::NonZeroU64; +use std::path::{Path, PathBuf}; +use std::str::FromStr; +use std::thread; +use std::time::{Duration, Instant, SystemTime}; + +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; + +use super::files::{open_regular, read_at_most}; +use super::key::{ClassIdentity, KeyIdentity, TemplateKey}; +use super::manifest::{HostClock, TimeContract}; +use crate::activation::StoreGeneration; +use crate::error::{Error, ErrorCode, ErrorKind, Result}; +use crate::host::platform::{self, parse_hex_id}; +use crate::host::process::BootId; +use crate::host::store::files::{open_lock, write_private}; + +/// Version of the store's records: its identity record and its index. +pub const STORE_FORMAT: u32 = 1; +/// Name of the store's identity record. +pub const STORE_RECORD_NAME: &str = "store.json"; +/// Name of the store's index. +pub const INDEX_NAME: &str = "index.json"; +/// Name of the lock of the store's index. +pub const INDEX_LOCK_NAME: &str = "index.lock"; +/// Name of the directory of the generations' payloads. +pub const GENERATIONS_NAME: &str = "generations"; +/// Name of the canonical encoding of a generation's key in its payload. +pub const KEY_NAME: &str = "key.bin"; +/// Name of a generation's snapshot in its payload. +pub const SNAPSHOT_NAME: &str = "snapshot"; +/// Most generations that a store holds. +pub const MAX_GENERATIONS: usize = 1024; + +/// The store generation that a new store assigns first. +const FIRST_GENERATION: StoreGeneration = StoreGeneration::new(NonZeroU64::MIN); + +/// Longest identity record, in bytes. +const MAX_STORE_RECORD_LEN: u64 = 4 * 1024; + +/// Longest index, in bytes: more than an index of [`MAX_GENERATIONS`] generations takes. +const MAX_INDEX_LEN: u64 = 4 * 1024 * 1024; + +/// Interval at which a lock that another holder has is tried again, by a caller that waits for it +/// until a deadline. +const LOCK_RETRY_INTERVAL: Duration = Duration::from_millis(25); + +/// Bytes of a [`StoreId`] and of a [`StorageGeneration`]. +const ID_LEN: usize = 16; + +/// Returns `bytes` as lowercase hexadecimal digits. +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + +/// Draws 16 random bytes, for `what`. +fn random_id(what: &str) -> Result<[u8; ID_LEN]> { + let mut bytes = [0; ID_LEN]; + getrandom::fill(&mut bytes) + .map_err(|error| Error::other(format!("cannot draw {what}")).with_source(error))?; + Ok(bytes) +} + +/// The identity of a store: 16 random bytes that its creation draws. +/// +/// Its text form, which `Display` writes and `FromStr` parses, is 32 lowercase hexadecimal +/// digits; parsing also accepts uppercase ones. +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +pub struct StoreId([u8; ID_LEN]); + +impl StoreId { + /// Returns the identity whose bytes are `bytes`. + pub const fn from_bytes(bytes: [u8; ID_LEN]) -> Self { + Self(bytes) + } + + /// Returns the bytes of the identity. + pub const fn as_bytes(&self) -> &[u8; ID_LEN] { + &self.0 + } +} + +impl fmt::Display for StoreId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&hex(&self.0)) + } +} + +impl fmt::Debug for StoreId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "StoreId({self})") + } +} + +impl FromStr for StoreId { + type Err = Error; + + /// Parses the text form of a store's identity, failing with + /// [`ErrorKind::Unsupported`] for anything else. + fn from_str(text: &str) -> Result { + parse_hex_id(text).map(Self).ok_or_else(|| { + Error::unsupported(format!( + "{text:?} is not a template store's identity, which is 32 hexadecimal digits" + )) + }) + } +} + +/// The bound-block storage generation of a capture: 16 bytes, not all zero, that a capture gives +/// OpenVMM for every block that it binds, which the storage owner keeps immutable for those +/// blocks, so that neither the capture nor a restore reads a block to identify it. +/// +/// It belongs to the storage owner, and is neither a [store generation](StoreGeneration), nor a +/// time lineage counter, nor a VM generation ID; see +/// [the four namespaces](Store#four-namespaces). Generations may share one. Its text form, which +/// `Display` writes and `FromStr` parses, is 32 lowercase hexadecimal digits, as OpenVMM's +/// `--snapshot-generation-id` takes it; parsing also accepts uppercase ones. +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +pub struct StorageGeneration([u8; ID_LEN]); + +impl StorageGeneration { + /// Draws a fresh storage generation at random. + pub fn generate() -> Result { + loop { + if let Some(generation) = Self::from_bytes(random_id("a storage generation")?) { + return Ok(generation); + } + } + } + + /// Returns the storage generation whose bytes are `bytes`, unless they are all zero, which no + /// storage generation is. + pub fn from_bytes(bytes: [u8; ID_LEN]) -> Option { + (bytes != [0; ID_LEN]).then_some(Self(bytes)) + } + + /// Returns the bytes of the storage generation. + pub const fn as_bytes(&self) -> &[u8; ID_LEN] { + &self.0 + } +} + +impl fmt::Display for StorageGeneration { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&hex(&self.0)) + } +} + +impl fmt::Debug for StorageGeneration { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "StorageGeneration({self})") + } +} + +impl FromStr for StorageGeneration { + type Err = Error; + + /// Parses the text form of a storage generation, failing with [`ErrorKind::Unsupported`] for + /// anything else, all zeros included. + fn from_str(text: &str) -> Result { + parse_hex_id(text) + .and_then(Self::from_bytes) + .ok_or_else(|| { + Error::unsupported(format!( + "{text:?} is not a storage generation, which is 32 hexadecimal digits, not \ + all zero" + )) + }) + } +} + +/// One generation of a template that a store holds, as its index lists it: its store generation, +/// the identities of its key and of its capture class, its storage generation, and what its +/// manifest records of the capture itself, in which the generations of one key differ. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Generation { + /// Its store generation. + generation: StoreGeneration, + /// The identity of its key. + key: KeyIdentity, + /// The identity of its capture class. + class: ClassIdentity, + /// The storage generation of its capture. + storage: StorageGeneration, + /// When OpenVMM created its snapshot. + created_at: SystemTime, + /// The time contract of its capture. + time: TimeContract, + /// The length of its saved state, in bytes. + state_len: u64, + /// The length of its RAM, in bytes. + memory_len: u64, +} + +impl Generation { + /// Returns the store generation, which names the generation in its store. + pub fn generation(&self) -> StoreGeneration { + self.generation + } + + /// Returns the identity of the generation's compatibility key, which + /// [`Store::read_key`] reads in full. + pub fn key(&self) -> KeyIdentity { + self.key + } + + /// Returns the identity of the class of the capture that made the generation. + pub fn class(&self) -> ClassIdentity { + self.class + } + + /// Returns the storage generation of the capture that made the generation. + pub fn storage_generation(&self) -> StorageGeneration { + self.storage + } + + /// Returns when OpenVMM created the generation's snapshot. + pub fn created_at(&self) -> SystemTime { + self.created_at + } + + /// Returns the time contract of the capture, with its anchor, host, boot, and time lineage + /// counter. + pub fn time(&self) -> &TimeContract { + &self.time + } + + /// Returns the length of the snapshot's saved state, in bytes. + pub fn state_len(&self) -> u64 { + self.state_len + } + + /// Returns the length of the snapshot's RAM, in bytes. + pub fn memory_len(&self) -> u64 { + self.memory_len + } +} + +/// The index of a store, as one read found it: its generations, in the order of their +/// publication, and the store generation that its next publication takes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Index { + /// The store generation that the next publication takes. + next: StoreGeneration, + /// The generations, in the order of their store generations. + generations: Vec, +} + +impl Index { + /// Returns the generations, in the order of their publication. + pub fn generations(&self) -> &[Generation] { + &self.generations + } + + /// Returns the generation of store generation `generation`, if the index lists it. + pub fn get(&self, generation: StoreGeneration) -> Option<&Generation> { + self.generations + .binary_search_by_key(&generation, Generation::generation) + .ok() + .map(|index| &self.generations[index]) + } + + /// Returns the generations whose key has the identity `key`, in the order of their + /// publication. + pub fn of_key(&self, key: KeyIdentity) -> impl Iterator { + self.generations + .iter() + .filter(move |generation| generation.key == key) + } + + /// Returns the store generation that the store's next publication takes: one past every store + /// generation that it ever assigned. + pub fn next_generation(&self) -> StoreGeneration { + self.next + } +} + +/// A store of templates: a directory that the adapter chooses, apart from every sandbox's +/// store, with the generations of templates that its host published, and their index. +/// +/// ```text +/// / the store, private to the current user +/// store.json the store's identity record, which its creation writes last +/// index.json the index of the generations that the store holds +/// index.lock the lock that serializes the transactions of the index +/// generations/ the payloads of the generations, which never change once indexed +/// / the payload of the store generation G +/// key.bin the canonical encoding of its compatibility key +/// snapshot/ its snapshot, as OpenVMM published it +/// ``` +/// +/// # Identity +/// +/// A store has an identity of its own, a [`StoreId`] that its creation draws, which its identity +/// record and its index both hold, so that the index of another store is refused. +/// [`Store::create`] creates a store, or opens the one that its directory already holds, and +/// [`Store::open`] opens an existing one. +/// +/// # The index +/// +/// The index lists the store's generations, in the order of their publication, with the metadata +/// of each that restores and their eligibility check without reading its payload: the identities +/// of its key and of its capture class, its storage generation, and what its manifest records of +/// the capture itself, its creation time, its time contract, and the lengths of its artifacts. +/// The payload of a generation never changes once the index lists it: the canonical encoding of +/// its key, which [`Store::read_key`] reads, and its snapshot. Only the index changes, as +/// generations are published and removed. +/// +/// The store's records, its identity record and its index, are JSON in format [`STORE_FORMAT`]. +/// A record in any other format fails with a message that names its format, and is neither read +/// nor migrated: a store of another format is replaced by a new one, whose templates are captured +/// again. Records are replaced atomically: written to a temporary file beside them, flushed to +/// disk, and renamed over them. +/// +/// # Four namespaces +/// +/// Four numbers name generations of different things, and the store keeps them apart: +/// +/// - The store generation, [`StoreGeneration`], is the host's publication ID: the store assigns +/// one to each generation as it indexes it, from 1 on, in the order of publication, and never +/// assigns one twice, even once its generation is gone. A clone's activation record names the +/// generation that it restores by its store generation and the identity of its key, in its +/// [`TemplateReference`](crate::activation::TemplateReference). +/// - The storage generation, [`StorageGeneration`], is the bound-block storage generation that a +/// capture gives OpenVMM, which the storage owner keeps immutable for the blocks that it binds. +/// It belongs to the storage owner, not to the store, and generations may share one. +/// - The time lineage counter, [`TimeContract::lineage`], is the generation counter of the +/// captured VM process, which each restore of a snapshot increments, so that the clones of one +/// generation share it. +/// - The VM generation ID is 16 random bytes that each VM process draws, which tell the clones of +/// one generation apart. Their guests read it, and the store records none. +/// +/// # Privacy +/// +/// The store's directories are private to the current user, the orchestrator's account, so that +/// no one else may write its records and payloads: each belongs to the user, and no one else may +/// access it. On Unix, a directory grants the group and others no permission, and those that the +/// store creates have mode 0700. On Windows, a directory that the store creates allows the user +/// alone, through a protected access control list that its files inherit; one that it did not +/// create may also allow SYSTEM and the Administrators group, as one under the user's profile +/// does, and must pass entries on to the files created in it. The store refuses a record whose +/// own list allows anyone else. A directory or a file of the store that is a link, or on Windows +/// any other reparse point, is refused, and so is a record that is not a regular file. The store +/// names its files by their paths, so the directories above it must be ones that only the user, +/// or an administrator, may change. +/// +/// # Example +/// +/// ```no_run +/// use nvx::host::template::Store; +/// +/// # fn example() -> nvx::Result<()> { +/// let store = Store::create("/var/lib/example/templates")?; +/// for generation in store.index()?.generations() { +/// let key = store.read_key(generation)?; +/// println!( +/// "generation {} of key {} on {}", +/// generation.generation(), +/// generation.key(), +/// key.hypervisor() +/// ); +/// } +/// # Ok(()) +/// # } +/// ``` +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Store { + /// The store's directory, as an absolute path. + dir: PathBuf, + /// The store's identity. + id: StoreId, +} + +impl Store { + /// Creates the directory `dir` if needed, whose parent must exist, private to the current + /// user, and a store in it, or opens the store that it holds. + /// + /// A directory that holds no store must hold nothing but what an interrupted creation leaves: + /// one with any other entry fails with [`ErrorCode::ForeignFiles`]. The creation writes the + /// store's identity record last, so a store whose creation is interrupted is none, and a + /// later creation replaces it. Creations of one store take its lock, so that they make one + /// store between them. An existing directory must already be private, as [`open`](Self::open) + /// requires: one that others could access is refused rather than restricted, since they could + /// hold its files open. + /// + /// A relative `dir` is resolved against the current directory. Fails with + /// [`ErrorKind::NotFound`] if the parent does not exist. + pub fn create(dir: impl Into) -> Result { + Self::create_racing(absolute(dir.into())?, || {}) + } + + /// Creates the store as [`create`](Self::create) does, in the absolute directory `dir`, and + /// runs `raced` once it has found no store there, before it checks the directory's entries, + /// where a concurrent creation may make one. + fn create_racing(dir: PathBuf, raced: impl FnOnce()) -> Result { + let problem = platform::create_owner_only_dir(&dir).map_err(store_error(format!( + "cannot create the template store {}", + dir.display() + )))?; + refuse_problem(&dir, "the template store", problem)?; + // A directory of anything else is refused before its lock file is created, unless a + // concurrent creation made a store in it meanwhile, and again under the lock. + if read_identity(&dir)?.is_none() { + raced(); + if let Err(error) = refuse_foreign_entries(&dir) + && read_identity(&dir)?.is_none() + { + return Err(error); + } + } + let lock = take_lock(&dir, None)?; + if let Some(id) = read_identity(&dir)? { + let store = Self { dir, id }; + store.check_generations_dir()?; + return Ok(store); + } + refuse_foreign_entries(&dir)?; + let generations = dir.join(GENERATIONS_NAME); + let problem = platform::create_owner_only_dir(&generations).map_err(store_error( + format!("cannot create the template store {}", dir.display()), + ))?; + refuse_problem( + &generations, + "the generations of the template store", + problem, + )?; + let store = Self { + dir, + id: StoreId(random_id("a template store's identity")?), + }; + store.write_index(&Index { + next: FIRST_GENERATION, + generations: Vec::new(), + })?; + write_record( + &store.dir.join(STORE_RECORD_NAME), + &StoreRecord { + format: STORE_FORMAT, + store: store.id.to_string(), + }, + "the identity record of the template store", + )?; + drop(lock); + Ok(store) + } + + /// Opens the store in the existing directory `dir`, which a relative path names from the + /// current directory, and which must be private to the current user, as + /// [`create`](Self::create) makes it. + /// + /// Fails with [`ErrorKind::NotFound`] if `dir` does not exist, or holds no store, and with + /// [`ErrorCode::FormatUnsupported`] if it holds a store of another format. + pub fn open(dir: impl Into) -> Result { + let dir = absolute(dir.into())?; + let problem = platform::check_owner_only_dir(&dir).map_err(store_error(format!( + "cannot open the template store {}", + dir.display() + )))?; + refuse_problem(&dir, "the template store", problem)?; + let Some(id) = read_identity(&dir)? else { + return Err(Error::new( + ErrorKind::NotFound, + format!("the directory {} holds no template store", dir.display()), + )); + }; + let store = Self { dir, id }; + store.check_generations_dir()?; + Ok(store) + } + + /// Returns the store's directory, as an absolute path. + pub fn dir(&self) -> &Path { + &self.dir + } + + /// Returns the store's identity. + pub fn id(&self) -> StoreId { + self.id + } + + /// Reads the store's index. + /// + /// A read that holds no lock sees one whole index, as the latest transaction left it. + /// Fails with [`ErrorCode::FormatUnsupported`] for an index of another format, and with + /// [`ErrorKind::Io`] for one that cannot be read, is malformed, or belongs to another store. + pub fn index(&self) -> Result { + let path = self.dir.join(INDEX_NAME); + let description = "index of the template store"; + let Some(record) = read_record::(&path, description, MAX_INDEX_LEN)? else { + return Err(Error::io(format!( + "the template store {} has no index", + self.dir.display() + ))); + }; + let malformed = |problem: String| { + Error::io(format!( + "the {description} {} is malformed: {problem}", + path.display() + )) + }; + if record.store != self.id.to_string() { + return Err(Error::io(format!( + "the {description} {} belongs to the template store {}, not to this one, {}", + path.display(), + record.store, + self.id + ))); + } + record.into_index().map_err(malformed) + } + + /// Returns the path of the payload of the store generation `generation`. + pub fn payload_dir(&self, generation: StoreGeneration) -> PathBuf { + self.dir.join(GENERATIONS_NAME).join(generation.to_string()) + } + + /// Checks the payload of `generation`, and returns the path of its snapshot, which OpenVMM + /// restores. + /// + /// The directories of the generations, of the payload, and of the snapshot must each be + /// private to the current user, and none of them a link, or on Windows any other reparse + /// point. Fails with [`ErrorKind::NotFound`] if the payload or its snapshot is missing. + pub fn snapshot_dir(&self, generation: &Generation) -> Result { + let payload = self.payload_dir(generation.generation); + let snapshot = payload.join(SNAPSHOT_NAME); + self.check_generations_dir()?; + for (path, what) in [ + (&payload, "the payload of the generation"), + (&snapshot, "the snapshot of the generation"), + ] { + let problem = platform::check_owner_only_dir(path).map_err(store_error(format!( + "cannot open {what} {}", + path.display() + )))?; + refuse_problem(path, what, problem)?; + } + Ok(snapshot) + } + + /// Reads the compatibility key of `generation` from its payload. + /// + /// The key is read as it was written when the generation was indexed, without computing its + /// identity again. Fails with [`ErrorKind::NotFound`] if the payload or its key is missing, + /// and as [`TemplateKey::from_bytes`] does for a key that is not the canonical encoding of + /// one. + pub fn read_key(&self, generation: &Generation) -> Result { + let payload = self.payload_dir(generation.generation); + self.check_generations_dir()?; + let what = "the payload of the generation"; + let problem = platform::check_owner_only_dir(&payload).map_err(store_error(format!( + "cannot open {what} {}", + payload.display() + )))?; + refuse_problem(&payload, what, problem)?; + let path = payload.join(KEY_NAME); + let description = "template key"; + let bytes = + read_private(&path, description, TemplateKey::MAX_LEN as u64)?.ok_or_else(|| { + Error::new( + ErrorKind::NotFound, + format!("the {description} {} is missing", path.display()), + ) + })?; + TemplateKey::from_bytes(&bytes).map_err(|error| { + Error::new( + error.code(), + format!("cannot read {}: {}", path.display(), error.message()), + ) + }) + } + + /// Checks that the directory of the generations is private to the current user, and not a + /// link. + fn check_generations_dir(&self) -> Result<()> { + let path = self.dir.join(GENERATIONS_NAME); + let what = "the generations of the template store"; + let problem = platform::check_owner_only_dir(&path).map_err(store_error(format!( + "cannot open {what} {}", + path.display() + )))?; + refuse_problem(&path, what, problem) + } + + /// Atomically replaces the store's index with `index`. + fn write_index(&self, index: &Index) -> Result<()> { + write_record( + &self.dir.join(INDEX_NAME), + &IndexRecord::new(self.id, index), + "the index of the template store", + ) + } +} + +/// Returns `dir` as an absolute path, which a relative one names from the current directory, +/// without trailing separators: Linux follows a link at a name that a separator ends, even +/// without following links at the final name, so the checks of the store's directory refuse a +/// link at its own name only without them. +fn absolute(dir: PathBuf) -> Result { + std::path::absolute(&dir) + .map(|absolute| absolute.components().collect()) + .map_err(|error| { + Error::io(format!( + "cannot resolve the template store {}", + dir.display() + )) + .with_source(error) + }) +} + +/// Builds an error mapper that keeps an I/O source under `context`, as an error of +/// [`ErrorKind::NotFound`] if the source says that a path does not exist, and of +/// [`ErrorKind::Io`] otherwise. +fn store_error(context: String) -> impl FnOnce(io::Error) -> Error { + move |error| { + let kind = match error.kind() { + io::ErrorKind::NotFound => ErrorKind::NotFound, + _ => ErrorKind::Io, + }; + Error::new(kind, context).with_source(error) + } +} + +/// Fails unless `problem`, why the directory `path`, which `what` names, is not private to the +/// current user, is `None`. +fn refuse_problem(path: &Path, what: &str, problem: Option) -> Result<()> { + match problem { + None => Ok(()), + Some(problem) => Err(Error::io(format!("{what} {} {problem}", path.display()))), + } +} + +/// Returns the names of the entries of the directory `dir`. +fn entries(dir: &Path) -> Result> { + let failed = || store_error(format!("cannot list {}", dir.display())); + fs::read_dir(dir) + .map_err(failed())? + .map(|entry| { + Ok(entry + .map_err(failed())? + .file_name() + .to_string_lossy() + .into_owned()) + }) + .collect() +} + +/// Returns whether `name` is an entry that a creation of a store makes before its identity record: +/// the index, its lock, the directory of the generations, and the temporary files that an +/// interrupted replacement of a record leaves, `.NAME.NUMBER.tmp`. +fn is_creation_leftover(name: &str) -> bool { + [INDEX_NAME, INDEX_LOCK_NAME, GENERATIONS_NAME].contains(&name) + || name + .strip_prefix('.') + .and_then(|name| name.strip_suffix(".tmp")) + .and_then(|name| name.rsplit_once('.')) + .is_some_and(|(file, number)| { + [STORE_RECORD_NAME, INDEX_NAME].contains(&file) + && !number.is_empty() + && number.bytes().all(|byte| byte.is_ascii_digit()) + }) +} + +/// Fails with [`ErrorCode::ForeignFiles`] if the directory `dir`, which holds no store, holds +/// anything but what an interrupted creation of one leaves: any other entry, or any in the +/// directory of the generations, which must be private to the current user, if it exists. +fn refuse_foreign_entries(dir: &Path) -> Result<()> { + let mut foreign = entries(dir)? + .into_iter() + .filter(|name| !is_creation_leftover(name)) + .collect::>(); + let generations = dir.join(GENERATIONS_NAME); + let what = "the generations of the template store"; + match platform::check_owner_only_dir(&generations) { + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + checked => { + let problem = checked.map_err(store_error(format!( + "cannot open {what} {}", + generations.display() + )))?; + refuse_problem(&generations, what, problem)?; + foreign.extend( + entries(&generations)? + .into_iter() + .map(|name| format!("{GENERATIONS_NAME}/{name}")), + ); + } + } + if foreign.is_empty() { + return Ok(()); + } + foreign.sort(); + Err(Error::new( + ErrorCode::ForeignFiles, + format!( + "the directory {} holds no template store, but entries that are not a store's: {}", + dir.display(), + foreign.join(", ") + ), + )) +} + +/// Opens the lock file of the store in `dir`, which it creates if needed, takes its lock, waiting +/// for it until `deadline`, or for as long as it takes without one, and checks that the path +/// still names the locked file. +/// +/// The store never removes or replaces its lock file, so one that its path no longer names once +/// it is locked was removed or replaced by someone else, and its lock serializes nothing: the +/// lock is then taken again, after a pause, until the deadline. +fn take_lock(dir: &Path, deadline: Option) -> Result { + let path = dir.join(INDEX_LOCK_NAME); + let failed = |error| store_error(format!("cannot lock {}", path.display()))(error); + let busy = || { + Error::new( + ErrorKind::TimedOut, + format!( + "another transaction of the template store {} is still in progress", + dir.display() + ), + ) + }; + loop { + let file = open_lock(&path).map_err(failed)?; + if let Some(problem) = platform::private_file_problem(&file).map_err(failed)? { + return Err(Error::io(format!( + "the lock of the template store {} {problem}", + path.display() + ))); + } + match deadline { + None => file.lock().map_err(failed)?, + Some(deadline) => loop { + match file.try_lock() { + Ok(()) => break, + Err(TryLockError::WouldBlock) => { + let left = deadline.saturating_duration_since(Instant::now()); + if left.is_zero() { + return Err(busy()); + } + thread::sleep(left.min(LOCK_RETRY_INTERVAL)); + } + Err(TryLockError::Error(error)) => return Err(failed(error)), + } + }, + } + let locked = platform::opened_file_identity(&file).map_err(failed)?; + match platform::file_identity(&path) { + Ok(current) if current == locked => return Ok(file), + Ok(_) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) if cfg!(windows) && error.kind() == io::ErrorKind::PermissionDenied => {} + Err(error) => return Err(failed(error)), + } + drop(file); + let left = deadline.map(|deadline| deadline.saturating_duration_since(Instant::now())); + if left.is_some_and(|left| left.is_zero()) { + return Err(busy()); + } + thread::sleep(left.map_or(LOCK_RETRY_INTERVAL, |left| left.min(LOCK_RETRY_INTERVAL))); + } +} + +/// Reads the private regular file at `path`, the store's `description`, of at most `limit` bytes, +/// or returns `None` if it does not exist. +/// +/// The file is opened without following a link, or on Windows any other reparse point, at its +/// own name, and refused unless it is a regular file, before anything is read from it. A file +/// that is not private to the current user is refused, since others could have written it: on +/// Windows, a file's own access control list decides who may open it, whatever the store's +/// directory allows. +fn read_private(path: &Path, description: &str, limit: u64) -> Result>> { + let failed = || store_error(format!("cannot read the {description} {}", path.display())); + let file = match open_regular(path) { + Ok(file) => file, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(failed()(error)), + }; + if let Some(problem) = platform::private_file_problem(&file).map_err(failed())? { + return Err(Error::io(format!( + "the {description} {} {problem}", + path.display() + ))); + } + read_at_most(file, limit, &format!("the {description}")) + .map(Some) + .map_err(failed()) +} + +/// Reads the store's record at `path`, its `description`, of at most `limit` bytes, in format +/// [`STORE_FORMAT`], or returns `None` if it does not exist. +fn read_record( + path: &Path, + description: &str, + limit: u64, +) -> Result> { + let Some(text) = read_private(path, description, limit)? else { + return Ok(None); + }; + let malformed = || format!("the {description} {} is malformed", path.display()); + let value: serde_json::Value = + serde_json::from_slice(&text).map_err(|error| Error::io(malformed()).with_source(error))?; + match value.get("format") { + Some(format) if format.as_u64() == Some(STORE_FORMAT.into()) => {} + Some(format) => { + return Err(Error::new( + ErrorCode::FormatUnsupported, + format!( + "the {description} {} has the unsupported format {format}; this build reads \ + format {STORE_FORMAT} only and migrates no other, so create a new template \ + store, and capture its templates again", + path.display() + ), + )); + } + None => return Err(Error::io(format!("{}: it has no format", malformed()))), + } + serde_json::from_value(value) + .map(Some) + .map_err(|error| Error::io(malformed()).with_source(error)) +} + +/// Atomically replaces the store's record at `path`, its `description`, with `record`, as pretty +/// JSON and a newline. +fn write_record(path: &Path, record: &T, description: &str) -> Result<()> { + let mut text = serde_json::to_vec_pretty(record) + .map_err(|error| Error::other(format!("cannot encode {description}")).with_source(error))?; + text.push(b'\n'); + write_private(path, &text) +} + +/// Reads the identity of the store in `dir` from its identity record, or returns `None` if the +/// directory holds none. +fn read_identity(dir: &Path) -> Result> { + let path = dir.join(STORE_RECORD_NAME); + let description = "identity record of the template store"; + let Some(record) = read_record::(&path, description, MAX_STORE_RECORD_LEN)? else { + return Ok(None); + }; + record.store.parse().map(Some).map_err(|error: Error| { + Error::io(format!( + "the {description} {} is malformed: {}", + path.display(), + error.message() + )) + }) +} + +/// The identity record of a store, as JSON. +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct StoreRecord { + /// [`STORE_FORMAT`]. + format: u32, + /// The store's identity. + store: String, +} + +/// The index of a store, as JSON. +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct IndexRecord { + /// [`STORE_FORMAT`]. + format: u32, + /// The identity of the store. + store: String, + /// The store generation that the next publication takes. + next_generation: u64, + /// The generations, in the order of their store generations. + generations: Vec, +} + +impl IndexRecord { + /// Returns the record of `index`, of the store `store`. + fn new(store: StoreId, index: &Index) -> Self { + Self { + format: STORE_FORMAT, + store: store.to_string(), + next_generation: index.next.get().get(), + generations: index + .generations + .iter() + .map(GenerationRecord::new) + .collect(), + } + } + + /// Checks the record, and returns its index, or says why it is malformed. + fn into_index(self) -> std::result::Result { + let next = NonZeroU64::new(self.next_generation) + .map(StoreGeneration::new) + .ok_or("its next store generation is 0")?; + if self.generations.len() > MAX_GENERATIONS { + return Err(format!( + "it lists {} generations, more than the {MAX_GENERATIONS} that a store holds", + self.generations.len() + )); + } + let mut generations = Vec::with_capacity(self.generations.len()); + for record in self.generations { + let generation = record.into_generation()?; + if generations + .last() + .is_some_and(|last: &Generation| last.generation >= generation.generation) + { + return Err(format!( + "it lists store generation {} out of order, or twice", + generation.generation + )); + } + if generation.generation >= next { + return Err(format!( + "it lists store generation {}, which its next store generation, {next}, does \ + not follow", + generation.generation + )); + } + generations.push(generation); + } + Ok(Index { next, generations }) + } +} + +/// A generation in the index of a store, as JSON. +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct GenerationRecord { + /// The store generation. + generation: u64, + /// The identity of the key. + key: String, + /// The identity of the capture class. + class: String, + /// The storage generation of the capture. + storage_generation: String, + /// When OpenVMM created the snapshot. + created_at: TimestampRecord, + /// The time contract of the capture. + time: TimeRecord, + /// The length of the saved state, in bytes. + state_len: u64, + /// The length of the RAM, in bytes. + memory_len: u64, +} + +impl GenerationRecord { + /// Returns the record of `generation`. + fn new(generation: &Generation) -> Self { + let created_at = generation + .created_at + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap_or_default(); + Self { + generation: generation.generation.get().get(), + key: generation.key.to_string(), + class: generation.class.to_string(), + storage_generation: generation.storage.to_string(), + created_at: TimestampRecord { + seconds: created_at.as_secs(), + nanos: created_at.subsec_nanos(), + }, + time: TimeRecord::new(&generation.time), + state_len: generation.state_len, + memory_len: generation.memory_len, + } + } + + /// Checks the record, and returns its generation, or says why it is malformed. + fn into_generation(self) -> std::result::Result { + let generation = NonZeroU64::new(self.generation) + .map(StoreGeneration::new) + .ok_or("it lists store generation 0, which no generation has")?; + let field = |what: &str, error: Error| { + format!( + "the {what} of store generation {generation} is invalid: {}", + error.message() + ) + }; + let created_at = (self.created_at.nanos < 1_000_000_000) + .then(|| { + SystemTime::UNIX_EPOCH.checked_add(Duration::new( + self.created_at.seconds, + self.created_at.nanos, + )) + }) + .flatten() + .ok_or_else(|| { + format!("the creation time of store generation {generation} is invalid") + })?; + Ok(Generation { + generation, + key: self.key.parse().map_err(|error| field("key", error))?, + class: self.class.parse().map_err(|error| field("class", error))?, + storage: self + .storage_generation + .parse() + .map_err(|error| field("storage generation", error))?, + created_at, + time: self.time.into_contract().map_err(|problem| { + format!("the time contract of store generation {generation} {problem}") + })?, + state_len: self.state_len, + memory_len: self.memory_len, + }) + } +} + +/// A time, as JSON: seconds and nanoseconds since the Unix epoch. +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct TimestampRecord { + /// Whole seconds. + seconds: u64, + /// Nanoseconds within the second. + nanos: u32, +} + +/// The time contract of a capture, as JSON. +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct TimeRecord { + /// [`TimeContract::tsc_frequency_hz`]. + tsc_frequency_hz: u64, + /// [`TimeContract::tsc_tolerance_ppm`]. + tsc_tolerance_ppm: u32, + /// [`TimeContract::apic_frequency_hz`]. + apic_frequency_hz: u64, + /// [`TimeContract::capture_tsc`]. + capture_tsc: u64, + /// [`TimeContract::capture_utc_ns`]. + capture_utc_ns: u64, + /// [`TimeContract::capture_monotonic_ns`]. + capture_monotonic_ns: u64, + /// [`TimeContract::host_clock`], as a manifest spells it. + host_clock: String, + /// [`TimeContract::host_id`], as 32 hexadecimal digits. + host_id: String, + /// [`TimeContract::boot_id`]. + boot_id: BootId, + /// [`TimeContract::lineage`]. + lineage: u32, + /// [`TimeContract::cut_to_anchor_ns`]. + cut_to_anchor_ns: Option, +} + +impl TimeRecord { + /// Returns the record of `time`. + fn new(time: &TimeContract) -> Self { + Self { + tsc_frequency_hz: time.tsc_frequency_hz, + tsc_tolerance_ppm: time.tsc_tolerance_ppm, + apic_frequency_hz: time.apic_frequency_hz, + capture_tsc: time.capture_tsc, + capture_utc_ns: time.capture_utc_ns, + capture_monotonic_ns: time.capture_monotonic_ns, + host_clock: time.host_clock.as_str().to_owned(), + host_id: hex(&time.host_id), + boot_id: time.boot_id, + lineage: time.lineage, + cut_to_anchor_ns: time.cut_to_anchor_ns, + } + } + + /// Checks the record, and returns its time contract, or says why it is invalid. + fn into_contract(self) -> std::result::Result { + Ok(TimeContract { + tsc_frequency_hz: self.tsc_frequency_hz, + tsc_tolerance_ppm: self.tsc_tolerance_ppm, + apic_frequency_hz: self.apic_frequency_hz, + capture_tsc: self.capture_tsc, + capture_utc_ns: self.capture_utc_ns, + capture_monotonic_ns: self.capture_monotonic_ns, + host_clock: HostClock::parse(&self.host_clock) + .ok_or_else(|| format!("has the unknown host clock {:?}", self.host_clock))?, + host_id: parse_hex_id(&self.host_id) + .ok_or_else(|| format!("has the invalid host identity {:?}", self.host_id))?, + boot_id: self.boot_id, + lineage: self.lineage, + cut_to_anchor_ns: self.cut_to_anchor_ns, + }) + } +} diff --git a/nvx-rs/src/host/template/store/tests.rs b/nvx-rs/src/host/template/store/tests.rs new file mode 100644 index 000000000..e85c0ead2 --- /dev/null +++ b/nvx-rs/src/host/template/store/tests.rs @@ -0,0 +1,949 @@ +//! Tests of the template store: its creation and opening, its records and their formats, the +//! namespaces of its generations, its lock, and its refusal of directories and files that are +//! not private to the current user, or that are links. + +use std::sync::{Arc, Barrier, mpsc}; + +use serde_json::{Value, json}; + +use super::*; +use crate::host::template::MANIFEST_NAME; +use crate::host::template::key::CaptureClass; +use crate::host::template::manifest::TemplateManifest; +use crate::host::template::test_support::{ManifestSpec, class}; + +/// A change to a record, as JSON. +type Change = fn(&mut Value); + +/// Creates a store in a fresh temporary directory, and returns the directory and the store. +fn store() -> (tempfile::TempDir, Store) { + let directory = tempfile::tempdir().unwrap(); + let store = Store::create(directory.path().join("templates")).unwrap(); + (directory, store) +} + +/// Returns the storage generation whose bytes are all `byte`, which must not be 0. +fn storage(byte: u8) -> StorageGeneration { + StorageGeneration::from_bytes([byte; ID_LEN]).unwrap() +} + +/// Creates the directory `path` private to the current user, as the store creates its own. +fn private_dir(path: &Path) { + assert_eq!(platform::create_owner_only_dir(path).unwrap(), None); +} + +/// Publishes a capture of `class` with the storage generation `storage`, whose manifest `spec` +/// encodes, as a publication does, under the store's lock: its snapshot at the place of the next +/// store generation, its key beside it, and then the index, which lists it. +fn publish_with( + store: &Store, + class: &CaptureClass, + spec: &ManifestSpec, + storage: StorageGeneration, +) -> Generation { + let _lock = take_lock(store.dir(), None).unwrap(); + let mut index = store.index().unwrap(); + let generation = index.next_generation(); + let payload = store.payload_dir(generation); + let snapshot = payload.join(SNAPSHOT_NAME); + private_dir(&payload); + private_dir(&snapshot); + fs::write(snapshot.join(MANIFEST_NAME), spec.encode()).unwrap(); + let manifest = TemplateManifest::read(&snapshot).unwrap(); + let key = TemplateKey::new(class, &manifest).unwrap(); + write_private(&payload.join(KEY_NAME), &key.to_bytes()).unwrap(); + let published = Generation { + generation, + key: key.identity(), + class: class.identity().unwrap(), + storage, + created_at: manifest.created_at(), + time: manifest.time().clone(), + state_len: manifest.state_len(), + memory_len: manifest.memory_len(), + }; + index.generations.push(published.clone()); + index.next = StoreGeneration::new(generation.get().checked_add(1).unwrap()); + store.write_index(&index).unwrap(); + published +} + +/// Publishes a capture of the class of [`ManifestSpec::template`], as [`publish_with`] does. +fn publish(store: &Store, spec: &ManifestSpec, storage: StorageGeneration) -> Generation { + publish_with(store, &class(), spec, storage) +} + +/// Removes the generation of store generation `generation` from the index of `store`, under its +/// lock, as a retirement does. +fn remove(store: &Store, generation: StoreGeneration) { + let _lock = take_lock(store.dir(), None).unwrap(); + let mut index = store.index().unwrap(); + index + .generations + .retain(|indexed| indexed.generation != generation); + store.write_index(&index).unwrap(); +} + +/// Returns the store's record `name` as JSON. +fn record(store: &Store, name: &str) -> Value { + serde_json::from_slice(&fs::read(store.dir().join(name)).unwrap()).unwrap() +} + +/// Replaces the store's record `name` with `value`. +fn rewrite(store: &Store, name: &str, value: &Value) { + write_private(&store.dir().join(name), value.to_string().as_bytes()).unwrap(); +} + +/// Returns the sorted names of the entries of `dir`. +fn sorted_entries(dir: &Path) -> Vec { + let mut names = entries(dir).unwrap(); + names.sort(); + names +} + +/// Returns the message of the cause of `error`. +fn cause(error: &Error) -> String { + std::error::Error::source(error) + .map(ToString::to_string) + .unwrap_or_default() +} + +/// Links `link` to the directory `target`: a symbolic link on Unix, and a junction on Windows, +/// which any user may create. +fn link_dir(target: &Path, link: &Path) { + #[cfg(unix)] + std::os::unix::fs::symlink(target, link).unwrap(); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + use std::process::{Command, Stdio}; + + let status = Command::new("cmd") + .raw_arg(format!( + "/C mklink /J \"{}\" \"{}\"", + link.display(), + target.display() + )) + .stdout(Stdio::null()) + .status() + .unwrap(); + assert!(status.success(), "cannot link {}", link.display()); + } +} + +/// Links `link` to the file `target` with a symbolic link, and returns whether it could: Windows +/// lets only Developer Mode or a privilege create one. +fn link_file(target: &Path, link: &Path) -> bool { + #[cfg(unix)] + let linked = std::os::unix::fs::symlink(target, link); + #[cfg(windows)] + let linked = std::os::windows::fs::symlink_file(target, link); + if linked.is_err() { + eprintln!("skipped: this host does not let the test create a symbolic link"); + } + linked.is_ok() +} + +/// Removes the link `link` to a directory, which leaves its target as it is. +fn unlink_dir(link: &Path) { + #[cfg(unix)] + fs::remove_file(link).unwrap(); + #[cfg(windows)] + fs::remove_dir(link).unwrap(); +} + +#[test] +fn stores_are_created_opened_and_reopened() { + let directory = tempfile::tempdir().unwrap(); + let dir = directory.path().join("templates"); + let error = Store::open(&dir).unwrap_err(); + assert_eq!(error.kind(), ErrorKind::NotFound, "{error}"); + let store = Store::create(&dir).unwrap(); + assert_eq!(store.dir(), dir); + assert_eq!( + sorted_entries(&dir), + [ + GENERATIONS_NAME, + INDEX_NAME, + INDEX_LOCK_NAME, + STORE_RECORD_NAME + ] + ); + assert!(entries(&dir.join(GENERATIONS_NAME)).unwrap().is_empty()); + let index = store.index().unwrap(); + assert!(index.generations().is_empty()); + assert_eq!(index.next_generation(), FIRST_GENERATION); + // Opening it, creating it again, and reopening it find the same store, with its index. + let published = publish(&store, &ManifestSpec::template(), storage(1)); + assert_eq!(Store::open(&dir).unwrap(), store); + assert_eq!(Store::create(&dir).unwrap(), store); + let id = store.id(); + drop(store); + let reopened = Store::open(&dir).unwrap(); + assert_eq!(reopened.id(), id); + assert_eq!(reopened.index().unwrap().generations(), [published]); + // Another store has an identity of its own. + let other = Store::create(directory.path().join("other")).unwrap(); + assert_ne!(other.id(), id); + // A directory without a store holds none. + let empty = directory.path().join("empty"); + private_dir(&empty); + let error = Store::open(&empty).unwrap_err(); + assert_eq!(error.kind(), ErrorKind::NotFound, "{error}"); + assert!( + error.message().ends_with("holds no template store"), + "{error}" + ); + // A store needs its parent. + let error = Store::create(directory.path().join("missing").join("templates")).unwrap_err(); + assert_eq!(error.kind(), ErrorKind::NotFound, "{error}"); +} + +#[test] +fn a_creation_replaces_an_interrupted_one_and_refuses_foreign_entries() { + let directory = tempfile::tempdir().unwrap(); + // What a creation leaves before it writes the identity record is replaced. + let interrupted = directory.path().join("interrupted"); + private_dir(&interrupted); + private_dir(&interrupted.join(GENERATIONS_NAME)); + for name in [ + INDEX_NAME, + INDEX_LOCK_NAME, + ".index.json.42.tmp", + ".store.json.7.tmp", + ] { + fs::write(interrupted.join(name), b"{").unwrap(); + } + let store = Store::create(&interrupted).unwrap(); + assert!(store.index().unwrap().generations().is_empty()); + assert_eq!(Store::open(&interrupted).unwrap(), store); + // Anything else is refused, and kept as it is: other files, and the payloads of a store whose + // identity record is gone. + let foreign = directory.path().join("foreign"); + private_dir(&foreign); + for name in ["notes.txt", ".index.json.1a.tmp", ".config.json.1.tmp"] { + fs::write(foreign.join(name), b"").unwrap(); + } + private_dir(&foreign.join(GENERATIONS_NAME)); + private_dir(&foreign.join(GENERATIONS_NAME).join("7")); + let before = sorted_entries(&foreign); + let error = Store::create(&foreign).unwrap_err(); + assert_eq!(error.code(), ErrorCode::ForeignFiles, "{error}"); + assert!( + error.message().ends_with( + "holds no template store, but entries that are not a store's: .config.json.1.tmp, \ + .index.json.1a.tmp, generations/7, notes.txt" + ), + "{error}" + ); + assert_eq!(sorted_entries(&foreign), before); + let error = Store::open(&foreign).unwrap_err(); + assert_eq!(error.kind(), ErrorKind::NotFound, "{error}"); +} + +#[test] +fn concurrent_creations_make_one_store() { + /// The creations that race. + const CREATIONS: usize = 8; + let directory = tempfile::tempdir().unwrap(); + let dir = directory.path().join("templates"); + let barrier = Arc::new(Barrier::new(CREATIONS)); + let creations: Vec<_> = (0..CREATIONS) + .map(|_| { + let (dir, barrier) = (dir.clone(), Arc::clone(&barrier)); + thread::spawn(move || { + barrier.wait(); + Store::create(&dir).unwrap().id() + }) + }) + .collect(); + let ids: Vec = creations + .into_iter() + .map(|creation| creation.join().unwrap()) + .collect(); + assert!(ids.iter().all(|id| *id == ids[0]), "{ids:?}"); + assert_eq!(Store::open(&dir).unwrap().id(), ids[0]); +} + +#[test] +fn a_creation_that_another_overtakes_opens_its_store() { + let directory = tempfile::tempdir().unwrap(); + // A creation that finds no store, and then the entries of one that another creation made + // meanwhile, even with a generation, opens that store. + let dir = directory.path().join("templates"); + let mut overtaking = None; + let store = Store::create_racing(dir.clone(), || { + let store = Store::create(&dir).unwrap(); + publish(&store, &ManifestSpec::template(), storage(1)); + overtaking = Some(store); + }) + .unwrap(); + assert_eq!(Some(store), overtaking); + // Entries that no store explains are still refused. + let foreign = directory.path().join("foreign"); + let error = Store::create_racing(foreign.clone(), || { + fs::write(foreign.join("notes.txt"), b"").unwrap(); + }) + .unwrap_err(); + assert_eq!(error.code(), ErrorCode::ForeignFiles, "{error}"); +} + +#[test] +fn records_keep_their_json_form() { + let (_directory, store) = store(); + let mut spec = ManifestSpec::template(); + // Windows keeps times in units of 100 ns. + spec.created_at = (1_700_000_000, 123_456_700); + spec.time().cut_to_anchor_ns = None; + let published = publish(&store, &spec, storage(9)); + let manifest = TemplateManifest::parse(&spec.encode()).unwrap(); + let key = TemplateKey::new(&class(), &manifest).unwrap(); + let time = manifest.time(); + assert_eq!( + record(&store, STORE_RECORD_NAME), + json!({ "format": 1, "store": store.id().to_string() }) + ); + // The index holds no VM generation ID, which only the clones' guests know. + assert_eq!( + record(&store, INDEX_NAME), + json!({ + "format": 1, + "store": store.id().to_string(), + "nextGeneration": 2, + "generations": [{ + "generation": 1, + "key": key.identity().to_string(), + "class": class().identity().unwrap().to_string(), + "storageGeneration": "09090909090909090909090909090909", + "createdAt": { "seconds": 1_700_000_000, "nanos": 123_456_700 }, + "time": { + "tscFrequencyHz": time.tsc_frequency_hz, + "tscTolerancePpm": time.tsc_tolerance_ppm, + "apicFrequencyHz": time.apic_frequency_hz, + "captureTsc": time.capture_tsc, + "captureUtcNs": time.capture_utc_ns, + "captureMonotonicNs": time.capture_monotonic_ns, + "hostClock": "linux-boottime", + "hostId": hex(&time.host_id), + "bootId": time.boot_id.to_string(), + "lineage": time.lineage, + "cutToAnchorNs": null, + }, + "stateLen": manifest.state_len(), + "memoryLen": manifest.memory_len(), + }], + }) + ); + // Each generation keeps its own metadata, which its manifest recorded, and its key. + let index = Store::open(store.dir()).unwrap().index().unwrap(); + assert_eq!(index.generations(), std::slice::from_ref(&published)); + assert_eq!(index.get(FIRST_GENERATION), Some(&published)); + assert_eq!(published.generation(), FIRST_GENERATION); + assert_eq!(published.key(), key.identity()); + assert_eq!(published.class(), class().identity().unwrap()); + assert_eq!(published.storage_generation(), storage(9)); + assert_eq!(published.created_at(), manifest.created_at()); + assert_eq!(published.time(), manifest.time()); + assert_eq!(published.state_len(), manifest.state_len()); + assert_eq!(published.memory_len(), manifest.memory_len()); + assert_eq!(store.read_key(&published).unwrap(), key); + assert_eq!( + store.snapshot_dir(&published).unwrap(), + store + .dir() + .join(GENERATIONS_NAME) + .join("1") + .join(SNAPSHOT_NAME) + ); +} + +#[test] +fn records_of_other_formats_fail_clearly() { + let (_directory, store) = store(); + publish(&store, &ManifestSpec::template(), storage(1)); + let valid = record(&store, INDEX_NAME); + for format in [0, 2] { + let mut index = valid.clone(); + index["format"] = format.into(); + rewrite(&store, INDEX_NAME, &index); + let error = store.index().unwrap_err(); + assert_eq!(error.code(), ErrorCode::FormatUnsupported, "{error}"); + assert!( + error.message().contains(&format!( + "has the unsupported format {format}; this build reads format 1 only and \ + migrates no other" + )), + "{error}" + ); + } + let mut index = valid.clone(); + index.as_object_mut().unwrap().remove("format"); + rewrite(&store, INDEX_NAME, &index); + let error = store.index().unwrap_err(); + assert_eq!(error.code(), ErrorCode::Io, "{error}"); + assert!(error.message().ends_with("it has no format"), "{error}"); + rewrite(&store, INDEX_NAME, &valid); + assert_eq!(store.index().unwrap().generations().len(), 1); + // A store whose identity record is of another format is opened by no build of this one. + rewrite( + &store, + STORE_RECORD_NAME, + &json!({ "format": 2, "store": store.id().to_string() }), + ); + for error in [ + Store::open(store.dir()).unwrap_err(), + Store::create(store.dir()).unwrap_err(), + ] { + assert_eq!(error.code(), ErrorCode::FormatUnsupported, "{error}"); + } +} + +#[test] +fn malformed_indexes_and_those_of_other_stores_are_refused() { + let (_directory, store) = store(); + publish(&store, &ManifestSpec::template(), storage(1)); + publish(&store, &ManifestSpec::template(), storage(2)); + let valid = record(&store, INDEX_NAME); + let cases: [(&str, Change); 11] = [ + ("its next store generation is 0", |index| { + index["nextGeneration"] = 0.into(); + }), + ( + "it lists store generation 2, which its next store generation, 2, does not follow", + |index| index["nextGeneration"] = 2.into(), + ), + ( + "it lists store generation 1 out of order, or twice", + |index| { + index["generations"][1]["generation"] = 1.into(); + }, + ), + ( + "it lists store generation 0, which no generation has", + |index| index["generations"][0]["generation"] = 0.into(), + ), + ("the key of store generation 1 is invalid", |index| { + index["generations"][0]["key"] = "00".into(); + }), + ("the class of store generation 1 is invalid", |index| { + index["generations"][0]["class"] = "".into(); + }), + ( + "the storage generation of store generation 1 is invalid", + |index| index["generations"][0]["storageGeneration"] = "0".repeat(32).into(), + ), + ( + "the creation time of store generation 1 is invalid", + |index| { + index["generations"][0]["createdAt"]["nanos"] = 1_000_000_000.into(); + }, + ), + ( + "the time contract of store generation 1 has the unknown host clock \"tsc\"", + |index| index["generations"][0]["time"]["hostClock"] = "tsc".into(), + ), + ( + "the time contract of store generation 1 has the invalid host identity \"01\"", + |index| index["generations"][0]["time"]["hostId"] = "01".into(), + ), + ( + "it lists 1025 generations, more than the 1024 that a store holds", + |index| { + let entry = index["generations"][0].clone(); + index["generations"] = (1..=MAX_GENERATIONS + 1) + .map(|generation| { + let mut entry = entry.clone(); + entry["generation"] = generation.into(); + entry + }) + .collect(); + index["nextGeneration"] = (MAX_GENERATIONS + 2).into(); + }, + ), + ]; + for (problem, change) in cases { + let mut index = valid.clone(); + change(&mut index); + rewrite(&store, INDEX_NAME, &index); + let error = store.index().unwrap_err(); + assert_eq!(error.code(), ErrorCode::Io, "{error}"); + assert!( + error + .message() + .contains(&format!("is malformed: {problem}")), + "{problem}: {error}" + ); + } + // Fields that the format does not have are refused. + let mut index = valid.clone(); + index["generations"][0]["generationId"] = "00".repeat(16).into(); + rewrite(&store, INDEX_NAME, &index); + let error = store.index().unwrap_err(); + assert!(error.message().ends_with("is malformed"), "{error}"); + assert!( + cause(&error).starts_with("unknown field `generationId`"), + "{error}" + ); + // So is the index of another store. + let mut index = valid.clone(); + index["store"] = "07".repeat(16).into(); + rewrite(&store, INDEX_NAME, &index); + let error = store.index().unwrap_err(); + assert!( + error.message().ends_with(&format!( + "belongs to the template store {}, not to this one, {}", + "07".repeat(16), + store.id() + )), + "{error}" + ); + // And one longer than an index may be, before anything of it is parsed. + let path = store.dir().join(INDEX_NAME); + write_private(&path, &vec![b' '; MAX_INDEX_LEN as usize + 1]).unwrap(); + let error = store.index().unwrap_err(); + assert_eq!( + cause(&error), + format!( + "it holds more than the {MAX_INDEX_LEN} bytes that the index of the template store \ + may hold" + ) + ); + // A missing index is an error of the store, not an empty index. + fs::remove_file(&path).unwrap(); + let error = store.index().unwrap_err(); + assert_eq!(error.code(), ErrorCode::Io, "{error}"); + assert!(error.message().ends_with("has no index"), "{error}"); + rewrite(&store, INDEX_NAME, &valid); + assert_eq!(store.index().unwrap().generations().len(), 2); +} + +#[test] +fn the_four_namespaces_stay_apart() { + let (_directory, store) = store(); + // Store generations follow the order of publication alone: neither the time lineage counters, + // nor the storage generations, which captures may share, and which may look like a store + // generation. + let one = StorageGeneration::from_bytes(1u128.to_be_bytes()).unwrap(); + let mut published = Vec::new(); + for (lineage, storage) in [(7, one), (7, one), (1, storage(0xff))] { + let mut spec = ManifestSpec::template(); + spec.time().lineage = lineage; + let generation = publish(&store, &spec, storage); + published.push(( + generation.generation().get().get(), + generation.time().lineage, + generation.storage_generation(), + )); + } + assert_eq!(published, [(1, 7, one), (2, 7, one), (3, 1, storage(0xff))]); + // A store generation is never assigned again, even once its generation is gone. + let third = StoreGeneration::new(NonZeroU64::new(3).unwrap()); + remove(&store, third); + let index = store.index().unwrap(); + assert_eq!(index.get(third), None); + assert_eq!(index.next_generation().get().get(), 4); + let fourth = publish(&store, &ManifestSpec::template(), one); + assert_eq!(fourth.generation().get().get(), 4); + // The generations of one key, whatever their lineage counters and storage generations. + let of_key: Vec = store + .index() + .unwrap() + .of_key(fourth.key()) + .map(|generation| generation.generation().get().get()) + .collect(); + assert_eq!(of_key, [1, 2, 4]); + // The text form of each names nothing of another's. + assert_eq!(one.to_string(), "00000000000000000000000000000001"); + assert_eq!(fourth.generation().to_string(), "4"); + assert!("1".parse::().is_err()); + assert!("4".parse::().is_err()); + assert_eq!(StorageGeneration::from_bytes([0; ID_LEN]), None); + assert!("0".repeat(32).parse::().is_err()); + assert_eq!( + "00000000000000000000000000000001" + .parse::() + .unwrap(), + one + ); + assert_eq!( + "FF".repeat(16).parse::().unwrap(), + storage(0xff) + ); + assert_eq!(format!("{one:?}"), format!("StorageGeneration({one})")); + let drawn = StorageGeneration::generate().unwrap(); + assert_ne!(drawn, StorageGeneration::generate().unwrap()); + // A store's identity is none of them either. + let id = store.id(); + assert_eq!(id.to_string().parse::().unwrap(), id); + assert_eq!( + id.to_string().to_uppercase().parse::().unwrap(), + id + ); + assert_eq!(format!("{id:?}"), format!("StoreId({id})")); + assert!("1".parse::().is_err()); +} + +#[test] +fn payloads_are_checked_as_they_are_read() { + let (_directory, store) = store(); + let published = publish(&store, &ManifestSpec::template(), storage(1)); + let payload = store.payload_dir(published.generation()); + let key = payload.join(KEY_NAME); + let bytes = fs::read(&key).unwrap(); + // A key that is not a key's canonical encoding is refused as such. + write_private(&key, &[2, 0]).unwrap(); + let error = store.read_key(&published).unwrap_err(); + assert_eq!(error.code(), ErrorCode::FormatUnsupported, "{error}"); + assert!( + error + .message() + .starts_with(&format!("cannot read {}: ", key.display())), + "{error}" + ); + write_private(&key, &bytes[..bytes.len() - 1]).unwrap(); + let error = store.read_key(&published).unwrap_err(); + assert_eq!(error.code(), ErrorCode::Io, "{error}"); + write_private(&key, &vec![0; TemplateKey::MAX_LEN + 1]).unwrap(); + let error = store.read_key(&published).unwrap_err(); + assert_eq!( + cause(&error), + format!( + "it holds more than the {} bytes that the template key may hold", + TemplateKey::MAX_LEN + ) + ); + // A missing key, snapshot, or payload, which a crash may lose, is missing. + fs::remove_file(&key).unwrap(); + let error = store.read_key(&published).unwrap_err(); + assert_eq!(error.kind(), ErrorKind::NotFound, "{error}"); + fs::remove_dir_all(payload.join(SNAPSHOT_NAME)).unwrap(); + let error = store.snapshot_dir(&published).unwrap_err(); + assert_eq!(error.kind(), ErrorKind::NotFound, "{error}"); + fs::remove_dir_all(&payload).unwrap(); + for error in [ + store.snapshot_dir(&published).unwrap_err(), + store.read_key(&published).unwrap_err(), + ] { + assert_eq!(error.kind(), ErrorKind::NotFound, "{error}"); + } +} + +#[test] +fn the_index_lock_excludes_other_holders() { + let (_directory, store) = store(); + let held = take_lock(store.dir(), None).unwrap(); + let started = Instant::now(); + let deadline = Some(Instant::now() + Duration::from_millis(100)); + let error = take_lock(store.dir(), deadline).unwrap_err(); + assert_eq!(error.kind(), ErrorKind::TimedOut, "{error}"); + assert!(error.message().ends_with("is still in progress"), "{error}"); + assert!(started.elapsed() >= Duration::from_millis(100)); + // A creation takes the lock too, and waits for its holder to release it. + let (sender, receiver) = mpsc::channel(); + let dir = store.dir().to_owned(); + let creation = thread::spawn(move || { + let created = Store::create(&dir).map(|store| store.id()); + sender.send(()).unwrap(); + created + }); + assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err()); + drop(held); + receiver.recv_timeout(Duration::from_secs(30)).unwrap(); + assert_eq!(creation.join().unwrap().unwrap(), store.id()); +} + +#[test] +fn links_are_refused() { + let (directory, store) = store(); + let published = publish(&store, &ManifestSpec::template(), storage(1)); + // A path that a separator ends names the store's own directory. + assert_eq!(Store::open(store.dir().join("")).unwrap(), store); + assert_eq!(Store::create(store.dir().join("")).unwrap(), store); + // A link to a store is no store, however its path ends: Linux follows a link at a name that a + // separator ends, even when it follows none at the final name. + let link = directory.path().join("link"); + link_dir(store.dir(), &link); + for path in [link.clone(), link.join(""), link.join(".")] { + for error in [ + Store::create(&path).unwrap_err(), + Store::open(&path).unwrap_err(), + ] { + assert_eq!(error.code(), ErrorCode::Io, "{}: {error}", path.display()); + assert!( + error.message().ends_with("is not a plain directory"), + "{}: {error}", + path.display() + ); + } + } + // Nor is a link to the generations, to a payload, or to a snapshot followed. + let payload = store.payload_dir(published.generation()); + for path in [ + store.dir().join(GENERATIONS_NAME), + payload.clone(), + payload.join(SNAPSHOT_NAME), + ] { + let moved = path.with_extension("moved"); + fs::rename(&path, &moved).unwrap(); + link_dir(&moved, &path); + let mut errors = vec![store.snapshot_dir(&published).unwrap_err()]; + if path != payload.join(SNAPSHOT_NAME) { + errors.push(store.read_key(&published).unwrap_err()); + } + if path.ends_with(GENERATIONS_NAME) { + errors.push(Store::open(store.dir()).unwrap_err()); + } + for error in errors { + assert!( + error.message().ends_with("is not a plain directory"), + "{}: {error}", + path.display() + ); + } + unlink_dir(&path); + fs::rename(&moved, &path).unwrap(); + } + assert_eq!( + store.read_key(&published).unwrap().identity(), + published.key() + ); + // Nor is a link to a record, or to a key. + for path in [ + store.dir().join(STORE_RECORD_NAME), + store.dir().join(INDEX_NAME), + payload.join(KEY_NAME), + ] { + let moved = path.with_extension("moved"); + fs::rename(&path, &moved).unwrap(); + if !link_file(&moved, &path) { + fs::rename(&moved, &path).unwrap(); + return; + } + let error = if path.ends_with(STORE_RECORD_NAME) { + Store::open(store.dir()).unwrap_err() + } else if path.ends_with(INDEX_NAME) { + store.index().unwrap_err() + } else { + store.read_key(&published).unwrap_err() + }; + assert_eq!( + cause(&error), + "it is a link, or not a regular file", + "{}: {error}", + path.display() + ); + fs::remove_file(&path).unwrap(); + fs::rename(&moved, &path).unwrap(); + } + // Nor a link to the lock file. + let lock = store.dir().join(INDEX_LOCK_NAME); + let moved = lock.with_extension("moved"); + fs::rename(&lock, &moved).unwrap(); + assert!(link_file(&moved, &lock)); + let error = take_lock(store.dir(), None).unwrap_err(); + assert_eq!(cause(&error), "is not a regular file", "{error}"); +} + +#[cfg(unix)] +#[test] +fn records_that_are_not_regular_files_fail_at_once() { + use nix::sys::stat::Mode; + + let (_directory, store) = store(); + for name in [STORE_RECORD_NAME, INDEX_NAME, INDEX_LOCK_NAME] { + let path = store.dir().join(name); + let moved = path.with_extension("moved"); + fs::rename(&path, &moved).unwrap(); + nix::unistd::mkfifo(&path, Mode::S_IRUSR | Mode::S_IWUSR).unwrap(); + // A read that waited for a writer would never end, so it runs on a thread that the test + // can leave behind. + let (sender, receiver) = mpsc::channel(); + let opened = store.clone(); + thread::spawn(move || { + let result = match name { + STORE_RECORD_NAME => Store::open(opened.dir()).map(drop), + INDEX_NAME => opened.index().map(drop), + _ => take_lock(opened.dir(), None).map(drop), + }; + sender.send(result).unwrap(); + }); + let error = receiver + .recv_timeout(Duration::from_secs(10)) + .expect("the read does not wait for a writer") + .unwrap_err(); + assert!( + [ + "it is a link, or not a regular file", + "is not a regular file" + ] + .contains(&cause(&error).as_str()), + "{name}: {error}" + ); + fs::remove_file(&path).unwrap(); + fs::rename(&moved, &path).unwrap(); + } +} + +#[cfg(target_os = "linux")] +#[test] +fn the_store_is_private_to_the_current_user() { + use std::os::unix::fs::PermissionsExt; + + let (directory, store) = store(); + let mode = |path: &Path| fs::metadata(path).unwrap().permissions().mode() & 0o7777; + let set_mode = |path: &Path, mode: u32| { + fs::set_permissions(path, fs::Permissions::from_mode(mode)).unwrap(); + }; + // The store's directories and records are the current user's alone. + for path in [store.dir().to_owned(), store.dir().join(GENERATIONS_NAME)] { + assert_eq!(mode(&path), 0o700, "{}", path.display()); + } + for name in [STORE_RECORD_NAME, INDEX_NAME, INDEX_LOCK_NAME] { + assert_eq!(mode(&store.dir().join(name)), 0o600, "{name}"); + } + // A directory that others could access is refused, and left as it is, rather than + // restricted: another user could hold its files open. + let shared = directory.path().join("shared"); + fs::create_dir(&shared).unwrap(); + for shared_mode in [0o750, 0o705, 0o755, 0o777] { + set_mode(&shared, shared_mode); + for error in [ + Store::create(&shared).unwrap_err(), + Store::open(&shared).unwrap_err(), + ] { + let expected = format!("is accessible to other users (mode {shared_mode:03o})"); + assert!(error.message().ends_with(&expected), "{error}"); + } + assert_eq!(mode(&shared), shared_mode); + } + assert!(entries(&shared).unwrap().is_empty()); + // So are a store whose generations others could access, and its payloads. + let generations = store.dir().join(GENERATIONS_NAME); + set_mode(&generations, 0o750); + let error = Store::open(store.dir()).unwrap_err(); + assert!( + error + .message() + .ends_with("is accessible to other users (mode 750)"), + "{error}" + ); + set_mode(&generations, 0o700); + let published = publish(&store, &ManifestSpec::template(), storage(1)); + let snapshot = store + .payload_dir(published.generation()) + .join(SNAPSHOT_NAME); + set_mode(&snapshot, 0o755); + let error = store.snapshot_dir(&published).unwrap_err(); + assert!( + error + .message() + .ends_with("is accessible to other users (mode 755)"), + "{error}" + ); + // A store created under a setgid directory drops the bit for mode 0700, and an existing + // directory that grants no one else access is private, whatever else its mode holds. + let setgid = directory.path().join("setgid"); + fs::create_dir(&setgid).unwrap(); + set_mode(&setgid, 0o2700); + let nested = Store::create(setgid.join("templates")).unwrap(); + assert_eq!(mode(nested.dir()), 0o700); + let existing = directory.path().join("existing"); + fs::create_dir(&existing).unwrap(); + set_mode(&existing, 0o2700); + Store::create(&existing).unwrap(); + assert_eq!(mode(&existing), 0o2700); +} + +#[cfg(target_os = "linux")] +#[test] +fn private_directories_belong_to_the_current_user_and_grant_others_nothing() { + // Another user's directory is refused whatever its mode, since its owner may change it. + assert_eq!( + platform::owner_only_problem(1001, 0o40700, 1000), + Some("belongs to user 1001, not to the current user 1000".to_owned()) + ); + for mode in [0o40700, 0o42700, 0o41700, 0o40500, 0o40300] { + assert_eq!( + platform::owner_only_problem(1000, mode, 1000), + None, + "{mode:o}" + ); + } + for mode in [0o40750, 0o40705, 0o40770, 0o40701, 0o40710] { + let problem = platform::owner_only_problem(1000, mode, 1000).unwrap(); + assert!( + problem.starts_with("is accessible to other users"), + "{problem}" + ); + } +} + +#[cfg(windows)] +#[test] +fn the_store_is_private_to_the_current_user() { + let directory = tempfile::tempdir().unwrap(); + platform::set_access(directory.path(), "D:(A;OICI;FA;;;WD)").unwrap(); + // A directory that every user may change holds no store, nor does one created in it. + let error = Store::open(directory.path()).unwrap_err(); + assert!(error.message().ends_with("allows other users"), "{error}"); + let shared = directory.path().join("shared"); + fs::create_dir(&shared).unwrap(); + let error = Store::create(&shared).unwrap_err(); + assert!(error.message().ends_with("allows other users"), "{error}"); + assert!(entries(&shared).unwrap().is_empty()); + // Nor does one that allows its owner alone, but passes nothing on to the files created in it, + // which would take their creator's default list. + platform::set_access(&shared, "D:P(A;;FA;;;OW)").unwrap(); + for error in [ + Store::create(&shared).unwrap_err(), + Store::open(&shared).unwrap_err(), + ] { + assert!( + error + .message() + .ends_with("passes no access control entries on to the files created in it"), + "{error}" + ); + } + // A store created there allows the current user alone, and so do its records. + let store = Store::create(directory.path().join("templates")).unwrap(); + let published = publish(&store, &ManifestSpec::template(), storage(1)); + let payload = store.payload_dir(published.generation()); + let problem = |path: &Path| { + let file = File::open(path).unwrap(); + platform::private_file_problem(&file).unwrap() + }; + for path in [ + store.dir().join(STORE_RECORD_NAME), + store.dir().join(INDEX_NAME), + store.dir().join(INDEX_LOCK_NAME), + payload.join(KEY_NAME), + ] { + assert_eq!(problem(&path), None, "{}", path.display()); + } + // A payload that every user may change is refused. + let snapshot = payload.join(SNAPSHOT_NAME); + platform::set_access(&snapshot, "D:(A;OICI;FA;;;WD)").unwrap(); + let error = store.snapshot_dir(&published).unwrap_err(); + assert!(error.message().ends_with("allows other users"), "{error}"); + // So is a record or a key whose own list allows others, who could have written it, whatever + // the store's directory allows, and a lock file that others could hold. + for name in [KEY_NAME, INDEX_NAME, INDEX_LOCK_NAME, STORE_RECORD_NAME] { + let path = if name == KEY_NAME { + payload.join(name) + } else { + store.dir().join(name) + }; + platform::set_access(&path, "D:P(A;;FA;;;WD)").unwrap(); + let error = match name { + KEY_NAME => store.read_key(&published).unwrap_err(), + INDEX_NAME => store.index().unwrap_err(), + INDEX_LOCK_NAME => take_lock(store.dir(), None).unwrap_err(), + _ => Store::open(store.dir()).unwrap_err(), + }; + assert!( + error.message().ends_with("allows other users"), + "{name}: {error}" + ); + } +} diff --git a/nvx-rs/src/lib.rs b/nvx-rs/src/lib.rs index 296018eef..0d7ce65c4 100644 --- a/nvx-rs/src/lib.rs +++ b/nvx-rs/src/lib.rs @@ -34,7 +34,7 @@ //! network and filesystem policies, the host's NVXS connection, control sessions, process and //! platform primitives, the supervision of VM instances: starting, reattaching, executing //! workloads, and stopping, the store of a sandbox's runtime records and launch journal, and the -//! manifests and compatibility keys of templates. +//! manifests, compatibility keys, and store of templates. //! - `testing` (feature `testing`): a fake OpenVMM that serves the control console without //! running a VM. //! - `guest` (feature `guest`, Linux): the programs that run in NVX guests, such as