Skip to main content

runner_manager_agent/lifecycle/
windows_hyperv.rs

1//! Hyper-V-isolated Windows container execution.
2//!
3//! Docker is used as the Windows container control plane, but no Docker socket,
4//! host directory, device, or credential is exposed to the container.  The
5//! verified runner package is copied into a fresh writable layer and the JIT
6//! value crosses the boundary once over the attached container stdin.
7
8use std::collections::BTreeMap;
9use std::ffi::{OsStr, OsString};
10use std::io::{Read as _, Write as _};
11use std::process::{Child, ChildStdin, Command, Output, Stdio};
12use std::sync::{Mutex, mpsc};
13use std::time::{Duration, Instant};
14
15use runner_manager_domain::attempt::{FailureReason, RunnerAttempt};
16use runner_manager_domain::execution::{
17    AttemptExecution, Backend, ExecutionPolicy, ImageReference, ResourceLimits,
18};
19use runner_manager_domain::model::{AttemptId, HostId};
20use runner_manager_domain::policy::ScalePolicy;
21
22use super::{
23    EnvironmentIdentity, EnvironmentState, ExecutionProvider, OneTimeJitHandoff,
24    PreparedEnvironment, ProcessStartFailure, ProviderCapability, ProviderDestroy,
25    ProviderDiagnostic, ProviderStop, ResolvedEnvironment, TERMINATE_INTENT_FILE,
26    write_durable_file,
27};
28
29const LABEL_HOST: &str = "runner-manager.host";
30const LABEL_ATTEMPT: &str = "runner-manager.attempt";
31const LABEL_GENERATION: &str = "runner-manager.generation";
32const LABEL_IMAGE: &str = "runner-manager.image";
33const LABEL_PROVIDER: &str = "runner-manager.provider";
34const PROVIDER_VALUE: &str = "windows-hyper-v-container";
35const MAX_CAPTURE: usize = 64 * 1024;
36const PREFLIGHT_INPUT: &str = "runner-manager-preflight-v1";
37#[cfg(all(test, windows))]
38const BOOTSTRAP_SELF_TEST_INPUT: &str = "runner-manager-windows-job-self-test-v1";
39const BOOTSTRAP_REQUEST_PROTOCOL: &str = "runner-manager-windows-job-request-v1";
40const BOOTSTRAP_ATTESTATION_PROTOCOL: &str = "runner-manager-windows-job-ready-v1";
41const WINDOWS_PROCESS_LIMIT: u32 = 256;
42const JOB_OBJECT_LIMIT_ACTIVE_PROCESS: u32 = 0x0000_0008;
43const JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE: u32 = 0x0000_2000;
44const JOB_LIMIT_FLAGS: u32 = JOB_OBJECT_LIMIT_ACTIVE_PROCESS | JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
45const BOOTSTRAP_ATTESTATION_TIMEOUT: Duration = Duration::from_secs(60);
46const PREFLIGHT_EXIT_TIMEOUT: Duration = Duration::from_secs(60);
47
48// This script is immutable Docker container metadata. It creates a nested Job
49// Object inside the Windows container, installs and queries the active-process
50// limit, and only then acknowledges the host. Runner.Listener is created
51// suspended, assigned to the job, checked for membership, and resumed. Neither
52// breakaway flag is set, so ordinary descendants inherit the nested job.
53const BOOTSTRAP: &str = r#"
54$ErrorActionPreference='Stop'
55$source=@'
56using System;
57using System.ComponentModel;
58using System.Runtime.InteropServices;
59using System.Text;
60
61public sealed class RunnerManagerJobGuard : IDisposable {
62    const uint JOB_OBJECT_LIMIT_ACTIVE_PROCESS = 0x00000008;
63    const uint JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000;
64    const uint CREATE_SUSPENDED = 0x00000004;
65    const uint INFINITE = 0xffffffff;
66    const int JobObjectExtendedLimitInformation = 9;
67    IntPtr job;
68
69    [StructLayout(LayoutKind.Sequential)]
70    struct BasicLimitInformation {
71        public long PerProcessUserTimeLimit;
72        public long PerJobUserTimeLimit;
73        public uint LimitFlags;
74        public UIntPtr MinimumWorkingSetSize;
75        public UIntPtr MaximumWorkingSetSize;
76        public uint ActiveProcessLimit;
77        public UIntPtr Affinity;
78        public uint PriorityClass;
79        public uint SchedulingClass;
80    }
81    [StructLayout(LayoutKind.Sequential)]
82    struct IoCounters {
83        public ulong ReadOperationCount;
84        public ulong WriteOperationCount;
85        public ulong OtherOperationCount;
86        public ulong ReadTransferCount;
87        public ulong WriteTransferCount;
88        public ulong OtherTransferCount;
89    }
90    [StructLayout(LayoutKind.Sequential)]
91    struct ExtendedLimitInformation {
92        public BasicLimitInformation BasicLimitInformation;
93        public IoCounters IoInfo;
94        public UIntPtr ProcessMemoryLimit;
95        public UIntPtr JobMemoryLimit;
96        public UIntPtr PeakProcessMemoryUsed;
97        public UIntPtr PeakJobMemoryUsed;
98    }
99    [StructLayout(LayoutKind.Sequential, CharSet=CharSet.Unicode)]
100    struct StartupInfo {
101        public uint cb;
102        public string lpReserved;
103        public string lpDesktop;
104        public string lpTitle;
105        public uint dwX;
106        public uint dwY;
107        public uint dwXSize;
108        public uint dwYSize;
109        public uint dwXCountChars;
110        public uint dwYCountChars;
111        public uint dwFillAttribute;
112        public uint dwFlags;
113        public ushort wShowWindow;
114        public ushort cbReserved2;
115        public IntPtr lpReserved2;
116        public IntPtr hStdInput;
117        public IntPtr hStdOutput;
118        public IntPtr hStdError;
119    }
120    [StructLayout(LayoutKind.Sequential)]
121    struct ProcessInformation {
122        public IntPtr hProcess;
123        public IntPtr hThread;
124        public uint dwProcessId;
125        public uint dwThreadId;
126    }
127
128    [DllImport("kernel32.dll", SetLastError=true, CharSet=CharSet.Unicode)]
129    static extern IntPtr CreateJobObject(IntPtr attributes, string name);
130    [DllImport("kernel32.dll", SetLastError=true)]
131    static extern bool SetInformationJobObject(IntPtr job, int infoClass, IntPtr info, uint length);
132    [DllImport("kernel32.dll", SetLastError=true)]
133    static extern bool QueryInformationJobObject(IntPtr job, int infoClass, IntPtr info, uint length, IntPtr returnedLength);
134    [DllImport("kernel32.dll", SetLastError=true, CharSet=CharSet.Unicode)]
135    static extern bool CreateProcess(string applicationName, StringBuilder commandLine, IntPtr processAttributes, IntPtr threadAttributes, bool inheritHandles, uint creationFlags, IntPtr environment, string currentDirectory, ref StartupInfo startupInfo, out ProcessInformation processInformation);
136    [DllImport("kernel32.dll", SetLastError=true)]
137    static extern bool AssignProcessToJobObject(IntPtr job, IntPtr process);
138    [DllImport("kernel32.dll", SetLastError=true)]
139    static extern bool IsProcessInJob(IntPtr process, IntPtr job, out bool result);
140    [DllImport("kernel32.dll", SetLastError=true)]
141    static extern uint ResumeThread(IntPtr thread);
142    [DllImport("kernel32.dll", SetLastError=true)]
143    static extern uint WaitForSingleObject(IntPtr handle, uint milliseconds);
144    [DllImport("kernel32.dll", SetLastError=true)]
145    static extern bool GetExitCodeProcess(IntPtr process, out uint exitCode);
146    [DllImport("kernel32.dll", SetLastError=true)]
147    static extern bool TerminateProcess(IntPtr process, uint exitCode);
148    [DllImport("kernel32.dll", SetLastError=true)]
149    static extern bool CloseHandle(IntPtr handle);
150
151    static void Check(bool ok) {
152        if (!ok) throw new Win32Exception(Marshal.GetLastWin32Error());
153    }
154
155    public RunnerManagerJobGuard(uint activeProcessLimit) {
156        job = CreateJobObject(IntPtr.Zero, null);
157        if (job == IntPtr.Zero) throw new Win32Exception(Marshal.GetLastWin32Error());
158        ExtendedLimitInformation limits = new ExtendedLimitInformation();
159        limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_ACTIVE_PROCESS | JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
160        limits.BasicLimitInformation.ActiveProcessLimit = activeProcessLimit;
161        int size = Marshal.SizeOf(typeof(ExtendedLimitInformation));
162        IntPtr buffer = Marshal.AllocHGlobal(size);
163        try {
164            Marshal.StructureToPtr(limits, buffer, false);
165            Check(SetInformationJobObject(job, JobObjectExtendedLimitInformation, buffer, (uint)size));
166        } finally {
167            Marshal.FreeHGlobal(buffer);
168        }
169    }
170
171    public string Attest() {
172        int size = Marshal.SizeOf(typeof(ExtendedLimitInformation));
173        IntPtr buffer = Marshal.AllocHGlobal(size);
174        try {
175            Check(QueryInformationJobObject(job, JobObjectExtendedLimitInformation, buffer, (uint)size, IntPtr.Zero));
176            ExtendedLimitInformation limits = (ExtendedLimitInformation)Marshal.PtrToStructure(buffer, typeof(ExtendedLimitInformation));
177            uint required = JOB_OBJECT_LIMIT_ACTIVE_PROCESS | JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
178            if (limits.BasicLimitInformation.LimitFlags != required) throw new InvalidOperationException("unexpected job limit flags");
179            return limits.BasicLimitInformation.ActiveProcessLimit.ToString() + "|" + limits.BasicLimitInformation.LimitFlags.ToString();
180        } finally {
181            Marshal.FreeHGlobal(buffer);
182        }
183    }
184
185    public int Run(string executable, string arguments, char[] jit) {
186        StartupInfo startup = new StartupInfo();
187        startup.cb = (uint)Marshal.SizeOf(typeof(StartupInfo));
188        ProcessInformation process;
189        StringBuilder command = new StringBuilder("\"" + executable + "\" " + arguments);
190        string priorJit = Environment.GetEnvironmentVariable("ACTIONS_RUNNER_INPUT_JITCONFIG", EnvironmentVariableTarget.Process);
191        string jitValue = jit == null ? null : new string(jit);
192        bool created;
193        try {
194            if (jitValue != null) Environment.SetEnvironmentVariable("ACTIONS_RUNNER_INPUT_JITCONFIG", jitValue, EnvironmentVariableTarget.Process);
195            created = CreateProcess(null, command, IntPtr.Zero, IntPtr.Zero, true, CREATE_SUSPENDED, IntPtr.Zero, null, ref startup, out process);
196        } finally {
197            if (jitValue != null) Environment.SetEnvironmentVariable("ACTIONS_RUNNER_INPUT_JITCONFIG", priorJit, EnvironmentVariableTarget.Process);
198            if (jit != null) Array.Clear(jit, 0, jit.Length);
199            jitValue = null;
200        }
201        Check(created);
202        bool assigned = false;
203        try {
204            Check(AssignProcessToJobObject(job, process.hProcess));
205            bool inJob;
206            Check(IsProcessInJob(process.hProcess, job, out inJob));
207            if (!inJob) throw new InvalidOperationException("runner did not enter process-limit job");
208            assigned = true;
209            if (ResumeThread(process.hThread) == 0xffffffff) throw new Win32Exception(Marshal.GetLastWin32Error());
210            if (WaitForSingleObject(process.hProcess, INFINITE) != 0) throw new Win32Exception(Marshal.GetLastWin32Error());
211            uint exitCode;
212            Check(GetExitCodeProcess(process.hProcess, out exitCode));
213            return unchecked((int)exitCode);
214        } finally {
215            if (!assigned) TerminateProcess(process.hProcess, 73);
216            CloseHandle(process.hThread);
217            CloseHandle(process.hProcess);
218        }
219    }
220
221    public void Dispose() {
222        if (job != IntPtr.Zero) {
223            CloseHandle(job);
224            job = IntPtr.Zero;
225        }
226    }
227}
228'@
229Add-Type -TypeDefinition $source -Language CSharp
230$request=[Console]::In.ReadLine()
231$parts=$request.Split('|')
232if ($parts.Length -ne 3 -or $parts[0] -ne 'runner-manager-windows-job-request-v1' -or $parts[1] -notmatch '^[0-9a-f]{32}$') { exit 70 }
233$limit=0
234if (-not [UInt32]::TryParse($parts[2],[ref]$limit) -or $limit -lt 1 -or $limit -gt 4096) { exit 70 }
235$guard=[RunnerManagerJobGuard]::new($limit)
236try {
237    $proof=$guard.Attest()
238    [Console]::Out.WriteLine('runner-manager-windows-job-ready-v1|'+$parts[1]+'|'+$proof)
239    [Console]::Out.Flush()
240    $payload=[Console]::In.ReadToEnd()
241    if ([String]::IsNullOrWhiteSpace($payload)) { exit 70 }
242    if ($payload -eq 'runner-manager-windows-job-self-test-v1') { exit $guard.Run(($env:WINDIR+'\\System32\\cmd.exe'),'/d /c exit 0',$null) }
243    if ($payload -eq 'runner-manager-preflight-v1') { exit $guard.Run('C:\\runner\\bin\\Runner.Listener.exe','--version',$null) }
244    $jit=$payload.ToCharArray()
245    $payload=$null
246    try { $code=$guard.Run('C:\\runner\\bin\\Runner.Listener.exe','run',$jit) } finally { [Array]::Clear($jit,0,$jit.Length); $jit=$null }
247    exit $code
248} finally {
249    $guard.Dispose()
250}
251"#;
252
253/// Host-only preflight detail. It is a closed vocabulary and never carries
254/// provider output, so CLI/TUI surfaces cannot accidentally expose it.
255#[derive(Debug, Clone, Copy, PartialEq, Eq)]
256pub enum WindowsHyperVHostState {
257    Ready,
258    UnsupportedHost,
259    HyperVUnavailable,
260    ContainersUnavailable,
261    RuntimeNotInstalled,
262    RuntimePermissionDenied,
263    RuntimeInLinuxMode,
264    RuntimeDegraded,
265}
266
267impl WindowsHyperVHostState {
268    #[must_use]
269    pub const fn capability(self) -> ProviderCapability {
270        match self {
271            Self::Ready => ProviderCapability::Ready,
272            Self::UnsupportedHost | Self::RuntimeInLinuxMode => ProviderCapability::Unsupported,
273            Self::HyperVUnavailable | Self::ContainersUnavailable | Self::RuntimeNotInstalled => {
274                ProviderCapability::NotInstalled
275            }
276            Self::RuntimePermissionDenied => ProviderCapability::PermissionDenied,
277            Self::RuntimeDegraded => ProviderCapability::Degraded,
278        }
279    }
280
281    #[must_use]
282    pub const fn remedy(self) -> Option<&'static str> {
283        match self {
284            Self::Ready => None,
285            Self::UnsupportedHost => Some(
286                "use Windows 11 Pro or Enterprise with Docker Desktop in Windows-container mode, or Windows Server Standard or Datacenter with Moby or Mirantis Container Runtime",
287            ),
288            Self::HyperVUnavailable => Some("enable Hyper-V and restart Windows"),
289            Self::ContainersUnavailable => {
290                Some("enable the Windows Containers feature and restart Windows")
291            }
292            Self::RuntimeNotInstalled => {
293                Some("install a Windows container runtime with Docker-compatible CLI")
294            }
295            Self::RuntimePermissionDenied => {
296                Some("grant the runner-manager service account access to the container runtime")
297            }
298            Self::RuntimeInLinuxMode => Some("switch the container runtime to Windows containers"),
299            Self::RuntimeDegraded => Some("repair or start the Windows container runtime"),
300        }
301    }
302}
303
304#[derive(Debug)]
305pub struct WindowsHyperVContainers {
306    host_id: HostId,
307    children: Mutex<BTreeMap<AttemptId, Child>>,
308    limits: Mutex<BTreeMap<AttemptId, ResourceLimits>>,
309    diagnostics: Mutex<BTreeMap<AttemptId, Vec<ProviderDiagnostic>>>,
310}
311
312impl WindowsHyperVContainers {
313    #[must_use]
314    pub fn new(host_id: HostId) -> Self {
315        Self {
316            host_id,
317            children: Mutex::new(BTreeMap::new()),
318            limits: Mutex::new(BTreeMap::new()),
319            diagnostics: Mutex::new(BTreeMap::new()),
320        }
321    }
322
323    #[must_use]
324    pub fn host_state() -> WindowsHyperVHostState {
325        #[cfg(test)]
326        {
327            WindowsHyperVHostState::UnsupportedHost
328        }
329        #[cfg(not(test))]
330        {
331            host_state_with(&SystemCommands)
332        }
333    }
334
335    fn note(&self, attempt: AttemptId, diagnostic: ProviderDiagnostic) {
336        let mut all = self
337            .diagnostics
338            .lock()
339            .unwrap_or_else(std::sync::PoisonError::into_inner);
340        let entries = all.entry(attempt).or_default();
341        if entries.len() < 32 && !entries.contains(&diagnostic) {
342            entries.push(diagnostic);
343        }
344    }
345
346    fn docker(args: &[OsString]) -> Result<CommandResult, CommandFailure> {
347        SystemCommands.run("docker", args)
348    }
349
350    fn inspect_record(name: &str) -> Result<Option<ResourceRecord>, CommandFailure> {
351        let format = format!(
352            "{{{{.Name}}}}|{{{{.State.Status}}}}|{{{{.State.ExitCode}}}}|{{{{index .Config.Labels \"{LABEL_HOST}\"}}}}|\
353             {{{{index .Config.Labels \"{LABEL_ATTEMPT}\"}}}}|\
354             {{{{index .Config.Labels \"{LABEL_GENERATION}\"}}}}|\
355             {{{{index .Config.Labels \"{LABEL_IMAGE}\"}}}}|\
356             {{{{index .Config.Labels \"{LABEL_PROVIDER}\"}}}}"
357        );
358        let result = Self::docker(&container_inspect_args(name, &format))?;
359        if !result.success {
360            return if result.is_missing_resource() {
361                Ok(None)
362            } else {
363                Err(result.failure())
364            };
365        }
366        Ok(ResourceRecord::parse(result.stdout.trim()))
367    }
368
369    fn inspect_configuration(name: &str) -> Result<ContainerAttestation, CommandFailure> {
370        let result = Self::docker(&container_configuration_inspect_args(name))?;
371        if !result.success {
372            return Err(result.failure());
373        }
374        ContainerAttestation::parse(result.stdout.trim()).ok_or(CommandFailure::Failed)
375    }
376
377    fn configuration_is_exact(
378        name: &str,
379        image: &ImageReference,
380        resources: ResourceLimits,
381    ) -> bool {
382        Self::inspect_configuration(name)
383            .is_ok_and(|found| found == ContainerAttestation::expected(image, resources))
384    }
385
386    fn start_attested(name: &str) -> Result<(Child, ChildStdin), ()> {
387        let mut child = Command::new("docker")
388            .args([
389                OsStr::new("start"),
390                OsStr::new("--attach"),
391                OsStr::new("--interactive"),
392                OsStr::new(name),
393            ])
394            .stdin(Stdio::piped())
395            .stdout(Stdio::piped())
396            .stderr(Stdio::null())
397            .spawn()
398            .map_err(|_| ())?;
399        let Some(mut stdin) = child.stdin.take() else {
400            stop_failed_bootstrap(&mut child, name);
401            return Err(());
402        };
403        let Some(stdout) = child.stdout.take() else {
404            stop_failed_bootstrap(&mut child, name);
405            return Err(());
406        };
407        let nonce = uuid::Uuid::new_v4().simple().to_string();
408        let request = format!("{BOOTSTRAP_REQUEST_PROTOCOL}|{nonce}|{WINDOWS_PROCESS_LIMIT}\n");
409        if stdin
410            .write_all(request.as_bytes())
411            .and_then(|()| stdin.flush())
412            .is_err()
413        {
414            stop_failed_bootstrap(&mut child, name);
415            return Err(());
416        }
417
418        let (sender, receiver) = mpsc::sync_channel(1);
419        std::thread::spawn(move || {
420            let mut stdout = stdout;
421            let mut bytes = Vec::with_capacity(256);
422            let result = loop {
423                if bytes.len() == 1024 {
424                    break Err(std::io::Error::new(
425                        std::io::ErrorKind::InvalidData,
426                        "bootstrap attestation exceeded limit",
427                    ));
428                }
429                let mut byte = [0_u8; 1];
430                match stdout.read(&mut byte) {
431                    Ok(0) => {
432                        break Err(std::io::Error::new(
433                            std::io::ErrorKind::UnexpectedEof,
434                            "bootstrap closed before attestation",
435                        ));
436                    }
437                    Ok(_) if byte[0] == b'\n' => {
438                        break String::from_utf8(bytes).map_err(|_| {
439                            std::io::Error::new(
440                                std::io::ErrorKind::InvalidData,
441                                "bootstrap attestation was not UTF-8",
442                            )
443                        });
444                    }
445                    Ok(_) => bytes.push(byte[0]),
446                    Err(error) => break Err(error),
447                }
448            };
449            let _ = sender.send((result, stdout));
450        });
451
452        let received = receiver.recv_timeout(BOOTSTRAP_ATTESTATION_TIMEOUT);
453        let Ok((Ok(line), mut stdout)) = received else {
454            stop_failed_bootstrap(&mut child, name);
455            return Err(());
456        };
457        let Some(attestation) = JobAttestation::parse(line.trim_end_matches('\r')) else {
458            stop_failed_bootstrap(&mut child, name);
459            return Err(());
460        };
461        if attestation
462            != (JobAttestation {
463                nonce,
464                active_process_limit: WINDOWS_PROCESS_LIMIT,
465                limit_flags: JOB_LIMIT_FLAGS,
466            })
467        {
468            stop_failed_bootstrap(&mut child, name);
469            return Err(());
470        }
471
472        // Runner.Listener inherits the attached output handles. Drain them so
473        // a verbose action cannot fill the pipe and stall the process tree.
474        std::thread::spawn(move || {
475            let _ = std::io::copy(&mut stdout, &mut std::io::sink());
476        });
477        Ok((child, stdin))
478    }
479
480    fn identity_for(
481        &self,
482        attempt: &RunnerAttempt,
483        environment_id: String,
484    ) -> Option<EnvironmentIdentity> {
485        let AttemptExecution::Isolated {
486            provider_kind,
487            resolved_image,
488            generation,
489            ..
490        } = attempt.execution()
491        else {
492            return None;
493        };
494        Some(EnvironmentIdentity::Isolated {
495            host: self.host_id,
496            attempt: attempt.id,
497            provider_kind: *provider_kind,
498            environment_id,
499            resolved_image: resolved_image.clone(),
500            generation: generation.clone(),
501        })
502    }
503
504    fn expected_record(&self, attempt: &RunnerAttempt) -> Option<ResourceRecord> {
505        let AttemptExecution::Isolated {
506            provider_kind: Backend::WindowsHyperVContainer,
507            resolved_image,
508            generation,
509            ..
510        } = attempt.execution()
511        else {
512            return None;
513        };
514        Some(ResourceRecord {
515            name: container_name(self.host_id, attempt.id, generation),
516            state: String::new(),
517            exit_code: None,
518            host: self.host_id,
519            attempt: attempt.id,
520            generation: generation.clone(),
521            image: resolved_image.clone(),
522        })
523    }
524
525    fn intent_path(attempt: &RunnerAttempt) -> std::path::PathBuf {
526        attempt.runtime_path().join(TERMINATE_INTENT_FILE)
527    }
528}
529
530impl ExecutionProvider for WindowsHyperVContainers {
531    fn probe(&self, policy: &ScalePolicy) -> ProviderCapability {
532        let ExecutionPolicy::Isolated { backend, image, .. } = policy.execution_policy() else {
533            return ProviderCapability::Unsupported;
534        };
535        if !matches!(backend, Backend::Auto | Backend::WindowsHyperVContainer) {
536            return ProviderCapability::Unsupported;
537        }
538        let host = Self::host_state().capability();
539        if host != ProviderCapability::Ready {
540            return host;
541        }
542        match inspect_image(image) {
543            Ok(true) => ProviderCapability::Ready,
544            Ok(false) => ProviderCapability::ImageUnavailableOrIncompatible,
545            Err(CommandFailure::PermissionDenied) => ProviderCapability::PermissionDenied,
546            Err(_) => ProviderCapability::ImageUnavailableOrIncompatible,
547        }
548    }
549
550    fn resolve(&self, policy: &ScalePolicy) -> Result<Option<ResolvedEnvironment>, FailureReason> {
551        let ExecutionPolicy::Isolated { backend, image, .. } = policy.execution_policy() else {
552            return Err(failure("Windows Hyper-V provider received a native policy"));
553        };
554        if !matches!(backend, Backend::Auto | Backend::WindowsHyperVContainer) {
555            return Err(failure("Windows Hyper-V provider was not selected"));
556        }
557        if self.probe(policy) != ProviderCapability::Ready {
558            return Err(failure("Windows Hyper-V provider preflight failed"));
559        }
560        Ok(Some(ResolvedEnvironment {
561            provider_kind: Backend::WindowsHyperVContainer,
562            image: image.clone(),
563        }))
564    }
565
566    fn prepare(
567        &self,
568        attempt: &RunnerAttempt,
569        policy: &ScalePolicy,
570    ) -> Result<PreparedEnvironment, FailureReason> {
571        let Some(expected) = self.expected_record(attempt) else {
572            self.note(attempt.id, ProviderDiagnostic::PrepareFailed);
573            return Err(failure("Windows Hyper-V allocation is invalid"));
574        };
575        let ExecutionPolicy::Isolated { resources, .. } = policy.execution_policy() else {
576            return Err(failure("Windows Hyper-V policy is invalid"));
577        };
578
579        let exists = match Self::inspect_record(&expected.name) {
580            Ok(Some(found))
581                if found.same_owner(&expected)
582                    && matches!(found.state.as_str(), "created" | "exited") =>
583            {
584                true
585            }
586            Ok(Some(found)) if found.same_owner(&expected) => {
587                self.note(attempt.id, ProviderDiagnostic::PrepareFailed);
588                return Err(failure(
589                    "Windows Hyper-V container state is incompatible with preparation",
590                ));
591            }
592            Ok(Some(_)) => {
593                self.note(attempt.id, ProviderDiagnostic::OwnershipMismatch);
594                return Err(failure("Windows Hyper-V container ownership mismatch"));
595            }
596            Ok(None) => false,
597            Err(_) => {
598                self.note(attempt.id, ProviderDiagnostic::PrepareFailed);
599                return Err(failure("Windows Hyper-V container inspection failed"));
600            }
601        };
602
603        if !exists {
604            let create = Self::docker(&create_args(&expected, *resources))
605                .map_err(|_| failure("Windows Hyper-V container creation failed"))?;
606            if !create.success {
607                self.note(attempt.id, ProviderDiagnostic::PrepareFailed);
608                return Err(failure("Windows Hyper-V container creation failed"));
609            }
610        }
611        if !Self::configuration_is_exact(&expected.name, &expected.image, *resources) {
612            let _ = Self::docker(&os_args(["rm", "--force", expected.name.as_str()]));
613            self.note(attempt.id, ProviderDiagnostic::PrepareFailed);
614            return Err(failure(
615                "Windows Hyper-V resource or isolation attestation failed",
616            ));
617        }
618
619        let source = format!(
620            "{}{}.",
621            attempt.runtime_path().display(),
622            std::path::MAIN_SEPARATOR
623        );
624        let destination = format!("{}:C:\\runner", expected.name);
625        let copied = Self::docker(&os_args(["cp", &source, &destination]));
626        if !matches!(copied, Ok(result) if result.success) {
627            let _ = Self::docker(&os_args(["rm", "--force", expected.name.as_str()]));
628            self.note(attempt.id, ProviderDiagnostic::PrepareFailed);
629            return Err(failure(
630                "runner package copy into Windows Hyper-V container failed",
631            ));
632        }
633
634        // Exercise the exact Hyper-V container, bootstrap, and copied runner
635        // before GitHub issues a JIT registration. An OS/architecture match is
636        // insufficient: an image can still lack PowerShell or the libraries
637        // Runner.Listener needs. The fixed marker is non-secret and the same
638        // stopped container is restarted later with the one-time JIT input.
639        let preflight_ok = match Self::start_attested(&expected.name) {
640            Ok((mut child, mut stdin)) => {
641                let wrote = stdin
642                    .write_all(PREFLIGHT_INPUT.as_bytes())
643                    .and_then(|()| stdin.flush())
644                    .is_ok();
645                // ReadToEnd is the payload boundary.
646                drop(stdin);
647                let ok = wrote && wait_until_exit(&mut child, PREFLIGHT_EXIT_TIMEOUT);
648                if !ok {
649                    stop_failed_bootstrap(&mut child, &expected.name);
650                }
651                ok
652            }
653            Err(()) => false,
654        };
655        let preflight_record = Self::inspect_record(&expected.name).ok().flatten();
656        if !preflight_ok
657            || !preflight_record.is_some_and(|record| {
658                record.same_owner(&expected)
659                    && record.state == "exited"
660                    && record.exit_code == Some(0)
661            })
662        {
663            let _ = Self::docker(&os_args(["rm", "--force", expected.name.as_str()]));
664            self.note(attempt.id, ProviderDiagnostic::PrepareFailed);
665            return Err(failure(
666                "Windows Hyper-V image bootstrap compatibility check failed",
667            ));
668        }
669
670        let identity = self
671            .identity_for(attempt, expected.name)
672            .ok_or_else(|| failure("Windows Hyper-V identity is invalid"))?;
673        self.limits
674            .lock()
675            .unwrap_or_else(std::sync::PoisonError::into_inner)
676            .insert(attempt.id, *resources);
677        Ok(PreparedEnvironment::isolated(attempt.id, identity))
678    }
679
680    fn start(
681        &self,
682        prepared: PreparedEnvironment,
683        attempt: &RunnerAttempt,
684        handoff: OneTimeJitHandoff<'_>,
685    ) -> Result<EnvironmentIdentity, ProcessStartFailure> {
686        let Some(expected) = self.expected_record(attempt) else {
687            return Err(ProcessStartFailure::before_spawn(failure(
688                "Windows Hyper-V allocation is invalid",
689            )));
690        };
691        if prepared.identity() != self.identity_for(attempt, expected.name.clone()).as_ref() {
692            self.note(attempt.id, ProviderDiagnostic::OwnershipMismatch);
693            return Err(ProcessStartFailure::before_spawn(failure(
694                "Windows Hyper-V prepared identity mismatch",
695            )));
696        }
697        if !self.owns(attempt).unwrap_or(false) {
698            self.note(attempt.id, ProviderDiagnostic::OwnershipMismatch);
699            return Err(ProcessStartFailure::before_spawn(failure(
700                "Windows Hyper-V container ownership mismatch",
701            )));
702        }
703        let Some(resources) = self
704            .limits
705            .lock()
706            .unwrap_or_else(std::sync::PoisonError::into_inner)
707            .get(&attempt.id)
708            .copied()
709        else {
710            self.note(attempt.id, ProviderDiagnostic::StartFailed);
711            return Err(ProcessStartFailure::before_spawn(failure(
712                "Windows Hyper-V resource attestation is unavailable",
713            )));
714        };
715        if !Self::configuration_is_exact(&expected.name, &expected.image, resources) {
716            self.note(attempt.id, ProviderDiagnostic::StartFailed);
717            return Err(ProcessStartFailure::before_spawn(failure(
718                "Windows Hyper-V resource or isolation attestation failed",
719            )));
720        }
721        // The script is constant container metadata. The JIT value is never an
722        // argument, Docker environment setting, label, file, or layer; it is
723        // sent only after the nonce-bound Job Object attestation proves the
724        // active-process limit and kill-on-close flag. Runner.Listener is then
725        // created suspended, assigned, membership-checked, and resumed.
726        let (mut child, mut stdin) = Self::start_attested(&expected.name).map_err(|()| {
727            self.note(attempt.id, ProviderDiagnostic::StartFailed);
728            ProcessStartFailure::after_spawn_stopped()
729        })?;
730        let write_result = stdin
731            .write_all(handoff.consume().expose().as_bytes())
732            .and_then(|()| stdin.flush());
733        drop(stdin);
734        if write_result.is_err() {
735            stop_failed_bootstrap(&mut child, &expected.name);
736            self.note(attempt.id, ProviderDiagnostic::StartFailed);
737            return Err(ProcessStartFailure::after_spawn_stopped());
738        }
739        self.children
740            .lock()
741            .unwrap_or_else(std::sync::PoisonError::into_inner)
742            .insert(attempt.id, child);
743        self.identity_for(attempt, expected.name)
744            .ok_or_else(ProcessStartFailure::after_spawn_stopped)
745    }
746
747    fn inspect(&self, attempt: &RunnerAttempt) -> Result<EnvironmentState, FailureReason> {
748        let Some(expected) = self.expected_record(attempt) else {
749            return Err(failure("Windows Hyper-V allocation is invalid"));
750        };
751        let Some(found) = Self::inspect_record(&expected.name)
752            .map_err(|_| failure("Windows Hyper-V container inspection failed"))?
753        else {
754            return Ok(EnvironmentState::Missing);
755        };
756        if !found.same_owner(&expected) {
757            self.note(attempt.id, ProviderDiagnostic::OwnershipMismatch);
758            return Err(failure("Windows Hyper-V container ownership mismatch"));
759        }
760        Ok(match found.state.as_str() {
761            "created" | "restarting" => EnvironmentState::Starting,
762            "running" | "paused" => EnvironmentState::Running,
763            "exited" | "dead" | "removing" => EnvironmentState::Exited,
764            _ => EnvironmentState::Exited,
765        })
766    }
767
768    fn stop(&self, attempt: &RunnerAttempt) -> Result<ProviderStop, FailureReason> {
769        let Some(expected) = self.expected_record(attempt) else {
770            return Err(failure("Windows Hyper-V allocation is invalid"));
771        };
772        if !self.owns(attempt)? {
773            return if Self::inspect_record(&expected.name)
774                .ok()
775                .flatten()
776                .is_none()
777            {
778                Ok(ProviderStop::Stopped)
779            } else {
780                self.note(attempt.id, ProviderDiagnostic::OwnershipMismatch);
781                Err(failure("Windows Hyper-V container ownership mismatch"))
782            };
783        }
784        if let Some(mut child) = self
785            .children
786            .lock()
787            .unwrap_or_else(std::sync::PoisonError::into_inner)
788            .remove(&attempt.id)
789        {
790            let _ = child.kill();
791            let _ = child.wait();
792        }
793        if matches!(Self::inspect_record(&expected.name), Ok(Some(record)) if matches!(record.state.as_str(), "exited" | "dead"))
794        {
795            return Ok(ProviderStop::Stopped);
796        }
797        let stopped = Self::docker(&os_args(["stop", "--time", "10", expected.name.as_str()]))
798            .map_err(|_| failure("Windows Hyper-V container stop failed"))?;
799        Ok(if stopped.success || stopped.is_missing_resource() {
800            ProviderStop::Stopped
801        } else {
802            ProviderStop::StillRunning
803        })
804    }
805
806    fn destroy(&self, attempt: &RunnerAttempt) -> Result<ProviderDestroy, FailureReason> {
807        let Some(expected) = self.expected_record(attempt) else {
808            return Err(failure("Windows Hyper-V allocation is invalid"));
809        };
810        if !self.owns(attempt)? {
811            return if Self::inspect_record(&expected.name)
812                .ok()
813                .flatten()
814                .is_none()
815            {
816                self.limits
817                    .lock()
818                    .unwrap_or_else(std::sync::PoisonError::into_inner)
819                    .remove(&attempt.id);
820                Ok(ProviderDestroy::Destroyed)
821            } else {
822                self.note(attempt.id, ProviderDiagnostic::OwnershipMismatch);
823                Err(failure("Windows Hyper-V container ownership mismatch"))
824            };
825        }
826        let removed = Self::docker(&os_args(["rm", "--force", expected.name.as_str()]))
827            .map_err(|_| failure("Windows Hyper-V container destroy failed"))?;
828        if (removed.success || removed.is_missing_resource())
829            && Self::inspect_record(&expected.name)
830                .ok()
831                .flatten()
832                .is_none()
833        {
834            self.limits
835                .lock()
836                .unwrap_or_else(std::sync::PoisonError::into_inner)
837                .remove(&attempt.id);
838            return Ok(ProviderDestroy::Destroyed);
839        }
840        self.note(attempt.id, ProviderDiagnostic::CleanupDeferred);
841        Ok(ProviderDestroy::Deferred(
842            "container runtime retained resource",
843        ))
844    }
845
846    fn recover(&self, attempt: &RunnerAttempt) -> Result<EnvironmentState, FailureReason> {
847        self.inspect(attempt)
848    }
849
850    fn enumerate_owned(&self, host_id: HostId) -> Vec<EnvironmentIdentity> {
851        let filter = format!("label={LABEL_HOST}={host_id}");
852        let Ok(list) = Self::docker(&os_args([
853            "ps",
854            "--all",
855            "--filter",
856            &filter,
857            "--format",
858            "{{.Names}}",
859        ])) else {
860            return Vec::new();
861        };
862        if !list.success {
863            return Vec::new();
864        }
865        list.stdout
866            .lines()
867            .take(1024)
868            .filter_map(|name| Self::inspect_record(name).ok().flatten())
869            .filter(|record| record.host == host_id)
870            .map(|record| EnvironmentIdentity::Isolated {
871                host: record.host,
872                attempt: record.attempt,
873                provider_kind: Backend::WindowsHyperVContainer,
874                environment_id: record.name,
875                resolved_image: record.image,
876                generation: record.generation,
877            })
878            .collect()
879    }
880
881    fn diagnostics(&self, attempt: &RunnerAttempt) -> Vec<ProviderDiagnostic> {
882        self.diagnostics
883            .lock()
884            .ok()
885            .and_then(|all| all.get(&attempt.id).cloned())
886            .unwrap_or_default()
887    }
888
889    fn owns(&self, attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
890        let Some(expected) = self.expected_record(attempt) else {
891            return Ok(false);
892        };
893        Self::inspect_record(&expected.name)
894            .map(|found| found.is_some_and(|record| record.same_owner(&expected)))
895            .map_err(|_| failure("Windows Hyper-V ownership inspection failed"))
896    }
897
898    fn spawn(
899        &self,
900        _attempt: &RunnerAttempt,
901        _config: &runner_manager_github::jit::EncodedJitConfig,
902    ) -> Result<u32, ProcessStartFailure> {
903        Err(ProcessStartFailure::before_spawn(failure(
904            "Windows Hyper-V provider cannot launch native processes",
905        )))
906    }
907
908    fn is_alive(&self, attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
909        self.inspect(attempt).map(|state| {
910            matches!(
911                state,
912                EnvironmentState::Starting | EnvironmentState::Running
913            )
914        })
915    }
916
917    fn recovered_pid(&self, _attempt: &RunnerAttempt) -> Result<Option<u32>, FailureReason> {
918        Ok(None)
919    }
920
921    fn completed_successfully(&self, attempt: &RunnerAttempt) -> bool {
922        let Some(expected) = self.expected_record(attempt) else {
923            return false;
924        };
925        matches!(Self::inspect_record(&expected.name), Ok(Some(record)) if record.same_owner(&expected) && record.state == "exited" && record.exit_code == Some(0))
926    }
927
928    fn record_terminate_intent(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason> {
929        write_durable_file(&Self::intent_path(attempt), b"registration-timeout\n")
930            .map_err(|_| failure("terminate intent could not be journalled"))
931    }
932
933    fn has_terminate_intent(&self, attempt: &RunnerAttempt) -> bool {
934        Self::intent_path(attempt).is_file()
935    }
936
937    fn terminate(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason> {
938        self.stop(attempt).map(|_| ())
939    }
940}
941
942#[derive(Debug, Clone)]
943struct ResourceRecord {
944    name: String,
945    state: String,
946    exit_code: Option<i64>,
947    host: HostId,
948    attempt: AttemptId,
949    generation: String,
950    image: ImageReference,
951}
952
953impl ResourceRecord {
954    fn parse(line: &str) -> Option<Self> {
955        let mut fields = line.trim_start_matches('/').split('|');
956        let name = fields.next()?.to_owned();
957        let state = fields.next()?.to_owned();
958        let exit_code = fields.next()?.parse().ok();
959        let host = HostId::from_uuid(uuid::Uuid::parse_str(fields.next()?).ok()?);
960        let attempt = AttemptId::from_uuid(uuid::Uuid::parse_str(fields.next()?).ok()?);
961        let generation = fields.next()?.to_owned();
962        let image = ImageReference::new(fields.next()?).ok()?;
963        let provider = fields.next()?;
964        (provider == PROVIDER_VALUE && fields.next().is_none()).then_some(Self {
965            name,
966            state,
967            exit_code,
968            host,
969            attempt,
970            generation,
971            image,
972        })
973    }
974
975    fn same_owner(&self, other: &Self) -> bool {
976        self.name == other.name
977            && self.host == other.host
978            && self.attempt == other.attempt
979            && self.generation == other.generation
980            && self.image == other.image
981    }
982}
983
984#[derive(Debug, Clone, PartialEq, Eq)]
985struct ContainerAttestation {
986    isolation: String,
987    nano_cpus: u64,
988    memory_bytes: u64,
989    storage_size: String,
990    network_mode: String,
991    mounts: u32,
992    privileged: bool,
993    binds: u32,
994    devices: u32,
995    image: ImageReference,
996}
997
998impl ContainerAttestation {
999    fn expected(image: &ImageReference, resources: ResourceLimits) -> Self {
1000        Self {
1001            isolation: "hyperv".into(),
1002            nano_cpus: u64::from(resources.cpu_millis) * 1_000_000,
1003            memory_bytes: u64::from(resources.memory_mib) * 1024 * 1024,
1004            storage_size: format!("{}m", resources.disk_mib),
1005            network_mode: "nat".into(),
1006            mounts: 0,
1007            privileged: false,
1008            binds: 0,
1009            devices: 0,
1010            image: image.clone(),
1011        }
1012    }
1013
1014    fn parse(line: &str) -> Option<Self> {
1015        let mut fields = line.split('|');
1016        let parsed = Self {
1017            isolation: fields.next()?.to_owned(),
1018            nano_cpus: fields.next()?.parse().ok()?,
1019            memory_bytes: fields.next()?.parse().ok()?,
1020            storage_size: fields.next()?.to_owned(),
1021            network_mode: fields.next()?.to_owned(),
1022            mounts: fields.next()?.parse().ok()?,
1023            privileged: fields.next()?.parse().ok()?,
1024            binds: fields.next()?.parse().ok()?,
1025            devices: fields.next()?.parse().ok()?,
1026            image: ImageReference::new(fields.next()?).ok()?,
1027        };
1028        fields.next().is_none().then_some(parsed)
1029    }
1030}
1031
1032#[derive(Debug, Clone, PartialEq, Eq)]
1033struct JobAttestation {
1034    nonce: String,
1035    active_process_limit: u32,
1036    limit_flags: u32,
1037}
1038
1039impl JobAttestation {
1040    fn parse(line: &str) -> Option<Self> {
1041        let mut fields = line.split('|');
1042        if fields.next()? != BOOTSTRAP_ATTESTATION_PROTOCOL {
1043            return None;
1044        }
1045        let nonce = fields.next()?;
1046        if nonce.len() != 32 || !nonce.bytes().all(|byte| byte.is_ascii_hexdigit()) {
1047            return None;
1048        }
1049        let parsed = Self {
1050            nonce: nonce.to_owned(),
1051            active_process_limit: fields.next()?.parse().ok()?,
1052            limit_flags: fields.next()?.parse().ok()?,
1053        };
1054        fields.next().is_none().then_some(parsed)
1055    }
1056}
1057
1058fn stop_failed_bootstrap(child: &mut Child, name: &str) {
1059    let _ = child.kill();
1060    let _ = WindowsHyperVContainers::docker(&os_args(["stop", "--time", "0", name]));
1061    let _ = child.wait();
1062}
1063
1064fn wait_until_exit(child: &mut Child, timeout: Duration) -> bool {
1065    let deadline = Instant::now() + timeout;
1066    loop {
1067        match child.try_wait() {
1068            Ok(Some(status)) => return status.success(),
1069            Ok(None) if Instant::now() < deadline => {
1070                std::thread::sleep(Duration::from_millis(25));
1071            }
1072            _ => return false,
1073        }
1074    }
1075}
1076
1077#[derive(Debug)]
1078struct CommandResult {
1079    success: bool,
1080    stdout: String,
1081    stderr: String,
1082}
1083
1084impl CommandResult {
1085    fn from_output(output: Output) -> Self {
1086        Self {
1087            success: output.status.success(),
1088            stdout: bounded_text(output.stdout),
1089            stderr: bounded_text(output.stderr),
1090        }
1091    }
1092
1093    fn permission_denied(&self) -> bool {
1094        let text = self.stderr.to_ascii_lowercase();
1095        text.contains("access is denied") || text.contains("permission denied")
1096    }
1097
1098    fn is_missing_resource(&self) -> bool {
1099        let text = self.stderr.to_ascii_lowercase();
1100        text.contains("no such container") || text.contains("no such object")
1101    }
1102
1103    fn failure(&self) -> CommandFailure {
1104        if self.permission_denied() {
1105            CommandFailure::PermissionDenied
1106        } else {
1107            CommandFailure::Failed
1108        }
1109    }
1110}
1111
1112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1113enum CommandFailure {
1114    NotFound,
1115    PermissionDenied,
1116    Failed,
1117}
1118
1119trait CommandRunner {
1120    fn run(&self, program: &str, args: &[OsString]) -> Result<CommandResult, CommandFailure>;
1121}
1122
1123#[derive(Debug)]
1124struct SystemCommands;
1125
1126impl CommandRunner for SystemCommands {
1127    fn run(&self, program: &str, args: &[OsString]) -> Result<CommandResult, CommandFailure> {
1128        Command::new(program)
1129            .args(args)
1130            .stdin(Stdio::null())
1131            .output()
1132            .map(CommandResult::from_output)
1133            .map_err(|error| {
1134                if error.kind() == std::io::ErrorKind::NotFound {
1135                    CommandFailure::NotFound
1136                } else if error.kind() == std::io::ErrorKind::PermissionDenied {
1137                    CommandFailure::PermissionDenied
1138                } else {
1139                    CommandFailure::Failed
1140                }
1141            })
1142    }
1143}
1144
1145#[cfg(any(not(test), windows))]
1146fn host_state_with(commands: &dyn CommandRunner) -> WindowsHyperVHostState {
1147    if !cfg!(windows) {
1148        return WindowsHyperVHostState::UnsupportedHost;
1149    }
1150    const PROBE: &str = "$os=Get-ItemProperty 'HKLM:\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion' -ErrorAction Stop; $f=Get-CimInstance Win32_OptionalFeature -ErrorAction Stop; $hv=@($f | Where-Object { $_.Name -in @('Microsoft-Hyper-V','Microsoft-Hyper-V-All') -and $_.InstallState -eq 1 }).Count; $ct=@($f | Where-Object { $_.Name -eq 'Containers' -and $_.InstallState -eq 1 }).Count; Write-Output \"edition=$($os.EditionID)\"; Write-Output \"build=$($os.CurrentBuildNumber)\"; Write-Output \"hyperv=$hv\"; Write-Output \"containers=$ct\"";
1151    let system = match commands.run(
1152        "powershell.exe",
1153        &os_args([
1154            "-NoLogo",
1155            "-NoProfile",
1156            "-NonInteractive",
1157            "-Command",
1158            PROBE,
1159        ]),
1160    ) {
1161        Ok(result) if result.success => result,
1162        Ok(result) if result.permission_denied() => {
1163            return WindowsHyperVHostState::RuntimePermissionDenied;
1164        }
1165        _ => return WindowsHyperVHostState::RuntimeDegraded,
1166    };
1167    let edition = value(&system.stdout, "edition").unwrap_or_default();
1168    let Some(edition_family) = supported_edition(edition) else {
1169        return WindowsHyperVHostState::UnsupportedHost;
1170    };
1171    let Some(build) = value(&system.stdout, "build").and_then(|build| build.parse::<u32>().ok())
1172    else {
1173        return WindowsHyperVHostState::RuntimeDegraded;
1174    };
1175    if !edition_family.supports_build(build) {
1176        return WindowsHyperVHostState::UnsupportedHost;
1177    }
1178    let Some(hyper_v_features) =
1179        value(&system.stdout, "hyperv").and_then(|count| count.parse::<u32>().ok())
1180    else {
1181        return WindowsHyperVHostState::RuntimeDegraded;
1182    };
1183    if hyper_v_features == 0 {
1184        return WindowsHyperVHostState::HyperVUnavailable;
1185    }
1186    let Some(container_features) =
1187        value(&system.stdout, "containers").and_then(|count| count.parse::<u32>().ok())
1188    else {
1189        return WindowsHyperVHostState::RuntimeDegraded;
1190    };
1191    if container_features == 0 {
1192        return WindowsHyperVHostState::ContainersUnavailable;
1193    }
1194    let runtime = match commands.run(
1195        "docker",
1196        &os_args(["version", "--format", "{{.Server.Os}}"]),
1197    ) {
1198        Err(CommandFailure::NotFound) => return WindowsHyperVHostState::RuntimeNotInstalled,
1199        Err(CommandFailure::PermissionDenied) => {
1200            return WindowsHyperVHostState::RuntimePermissionDenied;
1201        }
1202        Err(CommandFailure::Failed) => return WindowsHyperVHostState::RuntimeDegraded,
1203        Ok(result) if result.permission_denied() => {
1204            return WindowsHyperVHostState::RuntimePermissionDenied;
1205        }
1206        Ok(result) if !result.success => return WindowsHyperVHostState::RuntimeDegraded,
1207        Ok(result) => result,
1208    };
1209    if runtime.stdout.trim() != "windows" {
1210        return WindowsHyperVHostState::RuntimeInLinuxMode;
1211    }
1212    // Process count is installed inside the container by the pinned bootstrap
1213    // and proved again before JIT. Native client and Server acceptance remains
1214    // a separate preview gate; the static host prerequisites are ready here.
1215    WindowsHyperVHostState::Ready
1216}
1217
1218fn inspect_image(image: &ImageReference) -> Result<bool, CommandFailure> {
1219    let result = WindowsHyperVContainers::docker(&os_args([
1220        "image",
1221        "inspect",
1222        "--format",
1223        "{{.Os}}|{{.Architecture}}",
1224        image.as_str(),
1225    ]))?;
1226    if !result.success {
1227        return if result.permission_denied() {
1228            Err(CommandFailure::PermissionDenied)
1229        } else {
1230            Ok(false)
1231        };
1232    }
1233    Ok(compatible_image_metadata(&result.stdout))
1234}
1235
1236fn container_inspect_args(name: &str, format: &str) -> Vec<OsString> {
1237    // Generic `docker inspect` probes every supported object kind. When a
1238    // container is absent, that fallback reaches the managed-plugin endpoint;
1239    // Windows Docker Engine rejects that endpoint before the provider can
1240    // classify the container as missing. Keep this lookup type-scoped so a
1241    // missing owned container remains an ordinary, recoverable state.
1242    os_args(["container", "inspect", "--format", format, name])
1243}
1244
1245fn container_configuration_inspect_args(name: &str) -> Vec<OsString> {
1246    // Docker serializes absent Windows collections as JSON null. Go template's
1247    // `len` rejects a nil pointer, so normalize only those absent collections
1248    // to zero before the strict ten-field parser validates the result. Values
1249    // whose absence is invalid (including StorageOpt.size) remain unguarded and
1250    // therefore fail closed.
1251    const FORMAT: &str = "{{.HostConfig.Isolation}}|{{.HostConfig.NanoCpus}}|{{.HostConfig.Memory}}|{{index .HostConfig.StorageOpt \"size\"}}|{{.HostConfig.NetworkMode}}|{{if .Mounts}}{{len .Mounts}}{{else}}0{{end}}|{{.HostConfig.Privileged}}|{{if .HostConfig.Binds}}{{len .HostConfig.Binds}}{{else}}0{{end}}|{{if .HostConfig.Devices}}{{len .HostConfig.Devices}}{{else}}0{{end}}|{{.Config.Image}}";
1252    container_inspect_args(name, FORMAT)
1253}
1254
1255fn compatible_image_metadata(metadata: &str) -> bool {
1256    let mut fields = metadata.trim().split('|');
1257    fields.next() == Some("windows") && fields.next() == Some("amd64") && fields.next().is_none()
1258}
1259
1260fn create_args(record: &ResourceRecord, resources: ResourceLimits) -> Vec<OsString> {
1261    let cpu = format!("{:.3}", f64::from(resources.cpu_millis) / 1000.0);
1262    let memory = format!("{}m", resources.memory_mib);
1263    let disk = format!("{}m", resources.disk_mib);
1264    let host = record.host.to_string();
1265    let attempt = record.attempt.to_string();
1266    os_args([
1267        "create",
1268        "--name",
1269        record.name.as_str(),
1270        "--isolation=hyperv",
1271        "--interactive",
1272        "--network=nat",
1273        "--cpus",
1274        &cpu,
1275        "--memory",
1276        &memory,
1277        "--storage-opt",
1278        &format!("size={disk}"),
1279        "--label",
1280        &format!("{LABEL_HOST}={host}"),
1281        "--label",
1282        &format!("{LABEL_ATTEMPT}={attempt}"),
1283        "--label",
1284        &format!("{LABEL_GENERATION}={}", record.generation),
1285        "--label",
1286        &format!("{LABEL_IMAGE}={}", record.image.as_str()),
1287        "--label",
1288        &format!("{LABEL_PROVIDER}={PROVIDER_VALUE}"),
1289        "--entrypoint",
1290        "powershell.exe",
1291        record.image.as_str(),
1292        "-NoLogo",
1293        "-NoProfile",
1294        "-NonInteractive",
1295        "-Command",
1296        BOOTSTRAP,
1297    ])
1298}
1299
1300fn container_name(host: HostId, attempt: AttemptId, generation: &str) -> String {
1301    let compact = generation
1302        .chars()
1303        .filter(|ch| ch.is_ascii_hexdigit())
1304        .take(12)
1305        .collect::<String>();
1306    format!("runner-manager-{host}-{attempt}-{compact}")
1307}
1308
1309#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1310enum WindowsEditionFamily {
1311    Windows11Client,
1312    WindowsServer,
1313}
1314
1315impl WindowsEditionFamily {
1316    const fn supports_build(self, build: u32) -> bool {
1317        match self {
1318            // Windows 11 starts at build 22000. The client runtime path is
1319            // Docker Desktop, whose Windows-container support is limited to
1320            // Pro and Enterprise rather than every Desktop-supported edition.
1321            Self::Windows11Client => build >= 22_000,
1322            // Windows containers first shipped in Windows Server 2016.
1323            Self::WindowsServer => build >= 14_393,
1324        }
1325    }
1326}
1327
1328fn supported_edition(edition: &str) -> Option<WindowsEditionFamily> {
1329    if edition.eq_ignore_ascii_case("Professional") || edition.eq_ignore_ascii_case("Enterprise") {
1330        Some(WindowsEditionFamily::Windows11Client)
1331    } else if edition.eq_ignore_ascii_case("ServerStandard")
1332        || edition.eq_ignore_ascii_case("ServerDatacenter")
1333    {
1334        Some(WindowsEditionFamily::WindowsServer)
1335    } else {
1336        None
1337    }
1338}
1339
1340#[cfg(any(not(test), windows))]
1341fn value<'a>(text: &'a str, key: &str) -> Option<&'a str> {
1342    text.lines()
1343        .find_map(|line| line.trim().strip_prefix(key)?.strip_prefix('='))
1344}
1345
1346fn os_args<const N: usize>(args: [&str; N]) -> Vec<OsString> {
1347    args.into_iter().map(OsString::from).collect()
1348}
1349
1350fn bounded_text(mut bytes: Vec<u8>) -> String {
1351    bytes.truncate(MAX_CAPTURE);
1352    String::from_utf8_lossy(&bytes).into_owned()
1353}
1354
1355fn failure(message: &'static str) -> FailureReason {
1356    FailureReason::Other(message.into())
1357}
1358
1359#[cfg(test)]
1360mod tests {
1361    #[cfg(windows)]
1362    use std::collections::VecDeque;
1363
1364    use super::*;
1365
1366    #[cfg(windows)]
1367    #[derive(Debug)]
1368    struct FakeCommands(Mutex<VecDeque<Result<CommandResult, CommandFailure>>>);
1369
1370    #[cfg(windows)]
1371    impl CommandRunner for FakeCommands {
1372        fn run(&self, _program: &str, _args: &[OsString]) -> Result<CommandResult, CommandFailure> {
1373            self.0
1374                .lock()
1375                .unwrap()
1376                .pop_front()
1377                .expect("scripted command")
1378        }
1379    }
1380
1381    #[cfg(windows)]
1382    fn output(stdout: &str) -> Result<CommandResult, CommandFailure> {
1383        Ok(CommandResult {
1384            success: true,
1385            stdout: stdout.into(),
1386            stderr: String::new(),
1387        })
1388    }
1389
1390    #[cfg(windows)]
1391    fn host_probe(
1392        system: Result<CommandResult, CommandFailure>,
1393        runtime: Option<Result<CommandResult, CommandFailure>>,
1394    ) -> WindowsHyperVHostState {
1395        let mut commands = VecDeque::from([system]);
1396        if let Some(runtime) = runtime {
1397            commands.push_back(runtime);
1398        }
1399        host_state_with(&FakeCommands(Mutex::new(commands)))
1400    }
1401
1402    #[test]
1403    fn editions_are_fail_closed() {
1404        for (supported, family) in [
1405            ("Professional", WindowsEditionFamily::Windows11Client),
1406            ("Enterprise", WindowsEditionFamily::Windows11Client),
1407            ("ServerStandard", WindowsEditionFamily::WindowsServer),
1408            ("ServerDatacenter", WindowsEditionFamily::WindowsServer),
1409        ] {
1410            assert_eq!(supported_edition(supported), Some(family), "{supported}");
1411        }
1412        for refused in [
1413            "",
1414            "Core",
1415            "Home",
1416            "Cloud",
1417            "Education",
1418            "ProfessionalEducation",
1419            "EnterpriseS",
1420            "IoTEnterprise",
1421            "ServerStandardEval",
1422            "mystery",
1423        ] {
1424            assert_eq!(supported_edition(refused), None, "{refused}");
1425        }
1426        assert!(WindowsEditionFamily::Windows11Client.supports_build(22_000));
1427        assert!(!WindowsEditionFamily::Windows11Client.supports_build(19_045));
1428        assert!(WindowsEditionFamily::WindowsServer.supports_build(14_393));
1429    }
1430
1431    #[test]
1432    fn container_inspection_never_falls_through_to_windows_plugin_lookup() {
1433        assert_eq!(
1434            container_inspect_args("runner-manager-owned", "{{.State.Status}}"),
1435            os_args([
1436                "container",
1437                "inspect",
1438                "--format",
1439                "{{.State.Status}}",
1440                "runner-manager-owned",
1441            ])
1442        );
1443    }
1444
1445    #[test]
1446    fn configuration_inspection_normalizes_every_nullable_collection() {
1447        let expected_format = "{{.HostConfig.Isolation}}|{{.HostConfig.NanoCpus}}|{{.HostConfig.Memory}}|{{index .HostConfig.StorageOpt \"size\"}}|{{.HostConfig.NetworkMode}}|{{if .Mounts}}{{len .Mounts}}{{else}}0{{end}}|{{.HostConfig.Privileged}}|{{if .HostConfig.Binds}}{{len .HostConfig.Binds}}{{else}}0{{end}}|{{if .HostConfig.Devices}}{{len .HostConfig.Devices}}{{else}}0{{end}}|{{.Config.Image}}";
1448        assert_eq!(
1449            container_configuration_inspect_args("runner-manager-owned"),
1450            os_args([
1451                "container",
1452                "inspect",
1453                "--format",
1454                expected_format,
1455                "runner-manager-owned",
1456            ])
1457        );
1458    }
1459
1460    #[cfg(windows)]
1461    #[test]
1462    fn host_probe_distinguishes_every_fail_closed_prerequisite() {
1463        assert_eq!(
1464            host_probe(
1465                output("edition=Professional\nbuild=26100\nhyperv=1\ncontainers=1\n"),
1466                Some(output("windows\n")),
1467            ),
1468            WindowsHyperVHostState::Ready
1469        );
1470        assert_eq!(
1471            host_probe(
1472                output("edition=Core\nbuild=26100\nhyperv=1\ncontainers=1\n"),
1473                None,
1474            ),
1475            WindowsHyperVHostState::UnsupportedHost
1476        );
1477        assert_eq!(
1478            host_probe(
1479                output("edition=Professional\nbuild=19045\nhyperv=1\ncontainers=1\n"),
1480                None,
1481            ),
1482            WindowsHyperVHostState::UnsupportedHost
1483        );
1484        assert_eq!(
1485            host_probe(
1486                output("edition=ServerStandard\nbuild=26100\nhyperv=1\ncontainers=1\n"),
1487                Some(output("windows\n")),
1488            ),
1489            WindowsHyperVHostState::Ready
1490        );
1491        assert_eq!(
1492            host_probe(
1493                output("edition=Professional\nbuild=26100\nhyperv=0\ncontainers=1\n"),
1494                None,
1495            ),
1496            WindowsHyperVHostState::HyperVUnavailable
1497        );
1498        assert_eq!(
1499            host_probe(
1500                output("edition=Professional\nbuild=26100\nhyperv=1\ncontainers=0\n"),
1501                None,
1502            ),
1503            WindowsHyperVHostState::ContainersUnavailable
1504        );
1505        assert_eq!(
1506            host_probe(
1507                output("edition=Professional\nbuild=26100\nhyperv=1\ncontainers=1\n"),
1508                Some(Err(CommandFailure::NotFound)),
1509            ),
1510            WindowsHyperVHostState::RuntimeNotInstalled
1511        );
1512        assert_eq!(
1513            host_probe(
1514                output("edition=Professional\nbuild=26100\nhyperv=1\ncontainers=1\n"),
1515                Some(output("linux\n")),
1516            ),
1517            WindowsHyperVHostState::RuntimeInLinuxMode
1518        );
1519        assert_eq!(
1520            host_probe(
1521                output("edition=Professional\nbuild=26100\nhyperv=1\ncontainers=1\n"),
1522                Some(Err(CommandFailure::PermissionDenied)),
1523            ),
1524            WindowsHyperVHostState::RuntimePermissionDenied
1525        );
1526        assert_eq!(
1527            host_probe(
1528                output("edition=Professional\nbuild=26100\nhyperv=1\ncontainers=1\n"),
1529                Some(Err(CommandFailure::Failed)),
1530            ),
1531            WindowsHyperVHostState::RuntimeDegraded
1532        );
1533        for malformed in [
1534            "edition=Professional\nhyperv=1\ncontainers=1\n",
1535            "edition=Professional\nbuild=not-a-build\nhyperv=1\ncontainers=1\n",
1536            "edition=Professional\nbuild=26100\ncontainers=1\n",
1537            "edition=Professional\nbuild=26100\nhyperv=not-a-count\ncontainers=1\n",
1538            "edition=Professional\nbuild=26100\nhyperv=1\n",
1539            "edition=Professional\nbuild=26100\nhyperv=1\ncontainers=not-a-count\n",
1540        ] {
1541            assert_eq!(
1542                host_probe(output(malformed), None),
1543                WindowsHyperVHostState::RuntimeDegraded,
1544                "{malformed:?}"
1545            );
1546        }
1547    }
1548
1549    #[cfg(windows)]
1550    #[test]
1551    fn education_is_refused_before_the_runtime_probe() {
1552        let state = host_probe(
1553            output("edition=Education\nbuild=26100\nhyperv=1\ncontainers=1\n"),
1554            None,
1555        );
1556        assert_eq!(state, WindowsHyperVHostState::UnsupportedHost);
1557        assert_eq!(state.capability(), ProviderCapability::Unsupported);
1558        assert!(
1559            !state.remedy().unwrap().contains("Education"),
1560            "an unsupported edition must not be advertised by its remedy"
1561        );
1562    }
1563
1564    #[test]
1565    fn image_metadata_requires_the_windows_amd64_pair_exactly() {
1566        assert!(compatible_image_metadata("windows|amd64\n"));
1567        for rejected in [
1568            "linux|amd64",
1569            "windows|arm64",
1570            "windows|amd64|extra",
1571            "windows",
1572            "",
1573        ] {
1574            assert!(!compatible_image_metadata(rejected), "{rejected}");
1575        }
1576    }
1577
1578    #[test]
1579    fn resource_record_requires_every_ownership_label() {
1580        let host = HostId::from_u128(1);
1581        let attempt = AttemptId::from_u128(2);
1582        let image = format!("registry.example/runner@sha256:{}", "a".repeat(64));
1583        let line = format!("name|running|0|{host}|{attempt}|generation|{image}|{PROVIDER_VALUE}");
1584        let record = ResourceRecord::parse(&line).expect("complete record");
1585        assert_eq!(record.host, host);
1586        assert_eq!(record.attempt, attempt);
1587        assert!(ResourceRecord::parse(&line.replace(PROVIDER_VALUE, "process")).is_none());
1588        assert!(ResourceRecord::parse("name|running").is_none());
1589    }
1590
1591    #[test]
1592    fn bootstrap_never_embeds_the_jit_value_in_docker_metadata() {
1593        assert!(BOOTSTRAP.contains("[Console]::In.ReadToEnd()"));
1594        assert!(BOOTSTRAP.contains("ACTIONS_RUNNER_INPUT_JITCONFIG"));
1595        assert!(BOOTSTRAP.contains("CREATE_SUSPENDED"));
1596        assert!(BOOTSTRAP.contains("AssignProcessToJobObject"));
1597        assert!(BOOTSTRAP.contains("IsProcessInJob"));
1598        assert!(BOOTSTRAP.contains("JOB_OBJECT_LIMIT_ACTIVE_PROCESS"));
1599        assert!(BOOTSTRAP.contains("JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE"));
1600        assert!(!BOOTSTRAP.contains("JOB_OBJECT_LIMIT_BREAKAWAY_OK"));
1601        assert!(!BOOTSTRAP.contains("JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK"));
1602        assert!(BOOTSTRAP.contains(PREFLIGHT_INPUT));
1603        assert!(!BOOTSTRAP.contains("$env:ACTIONS_RUNNER_INPUT_JITCONFIG"));
1604        assert!(!BOOTSTRAP.contains("fixture-jit-value"));
1605    }
1606
1607    #[test]
1608    fn bootstrap_clears_the_one_time_jit_handoff_before_waiting_for_the_runner() {
1609        let run = BOOTSTRAP
1610            .split_once("public int Run")
1611            .expect("guard run method")
1612            .1;
1613        let clear = run
1614            .find("Array.Clear(jit, 0, jit.Length)")
1615            .expect("JIT clear");
1616        let wait = run.find("if (WaitForSingleObject").expect("runner wait");
1617        assert!(
1618            clear < wait,
1619            "the mutable JIT handoff must be cleared before the job wait"
1620        );
1621        assert!(BOOTSTRAP.contains("$jit=$payload.ToCharArray()\n    $payload=$null"));
1622    }
1623
1624    #[test]
1625    fn job_attestation_is_typed_nonce_bound_and_exact() {
1626        let nonce = "0123456789abcdef0123456789abcdef";
1627        let line = format!(
1628            "{BOOTSTRAP_ATTESTATION_PROTOCOL}|{nonce}|{WINDOWS_PROCESS_LIMIT}|{JOB_LIMIT_FLAGS}"
1629        );
1630        assert_eq!(
1631            JobAttestation::parse(&line),
1632            Some(JobAttestation {
1633                nonce: nonce.into(),
1634                active_process_limit: WINDOWS_PROCESS_LIMIT,
1635                limit_flags: JOB_LIMIT_FLAGS,
1636            })
1637        );
1638        for rejected in [
1639            line.replace(BOOTSTRAP_ATTESTATION_PROTOCOL, "untyped"),
1640            line.replace(nonce, "short"),
1641            line.replace(&WINDOWS_PROCESS_LIMIT.to_string(), "255"),
1642            format!("{line}|extra"),
1643        ] {
1644            let parsed = JobAttestation::parse(&rejected);
1645            assert!(
1646                parsed
1647                    != Some(JobAttestation {
1648                        nonce: nonce.into(),
1649                        active_process_limit: WINDOWS_PROCESS_LIMIT,
1650                        limit_flags: JOB_LIMIT_FLAGS,
1651                    }),
1652                "{rejected}"
1653            );
1654        }
1655    }
1656
1657    #[cfg(windows)]
1658    #[test]
1659    fn powershell_bootstrap_installs_queries_and_uses_the_nested_job() {
1660        let nonce = "0123456789abcdef0123456789abcdef";
1661        let compile_temp = tempfile::tempdir().expect("writable Add-Type directory");
1662        let mut child = Command::new("powershell.exe")
1663            .args([
1664                "-NoLogo",
1665                "-NoProfile",
1666                "-NonInteractive",
1667                "-Command",
1668                BOOTSTRAP,
1669            ])
1670            .env("TEMP", compile_temp.path())
1671            .env("TMP", compile_temp.path())
1672            .stdin(Stdio::piped())
1673            .stdout(Stdio::piped())
1674            .stderr(Stdio::piped())
1675            .spawn()
1676            .expect("PowerShell bootstrap starts");
1677        let mut stdin = child.stdin.take().expect("bootstrap stdin");
1678        write!(
1679            stdin,
1680            "{BOOTSTRAP_REQUEST_PROTOCOL}|{nonce}|{WINDOWS_PROCESS_LIMIT}\n{BOOTSTRAP_SELF_TEST_INPUT}"
1681        )
1682        .expect("bootstrap protocol write");
1683        drop(stdin);
1684        let success = wait_until_exit(&mut child, BOOTSTRAP_ATTESTATION_TIMEOUT);
1685        if !success {
1686            let _ = child.kill();
1687        }
1688        let mut stdout = String::new();
1689        child
1690            .stdout
1691            .take()
1692            .expect("bootstrap stdout")
1693            .read_to_string(&mut stdout)
1694            .expect("bootstrap stdout is readable");
1695        let mut stderr = String::new();
1696        child
1697            .stderr
1698            .take()
1699            .expect("bootstrap stderr")
1700            .read_to_string(&mut stderr)
1701            .expect("bootstrap stderr is readable");
1702        assert!(success, "bootstrap failed: {stderr}");
1703        assert_eq!(
1704            stdout.trim(),
1705            format!(
1706                "{BOOTSTRAP_ATTESTATION_PROTOCOL}|{nonce}|{WINDOWS_PROCESS_LIMIT}|{JOB_LIMIT_FLAGS}"
1707            )
1708        );
1709    }
1710
1711    #[test]
1712    fn container_attestation_requires_exact_limits_and_no_host_surface() {
1713        let image =
1714            ImageReference::new(format!("registry.example/runner@sha256:{}", "a".repeat(64)))
1715                .unwrap();
1716        let resources = ResourceLimits {
1717            cpu_millis: 2500,
1718            memory_mib: 3072,
1719            disk_mib: 8192,
1720        };
1721        let line = format!(
1722            "hyperv|2500000000|3221225472|8192m|nat|0|false|0|0|{}",
1723            image.as_str()
1724        );
1725        assert_eq!(
1726            ContainerAttestation::parse(&line),
1727            Some(ContainerAttestation::expected(&image, resources))
1728        );
1729        for rejected in [
1730            line.replacen("hyperv", "process", 1),
1731            line.replacen("2500000000", "2000000000", 1),
1732            line.replacen("3221225472", "2147483648", 1),
1733            line.replacen("8192m", "4096m", 1),
1734            line.replacen("|0|false|0|0|", "|1|false|0|0|", 1),
1735            line.replacen("|0|false|0|0|", "|0|true|0|0|", 1),
1736            line.replacen("|0|false|0|0|", "||false|0|0|", 1),
1737            line.replacen("|0|false|0|0|", "|0|false||0|", 1),
1738            line.replacen("|0|false|0|0|", "|0|false|0||", 1),
1739            format!("{line}|extra"),
1740        ] {
1741            assert_ne!(
1742                ContainerAttestation::parse(&rejected),
1743                Some(ContainerAttestation::expected(&image, resources)),
1744                "{rejected}"
1745            );
1746        }
1747    }
1748
1749    #[test]
1750    fn create_forces_hyper_v_limits_and_has_no_host_escape_surface() {
1751        let record = ResourceRecord {
1752            name: "runner-manager-fixture".into(),
1753            state: String::new(),
1754            exit_code: None,
1755            host: HostId::from_u128(1),
1756            attempt: AttemptId::from_u128(2),
1757            generation: "fixture-generation".into(),
1758            image: ImageReference::new(format!(
1759                "registry.example/runner@sha256:{}",
1760                "a".repeat(64)
1761            ))
1762            .unwrap(),
1763        };
1764        let args = create_args(
1765            &record,
1766            ResourceLimits {
1767                cpu_millis: 2500,
1768                memory_mib: 3072,
1769                disk_mib: 8192,
1770            },
1771        )
1772        .into_iter()
1773        .map(|arg| arg.to_string_lossy().into_owned())
1774        .collect::<Vec<_>>();
1775        assert!(args.iter().any(|arg| arg == "--isolation=hyperv"));
1776        assert!(args.windows(2).any(|pair| pair == ["--cpus", "2.500"]));
1777        assert!(args.windows(2).any(|pair| pair == ["--memory", "3072m"]));
1778        assert!(
1779            args.windows(2)
1780                .any(|pair| pair == ["--storage-opt", "size=8192m"])
1781        );
1782        assert!(!args.iter().any(|arg| arg == "--pids-limit"));
1783        for forbidden in ["--volume", "--mount", "--device", "--privileged"] {
1784            assert!(!args.iter().any(|arg| arg == forbidden), "{args:?}");
1785        }
1786        assert!(!args.iter().any(|arg| arg.contains("docker.sock")));
1787    }
1788}