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> {
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 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 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 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 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 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 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 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 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
857pub fn ps_quote(s: &str) -> String {
861 format!("'{}'", s.replace('\'', "''"))
862}
863
864fn 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
873fn 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
941fn 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 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
973fn 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("<", "<")
1040 .replace(">", ">")
1041 .replace(""", "\"")
1042 .replace("'", "'")
1043 .replace("&", "&")
1044}
1045
1046fn separator(p: Platform) -> char {
1049 match p {
1050 Platform::Posix => '/',
1051 Platform::Windows => '\\',
1052 }
1053}
1054
1055fn 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
1075fn 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
1084fn 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 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
1129fn 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
1140pub 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
1155pub 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 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
1199async 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
1243fn 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
1257fn 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 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 'C:\\nope'_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}