1use std::path::Path;
15use std::sync::Arc;
16use std::time::{Duration, Instant};
17
18use base64::Engine;
19use bytes::BytesMut;
20use openssh::{KnownHosts, Session, SessionBuilder, Stdio};
21use openssh_sftp_client::{Sftp, SftpOptions};
22use tokio::io::AsyncReadExt;
23use tokio::sync::{Mutex, Semaphore};
24
25use crate::config::{Limits, RemoteConfig};
26use crate::error::{Error, Result};
27use crate::text::shell_quote;
28
29const TIMEOUT_EXIT: i32 = 124;
31const WATCHDOG_GRACE: u64 = 15;
33const CD_FAILED_EXIT: i32 = 97;
35const MAX_CHANNELS: usize = 4;
39
40#[derive(Debug, Clone)]
42pub struct Address {
43 pub hostname: String,
44 pub port: u16,
45 pub alias: String,
47}
48
49#[derive(Debug, Clone)]
50pub struct ExecOutput {
51 pub exit_code: i32,
52 pub stdout: Vec<u8>,
53 pub stderr: Vec<u8>,
54 pub stdout_truncated: bool,
55 pub stderr_truncated: bool,
56 pub timed_out: bool,
57 pub duration: Duration,
58}
59
60struct Conn {
61 session: Arc<Session>,
62 sftp: Sftp,
63 home: String,
64 workdir: String,
66}
67
68pub struct Remote {
69 cfg: RemoteConfig,
70 limits: Limits,
71 conn: Mutex<Option<Arc<Conn>>>,
72 channels: Semaphore,
73}
74
75impl Remote {
76 pub fn new(cfg: RemoteConfig, limits: Limits) -> Self {
77 Self {
78 cfg,
79 limits,
80 conn: Mutex::new(None),
81 channels: Semaphore::new(MAX_CHANNELS),
82 }
83 }
84
85 pub fn host(&self) -> &str {
86 &self.cfg.host
87 }
88
89 pub fn address(&self) -> Address {
94 let fallback = Address {
95 hostname: self
96 .cfg
97 .host
98 .rsplit('@')
99 .next()
100 .unwrap_or(&self.cfg.host)
101 .to_string(),
102 port: 22,
103 alias: self.cfg.host.clone(),
104 };
105 let out = match std::process::Command::new("ssh")
106 .arg("-G")
107 .arg(&self.cfg.host)
108 .stdin(std::process::Stdio::null())
109 .stderr(std::process::Stdio::null())
110 .output()
111 {
112 Ok(o) if o.status.success() => o.stdout,
113 _ => return fallback,
114 };
115 let text = String::from_utf8_lossy(&out);
116 let mut addr = fallback;
117 for line in text.lines() {
118 match line.split_once(' ') {
119 Some(("hostname", v)) => addr.hostname = v.trim().to_string(),
120 Some(("port", v)) => addr.port = v.trim().parse().unwrap_or(22),
121 _ => {}
122 }
123 }
124 addr
125 }
126
127 pub fn workdir(&self) -> &str {
129 &self.cfg.workdir
130 }
131
132 pub async fn effective_workdir(&self) -> Result<String> {
134 Ok(self.conn().await?.workdir.clone())
135 }
136
137 pub fn limits(&self) -> &Limits {
138 &self.limits
139 }
140
141 pub async fn connect(&self) -> Result<()> {
143 self.conn().await.map(|_| ())
144 }
145
146 pub async fn is_connected(&self) -> bool {
147 match &*self.conn.lock().await {
148 Some(c) => c.session.check().await.is_ok(),
149 None => false,
150 }
151 }
152
153 async fn conn(&self) -> Result<Arc<Conn>> {
154 let mut slot = self.conn.lock().await;
155 if let Some(c) = &*slot {
156 if c.session.check().await.is_ok() {
157 return Ok(c.clone());
158 }
159 tracing::warn!("ssh session to {} dropped; reconnecting", self.cfg.host);
160 *slot = None;
161 }
162 let mut builder = SessionBuilder::default();
167 builder
168 .connect_timeout(Duration::from_secs(self.cfg.connect_timeout_secs))
169 .server_alive_interval(Duration::from_secs(self.cfg.server_alive_interval_secs))
170 .known_hosts_check(KnownHosts::Strict);
171 let session = builder.connect_mux(&self.cfg.host).await.map_err(|e| {
172 Error::Ssh(format!(
173 "cannot connect to `{}`: {e}. Check that `ssh {}` works from a terminal; \
174 FarHand refuses hosts whose key is not yet in known_hosts, so connect once \
175 by hand first.",
176 self.cfg.host, self.cfg.host
177 ))
178 })?;
179 let session = Arc::new(session);
180 let sftp = Sftp::from_clonable_session(session.clone(), SftpOptions::default())
181 .await
182 .map_err(|e| {
183 Error::Ssh(format!(
184 "sftp subsystem unavailable on `{}`: {e}",
185 self.cfg.host
186 ))
187 })?;
188 let home = {
189 let out = session
190 .command("sh")
191 .arg("-c")
192 .arg("printf %s \"$HOME\"")
193 .output()
194 .await?;
195 String::from_utf8_lossy(&out.stdout).trim().to_string()
196 };
197 let workdir = expand_tilde(&self.cfg.workdir, &home);
198 let conn = Arc::new(Conn {
199 session,
200 sftp,
201 home,
202 workdir,
203 });
204 *slot = Some(conn.clone());
205 tracing::info!(host = %self.cfg.host, "connected");
206 Ok(conn)
207 }
208
209 pub async fn resolve(&self, raw: &str) -> Result<String> {
212 let raw = raw.trim();
213 if raw.starts_with('/') {
214 return Ok(normalize(raw));
215 }
216 let conn = self.conn().await?;
217 if raw.is_empty() {
218 return Ok(conn.workdir.clone());
219 }
220 if raw == "~" || raw.starts_with("~/") {
221 return Ok(normalize(&expand_tilde(raw, &conn.home)));
222 }
223 Ok(normalize(&format!("{}/{raw}", conn.workdir)))
224 }
225
226 pub async fn exec(
230 &self,
231 command: &str,
232 cwd: Option<&str>,
233 timeout_secs: Option<u64>,
234 ) -> Result<ExecOutput> {
235 let conn = self.conn().await?;
236 let cwd = match cwd {
237 Some(c) => self.resolve(c).await?,
238 None => conn.workdir.clone(),
239 };
240 let timeout = timeout_secs
241 .unwrap_or(self.limits.command_timeout_secs)
242 .clamp(1, self.limits.max_command_timeout_secs);
243
244 let script = format!(
248 "cd {} || exit {CD_FAILED_EXIT}\nexport NO_COLOR=1\n{command}\n",
249 shell_quote(&cwd)
250 );
251 let b64 = base64::engine::general_purpose::STANDARD.encode(script.as_bytes());
252 let shell = self
253 .cfg
254 .shell
255 .iter()
256 .map(|s| format!("\"{}\"", s.replace('"', "\\\"")))
257 .collect::<Vec<_>>()
258 .join(" ");
259 let inner = format!("timeout -k 5 {timeout} {shell} \"$(printf %s {b64} | base64 -d)\"");
263
264 let _permit = self.channels.acquire().await.expect("semaphore open");
265 let started = Instant::now();
266 let mut cmd = conn.session.raw_command("sh");
267 cmd.raw_arg("-c")
268 .raw_arg(format!("'{inner}'"))
269 .stdin(Stdio::null())
270 .stdout(Stdio::piped())
271 .stderr(Stdio::piped());
272 let mut child = cmd.spawn().await?;
273 let stdout = child.stdout().take().expect("piped stdout");
274 let stderr = child.stderr().take().expect("piped stderr");
275 let cap = self.limits.max_output_bytes;
276
277 let collect = async {
278 let (out, err) =
279 tokio::join!(collect_bounded(stdout, cap), collect_bounded(stderr, cap));
280 let status = child.wait().await?;
281 Ok::<_, Error>((out?, err?, status))
282 };
283 let watchdog = Duration::from_secs(timeout + WATCHDOG_GRACE);
284 let (out, err, status) = match tokio::time::timeout(watchdog, collect).await {
285 Ok(r) => r?,
286 Err(_) => {
287 tracing::warn!("local watchdog fired after {}s", watchdog.as_secs());
288 return Err(Error::Timeout { secs: timeout });
289 }
290 };
291 let exit_code = status.code().unwrap_or(-1);
292 let timed_out = exit_code == TIMEOUT_EXIT;
293 if exit_code == CD_FAILED_EXIT && out.data.is_empty() {
294 return Err(Error::Invalid(format!(
295 "cannot cd into `{cwd}` on the remote"
296 )));
297 }
298 Ok(ExecOutput {
299 exit_code,
300 stdout: out.data,
301 stderr: err.data,
302 stdout_truncated: out.truncated,
303 stderr_truncated: err.truncated,
304 timed_out,
305 duration: started.elapsed(),
306 })
307 }
308
309 pub async fn read_file(&self, path: &str, max: usize) -> Result<(Vec<u8>, u64)> {
312 let conn = self.conn().await?;
313 let path = self.resolve(path).await?;
314 let _permit = self.channels.acquire().await.expect("semaphore open");
315 let mut fs = conn.sftp.fs();
316 let meta = fs.metadata(&path).await.map_err(|e| not_found(&path, e))?;
317 if meta.file_type().map(|t| t.is_dir()).unwrap_or(false) {
318 return Err(Error::Invalid(format!("`{path}` is a directory")));
319 }
320 let len = meta.len().unwrap_or(0);
321 let mut file = conn
322 .sftp
323 .open(&path)
324 .await
325 .map_err(|e| not_found(&path, e))?;
326 let want = (len as usize).min(max);
327 let mut buf = BytesMut::with_capacity(want);
328 let mut remaining = want;
329 while remaining > 0 {
330 let chunk = remaining.min(256 * 1024) as u32;
331 match file.read(chunk, buf.split_off(buf.len())).await? {
332 Some(bytes) => {
333 remaining -= bytes.len();
334 buf.unsplit(bytes);
335 }
336 None => break,
337 }
338 }
339 let _ = file.close().await;
340 Ok((buf.to_vec(), len))
341 }
342
343 pub async fn write_file(&self, path: &str, content: &[u8]) -> Result<String> {
345 let conn = self.conn().await?;
346 let path = self.resolve(path).await?;
347 if let Some(parent) = Path::new(&path).parent() {
348 let mkdir = format!("mkdir -p {}", shell_quote(&parent.to_string_lossy()));
349 let out = self.exec(&mkdir, None, Some(30)).await?;
350 if out.exit_code != 0 {
351 return Err(Error::Ssh(format!(
352 "mkdir -p failed: {}",
353 String::from_utf8_lossy(&out.stderr).trim()
354 )));
355 }
356 }
357 let _permit = self.channels.acquire().await.expect("semaphore open");
358 conn.sftp.fs().write(&path, content).await?;
359 Ok(path)
360 }
361
362 pub async fn exists(&self, path: &str) -> Result<Option<bool>> {
363 let conn = self.conn().await?;
364 let path = self.resolve(path).await?;
365 let _permit = self.channels.acquire().await.expect("semaphore open");
366 match conn.sftp.fs().metadata(&path).await {
367 Ok(m) => Ok(Some(m.file_type().map(|t| t.is_dir()).unwrap_or(false))),
368 Err(_) => Ok(None),
369 }
370 }
371
372 pub async fn has_tool(&self, name: &str) -> Result<bool> {
374 let out = self
375 .exec(
376 &format!("command -v {} >/dev/null 2>&1", shell_quote(name)),
377 None,
378 Some(15),
379 )
380 .await?;
381 Ok(out.exit_code == 0)
382 }
383}
384
385fn not_found(path: &str, e: openssh_sftp_client::Error) -> Error {
386 Error::Ssh(format!("`{path}`: {e}"))
387}
388
389fn expand_tilde(p: &str, home: &str) -> String {
391 if p == "~" {
392 home.to_string()
393 } else if let Some(rest) = p.strip_prefix("~/") {
394 format!("{home}/{rest}")
395 } else {
396 p.to_string()
397 }
398}
399
400pub fn normalize(p: &str) -> String {
402 let mut parts: Vec<&str> = Vec::new();
403 for seg in p.split('/') {
404 match seg {
405 "" | "." => {}
406 ".." => {
407 parts.pop();
408 }
409 s => parts.push(s),
410 }
411 }
412 format!("/{}", parts.join("/"))
413}
414
415struct Collected {
416 data: Vec<u8>,
417 truncated: bool,
418}
419
420async fn collect_bounded<R: AsyncReadExt + Unpin>(mut r: R, cap: usize) -> Result<Collected> {
423 let head_cap = cap * 2 / 3;
424 let tail_cap = cap - head_cap;
425 let mut head = Vec::new();
426 let mut tail = Vec::new();
427 let mut truncated = false;
428 let mut buf = vec![0u8; 64 * 1024];
429 loop {
430 let n = r.read(&mut buf).await?;
431 if n == 0 {
432 break;
433 }
434 let mut chunk = &buf[..n];
435 if head.len() < head_cap {
436 let take = chunk.len().min(head_cap - head.len());
437 head.extend_from_slice(&chunk[..take]);
438 chunk = &chunk[take..];
439 }
440 if !chunk.is_empty() {
441 truncated = true;
442 tail.extend_from_slice(chunk);
443 if tail.len() > tail_cap * 2 {
444 let drop = tail.len() - tail_cap;
445 tail.drain(..drop);
446 }
447 }
448 }
449 if truncated {
450 if tail.len() > tail_cap {
451 let drop = tail.len() - tail_cap;
452 tail.drain(..drop);
453 }
454 let omitted_note = format!("\n\n[... output truncated; limit is {cap} bytes ...]\n\n");
455 head.extend_from_slice(omitted_note.as_bytes());
456 head.extend_from_slice(&tail);
457 }
458 Ok(Collected {
459 data: head,
460 truncated,
461 })
462}
463
464#[cfg(test)]
465mod tests {
466 use super::*;
467
468 #[test]
469 fn tilde_expansion() {
470 assert_eq!(expand_tilde("~", "/root"), "/root");
471 assert_eq!(expand_tilde("~/a/b", "/root"), "/root/a/b");
472 assert_eq!(expand_tilde("/x", "/root"), "/x");
473 assert_eq!(expand_tilde("rel", "/root"), "rel");
474 }
475
476 #[test]
477 fn normalizes_paths() {
478 assert_eq!(normalize("/a/b/../c/./d"), "/a/c/d");
479 assert_eq!(normalize("/../x"), "/x");
480 assert_eq!(normalize("/"), "/");
481 }
482
483 #[tokio::test]
484 async fn bounded_collection_keeps_head_and_tail() {
485 let data: Vec<u8> = (0..10_000u32).map(|i| b'a' + (i % 26) as u8).collect();
486 let c = collect_bounded(&data[..], 300).await.unwrap();
487 assert!(c.truncated);
488 assert!(c.data.starts_with(b"abcdefghij"));
489 assert!(c.data.ends_with(&data[data.len() - 100..]));
490 let c = collect_bounded(&b"tiny"[..], 300).await.unwrap();
491 assert!(!c.truncated);
492 assert_eq!(c.data, b"tiny");
493 }
494}