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