| 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 | } |