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