From d46ca5a423279e49a515b6cf5c51067e24b1ed64 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 12 Nov 2026 06:42:09 -0800 Subject: [PATCH 1/2] tests: check the export planner through its unit tests and the CLI SL6.5a2 (#1125) makes the export planner and its host rechecks private to the CLI. Two integration tests reached them through their public paths, which they cannot once those are private, so each case moves beside its owner or drives the nvx-rs binary alone, with the same assertions: - The security checklist's four checks of an export, which only the CLI plans and checks again before a start, an_export_refuses_guest_targets_that_are_not_canonical_or_that_a_root_reserves, an_export_refuses_a_read_only_path_inside_a_writable_directory, the_caller_owner_is_refused_on_windows and an_exported_object_that_a_workload_could_move_is_checked_again_before_a_start, move unchanged to the unit tests of host::filesystem, where plan_blockless plans their mappings into the blockless root, as the checklist's plan did. - cold_run_expected planned the shares of a cold-run fixture through filesystem::plan before it lowered them through the public VM contract. fixture_export now builds the fixture's ExportPlan as host::filesystem documents an export, independently of the CLI's planner, as of its parser and token renderer, so the dry-run and one-shot tests compare the CLI's command line with an export that they derive on their own. A denied path that a workload could move carries a zero identity there: ExportPlan::check requires one, and no command line carries it. The case matrix names the moved checklist tests where they now are. Part of #1125 (SL6.5a2). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- doc/acceptance-case-matrix.md | 14 +-- nvx-rs/src/host/filesystem/tests.rs | 108 ++++++++++++++++++ nvx-rs/tests/cli/openvmm.rs | 168 +++++++++++++++++++++------- nvx-rs/tests/security_checklist.rs | 132 +--------------------- 4 files changed, 250 insertions(+), 172 deletions(-) diff --git a/doc/acceptance-case-matrix.md b/doc/acceptance-case-matrix.md index 0d897b707..e6a64ff73 100644 --- a/doc/acceptance-case-matrix.md +++ b/doc/acceptance-case-matrix.md @@ -74,7 +74,7 @@ The adapter's README said what each proved. | `a_run_refuses_a_selection_that_names_no_template_once_its_preflight_staged_the_images` | rewritten | Images that OpenVMM cannot attach are refused before anything: `nvx-rs/src/host/config/tests.rs::cold_boot_arguments_refuse_non_raw_block_extensions`; `nvx-rs/src/host/config/tests.rs::cold_boot_arguments_refuse_disk_syntax_in_image_and_scratch_paths`. Template selections and leases retire (#1121) | | `a_start_refuses_a_generation_that_the_template_store_does_not_hold_before_anything_else` | retired | Template generations leave with the old clone path (#1121) | | `a_start_refuses_a_network_that_the_plan_refuses_before_anything_else` | rewritten | `nvx-rs/tests/cli.rs::network_policies_that_the_planner_refuses_fail_before_launch` | -| `a_start_refuses_shares_that_it_cannot_export_before_anything_else` | rewritten | `nvx-rs/src/host/filesystem/tests.rs::read_only_paths_inside_read_write_directories_are_refused`; `nvx-rs/tests/security_checklist.rs::an_export_refuses_guest_targets_that_are_not_canonical_or_that_a_root_reserves` | +| `a_start_refuses_shares_that_it_cannot_export_before_anything_else` | rewritten | `nvx-rs/src/host/filesystem/tests.rs::read_only_paths_inside_read_write_directories_are_refused`; `nvx-rs/src/host/filesystem/tests.rs::an_export_refuses_guest_targets_that_are_not_canonical_or_that_a_root_reserves` | | `the_host_program_refuses_options_that_do_not_make_a_run` | retired | The command line and statuses of the adapter's `run` | | `the_host_program_refuses_options_that_do_not_make_a_start` | retired | The command line and statuses of the adapter's `start` | @@ -126,7 +126,7 @@ The adapter's README said what each proved. | `a_launch_reads_only_the_networks_that_the_plan_takes` | rewritten | `nvx-rs/tests/cli.rs::network_policies_that_the_planner_refuses_fail_before_launch`; `nvx-rs/src/host/network/tests.rs::invalid_rules_are_rejected_with_the_policy_compilers_messages` | | `a_launch_records_what_it_launched` | retired | The adapter's launch records. Callers keep `SavedVm`: `nvx-rs/src/host/vm/tests/reattach.rs::verifying_saved_values_checks_runtime_and_process_without_opening_an_endpoint` | | `a_launch_refuses_images_that_are_not_staged_or_changed_since` | rewritten | `nvx-rs/src/host/sandbox/distro/tests.rs::a_staged_image_that_was_replaced_fails_its_check`; `nvx-rs/src/host/sandbox/scratch/tests.rs::checks_fail_without_reading_when_the_image_changes`; `nvx-rs/tests/cli.rs::start_refuses_a_configuration_without_a_valid_distro_binding` | -| `a_launch_refuses_shares_that_it_cannot_export` | rewritten | `nvx-rs/src/host/filesystem/tests.rs::policy_paths_hide_show_and_narrow_writes`; `nvx-rs/tests/security_checklist.rs::an_export_refuses_a_read_only_path_inside_a_writable_directory` | +| `a_launch_refuses_shares_that_it_cannot_export` | rewritten | `nvx-rs/src/host/filesystem/tests.rs::policy_paths_hide_show_and_narrow_writes`; `nvx-rs/src/host/filesystem/tests.rs::an_export_refuses_a_read_only_path_inside_a_writable_directory` | | `a_launch_renders_each_element_of_its_network_through_the_plan` | rewritten | `nvx-rs/src/host/config/tests.rs::cold_boot_arguments_lower_layered_roots_networks_and_exports_by_path`; `nvx-rs/src/host/network/tests.rs::proxy_urls_name_the_gateway_as_the_guest_sees_it` | | `a_launch_shares_host_paths_inside_the_authorized_directories` | rewritten | `nvx-rs/src/host/filesystem/tests.rs::policy_paths_hide_show_and_narrow_writes`; `nvx-rs/src/host/config/tests.rs::cold_boot_arguments_lower_layered_roots_networks_and_exports_by_path` | | `a_layered_launch_hands_openvmm_the_checked_images` | rewritten | `nvx-rs/src/host/config/tests.rs::cold_boot_arguments_lower_layered_roots_networks_and_exports_by_path`. A launch names its images by path: the checked-file handoff leaves (SL6.3, #1122), as `images_replaced_after_the_checks_never_reach_the_vm` says | @@ -136,7 +136,7 @@ The adapter's README said what each proved. | `shares_read_their_host_path_their_target_and_their_access` | rewritten | `nvx-rs/src/host/cli/run/guest_tokens/tests.rs::planned_boot_shares_keep_table_sources_targets_access_and_file_kind` | | `the_host_program_refuses_a_network_before_it_launches` | rewritten | `nvx-rs/tests/cli.rs::network_policies_that_the_planner_refuses_fail_before_launch` | | `the_host_program_refuses_options_that_do_not_make_a_command` | retired | The adapter's command lines and usage statuses | -| `the_host_program_refuses_shares_before_it_touches_the_store` | rewritten | `nvx-rs/src/host/filesystem/tests.rs::read_only_paths_inside_read_write_directories_are_refused`; `nvx-rs/tests/security_checklist.rs::an_export_refuses_a_read_only_path_inside_a_writable_directory` | +| `the_host_program_refuses_shares_before_it_touches_the_store` | rewritten | `nvx-rs/src/host/filesystem/tests.rs::read_only_paths_inside_read_write_directories_are_refused`; `nvx-rs/src/host/filesystem/tests.rs::an_export_refuses_a_read_only_path_inside_a_writable_directory` | | `the_record_of_a_launch_gives_back_its_plan` | retired | The adapter's launch record and its reader | ### `vm_restart` (3) @@ -164,10 +164,10 @@ The adapter's README said what each proved. | `a_failed_mapping_tears_the_vm_down` | rewritten | The guest's refusal, which keeps the mappings before it, latches the table and refuses the start: `nvx-rs/src/guest/mappings/table/tests.rs::a_failed_mapping_keeps_those_before_it_and_latches_the_table`; `nvx-rs/src/guest/managed_agent/dispatch/tests.rs::a_failed_mapping_keeps_refusing_workloads_through_dispatch`. The stop that the guest confirms, with a successful outcome and no record of the VM: `nvx-rs/tests/cli.rs::a_mapping_that_the_guest_refuses_leaves_no_usable_start`. The teardown of the root and its mappings: `nvx-rs/src/guest/root/tests.rs::a_teardown_unmounts_the_layers_top_first_and_reports_every_failure`; `nvx-rs/src/guest/mappings/mapper/tests/privileged.rs::a_teardown_unmounts_each_mapping_and_reports_a_busy_one` | | `a_forward_reaches_its_guest_port` | rewritten | `test-microvm --scenario host-loopback-policy` | | `a_proxy_only_network_lets_only_its_proxy_through` | rewritten | `test-microvm --scenario sandbox-proxy-only` | -| `a_read_only_share_inside_a_writable_one_is_refused_before_launch` | rewritten | `nvx-rs/tests/security_checklist.rs::an_export_refuses_a_read_only_path_inside_a_writable_directory` | +| `a_read_only_share_inside_a_writable_one_is_refused_before_launch` | rewritten | `nvx-rs/src/host/filesystem/tests.rs::an_export_refuses_a_read_only_path_inside_a_writable_directory` | | `a_replaced_scratch_is_refused_before_spawn` | rewritten | `nvx-rs/src/host/sandbox/scratch/tests.rs::a_scratch_image_is_claimed_by_one_launch_and_checked_again_after_a_release`; `nvx-rs/tests/cli.rs::start_refuses_a_configuration_without_a_valid_scratch_binding` | -| `a_share_inside_a_reserved_tree_is_refused_before_launch` | rewritten | `nvx-rs/tests/security_checklist.rs::an_export_refuses_guest_targets_that_are_not_canonical_or_that_a_root_reserves`; `nvx-rs/src/host/filesystem/tests.rs::reserved_targets_depend_on_the_root` | -| `a_share_over_a_reserved_tree_is_refused_before_launch` | rewritten | `nvx-rs/tests/security_checklist.rs::an_export_refuses_guest_targets_that_are_not_canonical_or_that_a_root_reserves`; `nvx-rs/src/host/filesystem/tests.rs::reserved_targets_depend_on_the_root` | +| `a_share_inside_a_reserved_tree_is_refused_before_launch` | rewritten | `nvx-rs/src/host/filesystem/tests.rs::an_export_refuses_guest_targets_that_are_not_canonical_or_that_a_root_reserves`; `nvx-rs/src/host/filesystem/tests.rs::reserved_targets_depend_on_the_root` | +| `a_share_over_a_reserved_tree_is_refused_before_launch` | rewritten | `nvx-rs/src/host/filesystem/tests.rs::an_export_refuses_guest_targets_that_are_not_canonical_or_that_a_root_reserves`; `nvx-rs/src/host/filesystem/tests.rs::reserved_targets_depend_on_the_root` | | `accountless_workload_identity_fails_its_stage` | rewritten | `test-microvm --scenario workload-identity`; `nvx-rs/src/guest/init/boot/tests.rs::account_creation_replaces_only_the_requested_unusable_identity_once` | | `allowed_host_loopback_publishes_the_listeners` | rewritten | `test-microvm --scenario host-loopback-policy` | | `an_allow_rule_lets_only_its_destination_through` | rewritten | `test-microvm --scenario l3-l4-egress-policy`; `nvx-rs/src/host/network/policy/tests/properties.rs::policies_lower_to_canonical_rules_that_match_exactly` | @@ -242,7 +242,7 @@ The adapter's README said what each proved. | `a_run_whose_clone_does_not_start_runs_nothing` | retired | Template selections and the template's `run` leave with the old clone path (#1121) | | `a_run_whose_clone_s_vm_exits_during_its_execution_ends_with_the_clone_s_failure` | retired | The template's `run`, clone records and leases leave with the old clone path (#1121) | | `a_selection_that_names_no_template_leases_nothing` | retired | Template selections and leases leave with the old clone path (#1121) | -| `a_start_refuses_an_export_whose_mapped_objects_changed_before_any_vmm_starts` | rewritten | `nvx-rs/tests/security_checklist.rs::an_exported_object_that_a_workload_could_move_is_checked_again_before_a_start`; `nvx-rs/src/host/filesystem/tests.rs::swapped_objects_fail_verification` | +| `a_start_refuses_an_export_whose_mapped_objects_changed_before_any_vmm_starts` | rewritten | `nvx-rs/src/host/filesystem/tests.rs::an_exported_object_that_a_workload_could_move_is_checked_again_before_a_start`; `nvx-rs/src/host/filesystem/tests.rs::swapped_objects_fail_verification` | | `a_start_refuses_what_the_template_cannot_serve_before_any_vmm_starts` | rewritten | `nvx-rs/src/host/snapshot/tests.rs::snapshot_accepts_refuses_an_image_longer_than_its_placeholder`; `nvx-rs/src/host/snapshot/tests.rs::snapshot_accepts_refuses_each_scratch_geometry_mismatch`; `nvx-rs/src/host/snapshot/tests.rs::snapshot_accepts_refuses_each_memory_mismatch_without_rounding_or_overflow` | | `a_template_without_a_nic_serves_clones_without_a_network` | rewritten | `nvx-rs/src/host/snapshot/tests.rs::snapshot_accepts_refuses_both_nic_presence_mismatches_and_each_named_address_mismatch` | | `a_tombstone_leaves_the_running_clone_of_its_generation_to_its_lease` | retired | Tombstones, generations and leases leave with the old clone path (#1121) | diff --git a/nvx-rs/src/host/filesystem/tests.rs b/nvx-rs/src/host/filesystem/tests.rs index 37bd115ac..e7e54e522 100644 --- a/nvx-rs/src/host/filesystem/tests.rs +++ b/nvx-rs/src/host/filesystem/tests.rs @@ -1810,3 +1810,111 @@ fn mapped_paths_resolve_to_utf8_paths() { ); } } + +/// Plans the export of `mappings` into the blockless root. +fn plan_blockless(mappings: &[Mapping<'_>]) -> Result> { + super::plan(&plain_request(mappings, ReservedTargets::BLOCKLESS_ROOT)) +} + +#[test] +fn an_export_refuses_guest_targets_that_are_not_canonical_or_that_a_root_reserves() { + let directory = tempfile::tempdir().unwrap(); + for target in [ + "", + "relative", + "/", + "/work/", + "/work//child", + "/work/./child", + "/work/../child", + "/work\0child", + "/etc", + "/etc/child", + ] { + let mappings = [Mapping { + name: "filesystem", + path: directory.path(), + target: Target::Guest(target), + read_only: true, + }]; + let error = plan_blockless(&mappings).unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unsupported, "{target:?}: {error}"); + } + let mappings = [Mapping { + name: "filesystem", + path: directory.path(), + target: Target::Guest("/work"), + read_only: true, + }]; + let plan = plan_blockless(&mappings).unwrap().unwrap(); + plan.check(ReservedTargets::BLOCKLESS_ROOT).unwrap(); + plan.verify().unwrap(); +} + +#[test] +fn an_export_refuses_a_read_only_path_inside_a_writable_directory() { + let directory = tempfile::tempdir().unwrap(); + let file = directory.path().join("input"); + fs::write(&file, b"input").unwrap(); + let mappings = [ + Mapping { + name: "filesystem", + path: directory.path(), + target: Target::Guest("/work"), + read_only: false, + }, + Mapping { + name: "filesystem", + path: &file, + target: Target::Guest("/work/input"), + read_only: true, + }, + ]; + assert_eq!( + plan_blockless(&mappings).unwrap_err().kind(), + ErrorKind::Unsupported + ); +} + +#[cfg(windows)] +#[test] +fn the_caller_owner_is_refused_on_windows() { + let request = ExportRequest { + owner: Owner::Caller, + ..plain_request(&[], ReservedTargets::BLOCKLESS_ROOT) + }; + assert_eq!( + super::plan(&request).unwrap_err().kind(), + ErrorKind::Unsupported + ); +} + +#[test] +fn an_exported_object_that_a_workload_could_move_is_checked_again_before_a_start() { + let directory = tempfile::tempdir().unwrap(); + let file = directory.path().join("input"); + fs::write(&file, b"original").unwrap(); + let mappings = [ + Mapping { + name: "filesystem", + path: directory.path(), + target: Target::Guest("/work"), + read_only: false, + }, + Mapping { + name: "filesystem", + path: &file, + target: Target::Guest("/work/input"), + read_only: false, + }, + ]; + let plan = plan_blockless(&mappings).unwrap().unwrap(); + plan.verify().unwrap(); + fs::rename(&file, directory.path().join("kept-original")).unwrap(); + fs::write(&file, b"replacement").unwrap(); + assert_eq!(plan.verify().unwrap_err().kind(), ErrorKind::Other); + assert_eq!( + fs::read(directory.path().join("kept-original")).unwrap(), + b"original" + ); +} diff --git a/nvx-rs/tests/cli/openvmm.rs b/nvx-rs/tests/cli/openvmm.rs index a10f46639..9cd387fce 100644 --- a/nvx-rs/tests/cli/openvmm.rs +++ b/nvx-rs/tests/cli/openvmm.rs @@ -37,6 +37,123 @@ fn mount_value(value: &str) -> (String, String, bool) { (name.to_owned(), host.to_owned(), mode == "ro") } +/// Returns the export that `nvx-rs run` plans for the shares of a cold-run fixture, each a host +/// directory or file at its guest target, inside no other, with the denied, allowed, and write +/// paths of `policies`, and `owner`, or `None` without shares. It builds the plan from the fixture +/// alone, as `nvx::host::filesystem` documents an export, independently of the CLI's planner: each +/// share is a child of its own at its resolved host path, in the order of those paths, with the +/// resolved policy paths inside it, sorted, and writable if its share is, whole or only below its +/// write paths; each denied path that a workload could move, strictly inside a part that the guest +/// may write, carries an identity, which `ExportPlan::check` requires there but no command line +/// carries, so a zero one serves; and each share binds its child's root at its target, the +/// shallower targets first. +fn fixture_export( + shares: &[(String, PathBuf, bool)], + policies: &[Vec; 3], + owner: nvx::host::filesystem::Owner, +) -> Option { + use nvx::host::filesystem::{Bind, ExportChild, ExportPlan, FileIdentity}; + + if shares.is_empty() { + return None; + } + // A host path resolved, without the verbatim prefix that Windows gives it. + let resolved = |path: &Path| { + let path = std::fs::canonicalize(path).unwrap(); + let text = path.to_str().unwrap(); + PathBuf::from(text.strip_prefix(r"\\?\").unwrap_or(text)) + }; + // The policy paths outside the shares are left out, and a denied one need not exist there. + let [denied, allowed, write] = policies.each_ref().map(|paths| { + let mut paths: Vec = paths + .iter() + .filter(|path| path.exists()) + .map(|path| resolved(path)) + .collect(); + paths.sort(); + paths.dedup(); + paths + }); + let inside = |paths: &[PathBuf], root: &Path| -> Vec { + paths + .iter() + .filter(|path| path.starts_with(root)) + .cloned() + .collect() + }; + let mut roots: Vec<(PathBuf, bool, bool)> = shares + .iter() + .map(|(_, host, read_only)| (resolved(host), host.is_file(), !read_only)) + .collect(); + roots.sort(); + let children: Vec = roots + .into_iter() + .map(|(root, file, writable)| { + let write = if writable { + inside(&write, &root) + } else { + Vec::new() + }; + let denied = inside(&denied, &root); + let moves = |path: &Path| { + writable + && path != root.as_path() + && (write.is_empty() + || write + .iter() + .any(|part| path != part.as_path() && path.starts_with(part))) + }; + let denied_identities = denied + .iter() + .map(|path| { + moves(path).then_some(FileIdentity { + device: 0, + index: 0, + }) + }) + .collect(); + ExportChild { + allowed: inside(&allowed, &root), + denied, + denied_identities, + write, + root, + file, + writable, + } + }) + .collect(); + let mut binds: Vec = shares + .iter() + .map(|(target, host, read_only)| { + let root = resolved(host); + Bind { + child: children + .iter() + .position(|child| child.root == root) + .unwrap(), + source: String::new(), + target: target.clone(), + read_only: *read_only, + identity: None, + } + }) + .collect(); + binds.sort_by_key(|bind| { + let depth = bind + .target + .split('/') + .filter(|name| !name.is_empty()) + .count(); + (depth, bind.target.clone()) + }); + Some(ExportPlan { + children, + binds, + owner, + }) +} + /// The options of OpenVMM that take no value. const FLAGS: [&str; 3] = [ "--single-process", @@ -257,13 +374,12 @@ impl Options { } /// Projects an old cold-run fixture onto the checked public VM contract, independently of - /// the CLI parser and its private token renderer. Restore and sandbox fixtures stay old. + /// the CLI's parser, planner, and private token renderer. Restore and sandbox fixtures stay + /// old. pub(crate) fn cold_run_expected(&self, root: &Root, mode: &str) -> nvx::Result { use nvx::activation::{MachineIdMode, View}; use nvx::host::config::{Boot, ConsoleAttachment, NetworkConfig, RootConfig, VmConfig}; - use nvx::host::filesystem::{ - self, ExportRequest, Mapping, Owner, PathList, ReservedTargets, Target, - }; + use nvx::host::filesystem::Owner; use nvx::host::network::{ Access, EgressRule, EgressRules, HostLoopback, LoopbackForward, NetworkPolicy, NetworkProxy, @@ -351,40 +467,16 @@ impl Options { }); } } - let mappings: Vec<_> = shares - .iter() - .map(|(target, host, read_only)| Mapping { - name: "--mount", - path: host, - target: Target::Guest(target), - read_only: *read_only, - }) - .collect(); - let export = filesystem::plan(&ExportRequest { - mappings: &mappings, - denied: PathList { - name: "--mount-deny", - paths: &policies[0], - }, - allowed: PathList { - name: "--mount-allow", - paths: &policies[1], - }, - write: PathList { - name: "--mount-write", - paths: &policies[2], - }, - owner: if old - .values("--mount-owner") - .first() - .is_some_and(|value| value == "caller") - { - Owner::Caller - } else { - Owner::Vmm - }, - reserved: ReservedTargets::BLOCKLESS_ROOT, - })?; + let owner = if old + .values("--mount-owner") + .first() + .is_some_and(|value| value == "caller") + { + Owner::Caller + } else { + Owner::Vmm + }; + let export = fixture_export(&shares, &policies, owner); let mut boot_tokens = vec![format!("nvx_mode={mode}")]; if let Some(export) = &export { boot_tokens.extend(export.guest_table().into_iter().map(|row| { diff --git a/nvx-rs/tests/security_checklist.rs b/nvx-rs/tests/security_checklist.rs index 437f0c7cf..b758abf1f 100644 --- a/nvx-rs/tests/security_checklist.rs +++ b/nvx-rs/tests/security_checklist.rs @@ -1,8 +1,9 @@ //! The checklist of the security requirements that the host's documentation states, through the -//! public API alone: the guest paths of an export, the stability of exported objects, the privacy -//! of a VM's directory and outputs, and the strictness of a snapshot's directory. The checks of a -//! staged image and of a scratch image's claim, which only NVX's CLI makes, are unit tests of -//! `host::sandbox::distro` and `host::sandbox::scratch`. +//! public API alone: the privacy of a VM's directory and outputs, and the strictness of a +//! snapshot's directory. The checks of an export's guest targets, read-only paths and owner, and of +//! the stability of its exported objects, which only NVX's CLI plans and checks again, are unit +//! tests of `host::filesystem`, and those of a staged image and of a scratch image's claim, which +//! only NVX's CLI makes, unit tests of `host::sandbox::distro` and `host::sandbox::scratch`. //! //! Each check is short and runs no VM. The proofs of a saved VM's liveness, of its endpoint's //! owner and of its recovery from saved values need a process of the test's own, named by its @@ -19,133 +20,10 @@ use std::process::{Command, Stdio}; use nvx::activation::{MachineIdMode, View}; use nvx::host::config::{Boot, ConsoleAttachment, RootConfig, VmConfig}; -use nvx::host::filesystem::{ - self, ExportRequest, Mapping, Owner, PathList, ReservedTargets, Target, -}; use nvx::host::runtime::{ArtifactIdentity, LaunchOptions, Runtime, RuntimeIdentity}; use nvx::host::{snapshot, vm}; use nvx::{ErrorKind, Hypervisor}; -/// Returns the request that maps `mappings` into the blockless root as `owner`, without denied, -/// allowed, or write paths. -fn request<'a>(mappings: &'a [Mapping<'a>], owner: Owner) -> ExportRequest<'a> { - let none = PathList { - name: "", - paths: &[], - }; - ExportRequest { - mappings, - denied: none, - allowed: none, - write: none, - owner, - reserved: ReservedTargets::BLOCKLESS_ROOT, - } -} - -/// Plans the export of `mappings` into the blockless root. -fn plan(mappings: &[Mapping<'_>]) -> nvx::Result> { - filesystem::plan(&request(mappings, Owner::Vmm)) -} - -#[test] -fn an_export_refuses_guest_targets_that_are_not_canonical_or_that_a_root_reserves() { - let directory = tempfile::tempdir().unwrap(); - for target in [ - "", - "relative", - "/", - "/work/", - "/work//child", - "/work/./child", - "/work/../child", - "/work\0child", - "/etc", - "/etc/child", - ] { - let mappings = [Mapping { - name: "filesystem", - path: directory.path(), - target: Target::Guest(target), - read_only: true, - }]; - let error = plan(&mappings).unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Unsupported, "{target:?}: {error}"); - } - let mappings = [Mapping { - name: "filesystem", - path: directory.path(), - target: Target::Guest("/work"), - read_only: true, - }]; - let plan = plan(&mappings).unwrap().unwrap(); - plan.check(ReservedTargets::BLOCKLESS_ROOT).unwrap(); - plan.verify().unwrap(); -} - -#[test] -fn an_export_refuses_a_read_only_path_inside_a_writable_directory() { - let directory = tempfile::tempdir().unwrap(); - let file = directory.path().join("input"); - fs::write(&file, b"input").unwrap(); - let mappings = [ - Mapping { - name: "filesystem", - path: directory.path(), - target: Target::Guest("/work"), - read_only: false, - }, - Mapping { - name: "filesystem", - path: &file, - target: Target::Guest("/work/input"), - read_only: true, - }, - ]; - assert_eq!(plan(&mappings).unwrap_err().kind(), ErrorKind::Unsupported); -} - -#[cfg(windows)] -#[test] -fn the_caller_owner_is_refused_on_windows() { - assert_eq!( - filesystem::plan(&request(&[], Owner::Caller)) - .unwrap_err() - .kind(), - ErrorKind::Unsupported - ); -} - -#[test] -fn an_exported_object_that_a_workload_could_move_is_checked_again_before_a_start() { - let directory = tempfile::tempdir().unwrap(); - let file = directory.path().join("input"); - fs::write(&file, b"original").unwrap(); - let mappings = [ - Mapping { - name: "filesystem", - path: directory.path(), - target: Target::Guest("/work"), - read_only: false, - }, - Mapping { - name: "filesystem", - path: &file, - target: Target::Guest("/work/input"), - read_only: false, - }, - ]; - let plan = plan(&mappings).unwrap().unwrap(); - plan.verify().unwrap(); - fs::rename(&file, directory.path().join("kept-original")).unwrap(); - fs::write(&file, b"replacement").unwrap(); - assert_eq!(plan.verify().unwrap_err().kind(), ErrorKind::Other); - assert_eq!( - fs::read(directory.path().join("kept-original")).unwrap(), - b"original" - ); -} - /// Returns a new private directory below a temporary one, which the caller keeps. fn private_directory() -> (tempfile::TempDir, vm::VmDir) { let directory = tempfile::tempdir().unwrap(); From 43ba68825f07764f3bfc83a13b2a272b6b90f572 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 12 Nov 2026 06:52:42 -0800 Subject: [PATCH 2/2] host: make the export planner and its host rechecks private to the CLI The export planner was public API of nvx, although path resolution, the protected and reserved targets, the allow, deny and write rules, aliases and the host rechecks before a start are the CLI's policy: only nvx-rs run and the sandbox's shares plan exports, and only the CLI checks a recorded export again before it starts a VM. They become crate-private where they are, gated by cli, with no change of behavior: - host::filesystem's plan, ExportRequest, Mapping, Target and PathList become pub(crate) and take cli, with the helpers that only they use: resolve_mapping, check_targets, shows, Mapped and unsupported_inspection in the module, identity and canonicalize_lenient in host_path, check_root and check_policy_paths in limits, and the resolution and checks of policy paths in policy. - ExportPlan::verify, the host recheck before each start, becomes pub(crate) and takes cli, with bound, changed_object, uninspected, loops, check_caller_roots and CALLER_OWNER_ON_WINDOWS, which only it and the planner use. The CLI keeps the identity pins, and calls verify before each launch as before. - What stays public doesn't change: ExportPlan with its children, binds, owner and identities, check, openvmm_arguments, kernel_token and guest_table, ReservedTargets, Owner, FileIdentity, the constants, and guest_path, at nvx::host::filesystem:: and nvx::. host::paths is already crate-private; resolve, which only the CLI calls, took cli in SL6.7b, and the rest serves host::vm, host::snapshot and the VM directory too, so it stays ungated. host::platform's file_identity, crate-private since SL6.14, then has no caller outside the CLI and the tests: the planner, its limits and verify were the others. Like process_start_time, it and its re-exports take cfg(any(test, feature = "cli")), since the unit tests of host::vm and host::snapshot compare the files and volumes that paths name with it. On Windows, handle_identity and the five imports that only the two use take it too, and on Linux the identity module, whose other items already take cli. The test-layout allowlist records the eleven hooks as seams: two more in linux/mod.rs, one more in windows/mod.rs, seven in windows/identity.rs and one in other.rs. The 27 unit tests of the planner and of verify in host::filesystem take cli with their 11 helpers, so the all-features tests run them as before; the six tests of guest_path, within, outermost, the whole-volume refusal, the reserved targets and check run without it. The public API snapshots lose 20 lines on each platform, the planner's items with their fields and ExportPlan::verify, and gain none. The docs of host and host::filesystem, and those of ExportPlan and check, name the planner and the recheck as the CLI's, and the README's filesystem section and module row follow. Part of #1125 (SL6.5a2). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nvx-rs/README.md | 43 ++++++----- nvx-rs/public-api/linux.txt | 20 ----- nvx-rs/public-api/windows.txt | 20 ----- nvx-rs/src/host/filesystem/host_path.rs | 5 ++ nvx-rs/src/host/filesystem/limits.rs | 14 +++- nvx-rs/src/host/filesystem/mod.rs | 81 +++++++++++++------- nvx-rs/src/host/filesystem/policy.rs | 18 ++++- nvx-rs/src/host/filesystem/tests.rs | 43 ++++++++++- nvx-rs/src/host/filesystem/verify.rs | 21 +++-- nvx-rs/src/host/mod.rs | 3 +- nvx-rs/src/host/platform/linux/mod.rs | 4 + nvx-rs/src/host/platform/other.rs | 1 + nvx-rs/src/host/platform/windows/identity.rs | 13 +++- nvx-rs/src/host/platform/windows/mod.rs | 3 + scripts/test-layout-allowlist.json | 6 +- 15 files changed, 188 insertions(+), 107 deletions(-) diff --git a/nvx-rs/README.md b/nvx-rs/README.md index 37cbc960f..cb8f04ee5 100644 --- a/nvx-rs/README.md +++ b/nvx-rs/README.md @@ -115,7 +115,7 @@ changes only when the module's purpose does. | [`nvx::host::config`](#nvxhostconfig) | `VmConfig` and `CaptureConfig`: pure configuration, cold-boot kernel/OpenVMM arguments, portable length checks, and capture identity | | [`nvx::host::restore`](#nvxhostrestore) | A caller-driven restore to Ready without I/O or a clock, and its peer-verified restore-ready listener | | [`nvx::host::network`](#nvxhostnetwork) | The one lowering of a network posture into OpenVMM options, for the CLI and external orchestrators | -| [`nvx::host::filesystem`](#nvxhostfilesystem) | The planner of the host paths that every NVX caller exports to the guest through OpenVMM's virtio-fs device | +| [`nvx::host::filesystem`](#nvxhostfilesystem) | The plan of the host paths that a VM exports to the guest through OpenVMM's virtio-fs device, its check and lowering, and the guest-path convention | | [`nvx::host::nvxs`](#nvxhostnvxs) | Compatibility paths for the host NVXS connection and its retained cause details | | [`nvx::host::transport`](#nvxhosttransport) | A verified VM endpoint connection and the blocking host NVXS driver, with the existing timed transport | | [`nvx::host::process`](#nvxhostprocess) | Verified references to OpenVMM processes, through which callers wait for them and end them | @@ -136,8 +136,7 @@ layer whose superblock requests recovery, before mounting anything. This policy does not change EROFS layers or writable ext4 scratches. Lowering functions take the names by which messages refer to their inputs, such -as `EgressRules::name` and `PathList::name`, so callers' messages name their own -fields. +as `EgressRules::name`, so callers' messages name their own fields. ### `nvx::protocol` @@ -936,14 +935,18 @@ action that needs more than 256 rules. ### `nvx::host::filesystem` -The planner of every NVX caller: `plan` turns an `ExportRequest`, the mapped -host paths at their guest targets, their denied, allowed, and write paths, and -the owner, into the `ExportPlan` of host paths through OpenVMM's aggregate -virtio-fs device, for a root's `ReservedTargets`, the blockless root's or the -container root's: its children, its ordered binds, which give the host-mapping -table, and the identities that each start checks again; and `guest_path` (also -at `nvx::`), the guest path at which the host-path convention places a resolved -host path. +The `ExportPlan` of host paths through OpenVMM's aggregate virtio-fs device, for +a root's `ReservedTargets`, the blockless root's or the container root's: its +children, its ordered binds, which give the host-mapping table, and the +identities that each start checks again; `ExportPlan::check`, which checks a +recorded plan without reading the host, and the plan's OpenVMM options, boot +token, and mapping table; and `guest_path` (also at `nvx::`), the guest path at +which the host-path convention places a resolved host path. + +The planner, which turns the mapped host paths at their guest targets, their +denied, allowed, and write paths, and the owner into a plan, and the checks of a +plan's host objects before each start are the CLI's path rules, private to the +crate (feature `cli`). ### `nvx::host::nvxs` @@ -1511,15 +1514,15 @@ which only the CLI uses (feature `cli`), but for the block geometry that `nvx::host::vm` and `nvx::host::snapshot` read and the tag of the shares' virtio-fs device, which a snapshot's key names: the workload identity that `--workload-user` names; the live shares of `--mount`, their guest targets and -the reserved ones, their access policies and owner, and the checks of their -host paths, which it plans through `nvx::host::filesystem` for the container -root; and 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. Its image staging, the `layer`, the `distro` and `scratch` -bindings, and the seals of the private `host::seal` that they record, is -described in [Distro bindings](#distro-bindings) and +the reserved ones, their access policies and owner, and the checks of their host +paths, which it plans through the private planner of `nvx::host::filesystem` for +the container root; and 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. Its image staging, the `layer`, the `distro` and +`scratch` bindings, and the seals of the private `host::seal` that they record, +is described in [Distro bindings](#distro-bindings) and [Images by path](#images-by-path). The sandbox launch profile specifies its own inputs rather than emulating Python. diff --git a/nvx-rs/public-api/linux.txt b/nvx-rs/public-api/linux.txt index 0003feaca..e4d1a8cc1 100644 --- a/nvx-rs/public-api/linux.txt +++ b/nvx-rs/public-api/linux.txt @@ -963,30 +963,14 @@ method nvx::host::filesystem::ExportPlan::guest_table method nvx::host::filesystem::ExportPlan::kernel_token method nvx::host::filesystem::ExportPlan::openvmm_arguments field nvx::host::filesystem::ExportPlan::owner -method nvx::host::filesystem::ExportPlan::verify -struct nvx::host::filesystem::ExportRequest -field nvx::host::filesystem::ExportRequest::allowed -field nvx::host::filesystem::ExportRequest::denied -field nvx::host::filesystem::ExportRequest::mappings -field nvx::host::filesystem::ExportRequest::owner -field nvx::host::filesystem::ExportRequest::reserved -field nvx::host::filesystem::ExportRequest::write struct nvx::host::filesystem::FileIdentity field nvx::host::filesystem::FileIdentity::device field nvx::host::filesystem::FileIdentity::index const nvx::host::filesystem::GUEST_EXPORT const nvx::host::filesystem::MAX_CHILDREN -struct nvx::host::filesystem::Mapping -field nvx::host::filesystem::Mapping::name -field nvx::host::filesystem::Mapping::path -field nvx::host::filesystem::Mapping::read_only -field nvx::host::filesystem::Mapping::target enum nvx::host::filesystem::Owner variant nvx::host::filesystem::Owner::Caller variant nvx::host::filesystem::Owner::Vmm -struct nvx::host::filesystem::PathList -field nvx::host::filesystem::PathList::name -field nvx::host::filesystem::PathList::paths const nvx::host::filesystem::RESERVED_TARGETS struct nvx::host::filesystem::ReservedTargets const nvx::host::filesystem::ReservedTargets::BLOCKLESS_ROOT @@ -994,11 +978,7 @@ const nvx::host::filesystem::ReservedTargets::CONTAINER_ROOT field nvx::host::filesystem::ReservedTargets::exact method nvx::host::filesystem::ReservedTargets::reservation field nvx::host::filesystem::ReservedTargets::trees -enum nvx::host::filesystem::Target -variant nvx::host::filesystem::Target::Guest -field nvx::host::filesystem::Target::Guest::0 fn nvx::host::filesystem::guest_path -fn nvx::host::filesystem::plan mod nvx::host::network enum nvx::host::network::Access variant nvx::host::network::Access::Allow diff --git a/nvx-rs/public-api/windows.txt b/nvx-rs/public-api/windows.txt index f2a80bec3..ae266b3ac 100644 --- a/nvx-rs/public-api/windows.txt +++ b/nvx-rs/public-api/windows.txt @@ -421,30 +421,14 @@ method nvx::host::filesystem::ExportPlan::guest_table method nvx::host::filesystem::ExportPlan::kernel_token method nvx::host::filesystem::ExportPlan::openvmm_arguments field nvx::host::filesystem::ExportPlan::owner -method nvx::host::filesystem::ExportPlan::verify -struct nvx::host::filesystem::ExportRequest -field nvx::host::filesystem::ExportRequest::allowed -field nvx::host::filesystem::ExportRequest::denied -field nvx::host::filesystem::ExportRequest::mappings -field nvx::host::filesystem::ExportRequest::owner -field nvx::host::filesystem::ExportRequest::reserved -field nvx::host::filesystem::ExportRequest::write struct nvx::host::filesystem::FileIdentity field nvx::host::filesystem::FileIdentity::device field nvx::host::filesystem::FileIdentity::index const nvx::host::filesystem::GUEST_EXPORT const nvx::host::filesystem::MAX_CHILDREN -struct nvx::host::filesystem::Mapping -field nvx::host::filesystem::Mapping::name -field nvx::host::filesystem::Mapping::path -field nvx::host::filesystem::Mapping::read_only -field nvx::host::filesystem::Mapping::target enum nvx::host::filesystem::Owner variant nvx::host::filesystem::Owner::Caller variant nvx::host::filesystem::Owner::Vmm -struct nvx::host::filesystem::PathList -field nvx::host::filesystem::PathList::name -field nvx::host::filesystem::PathList::paths const nvx::host::filesystem::RESERVED_TARGETS struct nvx::host::filesystem::ReservedTargets const nvx::host::filesystem::ReservedTargets::BLOCKLESS_ROOT @@ -452,11 +436,7 @@ const nvx::host::filesystem::ReservedTargets::CONTAINER_ROOT field nvx::host::filesystem::ReservedTargets::exact method nvx::host::filesystem::ReservedTargets::reservation field nvx::host::filesystem::ReservedTargets::trees -enum nvx::host::filesystem::Target -variant nvx::host::filesystem::Target::Guest -field nvx::host::filesystem::Target::Guest::0 fn nvx::host::filesystem::guest_path -fn nvx::host::filesystem::plan mod nvx::host::network enum nvx::host::network::Access variant nvx::host::network::Access::Allow diff --git a/nvx-rs/src/host/filesystem/host_path.rs b/nvx-rs/src/host/filesystem/host_path.rs index e5ab9ad0a..1f7fbac94 100644 --- a/nvx-rs/src/host/filesystem/host_path.rs +++ b/nvx-rs/src/host/filesystem/host_path.rs @@ -3,12 +3,16 @@ use std::fs; use std::path::{Path, PathBuf}; +#[cfg(feature = "cli")] use super::FileIdentity; +#[cfg(feature = "cli")] use crate::error::{Error, Result}; +#[cfg(feature = "cli")] use crate::host::platform; pub(crate) use crate::host::platform::is_reparse_point; /// Returns the stable device-and-file identity of `path` for rename detection. +#[cfg(feature = "cli")] pub(super) fn identity(path: &Path) -> Result { platform::file_identity(path) .map(|(device, index)| FileIdentity { device, index }) @@ -53,6 +57,7 @@ pub(crate) fn canonicalize(path: &Path) -> std::io::Result { /// Canonicalizes the deepest existing ancestor and appends the rest. Returns whether the /// complete path exists. +#[cfg(feature = "cli")] pub(super) fn canonicalize_lenient(path: &Path) -> std::io::Result<(PathBuf, bool)> { let mut existing = path.to_path_buf(); let mut missing = Vec::new(); diff --git a/nvx-rs/src/host/filesystem/limits.rs b/nvx-rs/src/host/filesystem/limits.rs index 0eb273e69..7b4d9eebc 100644 --- a/nvx-rs/src/host/filesystem/limits.rs +++ b/nvx-rs/src/host/filesystem/limits.rs @@ -1,15 +1,22 @@ //! OpenVMM's limits and host checks for exported roots and access-policy paths. +#[cfg(feature = "cli")] use std::fs; -use std::path::{Component, Path, PathBuf}; +#[cfg(feature = "cli")] +use std::path::Component; +use std::path::{Path, PathBuf}; -use super::host_path::{is_reparse_point, relative_text}; +#[cfg(feature = "cli")] +use super::host_path::is_reparse_point; +use super::host_path::relative_text; use super::{MAX_POLICY_BYTES, MAX_POLICY_PATH_BYTES, MAX_POLICY_PATHS}; +#[cfg(feature = "cli")] use crate::error::{Error, Result}; -#[cfg(unix)] +#[cfg(all(unix, feature = "cli"))] use crate::host::platform; /// Applies OpenVMM's rules for exported directories, which it reaches without links. +#[cfg(feature = "cli")] pub(super) fn check_root(root: &Path) -> Result<()> { let mut current = PathBuf::new(); for component in root.components() { @@ -35,6 +42,7 @@ pub(super) fn check_root(root: &Path) -> Result<()> { /// so a policy that OpenVMM would refuse fails at provision rather than at every start: those of /// [`policy_limits`], and on the host, no link or reparse point below `root` and no other file /// system. Returns their combined length. +#[cfg(feature = "cli")] pub(super) fn check_policy_paths(root: &Path, paths: &[PathBuf], action: &str) -> Result { let unsupported = |message: String| Err(Error::unsupported(message)); let total = policy_limits(root, paths, action).map_err(Error::unsupported)?; diff --git a/nvx-rs/src/host/filesystem/mod.rs b/nvx-rs/src/host/filesystem/mod.rs index 3dced3d5f..9f0ee1372 100644 --- a/nvx-rs/src/host/filesystem/mod.rs +++ b/nvx-rs/src/host/filesystem/mod.rs @@ -1,18 +1,20 @@ -//! Host paths exposed to the guest through OpenVMM's aggregate virtio-fs export, as the one -//! planner of every NVX caller plans them, for the blockless root and for the container root of a -//! layered sandbox alike. +//! Host paths exposed to the guest through OpenVMM's aggregate virtio-fs export, for the blockless +//! root and for the container root of a layered sandbox alike: the [`ExportPlan`] that a caller +//! records, checks, and lowers into OpenVMM's options and the guest's mapping table. The planner +//! that makes a plan of host paths, with its path rules, and the checks of a plan's host objects +//! before each start belong to NVX's CLI, private to the crate (feature `cli`). //! -//! OpenVMM offers one virtio-fs device. [`plan`] attaches it as an aggregate: a synthetic, +//! OpenVMM offers one virtio-fs device, which a plan attaches as an aggregate: a synthetic, //! read-only root that lists one child per exported host directory or file, named by its index, //! which the guest mounts at a directory that only the guest's root can enter. The guest then //! bind-mounts each mapped path at its target, read-only or read-write, in the order of the plan's //! [`Bind`]s: from the mapping table that it receives over the control channel, or from boot //! tokens that a caller renders from the same binds. //! -//! An [`ExportRequest`] names the mapped host paths, each read-only or read-write at its guest -//! target; the host paths that OpenVMM hides inside them, and the paths inside those that stay -//! visible; the paths to which the writes of a read-write directory are narrowed; the host -//! identity of the file operations; and the guest targets that the guest's root keeps for itself. +//! A plan names the mapped host paths, each read-only or read-write at its guest target; the host +//! paths that OpenVMM hides inside them, and the paths inside those that stay visible; the paths to +//! which the writes of a read-write directory are narrowed; and the host identity of the file +//! operations. //! //! - A caller gives each target, such as the guest path that MXC's convention for Linux guests //! derives from a resolved host path ([`fn@guest_path`]): a Windows path `C:\work\src` appears @@ -31,7 +33,7 @@ //! denied paths, except for the allowed paths inside them. //! //! The plan records the identities of the mapped and denied paths that a workload could move, -//! those inside a writable part, and [`ExportPlan::verify`] checks them again before each start. +//! those inside a writable part, and the CLI checks them again before each start. //! A policy that would need more children than OpenVMM offers is refused rather than served //! through the directories of its files, which would grant more than the files. @@ -41,22 +43,30 @@ mod limits; mod policy; mod verify; +#[cfg(feature = "cli")] use std::fs; use std::path::{Component, Path, PathBuf, Prefix}; use serde::{Deserialize, Serialize}; +#[cfg(feature = "cli")] use crate::error::{Error, Result}; +#[cfg(feature = "cli")] use crate::protocol::MAX_HOST_MAPPINGS; use crate::protocol::export::EXPORT_DIRECTORY; use crate::protocol::share::{RESERVED_EXACT_MOUNT_TARGETS, RESERVED_MOUNT_TARGETS}; +#[cfg(feature = "cli")] +use self::guest_path::depth; pub use self::guest_path::guest_path; +use self::guest_path::guest_source; pub(crate) use self::guest_path::within; -use self::guest_path::{depth, guest_source}; pub(crate) use self::host_path::{canonicalize, is_reparse_point}; +#[cfg(feature = "cli")] use self::host_path::{identity, relative_text}; +#[cfg(feature = "cli")] use self::limits::{check_policy_paths, check_root}; +#[cfg(feature = "cli")] use self::policy::{ Writable, allowed_paths, canonicals, check_allowed_paths, check_read_only, check_write_paths, denied_paths, distinct_roots, nearest_rule, needed_denied_paths, writable_part, write_paths, @@ -89,6 +99,7 @@ pub const RESERVED_TARGETS: [&str; 13] = [ ]; /// Why a request for the caller owner fails on a Windows host. +#[cfg(feature = "cli")] const CALLER_OWNER_ON_WINDOWS: &str = "the caller owner requires a Linux host; Windows has no \ per-request POSIX identity for OpenVMM to switch to"; @@ -160,7 +171,7 @@ impl Owner { } } -/// The host paths of one sandbox, as [`plan`] exports them: the children of OpenVMM's aggregate +/// The host paths of one sandbox, as OpenVMM's aggregate export exports them: the children of the /// export, the bind mounts of the guest, and the host identity of the file operations. /// /// Callers record the plan, so its serde form is part of their state: a caller versions its @@ -238,58 +249,62 @@ impl Bind { } /// Where the guest sees a mapped host path. +#[cfg(feature = "cli")] #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Target<'a> { +pub(crate) enum Target<'a> { /// At this absolute guest path: a path other than the root, of at most 4,095 bytes, without /// NUL characters or empty, `.`, or `..` components. Guest(&'a str), } /// A host directory or regular file that the guest reads, or reads and writes, at a target. +#[cfg(feature = "cli")] #[derive(Debug, Clone, Copy)] -pub struct Mapping<'a> { +pub(crate) struct Mapping<'a> { /// How messages name the list or option that requested the mapping, such as /// `filesystem.readonlyPaths`. - pub name: &'a str, + pub(crate) name: &'a str, /// The host path. - pub path: &'a Path, + pub(crate) path: &'a Path, /// Where the guest sees it. - pub target: Target<'a>, + pub(crate) target: Target<'a>, /// Whether the guest may only read it. - pub read_only: bool, + pub(crate) read_only: bool, } /// A list of host paths, and the name by which messages refer to its entries, such as /// `readOnly`. +#[cfg(feature = "cli")] #[derive(Debug, Clone, Copy)] -pub struct PathList<'a> { +pub(crate) struct PathList<'a> { /// The name of the list in messages. - pub name: &'a str, + pub(crate) name: &'a str, /// The host paths. - pub paths: &'a [PathBuf], + pub(crate) paths: &'a [PathBuf], } /// The host paths that a sandbox maps, those that it hides inside them and shows again inside /// those, the paths to which it narrows writes, the host identity of the file operations, and the /// guest targets that the guest's root keeps for itself. +#[cfg(feature = "cli")] #[derive(Debug, Clone, Copy)] -pub struct ExportRequest<'a> { +pub(crate) struct ExportRequest<'a> { /// The mapped host paths, each at its target with its access. - pub mappings: &'a [Mapping<'a>], + pub(crate) mappings: &'a [Mapping<'a>], /// Host paths hidden inside the mapped paths. A mapped directory may be denied itself when /// allowed paths inside it stay visible. - pub denied: PathList<'a>, + pub(crate) denied: PathList<'a>, /// Host paths inside denied paths that stay visible, with the directories on the way to /// them listing only them. - pub allowed: PathList<'a>, + pub(crate) allowed: PathList<'a>, /// Host paths to which the guest's writes are narrowed inside the read-write directory that /// they lie in, which must be mapped whole: the guest may then write only them and the /// read-write mappings inside that directory. - pub write: PathList<'a>, + pub(crate) write: PathList<'a>, /// The host identity of the file operations. - pub owner: Owner, + pub(crate) owner: Owner, /// The guest targets that the guest's root keeps for itself. - pub reserved: ReservedTargets<'a>, + pub(crate) reserved: ReservedTargets<'a>, } /// Plans the export of `request`, or returns `None` if it maps nothing. @@ -318,7 +333,8 @@ pub struct ExportRequest<'a> { /// /// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) when the request breaks /// these rules or exceeds a limit of OpenVMM or the guest agent. -pub fn plan(request: &ExportRequest<'_>) -> Result> { +#[cfg(feature = "cli")] +pub(crate) fn plan(request: &ExportRequest<'_>) -> Result> { let unsupported = |message: String| Err(Error::unsupported(message)); if request.owner == Owner::Caller && cfg!(windows) { return unsupported(CALLER_OWNER_ON_WINDOWS.to_owned()); @@ -512,6 +528,7 @@ pub fn plan(request: &ExportRequest<'_>) -> Result> { /// Resolves one requested mapping: its host path must exist as a directory or regular file that /// is not a whole volume, and its target must be a guest path that `reserved` leaves free. +#[cfg(feature = "cli")] fn resolve_mapping<'a>(mapping: &Mapping<'a>, reserved: ReservedTargets<'_>) -> Result> { let unsupported = |message: String| Err(Error::unsupported(message)); let Mapping { @@ -596,6 +613,7 @@ fn target_problem(target: &str) -> Option<&'static str> { /// Refuses mappings whose targets collide: two objects at one target, or a target inside another /// mapping's that does not show the very object that the other mapping shows there, which it /// would hide. +#[cfg(feature = "cli")] fn check_targets(mapped: &[Mapped<'_>]) -> Result<()> { for (index, entry) in mapped.iter().enumerate() { for other in &mapped[..index] { @@ -639,6 +657,7 @@ fn check_targets(mapped: &[Mapped<'_>]) -> Result<()> { /// host path from `outer`'s object to `inner`'s, as a plan's bind sources spell it. A Windows host /// reads a backslash in a name as a separator, and a name with a root or a drive as another path, /// so such a guest name never matches. +#[cfg(feature = "cli")] fn shows(outer: &Mapped<'_>, inner: &Mapped<'_>) -> bool { let Some(rest) = inner .target @@ -654,6 +673,7 @@ fn shows(outer: &Mapped<'_>, inner: &Mapped<'_>) -> bool { /// Refuses an exported directory or file that belongs to UID 0 or GID 0, to which the caller /// owner would squash guest root, and fails as `uninspected` returns for one whose owner cannot be /// read, with its path and the failure. +#[cfg(feature = "cli")] fn check_caller_roots( children: &[ExportChild], uninspected: fn(&Path, std::io::Error) -> Error, @@ -679,12 +699,14 @@ fn check_caller_roots( /// Returns the failure of a plan that cannot inspect `path`, with `error`, which refuses the /// request. +#[cfg(feature = "cli")] fn unsupported_inspection(path: &Path, error: std::io::Error) -> Error { Error::unsupported(format!("cannot inspect {}", path.display())).with_source(error) } /// Returns the failure of [`ExportPlan::verify`] for `path`, which no longer names the host object /// that the plan found there. +#[cfg(feature = "cli")] fn changed_object(path: &Path) -> Error { Error::other(format!( "{} no longer names the host object it named at provision; deprovision the sandbox and \ @@ -698,6 +720,7 @@ fn changed_object(path: &Path) -> Error { /// object went, a component of which is no longer a directory or is a link that [`loops`], or /// that the host may no longer search or open, names it no longer, [`changed_object`], and any /// other failure is the host's own, which shows nothing of the object. +#[cfg(feature = "cli")] fn uninspected(path: &Path, error: std::io::Error) -> Error { if matches!( error.kind(), @@ -715,6 +738,7 @@ fn uninspected(path: &Path, error: std::io::Error) -> Error { /// Returns whether `error` is the host's refusal to resolve a path whose links loop, or nest /// deeper than the host follows them, as a link that names itself does: the failure that the /// standard library's unstable `ErrorKind::FilesystemLoop` names. +#[cfg(feature = "cli")] fn loops(error: &std::io::Error) -> bool { #[cfg(unix)] let code = Some(nix::errno::Errno::ELOOP as i32); @@ -779,6 +803,7 @@ fn outermost(mut paths: Vec) -> Vec { } /// Canonicalized, validated form of one requested host mapping. +#[cfg(feature = "cli")] struct Mapped<'a> { /// How messages name the list or option that requested it. name: &'a str, diff --git a/nvx-rs/src/host/filesystem/policy.rs b/nvx-rs/src/host/filesystem/policy.rs index 8070124a7..8d7940b12 100644 --- a/nvx-rs/src/host/filesystem/policy.rs +++ b/nvx-rs/src/host/filesystem/policy.rs @@ -2,11 +2,16 @@ use std::path::{Path, PathBuf}; +use super::ExportChild; +#[cfg(feature = "cli")] use super::host_path::{canonicalize, canonicalize_lenient, identity}; -use super::{ExportChild, FileIdentity, Mapped, PathList, outermost}; +#[cfg(feature = "cli")] +use super::{FileIdentity, Mapped, PathList, outermost}; +#[cfg(feature = "cli")] use crate::error::{Error, Result}; /// A path of an access policy, resolved. +#[cfg(feature = "cli")] pub(super) struct PolicyPath { /// Host path as the caller supplied it, for diagnostics. given: PathBuf, @@ -15,11 +20,13 @@ pub(super) struct PolicyPath { } /// Returns the resolved paths of `paths`. +#[cfg(feature = "cli")] pub(super) fn canonicals(paths: &[PolicyPath]) -> Vec { paths.iter().map(|path| path.canonical.clone()).collect() } /// Resolves the path of the list `field`, which must exist. +#[cfg(feature = "cli")] fn resolve_policy_path(field: &str, path: &Path) -> Result { canonicalize(path).map_err(|error| { Error::unsupported(format!( @@ -32,6 +39,7 @@ fn resolve_policy_path(field: &str, path: &Path) -> Result { /// Resolves the write paths of `list`: each must lie inside a read-write directory that a child /// of `roots` exports whole, other than that directory itself. +#[cfg(feature = "cli")] pub(super) fn write_paths( list: PathList<'_>, mapped: &[Mapped<'_>], @@ -71,6 +79,7 @@ pub(super) fn write_paths( /// of its paths, but cannot keep a path inside them read-only. Only the guest's bind mount would /// then protect the path, and guest root can bypass it: through the export, or on a Windows host, /// which opens files case-insensitively, through another spelling of a file's name. +#[cfg(feature = "cli")] pub(super) fn check_read_only( mapped: &[Mapped<'_>], write: &[PolicyPath], @@ -135,6 +144,7 @@ pub(super) fn check_read_only( /// Refuses roots that name one directory or file: OpenVMM refuses one object exported twice, /// which bind mounts and hard links make possible. +#[cfg(feature = "cli")] pub(super) fn distinct_roots(roots: &[PathBuf]) -> Result<()> { let mut identities: Vec<(FileIdentity, &Path)> = Vec::with_capacity(roots.len()); for root in roots { @@ -153,6 +163,7 @@ pub(super) fn distinct_roots(roots: &[PathBuf]) -> Result<()> { /// Resolves the allowed paths of `list`, which must exist inside a child of `roots`, below its /// root. +#[cfg(feature = "cli")] pub(super) fn allowed_paths(list: PathList<'_>, roots: &[PathBuf]) -> Result> { let field = list.name; let mut paths: Vec = Vec::with_capacity(list.paths.len()); @@ -182,6 +193,7 @@ pub(super) fn allowed_paths(list: PathList<'_>, roots: &[PathBuf]) -> Result, mapped: &[Mapped<'_>], @@ -255,6 +267,7 @@ pub(super) fn denied_paths( /// Refuses an allowed path that is also denied, or whose nearest enclosing policy path is not a /// denied one, which OpenVMM refuses. +#[cfg(feature = "cli")] pub(super) fn check_allowed_paths( allowed_name: &str, denied_name: &str, @@ -291,6 +304,7 @@ pub(super) fn check_allowed_paths( } /// Refuses a write path that a denied path hides, which OpenVMM refuses. +#[cfg(feature = "cli")] pub(super) fn check_write_paths( write_name: &str, denied_name: &str, @@ -318,6 +332,7 @@ pub(super) fn check_write_paths( /// /// It takes one child's paths alone: a denied path outside the child, such as one that contains a /// denied root, hides none of the child for OpenVMM, which receives each child's policy alone. +#[cfg(feature = "cli")] pub(super) fn needed_denied_paths(denied: &[PathBuf], allowed: &[PathBuf]) -> Vec { let mut needed: Vec = denied .iter() @@ -454,6 +469,7 @@ impl Writable { /// Returns the writable part of the child at `root`: its read-write mappings, or, when one of them /// is the whole child, all of it, unless write paths narrow its writes to them and the read-write /// mappings inside it. +#[cfg(feature = "cli")] pub(super) fn writable_part(root: &Path, mapped: &[Mapped<'_>], write: &[PolicyPath]) -> Writable { let read_write: Vec = mapped .iter() diff --git a/nvx-rs/src/host/filesystem/tests.rs b/nvx-rs/src/host/filesystem/tests.rs index e7e54e522..7caab2148 100644 --- a/nvx-rs/src/host/filesystem/tests.rs +++ b/nvx-rs/src/host/filesystem/tests.rs @@ -1,10 +1,13 @@ +#[cfg(feature = "cli")] use super::host_path::display_path; use super::*; use crate::ErrorKind; +#[cfg(feature = "cli")] use crate::host::platform; /// The lists of a filesystem policy as callers hold them, under the names that messages give /// them. +#[cfg(feature = "cli")] #[derive(Debug, Default)] struct FilesystemPolicy { readonly_paths: Vec, @@ -14,6 +17,7 @@ struct FilesystemPolicy { /// Plans `policy` into the blockless root, each path at the guest path at which MXC's convention /// for Linux guests places it, [`convention`]. +#[cfg(feature = "cli")] fn plan(policy: &FilesystemPolicy) -> Result> { let entries: Vec<(&str, &Path, String, bool)> = [ ("filesystem.readonlyPaths", &policy.readonly_paths, true), @@ -48,6 +52,7 @@ fn plan(policy: &FilesystemPolicy) -> Result> { /// [`guest_path`] derives from the resolved host path, so that every spelling of one object /// (links, letter case, or short names) takes one target. A path that does not resolve takes an /// empty target, which the planner never reads, since it refuses the path first. +#[cfg(feature = "cli")] fn convention(path: &Path) -> String { canonicalize(path) .ok() @@ -57,6 +62,7 @@ fn convention(path: &Path) -> String { /// Returns the request that maps `mappings` into a root that reserves `reserved`, as the VMM, /// without denied, allowed, or write paths. +#[cfg(feature = "cli")] fn plain_request<'a>( mappings: &'a [Mapping<'a>], reserved: ReservedTargets<'a>, @@ -75,6 +81,7 @@ fn plain_request<'a>( } } +#[cfg(feature = "cli")] fn root() -> tempfile::TempDir { let directory = tempfile::tempdir().unwrap(); for name in [ @@ -93,10 +100,12 @@ fn root() -> tempfile::TempDir { directory } +#[cfg(feature = "cli")] fn canonical(path: &Path) -> PathBuf { canonicalize(path).unwrap() } +#[cfg(feature = "cli")] fn sources(mapping: &ExportPlan) -> Vec<(String, bool)> { mapping .binds @@ -105,6 +114,7 @@ fn sources(mapping: &ExportPlan) -> Vec<(String, bool)> { .collect() } +#[cfg(feature = "cli")] fn option_values(arguments: &[String], option: &str) -> Vec { arguments .windows(2) @@ -163,6 +173,7 @@ fn guest_paths_are_within_themselves_and_their_ancestors() { /// The layout of microsoft/nvx#281: directories that share no directory but the volume's /// root, each with its own access. +#[cfg(feature = "cli")] #[test] fn unrelated_directories_are_exported_with_their_own_access() { let directory = root(); @@ -228,6 +239,7 @@ fn unrelated_directories_are_exported_with_their_own_access() { /// The layout of microsoft/nvx#283: directories whose names hold commas, each with a denied /// subdirectory. +#[cfg(feature = "cli")] #[test] fn host_paths_may_contain_commas() { let directory = root(); @@ -297,6 +309,7 @@ fn host_paths_may_contain_commas() { assert!(table[1].0.ends_with("/reports/2026, final"), "{table:?}"); } +#[cfg(feature = "cli")] #[test] fn mapped_files_are_exported_alone() { let directory = root(); @@ -398,6 +411,7 @@ fn mapped_files_are_exported_alone() { ); } +#[cfg(feature = "cli")] #[test] fn policies_beyond_the_children_of_an_aggregate_are_refused() { let directory = root(); @@ -455,6 +469,7 @@ fn policies_beyond_the_children_of_an_aggregate_are_refused() { assert_eq!(mapping.children.len(), 2); } +#[cfg(feature = "cli")] #[test] fn nested_mappings_merge_into_the_outermost_directory() { let directory = root(); @@ -514,6 +529,7 @@ fn nested_mappings_merge_into_the_outermost_directory() { /// Only the guest's bind mount could keep a read-only path inside a read-write directory /// read-only, so such a path is refused on every host, under any of its names, while the /// inverse nesting and denied paths inside a read-write directory stay valid. +#[cfg(feature = "cli")] #[test] fn read_only_paths_inside_read_write_directories_are_refused() { let directory = root(); @@ -599,6 +615,7 @@ fn read_only_paths_inside_read_write_directories_are_refused() { assert_eq!(plan(&denied).unwrap().unwrap().children[0].denied.len(), 1); } +#[cfg(feature = "cli")] #[test] fn denied_paths_belong_to_the_directory_that_contains_them() { let directory = root(); @@ -652,6 +669,7 @@ fn outermost_paths_are_sorted_once_without_the_paths_inside_others() { ); } +#[cfg(feature = "cli")] #[test] fn unenforceable_policies_are_rejected() { let directory = root(); @@ -721,6 +739,7 @@ fn whole_volumes_are_refused() { } } +#[cfg(feature = "cli")] #[test] fn spellings_of_one_object_share_its_guest_path() { let directory = root(); @@ -753,7 +772,7 @@ fn spellings_of_one_object_share_its_guest_path() { ); } -#[cfg(windows)] +#[cfg(all(feature = "cli", windows))] #[test] fn paths_that_only_a_verbatim_path_names_are_refused() { let directory = root(); @@ -797,6 +816,7 @@ fn paths_that_only_a_verbatim_path_names_are_refused() { assert_eq!(exported.children[0].root, canonical(&base.join("work"))); } +#[cfg(feature = "cli")] #[test] fn policy_paths_follow_openvmm_rules() { let directory = root(); @@ -843,6 +863,7 @@ fn policy_paths_follow_openvmm_rules() { } } +#[cfg(feature = "cli")] #[test] fn openvmm_limits_are_checked_per_directory() { let directory = root(); @@ -876,6 +897,7 @@ fn openvmm_limits_are_checked_per_directory() { } } +#[cfg(feature = "cli")] #[test] fn swapped_objects_fail_verification() { let directory = root(); @@ -938,6 +960,7 @@ fn swapped_objects_fail_verification() { /// A path that no longer reaches its object names it no longer, but any other failure to /// inspect an object shows nothing of it, so it is the host's own, with its cause. +#[cfg(feature = "cli")] #[test] fn objects_that_cannot_be_inspected_fail_verification_by_what_the_failure_shows() { let directory = root(); @@ -1033,6 +1056,7 @@ fn objects_that_cannot_be_inspected_fail_verification_by_what_the_failure_shows( /// A recorded plan with a read-only bind in a part of a child that the guest may write, which /// only the guest's bind mount would keep read-only, is refused before a guest runs. +#[cfg(feature = "cli")] #[test] fn read_only_binds_that_the_host_cannot_enforce_fail_verification() { let directory = root(); @@ -1078,6 +1102,7 @@ fn read_only_binds_that_the_host_cannot_enforce_fail_verification() { /// that `NVX_TEST_SECOND_VOLUME` names, or the runner's temporary directory /// on GitHub's Windows runners. Tests create their files below it. Linux's `/dev/shm` does /// not qualify, because it lies in the guest's reserved `/dev`. +#[cfg(feature = "cli")] fn second_volume() -> Option { let candidate = std::env::var_os("NVX_TEST_SECOND_VOLUME") .or_else(|| std::env::var_os("RUNNER_TEMP")) @@ -1088,6 +1113,7 @@ fn second_volume() -> Option { (candidate.is_dir() && volume(&candidate) != volume(&temporary)).then_some(candidate) } +#[cfg(feature = "cli")] #[test] fn directories_on_other_volumes_are_exported_too() { let Some(volume) = second_volume() else { @@ -1112,6 +1138,7 @@ fn directories_on_other_volumes_are_exported_too() { assert!(modes.contains(&true) && modes.contains(&false)); } /// Returns a mapping of `path` at the guest path `target`. +#[cfg(feature = "cli")] fn at<'a>(path: &'a Path, target: &'a str, read_only: bool) -> Mapping<'a> { Mapping { name: "mappings", @@ -1121,6 +1148,7 @@ fn at<'a>(path: &'a Path, target: &'a str, read_only: bool) -> Mapping<'a> { } } +#[cfg(feature = "cli")] #[test] fn explicit_targets_place_mappings_at_any_free_guest_path() { let directory = root(); @@ -1325,6 +1353,7 @@ fn reserved_targets_depend_on_the_root() { } } +#[cfg(feature = "cli")] #[test] fn policy_paths_hide_show_and_narrow_writes() { let directory = root(); @@ -1488,6 +1517,7 @@ fn policy_paths_hide_show_and_narrow_writes() { ); } +#[cfg(feature = "cli")] #[test] fn narrowed_writes_pin_only_what_lies_in_writable_paths() { let directory = root(); @@ -1565,6 +1595,7 @@ fn narrowed_writes_pin_only_what_lies_in_writable_paths() { assert_eq!(mapping.verify().unwrap_err().kind(), ErrorKind::Other); } +#[cfg(feature = "cli")] #[test] fn the_caller_owner_reaches_openvmm_on_linux_hosts() { let directory = root(); @@ -1624,6 +1655,7 @@ fn the_caller_owner_reaches_openvmm_on_linux_hosts() { assert!(serde_json::to_value(&vmm).unwrap().get("owner").is_none()); } +#[cfg(feature = "cli")] #[test] fn read_write_mappings_to_which_writes_are_narrowed_stay_visible() { let directory = root(); @@ -1685,6 +1717,7 @@ fn read_write_mappings_to_which_writes_are_narrowed_stay_visible() { assert_eq!(child.allowed, [canonical(&private).join("public")]); } +#[cfg(feature = "cli")] #[test] fn denied_mapped_directories_inside_denied_ones_show_their_allowed_paths() { let directory = root(); @@ -1783,7 +1816,7 @@ fn recorded_plans_hold_no_more_policy_than_one_aggregate_accepts() { ); } -#[cfg(target_os = "linux")] +#[cfg(all(feature = "cli", target_os = "linux"))] #[test] fn mapped_paths_resolve_to_utf8_paths() { use std::ffi::OsStr; @@ -1812,10 +1845,12 @@ fn mapped_paths_resolve_to_utf8_paths() { } /// Plans the export of `mappings` into the blockless root. +#[cfg(feature = "cli")] fn plan_blockless(mappings: &[Mapping<'_>]) -> Result> { super::plan(&plain_request(mappings, ReservedTargets::BLOCKLESS_ROOT)) } +#[cfg(feature = "cli")] #[test] fn an_export_refuses_guest_targets_that_are_not_canonical_or_that_a_root_reserves() { let directory = tempfile::tempdir().unwrap(); @@ -1851,6 +1886,7 @@ fn an_export_refuses_guest_targets_that_are_not_canonical_or_that_a_root_reserve plan.verify().unwrap(); } +#[cfg(feature = "cli")] #[test] fn an_export_refuses_a_read_only_path_inside_a_writable_directory() { let directory = tempfile::tempdir().unwrap(); @@ -1876,7 +1912,7 @@ fn an_export_refuses_a_read_only_path_inside_a_writable_directory() { ); } -#[cfg(windows)] +#[cfg(all(feature = "cli", windows))] #[test] fn the_caller_owner_is_refused_on_windows() { let request = ExportRequest { @@ -1889,6 +1925,7 @@ fn the_caller_owner_is_refused_on_windows() { ); } +#[cfg(feature = "cli")] #[test] fn an_exported_object_that_a_workload_could_move_is_checked_again_before_a_start() { let directory = tempfile::tempdir().unwrap(); diff --git a/nvx-rs/src/host/filesystem/verify.rs b/nvx-rs/src/host/filesystem/verify.rs index 81f6373bb..fb7890826 100644 --- a/nvx-rs/src/host/filesystem/verify.rs +++ b/nvx-rs/src/host/filesystem/verify.rs @@ -1,5 +1,6 @@ //! Checks and rendering of a recorded export plan. +#[cfg(feature = "cli")] use std::fs; use std::path::PathBuf; @@ -8,17 +9,21 @@ use super::host_path::{display_path, relative_text}; use super::limits::policy_limits; use super::policy::{Writable, nearest_rule, policy_rule_problem}; use super::{ - Bind, CALLER_OWNER_ON_WINDOWS, ExportChild, ExportPlan, FileIdentity, GUEST_EXPORT, - MAX_AGGREGATE_POLICY_BYTES, MAX_CHILDREN, MAX_GUEST_PATH, Owner, ReservedTargets, - changed_object, check_caller_roots, outermost, resolved_path_problem, target_problem, - uninspected, volume_root_refusal, + Bind, ExportChild, ExportPlan, GUEST_EXPORT, MAX_AGGREGATE_POLICY_BYTES, MAX_CHILDREN, + MAX_GUEST_PATH, Owner, ReservedTargets, outermost, resolved_path_problem, target_problem, + volume_root_refusal, +}; +#[cfg(feature = "cli")] +use super::{ + CALLER_OWNER_ON_WINDOWS, FileIdentity, changed_object, check_caller_roots, uninspected, }; use crate::error::{Error, Result}; +#[cfg(feature = "cli")] use crate::host::platform; use crate::protocol::{HostMap, MAPS_TOKEN, MAX_HOST_MAPPINGS}; impl ExportPlan { - /// Checks that a plan read back from a record is one that [`crate::host::filesystem::plan`] could have made for a root + /// Checks that a plan read from a record is one that the CLI's planner could make for a root /// that reserves `reserved`: at least one and at most [`MAX_CHILDREN`] children, none inside /// another, each an absolute host path below a volume's root, with its host paths in the form /// in which `plan` resolves them, whose policy paths lie inside it within OpenVMM's limits and @@ -32,7 +37,7 @@ impl ExportPlan { /// when they may narrow its writes only to paths that keep its other read-write binds /// writable; and each denied or mapped object that a workload could move, strictly inside a /// part of the child that the guest may write, must carry its identity. It reads nothing from - /// the host; [`verify`](Self::verify) checks the host. + /// the host, which the CLI checks before each start. /// /// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported). pub fn check(&self, reserved: ReservedTargets<'_>) -> Result<()> { @@ -338,7 +343,8 @@ impl ExportPlan { /// failure to inspect an object, which shows nothing of the object, fails with /// [`ErrorKind::Io`](crate::ErrorKind::Io), with its cause. Run it while the VM is stopped, so /// that no workload can race with it; OpenVMM checks each child's kind again when it opens it. - pub fn verify(&self) -> Result<()> { + #[cfg(feature = "cli")] + pub(crate) fn verify(&self) -> Result<()> { for bind in self.binds.iter().filter(|bind| bind.read_only) { let (child, path) = self.bound(bind)?; // Without writable paths, the guest may write all of a writable child. @@ -398,6 +404,7 @@ impl ExportPlan { } /// Returns the child that holds `bind` and the host path that `bind` maps. + #[cfg(feature = "cli")] fn bound(&self, bind: &Bind) -> Result<(&ExportChild, PathBuf)> { let Some(child) = self.children.get(bind.child) else { return Err(Error::io( diff --git a/nvx-rs/src/host/mod.rs b/nvx-rs/src/host/mod.rs index a08a7923b..296aa3b61 100644 --- a/nvx-rs/src/host/mod.rs +++ b/nvx-rs/src/host/mod.rs @@ -14,7 +14,8 @@ //! whose identity names the capture's snapshots. //! - [`network`] lowers egress rules into OpenVMM's rules, as its private `policy` (feature `cli`) //! lowers the egress policy files that `nvx-rs run` and `nvx-rs sandbox` accept, and -//! [`filesystem`] plans the export of host paths through OpenVMM's aggregate virtio-fs device. +//! [`filesystem`] checks and lowers the export of host paths through OpenVMM's aggregate +//! virtio-fs device, which its private planner (feature `cli`) plans for the CLI. //! - [`report`] reads OpenVMM's outcome report. //! - [`snapshot`] holds the compatibility key, [`SnapshotKey`](snapshot::SnapshotKey), derived //! from a capture's context and [`SnapshotManifest`](snapshot::SnapshotManifest), diff --git a/nvx-rs/src/host/platform/linux/mod.rs b/nvx-rs/src/host/platform/linux/mod.rs index db346b855..5d746bbbd 100644 --- a/nvx-rs/src/host/platform/linux/mod.rs +++ b/nvx-rs/src/host/platform/linux/mod.rs @@ -1,4 +1,5 @@ mod endpoint; +#[cfg(any(test, feature = "cli"))] mod identity; #[cfg(feature = "cli")] mod log_claim; @@ -20,6 +21,9 @@ pub(crate) use self::endpoint::{ReadyListener, connect_endpoint, listen_ready}; pub(crate) use self::endpoint::{ connect_child_endpoint, endpoint_server_pid_until, interrupt_transfers, transport_from_handle, }; +// Only the CLI, which plans the exports of host paths and checks them again before each start, +// and the tests, which compare the files and volumes that paths name, read file identities. +#[cfg(any(test, feature = "cli"))] pub(crate) use self::identity::file_identity; #[cfg(feature = "cli")] pub(crate) use self::identity::{ diff --git a/nvx-rs/src/host/platform/other.rs b/nvx-rs/src/host/platform/other.rs index 6cf304c7e..6cf3f3099 100644 --- a/nvx-rs/src/host/platform/other.rs +++ b/nvx-rs/src/host/platform/other.rs @@ -94,6 +94,7 @@ pub(crate) fn monotonic_ns() -> io::Result { } /// Fails: this host cannot export host paths. +#[cfg(any(test, feature = "cli"))] pub(crate) fn file_identity(_path: &Path) -> io::Result<(u64, u64)> { Err(unsupported()) } diff --git a/nvx-rs/src/host/platform/windows/identity.rs b/nvx-rs/src/host/platform/windows/identity.rs index c629e7b6a..1b38affaf 100644 --- a/nvx-rs/src/host/platform/windows/identity.rs +++ b/nvx-rs/src/host/platform/windows/identity.rs @@ -3,15 +3,20 @@ #[cfg(feature = "cli")] use std::ffi::OsStr; -use std::fs::{self, OpenOptions}; +use std::fs; +#[cfg(any(test, feature = "cli"))] +use std::fs::OpenOptions; use std::io; use std::mem; #[cfg(feature = "cli")] use std::os::windows::ffi::OsStrExt; +#[cfg(any(test, feature = "cli"))] use std::os::windows::fs::OpenOptionsExt; +#[cfg(any(test, feature = "cli"))] use std::os::windows::io::AsRawHandle; #[cfg(feature = "cli")] use std::os::windows::io::OwnedHandle; +#[cfg(any(test, feature = "cli"))] use std::path::Path; #[cfg(feature = "cli")] use std::ptr; @@ -19,8 +24,10 @@ use std::ptr; #[cfg(feature = "cli")] use windows_sys::Win32::Foundation::ERROR_INVALID_PARAMETER; use windows_sys::Win32::Foundation::HANDLE; +#[cfg(any(test, feature = "cli"))] +use windows_sys::Win32::Storage::FileSystem::FILE_FLAG_BACKUP_SEMANTICS; use windows_sys::Win32::Storage::FileSystem::{ - BY_HANDLE_FILE_INFORMATION, FILE_FLAG_BACKUP_SEMANTICS, GetFileInformationByHandle, + BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle, }; #[cfg(feature = "cli")] @@ -30,6 +37,7 @@ use super::{owned, raw}; /// system such as ReFS does not keep unique. /// /// The object is opened without access rights, which succeeds even while OpenVMM holds it open. +#[cfg(any(test, feature = "cli"))] pub(crate) fn file_identity(path: &Path) -> io::Result<(u64, u64)> { let file = OpenOptions::new() .access_mode(0) @@ -283,6 +291,7 @@ pub(crate) fn opened_file_id(file: &fs::File) -> io::Result<(u64, u128)> { } /// Returns the volume serial number and 64-bit file index of the file that `handle` holds open. +#[cfg(any(test, feature = "cli"))] fn handle_identity(handle: HANDLE) -> io::Result<(u64, u64)> { let information = handle_information(handle)?; Ok(( diff --git a/nvx-rs/src/host/platform/windows/mod.rs b/nvx-rs/src/host/platform/windows/mod.rs index 6dff5228f..b4f375264 100644 --- a/nvx-rs/src/host/platform/windows/mod.rs +++ b/nvx-rs/src/host/platform/windows/mod.rs @@ -27,6 +27,9 @@ pub(crate) use self::endpoint::{ReadyListener, connect_endpoint}; pub(crate) use self::endpoint::{ endpoint_server_pid_until, interrupt_transfers, transport_from_handle, }; +// Only the CLI, which plans the exports of host paths and checks them again before each start, +// and the tests, which compare the files and volumes that paths name, read file identities. +#[cfg(any(test, feature = "cli"))] pub(crate) use self::identity::file_identity; #[cfg(feature = "cli")] pub(crate) use self::identity::{ diff --git a/scripts/test-layout-allowlist.json b/scripts/test-layout-allowlist.json index b62692a24..f139492dd 100644 --- a/scripts/test-layout-allowlist.json +++ b/scripts/test-layout-allowlist.json @@ -8,8 +8,10 @@ "nvx-rs/src/guest/sys/spawn.rs": 5, "nvx-rs/src/guest/time/system.rs": 3, "nvx-rs/src/host/network/address.rs": 2, - "nvx-rs/src/host/platform/linux/mod.rs": 1, - "nvx-rs/src/host/platform/windows/mod.rs": 1, + "nvx-rs/src/host/platform/linux/mod.rs": 3, + "nvx-rs/src/host/platform/other.rs": 1, + "nvx-rs/src/host/platform/windows/identity.rs": 7, + "nvx-rs/src/host/platform/windows/mod.rs": 2, "nvx-rs/src/host/platform/windows/process.rs": 2, "nvx-rs/src/host/platform/windows/spawn.rs": 1, "nvx-rs/src/host/vm.rs": 2