| 1 | //! What a computer still lacks before a kind of job can run sandboxed, |
| 2 | //! each by name (`status --json`'s `needs`), found before anything is |
| 3 | //! downloaded: the desktop app says each in plain words and sets up those |
| 4 | //! it can, behind one administrator prompt. |
| 5 | //! |
| 6 | //! | Need | Kind of job | What is missing | Set up by the app | |
| 7 | //! | --- | --- | --- | --- | |
| 8 | //! | `restart` | Linux, Windows | Windows features were turned on, and take effect after a restart | No: Restart now | |
| 9 | //! | `windows_update` | Linux, Windows | Windows older than 2004 (WSL 2), or than 11 24H2 (`wsb`) | No: Windows Update | |
| 10 | //! | `virtualization` | all | Virtualization (VT-x, AMD-V) off in the firmware | No: steps | |
| 11 | //! | `nested_virtualization` | all | A VM whose host doesn't pass virtualization through | No: steps | |
| 12 | //! | `vm_platform` | Linux on Windows | The Virtual Machine Platform feature | Yes | |
| 13 | //! | `wsl` | Linux on Windows | WSL | Yes | |
| 14 | //! | `wsl_update` | Linux on Windows | WSL older than 2.0, or the one built into Windows | Yes | |
| 15 | //! | `windows_edition` | Windows | Windows Home, which has no Windows Sandbox | No: Linux jobs still run | |
| 16 | //! | `windows_sandbox` | Windows | The Windows Sandbox feature | Yes | |
| 17 | //! | `kvm_module` | Linux on Linux | `/dev/kvm`: its module isn't loaded | No: command | |
| 18 | //! | `kvm_group` | Linux on Linux | Permission to use `/dev/kvm` | No: command | |
| 19 | //! | `qemu` | Linux on Linux | QEMU | No: command | |
| 20 | //! | `macos_update` | Linux on macOS | macOS older than 13 | No: Software Update | |
| 21 | //! | `homebrew` | Linux on macOS | Homebrew, which installs vfkit | No: steps | |
| 22 | //! | `vfkit` | Linux on macOS | vfkit | No: command | |
| 23 | //! |
| 24 | //! Each is said only when the facts say it for certain: a computer that |
| 25 | //! can run the sandbox is never held back by a guess. What can't be known |
| 26 | //! beforehand (virtualization inside a VM) is named from the error that |
| 27 | //! starting the sandbox gives instead. |
| 28 | |
| 29 | use std::collections::BTreeMap; |
| 30 | use std::path::Path; |
| 31 | use std::process::{Command, Stdio}; |
| 32 | |
| 33 | use serde::{Deserialize, Serialize}; |
| 34 | |
| 35 | /// The file the desktop app's setup leaves in the launcher's state folder |
| 36 | /// when what it turned on takes a restart: this boot's time, as |
| 37 | /// [`WindowsFacts::booted`] says it. |
| 38 | pub const RESTART_FILE: &str = "restart-pending"; |
| 39 | |
| 40 | /// The oldest Windows build with WSL 2 (Windows 10 2004). |
| 41 | pub const WSL2_BUILD: u32 = 19041; |
| 42 | /// The oldest WSL the sandbox takes: the one from the Microsoft Store, 2.0 or later. |
| 43 | pub const WSL_VERSION: (u32, u32, u32) = (2, 0, 0); |
| 44 | /// The oldest macOS vfkit runs on. |
| 45 | pub const MACOS_MAJOR: u32 = 13; |
| 46 | |
| 47 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] |
| 48 | #[serde(rename_all = "snake_case")] |
| 49 | pub enum Need { |
| 50 | Restart, |
| 51 | WindowsUpdate, |
| 52 | Virtualization, |
| 53 | NestedVirtualization, |
| 54 | VmPlatform, |
| 55 | Wsl, |
| 56 | WslUpdate, |
| 57 | WindowsEdition, |
| 58 | WindowsSandbox, |
| 59 | KvmModule, |
| 60 | KvmGroup, |
| 61 | Qemu, |
| 62 | MacosUpdate, |
| 63 | Homebrew, |
| 64 | Vfkit, |
| 65 | } |
| 66 | |
| 67 | impl Need { |
| 68 | /// Why, and what to do, in the launcher's words: what `status` says |
| 69 | /// as a capability's `cause` and `fix`. The first sentences of those a |
| 70 | /// desktop app from before `needs` reads are kept as they were. |
| 71 | pub fn said(self, kind: &str) -> (String, String) { |
| 72 | let (cause, fix) = match (self, kind) { |
| 73 | (Need::Restart, _) => ( |
| 74 | "Windows needs a restart to finish turning on what the sandbox needs.", |
| 75 | "Restart Windows; the sandbox starts after.", |
| 76 | ), |
| 77 | (Need::WindowsUpdate, "windows") => ( |
| 78 | "This Windows has Windows Sandbox but not its command line, wsb, which came in Windows 11 24H2.", |
| 79 | "Update Windows to 24H2 or later.", |
| 80 | ), |
| 81 | (Need::WindowsUpdate, _) => ( |
| 82 | "This Windows is older than Windows 10 version 2004, the first with WSL 2.", |
| 83 | "Update Windows under Settings, Windows Update, then restart.", |
| 84 | ), |
| 85 | (Need::Virtualization, "linux") if cfg!(target_os = "linux") => ( |
| 86 | "This machine has neither Sysbox nor KVM: virtualization (Intel VT-x or AMD-V) is off in its firmware, or its processor has none.", |
| 87 | "Turn on Intel VT-x or AMD-V in the firmware (BIOS or UEFI) settings and restart, or install Sysbox 0.7 or later.", |
| 88 | ), |
| 89 | (Need::Virtualization, _) => ( |
| 90 | "Virtualization (Intel VT-x or AMD-V) is turned off in this computer's firmware, and the sandbox is a small virtual machine.", |
| 91 | "Restart into the firmware (BIOS or UEFI) settings, turn on Intel Virtualization Technology (VT-x) or SVM Mode (AMD-V), save and restart.", |
| 92 | ), |
| 93 | (Need::NestedVirtualization, _) => ( |
| 94 | "This computer is a virtual machine whose host doesn't pass virtualization through to it, and the sandbox is a virtual machine inside it.", |
| 95 | "Turn on nested virtualization for this VM on its host, then start it again: on Hyper-V, `Set-VMProcessor -VMName <name> -ExposeVirtualizationExtensions $true` with the VM off.", |
| 96 | ), |
| 97 | (Need::VmPlatform, _) => ( |
| 98 | "Virtual Machine Platform, the Windows feature WSL 2 runs on, is turned off.", |
| 99 | "Open a terminal as administrator and run `wsl --install --no-distribution`, which turns it on, then restart Windows.", |
| 100 | ), |
| 101 | (Need::Wsl, _) => ( |
| 102 | "WSL is not installed, and the sandbox for Linux jobs is a WSL distribution of its own.", |
| 103 | "Open a terminal as administrator and run `wsl --install --no-distribution`, then restart Windows. It works on every edition of Windows 11, Home included.", |
| 104 | ), |
| 105 | (Need::WslUpdate, _) => ( |
| 106 | "This WSL is older than the one the sandbox needs: WSL 2.0 or later, from the Microsoft Store.", |
| 107 | "Open a terminal as administrator and run `wsl --update`.", |
| 108 | ), |
| 109 | (Need::WindowsEdition, _) => ( |
| 110 | "Windows Sandbox, which Windows jobs run in, isn't in Windows Home.", |
| 111 | "Linux jobs run on every edition. Windows jobs need Windows 11 Pro, Enterprise or Education.", |
| 112 | ), |
| 113 | (Need::WindowsSandbox, _) => ( |
| 114 | "Windows Sandbox is not turned on, and Windows jobs each run in one.", |
| 115 | "Turn on Windows Sandbox under Settings, System, Optional features, More Windows features, then restart.", |
| 116 | ), |
| 117 | (Need::KvmModule, _) => ( |
| 118 | "This machine has neither Sysbox nor KVM: its processor can run virtual machines, but /dev/kvm isn't there.", |
| 119 | "Load KVM with `sudo modprobe kvm_intel` (or `kvm_amd`); if it says the firmware disabled it, turn on VT-x or AMD-V there. Or install Sysbox 0.7 or later.", |
| 120 | ), |
| 121 | (Need::KvmGroup, _) => ("This user may not use /dev/kvm.", "Run `sudo usermod -aG kvm $USER`, then log out and in again."), |
| 122 | (Need::Qemu, _) => ( |
| 123 | "QEMU, which runs the sandbox's VM, is not installed.", |
| 124 | "Install QEMU (qemu-system-x86 on Debian and Ubuntu, qemu-kvm on Fedora), or Sysbox 0.7 or later.", |
| 125 | ), |
| 126 | (Need::MacosUpdate, _) => ("The sandbox's VM needs macOS 13 or later.", "Update macOS under System Settings, General, Software Update."), |
| 127 | (Need::Homebrew, _) => ( |
| 128 | "vfkit, which runs the sandbox's VM with Apple's Virtualization framework, is not installed, nor is Homebrew, which installs it.", |
| 129 | "Install Homebrew (brew.sh), then run `brew install vfkit`.", |
| 130 | ), |
| 131 | (Need::Vfkit, _) => ( |
| 132 | "vfkit, which runs the sandbox's VM with Apple's Virtualization framework, is not installed.", |
| 133 | "Install it with `brew install vfkit` (macOS 13 or later), then start the sandbox again.", |
| 134 | ), |
| 135 | }; |
| 136 | (cause.to_owned(), fix.to_owned()) |
| 137 | } |
| 138 | } |
| 139 | |
| 140 | // --- Windows ------------------------------------------------------------------------ |
| 141 | |
| 142 | /// What Windows says of itself, read without an administrator. Each is |
| 143 | /// None (or empty) when it couldn't be read, and nothing is concluded from it. |
| 144 | #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] |
| 145 | #[serde(default)] |
| 146 | pub struct WindowsFacts { |
| 147 | /// `EditionID`: `Core` (Home), `Professional`, `Enterprise`, … |
| 148 | pub edition: Option<String>, |
| 149 | pub build: Option<u32>, |
| 150 | /// A hypervisor is running (`Win32_ComputerSystem.HypervisorPresent`). |
| 151 | pub hypervisor: Option<bool>, |
| 152 | /// Virtualization on in the firmware (`Win32_Processor`). It reads |
| 153 | /// false whenever a hypervisor is running, so it says something only |
| 154 | /// when none is. |
| 155 | pub firmware: Option<bool>, |
| 156 | pub model: Option<String>, |
| 157 | pub manufacturer: Option<String>, |
| 158 | /// `Win32_OptionalFeature.InstallState`: 1 on, 2 off, 3 not in this edition. |
| 159 | pub features: BTreeMap<String, u32>, |
| 160 | /// When Windows started, in UTC. |
| 161 | pub booted: Option<String>, |
| 162 | } |
| 163 | |
| 164 | pub const VM_PLATFORM: &str = "VirtualMachinePlatform"; |
| 165 | pub const WINDOWS_SANDBOX: &str = "Containers-DisposableClientVM"; |
| 166 | |
| 167 | /// The facts, as one PowerShell call prints them: about a second, so only |
| 168 | /// asked while something is missing. |
| 169 | const PROBE: &str = r#"$ErrorActionPreference = 'SilentlyContinue' |
| 170 | $v = Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' |
| 171 | $cs = Get-CimInstance Win32_ComputerSystem |
| 172 | $cpu = Get-CimInstance Win32_Processor | Select-Object -First 1 |
| 173 | $features = @{} |
| 174 | Get-CimInstance Win32_OptionalFeature | Where-Object { $_.Name -in @('VirtualMachinePlatform', 'Containers-DisposableClientVM') } | ForEach-Object { $features[$_.Name] = [int]$_.InstallState } |
| 175 | $os = Get-CimInstance Win32_OperatingSystem |
| 176 | [pscustomobject]@{ |
| 177 | edition = $v.EditionID |
| 178 | build = [int]$v.CurrentBuildNumber |
| 179 | hypervisor = $cs.HypervisorPresent |
| 180 | firmware = $cpu.VirtualizationFirmwareEnabled |
| 181 | model = $cs.Model |
| 182 | manufacturer = $cs.Manufacturer |
| 183 | features = $features |
| 184 | booted = $os.LastBootUpTime.ToUniversalTime().ToString('o') |
| 185 | } | ConvertTo-Json -Compress"#; |
| 186 | |
| 187 | /// PowerShell's `-EncodedCommand`: a script as UTF-16LE in base64, so |
| 188 | /// nothing in it is read by a shell on the way. |
| 189 | pub fn encoded(script: &str) -> String { |
| 190 | use base64::Engine; |
| 191 | let bytes: Vec<u8> = script.encode_utf16().flat_map(u16::to_le_bytes).collect(); |
| 192 | base64::engine::general_purpose::STANDARD.encode(bytes) |
| 193 | } |
| 194 | |
| 195 | impl WindowsFacts { |
| 196 | /// Asks Windows, once a process: what it can't say is left unknown. |
| 197 | pub fn look() -> WindowsFacts { |
| 198 | static FACTS: std::sync::OnceLock<WindowsFacts> = std::sync::OnceLock::new(); |
| 199 | FACTS |
| 200 | .get_or_init(|| { |
| 201 | let mut command = Command::new("powershell.exe"); |
| 202 | command.args(["-NoProfile", "-NonInteractive", "-EncodedCommand", &encoded(PROBE)]).stdin(Stdio::null()).stderr(Stdio::null()); |
| 203 | super::native::hide_window(&mut command); |
| 204 | command.output().ok().and_then(|out| serde_json::from_slice(&out.stdout).ok()).unwrap_or_default() |
| 205 | }) |
| 206 | .clone() |
| 207 | } |
| 208 | |
| 209 | /// [`look`](Self::look), kept in the launcher's state folder until |
| 210 | /// Windows restarts: what it says (the edition, the processor, a |
| 211 | /// hypervisor running, a feature turned on) changes only across a |
| 212 | /// restart, and asking takes a second or two, which a status every |
| 213 | /// few seconds shouldn't. |
| 214 | pub fn look_in(dir: &Path) -> WindowsFacts { |
| 215 | let path = dir.join(FACTS_FILE); |
| 216 | let Some(boot) = booted_at() else { return WindowsFacts::look() }; |
| 217 | let kept: Option<Kept> = std::fs::read(&path).ok().and_then(|bytes| serde_json::from_slice(&bytes).ok()); |
| 218 | if let Some(kept) = kept.filter(|kept| same_boot(kept.boot, boot)) { |
| 219 | return kept.facts; |
| 220 | } |
| 221 | let facts = WindowsFacts::look(); |
| 222 | // Kept only when Windows answered. |
| 223 | if facts.booted.is_some() { |
| 224 | let _ = std::fs::create_dir_all(dir); |
| 225 | let _ = std::fs::write(&path, serde_json::to_vec(&Kept { boot, facts: facts.clone() }).unwrap_or_default()); |
| 226 | } |
| 227 | facts |
| 228 | } |
| 229 | |
| 230 | /// Windows Home (`Core`, `CoreN`, `CoreSingleLanguage`, `CoreCountrySpecific`). |
| 231 | pub fn home(&self) -> bool { |
| 232 | self.edition.as_deref().is_some_and(|e| e.starts_with("Core")) |
| 233 | } |
| 234 | |
| 235 | /// This Windows is itself a virtual machine. |
| 236 | pub fn virtual_machine(&self) -> bool { |
| 237 | let said = format!("{} {}", self.model.as_deref().unwrap_or(""), self.manufacturer.as_deref().unwrap_or("")).to_ascii_lowercase(); |
| 238 | ["virtual machine", "vmware", "virtualbox", "qemu", "kvm", "parallels", "xen", "bochs"].iter().any(|vm| said.contains(vm)) |
| 239 | } |
| 240 | |
| 241 | /// A feature on (Some(true)), off or not here (Some(false)), or not known. |
| 242 | pub fn feature(&self, name: &str) -> Option<bool> { |
| 243 | self.features.get(name).map(|state| *state == 1) |
| 244 | } |
| 245 | |
| 246 | /// Virtualization is off in the firmware, for certain: a physical |
| 247 | /// computer with no hypervisor running whose processor says so. |
| 248 | pub fn firmware_off(&self) -> bool { |
| 249 | !self.virtual_machine() && self.hypervisor == Some(false) && self.firmware == Some(false) |
| 250 | } |
| 251 | |
| 252 | /// What the computer lacks for virtualization, said from an error |
| 253 | /// that starting a VM gave: off in the firmware, or not passed through to this VM. |
| 254 | pub fn no_virtualization(&self) -> Need { |
| 255 | if self.virtual_machine() { Need::NestedVirtualization } else { Need::Virtualization } |
| 256 | } |
| 257 | } |
| 258 | |
| 259 | /// The file [`WindowsFacts::look_in`] keeps them in. |
| 260 | pub const FACTS_FILE: &str = "windows-facts.json"; |
| 261 | |
| 262 | #[derive(Serialize, Deserialize)] |
| 263 | struct Kept { |
| 264 | /// When Windows started, in seconds since 1970. |
| 265 | boot: u64, |
| 266 | facts: WindowsFacts, |
| 267 | } |
| 268 | |
| 269 | /// Two readings of when Windows started are the same boot: they are |
| 270 | /// worked out from the time now and the time since, so differ by a second or so. |
| 271 | fn same_boot(a: u64, b: u64) -> bool { |
| 272 | a.abs_diff(b) <= 30 |
| 273 | } |
| 274 | |
| 275 | /// When Windows started, in seconds since 1970: now, less how long it has been running. |
| 276 | fn booted_at() -> Option<u64> { |
| 277 | #[cfg(windows)] |
| 278 | { |
| 279 | unsafe extern "system" { |
| 280 | fn GetTickCount64() -> u64; |
| 281 | } |
| 282 | // SAFETY: GetTickCount64 takes nothing and only reads a counter. |
| 283 | let up = unsafe { GetTickCount64() } / 1000; |
| 284 | let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).ok()?.as_secs(); |
| 285 | Some(now.saturating_sub(up)) |
| 286 | } |
| 287 | #[cfg(not(windows))] |
| 288 | None |
| 289 | } |
| 290 | |
| 291 | /// Whether setup left a restart to do, this boot: the file setup wrote |
| 292 | /// names the boot it ran in. One from an earlier boot is removed. |
| 293 | pub fn restart_pending(dir: &Path, facts: &WindowsFacts) -> bool { |
| 294 | let path = dir.join(RESTART_FILE); |
| 295 | let Ok(said) = std::fs::read_to_string(&path) else { return false }; |
| 296 | match facts.booted.as_deref() { |
| 297 | Some(booted) if said.trim() == booted => true, |
| 298 | Some(_) => { |
| 299 | let _ = std::fs::remove_file(&path); |
| 300 | false |
| 301 | } |
| 302 | // Not known: a restart is still to be done until Windows says otherwise. |
| 303 | None => true, |
| 304 | } |
| 305 | } |
| 306 | |
| 307 | /// What WSL says of itself. |
| 308 | #[derive(Clone, Debug, Default, PartialEq, Eq)] |
| 309 | pub struct WslFacts { |
| 310 | /// `wsl --status` answers. |
| 311 | pub installed: bool, |
| 312 | /// `wsl --version`'s, which the WSL built into Windows doesn't answer. |
| 313 | pub version: Option<(u32, u32, u32)>, |
| 314 | /// A WSL 2 distribution is here already: WSL 2 itself works. |
| 315 | pub has_wsl2: bool, |
| 316 | } |
| 317 | |
| 318 | /// The first `a.b.c` in `wsl --version`'s first line, in any language: |
| 319 | /// `WSL version: 2.5.9.0`, `WSL-Version: 2.5.9.0`. |
| 320 | pub fn wsl_version(text: &str) -> Option<(u32, u32, u32)> { |
| 321 | let line = text.replace('\0', ""); |
| 322 | let first = line.lines().find(|l| !l.trim().is_empty())?.to_owned(); |
| 323 | first.split(|c: char| !(c.is_ascii_digit() || c == '.')).find_map(|word| { |
| 324 | let mut parts = word.split('.').map(|p| p.parse::<u32>().ok()); |
| 325 | match (parts.next().flatten(), parts.next().flatten(), parts.next().flatten()) { |
| 326 | (Some(a), Some(b), Some(c)) => Some((a, b, c)), |
| 327 | _ => None, |
| 328 | } |
| 329 | }) |
| 330 | } |
| 331 | |
| 332 | /// What Linux jobs on Windows still need, first what must come first. |
| 333 | pub fn wsl_needs(win: &WindowsFacts, wsl: &WslFacts, restart: bool) -> Vec<Need> { |
| 334 | let mut needs = Vec::new(); |
| 335 | if win.build.is_some_and(|b| b < WSL2_BUILD) { |
| 336 | return vec![Need::WindowsUpdate]; |
| 337 | } |
| 338 | // A WSL 2 distribution that runs says virtualization and the platform are there. |
| 339 | if !wsl.has_wsl2 { |
| 340 | if win.firmware_off() && !restart { |
| 341 | return vec![Need::Virtualization]; |
| 342 | } |
| 343 | if win.feature(VM_PLATFORM) == Some(false) { |
| 344 | needs.push(Need::VmPlatform); |
| 345 | } |
| 346 | } |
| 347 | if !wsl.installed { |
| 348 | needs.push(Need::Wsl); |
| 349 | } else if wsl.version.is_none_or(|v| v < WSL_VERSION) { |
| 350 | needs.push(Need::WslUpdate); |
| 351 | } |
| 352 | if restart && (needs.iter().any(|n| matches!(n, Need::VmPlatform)) || win.hypervisor == Some(false)) { |
| 353 | // What is left to set up after it is said once it's done. |
| 354 | return vec![Need::Restart]; |
| 355 | } |
| 356 | needs |
| 357 | } |
| 358 | |
| 359 | /// What Windows jobs still need: Windows Sandbox (`feature`, its program |
| 360 | /// is here) and its command line (`cli`). |
| 361 | pub fn windows_sandbox_needs(win: &WindowsFacts, feature: bool, cli: bool, restart: bool) -> Vec<Need> { |
| 362 | if feature && cli { |
| 363 | return vec![]; |
| 364 | } |
| 365 | if feature { |
| 366 | return vec![Need::WindowsUpdate]; |
| 367 | } |
| 368 | if win.home() || (win.feature(WINDOWS_SANDBOX).is_none() && win.features.contains_key(VM_PLATFORM)) { |
| 369 | // Home, or an edition whose feature list has no Windows Sandbox. |
| 370 | return vec![Need::WindowsEdition]; |
| 371 | } |
| 372 | if restart { |
| 373 | return vec![Need::Restart]; |
| 374 | } |
| 375 | if win.firmware_off() { |
| 376 | return vec![Need::Virtualization]; |
| 377 | } |
| 378 | let mut needs = vec![Need::WindowsSandbox]; |
| 379 | if win.build.is_some_and(|b| b < 26100) { |
| 380 | needs.push(Need::WindowsUpdate); |
| 381 | } |
| 382 | needs |
| 383 | } |
| 384 | |
| 385 | /// Whether a sentence about a VM that wouldn't start is about virtualization. |
| 386 | pub fn about_virtualization(text: &str) -> bool { |
| 387 | let lower = text.to_ascii_lowercase(); |
| 388 | ["virtualization is not enabled", "enable virtualization", "ensure virtualization", "hcs_e_hyperv_not_installed", "hypervisor is not present", "vt-x", "nested virtualization"] |
| 389 | .iter() |
| 390 | .any(|said| lower.contains(said)) |
| 391 | } |
| 392 | |
| 393 | // --- Linux -------------------------------------------------------------------------- |
| 394 | |
| 395 | /// What a Linux computer has for a QEMU VM with KVM. |
| 396 | #[derive(Clone, Debug, Default, PartialEq, Eq)] |
| 397 | pub struct KvmFacts { |
| 398 | /// `/dev/kvm` is there. |
| 399 | pub device: bool, |
| 400 | /// This user may open it. |
| 401 | pub usable: bool, |
| 402 | /// The processor says it can virtualize (`vmx`, `svm`): None on an |
| 403 | /// architecture whose flags don't say. |
| 404 | pub cpu: Option<bool>, |
| 405 | /// This Linux is itself a virtual machine (`hypervisor`). |
| 406 | pub guest: bool, |
| 407 | pub qemu: bool, |
| 408 | } |
| 409 | |
| 410 | /// `/proc/cpuinfo`'s flags: whether the processor can virtualize, and |
| 411 | /// whether this is a guest. On x86 only; elsewhere it says nothing. |
| 412 | pub fn cpu_flags(cpuinfo: &str) -> (Option<bool>, bool) { |
| 413 | let Some(flags) = cpuinfo.lines().find(|l| l.starts_with("flags")).and_then(|l| l.split_once(':')).map(|(_, f)| f) else { |
| 414 | return (None, false); |
| 415 | }; |
| 416 | let has = |flag: &str| flags.split_whitespace().any(|f| f == flag); |
| 417 | (Some(has("vmx") || has("svm")), has("hypervisor")) |
| 418 | } |
| 419 | |
| 420 | pub fn kvm_needs(facts: &KvmFacts) -> Vec<Need> { |
| 421 | let mut needs = Vec::new(); |
| 422 | if !facts.device { |
| 423 | needs.push(match facts.cpu { |
| 424 | Some(false) if facts.guest => Need::NestedVirtualization, |
| 425 | Some(false) => Need::Virtualization, |
| 426 | _ => Need::KvmModule, |
| 427 | }); |
| 428 | } else if !facts.usable { |
| 429 | needs.push(Need::KvmGroup); |
| 430 | } |
| 431 | if !facts.qemu { |
| 432 | needs.push(Need::Qemu); |
| 433 | } |
| 434 | needs |
| 435 | } |
| 436 | |
| 437 | // --- macOS -------------------------------------------------------------------------- |
| 438 | |
| 439 | /// `sw_vers -productVersion`'s major version: `14.6.1` is 14. |
| 440 | pub fn macos_major(text: &str) -> Option<u32> { |
| 441 | text.trim().split('.').next()?.parse().ok() |
| 442 | } |
| 443 | |
| 444 | pub fn vfkit_needs(macos: Option<u32>, vfkit: bool, homebrew: bool) -> Vec<Need> { |
| 445 | if macos.is_some_and(|major| major < MACOS_MAJOR) { |
| 446 | return vec![Need::MacosUpdate]; |
| 447 | } |
| 448 | match (vfkit, homebrew) { |
| 449 | (true, _) => vec![], |
| 450 | (false, true) => vec![Need::Vfkit], |
| 451 | (false, false) => vec![Need::Homebrew], |
| 452 | } |
| 453 | } |
| 454 | |
| 455 | /// Where Homebrew puts programs, which a program a Mac app starts doesn't |
| 456 | /// have on its PATH: Apple silicon's, then Intel's. |
| 457 | pub const HOMEBREW_BINS: [&str; 2] = ["/opt/homebrew/bin", "/usr/local/bin"]; |
| 458 | |
| 459 | #[cfg(test)] |
| 460 | mod tests { |
| 461 | use super::*; |
| 462 | |
| 463 | /// What the probe printed on a Windows 11 Pro desktop with Hyper-V running. |
| 464 | const PRO: &str = r#"{"edition":"Professional","build":26220,"hypervisor":true,"firmware":false,"model":"MS-7D25","manufacturer":"Micro-Star International Co., Ltd.","features":{"VirtualMachinePlatform":1,"Containers-DisposableClientVM":1},"booted":"2026-10-14T20:17:29.5000000Z"}"#; |
| 465 | |
| 466 | fn facts(json: &str) -> WindowsFacts { |
| 467 | serde_json::from_str(json).unwrap() |
| 468 | } |
| 469 | |
| 470 | /// A clean Windows 11 Home laptop: nothing turned on, virtualization on in its firmware. |
| 471 | fn clean_home() -> WindowsFacts { |
| 472 | facts(r#"{"edition":"Core","build":26100,"hypervisor":false,"firmware":true,"model":"XPS 13","manufacturer":"Dell Inc.","features":{"VirtualMachinePlatform":2},"booted":"2026-10-14T08:00:00Z"}"#) |
| 473 | } |
| 474 | |
| 475 | fn wsl(installed: bool, version: Option<(u32, u32, u32)>, has_wsl2: bool) -> WslFacts { |
| 476 | WslFacts { installed, version, has_wsl2 } |
| 477 | } |
| 478 | |
| 479 | #[test] |
| 480 | fn a_computer_that_has_everything_needs_nothing() { |
| 481 | let pro = facts(PRO); |
| 482 | assert!(!pro.home() && !pro.virtual_machine() && !pro.firmware_off()); |
| 483 | // Hyper-V running: firmware reads false, and means nothing. |
| 484 | assert_eq!(wsl_needs(&pro, &wsl(true, Some((2, 5, 9)), true), false), vec![]); |
| 485 | assert_eq!(wsl_needs(&pro, &wsl(true, Some((2, 5, 9)), false), false), vec![]); |
| 486 | assert_eq!(windows_sandbox_needs(&pro, true, true, false), vec![]); |
| 487 | } |
| 488 | |
| 489 | #[test] |
| 490 | fn a_clean_computer_needs_the_platform_and_wsl_set_up() { |
| 491 | let home = clean_home(); |
| 492 | assert_eq!(wsl_needs(&home, &wsl(false, None, false), false), vec![Need::VmPlatform, Need::Wsl]); |
| 493 | // The WSL built into Windows answers --status but not --version. |
| 494 | assert_eq!(wsl_needs(&home, &wsl(true, None, false), false), vec![Need::VmPlatform, Need::WslUpdate]); |
| 495 | assert_eq!(wsl_needs(&home, &wsl(true, Some((1, 2, 5)), false), false), vec![Need::VmPlatform, Need::WslUpdate]); |
| 496 | // Home has no Windows Sandbox, and that's said as such, not as "off". |
| 497 | assert_eq!(windows_sandbox_needs(&home, false, false, false), vec![Need::WindowsEdition]); |
| 498 | } |
| 499 | |
| 500 | #[test] |
| 501 | fn after_setup_it_says_restart_until_windows_has_restarted() { |
| 502 | let home = clean_home(); |
| 503 | assert_eq!(wsl_needs(&home, &wsl(true, Some((2, 5, 9)), false), true), vec![Need::Restart]); |
| 504 | let pro = WindowsFacts { edition: Some("Professional".into()), features: [(VM_PLATFORM.to_owned(), 1), (WINDOWS_SANDBOX.to_owned(), 2)].into(), ..clean_home() }; |
| 505 | assert_eq!(windows_sandbox_needs(&pro, false, false, true), vec![Need::Restart]); |
| 506 | // Restarted, with everything there: nothing. |
| 507 | let after = WindowsFacts { hypervisor: Some(true), firmware: Some(false), features: [(VM_PLATFORM.to_owned(), 1)].into(), ..clean_home() }; |
| 508 | assert_eq!(wsl_needs(&after, &wsl(true, Some((2, 5, 9)), false), false), vec![]); |
| 509 | // The file names the boot it was written in; a later boot's facts remove it. |
| 510 | let dir = std::env::temp_dir().join(format!("g1t-needs-restart-{}", std::process::id())); |
| 511 | std::fs::create_dir_all(&dir).unwrap(); |
| 512 | std::fs::write(dir.join(RESTART_FILE), "2026-10-14T08:00:00Z\r\n").unwrap(); |
| 513 | assert!(restart_pending(&dir, &home)); |
| 514 | let rebooted = WindowsFacts { booted: Some("2026-10-14T09:30:00Z".into()), ..clean_home() }; |
| 515 | assert!(!restart_pending(&dir, &rebooted)); |
| 516 | assert!(!dir.join(RESTART_FILE).exists()); |
| 517 | let _ = std::fs::remove_dir_all(&dir); |
| 518 | } |
| 519 | |
| 520 | #[test] |
| 521 | fn virtualization_off_in_the_firmware_comes_first_and_is_never_guessed() { |
| 522 | let off = WindowsFacts { firmware: Some(false), ..clean_home() }; |
| 523 | assert!(off.firmware_off()); |
| 524 | assert_eq!(wsl_needs(&off, &wsl(false, None, false), false), vec![Need::Virtualization]); |
| 525 | let pro = WindowsFacts { edition: Some("Professional".into()), features: [(VM_PLATFORM.to_owned(), 2), (WINDOWS_SANDBOX.to_owned(), 2)].into(), ..off.clone() }; |
| 526 | assert_eq!(windows_sandbox_needs(&pro, false, false, false), vec![Need::Virtualization]); |
| 527 | // Pro whose feature list leaves Windows Sandbox out (Windows Server, N editions): no Windows Sandbox. |
| 528 | let without = WindowsFacts { features: [(VM_PLATFORM.to_owned(), 2)].into(), ..pro }; |
| 529 | assert_eq!(windows_sandbox_needs(&without, false, false, false), vec![Need::WindowsEdition]); |
| 530 | // Not known: not said. |
| 531 | let unknown = WindowsFacts { firmware: None, ..off.clone() }; |
| 532 | assert_eq!(wsl_needs(&unknown, &wsl(false, None, false), false), vec![Need::VmPlatform, Need::Wsl]); |
| 533 | // In a VM the firmware's flag says nothing; an error names it instead. |
| 534 | let guest = WindowsFacts { model: Some("Virtual Machine".into()), manufacturer: Some("Microsoft Corporation".into()), ..off }; |
| 535 | assert!(guest.virtual_machine() && !guest.firmware_off()); |
| 536 | assert_eq!(guest.no_virtualization(), Need::NestedVirtualization); |
| 537 | assert_eq!(clean_home().no_virtualization(), Need::Virtualization); |
| 538 | assert!(about_virtualization("WSL could not import the sandbox: Please enable the Virtual Machine Platform Windows feature and ensure virtualization is enabled in the BIOS.")); |
| 539 | assert!(!about_virtualization("could not fetch https://g1t.sh")); |
| 540 | // Nothing read at all (PowerShell blocked): nothing concluded but what WSL says. |
| 541 | assert_eq!(wsl_needs(&WindowsFacts::default(), &wsl(false, None, false), false), vec![Need::Wsl]); |
| 542 | assert_eq!(windows_sandbox_needs(&WindowsFacts::default(), false, false, false), vec![Need::WindowsSandbox]); |
| 543 | } |
| 544 | |
| 545 | #[test] |
| 546 | fn old_windows_says_update() { |
| 547 | let old = WindowsFacts { build: Some(18363), ..clean_home() }; |
| 548 | assert_eq!(wsl_needs(&old, &wsl(false, None, false), false), vec![Need::WindowsUpdate]); |
| 549 | let pro = facts(PRO); |
| 550 | assert_eq!(windows_sandbox_needs(&pro, true, false, false), vec![Need::WindowsUpdate]); |
| 551 | let pro_23h2 = WindowsFacts { build: Some(22631), features: [(VM_PLATFORM.to_owned(), 1), (WINDOWS_SANDBOX.to_owned(), 2)].into(), ..pro }; |
| 552 | assert_eq!(windows_sandbox_needs(&pro_23h2, false, false, false), vec![Need::WindowsSandbox, Need::WindowsUpdate]); |
| 553 | } |
| 554 | |
| 555 | /// Asks this Windows: `cargo test -p g1t-runner -- --ignored this_windows --nocapture`. |
| 556 | #[test] |
| 557 | #[ignore] |
| 558 | fn this_windows_says_what_it_has() { |
| 559 | let facts = WindowsFacts::look(); |
| 560 | println!("{facts:#?}"); |
| 561 | assert!(facts.edition.is_some() && facts.build.is_some() && facts.booted.is_some(), "{facts:?}"); |
| 562 | assert!(facts.features.contains_key(VM_PLATFORM), "{facts:?}"); |
| 563 | // Kept until Windows restarts: asked once, then read back at once. |
| 564 | let dir = std::env::temp_dir().join(format!("g1t-facts-{}", std::process::id())); |
| 565 | let first = WindowsFacts::look_in(&dir); |
| 566 | let started = std::time::Instant::now(); |
| 567 | assert_eq!(WindowsFacts::look_in(&dir), first); |
| 568 | println!("read back in {:?}", started.elapsed()); |
| 569 | assert!(started.elapsed() < std::time::Duration::from_millis(100)); |
| 570 | let _ = std::fs::remove_dir_all(&dir); |
| 571 | } |
| 572 | |
| 573 | #[test] |
| 574 | fn what_windows_says_is_kept_until_it_restarts() { |
| 575 | assert!(same_boot(1_760_000_000, 1_760_000_002)); |
| 576 | assert!(!same_boot(1_760_000_000, 1_760_003_600)); |
| 577 | let dir = std::env::temp_dir().join(format!("g1t-facts-kept-{}", std::process::id())); |
| 578 | std::fs::create_dir_all(&dir).unwrap(); |
| 579 | let Some(boot) = booted_at() else { return }; |
| 580 | let kept = Kept { boot, facts: clean_home() }; |
| 581 | std::fs::write(dir.join(FACTS_FILE), serde_json::to_vec(&kept).unwrap()).unwrap(); |
| 582 | assert_eq!(WindowsFacts::look_in(&dir), clean_home()); |
| 583 | let _ = std::fs::remove_dir_all(&dir); |
| 584 | } |
| 585 | |
| 586 | #[test] |
| 587 | fn wsl_says_its_version_in_any_language() { |
| 588 | assert_eq!(wsl_version("WSL version: 2.5.9.0\nKernel version: 6.6.87.2-1\n"), Some((2, 5, 9))); |
| 589 | assert_eq!(wsl_version("W\0S\0L\0-\0V\0e\0r\0s\0i\0o\0n\0:\0 \x002\0.\x000\0.\x004\0.\x000\0"), Some((2, 0, 4))); |
| 590 | assert_eq!(wsl_version("Version de WSL : 1.2.5.0"), Some((1, 2, 5))); |
| 591 | assert_eq!(wsl_version("Invalid command line option: --version"), None); |
| 592 | } |
| 593 | |
| 594 | #[test] |
| 595 | fn linux_says_what_kvm_lacks() { |
| 596 | assert_eq!(cpu_flags("processor\t: 0\nflags\t\t: fpu vme sse vmx ept\n"), (Some(true), false)); |
| 597 | assert_eq!(cpu_flags("flags\t\t: fpu sse hypervisor\n"), (Some(false), true)); |
| 598 | assert_eq!(cpu_flags("Features\t: fp asimd\n"), (None, false)); |
| 599 | let all = KvmFacts { device: true, usable: true, cpu: Some(true), guest: false, qemu: true }; |
| 600 | assert_eq!(kvm_needs(&all), vec![]); |
| 601 | assert_eq!(kvm_needs(&KvmFacts { usable: false, ..all.clone() }), vec![Need::KvmGroup]); |
| 602 | assert_eq!(kvm_needs(&KvmFacts { device: false, usable: false, ..all.clone() }), vec![Need::KvmModule]); |
| 603 | assert_eq!(kvm_needs(&KvmFacts { device: false, usable: false, cpu: Some(false), ..all.clone() }), vec![Need::Virtualization]); |
| 604 | assert_eq!(kvm_needs(&KvmFacts { device: false, usable: false, cpu: Some(false), guest: true, qemu: false }), vec![Need::NestedVirtualization, Need::Qemu]); |
| 605 | } |
| 606 | |
| 607 | #[test] |
| 608 | fn a_mac_says_what_vfkit_lacks() { |
| 609 | assert_eq!(macos_major("14.6.1\n"), Some(14)); |
| 610 | assert_eq!(vfkit_needs(Some(12), false, true), vec![Need::MacosUpdate]); |
| 611 | assert_eq!(vfkit_needs(Some(15), false, false), vec![Need::Homebrew]); |
| 612 | assert_eq!(vfkit_needs(Some(15), false, true), vec![Need::Vfkit]); |
| 613 | assert_eq!(vfkit_needs(None, true, false), vec![]); |
| 614 | } |
| 615 | |
| 616 | #[test] |
| 617 | fn needs_are_said_by_name_and_old_apps_still_read_them() { |
| 618 | assert_eq!(serde_json::to_string(&Need::VmPlatform).unwrap(), r#""vm_platform""#); |
| 619 | assert_eq!(serde_json::from_str::<Need>(r#""nested_virtualization""#).unwrap(), Need::NestedVirtualization); |
| 620 | // A desktop app from before `needs` reads the cause's words. |
| 621 | assert!(Need::Wsl.said("linux").0.to_ascii_lowercase().contains("wsl is not installed")); |
| 622 | assert!(Need::WindowsSandbox.said("windows").0.to_ascii_lowercase().contains("windows sandbox is not turned on")); |
| 623 | assert!(Need::Virtualization.said("linux").0.to_ascii_lowercase().contains("vt-x")); |
| 624 | assert!(Need::Homebrew.said("linux").0.contains("vfkit") && Need::Homebrew.said("linux").0.contains("not installed")); |
| 625 | assert!(Need::WindowsUpdate.said("windows").0.contains("24H2")); |
| 626 | } |
| 627 | } |