Skip to main content

hara_native/file/
sftp.rs

1//! Native SSH/SFTP transport for the provider-neutral filesystem surface.
2//!
3//! The public provider remains synchronous at the host capability seam, while
4//! the SSH/SFTP protocol runs on one private Tokio worker. Credentials and
5//! host-key policy stay in this transport and never enter a filesystem
6//! descriptor.
7
8use 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/// Explicit credentials for one native SFTP connection.
28#[derive(Clone)]
29pub enum SftpAuthentication {
30    Password(String),
31    PrivateKey(PrivateKeyWithHashAlg),
32}
33
34/// Fail-closed server host-key policy.
35#[derive(Clone)]
36pub enum SftpHostKeyPolicy {
37    Pinned(Vec<PublicKey>),
38    KnownHosts(PathBuf),
39}
40
41/// Host-owned options for opening one authenticated SFTP transport.
42#[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
69/// A native SFTP client backed by a dedicated protocol worker.
70pub 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}