Skip to content
290 linesCodeBlameRaw
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
29use std::path::{Path, PathBuf};
30use std::process::{Command, Output, Stdio};
31
32use anyhow::{Context, Result, bail};
33
34use super::guest::{GuestStatus, Settings};
35use super::image::{self, Local};
36use super::{Backend, Resources, StopMode, process_alive};
37use crate::selfhosted::native::{Availability, hide_window};
38
39/// What the distribution is called.
40pub const DISTRO: &str = "g1t-sandbox";
41const GUEST: &str = "/usr/local/bin/g1t-runner";
42
43pub 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
49fn 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.
57pub 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`.
70pub 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
86impl 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
119impl 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`.
257pub 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)]
262mod 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}