Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 40 additions & 1 deletion nvx-rs/src/host/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PathBuf> {
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<String> {
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<const N: usize>() -> 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.
Expand Down
76 changes: 36 additions & 40 deletions nvx-rs/src/host/cli/sandbox/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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<PathBuf> {
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<String> {
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<const N: usize>() -> 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 {
Expand Down Expand Up @@ -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
Expand All @@ -291,6 +261,17 @@ pub(crate) fn serialize_launch(
distro: &RecordedDistro,
scratch: Option<&RecordedScratch>,
config: &ProvisionConfig<'_>,
) -> Result<String> {
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<String> {
let layer = &distro.layer;
let settings = launch.settings();
Expand All @@ -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)
}
Expand Down Expand Up @@ -375,9 +357,22 @@ pub(crate) struct Config {
/// The sandbox's network, `null` for none, if recorded.
#[serde(default, deserialize_with = "present")]
network: Option<Value>,
/// The source of a restored sandbox, absent for a cold launch.
source_snapshot: Option<SnapshotName>,
}

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.
Expand All @@ -403,6 +398,7 @@ impl Config {
),
));
}

serde_json::from_value(Value::Object(object)).map_err(|_| malformed())
}

Expand Down
35 changes: 35 additions & 0 deletions nvx-rs/src/host/cli/sandbox/state/tests.rs
Original file line number Diff line number Diff line change
@@ -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());
}
}
121 changes: 121 additions & 0 deletions nvx-rs/src/host/cli/snapshots.rs
Original file line number Diff line number Diff line change
@@ -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<String> for Name {
type Error = Error;

fn try_from(value: String) -> crate::Result<Self> {
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<Self> {
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<LockedSnapshot<'_>> {
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<StateDir> {
self.store.check()?;
StateDir::create(&temporary_path(&self.store.staging.dir().join("snapshot"))?)
}

pub(crate) fn open(&self) -> Result<Snapshot> {
self.store.check()?;
Snapshot::open(&self.dir).map_err(Into::into)
}

pub(crate) fn publish(&self, staged: StateDir) -> Result<Snapshot> {
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;
Loading
Loading