1use 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
40const TIMEOUT_EXIT: i32 = 124;
42const WATCHDOG_GRACE: u64 = 15;
44const CD_FAILED_EXIT: i32 = 97;
46const MAX_CHANNELS: usize = 4;
50const TRANSFER_CHUNK: usize = 1024 * 1024;
54const UPLOAD_WINDOW: usize = 16 * 1024 * 1024;
58const DOWNLOAD_STREAMS: u64 = 32;
61const DOWNLOAD_STEP: u64 = 1024 * 1024;
63const WIN_EXIT_MARKER: &str = "__FARHAND_EXIT__=";
66
67#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87enum LoginShell {
88 PowerShell,
89 Cmd,
90}
91
92#[derive(Debug, Clone)]
94pub struct Address {
95 pub hostname: String,
96 pub port: u16,
97 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 home: String,
119 workdir: String,
121 workdir_created: bool,
124}
125
126pub struct Remote {
127 cfg: RemoteConfig,
128 limits: Limits,
129 conn: Mutex<Option<Arc<Conn>>>,
130 channels: Semaphore,
131 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 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 pub fn workdir(&self) -> &str {
191 &self.cfg.workdir
192 }
193
194 pub async fn effective_workdir(&self) -> Result<String> {
196 Ok(self.conn().await?.workdir.clone())
197 }
198
199 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 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 pub async fn platform(&self) -> Result<Platform> {
221 Ok(self.conn().await?.platform)
222 }
223
224 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 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 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, Platform::Windows => {
309 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 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 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 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 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 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 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 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 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.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 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 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 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 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 pub async fn download_file(&self, path: &str, dest: &Path) -> Result<u64> {
618 let conn = self.conn().await?;
619 let path = self.resolve(path).await?;
620 let sftp_path = to_sftp_path(conn.platform, &path);
621 let _permit = self.channels.acquire().await.expect("semaphore open");
622 let meta = conn
623 .sftp
624 .fs()
625 .metadata(&sftp_path)
626 .await
627 .map_err(|e| not_found(&path, e))?;
628 if meta.file_type().map(|t| t.is_dir()).unwrap_or(false) {
629 return Err(Error::Invalid(format!("`{path}` is a directory")));
630 }
631 let len = meta.len().unwrap_or(0);
632 let part = {
633 let mut name = dest.file_name().unwrap_or_default().to_os_string();
634 name.push(".farhand-part");
635 dest.with_file_name(name)
636 };
637 {
638 let f = tokio::fs::File::create(&part).await?;
639 f.set_len(len).await?;
640 }
641 let next = AtomicU64::new(0);
642 let streams = DOWNLOAD_STREAMS.min(len.div_ceil(DOWNLOAD_STEP)).max(1);
643 let readers = (0..streams).map(|_| async {
644 let mut file = conn
645 .sftp
646 .open(&sftp_path)
647 .await
648 .map_err(|e| not_found(&path, e))?;
649 let mut out = tokio::fs::OpenOptions::new()
650 .write(true)
651 .open(&part)
652 .await?;
653 loop {
654 let offset = next.fetch_add(DOWNLOAD_STEP, Ordering::Relaxed);
655 if offset >= len {
656 break;
657 }
658 let want = DOWNLOAD_STEP.min(len - offset);
659 file.seek(std::io::SeekFrom::Start(offset)).await?;
660 let mut buf = BytesMut::with_capacity(want as usize);
661 while (buf.len() as u64) < want {
662 file.seek(std::io::SeekFrom::Start(offset + buf.len() as u64))
664 .await?;
665 let n = (want - buf.len() as u64).min(256 * 1024) as u32;
666 match file.read(n, buf.split_off(buf.len())).await? {
667 Some(bytes) => buf.unsplit(bytes),
668 None => {
669 return Err(Error::Ssh(format!(
670 "`{path}` shrank while it was being downloaded"
671 )))
672 }
673 }
674 }
675 out.seek(std::io::SeekFrom::Start(offset)).await?;
676 out.write_all(&buf).await?;
677 }
678 out.flush().await?;
679 let _ = file.close().await;
680 Ok::<(), Error>(())
681 });
682 let done = futures_util::future::try_join_all(readers).await;
683 if let Err(e) = done {
684 let _ = tokio::fs::remove_file(&part).await;
685 return Err(e);
686 }
687 tokio::fs::rename(&part, dest).await?;
688 Ok(len)
689 }
690
691 pub async fn ensure_dir(&self, dir: &str) -> Result<()> {
694 let conn = self.conn().await?;
695 self.ensure_dir_on(&conn.sftp, conn.platform, dir)
696 .await
697 .map(|_| ())
698 }
699
700 async fn ensure_dir_on(&self, sftp: &Sftp, platform: Platform, dir: &str) -> Result<bool> {
703 if self.known_dirs.lock().await.contains(dir) {
704 return Ok(false);
705 }
706 let mut missing: Vec<String> = Vec::new();
708 let mut cur = dir.to_string();
709 loop {
710 if self.known_dirs.lock().await.contains(&cur) {
711 break;
712 }
713 let _permit = self.channels.acquire().await.expect("semaphore open");
714 match sftp.fs().metadata(to_sftp_path(platform, &cur)).await {
715 Ok(m) => {
716 if !m.file_type().map(|t| t.is_dir()).unwrap_or(false) {
717 return Err(Error::Invalid(format!(
718 "`{cur}` exists on the remote and is not a directory"
719 )));
720 }
721 self.known_dirs.lock().await.insert(cur.clone());
722 break;
723 }
724 Err(_) => {
725 missing.push(cur.clone());
726 match parent_of(platform, &cur) {
727 Some(p) => cur = p,
728 None => break,
729 }
730 }
731 }
732 }
733 let mut created = false;
734 for d in missing.into_iter().rev() {
735 let _permit = self.channels.acquire().await.expect("semaphore open");
736 if let Err(e) = sftp.fs().create_dir(to_sftp_path(platform, &d)).await {
737 let exists = sftp
739 .fs()
740 .metadata(to_sftp_path(platform, &d))
741 .await
742 .map(|m| m.file_type().map(|t| t.is_dir()).unwrap_or(false))
743 .unwrap_or(false);
744 if !exists {
745 return Err(Error::Ssh(format!("cannot create directory `{d}`: {e}")));
746 }
747 }
748 self.known_dirs.lock().await.insert(d);
749 created = true;
750 }
751 Ok(created)
752 }
753
754 pub async fn exists(&self, path: &str) -> Result<Option<bool>> {
755 let conn = self.conn().await?;
756 let path = self.resolve(path).await?;
757 let _permit = self.channels.acquire().await.expect("semaphore open");
758 match conn
759 .sftp
760 .fs()
761 .metadata(to_sftp_path(conn.platform, &path))
762 .await
763 {
764 Ok(m) => Ok(Some(m.file_type().map(|t| t.is_dir()).unwrap_or(false))),
765 Err(_) => Ok(None),
766 }
767 }
768
769 pub async fn walk_files(&self, dir: &str, limit: usize) -> Result<Vec<String>> {
773 let conn = self.conn().await?;
774 let sep = separator(conn.platform);
775 let mut files = Vec::new();
776 let mut stack = vec![dir.to_string()];
777 while let Some(d) = stack.pop() {
778 let _permit = self.channels.acquire().await.expect("semaphore open");
779 let handle = conn
780 .sftp
781 .fs()
782 .open_dir(to_sftp_path(conn.platform, &d))
783 .await
784 .map_err(|e| Error::Ssh(format!("cannot list `{d}`: {e}")))?;
785 let mut entries = std::pin::pin!(handle.read_dir());
786 while let Some(entry) = entries.next().await {
787 let entry = entry.map_err(|e| Error::Ssh(format!("cannot list `{d}`: {e}")))?;
788 let name = entry.filename().to_string_lossy().into_owned();
789 if name == "." || name == ".." {
790 continue;
791 }
792 let full = format!("{d}{sep}{name}");
793 match entry.file_type() {
794 Some(t) if t.is_dir() => stack.push(full),
795 Some(t) if t.is_file() => {
796 files.push(full);
797 if files.len() > limit {
798 return Ok(files);
799 }
800 }
801 _ => {}
802 }
803 }
804 }
805 files.sort();
806 Ok(files)
807 }
808
809 pub async fn has_tool(&self, name: &str) -> Result<bool> {
811 let conn = self.conn().await?;
812 let cmd = match conn.platform {
813 Platform::Posix => format!("command -v {} >/dev/null 2>&1", shell_quote(name)),
814 Platform::Windows => format!(
815 "if (Get-Command {} -ErrorAction SilentlyContinue) {{ 'yes' }} else {{ 'no' }}",
816 ps_quote(name)
817 ),
818 };
819 let out = self.exec(&cmd, None, Some(15)).await?;
820 Ok(match conn.platform {
821 Platform::Posix => out.exit_code == 0,
822 Platform::Windows => String::from_utf8_lossy(&out.stdout).contains("yes"),
823 })
824 }
825}
826
827fn not_found(path: &str, e: openssh_sftp_client::Error) -> Error {
828 Error::Ssh(format!("`{path}`: {e}"))
829}
830
831pub fn ps_quote(s: &str) -> String {
835 format!("'{}'", s.replace('\'', "''"))
836}
837
838fn encode_utf16_b64(script: &str) -> String {
840 let bytes: Vec<u8> = script
841 .encode_utf16()
842 .flat_map(|u| u.to_le_bytes())
843 .collect();
844 base64::engine::general_purpose::STANDARD.encode(bytes)
845}
846
847fn windows_wrapper(exe: &str, extra: &[String], cwd: &str, command: &str, timeout: u64) -> String {
875 let inner = format!(
876 "$ProgressPreference='SilentlyContinue'; $ErrorActionPreference='Continue'\n\
877 [Console]::OutputEncoding=[Text.Encoding]::UTF8; [Console]::InputEncoding=[Text.Encoding]::UTF8; \
878 $OutputEncoding=[Text.Encoding]::UTF8\n\
879 $env:NO_COLOR='1'; $env:PYTHONUTF8='1'; $env:PYTHONIOENCODING='utf-8'\n\
880 try {{ Set-Location -LiteralPath {cwd} -ErrorAction Stop }} \
881 catch {{ [Console]::Error.WriteLine('cannot cd'); exit {CD_FAILED_EXIT} }}\n\
882 $global:LASTEXITCODE=0\n\
883 {command}\n\
884 $__fh_ok=$?\n\
885 Write-Output \"{WIN_EXIT_MARKER}$(if ($__fh_ok) {{0}} elseif ($LASTEXITCODE) {{$LASTEXITCODE}} \
886 elseif ($Error.Count -and $Error[0].FullyQualifiedErrorId -like 'NativeCommandError*') {{0}} else {{1}})\"\n",
887 cwd = ps_quote(cwd),
888 );
889 let mut args: Vec<String> = extra.iter().map(|a| ps_quote(a)).collect();
890 args.extend(
891 [
892 "-NoProfile",
893 "-NonInteractive",
894 "-ExecutionPolicy",
895 "Bypass",
896 "-EncodedCommand",
897 ]
898 .iter()
899 .map(|a| ps_quote(a)),
900 );
901 args.push(ps_quote(&encode_utf16_b64(&inner)));
902 format!(
903 "$ProgressPreference='SilentlyContinue'; $ErrorActionPreference='Continue'\n\
904 $p=Start-Process -FilePath {exe} -ArgumentList {args} -NoNewWindow -PassThru\n\
905 $null=$p.Handle\n\
906 if (-not $p.WaitForExit({ms})) {{ taskkill /T /F /PID $p.Id 2>&1 | Out-Null; \
907 $p.WaitForExit(); exit {TIMEOUT_EXIT} }}\n\
908 exit $p.ExitCode\n",
909 exe = ps_quote(exe),
910 args = args.join(","),
911 ms = timeout.saturating_mul(1000),
912 )
913}
914
915fn take_windows_exit_marker(stdout: &mut Vec<u8>) -> Option<i32> {
918 let text = String::from_utf8_lossy(stdout);
919 let pos = text.rfind(WIN_EXIT_MARKER)?;
920 let rest = &text[pos + WIN_EXIT_MARKER.len()..];
921 let line = rest.lines().next().unwrap_or("").trim();
922 let code: i32 = line.parse().ok()?;
923 let keep = text[..pos].trim_end_matches(['\r', '\n']).to_string();
925 stdout.clear();
926 stdout.extend_from_slice(keep.as_bytes());
927 if !stdout.is_empty() {
928 stdout.push(b'\n');
929 }
930 Some(code)
931}
932
933fn crlf_to_lf(data: &[u8]) -> Vec<u8> {
934 let mut out = Vec::with_capacity(data.len());
935 let mut i = 0;
936 while i < data.len() {
937 if data[i] == b'\r' && data.get(i + 1) == Some(&b'\n') {
938 i += 1;
939 continue;
940 }
941 out.push(data[i]);
942 i += 1;
943 }
944 out
945}
946
947fn decode_clixml(data: &[u8]) -> Vec<u8> {
951 let text = String::from_utf8_lossy(data);
952 if !text.contains("#< CLIXML") {
953 return data.to_vec();
954 }
955 let mut out = String::new();
956 let mut rest: &str = &text;
957 loop {
958 match rest.find("#< CLIXML") {
959 None => {
960 out.push_str(rest);
961 break;
962 }
963 Some(h) => {
964 out.push_str(&rest[..h]);
965 let after = &rest[h + "#< CLIXML".len()..];
966 let after = after
967 .strip_prefix("\r\n")
968 .or_else(|| after.strip_prefix('\n'))
969 .unwrap_or(after);
970 match after.find("</Objs>") {
971 Some(end) => {
972 out.push_str(&clixml_strings(&after[..end]));
973 rest = &after[end + "</Objs>".len()..];
974 }
975 None => {
976 out.push_str(&clixml_strings(after));
977 break;
978 }
979 }
980 }
981 }
982 }
983 out.into_bytes()
984}
985
986fn clixml_strings(xml: &str) -> String {
987 let mut out = String::new();
988 let mut rest = xml;
989 while let Some(start) = rest.find("<S S=\"") {
990 let after = &rest[start + 6..];
991 let Some(q) = after.find('"') else { break };
992 let kind = &after[..q];
993 let after = &after[q + 1..];
994 let Some(gt) = after.find('>') else { break };
995 let body_start = &after[gt + 1..];
996 let Some(end) = body_start.find("</S>") else {
997 break;
998 };
999 if kind == "Error" || kind == "Warning" {
1000 out.push_str(&unescape_clixml(&body_start[..end]));
1001 }
1002 rest = &body_start[end + 4..];
1003 }
1004 out
1005}
1006
1007fn unescape_clixml(s: &str) -> String {
1008 let s = s
1009 .replace("_x000D__x000A_", "\n")
1010 .replace("_x000A_", "\n")
1011 .replace("_x000D_", "")
1012 .replace("_x0009_", "\t");
1013 s.replace("<", "<")
1014 .replace(">", ">")
1015 .replace(""", "\"")
1016 .replace("'", "'")
1017 .replace("&", "&")
1018}
1019
1020fn separator(p: Platform) -> char {
1023 match p {
1024 Platform::Posix => '/',
1025 Platform::Windows => '\\',
1026 }
1027}
1028
1029fn is_sftp_drive_path(s: &str) -> bool {
1031 let b = s.as_bytes();
1032 b.len() >= 3 && b[0] == b'/' && b[1].is_ascii_alphabetic() && b[2] == b':'
1033}
1034
1035fn is_drive_path(s: &str) -> bool {
1036 let b = s.as_bytes();
1037 b.len() >= 2 && b[0].is_ascii_alphabetic() && b[1] == b':'
1038}
1039
1040fn is_absolute_for(p: Platform, raw: &str) -> bool {
1041 match p {
1042 Platform::Posix => raw.starts_with('/'),
1043 Platform::Windows => {
1044 is_drive_path(raw) || is_sftp_drive_path(raw) || raw.starts_with("\\\\")
1045 }
1046 }
1047}
1048
1049fn to_sftp_path(p: Platform, native: &str) -> String {
1052 match p {
1053 Platform::Posix => native.to_string(),
1054 Platform::Windows => format!("/{}", native.replace('\\', "/")),
1055 }
1056}
1057
1058fn from_sftp_path(sftp: &str) -> String {
1060 let s = sftp.strip_prefix('/').unwrap_or(sftp);
1061 let s = s.replace('/', "\\");
1062 if s.len() == 2 && is_drive_path(&s) {
1063 format!("{s}\\")
1064 } else {
1065 s
1066 }
1067}
1068
1069fn parent_of(p: Platform, path: &str) -> Option<String> {
1070 match p {
1071 Platform::Posix => {
1072 if path == "/" {
1073 return None;
1074 }
1075 match path.rfind('/') {
1076 Some(0) => Some("/".to_string()),
1077 Some(i) => Some(path[..i].to_string()),
1078 None => None,
1079 }
1080 }
1081 Platform::Windows => {
1082 if path.len() <= 3 {
1084 return None;
1085 }
1086 let i = path.rfind('\\')?;
1087 if i <= 2 {
1088 Some(path[..3].to_string())
1089 } else {
1090 Some(path[..i].to_string())
1091 }
1092 }
1093 }
1094}
1095
1096fn normalize_for(p: Platform, raw: &str) -> String {
1097 match p {
1098 Platform::Posix => normalize(raw),
1099 Platform::Windows => normalize_windows(raw),
1100 }
1101}
1102
1103fn expand_tilde(p: &str, home: &str) -> String {
1105 if p == "~" {
1106 home.to_string()
1107 } else if let Some(rest) = p.strip_prefix("~/").or_else(|| p.strip_prefix("~\\")) {
1108 format!("{home}/{rest}")
1109 } else {
1110 p.to_string()
1111 }
1112}
1113
1114pub fn normalize(p: &str) -> String {
1116 let mut parts: Vec<&str> = Vec::new();
1117 for seg in p.split('/') {
1118 match seg {
1119 "" | "." => {}
1120 ".." => {
1121 parts.pop();
1122 }
1123 s => parts.push(s),
1124 }
1125 }
1126 format!("/{}", parts.join("/"))
1127}
1128
1129pub fn normalize_windows(p: &str) -> String {
1132 let unc = p.starts_with("\\\\") || p.starts_with("//");
1133 let s = p.replace('/', "\\");
1134 let s = s
1135 .strip_prefix('\\')
1136 .filter(|r| is_drive_path(r))
1137 .unwrap_or(&s);
1138 let mut parts: Vec<&str> = Vec::new();
1139 let mut drive = String::new();
1140 for (i, seg) in s.split('\\').enumerate() {
1141 if i == 0 && is_drive_path(seg) && seg.len() == 2 {
1142 drive = seg.to_uppercase();
1143 continue;
1144 }
1145 match seg {
1146 "" | "." => {}
1147 ".." => {
1148 parts.pop();
1149 }
1150 x => parts.push(x),
1151 }
1152 }
1153 if unc {
1154 return format!("\\\\{}", parts.join("\\"));
1155 }
1156 if drive.is_empty() {
1157 return format!("\\{}", parts.join("\\"));
1160 }
1161 if parts.is_empty() {
1162 format!("{drive}\\")
1163 } else {
1164 format!("{drive}\\{}", parts.join("\\"))
1165 }
1166}
1167
1168struct Collected {
1169 data: Vec<u8>,
1170 truncated: bool,
1171}
1172
1173async fn collect_bounded<R: AsyncReadExt + Unpin>(mut r: R, cap: usize) -> Result<Collected> {
1176 let head_cap = cap * 2 / 3;
1177 let tail_cap = cap - head_cap;
1178 let mut head = Vec::new();
1179 let mut tail = Vec::new();
1180 let mut truncated = false;
1181 let mut buf = vec![0u8; 64 * 1024];
1182 loop {
1183 let n = r.read(&mut buf).await?;
1184 if n == 0 {
1185 break;
1186 }
1187 let mut chunk = &buf[..n];
1188 if head.len() < head_cap {
1189 let take = chunk.len().min(head_cap - head.len());
1190 head.extend_from_slice(&chunk[..take]);
1191 chunk = &chunk[take..];
1192 }
1193 if !chunk.is_empty() {
1194 truncated = true;
1195 tail.extend_from_slice(chunk);
1196 if tail.len() > tail_cap * 2 {
1197 let drop = tail.len() - tail_cap;
1198 tail.drain(..drop);
1199 }
1200 }
1201 }
1202 if truncated {
1203 if tail.len() > tail_cap {
1204 let drop = tail.len() - tail_cap;
1205 tail.drain(..drop);
1206 }
1207 let omitted_note = format!("\n\n[... output truncated; limit is {cap} bytes ...]\n\n");
1208 head.extend_from_slice(omitted_note.as_bytes());
1209 head.extend_from_slice(&tail);
1210 }
1211 Ok(Collected {
1212 data: head,
1213 truncated,
1214 })
1215}
1216
1217#[cfg(test)]
1218mod tests {
1219 use super::*;
1220
1221 #[test]
1222 fn tilde_expansion() {
1223 assert_eq!(expand_tilde("~", "/root"), "/root");
1224 assert_eq!(expand_tilde("~/a/b", "/root"), "/root/a/b");
1225 assert_eq!(expand_tilde("/x", "/root"), "/x");
1226 assert_eq!(expand_tilde("rel", "/root"), "rel");
1227 assert_eq!(
1228 normalize_windows(&expand_tilde("~\\x", "C:\\Users\\me")),
1229 "C:\\Users\\me\\x"
1230 );
1231 }
1232
1233 #[test]
1234 fn normalizes_paths() {
1235 assert_eq!(normalize("/a/b/../c/./d"), "/a/c/d");
1236 assert_eq!(normalize("/../x"), "/x");
1237 assert_eq!(normalize("/"), "/");
1238 }
1239
1240 #[test]
1241 fn windows_paths() {
1242 assert_eq!(normalize_windows("C:/Users/me/../x/./y"), "C:\\Users\\x\\y");
1243 assert_eq!(normalize_windows("/C:/Users/me"), "C:\\Users\\me");
1244 assert_eq!(normalize_windows("c:\\"), "C:\\");
1245 assert_eq!(
1246 normalize_windows("C:\\work/proj\\src"),
1247 "C:\\work\\proj\\src"
1248 );
1249 assert_eq!(
1250 normalize_windows("\\\\server\\share\\x"),
1251 "\\\\server\\share\\x"
1252 );
1253 assert_eq!(to_sftp_path(Platform::Windows, "C:\\a\\b"), "/C:/a/b");
1254 assert_eq!(from_sftp_path("/C:/Users/test"), "C:\\Users\\test");
1255 assert_eq!(from_sftp_path("/C:"), "C:\\");
1256 assert!(is_absolute_for(Platform::Windows, "D:\\x"));
1257 assert!(is_absolute_for(Platform::Windows, "/C:/x"));
1258 assert!(!is_absolute_for(Platform::Windows, "src\\main.rs"));
1259 assert!(!is_absolute_for(Platform::Windows, "/tmp"));
1260 assert_eq!(
1261 parent_of(Platform::Windows, "C:\\a\\b").as_deref(),
1262 Some("C:\\a")
1263 );
1264 assert_eq!(
1265 parent_of(Platform::Windows, "C:\\a").as_deref(),
1266 Some("C:\\")
1267 );
1268 assert_eq!(parent_of(Platform::Windows, "C:\\"), None);
1269 assert_eq!(parent_of(Platform::Posix, "/a/b").as_deref(), Some("/a"));
1270 assert_eq!(parent_of(Platform::Posix, "/a").as_deref(), Some("/"));
1271 assert_eq!(parent_of(Platform::Posix, "/"), None);
1272 }
1273
1274 #[test]
1275 fn exit_marker_and_clixml() {
1276 let mut out = b"table\r\n__FARHAND_EXIT__=3\r\n\r\n".to_vec();
1277 assert_eq!(take_windows_exit_marker(&mut out), Some(3));
1278 assert_eq!(out, b"table\n");
1279 let mut out = b"x\n__FARHAND_EXIT__=1\n".to_vec();
1280 assert_eq!(take_windows_exit_marker(&mut out), Some(1));
1281 let mut out = b"__FARHAND_EXIT__=0\n".to_vec();
1282 assert_eq!(take_windows_exit_marker(&mut out), Some(0));
1283 assert!(out.is_empty());
1284 let mut out = b"no marker".to_vec();
1285 assert_eq!(take_windows_exit_marker(&mut out), None);
1286
1287 let err = b"raw first\r\n#< CLIXML\r\n<Objs Version=\"1.1.0.1\"><Obj S=\"progress\"><TN></TN></Obj>\
1288 <S S=\"Error\">Get-Item : Cannot find path 'C:\\nope'_x000D__x000A_</S>\
1289 <S S=\"Error\">At line:6_x000D__x000A_</S></Objs>";
1290 let text = String::from_utf8(decode_clixml(err)).unwrap();
1291 assert_eq!(
1292 text,
1293 "raw first\r\nGet-Item : Cannot find path 'C:\\nope'\nAt line:6\n"
1294 );
1295 assert_eq!(crlf_to_lf(b"a\r\nb\r\n"), b"a\nb\n");
1296 assert_eq!(ps_quote("it's"), "'it''s'");
1297 }
1298
1299 #[test]
1300 fn wrapper_is_plain_ascii_on_the_command_line() {
1301 let w = windows_wrapper("powershell.exe", &[], "C:\\Users\\me", "Get-ChildItem", 30);
1302 assert!(w.contains("WaitForExit(30000)"));
1303 assert!(w.contains("-EncodedCommand"));
1304 assert!(encode_utf16_b64("x")
1305 .chars()
1306 .all(|c| c.is_ascii_alphanumeric() || "+/=".contains(c)));
1307 }
1308
1309 #[tokio::test]
1310 async fn bounded_collection_keeps_head_and_tail() {
1311 let data: Vec<u8> = (0..10_000u32).map(|i| b'a' + (i % 26) as u8).collect();
1312 let c = collect_bounded(&data[..], 300).await.unwrap();
1313 assert!(c.truncated);
1314 assert!(c.data.starts_with(b"abcdefghij"));
1315 assert!(c.data.ends_with(&data[data.len() - 100..]));
1316 let c = collect_bounded(&b"tiny"[..], 300).await.unwrap();
1317 assert!(!c.truncated);
1318 assert_eq!(c.data, b"tiny");
1319 }
1320}