arcbox_hypervisor/capability.rs
1//! Host capability probes that answer without constructing a hypervisor.
2//!
3//! `Hypervisor::capabilities` is the richer surface, but reaching it means
4//! opening `/dev/kvm` (or a VZ configuration) first. Callers that must
5//! answer before any VM exists — the sandbox capability gate, which
6//! `GetCapabilities` and the `Create` fail-fast path both consult — use
7//! these instead. The concrete backends read the same probes when they
8//! fill in `PlatformCapabilities`, so the two never drift.
9
10/// Whether this host can nest a VM, and the platform's own reason when it
11/// cannot.
12///
13/// The reason is platform knowledge, not product copy: it names the
14/// hardware or kernel requirement that failed, so a caller can surface it
15/// without carrying a `#[cfg]` of its own.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub struct NestedVirtSupport {
18 /// True when the hardware and kernel allow a nested guest.
19 pub supported: bool,
20 /// Why not, when `supported` is false; empty otherwise.
21 ///
22 /// Phrased as the requirement that failed, with no leading subject, so
23 /// a caller can prefix its own context — `arcbox-api` renders it as
24 /// `"sandboxes cannot run on this host: {reason}"` — without ending up
25 /// with two stapled-together clauses.
26 pub reason: &'static str,
27}
28
29/// Probes nested-virtualization support on this host.
30///
31/// Cached on macOS, where this is a fixed hardware/OS property that
32/// cannot change while the process lives. Re-probed on every call
33/// elsewhere: on Linux the answer depends on the `kvm_intel`/`kvm_amd`
34/// module being loaded, and this function is reachable — via the sandbox
35/// capability gate — before any VM has ever booted (in particular under
36/// `--no-linux-vm`, where nothing in this process ever opens `/dev/kvm`).
37/// A query that lands before the module autoloads would otherwise cache a
38/// false negative for the rest of the process's life.
39#[must_use]
40pub fn host_nested_virt() -> NestedVirtSupport {
41 let supported = cached_or_reprobed();
42 NestedVirtSupport {
43 supported,
44 reason: if supported { "" } else { UNSUPPORTED_REASON },
45 }
46}
47
48#[cfg(target_os = "macos")]
49fn cached_or_reprobed() -> bool {
50 static SUPPORTED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
51 *SUPPORTED.get_or_init(probe_nested_virt)
52}
53
54#[cfg(not(target_os = "macos"))]
55fn cached_or_reprobed() -> bool {
56 probe_nested_virt()
57}
58
59#[cfg(target_os = "macos")]
60const UNSUPPORTED_REASON: &str = concat!(
61 "nested virtualization requires Apple Silicon M3 or newer ",
62 "with macOS 15 or newer",
63);
64
65/// `VZGenericPlatformConfiguration.isNestedVirtualizationSupported`.
66#[cfg(target_os = "macos")]
67fn probe_nested_virt() -> bool {
68 arcbox_vz::GenericPlatform::is_nested_virt_supported()
69}
70
71#[cfg(target_os = "linux")]
72const UNSUPPORTED_REASON: &str = concat!(
73 "the host kernel does not expose nested KVM ",
74 "(/sys/module/kvm_{intel,amd}/parameters/nested)",
75);
76
77/// Nested-KVM module parameters.
78///
79/// Not gated on x86_64: the files simply do not exist on other
80/// architectures, so the read fails and the answer is false — which is
81/// also the honest answer for ARM until the kernel grows a probe for
82/// ARMv8.4 nested virtualization.
83#[cfg(target_os = "linux")]
84fn probe_nested_virt() -> bool {
85 ["kvm_intel", "kvm_amd"].iter().any(|module| {
86 std::fs::read_to_string(format!("/sys/module/{module}/parameters/nested"))
87 .is_ok_and(|content| matches!(content.trim(), "1" | "Y" | "y"))
88 })
89}
90
91#[cfg(not(any(target_os = "macos", target_os = "linux")))]
92const UNSUPPORTED_REASON: &str = "nested virtualization is not supported on this platform";
93
94#[cfg(not(any(target_os = "macos", target_os = "linux")))]
95fn probe_nested_virt() -> bool {
96 false
97}
98
99#[cfg(test)]
100mod tests {
101 use super::*;
102
103 // The reason is user-facing: `arcbox-computer` surfaces it verbatim in
104 // the `NESTED_VIRT_UNSUPPORTED` error a failing `Create` returns, so
105 // the hardware requirement it names is contract, not an internal
106 // string. Pin it here, where the constant lives.
107 #[cfg(target_os = "macos")]
108 #[test]
109 fn the_macos_reason_names_the_hardware_requirement() {
110 let reason = super::UNSUPPORTED_REASON;
111 assert!(reason.contains("M3"), "{reason}");
112 assert!(reason.contains("macOS 15"), "{reason}");
113 }
114
115 #[test]
116 fn an_unsupported_host_always_carries_a_reason() {
117 let support = host_nested_virt();
118 assert_eq!(support.supported, support.reason.is_empty());
119 }
120
121 #[test]
122 fn the_probe_is_stable_across_repeated_calls() {
123 // Cached on macOS (a fixed hardware property); re-probed every call
124 // on Linux (see `cached_or_reprobed`). Either way, two calls back to
125 // back must agree — nothing in this process changes kernel module
126 // state between them.
127 assert_eq!(host_nested_virt(), host_nested_virt());
128 }
129}