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.
| Merge the runner sandbox: g1t-runner sandbox up makes a person's own computer a self-hosted runner that nothing a job runs can get out of. Linux jobs run in a Linux VM of the runner's own (a WSL 2 distribution with no host mounts, interop or Docker socket on Windows; vfkit on macOS; Sysbox, or QEMU and KVM, on Linux), each in a fresh container under Sysbox inside it; on Windows 11 Pro and up, Windows jobs each run in a Windows Sandbox discarded after the job. Runners report their isolation, and the Runners page shows it. | 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 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.