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