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 = Arc::new(*key);
442 let hash_algs: &[Option<HashAlg>] =
443 if matches!(key.algorithm(), russh::keys::Algorithm::Rsa { .. }) {
444 &[Some(HashAlg::Sha512), Some(HashAlg::Sha256), None]
445 } else {
446 &[None]
447 };
448
449 let mut authenticated = false;
450 for hash_alg in hash_algs.iter().copied() {
451 let key_with_alg =
452 russh::keys::key::PrivateKeyWithHashAlg::new(key.clone(), hash_alg);
453 let auth = session
454 .authenticate_publickey(username, key_with_alg)
455 .await
456 .map_err(|e| {
457 anyhow::anyhow!(
458 "Public key authentication failed: {}. The key may not be authorized on the server.",
459 e
460 )
461 })?;
462 if auth.success() {
463 authenticated = true;
464 break;
465 }
466 }
467 authenticated
468 }
469 ResolvedAuth::Agent { identity_hint } => {
470 let mut agent = russh::keys::agent::client::AgentClient::connect_env()
474 .await
475 .map_err(|e| {
476 anyhow::anyhow!(
477 "SSH agent authentication is enabled, but r-shell could not connect to SSH_AUTH_SOCK: {}",
478 e
479 )
480 })?;
481 let identities = agent
482 .request_identities()
483 .await
484 .map_err(|e| anyhow::anyhow!("SSH agent did not return identities: {}", e))?;
485 let identity = select_agent_identity(identities, identity_hint).ok_or_else(|| {
486 if let Some(hint) = identity_hint.filter(|hint| !hint.is_empty()) {
487 anyhow::anyhow!(
488 "SSH agent has no identity matching '{}'. Add the key to your agent or clear the identity hint.",
489 hint
490 )
491 } else {
492 anyhow::anyhow!("SSH agent has no identities. Add a key to your agent and try again.")
493 }
494 })?;
495 let public_key = identity.public_key().into_owned();
497 session
498 .authenticate_publickey_with(username, public_key, None, &mut agent)
499 .await
500 .map_err(|e| anyhow::anyhow!("SSH agent authentication failed: {}", e))?
501 .success()
502 }
503 };
504
505 if !authenticated {
506 return Err(match key_hint_for_error {
507 None => anyhow::anyhow!(
508 "Authentication failed for {}@{} with password authentication.",
509 username,
510 host
511 ),
512 Some(path) => anyhow::anyhow!(
513 "Authentication failed for {}@{} using public key {}.",
514 username,
515 host,
516 path
517 ),
518 });
519 }
520
521 Ok(session)
522}
523
524fn select_agent_identity(
531 identities: Vec<russh::keys::agent::AgentIdentity>,
532 identity_hint: Option<&str>,
533) -> Option<russh::keys::agent::AgentIdentity> {
534 let hint = identity_hint.map(str::trim).filter(|hint| !hint.is_empty());
535
536 match hint {
537 None => identities.into_iter().next(),
538 Some(hint) => identities.into_iter().find(|identity| {
539 let encoded = identity.public_key().public_key_base64();
540 encoded.contains(hint) || hint.contains(&encoded)
541 }),
542 }
543}
544
545pub fn format_mismatch(m: &HostKeyMismatch) -> String {
547 format!(
548 "Host key verification failed for {}:{}.\n\
549 Expected fingerprint (stored): SHA256:{}\n\
550 Offered fingerprint (server): SHA256:{}\n\
551 If the remote host legitimately rotated its key, remove the entry from:\n {}",
552 m.host,
553 m.port,
554 m.expected_fingerprint,
555 m.got_fingerprint,
556 m.store_path.display()
557 )
558}
559
560fn format_store_access_error(err: &HostKeyStoreAccessError) -> String {
561 format!(
562 "Host key verification could not complete for {}:{}.\n\
563 r-shell could not {} the trusted host-key store at:\n {}\n\
564 Underlying error: {}\n\
565 Connection refused to avoid trusting a host key without a durable trust store.",
566 err.host,
567 err.port,
568 err.operation,
569 err.store_path.display(),
570 err.source
571 )
572}
573
574pub fn format_verification_failure(failure: &HostKeyVerificationFailure) -> String {
575 match failure {
576 HostKeyVerificationFailure::Mismatch(mismatch) => format_mismatch(mismatch),
577 HostKeyVerificationFailure::StoreAccess(err) => format_store_access_error(err),
578 }
579}
580
581pub(crate) fn expand_home_path(path: &str) -> Option<String> {
586 if let Some(rest) = path.strip_prefix("~/") {
587 let home = dirs::home_dir()?;
588 Some(home.join(rest).to_string_lossy().into_owned())
589 } else if path == "~" {
590 dirs::home_dir().map(|h| h.to_string_lossy().into_owned())
591 } else {
592 Some(path.to_string())
593 }
594}
595
596pub(crate) fn load_private_key(
599 key_path: &str,
600 passphrase: Option<&str>,
601) -> Result<russh::keys::PrivateKey> {
602 let expanded = expand_home_path(key_path).ok_or_else(|| {
603 anyhow::anyhow!(
604 "Cannot resolve '~' in SSH key path '{}': home directory unknown.",
605 key_path
606 )
607 })?;
608 let path = Path::new(&expanded);
609
610 let location = if expanded != key_path {
613 format!("{} (expanded from {})", expanded, key_path)
614 } else {
615 expanded.clone()
616 };
617
618 match path.metadata() {
622 Ok(_) => {}
623 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
624 return Err(anyhow::anyhow!(
625 "SSH key file not found: {}. Please check the file path and try again.",
626 location
627 ));
628 }
629 Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
630 return Err(anyhow::anyhow!(
631 "Permission denied reading SSH key at {}.\n\
632 On macOS this usually means r-shell hasn't been granted access to this file. \
633 Open System Settings → Privacy & Security → Full Disk Access (or App Management / Files and Folders), \
634 add r-shell to the list, then try again.",
635 location
636 ));
637 }
638 Err(e) => {
639 return Err(anyhow::anyhow!(
640 "Cannot access SSH key at {}: {}",
641 location,
642 e
643 ));
644 }
645 }
646
647 russh::keys::load_secret_key(path, passphrase).map_err(|e| {
649 let msg = e.to_string();
650 if msg.contains("encrypted") || msg.contains("passphrase") {
651 anyhow::anyhow!(
652 "Failed to decrypt SSH key at {}. The key may be encrypted. Please provide the correct passphrase.",
653 expanded
654 )
655 } else {
656 anyhow::anyhow!(
657 "Failed to load SSH key from {}: {}. Ensure the file is a valid SSH private key (RSA, Ed25519, or ECDSA).",
658 expanded, e
659 )
660 }
661 })
662}
663
664impl SshClient {
665 pub fn new(host_keys: Arc<HostKeyStore>) -> Self {
666 Self {
667 session: None,
668 host_keys,
669 sftp: tokio::sync::OnceCell::new(),
670 }
671 }
672
673 async fn sftp_session(&self) -> Result<Arc<SftpSession>> {
677 let session = self
678 .session
679 .as_ref()
680 .ok_or_else(|| anyhow::anyhow!("Not connected"))?
681 .clone();
682 let sftp = self
683 .sftp
684 .get_or_try_init(|| async move {
685 let channel = session.channel_open_session().await?;
686 channel.request_subsystem(true, "sftp").await?;
687 let session = SftpSession::new(channel.into_stream()).await?;
688 Ok::<_, anyhow::Error>(Arc::new(session))
689 })
690 .await?;
691 Ok(sftp.clone())
692 }
693
694 pub async fn connect(&mut self, config: &SshConfig) -> Result<()> {
695 let auth = match &config.auth_method {
696 AuthMethod::Password { password } => ResolvedAuth::Password { password },
697 AuthMethod::PublicKey {
698 key_path,
699 passphrase,
700 } => ResolvedAuth::Key {
701 key: Box::new(load_private_key(key_path, passphrase.as_deref())?),
702 key_path_hint: Some(key_path.as_str()),
703 },
704 AuthMethod::Agent { identity_hint } => ResolvedAuth::Agent {
705 identity_hint: identity_hint.as_deref(),
706 },
707 };
708
709 let session = connect_authenticated(
710 &config.host,
711 config.port,
712 &config.username,
713 auth,
714 Duration::from_secs(10),
715 self.host_keys.clone(),
716 )
717 .await?;
718
719 self.session = Some(Arc::new(session));
720 Ok(())
721 }
722
723 pub async fn execute_command(&self, command: &str) -> Result<String> {
732 let out = self.execute_command_full(command).await?;
733 Ok(out.combined())
734 }
735
736 pub async fn execute_command_full(&self, command: &str) -> Result<CommandOutput> {
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 mut stdout = String::new();
746 let mut stderr = String::new();
747 let mut exit_code: Option<u32> = None;
748 let mut eof_received = false;
749
750 loop {
751 let msg = channel.wait().await;
752 match msg {
753 Some(ChannelMsg::Data { ref data }) => {
754 stdout.push_str(&String::from_utf8_lossy(data));
755 }
756 Some(ChannelMsg::ExtendedData { ref data, .. }) => {
757 stderr.push_str(&String::from_utf8_lossy(data));
762 }
763 Some(ChannelMsg::ExitStatus { exit_status }) => {
764 exit_code = Some(exit_status);
765 if eof_received {
766 break;
767 }
768 }
769 Some(ChannelMsg::Eof) => {
770 eof_received = true;
771 if exit_code.is_some() {
772 break;
773 }
774 }
775 Some(ChannelMsg::Close) | None => {
776 break;
777 }
778 _ => {}
779 }
780 }
781
782 Ok(CommandOutput {
783 stdout,
784 stderr,
785 exit_code,
786 })
787 }
788
789 pub async fn execute_command_streaming(
800 &self,
801 command: &str,
802 ) -> Result<(mpsc::Receiver<String>, CancellationToken)> {
803 let Some(session) = &self.session else {
804 return Err(anyhow::anyhow!("Not connected"));
805 };
806
807 let mut channel = session.channel_open_session().await?;
808 channel.exec(true, command).await?;
809
810 let (tx, rx) = mpsc::channel::<String>(256);
811 let cancel = CancellationToken::new();
812 let cancel_task = cancel.clone();
813
814 tokio::spawn(async move {
815 let mut stdout_buf = String::new();
817 let mut stderr_buf = String::new();
818 loop {
819 tokio::select! {
820 _ = cancel_task.cancelled() => {
821 let _ = channel.eof().await;
822 let _ = channel.close().await;
823 break;
824 }
825 msg = channel.wait() => {
826 match msg {
827 Some(ChannelMsg::Data { ref data }) => {
828 stdout_buf.push_str(&String::from_utf8_lossy(data));
829 while let Some(idx) = stdout_buf.find('\n') {
830 let line: String = stdout_buf.drain(..=idx).collect();
831 let trimmed = line.trim_end_matches(['\r', '\n']).to_string();
832 if tx.send(trimmed).await.is_err() {
833 cancel_task.cancel();
834 break;
835 }
836 }
837 }
838 Some(ChannelMsg::ExtendedData { ref data, .. }) => {
839 stderr_buf.push_str(&String::from_utf8_lossy(data));
840 while let Some(idx) = stderr_buf.find('\n') {
841 let line: String = stderr_buf.drain(..=idx).collect();
842 let trimmed = line.trim_end_matches(['\r', '\n']).to_string();
843 if tx.send(format!("!{}", trimmed)).await.is_err() {
844 cancel_task.cancel();
845 break;
846 }
847 }
848 }
849 Some(ChannelMsg::Eof) | Some(ChannelMsg::Close) | None => {
850 if !stdout_buf.is_empty() {
851 let _ = tx.send(stdout_buf.trim_end_matches(['\r', '\n']).to_string()).await;
852 }
853 if !stderr_buf.is_empty() {
854 let _ = tx.send(format!("!{}", stderr_buf.trim_end_matches(['\r', '\n']))).await;
855 }
856 break;
857 }
858 _ => {}
859 }
860 }
861 }
862 }
863 });
864
865 Ok((rx, cancel))
866 }
867
868 pub async fn disconnect(&mut self) -> Result<()> {
869 self.sftp.take();
872
873 if let Some(session) = self.session.take() {
874 match Arc::try_unwrap(session) {
875 Ok(session) => {
876 if let Err(e) = session.disconnect(Disconnect::ByApplication, "", "").await {
877 tracing::warn!("SSH disconnect failed cleanly: {}", e);
878 }
879 }
880 Err(arc_session) => {
881 tracing::debug!("SSH disconnect: other refs still alive, dropping handle");
885 drop(arc_session);
886 }
887 }
888 }
889 Ok(())
890 }
891
892 pub async fn open_direct_tcpip(
901 &self,
902 host: &str,
903 port: u16,
904 ) -> Result<russh::Channel<russh::client::Msg>> {
905 let Some(session) = &self.session else {
906 return Err(anyhow::anyhow!("Not connected"));
907 };
908 let channel = session
909 .channel_open_direct_tcpip(host.to_string(), port as u32, "127.0.0.1", 0)
910 .await?;
911 Ok(channel)
912 }
913
914 pub async fn create_pty_session(&self, cols: u32, rows: u32) -> Result<PtySession> {
917 if let Some(session) = &self.session {
918 let mut channel = session.channel_open_session().await?;
920
921 channel
924 .request_pty(
925 true, "xterm-256color", cols, rows, 0, 0, &[], )
933 .await?;
934
935 channel.request_shell(true).await?;
937
938 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();
945
946 let (resize_tx, mut resize_rx) = mpsc::channel::<(u32, u32)>(16);
948
949 let cancel = CancellationToken::new();
953
954 let input_cancel = cancel.clone();
958 tokio::spawn(async move {
959 let mut writer = input_channel;
960 loop {
961 tokio::select! {
962 biased;
963 _ = input_cancel.cancelled() => {
964 tracing::debug!("[PTY] input task cancelled");
965 break;
966 }
967 maybe_data = input_rx.recv() => {
968 let Some(data) = maybe_data else {
969 break;
971 };
972 if let Err(e) = writer.write_all(&data).await {
973 tracing::error!("[PTY] failed to send data to SSH: {}", e);
974 break;
975 }
976 if let Err(e) = writer.flush().await {
977 tracing::error!("[PTY] failed to flush data to SSH: {}", e);
978 break;
979 }
980 }
981 }
982 }
983 });
984
985 let output_cancel = cancel.clone();
993 tokio::spawn(async move {
994 loop {
995 tokio::select! {
996 biased;
997 _ = output_cancel.cancelled() => {
998 tracing::debug!("[PTY] output task cancelled");
999 break;
1000 }
1001 msg = channel.wait() => {
1002 match msg {
1003 Some(ChannelMsg::Data { data })
1004 if output_tx.send(data.to_vec()).await.is_err() =>
1005 {
1006 break;
1007 }
1008 Some(ChannelMsg::ExtendedData { data, .. })
1009 if output_tx.send(data.to_vec()).await.is_err() =>
1010 {
1011 break;
1013 }
1014 Some(ChannelMsg::Eof) | Some(ChannelMsg::Close) | None => {
1015 tracing::debug!("[PTY] channel closed");
1016 break;
1017 }
1018 Some(ChannelMsg::ExitStatus { exit_status }) => {
1019 tracing::info!("[PTY] process exited with status: {}", exit_status);
1020 }
1021 _ => {}
1022 }
1023 }
1024 resize = resize_rx.recv() => {
1025 match resize {
1026 Some((cols, rows)) => {
1027 if let Err(e) = channel.window_change(cols, rows, 0, 0).await {
1028 tracing::error!("[PTY] failed to send window change: {}", e);
1029 } else {
1030 tracing::debug!("[PTY] window changed to {}x{}", cols, rows);
1031 }
1032 }
1033 None => {
1034 break;
1036 }
1037 }
1038 }
1039 }
1040 }
1041 });
1042
1043 Ok(PtySession {
1044 input_tx,
1045 output_rx: Arc::new(tokio::sync::Mutex::new(output_rx)),
1046 resize_tx,
1047 cancel,
1048 })
1049 } else {
1050 Err(anyhow::anyhow!("Not connected"))
1051 }
1052 }
1053
1054 pub async fn list_dir(&self, path: &str) -> Result<Vec<RemoteFileEntry>> {
1055 let sftp = self.sftp_session().await?;
1056 let entries = sftp
1057 .read_dir(path)
1058 .await
1059 .map_err(|e| anyhow::anyhow!("Failed to list directory '{}': {}", path, e))?;
1060
1061 let mut result = Vec::new();
1062 for entry in entries {
1063 let name = entry.file_name();
1064 if name == "." || name == ".." {
1065 continue;
1066 }
1067
1068 let attrs = entry.metadata();
1069 let size = attrs.size.unwrap_or(0);
1070 let mtime_secs = attrs.mtime.map(|t| t as i64);
1071 let modified = mtime_secs.map(format_unix_timestamp);
1072 let permissions = attrs.permissions.map(format_permissions);
1073 let owner = attrs.uid.map(|u| u.to_string());
1074 let group = attrs.gid.map(|g| g.to_string());
1075
1076 let file_type = if attrs.is_dir() {
1077 FileEntryType::Directory
1078 } else if attrs.is_symlink() {
1079 FileEntryType::Symlink
1080 } else {
1081 FileEntryType::File
1082 };
1083
1084 result.push(RemoteFileEntry {
1085 name,
1086 size,
1087 modified,
1088 modified_unix: mtime_secs,
1089 permissions,
1090 owner,
1091 group,
1092 file_type,
1093 });
1094 }
1095
1096 result.sort_by(|a, b| {
1097 let a_is_dir = matches!(a.file_type, FileEntryType::Directory);
1098 let b_is_dir = matches!(b.file_type, FileEntryType::Directory);
1099 b_is_dir
1100 .cmp(&a_is_dir)
1101 .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
1102 });
1103
1104 Ok(result)
1105 }
1106
1107 pub async fn download_file(&self, remote_path: &str, local_path: &str) -> Result<u64> {
1108 self.download_file_with_progress(remote_path, local_path, |_| {}, None)
1109 .await
1110 }
1111
1112 pub async fn download_file_with_progress(
1128 &self,
1129 remote_path: &str,
1130 local_path: &str,
1131 mut progress: impl FnMut(u64),
1132 cancel: Option<&CancellationToken>,
1133 ) -> Result<u64> {
1134 let sftp = self.sftp_session().await?;
1135 let mut remote_file = sftp.open(remote_path).await?;
1136 let mut local_file = tokio::fs::File::create(local_path).await?;
1137
1138 let mut buf = vec![0u8; SFTP_CHUNK_SIZE];
1139 let mut total_bytes = 0u64;
1140 loop {
1141 if let Some(token) = cancel
1142 && token.is_cancelled()
1143 {
1144 return Err(anyhow::anyhow!("Transfer cancelled"));
1145 }
1146 let n = remote_file.read(&mut buf).await?;
1147 if n == 0 {
1148 break;
1149 }
1150 local_file.write_all(&buf[..n]).await?;
1151 total_bytes += n as u64;
1152 progress(total_bytes);
1153 }
1154 local_file.flush().await?;
1155
1156 Ok(total_bytes)
1157 }
1158
1159 pub async fn download_file_to_memory(&self, remote_path: &str) -> Result<Vec<u8>> {
1160 let sftp = self.sftp_session().await?;
1161 let mut remote_file = sftp.open(remote_path).await?;
1162
1163 let mut buffer = Vec::new();
1164 let mut temp_buf = vec![0u8; SFTP_CHUNK_SIZE];
1165 loop {
1166 let n = remote_file.read(&mut temp_buf).await?;
1167 if n == 0 {
1168 break;
1169 }
1170 buffer.extend_from_slice(&temp_buf[..n]);
1171 }
1172 Ok(buffer)
1173 }
1174
1175 pub async fn upload_file(&self, local_path: &str, remote_path: &str) -> Result<u64> {
1176 self.upload_file_with_progress(local_path, remote_path, |_| {}, None)
1177 .await
1178 }
1179
1180 pub async fn upload_file_with_progress(
1188 &self,
1189 local_path: &str,
1190 remote_path: &str,
1191 mut progress: impl FnMut(u64),
1192 cancel: Option<&CancellationToken>,
1193 ) -> Result<u64> {
1194 let sftp = self.sftp_session().await?;
1195 let mut local_file = tokio::fs::File::open(local_path).await?;
1196 let mut remote_file = sftp.create(remote_path).await?;
1197
1198 let mut buf = vec![0u8; SFTP_CHUNK_SIZE];
1199 let mut total_bytes = 0u64;
1200 loop {
1201 if let Some(token) = cancel
1202 && token.is_cancelled()
1203 {
1204 return Err(anyhow::anyhow!("Transfer cancelled"));
1205 }
1206 let n = local_file.read(&mut buf).await?;
1207 if n == 0 {
1208 break;
1209 }
1210 remote_file.write_all(&buf[..n]).await?;
1211 total_bytes += n as u64;
1212 progress(total_bytes);
1213 }
1214 remote_file.flush().await?;
1215
1216 Ok(total_bytes)
1217 }
1218
1219 pub async fn upload_file_from_bytes(&self, data: &[u8], remote_path: &str) -> Result<u64> {
1220 let sftp = self.sftp_session().await?;
1221 let mut remote_file = sftp.create(remote_path).await?;
1222
1223 for chunk in data.chunks(SFTP_CHUNK_SIZE) {
1224 remote_file.write_all(chunk).await?;
1225 }
1226 remote_file.flush().await?;
1227
1228 Ok(data.len() as u64)
1229 }
1230
1231 pub async fn create_dir(&self, path: &str) -> Result<()> {
1234 let sftp = self.sftp_session().await?;
1235 sftp.create_dir(path)
1236 .await
1237 .map_err(|e| anyhow::anyhow!("Failed to create directory '{}': {}", path, e))?;
1238 Ok(())
1239 }
1240
1241 pub async fn rename(&self, old_path: &str, new_path: &str) -> Result<()> {
1245 let sftp = self.sftp_session().await?;
1246 sftp.rename(old_path, new_path).await.map_err(|e| {
1247 anyhow::anyhow!("Failed to rename '{}' to '{}': {}", old_path, new_path, e)
1248 })?;
1249 Ok(())
1250 }
1251
1252 pub async fn delete_file(&self, path: &str) -> Result<()> {
1254 let sftp = self.sftp_session().await?;
1255 sftp.remove_file(path)
1256 .await
1257 .map_err(|e| anyhow::anyhow!("Failed to delete file '{}': {}", path, e))?;
1258 Ok(())
1259 }
1260
1261 pub async fn delete_dir(&self, path: &str) -> Result<()> {
1265 let sftp = self.sftp_session().await?;
1266 sftp.remove_dir(path)
1267 .await
1268 .map_err(|e| anyhow::anyhow!("Failed to delete directory '{}': {}", path, e))?;
1269 Ok(())
1270 }
1271}
1272
1273#[cfg(test)]
1274mod expand_home_tests {
1275 use super::expand_home_path;
1276
1277 #[test]
1278 fn returns_non_tilde_paths_unchanged() {
1279 assert_eq!(
1280 expand_home_path("/absolute/path").as_deref(),
1281 Some("/absolute/path")
1282 );
1283 assert_eq!(
1284 expand_home_path("relative/dir").as_deref(),
1285 Some("relative/dir")
1286 );
1287 assert_eq!(expand_home_path("").as_deref(), Some(""));
1288 }
1289
1290 #[test]
1291 fn expands_tilde_slash_prefix_when_home_is_known() {
1292 let expanded = expand_home_path("~/.ssh/id_rsa");
1295 if let Some(result) = expanded {
1298 assert!(
1299 !result.starts_with("~/"),
1300 "tilde must be expanded: {}",
1301 result
1302 );
1303 assert!(
1304 result.ends_with("/.ssh/id_rsa"),
1305 "suffix preserved: {}",
1306 result
1307 );
1308 }
1309 }
1310}
1311
1312#[cfg(test)]
1313mod command_output_tests {
1314 use super::CommandOutput;
1315
1316 #[test]
1317 fn is_success_requires_zero_exit() {
1318 assert!(
1319 CommandOutput {
1320 stdout: "x".into(),
1321 stderr: "".into(),
1322 exit_code: Some(0),
1323 }
1324 .is_success()
1325 );
1326 assert!(
1327 !CommandOutput {
1328 stdout: "x".into(),
1329 stderr: "".into(),
1330 exit_code: Some(1),
1331 }
1332 .is_success()
1333 );
1334 assert!(
1335 !CommandOutput {
1336 stdout: "x".into(),
1337 stderr: "".into(),
1338 exit_code: None,
1339 }
1340 .is_success()
1341 );
1342 }
1343
1344 #[test]
1345 fn combined_merges_streams_with_separator() {
1346 let c = CommandOutput {
1347 stdout: "out".into(),
1348 stderr: "err".into(),
1349 exit_code: Some(0),
1350 };
1351 assert_eq!(c.combined(), "out\nerr");
1352 }
1353
1354 #[test]
1355 fn combined_preserves_trailing_newline() {
1356 let c = CommandOutput {
1357 stdout: "out\n".into(),
1358 stderr: "err".into(),
1359 exit_code: Some(0),
1360 };
1361 assert_eq!(c.combined(), "out\nerr");
1362 }
1363
1364 #[test]
1365 fn combined_returns_single_stream_when_other_empty() {
1366 assert_eq!(
1367 CommandOutput {
1368 stdout: "only".into(),
1369 stderr: "".into(),
1370 exit_code: Some(0),
1371 }
1372 .combined(),
1373 "only"
1374 );
1375 assert_eq!(
1376 CommandOutput {
1377 stdout: "".into(),
1378 stderr: "only-err".into(),
1379 exit_code: Some(1),
1380 }
1381 .combined(),
1382 "only-err"
1383 );
1384 }
1385}
1386
1387#[cfg(test)]
1388mod redaction_tests {
1389 use super::{AuthMethod, SshConfig};
1390
1391 #[test]
1392 fn debug_redacts_password() {
1393 let cfg = SshConfig {
1394 host: "h".into(),
1395 port: 22,
1396 username: "u".into(),
1397 auth_method: AuthMethod::Password {
1398 password: "super-secret-123".into(),
1399 },
1400 };
1401 let rendered = format!("{:?}", cfg);
1402 assert!(
1403 !rendered.contains("super-secret-123"),
1404 "password must not appear in Debug output: {}",
1405 rendered
1406 );
1407 assert!(rendered.contains("<redacted>"), "expected redaction marker");
1408 }
1409
1410 #[test]
1411 fn debug_redacts_passphrase() {
1412 let m = AuthMethod::PublicKey {
1413 key_path: "/tmp/id".into(),
1414 passphrase: Some("xyz-passphrase".into()),
1415 };
1416 let rendered = format!("{:?}", m);
1417 assert!(!rendered.contains("xyz-passphrase"));
1418 assert!(rendered.contains("<redacted>"));
1419 assert!(rendered.contains("/tmp/id"));
1420 }
1421
1422 #[test]
1423 fn debug_shows_none_when_no_passphrase() {
1424 let m = AuthMethod::PublicKey {
1425 key_path: "/tmp/id".into(),
1426 passphrase: None,
1427 };
1428 let rendered = format!("{:?}", m);
1429 assert!(rendered.contains("<none>"));
1430 }
1431}
1432
1433#[cfg(test)]
1434mod key_loading_tests {
1435 use super::load_private_key;
1436 use std::io::Write;
1437 use tempfile::NamedTempFile;
1438
1439 const TEST_OPENSSH_PRIVATE_KEY: &str = "\
1440-----BEGIN OPENSSH PRIVATE KEY-----\n\
1441b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\n\
1442QyNTUxOQAAACCzPq7zfqLffKoBDe/eo04kH2XxtSmk9D7RQyf1xUqrYgAAAJgAIAxdACAM\n\
1443XQAAAAtzc2gtZWQyNTUxOQAAACCzPq7zfqLffKoBDe/eo04kH2XxtSmk9D7RQyf1xUqrYg\n\
1444AAAEC2BsIi0QwW2uFscKTUUXNHLsYX4FxlaSDSblbAj7WR7bM+rvN+ot98qgEN796jTiQf\n\
1445ZfG1KaT0PtFDJ/XFSqtiAAAAEHVzZXJAZXhhbXBsZS5jb20BAgMEBQ==\n\
1446-----END OPENSSH PRIVATE KEY-----\n";
1447
1448 #[test]
1449 fn load_private_key_reads_key_file_contents() {
1450 let mut key_file = NamedTempFile::new().expect("failed to create temp key file");
1451 key_file
1452 .write_all(TEST_OPENSSH_PRIVATE_KEY.as_bytes())
1453 .expect("failed to write temp key file");
1454
1455 let key = load_private_key(
1456 key_file
1457 .path()
1458 .to_str()
1459 .expect("temp key path must be valid UTF-8"),
1460 None,
1461 )
1462 .expect("expected key file to load successfully");
1463
1464 assert_eq!(key.algorithm().to_string(), "ssh-ed25519");
1467 }
1468}
1469
1470#[cfg(test)]
1471mod tests;