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