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(
317        &mut self,
318        dir: &Path,
319        entries: &[&str],
320    ) -> HashMap<String, SystemTime> {
321        if entries.is_empty() {
322            return HashMap::new();
323        }
324        let fmt = match self.stat_flavor() {
325            StatFlavor::Gnu => "-c '%Y %n'",
326            StatFlavor::Bsd => "-f '%m %N'",
327            StatFlavor::Unsupported => return HashMap::new(),
328        };
329        let args: Vec<String> = entries
330            .iter()
331            .map(|name| format!("\"{}\"", dir.join(name).display()))
332            .collect();
333        let cmd = format!("stat {} {}", fmt, args.join(" "));
334        match self.session.as_mut().unwrap().cmd(cmd) {
335            Ok((_, output)) => parser_utils::parse_stat_listing(&output),
336            Err(err) => {
337                warn!("Batched stat failed, falling back to ls timestamps: {err}");
338                HashMap::new()
339            }
340        }
341    }
342}
343
344impl<S> RemoteFs for ScpFs<S>
345where
346    S: SshSession,
347{
348    fn connect(&mut self) -> RemoteResult<Welcome> {
349        debug!("Initializing SFTP connection...");
350        let mut session = S::connect(&self.opts)?;
351        // Get banner
352        let banner = session.banner()?;
353        debug!(
354            "Connection established: {}",
355            banner.as_deref().unwrap_or("")
356        );
357        // Get working directory
358        debug!("Getting working directory...");
359        self.wrkdir = session
360            .cmd("pwd")
361            .map(|(_rc, output)| PathBuf::from(output.as_str().trim()))?;
362        // Set session
363        self.session = Some(session);
364        info!(
365            "Connection established; working directory: {}",
366            self.wrkdir.display()
367        );
368        Ok(Welcome::default().banner(banner))
369    }
370
371    fn disconnect(&mut self) -> RemoteResult<()> {
372        debug!("Disconnecting from remote...");
373        if let Some(session) = self.session.as_ref() {
374            // Disconnect (greet server with 'Mandi' as they do in Friuli)
375            match session.disconnect() {
376                Ok(_) => {
377                    // Set session and sftp to none
378                    self.session = None;
379                    // Drop cached probe so a fresh connection re-detects.
380                    self.stat_flavor = None;
381                    Ok(())
382                }
383                Err(err) => Err(RemoteError::new_ex(RemoteErrorType::ConnectionError, err)),
384            }
385        } else {
386            Err(RemoteError::new(RemoteErrorType::NotConnected))
387        }
388    }
389
390    fn is_connected(&mut self) -> bool {
391        self.session
392            .as_ref()
393            .map(|x| x.authenticated().unwrap_or_default())
394            .unwrap_or(false)
395    }
396
397    fn pwd(&mut self) -> RemoteResult<PathBuf> {
398        self.check_connection()?;
399        Ok(self.wrkdir.clone())
400    }
401
402    fn change_dir(&mut self, dir: &Path) -> RemoteResult<PathBuf> {
403        self.check_connection()?;
404        let dir = path_utils::absolutize(self.wrkdir.as_path(), dir);
405        debug!("Changing working directory to {}", dir.display());
406        match self
407            .session
408            .as_mut()
409            .unwrap()
410            .cmd(format!("cd \"{}\"; echo $?; pwd", dir.display()))
411        {
412            Ok((rc, output)) => {
413                if rc != 0 {
414                    return Err(RemoteError::new_ex(
415                        RemoteErrorType::ProtocolError,
416                        format!("Failed to change directory: {}", output),
417                    ));
418                }
419                // Trim
420                let output: String = String::from(output.as_str().trim());
421                // Check if output starts with 0; should be 0{PWD}
422                match output.as_str().starts_with('0') {
423                    true => {
424                        // Set working directory
425                        self.wrkdir = PathBuf::from(&output.as_str()[1..].trim());
426                        debug!("Changed working directory to {}", self.wrkdir.display());
427                        Ok(self.wrkdir.clone())
428                    }
429                    false => Err(RemoteError::new_ex(
430                        // No such file or directory
431                        RemoteErrorType::NoSuchFileOrDirectory,
432                        format!("\"{}\"", dir.display()),
433                    )),
434                }
435            }
436            Err(err) => Err(RemoteError::new_ex(RemoteErrorType::ProtocolError, err)),
437        }
438    }
439
440    fn list_dir(&mut self, path: &Path) -> RemoteResult<Vec<File>> {
441        self.check_connection()?;
442        let path = path_utils::absolutize(self.wrkdir.as_path(), path);
443        debug!("Getting file entries in {}", path.display());
444        // check if exists
445        if !self.exists(path.as_path()).ok().unwrap_or(false) {
446            return Err(RemoteError::new(RemoteErrorType::NoSuchFileOrDirectory));
447        }
448        match self
449            .session
450            .as_mut()
451            .unwrap()
452            .cmd(format!("unset LANG; ls -la \"{}/\"", path.display()).as_str())
453        {
454            Ok((rc, output)) => {
455                if rc != 0 {
456                    return Err(RemoteError::new_ex(
457                        RemoteErrorType::ProtocolError,
458                        format!("Failed to list directory: {}", output),
459                    ));
460                }
461                // Split output by (\r)\n
462                let lines: Vec<&str> = output.as_str().lines().collect();
463                let mut entries: Vec<File> = Vec::with_capacity(lines.len());
464                for line in lines.iter() {
465                    // First line must always be ignored
466                    // Parse row, if ok push to entries
467                    if let Ok(entry) = self.parse_ls_output(path.as_path(), line) {
468                        entries.push(entry);
469                    }
470                }
471                // Override `ls`-parsed mtimes (which are TZ-ambiguous) with
472                // the server's Unix epoch via `stat`. One batched roundtrip.
473                let names: Vec<String> = entries.iter().map(|e| e.name()).collect();
474                let name_refs: Vec<&str> = names.iter().map(String::as_str).collect();
475                let mtimes = self.mtimes_in_dir(path.as_path(), &name_refs);
476                for (entry, name) in entries.iter_mut().zip(names.iter()) {
477                    if let Some(t) = mtimes.get(name) {
478                        entry.metadata.modified = Some(*t);
479                    }
480                }
481                debug!(
482                    "Found {} out of {} valid file entries",
483                    entries.len(),
484                    lines.len()
485                );
486                Ok(entries)
487            }
488            Err(err) => Err(RemoteError::new_ex(RemoteErrorType::ProtocolError, err)),
489        }
490    }
491
492    fn stat(&mut self, path: &Path) -> RemoteResult<File> {
493        self.check_connection()?;
494        let path = path_utils::absolutize(self.wrkdir.as_path(), path);
495        debug!("Stat {}", path.display());
496        // make command; Directories require `-d` option
497        let cmd = match self.is_directory(path.as_path())? {
498            true => format!("ls -ld \"{}\"", path.display()),
499            false => format!("ls -l \"{}\"", path.display()),
500        };
501        match self.session.as_mut().unwrap().cmd(cmd.as_str()) {
502            Ok((rc, line)) => {
503                if rc != 0 {
504                    return Err(RemoteError::new_ex(
505                        RemoteErrorType::NoSuchFileOrDirectory,
506                        format!("Failed to stat file: {line}"),
507                    ));
508                }
509                // Parse ls line
510                let parent: PathBuf = match path.as_path().parent() {
511                    Some(p) => PathBuf::from(p),
512                    None => {
513                        return Err(RemoteError::new_ex(
514                            RemoteErrorType::StatFailed,
515                            "Path has no parent",
516                        ));
517                    }
518                };
519                match self.parse_ls_output(parent.as_path(), line.as_str().trim()) {
520                    Ok(mut entry) => {
521                        // Override TZ-ambiguous `ls` mtime with Unix epoch.
522                        if let Some(t) = self.mtime_epoch(path.as_path()) {
523                            entry.metadata.modified = Some(t);
524                        }
525                        Ok(entry)
526                    }
527                    Err(_) => Err(RemoteError::new(RemoteErrorType::NoSuchFileOrDirectory)),
528                }
529            }
530            Err(err) => Err(RemoteError::new_ex(RemoteErrorType::ProtocolError, err)),
531        }
532    }
533
534    fn exists(&mut self, path: &Path) -> RemoteResult<bool> {
535        self.check_connection()?;
536        let path = path_utils::absolutize(self.wrkdir.as_path(), path);
537        match self
538            .session
539            .as_mut()
540            .unwrap()
541            .cmd(format!("test -e \"{}\"", path.display()))
542        {
543            Ok((0, _)) => Ok(true),
544            Ok(_) => Ok(false),
545            Err(err) => Err(RemoteError::new_ex(RemoteErrorType::StatFailed, err)),
546        }
547    }
548
549    fn setstat(&mut self, path: &Path, metadata: Metadata) -> RemoteResult<()> {
550        self.check_connection()?;
551        let path = path_utils::absolutize(self.wrkdir.as_path(), path);
552        debug!("Setting attributes for {}", path.display());
553        if !self.exists(path.as_path()).ok().unwrap_or(false) {
554            return Err(RemoteError::new(RemoteErrorType::NoSuchFileOrDirectory));
555        }
556        // set mode with chmod
557        if let Some(mode) = metadata.mode {
558            self.assert_stat_command(format!(
559                "chmod {:o} \"{}\"",
560                u32::from(mode),
561                path.display()
562            ))?;
563        }
564        if let Some(user) = metadata.uid {
565            self.assert_stat_command(format!(
566                "chown {}{} \"{}\"",
567                user,
568                metadata.gid.map(|x| format!(":{x}")).unwrap_or_default(),
569                path.display()
570            ))?;
571        }
572        // set times
573        if let Some(accessed) = metadata.accessed {
574            self.assert_stat_command(format!(
575                "touch -a -t {} \"{}\"",
576                fmt_utils::fmt_time_utc(accessed, "%Y%m%d%H%M.%S"),
577                path.display()
578            ))?;
579        }
580        if let Some(modified) = metadata.modified {
581            self.assert_stat_command(format!(
582                "touch -m -t {} \"{}\"",
583                fmt_utils::fmt_time_utc(modified, "%Y%m%d%H%M.%S"),
584                path.display()
585            ))?;
586        }
587        Ok(())
588    }
589
590    fn remove_file(&mut self, path: &Path) -> RemoteResult<()> {
591        self.check_connection()?;
592        let path = path_utils::absolutize(self.wrkdir.as_path(), path);
593        if !self.exists(path.as_path()).ok().unwrap_or(false) {
594            return Err(RemoteError::new(RemoteErrorType::NoSuchFileOrDirectory));
595        }
596        debug!("Removing file {}", path.display());
597        match self
598            .session
599            .as_mut()
600            .unwrap()
601            .cmd(format!("rm -f \"{}\"", path.display()))
602        {
603            Ok((0, _)) => Ok(()),
604            Ok(_) => Err(RemoteError::new(RemoteErrorType::CouldNotRemoveFile)),
605            Err(err) => Err(RemoteError::new_ex(RemoteErrorType::ProtocolError, err)),
606        }
607    }
608
609    fn remove_dir(&mut self, path: &Path) -> RemoteResult<()> {
610        self.check_connection()?;
611        let path = path_utils::absolutize(self.wrkdir.as_path(), path);
612        if !self.exists(path.as_path()).ok().unwrap_or(false) {
613            return Err(RemoteError::new(RemoteErrorType::NoSuchFileOrDirectory));
614        }
615        debug!("Removing directory {}", path.display());
616        match self
617            .session
618            .as_mut()
619            .unwrap()
620            .cmd(format!("rmdir \"{}\"", path.display()))
621        {
622            Ok((0, _)) => Ok(()),
623            Ok(_) => Err(RemoteError::new(RemoteErrorType::DirectoryNotEmpty)),
624            Err(err) => Err(RemoteError::new_ex(RemoteErrorType::ProtocolError, err)),
625        }
626    }
627
628    fn remove_dir_all(&mut self, path: &Path) -> RemoteResult<()> {
629        self.check_connection()?;
630        let path = path_utils::absolutize(self.wrkdir.as_path(), path);
631        if !self.exists(path.as_path()).ok().unwrap_or(false) {
632            return Err(RemoteError::new(RemoteErrorType::NoSuchFileOrDirectory));
633        }
634        debug!("Removing directory {} recursively", path.display());
635        match self
636            .session
637            .as_mut()
638            .unwrap()
639            .cmd(format!("rm -rf \"{}\"", path.display()))
640        {
641            Ok((0, _)) => Ok(()),
642            Ok(_) => Err(RemoteError::new(RemoteErrorType::CouldNotRemoveFile)),
643            Err(err) => Err(RemoteError::new_ex(RemoteErrorType::ProtocolError, err)),
644        }
645    }
646
647    fn create_dir(&mut self, path: &Path, mode: UnixPex) -> RemoteResult<()> {
648        self.check_connection()?;
649        let path = path_utils::absolutize(self.wrkdir.as_path(), path);
650        if self.exists(path.as_path()).ok().unwrap_or(false) {
651            return Err(RemoteError::new(RemoteErrorType::DirectoryAlreadyExists));
652        }
653        let mode = format!("{:o}", u32::from(mode));
654        debug!(
655            "Creating directory at {} with mode {}",
656            path.display(),
657            mode
658        );
659        match self.session.as_mut().unwrap().cmd(format!(
660            "mkdir -m {} \"{}\"",
661            mode,
662            path.display()
663        )) {
664            Ok((0, _)) => Ok(()),
665            Ok(_) => Err(RemoteError::new(RemoteErrorType::FileCreateDenied)),
666            Err(err) => Err(RemoteError::new_ex(RemoteErrorType::ProtocolError, err)),
667        }
668    }
669
670    fn symlink(&mut self, path: &Path, target: &Path) -> RemoteResult<()> {
671        self.check_connection()?;
672        let path = path_utils::absolutize(self.wrkdir.as_path(), path);
673        debug!(
674            "Creating a symlink at {} pointing at {}",
675            path.display(),
676            target.display()
677        );
678        if !self.exists(target).ok().unwrap_or(false) {
679            return Err(RemoteError::new(RemoteErrorType::NoSuchFileOrDirectory));
680        }
681        if self.exists(path.as_path()).ok().unwrap_or(false) {
682            return Err(RemoteError::new(RemoteErrorType::FileCreateDenied));
683        }
684        match self.session.as_mut().unwrap().cmd(format!(
685            "ln -s \"{}\" \"{}\"",
686            target.display(),
687            path.display()
688        )) {
689            Ok((0, _)) => Ok(()),
690            Ok(_) => Err(RemoteError::new(RemoteErrorType::FileCreateDenied)),
691            Err(err) => Err(RemoteError::new_ex(RemoteErrorType::ProtocolError, err)),
692        }
693    }
694
695    fn copy(&mut self, src: &Path, dest: &Path) -> RemoteResult<()> {
696        self.check_connection()?;
697        let src = path_utils::absolutize(self.wrkdir.as_path(), src);
698        // check if file exists
699        if !self.exists(src.as_path()).ok().unwrap_or(false) {
700            return Err(RemoteError::new(RemoteErrorType::NoSuchFileOrDirectory));
701        }
702        let dest = path_utils::absolutize(self.wrkdir.as_path(), dest);
703        debug!("Copying {} to {}", src.display(), dest.display());
704        match self
705            .session
706            .as_mut()
707            .unwrap()
708            .cmd(format!("cp -rf \"{}\" \"{}\"", src.display(), dest.display()).as_str())
709        {
710            Ok((0, _)) => Ok(()),
711            Ok(_) => Err(RemoteError::new_ex(
712                // Could not copy file
713                RemoteErrorType::FileCreateDenied,
714                format!("\"{}\"", dest.display()),
715            )),
716            Err(err) => Err(RemoteError::new_ex(
717                RemoteErrorType::ProtocolError,
718                err.to_string(),
719            )),
720        }
721    }
722
723    fn mov(&mut self, src: &Path, dest: &Path) -> RemoteResult<()> {
724        self.check_connection()?;
725        let src = path_utils::absolutize(self.wrkdir.as_path(), src);
726        // check if file exists
727        if !self.exists(src.as_path()).ok().unwrap_or(false) {
728            return Err(RemoteError::new(RemoteErrorType::NoSuchFileOrDirectory));
729        }
730        let dest = path_utils::absolutize(self.wrkdir.as_path(), dest);
731        debug!("Moving {} to {}", src.display(), dest.display());
732        match self
733            .session
734            .as_mut()
735            .unwrap()
736            .cmd(format!("mv -f \"{}\" \"{}\"", src.display(), dest.display()).as_str())
737        {
738            Ok((0, _)) => Ok(()),
739            Ok(_) => Err(RemoteError::new_ex(
740                // Could not copy file
741                RemoteErrorType::FileCreateDenied,
742                format!("\"{}\"", dest.display()),
743            )),
744            Err(err) => Err(RemoteError::new_ex(RemoteErrorType::ProtocolError, err)),
745        }
746    }
747
748    fn exec(&mut self, cmd: &str) -> RemoteResult<(u32, String)> {
749        self.check_connection()?;
750        debug!(r#"Executing command "{cmd}""#);
751        self.session
752            .as_mut()
753            .unwrap()
754            .cmd_at(cmd, self.wrkdir.as_path())
755    }
756
757    fn append(&mut self, _path: &Path, _metadata: &Metadata) -> RemoteResult<WriteStream> {
758        Err(RemoteError::new(RemoteErrorType::UnsupportedFeature))
759    }
760
761    fn create(&mut self, path: &Path, metadata: &Metadata) -> RemoteResult<WriteStream> {
762        self.check_connection()?;
763        let path = path_utils::absolutize(self.wrkdir.as_path(), path);
764        debug!("Creating file {}", path.display());
765        trace!("blocked channel");
766        let mode = metadata.mode.map(u32::from).unwrap_or(0o644) as i32;
767        let accessed = metadata
768            .accessed
769            .unwrap_or(SystemTime::UNIX_EPOCH)
770            .duration_since(SystemTime::UNIX_EPOCH)
771            .ok()
772            .unwrap_or(Duration::ZERO)
773            .as_secs();
774        let modified = metadata
775            .modified
776            .unwrap_or(SystemTime::UNIX_EPOCH)
777            .duration_since(SystemTime::UNIX_EPOCH)
778            .ok()
779            .unwrap_or(Duration::ZERO)
780            .as_secs();
781        trace!("Creating file with mode {mode:o}, accessed: {accessed}, modified: {modified}");
782        match self.session.as_mut().unwrap().scp_send(
783            path.as_path(),
784            mode,
785            metadata.size,
786            Some((modified, accessed)),
787        ) {
788            Ok(channel) => Ok(WriteStream::from(channel)),
789            Err(err) => {
790                error!("Failed to create file: {err}");
791                Err(RemoteError::new_ex(RemoteErrorType::FileCreateDenied, err))
792            }
793        }
794    }
795
796    fn open(&mut self, path: &Path) -> RemoteResult<ReadStream> {
797        self.check_connection()?;
798        let path = path_utils::absolutize(self.wrkdir.as_path(), path);
799        debug!("Opening file {} for read", path.display());
800        // check if file exists
801        if !self.exists(path.as_path()).ok().unwrap_or(false) {
802            return Err(RemoteError::new(RemoteErrorType::NoSuchFileOrDirectory));
803        }
804        trace!("blocked channel");
805        match self.session.as_mut().unwrap().scp_recv(path.as_path()) {
806            Ok(channel) => Ok(ReadStream::from(channel)),
807            Err(err) => {
808                error!("Failed to open file: {err}");
809                Err(RemoteError::new_ex(RemoteErrorType::CouldNotOpenFile, err))
810            }
811        }
812    }
813}
814
815#[cfg(test)]
816mod tests;