| 1 | //! Windows: the sandbox is a WSL 2 distribution of its own, `g1t-sandbox`, |
| 2 | //! imported from the release's signed root filesystem. |
| 3 | //! |
| 4 | //! Why WSL 2 rather than a Hyper-V VM: WSL 2 is on every edition of |
| 5 | //! Windows 11, Home included, and once it is installed (one administrator |
| 6 | //! step, `wsl --install --no-distribution`, which many people have done |
| 7 | //! already), importing, starting and removing a distribution needs no |
| 8 | //! administrator. A Hyper-V VM needs the Hyper-V feature, which Home does |
| 9 | //! not have, and membership of Hyper-V Administrators to make or start a |
| 10 | //! VM. A WSL 2 distribution is a real Linux kernel in a VM, not the |
| 11 | //! Windows kernel. |
| 12 | //! |
| 13 | //! What WSL would otherwise share with Windows is turned off: the image's |
| 14 | //! `/etc/wsl.conf` mounts no drives and starts no Windows programs, and |
| 15 | //! the supervisor inside unmounts what WSL mounts into every distribution |
| 16 | //! anyway and refuses to run while anything of the computer's is reachable |
| 17 | //! (guest.rs). The distribution is started by a hidden `wsl.exe` that the |
| 18 | //! launcher keeps running (WSL stops a distribution once nothing of |
| 19 | //! Windows' is attached to it); nothing listens on the computer. |
| 20 | //! |
| 21 | //! What it cannot do: every WSL 2 distribution, the person's own |
| 22 | //! included, runs in one utility VM with one kernel, so root in this |
| 23 | //! distribution could reach theirs. That is why nothing a job runs is root |
| 24 | //! here: the runner is a Sysbox container and each job a container inside |
| 25 | //! it. Global WSL settings (`%USERPROFILE%\.wslconfig`) apply too: with |
| 26 | //! `networkingMode=mirrored` Linux reaches Windows' own `localhost`, so the |
| 27 | //! launcher refuses to start with it. |
| 28 | |
| 29 | use std::path::{Path, PathBuf}; |
| 30 | use std::process::{Command, Output, Stdio}; |
| 31 | |
| 32 | use anyhow::{Context, Result, bail}; |
| 33 | |
| 34 | use super::guest::{GuestStatus, Settings}; |
| 35 | use super::image::{self, Local}; |
| 36 | use super::{Backend, Resources, StopMode, process_alive}; |
| 37 | use crate::selfhosted::native::{Availability, hide_window}; |
| 38 | |
| 39 | /// What the distribution is called. |
| 40 | pub const DISTRO: &str = "g1t-sandbox"; |
| 41 | const GUEST: &str = "/usr/local/bin/g1t-runner"; |
| 42 | |
| 43 | pub struct Wsl { |
| 44 | pub distro: String, |
| 45 | /// The launcher's state folder: the distribution's disk is in `vm`. |
| 46 | pub dir: PathBuf, |
| 47 | } |
| 48 | |
| 49 | fn wsl() -> Command { |
| 50 | let mut command = Command::new("wsl.exe"); |
| 51 | command.env("WSL_UTF8", "1"); |
| 52 | hide_window(&mut command); |
| 53 | command |
| 54 | } |
| 55 | |
| 56 | /// `wsl.exe --list --verbose`: each distribution's name and state. |
| 57 | pub fn parse_list(text: &str) -> Vec<(String, String)> { |
| 58 | text.replace('\0', "") |
| 59 | .lines() |
| 60 | .skip(1) |
| 61 | .filter_map(|line| { |
| 62 | let fields: Vec<&str> = line.trim().trim_start_matches('*').split_whitespace().collect(); |
| 63 | (fields.len() >= 2).then(|| (fields[0].to_owned(), fields[1].to_owned())) |
| 64 | }) |
| 65 | .collect() |
| 66 | } |
| 67 | |
| 68 | /// Whether `.wslconfig` puts WSL on mirrored networking, where Linux |
| 69 | /// reaches Windows' own `localhost`. |
| 70 | pub fn mirrored(wslconfig: &str) -> bool { |
| 71 | let mut section = String::new(); |
| 72 | for line in wslconfig.lines() { |
| 73 | let line = line.trim(); |
| 74 | if line.starts_with('[') { |
| 75 | section = line.trim_matches(['[', ']']).trim().to_ascii_lowercase(); |
| 76 | } else if section == "wsl2" |
| 77 | && let Some((key, value)) = line.split_once('=') |
| 78 | && key.trim().eq_ignore_ascii_case("networkingMode") |
| 79 | { |
| 80 | return value.split('#').next().unwrap_or("").trim().eq_ignore_ascii_case("mirrored"); |
| 81 | } |
| 82 | } |
| 83 | false |
| 84 | } |
| 85 | |
| 86 | impl Wsl { |
| 87 | fn list(&self) -> Vec<(String, String)> { |
| 88 | wsl().args(["--list", "--verbose"]).output().map(|o| parse_list(&String::from_utf8_lossy(&o.stdout))).unwrap_or_default() |
| 89 | } |
| 90 | |
| 91 | fn state(&self) -> Option<String> { |
| 92 | self.list().into_iter().find(|(name, _)| name.eq_ignore_ascii_case(&self.distro)).map(|(_, state)| state) |
| 93 | } |
| 94 | |
| 95 | fn keepalive_pid(&self) -> PathBuf { |
| 96 | self.dir.join("keepalive.pid") |
| 97 | } |
| 98 | |
| 99 | /// `g1t-runner sandbox guest …` inside the distribution. |
| 100 | fn guest(&self, args: &[&str], input: Option<&[u8]>) -> Result<Output> { |
| 101 | let mut command = wsl(); |
| 102 | command.args(["-d", &self.distro, "-u", "root", "--exec", GUEST, "sandbox", "guest"]).args(args); |
| 103 | command.stdout(Stdio::piped()).stderr(Stdio::piped()); |
| 104 | command.stdin(if input.is_some() { Stdio::piped() } else { Stdio::null() }); |
| 105 | let mut child = command.spawn().context("could not run wsl.exe")?; |
| 106 | if let Some(bytes) = input { |
| 107 | use std::io::Write; |
| 108 | let mut stdin = child.stdin.take().expect("piped"); |
| 109 | stdin.write_all(bytes)?; |
| 110 | } |
| 111 | let output = child.wait_with_output()?; |
| 112 | if !output.status.success() { |
| 113 | bail!("the sandbox said: {}", String::from_utf8_lossy(&output.stderr).trim().trim_start_matches("g1t-runner: ")); |
| 114 | } |
| 115 | Ok(output) |
| 116 | } |
| 117 | } |
| 118 | |
| 119 | impl Backend for Wsl { |
| 120 | fn name(&self) -> &'static str { |
| 121 | "wsl2" |
| 122 | } |
| 123 | |
| 124 | fn image_keys(&self) -> Vec<String> { |
| 125 | vec![format!("wsl-{}", image::arch())] |
| 126 | } |
| 127 | |
| 128 | fn availability(&self) -> Availability { |
| 129 | let installed = wsl().arg("--status").stdout(Stdio::null()).stderr(Stdio::null()).status().is_ok_and(|s| s.success()); |
| 130 | if !installed { |
| 131 | return Availability::no( |
| 132 | "WSL is not installed, and the sandbox for Linux jobs is a WSL distribution of its own.", |
| 133 | "Open a terminal as administrator and run `wsl --install --no-distribution`, then restart Windows. It works on every edition of Windows 11, Home included.", |
| 134 | ); |
| 135 | } |
| 136 | let config = std::env::var_os("USERPROFILE").map(|home| PathBuf::from(home).join(".wslconfig")); |
| 137 | if config.and_then(|c| std::fs::read_to_string(c).ok()).is_some_and(|text| mirrored(&text)) { |
| 138 | return Availability::no( |
| 139 | "WSL is set to mirrored networking, in which Linux reaches this computer's own localhost.", |
| 140 | "Remove networkingMode=mirrored from %USERPROFILE%\\.wslconfig, run `wsl --shutdown`, and start the sandbox again.", |
| 141 | ); |
| 142 | } |
| 143 | Availability::yes() |
| 144 | } |
| 145 | |
| 146 | fn exists(&self) -> bool { |
| 147 | self.state().is_some() |
| 148 | } |
| 149 | |
| 150 | fn running(&self) -> bool { |
| 151 | self.state().is_some_and(|s| s.eq_ignore_ascii_case("Running")) |
| 152 | } |
| 153 | |
| 154 | fn create(&self, local: &Local, _resources: &Resources) -> Result<()> { |
| 155 | let key = &self.image_keys()[0]; |
| 156 | let Some(rootfs) = local.files.get(key) else { bail!("no {key} image") }; |
| 157 | let vm = self.dir.join("vm"); |
| 158 | std::fs::create_dir_all(&vm)?; |
| 159 | let output = wsl().args(import_args(&self.distro, &vm, rootfs)).output().context("could not run wsl.exe")?; |
| 160 | if !output.status.success() { |
| 161 | bail!("WSL could not import the sandbox: {}{}", String::from_utf8_lossy(&output.stdout).trim(), String::from_utf8_lossy(&output.stderr).trim()); |
| 162 | } |
| 163 | // The disk is not limited to `disk_gib`: `wsl --manage --resize` |
| 164 | // only works with all of WSL shut down, which would stop the |
| 165 | // person's own distributions and Docker Desktop. It grows as |
| 166 | // needed, up to WSL's default of 1 TB. |
| 167 | Ok(()) |
| 168 | } |
| 169 | |
| 170 | fn configure(&self, settings: &Settings) -> Result<()> { |
| 171 | self.guest(&["configure"], Some(&serde_json::to_vec(settings)?))?; |
| 172 | Ok(()) |
| 173 | } |
| 174 | |
| 175 | fn load_image(&self, tar: &Path) -> Result<()> { |
| 176 | let bytes = std::fs::read(tar).with_context(|| format!("could not read {}", tar.display()))?; |
| 177 | self.guest(&["load-image"], Some(&bytes))?; |
| 178 | Ok(()) |
| 179 | } |
| 180 | |
| 181 | fn start(&self) -> Result<()> { |
| 182 | let pid_file = self.keepalive_pid(); |
| 183 | if std::fs::read_to_string(&pid_file).ok().and_then(|p| p.trim().parse().ok()).is_some_and(process_alive) && self.running() { |
| 184 | return Ok(()); |
| 185 | } |
| 186 | let log = std::fs::File::create(self.dir.join("sandbox.log"))?; |
| 187 | let mut command = wsl(); |
| 188 | command |
| 189 | .args(["-d", &self.distro, "-u", "root", "--exec", GUEST, "sandbox", "guest", "run"]) |
| 190 | .stdin(Stdio::null()) |
| 191 | .stdout(log.try_clone()?) |
| 192 | .stderr(log); |
| 193 | let child = super::spawn_detached(command)?; |
| 194 | std::fs::write(&pid_file, child.to_string())?; |
| 195 | Ok(()) |
| 196 | } |
| 197 | |
| 198 | fn guest_status(&self) -> Option<GuestStatus> { |
| 199 | if !self.running() { |
| 200 | return None; |
| 201 | } |
| 202 | let output = self.guest(&["status"], None).ok()?; |
| 203 | serde_json::from_slice(&output.stdout).ok() |
| 204 | } |
| 205 | |
| 206 | fn stop(&self, mode: StopMode) -> Result<()> { |
| 207 | if self.running() { |
| 208 | let args: Vec<&str> = match mode { |
| 209 | StopMode::Drain => vec!["stop"], |
| 210 | StopMode::Now => vec!["stop", "--now", "--wait", "120"], |
| 211 | }; |
| 212 | // Stopping now goes on to end the distribution whatever the |
| 213 | // guest said; a drain that failed must not. |
| 214 | if let Err(error) = self.guest(&args, None) |
| 215 | && mode == StopMode::Drain |
| 216 | { |
| 217 | return Err(error); |
| 218 | } |
| 219 | } |
| 220 | let _ = wsl().args(["--terminate", &self.distro]).stdout(Stdio::null()).stderr(Stdio::null()).status(); |
| 221 | if let Some(pid) = std::fs::read_to_string(self.keepalive_pid()).ok().and_then(|p| p.trim().parse::<u32>().ok()) { |
| 222 | if process_alive(pid) { |
| 223 | let _ = Command::new("taskkill").args(["/F", "/PID", &pid.to_string()]).stdout(Stdio::null()).stderr(Stdio::null()).status(); |
| 224 | } |
| 225 | let _ = std::fs::remove_file(self.keepalive_pid()); |
| 226 | } |
| 227 | Ok(()) |
| 228 | } |
| 229 | |
| 230 | fn export_state(&self) -> Result<Option<Vec<u8>>> { |
| 231 | if !self.exists() { |
| 232 | return Ok(None); |
| 233 | } |
| 234 | // Not registered yet: nothing to carry. |
| 235 | Ok(self.guest(&["export-state"], None).ok().map(|o| o.stdout).filter(|b| !b.is_empty())) |
| 236 | } |
| 237 | |
| 238 | fn import_state(&self, bytes: &[u8]) -> Result<()> { |
| 239 | self.guest(&["import-state"], Some(bytes))?; |
| 240 | Ok(()) |
| 241 | } |
| 242 | |
| 243 | fn remove(&self) -> Result<()> { |
| 244 | self.stop(StopMode::Now)?; |
| 245 | if self.exists() { |
| 246 | let output = wsl().args(["--unregister", &self.distro]).output().context("could not run wsl.exe")?; |
| 247 | if !output.status.success() { |
| 248 | bail!("WSL could not remove the sandbox: {}", String::from_utf8_lossy(&output.stdout).trim()); |
| 249 | } |
| 250 | } |
| 251 | let _ = std::fs::remove_dir_all(self.dir.join("vm")); |
| 252 | Ok(()) |
| 253 | } |
| 254 | } |
| 255 | |
| 256 | /// `wsl.exe --import`: a WSL 2 distribution with its disk in `vm`. |
| 257 | pub fn import_args(distro: &str, vm: &Path, rootfs: &Path) -> Vec<String> { |
| 258 | vec!["--import".into(), distro.into(), vm.display().to_string(), rootfs.display().to_string(), "--version".into(), "2".into()] |
| 259 | } |
| 260 | |
| 261 | #[cfg(test)] |
| 262 | mod tests { |
| 263 | use super::*; |
| 264 | |
| 265 | #[test] |
| 266 | fn the_list_reads_with_its_default_marker() { |
| 267 | let text = " NAME STATE VERSION\n* Ubuntu Stopped 2\n g1t-sandbox Running 2\n docker-desktop Running 2\n"; |
| 268 | let list = parse_list(text); |
| 269 | assert_eq!(list[0], ("Ubuntu".to_owned(), "Stopped".to_owned())); |
| 270 | assert_eq!(list[1], ("g1t-sandbox".to_owned(), "Running".to_owned())); |
| 271 | assert_eq!(list.len(), 3); |
| 272 | // wsl.exe without WSL_UTF8 writes UTF-16: its NULs are dropped. |
| 273 | assert_eq!(parse_list("N\0A\0M\0E\0\n\0g\x001\0t\0 \0R\0u\0n\0\n")[0].0, "g1t"); |
| 274 | } |
| 275 | |
| 276 | #[test] |
| 277 | fn mirrored_networking_is_found() { |
| 278 | assert!(mirrored("[wsl2]\nmemory=16GB\nnetworkingMode = Mirrored # yes\n")); |
| 279 | assert!(!mirrored("[wsl2]\nnetworkingMode=nat\n")); |
| 280 | assert!(!mirrored("[experimental]\nnetworkingMode=mirrored\n")); |
| 281 | assert!(!mirrored("")); |
| 282 | } |
| 283 | |
| 284 | #[test] |
| 285 | fn the_import_is_wsl2_with_a_size() { |
| 286 | let args = import_args("g1t-sandbox", Path::new(r"C:\s\vm"), Path::new(r"C:\s\images\0.3.0\g1t-sandbox-wsl-x64.tar.gz")); |
| 287 | assert_eq!(args[..3], ["--import", "g1t-sandbox", r"C:\s\vm"]); |
| 288 | assert!(args.windows(2).any(|w| w == ["--version", "2"])); |
| 289 | } |
| 290 | } |