Skip to main content

remotefs_ssh/ssh/
scp.rs

1//! ## SCP
2//!
3//! Scp remote fs implementation
4
5use std::collections::HashMap;
6use std::ops::Range;
7use std::path::{Path, PathBuf};
8use std::time::{Duration, SystemTime};
9
10use lazy_regex::{Lazy, Regex};
11use remotefs::File;
12use remotefs::fs::{
13    FileType, Metadata, ReadStream, RemoteError, RemoteErrorType, RemoteFs, RemoteResult, UnixPex,
14    UnixPexClass, Welcome, WriteStream,
15};
16
17use super::SshOpts;
18use crate::SshSession;
19use crate::utils::{fmt as fmt_utils, parser as parser_utils, path as path_utils};
20
21/// NOTE: about this damn regex <https://stackoverflow.com/questions/32480890/is-there-a-regex-to-parse-the-values-from-an-ftp-directory-listing>
22static LS_RE: Lazy<Regex> = lazy_regex!(
23    r#"^(?<sym_dir>[\-ld])(?<pex>[\-rwxsStT]{9})(?<sec_ctx>\.|\+|\@)?\s+(?<n_links>\d+)\s+(?<uid>.+)\s+(?<gid>.+)\s+(?<size>\d+)\s+(?<date_time>\w{3}\s+\d{1,2}\s+(?:\d{1,2}:\d{1,2}|\d{4}))\s+(?<name>.+)$"#
24);
25
26/// Which `stat(1)` format flags the remote host accepts.
27///
28/// `ls -l` prints times in the server's local timezone with no offset, so we
29/// use `stat` to get a timezone-free Unix epoch. The CLI flags differ between
30/// GNU coreutils and BSD `stat`, so we probe once per session and cache the
31/// result.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33enum StatFlavor {
34    /// GNU coreutils: `stat -c '<fmt>'`.
35    Gnu,
36    /// BSD / macOS: `stat -f '<fmt>'`.
37    Bsd,
38    /// Neither flavor is available; fall back to parsing `ls` output.
39    Unsupported,
40}
41
42/// SCP "filesystem" client
43pub struct ScpFs<S>
44where
45    S: SshSession,
46{
47    session: Option<S>,
48    wrkdir: PathBuf,
49    opts: SshOpts,
50    /// Cached `stat(1)` flavor for the remote host; probed lazily on first use.
51    stat_flavor: Option<StatFlavor>,
52}
53
54#[cfg(feature = "libssh2")]
55#[cfg_attr(docsrs, doc(cfg(feature = "libssh2")))]
56impl ScpFs<super::backend::LibSsh2Session> {
57    /// Constructs a new [`ScpFs`] instance with the `libssh2` backend.
58    pub fn libssh2(opts: SshOpts) -> Self {
59        Self {
60            session: None,
61            wrkdir: PathBuf::from("/"),
62            opts,
63            stat_flavor: None,
64        }
65    }
66}
67
68#[cfg(feature = "libssh")]
69#[cfg_attr(docsrs, doc(cfg(feature = "libssh")))]
70impl ScpFs<super::backend::LibSshSession> {
71    /// Constructs a new [`ScpFs`] instance with the `libssh` backend.
72    pub fn libssh(opts: SshOpts) -> Self {
73        Self {
74            session: None,
75            wrkdir: PathBuf::from("/"),
76            opts,
77            stat_flavor: None,
78        }
79    }
80}
81
82#[cfg(feature = "russh")]
83#[cfg_attr(docsrs, doc(cfg(feature = "russh")))]
84impl<T> ScpFs<super::backend::RusshSession<T>>
85where
86    T: russh::client::Handler + Default + Send + 'static,
87{
88    /// Constructs a new [`ScpFs`] instance with the `russh` backend.
89    pub fn russh(opts: SshOpts, runtime: std::sync::Arc<tokio::runtime::Runtime>) -> Self {
90        let opts = opts.runtime(runtime);
91        Self {
92            session: None,
93            wrkdir: PathBuf::from("/"),
94            opts,
95            stat_flavor: None,
96        }
97    }
98}
99
100impl<S> ScpFs<S>
101where
102    S: SshSession,
103{
104    /// Get a reference to current `session` value.
105    pub fn session(&mut self) -> Option<&mut S> {
106        self.session.as_mut()
107    }
108
109    // -- private
110
111    /// Check connection status
112    fn check_connection(&mut self) -> RemoteResult<()> {
113        if self.is_connected() {
114            Ok(())
115        } else {
116            Err(RemoteError::new(RemoteErrorType::NotConnected))
117        }
118    }
119
120    /// Parse a line of `ls -l` output and tokenize the output into a `FsFile`
121    fn parse_ls_output(&self, path: &Path, line: &str) -> Result<File, ()> {
122        // Prepare list regex
123        trace!("Parsing LS line: '{line}'");
124        // Apply regex to result
125        match LS_RE.captures(line) {
126            // String matches regex
127            Some(metadata) => {
128                // NOTE: metadata fmt: (regex, file_type, permissions, link_count, uid, gid, filesize, modified, filename)
129                // Expected 7 + 1 (8) values: + 1 cause regex is repeated at 0
130                if metadata.len() < 8 {
131                    return Err(());
132                }
133                // Collect metadata
134                // Get if is directory and if is symlink
135
136                let (is_dir, is_symlink): (bool, bool) = match &metadata["sym_dir"] {
137                    "-" => (false, false),
138                    "l" => (false, true),
139                    "d" => (true, false),
140                    _ => return Err(()), // Ignore special files
141                };
142                // Check string length (unix pex)
143                if metadata["pex"].len() < 9 {
144                    return Err(());
145                }
146
147                let pex = |range: Range<usize>| {
148                    let mut count: u8 = 0;
149                    for (i, c) in metadata["pex"][range].chars().enumerate() {
150                        match c {
151                            '-' => {}
152                            _ => {
153                                count += match i {
154                                    0 => 4,
155                                    1 => 2,
156                                    2 => 1,
157                                    _ => 0,
158                                }
159                            }
160                        }
161                    }
162                    count
163                };
164
165                // Get unix pex
166                let mode = UnixPex::new(
167                    UnixPexClass::from(pex(0..3)),
168                    UnixPexClass::from(pex(3..6)),
169                    UnixPexClass::from(pex(6..9)),
170                );
171
172                // Parse modified and convert to SystemTime
173                let modified: SystemTime = match parser_utils::parse_lstime(
174                    &metadata["date_time"],
175                    "%b %d %Y",
176                    "%b %d %H:%M",
177                ) {
178                    Ok(t) => t,
179                    Err(_) => SystemTime::UNIX_EPOCH,
180                };
181                // Get uid
182                let uid: Option<u32> = metadata["uid"].parse::<u32>().ok();
183                // Get gid
184                let gid: Option<u32> = metadata["gid"].parse::<u32>().ok();
185                // Get filesize
186                let size = metadata["size"].parse::<u64>().unwrap_or(0);
187                // Get link and name
188                let (file_name, symlink): (String, Option<PathBuf>) = match is_symlink {
189                    true => self.get_name_and_link(&metadata["name"]),
190                    false => (String::from(&metadata["name"]), None),
191                };
192                // Sanitize file name
193                let file_name = PathBuf::from(&file_name)
194                    .file_name()
195                    .map(|x| x.to_string_lossy().to_string())
196                    .unwrap_or(file_name);
197                // Check if file_name is '.' or '..'
198                if file_name.as_str() == "." || file_name.as_str() == ".." {
199                    return Err(());
200                }
201                // Re-check if is directory
202                let mut path: PathBuf = path.to_path_buf();
203                path.push(file_name.as_str());
204                // get file type
205                let file_type = if symlink.is_some() {
206                    FileType::Symlink
207                } else if is_dir {
208                    FileType::Directory
209                } else {
210                    FileType::File
211                };
212                // make metadata
213                let metadata = Metadata {
214                    accessed: None,
215                    created: None,
216                    file_type,
217                    gid,
218                    mode: Some(mode),
219                    modified: Some(modified),
220                    size,
221                    symlink,
222                    uid,
223                };
224                trace!(
225                    "Found entry at {} with metadata {:?}",
226                    path.display(),
227                    metadata
228                );
229                // Push to entries
230                Ok(File { path, metadata })
231            }
232            None => Err(()),
233        }
234    }
235
236    /// ### get_name_and_link
237    ///
238    /// Returns from a `ls -l` command output file name token, the name of the file and the symbolic link (if there is any)
239    fn get_name_and_link(&self, token: &str) -> (String, Option<PathBuf>) {
240        let tokens: Vec<&str> = token.split(" -> ").collect();
241        let filename: String = String::from(*tokens.first().unwrap());
242        let symlink: Option<PathBuf> = tokens.get(1).map(PathBuf::from);
243        (filename, symlink)
244    }
245
246    /// Execute setstat command and assert result is 0
247    fn assert_stat_command(&mut self, cmd: String) -> RemoteResult<()> {
248        match self.session.as_mut().unwrap().cmd(cmd) {
249            Ok((0, _)) => Ok(()),
250            Ok(_) => Err(RemoteError::new(RemoteErrorType::StatFailed)),
251            Err(err) => Err(RemoteError::new_ex(RemoteErrorType::ProtocolError, err)),
252        }
253    }
254
255    /// Returns whether file at `path` is a directory
256    fn is_directory(&mut self, path: &Path) -> RemoteResult<bool> {
257        let path = path_utils::absolutize(self.wrkdir.as_path(), path);
258        match self
259            .session
260            .as_mut()
261            .unwrap()
262            .cmd(format!("test -d \"{}\"", path.display()))
263        {
264            Ok((0, _)) => Ok(true),
265            Ok(_) => Ok(false),
266            Err(err) => Err(RemoteError::new_ex(RemoteErrorType::StatFailed, err)),
267        }
268    }
269
270    /// Detect which `stat(1)` flavor the remote host supports.
271    ///
272    /// Probes GNU first (`stat --version`, which BSD rejects) then BSD (`stat
273    /// -f %m /`). The result is cached on the session; callers pay at most
274    /// one extra roundtrip per connection. Returns
275    /// [`StatFlavor::Unsupported`] when neither flavor works so the caller
276    /// can fall back to the `ls`-based parser.
277    fn stat_flavor(&mut self) -> StatFlavor {
278        if let Some(flavor) = self.stat_flavor {
279            return flavor;
280        }
281        let session = self.session.as_mut().unwrap();
282        let flavor = match session.cmd("stat --version >/dev/null 2>&1") {
283            Ok((0, _)) => StatFlavor::Gnu,
284            _ => match session.cmd("stat -f %m / >/dev/null 2>&1") {
285                Ok((0, _)) => StatFlavor::Bsd,
286                _ => StatFlavor::Unsupported,
287            },
288        };
289        trace!("Detected remote stat flavor: {flavor:?}");
290        self.stat_flavor = Some(flavor);
291        flavor
292    }
293
294    /// Fetch the Unix epoch mtime of a single `path` via `stat`.
295    ///
296    /// Returns [`None`] when the remote host exposes neither GNU nor BSD
297    /// `stat`, or when the command fails.
298    fn mtime_epoch(&mut self, path: &Path) -> Option<SystemTime> {
299        let flag = match self.stat_flavor() {
300            StatFlavor::Gnu => "-c %Y",
301            StatFlavor::Bsd => "-f %m",
302            StatFlavor::Unsupported => return None,
303        };
304        let cmd = format!("stat {} \"{}\"", flag, path.display());
305        match self.session.as_mut().unwrap().cmd(cmd) {
306            Ok((0, output)) => parser_utils::parse_stat_epoch(&output),
307            _ => None,
308        }
309    }
310
311    /// Fetch the Unix epoch mtime for every entry in `entries` under `dir`
312    /// with a single batched `stat` invocation.
313    ///
314    /// Returns a map from basename to [`SystemTime`]. Entries missing from
315    /// the map should fall back to the `ls`-parsed timestamp.
316    fn mtimes_in_dir(&mut self, dir: &Path, entries: &[&str]) -> HashMap<String, SystemTime> {
317        if entries.is_empty() {
318            return HashMap::new();
319        }
320        let fmt = match self.stat_flavor() {
321            StatFlavor::Gnu => "-c '%Y %n'",
322            StatFlavor::Bsd => "-f '%m %N'",
323            StatFlavor::Unsupported => return HashMap::new(),
324        };
325        let args: Vec<String> = entries
326            .iter()
327            .map(|name| format!("\"{}\"", dir.join(name).display()))
328            .collect();
329        let cmd = format!("stat {} {}", fmt, args.join(" "));
330        match self.session.as_mut().unwrap().cmd(cmd) {
331            Ok((_, output)) => parser_utils::parse_stat_listing(&output),
332            Err(err) => {
333                warn!("Batched stat failed, falling back to ls timestamps: {err}");
334                HashMap::new()
335            }
336        }
337    }
338}
339
340impl<S> RemoteFs for ScpFs<S>
341where
342    S: SshSession,
343{
344    fn connect(&mut self) -> RemoteResult<Welcome> {
345        debug!("Initializing SFTP connection...");
346        let mut session = S::connect(&self.opts)?;
347        // Get banner
348        let banner = session.banner()?;
349        debug!(
350            "Connection established: {}",
351            banner.as_deref().unwrap_or("")
352        );
353        // Get working directory
354        debug!("Getting working directory...");
355        self.wrkdir = session
356            .cmd("pwd")
357            .map(|(_rc, output)| PathBuf::from(output.as_str().trim()))?;
358        // Set session
359        self.session = Some(session);
360        info!(
361            "Connection established; working directory: {}",
362            self.wrkdir.display()
363        );
364        Ok(Welcome::default().banner(banner))
365    }
366
367    fn disconnect(&mut self) -> RemoteResult<()> {
368        debug!("Disconnecting from remote...");
369        if let Some(session) = self.session.as_ref() {
370            // Disconnect (greet server with 'Mandi' as they do in Friuli)
371            match session.disconnect() {
372                Ok(_) => {
373                    // Set session and sftp to none
374                    self.session = None;
375                    // Drop cached probe so a fresh connection re-detects.
376                    self.stat_flavor = None;
377                    Ok(())
378                }
379                Err(err) => Err(RemoteError::new_ex(RemoteErrorType::ConnectionError, err)),
380            }
381        } else {
382            Err(RemoteError::new(RemoteErrorType::NotConnected))
383        }
384    }
385
386    fn is_connected(&mut self) -> bool {
387        self.session
388            .as_ref()
389            .map(|x| x.authenticated().unwrap_or_default())
390            .unwrap_or(false)
391    }
392
393    fn pwd(&mut self) -> RemoteResult<PathBuf> {
394        self.check_connection()?;
395        Ok(self.wrkdir.clone())
396    }
397
398    fn change_dir(&mut self, dir: &Path) -> RemoteResult<PathBuf> {
399        self.check_connection()?;
400        let dir = path_utils::absolutize(self.wrkdir.as_path(), dir);
401        debug!("Changing working directory to {}", dir.display());
402        match self
403            .session
404            .as_mut()
405            .unwrap()
406            .cmd(format!("cd \"{}\"; echo $?; pwd", dir.display()))
407        {
408            Ok((rc, output)) => {
409                if rc != 0 {
410                    return Err(RemoteError::new_ex(
411                        RemoteErrorType::ProtocolError,
412                        format!("Failed to change directory: {}", output),
413                    ));
414                }
415                // Trim
416                let output: String = String::from(output.as_str().trim());
417                // Check if output starts with 0; should be 0{PWD}
418                match output.as_str().starts_with('0') {
419                    true => {
420                        // Set working directory
421                        self.wrkdir = PathBuf::from(&output.as_str()[1..].trim());
422                        debug!("Changed working directory to {}", self.wrkdir.display());
423                        Ok(self.wrkdir.clone())
424                    }
425                    false => Err(RemoteError::new_ex(
426                        // No such file or directory
427                        RemoteErrorType::NoSuchFileOrDirectory,
428                        format!("\"{}\"", dir.display()),
429                    )),
430                }
431            }
432            Err(err) => Err(RemoteError::new_ex(RemoteErrorType::ProtocolError, err)),
433        }
434    }
435
436    fn list_dir(&mut self, path: &Path) -> RemoteResult<Vec<File>> {
437        self.check_connection()?;
438        let path = path_utils::absolutize(self.wrkdir.as_path(), path);
439        debug!("Getting file entries in {}", path.display());
440        // check if exists
441        if !self.exists(path.as_path()).ok().unwrap_or(false) {
442            return Err(RemoteError::new(RemoteErrorType::NoSuchFileOrDirectory));
443        }
444        match self
445            .session
446            .as_mut()
447            .unwrap()
448            .cmd(format!("unset LANG; ls -la \"{}/\"", path.display()).as_str())
449        {
450            Ok((rc, output)) => {
451                if rc != 0 {
452                    return Err(RemoteError::new_ex(
453                        RemoteErrorType::ProtocolError,
454                        format!("Failed to list directory: {}", output),
455                    ));
456                }
457                // Split output by (\r)\n
458                let lines: Vec<&str> = output.as_str().lines().collect();
459                let mut entries: Vec<File> = Vec::with_capacity(lines.len());
460                for line in lines.iter() {
461                    // First line must always be ignored
462                    // Parse row, if ok push to entries
463                    if let Ok(entry) = self.parse_ls_output(path.as_path(), line) {
464                        entries.push(entry);
465                    }
466                }
467                // Override `ls`-parsed mtimes (which are TZ-ambiguous) with
468                // the server's Unix epoch via `stat`. One batched roundtrip.
469                let names: Vec<String> = entries.iter().map(|e| e.name()).collect();
470                let name_refs: Vec<&str> = names.iter().map(String::as_str).collect();
471                let mtimes = self.mtimes_in_dir(path.as_path(), &name_refs);
472                for (entry, name) in entries.iter_mut().zip(names.iter()) {
473                    if let Some(t) = mtimes.get(name) {
474                        entry.metadata.modified = Some(*t);
475                    }
476                }
477                debug!(
478                    "Found {} out of {} valid file entries",
479                    entries.len(),
480                    lines.len()
481                );
482                Ok(entries)
483            }
484            Err(err) => Err(RemoteError::new_ex(RemoteErrorType::ProtocolError, err)),
485        }
486    }
487
488    fn stat(&mut self, path: &Path) -> RemoteResult<File> {
489        self.check_connection()?;
490        let path = path_utils::absolutize(self.wrkdir.as_path(), path);
491        debug!("Stat {}", path.display());
492        // make command; Directories require `-d` option
493        let cmd = match self.is_directory(path.as_path())? {
494            true => format!("ls -ld \"{}\"", path.display()),
495            false => format!("ls -l \"{}\"", path.display()),
496        };
497        match self.session.as_mut().unwrap().cmd(cmd.as_str()) {
498            Ok((rc, line)) => {
499                if rc != 0 {
500                    return Err(RemoteError::new_ex(
501                        RemoteErrorType::NoSuchFileOrDirectory,
502                        format!("Failed to stat file: {line}"),
503                    ));
504                }
505                // Parse ls line
506                let parent: PathBuf = match path.as_path().parent() {
507                    Some(p) => PathBuf::from(p),
508                    None => {
509                        return Err(RemoteError::new_ex(
510                            RemoteErrorType::StatFailed,
511                            "Path has no parent",
512                        ));
513                    }
514                };
515                match self.parse_ls_output(parent.as_path(), line.as_str().trim()) {
516                    Ok(mut entry) => {
517                        // Override TZ-ambiguous `ls` mtime with Unix epoch.
518                        if let Some(t) = self.mtime_epoch(path.as_path()) {
519                            entry.metadata.modified = Some(t);
520                        }
521                        Ok(entry)
522                    }
523                    Err(_) => Err(RemoteError::new(RemoteErrorType::NoSuchFileOrDirectory)),
524                }
525            }
526            Err(err) => Err(RemoteError::new_ex(RemoteErrorType::ProtocolError, err)),
527        }
528    }
529
530    fn exists(&mut self, path: &Path) -> RemoteResult<bool> {
531        self.check_connection()?;
532        let path = path_utils::absolutize(self.wrkdir.as_path(), path);
533        match self
534            .session
535            .as_mut()
536            .unwrap()
537            .cmd(format!("test -e \"{}\"", path.display()))
538        {
539            Ok((0, _)) => Ok(true),
540            Ok(_) => Ok(false),
541            Err(err) => Err(RemoteError::new_ex(RemoteErrorType::StatFailed, err)),
542        }
543    }
544
545    fn setstat(&mut self, path: &Path, metadata: Metadata) -> RemoteResult<()> {
546        self.check_connection()?;
547        let path = path_utils::absolutize(self.wrkdir.as_path(), path);
548        debug!("Setting attributes for {}", path.display());
549        if !self.exists(path.as_path()).ok().unwrap_or(false) {
550            return Err(RemoteError::new(RemoteErrorType::NoSuchFileOrDirectory));
551        }
552        // set mode with chmod
553        if let Some(mode) = metadata.mode {
554            self.assert_stat_command(format!(
555                "chmod {:o} \"{}\"",
556                u32::from(mode),
557                path.display()
558            ))?;
559        }
560        if let Some(user) = metadata.uid {
561            self.assert_stat_command(format!(
562                "chown {}{} \"{}\"",
563                user,
564                metadata.gid.map(|x| format!(":{x}")).unwrap_or_default(),
565                path.display()
566            ))?;
567        }
568        // set times
569        if let Some(accessed) = metadata.accessed {
570            self.assert_stat_command(format!(
571                "touch -a -t {} \"{}\"",
572                fmt_utils::fmt_time_utc(accessed, "%Y%m%d%H%M.%S"),
573                path.display()
574            ))?;
575        }
576        if let Some(modified) = metadata.modified {
577            self.assert_stat_command(format!(
578                "touch -m -t {} \"{}\"",
579                fmt_utils::fmt_time_utc(modified, "%Y%m%d%H%M.%S"),
580                path.display()
581            ))?;
582        }
583        Ok(())
584    }
585
586    fn remove_file(&mut self, path: &Path) -> RemoteResult<()> {
587        self.check_connection()?;
588        let path = path_utils::absolutize(self.wrkdir.as_path(), path);
589        if !self.exists(path.as_path()).ok().unwrap_or(false) {
590            return Err(RemoteError::new(RemoteErrorType::NoSuchFileOrDirectory));
591        }
592        debug!("Removing file {}", path.display());
593        match self
594            .session
595            .as_mut()
596            .unwrap()
597            .cmd(format!("rm -f \"{}\"", path.display()))
598        {
599            Ok((0, _)) => Ok(()),
600            Ok(_) => Err(RemoteError::new(RemoteErrorType::CouldNotRemoveFile)),
601            Err(err) => Err(RemoteError::new_ex(RemoteErrorType::ProtocolError, err)),
602        }
603    }
604
605    fn remove_dir(&mut self, path: &Path) -> RemoteResult<()> {
606        self.check_connection()?;
607        let path = path_utils::absolutize(self.wrkdir.as_path(), path);
608        if !self.exists(path.as_path()).ok().unwrap_or(false) {
609            return Err(RemoteError::new(RemoteErrorType::NoSuchFileOrDirectory));
610        }
611        debug!("Removing directory {}", path.display());
612        match self
613            .session
614            .as_mut()
615            .unwrap()
616            .cmd(format!("rmdir \"{}\"", path.display()))
617        {
618            Ok((0, _)) => Ok(()),
619            Ok(_) => Err(RemoteError::new(RemoteErrorType::DirectoryNotEmpty)),
620            Err(err) => Err(RemoteError::new_ex(RemoteErrorType::ProtocolError, err)),
621        }
622    }
623
624    fn remove_dir_all(&mut self, path: &Path) -> RemoteResult<()> {
625        self.check_connection()?;
626        let path = path_utils::absolutize(self.wrkdir.as_path(), path);
627        if !self.exists(path.as_path()).ok().unwrap_or(false) {
628            return Err(RemoteError::new(RemoteErrorType::NoSuchFileOrDirectory));
629        }
630        debug!("Removing directory {} recursively", path.display());
631        match self
632            .session
633            .as_mut()
634            .unwrap()
635            .cmd(format!("rm -rf \"{}\"", path.display()))
636        {
637            Ok((0, _)) => Ok(()),
638            Ok(_) => Err(RemoteError::new(RemoteErrorType::CouldNotRemoveFile)),
639            Err(err) => Err(RemoteError::new_ex(RemoteErrorType::ProtocolError, err)),
640        }
641    }
642
643    fn create_dir(&mut self, path: &Path, mode: UnixPex) -> RemoteResult<()> {
644        self.check_connection()?;
645        let path = path_utils::absolutize(self.wrkdir.as_path(), path);
646        if self.exists(path.as_path()).ok().unwrap_or(false) {
647            return Err(RemoteError::new(RemoteErrorType::DirectoryAlreadyExists));
648        }
649        let mode = format!("{:o}", u32::from(mode));
650        debug!(
651            "Creating directory at {} with mode {}",
652            path.display(),
653            mode
654        );
655        match self.session.as_mut().unwrap().cmd(format!(
656            "mkdir -m {} \"{}\"",
657            mode,
658            path.display()
659        )) {
660            Ok((0, _)) => Ok(()),
661            Ok(_) => Err(RemoteError::new(RemoteErrorType::FileCreateDenied)),
662            Err(err) => Err(RemoteError::new_ex(RemoteErrorType::ProtocolError, err)),
663        }
664    }
665
666    fn symlink(&mut self, path: &Path, target: &Path) -> RemoteResult<()> {
667        self.check_connection()?;
668        let path = path_utils::absolutize(self.wrkdir.as_path(), path);
669        debug!(
670            "Creating a symlink at {} pointing at {}",
671            path.display(),
672            target.display()
673        );
674        if !self.exists(target).ok().unwrap_or(false) {
675            return Err(RemoteError::new(RemoteErrorType::NoSuchFileOrDirectory));
676        }
677        if self.exists(path.as_path()).ok().unwrap_or(false) {
678            return Err(RemoteError::new(RemoteErrorType::FileCreateDenied));
679        }
680        match self.session.as_mut().unwrap().cmd(format!(
681            "ln -s \"{}\" \"{}\"",
682            target.display(),
683            path.display()
684        )) {
685            Ok((0, _)) => Ok(()),
686            Ok(_) => Err(RemoteError::new(RemoteErrorType::FileCreateDenied)),
687            Err(err) => Err(RemoteError::new_ex(RemoteErrorType::ProtocolError, err)),
688        }
689    }
690
691    fn copy(&mut self, src: &Path, dest: &Path) -> RemoteResult<()> {
692        self.check_connection()?;
693        let src = path_utils::absolutize(self.wrkdir.as_path(), src);
694        // check if file exists
695        if !self.exists(src.as_path()).ok().unwrap_or(false) {
696            return Err(RemoteError::new(RemoteErrorType::NoSuchFileOrDirectory));
697        }
698        let dest = path_utils::absolutize(self.wrkdir.as_path(), dest);
699        debug!("Copying {} to {}", src.display(), dest.display());
700        match self
701            .session
702            .as_mut()
703            .unwrap()
704            .cmd(format!("cp -rf \"{}\" \"{}\"", src.display(), dest.display()).as_str())
705        {
706            Ok((0, _)) => Ok(()),
707            Ok(_) => Err(RemoteError::new_ex(
708                // Could not copy file
709                RemoteErrorType::FileCreateDenied,
710                format!("\"{}\"", dest.display()),
711            )),
712            Err(err) => Err(RemoteError::new_ex(
713                RemoteErrorType::ProtocolError,
714                err.to_string(),
715            )),
716        }
717    }
718
719    fn mov(&mut self, src: &Path, dest: &Path) -> RemoteResult<()> {
720        self.check_connection()?;
721        let src = path_utils::absolutize(self.wrkdir.as_path(), src);
722        // check if file exists
723        if !self.exists(src.as_path()).ok().unwrap_or(false) {
724            return Err(RemoteError::new(RemoteErrorType::NoSuchFileOrDirectory));
725        }
726        let dest = path_utils::absolutize(self.wrkdir.as_path(), dest);
727        debug!("Moving {} to {}", src.display(), dest.display());
728        match self
729            .session
730            .as_mut()
731            .unwrap()
732            .cmd(format!("mv -f \"{}\" \"{}\"", src.display(), dest.display()).as_str())
733        {
734            Ok((0, _)) => Ok(()),
735            Ok(_) => Err(RemoteError::new_ex(
736                // Could not copy file
737                RemoteErrorType::FileCreateDenied,
738                format!("\"{}\"", dest.display()),
739            )),
740            Err(err) => Err(RemoteError::new_ex(RemoteErrorType::ProtocolError, err)),
741        }
742    }
743
744    fn exec(&mut self, cmd: &str) -> RemoteResult<(u32, String)> {
745        self.check_connection()?;
746        debug!(r#"Executing command "{cmd}""#);
747        self.session
748            .as_mut()
749            .unwrap()
750            .cmd_at(cmd, self.wrkdir.as_path())
751    }
752
753    fn append(&mut self, _path: &Path, _metadata: &Metadata) -> RemoteResult<WriteStream> {
754        Err(RemoteError::new(RemoteErrorType::UnsupportedFeature))
755    }
756
757    fn create(&mut self, path: &Path, metadata: &Metadata) -> RemoteResult<WriteStream> {
758        self.check_connection()?;
759        let path = path_utils::absolutize(self.wrkdir.as_path(), path);
760        debug!("Creating file {}", path.display());
761        trace!("blocked channel");
762        let mode = metadata.mode.map(u32::from).unwrap_or(0o644) as i32;
763        let accessed = metadata
764            .accessed
765            .unwrap_or(SystemTime::UNIX_EPOCH)
766            .duration_since(SystemTime::UNIX_EPOCH)
767            .ok()
768            .unwrap_or(Duration::ZERO)
769            .as_secs();
770        let modified = metadata
771            .modified
772            .unwrap_or(SystemTime::UNIX_EPOCH)
773            .duration_since(SystemTime::UNIX_EPOCH)
774            .ok()
775            .unwrap_or(Duration::ZERO)
776            .as_secs();
777        trace!("Creating file with mode {mode:o}, accessed: {accessed}, modified: {modified}");
778        match self.session.as_mut().unwrap().scp_send(
779            path.as_path(),
780            mode,
781            metadata.size,
782            Some((modified, accessed)),
783        ) {
784            Ok(channel) => Ok(WriteStream::from(channel)),
785            Err(err) => {
786                error!("Failed to create file: {err}");
787                Err(RemoteError::new_ex(RemoteErrorType::FileCreateDenied, err))
788            }
789        }
790    }
791
792    fn open(&mut self, path: &Path) -> RemoteResult<ReadStream> {
793        self.check_connection()?;
794        let path = path_utils::absolutize(self.wrkdir.as_path(), path);
795        debug!("Opening file {} for read", path.display());
796        // check if file exists
797        if !self.exists(path.as_path()).ok().unwrap_or(false) {
798            return Err(RemoteError::new(RemoteErrorType::NoSuchFileOrDirectory));
799        }
800        trace!("blocked channel");
801        match self.session.as_mut().unwrap().scp_recv(path.as_path()) {
802            Ok(channel) => Ok(ReadStream::from(channel)),
803            Err(err) => {
804                error!("Failed to open file: {err}");
805                Err(RemoteError::new_ex(RemoteErrorType::CouldNotOpenFile, err))
806            }
807        }
808    }
809}
810
811#[cfg(test)]
812mod tests;