Skip to main content

ignition_core/deploy/transport/
winps.rs

1//! PowerShell on a Windows OpenSSH host (22-13), built only from the C7a
2//! captures in `22-LIVE-CAPTURES.md`.
3//!
4//! sftp cannot do three things the Windows read and write halves need, so a
5//! small, fixed set of scripts runs through
6//! [`RemoteShell::exec_powershell`](super::remote::RemoteShell::exec_powershell):
7//!
8//! - [`ENUMERATE`]: an independent, complete walk of a subtree with the
9//!   `\\?\` prefix. The sftp-server resolves no path of 260 characters or
10//!   more and silently omits such entries from listings (C7a-8), and `get -R`
11//!   follows junctions (C7a-5), so an sftp listing is never proof of
12//!   completeness on this host. The walk returns every entry with its size and
13//!   reparse-point flag, and the read compares it with what sftp returned.
14//! - [`MOVE_REPLACE`]: `Move-Item -Force`, the documented fallback for
15//!   replacing a file when sftp `rename` does not (C7a-6). On the captured host
16//!   `rename` over an existing file replaces it (C7a-4), so the Windows commit
17//!   uses `rename`; this script is the proven alternative, exercised live.
18//! - [`RECORD_REPLACE`]: the atomic replace of the release record (25-20,
19//!   G-25-4), one `MoveFileEx(REPLACE_EXISTING | WRITE_THROUGH)` call.
20//! - [`GIT_ENTRY`]: the `.git` probe of `deploy projects` (25-08, K9), one
21//!   `Get-Item -Force` per level, so a Hidden entry is seen and a denied one is
22//!   an error rather than "absent".
23//!
24//! ## How a command is built
25//!
26//! [`encoded_command`] assembles `prelude + parameter decode + body` and hands
27//! the text to [`RemoteCommand::powershell_encoded`], the one UTF-16LE base64
28//! encoder; there is no second encoder and no way to pass a raw command line.
29//! The bodies are `const` strings. Parameters travel only as a base64 JSON
30//! blob decoded inside the script (`$p`), never by string interpolation, so a
31//! path with a quote, a `$` or a backtick is data. The whole remote command
32//! is held to [`COMMAND_BUDGET`] characters before anything spawns (C7a-7: it
33//! works to 8154, fails at 8162).
34//!
35//! ## What the output means
36//!
37//! - The prelude sets `$ErrorActionPreference='Stop'` (without it a failed
38//!   step exits 0, C7a-6) and `$ProgressPreference='SilentlyContinue'`
39//!   (without it progress CLIXML reaches stderr). Error CLIXML still reaches
40//!   stderr on a failure even so, so **stderr is opaque**: success is decided
41//!   from the exit code and exactly one [`IGN_RESULT_SENTINEL`] line on
42//!   stdout, and stderr is decoded only to explain a failure.
43//! - The sentinel line carries compact JSON, escaped to ASCII: PowerShell
44//!   writes stdout in the OEM code page 437 (C7a-6), so non-ASCII text is
45//!   `\uXXXX` before it leaves the host.
46//! - An error record on stderr with exit 0 is a failure too: with `Stop` no
47//!   error should survive, so one that does means a step did not do what the
48//!   script says.
49
50use serde_json::Value;
51
52use super::process::ProcessOutput;
53use super::remote::{RemoteCommand, scrub_stderr};
54
55/// The marker that starts the one stdout line a script's result travels on.
56pub const IGN_RESULT_SENTINEL: &str = "IGN-RESULT:";
57
58/// The longest remote command, in characters, that is sent (C7a-7: 8154 works,
59/// 8162 fails with "The command line is too long."; 8000 leaves a margin).
60pub const COMMAND_BUDGET: usize = 8000;
61
62/// `\\?\`: the extended-length path prefix the enumeration walks with.
63pub const LONG_PATH_PREFIX: &str = "\\\\?\\";
64
65/// Entries whose absolute path has this many characters or more are refused:
66/// the sftp-server cannot resolve a path of 260 characters (C7a-8), and a
67/// margin keeps a path that grows by a rename from crossing it unseen.
68pub const LONG_PATH_LIMIT: usize = 240;
69
70/// Run before every script. `Stop` makes a failed step exit non-zero; the
71/// progress preference silences progress CLIXML; `E` writes the result line
72/// with every non-ASCII character as a `\uXXXX` escape.
73const PRELUDE: &str = "$ProgressPreference='SilentlyContinue';$ErrorActionPreference='Stop';function E($o){$j=ConvertTo-Json -InputObject $o -Compress -Depth 6;$j=[regex]::Replace($j,'[^\\x00-\\x7e]',{param($m)'\\u{0:x4}' -f [int][char]$m.Value});[Console]::Out.WriteLine('IGN-RESULT:'+$j)};";
74
75/// `Move-Item -Force`: replace `dst` with `src`. A missing source or an
76/// unwritable target is a terminating error (exit 1).
77///
78/// This is the apply-era fallback and is no longer the release-record replace
79/// path: on Windows PowerShell 5.1 `Move-Item -Force` over an existing file is
80/// delete-then-rename, so a concurrent reader sees the target missing for a
81/// moment (G-25-4). The record uses [`RECORD_REPLACE`]. It is kept for the
82/// generic exec test and as the negative control of the live race gate.
83pub const MOVE_REPLACE: &str = "Move-Item -LiteralPath $p.src -Destination $p.dst -Force;E @{ok=1}";
84
85/// The atomic release-record replace (25-20, G-25-4): `MoveFileEx(src, dst,
86/// MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)` declared through
87/// `Add-Type` P/Invoke. Win32 performs the rename-with-replace as one
88/// operation, so a concurrent reader of `dst` sees the old file or the new one
89/// on NTFS, never a missing or a half-written one. That is the guarantee of
90/// the atomic rename of NTFS on Windows (and of `rename(2)` on Linux, which the
91/// POSIX arm uses): it is atomic for readers and it is not a durable write
92/// (the temporary file is not flushed first). `Move-Item -Force` was retired
93/// for the record because on PowerShell 5.1 it deletes the target before the
94/// rename.
95///
96/// It fails closed: unless the session is `FullLanguage` the script throws
97/// naming the language mode, and a failing `Add-Type` throws naming the
98/// innermost exception, so the writer returns `transport_failed` and never
99/// falls back to a non-atomic replace. The type is compiled only when
100/// `IgnRecord.Native` is not loaded yet (production runs one fresh process per
101/// replace, so this matters only to the in-process race harness). A sharing or
102/// access violation (Win32 error 32 or 5, a reader holding the target without
103/// delete-share) is retried `$p.retries` times with one-second pauses, then the
104/// script throws naming the last error and its system message. Error 5 also
105/// covers a permanent ACL denial, which therefore surfaces after about
106/// `retries` seconds. A replace still blocked after the retries throws and the
107/// previous record stays. `$p.src`, `$p.dst` and `$p.retries` are data in the
108/// base64 parameter blob, never text of the script.
109///
110/// Every failure says why (G-25-5, WR-01). The variable part of a thrown text
111/// is one line of printable ASCII, at most 200 characters plus `...` when cut,
112/// with every drive-letter or UNC path replaced by `<path>`. It is only
113/// concatenated into the thrown string, never evaluated. Each thrown text
114/// stays within 300 characters: PowerShell 5.1 wraps error lines at 119
115/// columns and a thrown string ends its error record with
116/// `FullyQualifiedErrorId : <thrown text>`, so at most 3 such chunks and the
117/// CategoryInfo line must fit the 5-line stderr tail kept by `scrub_stderr`.
118/// The helpers are named `IgnLine` and `IgnCause` and never with one letter,
119/// because PowerShell resolves an alias before a function (`r` is the built-in
120/// alias of `Invoke-History`). `IgnCause` has its own try and catch, so a
121/// failure inside it can never stand in for the original failure.
122///
123/// `Add-Type` is kept (the G-25-5 evaluation): a `DefinePInvokeMethod`
124/// declaration needs a dynamic assembly, a module, a type and a custom
125/// attribute that sets `SetLastError`, none of it can be exercised offline,
126/// and in-memory P/Invoke through Reflection.Emit is the technique of
127/// offensive tooling that endpoint protection watches. The cost is one
128/// `csc.exe` compile in every fresh process, inside a whole fresh-process
129/// replace of 2.70 s on the capture host (25-20 preflight). The prerequisites
130/// are a FullLanguage session, `csc.exe` and a writable `%TEMP%` for the
131/// deploy account.
132pub const RECORD_REPLACE: &str = concat!(
133    r#"if($ExecutionContext.SessionState.LanguageMode -ne 'FullLanguage'){throw ('the record replace needs a FullLanguage PowerShell session, not '+$ExecutionContext.SessionState.LanguageMode)};"#,
134    r#"function IgnLine($t){$s=[regex]::Replace([string]$t,'[\x00-\x1f]+',' ');$s=[regex]::Replace($s,'(?:[A-Za-z]:[\\/]|\\\\)[^''"]*','<path>');$s=[regex]::Replace($s,'[^\x20-\x7e]','?').Trim();if($s.Length -gt 200){$s=$s.Substring(0,200)+'...'};$s};"#,
135    r#"function IgnCause($x){try{while($x.InnerException){$x=$x.InnerException};$m=[string]$x.Message;$k=$x.GetType().FullName+' '+('0x{0:X8}' -f $x.HResult);if($m){$k+=': '+$m};IgnLine $k}catch{'the cause could not be read'}};"#,
136    "if(-not ('IgnRecord.Native' -as [type])){try{Add-Type -Namespace IgnRecord -Name Native -MemberDefinition '[DllImport(\"kernel32.dll\",SetLastError=true,CharSet=CharSet.Unicode)]public static extern bool MoveFileEx(string a,string b,uint c);'}catch{throw ('the record replace cannot declare MoveFileEx: '+(IgnCause $_.Exception))}};",
137    "$f=[uint32](0x1 -bor 0x8);$n=0;",
138    "while(-not [IgnRecord.Native]::MoveFileEx($p.src,$p.dst,$f)){$c=[Runtime.InteropServices.Marshal]::GetLastWin32Error();",
139    r#"if(($c -ne 32 -and $c -ne 5) -or $n -ge [int]$p.retries){throw ('the record replace failed with Win32 error '+$c+': '+(IgnLine $(try{(New-Object ComponentModel.Win32Exception ([int]$c)).Message}catch{''})))};"#,
140    "$n++;Start-Sleep -Seconds 1};E @{ok=1}"
141);
142
143/// Walk each `$p.r[i]` (with the prefix `$p.x` in front) and report every
144/// entry as `[relative path, is_dir, size, is_reparse_point, absolute path
145/// length]`. A reparse point is reported and not descended. A missing root is
146/// `x=0`. An unreadable directory is a terminating error.
147///
148/// The relative path is built from entry names during the walk (`\`-joined),
149/// never by cutting the root off `FullName`: .NET rewrites a root it
150/// normalises (an 8.3 component such as `RUNNER~1` expanded, `.`, a doubled or
151/// trailing separator dropped), so the caller's spelling and `FullName` need
152/// not share a prefix length (G-22-1).
153pub const ENUMERATE: &str = "$o=@();for($i=0;$i -lt $p.r.Count;$i++){$b=$p.x+$p.r[$i];$e=@();$x=[IO.Directory]::Exists($b);if($x){$s=New-Object Collections.Stack;$s.Push(@([IO.DirectoryInfo]$b,''));while($s.Count){$q=$s.Pop();foreach($f in $q[0].EnumerateFileSystemInfos()){$a=[int]$f.Attributes;$d=[int](($a -band 16) -ne 0);$r=[int](($a -band 1024) -ne 0);$z=0;if(!$d){$z=$f.Length};$m=$q[1]+$f.Name;$e+=,@($m,$d,$z,$r,$f.FullName.Length);if($d -and !$r){$s.Push(@($f,($m+'\\')))}}}};$o+=@{i=$i;x=[int]$x;e=$e}};E @{ok=1;res=$o}";
154
155/// The `.git` probe of `deploy projects` (Phase 25, 25-08, K9): what is at
156/// `$p.c` (`<data>\projects\<name>\.git`), asked only when `$p.a`
157/// (`<data>\projects`) and `$p.b` (the project directory) are real
158/// directories.
159///
160/// Each level is one `Get-Item -LiteralPath ... -Force` and nothing else. The
161/// 25-03 captures (`## C-H before grant`, `## Host grant and prod git.yaml
162/// (25-03)`) showed why: `-Force` sees a Hidden entry, the .NET `Exists`
163/// methods and `Test-Path` answer false (or, run to run, throw) for a
164/// directory the account cannot traverse, and `Get-Item` throws
165/// `UnauthorizedAccessException` there. Only an `ObjectNotFound` error record
166/// is `none`; every other error is rethrown, which under the prelude's `Stop`
167/// is a non-zero exit and so `transport_failed`, never "absent". A reparse
168/// point (junction or symlink) is reported with `r=1` and never opened or
169/// followed; a Hidden `.git` is reported with `h=1` and counts like any other.
170///
171/// Result: `projects` and `project` are `none | dir | file | symlink | skip`,
172/// `kind` is `none | dir | file | skip` for the `.git` entry, `hidden` and
173/// `reparse` are 0 or 1 for it.
174pub const GIT_ENTRY: &str = concat!(
175    "function T($x){try{$i=Get-Item -LiteralPath $x -Force -ErrorAction Stop}",
176    "catch{if($_.CategoryInfo.Category -eq 'ObjectNotFound'){return @{k='none';h=0;r=0}};throw};",
177    "$a=[int]$i.Attributes;",
178    "return @{k=$(if($a -band 16){'dir'}else{'file'});h=[int](($a -band 2) -ne 0);r=[int](($a -band 1024) -ne 0)}};",
179    "function W($t){if($t.r){'symlink'}else{$t.k}};",
180    "$s=@{k='skip';h=0;r=0};$a=T $p.a;$b=$s;$c=$s;",
181    "if($a.k -eq 'dir' -and !$a.r){$b=T $p.b;if($b.k -eq 'dir' -and !$b.r){$c=T $p.c}};",
182    "E @{ok=1;projects=(W $a);project=(W $b);kind=$c.k;hidden=$c.h;reparse=$c.r}"
183);
184
185/// Why `name` cannot be ONE component of a path on a Windows host, or `None`.
186///
187/// This is the Windows half of the path gate. It refuses everything the POSIX
188/// gate refuses (empty, `.`, `..`, a separator, NUL and other control
189/// characters) and the ways Windows lets a name leave its directory or mean
190/// something else: a `:` (a drive letter injected into a component, or an
191/// alternate data stream `name:stream`), the characters NTFS forbids
192/// (`< > " | ? *`), a trailing dot or space (Windows strips them, so two names
193/// would be one entry) and the reserved device names (`CON`, `NUL`, `COM1`,
194/// ... with or without an extension). `\\?\` and UNC prefixes cannot pass as a
195/// component because they hold separators and a colon.
196pub fn windows_component_problem(name: &str) -> Option<String> {
197    let quoted = |why: &str| Some(format!("the name {name:?} {why}"));
198    if name.is_empty() {
199        return quoted("is empty");
200    }
201    if name == "." || name == ".." {
202        return quoted("is `.` or `..`");
203    }
204    if let Some(bad) = name.chars().find(|c| {
205        c.is_control() || matches!(c, '/' | '\\' | ':' | '<' | '>' | '"' | '|' | '?' | '*')
206    }) {
207        return Some(format!(
208            "the name {name:?} holds {bad:?}, which cannot be part of a Windows path component (separators, drive letters, alternate data streams and reserved characters)"
209        ));
210    }
211    if name.ends_with('.') || name.ends_with(' ') {
212        return quoted("ends in a dot or a space, which Windows strips");
213    }
214    let stem = name.split('.').next().unwrap_or(name).trim_end();
215    let upper = stem.to_ascii_uppercase();
216    let device = matches!(upper.as_str(), "CON" | "PRN" | "AUX" | "NUL")
217        || ["COM", "LPT"].iter().any(|prefix| {
218            upper
219                .strip_prefix(prefix)
220                .is_some_and(|n| matches!(n.as_bytes(), [b'1'..=b'9']))
221        });
222    if device {
223        return quoted("is a reserved Windows device name");
224    }
225    None
226}
227
228/// Why `path` is not an absolute path in the sftp spelling of a Windows host
229/// (`/C:/dir/...`), or `None`: a leading `/`, a drive letter component
230/// (`C:`), at least one more component, and every component passing
231/// [`windows_component_problem`]. Refuses `C:/...` (home-relative on this
232/// server), UNC (`//host/...`), `\\?\` prefixes, `..`, empty components and a
233/// second drive letter.
234pub fn windows_absolute_path_problem(path: &str) -> Option<String> {
235    let Some(rest) = path.strip_prefix('/') else {
236        return Some(format!("{path:?} does not start with `/`"));
237    };
238    let mut parts = rest.split('/');
239    let drive = parts.next().unwrap_or_default();
240    let drive_ok = matches!(drive.as_bytes(), [letter, b':'] if letter.is_ascii_alphabetic());
241    if !drive_ok {
242        return Some(format!(
243            "{path:?} does not start with a drive letter component such as `/C:/`"
244        ));
245    }
246    let mut below = 0usize;
247    for part in parts {
248        if let Some(why) = windows_component_problem(part) {
249            return Some(format!("{path:?}: {why}"));
250        }
251        below += 1;
252    }
253    if below == 0 {
254        return Some(format!("{path:?} is a drive root"));
255    }
256    None
257}
258
259/// One fixed script and its parameters.
260#[derive(Debug, Clone)]
261pub struct WinScript {
262    /// A short name for error messages.
263    pub name: &'static str,
264    /// The script body, a `const`. It reads its parameters from `$p` and ends
265    /// by calling `E` with the result.
266    pub body: &'static str,
267    /// The parameters: a JSON object (or `Null` for none), decoded in-script.
268    pub params: Value,
269}
270
271impl WinScript {
272    /// The script that replaces `dst` with `src` ([`MOVE_REPLACE`]).
273    pub fn move_replace(src: &str, dst: &str) -> WinScript {
274        WinScript {
275            name: "move_replace",
276            body: MOVE_REPLACE,
277            params: serde_json::json!({ "src": src, "dst": dst }),
278        }
279    }
280
281    /// The script that atomically replaces the release record `dst` with
282    /// `src` ([`RECORD_REPLACE`]). `retries` is how often a sharing violation
283    /// is retried; like both paths it travels only in the JSON parameters.
284    pub fn record_replace(src: &str, dst: &str, retries: u32) -> WinScript {
285        WinScript {
286            name: "record_replace",
287            body: RECORD_REPLACE,
288            params: serde_json::json!({ "src": src, "dst": dst, "retries": retries }),
289        }
290    }
291
292    /// The script that probes the `.git` entry of one project ([`GIT_ENTRY`]).
293    /// The three native paths are `<data>\projects`, the project directory and
294    /// its `.git`; they travel only as parameters.
295    pub fn git_entry(projects_dir: &str, project_dir: &str, git: &str) -> WinScript {
296        WinScript {
297            name: "git_entry",
298            body: GIT_ENTRY,
299            params: serde_json::json!({ "a": projects_dir, "b": project_dir, "c": git }),
300        }
301    }
302
303    /// The script that walks `roots` with `prefix` in front of each
304    /// ([`ENUMERATE`]). `prefix` is [`LONG_PATH_PREFIX`] on a Windows host.
305    pub fn enumerate(prefix: &str, roots: &[String]) -> WinScript {
306        WinScript {
307            name: "enumerate",
308            body: ENUMERATE,
309            params: serde_json::json!({ "x": prefix, "r": roots }),
310        }
311    }
312}
313
314/// The remote command for `script`, or why it cannot be sent: the parameters
315/// must be a JSON object (or `Null`), and the whole command must fit `budget`
316/// characters ([`COMMAND_BUDGET`] in production). Built by
317/// [`RemoteCommand::powershell_encoded`]; this function only composes the
318/// script text.
319pub fn encoded_command(script: &WinScript, budget: usize) -> Result<RemoteCommand, String> {
320    use base64::Engine as _;
321    let mut text = String::from(PRELUDE);
322    match &script.params {
323        Value::Null => {}
324        Value::Object(map) if map.is_empty() => {}
325        Value::Object(_) => {
326            let json = serde_json::to_vec(&script.params).map_err(|e| {
327                format!("the parameters of {} cannot be encoded ({e})", script.name)
328            })?;
329            let blob = base64::engine::general_purpose::STANDARD.encode(json);
330            text.push_str("$p=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('");
331            text.push_str(&blob);
332            text.push_str("'))|ConvertFrom-Json;");
333        }
334        _ => {
335            return Err(format!(
336                "the parameters of {} must be a JSON object",
337                script.name
338            ));
339        }
340    }
341    text.push_str(script.body);
342    let command = RemoteCommand::powershell_encoded(&text);
343    let length = command.as_str().chars().count();
344    if length > budget {
345        return Err(format!(
346            "the remote command for {} is {length} characters, over the {budget} character budget",
347            script.name
348        ));
349    }
350    Ok(command)
351}
352
353/// The JSON of the single [`IGN_RESULT_SENTINEL`] line in `stdout`. Lines
354/// without the sentinel are ignored; no such line, two or more, or text after
355/// the sentinel that is not JSON is an error.
356pub fn parse_ign_result(stdout: &[u8]) -> Result<Value, String> {
357    let mut found: Option<&[u8]> = None;
358    for line in stdout.split(|b| *b == b'\n') {
359        let line = line.strip_suffix(b"\r").unwrap_or(line);
360        if let Some(rest) = line.strip_prefix(IGN_RESULT_SENTINEL.as_bytes()) {
361            if found.is_some() {
362                return Err(format!(
363                    "the output holds more than one {IGN_RESULT_SENTINEL} line"
364                ));
365            }
366            found = Some(rest);
367        }
368    }
369    let Some(json) = found else {
370        return Err(format!("the output holds no {IGN_RESULT_SENTINEL} line"));
371    };
372    serde_json::from_slice(json)
373        .map_err(|_| format!("the {IGN_RESULT_SENTINEL} line is not valid JSON"))
374}
375
376/// The messages of the `<S S="Error">` strings in a CLIXML stderr, decoded
377/// for display only. Empty when `stderr` is not CLIXML.
378pub fn clixml_errors(stderr: &[u8]) -> Vec<String> {
379    let text = String::from_utf8_lossy(stderr);
380    if !text.contains("CLIXML") {
381        return Vec::new();
382    }
383    let mut out = Vec::new();
384    let mut rest: &str = &text;
385    while let Some(at) = rest.find("<S S=\"Error\">") {
386        rest = &rest[at + "<S S=\"Error\">".len()..];
387        let Some(end) = rest.find("</S>") else { break };
388        let raw = &rest[..end];
389        rest = &rest[end..];
390        let decoded = raw
391            .replace("_x000D__x000A_", "\n")
392            .replace("_x000A_", "\n")
393            .replace("_x000D_", "")
394            .replace("_x0009_", "\t")
395            .replace("&lt;", "<")
396            .replace("&gt;", ">")
397            .replace("&quot;", "\"")
398            .replace("&apos;", "'")
399            .replace("&amp;", "&");
400        let decoded = decoded.trim();
401        if !decoded.is_empty() {
402            out.push(decoded.to_string());
403        }
404    }
405    out
406}
407
408/// The result of one [`WinScript`] run, or the detail of the
409/// `transport_failed` it is. Success needs exit 0 within the timeout, exactly
410/// one sentinel line, and no error record on stderr; stderr is otherwise
411/// opaque (see the module doc).
412pub fn interpret_output(
413    out: &ProcessOutput,
414    timeout_s: u64,
415    temp_dir: Option<&std::path::Path>,
416) -> Result<Value, String> {
417    if out.timed_out {
418        return Err(format!("timed out after {timeout_s}s and was killed"));
419    }
420    let explain = || -> String {
421        let decoded = clixml_errors(&out.stderr);
422        if decoded.is_empty() {
423            scrub_stderr(&out.stderr, temp_dir)
424        } else {
425            scrub_stderr(decoded.join("\n").as_bytes(), temp_dir)
426        }
427    };
428    if out.code != 0 {
429        let stderr_text = String::from_utf8_lossy(&out.stderr);
430        if stderr_text.contains("The command line is too long") {
431            return Err(format!(
432                "exit code {}: the host refused the remote command as too long (budget {COMMAND_BUDGET} characters)",
433                out.code
434            ));
435        }
436        let tail = explain();
437        return Err(if tail.is_empty() {
438            format!("exit code {}", out.code)
439        } else {
440            format!("exit code {}: {tail}", out.code)
441        });
442    }
443    let errors = clixml_errors(&out.stderr);
444    if !errors.is_empty() {
445        return Err(format!(
446            "the script exited 0 but wrote an error record: {}",
447            scrub_stderr(errors.join("\n").as_bytes(), temp_dir)
448        ));
449    }
450    parse_ign_result(&out.stdout)
451}
452
453/// One entry of an [`ENUMERATE`] result.
454#[derive(Debug, Clone, PartialEq, Eq)]
455pub struct EnumEntry {
456    /// Path below the walked root, `/`-separated.
457    pub rel: String,
458    /// A directory.
459    pub dir: bool,
460    /// File size (0 for a directory).
461    pub size: u64,
462    /// A reparse point (junction or symlink).
463    pub reparse: bool,
464    /// Length of the entry's absolute path, prefix included.
465    pub full_len: usize,
466}
467
468/// The entries per root of an [`ENUMERATE`] result: `None` for a root that
469/// does not exist. `expected_roots` roots must be reported, in order.
470pub fn parse_enumeration(
471    value: &Value,
472    expected_roots: usize,
473) -> Result<Vec<Option<Vec<EnumEntry>>>, String> {
474    let bad = |why: &str| format!("the enumeration result is malformed ({why})");
475    if value.get("ok").and_then(Value::as_i64) != Some(1) {
476        return Err(bad("ok is not 1"));
477    }
478    let res = value
479        .get("res")
480        .and_then(Value::as_array)
481        .ok_or_else(|| bad("res is not an array"))?;
482    if res.len() != expected_roots {
483        return Err(bad("the number of roots differs"));
484    }
485    let mut out = Vec::with_capacity(res.len());
486    for (index, root) in res.iter().enumerate() {
487        if root.get("i").and_then(Value::as_u64) != Some(index as u64) {
488            return Err(bad("roots are out of order"));
489        }
490        if root.get("x").and_then(Value::as_i64) == Some(0) {
491            out.push(None);
492            continue;
493        }
494        let entries = root
495            .get("e")
496            .and_then(Value::as_array)
497            .ok_or_else(|| bad("e is not an array"))?;
498        let mut list = Vec::with_capacity(entries.len());
499        for entry in entries {
500            let cols = entry
501                .as_array()
502                .ok_or_else(|| bad("an entry is not an array"))?;
503            let (Some(rel), Some(dir), Some(size), Some(reparse), Some(full_len)) = (
504                cols.first().and_then(Value::as_str),
505                cols.get(1).and_then(Value::as_i64),
506                cols.get(2).and_then(Value::as_u64),
507                cols.get(3).and_then(Value::as_i64),
508                cols.get(4).and_then(Value::as_u64),
509            ) else {
510                return Err(bad("an entry has the wrong columns"));
511            };
512            // every path the host reports passes the same gate as any other
513            // path that can reach a batch line
514            let rel = rel.replace('\\', "/");
515            for component in rel.split('/') {
516                if let Some(why) = windows_component_problem(component) {
517                    return Err(bad(&format!("an entry path is refused: {why}")));
518                }
519            }
520            list.push(EnumEntry {
521                rel,
522                dir: dir != 0,
523                size,
524                reparse: reparse != 0,
525                full_len: full_len as usize,
526            });
527        }
528        out.push(Some(list));
529    }
530    Ok(out)
531}
532
533/// Split `roots` into the fewest [`ENUMERATE`] commands that each fit
534/// [`COMMAND_BUDGET`]: the index ranges of `roots` and the script for each. A
535/// single root that does not fit alone is an error.
536pub fn enumerate_chunks(
537    prefix: &str,
538    roots: &[String],
539) -> Result<Vec<(std::ops::Range<usize>, WinScript)>, String> {
540    let mut chunks = Vec::new();
541    let mut start = 0usize;
542    while start < roots.len() {
543        let mut end = start + 1;
544        if encoded_command(
545            &WinScript::enumerate(prefix, &roots[start..end]),
546            COMMAND_BUDGET,
547        )
548        .is_err()
549        {
550            return Err(format!(
551                "the path {:?} is too long to enumerate within the remote command budget",
552                roots[start]
553            ));
554        }
555        while end < roots.len()
556            && encoded_command(
557                &WinScript::enumerate(prefix, &roots[start..=end]),
558                COMMAND_BUDGET,
559            )
560            .is_ok()
561        {
562            end += 1;
563        }
564        chunks.push((start..end, WinScript::enumerate(prefix, &roots[start..end])));
565        start = end;
566    }
567    Ok(chunks)
568}
569
570/// The three native absolute roots the uploaded script works in (Phase 23,
571/// 23-08): the run's staging directory (it holds `commit.ps1` and
572/// `work.tsv`), the mode collection and the `ign-deploy/<gateway>/<mode>`
573/// directory. Native form (`C:\Users\...`), not the sftp spelling.
574#[derive(Debug, Clone, PartialEq, Eq)]
575pub struct StubRoots {
576    /// The directory that holds the script, the work list and the staged files.
577    pub staging: String,
578    /// `<data dir>\config\resources\<mode>`.
579    pub collection: String,
580    /// `<home>\ign-deploy\<gateway>\<mode>`.
581    pub deploy: String,
582}
583
584/// The fixed body of the stub that runs the uploaded script (D-01, C-script-run).
585///
586/// It reads `commit.ps1` and `work.tsv` from the staging directory ONCE each,
587/// hashes the bytes it read with SHA-256 and compares them with the values the
588/// runner computed (`$p.ss`, `$p.ws`). On a mismatch it writes one fixed
589/// sentence to stderr and exits 97
590/// ([`STUB_MISMATCH_EXIT`](super::remote::STUB_MISMATCH_EXIT)) before it runs
591/// anything. Otherwise it runs
592/// the script text it hashed through `[ScriptBlock]::Create` (the execution
593/// policy does not govern a script block, so the same stub works under
594/// `Restricted` and `AllSigned`), passing the operation, the three roots and
595/// the text of the work list it hashed, so that the list the script acts on is
596/// the list that was checked. No path, hash or reason is part of the body:
597/// they travel in the base64 JSON parameters.
598pub const STUB: &str = concat!(
599    "$q='\\\\?\\';",
600    "$b=[IO.File]::ReadAllBytes($q+$p.t+'\\commit.ps1');",
601    "$w=[IO.File]::ReadAllBytes($q+$p.t+'\\work.tsv');",
602    "$a=[Security.Cryptography.SHA256]::Create();",
603    "$x=[BitConverter]::ToString($a.ComputeHash($b)).Replace('-','').ToLower();",
604    "$y=[BitConverter]::ToString($a.ComputeHash($w)).Replace('-','').ToLower();",
605    "if($x -cne $p.ss -or $y -cne $p.ws){",
606    "[Console]::Error.WriteLine('ign: the staged script or work list does not match its hash');exit 97};",
607    "& ([ScriptBlock]::Create([Text.Encoding]::UTF8.GetString($b))) ",
608    "$p.o $p.t $p.c $p.d ([Text.Encoding]::UTF8.GetString($w))"
609);
610
611/// The [`WinScript`] that verifies the uploaded `commit.ps1` and `work.tsv`
612/// against `script_sha256` and `work_sha256` and then runs the script's `op`
613/// over `roots` ([`STUB`]). The staging directory of `roots` must hold both
614/// files. Paths travel only in the parameters; the command is held to
615/// [`COMMAND_BUDGET`] by `exec_powershell`.
616pub fn script_stub(
617    script_sha256: &str,
618    work_sha256: &str,
619    op: &str,
620    roots: &StubRoots,
621) -> WinScript {
622    WinScript {
623        name: "script_stub",
624        body: STUB,
625        params: serde_json::json!({
626            "t": roots.staging,
627            "c": roots.collection,
628            "d": roots.deploy,
629            "o": op,
630            "ss": script_sha256,
631            "ws": work_sha256,
632        }),
633    }
634}
635
636/// Cuts `line` every 119 characters with no indent: how the capture host's
637/// PowerShell 5.1 wraps an error line (22-LIVE-CAPTURES.md line 7223, chunks
638/// of 119, 119, 119 and 1 character).
639#[cfg(test)]
640fn ps51_wrap(line: &str) -> Vec<String> {
641    let chars: Vec<char> = line.chars().collect();
642    chars
643        .chunks(119)
644        .map(|piece| piece.iter().collect())
645        .collect()
646}
647
648/// A `+ ...` line of an error record: the text is split into 114-character
649/// pieces, each piece gets 4 spaces in front and the pieces are joined with
650/// nothing between. Cut by [`ps51_wrap`], a 123-character CategoryInfo text
651/// gives a 119-character chunk ending in a space and then a chunk of 3 spaces
652/// and the rest (capture line 7223).
653#[cfg(test)]
654fn ps51_info_line(text: &str) -> String {
655    let chars: Vec<char> = text.chars().collect();
656    chars
657        .chunks(114)
658        .map(|piece| format!("    {}", piece.iter().collect::<String>()))
659        .collect()
660}
661
662/// The CategoryInfo target: the whole text when it has 40 characters or
663/// fewer, otherwise its first 15 characters, `...` and its last 15 (the 15/15
664/// cut is on capture line 7223; the 40-character threshold is in no capture).
665#[cfg(test)]
666fn ps51_ellipsize(target: &str) -> String {
667    let chars: Vec<char> = target.chars().collect();
668    if chars.len() <= 40 {
669        return target.to_string();
670    }
671    let head: String = chars[..15].iter().collect();
672    let tail: String = chars[chars.len() - 15..].iter().collect();
673    format!("{head}...{tail}")
674}
675
676/// The CLIXML stderr of Windows PowerShell 5.1 for `throw '<thrown>'`, in the
677/// shape captured on the capture host: `#< CLIXML`, CRLF, the `<Objs>` frame
678/// and one `<S S="Error">` chunk per output line, each ending
679/// `_x000D__x000A_`. Checked byte for byte against the records of
680/// 22-LIVE-CAPTURES.md lines 4751-4752 and 4767-4768 (a thrown string) and
681/// line 7223 (the 119-column wrap, the wrapped `+` line and the target
682/// ellipsis) by `ps51_error_clixml_matches_the_captured_records`.
683///
684/// One part stays a model: a FullyQualifiedErrorId line longer than one
685/// 114-character piece has no capture. The live Add-Type cause test proves it,
686/// because its thrown text spans three pieces on a real host.
687#[cfg(test)]
688pub(crate) fn ps51_error_clixml(thrown: &str) -> Vec<u8> {
689    let mut lines = ps51_wrap(thrown);
690    lines.push("At line:1 char:1200".to_string());
691    lines.push("+ ... $n -ge [int]$p.retries){throw ('the record replace ...".to_string());
692    lines.push(format!("+ {}{}", " ".repeat(30), "~".repeat(12)));
693    lines.extend(ps51_wrap(&ps51_info_line(&format!(
694        "+ CategoryInfo          : OperationStopped: ({}:String) [], RuntimeException",
695        ps51_ellipsize(thrown)
696    ))));
697    lines.extend(ps51_wrap(&ps51_info_line(&format!(
698        "+ FullyQualifiedErrorId : {thrown}"
699    ))));
700    lines.push(" ".to_string());
701    let mut out = String::from(
702        "#< CLIXML\r\n<Objs Version=\"1.1.0.1\" xmlns=\"http://schemas.microsoft.com/powershell/2004/04\">",
703    );
704    for line in &lines {
705        let escaped = line
706            .replace('&', "&amp;")
707            .replace('<', "&lt;")
708            .replace('>', "&gt;");
709        out.push_str(&format!("<S S=\"Error\">{escaped}_x000D__x000A_</S>"));
710    }
711    out.push_str("</Objs>");
712    out.into_bytes()
713}
714
715#[cfg(test)]
716mod tests {
717    use super::*;
718    use base64::Engine as _;
719
720    fn decode_command(command: &RemoteCommand) -> String {
721        let text = command.as_str();
722        let b64 = text
723            .strip_prefix("powershell -NoProfile -NonInteractive -EncodedCommand ")
724            .expect("the fixed powershell prefix");
725        let bytes = base64::engine::general_purpose::STANDARD
726            .decode(b64)
727            .expect("base64");
728        assert_eq!(bytes.len() % 2, 0, "UTF-16LE has an even length");
729        let units: Vec<u16> = bytes
730            .chunks(2)
731            .map(|c| u16::from_le_bytes([c[0], c[1]]))
732            .collect();
733        String::from_utf16(&units).expect("utf-16")
734    }
735
736    fn script(params: Value) -> WinScript {
737        WinScript {
738            name: "t",
739            body: "E @{ok=1}",
740            params,
741        }
742    }
743
744    #[test]
745    fn encoded_command_is_utf16le_base64() {
746        let command = encoded_command(&script(Value::Null), COMMAND_BUDGET).expect("command");
747        let text = decode_command(&command);
748        assert_eq!(text, format!("{PRELUDE}E @{{ok=1}}"));
749        assert!(
750            text.starts_with(
751                "$ProgressPreference='SilentlyContinue';$ErrorActionPreference='Stop';"
752            )
753        );
754        // only base64 characters reach the shell after the fixed prefix
755        let b64 = command
756            .as_str()
757            .strip_prefix("powershell -NoProfile -NonInteractive -EncodedCommand ")
758            .expect("prefix");
759        assert!(
760            b64.bytes()
761                .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'/' | b'='))
762        );
763    }
764
765    #[test]
766    fn parameters_travel_as_a_base64_json_blob_never_interpolated() {
767        let hostile = "C:\\a b\\'; Remove-Item -Recurse C:\\ ; '$(whoami)`\"";
768        let command = encoded_command(
769            &script(serde_json::json!({ "path": hostile })),
770            COMMAND_BUDGET,
771        )
772        .expect("command");
773        let text = decode_command(&command);
774        assert!(
775            !text.contains("Remove-Item"),
776            "the hostile text must not appear in the script: {text}"
777        );
778        assert!(!text.contains("whoami"));
779        let blob = text
780            .split("FromBase64String('")
781            .nth(1)
782            .and_then(|rest| rest.split('\'').next())
783            .expect("blob");
784        let json = base64::engine::general_purpose::STANDARD
785            .decode(blob)
786            .expect("blob base64");
787        let value: Value = serde_json::from_slice(&json).expect("json");
788        assert_eq!(value["path"], hostile);
789        // parameters must be an object
790        for bad in [
791            serde_json::json!([1]),
792            serde_json::json!("x"),
793            serde_json::json!(3),
794        ] {
795            assert!(encoded_command(&script(bad), COMMAND_BUDGET).is_err());
796        }
797    }
798
799    #[test]
800    fn encoded_command_budget() {
801        let exact = encoded_command(&script(Value::Null), COMMAND_BUDGET).expect("fits");
802        let length = exact.as_str().chars().count();
803        assert!(encoded_command(&script(Value::Null), length).is_ok());
804        let err = encoded_command(&script(Value::Null), length - 1).expect_err("one over");
805        assert!(err.contains("over the") && err.contains("budget"), "{err}");
806
807        // a parameter blob too big for the budget is refused before any spawn
808        let big = "x".repeat(COMMAND_BUDGET);
809        assert!(encoded_command(&script(serde_json::json!({ "p": big })), COMMAND_BUDGET).is_err());
810        // the shipped scripts leave room for their parameters
811        let enumerate = encoded_command(
812            &WinScript::enumerate(
813                LONG_PATH_PREFIX,
814                &[
815                    "C:\\Program Files\\Inductive Automation\\Ignition\\data\\config\\resources\\production\\ignition\\tag-definition\\provider".to_string(),
816                ],
817            ),
818            COMMAND_BUDGET,
819        )
820        .expect("one realistic root fits");
821        assert!(enumerate.as_str().len() < COMMAND_BUDGET);
822    }
823
824    #[test]
825    fn enumerate_chunks_split_by_budget_and_cover_every_root() {
826        let root = |i: usize| {
827            format!(
828                "C:\\Program Files\\Inductive Automation\\Ignition\\data\\config\\resources\\production\\ignition\\tag-definition\\provider-{i:04}"
829            )
830        };
831        let roots: Vec<String> = (0..60).map(root).collect();
832        let chunks = enumerate_chunks(LONG_PATH_PREFIX, &roots).expect("chunks");
833        assert!(chunks.len() >= 2, "60 long roots cannot share one command");
834        let mut next = 0;
835        for (range, script) in &chunks {
836            assert_eq!(range.start, next, "contiguous");
837            next = range.end;
838            assert!(
839                encoded_command(script, COMMAND_BUDGET).is_ok(),
840                "each chunk fits the budget"
841            );
842        }
843        assert_eq!(next, roots.len(), "every root is covered");
844        let huge = vec!["C:\\".to_string() + &"d".repeat(COMMAND_BUDGET)];
845        assert!(enumerate_chunks(LONG_PATH_PREFIX, &huge).is_err());
846        assert!(
847            enumerate_chunks(LONG_PATH_PREFIX, &[])
848                .expect("none")
849                .is_empty()
850        );
851    }
852
853    /// T-25-40, T-25-46, T-25-42: the record replace is fixed one-line ASCII
854    /// text that asserts FullLanguage, declares the `MoveFileEx` P/Invoke once
855    /// inside a try whose catch throws, never names `Move-Item` or a path, and
856    /// takes its paths and retry count only from the JSON blob.
857    #[test]
858    fn record_replace_is_fixed_fail_closed_text() {
859        let body = RECORD_REPLACE;
860        assert!(!body.is_empty());
861        assert!(
862            body.bytes().all(|b| (0x20..=0x7e).contains(&b)),
863            "one line of printable ASCII"
864        );
865        assert!(!body.contains("ign-deploy") && !body.contains("C:\\"));
866        assert!(
867            !body.contains("Move-Item"),
868            "not the delete-then-rename cmdlet"
869        );
870        assert!(body.contains("$ExecutionContext.SessionState.LanguageMode -ne 'FullLanguage'"));
871        assert!(body.contains("MoveFileEx(string a,string b,uint c)"));
872        assert!(body.contains("kernel32.dll") && body.contains("SetLastError=true"));
873        assert!(
874            body.contains("0x1 -bor 0x8"),
875            "REPLACE_EXISTING | WRITE_THROUGH"
876        );
877        assert!(body.contains("GetLastWin32Error"));
878        assert!(body.contains("$p.src") && body.contains("$p.dst") && body.contains("$p.retries"));
879        // the type-exists guard comes first and there is a single Add-Type
880        let guard = body.find("'IgnRecord.Native' -as [type]").expect("guard");
881        assert_eq!(body.matches("Add-Type").count(), 1);
882        let add = body.find("Add-Type").expect("Add-Type");
883        assert!(guard < add, "the guard precedes the declaration");
884        let try_at = body[..add].rfind("try{").expect("try before Add-Type");
885        let catch_at = body[add..].find("catch{throw ").expect("catch rethrows") + add;
886        assert!(guard < try_at && try_at < add && add < catch_at);
887
888        // sized for realistic account-home paths, plain and accented
889        let long = format!("C:\\Users\\svc\\{}", "d".repeat(227));
890        assert_eq!(long.len(), 240);
891        let accented = format!("C:\\Users\\sv\u{e7}\\{}", "\u{e9}".repeat(110));
892        for path in [&long, &accented] {
893            assert!(
894                encoded_command(
895                    &WinScript::record_replace(
896                        path,
897                        path,
898                        crate::deploy::transport::batch::RENAME_RETRIES
899                    ),
900                    COMMAND_BUDGET
901                )
902                .is_ok(),
903                "fits the budget"
904            );
905        }
906
907        // the paths and the count reach the script only through the blob
908        let (src, dst) = ("C:\\h\\a.tmp-r1", "C:\\h\\a.json");
909        let split = |retries: u32| {
910            let text = decode_command(
911                &encoded_command(
912                    &WinScript::record_replace(src, dst, retries),
913                    COMMAND_BUDGET,
914                )
915                .expect("command"),
916            );
917            let blob = text
918                .split("FromBase64String('")
919                .nth(1)
920                .and_then(|rest| rest.split('\'').next())
921                .expect("blob")
922                .to_string();
923            let json = base64::engine::general_purpose::STANDARD
924                .decode(&blob)
925                .expect("blob base64");
926            let value: Value = serde_json::from_slice(&json).expect("json");
927            (text.replace(&blob, "<blob>"), value)
928        };
929        let (text3, value3) = split(3);
930        let (text7, value7) = split(7);
931        assert_eq!(
932            value3,
933            serde_json::json!({ "src": src, "dst": dst, "retries": 3 })
934        );
935        assert_eq!(
936            value7,
937            serde_json::json!({ "src": src, "dst": dst, "retries": 7 })
938        );
939        assert!(value3["retries"].is_number());
940        assert_eq!(
941            text3, text7,
942            "the text outside the blob does not depend on the count"
943        );
944        assert!(text3.contains("$p.retries"));
945        assert!(!text3.contains(src) && !text3.contains(dst));
946        assert!(text3.ends_with(RECORD_REPLACE));
947    }
948
949    /// Whitespace-free form: PowerShell's 119-column wrap can fall anywhere
950    /// and `clixml_errors` trims each chunk, so texts are compared squashed.
951    fn squash(text: &str) -> String {
952        text.chars().filter(|c| !c.is_whitespace()).collect()
953    }
954
955    /// T-25-46, T-25-67, T-25-68: every thrown text is a bounded, single-line,
956    /// printable-ASCII sentence built from the three fixed prefixes, and the
957    /// 25-20 mechanism is still there byte for byte.
958    #[test]
959    fn record_replace_cause_is_bounded_single_line_ascii() {
960        let body = RECORD_REPLACE;
961        assert!(
962            body.bytes().all(|b| (0x20..=0x7e).contains(&b)),
963            "one line of printable ASCII"
964        );
965        let prefixes = [
966            "the record replace needs a FullLanguage PowerShell session, not ",
967            "the record replace cannot declare MoveFileEx: ",
968            "the record replace failed with Win32 error ",
969        ];
970        for prefix in prefixes {
971            assert!(body.contains(prefix), "{prefix}");
972            // the cause is at most 200 characters + `...`, the Win32 number
973            // at most 11 characters + `: ` (the language mode is at most 19)
974            assert!(prefix.len() + 203 + 13 <= 300, "{prefix}");
975        }
976        let language = body
977            .find("$ExecutionContext.SessionState.LanguageMode -ne 'FullLanguage'")
978            .expect("language check");
979        let line = body.find("function IgnLine").expect("IgnLine");
980        let cause = body.find("function IgnCause").expect("IgnCause");
981        let guard = body.find("'IgnRecord.Native' -as [type]").expect("guard");
982        assert!(language < line && line < cause && cause < guard);
983        let cause_body = &body[cause..guard];
984        assert!(cause_body.contains("try{") && cause_body.contains("catch{"));
985        assert!(
986            cause_body.contains("catch{'the cause could not be read'}"),
987            "a failing helper returns a fixed sentence"
988        );
989        assert!(!body[line..guard].contains("Add-Type"));
990        // the cause is concatenated into a string and never evaluated
991        let lower = body.to_ascii_lowercase();
992        for banned in ["invoke-expression", "iex ", "scriptblock", "[scriptblock]"] {
993            assert!(!lower.contains(banned), "{banned}");
994        }
995        // 25-20 mechanism segments, unchanged
996        for segment in [
997            "if(-not ('IgnRecord.Native' -as [type])){try{Add-Type -Namespace IgnRecord -Name Native -MemberDefinition '[DllImport(\"kernel32.dll\",SetLastError=true,CharSet=CharSet.Unicode)]public static extern bool MoveFileEx(string a,string b,uint c);'}catch{throw ",
998            "$f=[uint32](0x1 -bor 0x8);$n=0;",
999            "while(-not [IgnRecord.Native]::MoveFileEx($p.src,$p.dst,$f)){$c=[Runtime.InteropServices.Marshal]::GetLastWin32Error();",
1000            "if(($c -ne 32 -and $c -ne 5) -or $n -ge [int]$p.retries){throw ",
1001            "$n++;Start-Sleep -Seconds 1};E @{ok=1}",
1002        ] {
1003            assert!(body.contains(segment), "{segment}");
1004        }
1005        assert!(body.ends_with("$n++;Start-Sleep -Seconds 1};E @{ok=1}"));
1006        // no failure branch reaches the success line other than the loop exit
1007        assert_eq!(body.matches("E @{ok=1}").count(), 1);
1008    }
1009
1010    /// A thrown text of exactly `len` characters, made of numbered words.
1011    fn thrown_text(len: usize, special: bool) -> String {
1012        let mut text = String::from("the record replace failed with Win32 error 32: ");
1013        if special {
1014            text.push_str("<path> it's a & b ");
1015        }
1016        let mut n = 0;
1017        while text.len() < len {
1018            text.push_str(&format!("w{n} "));
1019            n += 1;
1020        }
1021        text.truncate(len);
1022        text
1023    }
1024
1025    /// WR-01: the cause survives the captured PowerShell 5.1 error record and
1026    /// the 5-line stderr tail, whole, up to the 300-character bound.
1027    #[test]
1028    fn record_replace_cause_survives_the_clixml_tail() {
1029        let failed = |thrown: &str| -> String {
1030            let stderr = ps51_error_clixml(thrown);
1031            let output = ProcessOutput {
1032                stdout: Vec::new(),
1033                stderr,
1034                code: 1,
1035                timed_out: false,
1036            };
1037            interpret_output(&output, 60, None).expect_err("a non-zero exit is an error")
1038        };
1039        for (len, special) in [(60, false), (150, false), (150, true), (299, true)] {
1040            let thrown = thrown_text(len, special);
1041            assert_eq!(thrown.len(), len);
1042            let detail = failed(&thrown);
1043            assert!(
1044                squash(&detail).contains(&squash(&thrown)),
1045                "{len}: {detail}"
1046            );
1047            assert!(
1048                !detail.contains("<S S=") && !detail.contains("&lt;"),
1049                "{detail}"
1050            );
1051            if special {
1052                assert!(
1053                    detail.contains("<path>") && detail.contains('&'),
1054                    "{detail}"
1055                );
1056            }
1057        }
1058        // the boundary: exactly 300 characters fills the 5-line tail and is whole
1059        let edge = thrown_text(300, false);
1060        assert_eq!(edge.chars().count(), 300);
1061        let detail = failed(&edge);
1062        assert!(squash(&detail).contains(&squash(&edge)), "{detail}");
1063        let tail = detail
1064            .strip_prefix("exit code 1: ")
1065            .expect("exit code prefix");
1066        assert_eq!(tail.lines().count(), 5, "{detail}");
1067        // control: past the bound the tail cuts, which is why the bound matters
1068        let long = thrown_text(700, false);
1069        let detail = failed(&long);
1070        assert!(!squash(&detail).contains(&squash(&long)), "{detail}");
1071    }
1072
1073    // The records below are copied byte for byte from
1074    // .planning/phases/22-transport-plan-pull-verify/22-LIVE-CAPTURES.md. The
1075    // capture tool removed one CR from each, so the raw bytes are the
1076    // `#< CLIXML` line, CRLF, then the `<Objs>` line with no trailing newline.
1077    /// Lines 4751-4752: `throw 'boom'`, bare.
1078    const CAPTURED_BOOM_BARE: &str = concat!(
1079        "#< CLIXML",
1080        "\r\n",
1081        r#"<Objs Version="1.1.0.1" xmlns="http://schemas.microsoft.com/powershell/2004/04"><S S="Error">boom_x000D__x000A_</S><S S="Error">At line:1 char:1_x000D__x000A_</S><S S="Error">+ throw 'boom'_x000D__x000A_</S><S S="Error">+ ~~~~~~~~~~~~_x000D__x000A_</S><S S="Error">    + CategoryInfo          : OperationStopped: (boom:String) [], RuntimeException_x000D__x000A_</S><S S="Error">    + FullyQualifiedErrorId : boom_x000D__x000A_</S><S S="Error"> _x000D__x000A_</S></Objs>"#
1082    );
1083    /// Lines 4767-4768: `throw 'boom'` after the prelude.
1084    const CAPTURED_BOOM_PRELUDE: &str = concat!(
1085        "#< CLIXML",
1086        "\r\n",
1087        r#"<Objs Version="1.1.0.1" xmlns="http://schemas.microsoft.com/powershell/2004/04"><S S="Error">boom_x000D__x000A_</S><S S="Error">At line:1 char:72_x000D__x000A_</S><S S="Error">+ ... rence='SilentlyContinue'; $ErrorActionPreference='Stop'; throw 'boom'_x000D__x000A_</S><S S="Error">+                                                              ~~~~~~~~~~~~_x000D__x000A_</S><S S="Error">    + CategoryInfo          : OperationStopped: (boom:String) [], RuntimeException_x000D__x000A_</S><S S="Error">    + FullyQualifiedErrorId : boom_x000D__x000A_</S><S S="Error"> _x000D__x000A_</S></Objs>"#
1088    );
1089    /// Line 7223 alone: a Get-ChildItem error on a long path.
1090    const CAPTURED_WRAPPED: &str = r#"<Objs Version="1.1.0.1" xmlns="http://schemas.microsoft.com/powershell/2004/04"><S S="Error">Get-ChildItem : Could not find a part of the path 'C:\Program Files\Inductive Automation\Ignition\data\config\resources_x000D__x000A_</S><S S="Error">\core\ignition\tag-definition\CAP22H\LLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLL_x000D__x000A_</S><S S="Error">LLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLL'_x000D__x000A_</S><S S="Error">._x000D__x000A_</S><S S="Error">At line:1 char:72_x000D__x000A_</S><S S="Error">+ ... nce='Stop'; Get-ChildItem -LiteralPath (Join-Path 'C:\Program Files\I ..._x000D__x000A_</S><S S="Error">+                 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~_x000D__x000A_</S><S S="Error">    + CategoryInfo          : ReadError: (C:\Program File...LLLLLLLLLLLLLLL:String) [Get-ChildItem], DirectoryNotFound _x000D__x000A_</S><S S="Error">   Exception_x000D__x000A_</S><S S="Error">    + FullyQualifiedErrorId : DirIOError,Microsoft.PowerShell.Commands.GetChildItemCommand_x000D__x000A_</S><S S="Error"> _x000D__x000A_</S></Objs>"#;
1091
1092    /// The raw `<S S="Error">` chunk bodies of a CLIXML record, with the
1093    /// `_x000D__x000A_` ends left on.
1094    fn raw_chunks(clixml: &str) -> Vec<String> {
1095        clixml
1096            .split("<S S=\"Error\">")
1097            .skip(1)
1098            .map(|piece| piece.split("</S>").next().expect("chunk").to_string())
1099            .collect()
1100    }
1101
1102    const CRLF_ENTITY: &str = "_x000D__x000A_";
1103
1104    /// The offline proof that `ps51_error_clixml` is the PowerShell 5.1 shape
1105    /// and not only a model of it: the cause-survival test and the writer
1106    /// mock test rest on it (D-03, D-06, D-12).
1107    #[test]
1108    fn ps51_error_clixml_matches_the_captured_records() {
1109        for captured in [CAPTURED_BOOM_BARE, CAPTURED_BOOM_PRELUDE] {
1110            let built = String::from_utf8(ps51_error_clixml("boom")).expect("utf-8");
1111            let head = |text: &str| text.split("<S S=").next().expect("head").to_string();
1112            assert_eq!(head(&built), head(captured), "framing through <Objs>");
1113            assert!(built.ends_with("</Objs>") && captured.ends_with("</Objs>"));
1114            assert!(!built.ends_with('\n') && !captured.ends_with('\n'));
1115            let (mine, theirs) = (raw_chunks(&built), raw_chunks(captured));
1116            assert_eq!((mine.len(), theirs.len()), (7, 7));
1117            for at in [0, 4, 5, 6] {
1118                assert_eq!(mine[at], theirs[at], "chunk {}", at + 1);
1119                assert!(mine[at].ends_with(CRLF_ENTITY));
1120            }
1121            // chunks 2 to 4 depend on the script position: the form only
1122            for chunk in [&mine, &theirs] {
1123                let digits = chunk[1]
1124                    .strip_prefix("At line:1 char:")
1125                    .and_then(|rest| rest.strip_suffix(CRLF_ENTITY))
1126                    .expect("At line form");
1127                assert!(!digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit()));
1128                assert!(chunk[2].starts_with("+ ") && chunk[2].ends_with(CRLF_ENTITY));
1129                let marks = chunk[3]
1130                    .strip_prefix('+')
1131                    .and_then(|rest| rest.strip_suffix(CRLF_ENTITY))
1132                    .expect("underline form");
1133                assert!(marks.chars().all(|c| c == ' ' || c == '~'));
1134                assert!(marks.contains('~'));
1135            }
1136            let last_two = |text: &str| {
1137                let errors = clixml_errors(text.as_bytes());
1138                errors[errors.len() - 2..].to_vec()
1139            };
1140            assert_eq!(last_two(&built), last_two(captured));
1141            assert_eq!(
1142                last_two(captured),
1143                [
1144                    "+ CategoryInfo          : OperationStopped: (boom:String) [], RuntimeException",
1145                    "+ FullyQualifiedErrorId : boom",
1146                ]
1147            );
1148        }
1149
1150        // line 7223: the message wrap, the wrapped + line, the ellipsis
1151        let captured = raw_chunks(CAPTURED_WRAPPED);
1152        assert_eq!(captured.len(), 11);
1153        let bare: Vec<String> = captured
1154            .iter()
1155            .map(|chunk| chunk.strip_suffix(CRLF_ENTITY).expect("end").to_string())
1156            .collect();
1157        let message = bare[..4].concat();
1158        assert_eq!(
1159            bare[..4]
1160                .iter()
1161                .map(|c| c.chars().count())
1162                .collect::<Vec<_>>(),
1163            [119, 119, 119, 1]
1164        );
1165        assert_eq!(ps51_wrap(&message), bare[..4]);
1166        let quoted = message
1167            .split_once("the path '")
1168            .and_then(|(_, rest)| rest.strip_suffix("'."))
1169            .expect("quoted path");
1170        let ellipsis = ps51_ellipsize(quoted);
1171        assert_eq!(ellipsis, "C:\\Program File...LLLLLLLLLLLLLLL");
1172        let category = format!(
1173            "+ CategoryInfo          : ReadError: ({ellipsis}:String) [Get-ChildItem], DirectoryNotFoundException"
1174        );
1175        assert_eq!(ps51_wrap(&ps51_info_line(&category)), bare[7..9]);
1176        assert_eq!(bare[7].chars().count(), 119);
1177        assert!(bare[7].ends_with(' ') && bare[8].starts_with("   Exception"));
1178        let id = "+ FullyQualifiedErrorId : DirIOError,Microsoft.PowerShell.Commands.GetChildItemCommand";
1179        assert_eq!(ps51_wrap(&ps51_info_line(id)), bare[9..10]);
1180        assert_eq!(bare[10], " ");
1181        // the short-target rule: nothing is cut at 40 characters or fewer
1182        assert_eq!(ps51_ellipsize("boom"), "boom");
1183    }
1184
1185    #[test]
1186    fn parse_ign_result_cases() {
1187        let ok = parse_ign_result(b"noise\r\nIGN-RESULT:{\"ok\":1}\r\n").expect("one line");
1188        assert_eq!(ok["ok"], 1);
1189        // a result with no trailing newline
1190        assert!(parse_ign_result(b"IGN-RESULT:{\"ok\":1}").is_ok());
1191        for (bad, why) in [
1192            (&b""[..], "no line"),
1193            (b"hello\n", "sentinel absent"),
1194            (b"IGN-RESULT:{\"a\":1}\nIGN-RESULT:{\"a\":2}\n", "two lines"),
1195            (b"IGN-RESULT:{nope\n", "invalid JSON"),
1196            (b"IGN-RESULT:\n", "empty payload"),
1197            (
1198                b" IGN-RESULT:{\"ok\":1}\n",
1199                "indented sentinel is not a sentinel line",
1200            ),
1201        ] {
1202            assert!(parse_ign_result(bad).is_err(), "{why} must be an error");
1203        }
1204    }
1205
1206    fn out(code: i32, stdout: &str, stderr: &str, timed_out: bool) -> ProcessOutput {
1207        ProcessOutput {
1208            stdout: stdout.as_bytes().to_vec(),
1209            stderr: stderr.as_bytes().to_vec(),
1210            code,
1211            timed_out,
1212        }
1213    }
1214
1215    const CLIXML_ERROR: &str = "#< CLIXML\r\n<Objs Version=\"1.1.0.1\" xmlns=\"http://schemas.microsoft.com/powershell/2004/04\"><S S=\"Error\">Cannot find path 'C:\\x' because it does not exist._x000D__x000A_</S><S S=\"Error\">At line:1 char:1_x000D__x000A_</S></Objs>";
1216    const CLIXML_PROGRESS: &str = "#< CLIXML\r\n<Objs Version=\"1.1.0.1\" xmlns=\"http://schemas.microsoft.com/powershell/2004/04\"><Obj S=\"progress\" RefId=\"0\"><TN RefId=\"0\"><T>System.Management.Automation.PSCustomObject</T></TN><MS><I64 N=\"SourceId\">1</I64></MS></Obj></Objs>";
1217
1218    #[test]
1219    fn interpret_output_decides_from_exit_code_and_the_sentinel() {
1220        let ok = out(0, "IGN-RESULT:{\"ok\":1}\r\n", "", false);
1221        assert_eq!(interpret_output(&ok, 5, None).expect("ok")["ok"], 1);
1222        // progress CLIXML on stderr does not make a success a failure
1223        let progress = out(0, "IGN-RESULT:{\"ok\":1}\r\n", CLIXML_PROGRESS, false);
1224        assert!(interpret_output(&progress, 5, None).is_ok());
1225
1226        // non-zero exit: failure, with the CLIXML error text decoded for display
1227        let failed = out(1, "", CLIXML_ERROR, false);
1228        let detail = interpret_output(&failed, 5, None).expect_err("exit 1");
1229        assert!(detail.starts_with("exit code 1: "), "{detail}");
1230        assert!(detail.contains("Cannot find path"), "{detail}");
1231        assert!(
1232            !detail.contains("<S S="),
1233            "no raw XML in the detail: {detail}"
1234        );
1235        // exit 0 with an error record on stderr
1236        let recorded = out(0, "IGN-RESULT:{\"ok\":1}\r\n", CLIXML_ERROR, false);
1237        let detail = interpret_output(&recorded, 5, None).expect_err("error record");
1238        assert!(detail.contains("error record"), "{detail}");
1239        // a missing sentinel with exit 0
1240        let silent = out(0, "all good\r\n", "", false);
1241        assert!(interpret_output(&silent, 5, None).is_err());
1242        // a timeout
1243        let timed = out(-1, "", "", true);
1244        assert_eq!(
1245            interpret_output(&timed, 7, None).expect_err("timeout"),
1246            "timed out after 7s and was killed"
1247        );
1248        // the host refusing a long command line (C7a-7)
1249        let long = out(1, "", "The command line is too long.\r\n", false);
1250        let detail = interpret_output(&long, 5, None).expect_err("too long");
1251        assert!(detail.contains("too long"), "{detail}");
1252        // ssh itself failing
1253        let ssh = out(255, "", "exec request failed on channel 0", false);
1254        assert!(
1255            interpret_output(&ssh, 5, None)
1256                .expect_err("255")
1257                .contains("exit code 255")
1258        );
1259    }
1260
1261    #[test]
1262    fn clixml_errors_decode_and_ignore_non_clixml() {
1263        let got = clixml_errors(CLIXML_ERROR.as_bytes());
1264        assert_eq!(got.len(), 2);
1265        assert_eq!(
1266            got[0],
1267            "Cannot find path 'C:\\x' because it does not exist."
1268        );
1269        assert!(clixml_errors(b"plain stderr").is_empty());
1270        assert!(clixml_errors(CLIXML_PROGRESS.as_bytes()).is_empty());
1271    }
1272
1273    #[test]
1274    fn parse_enumeration_cases() {
1275        let value = serde_json::json!({
1276            "ok": 1,
1277            "res": [
1278                { "i": 0, "x": 1, "e": [["a\\b.json", 0, 12, 0, 90], ["a", 1, 0, 0, 80]] },
1279                { "i": 1, "x": 0, "e": [] }
1280            ]
1281        });
1282        let got = parse_enumeration(&value, 2).expect("parse");
1283        let first = got[0].as_ref().expect("root 0 exists");
1284        assert_eq!(first[0].rel, "a/b.json", "backslashes become slashes");
1285        assert!(!first[0].dir && first[0].size == 12 && !first[0].reparse);
1286        assert!(first[1].dir);
1287        assert!(got[1].is_none(), "a missing root");
1288        for bad in [
1289            serde_json::json!({ "ok": 0, "res": [] }),
1290            serde_json::json!({ "ok": 1 }),
1291            serde_json::json!({ "ok": 1, "res": [] }),
1292            serde_json::json!({ "ok": 1, "res": [{ "i": 5, "x": 1, "e": [] }, { "i": 1, "x": 1, "e": [] }] }),
1293            serde_json::json!({ "ok": 1, "res": [{ "i": 0, "x": 1, "e": [["a"]] }, { "i": 1, "x": 0 }] }),
1294        ] {
1295            assert!(parse_enumeration(&bad, 2).is_err(), "{bad}");
1296        }
1297    }
1298
1299    /// The PowerShell the tests can run: `pwsh` on the dev Mac and CI,
1300    /// `powershell` on Windows. Fails loudly, never skips.
1301    fn powershell() -> std::path::PathBuf {
1302        for name in ["pwsh", "powershell", "powershell.exe"] {
1303            let Some(paths) = std::env::var_os("PATH") else {
1304                continue;
1305            };
1306            for dir in std::env::split_paths(&paths) {
1307                let candidate = dir.join(name);
1308                if candidate.is_file() {
1309                    return candidate;
1310                }
1311            }
1312        }
1313        for fixed in [
1314            "/usr/local/bin/pwsh",
1315            "/opt/homebrew/bin/pwsh",
1316            "/usr/bin/pwsh",
1317        ] {
1318            if std::path::Path::new(fixed).is_file() {
1319                return fixed.into();
1320            }
1321        }
1322        panic!(
1323            "no pwsh or powershell on PATH: install PowerShell 7 (macOS: `brew install --cask powershell`; Debian/Ubuntu: the Microsoft packages) so the script-logic tests can run. These tests are never skipped."
1324        );
1325    }
1326
1327    /// Run `script` for real: the same text `-EncodedCommand` carries, as
1328    /// `-Command`. Returns the output.
1329    fn run_script(script: &WinScript) -> std::process::Output {
1330        let command = encoded_command(script, COMMAND_BUDGET).expect("command");
1331        let encoded = command
1332            .as_str()
1333            .strip_prefix("powershell -NoProfile -NonInteractive -EncodedCommand ")
1334            .expect("prefix");
1335        std::process::Command::new(powershell())
1336            .args(["-NoProfile", "-NonInteractive", "-EncodedCommand", encoded])
1337            .stdin(std::process::Stdio::null())
1338            .output()
1339            .expect("run powershell")
1340    }
1341
1342    #[test]
1343    fn winps_script_logic() {
1344        let dir = tempfile::tempdir().expect("tempdir");
1345        let src = dir.path().join("src file.txt");
1346        let dst = dir.path().join("dst; echo 'hi' $(x).txt");
1347        std::fs::write(&src, b"new").expect("src");
1348        std::fs::write(&dst, b"old").expect("dst");
1349
1350        // MOVE_REPLACE replaces an existing target and prints the sentinel
1351        let moved = run_script(&WinScript::move_replace(
1352            src.to_str().expect("utf8"),
1353            dst.to_str().expect("utf8"),
1354        ));
1355        assert!(moved.status.success(), "{moved:?}");
1356        let value = parse_ign_result(&moved.stdout).expect("sentinel");
1357        assert_eq!(value["ok"], 1);
1358        assert_eq!(std::fs::read(&dst).expect("dst"), b"new");
1359        assert!(!src.exists(), "the source was moved");
1360
1361        // a missing source exits non-zero and prints no sentinel
1362        let missing = run_script(&WinScript::move_replace(
1363            dir.path().join("nope").to_str().expect("utf8"),
1364            dst.to_str().expect("utf8"),
1365        ));
1366        assert!(!missing.status.success(), "a missing source must fail");
1367        assert!(parse_ign_result(&missing.stdout).is_err());
1368
1369        // a script that exits 7 propagates its code
1370        let seven = run_script(&WinScript {
1371            name: "seven",
1372            body: "exit 7",
1373            params: Value::Null,
1374        });
1375        assert_eq!(seven.status.code(), Some(7));
1376
1377        // non-ASCII text leaves the host as \u escapes, ASCII only
1378        let unicode = run_script(&WinScript {
1379            name: "unicode",
1380            body: "E @{s=\"caf\u{e9} \u{1f600}\"}",
1381            params: Value::Null,
1382        });
1383        assert!(unicode.status.success(), "{unicode:?}");
1384        assert!(unicode.stdout.is_ascii(), "stdout must be ASCII");
1385        let value = parse_ign_result(&unicode.stdout).expect("sentinel");
1386        assert_eq!(value["s"], "caf\u{e9} \u{1f600}");
1387    }
1388
1389    #[test]
1390    fn winps_enumerate_reports_files_dirs_and_reparse_points() {
1391        let dir = tempfile::tempdir().expect("tempdir");
1392        let root = dir.path().join("root");
1393        std::fs::create_dir_all(root.join("a b").join("c")).expect("dirs");
1394        std::fs::write(root.join("top.json"), b"12345").expect("file");
1395        std::fs::write(root.join("a b").join("caf\u{e9}.json"), b"xyz").expect("file");
1396        std::fs::write(root.join("a b").join("c").join("deep.json"), b"").expect("file");
1397        let absent = dir.path().join("absent");
1398        let run = |roots: &[String]| {
1399            let out = run_script(&WinScript::enumerate("", roots));
1400            assert!(out.status.success(), "{out:?}");
1401            parse_enumeration(
1402                &parse_ign_result(&out.stdout).expect("sentinel"),
1403                roots.len(),
1404            )
1405            .expect("enumeration")
1406        };
1407        let roots = vec![
1408            root.to_str().expect("utf8").to_string(),
1409            absent.to_str().expect("utf8").to_string(),
1410        ];
1411        let got = run(&roots);
1412        let mut entries: Vec<(String, bool, u64, bool)> = got[0]
1413            .as_ref()
1414            .expect("root exists")
1415            .iter()
1416            .map(|e| (e.rel.clone(), e.dir, e.size, e.reparse))
1417            .collect();
1418        entries.sort();
1419        assert_eq!(
1420            entries,
1421            [
1422                ("a b".to_string(), true, 0, false),
1423                ("a b/c".to_string(), true, 0, false),
1424                ("a b/c/deep.json".to_string(), false, 0, false),
1425                ("a b/caf\u{e9}.json".to_string(), false, 3, false),
1426                ("top.json".to_string(), false, 5, false),
1427            ]
1428        );
1429        assert!(got[1].is_none(), "a missing root is reported as missing");
1430
1431        // an empty root exists and has no entries
1432        let empty = dir.path().join("empty");
1433        std::fs::create_dir(&empty).expect("empty");
1434        let got = run(&[empty.to_str().expect("utf8").to_string()]);
1435        assert_eq!(got[0].as_ref().expect("exists").len(), 0);
1436
1437        // a symlink is a reparse point on every platform pwsh runs on
1438        #[cfg(unix)]
1439        {
1440            std::os::unix::fs::symlink(&root, root.join("link")).expect("symlink");
1441            let got = run(&[root.to_str().expect("utf8").to_string()]);
1442            let link = got[0]
1443                .as_ref()
1444                .expect("exists")
1445                .iter()
1446                .find(|e| e.rel == "link")
1447                .expect("the link is reported");
1448            // on macOS/Linux pwsh the ReparsePoint attribute is set for symlinks
1449            assert!(link.reparse, "a symlink is flagged: {link:?}");
1450        }
1451    }
1452
1453    /// G-22-1: the relative paths come from the walk, not from cutting the
1454    /// caller's spelling of the root off each `FullName`. .NET rewrites a root
1455    /// it normalises: it expands an 8.3 component (`RUNNER~1` becomes
1456    /// `runneradmin` under the Windows runner's `%TEMP%`, 3 characters longer)
1457    /// and drops `.`, doubled separators and a trailing separator, so a length
1458    /// taken from the caller's string shifts every entry (`ot\a b` for `a b`).
1459    #[test]
1460    fn winps_enumerate_rel_paths_do_not_depend_on_the_root_spelling() {
1461        let dir = tempfile::tempdir().expect("tempdir");
1462        let root = dir.path().join("root");
1463        std::fs::create_dir_all(root.join("a b").join("c")).expect("dirs");
1464        std::fs::write(root.join("top.json"), b"12345").expect("file");
1465        std::fs::write(root.join("a b").join("c").join("deep.json"), b"").expect("file");
1466        let base = dir.path().to_str().expect("utf8");
1467        let sep = std::path::MAIN_SEPARATOR;
1468        let expected = [
1469            ("a b".to_string(), true, 0, false),
1470            ("a b/c".to_string(), true, 0, false),
1471            ("a b/c/deep.json".to_string(), false, 0, false),
1472            ("top.json".to_string(), false, 5, false),
1473        ];
1474        for spelling in [
1475            // on the Windows runner this one is the 8.3 `RUNNER~1` path
1476            format!("{base}{sep}root"),
1477            format!("{base}{sep}.{sep}root"),
1478            format!("{base}{sep}{sep}root"),
1479            format!("{base}{sep}root{sep}"),
1480        ] {
1481            let roots = [spelling.clone()];
1482            let out = run_script(&WinScript::enumerate("", &roots));
1483            assert!(out.status.success(), "{spelling}: {out:?}");
1484            let got = parse_enumeration(&parse_ign_result(&out.stdout).expect("sentinel"), 1)
1485                .expect("enumeration");
1486            let mut entries: Vec<(String, bool, u64, bool)> = got[0]
1487                .as_ref()
1488                .expect("root exists")
1489                .iter()
1490                .map(|e| (e.rel.clone(), e.dir, e.size, e.reparse))
1491                .collect();
1492            entries.sort();
1493            assert_eq!(entries, expected, "root spelled {spelling:?}");
1494        }
1495    }
1496
1497    /// Gate parity (post-review): everything the POSIX gate refuses, and the
1498    /// Windows-only ways to escape a directory, are refused as a component.
1499    #[test]
1500    fn windows_component_gate_refuses_what_posix_refuses_and_more() {
1501        for ok in [
1502            "plain",
1503            "MES db,1",
1504            "Caf\u{e9} DB",
1505            ".resources",
1506            "Program Files (x86)",
1507            "a.b",
1508            "%43%4f%4e",
1509            "CONSOLE",
1510            "NULL1",
1511            "COM10",
1512        ] {
1513            assert_eq!(windows_component_problem(ok), None, "{ok:?} is allowed");
1514        }
1515        for bad in [
1516            // the POSIX gate
1517            "",
1518            ".",
1519            "..",
1520            "a/b",
1521            "a\\b",
1522            "a\0b",
1523            "a\nb",
1524            // drive letters and alternate data streams
1525            "C:",
1526            "C:x",
1527            "a:b",
1528            "name:stream",
1529            "name::$DATA",
1530            // \\?\ and UNC prefixes arrive as separators and colons
1531            "\\\\?\\C:",
1532            "?",
1533            // Windows strips a trailing dot or space: two names, one entry
1534            "a.",
1535            "a ",
1536            "a..",
1537            // reserved device names, with or without an extension
1538            "CON",
1539            "con",
1540            "Nul",
1541            "aux.txt",
1542            "PRN.json",
1543            "COM1",
1544            "lpt9.cfg",
1545            // characters Windows forbids
1546            "a<b",
1547            "a>b",
1548            "a|b",
1549            "a\"b",
1550            "a*b",
1551        ] {
1552            assert!(
1553                windows_component_problem(bad).is_some(),
1554                "{bad:?} must be refused"
1555            );
1556        }
1557    }
1558
1559    #[test]
1560    fn windows_absolute_path_gate() {
1561        for ok in [
1562            "/C:/Users/svc",
1563            "/c:/Users/svc/ign-deploy/gw/production/staging/run-1",
1564            "/D:/a b/c",
1565        ] {
1566            assert_eq!(windows_absolute_path_problem(ok), None, "{ok}");
1567        }
1568        for bad in [
1569            "",
1570            "/",
1571            "/C:",
1572            "C:/Users/svc",
1573            "/Users/svc",
1574            "/C:/Users/../x",
1575            "/C:/Users/./x",
1576            "/C:/Users//x",
1577            "/C:/Users/svc/D:/x",
1578            "/C:/Users/svc/x:stream",
1579            "/C:/Users/svc/x.",
1580            "/C:/Users/svc/NUL",
1581            "//server/share/x",
1582            "/C:\\Users",
1583            "/C:/Users/svc\0",
1584            "/\\\\?\\C:/x",
1585            "/CC:/x",
1586        ] {
1587            assert!(
1588                windows_absolute_path_problem(bad).is_some(),
1589                "{bad:?} must be refused"
1590            );
1591        }
1592    }
1593
1594    #[test]
1595    fn parse_enumeration_applies_the_windows_gate_to_every_path() {
1596        let entry = |rel: &str| {
1597            serde_json::json!({
1598                "ok": 1,
1599                "res": [{ "i": 0, "x": 1, "e": [[rel, 0, 1, 0, 50]] }]
1600            })
1601        };
1602        assert!(parse_enumeration(&entry("a\\b.json"), 1).is_ok());
1603        for bad in [
1604            "..\\x",
1605            "a\\..\\b",
1606            "\\\\?\\C:\\x",
1607            "C:\\x",
1608            "/abs",
1609            "a\\\\b",
1610            "a:b\\c",
1611            "a\\b.",
1612            "a\\NUL",
1613            "",
1614            "a\0b",
1615        ] {
1616            assert!(
1617                parse_enumeration(&entry(bad), 1).is_err(),
1618                "{bad:?} must be refused"
1619            );
1620        }
1621    }
1622
1623    // ---- the script stub (23-08) --------------------------------------------
1624
1625    fn roots(staging: &str, collection: &str, deploy: &str) -> StubRoots {
1626        StubRoots {
1627            staging: staging.to_string(),
1628            collection: collection.to_string(),
1629            deploy: deploy.to_string(),
1630        }
1631    }
1632
1633    fn sha(fill: char) -> String {
1634        std::iter::repeat_n(fill, 64).collect()
1635    }
1636
1637    #[test]
1638    fn stub_fits_the_budget() {
1639        // the longest roots a legal node can make: the data directory is held to
1640        // WINDOWS_DATA_DIR_MAX (150) characters and everything below the account
1641        // home stays under LONG_PATH_LIMIT; 259-character roots are past both
1642        for len in [60usize, 150, 240, 259] {
1643            let root = |tag: char| format!("C:\\{}", tag.to_string().repeat(len - 3));
1644            let stub = script_stub(
1645                &sha('a'),
1646                &sha('b'),
1647                "restore",
1648                &roots(&root('s'), &root('c'), &root('d')),
1649            );
1650            let command = encoded_command(&stub, COMMAND_BUDGET)
1651                .unwrap_or_else(|why| panic!("{len}-character roots: {why}"));
1652            eprintln!(
1653                "stub with {len}-character roots: {} characters",
1654                command.as_str().chars().count()
1655            );
1656            assert!(
1657                command.as_str().chars().count() <= COMMAND_BUDGET,
1658                "{len}-character roots"
1659            );
1660        }
1661        // non-ASCII path characters travel as UTF-8 inside the JSON and still fit
1662        let accented = format!("C:\\Users\\{}", "\u{e9}".repeat(100));
1663        let stub = script_stub(
1664            &sha('a'),
1665            &sha('b'),
1666            "commit",
1667            &roots(&accented, &accented, &accented),
1668        );
1669        assert!(encoded_command(&stub, COMMAND_BUDGET).is_ok());
1670    }
1671
1672    #[test]
1673    fn stub_body_is_fixed_text_and_carries_no_path_or_hash() {
1674        let stub = script_stub(
1675            &sha('a'),
1676            &sha('b'),
1677            "commit",
1678            &roots(
1679                "C:\\Users\\svc\\ign-deploy\\gw\\production\\staging\\run-1",
1680                "C:\\Data\\config\\resources\\production",
1681                "C:\\Users\\svc\\ign-deploy\\gw\\production",
1682            ),
1683        );
1684        assert_eq!(stub.body, STUB);
1685        // the body is one line of printable ASCII
1686        assert!(STUB.bytes().all(|b| (0x20..0x7f).contains(&b)), "{STUB}");
1687        assert!(!STUB.contains("ign-deploy") && !STUB.contains("Users"));
1688        // the parameters hold the roots, the operation and both hashes
1689        let text = decode_command(&encoded_command(&stub, COMMAND_BUDGET).expect("command"));
1690        let blob = text
1691            .split("FromBase64String('")
1692            .nth(1)
1693            .and_then(|rest| rest.split('\'').next())
1694            .expect("blob");
1695        let json = base64::engine::general_purpose::STANDARD
1696            .decode(blob)
1697            .expect("blob base64");
1698        let value: Value = serde_json::from_slice(&json).expect("json");
1699        assert_eq!(value["o"], "commit");
1700        assert_eq!(value["ss"], sha('a'));
1701        assert_eq!(value["ws"], sha('b'));
1702        assert_eq!(
1703            value["t"],
1704            "C:\\Users\\svc\\ign-deploy\\gw\\production\\staging\\run-1"
1705        );
1706        // the command text itself shows no path (the parameters are base64)
1707        assert!(!text.contains("staging"));
1708        assert!(text.ends_with(STUB));
1709    }
1710
1711    #[test]
1712    fn stub_verifies_before_it_runs_and_hashes_the_bytes_it_runs() {
1713        // exit 97 comes before the script block is created, and the script and
1714        // the work list are each read once, hashed and then used from memory
1715        let verify = STUB.find("exit 97").expect("the mismatch exit");
1716        let run = STUB.find("[ScriptBlock]::Create").expect("the run");
1717        assert!(verify < run);
1718        assert_eq!(STUB.matches("ReadAllBytes").count(), 2);
1719        assert!(STUB.contains("GetString($b)") && STUB.contains("GetString($w)"));
1720        assert!(!STUB.contains("ReadAllText"));
1721        assert!(!STUB.contains("-File") && !STUB.contains("Invoke-Expression"));
1722    }
1723
1724    #[test]
1725    fn the_shipped_script_is_ascii_text_the_stub_can_hash() {
1726        use crate::deploy::transport::batch::{SCRIPT_PS1, script_ps1_sha256};
1727        assert!(SCRIPT_PS1.is_ascii(), "the script is pure ASCII");
1728        assert!(!SCRIPT_PS1.starts_with('\u{feff}'), "no byte order mark");
1729        assert!(SCRIPT_PS1.contains("IGN-STOP") || SCRIPT_PS1.contains("E $S.Res"));
1730        assert_eq!(
1731            script_ps1_sha256(),
1732            crate::deploy::tree::sha256_hex(SCRIPT_PS1.as_bytes())
1733        );
1734        assert_eq!(script_ps1_sha256().len(), 64);
1735        // it starts with the parameter block after its comment header and uses
1736        // none of the things a script block run by the stub cannot rely on
1737        assert!(SCRIPT_PS1.contains("\nparam([string]$Op"));
1738        assert!(!SCRIPT_PS1.contains("$MyInvocation") && !SCRIPT_PS1.contains("$PSScriptRoot"));
1739    }
1740
1741    /// Windows PowerShell 5.1 resolves an alias before a function, so a script
1742    /// function named like an alias is never called (`LP`, the alias of
1743    /// `Out-Printer`, made every hash of the first live run empty). The list is
1744    /// `Get-Alias` on the capture host (Windows PowerShell 5.1.20348, 2026-10-17).
1745    const ALIASES_5_1: &str = "% ? ac asnp cat cd cfs chdir clc clear clhy cli clp cls clv cnsn compare copy cp cpi cpp curl cvpa dbp del diff dir dnsn ebp echo epal epcsv epsn erase etsn exsn fc fhx fl foreach ft fw gal gbp gc gci gcm gcs gdr ghy gi gjb gl gm gmo gp gps gpv group gsn gsnp gsv gu gv gwmi h history icm iex ihy ii ipal ipcsv ipmo ipsn irm ise iwmi iwr kill lp ls man md measure mi mount move mp mv nal ndr ni nmo npssc nsn nv ogv oh popd ps pushd pwd r rbp rcjb rcsn rd rdr ren ri rjb rm rmdir rmo rni rnp rp rsn rsnp rujb rv rvpa rwmi sajb sal saps sasv sbp sc select set shcm si sl sleep sls sort sp spjb spps spsv start sujb sv swmi tee trcm type wget where wjb write";
1746
1747    #[test]
1748    fn the_shipped_script_names_no_function_like_an_alias_or_a_cmdlet() {
1749        use crate::deploy::transport::batch::SCRIPT_PS1;
1750        let aliases: std::collections::BTreeSet<&str> = ALIASES_5_1.split(' ').collect();
1751        let mut seen = 0;
1752        for line in SCRIPT_PS1.lines() {
1753            let Some(rest) = line.strip_prefix("function ") else {
1754                continue;
1755            };
1756            let name: String = rest
1757                .chars()
1758                .take_while(|c| c.is_ascii_alphanumeric())
1759                .collect();
1760            seen += 1;
1761            assert!(
1762                !aliases.contains(name.to_ascii_lowercase().as_str()),
1763                "function {name} has the name of a Windows PowerShell alias, which wins over it"
1764            );
1765            assert!(!name.contains('-'), "function {name} reads like a cmdlet");
1766        }
1767        assert!(
1768            seen > 20,
1769            "the script defines its helpers as functions ({seen})"
1770        );
1771    }
1772
1773    /// 23-20: the PowerShell script has every operation the POSIX script has,
1774    /// in the same dispatch, and the 23-08 hardening holds in the new ones: a
1775    /// file is moved only by `MoveReplace` (so `mirror` reuses `MoveOne` and
1776    /// gets the access-list reset), a tree is removed with .NET
1777    /// `Directory.Delete` and never with `Remove-Item -Recurse`, which in Windows
1778    /// PowerShell 5.1 can follow a junction.
1779    #[test]
1780    fn the_shipped_script_runs_every_operation_commit_sh_has_and_keeps_the_hardening() {
1781        use crate::deploy::transport::batch::{SCRIPT_PS1, SCRIPT_SH};
1782        let ops = [
1783            "commit",
1784            "restore",
1785            "mirror",
1786            "fingerprint",
1787            "probe",
1788            "finalize",
1789            "discard",
1790            "verify",
1791            "unstage",
1792        ];
1793        let mut sh_order: Vec<usize> = Vec::new();
1794        let mut ps_order: Vec<usize> = Vec::new();
1795        for op in ops {
1796            sh_order.push(
1797                SCRIPT_SH
1798                    .find(&format!("  {op}) op_{op} ;;"))
1799                    .unwrap_or_else(|| panic!("commit.sh has no {op}")),
1800            );
1801            let ps = SCRIPT_PS1
1802                .find(&format!("        '{op}' {{ "))
1803                .unwrap_or_else(|| panic!("commit.ps1 has no {op}"));
1804            ps_order.push(ps);
1805        }
1806        let ranks = |v: &[usize]| {
1807            let mut idx: Vec<usize> = (0..v.len()).collect();
1808            idx.sort_by_key(|i| v[*i]);
1809            idx
1810        };
1811        assert_eq!(
1812            ranks(&sh_order),
1813            ranks(&ps_order),
1814            "the same dispatch order"
1815        );
1816        let code: Vec<&str> = SCRIPT_PS1
1817            .lines()
1818            .filter(|l| !l.trim_start().starts_with('#'))
1819            .collect();
1820        let uses = |needle: &str| code.iter().filter(|l| l.contains(needle)).count();
1821        assert_eq!(uses("Move-Item"), 1, "only MoveReplace moves a file");
1822        assert_eq!(uses("Remove-Item"), 0, "trees go with Directory.Delete");
1823        assert!(uses("[IO.Directory]::Delete((Xp $pth), $true)") >= 1);
1824        // mirror moves through MoveOne, which resets the access list
1825        let mirror = SCRIPT_PS1
1826            .split("function OpMirror")
1827            .nth(1)
1828            .and_then(|rest| rest.split("# ---- main").next())
1829            .expect("OpMirror");
1830        assert!(mirror.contains("Moves"), "{mirror}");
1831        let moves = SCRIPT_PS1
1832            .split("function Moves")
1833            .nth(1)
1834            .and_then(|rest| rest.split("\nfunction ").next())
1835            .expect("Moves");
1836        assert!(moves.contains("MoveOne"));
1837        let move_one = SCRIPT_PS1
1838            .split("function MoveOne")
1839            .nth(1)
1840            .and_then(|rest| rest.split("\nfunction ").next())
1841            .expect("MoveOne");
1842        assert!(move_one.contains("MoveWithRetries") && move_one.contains("ResetAcl"));
1843    }
1844
1845    /// A tampered script or work list makes the stub exit 97 before the script
1846    /// runs. This needs Windows PowerShell (the stub reads `\\?\` paths), so it
1847    /// runs on the windows-latest CI leg; the same property is asserted against
1848    /// the real host in `wflwvm01_apply_script_run`.
1849    #[cfg(windows)]
1850    #[test]
1851    fn stub_refuses_hash_mismatch() {
1852        use crate::deploy::tree::sha256_hex;
1853        let dir = tempfile::tempdir().expect("temp dir");
1854        let script = "param($o,$t,$c,$d,$w) [Console]::Out.WriteLine('RAN '+$o)\n";
1855        let work = "ign-work 1\n";
1856        std::fs::write(dir.path().join("commit.ps1"), script).expect("script");
1857        std::fs::write(dir.path().join("work.tsv"), work).expect("work");
1858        let staging = dir.path().to_string_lossy().into_owned();
1859        let run = |script_sha: &str, work_sha: &str| {
1860            let stub = script_stub(
1861                script_sha,
1862                work_sha,
1863                "commit",
1864                &roots(&staging, &staging, &staging),
1865            );
1866            let command = encoded_command(&stub, COMMAND_BUDGET).expect("command");
1867            let text = command.as_str();
1868            let b64 = text
1869                .strip_prefix("powershell -NoProfile -NonInteractive -EncodedCommand ")
1870                .expect("prefix");
1871            std::process::Command::new("powershell")
1872                .args(["-NoProfile", "-NonInteractive", "-EncodedCommand", b64])
1873                .output()
1874                .expect("powershell")
1875        };
1876        let good = run(&sha256_hex(script.as_bytes()), &sha256_hex(work.as_bytes()));
1877        assert_eq!(good.status.code(), Some(0), "{good:?}");
1878        assert!(String::from_utf8_lossy(&good.stdout).contains("RAN commit"));
1879        for (script_sha, work_sha) in [
1880            (sha('0'), sha256_hex(work.as_bytes())),
1881            (sha256_hex(script.as_bytes()), sha('0')),
1882        ] {
1883            let bad = run(&script_sha, &work_sha);
1884            assert_eq!(bad.status.code(), Some(97), "{bad:?}");
1885            assert!(!String::from_utf8_lossy(&bad.stdout).contains("RAN"));
1886        }
1887    }
1888}