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 let mut remaining = want;
520 while remaining > 0 {
521 let chunk = remaining.min(256 * 1024) as u32;
522 match file.read(chunk, buf.split_off(buf.len())).await? {
523 Some(bytes) => {
524 remaining -= bytes.len();
525 buf.unsplit(bytes);
526 }
527 None => break,
528 }
529 }
530 let _ = file.close().await;
531 Ok((buf.to_vec(), len))
532 }
533
534 pub async fn write_file(&self, path: &str, content: &[u8]) -> Result<String> {
536 let conn = self.conn().await?;
537 let path = self.resolve(path).await?;
538 if let Some(parent) = parent_of(conn.platform, &path) {
539 self.ensure_dir(&parent).await?;
540 }
541 let _permit = self.channels.acquire().await.expect("semaphore open");
542 conn.sftp
543 .fs()
544 .write(to_sftp_path(conn.platform, &path), content)
545 .await
546 .map_err(|e| Error::Ssh(format!("cannot write `{path}`: {e}")))?;
547 Ok(path)
548 }
549
550 pub async fn upload_file(&self, local: &Path, dest: &str) -> Result<(String, u64)> {
555 let conn = self.conn().await?;
556 let path = self.resolve(dest).await?;
557 if let Some(parent) = parent_of(conn.platform, &path) {
558 self.ensure_dir(&parent).await?;
559 }
560 let sftp_path = to_sftp_path(conn.platform, &path);
561 let _permit = self.channels.acquire().await.expect("semaphore open");
562 let mut src = tokio::fs::File::open(local).await?;
563 let file = conn
564 .sftp
565 .create(&sftp_path)
566 .await
567 .map_err(|e| Error::Ssh(format!("cannot create `{path}`: {e}")))?;
568 let mut buf = vec![0u8; TRANSFER_CHUNK];
569 let mut sent: u64 = 0;
570 let copied: Result<()> = async {
571 let mut file = std::pin::pin!(TokioCompatFile::new(file));
575 loop {
576 let n = src.read(&mut buf).await?;
577 if n == 0 {
578 break;
579 }
580 file.write_all(&buf[..n]).await?;
581 sent += n as u64;
582 }
583 file.flush().await?;
584 Ok(())
585 }
586 .await;
587 if let Err(e) = copied {
588 let _ = conn.sftp.fs().remove_file(&sftp_path).await;
589 return Err(Error::Ssh(format!("upload of `{path}` failed: {e}")));
590 }
591 Ok((path, sent))
592 }
593
594 pub async fn file_size(&self, path: &str) -> Result<u64> {
596 let conn = self.conn().await?;
597 let path = self.resolve(path).await?;
598 let _permit = self.channels.acquire().await.expect("semaphore open");
599 let meta = conn
600 .sftp
601 .fs()
602 .metadata(to_sftp_path(conn.platform, &path))
603 .await
604 .map_err(|e| not_found(&path, e))?;
605 if meta.file_type().map(|t| t.is_dir()).unwrap_or(false) {
606 return Err(Error::Invalid(format!("`{path}` is a directory")));
607 }
608 Ok(meta.len().unwrap_or(0))
609 }
610
611 pub async fn download_file(&self, path: &str, dest: &Path) -> Result<u64> {
617 let conn = self.conn().await?;
618 let path = self.resolve(path).await?;
619 let sftp_path = to_sftp_path(conn.platform, &path);
620 let _permit = self.channels.acquire().await.expect("semaphore open");
621 let meta = conn
622 .sftp
623 .fs()
624 .metadata(&sftp_path)
625 .await
626 .map_err(|e| not_found(&path, e))?;
627 if meta.file_type().map(|t| t.is_dir()).unwrap_or(false) {
628 return Err(Error::Invalid(format!("`{path}` is a directory")));
629 }
630 let len = meta.len().unwrap_or(0);
631 let part = {
632 let mut name = dest.file_name().unwrap_or_default().to_os_string();
633 name.push(".farhand-part");
634 dest.with_file_name(name)
635 };
636 {
637 let f = tokio::fs::File::create(&part).await?;
638 f.set_len(len).await?;
639 }
640 let next = AtomicU64::new(0);
641 let streams = DOWNLOAD_STREAMS.min(len.div_ceil(DOWNLOAD_STEP)).max(1);
642 let readers = (0..streams).map(|_| async {
643 let mut file = conn
644 .sftp
645 .open(&sftp_path)
646 .await
647 .map_err(|e| not_found(&path, e))?;
648 let mut out = tokio::fs::OpenOptions::new()
649 .write(true)
650 .open(&part)
651 .await?;
652 loop {
653 let offset = next.fetch_add(DOWNLOAD_STEP, Ordering::Relaxed);
654 if offset >= len {
655 break;
656 }
657 let want = DOWNLOAD_STEP.min(len - offset);
658 file.seek(std::io::SeekFrom::Start(offset)).await?;
659 let mut buf = BytesMut::with_capacity(want as usize);
660 while (buf.len() as u64) < want {
661 let n = (want - buf.len() as u64).min(256 * 1024) as u32;
662 match file.read(n, buf.split_off(buf.len())).await? {
663 Some(bytes) => buf.unsplit(bytes),
664 None => {
665 return Err(Error::Ssh(format!(
666 "`{path}` shrank while it was being downloaded"
667 )))
668 }
669 }
670 }
671 out.seek(std::io::SeekFrom::Start(offset)).await?;
672 out.write_all(&buf).await?;
673 }
674 out.flush().await?;
675 let _ = file.close().await;
676 Ok::<(), Error>(())
677 });
678 let done = futures_util::future::try_join_all(readers).await;
679 if let Err(e) = done {
680 let _ = tokio::fs::remove_file(&part).await;
681 return Err(e);
682 }
683 tokio::fs::rename(&part, dest).await?;
684 Ok(len)
685 }
686
687 pub async fn ensure_dir(&self, dir: &str) -> Result<()> {
690 let conn = self.conn().await?;
691 self.ensure_dir_on(&conn.sftp, conn.platform, dir)
692 .await
693 .map(|_| ())
694 }
695
696 async fn ensure_dir_on(&self, sftp: &Sftp, platform: Platform, dir: &str) -> Result<bool> {
699 if self.known_dirs.lock().await.contains(dir) {
700 return Ok(false);
701 }
702 let mut missing: Vec<String> = Vec::new();
704 let mut cur = dir.to_string();
705 loop {
706 if self.known_dirs.lock().await.contains(&cur) {
707 break;
708 }
709 let _permit = self.channels.acquire().await.expect("semaphore open");
710 match sftp.fs().metadata(to_sftp_path(platform, &cur)).await {
711 Ok(m) => {
712 if !m.file_type().map(|t| t.is_dir()).unwrap_or(false) {
713 return Err(Error::Invalid(format!(
714 "`{cur}` exists on the remote and is not a directory"
715 )));
716 }
717 self.known_dirs.lock().await.insert(cur.clone());
718 break;
719 }
720 Err(_) => {
721 missing.push(cur.clone());
722 match parent_of(platform, &cur) {
723 Some(p) => cur = p,
724 None => break,
725 }
726 }
727 }
728 }
729 let mut created = false;
730 for d in missing.into_iter().rev() {
731 let _permit = self.channels.acquire().await.expect("semaphore open");
732 if let Err(e) = sftp.fs().create_dir(to_sftp_path(platform, &d)).await {
733 let exists = sftp
735 .fs()
736 .metadata(to_sftp_path(platform, &d))
737 .await
738 .map(|m| m.file_type().map(|t| t.is_dir()).unwrap_or(false))
739 .unwrap_or(false);
740 if !exists {
741 return Err(Error::Ssh(format!("cannot create directory `{d}`: {e}")));
742 }
743 }
744 self.known_dirs.lock().await.insert(d);
745 created = true;
746 }
747 Ok(created)
748 }
749
750 pub async fn exists(&self, path: &str) -> Result<Option<bool>> {
751 let conn = self.conn().await?;
752 let path = self.resolve(path).await?;
753 let _permit = self.channels.acquire().await.expect("semaphore open");
754 match conn
755 .sftp
756 .fs()
757 .metadata(to_sftp_path(conn.platform, &path))
758 .await
759 {
760 Ok(m) => Ok(Some(m.file_type().map(|t| t.is_dir()).unwrap_or(false))),
761 Err(_) => Ok(None),
762 }
763 }
764
765 pub async fn walk_files(&self, dir: &str, limit: usize) -> Result<Vec<String>> {
769 let conn = self.conn().await?;
770 let sep = separator(conn.platform);
771 let mut files = Vec::new();
772 let mut stack = vec![dir.to_string()];
773 while let Some(d) = stack.pop() {
774 let _permit = self.channels.acquire().await.expect("semaphore open");
775 let handle = conn
776 .sftp
777 .fs()
778 .open_dir(to_sftp_path(conn.platform, &d))
779 .await
780 .map_err(|e| Error::Ssh(format!("cannot list `{d}`: {e}")))?;
781 let mut entries = std::pin::pin!(handle.read_dir());
782 while let Some(entry) = entries.next().await {
783 let entry = entry.map_err(|e| Error::Ssh(format!("cannot list `{d}`: {e}")))?;
784 let name = entry.filename().to_string_lossy().into_owned();
785 if name == "." || name == ".." {
786 continue;
787 }
788 let full = format!("{d}{sep}{name}");
789 match entry.file_type() {
790 Some(t) if t.is_dir() => stack.push(full),
791 Some(t) if t.is_file() => {
792 files.push(full);
793 if files.len() > limit {
794 return Ok(files);
795 }
796 }
797 _ => {}
798 }
799 }
800 }
801 files.sort();
802 Ok(files)
803 }
804
805 pub async fn has_tool(&self, name: &str) -> Result<bool> {
807 let conn = self.conn().await?;
808 let cmd = match conn.platform {
809 Platform::Posix => format!("command -v {} >/dev/null 2>&1", shell_quote(name)),
810 Platform::Windows => format!(
811 "if (Get-Command {} -ErrorAction SilentlyContinue) {{ 'yes' }} else {{ 'no' }}",
812 ps_quote(name)
813 ),
814 };
815 let out = self.exec(&cmd, None, Some(15)).await?;
816 Ok(match conn.platform {
817 Platform::Posix => out.exit_code == 0,
818 Platform::Windows => String::from_utf8_lossy(&out.stdout).contains("yes"),
819 })
820 }
821}
822
823fn not_found(path: &str, e: openssh_sftp_client::Error) -> Error {
824 Error::Ssh(format!("`{path}`: {e}"))
825}
826
827pub fn ps_quote(s: &str) -> String {
831 format!("'{}'", s.replace('\'', "''"))
832}
833
834fn encode_utf16_b64(script: &str) -> String {
836 let bytes: Vec<u8> = script
837 .encode_utf16()
838 .flat_map(|u| u.to_le_bytes())
839 .collect();
840 base64::engine::general_purpose::STANDARD.encode(bytes)
841}
842
843fn windows_wrapper(exe: &str, extra: &[String], cwd: &str, command: &str, timeout: u64) -> String {
866 let inner = format!(
867 "$ProgressPreference='SilentlyContinue'; $ErrorActionPreference='Continue'\n\
868 [Console]::OutputEncoding=[Text.Encoding]::UTF8; [Console]::InputEncoding=[Text.Encoding]::UTF8; \
869 $OutputEncoding=[Text.Encoding]::UTF8\n\
870 $env:NO_COLOR='1'; $env:PYTHONUTF8='1'; $env:PYTHONIOENCODING='utf-8'\n\
871 try {{ Set-Location -LiteralPath {cwd} -ErrorAction Stop }} \
872 catch {{ [Console]::Error.WriteLine('cannot cd'); exit {CD_FAILED_EXIT} }}\n\
873 $global:LASTEXITCODE=0\n\
874 {command}\n\
875 $__fh_ok=$?\n\
876 Write-Output \"{WIN_EXIT_MARKER}$(if ($__fh_ok) {{0}} elseif ($LASTEXITCODE) {{$LASTEXITCODE}} else {{1}})\"\n",
877 cwd = ps_quote(cwd),
878 );
879 let mut args: Vec<String> = extra.iter().map(|a| ps_quote(a)).collect();
880 args.extend(
881 [
882 "-NoProfile",
883 "-NonInteractive",
884 "-ExecutionPolicy",
885 "Bypass",
886 "-EncodedCommand",
887 ]
888 .iter()
889 .map(|a| ps_quote(a)),
890 );
891 args.push(ps_quote(&encode_utf16_b64(&inner)));
892 format!(
893 "$ProgressPreference='SilentlyContinue'; $ErrorActionPreference='Continue'\n\
894 $p=Start-Process -FilePath {exe} -ArgumentList {args} -NoNewWindow -PassThru\n\
895 $null=$p.Handle\n\
896 if (-not $p.WaitForExit({ms})) {{ taskkill /T /F /PID $p.Id 2>&1 | Out-Null; \
897 $p.WaitForExit(); exit {TIMEOUT_EXIT} }}\n\
898 exit $p.ExitCode\n",
899 exe = ps_quote(exe),
900 args = args.join(","),
901 ms = timeout.saturating_mul(1000),
902 )
903}
904
905fn take_windows_exit_marker(stdout: &mut Vec<u8>) -> Option<i32> {
908 let text = String::from_utf8_lossy(stdout);
909 let pos = text.rfind(WIN_EXIT_MARKER)?;
910 let rest = &text[pos + WIN_EXIT_MARKER.len()..];
911 let line = rest.lines().next().unwrap_or("").trim();
912 let code: i32 = line.parse().ok()?;
913 let keep = text[..pos].trim_end_matches(['\r', '\n']).to_string();
915 stdout.clear();
916 stdout.extend_from_slice(keep.as_bytes());
917 if !stdout.is_empty() {
918 stdout.push(b'\n');
919 }
920 Some(code)
921}
922
923fn crlf_to_lf(data: &[u8]) -> Vec<u8> {
924 let mut out = Vec::with_capacity(data.len());
925 let mut i = 0;
926 while i < data.len() {
927 if data[i] == b'\r' && data.get(i + 1) == Some(&b'\n') {
928 i += 1;
929 continue;
930 }
931 out.push(data[i]);
932 i += 1;
933 }
934 out
935}
936
937fn decode_clixml(data: &[u8]) -> Vec<u8> {
941 let text = String::from_utf8_lossy(data);
942 if !text.contains("#< CLIXML") {
943 return data.to_vec();
944 }
945 let mut out = String::new();
946 let mut rest: &str = &text;
947 loop {
948 match rest.find("#< CLIXML") {
949 None => {
950 out.push_str(rest);
951 break;
952 }
953 Some(h) => {
954 out.push_str(&rest[..h]);
955 let after = &rest[h + "#< CLIXML".len()..];
956 let after = after
957 .strip_prefix("\r\n")
958 .or_else(|| after.strip_prefix('\n'))
959 .unwrap_or(after);
960 match after.find("</Objs>") {
961 Some(end) => {
962 out.push_str(&clixml_strings(&after[..end]));
963 rest = &after[end + "</Objs>".len()..];
964 }
965 None => {
966 out.push_str(&clixml_strings(after));
967 break;
968 }
969 }
970 }
971 }
972 }
973 out.into_bytes()
974}
975
976fn clixml_strings(xml: &str) -> String {
977 let mut out = String::new();
978 let mut rest = xml;
979 while let Some(start) = rest.find("<S S=\"") {
980 let after = &rest[start + 6..];
981 let Some(q) = after.find('"') else { break };
982 let kind = &after[..q];
983 let after = &after[q + 1..];
984 let Some(gt) = after.find('>') else { break };
985 let body_start = &after[gt + 1..];
986 let Some(end) = body_start.find("</S>") else {
987 break;
988 };
989 if kind == "Error" || kind == "Warning" {
990 out.push_str(&unescape_clixml(&body_start[..end]));
991 }
992 rest = &body_start[end + 4..];
993 }
994 out
995}
996
997fn unescape_clixml(s: &str) -> String {
998 let s = s
999 .replace("_x000D__x000A_", "\n")
1000 .replace("_x000A_", "\n")
1001 .replace("_x000D_", "")
1002 .replace("_x0009_", "\t");
1003 s.replace("<", "<")
1004 .replace(">", ">")
1005 .replace(""", "\"")
1006 .replace("'", "'")
1007 .replace("&", "&")
1008}
1009
1010fn separator(p: Platform) -> char {
1013 match p {
1014 Platform::Posix => '/',
1015 Platform::Windows => '\\',
1016 }
1017}
1018
1019fn is_sftp_drive_path(s: &str) -> bool {
1021 let b = s.as_bytes();
1022 b.len() >= 3 && b[0] == b'/' && b[1].is_ascii_alphabetic() && b[2] == b':'
1023}
1024
1025fn is_drive_path(s: &str) -> bool {
1026 let b = s.as_bytes();
1027 b.len() >= 2 && b[0].is_ascii_alphabetic() && b[1] == b':'
1028}
1029
1030fn is_absolute_for(p: Platform, raw: &str) -> bool {
1031 match p {
1032 Platform::Posix => raw.starts_with('/'),
1033 Platform::Windows => {
1034 is_drive_path(raw) || is_sftp_drive_path(raw) || raw.starts_with("\\\\")
1035 }
1036 }
1037}
1038
1039fn to_sftp_path(p: Platform, native: &str) -> String {
1042 match p {
1043 Platform::Posix => native.to_string(),
1044 Platform::Windows => format!("/{}", native.replace('\\', "/")),
1045 }
1046}
1047
1048fn from_sftp_path(sftp: &str) -> String {
1050 let s = sftp.strip_prefix('/').unwrap_or(sftp);
1051 let s = s.replace('/', "\\");
1052 if s.len() == 2 && is_drive_path(&s) {
1053 format!("{s}\\")
1054 } else {
1055 s
1056 }
1057}
1058
1059fn parent_of(p: Platform, path: &str) -> Option<String> {
1060 match p {
1061 Platform::Posix => {
1062 if path == "/" {
1063 return None;
1064 }
1065 match path.rfind('/') {
1066 Some(0) => Some("/".to_string()),
1067 Some(i) => Some(path[..i].to_string()),
1068 None => None,
1069 }
1070 }
1071 Platform::Windows => {
1072 if path.len() <= 3 {
1074 return None;
1075 }
1076 let i = path.rfind('\\')?;
1077 if i <= 2 {
1078 Some(path[..3].to_string())
1079 } else {
1080 Some(path[..i].to_string())
1081 }
1082 }
1083 }
1084}
1085
1086fn normalize_for(p: Platform, raw: &str) -> String {
1087 match p {
1088 Platform::Posix => normalize(raw),
1089 Platform::Windows => normalize_windows(raw),
1090 }
1091}
1092
1093fn expand_tilde(p: &str, home: &str) -> String {
1095 if p == "~" {
1096 home.to_string()
1097 } else if let Some(rest) = p.strip_prefix("~/").or_else(|| p.strip_prefix("~\\")) {
1098 format!("{home}/{rest}")
1099 } else {
1100 p.to_string()
1101 }
1102}
1103
1104pub fn normalize(p: &str) -> String {
1106 let mut parts: Vec<&str> = Vec::new();
1107 for seg in p.split('/') {
1108 match seg {
1109 "" | "." => {}
1110 ".." => {
1111 parts.pop();
1112 }
1113 s => parts.push(s),
1114 }
1115 }
1116 format!("/{}", parts.join("/"))
1117}
1118
1119pub fn normalize_windows(p: &str) -> String {
1122 let unc = p.starts_with("\\\\") || p.starts_with("//");
1123 let s = p.replace('/', "\\");
1124 let s = s
1125 .strip_prefix('\\')
1126 .filter(|r| is_drive_path(r))
1127 .unwrap_or(&s);
1128 let mut parts: Vec<&str> = Vec::new();
1129 let mut drive = String::new();
1130 for (i, seg) in s.split('\\').enumerate() {
1131 if i == 0 && is_drive_path(seg) && seg.len() == 2 {
1132 drive = seg.to_uppercase();
1133 continue;
1134 }
1135 match seg {
1136 "" | "." => {}
1137 ".." => {
1138 parts.pop();
1139 }
1140 x => parts.push(x),
1141 }
1142 }
1143 if unc {
1144 return format!("\\\\{}", parts.join("\\"));
1145 }
1146 if drive.is_empty() {
1147 return format!("\\{}", parts.join("\\"));
1150 }
1151 if parts.is_empty() {
1152 format!("{drive}\\")
1153 } else {
1154 format!("{drive}\\{}", parts.join("\\"))
1155 }
1156}
1157
1158struct Collected {
1159 data: Vec<u8>,
1160 truncated: bool,
1161}
1162
1163async fn collect_bounded<R: AsyncReadExt + Unpin>(mut r: R, cap: usize) -> Result<Collected> {
1166 let head_cap = cap * 2 / 3;
1167 let tail_cap = cap - head_cap;
1168 let mut head = Vec::new();
1169 let mut tail = Vec::new();
1170 let mut truncated = false;
1171 let mut buf = vec![0u8; 64 * 1024];
1172 loop {
1173 let n = r.read(&mut buf).await?;
1174 if n == 0 {
1175 break;
1176 }
1177 let mut chunk = &buf[..n];
1178 if head.len() < head_cap {
1179 let take = chunk.len().min(head_cap - head.len());
1180 head.extend_from_slice(&chunk[..take]);
1181 chunk = &chunk[take..];
1182 }
1183 if !chunk.is_empty() {
1184 truncated = true;
1185 tail.extend_from_slice(chunk);
1186 if tail.len() > tail_cap * 2 {
1187 let drop = tail.len() - tail_cap;
1188 tail.drain(..drop);
1189 }
1190 }
1191 }
1192 if truncated {
1193 if tail.len() > tail_cap {
1194 let drop = tail.len() - tail_cap;
1195 tail.drain(..drop);
1196 }
1197 let omitted_note = format!("\n\n[... output truncated; limit is {cap} bytes ...]\n\n");
1198 head.extend_from_slice(omitted_note.as_bytes());
1199 head.extend_from_slice(&tail);
1200 }
1201 Ok(Collected {
1202 data: head,
1203 truncated,
1204 })
1205}
1206
1207#[cfg(test)]
1208mod tests {
1209 use super::*;
1210
1211 #[test]
1212 fn tilde_expansion() {
1213 assert_eq!(expand_tilde("~", "/root"), "/root");
1214 assert_eq!(expand_tilde("~/a/b", "/root"), "/root/a/b");
1215 assert_eq!(expand_tilde("/x", "/root"), "/x");
1216 assert_eq!(expand_tilde("rel", "/root"), "rel");
1217 assert_eq!(
1218 normalize_windows(&expand_tilde("~\\x", "C:\\Users\\me")),
1219 "C:\\Users\\me\\x"
1220 );
1221 }
1222
1223 #[test]
1224 fn normalizes_paths() {
1225 assert_eq!(normalize("/a/b/../c/./d"), "/a/c/d");
1226 assert_eq!(normalize("/../x"), "/x");
1227 assert_eq!(normalize("/"), "/");
1228 }
1229
1230 #[test]
1231 fn windows_paths() {
1232 assert_eq!(normalize_windows("C:/Users/me/../x/./y"), "C:\\Users\\x\\y");
1233 assert_eq!(normalize_windows("/C:/Users/me"), "C:\\Users\\me");
1234 assert_eq!(normalize_windows("c:\\"), "C:\\");
1235 assert_eq!(
1236 normalize_windows("C:\\work/proj\\src"),
1237 "C:\\work\\proj\\src"
1238 );
1239 assert_eq!(
1240 normalize_windows("\\\\server\\share\\x"),
1241 "\\\\server\\share\\x"
1242 );
1243 assert_eq!(to_sftp_path(Platform::Windows, "C:\\a\\b"), "/C:/a/b");
1244 assert_eq!(from_sftp_path("/C:/Users/test"), "C:\\Users\\test");
1245 assert_eq!(from_sftp_path("/C:"), "C:\\");
1246 assert!(is_absolute_for(Platform::Windows, "D:\\x"));
1247 assert!(is_absolute_for(Platform::Windows, "/C:/x"));
1248 assert!(!is_absolute_for(Platform::Windows, "src\\main.rs"));
1249 assert!(!is_absolute_for(Platform::Windows, "/tmp"));
1250 assert_eq!(
1251 parent_of(Platform::Windows, "C:\\a\\b").as_deref(),
1252 Some("C:\\a")
1253 );
1254 assert_eq!(
1255 parent_of(Platform::Windows, "C:\\a").as_deref(),
1256 Some("C:\\")
1257 );
1258 assert_eq!(parent_of(Platform::Windows, "C:\\"), None);
1259 assert_eq!(parent_of(Platform::Posix, "/a/b").as_deref(), Some("/a"));
1260 assert_eq!(parent_of(Platform::Posix, "/a").as_deref(), Some("/"));
1261 assert_eq!(parent_of(Platform::Posix, "/"), None);
1262 }
1263
1264 #[test]
1265 fn exit_marker_and_clixml() {
1266 let mut out = b"table\r\n__FARHAND_EXIT__=3\r\n\r\n".to_vec();
1267 assert_eq!(take_windows_exit_marker(&mut out), Some(3));
1268 assert_eq!(out, b"table\n");
1269 let mut out = b"x\n__FARHAND_EXIT__=1\n".to_vec();
1270 assert_eq!(take_windows_exit_marker(&mut out), Some(1));
1271 let mut out = b"__FARHAND_EXIT__=0\n".to_vec();
1272 assert_eq!(take_windows_exit_marker(&mut out), Some(0));
1273 assert!(out.is_empty());
1274 let mut out = b"no marker".to_vec();
1275 assert_eq!(take_windows_exit_marker(&mut out), None);
1276
1277 let err = b"raw first\r\n#< CLIXML\r\n<Objs Version=\"1.1.0.1\"><Obj S=\"progress\"><TN></TN></Obj>\
1278 <S S=\"Error\">Get-Item : Cannot find path 'C:\\nope'_x000D__x000A_</S>\
1279 <S S=\"Error\">At line:6_x000D__x000A_</S></Objs>";
1280 let text = String::from_utf8(decode_clixml(err)).unwrap();
1281 assert_eq!(
1282 text,
1283 "raw first\r\nGet-Item : Cannot find path 'C:\\nope'\nAt line:6\n"
1284 );
1285 assert_eq!(crlf_to_lf(b"a\r\nb\r\n"), b"a\nb\n");
1286 assert_eq!(ps_quote("it's"), "'it''s'");
1287 }
1288
1289 #[test]
1290 fn wrapper_is_plain_ascii_on_the_command_line() {
1291 let w = windows_wrapper("powershell.exe", &[], "C:\\Users\\me", "Get-ChildItem", 30);
1292 assert!(w.contains("WaitForExit(30000)"));
1293 assert!(w.contains("-EncodedCommand"));
1294 assert!(encode_utf16_b64("x")
1295 .chars()
1296 .all(|c| c.is_ascii_alphanumeric() || "+/=".contains(c)));
1297 }
1298
1299 #[tokio::test]
1300 async fn bounded_collection_keeps_head_and_tail() {
1301 let data: Vec<u8> = (0..10_000u32).map(|i| b'a' + (i % 26) as u8).collect();
1302 let c = collect_bounded(&data[..], 300).await.unwrap();
1303 assert!(c.truncated);
1304 assert!(c.data.starts_with(b"abcdefghij"));
1305 assert!(c.data.ends_with(&data[data.len() - 100..]));
1306 let c = collect_bounded(&b"tiny"[..], 300).await.unwrap();
1307 assert!(!c.truncated);
1308 assert_eq!(c.data, b"tiny");
1309 }
1310}