1use std::collections::HashSet;
21use std::sync::Arc;
22use std::time::{Duration, Instant};
23
24use base64::Engine;
25use bytes::BytesMut;
26use futures_util::StreamExt;
27use openssh::{KnownHosts, Session, SessionBuilder, Stdio};
28use openssh_sftp_client::{Sftp, SftpOptions};
29use tokio::io::AsyncReadExt;
30use tokio::sync::{Mutex, Semaphore};
31
32use crate::config::{default_shell, Limits, Os, RemoteConfig};
33use crate::error::{Error, Result};
34use crate::text::shell_quote;
35
36const TIMEOUT_EXIT: i32 = 124;
38const WATCHDOG_GRACE: u64 = 15;
40const CD_FAILED_EXIT: i32 = 97;
42const MAX_CHANNELS: usize = 4;
46const WIN_EXIT_MARKER: &str = "__FARHAND_EXIT__=";
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum Platform {
53 Posix,
54 Windows,
55}
56
57impl Platform {
58 pub fn name(self) -> &'static str {
59 match self {
60 Platform::Posix => "posix",
61 Platform::Windows => "windows",
62 }
63 }
64}
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70enum LoginShell {
71 PowerShell,
72 Cmd,
73}
74
75#[derive(Debug, Clone)]
77pub struct Address {
78 pub hostname: String,
79 pub port: u16,
80 pub alias: String,
82}
83
84#[derive(Debug, Clone)]
85pub struct ExecOutput {
86 pub exit_code: i32,
87 pub stdout: Vec<u8>,
88 pub stderr: Vec<u8>,
89 pub stdout_truncated: bool,
90 pub stderr_truncated: bool,
91 pub timed_out: bool,
92 pub duration: Duration,
93}
94
95struct Conn {
96 session: Arc<Session>,
97 sftp: Sftp,
98 platform: Platform,
99 login_shell: LoginShell,
100 home: String,
102 workdir: String,
104}
105
106pub struct Remote {
107 cfg: RemoteConfig,
108 limits: Limits,
109 conn: Mutex<Option<Arc<Conn>>>,
110 channels: Semaphore,
111 known_dirs: Mutex<HashSet<String>>,
114}
115
116impl Remote {
117 pub fn new(cfg: RemoteConfig, limits: Limits) -> Self {
118 Self {
119 cfg,
120 limits,
121 conn: Mutex::new(None),
122 channels: Semaphore::new(MAX_CHANNELS),
123 known_dirs: Mutex::new(HashSet::new()),
124 }
125 }
126
127 pub fn host(&self) -> &str {
128 &self.cfg.host
129 }
130
131 pub fn address(&self) -> Address {
136 let fallback = Address {
137 hostname: self
138 .cfg
139 .host
140 .rsplit('@')
141 .next()
142 .unwrap_or(&self.cfg.host)
143 .to_string(),
144 port: 22,
145 alias: self.cfg.host.clone(),
146 };
147 let out = match std::process::Command::new("ssh")
148 .arg("-G")
149 .arg(&self.cfg.host)
150 .stdin(std::process::Stdio::null())
151 .stderr(std::process::Stdio::null())
152 .output()
153 {
154 Ok(o) if o.status.success() => o.stdout,
155 _ => return fallback,
156 };
157 let text = String::from_utf8_lossy(&out);
158 let mut addr = fallback;
159 for line in text.lines() {
160 match line.split_once(' ') {
161 Some(("hostname", v)) => addr.hostname = v.trim().to_string(),
162 Some(("port", v)) => addr.port = v.trim().parse().unwrap_or(22),
163 _ => {}
164 }
165 }
166 addr
167 }
168
169 pub fn workdir(&self) -> &str {
171 &self.cfg.workdir
172 }
173
174 pub async fn effective_workdir(&self) -> Result<String> {
176 Ok(self.conn().await?.workdir.clone())
177 }
178
179 pub fn limits(&self) -> &Limits {
180 &self.limits
181 }
182
183 pub fn configured_platform(&self) -> Option<Platform> {
186 match self.cfg.os {
187 Os::Auto => None,
188 Os::Posix => Some(Platform::Posix),
189 Os::Windows => Some(Platform::Windows),
190 }
191 }
192
193 pub async fn platform(&self) -> Result<Platform> {
195 Ok(self.conn().await?.platform)
196 }
197
198 pub async fn connect(&self) -> Result<()> {
200 self.conn().await.map(|_| ())
201 }
202
203 pub async fn is_connected(&self) -> bool {
204 match &*self.conn.lock().await {
205 Some(c) => c.session.check().await.is_ok(),
206 None => false,
207 }
208 }
209
210 async fn conn(&self) -> Result<Arc<Conn>> {
211 let mut slot = self.conn.lock().await;
212 if let Some(c) = &*slot {
213 if c.session.check().await.is_ok() {
214 return Ok(c.clone());
215 }
216 tracing::warn!("ssh session to {} dropped; reconnecting", self.cfg.host);
217 *slot = None;
218 self.known_dirs.lock().await.clear();
219 }
220 let mut builder = SessionBuilder::default();
225 builder
226 .connect_timeout(Duration::from_secs(self.cfg.connect_timeout_secs))
227 .server_alive_interval(Duration::from_secs(self.cfg.server_alive_interval_secs))
228 .known_hosts_check(KnownHosts::Strict);
229 let session = builder.connect_mux(&self.cfg.host).await.map_err(|e| {
230 Error::Ssh(format!(
231 "cannot connect to `{}`: {e}. Check that `ssh {}` works from a terminal; \
232 FarHand refuses hosts whose key is not yet in known_hosts, so connect once \
233 by hand first.",
234 self.cfg.host, self.cfg.host
235 ))
236 })?;
237 let session = Arc::new(session);
238 let sftp = Sftp::from_clonable_session(session.clone(), SftpOptions::default())
239 .await
240 .map_err(|e| {
241 Error::Ssh(format!(
242 "sftp subsystem unavailable on `{}`: {e}",
243 self.cfg.host
244 ))
245 })?;
246 let sftp_home = sftp
249 .fs()
250 .canonicalize(".")
251 .await
252 .map_err(|e| Error::Ssh(format!("cannot resolve the remote home: {e}")))?
253 .to_string_lossy()
254 .into_owned();
255 let detected = if is_sftp_drive_path(&sftp_home) {
256 Platform::Windows
257 } else {
258 Platform::Posix
259 };
260 let platform = match (self.cfg.os, detected) {
261 (Os::Auto, d) => d,
262 (Os::Posix, Platform::Posix) | (Os::Windows, Platform::Windows) => detected,
263 (configured, d) => {
264 tracing::warn!(
265 "config says os = {:?} but the remote looks like {}; using {}",
266 configured,
267 d.name(),
268 d.name()
269 );
270 d
271 }
272 };
273 let home = match platform {
274 Platform::Posix => sftp_home,
275 Platform::Windows => from_sftp_path(&sftp_home),
276 };
277 let login_shell = match platform {
278 Platform::Posix => LoginShell::PowerShell, Platform::Windows => {
280 let out = session
282 .raw_command("echo %OS%")
283 .stdin(Stdio::null())
284 .output()
285 .await?;
286 if String::from_utf8_lossy(&out.stdout).contains("Windows_NT") {
287 LoginShell::Cmd
288 } else {
289 LoginShell::PowerShell
290 }
291 }
292 };
293 let workdir = normalize_for(platform, &expand_tilde(&self.cfg.workdir, &home));
294 let conn = Arc::new(Conn {
295 session,
296 sftp,
297 platform,
298 login_shell,
299 home,
300 workdir,
301 });
302 *slot = Some(conn.clone());
303 tracing::info!(host = %self.cfg.host, platform = platform.name(), "connected");
304 Ok(conn)
305 }
306
307 pub async fn resolve(&self, raw: &str) -> Result<String> {
311 let raw = raw.trim();
312 let conn = self.conn().await?;
313 if raw.is_empty() {
314 return Ok(conn.workdir.clone());
315 }
316 if is_absolute_for(conn.platform, raw) {
317 return Ok(normalize_for(conn.platform, raw));
318 }
319 if raw == "~" || raw.starts_with("~/") || raw.starts_with("~\\") {
320 return Ok(normalize_for(conn.platform, &expand_tilde(raw, &conn.home)));
321 }
322 Ok(normalize_for(
323 conn.platform,
324 &format!("{}/{raw}", conn.workdir),
325 ))
326 }
327
328 pub async fn exec(
332 &self,
333 command: &str,
334 cwd: Option<&str>,
335 timeout_secs: Option<u64>,
336 ) -> Result<ExecOutput> {
337 let conn = self.conn().await?;
338 let cwd = match cwd {
339 Some(c) => self.resolve(c).await?,
340 None => conn.workdir.clone(),
341 };
342 let timeout = timeout_secs
343 .unwrap_or(self.limits.command_timeout_secs)
344 .clamp(1, self.limits.max_command_timeout_secs);
345
346 let _permit = self.channels.acquire().await.expect("semaphore open");
347 let started = Instant::now();
348 let mut cmd = match conn.platform {
349 Platform::Posix => {
350 let script = format!(
354 "cd {} || exit {CD_FAILED_EXIT}\nexport NO_COLOR=1\n{command}\n",
355 shell_quote(&cwd)
356 );
357 let b64 = base64::engine::general_purpose::STANDARD.encode(script.as_bytes());
358 let shell = self
359 .cfg
360 .shell
361 .iter()
362 .map(|s| format!("\"{}\"", s.replace('"', "\\\"")))
363 .collect::<Vec<_>>()
364 .join(" ");
365 let inner =
370 format!("timeout -k 5 {timeout} {shell} \"$(printf %s {b64} | base64 -d)\"");
371 let mut c = conn.session.raw_command("sh");
372 c.raw_arg("-c").raw_arg(format!("'{inner}'"));
373 c
374 }
375 Platform::Windows => {
376 let (exe, extra) = self.powershell();
377 let outer = windows_wrapper(exe, &extra, &cwd, command, timeout);
378 let mut c = conn.session.raw_command(exe);
379 for a in &extra {
380 c.raw_arg(a);
381 }
382 c.raw_arg("-NoProfile")
383 .raw_arg("-NonInteractive")
384 .raw_arg("-ExecutionPolicy")
385 .raw_arg("Bypass")
386 .raw_arg("-EncodedCommand")
387 .raw_arg(encode_utf16_b64(&outer));
388 if conn.login_shell == LoginShell::PowerShell {
389 c.raw_arg("; exit $LASTEXITCODE");
392 }
393 c
394 }
395 };
396 cmd.stdin(Stdio::null())
397 .stdout(Stdio::piped())
398 .stderr(Stdio::piped());
399 let mut child = cmd.spawn().await?;
400 let stdout = child.stdout().take().expect("piped stdout");
401 let stderr = child.stderr().take().expect("piped stderr");
402 let cap = self.limits.max_output_bytes;
403
404 let collect = async {
405 let (out, err) =
406 tokio::join!(collect_bounded(stdout, cap), collect_bounded(stderr, cap));
407 let status = child.wait().await?;
408 Ok::<_, Error>((out?, err?, status))
409 };
410 let watchdog = Duration::from_secs(timeout + WATCHDOG_GRACE);
411 let (mut out, mut err, status) = match tokio::time::timeout(watchdog, collect).await {
412 Ok(r) => r?,
413 Err(_) => {
414 tracing::warn!("local watchdog fired after {}s", watchdog.as_secs());
415 return Err(Error::Timeout { secs: timeout });
416 }
417 };
418 let mut exit_code = status.code().unwrap_or(-1);
419 if conn.platform == Platform::Windows {
420 if let Some(code) = take_windows_exit_marker(&mut out.data) {
421 exit_code = code;
422 }
423 out.data = crlf_to_lf(&out.data);
424 err.data = crlf_to_lf(&decode_clixml(&err.data));
425 }
426 let timed_out = exit_code == TIMEOUT_EXIT;
427 if exit_code == CD_FAILED_EXIT && out.data.is_empty() {
428 return Err(Error::Invalid(format!(
429 "cannot cd into `{cwd}` on the remote"
430 )));
431 }
432 Ok(ExecOutput {
433 exit_code,
434 stdout: out.data,
435 stderr: err.data,
436 stdout_truncated: out.truncated,
437 stderr_truncated: err.truncated,
438 timed_out,
439 duration: started.elapsed(),
440 })
441 }
442
443 fn powershell(&self) -> (&str, Vec<String>) {
446 if self.cfg.shell == default_shell() || self.cfg.shell.is_empty() {
447 ("powershell.exe", Vec::new())
448 } else {
449 (&self.cfg.shell[0], self.cfg.shell[1..].to_vec())
450 }
451 }
452
453 pub async fn read_file(&self, path: &str, max: usize) -> Result<(Vec<u8>, u64)> {
456 let conn = self.conn().await?;
457 let path = self.resolve(path).await?;
458 let sftp_path = to_sftp_path(conn.platform, &path);
459 let _permit = self.channels.acquire().await.expect("semaphore open");
460 let mut fs = conn.sftp.fs();
461 let meta = fs
462 .metadata(&sftp_path)
463 .await
464 .map_err(|e| not_found(&path, e))?;
465 if meta.file_type().map(|t| t.is_dir()).unwrap_or(false) {
466 return Err(Error::Invalid(format!("`{path}` is a directory")));
467 }
468 let len = meta.len().unwrap_or(0);
469 let mut file = conn
470 .sftp
471 .open(&sftp_path)
472 .await
473 .map_err(|e| not_found(&path, e))?;
474 let want = (len as usize).min(max);
475 let mut buf = BytesMut::with_capacity(want);
476 let mut remaining = want;
477 while remaining > 0 {
478 let chunk = remaining.min(256 * 1024) as u32;
479 match file.read(chunk, buf.split_off(buf.len())).await? {
480 Some(bytes) => {
481 remaining -= bytes.len();
482 buf.unsplit(bytes);
483 }
484 None => break,
485 }
486 }
487 let _ = file.close().await;
488 Ok((buf.to_vec(), len))
489 }
490
491 pub async fn write_file(&self, path: &str, content: &[u8]) -> Result<String> {
493 let conn = self.conn().await?;
494 let path = self.resolve(path).await?;
495 if let Some(parent) = parent_of(conn.platform, &path) {
496 self.ensure_dir(&parent).await?;
497 }
498 let _permit = self.channels.acquire().await.expect("semaphore open");
499 conn.sftp
500 .fs()
501 .write(to_sftp_path(conn.platform, &path), content)
502 .await
503 .map_err(|e| Error::Ssh(format!("cannot write `{path}`: {e}")))?;
504 Ok(path)
505 }
506
507 pub async fn ensure_dir(&self, dir: &str) -> Result<()> {
510 let conn = self.conn().await?;
511 if self.known_dirs.lock().await.contains(dir) {
512 return Ok(());
513 }
514 let mut missing: Vec<String> = Vec::new();
516 let mut cur = dir.to_string();
517 loop {
518 if self.known_dirs.lock().await.contains(&cur) {
519 break;
520 }
521 let _permit = self.channels.acquire().await.expect("semaphore open");
522 match conn
523 .sftp
524 .fs()
525 .metadata(to_sftp_path(conn.platform, &cur))
526 .await
527 {
528 Ok(m) => {
529 if !m.file_type().map(|t| t.is_dir()).unwrap_or(false) {
530 return Err(Error::Invalid(format!(
531 "`{cur}` exists on the remote and is not a directory"
532 )));
533 }
534 self.known_dirs.lock().await.insert(cur.clone());
535 break;
536 }
537 Err(_) => {
538 missing.push(cur.clone());
539 match parent_of(conn.platform, &cur) {
540 Some(p) => cur = p,
541 None => break,
542 }
543 }
544 }
545 }
546 for d in missing.into_iter().rev() {
547 let _permit = self.channels.acquire().await.expect("semaphore open");
548 if let Err(e) = conn
549 .sftp
550 .fs()
551 .create_dir(to_sftp_path(conn.platform, &d))
552 .await
553 {
554 let exists = conn
556 .sftp
557 .fs()
558 .metadata(to_sftp_path(conn.platform, &d))
559 .await
560 .map(|m| m.file_type().map(|t| t.is_dir()).unwrap_or(false))
561 .unwrap_or(false);
562 if !exists {
563 return Err(Error::Ssh(format!("cannot create directory `{d}`: {e}")));
564 }
565 }
566 self.known_dirs.lock().await.insert(d);
567 }
568 Ok(())
569 }
570
571 pub async fn exists(&self, path: &str) -> Result<Option<bool>> {
572 let conn = self.conn().await?;
573 let path = self.resolve(path).await?;
574 let _permit = self.channels.acquire().await.expect("semaphore open");
575 match conn
576 .sftp
577 .fs()
578 .metadata(to_sftp_path(conn.platform, &path))
579 .await
580 {
581 Ok(m) => Ok(Some(m.file_type().map(|t| t.is_dir()).unwrap_or(false))),
582 Err(_) => Ok(None),
583 }
584 }
585
586 pub async fn walk_files(&self, dir: &str, limit: usize) -> Result<Vec<String>> {
590 let conn = self.conn().await?;
591 let sep = separator(conn.platform);
592 let mut files = Vec::new();
593 let mut stack = vec![dir.to_string()];
594 while let Some(d) = stack.pop() {
595 let _permit = self.channels.acquire().await.expect("semaphore open");
596 let handle = conn
597 .sftp
598 .fs()
599 .open_dir(to_sftp_path(conn.platform, &d))
600 .await
601 .map_err(|e| Error::Ssh(format!("cannot list `{d}`: {e}")))?;
602 let mut entries = std::pin::pin!(handle.read_dir());
603 while let Some(entry) = entries.next().await {
604 let entry = entry.map_err(|e| Error::Ssh(format!("cannot list `{d}`: {e}")))?;
605 let name = entry.filename().to_string_lossy().into_owned();
606 if name == "." || name == ".." {
607 continue;
608 }
609 let full = format!("{d}{sep}{name}");
610 match entry.file_type() {
611 Some(t) if t.is_dir() => stack.push(full),
612 Some(t) if t.is_file() => {
613 files.push(full);
614 if files.len() > limit {
615 return Ok(files);
616 }
617 }
618 _ => {}
619 }
620 }
621 }
622 files.sort();
623 Ok(files)
624 }
625
626 pub async fn has_tool(&self, name: &str) -> Result<bool> {
628 let conn = self.conn().await?;
629 let cmd = match conn.platform {
630 Platform::Posix => format!("command -v {} >/dev/null 2>&1", shell_quote(name)),
631 Platform::Windows => format!(
632 "if (Get-Command {} -ErrorAction SilentlyContinue) {{ 'yes' }} else {{ 'no' }}",
633 ps_quote(name)
634 ),
635 };
636 let out = self.exec(&cmd, None, Some(15)).await?;
637 Ok(match conn.platform {
638 Platform::Posix => out.exit_code == 0,
639 Platform::Windows => String::from_utf8_lossy(&out.stdout).contains("yes"),
640 })
641 }
642}
643
644fn not_found(path: &str, e: openssh_sftp_client::Error) -> Error {
645 Error::Ssh(format!("`{path}`: {e}"))
646}
647
648pub fn ps_quote(s: &str) -> String {
652 format!("'{}'", s.replace('\'', "''"))
653}
654
655fn encode_utf16_b64(script: &str) -> String {
657 let bytes: Vec<u8> = script
658 .encode_utf16()
659 .flat_map(|u| u.to_le_bytes())
660 .collect();
661 base64::engine::general_purpose::STANDARD.encode(bytes)
662}
663
664fn windows_wrapper(exe: &str, extra: &[String], cwd: &str, command: &str, timeout: u64) -> String {
678 let inner = format!(
679 "$ProgressPreference='SilentlyContinue'; $ErrorActionPreference='Continue'\n\
680 [Console]::OutputEncoding=[Text.Encoding]::UTF8; $OutputEncoding=[Text.Encoding]::UTF8\n\
681 $env:NO_COLOR='1'\n\
682 try {{ Set-Location -LiteralPath {cwd} -ErrorAction Stop }} \
683 catch {{ [Console]::Error.WriteLine('cannot cd'); exit {CD_FAILED_EXIT} }}\n\
684 $global:LASTEXITCODE=0; $Error.Clear()\n\
685 {command}\n\
686 Write-Output \"{WIN_EXIT_MARKER}$LASTEXITCODE,$($Error.Count)\"\n",
687 cwd = ps_quote(cwd),
688 );
689 let mut args: Vec<String> = extra.iter().map(|a| ps_quote(a)).collect();
690 args.extend(
691 [
692 "-NoProfile",
693 "-NonInteractive",
694 "-ExecutionPolicy",
695 "Bypass",
696 "-EncodedCommand",
697 ]
698 .iter()
699 .map(|a| ps_quote(a)),
700 );
701 args.push(ps_quote(&encode_utf16_b64(&inner)));
702 format!(
703 "$ProgressPreference='SilentlyContinue'; $ErrorActionPreference='Continue'\n\
704 $p=Start-Process -FilePath {exe} -ArgumentList {args} -NoNewWindow -PassThru\n\
705 $null=$p.Handle\n\
706 if (-not $p.WaitForExit({ms})) {{ taskkill /T /F /PID $p.Id 2>&1 | Out-Null; \
707 $p.WaitForExit(); exit {TIMEOUT_EXIT} }}\n\
708 exit $p.ExitCode\n",
709 exe = ps_quote(exe),
710 args = args.join(","),
711 ms = timeout.saturating_mul(1000),
712 )
713}
714
715fn take_windows_exit_marker(stdout: &mut Vec<u8>) -> Option<i32> {
718 let text = String::from_utf8_lossy(stdout);
719 let pos = text.rfind(WIN_EXIT_MARKER)?;
720 let rest = &text[pos + WIN_EXIT_MARKER.len()..];
721 let line = rest.lines().next().unwrap_or("").trim();
722 let (last, errors) = line.split_once(',')?;
723 let last: i32 = last.trim().parse().ok()?;
724 let errors: u32 = errors.trim().parse().ok()?;
725 let code = if last != 0 {
726 last
727 } else if errors > 0 {
728 1
729 } else {
730 0
731 };
732 let keep = text[..pos].trim_end_matches(['\r', '\n']).to_string();
734 stdout.clear();
735 stdout.extend_from_slice(keep.as_bytes());
736 if !stdout.is_empty() {
737 stdout.push(b'\n');
738 }
739 Some(code)
740}
741
742fn crlf_to_lf(data: &[u8]) -> Vec<u8> {
743 let mut out = Vec::with_capacity(data.len());
744 let mut i = 0;
745 while i < data.len() {
746 if data[i] == b'\r' && data.get(i + 1) == Some(&b'\n') {
747 i += 1;
748 continue;
749 }
750 out.push(data[i]);
751 i += 1;
752 }
753 out
754}
755
756fn decode_clixml(data: &[u8]) -> Vec<u8> {
760 let text = String::from_utf8_lossy(data);
761 if !text.contains("#< CLIXML") {
762 return data.to_vec();
763 }
764 let mut out = String::new();
765 let mut rest: &str = &text;
766 loop {
767 match rest.find("#< CLIXML") {
768 None => {
769 out.push_str(rest);
770 break;
771 }
772 Some(h) => {
773 out.push_str(&rest[..h]);
774 let after = &rest[h + "#< CLIXML".len()..];
775 let after = after
776 .strip_prefix("\r\n")
777 .or_else(|| after.strip_prefix('\n'))
778 .unwrap_or(after);
779 match after.find("</Objs>") {
780 Some(end) => {
781 out.push_str(&clixml_strings(&after[..end]));
782 rest = &after[end + "</Objs>".len()..];
783 }
784 None => {
785 out.push_str(&clixml_strings(after));
786 break;
787 }
788 }
789 }
790 }
791 }
792 out.into_bytes()
793}
794
795fn clixml_strings(xml: &str) -> String {
796 let mut out = String::new();
797 let mut rest = xml;
798 while let Some(start) = rest.find("<S S=\"") {
799 let after = &rest[start + 6..];
800 let Some(q) = after.find('"') else { break };
801 let kind = &after[..q];
802 let after = &after[q + 1..];
803 let Some(gt) = after.find('>') else { break };
804 let body_start = &after[gt + 1..];
805 let Some(end) = body_start.find("</S>") else {
806 break;
807 };
808 if kind == "Error" || kind == "Warning" {
809 out.push_str(&unescape_clixml(&body_start[..end]));
810 }
811 rest = &body_start[end + 4..];
812 }
813 out
814}
815
816fn unescape_clixml(s: &str) -> String {
817 let s = s
818 .replace("_x000D__x000A_", "\n")
819 .replace("_x000A_", "\n")
820 .replace("_x000D_", "")
821 .replace("_x0009_", "\t");
822 s.replace("<", "<")
823 .replace(">", ">")
824 .replace(""", "\"")
825 .replace("'", "'")
826 .replace("&", "&")
827}
828
829fn separator(p: Platform) -> char {
832 match p {
833 Platform::Posix => '/',
834 Platform::Windows => '\\',
835 }
836}
837
838fn is_sftp_drive_path(s: &str) -> bool {
840 let b = s.as_bytes();
841 b.len() >= 3 && b[0] == b'/' && b[1].is_ascii_alphabetic() && b[2] == b':'
842}
843
844fn is_drive_path(s: &str) -> bool {
845 let b = s.as_bytes();
846 b.len() >= 2 && b[0].is_ascii_alphabetic() && b[1] == b':'
847}
848
849fn is_absolute_for(p: Platform, raw: &str) -> bool {
850 match p {
851 Platform::Posix => raw.starts_with('/'),
852 Platform::Windows => {
853 is_drive_path(raw) || is_sftp_drive_path(raw) || raw.starts_with("\\\\")
854 }
855 }
856}
857
858fn to_sftp_path(p: Platform, native: &str) -> String {
861 match p {
862 Platform::Posix => native.to_string(),
863 Platform::Windows => format!("/{}", native.replace('\\', "/")),
864 }
865}
866
867fn from_sftp_path(sftp: &str) -> String {
869 let s = sftp.strip_prefix('/').unwrap_or(sftp);
870 let s = s.replace('/', "\\");
871 if s.len() == 2 && is_drive_path(&s) {
872 format!("{s}\\")
873 } else {
874 s
875 }
876}
877
878fn parent_of(p: Platform, path: &str) -> Option<String> {
879 match p {
880 Platform::Posix => {
881 if path == "/" {
882 return None;
883 }
884 match path.rfind('/') {
885 Some(0) => Some("/".to_string()),
886 Some(i) => Some(path[..i].to_string()),
887 None => None,
888 }
889 }
890 Platform::Windows => {
891 if path.len() <= 3 {
893 return None;
894 }
895 let i = path.rfind('\\')?;
896 if i <= 2 {
897 Some(path[..3].to_string())
898 } else {
899 Some(path[..i].to_string())
900 }
901 }
902 }
903}
904
905fn normalize_for(p: Platform, raw: &str) -> String {
906 match p {
907 Platform::Posix => normalize(raw),
908 Platform::Windows => normalize_windows(raw),
909 }
910}
911
912fn expand_tilde(p: &str, home: &str) -> String {
914 if p == "~" {
915 home.to_string()
916 } else if let Some(rest) = p.strip_prefix("~/").or_else(|| p.strip_prefix("~\\")) {
917 format!("{home}/{rest}")
918 } else {
919 p.to_string()
920 }
921}
922
923pub fn normalize(p: &str) -> String {
925 let mut parts: Vec<&str> = Vec::new();
926 for seg in p.split('/') {
927 match seg {
928 "" | "." => {}
929 ".." => {
930 parts.pop();
931 }
932 s => parts.push(s),
933 }
934 }
935 format!("/{}", parts.join("/"))
936}
937
938pub fn normalize_windows(p: &str) -> String {
941 let unc = p.starts_with("\\\\") || p.starts_with("//");
942 let s = p.replace('/', "\\");
943 let s = s
944 .strip_prefix('\\')
945 .filter(|r| is_drive_path(r))
946 .unwrap_or(&s);
947 let mut parts: Vec<&str> = Vec::new();
948 let mut drive = String::new();
949 for (i, seg) in s.split('\\').enumerate() {
950 if i == 0 && is_drive_path(seg) && seg.len() == 2 {
951 drive = seg.to_uppercase();
952 continue;
953 }
954 match seg {
955 "" | "." => {}
956 ".." => {
957 parts.pop();
958 }
959 x => parts.push(x),
960 }
961 }
962 if unc {
963 return format!("\\\\{}", parts.join("\\"));
964 }
965 if drive.is_empty() {
966 return format!("\\{}", parts.join("\\"));
969 }
970 if parts.is_empty() {
971 format!("{drive}\\")
972 } else {
973 format!("{drive}\\{}", parts.join("\\"))
974 }
975}
976
977struct Collected {
978 data: Vec<u8>,
979 truncated: bool,
980}
981
982async fn collect_bounded<R: AsyncReadExt + Unpin>(mut r: R, cap: usize) -> Result<Collected> {
985 let head_cap = cap * 2 / 3;
986 let tail_cap = cap - head_cap;
987 let mut head = Vec::new();
988 let mut tail = Vec::new();
989 let mut truncated = false;
990 let mut buf = vec![0u8; 64 * 1024];
991 loop {
992 let n = r.read(&mut buf).await?;
993 if n == 0 {
994 break;
995 }
996 let mut chunk = &buf[..n];
997 if head.len() < head_cap {
998 let take = chunk.len().min(head_cap - head.len());
999 head.extend_from_slice(&chunk[..take]);
1000 chunk = &chunk[take..];
1001 }
1002 if !chunk.is_empty() {
1003 truncated = true;
1004 tail.extend_from_slice(chunk);
1005 if tail.len() > tail_cap * 2 {
1006 let drop = tail.len() - tail_cap;
1007 tail.drain(..drop);
1008 }
1009 }
1010 }
1011 if truncated {
1012 if tail.len() > tail_cap {
1013 let drop = tail.len() - tail_cap;
1014 tail.drain(..drop);
1015 }
1016 let omitted_note = format!("\n\n[... output truncated; limit is {cap} bytes ...]\n\n");
1017 head.extend_from_slice(omitted_note.as_bytes());
1018 head.extend_from_slice(&tail);
1019 }
1020 Ok(Collected {
1021 data: head,
1022 truncated,
1023 })
1024}
1025
1026#[cfg(test)]
1027mod tests {
1028 use super::*;
1029
1030 #[test]
1031 fn tilde_expansion() {
1032 assert_eq!(expand_tilde("~", "/root"), "/root");
1033 assert_eq!(expand_tilde("~/a/b", "/root"), "/root/a/b");
1034 assert_eq!(expand_tilde("/x", "/root"), "/x");
1035 assert_eq!(expand_tilde("rel", "/root"), "rel");
1036 assert_eq!(
1037 normalize_windows(&expand_tilde("~\\x", "C:\\Users\\me")),
1038 "C:\\Users\\me\\x"
1039 );
1040 }
1041
1042 #[test]
1043 fn normalizes_paths() {
1044 assert_eq!(normalize("/a/b/../c/./d"), "/a/c/d");
1045 assert_eq!(normalize("/../x"), "/x");
1046 assert_eq!(normalize("/"), "/");
1047 }
1048
1049 #[test]
1050 fn windows_paths() {
1051 assert_eq!(normalize_windows("C:/Users/me/../x/./y"), "C:\\Users\\x\\y");
1052 assert_eq!(normalize_windows("/C:/Users/me"), "C:\\Users\\me");
1053 assert_eq!(normalize_windows("c:\\"), "C:\\");
1054 assert_eq!(
1055 normalize_windows("C:\\work/proj\\src"),
1056 "C:\\work\\proj\\src"
1057 );
1058 assert_eq!(
1059 normalize_windows("\\\\server\\share\\x"),
1060 "\\\\server\\share\\x"
1061 );
1062 assert_eq!(to_sftp_path(Platform::Windows, "C:\\a\\b"), "/C:/a/b");
1063 assert_eq!(from_sftp_path("/C:/Users/test"), "C:\\Users\\test");
1064 assert_eq!(from_sftp_path("/C:"), "C:\\");
1065 assert!(is_absolute_for(Platform::Windows, "D:\\x"));
1066 assert!(is_absolute_for(Platform::Windows, "/C:/x"));
1067 assert!(!is_absolute_for(Platform::Windows, "src\\main.rs"));
1068 assert!(!is_absolute_for(Platform::Windows, "/tmp"));
1069 assert_eq!(
1070 parent_of(Platform::Windows, "C:\\a\\b").as_deref(),
1071 Some("C:\\a")
1072 );
1073 assert_eq!(
1074 parent_of(Platform::Windows, "C:\\a").as_deref(),
1075 Some("C:\\")
1076 );
1077 assert_eq!(parent_of(Platform::Windows, "C:\\"), None);
1078 assert_eq!(parent_of(Platform::Posix, "/a/b").as_deref(), Some("/a"));
1079 assert_eq!(parent_of(Platform::Posix, "/a").as_deref(), Some("/"));
1080 assert_eq!(parent_of(Platform::Posix, "/"), None);
1081 }
1082
1083 #[test]
1084 fn exit_marker_and_clixml() {
1085 let mut out = b"table\r\n__FARHAND_EXIT__=3,1\r\n\r\n".to_vec();
1086 assert_eq!(take_windows_exit_marker(&mut out), Some(3));
1087 assert_eq!(out, b"table\n");
1088 let mut out = b"x\n__FARHAND_EXIT__=0,2\n".to_vec();
1089 assert_eq!(take_windows_exit_marker(&mut out), Some(1));
1090 let mut out = b"__FARHAND_EXIT__=0,0\n".to_vec();
1091 assert_eq!(take_windows_exit_marker(&mut out), Some(0));
1092 assert!(out.is_empty());
1093 let mut out = b"no marker".to_vec();
1094 assert_eq!(take_windows_exit_marker(&mut out), None);
1095
1096 let err = b"raw first\r\n#< CLIXML\r\n<Objs Version=\"1.1.0.1\"><Obj S=\"progress\"><TN></TN></Obj>\
1097 <S S=\"Error\">Get-Item : Cannot find path 'C:\\nope'_x000D__x000A_</S>\
1098 <S S=\"Error\">At line:6_x000D__x000A_</S></Objs>";
1099 let text = String::from_utf8(decode_clixml(err)).unwrap();
1100 assert_eq!(
1101 text,
1102 "raw first\r\nGet-Item : Cannot find path 'C:\\nope'\nAt line:6\n"
1103 );
1104 assert_eq!(crlf_to_lf(b"a\r\nb\r\n"), b"a\nb\n");
1105 assert_eq!(ps_quote("it's"), "'it''s'");
1106 }
1107
1108 #[test]
1109 fn wrapper_is_plain_ascii_on_the_command_line() {
1110 let w = windows_wrapper("powershell.exe", &[], "C:\\Users\\me", "Get-ChildItem", 30);
1111 assert!(w.contains("WaitForExit(30000)"));
1112 assert!(w.contains("-EncodedCommand"));
1113 assert!(encode_utf16_b64("x")
1114 .chars()
1115 .all(|c| c.is_ascii_alphanumeric() || "+/=".contains(c)));
1116 }
1117
1118 #[tokio::test]
1119 async fn bounded_collection_keeps_head_and_tail() {
1120 let data: Vec<u8> = (0..10_000u32).map(|i| b'a' + (i % 26) as u8).collect();
1121 let c = collect_bounded(&data[..], 300).await.unwrap();
1122 assert!(c.truncated);
1123 assert!(c.data.starts_with(b"abcdefghij"));
1124 assert!(c.data.ends_with(&data[data.len() - 100..]));
1125 let c = collect_bounded(&b"tiny"[..], 300).await.unwrap();
1126 assert!(!c.truncated);
1127 assert_eq!(c.data, b"tiny");
1128 }
1129}