Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Self-hosted runners on a person's own computer are sandboxes that nothing a job runs can get out of. `g1t-runner sandbox up|down|status|update [--json]` gives a Windows, macOS or Linux computer up to two capabilities, each a runner of its own: Linux jobs in a Linux VM the runner owns, where the runner's image runs under Sysbox and each job is a fresh container inside it (nested-container, three walls from the computer); and jobs native to Windows, each in a Windows Sandbox of its own, discarded after, with one read-only folder holding the harness (a new `vm` isolation level, also `g1t-runner register --native`). On Windows the VM is a WSL 2 distribution imported from the release's signed root filesystem: its wsl.conf turns off drives, Windows programs and Windows' PATH, and the supervisor inside unmounts what WSL shares with every distribution anyway (/mnt/wsl, where Docker Desktop keeps its sockets to Windows, WSLg, the GPU driver folders), refuses to run while anything of the computer's is reachable, and drops containers' traffic to the computer's address but for DNS. It needs no administrator once WSL is installed, and works on Windows 11 Home. On macOS the VM runs under vfkit and on Linux without Sysbox under QEMU with KVM, with no shared folders and nothing forwarded in (built and tested as commands, not yet run on those machines); on Linux with Sysbox no VM is needed. `status --json` reports the state, versions, isolation, the current job and a sentence when something is wrong, and each capability's availability with its cause and fix, for the desktop app. `update` drains the runner, swaps in the newest signed image and starts again without registering again. The sandbox's images are built from deploy/sandbox/Dockerfile by scripts/sandbox-image.mjs and listed with their SHA-256 in the release's signed manifest, beside the runner image's digest. The release workflow now builds its images on g1t's own machines (g1t-4core) rather than waiting forever for a self-hosted runner, registering QEMU for arm64 where the machine allows it and building amd64 alone with a warning where it does not, and signs and publishes once the images are built. The Runners page shows each runner's isolation (Sandboxed, VM per job, Shared Docker, Unsandboxed), which the API now returns as `isolation`. The Self-hosted runners guide has a new "Sandbox on your computer" section, with what it cannot stop. | 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 | ||
| 30 | use std::collections::BTreeMap; | |
| 31 | use std::path::{Path, PathBuf}; | |
| 32 | use std::process::{Command, Stdio}; | |
| 33 | ||
| 34 | use anyhow::{Context, Result, anyhow, bail}; | |
| 35 | use serde::Serialize; | |
| 36 | ||
| 37 | use 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)] | |
| 43 | pub 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 | ||
| 51 | impl 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. | |
| 63 | pub const DROP_IN_SANDBOX: &str = r"C:\g1t"; | |
| 64 | /// Where a workflow job keeps its files inside the sandbox. | |
| 65 | pub const WORK_IN_SANDBOX: &str = r"C:\work"; | |
| 66 | ||
| 67 | /// Windows Sandbox, one per job. | |
| 68 | pub struct WindowsSandbox; | |
| 69 | ||
| 70 | /// What a machine has: the feature's program, and its command line. | |
| 71 | #[derive(Clone, Copy, Debug, Default)] | |
| 72 | pub 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 | ||
| 79 | impl 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 | ||
| 107 | impl 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 | ||
| 120 | fn 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. | |
| 127 | pub 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 | ||
| 138 | fn xml_escape(text: &str) -> String { | |
| 139 | text.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """).replace('\'', "'") | |
| 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. | |
| 145 | pub 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. | |
| 164 | pub 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. | |
| 177 | pub 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\ | |
| 181 | if ($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. | |
| 198 | pub 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`. | |
| 203 | pub 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`. | |
| 210 | pub 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. | |
| 217 | pub struct WsbJob { | |
| 218 | pub id: String, | |
| 219 | /// The job's read-only folder on the machine, removed after. | |
| 220 | pub drop: PathBuf, | |
| 221 | } | |
| 222 | ||
| 223 | impl 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. | |
| 270 | pub 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. | |
| 275 | pub fn sweep(folder: &Path) { | |
| 276 | let note = noted(folder); | |
| 277 | if let Ok(id) = std::fs::read_to_string(¬e) { | |
| 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(¬e); | |
| 283 | } | |
| 284 | } | |
| 285 | ||
| 286 | /// The ids in `wsb list --raw`. | |
| 287 | pub 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. | |
| 311 | pub struct MacVm; | |
| 312 | ||
| 313 | // The commands are built and tested ahead of the driver that runs them. | |
| 314 | #[allow(dead_code)] | |
| 315 | impl 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 | ||
| 346 | impl 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)] | |
| 357 | mod 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 & 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 | } |