From 6d20c37f61e3315388a57344ed88f7e6fe04c818 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 04:40:32 -0800 Subject: [PATCH] host: keep immutable named CLI snapshots Port only SL3.7a commits 5a5cebc4034d39ef0fe9c32b2e8c13d750d2aea9 and 593a17d04f3a245ed8257b320b36943c79bc9c9d onto the actual preserved-parent reader/CLI input join 4662351ccc22198ffe16b6935d157bee14a163c0. Validate and publish whole private snapshots without replacement, keep the exclusive per-name lock outside each immutable entry, and record only the restored sandbox's own source name. Reuse existing CLI privacy/locking helpers. No capture, restore, index, shared-lock mode or blockless driver is added. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0c8e42ad-4102-451f-96f0-0a4ae0483d1a --- nvx-rs/src/host/cli/mod.rs | 41 +++- nvx-rs/src/host/cli/sandbox/state.rs | 76 ++++--- nvx-rs/src/host/cli/sandbox/state/tests.rs | 35 +++ nvx-rs/src/host/cli/snapshots.rs | 121 +++++++++++ nvx-rs/src/host/cli/snapshots/tests.rs | 238 +++++++++++++++++++++ nvx-rs/src/host/cli/store/mod.rs | 1 + scripts/test-layout-allowlist.json | 2 + 7 files changed, 473 insertions(+), 41 deletions(-) create mode 100644 nvx-rs/src/host/cli/sandbox/state/tests.rs create mode 100644 nvx-rs/src/host/cli/snapshots.rs create mode 100644 nvx-rs/src/host/cli/snapshots/tests.rs diff --git a/nvx-rs/src/host/cli/mod.rs b/nvx-rs/src/host/cli/mod.rs index d72dd8d8a..4af6a3051 100644 --- a/nvx-rs/src/host/cli/mod.rs +++ b/nvx-rs/src/host/cli/mod.rs @@ -47,16 +47,55 @@ mod run; mod sandbox; mod session; mod shell; +mod snapshots; mod store; use std::borrow::Cow; use std::ffi::OsString; -use std::path::Path; +use std::path::{Path, PathBuf}; use self::args::{Parser, Receiver, Spec, Stop, Syntax}; use crate::ErrorCode; use crate::host::platform; +/// Returns the name of a temporary file next to `path`: `.NAME.UUID.tmp`, with a fresh UUID4 in +/// hexadecimal. +pub(crate) fn temporary_path(path: &Path) -> Result { + let Some(name) = path.file_name() else { + return Err(Exit::error( + ErrorCode::Other, + format!("{} has an empty name", path.display()), + )); + }; + let mut temporary = OsString::from("."); + temporary.push(name); + temporary.push(format!(".{}.tmp", uuid4_hex()?)); + Ok(path.with_file_name(temporary)) +} + +/// Returns 32 lowercase hexadecimal digits of 122 random bits, with the version and variant bits +/// of a version-4 UUID. +fn uuid4_hex() -> Result { + let mut bytes = random_bytes::<16>()?; + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + Ok(hex(&bytes)) +} + +/// Returns `N` random bytes from the operating system. +pub(crate) fn random_bytes() -> Result<[u8; N]> { + let mut bytes = [0u8; N]; + getrandom::fill(&mut bytes).map_err(|error| { + Exit::error(ErrorCode::Io, format!("cannot read random bytes: {error}")) + })?; + Ok(bytes) +} + +/// Returns `bytes` in lowercase hexadecimal. +pub(crate) fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + /// The commands, in the order in which usage messages list them. const COMMANDS: [&str; 5] = ["artifacts", "download", "egress", "run", "sandbox"]; /// The options of the top-level parser. diff --git a/nvx-rs/src/host/cli/sandbox/state.rs b/nvx-rs/src/host/cli/sandbox/state.rs index d9fbe5b73..2dff2c014 100644 --- a/nvx-rs/src/host/cli/sandbox/state.rs +++ b/nvx-rs/src/host/cli/sandbox/state.rs @@ -30,10 +30,9 @@ //! [`Config::network`] reads back for `start` to render. It replaces the network options that //! `nvx.py` recorded, which no reader reads: `start` refuses a configuration without it. -use std::ffi::OsString; use std::fs::OpenOptions; use std::io::Write; -use std::path::{Path, PathBuf}; +use std::path::Path; use serde::de::DeserializeOwned; use serde::{Deserialize, Deserializer, Serialize}; @@ -44,6 +43,7 @@ use super::{MAX_MEMORY_MIB, path_string}; use crate::ErrorCode; use crate::host::cli::json::{Object, pretty_serde, read_object}; use crate::host::cli::output::LINE_SEPARATOR; +use crate::host::cli::snapshots::Name as SnapshotName; use crate::host::cli::{Exit, Result, io_error, io_message}; use crate::host::filesystem::ExportPlan; use crate::host::paths; @@ -53,6 +53,11 @@ use crate::host::sandbox::scratch::ScratchBinding; use crate::host::sandbox::{SandboxLaunch, SandboxNetwork, SandboxSettings, SandboxUpper}; use crate::protocol::root::RootUpper; +pub(crate) use crate::host::cli::{hex, random_bytes, temporary_path}; + +#[cfg(test)] +mod tests; + /// The configuration that earlier builds wrote for a sandbox without shares, whose `mount` is /// `null`, which `start` still reads. pub(crate) const CONFIG_FORMAT: u64 = 1; @@ -69,44 +74,6 @@ pub(crate) const CONFIG_FORMATS: [u64; 3] = /// What messages call a configuration. const DESCRIPTION: &str = "sandbox configuration"; -/// Returns the name of a temporary file next to `path`: `.NAME.UUID.tmp`, with a fresh UUID4 in -/// hexadecimal. -pub(crate) fn temporary_path(path: &Path) -> Result { - let Some(name) = path.file_name() else { - return Err(Exit::error( - ErrorCode::Other, - format!("{} has an empty name", path.display()), - )); - }; - let mut temporary = OsString::from("."); - temporary.push(name); - temporary.push(format!(".{}.tmp", uuid4_hex()?)); - Ok(path.with_file_name(temporary)) -} - -/// Returns 32 lowercase hexadecimal digits of 122 random bits, with the version and variant bits -/// of a version-4 UUID. -pub(crate) fn uuid4_hex() -> Result { - let mut bytes = random_bytes::<16>()?; - bytes[6] = (bytes[6] & 0x0f) | 0x40; - bytes[8] = (bytes[8] & 0x3f) | 0x80; - Ok(hex(&bytes)) -} - -/// Returns `N` random bytes from the operating system. -pub(crate) fn random_bytes() -> Result<[u8; N]> { - let mut bytes = [0u8; N]; - getrandom::fill(&mut bytes).map_err(|error| { - Exit::error(ErrorCode::Io, format!("cannot read random bytes: {error}")) - })?; - Ok(bytes) -} - -/// Returns `bytes` in lowercase hexadecimal. -pub(crate) fn hex(bytes: &[u8]) -> String { - bytes.iter().map(|byte| format!("{byte:02x}")).collect() -} - /// Atomically replaces `path` with the JSON `text` and a line end, with the line separator of the /// host for each `\n`, in a temporary file of mode 0600 that then replaces `path`. pub(crate) fn write_json(path: &Path, text: &str) -> Result { @@ -280,6 +247,9 @@ struct Record<'a> { scratch_binding: Option<&'a ScratchBinding>, /// The export plan of the shares, or `None` without shares. export: Option<&'a ExportPlan>, + /// The immutable named snapshot that supplies this sandbox's restore view. + #[serde(skip_serializing_if = "Option::is_none")] + source_snapshot: Option<&'a SnapshotName>, } /// Returns the JSON text of the configuration that `provision` records for `launch` and the other @@ -291,6 +261,17 @@ pub(crate) fn serialize_launch( distro: &RecordedDistro, scratch: Option<&RecordedScratch>, config: &ProvisionConfig<'_>, +) -> Result { + serialize_launch_with_snapshot(launch, distro, scratch, config, None) +} + +/// Records a restored sandbox's source alongside its own launch, without a snapshot index. +pub(crate) fn serialize_launch_with_snapshot( + launch: &SandboxLaunch, + distro: &RecordedDistro, + scratch: Option<&RecordedScratch>, + config: &ProvisionConfig<'_>, + source_snapshot: Option<&SnapshotName>, ) -> Result { let layer = &distro.layer; let settings = launch.settings(); @@ -314,6 +295,7 @@ pub(crate) fn serialize_launch( distro_binding: &distro.binding, scratch_binding: scratch.map(|scratch| &scratch.binding), export: launch.export(), + source_snapshot, }; pretty_serde(&record, DESCRIPTION) } @@ -375,9 +357,22 @@ pub(crate) struct Config { /// The sandbox's network, `null` for none, if recorded. #[serde(default, deserialize_with = "present")] network: Option, + /// The source of a restored sandbox, absent for a cold launch. + source_snapshot: Option, } impl Config { + #[cfg_attr( + not(test), + expect( + dead_code, + reason = "SL3.8a locks a restored sandbox's source snapshot" + ) + )] + pub(crate) fn source_snapshot(&self) -> Option<&SnapshotName> { + self.source_snapshot.as_ref() + } + /// Returns the fields of `object`, a configuration that [`read_config`] read, which fails as /// `sandbox configuration is malformed` without a field that it requires, or with a value of /// another type. @@ -403,6 +398,7 @@ impl Config { ), )); } + serde_json::from_value(Value::Object(object)).map_err(|_| malformed()) } diff --git a/nvx-rs/src/host/cli/sandbox/state/tests.rs b/nvx-rs/src/host/cli/sandbox/state/tests.rs new file mode 100644 index 000000000..6ec46cf9b --- /dev/null +++ b/nvx-rs/src/host/cli/sandbox/state/tests.rs @@ -0,0 +1,35 @@ +use super::*; +use crate::host::sandbox::layer::LayerRole; + +#[test] +fn restored_sandbox_records_round_trip_only_their_own_snapshot_name() { + let directory = tempfile::tempdir().unwrap(); + let image = directory.path().join("distro.erofs"); + std::fs::write(&image, b"distro layer\n").unwrap(); + let layer = SandboxLayer::new( + LayerRole::Distro, + image.to_str().unwrap(), + "11111111-1111-1111-1111-111111111111", + ); + let binding = DistroBinding::verify(&layer).unwrap(); + let launch = + SandboxLaunch::new(vec![layer], SandboxUpper::RAM, SandboxSettings::default()).unwrap(); + let distro = RecordedDistro::new(&launch, binding).unwrap(); + let options = ProvisionConfig { + hypervisor: "whp", + memory_mib: 128, + cmdline: "", + }; + for source in [None, Some("My.snapshot-1"), Some("other")] { + let name = source.map(|name| SnapshotName::try_from(name.to_owned()).unwrap()); + let text = serialize_launch_with_snapshot(&launch, &distro, None, &options, name.as_ref()) + .unwrap(); + let object: Object = serde_json::from_str(&text).unwrap(); + assert_eq!(object.contains_key("source_snapshot"), source.is_some()); + let record = Config::new(object.clone()).unwrap(); + assert_eq!(record.source_snapshot(), name.as_ref()); + let mut malformed = object; + malformed.insert("source_snapshot".to_owned(), Value::from("../other")); + assert!(Config::new(malformed).is_err()); + } +} diff --git a/nvx-rs/src/host/cli/snapshots.rs b/nvx-rs/src/host/cli/snapshots.rs new file mode 100644 index 000000000..044c8fd6c --- /dev/null +++ b/nvx-rs/src/host/cli/snapshots.rs @@ -0,0 +1,121 @@ +//! Immutable named snapshots and their external, exclusive locks. +//! +//! Names map to hexadecimal directory names under `snapshots/`. Each published directory +//! contains only the strict snapshot's three entries; its lock stays under `locks/`. +//! Completed private directories under `staging/` publish with one non-replacing rename. +#![cfg_attr( + not(test), + expect(dead_code, reason = "SL3.7b and SL3.8a use the named snapshot store") +)] + +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use super::deadline::Deadline; +use super::store::{LockedState, StateDir}; +use super::{Exit, Result, hex, io_error, temporary_path}; +use crate::host::snapshot::Snapshot; +use crate::host::{platform, store}; +use crate::{Error, ErrorCode}; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(try_from = "String")] +pub(crate) struct Name(String); + +impl TryFrom for Name { + type Error = Error; + + fn try_from(value: String) -> crate::Result { + if !(1..=64).contains(&value.len()) + || !value.as_bytes()[0].is_ascii_alphanumeric() + || !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b"._-".contains(&byte)) + { + return Err(Error::unsupported( + "a snapshot name must be 1 to 64 ASCII letters, digits, dots, underscores or \ + hyphens, starting with a letter or digit", + )); + } + Ok(Self(value)) + } +} + +#[derive(Debug)] +pub(crate) struct Store { + root: StateDir, + entries: StateDir, + locks: StateDir, + staging: StateDir, +} + +impl Store { + pub(crate) fn create(root: &Path) -> Result { + let root = StateDir::create(&std::path::absolute(root).map_err(Exit::from_os_error)?)?; + Ok(Self { + entries: StateDir::create(&root.dir().join("snapshots"))?, + locks: StateDir::create(&root.dir().join("locks"))?, + staging: StateDir::create(&root.dir().join("staging"))?, + root, + }) + } + + fn check(&self) -> Result { + for directory in [&self.root, &self.entries, &self.locks, &self.staging] { + StateDir::open(directory.dir())?; + } + Ok(()) + } + + pub(crate) fn lock(&self, name: &Name, deadline: Deadline) -> Result> { + self.check()?; + // Hex preserves case distinctions and avoids reserved host filenames. + let directory = hex(name.0.as_bytes()); + let lock = StateDir::create(&self.locks.dir().join(&directory))?.lock(deadline)?; + Ok(LockedSnapshot { + store: self, + dir: self.entries.dir().join(directory), + _lock: lock, + }) + } +} + +#[derive(Debug)] +pub(crate) struct LockedSnapshot<'a> { + store: &'a Store, + dir: PathBuf, + _lock: LockedState, +} + +impl LockedSnapshot<'_> { + pub(crate) fn stage(&self) -> Result { + self.store.check()?; + StateDir::create(&temporary_path(&self.store.staging.dir().join("snapshot"))?) + } + + pub(crate) fn open(&self) -> Result { + self.store.check()?; + Snapshot::open(&self.dir).map_err(Into::into) + } + + pub(crate) fn publish(&self, staged: StateDir) -> Result { + self.store.check()?; + if staged.dir().parent() != Some(self.store.staging.dir()) { + return Err(Exit::error( + ErrorCode::Io, + "only a private staging directory can publish a named snapshot", + )); + } + Snapshot::open(staged.dir())?; + store::files::sync_dir(staged.dir())?; + platform::rename_no_replace(staged.dir(), &self.dir) + .map_err(|error| io_error(ErrorCode::Io, &error, &self.dir))?; + store::files::sync_dir(self.store.entries.dir())?; + store::files::sync_dir(self.store.staging.dir())?; + self.open() + } +} + +#[cfg(test)] +mod tests; diff --git a/nvx-rs/src/host/cli/snapshots/tests.rs b/nvx-rs/src/host/cli/snapshots/tests.rs new file mode 100644 index 000000000..4b75fa374 --- /dev/null +++ b/nvx-rs/src/host/cli/snapshots/tests.rs @@ -0,0 +1,238 @@ +use std::fs; +use std::path::Path; + +use super::*; +use crate::host::snapshot::tests::SnapshotFixture; +use crate::host::snapshot::{DESCRIPTION_FILE, KEY_FILE, MANIFEST_NAME, OPENVMM_DIR}; +use crate::host::tests::released; + +fn name(value: &str) -> Name { + Name::try_from(value.to_owned()).unwrap() +} + +fn staged(locked: &LockedSnapshot<'_>) -> StateDir { + let fixture = SnapshotFixture::new(); + let staged = locked.stage().unwrap(); + for entry in [KEY_FILE, DESCRIPTION_FILE, OPENVMM_DIR] { + fs::rename(fixture.dir.join(entry), staged.dir().join(entry)).unwrap(); + } + staged +} + +fn link_directory(link: &Path, target: &Path) { + #[cfg(unix)] + std::os::unix::fs::symlink(target, link).unwrap(); + #[cfg(windows)] + crate::host::tests::junction(link, target); +} + +#[test] +fn names_are_bounded_portable_components_with_distinct_mappings() { + for invalid in [ + "", + ".", + "..", + ".hidden", + "../escape", + "a/b", + "a\\b", + "a:b", + "a b", + "a\nb", + "snapshot\u{e9}", + ] { + assert!(Name::try_from(invalid.to_owned()).is_err(), "{invalid:?}"); + } + assert!(Name::try_from("a".repeat(65)).is_err()); + assert!(Name::try_from("a".repeat(64)).is_ok()); + let root = tempfile::tempdir().unwrap(); + let store = Store::create(&root.path().join("store")).unwrap(); + let mut mapped = Vec::new(); + for valid in ["a", "CON", "con.", "A", "name.1-2_3"] { + let held = store.lock(&name(valid), Deadline::after(0.0)).unwrap(); + assert_eq!(held.dir.parent(), Some(store.entries.dir())); + assert!(!held.dir.exists()); + assert!(!held._lock.dir().starts_with(&held.dir)); + mapped.push( + held.dir + .file_name() + .unwrap() + .to_str() + .unwrap() + .to_ascii_lowercase(), + ); + } + mapped.sort(); + mapped.dedup(); + assert_eq!(mapped.len(), 5); +} + +#[test] +fn publication_is_strict_whole_and_cannot_replace_or_move_a_published_snapshot() { + let root = tempfile::tempdir().unwrap(); + let store = Store::create(&root.path().join("store")).unwrap(); + let held = store.lock(&name("first"), Deadline::after(0.0)).unwrap(); + let pending = staged(&held); + fs::write(pending.dir().join("log"), b"not a snapshot entry").unwrap(); + assert!(held.publish(pending.clone()).is_err()); + assert!(pending.dir().exists()); + assert!(!held.dir.exists()); + fs::remove_file(pending.dir().join("log")).unwrap(); + let expected = Snapshot::open(pending.dir()).unwrap(); + let key = fs::read(pending.dir().join(KEY_FILE)).unwrap(); + let published = held.publish(pending.clone()).unwrap(); + assert!(!pending.dir().exists()); + assert_eq!(published.key(), expected.key()); + assert_eq!(published.manifest(), expected.manifest()); + let mut entries: Vec<_> = fs::read_dir(&held.dir) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .collect(); + entries.sort(); + assert_eq!(entries, [KEY_FILE, OPENVMM_DIR, DESCRIPTION_FILE]); + let files = [ + held.dir.join(KEY_FILE), + held.dir.join(DESCRIPTION_FILE), + held.dir.join(OPENVMM_DIR).join(MANIFEST_NAME), + held.dir.join(OPENVMM_DIR).join("memory.bin"), + held.dir.join(OPENVMM_DIR).join("state.bin"), + ]; + let unchanged = || { + files + .iter() + .map(|path| { + let metadata = fs::metadata(path).unwrap(); + ( + platform::file_id(path).unwrap(), + metadata.len(), + metadata.modified().unwrap(), + ) + }) + .collect::>() + }; + let before = unchanged(); + let replacement = staged(&held); + assert!(held.publish(replacement.clone()).is_err()); + assert!(replacement.dir().exists()); + assert!(held.publish(StateDir::open(&held.dir).unwrap()).is_err()); + assert_eq!(fs::read(held.dir.join(KEY_FILE)).unwrap(), key); + assert_eq!( + fs::read(held.dir.join(DESCRIPTION_FILE)).unwrap(), + b"not JSON" + ); + assert_eq!(held.open().unwrap().key(), published.key()); + assert_eq!(unchanged(), before); +} + +#[test] +fn invalid_or_linked_directories_never_publish_or_open() { + let root = tempfile::tempdir().unwrap(); + let store = Store::create(&root.path().join("store")).unwrap(); + let held = store.lock(&name("first"), Deadline::after(0.0)).unwrap(); + let pending = staged(&held); + crate::host::store::files::write_private(&pending.dir().join(KEY_FILE), b"bad key").unwrap(); + assert!(held.publish(pending.clone()).is_err()); + assert!(!held.dir.exists()); + let valid = staged(&held); + let expected = Snapshot::open(valid.dir()).unwrap(); + let identity = platform::file_id(valid.dir()).unwrap(); + let key = fs::read(valid.dir().join(KEY_FILE)).unwrap(); + let linked = held.stage().unwrap(); + fs::remove_dir(linked.dir()).unwrap(); + link_directory(linked.dir(), valid.dir()); + assert!(held.publish(linked).is_err()); + assert!(!held.dir.exists()); + link_directory(&held.dir, valid.dir()); + assert!(held.open().is_err()); + assert!(held.publish(staged(&held)).is_err()); + assert_eq!(platform::file_id(valid.dir()).unwrap(), identity); + assert_eq!(fs::read(valid.dir().join(KEY_FILE)).unwrap(), key); + assert_eq!(Snapshot::open(valid.dir()).unwrap().key(), expected.key()); + let alias = root.path().join("alias"); + link_directory(&alias, store.root.dir()); + assert!(Store::create(&alias).is_err()); +} + +#[test] +fn exclusive_snapshot_locks_wait_and_other_names_remain_independent() { + let root = tempfile::tempdir().unwrap(); + let store = Store::create(&root.path().join("store")).unwrap(); + let first = store.lock(&name("first"), Deadline::after(0.0)).unwrap(); + let other_handle = Store::create(store.root.dir()).unwrap(); + assert!(matches!( + other_handle.lock(&name("first"), Deadline::after(0.0)), + Err(Exit::Error { + code: ErrorCode::SandboxBusy, + .. + }) + )); + let second = store.lock(&name("second"), Deadline::after(0.0)).unwrap(); + assert_ne!(first._lock.dir(), second._lock.dir()); + drop(first); + assert!(released(|| { + other_handle + .lock(&name("first"), Deadline::after(0.0)) + .is_ok() + })); + assert_eq!(fs::read_dir(store.entries.dir()).unwrap().count(), 0); +} + +#[cfg(target_os = "linux")] +#[test] +fn shared_directories_and_linked_locks_are_refused_without_restricting_them() { + use std::os::unix::fs::{PermissionsExt, symlink}; + + let root = tempfile::tempdir().unwrap(); + let shared = root.path().join("shared"); + fs::create_dir(&shared).unwrap(); + fs::set_permissions(&shared, fs::Permissions::from_mode(0o755)).unwrap(); + assert!(Store::create(&shared).is_err()); + assert_eq!( + fs::metadata(&shared).unwrap().permissions().mode() & 0o777, + 0o755 + ); + let store = Store::create(&root.path().join("store")).unwrap(); + let held = store.lock(&name("first"), Deadline::after(0.0)).unwrap(); + let pending = staged(&held); + fs::set_permissions(pending.dir(), fs::Permissions::from_mode(0o755)).unwrap(); + assert!(held.publish(pending).is_err()); + let lock = held._lock.path(super::super::store::StateFile::Lock); + drop(held); + fs::remove_file(&lock).unwrap(); + let target = root.path().join("target"); + fs::write(&target, b"unchanged").unwrap(); + symlink(&target, &lock).unwrap(); + assert!(store.lock(&name("first"), Deadline::after(0.0)).is_err()); + assert_eq!(fs::read(&target).unwrap(), b"unchanged"); +} + +#[cfg(windows)] +#[test] +fn shared_directory_acls_are_refused_before_publication() { + use std::process::{Command, Stdio}; + + let allow_everyone = |path: &Path| { + let status = crate::host::tests::spawn_locked( + Command::new("icacls") + .arg(path) + .args(["/grant", "*S-1-1-0:(OI)(CI)(F)"]) + .stdout(Stdio::null()), + ) + .unwrap() + .wait() + .unwrap(); + assert!(status.success()); + }; + let root = tempfile::tempdir().unwrap(); + let shared = StateDir::create(&root.path().join("shared")).unwrap(); + allow_everyone(shared.dir()); + assert!(Store::create(shared.dir()).is_err()); + assert!(platform::check_store_dir(shared.dir()).unwrap().is_some()); + let store = Store::create(&root.path().join("store")).unwrap(); + let held = store.lock(&name("first"), Deadline::after(0.0)).unwrap(); + let pending = staged(&held); + allow_everyone(&pending.dir().join(OPENVMM_DIR)); + assert!(held.publish(pending.clone()).is_err()); + assert!(pending.dir().exists()); + assert!(!held.dir.exists()); +} diff --git a/nvx-rs/src/host/cli/store/mod.rs b/nvx-rs/src/host/cli/store/mod.rs index ac58708e0..16330a7d9 100644 --- a/nvx-rs/src/host/cli/store/mod.rs +++ b/nvx-rs/src/host/cli/store/mod.rs @@ -51,6 +51,7 @@ mod record; use std::io; use std::path::{Path, PathBuf}; +pub(crate) use self::locked::LockedState; pub(crate) use self::names::StateFile; use crate::ErrorCode; use crate::host::cli::{Exit, Result}; diff --git a/scripts/test-layout-allowlist.json b/scripts/test-layout-allowlist.json index 399c715f3..9d5af723f 100644 --- a/scripts/test-layout-allowlist.json +++ b/scripts/test-layout-allowlist.json @@ -11,6 +11,8 @@ "nvx-rs/src/guest/sys/entry.rs": 3, "nvx-rs/src/guest/sys/spawn.rs": 7, "nvx-rs/src/guest/time/system.rs": 3, + "nvx-rs/src/host/cli/sandbox/state.rs": 1, + "nvx-rs/src/host/cli/snapshots.rs": 1, "nvx-rs/src/host/cli/store/mod.rs": 1, "nvx-rs/src/host/network/address.rs": 2, "nvx-rs/src/host/platform/windows/spawn.rs": 1,