1use super::providers::RemoteFilesystemClient;
9use crate::file::{
10 CopyOptions, DeleteOptions, FileError, FileType, MkdirOptions, MoveOptions, WriteMode,
11 WriteOptions,
12};
13use crate::filesystem::{
14 FilesystemCapabilities, FilesystemCapability, FilesystemEntry, FilesystemEntryPage,
15 FilesystemMutation, FilesystemMutationContext, FilesystemPageRequest,
16};
17use russh::client::Handler;
18use russh::keys::{check_known_hosts_path, PrivateKeyWithHashAlg, PublicKey};
19use russh_sftp::client::{error::Error as SftpError, SftpSession};
20use russh_sftp::protocol::{FileType as SftpFileType, OpenFlags, StatusCode};
21use std::path::PathBuf;
22use std::sync::atomic::{AtomicBool, Ordering};
23use std::sync::{mpsc, Arc};
24use std::time::Duration;
25use tokio::io::{AsyncReadExt, AsyncWriteExt};
26
27#[derive(Clone)]
29pub enum SftpAuthentication {
30 Password(String),
31 PrivateKey(PrivateKeyWithHashAlg),
32}
33
34#[derive(Clone)]
36pub enum SftpHostKeyPolicy {
37 Pinned(Vec<PublicKey>),
38 KnownHosts(PathBuf),
39}
40
41#[derive(Clone)]
43pub struct SftpConnectOptions {
44 pub host: String,
45 pub port: u16,
46 pub username: String,
47 pub authentication: SftpAuthentication,
48 pub host_key_policy: SftpHostKeyPolicy,
49 pub timeout: Duration,
50}
51
52impl SftpConnectOptions {
53 fn validate(&self) -> Result<(), FileError> {
54 if self.host.trim().is_empty() || self.host.contains('/') || self.host.contains('\0') {
55 return Err(FileError::InvalidPath("SFTP host is invalid".into()));
56 }
57 if self.port == 0 || self.username.trim().is_empty() || self.timeout.is_zero() {
58 return Err(FileError::InvalidPath(
59 "SFTP connection options are invalid".into(),
60 ));
61 }
62 if matches!(&self.host_key_policy, SftpHostKeyPolicy::Pinned(keys) if keys.is_empty()) {
63 return Err(FileError::PermissionDenied);
64 }
65 Ok(())
66 }
67}
68
69pub struct NativeSftpClient {
71 sender: mpsc::Sender<Request>,
72 closed: Arc<AtomicBool>,
73 capabilities: FilesystemCapabilities,
74}
75
76enum Request {
77 Stat(String, Reply<FilesystemEntry>),
78 Read(String, Reply<Vec<u8>>),
79 Write(String, Vec<u8>, WriteOptions, Reply<FilesystemMutation>),
80 Entries(String, FilesystemPageRequest, Reply<FilesystemEntryPage>),
81 Mkdir(String, MkdirOptions, Reply<FilesystemMutation>),
82 Delete(String, DeleteOptions, Reply<FilesystemMutation>),
83 Copy(String, String, CopyOptions, Reply<FilesystemMutation>),
84 Move(String, String, MoveOptions, Reply<FilesystemMutation>),
85 Close(Reply<()>),
86}
87
88type Reply<T> = mpsc::Sender<Result<T, FileError>>;
89
90impl NativeSftpClient {
91 pub fn connect(options: SftpConnectOptions) -> Result<Self, FileError> {
92 options.validate()?;
93 let (sender, receiver) = mpsc::channel();
94 let (ready_sender, ready_receiver) = mpsc::channel();
95 std::thread::Builder::new()
96 .name("hara-sftp".into())
97 .spawn(move || worker(options, receiver, ready_sender))
98 .map_err(|error| FileError::Io(format!("could not start SFTP worker: {error}")))?;
99 let capabilities = ready_receiver
100 .recv()
101 .map_err(|_| FileError::Io("SFTP worker stopped during connection".into()))??;
102 Ok(Self {
103 sender,
104 closed: Arc::new(AtomicBool::new(false)),
105 capabilities,
106 })
107 }
108
109 fn request<T>(&self, build: impl FnOnce(Reply<T>) -> Request) -> Result<T, FileError> {
110 if self.closed.load(Ordering::Acquire) {
111 return Err(FileError::Io("SFTP client is closed".into()));
112 }
113 let (reply_sender, reply_receiver) = mpsc::channel();
114 self.sender
115 .send(build(reply_sender))
116 .map_err(|_| FileError::Io("SFTP worker is unavailable".into()))?;
117 reply_receiver
118 .recv()
119 .map_err(|_| FileError::Io("SFTP worker dropped the response".into()))?
120 }
121}
122
123impl RemoteFilesystemClient for NativeSftpClient {
124 fn authenticated(&self) -> bool {
125 true
126 }
127
128 fn host_key_verified(&self) -> bool {
129 true
130 }
131
132 fn capabilities(&self) -> FilesystemCapabilities {
133 self.capabilities.clone()
134 }
135
136 fn stat(&self, path: &str) -> Result<FilesystemEntry, FileError> {
137 self.request(|reply| Request::Stat(path.into(), reply))
138 }
139
140 fn read(&self, path: &str) -> Result<Vec<u8>, FileError> {
141 self.request(|reply| Request::Read(path.into(), reply))
142 }
143
144 fn write(
145 &self,
146 path: &str,
147 bytes: Vec<u8>,
148 options: WriteOptions,
149 _mutation: &FilesystemMutationContext,
150 ) -> Result<FilesystemMutation, FileError> {
151 self.request(|reply| Request::Write(path.into(), bytes, options, reply))
152 }
153
154 fn entries_page(
155 &self,
156 path: &str,
157 request: &FilesystemPageRequest,
158 ) -> Result<FilesystemEntryPage, FileError> {
159 self.request(|reply| Request::Entries(path.into(), request.clone(), reply))
160 }
161
162 fn mkdir(
163 &self,
164 path: &str,
165 options: MkdirOptions,
166 _mutation: &FilesystemMutationContext,
167 ) -> Result<FilesystemMutation, FileError> {
168 self.request(|reply| Request::Mkdir(path.into(), options, reply))
169 }
170
171 fn delete(
172 &self,
173 path: &str,
174 options: DeleteOptions,
175 _mutation: &FilesystemMutationContext,
176 ) -> Result<FilesystemMutation, FileError> {
177 self.request(|reply| Request::Delete(path.into(), options, reply))
178 }
179
180 fn copy(
181 &self,
182 source: &str,
183 target: &str,
184 options: CopyOptions,
185 _mutation: &FilesystemMutationContext,
186 ) -> Result<FilesystemMutation, FileError> {
187 self.request(|reply| Request::Copy(source.into(), target.into(), options, reply))
188 }
189
190 fn move_entry(
191 &self,
192 source: &str,
193 target: &str,
194 options: MoveOptions,
195 _mutation: &FilesystemMutationContext,
196 ) -> Result<FilesystemMutation, FileError> {
197 self.request(|reply| Request::Move(source.into(), target.into(), options, reply))
198 }
199
200 fn close(&self) -> Result<(), FileError> {
201 if self.closed.swap(true, Ordering::AcqRel) {
202 return Ok(());
203 }
204 let (reply_sender, reply_receiver) = mpsc::channel();
205 self.sender
206 .send(Request::Close(reply_sender))
207 .map_err(|_| FileError::Io("SFTP worker is unavailable".into()))?;
208 reply_receiver
209 .recv()
210 .map_err(|_| FileError::Io("SFTP worker dropped the close response".into()))?
211 }
212}
213
214impl Drop for NativeSftpClient {
215 fn drop(&mut self) {
216 if self.closed.swap(true, Ordering::AcqRel) {
217 return;
218 }
219 let (reply_sender, _reply_receiver) = mpsc::channel();
220 let _ = self.sender.send(Request::Close(reply_sender));
221 }
222}
223
224struct ClientHandler {
225 host: String,
226 port: u16,
227 policy: SftpHostKeyPolicy,
228}
229
230impl Handler for ClientHandler {
231 type Error = russh::Error;
232
233 fn check_server_key(
234 &mut self,
235 server_public_key: &PublicKey,
236 ) -> impl std::future::Future<Output = Result<bool, Self::Error>> + Send {
237 let accepted = match &self.policy {
238 SftpHostKeyPolicy::Pinned(keys) => keys.iter().any(|key| key == server_public_key),
239 SftpHostKeyPolicy::KnownHosts(path) => {
240 check_known_hosts_path(&self.host, self.port, server_public_key, path)
241 .unwrap_or(false)
242 }
243 };
244 async move { Ok(accepted) }
245 }
246}
247
248fn worker(
249 options: SftpConnectOptions,
250 receiver: mpsc::Receiver<Request>,
251 ready_sender: mpsc::Sender<Result<FilesystemCapabilities, FileError>>,
252) {
253 let runtime = match tokio::runtime::Builder::new_current_thread()
254 .enable_all()
255 .build()
256 {
257 Ok(runtime) => runtime,
258 Err(error) => {
259 let _ = ready_sender.send(Err(FileError::Io(format!(
260 "could not start SFTP runtime: {error}"
261 ))));
262 return;
263 }
264 };
265 match runtime.block_on(connect_session(&options)) {
266 Ok((mut session, sftp, capabilities)) => {
267 let _ = ready_sender.send(Ok(capabilities));
268 runtime.block_on(run_requests(&mut session, sftp, receiver));
269 }
270 Err(error) => {
271 let _ = ready_sender.send(Err(error));
272 }
273 }
274}
275
276async fn connect_session(
277 options: &SftpConnectOptions,
278) -> Result<
279 (
280 russh::client::Handle<ClientHandler>,
281 SftpSession,
282 FilesystemCapabilities,
283 ),
284 FileError,
285> {
286 let handler = ClientHandler {
287 host: options.host.clone(),
288 port: options.port,
289 policy: options.host_key_policy.clone(),
290 };
291 let address = format!("{}:{}", options.host, options.port);
292 let mut session = tokio::time::timeout(
293 options.timeout,
294 russh::client::connect(Arc::new(russh::client::Config::default()), address, handler),
295 )
296 .await
297 .map_err(|_| FileError::Io("SFTP connection timed out".into()))?
298 .map_err(|_| FileError::PermissionDenied)?;
299 let authentication = match &options.authentication {
300 SftpAuthentication::Password(password) => {
301 session
302 .authenticate_password(options.username.clone(), password.clone())
303 .await
304 }
305 SftpAuthentication::PrivateKey(key) => {
306 session
307 .authenticate_publickey(options.username.clone(), key.clone())
308 .await
309 }
310 }
311 .map_err(|_| FileError::PermissionDenied)?;
312 if !authentication.success() {
313 return Err(FileError::PermissionDenied);
314 }
315 let channel = tokio::time::timeout(options.timeout, session.channel_open_session())
316 .await
317 .map_err(|_| FileError::Io("SFTP channel opening timed out".into()))?
318 .map_err(|_| FileError::Io("could not open SFTP channel".into()))?;
319 tokio::time::timeout(options.timeout, channel.request_subsystem(true, "sftp"))
320 .await
321 .map_err(|_| FileError::Io("SFTP subsystem request timed out".into()))?
322 .map_err(|_| FileError::Io("SFTP subsystem was rejected".into()))?;
323 let sftp = tokio::time::timeout(options.timeout, SftpSession::new(channel.into_stream()))
324 .await
325 .map_err(|_| FileError::Io("SFTP initialization timed out".into()))?
326 .map_err(map_sftp_error)?;
327 let capabilities = FilesystemCapabilities::new([
328 FilesystemCapability::Read,
329 FilesystemCapability::Write,
330 FilesystemCapability::Entries,
331 FilesystemCapability::Mkdir,
332 FilesystemCapability::Delete,
333 FilesystemCapability::Copy,
334 FilesystemCapability::Move,
335 FilesystemCapability::Append,
336 ]);
337 Ok((session, sftp, capabilities))
338}
339
340async fn run_requests(
341 session: &mut russh::client::Handle<ClientHandler>,
342 sftp: SftpSession,
343 receiver: mpsc::Receiver<Request>,
344) {
345 while let Ok(request) = receiver.recv() {
346 match request {
347 Request::Stat(path, reply) => {
348 let _ = reply.send(remote_stat(&sftp, &path).await);
349 }
350 Request::Read(path, reply) => {
351 let _ = reply.send(remote_read(&sftp, &path).await);
352 }
353 Request::Write(path, bytes, options, reply) => {
354 let _ = reply.send(remote_write(&sftp, &path, bytes, options).await);
355 }
356 Request::Entries(path, request, reply) => {
357 let _ = reply.send(remote_entries(&sftp, &path, &request).await);
358 }
359 Request::Mkdir(path, options, reply) => {
360 let _ = reply.send(remote_mkdir(&sftp, &path, options).await);
361 }
362 Request::Delete(path, options, reply) => {
363 let _ = reply.send(remote_delete(&sftp, &path, options).await);
364 }
365 Request::Copy(source, target, options, reply) => {
366 let _ = reply.send(remote_copy(&sftp, &source, &target, options).await);
367 }
368 Request::Move(source, target, options, reply) => {
369 let _ = reply.send(remote_move(&sftp, &source, &target, options).await);
370 }
371 Request::Close(reply) => {
372 let result = match sftp.close().await.map_err(map_sftp_error) {
373 Ok(()) => session
374 .disconnect(russh::Disconnect::ByApplication, "closed", "")
375 .await
376 .map_err(|_| FileError::Io("could not close SFTP session".into())),
377 Err(error) => Err(error),
378 };
379 let _ = reply.send(result);
380 break;
381 }
382 }
383 }
384}
385
386async fn remote_stat(sftp: &SftpSession, path: &str) -> Result<FilesystemEntry, FileError> {
387 let metadata = sftp.symlink_metadata(path).await.map_err(map_sftp_error)?;
388 Ok(entry(path, metadata))
389}
390
391async fn remote_read(sftp: &SftpSession, path: &str) -> Result<Vec<u8>, FileError> {
392 let mut file = sftp.open(path).await.map_err(map_sftp_error)?;
393 let mut bytes = Vec::new();
394 file.read_to_end(&mut bytes)
395 .await
396 .map_err(|error| FileError::Io(format!("SFTP read failed: {error}")))?;
397 file.shutdown()
398 .await
399 .map_err(|error| FileError::Io(format!("SFTP read close failed: {error}")))?;
400 Ok(bytes)
401}
402
403async fn remote_write(
404 sftp: &SftpSession,
405 path: &str,
406 bytes: Vec<u8>,
407 options: WriteOptions,
408) -> Result<FilesystemMutation, FileError> {
409 let flags = match options.mode {
410 WriteMode::Create => OpenFlags::WRITE | OpenFlags::CREATE | OpenFlags::EXCLUDE,
411 WriteMode::Replace => OpenFlags::WRITE | OpenFlags::CREATE | OpenFlags::TRUNCATE,
412 WriteMode::Append => OpenFlags::WRITE | OpenFlags::CREATE | OpenFlags::APPEND,
413 };
414 let mut file = sftp
415 .open_with_flags(path, flags)
416 .await
417 .map_err(map_sftp_error)?;
418 file.write_all(&bytes)
419 .await
420 .map_err(|error| FileError::Io(format!("SFTP write failed: {error}")))?;
421 file.shutdown()
422 .await
423 .map_err(|error| FileError::Io(format!("SFTP write close failed: {error}")))?;
424 Ok(FilesystemMutation::path(path))
425}
426
427async fn remote_entries(
428 sftp: &SftpSession,
429 path: &str,
430 request: &FilesystemPageRequest,
431) -> Result<FilesystemEntryPage, FileError> {
432 let mut entries = Vec::new();
433 let read_dir = sftp.read_dir(path).await.map_err(map_sftp_error)?;
434 for item in read_dir {
435 let name = item.file_name();
436 if name.is_empty() || name.contains('/') || name.contains('\0') {
437 return Err(FileError::InvalidPath(
438 "SFTP returned an invalid entry name".into(),
439 ));
440 }
441 let child = crate::file::logical_join(path, &name)?;
442 entries.push(entry(&child, item.metadata()));
443 }
444 entries.sort_by(|left, right| left.path.cmp(&right.path));
445 let offset = request
446 .token
447 .as_deref()
448 .unwrap_or("0")
449 .parse::<usize>()
450 .map_err(|_| FileError::InvalidPath("invalid filesystem page token".into()))?;
451 if offset > entries.len() {
452 return Err(FileError::InvalidPath(
453 "filesystem page token is out of range".into(),
454 ));
455 }
456 let end = offset
457 .saturating_add(request.limit.max(1))
458 .min(entries.len());
459 Ok(FilesystemEntryPage {
460 entries: entries[offset..end].to_vec(),
461 next_token: (end < entries.len()).then(|| end.to_string()),
462 })
463}
464
465async fn remote_mkdir(
466 sftp: &SftpSession,
467 path: &str,
468 _options: MkdirOptions,
469) -> Result<FilesystemMutation, FileError> {
470 sftp.create_dir(path).await.map_err(map_sftp_error)?;
471 Ok(FilesystemMutation::path(path))
472}
473
474async fn remote_delete(
475 sftp: &SftpSession,
476 path: &str,
477 options: DeleteOptions,
478) -> Result<FilesystemMutation, FileError> {
479 let metadata = match sftp.symlink_metadata(path).await {
480 Ok(metadata) => metadata,
481 Err(error)
482 if options.missing_ok
483 && matches!(map_sftp_error(error.clone()), FileError::NotFound) =>
484 {
485 return Ok(FilesystemMutation::path(path));
486 }
487 Err(error) => return Err(map_sftp_error(error)),
488 };
489 if metadata.is_dir() {
490 sftp.remove_dir(path).await.map_err(map_sftp_error)?;
491 } else {
492 sftp.remove_file(path).await.map_err(map_sftp_error)?;
493 }
494 Ok(FilesystemMutation::path(path))
495}
496
497async fn remote_copy(
498 sftp: &SftpSession,
499 source: &str,
500 target: &str,
501 options: CopyOptions,
502) -> Result<FilesystemMutation, FileError> {
503 if options.preserve_modified {
504 return Err(FileError::Unsupported);
505 }
506 let target_exists = match sftp.symlink_metadata(target).await {
507 Ok(metadata) => {
508 if metadata.is_dir() || metadata.is_symlink() {
509 return Err(FileError::Unsupported);
510 }
511 true
512 }
513 Err(error) if matches!(map_sftp_error(error.clone()), FileError::NotFound) => false,
514 Err(error) => return Err(map_sftp_error(error)),
515 };
516 if target_exists && !options.replace {
517 return Err(FileError::AlreadyExists);
518 }
519 let bytes = remote_read(sftp, source).await?;
520 remote_write(
521 sftp,
522 target,
523 bytes,
524 WriteOptions {
525 mode: if target_exists {
526 WriteMode::Replace
527 } else {
528 WriteMode::Create
529 },
530 parents: options.parents,
531 },
532 )
533 .await
534}
535
536async fn remote_move(
537 sftp: &SftpSession,
538 source: &str,
539 target: &str,
540 options: MoveOptions,
541) -> Result<FilesystemMutation, FileError> {
542 if options.atomic || options.replace {
543 return Err(FileError::Unsupported);
544 }
545 match sftp.symlink_metadata(target).await {
546 Ok(_) => return Err(FileError::AlreadyExists),
547 Err(error) if matches!(map_sftp_error(error.clone()), FileError::NotFound) => {}
548 Err(error) => return Err(map_sftp_error(error)),
549 }
550 sftp.rename(source, target).await.map_err(map_sftp_error)?;
551 Ok(FilesystemMutation::path(target))
552}
553
554fn entry(path: &str, metadata: russh_sftp::client::fs::Metadata) -> FilesystemEntry {
555 let kind = match metadata.file_type() {
556 SftpFileType::Dir => FileType::Directory,
557 SftpFileType::File => FileType::File,
558 SftpFileType::Symlink => FileType::Symlink,
559 SftpFileType::Other => FileType::Other,
560 };
561 FilesystemEntry {
562 path: path.to_owned(),
563 name: crate::file::logical_name(path).unwrap_or_default(),
564 kind,
565 size: (kind == FileType::File).then_some(metadata.len()),
566 modified_at: metadata.mtime.map(|value| value as i64 * 1000),
567 id: None,
568 revision: None,
569 capabilities: None,
570 extensions: Default::default(),
571 }
572}
573
574fn map_sftp_error(error: SftpError) -> FileError {
575 match error {
576 SftpError::Status(status) => match status.status_code {
577 StatusCode::NoSuchFile => FileError::NotFound,
578 StatusCode::PermissionDenied => FileError::PermissionDenied,
579 StatusCode::OpUnsupported => FileError::Unsupported,
580 StatusCode::Eof => FileError::NotFound,
581 StatusCode::Failure => FileError::Io("SFTP operation failed".into()),
582 _ => FileError::Io("SFTP protocol operation failed".into()),
583 },
584 SftpError::Timeout => FileError::Io("SFTP operation timed out".into()),
585 SftpError::Limited(_) => FileError::Unsupported,
586 SftpError::IO(_) | SftpError::UnexpectedPacket | SftpError::UnexpectedBehavior(_) => {
587 FileError::Io("SFTP transport operation failed".into())
588 }
589 }
590}
591
592#[cfg(test)]
593mod tests {
594 use super::*;
595 use russh::server::{Auth, Msg, Server as _, Session};
596 use russh::{Channel, ChannelId};
597 use russh_sftp::protocol::{Attrs, Data, File, FileAttributes, Handle, Name, Status, Version};
598 use std::collections::HashMap;
599 use std::net::SocketAddr;
600 use tokio::net::TcpListener;
601
602 fn options() -> SftpConnectOptions {
603 SftpConnectOptions {
604 host: "127.0.0.1".into(),
605 port: 22,
606 username: "hara".into(),
607 authentication: SftpAuthentication::Password("secret".into()),
608 host_key_policy: SftpHostKeyPolicy::Pinned(vec![]),
609 timeout: Duration::from_secs(1),
610 }
611 }
612
613 #[test]
614 fn connection_options_fail_closed_before_starting_a_worker() {
615 match NativeSftpClient::connect(options()) {
616 Err(error) => assert_eq!(error.code(), "permission-denied"),
617 Ok(_) => panic!("empty pinned host-key policy must fail closed"),
618 }
619
620 let mut invalid = options();
621 invalid.host.clear();
622 match NativeSftpClient::connect(invalid) {
623 Err(error) => assert_eq!(error.code(), "invalid-path"),
624 Ok(_) => panic!("an empty SFTP host must fail validation"),
625 }
626 }
627
628 #[derive(Default)]
629 struct LoopbackServer;
630
631 struct LoopbackSshSession {
632 channels: std::sync::Arc<tokio::sync::Mutex<HashMap<ChannelId, Channel<Msg>>>>,
633 }
634
635 impl Default for LoopbackSshSession {
636 fn default() -> Self {
637 Self {
638 channels: std::sync::Arc::new(tokio::sync::Mutex::new(HashMap::new())),
639 }
640 }
641 }
642
643 impl russh::server::Server for LoopbackServer {
644 type Handler = LoopbackSshSession;
645
646 fn new_client(&mut self, _peer: Option<SocketAddr>) -> Self::Handler {
647 LoopbackSshSession::default()
648 }
649 }
650
651 impl russh::server::Handler for LoopbackSshSession {
652 type Error = russh::Error;
653
654 async fn auth_password(
655 &mut self,
656 _user: &str,
657 _password: &str,
658 ) -> Result<Auth, Self::Error> {
659 Ok(Auth::Accept)
660 }
661
662 async fn channel_open_session(
663 &mut self,
664 channel: Channel<Msg>,
665 reply: russh::server::ChannelOpenHandle,
666 _session: &mut Session,
667 ) -> Result<(), Self::Error> {
668 self.channels.lock().await.insert(channel.id(), channel);
669 reply.accept().await;
670 Ok(())
671 }
672
673 async fn subsystem_request(
674 &mut self,
675 channel_id: ChannelId,
676 name: &str,
677 session: &mut Session,
678 ) -> Result<(), Self::Error> {
679 if name != "sftp" {
680 session.channel_failure(channel_id)?;
681 return Ok(());
682 }
683 let channel = self.channels.lock().await.remove(&channel_id).unwrap();
684 session.channel_success(channel_id)?;
685 russh_sftp::server::run(channel.into_stream(), LoopbackSftp::default()).await;
686 Ok(())
687 }
688 }
689
690 #[derive(Default)]
691 struct LoopbackSftp {
692 handles: HashMap<String, String>,
693 }
694
695 impl LoopbackSftp {
696 fn attrs(path: &str) -> Result<FileAttributes, StatusCode> {
697 let mut attrs = FileAttributes::default();
698 match path {
699 "/" => attrs.set_dir(true),
700 "/probe.txt" => {
701 attrs.size = Some(11);
702 attrs.set_regular(true);
703 }
704 _ => return Err(StatusCode::NoSuchFile),
705 }
706 Ok(attrs)
707 }
708
709 fn status(id: u32) -> Status {
710 Status {
711 id,
712 status_code: StatusCode::Ok,
713 error_message: "ok".into(),
714 language_tag: "en".into(),
715 }
716 }
717 }
718
719 impl russh_sftp::server::Handler for LoopbackSftp {
720 type Error = StatusCode;
721
722 fn unimplemented(&self) -> Self::Error {
723 StatusCode::OpUnsupported
724 }
725
726 async fn init(
727 &mut self,
728 _version: u32,
729 _extensions: HashMap<String, String>,
730 ) -> Result<Version, Self::Error> {
731 Ok(Version::new())
732 }
733
734 async fn open(
735 &mut self,
736 id: u32,
737 filename: String,
738 _pflags: OpenFlags,
739 _attrs: FileAttributes,
740 ) -> Result<Handle, Self::Error> {
741 Self::attrs(&filename)?;
742 self.handles.insert(filename.clone(), filename.clone());
743 Ok(Handle {
744 id,
745 handle: filename,
746 })
747 }
748
749 async fn close(&mut self, id: u32, handle: String) -> Result<Status, Self::Error> {
750 self.handles.remove(&handle);
751 Ok(Self::status(id))
752 }
753
754 async fn read(
755 &mut self,
756 id: u32,
757 handle: String,
758 offset: u64,
759 len: u32,
760 ) -> Result<Data, Self::Error> {
761 let path = self.handles.get(&handle).ok_or(StatusCode::Failure)?;
762 let bytes = b"loopback-ok";
763 if path != "/probe.txt" {
764 return Err(StatusCode::NoSuchFile);
765 }
766 let start = usize::try_from(offset).map_err(|_| StatusCode::Failure)?;
767 if start >= bytes.len() {
768 return Err(StatusCode::Eof);
769 }
770 let end = start.saturating_add(len as usize).min(bytes.len());
771 Ok(Data {
772 id,
773 data: bytes[start..end].to_vec(),
774 })
775 }
776
777 async fn lstat(&mut self, id: u32, path: String) -> Result<Attrs, Self::Error> {
778 Ok(Attrs {
779 id,
780 attrs: Self::attrs(&path)?,
781 })
782 }
783
784 async fn stat(&mut self, id: u32, path: String) -> Result<Attrs, Self::Error> {
785 self.lstat(id, path).await
786 }
787
788 async fn realpath(&mut self, id: u32, _path: String) -> Result<Name, Self::Error> {
789 Ok(Name {
790 id,
791 files: vec![File::dummy("/")],
792 })
793 }
794 }
795
796 #[test]
797 fn native_transport_reads_from_a_pinned_loopback_server() {
798 match std::net::TcpListener::bind(("127.0.0.1", 0)) {
799 Ok(listener) => drop(listener),
800 Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => return,
801 Err(error) => panic!("could not probe loopback socket support: {error}"),
802 }
803 let (ready_sender, ready_receiver) = mpsc::channel();
804 let server_thread = std::thread::spawn(move || {
805 let runtime = tokio::runtime::Builder::new_current_thread()
806 .enable_all()
807 .build()
808 .unwrap();
809 runtime.block_on(async move {
810 let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
811 let host_key = russh::keys::PrivateKey::random(
812 &mut rand::rng(),
813 russh::keys::Algorithm::Ed25519,
814 )
815 .unwrap();
816 let host_public_key = host_key.public_key().clone();
817 let config = russh::server::Config {
818 keys: vec![host_key],
819 ..Default::default()
820 };
821 let mut server = LoopbackServer;
822 let running = server.run_on_socket(Arc::new(config), &listener);
823 let handle = running.handle();
824 ready_sender
825 .send((
826 listener.local_addr().unwrap().port(),
827 host_public_key,
828 handle,
829 ))
830 .unwrap();
831 running.await.unwrap();
832 });
833 });
834
835 let (port, host_key, server_handle) = ready_receiver.recv().unwrap();
836 let client = NativeSftpClient::connect(SftpConnectOptions {
837 host: "127.0.0.1".into(),
838 port,
839 username: "hara".into(),
840 authentication: SftpAuthentication::Password("secret".into()),
841 host_key_policy: SftpHostKeyPolicy::Pinned(vec![host_key]),
842 timeout: Duration::from_secs(5),
843 })
844 .unwrap();
845
846 let root = client.stat("/").unwrap();
847 assert_eq!(root.kind, FileType::Directory);
848 let file = client.stat("/probe.txt").unwrap();
849 assert_eq!(file.kind, FileType::File);
850 assert_eq!(file.size, Some(11));
851 assert_eq!(client.read("/probe.txt").unwrap(), b"loopback-ok");
852
853 client.close().unwrap();
854 server_handle.shutdown("test complete".into());
855 server_thread.join().unwrap();
856 }
857}