Skip to content
447 linesCodeBlameRaw
1//! Jobs native to the runner's own OS, each in a throwaway virtual machine
2//! made for it and destroyed after: `vm` isolation (sandbox.rs).
3//!
4//! - **Windows: Windows Sandbox**, through its command line (`wsb`, in
5//! Windows 11 24H2 and later). Each job gets a fresh sandbox: a Hyper-V
6//! VM with its own kernel, started from the machine's own Windows image
7//! and discarded, disk and all, when it stops. Its configuration turns
8//! off everything that reaches the machine: no GPU, no clipboard, no
9//! printers, no microphone or camera, and one folder mapped read-only,
10//! which holds only a copy of the harness and the job's script. The
11//! runner on the machine starts the sandbox, runs the script in it as
12//! SYSTEM (`wsb exec`, which waits and returns the exit code) and stops
13//! it. Windows Sandbox needs Windows 11 Pro, Enterprise or Education
14//! with the Windows Sandbox feature on; it is not in Home.
15//!
16//! Chosen over Hyper-V-isolated Windows containers, which need the
17//! Containers and Hyper-V features (Windows 11 Pro or Enterprise, and an
18//! administrator to turn them on), a container engine in Windows
19//! containers mode, and an image matching the machine's Windows build.
20//! A Windows Sandbox needs none of that, only the one feature, and its
21//! disposable VM is the same kind of wall.
22//!
23//! - **macOS: a cloned macOS VM** (designed, not run on a Mac yet). Each
24//! job gets an APFS clone of a signed macOS image, booted with Apple's
25//! Virtualization framework through `tart` with no display, no audio,
26//! no clipboard and no shared folder, and deleted after. Apple's licence
27//! allows two macOS VMs per Mac at once ([`MacVm::MAX_VMS`]); a runner
28//! runs one job at a time, so a Mac runs at most two such runners.
29
30use std::collections::BTreeMap;
31use std::path::{Path, PathBuf};
32use std::process::{Command, Stdio};
33
34use anyhow::{Context, Result, anyhow, bail};
35use serde::Serialize;
36
37use super::sandbox::{Isolation, SandboxDriver};
38
39/// Whether a kind of sandbox can be made here, and if not, why and what
40/// to do about it: what `g1t-runner sandbox status` reports for each
41/// capability, and what the desktop app shows.
42#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
43pub struct Availability {
44 pub available: bool,
45 /// Why it is not, in a sentence.
46 pub cause: Option<String>,
47 /// What the person can do about it, in a sentence.
48 pub fix: Option<String>,
49}
50
51impl Availability {
52 pub fn yes() -> Availability {
53 Availability { available: true, cause: None, fix: None }
54 }
55 pub fn no(cause: impl Into<String>, fix: impl Into<String>) -> Availability {
56 Availability { available: false, cause: Some(cause.into()), fix: Some(fix.into()) }
57 }
58}
59
60// --- Windows Sandbox --------------------------------------------------------
61
62/// Where the job's read-only folder appears inside the sandbox.
63pub const DROP_IN_SANDBOX: &str = r"C:\g1t";
64/// Where a workflow job keeps its files inside the sandbox.
65pub const WORK_IN_SANDBOX: &str = r"C:\work";
66
67/// Windows Sandbox, one per job.
68pub struct WindowsSandbox;
69
70/// What a machine has: the feature's program, and its command line.
71#[derive(Clone, Copy, Debug, Default)]
72pub struct WsbFound {
73 /// `WindowsSandbox.exe` in System32: the feature is on.
74 pub feature: bool,
75 /// `wsb.exe` answers: Windows 11 24H2 or later.
76 pub cli: bool,
77}
78
79impl WsbFound {
80 pub fn look() -> WsbFound {
81 let windir = std::env::var_os("windir").map(PathBuf::from).unwrap_or_else(|| PathBuf::from(r"C:\Windows"));
82 let feature = windir.join("System32").join("WindowsSandbox.exe").exists();
83 let cli = feature && wsb().arg("--version").stdout(Stdio::null()).stderr(Stdio::null()).status().is_ok_and(|s| s.success());
84 WsbFound { feature, cli }
85 }
86
87 pub fn availability(self) -> Availability {
88 if !cfg!(windows) {
89 return Availability::no("Windows Sandbox is only on Windows.", "Use a Windows machine for Windows jobs.");
90 }
91 if !self.feature {
92 return Availability::no(
93 "Windows Sandbox is not turned on, or this edition of Windows has none (Home does not).",
94 "On Windows 11 Pro, Enterprise or Education, turn on Windows Sandbox under Settings, System, Optional features, More Windows features, then restart.",
95 );
96 }
97 if !self.cli {
98 return Availability::no(
99 "This Windows has Windows Sandbox but not its command line, wsb, which came in Windows 11 24H2.",
100 "Update Windows to 24H2 or later.",
101 );
102 }
103 Availability::yes()
104 }
105}
106
107impl SandboxDriver for WindowsSandbox {
108 fn name(&self) -> &'static str {
109 "Windows Sandbox"
110 }
111 fn preflight(&self) -> Result<Isolation> {
112 let found = WsbFound::look().availability();
113 if found.available {
114 return Ok(Isolation::Vm);
115 }
116 bail!("{} {}", found.cause.unwrap_or_default(), found.fix.unwrap_or_default())
117 }
118}
119
120fn wsb() -> Command {
121 let mut command = Command::new("wsb");
122 hide_window(&mut command);
123 command
124}
125
126/// No console window flashes up for a command the runner runs.
127pub fn hide_window(command: &mut Command) {
128 #[cfg(windows)]
129 {
130 use std::os::windows::process::CommandExt;
131 const CREATE_NO_WINDOW: u32 = 0x0800_0000;
132 command.creation_flags(CREATE_NO_WINDOW);
133 }
134 #[cfg(not(windows))]
135 let _ = command;
136}
137
138fn xml_escape(text: &str) -> String {
139 text.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;").replace('\'', "&apos;")
140}
141
142/// A job's sandbox's configuration (a `.wsb` file's XML, which `wsb start
143/// -c` takes as a string). Every redirection that reaches the machine is
144/// off; the only folder is `drop`, read-only.
145pub fn wsb_config(drop: &Path, memory_mib: u64) -> String {
146 format!(
147 "<Configuration>\
148<vGPU>Disable</vGPU>\
149<Networking>Enable</Networking>\
150<AudioInput>Disable</AudioInput>\
151<VideoInput>Disable</VideoInput>\
152<ClipboardRedirection>Disable</ClipboardRedirection>\
153<PrinterRedirection>Disable</PrinterRedirection>\
154<ProtectedClient>Enable</ProtectedClient>\
155<MemoryInMB>{memory_mib}</MemoryInMB>\
156<MappedFolders><MappedFolder><HostFolder>{}</HostFolder><SandboxFolder>{DROP_IN_SANDBOX}</SandboxFolder><ReadOnly>true</ReadOnly></MappedFolder></MappedFolders>\
157</Configuration>",
158 xml_escape(&drop.display().to_string())
159 )
160}
161
162/// A string in PowerShell's single quotes, where nothing is expanded and a
163/// quote is written twice.
164pub fn ps_quote(text: &str) -> String {
165 format!("'{}'", text.replace('\'', "''"))
166}
167
168/// The script a job runs in its sandbox: a firewall rule against the
169/// machine, its variables, then the harness. The variables are written
170/// here, in the job's read-only folder, rather than on `wsb exec`'s
171/// command line, where the machine's other programs could read them.
172///
173/// The firewall rule keeps the job from the machine's own services on the
174/// address the sandbox reaches it by (its gateway), but the job runs as
175/// SYSTEM in its sandbox and could remove it: it is a speed bump, not a
176/// wall. The wall is the VM.
177pub fn job_script(env: &BTreeMap<String, String>) -> String {
178 let mut script = String::from(
179 "$ErrorActionPreference = 'Stop'\r\n\
180$gateway = (Get-NetRoute -DestinationPrefix '0.0.0.0/0' -ErrorAction SilentlyContinue | Select-Object -First 1).NextHop\r\n\
181if ($gateway) { New-NetFirewallRule -DisplayName 'g1t: not the machine' -Direction Outbound -Action Block -Protocol TCP -RemoteAddress $gateway | Out-Null }\r\n",
182 );
183 script.push_str(&format!("New-Item -ItemType Directory -Force -Path {} | Out-Null\r\n", ps_quote(WORK_IN_SANDBOX)));
184 for (name, value) in env {
185 // Names are the harness's own (`ACTIONS_TOKEN`, `MODE`…): only
186 // letters, digits and underscores reach `$env:`.
187 if name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
188 script.push_str(&format!("${{env:{name}}} = {}\r\n", ps_quote(value)));
189 }
190 }
191 script.push_str(&format!("$env:G1T_RUNNER_ROOT = {}\r\n", ps_quote(WORK_IN_SANDBOX)));
192 script.push_str(&format!("Set-Location {}\r\n", ps_quote(WORK_IN_SANDBOX)));
193 script.push_str(&format!("& {}\r\nexit $LASTEXITCODE\r\n", ps_quote(&format!(r"{DROP_IN_SANDBOX}\g1t-runner.exe"))));
194 script
195}
196
197/// What `wsb exec` runs.
198pub fn exec_command() -> String {
199 format!(r"powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File {DROP_IN_SANDBOX}\job.ps1")
200}
201
202/// `{"ExitCode": 7}`, from `wsb exec --raw`.
203pub fn parse_exit_code(raw: &str) -> Option<i32> {
204 let start = raw.find('{')?;
205 let value: serde_json::Value = serde_json::from_str(raw[start..].trim()).ok()?;
206 value.get("ExitCode").and_then(serde_json::Value::as_i64).map(|code| code as i32)
207}
208
209/// `{"Id": "…"}`, from `wsb start --raw`.
210pub fn parse_id(raw: &str) -> Option<String> {
211 let start = raw.find('{')?;
212 let value: serde_json::Value = serde_json::from_str(raw[start..].trim()).ok()?;
213 value.get("Id").and_then(serde_json::Value::as_str).map(str::to_owned)
214}
215
216/// A job's sandbox, while it exists.
217pub struct WsbJob {
218 pub id: String,
219 /// The job's read-only folder on the machine, removed after.
220 pub drop: PathBuf,
221}
222
223impl WsbJob {
224 /// Makes the job's folder (a copy of this program, which is the
225 /// harness, and its script) and starts its sandbox.
226 pub fn start(drop: PathBuf, env: &BTreeMap<String, String>, memory_mib: u64) -> Result<WsbJob> {
227 let _ = std::fs::remove_dir_all(&drop);
228 std::fs::create_dir_all(&drop).with_context(|| format!("could not make {}", drop.display()))?;
229 let me = std::env::current_exe().context("could not find this program, the job's harness")?;
230 std::fs::copy(&me, drop.join("g1t-runner.exe")).context("could not copy the harness for the sandbox")?;
231 std::fs::write(drop.join("job.ps1"), job_script(env))?;
232 let output = wsb().args(["start", "--raw", "-c", &wsb_config(&drop, memory_mib)]).output().context("could not run wsb")?;
233 let text = String::from_utf8_lossy(&output.stdout);
234 let Some(id) = parse_id(&text).filter(|_| output.status.success()) else {
235 let _ = std::fs::remove_dir_all(&drop);
236 bail!("Windows Sandbox did not start: {} {}", text.trim(), String::from_utf8_lossy(&output.stderr).trim());
237 };
238 if let Some(parent) = drop.parent() {
239 let _ = std::fs::write(parent.join("sandbox-id"), &id);
240 }
241 Ok(WsbJob { id, drop })
242 }
243
244 /// The process that runs the job and waits for it: its standard output
245 /// carries the exit code ([`parse_exit_code`]).
246 pub fn exec(&self) -> Command {
247 let mut command = wsb();
248 command.args(["exec", "--raw", "--id", &self.id, "-c", &exec_command(), "-r", "System"]);
249 command.stdout(Stdio::piped()).stderr(Stdio::null()).stdin(Stdio::null());
250 command
251 }
252
253 /// Stops the sandbox, which discards it.
254 pub fn stop(&self) {
255 let _ = wsb().args(["stop", "--id", &self.id]).stdout(Stdio::null()).stderr(Stdio::null()).status();
256 }
257
258 pub fn clean(&self) {
259 self.stop();
260 let _ = std::fs::remove_dir_all(&self.drop);
261 if let Some(parent) = self.drop.parent() {
262 let _ = std::fs::remove_file(parent.join("sandbox-id"));
263 }
264 }
265}
266
267/// Where a runner notes the sandbox it is running, so that one, and only
268/// that one, is stopped if the runner stopped mid-job: the person may have
269/// Windows Sandboxes of their own open.
270pub fn noted(folder: &Path) -> PathBuf {
271 folder.join("native").join("sandbox-id")
272}
273
274/// Stops the sandbox an earlier run of this runner left, if any.
275pub fn sweep(folder: &Path) {
276 let note = noted(folder);
277 if let Ok(id) = std::fs::read_to_string(&note) {
278 let id = id.trim();
279 if parse_list(&String::from_utf8_lossy(&wsb().args(["list", "--raw"]).output().map(|o| o.stdout).unwrap_or_default())).iter().any(|running| running == id) {
280 let _ = wsb().args(["stop", "--id", id]).stdout(Stdio::null()).stderr(Stdio::null()).status();
281 }
282 let _ = std::fs::remove_file(&note);
283 }
284}
285
286/// The ids in `wsb list --raw`.
287pub fn parse_list(raw: &str) -> Vec<String> {
288 let Some(start) = raw.find('{') else { return Vec::new() };
289 let Ok(value) = serde_json::from_str::<serde_json::Value>(raw[start..].trim()) else { return Vec::new() };
290 value
291 .get("WindowsSandboxEnvironments")
292 .and_then(serde_json::Value::as_array)
293 .map(|all| all.iter().filter_map(|e| e.get("Id").and_then(serde_json::Value::as_str).map(str::to_owned)).collect())
294 .unwrap_or_default()
295}
296
297// --- macOS VMs (designed) -----------------------------------------------------
298
299/// A cloned macOS VM per job, through `tart` on Apple's Virtualization
300/// framework. **Designed, not run on a Mac yet:** its commands are built
301/// and tested here, and [`SandboxDriver::preflight`] says it is not
302/// available, so no Mac registers as able to take macOS jobs until it has
303/// been run on one.
304///
305/// - The image is an OCI image of a macOS VM, named by digest in the
306/// signed release manifest, so `tart clone` fetches exactly that.
307/// - `tart run --no-graphics --no-audio --no-clipboard`, with no `--dir`:
308/// no display, no sound, no clipboard, no folder of the Mac's.
309/// - The job runs through `tart exec` (the guest agent in the image), and
310/// the VM is stopped and deleted after.
311pub struct MacVm;
312
313// The commands are built and tested ahead of the driver that runs them.
314#[allow(dead_code)]
315impl MacVm {
316 /// Apple's macOS licence: two virtual copies per Mac.
317 pub const MAX_VMS: u32 = 2;
318
319 pub fn clone_args(image: &str, name: &str) -> Vec<String> {
320 vec!["clone".into(), image.into(), name.into()]
321 }
322
323 pub fn run_args(name: &str) -> Vec<String> {
324 vec!["run".into(), "--no-graphics".into(), "--no-audio".into(), "--no-clipboard".into(), name.into()]
325 }
326
327 pub fn exec_args(name: &str, script: &str) -> Vec<String> {
328 vec!["exec".into(), name.into(), "/bin/bash".into(), "-lc".into(), script.into()]
329 }
330
331 pub fn delete_args(name: &str) -> Vec<String> {
332 vec!["delete".into(), name.into()]
333 }
334
335 pub fn availability() -> Availability {
336 if !cfg!(target_os = "macos") {
337 return Availability::no("macOS VMs are only on a Mac.", "Use a Mac for macOS jobs.");
338 }
339 Availability::no(
340 "Sandboxed macOS jobs are not released yet.",
341 "Run macOS jobs on a runner registered with --no-docker (host mode, unsandboxed) for now, or wait for a release that has them.",
342 )
343 }
344}
345
346impl SandboxDriver for MacVm {
347 fn name(&self) -> &'static str {
348 "a cloned macOS VM per job"
349 }
350 fn preflight(&self) -> Result<Isolation> {
351 let why = MacVm::availability();
352 Err(anyhow!("{} {}", why.cause.unwrap_or_default(), why.fix.unwrap_or_default()))
353 }
354}
355
356#[cfg(test)]
357mod tests {
358 use super::*;
359
360 #[test]
361 fn a_job_sandbox_reaches_nothing_of_the_machines_but_its_read_only_folder() {
362 let xml = wsb_config(Path::new(r"C:\Users\a & b\.g1t-runner\native\job_1"), 4096);
363 for off in ["vGPU", "AudioInput", "VideoInput", "ClipboardRedirection", "PrinterRedirection"] {
364 assert!(xml.contains(&format!("<{off}>Disable</{off}>")), "{off} in {xml}");
365 }
366 assert!(xml.contains("<ProtectedClient>Enable</ProtectedClient>"));
367 assert_eq!(xml.matches("<MappedFolder>").count(), 1);
368 assert!(xml.contains("<ReadOnly>true</ReadOnly>"));
369 assert!(xml.contains(r"<HostFolder>C:\Users\a &amp; b\.g1t-runner\native\job_1</HostFolder>"));
370 assert!(xml.contains("<MemoryInMB>4096</MemoryInMB>"));
371 assert!(!xml.contains("LogonCommand"), "the job runs through wsb exec, not at logon");
372 }
373
374 #[test]
375 fn the_job_script_quotes_every_value() {
376 let mut env = BTreeMap::new();
377 env.insert("ACTIONS_TOKEN".to_owned(), "it's $(evil) `x`".to_owned());
378 env.insert("MODE".to_owned(), "actions".to_owned());
379 env.insert("BAD NAME;rm".to_owned(), "x".to_owned());
380 let script = job_script(&env);
381 assert!(script.contains("${env:ACTIONS_TOKEN} = 'it''s $(evil) `x`'"), "{script}");
382 assert!(script.contains("${env:MODE} = 'actions'"));
383 assert!(!script.contains("BAD NAME"));
384 assert!(script.contains(r"$env:G1T_RUNNER_ROOT = 'C:\work'"));
385 assert!(script.ends_with("& 'C:\\g1t\\g1t-runner.exe'\r\nexit $LASTEXITCODE\r\n"));
386 assert!(script.contains("New-NetFirewallRule"));
387 }
388
389 #[test]
390 fn wsb_output_reads() {
391 assert_eq!(parse_exit_code("{\n \"ExitCode\": 7\n}\n"), Some(7));
392 assert_eq!(parse_exit_code("nothing"), None);
393 assert_eq!(parse_id("{\n \"Id\": \"902c8565-4b36\"\n}"), Some("902c8565-4b36".into()));
394 assert_eq!(parse_list("{\"WindowsSandboxEnvironments\": [{\"Id\": \"a\"}, {\"Id\": \"b\"}]}"), vec!["a", "b"]);
395 assert!(parse_list("{\"WindowsSandboxEnvironments\": []}").is_empty());
396 assert!(exec_command().ends_with(r"-File C:\g1t\job.ps1"));
397 }
398
399 #[test]
400 fn what_is_missing_says_how_to_fix_it() {
401 if cfg!(windows) {
402 let off = WsbFound { feature: false, cli: false }.availability();
403 assert!(!off.available);
404 assert!(off.cause.unwrap().contains("not turned on"));
405 assert!(off.fix.unwrap().contains("Pro, Enterprise or Education"));
406 assert!(WsbFound { feature: true, cli: false }.availability().cause.unwrap().contains("24H2"));
407 assert!(WsbFound { feature: true, cli: true }.availability().available);
408 } else {
409 assert!(!WsbFound { feature: true, cli: true }.availability().available);
410 }
411 }
412
413 #[test]
414 fn a_macos_vm_shares_nothing_and_is_not_offered_until_run() {
415 let run = MacVm::run_args("g1t-job_1");
416 assert!(run.iter().any(|a| a == "--no-graphics") && run.iter().any(|a| a == "--no-clipboard"));
417 assert!(!run.iter().any(|a| a.starts_with("--dir")), "no folder of the Mac's");
418 assert_eq!(MacVm::clone_args("g1t.sh/flagon-io/macos@sha256:ab", "g1t-job_1"), vec!["clone", "g1t.sh/flagon-io/macos@sha256:ab", "g1t-job_1"]);
419 assert_eq!(MacVm::delete_args("x"), vec!["delete", "x"]);
420 assert_eq!(MacVm::exec_args("x", "echo hi")[0], "exec");
421 assert!(MacVm.preflight().is_err());
422 assert_eq!(MacVm::MAX_VMS, 2);
423 }
424
425 /// Starts a real Windows Sandbox: run with `cargo test -p g1t-runner -- --ignored windows_sandbox`
426 /// on a machine with the feature on.
427 #[test]
428 #[ignore]
429 fn windows_sandbox_runs_a_script_and_keeps_its_folder_read_only() {
430 let drop = std::env::temp_dir().join(format!("g1t-wsb-test-{}", std::process::id()));
431 let _ = std::fs::remove_dir_all(&drop);
432 std::fs::create_dir_all(&drop).unwrap();
433 std::fs::write(
434 drop.join("job.ps1"),
435 "try { 'x' | Out-File C:\\g1t\\written.txt -ErrorAction Stop; exit 3 } catch { }\r\nif (Test-Path 'C:\\Users\\Public\\g1t-from-host') { exit 4 }\r\nexit 7\r\n",
436 )
437 .unwrap();
438 let output = wsb().args(["start", "--raw", "-c", &wsb_config(&drop, 2048)]).output().unwrap();
439 let id = parse_id(&String::from_utf8_lossy(&output.stdout)).expect("a sandbox id");
440 let job = WsbJob { id, drop: drop.clone() };
441 let out = job.exec().output().unwrap();
442 let code = parse_exit_code(&String::from_utf8_lossy(&out.stdout));
443 job.clean();
444 assert_eq!(code, Some(7), "the script ran, and could not write to its folder");
445 assert!(!drop.exists());
446 }
447}