1use std::ffi::{OsStr, OsString};
35use std::fmt;
36use std::path::{Path, PathBuf};
37use std::sync::Arc;
38use std::time::Duration;
39
40use base64::Engine as _;
41
42use super::process::{ProcessOutput, ProcessRunner};
43use super::winps::{self, COMMAND_BUDGET, WinScript};
44use crate::deploy::target::{DEFAULT_CONNECT_TIMEOUT_S, DEFAULT_OP_TIMEOUT_S, Ssh};
45use crate::deploy::tree::read_repo_file;
46use crate::error::CoreError;
47
48pub const STDERR_TAIL_LINES: usize = 5;
50
51const DETAIL_MAX_BYTES: usize = 1024;
53
54pub fn safe_remote_path(path: &str) -> Result<(), String> {
69 refuse_unsafe_path("remote", path)
70}
71
72fn refuse_unsafe_path(kind: &str, path: &str) -> Result<(), String> {
75 if path.is_empty() {
76 return Err(format!("{kind} path must not be empty"));
77 }
78 for c in path.chars() {
79 let why = match c {
80 c if c.is_control() => "a control character",
81 '"' => "a double quote",
82 '\\' => "a backslash",
83 '*' | '?' | '[' | ']' | '{' | '}' => "an sftp glob character",
84 _ => continue,
85 };
86 return Err(format!(
87 "{kind} path {path:?} contains {why} ({c:?}), which cannot be placed safely in an sftp batch line"
88 ));
89 }
90 Ok(())
91}
92
93pub fn safe_local_path(path: &str) -> Result<(), String> {
100 refuse_unsafe_path("local", path)?;
101 refuse_leading_dash("local", path)
102}
103
104fn refuse_leading_dash(kind: &str, path: &str) -> Result<(), String> {
105 if path.starts_with('-') {
106 return Err(format!(
107 "{kind} path {path:?} starts with '-', which the sftp client would read as an option"
108 ));
109 }
110 Ok(())
111}
112
113fn strip_controls(text: &str) -> String {
115 let mut out = String::with_capacity(text.len());
116 let mut chars = text.chars().peekable();
117 while let Some(c) = chars.next() {
118 if c == '\u{1b}' {
119 if chars.peek() == Some(&'[') {
120 chars.next();
121 for next in chars.by_ref() {
123 if ('\u{40}'..='\u{7e}').contains(&next) {
124 break;
125 }
126 }
127 }
128 continue;
129 }
130 if c == '\n' || !c.is_control() {
131 out.push(c);
132 }
133 }
134 out
135}
136
137pub fn scrub_stderr(stderr: &[u8], temp_dir: Option<&Path>) -> String {
144 let mut text = strip_controls(&String::from_utf8_lossy(stderr));
145 if let Some(dir) = temp_dir {
146 let native = dir.display().to_string();
147 let slashed = native.replace('\\', "/");
148 if !native.is_empty() {
149 text = text.replace(&native, "<tmp>");
150 }
151 if !slashed.is_empty() && slashed != native {
152 text = text.replace(&slashed, "<tmp>");
153 }
154 }
155 let lines: Vec<&str> = text.lines().collect();
156 let from = lines.len().saturating_sub(STDERR_TAIL_LINES);
157 let mut out = lines[from..].join("\n");
158 if out.len() > DETAIL_MAX_BYTES {
159 let mut cut = out.len() - DETAIL_MAX_BYTES;
160 while !out.is_char_boundary(cut) {
161 cut += 1;
162 }
163 out.drain(..cut);
164 }
165 out
166}
167
168#[cfg(windows)]
170pub const NULL_DEVICE: &str = "NUL";
171#[cfg(not(windows))]
173pub const NULL_DEVICE: &str = "/dev/null";
174
175pub fn normalize_private_key(text: &str) -> String {
180 let mut out = text.replace("\r\n", "\n").replace('\r', "\n");
181 let trimmed = out.trim_end_matches('\n').len();
182 out.truncate(trimmed);
183 out.push('\n');
184 out
185}
186
187fn option_path(path: &Path) -> Result<String, String> {
192 let text = path
193 .to_str()
194 .ok_or_else(|| "a path that is not valid UTF-8 cannot be passed to ssh".to_string())?;
195 if let Some(bad) = text
196 .chars()
197 .find(|c| *c == '"' || *c == '$' || c.is_control())
198 {
199 return Err(format!(
200 "path {text:?} contains {bad:?}, which cannot be passed safely as an ssh option"
201 ));
202 }
203 let mut value = if cfg!(windows) {
204 text.replace('\\', "/")
205 } else {
206 text.to_string()
207 };
208 value = value.replace('%', "%%");
209 if value.chars().any(char::is_whitespace) {
210 value = format!("\"{value}\"");
211 }
212 Ok(value)
213}
214
215pub fn hardened_options(
244 known_hosts: &Path,
245 identity: Option<&Path>,
246 connect_timeout_s: u32,
247) -> Result<Vec<(String, String)>, String> {
248 let pair = |k: &str, v: &str| (k.to_string(), v.to_string());
249 let mut options = vec![
250 pair("BatchMode", "yes"),
251 pair("StrictHostKeyChecking", "yes"),
252 ("UserKnownHostsFile".to_string(), option_path(known_hosts)?),
253 pair("GlobalKnownHostsFile", NULL_DEVICE),
254 pair("UpdateHostKeys", "no"),
255 pair("VerifyHostKeyDNS", "no"),
256 pair("PreferredAuthentications", "publickey"),
257 pair("PasswordAuthentication", "no"),
258 pair("KbdInteractiveAuthentication", "no"),
259 pair("NumberOfPasswordPrompts", "0"),
260 ];
261 if let Some(identity) = identity {
262 option_path(identity)?;
264 options.push(pair("IdentitiesOnly", "yes"));
265 }
266 options.extend([
267 pair("ForwardAgent", "no"),
268 pair("ForwardX11", "no"),
269 pair("ClearAllForwardings", "yes"),
270 pair("PermitLocalCommand", "no"),
271 pair("ControlMaster", "no"),
272 pair("ControlPath", "none"),
273 ("ConnectTimeout".to_string(), connect_timeout_s.to_string()),
274 pair("ServerAliveInterval", "5"),
275 pair("ServerAliveCountMax", "3"),
276 pair("LogLevel", "ERROR"),
277 ]);
278 Ok(options)
279}
280
281fn setup_failed(label: &str, detail: impl Into<String>) -> CoreError {
282 CoreError::TransportFailed {
283 operation: "ssh-setup".to_string(),
284 node: label.to_string(),
285 detail: detail.into(),
286 }
287}
288
289const TEMP_PREFIX: &str = "ign-ssh-";
291
292pub(crate) const STALE_TEMP_AGE: Duration = Duration::from_secs(6 * 60 * 60);
296
297pub(crate) fn sweep_stale_temp_dirs(
307 root: &Path,
308 owner: Option<u32>,
309 now: std::time::SystemTime,
310 max_age: Duration,
311) -> usize {
312 let Ok(read) = std::fs::read_dir(root) else {
313 return 0;
314 };
315 let mut removed = 0;
316 for entry in read.flatten() {
317 if !entry.file_name().to_string_lossy().starts_with(TEMP_PREFIX) {
318 continue;
319 }
320 let Ok(meta) = std::fs::symlink_metadata(entry.path()) else {
321 continue;
322 };
323 if meta.file_type().is_symlink() || !meta.is_dir() {
324 continue;
325 }
326 #[cfg(unix)]
327 {
328 use std::os::unix::fs::MetadataExt;
329 if owner.is_some_and(|uid| uid != meta.uid()) {
330 continue;
331 }
332 }
333 #[cfg(not(unix))]
334 let _ = owner;
335 let old = meta
336 .modified()
337 .ok()
338 .and_then(|modified| now.duration_since(modified).ok())
339 .is_some_and(|age| age >= max_age);
340 if old && std::fs::remove_dir_all(entry.path()).is_ok() {
341 removed += 1;
342 }
343 }
344 removed
345}
346
347fn sweep_own_stale_temp_dirs(own: &Path) {
350 let Some(root) = own.parent() else { return };
351 #[cfg(unix)]
352 let owner = {
353 use std::os::unix::fs::MetadataExt;
354 std::fs::symlink_metadata(own).ok().map(|m| m.uid())
355 };
356 #[cfg(not(unix))]
357 let owner = None;
358 #[cfg(unix)]
359 if owner.is_none() {
360 return;
361 }
362 sweep_stale_temp_dirs(root, owner, std::time::SystemTime::now(), STALE_TEMP_AGE);
363}
364
365fn write_private(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
367 use std::io::Write;
368 let mut options = std::fs::OpenOptions::new();
369 options.write(true).create_new(true);
370 #[cfg(unix)]
371 {
372 use std::os::unix::fs::OpenOptionsExt;
373 options.mode(0o600);
374 }
375 let mut file = options.open(path)?;
376 file.write_all(bytes)?;
377 file.flush()
378}
379
380#[cfg(windows)]
388fn restrict_to_owner(file: &Path, label: &str) -> Result<(), CoreError> {
389 crate::deploy::owner_acl::restrict_to_current_user(file, "R").map_err(|why| {
390 setup_failed(
391 label,
392 format!("cannot restrict the key file to the current user: {why}"),
393 )
394 })
395}
396
397#[derive(Debug, Clone, PartialEq, Eq)]
403pub struct RemoteCommand(String);
404
405impl RemoteCommand {
406 pub fn powershell_encoded(script: &str) -> RemoteCommand {
411 let utf16le: Vec<u8> = script.encode_utf16().flat_map(u16::to_le_bytes).collect();
412 let encoded = base64::engine::general_purpose::STANDARD.encode(utf16le);
413 RemoteCommand(format!(
414 "powershell -NoProfile -NonInteractive -EncodedCommand {encoded}"
415 ))
416 }
417
418 pub fn posix(argv: &[&str]) -> Result<RemoteCommand, String> {
426 if argv.is_empty() {
427 return Err("a remote command needs at least a program".to_string());
428 }
429 let mut quoted = Vec::with_capacity(argv.len());
430 for arg in argv {
431 if let Some(bad) = arg.chars().find(|c| c.is_control()) {
432 return Err(format!(
433 "remote command argument {arg:?} contains the control character {bad:?}"
434 ));
435 }
436 quoted.push(format!("'{}'", arg.replace('\'', "'\\''")));
437 }
438 Ok(RemoteCommand(quoted.join(" ")))
439 }
440
441 pub fn as_str(&self) -> &str {
443 &self.0
444 }
445}
446
447#[derive(Debug, Clone, PartialEq, Eq)]
457pub enum SftpOp {
458 Ls { path: String },
460 LsLong { path: String },
462 LsLongAll { path: String },
467 LsLongAllNumeric { path: String },
474 Pwd,
480 Get { remote: String, local: PathBuf },
482 GetRecursive { remote: String, local: PathBuf },
485 Put { local: PathBuf, remote: String },
487 Rename { from: String, to: String },
489 Rm { path: String },
491 Mkdir { path: String },
493 Rmdir { path: String },
495}
496
497impl SftpOp {
498 pub fn render(&self) -> Result<String, String> {
500 self.render_on(cfg!(windows))
501 }
502
503 fn render_on(&self, windows_runner: bool) -> Result<String, String> {
509 let remote = |path: &str| -> Result<String, String> {
510 safe_remote_path(path)?;
511 refuse_leading_dash("remote", path)?;
512 Ok(format!("\"{path}\""))
513 };
514 let local = |path: &Path| -> Result<String, String> {
515 let text = path
516 .to_str()
517 .ok_or_else(|| "a local path that is not valid UTF-8 cannot be used".to_string())?;
518 let text = if windows_runner {
519 text.replace('\\', "/")
520 } else {
521 text.to_string()
522 };
523 safe_local_path(&text)?;
524 Ok(format!("\"{text}\""))
525 };
526 Ok(match self {
527 SftpOp::Ls { path } => format!("@ls -1 {}", remote(path)?),
528 SftpOp::LsLong { path } => format!("@ls -l {}", remote(path)?),
529 SftpOp::LsLongAll { path } => format!("@ls -la {}", remote(path)?),
530 SftpOp::LsLongAllNumeric { path } => format!("@ls -lan {}", remote(path)?),
531 SftpOp::Pwd => "@pwd".to_string(),
532 SftpOp::Get {
533 remote: r,
534 local: l,
535 } => format!("get {} {}", remote(r)?, local(l)?),
536 SftpOp::GetRecursive {
537 remote: r,
538 local: l,
539 } => {
540 format!("get -R {} {}", remote(r)?, local(l)?)
541 }
542 SftpOp::Put {
543 local: l,
544 remote: r,
545 } => format!("put {} {}", local(l)?, remote(r)?),
546 SftpOp::Rename { from, to } => {
547 if same_remote_path(from, to) {
551 return Err(format!(
552 "refused a rename of {from:?} onto itself (on a Windows OpenSSH host that deletes the file)"
553 ));
554 }
555 format!("rename {} {}", remote(from)?, remote(to)?)
556 }
557 SftpOp::Rm { path } => format!("rm {}", remote(path)?),
558 SftpOp::Mkdir { path } => format!("mkdir {}", remote(path)?),
559 SftpOp::Rmdir { path } => format!("rmdir {}", remote(path)?),
560 })
561 }
562}
563
564pub(crate) fn same_remote_path(a: &str, b: &str) -> bool {
569 fn fold(path: &str) -> String {
570 let mut out = String::with_capacity(path.len());
571 let mut last_slash = false;
572 for c in path.chars() {
573 let c = c.to_ascii_lowercase();
574 if c == '/' {
575 if last_slash {
576 continue;
577 }
578 last_slash = true;
579 } else {
580 last_slash = false;
581 }
582 out.push(c);
583 }
584 while out.len() > 1 && out.ends_with('/') {
585 out.pop();
586 }
587 out
588 }
589 fold(a) == fold(b)
590}
591
592#[derive(Debug, Clone, Copy, PartialEq, Eq)]
595pub enum SftpFailure {
596 NotFound,
599 PermissionDenied,
601 Failure,
604 Other,
606}
607
608pub fn classify_sftp_stderr(stderr: &str) -> SftpFailure {
610 let text = stderr.to_ascii_lowercase();
611 if text.contains("permission denied") {
612 SftpFailure::PermissionDenied
613 } else if text.contains("no such file") || text.contains(" not found") {
614 SftpFailure::NotFound
615 } else if text.trim_end().ends_with(": failure") || text.contains(": failure") {
616 SftpFailure::Failure
617 } else {
618 SftpFailure::Other
619 }
620}
621
622fn failing_command_is_mkdir(stderr: &str) -> bool {
628 stderr.lines().any(|line| {
629 line.starts_with("remote mkdir ") && line.to_ascii_lowercase().contains(": failure")
630 })
631}
632
633fn check_batch_line(line: &str) -> Result<(), String> {
637 if line.contains(['\n', '\r']) {
638 return Err(format!(
639 "refused a batch line holding a line break: {line:?}"
640 ));
641 }
642 let head = line.trim_start_matches(|c: char| c.is_whitespace() || c == '@' || c == '-');
643 if head.starts_with('!') {
644 return Err(format!(
645 "refused a batch line starting with '!' (a local shell escape): {line:?}"
646 ));
647 }
648 Ok(())
649}
650
651pub struct RemoteShell {
653 runner: Arc<dyn ProcessRunner>,
654 host: String,
655 port: u16,
656 user: String,
657 temp: Option<tempfile::TempDir>,
659 known_hosts: PathBuf,
660 identity: Option<PathBuf>,
661 options: Vec<(String, String)>,
662 op_timeout: Duration,
663 label: String,
664 destination_override: Option<Vec<OsString>>,
666 local_exec: bool,
669}
670
671impl fmt::Debug for RemoteShell {
672 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
673 f.debug_struct("RemoteShell")
674 .field("host", &self.host)
675 .field("port", &self.port)
676 .field("user", &self.user)
677 .field("label", &self.label)
678 .finish_non_exhaustive()
679 }
680}
681
682impl RemoteShell {
683 pub fn from_ssh(
686 repo: &Path,
687 target_file: &str,
688 label: &str,
689 ssh: &Ssh,
690 runner: Arc<dyn ProcessRunner>,
691 ) -> Result<RemoteShell, CoreError> {
692 Self::from_ssh_with_env(repo, target_file, label, ssh, runner, &|name| {
693 std::env::var(name).ok()
694 })
695 }
696
697 pub fn from_ssh_with_env(
700 repo: &Path,
701 target_file: &str,
702 label: &str,
703 ssh: &Ssh,
704 runner: Arc<dyn ProcessRunner>,
705 env: &dyn Fn(&str) -> Option<String>,
706 ) -> Result<RemoteShell, CoreError> {
707 let hint = format!("{label}.ssh.known_hosts in {target_file}");
708 let pinned = read_repo_file(repo, &ssh.known_hosts, Some(&hint))?;
709 let has_entry = String::from_utf8_lossy(&pinned)
710 .lines()
711 .map(str::trim)
712 .any(|line| !line.is_empty() && !line.starts_with('#'));
713 if !has_entry {
714 return Err(CoreError::deploy_tree_invalid(
715 &ssh.known_hosts,
716 None,
717 None,
718 format!(
719 "has no host key entries (empty or comment-only); {label}.ssh.known_hosts must pin the host key"
720 ),
721 ));
722 }
723
724 let temp = tempfile::Builder::new()
725 .prefix(TEMP_PREFIX)
726 .tempdir()
727 .map_err(|e| {
728 setup_failed(label, format!("cannot create a temp dir ({:?})", e.kind()))
729 })?;
730 sweep_own_stale_temp_dirs(temp.path());
731 #[cfg(unix)]
732 {
733 use std::os::unix::fs::PermissionsExt;
734 std::fs::set_permissions(temp.path(), std::fs::Permissions::from_mode(0o700)).map_err(
735 |e| {
736 setup_failed(
737 label,
738 format!("cannot restrict the temp dir ({:?})", e.kind()),
739 )
740 },
741 )?;
742 }
743 let known_hosts = temp.path().join("known_hosts");
744 write_private(&known_hosts, &pinned).map_err(|e| {
745 setup_failed(
746 label,
747 format!("cannot write the known_hosts copy ({:?})", e.kind()),
748 )
749 })?;
750
751 let identity = match (&ssh.key_file, &ssh.key_env) {
752 (Some(file), _) => {
753 let path = PathBuf::from(file);
754 let regular = std::fs::metadata(&path)
755 .map(|m| m.is_file())
756 .unwrap_or(false);
757 if !regular {
758 return Err(setup_failed(
759 label,
760 format!("{label}.ssh.key_file {file:?} is not a readable regular file"),
761 ));
762 }
763 Some(path)
764 }
765 (None, Some(name)) => {
766 let text = env(name).unwrap_or_default();
767 if text.trim().is_empty() {
768 return Err(setup_failed(
769 label,
770 format!(
771 "environment variable {name} is not set or is empty; it must hold the private key text"
772 ),
773 ));
774 }
775 let key = temp.path().join("key");
776 write_private(&key, normalize_private_key(&text).as_bytes()).map_err(|e| {
777 setup_failed(label, format!("cannot write the key file ({:?})", e.kind()))
778 })?;
779 #[cfg(windows)]
780 restrict_to_owner(&key, label)?;
781 Some(key)
782 }
783 (None, None) => None,
784 };
785
786 let connect_timeout_s = ssh.connect_timeout_s.unwrap_or(DEFAULT_CONNECT_TIMEOUT_S);
787 let op_timeout_s = ssh.op_timeout_s.unwrap_or(DEFAULT_OP_TIMEOUT_S);
788 let options = hardened_options(&known_hosts, identity.as_deref(), connect_timeout_s)
789 .map_err(|why| setup_failed(label, why))?;
790 Ok(RemoteShell {
791 runner,
792 host: ssh.host.clone(),
793 port: ssh.port.unwrap_or(22),
794 user: ssh.user.clone(),
795 temp: Some(temp),
796 known_hosts,
797 identity,
798 options,
799 op_timeout: Duration::from_secs(u64::from(op_timeout_s)),
800 label: label.to_string(),
801 destination_override: None,
802 local_exec: false,
803 })
804 }
805
806 #[doc(hidden)]
810 pub fn local_sftp_server(
811 server: &Path,
812 runner: Arc<dyn ProcessRunner>,
813 op_timeout: Duration,
814 ) -> RemoteShell {
815 RemoteShell {
816 runner,
817 host: "localhost".to_string(),
818 port: 22,
819 user: "ign".to_string(),
820 temp: None,
821 known_hosts: PathBuf::new(),
822 identity: None,
823 options: Vec::new(),
824 op_timeout,
825 label: "nodes.test".to_string(),
826 destination_override: Some(vec![OsString::from("-D"), server.as_os_str().to_owned()]),
827 local_exec: false,
828 }
829 }
830
831 #[doc(hidden)]
839 pub fn local_exec(mut self) -> RemoteShell {
840 self.local_exec = true;
841 self
842 }
843
844 pub fn label(&self) -> &str {
846 &self.label
847 }
848
849 pub fn op_timeout(&self) -> Duration {
851 self.op_timeout
852 }
853
854 pub fn temp_dir(&self) -> Option<&Path> {
856 self.temp.as_ref().map(tempfile::TempDir::path)
857 }
858
859 pub fn known_hosts_copy(&self) -> &Path {
861 &self.known_hosts
862 }
863
864 pub fn identity(&self) -> Option<&Path> {
866 self.identity.as_deref()
867 }
868
869 fn destination(&self) -> OsString {
870 OsString::from(format!("{}@{}", self.user, self.host))
871 }
872
873 fn option_args(&self) -> Vec<OsString> {
874 let mut args = Vec::with_capacity(self.options.len() * 2);
875 for (key, value) in &self.options {
876 args.push(OsString::from("-o"));
877 args.push(OsString::from(format!("{key}={value}")));
878 }
879 args
880 }
881
882 pub fn ssh_argv(&self, remote_command: Option<&RemoteCommand>) -> Vec<OsString> {
886 let mut args = vec![
887 OsString::from("-T"),
888 OsString::from("-p"),
889 OsString::from(self.port.to_string()),
890 ];
891 if let Some(identity) = &self.identity {
892 args.push(OsString::from("-i"));
893 args.push(identity.as_os_str().to_owned());
894 }
895 args.extend(self.option_args());
896 args.push(OsString::from("--"));
897 args.push(self.destination());
898 if let Some(command) = remote_command {
899 args.push(OsString::from(command.as_str()));
900 }
901 args
902 }
903
904 pub fn sftp_argv(&self) -> Vec<OsString> {
907 let mut args: Vec<OsString> = ["-q", "-b", "-"].into_iter().map(OsString::from).collect();
908 if let Some(destination) = &self.destination_override {
909 args.extend(destination.iter().cloned());
910 return args;
911 }
912 args.push(OsString::from("-P"));
913 args.push(OsString::from(self.port.to_string()));
914 if let Some(identity) = &self.identity {
915 args.push(OsString::from("-i"));
916 args.push(identity.as_os_str().to_owned());
917 }
918 args.extend(self.option_args());
919 args.push(OsString::from("--"));
920 args.push(self.destination());
921 args
922 }
923
924 pub async fn sftp_batch(&self, ops: &[SftpOp]) -> Result<ProcessOutput, CoreError> {
928 let mut lines = Vec::with_capacity(ops.len());
929 for op in ops {
930 lines.push(op.render().map_err(|why| self.refused_batch(why))?);
931 }
932 self.send_batch(&lines).await
933 }
934
935 #[cfg(test)]
938 async fn sftp_batch_raw(&self, lines: &[String]) -> Result<ProcessOutput, CoreError> {
939 self.send_batch(lines).await
940 }
941
942 fn refused_batch(&self, detail: String) -> CoreError {
943 CoreError::TransportFailed {
944 operation: "sftp".to_string(),
945 node: self.label.clone(),
946 detail,
947 }
948 }
949
950 async fn send_batch(&self, lines: &[String]) -> Result<ProcessOutput, CoreError> {
953 for line in lines {
954 check_batch_line(line).map_err(|why| self.refused_batch(why))?;
955 }
956 let mut batch = lines.join("\n");
957 batch.push('\n');
958 Ok(self
959 .runner
960 .run(
961 OsStr::new("sftp"),
962 &self.sftp_argv(),
963 Some(batch.into_bytes()),
964 self.op_timeout,
965 )
966 .await)
967 }
968
969 pub fn require_success(
974 &self,
975 operation: &str,
976 out: ProcessOutput,
977 ) -> Result<ProcessOutput, CoreError> {
978 let failure = |detail: String| CoreError::TransportFailed {
979 operation: operation.to_string(),
980 node: self.label.clone(),
981 detail,
982 };
983 let tail = scrub_stderr(&out.stderr, self.temp_dir());
984 if out.timed_out {
985 return Err(failure(format!(
986 "timed out after {}s and was killed",
987 self.op_timeout.as_secs()
988 )));
989 }
990 if out.code != 0 {
991 let class = if out.code == 1 {
996 match classify_sftp_stderr(&String::from_utf8_lossy(&out.stderr)) {
997 SftpFailure::NotFound => " (not found or unreadable)",
998 SftpFailure::PermissionDenied => " (permission denied)",
999 SftpFailure::Failure
1000 if failing_command_is_mkdir(&String::from_utf8_lossy(&out.stderr)) =>
1001 {
1002 " (refused with no reason: the directory may already exist, for example under an 8.3 short spelling, or cannot be created there)"
1003 }
1004 SftpFailure::Failure => " (refused with no reason: a held or non-empty target)",
1005 SftpFailure::Other => "",
1006 }
1007 } else {
1008 ""
1009 };
1010 return Err(failure(if tail.is_empty() {
1011 format!("exit code {}{class}", out.code)
1012 } else {
1013 format!("exit code {}{class}: {tail}", out.code)
1014 }));
1015 }
1016 if !out.stderr.is_empty() {
1017 return Err(failure(format!("unexpected stderr output: {tail}")));
1018 }
1019 Ok(out)
1020 }
1021
1022 pub async fn exec_powershell(
1031 &self,
1032 script: &WinScript,
1033 ) -> Result<serde_json::Value, CoreError> {
1034 self.exec_powershell_within(script, self.op_timeout).await
1035 }
1036
1037 pub async fn exec_powershell_within(
1041 &self,
1042 script: &WinScript,
1043 limit: Duration,
1044 ) -> Result<serde_json::Value, CoreError> {
1045 let failure = |detail: String| CoreError::TransportFailed {
1046 operation: "exec".to_string(),
1047 node: self.label.clone(),
1048 detail,
1049 };
1050 let command = winps::encoded_command(script, COMMAND_BUDGET).map_err(failure)?;
1051 let out = if self.local_exec {
1052 let (program, flag) = if cfg!(windows) {
1055 ("cmd", "/C")
1056 } else {
1057 ("sh", "-c")
1058 };
1059 self.runner
1060 .run(
1061 OsStr::new(program),
1062 &[OsString::from(flag), OsString::from(command.as_str())],
1063 None,
1064 limit,
1065 )
1066 .await
1067 } else {
1068 self.runner
1069 .run(
1070 OsStr::new("ssh"),
1071 &self.ssh_argv(Some(&command)),
1072 None,
1073 limit,
1074 )
1075 .await
1076 };
1077 winps::interpret_output(&out, limit.as_secs(), self.temp_dir())
1078 .map_err(|detail| failure(format!("{}: {detail}", script.name)))
1079 }
1080
1081 pub async fn exec_posix(
1090 &self,
1091 op: &str,
1092 command: &RemoteCommand,
1093 ) -> Result<serde_json::Value, CoreError> {
1094 self.exec_posix_within(op, command, self.op_timeout).await
1095 }
1096
1097 pub async fn exec_posix_within(
1100 &self,
1101 op: &str,
1102 command: &RemoteCommand,
1103 limit: Duration,
1104 ) -> Result<serde_json::Value, CoreError> {
1105 let out = self.run_posix(op, command, limit).await?;
1106 winps::parse_ign_result(&out.stdout).map_err(|detail| self.exec_failure(op, detail))
1107 }
1108
1109 pub async fn exec_posix_status(
1117 &self,
1118 op: &str,
1119 command: &RemoteCommand,
1120 ) -> Result<(), CoreError> {
1121 self.run_posix(op, command, self.op_timeout).await?;
1122 Ok(())
1123 }
1124
1125 fn exec_failure(&self, op: &str, detail: String) -> CoreError {
1126 CoreError::TransportFailed {
1127 operation: "exec".to_string(),
1128 node: self.label.clone(),
1129 detail: format!("{op}: {detail}"),
1130 }
1131 }
1132
1133 async fn run_posix(
1136 &self,
1137 op: &str,
1138 command: &RemoteCommand,
1139 limit: Duration,
1140 ) -> Result<ProcessOutput, CoreError> {
1141 let out = if self.local_exec {
1142 self.runner
1143 .run(
1144 OsStr::new("sh"),
1145 &[OsString::from("-c"), OsString::from(command.as_str())],
1146 None,
1147 limit,
1148 )
1149 .await
1150 } else {
1151 self.runner
1152 .run(
1153 OsStr::new("ssh"),
1154 &self.ssh_argv(Some(command)),
1155 None,
1156 limit,
1157 )
1158 .await
1159 };
1160 if out.timed_out {
1161 return Err(self.exec_failure(
1162 op,
1163 format!("timed out after {}s and was killed", limit.as_secs()),
1164 ));
1165 }
1166 if out.code != 0 {
1167 let tail = scrub_stderr(&out.stderr, self.temp_dir());
1168 let why = match out.code {
1169 STUB_MISMATCH_EXIT => {
1170 "the staged script or work list does not match its hash; nothing ran"
1171 .to_string()
1172 }
1173 code if tail.is_empty() => format!("exit code {code}"),
1174 code => format!("exit code {code}: {tail}"),
1175 };
1176 return Err(self.exec_failure(op, why));
1177 }
1178 Ok(out)
1179 }
1180}
1181
1182pub const STUB_MISMATCH_EXIT: i32 = 97;
1185
1186#[cfg(test)]
1187mod tests {
1188 use super::*;
1189 use std::sync::Mutex;
1190
1191 #[cfg(unix)]
1192 #[test]
1193 fn stale_ssh_temp_dirs_are_swept_and_nothing_else() {
1194 use std::os::unix::fs::MetadataExt;
1197 let root = tempfile::tempdir().expect("root");
1198 let age = |dir: &Path, by: Duration| {
1199 let file = std::fs::File::open(dir).expect("open dir");
1200 file.set_modified(std::time::SystemTime::now() - by)
1201 .expect("set mtime");
1202 };
1203 let old = root.path().join("ign-ssh-old");
1204 std::fs::create_dir_all(old.join("ign-read-1")).expect("old");
1205 std::fs::write(old.join("key"), b"PRIVATE").expect("key");
1206 age(&old, Duration::from_secs(7 * 60 * 60));
1207 let fresh = root.path().join("ign-ssh-fresh");
1208 std::fs::create_dir(&fresh).expect("fresh");
1209 let other = root.path().join("other-old");
1210 std::fs::create_dir(&other).expect("other");
1211 age(&other, Duration::from_secs(7 * 60 * 60));
1212 let outside = tempfile::tempdir().expect("outside");
1213 std::fs::write(outside.path().join("precious"), b"x").expect("precious");
1214 age(outside.path(), Duration::from_secs(7 * 60 * 60));
1215 let link = root.path().join("ign-ssh-link");
1216 std::os::unix::fs::symlink(outside.path(), &link).expect("symlink");
1217
1218 let uid = std::fs::metadata(root.path()).expect("meta").uid();
1219 assert_eq!(
1221 sweep_stale_temp_dirs(
1222 root.path(),
1223 Some(uid.wrapping_add(1)),
1224 std::time::SystemTime::now(),
1225 STALE_TEMP_AGE
1226 ),
1227 0
1228 );
1229 assert!(old.exists());
1230 let removed = sweep_stale_temp_dirs(
1231 root.path(),
1232 Some(uid),
1233 std::time::SystemTime::now(),
1234 STALE_TEMP_AGE,
1235 );
1236 assert_eq!(removed, 1);
1237 assert!(!old.exists(), "the stale dir and its key are gone");
1238 assert!(fresh.exists(), "a recent dir may belong to a live run");
1239 assert!(other.exists(), "only ign-ssh-* dirs are touched");
1240 assert!(
1241 outside.path().join("precious").exists(),
1242 "a symlink is never followed"
1243 );
1244 assert!(
1245 link.symlink_metadata().is_ok(),
1246 "the symlink itself is left alone"
1247 );
1248 }
1249
1250 #[test]
1251 fn safe_remote_path_hostile_table() {
1252 for bad in [
1253 "", "a\nb", "a\rb", "a\tb", "a\"b", "a\\b", "a*b", "a?b", "a[1]", "a{b}", "a]b", "a}b",
1254 ] {
1255 assert!(safe_remote_path(bad).is_err(), "{bad:?} must be refused");
1256 }
1257 assert_eq!(
1258 safe_remote_path(
1259 "/opt/ignition/data/config/resources/production/ignition/database-connection/My DB,1"
1260 ),
1261 Ok(())
1262 );
1263 }
1264
1265 #[test]
1266 fn scrub_stderr_tail() {
1267 let many: String = (1..=20).map(|i| format!("line {i}\n")).collect();
1268 let out = scrub_stderr(many.as_bytes(), None);
1269 let lines: Vec<&str> = out.lines().collect();
1270 assert_eq!(lines.len(), STDERR_TAIL_LINES, "{out:?}");
1271 assert_eq!(lines[0], "line 16");
1272 assert_eq!(lines[STDERR_TAIL_LINES - 1], "line 20");
1273
1274 let tmp = Path::new("/var/folders/xx/ign-ssh-AbC123");
1275 let text = format!(
1276 "Load key \"{0}/key\": invalid format\nWarning: {0}/known_hosts\n",
1277 tmp.display()
1278 );
1279 let out = scrub_stderr(text.as_bytes(), Some(tmp));
1280 assert!(!out.contains("ign-ssh-AbC123"), "{out}");
1281 assert!(out.contains("<tmp>/key"), "{out}");
1282 assert!(out.contains("<tmp>/known_hosts"), "{out}");
1283
1284 let out = scrub_stderr(b"\x1b[31mred\x1b[0m\x07 bell\x00nul\r\nnext\n", None);
1285 assert!(out.chars().all(|c| c == '\n' || !c.is_control()), "{out:?}");
1286 assert!(out.contains("red") && out.contains("next"), "{out:?}");
1287 assert!(!out.contains("[31m"), "{out:?}");
1288
1289 let big = "é".repeat(2000);
1290 let out = scrub_stderr(big.as_bytes(), None);
1291 assert!(out.len() <= 1024, "{}", out.len());
1292 assert!(!out.is_empty());
1293 }
1294
1295 #[test]
1296 fn normalize_private_key_fixes_crlf_and_trailing_newline() {
1297 assert_eq!(normalize_private_key("a\r\nb\r\n"), "a\nb\n");
1298 assert_eq!(normalize_private_key("a\nb"), "a\nb\n");
1299 assert_eq!(normalize_private_key("a\nb\n\n\n"), "a\nb\n");
1300 assert_eq!(normalize_private_key("a\rb"), "a\nb\n");
1301 }
1302
1303 #[test]
1304 fn option_paths_are_quoted_escaped_and_refused() {
1305 assert_eq!(
1306 option_path(Path::new("/tmp/a b/x")),
1307 Ok("\"/tmp/a b/x\"".to_string())
1308 );
1309 assert_eq!(
1310 option_path(Path::new("/tmp/p%h/x")),
1311 Ok("/tmp/p%%h/x".to_string())
1312 );
1313 assert!(option_path(Path::new("/tmp/a\"b")).is_err());
1314 assert!(option_path(Path::new("/tmp/$HOME")).is_err());
1315 assert!(option_path(Path::new("/tmp/a\nb")).is_err());
1316 }
1317
1318 #[test]
1319 fn hardened_options_order_is_fixed() {
1320 let keys = |identity: Option<&Path>| -> Vec<String> {
1321 hardened_options(Path::new("/t/kh"), identity, 10)
1322 .expect("options")
1323 .into_iter()
1324 .map(|(k, _)| k)
1325 .collect()
1326 };
1327 let without = keys(None);
1328 assert_eq!(without.len(), 20);
1329 assert_eq!(without[0], "BatchMode");
1330 assert_eq!(without[2], "UserKnownHostsFile");
1331 assert_eq!(without[19], "LogLevel");
1332 assert!(!without.contains(&"IdentitiesOnly".to_string()));
1333 let with = keys(Some(Path::new("/t/key")));
1334 assert_eq!(with.len(), 21);
1335 assert_eq!(with[10], "IdentitiesOnly");
1336 }
1337
1338 #[cfg(unix)]
1341 const HOSTILE_ARGS: [&str; 12] = [
1342 "plain",
1343 "with space",
1344 "it's",
1345 "'",
1346 "a;b",
1347 "$(touch IGN_PWNED)",
1348 "`touch IGN_PWNED`",
1349 "!rm -rf /",
1350 "a && b | c > d",
1351 "\"quoted\"",
1352 "back\\slash",
1353 "*.json ~ {a,b} $HOME",
1354 ];
1355
1356 #[test]
1357 fn posix_command_single_quotes_every_argument() {
1358 let cmd = RemoteCommand::posix(&["echo", "it's", "a b"]).expect("posix");
1359 assert_eq!(cmd.as_str(), r"'echo' 'it'\''s' 'a b'");
1360 let cmd = RemoteCommand::posix(&["true"]).expect("posix");
1361 assert_eq!(cmd.as_str(), "'true'");
1362 }
1363
1364 #[test]
1365 fn posix_command_refuses_nul_control_characters_and_empty_argv() {
1366 for bad in [
1367 "a\0b", "a\nb", "a\rb", "a\tb", "\x1b[31m", "a\x7fb", "\u{85}", "\u{9f}",
1368 ] {
1369 assert!(
1370 RemoteCommand::posix(&["echo", bad]).is_err(),
1371 "{bad:?} must be refused"
1372 );
1373 assert!(
1374 RemoteCommand::posix(&[bad]).is_err(),
1375 "{bad:?} must be refused as the program"
1376 );
1377 }
1378 assert!(RemoteCommand::posix(&[]).is_err(), "empty argv");
1379 assert!(
1380 RemoteCommand::posix(&[""]).is_ok(),
1381 "an empty arg is quoted"
1382 );
1383 assert_eq!(RemoteCommand::posix(&[""]).unwrap().as_str(), "''");
1384 }
1385
1386 #[cfg(unix)]
1387 #[test]
1388 fn posix_command_round_trips_through_a_local_sh() {
1389 let dir = tempfile::tempdir().expect("dir");
1390 let mut argv: Vec<&str> = vec!["printf", "%s\\0"];
1393 argv.extend(HOSTILE_ARGS);
1394 let cmd = RemoteCommand::posix(&argv).expect("posix");
1395 let out = std::process::Command::new("/bin/sh")
1396 .arg("-c")
1397 .arg(cmd.as_str())
1398 .current_dir(dir.path())
1399 .output()
1400 .expect("sh");
1401 assert!(
1402 out.status.success(),
1403 "{}",
1404 String::from_utf8_lossy(&out.stderr)
1405 );
1406 let printed: Vec<&[u8]> = out
1407 .stdout
1408 .split(|b| *b == 0)
1409 .filter(|chunk| !chunk.is_empty())
1410 .collect();
1411 let expected: Vec<&[u8]> = HOSTILE_ARGS.iter().map(|a| a.as_bytes()).collect();
1412 assert_eq!(printed, expected, "the shell saw the argv unchanged");
1413 assert!(
1414 std::fs::read_dir(dir.path())
1415 .expect("read")
1416 .next()
1417 .is_none(),
1418 "no hostile argument ran as a command"
1419 );
1420 }
1421
1422 #[test]
1423 fn powershell_command_decodes_back_to_the_script() {
1424 for script in [
1425 "Get-ChildItem 'C:\\Program Files'",
1426 "Write-Output \"caf\u{e9} \u{20ac} \u{1f600}\"; $x = $(whoami); `n",
1427 "'; Remove-Item -Recurse C:\\ ; '",
1428 "",
1429 ] {
1430 let cmd = RemoteCommand::powershell_encoded(script);
1431 let prefix = "powershell -NoProfile -NonInteractive -EncodedCommand ";
1432 let b64 = cmd
1433 .as_str()
1434 .strip_prefix(prefix)
1435 .unwrap_or_else(|| panic!("prefix: {}", cmd.as_str()));
1436 assert!(
1437 b64.bytes()
1438 .all(|b| b.is_ascii_alphanumeric() || b == b'+' || b == b'/' || b == b'='),
1439 "base64 is a safe charset: {b64}"
1440 );
1441 let bytes = base64::engine::general_purpose::STANDARD
1442 .decode(b64)
1443 .expect("base64");
1444 assert_eq!(bytes.len() % 2, 0);
1445 let units: Vec<u16> = bytes
1446 .chunks(2)
1447 .map(|c| u16::from_le_bytes([c[0], c[1]]))
1448 .collect();
1449 assert_eq!(String::from_utf16(&units).expect("utf16"), script);
1450 }
1451 assert_eq!(
1453 RemoteCommand::powershell_encoded("hi").as_str(),
1454 "powershell -NoProfile -NonInteractive -EncodedCommand aABpAA=="
1455 );
1456 }
1457
1458 fn render_all(ops: &[SftpOp]) -> Vec<String> {
1461 ops.iter().map(|op| op.render().expect("render")).collect()
1462 }
1463
1464 #[test]
1465 fn sftp_ops_render_exact_lines() {
1466 let ops = [
1467 SftpOp::Ls {
1468 path: "/d/a b".into(),
1469 },
1470 SftpOp::LsLong { path: "/d".into() },
1471 SftpOp::LsLongAll { path: "/d".into() },
1472 SftpOp::LsLongAllNumeric { path: "/d".into() },
1473 SftpOp::Pwd,
1474 SftpOp::Get {
1475 remote: "/r/f".into(),
1476 local: PathBuf::from("/l/f"),
1477 },
1478 SftpOp::GetRecursive {
1479 remote: "/r/dir".into(),
1480 local: PathBuf::from("/l/mirror/"),
1481 },
1482 SftpOp::Put {
1483 local: PathBuf::from("/l/f"),
1484 remote: "/r/f".into(),
1485 },
1486 SftpOp::Rename {
1487 from: "/r/a".into(),
1488 to: "/r/b".into(),
1489 },
1490 SftpOp::Rm {
1491 path: "/r/f".into(),
1492 },
1493 SftpOp::Mkdir {
1494 path: "/r/d".into(),
1495 },
1496 SftpOp::Rmdir {
1497 path: "/r/d".into(),
1498 },
1499 ];
1500 assert_eq!(
1501 render_all(&ops),
1502 [
1503 "@ls -1 \"/d/a b\"",
1504 "@ls -l \"/d\"",
1505 "@ls -la \"/d\"",
1506 "@ls -lan \"/d\"",
1507 "@pwd",
1508 "get \"/r/f\" \"/l/f\"",
1509 "get -R \"/r/dir\" \"/l/mirror/\"",
1510 "put \"/l/f\" \"/r/f\"",
1511 "rename \"/r/a\" \"/r/b\"",
1512 "rm \"/r/f\"",
1513 "mkdir \"/r/d\"",
1514 "rmdir \"/r/d\"",
1515 ]
1516 );
1517 for line in render_all(&ops) {
1518 assert!(
1519 line.starts_with(|c: char| c.is_ascii_lowercase() || c == '@'),
1520 "{line}"
1521 );
1522 }
1523 }
1524
1525 #[test]
1526 fn sftp_ops_refuse_hostile_remote_and_local_paths() {
1527 let refused = [
1528 "a\nrm \"/x\"",
1529 "a\rb",
1530 "a\"; rm \"/x",
1531 "a\\b",
1532 "a*b",
1533 "a?b",
1534 "a[1]",
1535 "a{b}",
1536 "",
1537 "-R",
1538 "-rf",
1539 "a\0b",
1540 "a\x1bb",
1541 ];
1542 for bad in refused {
1543 let remote_ops = [
1544 SftpOp::Ls { path: bad.into() },
1545 SftpOp::LsLong { path: bad.into() },
1546 SftpOp::LsLongAll { path: bad.into() },
1547 SftpOp::LsLongAllNumeric { path: bad.into() },
1548 SftpOp::Get {
1549 remote: bad.into(),
1550 local: PathBuf::from("/l"),
1551 },
1552 SftpOp::GetRecursive {
1553 remote: bad.into(),
1554 local: PathBuf::from("/l"),
1555 },
1556 SftpOp::Put {
1557 local: PathBuf::from("/l"),
1558 remote: bad.into(),
1559 },
1560 SftpOp::Rename {
1561 from: bad.into(),
1562 to: "/r/b".into(),
1563 },
1564 SftpOp::Rename {
1565 from: "/r/a".into(),
1566 to: bad.into(),
1567 },
1568 SftpOp::Rm { path: bad.into() },
1569 SftpOp::Mkdir { path: bad.into() },
1570 SftpOp::Rmdir { path: bad.into() },
1571 ];
1572 for op in &remote_ops {
1573 for windows_runner in [false, true] {
1574 assert!(
1575 op.render_on(windows_runner).is_err(),
1576 "{op:?} must be refused (windows_runner {windows_runner})"
1577 );
1578 }
1579 }
1580 let local_ops = [
1581 SftpOp::Get {
1582 remote: "/r".into(),
1583 local: PathBuf::from(bad),
1584 },
1585 SftpOp::GetRecursive {
1586 remote: "/r".into(),
1587 local: PathBuf::from(bad),
1588 },
1589 SftpOp::Put {
1590 local: PathBuf::from(bad),
1591 remote: "/r".into(),
1592 },
1593 ];
1594 for op in &local_ops {
1595 for windows_runner in [false, true] {
1596 let rendered = op.render_on(windows_runner);
1597 if windows_runner && bad == "a\\b" {
1598 let line = rendered.expect("a Windows runner renders a\\b as a/b");
1602 assert!(line.contains("\"a/b\"") && !line.contains('\\'), "{line}");
1603 } else {
1604 assert!(
1605 rendered.is_err(),
1606 "{op:?} must be refused (windows_runner {windows_runner})"
1607 );
1608 }
1609 }
1610 }
1611 }
1612 }
1613
1614 #[test]
1619 fn local_backslashes_are_separators_only_on_a_windows_runner() {
1620 let get = |local: &str| SftpOp::Get {
1621 remote: "/r".into(),
1622 local: PathBuf::from(local),
1623 };
1624 for (local, line) in [
1625 ("a\\b", "get \"/r\" \"a/b\""),
1626 (
1627 "C:\\Users\\RUNNER~1\\AppData\\Local\\Temp\\.tmpAb1\\f.json",
1628 "get \"/r\" \"C:/Users/RUNNER~1/AppData/Local/Temp/.tmpAb1/f.json\"",
1629 ),
1630 ("C:\\a b\\c\\", "get \"/r\" \"C:/a b/c/\""),
1631 ] {
1632 assert_eq!(
1633 get(local).render_on(true).as_deref(),
1634 Ok(line),
1635 "{local:?} on a Windows runner"
1636 );
1637 assert!(
1638 get(local).render_on(false).is_err(),
1639 "{local:?} is refused off Windows"
1640 );
1641 }
1642 for bad in [
1643 "a\\b\"c",
1644 "a\\*",
1645 "C:\\x\\[1]",
1646 "\\\\?\\C:\\x",
1647 "-a\\b",
1648 "a\\\nb",
1649 ] {
1650 assert!(get(bad).render_on(true).is_err(), "{bad:?}");
1651 }
1652 for windows_runner in [false, true] {
1653 let rm = SftpOp::Rm {
1654 path: "/r\\x".into(),
1655 };
1656 assert!(rm.render_on(windows_runner).is_err(), "{rm:?}");
1657 }
1658 }
1659
1660 #[test]
1661 fn sftp_ops_keep_other_hostile_text_inertly_inside_the_quotes() {
1662 for inert in [
1665 "!rm -rf /x",
1666 "/d/a;b",
1667 "/d/$(touch IGN_PWNED)",
1668 "/d/`touch IGN_PWNED`",
1669 "/d/it's",
1670 "/d/a&b|c>d",
1671 "/d/~tilde",
1672 ] {
1673 let line = SftpOp::Rm { path: inert.into() }.render().expect("render");
1674 assert_eq!(line, format!("rm \"{inert}\""));
1675 assert!(line.starts_with("rm \""), "{line}");
1676 }
1677 }
1678
1679 #[test]
1680 fn local_path_check_table() {
1681 for bad in [
1682 "", "a\"b", "a\\b", "a*b", "a?b", "a[b", "a]b", "a{b", "a}b", "a\nb", "a\tb", "-x",
1683 ] {
1684 assert!(safe_local_path(bad).is_err(), "{bad:?} must be refused");
1685 }
1686 for ok in [
1687 "/tmp/ign-ssh-AbC/mirror/",
1688 "/tmp/a b,c/d",
1689 "rel/dir/file.json",
1690 "/tmp/it's",
1691 ] {
1692 assert_eq!(safe_local_path(ok), Ok(()), "{ok:?}");
1693 }
1694 }
1695
1696 #[test]
1697 fn batch_line_guard_refuses_line_breaks_and_a_leading_bang() {
1698 for line in [
1699 "!rm -rf /",
1700 "@!rm -rf /",
1701 "-!rm -rf /",
1702 " !id",
1703 "-@!id",
1704 "ls \"/d\"\nrm \"/x\"",
1705 "ls \"/d\"\rrm \"/x\"",
1706 ] {
1707 assert!(check_batch_line(line).is_err(), "{line:?} must be refused");
1708 }
1709 for line in ["@ls -1 \"/d\"", "get \"/r\" \"/l\"", "rm \"!x\""] {
1710 assert_eq!(check_batch_line(line), Ok(()), "{line:?}");
1711 }
1712 }
1713
1714 #[derive(Default)]
1715 struct Recording {
1716 calls: std::sync::Mutex<Vec<Option<Vec<u8>>>>,
1717 }
1718
1719 #[async_trait::async_trait]
1720 impl ProcessRunner for Recording {
1721 async fn run(
1722 &self,
1723 _program: &OsStr,
1724 _args: &[OsString],
1725 stdin: Option<Vec<u8>>,
1726 _timeout: Duration,
1727 ) -> ProcessOutput {
1728 self.calls.lock().expect("calls").push(stdin);
1729 ProcessOutput {
1730 stdout: Vec::new(),
1731 stderr: Vec::new(),
1732 code: 0,
1733 timed_out: false,
1734 }
1735 }
1736 }
1737
1738 #[tokio::test]
1739 async fn sftp_batch_sends_typed_lines_and_refuses_bad_ops_before_spawning() {
1740 let recording = Arc::new(Recording::default());
1741 let shell = RemoteShell::local_sftp_server(
1742 Path::new("/unused"),
1743 recording.clone(),
1744 Duration::from_secs(1),
1745 );
1746 shell
1747 .sftp_batch(&[
1748 SftpOp::Ls { path: "/d".into() },
1749 SftpOp::Rm {
1750 path: "/d/x".into(),
1751 },
1752 ])
1753 .await
1754 .expect("batch");
1755 assert_eq!(
1756 recording.calls.lock().expect("calls")[0].as_deref(),
1757 Some(b"@ls -1 \"/d\"\nrm \"/d/x\"\n".as_slice())
1758 );
1759
1760 let err = shell
1762 .sftp_batch(&[
1763 SftpOp::Ls { path: "/d".into() },
1764 SftpOp::Rm {
1765 path: "/d\nrm \"/x\"".into(),
1766 },
1767 ])
1768 .await
1769 .expect_err("refused");
1770 assert!(matches!(err, CoreError::TransportFailed { .. }));
1771 assert_eq!(recording.calls.lock().expect("calls").len(), 1);
1772
1773 for line in ["ls \"/d\"\nrm \"/x\"", "!rm -rf /", "@!id"] {
1775 let err = shell
1776 .sftp_batch_raw(&[line.to_string()])
1777 .await
1778 .expect_err("raw line refused");
1779 assert!(matches!(err, CoreError::TransportFailed { .. }), "{line:?}");
1780 }
1781 assert_eq!(recording.calls.lock().expect("calls").len(), 1);
1782 }
1783
1784 #[test]
1787 fn rename_onto_itself_is_refused_because_it_deletes_the_file() {
1788 for (from, to) in [
1791 ("/C:/Users/u/f.json", "/C:/Users/u/f.json"),
1792 ("/C:/Users/u/f.json", "/c:/users/U/F.JSON"),
1793 ("/C:/Users/u//f.json", "/C:/Users/u/f.json"),
1794 ("/C:/Users/u/dir/", "/C:/Users/u/dir"),
1795 ("rel/f", "rel/f"),
1796 ] {
1797 let err = SftpOp::Rename {
1798 from: from.into(),
1799 to: to.into(),
1800 }
1801 .render()
1802 .expect_err(from);
1803 assert!(err.contains("onto itself"), "{from} -> {to}: {err}");
1804 }
1805 assert!(
1807 SftpOp::Rename {
1808 from: "/C:/Users/u/staging/f.json".into(),
1809 to: "/C:/Program Files/x/f.json".into(),
1810 }
1811 .render()
1812 .is_ok()
1813 );
1814 assert!(same_remote_path("/a/B", "/A/b"));
1815 assert!(!same_remote_path("/a/b", "/a/bc"));
1816 }
1817
1818 #[test]
1819 fn sftp_stderr_is_classified_by_text() {
1820 use SftpFailure::*;
1821 for (text, want) in [
1822 ("Can't ls: \"/C:/x\" not found", NotFound),
1823 ("File \"/C:/x\" not found.", NotFound),
1824 ("remote delete /C:/x: No such file or directory", NotFound),
1825 (
1826 "remote rename \"a\" to \"b\": No such file or directory",
1827 NotFound,
1828 ),
1829 ("dest open \"/C:/x\": Permission denied", PermissionDenied),
1830 (
1831 "remote mkdir \"/C:/x\": Permission denied",
1832 PermissionDenied,
1833 ),
1834 ("remote mkdir \"/C:/x\": Failure", Failure),
1835 ("remote rename \"a\" to \"b\": Failure", Failure),
1836 ("remote readdir(\"/C:/x/\"): Bad message", Other),
1837 ("", Other),
1838 ] {
1839 assert_eq!(classify_sftp_stderr(text), want, "{text}");
1840 }
1841 }
1842
1843 #[test]
1844 fn require_success_names_the_sftp_failure_class_for_exit_1_only() {
1845 let recording = Arc::new(Recording::default());
1846 let shell =
1847 RemoteShell::local_sftp_server(Path::new("/unused"), recording, Duration::from_secs(1));
1848 let out = |code: i32, stderr: &str| ProcessOutput {
1849 stdout: Vec::new(),
1850 stderr: stderr.as_bytes().to_vec(),
1851 code,
1852 timed_out: false,
1853 };
1854 let detail = |o: ProcessOutput| match shell.require_success("read", o).expect_err("fail") {
1855 CoreError::TransportFailed { detail, .. } => detail,
1856 other => panic!("{other:?}"),
1857 };
1858 assert!(
1859 detail(out(1, "File \"/C:/x\" not found.\r\n")).contains("(not found or unreadable)")
1860 );
1861 assert!(detail(out(1, "dest open: Permission denied")).contains("(permission denied)"));
1862 assert!(detail(out(1, "remote rename: Failure")).contains("(refused with no reason"));
1863 let ssh = detail(out(255, "Permission denied (publickey)."));
1865 assert!(!ssh.contains("(permission denied)"), "{ssh}");
1866 }
1867
1868 #[test]
1869 fn a_mkdir_failure_is_not_called_a_held_target() {
1870 let recording = Arc::new(Recording::default());
1871 let shell =
1872 RemoteShell::local_sftp_server(Path::new("/unused"), recording, Duration::from_secs(1));
1873 let detail = |stderr: &str| {
1874 let out = ProcessOutput {
1875 stdout: Vec::new(),
1876 stderr: stderr.as_bytes().to_vec(),
1877 code: 1,
1878 timed_out: false,
1879 };
1880 match shell.require_success("prepare", out).expect_err("fail") {
1881 CoreError::TransportFailed { detail, .. } => detail,
1882 other => panic!("{other:?}"),
1883 }
1884 };
1885 let mkdir = detail("remote mkdir \"/C:/Users/RUNNER~1\": Failure\n");
1887 assert!(mkdir.contains("(refused with no reason"), "{mkdir}");
1888 assert!(mkdir.contains("may already exist"), "{mkdir}");
1889 assert!(mkdir.contains("8.3 short"), "{mkdir}");
1890 assert!(mkdir.contains("cannot be created"), "{mkdir}");
1891 assert!(!mkdir.contains("held"), "{mkdir}");
1892 assert!(!mkdir.contains("non-empty"), "{mkdir}");
1893 assert!(
1894 mkdir.contains("remote mkdir \"/C:/Users/RUNNER~1\": Failure"),
1895 "{mkdir}"
1896 );
1897 for line in [
1899 "remote rename \"/a\" to \"/b\": Failure",
1900 "remote rmdir \"/a\": Failure",
1901 "remote delete /a: Failure",
1902 ] {
1903 let other = detail(line);
1904 assert!(other.contains("a held or non-empty target"), "{other}");
1905 assert!(!other.contains("may already exist"), "{other}");
1906 }
1907 }
1908
1909 struct ScriptedExec {
1910 out: Mutex<Option<ProcessOutput>>,
1911 argv: Mutex<Vec<(String, Vec<String>)>>,
1912 }
1913
1914 #[async_trait::async_trait]
1915 impl ProcessRunner for ScriptedExec {
1916 async fn run(
1917 &self,
1918 program: &OsStr,
1919 args: &[OsString],
1920 _stdin: Option<Vec<u8>>,
1921 _timeout: Duration,
1922 ) -> ProcessOutput {
1923 self.argv.lock().expect("argv").push((
1924 program.to_string_lossy().into_owned(),
1925 args.iter()
1926 .map(|a| a.to_string_lossy().into_owned())
1927 .collect(),
1928 ));
1929 self.out.lock().expect("out").take().expect("one call")
1930 }
1931 }
1932
1933 fn exec_shell(out: ProcessOutput) -> (Arc<ScriptedExec>, RemoteShell) {
1934 let runner = Arc::new(ScriptedExec {
1935 out: Mutex::new(Some(out)),
1936 argv: Mutex::new(Vec::new()),
1937 });
1938 let shell = RemoteShell::local_sftp_server(
1939 Path::new("/unused"),
1940 runner.clone(),
1941 Duration::from_secs(3),
1942 );
1943 (runner, shell)
1944 }
1945
1946 fn exec_script(params: serde_json::Value) -> WinScript {
1947 WinScript {
1948 name: "probe",
1949 body: "E @{ok=1}",
1950 params,
1951 }
1952 }
1953
1954 #[tokio::test]
1955 async fn exec_powershell_runs_ssh_with_the_encoded_command_and_parses_the_sentinel() {
1956 let (runner, shell) = exec_shell(ProcessOutput {
1957 stdout: b"IGN-RESULT:{\"ok\":1}\r\n".to_vec(),
1958 stderr: Vec::new(),
1959 code: 0,
1960 timed_out: false,
1961 });
1962 let value = shell
1963 .exec_powershell(&exec_script(serde_json::Value::Null))
1964 .await
1965 .expect("exec");
1966 assert_eq!(value["ok"], 1);
1967 let argv = runner.argv.lock().expect("argv");
1968 assert_eq!(argv[0].0, "ssh");
1969 let last = argv[0].1.last().expect("remote command");
1970 assert!(
1971 last.starts_with("powershell -NoProfile -NonInteractive -EncodedCommand "),
1972 "{last}"
1973 );
1974 assert!(argv[0].1.iter().any(|a| a == "--"));
1976 }
1977
1978 #[tokio::test]
1981 async fn exec_powershell_with_local_exec_uses_the_local_shell_not_ssh() {
1982 let (runner, shell) = exec_shell(ProcessOutput {
1983 stdout: b"IGN-RESULT:{\"ok\":1}\r\n".to_vec(),
1984 stderr: Vec::new(),
1985 code: 0,
1986 timed_out: false,
1987 });
1988 let shell = shell.local_exec();
1989 let value = shell
1990 .exec_powershell(&exec_script(serde_json::Value::Null))
1991 .await
1992 .expect("exec");
1993 assert_eq!(value["ok"], 1);
1994 let argv = runner.argv.lock().expect("argv");
1995 let (program, flag) = if cfg!(windows) {
1996 ("cmd", "/C")
1997 } else {
1998 ("sh", "-c")
1999 };
2000 assert_eq!(argv[0].0, program);
2001 assert_eq!(argv[0].1[0], flag);
2002 assert_eq!(argv[0].1.len(), 2, "the command text is one argument");
2003 assert!(
2004 argv[0].1[1].starts_with("powershell -NoProfile -NonInteractive -EncodedCommand "),
2005 "{argv:?}"
2006 );
2007 assert!(!argv[0].1.iter().any(|a| a == "--"), "no ssh argv");
2008 }
2009
2010 #[tokio::test]
2011 async fn exec_powershell_failures_are_transport_failed() {
2012 let cases = [
2013 (
2014 "non-zero exit with CLIXML",
2015 ProcessOutput {
2016 stdout: Vec::new(),
2017 stderr: b"#< CLIXML\r\n<Objs><S S=\"Error\">boom_x000D__x000A_</S></Objs>"
2018 .to_vec(),
2019 code: 1,
2020 timed_out: false,
2021 },
2022 "boom",
2023 ),
2024 (
2025 "missing sentinel",
2026 ProcessOutput {
2027 stdout: b"hello\r\n".to_vec(),
2028 stderr: Vec::new(),
2029 code: 0,
2030 timed_out: false,
2031 },
2032 "IGN-RESULT:",
2033 ),
2034 (
2035 "timeout",
2036 ProcessOutput {
2037 stdout: Vec::new(),
2038 stderr: Vec::new(),
2039 code: -1,
2040 timed_out: true,
2041 },
2042 "timed out after 3s",
2043 ),
2044 ];
2045 for (name, out, needle) in cases {
2046 let (_runner, shell) = exec_shell(out);
2047 let err = shell
2048 .exec_powershell(&exec_script(serde_json::Value::Null))
2049 .await
2050 .expect_err(name);
2051 let CoreError::TransportFailed {
2052 operation, detail, ..
2053 } = err
2054 else {
2055 panic!("{name}: not transport_failed");
2056 };
2057 assert_eq!(operation, "exec", "{name}");
2058 assert!(detail.contains(needle), "{name}: {detail}");
2059 assert!(detail.starts_with("probe: "), "{name}: {detail}");
2060 }
2061 }
2062
2063 fn posix_command() -> RemoteCommand {
2064 RemoteCommand::posix(&["sh", "-c", "echo hi", "ign-stub", "/h/commit.sh", "commit"])
2065 .expect("command")
2066 }
2067
2068 #[tokio::test]
2069 async fn exec_posix_runs_ssh_with_the_quoted_command_and_parses_the_sentinel() {
2070 let (runner, shell) = exec_shell(ProcessOutput {
2071 stdout: b"noise\nIGN-RESULT:{\"ok\":1}\n".to_vec(),
2072 stderr: Vec::new(),
2073 code: 0,
2074 timed_out: false,
2075 });
2076 let value = shell
2077 .exec_posix("commit", &posix_command())
2078 .await
2079 .expect("exec");
2080 assert_eq!(value["ok"], 1);
2081 let argv = runner.argv.lock().expect("argv");
2082 assert_eq!(argv[0].0, "ssh");
2083 let last = argv[0].1.last().expect("remote command");
2084 assert_eq!(last, posix_command().as_str());
2085 assert!(argv[0].1.iter().any(|a| a == "--"));
2086 }
2087
2088 #[tokio::test]
2092 async fn the_within_forms_kill_at_their_own_limit_not_the_op_timeout() {
2093 let killed = ProcessOutput {
2094 stdout: Vec::new(),
2095 stderr: Vec::new(),
2096 code: -1,
2097 timed_out: true,
2098 };
2099 let (_runner, shell) = exec_shell(killed.clone());
2100 assert_eq!(shell.op_timeout(), Duration::from_secs(3));
2101 let limit = Duration::from_secs(7);
2102 let command = RemoteCommand::posix(&["true"]).expect("command");
2103 let err = shell
2104 .exec_posix_within("commit", &command, limit)
2105 .await
2106 .expect_err("killed");
2107 assert!(err.to_string().contains("timed out after 7s"), "{err}");
2108 let (_runner, shell) = exec_shell(killed.clone());
2109 let err = shell
2110 .exec_powershell_within(&exec_script(serde_json::Value::Null), limit)
2111 .await
2112 .expect_err("killed");
2113 assert!(err.to_string().contains("timed out after 7s"), "{err}");
2114 let (_runner, shell) = exec_shell(killed);
2116 let err = shell
2117 .exec_posix("commit", &command)
2118 .await
2119 .expect_err("killed");
2120 assert!(err.to_string().contains("timed out after 3s"), "{err}");
2121 }
2122
2123 #[tokio::test]
2124 async fn exec_posix_failures_are_transport_failed() {
2125 let ok = |stdout: &[u8], stderr: &[u8], code: i32, timed_out: bool| ProcessOutput {
2126 stdout: stdout.to_vec(),
2127 stderr: stderr.to_vec(),
2128 code,
2129 timed_out,
2130 };
2131 let cases = [
2132 (
2133 "non-zero exit",
2134 ok(b"IGN-RESULT:{\"ok\":1}\n", b"boom\n", 1, false),
2135 "exit code 1: boom",
2136 ),
2137 (
2138 "the stub refuses",
2139 ok(
2140 b"",
2141 b"ign: the staged script or work list does not match its hash\n",
2142 97,
2143 false,
2144 ),
2145 "does not match its hash",
2146 ),
2147 (
2148 "exit 0 with no sentinel",
2149 ok(b"SECRETSTDOUT hello\n", b"", 0, false),
2150 "IGN-RESULT:",
2151 ),
2152 (
2153 "two sentinel lines",
2154 ok(
2155 b"IGN-RESULT:{\"ok\":1}\nIGN-RESULT:{\"ok\":1}\n",
2156 b"",
2157 0,
2158 false,
2159 ),
2160 "more than one",
2161 ),
2162 ("a timeout", ok(b"", b"", -1, true), "timed out after 3s"),
2163 (
2164 "non-JSON after the sentinel",
2165 ok(b"IGN-RESULT:SECRETSTDOUT\n", b"", 0, false),
2166 "not valid JSON",
2167 ),
2168 ];
2169 for (name, out, needle) in cases {
2170 let (_runner, shell) = exec_shell(out);
2171 let err = shell
2172 .exec_posix("commit", &posix_command())
2173 .await
2174 .expect_err(name);
2175 let CoreError::TransportFailed {
2176 operation, detail, ..
2177 } = err
2178 else {
2179 panic!("{name}: not transport_failed");
2180 };
2181 assert_eq!(operation, "exec", "{name}");
2182 assert!(detail.starts_with("commit: "), "{name}: {detail}");
2183 assert!(detail.contains(needle), "{name}: {detail}");
2184 assert!(
2185 !detail.contains("SECRETSTDOUT"),
2186 "{name}: stdout must never be echoed: {detail}"
2187 );
2188 }
2189 }
2190
2191 #[tokio::test]
2192 async fn exec_powershell_over_budget_never_spawns() {
2193 let runner = Arc::new(ScriptedExec {
2196 out: Mutex::new(None),
2197 argv: Mutex::new(Vec::new()),
2198 });
2199 let shell = RemoteShell::local_sftp_server(
2200 Path::new("/unused"),
2201 runner.clone(),
2202 Duration::from_secs(3),
2203 );
2204 let big = "x".repeat(winps::COMMAND_BUDGET);
2205 let err = shell
2206 .exec_powershell(&exec_script(serde_json::json!({ "p": big })))
2207 .await
2208 .expect_err("over budget");
2209 assert!(matches!(err, CoreError::TransportFailed { .. }));
2210 assert!(
2211 runner.argv.lock().expect("argv").is_empty(),
2212 "nothing spawned"
2213 );
2214 }
2215}