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
160                .open_write(dest, WriteMode::Truncate, mode)
161                .map_err(|e| {
162                    error!("Failed to open {} for writing: {e}", dest.display());
163                    RemoteError::new_ex(RemoteErrorType::FileCreateDenied, e)
164                })?;
165            let mut buffer = [0u8; 65535];
166            loop {
167                let bytes_read = reader
168                    .read(&mut buffer)
169                    .map_err(|e| RemoteError::new_ex(RemoteErrorType::IoError, e))?;
170                if bytes_read == 0 {
171                    break;
172                }
173                writer
174                    .write_all(&buffer[..bytes_read])
175                    .map_err(|e| RemoteError::new_ex(RemoteErrorType::IoError, e))?;
176            }
177            writer
178                .flush()
179                .map_err(|e| RemoteError::new_ex(RemoteErrorType::IoError, e))?;
180        }
181
182        Ok(())
183    }
184
185    /// Check connection status
186    fn check_connection(&mut self) -> RemoteResult<()> {
187        if self.is_connected() {
188            Ok(())
189        } else {
190            Err(RemoteError::new(RemoteErrorType::NotConnected))
191        }
192    }
193}
194
195impl<S> RemoteFs for SftpFs<S>
196where
197    S: SshSession,
198{
199    fn connect(&mut self) -> RemoteResult<Welcome> {
200        debug!("Initializing SFTP connection...");
201        let session = S::connect(&self.opts)?;
202        // Get SFTP client first so we can resolve the working directory without shell commands
203        debug!("Getting SFTP client...");
204        let sftp = match session.sftp() {
205            Ok(s) => s,
206            Err(err) => {
207                error!("Could not get sftp client: {err}");
208                return Err(RemoteError::new_ex(RemoteErrorType::ProtocolError, err));
209            }
210        };
211        // Resolve working directory via SFTP realpath instead of shell `pwd`
212        debug!("Getting working directory...");
213        self.wrkdir = sftp.realpath(Path::new(".")).map_err(|err| {
214            error!("Could not resolve working directory: {err}");
215            RemoteError::new_ex(RemoteErrorType::ProtocolError, err)
216        })?;
217        self.session = Some(session);
218        self.sftp = Some(sftp);
219        let banner = self.session.as_ref().unwrap().banner()?;
220        debug!(
221            "Connection established: '{}'; working directory {}",
222            banner.as_deref().unwrap_or(""),
223            self.wrkdir.display()
224        );
225        Ok(Welcome::default().banner(banner))
226    }
227
228    fn disconnect(&mut self) -> RemoteResult<()> {
229        debug!("Disconnecting from remote...");
230        if let Some(session) = self.session.as_ref() {
231            // First free sftp
232            self.sftp = None;
233            // Disconnect (greet server with 'Mandi' as they do in Friuli)
234            match session.disconnect() {
235                Ok(_) => {
236                    // Set session and sftp to none
237                    self.session = None;
238                    Ok(())
239                }
240                Err(err) => Err(RemoteError::new_ex(RemoteErrorType::ConnectionError, err)),
241            }
242        } else {
243            Err(RemoteError::new(RemoteErrorType::NotConnected))
244        }
245    }
246
247    fn is_connected(&mut self) -> bool {
248        self.session
249            .as_ref()
250            .map(|x| x.authenticated().unwrap_or_default())
251            .unwrap_or_default()
252    }
253
254    fn pwd(&mut self) -> RemoteResult<PathBuf> {
255        self.check_connection()?;
256        Ok(self.wrkdir.clone())
257    }
258
259    fn change_dir(&mut self, dir: &Path) -> RemoteResult<PathBuf> {
260        self.check_connection()?;
261        let dir = path_utils::absolutize(self.wrkdir.as_path(), dir);
262        // Stat path to check if it exists. If it is a file, return error
263        match self.stat(dir.as_path()) {
264            Err(err) => Err(err),
265            Ok(file) if file.is_dir() => {
266                self.wrkdir = dir;
267                debug!("Changed working directory to {}", self.wrkdir.display());
268                Ok(self.wrkdir.clone())
269            }
270            Ok(_) => Err(RemoteError::new_ex(
271                RemoteErrorType::BadFile,
272                "expected directory, got file",
273            )),
274        }
275    }
276
277    fn list_dir(&mut self, path: &Path) -> RemoteResult<Vec<File>> {
278        if let Some(sftp) = self.sftp.as_ref() {
279            let path = path_utils::absolutize(self.wrkdir.as_path(), path);
280            debug!("Reading directory content of {}", path.display());
281            match sftp.readdir(path.as_path()) {
282                Err(err) => Err(RemoteError::new_ex(RemoteErrorType::StatFailed, err)),
283                Ok(files) => Ok(files),
284            }
285        } else {
286            Err(RemoteError::new(RemoteErrorType::NotConnected))
287        }
288    }
289
290    fn stat(&mut self, path: &Path) -> RemoteResult<File> {
291        if let Some(sftp) = self.sftp.as_ref() {
292            let path = path_utils::absolutize(self.wrkdir.as_path(), path);
293            debug!("Collecting metadata for {}", path.display());
294            sftp.stat(path.as_path()).map_err(|e| {
295                error!("Stat failed: {e}");
296                RemoteError::new_ex(RemoteErrorType::NoSuchFileOrDirectory, e)
297            })
298        } else {
299            Err(RemoteError::new(RemoteErrorType::NotConnected))
300        }
301    }
302
303    fn setstat(&mut self, path: &Path, metadata: Metadata) -> RemoteResult<()> {
304        if let Some(sftp) = self.sftp.as_ref() {
305            let path = path_utils::absolutize(self.wrkdir.as_path(), path);
306            debug!("Setting metadata for {}", path.display());
307            sftp.setstat(path.as_path(), metadata)
308                .map(|_| ())
309                .map_err(|e| {
310                    error!("Setstat failed: {e}");
311                    RemoteError::new_ex(RemoteErrorType::StatFailed, e)
312                })
313        } else {
314            Err(RemoteError::new(RemoteErrorType::NotConnected))
315        }
316    }
317
318    fn exists(&mut self, path: &Path) -> RemoteResult<bool> {
319        match self.stat(path) {
320            Ok(_) => Ok(true),
321            Err(RemoteError {
322                kind: RemoteErrorType::NoSuchFileOrDirectory,
323                ..
324            }) => Ok(false),
325            Err(err) => Err(err),
326        }
327    }
328
329    fn remove_file(&mut self, path: &Path) -> RemoteResult<()> {
330        if let Some(sftp) = self.sftp.as_ref() {
331            let path = path_utils::absolutize(self.wrkdir.as_path(), path);
332            debug!("Remove file {}", path.display());
333            sftp.unlink(path.as_path()).map_err(|e| {
334                error!("Remove failed: {e}");
335                RemoteError::new_ex(RemoteErrorType::CouldNotRemoveFile, e)
336            })
337        } else {
338            Err(RemoteError::new(RemoteErrorType::NotConnected))
339        }
340    }
341
342    fn remove_dir(&mut self, path: &Path) -> RemoteResult<()> {
343        if let Some(sftp) = self.sftp.as_ref() {
344            let path = path_utils::absolutize(self.wrkdir.as_path(), path);
345            debug!("Remove dir {}", path.display());
346            sftp.rmdir(path.as_path()).map_err(|e| {
347                error!("Remove failed: {e}");
348                RemoteError::new_ex(RemoteErrorType::CouldNotRemoveFile, e)
349            })
350        } else {
351            Err(RemoteError::new(RemoteErrorType::NotConnected))
352        }
353    }
354
355    fn remove_dir_all(&mut self, path: &Path) -> RemoteResult<()> {
356        self.check_connection()?;
357        let path = path_utils::absolutize(self.wrkdir.as_path(), path);
358        if !self.exists(path.as_path()).ok().unwrap_or(false) {
359            return Err(RemoteError::new(RemoteErrorType::NoSuchFileOrDirectory));
360        }
361        debug!("Removing directory {} recursively", path.display());
362        let sftp = self.sftp.as_ref().unwrap();
363        Self::remove_dir_all_recursive(sftp, &path)
364    }
365
366    fn create_dir(&mut self, path: &Path, mode: UnixPex) -> RemoteResult<()> {
367        self.check_connection()?;
368        let path = path_utils::absolutize(self.wrkdir.as_path(), path);
369        // Check if already exists
370        debug!(
371            "Creating directory {} (mode: {:o})",
372            path.display(),
373            u32::from(mode)
374        );
375        if self.exists(path.as_path())? {
376            error!("directory {} already exists", path.display());
377            return Err(RemoteError::new(RemoteErrorType::DirectoryAlreadyExists));
378        }
379        self.sftp
380            .as_ref()
381            .unwrap()
382            .mkdir(path.as_path(), u32::from(mode) as i32)
383            .map_err(|e| {
384                error!("Create dir failed: {e}");
385                RemoteError::new_ex(RemoteErrorType::FileCreateDenied, e)
386            })
387    }
388
389    fn symlink(&mut self, path: &Path, target: &Path) -> RemoteResult<()> {
390        self.check_connection()?;
391        let path = path_utils::absolutize(self.wrkdir.as_path(), path);
392        // Check if already exists
393        debug!(
394            "Creating symlink at {} pointing to {}",
395            path.display(),
396            target.display()
397        );
398        if !self.exists(target)? {
399            error!("target {} doesn't exist", target.display());
400            return Err(RemoteError::new(RemoteErrorType::NoSuchFileOrDirectory));
401        }
402        self.sftp
403            .as_ref()
404            .unwrap()
405            .symlink(target, path.as_path())
406            .map_err(|e| {
407                error!("Symlink failed: {e}");
408                RemoteError::new_ex(RemoteErrorType::FileCreateDenied, e)
409            })
410    }
411
412    fn copy(&mut self, src: &Path, dest: &Path) -> RemoteResult<()> {
413        self.check_connection()?;
414        let src = path_utils::absolutize(self.wrkdir.as_path(), src);
415        if !self.exists(src.as_path()).ok().unwrap_or(false) {
416            return Err(RemoteError::new(RemoteErrorType::NoSuchFileOrDirectory));
417        }
418        let dest = path_utils::absolutize(self.wrkdir.as_path(), dest);
419        debug!("Copying {} to {}", src.display(), dest.display());
420        let sftp = self.sftp.as_ref().unwrap();
421        Self::copy_recursive(sftp, &src, &dest)
422    }
423
424    fn mov(&mut self, src: &Path, dest: &Path) -> RemoteResult<()> {
425        self.check_connection()?;
426        let src = path_utils::absolutize(self.wrkdir.as_path(), src);
427        // check if file exists
428        if !self.exists(src.as_path()).ok().unwrap_or(false) {
429            return Err(RemoteError::new(RemoteErrorType::NoSuchFileOrDirectory));
430        }
431        let dest = path_utils::absolutize(self.wrkdir.as_path(), dest);
432        debug!("Moving {} to {}", src.display(), dest.display());
433        self.sftp
434            .as_ref()
435            .unwrap()
436            .rename(src.as_path(), dest.as_path())
437            .map_err(|e| {
438                error!("Move failed: {e}",);
439                RemoteError::new_ex(RemoteErrorType::FileCreateDenied, e)
440            })
441    }
442
443    fn exec(&mut self, cmd: &str) -> RemoteResult<(u32, String)> {
444        self.check_connection()?;
445        debug!(r#"Executing command "{cmd}""#);
446
447        self.session
448            .as_mut()
449            .unwrap()
450            .cmd_at(cmd, self.wrkdir.as_path())
451    }
452
453    fn append(&mut self, path: &Path, metadata: &Metadata) -> RemoteResult<WriteStream> {
454        if let Some(sftp) = self.sftp.as_ref() {
455            let path = path_utils::absolutize(self.wrkdir.as_path(), path);
456            debug!("Opening file at {} for appending", path.display());
457            let mode = metadata.mode.map(|x| u32::from(x) as i32).unwrap_or(0o644);
458            sftp.open_write(path.as_path(), WriteMode::Append, mode)
459                .map_err(|e| {
460                    error!("Append failed: {e}",);
461                    RemoteError::new_ex(RemoteErrorType::CouldNotOpenFile, e)
462                })
463        } else {
464            Err(RemoteError::new(RemoteErrorType::NotConnected))
465        }
466    }
467
468    fn create(&mut self, path: &Path, metadata: &Metadata) -> RemoteResult<WriteStream> {
469        if let Some(sftp) = self.sftp.as_ref() {
470            let path = path_utils::absolutize(self.wrkdir.as_path(), path);
471            debug!("Creating file at {}", path.display());
472            let mode = metadata.mode.map(|x| u32::from(x) as i32).unwrap_or(0o644);
473            sftp.open_write(path.as_path(), WriteMode::Truncate, mode)
474                .map_err(|e| {
475                    error!("Create failed: {e}",);
476                    RemoteError::new_ex(RemoteErrorType::FileCreateDenied, e)
477                })
478        } else {
479            Err(RemoteError::new(RemoteErrorType::NotConnected))
480        }
481    }
482
483    fn open(&mut self, path: &Path) -> RemoteResult<ReadStream> {
484        self.check_connection()?;
485        let path = path_utils::absolutize(self.wrkdir.as_path(), path);
486        // check if file exists
487        if !self.exists(path.as_path()).ok().unwrap_or(false) {
488            return Err(RemoteError::new(RemoteErrorType::NoSuchFileOrDirectory));
489        }
490        debug!("Opening file at {}", path.display());
491        self.sftp
492            .as_ref()
493            .unwrap()
494            .open_read(path.as_path())
495            .map_err(|e| {
496                error!("Open failed: {e}",);
497                RemoteError::new_ex(RemoteErrorType::CouldNotOpenFile, e)
498            })
499    }
500
501    // -- override (std::io::copy is VERY slow on SFTP <https://github.com/remotefs-rs/remotefs-rs/issues/6>)
502
503    fn append_file(
504        &mut self,
505        path: &Path,
506        metadata: &Metadata,
507        mut reader: Box<dyn Read + Send>,
508    ) -> RemoteResult<u64> {
509        if self.is_connected() {
510            let mut stream = self.append(path, metadata)?;
511            trace!("Opened remote file");
512            let mut bytes: usize = 0;
513            let transfer_size = metadata.size as usize;
514            let mut buffer: [u8; 65535] = [0; 65535];
515            while bytes < transfer_size {
516                let bytes_read = reader.read(&mut buffer).map_err(|e| {
517                    error!("Failed to read from file: {e}",);
518                    RemoteError::new_ex(RemoteErrorType::IoError, e)
519                })?;
520                let mut delta = 0;
521                while delta < bytes_read {
522                    delta += stream.write(&buffer[delta..bytes_read]).map_err(|e| {
523                        error!("Failed to write to stream: {e}",);
524                        RemoteError::new_ex(RemoteErrorType::IoError, e)
525                    })?;
526                }
527                bytes += bytes_read;
528            }
529            self.on_written(stream)?;
530            trace!("Written {bytes} bytes to destination",);
531            Ok(bytes as u64)
532        } else {
533            Err(RemoteError::new(RemoteErrorType::NotConnected))
534        }
535    }
536
537    fn create_file(
538        &mut self,
539        path: &Path,
540        metadata: &Metadata,
541        mut reader: Box<dyn std::io::Read + Send>,
542    ) -> RemoteResult<u64> {
543        if self.is_connected() {
544            let mut stream = self.create(path, metadata)?;
545            trace!("Opened remote file");
546            let mut bytes: usize = 0;
547            let transfer_size = metadata.size as usize;
548            let mut buffer: [u8; 65535] = [0; 65535];
549            while bytes < transfer_size {
550                let bytes_read = reader.read(&mut buffer).map_err(|e| {
551                    error!("Failed to read from file: {e}",);
552                    RemoteError::new_ex(RemoteErrorType::IoError, e)
553                })?;
554                let mut delta = 0;
555                while delta < bytes_read {
556                    delta += stream.write(&buffer[delta..bytes_read]).map_err(|e| {
557                        error!("Failed to write to stream: {e}",);
558                        RemoteError::new_ex(RemoteErrorType::IoError, e)
559                    })?;
560                }
561                bytes += bytes_read;
562            }
563            stream.flush().map_err(|e| {
564                error!("Failed to flush stream: {e}");
565                RemoteError::new_ex(RemoteErrorType::IoError, e)
566            })?;
567            self.on_written(stream)?;
568            trace!("Written {bytes} bytes to destination",);
569            Ok(bytes as u64)
570        } else {
571            Err(RemoteError::new(RemoteErrorType::NotConnected))
572        }
573    }
574
575    fn open_file(&mut self, src: &Path, mut dest: Box<dyn Write + Send>) -> RemoteResult<u64> {
576        if self.is_connected() {
577            let transfer_size = self.stat(src)?.metadata().size as usize;
578            let mut stream = self.open(src)?;
579            trace!("File opened");
580            let mut bytes: usize = 0;
581            let mut buffer: [u8; 65535] = [0; 65535];
582            while bytes < transfer_size {
583                let bytes_read = stream.read(&mut buffer).map_err(|e| {
584                    error!("Failed to read from stream: {e}");
585                    RemoteError::new_ex(RemoteErrorType::IoError, e)
586                })?;
587                let mut delta = 0;
588                while delta < bytes_read {
589                    delta += dest.write(&buffer[delta..bytes_read]).map_err(|e| {
590                        error!("Failed to write to file: {e}",);
591                        RemoteError::new_ex(RemoteErrorType::IoError, e)
592                    })?;
593                }
594                bytes += bytes_read;
595            }
596            self.on_read(stream)?;
597            trace!("Copied {bytes} bytes to destination",);
598            Ok(bytes as u64)
599        } else {
600            Err(RemoteError::new(RemoteErrorType::NotConnected))
601        }
602    }
603}
604
605#[cfg(test)]
606mod tests;