Skip to main content

ignition_core/deploy/transport/
remote.rs

1//! The remote shell under the ssh transport (22-05).
2//!
3//! [`RemoteShell`] turns one `nodes.*.ssh` target block into hardened `ssh`
4//! and `sftp` argv vectors and runs them only through an
5//! `Arc<dyn ProcessRunner>`:
6//!
7//! - **Argv only.** No shell string is ever built locally. `host` and `user`
8//!   are validated by the target schema and every argv puts `--` before
9//!   `user@host`, so no target value can be read as an ssh option (T-22-17).
10//! - **Pinned host key (D-01).** The repo's reviewed `known_hosts` is read
11//!   through the symlink-refusing repo reader and copied into the shell's own
12//!   0700 temp dir; ssh gets that copy as `UserKnownHostsFile` with
13//!   `StrictHostKeyChecking=yes`, and the runner's own files are never read.
14//! - **Key handling (D-03).** `key_env` names a variable holding the key
15//!   text; it is normalised, written 0600 into the temp dir, passed as
16//!   `-i <file>` with `IdentitiesOnly=yes`, and deleted with the temp dir on
17//!   drop. Only the path ever appears in argv. The key text is not retained.
18//!   The binary installs no signal handler, so a SIGINT or SIGTERM can skip
19//!   that drop. The residual is bounded: the dir is 0700 and the key 0600, and
20//!   the next shell sweeps `ign-ssh-*` dirs of the same owner that are older
21//!   than [`STALE_TEMP_AGE`] ([`sweep_stale_temp_dirs`]).
22//! - **Bounded in time (D-04).** `ConnectTimeout` plus `ServerAlive*` inside
23//!   ssh, and a per-call timeout with kill in the process runner.
24//! - **One temp dir per shell**, so two shells never share a file.
25//! - **Typed remote input.** The remote shell only receives a
26//!   [`RemoteCommand`] (every constructor yields an injection-safe string) and
27//!   sftp only receives [`SftpOp`] values (every path checked and
28//!   double-quoted), never a caller-built string. A raw-line runner exists
29//!   under `cfg(test)` only.
30//!
31//! The exact `-o` order is [`hardened_options`]; `ssh -G` on the generated
32//! argv is pinned by `tests/deploy_transport.rs`.
33
34use 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
48/// Lines of ssh or sftp stderr kept in an error detail.
49pub const STDERR_TAIL_LINES: usize = 5;
50
51/// An error detail never exceeds this many bytes.
52const DETAIL_MAX_BYTES: usize = 1024;
53
54/// Why `path` cannot be placed in an sftp batch line, or `Ok` when it can.
55///
56/// Remote paths reach sftp inside double quotes on a batch line, and the sftp
57/// client globs what it is given, so these are refused outright:
58///
59/// | Refused | Why |
60/// |---|---|
61/// | empty | names the working directory, never a resource |
62/// | control characters (newline, CR, tab, DEL, C1) | a newline ends the batch line and starts another command |
63/// | `"` | closes the quoting |
64/// | `\` | escapes inside the quoting (and is a separator on Windows hosts, normalised before this check) |
65/// | `*` `?` `[` `]` `{` `}` | sftp glob characters: `ls "dir/odd[1]"` only worked because no `odd1` existed, a sibling would silently win |
66///
67/// Spaces, commas and other printable characters pass.
68pub fn safe_remote_path(path: &str) -> Result<(), String> {
69    refuse_unsafe_path("remote", path)
70}
71
72/// The shared character check behind [`safe_remote_path`] and
73/// [`safe_local_path`]; `kind` names the side in the error.
74fn 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
93/// [`safe_remote_path`] for a path on the machine running `ign`, as it
94/// appears in an sftp `get` or `put`: the same refusals (empty, control
95/// characters, `"`, `\`, glob characters, because the sftp client globs local
96/// paths too), plus a leading `-`, which the sftp client would read as an
97/// option (`get "-R" "x"`) even though it was quoted. On a Windows runner
98/// backslashes are normalised to `/` by the caller before this check.
99pub 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
113/// Drop ANSI CSI sequences and every control character except `\n`.
114fn 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                // Parameter and intermediate bytes, then one final byte.
122                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
137/// A bounded, scrubbed tail of `stderr` fit for an error detail.
138///
139/// Lossy UTF-8; ANSI sequences and control characters (newline excepted) are
140/// removed; the native and forward-slash spellings of `temp_dir` become
141/// `<tmp>`; only the last [`STDERR_TAIL_LINES`] lines are kept, and the
142/// result is at most 1 KiB (cut from the front on a character boundary).
143pub 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/// The null device ssh is pointed at for files it must not consult.
169#[cfg(windows)]
170pub const NULL_DEVICE: &str = "NUL";
171/// The null device ssh is pointed at for files it must not consult.
172#[cfg(not(windows))]
173pub const NULL_DEVICE: &str = "/dev/null";
174
175/// Normalise private key text for OpenSSH: CRLF and lone CR become LF and
176/// exactly one trailing LF is kept. OpenSSH 10 answers `invalid format` for a
177/// key with no trailing newline and for CRLF, which a CI secret store
178/// produces easily.
179pub 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
187/// A path as an ssh `-o` value: quoted when it holds whitespace, `%` doubled
188/// (ssh expands `%h`-style tokens in file options), forward slashes on a
189/// Windows runner. `"`, `$` (environment expansion) and control characters
190/// are refused.
191fn 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
215/// The security-relevant `-o key=value` pairs, in the fixed order every ssh
216/// and sftp argv uses:
217///
218/// 1. `BatchMode=yes`
219/// 2. `StrictHostKeyChecking=yes`
220/// 3. `UserKnownHostsFile=<known_hosts>`
221/// 4. `GlobalKnownHostsFile=<null device>`
222/// 5. `UpdateHostKeys=no`
223/// 6. `VerifyHostKeyDNS=no`
224/// 7. `PreferredAuthentications=publickey`
225/// 8. `PasswordAuthentication=no`
226/// 9. `KbdInteractiveAuthentication=no`
227/// 10. `NumberOfPasswordPrompts=0`
228/// 11. `IdentitiesOnly=yes` (only when `identity` is given)
229/// 12. `ForwardAgent=no`
230/// 13. `ForwardX11=no`
231/// 14. `ClearAllForwardings=yes`
232/// 15. `PermitLocalCommand=no`
233/// 16. `ControlMaster=no`
234/// 17. `ControlPath=none`
235/// 18. `ConnectTimeout=<connect_timeout_s>`
236/// 19. `ServerAliveInterval=5`
237/// 20. `ServerAliveCountMax=3`
238/// 21. `LogLevel=ERROR`
239///
240/// Command-line `-o` wins over `~/.ssh/config`, so a user's agent and
241/// `ProxyJump` still work while none of these can be weakened by it. A path
242/// that cannot be passed safely (see [`option_path`]) is an error.
243pub 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        // Validate the identity path like any other path option.
263        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
289/// Name prefix of a shell's private temp dir.
290const TEMP_PREFIX: &str = "ign-ssh-";
291
292/// How old an `ign-ssh-*` dir must be before a later shell treats it as left
293/// behind by a killed run. Far longer than any one run (the default op timeout
294/// is 1050 s), so a concurrent run's dir is never swept.
295pub(crate) const STALE_TEMP_AGE: Duration = Duration::from_secs(6 * 60 * 60);
296
297/// Best-effort removal of `ign-ssh-*` dirs left in `root` by a run that was
298/// killed before its `TempDir` dropped (SIGINT, SIGTERM): they may hold a
299/// `key_env` private key and downloaded gateway files.
300///
301/// Only a real directory (never a symlink) whose name starts with
302/// [`TEMP_PREFIX`], whose owner is `owner` (the uid of this shell's own temp
303/// dir; `None` skips the check on platforms without uids) and whose mtime is at
304/// least `max_age` before `now` is removed. Every error is ignored: a failed
305/// sweep must never fail a run. Returns how many dirs were removed.
306pub(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
347/// Sweep the siblings of `own` (this shell's fresh temp dir) that a killed run
348/// left behind.
349fn 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
365/// Create `path` exclusively with owner-only permissions and write `bytes`.
366fn 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/// Restrict `file` to the account this process runs as, on a Windows runner.
381///
382/// The grant is by SID (read from `whoami`), not by the user-name variable: a
383/// service account such as NETWORK SERVICE has a machine-account name that
384/// `icacls` cannot map (error 1332, G-25-10). Fail-closed: any failure refuses
385/// the setup before `ssh` is spawned with the key, and nothing falls back to a
386/// name or to an unrestricted file.
387#[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/// A command string for the remote shell whose only constructors produce
398/// injection-safe text. `ssh` hands its trailing argument to the remote login
399/// shell, so a caller-built string would be a shell-injection sink; this type
400/// is the only thing [`RemoteShell::ssh_argv`] accepts. The inner string is
401/// private.
402#[derive(Debug, Clone, PartialEq, Eq)]
403pub struct RemoteCommand(String);
404
405impl RemoteCommand {
406    /// `powershell -NoProfile -NonInteractive -EncodedCommand <base64>`, where
407    /// the payload is `script` as UTF-16LE. Base64 (`A-Za-z0-9+/=`) has no
408    /// character any shell, `cmd.exe` or PowerShell treats specially, so the
409    /// script cannot break out whatever the host's default shell is.
410    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    /// A POSIX `sh` command line for `argv`: each argument single-quoted with
419    /// every `'` written `'\''`, joined by spaces. Inside single quotes a POSIX
420    /// shell interprets nothing, so `$(..)`, backticks, `;`, `!`, `*` and
421    /// the rest reach the program as literal text. Refused: an empty `argv`,
422    /// and NUL or any other control character in any argument (a newline or
423    /// tab would otherwise survive quoting and still be a hazard to every log
424    /// and terminal it reaches).
425    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    /// The command text, exactly as it is sent.
442    pub fn as_str(&self) -> &str {
443        &self.0
444    }
445}
446
447/// One sftp batch command. [`RemoteShell::sftp_batch`] accepts only these, so
448/// a caller cannot hand sftp a raw line: in batch mode a leading `!` runs a
449/// LOCAL shell command and a leading `-` or `@` changes error handling.
450///
451/// Each op renders to `<command> "<path>"…` with every remote path through
452/// [`safe_remote_path`] and every local path through [`safe_local_path`]
453/// (both also refuse a leading `-`). Only the listing ops carry the `@`
454/// quiet prefix, which hides the echoed command so stdout is the listing; no
455/// op ever starts with `-`, so a failure is never suppressed.
456#[derive(Debug, Clone, PartialEq, Eq)]
457pub enum SftpOp {
458    /// `@ls -1 "<path>"`: names only, one per line.
459    Ls { path: String },
460    /// `@ls -l "<path>"`: the long listing.
461    LsLong { path: String },
462    /// `@ls -la "<path>"`: the long listing including dot-named entries and
463    /// the `.` / `..` pair, so that no entry is hidden and a symlink or
464    /// special file is visible. The read transport lists a Windows host with
465    /// this (the server's bare names; a Win32 name holds no line break).
466    LsLongAll { path: String },
467    /// `@ls -lan "<path>"`: [`LsLongAll`](Self::LsLongAll) with numeric
468    /// owner and group. With `-n` every OpenSSH client formats each line
469    /// itself, with the listed path in front of the name, whatever the
470    /// server; the read transport lists a POSIX host with this so that every
471    /// entry line is anchored to its directory (a fragment after a raw
472    /// newline inside a name carries no anchor and is refused).
473    LsLongAllNumeric { path: String },
474    /// `@pwd`: prints `Remote working directory: <absolute path>`, the
475    /// account's home at the start of a session. A Windows OpenSSH server
476    /// prints long-listing names WITHOUT the listed directory in front of them
477    /// (C7a-2), so a batch that lists several directories puts a `pwd` before
478    /// each listing as the marker that says where one directory's lines start.
479    Pwd,
480    /// `get "<remote>" "<local>"`.
481    Get { remote: String, local: PathBuf },
482    /// `get -R "<remote>" "<local>"`: a directory tree; symlinks are skipped
483    /// by the client.
484    GetRecursive { remote: String, local: PathBuf },
485    /// `put "<local>" "<remote>"`.
486    Put { local: PathBuf, remote: String },
487    /// `rename "<from>" "<to>"`.
488    Rename { from: String, to: String },
489    /// `rm "<path>"`.
490    Rm { path: String },
491    /// `mkdir "<path>"`.
492    Mkdir { path: String },
493    /// `rmdir "<path>"`.
494    Rmdir { path: String },
495}
496
497impl SftpOp {
498    /// The batch line for this op, or why a path cannot be placed in one.
499    pub fn render(&self) -> Result<String, String> {
500        self.render_on(cfg!(windows))
501    }
502
503    /// [`render`](Self::render) as on a Windows runner or not, so both are
504    /// tested on every host. On a Windows runner a `\` in a LOCAL path is a
505    /// separator (no Windows file name can hold one) and becomes `/` before
506    /// [`safe_local_path`]; elsewhere it is refused. Either way no backslash
507    /// reaches the batch line. Remote paths are never rewritten.
508    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                // A rename onto itself DELETES the file on a Windows OpenSSH
548                // server (C7a-4): the source is removed and the target, the
549                // same path, is gone. Refuse it for every host.
550                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
564/// Whether two remote paths name the same entry as far as a Windows host is
565/// concerned: equal after ASCII-lowercasing (NTFS is case-insensitive) and
566/// collapsing repeated `/`, ignoring a trailing `/`. Over-matches on a
567/// case-sensitive host, which is the safe direction for a refusal.
568pub(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/// What an sftp failure said, read from stderr text because every failure
593/// exits 1 (C7a-3).
594#[derive(Debug, Clone, Copy, PartialEq, Eq)]
595pub enum SftpFailure {
596    /// "not found" / "No such file". On a Windows host a read the ACL denies
597    /// on a file that exists reports this too.
598    NotFound,
599    /// "Permission denied".
600    PermissionDenied,
601    /// "Failure": no reason given (a held target, a non-empty directory, an
602    /// existing directory).
603    Failure,
604    /// Anything else.
605    Other,
606}
607
608/// Classify an sftp client's stderr by its text.
609pub 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
622/// Whether the sftp client's failing line names a `mkdir`: the client prints
623/// `remote mkdir "<path>": Failure` for the command that failed. A `mkdir` that
624/// answers `Failure` is a directory that exists (a Windows host answers so for
625/// a path spelled with an 8.3 short name its parent's listing never shows) or
626/// one that cannot be created there; it is never a held or non-empty target.
627fn 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
633/// The second layer behind [`SftpOp`]: a rendered batch line must hold no line
634/// break (it would start another command) and must not begin, after optional
635/// whitespace and `@` / `-` prefixes, with `!` (a local shell escape).
636fn 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
651/// How one node's ssh and sftp invocations are built and run.
652pub struct RemoteShell {
653    runner: Arc<dyn ProcessRunner>,
654    host: String,
655    port: u16,
656    user: String,
657    /// Owns the known_hosts copy and the key file; removed on drop.
658    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    /// Replaces the network destination (the `-D <server>` test seam).
665    destination_override: Option<Vec<OsString>>,
666    /// Runs a remote command with the local `sh -c` instead of `ssh` (the
667    /// test seam that pairs with `destination_override`).
668    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    /// Build the shell for `ssh` of node `label` (`nodes.master`), reading
684    /// the key from the process environment.
685    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    /// [`from_ssh`](Self::from_ssh) with an injectable environment lookup
698    /// (tests; avoids mutating the process environment).
699    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    /// Test seam: a shell whose sftp talks to a local `sftp-server` binary
807    /// (`-D <server>`) instead of a network destination, so the sftp
808    /// protocol layer is exercised without an sshd.
809    #[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    /// Test seam: run [`exec_posix`](Self::exec_posix) commands with the local
832    /// `sh -c <command text>` (and [`exec_powershell`](Self::exec_powershell)
833    /// commands with the local `cmd /C <command text>` on Windows, the way the
834    /// Win32-OpenSSH default shell receives them) instead of `ssh`, so the
835    /// script host is exercised end to end without an sshd. Pair it with
836    /// [`local_sftp_server`](Self::local_sftp_server). Nothing in the binary
837    /// calls it.
838    #[doc(hidden)]
839    pub fn local_exec(mut self) -> RemoteShell {
840        self.local_exec = true;
841        self
842    }
843
844    /// The node label used in errors (`nodes.master`).
845    pub fn label(&self) -> &str {
846        &self.label
847    }
848
849    /// The per-call timeout.
850    pub fn op_timeout(&self) -> Duration {
851        self.op_timeout
852    }
853
854    /// This shell's private temp dir (known_hosts copy and key).
855    pub fn temp_dir(&self) -> Option<&Path> {
856        self.temp.as_ref().map(tempfile::TempDir::path)
857    }
858
859    /// The known_hosts copy ssh is pointed at.
860    pub fn known_hosts_copy(&self) -> &Path {
861        &self.known_hosts
862    }
863
864    /// The identity file passed with `-i`, when one is configured.
865    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    /// `ssh` argv: `-T -p <port> [-i <key>] -o k=v… -- user@host [command]`.
883    /// `remote_command` is sent to the remote shell as one argument; a
884    /// [`RemoteCommand`] can only be built injection-safe.
885    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    /// `sftp` argv: `-q -b - -P <port> [-i <key>] -o k=v… -- user@host`
905    /// (the batch comes on stdin).
906    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    /// Run `sftp` with `ops` as the batch on stdin, then close stdin. Every
925    /// op is rendered first, so one op with an unsafe path refuses the whole
926    /// batch before anything spawns.
927    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    /// Test-only: run raw batch lines, to exercise the line guard that backs
936    /// up [`SftpOp`]. Not compiled outside tests, never `pub`.
937    #[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    /// The second layer: each line passes [`check_batch_line`], then the
951    /// batch goes to sftp's stdin.
952    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    /// Map a finished invocation to `Ok` only when it exited 0 within its
970    /// timeout with nothing on stderr. Anything else is
971    /// [`CoreError::TransportFailed`] whose detail is the scrubbed stderr
972    /// tail (never the stdout, never the key).
973    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            // Every sftp failure exits 1 and the class is only in stderr
992            // text (C7a-3): name it. A not-found answer for a path a listing
993            // just returned means "unreadable" on a Windows host, where a
994            // read the ACL denies reports not found.
995            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    /// Run a fixed [`WinScript`] on a Windows host over ssh and return its
1023    /// result. The remote command is built by
1024    /// [`winps::encoded_command`] (the typed
1025    /// [`RemoteCommand::powershell_encoded`], held to the 8000 character
1026    /// budget before anything spawns); success needs exit 0 within the
1027    /// per-call timeout and exactly one `IGN-RESULT:` stdout line, and
1028    /// anything else, CLIXML on stderr included, is
1029    /// [`CoreError::TransportFailed`] (see [`winps`]).
1030    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    /// [`exec_powershell`](Self::exec_powershell) with `limit` as the per-call
1038    /// timeout instead of `ssh.op_timeout_s` (the commit's, which must not
1039    /// outlive the scan-lock hold by much, review WR-04).
1040    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            // the test seam: the command text goes to the local shell as the
1053            // Win32-OpenSSH default shell (`cmd.exe`) receives it
1054            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    /// Run one POSIX `command` (built by [`RemoteCommand::posix`]) on a Linux
1082    /// host over ssh and return the JSON of its one `IGN-RESULT:` stdout
1083    /// line. `op` names the script operation in every error. Success needs
1084    /// exit 0 within the per-call timeout and exactly one sentinel line; a
1085    /// non-zero exit, a timeout, a missing or doubled sentinel and text after
1086    /// the sentinel that is not JSON are all [`CoreError::TransportFailed`]
1087    /// naming `op`. The detail carries a scrubbed stderr tail and never the
1088    /// stdout.
1089    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    /// [`exec_posix`](Self::exec_posix) with `limit` as the per-call timeout
1098    /// instead of `ssh.op_timeout_s` (the commit's, review WR-04).
1099    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    /// Run one POSIX `command` (built by [`RemoteCommand::posix`]) for its
1110    /// exit status alone: success is exit 0 within the per-call timeout, and
1111    /// the stdout is not read. For a program that prints nothing on success
1112    /// (`mv`), where no `IGN-RESULT:` line can be asked for without wrapping
1113    /// the program in a shell script. A non-zero exit and a timeout are
1114    /// [`CoreError::TransportFailed`] naming `op`, with the scrubbed stderr
1115    /// tail and never the stdout.
1116    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    /// Run `command` and return the finished invocation when it exited 0
1134    /// within `limit`.
1135    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
1182/// The exit status of the fixed POSIX stub when the script or the work list
1183/// it was asked to run does not hash to the value the runner computed.
1184pub 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        // WR-01: a killed run leaves its key and downloads behind; the next
1195        // run removes only old, owned, real `ign-ssh-*` directories.
1196        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        // a different owner is never swept
1220        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    // ---- RemoteCommand (22-05 review, F5a) --------------------------------
1339
1340    #[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        // printf prints each argument NUL-terminated, so the output is the
1391        // argv exactly as the shell tokenised it
1392        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        // a known vector: "Write-Output hi" in UTF-16LE base64
1452        assert_eq!(
1453            RemoteCommand::powershell_encoded("hi").as_str(),
1454            "powershell -NoProfile -NonInteractive -EncodedCommand aABpAA=="
1455        );
1456    }
1457
1458    // ---- SftpOp (22-05 review, F5b) ---------------------------------------
1459
1460    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                        // G-22-1: on a Windows runner `\` in a local path is
1599                        // a separator and becomes `/`; no backslash reaches
1600                        // the line
1601                        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    /// G-22-1: a Windows runner's local paths (its `%TEMP%` is
1615    /// `C:\Users\RUNNER~1\...`) reach the batch line with `/`; any other
1616    /// runner refuses a `\`. Turning a separator into `/` never lets another
1617    /// refused character through, and a remote `\` is refused everywhere.
1618    #[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        // each of these is a path character sftp's double quotes make inert;
1663        // none can start a line, so none can be a command or a `!` shell escape
1664        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        // one bad op refuses the whole batch before anything spawns
1761        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        // the raw runner is the second layer: line break and leading `!`
1774        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    // ---- 22-13: Windows host behaviours from C7a ---------------------------
1785
1786    #[test]
1787    fn rename_onto_itself_is_refused_because_it_deletes_the_file() {
1788        // C7a-4: on Win32-OpenSSH `rename x x` answers "No such file" and the
1789        // file is GONE. No spelling of the same path may reach the host.
1790        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        // different paths still render
1806        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        // ssh's own auth failure exits 255 and is not an sftp class
1864        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        // the windows-latest line (G2)
1886        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        // every other command keeps the held or non-empty target text
1898        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        // the encoded command is ONE argv element, after `--` and the destination
1975        assert!(argv[0].1.iter().any(|a| a == "--"));
1976    }
1977
1978    /// The `local_exec` seam runs the encoded command through the local shell
1979    /// instead of `ssh`: `cmd /C <text>` on Windows, `sh -c <text>` elsewhere.
1980    #[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    /// Review WR-04: the commit's call is bounded by its own limit (the work
2089    /// list's budget plus a slack), not by `ssh.op_timeout_s`; a kill names the
2090    /// limit that applied.
2091    #[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        // The plain form still uses the op timeout.
2115        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        // an over-budget command is refused before the runner is called: the
2194        // scripted runner would panic on a call with no output queued
2195        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}