1use crate::file_entry::{
2 FileEntryType, RemoteFileEntry, format_permissions, format_unix_timestamp,
3};
4use anyhow::Result;
5use russh::*;
6use russh_keys::PublicKeyBase64;
7use russh_keys::*;
8use russh_sftp::client::SftpSession;
9use serde::{Deserialize, Serialize};
10use std::path::Path;
11use std::sync::Arc;
12use std::time::Duration;
13use tokio::io::{AsyncReadExt, AsyncWriteExt};
14use tokio::sync::mpsc;
15use tokio_util::sync::CancellationToken;
16
17pub mod host_keys;
18pub mod shell;
19pub mod tunnel;
20pub use host_keys::{
21 HostKeyMismatch, HostKeyStore, HostKeyStoreAccessError, HostKeyVerificationFailure, Verdict,
22 VerificationFailureSlot,
23};
24pub use tunnel::{SshTunnel, SshTunnelRef};
25
26pub const SFTP_CHUNK_SIZE: usize = crate::file_entry::FILE_TRANSFER_CHUNK_SIZE;
31
32pub static PREFERRED_HOST_KEY_ALGOS: &[russh_keys::key::Name] = &[
38 russh_keys::key::ED25519,
39 russh_keys::key::ECDSA_SHA2_NISTP256,
40 russh_keys::key::ECDSA_SHA2_NISTP521,
41 russh_keys::key::RSA_SHA2_256,
42 russh_keys::key::RSA_SHA2_512,
43 russh_keys::key::SSH_RSA,
44];
45
46pub static PREFERRED_KEX_ALGOS: &[russh::kex::Name] = &[
54 russh::kex::CURVE25519,
55 russh::kex::CURVE25519_PRE_RFC_8731,
56 russh::kex::DH_G16_SHA512,
57 russh::kex::DH_G14_SHA256,
58 russh::kex::DH_G14_SHA1,
59 russh::kex::DH_G1_SHA1,
60 russh::kex::EXTENSION_SUPPORT_AS_CLIENT,
63 russh::kex::EXTENSION_OPENSSH_STRICT_KEX_AS_CLIENT,
64];
65
66#[derive(Clone, Serialize, Deserialize)]
67pub struct SshConfig {
68 pub host: String,
69 pub port: u16,
70 pub username: String,
71 pub auth_method: AuthMethod,
72}
73
74impl std::fmt::Debug for SshConfig {
75 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76 f.debug_struct("SshConfig")
77 .field("host", &self.host)
78 .field("port", &self.port)
79 .field("username", &self.username)
80 .field("auth_method", &self.auth_method)
81 .finish()
82 }
83}
84
85#[derive(Clone, Serialize, Deserialize)]
86#[serde(tag = "type")]
87pub enum AuthMethod {
88 Password {
89 password: String,
90 },
91 PublicKey {
92 key_path: String,
93 passphrase: Option<String>,
94 },
95 Agent {
96 identity_hint: Option<String>,
97 },
98}
99
100impl std::fmt::Debug for AuthMethod {
101 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102 match self {
103 AuthMethod::Password { .. } => f
104 .debug_struct("AuthMethod::Password")
105 .field("password", &"<redacted>")
106 .finish(),
107 AuthMethod::PublicKey {
108 key_path,
109 passphrase,
110 } => f
111 .debug_struct("AuthMethod::PublicKey")
112 .field("key_path", key_path)
113 .field(
114 "passphrase",
115 &passphrase
116 .as_ref()
117 .map(|_| "<redacted>")
118 .unwrap_or("<none>"),
119 )
120 .finish(),
121 AuthMethod::Agent { identity_hint } => f
122 .debug_struct("AuthMethod::Agent")
123 .field("identity_hint", identity_hint)
124 .finish(),
125 }
126 }
127}
128
129pub struct SshClient {
130 session: Option<Arc<client::Handle<Client>>>,
131 host_keys: Arc<HostKeyStore>,
132 sftp: tokio::sync::OnceCell<Arc<SftpSession>>,
136}
137
138#[derive(Debug, Clone, Default)]
142pub struct CommandOutput {
143 pub stdout: String,
144 pub stderr: String,
145 pub exit_code: Option<u32>,
146}
147
148impl CommandOutput {
149 pub fn is_success(&self) -> bool {
151 matches!(self.exit_code, Some(0))
152 }
153
154 pub fn combined(&self) -> String {
158 if self.stderr.is_empty() {
159 self.stdout.clone()
160 } else if self.stdout.is_empty() {
161 self.stderr.clone()
162 } else {
163 let mut out = String::with_capacity(self.stdout.len() + self.stderr.len() + 1);
164 out.push_str(&self.stdout);
165 if !self.stdout.ends_with('\n') {
166 out.push('\n');
167 }
168 out.push_str(&self.stderr);
169 out
170 }
171 }
172}
173
174pub struct PtySession {
176 pub input_tx: mpsc::Sender<Vec<u8>>,
177 pub output_rx: Arc<tokio::sync::Mutex<mpsc::Receiver<Vec<u8>>>>,
178 pub resize_tx: mpsc::Sender<(u32, u32)>,
180 pub cancel: CancellationToken,
183}
184
185pub struct Client {
192 host: String,
193 port: u16,
194 store: Arc<HostKeyStore>,
195 verification_failure_slot: VerificationFailureSlot,
196}
197
198impl Client {
199 pub fn new(
200 host: impl Into<String>,
201 port: u16,
202 store: Arc<HostKeyStore>,
203 ) -> (Self, VerificationFailureSlot) {
204 let slot: VerificationFailureSlot = Arc::new(std::sync::Mutex::new(None));
205 let client = Self {
206 host: host.into(),
207 port,
208 store,
209 verification_failure_slot: slot.clone(),
210 };
211 (client, slot)
212 }
213}
214
215#[async_trait::async_trait]
216impl client::Handler for Client {
217 type Error = russh::Error;
218
219 async fn check_server_key(
220 &mut self,
221 server_public_key: &key::PublicKey,
222 ) -> Result<bool, Self::Error> {
223 match self
224 .store
225 .verify(&self.host, self.port, server_public_key)
226 .await
227 {
228 Ok(Verdict::Known) => {
229 tracing::debug!(
230 "host key for {}:{} matches known_hosts",
231 self.host,
232 self.port
233 );
234 Ok(true)
235 }
236 Ok(Verdict::Unknown) => {
237 tracing::warn!(
238 "TOFU: trusting new host key for {}:{} (fingerprint SHA256:{})",
239 self.host,
240 self.port,
241 server_public_key.fingerprint()
242 );
243 if let Err(e) = self
244 .store
245 .trust(&self.host, self.port, server_public_key)
246 .await
247 {
248 tracing::error!("failed to persist host key: {}", e);
249 if let Ok(mut slot) = self.verification_failure_slot.lock() {
250 *slot = Some(HostKeyVerificationFailure::StoreAccess(
251 HostKeyStoreAccessError {
252 host: self.host.clone(),
253 port: self.port,
254 store_path: self.store.path().to_path_buf(),
255 operation: "write",
256 source: e.to_string(),
257 },
258 ));
259 }
260 return Err(
261 std::io::Error::other("failed to persist trusted SSH host key").into(),
262 );
263 }
264 Ok(true)
265 }
266 Ok(Verdict::Mismatch {
267 expected_fingerprint,
268 got_fingerprint,
269 }) => {
270 tracing::error!(
271 "host key mismatch for {}:{} — expected SHA256:{}, got SHA256:{}",
272 self.host,
273 self.port,
274 expected_fingerprint,
275 got_fingerprint
276 );
277 if let Ok(mut slot) = self.verification_failure_slot.lock() {
278 *slot = Some(HostKeyVerificationFailure::Mismatch(HostKeyMismatch {
279 host: self.host.clone(),
280 port: self.port,
281 expected_fingerprint,
282 got_fingerprint,
283 store_path: self.store.path().to_path_buf(),
284 }));
285 }
286 Ok(false)
287 }
288 Err(e) => {
289 tracing::error!("failed to access host-key store: {}", e);
290 if let Ok(mut slot) = self.verification_failure_slot.lock() {
291 *slot = Some(HostKeyVerificationFailure::StoreAccess(
292 HostKeyStoreAccessError {
293 host: self.host.clone(),
294 port: self.port,
295 store_path: self.store.path().to_path_buf(),
296 operation: "read",
297 source: e.to_string(),
298 },
299 ));
300 }
301 Err(std::io::Error::other("failed to access SSH host-key store").into())
302 }
303 }
304 }
305}
306
307pub(crate) enum ResolvedAuth<'a> {
311 Password {
312 password: &'a str,
313 },
314 Key {
315 key: Box<key::KeyPair>,
316 key_path_hint: Option<&'a str>,
319 },
320 Agent {
321 identity_hint: Option<&'a str>,
322 },
323}
324
325pub(crate) async fn connect_authenticated(
329 host: &str,
330 port: u16,
331 username: &str,
332 auth: ResolvedAuth<'_>,
333 timeout: Duration,
334 host_keys: Arc<HostKeyStore>,
335) -> Result<client::Handle<Client>> {
336 let ssh_config = client::Config {
337 preferred: russh::Preferred {
338 key: PREFERRED_HOST_KEY_ALGOS,
339 kex: PREFERRED_KEX_ALGOS,
340 ..russh::Preferred::DEFAULT
341 },
342 keepalive_interval: Some(Duration::from_secs(60)),
343 keepalive_max: 3,
344 ..client::Config::default()
345 };
346
347 let (handler, verification_failure_slot) = Client::new(host, port, host_keys);
348
349 let mut session = tokio::time::timeout(
350 timeout,
351 client::connect(Arc::new(ssh_config), (host, port), handler),
352 )
353 .await
354 .map_err(|_| {
355 anyhow::anyhow!(
356 "Connection timed out after {}s. Please check the host address and network.",
357 timeout.as_secs()
358 )
359 })?
360 .map_err(|e| {
361 if let Ok(mut guard) = verification_failure_slot.lock()
362 && let Some(failure) = guard.take()
363 {
364 return anyhow::anyhow!(format_verification_failure(&failure));
365 }
366
367 let msg = e.to_string();
374 let looks_like_reset = msg.contains("reset by peer")
375 || msg.contains("ConnectionReset")
376 || msg.contains("kex_exchange_identification");
377 if looks_like_reset {
378 return anyhow::anyhow!(
379 "The SSH server at {}:{} accepted the TCP connection but then \
380 reset it during the handshake ({}).\n\n\
381 This usually means the server is rejecting your source IP \
382 or SSH client via a firewall / access list. Try:\n\
383 - Confirm your public IP is on the server's allowlist (ask \
384 the service operator).\n\
385 - Connect over a VPN that terminates inside the allowed \
386 network.\n\
387 - Verify the host and port are correct for external access \
388 (some services publish a different SFTP endpoint).",
389 host,
390 port,
391 e
392 );
393 }
394
395 anyhow::anyhow!("Failed to connect to {}:{}: {}", host, port, e)
396 })?;
397
398 let key_hint_for_error = match &auth {
401 ResolvedAuth::Password { .. } => None,
402 ResolvedAuth::Key { key_path_hint, .. } => key_path_hint.map(String::from),
403 ResolvedAuth::Agent { identity_hint } => Some(
404 identity_hint
405 .filter(|hint| !hint.is_empty())
406 .unwrap_or("SSH agent")
407 .to_string(),
408 ),
409 };
410
411 let authenticated = match auth {
412 ResolvedAuth::Password { password } => session
413 .authenticate_password(username, password)
414 .await
415 .map_err(|e| anyhow::anyhow!("Password authentication failed: {}", e))?,
416 ResolvedAuth::Key { key, .. } => session
417 .authenticate_publickey(username, Arc::new(*key))
418 .await
419 .map_err(|e| {
420 anyhow::anyhow!(
421 "Public key authentication failed: {}. The key may not be authorized on the server.",
422 e
423 )
424 })?,
425 ResolvedAuth::Agent { identity_hint } => {
426 let mut agent = russh_keys::agent::client::AgentClient::connect_env()
427 .await
428 .map_err(|e| {
429 anyhow::anyhow!(
430 "SSH agent authentication is enabled, but r-shell could not connect to SSH_AUTH_SOCK: {}",
431 e
432 )
433 })?;
434 let identities = agent.request_identities().await.map_err(|e| {
435 anyhow::anyhow!("SSH agent did not return identities: {}", e)
436 })?;
437 let key = select_agent_identity(identities, identity_hint).ok_or_else(|| {
438 if let Some(hint) = identity_hint.filter(|hint| !hint.is_empty()) {
439 anyhow::anyhow!(
440 "SSH agent has no identity matching '{}'. Add the key to your agent or clear the identity hint.",
441 hint
442 )
443 } else {
444 anyhow::anyhow!("SSH agent has no identities. Add a key to your agent and try again.")
445 }
446 })?;
447 let (_agent, result) = session.authenticate_future(username.to_string(), key, agent).await;
448 result.map_err(|e| anyhow::anyhow!("SSH agent authentication failed: {}", e))?
449 }
450 };
451
452 if !authenticated {
453 return Err(match key_hint_for_error {
454 None => anyhow::anyhow!(
455 "Authentication failed for {}@{} with password authentication.",
456 username,
457 host
458 ),
459 Some(path) => anyhow::anyhow!(
460 "Authentication failed for {}@{} using public key {}.",
461 username,
462 host,
463 path
464 ),
465 });
466 }
467
468 Ok(session)
469}
470
471fn select_agent_identity(
472 identities: Vec<key::PublicKey>,
473 identity_hint: Option<&str>,
474) -> Option<key::PublicKey> {
475 let hint = identity_hint.map(str::trim).filter(|hint| !hint.is_empty());
476
477 match hint {
478 None => identities.into_iter().next(),
479 Some(hint) => identities.into_iter().find(|identity| {
480 let encoded = identity.public_key_base64();
481 encoded.contains(hint) || hint.contains(&encoded)
482 }),
483 }
484}
485
486pub fn format_mismatch(m: &HostKeyMismatch) -> String {
488 format!(
489 "Host key verification failed for {}:{}.\n\
490 Expected fingerprint (stored): SHA256:{}\n\
491 Offered fingerprint (server): SHA256:{}\n\
492 If the remote host legitimately rotated its key, remove the entry from:\n {}",
493 m.host,
494 m.port,
495 m.expected_fingerprint,
496 m.got_fingerprint,
497 m.store_path.display()
498 )
499}
500
501fn format_store_access_error(err: &HostKeyStoreAccessError) -> String {
502 format!(
503 "Host key verification could not complete for {}:{}.\n\
504 r-shell could not {} the trusted host-key store at:\n {}\n\
505 Underlying error: {}\n\
506 Connection refused to avoid trusting a host key without a durable trust store.",
507 err.host,
508 err.port,
509 err.operation,
510 err.store_path.display(),
511 err.source
512 )
513}
514
515pub fn format_verification_failure(failure: &HostKeyVerificationFailure) -> String {
516 match failure {
517 HostKeyVerificationFailure::Mismatch(mismatch) => format_mismatch(mismatch),
518 HostKeyVerificationFailure::StoreAccess(err) => format_store_access_error(err),
519 }
520}
521
522pub(crate) fn expand_home_path(path: &str) -> Option<String> {
527 if let Some(rest) = path.strip_prefix("~/") {
528 let home = dirs::home_dir()?;
529 Some(home.join(rest).to_string_lossy().into_owned())
530 } else if path == "~" {
531 dirs::home_dir().map(|h| h.to_string_lossy().into_owned())
532 } else {
533 Some(path.to_string())
534 }
535}
536
537pub(crate) fn load_private_key(key_path: &str, passphrase: Option<&str>) -> Result<key::KeyPair> {
538 let expanded = expand_home_path(key_path).ok_or_else(|| {
539 anyhow::anyhow!(
540 "Cannot resolve '~' in SSH key path '{}': home directory unknown.",
541 key_path
542 )
543 })?;
544 let path = Path::new(&expanded);
545
546 let location = if expanded != key_path {
549 format!("{} (expanded from {})", expanded, key_path)
550 } else {
551 expanded.clone()
552 };
553
554 match path.metadata() {
558 Ok(_) => {}
559 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
560 return Err(anyhow::anyhow!(
561 "SSH key file not found: {}. Please check the file path and try again.",
562 location
563 ));
564 }
565 Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
566 return Err(anyhow::anyhow!(
567 "Permission denied reading SSH key at {}.\n\
568 On macOS this usually means r-shell hasn't been granted access to this file. \
569 Open System Settings → Privacy & Security → Full Disk Access (or App Management / Files and Folders), \
570 add r-shell to the list, then try again.",
571 location
572 ));
573 }
574 Err(e) => {
575 return Err(anyhow::anyhow!(
576 "Cannot access SSH key at {}: {}",
577 location,
578 e
579 ));
580 }
581 }
582
583 load_secret_key(path, passphrase).map_err(|e| {
584 let msg = e.to_string();
585 if msg.contains("encrypted") || msg.contains("passphrase") {
586 anyhow::anyhow!(
587 "Failed to decrypt SSH key at {}. The key may be encrypted. Please provide the correct passphrase.",
588 expanded
589 )
590 } else {
591 anyhow::anyhow!(
592 "Failed to load SSH key from {}: {}. Ensure the file is a valid SSH private key (RSA, Ed25519, or ECDSA).",
593 expanded, e
594 )
595 }
596 })
597}
598
599impl SshClient {
600 pub fn new(host_keys: Arc<HostKeyStore>) -> Self {
601 Self {
602 session: None,
603 host_keys,
604 sftp: tokio::sync::OnceCell::new(),
605 }
606 }
607
608 async fn sftp_session(&self) -> Result<Arc<SftpSession>> {
612 let session = self
613 .session
614 .as_ref()
615 .ok_or_else(|| anyhow::anyhow!("Not connected"))?
616 .clone();
617 let sftp = self
618 .sftp
619 .get_or_try_init(|| async move {
620 let channel = session.channel_open_session().await?;
621 channel.request_subsystem(true, "sftp").await?;
622 let session = SftpSession::new(channel.into_stream()).await?;
623 Ok::<_, anyhow::Error>(Arc::new(session))
624 })
625 .await?;
626 Ok(sftp.clone())
627 }
628
629 pub async fn connect(&mut self, config: &SshConfig) -> Result<()> {
630 let auth = match &config.auth_method {
631 AuthMethod::Password { password } => ResolvedAuth::Password { password },
632 AuthMethod::PublicKey {
633 key_path,
634 passphrase,
635 } => ResolvedAuth::Key {
636 key: Box::new(load_private_key(key_path, passphrase.as_deref())?),
637 key_path_hint: Some(key_path),
638 },
639 AuthMethod::Agent { identity_hint } => ResolvedAuth::Agent {
640 identity_hint: identity_hint.as_deref(),
641 },
642 };
643
644 let session = connect_authenticated(
645 &config.host,
646 config.port,
647 &config.username,
648 auth,
649 Duration::from_secs(10),
650 self.host_keys.clone(),
651 )
652 .await?;
653
654 self.session = Some(Arc::new(session));
655 Ok(())
656 }
657
658 pub async fn execute_command(&self, command: &str) -> Result<String> {
667 let out = self.execute_command_full(command).await?;
668 Ok(out.combined())
669 }
670
671 pub async fn execute_command_full(&self, command: &str) -> Result<CommandOutput> {
673 let Some(session) = &self.session else {
674 return Err(anyhow::anyhow!("Not connected"));
675 };
676
677 let mut channel = session.channel_open_session().await?;
678 channel.exec(true, command).await?;
679
680 let mut stdout = String::new();
681 let mut stderr = String::new();
682 let mut exit_code: Option<u32> = None;
683 let mut eof_received = false;
684
685 loop {
686 let msg = channel.wait().await;
687 match msg {
688 Some(ChannelMsg::Data { ref data }) => {
689 stdout.push_str(&String::from_utf8_lossy(data));
690 }
691 Some(ChannelMsg::ExtendedData { ref data, .. }) => {
692 stderr.push_str(&String::from_utf8_lossy(data));
697 }
698 Some(ChannelMsg::ExitStatus { exit_status }) => {
699 exit_code = Some(exit_status);
700 if eof_received {
701 break;
702 }
703 }
704 Some(ChannelMsg::Eof) => {
705 eof_received = true;
706 if exit_code.is_some() {
707 break;
708 }
709 }
710 Some(ChannelMsg::Close) | None => {
711 break;
712 }
713 _ => {}
714 }
715 }
716
717 Ok(CommandOutput {
718 stdout,
719 stderr,
720 exit_code,
721 })
722 }
723
724 pub async fn execute_command_streaming(
735 &self,
736 command: &str,
737 ) -> Result<(mpsc::Receiver<String>, CancellationToken)> {
738 let Some(session) = &self.session else {
739 return Err(anyhow::anyhow!("Not connected"));
740 };
741
742 let mut channel = session.channel_open_session().await?;
743 channel.exec(true, command).await?;
744
745 let (tx, rx) = mpsc::channel::<String>(256);
746 let cancel = CancellationToken::new();
747 let cancel_task = cancel.clone();
748
749 tokio::spawn(async move {
750 let mut stdout_buf = String::new();
752 let mut stderr_buf = String::new();
753 loop {
754 tokio::select! {
755 _ = cancel_task.cancelled() => {
756 let _ = channel.eof().await;
757 let _ = channel.close().await;
758 break;
759 }
760 msg = channel.wait() => {
761 match msg {
762 Some(ChannelMsg::Data { ref data }) => {
763 stdout_buf.push_str(&String::from_utf8_lossy(data));
764 while let Some(idx) = stdout_buf.find('\n') {
765 let line: String = stdout_buf.drain(..=idx).collect();
766 let trimmed = line.trim_end_matches(['\r', '\n']).to_string();
767 if tx.send(trimmed).await.is_err() {
768 cancel_task.cancel();
769 break;
770 }
771 }
772 }
773 Some(ChannelMsg::ExtendedData { ref data, .. }) => {
774 stderr_buf.push_str(&String::from_utf8_lossy(data));
775 while let Some(idx) = stderr_buf.find('\n') {
776 let line: String = stderr_buf.drain(..=idx).collect();
777 let trimmed = line.trim_end_matches(['\r', '\n']).to_string();
778 if tx.send(format!("!{}", trimmed)).await.is_err() {
779 cancel_task.cancel();
780 break;
781 }
782 }
783 }
784 Some(ChannelMsg::Eof) | Some(ChannelMsg::Close) | None => {
785 if !stdout_buf.is_empty() {
786 let _ = tx.send(stdout_buf.trim_end_matches(['\r', '\n']).to_string()).await;
787 }
788 if !stderr_buf.is_empty() {
789 let _ = tx.send(format!("!{}", stderr_buf.trim_end_matches(['\r', '\n']))).await;
790 }
791 break;
792 }
793 _ => {}
794 }
795 }
796 }
797 }
798 });
799
800 Ok((rx, cancel))
801 }
802
803 pub async fn disconnect(&mut self) -> Result<()> {
804 self.sftp.take();
807
808 if let Some(session) = self.session.take() {
809 match Arc::try_unwrap(session) {
810 Ok(session) => {
811 if let Err(e) = session.disconnect(Disconnect::ByApplication, "", "").await {
812 tracing::warn!("SSH disconnect failed cleanly: {}", e);
813 }
814 }
815 Err(arc_session) => {
816 tracing::debug!("SSH disconnect: other refs still alive, dropping handle");
820 drop(arc_session);
821 }
822 }
823 }
824 Ok(())
825 }
826
827 pub async fn open_direct_tcpip(
836 &self,
837 host: &str,
838 port: u16,
839 ) -> Result<russh::Channel<russh::client::Msg>> {
840 let Some(session) = &self.session else {
841 return Err(anyhow::anyhow!("Not connected"));
842 };
843 let channel = session
844 .channel_open_direct_tcpip(host.to_string(), port as u32, "127.0.0.1", 0)
845 .await?;
846 Ok(channel)
847 }
848
849 pub async fn create_pty_session(&self, cols: u32, rows: u32) -> Result<PtySession> {
852 if let Some(session) = &self.session {
853 let mut channel = session.channel_open_session().await?;
855
856 channel
859 .request_pty(
860 true, "xterm-256color", cols, rows, 0, 0, &[], )
868 .await?;
869
870 channel.request_shell(true).await?;
872
873 let (input_tx, mut input_rx) = mpsc::channel::<Vec<u8>>(1000); let (output_tx, output_rx) = mpsc::channel::<Vec<u8>>(2000); let input_channel = channel.make_writer();
880
881 let (resize_tx, mut resize_rx) = mpsc::channel::<(u32, u32)>(16);
883
884 let cancel = CancellationToken::new();
888
889 let input_cancel = cancel.clone();
893 tokio::spawn(async move {
894 let mut writer = input_channel;
895 loop {
896 tokio::select! {
897 biased;
898 _ = input_cancel.cancelled() => {
899 tracing::debug!("[PTY] input task cancelled");
900 break;
901 }
902 maybe_data = input_rx.recv() => {
903 let Some(data) = maybe_data else {
904 break;
906 };
907 if let Err(e) = writer.write_all(&data).await {
908 tracing::error!("[PTY] failed to send data to SSH: {}", e);
909 break;
910 }
911 if let Err(e) = writer.flush().await {
912 tracing::error!("[PTY] failed to flush data to SSH: {}", e);
913 break;
914 }
915 }
916 }
917 }
918 });
919
920 let output_cancel = cancel.clone();
928 tokio::spawn(async move {
929 loop {
930 tokio::select! {
931 biased;
932 _ = output_cancel.cancelled() => {
933 tracing::debug!("[PTY] output task cancelled");
934 break;
935 }
936 msg = channel.wait() => {
937 match msg {
938 Some(ChannelMsg::Data { data })
939 if output_tx.send(data.to_vec()).await.is_err() =>
940 {
941 break;
942 }
943 Some(ChannelMsg::ExtendedData { data, .. })
944 if output_tx.send(data.to_vec()).await.is_err() =>
945 {
946 break;
948 }
949 Some(ChannelMsg::Eof) | Some(ChannelMsg::Close) | None => {
950 tracing::debug!("[PTY] channel closed");
951 break;
952 }
953 Some(ChannelMsg::ExitStatus { exit_status }) => {
954 tracing::info!("[PTY] process exited with status: {}", exit_status);
955 }
956 _ => {}
957 }
958 }
959 resize = resize_rx.recv() => {
960 match resize {
961 Some((cols, rows)) => {
962 if let Err(e) = channel.window_change(cols, rows, 0, 0).await {
963 tracing::error!("[PTY] failed to send window change: {}", e);
964 } else {
965 tracing::debug!("[PTY] window changed to {}x{}", cols, rows);
966 }
967 }
968 None => {
969 break;
971 }
972 }
973 }
974 }
975 }
976 });
977
978 Ok(PtySession {
979 input_tx,
980 output_rx: Arc::new(tokio::sync::Mutex::new(output_rx)),
981 resize_tx,
982 cancel,
983 })
984 } else {
985 Err(anyhow::anyhow!("Not connected"))
986 }
987 }
988
989 pub async fn list_dir(&self, path: &str) -> Result<Vec<RemoteFileEntry>> {
990 let sftp = self.sftp_session().await?;
991 let entries = sftp
992 .read_dir(path)
993 .await
994 .map_err(|e| anyhow::anyhow!("Failed to list directory '{}': {}", path, e))?;
995
996 let mut result = Vec::new();
997 for entry in entries {
998 let name = entry.file_name();
999 if name == "." || name == ".." {
1000 continue;
1001 }
1002
1003 let attrs = entry.metadata();
1004 let size = attrs.size.unwrap_or(0);
1005 let mtime_secs = attrs.mtime.map(|t| t as i64);
1006 let modified = mtime_secs.map(format_unix_timestamp);
1007 let permissions = attrs.permissions.map(format_permissions);
1008 let owner = attrs.uid.map(|u| u.to_string());
1009 let group = attrs.gid.map(|g| g.to_string());
1010
1011 let file_type = if attrs.is_dir() {
1012 FileEntryType::Directory
1013 } else if attrs.is_symlink() {
1014 FileEntryType::Symlink
1015 } else {
1016 FileEntryType::File
1017 };
1018
1019 result.push(RemoteFileEntry {
1020 name,
1021 size,
1022 modified,
1023 modified_unix: mtime_secs,
1024 permissions,
1025 owner,
1026 group,
1027 file_type,
1028 });
1029 }
1030
1031 result.sort_by(|a, b| {
1032 let a_is_dir = matches!(a.file_type, FileEntryType::Directory);
1033 let b_is_dir = matches!(b.file_type, FileEntryType::Directory);
1034 b_is_dir
1035 .cmp(&a_is_dir)
1036 .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
1037 });
1038
1039 Ok(result)
1040 }
1041
1042 pub async fn download_file(&self, remote_path: &str, local_path: &str) -> Result<u64> {
1043 self.download_file_with_progress(remote_path, local_path, |_| {}, None)
1044 .await
1045 }
1046
1047 pub async fn download_file_with_progress(
1063 &self,
1064 remote_path: &str,
1065 local_path: &str,
1066 mut progress: impl FnMut(u64),
1067 cancel: Option<&CancellationToken>,
1068 ) -> Result<u64> {
1069 let sftp = self.sftp_session().await?;
1070 let mut remote_file = sftp.open(remote_path).await?;
1071 let mut local_file = tokio::fs::File::create(local_path).await?;
1072
1073 let mut buf = vec![0u8; SFTP_CHUNK_SIZE];
1074 let mut total_bytes = 0u64;
1075 loop {
1076 if let Some(token) = cancel
1077 && token.is_cancelled()
1078 {
1079 return Err(anyhow::anyhow!("Transfer cancelled"));
1080 }
1081 let n = remote_file.read(&mut buf).await?;
1082 if n == 0 {
1083 break;
1084 }
1085 local_file.write_all(&buf[..n]).await?;
1086 total_bytes += n as u64;
1087 progress(total_bytes);
1088 }
1089 local_file.flush().await?;
1090
1091 Ok(total_bytes)
1092 }
1093
1094 pub async fn download_file_to_memory(&self, remote_path: &str) -> Result<Vec<u8>> {
1095 let sftp = self.sftp_session().await?;
1096 let mut remote_file = sftp.open(remote_path).await?;
1097
1098 let mut buffer = Vec::new();
1099 let mut temp_buf = vec![0u8; SFTP_CHUNK_SIZE];
1100 loop {
1101 let n = remote_file.read(&mut temp_buf).await?;
1102 if n == 0 {
1103 break;
1104 }
1105 buffer.extend_from_slice(&temp_buf[..n]);
1106 }
1107 Ok(buffer)
1108 }
1109
1110 pub async fn upload_file(&self, local_path: &str, remote_path: &str) -> Result<u64> {
1111 self.upload_file_with_progress(local_path, remote_path, |_| {}, None)
1112 .await
1113 }
1114
1115 pub async fn upload_file_with_progress(
1123 &self,
1124 local_path: &str,
1125 remote_path: &str,
1126 mut progress: impl FnMut(u64),
1127 cancel: Option<&CancellationToken>,
1128 ) -> Result<u64> {
1129 let sftp = self.sftp_session().await?;
1130 let mut local_file = tokio::fs::File::open(local_path).await?;
1131 let mut remote_file = sftp.create(remote_path).await?;
1132
1133 let mut buf = vec![0u8; SFTP_CHUNK_SIZE];
1134 let mut total_bytes = 0u64;
1135 loop {
1136 if let Some(token) = cancel
1137 && token.is_cancelled()
1138 {
1139 return Err(anyhow::anyhow!("Transfer cancelled"));
1140 }
1141 let n = local_file.read(&mut buf).await?;
1142 if n == 0 {
1143 break;
1144 }
1145 remote_file.write_all(&buf[..n]).await?;
1146 total_bytes += n as u64;
1147 progress(total_bytes);
1148 }
1149 remote_file.flush().await?;
1150
1151 Ok(total_bytes)
1152 }
1153
1154 pub async fn upload_file_from_bytes(&self, data: &[u8], remote_path: &str) -> Result<u64> {
1155 let sftp = self.sftp_session().await?;
1156 let mut remote_file = sftp.create(remote_path).await?;
1157
1158 for chunk in data.chunks(SFTP_CHUNK_SIZE) {
1159 remote_file.write_all(chunk).await?;
1160 }
1161 remote_file.flush().await?;
1162
1163 Ok(data.len() as u64)
1164 }
1165
1166 pub async fn create_dir(&self, path: &str) -> Result<()> {
1169 let sftp = self.sftp_session().await?;
1170 sftp.create_dir(path)
1171 .await
1172 .map_err(|e| anyhow::anyhow!("Failed to create directory '{}': {}", path, e))?;
1173 Ok(())
1174 }
1175
1176 pub async fn rename(&self, old_path: &str, new_path: &str) -> Result<()> {
1180 let sftp = self.sftp_session().await?;
1181 sftp.rename(old_path, new_path).await.map_err(|e| {
1182 anyhow::anyhow!("Failed to rename '{}' to '{}': {}", old_path, new_path, e)
1183 })?;
1184 Ok(())
1185 }
1186
1187 pub async fn delete_file(&self, path: &str) -> Result<()> {
1189 let sftp = self.sftp_session().await?;
1190 sftp.remove_file(path)
1191 .await
1192 .map_err(|e| anyhow::anyhow!("Failed to delete file '{}': {}", path, e))?;
1193 Ok(())
1194 }
1195
1196 pub async fn delete_dir(&self, path: &str) -> Result<()> {
1200 let sftp = self.sftp_session().await?;
1201 sftp.remove_dir(path)
1202 .await
1203 .map_err(|e| anyhow::anyhow!("Failed to delete directory '{}': {}", path, e))?;
1204 Ok(())
1205 }
1206}
1207
1208#[cfg(test)]
1209mod expand_home_tests {
1210 use super::expand_home_path;
1211
1212 #[test]
1213 fn returns_non_tilde_paths_unchanged() {
1214 assert_eq!(
1215 expand_home_path("/absolute/path").as_deref(),
1216 Some("/absolute/path")
1217 );
1218 assert_eq!(
1219 expand_home_path("relative/dir").as_deref(),
1220 Some("relative/dir")
1221 );
1222 assert_eq!(expand_home_path("").as_deref(), Some(""));
1223 }
1224
1225 #[test]
1226 fn expands_tilde_slash_prefix_when_home_is_known() {
1227 let expanded = expand_home_path("~/.ssh/id_rsa");
1230 if let Some(result) = expanded {
1233 assert!(
1234 !result.starts_with("~/"),
1235 "tilde must be expanded: {}",
1236 result
1237 );
1238 assert!(
1239 result.ends_with("/.ssh/id_rsa"),
1240 "suffix preserved: {}",
1241 result
1242 );
1243 }
1244 }
1245}
1246
1247#[cfg(test)]
1248mod command_output_tests {
1249 use super::CommandOutput;
1250
1251 #[test]
1252 fn is_success_requires_zero_exit() {
1253 assert!(
1254 CommandOutput {
1255 stdout: "x".into(),
1256 stderr: "".into(),
1257 exit_code: Some(0),
1258 }
1259 .is_success()
1260 );
1261 assert!(
1262 !CommandOutput {
1263 stdout: "x".into(),
1264 stderr: "".into(),
1265 exit_code: Some(1),
1266 }
1267 .is_success()
1268 );
1269 assert!(
1270 !CommandOutput {
1271 stdout: "x".into(),
1272 stderr: "".into(),
1273 exit_code: None,
1274 }
1275 .is_success()
1276 );
1277 }
1278
1279 #[test]
1280 fn combined_merges_streams_with_separator() {
1281 let c = CommandOutput {
1282 stdout: "out".into(),
1283 stderr: "err".into(),
1284 exit_code: Some(0),
1285 };
1286 assert_eq!(c.combined(), "out\nerr");
1287 }
1288
1289 #[test]
1290 fn combined_preserves_trailing_newline() {
1291 let c = CommandOutput {
1292 stdout: "out\n".into(),
1293 stderr: "err".into(),
1294 exit_code: Some(0),
1295 };
1296 assert_eq!(c.combined(), "out\nerr");
1297 }
1298
1299 #[test]
1300 fn combined_returns_single_stream_when_other_empty() {
1301 assert_eq!(
1302 CommandOutput {
1303 stdout: "only".into(),
1304 stderr: "".into(),
1305 exit_code: Some(0),
1306 }
1307 .combined(),
1308 "only"
1309 );
1310 assert_eq!(
1311 CommandOutput {
1312 stdout: "".into(),
1313 stderr: "only-err".into(),
1314 exit_code: Some(1),
1315 }
1316 .combined(),
1317 "only-err"
1318 );
1319 }
1320}
1321
1322#[cfg(test)]
1323mod redaction_tests {
1324 use super::{AuthMethod, SshConfig};
1325
1326 #[test]
1327 fn debug_redacts_password() {
1328 let cfg = SshConfig {
1329 host: "h".into(),
1330 port: 22,
1331 username: "u".into(),
1332 auth_method: AuthMethod::Password {
1333 password: "super-secret-123".into(),
1334 },
1335 };
1336 let rendered = format!("{:?}", cfg);
1337 assert!(
1338 !rendered.contains("super-secret-123"),
1339 "password must not appear in Debug output: {}",
1340 rendered
1341 );
1342 assert!(rendered.contains("<redacted>"), "expected redaction marker");
1343 }
1344
1345 #[test]
1346 fn debug_redacts_passphrase() {
1347 let m = AuthMethod::PublicKey {
1348 key_path: "/tmp/id".into(),
1349 passphrase: Some("xyz-passphrase".into()),
1350 };
1351 let rendered = format!("{:?}", m);
1352 assert!(!rendered.contains("xyz-passphrase"));
1353 assert!(rendered.contains("<redacted>"));
1354 assert!(rendered.contains("/tmp/id"));
1355 }
1356
1357 #[test]
1358 fn debug_shows_none_when_no_passphrase() {
1359 let m = AuthMethod::PublicKey {
1360 key_path: "/tmp/id".into(),
1361 passphrase: None,
1362 };
1363 let rendered = format!("{:?}", m);
1364 assert!(rendered.contains("<none>"));
1365 }
1366}
1367
1368#[cfg(test)]
1369mod key_loading_tests {
1370 use super::load_private_key;
1371 use std::io::Write;
1372 use tempfile::NamedTempFile;
1373
1374 const TEST_OPENSSH_PRIVATE_KEY: &str = "\
1375-----BEGIN OPENSSH PRIVATE KEY-----\n\
1376b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\n\
1377QyNTUxOQAAACCzPq7zfqLffKoBDe/eo04kH2XxtSmk9D7RQyf1xUqrYgAAAJgAIAxdACAM\n\
1378XQAAAAtzc2gtZWQyNTUxOQAAACCzPq7zfqLffKoBDe/eo04kH2XxtSmk9D7RQyf1xUqrYg\n\
1379AAAEC2BsIi0QwW2uFscKTUUXNHLsYX4FxlaSDSblbAj7WR7bM+rvN+ot98qgEN796jTiQf\n\
1380ZfG1KaT0PtFDJ/XFSqtiAAAAEHVzZXJAZXhhbXBsZS5jb20BAgMEBQ==\n\
1381-----END OPENSSH PRIVATE KEY-----\n";
1382
1383 #[test]
1384 fn load_private_key_reads_key_file_contents() {
1385 let mut key_file = NamedTempFile::new().expect("failed to create temp key file");
1386 key_file
1387 .write_all(TEST_OPENSSH_PRIVATE_KEY.as_bytes())
1388 .expect("failed to write temp key file");
1389
1390 let key = load_private_key(
1391 key_file
1392 .path()
1393 .to_str()
1394 .expect("temp key path must be valid UTF-8"),
1395 None,
1396 )
1397 .expect("expected key file to load successfully");
1398
1399 assert_eq!(key.name(), "ssh-ed25519");
1400 }
1401}
1402
1403#[cfg(test)]
1404mod tests;