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::guard::StreamGuard;
39use crate::text::shell_quote;
40
41const TIMEOUT_EXIT: i32 = 124;
43const WATCHDOG_GRACE: u64 = 15;
45const CD_FAILED_EXIT: i32 = 97;
47const MAX_CHANNELS: usize = 4;
51const TRANSFER_CHUNK: usize = 1024 * 1024;
55const UPLOAD_WINDOW: usize = 16 * 1024 * 1024;
59const DOWNLOAD_STREAMS: u64 = 32;
62const DOWNLOAD_STEP: u64 = 1024 * 1024;
64const WIN_EXIT_MARKER: &str = "__FARHAND_EXIT__=";
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum Platform {
71 Posix,
72 Windows,
73}
74
75impl Platform {
76 pub fn name(self) -> &'static str {
77 match self {
78 Platform::Posix => "posix",
79 Platform::Windows => "windows",
80 }
81 }
82}
83
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88enum LoginShell {
89 PowerShell,
90 Cmd,
91}
92
93#[derive(Debug, Clone)]
95pub struct Address {
96 pub hostname: String,
97 pub port: u16,
98 pub alias: String,
100}
101
102#[derive(Debug, Clone)]
103pub struct ExecOutput {
104 pub exit_code: i32,
105 pub stdout: Vec<u8>,
106 pub stderr: Vec<u8>,
107 pub stdout_truncated: bool,
108 pub stderr_truncated: bool,
109 pub timed_out: bool,
110 pub duration: Duration,
111}
112
113struct Conn {
114 session: Arc<Session>,
115 sftp: Sftp,
116 platform: Platform,
117 login_shell: LoginShell,
118 home: String,
120 workdir: String,
122 workdir_created: bool,
125}
126
127pub struct Remote {
128 cfg: RemoteConfig,
129 limits: Limits,
130 conn: Mutex<Option<Arc<Conn>>>,
131 channels: Semaphore,
132 known_dirs: Mutex<HashSet<String>>,
135}
136
137impl Remote {
138 pub fn new(cfg: RemoteConfig, limits: Limits) -> Self {
139 Self {
140 cfg,
141 limits,
142 conn: Mutex::new(None),
143 channels: Semaphore::new(MAX_CHANNELS),
144 known_dirs: Mutex::new(HashSet::new()),
145 }
146 }
147
148 pub fn host(&self) -> &str {
149 &self.cfg.host
150 }
151
152 pub fn address(&self) -> Address {
157 let fallback = Address {
158 hostname: self
159 .cfg
160 .host
161 .rsplit('@')
162 .next()
163 .unwrap_or(&self.cfg.host)
164 .to_string(),
165 port: 22,
166 alias: self.cfg.host.clone(),
167 };
168 let out = match std::process::Command::new("ssh")
169 .arg("-G")
170 .arg(&self.cfg.host)
171 .stdin(std::process::Stdio::null())
172 .stderr(std::process::Stdio::null())
173 .output()
174 {
175 Ok(o) if o.status.success() => o.stdout,
176 _ => return fallback,
177 };
178 let text = String::from_utf8_lossy(&out);
179 let mut addr = fallback;
180 for line in text.lines() {
181 match line.split_once(' ') {
182 Some(("hostname", v)) => addr.hostname = v.trim().to_string(),
183 Some(("port", v)) => addr.port = v.trim().parse().unwrap_or(22),
184 _ => {}
185 }
186 }
187 addr
188 }
189
190 pub fn workdir(&self) -> &str {
192 &self.cfg.workdir
193 }
194
195 pub async fn effective_workdir(&self) -> Result<String> {
197 Ok(self.conn().await?.workdir.clone())
198 }
199
200 pub async fn workdir_created(&self) -> Result<bool> {
203 Ok(self.conn().await?.workdir_created)
204 }
205
206 pub fn limits(&self) -> &Limits {
207 &self.limits
208 }
209
210 pub fn configured_platform(&self) -> Option<Platform> {
213 match self.cfg.os {
214 Os::Auto => None,
215 Os::Posix => Some(Platform::Posix),
216 Os::Windows => Some(Platform::Windows),
217 }
218 }
219
220 pub async fn platform(&self) -> Result<Platform> {
222 Ok(self.conn().await?.platform)
223 }
224
225 pub async fn connect(&self) -> Result<()> {
227 self.conn().await.map(|_| ())
228 }
229
230 pub async fn is_connected(&self) -> bool {
231 match &*self.conn.lock().await {
232 Some(c) => c.session.check().await.is_ok(),
233 None => false,
234 }
235 }
236
237 async fn conn(&self) -> Result<Arc<Conn>> {
238 let mut slot = self.conn.lock().await;
239 if let Some(c) = &*slot {
240 if c.session.check().await.is_ok() {
241 return Ok(c.clone());
242 }
243 tracing::warn!("ssh session to {} dropped; reconnecting", self.cfg.host);
244 *slot = None;
245 self.known_dirs.lock().await.clear();
246 }
247 let wrapper = SshConfigWrapper::create(&[USER_SSH_CONFIG, SYSTEM_SSH_CONFIG])?;
259 let mut builder = SessionBuilder::default();
260 builder
261 .connect_timeout(Duration::from_secs(self.cfg.connect_timeout_secs))
262 .server_alive_interval(Duration::from_secs(self.cfg.server_alive_interval_secs))
263 .known_hosts_check(KnownHosts::Strict)
264 .config_file(&wrapper.path);
265 let session = builder.connect_mux(&self.cfg.host).await.map_err(|e| {
266 Error::Ssh(format!(
267 "cannot connect to `{}`: {e}. Check that `ssh {}` works from a terminal; \
268 FarHand refuses hosts whose key is not yet in known_hosts, so connect once \
269 by hand first.",
270 self.cfg.host, self.cfg.host
271 ))
272 })?;
273 drop(wrapper);
276 let session = Arc::new(session);
277 let sftp_options = SftpOptions::new().tokio_compat_file_write_limit(
278 NonZeroUsize::new(UPLOAD_WINDOW).expect("non-zero window"),
279 );
280 let sftp = Sftp::from_clonable_session(session.clone(), sftp_options)
281 .await
282 .map_err(|e| {
283 Error::Ssh(format!(
284 "sftp subsystem unavailable on `{}`: {e}",
285 self.cfg.host
286 ))
287 })?;
288 let sftp_home = sftp
291 .fs()
292 .canonicalize(".")
293 .await
294 .map_err(|e| Error::Ssh(format!("cannot resolve the remote home: {e}")))?
295 .to_string_lossy()
296 .into_owned();
297 let detected = if is_sftp_drive_path(&sftp_home) {
298 Platform::Windows
299 } else {
300 Platform::Posix
301 };
302 let platform = match (self.cfg.os, detected) {
303 (Os::Auto, d) => d,
304 (Os::Posix, Platform::Posix) | (Os::Windows, Platform::Windows) => detected,
305 (configured, d) => {
306 tracing::warn!(
307 "config says os = {:?} but the remote looks like {}; using {}",
308 configured,
309 d.name(),
310 d.name()
311 );
312 d
313 }
314 };
315 let home = match platform {
316 Platform::Posix => sftp_home,
317 Platform::Windows => from_sftp_path(&sftp_home),
318 };
319 let login_shell = match platform {
320 Platform::Posix => LoginShell::PowerShell, Platform::Windows => {
322 let out = session
324 .raw_command("echo %OS%")
325 .stdin(Stdio::null())
326 .output()
327 .await?;
328 if String::from_utf8_lossy(&out.stdout).contains("Windows_NT") {
329 LoginShell::Cmd
330 } else {
331 LoginShell::PowerShell
332 }
333 }
334 };
335 let workdir = normalize_for(platform, &expand_tilde(&self.cfg.workdir, &home));
336 let workdir_created = self
340 .ensure_dir_on(&sftp, platform, &workdir)
341 .await
342 .map_err(|e| match e {
343 Error::Invalid(m) => Error::Invalid(format!("remote.workdir: {m}")),
344 e => Error::Ssh(format!("cannot create remote.workdir `{workdir}`: {e}")),
345 })?;
346 if workdir_created {
347 tracing::info!(host = %self.cfg.host, "created the working directory {workdir}");
348 }
349 let conn = Arc::new(Conn {
350 session,
351 sftp,
352 platform,
353 login_shell,
354 home,
355 workdir,
356 workdir_created,
357 });
358 *slot = Some(conn.clone());
359 tracing::info!(host = %self.cfg.host, platform = platform.name(), "connected");
360 Ok(conn)
361 }
362
363 pub async fn resolve(&self, raw: &str) -> Result<String> {
367 let raw = raw.trim();
368 let conn = self.conn().await?;
369 if raw.is_empty() {
370 return Ok(conn.workdir.clone());
371 }
372 if is_absolute_for(conn.platform, raw) {
373 return Ok(normalize_for(conn.platform, raw));
374 }
375 if raw == "~" || raw.starts_with("~/") || raw.starts_with("~\\") {
376 return Ok(normalize_for(conn.platform, &expand_tilde(raw, &conn.home)));
377 }
378 Ok(normalize_for(
379 conn.platform,
380 &format!("{}/{raw}", conn.workdir),
381 ))
382 }
383
384 pub async fn exec(
388 &self,
389 command: &str,
390 cwd: Option<&str>,
391 timeout_secs: Option<u64>,
392 ) -> Result<ExecOutput> {
393 let conn = self.conn().await?;
394 let cwd = match cwd {
395 Some(c) => self.resolve(c).await?,
396 None => conn.workdir.clone(),
397 };
398 let timeout = timeout_secs
399 .unwrap_or(self.limits.command_timeout_secs)
400 .clamp(1, self.limits.max_command_timeout_secs);
401
402 let _permit = self.channels.acquire().await.expect("semaphore open");
403 let started = Instant::now();
404 let mut cmd = match conn.platform {
405 Platform::Posix => {
406 let script = format!(
410 "cd {} || exit {CD_FAILED_EXIT}\nexport NO_COLOR=1\n{command}\n",
411 shell_quote(&cwd)
412 );
413 let b64 = base64::engine::general_purpose::STANDARD.encode(script.as_bytes());
414 let shell = self
415 .cfg
416 .shell
417 .iter()
418 .map(|s| format!("\"{}\"", s.replace('"', "\\\"")))
419 .collect::<Vec<_>>()
420 .join(" ");
421 let inner =
426 format!("timeout -k 5 {timeout} {shell} \"$(printf %s {b64} | base64 -d)\"");
427 let mut c = conn.session.raw_command("sh");
428 c.raw_arg("-c").raw_arg(format!("'{inner}'"));
429 c
430 }
431 Platform::Windows => {
432 let (exe, extra) = self.powershell();
433 let outer = windows_wrapper(exe, &extra, &cwd, command, timeout);
434 let mut c = conn.session.raw_command(exe);
435 for a in &extra {
436 c.raw_arg(a);
437 }
438 c.raw_arg("-NoProfile")
439 .raw_arg("-NonInteractive")
440 .raw_arg("-ExecutionPolicy")
441 .raw_arg("Bypass")
442 .raw_arg("-EncodedCommand")
443 .raw_arg(encode_utf16_b64(&outer));
444 if conn.login_shell == LoginShell::PowerShell {
445 c.raw_arg("; exit $LASTEXITCODE");
448 }
449 c
450 }
451 };
452 cmd.stdin(Stdio::null())
453 .stdout(Stdio::piped())
454 .stderr(Stdio::piped());
455 let mut child = cmd.spawn().await?;
456 let stdout = child.stdout().take().expect("piped stdout");
457 let stderr = child.stderr().take().expect("piped stderr");
458 let cap = self.limits.max_output_bytes;
459
460 let collect = async {
461 let (out, err) =
462 tokio::join!(collect_bounded(stdout, cap), collect_bounded(stderr, cap));
463 let status = child.wait().await?;
464 Ok::<_, Error>((out?, err?, status))
465 };
466 let watchdog = Duration::from_secs(timeout + WATCHDOG_GRACE);
467 let (mut out, mut err, status) = match tokio::time::timeout(watchdog, collect).await {
468 Ok(r) => r?,
469 Err(_) => {
470 tracing::warn!("local watchdog fired after {}s", watchdog.as_secs());
471 return Err(Error::Timeout { secs: timeout });
472 }
473 };
474 let mut exit_code = status.code().unwrap_or(-1);
475 if conn.platform == Platform::Windows {
476 if let Some(code) = take_windows_exit_marker(&mut out.data) {
477 exit_code = code;
478 }
479 out.data = crlf_to_lf(&out.data);
480 err.data = crlf_to_lf(&decode_clixml(&err.data));
481 }
482 let timed_out = exit_code == TIMEOUT_EXIT;
483 if exit_code == CD_FAILED_EXIT && out.data.is_empty() {
484 return Err(Error::Invalid(format!(
485 "cannot cd into `{cwd}` on the remote"
486 )));
487 }
488 Ok(ExecOutput {
489 exit_code,
490 stdout: out.data,
491 stderr: err.data,
492 stdout_truncated: out.truncated,
493 stderr_truncated: err.truncated,
494 timed_out,
495 duration: started.elapsed(),
496 })
497 }
498
499 pub async fn home(&self) -> Result<String> {
501 Ok(self.conn().await?.home.clone())
502 }
503
504 pub(crate) fn shell(&self) -> &[String] {
506 &self.cfg.shell
507 }
508
509 pub(crate) fn powershell(&self) -> (&str, Vec<String>) {
512 if self.cfg.shell == default_shell() || self.cfg.shell.is_empty() {
513 ("powershell.exe", Vec::new())
514 } else {
515 (&self.cfg.shell[0], self.cfg.shell[1..].to_vec())
516 }
517 }
518
519 pub async fn read_file(&self, path: &str, max: usize) -> Result<(Vec<u8>, u64)> {
522 let conn = self.conn().await?;
523 let path = self.resolve(path).await?;
524 let sftp_path = to_sftp_path(conn.platform, &path);
525 let _permit = self.channels.acquire().await.expect("semaphore open");
526 let mut fs = conn.sftp.fs();
527 let meta = fs
528 .metadata(&sftp_path)
529 .await
530 .map_err(|e| not_found(&path, e))?;
531 if meta.file_type().map(|t| t.is_dir()).unwrap_or(false) {
532 return Err(Error::Invalid(format!("`{path}` is a directory")));
533 }
534 let len = meta.len().unwrap_or(0);
535 let mut file = conn
536 .sftp
537 .open(&sftp_path)
538 .await
539 .map_err(|e| not_found(&path, e))?;
540 let want = (len as usize).min(max);
541 let mut buf = BytesMut::with_capacity(want);
542 while buf.len() < want {
543 file.seek(std::io::SeekFrom::Start(buf.len() as u64))
547 .await?;
548 let chunk = (want - buf.len()).min(256 * 1024) as u32;
549 match file.read(chunk, buf.split_off(buf.len())).await? {
550 Some(bytes) => buf.unsplit(bytes),
551 None => break,
552 }
553 }
554 let _ = file.close().await;
555 Ok((buf.to_vec(), len))
556 }
557
558 pub async fn write_file(&self, path: &str, content: &[u8]) -> Result<String> {
560 let conn = self.conn().await?;
561 let path = self.resolve(path).await?;
562 if let Some(parent) = parent_of(conn.platform, &path) {
563 self.ensure_dir(&parent).await?;
564 }
565 let _permit = self.channels.acquire().await.expect("semaphore open");
566 conn.sftp
567 .fs()
568 .write(to_sftp_path(conn.platform, &path), content)
569 .await
570 .map_err(|e| Error::Ssh(format!("cannot write `{path}`: {e}")))?;
571 Ok(path)
572 }
573
574 pub async fn upload_file(
581 &self,
582 src: std::fs::File,
583 dest: &str,
584 mut scan: StreamGuard<'_>,
585 max: u64,
586 ) -> Result<(String, u64)> {
587 let conn = self.conn().await?;
588 let path = self.resolve(dest).await?;
589 if let Some(parent) = parent_of(conn.platform, &path) {
590 self.ensure_dir(&parent).await?;
591 }
592 let sftp_path = to_sftp_path(conn.platform, &path);
593 let _permit = self.channels.acquire().await.expect("semaphore open");
594 let mut src = tokio::fs::File::from_std(src);
595 let file = conn
596 .sftp
597 .create(&sftp_path)
598 .await
599 .map_err(|e| Error::Ssh(format!("cannot create `{path}`: {e}")))?;
600 let mut buf = vec![0u8; TRANSFER_CHUNK];
601 let mut sent: u64 = 0;
602 let copied: Result<()> = async {
603 let mut file = std::pin::pin!(TokioCompatFile::new(file));
607 let mut read: u64 = 0;
608 loop {
609 let n = src.read(&mut buf).await?;
610 if n == 0 {
611 break;
612 }
613 read += n as u64;
614 if read > max {
615 return Err(Error::Invalid(format!(
616 "`{path}`: the local file grew past the {max} bytes the transfer \
617 limit leaves while it was being uploaded"
618 )));
619 }
620 let out = scan.feed(&buf[..n])?;
621 file.write_all(&out).await?;
622 sent += out.len() as u64;
623 }
624 let out = scan.finish();
625 file.write_all(&out).await?;
626 sent += out.len() as u64;
627 file.flush().await?;
628 Ok(())
629 }
630 .await;
631 if let Err(e) = copied {
632 let _ = conn.sftp.fs().remove_file(&sftp_path).await;
633 return Err(match e {
634 Error::Denied(_) | Error::Invalid(_) => e,
635 e => Error::Ssh(format!("upload of `{path}` failed: {e}")),
636 });
637 }
638 Ok((path, sent))
639 }
640
641 pub async fn file_size(&self, path: &str) -> Result<u64> {
643 let conn = self.conn().await?;
644 let path = self.resolve(path).await?;
645 let _permit = self.channels.acquire().await.expect("semaphore open");
646 let meta = conn
647 .sftp
648 .fs()
649 .metadata(to_sftp_path(conn.platform, &path))
650 .await
651 .map_err(|e| not_found(&path, e))?;
652 if meta.file_type().map(|t| t.is_dir()).unwrap_or(false) {
653 return Err(Error::Invalid(format!("`{path}` is a directory")));
654 }
655 Ok(meta.len().unwrap_or(0))
656 }
657
658 pub async fn download_into(
664 &self,
665 path: &str,
666 out: &Arc<std::fs::File>,
667 max: u64,
668 ) -> Result<u64> {
669 let conn = self.conn().await?;
670 let path = self.resolve(path).await?;
671 let sftp_path = to_sftp_path(conn.platform, &path);
672 let _permit = self.channels.acquire().await.expect("semaphore open");
673 let meta = conn
674 .sftp
675 .fs()
676 .metadata(&sftp_path)
677 .await
678 .map_err(|e| not_found(&path, e))?;
679 if meta.file_type().map(|t| t.is_dir()).unwrap_or(false) {
680 return Err(Error::Invalid(format!("`{path}` is a directory")));
681 }
682 let len = meta.len().unwrap_or(0);
683 if len > max {
684 return Err(Error::Invalid(format!(
685 "`{path}` is now {len} bytes, more than the {max} bytes the transfer limit \
686 leaves; it changed while being downloaded"
687 )));
688 }
689 out.set_len(len)?;
690 let next = AtomicU64::new(0);
691 let streams = DOWNLOAD_STREAMS.min(len.div_ceil(DOWNLOAD_STEP)).max(1);
692 let readers = (0..streams).map(|_| async {
693 let mut file = conn
694 .sftp
695 .open(&sftp_path)
696 .await
697 .map_err(|e| not_found(&path, e))?;
698 loop {
699 let offset = next.fetch_add(DOWNLOAD_STEP, Ordering::Relaxed);
700 if offset >= len {
701 break;
702 }
703 let want = DOWNLOAD_STEP.min(len - offset);
704 let mut buf = BytesMut::with_capacity(want as usize);
705 while (buf.len() as u64) < want {
706 file.seek(std::io::SeekFrom::Start(offset + buf.len() as u64))
708 .await?;
709 let n = (want - buf.len() as u64).min(256 * 1024) as u32;
710 match file.read(n, buf.split_off(buf.len())).await? {
711 Some(bytes) => buf.unsplit(bytes),
712 None => {
713 return Err(Error::Ssh(format!(
714 "`{path}` shrank while it was being downloaded"
715 )))
716 }
717 }
718 }
719 let out = out.clone();
720 tokio::task::spawn_blocking(move || write_all_at(&out, &buf, offset))
721 .await
722 .map_err(|e| Error::Ssh(format!("download writer failed: {e}")))??;
723 }
724 let _ = file.close().await;
725 Ok::<(), Error>(())
726 });
727 futures_util::future::try_join_all(readers).await?;
728 Ok(len)
729 }
730
731 pub async fn ensure_dir(&self, dir: &str) -> Result<()> {
734 let conn = self.conn().await?;
735 self.ensure_dir_on(&conn.sftp, conn.platform, dir)
736 .await
737 .map(|_| ())
738 }
739
740 async fn ensure_dir_on(&self, sftp: &Sftp, platform: Platform, dir: &str) -> Result<bool> {
743 if self.known_dirs.lock().await.contains(dir) {
744 return Ok(false);
745 }
746 let mut missing: Vec<String> = Vec::new();
748 let mut cur = dir.to_string();
749 loop {
750 if self.known_dirs.lock().await.contains(&cur) {
751 break;
752 }
753 let _permit = self.channels.acquire().await.expect("semaphore open");
754 match sftp.fs().metadata(to_sftp_path(platform, &cur)).await {
755 Ok(m) => {
756 if !m.file_type().map(|t| t.is_dir()).unwrap_or(false) {
757 return Err(Error::Invalid(format!(
758 "`{cur}` exists on the remote and is not a directory"
759 )));
760 }
761 self.known_dirs.lock().await.insert(cur.clone());
762 break;
763 }
764 Err(_) => {
765 missing.push(cur.clone());
766 match parent_of(platform, &cur) {
767 Some(p) => cur = p,
768 None => break,
769 }
770 }
771 }
772 }
773 let mut created = false;
774 for d in missing.into_iter().rev() {
775 let _permit = self.channels.acquire().await.expect("semaphore open");
776 if let Err(e) = sftp.fs().create_dir(to_sftp_path(platform, &d)).await {
777 let exists = sftp
779 .fs()
780 .metadata(to_sftp_path(platform, &d))
781 .await
782 .map(|m| m.file_type().map(|t| t.is_dir()).unwrap_or(false))
783 .unwrap_or(false);
784 if !exists {
785 return Err(Error::Ssh(format!("cannot create directory `{d}`: {e}")));
786 }
787 }
788 self.known_dirs.lock().await.insert(d);
789 created = true;
790 }
791 Ok(created)
792 }
793
794 pub async fn exists(&self, path: &str) -> Result<Option<bool>> {
795 let conn = self.conn().await?;
796 let path = self.resolve(path).await?;
797 let _permit = self.channels.acquire().await.expect("semaphore open");
798 match conn
799 .sftp
800 .fs()
801 .metadata(to_sftp_path(conn.platform, &path))
802 .await
803 {
804 Ok(m) => Ok(Some(m.file_type().map(|t| t.is_dir()).unwrap_or(false))),
805 Err(_) => Ok(None),
806 }
807 }
808
809 pub async fn walk_files(&self, dir: &str, limit: usize) -> Result<Vec<String>> {
813 let conn = self.conn().await?;
814 let sep = separator(conn.platform);
815 let mut files = Vec::new();
816 let mut stack = vec![dir.to_string()];
817 while let Some(d) = stack.pop() {
818 let _permit = self.channels.acquire().await.expect("semaphore open");
819 let handle = conn
820 .sftp
821 .fs()
822 .open_dir(to_sftp_path(conn.platform, &d))
823 .await
824 .map_err(|e| Error::Ssh(format!("cannot list `{d}`: {e}")))?;
825 let mut entries = std::pin::pin!(handle.read_dir());
826 while let Some(entry) = entries.next().await {
827 let entry = entry.map_err(|e| Error::Ssh(format!("cannot list `{d}`: {e}")))?;
828 let name = entry.filename().to_string_lossy().into_owned();
829 if name == "." || name == ".." {
830 continue;
831 }
832 let full = format!("{d}{sep}{name}");
833 match entry.file_type() {
834 Some(t) if t.is_dir() => stack.push(full),
835 Some(t) if t.is_file() => {
836 files.push(full);
837 if files.len() > limit {
838 return Ok(files);
839 }
840 }
841 _ => {}
842 }
843 }
844 }
845 files.sort();
846 Ok(files)
847 }
848
849 pub async fn list_files(&self, dir: &str) -> Result<Vec<String>> {
852 let conn = self.conn().await?;
853 let _permit = self.channels.acquire().await.expect("semaphore open");
854 let Ok(handle) = conn
855 .sftp
856 .fs()
857 .open_dir(to_sftp_path(conn.platform, dir))
858 .await
859 else {
860 return Ok(Vec::new());
861 };
862 let mut names = Vec::new();
863 let mut entries = std::pin::pin!(handle.read_dir());
864 while let Some(entry) = entries.next().await {
865 let entry = entry.map_err(|e| Error::Ssh(format!("cannot list `{dir}`: {e}")))?;
866 if entry.file_type().is_some_and(|t| t.is_file()) {
867 names.push(entry.filename().to_string_lossy().into_owned());
868 }
869 }
870 names.sort();
871 Ok(names)
872 }
873
874 pub async fn has_tool(&self, name: &str) -> Result<bool> {
876 let conn = self.conn().await?;
877 let cmd = match conn.platform {
878 Platform::Posix => format!("command -v {} >/dev/null 2>&1", shell_quote(name)),
879 Platform::Windows => format!(
880 "if (Get-Command {} -ErrorAction SilentlyContinue) {{ 'yes' }} else {{ 'no' }}",
881 ps_quote(name)
882 ),
883 };
884 let out = self.exec(&cmd, None, Some(15)).await?;
885 Ok(match conn.platform {
886 Platform::Posix => out.exit_code == 0,
887 Platform::Windows => String::from_utf8_lossy(&out.stdout).contains("yes"),
888 })
889 }
890
891 pub async fn writable_host_shares(&self) -> Result<Vec<String>> {
897 if self.platform().await? != Platform::Posix {
898 return Ok(Vec::new());
899 }
900 let out = self
901 .exec("cat /proc/mounts 2>/dev/null", None, Some(15))
902 .await?;
903 Ok(writable_virtiofs_mounts(&String::from_utf8_lossy(
904 &out.stdout,
905 )))
906 }
907}
908
909fn writable_virtiofs_mounts(mounts: &str) -> Vec<String> {
911 mounts
912 .lines()
913 .filter_map(|line| {
914 let f: Vec<&str> = line.split_whitespace().collect();
915 (f.len() >= 4 && f[2] == "virtiofs" && f[3].split(',').any(|o| o == "rw"))
916 .then(|| unescape_mount_field(f[1]))
917 })
918 .collect()
919}
920
921fn unescape_mount_field(s: &str) -> String {
924 let b = s.as_bytes();
925 let mut out = Vec::with_capacity(b.len());
926 let mut i = 0;
927 while i < b.len() {
928 let octal = b
929 .get(i + 1..i + 4)
930 .filter(|d| d.iter().all(|c| (b'0'..=b'7').contains(c)));
931 match (b[i], octal) {
932 (b'\\', Some(d)) => {
933 out.push(d.iter().fold(0u8, |n, c| n.wrapping_mul(8) + (c - b'0')));
934 i += 4;
935 }
936 (c, _) => {
937 out.push(c);
938 i += 1;
939 }
940 }
941 }
942 String::from_utf8_lossy(&out).into_owned()
943}
944
945fn not_found(path: &str, e: openssh_sftp_client::Error) -> Error {
946 Error::Ssh(format!("`{path}`: {e}"))
947}
948
949pub fn ps_quote(s: &str) -> String {
953 format!("'{}'", s.replace('\'', "''"))
954}
955
956pub(crate) fn encode_utf16_b64(script: &str) -> String {
958 let bytes: Vec<u8> = script
959 .encode_utf16()
960 .flat_map(|u| u.to_le_bytes())
961 .collect();
962 base64::engine::general_purpose::STANDARD.encode(bytes)
963}
964
965fn windows_wrapper(exe: &str, extra: &[String], cwd: &str, command: &str, timeout: u64) -> String {
993 let inner = format!(
994 "$ProgressPreference='SilentlyContinue'; $ErrorActionPreference='Continue'\n\
995 [Console]::OutputEncoding=[Text.Encoding]::UTF8; [Console]::InputEncoding=[Text.Encoding]::UTF8; \
996 $OutputEncoding=[Text.Encoding]::UTF8\n\
997 $env:NO_COLOR='1'; $env:PYTHONUTF8='1'; $env:PYTHONIOENCODING='utf-8'\n\
998 try {{ Set-Location -LiteralPath {cwd} -ErrorAction Stop }} \
999 catch {{ [Console]::Error.WriteLine('cannot cd'); exit {CD_FAILED_EXIT} }}\n\
1000 $global:LASTEXITCODE=0\n\
1001 {command}\n\
1002 $__fh_ok=$?\n\
1003 Write-Output \"{WIN_EXIT_MARKER}$(if ($__fh_ok) {{0}} elseif ($LASTEXITCODE) {{$LASTEXITCODE}} \
1004 elseif ($Error.Count -and $Error[0].FullyQualifiedErrorId -like 'NativeCommandError*') {{0}} else {{1}})\"\n",
1005 cwd = ps_quote(cwd),
1006 );
1007 let mut args: Vec<String> = extra.iter().map(|a| ps_quote(a)).collect();
1008 args.extend(
1009 [
1010 "-NoProfile",
1011 "-NonInteractive",
1012 "-ExecutionPolicy",
1013 "Bypass",
1014 "-EncodedCommand",
1015 ]
1016 .iter()
1017 .map(|a| ps_quote(a)),
1018 );
1019 args.push(ps_quote(&encode_utf16_b64(&inner)));
1020 format!(
1021 "$ProgressPreference='SilentlyContinue'; $ErrorActionPreference='Continue'\n\
1022 $p=Start-Process -FilePath {exe} -ArgumentList {args} -NoNewWindow -PassThru\n\
1023 $null=$p.Handle\n\
1024 if (-not $p.WaitForExit({ms})) {{ taskkill /T /F /PID $p.Id 2>&1 | Out-Null; \
1025 $p.WaitForExit(); exit {TIMEOUT_EXIT} }}\n\
1026 exit $p.ExitCode\n",
1027 exe = ps_quote(exe),
1028 args = args.join(","),
1029 ms = timeout.saturating_mul(1000),
1030 )
1031}
1032
1033fn take_windows_exit_marker(stdout: &mut Vec<u8>) -> Option<i32> {
1036 let text = String::from_utf8_lossy(stdout);
1037 let pos = text.rfind(WIN_EXIT_MARKER)?;
1038 let rest = &text[pos + WIN_EXIT_MARKER.len()..];
1039 let line = rest.lines().next().unwrap_or("").trim();
1040 let code: i32 = line.parse().ok()?;
1041 let keep = text[..pos].trim_end_matches(['\r', '\n']).to_string();
1043 stdout.clear();
1044 stdout.extend_from_slice(keep.as_bytes());
1045 if !stdout.is_empty() {
1046 stdout.push(b'\n');
1047 }
1048 Some(code)
1049}
1050
1051fn crlf_to_lf(data: &[u8]) -> Vec<u8> {
1052 let mut out = Vec::with_capacity(data.len());
1053 let mut i = 0;
1054 while i < data.len() {
1055 if data[i] == b'\r' && data.get(i + 1) == Some(&b'\n') {
1056 i += 1;
1057 continue;
1058 }
1059 out.push(data[i]);
1060 i += 1;
1061 }
1062 out
1063}
1064
1065fn decode_clixml(data: &[u8]) -> Vec<u8> {
1069 let text = String::from_utf8_lossy(data);
1070 if !text.contains("#< CLIXML") {
1071 return data.to_vec();
1072 }
1073 let mut out = String::new();
1074 let mut rest: &str = &text;
1075 loop {
1076 match rest.find("#< CLIXML") {
1077 None => {
1078 out.push_str(rest);
1079 break;
1080 }
1081 Some(h) => {
1082 out.push_str(&rest[..h]);
1083 let after = &rest[h + "#< CLIXML".len()..];
1084 let after = after
1085 .strip_prefix("\r\n")
1086 .or_else(|| after.strip_prefix('\n'))
1087 .unwrap_or(after);
1088 match after.find("</Objs>") {
1089 Some(end) => {
1090 out.push_str(&clixml_strings(&after[..end]));
1091 rest = &after[end + "</Objs>".len()..];
1092 }
1093 None => {
1094 out.push_str(&clixml_strings(after));
1095 break;
1096 }
1097 }
1098 }
1099 }
1100 }
1101 out.into_bytes()
1102}
1103
1104fn clixml_strings(xml: &str) -> String {
1105 let mut out = String::new();
1106 let mut rest = xml;
1107 while let Some(start) = rest.find("<S S=\"") {
1108 let after = &rest[start + 6..];
1109 let Some(q) = after.find('"') else { break };
1110 let kind = &after[..q];
1111 let after = &after[q + 1..];
1112 let Some(gt) = after.find('>') else { break };
1113 let body_start = &after[gt + 1..];
1114 let Some(end) = body_start.find("</S>") else {
1115 break;
1116 };
1117 if kind == "Error" || kind == "Warning" {
1118 out.push_str(&unescape_clixml(&body_start[..end]));
1119 }
1120 rest = &body_start[end + 4..];
1121 }
1122 out
1123}
1124
1125fn unescape_clixml(s: &str) -> String {
1126 let s = s
1127 .replace("_x000D__x000A_", "\n")
1128 .replace("_x000A_", "\n")
1129 .replace("_x000D_", "")
1130 .replace("_x0009_", "\t");
1131 s.replace("<", "<")
1132 .replace(">", ">")
1133 .replace(""", "\"")
1134 .replace("'", "'")
1135 .replace("&", "&")
1136}
1137
1138pub(crate) fn separator(p: Platform) -> char {
1141 match p {
1142 Platform::Posix => '/',
1143 Platform::Windows => '\\',
1144 }
1145}
1146
1147fn is_sftp_drive_path(s: &str) -> bool {
1149 let b = s.as_bytes();
1150 b.len() >= 3 && b[0] == b'/' && b[1].is_ascii_alphabetic() && b[2] == b':'
1151}
1152
1153fn is_drive_path(s: &str) -> bool {
1154 let b = s.as_bytes();
1155 b.len() >= 2 && b[0].is_ascii_alphabetic() && b[1] == b':'
1156}
1157
1158fn is_absolute_for(p: Platform, raw: &str) -> bool {
1159 match p {
1160 Platform::Posix => raw.starts_with('/'),
1161 Platform::Windows => {
1162 is_drive_path(raw) || is_sftp_drive_path(raw) || raw.starts_with("\\\\")
1163 }
1164 }
1165}
1166
1167fn to_sftp_path(p: Platform, native: &str) -> String {
1170 match p {
1171 Platform::Posix => native.to_string(),
1172 Platform::Windows => format!("/{}", native.replace('\\', "/")),
1173 }
1174}
1175
1176fn from_sftp_path(sftp: &str) -> String {
1178 let s = sftp.strip_prefix('/').unwrap_or(sftp);
1179 let s = s.replace('/', "\\");
1180 if s.len() == 2 && is_drive_path(&s) {
1181 format!("{s}\\")
1182 } else {
1183 s
1184 }
1185}
1186
1187fn parent_of(p: Platform, path: &str) -> Option<String> {
1188 match p {
1189 Platform::Posix => {
1190 if path == "/" {
1191 return None;
1192 }
1193 match path.rfind('/') {
1194 Some(0) => Some("/".to_string()),
1195 Some(i) => Some(path[..i].to_string()),
1196 None => None,
1197 }
1198 }
1199 Platform::Windows => {
1200 if path.len() <= 3 {
1202 return None;
1203 }
1204 let i = path.rfind('\\')?;
1205 if i <= 2 {
1206 Some(path[..3].to_string())
1207 } else {
1208 Some(path[..i].to_string())
1209 }
1210 }
1211 }
1212}
1213
1214fn normalize_for(p: Platform, raw: &str) -> String {
1215 match p {
1216 Platform::Posix => normalize(raw),
1217 Platform::Windows => normalize_windows(raw),
1218 }
1219}
1220
1221fn expand_tilde(p: &str, home: &str) -> String {
1223 if p == "~" {
1224 home.to_string()
1225 } else if let Some(rest) = p.strip_prefix("~/").or_else(|| p.strip_prefix("~\\")) {
1226 format!("{home}/{rest}")
1227 } else {
1228 p.to_string()
1229 }
1230}
1231
1232pub fn normalize(p: &str) -> String {
1234 let mut parts: Vec<&str> = Vec::new();
1235 for seg in p.split('/') {
1236 match seg {
1237 "" | "." => {}
1238 ".." => {
1239 parts.pop();
1240 }
1241 s => parts.push(s),
1242 }
1243 }
1244 format!("/{}", parts.join("/"))
1245}
1246
1247pub fn normalize_windows(p: &str) -> String {
1250 let unc = p.starts_with("\\\\") || p.starts_with("//");
1251 let s = p.replace('/', "\\");
1252 let s = s
1253 .strip_prefix('\\')
1254 .filter(|r| is_drive_path(r))
1255 .unwrap_or(&s);
1256 let mut parts: Vec<&str> = Vec::new();
1257 let mut drive = String::new();
1258 for (i, seg) in s.split('\\').enumerate() {
1259 if i == 0 && is_drive_path(seg) && seg.len() == 2 {
1260 drive = seg.to_uppercase();
1261 continue;
1262 }
1263 match seg {
1264 "" | "." => {}
1265 ".." => {
1266 parts.pop();
1267 }
1268 x => parts.push(x),
1269 }
1270 }
1271 if unc {
1272 return format!("\\\\{}", parts.join("\\"));
1273 }
1274 if drive.is_empty() {
1275 return format!("\\{}", parts.join("\\"));
1278 }
1279 if parts.is_empty() {
1280 format!("{drive}\\")
1281 } else {
1282 format!("{drive}\\{}", parts.join("\\"))
1283 }
1284}
1285
1286struct Collected {
1287 data: Vec<u8>,
1288 truncated: bool,
1289}
1290
1291async fn collect_bounded<R: AsyncReadExt + Unpin>(mut r: R, cap: usize) -> Result<Collected> {
1294 let head_cap = cap * 2 / 3;
1295 let tail_cap = cap - head_cap;
1296 let mut head = Vec::new();
1297 let mut tail = Vec::new();
1298 let mut truncated = false;
1299 let mut buf = vec![0u8; 64 * 1024];
1300 loop {
1301 let n = r.read(&mut buf).await?;
1302 if n == 0 {
1303 break;
1304 }
1305 let mut chunk = &buf[..n];
1306 if head.len() < head_cap {
1307 let take = chunk.len().min(head_cap - head.len());
1308 head.extend_from_slice(&chunk[..take]);
1309 chunk = &chunk[take..];
1310 }
1311 if !chunk.is_empty() {
1312 truncated = true;
1313 tail.extend_from_slice(chunk);
1314 if tail.len() > tail_cap * 2 {
1315 let drop = tail.len() - tail_cap;
1316 tail.drain(..drop);
1317 }
1318 }
1319 }
1320 if truncated {
1321 if tail.len() > tail_cap {
1322 let drop = tail.len() - tail_cap;
1323 tail.drain(..drop);
1324 }
1325 let omitted_note = format!("\n\n[... output truncated; limit is {cap} bytes ...]\n\n");
1326 head.extend_from_slice(omitted_note.as_bytes());
1327 head.extend_from_slice(&tail);
1328 }
1329 Ok(Collected {
1330 data: head,
1331 truncated,
1332 })
1333}
1334
1335const USER_SSH_CONFIG: &str = "~/.ssh/config";
1337const SYSTEM_SSH_CONFIG: &str = "/etc/ssh/ssh_config";
1338
1339const SSH_CONFIG_HEADER: &str = "\
1343# Written by FarHand for one connection and removed once it is up.
1344ClearAllForwardings yes
1345ForwardAgent no
1346ForwardX11 no
1347ForwardX11Trusted no
1348Tunnel no
1349GSSAPIDelegateCredentials no
1350";
1351
1352fn ssh_config_text(includes: &[&str]) -> String {
1353 let mut s = SSH_CONFIG_HEADER.to_string();
1354 for i in includes {
1355 s.push_str(&format!("Include {i}\n"));
1356 }
1357 s
1358}
1359
1360struct SshConfigWrapper {
1363 path: std::path::PathBuf,
1364}
1365
1366impl SshConfigWrapper {
1367 fn create(includes: &[&str]) -> Result<Self> {
1368 use std::io::Write;
1369 let path = std::env::temp_dir().join(format!(
1370 "farhand-ssh-{}-{:016x}.conf",
1371 std::process::id(),
1372 random_u64()
1373 ));
1374 let mut f = create_exclusive(&path)?;
1375 f.write_all(ssh_config_text(includes).as_bytes())?;
1376 Ok(Self { path })
1377 }
1378}
1379
1380impl Drop for SshConfigWrapper {
1381 fn drop(&mut self) {
1382 let _ = std::fs::remove_file(&self.path);
1383 }
1384}
1385
1386pub(crate) fn random_u64() -> u64 {
1389 use std::hash::{BuildHasher, Hasher};
1390 let mut h = std::collections::hash_map::RandomState::new().build_hasher();
1391 h.write_u128(
1392 std::time::SystemTime::now()
1393 .duration_since(std::time::UNIX_EPOCH)
1394 .map(|d| d.as_nanos())
1395 .unwrap_or(0),
1396 );
1397 h.finish()
1398}
1399
1400fn create_exclusive(path: &Path) -> Result<std::fs::File> {
1403 let mut o = std::fs::OpenOptions::new();
1404 o.write(true).create_new(true);
1405 #[cfg(unix)]
1406 {
1407 use std::os::unix::fs::OpenOptionsExt;
1408 o.custom_flags(libc::O_NOFOLLOW).mode(0o600);
1409 }
1410 o.open(path).map_err(|e| {
1411 Error::Io(std::io::Error::new(
1412 e.kind(),
1413 format!("cannot create {}: {e}", path.display()),
1414 ))
1415 })
1416}
1417
1418#[cfg(unix)]
1419fn write_all_at(file: &std::fs::File, buf: &[u8], offset: u64) -> std::io::Result<()> {
1420 std::os::unix::fs::FileExt::write_all_at(file, buf, offset)
1421}
1422
1423#[cfg(windows)]
1424fn write_all_at(file: &std::fs::File, mut buf: &[u8], mut offset: u64) -> std::io::Result<()> {
1425 while !buf.is_empty() {
1426 let n = std::os::windows::fs::FileExt::seek_write(file, buf, offset)?;
1427 buf = &buf[n..];
1428 offset += n as u64;
1429 }
1430 Ok(())
1431}
1432
1433#[cfg(test)]
1434mod tests {
1435 use super::*;
1436
1437 #[test]
1438 fn only_read_write_virtiofs_mounts_are_host_shares() {
1439 let mounts = "overlay / overlay rw,relatime,lowerdir=/a:/b 0 0\n\
1440 /dev/vdd /var/lib/docker ext4 rw,relatime 0 0\n\
1441 bind-95f0 /etc/resolv.conf virtiofs ro,relatime 0 0\n\
1442 bind-1a2b /run/sandbox/source virtiofs ro,relatime 0 0\n\
1443 bind-3c4d /Users/me/My\\040Project virtiofs rw,relatime 0 0\n\
1444 bind-5e6f /Users/me/odd\\134name virtiofs relatime,rw 0 0\n\
1445 short line\n";
1446 assert_eq!(
1447 writable_virtiofs_mounts(mounts),
1448 ["/Users/me/My Project", "/Users/me/odd\\name"]
1449 );
1450 assert!(writable_virtiofs_mounts("").is_empty());
1451 assert_eq!(unescape_mount_field("a\\04"), "a\\04");
1452 assert_eq!(unescape_mount_field("a\\011b"), "a\tb");
1453 }
1454
1455 #[cfg(unix)]
1456 #[test]
1457 fn temp_files_never_follow_a_planted_link() {
1458 let d = tempfile::tempdir().unwrap();
1459 let outside = d.path().join("outside");
1460 let link = d.path().join("part");
1461 std::os::unix::fs::symlink(&outside, &link).unwrap();
1462 assert!(create_exclusive(&link).is_err());
1464 assert!(!outside.exists());
1465 std::fs::write(&outside, "keep").unwrap();
1466 assert!(create_exclusive(&link).is_err());
1467 assert_eq!(std::fs::read_to_string(&outside).unwrap(), "keep");
1468 let fresh = d.path().join("fresh");
1469 let f = create_exclusive(&fresh).unwrap();
1470 write_all_at(&f, b"xy", 3).unwrap();
1471 assert_eq!(std::fs::read(&fresh).unwrap(), b"\0\0\0xy");
1472 assert!(create_exclusive(&fresh).is_err());
1473 }
1474
1475 #[test]
1476 fn the_ssh_config_wrapper_closes_every_way_back() {
1477 let d = tempfile::tempdir().unwrap();
1478 let user = d.path().join("config");
1479 std::fs::write(
1480 &user,
1481 "Host fhtest\n HostName 192.0.2.1\n RemoteForward 9000 localhost:22\n \
1482 LocalForward 9001 localhost:22\n DynamicForward 9002\n ForwardAgent yes\n \
1483 ForwardX11 yes\n",
1484 )
1485 .unwrap();
1486 let w = SshConfigWrapper::create(&[user.to_str().unwrap()]).unwrap();
1487 let path = w.path.clone();
1488 let out = std::process::Command::new("ssh")
1490 .arg("-G")
1491 .arg("-F")
1492 .arg(&path)
1493 .arg("fhtest")
1494 .stdin(std::process::Stdio::null())
1495 .output();
1496 drop(w);
1497 assert!(!path.exists());
1498 let Ok(out) = out else {
1499 return; };
1501 let text = String::from_utf8_lossy(&out.stdout).to_lowercase();
1502 assert!(text.lines().any(|l| l == "hostname 192.0.2.1"), "{text}");
1504 for line in [
1506 "clearallforwardings yes",
1507 "forwardagent no",
1508 "forwardx11 no",
1509 ] {
1510 assert!(text.lines().any(|l| l == line), "{line}: {text}");
1511 }
1512 for word in ["remoteforward", "localforward", "dynamicforward"] {
1513 assert!(!text.contains(word), "{word}: {text}");
1514 }
1515 }
1516
1517 #[test]
1518 fn tilde_expansion() {
1519 assert_eq!(expand_tilde("~", "/root"), "/root");
1520 assert_eq!(expand_tilde("~/a/b", "/root"), "/root/a/b");
1521 assert_eq!(expand_tilde("/x", "/root"), "/x");
1522 assert_eq!(expand_tilde("rel", "/root"), "rel");
1523 assert_eq!(
1524 normalize_windows(&expand_tilde("~\\x", "C:\\Users\\me")),
1525 "C:\\Users\\me\\x"
1526 );
1527 }
1528
1529 #[test]
1530 fn normalizes_paths() {
1531 assert_eq!(normalize("/a/b/../c/./d"), "/a/c/d");
1532 assert_eq!(normalize("/../x"), "/x");
1533 assert_eq!(normalize("/"), "/");
1534 }
1535
1536 #[test]
1537 fn windows_paths() {
1538 assert_eq!(normalize_windows("C:/Users/me/../x/./y"), "C:\\Users\\x\\y");
1539 assert_eq!(normalize_windows("/C:/Users/me"), "C:\\Users\\me");
1540 assert_eq!(normalize_windows("c:\\"), "C:\\");
1541 assert_eq!(
1542 normalize_windows("C:\\work/proj\\src"),
1543 "C:\\work\\proj\\src"
1544 );
1545 assert_eq!(
1546 normalize_windows("\\\\server\\share\\x"),
1547 "\\\\server\\share\\x"
1548 );
1549 assert_eq!(to_sftp_path(Platform::Windows, "C:\\a\\b"), "/C:/a/b");
1550 assert_eq!(from_sftp_path("/C:/Users/test"), "C:\\Users\\test");
1551 assert_eq!(from_sftp_path("/C:"), "C:\\");
1552 assert!(is_absolute_for(Platform::Windows, "D:\\x"));
1553 assert!(is_absolute_for(Platform::Windows, "/C:/x"));
1554 assert!(!is_absolute_for(Platform::Windows, "src\\main.rs"));
1555 assert!(!is_absolute_for(Platform::Windows, "/tmp"));
1556 assert_eq!(
1557 parent_of(Platform::Windows, "C:\\a\\b").as_deref(),
1558 Some("C:\\a")
1559 );
1560 assert_eq!(
1561 parent_of(Platform::Windows, "C:\\a").as_deref(),
1562 Some("C:\\")
1563 );
1564 assert_eq!(parent_of(Platform::Windows, "C:\\"), None);
1565 assert_eq!(parent_of(Platform::Posix, "/a/b").as_deref(), Some("/a"));
1566 assert_eq!(parent_of(Platform::Posix, "/a").as_deref(), Some("/"));
1567 assert_eq!(parent_of(Platform::Posix, "/"), None);
1568 }
1569
1570 #[test]
1571 fn exit_marker_and_clixml() {
1572 let mut out = b"table\r\n__FARHAND_EXIT__=3\r\n\r\n".to_vec();
1573 assert_eq!(take_windows_exit_marker(&mut out), Some(3));
1574 assert_eq!(out, b"table\n");
1575 let mut out = b"x\n__FARHAND_EXIT__=1\n".to_vec();
1576 assert_eq!(take_windows_exit_marker(&mut out), Some(1));
1577 let mut out = b"__FARHAND_EXIT__=0\n".to_vec();
1578 assert_eq!(take_windows_exit_marker(&mut out), Some(0));
1579 assert!(out.is_empty());
1580 let mut out = b"no marker".to_vec();
1581 assert_eq!(take_windows_exit_marker(&mut out), None);
1582
1583 let err = b"raw first\r\n#< CLIXML\r\n<Objs Version=\"1.1.0.1\"><Obj S=\"progress\"><TN></TN></Obj>\
1584 <S S=\"Error\">Get-Item : Cannot find path 'C:\\nope'_x000D__x000A_</S>\
1585 <S S=\"Error\">At line:6_x000D__x000A_</S></Objs>";
1586 let text = String::from_utf8(decode_clixml(err)).unwrap();
1587 assert_eq!(
1588 text,
1589 "raw first\r\nGet-Item : Cannot find path 'C:\\nope'\nAt line:6\n"
1590 );
1591 assert_eq!(crlf_to_lf(b"a\r\nb\r\n"), b"a\nb\n");
1592 assert_eq!(ps_quote("it's"), "'it''s'");
1593 }
1594
1595 #[test]
1596 fn wrapper_is_plain_ascii_on_the_command_line() {
1597 let w = windows_wrapper("powershell.exe", &[], "C:\\Users\\me", "Get-ChildItem", 30);
1598 assert!(w.contains("WaitForExit(30000)"));
1599 assert!(w.contains("-EncodedCommand"));
1600 assert!(encode_utf16_b64("x")
1601 .chars()
1602 .all(|c| c.is_ascii_alphanumeric() || "+/=".contains(c)));
1603 }
1604
1605 #[tokio::test]
1606 async fn bounded_collection_keeps_head_and_tail() {
1607 let data: Vec<u8> = (0..10_000u32).map(|i| b'a' + (i % 26) as u8).collect();
1608 let c = collect_bounded(&data[..], 300).await.unwrap();
1609 assert!(c.truncated);
1610 assert!(c.data.starts_with(b"abcdefghij"));
1611 assert!(c.data.ends_with(&data[data.len() - 100..]));
1612 let c = collect_bounded(&b"tiny"[..], 300).await.unwrap();
1613 assert!(!c.truncated);
1614 assert_eq!(c.data, b"tiny");
1615 }
1616}