Skip to main content

remotefs_ssh/ssh/
sftp.rs

1//! ## SFTP
2//!
3//! Sftp remote fs implementation
4
5use std::io::{Read, Write};
6use std::path::{Path, PathBuf};
7
8use remotefs::File;
9use remotefs::fs::{
10    Metadata, ReadStream, RemoteError, RemoteErrorType, RemoteFs, RemoteResult, UnixPex, Welcome,
11    WriteStream,
12};
13
14use super::SshOpts;
15use crate::SshSession;
16use crate::ssh::backend::{Sftp as _, WriteMode};
17use crate::utils::path as path_utils;
18
19/// Sftp "filesystem" client
20pub struct SftpFs<S>
21where
22    S: SshSession,
23{
24    session: Option<S>,
25    sftp: Option<S::Sftp>,
26    wrkdir: PathBuf,
27    opts: SshOpts,
28}
29
30#[cfg(feature = "libssh2")]
31#[cfg_attr(docsrs, doc(cfg(feature = "libssh2")))]
32impl SftpFs<super::backend::LibSsh2Session> {
33    /// Constructs a new [`SftpFs`] instance with the `libssh2` backend.
34    pub fn libssh2(opts: SshOpts) -> Self {
35        Self {
36            session: None,
37            sftp: None,
38            wrkdir: PathBuf::from("/"),
39            opts,
40        }
41    }
42}
43
44#[cfg(feature = "libssh")]
45#[cfg_attr(docsrs, doc(cfg(feature = "libssh")))]
46impl SftpFs<super::backend::LibSshSession> {
47    /// Constructs a new [`SftpFs`] instance with the `libssh` backend.
48    pub fn libssh(opts: SshOpts) -> Self {
49        Self {
50            session: None,
51            sftp: None,
52            wrkdir: PathBuf::from("/"),
53            opts,
54        }
55    }
56}
57
58#[cfg(feature = "russh")]
59#[cfg_attr(docsrs, doc(cfg(feature = "russh")))]
60impl<T> SftpFs<super::backend::RusshSession<T>>
61where
62    T: russh::client::Handler + Default + Send + 'static,
63{
64    /// Constructs a new [`SftpFs`] instance with the `russh` backend.
65    pub fn russh(opts: SshOpts, runtime: std::sync::Arc<tokio::runtime::Runtime>) -> Self {
66        let opts = opts.runtime(runtime);
67        Self {
68            session: None,
69            sftp: None,
70            wrkdir: PathBuf::from("/"),
71            opts,
72        }
73    }
74}
75
76impl<S> SftpFs<S>
77where
78    S: SshSession,
79{
80    /// Get a reference to current `session` value.
81    pub fn session(&mut self) -> Option<&mut S> {
82        self.session.as_mut()
83    }
84
85    /// Get a reference to current `sftp` value.
86    pub fn sftp(&mut self) -> Option<&mut S::Sftp> {
87        self.sftp.as_mut()
88    }
89
90    // -- private
91
92    /// Recursively removes a directory and all its contents using only SFTP operations.
93    fn remove_dir_all_recursive(sftp: &S::Sftp, path: &Path) -> RemoteResult<()> {
94        let entries = sftp.readdir(path).map_err(|e| {
95            error!("Failed to list directory {}: {e}", path.display());
96            RemoteError::new_ex(RemoteErrorType::CouldNotRemoveFile, e)
97        })?;
98        for entry in &entries {
99            let entry_path = entry.path();
100            if entry.is_dir() {
101                Self::remove_dir_all_recursive(sftp, entry_path)?;
102            } else {
103                sftp.unlink(entry_path).map_err(|e| {
104                    error!("Failed to remove file {}: {e}", entry_path.display());
105                    RemoteError::new_ex(RemoteErrorType::CouldNotRemoveFile, e)
106                })?;
107            }
108        }
109        sftp.rmdir(path).map_err(|e| {
110            error!("Failed to remove directory {}: {e}", path.display());
111            RemoteError::new_ex(RemoteErrorType::CouldNotRemoveFile, e)
112        })
113    }
114
115    /// Recursively copies a file or directory using only SFTP operations.
116    fn copy_recursive(sftp: &S::Sftp, src: &Path, dest: &Path) -> RemoteResult<()> {
117        let src_file = sftp.stat(src).map_err(|e| {
118            error!("Failed to stat {}: {e}", src.display());
119            RemoteError::new_ex(RemoteErrorType::NoSuchFileOrDirectory, e)
120        })?;
121
122        if src_file.is_dir() {
123            // Create destination directory with same mode
124            let mode = src_file
125                .metadata()
126                .mode
127                .map(|m| u32::from(m) as i32)
128                .unwrap_or(0o755);
129            sftp.mkdir(dest, mode).map_err(|e| {
130                error!("Failed to create directory {}: {e}", dest.display());
131                RemoteError::new_ex(RemoteErrorType::FileCreateDenied, e)
132            })?;
133            // Recurse into children
134            let entries = sftp.readdir(src).map_err(|e| {
135                error!("Failed to list directory {}: {e}", src.display());
136                RemoteError::new_ex(RemoteErrorType::CouldNotOpenFile, e)
137            })?;
138            for entry in &entries {
139                let name = entry.path().file_name().ok_or_else(|| {
140                    RemoteError::new_ex(
141                        RemoteErrorType::BadFile,
142                        format!("entry has no file name: {}", entry.path().display()),
143                    )
144                })?;
145                let child_dest = dest.join(name);
146                Self::copy_recursive(sftp, entry.path(), &child_dest)?;
147            }
148        } else {
149            // Copy file contents
150            let mode = src_file
151                .metadata()
152                .mode
153                .map(|m| u32::from(m) as i32)
154                .unwrap_or(0o644);
155            let mut reader = sftp.open_read(src).map_err(|e| {
156                error!("Failed to open {} for reading: {e}", src.display());
157                RemoteError::new_ex(RemoteErrorType::CouldNotOpenFile, e)
158            })?;
159            let mut writer = sftp.open_write(dest, WriteMode::Truncate, mode).map_err(|e| {
160                error!("Failed to open {} for writing: {e}", dest.display());
161                RemoteError::new_ex(RemoteErrorType::FileCreateDenied, e)
162            })?;
163            let mut buffer = [0u8; 65535];
164            loop {
165                let bytes_read = reader.read(&mut buffer).map_err(|e| {
166                    RemoteError::new_ex(RemoteErrorType::IoError, e)
167                })?;
168                if bytes_read == 0 {
169                    break;
170                }
171                writer.write_all(&buffer[..bytes_read]).map_err(|e| {
172                    RemoteError::new_ex(RemoteErrorType::IoError, e)
173                })?;
174            }
175            writer.flush().map_err(|e| {
176                RemoteError::new_ex(RemoteErrorType::IoError, e)
177            })?;
178        }
179
180        Ok(())
181    }
182
183    /// Check connection status
184    fn check_connection(&mut self) -> RemoteResult<()> {
185        if self.is_connected() {
186            Ok(())
187        } else {
188            Err(RemoteError::new(RemoteErrorType::NotConnected))
189        }
190    }
191}
192
193impl<S> RemoteFs for SftpFs<S>
194where
195    S: SshSession,
196{
197    fn connect(&mut self) -> RemoteResult<Welcome> {
198        debug!("Initializing SFTP connection...");
199        let session = S::connect(&self.opts)?;
200        // Get SFTP client first so we can resolve the working directory without shell commands
201        debug!("Getting SFTP client...");
202        let sftp = match session.sftp() {
203            Ok(s) => s,
204            Err(err) => {
205                error!("Could not get sftp client: {err}");
206                return Err(RemoteError::new_ex(RemoteErrorType::ProtocolError, err));
207            }
208        };
209        // Resolve working directory via SFTP realpath instead of shell `pwd`
210        debug!("Getting working directory...");
211        self.wrkdir = sftp.realpath(Path::new(".")).map_err(|err| {
212            error!("Could not resolve working directory: {err}");
213            RemoteError::new_ex(RemoteErrorType::ProtocolError, err)
214        })?;
215        self.session = Some(session);
216        self.sftp = Some(sftp);
217        let banner = self.session.as_ref().unwrap().banner()?;
218        debug!(
219            "Connection established: '{}'; working directory {}",
220            banner.as_deref().unwrap_or(""),
221            self.wrkdir.display()
222        );
223        Ok(Welcome::default().banner(banner))
224    }
225
226    fn disconnect(&mut self) -> RemoteResult<()> {
227        debug!("Disconnecting from remote...");
228        if let Some(session) = self.session.as_ref() {
229            // First free sftp
230            self.sftp = None;
231            // Disconnect (greet server with 'Mandi' as they do in Friuli)
232            match session.disconnect() {
233                Ok(_) => {
234                    // Set session and sftp to none
235                    self.session = None;
236                    Ok(())
237                }
238                Err(err) => Err(RemoteError::new_ex(RemoteErrorType::ConnectionError, err)),
239            }
240        } else {
241            Err(RemoteError::new(RemoteErrorType::NotConnected))
242        }
243    }
244
245    fn is_connected(&mut self) -> bool {
246        self.session
247            .as_ref()
248            .map(|x| x.authenticated().unwrap_or_default())
249            .unwrap_or_default()
250    }
251
252    fn pwd(&mut self) -> RemoteResult<PathBuf> {
253        self.check_connection()?;
254        Ok(self.wrkdir.clone())
255    }
256
257    fn change_dir(&mut self, dir: &Path) -> RemoteResult<PathBuf> {
258        self.check_connection()?;
259        let dir = path_utils::absolutize(self.wrkdir.as_path(), dir);
260        // Stat path to check if it exists. If it is a file, return error
261        match self.stat(dir.as_path()) {
262            Err(err) => Err(err),
263            Ok(file) if file.is_dir() => {
264                self.wrkdir = dir;
265                debug!("Changed working directory to {}", self.wrkdir.display());
266                Ok(self.wrkdir.clone())
267            }
268            Ok(_) => Err(RemoteError::new_ex(
269                RemoteErrorType::BadFile,
270                "expected directory, got file",
271            )),
272        }
273    }
274
275    fn list_dir(&mut self, path: &Path) -> RemoteResult<Vec<File>> {
276        if let Some(sftp) = self.sftp.as_ref() {
277            let path = path_utils::absolutize(self.wrkdir.as_path(), path);
278            debug!("Reading directory content of {}", path.display());
279            match sftp.readdir(path.as_path()) {
280                Err(err) => Err(RemoteError::new_ex(RemoteErrorType::StatFailed, err)),
281                Ok(files) => Ok(files),
282            }
283        } else {
284            Err(RemoteError::new(RemoteErrorType::NotConnected))
285        }
286    }
287
288    fn stat(&mut self, path: &Path) -> RemoteResult<File> {
289        if let Some(sftp) = self.sftp.as_ref() {
290            let path = path_utils::absolutize(self.wrkdir.as_path(), path);
291            debug!("Collecting metadata for {}", path.display());
292            sftp.stat(path.as_path()).map_err(|e| {
293                error!("Stat failed: {e}");
294                RemoteError::new_ex(RemoteErrorType::NoSuchFileOrDirectory, e)
295            })
296        } else {
297            Err(RemoteError::new(RemoteErrorType::NotConnected))
298        }
299    }
300
301    fn setstat(&mut self, path: &Path, metadata: Metadata) -> RemoteResult<()> {
302        if let Some(sftp) = self.sftp.as_ref() {
303            let path = path_utils::absolutize(self.wrkdir.as_path(), path);
304            debug!("Setting metadata for {}", path.display());
305            sftp.setstat(path.as_path(), metadata)
306                .map(|_| ())
307                .map_err(|e| {
308                    error!("Setstat failed: {e}");
309                    RemoteError::new_ex(RemoteErrorType::StatFailed, e)
310                })
311        } else {
312            Err(RemoteError::new(RemoteErrorType::NotConnected))
313        }
314    }
315
316    fn exists(&mut self, path: &Path) -> RemoteResult<bool> {
317        match self.stat(path) {
318            Ok(_) => Ok(true),
319            Err(RemoteError {
320                kind: RemoteErrorType::NoSuchFileOrDirectory,
321                ..
322            }) => Ok(false),
323            Err(err) => Err(err),
324        }
325    }
326
327    fn remove_file(&mut self, path: &Path) -> RemoteResult<()> {
328        if let Some(sftp) = self.sftp.as_ref() {
329            let path = path_utils::absolutize(self.wrkdir.as_path(), path);
330            debug!("Remove file {}", path.display());
331            sftp.unlink(path.as_path()).map_err(|e| {
332                error!("Remove failed: {e}");
333                RemoteError::new_ex(RemoteErrorType::CouldNotRemoveFile, e)
334            })
335        } else {
336            Err(RemoteError::new(RemoteErrorType::NotConnected))
337        }
338    }
339
340    fn remove_dir(&mut self, path: &Path) -> RemoteResult<()> {
341        if let Some(sftp) = self.sftp.as_ref() {
342            let path = path_utils::absolutize(self.wrkdir.as_path(), path);
343            debug!("Remove dir {}", path.display());
344            sftp.rmdir(path.as_path()).map_err(|e| {
345                error!("Remove failed: {e}");
346                RemoteError::new_ex(RemoteErrorType::CouldNotRemoveFile, e)
347            })
348        } else {
349            Err(RemoteError::new(RemoteErrorType::NotConnected))
350        }
351    }
352
353    fn remove_dir_all(&mut self, path: &Path) -> RemoteResult<()> {
354        self.check_connection()?;
355        let path = path_utils::absolutize(self.wrkdir.as_path(), path);
356        if !self.exists(path.as_path()).ok().unwrap_or(false) {
357            return Err(RemoteError::new(RemoteErrorType::NoSuchFileOrDirectory));
358        }
359        debug!("Removing directory {} recursively", path.display());
360        let sftp = self.sftp.as_ref().unwrap();
361        Self::remove_dir_all_recursive(sftp, &path)
362    }
363
364    fn create_dir(&mut self, path: &Path, mode: UnixPex) -> RemoteResult<()> {
365        self.check_connection()?;
366        let path = path_utils::absolutize(self.wrkdir.as_path(), path);
367        // Check if already exists
368        debug!(
369            "Creating directory {} (mode: {:o})",
370            path.display(),
371            u32::from(mode)
372        );
373        if self.exists(path.as_path())? {
374            error!("directory {} already exists", path.display());
375            return Err(RemoteError::new(RemoteErrorType::DirectoryAlreadyExists));
376        }
377        self.sftp
378            .as_ref()
379            .unwrap()
380            .mkdir(path.as_path(), u32::from(mode) as i32)
381            .map_err(|e| {
382                error!("Create dir failed: {e}");
383                RemoteError::new_ex(RemoteErrorType::FileCreateDenied, e)
384            })
385    }
386
387    fn symlink(&mut self, path: &Path, target: &Path) -> RemoteResult<()> {
388        self.check_connection()?;
389        let path = path_utils::absolutize(self.wrkdir.as_path(), path);
390        // Check if already exists
391        debug!(
392            "Creating symlink at {} pointing to {}",
393            path.display(),
394            target.display()
395        );
396        if !self.exists(target)? {
397            error!("target {} doesn't exist", target.display());
398            return Err(RemoteError::new(RemoteErrorType::NoSuchFileOrDirectory));
399        }
400        self.sftp
401            .as_ref()
402            .unwrap()
403            .symlink(target, path.as_path())
404            .map_err(|e| {
405                error!("Symlink failed: {e}");
406                RemoteError::new_ex(RemoteErrorType::FileCreateDenied, e)
407            })
408    }
409
410    fn copy(&mut self, src: &Path, dest: &Path) -> RemoteResult<()> {
411        self.check_connection()?;
412        let src = path_utils::absolutize(self.wrkdir.as_path(), src);
413        if !self.exists(src.as_path()).ok().unwrap_or(false) {
414            return Err(RemoteError::new(RemoteErrorType::NoSuchFileOrDirectory));
415        }
416        let dest = path_utils::absolutize(self.wrkdir.as_path(), dest);
417        debug!("Copying {} to {}", src.display(), dest.display());
418        let sftp = self.sftp.as_ref().unwrap();
419        Self::copy_recursive(sftp, &src, &dest)
420    }
421
422    fn mov(&mut self, src: &Path, dest: &Path) -> RemoteResult<()> {
423        self.check_connection()?;
424        let src = path_utils::absolutize(self.wrkdir.as_path(), src);
425        // check if file exists
426        if !self.exists(src.as_path()).ok().unwrap_or(false) {
427            return Err(RemoteError::new(RemoteErrorType::NoSuchFileOrDirectory));
428        }
429        let dest = path_utils::absolutize(self.wrkdir.as_path(), dest);
430        debug!("Moving {} to {}", src.display(), dest.display());
431        self.sftp
432            .as_ref()
433            .unwrap()
434            .rename(src.as_path(), dest.as_path())
435            .map_err(|e| {
436                error!("Move failed: {e}",);
437                RemoteError::new_ex(RemoteErrorType::FileCreateDenied, e)
438            })
439    }
440
441    fn exec(&mut self, cmd: &str) -> RemoteResult<(u32, String)> {
442        self.check_connection()?;
443        debug!(r#"Executing command "{cmd}""#);
444
445        self.session
446            .as_mut()
447            .unwrap()
448            .cmd_at(cmd, self.wrkdir.as_path())
449    }
450
451    fn append(&mut self, path: &Path, metadata: &Metadata) -> RemoteResult<WriteStream> {
452        if let Some(sftp) = self.sftp.as_ref() {
453            let path = path_utils::absolutize(self.wrkdir.as_path(), path);
454            debug!("Opening file at {} for appending", path.display());
455            let mode = metadata.mode.map(|x| u32::from(x) as i32).unwrap_or(0o644);
456            sftp.open_write(path.as_path(), WriteMode::Append, mode)
457                .map_err(|e| {
458                    error!("Append failed: {e}",);
459                    RemoteError::new_ex(RemoteErrorType::CouldNotOpenFile, e)
460                })
461        } else {
462            Err(RemoteError::new(RemoteErrorType::NotConnected))
463        }
464    }
465
466    fn create(&mut self, path: &Path, metadata: &Metadata) -> RemoteResult<WriteStream> {
467        if let Some(sftp) = self.sftp.as_ref() {
468            let path = path_utils::absolutize(self.wrkdir.as_path(), path);
469            debug!("Creating file at {}", path.display());
470            let mode = metadata.mode.map(|x| u32::from(x) as i32).unwrap_or(0o644);
471            sftp.open_write(path.as_path(), WriteMode::Truncate, mode)
472                .map_err(|e| {
473                    error!("Create failed: {e}",);
474                    RemoteError::new_ex(RemoteErrorType::FileCreateDenied, e)
475                })
476        } else {
477            Err(RemoteError::new(RemoteErrorType::NotConnected))
478        }
479    }
480
481    fn open(&mut self, path: &Path) -> RemoteResult<ReadStream> {
482        self.check_connection()?;
483        let path = path_utils::absolutize(self.wrkdir.as_path(), path);
484        // check if file exists
485        if !self.exists(path.as_path()).ok().unwrap_or(false) {
486            return Err(RemoteError::new(RemoteErrorType::NoSuchFileOrDirectory));
487        }
488        debug!("Opening file at {}", path.display());
489        self.sftp
490            .as_ref()
491            .unwrap()
492            .open_read(path.as_path())
493            .map_err(|e| {
494                error!("Open failed: {e}",);
495                RemoteError::new_ex(RemoteErrorType::CouldNotOpenFile, e)
496            })
497    }
498
499    // -- override (std::io::copy is VERY slow on SFTP <https://github.com/remotefs-rs/remotefs-rs/issues/6>)
500
501    fn append_file(
502        &mut self,
503        path: &Path,
504        metadata: &Metadata,
505        mut reader: Box<dyn Read + Send>,
506    ) -> RemoteResult<u64> {
507        if self.is_connected() {
508            let mut stream = self.append(path, metadata)?;
509            trace!("Opened remote file");
510            let mut bytes: usize = 0;
511            let transfer_size = metadata.size as usize;
512            let mut buffer: [u8; 65535] = [0; 65535];
513            while bytes < transfer_size {
514                let bytes_read = reader.read(&mut buffer).map_err(|e| {
515                    error!("Failed to read from file: {e}",);
516                    RemoteError::new_ex(RemoteErrorType::IoError, e)
517                })?;
518                let mut delta = 0;
519                while delta < bytes_read {
520                    delta += stream.write(&buffer[delta..bytes_read]).map_err(|e| {
521                        error!("Failed to write to stream: {e}",);
522                        RemoteError::new_ex(RemoteErrorType::IoError, e)
523                    })?;
524                }
525                bytes += bytes_read;
526            }
527            self.on_written(stream)?;
528            trace!("Written {bytes} bytes to destination",);
529            Ok(bytes as u64)
530        } else {
531            Err(RemoteError::new(RemoteErrorType::NotConnected))
532        }
533    }
534
535    fn create_file(
536        &mut self,
537        path: &Path,
538        metadata: &Metadata,
539        mut reader: Box<dyn std::io::Read + Send>,
540    ) -> RemoteResult<u64> {
541        if self.is_connected() {
542            let mut stream = self.create(path, metadata)?;
543            trace!("Opened remote file");
544            let mut bytes: usize = 0;
545            let transfer_size = metadata.size as usize;
546            let mut buffer: [u8; 65535] = [0; 65535];
547            while bytes < transfer_size {
548                let bytes_read = reader.read(&mut buffer).map_err(|e| {
549                    error!("Failed to read from file: {e}",);
550                    RemoteError::new_ex(RemoteErrorType::IoError, e)
551                })?;
552                let mut delta = 0;
553                while delta < bytes_read {
554                    delta += stream.write(&buffer[delta..bytes_read]).map_err(|e| {
555                        error!("Failed to write to stream: {e}",);
556                        RemoteError::new_ex(RemoteErrorType::IoError, e)
557                    })?;
558                }
559                bytes += bytes_read;
560            }
561            stream.flush().map_err(|e| {
562                error!("Failed to flush stream: {e}");
563                RemoteError::new_ex(RemoteErrorType::IoError, e)
564            })?;
565            self.on_written(stream)?;
566            trace!("Written {bytes} bytes to destination",);
567            Ok(bytes as u64)
568        } else {
569            Err(RemoteError::new(RemoteErrorType::NotConnected))
570        }
571    }
572
573    fn open_file(&mut self, src: &Path, mut dest: Box<dyn Write + Send>) -> RemoteResult<u64> {
574        if self.is_connected() {
575            let transfer_size = self.stat(src)?.metadata().size as usize;
576            let mut stream = self.open(src)?;
577            trace!("File opened");
578            let mut bytes: usize = 0;
579            let mut buffer: [u8; 65535] = [0; 65535];
580            while bytes < transfer_size {
581                let bytes_read = stream.read(&mut buffer).map_err(|e| {
582                    error!("Failed to read from stream: {e}");
583                    RemoteError::new_ex(RemoteErrorType::IoError, e)
584                })?;
585                let mut delta = 0;
586                while delta < bytes_read {
587                    delta += dest.write(&buffer[delta..bytes_read]).map_err(|e| {
588                        error!("Failed to write to file: {e}",);
589                        RemoteError::new_ex(RemoteErrorType::IoError, e)
590                    })?;
591                }
592                bytes += bytes_read;
593            }
594            self.on_read(stream)?;
595            trace!("Copied {bytes} bytes to destination",);
596            Ok(bytes as u64)
597        } else {
598            Err(RemoteError::new(RemoteErrorType::NotConnected))
599        }
600    }
601}
602
603#[cfg(test)]
604mod tests;