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