1use serde_json::Value;
54
55use super::process::ProcessOutput;
56use super::remote::{RemoteCommand, scrub_stderr};
57
58pub const IGN_RESULT_SENTINEL: &str = "IGN-RESULT:";
60
61pub const COMMAND_BUDGET: usize = 8000;
64
65pub const LONG_PATH_PREFIX: &str = "\\\\?\\";
67
68pub const LONG_PATH_LIMIT: usize = 240;
72
73const PRELUDE: &str = "$ProgressPreference='SilentlyContinue';$ErrorActionPreference='Stop';function E($o){$j=ConvertTo-Json -InputObject $o -Compress -Depth 6;$j=[regex]::Replace($j,'[^\\x00-\\x7e]',{param($m)'\\u{0:x4}' -f [int][char]$m.Value});[Console]::Out.WriteLine('IGN-RESULT:'+$j)};";
77
78pub const MOVE_REPLACE: &str = "Move-Item -LiteralPath $p.src -Destination $p.dst -Force;E @{ok=1}";
87
88pub const RECORD_REPLACE: &str = concat!(
136 r#"if($ExecutionContext.SessionState.LanguageMode -ne 'FullLanguage'){throw ('the record replace needs a FullLanguage PowerShell session, not '+$ExecutionContext.SessionState.LanguageMode)};"#,
137 r#"function IgnLine($t){$s=[regex]::Replace([string]$t,'[\x00-\x1f]+',' ');$s=[regex]::Replace($s,'(?:[A-Za-z]:[\\/]|\\\\)[^''"]*','<path>');$s=[regex]::Replace($s,'[^\x20-\x7e]','?').Trim();if($s.Length -gt 200){$s=$s.Substring(0,200)+'...'};$s};"#,
138 r#"function IgnCause($x){try{while($x.InnerException){$x=$x.InnerException};$m=[string]$x.Message;$k=$x.GetType().FullName+' '+('0x{0:X8}' -f $x.HResult);if($m){$k+=': '+$m};IgnLine $k}catch{'the cause could not be read'}};"#,
139 "if(-not ('IgnRecord.Native' -as [type])){try{Add-Type -Namespace IgnRecord -Name Native -MemberDefinition '[DllImport(\"kernel32.dll\",SetLastError=true,CharSet=CharSet.Unicode)]public static extern bool MoveFileEx(string a,string b,uint c);'}catch{throw ('the record replace cannot declare MoveFileEx: '+(IgnCause $_.Exception))}};",
140 "$f=[uint32](0x1 -bor 0x8);$n=0;",
141 "while(-not [IgnRecord.Native]::MoveFileEx($p.src,$p.dst,$f)){$c=[Runtime.InteropServices.Marshal]::GetLastWin32Error();",
142 r#"if(($c -ne 32 -and $c -ne 5) -or $n -ge [int]$p.retries){throw ('the record replace failed with Win32 error '+$c+': '+(IgnLine $(try{(New-Object ComponentModel.Win32Exception ([int]$c)).Message}catch{''})))};"#,
143 "$n++;Start-Sleep -Seconds 1};E @{ok=1}"
144);
145
146pub const ENUMERATE: &str = "$o=@();for($i=0;$i -lt $p.r.Count;$i++){$b=$p.x+$p.r[$i];$e=@();$x=[IO.Directory]::Exists($b);if($x){$s=New-Object Collections.Stack;$s.Push(@([IO.DirectoryInfo]$b,''));while($s.Count){$q=$s.Pop();foreach($f in $q[0].EnumerateFileSystemInfos()){$a=[int]$f.Attributes;$d=[int](($a -band 16) -ne 0);$r=[int](($a -band 1024) -ne 0);$z=0;if(!$d){$z=$f.Length};$m=$q[1]+$f.Name;$e+=,@($m,$d,$z,$r,$f.FullName.Length);if($d -and !$r){$s.Push(@($f,($m+'\\')))}}}};$o+=@{i=$i;x=[int]$x;e=$e}};E @{ok=1;res=$o}";
157
158pub const ENUMERATE_CHILDREN: &str = "$o=@();for($i=0;$i -lt $p.r.Count;$i++){$b=$p.x+$p.r[$i];$e=@();$x=[IO.Directory]::Exists($b);if($x){foreach($f in ([IO.DirectoryInfo]$b).EnumerateFileSystemInfos()){$a=[int]$f.Attributes;$d=[int](($a -band 16) -ne 0);$r=[int](($a -band 1024) -ne 0);$z=0;if(!$d){$z=$f.Length};$e+=,@($f.Name,$d,$z,$r,$f.FullName.Length)}};$o+=@{i=$i;x=[int]$x;e=$e}};E @{ok=1;res=$o}";
165
166pub const GIT_ENTRY: &str = concat!(
186 "function T($x){try{$i=Get-Item -LiteralPath $x -Force -ErrorAction Stop}",
187 "catch{if($_.CategoryInfo.Category -eq 'ObjectNotFound'){return @{k='none';h=0;r=0}};throw};",
188 "$a=[int]$i.Attributes;",
189 "return @{k=$(if($a -band 16){'dir'}else{'file'});h=[int](($a -band 2) -ne 0);r=[int](($a -band 1024) -ne 0)}};",
190 "function W($t){if($t.r){'symlink'}else{$t.k}};",
191 "$s=@{k='skip';h=0;r=0};$a=T $p.a;$b=$s;$c=$s;",
192 "if($a.k -eq 'dir' -and !$a.r){$b=T $p.b;if($b.k -eq 'dir' -and !$b.r){$c=T $p.c}};",
193 "E @{ok=1;projects=(W $a);project=(W $b);kind=$c.k;hidden=$c.h;reparse=$c.r}"
194);
195
196pub fn windows_component_problem(name: &str) -> Option<String> {
208 let quoted = |why: &str| Some(format!("the name {name:?} {why}"));
209 if name.is_empty() {
210 return quoted("is empty");
211 }
212 if name == "." || name == ".." {
213 return quoted("is `.` or `..`");
214 }
215 if let Some(bad) = name.chars().find(|c| {
216 c.is_control() || matches!(c, '/' | '\\' | ':' | '<' | '>' | '"' | '|' | '?' | '*')
217 }) {
218 return Some(format!(
219 "the name {name:?} holds {bad:?}, which cannot be part of a Windows path component (separators, drive letters, alternate data streams and reserved characters)"
220 ));
221 }
222 if name.ends_with('.') || name.ends_with(' ') {
223 return quoted("ends in a dot or a space, which Windows strips");
224 }
225 let stem = name.split('.').next().unwrap_or(name).trim_end();
226 let upper = stem.to_ascii_uppercase();
227 let device = matches!(upper.as_str(), "CON" | "PRN" | "AUX" | "NUL")
228 || ["COM", "LPT"].iter().any(|prefix| {
229 upper
230 .strip_prefix(prefix)
231 .is_some_and(|n| matches!(n.as_bytes(), [b'1'..=b'9']))
232 });
233 if device {
234 return quoted("is a reserved Windows device name");
235 }
236 None
237}
238
239pub fn windows_absolute_path_problem(path: &str) -> Option<String> {
246 let Some(rest) = path.strip_prefix('/') else {
247 return Some(format!("{path:?} does not start with `/`"));
248 };
249 let mut parts = rest.split('/');
250 let drive = parts.next().unwrap_or_default();
251 let drive_ok = matches!(drive.as_bytes(), [letter, b':'] if letter.is_ascii_alphabetic());
252 if !drive_ok {
253 return Some(format!(
254 "{path:?} does not start with a drive letter component such as `/C:/`"
255 ));
256 }
257 let mut below = 0usize;
258 for part in parts {
259 if let Some(why) = windows_component_problem(part) {
260 return Some(format!("{path:?}: {why}"));
261 }
262 below += 1;
263 }
264 if below == 0 {
265 return Some(format!("{path:?} is a drive root"));
266 }
267 None
268}
269
270#[derive(Debug, Clone)]
272pub struct WinScript {
273 pub name: &'static str,
275 pub body: &'static str,
278 pub params: Value,
280}
281
282impl WinScript {
283 pub fn move_replace(src: &str, dst: &str) -> WinScript {
285 WinScript {
286 name: "move_replace",
287 body: MOVE_REPLACE,
288 params: serde_json::json!({ "src": src, "dst": dst }),
289 }
290 }
291
292 pub fn record_replace(src: &str, dst: &str, retries: u32) -> WinScript {
296 WinScript {
297 name: "record_replace",
298 body: RECORD_REPLACE,
299 params: serde_json::json!({ "src": src, "dst": dst, "retries": retries }),
300 }
301 }
302
303 pub fn git_entry(projects_dir: &str, project_dir: &str, git: &str) -> WinScript {
307 WinScript {
308 name: "git_entry",
309 body: GIT_ENTRY,
310 params: serde_json::json!({ "a": projects_dir, "b": project_dir, "c": git }),
311 }
312 }
313
314 pub fn enumerate(prefix: &str, roots: &[String]) -> WinScript {
317 WinScript {
318 name: "enumerate",
319 body: ENUMERATE,
320 params: serde_json::json!({ "x": prefix, "r": roots }),
321 }
322 }
323
324 pub fn enumerate_children(prefix: &str, roots: &[String]) -> WinScript {
328 WinScript {
329 name: "enumerate_children",
330 body: ENUMERATE_CHILDREN,
331 params: serde_json::json!({ "x": prefix, "r": roots }),
332 }
333 }
334}
335
336pub fn encoded_command(script: &WinScript, budget: usize) -> Result<RemoteCommand, String> {
342 use base64::Engine as _;
343 let mut text = String::from(PRELUDE);
344 match &script.params {
345 Value::Null => {}
346 Value::Object(map) if map.is_empty() => {}
347 Value::Object(_) => {
348 let json = serde_json::to_vec(&script.params).map_err(|e| {
349 format!("the parameters of {} cannot be encoded ({e})", script.name)
350 })?;
351 let blob = base64::engine::general_purpose::STANDARD.encode(json);
352 text.push_str("$p=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('");
353 text.push_str(&blob);
354 text.push_str("'))|ConvertFrom-Json;");
355 }
356 _ => {
357 return Err(format!(
358 "the parameters of {} must be a JSON object",
359 script.name
360 ));
361 }
362 }
363 text.push_str(script.body);
364 let command = RemoteCommand::powershell_encoded(&text);
365 let length = command.as_str().chars().count();
366 if length > budget {
367 return Err(format!(
368 "the remote command for {} is {length} characters, over the {budget} character budget",
369 script.name
370 ));
371 }
372 Ok(command)
373}
374
375pub fn parse_ign_result(stdout: &[u8]) -> Result<Value, String> {
379 let mut found: Option<&[u8]> = None;
380 for line in stdout.split(|b| *b == b'\n') {
381 let line = line.strip_suffix(b"\r").unwrap_or(line);
382 if let Some(rest) = line.strip_prefix(IGN_RESULT_SENTINEL.as_bytes()) {
383 if found.is_some() {
384 return Err(format!(
385 "the output holds more than one {IGN_RESULT_SENTINEL} line"
386 ));
387 }
388 found = Some(rest);
389 }
390 }
391 let Some(json) = found else {
392 return Err(format!("the output holds no {IGN_RESULT_SENTINEL} line"));
393 };
394 serde_json::from_slice(json)
395 .map_err(|_| format!("the {IGN_RESULT_SENTINEL} line is not valid JSON"))
396}
397
398pub fn clixml_errors(stderr: &[u8]) -> Vec<String> {
401 let text = String::from_utf8_lossy(stderr);
402 if !text.contains("CLIXML") {
403 return Vec::new();
404 }
405 let mut out = Vec::new();
406 let mut rest: &str = &text;
407 while let Some(at) = rest.find("<S S=\"Error\">") {
408 rest = &rest[at + "<S S=\"Error\">".len()..];
409 let Some(end) = rest.find("</S>") else { break };
410 let raw = &rest[..end];
411 rest = &rest[end..];
412 let decoded = raw
413 .replace("_x000D__x000A_", "\n")
414 .replace("_x000A_", "\n")
415 .replace("_x000D_", "")
416 .replace("_x0009_", "\t")
417 .replace("<", "<")
418 .replace(">", ">")
419 .replace(""", "\"")
420 .replace("'", "'")
421 .replace("&", "&");
422 let decoded = decoded.trim();
423 if !decoded.is_empty() {
424 out.push(decoded.to_string());
425 }
426 }
427 out
428}
429
430pub fn interpret_output(
435 out: &ProcessOutput,
436 timeout_s: u64,
437 temp_dir: Option<&std::path::Path>,
438) -> Result<Value, String> {
439 if out.timed_out {
440 return Err(format!("timed out after {timeout_s}s and was killed"));
441 }
442 let explain = || -> String {
443 let decoded = clixml_errors(&out.stderr);
444 if decoded.is_empty() {
445 scrub_stderr(&out.stderr, temp_dir)
446 } else {
447 scrub_stderr(decoded.join("\n").as_bytes(), temp_dir)
448 }
449 };
450 if out.code != 0 {
451 let stderr_text = String::from_utf8_lossy(&out.stderr);
452 if stderr_text.contains("The command line is too long") {
453 return Err(format!(
454 "exit code {}: the host refused the remote command as too long (budget {COMMAND_BUDGET} characters)",
455 out.code
456 ));
457 }
458 let tail = explain();
459 return Err(if tail.is_empty() {
460 format!("exit code {}", out.code)
461 } else {
462 format!("exit code {}: {tail}", out.code)
463 });
464 }
465 let errors = clixml_errors(&out.stderr);
466 if !errors.is_empty() {
467 return Err(format!(
468 "the script exited 0 but wrote an error record: {}",
469 scrub_stderr(errors.join("\n").as_bytes(), temp_dir)
470 ));
471 }
472 parse_ign_result(&out.stdout)
473}
474
475#[derive(Debug, Clone, PartialEq, Eq)]
477pub struct EnumEntry {
478 pub rel: String,
480 pub dir: bool,
482 pub size: u64,
484 pub reparse: bool,
486 pub full_len: usize,
488}
489
490pub fn parse_enumeration(
493 value: &Value,
494 expected_roots: usize,
495) -> Result<Vec<Option<Vec<EnumEntry>>>, String> {
496 let bad = |why: &str| format!("the enumeration result is malformed ({why})");
497 if value.get("ok").and_then(Value::as_i64) != Some(1) {
498 return Err(bad("ok is not 1"));
499 }
500 let res = value
501 .get("res")
502 .and_then(Value::as_array)
503 .ok_or_else(|| bad("res is not an array"))?;
504 if res.len() != expected_roots {
505 return Err(bad("the number of roots differs"));
506 }
507 let mut out = Vec::with_capacity(res.len());
508 for (index, root) in res.iter().enumerate() {
509 if root.get("i").and_then(Value::as_u64) != Some(index as u64) {
510 return Err(bad("roots are out of order"));
511 }
512 if root.get("x").and_then(Value::as_i64) == Some(0) {
513 out.push(None);
514 continue;
515 }
516 let entries = root
517 .get("e")
518 .and_then(Value::as_array)
519 .ok_or_else(|| bad("e is not an array"))?;
520 let mut list = Vec::with_capacity(entries.len());
521 for entry in entries {
522 let cols = entry
523 .as_array()
524 .ok_or_else(|| bad("an entry is not an array"))?;
525 let (Some(rel), Some(dir), Some(size), Some(reparse), Some(full_len)) = (
526 cols.first().and_then(Value::as_str),
527 cols.get(1).and_then(Value::as_i64),
528 cols.get(2).and_then(Value::as_u64),
529 cols.get(3).and_then(Value::as_i64),
530 cols.get(4).and_then(Value::as_u64),
531 ) else {
532 return Err(bad("an entry has the wrong columns"));
533 };
534 let rel = rel.replace('\\', "/");
537 for component in rel.split('/') {
538 if let Some(why) = windows_component_problem(component) {
539 return Err(bad(&format!("an entry path is refused: {why}")));
540 }
541 }
542 list.push(EnumEntry {
543 rel,
544 dir: dir != 0,
545 size,
546 reparse: reparse != 0,
547 full_len: full_len as usize,
548 });
549 }
550 out.push(Some(list));
551 }
552 Ok(out)
553}
554
555pub fn enumerate_chunks(
559 prefix: &str,
560 roots: &[String],
561) -> Result<Vec<(std::ops::Range<usize>, WinScript)>, String> {
562 let mut chunks = Vec::new();
563 let mut start = 0usize;
564 while start < roots.len() {
565 let mut end = start + 1;
566 if encoded_command(
567 &WinScript::enumerate(prefix, &roots[start..end]),
568 COMMAND_BUDGET,
569 )
570 .is_err()
571 {
572 return Err(format!(
573 "the path {:?} is too long to enumerate within the remote command budget",
574 roots[start]
575 ));
576 }
577 while end < roots.len()
578 && encoded_command(
579 &WinScript::enumerate(prefix, &roots[start..=end]),
580 COMMAND_BUDGET,
581 )
582 .is_ok()
583 {
584 end += 1;
585 }
586 chunks.push((start..end, WinScript::enumerate(prefix, &roots[start..end])));
587 start = end;
588 }
589 Ok(chunks)
590}
591
592#[derive(Debug, Clone, PartialEq, Eq)]
597pub struct StubRoots {
598 pub staging: String,
600 pub collection: String,
602 pub deploy: String,
604}
605
606pub const STUB: &str = concat!(
621 "$q='\\\\?\\';",
622 "$b=[IO.File]::ReadAllBytes($q+$p.t+'\\commit.ps1');",
623 "$w=[IO.File]::ReadAllBytes($q+$p.t+'\\work.tsv');",
624 "$a=[Security.Cryptography.SHA256]::Create();",
625 "$x=[BitConverter]::ToString($a.ComputeHash($b)).Replace('-','').ToLower();",
626 "$y=[BitConverter]::ToString($a.ComputeHash($w)).Replace('-','').ToLower();",
627 "if($x -cne $p.ss -or $y -cne $p.ws){",
628 "[Console]::Error.WriteLine('ign: the staged script or work list does not match its hash');exit 97};",
629 "& ([ScriptBlock]::Create([Text.Encoding]::UTF8.GetString($b))) ",
630 "$p.o $p.t $p.c $p.d ([Text.Encoding]::UTF8.GetString($w))"
631);
632
633pub fn script_stub(
639 script_sha256: &str,
640 work_sha256: &str,
641 op: &str,
642 roots: &StubRoots,
643) -> WinScript {
644 WinScript {
645 name: "script_stub",
646 body: STUB,
647 params: serde_json::json!({
648 "t": roots.staging,
649 "c": roots.collection,
650 "d": roots.deploy,
651 "o": op,
652 "ss": script_sha256,
653 "ws": work_sha256,
654 }),
655 }
656}
657
658#[cfg(test)]
662fn ps51_wrap(line: &str) -> Vec<String> {
663 let chars: Vec<char> = line.chars().collect();
664 chars
665 .chunks(119)
666 .map(|piece| piece.iter().collect())
667 .collect()
668}
669
670#[cfg(test)]
676fn ps51_info_line(text: &str) -> String {
677 let chars: Vec<char> = text.chars().collect();
678 chars
679 .chunks(114)
680 .map(|piece| format!(" {}", piece.iter().collect::<String>()))
681 .collect()
682}
683
684#[cfg(test)]
688fn ps51_ellipsize(target: &str) -> String {
689 let chars: Vec<char> = target.chars().collect();
690 if chars.len() <= 40 {
691 return target.to_string();
692 }
693 let head: String = chars[..15].iter().collect();
694 let tail: String = chars[chars.len() - 15..].iter().collect();
695 format!("{head}...{tail}")
696}
697
698#[cfg(test)]
710pub(crate) fn ps51_error_clixml(thrown: &str) -> Vec<u8> {
711 let mut lines = ps51_wrap(thrown);
712 lines.push("At line:1 char:1200".to_string());
713 lines.push("+ ... $n -ge [int]$p.retries){throw ('the record replace ...".to_string());
714 lines.push(format!("+ {}{}", " ".repeat(30), "~".repeat(12)));
715 lines.extend(ps51_wrap(&ps51_info_line(&format!(
716 "+ CategoryInfo : OperationStopped: ({}:String) [], RuntimeException",
717 ps51_ellipsize(thrown)
718 ))));
719 lines.extend(ps51_wrap(&ps51_info_line(&format!(
720 "+ FullyQualifiedErrorId : {thrown}"
721 ))));
722 lines.push(" ".to_string());
723 let mut out = String::from(
724 "#< CLIXML\r\n<Objs Version=\"1.1.0.1\" xmlns=\"http://schemas.microsoft.com/powershell/2004/04\">",
725 );
726 for line in &lines {
727 let escaped = line
728 .replace('&', "&")
729 .replace('<', "<")
730 .replace('>', ">");
731 out.push_str(&format!("<S S=\"Error\">{escaped}_x000D__x000A_</S>"));
732 }
733 out.push_str("</Objs>");
734 out.into_bytes()
735}
736
737#[cfg(test)]
738mod tests {
739 use super::*;
740 use base64::Engine as _;
741
742 fn decode_command(command: &RemoteCommand) -> String {
743 let text = command.as_str();
744 let b64 = text
745 .strip_prefix("powershell -NoProfile -NonInteractive -EncodedCommand ")
746 .expect("the fixed powershell prefix");
747 let bytes = base64::engine::general_purpose::STANDARD
748 .decode(b64)
749 .expect("base64");
750 assert_eq!(bytes.len() % 2, 0, "UTF-16LE has an even length");
751 let units: Vec<u16> = bytes
752 .chunks(2)
753 .map(|c| u16::from_le_bytes([c[0], c[1]]))
754 .collect();
755 String::from_utf16(&units).expect("utf-16")
756 }
757
758 fn script(params: Value) -> WinScript {
759 WinScript {
760 name: "t",
761 body: "E @{ok=1}",
762 params,
763 }
764 }
765
766 #[test]
767 fn encoded_command_is_utf16le_base64() {
768 let command = encoded_command(&script(Value::Null), COMMAND_BUDGET).expect("command");
769 let text = decode_command(&command);
770 assert_eq!(text, format!("{PRELUDE}E @{{ok=1}}"));
771 assert!(
772 text.starts_with(
773 "$ProgressPreference='SilentlyContinue';$ErrorActionPreference='Stop';"
774 )
775 );
776 let b64 = command
778 .as_str()
779 .strip_prefix("powershell -NoProfile -NonInteractive -EncodedCommand ")
780 .expect("prefix");
781 assert!(
782 b64.bytes()
783 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'/' | b'='))
784 );
785 }
786
787 #[test]
788 fn parameters_travel_as_a_base64_json_blob_never_interpolated() {
789 let hostile = "C:\\a b\\'; Remove-Item -Recurse C:\\ ; '$(whoami)`\"";
790 let command = encoded_command(
791 &script(serde_json::json!({ "path": hostile })),
792 COMMAND_BUDGET,
793 )
794 .expect("command");
795 let text = decode_command(&command);
796 assert!(
797 !text.contains("Remove-Item"),
798 "the hostile text must not appear in the script: {text}"
799 );
800 assert!(!text.contains("whoami"));
801 let blob = text
802 .split("FromBase64String('")
803 .nth(1)
804 .and_then(|rest| rest.split('\'').next())
805 .expect("blob");
806 let json = base64::engine::general_purpose::STANDARD
807 .decode(blob)
808 .expect("blob base64");
809 let value: Value = serde_json::from_slice(&json).expect("json");
810 assert_eq!(value["path"], hostile);
811 for bad in [
813 serde_json::json!([1]),
814 serde_json::json!("x"),
815 serde_json::json!(3),
816 ] {
817 assert!(encoded_command(&script(bad), COMMAND_BUDGET).is_err());
818 }
819 }
820
821 #[test]
822 fn encoded_command_budget() {
823 let exact = encoded_command(&script(Value::Null), COMMAND_BUDGET).expect("fits");
824 let length = exact.as_str().chars().count();
825 assert!(encoded_command(&script(Value::Null), length).is_ok());
826 let err = encoded_command(&script(Value::Null), length - 1).expect_err("one over");
827 assert!(err.contains("over the") && err.contains("budget"), "{err}");
828
829 let big = "x".repeat(COMMAND_BUDGET);
831 assert!(encoded_command(&script(serde_json::json!({ "p": big })), COMMAND_BUDGET).is_err());
832 let enumerate = encoded_command(
834 &WinScript::enumerate(
835 LONG_PATH_PREFIX,
836 &[
837 "C:\\Program Files\\Inductive Automation\\Ignition\\data\\config\\resources\\production\\ignition\\tag-definition\\provider".to_string(),
838 ],
839 ),
840 COMMAND_BUDGET,
841 )
842 .expect("one realistic root fits");
843 assert!(enumerate.as_str().len() < COMMAND_BUDGET);
844 }
845
846 #[test]
847 fn enumerate_chunks_split_by_budget_and_cover_every_root() {
848 let root = |i: usize| {
849 format!(
850 "C:\\Program Files\\Inductive Automation\\Ignition\\data\\config\\resources\\production\\ignition\\tag-definition\\provider-{i:04}"
851 )
852 };
853 let roots: Vec<String> = (0..60).map(root).collect();
854 let chunks = enumerate_chunks(LONG_PATH_PREFIX, &roots).expect("chunks");
855 assert!(chunks.len() >= 2, "60 long roots cannot share one command");
856 let mut next = 0;
857 for (range, script) in &chunks {
858 assert_eq!(range.start, next, "contiguous");
859 next = range.end;
860 assert!(
861 encoded_command(script, COMMAND_BUDGET).is_ok(),
862 "each chunk fits the budget"
863 );
864 }
865 assert_eq!(next, roots.len(), "every root is covered");
866 let huge = vec!["C:\\".to_string() + &"d".repeat(COMMAND_BUDGET)];
867 assert!(enumerate_chunks(LONG_PATH_PREFIX, &huge).is_err());
868 assert!(
869 enumerate_chunks(LONG_PATH_PREFIX, &[])
870 .expect("none")
871 .is_empty()
872 );
873 }
874
875 #[test]
880 fn record_replace_is_fixed_fail_closed_text() {
881 let body = RECORD_REPLACE;
882 assert!(!body.is_empty());
883 assert!(
884 body.bytes().all(|b| (0x20..=0x7e).contains(&b)),
885 "one line of printable ASCII"
886 );
887 assert!(!body.contains("ign-deploy") && !body.contains("C:\\"));
888 assert!(
889 !body.contains("Move-Item"),
890 "not the delete-then-rename cmdlet"
891 );
892 assert!(body.contains("$ExecutionContext.SessionState.LanguageMode -ne 'FullLanguage'"));
893 assert!(body.contains("MoveFileEx(string a,string b,uint c)"));
894 assert!(body.contains("kernel32.dll") && body.contains("SetLastError=true"));
895 assert!(
896 body.contains("0x1 -bor 0x8"),
897 "REPLACE_EXISTING | WRITE_THROUGH"
898 );
899 assert!(body.contains("GetLastWin32Error"));
900 assert!(body.contains("$p.src") && body.contains("$p.dst") && body.contains("$p.retries"));
901 let guard = body.find("'IgnRecord.Native' -as [type]").expect("guard");
903 assert_eq!(body.matches("Add-Type").count(), 1);
904 let add = body.find("Add-Type").expect("Add-Type");
905 assert!(guard < add, "the guard precedes the declaration");
906 let try_at = body[..add].rfind("try{").expect("try before Add-Type");
907 let catch_at = body[add..].find("catch{throw ").expect("catch rethrows") + add;
908 assert!(guard < try_at && try_at < add && add < catch_at);
909
910 let long = format!("C:\\Users\\svc\\{}", "d".repeat(227));
912 assert_eq!(long.len(), 240);
913 let accented = format!("C:\\Users\\sv\u{e7}\\{}", "\u{e9}".repeat(110));
914 for path in [&long, &accented] {
915 assert!(
916 encoded_command(
917 &WinScript::record_replace(
918 path,
919 path,
920 crate::deploy::transport::batch::RENAME_RETRIES
921 ),
922 COMMAND_BUDGET
923 )
924 .is_ok(),
925 "fits the budget"
926 );
927 }
928
929 let (src, dst) = ("C:\\h\\a.tmp-r1", "C:\\h\\a.json");
931 let split = |retries: u32| {
932 let text = decode_command(
933 &encoded_command(
934 &WinScript::record_replace(src, dst, retries),
935 COMMAND_BUDGET,
936 )
937 .expect("command"),
938 );
939 let blob = text
940 .split("FromBase64String('")
941 .nth(1)
942 .and_then(|rest| rest.split('\'').next())
943 .expect("blob")
944 .to_string();
945 let json = base64::engine::general_purpose::STANDARD
946 .decode(&blob)
947 .expect("blob base64");
948 let value: Value = serde_json::from_slice(&json).expect("json");
949 (text.replace(&blob, "<blob>"), value)
950 };
951 let (text3, value3) = split(3);
952 let (text7, value7) = split(7);
953 assert_eq!(
954 value3,
955 serde_json::json!({ "src": src, "dst": dst, "retries": 3 })
956 );
957 assert_eq!(
958 value7,
959 serde_json::json!({ "src": src, "dst": dst, "retries": 7 })
960 );
961 assert!(value3["retries"].is_number());
962 assert_eq!(
963 text3, text7,
964 "the text outside the blob does not depend on the count"
965 );
966 assert!(text3.contains("$p.retries"));
967 assert!(!text3.contains(src) && !text3.contains(dst));
968 assert!(text3.ends_with(RECORD_REPLACE));
969 }
970
971 fn squash(text: &str) -> String {
974 text.chars().filter(|c| !c.is_whitespace()).collect()
975 }
976
977 #[test]
981 fn record_replace_cause_is_bounded_single_line_ascii() {
982 let body = RECORD_REPLACE;
983 assert!(
984 body.bytes().all(|b| (0x20..=0x7e).contains(&b)),
985 "one line of printable ASCII"
986 );
987 let prefixes = [
988 "the record replace needs a FullLanguage PowerShell session, not ",
989 "the record replace cannot declare MoveFileEx: ",
990 "the record replace failed with Win32 error ",
991 ];
992 for prefix in prefixes {
993 assert!(body.contains(prefix), "{prefix}");
994 assert!(prefix.len() + 203 + 13 <= 300, "{prefix}");
997 }
998 let language = body
999 .find("$ExecutionContext.SessionState.LanguageMode -ne 'FullLanguage'")
1000 .expect("language check");
1001 let line = body.find("function IgnLine").expect("IgnLine");
1002 let cause = body.find("function IgnCause").expect("IgnCause");
1003 let guard = body.find("'IgnRecord.Native' -as [type]").expect("guard");
1004 assert!(language < line && line < cause && cause < guard);
1005 let cause_body = &body[cause..guard];
1006 assert!(cause_body.contains("try{") && cause_body.contains("catch{"));
1007 assert!(
1008 cause_body.contains("catch{'the cause could not be read'}"),
1009 "a failing helper returns a fixed sentence"
1010 );
1011 assert!(!body[line..guard].contains("Add-Type"));
1012 let lower = body.to_ascii_lowercase();
1014 for banned in ["invoke-expression", "iex ", "scriptblock", "[scriptblock]"] {
1015 assert!(!lower.contains(banned), "{banned}");
1016 }
1017 for segment in [
1019 "if(-not ('IgnRecord.Native' -as [type])){try{Add-Type -Namespace IgnRecord -Name Native -MemberDefinition '[DllImport(\"kernel32.dll\",SetLastError=true,CharSet=CharSet.Unicode)]public static extern bool MoveFileEx(string a,string b,uint c);'}catch{throw ",
1020 "$f=[uint32](0x1 -bor 0x8);$n=0;",
1021 "while(-not [IgnRecord.Native]::MoveFileEx($p.src,$p.dst,$f)){$c=[Runtime.InteropServices.Marshal]::GetLastWin32Error();",
1022 "if(($c -ne 32 -and $c -ne 5) -or $n -ge [int]$p.retries){throw ",
1023 "$n++;Start-Sleep -Seconds 1};E @{ok=1}",
1024 ] {
1025 assert!(body.contains(segment), "{segment}");
1026 }
1027 assert!(body.ends_with("$n++;Start-Sleep -Seconds 1};E @{ok=1}"));
1028 assert_eq!(body.matches("E @{ok=1}").count(), 1);
1030 }
1031
1032 fn thrown_text(len: usize, special: bool) -> String {
1034 let mut text = String::from("the record replace failed with Win32 error 32: ");
1035 if special {
1036 text.push_str("<path> it's a & b ");
1037 }
1038 let mut n = 0;
1039 while text.len() < len {
1040 text.push_str(&format!("w{n} "));
1041 n += 1;
1042 }
1043 text.truncate(len);
1044 text
1045 }
1046
1047 #[test]
1050 fn record_replace_cause_survives_the_clixml_tail() {
1051 let failed = |thrown: &str| -> String {
1052 let stderr = ps51_error_clixml(thrown);
1053 let output = ProcessOutput {
1054 stdout: Vec::new(),
1055 stderr,
1056 code: 1,
1057 timed_out: false,
1058 };
1059 interpret_output(&output, 60, None).expect_err("a non-zero exit is an error")
1060 };
1061 for (len, special) in [(60, false), (150, false), (150, true), (299, true)] {
1062 let thrown = thrown_text(len, special);
1063 assert_eq!(thrown.len(), len);
1064 let detail = failed(&thrown);
1065 assert!(
1066 squash(&detail).contains(&squash(&thrown)),
1067 "{len}: {detail}"
1068 );
1069 assert!(
1070 !detail.contains("<S S=") && !detail.contains("<"),
1071 "{detail}"
1072 );
1073 if special {
1074 assert!(
1075 detail.contains("<path>") && detail.contains('&'),
1076 "{detail}"
1077 );
1078 }
1079 }
1080 let edge = thrown_text(300, false);
1082 assert_eq!(edge.chars().count(), 300);
1083 let detail = failed(&edge);
1084 assert!(squash(&detail).contains(&squash(&edge)), "{detail}");
1085 let tail = detail
1086 .strip_prefix("exit code 1: ")
1087 .expect("exit code prefix");
1088 assert_eq!(tail.lines().count(), 5, "{detail}");
1089 let long = thrown_text(700, false);
1091 let detail = failed(&long);
1092 assert!(!squash(&detail).contains(&squash(&long)), "{detail}");
1093 }
1094
1095 const CAPTURED_BOOM_BARE: &str = concat!(
1101 "#< CLIXML",
1102 "\r\n",
1103 r#"<Objs Version="1.1.0.1" xmlns="http://schemas.microsoft.com/powershell/2004/04"><S S="Error">boom_x000D__x000A_</S><S S="Error">At line:1 char:1_x000D__x000A_</S><S S="Error">+ throw 'boom'_x000D__x000A_</S><S S="Error">+ ~~~~~~~~~~~~_x000D__x000A_</S><S S="Error"> + CategoryInfo : OperationStopped: (boom:String) [], RuntimeException_x000D__x000A_</S><S S="Error"> + FullyQualifiedErrorId : boom_x000D__x000A_</S><S S="Error"> _x000D__x000A_</S></Objs>"#
1104 );
1105 const CAPTURED_BOOM_PRELUDE: &str = concat!(
1107 "#< CLIXML",
1108 "\r\n",
1109 r#"<Objs Version="1.1.0.1" xmlns="http://schemas.microsoft.com/powershell/2004/04"><S S="Error">boom_x000D__x000A_</S><S S="Error">At line:1 char:72_x000D__x000A_</S><S S="Error">+ ... rence='SilentlyContinue'; $ErrorActionPreference='Stop'; throw 'boom'_x000D__x000A_</S><S S="Error">+ ~~~~~~~~~~~~_x000D__x000A_</S><S S="Error"> + CategoryInfo : OperationStopped: (boom:String) [], RuntimeException_x000D__x000A_</S><S S="Error"> + FullyQualifiedErrorId : boom_x000D__x000A_</S><S S="Error"> _x000D__x000A_</S></Objs>"#
1110 );
1111 const CAPTURED_WRAPPED: &str = r#"<Objs Version="1.1.0.1" xmlns="http://schemas.microsoft.com/powershell/2004/04"><S S="Error">Get-ChildItem : Could not find a part of the path 'C:\Program Files\Inductive Automation\Ignition\data\config\resources_x000D__x000A_</S><S S="Error">\core\ignition\tag-definition\CAP22H\LLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLL_x000D__x000A_</S><S S="Error">LLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLL'_x000D__x000A_</S><S S="Error">._x000D__x000A_</S><S S="Error">At line:1 char:72_x000D__x000A_</S><S S="Error">+ ... nce='Stop'; Get-ChildItem -LiteralPath (Join-Path 'C:\Program Files\I ..._x000D__x000A_</S><S S="Error">+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~_x000D__x000A_</S><S S="Error"> + CategoryInfo : ReadError: (C:\Program File...LLLLLLLLLLLLLLL:String) [Get-ChildItem], DirectoryNotFound _x000D__x000A_</S><S S="Error"> Exception_x000D__x000A_</S><S S="Error"> + FullyQualifiedErrorId : DirIOError,Microsoft.PowerShell.Commands.GetChildItemCommand_x000D__x000A_</S><S S="Error"> _x000D__x000A_</S></Objs>"#;
1113
1114 fn raw_chunks(clixml: &str) -> Vec<String> {
1117 clixml
1118 .split("<S S=\"Error\">")
1119 .skip(1)
1120 .map(|piece| piece.split("</S>").next().expect("chunk").to_string())
1121 .collect()
1122 }
1123
1124 const CRLF_ENTITY: &str = "_x000D__x000A_";
1125
1126 #[test]
1130 fn ps51_error_clixml_matches_the_captured_records() {
1131 for captured in [CAPTURED_BOOM_BARE, CAPTURED_BOOM_PRELUDE] {
1132 let built = String::from_utf8(ps51_error_clixml("boom")).expect("utf-8");
1133 let head = |text: &str| text.split("<S S=").next().expect("head").to_string();
1134 assert_eq!(head(&built), head(captured), "framing through <Objs>");
1135 assert!(built.ends_with("</Objs>") && captured.ends_with("</Objs>"));
1136 assert!(!built.ends_with('\n') && !captured.ends_with('\n'));
1137 let (mine, theirs) = (raw_chunks(&built), raw_chunks(captured));
1138 assert_eq!((mine.len(), theirs.len()), (7, 7));
1139 for at in [0, 4, 5, 6] {
1140 assert_eq!(mine[at], theirs[at], "chunk {}", at + 1);
1141 assert!(mine[at].ends_with(CRLF_ENTITY));
1142 }
1143 for chunk in [&mine, &theirs] {
1145 let digits = chunk[1]
1146 .strip_prefix("At line:1 char:")
1147 .and_then(|rest| rest.strip_suffix(CRLF_ENTITY))
1148 .expect("At line form");
1149 assert!(!digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit()));
1150 assert!(chunk[2].starts_with("+ ") && chunk[2].ends_with(CRLF_ENTITY));
1151 let marks = chunk[3]
1152 .strip_prefix('+')
1153 .and_then(|rest| rest.strip_suffix(CRLF_ENTITY))
1154 .expect("underline form");
1155 assert!(marks.chars().all(|c| c == ' ' || c == '~'));
1156 assert!(marks.contains('~'));
1157 }
1158 let last_two = |text: &str| {
1159 let errors = clixml_errors(text.as_bytes());
1160 errors[errors.len() - 2..].to_vec()
1161 };
1162 assert_eq!(last_two(&built), last_two(captured));
1163 assert_eq!(
1164 last_two(captured),
1165 [
1166 "+ CategoryInfo : OperationStopped: (boom:String) [], RuntimeException",
1167 "+ FullyQualifiedErrorId : boom",
1168 ]
1169 );
1170 }
1171
1172 let captured = raw_chunks(CAPTURED_WRAPPED);
1174 assert_eq!(captured.len(), 11);
1175 let bare: Vec<String> = captured
1176 .iter()
1177 .map(|chunk| chunk.strip_suffix(CRLF_ENTITY).expect("end").to_string())
1178 .collect();
1179 let message = bare[..4].concat();
1180 assert_eq!(
1181 bare[..4]
1182 .iter()
1183 .map(|c| c.chars().count())
1184 .collect::<Vec<_>>(),
1185 [119, 119, 119, 1]
1186 );
1187 assert_eq!(ps51_wrap(&message), bare[..4]);
1188 let quoted = message
1189 .split_once("the path '")
1190 .and_then(|(_, rest)| rest.strip_suffix("'."))
1191 .expect("quoted path");
1192 let ellipsis = ps51_ellipsize(quoted);
1193 assert_eq!(ellipsis, "C:\\Program File...LLLLLLLLLLLLLLL");
1194 let category = format!(
1195 "+ CategoryInfo : ReadError: ({ellipsis}:String) [Get-ChildItem], DirectoryNotFoundException"
1196 );
1197 assert_eq!(ps51_wrap(&ps51_info_line(&category)), bare[7..9]);
1198 assert_eq!(bare[7].chars().count(), 119);
1199 assert!(bare[7].ends_with(' ') && bare[8].starts_with(" Exception"));
1200 let id = "+ FullyQualifiedErrorId : DirIOError,Microsoft.PowerShell.Commands.GetChildItemCommand";
1201 assert_eq!(ps51_wrap(&ps51_info_line(id)), bare[9..10]);
1202 assert_eq!(bare[10], " ");
1203 assert_eq!(ps51_ellipsize("boom"), "boom");
1205 }
1206
1207 #[test]
1208 fn parse_ign_result_cases() {
1209 let ok = parse_ign_result(b"noise\r\nIGN-RESULT:{\"ok\":1}\r\n").expect("one line");
1210 assert_eq!(ok["ok"], 1);
1211 assert!(parse_ign_result(b"IGN-RESULT:{\"ok\":1}").is_ok());
1213 for (bad, why) in [
1214 (&b""[..], "no line"),
1215 (b"hello\n", "sentinel absent"),
1216 (b"IGN-RESULT:{\"a\":1}\nIGN-RESULT:{\"a\":2}\n", "two lines"),
1217 (b"IGN-RESULT:{nope\n", "invalid JSON"),
1218 (b"IGN-RESULT:\n", "empty payload"),
1219 (
1220 b" IGN-RESULT:{\"ok\":1}\n",
1221 "indented sentinel is not a sentinel line",
1222 ),
1223 ] {
1224 assert!(parse_ign_result(bad).is_err(), "{why} must be an error");
1225 }
1226 }
1227
1228 fn out(code: i32, stdout: &str, stderr: &str, timed_out: bool) -> ProcessOutput {
1229 ProcessOutput {
1230 stdout: stdout.as_bytes().to_vec(),
1231 stderr: stderr.as_bytes().to_vec(),
1232 code,
1233 timed_out,
1234 }
1235 }
1236
1237 const CLIXML_ERROR: &str = "#< CLIXML\r\n<Objs Version=\"1.1.0.1\" xmlns=\"http://schemas.microsoft.com/powershell/2004/04\"><S S=\"Error\">Cannot find path 'C:\\x' because it does not exist._x000D__x000A_</S><S S=\"Error\">At line:1 char:1_x000D__x000A_</S></Objs>";
1238 const CLIXML_PROGRESS: &str = "#< CLIXML\r\n<Objs Version=\"1.1.0.1\" xmlns=\"http://schemas.microsoft.com/powershell/2004/04\"><Obj S=\"progress\" RefId=\"0\"><TN RefId=\"0\"><T>System.Management.Automation.PSCustomObject</T></TN><MS><I64 N=\"SourceId\">1</I64></MS></Obj></Objs>";
1239
1240 #[test]
1241 fn interpret_output_decides_from_exit_code_and_the_sentinel() {
1242 let ok = out(0, "IGN-RESULT:{\"ok\":1}\r\n", "", false);
1243 assert_eq!(interpret_output(&ok, 5, None).expect("ok")["ok"], 1);
1244 let progress = out(0, "IGN-RESULT:{\"ok\":1}\r\n", CLIXML_PROGRESS, false);
1246 assert!(interpret_output(&progress, 5, None).is_ok());
1247
1248 let failed = out(1, "", CLIXML_ERROR, false);
1250 let detail = interpret_output(&failed, 5, None).expect_err("exit 1");
1251 assert!(detail.starts_with("exit code 1: "), "{detail}");
1252 assert!(detail.contains("Cannot find path"), "{detail}");
1253 assert!(
1254 !detail.contains("<S S="),
1255 "no raw XML in the detail: {detail}"
1256 );
1257 let recorded = out(0, "IGN-RESULT:{\"ok\":1}\r\n", CLIXML_ERROR, false);
1259 let detail = interpret_output(&recorded, 5, None).expect_err("error record");
1260 assert!(detail.contains("error record"), "{detail}");
1261 let silent = out(0, "all good\r\n", "", false);
1263 assert!(interpret_output(&silent, 5, None).is_err());
1264 let timed = out(-1, "", "", true);
1266 assert_eq!(
1267 interpret_output(&timed, 7, None).expect_err("timeout"),
1268 "timed out after 7s and was killed"
1269 );
1270 let long = out(1, "", "The command line is too long.\r\n", false);
1272 let detail = interpret_output(&long, 5, None).expect_err("too long");
1273 assert!(detail.contains("too long"), "{detail}");
1274 let ssh = out(255, "", "exec request failed on channel 0", false);
1276 assert!(
1277 interpret_output(&ssh, 5, None)
1278 .expect_err("255")
1279 .contains("exit code 255")
1280 );
1281 }
1282
1283 #[test]
1284 fn clixml_errors_decode_and_ignore_non_clixml() {
1285 let got = clixml_errors(CLIXML_ERROR.as_bytes());
1286 assert_eq!(got.len(), 2);
1287 assert_eq!(
1288 got[0],
1289 "Cannot find path 'C:\\x' because it does not exist."
1290 );
1291 assert!(clixml_errors(b"plain stderr").is_empty());
1292 assert!(clixml_errors(CLIXML_PROGRESS.as_bytes()).is_empty());
1293 }
1294
1295 #[test]
1296 fn parse_enumeration_cases() {
1297 let value = serde_json::json!({
1298 "ok": 1,
1299 "res": [
1300 { "i": 0, "x": 1, "e": [["a\\b.json", 0, 12, 0, 90], ["a", 1, 0, 0, 80]] },
1301 { "i": 1, "x": 0, "e": [] }
1302 ]
1303 });
1304 let got = parse_enumeration(&value, 2).expect("parse");
1305 let first = got[0].as_ref().expect("root 0 exists");
1306 assert_eq!(first[0].rel, "a/b.json", "backslashes become slashes");
1307 assert!(!first[0].dir && first[0].size == 12 && !first[0].reparse);
1308 assert!(first[1].dir);
1309 assert!(got[1].is_none(), "a missing root");
1310 for bad in [
1311 serde_json::json!({ "ok": 0, "res": [] }),
1312 serde_json::json!({ "ok": 1 }),
1313 serde_json::json!({ "ok": 1, "res": [] }),
1314 serde_json::json!({ "ok": 1, "res": [{ "i": 5, "x": 1, "e": [] }, { "i": 1, "x": 1, "e": [] }] }),
1315 serde_json::json!({ "ok": 1, "res": [{ "i": 0, "x": 1, "e": [["a"]] }, { "i": 1, "x": 0 }] }),
1316 ] {
1317 assert!(parse_enumeration(&bad, 2).is_err(), "{bad}");
1318 }
1319 }
1320
1321 fn powershell() -> std::path::PathBuf {
1324 for name in ["pwsh", "powershell", "powershell.exe"] {
1325 let Some(paths) = std::env::var_os("PATH") else {
1326 continue;
1327 };
1328 for dir in std::env::split_paths(&paths) {
1329 let candidate = dir.join(name);
1330 if candidate.is_file() {
1331 return candidate;
1332 }
1333 }
1334 }
1335 for fixed in [
1336 "/usr/local/bin/pwsh",
1337 "/opt/homebrew/bin/pwsh",
1338 "/usr/bin/pwsh",
1339 ] {
1340 if std::path::Path::new(fixed).is_file() {
1341 return fixed.into();
1342 }
1343 }
1344 panic!(
1345 "no pwsh or powershell on PATH: install PowerShell 7 (macOS: `brew install --cask powershell`; Debian/Ubuntu: the Microsoft packages) so the script-logic tests can run. These tests are never skipped."
1346 );
1347 }
1348
1349 fn run_script(script: &WinScript) -> std::process::Output {
1352 let command = encoded_command(script, COMMAND_BUDGET).expect("command");
1353 let encoded = command
1354 .as_str()
1355 .strip_prefix("powershell -NoProfile -NonInteractive -EncodedCommand ")
1356 .expect("prefix");
1357 std::process::Command::new(powershell())
1358 .args(["-NoProfile", "-NonInteractive", "-EncodedCommand", encoded])
1359 .stdin(std::process::Stdio::null())
1360 .output()
1361 .expect("run powershell")
1362 }
1363
1364 #[test]
1365 fn winps_script_logic() {
1366 let dir = tempfile::tempdir().expect("tempdir");
1367 let src = dir.path().join("src file.txt");
1368 let dst = dir.path().join("dst; echo 'hi' $(x).txt");
1369 std::fs::write(&src, b"new").expect("src");
1370 std::fs::write(&dst, b"old").expect("dst");
1371
1372 let moved = run_script(&WinScript::move_replace(
1374 src.to_str().expect("utf8"),
1375 dst.to_str().expect("utf8"),
1376 ));
1377 assert!(moved.status.success(), "{moved:?}");
1378 let value = parse_ign_result(&moved.stdout).expect("sentinel");
1379 assert_eq!(value["ok"], 1);
1380 assert_eq!(std::fs::read(&dst).expect("dst"), b"new");
1381 assert!(!src.exists(), "the source was moved");
1382
1383 let missing = run_script(&WinScript::move_replace(
1385 dir.path().join("nope").to_str().expect("utf8"),
1386 dst.to_str().expect("utf8"),
1387 ));
1388 assert!(!missing.status.success(), "a missing source must fail");
1389 assert!(parse_ign_result(&missing.stdout).is_err());
1390
1391 let seven = run_script(&WinScript {
1393 name: "seven",
1394 body: "exit 7",
1395 params: Value::Null,
1396 });
1397 assert_eq!(seven.status.code(), Some(7));
1398
1399 let unicode = run_script(&WinScript {
1401 name: "unicode",
1402 body: "E @{s=\"caf\u{e9} \u{1f600}\"}",
1403 params: Value::Null,
1404 });
1405 assert!(unicode.status.success(), "{unicode:?}");
1406 assert!(unicode.stdout.is_ascii(), "stdout must be ASCII");
1407 let value = parse_ign_result(&unicode.stdout).expect("sentinel");
1408 assert_eq!(value["s"], "caf\u{e9} \u{1f600}");
1409 }
1410
1411 #[test]
1412 fn winps_enumerate_reports_files_dirs_and_reparse_points() {
1413 let dir = tempfile::tempdir().expect("tempdir");
1414 let root = dir.path().join("root");
1415 std::fs::create_dir_all(root.join("a b").join("c")).expect("dirs");
1416 std::fs::write(root.join("top.json"), b"12345").expect("file");
1417 std::fs::write(root.join("a b").join("caf\u{e9}.json"), b"xyz").expect("file");
1418 std::fs::write(root.join("a b").join("c").join("deep.json"), b"").expect("file");
1419 let absent = dir.path().join("absent");
1420 let run = |roots: &[String]| {
1421 let out = run_script(&WinScript::enumerate("", roots));
1422 assert!(out.status.success(), "{out:?}");
1423 parse_enumeration(
1424 &parse_ign_result(&out.stdout).expect("sentinel"),
1425 roots.len(),
1426 )
1427 .expect("enumeration")
1428 };
1429 let roots = vec![
1430 root.to_str().expect("utf8").to_string(),
1431 absent.to_str().expect("utf8").to_string(),
1432 ];
1433 let got = run(&roots);
1434 let mut entries: Vec<(String, bool, u64, bool)> = got[0]
1435 .as_ref()
1436 .expect("root exists")
1437 .iter()
1438 .map(|e| (e.rel.clone(), e.dir, e.size, e.reparse))
1439 .collect();
1440 entries.sort();
1441 assert_eq!(
1442 entries,
1443 [
1444 ("a b".to_string(), true, 0, false),
1445 ("a b/c".to_string(), true, 0, false),
1446 ("a b/c/deep.json".to_string(), false, 0, false),
1447 ("a b/caf\u{e9}.json".to_string(), false, 3, false),
1448 ("top.json".to_string(), false, 5, false),
1449 ]
1450 );
1451 assert!(got[1].is_none(), "a missing root is reported as missing");
1452
1453 let empty = dir.path().join("empty");
1455 std::fs::create_dir(&empty).expect("empty");
1456 let got = run(&[empty.to_str().expect("utf8").to_string()]);
1457 assert_eq!(got[0].as_ref().expect("exists").len(), 0);
1458
1459 #[cfg(unix)]
1461 {
1462 std::os::unix::fs::symlink(&root, root.join("link")).expect("symlink");
1463 let got = run(&[root.to_str().expect("utf8").to_string()]);
1464 let link = got[0]
1465 .as_ref()
1466 .expect("exists")
1467 .iter()
1468 .find(|e| e.rel == "link")
1469 .expect("the link is reported");
1470 assert!(link.reparse, "a symlink is flagged: {link:?}");
1472 }
1473 }
1474
1475 #[test]
1479 fn winps_enumerate_children_reports_direct_children_only() {
1480 let dir = tempfile::tempdir().expect("tempdir");
1481 let root = dir.path().join("root");
1482 std::fs::create_dir_all(root.join("a b").join("c")).expect("dirs");
1483 std::fs::write(root.join("top.json"), b"12345").expect("file");
1484 std::fs::write(root.join("a b").join("x.json"), b"xyz").expect("file");
1485 std::fs::write(root.join("a b").join("c").join("deep.json"), b"").expect("file");
1486 let absent = dir.path().join("absent");
1487 let roots = vec![
1488 root.to_str().expect("utf8").to_string(),
1489 absent.to_str().expect("utf8").to_string(),
1490 ];
1491 let run = |roots: &[String]| {
1492 let out = run_script(&WinScript::enumerate_children("", roots));
1493 assert!(out.status.success(), "{out:?}");
1494 parse_enumeration(
1495 &parse_ign_result(&out.stdout).expect("sentinel"),
1496 roots.len(),
1497 )
1498 .expect("enumeration")
1499 };
1500 let got = run(&roots);
1501 let listed = got[0].as_ref().expect("root exists");
1502 let mut entries: Vec<(String, bool, u64, bool)> = listed
1503 .iter()
1504 .map(|e| (e.rel.clone(), e.dir, e.size, e.reparse))
1505 .collect();
1506 entries.sort();
1507 assert_eq!(
1508 entries,
1509 [
1510 ("a b".to_string(), true, 0, false),
1511 ("top.json".to_string(), false, 5, false),
1512 ]
1513 );
1514 let top = listed.iter().find(|e| e.rel == "top.json").expect("top");
1515 assert!(top.full_len >= root.to_str().expect("utf8").len() + "/top.json".len());
1516 assert!(got[1].is_none(), "a missing root is reported as missing");
1517
1518 #[cfg(unix)]
1519 {
1520 std::os::unix::fs::symlink(root.join("a b"), root.join("link")).expect("symlink");
1521 let got = run(&roots[..1]);
1522 let listed = got[0].as_ref().expect("exists");
1523 let link = listed
1524 .iter()
1525 .find(|e| e.rel == "link")
1526 .expect("the link is reported");
1527 assert!(link.reparse, "a symlink is flagged: {link:?}");
1528 assert!(
1529 listed.iter().all(|e| !e.rel.contains('/')),
1530 "a reparse point is not followed: {listed:?}"
1531 );
1532 }
1533 }
1534
1535 #[test]
1539 fn enumerate_children_never_descends() {
1540 let body = ENUMERATE_CHILDREN;
1541 assert!(body.bytes().all(|b| (0x20..=0x7e).contains(&b)));
1542 assert!(!body.contains("Stack") && !body.contains("Push"), "{body}");
1543 assert!(ENUMERATE.contains("Collections.Stack") && ENUMERATE.contains(".Push("));
1544 let root = "C:\\Program Files\\Inductive Automation\\Ignition\\data\\config\\resources\\core\\ignition\\tag-provider".to_string();
1545 assert!(
1546 encoded_command(
1547 &WinScript::enumerate_children(LONG_PATH_PREFIX, &[root]),
1548 COMMAND_BUDGET
1549 )
1550 .is_ok()
1551 );
1552 }
1553
1554 #[test]
1561 fn winps_enumerate_rel_paths_do_not_depend_on_the_root_spelling() {
1562 let dir = tempfile::tempdir().expect("tempdir");
1563 let root = dir.path().join("root");
1564 std::fs::create_dir_all(root.join("a b").join("c")).expect("dirs");
1565 std::fs::write(root.join("top.json"), b"12345").expect("file");
1566 std::fs::write(root.join("a b").join("c").join("deep.json"), b"").expect("file");
1567 let base = dir.path().to_str().expect("utf8");
1568 let sep = std::path::MAIN_SEPARATOR;
1569 let expected = [
1570 ("a b".to_string(), true, 0, false),
1571 ("a b/c".to_string(), true, 0, false),
1572 ("a b/c/deep.json".to_string(), false, 0, false),
1573 ("top.json".to_string(), false, 5, false),
1574 ];
1575 for spelling in [
1576 format!("{base}{sep}root"),
1578 format!("{base}{sep}.{sep}root"),
1579 format!("{base}{sep}{sep}root"),
1580 format!("{base}{sep}root{sep}"),
1581 ] {
1582 let roots = [spelling.clone()];
1583 let out = run_script(&WinScript::enumerate("", &roots));
1584 assert!(out.status.success(), "{spelling}: {out:?}");
1585 let got = parse_enumeration(&parse_ign_result(&out.stdout).expect("sentinel"), 1)
1586 .expect("enumeration");
1587 let mut entries: Vec<(String, bool, u64, bool)> = got[0]
1588 .as_ref()
1589 .expect("root exists")
1590 .iter()
1591 .map(|e| (e.rel.clone(), e.dir, e.size, e.reparse))
1592 .collect();
1593 entries.sort();
1594 assert_eq!(entries, expected, "root spelled {spelling:?}");
1595 }
1596 }
1597
1598 #[test]
1601 fn windows_component_gate_refuses_what_posix_refuses_and_more() {
1602 for ok in [
1603 "plain",
1604 "MES db,1",
1605 "Caf\u{e9} DB",
1606 ".resources",
1607 "Program Files (x86)",
1608 "a.b",
1609 "%43%4f%4e",
1610 "CONSOLE",
1611 "NULL1",
1612 "COM10",
1613 ] {
1614 assert_eq!(windows_component_problem(ok), None, "{ok:?} is allowed");
1615 }
1616 for bad in [
1617 "",
1619 ".",
1620 "..",
1621 "a/b",
1622 "a\\b",
1623 "a\0b",
1624 "a\nb",
1625 "C:",
1627 "C:x",
1628 "a:b",
1629 "name:stream",
1630 "name::$DATA",
1631 "\\\\?\\C:",
1633 "?",
1634 "a.",
1636 "a ",
1637 "a..",
1638 "CON",
1640 "con",
1641 "Nul",
1642 "aux.txt",
1643 "PRN.json",
1644 "COM1",
1645 "lpt9.cfg",
1646 "a<b",
1648 "a>b",
1649 "a|b",
1650 "a\"b",
1651 "a*b",
1652 ] {
1653 assert!(
1654 windows_component_problem(bad).is_some(),
1655 "{bad:?} must be refused"
1656 );
1657 }
1658 }
1659
1660 #[test]
1661 fn windows_absolute_path_gate() {
1662 for ok in [
1663 "/C:/Users/svc",
1664 "/c:/Users/svc/ign-deploy/gw/production/staging/run-1",
1665 "/D:/a b/c",
1666 ] {
1667 assert_eq!(windows_absolute_path_problem(ok), None, "{ok}");
1668 }
1669 for bad in [
1670 "",
1671 "/",
1672 "/C:",
1673 "C:/Users/svc",
1674 "/Users/svc",
1675 "/C:/Users/../x",
1676 "/C:/Users/./x",
1677 "/C:/Users//x",
1678 "/C:/Users/svc/D:/x",
1679 "/C:/Users/svc/x:stream",
1680 "/C:/Users/svc/x.",
1681 "/C:/Users/svc/NUL",
1682 "//server/share/x",
1683 "/C:\\Users",
1684 "/C:/Users/svc\0",
1685 "/\\\\?\\C:/x",
1686 "/CC:/x",
1687 ] {
1688 assert!(
1689 windows_absolute_path_problem(bad).is_some(),
1690 "{bad:?} must be refused"
1691 );
1692 }
1693 }
1694
1695 #[test]
1696 fn parse_enumeration_applies_the_windows_gate_to_every_path() {
1697 let entry = |rel: &str| {
1698 serde_json::json!({
1699 "ok": 1,
1700 "res": [{ "i": 0, "x": 1, "e": [[rel, 0, 1, 0, 50]] }]
1701 })
1702 };
1703 assert!(parse_enumeration(&entry("a\\b.json"), 1).is_ok());
1704 for bad in [
1705 "..\\x",
1706 "a\\..\\b",
1707 "\\\\?\\C:\\x",
1708 "C:\\x",
1709 "/abs",
1710 "a\\\\b",
1711 "a:b\\c",
1712 "a\\b.",
1713 "a\\NUL",
1714 "",
1715 "a\0b",
1716 ] {
1717 assert!(
1718 parse_enumeration(&entry(bad), 1).is_err(),
1719 "{bad:?} must be refused"
1720 );
1721 }
1722 }
1723
1724 fn roots(staging: &str, collection: &str, deploy: &str) -> StubRoots {
1727 StubRoots {
1728 staging: staging.to_string(),
1729 collection: collection.to_string(),
1730 deploy: deploy.to_string(),
1731 }
1732 }
1733
1734 fn sha(fill: char) -> String {
1735 std::iter::repeat_n(fill, 64).collect()
1736 }
1737
1738 #[test]
1739 fn stub_fits_the_budget() {
1740 for len in [60usize, 150, 240, 259] {
1744 let root = |tag: char| format!("C:\\{}", tag.to_string().repeat(len - 3));
1745 let stub = script_stub(
1746 &sha('a'),
1747 &sha('b'),
1748 "restore",
1749 &roots(&root('s'), &root('c'), &root('d')),
1750 );
1751 let command = encoded_command(&stub, COMMAND_BUDGET)
1752 .unwrap_or_else(|why| panic!("{len}-character roots: {why}"));
1753 eprintln!(
1754 "stub with {len}-character roots: {} characters",
1755 command.as_str().chars().count()
1756 );
1757 assert!(
1758 command.as_str().chars().count() <= COMMAND_BUDGET,
1759 "{len}-character roots"
1760 );
1761 }
1762 let accented = format!("C:\\Users\\{}", "\u{e9}".repeat(100));
1764 let stub = script_stub(
1765 &sha('a'),
1766 &sha('b'),
1767 "commit",
1768 &roots(&accented, &accented, &accented),
1769 );
1770 assert!(encoded_command(&stub, COMMAND_BUDGET).is_ok());
1771 }
1772
1773 #[test]
1774 fn stub_body_is_fixed_text_and_carries_no_path_or_hash() {
1775 let stub = script_stub(
1776 &sha('a'),
1777 &sha('b'),
1778 "commit",
1779 &roots(
1780 "C:\\Users\\svc\\ign-deploy\\gw\\production\\staging\\run-1",
1781 "C:\\Data\\config\\resources\\production",
1782 "C:\\Users\\svc\\ign-deploy\\gw\\production",
1783 ),
1784 );
1785 assert_eq!(stub.body, STUB);
1786 assert!(STUB.bytes().all(|b| (0x20..0x7f).contains(&b)), "{STUB}");
1788 assert!(!STUB.contains("ign-deploy") && !STUB.contains("Users"));
1789 let text = decode_command(&encoded_command(&stub, COMMAND_BUDGET).expect("command"));
1791 let blob = text
1792 .split("FromBase64String('")
1793 .nth(1)
1794 .and_then(|rest| rest.split('\'').next())
1795 .expect("blob");
1796 let json = base64::engine::general_purpose::STANDARD
1797 .decode(blob)
1798 .expect("blob base64");
1799 let value: Value = serde_json::from_slice(&json).expect("json");
1800 assert_eq!(value["o"], "commit");
1801 assert_eq!(value["ss"], sha('a'));
1802 assert_eq!(value["ws"], sha('b'));
1803 assert_eq!(
1804 value["t"],
1805 "C:\\Users\\svc\\ign-deploy\\gw\\production\\staging\\run-1"
1806 );
1807 assert!(!text.contains("staging"));
1809 assert!(text.ends_with(STUB));
1810 }
1811
1812 #[test]
1813 fn stub_verifies_before_it_runs_and_hashes_the_bytes_it_runs() {
1814 let verify = STUB.find("exit 97").expect("the mismatch exit");
1817 let run = STUB.find("[ScriptBlock]::Create").expect("the run");
1818 assert!(verify < run);
1819 assert_eq!(STUB.matches("ReadAllBytes").count(), 2);
1820 assert!(STUB.contains("GetString($b)") && STUB.contains("GetString($w)"));
1821 assert!(!STUB.contains("ReadAllText"));
1822 assert!(!STUB.contains("-File") && !STUB.contains("Invoke-Expression"));
1823 }
1824
1825 #[test]
1826 fn the_shipped_script_is_ascii_text_the_stub_can_hash() {
1827 use crate::deploy::transport::batch::{SCRIPT_PS1, script_ps1_sha256};
1828 assert!(SCRIPT_PS1.is_ascii(), "the script is pure ASCII");
1829 assert!(!SCRIPT_PS1.starts_with('\u{feff}'), "no byte order mark");
1830 assert!(SCRIPT_PS1.contains("IGN-STOP") || SCRIPT_PS1.contains("E $S.Res"));
1831 assert_eq!(
1832 script_ps1_sha256(),
1833 crate::deploy::tree::sha256_hex(SCRIPT_PS1.as_bytes())
1834 );
1835 assert_eq!(script_ps1_sha256().len(), 64);
1836 assert!(SCRIPT_PS1.contains("\nparam([string]$Op"));
1839 assert!(!SCRIPT_PS1.contains("$MyInvocation") && !SCRIPT_PS1.contains("$PSScriptRoot"));
1840 }
1841
1842 const ALIASES_5_1: &str = "% ? ac asnp cat cd cfs chdir clc clear clhy cli clp cls clv cnsn compare copy cp cpi cpp curl cvpa dbp del diff dir dnsn ebp echo epal epcsv epsn erase etsn exsn fc fhx fl foreach ft fw gal gbp gc gci gcm gcs gdr ghy gi gjb gl gm gmo gp gps gpv group gsn gsnp gsv gu gv gwmi h history icm iex ihy ii ipal ipcsv ipmo ipsn irm ise iwmi iwr kill lp ls man md measure mi mount move mp mv nal ndr ni nmo npssc nsn nv ogv oh popd ps pushd pwd r rbp rcjb rcsn rd rdr ren ri rjb rm rmdir rmo rni rnp rp rsn rsnp rujb rv rvpa rwmi sajb sal saps sasv sbp sc select set shcm si sl sleep sls sort sp spjb spps spsv start sujb sv swmi tee trcm type wget where wjb write";
1847
1848 #[test]
1849 fn the_shipped_script_names_no_function_like_an_alias_or_a_cmdlet() {
1850 use crate::deploy::transport::batch::SCRIPT_PS1;
1851 let aliases: std::collections::BTreeSet<&str> = ALIASES_5_1.split(' ').collect();
1852 let mut seen = 0;
1853 for line in SCRIPT_PS1.lines() {
1854 let Some(rest) = line.strip_prefix("function ") else {
1855 continue;
1856 };
1857 let name: String = rest
1858 .chars()
1859 .take_while(|c| c.is_ascii_alphanumeric())
1860 .collect();
1861 seen += 1;
1862 assert!(
1863 !aliases.contains(name.to_ascii_lowercase().as_str()),
1864 "function {name} has the name of a Windows PowerShell alias, which wins over it"
1865 );
1866 assert!(!name.contains('-'), "function {name} reads like a cmdlet");
1867 }
1868 assert!(
1869 seen > 20,
1870 "the script defines its helpers as functions ({seen})"
1871 );
1872 }
1873
1874 #[test]
1881 fn the_shipped_script_runs_every_operation_commit_sh_has_and_keeps_the_hardening() {
1882 use crate::deploy::transport::batch::{SCRIPT_PS1, SCRIPT_SH};
1883 let ops = [
1884 "commit",
1885 "restore",
1886 "mirror",
1887 "fingerprint",
1888 "probe",
1889 "finalize",
1890 "discard",
1891 "verify",
1892 "unstage",
1893 ];
1894 let mut sh_order: Vec<usize> = Vec::new();
1895 let mut ps_order: Vec<usize> = Vec::new();
1896 for op in ops {
1897 sh_order.push(
1898 SCRIPT_SH
1899 .find(&format!(" {op}) op_{op} ;;"))
1900 .unwrap_or_else(|| panic!("commit.sh has no {op}")),
1901 );
1902 let ps = SCRIPT_PS1
1903 .find(&format!(" '{op}' {{ "))
1904 .unwrap_or_else(|| panic!("commit.ps1 has no {op}"));
1905 ps_order.push(ps);
1906 }
1907 let ranks = |v: &[usize]| {
1908 let mut idx: Vec<usize> = (0..v.len()).collect();
1909 idx.sort_by_key(|i| v[*i]);
1910 idx
1911 };
1912 assert_eq!(
1913 ranks(&sh_order),
1914 ranks(&ps_order),
1915 "the same dispatch order"
1916 );
1917 let code: Vec<&str> = SCRIPT_PS1
1918 .lines()
1919 .filter(|l| !l.trim_start().starts_with('#'))
1920 .collect();
1921 let uses = |needle: &str| code.iter().filter(|l| l.contains(needle)).count();
1922 assert_eq!(uses("Move-Item"), 1, "only MoveReplace moves a file");
1923 assert_eq!(uses("Remove-Item"), 0, "trees go with Directory.Delete");
1924 assert!(uses("[IO.Directory]::Delete((Xp $pth), $true)") >= 1);
1925 let mirror = SCRIPT_PS1
1927 .split("function OpMirror")
1928 .nth(1)
1929 .and_then(|rest| rest.split("# ---- main").next())
1930 .expect("OpMirror");
1931 assert!(mirror.contains("Moves"), "{mirror}");
1932 let moves = SCRIPT_PS1
1933 .split("function Moves")
1934 .nth(1)
1935 .and_then(|rest| rest.split("\nfunction ").next())
1936 .expect("Moves");
1937 assert!(moves.contains("MoveOne"));
1938 let move_one = SCRIPT_PS1
1939 .split("function MoveOne")
1940 .nth(1)
1941 .and_then(|rest| rest.split("\nfunction ").next())
1942 .expect("MoveOne");
1943 assert!(move_one.contains("MoveWithRetries") && move_one.contains("ResetAcl"));
1944 }
1945
1946 #[cfg(windows)]
1951 #[test]
1952 fn stub_refuses_hash_mismatch() {
1953 use crate::deploy::tree::sha256_hex;
1954 let dir = tempfile::tempdir().expect("temp dir");
1955 let script = "param($o,$t,$c,$d,$w) [Console]::Out.WriteLine('RAN '+$o)\n";
1956 let work = "ign-work 1\n";
1957 std::fs::write(dir.path().join("commit.ps1"), script).expect("script");
1958 std::fs::write(dir.path().join("work.tsv"), work).expect("work");
1959 let staging = dir.path().to_string_lossy().into_owned();
1960 let run = |script_sha: &str, work_sha: &str| {
1961 let stub = script_stub(
1962 script_sha,
1963 work_sha,
1964 "commit",
1965 &roots(&staging, &staging, &staging),
1966 );
1967 let command = encoded_command(&stub, COMMAND_BUDGET).expect("command");
1968 let text = command.as_str();
1969 let b64 = text
1970 .strip_prefix("powershell -NoProfile -NonInteractive -EncodedCommand ")
1971 .expect("prefix");
1972 std::process::Command::new("powershell")
1973 .args(["-NoProfile", "-NonInteractive", "-EncodedCommand", b64])
1974 .output()
1975 .expect("powershell")
1976 };
1977 let good = run(&sha256_hex(script.as_bytes()), &sha256_hex(work.as_bytes()));
1978 assert_eq!(good.status.code(), Some(0), "{good:?}");
1979 assert!(String::from_utf8_lossy(&good.stdout).contains("RAN commit"));
1980 for (script_sha, work_sha) in [
1981 (sha('0'), sha256_hex(work.as_bytes())),
1982 (sha256_hex(script.as_bytes()), sha('0')),
1983 ] {
1984 let bad = run(&script_sha, &work_sha);
1985 assert_eq!(bad.status.code(), Some(97), "{bad:?}");
1986 assert!(!String::from_utf8_lossy(&bad.stdout).contains("RAN"));
1987 }
1988 }
1989}