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::num::NonZeroUsize;
22use std::path::Path;
23use std::sync::atomic::{AtomicU64, Ordering};
24use std::sync::Arc;
25use std::time::{Duration, Instant};
26
27use base64::Engine;
28use bytes::BytesMut;
29use futures_util::StreamExt;
30use openssh::{KnownHosts, Session, SessionBuilder, Stdio};
31use openssh_sftp_client::file::TokioCompatFile;
32use openssh_sftp_client::{Sftp, SftpOptions};
33use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt};
34use tokio::sync::{Mutex, Semaphore};
35
36use crate::config::{default_shell, Limits, Os, RemoteConfig};
37use crate::error::{Error, Result};
38use crate::guard::StreamGuard;
39use crate::text::shell_quote;
40
41/// Remote `timeout(1)` exit status; the Windows wrapper uses the same.
42const TIMEOUT_EXIT: i32 = 124;
43/// Extra seconds the local watchdog waits past the remote timeout.
44const WATCHDOG_GRACE: u64 = 15;
45/// Exit status the wrapper uses when `cd` into the working directory fails.
46const CD_FAILED_EXIT: i32 = 97;
47/// Concurrent channels on the one session. sshd's default `MaxSessions` is
48/// 10 and the SFTP channel holds one permanently; agents that fan out tool
49/// calls otherwise hit "failed to connect to the ssh multiplex server".
50const MAX_CHANNELS: usize = 4;
51/// One local read or remote write per step of a file transfer. The wire
52/// packets are smaller (the server caps a request near 256 KiB) and the
53/// client splits a step into as many as it needs.
54const TRANSFER_CHUNK: usize = 1024 * 1024;
55/// Bytes an upload may have in flight before it waits for acknowledgements:
56/// about 64 requests, which keeps a high-latency link busy the way scp does.
57/// A plain sequential write would pay one round trip per 256 KiB.
58const UPLOAD_WINDOW: usize = 16 * 1024 * 1024;
59/// Concurrent readers on one download. Each has one request outstanding, so
60/// this is the download's window in requests.
61const DOWNLOAD_STREAMS: u64 = 32;
62/// Bytes one download reader claims at a time.
63const DOWNLOAD_STEP: u64 = 1024 * 1024;
64/// Last line the Windows wrapper prints so the exit status of a script
65/// that ended normally survives PowerShell's own 0/1 convention.
66const WIN_EXIT_MARKER: &str = "__FARHAND_EXIT__=";
67
68/// What the remote runs, detected from the SFTP root on connect.
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum Platform {
71    Posix,
72    Windows,
73}
74
75impl Platform {
76    pub fn name(self) -> &'static str {
77        match self {
78            Platform::Posix => "posix",
79            Platform::Windows => "windows",
80        }
81    }
82}
83
84/// On Windows, what sshd hands our command line to (its `DefaultShell`).
85/// The exit status of a nested `powershell` is only propagated by `cmd`;
86/// under PowerShell we have to ask for it.
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88enum LoginShell {
89    PowerShell,
90    Cmd,
91}
92
93/// The remote as reachable from the local machine.
94#[derive(Debug, Clone)]
95pub struct Address {
96    pub hostname: String,
97    pub port: u16,
98    /// The destination as written in the config (an alias or `user@host`).
99    pub alias: String,
100}
101
102#[derive(Debug, Clone)]
103pub struct ExecOutput {
104    pub exit_code: i32,
105    pub stdout: Vec<u8>,
106    pub stderr: Vec<u8>,
107    pub stdout_truncated: bool,
108    pub stderr_truncated: bool,
109    pub timed_out: bool,
110    pub duration: Duration,
111}
112
113struct Conn {
114    session: Arc<Session>,
115    sftp: Sftp,
116    platform: Platform,
117    login_shell: LoginShell,
118    /// Native form: `/home/me` or `C:\Users\me`.
119    home: String,
120    /// The configured workdir with `~` expanded to `home`, native form.
121    workdir: String,
122    /// The workdir did not exist when this connection was made and was
123    /// created then.
124    workdir_created: bool,
125}
126
127pub struct Remote {
128    cfg: RemoteConfig,
129    limits: Limits,
130    conn: Mutex<Option<Arc<Conn>>>,
131    channels: Semaphore,
132    /// Directories known to exist on the remote (native form), so a tree
133    /// upload does not re-check every parent for every file.
134    known_dirs: Mutex<HashSet<String>>,
135}
136
137impl Remote {
138    pub fn new(cfg: RemoteConfig, limits: Limits) -> Self {
139        Self {
140            cfg,
141            limits,
142            conn: Mutex::new(None),
143            channels: Semaphore::new(MAX_CHANNELS),
144            known_dirs: Mutex::new(HashSet::new()),
145        }
146    }
147
148    pub fn host(&self) -> &str {
149        &self.cfg.host
150    }
151
152    /// How the remote is addressed from this machine, as OpenSSH resolves
153    /// the destination: `(hostname, port)`. The model needs this to report
154    /// URLs the user can open; an alias from `~/.ssh/config` means nothing
155    /// to a browser. Falls back to the configured destination.
156    pub fn address(&self) -> Address {
157        let fallback = Address {
158            hostname: self
159                .cfg
160                .host
161                .rsplit('@')
162                .next()
163                .unwrap_or(&self.cfg.host)
164                .to_string(),
165            port: 22,
166            alias: self.cfg.host.clone(),
167        };
168        let out = match std::process::Command::new("ssh")
169            .arg("-G")
170            .arg(&self.cfg.host)
171            .stdin(std::process::Stdio::null())
172            .stderr(std::process::Stdio::null())
173            .output()
174        {
175            Ok(o) if o.status.success() => o.stdout,
176            _ => return fallback,
177        };
178        let text = String::from_utf8_lossy(&out);
179        let mut addr = fallback;
180        for line in text.lines() {
181            match line.split_once(' ') {
182                Some(("hostname", v)) => addr.hostname = v.trim().to_string(),
183                Some(("port", v)) => addr.port = v.trim().parse().unwrap_or(22),
184                _ => {}
185            }
186        }
187        addr
188    }
189
190    /// The workdir as configured (`~` not yet expanded).
191    pub fn workdir(&self) -> &str {
192        &self.cfg.workdir
193    }
194
195    /// The workdir with `~` expanded; connects if needed.
196    pub async fn effective_workdir(&self) -> Result<String> {
197        Ok(self.conn().await?.workdir.clone())
198    }
199
200    /// Whether the workdir was missing and got created when the current
201    /// connection was made; connects if needed.
202    pub async fn workdir_created(&self) -> Result<bool> {
203        Ok(self.conn().await?.workdir_created)
204    }
205
206    pub fn limits(&self) -> &Limits {
207        &self.limits
208    }
209
210    /// The platform as configured, before any connection: `None` for
211    /// `auto`.
212    pub fn configured_platform(&self) -> Option<Platform> {
213        match self.cfg.os {
214            Os::Auto => None,
215            Os::Posix => Some(Platform::Posix),
216            Os::Windows => Some(Platform::Windows),
217        }
218    }
219
220    /// The detected platform; connects if needed.
221    pub async fn platform(&self) -> Result<Platform> {
222        Ok(self.conn().await?.platform)
223    }
224
225    /// Connect now, or report why that is impossible.
226    pub async fn connect(&self) -> Result<()> {
227        self.conn().await.map(|_| ())
228    }
229
230    pub async fn is_connected(&self) -> bool {
231        match &*self.conn.lock().await {
232            Some(c) => c.session.check().await.is_ok(),
233            None => false,
234        }
235    }
236
237    async fn conn(&self) -> Result<Arc<Conn>> {
238        let mut slot = self.conn.lock().await;
239        if let Some(c) = &*slot {
240            if c.session.check().await.is_ok() {
241                return Ok(c.clone());
242            }
243            tracing::warn!("ssh session to {} dropped; reconnecting", self.cfg.host);
244            *slot = None;
245            self.known_dirs.lock().await.clear();
246        }
247        // Strict host-key checking: FarHand only talks to hosts the user has
248        // already connected to by hand. A `.farhand.toml` that arrives with a
249        // cloned repository therefore cannot point an agent at a machine the
250        // user never chose.
251        //
252        // The user's ssh config is read through a wrapper that turns off
253        // every channel back to this machine first: a `RemoteForward`,
254        // `ForwardAgent` or `ForwardX11` meant for interactive use would
255        // otherwise hand the remote a local service, the local agent's keys
256        // or the local display. ssh keeps the first value it reads, so the
257        // user's own settings cannot turn them back on.
258        let wrapper = SshConfigWrapper::create(&[USER_SSH_CONFIG, SYSTEM_SSH_CONFIG])?;
259        let mut builder = SessionBuilder::default();
260        builder
261            .connect_timeout(Duration::from_secs(self.cfg.connect_timeout_secs))
262            .server_alive_interval(Duration::from_secs(self.cfg.server_alive_interval_secs))
263            .known_hosts_check(KnownHosts::Strict)
264            .config_file(&wrapper.path);
265        let session = builder.connect_mux(&self.cfg.host).await.map_err(|e| {
266            Error::Ssh(format!(
267                "cannot connect to `{}`: {e}. Check that `ssh {}` works from a terminal; \
268                 FarHand refuses hosts whose key is not yet in known_hosts, so connect once \
269                 by hand first.",
270                self.cfg.host, self.cfg.host
271            ))
272        })?;
273        // The master has read its config (and started any jump host) by
274        // the time `connect_mux` returns.
275        drop(wrapper);
276        let session = Arc::new(session);
277        let sftp_options = SftpOptions::new().tokio_compat_file_write_limit(
278            NonZeroUsize::new(UPLOAD_WINDOW).expect("non-zero window"),
279        );
280        let sftp = Sftp::from_clonable_session(session.clone(), sftp_options)
281            .await
282            .map_err(|e| {
283                Error::Ssh(format!(
284                    "sftp subsystem unavailable on `{}`: {e}",
285                    self.cfg.host
286                ))
287            })?;
288        // The SFTP session starts in the user's home. Its canonical form
289        // also tells the platform apart: Windows OpenSSH reports `/C:/...`.
290        let sftp_home = sftp
291            .fs()
292            .canonicalize(".")
293            .await
294            .map_err(|e| Error::Ssh(format!("cannot resolve the remote home: {e}")))?
295            .to_string_lossy()
296            .into_owned();
297        let detected = if is_sftp_drive_path(&sftp_home) {
298            Platform::Windows
299        } else {
300            Platform::Posix
301        };
302        let platform = match (self.cfg.os, detected) {
303            (Os::Auto, d) => d,
304            (Os::Posix, Platform::Posix) | (Os::Windows, Platform::Windows) => detected,
305            (configured, d) => {
306                tracing::warn!(
307                    "config says os = {:?} but the remote looks like {}; using {}",
308                    configured,
309                    d.name(),
310                    d.name()
311                );
312                d
313            }
314        };
315        let home = match platform {
316            Platform::Posix => sftp_home,
317            Platform::Windows => from_sftp_path(&sftp_home),
318        };
319        let login_shell = match platform {
320            Platform::Posix => LoginShell::PowerShell, // unused
321            Platform::Windows => {
322                // `%OS%` is expanded by cmd and left alone by PowerShell.
323                let out = session
324                    .raw_command("echo %OS%")
325                    .stdin(Stdio::null())
326                    .output()
327                    .await?;
328                if String::from_utf8_lossy(&out.stdout).contains("Windows_NT") {
329                    LoginShell::Cmd
330                } else {
331                    LoginShell::PowerShell
332                }
333            }
334        };
335        let workdir = normalize_for(platform, &expand_tilde(&self.cfg.workdir, &home));
336        // A missing workdir would make every command fail at `cd`. It is a
337        // path the user wrote into the config, so creating it is what they
338        // meant; anything already there that is not a directory is an error.
339        let workdir_created = self
340            .ensure_dir_on(&sftp, platform, &workdir)
341            .await
342            .map_err(|e| match e {
343                Error::Invalid(m) => Error::Invalid(format!("remote.workdir: {m}")),
344                e => Error::Ssh(format!("cannot create remote.workdir `{workdir}`: {e}")),
345            })?;
346        if workdir_created {
347            tracing::info!(host = %self.cfg.host, "created the working directory {workdir}");
348        }
349        let conn = Arc::new(Conn {
350            session,
351            sftp,
352            platform,
353            login_shell,
354            home,
355            workdir,
356            workdir_created,
357        });
358        *slot = Some(conn.clone());
359        tracing::info!(host = %self.cfg.host, platform = platform.name(), "connected");
360        Ok(conn)
361    }
362
363    /// Absolute remote path, in the remote's native form, for what the
364    /// model typed: `~` expands to the remote home, relative paths hang off
365    /// the working directory. `/` and `\` are both accepted on Windows.
366    pub async fn resolve(&self, raw: &str) -> Result<String> {
367        let raw = raw.trim();
368        let conn = self.conn().await?;
369        if raw.is_empty() {
370            return Ok(conn.workdir.clone());
371        }
372        if is_absolute_for(conn.platform, raw) {
373            return Ok(normalize_for(conn.platform, raw));
374        }
375        if raw == "~" || raw.starts_with("~/") || raw.starts_with("~\\") {
376            return Ok(normalize_for(conn.platform, &expand_tilde(raw, &conn.home)));
377        }
378        Ok(normalize_for(
379            conn.platform,
380            &format!("{}/{raw}", conn.workdir),
381        ))
382    }
383
384    /// Run `command` in `cwd` (default: the working directory) through the
385    /// configured shell — PowerShell on Windows. `timeout_secs` is clamped
386    /// to the configured maximum.
387    pub async fn exec(
388        &self,
389        command: &str,
390        cwd: Option<&str>,
391        timeout_secs: Option<u64>,
392    ) -> Result<ExecOutput> {
393        let conn = self.conn().await?;
394        let cwd = match cwd {
395            Some(c) => self.resolve(c).await?,
396            None => conn.workdir.clone(),
397        };
398        let timeout = timeout_secs
399            .unwrap_or(self.limits.command_timeout_secs)
400            .clamp(1, self.limits.max_command_timeout_secs);
401
402        let _permit = self.channels.acquire().await.expect("semaphore open");
403        let started = Instant::now();
404        let mut cmd = match conn.platform {
405            Platform::Posix => {
406                // The script the user's shell runs. Colour is off because
407                // the model reads the output; nothing else about the remote
408                // environment is touched, and nothing local is forwarded.
409                let script = format!(
410                    "cd {} || exit {CD_FAILED_EXIT}\nexport NO_COLOR=1\n{command}\n",
411                    shell_quote(&cwd)
412                );
413                let b64 = base64::engine::general_purpose::STANDARD.encode(script.as_bytes());
414                let shell = self
415                    .cfg
416                    .shell
417                    .iter()
418                    .map(|s| format!("\"{}\"", s.replace('"', "\\\"")))
419                    .collect::<Vec<_>>()
420                    .join(" ");
421                // Outer `sh -c` so the remote login shell only has to parse
422                // one single-quoted word; the inner script has no single
423                // quotes because base64 has none and the argv is
424                // double-quoted.
425                let inner =
426                    format!("timeout -k 5 {timeout} {shell} \"$(printf %s {b64} | base64 -d)\"");
427                let mut c = conn.session.raw_command("sh");
428                c.raw_arg("-c").raw_arg(format!("'{inner}'"));
429                c
430            }
431            Platform::Windows => {
432                let (exe, extra) = self.powershell();
433                let outer = windows_wrapper(exe, &extra, &cwd, command, timeout);
434                let mut c = conn.session.raw_command(exe);
435                for a in &extra {
436                    c.raw_arg(a);
437                }
438                c.raw_arg("-NoProfile")
439                    .raw_arg("-NonInteractive")
440                    .raw_arg("-ExecutionPolicy")
441                    .raw_arg("Bypass")
442                    .raw_arg("-EncodedCommand")
443                    .raw_arg(encode_utf16_b64(&outer));
444                if conn.login_shell == LoginShell::PowerShell {
445                    // `powershell -c "<cmd>"` would otherwise turn any
446                    // non-zero status into 1.
447                    c.raw_arg("; exit $LASTEXITCODE");
448                }
449                c
450            }
451        };
452        cmd.stdin(Stdio::null())
453            .stdout(Stdio::piped())
454            .stderr(Stdio::piped());
455        let mut child = cmd.spawn().await?;
456        let stdout = child.stdout().take().expect("piped stdout");
457        let stderr = child.stderr().take().expect("piped stderr");
458        let cap = self.limits.max_output_bytes;
459
460        let collect = async {
461            let (out, err) =
462                tokio::join!(collect_bounded(stdout, cap), collect_bounded(stderr, cap));
463            let status = child.wait().await?;
464            Ok::<_, Error>((out?, err?, status))
465        };
466        let watchdog = Duration::from_secs(timeout + WATCHDOG_GRACE);
467        let (mut out, mut err, status) = match tokio::time::timeout(watchdog, collect).await {
468            Ok(r) => r?,
469            Err(_) => {
470                tracing::warn!("local watchdog fired after {}s", watchdog.as_secs());
471                return Err(Error::Timeout { secs: timeout });
472            }
473        };
474        let mut exit_code = status.code().unwrap_or(-1);
475        if conn.platform == Platform::Windows {
476            if let Some(code) = take_windows_exit_marker(&mut out.data) {
477                exit_code = code;
478            }
479            out.data = crlf_to_lf(&out.data);
480            err.data = crlf_to_lf(&decode_clixml(&err.data));
481        }
482        let timed_out = exit_code == TIMEOUT_EXIT;
483        if exit_code == CD_FAILED_EXIT && out.data.is_empty() {
484            return Err(Error::Invalid(format!(
485                "cannot cd into `{cwd}` on the remote"
486            )));
487        }
488        Ok(ExecOutput {
489            exit_code,
490            stdout: out.data,
491            stderr: err.data,
492            stdout_truncated: out.truncated,
493            stderr_truncated: err.truncated,
494            timed_out,
495            duration: started.elapsed(),
496        })
497    }
498
499    /// The remote user's home, native form; connects if needed.
500    pub async fn home(&self) -> Result<String> {
501        Ok(self.conn().await?.home.clone())
502    }
503
504    /// The configured POSIX shell argv (`["bash", "-lc"]` by default).
505    pub(crate) fn shell(&self) -> &[String] {
506        &self.cfg.shell
507    }
508
509    /// The PowerShell executable and any extra leading arguments, from
510    /// `remote.shell` unless that is still the POSIX default.
511    pub(crate) fn powershell(&self) -> (&str, Vec<String>) {
512        if self.cfg.shell == default_shell() || self.cfg.shell.is_empty() {
513            ("powershell.exe", Vec::new())
514        } else {
515            (&self.cfg.shell[0], self.cfg.shell[1..].to_vec())
516        }
517    }
518
519    /// Read at most `max` bytes of a remote file. Returns the bytes and the
520    /// file's full length.
521    pub async fn read_file(&self, path: &str, max: usize) -> Result<(Vec<u8>, u64)> {
522        let conn = self.conn().await?;
523        let path = self.resolve(path).await?;
524        let sftp_path = to_sftp_path(conn.platform, &path);
525        let _permit = self.channels.acquire().await.expect("semaphore open");
526        let mut fs = conn.sftp.fs();
527        let meta = fs
528            .metadata(&sftp_path)
529            .await
530            .map_err(|e| not_found(&path, e))?;
531        if meta.file_type().map(|t| t.is_dir()).unwrap_or(false) {
532            return Err(Error::Invalid(format!("`{path}` is a directory")));
533        }
534        let len = meta.len().unwrap_or(0);
535        let mut file = conn
536            .sftp
537            .open(&sftp_path)
538            .await
539            .map_err(|e| not_found(&path, e))?;
540        let want = (len as usize).min(max);
541        let mut buf = BytesMut::with_capacity(want);
542        while buf.len() < want {
543            // `File::read` advances the offset by the amount asked for, not
544            // the amount returned, and servers may return less (Windows
545            // OpenSSH does): seek to what actually arrived before each read.
546            file.seek(std::io::SeekFrom::Start(buf.len() as u64))
547                .await?;
548            let chunk = (want - buf.len()).min(256 * 1024) as u32;
549            match file.read(chunk, buf.split_off(buf.len())).await? {
550                Some(bytes) => buf.unsplit(bytes),
551                None => break,
552            }
553        }
554        let _ = file.close().await;
555        Ok((buf.to_vec(), len))
556    }
557
558    /// Write a whole file, creating parent directories.
559    pub async fn write_file(&self, path: &str, content: &[u8]) -> Result<String> {
560        let conn = self.conn().await?;
561        let path = self.resolve(path).await?;
562        if let Some(parent) = parent_of(conn.platform, &path) {
563            self.ensure_dir(&parent).await?;
564        }
565        let _permit = self.channels.acquire().await.expect("semaphore open");
566        conn.sftp
567            .fs()
568            .write(to_sftp_path(conn.platform, &path), content)
569            .await
570            .map_err(|e| Error::Ssh(format!("cannot write `{path}`: {e}")))?;
571        Ok(path)
572    }
573
574    /// Copy the open local file `src` to `dest` (resolved here, parents
575    /// created), streaming it in `TRANSFER_CHUNK` pieces with up to
576    /// `UPLOAD_WINDOW` bytes unacknowledged. Every piece passes `scan`
577    /// before it is written, so what arrives is exactly what the guard saw,
578    /// and no more than `max` bytes are sent. Returns the native remote
579    /// path and the byte count. A failed upload removes what it left behind.
580    pub async fn upload_file(
581        &self,
582        src: std::fs::File,
583        dest: &str,
584        mut scan: StreamGuard<'_>,
585        max: u64,
586    ) -> Result<(String, u64)> {
587        let conn = self.conn().await?;
588        let path = self.resolve(dest).await?;
589        if let Some(parent) = parent_of(conn.platform, &path) {
590            self.ensure_dir(&parent).await?;
591        }
592        let sftp_path = to_sftp_path(conn.platform, &path);
593        let _permit = self.channels.acquire().await.expect("semaphore open");
594        let mut src = tokio::fs::File::from_std(src);
595        let file = conn
596            .sftp
597            .create(&sftp_path)
598            .await
599            .map_err(|e| Error::Ssh(format!("cannot create `{path}`: {e}")))?;
600        let mut buf = vec![0u8; TRANSFER_CHUNK];
601        let mut sent: u64 = 0;
602        let copied: Result<()> = async {
603            // The pipelined writer is not `Unpin`. Once `flush` has returned
604            // every write is acknowledged; dropping it at the end of this
605            // block closes the handle in the background.
606            let mut file = std::pin::pin!(TokioCompatFile::new(file));
607            let mut read: u64 = 0;
608            loop {
609                let n = src.read(&mut buf).await?;
610                if n == 0 {
611                    break;
612                }
613                read += n as u64;
614                if read > max {
615                    return Err(Error::Invalid(format!(
616                        "`{path}`: the local file grew past the {max} bytes the transfer \
617                         limit leaves while it was being uploaded"
618                    )));
619                }
620                let out = scan.feed(&buf[..n])?;
621                file.write_all(&out).await?;
622                sent += out.len() as u64;
623            }
624            let out = scan.finish();
625            file.write_all(&out).await?;
626            sent += out.len() as u64;
627            file.flush().await?;
628            Ok(())
629        }
630        .await;
631        if let Err(e) = copied {
632            let _ = conn.sftp.fs().remove_file(&sftp_path).await;
633            return Err(match e {
634                Error::Denied(_) | Error::Invalid(_) => e,
635                e => Error::Ssh(format!("upload of `{path}` failed: {e}")),
636            });
637        }
638        Ok((path, sent))
639    }
640
641    /// Size of the regular file at `path` (resolved here).
642    pub async fn file_size(&self, path: &str) -> Result<u64> {
643        let conn = self.conn().await?;
644        let path = self.resolve(path).await?;
645        let _permit = self.channels.acquire().await.expect("semaphore open");
646        let meta = conn
647            .sftp
648            .fs()
649            .metadata(to_sftp_path(conn.platform, &path))
650            .await
651            .map_err(|e| not_found(&path, e))?;
652        if meta.file_type().map(|t| t.is_dir()).unwrap_or(false) {
653            return Err(Error::Invalid(format!("`{path}` is a directory")));
654        }
655        Ok(meta.len().unwrap_or(0))
656    }
657
658    /// Copy the remote file at `path` (resolved here) into `out`, using
659    /// `DOWNLOAD_STREAMS` readers that each claim `DOWNLOAD_STEP` ranges in
660    /// turn and write through the one handle. The size is taken once, here,
661    /// and nothing past it is read: a file over `max` bytes by now is
662    /// refused before anything is allocated. Returns the byte count.
663    pub async fn download_into(
664        &self,
665        path: &str,
666        out: &Arc<std::fs::File>,
667        max: u64,
668    ) -> Result<u64> {
669        let conn = self.conn().await?;
670        let path = self.resolve(path).await?;
671        let sftp_path = to_sftp_path(conn.platform, &path);
672        let _permit = self.channels.acquire().await.expect("semaphore open");
673        let meta = conn
674            .sftp
675            .fs()
676            .metadata(&sftp_path)
677            .await
678            .map_err(|e| not_found(&path, e))?;
679        if meta.file_type().map(|t| t.is_dir()).unwrap_or(false) {
680            return Err(Error::Invalid(format!("`{path}` is a directory")));
681        }
682        let len = meta.len().unwrap_or(0);
683        if len > max {
684            return Err(Error::Invalid(format!(
685                "`{path}` is now {len} bytes, more than the {max} bytes the transfer limit \
686                 leaves; it changed while being downloaded"
687            )));
688        }
689        out.set_len(len)?;
690        let next = AtomicU64::new(0);
691        let streams = DOWNLOAD_STREAMS.min(len.div_ceil(DOWNLOAD_STEP)).max(1);
692        let readers = (0..streams).map(|_| async {
693            let mut file = conn
694                .sftp
695                .open(&sftp_path)
696                .await
697                .map_err(|e| not_found(&path, e))?;
698            loop {
699                let offset = next.fetch_add(DOWNLOAD_STEP, Ordering::Relaxed);
700                if offset >= len {
701                    break;
702                }
703                let want = DOWNLOAD_STEP.min(len - offset);
704                let mut buf = BytesMut::with_capacity(want as usize);
705                while (buf.len() as u64) < want {
706                    // Short reads: see `read_file`.
707                    file.seek(std::io::SeekFrom::Start(offset + buf.len() as u64))
708                        .await?;
709                    let n = (want - buf.len() as u64).min(256 * 1024) as u32;
710                    match file.read(n, buf.split_off(buf.len())).await? {
711                        Some(bytes) => buf.unsplit(bytes),
712                        None => {
713                            return Err(Error::Ssh(format!(
714                                "`{path}` shrank while it was being downloaded"
715                            )))
716                        }
717                    }
718                }
719                let out = out.clone();
720                tokio::task::spawn_blocking(move || write_all_at(&out, &buf, offset))
721                    .await
722                    .map_err(|e| Error::Ssh(format!("download writer failed: {e}")))??;
723            }
724            let _ = file.close().await;
725            Ok::<(), Error>(())
726        });
727        futures_util::future::try_join_all(readers).await?;
728        Ok(len)
729    }
730
731    /// Create `dir` and any missing parents over SFTP. `dir` is a resolved
732    /// native path.
733    pub async fn ensure_dir(&self, dir: &str) -> Result<()> {
734        let conn = self.conn().await?;
735        self.ensure_dir_on(&conn.sftp, conn.platform, dir)
736            .await
737            .map(|_| ())
738    }
739
740    /// `ensure_dir` on a given SFTP session, usable while the connection is
741    /// still being set up. Returns whether anything was created.
742    async fn ensure_dir_on(&self, sftp: &Sftp, platform: Platform, dir: &str) -> Result<bool> {
743        if self.known_dirs.lock().await.contains(dir) {
744            return Ok(false);
745        }
746        // Walk up to the nearest existing ancestor, then create downwards.
747        let mut missing: Vec<String> = Vec::new();
748        let mut cur = dir.to_string();
749        loop {
750            if self.known_dirs.lock().await.contains(&cur) {
751                break;
752            }
753            let _permit = self.channels.acquire().await.expect("semaphore open");
754            match sftp.fs().metadata(to_sftp_path(platform, &cur)).await {
755                Ok(m) => {
756                    if !m.file_type().map(|t| t.is_dir()).unwrap_or(false) {
757                        return Err(Error::Invalid(format!(
758                            "`{cur}` exists on the remote and is not a directory"
759                        )));
760                    }
761                    self.known_dirs.lock().await.insert(cur.clone());
762                    break;
763                }
764                Err(_) => {
765                    missing.push(cur.clone());
766                    match parent_of(platform, &cur) {
767                        Some(p) => cur = p,
768                        None => break,
769                    }
770                }
771            }
772        }
773        let mut created = false;
774        for d in missing.into_iter().rev() {
775            let _permit = self.channels.acquire().await.expect("semaphore open");
776            if let Err(e) = sftp.fs().create_dir(to_sftp_path(platform, &d)).await {
777                // Another call may have created it meanwhile.
778                let exists = sftp
779                    .fs()
780                    .metadata(to_sftp_path(platform, &d))
781                    .await
782                    .map(|m| m.file_type().map(|t| t.is_dir()).unwrap_or(false))
783                    .unwrap_or(false);
784                if !exists {
785                    return Err(Error::Ssh(format!("cannot create directory `{d}`: {e}")));
786                }
787            }
788            self.known_dirs.lock().await.insert(d);
789            created = true;
790        }
791        Ok(created)
792    }
793
794    pub async fn exists(&self, path: &str) -> Result<Option<bool>> {
795        let conn = self.conn().await?;
796        let path = self.resolve(path).await?;
797        let _permit = self.channels.acquire().await.expect("semaphore open");
798        match conn
799            .sftp
800            .fs()
801            .metadata(to_sftp_path(conn.platform, &path))
802            .await
803        {
804            Ok(m) => Ok(Some(m.file_type().map(|t| t.is_dir()).unwrap_or(false))),
805            Err(_) => Ok(None),
806        }
807    }
808
809    /// Every regular file under `dir` (a resolved native path), as native
810    /// paths, over SFTP. Symlinks are not followed. Stops after `limit`
811    /// entries so a huge tree is refused quickly rather than listed.
812    pub async fn walk_files(&self, dir: &str, limit: usize) -> Result<Vec<String>> {
813        let conn = self.conn().await?;
814        let sep = separator(conn.platform);
815        let mut files = Vec::new();
816        let mut stack = vec![dir.to_string()];
817        while let Some(d) = stack.pop() {
818            let _permit = self.channels.acquire().await.expect("semaphore open");
819            let handle = conn
820                .sftp
821                .fs()
822                .open_dir(to_sftp_path(conn.platform, &d))
823                .await
824                .map_err(|e| Error::Ssh(format!("cannot list `{d}`: {e}")))?;
825            let mut entries = std::pin::pin!(handle.read_dir());
826            while let Some(entry) = entries.next().await {
827                let entry = entry.map_err(|e| Error::Ssh(format!("cannot list `{d}`: {e}")))?;
828                let name = entry.filename().to_string_lossy().into_owned();
829                if name == "." || name == ".." {
830                    continue;
831                }
832                let full = format!("{d}{sep}{name}");
833                match entry.file_type() {
834                    Some(t) if t.is_dir() => stack.push(full),
835                    Some(t) if t.is_file() => {
836                        files.push(full);
837                        if files.len() > limit {
838                            return Ok(files);
839                        }
840                    }
841                    _ => {}
842                }
843            }
844        }
845        files.sort();
846        Ok(files)
847    }
848
849    /// The names of the regular files directly in `dir` (a resolved native
850    /// path), over SFTP; none if `dir` does not exist.
851    pub async fn list_files(&self, dir: &str) -> Result<Vec<String>> {
852        let conn = self.conn().await?;
853        let _permit = self.channels.acquire().await.expect("semaphore open");
854        let Ok(handle) = conn
855            .sftp
856            .fs()
857            .open_dir(to_sftp_path(conn.platform, dir))
858            .await
859        else {
860            return Ok(Vec::new());
861        };
862        let mut names = Vec::new();
863        let mut entries = std::pin::pin!(handle.read_dir());
864        while let Some(entry) = entries.next().await {
865            let entry = entry.map_err(|e| Error::Ssh(format!("cannot list `{dir}`: {e}")))?;
866            if entry.file_type().is_some_and(|t| t.is_file()) {
867                names.push(entry.filename().to_string_lossy().into_owned());
868            }
869        }
870        names.sort();
871        Ok(names)
872    }
873
874    /// Whether a remote executable is on PATH.
875    pub async fn has_tool(&self, name: &str) -> Result<bool> {
876        let conn = self.conn().await?;
877        let cmd = match conn.platform {
878            Platform::Posix => format!("command -v {} >/dev/null 2>&1", shell_quote(name)),
879            Platform::Windows => format!(
880                "if (Get-Command {} -ErrorAction SilentlyContinue) {{ 'yes' }} else {{ 'no' }}",
881                ps_quote(name)
882            ),
883        };
884        let out = self.exec(&cmd, None, Some(15)).await?;
885        Ok(match conn.platform {
886            Platform::Posix => out.exit_code == 0,
887            Platform::Windows => String::from_utf8_lossy(&out.stdout).contains("yes"),
888        })
889    }
890
891    /// Mount points of read-write `virtiofs` file systems on a POSIX
892    /// remote. That is how a microVM such as a Docker sandbox passes a host
893    /// folder straight through: whatever is written under one lands on the
894    /// host's disk at once, past every check `download` makes. Empty on
895    /// Windows and where `/proc/mounts` does not exist.
896    pub async fn writable_host_shares(&self) -> Result<Vec<String>> {
897        if self.platform().await? != Platform::Posix {
898            return Ok(Vec::new());
899        }
900        let out = self
901            .exec("cat /proc/mounts 2>/dev/null", None, Some(15))
902            .await?;
903        Ok(writable_virtiofs_mounts(&String::from_utf8_lossy(
904            &out.stdout,
905        )))
906    }
907}
908
909/// The mount points of the read-write `virtiofs` entries in `/proc/mounts`.
910fn writable_virtiofs_mounts(mounts: &str) -> Vec<String> {
911    mounts
912        .lines()
913        .filter_map(|line| {
914            let f: Vec<&str> = line.split_whitespace().collect();
915            (f.len() >= 4 && f[2] == "virtiofs" && f[3].split(',').any(|o| o == "rw"))
916                .then(|| unescape_mount_field(f[1]))
917        })
918        .collect()
919}
920
921/// `/proc/mounts` writes space, tab, newline and backslash in a path as
922/// `\ooo` octal escapes.
923fn unescape_mount_field(s: &str) -> String {
924    let b = s.as_bytes();
925    let mut out = Vec::with_capacity(b.len());
926    let mut i = 0;
927    while i < b.len() {
928        let octal = b
929            .get(i + 1..i + 4)
930            .filter(|d| d.iter().all(|c| (b'0'..=b'7').contains(c)));
931        match (b[i], octal) {
932            (b'\\', Some(d)) => {
933                out.push(d.iter().fold(0u8, |n, c| n.wrapping_mul(8) + (c - b'0')));
934                i += 4;
935            }
936            (c, _) => {
937                out.push(c);
938                i += 1;
939            }
940        }
941    }
942    String::from_utf8_lossy(&out).into_owned()
943}
944
945fn not_found(path: &str, e: openssh_sftp_client::Error) -> Error {
946    Error::Ssh(format!("`{path}`: {e}"))
947}
948
949// ---- Windows command wrapper ------------------------------------------
950
951/// Quote for PowerShell as a single-quoted literal.
952pub fn ps_quote(s: &str) -> String {
953    format!("'{}'", s.replace('\'', "''"))
954}
955
956/// UTF-16LE base64, what `powershell -EncodedCommand` expects.
957pub(crate) fn encode_utf16_b64(script: &str) -> String {
958    let bytes: Vec<u8> = script
959        .encode_utf16()
960        .flat_map(|u| u.to_le_bytes())
961        .collect();
962    base64::engine::general_purpose::STANDARD.encode(bytes)
963}
964
965/// The PowerShell script that runs the model's command on Windows.
966///
967/// Two processes: the outer one enforces the timeout (`Start-Process`,
968/// `WaitForExit`, `taskkill /T`), the inner one runs the command with the
969/// working directory set, UTF-8 output and colour off. Output flows through
970/// inherited handles, so nothing is buffered in files.
971///
972/// UTF-8 is set on the console (both code pages) and, because a program
973/// whose stdout is a pipe consults its locale rather than the console,
974/// also for Python through `PYTHONUTF8`/`PYTHONIOENCODING`; the model reads
975/// what such programs print, and on a `cp1252` machine that is otherwise
976/// a `UnicodeEncodeError` for any non-Latin text.
977///
978/// The inner script must end naturally: `exit` from a `-EncodedCommand`
979/// script discards output still in PowerShell's formatting pipeline. So the
980/// last thing it prints is [`WIN_EXIT_MARKER`] with the status, which
981/// [`take_windows_exit_marker`] reads back. The status follows shell
982/// convention: that of the last statement (`$?`), with `$LASTEXITCODE` for
983/// a failed native command. `$Error` is otherwise not consulted: it also
984/// collects every stderr line a native program wrote and every error that
985/// `-ErrorAction SilentlyContinue` hid. The one exception is Windows
986/// PowerShell 5.1 under `2>&1`: it turns a native program's stderr into
987/// `NativeCommandError` records and clears `$?`, so `git ... 2>&1` that
988/// exits 0 would read as a failure; when the newest error is that record
989/// and `$LASTEXITCODE` is 0, the status is 0. A command that calls `exit`
990/// itself skips the marker and its status arrives as the process status
991/// instead.
992fn windows_wrapper(exe: &str, extra: &[String], cwd: &str, command: &str, timeout: u64) -> String {
993    let inner = format!(
994        "$ProgressPreference='SilentlyContinue'; $ErrorActionPreference='Continue'\n\
995         [Console]::OutputEncoding=[Text.Encoding]::UTF8; [Console]::InputEncoding=[Text.Encoding]::UTF8; \
996         $OutputEncoding=[Text.Encoding]::UTF8\n\
997         $env:NO_COLOR='1'; $env:PYTHONUTF8='1'; $env:PYTHONIOENCODING='utf-8'\n\
998         try {{ Set-Location -LiteralPath {cwd} -ErrorAction Stop }} \
999         catch {{ [Console]::Error.WriteLine('cannot cd'); exit {CD_FAILED_EXIT} }}\n\
1000         $global:LASTEXITCODE=0\n\
1001         {command}\n\
1002         $__fh_ok=$?\n\
1003         Write-Output \"{WIN_EXIT_MARKER}$(if ($__fh_ok) {{0}} elseif ($LASTEXITCODE) {{$LASTEXITCODE}} \
1004         elseif ($Error.Count -and $Error[0].FullyQualifiedErrorId -like 'NativeCommandError*') {{0}} else {{1}})\"\n",
1005        cwd = ps_quote(cwd),
1006    );
1007    let mut args: Vec<String> = extra.iter().map(|a| ps_quote(a)).collect();
1008    args.extend(
1009        [
1010            "-NoProfile",
1011            "-NonInteractive",
1012            "-ExecutionPolicy",
1013            "Bypass",
1014            "-EncodedCommand",
1015        ]
1016        .iter()
1017        .map(|a| ps_quote(a)),
1018    );
1019    args.push(ps_quote(&encode_utf16_b64(&inner)));
1020    format!(
1021        "$ProgressPreference='SilentlyContinue'; $ErrorActionPreference='Continue'\n\
1022         $p=Start-Process -FilePath {exe} -ArgumentList {args} -NoNewWindow -PassThru\n\
1023         $null=$p.Handle\n\
1024         if (-not $p.WaitForExit({ms})) {{ taskkill /T /F /PID $p.Id 2>&1 | Out-Null; \
1025         $p.WaitForExit(); exit {TIMEOUT_EXIT} }}\n\
1026         exit $p.ExitCode\n",
1027        exe = ps_quote(exe),
1028        args = args.join(","),
1029        ms = timeout.saturating_mul(1000),
1030    )
1031}
1032
1033/// Find the exit marker the wrapper printed, remove it (and anything
1034/// after it) from `stdout`, and return the status it encodes.
1035fn take_windows_exit_marker(stdout: &mut Vec<u8>) -> Option<i32> {
1036    let text = String::from_utf8_lossy(stdout);
1037    let pos = text.rfind(WIN_EXIT_MARKER)?;
1038    let rest = &text[pos + WIN_EXIT_MARKER.len()..];
1039    let line = rest.lines().next().unwrap_or("").trim();
1040    let code: i32 = line.parse().ok()?;
1041    // Keep only what came before the marker, minus trailing blank lines.
1042    let keep = text[..pos].trim_end_matches(['\r', '\n']).to_string();
1043    stdout.clear();
1044    stdout.extend_from_slice(keep.as_bytes());
1045    if !stdout.is_empty() {
1046        stdout.push(b'\n');
1047    }
1048    Some(code)
1049}
1050
1051fn crlf_to_lf(data: &[u8]) -> Vec<u8> {
1052    let mut out = Vec::with_capacity(data.len());
1053    let mut i = 0;
1054    while i < data.len() {
1055        if data[i] == b'\r' && data.get(i + 1) == Some(&b'\n') {
1056            i += 1;
1057            continue;
1058        }
1059        out.push(data[i]);
1060        i += 1;
1061    }
1062    out
1063}
1064
1065/// PowerShell serialises its error stream as CLIXML when stderr is not a
1066/// console. Turn `<S S="Error">…</S>` records back into text, drop
1067/// progress records, and leave anything that is not CLIXML alone.
1068fn decode_clixml(data: &[u8]) -> Vec<u8> {
1069    let text = String::from_utf8_lossy(data);
1070    if !text.contains("#< CLIXML") {
1071        return data.to_vec();
1072    }
1073    let mut out = String::new();
1074    let mut rest: &str = &text;
1075    loop {
1076        match rest.find("#< CLIXML") {
1077            None => {
1078                out.push_str(rest);
1079                break;
1080            }
1081            Some(h) => {
1082                out.push_str(&rest[..h]);
1083                let after = &rest[h + "#< CLIXML".len()..];
1084                let after = after
1085                    .strip_prefix("\r\n")
1086                    .or_else(|| after.strip_prefix('\n'))
1087                    .unwrap_or(after);
1088                match after.find("</Objs>") {
1089                    Some(end) => {
1090                        out.push_str(&clixml_strings(&after[..end]));
1091                        rest = &after[end + "</Objs>".len()..];
1092                    }
1093                    None => {
1094                        out.push_str(&clixml_strings(after));
1095                        break;
1096                    }
1097                }
1098            }
1099        }
1100    }
1101    out.into_bytes()
1102}
1103
1104fn clixml_strings(xml: &str) -> String {
1105    let mut out = String::new();
1106    let mut rest = xml;
1107    while let Some(start) = rest.find("<S S=\"") {
1108        let after = &rest[start + 6..];
1109        let Some(q) = after.find('"') else { break };
1110        let kind = &after[..q];
1111        let after = &after[q + 1..];
1112        let Some(gt) = after.find('>') else { break };
1113        let body_start = &after[gt + 1..];
1114        let Some(end) = body_start.find("</S>") else {
1115            break;
1116        };
1117        if kind == "Error" || kind == "Warning" {
1118            out.push_str(&unescape_clixml(&body_start[..end]));
1119        }
1120        rest = &body_start[end + 4..];
1121    }
1122    out
1123}
1124
1125fn unescape_clixml(s: &str) -> String {
1126    let s = s
1127        .replace("_x000D__x000A_", "\n")
1128        .replace("_x000A_", "\n")
1129        .replace("_x000D_", "")
1130        .replace("_x0009_", "\t");
1131    s.replace("&lt;", "<")
1132        .replace("&gt;", ">")
1133        .replace("&quot;", "\"")
1134        .replace("&apos;", "'")
1135        .replace("&amp;", "&")
1136}
1137
1138// ---- paths -----------------------------------------------------------------
1139
1140pub(crate) fn separator(p: Platform) -> char {
1141    match p {
1142        Platform::Posix => '/',
1143        Platform::Windows => '\\',
1144    }
1145}
1146
1147/// `/C:/Users/me` — how Windows OpenSSH's sftp-server spells paths.
1148fn is_sftp_drive_path(s: &str) -> bool {
1149    let b = s.as_bytes();
1150    b.len() >= 3 && b[0] == b'/' && b[1].is_ascii_alphabetic() && b[2] == b':'
1151}
1152
1153fn is_drive_path(s: &str) -> bool {
1154    let b = s.as_bytes();
1155    b.len() >= 2 && b[0].is_ascii_alphabetic() && b[1] == b':'
1156}
1157
1158fn is_absolute_for(p: Platform, raw: &str) -> bool {
1159    match p {
1160        Platform::Posix => raw.starts_with('/'),
1161        Platform::Windows => {
1162            is_drive_path(raw) || is_sftp_drive_path(raw) || raw.starts_with("\\\\")
1163        }
1164    }
1165}
1166
1167/// Native path for the SFTP layer: identity on POSIX, `C:\a\b` →
1168/// `/C:/a/b` on Windows.
1169fn to_sftp_path(p: Platform, native: &str) -> String {
1170    match p {
1171        Platform::Posix => native.to_string(),
1172        Platform::Windows => format!("/{}", native.replace('\\', "/")),
1173    }
1174}
1175
1176/// `/C:/a/b` → `C:\a\b`.
1177fn from_sftp_path(sftp: &str) -> String {
1178    let s = sftp.strip_prefix('/').unwrap_or(sftp);
1179    let s = s.replace('/', "\\");
1180    if s.len() == 2 && is_drive_path(&s) {
1181        format!("{s}\\")
1182    } else {
1183        s
1184    }
1185}
1186
1187fn parent_of(p: Platform, path: &str) -> Option<String> {
1188    match p {
1189        Platform::Posix => {
1190            if path == "/" {
1191                return None;
1192            }
1193            match path.rfind('/') {
1194                Some(0) => Some("/".to_string()),
1195                Some(i) => Some(path[..i].to_string()),
1196                None => None,
1197            }
1198        }
1199        Platform::Windows => {
1200            // `C:\` has no parent; `C:\a` → `C:\`.
1201            if path.len() <= 3 {
1202                return None;
1203            }
1204            let i = path.rfind('\\')?;
1205            if i <= 2 {
1206                Some(path[..3].to_string())
1207            } else {
1208                Some(path[..i].to_string())
1209            }
1210        }
1211    }
1212}
1213
1214fn normalize_for(p: Platform, raw: &str) -> String {
1215    match p {
1216        Platform::Posix => normalize(raw),
1217        Platform::Windows => normalize_windows(raw),
1218    }
1219}
1220
1221/// `~` and `~/x` become `home` and `home/x`; anything else is unchanged.
1222fn expand_tilde(p: &str, home: &str) -> String {
1223    if p == "~" {
1224        home.to_string()
1225    } else if let Some(rest) = p.strip_prefix("~/").or_else(|| p.strip_prefix("~\\")) {
1226        format!("{home}/{rest}")
1227    } else {
1228        p.to_string()
1229    }
1230}
1231
1232/// Collapse `.` and `..` segments without touching the filesystem.
1233pub fn normalize(p: &str) -> String {
1234    let mut parts: Vec<&str> = Vec::new();
1235    for seg in p.split('/') {
1236        match seg {
1237            "" | "." => {}
1238            ".." => {
1239                parts.pop();
1240            }
1241            s => parts.push(s),
1242        }
1243    }
1244    format!("/{}", parts.join("/"))
1245}
1246
1247/// Windows form with backslashes and a drive: accepts `C:/a/../b`,
1248/// `/C:/a/b` (SFTP form) and `C:\a\b`; UNC paths keep their `\\` prefix.
1249pub fn normalize_windows(p: &str) -> String {
1250    let unc = p.starts_with("\\\\") || p.starts_with("//");
1251    let s = p.replace('/', "\\");
1252    let s = s
1253        .strip_prefix('\\')
1254        .filter(|r| is_drive_path(r))
1255        .unwrap_or(&s);
1256    let mut parts: Vec<&str> = Vec::new();
1257    let mut drive = String::new();
1258    for (i, seg) in s.split('\\').enumerate() {
1259        if i == 0 && is_drive_path(seg) && seg.len() == 2 {
1260            drive = seg.to_uppercase();
1261            continue;
1262        }
1263        match seg {
1264            "" | "." => {}
1265            ".." => {
1266                parts.pop();
1267            }
1268            x => parts.push(x),
1269        }
1270    }
1271    if unc {
1272        return format!("\\\\{}", parts.join("\\"));
1273    }
1274    if drive.is_empty() {
1275        // Drive-less absolute path: leave the caller a clear failure
1276        // rather than guessing a drive.
1277        return format!("\\{}", parts.join("\\"));
1278    }
1279    if parts.is_empty() {
1280        format!("{drive}\\")
1281    } else {
1282        format!("{drive}\\{}", parts.join("\\"))
1283    }
1284}
1285
1286struct Collected {
1287    data: Vec<u8>,
1288    truncated: bool,
1289}
1290
1291/// Read a stream to its end, keeping at most the first two thirds and the
1292/// last third of `cap` bytes so a runaway command cannot exhaust memory.
1293async fn collect_bounded<R: AsyncReadExt + Unpin>(mut r: R, cap: usize) -> Result<Collected> {
1294    let head_cap = cap * 2 / 3;
1295    let tail_cap = cap - head_cap;
1296    let mut head = Vec::new();
1297    let mut tail = Vec::new();
1298    let mut truncated = false;
1299    let mut buf = vec![0u8; 64 * 1024];
1300    loop {
1301        let n = r.read(&mut buf).await?;
1302        if n == 0 {
1303            break;
1304        }
1305        let mut chunk = &buf[..n];
1306        if head.len() < head_cap {
1307            let take = chunk.len().min(head_cap - head.len());
1308            head.extend_from_slice(&chunk[..take]);
1309            chunk = &chunk[take..];
1310        }
1311        if !chunk.is_empty() {
1312            truncated = true;
1313            tail.extend_from_slice(chunk);
1314            if tail.len() > tail_cap * 2 {
1315                let drop = tail.len() - tail_cap;
1316                tail.drain(..drop);
1317            }
1318        }
1319    }
1320    if truncated {
1321        if tail.len() > tail_cap {
1322            let drop = tail.len() - tail_cap;
1323            tail.drain(..drop);
1324        }
1325        let omitted_note = format!("\n\n[... output truncated; limit is {cap} bytes ...]\n\n");
1326        head.extend_from_slice(omitted_note.as_bytes());
1327        head.extend_from_slice(&tail);
1328    }
1329    Ok(Collected {
1330        data: head,
1331        truncated,
1332    })
1333}
1334
1335/// Where ssh reads its configuration when not given `-F`, in its order.
1336const USER_SSH_CONFIG: &str = "~/.ssh/config";
1337const SYSTEM_SSH_CONFIG: &str = "/etc/ssh/ssh_config";
1338
1339/// What precedes the user's own ssh config: nothing on the connection may
1340/// lead back to this machine. Port forwardings are cleared whichever file
1341/// set them; the rest win by coming first.
1342const SSH_CONFIG_HEADER: &str = "\
1343# Written by FarHand for one connection and removed once it is up.
1344ClearAllForwardings yes
1345ForwardAgent no
1346ForwardX11 no
1347ForwardX11Trusted no
1348Tunnel no
1349GSSAPIDelegateCredentials no
1350";
1351
1352fn ssh_config_text(includes: &[&str]) -> String {
1353    let mut s = SSH_CONFIG_HEADER.to_string();
1354    for i in includes {
1355        s.push_str(&format!("Include {i}\n"));
1356    }
1357    s
1358}
1359
1360/// The `-F` file for one connection: created exclusively and readable by
1361/// the user only, removed when dropped.
1362struct SshConfigWrapper {
1363    path: std::path::PathBuf,
1364}
1365
1366impl SshConfigWrapper {
1367    fn create(includes: &[&str]) -> Result<Self> {
1368        use std::io::Write;
1369        let path = std::env::temp_dir().join(format!(
1370            "farhand-ssh-{}-{:016x}.conf",
1371            std::process::id(),
1372            random_u64()
1373        ));
1374        let mut f = create_exclusive(&path)?;
1375        f.write_all(ssh_config_text(includes).as_bytes())?;
1376        Ok(Self { path })
1377    }
1378}
1379
1380impl Drop for SshConfigWrapper {
1381    fn drop(&mut self) {
1382        let _ = std::fs::remove_file(&self.path);
1383    }
1384}
1385
1386/// A random value for temporary file names, from the standard library's
1387/// per-process random hasher keys.
1388pub(crate) fn random_u64() -> u64 {
1389    use std::hash::{BuildHasher, Hasher};
1390    let mut h = std::collections::hash_map::RandomState::new().build_hasher();
1391    h.write_u128(
1392        std::time::SystemTime::now()
1393            .duration_since(std::time::UNIX_EPOCH)
1394            .map(|d| d.as_nanos())
1395            .unwrap_or(0),
1396    );
1397    h.finish()
1398}
1399
1400/// Create `path` for writing, readable by the user only; fail if anything,
1401/// a symlink included, is already there.
1402fn create_exclusive(path: &Path) -> Result<std::fs::File> {
1403    let mut o = std::fs::OpenOptions::new();
1404    o.write(true).create_new(true);
1405    #[cfg(unix)]
1406    {
1407        use std::os::unix::fs::OpenOptionsExt;
1408        o.custom_flags(libc::O_NOFOLLOW).mode(0o600);
1409    }
1410    o.open(path).map_err(|e| {
1411        Error::Io(std::io::Error::new(
1412            e.kind(),
1413            format!("cannot create {}: {e}", path.display()),
1414        ))
1415    })
1416}
1417
1418#[cfg(unix)]
1419fn write_all_at(file: &std::fs::File, buf: &[u8], offset: u64) -> std::io::Result<()> {
1420    std::os::unix::fs::FileExt::write_all_at(file, buf, offset)
1421}
1422
1423#[cfg(windows)]
1424fn write_all_at(file: &std::fs::File, mut buf: &[u8], mut offset: u64) -> std::io::Result<()> {
1425    while !buf.is_empty() {
1426        let n = std::os::windows::fs::FileExt::seek_write(file, buf, offset)?;
1427        buf = &buf[n..];
1428        offset += n as u64;
1429    }
1430    Ok(())
1431}
1432
1433#[cfg(test)]
1434mod tests {
1435    use super::*;
1436
1437    #[test]
1438    fn only_read_write_virtiofs_mounts_are_host_shares() {
1439        let mounts = "overlay / overlay rw,relatime,lowerdir=/a:/b 0 0\n\
1440                      /dev/vdd /var/lib/docker ext4 rw,relatime 0 0\n\
1441                      bind-95f0 /etc/resolv.conf virtiofs ro,relatime 0 0\n\
1442                      bind-1a2b /run/sandbox/source virtiofs ro,relatime 0 0\n\
1443                      bind-3c4d /Users/me/My\\040Project virtiofs rw,relatime 0 0\n\
1444                      bind-5e6f /Users/me/odd\\134name virtiofs relatime,rw 0 0\n\
1445                      short line\n";
1446        assert_eq!(
1447            writable_virtiofs_mounts(mounts),
1448            ["/Users/me/My Project", "/Users/me/odd\\name"]
1449        );
1450        assert!(writable_virtiofs_mounts("").is_empty());
1451        assert_eq!(unescape_mount_field("a\\04"), "a\\04");
1452        assert_eq!(unescape_mount_field("a\\011b"), "a\tb");
1453    }
1454
1455    #[cfg(unix)]
1456    #[test]
1457    fn temp_files_never_follow_a_planted_link() {
1458        let d = tempfile::tempdir().unwrap();
1459        let outside = d.path().join("outside");
1460        let link = d.path().join("part");
1461        std::os::unix::fs::symlink(&outside, &link).unwrap();
1462        // Dangling: the target does not exist and must not be created.
1463        assert!(create_exclusive(&link).is_err());
1464        assert!(!outside.exists());
1465        std::fs::write(&outside, "keep").unwrap();
1466        assert!(create_exclusive(&link).is_err());
1467        assert_eq!(std::fs::read_to_string(&outside).unwrap(), "keep");
1468        let fresh = d.path().join("fresh");
1469        let f = create_exclusive(&fresh).unwrap();
1470        write_all_at(&f, b"xy", 3).unwrap();
1471        assert_eq!(std::fs::read(&fresh).unwrap(), b"\0\0\0xy");
1472        assert!(create_exclusive(&fresh).is_err());
1473    }
1474
1475    #[test]
1476    fn the_ssh_config_wrapper_closes_every_way_back() {
1477        let d = tempfile::tempdir().unwrap();
1478        let user = d.path().join("config");
1479        std::fs::write(
1480            &user,
1481            "Host fhtest\n  HostName 192.0.2.1\n  RemoteForward 9000 localhost:22\n  \
1482             LocalForward 9001 localhost:22\n  DynamicForward 9002\n  ForwardAgent yes\n  \
1483             ForwardX11 yes\n",
1484        )
1485        .unwrap();
1486        let w = SshConfigWrapper::create(&[user.to_str().unwrap()]).unwrap();
1487        let path = w.path.clone();
1488        // `ssh -G` only prints the resolved config; it connects to nothing.
1489        let out = std::process::Command::new("ssh")
1490            .arg("-G")
1491            .arg("-F")
1492            .arg(&path)
1493            .arg("fhtest")
1494            .stdin(std::process::Stdio::null())
1495            .output();
1496        drop(w);
1497        assert!(!path.exists());
1498        let Ok(out) = out else {
1499            return; // no ssh on this machine
1500        };
1501        let text = String::from_utf8_lossy(&out.stdout).to_lowercase();
1502        // The user's own settings still apply ...
1503        assert!(text.lines().any(|l| l == "hostname 192.0.2.1"), "{text}");
1504        // ... except those leading back to this machine.
1505        for line in [
1506            "clearallforwardings yes",
1507            "forwardagent no",
1508            "forwardx11 no",
1509        ] {
1510            assert!(text.lines().any(|l| l == line), "{line}: {text}");
1511        }
1512        for word in ["remoteforward", "localforward", "dynamicforward"] {
1513            assert!(!text.contains(word), "{word}: {text}");
1514        }
1515    }
1516
1517    #[test]
1518    fn tilde_expansion() {
1519        assert_eq!(expand_tilde("~", "/root"), "/root");
1520        assert_eq!(expand_tilde("~/a/b", "/root"), "/root/a/b");
1521        assert_eq!(expand_tilde("/x", "/root"), "/x");
1522        assert_eq!(expand_tilde("rel", "/root"), "rel");
1523        assert_eq!(
1524            normalize_windows(&expand_tilde("~\\x", "C:\\Users\\me")),
1525            "C:\\Users\\me\\x"
1526        );
1527    }
1528
1529    #[test]
1530    fn normalizes_paths() {
1531        assert_eq!(normalize("/a/b/../c/./d"), "/a/c/d");
1532        assert_eq!(normalize("/../x"), "/x");
1533        assert_eq!(normalize("/"), "/");
1534    }
1535
1536    #[test]
1537    fn windows_paths() {
1538        assert_eq!(normalize_windows("C:/Users/me/../x/./y"), "C:\\Users\\x\\y");
1539        assert_eq!(normalize_windows("/C:/Users/me"), "C:\\Users\\me");
1540        assert_eq!(normalize_windows("c:\\"), "C:\\");
1541        assert_eq!(
1542            normalize_windows("C:\\work/proj\\src"),
1543            "C:\\work\\proj\\src"
1544        );
1545        assert_eq!(
1546            normalize_windows("\\\\server\\share\\x"),
1547            "\\\\server\\share\\x"
1548        );
1549        assert_eq!(to_sftp_path(Platform::Windows, "C:\\a\\b"), "/C:/a/b");
1550        assert_eq!(from_sftp_path("/C:/Users/test"), "C:\\Users\\test");
1551        assert_eq!(from_sftp_path("/C:"), "C:\\");
1552        assert!(is_absolute_for(Platform::Windows, "D:\\x"));
1553        assert!(is_absolute_for(Platform::Windows, "/C:/x"));
1554        assert!(!is_absolute_for(Platform::Windows, "src\\main.rs"));
1555        assert!(!is_absolute_for(Platform::Windows, "/tmp"));
1556        assert_eq!(
1557            parent_of(Platform::Windows, "C:\\a\\b").as_deref(),
1558            Some("C:\\a")
1559        );
1560        assert_eq!(
1561            parent_of(Platform::Windows, "C:\\a").as_deref(),
1562            Some("C:\\")
1563        );
1564        assert_eq!(parent_of(Platform::Windows, "C:\\"), None);
1565        assert_eq!(parent_of(Platform::Posix, "/a/b").as_deref(), Some("/a"));
1566        assert_eq!(parent_of(Platform::Posix, "/a").as_deref(), Some("/"));
1567        assert_eq!(parent_of(Platform::Posix, "/"), None);
1568    }
1569
1570    #[test]
1571    fn exit_marker_and_clixml() {
1572        let mut out = b"table\r\n__FARHAND_EXIT__=3\r\n\r\n".to_vec();
1573        assert_eq!(take_windows_exit_marker(&mut out), Some(3));
1574        assert_eq!(out, b"table\n");
1575        let mut out = b"x\n__FARHAND_EXIT__=1\n".to_vec();
1576        assert_eq!(take_windows_exit_marker(&mut out), Some(1));
1577        let mut out = b"__FARHAND_EXIT__=0\n".to_vec();
1578        assert_eq!(take_windows_exit_marker(&mut out), Some(0));
1579        assert!(out.is_empty());
1580        let mut out = b"no marker".to_vec();
1581        assert_eq!(take_windows_exit_marker(&mut out), None);
1582
1583        let err = b"raw first\r\n#< CLIXML\r\n<Objs Version=\"1.1.0.1\"><Obj S=\"progress\"><TN></TN></Obj>\
1584                    <S S=\"Error\">Get-Item : Cannot find path &apos;C:\\nope&apos;_x000D__x000A_</S>\
1585                    <S S=\"Error\">At line:6_x000D__x000A_</S></Objs>";
1586        let text = String::from_utf8(decode_clixml(err)).unwrap();
1587        assert_eq!(
1588            text,
1589            "raw first\r\nGet-Item : Cannot find path 'C:\\nope'\nAt line:6\n"
1590        );
1591        assert_eq!(crlf_to_lf(b"a\r\nb\r\n"), b"a\nb\n");
1592        assert_eq!(ps_quote("it's"), "'it''s'");
1593    }
1594
1595    #[test]
1596    fn wrapper_is_plain_ascii_on_the_command_line() {
1597        let w = windows_wrapper("powershell.exe", &[], "C:\\Users\\me", "Get-ChildItem", 30);
1598        assert!(w.contains("WaitForExit(30000)"));
1599        assert!(w.contains("-EncodedCommand"));
1600        assert!(encode_utf16_b64("x")
1601            .chars()
1602            .all(|c| c.is_ascii_alphanumeric() || "+/=".contains(c)));
1603    }
1604
1605    #[tokio::test]
1606    async fn bounded_collection_keeps_head_and_tail() {
1607        let data: Vec<u8> = (0..10_000u32).map(|i| b'a' + (i % 26) as u8).collect();
1608        let c = collect_bounded(&data[..], 300).await.unwrap();
1609        assert!(c.truncated);
1610        assert!(c.data.starts_with(b"abcdefghij"));
1611        assert!(c.data.ends_with(&data[data.len() - 100..]));
1612        let c = collect_bounded(&b"tiny"[..], 300).await.unwrap();
1613        assert!(!c.truncated);
1614        assert_eq!(c.data, b"tiny");
1615    }
1616}