1use serde_json::Value;
63
64use super::process::ProcessOutput;
65use super::remote::{RemoteCommand, scrub_stderr};
66
67pub const IGN_RESULT_SENTINEL: &str = "IGN-RESULT:";
69
70pub const COMMAND_BUDGET: usize = 8000;
73
74pub const LONG_PATH_PREFIX: &str = "\\\\?\\";
76
77pub const LONG_PATH_LIMIT: usize = 240;
81
82const 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
87pub const MOVE_REPLACE: &str = "Move-Item -LiteralPath $p.src -Destination $p.dst -Force;E @{ok=1}";
96
97pub 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
155pub 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
167pub 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
175pub 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
245pub 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
268pub 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
298pub 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
341pub 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#[derive(Debug, Clone)]
374pub struct WinScript {
375 pub name: &'static str,
377 pub body: &'static str,
380 pub params: Value,
382}
383
384impl WinScript {
385 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 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 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 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 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 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 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
475pub 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
514pub 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
537pub 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("<", "<")
557 .replace(">", ">")
558 .replace(""", "\"")
559 .replace("'", "'")
560 .replace("&", "&");
561 let decoded = decoded.trim();
562 if !decoded.is_empty() {
563 out.push(decoded.to_string());
564 }
565 }
566 out
567}
568
569pub 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#[derive(Debug, Clone, PartialEq, Eq)]
616pub struct EnumEntry {
617 pub rel: String,
619 pub dir: bool,
621 pub size: u64,
623 pub reparse: bool,
625 pub full_len: usize,
627}
628
629pub 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 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
694pub 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
704pub 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
714fn 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#[derive(Debug, Clone, PartialEq, Eq)]
747pub struct StubRoots {
748 pub staging: String,
750 pub collection: String,
752 pub deploy: String,
754}
755
756pub 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
783pub 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#[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#[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#[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#[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('&', "&")
879 .replace('<', "<")
880 .replace('>', ">");
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 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 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 let big = "x".repeat(COMMAND_BUDGET);
981 assert!(encoded_command(&script(serde_json::json!({ "p": big })), COMMAND_BUDGET).is_err());
982 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 #[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 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 #[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 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 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 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 fn squash(text: &str) -> String {
1162 text.chars().filter(|c| !c.is_whitespace()).collect()
1163 }
1164
1165 #[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 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 let lower = body.to_ascii_lowercase();
1202 for banned in ["invoke-expression", "iex ", "scriptblock", "[scriptblock]"] {
1203 assert!(!lower.contains(banned), "{banned}");
1204 }
1205 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 assert_eq!(body.matches("E @{ok=1}").count(), 1);
1218 }
1219
1220 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 #[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("<"),
1259 "{detail}"
1260 );
1261 if special {
1262 assert!(
1263 detail.contains("<path>") && detail.contains('&'),
1264 "{detail}"
1265 );
1266 }
1267 }
1268 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 let long = thrown_text(700, false);
1279 let detail = failed(&long);
1280 assert!(!squash(&detail).contains(&squash(&long)), "{detail}");
1281 }
1282
1283 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 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 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 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 #[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 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 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 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 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 let progress = out(0, "IGN-RESULT:{\"ok\":1}\r\n", CLIXML_PROGRESS, false);
1434 assert!(interpret_output(&progress, 5, None).is_ok());
1435
1436 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 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 let silent = out(0, "all good\r\n", "", false);
1451 assert!(interpret_output(&silent, 5, None).is_err());
1452 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 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 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 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 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 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 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 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 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 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 #[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 assert!(link.reparse, "a symlink is flagged: {link:?}");
1660 }
1661 }
1662
1663 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 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 #[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 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 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 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 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 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 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 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 #[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 #[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 assert_eq!(clean(&stage), serde_json::json!({ "ok": 1 }));
1887
1888 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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 "",
2108 ".",
2109 "..",
2110 "a/b",
2111 "a\\b",
2112 "a\0b",
2113 "a\nb",
2114 "C:",
2116 "C:x",
2117 "a:b",
2118 "name:stream",
2119 "name::$DATA",
2120 "\\\\?\\C:",
2122 "?",
2123 "a.",
2125 "a ",
2126 "a..",
2127 "CON",
2129 "con",
2130 "Nul",
2131 "aux.txt",
2132 "PRN.json",
2133 "COM1",
2134 "lpt9.cfg",
2135 "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 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 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 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 assert!(STUB.bytes().all(|b| (0x20..0x7f).contains(&b)), "{STUB}");
2277 assert!(!STUB.contains("ign-deploy") && !STUB.contains("Users"));
2278 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 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 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 assert!(SCRIPT_PS1.contains("\nparam([string]$Op"));
2328 assert!(!SCRIPT_PS1.contains("$MyInvocation") && !SCRIPT_PS1.contains("$PSScriptRoot"));
2329 }
2330
2331 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 #[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 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 #[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 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 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 #[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 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 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 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 #[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}