From 5afe173942d9967afdfad099bc9010b8485a77d4 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Mon, 26 Oct 2026 18:44:08 -0700 Subject: [PATCH 1/6] nvx-rs: split the network module along its sections host/network/mod.rs held, in 1,916 lines, the network posture of a VM with its parts and the parsers of their native forms, the translation of a posture into OpenVMM options, and 988 lines of inline tests. Split it along those sections into new files beside it, as #898 lays them out: - rule.rs: Access, and the egress rules, Protocol through EgressRules, with the parsing of their native form, and parse_rules and parse_rule, which turn the rules of an action into those that the lowering takes; - loopback.rs: HostLoopback, ForwardProtocol and LoopbackForward, and check_loopback_forwards; - proxy.rs: ProxyScheme and NetworkProxy; - arguments.rs: openvmm_arguments, proxy_arguments and check_proxy_only, and guest_subnet; - tests.rs: the inline tests, which keep their module path, host::network::tests, and so their names. NetworkPolicy and the constants stay in mod.rs, beside the address, lowering, and policy submodules; policy holds the egress policy files, not NetworkPolicy. The code moves unchanged. The new modules are private, and mod.rs re-exports their public items, so every path under nvx::host::network resolves as before. The helpers that the new files share, parse_port, parse_rules, check_loopback_forwards, and guest_subnet, become pub(super). rule.rs and loopback.rs import what only the cli feature's parsers use under that feature, so that builds without it import nothing unused. The tests import Ipv4Addr, which they took from mod.rs's imports through their glob. Without their indentation, rustfmt joins two of their statements that now fit on one line, and the continuations of two strings move left with the code around them, which leaves the strings as they were, since a continuation skips the leading whitespace of its line. Five links of the moved documentation named items that mod.rs has in scope but their new files do not: policy, twice, NetworkProxy, EgressRules::name, and MAX_RULES. Each gets a link definition with its crate path, so that the moved lines stay as they were and the links keep their targets (#896, invariant 6). Validation at this commit, on Windows and on Linux under WSL 2: rustdoc's JSON lists the same public API as on dev, for Windows (9,632 entries) and Linux (13,779), and, with private items, every intra-doc link of the crate keeps its target, with the items of the new modules named as their parent's. The lists of tests, with all features, with default features, and of the library without default features, are those of dev on Windows (841, 695, and 201) and Linux (1,440, 681, and 201). Clippy with warnings denied passes for Windows and Linux, and cargo check for macOS, the host-module guard passes, and the library tests with all features pass on Windows (700) and Linux (1,237). Part of #898. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nvx-rs/src/host/network/arguments.rs | 200 +++ nvx-rs/src/host/network/loopback.rs | 191 +++ nvx-rs/src/host/network/mod.rs | 1791 +------------------------- nvx-rs/src/host/network/proxy.rs | 140 ++ nvx-rs/src/host/network/rule.rs | 305 +++++ nvx-rs/src/host/network/tests.rs | 988 ++++++++++++++ 6 files changed, 1833 insertions(+), 1782 deletions(-) create mode 100644 nvx-rs/src/host/network/arguments.rs create mode 100644 nvx-rs/src/host/network/loopback.rs create mode 100644 nvx-rs/src/host/network/proxy.rs create mode 100644 nvx-rs/src/host/network/rule.rs create mode 100644 nvx-rs/src/host/network/tests.rs diff --git a/nvx-rs/src/host/network/arguments.rs b/nvx-rs/src/host/network/arguments.rs new file mode 100644 index 000000000..ce92e5dc0 --- /dev/null +++ b/nvx-rs/src/host/network/arguments.rs @@ -0,0 +1,200 @@ +//! The OpenVMM options that enforce a network posture. + +use std::net::Ipv4Addr; + +use super::address::Interval; +use super::loopback::check_loopback_forwards; +use super::lowering; +use super::rule::parse_rules; +use super::{ + Access, DNS_PORT, HostLoopback, MAX_HOST_LOOPBACK_PREFIX, NetworkPolicy, NetworkProxy, +}; +use crate::error::{Error, Result}; +use crate::net::{self, cidr}; + +/// Returns the OpenVMM options that enforce `policy` for a guest at `guest_network`. +/// +/// Without a policy, or when egress is denied without allow rules, ingress and host-loopback +/// access are denied, and there is no proxy, no network device is attached. A proxy-only policy +/// attaches the portable profile with egress, ingress, and host-loopback access denied, and the +/// proxy at the guest network's gateway (`--network-proxy GATEWAY:PORT`). Otherwise the portable +/// profile applies the egress posture, the ingress default, the host-loopback access, which +/// [`HostLoopback::Outbound`] leaves to OpenVMM's default, and the host-loopback forwards. Each +/// action's rules are lowered as an egress policy file's are ([`policy`]), and fail with its +/// messages, which name the rules after [`EgressRules::name`]. +/// +/// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) when a rule is invalid, +/// when the rules of one action need more than [`MAX_RULES`] OpenVMM rules, when host-loopback +/// access is allowed for a guest network whose prefix is longer than +/// [`MAX_HOST_LOOPBACK_PREFIX`], when the host-loopback forwards break the rules of +/// [`NetworkPolicy::host_loopback_forwards`], or when a policy with a proxy is not proxy-only +/// ([`NetworkPolicy::proxy`]), its proxy's port is one that [`NetworkProxy::port`] excludes, or +/// its guest network has no gateway. +/// +/// [`policy`]: crate::host::network::policy +/// [`EgressRules::name`]: crate::host::network::EgressRules::name +/// [`MAX_RULES`]: crate::host::network::MAX_RULES +pub fn openvmm_arguments( + policy: Option<&NetworkPolicy>, + guest_network: &str, +) -> Result> { + let Some(policy) = policy else { + return Ok(Vec::new()); + }; + if let Some(proxy) = &policy.proxy { + return proxy_arguments(policy, proxy, guest_network); + } + if !policy.host_loopback_forwards.is_empty() && policy.host_loopback != HostLoopback::Allow { + return Err(Error::unsupported( + "host-loopback forwards need host-loopback access allowed", + )); + } + // OpenVMM presents a host peer to the guest at an address of the guest network, which a /30 + // network, holding only the gateway and the guest, cannot spare. + if policy.host_loopback == HostLoopback::Allow + && guest_network + .split_once('/') + .and_then(|(_, prefix)| cidr::parse_prefix_length(prefix)) + .is_some_and(|prefix| prefix > MAX_HOST_LOOPBACK_PREFIX) + { + return Err(Error::unsupported(format!( + "host-loopback access needs a guest network prefix of /{MAX_HOST_LOOPBACK_PREFIX} or \ + shorter, which leaves an address at which OpenVMM presents host peers to the guest" + ))); + } + if policy.egress == Access::Deny + && policy.allow.rules.is_empty() + && policy.ingress == Access::Deny + && policy.host_loopback == HostLoopback::Deny + { + return Ok(Vec::new()); + } + let mut arguments: Vec = [ + "--net", + guest_network, + "--network-profile", + "portable", + "--network-egress", + policy.egress.as_str(), + "--network-ingress", + policy.ingress.as_str(), + ] + .map(str::to_owned) + .to_vec(); + // Without the option, OpenVMM lets the guest alone reach host loopback. + if policy.host_loopback != HostLoopback::Outbound { + arguments.push("--host-loopback".to_owned()); + arguments.push(policy.host_loopback.as_str().to_owned()); + } + // As in an egress policy file, the allow rules are checked and lowered before the deny rules. + let mut deny = Vec::new(); + for (rules, option) in [ + (&policy.allow, "--network-egress-allow"), + (&policy.deny, "--network-egress-deny"), + ] { + let parsed = parse_rules(rules)?; + let lowered = lowering::lower(&parsed.concat(), &rules.name)?; + for rule in lowered { + arguments.push(option.to_owned()); + arguments.push(rule); + } + deny = parsed; + } + check_loopback_forwards(policy, &deny, guest_network)?; + for forward in &policy.host_loopback_forwards { + arguments.push("--host-loopback-forward".to_owned()); + arguments.push(format!( + "{}:{}:{}", + forward.protocol.as_str(), + forward.host_port, + forward.guest_port + )); + } + Ok(arguments) +} + +/// Returns the OpenVMM options of a policy with a proxy, which must be proxy-only, for a guest at +/// `guest_network`: everything denied but the proxy, which the guest reaches at its gateway. +fn proxy_arguments( + policy: &NetworkPolicy, + proxy: &NetworkProxy, + guest_network: &str, +) -> Result> { + check_proxy_only(policy, proxy)?; + let Some(gateway) = net::guest_gateway(guest_network) else { + return Err(Error::unsupported(format!( + "guest network {guest_network:?} is not valid" + ))); + }; + let mut arguments: Vec = [ + "--net", + guest_network, + "--network-profile", + "portable", + "--network-egress", + "deny", + "--network-ingress", + "deny", + "--host-loopback", + "deny", + "--network-proxy", + ] + .map(str::to_owned) + .to_vec(); + arguments.push(format!("{gateway}:{}", proxy.port)); + Ok(arguments) +} + +/// Checks that a policy with `proxy` is proxy-only, as [`NetworkPolicy::proxy`] describes it, and +/// that its port is one that [`NetworkProxy::port`] allows. +fn check_proxy_only(policy: &NetworkPolicy, proxy: &NetworkProxy) -> Result<()> { + let refused = |message: String| Err(Error::unsupported(message)); + if proxy.port == 0 { + return refused("a network proxy needs a port between 1 and 65535".to_owned()); + } + // OpenVMM's DNS service at the gateway takes the guest's TCP connections to this port before + // the proxy's relay does: the proxy would receive nothing, and the guest would reach the + // host's resolver instead. + if proxy.port == DNS_PORT { + return refused(format!( + "a network proxy needs a port other than {DNS_PORT}, where OpenVMM answers DNS at the \ + guest's gateway" + )); + } + if policy.egress != Access::Deny { + return refused( + "a network proxy needs egress denied by default, since the proxy is the guest's only \ + way out" + .to_owned(), + ); + } + for rules in [&policy.allow, &policy.deny] { + if !rules.rules.is_empty() { + return refused(format!( + "a network proxy takes no {} rules, since the proxy is the guest's only way out", + rules.name + )); + } + } + if policy.ingress != Access::Deny { + return refused("a network proxy needs ingress denied".to_owned()); + } + if policy.host_loopback != HostLoopback::Deny { + return refused("a network proxy needs host-loopback access denied".to_owned()); + } + if !policy.host_loopback_forwards.is_empty() { + return refused("a network proxy takes no host-loopback forwards".to_owned()); + } + Ok(()) +} + +/// Returns the addresses of the IPv4 network of `guest_network`, a static guest address in +/// `ADDRESS/PREFIX` form. +pub(super) fn guest_subnet(guest_network: &str) -> Option { + let (address, prefix) = guest_network.split_once('/')?; + let address: Ipv4Addr = address.parse().ok()?; + let prefix = cidr::parse_prefix_length(prefix).filter(|prefix| (1..=32).contains(prefix))?; + let mask = u32::MAX << (32 - u32::from(prefix)); + let network = u32::from(address) & mask; + Some((u128::from(network), u128::from(network | !mask))) +} diff --git a/nvx-rs/src/host/network/loopback.rs b/nvx-rs/src/host/network/loopback.rs new file mode 100644 index 000000000..d188e143e --- /dev/null +++ b/nvx-rs/src/host/network/loopback.rs @@ -0,0 +1,191 @@ +//! Access between the guest and host loopback, the host loopback ports that OpenVMM forwards to +//! guest ports, and the checks of those forwards. + +use std::collections::HashSet; + +use super::address::Family; +use super::arguments::guest_subnet; +use super::lowering::Rule; +#[cfg(feature = "cli")] +use super::rule::parse_port; +use super::{Access, MAX_LOOPBACK_FORWARDS, NetworkPolicy}; +use crate::error::{Error, Result}; + +/// Access between the guest and host loopback. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum HostLoopback { + /// Neither direction: the guest reaches no host loopback service other than a + /// [`NetworkProxy`], and the host reaches none of the guest's listeners. + /// + /// [`NetworkProxy`]: crate::host::network::NetworkProxy + Deny, + /// From the guest alone: the guest reaches host loopback services through its gateway, + /// subject to the egress policy, and the host reaches none of the guest's listeners. This is + /// OpenVMM's own default, which the lowering keeps by passing no `--host-loopback` option. + Outbound, + /// Both directions: the guest reaches host loopback services through its gateway, subject to + /// the egress policy, and the host reaches the guest's listeners; see + /// [`NetworkPolicy::host_loopback`]. + Allow, +} + +impl HostLoopback { + /// Returns the name of the access: `deny`, `outbound`, or `allow`. + pub const fn as_str(self) -> &'static str { + match self { + Self::Deny => "deny", + Self::Outbound => "outbound", + Self::Allow => "allow", + } + } +} + +impl From for HostLoopback { + /// Returns the access that allows or denies both directions. + fn from(access: Access) -> Self { + match access { + Access::Allow => Self::Allow, + Access::Deny => Self::Deny, + } + } +} + +/// Transport protocol of a [`LoopbackForward`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ForwardProtocol { + /// TCP connections. + Tcp, + /// UDP datagrams. + Udp, +} + +impl ForwardProtocol { + /// Returns OpenVMM's spelling of this protocol. + pub const fn as_str(self) -> &'static str { + match self { + Self::Tcp => "tcp", + Self::Udp => "udp", + } + } +} + +/// A port on host IPv4 loopback that OpenVMM forwards to a guest port. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct LoopbackForward { + /// Transport protocol. + pub protocol: ForwardProtocol, + /// Port on `127.0.0.1` where OpenVMM listens; nonzero. + pub host_port: u16, + /// Guest port that receives the traffic; nonzero. + pub guest_port: u16, +} + +impl LoopbackForward { + /// Parses OpenVMM's `--host-loopback-forward` value: `tcp:HOST_PORT:GUEST_PORT` or + /// `udp:HOST_PORT:GUEST_PORT`, with ports between 1 and 65535. + /// + /// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) and a message that + /// starts with `value`. + #[cfg(feature = "cli")] + pub(crate) fn parse(value: &str) -> Result { + let refused = |reason: &str| Err(Error::unsupported(format!("{value}: {reason}"))); + let fields: Vec<&str> = value.split(':').collect(); + let [protocol, host_port, guest_port] = fields[..] else { + return refused("expected tcp:HOST_PORT:GUEST_PORT or udp:HOST_PORT:GUEST_PORT"); + }; + let protocol = match protocol { + "tcp" => ForwardProtocol::Tcp, + "udp" => ForwardProtocol::Udp, + _ => return refused("the protocol must be tcp or udp"), + }; + let (Some(host_port), Some(guest_port)) = (parse_port(host_port), parse_port(guest_port)) + else { + return refused("ports must be between 1 and 65535"); + }; + Ok(Self { + protocol, + host_port, + guest_port, + }) + } +} + +/// Checks the host-loopback forwards of a policy whose rules lower, and whose host-loopback +/// access is allowed when it has forwards, against the rules of +/// [`NetworkPolicy::host_loopback_forwards`]. `deny` holds the parsed deny rules. +pub(super) fn check_loopback_forwards( + policy: &NetworkPolicy, + deny: &[Vec], + guest_network: &str, +) -> Result<()> { + let forwards = &policy.host_loopback_forwards; + if forwards.is_empty() { + return Ok(()); + } + let unsupported = |message: String| Err(Error::unsupported(message)); + if forwards.len() > MAX_LOOPBACK_FORWARDS { + return unsupported(format!( + "the openvmm backend accepts at most {MAX_LOOPBACK_FORWARDS} host-loopback forwards" + )); + } + let mut bindings = HashSet::new(); + let mut targets = HashSet::new(); + for forward in forwards { + if forward.host_port == 0 || forward.guest_port == 0 { + return unsupported( + "host-loopback forward ports must be between 1 and 65535".to_owned(), + ); + } + if !bindings.insert((forward.protocol, forward.host_port)) { + return unsupported(format!( + "host-loopback forwards bind {} host port {} more than once", + forward.protocol.as_str(), + forward.host_port + )); + } + // OpenVMM keys a forward's listener by its guest port, so a second forward to one guest + // port would fail to bind when the VM starts. + if !targets.insert((forward.protocol, forward.guest_port)) { + return unsupported(format!( + "host-loopback forwards reach {} guest port {} more than once", + forward.protocol.as_str(), + forward.guest_port + )); + } + } + if forwards + .iter() + .all(|forward| forward.protocol != ForwardProtocol::Udp) + { + // OpenVMM lets the guest's replies on a forwarded TCP connection through whatever the + // egress policy, since it carries that connection. + return Ok(()); + } + if policy.egress != Access::Allow { + return unsupported( + "host-loopback UDP forwards need egress allowed by default for their replies" + .to_owned(), + ); + } + let Some((first, last)) = guest_subnet(guest_network) else { + return unsupported(format!("guest network {guest_network:?} is not valid")); + }; + // A deny rule covers the guest network if it selects one of its addresses, on any protocol. + for (index, rules) in deny.iter().enumerate() { + let covers = rules.iter().any(|rule| { + rule.family == Family::V4 + && rule + .addresses + .iter() + .any(|&(start, end)| start <= last && first <= end) + }); + if covers { + return unsupported(format!( + "{}[{index}] cannot cover the guest network, which carries host-loopback UDP \ + forward replies", + policy.deny.name + )); + } + } + Ok(()) +} diff --git a/nvx-rs/src/host/network/mod.rs b/nvx-rs/src/host/network/mod.rs index 7c5139f9f..34e7a256e 100644 --- a/nvx-rs/src/host/network/mod.rs +++ b/nvx-rs/src/host/network/mod.rs @@ -26,16 +26,17 @@ //! learn it is the caller's choice. mod address; +mod arguments; +mod loopback; mod lowering; pub mod policy; +mod proxy; +mod rule; -use std::collections::HashSet; -use std::net::Ipv4Addr; - -use self::address::{Family, Interval}; -use self::lowering::{PortValue, Rule, RuleProtocol}; -use crate::error::{Error, Result}; -use crate::net::{self, cidr}; +pub use self::arguments::openvmm_arguments; +pub use self::loopback::{ForwardProtocol, HostLoopback, LoopbackForward}; +pub use self::proxy::{NetworkProxy, ProxyScheme}; +pub use self::rule::{Access, Destination, EgressRule, EgressRules, PortSelector, Protocol}; /// Largest number of allow rules, and of deny rules, that OpenVMM accepts. pub const MAX_RULES: usize = 256; @@ -52,478 +53,6 @@ pub const MAX_HOST_LOOPBACK_PREFIX: u8 = 29; /// UDP, which no [`NetworkProxy`] may take. const DNS_PORT: u16 = 53; -/// Allow or deny decision of a network posture. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum Access { - /// Permit the traffic. - Allow, - /// Block the traffic. - Deny, -} - -impl Access { - /// Returns OpenVMM's spelling of this decision. - pub const fn as_str(self) -> &'static str { - match self { - Self::Allow => "allow", - Self::Deny => "deny", - } - } -} - -/// Access between the guest and host loopback. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum HostLoopback { - /// Neither direction: the guest reaches no host loopback service other than a - /// [`NetworkProxy`], and the host reaches none of the guest's listeners. - Deny, - /// From the guest alone: the guest reaches host loopback services through its gateway, - /// subject to the egress policy, and the host reaches none of the guest's listeners. This is - /// OpenVMM's own default, which the lowering keeps by passing no `--host-loopback` option. - Outbound, - /// Both directions: the guest reaches host loopback services through its gateway, subject to - /// the egress policy, and the host reaches the guest's listeners; see - /// [`NetworkPolicy::host_loopback`]. - Allow, -} - -impl HostLoopback { - /// Returns the name of the access: `deny`, `outbound`, or `allow`. - pub const fn as_str(self) -> &'static str { - match self { - Self::Deny => "deny", - Self::Outbound => "outbound", - Self::Allow => "allow", - } - } -} - -impl From for HostLoopback { - /// Returns the access that allows or denies both directions. - fn from(access: Access) -> Self { - match access { - Access::Allow => Self::Allow, - Access::Deny => Self::Deny, - } - } -} - -/// Transport protocol of a [`PortSelector`]. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] -pub enum Protocol { - /// TCP. - Tcp, - /// UDP. - Udp, - /// ICMP, or ICMPv6 for IPv6 networks. - Icmp, - /// Any protocol, or TCP and UDP with a port. - #[default] - Any, -} - -/// A destination network of an [`EgressRule`], without its exceptions. -/// -/// Networks are read as egress policy files read their CIDRs ([`policy`]): host bits are cleared, -/// IPv4 netmasks such as `/255.0.0.0` are accepted, and IPv6 scopes are refused. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Destination { - /// Destination address or CIDR, such as `"10.0.0.0/8"` or `"192.0.2.1"`. - pub cidr: String, - /// Sub-networks of `cidr` that the rule does not match, each within `cidr`. - pub except: Vec, -} - -impl Destination { - /// Creates a destination for `cidr` without exceptions. - pub fn new(cidr: impl Into) -> Self { - Self { - cidr: cidr.into(), - except: Vec::new(), - } - } -} - -/// A destination protocol and port range of an [`EgressRule`]. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct PortSelector { - /// Transport protocol. - pub protocol: Protocol, - /// First destination port. `None` matches every port. - pub port: Option, - /// Last destination port of a range that starts at `port`. - pub end_port: Option, -} - -/// Traffic to some destinations and ports. -/// -/// An empty `to` matches every destination of both families, and an empty `ports` matches every -/// protocol and port. -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct EgressRule { - /// Destination networks. - pub to: Vec, - /// Destination protocols and ports. - pub ports: Vec, -} - -impl EgressRule { - /// Creates a rule that matches every protocol and port of `cidr`, such as `"10.0.0.0/8"` or - /// `"192.0.2.1"`. - pub fn to(cidr: impl Into) -> Self { - Self { - to: vec![Destination::new(cidr)], - ports: Vec::new(), - } - } - - /// Restricts the rule to one port of `protocol`. - #[must_use] - pub fn on_port(mut self, protocol: Protocol, port: u16) -> Self { - self.ports.push(PortSelector { - protocol, - port: Some(port), - end_port: None, - }); - self - } - - /// Restricts the rule to the ports of `protocol` from `port` through `end_port`, both - /// included. - #[must_use] - pub fn on_port_range(mut self, protocol: Protocol, port: u16, end_port: u16) -> Self { - self.ports.push(PortSelector { - protocol, - port: Some(port), - end_port: Some(end_port), - }); - self - } - - /// Restricts the rule to `protocol` on every port. - #[must_use] - pub fn on_protocol(mut self, protocol: Protocol) -> Self { - self.ports.push(PortSelector { - protocol, - port: None, - end_port: None, - }); - self - } - - /// Parses one of OpenVMM's native rules, the value of an `--network-egress-allow` or - /// `--network-egress-deny` option, as OpenVMM parses it: `CIDR`, `CIDR:PROTOCOL`, - /// `CIDR:PROTOCOL:PORT`, or `CIDR:PROTOCOL:FIRST-LAST`, where `CIDR` is an IPv4 or IPv6 - /// address with an optional prefix length, whose host bits are cleared, `PROTOCOL` is `tcp`, - /// `udp`, or `icmp`, and only `tcp` and `udp` take ports. The rule's destination is the - /// canonical network. - /// - /// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) and a message that - /// starts with `value`. - #[cfg(feature = "cli")] - pub(crate) fn parse_native(value: &str) -> Result { - let refused = |reason: &str| Err(Error::unsupported(format!("{value}: {reason}"))); - let (destination, selector) = split_native_rule(value); - let (protocol, ports) = - match selector.map(|selector| selector.split(':').collect::>()) { - None => (None, None), - Some(fields) => match fields[..] { - [protocol] => (Some(protocol), None), - [protocol, ports] => (Some(protocol), Some(ports)), - _ => return refused("expected CIDR[:PROTOCOL[:PORT[-PORT]]]"), - }, - }; - let protocol = match protocol { - None => None, - Some("tcp") => Some(Protocol::Tcp), - Some("udp") => Some(Protocol::Udp), - Some("icmp") => Some(Protocol::Icmp), - Some(_) => return refused("the protocol must be tcp, udp, or icmp"), - }; - let ports = match ports { - None => None, - Some(_) if protocol == Some(Protocol::Icmp) => return refused("icmp takes no port"), - Some(ports) => match ports.split_once('-') { - None => match parse_port(ports) { - Some(port) => Some((port, port)), - None => return refused("ports must be between 1 and 65535"), - }, - Some((first, last)) - if first.is_empty() || last.is_empty() || last.contains('-') => - { - return refused("a port range must be FIRST-LAST"); - } - Some((first, last)) => match (parse_port(first), parse_port(last)) { - (Some(first), Some(last)) if first <= last => Some((first, last)), - (Some(_), Some(_)) => { - return refused("the port range ends below its first port"); - } - _ => return refused("ports must be between 1 and 65535"), - }, - }, - }; - let Ok(destination) = cidr::Cidr::parse_masked(destination) else { - return refused( - "the destination must be an IPv4 or IPv6 address with an optional prefix", - ); - }; - let rule = Self::to(destination.to_string()); - // A range of one port is that port. - Ok(match (protocol, ports) { - (None, _) => rule, - (Some(protocol), None) => rule.on_protocol(protocol), - (Some(protocol), Some((first, last))) if first == last => rule.on_port(protocol, first), - (Some(protocol), Some((first, last))) => rule.on_port_range(protocol, first, last), - }) - } -} - -/// Splits a native rule into its destination and its optional protocol and ports, where OpenVMM -/// splits it: an IPv4 address holds no `:`, so its first `:` starts the selector; a network with a -/// prefix ends at the first `:` after its `/`; and an IPv6 address without a prefix ends at the -/// last `:` that leaves an IPv6 address before it, since protocol names are not hexadecimal. -#[cfg(feature = "cli")] -fn split_native_rule(value: &str) -> (&str, Option<&str>) { - let split_at = |index: usize| (&value[..index], Some(&value[index + 1..])); - if let Some(slash) = value.find('/') { - return match value[slash..].find(':') { - Some(colon) => split_at(slash + colon), - None => (value, None), - }; - } - match value.find(':') { - None => (value, None), - Some(colon) if value[..colon].contains('.') => split_at(colon), - Some(_) if value.parse::().is_ok() => (value, None), - Some(_) => value - .rmatch_indices(':') - .map(|(index, _)| index) - .find(|&index| value[..index].parse::().is_ok()) - .map_or((value, None), split_at), - } -} - -/// Returns the port that `text` names, read as OpenVMM reads its ports, if it is nonzero. -fn parse_port(text: &str) -> Option { - text.parse().ok().filter(|&port| port != 0) -} - -/// The egress rules of one action, and the name by which messages refer to them, such as -/// `egress.allow`. Messages name a rule by its index and its fields by their MXC names, as in -/// `egress.allow[0].to[1].cidr`, and the action by the name, as in `egress.allow emits at most 256 -/// native rules`. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct EgressRules { - /// The name of the rules in messages. - pub name: String, - /// The rules. - pub rules: Vec, -} - -impl EgressRules { - /// Creates an empty list of rules that messages call `name`. - pub fn new(name: impl Into) -> Self { - Self { - name: name.into(), - rules: Vec::new(), - } - } -} - -/// Transport protocol of a [`LoopbackForward`]. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum ForwardProtocol { - /// TCP connections. - Tcp, - /// UDP datagrams. - Udp, -} - -impl ForwardProtocol { - /// Returns OpenVMM's spelling of this protocol. - pub const fn as_str(self) -> &'static str { - match self { - Self::Tcp => "tcp", - Self::Udp => "udp", - } - } -} - -/// A port on host IPv4 loopback that OpenVMM forwards to a guest port. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct LoopbackForward { - /// Transport protocol. - pub protocol: ForwardProtocol, - /// Port on `127.0.0.1` where OpenVMM listens; nonzero. - pub host_port: u16, - /// Guest port that receives the traffic; nonzero. - pub guest_port: u16, -} - -impl LoopbackForward { - /// Parses OpenVMM's `--host-loopback-forward` value: `tcp:HOST_PORT:GUEST_PORT` or - /// `udp:HOST_PORT:GUEST_PORT`, with ports between 1 and 65535. - /// - /// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) and a message that - /// starts with `value`. - #[cfg(feature = "cli")] - pub(crate) fn parse(value: &str) -> Result { - let refused = |reason: &str| Err(Error::unsupported(format!("{value}: {reason}"))); - let fields: Vec<&str> = value.split(':').collect(); - let [protocol, host_port, guest_port] = fields[..] else { - return refused("expected tcp:HOST_PORT:GUEST_PORT or udp:HOST_PORT:GUEST_PORT"); - }; - let protocol = match protocol { - "tcp" => ForwardProtocol::Tcp, - "udp" => ForwardProtocol::Udp, - _ => return refused("the protocol must be tcp or udp"), - }; - let (Some(host_port), Some(guest_port)) = (parse_port(host_port), parse_port(guest_port)) - else { - return refused("ports must be between 1 and 65535"); - }; - Ok(Self { - protocol, - host_port, - guest_port, - }) - } -} - -/// The scheme of a [`NetworkProxy`]'s URL. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum ProxyScheme { - /// `http`. - Http, - /// `https`. - Https, -} - -impl ProxyScheme { - /// Returns the scheme's name: `http` or `https`. - pub const fn as_str(self) -> &'static str { - match self { - Self::Http => "http", - Self::Https => "https", - } - } - - /// Returns the scheme that `name` names: `http` or `https`, in lowercase. - /// - /// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) for any other name. - pub fn from_name(name: &str) -> Result { - match name { - "http" => Ok(Self::Http), - "https" => Ok(Self::Https), - _ => Err(Error::unsupported(format!( - "network proxy scheme {name:?} must be http or https" - ))), - } - } -} - -/// An HTTP or HTTPS proxy that listens on host IPv4 loopback: the one way out of a proxy-only -/// network, which its guest reaches at the same port of its gateway. -/// -/// OpenVMM relays the guest's TCP connections to `port` of its gateway to `127.0.0.1:port` on the -/// host, whatever they carry. The scheme only names the URL that [`url`](Self::url) gives: an -/// HTTPS proxy's certificate must therefore be valid for the gateway's address. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct NetworkProxy { - /// TCP port where the proxy listens on `127.0.0.1`, and where the guest reaches it at its - /// gateway; nonzero, and not 53, where OpenVMM answers DNS at the gateway, over TCP too, - /// before it relays a connection to a proxy. - pub port: u16, - /// Scheme of the proxy's URL, if any. - pub scheme: Option, -} - -impl NetworkProxy { - /// Creates a proxy at `port` of host loopback, whose URL has no scheme. - pub const fn new(port: u16) -> Self { - Self { port, scheme: None } - } - - /// Names the scheme of the proxy's URL. - #[must_use] - pub const fn with_scheme(mut self, scheme: ProxyScheme) -> Self { - self.scheme = Some(scheme); - self - } - - /// Parses the URL of a proxy on host loopback, as the host names it: `http` or `https`, - /// `127.0.0.1` or `localhost`, and an explicit port, such as `http://127.0.0.1:3128`, with at - /// most a `/` after the port. - /// - /// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) for another scheme, a - /// host other than IPv4 loopback, a missing or zero port, or a path, query, or fragment. - pub fn parse_url(url: &str) -> Result { - let refused = |reason: &str| { - Err(Error::unsupported(format!( - "network proxy URL {url}: {reason}" - ))) - }; - let Some((scheme, rest)) = url.split_once("://") else { - return refused("expected http://HOST:PORT or https://HOST:PORT"); - }; - let scheme = match ProxyScheme::from_name(scheme) { - Ok(scheme) => scheme, - Err(_) => return refused("the scheme must be http or https"), - }; - let authority = rest.strip_suffix('/').unwrap_or(rest); - if authority.contains(['/', '?', '#']) { - return refused("nothing but a / may follow the port"); - } - let Some((host, port)) = authority.rsplit_once(':') else { - return refused("the port must be explicit"); - }; - if !matches!(host, "127.0.0.1" | "localhost") { - return refused("the host must be 127.0.0.1 or localhost"); - } - let port = Some(port) - .filter(|port| port.bytes().all(|byte| byte.is_ascii_digit())) - .and_then(parse_port); - let Some(port) = port else { - return refused("the port must be between 1 and 65535"); - }; - Ok(Self::new(port).with_scheme(scheme)) - } - - /// Parses OpenVMM's `--network-proxy` value, `IPV4:TCP-PORT`, for a guest whose gateway is - /// `gateway`, which the address must be. The proxy's URL has no scheme. - /// - /// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) and a message that - /// starts with `endpoint`. - #[cfg(feature = "cli")] - pub(crate) fn parse_endpoint(endpoint: &str, gateway: Ipv4Addr) -> Result { - let refused = |reason: String| Err(Error::unsupported(format!("{endpoint}: {reason}"))); - let Some((address, port)) = endpoint - .rsplit_once(':') - .and_then(|(address, port)| Some((address.parse::().ok()?, port))) - else { - return refused("expected IPV4:TCP-PORT".to_owned()); - }; - let Some(port) = parse_port(port) else { - return refused("the port must be between 1 and 65535".to_owned()); - }; - if address != gateway { - return refused(format!( - "the address must be the guest's gateway, {gateway}" - )); - } - Ok(Self::new(port)) - } - - /// Returns the proxy's URL as the guest sees it, at `gateway`: `SCHEME://GATEWAY:PORT`, or - /// `GATEWAY:PORT` without a scheme. - pub fn url(&self, gateway: Ipv4Addr) -> String { - match self.scheme { - Some(scheme) => format!("{}://{gateway}:{}", scheme.as_str(), self.port), - None => format!("{gateway}:{}", self.port), - } - } -} - /// The network posture of a VM. /// /// A connection matching a `deny` rule is blocked. Otherwise a connection matching an `allow` @@ -610,1307 +139,5 @@ impl NetworkPolicy { } } -/// Returns the OpenVMM options that enforce `policy` for a guest at `guest_network`. -/// -/// Without a policy, or when egress is denied without allow rules, ingress and host-loopback -/// access are denied, and there is no proxy, no network device is attached. A proxy-only policy -/// attaches the portable profile with egress, ingress, and host-loopback access denied, and the -/// proxy at the guest network's gateway (`--network-proxy GATEWAY:PORT`). Otherwise the portable -/// profile applies the egress posture, the ingress default, the host-loopback access, which -/// [`HostLoopback::Outbound`] leaves to OpenVMM's default, and the host-loopback forwards. Each -/// action's rules are lowered as an egress policy file's are ([`policy`]), and fail with its -/// messages, which name the rules after [`EgressRules::name`]. -/// -/// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) when a rule is invalid, -/// when the rules of one action need more than [`MAX_RULES`] OpenVMM rules, when host-loopback -/// access is allowed for a guest network whose prefix is longer than -/// [`MAX_HOST_LOOPBACK_PREFIX`], when the host-loopback forwards break the rules of -/// [`NetworkPolicy::host_loopback_forwards`], or when a policy with a proxy is not proxy-only -/// ([`NetworkPolicy::proxy`]), its proxy's port is one that [`NetworkProxy::port`] excludes, or -/// its guest network has no gateway. -pub fn openvmm_arguments( - policy: Option<&NetworkPolicy>, - guest_network: &str, -) -> Result> { - let Some(policy) = policy else { - return Ok(Vec::new()); - }; - if let Some(proxy) = &policy.proxy { - return proxy_arguments(policy, proxy, guest_network); - } - if !policy.host_loopback_forwards.is_empty() && policy.host_loopback != HostLoopback::Allow { - return Err(Error::unsupported( - "host-loopback forwards need host-loopback access allowed", - )); - } - // OpenVMM presents a host peer to the guest at an address of the guest network, which a /30 - // network, holding only the gateway and the guest, cannot spare. - if policy.host_loopback == HostLoopback::Allow - && guest_network - .split_once('/') - .and_then(|(_, prefix)| cidr::parse_prefix_length(prefix)) - .is_some_and(|prefix| prefix > MAX_HOST_LOOPBACK_PREFIX) - { - return Err(Error::unsupported(format!( - "host-loopback access needs a guest network prefix of /{MAX_HOST_LOOPBACK_PREFIX} or \ - shorter, which leaves an address at which OpenVMM presents host peers to the guest" - ))); - } - if policy.egress == Access::Deny - && policy.allow.rules.is_empty() - && policy.ingress == Access::Deny - && policy.host_loopback == HostLoopback::Deny - { - return Ok(Vec::new()); - } - let mut arguments: Vec = [ - "--net", - guest_network, - "--network-profile", - "portable", - "--network-egress", - policy.egress.as_str(), - "--network-ingress", - policy.ingress.as_str(), - ] - .map(str::to_owned) - .to_vec(); - // Without the option, OpenVMM lets the guest alone reach host loopback. - if policy.host_loopback != HostLoopback::Outbound { - arguments.push("--host-loopback".to_owned()); - arguments.push(policy.host_loopback.as_str().to_owned()); - } - // As in an egress policy file, the allow rules are checked and lowered before the deny rules. - let mut deny = Vec::new(); - for (rules, option) in [ - (&policy.allow, "--network-egress-allow"), - (&policy.deny, "--network-egress-deny"), - ] { - let parsed = parse_rules(rules)?; - let lowered = lowering::lower(&parsed.concat(), &rules.name)?; - for rule in lowered { - arguments.push(option.to_owned()); - arguments.push(rule); - } - deny = parsed; - } - check_loopback_forwards(policy, &deny, guest_network)?; - for forward in &policy.host_loopback_forwards { - arguments.push("--host-loopback-forward".to_owned()); - arguments.push(format!( - "{}:{}:{}", - forward.protocol.as_str(), - forward.host_port, - forward.guest_port - )); - } - Ok(arguments) -} - -/// Returns the OpenVMM options of a policy with a proxy, which must be proxy-only, for a guest at -/// `guest_network`: everything denied but the proxy, which the guest reaches at its gateway. -fn proxy_arguments( - policy: &NetworkPolicy, - proxy: &NetworkProxy, - guest_network: &str, -) -> Result> { - check_proxy_only(policy, proxy)?; - let Some(gateway) = net::guest_gateway(guest_network) else { - return Err(Error::unsupported(format!( - "guest network {guest_network:?} is not valid" - ))); - }; - let mut arguments: Vec = [ - "--net", - guest_network, - "--network-profile", - "portable", - "--network-egress", - "deny", - "--network-ingress", - "deny", - "--host-loopback", - "deny", - "--network-proxy", - ] - .map(str::to_owned) - .to_vec(); - arguments.push(format!("{gateway}:{}", proxy.port)); - Ok(arguments) -} - -/// Checks that a policy with `proxy` is proxy-only, as [`NetworkPolicy::proxy`] describes it, and -/// that its port is one that [`NetworkProxy::port`] allows. -fn check_proxy_only(policy: &NetworkPolicy, proxy: &NetworkProxy) -> Result<()> { - let refused = |message: String| Err(Error::unsupported(message)); - if proxy.port == 0 { - return refused("a network proxy needs a port between 1 and 65535".to_owned()); - } - // OpenVMM's DNS service at the gateway takes the guest's TCP connections to this port before - // the proxy's relay does: the proxy would receive nothing, and the guest would reach the - // host's resolver instead. - if proxy.port == DNS_PORT { - return refused(format!( - "a network proxy needs a port other than {DNS_PORT}, where OpenVMM answers DNS at the \ - guest's gateway" - )); - } - if policy.egress != Access::Deny { - return refused( - "a network proxy needs egress denied by default, since the proxy is the guest's only \ - way out" - .to_owned(), - ); - } - for rules in [&policy.allow, &policy.deny] { - if !rules.rules.is_empty() { - return refused(format!( - "a network proxy takes no {} rules, since the proxy is the guest's only way out", - rules.name - )); - } - } - if policy.ingress != Access::Deny { - return refused("a network proxy needs ingress denied".to_owned()); - } - if policy.host_loopback != HostLoopback::Deny { - return refused("a network proxy needs host-loopback access denied".to_owned()); - } - if !policy.host_loopback_forwards.is_empty() { - return refused("a network proxy takes no host-loopback forwards".to_owned()); - } - Ok(()) -} - -/// Parses the rules of one action, each into the rules that the lowering takes, as an egress -/// policy file's MXC rules are parsed: an empty `to` or `ports` is a rule without that field. -fn parse_rules(rules: &EgressRules) -> Result>> { - rules - .rules - .iter() - .enumerate() - .map(|(index, rule)| parse_rule(rule, &format!("{}[{index}]", rules.name))) - .collect() -} - -/// Parses one rule, which messages call `description`. -fn parse_rule(rule: &EgressRule, description: &str) -> Result> { - let mut destinations = None; - if !rule.to.is_empty() { - let mut parsed = Vec::with_capacity(rule.to.len()); - for (index, peer) in rule.to.iter().enumerate() { - let description = format!("{description}.to[{index}]"); - let network = |value: &str, field: &str| lowering::network(value, field); - let parent = network(&peer.cidr, &format!("{description}.cidr"))?; - parsed.push(lowering::destination( - parent, - peer.except.iter().enumerate().map(|(exception, value)| { - network(value, &format!("{description}.except[{exception}]")) - }), - &description, - )?); - } - destinations = Some(lowering::group_destinations(&parsed)); - } - let mut selectors = None; - if !rule.ports.is_empty() { - let mut parsed = Vec::new(); - for (index, port) in rule.ports.iter().enumerate() { - let protocol = match port.protocol { - Protocol::Tcp => RuleProtocol::Tcp, - Protocol::Udp => RuleProtocol::Udp, - Protocol::Icmp => RuleProtocol::Icmp, - Protocol::Any => RuleProtocol::Any, - }; - let value = |port: u16| PortValue::Integer(i128::from(port)); - parsed.extend(lowering::port_selectors( - protocol, - port.port.map(value), - port.end_port.map(value), - &format!("{description}.ports[{index}]"), - )?); - } - selectors = Some(parsed); - } - Ok(lowering::combine(destinations, selectors)) -} - -/// Checks the host-loopback forwards of a policy whose rules lower, and whose host-loopback -/// access is allowed when it has forwards, against the rules of -/// [`NetworkPolicy::host_loopback_forwards`]. `deny` holds the parsed deny rules. -fn check_loopback_forwards( - policy: &NetworkPolicy, - deny: &[Vec], - guest_network: &str, -) -> Result<()> { - let forwards = &policy.host_loopback_forwards; - if forwards.is_empty() { - return Ok(()); - } - let unsupported = |message: String| Err(Error::unsupported(message)); - if forwards.len() > MAX_LOOPBACK_FORWARDS { - return unsupported(format!( - "the openvmm backend accepts at most {MAX_LOOPBACK_FORWARDS} host-loopback forwards" - )); - } - let mut bindings = HashSet::new(); - let mut targets = HashSet::new(); - for forward in forwards { - if forward.host_port == 0 || forward.guest_port == 0 { - return unsupported( - "host-loopback forward ports must be between 1 and 65535".to_owned(), - ); - } - if !bindings.insert((forward.protocol, forward.host_port)) { - return unsupported(format!( - "host-loopback forwards bind {} host port {} more than once", - forward.protocol.as_str(), - forward.host_port - )); - } - // OpenVMM keys a forward's listener by its guest port, so a second forward to one guest - // port would fail to bind when the VM starts. - if !targets.insert((forward.protocol, forward.guest_port)) { - return unsupported(format!( - "host-loopback forwards reach {} guest port {} more than once", - forward.protocol.as_str(), - forward.guest_port - )); - } - } - if forwards - .iter() - .all(|forward| forward.protocol != ForwardProtocol::Udp) - { - // OpenVMM lets the guest's replies on a forwarded TCP connection through whatever the - // egress policy, since it carries that connection. - return Ok(()); - } - if policy.egress != Access::Allow { - return unsupported( - "host-loopback UDP forwards need egress allowed by default for their replies" - .to_owned(), - ); - } - let Some((first, last)) = guest_subnet(guest_network) else { - return unsupported(format!("guest network {guest_network:?} is not valid")); - }; - // A deny rule covers the guest network if it selects one of its addresses, on any protocol. - for (index, rules) in deny.iter().enumerate() { - let covers = rules.iter().any(|rule| { - rule.family == Family::V4 - && rule - .addresses - .iter() - .any(|&(start, end)| start <= last && first <= end) - }); - if covers { - return unsupported(format!( - "{}[{index}] cannot cover the guest network, which carries host-loopback UDP \ - forward replies", - policy.deny.name - )); - } - } - Ok(()) -} - -/// Returns the addresses of the IPv4 network of `guest_network`, a static guest address in -/// `ADDRESS/PREFIX` form. -fn guest_subnet(guest_network: &str) -> Option { - let (address, prefix) = guest_network.split_once('/')?; - let address: Ipv4Addr = address.parse().ok()?; - let prefix = cidr::parse_prefix_length(prefix).filter(|prefix| (1..=32).contains(prefix))?; - let mask = u32::MAX << (32 - u32::from(prefix)); - let network = u32::from(address) & mask; - Some((u128::from(network), u128::from(network | !mask))) -} - #[cfg(test)] -mod tests { - use super::*; - use crate::ErrorKind; - - fn rules(arguments: &[String], option: &str) -> Vec { - arguments - .windows(2) - .filter(|pair| pair[0] == option) - .map(|pair| pair[1].clone()) - .collect() - } - - #[test] - fn default_postures_map_to_portable_profile_options() { - assert!(openvmm_arguments(None, "10.0.0.2/24").unwrap().is_empty()); - assert!( - openvmm_arguments(Some(&NetworkPolicy::new(Access::Deny)), "10.0.0.2/24") - .unwrap() - .is_empty() - ); - assert_eq!( - openvmm_arguments(Some(&NetworkPolicy::new(Access::Allow)), "10.0.0.2/24").unwrap(), - [ - "--net", - "10.0.0.2/24", - "--network-profile", - "portable", - "--network-egress", - "allow", - "--network-ingress", - "deny", - "--host-loopback", - "deny", - ] - ); - // Deny rules under a deny default change nothing, so no device is attached. - let denied = NetworkPolicy::new(Access::Deny).with_deny(EgressRule::to("1.2.3.4")); - assert!( - openvmm_arguments(Some(&denied), "10.0.0.2/24") - .unwrap() - .is_empty() - ); - // Ingress needs the device even when egress is denied, and host loopback stays denied. - for egress in [Access::Deny, Access::Allow] { - let mut ingress = NetworkPolicy::new(egress); - ingress.ingress = Access::Allow; - assert_eq!( - openvmm_arguments(Some(&ingress), "10.0.0.2/24").unwrap(), - [ - "--net", - "10.0.0.2/24", - "--network-profile", - "portable", - "--network-egress", - egress.as_str(), - "--network-ingress", - "allow", - "--host-loopback", - "deny", - ] - ); - } - // Host-loopback access needs the device even when egress and ingress are denied. - let mut host_loopback = NetworkPolicy::new(Access::Deny); - host_loopback.host_loopback = HostLoopback::Allow; - assert_eq!( - openvmm_arguments(Some(&host_loopback), "10.0.0.2/24").unwrap(), - [ - "--net", - "10.0.0.2/24", - "--network-profile", - "portable", - "--network-egress", - "deny", - "--network-ingress", - "deny", - "--host-loopback", - "allow", - ] - ); - // So does outbound-only access, which OpenVMM's default provides. - host_loopback.host_loopback = HostLoopback::Outbound; - assert_eq!( - openvmm_arguments(Some(&host_loopback), "10.0.0.2/24").unwrap(), - [ - "--net", - "10.0.0.2/24", - "--network-profile", - "portable", - "--network-egress", - "deny", - "--network-ingress", - "deny", - ] - ); - } - - #[test] - fn host_loopback_access_needs_room_for_host_peers() { - let mut policy = NetworkPolicy::new(Access::Deny); - policy.host_loopback = HostLoopback::Allow; - // A /30 network holds only the gateway and the guest. - let error = openvmm_arguments(Some(&policy), "10.0.0.2/30").unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Unsupported); - assert!(error.message().contains("/29 or shorter"), "{error}"); - for guest_network in ["10.0.0.2/29", "10.0.0.2/24"] { - let arguments = openvmm_arguments(Some(&policy), guest_network).unwrap(); - assert!( - has_pair(&arguments, "--host-loopback", "allow"), - "{guest_network}" - ); - } - // Host-loopback access that is denied, or open only to the guest, needs no host peers. - let mut denied = NetworkPolicy::new(Access::Allow); - denied.host_loopback = HostLoopback::Deny; - let arguments = openvmm_arguments(Some(&denied), "10.0.0.2/30").unwrap(); - assert!(has_pair(&arguments, "--host-loopback", "deny")); - denied.host_loopback = HostLoopback::Outbound; - let arguments = openvmm_arguments(Some(&denied), "10.0.0.2/30").unwrap(); - assert!( - !arguments - .iter() - .any(|argument| argument == "--host-loopback") - ); - } - - #[test] - fn host_loopback_forwards_follow_openvmm_rules() { - let forward = |protocol, host_port, guest_port| LoopbackForward { - protocol, - host_port, - guest_port, - }; - let forwarded = |forwards: Vec| { - let mut policy = NetworkPolicy::new(Access::Allow); - policy.host_loopback = HostLoopback::Allow; - policy.host_loopback_forwards = forwards; - policy - }; - let rejected = |policy: &NetworkPolicy, expected: &str| { - let error = openvmm_arguments(Some(policy), "10.0.0.2/24").unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Unsupported); - assert!(error.message().contains(expected), "{error}"); - }; - - let valid = forwarded(vec![ - forward(ForwardProtocol::Tcp, 18080, 8080), - forward(ForwardProtocol::Udp, 18080, 8081), - // One guest port may receive a forward of each protocol. - forward(ForwardProtocol::Udp, 18081, 8080), - ]); - let arguments = openvmm_arguments(Some(&valid), "10.0.0.2/24").unwrap(); - assert_eq!( - rules(&arguments, "--host-loopback-forward"), - ["tcp:18080:8080", "udp:18080:8081", "udp:18081:8080"] - ); - assert!(has_pair(&arguments, "--host-loopback", "allow")); - - let mut denied = valid.clone(); - denied.host_loopback = HostLoopback::Deny; - rejected(&denied, "need host-loopback access allowed"); - // Forwards publish host ports to the guest, which outbound-only access does not. - let mut outbound = valid.clone(); - outbound.host_loopback = HostLoopback::Outbound; - rejected(&outbound, "need host-loopback access allowed"); - let mut closed = valid.clone(); - closed.egress = Access::Deny; - rejected( - &closed, - "host-loopback UDP forwards need egress allowed by default for their replies", - ); - // OpenVMM lets the guest's replies on a forwarded TCP connection through whatever the - // egress policy, so TCP forwards alone need neither egress allowed by default nor a guest - // network that no deny rule covers. - let mut tcp = forwarded(vec![forward(ForwardProtocol::Tcp, 18080, 8080)]); - tcp.egress = Access::Deny; - let tcp = tcp.with_deny(EgressRule::to("10.0.0.0/24")); - let arguments = openvmm_arguments(Some(&tcp), "10.0.0.2/24").unwrap(); - assert!(has_pair(&arguments, "--network-egress", "deny")); - assert_eq!(rules(&arguments, "--network-egress-deny"), ["10.0.0.0/24"]); - assert_eq!( - rules(&arguments, "--host-loopback-forward"), - ["tcp:18080:8080"] - ); - // A UDP forward beside them needs both. - let mut udp = tcp.clone(); - udp.host_loopback_forwards - .push(forward(ForwardProtocol::Udp, 18081, 8081)); - rejected( - &udp, - "host-loopback UDP forwards need egress allowed by default", - ); - udp.egress = Access::Allow; - rejected( - &udp, - "egress.deny[0] cannot cover the guest network, which carries host-loopback UDP \ - forward replies", - ); - rejected( - &forwarded(vec![ - forward(ForwardProtocol::Tcp, 18080, 8080), - forward(ForwardProtocol::Tcp, 18080, 8081), - ]), - "bind tcp host port 18080 more than once", - ); - // OpenVMM could not bind a second forward to one guest port. - rejected( - &forwarded(vec![ - forward(ForwardProtocol::Udp, 18080, 8080), - forward(ForwardProtocol::Udp, 18081, 8080), - ]), - "reach udp guest port 8080 more than once", - ); - rejected( - &forwarded(vec![forward(ForwardProtocol::Udp, 0, 8080)]), - "between 1 and 65535", - ); - rejected( - &forwarded( - (1..=65) - .map(|port| forward(ForwardProtocol::Tcp, port, port)) - .collect(), - ), - "at most 64", - ); - - // The guest's replies to forwarded traffic leave toward the guest network. - for rule in [ - EgressRule::default(), - EgressRule::to("10.0.0.0/8"), - EgressRule::to("10.0.0.0/24"), - EgressRule::to("10.0.0.77").on_port(Protocol::Tcp, 443), - // Networks are read as egress policy files read them, host bits and netmasks included. - EgressRule::to("10.0.0.77/24"), - EgressRule::to("10.0.0.0/255.255.255.0"), - ] { - rejected(&valid.clone().with_deny(rule), "forward replies"); - } - for rule in [ - EgressRule::to("10.0.1.0/24"), - EgressRule::to("fd00::/8"), - EgressRule { - to: vec![Destination { - cidr: "0.0.0.0/0".to_owned(), - except: vec!["10.0.0.0/24".to_owned()], - }], - ports: Vec::new(), - }, - ] { - openvmm_arguments(Some(&valid.clone().with_deny(rule)), "10.0.0.2/24").unwrap(); - } - } - - #[test] - fn rules_expand_exactly() { - let egress = NetworkPolicy::new(Access::Deny) - .with_allow(EgressRule { - to: vec![Destination { - cidr: "10.0.0.0/30".to_owned(), - except: vec!["10.0.0.1".to_owned()], - }], - ports: vec![PortSelector { - protocol: Protocol::Tcp, - port: Some(80), - end_port: Some(81), - }], - }) - .with_allow(EgressRule::to("192.0.2.7").on_port(Protocol::Any, 53)) - .with_allow(EgressRule::to("198.51.100.0/24")); - let arguments = openvmm_arguments(Some(&egress), "10.0.0.2/24").unwrap(); - assert_eq!( - rules(&arguments, "--network-egress-allow"), - [ - "10.0.0.0/32:tcp:80-81", - "10.0.0.2/31:tcp:80-81", - "192.0.2.7/32:tcp:53", - "192.0.2.7/32:udp:53", - "198.51.100.0/24", - ] - ); - assert!(has_pair(&arguments, "--network-egress", "deny")); - - let egress = NetworkPolicy::new(Access::Allow).with_deny(EgressRule { - to: Vec::new(), - ports: vec![PortSelector { - protocol: Protocol::Udp, - port: Some(53), - end_port: None, - }], - }); - let arguments = openvmm_arguments(Some(&egress), "10.0.0.2/24").unwrap(); - // A rule without destinations matches both address families. - assert_eq!( - rules(&arguments, "--network-egress-deny"), - ["0.0.0.0/0:udp:53", "::/0:udp:53"] - ); - } - - #[test] - fn ipv6_rules_lower_exactly() { - let egress = NetworkPolicy::new(Access::Deny) - .with_allow(EgressRule::to("2001:db8:1::/64").on_port(Protocol::Tcp, 443)) - .with_allow(EgressRule { - to: vec![Destination { - cidr: "2001:db8:2::/126".to_owned(), - except: vec!["2001:db8:2::1".to_owned()], - }], - ports: vec![PortSelector { - protocol: Protocol::Any, - port: Some(53), - end_port: None, - }], - }) - .with_allow(EgressRule::to("192.0.2.0/24").on_protocol(Protocol::Icmp)) - .with_allow(EgressRule::to("::/0").on_protocol(Protocol::Icmp)) - .with_deny(EgressRule::to("2001:db8:1::123")); - let arguments = openvmm_arguments(Some(&egress), "10.0.0.2/24").unwrap(); - // Rules sort by family and then by address, so `::/0` leads the IPv6 rules. - assert_eq!( - rules(&arguments, "--network-egress-allow"), - [ - "192.0.2.0/24:icmp", - "::/0:icmp", - "2001:db8:1::/64:tcp:443", - "2001:db8:2::/128:tcp:53", - "2001:db8:2::/128:udp:53", - "2001:db8:2::2/127:tcp:53", - "2001:db8:2::2/127:udp:53", - ] - ); - assert_eq!( - rules(&arguments, "--network-egress-deny"), - ["2001:db8:1::123/128"] - ); - } - - #[test] - fn rules_lower_like_egress_policy_files() { - let lowered = |rules: Vec| { - let mut egress = NetworkPolicy::new(Access::Deny); - egress.allow.rules = rules; - let arguments = openvmm_arguments(Some(&egress), "10.0.0.2/24").unwrap(); - self::rules(&arguments, "--network-egress-allow") - }; - let tcp = |cidr: &str, port, end_port| { - EgressRule::to(cidr).on_port_range(Protocol::Tcp, port, end_port) - }; - // Destinations merge into the fewest networks, and port ranges into one range. - assert_eq!( - lowered(vec![EgressRule { - to: (0..300) - .map(|index| Destination::new(format!("10.0.{}.0/24", index % 256))) - .collect(), - ports: vec![PortSelector { - protocol: Protocol::Tcp, - port: Some(1), - end_port: Some(2), - }], - }]), - ["10.0.0.0/16:tcp:1-2"] - ); - assert_eq!( - lowered(vec![ - tcp("192.0.2.7", 8000, 8004), - tcp("192.0.2.7", 8003, 8010), - tcp("192.0.2.6", 8000, 8010), - ]), - ["192.0.2.6/31:tcp:8000-8010"] - ); - // Rules for every port of a protocol, or every protocol, subsume narrower ones. - assert_eq!( - lowered(vec![ - EgressRule::to("192.0.2.7").on_port(Protocol::Tcp, 443), - EgressRule::to("192.0.2.0/24").on_protocol(Protocol::Tcp), - EgressRule::to("198.51.100.0/24").on_port(Protocol::Udp, 53), - EgressRule::to("198.51.100.0/23"), - ]), - ["192.0.2.0/24:tcp", "198.51.100.0/23"] - ); - // Host bits are cleared, as an egress policy file's are. - assert_eq!( - lowered(vec![EgressRule::to("10.0.0.5/255.255.255.0")]), - ["10.0.0.0/24"] - ); - } - - #[test] - fn invalid_rules_are_rejected_with_the_policy_compilers_messages() { - let port = |protocol, port, end_port| EgressRule { - to: vec![Destination::new("10.0.0.0/8")], - ports: vec![PortSelector { - protocol, - port, - end_port, - }], - }; - let excluding = |cidr: &str, except: &str| EgressRule { - to: vec![ - Destination::new("192.0.2.0/24"), - Destination { - cidr: cidr.to_owned(), - except: vec![except.to_owned()], - }, - ], - ports: Vec::new(), - }; - for (rule, message) in [ - ( - port(Protocol::Icmp, Some(8), None), - "egress.allow[0].ports[0].port is not supported with icmp", - ), - // Structural validation rejects these first, unless the backend is called directly. - ( - port(Protocol::Tcp, Some(90), Some(80)), - "egress.allow[0].ports[0].endPort 80 cannot be below port 90", - ), - ( - port(Protocol::Udp, None, Some(80)), - "egress.allow[0].ports[0].endPort requires port", - ), - ( - port(Protocol::Tcp, Some(0), None), - "egress.allow[0].ports[0].port must be between 1 and 65535", - ), - ( - port(Protocol::Any, Some(80), Some(0)), - "egress.allow[0].ports[0].endPort must be between 1 and 65535", - ), - ( - EgressRule::to("example.com"), - "egress.allow[0].to[0].cidr is not a valid IPv4 or IPv6 CIDR: \"example.com\"", - ), - ( - EgressRule::to("fe80::1%eth0/64"), - "egress.allow[0].to[0].cidr must not name an IPv6 scope: \"fe80::1%eth0/64\"", - ), - ( - excluding("10.0.0.0/8", "11.0.0.0/16"), - "egress.allow[0].to[1].except[0] must be contained in 10.0.0.0/8", - ), - ( - excluding("10.0.0.7/8", "10.1.0.0/+16"), - "egress.allow[0].to[1].except[0] is not a valid IPv4 or IPv6 CIDR: \"10.1.0.0/+16\"", - ), - // 129 separate ranges of both TCP and UDP need 258 OpenVMM rules. - ( - EgressRule { - to: vec![Destination::new("10.0.0.0/8")], - ports: (0..129) - .map(|index| PortSelector { - protocol: Protocol::Any, - port: Some(3 * index + 1), - end_port: Some(3 * index + 2), - }) - .collect(), - }, - "egress.allow emits at most 256 native rules", - ), - ] { - let egress = NetworkPolicy::new(Access::Deny).with_allow(rule.clone()); - let error = openvmm_arguments(Some(&egress), "10.0.0.2/24").unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Unsupported, "{rule:?}"); - assert_eq!(error.message(), message, "{rule:?}"); - } - // The allow rules are checked and lowered before the deny rules. - let egress = NetworkPolicy::new(Access::Allow) - .with_allow(EgressRule::to("10.0.0.0/33")) - .with_deny(port(Protocol::Icmp, Some(8), None)); - let error = openvmm_arguments(Some(&egress), "10.0.0.2/24").unwrap_err(); - assert_eq!( - error.message(), - "egress.allow[0].to[0].cidr is not a valid IPv4 or IPv6 CIDR: \"10.0.0.0/33\"" - ); - } - - #[test] - fn exceptions_that_split_a_network_too_finely_are_rejected() { - // Without a limit, these exceptions would split the address space into 512 * 23 networks. - let peer = Destination { - cidr: "0.0.0.0/0".to_owned(), - except: (0..512u32) - .map(|index| std::net::Ipv4Addr::from(index << 23).to_string()) - .collect(), - }; - let rule = EgressRule { - to: vec![peer], - ports: Vec::new(), - }; - let egress = NetworkPolicy::new(Access::Deny).with_allow(rule); - let error = openvmm_arguments(Some(&egress), "10.0.0.2/24").unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Unsupported); - assert_eq!( - error.message(), - "egress.allow emits at most 256 native rules" - ); - } - - #[test] - fn exclusions_remain_local_to_their_rules() { - let excluding = |cidr: &str, excluded: &str| EgressRule { - to: vec![Destination { - cidr: cidr.to_owned(), - except: vec![excluded.to_owned()], - }], - ports: Vec::new(), - }; - let egress = NetworkPolicy::new(Access::Deny) - .with_allow(excluding("192.0.2.0/24", "192.0.2.128/25")) - .with_allow(EgressRule::to("192.0.2.200")) - .with_deny(excluding("198.51.100.0/24", "198.51.100.128/25")); - let arguments = openvmm_arguments(Some(&egress), "10.0.0.2/24").unwrap(); - - assert_eq!( - rules(&arguments, "--network-egress-allow"), - ["192.0.2.0/25", "192.0.2.200/32"] - ); - assert_eq!( - rules(&arguments, "--network-egress-deny"), - ["198.51.100.0/25"] - ); - } - - fn has_pair(arguments: &[String], name: &str, value: &str) -> bool { - arguments - .windows(2) - .any(|pair| pair[0] == name && pair[1] == value) - } - - #[test] - fn protocols_without_ports_expand_to_protocol_rules() { - let egress = NetworkPolicy::new(Access::Deny) - .with_allow(EgressRule::to("192.0.2.7").on_protocol(Protocol::Tcp)) - .with_allow(EgressRule::to("192.0.2.7").on_protocol(Protocol::Udp)) - .with_allow(EgressRule::to("192.0.2.7").on_protocol(Protocol::Icmp)) - .with_allow(EgressRule::to("198.51.100.0/24").on_protocol(Protocol::Any)) - .with_allow(EgressRule::to("203.0.113.9").on_port(Protocol::Any, 443)) - .with_deny( - EgressRule::to("192.0.2.7") - .on_protocol(Protocol::Icmp) - .on_port(Protocol::Tcp, 22), - ); - let arguments = openvmm_arguments(Some(&egress), "10.0.0.2/24").unwrap(); - assert_eq!( - rules(&arguments, "--network-egress-allow"), - [ - "192.0.2.7/32:icmp", - "192.0.2.7/32:tcp", - "192.0.2.7/32:udp", - "198.51.100.0/24", - "203.0.113.9/32:tcp:443", - "203.0.113.9/32:udp:443", - ] - ); - assert_eq!( - rules(&arguments, "--network-egress-deny"), - ["192.0.2.7/32:icmp", "192.0.2.7/32:tcp:22"] - ); - } - - #[test] - fn port_ranges_expand_to_one_rule_per_network_and_protocol() { - let egress = NetworkPolicy::new(Access::Deny) - .with_allow(EgressRule::to("192.0.2.7").on_port_range(Protocol::Tcp, 8000, 8010)) - .with_allow(EgressRule::to("192.0.2.7").on_port_range(Protocol::Udp, 5000, 5010)) - .with_allow(EgressRule::to("198.51.100.0/24").on_port_range(Protocol::Any, 1, 65535)) - // A range of one port is that port's rule. - .with_allow(EgressRule::to("203.0.113.9").on_port_range(Protocol::Tcp, 443, 443)) - .with_deny(EgressRule::to("192.0.2.7").on_port(Protocol::Tcp, 8005)); - let arguments = openvmm_arguments(Some(&egress), "10.0.0.2/24").unwrap(); - assert_eq!( - rules(&arguments, "--network-egress-allow"), - [ - "192.0.2.7/32:tcp:8000-8010", - "192.0.2.7/32:udp:5000-5010", - "198.51.100.0/24:tcp:1-65535", - "198.51.100.0/24:udp:1-65535", - "203.0.113.9/32:tcp:443", - ] - ); - assert_eq!( - rules(&arguments, "--network-egress-deny"), - ["192.0.2.7/32:tcp:8005"] - ); - } - - /// The proxy-only policy of a proxy at port 3128 of host loopback. - fn proxy_only() -> NetworkPolicy { - NetworkPolicy::proxy_only(NetworkProxy::new(3128)) - } - - #[test] - fn proxy_only_policies_reach_the_proxy_at_the_gateway() { - for (guest_network, endpoint) in [ - ("10.0.0.2/24", "10.0.0.1:3128"), - ("172.16.40.9/16", "172.16.0.1:3128"), - // A /30 network holds the gateway and the guest, which is all that a proxy needs. - ("192.168.7.6/30", "192.168.7.5:3128"), - ] { - assert_eq!( - openvmm_arguments(Some(&proxy_only()), guest_network).unwrap(), - [ - "--net", - guest_network, - "--network-profile", - "portable", - "--network-egress", - "deny", - "--network-ingress", - "deny", - "--host-loopback", - "deny", - "--network-proxy", - endpoint, - ] - ); - } - // The scheme names only the URL. - let https = - NetworkPolicy::proxy_only(NetworkProxy::new(3128).with_scheme(ProxyScheme::Https)); - assert_eq!( - openvmm_arguments(Some(&https), "10.0.0.2/24").unwrap(), - openvmm_arguments(Some(&proxy_only()), "10.0.0.2/24").unwrap() - ); - let mut built = NetworkPolicy::new(Access::Deny); - built.proxy = Some(NetworkProxy::new(3128)); - assert_eq!(built, proxy_only()); - } - - #[test] - fn proxies_refuse_every_other_way_out() { - let refused = |edit: &dyn Fn(&mut NetworkPolicy), message: &str| { - let mut policy = proxy_only(); - edit(&mut policy); - let error = openvmm_arguments(Some(&policy), "10.0.0.2/24").unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Unsupported); - assert_eq!(error.message(), message); - }; - let only_way_out = "since the proxy is the guest's only way out"; - refused( - &|policy| policy.egress = Access::Allow, - &format!("a network proxy needs egress denied by default, {only_way_out}"), - ); - refused( - &|policy| policy.allow.rules.push(EgressRule::to("192.0.2.0/24")), - &format!("a network proxy takes no egress.allow rules, {only_way_out}"), - ); - refused( - &|policy| policy.deny.rules.push(EgressRule::to("192.0.2.0/24")), - &format!("a network proxy takes no egress.deny rules, {only_way_out}"), - ); - refused( - &|policy| policy.ingress = Access::Allow, - "a network proxy needs ingress denied", - ); - for host_loopback in [HostLoopback::Allow, HostLoopback::Outbound] { - refused( - &|policy| policy.host_loopback = host_loopback, - "a network proxy needs host-loopback access denied", - ); - } - refused( - &|policy| { - policy.host_loopback_forwards.push(LoopbackForward { - protocol: ForwardProtocol::Tcp, - host_port: 18080, - guest_port: 8080, - }); - }, - "a network proxy takes no host-loopback forwards", - ); - refused( - &|policy| policy.proxy = Some(NetworkProxy::new(0)), - "a network proxy needs a port between 1 and 65535", - ); - // OpenVMM's DNS service at the gateway takes the guest's TCP connections to port 53 - // before the proxy's relay does, whatever the proxy's scheme. - refused( - &|policy| policy.proxy = Some(NetworkProxy::new(53).with_scheme(ProxyScheme::Http)), - "a network proxy needs a port other than 53, where OpenVMM answers DNS at the guest's \ - gateway", - ); - // The proxy's checks come first: a proxy beside allowed forwards is refused as a proxy. - refused( - &|policy| { - policy.egress = Access::Allow; - policy.host_loopback = HostLoopback::Allow; - policy.host_loopback_forwards.push(LoopbackForward { - protocol: ForwardProtocol::Tcp, - host_port: 18080, - guest_port: 8080, - }); - }, - &format!("a network proxy needs egress denied by default, {only_way_out}"), - ); - // The guest reaches the proxy at its gateway, which an invalid guest network lacks. - for guest_network in [ - "10.0.0.1/24", - "10.0.0.0/24", - "10.0.0.255/24", - "10.0.0.2/31", - "10.0.0.2", - "fd00::2/64", - ] { - let error = openvmm_arguments(Some(&proxy_only()), guest_network).unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Unsupported); - assert_eq!( - error.message(), - format!("guest network {guest_network:?} is not valid") - ); - } - } - - #[test] - fn proxy_urls_name_the_gateway_as_the_guest_sees_it() { - let gateway = Ipv4Addr::new(10, 0, 0, 1); - assert_eq!(NetworkProxy::new(3128).url(gateway), "10.0.0.1:3128"); - assert_eq!( - NetworkProxy::new(3128) - .with_scheme(ProxyScheme::Http) - .url(gateway), - "http://10.0.0.1:3128" - ); - assert_eq!( - NetworkProxy::new(8443) - .with_scheme(ProxyScheme::Https) - .url(gateway), - "https://10.0.0.1:8443" - ); - } - - #[test] - fn proxy_urls_name_a_port_of_host_loopback() { - let http = |port| NetworkProxy::new(port).with_scheme(ProxyScheme::Http); - let https = |port| NetworkProxy::new(port).with_scheme(ProxyScheme::Https); - for (url, proxy) in [ - ("http://127.0.0.1:3128", http(3128)), - ("http://localhost:3128/", http(3128)), - ("https://127.0.0.1:65535", https(65535)), - ("https://localhost:1", https(1)), - ] { - assert_eq!(NetworkProxy::parse_url(url).unwrap(), proxy, "{url}"); - } - for (url, reason) in [ - ( - "127.0.0.1:3128", - "expected http://HOST:PORT or https://HOST:PORT", - ), - ("ftp://127.0.0.1:3128", "the scheme must be http or https"), - ("HTTP://127.0.0.1:3128", "the scheme must be http or https"), - ( - "http://10.0.0.1:3128", - "the host must be 127.0.0.1 or localhost", - ), - ( - "http://[::1]:3128", - "the host must be 127.0.0.1 or localhost", - ), - ( - "http://user@localhost:3128", - "the host must be 127.0.0.1 or localhost", - ), - ("http://127.0.0.1", "the port must be explicit"), - ("http://127.0.0.1:", "the port must be between 1 and 65535"), - ("http://127.0.0.1:0", "the port must be between 1 and 65535"), - ( - "http://127.0.0.1:65536", - "the port must be between 1 and 65535", - ), - ( - "http://127.0.0.1:+3128", - "the port must be between 1 and 65535", - ), - ( - "http://127.0.0.1:3128/path", - "nothing but a / may follow the port", - ), - ( - "http://127.0.0.1:3128?query", - "nothing but a / may follow the port", - ), - ( - "http://127.0.0.1:3128//", - "nothing but a / may follow the port", - ), - ] { - let error = NetworkProxy::parse_url(url).unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Unsupported, "{url}"); - assert_eq!( - error.message(), - format!("network proxy URL {url}: {reason}") - ); - } - assert_eq!(ProxyScheme::from_name("http").unwrap(), ProxyScheme::Http); - assert_eq!(ProxyScheme::from_name("https").unwrap(), ProxyScheme::Https); - let error = ProxyScheme::from_name("ftp").unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Unsupported); - assert_eq!( - error.message(), - "network proxy scheme \"ftp\" must be http or https" - ); - } - - #[test] - #[cfg(feature = "cli")] - fn proxy_endpoints_name_the_guest_gateway() { - let gateway = Ipv4Addr::new(10, 0, 0, 1); - assert_eq!( - NetworkProxy::parse_endpoint("10.0.0.1:3128", gateway).unwrap(), - NetworkProxy::new(3128) - ); - assert_eq!( - NetworkProxy::parse_endpoint("192.168.7.5:8080", Ipv4Addr::new(192, 168, 7, 5)) - .unwrap(), - NetworkProxy::new(8080) - ); - for (endpoint, reason) in [ - ( - "198.51.100.1:3128", - "the address must be the guest's gateway, 10.0.0.1", - ), - ( - "10.0.0.2:3128", - "the address must be the guest's gateway, 10.0.0.1", - ), - ("10.0.0.1", "expected IPV4:TCP-PORT"), - ("proxy:3128", "expected IPV4:TCP-PORT"), - ("[::1]:3128", "expected IPV4:TCP-PORT"), - ("10.0.0.1:", "the port must be between 1 and 65535"), - ("10.0.0.1:0", "the port must be between 1 and 65535"), - ("10.0.0.1:65536", "the port must be between 1 and 65535"), - ] { - let error = NetworkProxy::parse_endpoint(endpoint, gateway).unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Unsupported, "{endpoint}"); - assert_eq!(error.message(), format!("{endpoint}: {reason}")); - } - } - - #[test] - #[cfg(feature = "cli")] - fn native_rules_parse_as_openvmm_parses_them() { - let parsed = |value: &str| EgressRule::parse_native(value).unwrap(); - for (value, rule) in [ - ("10.0.0.0/8", EgressRule::to("10.0.0.0/8")), - // Host bits are cleared, and an address is a network of one address. - ( - "10.0.0.5/8:tcp", - EgressRule::to("10.0.0.0/8").on_protocol(Protocol::Tcp), - ), - ( - "192.0.2.7:udp:53", - EgressRule::to("192.0.2.7/32").on_port(Protocol::Udp, 53), - ), - ( - "192.0.2.7:tcp:8000-8010", - EgressRule::to("192.0.2.7/32").on_port_range(Protocol::Tcp, 8000, 8010), - ), - // A range of one port is that port. - ( - "192.0.2.7:tcp:443-443", - EgressRule::to("192.0.2.7/32").on_port(Protocol::Tcp, 443), - ), - ( - "192.0.2.0/24:icmp", - EgressRule::to("192.0.2.0/24").on_protocol(Protocol::Icmp), - ), - ( - "::/0:icmp", - EgressRule::to("::/0").on_protocol(Protocol::Icmp), - ), - ( - "2001:db8::/32:tcp:443", - EgressRule::to("2001:db8::/32").on_port(Protocol::Tcp, 443), - ), - ("2001:db8::1", EgressRule::to("2001:db8::1/128")), - // Protocol names are not hexadecimal, so they never belong to an IPv6 address. - ( - "2001:db8::1:tcp:443", - EgressRule::to("2001:db8::1/128").on_port(Protocol::Tcp, 443), - ), - ] { - assert_eq!(parsed(value), rule, "{value}"); - } - let destination = "the destination must be an IPv4 or IPv6 address with an optional prefix"; - let ports = "ports must be between 1 and 65535"; - for (value, reason) in [ - ( - "10.0.0.0/8:tcp:80:90", - "expected CIDR[:PROTOCOL[:PORT[-PORT]]]", - ), - ("10.0.0.0/8:sctp", "the protocol must be tcp, udp, or icmp"), - ("10.0.0.0/8:TCP", "the protocol must be tcp, udp, or icmp"), - ("10.0.0.0/8:", "the protocol must be tcp, udp, or icmp"), - ("10.0.0.0/8:icmp:8", "icmp takes no port"), - ("10.0.0.0/8:tcp:0", ports), - ("10.0.0.0/8:tcp:65536", ports), - ("10.0.0.0/8:tcp:http", ports), - ("10.0.0.0/8:udp:0-80", ports), - ( - "10.0.0.0/8:tcp:90-80", - "the port range ends below its first port", - ), - ("10.0.0.0/8:tcp:-80", "a port range must be FIRST-LAST"), - ("10.0.0.0/8:tcp:80-", "a port range must be FIRST-LAST"), - ( - "10.0.0.0/8:tcp:80-90-100", - "a port range must be FIRST-LAST", - ), - ("10.0.0.0/33", destination), - ("10.0.0.0/255.0.0.0", destination), - ("example.com", destination), - ("example.com:tcp:443", destination), - ("fe80::1%eth0", destination), - ] { - let error = EgressRule::parse_native(value).unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Unsupported, "{value}"); - assert_eq!(error.message(), format!("{value}: {reason}")); - } - } - - #[test] - #[cfg(feature = "cli")] - fn lowered_rules_parse_back_to_the_same_rules() { - let egress = NetworkPolicy::new(Access::Deny) - .with_allow(EgressRule { - to: vec![Destination { - cidr: "10.0.0.0/30".to_owned(), - except: vec!["10.0.0.1".to_owned()], - }], - ports: vec![PortSelector { - protocol: Protocol::Any, - port: Some(80), - end_port: Some(81), - }], - }) - .with_allow(EgressRule::to("2001:db8::/32").on_protocol(Protocol::Icmp)) - .with_allow(EgressRule::to("198.51.100.0/24")) - .with_deny(EgressRule::to("2001:db8::123").on_port(Protocol::Udp, 53)); - let arguments = openvmm_arguments(Some(&egress), "10.0.0.2/24").unwrap(); - let mut parsed = NetworkPolicy::new(Access::Deny); - for (option, rules) in [ - ("--network-egress-allow", &mut parsed.allow), - ("--network-egress-deny", &mut parsed.deny), - ] { - for rule in self::rules(&arguments, option) { - rules.rules.push(EgressRule::parse_native(&rule).unwrap()); - } - } - assert_eq!( - openvmm_arguments(Some(&parsed), "10.0.0.2/24").unwrap(), - arguments - ); - } - - #[test] - #[cfg(feature = "cli")] - fn loopback_forwards_parse_as_openvmm_parses_them() { - for (value, protocol, host_port, guest_port) in [ - ("tcp:18080:8080", ForwardProtocol::Tcp, 18080, 8080), - ("udp:1:65535", ForwardProtocol::Udp, 1, 65535), - ] { - assert_eq!( - LoopbackForward::parse(value).unwrap(), - LoopbackForward { - protocol, - host_port, - guest_port - } - ); - } - let format = "expected tcp:HOST_PORT:GUEST_PORT or udp:HOST_PORT:GUEST_PORT"; - let ports = "ports must be between 1 and 65535"; - for (value, reason) in [ - ("8080", format), - ("tcp:8080", format), - ("tcp:8080:80:90", format), - ("sctp:8080:80", "the protocol must be tcp or udp"), - ("tcp:0:80", ports), - ("tcp:8080:0", ports), - ("udp:65536:80", ports), - ("udp:8080:http", ports), - ] { - let error = LoopbackForward::parse(value).unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Unsupported, "{value}"); - assert_eq!(error.message(), format!("{value}: {reason}")); - } - } -} +mod tests; diff --git a/nvx-rs/src/host/network/proxy.rs b/nvx-rs/src/host/network/proxy.rs new file mode 100644 index 000000000..a81cbeed2 --- /dev/null +++ b/nvx-rs/src/host/network/proxy.rs @@ -0,0 +1,140 @@ +//! The HTTP or HTTPS proxy on host IPv4 loopback that is the one way out of a proxy-only network. + +use std::net::Ipv4Addr; + +use super::rule::parse_port; +use crate::error::{Error, Result}; + +/// The scheme of a [`NetworkProxy`]'s URL. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ProxyScheme { + /// `http`. + Http, + /// `https`. + Https, +} + +impl ProxyScheme { + /// Returns the scheme's name: `http` or `https`. + pub const fn as_str(self) -> &'static str { + match self { + Self::Http => "http", + Self::Https => "https", + } + } + + /// Returns the scheme that `name` names: `http` or `https`, in lowercase. + /// + /// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) for any other name. + pub fn from_name(name: &str) -> Result { + match name { + "http" => Ok(Self::Http), + "https" => Ok(Self::Https), + _ => Err(Error::unsupported(format!( + "network proxy scheme {name:?} must be http or https" + ))), + } + } +} + +/// An HTTP or HTTPS proxy that listens on host IPv4 loopback: the one way out of a proxy-only +/// network, which its guest reaches at the same port of its gateway. +/// +/// OpenVMM relays the guest's TCP connections to `port` of its gateway to `127.0.0.1:port` on the +/// host, whatever they carry. The scheme only names the URL that [`url`](Self::url) gives: an +/// HTTPS proxy's certificate must therefore be valid for the gateway's address. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct NetworkProxy { + /// TCP port where the proxy listens on `127.0.0.1`, and where the guest reaches it at its + /// gateway; nonzero, and not 53, where OpenVMM answers DNS at the gateway, over TCP too, + /// before it relays a connection to a proxy. + pub port: u16, + /// Scheme of the proxy's URL, if any. + pub scheme: Option, +} + +impl NetworkProxy { + /// Creates a proxy at `port` of host loopback, whose URL has no scheme. + pub const fn new(port: u16) -> Self { + Self { port, scheme: None } + } + + /// Names the scheme of the proxy's URL. + #[must_use] + pub const fn with_scheme(mut self, scheme: ProxyScheme) -> Self { + self.scheme = Some(scheme); + self + } + + /// Parses the URL of a proxy on host loopback, as the host names it: `http` or `https`, + /// `127.0.0.1` or `localhost`, and an explicit port, such as `http://127.0.0.1:3128`, with at + /// most a `/` after the port. + /// + /// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) for another scheme, a + /// host other than IPv4 loopback, a missing or zero port, or a path, query, or fragment. + pub fn parse_url(url: &str) -> Result { + let refused = |reason: &str| { + Err(Error::unsupported(format!( + "network proxy URL {url}: {reason}" + ))) + }; + let Some((scheme, rest)) = url.split_once("://") else { + return refused("expected http://HOST:PORT or https://HOST:PORT"); + }; + let scheme = match ProxyScheme::from_name(scheme) { + Ok(scheme) => scheme, + Err(_) => return refused("the scheme must be http or https"), + }; + let authority = rest.strip_suffix('/').unwrap_or(rest); + if authority.contains(['/', '?', '#']) { + return refused("nothing but a / may follow the port"); + } + let Some((host, port)) = authority.rsplit_once(':') else { + return refused("the port must be explicit"); + }; + if !matches!(host, "127.0.0.1" | "localhost") { + return refused("the host must be 127.0.0.1 or localhost"); + } + let port = Some(port) + .filter(|port| port.bytes().all(|byte| byte.is_ascii_digit())) + .and_then(parse_port); + let Some(port) = port else { + return refused("the port must be between 1 and 65535"); + }; + Ok(Self::new(port).with_scheme(scheme)) + } + + /// Parses OpenVMM's `--network-proxy` value, `IPV4:TCP-PORT`, for a guest whose gateway is + /// `gateway`, which the address must be. The proxy's URL has no scheme. + /// + /// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) and a message that + /// starts with `endpoint`. + #[cfg(feature = "cli")] + pub(crate) fn parse_endpoint(endpoint: &str, gateway: Ipv4Addr) -> Result { + let refused = |reason: String| Err(Error::unsupported(format!("{endpoint}: {reason}"))); + let Some((address, port)) = endpoint + .rsplit_once(':') + .and_then(|(address, port)| Some((address.parse::().ok()?, port))) + else { + return refused("expected IPV4:TCP-PORT".to_owned()); + }; + let Some(port) = parse_port(port) else { + return refused("the port must be between 1 and 65535".to_owned()); + }; + if address != gateway { + return refused(format!( + "the address must be the guest's gateway, {gateway}" + )); + } + Ok(Self::new(port)) + } + + /// Returns the proxy's URL as the guest sees it, at `gateway`: `SCHEME://GATEWAY:PORT`, or + /// `GATEWAY:PORT` without a scheme. + pub fn url(&self, gateway: Ipv4Addr) -> String { + match self.scheme { + Some(scheme) => format!("{}://{gateway}:{}", scheme.as_str(), self.port), + None => format!("{gateway}:{}", self.port), + } + } +} diff --git a/nvx-rs/src/host/network/rule.rs b/nvx-rs/src/host/network/rule.rs new file mode 100644 index 000000000..bc0aeeba4 --- /dev/null +++ b/nvx-rs/src/host/network/rule.rs @@ -0,0 +1,305 @@ +//! The allow and deny decision, the egress rules of a network posture and their native form, and +//! the parsing of the rules of an action into the rules that the lowering takes. + +use super::lowering::{self, PortValue, Rule, RuleProtocol}; +#[cfg(feature = "cli")] +use crate::error::Error; +use crate::error::Result; +#[cfg(feature = "cli")] +use crate::net::cidr; + +/// Allow or deny decision of a network posture. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Access { + /// Permit the traffic. + Allow, + /// Block the traffic. + Deny, +} + +impl Access { + /// Returns OpenVMM's spelling of this decision. + pub const fn as_str(self) -> &'static str { + match self { + Self::Allow => "allow", + Self::Deny => "deny", + } + } +} + +/// Transport protocol of a [`PortSelector`]. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +pub enum Protocol { + /// TCP. + Tcp, + /// UDP. + Udp, + /// ICMP, or ICMPv6 for IPv6 networks. + Icmp, + /// Any protocol, or TCP and UDP with a port. + #[default] + Any, +} + +/// A destination network of an [`EgressRule`], without its exceptions. +/// +/// Networks are read as egress policy files read their CIDRs ([`policy`]): host bits are cleared, +/// IPv4 netmasks such as `/255.0.0.0` are accepted, and IPv6 scopes are refused. +/// +/// [`policy`]: crate::host::network::policy +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Destination { + /// Destination address or CIDR, such as `"10.0.0.0/8"` or `"192.0.2.1"`. + pub cidr: String, + /// Sub-networks of `cidr` that the rule does not match, each within `cidr`. + pub except: Vec, +} + +impl Destination { + /// Creates a destination for `cidr` without exceptions. + pub fn new(cidr: impl Into) -> Self { + Self { + cidr: cidr.into(), + except: Vec::new(), + } + } +} + +/// A destination protocol and port range of an [`EgressRule`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PortSelector { + /// Transport protocol. + pub protocol: Protocol, + /// First destination port. `None` matches every port. + pub port: Option, + /// Last destination port of a range that starts at `port`. + pub end_port: Option, +} + +/// Traffic to some destinations and ports. +/// +/// An empty `to` matches every destination of both families, and an empty `ports` matches every +/// protocol and port. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct EgressRule { + /// Destination networks. + pub to: Vec, + /// Destination protocols and ports. + pub ports: Vec, +} + +impl EgressRule { + /// Creates a rule that matches every protocol and port of `cidr`, such as `"10.0.0.0/8"` or + /// `"192.0.2.1"`. + pub fn to(cidr: impl Into) -> Self { + Self { + to: vec![Destination::new(cidr)], + ports: Vec::new(), + } + } + + /// Restricts the rule to one port of `protocol`. + #[must_use] + pub fn on_port(mut self, protocol: Protocol, port: u16) -> Self { + self.ports.push(PortSelector { + protocol, + port: Some(port), + end_port: None, + }); + self + } + + /// Restricts the rule to the ports of `protocol` from `port` through `end_port`, both + /// included. + #[must_use] + pub fn on_port_range(mut self, protocol: Protocol, port: u16, end_port: u16) -> Self { + self.ports.push(PortSelector { + protocol, + port: Some(port), + end_port: Some(end_port), + }); + self + } + + /// Restricts the rule to `protocol` on every port. + #[must_use] + pub fn on_protocol(mut self, protocol: Protocol) -> Self { + self.ports.push(PortSelector { + protocol, + port: None, + end_port: None, + }); + self + } + + /// Parses one of OpenVMM's native rules, the value of an `--network-egress-allow` or + /// `--network-egress-deny` option, as OpenVMM parses it: `CIDR`, `CIDR:PROTOCOL`, + /// `CIDR:PROTOCOL:PORT`, or `CIDR:PROTOCOL:FIRST-LAST`, where `CIDR` is an IPv4 or IPv6 + /// address with an optional prefix length, whose host bits are cleared, `PROTOCOL` is `tcp`, + /// `udp`, or `icmp`, and only `tcp` and `udp` take ports. The rule's destination is the + /// canonical network. + /// + /// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) and a message that + /// starts with `value`. + #[cfg(feature = "cli")] + pub(crate) fn parse_native(value: &str) -> Result { + let refused = |reason: &str| Err(Error::unsupported(format!("{value}: {reason}"))); + let (destination, selector) = split_native_rule(value); + let (protocol, ports) = + match selector.map(|selector| selector.split(':').collect::>()) { + None => (None, None), + Some(fields) => match fields[..] { + [protocol] => (Some(protocol), None), + [protocol, ports] => (Some(protocol), Some(ports)), + _ => return refused("expected CIDR[:PROTOCOL[:PORT[-PORT]]]"), + }, + }; + let protocol = match protocol { + None => None, + Some("tcp") => Some(Protocol::Tcp), + Some("udp") => Some(Protocol::Udp), + Some("icmp") => Some(Protocol::Icmp), + Some(_) => return refused("the protocol must be tcp, udp, or icmp"), + }; + let ports = match ports { + None => None, + Some(_) if protocol == Some(Protocol::Icmp) => return refused("icmp takes no port"), + Some(ports) => match ports.split_once('-') { + None => match parse_port(ports) { + Some(port) => Some((port, port)), + None => return refused("ports must be between 1 and 65535"), + }, + Some((first, last)) + if first.is_empty() || last.is_empty() || last.contains('-') => + { + return refused("a port range must be FIRST-LAST"); + } + Some((first, last)) => match (parse_port(first), parse_port(last)) { + (Some(first), Some(last)) if first <= last => Some((first, last)), + (Some(_), Some(_)) => { + return refused("the port range ends below its first port"); + } + _ => return refused("ports must be between 1 and 65535"), + }, + }, + }; + let Ok(destination) = cidr::Cidr::parse_masked(destination) else { + return refused( + "the destination must be an IPv4 or IPv6 address with an optional prefix", + ); + }; + let rule = Self::to(destination.to_string()); + // A range of one port is that port. + Ok(match (protocol, ports) { + (None, _) => rule, + (Some(protocol), None) => rule.on_protocol(protocol), + (Some(protocol), Some((first, last))) if first == last => rule.on_port(protocol, first), + (Some(protocol), Some((first, last))) => rule.on_port_range(protocol, first, last), + }) + } +} + +/// Splits a native rule into its destination and its optional protocol and ports, where OpenVMM +/// splits it: an IPv4 address holds no `:`, so its first `:` starts the selector; a network with a +/// prefix ends at the first `:` after its `/`; and an IPv6 address without a prefix ends at the +/// last `:` that leaves an IPv6 address before it, since protocol names are not hexadecimal. +#[cfg(feature = "cli")] +fn split_native_rule(value: &str) -> (&str, Option<&str>) { + let split_at = |index: usize| (&value[..index], Some(&value[index + 1..])); + if let Some(slash) = value.find('/') { + return match value[slash..].find(':') { + Some(colon) => split_at(slash + colon), + None => (value, None), + }; + } + match value.find(':') { + None => (value, None), + Some(colon) if value[..colon].contains('.') => split_at(colon), + Some(_) if value.parse::().is_ok() => (value, None), + Some(_) => value + .rmatch_indices(':') + .map(|(index, _)| index) + .find(|&index| value[..index].parse::().is_ok()) + .map_or((value, None), split_at), + } +} + +/// Returns the port that `text` names, read as OpenVMM reads its ports, if it is nonzero. +pub(super) fn parse_port(text: &str) -> Option { + text.parse().ok().filter(|&port| port != 0) +} + +/// The egress rules of one action, and the name by which messages refer to them, such as +/// `egress.allow`. Messages name a rule by its index and its fields by their MXC names, as in +/// `egress.allow[0].to[1].cidr`, and the action by the name, as in `egress.allow emits at most 256 +/// native rules`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EgressRules { + /// The name of the rules in messages. + pub name: String, + /// The rules. + pub rules: Vec, +} + +impl EgressRules { + /// Creates an empty list of rules that messages call `name`. + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + rules: Vec::new(), + } + } +} + +/// Parses the rules of one action, each into the rules that the lowering takes, as an egress +/// policy file's MXC rules are parsed: an empty `to` or `ports` is a rule without that field. +pub(super) fn parse_rules(rules: &EgressRules) -> Result>> { + rules + .rules + .iter() + .enumerate() + .map(|(index, rule)| parse_rule(rule, &format!("{}[{index}]", rules.name))) + .collect() +} + +/// Parses one rule, which messages call `description`. +fn parse_rule(rule: &EgressRule, description: &str) -> Result> { + let mut destinations = None; + if !rule.to.is_empty() { + let mut parsed = Vec::with_capacity(rule.to.len()); + for (index, peer) in rule.to.iter().enumerate() { + let description = format!("{description}.to[{index}]"); + let network = |value: &str, field: &str| lowering::network(value, field); + let parent = network(&peer.cidr, &format!("{description}.cidr"))?; + parsed.push(lowering::destination( + parent, + peer.except.iter().enumerate().map(|(exception, value)| { + network(value, &format!("{description}.except[{exception}]")) + }), + &description, + )?); + } + destinations = Some(lowering::group_destinations(&parsed)); + } + let mut selectors = None; + if !rule.ports.is_empty() { + let mut parsed = Vec::new(); + for (index, port) in rule.ports.iter().enumerate() { + let protocol = match port.protocol { + Protocol::Tcp => RuleProtocol::Tcp, + Protocol::Udp => RuleProtocol::Udp, + Protocol::Icmp => RuleProtocol::Icmp, + Protocol::Any => RuleProtocol::Any, + }; + let value = |port: u16| PortValue::Integer(i128::from(port)); + parsed.extend(lowering::port_selectors( + protocol, + port.port.map(value), + port.end_port.map(value), + &format!("{description}.ports[{index}]"), + )?); + } + selectors = Some(parsed); + } + Ok(lowering::combine(destinations, selectors)) +} diff --git a/nvx-rs/src/host/network/tests.rs b/nvx-rs/src/host/network/tests.rs new file mode 100644 index 000000000..9e9a25163 --- /dev/null +++ b/nvx-rs/src/host/network/tests.rs @@ -0,0 +1,988 @@ +//! Tests of the OpenVMM options of network postures, and of the parsers of their rules, +//! forwards, and proxies. + +use std::net::Ipv4Addr; + +use super::*; +use crate::ErrorKind; + +fn rules(arguments: &[String], option: &str) -> Vec { + arguments + .windows(2) + .filter(|pair| pair[0] == option) + .map(|pair| pair[1].clone()) + .collect() +} + +#[test] +fn default_postures_map_to_portable_profile_options() { + assert!(openvmm_arguments(None, "10.0.0.2/24").unwrap().is_empty()); + assert!( + openvmm_arguments(Some(&NetworkPolicy::new(Access::Deny)), "10.0.0.2/24") + .unwrap() + .is_empty() + ); + assert_eq!( + openvmm_arguments(Some(&NetworkPolicy::new(Access::Allow)), "10.0.0.2/24").unwrap(), + [ + "--net", + "10.0.0.2/24", + "--network-profile", + "portable", + "--network-egress", + "allow", + "--network-ingress", + "deny", + "--host-loopback", + "deny", + ] + ); + // Deny rules under a deny default change nothing, so no device is attached. + let denied = NetworkPolicy::new(Access::Deny).with_deny(EgressRule::to("1.2.3.4")); + assert!( + openvmm_arguments(Some(&denied), "10.0.0.2/24") + .unwrap() + .is_empty() + ); + // Ingress needs the device even when egress is denied, and host loopback stays denied. + for egress in [Access::Deny, Access::Allow] { + let mut ingress = NetworkPolicy::new(egress); + ingress.ingress = Access::Allow; + assert_eq!( + openvmm_arguments(Some(&ingress), "10.0.0.2/24").unwrap(), + [ + "--net", + "10.0.0.2/24", + "--network-profile", + "portable", + "--network-egress", + egress.as_str(), + "--network-ingress", + "allow", + "--host-loopback", + "deny", + ] + ); + } + // Host-loopback access needs the device even when egress and ingress are denied. + let mut host_loopback = NetworkPolicy::new(Access::Deny); + host_loopback.host_loopback = HostLoopback::Allow; + assert_eq!( + openvmm_arguments(Some(&host_loopback), "10.0.0.2/24").unwrap(), + [ + "--net", + "10.0.0.2/24", + "--network-profile", + "portable", + "--network-egress", + "deny", + "--network-ingress", + "deny", + "--host-loopback", + "allow", + ] + ); + // So does outbound-only access, which OpenVMM's default provides. + host_loopback.host_loopback = HostLoopback::Outbound; + assert_eq!( + openvmm_arguments(Some(&host_loopback), "10.0.0.2/24").unwrap(), + [ + "--net", + "10.0.0.2/24", + "--network-profile", + "portable", + "--network-egress", + "deny", + "--network-ingress", + "deny", + ] + ); +} + +#[test] +fn host_loopback_access_needs_room_for_host_peers() { + let mut policy = NetworkPolicy::new(Access::Deny); + policy.host_loopback = HostLoopback::Allow; + // A /30 network holds only the gateway and the guest. + let error = openvmm_arguments(Some(&policy), "10.0.0.2/30").unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unsupported); + assert!(error.message().contains("/29 or shorter"), "{error}"); + for guest_network in ["10.0.0.2/29", "10.0.0.2/24"] { + let arguments = openvmm_arguments(Some(&policy), guest_network).unwrap(); + assert!( + has_pair(&arguments, "--host-loopback", "allow"), + "{guest_network}" + ); + } + // Host-loopback access that is denied, or open only to the guest, needs no host peers. + let mut denied = NetworkPolicy::new(Access::Allow); + denied.host_loopback = HostLoopback::Deny; + let arguments = openvmm_arguments(Some(&denied), "10.0.0.2/30").unwrap(); + assert!(has_pair(&arguments, "--host-loopback", "deny")); + denied.host_loopback = HostLoopback::Outbound; + let arguments = openvmm_arguments(Some(&denied), "10.0.0.2/30").unwrap(); + assert!( + !arguments + .iter() + .any(|argument| argument == "--host-loopback") + ); +} + +#[test] +fn host_loopback_forwards_follow_openvmm_rules() { + let forward = |protocol, host_port, guest_port| LoopbackForward { + protocol, + host_port, + guest_port, + }; + let forwarded = |forwards: Vec| { + let mut policy = NetworkPolicy::new(Access::Allow); + policy.host_loopback = HostLoopback::Allow; + policy.host_loopback_forwards = forwards; + policy + }; + let rejected = |policy: &NetworkPolicy, expected: &str| { + let error = openvmm_arguments(Some(policy), "10.0.0.2/24").unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unsupported); + assert!(error.message().contains(expected), "{error}"); + }; + + let valid = forwarded(vec![ + forward(ForwardProtocol::Tcp, 18080, 8080), + forward(ForwardProtocol::Udp, 18080, 8081), + // One guest port may receive a forward of each protocol. + forward(ForwardProtocol::Udp, 18081, 8080), + ]); + let arguments = openvmm_arguments(Some(&valid), "10.0.0.2/24").unwrap(); + assert_eq!( + rules(&arguments, "--host-loopback-forward"), + ["tcp:18080:8080", "udp:18080:8081", "udp:18081:8080"] + ); + assert!(has_pair(&arguments, "--host-loopback", "allow")); + + let mut denied = valid.clone(); + denied.host_loopback = HostLoopback::Deny; + rejected(&denied, "need host-loopback access allowed"); + // Forwards publish host ports to the guest, which outbound-only access does not. + let mut outbound = valid.clone(); + outbound.host_loopback = HostLoopback::Outbound; + rejected(&outbound, "need host-loopback access allowed"); + let mut closed = valid.clone(); + closed.egress = Access::Deny; + rejected( + &closed, + "host-loopback UDP forwards need egress allowed by default for their replies", + ); + // OpenVMM lets the guest's replies on a forwarded TCP connection through whatever the + // egress policy, so TCP forwards alone need neither egress allowed by default nor a guest + // network that no deny rule covers. + let mut tcp = forwarded(vec![forward(ForwardProtocol::Tcp, 18080, 8080)]); + tcp.egress = Access::Deny; + let tcp = tcp.with_deny(EgressRule::to("10.0.0.0/24")); + let arguments = openvmm_arguments(Some(&tcp), "10.0.0.2/24").unwrap(); + assert!(has_pair(&arguments, "--network-egress", "deny")); + assert_eq!(rules(&arguments, "--network-egress-deny"), ["10.0.0.0/24"]); + assert_eq!( + rules(&arguments, "--host-loopback-forward"), + ["tcp:18080:8080"] + ); + // A UDP forward beside them needs both. + let mut udp = tcp.clone(); + udp.host_loopback_forwards + .push(forward(ForwardProtocol::Udp, 18081, 8081)); + rejected( + &udp, + "host-loopback UDP forwards need egress allowed by default", + ); + udp.egress = Access::Allow; + rejected( + &udp, + "egress.deny[0] cannot cover the guest network, which carries host-loopback UDP \ + forward replies", + ); + rejected( + &forwarded(vec![ + forward(ForwardProtocol::Tcp, 18080, 8080), + forward(ForwardProtocol::Tcp, 18080, 8081), + ]), + "bind tcp host port 18080 more than once", + ); + // OpenVMM could not bind a second forward to one guest port. + rejected( + &forwarded(vec![ + forward(ForwardProtocol::Udp, 18080, 8080), + forward(ForwardProtocol::Udp, 18081, 8080), + ]), + "reach udp guest port 8080 more than once", + ); + rejected( + &forwarded(vec![forward(ForwardProtocol::Udp, 0, 8080)]), + "between 1 and 65535", + ); + rejected( + &forwarded( + (1..=65) + .map(|port| forward(ForwardProtocol::Tcp, port, port)) + .collect(), + ), + "at most 64", + ); + + // The guest's replies to forwarded traffic leave toward the guest network. + for rule in [ + EgressRule::default(), + EgressRule::to("10.0.0.0/8"), + EgressRule::to("10.0.0.0/24"), + EgressRule::to("10.0.0.77").on_port(Protocol::Tcp, 443), + // Networks are read as egress policy files read them, host bits and netmasks included. + EgressRule::to("10.0.0.77/24"), + EgressRule::to("10.0.0.0/255.255.255.0"), + ] { + rejected(&valid.clone().with_deny(rule), "forward replies"); + } + for rule in [ + EgressRule::to("10.0.1.0/24"), + EgressRule::to("fd00::/8"), + EgressRule { + to: vec![Destination { + cidr: "0.0.0.0/0".to_owned(), + except: vec!["10.0.0.0/24".to_owned()], + }], + ports: Vec::new(), + }, + ] { + openvmm_arguments(Some(&valid.clone().with_deny(rule)), "10.0.0.2/24").unwrap(); + } +} + +#[test] +fn rules_expand_exactly() { + let egress = NetworkPolicy::new(Access::Deny) + .with_allow(EgressRule { + to: vec![Destination { + cidr: "10.0.0.0/30".to_owned(), + except: vec!["10.0.0.1".to_owned()], + }], + ports: vec![PortSelector { + protocol: Protocol::Tcp, + port: Some(80), + end_port: Some(81), + }], + }) + .with_allow(EgressRule::to("192.0.2.7").on_port(Protocol::Any, 53)) + .with_allow(EgressRule::to("198.51.100.0/24")); + let arguments = openvmm_arguments(Some(&egress), "10.0.0.2/24").unwrap(); + assert_eq!( + rules(&arguments, "--network-egress-allow"), + [ + "10.0.0.0/32:tcp:80-81", + "10.0.0.2/31:tcp:80-81", + "192.0.2.7/32:tcp:53", + "192.0.2.7/32:udp:53", + "198.51.100.0/24", + ] + ); + assert!(has_pair(&arguments, "--network-egress", "deny")); + + let egress = NetworkPolicy::new(Access::Allow).with_deny(EgressRule { + to: Vec::new(), + ports: vec![PortSelector { + protocol: Protocol::Udp, + port: Some(53), + end_port: None, + }], + }); + let arguments = openvmm_arguments(Some(&egress), "10.0.0.2/24").unwrap(); + // A rule without destinations matches both address families. + assert_eq!( + rules(&arguments, "--network-egress-deny"), + ["0.0.0.0/0:udp:53", "::/0:udp:53"] + ); +} + +#[test] +fn ipv6_rules_lower_exactly() { + let egress = NetworkPolicy::new(Access::Deny) + .with_allow(EgressRule::to("2001:db8:1::/64").on_port(Protocol::Tcp, 443)) + .with_allow(EgressRule { + to: vec![Destination { + cidr: "2001:db8:2::/126".to_owned(), + except: vec!["2001:db8:2::1".to_owned()], + }], + ports: vec![PortSelector { + protocol: Protocol::Any, + port: Some(53), + end_port: None, + }], + }) + .with_allow(EgressRule::to("192.0.2.0/24").on_protocol(Protocol::Icmp)) + .with_allow(EgressRule::to("::/0").on_protocol(Protocol::Icmp)) + .with_deny(EgressRule::to("2001:db8:1::123")); + let arguments = openvmm_arguments(Some(&egress), "10.0.0.2/24").unwrap(); + // Rules sort by family and then by address, so `::/0` leads the IPv6 rules. + assert_eq!( + rules(&arguments, "--network-egress-allow"), + [ + "192.0.2.0/24:icmp", + "::/0:icmp", + "2001:db8:1::/64:tcp:443", + "2001:db8:2::/128:tcp:53", + "2001:db8:2::/128:udp:53", + "2001:db8:2::2/127:tcp:53", + "2001:db8:2::2/127:udp:53", + ] + ); + assert_eq!( + rules(&arguments, "--network-egress-deny"), + ["2001:db8:1::123/128"] + ); +} + +#[test] +fn rules_lower_like_egress_policy_files() { + let lowered = |rules: Vec| { + let mut egress = NetworkPolicy::new(Access::Deny); + egress.allow.rules = rules; + let arguments = openvmm_arguments(Some(&egress), "10.0.0.2/24").unwrap(); + self::rules(&arguments, "--network-egress-allow") + }; + let tcp = |cidr: &str, port, end_port| { + EgressRule::to(cidr).on_port_range(Protocol::Tcp, port, end_port) + }; + // Destinations merge into the fewest networks, and port ranges into one range. + assert_eq!( + lowered(vec![EgressRule { + to: (0..300) + .map(|index| Destination::new(format!("10.0.{}.0/24", index % 256))) + .collect(), + ports: vec![PortSelector { + protocol: Protocol::Tcp, + port: Some(1), + end_port: Some(2), + }], + }]), + ["10.0.0.0/16:tcp:1-2"] + ); + assert_eq!( + lowered(vec![ + tcp("192.0.2.7", 8000, 8004), + tcp("192.0.2.7", 8003, 8010), + tcp("192.0.2.6", 8000, 8010), + ]), + ["192.0.2.6/31:tcp:8000-8010"] + ); + // Rules for every port of a protocol, or every protocol, subsume narrower ones. + assert_eq!( + lowered(vec![ + EgressRule::to("192.0.2.7").on_port(Protocol::Tcp, 443), + EgressRule::to("192.0.2.0/24").on_protocol(Protocol::Tcp), + EgressRule::to("198.51.100.0/24").on_port(Protocol::Udp, 53), + EgressRule::to("198.51.100.0/23"), + ]), + ["192.0.2.0/24:tcp", "198.51.100.0/23"] + ); + // Host bits are cleared, as an egress policy file's are. + assert_eq!( + lowered(vec![EgressRule::to("10.0.0.5/255.255.255.0")]), + ["10.0.0.0/24"] + ); +} + +#[test] +fn invalid_rules_are_rejected_with_the_policy_compilers_messages() { + let port = |protocol, port, end_port| EgressRule { + to: vec![Destination::new("10.0.0.0/8")], + ports: vec![PortSelector { + protocol, + port, + end_port, + }], + }; + let excluding = |cidr: &str, except: &str| EgressRule { + to: vec![ + Destination::new("192.0.2.0/24"), + Destination { + cidr: cidr.to_owned(), + except: vec![except.to_owned()], + }, + ], + ports: Vec::new(), + }; + for (rule, message) in [ + ( + port(Protocol::Icmp, Some(8), None), + "egress.allow[0].ports[0].port is not supported with icmp", + ), + // Structural validation rejects these first, unless the backend is called directly. + ( + port(Protocol::Tcp, Some(90), Some(80)), + "egress.allow[0].ports[0].endPort 80 cannot be below port 90", + ), + ( + port(Protocol::Udp, None, Some(80)), + "egress.allow[0].ports[0].endPort requires port", + ), + ( + port(Protocol::Tcp, Some(0), None), + "egress.allow[0].ports[0].port must be between 1 and 65535", + ), + ( + port(Protocol::Any, Some(80), Some(0)), + "egress.allow[0].ports[0].endPort must be between 1 and 65535", + ), + ( + EgressRule::to("example.com"), + "egress.allow[0].to[0].cidr is not a valid IPv4 or IPv6 CIDR: \"example.com\"", + ), + ( + EgressRule::to("fe80::1%eth0/64"), + "egress.allow[0].to[0].cidr must not name an IPv6 scope: \"fe80::1%eth0/64\"", + ), + ( + excluding("10.0.0.0/8", "11.0.0.0/16"), + "egress.allow[0].to[1].except[0] must be contained in 10.0.0.0/8", + ), + ( + excluding("10.0.0.7/8", "10.1.0.0/+16"), + "egress.allow[0].to[1].except[0] is not a valid IPv4 or IPv6 CIDR: \"10.1.0.0/+16\"", + ), + // 129 separate ranges of both TCP and UDP need 258 OpenVMM rules. + ( + EgressRule { + to: vec![Destination::new("10.0.0.0/8")], + ports: (0..129) + .map(|index| PortSelector { + protocol: Protocol::Any, + port: Some(3 * index + 1), + end_port: Some(3 * index + 2), + }) + .collect(), + }, + "egress.allow emits at most 256 native rules", + ), + ] { + let egress = NetworkPolicy::new(Access::Deny).with_allow(rule.clone()); + let error = openvmm_arguments(Some(&egress), "10.0.0.2/24").unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unsupported, "{rule:?}"); + assert_eq!(error.message(), message, "{rule:?}"); + } + // The allow rules are checked and lowered before the deny rules. + let egress = NetworkPolicy::new(Access::Allow) + .with_allow(EgressRule::to("10.0.0.0/33")) + .with_deny(port(Protocol::Icmp, Some(8), None)); + let error = openvmm_arguments(Some(&egress), "10.0.0.2/24").unwrap_err(); + assert_eq!( + error.message(), + "egress.allow[0].to[0].cidr is not a valid IPv4 or IPv6 CIDR: \"10.0.0.0/33\"" + ); +} + +#[test] +fn exceptions_that_split_a_network_too_finely_are_rejected() { + // Without a limit, these exceptions would split the address space into 512 * 23 networks. + let peer = Destination { + cidr: "0.0.0.0/0".to_owned(), + except: (0..512u32) + .map(|index| std::net::Ipv4Addr::from(index << 23).to_string()) + .collect(), + }; + let rule = EgressRule { + to: vec![peer], + ports: Vec::new(), + }; + let egress = NetworkPolicy::new(Access::Deny).with_allow(rule); + let error = openvmm_arguments(Some(&egress), "10.0.0.2/24").unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unsupported); + assert_eq!( + error.message(), + "egress.allow emits at most 256 native rules" + ); +} + +#[test] +fn exclusions_remain_local_to_their_rules() { + let excluding = |cidr: &str, excluded: &str| EgressRule { + to: vec![Destination { + cidr: cidr.to_owned(), + except: vec![excluded.to_owned()], + }], + ports: Vec::new(), + }; + let egress = NetworkPolicy::new(Access::Deny) + .with_allow(excluding("192.0.2.0/24", "192.0.2.128/25")) + .with_allow(EgressRule::to("192.0.2.200")) + .with_deny(excluding("198.51.100.0/24", "198.51.100.128/25")); + let arguments = openvmm_arguments(Some(&egress), "10.0.0.2/24").unwrap(); + + assert_eq!( + rules(&arguments, "--network-egress-allow"), + ["192.0.2.0/25", "192.0.2.200/32"] + ); + assert_eq!( + rules(&arguments, "--network-egress-deny"), + ["198.51.100.0/25"] + ); +} + +fn has_pair(arguments: &[String], name: &str, value: &str) -> bool { + arguments + .windows(2) + .any(|pair| pair[0] == name && pair[1] == value) +} + +#[test] +fn protocols_without_ports_expand_to_protocol_rules() { + let egress = NetworkPolicy::new(Access::Deny) + .with_allow(EgressRule::to("192.0.2.7").on_protocol(Protocol::Tcp)) + .with_allow(EgressRule::to("192.0.2.7").on_protocol(Protocol::Udp)) + .with_allow(EgressRule::to("192.0.2.7").on_protocol(Protocol::Icmp)) + .with_allow(EgressRule::to("198.51.100.0/24").on_protocol(Protocol::Any)) + .with_allow(EgressRule::to("203.0.113.9").on_port(Protocol::Any, 443)) + .with_deny( + EgressRule::to("192.0.2.7") + .on_protocol(Protocol::Icmp) + .on_port(Protocol::Tcp, 22), + ); + let arguments = openvmm_arguments(Some(&egress), "10.0.0.2/24").unwrap(); + assert_eq!( + rules(&arguments, "--network-egress-allow"), + [ + "192.0.2.7/32:icmp", + "192.0.2.7/32:tcp", + "192.0.2.7/32:udp", + "198.51.100.0/24", + "203.0.113.9/32:tcp:443", + "203.0.113.9/32:udp:443", + ] + ); + assert_eq!( + rules(&arguments, "--network-egress-deny"), + ["192.0.2.7/32:icmp", "192.0.2.7/32:tcp:22"] + ); +} + +#[test] +fn port_ranges_expand_to_one_rule_per_network_and_protocol() { + let egress = NetworkPolicy::new(Access::Deny) + .with_allow(EgressRule::to("192.0.2.7").on_port_range(Protocol::Tcp, 8000, 8010)) + .with_allow(EgressRule::to("192.0.2.7").on_port_range(Protocol::Udp, 5000, 5010)) + .with_allow(EgressRule::to("198.51.100.0/24").on_port_range(Protocol::Any, 1, 65535)) + // A range of one port is that port's rule. + .with_allow(EgressRule::to("203.0.113.9").on_port_range(Protocol::Tcp, 443, 443)) + .with_deny(EgressRule::to("192.0.2.7").on_port(Protocol::Tcp, 8005)); + let arguments = openvmm_arguments(Some(&egress), "10.0.0.2/24").unwrap(); + assert_eq!( + rules(&arguments, "--network-egress-allow"), + [ + "192.0.2.7/32:tcp:8000-8010", + "192.0.2.7/32:udp:5000-5010", + "198.51.100.0/24:tcp:1-65535", + "198.51.100.0/24:udp:1-65535", + "203.0.113.9/32:tcp:443", + ] + ); + assert_eq!( + rules(&arguments, "--network-egress-deny"), + ["192.0.2.7/32:tcp:8005"] + ); +} + +/// The proxy-only policy of a proxy at port 3128 of host loopback. +fn proxy_only() -> NetworkPolicy { + NetworkPolicy::proxy_only(NetworkProxy::new(3128)) +} + +#[test] +fn proxy_only_policies_reach_the_proxy_at_the_gateway() { + for (guest_network, endpoint) in [ + ("10.0.0.2/24", "10.0.0.1:3128"), + ("172.16.40.9/16", "172.16.0.1:3128"), + // A /30 network holds the gateway and the guest, which is all that a proxy needs. + ("192.168.7.6/30", "192.168.7.5:3128"), + ] { + assert_eq!( + openvmm_arguments(Some(&proxy_only()), guest_network).unwrap(), + [ + "--net", + guest_network, + "--network-profile", + "portable", + "--network-egress", + "deny", + "--network-ingress", + "deny", + "--host-loopback", + "deny", + "--network-proxy", + endpoint, + ] + ); + } + // The scheme names only the URL. + let https = NetworkPolicy::proxy_only(NetworkProxy::new(3128).with_scheme(ProxyScheme::Https)); + assert_eq!( + openvmm_arguments(Some(&https), "10.0.0.2/24").unwrap(), + openvmm_arguments(Some(&proxy_only()), "10.0.0.2/24").unwrap() + ); + let mut built = NetworkPolicy::new(Access::Deny); + built.proxy = Some(NetworkProxy::new(3128)); + assert_eq!(built, proxy_only()); +} + +#[test] +fn proxies_refuse_every_other_way_out() { + let refused = |edit: &dyn Fn(&mut NetworkPolicy), message: &str| { + let mut policy = proxy_only(); + edit(&mut policy); + let error = openvmm_arguments(Some(&policy), "10.0.0.2/24").unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unsupported); + assert_eq!(error.message(), message); + }; + let only_way_out = "since the proxy is the guest's only way out"; + refused( + &|policy| policy.egress = Access::Allow, + &format!("a network proxy needs egress denied by default, {only_way_out}"), + ); + refused( + &|policy| policy.allow.rules.push(EgressRule::to("192.0.2.0/24")), + &format!("a network proxy takes no egress.allow rules, {only_way_out}"), + ); + refused( + &|policy| policy.deny.rules.push(EgressRule::to("192.0.2.0/24")), + &format!("a network proxy takes no egress.deny rules, {only_way_out}"), + ); + refused( + &|policy| policy.ingress = Access::Allow, + "a network proxy needs ingress denied", + ); + for host_loopback in [HostLoopback::Allow, HostLoopback::Outbound] { + refused( + &|policy| policy.host_loopback = host_loopback, + "a network proxy needs host-loopback access denied", + ); + } + refused( + &|policy| { + policy.host_loopback_forwards.push(LoopbackForward { + protocol: ForwardProtocol::Tcp, + host_port: 18080, + guest_port: 8080, + }); + }, + "a network proxy takes no host-loopback forwards", + ); + refused( + &|policy| policy.proxy = Some(NetworkProxy::new(0)), + "a network proxy needs a port between 1 and 65535", + ); + // OpenVMM's DNS service at the gateway takes the guest's TCP connections to port 53 + // before the proxy's relay does, whatever the proxy's scheme. + refused( + &|policy| policy.proxy = Some(NetworkProxy::new(53).with_scheme(ProxyScheme::Http)), + "a network proxy needs a port other than 53, where OpenVMM answers DNS at the guest's \ + gateway", + ); + // The proxy's checks come first: a proxy beside allowed forwards is refused as a proxy. + refused( + &|policy| { + policy.egress = Access::Allow; + policy.host_loopback = HostLoopback::Allow; + policy.host_loopback_forwards.push(LoopbackForward { + protocol: ForwardProtocol::Tcp, + host_port: 18080, + guest_port: 8080, + }); + }, + &format!("a network proxy needs egress denied by default, {only_way_out}"), + ); + // The guest reaches the proxy at its gateway, which an invalid guest network lacks. + for guest_network in [ + "10.0.0.1/24", + "10.0.0.0/24", + "10.0.0.255/24", + "10.0.0.2/31", + "10.0.0.2", + "fd00::2/64", + ] { + let error = openvmm_arguments(Some(&proxy_only()), guest_network).unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unsupported); + assert_eq!( + error.message(), + format!("guest network {guest_network:?} is not valid") + ); + } +} + +#[test] +fn proxy_urls_name_the_gateway_as_the_guest_sees_it() { + let gateway = Ipv4Addr::new(10, 0, 0, 1); + assert_eq!(NetworkProxy::new(3128).url(gateway), "10.0.0.1:3128"); + assert_eq!( + NetworkProxy::new(3128) + .with_scheme(ProxyScheme::Http) + .url(gateway), + "http://10.0.0.1:3128" + ); + assert_eq!( + NetworkProxy::new(8443) + .with_scheme(ProxyScheme::Https) + .url(gateway), + "https://10.0.0.1:8443" + ); +} + +#[test] +fn proxy_urls_name_a_port_of_host_loopback() { + let http = |port| NetworkProxy::new(port).with_scheme(ProxyScheme::Http); + let https = |port| NetworkProxy::new(port).with_scheme(ProxyScheme::Https); + for (url, proxy) in [ + ("http://127.0.0.1:3128", http(3128)), + ("http://localhost:3128/", http(3128)), + ("https://127.0.0.1:65535", https(65535)), + ("https://localhost:1", https(1)), + ] { + assert_eq!(NetworkProxy::parse_url(url).unwrap(), proxy, "{url}"); + } + for (url, reason) in [ + ( + "127.0.0.1:3128", + "expected http://HOST:PORT or https://HOST:PORT", + ), + ("ftp://127.0.0.1:3128", "the scheme must be http or https"), + ("HTTP://127.0.0.1:3128", "the scheme must be http or https"), + ( + "http://10.0.0.1:3128", + "the host must be 127.0.0.1 or localhost", + ), + ( + "http://[::1]:3128", + "the host must be 127.0.0.1 or localhost", + ), + ( + "http://user@localhost:3128", + "the host must be 127.0.0.1 or localhost", + ), + ("http://127.0.0.1", "the port must be explicit"), + ("http://127.0.0.1:", "the port must be between 1 and 65535"), + ("http://127.0.0.1:0", "the port must be between 1 and 65535"), + ( + "http://127.0.0.1:65536", + "the port must be between 1 and 65535", + ), + ( + "http://127.0.0.1:+3128", + "the port must be between 1 and 65535", + ), + ( + "http://127.0.0.1:3128/path", + "nothing but a / may follow the port", + ), + ( + "http://127.0.0.1:3128?query", + "nothing but a / may follow the port", + ), + ( + "http://127.0.0.1:3128//", + "nothing but a / may follow the port", + ), + ] { + let error = NetworkProxy::parse_url(url).unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unsupported, "{url}"); + assert_eq!( + error.message(), + format!("network proxy URL {url}: {reason}") + ); + } + assert_eq!(ProxyScheme::from_name("http").unwrap(), ProxyScheme::Http); + assert_eq!(ProxyScheme::from_name("https").unwrap(), ProxyScheme::Https); + let error = ProxyScheme::from_name("ftp").unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unsupported); + assert_eq!( + error.message(), + "network proxy scheme \"ftp\" must be http or https" + ); +} + +#[test] +#[cfg(feature = "cli")] +fn proxy_endpoints_name_the_guest_gateway() { + let gateway = Ipv4Addr::new(10, 0, 0, 1); + assert_eq!( + NetworkProxy::parse_endpoint("10.0.0.1:3128", gateway).unwrap(), + NetworkProxy::new(3128) + ); + assert_eq!( + NetworkProxy::parse_endpoint("192.168.7.5:8080", Ipv4Addr::new(192, 168, 7, 5)).unwrap(), + NetworkProxy::new(8080) + ); + for (endpoint, reason) in [ + ( + "198.51.100.1:3128", + "the address must be the guest's gateway, 10.0.0.1", + ), + ( + "10.0.0.2:3128", + "the address must be the guest's gateway, 10.0.0.1", + ), + ("10.0.0.1", "expected IPV4:TCP-PORT"), + ("proxy:3128", "expected IPV4:TCP-PORT"), + ("[::1]:3128", "expected IPV4:TCP-PORT"), + ("10.0.0.1:", "the port must be between 1 and 65535"), + ("10.0.0.1:0", "the port must be between 1 and 65535"), + ("10.0.0.1:65536", "the port must be between 1 and 65535"), + ] { + let error = NetworkProxy::parse_endpoint(endpoint, gateway).unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unsupported, "{endpoint}"); + assert_eq!(error.message(), format!("{endpoint}: {reason}")); + } +} + +#[test] +#[cfg(feature = "cli")] +fn native_rules_parse_as_openvmm_parses_them() { + let parsed = |value: &str| EgressRule::parse_native(value).unwrap(); + for (value, rule) in [ + ("10.0.0.0/8", EgressRule::to("10.0.0.0/8")), + // Host bits are cleared, and an address is a network of one address. + ( + "10.0.0.5/8:tcp", + EgressRule::to("10.0.0.0/8").on_protocol(Protocol::Tcp), + ), + ( + "192.0.2.7:udp:53", + EgressRule::to("192.0.2.7/32").on_port(Protocol::Udp, 53), + ), + ( + "192.0.2.7:tcp:8000-8010", + EgressRule::to("192.0.2.7/32").on_port_range(Protocol::Tcp, 8000, 8010), + ), + // A range of one port is that port. + ( + "192.0.2.7:tcp:443-443", + EgressRule::to("192.0.2.7/32").on_port(Protocol::Tcp, 443), + ), + ( + "192.0.2.0/24:icmp", + EgressRule::to("192.0.2.0/24").on_protocol(Protocol::Icmp), + ), + ( + "::/0:icmp", + EgressRule::to("::/0").on_protocol(Protocol::Icmp), + ), + ( + "2001:db8::/32:tcp:443", + EgressRule::to("2001:db8::/32").on_port(Protocol::Tcp, 443), + ), + ("2001:db8::1", EgressRule::to("2001:db8::1/128")), + // Protocol names are not hexadecimal, so they never belong to an IPv6 address. + ( + "2001:db8::1:tcp:443", + EgressRule::to("2001:db8::1/128").on_port(Protocol::Tcp, 443), + ), + ] { + assert_eq!(parsed(value), rule, "{value}"); + } + let destination = "the destination must be an IPv4 or IPv6 address with an optional prefix"; + let ports = "ports must be between 1 and 65535"; + for (value, reason) in [ + ( + "10.0.0.0/8:tcp:80:90", + "expected CIDR[:PROTOCOL[:PORT[-PORT]]]", + ), + ("10.0.0.0/8:sctp", "the protocol must be tcp, udp, or icmp"), + ("10.0.0.0/8:TCP", "the protocol must be tcp, udp, or icmp"), + ("10.0.0.0/8:", "the protocol must be tcp, udp, or icmp"), + ("10.0.0.0/8:icmp:8", "icmp takes no port"), + ("10.0.0.0/8:tcp:0", ports), + ("10.0.0.0/8:tcp:65536", ports), + ("10.0.0.0/8:tcp:http", ports), + ("10.0.0.0/8:udp:0-80", ports), + ( + "10.0.0.0/8:tcp:90-80", + "the port range ends below its first port", + ), + ("10.0.0.0/8:tcp:-80", "a port range must be FIRST-LAST"), + ("10.0.0.0/8:tcp:80-", "a port range must be FIRST-LAST"), + ( + "10.0.0.0/8:tcp:80-90-100", + "a port range must be FIRST-LAST", + ), + ("10.0.0.0/33", destination), + ("10.0.0.0/255.0.0.0", destination), + ("example.com", destination), + ("example.com:tcp:443", destination), + ("fe80::1%eth0", destination), + ] { + let error = EgressRule::parse_native(value).unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unsupported, "{value}"); + assert_eq!(error.message(), format!("{value}: {reason}")); + } +} + +#[test] +#[cfg(feature = "cli")] +fn lowered_rules_parse_back_to_the_same_rules() { + let egress = NetworkPolicy::new(Access::Deny) + .with_allow(EgressRule { + to: vec![Destination { + cidr: "10.0.0.0/30".to_owned(), + except: vec!["10.0.0.1".to_owned()], + }], + ports: vec![PortSelector { + protocol: Protocol::Any, + port: Some(80), + end_port: Some(81), + }], + }) + .with_allow(EgressRule::to("2001:db8::/32").on_protocol(Protocol::Icmp)) + .with_allow(EgressRule::to("198.51.100.0/24")) + .with_deny(EgressRule::to("2001:db8::123").on_port(Protocol::Udp, 53)); + let arguments = openvmm_arguments(Some(&egress), "10.0.0.2/24").unwrap(); + let mut parsed = NetworkPolicy::new(Access::Deny); + for (option, rules) in [ + ("--network-egress-allow", &mut parsed.allow), + ("--network-egress-deny", &mut parsed.deny), + ] { + for rule in self::rules(&arguments, option) { + rules.rules.push(EgressRule::parse_native(&rule).unwrap()); + } + } + assert_eq!( + openvmm_arguments(Some(&parsed), "10.0.0.2/24").unwrap(), + arguments + ); +} + +#[test] +#[cfg(feature = "cli")] +fn loopback_forwards_parse_as_openvmm_parses_them() { + for (value, protocol, host_port, guest_port) in [ + ("tcp:18080:8080", ForwardProtocol::Tcp, 18080, 8080), + ("udp:1:65535", ForwardProtocol::Udp, 1, 65535), + ] { + assert_eq!( + LoopbackForward::parse(value).unwrap(), + LoopbackForward { + protocol, + host_port, + guest_port + } + ); + } + let format = "expected tcp:HOST_PORT:GUEST_PORT or udp:HOST_PORT:GUEST_PORT"; + let ports = "ports must be between 1 and 65535"; + for (value, reason) in [ + ("8080", format), + ("tcp:8080", format), + ("tcp:8080:80:90", format), + ("sctp:8080:80", "the protocol must be tcp or udp"), + ("tcp:0:80", ports), + ("tcp:8080:0", ports), + ("udp:65536:80", ports), + ("udp:8080:http", ports), + ] { + let error = LoopbackForward::parse(value).unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unsupported, "{value}"); + assert_eq!(error.message(), format!("{value}: {reason}")); + } +} From dbd584737609732838c720e4134775733e9b18b2 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Mon, 26 Oct 2026 18:57:02 -0700 Subject: [PATCH 2/6] nvx-rs: move the artifacts module into a directory host/artifacts.rs holds, in 1,667 lines, the discovery of the artifacts that a launch runs, the guests and the layouts that hold their artifacts, the identities of those artifacts, the checks that they exist, and 727 lines of inline tests. The next commit splits it into a directory module along those sections, as #898 lays them out. Move it first, unchanged, to host/artifacts/mod.rs, with git mv, in a commit of its own, so that git log --follow and git blame follow it through the split. Its module path, host::artifacts, stays the same, and with it the paths of its items and the names of its tests. Validation at this commit, on Windows and on Linux under WSL 2: rustdoc's JSON lists the same public API as on dev, for Windows (9,632 entries) and Linux (13,779), and, with private items, every intra-doc link of the crate keeps its target. The lists of tests, with all features, with default features, and of the library without default features, are those of dev on Windows (841, 695, and 201) and Linux (1,440, 681, and 201). Clippy with warnings denied passes for Windows and Linux, and cargo check for macOS, the host-module guard passes, and the library tests with all features pass on Windows (700) and Linux (1,237). Part of #898. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nvx-rs/src/host/{artifacts.rs => artifacts/mod.rs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename nvx-rs/src/host/{artifacts.rs => artifacts/mod.rs} (100%) diff --git a/nvx-rs/src/host/artifacts.rs b/nvx-rs/src/host/artifacts/mod.rs similarity index 100% rename from nvx-rs/src/host/artifacts.rs rename to nvx-rs/src/host/artifacts/mod.rs From 02ecc74a7b05ad1c3b2c41c45b76d712d3403dd9 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Mon, 26 Oct 2026 18:44:09 -0700 Subject: [PATCH 3/6] nvx-rs: split the artifacts module along its sections host/artifacts/mod.rs, which the previous commit moved from host/artifacts.rs, held the discovery of the artifacts that a launch runs, the guests and the directories that hold their artifacts, the identities that the install metadata of those directories names, the checks that artifacts exist, and the inline tests. Split it along those sections, as #898 lays them out: - guest.rs: Guest, with its Display; - dir.rs: Layout and ArtifactDir, and ArtifactPaths and GuestPaths, which ArtifactDir::paths returns; - identity.rs: sha256_identity, read_metadata, and provenance_identity; - require.rs: required, and require_artifact and require_existing, which #897 moved here from host::sandbox; - tests.rs: the inline tests, which keep their module path, host::artifacts::tests, so that the artifact-bundling step of check-nvx-rs, cargo test --lib --features bundled artifacts, still selects them by name. Artifacts and its discovery, from MANIFEST_NAME through check_manifest, and the constants of the layouts and their metadata stay in mod.rs. The code moves unchanged. The new modules are private, and mod.rs re-exports their public items, so every path under nvx::host::artifacts resolves as before. It also re-exports, under their pub(crate) visibility, require_artifact and require_existing, which other modules of the crate import from host::artifacts. Only the CLI's run and sandbox commands import require_artifact, so its re-export takes the cli feature, as the crate's other re-exports for the CLI alone do. The helpers that dir.rs takes from its siblings, sha256_identity, read_metadata, provenance_identity, and required, become pub(super), and rustfmt wraps the signature of provenance_identity, which the visibility makes too long for one line. The tests import ArtifactIdentity, which they took from mod.rs's imports through their glob. Without their indentation, rustfmt reflows two of their statements, and the lines of their raw string, a compatible SOURCE-MANIFEST.json, keep their bytes. Four links in the moved documentation of ArtifactDir name ErrorCode::ArtifactMissing, which dir.rs does not import. Each gets a link definition with its crate path, so that the moved lines stay as they were and the links keep their targets (#896, invariant 6). Validation at this commit, on Windows and on Linux under WSL 2: rustdoc's JSON lists the same public API as on dev, for Windows (9,632 entries) and Linux (13,779), and, with private items, every intra-doc link of the crate keeps its target, with the items of the new modules named as their parent's. The lists of tests, with all features, with default features, and of the library without default features, are those of dev on Windows (841, 695, and 201) and Linux (1,440, 681, and 201). Clippy with warnings denied passes for Windows and Linux, and cargo check for macOS, the host-module guard passes, and the library tests with all features pass on Windows (700) and Linux (1,237). The artifact-bundling step passes on Windows, with the same 18 tests. Part of #898. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nvx-rs/src/host/artifacts/dir.rs | 346 +++++++ nvx-rs/src/host/artifacts/guest.rs | 105 ++ nvx-rs/src/host/artifacts/identity.rs | 116 +++ nvx-rs/src/host/artifacts/mod.rs | 1294 +------------------------ nvx-rs/src/host/artifacts/require.rs | 35 + nvx-rs/src/host/artifacts/tests.rs | 728 ++++++++++++++ 6 files changed, 1343 insertions(+), 1281 deletions(-) create mode 100644 nvx-rs/src/host/artifacts/dir.rs create mode 100644 nvx-rs/src/host/artifacts/guest.rs create mode 100644 nvx-rs/src/host/artifacts/identity.rs create mode 100644 nvx-rs/src/host/artifacts/require.rs create mode 100644 nvx-rs/src/host/artifacts/tests.rs diff --git a/nvx-rs/src/host/artifacts/dir.rs b/nvx-rs/src/host/artifacts/dir.rs new file mode 100644 index 000000000..add08194d --- /dev/null +++ b/nvx-rs/src/host/artifacts/dir.rs @@ -0,0 +1,346 @@ +//! The directories of the artifacts that `nvx.py` runs, repository checkouts and extracted +//! releases, and the paths of their artifacts. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use serde::Serialize; + +use super::identity::{provenance_identity, read_metadata, sha256_identity}; +use super::require::required; +use super::{ + Artifacts, CHECKSUMS_NAME, Guest, INITRAMFS_PROVENANCE_NAME, KERNEL_NAME, + KERNEL_PROVENANCE_NAME, OPENVMM_PROVENANCE_NAME, RELEASE_BIN_DIR, RELEASE_GUEST_DIR, + REPO_BUILD_DIR, REPO_OPENVMM_DIR, absolute, is_release_dir, openvmm_executable, +}; +use crate::error::{Error, Result}; +use crate::host::store::{ArtifactIdentity, RuntimeIdentity}; + +/// The layout of a directory of NVX artifacts. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum Layout { + /// A repository checkout: the guest artifacts in `build/`, and OpenVMM in + /// `openvmm/target/release/`, where a local build writes them and where `nvx.py download` + /// installs a release. + Repository, + /// An extracted release package: the guest artifacts in `guest/`, and OpenVMM in `bin/`. + Release, +} + +/// A directory of the artifacts that `nvx.py` runs: a repository checkout or an extracted +/// release, with the paths that `artifact_path` and `openvmm_binary_path` of +/// `scripts/nvx_tools/common.py` give for a checkout. +/// +/// Paths are joined to the directory, made absolute, without checking that they exist; the +/// `require_*` methods check them with the messages of `nvx.py`. Unlike [`Artifacts`], nothing +/// here reads the release's `SOURCE-MANIFEST.json`, which `nvx.py` does not check either. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ArtifactDir { + /// The absolute directory. + root: PathBuf, + /// Its layout. + layout: Layout, +} + +impl ArtifactDir { + /// Uses a repository checkout. + /// + /// Fails with [`ErrorKind::Unavailable`](crate::ErrorKind::Unavailable) if a relative + /// `root` meets an unreadable current directory. + pub fn repository(root: impl AsRef) -> Result { + Self::new(root.as_ref(), Layout::Repository) + } + + /// Uses an extracted release package. + /// + /// Fails as [`repository`](Self::repository) does. + pub fn release(root: impl AsRef) -> Result { + Self::new(root.as_ref(), Layout::Release) + } + + /// Uses a directory in the layout that its built artifacts reveal, as + /// [`Artifacts::from_dir`] tells them apart: a directory that contains `bin/openvmm[.exe]`, + /// `guest/vmlinux`, or `guest/initramfs.cpio.gz` is a release, and any other directory is a + /// repository checkout. + /// + /// Fails as [`repository`](Self::repository) does. + pub fn from_dir(directory: impl AsRef) -> Result { + let directory = directory.as_ref(); + let layout = if is_release_dir(directory) { + Layout::Release + } else { + Layout::Repository + }; + Self::new(directory, layout) + } + + /// Returns the directory of `layout` at `root`, made absolute. + fn new(root: &Path, layout: Layout) -> Result { + Ok(Self { + root: absolute(root)?, + layout, + }) + } + + /// Returns the absolute directory. + pub fn root(&self) -> &Path { + &self.root + } + + /// Returns the directory's layout. + pub fn layout(&self) -> Layout { + self.layout + } + + /// Returns the path of the guest artifact `name`, `artifact_path(name)` of `nvx.py`: + /// `build/NAME` in a checkout, and `guest/NAME` in a release. + pub fn artifact(&self, name: &str) -> PathBuf { + let directory = match self.layout { + Layout::Repository => REPO_BUILD_DIR, + Layout::Release => RELEASE_GUEST_DIR, + }; + self.root.join(directory).join(name) + } + + /// Returns the path of the OpenVMM executable, `openvmm_binary_path()` of `nvx.py`: + /// `openvmm/target/release/openvmm[.exe]` in a checkout, and `bin/openvmm[.exe]` in a + /// release. + pub fn openvmm(&self) -> PathBuf { + let directory = match self.layout { + Layout::Repository => REPO_OPENVMM_DIR.iter().collect(), + Layout::Release => PathBuf::from(RELEASE_BIN_DIR), + }; + self.root.join(directory).join(openvmm_executable()) + } + + /// Returns the path of the guest kernel, which every guest boots. + pub fn kernel(&self) -> PathBuf { + self.artifact(KERNEL_NAME) + } + + /// Returns the path of a guest's initramfs. + pub fn initramfs(&self, guest: Guest) -> PathBuf { + self.artifact(guest.initramfs_name()) + } + + /// Returns the path of the package manifest of a guest's initramfs. + pub fn package_manifest(&self, guest: Guest) -> PathBuf { + self.artifact(&guest.package_manifest_name()) + } + + /// Returns the path of a guest's distro layer, if the guest provides one. + pub fn distro_layer(&self, guest: Guest) -> Option { + guest.distro_layer_name().map(|name| self.artifact(name)) + } + + /// Returns the path of the manifest of a guest's distro layer, if the guest provides one. + pub fn distro_manifest(&self, guest: Guest) -> Option { + guest + .distro_manifest_name() + .map(|name| self.artifact(&name)) + } + + /// Checks that the OpenVMM executable is a regular file and returns its path. + /// + /// Fails with [`ErrorCode::ArtifactMissing`] and the message `OpenVMM release binary not + /// found: PATH`. + /// + /// [`ErrorCode::ArtifactMissing`]: crate::ErrorCode::ArtifactMissing + pub fn require_openvmm(&self) -> Result { + required(self.openvmm(), "OpenVMM release binary") + } + + /// Checks that the guest kernel is a regular file and returns its path. + /// + /// Fails with [`ErrorCode::ArtifactMissing`] and the message `Linux direct kernel not found: + /// PATH`. + /// + /// [`ErrorCode::ArtifactMissing`]: crate::ErrorCode::ArtifactMissing + pub fn require_kernel(&self) -> Result { + required(self.kernel(), "Linux direct kernel") + } + + /// Checks that a guest's initramfs is a regular file and returns its path, as `nvx-rs run` + /// does before it boots the guest. + /// + /// Fails with [`ErrorCode::ArtifactMissing`] and the message `DISTRIBUTION initramfs not + /// found: PATH`, such as `Ubuntu Base initramfs not found: PATH`. + /// + /// [`ErrorCode::ArtifactMissing`]: crate::ErrorCode::ArtifactMissing + pub fn require_initramfs(&self, guest: Guest) -> Result { + required( + self.initramfs(guest), + &format!("{} initramfs", guest.distribution()), + ) + } + + /// Returns the artifacts of a sandbox, as `nvx-rs sandbox run` and `nvx-rs sandbox start` + /// require them: the OpenVMM executable, the kernel, and the Alpine initramfs, in that order, + /// located in this directory. + /// + /// Fails with [`ErrorCode::ArtifactMissing`] for the first one that is not a regular file, + /// with the messages of [`require_openvmm`](Self::require_openvmm) and + /// [`require_kernel`](Self::require_kernel), or `initramfs not found: PATH`. + /// + /// [`ErrorCode::ArtifactMissing`]: crate::ErrorCode::ArtifactMissing + pub fn sandbox_artifacts(&self) -> Result { + self.require_openvmm()?; + self.require_kernel()?; + required(self.initramfs(Guest::Alpine), "initramfs")?; + Ok(Artifacts::located(self)) + } + + /// Returns the paths of every artifact, for a caller to show or record. + pub fn paths(&self) -> ArtifactPaths { + ArtifactPaths { + layout: self.layout, + root: self.root.clone(), + openvmm: self.openvmm(), + kernel: self.kernel(), + guests: Guest::ALL + .iter() + .map(|&guest| GuestPaths { + name: guest.name(), + distribution: guest.distribution(), + initramfs: self.initramfs(guest), + package_manifest: self.package_manifest(guest), + distro_layer: self.distro_layer(guest), + distro_manifest: self.distro_manifest(guest), + }) + .collect(), + } + } + + /// Returns the identity of the sandbox runtime of this directory, the one that + /// [`sandbox_artifacts`](Self::sandbox_artifacts) and [`Artifacts::from_dir`] name: its + /// OpenVMM executable, its kernel, and its Alpine initramfs, as the directory's install + /// metadata names them, under the contracts that this crate speaks. + /// + /// The metadata names each artifact by the SHA-256 digest that its build or its package + /// recorded, so nothing here reads or hashes an artifact: + /// + /// - A release lists the digest of each of its files in its `SHA256SUMS`, under the file's + /// path in the release: `bin/openvmm[.exe]`, `guest/vmlinux`, and + /// `guest/initramfs.cpio.gz`. + /// - A repository checkout keeps the build provenance of each artifact beside its guest + /// artifacts: `build/openvmm.provenance.json`, `build/vmlinux.provenance.json`, and + /// `build/initramfs.provenance.json`, of format 1, whose `executable_sha256`, + /// `kernel_sha256`, and `initramfs_sha256` are the digests. `nvx.py` records them as it + /// builds an artifact, and `nvx.py download` installs those of a release. + /// + /// The artifacts of a release therefore have the same identity in the release and in a + /// checkout that installed it. Fails with + /// [`ErrorKind::Unavailable`](crate::ErrorKind::Unavailable) when the metadata is missing or + /// unreadable, or names no valid digest of an artifact. A file of metadata that is not a + /// regular file, such as a FIFO, is refused without being waited on. + pub fn runtime_identity(&self) -> Result { + let [openvmm, kernel, initramfs] = match self.layout { + Layout::Release => self.listed_identities()?, + Layout::Repository => [ + provenance_identity( + &self.artifact(OPENVMM_PROVENANCE_NAME), + "OpenVMM", + "executable_sha256", + )?, + provenance_identity( + &self.artifact(KERNEL_PROVENANCE_NAME), + "kernel", + "kernel_sha256", + )?, + provenance_identity( + &self.artifact(INITRAMFS_PROVENANCE_NAME), + "initramfs", + "initramfs_sha256", + )?, + ], + }; + Ok(RuntimeIdentity::new(openvmm, kernel, initramfs)) + } + + /// Returns the identities of the OpenVMM executable, kernel, and Alpine initramfs of a + /// release, as its `SHA256SUMS` lists their digests: one line for each file, its 64 + /// lowercase hexadecimal digits, two spaces, and its path in the release. + fn listed_identities(&self) -> Result<[ArtifactIdentity; 3]> { + let path = self.root.join(CHECKSUMS_NAME); + let description = "release checksum list"; + let text = read_metadata(&path, description, "the release is incomplete")?; + let mut identities = HashMap::new(); + for line in text.lines() { + let malformed = || { + Error::unavailable(format!( + "the {description} {} has a malformed line: {line:?}", + path.display() + )) + }; + let (digest, name) = line.split_once(" ").ok_or_else(malformed)?; + let identity = sha256_identity(digest) + .filter(|_| !name.is_empty()) + .ok_or_else(malformed)?; + if identities.insert(name, identity).is_some() { + return Err(Error::unavailable(format!( + "the {description} {} lists {name} twice", + path.display() + ))); + } + } + let mut listed = |name: String| { + identities.remove(name.as_str()).ok_or_else(|| { + Error::unavailable(format!( + "the {description} {} does not list {name}", + path.display() + )) + }) + }; + Ok([ + listed(format!("{RELEASE_BIN_DIR}/{}", openvmm_executable()))?, + listed(format!("{RELEASE_GUEST_DIR}/{KERNEL_NAME}"))?, + listed(format!( + "{RELEASE_GUEST_DIR}/{}", + Guest::Alpine.initramfs_name() + ))?, + ]) + } +} + +/// The paths of the artifacts of an [`ArtifactDir`], which serialize as a JSON object such as: +/// +/// ```json +/// {"layout": "repository", "root": "/nvx", "openvmm": "/nvx/openvmm/target/release/openvmm", +/// "kernel": "/nvx/build/vmlinux", "guests": [{"name": "alpine", +/// "distribution": "Alpine Linux", "initramfs": "/nvx/build/initramfs.cpio.gz", +/// "package_manifest": "/nvx/build/initramfs.cpio.gz.packages.json", "distro_layer": null, +/// "distro_manifest": null}, ...]} +/// ``` +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[non_exhaustive] +pub struct ArtifactPaths { + /// The directory's layout. + pub layout: Layout, + /// The absolute directory. + pub root: PathBuf, + /// The OpenVMM executable. + pub openvmm: PathBuf, + /// The guest kernel. + pub kernel: PathBuf, + /// The artifacts of each guest, in the order of [`Guest::ALL`]. + pub guests: Vec, +} + +/// The paths of the artifacts of one guest. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[non_exhaustive] +pub struct GuestPaths { + /// The guest's name, [`Guest::name`]. + pub name: &'static str, + /// The guest's distribution, [`Guest::distribution`]. + pub distribution: &'static str, + /// The initramfs. + pub initramfs: PathBuf, + /// The package manifest of the initramfs. + pub package_manifest: PathBuf, + /// The distro layer, if the guest provides one. + pub distro_layer: Option, + /// The manifest of the distro layer, if the guest provides one. + pub distro_manifest: Option, +} diff --git a/nvx-rs/src/host/artifacts/guest.rs b/nvx-rs/src/host/artifacts/guest.rs new file mode 100644 index 000000000..8b9f7268e --- /dev/null +++ b/nvx-rs/src/host/artifacts/guest.rs @@ -0,0 +1,105 @@ +//! The Linux guests that `nvx.py` builds and runs, and the names of their artifacts. + +use std::fmt; + +use super::{DISTRO_MANIFEST_SUFFIX, PACKAGE_MANIFEST_SUFFIX}; +use crate::error::{Error, Result}; + +/// A Linux guest that `nvx.py` builds and runs, as `scripts/nvx_tools/guests.py` describes it. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Guest { + /// Alpine Linux: the default guest of `nvx-rs run`, and the one that runs sandboxes. + Alpine, + /// Ubuntu Base, which also provides the distro layer of sandbox workloads. + Ubuntu, + /// Azure Linux. + AzureLinux, +} + +impl Guest { + /// Every guest, in the order in which `nvx.py` lists them. + pub const ALL: [Self; 3] = [Self::Alpine, Self::Ubuntu, Self::AzureLinux]; + + /// Returns the guest's name in `nvx.py` options: `alpine`, `ubuntu`, or `azurelinux`. + pub const fn name(self) -> &'static str { + match self { + Self::Alpine => "alpine", + Self::Ubuntu => "ubuntu", + Self::AzureLinux => "azurelinux", + } + } + + /// Returns the guest named `name`. + /// + /// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) and the message + /// `unsupported guest "NAME"; choose alpine, ubuntu, azurelinux`, which quotes the name with + /// Rust's debug formatting. + pub fn from_name(name: &str) -> Result { + Self::ALL + .into_iter() + .find(|guest| guest.name() == name) + .ok_or_else(|| { + let names: Vec<&str> = Self::ALL.iter().map(|guest| guest.name()).collect(); + Error::unsupported(format!( + "unsupported guest {name:?}; choose {}", + names.join(", ") + )) + }) + } + + /// Returns the name of the guest's distribution, as messages show it: `Alpine Linux`, + /// `Ubuntu Base`, or `Azure Linux`. + pub const fn distribution(self) -> &'static str { + match self { + Self::Alpine => "Alpine Linux", + Self::Ubuntu => "Ubuntu Base", + Self::AzureLinux => "Azure Linux", + } + } + + /// Returns the file name of the guest's initramfs. + pub const fn initramfs_name(self) -> &'static str { + match self { + Self::Alpine => "initramfs.cpio.gz", + Self::Ubuntu => "initramfs-ubuntu.cpio.gz", + Self::AzureLinux => "initramfs-azurelinux.cpio.gz", + } + } + + /// Returns the RAM, in MiB, with which `nvx-rs run` boots the guest without `--memory-mib`: + /// the `default_memory_mib` of its descriptor in `scripts/nvx_tools/guests.py`, which + /// `nvx.py test-microvm` also uses, and which a test of `nvx.py` checks against the help of + /// `nvx-rs run`. + pub const fn default_memory_mib(self) -> u32 { + match self { + Self::Alpine => 128, + Self::Ubuntu | Self::AzureLinux => 512, + } + } + + /// Returns the file name of the package manifest of the guest's initramfs. + pub fn package_manifest_name(self) -> String { + format!("{}{PACKAGE_MANIFEST_SUFFIX}", self.initramfs_name()) + } + + /// Returns the file name of the guest's EROFS distro layer, `GUEST-distro.erofs`, if the + /// guest provides one: only Ubuntu does. + pub const fn distro_layer_name(self) -> Option<&'static str> { + match self { + Self::Ubuntu => Some("ubuntu-distro.erofs"), + Self::Alpine | Self::AzureLinux => None, + } + } + + /// Returns the file name of the manifest of the guest's distro layer, if it has one. + pub fn distro_manifest_name(self) -> Option { + self.distro_layer_name() + .map(|name| format!("{name}{DISTRO_MANIFEST_SUFFIX}")) + } +} + +impl fmt::Display for Guest { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.name()) + } +} diff --git a/nvx-rs/src/host/artifacts/identity.rs b/nvx-rs/src/host/artifacts/identity.rs new file mode 100644 index 000000000..17ed45563 --- /dev/null +++ b/nvx-rs/src/host/artifacts/identity.rs @@ -0,0 +1,116 @@ +//! The identities of artifacts, as the install metadata of a release or a repository checkout +//! names them. + +use std::fs; +use std::io::{self, Read}; +use std::path::Path; + +use serde_json::Value; + +use super::{MAX_METADATA_LEN, PROVENANCE_FORMAT}; +use crate::error::{Error, Result}; +use crate::host::store::ArtifactIdentity; + +/// Returns the identity of an artifact whose SHA-256 digest is `digest`, if it holds 64 lowercase +/// hexadecimal digits, as `nvx.py` writes digests and requires them. +pub(super) fn sha256_identity(digest: &str) -> Option { + if digest + .bytes() + .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f')) + { + ArtifactIdentity::sha256(digest).ok() + } else { + None + } +} + +/// Reads the file of install metadata at `path`, which messages call `description`, as text of +/// at most [`MAX_METADATA_LEN`] bytes. A missing file fails with `advice` on how to provide it. +/// +/// The open does not wait, as it would on Unix for a FIFO without a writer, and a file that is +/// not a regular file is refused before anything is read from it, as a FIFO whose writer keeps it +/// open would keep a read waiting: a sandbox's transitions read the metadata while they hold its +/// lock. +pub(super) fn read_metadata(path: &Path, description: &str, advice: &str) -> Result { + let unreadable = |error: io::Error| { + if error.kind() == io::ErrorKind::NotFound { + Error::unavailable(format!( + "the {description} {} is missing, so the runtime cannot be identified: {advice}", + path.display() + )) + } else { + Error::unavailable(format!("cannot read the {description} {}", path.display())) + .with_source(error) + } + }; + let not_regular = || { + Error::unavailable(format!( + "the {description} {} is not a regular file", + path.display() + )) + }; + let mut options = fs::OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + + // Reads of a regular file do not depend on the flag. + options.custom_flags(nix::fcntl::OFlag::O_NONBLOCK.bits()); + } + let opened = options.open(path); + #[cfg(unix)] + { + // A socket refuses an open. + if let Err(error) = &opened + && error.raw_os_error() == Some(nix::errno::Errno::ENXIO as i32) + { + return Err(not_regular()); + } + } + let file = opened.map_err(unreadable)?; + if !file.metadata().map_err(unreadable)?.file_type().is_file() { + return Err(not_regular()); + } + let mut text = String::new(); + file.take(MAX_METADATA_LEN + 1) + .read_to_string(&mut text) + .map_err(unreadable)?; + if text.len() as u64 > MAX_METADATA_LEN { + return Err(Error::unavailable(format!( + "the {description} {} is larger than {MAX_METADATA_LEN} bytes", + path.display() + ))); + } + Ok(text) +} + +/// Returns the identity of the artifact `what` that the build provenance at `path` records: the +/// SHA-256 digest of its `field`, in a provenance of format [`PROVENANCE_FORMAT`]. +pub(super) fn provenance_identity( + path: &Path, + what: &str, + field: &str, +) -> Result { + let description = format!("{what} build provenance"); + let text = read_metadata( + path, + &description, + "build the artifact with nvx.py, or install a release with nvx.py download", + )?; + let invalid = |problem: &str| { + Error::unavailable(format!("the {description} {} {problem}", path.display())) + }; + let provenance: Value = serde_json::from_str(&text) + .map_err(|error| invalid("is not valid JSON").with_source(error))?; + if provenance.get("format").and_then(Value::as_u64) != Some(PROVENANCE_FORMAT) { + return Err(invalid(&format!( + "does not have format {PROVENANCE_FORMAT}" + ))); + } + provenance + .get(field) + .and_then(Value::as_str) + .and_then(sha256_identity) + .ok_or_else(|| invalid(&format!("has no SHA-256 digest in {field}"))) +} diff --git a/nvx-rs/src/host/artifacts/mod.rs b/nvx-rs/src/host/artifacts/mod.rs index c8cc59871..2b148ee3a 100644 --- a/nvx-rs/src/host/artifacts/mod.rs +++ b/nvx-rs/src/host/artifacts/mod.rs @@ -25,22 +25,28 @@ //! `scripts/nvx_tools/common.py`, so that `nvx-rs` and the `nvx.py` commands that build, //! install, benchmark, and test the guests agree on where each artifact is. -use std::collections::HashMap; +mod dir; +mod guest; +mod identity; +mod require; + use std::env; use std::ffi::OsString; -use std::fmt; use std::fs; -use std::io::{self, Read}; use std::path::{Path, PathBuf}; -use serde::Serialize; use serde_json::Value; use crate::contract; use crate::error::{Error, ErrorCode, Result}; -use crate::host::paths; use crate::host::platform; -use crate::host::store::{ArtifactIdentity, RuntimeIdentity}; +use crate::host::store::RuntimeIdentity; + +pub use self::dir::{ArtifactDir, ArtifactPaths, GuestPaths, Layout}; +pub use self::guest::Guest; +#[cfg(feature = "cli")] +pub(crate) use self::require::require_artifact; +pub(crate) use self::require::require_existing; /// Name of the release manifest at the root of a release archive or repository checkout. pub const MANIFEST_NAME: &str = "SOURCE-MANIFEST.json"; @@ -389,1279 +395,5 @@ const PROVENANCE_FORMAT: u64 = 1; /// holds one line for each of its files, and a build provenance a few fields. const MAX_METADATA_LEN: u64 = 1 << 20; -/// A Linux guest that `nvx.py` builds and runs, as `scripts/nvx_tools/guests.py` describes it. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub enum Guest { - /// Alpine Linux: the default guest of `nvx-rs run`, and the one that runs sandboxes. - Alpine, - /// Ubuntu Base, which also provides the distro layer of sandbox workloads. - Ubuntu, - /// Azure Linux. - AzureLinux, -} - -impl Guest { - /// Every guest, in the order in which `nvx.py` lists them. - pub const ALL: [Self; 3] = [Self::Alpine, Self::Ubuntu, Self::AzureLinux]; - - /// Returns the guest's name in `nvx.py` options: `alpine`, `ubuntu`, or `azurelinux`. - pub const fn name(self) -> &'static str { - match self { - Self::Alpine => "alpine", - Self::Ubuntu => "ubuntu", - Self::AzureLinux => "azurelinux", - } - } - - /// Returns the guest named `name`. - /// - /// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) and the message - /// `unsupported guest "NAME"; choose alpine, ubuntu, azurelinux`, which quotes the name with - /// Rust's debug formatting. - pub fn from_name(name: &str) -> Result { - Self::ALL - .into_iter() - .find(|guest| guest.name() == name) - .ok_or_else(|| { - let names: Vec<&str> = Self::ALL.iter().map(|guest| guest.name()).collect(); - Error::unsupported(format!( - "unsupported guest {name:?}; choose {}", - names.join(", ") - )) - }) - } - - /// Returns the name of the guest's distribution, as messages show it: `Alpine Linux`, - /// `Ubuntu Base`, or `Azure Linux`. - pub const fn distribution(self) -> &'static str { - match self { - Self::Alpine => "Alpine Linux", - Self::Ubuntu => "Ubuntu Base", - Self::AzureLinux => "Azure Linux", - } - } - - /// Returns the file name of the guest's initramfs. - pub const fn initramfs_name(self) -> &'static str { - match self { - Self::Alpine => "initramfs.cpio.gz", - Self::Ubuntu => "initramfs-ubuntu.cpio.gz", - Self::AzureLinux => "initramfs-azurelinux.cpio.gz", - } - } - - /// Returns the RAM, in MiB, with which `nvx-rs run` boots the guest without `--memory-mib`: - /// the `default_memory_mib` of its descriptor in `scripts/nvx_tools/guests.py`, which - /// `nvx.py test-microvm` also uses, and which a test of `nvx.py` checks against the help of - /// `nvx-rs run`. - pub const fn default_memory_mib(self) -> u32 { - match self { - Self::Alpine => 128, - Self::Ubuntu | Self::AzureLinux => 512, - } - } - - /// Returns the file name of the package manifest of the guest's initramfs. - pub fn package_manifest_name(self) -> String { - format!("{}{PACKAGE_MANIFEST_SUFFIX}", self.initramfs_name()) - } - - /// Returns the file name of the guest's EROFS distro layer, `GUEST-distro.erofs`, if the - /// guest provides one: only Ubuntu does. - pub const fn distro_layer_name(self) -> Option<&'static str> { - match self { - Self::Ubuntu => Some("ubuntu-distro.erofs"), - Self::Alpine | Self::AzureLinux => None, - } - } - - /// Returns the file name of the manifest of the guest's distro layer, if it has one. - pub fn distro_manifest_name(self) -> Option { - self.distro_layer_name() - .map(|name| format!("{name}{DISTRO_MANIFEST_SUFFIX}")) - } -} - -impl fmt::Display for Guest { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str(self.name()) - } -} - -/// The layout of a directory of NVX artifacts. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize)] -#[serde(rename_all = "lowercase")] -pub enum Layout { - /// A repository checkout: the guest artifacts in `build/`, and OpenVMM in - /// `openvmm/target/release/`, where a local build writes them and where `nvx.py download` - /// installs a release. - Repository, - /// An extracted release package: the guest artifacts in `guest/`, and OpenVMM in `bin/`. - Release, -} - -/// A directory of the artifacts that `nvx.py` runs: a repository checkout or an extracted -/// release, with the paths that `artifact_path` and `openvmm_binary_path` of -/// `scripts/nvx_tools/common.py` give for a checkout. -/// -/// Paths are joined to the directory, made absolute, without checking that they exist; the -/// `require_*` methods check them with the messages of `nvx.py`. Unlike [`Artifacts`], nothing -/// here reads the release's `SOURCE-MANIFEST.json`, which `nvx.py` does not check either. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct ArtifactDir { - /// The absolute directory. - root: PathBuf, - /// Its layout. - layout: Layout, -} - -impl ArtifactDir { - /// Uses a repository checkout. - /// - /// Fails with [`ErrorKind::Unavailable`](crate::ErrorKind::Unavailable) if a relative - /// `root` meets an unreadable current directory. - pub fn repository(root: impl AsRef) -> Result { - Self::new(root.as_ref(), Layout::Repository) - } - - /// Uses an extracted release package. - /// - /// Fails as [`repository`](Self::repository) does. - pub fn release(root: impl AsRef) -> Result { - Self::new(root.as_ref(), Layout::Release) - } - - /// Uses a directory in the layout that its built artifacts reveal, as - /// [`Artifacts::from_dir`] tells them apart: a directory that contains `bin/openvmm[.exe]`, - /// `guest/vmlinux`, or `guest/initramfs.cpio.gz` is a release, and any other directory is a - /// repository checkout. - /// - /// Fails as [`repository`](Self::repository) does. - pub fn from_dir(directory: impl AsRef) -> Result { - let directory = directory.as_ref(); - let layout = if is_release_dir(directory) { - Layout::Release - } else { - Layout::Repository - }; - Self::new(directory, layout) - } - - /// Returns the directory of `layout` at `root`, made absolute. - fn new(root: &Path, layout: Layout) -> Result { - Ok(Self { - root: absolute(root)?, - layout, - }) - } - - /// Returns the absolute directory. - pub fn root(&self) -> &Path { - &self.root - } - - /// Returns the directory's layout. - pub fn layout(&self) -> Layout { - self.layout - } - - /// Returns the path of the guest artifact `name`, `artifact_path(name)` of `nvx.py`: - /// `build/NAME` in a checkout, and `guest/NAME` in a release. - pub fn artifact(&self, name: &str) -> PathBuf { - let directory = match self.layout { - Layout::Repository => REPO_BUILD_DIR, - Layout::Release => RELEASE_GUEST_DIR, - }; - self.root.join(directory).join(name) - } - - /// Returns the path of the OpenVMM executable, `openvmm_binary_path()` of `nvx.py`: - /// `openvmm/target/release/openvmm[.exe]` in a checkout, and `bin/openvmm[.exe]` in a - /// release. - pub fn openvmm(&self) -> PathBuf { - let directory = match self.layout { - Layout::Repository => REPO_OPENVMM_DIR.iter().collect(), - Layout::Release => PathBuf::from(RELEASE_BIN_DIR), - }; - self.root.join(directory).join(openvmm_executable()) - } - - /// Returns the path of the guest kernel, which every guest boots. - pub fn kernel(&self) -> PathBuf { - self.artifact(KERNEL_NAME) - } - - /// Returns the path of a guest's initramfs. - pub fn initramfs(&self, guest: Guest) -> PathBuf { - self.artifact(guest.initramfs_name()) - } - - /// Returns the path of the package manifest of a guest's initramfs. - pub fn package_manifest(&self, guest: Guest) -> PathBuf { - self.artifact(&guest.package_manifest_name()) - } - - /// Returns the path of a guest's distro layer, if the guest provides one. - pub fn distro_layer(&self, guest: Guest) -> Option { - guest.distro_layer_name().map(|name| self.artifact(name)) - } - - /// Returns the path of the manifest of a guest's distro layer, if the guest provides one. - pub fn distro_manifest(&self, guest: Guest) -> Option { - guest - .distro_manifest_name() - .map(|name| self.artifact(&name)) - } - - /// Checks that the OpenVMM executable is a regular file and returns its path. - /// - /// Fails with [`ErrorCode::ArtifactMissing`] and the message `OpenVMM release binary not - /// found: PATH`. - pub fn require_openvmm(&self) -> Result { - required(self.openvmm(), "OpenVMM release binary") - } - - /// Checks that the guest kernel is a regular file and returns its path. - /// - /// Fails with [`ErrorCode::ArtifactMissing`] and the message `Linux direct kernel not found: - /// PATH`. - pub fn require_kernel(&self) -> Result { - required(self.kernel(), "Linux direct kernel") - } - - /// Checks that a guest's initramfs is a regular file and returns its path, as `nvx-rs run` - /// does before it boots the guest. - /// - /// Fails with [`ErrorCode::ArtifactMissing`] and the message `DISTRIBUTION initramfs not - /// found: PATH`, such as `Ubuntu Base initramfs not found: PATH`. - pub fn require_initramfs(&self, guest: Guest) -> Result { - required( - self.initramfs(guest), - &format!("{} initramfs", guest.distribution()), - ) - } - - /// Returns the artifacts of a sandbox, as `nvx-rs sandbox run` and `nvx-rs sandbox start` - /// require them: the OpenVMM executable, the kernel, and the Alpine initramfs, in that order, - /// located in this directory. - /// - /// Fails with [`ErrorCode::ArtifactMissing`] for the first one that is not a regular file, - /// with the messages of [`require_openvmm`](Self::require_openvmm) and - /// [`require_kernel`](Self::require_kernel), or `initramfs not found: PATH`. - pub fn sandbox_artifacts(&self) -> Result { - self.require_openvmm()?; - self.require_kernel()?; - required(self.initramfs(Guest::Alpine), "initramfs")?; - Ok(Artifacts::located(self)) - } - - /// Returns the paths of every artifact, for a caller to show or record. - pub fn paths(&self) -> ArtifactPaths { - ArtifactPaths { - layout: self.layout, - root: self.root.clone(), - openvmm: self.openvmm(), - kernel: self.kernel(), - guests: Guest::ALL - .iter() - .map(|&guest| GuestPaths { - name: guest.name(), - distribution: guest.distribution(), - initramfs: self.initramfs(guest), - package_manifest: self.package_manifest(guest), - distro_layer: self.distro_layer(guest), - distro_manifest: self.distro_manifest(guest), - }) - .collect(), - } - } - - /// Returns the identity of the sandbox runtime of this directory, the one that - /// [`sandbox_artifacts`](Self::sandbox_artifacts) and [`Artifacts::from_dir`] name: its - /// OpenVMM executable, its kernel, and its Alpine initramfs, as the directory's install - /// metadata names them, under the contracts that this crate speaks. - /// - /// The metadata names each artifact by the SHA-256 digest that its build or its package - /// recorded, so nothing here reads or hashes an artifact: - /// - /// - A release lists the digest of each of its files in its `SHA256SUMS`, under the file's - /// path in the release: `bin/openvmm[.exe]`, `guest/vmlinux`, and - /// `guest/initramfs.cpio.gz`. - /// - A repository checkout keeps the build provenance of each artifact beside its guest - /// artifacts: `build/openvmm.provenance.json`, `build/vmlinux.provenance.json`, and - /// `build/initramfs.provenance.json`, of format 1, whose `executable_sha256`, - /// `kernel_sha256`, and `initramfs_sha256` are the digests. `nvx.py` records them as it - /// builds an artifact, and `nvx.py download` installs those of a release. - /// - /// The artifacts of a release therefore have the same identity in the release and in a - /// checkout that installed it. Fails with - /// [`ErrorKind::Unavailable`](crate::ErrorKind::Unavailable) when the metadata is missing or - /// unreadable, or names no valid digest of an artifact. A file of metadata that is not a - /// regular file, such as a FIFO, is refused without being waited on. - pub fn runtime_identity(&self) -> Result { - let [openvmm, kernel, initramfs] = match self.layout { - Layout::Release => self.listed_identities()?, - Layout::Repository => [ - provenance_identity( - &self.artifact(OPENVMM_PROVENANCE_NAME), - "OpenVMM", - "executable_sha256", - )?, - provenance_identity( - &self.artifact(KERNEL_PROVENANCE_NAME), - "kernel", - "kernel_sha256", - )?, - provenance_identity( - &self.artifact(INITRAMFS_PROVENANCE_NAME), - "initramfs", - "initramfs_sha256", - )?, - ], - }; - Ok(RuntimeIdentity::new(openvmm, kernel, initramfs)) - } - - /// Returns the identities of the OpenVMM executable, kernel, and Alpine initramfs of a - /// release, as its `SHA256SUMS` lists their digests: one line for each file, its 64 - /// lowercase hexadecimal digits, two spaces, and its path in the release. - fn listed_identities(&self) -> Result<[ArtifactIdentity; 3]> { - let path = self.root.join(CHECKSUMS_NAME); - let description = "release checksum list"; - let text = read_metadata(&path, description, "the release is incomplete")?; - let mut identities = HashMap::new(); - for line in text.lines() { - let malformed = || { - Error::unavailable(format!( - "the {description} {} has a malformed line: {line:?}", - path.display() - )) - }; - let (digest, name) = line.split_once(" ").ok_or_else(malformed)?; - let identity = sha256_identity(digest) - .filter(|_| !name.is_empty()) - .ok_or_else(malformed)?; - if identities.insert(name, identity).is_some() { - return Err(Error::unavailable(format!( - "the {description} {} lists {name} twice", - path.display() - ))); - } - } - let mut listed = |name: String| { - identities.remove(name.as_str()).ok_or_else(|| { - Error::unavailable(format!( - "the {description} {} does not list {name}", - path.display() - )) - }) - }; - Ok([ - listed(format!("{RELEASE_BIN_DIR}/{}", openvmm_executable()))?, - listed(format!("{RELEASE_GUEST_DIR}/{KERNEL_NAME}"))?, - listed(format!( - "{RELEASE_GUEST_DIR}/{}", - Guest::Alpine.initramfs_name() - ))?, - ]) - } -} - -/// Returns the identity of an artifact whose SHA-256 digest is `digest`, if it holds 64 lowercase -/// hexadecimal digits, as `nvx.py` writes digests and requires them. -fn sha256_identity(digest: &str) -> Option { - if digest - .bytes() - .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f')) - { - ArtifactIdentity::sha256(digest).ok() - } else { - None - } -} - -/// Reads the file of install metadata at `path`, which messages call `description`, as text of -/// at most [`MAX_METADATA_LEN`] bytes. A missing file fails with `advice` on how to provide it. -/// -/// The open does not wait, as it would on Unix for a FIFO without a writer, and a file that is -/// not a regular file is refused before anything is read from it, as a FIFO whose writer keeps it -/// open would keep a read waiting: a sandbox's transitions read the metadata while they hold its -/// lock. -fn read_metadata(path: &Path, description: &str, advice: &str) -> Result { - let unreadable = |error: io::Error| { - if error.kind() == io::ErrorKind::NotFound { - Error::unavailable(format!( - "the {description} {} is missing, so the runtime cannot be identified: {advice}", - path.display() - )) - } else { - Error::unavailable(format!("cannot read the {description} {}", path.display())) - .with_source(error) - } - }; - let not_regular = || { - Error::unavailable(format!( - "the {description} {} is not a regular file", - path.display() - )) - }; - let mut options = fs::OpenOptions::new(); - options.read(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - - // Reads of a regular file do not depend on the flag. - options.custom_flags(nix::fcntl::OFlag::O_NONBLOCK.bits()); - } - let opened = options.open(path); - #[cfg(unix)] - { - // A socket refuses an open. - if let Err(error) = &opened - && error.raw_os_error() == Some(nix::errno::Errno::ENXIO as i32) - { - return Err(not_regular()); - } - } - let file = opened.map_err(unreadable)?; - if !file.metadata().map_err(unreadable)?.file_type().is_file() { - return Err(not_regular()); - } - let mut text = String::new(); - file.take(MAX_METADATA_LEN + 1) - .read_to_string(&mut text) - .map_err(unreadable)?; - if text.len() as u64 > MAX_METADATA_LEN { - return Err(Error::unavailable(format!( - "the {description} {} is larger than {MAX_METADATA_LEN} bytes", - path.display() - ))); - } - Ok(text) -} - -/// Returns the identity of the artifact `what` that the build provenance at `path` records: the -/// SHA-256 digest of its `field`, in a provenance of format [`PROVENANCE_FORMAT`]. -fn provenance_identity(path: &Path, what: &str, field: &str) -> Result { - let description = format!("{what} build provenance"); - let text = read_metadata( - path, - &description, - "build the artifact with nvx.py, or install a release with nvx.py download", - )?; - let invalid = |problem: &str| { - Error::unavailable(format!("the {description} {} {problem}", path.display())) - }; - let provenance: Value = serde_json::from_str(&text) - .map_err(|error| invalid("is not valid JSON").with_source(error))?; - if provenance.get("format").and_then(Value::as_u64) != Some(PROVENANCE_FORMAT) { - return Err(invalid(&format!( - "does not have format {PROVENANCE_FORMAT}" - ))); - } - provenance - .get(field) - .and_then(Value::as_str) - .and_then(sha256_identity) - .ok_or_else(|| invalid(&format!("has no SHA-256 digest in {field}"))) -} - -/// Checks that `path` is a regular file, as `require_file` of `nvx.py` does, and returns it, or -/// fails with [`ErrorCode::ArtifactMissing`]. -fn required(path: PathBuf, description: &str) -> Result { - require_artifact(&path, description)?; - Ok(path) -} - -/// Checks that an artifact that a launch runs, such as the OpenVMM executable, is a regular -/// file, as [`require_existing`] does, and fails with [`ErrorCode::ArtifactMissing`]. -pub(crate) fn require_artifact(path: &Path, description: &str) -> Result<()> { - require_existing(path, description, ErrorCode::ArtifactMissing) -} - -/// Checks that `path` is a regular file, following links, as `require_file` of `nvx.py` does, -/// and fails with `code` and the message `{description} not found: {path}`, with `path` spelled -/// with its components alone, as [`Path::components`] reads them. -pub(crate) fn require_existing(path: &Path, description: &str, code: ErrorCode) -> Result<()> { - if path.is_file() { - return Ok(()); - } - Err(Error::new( - code, - format!( - "{description} not found: {}", - paths::normalized(path).display() - ), - )) -} - -/// The paths of the artifacts of an [`ArtifactDir`], which serialize as a JSON object such as: -/// -/// ```json -/// {"layout": "repository", "root": "/nvx", "openvmm": "/nvx/openvmm/target/release/openvmm", -/// "kernel": "/nvx/build/vmlinux", "guests": [{"name": "alpine", -/// "distribution": "Alpine Linux", "initramfs": "/nvx/build/initramfs.cpio.gz", -/// "package_manifest": "/nvx/build/initramfs.cpio.gz.packages.json", "distro_layer": null, -/// "distro_manifest": null}, ...]} -/// ``` -#[derive(Clone, Debug, PartialEq, Eq, Serialize)] -#[non_exhaustive] -pub struct ArtifactPaths { - /// The directory's layout. - pub layout: Layout, - /// The absolute directory. - pub root: PathBuf, - /// The OpenVMM executable. - pub openvmm: PathBuf, - /// The guest kernel. - pub kernel: PathBuf, - /// The artifacts of each guest, in the order of [`Guest::ALL`]. - pub guests: Vec, -} - -/// The paths of the artifacts of one guest. -#[derive(Clone, Debug, PartialEq, Eq, Serialize)] -#[non_exhaustive] -pub struct GuestPaths { - /// The guest's name, [`Guest::name`]. - pub name: &'static str, - /// The guest's distribution, [`Guest::distribution`]. - pub distribution: &'static str, - /// The initramfs. - pub initramfs: PathBuf, - /// The package manifest of the initramfs. - pub package_manifest: PathBuf, - /// The distro layer, if the guest provides one. - pub distro_layer: Option, - /// The manifest of the distro layer, if the guest provides one. - pub distro_manifest: Option, -} - #[cfg(test)] -mod tests { - use std::collections::HashMap; - - use super::*; - use crate::ErrorKind; - - const COMPATIBLE_MANIFEST: &str = r#"{"openvmm":{"microvm_abi_version":2, - "control_session_protocol_version":1, - "control_contract_revision":"nvx-microvm-v2-control-v2"}}"#; - - fn release(root: &Path) { - for artifact in [ - Path::new("bin").join(openvmm_executable()), - Path::new("guest").join("vmlinux"), - Path::new("guest").join("initramfs.cpio.gz"), - ] { - let path = root.join(artifact); - fs::create_dir_all(path.parent().unwrap()).unwrap(); - fs::write(path, b"artifact").unwrap(); - } - fs::write(root.join(MANIFEST_NAME), COMPATIBLE_MANIFEST).unwrap(); - } - - /// Creates a repository checkout with its tracked guest sources and no build outputs. - fn checkout(root: &Path) { - let sources = root.join("guest").join("common"); - fs::create_dir_all(&sources).unwrap(); - fs::write(sources.join("init"), b"#!/bin/sh").unwrap(); - fs::write(root.join(MANIFEST_NAME), COMPATIBLE_MANIFEST).unwrap(); - } - - fn no_bundle() -> Result> { - Ok(None) - } - - fn variables(pairs: &[(&str, &Path)]) -> impl Fn(&str) -> Option { - let map: HashMap = pairs - .iter() - .map(|(name, value)| ((*name).to_owned(), value.as_os_str().to_owned())) - .collect(); - move |name| map.get(name).cloned() - } - - #[test] - fn release_layout_names_the_guest_artifacts() { - let directory = tempfile::tempdir().unwrap(); - release(directory.path()); - let artifacts = Artifacts::from_dir(directory.path()).unwrap(); - let root = std::path::absolute(directory.path()).unwrap(); - assert_eq!( - artifacts.openvmm, - root.join("bin").join(openvmm_executable()) - ); - assert_eq!(artifacts.kernel, root.join("guest").join("vmlinux")); - assert_eq!( - artifacts.initrd, - root.join("guest").join("initramfs.cpio.gz") - ); - // A directory that the caller hands over, such as a `TempDir`, lives until the - // constructor returns, after it reads the manifest. - assert_eq!(Artifacts::from_release_dir(directory).unwrap(), artifacts); - } - - #[test] - fn repository_layout_is_detected_beside_the_guest_sources() { - let directory = tempfile::tempdir().unwrap(); - checkout(directory.path()); - let root = std::path::absolute(directory.path()).unwrap(); - let expected = |artifacts: &Artifacts| { - assert_eq!(artifacts.kernel, root.join("build").join("vmlinux")); - assert_eq!( - artifacts.initrd, - root.join("build").join("initramfs.cpio.gz") - ); - assert_eq!( - artifacts.openvmm, - root.join("openvmm") - .join("target") - .join("release") - .join(openvmm_executable()) - ); - }; - expected(&Artifacts::from_dir(directory.path()).unwrap()); - expected(&Artifacts::from_repo_layout(directory.path()).unwrap()); - - // Build outputs do not turn a checkout into a release. - let build = directory.path().join("build"); - fs::create_dir_all(&build).unwrap(); - fs::write(build.join("vmlinux"), b"kernel").unwrap(); - fs::write(build.join("initramfs.cpio.gz"), b"initrd").unwrap(); - expected(&Artifacts::from_dir(directory.path()).unwrap()); - } - - #[test] - fn the_directory_variable_accepts_a_repository_checkout() { - let directory = tempfile::tempdir().unwrap(); - checkout(directory.path()); - let artifacts = discover_with( - variables(&[(Artifacts::ENV_DIR, directory.path())]), - None, - no_bundle, - ) - .unwrap(); - let build = std::path::absolute(directory.path()).unwrap().join("build"); - assert_eq!(artifacts.kernel, build.join("vmlinux")); - } - - #[test] - fn this_repository_uses_the_repository_layout() { - let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap(); - if root.join(MANIFEST_NAME).is_file() { - let artifacts = Artifacts::from_dir(root).unwrap(); - let kernel = std::path::absolute(root).unwrap().join("build"); - assert_eq!(artifacts.kernel, kernel.join("vmlinux")); - let directory = ArtifactDir::from_dir(root).unwrap(); - assert_eq!(directory.layout(), Layout::Repository); - assert_eq!(directory.kernel(), artifacts.kernel); - } - } - - #[test] - fn explicit_variables_win_and_must_be_complete() { - let directory = tempfile::tempdir().unwrap(); - release(directory.path()); - let (openvmm, kernel, initrd) = (Path::new("o"), Path::new("k"), Path::new("i")); - let artifacts = discover_with( - variables(&[ - (Artifacts::ENV_OPENVMM, openvmm), - (Artifacts::ENV_KERNEL, kernel), - (Artifacts::ENV_INITRD, initrd), - (Artifacts::ENV_DIR, directory.path()), - ]), - None, - no_bundle, - ) - .unwrap(); - assert_eq!(artifacts, Artifacts::new(openvmm, kernel, initrd)); - - let error = discover_with( - variables(&[(Artifacts::ENV_OPENVMM, openvmm)]), - None, - no_bundle, - ) - .unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Unavailable); - } - - #[test] - fn the_directory_variable_precedes_the_executable_directory() { - let variable_dir = tempfile::tempdir().unwrap(); - release(variable_dir.path()); - let exe_dir = tempfile::tempdir().unwrap(); - release(&exe_dir.path().join(Artifacts::BESIDE_EXE_DIR)); - - let artifacts = discover_with( - variables(&[(Artifacts::ENV_DIR, variable_dir.path())]), - Some(exe_dir.path()), - no_bundle, - ) - .unwrap(); - assert!( - artifacts - .kernel - .starts_with(std::path::absolute(variable_dir.path()).unwrap()) - ); - - let artifacts = discover_with(variables(&[]), Some(exe_dir.path()), no_bundle).unwrap(); - assert!(artifacts.kernel.starts_with( - std::path::absolute(exe_dir.path().join(Artifacts::BESIDE_EXE_DIR)).unwrap() - )); - } - - #[test] - fn the_bundle_is_the_last_resort() { - let exe_dir = tempfile::tempdir().unwrap(); - let bundle = Artifacts::new("bundled-openvmm", "bundled-kernel", "bundled-initrd"); - let expected = bundle.clone(); - let artifacts = - discover_with(variables(&[]), Some(exe_dir.path()), || Ok(Some(bundle))).unwrap(); - assert_eq!(artifacts, expected); - - let error = discover_with(variables(&[]), Some(exe_dir.path()), no_bundle).unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Unavailable); - assert!(error.message().contains(Artifacts::ENV_DIR)); - assert!( - error - .message() - .contains("build the nvx crate with the bundled feature") - ); - } - - #[test] - fn incompatible_manifests_are_unavailable() { - let directory = tempfile::tempdir().unwrap(); - let manifest = directory.path().join(MANIFEST_NAME); - fs::write( - &manifest, - r#"{"openvmm":{"microvm_abi_version":2,"control_session_protocol_version":2, - "control_contract_revision":"nvx-microvm-v2-control-v2"}}"#, - ) - .unwrap(); - let error = check_manifest(&manifest).unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Unavailable); - let error = check_manifest(&directory.path().join("missing.json")).unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Unavailable); - } - - #[test] - fn repository_manifest_declares_the_supported_contract() { - let manifest = Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .join(MANIFEST_NAME); - if manifest.is_file() { - check_manifest(&manifest).unwrap(); - } - } - - #[test] - fn guests_name_their_artifacts_as_nvx_py_does() { - assert_eq!( - Guest::ALL.map(Guest::name), - ["alpine", "ubuntu", "azurelinux"] - ); - for guest in Guest::ALL { - assert_eq!(Guest::from_name(guest.name()).unwrap(), guest); - assert_eq!(guest.to_string(), guest.name()); - } - let error = Guest::from_name("Ubuntu").unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Unsupported); - assert_eq!( - error.message(), - "unsupported guest \"Ubuntu\"; choose alpine, ubuntu, azurelinux" - ); - assert_eq!( - Guest::ALL.map(Guest::distribution), - ["Alpine Linux", "Ubuntu Base", "Azure Linux"] - ); - assert_eq!( - Guest::ALL.map(Guest::package_manifest_name), - [ - "initramfs.cpio.gz.packages.json", - "initramfs-ubuntu.cpio.gz.packages.json", - "initramfs-azurelinux.cpio.gz.packages.json", - ] - ); - assert_eq!( - Guest::ALL.map(Guest::distro_layer_name), - [None, Some("ubuntu-distro.erofs"), None] - ); - assert_eq!( - Guest::Ubuntu.distro_manifest_name().as_deref(), - Some("ubuntu-distro.erofs.manifest.json") - ); - assert_eq!(Guest::Alpine.distro_manifest_name(), None); - assert_eq!(Guest::ALL.map(Guest::default_memory_mib), [128, 512, 512]); - } - - #[test] - fn a_checkout_holds_the_guest_artifacts_in_build() { - let directory = tempfile::tempdir().unwrap(); - checkout(directory.path()); - let artifacts = ArtifactDir::from_dir(directory.path()).unwrap(); - let root = std::path::absolute(directory.path()).unwrap(); - let build = root.join("build"); - assert_eq!(artifacts.layout(), Layout::Repository); - assert_eq!(artifacts.root(), root); - assert_eq!( - artifacts.openvmm(), - root.join("openvmm") - .join("target") - .join("release") - .join(openvmm_executable()) - ); - assert_eq!(artifacts.kernel(), build.join("vmlinux")); - assert_eq!( - Guest::ALL.map(|guest| artifacts.initramfs(guest)), - [ - build.join("initramfs.cpio.gz"), - build.join("initramfs-ubuntu.cpio.gz"), - build.join("initramfs-azurelinux.cpio.gz"), - ] - ); - assert_eq!( - artifacts.package_manifest(Guest::Ubuntu), - build.join("initramfs-ubuntu.cpio.gz.packages.json") - ); - assert_eq!( - Guest::ALL.map(|guest| artifacts.distro_layer(guest)), - [None, Some(build.join("ubuntu-distro.erofs")), None] - ); - assert_eq!( - artifacts.distro_manifest(Guest::Ubuntu), - Some(build.join("ubuntu-distro.erofs.manifest.json")) - ); - assert_eq!( - artifacts.artifact("vmlinux.provenance.json"), - build.join("vmlinux.provenance.json") - ); - - // A relative directory is made absolute, and an explicit layout overrides detection. - let relative = ArtifactDir::repository(".").unwrap(); - assert_eq!(relative.root(), std::path::absolute(".").unwrap()); - release(directory.path()); - assert_eq!( - ArtifactDir::repository(directory.path()).unwrap().layout(), - Layout::Repository - ); - } - - #[test] - fn a_release_holds_the_guest_artifacts_in_guest() { - let directory = tempfile::tempdir().unwrap(); - release(directory.path()); - let artifacts = ArtifactDir::from_dir(directory.path()).unwrap(); - let root = std::path::absolute(directory.path()).unwrap(); - let guest = root.join("guest"); - assert_eq!(artifacts.layout(), Layout::Release); - assert_eq!(artifacts, ArtifactDir::release(directory.path()).unwrap()); - assert_eq!( - artifacts.openvmm(), - root.join("bin").join(openvmm_executable()) - ); - assert_eq!(artifacts.kernel(), guest.join("vmlinux")); - assert_eq!( - Guest::ALL.map(|guest| artifacts.initramfs(guest)), - [ - guest.join("initramfs.cpio.gz"), - guest.join("initramfs-ubuntu.cpio.gz"), - guest.join("initramfs-azurelinux.cpio.gz"), - ] - ); - assert_eq!( - artifacts.distro_layer(Guest::Ubuntu), - Some(guest.join("ubuntu-distro.erofs")) - ); - - // The release's artifacts are the ones that discovery finds for its directory. - let discovered = Artifacts::from_dir(directory.path()).unwrap(); - assert_eq!(artifacts.sandbox_artifacts().unwrap(), discovered); - } - - #[test] - fn guest_artifacts_are_required_in_the_order_and_words_of_nvx_py() { - let directory = tempfile::tempdir().unwrap(); - checkout(directory.path()); - let artifacts = ArtifactDir::repository(directory.path()).unwrap(); - fn missing(result: Result, description: &str, path: PathBuf) { - let error = result.unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Unavailable); - assert_eq!( - error.message(), - format!("{description} not found: {}", path.display()) - ); - } - let create = |path: PathBuf| { - fs::create_dir_all(path.parent().unwrap()).unwrap(); - fs::write(path, b"artifact").unwrap(); - }; - missing( - artifacts.sandbox_artifacts(), - "OpenVMM release binary", - artifacts.openvmm(), - ); - create(artifacts.openvmm()); - missing( - artifacts.sandbox_artifacts(), - "Linux direct kernel", - artifacts.kernel(), - ); - create(artifacts.kernel()); - missing( - artifacts.sandbox_artifacts(), - "initramfs", - artifacts.initramfs(Guest::Alpine), - ); - create(artifacts.initramfs(Guest::Alpine)); - let located = artifacts.sandbox_artifacts().unwrap(); - assert_eq!( - [&located.openvmm, &located.kernel, &located.initrd], - [ - &artifacts.openvmm(), - &artifacts.kernel(), - &artifacts.initramfs(Guest::Alpine) - ] - ); - assert_eq!(located.artifact_dir(), Some(&artifacts)); - assert_eq!( - located, - Artifacts::from_repo_layout(directory.path()).unwrap() - ); - - assert_eq!(artifacts.require_openvmm().unwrap(), artifacts.openvmm()); - assert_eq!(artifacts.require_kernel().unwrap(), artifacts.kernel()); - missing( - artifacts.require_initramfs(Guest::Ubuntu), - "Ubuntu Base initramfs", - artifacts.initramfs(Guest::Ubuntu), - ); - missing( - artifacts.require_initramfs(Guest::AzureLinux), - "Azure Linux initramfs", - artifacts.initramfs(Guest::AzureLinux), - ); - create(artifacts.initramfs(Guest::Ubuntu)); - assert_eq!( - artifacts.require_initramfs(Guest::Ubuntu).unwrap(), - artifacts.initramfs(Guest::Ubuntu) - ); - - // A directory is not a regular file. - fs::create_dir_all(artifacts.initramfs(Guest::AzureLinux)).unwrap(); - missing( - artifacts.require_initramfs(Guest::AzureLinux), - "Azure Linux initramfs", - artifacts.initramfs(Guest::AzureLinux), - ); - } - - #[test] - fn artifact_paths_serialize_every_guest_in_order() { - let directory = tempfile::tempdir().unwrap(); - release(directory.path()); - let artifacts = ArtifactDir::from_dir(directory.path()).unwrap(); - let text = |path: PathBuf| Value::from(path.to_str().unwrap()); - let value = serde_json::to_value(artifacts.paths()).unwrap(); - assert_eq!(value["layout"], "release"); - assert_eq!(value["root"], text(artifacts.root().to_path_buf())); - assert_eq!(value["openvmm"], text(artifacts.openvmm())); - assert_eq!(value["kernel"], text(artifacts.kernel())); - let guests = value["guests"].as_array().unwrap(); - assert_eq!(guests.len(), Guest::ALL.len()); - for (paths, guest) in guests.iter().zip(Guest::ALL) { - assert_eq!(paths["name"], guest.name()); - assert_eq!(paths["distribution"], guest.distribution()); - assert_eq!(paths["initramfs"], text(artifacts.initramfs(guest))); - assert_eq!( - paths["package_manifest"], - text(artifacts.package_manifest(guest)) - ); - assert_eq!( - paths["distro_layer"], - artifacts.distro_layer(guest).map_or(Value::Null, text) - ); - assert_eq!( - paths["distro_manifest"], - artifacts.distro_manifest(guest).map_or(Value::Null, text) - ); - } - let repository = ArtifactDir::repository(directory.path()).unwrap(); - assert_eq!( - serde_json::to_value(repository.paths()).unwrap()["layout"], - "repository" - ); - } - - /// Returns the identity of artifacts whose SHA-256 digests are `digests`. - fn identity_of(digests: &[String; 3]) -> RuntimeIdentity { - let [openvmm, kernel, initramfs] = digests - .each_ref() - .map(|digest| ArtifactIdentity::sha256(digest).unwrap()); - RuntimeIdentity::new(openvmm, kernel, initramfs) - } - - /// Writes the build provenance of the artifacts of the checkout at `root`, as `nvx.py` - /// records it, with `digests` for the OpenVMM executable, the kernel, and the initramfs. - fn record_provenance(root: &Path, digests: &[String; 3]) { - let build = root.join("build"); - fs::create_dir_all(&build).unwrap(); - for ((name, field), digest) in [ - (OPENVMM_PROVENANCE_NAME, "executable_sha256"), - (KERNEL_PROVENANCE_NAME, "kernel_sha256"), - (INITRAMFS_PROVENANCE_NAME, "initramfs_sha256"), - ] - .into_iter() - .zip(digests) - { - let text = format!("{{\n \"format\": 1,\n \"{field}\": \"{digest}\"\n}}\n"); - fs::write(build.join(name), text).unwrap(); - } - } - - #[test] - fn a_checkout_identifies_its_runtime_by_the_build_provenance_of_its_artifacts() { - let directory = tempfile::tempdir().unwrap(); - checkout(directory.path()); - let artifacts = ArtifactDir::repository(directory.path()).unwrap(); - let digests = ["a", "b", "c"].map(|digit| digit.repeat(64)); - // Only the metadata counts: the artifacts themselves are neither read nor hashed. - record_provenance(directory.path(), &digests); - assert_eq!(artifacts.runtime_identity().unwrap(), identity_of(&digests)); - - let kernel = artifacts.artifact(KERNEL_PROVENANCE_NAME); - let fails = |content: Option<&str>, problem: &str| { - match content { - Some(content) => fs::write(&kernel, content).unwrap(), - None => fs::remove_file(&kernel).unwrap(), - } - let error = artifacts.runtime_identity().unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Unavailable); - assert_eq!( - error.message(), - format!("the kernel build provenance {} {problem}", kernel.display()) - ); - }; - fails( - None, - "is missing, so the runtime cannot be identified: build the artifact with nvx.py, or \ - install a release with nvx.py download", - ); - fails(Some("{"), "is not valid JSON"); - let digest = &digests[1]; - fails( - Some(&format!(r#"{{"format": 2, "kernel_sha256": "{digest}"}}"#)), - "does not have format 1", - ); - fails( - Some(r#"{"format": 1, "initramfs_sha256": "0"}"#), - "has no SHA-256 digest in kernel_sha256", - ); - let upper = digest.to_ascii_uppercase(); - fails( - Some(&format!(r#"{{"format": 1, "kernel_sha256": "{upper}"}}"#)), - "has no SHA-256 digest in kernel_sha256", - ); - } - - #[test] - fn a_release_identifies_its_runtime_by_its_checksum_list() { - let directory = tempfile::tempdir().unwrap(); - release(directory.path()); - let artifacts = ArtifactDir::release(directory.path()).unwrap(); - let digests = ["1", "2", "3"].map(|digit| digit.repeat(64)); - let other = "f".repeat(64); - let openvmm = format!("bin/{}", openvmm_executable()); - let list = artifacts.root().join(CHECKSUMS_NAME); - let write = |lines: &[(&str, &str)]| { - let text: String = lines - .iter() - .map(|(digest, name)| format!("{digest} {name}\n")) - .collect(); - fs::write(&list, text).unwrap(); - }; - let complete = [ - (other.as_str(), "SOURCE-MANIFEST.json"), - (digests[0].as_str(), openvmm.as_str()), - (other.as_str(), "guest/initramfs-ubuntu.cpio.gz"), - (digests[2].as_str(), "guest/initramfs.cpio.gz"), - (digests[1].as_str(), "guest/vmlinux"), - ]; - write(&complete); - let identity = artifacts.runtime_identity().unwrap(); - assert_eq!(identity, identity_of(&digests)); - - // A checkout that installed the release has the release's identity. - let installed = tempfile::tempdir().unwrap(); - checkout(installed.path()); - record_provenance(installed.path(), &digests); - let checkout = ArtifactDir::repository(installed.path()).unwrap(); - assert_eq!(checkout.runtime_identity().unwrap(), identity); - - let fails = |problem: String| { - let error = artifacts.runtime_identity().unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Unavailable); - assert_eq!( - error.message(), - format!("the release checksum list {} {problem}", list.display()) - ); - }; - write(&complete[..4]); - fails("does not list guest/vmlinux".to_owned()); - let upper = other.to_ascii_uppercase(); - write(&[(upper.as_str(), "guest/vmlinux")]); - fails(format!( - "has a malformed line: {:?}", - format!("{upper} guest/vmlinux") - )); - write(&[(digests[1].as_str(), "")]); - fails(format!( - "has a malformed line: {:?}", - format!("{} ", digests[1]) - )); - write(&[ - (digests[1].as_str(), "guest/vmlinux"), - (other.as_str(), "guest/vmlinux"), - ]); - fails("lists guest/vmlinux twice".to_owned()); - fs::remove_file(&list).unwrap(); - fails( - "is missing, so the runtime cannot be identified: the release is incomplete".to_owned(), - ); - } - - #[cfg(unix)] - #[test] - fn metadata_that_is_not_a_regular_file_fails_at_once() { - use std::os::unix::fs::OpenOptionsExt; - use std::sync::mpsc; - use std::thread; - use std::time::Duration; - - use nix::sys::stat::Mode; - - let installed = tempfile::tempdir().unwrap(); - checkout(installed.path()); - record_provenance( - installed.path(), - &["a", "b", "c"].map(|digit| digit.repeat(64)), - ); - let repository = ArtifactDir::repository(installed.path()).unwrap(); - let extracted = tempfile::tempdir().unwrap(); - release(extracted.path()); - let released = ArtifactDir::release(extracted.path()).unwrap(); - for (artifacts, path, description) in [ - ( - &repository, - repository.artifact(KERNEL_PROVENANCE_NAME), - "kernel build provenance", - ), - ( - &released, - released.root().join(CHECKSUMS_NAME), - "release checksum list", - ), - ] { - let _ = fs::remove_file(&path); - nix::unistd::mkfifo(&path, Mode::S_IRUSR | Mode::S_IWUSR).unwrap(); - // An open of a FIFO for reading that waits would wait for a writer, and a read of one - // would wait until its writer closes it, so the identity is read on a thread that the - // test can leave behind. - let refused = || { - let (sender, receiver) = mpsc::channel(); - let artifacts = artifacts.clone(); - thread::spawn(move || sender.send(artifacts.runtime_identity()).unwrap()); - let error = receiver - .recv_timeout(Duration::from_secs(10)) - .unwrap_or_else(|_| panic!("the read of the {description} waits on the FIFO")) - .unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Unavailable); - assert_eq!( - error.message(), - format!("the {description} {} is not a regular file", path.display()) - ); - }; - refused(); - // A FIFO that has a writer opens at once, and is refused all the same, before a read - // waits for the writer to close it. - let reader = fs::OpenOptions::new() - .read(true) - .custom_flags(nix::fcntl::OFlag::O_NONBLOCK.bits()) - .open(&path) - .unwrap(); - let writer = fs::OpenOptions::new() - .write(true) - .custom_flags(nix::fcntl::OFlag::O_NONBLOCK.bits()) - .open(&path) - .unwrap(); - drop(reader); - refused(); - drop(writer); - } - } - - #[test] - fn located_artifacts_have_the_identity_of_their_directory() { - let directory = tempfile::tempdir().unwrap(); - release(directory.path()); - let digests = ["4", "5", "6"].map(|digit| digit.repeat(64)); - let text: String = [ - (&digests[0], format!("bin/{}", openvmm_executable())), - (&digests[1], "guest/vmlinux".to_owned()), - (&digests[2], "guest/initramfs.cpio.gz".to_owned()), - ] - .iter() - .map(|(digest, name)| format!("{digest} {name}\n")) - .collect(); - fs::write(directory.path().join(CHECKSUMS_NAME), text).unwrap(); - let located = Artifacts::from_dir(directory.path()).unwrap(); - assert_eq!( - located.artifact_dir(), - Some(&ArtifactDir::release(directory.path()).unwrap()) - ); - assert_eq!(located.runtime_identity().unwrap(), identity_of(&digests)); - - // Artifacts named by path have no install metadata. - let named = Artifacts::new(&located.openvmm, &located.kernel, &located.initrd); - assert_eq!(named.artifact_dir(), None); - let error = named.runtime_identity().unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Unavailable); - assert!( - error.message().contains("supply their runtime identity"), - "{error}" - ); - // Nor does an artifact that replaced one of the directory's. - let mut replaced = located.clone(); - replaced.kernel = directory.path().join("vmlinux"); - let error = replaced.runtime_identity().unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Unavailable); - assert_eq!( - error.message(), - format!( - "the guest kernel {} is not the one of {}, whose install metadata identifies the \ - artifacts", - replaced.kernel.display(), - located.artifact_dir().unwrap().root().display() - ) - ); - } - - #[cfg(feature = "bundled")] - #[test] - fn the_bundle_is_a_compatible_release() { - let bundled = Artifacts::bundled().unwrap(); - // A build that was asked to stage a release must have staged it. - let requested = env::var_os("NVX_BUNDLE_DIR").is_some() - && env::var_os("NVX_BUNDLE").is_none_or(|value| value != "skip"); - if requested && (cfg!(target_os = "linux") || cfg!(windows)) { - assert!(bundled.is_some(), "NVX_BUNDLE_DIR was not staged"); - } - if let Some(artifacts) = bundled { - for path in [&artifacts.openvmm, &artifacts.kernel, &artifacts.initrd] { - assert!(path.is_file(), "{} is missing", path.display()); - } - } - } -} +mod tests; diff --git a/nvx-rs/src/host/artifacts/require.rs b/nvx-rs/src/host/artifacts/require.rs new file mode 100644 index 000000000..a941a8b41 --- /dev/null +++ b/nvx-rs/src/host/artifacts/require.rs @@ -0,0 +1,35 @@ +//! The checks that the artifacts that a launch runs are regular files. + +use std::path::{Path, PathBuf}; + +use crate::error::{Error, ErrorCode, Result}; +use crate::host::paths; + +/// Checks that `path` is a regular file, as `require_file` of `nvx.py` does, and returns it, or +/// fails with [`ErrorCode::ArtifactMissing`]. +pub(super) fn required(path: PathBuf, description: &str) -> Result { + require_artifact(&path, description)?; + Ok(path) +} + +/// Checks that an artifact that a launch runs, such as the OpenVMM executable, is a regular +/// file, as [`require_existing`] does, and fails with [`ErrorCode::ArtifactMissing`]. +pub(crate) fn require_artifact(path: &Path, description: &str) -> Result<()> { + require_existing(path, description, ErrorCode::ArtifactMissing) +} + +/// Checks that `path` is a regular file, following links, as `require_file` of `nvx.py` does, +/// and fails with `code` and the message `{description} not found: {path}`, with `path` spelled +/// with its components alone, as [`Path::components`] reads them. +pub(crate) fn require_existing(path: &Path, description: &str, code: ErrorCode) -> Result<()> { + if path.is_file() { + return Ok(()); + } + Err(Error::new( + code, + format!( + "{description} not found: {}", + paths::normalized(path).display() + ), + )) +} diff --git a/nvx-rs/src/host/artifacts/tests.rs b/nvx-rs/src/host/artifacts/tests.rs new file mode 100644 index 000000000..d700e5729 --- /dev/null +++ b/nvx-rs/src/host/artifacts/tests.rs @@ -0,0 +1,728 @@ +//! Tests of the discovery of artifacts, of the directories that hold them, and of their +//! identities. + +use std::collections::HashMap; + +use super::*; +use crate::ErrorKind; +use crate::host::store::ArtifactIdentity; + +const COMPATIBLE_MANIFEST: &str = r#"{"openvmm":{"microvm_abi_version":2, + "control_session_protocol_version":1, + "control_contract_revision":"nvx-microvm-v2-control-v2"}}"#; + +fn release(root: &Path) { + for artifact in [ + Path::new("bin").join(openvmm_executable()), + Path::new("guest").join("vmlinux"), + Path::new("guest").join("initramfs.cpio.gz"), + ] { + let path = root.join(artifact); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, b"artifact").unwrap(); + } + fs::write(root.join(MANIFEST_NAME), COMPATIBLE_MANIFEST).unwrap(); +} + +/// Creates a repository checkout with its tracked guest sources and no build outputs. +fn checkout(root: &Path) { + let sources = root.join("guest").join("common"); + fs::create_dir_all(&sources).unwrap(); + fs::write(sources.join("init"), b"#!/bin/sh").unwrap(); + fs::write(root.join(MANIFEST_NAME), COMPATIBLE_MANIFEST).unwrap(); +} + +fn no_bundle() -> Result> { + Ok(None) +} + +fn variables(pairs: &[(&str, &Path)]) -> impl Fn(&str) -> Option { + let map: HashMap = pairs + .iter() + .map(|(name, value)| ((*name).to_owned(), value.as_os_str().to_owned())) + .collect(); + move |name| map.get(name).cloned() +} + +#[test] +fn release_layout_names_the_guest_artifacts() { + let directory = tempfile::tempdir().unwrap(); + release(directory.path()); + let artifacts = Artifacts::from_dir(directory.path()).unwrap(); + let root = std::path::absolute(directory.path()).unwrap(); + assert_eq!( + artifacts.openvmm, + root.join("bin").join(openvmm_executable()) + ); + assert_eq!(artifacts.kernel, root.join("guest").join("vmlinux")); + assert_eq!( + artifacts.initrd, + root.join("guest").join("initramfs.cpio.gz") + ); + // A directory that the caller hands over, such as a `TempDir`, lives until the + // constructor returns, after it reads the manifest. + assert_eq!(Artifacts::from_release_dir(directory).unwrap(), artifacts); +} + +#[test] +fn repository_layout_is_detected_beside_the_guest_sources() { + let directory = tempfile::tempdir().unwrap(); + checkout(directory.path()); + let root = std::path::absolute(directory.path()).unwrap(); + let expected = |artifacts: &Artifacts| { + assert_eq!(artifacts.kernel, root.join("build").join("vmlinux")); + assert_eq!( + artifacts.initrd, + root.join("build").join("initramfs.cpio.gz") + ); + assert_eq!( + artifacts.openvmm, + root.join("openvmm") + .join("target") + .join("release") + .join(openvmm_executable()) + ); + }; + expected(&Artifacts::from_dir(directory.path()).unwrap()); + expected(&Artifacts::from_repo_layout(directory.path()).unwrap()); + + // Build outputs do not turn a checkout into a release. + let build = directory.path().join("build"); + fs::create_dir_all(&build).unwrap(); + fs::write(build.join("vmlinux"), b"kernel").unwrap(); + fs::write(build.join("initramfs.cpio.gz"), b"initrd").unwrap(); + expected(&Artifacts::from_dir(directory.path()).unwrap()); +} + +#[test] +fn the_directory_variable_accepts_a_repository_checkout() { + let directory = tempfile::tempdir().unwrap(); + checkout(directory.path()); + let artifacts = discover_with( + variables(&[(Artifacts::ENV_DIR, directory.path())]), + None, + no_bundle, + ) + .unwrap(); + let build = std::path::absolute(directory.path()).unwrap().join("build"); + assert_eq!(artifacts.kernel, build.join("vmlinux")); +} + +#[test] +fn this_repository_uses_the_repository_layout() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap(); + if root.join(MANIFEST_NAME).is_file() { + let artifacts = Artifacts::from_dir(root).unwrap(); + let kernel = std::path::absolute(root).unwrap().join("build"); + assert_eq!(artifacts.kernel, kernel.join("vmlinux")); + let directory = ArtifactDir::from_dir(root).unwrap(); + assert_eq!(directory.layout(), Layout::Repository); + assert_eq!(directory.kernel(), artifacts.kernel); + } +} + +#[test] +fn explicit_variables_win_and_must_be_complete() { + let directory = tempfile::tempdir().unwrap(); + release(directory.path()); + let (openvmm, kernel, initrd) = (Path::new("o"), Path::new("k"), Path::new("i")); + let artifacts = discover_with( + variables(&[ + (Artifacts::ENV_OPENVMM, openvmm), + (Artifacts::ENV_KERNEL, kernel), + (Artifacts::ENV_INITRD, initrd), + (Artifacts::ENV_DIR, directory.path()), + ]), + None, + no_bundle, + ) + .unwrap(); + assert_eq!(artifacts, Artifacts::new(openvmm, kernel, initrd)); + + let error = discover_with( + variables(&[(Artifacts::ENV_OPENVMM, openvmm)]), + None, + no_bundle, + ) + .unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unavailable); +} + +#[test] +fn the_directory_variable_precedes_the_executable_directory() { + let variable_dir = tempfile::tempdir().unwrap(); + release(variable_dir.path()); + let exe_dir = tempfile::tempdir().unwrap(); + release(&exe_dir.path().join(Artifacts::BESIDE_EXE_DIR)); + + let artifacts = discover_with( + variables(&[(Artifacts::ENV_DIR, variable_dir.path())]), + Some(exe_dir.path()), + no_bundle, + ) + .unwrap(); + assert!( + artifacts + .kernel + .starts_with(std::path::absolute(variable_dir.path()).unwrap()) + ); + + let artifacts = discover_with(variables(&[]), Some(exe_dir.path()), no_bundle).unwrap(); + assert!( + artifacts.kernel.starts_with( + std::path::absolute(exe_dir.path().join(Artifacts::BESIDE_EXE_DIR)).unwrap() + ) + ); +} + +#[test] +fn the_bundle_is_the_last_resort() { + let exe_dir = tempfile::tempdir().unwrap(); + let bundle = Artifacts::new("bundled-openvmm", "bundled-kernel", "bundled-initrd"); + let expected = bundle.clone(); + let artifacts = + discover_with(variables(&[]), Some(exe_dir.path()), || Ok(Some(bundle))).unwrap(); + assert_eq!(artifacts, expected); + + let error = discover_with(variables(&[]), Some(exe_dir.path()), no_bundle).unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unavailable); + assert!(error.message().contains(Artifacts::ENV_DIR)); + assert!( + error + .message() + .contains("build the nvx crate with the bundled feature") + ); +} + +#[test] +fn incompatible_manifests_are_unavailable() { + let directory = tempfile::tempdir().unwrap(); + let manifest = directory.path().join(MANIFEST_NAME); + fs::write( + &manifest, + r#"{"openvmm":{"microvm_abi_version":2,"control_session_protocol_version":2, + "control_contract_revision":"nvx-microvm-v2-control-v2"}}"#, + ) + .unwrap(); + let error = check_manifest(&manifest).unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unavailable); + let error = check_manifest(&directory.path().join("missing.json")).unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unavailable); +} + +#[test] +fn repository_manifest_declares_the_supported_contract() { + let manifest = Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .join(MANIFEST_NAME); + if manifest.is_file() { + check_manifest(&manifest).unwrap(); + } +} + +#[test] +fn guests_name_their_artifacts_as_nvx_py_does() { + assert_eq!( + Guest::ALL.map(Guest::name), + ["alpine", "ubuntu", "azurelinux"] + ); + for guest in Guest::ALL { + assert_eq!(Guest::from_name(guest.name()).unwrap(), guest); + assert_eq!(guest.to_string(), guest.name()); + } + let error = Guest::from_name("Ubuntu").unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unsupported); + assert_eq!( + error.message(), + "unsupported guest \"Ubuntu\"; choose alpine, ubuntu, azurelinux" + ); + assert_eq!( + Guest::ALL.map(Guest::distribution), + ["Alpine Linux", "Ubuntu Base", "Azure Linux"] + ); + assert_eq!( + Guest::ALL.map(Guest::package_manifest_name), + [ + "initramfs.cpio.gz.packages.json", + "initramfs-ubuntu.cpio.gz.packages.json", + "initramfs-azurelinux.cpio.gz.packages.json", + ] + ); + assert_eq!( + Guest::ALL.map(Guest::distro_layer_name), + [None, Some("ubuntu-distro.erofs"), None] + ); + assert_eq!( + Guest::Ubuntu.distro_manifest_name().as_deref(), + Some("ubuntu-distro.erofs.manifest.json") + ); + assert_eq!(Guest::Alpine.distro_manifest_name(), None); + assert_eq!(Guest::ALL.map(Guest::default_memory_mib), [128, 512, 512]); +} + +#[test] +fn a_checkout_holds_the_guest_artifacts_in_build() { + let directory = tempfile::tempdir().unwrap(); + checkout(directory.path()); + let artifacts = ArtifactDir::from_dir(directory.path()).unwrap(); + let root = std::path::absolute(directory.path()).unwrap(); + let build = root.join("build"); + assert_eq!(artifacts.layout(), Layout::Repository); + assert_eq!(artifacts.root(), root); + assert_eq!( + artifacts.openvmm(), + root.join("openvmm") + .join("target") + .join("release") + .join(openvmm_executable()) + ); + assert_eq!(artifacts.kernel(), build.join("vmlinux")); + assert_eq!( + Guest::ALL.map(|guest| artifacts.initramfs(guest)), + [ + build.join("initramfs.cpio.gz"), + build.join("initramfs-ubuntu.cpio.gz"), + build.join("initramfs-azurelinux.cpio.gz"), + ] + ); + assert_eq!( + artifacts.package_manifest(Guest::Ubuntu), + build.join("initramfs-ubuntu.cpio.gz.packages.json") + ); + assert_eq!( + Guest::ALL.map(|guest| artifacts.distro_layer(guest)), + [None, Some(build.join("ubuntu-distro.erofs")), None] + ); + assert_eq!( + artifacts.distro_manifest(Guest::Ubuntu), + Some(build.join("ubuntu-distro.erofs.manifest.json")) + ); + assert_eq!( + artifacts.artifact("vmlinux.provenance.json"), + build.join("vmlinux.provenance.json") + ); + + // A relative directory is made absolute, and an explicit layout overrides detection. + let relative = ArtifactDir::repository(".").unwrap(); + assert_eq!(relative.root(), std::path::absolute(".").unwrap()); + release(directory.path()); + assert_eq!( + ArtifactDir::repository(directory.path()).unwrap().layout(), + Layout::Repository + ); +} + +#[test] +fn a_release_holds_the_guest_artifacts_in_guest() { + let directory = tempfile::tempdir().unwrap(); + release(directory.path()); + let artifacts = ArtifactDir::from_dir(directory.path()).unwrap(); + let root = std::path::absolute(directory.path()).unwrap(); + let guest = root.join("guest"); + assert_eq!(artifacts.layout(), Layout::Release); + assert_eq!(artifacts, ArtifactDir::release(directory.path()).unwrap()); + assert_eq!( + artifacts.openvmm(), + root.join("bin").join(openvmm_executable()) + ); + assert_eq!(artifacts.kernel(), guest.join("vmlinux")); + assert_eq!( + Guest::ALL.map(|guest| artifacts.initramfs(guest)), + [ + guest.join("initramfs.cpio.gz"), + guest.join("initramfs-ubuntu.cpio.gz"), + guest.join("initramfs-azurelinux.cpio.gz"), + ] + ); + assert_eq!( + artifacts.distro_layer(Guest::Ubuntu), + Some(guest.join("ubuntu-distro.erofs")) + ); + + // The release's artifacts are the ones that discovery finds for its directory. + let discovered = Artifacts::from_dir(directory.path()).unwrap(); + assert_eq!(artifacts.sandbox_artifacts().unwrap(), discovered); +} + +#[test] +fn guest_artifacts_are_required_in_the_order_and_words_of_nvx_py() { + let directory = tempfile::tempdir().unwrap(); + checkout(directory.path()); + let artifacts = ArtifactDir::repository(directory.path()).unwrap(); + fn missing(result: Result, description: &str, path: PathBuf) { + let error = result.unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unavailable); + assert_eq!( + error.message(), + format!("{description} not found: {}", path.display()) + ); + } + let create = |path: PathBuf| { + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, b"artifact").unwrap(); + }; + missing( + artifacts.sandbox_artifacts(), + "OpenVMM release binary", + artifacts.openvmm(), + ); + create(artifacts.openvmm()); + missing( + artifacts.sandbox_artifacts(), + "Linux direct kernel", + artifacts.kernel(), + ); + create(artifacts.kernel()); + missing( + artifacts.sandbox_artifacts(), + "initramfs", + artifacts.initramfs(Guest::Alpine), + ); + create(artifacts.initramfs(Guest::Alpine)); + let located = artifacts.sandbox_artifacts().unwrap(); + assert_eq!( + [&located.openvmm, &located.kernel, &located.initrd], + [ + &artifacts.openvmm(), + &artifacts.kernel(), + &artifacts.initramfs(Guest::Alpine) + ] + ); + assert_eq!(located.artifact_dir(), Some(&artifacts)); + assert_eq!( + located, + Artifacts::from_repo_layout(directory.path()).unwrap() + ); + + assert_eq!(artifacts.require_openvmm().unwrap(), artifacts.openvmm()); + assert_eq!(artifacts.require_kernel().unwrap(), artifacts.kernel()); + missing( + artifacts.require_initramfs(Guest::Ubuntu), + "Ubuntu Base initramfs", + artifacts.initramfs(Guest::Ubuntu), + ); + missing( + artifacts.require_initramfs(Guest::AzureLinux), + "Azure Linux initramfs", + artifacts.initramfs(Guest::AzureLinux), + ); + create(artifacts.initramfs(Guest::Ubuntu)); + assert_eq!( + artifacts.require_initramfs(Guest::Ubuntu).unwrap(), + artifacts.initramfs(Guest::Ubuntu) + ); + + // A directory is not a regular file. + fs::create_dir_all(artifacts.initramfs(Guest::AzureLinux)).unwrap(); + missing( + artifacts.require_initramfs(Guest::AzureLinux), + "Azure Linux initramfs", + artifacts.initramfs(Guest::AzureLinux), + ); +} + +#[test] +fn artifact_paths_serialize_every_guest_in_order() { + let directory = tempfile::tempdir().unwrap(); + release(directory.path()); + let artifacts = ArtifactDir::from_dir(directory.path()).unwrap(); + let text = |path: PathBuf| Value::from(path.to_str().unwrap()); + let value = serde_json::to_value(artifacts.paths()).unwrap(); + assert_eq!(value["layout"], "release"); + assert_eq!(value["root"], text(artifacts.root().to_path_buf())); + assert_eq!(value["openvmm"], text(artifacts.openvmm())); + assert_eq!(value["kernel"], text(artifacts.kernel())); + let guests = value["guests"].as_array().unwrap(); + assert_eq!(guests.len(), Guest::ALL.len()); + for (paths, guest) in guests.iter().zip(Guest::ALL) { + assert_eq!(paths["name"], guest.name()); + assert_eq!(paths["distribution"], guest.distribution()); + assert_eq!(paths["initramfs"], text(artifacts.initramfs(guest))); + assert_eq!( + paths["package_manifest"], + text(artifacts.package_manifest(guest)) + ); + assert_eq!( + paths["distro_layer"], + artifacts.distro_layer(guest).map_or(Value::Null, text) + ); + assert_eq!( + paths["distro_manifest"], + artifacts.distro_manifest(guest).map_or(Value::Null, text) + ); + } + let repository = ArtifactDir::repository(directory.path()).unwrap(); + assert_eq!( + serde_json::to_value(repository.paths()).unwrap()["layout"], + "repository" + ); +} + +/// Returns the identity of artifacts whose SHA-256 digests are `digests`. +fn identity_of(digests: &[String; 3]) -> RuntimeIdentity { + let [openvmm, kernel, initramfs] = digests + .each_ref() + .map(|digest| ArtifactIdentity::sha256(digest).unwrap()); + RuntimeIdentity::new(openvmm, kernel, initramfs) +} + +/// Writes the build provenance of the artifacts of the checkout at `root`, as `nvx.py` +/// records it, with `digests` for the OpenVMM executable, the kernel, and the initramfs. +fn record_provenance(root: &Path, digests: &[String; 3]) { + let build = root.join("build"); + fs::create_dir_all(&build).unwrap(); + for ((name, field), digest) in [ + (OPENVMM_PROVENANCE_NAME, "executable_sha256"), + (KERNEL_PROVENANCE_NAME, "kernel_sha256"), + (INITRAMFS_PROVENANCE_NAME, "initramfs_sha256"), + ] + .into_iter() + .zip(digests) + { + let text = format!("{{\n \"format\": 1,\n \"{field}\": \"{digest}\"\n}}\n"); + fs::write(build.join(name), text).unwrap(); + } +} + +#[test] +fn a_checkout_identifies_its_runtime_by_the_build_provenance_of_its_artifacts() { + let directory = tempfile::tempdir().unwrap(); + checkout(directory.path()); + let artifacts = ArtifactDir::repository(directory.path()).unwrap(); + let digests = ["a", "b", "c"].map(|digit| digit.repeat(64)); + // Only the metadata counts: the artifacts themselves are neither read nor hashed. + record_provenance(directory.path(), &digests); + assert_eq!(artifacts.runtime_identity().unwrap(), identity_of(&digests)); + + let kernel = artifacts.artifact(KERNEL_PROVENANCE_NAME); + let fails = |content: Option<&str>, problem: &str| { + match content { + Some(content) => fs::write(&kernel, content).unwrap(), + None => fs::remove_file(&kernel).unwrap(), + } + let error = artifacts.runtime_identity().unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unavailable); + assert_eq!( + error.message(), + format!("the kernel build provenance {} {problem}", kernel.display()) + ); + }; + fails( + None, + "is missing, so the runtime cannot be identified: build the artifact with nvx.py, or \ + install a release with nvx.py download", + ); + fails(Some("{"), "is not valid JSON"); + let digest = &digests[1]; + fails( + Some(&format!(r#"{{"format": 2, "kernel_sha256": "{digest}"}}"#)), + "does not have format 1", + ); + fails( + Some(r#"{"format": 1, "initramfs_sha256": "0"}"#), + "has no SHA-256 digest in kernel_sha256", + ); + let upper = digest.to_ascii_uppercase(); + fails( + Some(&format!(r#"{{"format": 1, "kernel_sha256": "{upper}"}}"#)), + "has no SHA-256 digest in kernel_sha256", + ); +} + +#[test] +fn a_release_identifies_its_runtime_by_its_checksum_list() { + let directory = tempfile::tempdir().unwrap(); + release(directory.path()); + let artifacts = ArtifactDir::release(directory.path()).unwrap(); + let digests = ["1", "2", "3"].map(|digit| digit.repeat(64)); + let other = "f".repeat(64); + let openvmm = format!("bin/{}", openvmm_executable()); + let list = artifacts.root().join(CHECKSUMS_NAME); + let write = |lines: &[(&str, &str)]| { + let text: String = lines + .iter() + .map(|(digest, name)| format!("{digest} {name}\n")) + .collect(); + fs::write(&list, text).unwrap(); + }; + let complete = [ + (other.as_str(), "SOURCE-MANIFEST.json"), + (digests[0].as_str(), openvmm.as_str()), + (other.as_str(), "guest/initramfs-ubuntu.cpio.gz"), + (digests[2].as_str(), "guest/initramfs.cpio.gz"), + (digests[1].as_str(), "guest/vmlinux"), + ]; + write(&complete); + let identity = artifacts.runtime_identity().unwrap(); + assert_eq!(identity, identity_of(&digests)); + + // A checkout that installed the release has the release's identity. + let installed = tempfile::tempdir().unwrap(); + checkout(installed.path()); + record_provenance(installed.path(), &digests); + let checkout = ArtifactDir::repository(installed.path()).unwrap(); + assert_eq!(checkout.runtime_identity().unwrap(), identity); + + let fails = |problem: String| { + let error = artifacts.runtime_identity().unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unavailable); + assert_eq!( + error.message(), + format!("the release checksum list {} {problem}", list.display()) + ); + }; + write(&complete[..4]); + fails("does not list guest/vmlinux".to_owned()); + let upper = other.to_ascii_uppercase(); + write(&[(upper.as_str(), "guest/vmlinux")]); + fails(format!( + "has a malformed line: {:?}", + format!("{upper} guest/vmlinux") + )); + write(&[(digests[1].as_str(), "")]); + fails(format!( + "has a malformed line: {:?}", + format!("{} ", digests[1]) + )); + write(&[ + (digests[1].as_str(), "guest/vmlinux"), + (other.as_str(), "guest/vmlinux"), + ]); + fails("lists guest/vmlinux twice".to_owned()); + fs::remove_file(&list).unwrap(); + fails("is missing, so the runtime cannot be identified: the release is incomplete".to_owned()); +} + +#[cfg(unix)] +#[test] +fn metadata_that_is_not_a_regular_file_fails_at_once() { + use std::os::unix::fs::OpenOptionsExt; + use std::sync::mpsc; + use std::thread; + use std::time::Duration; + + use nix::sys::stat::Mode; + + let installed = tempfile::tempdir().unwrap(); + checkout(installed.path()); + record_provenance( + installed.path(), + &["a", "b", "c"].map(|digit| digit.repeat(64)), + ); + let repository = ArtifactDir::repository(installed.path()).unwrap(); + let extracted = tempfile::tempdir().unwrap(); + release(extracted.path()); + let released = ArtifactDir::release(extracted.path()).unwrap(); + for (artifacts, path, description) in [ + ( + &repository, + repository.artifact(KERNEL_PROVENANCE_NAME), + "kernel build provenance", + ), + ( + &released, + released.root().join(CHECKSUMS_NAME), + "release checksum list", + ), + ] { + let _ = fs::remove_file(&path); + nix::unistd::mkfifo(&path, Mode::S_IRUSR | Mode::S_IWUSR).unwrap(); + // An open of a FIFO for reading that waits would wait for a writer, and a read of one + // would wait until its writer closes it, so the identity is read on a thread that the + // test can leave behind. + let refused = || { + let (sender, receiver) = mpsc::channel(); + let artifacts = artifacts.clone(); + thread::spawn(move || sender.send(artifacts.runtime_identity()).unwrap()); + let error = receiver + .recv_timeout(Duration::from_secs(10)) + .unwrap_or_else(|_| panic!("the read of the {description} waits on the FIFO")) + .unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unavailable); + assert_eq!( + error.message(), + format!("the {description} {} is not a regular file", path.display()) + ); + }; + refused(); + // A FIFO that has a writer opens at once, and is refused all the same, before a read + // waits for the writer to close it. + let reader = fs::OpenOptions::new() + .read(true) + .custom_flags(nix::fcntl::OFlag::O_NONBLOCK.bits()) + .open(&path) + .unwrap(); + let writer = fs::OpenOptions::new() + .write(true) + .custom_flags(nix::fcntl::OFlag::O_NONBLOCK.bits()) + .open(&path) + .unwrap(); + drop(reader); + refused(); + drop(writer); + } +} + +#[test] +fn located_artifacts_have_the_identity_of_their_directory() { + let directory = tempfile::tempdir().unwrap(); + release(directory.path()); + let digests = ["4", "5", "6"].map(|digit| digit.repeat(64)); + let text: String = [ + (&digests[0], format!("bin/{}", openvmm_executable())), + (&digests[1], "guest/vmlinux".to_owned()), + (&digests[2], "guest/initramfs.cpio.gz".to_owned()), + ] + .iter() + .map(|(digest, name)| format!("{digest} {name}\n")) + .collect(); + fs::write(directory.path().join(CHECKSUMS_NAME), text).unwrap(); + let located = Artifacts::from_dir(directory.path()).unwrap(); + assert_eq!( + located.artifact_dir(), + Some(&ArtifactDir::release(directory.path()).unwrap()) + ); + assert_eq!(located.runtime_identity().unwrap(), identity_of(&digests)); + + // Artifacts named by path have no install metadata. + let named = Artifacts::new(&located.openvmm, &located.kernel, &located.initrd); + assert_eq!(named.artifact_dir(), None); + let error = named.runtime_identity().unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unavailable); + assert!( + error.message().contains("supply their runtime identity"), + "{error}" + ); + // Nor does an artifact that replaced one of the directory's. + let mut replaced = located.clone(); + replaced.kernel = directory.path().join("vmlinux"); + let error = replaced.runtime_identity().unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unavailable); + assert_eq!( + error.message(), + format!( + "the guest kernel {} is not the one of {}, whose install metadata identifies the \ + artifacts", + replaced.kernel.display(), + located.artifact_dir().unwrap().root().display() + ) + ); +} + +#[cfg(feature = "bundled")] +#[test] +fn the_bundle_is_a_compatible_release() { + let bundled = Artifacts::bundled().unwrap(); + // A build that was asked to stage a release must have staged it. + let requested = env::var_os("NVX_BUNDLE_DIR").is_some() + && env::var_os("NVX_BUNDLE").is_none_or(|value| value != "skip"); + if requested && (cfg!(target_os = "linux") || cfg!(windows)) { + assert!(bundled.is_some(), "NVX_BUNDLE_DIR was not staged"); + } + if let Some(artifacts) = bundled { + for path in [&artifacts.openvmm, &artifacts.kernel, &artifacts.initrd] { + assert!(path.is_file(), "{} is missing", path.display()); + } + } +} From 69c04ec8847178242639f1a72897f1ce5300ce3e Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Mon, 26 Oct 2026 18:44:09 -0700 Subject: [PATCH 4/6] nvx-rs: split the parser and one-shot runs out of the sandbox command host/cli/sandbox/mod.rs held, in 888 lines, the parser of nvx-rs sandbox, from its usage and help through the network that its network options describe, the operations that run with the parsed options, and the OpenVMM process of a one-shot run. Split the parser and that process into new files beside it, as #898 lays them out: - options.rs: the parser, from SYNTAX through sandbox_network, with Options and its Default and Receiver implementations; - one_shot.rs: run_openvmm, OneShot, end_interrupted, and spawn_error. Artifacts, error_with_cause, run, run_plan, and prepare_run stay in mod.rs, beside the lifecycle, lock, state, and tests submodules. The code moves unchanged. The items that cli, the parent module, takes from its sandbox module, SYNTAX, HYPERVISORS, MOUNT_OWNERS, MAX_MEMORY_MIB, OPTIONS, parse, hypervisor, path_text, and run_openvmm, were pub(super) there, so they become pub(in crate::host::cli), which names the same module from their new files, and mod.rs re-exports them, pub(super), at their paths. Only cli's tests take SYNTAX and OPTIONS, since parse, which reads them in production, moves with them, so they are re-exported for tests alone, as the crate's other re-exports for tests are. mod.rs also re-exports Options and path_string under their pub(crate) visibility, and imports sandbox_network, which run calls, and which becomes pub(super). rustfmt wraps the signatures of sandbox_network and run_openvmm, which their visibilities make too long for one line. spawn_error keeps its pub(crate) visibility; only OneShot, beside it, calls it. The tests of the sandbox command import SandboxLayer, PolicyKind, and PolicyPathOption, which they took from mod.rs's imports through their glob, and which only the parser uses now. The moved documentation links only to items that its new files import, or by crate paths, so its links keep their targets (#896, invariant 6). Validation at this commit, on Windows and on Linux under WSL 2: rustdoc's JSON lists the same public API as on dev, for Windows (9,632 entries) and Linux (13,779), and, with private items, every intra-doc link of the crate keeps its target, with the items of the new modules named as their parent's. The lists of tests, with all features, with default features, and of the library without default features, are those of dev on Windows (841, 695, and 201) and Linux (1,440, 681, and 201). Clippy with warnings denied passes for Windows and Linux, and cargo check for macOS, the host-module guard passes, and the library tests with all features pass on Windows (700) and Linux (1,237). Part of #898. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nvx-rs/src/host/cli/sandbox/mod.rs | 584 +----------------------- nvx-rs/src/host/cli/sandbox/one_shot.rs | 150 ++++++ nvx-rs/src/host/cli/sandbox/options.rs | 438 ++++++++++++++++++ nvx-rs/src/host/cli/sandbox/tests.rs | 4 +- 4 files changed, 605 insertions(+), 571 deletions(-) create mode 100644 nvx-rs/src/host/cli/sandbox/one_shot.rs create mode 100644 nvx-rs/src/host/cli/sandbox/options.rs diff --git a/nvx-rs/src/host/cli/sandbox/mod.rs b/nvx-rs/src/host/cli/sandbox/mod.rs index 2004c047a..9fb28857b 100644 --- a/nvx-rs/src/host/cli/sandbox/mod.rs +++ b/nvx-rs/src/host/cli/sandbox/mod.rs @@ -17,459 +17,36 @@ mod lifecycle; mod lock; +mod one_shot; +mod options; mod state; -use std::path::Path; -#[cfg(not(windows))] -use std::process::{Child, Command}; -use std::thread; -use std::time::{Duration, Instant}; +use std::time::Instant; -use super::args::{Parser, Receiver, Spec, Syntax, choice, integer}; use super::artifacts::{Located, OPENVMM_VARIABLE, locate}; -use super::network::{NativeRules, NetworkOptions}; use super::shell::format_command; use super::{Exit, Result, output}; use crate::ErrorCode; use crate::host::artifacts::{Guest, KERNEL_NAME, require_artifact}; -use crate::host::network::{ - Access, EgressRule, EgressRules, HostLoopback, LoopbackForward, NetworkPolicy, NetworkProxy, -}; -use crate::host::paths; -use crate::host::platform; use crate::host::sandbox::distro::{DISTRO_POLICY_REVISION, DistroBinding}; -use crate::host::sandbox::mount::{MountOwner, PolicyKind, plan_mounts, validate_mounts}; +use crate::host::sandbox::mount::{plan_mounts, validate_mounts}; use crate::host::sandbox::options::{ - PolicyPathOption, SandboxOperation, SandboxOptions, sandbox_mounts, - validate_sandbox_systemd_policy, + SandboxOperation, SandboxOptions, sandbox_mounts, validate_sandbox_systemd_policy, }; -use crate::host::sandbox::plan::guest_gateway; use crate::host::sandbox::scratch::{CLAIM_RELEASE_WAIT, ScratchBinding}; -use crate::host::sandbox::{ - DEFAULT_ENTRYPOINT, DEFAULT_HOSTNAME, DEFAULT_WORKLOAD_IDENTITY, PIDS_MAX_LIMIT, - PreparedLaunch, SandboxLaunch, SandboxLayer, SandboxNetwork, SandboxPlan, SandboxSettings, - parse_workload_identity, -}; +use crate::host::sandbox::{PreparedLaunch, SandboxLaunch, SandboxPlan, SandboxSettings}; use crate::host::store::RuntimeIdentity; -use crate::host::supervisor::Inherited; pub(crate) use self::lifecycle::ProvisionConfig; -use self::state::RecordedDistro; - -/// The usage and help of the parser. -pub(super) const SYNTAX: Syntax = Syntax { - command: "sandbox", - arguments: "[{run,provision,start,exec,stop,deprovision}] [OPTIONS]", - help: "\ -positional arguments: - {run,provision,start,exec,stop,deprovision} - -options: - -h, --help show this help message and exit - --layer ROLE,PATH,EROFS_UUID - --scratch SCRATCH - --state-dir STATE_DIR - --entrypoint ENTRYPOINT - --arg SANDBOX_ARG - --hostname HOSTNAME - --workload-user UID:GID - fixed non-root workload identity (default: - 65534:65534) - --memory-max MEMORY_MAX - --pids-max PIDS_MAX - --memory-mib MEMORY_MIB - --timeout TIMEOUT control operation timeout, and the longest wait for - another lifecycle transition of the sandbox, in - seconds (default: 60) - --exec-timeout-ms EXEC_TIMEOUT_MS - guest workload timeout in milliseconds; zero disables - it - --cwd CWD absolute guest working directory for managed exec - (default: /) - --environment KEY=VALUE - set the exact managed exec environment; repeat for - multiple entries - --environment-file PATH - read the exact managed exec environment from a JSON - string array - --inherit-default-environment - layer the managed exec environment over the guest - default environment instead of replacing it - --hypervisor {auto,whp,kvm,mshv} - --mount GUEST_TARGET,HOST_PATH[,ro|rw] - live-share a host directory, or a single regular host - file without its directory, inside the container - rootfs; the mode follows the last comma, so HOST_PATH - may contain commas when the mode is given; repeat to - attach more shares, each with its own target and mode, - as children of one virtio-fs device - --mount-deny HOST_PATH - hide one existing path inside a --mount host - directory; with several --mount options, it applies to - the --mount before it - --mount-allow HOST_PATH - expose one existing path inside a --mount-deny path of - the same share again; the hidden directories on the - way list only it - --mount-write HOST_PATH - make one existing path one of the only writable parts - of a read-write --mount; the rest of the share is - read-only - --mount-owner {vmm,caller} - host identity of the share's file operations: vmm - (default) or caller, the workload identity with guest - root squashed to the owner of the shared directory or - file (Linux only) - --net IPV4/PREFIX - --network-profile {portable} - --network-egress {allow,deny} - --network-ingress {allow,deny} - --network-egress-allow CIDR[:PROTOCOL[:PORT[-PORT]]] - allow guest egress to an IPv4 or IPv6 CIDR, optionally - only for PROTOCOL tcp, udp, or icmp, and for tcp or - udp only on PORT or on the inclusive range PORT-PORT; - repeat to add rules - --network-egress-deny CIDR[:PROTOCOL[:PORT[-PORT]]] - deny guest egress to an IPv4 or IPv6 CIDR, optionally - only for PROTOCOL tcp, udp, or icmp, and for tcp or - udp only on PORT or on the inclusive range PORT-PORT; - repeat to add rules - --network-egress-policy-file PATH - load bounded IPv4 and IPv6 ranges and rule-local - exclusions for run/provision - --host-loopback {allow,deny} - --network-proxy IPV4:TCP-PORT - --host-loopback-forward HOST_LOOPBACK_FORWARD - --outcome-report OUTCOME_REPORT - write a bounded local JSON outcome report for run or - exec - --cmdline CMDLINE - --dry-run print the one-shot run command without launching it", +pub(super) use self::one_shot::run_openvmm; +use self::options::sandbox_network; +pub(super) use self::options::{ + HYPERVISORS, MAX_MEMORY_MIB, MOUNT_OWNERS, hypervisor, parse, path_text, }; - -/// The operations, in the order in which usage messages list them. -const OPERATIONS: [&str; 6] = ["run", "provision", "start", "exec", "stop", "deprovision"]; -/// The choices of `--hypervisor`. -pub(super) const HYPERVISORS: [&str; 4] = ["auto", "whp", "kvm", "mshv"]; -/// The choices of `--mount-owner`. -pub(super) const MOUNT_OWNERS: [&str; 2] = ["vmm", "caller"]; -/// The most MiB of guest RAM that `--memory-mib` and the memory options of `run` take: the most -/// whose count of bytes fits in 64 bits, as OpenVMM reads a size of RAM. -pub(super) const MAX_MEMORY_MIB: u64 = u64::MAX >> 20; - -/// Returns the seconds that `value` gives, a finite number greater than 0, as Rust's `f64` -/// parses it: digits with an optional fraction and exponent, after an optional sign. -fn seconds(value: &str) -> std::result::Result { - value - .parse::() - .ok() - .filter(|seconds| seconds.is_finite() && *seconds > 0.0) - .ok_or_else(|| format!("expected a finite number of seconds greater than 0, not {value:?}")) -} - -/// The options of `nvx-rs sandbox`, which parse with the defaults of the former -/// `nvx.py sandbox`. -pub(super) const OPTIONS: [Spec; 35] = [ - Spec::one("--layer"), - Spec::one("--scratch"), - Spec::one("--state-dir"), - Spec::one("--entrypoint"), - Spec::one("--arg"), - Spec::one("--hostname"), - Spec::one("--workload-user"), - Spec::one("--memory-max"), - Spec::one("--pids-max"), - Spec::one("--memory-mib"), - Spec::one("--timeout"), - Spec::one("--exec-timeout-ms"), - Spec::one("--cwd"), - Spec::one("--environment"), - Spec::one("--environment-file"), - Spec::flag("--inherit-default-environment"), - Spec::one("--hypervisor"), - Spec::one("--mount"), - Spec::one("--mount-deny"), - Spec::one("--mount-allow"), - Spec::one("--mount-write"), - Spec::one("--mount-owner"), - Spec::one("--net"), - Spec::one("--network-profile"), - Spec::one("--network-egress"), - Spec::one("--network-ingress"), - Spec::one("--network-egress-allow"), - Spec::one("--network-egress-deny"), - Spec::one("--network-egress-policy-file"), - Spec::one("--host-loopback"), - Spec::one("--network-proxy"), - Spec::one("--host-loopback-forward"), - Spec::one("--outcome-report"), - Spec::one("--cmdline"), - Spec::flag("--dry-run"), -]; - -/// The parsed options of `nvx-rs sandbox`: the namespace of the former `nvx.py sandbox`. -#[derive(Debug, Clone, PartialEq)] -pub(crate) struct Options { - /// The operation, `run` by default. - pub(crate) operation: SandboxOperation, - /// `--layer`, parsed as `SandboxLayer.parse` parses it. - pub(crate) layers: Vec, - /// `--scratch`, as given. - pub(crate) scratch: Option, - /// `--state-dir`, as given. - pub(crate) state_dir: Option, - /// `--entrypoint`. - pub(crate) entrypoint: String, - /// The `--arg` options. - pub(crate) args: Vec, - /// `--hostname`. - pub(crate) hostname: String, - /// `--workload-user`. - pub(crate) workload_user: (u32, u32), - /// `--memory-max`. - pub(crate) memory_max: Option, - /// `--pids-max`. - pub(crate) pids_max: Option, - /// `--memory-mib`, 256 by default. - pub(crate) memory_mib: u64, - /// `--timeout` in seconds, 60 by default. - pub(crate) timeout: f64, - /// `--exec-timeout-ms`, 0 by default. - pub(crate) exec_timeout_ms: u32, - /// `--cwd`. - pub(crate) cwd: Option, - /// The `--environment` options. - pub(crate) environment: Vec, - /// `--environment-file`, as given. - pub(crate) environment_file: Option, - /// `--inherit-default-environment`. - pub(crate) inherit_default_environment: bool, - /// `--hypervisor`, `auto` by default. - pub(crate) hypervisor: &'static str, - /// The `--mount` options. - pub(crate) mounts: Vec, - /// The `--mount-deny`, `--mount-allow`, and `--mount-write` options, with the `--mount` - /// before each. - pub(crate) policy_paths: Vec, - /// `--mount-owner`. - pub(crate) mount_owner: Option, - /// The network options. - pub(crate) network: NetworkOptions, - /// `--outcome-report`, as given. - pub(crate) outcome_report: Option, - /// `--cmdline`, empty by default. - pub(crate) cmdline: String, - /// `--dry-run`. - pub(crate) dry_run: bool, -} - -impl Default for Options { - fn default() -> Self { - Self { - operation: SandboxOperation::Run, - layers: Vec::new(), - scratch: None, - state_dir: None, - entrypoint: DEFAULT_ENTRYPOINT.to_owned(), - args: Vec::new(), - hostname: DEFAULT_HOSTNAME.to_owned(), - workload_user: DEFAULT_WORKLOAD_IDENTITY, - memory_max: None, - pids_max: None, - memory_mib: 256, - timeout: 60.0, - exec_timeout_ms: 0, - cwd: None, - environment: Vec::new(), - environment_file: None, - inherit_default_environment: false, - hypervisor: "auto", - mounts: Vec::new(), - policy_paths: Vec::new(), - mount_owner: None, - network: NetworkOptions::default(), - outcome_report: None, - cmdline: String::new(), - dry_run: false, - } - } -} - -impl Receiver for Options { - fn take(&mut self, name: &'static str, value: Option<&str>) -> std::result::Result<(), String> { - let text = || value.unwrap_or_default().to_owned(); - match name { - "sandbox_operation" => { - let operation = choice(value.unwrap_or("run"), &OPERATIONS)?; - self.operation = match operation { - "provision" => SandboxOperation::Provision, - "start" => SandboxOperation::Start, - "exec" => SandboxOperation::Exec, - "stop" => SandboxOperation::Stop, - "deprovision" => SandboxOperation::Deprovision, - _ => SandboxOperation::Run, - }; - } - "--layer" => self - .layers - .push(SandboxLayer::parse(&text()).map_err(|error| error.message().to_owned())?), - "--scratch" => self.scratch = Some(text()), - "--state-dir" => self.state_dir = Some(text()), - "--entrypoint" => self.entrypoint = text(), - "--arg" => self.args.push(text()), - "--hostname" => self.hostname = text(), - "--workload-user" => { - self.workload_user = - parse_workload_identity(&text()).map_err(|error| error.message().to_owned())?; - } - "--memory-max" => self.memory_max = Some(integer(&text(), 1..=u64::MAX)?), - "--pids-max" => self.pids_max = Some(integer(&text(), 1..=PIDS_MAX_LIMIT)?), - "--memory-mib" => self.memory_mib = integer(&text(), 1..=MAX_MEMORY_MIB)?, - "--timeout" => self.timeout = seconds(&text())?, - "--exec-timeout-ms" => self.exec_timeout_ms = integer(&text(), 0..=u32::MAX)?, - "--cwd" => self.cwd = Some(text()), - "--environment" => self.environment.push(text()), - "--environment-file" => self.environment_file = Some(text()), - "--inherit-default-environment" => self.inherit_default_environment = true, - "--hypervisor" => self.hypervisor = choice(&text(), &HYPERVISORS)?, - "--mount" => self.mounts.push(text()), - "--mount-deny" | "--mount-allow" | "--mount-write" => { - let kind = match name { - "--mount-deny" => PolicyKind::Denied, - "--mount-allow" => PolicyKind::Allowed, - _ => PolicyKind::Writable, - }; - self.policy_paths.push(PolicyPathOption { - kind, - preceding_mount: self.mounts.len().checked_sub(1), - path: text(), - }); - } - "--mount-owner" => { - self.mount_owner = Some(match choice(&text(), &MOUNT_OWNERS)? { - "caller" => MountOwner::Caller, - _ => MountOwner::Vmm, - }); - } - "--outcome-report" => self.outcome_report = Some(text()), - "--cmdline" => self.cmdline = text(), - "--dry-run" => self.dry_run = true, - _ => return self.network.take(name, &text()), - } - Ok(()) - } -} - -/// Parses the arguments of `sandbox`, and returns the options and the arguments that the parser -/// does not recognize. -pub(super) fn parse(arguments: &[String]) -> Result<(Options, Vec)> { - let mut options = Options::default(); - let unrecognized = Parser::new(&OPTIONS, &[Spec::optional("sandbox_operation")]) - .parse(arguments, &mut options) - .map_err(|stop| Exit::from_stop(stop, &SYNTAX))?; - Ok((options, unrecognized)) -} - -/// Returns `_hypervisor(selected)`: `auto` selects `whp` on Windows and `kvm` elsewhere. -pub(super) fn hypervisor(selected: &'static str) -> &'static str { - match selected { - "auto" if cfg!(windows) => "whp", - "auto" => "kvm", - other => other, - } -} - -/// Returns the path `value` spelled with its components alone, as [`paths::normalized`] spells -/// it. -pub(super) fn path_text(value: &str) -> String { - paths::normalized(Path::new(value)).display().to_string() -} - -/// Returns `path` as text, which the commands of `nvx.py` hold as `str`. -pub(crate) fn path_string(path: &Path) -> Result { - path.to_str().map(str::to_owned).ok_or_else(|| { - Exit::error( - ErrorCode::Unsupported, - format!("path is not valid Unicode: {}", path.display()), - ) - }) -} - -/// Returns the network that the network options of `options` describe, with `rules`, the egress -/// rules that [`NetworkOptions::egress_rules`] resolved: the typed policy at the guest network of -/// `--net`, which [`SandboxNetwork::new`] checks before any launch, or `None` without `--net`, which -/// no other network option may then accompany. -/// -/// Omitted options take OpenVMM's defaults: egress allowed, ingress denied, and host-loopback -/// access from the guest alone, [`HostLoopback::Outbound`]. As OpenVMM does, the policy takes -/// egress rules only with an explicit `--network-egress`, and reads `--network-egress-allow`, -/// `--network-egress-deny`, and `--host-loopback-forward` in OpenVMM's form. `--network-proxy` must -/// name the guest's gateway, and makes the policy proxy-only, with no scheme in its URL. -fn sandbox_network(options: &Options, rules: &NativeRules) -> Result> { - let network = &options.network; - let Some(net) = &network.net else { - let network_options = network.network_egress.is_some() - || network.network_ingress.is_some() - || network.network_egress_policy_file.is_some() - || network.host_loopback.is_some() - || network.network_proxy.is_some() - || !rules.allow.is_empty() - || !rules.deny.is_empty() - || !network.host_loopback_forward.is_empty(); - if network_options { - return Err(Exit::error( - ErrorCode::Unsupported, - "network policy options require --net", - )); - } - return Ok(None); - }; - let gateway = guest_gateway(net)?; - if network.network_egress.is_none() && (!rules.allow.is_empty() || !rules.deny.is_empty()) { - return Err(Exit::error( - ErrorCode::Unsupported, - "--network-egress is required with --network-egress-allow or --network-egress-deny", - )); - } - let access = |value: Option<&str>, default| match value { - Some("allow") => Access::Allow, - Some(_) => Access::Deny, - None => default, - }; - // A refused value keeps the code of its refusal, after the option that gave it. - let refused = |option: &str, error: crate::Error| { - Exit::error(error.code(), format!("{option} {}", error.message())) - }; - let parsed_rules = |option: &str, native: &[String]| -> Result { - let rules = native - .iter() - .map(|rule| EgressRule::parse_native(rule).map_err(|error| refused(option, error))) - .collect::>()?; - Ok(EgressRules { - name: option.to_owned(), - rules, - }) - }; - let mut policy = NetworkPolicy::new(access(network.network_egress, Access::Allow)); - policy.allow = parsed_rules("--network-egress-allow", &rules.allow)?; - policy.deny = parsed_rules("--network-egress-deny", &rules.deny)?; - policy.ingress = access(network.network_ingress, Access::Deny); - policy.host_loopback = match network.host_loopback { - Some(host_loopback) => access(Some(host_loopback), Access::Deny).into(), - None => HostLoopback::Outbound, - }; - for forward in &network.host_loopback_forward { - let forward = LoopbackForward::parse(forward) - .map_err(|error| refused("--host-loopback-forward", error))?; - policy.host_loopback_forwards.push(forward); - } - if let Some(proxy) = &network.network_proxy { - let proxy = NetworkProxy::parse_endpoint(proxy, gateway) - .map_err(|error| refused("--network-proxy", error))?; - policy.proxy = Some(proxy); - } - Ok(Some(SandboxNetwork::new(net, policy)?)) -} +#[cfg(test)] +pub(super) use self::options::{OPTIONS, SYNTAX}; +pub(crate) use self::options::{Options, path_string}; +use self::state::RecordedDistro; /// The artifacts of a sandbox, found when an operation first needs them. pub(crate) struct Artifacts<'a> { @@ -751,138 +328,5 @@ fn prepare_run( PreparedLaunch::one_shot(plan, distro, Some(scratch), cmdline).map_err(Exit::from) } -/// Runs OpenVMM on the terminal, as `subprocess.run(command)` does, and returns its status. -/// -/// OpenVMM inherits the descriptors or handles `inherit`, under their numbers in this process, and -/// no other of this process beyond its standard streams, as with `subprocess.run`, which closes -/// the others on Linux and passes none on Windows: on Linux, `inherit` names the checked files of -/// the run; on Windows, it names the claim's handle of the run's scratch image, OpenVMM's handle -/// list holds copies of this process's standard handles and that handle alone, and OpenVMM opens -/// the checked files by their final paths, which the checked files hold in place. `nvx-rs run`, -/// which holds no checked files, passes none, as `nvx.py run` passed none to `subprocess.run`. -/// -/// OpenVMM shares the terminal and its process group, so Ctrl-C reaches it too. As -/// `subprocess.run` does, the CLI then gives it 0.25 s to exit on Unix before it kills it, and on -/// Windows, where Python cannot interrupt its wait, waits for it to exit; either way the command -/// ends as interrupted, also when OpenVMM exited first, since Python's wait raises -/// `KeyboardInterrupt` once Ctrl-C arrives. -pub(super) fn run_openvmm(command: &[String], inherit: &[Inherited<'_>]) -> Result { - let mut openvmm = OneShot::spawn(command, inherit)?; - loop { - let status = openvmm.poll()?; - if platform::interrupted() { - if status.is_none() { - end_interrupted(&mut openvmm)?; - } - return Err(Exit::Interrupted); - } - if let Some(status) = status { - return Ok(status); - } - thread::sleep(Duration::from_millis(10)); - } -} - -/// The OpenVMM process of a one-shot run. -struct OneShot { - /// The process, which `std::process` started. - #[cfg(not(windows))] - child: Child, - /// The process, which [`platform::spawn_foreground`] started. - #[cfg(windows)] - process: platform::LaunchedProcess, -} - -impl OneShot { - /// Starts OpenVMM with `command`, with the descriptors or handles `inherit`, as - /// [`run_openvmm`] says. - fn spawn(command: &[String], inherit: &[Inherited<'_>]) -> Result { - let failed = |error: std::io::Error| { - // The host's own refusals of a command, such as of a batch file on Windows, carry no - // OS error. - let refused = - error.kind() == std::io::ErrorKind::InvalidInput && error.raw_os_error().is_none(); - let code = if refused { - ErrorCode::Unsupported - } else { - ErrorCode::Io - }; - Exit::error(code, spawn_error(&error, &command[0])) - }; - #[cfg(windows)] - { - let arguments: Vec = command[1..].iter().map(Into::into).collect(); - platform::spawn_foreground(std::path::Path::new(&command[0]), &arguments, inherit) - .map(|process| Self { process }) - .map_err(failed) - } - #[cfg(not(windows))] - { - let mut child = Command::new(&command[0]); - child.args(&command[1..]); - platform::inherit_only(&mut child, inherit).map_err(failed)?; - child.spawn().map(|child| Self { child }).map_err(failed) - } - } - - /// Returns OpenVMM's status if it has exited: `Popen.returncode`, which on Unix is the - /// negated signal of a process that a signal ended, and on Windows the status of - /// `GetExitCodeProcess`, as `std::process::ExitStatus::code` reads it. - fn poll(&mut self) -> Result> { - #[cfg(windows)] - { - let status = self - .process - .wait_timeout(Duration::ZERO) - .map_err(Exit::from_os_error)?; - Ok(status.map(|status| status as i32)) - } - #[cfg(not(windows))] - { - let status = self.child.try_wait().map_err(Exit::from_os_error)?; - Ok(status.map(|status| { - #[cfg(unix)] - { - use std::os::unix::process::ExitStatusExt; - - status - .code() - .unwrap_or_else(|| -status.signal().unwrap_or_default()) - } - #[cfg(not(unix))] - status.code().unwrap_or_default() - })) - } - } -} - -/// Ends the OpenVMM of an interrupted run as `subprocess.run` does: on Unix, it waits 0.25 s for -/// OpenVMM to exit and then kills it, and on Windows, it waits for OpenVMM to exit. -fn end_interrupted(openvmm: &mut OneShot) -> Result { - #[cfg(not(windows))] - let deadline = Instant::now() + Duration::from_millis(250); - while openvmm.poll()?.is_none() { - #[cfg(not(windows))] - if cfg!(unix) && Instant::now() >= deadline { - let _ = openvmm.child.kill(); - let _ = openvmm.child.wait(); - break; - } - thread::sleep(Duration::from_millis(10)); - } - Ok(()) -} - -/// Returns the message of the I/O error with which the host fails to start `program`: the -/// program, quoted, and the error, as [`io_message`](crate::host::cli::io_message) writes them, -/// or the error alone for a refusal of the host's own, such as of a batch file on Windows, which -/// names the program itself. -pub(crate) fn spawn_error(error: &std::io::Error, program: &str) -> String { - match error.raw_os_error() { - Some(_) => crate::host::cli::io_message(error, std::path::Path::new(program)), - None => error.to_string(), - } -} - #[cfg(test)] mod tests; diff --git a/nvx-rs/src/host/cli/sandbox/one_shot.rs b/nvx-rs/src/host/cli/sandbox/one_shot.rs new file mode 100644 index 000000000..becb993ad --- /dev/null +++ b/nvx-rs/src/host/cli/sandbox/one_shot.rs @@ -0,0 +1,150 @@ +//! The OpenVMM process of a one-shot run, which runs on the terminal until it exits or the run is +//! interrupted. + +#[cfg(not(windows))] +use std::process::{Child, Command}; +use std::thread; +use std::time::Duration; +#[cfg(not(windows))] +use std::time::Instant; + +use crate::ErrorCode; +use crate::host::cli::{Exit, Result}; +use crate::host::platform; +use crate::host::supervisor::Inherited; + +/// Runs OpenVMM on the terminal, as `subprocess.run(command)` does, and returns its status. +/// +/// OpenVMM inherits the descriptors or handles `inherit`, under their numbers in this process, and +/// no other of this process beyond its standard streams, as with `subprocess.run`, which closes +/// the others on Linux and passes none on Windows: on Linux, `inherit` names the checked files of +/// the run; on Windows, it names the claim's handle of the run's scratch image, OpenVMM's handle +/// list holds copies of this process's standard handles and that handle alone, and OpenVMM opens +/// the checked files by their final paths, which the checked files hold in place. `nvx-rs run`, +/// which holds no checked files, passes none, as `nvx.py run` passed none to `subprocess.run`. +/// +/// OpenVMM shares the terminal and its process group, so Ctrl-C reaches it too. As +/// `subprocess.run` does, the CLI then gives it 0.25 s to exit on Unix before it kills it, and on +/// Windows, where Python cannot interrupt its wait, waits for it to exit; either way the command +/// ends as interrupted, also when OpenVMM exited first, since Python's wait raises +/// `KeyboardInterrupt` once Ctrl-C arrives. +pub(in crate::host::cli) fn run_openvmm( + command: &[String], + inherit: &[Inherited<'_>], +) -> Result { + let mut openvmm = OneShot::spawn(command, inherit)?; + loop { + let status = openvmm.poll()?; + if platform::interrupted() { + if status.is_none() { + end_interrupted(&mut openvmm)?; + } + return Err(Exit::Interrupted); + } + if let Some(status) = status { + return Ok(status); + } + thread::sleep(Duration::from_millis(10)); + } +} + +/// The OpenVMM process of a one-shot run. +struct OneShot { + /// The process, which `std::process` started. + #[cfg(not(windows))] + child: Child, + /// The process, which [`platform::spawn_foreground`] started. + #[cfg(windows)] + process: platform::LaunchedProcess, +} + +impl OneShot { + /// Starts OpenVMM with `command`, with the descriptors or handles `inherit`, as + /// [`run_openvmm`] says. + fn spawn(command: &[String], inherit: &[Inherited<'_>]) -> Result { + let failed = |error: std::io::Error| { + // The host's own refusals of a command, such as of a batch file on Windows, carry no + // OS error. + let refused = + error.kind() == std::io::ErrorKind::InvalidInput && error.raw_os_error().is_none(); + let code = if refused { + ErrorCode::Unsupported + } else { + ErrorCode::Io + }; + Exit::error(code, spawn_error(&error, &command[0])) + }; + #[cfg(windows)] + { + let arguments: Vec = command[1..].iter().map(Into::into).collect(); + platform::spawn_foreground(std::path::Path::new(&command[0]), &arguments, inherit) + .map(|process| Self { process }) + .map_err(failed) + } + #[cfg(not(windows))] + { + let mut child = Command::new(&command[0]); + child.args(&command[1..]); + platform::inherit_only(&mut child, inherit).map_err(failed)?; + child.spawn().map(|child| Self { child }).map_err(failed) + } + } + + /// Returns OpenVMM's status if it has exited: `Popen.returncode`, which on Unix is the + /// negated signal of a process that a signal ended, and on Windows the status of + /// `GetExitCodeProcess`, as `std::process::ExitStatus::code` reads it. + fn poll(&mut self) -> Result> { + #[cfg(windows)] + { + let status = self + .process + .wait_timeout(Duration::ZERO) + .map_err(Exit::from_os_error)?; + Ok(status.map(|status| status as i32)) + } + #[cfg(not(windows))] + { + let status = self.child.try_wait().map_err(Exit::from_os_error)?; + Ok(status.map(|status| { + #[cfg(unix)] + { + use std::os::unix::process::ExitStatusExt; + + status + .code() + .unwrap_or_else(|| -status.signal().unwrap_or_default()) + } + #[cfg(not(unix))] + status.code().unwrap_or_default() + })) + } + } +} + +/// Ends the OpenVMM of an interrupted run as `subprocess.run` does: on Unix, it waits 0.25 s for +/// OpenVMM to exit and then kills it, and on Windows, it waits for OpenVMM to exit. +fn end_interrupted(openvmm: &mut OneShot) -> Result { + #[cfg(not(windows))] + let deadline = Instant::now() + Duration::from_millis(250); + while openvmm.poll()?.is_none() { + #[cfg(not(windows))] + if cfg!(unix) && Instant::now() >= deadline { + let _ = openvmm.child.kill(); + let _ = openvmm.child.wait(); + break; + } + thread::sleep(Duration::from_millis(10)); + } + Ok(()) +} + +/// Returns the message of the I/O error with which the host fails to start `program`: the +/// program, quoted, and the error, as [`io_message`](crate::host::cli::io_message) writes them, +/// or the error alone for a refusal of the host's own, such as of a batch file on Windows, which +/// names the program itself. +pub(crate) fn spawn_error(error: &std::io::Error, program: &str) -> String { + match error.raw_os_error() { + Some(_) => crate::host::cli::io_message(error, std::path::Path::new(program)), + None => error.to_string(), + } +} diff --git a/nvx-rs/src/host/cli/sandbox/options.rs b/nvx-rs/src/host/cli/sandbox/options.rs new file mode 100644 index 000000000..4258a1c19 --- /dev/null +++ b/nvx-rs/src/host/cli/sandbox/options.rs @@ -0,0 +1,438 @@ +//! The parser of `nvx-rs sandbox`, with the options of the former `nvx.py sandbox`, and the +//! network that its network options describe. + +use std::path::Path; + +use crate::ErrorCode; +use crate::host::cli::args::{Parser, Receiver, Spec, Syntax, choice, integer}; +use crate::host::cli::network::{NativeRules, NetworkOptions}; +use crate::host::cli::{Exit, Result}; +use crate::host::network::{ + Access, EgressRule, EgressRules, HostLoopback, LoopbackForward, NetworkPolicy, NetworkProxy, +}; +use crate::host::paths; +use crate::host::sandbox::mount::{MountOwner, PolicyKind}; +use crate::host::sandbox::options::{PolicyPathOption, SandboxOperation}; +use crate::host::sandbox::plan::guest_gateway; +use crate::host::sandbox::{ + DEFAULT_ENTRYPOINT, DEFAULT_HOSTNAME, DEFAULT_WORKLOAD_IDENTITY, PIDS_MAX_LIMIT, SandboxLayer, + SandboxNetwork, parse_workload_identity, +}; + +/// The usage and help of the parser. +pub(in crate::host::cli) const SYNTAX: Syntax = Syntax { + command: "sandbox", + arguments: "[{run,provision,start,exec,stop,deprovision}] [OPTIONS]", + help: "\ +positional arguments: + {run,provision,start,exec,stop,deprovision} + +options: + -h, --help show this help message and exit + --layer ROLE,PATH,EROFS_UUID + --scratch SCRATCH + --state-dir STATE_DIR + --entrypoint ENTRYPOINT + --arg SANDBOX_ARG + --hostname HOSTNAME + --workload-user UID:GID + fixed non-root workload identity (default: + 65534:65534) + --memory-max MEMORY_MAX + --pids-max PIDS_MAX + --memory-mib MEMORY_MIB + --timeout TIMEOUT control operation timeout, and the longest wait for + another lifecycle transition of the sandbox, in + seconds (default: 60) + --exec-timeout-ms EXEC_TIMEOUT_MS + guest workload timeout in milliseconds; zero disables + it + --cwd CWD absolute guest working directory for managed exec + (default: /) + --environment KEY=VALUE + set the exact managed exec environment; repeat for + multiple entries + --environment-file PATH + read the exact managed exec environment from a JSON + string array + --inherit-default-environment + layer the managed exec environment over the guest + default environment instead of replacing it + --hypervisor {auto,whp,kvm,mshv} + --mount GUEST_TARGET,HOST_PATH[,ro|rw] + live-share a host directory, or a single regular host + file without its directory, inside the container + rootfs; the mode follows the last comma, so HOST_PATH + may contain commas when the mode is given; repeat to + attach more shares, each with its own target and mode, + as children of one virtio-fs device + --mount-deny HOST_PATH + hide one existing path inside a --mount host + directory; with several --mount options, it applies to + the --mount before it + --mount-allow HOST_PATH + expose one existing path inside a --mount-deny path of + the same share again; the hidden directories on the + way list only it + --mount-write HOST_PATH + make one existing path one of the only writable parts + of a read-write --mount; the rest of the share is + read-only + --mount-owner {vmm,caller} + host identity of the share's file operations: vmm + (default) or caller, the workload identity with guest + root squashed to the owner of the shared directory or + file (Linux only) + --net IPV4/PREFIX + --network-profile {portable} + --network-egress {allow,deny} + --network-ingress {allow,deny} + --network-egress-allow CIDR[:PROTOCOL[:PORT[-PORT]]] + allow guest egress to an IPv4 or IPv6 CIDR, optionally + only for PROTOCOL tcp, udp, or icmp, and for tcp or + udp only on PORT or on the inclusive range PORT-PORT; + repeat to add rules + --network-egress-deny CIDR[:PROTOCOL[:PORT[-PORT]]] + deny guest egress to an IPv4 or IPv6 CIDR, optionally + only for PROTOCOL tcp, udp, or icmp, and for tcp or + udp only on PORT or on the inclusive range PORT-PORT; + repeat to add rules + --network-egress-policy-file PATH + load bounded IPv4 and IPv6 ranges and rule-local + exclusions for run/provision + --host-loopback {allow,deny} + --network-proxy IPV4:TCP-PORT + --host-loopback-forward HOST_LOOPBACK_FORWARD + --outcome-report OUTCOME_REPORT + write a bounded local JSON outcome report for run or + exec + --cmdline CMDLINE + --dry-run print the one-shot run command without launching it", +}; + +/// The operations, in the order in which usage messages list them. +const OPERATIONS: [&str; 6] = ["run", "provision", "start", "exec", "stop", "deprovision"]; +/// The choices of `--hypervisor`. +pub(in crate::host::cli) const HYPERVISORS: [&str; 4] = ["auto", "whp", "kvm", "mshv"]; +/// The choices of `--mount-owner`. +pub(in crate::host::cli) const MOUNT_OWNERS: [&str; 2] = ["vmm", "caller"]; +/// The most MiB of guest RAM that `--memory-mib` and the memory options of `run` take: the most +/// whose count of bytes fits in 64 bits, as OpenVMM reads a size of RAM. +pub(in crate::host::cli) const MAX_MEMORY_MIB: u64 = u64::MAX >> 20; + +/// Returns the seconds that `value` gives, a finite number greater than 0, as Rust's `f64` +/// parses it: digits with an optional fraction and exponent, after an optional sign. +fn seconds(value: &str) -> std::result::Result { + value + .parse::() + .ok() + .filter(|seconds| seconds.is_finite() && *seconds > 0.0) + .ok_or_else(|| format!("expected a finite number of seconds greater than 0, not {value:?}")) +} + +/// The options of `nvx-rs sandbox`, which parse with the defaults of the former +/// `nvx.py sandbox`. +pub(in crate::host::cli) const OPTIONS: [Spec; 35] = [ + Spec::one("--layer"), + Spec::one("--scratch"), + Spec::one("--state-dir"), + Spec::one("--entrypoint"), + Spec::one("--arg"), + Spec::one("--hostname"), + Spec::one("--workload-user"), + Spec::one("--memory-max"), + Spec::one("--pids-max"), + Spec::one("--memory-mib"), + Spec::one("--timeout"), + Spec::one("--exec-timeout-ms"), + Spec::one("--cwd"), + Spec::one("--environment"), + Spec::one("--environment-file"), + Spec::flag("--inherit-default-environment"), + Spec::one("--hypervisor"), + Spec::one("--mount"), + Spec::one("--mount-deny"), + Spec::one("--mount-allow"), + Spec::one("--mount-write"), + Spec::one("--mount-owner"), + Spec::one("--net"), + Spec::one("--network-profile"), + Spec::one("--network-egress"), + Spec::one("--network-ingress"), + Spec::one("--network-egress-allow"), + Spec::one("--network-egress-deny"), + Spec::one("--network-egress-policy-file"), + Spec::one("--host-loopback"), + Spec::one("--network-proxy"), + Spec::one("--host-loopback-forward"), + Spec::one("--outcome-report"), + Spec::one("--cmdline"), + Spec::flag("--dry-run"), +]; + +/// The parsed options of `nvx-rs sandbox`: the namespace of the former `nvx.py sandbox`. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct Options { + /// The operation, `run` by default. + pub(crate) operation: SandboxOperation, + /// `--layer`, parsed as `SandboxLayer.parse` parses it. + pub(crate) layers: Vec, + /// `--scratch`, as given. + pub(crate) scratch: Option, + /// `--state-dir`, as given. + pub(crate) state_dir: Option, + /// `--entrypoint`. + pub(crate) entrypoint: String, + /// The `--arg` options. + pub(crate) args: Vec, + /// `--hostname`. + pub(crate) hostname: String, + /// `--workload-user`. + pub(crate) workload_user: (u32, u32), + /// `--memory-max`. + pub(crate) memory_max: Option, + /// `--pids-max`. + pub(crate) pids_max: Option, + /// `--memory-mib`, 256 by default. + pub(crate) memory_mib: u64, + /// `--timeout` in seconds, 60 by default. + pub(crate) timeout: f64, + /// `--exec-timeout-ms`, 0 by default. + pub(crate) exec_timeout_ms: u32, + /// `--cwd`. + pub(crate) cwd: Option, + /// The `--environment` options. + pub(crate) environment: Vec, + /// `--environment-file`, as given. + pub(crate) environment_file: Option, + /// `--inherit-default-environment`. + pub(crate) inherit_default_environment: bool, + /// `--hypervisor`, `auto` by default. + pub(crate) hypervisor: &'static str, + /// The `--mount` options. + pub(crate) mounts: Vec, + /// The `--mount-deny`, `--mount-allow`, and `--mount-write` options, with the `--mount` + /// before each. + pub(crate) policy_paths: Vec, + /// `--mount-owner`. + pub(crate) mount_owner: Option, + /// The network options. + pub(crate) network: NetworkOptions, + /// `--outcome-report`, as given. + pub(crate) outcome_report: Option, + /// `--cmdline`, empty by default. + pub(crate) cmdline: String, + /// `--dry-run`. + pub(crate) dry_run: bool, +} + +impl Default for Options { + fn default() -> Self { + Self { + operation: SandboxOperation::Run, + layers: Vec::new(), + scratch: None, + state_dir: None, + entrypoint: DEFAULT_ENTRYPOINT.to_owned(), + args: Vec::new(), + hostname: DEFAULT_HOSTNAME.to_owned(), + workload_user: DEFAULT_WORKLOAD_IDENTITY, + memory_max: None, + pids_max: None, + memory_mib: 256, + timeout: 60.0, + exec_timeout_ms: 0, + cwd: None, + environment: Vec::new(), + environment_file: None, + inherit_default_environment: false, + hypervisor: "auto", + mounts: Vec::new(), + policy_paths: Vec::new(), + mount_owner: None, + network: NetworkOptions::default(), + outcome_report: None, + cmdline: String::new(), + dry_run: false, + } + } +} + +impl Receiver for Options { + fn take(&mut self, name: &'static str, value: Option<&str>) -> std::result::Result<(), String> { + let text = || value.unwrap_or_default().to_owned(); + match name { + "sandbox_operation" => { + let operation = choice(value.unwrap_or("run"), &OPERATIONS)?; + self.operation = match operation { + "provision" => SandboxOperation::Provision, + "start" => SandboxOperation::Start, + "exec" => SandboxOperation::Exec, + "stop" => SandboxOperation::Stop, + "deprovision" => SandboxOperation::Deprovision, + _ => SandboxOperation::Run, + }; + } + "--layer" => self + .layers + .push(SandboxLayer::parse(&text()).map_err(|error| error.message().to_owned())?), + "--scratch" => self.scratch = Some(text()), + "--state-dir" => self.state_dir = Some(text()), + "--entrypoint" => self.entrypoint = text(), + "--arg" => self.args.push(text()), + "--hostname" => self.hostname = text(), + "--workload-user" => { + self.workload_user = + parse_workload_identity(&text()).map_err(|error| error.message().to_owned())?; + } + "--memory-max" => self.memory_max = Some(integer(&text(), 1..=u64::MAX)?), + "--pids-max" => self.pids_max = Some(integer(&text(), 1..=PIDS_MAX_LIMIT)?), + "--memory-mib" => self.memory_mib = integer(&text(), 1..=MAX_MEMORY_MIB)?, + "--timeout" => self.timeout = seconds(&text())?, + "--exec-timeout-ms" => self.exec_timeout_ms = integer(&text(), 0..=u32::MAX)?, + "--cwd" => self.cwd = Some(text()), + "--environment" => self.environment.push(text()), + "--environment-file" => self.environment_file = Some(text()), + "--inherit-default-environment" => self.inherit_default_environment = true, + "--hypervisor" => self.hypervisor = choice(&text(), &HYPERVISORS)?, + "--mount" => self.mounts.push(text()), + "--mount-deny" | "--mount-allow" | "--mount-write" => { + let kind = match name { + "--mount-deny" => PolicyKind::Denied, + "--mount-allow" => PolicyKind::Allowed, + _ => PolicyKind::Writable, + }; + self.policy_paths.push(PolicyPathOption { + kind, + preceding_mount: self.mounts.len().checked_sub(1), + path: text(), + }); + } + "--mount-owner" => { + self.mount_owner = Some(match choice(&text(), &MOUNT_OWNERS)? { + "caller" => MountOwner::Caller, + _ => MountOwner::Vmm, + }); + } + "--outcome-report" => self.outcome_report = Some(text()), + "--cmdline" => self.cmdline = text(), + "--dry-run" => self.dry_run = true, + _ => return self.network.take(name, &text()), + } + Ok(()) + } +} + +/// Parses the arguments of `sandbox`, and returns the options and the arguments that the parser +/// does not recognize. +pub(in crate::host::cli) fn parse(arguments: &[String]) -> Result<(Options, Vec)> { + let mut options = Options::default(); + let unrecognized = Parser::new(&OPTIONS, &[Spec::optional("sandbox_operation")]) + .parse(arguments, &mut options) + .map_err(|stop| Exit::from_stop(stop, &SYNTAX))?; + Ok((options, unrecognized)) +} + +/// Returns `_hypervisor(selected)`: `auto` selects `whp` on Windows and `kvm` elsewhere. +pub(in crate::host::cli) fn hypervisor(selected: &'static str) -> &'static str { + match selected { + "auto" if cfg!(windows) => "whp", + "auto" => "kvm", + other => other, + } +} + +/// Returns the path `value` spelled with its components alone, as [`paths::normalized`] spells +/// it. +pub(in crate::host::cli) fn path_text(value: &str) -> String { + paths::normalized(Path::new(value)).display().to_string() +} + +/// Returns `path` as text, which the commands of `nvx.py` hold as `str`. +pub(crate) fn path_string(path: &Path) -> Result { + path.to_str().map(str::to_owned).ok_or_else(|| { + Exit::error( + ErrorCode::Unsupported, + format!("path is not valid Unicode: {}", path.display()), + ) + }) +} + +/// Returns the network that the network options of `options` describe, with `rules`, the egress +/// rules that [`NetworkOptions::egress_rules`] resolved: the typed policy at the guest network of +/// `--net`, which [`SandboxNetwork::new`] checks before any launch, or `None` without `--net`, which +/// no other network option may then accompany. +/// +/// Omitted options take OpenVMM's defaults: egress allowed, ingress denied, and host-loopback +/// access from the guest alone, [`HostLoopback::Outbound`]. As OpenVMM does, the policy takes +/// egress rules only with an explicit `--network-egress`, and reads `--network-egress-allow`, +/// `--network-egress-deny`, and `--host-loopback-forward` in OpenVMM's form. `--network-proxy` must +/// name the guest's gateway, and makes the policy proxy-only, with no scheme in its URL. +pub(super) fn sandbox_network( + options: &Options, + rules: &NativeRules, +) -> Result> { + let network = &options.network; + let Some(net) = &network.net else { + let network_options = network.network_egress.is_some() + || network.network_ingress.is_some() + || network.network_egress_policy_file.is_some() + || network.host_loopback.is_some() + || network.network_proxy.is_some() + || !rules.allow.is_empty() + || !rules.deny.is_empty() + || !network.host_loopback_forward.is_empty(); + if network_options { + return Err(Exit::error( + ErrorCode::Unsupported, + "network policy options require --net", + )); + } + return Ok(None); + }; + let gateway = guest_gateway(net)?; + if network.network_egress.is_none() && (!rules.allow.is_empty() || !rules.deny.is_empty()) { + return Err(Exit::error( + ErrorCode::Unsupported, + "--network-egress is required with --network-egress-allow or --network-egress-deny", + )); + } + let access = |value: Option<&str>, default| match value { + Some("allow") => Access::Allow, + Some(_) => Access::Deny, + None => default, + }; + // A refused value keeps the code of its refusal, after the option that gave it. + let refused = |option: &str, error: crate::Error| { + Exit::error(error.code(), format!("{option} {}", error.message())) + }; + let parsed_rules = |option: &str, native: &[String]| -> Result { + let rules = native + .iter() + .map(|rule| EgressRule::parse_native(rule).map_err(|error| refused(option, error))) + .collect::>()?; + Ok(EgressRules { + name: option.to_owned(), + rules, + }) + }; + let mut policy = NetworkPolicy::new(access(network.network_egress, Access::Allow)); + policy.allow = parsed_rules("--network-egress-allow", &rules.allow)?; + policy.deny = parsed_rules("--network-egress-deny", &rules.deny)?; + policy.ingress = access(network.network_ingress, Access::Deny); + policy.host_loopback = match network.host_loopback { + Some(host_loopback) => access(Some(host_loopback), Access::Deny).into(), + None => HostLoopback::Outbound, + }; + for forward in &network.host_loopback_forward { + let forward = LoopbackForward::parse(forward) + .map_err(|error| refused("--host-loopback-forward", error))?; + policy.host_loopback_forwards.push(forward); + } + if let Some(proxy) = &network.network_proxy { + let proxy = NetworkProxy::parse_endpoint(proxy, gateway) + .map_err(|error| refused("--network-proxy", error))?; + policy.proxy = Some(proxy); + } + Ok(Some(SandboxNetwork::new(net, policy)?)) +} diff --git a/nvx-rs/src/host/cli/sandbox/tests.rs b/nvx-rs/src/host/cli/sandbox/tests.rs index 29b48d7d2..98547eccf 100644 --- a/nvx-rs/src/host/cli/sandbox/tests.rs +++ b/nvx-rs/src/host/cli/sandbox/tests.rs @@ -6,7 +6,9 @@ use proptest::prelude::*; use super::*; use crate::host::paths::resolve; -use crate::host::sandbox::layer::LayerRole; +use crate::host::sandbox::layer::{LayerRole, SandboxLayer}; +use crate::host::sandbox::mount::PolicyKind; +use crate::host::sandbox::options::PolicyPathOption; #[test] fn resource_limits_are_bounded_ascii_decimal() { From 6f9018df0451757cafc267608525498b155615fb Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Mon, 26 Oct 2026 18:44:10 -0700 Subject: [PATCH 5/6] nvx-rs: move the sandbox's mount module into its directory host/sandbox/mount.rs holds, in 1,142 lines, the mounts of a sandbox: the types of their access, owner, kind, and policy paths, the parsing and checks of their values and targets, SandboxMount, the queries of their host paths, the aggregation of several shares into one virtio-fs device, and the shares that a launch hands the guest at boot. Its tests already live in host/sandbox/mount/tests.rs. The next commit splits it into that directory along those sections, as #898 lays them out. Move it first, unchanged, to host/sandbox/mount/mod.rs, with git mv, in a commit of its own, so that git log --follow and git blame follow it through the split. Its module path, host::sandbox::mount, stays the same, and with it the paths of its items and the names of its tests, whose module still reads tests.rs beside it. Validation at this commit, on Windows and on Linux under WSL 2: rustdoc's JSON lists the same public API as on dev, for Windows (9,632 entries) and Linux (13,779), and, with private items, every intra-doc link of the crate keeps its target. The lists of tests, with all features, with default features, and of the library without default features, are those of dev on Windows (841, 695, and 201) and Linux (1,440, 681, and 201). Clippy with warnings denied passes for Windows and Linux, and cargo check for macOS, the host-module guard passes, and the library tests with all features pass on Windows (700) and Linux (1,237). Part of #898. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nvx-rs/src/host/sandbox/{mount.rs => mount/mod.rs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename nvx-rs/src/host/sandbox/{mount.rs => mount/mod.rs} (100%) diff --git a/nvx-rs/src/host/sandbox/mount.rs b/nvx-rs/src/host/sandbox/mount/mod.rs similarity index 100% rename from nvx-rs/src/host/sandbox/mount.rs rename to nvx-rs/src/host/sandbox/mount/mod.rs From 189666cd57beb4f4ddd0969be168f8d72aecce56 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Mon, 26 Oct 2026 18:44:10 -0700 Subject: [PATCH 6/6] nvx-rs: split the sandbox's mount module along its sections host/sandbox/mount/mod.rs, which the previous commit moved from host/sandbox/mount.rs, held the mounts of a sandbox: the types of their access, owner, kind, and policy paths, the requests of shares, the checks of their host paths, the planning of their export, and the shares that a launch hands the guest at boot. Split it along those sections, beside its tests, as #898 lays them out: - request.rs: the parsing and checks of mount values and targets, parse_mount_value through os_error, and SandboxMount, with absolute_host_path, command_line_fragment, and validate_mounts, which follow it; - host_paths.rs: HostQueries through check_distinct_host_paths; - aggregate.rs: aggregate_child_name through plan_mounts_with; - boot.rs: BootShares, boot_shares, and the command-line fragments, path_text through aggregate_fragment. The constants and the access, owner, kind, and policy types stay in mod.rs, with setting_from_name, which the from_name functions of three of those types share. The code moves unchanged. The new modules are private, and mod.rs re-exports their public items, so every path under nvx::host::sandbox::mount resolves as before. It also re-exports, under their pub(crate) visibility, the helpers that other modules of the crate take from it, os_error, has_forbidden_character, has_irregular_component, targets_overlap, HostQueries, check_distinct_export_roots, and export_command_line_fragment, and, for the sandbox tests alone, validate_mount_host_paths_with. The fields and helpers that the new files share become pub(super): the guest_target, host_path, access, owner, and kind fields of SandboxMount, which plan_mounts_with and validate_mount_host_paths_with read, and its kind_refusal; command_line_fragment, which the boot fragments call; and path_text, which check_distinct_export_roots calls. The policy field, which only SandboxMount reads, stays private. The tests import Path, PathBuf, Owner, ReservedTargets, and paths, which they took from mod.rs's imports through their glob, and plan_mounts_with and require_mount_owner_supported_on, crate-visible helpers that only they call from outside the files of those helpers. Links in the moved documentation, and in the module's own, name items that their files no longer import: RESERVED_MOUNT_TARGETS and RESERVED_EXACT_MOUNT_TARGETS in request.rs, plan_mounts in host_paths.rs, and filesystem::plan, ExportPlan, and GUEST_EXPORT in mod.rs. Each gets a link definition with its crate path, so that the documentation stays as it was and the links keep their targets (#896, invariant 6). Validation at this commit, on Windows and on Linux under WSL 2: rustdoc's JSON lists the same public API as on dev, for Windows (9,632 entries) and Linux (13,779), and, with private items, every intra-doc link of the crate keeps its target, with the items of the new modules named as their parent's; the one new link is that of request.rs's module documentation. The lists of tests, with all features, with default features, and of the library without default features, are those of dev on Windows (841, 695, and 201) and Linux (1,440, 681, and 201). Clippy with warnings denied passes for Windows and Linux, and cargo check for macOS, the host-module guard passes, and the library tests with all features pass on Windows (700) and Linux (1,237). The other steps of check-nvx-rs and check-aci-edge-sandboxes pass too, on both: the tests with all features (830 on Windows, 1,418 on Linux) and default features (685 and 669), the artifact bundling (18 and 19), rustdoc with warnings denied, the Rust 1.89 check, the runtime adapter's configurations, the guest binaries' checks on Linux, and aci_edge_sandboxes's lints, tests, agent bundling, and documentation. Part of #898. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nvx-rs/src/host/sandbox/mount/aggregate.rs | 147 ++++ nvx-rs/src/host/sandbox/mount/boot.rs | 202 +++++ nvx-rs/src/host/sandbox/mount/host_paths.rs | 152 ++++ nvx-rs/src/host/sandbox/mount/mod.rs | 930 +------------------- nvx-rs/src/host/sandbox/mount/request.rs | 451 ++++++++++ nvx-rs/src/host/sandbox/mount/tests.rs | 6 +- 6 files changed, 987 insertions(+), 901 deletions(-) create mode 100644 nvx-rs/src/host/sandbox/mount/aggregate.rs create mode 100644 nvx-rs/src/host/sandbox/mount/boot.rs create mode 100644 nvx-rs/src/host/sandbox/mount/host_paths.rs create mode 100644 nvx-rs/src/host/sandbox/mount/request.rs diff --git a/nvx-rs/src/host/sandbox/mount/aggregate.rs b/nvx-rs/src/host/sandbox/mount/aggregate.rs new file mode 100644 index 000000000..ce52062d7 --- /dev/null +++ b/nvx-rs/src/host/sandbox/mount/aggregate.rs @@ -0,0 +1,147 @@ +//! The children of an aggregate of shares, the values of OpenVMM's options that attach shares, +//! and the planning of the export of a sandbox's shares. + +use std::io; +use std::path::PathBuf; + +use sha2::{Digest, Sha256}; + +use super::{ + MountAccess, MountKind, MountOwner, PolicyKind, SandboxMount, validate_mount_host_paths, + validate_mounts, +}; +use crate::error::Result; +use crate::host::filesystem::{ + self, ExportPlan, ExportRequest, Mapping, Owner, PathList, ReservedTargets, Target, +}; +use crate::host::paths; +use crate::host::sandbox::refused; + +/// Returns the name of the aggregate child at `index` that the guest binds at `target`: the +/// index, a hyphen, and the first 32 hexadecimal digits of the SHA-256 digest of the target. +/// +/// A snapshot records every child's name, so it pins each target, and OpenVMM refuses a restore +/// that requests other targets before the guest runs, rather than keep the snapshot's binds. +pub fn aggregate_child_name(index: usize, target: &str) -> String { + let digest = Sha256::digest(target.as_bytes()); + let hex: String = digest[..16] + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(); + format!("{index}-{hex}") +} + +/// Returns the `--mount-child` value that attaches `host_path` as the child `name`. It always +/// gives the mode, which OpenVMM reads after the last comma or before the `file` flag, so the +/// host path may contain commas. A file child carries OpenVMM's `file` flag, so OpenVMM refuses +/// a directory that replaced the file instead of exporting all of it. +pub fn aggregate_child_argument( + name: &str, + host_path: &str, + access: MountAccess, + kind: MountKind, +) -> String { + let file = if kind == MountKind::File { ",file" } else { "" }; + format!("{name},{host_path},{access}{file}") +} + +/// Returns the `--mount` value that attaches `host_path` at `target`. It always gives the mode, +/// which OpenVMM reads after the last comma, so the host path may contain commas. +pub fn mount_argument(target: &str, host_path: &str, access: MountAccess) -> String { + format!("{target},{host_path},{access}") +} + +/// Plans the export of `mounts`, the live shares of one sandbox, through the planner of every +/// NVX caller, [`filesystem::plan`], after it checks them with [`validate_mounts`], each share +/// with [`SandboxMount::validated`], and their host paths with [`validate_mount_host_paths`]. +/// +/// Each share maps its host path at its guest target, read-only or read-write, and its denied, +/// allowed, and writable paths, joined to its absolute host path when relative, become the +/// export's denied, allowed, and write paths. The owner of the shares, which [`validate_mounts`] +/// requires to be one, becomes the export's, and the container root reserves the trees of its +/// runtime. Each share keeps the kind with which it was requested: a host path that the planner +/// finds to be of the other kind is refused, as [`SandboxMount::validated`] refuses it. Returns +/// `None` without shares. +/// +/// Fails as those checks, [`SandboxMount::absolute`], and [`filesystem::plan`] do. +pub fn plan_mounts(mounts: &[SandboxMount]) -> Result> { + validate_mounts(mounts)?; + for mount in mounts { + mount.validated()?; + } + validate_mount_host_paths(mounts)?; + plan_mounts_with(mounts, &paths::current_dir) +} + +/// Plans the export of `mounts` as [`plan_mounts`] does, without checking them against the host +/// first, with `cwd` as the current directory. +pub(crate) fn plan_mounts_with( + mounts: &[SandboxMount], + cwd: &dyn Fn() -> io::Result, +) -> Result> { + let Some(first) = mounts.first() else { + return Ok(None); + }; + let mut hosts = Vec::with_capacity(mounts.len()); + let (mut denied, mut allowed, mut write) = (Vec::new(), Vec::new(), Vec::new()); + for mount in mounts { + hosts.push(mount.absolute_with(cwd)?.host_path); + for (kind, paths) in mount.absolute_policy_paths_with(cwd)? { + match kind { + PolicyKind::Denied => &mut denied, + PolicyKind::Allowed => &mut allowed, + PolicyKind::Writable => &mut write, + } + .extend(paths.into_iter().map(PathBuf::from)); + } + } + let mappings: Vec> = mounts + .iter() + .zip(&hosts) + .map(|(mount, host)| Mapping { + name: "--mount", + path: host, + target: Target::Guest(&mount.guest_target), + read_only: mount.access == MountAccess::ReadOnly, + }) + .collect(); + let list = |kind: PolicyKind, paths| PathList { + name: kind.option(), + paths, + }; + let plan = filesystem::plan(&ExportRequest { + mappings: &mappings, + denied: list(PolicyKind::Denied, &denied), + allowed: list(PolicyKind::Allowed, &allowed), + write: list(PolicyKind::Writable, &write), + owner: match first.owner { + MountOwner::Vmm => Owner::Vmm, + MountOwner::Caller => Owner::Caller, + }, + reserved: ReservedTargets::CONTAINER_ROOT, + })?; + // The planner reads each host path's kind again; a directory that replaced a shared file + // since the share was requested is refused rather than shared whole, and so is a host path + // that now leads into another share's directory, where the share would have no root of its + // own. + if let Some(plan) = &plan { + for mount in mounts { + let Some(bind) = plan + .binds + .iter() + .find(|bind| bind.target == mount.guest_target && bind.source.is_empty()) + else { + return Err(refused(format!( + "sandbox mount host path {} lies inside the host directory of another share; \ + a share could reach files of another under its access mode", + mount.host_path.display() + ))); + }; + let file = plan.children.get(bind.child).map(|child| child.file); + if file != Some(mount.kind == MountKind::File) { + return Err(mount.kind_refusal()); + } + } + } + Ok(plan) +} diff --git a/nvx-rs/src/host/sandbox/mount/boot.rs b/nvx-rs/src/host/sandbox/mount/boot.rs new file mode 100644 index 000000000..f4ae4e5df --- /dev/null +++ b/nvx-rs/src/host/sandbox/mount/boot.rs @@ -0,0 +1,202 @@ +//! The shares of an export plan in the form that a launch delivers at boot, and the bootstrap +//! tokens that OpenVMM appends to the kernel command line for them. + +use std::path::Path; + +use super::request::command_line_fragment; +use super::{ + AGGREGATE_COMMAND_LINE_SUFFIX, AGGREGATE_MOUNT_TARGET, MountAccess, MountKind, MountOwner, + PolicyKind, aggregate_child_argument, aggregate_child_name, mount_argument, + validate_mount_target, +}; +use crate::error::Result; +use crate::host::filesystem::{ExportPlan, GUEST_EXPORT, Owner}; +use crate::host::sandbox::refused; + +/// The form in which a launch delivers an export plan at boot: OpenVMM's arguments that attach +/// its directories and files, the kernel parameters that the plan adds for them, and the +/// bootstrap tokens that OpenVMM appends to the kernel command line. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct BootShares { + /// OpenVMM's arguments: one directory share or an aggregate with its children, in order, the + /// access-policy paths of each, absolute, and the owner unless it is `vmm`. + pub arguments: Vec, + /// The kernel parameters: as [`boot_shares`] renders them, the `nvx_share=` tokens of an + /// aggregate, one for each child, in order, and none for one directory share; for a mapping + /// table, the `nvx_maps=` token of its number of mappings. + pub tokens: Vec, + /// The bootstrap tokens that OpenVMM appends after the kernel command line. + pub fragment: String, +} + +/// Renders `plan` in the form that the guest's shell reads at boot. +/// +/// The shell binds each exported directory or file at one target, so every child of the plan +/// must be mapped exactly once, at its root, with the access with which OpenVMM exports it, at a +/// target that [`validate_mount_target`] accepts. +/// One directory then uses OpenVMM's virtio-fs device directly, and anything else shares it as +/// the children of an aggregate, named by [`aggregate_child_name`]. +/// +/// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) for a plan that the boot +/// tokens cannot express, such as one that maps a path below a share's root, rather than extend +/// what the shell reads. +pub fn boot_shares(plan: &ExportPlan) -> Result { + let mut shares = Vec::with_capacity(plan.children.len()); + for (index, child) in plan.children.iter().enumerate() { + let root = path_text(&child.root)?; + let binds: Vec<_> = plan + .binds + .iter() + .filter(|bind| bind.child == index) + .collect(); + let bind = match binds[..] { + [bind] if bind.source.is_empty() => bind, + [bind] => { + return Err(refused(format!( + "the boot tokens of a share cannot map {}, a path below the share's root {root}", + child.root.join(&bind.source).display() + ))); + } + _ => { + return Err(refused(format!( + "the boot tokens of a share map {root} at one target, not {}", + binds.len() + ))); + } + }; + validate_mount_target(&bind.target)?; + // The tokens mount a share with the access with which OpenVMM exports it, so a bind of + // another access, such as a read-only bind of a directory whose writes OpenVMM narrows to + // a path inside it, would let the guest write what the plan keeps read-only. + if bind.read_only == child.writable { + let (bound, exported) = if child.writable { + ("read-only", "read-write") + } else { + ("read-write", "read-only") + }; + return Err(refused(format!( + "the boot tokens of a share cannot map {root} {bound} while OpenVMM exports it \ + {exported}" + ))); + } + let access = if child.writable { + MountAccess::ReadWrite + } else { + MountAccess::ReadOnly + }; + shares.push((child, root, bind.target.as_str(), access)); + } + if plan.binds.len() != shares.len() { + return Err(refused( + "the boot tokens of a share cannot map a path of an unknown host directory", + )); + } + let mut boot = BootShares::default(); + match shares[..] { + [] => return Ok(boot), + [(child, root, target, access)] if !child.file => { + boot.arguments = vec!["--mount".to_owned(), mount_argument(target, root, access)]; + boot.fragment = command_line_fragment(target, access); + } + _ => { + boot.arguments = vec![ + "--mount-aggregate".to_owned(), + AGGREGATE_MOUNT_TARGET.to_owned(), + ]; + for (index, &(child, root, target, access)) in shares.iter().enumerate() { + let name = aggregate_child_name(index, target); + let (kind, file) = if child.file { + (MountKind::File, ",file") + } else { + (MountKind::Directory, "") + }; + boot.arguments.push("--mount-child".to_owned()); + boot.arguments + .push(aggregate_child_argument(&name, root, access, kind)); + boot.tokens + .push(format!("nvx_share={name},{target},{access}{file}")); + } + let accesses: Vec = + shares.iter().map(|&(_, _, _, access)| access).collect(); + boot.fragment = aggregate_command_line_fragment(&accesses); + } + } + for &(child, ..) in &shares { + for (kind, paths) in [ + (PolicyKind::Denied, &child.denied), + (PolicyKind::Allowed, &child.allowed), + (PolicyKind::Writable, &child.write), + ] { + for path in paths { + boot.arguments.push(kind.option().to_owned()); + boot.arguments.push(path_text(path)?.to_owned()); + } + } + } + if plan.owner == Owner::Caller { + boot.arguments.push("--mount-owner".to_owned()); + boot.arguments.push(MountOwner::Caller.as_str().to_owned()); + } + Ok(boot) +} + +/// Returns the text of a host path of an export plan, which OpenVMM's options carry. +/// +/// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) for a path that is not +/// valid UTF-8. +pub(super) fn path_text(path: &Path) -> Result<&str> { + path.to_str() + .ok_or_else(|| refused(format!("{} is not valid UTF-8", path.display()))) +} + +/// Returns the kernel command-line tokens that tell the guest where to bind each child of an +/// aggregate, given each share's guest target, access, and kind. A file child's token ends in +/// `,file`, so the guest binds it at a file. +pub fn aggregate_share_tokens(shares: &[(&str, MountAccess, MountKind)]) -> Vec { + shares + .iter() + .enumerate() + .map(|(index, &(target, access, kind))| { + let file = if kind == MountKind::File { ",file" } else { "" }; + format!( + "nvx_share={},{target},{access}{file}", + aggregate_child_name(index, target) + ) + }) + .collect() +} + +/// Returns the bootstrap tokens that OpenVMM appends for an aggregate of shares with +/// `accesses`, which it mounts read-write if any child is. +pub fn aggregate_command_line_fragment(accesses: &[MountAccess]) -> String { + aggregate_fragment(AGGREGATE_MOUNT_TARGET, accesses) +} + +/// Returns the bootstrap tokens that OpenVMM appends for the export of `plan` at +/// [`GUEST_EXPORT`], the aggregate from which a managed sandbox's guest agent maps the plan's +/// mapping table. +pub(crate) fn export_command_line_fragment(plan: &ExportPlan) -> String { + let accesses: Vec = plan + .children + .iter() + .map(|child| { + if child.writable { + MountAccess::ReadWrite + } else { + MountAccess::ReadOnly + } + }) + .collect(); + aggregate_fragment(GUEST_EXPORT, &accesses) +} + +/// Returns the bootstrap tokens that OpenVMM appends for an aggregate at `target` whose children +/// have `accesses`, which it mounts read-write if any child is. +fn aggregate_fragment(target: &str, accesses: &[MountAccess]) -> String { + let access = if accesses.contains(&MountAccess::ReadWrite) { + MountAccess::ReadWrite + } else { + MountAccess::ReadOnly + }; + command_line_fragment(target, access) + AGGREGATE_COMMAND_LINE_SUFFIX +} diff --git a/nvx-rs/src/host/sandbox/mount/host_paths.rs b/nvx-rs/src/host/sandbox/mount/host_paths.rs new file mode 100644 index 000000000..915f051e9 --- /dev/null +++ b/nvx-rs/src/host/sandbox/mount/host_paths.rs @@ -0,0 +1,152 @@ +//! The checks of the host paths of shares: shares whose host paths overlap, and policy paths +//! outside their share. + +use std::io; +use std::path::{Path, PathBuf}; + +use super::boot::path_text; +use super::{PolicyKind, SandboxMount, os_error}; +use crate::error::Result; +use crate::host::filesystem::ExportPlan; +use crate::host::sandbox::refused; +use crate::host::{paths, platform}; + +/// The queries of the host that [`validate_mount_host_paths`] makes, which tests replace. +pub(crate) struct HostQueries<'a> { + /// The current directory, which [`paths::current_dir`] gives. + pub(crate) cwd: &'a dyn Fn() -> io::Result, + /// A path with its links resolved, as [`paths::resolve`] resolves them. + pub(crate) resolve: &'a dyn Fn(&Path) -> io::Result, + /// The identity of the file or directory at a path, following links, which + /// [`platform::file_identity`] gives. + pub(crate) identity: &'a dyn Fn(&Path) -> io::Result<(u64, u64)>, +} + +impl HostQueries<'_> { + /// Returns the queries of this host. + pub(crate) fn native() -> HostQueries<'static> { + HostQueries { + cwd: &paths::current_dir, + resolve: &paths::resolve, + identity: &platform::file_identity, + } + } +} + +/// Refuses shares whose host paths overlap, and policy paths outside their share. +/// +/// Two host paths overlap if one contains the other once both are resolved, or, since resolving +/// follows symbolic links but not bind mounts, if one has the file identity of the other or of +/// one of its ancestors, as a hard link or a path inside a bind mount of it does. A shared file +/// therefore overlaps a shared directory that contains it, or another share of the same file, +/// but not a file beside it. Each policy path must name a path inside the host directory of its +/// share, and only a denied path may name the share itself. +/// +/// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported), or +/// [`ErrorKind::Io`](crate::ErrorKind::Io) if a path cannot be resolved or examined. +pub fn validate_mount_host_paths(mounts: &[SandboxMount]) -> Result<()> { + validate_mount_host_paths_with(mounts, &HostQueries::native()) +} + +/// Implements [`validate_mount_host_paths`] with the given host queries. +pub(crate) fn validate_mount_host_paths_with( + mounts: &[SandboxMount], + host: &HostQueries<'_>, +) -> Result<()> { + let roots = mounts + .iter() + .map(|mount| { + (host.resolve)(&mount.host_path) + .map_err(|error| os_error(&error, Some(&mount.host_path))) + }) + .collect::>>()?; + if mounts.len() > 1 { + check_distinct_host_paths(&roots, host)?; + } + for (mount, root) in mounts.iter().zip(&roots) { + for (kind, paths) in mount.absolute_policy_paths_with(host.cwd)? { + let option = kind.option(); + for path in paths { + let resolved = (host.resolve)(Path::new(&path)) + .map_err(|error| os_error(&error, Some(Path::new(&path))))?; + // A denied root hides the share behind its allowed paths, which OpenVMM requires; + // it accepts no other policy path at a root. + if resolved.starts_with(root) && (resolved != *root || kind == PolicyKind::Denied) { + continue; + } + let several = if mounts.len() > 1 { + format!( + "; with several shares, each {option} follows the --mount whose \ + directory it names" + ) + } else { + String::new() + }; + return Err(refused(format!( + "{option} {path} is not inside the host directory of --mount {}{several}", + mount.guest_target + ))); + } + } + } + Ok(()) +} + +/// Refuses directories and files of `export` that came to overlap since it was planned: one that +/// a hard link or bind mount made the same object as another, or put inside another, so that a +/// share could reach the files of another under its access mode. [`plan_mounts`] refused such +/// host paths when it planned them, and a launch checks them again, since the host may have +/// changed meanwhile. +/// +/// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported), or +/// [`ErrorKind::Io`](crate::ErrorKind::Io) if a path cannot be examined. +/// +/// [`plan_mounts`]: crate::host::sandbox::mount::plan_mounts +pub(crate) fn check_distinct_export_roots( + export: &ExportPlan, + host: &HostQueries<'_>, +) -> Result<()> { + if export.children.len() < 2 { + return Ok(()); + } + let roots = export + .children + .iter() + .map(|child| path_text(&child.root).map(PathBuf::from)) + .collect::>>()?; + check_distinct_host_paths(&roots, host) +} + +/// Refuses resolved share roots that overlap, by path or by the file identity of a root and its +/// ancestors. +fn check_distinct_host_paths(roots: &[PathBuf], host: &HostQueries<'_>) -> Result<()> { + // The identity of each root, then of each of its ancestors. + let lineages = roots + .iter() + .map(|root| { + root.ancestors() + .map(|path| (host.identity)(path).map_err(|error| os_error(&error, Some(path)))) + .collect::>>() + }) + .collect::>>()?; + for (index, root) in roots.iter().enumerate() { + for (other_index, other) in roots[..index].iter().enumerate() { + let (shown, other_shown) = (root.display(), other.display()); + if root.starts_with(other) || other.starts_with(root) { + return Err(refused(format!( + "sandbox mount host paths {other_shown} and {shown} overlap; a share could \ + reach files of another under its access mode" + ))); + } + if lineages[other_index].contains(&lineages[index][0]) + || lineages[index].contains(&lineages[other_index][0]) + { + return Err(refused(format!( + "sandbox mount host paths {other_shown} and {shown} name the same directory \ + or file, or one is inside the other through a bind mount" + ))); + } + } + } + Ok(()) +} diff --git a/nvx-rs/src/host/sandbox/mount/mod.rs b/nvx-rs/src/host/sandbox/mount/mod.rs index 8554887bc..8d04a91cc 100644 --- a/nvx-rs/src/host/sandbox/mount/mod.rs +++ b/nvx-rs/src/host/sandbox/mount/mod.rs @@ -20,24 +20,42 @@ //! exposes a path inside a denied path again, and `--mount-write` makes a path one of the only //! writable parts of a read-write share. A relative path names a path in its share, and each //! must lie in the host directory of its share. +//! +//! [`filesystem::plan`]: crate::host::filesystem::plan +//! [`ExportPlan`]: crate::host::filesystem::ExportPlan +//! [`GUEST_EXPORT`]: crate::host::filesystem::GUEST_EXPORT -use std::fmt; -use std::io; -use std::path::{Component, Path, PathBuf}; +mod aggregate; +mod boot; +mod host_paths; +mod request; -use sha2::{Digest, Sha256}; +use std::fmt; -use super::{has_kernel_separator, refused}; -use crate::error::{Error, Result}; -use crate::host::filesystem::{ - self, ExportPlan, ExportRequest, GUEST_EXPORT, Mapping, Owner, PathList, ReservedTargets, - Target, within, -}; -use crate::host::{paths, platform}; -use crate::protocol::share::{self, RESERVED_TARGET_CHARACTERS, Reservation}; +use super::refused; +use crate::error::Result; pub use crate::protocol::share::{RESERVED_EXACT_MOUNT_TARGETS, RESERVED_MOUNT_TARGETS}; +pub use self::aggregate::{ + aggregate_child_argument, aggregate_child_name, mount_argument, plan_mounts, +}; +pub(crate) use self::boot::export_command_line_fragment; +pub use self::boot::{ + BootShares, aggregate_command_line_fragment, aggregate_share_tokens, boot_shares, +}; +pub use self::host_paths::validate_mount_host_paths; +#[cfg(test)] +pub(crate) use self::host_paths::validate_mount_host_paths_with; +pub(crate) use self::host_paths::{HostQueries, check_distinct_export_roots}; +pub use self::request::{ + SandboxMount, mount_kind, parse_mount_value, require_mount_owner_supported, + validate_mount_target, validate_mounts, +}; +pub(crate) use self::request::{ + has_forbidden_character, has_irregular_component, os_error, targets_overlap, +}; + /// The tag of OpenVMM's virtio-fs device. pub const MOUNT_TAG: &str = "microvm"; /// The guest directory at which the guest mounts the aggregate of several shares. @@ -250,893 +268,5 @@ impl MountPolicy { } } -/// Splits a `--mount` value, `GUEST_TARGET,HOST_PATH[,ro|rw]`, into its guest target, host path, -/// and access mode, which defaults to `ro`. -/// -/// The guest target ends at the first comma, and with another comma the mode follows the last -/// one, so a host path may contain commas when the mode is given. OpenVMM reads its `--mount` -/// and `--mount-child` values the same way. Fails with -/// [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported). -pub fn parse_mount_value(value: &str) -> Result<(String, String, MountAccess)> { - let Some((target, rest)) = value.split_once(',') else { - return Err(refused("--mount must be GUEST_TARGET,HOST_PATH[,ro|rw]")); - }; - let (host_path, access) = rest.rsplit_once(',').unwrap_or((rest, "ro")); - if host_path.is_empty() { - return Err(refused("--mount host path is empty")); - } - let access = match access { - "ro" => MountAccess::ReadOnly, - "rw" => MountAccess::ReadWrite, - _ => { - return Err(refused(format!( - "unsupported --mount mode {access:?}; choose ro or rw (if HOST_PATH contains commas, end \ - the value with ,ro or ,rw)", - ))); - } - }; - Ok((target.to_owned(), host_path.to_owned(), access)) -} - -/// Returns whether `target` cannot stay one kernel token or holds one of the -/// [`RESERVED_TARGET_CHARACTERS`], NUL, `\`, `=`, `,`, and `"`. -pub(crate) fn has_forbidden_character(target: &str) -> bool { - has_kernel_separator(target) - || target - .chars() - .any(|character| RESERVED_TARGET_CHARACTERS.contains(character)) -} - -/// Returns whether a guest target has an empty, `.`, or `..` component after its leading slash. -pub(crate) fn has_irregular_component(target: &str) -> bool { - target - .split('/') - .skip(1) - .any(|component| matches!(component, "" | "." | "..")) -} - -/// Checks a live share's guest target inside the sandbox container rootfs: an absolute non-root -/// path of at most [`MAX_MOUNT_TARGET_BYTES`] bytes, without empty, dot, or parent components, -/// whitespace, control characters, quotes, `\`, `=`, or `,`, and outside the -/// [`RESERVED_MOUNT_TARGETS`] trees and the -/// [`RESERVED_EXACT_MOUNT_TARGETS`]. -/// -/// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported). -pub fn validate_mount_target(target: &str) -> Result<()> { - if !target.starts_with('/') - || target == "/" - || target.len() > MAX_MOUNT_TARGET_BYTES - || has_forbidden_character(target) - || has_irregular_component(target) - { - return Err(refused(format!( - "invalid sandbox mount target {target:?}: expected an absolute non-root path without empty, \ - dot, or parent components, whitespace, control characters, quotes, '\\', '=', or ','", - ))); - } - match share::reservation(target.as_bytes()) { - Some(Reservation::Tree(reserved)) => Err(refused(format!( - "sandbox mount target {target} overlaps the reserved {reserved} tree" - ))), - Some(Reservation::Exact) => Err(refused(format!( - "sandbox mount target {target} is reserved" - ))), - None => Ok(()), - } -} - -/// Refuses an owner that this host cannot enforce: `caller` on Windows, which has no -/// per-request POSIX identity for OpenVMM to switch to. -/// -/// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported). -pub fn require_mount_owner_supported(owner: MountOwner) -> Result<()> { - require_mount_owner_supported_on(owner, cfg!(windows)) -} - -/// Implements [`require_mount_owner_supported`] for a Windows host or another. -pub(crate) fn require_mount_owner_supported_on(owner: MountOwner, windows: bool) -> Result<()> { - if owner == MountOwner::Caller && windows { - return Err(refused( - "--mount-owner caller requires a Linux host; Windows has no per-request POSIX \ - identity for OpenVMM to switch to", - )); - } - Ok(()) -} - -/// Returns whether two guest targets overlap: whether they are equal, or one contains the other. -pub(crate) fn targets_overlap(left: &str, right: &str) -> bool { - within(left, right) || within(right, left) -} - -/// Returns the kind of share that `host_path` requests: a file for a regular file, and -/// otherwise a directory, which [`SandboxMount::validated`] then requires. -pub fn mount_kind(host_path: &Path) -> MountKind { - if host_path.is_file() { - MountKind::File - } else { - MountKind::Directory - } -} - -/// Returns a native I/O error with its path, when available. -pub(crate) fn os_error(error: &io::Error, path: Option<&Path>) -> Error { - Error::io(match path { - Some(path) => format!("{path:?}: {error}"), - None => error.to_string(), - }) -} - -/// A live virtio-fs share mounted inside the sandbox container rootfs. -/// -/// Its denied paths hide paths in the share, its allowed paths expose paths inside denied paths -/// again, and its writable paths, when present, are the only paths of a read-write share that -/// the workload can modify. A share of a file binds one regular host file, whose mode applies to -/// the whole file, so it has none of them. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct SandboxMount { - /// The guest directory or file at which the share appears. - guest_target: String, - /// The host directory or file, with its components normalized. - host_path: PathBuf, - /// The access mode. - access: MountAccess, - /// The access-policy paths. - policy: MountPolicy, - /// The identity of the share's file operations. - owner: MountOwner, - /// Whether the share binds a directory or a file. - kind: MountKind, -} - -impl SandboxMount { - /// Returns the share of `host_path` at `guest_target`, with `host_path` spelled with its - /// components alone, as [`Path::components`] reads them. - /// - /// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) for a guest target - /// that [`validate_mount_target`] refuses, a host path with a NUL character or a `..` - /// component, policy paths for a file, more than [`MAX_MOUNT_POLICY_PATHS`] policy paths of a - /// kind, empty or repeated ones, allowed paths without denied paths, and writable paths of a - /// read-only share. - pub fn new( - guest_target: &str, - host_path: &str, - access: MountAccess, - policy: MountPolicy, - owner: MountOwner, - kind: MountKind, - ) -> Result { - Self::checked(Self { - guest_target: guest_target.to_owned(), - host_path: paths::normalized(Path::new(host_path)), - access, - policy, - owner, - kind, - }) - } - - /// Parses a `--mount` value with [`parse_mount_value`] into a share of the given policy, - /// owner, and kind. - /// - /// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported), as - /// [`parse_mount_value`] and [`new`](Self::new) do. - pub fn parse( - value: &str, - policy: MountPolicy, - owner: MountOwner, - kind: MountKind, - ) -> Result { - let (guest_target, host_path, access) = parse_mount_value(value)?; - Self::new(&guest_target, &host_path, access, policy, owner, kind) - } - - /// Checks the target, host path, kind, and access policy, in that order. - fn checked(self) -> Result { - validate_mount_target(&self.guest_target)?; - let raw_path = self.host_path.as_os_str(); - if raw_path.as_encoded_bytes().contains(&0) { - return Err(refused(format!( - "sandbox mount host path contains a NUL character: {raw_path:?}", - ))); - } - if self - .host_path - .components() - .any(|component| component == Component::ParentDir) - { - return Err(refused(format!( - "sandbox mount host path contains a parent component: {}", - self.host_path.display() - ))); - } - if self.kind == MountKind::File && !self.policy.is_empty() { - return Err(refused(format!( - "--mount-deny, --mount-allow, and --mount-write name paths inside a shared \ - directory, but --mount {} shares a file, whose mode applies to the whole file", - self.guest_target - ))); - } - for kind in POLICY_KINDS { - let paths = self.policy.paths(kind); - if paths.len() > MAX_MOUNT_POLICY_PATHS { - return Err(refused(format!( - "a sandbox mount permits at most {MAX_MOUNT_POLICY_PATHS} {kind} paths" - ))); - } - if paths - .iter() - .any(|path| path.is_empty() || path.contains('\0')) - { - return Err(refused(format!( - "sandbox mount {kind} paths must be nonempty" - ))); - } - let unique: std::collections::HashSet<&String> = paths.iter().collect(); - if unique.len() != paths.len() { - return Err(refused(format!( - "sandbox mount {kind} paths must be unique" - ))); - } - } - if !self.policy.allowed.is_empty() && self.policy.denied.is_empty() { - return Err(refused( - "--mount-allow exposes a path inside a --mount-deny path of the same share, \ - which has none", - )); - } - if !self.policy.writable.is_empty() && self.access != MountAccess::ReadWrite { - return Err(refused(format!( - "--mount-write narrows the writes of a read-write share; --mount {} is read-only", - self.guest_target - ))); - } - Ok(self) - } - - /// Returns the share with another kind, checked again, as `dataclasses.replace` does. - /// - /// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) for a file share - /// with policy paths. - pub fn with_kind(&self, kind: MountKind) -> Result { - Self::checked(Self { - kind, - ..self.clone() - }) - } - - /// Returns the guest target. - pub fn guest_target(&self) -> &str { - &self.guest_target - } - - /// Returns the host directory or file. - pub fn host_path(&self) -> &Path { - &self.host_path - } - - /// Returns the access mode. - pub fn access(&self) -> MountAccess { - self.access - } - - /// Returns the access-policy paths. - pub fn policy(&self) -> &MountPolicy { - &self.policy - } - - /// Returns the identity of the share's file operations. - pub fn owner(&self) -> MountOwner { - self.owner - } - - /// Returns whether the share binds a directory or a file. - pub fn kind(&self) -> MountKind { - self.kind - } - - /// Checks the share against the host: its owner must be one that the host supports, its - /// host path must be a regular file or a plain directory, as its kind requires, without - /// being a symbolic link, which on Windows includes a junction, as [`Path::is_symlink`] tells - /// them, and with the `caller` owner on Linux, it must not belong to UID 0 or GID 0, to which - /// guest root would be squashed. - /// - /// The kind was fixed when the share was requested, so a directory that replaced a shared - /// file is not shared in its place. Fails with - /// [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported), or - /// [`ErrorKind::Io`](crate::ErrorKind::Io) if the host path cannot be examined. - pub fn validated(&self) -> Result<()> { - require_mount_owner_supported(self.owner)?; - let host_path = self.host_path.as_path(); - let kind_matches = match self.kind { - MountKind::File => host_path.is_file(), - MountKind::Directory => host_path.is_dir(), - }; - if host_path.is_symlink() || !kind_matches { - return Err(self.kind_refusal()); - } - #[cfg(unix)] - if self.owner == MountOwner::Caller { - use std::os::unix::fs::MetadataExt; - - let metadata = - std::fs::metadata(host_path).map_err(|error| os_error(&error, Some(host_path)))?; - if metadata.uid() == 0 || metadata.gid() == 0 { - return Err(refused(format!( - "--mount-owner caller squashes guest root to the owner of the shared {}, so \ - {} must not be owned by UID 0 or GID 0", - self.kind, - host_path.display() - ))); - } - } - Ok(()) - } - - /// Returns the error for a host path that is not of the share's kind. - fn kind_refusal(&self) -> Error { - let shown = self.host_path.display(); - refused(match self.kind { - MountKind::File => format!("sandbox mount host path is not a regular file: {shown}"), - MountKind::Directory => { - format!("sandbox mount host path is not a plain directory: {shown}") - } - }) - } - - /// Returns the share with its host path made absolute, as the one function of NVX that makes - /// host paths absolute makes it: joined to the current directory if it is relative, without - /// resolving it, so OpenVMM still sees, and refuses, every symbolic-link component of the - /// path that was requested. On Windows, a path with another drive and no root, such as - /// `D:relative`, is below the current directory of its drive. - /// - /// Fails with [`ErrorKind::Io`](crate::ErrorKind::Io) if a relative host path meets an - /// unreadable current directory, or Windows cannot qualify a drive-relative path. - pub fn absolute(&self) -> Result { - self.absolute_with(&paths::current_dir) - } - - /// Implements [`absolute`](Self::absolute) with `cwd` as the current directory. - pub(crate) fn absolute_with(&self, cwd: &dyn Fn() -> io::Result) -> Result { - if self.host_path.is_absolute() { - return Ok(self.clone()); - } - let directory = cwd().map_err(|error| os_error(&error, None))?; - Self::checked(Self { - host_path: absolute_host_path(&directory, &self.host_path)?, - ..self.clone() - }) - } - - /// Returns each kind of access-policy path with its requested paths, relative paths joined - /// to the absolute host path of the share, as OpenVMM attributes them to the children of an - /// aggregate. - /// - /// Fails as [`absolute`](Self::absolute) does. - pub fn absolute_policy_paths(&self) -> Result)>> { - self.absolute_policy_paths_with(&paths::current_dir) - } - - /// Implements [`absolute_policy_paths`](Self::absolute_policy_paths) with `cwd` as the - /// current directory. - pub(crate) fn absolute_policy_paths_with( - &self, - cwd: &dyn Fn() -> io::Result, - ) -> Result)>> { - let host_path = self.absolute_with(cwd)?.host_path; - POLICY_KINDS - .iter() - .map(|&kind| { - let paths = self - .policy - .paths(kind) - .iter() - .map(|path| { - if Path::new(path).is_absolute() { - return Ok(path.clone()); - } - let joined = - paths::normalized(&absolute_host_path(&host_path, Path::new(path))?); - paths::text(&joined) - .map(str::to_owned) - .map_err(|error| os_error(&error, None)) - }) - .collect::>>()?; - Ok((kind, paths)) - }) - .collect() - } -} - -/// Returns `path` joined to `directory` without resolving links, as [`paths::absolute_in`] joins -/// them. -fn absolute_host_path(directory: &Path, path: &Path) -> Result { - paths::absolute_in(directory, path).map_err(|error| os_error(&error, Some(path))) -} - -/// Returns the bootstrap tokens of a share at `target` with `access`. -fn command_line_fragment(target: &str, access: MountAccess) -> String { - let [directory, tag, mode] = MOUNT_COMMAND_LINE_FRAGMENT; - format!("{directory}{target}{tag}{MOUNT_TAG}{mode}{access}") -} - -/// Checks the live shares that one sandbox attaches, in child order: no target may overlap -/// another, so that no share hides another, and every share has the same owner. -/// -/// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported). -pub fn validate_mounts(mounts: &[SandboxMount]) -> Result<()> { - for (index, mount) in mounts.iter().enumerate() { - for other in &mounts[..index] { - if targets_overlap(&other.guest_target, &mount.guest_target) { - return Err(refused(format!( - "sandbox mount targets {} and {} overlap; a share cannot hide another", - other.guest_target, mount.guest_target - ))); - } - } - } - if mounts.iter().any(|mount| mount.owner != mounts[0].owner) { - return Err(refused( - "every sandbox --mount must use the same --mount-owner", - )); - } - Ok(()) -} - -/// The queries of the host that [`validate_mount_host_paths`] makes, which tests replace. -pub(crate) struct HostQueries<'a> { - /// The current directory, which [`paths::current_dir`] gives. - pub(crate) cwd: &'a dyn Fn() -> io::Result, - /// A path with its links resolved, as [`paths::resolve`] resolves them. - pub(crate) resolve: &'a dyn Fn(&Path) -> io::Result, - /// The identity of the file or directory at a path, following links, which - /// [`platform::file_identity`] gives. - pub(crate) identity: &'a dyn Fn(&Path) -> io::Result<(u64, u64)>, -} - -impl HostQueries<'_> { - /// Returns the queries of this host. - pub(crate) fn native() -> HostQueries<'static> { - HostQueries { - cwd: &paths::current_dir, - resolve: &paths::resolve, - identity: &platform::file_identity, - } - } -} - -/// Refuses shares whose host paths overlap, and policy paths outside their share. -/// -/// Two host paths overlap if one contains the other once both are resolved, or, since resolving -/// follows symbolic links but not bind mounts, if one has the file identity of the other or of -/// one of its ancestors, as a hard link or a path inside a bind mount of it does. A shared file -/// therefore overlaps a shared directory that contains it, or another share of the same file, -/// but not a file beside it. Each policy path must name a path inside the host directory of its -/// share, and only a denied path may name the share itself. -/// -/// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported), or -/// [`ErrorKind::Io`](crate::ErrorKind::Io) if a path cannot be resolved or examined. -pub fn validate_mount_host_paths(mounts: &[SandboxMount]) -> Result<()> { - validate_mount_host_paths_with(mounts, &HostQueries::native()) -} - -/// Implements [`validate_mount_host_paths`] with the given host queries. -pub(crate) fn validate_mount_host_paths_with( - mounts: &[SandboxMount], - host: &HostQueries<'_>, -) -> Result<()> { - let roots = mounts - .iter() - .map(|mount| { - (host.resolve)(&mount.host_path) - .map_err(|error| os_error(&error, Some(&mount.host_path))) - }) - .collect::>>()?; - if mounts.len() > 1 { - check_distinct_host_paths(&roots, host)?; - } - for (mount, root) in mounts.iter().zip(&roots) { - for (kind, paths) in mount.absolute_policy_paths_with(host.cwd)? { - let option = kind.option(); - for path in paths { - let resolved = (host.resolve)(Path::new(&path)) - .map_err(|error| os_error(&error, Some(Path::new(&path))))?; - // A denied root hides the share behind its allowed paths, which OpenVMM requires; - // it accepts no other policy path at a root. - if resolved.starts_with(root) && (resolved != *root || kind == PolicyKind::Denied) { - continue; - } - let several = if mounts.len() > 1 { - format!( - "; with several shares, each {option} follows the --mount whose \ - directory it names" - ) - } else { - String::new() - }; - return Err(refused(format!( - "{option} {path} is not inside the host directory of --mount {}{several}", - mount.guest_target - ))); - } - } - } - Ok(()) -} - -/// Refuses directories and files of `export` that came to overlap since it was planned: one that -/// a hard link or bind mount made the same object as another, or put inside another, so that a -/// share could reach the files of another under its access mode. [`plan_mounts`] refused such -/// host paths when it planned them, and a launch checks them again, since the host may have -/// changed meanwhile. -/// -/// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported), or -/// [`ErrorKind::Io`](crate::ErrorKind::Io) if a path cannot be examined. -pub(crate) fn check_distinct_export_roots( - export: &ExportPlan, - host: &HostQueries<'_>, -) -> Result<()> { - if export.children.len() < 2 { - return Ok(()); - } - let roots = export - .children - .iter() - .map(|child| path_text(&child.root).map(PathBuf::from)) - .collect::>>()?; - check_distinct_host_paths(&roots, host) -} - -/// Refuses resolved share roots that overlap, by path or by the file identity of a root and its -/// ancestors. -fn check_distinct_host_paths(roots: &[PathBuf], host: &HostQueries<'_>) -> Result<()> { - // The identity of each root, then of each of its ancestors. - let lineages = roots - .iter() - .map(|root| { - root.ancestors() - .map(|path| (host.identity)(path).map_err(|error| os_error(&error, Some(path)))) - .collect::>>() - }) - .collect::>>()?; - for (index, root) in roots.iter().enumerate() { - for (other_index, other) in roots[..index].iter().enumerate() { - let (shown, other_shown) = (root.display(), other.display()); - if root.starts_with(other) || other.starts_with(root) { - return Err(refused(format!( - "sandbox mount host paths {other_shown} and {shown} overlap; a share could \ - reach files of another under its access mode" - ))); - } - if lineages[other_index].contains(&lineages[index][0]) - || lineages[index].contains(&lineages[other_index][0]) - { - return Err(refused(format!( - "sandbox mount host paths {other_shown} and {shown} name the same directory \ - or file, or one is inside the other through a bind mount" - ))); - } - } - } - Ok(()) -} - -/// Returns the name of the aggregate child at `index` that the guest binds at `target`: the -/// index, a hyphen, and the first 32 hexadecimal digits of the SHA-256 digest of the target. -/// -/// A snapshot records every child's name, so it pins each target, and OpenVMM refuses a restore -/// that requests other targets before the guest runs, rather than keep the snapshot's binds. -pub fn aggregate_child_name(index: usize, target: &str) -> String { - let digest = Sha256::digest(target.as_bytes()); - let hex: String = digest[..16] - .iter() - .map(|byte| format!("{byte:02x}")) - .collect(); - format!("{index}-{hex}") -} - -/// Returns the `--mount-child` value that attaches `host_path` as the child `name`. It always -/// gives the mode, which OpenVMM reads after the last comma or before the `file` flag, so the -/// host path may contain commas. A file child carries OpenVMM's `file` flag, so OpenVMM refuses -/// a directory that replaced the file instead of exporting all of it. -pub fn aggregate_child_argument( - name: &str, - host_path: &str, - access: MountAccess, - kind: MountKind, -) -> String { - let file = if kind == MountKind::File { ",file" } else { "" }; - format!("{name},{host_path},{access}{file}") -} - -/// Returns the `--mount` value that attaches `host_path` at `target`. It always gives the mode, -/// which OpenVMM reads after the last comma, so the host path may contain commas. -pub fn mount_argument(target: &str, host_path: &str, access: MountAccess) -> String { - format!("{target},{host_path},{access}") -} - -/// Plans the export of `mounts`, the live shares of one sandbox, through the planner of every -/// NVX caller, [`filesystem::plan`], after it checks them with [`validate_mounts`], each share -/// with [`SandboxMount::validated`], and their host paths with [`validate_mount_host_paths`]. -/// -/// Each share maps its host path at its guest target, read-only or read-write, and its denied, -/// allowed, and writable paths, joined to its absolute host path when relative, become the -/// export's denied, allowed, and write paths. The owner of the shares, which [`validate_mounts`] -/// requires to be one, becomes the export's, and the container root reserves the trees of its -/// runtime. Each share keeps the kind with which it was requested: a host path that the planner -/// finds to be of the other kind is refused, as [`SandboxMount::validated`] refuses it. Returns -/// `None` without shares. -/// -/// Fails as those checks, [`SandboxMount::absolute`], and [`filesystem::plan`] do. -pub fn plan_mounts(mounts: &[SandboxMount]) -> Result> { - validate_mounts(mounts)?; - for mount in mounts { - mount.validated()?; - } - validate_mount_host_paths(mounts)?; - plan_mounts_with(mounts, &paths::current_dir) -} - -/// Plans the export of `mounts` as [`plan_mounts`] does, without checking them against the host -/// first, with `cwd` as the current directory. -pub(crate) fn plan_mounts_with( - mounts: &[SandboxMount], - cwd: &dyn Fn() -> io::Result, -) -> Result> { - let Some(first) = mounts.first() else { - return Ok(None); - }; - let mut hosts = Vec::with_capacity(mounts.len()); - let (mut denied, mut allowed, mut write) = (Vec::new(), Vec::new(), Vec::new()); - for mount in mounts { - hosts.push(mount.absolute_with(cwd)?.host_path); - for (kind, paths) in mount.absolute_policy_paths_with(cwd)? { - match kind { - PolicyKind::Denied => &mut denied, - PolicyKind::Allowed => &mut allowed, - PolicyKind::Writable => &mut write, - } - .extend(paths.into_iter().map(PathBuf::from)); - } - } - let mappings: Vec> = mounts - .iter() - .zip(&hosts) - .map(|(mount, host)| Mapping { - name: "--mount", - path: host, - target: Target::Guest(&mount.guest_target), - read_only: mount.access == MountAccess::ReadOnly, - }) - .collect(); - let list = |kind: PolicyKind, paths| PathList { - name: kind.option(), - paths, - }; - let plan = filesystem::plan(&ExportRequest { - mappings: &mappings, - denied: list(PolicyKind::Denied, &denied), - allowed: list(PolicyKind::Allowed, &allowed), - write: list(PolicyKind::Writable, &write), - owner: match first.owner { - MountOwner::Vmm => Owner::Vmm, - MountOwner::Caller => Owner::Caller, - }, - reserved: ReservedTargets::CONTAINER_ROOT, - })?; - // The planner reads each host path's kind again; a directory that replaced a shared file - // since the share was requested is refused rather than shared whole, and so is a host path - // that now leads into another share's directory, where the share would have no root of its - // own. - if let Some(plan) = &plan { - for mount in mounts { - let Some(bind) = plan - .binds - .iter() - .find(|bind| bind.target == mount.guest_target && bind.source.is_empty()) - else { - return Err(refused(format!( - "sandbox mount host path {} lies inside the host directory of another share; \ - a share could reach files of another under its access mode", - mount.host_path.display() - ))); - }; - let file = plan.children.get(bind.child).map(|child| child.file); - if file != Some(mount.kind == MountKind::File) { - return Err(mount.kind_refusal()); - } - } - } - Ok(plan) -} - -/// The form in which a launch delivers an export plan at boot: OpenVMM's arguments that attach -/// its directories and files, the kernel parameters that the plan adds for them, and the -/// bootstrap tokens that OpenVMM appends to the kernel command line. -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct BootShares { - /// OpenVMM's arguments: one directory share or an aggregate with its children, in order, the - /// access-policy paths of each, absolute, and the owner unless it is `vmm`. - pub arguments: Vec, - /// The kernel parameters: as [`boot_shares`] renders them, the `nvx_share=` tokens of an - /// aggregate, one for each child, in order, and none for one directory share; for a mapping - /// table, the `nvx_maps=` token of its number of mappings. - pub tokens: Vec, - /// The bootstrap tokens that OpenVMM appends after the kernel command line. - pub fragment: String, -} - -/// Renders `plan` in the form that the guest's shell reads at boot. -/// -/// The shell binds each exported directory or file at one target, so every child of the plan -/// must be mapped exactly once, at its root, with the access with which OpenVMM exports it, at a -/// target that [`validate_mount_target`] accepts. -/// One directory then uses OpenVMM's virtio-fs device directly, and anything else shares it as -/// the children of an aggregate, named by [`aggregate_child_name`]. -/// -/// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) for a plan that the boot -/// tokens cannot express, such as one that maps a path below a share's root, rather than extend -/// what the shell reads. -pub fn boot_shares(plan: &ExportPlan) -> Result { - let mut shares = Vec::with_capacity(plan.children.len()); - for (index, child) in plan.children.iter().enumerate() { - let root = path_text(&child.root)?; - let binds: Vec<_> = plan - .binds - .iter() - .filter(|bind| bind.child == index) - .collect(); - let bind = match binds[..] { - [bind] if bind.source.is_empty() => bind, - [bind] => { - return Err(refused(format!( - "the boot tokens of a share cannot map {}, a path below the share's root {root}", - child.root.join(&bind.source).display() - ))); - } - _ => { - return Err(refused(format!( - "the boot tokens of a share map {root} at one target, not {}", - binds.len() - ))); - } - }; - validate_mount_target(&bind.target)?; - // The tokens mount a share with the access with which OpenVMM exports it, so a bind of - // another access, such as a read-only bind of a directory whose writes OpenVMM narrows to - // a path inside it, would let the guest write what the plan keeps read-only. - if bind.read_only == child.writable { - let (bound, exported) = if child.writable { - ("read-only", "read-write") - } else { - ("read-write", "read-only") - }; - return Err(refused(format!( - "the boot tokens of a share cannot map {root} {bound} while OpenVMM exports it \ - {exported}" - ))); - } - let access = if child.writable { - MountAccess::ReadWrite - } else { - MountAccess::ReadOnly - }; - shares.push((child, root, bind.target.as_str(), access)); - } - if plan.binds.len() != shares.len() { - return Err(refused( - "the boot tokens of a share cannot map a path of an unknown host directory", - )); - } - let mut boot = BootShares::default(); - match shares[..] { - [] => return Ok(boot), - [(child, root, target, access)] if !child.file => { - boot.arguments = vec!["--mount".to_owned(), mount_argument(target, root, access)]; - boot.fragment = command_line_fragment(target, access); - } - _ => { - boot.arguments = vec![ - "--mount-aggregate".to_owned(), - AGGREGATE_MOUNT_TARGET.to_owned(), - ]; - for (index, &(child, root, target, access)) in shares.iter().enumerate() { - let name = aggregate_child_name(index, target); - let (kind, file) = if child.file { - (MountKind::File, ",file") - } else { - (MountKind::Directory, "") - }; - boot.arguments.push("--mount-child".to_owned()); - boot.arguments - .push(aggregate_child_argument(&name, root, access, kind)); - boot.tokens - .push(format!("nvx_share={name},{target},{access}{file}")); - } - let accesses: Vec = - shares.iter().map(|&(_, _, _, access)| access).collect(); - boot.fragment = aggregate_command_line_fragment(&accesses); - } - } - for &(child, ..) in &shares { - for (kind, paths) in [ - (PolicyKind::Denied, &child.denied), - (PolicyKind::Allowed, &child.allowed), - (PolicyKind::Writable, &child.write), - ] { - for path in paths { - boot.arguments.push(kind.option().to_owned()); - boot.arguments.push(path_text(path)?.to_owned()); - } - } - } - if plan.owner == Owner::Caller { - boot.arguments.push("--mount-owner".to_owned()); - boot.arguments.push(MountOwner::Caller.as_str().to_owned()); - } - Ok(boot) -} - -/// Returns the text of a host path of an export plan, which OpenVMM's options carry. -/// -/// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) for a path that is not -/// valid UTF-8. -fn path_text(path: &Path) -> Result<&str> { - path.to_str() - .ok_or_else(|| refused(format!("{} is not valid UTF-8", path.display()))) -} - -/// Returns the kernel command-line tokens that tell the guest where to bind each child of an -/// aggregate, given each share's guest target, access, and kind. A file child's token ends in -/// `,file`, so the guest binds it at a file. -pub fn aggregate_share_tokens(shares: &[(&str, MountAccess, MountKind)]) -> Vec { - shares - .iter() - .enumerate() - .map(|(index, &(target, access, kind))| { - let file = if kind == MountKind::File { ",file" } else { "" }; - format!( - "nvx_share={},{target},{access}{file}", - aggregate_child_name(index, target) - ) - }) - .collect() -} - -/// Returns the bootstrap tokens that OpenVMM appends for an aggregate of shares with -/// `accesses`, which it mounts read-write if any child is. -pub fn aggregate_command_line_fragment(accesses: &[MountAccess]) -> String { - aggregate_fragment(AGGREGATE_MOUNT_TARGET, accesses) -} - -/// Returns the bootstrap tokens that OpenVMM appends for the export of `plan` at -/// [`GUEST_EXPORT`], the aggregate from which a managed sandbox's guest agent maps the plan's -/// mapping table. -pub(crate) fn export_command_line_fragment(plan: &ExportPlan) -> String { - let accesses: Vec = plan - .children - .iter() - .map(|child| { - if child.writable { - MountAccess::ReadWrite - } else { - MountAccess::ReadOnly - } - }) - .collect(); - aggregate_fragment(GUEST_EXPORT, &accesses) -} - -/// Returns the bootstrap tokens that OpenVMM appends for an aggregate at `target` whose children -/// have `accesses`, which it mounts read-write if any child is. -fn aggregate_fragment(target: &str, accesses: &[MountAccess]) -> String { - let access = if accesses.contains(&MountAccess::ReadWrite) { - MountAccess::ReadWrite - } else { - MountAccess::ReadOnly - }; - command_line_fragment(target, access) + AGGREGATE_COMMAND_LINE_SUFFIX -} - #[cfg(test)] mod tests; diff --git a/nvx-rs/src/host/sandbox/mount/request.rs b/nvx-rs/src/host/sandbox/mount/request.rs new file mode 100644 index 000000000..e7050f6fa --- /dev/null +++ b/nvx-rs/src/host/sandbox/mount/request.rs @@ -0,0 +1,451 @@ +//! The requests of shares: the parsing and checks of `--mount` values and guest targets, and +//! [`SandboxMount`], one share with its access, owner, kind, and access-policy paths. + +use std::io; +use std::path::{Component, Path, PathBuf}; + +use super::{ + MAX_MOUNT_POLICY_PATHS, MAX_MOUNT_TARGET_BYTES, MOUNT_COMMAND_LINE_FRAGMENT, MOUNT_TAG, + MountAccess, MountKind, MountOwner, MountPolicy, POLICY_KINDS, PolicyKind, +}; +use crate::error::{Error, Result}; +use crate::host::filesystem::within; +use crate::host::paths; +use crate::host::sandbox::{has_kernel_separator, refused}; +use crate::protocol::share::{self, RESERVED_TARGET_CHARACTERS, Reservation}; + +/// Splits a `--mount` value, `GUEST_TARGET,HOST_PATH[,ro|rw]`, into its guest target, host path, +/// and access mode, which defaults to `ro`. +/// +/// The guest target ends at the first comma, and with another comma the mode follows the last +/// one, so a host path may contain commas when the mode is given. OpenVMM reads its `--mount` +/// and `--mount-child` values the same way. Fails with +/// [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported). +pub fn parse_mount_value(value: &str) -> Result<(String, String, MountAccess)> { + let Some((target, rest)) = value.split_once(',') else { + return Err(refused("--mount must be GUEST_TARGET,HOST_PATH[,ro|rw]")); + }; + let (host_path, access) = rest.rsplit_once(',').unwrap_or((rest, "ro")); + if host_path.is_empty() { + return Err(refused("--mount host path is empty")); + } + let access = match access { + "ro" => MountAccess::ReadOnly, + "rw" => MountAccess::ReadWrite, + _ => { + return Err(refused(format!( + "unsupported --mount mode {access:?}; choose ro or rw (if HOST_PATH contains commas, end \ + the value with ,ro or ,rw)", + ))); + } + }; + Ok((target.to_owned(), host_path.to_owned(), access)) +} + +/// Returns whether `target` cannot stay one kernel token or holds one of the +/// [`RESERVED_TARGET_CHARACTERS`], NUL, `\`, `=`, `,`, and `"`. +pub(crate) fn has_forbidden_character(target: &str) -> bool { + has_kernel_separator(target) + || target + .chars() + .any(|character| RESERVED_TARGET_CHARACTERS.contains(character)) +} + +/// Returns whether a guest target has an empty, `.`, or `..` component after its leading slash. +pub(crate) fn has_irregular_component(target: &str) -> bool { + target + .split('/') + .skip(1) + .any(|component| matches!(component, "" | "." | "..")) +} + +/// Checks a live share's guest target inside the sandbox container rootfs: an absolute non-root +/// path of at most [`MAX_MOUNT_TARGET_BYTES`] bytes, without empty, dot, or parent components, +/// whitespace, control characters, quotes, `\`, `=`, or `,`, and outside the +/// [`RESERVED_MOUNT_TARGETS`] trees and the +/// [`RESERVED_EXACT_MOUNT_TARGETS`]. +/// +/// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported). +/// +/// [`RESERVED_MOUNT_TARGETS`]: crate::host::sandbox::mount::RESERVED_MOUNT_TARGETS +/// [`RESERVED_EXACT_MOUNT_TARGETS`]: crate::host::sandbox::mount::RESERVED_EXACT_MOUNT_TARGETS +pub fn validate_mount_target(target: &str) -> Result<()> { + if !target.starts_with('/') + || target == "/" + || target.len() > MAX_MOUNT_TARGET_BYTES + || has_forbidden_character(target) + || has_irregular_component(target) + { + return Err(refused(format!( + "invalid sandbox mount target {target:?}: expected an absolute non-root path without empty, \ + dot, or parent components, whitespace, control characters, quotes, '\\', '=', or ','", + ))); + } + match share::reservation(target.as_bytes()) { + Some(Reservation::Tree(reserved)) => Err(refused(format!( + "sandbox mount target {target} overlaps the reserved {reserved} tree" + ))), + Some(Reservation::Exact) => Err(refused(format!( + "sandbox mount target {target} is reserved" + ))), + None => Ok(()), + } +} + +/// Refuses an owner that this host cannot enforce: `caller` on Windows, which has no +/// per-request POSIX identity for OpenVMM to switch to. +/// +/// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported). +pub fn require_mount_owner_supported(owner: MountOwner) -> Result<()> { + require_mount_owner_supported_on(owner, cfg!(windows)) +} + +/// Implements [`require_mount_owner_supported`] for a Windows host or another. +pub(crate) fn require_mount_owner_supported_on(owner: MountOwner, windows: bool) -> Result<()> { + if owner == MountOwner::Caller && windows { + return Err(refused( + "--mount-owner caller requires a Linux host; Windows has no per-request POSIX \ + identity for OpenVMM to switch to", + )); + } + Ok(()) +} + +/// Returns whether two guest targets overlap: whether they are equal, or one contains the other. +pub(crate) fn targets_overlap(left: &str, right: &str) -> bool { + within(left, right) || within(right, left) +} + +/// Returns the kind of share that `host_path` requests: a file for a regular file, and +/// otherwise a directory, which [`SandboxMount::validated`] then requires. +pub fn mount_kind(host_path: &Path) -> MountKind { + if host_path.is_file() { + MountKind::File + } else { + MountKind::Directory + } +} + +/// Returns a native I/O error with its path, when available. +pub(crate) fn os_error(error: &io::Error, path: Option<&Path>) -> Error { + Error::io(match path { + Some(path) => format!("{path:?}: {error}"), + None => error.to_string(), + }) +} + +/// A live virtio-fs share mounted inside the sandbox container rootfs. +/// +/// Its denied paths hide paths in the share, its allowed paths expose paths inside denied paths +/// again, and its writable paths, when present, are the only paths of a read-write share that +/// the workload can modify. A share of a file binds one regular host file, whose mode applies to +/// the whole file, so it has none of them. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SandboxMount { + /// The guest directory or file at which the share appears. + pub(super) guest_target: String, + /// The host directory or file, with its components normalized. + pub(super) host_path: PathBuf, + /// The access mode. + pub(super) access: MountAccess, + /// The access-policy paths. + policy: MountPolicy, + /// The identity of the share's file operations. + pub(super) owner: MountOwner, + /// Whether the share binds a directory or a file. + pub(super) kind: MountKind, +} + +impl SandboxMount { + /// Returns the share of `host_path` at `guest_target`, with `host_path` spelled with its + /// components alone, as [`Path::components`] reads them. + /// + /// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) for a guest target + /// that [`validate_mount_target`] refuses, a host path with a NUL character or a `..` + /// component, policy paths for a file, more than [`MAX_MOUNT_POLICY_PATHS`] policy paths of a + /// kind, empty or repeated ones, allowed paths without denied paths, and writable paths of a + /// read-only share. + pub fn new( + guest_target: &str, + host_path: &str, + access: MountAccess, + policy: MountPolicy, + owner: MountOwner, + kind: MountKind, + ) -> Result { + Self::checked(Self { + guest_target: guest_target.to_owned(), + host_path: paths::normalized(Path::new(host_path)), + access, + policy, + owner, + kind, + }) + } + + /// Parses a `--mount` value with [`parse_mount_value`] into a share of the given policy, + /// owner, and kind. + /// + /// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported), as + /// [`parse_mount_value`] and [`new`](Self::new) do. + pub fn parse( + value: &str, + policy: MountPolicy, + owner: MountOwner, + kind: MountKind, + ) -> Result { + let (guest_target, host_path, access) = parse_mount_value(value)?; + Self::new(&guest_target, &host_path, access, policy, owner, kind) + } + + /// Checks the target, host path, kind, and access policy, in that order. + fn checked(self) -> Result { + validate_mount_target(&self.guest_target)?; + let raw_path = self.host_path.as_os_str(); + if raw_path.as_encoded_bytes().contains(&0) { + return Err(refused(format!( + "sandbox mount host path contains a NUL character: {raw_path:?}", + ))); + } + if self + .host_path + .components() + .any(|component| component == Component::ParentDir) + { + return Err(refused(format!( + "sandbox mount host path contains a parent component: {}", + self.host_path.display() + ))); + } + if self.kind == MountKind::File && !self.policy.is_empty() { + return Err(refused(format!( + "--mount-deny, --mount-allow, and --mount-write name paths inside a shared \ + directory, but --mount {} shares a file, whose mode applies to the whole file", + self.guest_target + ))); + } + for kind in POLICY_KINDS { + let paths = self.policy.paths(kind); + if paths.len() > MAX_MOUNT_POLICY_PATHS { + return Err(refused(format!( + "a sandbox mount permits at most {MAX_MOUNT_POLICY_PATHS} {kind} paths" + ))); + } + if paths + .iter() + .any(|path| path.is_empty() || path.contains('\0')) + { + return Err(refused(format!( + "sandbox mount {kind} paths must be nonempty" + ))); + } + let unique: std::collections::HashSet<&String> = paths.iter().collect(); + if unique.len() != paths.len() { + return Err(refused(format!( + "sandbox mount {kind} paths must be unique" + ))); + } + } + if !self.policy.allowed.is_empty() && self.policy.denied.is_empty() { + return Err(refused( + "--mount-allow exposes a path inside a --mount-deny path of the same share, \ + which has none", + )); + } + if !self.policy.writable.is_empty() && self.access != MountAccess::ReadWrite { + return Err(refused(format!( + "--mount-write narrows the writes of a read-write share; --mount {} is read-only", + self.guest_target + ))); + } + Ok(self) + } + + /// Returns the share with another kind, checked again, as `dataclasses.replace` does. + /// + /// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) for a file share + /// with policy paths. + pub fn with_kind(&self, kind: MountKind) -> Result { + Self::checked(Self { + kind, + ..self.clone() + }) + } + + /// Returns the guest target. + pub fn guest_target(&self) -> &str { + &self.guest_target + } + + /// Returns the host directory or file. + pub fn host_path(&self) -> &Path { + &self.host_path + } + + /// Returns the access mode. + pub fn access(&self) -> MountAccess { + self.access + } + + /// Returns the access-policy paths. + pub fn policy(&self) -> &MountPolicy { + &self.policy + } + + /// Returns the identity of the share's file operations. + pub fn owner(&self) -> MountOwner { + self.owner + } + + /// Returns whether the share binds a directory or a file. + pub fn kind(&self) -> MountKind { + self.kind + } + + /// Checks the share against the host: its owner must be one that the host supports, its + /// host path must be a regular file or a plain directory, as its kind requires, without + /// being a symbolic link, which on Windows includes a junction, as [`Path::is_symlink`] tells + /// them, and with the `caller` owner on Linux, it must not belong to UID 0 or GID 0, to which + /// guest root would be squashed. + /// + /// The kind was fixed when the share was requested, so a directory that replaced a shared + /// file is not shared in its place. Fails with + /// [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported), or + /// [`ErrorKind::Io`](crate::ErrorKind::Io) if the host path cannot be examined. + pub fn validated(&self) -> Result<()> { + require_mount_owner_supported(self.owner)?; + let host_path = self.host_path.as_path(); + let kind_matches = match self.kind { + MountKind::File => host_path.is_file(), + MountKind::Directory => host_path.is_dir(), + }; + if host_path.is_symlink() || !kind_matches { + return Err(self.kind_refusal()); + } + #[cfg(unix)] + if self.owner == MountOwner::Caller { + use std::os::unix::fs::MetadataExt; + + let metadata = + std::fs::metadata(host_path).map_err(|error| os_error(&error, Some(host_path)))?; + if metadata.uid() == 0 || metadata.gid() == 0 { + return Err(refused(format!( + "--mount-owner caller squashes guest root to the owner of the shared {}, so \ + {} must not be owned by UID 0 or GID 0", + self.kind, + host_path.display() + ))); + } + } + Ok(()) + } + + /// Returns the error for a host path that is not of the share's kind. + pub(super) fn kind_refusal(&self) -> Error { + let shown = self.host_path.display(); + refused(match self.kind { + MountKind::File => format!("sandbox mount host path is not a regular file: {shown}"), + MountKind::Directory => { + format!("sandbox mount host path is not a plain directory: {shown}") + } + }) + } + + /// Returns the share with its host path made absolute, as the one function of NVX that makes + /// host paths absolute makes it: joined to the current directory if it is relative, without + /// resolving it, so OpenVMM still sees, and refuses, every symbolic-link component of the + /// path that was requested. On Windows, a path with another drive and no root, such as + /// `D:relative`, is below the current directory of its drive. + /// + /// Fails with [`ErrorKind::Io`](crate::ErrorKind::Io) if a relative host path meets an + /// unreadable current directory, or Windows cannot qualify a drive-relative path. + pub fn absolute(&self) -> Result { + self.absolute_with(&paths::current_dir) + } + + /// Implements [`absolute`](Self::absolute) with `cwd` as the current directory. + pub(crate) fn absolute_with(&self, cwd: &dyn Fn() -> io::Result) -> Result { + if self.host_path.is_absolute() { + return Ok(self.clone()); + } + let directory = cwd().map_err(|error| os_error(&error, None))?; + Self::checked(Self { + host_path: absolute_host_path(&directory, &self.host_path)?, + ..self.clone() + }) + } + + /// Returns each kind of access-policy path with its requested paths, relative paths joined + /// to the absolute host path of the share, as OpenVMM attributes them to the children of an + /// aggregate. + /// + /// Fails as [`absolute`](Self::absolute) does. + pub fn absolute_policy_paths(&self) -> Result)>> { + self.absolute_policy_paths_with(&paths::current_dir) + } + + /// Implements [`absolute_policy_paths`](Self::absolute_policy_paths) with `cwd` as the + /// current directory. + pub(crate) fn absolute_policy_paths_with( + &self, + cwd: &dyn Fn() -> io::Result, + ) -> Result)>> { + let host_path = self.absolute_with(cwd)?.host_path; + POLICY_KINDS + .iter() + .map(|&kind| { + let paths = self + .policy + .paths(kind) + .iter() + .map(|path| { + if Path::new(path).is_absolute() { + return Ok(path.clone()); + } + let joined = + paths::normalized(&absolute_host_path(&host_path, Path::new(path))?); + paths::text(&joined) + .map(str::to_owned) + .map_err(|error| os_error(&error, None)) + }) + .collect::>>()?; + Ok((kind, paths)) + }) + .collect() + } +} + +/// Returns `path` joined to `directory` without resolving links, as [`paths::absolute_in`] joins +/// them. +fn absolute_host_path(directory: &Path, path: &Path) -> Result { + paths::absolute_in(directory, path).map_err(|error| os_error(&error, Some(path))) +} + +/// Returns the bootstrap tokens of a share at `target` with `access`. +pub(super) fn command_line_fragment(target: &str, access: MountAccess) -> String { + let [directory, tag, mode] = MOUNT_COMMAND_LINE_FRAGMENT; + format!("{directory}{target}{tag}{MOUNT_TAG}{mode}{access}") +} + +/// Checks the live shares that one sandbox attaches, in child order: no target may overlap +/// another, so that no share hides another, and every share has the same owner. +/// +/// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported). +pub fn validate_mounts(mounts: &[SandboxMount]) -> Result<()> { + for (index, mount) in mounts.iter().enumerate() { + for other in &mounts[..index] { + if targets_overlap(&other.guest_target, &mount.guest_target) { + return Err(refused(format!( + "sandbox mount targets {} and {} overlap; a share cannot hide another", + other.guest_target, mount.guest_target + ))); + } + } + } + if mounts.iter().any(|mount| mount.owner != mounts[0].owner) { + return Err(refused( + "every sandbox --mount must use the same --mount-owner", + )); + } + Ok(()) +} diff --git a/nvx-rs/src/host/sandbox/mount/tests.rs b/nvx-rs/src/host/sandbox/mount/tests.rs index c376b64a6..73072be70 100644 --- a/nvx-rs/src/host/sandbox/mount/tests.rs +++ b/nvx-rs/src/host/sandbox/mount/tests.rs @@ -1,10 +1,14 @@ //! The share tests of `scripts/test_nvx_tools.py`, ported. use std::fs; +use std::path::{Path, PathBuf}; +use super::aggregate::plan_mounts_with; +use super::request::require_mount_owner_supported_on; use super::*; use crate::ErrorKind; -use crate::host::filesystem::Bind; +use crate::host::filesystem::{Bind, Owner, ReservedTargets}; +use crate::host::paths; use crate::host::sandbox::tests::{deny, exported, host, host_text, native, policy}; fn parse(value: &str, policy: MountPolicy) -> Result {