Skip to main content

farhand_core/
remote.rs

1//! Everything that touches the remote host: one multiplexed OpenSSH
2//! session, an SFTP channel on top of it, and the operations the tools are
3//! built from.
4//!
5//! Design rules:
6//! - Authentication is OpenSSH's job. FarHand starts `ssh <host>` and lets
7//!   the user's own config supply keys, agents, jump hosts and ports.
8//! - Command text is never shell-escaped by hand. It is base64-encoded and
9//!   decoded on the remote, so quoting cannot go wrong.
10//! - The local environment is never forwarded.
11//! - Every command runs under a remote timeout with a local watchdog
12//!   behind it.
13//! - Directory creation and tree walking go through SFTP, not the shell,
14//!   so they behave the same on every platform.
15//!
16//! Two platforms: POSIX (a `sh`, `timeout(1)` and `base64(1)`; the
17//! configured shell runs the script) and Windows with OpenSSH Server, where
18//! the script runs in PowerShell. See [`Platform`].
19
20use std::collections::HashSet;
21use std::sync::Arc;
22use std::time::{Duration, Instant};
23
24use base64::Engine;
25use bytes::BytesMut;
26use futures_util::StreamExt;
27use openssh::{KnownHosts, Session, SessionBuilder, Stdio};
28use openssh_sftp_client::{Sftp, SftpOptions};
29use tokio::io::AsyncReadExt;
30use tokio::sync::{Mutex, Semaphore};
31
32use crate::config::{default_shell, Limits, Os, RemoteConfig};
33use crate::error::{Error, Result};
34use crate::text::shell_quote;
35
36/// Remote `timeout(1)` exit status; the Windows wrapper uses the same.
37const TIMEOUT_EXIT: i32 = 124;
38/// Extra seconds the local watchdog waits past the remote timeout.
39const WATCHDOG_GRACE: u64 = 15;
40/// Exit status the wrapper uses when `cd` into the working directory fails.
41const CD_FAILED_EXIT: i32 = 97;
42/// Concurrent channels on the one session. sshd's default `MaxSessions` is
43/// 10 and the SFTP channel holds one permanently; agents that fan out tool
44/// calls otherwise hit "failed to connect to the ssh multiplex server".
45const MAX_CHANNELS: usize = 4;
46/// Last line the Windows wrapper prints so the exit status of a script
47/// that ended normally survives PowerShell's own 0/1 convention.
48const WIN_EXIT_MARKER: &str = "__FARHAND_EXIT__=";
49
50/// What the remote runs, detected from the SFTP root on connect.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum Platform {
53    Posix,
54    Windows,
55}
56
57impl Platform {
58    pub fn name(self) -> &'static str {
59        match self {
60            Platform::Posix => "posix",
61            Platform::Windows => "windows",
62        }
63    }
64}
65
66/// On Windows, what sshd hands our command line to (its `DefaultShell`).
67/// The exit status of a nested `powershell` is only propagated by `cmd`;
68/// under PowerShell we have to ask for it.
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70enum LoginShell {
71    PowerShell,
72    Cmd,
73}
74
75/// The remote as reachable from the local machine.
76#[derive(Debug, Clone)]
77pub struct Address {
78    pub hostname: String,
79    pub port: u16,
80    /// The destination as written in the config (an alias or `user@host`).
81    pub alias: String,
82}
83
84#[derive(Debug, Clone)]
85pub struct ExecOutput {
86    pub exit_code: i32,
87    pub stdout: Vec<u8>,
88    pub stderr: Vec<u8>,
89    pub stdout_truncated: bool,
90    pub stderr_truncated: bool,
91    pub timed_out: bool,
92    pub duration: Duration,
93}
94
95struct Conn {
96    session: Arc<Session>,
97    sftp: Sftp,
98    platform: Platform,
99    login_shell: LoginShell,
100    /// Native form: `/home/me` or `C:\Users\me`.
101    home: String,
102    /// The configured workdir with `~` expanded to `home`, native form.
103    workdir: String,
104}
105
106pub struct Remote {
107    cfg: RemoteConfig,
108    limits: Limits,
109    conn: Mutex<Option<Arc<Conn>>>,
110    channels: Semaphore,
111    /// Directories known to exist on the remote (native form), so a tree
112    /// upload does not re-check every parent for every file.
113    known_dirs: Mutex<HashSet<String>>,
114}
115
116impl Remote {
117    pub fn new(cfg: RemoteConfig, limits: Limits) -> Self {
118        Self {
119            cfg,
120            limits,
121            conn: Mutex::new(None),
122            channels: Semaphore::new(MAX_CHANNELS),
123            known_dirs: Mutex::new(HashSet::new()),
124        }
125    }
126
127    pub fn host(&self) -> &str {
128        &self.cfg.host
129    }
130
131    /// How the remote is addressed from this machine, as OpenSSH resolves
132    /// the destination: `(hostname, port)`. The model needs this to report
133    /// URLs the user can open; an alias from `~/.ssh/config` means nothing
134    /// to a browser. Falls back to the configured destination.
135    pub fn address(&self) -> Address {
136        let fallback = Address {
137            hostname: self
138                .cfg
139                .host
140                .rsplit('@')
141                .next()
142                .unwrap_or(&self.cfg.host)
143                .to_string(),
144            port: 22,
145            alias: self.cfg.host.clone(),
146        };
147        let out = match std::process::Command::new("ssh")
148            .arg("-G")
149            .arg(&self.cfg.host)
150            .stdin(std::process::Stdio::null())
151            .stderr(std::process::Stdio::null())
152            .output()
153        {
154            Ok(o) if o.status.success() => o.stdout,
155            _ => return fallback,
156        };
157        let text = String::from_utf8_lossy(&out);
158        let mut addr = fallback;
159        for line in text.lines() {
160            match line.split_once(' ') {
161                Some(("hostname", v)) => addr.hostname = v.trim().to_string(),
162                Some(("port", v)) => addr.port = v.trim().parse().unwrap_or(22),
163                _ => {}
164            }
165        }
166        addr
167    }
168
169    /// The workdir as configured (`~` not yet expanded).
170    pub fn workdir(&self) -> &str {
171        &self.cfg.workdir
172    }
173
174    /// The workdir with `~` expanded; connects if needed.
175    pub async fn effective_workdir(&self) -> Result<String> {
176        Ok(self.conn().await?.workdir.clone())
177    }
178
179    pub fn limits(&self) -> &Limits {
180        &self.limits
181    }
182
183    /// The platform as configured, before any connection: `None` for
184    /// `auto`.
185    pub fn configured_platform(&self) -> Option<Platform> {
186        match self.cfg.os {
187            Os::Auto => None,
188            Os::Posix => Some(Platform::Posix),
189            Os::Windows => Some(Platform::Windows),
190        }
191    }
192
193    /// The detected platform; connects if needed.
194    pub async fn platform(&self) -> Result<Platform> {
195        Ok(self.conn().await?.platform)
196    }
197
198    /// Connect now, or report why that is impossible.
199    pub async fn connect(&self) -> Result<()> {
200        self.conn().await.map(|_| ())
201    }
202
203    pub async fn is_connected(&self) -> bool {
204        match &*self.conn.lock().await {
205            Some(c) => c.session.check().await.is_ok(),
206            None => false,
207        }
208    }
209
210    async fn conn(&self) -> Result<Arc<Conn>> {
211        let mut slot = self.conn.lock().await;
212        if let Some(c) = &*slot {
213            if c.session.check().await.is_ok() {
214                return Ok(c.clone());
215            }
216            tracing::warn!("ssh session to {} dropped; reconnecting", self.cfg.host);
217            *slot = None;
218            self.known_dirs.lock().await.clear();
219        }
220        // Strict host-key checking: FarHand only talks to hosts the user has
221        // already connected to by hand. A `.farhand.toml` that arrives with a
222        // cloned repository therefore cannot point an agent at a machine the
223        // user never chose.
224        let mut builder = SessionBuilder::default();
225        builder
226            .connect_timeout(Duration::from_secs(self.cfg.connect_timeout_secs))
227            .server_alive_interval(Duration::from_secs(self.cfg.server_alive_interval_secs))
228            .known_hosts_check(KnownHosts::Strict);
229        let session = builder.connect_mux(&self.cfg.host).await.map_err(|e| {
230            Error::Ssh(format!(
231                "cannot connect to `{}`: {e}. Check that `ssh {}` works from a terminal; \
232                 FarHand refuses hosts whose key is not yet in known_hosts, so connect once \
233                 by hand first.",
234                self.cfg.host, self.cfg.host
235            ))
236        })?;
237        let session = Arc::new(session);
238        let sftp = Sftp::from_clonable_session(session.clone(), SftpOptions::default())
239            .await
240            .map_err(|e| {
241                Error::Ssh(format!(
242                    "sftp subsystem unavailable on `{}`: {e}",
243                    self.cfg.host
244                ))
245            })?;
246        // The SFTP session starts in the user's home. Its canonical form
247        // also tells the platform apart: Windows OpenSSH reports `/C:/...`.
248        let sftp_home = sftp
249            .fs()
250            .canonicalize(".")
251            .await
252            .map_err(|e| Error::Ssh(format!("cannot resolve the remote home: {e}")))?
253            .to_string_lossy()
254            .into_owned();
255        let detected = if is_sftp_drive_path(&sftp_home) {
256            Platform::Windows
257        } else {
258            Platform::Posix
259        };
260        let platform = match (self.cfg.os, detected) {
261            (Os::Auto, d) => d,
262            (Os::Posix, Platform::Posix) | (Os::Windows, Platform::Windows) => detected,
263            (configured, d) => {
264                tracing::warn!(
265                    "config says os = {:?} but the remote looks like {}; using {}",
266                    configured,
267                    d.name(),
268                    d.name()
269                );
270                d
271            }
272        };
273        let home = match platform {
274            Platform::Posix => sftp_home,
275            Platform::Windows => from_sftp_path(&sftp_home),
276        };
277        let login_shell = match platform {
278            Platform::Posix => LoginShell::PowerShell, // unused
279            Platform::Windows => {
280                // `%OS%` is expanded by cmd and left alone by PowerShell.
281                let out = session
282                    .raw_command("echo %OS%")
283                    .stdin(Stdio::null())
284                    .output()
285                    .await?;
286                if String::from_utf8_lossy(&out.stdout).contains("Windows_NT") {
287                    LoginShell::Cmd
288                } else {
289                    LoginShell::PowerShell
290                }
291            }
292        };
293        let workdir = normalize_for(platform, &expand_tilde(&self.cfg.workdir, &home));
294        let conn = Arc::new(Conn {
295            session,
296            sftp,
297            platform,
298            login_shell,
299            home,
300            workdir,
301        });
302        *slot = Some(conn.clone());
303        tracing::info!(host = %self.cfg.host, platform = platform.name(), "connected");
304        Ok(conn)
305    }
306
307    /// Absolute remote path, in the remote's native form, for what the
308    /// model typed: `~` expands to the remote home, relative paths hang off
309    /// the working directory. `/` and `\` are both accepted on Windows.
310    pub async fn resolve(&self, raw: &str) -> Result<String> {
311        let raw = raw.trim();
312        let conn = self.conn().await?;
313        if raw.is_empty() {
314            return Ok(conn.workdir.clone());
315        }
316        if is_absolute_for(conn.platform, raw) {
317            return Ok(normalize_for(conn.platform, raw));
318        }
319        if raw == "~" || raw.starts_with("~/") || raw.starts_with("~\\") {
320            return Ok(normalize_for(conn.platform, &expand_tilde(raw, &conn.home)));
321        }
322        Ok(normalize_for(
323            conn.platform,
324            &format!("{}/{raw}", conn.workdir),
325        ))
326    }
327
328    /// Run `command` in `cwd` (default: the working directory) through the
329    /// configured shell — PowerShell on Windows. `timeout_secs` is clamped
330    /// to the configured maximum.
331    pub async fn exec(
332        &self,
333        command: &str,
334        cwd: Option<&str>,
335        timeout_secs: Option<u64>,
336    ) -> Result<ExecOutput> {
337        let conn = self.conn().await?;
338        let cwd = match cwd {
339            Some(c) => self.resolve(c).await?,
340            None => conn.workdir.clone(),
341        };
342        let timeout = timeout_secs
343            .unwrap_or(self.limits.command_timeout_secs)
344            .clamp(1, self.limits.max_command_timeout_secs);
345
346        let _permit = self.channels.acquire().await.expect("semaphore open");
347        let started = Instant::now();
348        let mut cmd = match conn.platform {
349            Platform::Posix => {
350                // The script the user's shell runs. Colour is off because
351                // the model reads the output; nothing else about the remote
352                // environment is touched, and nothing local is forwarded.
353                let script = format!(
354                    "cd {} || exit {CD_FAILED_EXIT}\nexport NO_COLOR=1\n{command}\n",
355                    shell_quote(&cwd)
356                );
357                let b64 = base64::engine::general_purpose::STANDARD.encode(script.as_bytes());
358                let shell = self
359                    .cfg
360                    .shell
361                    .iter()
362                    .map(|s| format!("\"{}\"", s.replace('"', "\\\"")))
363                    .collect::<Vec<_>>()
364                    .join(" ");
365                // Outer `sh -c` so the remote login shell only has to parse
366                // one single-quoted word; the inner script has no single
367                // quotes because base64 has none and the argv is
368                // double-quoted.
369                let inner =
370                    format!("timeout -k 5 {timeout} {shell} \"$(printf %s {b64} | base64 -d)\"");
371                let mut c = conn.session.raw_command("sh");
372                c.raw_arg("-c").raw_arg(format!("'{inner}'"));
373                c
374            }
375            Platform::Windows => {
376                let (exe, extra) = self.powershell();
377                let outer = windows_wrapper(exe, &extra, &cwd, command, timeout);
378                let mut c = conn.session.raw_command(exe);
379                for a in &extra {
380                    c.raw_arg(a);
381                }
382                c.raw_arg("-NoProfile")
383                    .raw_arg("-NonInteractive")
384                    .raw_arg("-ExecutionPolicy")
385                    .raw_arg("Bypass")
386                    .raw_arg("-EncodedCommand")
387                    .raw_arg(encode_utf16_b64(&outer));
388                if conn.login_shell == LoginShell::PowerShell {
389                    // `powershell -c "<cmd>"` would otherwise turn any
390                    // non-zero status into 1.
391                    c.raw_arg("; exit $LASTEXITCODE");
392                }
393                c
394            }
395        };
396        cmd.stdin(Stdio::null())
397            .stdout(Stdio::piped())
398            .stderr(Stdio::piped());
399        let mut child = cmd.spawn().await?;
400        let stdout = child.stdout().take().expect("piped stdout");
401        let stderr = child.stderr().take().expect("piped stderr");
402        let cap = self.limits.max_output_bytes;
403
404        let collect = async {
405            let (out, err) =
406                tokio::join!(collect_bounded(stdout, cap), collect_bounded(stderr, cap));
407            let status = child.wait().await?;
408            Ok::<_, Error>((out?, err?, status))
409        };
410        let watchdog = Duration::from_secs(timeout + WATCHDOG_GRACE);
411        let (mut out, mut err, status) = match tokio::time::timeout(watchdog, collect).await {
412            Ok(r) => r?,
413            Err(_) => {
414                tracing::warn!("local watchdog fired after {}s", watchdog.as_secs());
415                return Err(Error::Timeout { secs: timeout });
416            }
417        };
418        let mut exit_code = status.code().unwrap_or(-1);
419        if conn.platform == Platform::Windows {
420            if let Some(code) = take_windows_exit_marker(&mut out.data) {
421                exit_code = code;
422            }
423            out.data = crlf_to_lf(&out.data);
424            err.data = crlf_to_lf(&decode_clixml(&err.data));
425        }
426        let timed_out = exit_code == TIMEOUT_EXIT;
427        if exit_code == CD_FAILED_EXIT && out.data.is_empty() {
428            return Err(Error::Invalid(format!(
429                "cannot cd into `{cwd}` on the remote"
430            )));
431        }
432        Ok(ExecOutput {
433            exit_code,
434            stdout: out.data,
435            stderr: err.data,
436            stdout_truncated: out.truncated,
437            stderr_truncated: err.truncated,
438            timed_out,
439            duration: started.elapsed(),
440        })
441    }
442
443    /// The PowerShell executable and any extra leading arguments, from
444    /// `remote.shell` unless that is still the POSIX default.
445    fn powershell(&self) -> (&str, Vec<String>) {
446        if self.cfg.shell == default_shell() || self.cfg.shell.is_empty() {
447            ("powershell.exe", Vec::new())
448        } else {
449            (&self.cfg.shell[0], self.cfg.shell[1..].to_vec())
450        }
451    }
452
453    /// Read at most `max` bytes of a remote file. Returns the bytes and the
454    /// file's full length.
455    pub async fn read_file(&self, path: &str, max: usize) -> Result<(Vec<u8>, u64)> {
456        let conn = self.conn().await?;
457        let path = self.resolve(path).await?;
458        let sftp_path = to_sftp_path(conn.platform, &path);
459        let _permit = self.channels.acquire().await.expect("semaphore open");
460        let mut fs = conn.sftp.fs();
461        let meta = fs
462            .metadata(&sftp_path)
463            .await
464            .map_err(|e| not_found(&path, e))?;
465        if meta.file_type().map(|t| t.is_dir()).unwrap_or(false) {
466            return Err(Error::Invalid(format!("`{path}` is a directory")));
467        }
468        let len = meta.len().unwrap_or(0);
469        let mut file = conn
470            .sftp
471            .open(&sftp_path)
472            .await
473            .map_err(|e| not_found(&path, e))?;
474        let want = (len as usize).min(max);
475        let mut buf = BytesMut::with_capacity(want);
476        let mut remaining = want;
477        while remaining > 0 {
478            let chunk = remaining.min(256 * 1024) as u32;
479            match file.read(chunk, buf.split_off(buf.len())).await? {
480                Some(bytes) => {
481                    remaining -= bytes.len();
482                    buf.unsplit(bytes);
483                }
484                None => break,
485            }
486        }
487        let _ = file.close().await;
488        Ok((buf.to_vec(), len))
489    }
490
491    /// Write a whole file, creating parent directories.
492    pub async fn write_file(&self, path: &str, content: &[u8]) -> Result<String> {
493        let conn = self.conn().await?;
494        let path = self.resolve(path).await?;
495        if let Some(parent) = parent_of(conn.platform, &path) {
496            self.ensure_dir(&parent).await?;
497        }
498        let _permit = self.channels.acquire().await.expect("semaphore open");
499        conn.sftp
500            .fs()
501            .write(to_sftp_path(conn.platform, &path), content)
502            .await
503            .map_err(|e| Error::Ssh(format!("cannot write `{path}`: {e}")))?;
504        Ok(path)
505    }
506
507    /// Create `dir` and any missing parents over SFTP. `dir` is a resolved
508    /// native path.
509    pub async fn ensure_dir(&self, dir: &str) -> Result<()> {
510        let conn = self.conn().await?;
511        if self.known_dirs.lock().await.contains(dir) {
512            return Ok(());
513        }
514        // Walk up to the nearest existing ancestor, then create downwards.
515        let mut missing: Vec<String> = Vec::new();
516        let mut cur = dir.to_string();
517        loop {
518            if self.known_dirs.lock().await.contains(&cur) {
519                break;
520            }
521            let _permit = self.channels.acquire().await.expect("semaphore open");
522            match conn
523                .sftp
524                .fs()
525                .metadata(to_sftp_path(conn.platform, &cur))
526                .await
527            {
528                Ok(m) => {
529                    if !m.file_type().map(|t| t.is_dir()).unwrap_or(false) {
530                        return Err(Error::Invalid(format!(
531                            "`{cur}` exists on the remote and is not a directory"
532                        )));
533                    }
534                    self.known_dirs.lock().await.insert(cur.clone());
535                    break;
536                }
537                Err(_) => {
538                    missing.push(cur.clone());
539                    match parent_of(conn.platform, &cur) {
540                        Some(p) => cur = p,
541                        None => break,
542                    }
543                }
544            }
545        }
546        for d in missing.into_iter().rev() {
547            let _permit = self.channels.acquire().await.expect("semaphore open");
548            if let Err(e) = conn
549                .sftp
550                .fs()
551                .create_dir(to_sftp_path(conn.platform, &d))
552                .await
553            {
554                // Another call may have created it meanwhile.
555                let exists = conn
556                    .sftp
557                    .fs()
558                    .metadata(to_sftp_path(conn.platform, &d))
559                    .await
560                    .map(|m| m.file_type().map(|t| t.is_dir()).unwrap_or(false))
561                    .unwrap_or(false);
562                if !exists {
563                    return Err(Error::Ssh(format!("cannot create directory `{d}`: {e}")));
564                }
565            }
566            self.known_dirs.lock().await.insert(d);
567        }
568        Ok(())
569    }
570
571    pub async fn exists(&self, path: &str) -> Result<Option<bool>> {
572        let conn = self.conn().await?;
573        let path = self.resolve(path).await?;
574        let _permit = self.channels.acquire().await.expect("semaphore open");
575        match conn
576            .sftp
577            .fs()
578            .metadata(to_sftp_path(conn.platform, &path))
579            .await
580        {
581            Ok(m) => Ok(Some(m.file_type().map(|t| t.is_dir()).unwrap_or(false))),
582            Err(_) => Ok(None),
583        }
584    }
585
586    /// Every regular file under `dir` (a resolved native path), as native
587    /// paths, over SFTP. Symlinks are not followed. Stops after `limit`
588    /// entries so a huge tree is refused quickly rather than listed.
589    pub async fn walk_files(&self, dir: &str, limit: usize) -> Result<Vec<String>> {
590        let conn = self.conn().await?;
591        let sep = separator(conn.platform);
592        let mut files = Vec::new();
593        let mut stack = vec![dir.to_string()];
594        while let Some(d) = stack.pop() {
595            let _permit = self.channels.acquire().await.expect("semaphore open");
596            let handle = conn
597                .sftp
598                .fs()
599                .open_dir(to_sftp_path(conn.platform, &d))
600                .await
601                .map_err(|e| Error::Ssh(format!("cannot list `{d}`: {e}")))?;
602            let mut entries = std::pin::pin!(handle.read_dir());
603            while let Some(entry) = entries.next().await {
604                let entry = entry.map_err(|e| Error::Ssh(format!("cannot list `{d}`: {e}")))?;
605                let name = entry.filename().to_string_lossy().into_owned();
606                if name == "." || name == ".." {
607                    continue;
608                }
609                let full = format!("{d}{sep}{name}");
610                match entry.file_type() {
611                    Some(t) if t.is_dir() => stack.push(full),
612                    Some(t) if t.is_file() => {
613                        files.push(full);
614                        if files.len() > limit {
615                            return Ok(files);
616                        }
617                    }
618                    _ => {}
619                }
620            }
621        }
622        files.sort();
623        Ok(files)
624    }
625
626    /// Whether a remote executable is on PATH.
627    pub async fn has_tool(&self, name: &str) -> Result<bool> {
628        let conn = self.conn().await?;
629        let cmd = match conn.platform {
630            Platform::Posix => format!("command -v {} >/dev/null 2>&1", shell_quote(name)),
631            Platform::Windows => format!(
632                "if (Get-Command {} -ErrorAction SilentlyContinue) {{ 'yes' }} else {{ 'no' }}",
633                ps_quote(name)
634            ),
635        };
636        let out = self.exec(&cmd, None, Some(15)).await?;
637        Ok(match conn.platform {
638            Platform::Posix => out.exit_code == 0,
639            Platform::Windows => String::from_utf8_lossy(&out.stdout).contains("yes"),
640        })
641    }
642}
643
644fn not_found(path: &str, e: openssh_sftp_client::Error) -> Error {
645    Error::Ssh(format!("`{path}`: {e}"))
646}
647
648// ---- Windows command wrapper ------------------------------------------
649
650/// Quote for PowerShell as a single-quoted literal.
651pub fn ps_quote(s: &str) -> String {
652    format!("'{}'", s.replace('\'', "''"))
653}
654
655/// UTF-16LE base64, what `powershell -EncodedCommand` expects.
656fn encode_utf16_b64(script: &str) -> String {
657    let bytes: Vec<u8> = script
658        .encode_utf16()
659        .flat_map(|u| u.to_le_bytes())
660        .collect();
661    base64::engine::general_purpose::STANDARD.encode(bytes)
662}
663
664/// The PowerShell script that runs the model's command on Windows.
665///
666/// Two processes: the outer one enforces the timeout (`Start-Process`,
667/// `WaitForExit`, `taskkill /T`), the inner one runs the command with the
668/// working directory set, UTF-8 output and colour off. Output flows through
669/// inherited handles, so nothing is buffered in files.
670///
671/// UTF-8 is set on the console (both code pages) and, because a program
672/// whose stdout is a pipe consults its locale rather than the console,
673/// also for Python through `PYTHONUTF8`/`PYTHONIOENCODING`; the model reads
674/// what such programs print, and on a `cp1252` machine that is otherwise
675/// a `UnicodeEncodeError` for any non-Latin text.
676///
677/// The inner script must end naturally: `exit` from a `-EncodedCommand`
678/// script discards output still in PowerShell's formatting pipeline. So the
679/// last thing it prints is [`WIN_EXIT_MARKER`] with the status, which
680/// [`take_windows_exit_marker`] reads back. The status follows shell
681/// convention: that of the last statement (`$?`), with `$LASTEXITCODE` for
682/// a failed native command. `$Error` is deliberately not consulted: it also
683/// collects every stderr line a native program wrote and every error that
684/// `-ErrorAction SilentlyContinue` hid. A command that calls `exit` itself
685/// skips the marker and its status arrives as the process status instead.
686fn windows_wrapper(exe: &str, extra: &[String], cwd: &str, command: &str, timeout: u64) -> String {
687    let inner = format!(
688        "$ProgressPreference='SilentlyContinue'; $ErrorActionPreference='Continue'\n\
689         [Console]::OutputEncoding=[Text.Encoding]::UTF8; [Console]::InputEncoding=[Text.Encoding]::UTF8; \
690         $OutputEncoding=[Text.Encoding]::UTF8\n\
691         $env:NO_COLOR='1'; $env:PYTHONUTF8='1'; $env:PYTHONIOENCODING='utf-8'\n\
692         try {{ Set-Location -LiteralPath {cwd} -ErrorAction Stop }} \
693         catch {{ [Console]::Error.WriteLine('cannot cd'); exit {CD_FAILED_EXIT} }}\n\
694         $global:LASTEXITCODE=0\n\
695         {command}\n\
696         $__fh_ok=$?\n\
697         Write-Output \"{WIN_EXIT_MARKER}$(if ($__fh_ok) {{0}} elseif ($LASTEXITCODE) {{$LASTEXITCODE}} else {{1}})\"\n",
698        cwd = ps_quote(cwd),
699    );
700    let mut args: Vec<String> = extra.iter().map(|a| ps_quote(a)).collect();
701    args.extend(
702        [
703            "-NoProfile",
704            "-NonInteractive",
705            "-ExecutionPolicy",
706            "Bypass",
707            "-EncodedCommand",
708        ]
709        .iter()
710        .map(|a| ps_quote(a)),
711    );
712    args.push(ps_quote(&encode_utf16_b64(&inner)));
713    format!(
714        "$ProgressPreference='SilentlyContinue'; $ErrorActionPreference='Continue'\n\
715         $p=Start-Process -FilePath {exe} -ArgumentList {args} -NoNewWindow -PassThru\n\
716         $null=$p.Handle\n\
717         if (-not $p.WaitForExit({ms})) {{ taskkill /T /F /PID $p.Id 2>&1 | Out-Null; \
718         $p.WaitForExit(); exit {TIMEOUT_EXIT} }}\n\
719         exit $p.ExitCode\n",
720        exe = ps_quote(exe),
721        args = args.join(","),
722        ms = timeout.saturating_mul(1000),
723    )
724}
725
726/// Find the exit marker the wrapper printed, remove it (and anything
727/// after it) from `stdout`, and return the status it encodes.
728fn take_windows_exit_marker(stdout: &mut Vec<u8>) -> Option<i32> {
729    let text = String::from_utf8_lossy(stdout);
730    let pos = text.rfind(WIN_EXIT_MARKER)?;
731    let rest = &text[pos + WIN_EXIT_MARKER.len()..];
732    let line = rest.lines().next().unwrap_or("").trim();
733    let code: i32 = line.parse().ok()?;
734    // Keep only what came before the marker, minus trailing blank lines.
735    let keep = text[..pos].trim_end_matches(['\r', '\n']).to_string();
736    stdout.clear();
737    stdout.extend_from_slice(keep.as_bytes());
738    if !stdout.is_empty() {
739        stdout.push(b'\n');
740    }
741    Some(code)
742}
743
744fn crlf_to_lf(data: &[u8]) -> Vec<u8> {
745    let mut out = Vec::with_capacity(data.len());
746    let mut i = 0;
747    while i < data.len() {
748        if data[i] == b'\r' && data.get(i + 1) == Some(&b'\n') {
749            i += 1;
750            continue;
751        }
752        out.push(data[i]);
753        i += 1;
754    }
755    out
756}
757
758/// PowerShell serialises its error stream as CLIXML when stderr is not a
759/// console. Turn `<S S="Error">…</S>` records back into text, drop
760/// progress records, and leave anything that is not CLIXML alone.
761fn decode_clixml(data: &[u8]) -> Vec<u8> {
762    let text = String::from_utf8_lossy(data);
763    if !text.contains("#< CLIXML") {
764        return data.to_vec();
765    }
766    let mut out = String::new();
767    let mut rest: &str = &text;
768    loop {
769        match rest.find("#< CLIXML") {
770            None => {
771                out.push_str(rest);
772                break;
773            }
774            Some(h) => {
775                out.push_str(&rest[..h]);
776                let after = &rest[h + "#< CLIXML".len()..];
777                let after = after
778                    .strip_prefix("\r\n")
779                    .or_else(|| after.strip_prefix('\n'))
780                    .unwrap_or(after);
781                match after.find("</Objs>") {
782                    Some(end) => {
783                        out.push_str(&clixml_strings(&after[..end]));
784                        rest = &after[end + "</Objs>".len()..];
785                    }
786                    None => {
787                        out.push_str(&clixml_strings(after));
788                        break;
789                    }
790                }
791            }
792        }
793    }
794    out.into_bytes()
795}
796
797fn clixml_strings(xml: &str) -> String {
798    let mut out = String::new();
799    let mut rest = xml;
800    while let Some(start) = rest.find("<S S=\"") {
801        let after = &rest[start + 6..];
802        let Some(q) = after.find('"') else { break };
803        let kind = &after[..q];
804        let after = &after[q + 1..];
805        let Some(gt) = after.find('>') else { break };
806        let body_start = &after[gt + 1..];
807        let Some(end) = body_start.find("</S>") else {
808            break;
809        };
810        if kind == "Error" || kind == "Warning" {
811            out.push_str(&unescape_clixml(&body_start[..end]));
812        }
813        rest = &body_start[end + 4..];
814    }
815    out
816}
817
818fn unescape_clixml(s: &str) -> String {
819    let s = s
820        .replace("_x000D__x000A_", "\n")
821        .replace("_x000A_", "\n")
822        .replace("_x000D_", "")
823        .replace("_x0009_", "\t");
824    s.replace("&lt;", "<")
825        .replace("&gt;", ">")
826        .replace("&quot;", "\"")
827        .replace("&apos;", "'")
828        .replace("&amp;", "&")
829}
830
831// ---- paths -----------------------------------------------------------------
832
833fn separator(p: Platform) -> char {
834    match p {
835        Platform::Posix => '/',
836        Platform::Windows => '\\',
837    }
838}
839
840/// `/C:/Users/me` — how Windows OpenSSH's sftp-server spells paths.
841fn is_sftp_drive_path(s: &str) -> bool {
842    let b = s.as_bytes();
843    b.len() >= 3 && b[0] == b'/' && b[1].is_ascii_alphabetic() && b[2] == b':'
844}
845
846fn is_drive_path(s: &str) -> bool {
847    let b = s.as_bytes();
848    b.len() >= 2 && b[0].is_ascii_alphabetic() && b[1] == b':'
849}
850
851fn is_absolute_for(p: Platform, raw: &str) -> bool {
852    match p {
853        Platform::Posix => raw.starts_with('/'),
854        Platform::Windows => {
855            is_drive_path(raw) || is_sftp_drive_path(raw) || raw.starts_with("\\\\")
856        }
857    }
858}
859
860/// Native path for the SFTP layer: identity on POSIX, `C:\a\b` →
861/// `/C:/a/b` on Windows.
862fn to_sftp_path(p: Platform, native: &str) -> String {
863    match p {
864        Platform::Posix => native.to_string(),
865        Platform::Windows => format!("/{}", native.replace('\\', "/")),
866    }
867}
868
869/// `/C:/a/b` → `C:\a\b`.
870fn from_sftp_path(sftp: &str) -> String {
871    let s = sftp.strip_prefix('/').unwrap_or(sftp);
872    let s = s.replace('/', "\\");
873    if s.len() == 2 && is_drive_path(&s) {
874        format!("{s}\\")
875    } else {
876        s
877    }
878}
879
880fn parent_of(p: Platform, path: &str) -> Option<String> {
881    match p {
882        Platform::Posix => {
883            if path == "/" {
884                return None;
885            }
886            match path.rfind('/') {
887                Some(0) => Some("/".to_string()),
888                Some(i) => Some(path[..i].to_string()),
889                None => None,
890            }
891        }
892        Platform::Windows => {
893            // `C:\` has no parent; `C:\a` → `C:\`.
894            if path.len() <= 3 {
895                return None;
896            }
897            let i = path.rfind('\\')?;
898            if i <= 2 {
899                Some(path[..3].to_string())
900            } else {
901                Some(path[..i].to_string())
902            }
903        }
904    }
905}
906
907fn normalize_for(p: Platform, raw: &str) -> String {
908    match p {
909        Platform::Posix => normalize(raw),
910        Platform::Windows => normalize_windows(raw),
911    }
912}
913
914/// `~` and `~/x` become `home` and `home/x`; anything else is unchanged.
915fn expand_tilde(p: &str, home: &str) -> String {
916    if p == "~" {
917        home.to_string()
918    } else if let Some(rest) = p.strip_prefix("~/").or_else(|| p.strip_prefix("~\\")) {
919        format!("{home}/{rest}")
920    } else {
921        p.to_string()
922    }
923}
924
925/// Collapse `.` and `..` segments without touching the filesystem.
926pub fn normalize(p: &str) -> String {
927    let mut parts: Vec<&str> = Vec::new();
928    for seg in p.split('/') {
929        match seg {
930            "" | "." => {}
931            ".." => {
932                parts.pop();
933            }
934            s => parts.push(s),
935        }
936    }
937    format!("/{}", parts.join("/"))
938}
939
940/// Windows form with backslashes and a drive: accepts `C:/a/../b`,
941/// `/C:/a/b` (SFTP form) and `C:\a\b`; UNC paths keep their `\\` prefix.
942pub fn normalize_windows(p: &str) -> String {
943    let unc = p.starts_with("\\\\") || p.starts_with("//");
944    let s = p.replace('/', "\\");
945    let s = s
946        .strip_prefix('\\')
947        .filter(|r| is_drive_path(r))
948        .unwrap_or(&s);
949    let mut parts: Vec<&str> = Vec::new();
950    let mut drive = String::new();
951    for (i, seg) in s.split('\\').enumerate() {
952        if i == 0 && is_drive_path(seg) && seg.len() == 2 {
953            drive = seg.to_uppercase();
954            continue;
955        }
956        match seg {
957            "" | "." => {}
958            ".." => {
959                parts.pop();
960            }
961            x => parts.push(x),
962        }
963    }
964    if unc {
965        return format!("\\\\{}", parts.join("\\"));
966    }
967    if drive.is_empty() {
968        // Drive-less absolute path: leave the caller a clear failure
969        // rather than guessing a drive.
970        return format!("\\{}", parts.join("\\"));
971    }
972    if parts.is_empty() {
973        format!("{drive}\\")
974    } else {
975        format!("{drive}\\{}", parts.join("\\"))
976    }
977}
978
979struct Collected {
980    data: Vec<u8>,
981    truncated: bool,
982}
983
984/// Read a stream to its end, keeping at most the first two thirds and the
985/// last third of `cap` bytes so a runaway command cannot exhaust memory.
986async fn collect_bounded<R: AsyncReadExt + Unpin>(mut r: R, cap: usize) -> Result<Collected> {
987    let head_cap = cap * 2 / 3;
988    let tail_cap = cap - head_cap;
989    let mut head = Vec::new();
990    let mut tail = Vec::new();
991    let mut truncated = false;
992    let mut buf = vec![0u8; 64 * 1024];
993    loop {
994        let n = r.read(&mut buf).await?;
995        if n == 0 {
996            break;
997        }
998        let mut chunk = &buf[..n];
999        if head.len() < head_cap {
1000            let take = chunk.len().min(head_cap - head.len());
1001            head.extend_from_slice(&chunk[..take]);
1002            chunk = &chunk[take..];
1003        }
1004        if !chunk.is_empty() {
1005            truncated = true;
1006            tail.extend_from_slice(chunk);
1007            if tail.len() > tail_cap * 2 {
1008                let drop = tail.len() - tail_cap;
1009                tail.drain(..drop);
1010            }
1011        }
1012    }
1013    if truncated {
1014        if tail.len() > tail_cap {
1015            let drop = tail.len() - tail_cap;
1016            tail.drain(..drop);
1017        }
1018        let omitted_note = format!("\n\n[... output truncated; limit is {cap} bytes ...]\n\n");
1019        head.extend_from_slice(omitted_note.as_bytes());
1020        head.extend_from_slice(&tail);
1021    }
1022    Ok(Collected {
1023        data: head,
1024        truncated,
1025    })
1026}
1027
1028#[cfg(test)]
1029mod tests {
1030    use super::*;
1031
1032    #[test]
1033    fn tilde_expansion() {
1034        assert_eq!(expand_tilde("~", "/root"), "/root");
1035        assert_eq!(expand_tilde("~/a/b", "/root"), "/root/a/b");
1036        assert_eq!(expand_tilde("/x", "/root"), "/x");
1037        assert_eq!(expand_tilde("rel", "/root"), "rel");
1038        assert_eq!(
1039            normalize_windows(&expand_tilde("~\\x", "C:\\Users\\me")),
1040            "C:\\Users\\me\\x"
1041        );
1042    }
1043
1044    #[test]
1045    fn normalizes_paths() {
1046        assert_eq!(normalize("/a/b/../c/./d"), "/a/c/d");
1047        assert_eq!(normalize("/../x"), "/x");
1048        assert_eq!(normalize("/"), "/");
1049    }
1050
1051    #[test]
1052    fn windows_paths() {
1053        assert_eq!(normalize_windows("C:/Users/me/../x/./y"), "C:\\Users\\x\\y");
1054        assert_eq!(normalize_windows("/C:/Users/me"), "C:\\Users\\me");
1055        assert_eq!(normalize_windows("c:\\"), "C:\\");
1056        assert_eq!(
1057            normalize_windows("C:\\work/proj\\src"),
1058            "C:\\work\\proj\\src"
1059        );
1060        assert_eq!(
1061            normalize_windows("\\\\server\\share\\x"),
1062            "\\\\server\\share\\x"
1063        );
1064        assert_eq!(to_sftp_path(Platform::Windows, "C:\\a\\b"), "/C:/a/b");
1065        assert_eq!(from_sftp_path("/C:/Users/test"), "C:\\Users\\test");
1066        assert_eq!(from_sftp_path("/C:"), "C:\\");
1067        assert!(is_absolute_for(Platform::Windows, "D:\\x"));
1068        assert!(is_absolute_for(Platform::Windows, "/C:/x"));
1069        assert!(!is_absolute_for(Platform::Windows, "src\\main.rs"));
1070        assert!(!is_absolute_for(Platform::Windows, "/tmp"));
1071        assert_eq!(
1072            parent_of(Platform::Windows, "C:\\a\\b").as_deref(),
1073            Some("C:\\a")
1074        );
1075        assert_eq!(
1076            parent_of(Platform::Windows, "C:\\a").as_deref(),
1077            Some("C:\\")
1078        );
1079        assert_eq!(parent_of(Platform::Windows, "C:\\"), None);
1080        assert_eq!(parent_of(Platform::Posix, "/a/b").as_deref(), Some("/a"));
1081        assert_eq!(parent_of(Platform::Posix, "/a").as_deref(), Some("/"));
1082        assert_eq!(parent_of(Platform::Posix, "/"), None);
1083    }
1084
1085    #[test]
1086    fn exit_marker_and_clixml() {
1087        let mut out = b"table\r\n__FARHAND_EXIT__=3\r\n\r\n".to_vec();
1088        assert_eq!(take_windows_exit_marker(&mut out), Some(3));
1089        assert_eq!(out, b"table\n");
1090        let mut out = b"x\n__FARHAND_EXIT__=1\n".to_vec();
1091        assert_eq!(take_windows_exit_marker(&mut out), Some(1));
1092        let mut out = b"__FARHAND_EXIT__=0\n".to_vec();
1093        assert_eq!(take_windows_exit_marker(&mut out), Some(0));
1094        assert!(out.is_empty());
1095        let mut out = b"no marker".to_vec();
1096        assert_eq!(take_windows_exit_marker(&mut out), None);
1097
1098        let err = b"raw first\r\n#< CLIXML\r\n<Objs Version=\"1.1.0.1\"><Obj S=\"progress\"><TN></TN></Obj>\
1099                    <S S=\"Error\">Get-Item : Cannot find path &apos;C:\\nope&apos;_x000D__x000A_</S>\
1100                    <S S=\"Error\">At line:6_x000D__x000A_</S></Objs>";
1101        let text = String::from_utf8(decode_clixml(err)).unwrap();
1102        assert_eq!(
1103            text,
1104            "raw first\r\nGet-Item : Cannot find path 'C:\\nope'\nAt line:6\n"
1105        );
1106        assert_eq!(crlf_to_lf(b"a\r\nb\r\n"), b"a\nb\n");
1107        assert_eq!(ps_quote("it's"), "'it''s'");
1108    }
1109
1110    #[test]
1111    fn wrapper_is_plain_ascii_on_the_command_line() {
1112        let w = windows_wrapper("powershell.exe", &[], "C:\\Users\\me", "Get-ChildItem", 30);
1113        assert!(w.contains("WaitForExit(30000)"));
1114        assert!(w.contains("-EncodedCommand"));
1115        assert!(encode_utf16_b64("x")
1116            .chars()
1117            .all(|c| c.is_ascii_alphanumeric() || "+/=".contains(c)));
1118    }
1119
1120    #[tokio::test]
1121    async fn bounded_collection_keeps_head_and_tail() {
1122        let data: Vec<u8> = (0..10_000u32).map(|i| b'a' + (i % 26) as u8).collect();
1123        let c = collect_bounded(&data[..], 300).await.unwrap();
1124        assert!(c.truncated);
1125        assert!(c.data.starts_with(b"abcdefghij"));
1126        assert!(c.data.ends_with(&data[data.len() - 100..]));
1127        let c = collect_bounded(&b"tiny"[..], 300).await.unwrap();
1128        assert!(!c.truncated);
1129        assert_eq!(c.data, b"tiny");
1130    }
1131}