diff --git a/nvx-rs/src/host/cli/sandbox/lifecycle/mod.rs b/nvx-rs/src/host/cli/sandbox/lifecycle/mod.rs index 589471f2b..65f8319d7 100644 --- a/nvx-rs/src/host/cli/sandbox/lifecycle/mod.rs +++ b/nvx-rs/src/host/cli/sandbox/lifecycle/mod.rs @@ -31,16 +31,18 @@ use crate::host::cli::artifacts::{Located, locate}; use crate::host::cli::deadline::RETRY_INTERVAL; use crate::host::cli::sandbox::lock::LifecycleLock; use crate::host::cli::sandbox::state::{ - Config, NetworkRecord, RecordedDistro, RecordedScratch, Source, read_config, serialize_launch, - write_json, + Config, NetworkRecord, RecordedDistro, RecordedScratch, Source, check_images, read_config, + serialize_launch, write_json, }; use crate::host::cli::sandbox::{Artifacts, error_with_cause}; use crate::host::cli::session::Session; use crate::host::cli::store::StateFile; use crate::host::cli::{Exit, Result}; +use crate::host::filesystem::ExportPlan; use crate::host::process::{Opened, Process}; use crate::host::runtime::{IdentityPart, LaunchOptions, Runtime, RuntimeIdentity}; -use crate::host::sandbox::{SandboxLaunch, require_file}; +use crate::host::sandbox::layer::SandboxLayer; +use crate::host::sandbox::require_file; use crate::host::vm::SavedVm; use crate::protocol::GuestFeatures; @@ -59,7 +61,7 @@ pub(crate) const LOCK_RETRY_INTERVAL: Duration = RETRY_INTERVAL; /// The version of outcome reports. pub(crate) const OUTCOME_SCHEMA_VERSION: u64 = 1; -/// The options of a launch, besides the launch itself, that `provision` records. +/// The options of a launch, besides its images, that `provision` records. pub(crate) struct ProvisionConfig<'a> { /// `--hypervisor`, resolved. pub(crate) hypervisor: &'a str, @@ -69,6 +71,12 @@ pub(crate) struct ProvisionConfig<'a> { pub(crate) cmdline: &'a str, /// The RAM upper's cap, or the default when omitted. pub(crate) ram_upper_percent: Option, + /// `--hostname`. + pub(crate) hostname: &'a str, + /// `--workload-user`. + pub(crate) workload_identity: (u32, u32), + /// The export plan of the shares, if any. + pub(crate) export: Option<&'a ExportPlan>, /// The network options, or `None` without `--net`. pub(crate) network: Option<&'a NetworkRecord>, } @@ -77,13 +85,16 @@ pub(crate) struct ProvisionConfig<'a> { /// `nvx-rs sandbox`. pub(crate) const REQUIRED_FEATURES: GuestFeatures = GuestFeatures::LAYERED_SANDBOX; -/// Records a launch and its distro layer, at the path at which it was verified, with the layer's -/// binding, in a new state directory, as `provision` does, with the binding of its scratch image, -/// if its upper is on scratch, at the path that it records for it, and `source`, the named -/// snapshot that each start of a sandbox of `snapshot restore` restores, or `None`. +/// Records the launch of the distro layer `distro`, at the path at which it was verified, with +/// the layer's binding, over `scratch`, the scratch image of an upper on scratch, with the options +/// of `config`, in a new state directory, as `provision` does, with the binding of its scratch +/// image at the path that it records for it, and `source`, the named snapshot that each start of +/// a sandbox of `snapshot restore` restores, or `None`. `layer` is the distro layer as given, +/// whose images it checks again, with the export plan, as [`check_images`] does. pub(crate) fn provision( state_path: &str, - launch: &SandboxLaunch, + layer: &SandboxLayer, + scratch: Option<&Path>, distro: &RecordedDistro, config: &ProvisionConfig<'_>, source: Option<&Source>, @@ -99,11 +110,11 @@ pub(crate) fn provision( "sandbox is already provisioned", )); } - launch.validated()?; - let scratch = RecordedScratch::new(launch)?; + check_images(layer, scratch, config.export)?; + let scratch = RecordedScratch::new(scratch)?; write_json( &config_path, - &serialize_launch(launch, distro, scratch.as_ref(), config, source)?, + &serialize_launch(distro, scratch.as_ref(), config, source)?, ) } diff --git a/nvx-rs/src/host/cli/sandbox/lifecycle/start.rs b/nvx-rs/src/host/cli/sandbox/lifecycle/start.rs index 011ab2541..be0b5d1ec 100644 --- a/nvx-rs/src/host/cli/sandbox/lifecycle/start.rs +++ b/nvx-rs/src/host/cli/sandbox/lifecycle/start.rs @@ -24,10 +24,10 @@ use crate::host::cli::download::Hold; use crate::host::cli::exec_lock::ExecLock; use crate::host::cli::sandbox::lock::LifecycleLock; use crate::host::cli::sandbox::state::{ - Config, NetworkRecord, RecordedDistro, RecordedScratch, Source, read_config, serialize_launch, + Config, RecordedDistro, RecordedScratch, Source, read_config, serialize_launch, }; use crate::host::cli::sandbox::{ - Artifacts, Options, error_with_cause, hypervisor, path_string, refuse_own_files, + Artifacts, Options, error_with_cause, path_string, refuse_own_files, }; use crate::host::cli::session::{ExecRequest, ExecResult, Session}; use crate::host::cli::shell::format_command; @@ -41,9 +41,9 @@ use crate::host::report::Report; use crate::host::restore::RestoreEvidence; use crate::host::restore_driver::{self, Cancel, ControlRestoreError, Workload}; use crate::host::runtime::{ArtifactIdentity, Runtime, RuntimeIdentity}; -use crate::host::sandbox::SandboxLaunch; use crate::host::sandbox::distro::DISTRO_POLICY_REVISION; use crate::host::sandbox::scratch::{CLAIM_RELEASE_WAIT, CheckedScratch}; +use crate::host::sandbox::{SandboxLaunch, SandboxSettings, SandboxUpper}; use crate::host::session::{ExecEvent, SessionError}; use crate::host::snapshot::OPENVMM_DIR; use crate::host::vm::{self, Launched, Prepared, SavedVm, VmDir}; @@ -98,42 +98,51 @@ fn start_locked( Found::Mismatched(saved, _, differences) => return Err(mismatched(&saved, &differences)), } let config = Config::new(config)?; - let launch = config.launch()?; + config.check()?; let source = config.source()?; + let image = config.distro(); + let scratch_image = config.scratch(); refuse_own_files( Some(("state directory", state_dir)), - &launch, + image.path(), + scratch_image.as_deref(), + config.export(), Some(&locate(artifacts.root)?), )?; // The view of each restore links the snapshot's files, which a share of the store would // give the guest to change. if let Some(source) = &source { - refuse_own_files(Some(("snapshot store", &source.store)), &launch, None)?; + refuse_own_files( + Some(("snapshot store", &source.store)), + image.path(), + scratch_image.as_deref(), + config.export(), + None, + )?; } let binding = config.distro_binding(); let scratch_binding = config.scratch_binding()?; let distro = binding - .check(launch.distro(), DISTRO_POLICY_REVISION) + .check(&image, DISTRO_POLICY_REVISION) .map_err(binding_error)?; - let scratch = match (scratch_binding, launch.scratch()) { + let scratch = match (scratch_binding, scratch_image) { (Some(scratch_binding), Some(scratch)) => Some( scratch_binding - .check(scratch, Instant::now() + CLAIM_RELEASE_WAIT) + .check(&scratch, Instant::now() + CLAIM_RELEASE_WAIT) .map_err(binding_error)?, ), _ => None, }; - let plan = launch.bound_plan(binding)?; // Keep provision's refusal of caller-owned nvx_* tokens, without launching its old plan. - plan.kernel_command_line(config.cmdline())?; + check_command_line(&config)?; artifacts.require()?; let runtime = runtime_for(artifacts, &config, artifacts.launch_identity()?)?; let restore = source.as_ref().map(|source| Restore { source, - identity: launch.settings().workload_identity, + identity: config.workload_identity(), forward_status, }); - let config = config.vm_config(&launch)?; + let config = config.vm_config()?; let started = launch_locked(lock, &runtime, config, restore, scratch, timeout, vm::spawn); drop(distro); started @@ -537,11 +546,11 @@ pub(crate) fn exec_workload( /// Runs one workload in a VM of its own and stops the VM, as `sandbox run` does, and returns how /// the workload ended, or nothing for a dry run, which launches nothing. /// -/// The VM is the one that `start` launches for the configuration that `provision` records for -/// `launch`, whose distro layer `distro` is at the path that `provision` records, and `network`, -/// the record of its network options, if any, with the options of `options`: the run serializes -/// that configuration and reads it back as `start` does, then launches it as `start` does, but in -/// a temporary VM directory rather than a state directory. +/// The VM is the one that `start` launches for the configuration that `provision` records for the +/// options of `provision`, with the distro layer `distro` at the path that `provision` records, +/// over `scratch`, the scratch image of an upper on scratch, as given, and with the options of +/// `options`: the run serializes that configuration and reads it back as `start` does, then +/// launches it as `start` does, but in a temporary VM directory rather than a state directory. /// Before it downloads or launches anything, it refuses what `start` refuses of the configuration /// and `exec` of `request`, after a missing artifact; it then refuses the shares that `start` /// refuses, with its VM directory in place of a state directory, and those of the OpenVMM that it @@ -554,45 +563,37 @@ pub(crate) fn exec_workload( /// removes the VM directory, which it keeps only if it cannot confirm that exit. It holds `held`, /// the lock of the downloads, until the VM is ready or has failed to be. pub(crate) fn run( - launch: &SandboxLaunch, distro: &RecordedDistro, - network: Option<&NetworkRecord>, + scratch: Option<&Path>, + provision: &ProvisionConfig<'_>, artifacts: &Artifacts<'_>, options: &Options, request: &ExecRequest, held: Hold, ) -> Result> { - let hypervisor = hypervisor(options.hypervisor); - let provision = ProvisionConfig { - hypervisor, - memory_mib: options.memory_mib, - cmdline: &options.cmdline, - ram_upper_percent: options.ram_upper_percent, - network, - }; - let recorded = record(launch, distro, &provision).and_then(|(config, launch)| { + let recorded = record(distro, scratch, provision).and_then(|config| { // As `start` does, refuse the user's own nvx_* tokens, and the command line that the // launch would refuse, such as one that holds double quotes. - launch - .bound_plan(config.distro_binding())? - .kernel_command_line(config.cmdline())?; - let vm_config = config.vm_config(&launch)?; + check_command_line(&config)?; + let vm_config = config.vm_config()?; vm_config.check_command_line().map_err(error_with_cause)?; Session::exec_payload(request)?; - Ok((config, launch, vm_config)) + Ok((config, vm_config)) }); // A run that its options refuse downloads nothing, and reports a missing artifact first. let hold = match recorded { - Ok(_) => artifacts.ensure_under(held, hypervisor)?, + Ok(_) => artifacts.ensure_under(held, provision.hypervisor)?, Err(_) => held, }; artifacts.require()?; - let (config, launch, vm_config) = recorded?; + let (config, vm_config) = recorded?; let name = hex(&random_bytes::<16>()?); let path = std::env::temp_dir().join(format!("nvx-sandbox-run-{name}")); refuse_own_files( Some(("VM directory", &path)), - &launch, + config.distro().path(), + config.scratch().as_deref(), + config.export(), Some(&locate(artifacts.root)?), )?; if !options.dry_run @@ -612,12 +613,12 @@ pub(crate) fn run( } else { let distro = config .distro_binding() - .check(launch.distro(), DISTRO_POLICY_REVISION) + .check(&config.distro(), DISTRO_POLICY_REVISION) .map_err(error_with_cause)?; - let scratch = match (config.scratch_binding()?, launch.scratch()) { + let scratch = match (config.scratch_binding()?, config.scratch()) { (Some(binding), Some(scratch)) => Some( binding - .check(scratch, Instant::now() + CLAIM_RELEASE_WAIT) + .check(&scratch, Instant::now() + CLAIM_RELEASE_WAIT) .map_err(error_with_cause)?, ), _ => None, @@ -749,22 +750,45 @@ pub(crate) fn run( result } -/// Returns the configuration that `provision` records for `launch`, whose distro layer `distro` is -/// at the path that `provision` records, with the options of `config`, as `start` reads it back, -/// and the launch that `start` makes of it, which checks its shares again. +/// Returns the configuration that `provision` records for the options of `config`, with the +/// distro layer `distro` at the path that `provision` records, over `scratch`, the scratch image of +/// an upper on scratch, as given, as `start` reads it back and checks it, shares included. fn record( - launch: &SandboxLaunch, distro: &RecordedDistro, + scratch: Option<&Path>, config: &ProvisionConfig<'_>, -) -> Result<(Config, SandboxLaunch)> { - let scratch = RecordedScratch::new(launch)?; - let record = serialize_launch(launch, distro, scratch.as_ref(), config, None)?; +) -> Result { + let scratch = RecordedScratch::new(scratch)?; + let record = serialize_launch(distro, scratch.as_ref(), config, None)?; let config = Config::new( serde_json::from_str(&record) .map_err(|error| Exit::error(ErrorCode::Other, error.to_string()))?, )?; - let launch = config.launch()?; - Ok((config, launch)) + config.check()?; + Ok(config) +} + +/// Refuses the user's kernel command line of `config` as the plan of the sandbox profile refuses +/// it: a NUL; a token that the profile or the `--mount` option owns, an `nvx_*` or `virtfs_*` one, +/// as Linux or the guest's init reads it; and a line that, with the boot tokens of the root and +/// the bootstrap tokens of the shares, exceeds its 1024-byte x86 budget, or conflicts with or hides +/// those of the root. It builds that plan from the configuration: the one part of a start and a +/// run that still goes through the old `host::sandbox` launch. +fn check_command_line(config: &Config) -> Result { + let upper = match config.scratch() { + Some(scratch) => SandboxUpper::scratch_at(scratch), + None => SandboxUpper::RAM, + }; + let settings = SandboxSettings { + hostname: config.hostname().to_owned(), + workload_identity: config.workload_identity(), + ..SandboxSettings::default() + }; + SandboxLaunch::new(vec![config.distro()], upper, settings)? + .with_export(config.export().cloned())? + .bound_plan(config.distro_binding())? + .kernel_command_line(config.cmdline())?; + Ok(()) } /// Removes the temporary VM directory of a run, with what its VM left in it: OpenVMM's log and diff --git a/nvx-rs/src/host/cli/sandbox/lifecycle/start/tests.rs b/nvx-rs/src/host/cli/sandbox/lifecycle/start/tests.rs index 3e35b3a9e..9f4825883 100644 --- a/nvx-rs/src/host/cli/sandbox/lifecycle/start/tests.rs +++ b/nvx-rs/src/host/cli/sandbox/lifecycle/start/tests.rs @@ -1,13 +1,15 @@ //! Tests of the configuration that a run launches, and of the removal of its VM directory. use std::fs; +use std::path::PathBuf; use super::*; use crate::host::paths::resolve; +use crate::host::sandbox::distro::DistroBinding; use crate::host::sandbox::layer::{LayerRole, SandboxLayer}; use crate::host::sandbox::mount::{plan_mounts, validate_mounts}; -use crate::host::sandbox::options::{sandbox_mounts, validate_sandbox_systemd_policy}; -use crate::host::sandbox::{SandboxSettings, SandboxUpper}; +use crate::host::sandbox::options::sandbox_mounts; +use crate::host::sandbox::{DEFAULT_HOSTNAME, DEFAULT_WORKLOAD_IDENTITY}; /// The starts of restored sandboxes, with a fake OpenVMM and guest in place of OpenVMM's restore, /// which the tests fake on Linux and Windows. @@ -37,36 +39,34 @@ fn run_checks_again_that_its_shares_do_not_overlap() { ) .unwrap(); validate_mounts(&mounts).unwrap(); - let launch = SandboxLaunch::new( - vec![SandboxLayer::new( - LayerRole::Distro, - &path("distro.erofs"), - "11111111-1111-1111-1111-111111111111", - )], - SandboxUpper::scratch(&path("scratch.ext4")), - SandboxSettings::default(), - ) - .unwrap() - .with_export(plan_mounts(&mounts).unwrap()) - .unwrap(); - let binding = validate_sandbox_systemd_policy(&launch).unwrap(); - let distro = RecordedDistro::new(&launch, binding).unwrap(); + let layer = SandboxLayer::new( + LayerRole::Distro, + &path("distro.erofs"), + "11111111-1111-1111-1111-111111111111", + ); + let scratch = PathBuf::from(path("scratch.ext4")); + let export = plan_mounts(&mounts).unwrap(); + let binding = DistroBinding::verify(&layer).unwrap(); + let distro = RecordedDistro::new(&layer, binding).unwrap(); let config = ProvisionConfig { hypervisor: "kvm", memory_mib: 256, cmdline: "", ram_upper_percent: None, + hostname: DEFAULT_HOSTNAME, + workload_identity: DEFAULT_WORKLOAD_IDENTITY, + export: export.as_ref(), network: None, }; - record(&launch, &distro, &config).unwrap(); + record(&distro, Some(&scratch), &config).unwrap(); // A hard link that replaced the read-only file with the writable one while the run verified // its distro layer would export one file under both access modes; the export plan's own // checks pass, since a file share's root is not pinned. fs::remove_file(path("settings.json")).unwrap(); fs::hard_link(path("output.log"), path("settings.json")).unwrap(); - launch.export().unwrap().verify().unwrap(); - let Err(error) = record(&launch, &distro, &config) else { + export.as_ref().unwrap().verify().unwrap(); + let Err(error) = record(&distro, Some(&scratch), &config) else { panic!("the run launched shares that came to overlap"); }; assert_eq!( diff --git a/nvx-rs/src/host/cli/sandbox/lifecycle/start/tests/restore.rs b/nvx-rs/src/host/cli/sandbox/lifecycle/start/tests/restore.rs index 1257c9523..d9a64a364 100644 --- a/nvx-rs/src/host/cli/sandbox/lifecycle/start/tests/restore.rs +++ b/nvx-rs/src/host/cli/sandbox/lifecycle/start/tests/restore.rs @@ -27,7 +27,7 @@ use crate::host::sandbox::distro::DistroBinding; use crate::host::sandbox::layer::{LayerRole, SandboxLayer}; use crate::host::sandbox::mount::{plan_mounts, validate_mounts}; use crate::host::sandbox::options::sandbox_mounts; -use crate::host::sandbox::{SandboxLaunch, SandboxSettings, SandboxUpper}; +use crate::host::sandbox::{DEFAULT_HOSTNAME, DEFAULT_WORKLOAD_IDENTITY}; use crate::host::snapshot::tests::RestoreFixture; use crate::host::snapshot::{DESCRIPTION_FILE, KEY_FILE, OPENVMM_DIR}; use crate::protocol::root::RootImage; @@ -678,17 +678,18 @@ fn deprovision_removes_a_restored_sandboxs_view_only_under_its_snapshots_lock() "11111111-1111-1111-1111-111111111111", ); let binding = DistroBinding::verify(&layer).unwrap(); - let launch = - SandboxLaunch::new(vec![layer], SandboxUpper::RAM, SandboxSettings::default()).unwrap(); let options = ProvisionConfig { hypervisor: "kvm", memory_mib: 512, cmdline: "", ram_upper_percent: None, + hostname: DEFAULT_HOSTNAME, + workload_identity: DEFAULT_WORKLOAD_IDENTITY, + export: None, network: None, }; - let distro = RecordedDistro::new(&launch, binding).unwrap(); - let text = serialize_launch(&launch, &distro, None, &options, Some(&source)).unwrap(); + let distro = RecordedDistro::new(&layer, binding).unwrap(); + let text = serialize_launch(&distro, None, &options, Some(&source)).unwrap(); write_json(&state_dir.join(CONFIG_NAME), &text).unwrap(); let state_path = state_dir.to_str().unwrap(); let artifacts = Artifacts { root: None }; @@ -728,20 +729,20 @@ fn a_restored_start_refuses_a_share_of_its_snapshot_store() { let store = fixture.root.path().join("store"); let mounts = sandbox_mounts(&[format!("/data,{},rw", store.display())], &[], None).unwrap(); validate_mounts(&mounts).unwrap(); - let launch = SandboxLaunch::new(vec![layer], SandboxUpper::RAM, SandboxSettings::default()) - .unwrap() - .with_export(plan_mounts(&mounts).unwrap()) - .unwrap(); + let export = plan_mounts(&mounts).unwrap(); let options = ProvisionConfig { hypervisor: "kvm", memory_mib: 512, cmdline: "", ram_upper_percent: None, + hostname: DEFAULT_HOSTNAME, + workload_identity: DEFAULT_WORKLOAD_IDENTITY, + export: export.as_ref(), network: None, }; - let distro = RecordedDistro::new(&launch, binding).unwrap(); + let distro = RecordedDistro::new(&layer, binding).unwrap(); let source = fixture.source(); - let text = serialize_launch(&launch, &distro, None, &options, Some(&source)).unwrap(); + let text = serialize_launch(&distro, None, &options, Some(&source)).unwrap(); write_json(&state_dir.join(CONFIG_NAME), &text).unwrap(); let root = fixture.state("checkout"); let artifacts = Artifacts { diff --git a/nvx-rs/src/host/cli/sandbox/mod.rs b/nvx-rs/src/host/cli/sandbox/mod.rs index ff3edcb3f..8055b6250 100644 --- a/nvx-rs/src/host/cli/sandbox/mod.rs +++ b/nvx-rs/src/host/cli/sandbox/mod.rs @@ -28,14 +28,14 @@ use super::session::ExecRequest; use super::{Exit, Result, io_error, output}; use crate::ErrorCode; use crate::host::artifacts::{Guest, KERNEL_NAME, require_artifact}; +use crate::host::filesystem::{ExportPlan, ReservedTargets}; use crate::host::paths; use crate::host::runtime::RuntimeIdentity; -use crate::host::sandbox::distro::DistroFormat; +use crate::host::sandbox::DEFAULT_ENTRYPOINT; +use crate::host::sandbox::distro::{DistroBinding, DistroFormat}; +use crate::host::sandbox::layer::SandboxLayer; use crate::host::sandbox::mount::{plan_mounts, validate_mounts}; -use crate::host::sandbox::options::{ - SandboxOperation, SandboxOptions, sandbox_mounts, validate_sandbox_systemd_policy, -}; -use crate::host::sandbox::{SandboxLaunch, SandboxSettings, SandboxUpper}; +use crate::host::sandbox::options::{SandboxOperation, SandboxOptions, sandbox_mounts}; use crate::protocol::root::RootUpper; pub(crate) use self::lifecycle::ProvisionConfig; @@ -46,7 +46,7 @@ pub(super) use self::options::{ }; pub(crate) use self::options::{Options, path_string}; pub(crate) use self::state::Source; -use self::state::{NetworkRecord, RecordedDistro}; +use self::state::{NetworkRecord, RecordedDistro, check_images, check_workload}; /// The artifacts of a sandbox, found when an operation first needs them. pub(crate) struct Artifacts<'a> { @@ -248,54 +248,60 @@ fn run_from(root: Option<&str>, options: Options, source: Option<&Source>) -> Re ) { let rules = options.network.egress_rules()?; let mounts = sandbox_mounts(&options.mounts, &options.policy_paths, options.mount_owner)?; - let mut settings = SandboxSettings { - entrypoint: options.entrypoint.clone(), - args: options.args.clone(), - hostname: options.hostname.clone(), - workload_identity: options.workload_user, - ..SandboxSettings::default() - }; - // The workload of a run, and that of a restore, is an exec request, which no boot token - // carries: a run's control session checks it, and `snapshot restore` a restore's. - if operation == SandboxOperation::Run || source.is_some() { - let workload = SandboxSettings::default(); - (settings.entrypoint, settings.args) = (workload.entrypoint, workload.args); - } - // The checks above require --scratch for an upper on scratch, and refuse it for one in - // RAM. - let upper = match options.upper.unwrap_or(RootUpper::Scratch) { - RootUpper::Scratch => { - SandboxUpper::scratch(options.scratch.as_deref().unwrap_or_default()) - } - RootUpper::Ram => SandboxUpper::RAM, - }; let layers = options .layers .iter() .map(|layer| { Ok(match options.layer_format { - Some(DistroFormat::Gpt) => crate::host::sandbox::SandboxLayer::gpt( - layer.role(), - &path_string(layer.path())?, - layer.uuid(), - ), + Some(DistroFormat::Gpt) => { + SandboxLayer::gpt(layer.role(), &path_string(layer.path())?, layer.uuid()) + } _ => layer.clone(), }) }) .collect::>>()?; - let launch = SandboxLaunch::new(layers, upper, settings)?; + // A sandbox takes one read-only layer, of the one role of a layer: its distro layer. + let layer = match &layers[..] { + [layer] => layer.clone(), + layers => { + let message = match layers.first() { + Some(layer) => format!("duplicate sandbox layer role(s): {}", layer.role()), + None => "a sandbox requires exactly one read-only distro layer".to_owned(), + }; + return Err(Exit::error(ErrorCode::Unsupported, message)); + } + }; + // The workload of a run, and that of a restore, is an exec request, which no boot token + // carries: a run's control session checks it, and `snapshot restore` a restore's. + let (entrypoint, args) = if operation == SandboxOperation::Run || source.is_some() { + (DEFAULT_ENTRYPOINT, &[][..]) + } else { + (options.entrypoint.as_str(), &options.args[..]) + }; + check_workload(entrypoint, args, &options.hostname, options.workload_user)?; + // The checks above require --scratch for an upper on scratch, and refuse it for one in + // RAM. + let scratch = match options.upper.unwrap_or(RootUpper::Scratch) { + RootUpper::Scratch => Some(paths::normalized(Path::new( + options.scratch.as_deref().unwrap_or_default(), + ))), + RootUpper::Ram => None, + }; validate_mounts(&mounts)?; - launch.validated()?; + check_images(&layer, scratch.as_deref(), None)?; // The shares are planned once, from their resolved host paths, and `provision` records // the plan, whose identities each `start` checks again. - let launch = launch.with_export(plan_mounts(&mounts)?)?; + let export = plan_mounts(&mounts)?; + if let Some(export) = &export { + export.check(ReservedTargets::CONTAINER_ROOT)?; + } // `run` and `provision` verify the distro layer explicitly, at the path given; // `provision` records its binding, which `start` checks without reading the layer again. - let binding = validate_sandbox_systemd_policy(&launch)?; + let binding = DistroBinding::verify(&layer)?; // The network's policy is checked before anything launches or is recorded. sandbox_network(&options.network, &rules)?; let network = NetworkRecord::new(&options.network, &rules); - Some((launch, binding, network)) + Some((layer, scratch, export, binding, network)) } else { None }; @@ -318,30 +324,27 @@ fn run_from(root: Option<&str>, options: Options, source: Option<&Source>) -> Re inherit_default_environment: options.inherit_default_environment, }; match (operation, launch) { - (SandboxOperation::Provision, Some((launch, binding, network))) => { + (SandboxOperation::Provision, Some((layer, scratch, export, binding, network))) => { // `provision` records the layer at its resolved path, where `start` checks the // binding, with the manifest beside that path. - let distro = RecordedDistro::new(&launch, binding)?; + let distro = RecordedDistro::new(&layer, binding)?; let state_dir = state_dir("provision")?; // A provision runs no artifact, so it needs no artifact root; without one, `start` // checks the shares against the artifacts that it runs. let located = locate(root).ok(); refuse_own_files( Some(("state directory", Path::new(state_dir))), - &launch, + layer.path(), + scratch.as_deref(), + export.as_ref(), located.as_ref(), )?; lifecycle::provision( state_dir, - &launch, + &layer, + scratch.as_deref(), &distro, - &ProvisionConfig { - hypervisor: hypervisor(options.hypervisor), - memory_mib: options.memory_mib, - cmdline: &options.cmdline, - ram_upper_percent: options.ram_upper_percent, - network: network.as_ref(), - }, + &recorded(&options, export.as_ref(), network.as_ref()), source, options.timeout, ) @@ -379,7 +382,7 @@ fn run_from(root: Option<&str>, options: Options, source: Option<&Source>) -> Re (SandboxOperation::Deprovision, _) => { lifecycle::deprovision(state_dir("deprovision")?, &artifacts, options.timeout) } - (SandboxOperation::Run, Some((launch, binding, network))) => { + (SandboxOperation::Run, Some((layer, scratch, export, binding, network))) => { if options.state_dir.is_some() { return Err(Exit::error( ErrorCode::Unsupported, @@ -387,12 +390,12 @@ fn run_from(root: Option<&str>, options: Options, source: Option<&Source>) -> Re )); } // A run launches the layer at the path that a provision would record. - let distro = RecordedDistro::new(&launch, binding)?; + let distro = RecordedDistro::new(&layer, binding)?; let held = early.transpose()?.unwrap_or_else(Hold::none); let Some(result) = lifecycle::run( - &launch, &distro, - network.as_ref(), + scratch.as_deref(), + &recorded(&options, export.as_ref(), network.as_ref()), &artifacts, &options, &request, @@ -412,12 +415,33 @@ fn run_from(root: Option<&str>, options: Options, source: Option<&Source>) -> Re } } +/// Returns the options of `options` that `provision` records beside the images of its launch, with +/// `export`, the export plan of its shares, and `network`, the record of its network options, +/// which a run records and reads back too. +fn recorded<'a>( + options: &'a Options, + export: Option<&'a ExportPlan>, + network: Option<&'a NetworkRecord>, +) -> ProvisionConfig<'a> { + ProvisionConfig { + hypervisor: hypervisor(options.hypervisor), + memory_mib: options.memory_mib, + cmdline: &options.cmdline, + ram_upper_percent: options.ram_upper_percent, + hostname: &options.hostname, + workload_identity: options.workload_user, + export, + network, + } +} + /// Refuses a share of the sandbox's own files: one whose host path equals, contains, or lies /// inside `directory`, if the sandbox has one, with its name, its state directory, a run's VM -/// directory, or a restored sandbox's snapshot store; the image or scratch image of `launch`; or, -/// if known, the OpenVMM, kernel, or Alpine initramfs of the artifact root of `artifacts`, or the -/// OpenVMM that `NVX_OPENVMM` names instead, which a run runs. Through it, the guest could read or -/// change the sandbox's records, its storage, its snapshot, or what the host runs. +/// directory, or a restored sandbox's snapshot store; its image, `image`, or its scratch image, +/// `scratch`, if any; or, if known, the OpenVMM, kernel, or Alpine initramfs of the artifact root +/// of `artifacts`, or the OpenVMM that `NVX_OPENVMM` names instead, which a run runs. The shares +/// are the children of `export`, if any. Through them, the guest could read or change the +/// sandbox's records, its storage, its snapshot, or what the host runs. /// /// The paths are compared once [`paths::resolve`] has resolved them, without a Windows verbatim /// prefix. Resolution follows symbolic links and junctions, but not hard links or bind mounts. @@ -426,22 +450,20 @@ fn run_from(root: Option<&str>, options: Options, source: Option<&Source>) -> Re /// directory, checks before it creates its VM directory and launches its VM. fn refuse_own_files( directory: Option<(&str, &Path)>, - launch: &SandboxLaunch, + image: &Path, + scratch: Option<&Path>, + export: Option<&ExportPlan>, artifacts: Option<&Located>, ) -> Result { - let Some(export) = launch.export() else { + let Some(export) = export else { return Ok(()); }; let mut owned: Vec<_> = directory .map(|(name, path)| (name, path.to_owned())) .into_iter() .collect(); - owned.push(("image", launch.distro().path().to_owned())); - owned.extend( - launch - .scratch() - .map(|path| ("scratch image", path.to_owned())), - ); + owned.push(("image", image.to_owned())); + owned.extend(scratch.map(|path| ("scratch image", path.to_owned()))); if let Some(Located { dir, openvmm }) = artifacts { owned.extend([ ("OpenVMM", dir.openvmm()), diff --git a/nvx-rs/src/host/cli/sandbox/state.rs b/nvx-rs/src/host/cli/sandbox/state.rs index 66e034f1b..4a68ba0c6 100644 --- a/nvx-rs/src/host/cli/sandbox/state.rs +++ b/nvx-rs/src/host/cli/sandbox/state.rs @@ -7,6 +7,10 @@ //! provision it again. Bindings still check the recorded caller-owned paths before launch, and the //! caller keeps those paths stable while the VM uses them. //! +//! Provision checks the images, the workload and the export plan of its options with +//! [`check_images`] and [`check_workload`], as the plan of the sandbox profile checked them, and +//! each start checks them again in the record, with [`Config::check`]. +//! //! The network is the [`NetworkRecord`] of the sandbox's network options, from which each start //! builds the network's policy again, as provision built it. //! @@ -34,13 +38,16 @@ use crate::host::cli::snapshots::Name as SnapshotName; use crate::host::cli::{Exit, Result, io_error, io_message}; use crate::host::config::{ Boot, ConsoleAttachment, LayeredRoot, NetworkConfig, RootConfig, UpperConfig, VmConfig, + reject_container_path, reject_disk_path, }; -use crate::host::filesystem::ExportPlan; +use crate::host::filesystem::{ExportPlan, ReservedTargets}; use crate::host::paths; use crate::host::sandbox::distro::{DISTRO_POLICY_REVISION, DistroBinding, DistroFormat}; use crate::host::sandbox::layer::{LayerRole, SandboxLayer}; +use crate::host::sandbox::mount::{HostQueries, check_distinct_export_roots, os_error}; use crate::host::sandbox::scratch::ScratchBinding; -use crate::host::sandbox::{SandboxLaunch, SandboxSettings, SandboxUpper}; +use crate::host::sandbox::{DEFAULT_ENTRYPOINT, has_kernel_separator, require_file}; +use crate::net::is_valid_hostname; use crate::protocol::root::{RAM_UPPER_CAPS, RootImage}; use crate::uuid::Uuid; @@ -110,6 +117,87 @@ fn resolved(path: &Path) -> Result { path_string(&paths::resolve(path).map_err(Exit::from_os_error)?) } +/// Refuses the workload of a sandbox as the plan of the sandbox profile refused it: an +/// `entrypoint` that is not an absolute path or that holds whitespace, a control character, or a +/// quote, an argument of `args` that is empty or holds one, a `hostname` that is not a lowercase +/// RFC 1123 label of up to 63 characters, and the root's UID or GID in `identity`. +pub(crate) fn check_workload( + entrypoint: &str, + args: &[String], + hostname: &str, + (uid, gid): (u32, u32), +) -> Result { + let refused = |message: &str| Err(Exit::error(ErrorCode::Unsupported, message)); + if !entrypoint.starts_with('/') || has_kernel_separator(entrypoint) { + return refused( + "sandbox entrypoint must be an absolute path without whitespace, control characters, \ + or quotes", + ); + } + if args + .iter() + .any(|argument| argument.is_empty() || has_kernel_separator(argument)) + { + return refused( + "sandbox arguments must be nonempty and contain no whitespace, control characters, or \ + quotes", + ); + } + if !is_valid_hostname(hostname) { + return refused("sandbox hostname must be a lowercase RFC 1123 label up to 63 characters"); + } + if uid == 0 || gid == 0 { + return refused("sandbox workload identity must be a non-root UID:GID"); + } + Ok(()) +} + +/// Refuses the images of a sandbox as the plan of the sandbox profile refused them before its +/// launch: `distro`, and `scratch`, the scratch image of an upper on scratch, must each be a +/// regular file whose path, and the path to which it resolves, has no comma, semicolon, or +/// unbalanced bracket, and does not end in an extension with which OpenVMM would not attach it as +/// a raw image. It also refuses `export`, if any, once its directories and files overlap, by path +/// or through a hard link or a bind mount, or once [`ExportPlan::verify`] refuses it. +/// +/// A missing image fails with [`ErrorCode::Unavailable`], as `distro EROFS layer not found: +/// PATH`, `distro GPT image not found: PATH`, or `ext4 scratch image not found: PATH`. +pub(crate) fn check_images( + distro: &SandboxLayer, + scratch: Option<&Path>, + export: Option<&ExportPlan>, +) -> Result { + let image = match distro.format() { + DistroFormat::Erofs => "distro EROFS layer", + DistroFormat::Gpt => "distro GPT image", + }; + let images = [ + (Some(distro.path()), image), + (scratch, "ext4 scratch image"), + ]; + for (path, description) in images { + let Some(path) = path else { + continue; + }; + require_file(path, description)?; + let shown = path.display().to_string(); + reject_disk_path(path, &shown)?; + reject_container_path(path, &shown)?; + // A provisioned sandbox records the resolved path, which must also fit OpenVMM's block + // device option and name a raw image. + let resolved = paths::resolve(path).map_err(|error| os_error(&error, Some(path)))?; + if resolved != path { + let shown = format!("{shown} resolves to {}", resolved.display()); + reject_disk_path(&resolved, &shown)?; + reject_container_path(&resolved, &shown)?; + } + } + if let Some(export) = export { + check_distinct_export_roots(export, &HostQueries::native())?; + export.verify()?; + } + Ok(()) +} + /// The distro layer that `provision` records, at the path that it records for it, with the /// binding of the layer at that path, which `start` checks. pub(crate) struct RecordedDistro { @@ -120,7 +208,7 @@ pub(crate) struct RecordedDistro { } impl RecordedDistro { - /// Returns the distro layer of `launch` at the path that `provision` records, the path given, + /// Returns the distro layer `layer` at the path that `provision` records, the path given, /// resolved, with a binding of the layer there: `binding`, which explicit /// verification of the layer at the path given made, if it holds at the recorded path, and /// otherwise the binding that explicit verification of the layer at the recorded path makes, @@ -129,8 +217,7 @@ impl RecordedDistro { /// The check of `binding` at the recorded path reads nothing, and passes unless the paths name /// different manifests: when the path given is a link to a layer of another name, `start` /// checks the manifest beside the link's target, so `provision` verifies the layer there too. - pub(crate) fn new(launch: &SandboxLaunch, binding: DistroBinding) -> Result { - let distro = launch.distro(); + pub(crate) fn new(distro: &SandboxLayer, binding: DistroBinding) -> Result { let path = resolved(distro.path())?; let image = Image { path, @@ -162,11 +249,11 @@ pub(crate) struct RecordedScratch { } impl RecordedScratch { - /// Returns the scratch image of `launch` at the path that `provision` records, the path given, - /// resolved, with a binding of the image there, which claims nothing, or `None` for a launch - /// whose upper is in RAM, which has no scratch image. - pub(crate) fn new(launch: &SandboxLaunch) -> Result> { - let Some(scratch) = launch.scratch() else { + /// Returns the scratch image `scratch` at the path that `provision` records, the path given, + /// resolved, with a binding of the image there, which claims nothing, or `None` for an upper + /// in RAM, which has no scratch image. + pub(crate) fn new(scratch: Option<&Path>) -> Result> { + let Some(scratch) = scratch else { return Ok(None); }; let path = resolved(scratch)?; @@ -295,21 +382,19 @@ impl NetworkRecord { } } -/// Returns the JSON text of the configuration that `provision` records for `launch` and the other -/// options, with `distro`, the launch's distro layer at the path that `provision` verified and -/// records, and its binding, and `scratch`, the launch's scratch image at the path that -/// `provision` records, and its binding, or `None` for an upper in RAM. The configuration of a -/// restored sandbox also records its `source`, without any index of the sandboxes that a snapshot -/// supplies; that of a sandbox that boots cold, whose `source` is `None`, records none. +/// Returns the JSON text of the configuration that `provision` records for the options of +/// `config`, with `distro`, the distro layer at the path that `provision` verified and records, +/// and its binding, and `scratch`, the scratch image at the path that `provision` records, and its +/// binding, or `None` for an upper in RAM, once [`Config::check`] has checked it. The configuration +/// of a restored sandbox also records its `source`, without any index of the sandboxes that a +/// snapshot supplies; that of a sandbox that boots cold, whose `source` is `None`, records none. pub(crate) fn serialize_launch( - launch: &SandboxLaunch, distro: &RecordedDistro, scratch: Option<&RecordedScratch>, config: &ProvisionConfig<'_>, source: Option<&Source>, ) -> Result { - let settings = launch.settings(); - let (workload_uid, workload_gid) = settings.workload_identity; + let (workload_uid, workload_gid) = config.workload_identity; let record = Config { format: CONFIG_FORMAT, image: distro.image.clone(), @@ -323,7 +408,7 @@ pub(crate) fn serialize_launch( .unwrap_or(UpperConfig::DEFAULT_MAX_MEMORY_PERCENT), }, }, - hostname: settings.hostname.clone(), + hostname: config.hostname.to_owned(), workload_uid, workload_gid, hypervisor: config.hypervisor.to_owned(), @@ -332,15 +417,16 @@ pub(crate) fn serialize_launch( cmdline: config.cmdline.to_owned(), distro_binding: distro.binding.clone(), scratch_binding: scratch.map(|scratch| scratch.binding.clone()), - export: launch.export().cloned(), + export: config.export.cloned(), source_snapshot: source.map(|source| source.snapshot.clone()), source_store: source .map(|source| path_string(&source.store)) .transpose()?, workload: source.map(|source| source.workload.clone()), }; + record.check()?; record - .vm_config(&record.launch()?)? + .vm_config()? .check() .map_err(super::error_with_cause)?; pretty_serde(&record, DESCRIPTION) @@ -483,27 +569,60 @@ impl Config { &self.cmdline } - /// Reconstructs and validates the recorded image, upper, network and export. - pub(crate) fn launch(&self) -> Result { - let upper = match &self.upper { - Upper::Scratch { path } => SandboxUpper::scratch(path), - Upper::Ram { max_memory_percent } if RAM_UPPER_CAPS.contains(max_memory_percent) => { - SandboxUpper::RAM - } - _ => return Err(malformed()), - }; - let launch = SandboxLaunch::new( - vec![self.image.layer()], - upper, - SandboxSettings { - hostname: self.hostname.clone(), - workload_identity: (self.workload_uid, self.workload_gid), - ..SandboxSettings::default() - }, - )?; - let launch = launch.with_export(self.export.clone())?; - launch.validated()?; - Ok(launch) + /// Checks the recorded image, upper, workload and export at each start, as the plan of the + /// sandbox profile checked them: a RAM upper's cap from 1 to 90 percent, or the configuration + /// is malformed; the image's identity, a UUID in its canonical text; the workload, as + /// [`check_workload`] checks it with the default entrypoint; the export plan, for the + /// container root; and the images and the export plan, as [`check_images`] checks them. + pub(crate) fn check(&self) -> Result { + if let Upper::Ram { max_memory_percent } = &self.upper + && !RAM_UPPER_CAPS.contains(max_memory_percent) + { + return Err(malformed()); + } + let (distro, identity) = (self.distro(), self.workload_identity()); + if Uuid::parse(distro.uuid()).is_none() { + let message = format!( + "{} layer UUID is invalid: {:?}", + distro.role(), + distro.uuid() + ); + return Err(Exit::error(ErrorCode::Unsupported, message)); + } + check_workload(DEFAULT_ENTRYPOINT, &[], &self.hostname, identity)?; + if let Some(export) = &self.export { + export.check(ReservedTargets::CONTAINER_ROOT)?; + } + check_images(&distro, self.scratch().as_deref(), self.export.as_ref()) + } + + /// Returns the recorded image, as the distro layer that `start` checks. + pub(crate) fn distro(&self) -> SandboxLayer { + self.image.layer() + } + + /// Returns the recorded scratch image, spelled with its components alone, or `None` for an + /// upper in RAM. + pub(crate) fn scratch(&self) -> Option { + match &self.upper { + Upper::Scratch { path } => Some(paths::normalized(Path::new(path))), + Upper::Ram { .. } => None, + } + } + + /// Returns the workload's hostname. + pub(crate) fn hostname(&self) -> &str { + &self.hostname + } + + /// Returns the workload's UID and GID. + pub(crate) fn workload_identity(&self) -> (u32, u32) { + (self.workload_uid, self.workload_gid) + } + + /// Returns the export plan of the shares, if any. + pub(crate) fn export(&self) -> Option<&ExportPlan> { + self.export.as_ref() } /// The binding that start checks against the recorded image. @@ -528,7 +647,7 @@ impl Config { } /// The shared projection checked at provision and consumed unchanged at start. - pub(crate) fn vm_config(&self, launch: &SandboxLaunch) -> Result { + pub(crate) fn vm_config(&self) -> Result { let identity = Some(Uuid::parse(&self.image.identity).ok_or_else(malformed)?); let format = match self.image.format { DistroFormat::Erofs => RootImage::erofs(identity), @@ -543,7 +662,7 @@ impl Config { max_memory_percent: *max_memory_percent, }, }; - let (uid, gid) = launch.settings().workload_identity; + let (uid, gid) = self.workload_identity(); let mut command_line = format!("nvx_sandbox=1 nvx_mode=managed nvx_identity={uid}:{gid}"); if !self.cmdline.is_empty() { command_line.push(' '); @@ -571,7 +690,7 @@ impl Config { machine_id: MachineIdMode::Generated, }, network, - filesystem: launch.export().cloned(), + filesystem: self.export.clone(), console: ConsoleAttachment::Disconnected, boot: Some(Boot { command_line, diff --git a/nvx-rs/src/host/cli/sandbox/state/tests.rs b/nvx-rs/src/host/cli/sandbox/state/tests.rs index d4bf9e8fd..4ffeb45ea 100644 --- a/nvx-rs/src/host/cli/sandbox/state/tests.rs +++ b/nvx-rs/src/host/cli/sandbox/state/tests.rs @@ -2,6 +2,7 @@ use serde_json::json; use super::*; use crate::host::sandbox::layer::LayerRole; +use crate::host::sandbox::{DEFAULT_HOSTNAME, DEFAULT_WORKLOAD_IDENTITY}; #[test] fn restored_sandbox_records_round_trip_only_their_own_source() { @@ -14,14 +15,15 @@ fn restored_sandbox_records_round_trip_only_their_own_source() { "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 distro = RecordedDistro::new(&layer, binding).unwrap(); let options = ProvisionConfig { hypervisor: "whp", memory_mib: 128, cmdline: "", ram_upper_percent: None, + hostname: DEFAULT_HOSTNAME, + workload_identity: DEFAULT_WORKLOAD_IDENTITY, + export: None, network: None, }; for name in [None, Some("My.snapshot-1"), Some("other")] { @@ -30,7 +32,7 @@ fn restored_sandbox_records_round_trip_only_their_own_source() { store: directory.path().join("store"), workload: vec!["/bin/echo".to_owned(), name.to_owned()], }); - let text = serialize_launch(&launch, &distro, None, &options, source.as_ref()).unwrap(); + let text = serialize_launch(&distro, None, &options, source.as_ref()).unwrap(); let object: Object = serde_json::from_str(&text).unwrap(); for key in ["source_snapshot", "source_store", "workload"] { assert_eq!(object.contains_key(key), name.is_some(), "{key}"); diff --git a/nvx-rs/tests/cli/lifecycle.rs b/nvx-rs/tests/cli/lifecycle.rs index caf2a601e..fc0ec19b0 100644 --- a/nvx-rs/tests/cli/lifecycle.rs +++ b/nvx-rs/tests/cli/lifecycle.rs @@ -1383,6 +1383,49 @@ fn unusable_guest_ram(root: &Root) { assert!(!root.below("state").exists()); } +/// A start refuses a configuration whose kernel command line holds a token of the sandbox profile, +/// `nvx_*`, or of `--mount`, `virtfs_*`, as Linux or the guest's init reads the line, before it +/// launches OpenVMM. `provision` records the profile's tokens that the configuration of the VM +/// leaves to the caller, such as `nvx_entrypoint`, and the start refuses those too. +#[test] +fn starts_refuse_the_kernel_tokens_of_the_sandbox_profile_and_of_its_mounts() { + let root = Root::new(); + root.nvx_rs(&provision( + "{root/state}", + &["--cmdline", "quiet nvx_entrypoint=/bin/true"], + )) + .assert_silent_success(); + let config = json(&root.below("state/config.json")); + for (cmdline, fragment) in [ + (None, "nvx_entrypoint is owned by the sandbox profile"), + // Linux reads dashes in a parameter's name as underscores. + ( + Some("nvx-sandbox=0"), + "nvx-sandbox is owned by the sandbox profile", + ), + // The guest's init splits the line at whitespace alone, whatever its quotes. + ( + Some("\"a nvx_mode=one-shot\""), + "nvx_mode is owned by the sandbox profile", + ), + ( + Some("virtfs_tag=microvm"), + "virtfs_tag is owned by the sandbox --mount option", + ), + ] { + if let Some(cmdline) = cmdline { + let mut config = config.clone(); + config["cmdline"] = json!(cmdline); + root.write("state/config.json", config.to_string()); + } + let before = root.snapshot(); + let outcome = operate(&root, "start", "state", &[]); + assert_refused(&root, &outcome, "unsupported", &[fragment]); + assert_eq!(root.snapshot(), before, "{cmdline:?}"); + } + operate(&root, "deprovision", "state", &[]).assert_silent_success(); +} + /// A state directory must be a directory: a file there fails every operation, and provision /// leaves it as it is. fn not_a_directory(root: &Root) {