qcp 0.8.3

Secure remote file copy utility which uses the QUIC protocol over UDP
Documentation
//! Job specifications for the client
// (c) 2024 Ross Younger

use std::{ffi::OsStr, path::Path, str::FromStr};

use crate::os::{self, AbstractPlatform as _};
use crate::protocol::control::Direction;

/// Strips the optional user@ part off a hostname
fn hostname_of(user_at_host: &str) -> &str {
    user_at_host.split_once('@').unwrap_or(("", user_at_host)).1
}

/// Returns the username from a user@host, if one was specified
fn username_of(user_at_host: &str) -> Option<&str> {
    user_at_host.split_once('@').map(|tup| tup.0)
}

/// A file source or destination specified by the user
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct FileSpec {
    /// The remote `[user@]host` for the file. This may be a hostname or an IP address.
    /// It may also be a _hostname alias_ that matches a Host section in the user's ssh config file.
    /// (In that case, the ssh config file must specify a HostName.)
    ///
    /// If not present, this is a local file.
    pub user_at_host: Option<String>,
    /// Filename
    ///
    /// If this is a destination, it might be a directory.
    pub filename: String,
}

impl FileSpec {
    /// Returns only the hostname part of the file, if any; the username is stripped.
    pub(crate) fn hostname(&self) -> Option<&str> {
        self.user_at_host.as_ref().map(|s| hostname_of(s))
    }
    /// Returns the username part of the filespec, if this is a remote filespec and a username was given.
    /// Otherwise, returns None.
    pub(crate) fn remote_user(&self) -> Option<&str> {
        self.user_at_host.as_ref().and_then(|s| username_of(s))
    }
}

impl FromStr for FileSpec {
    type Err = anyhow::Error;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        if s.starts_with('[') {
            // Assume raw IPv6 address [1:2:3::4]:File
            match s.split_once("]:") {
                Some((hostish, filename)) => Ok(Self {
                    // lose the leading bracket as well so it can be looked up as if a hostname
                    user_at_host: Some(hostish[1..].to_owned()),
                    filename: filename.into(),
                }),
                None => Ok(Self {
                    user_at_host: None,
                    filename: s.to_owned(),
                }),
            }
        } else {
            // If all we had to worry about was unix paths, life would be easier!
            // A path could be Host:File, or a raw IPv4 address 1.2.3.4:File; or even just a filename.
            // However, absolute paths on Windows hosts (remote or local)
            // may be expressed as 'C:\dir\file' or as the UNC-style '\\?\C:\dir\file'.
            // If there is more than one : in the path, that's easy: the first one delimits the remote user@host,
            // so we only need to split once.
            //
            // The tricky situation is if there is precisely one colon in the string, which is a common case!
            //
            // On a Windows host, using OpenSSH, these all work:
            //          scp host:file c:\users\me\file
            //          scp host:file c:/users/me/file
            //          scp c:\users\me\file host:file
            //          scp c:/users/me/file host:file
            //
            // So we cannot rely on the presence of backslashes to identify a Windows path.
            // Instead, we will add special treatment on Windows builds to detect local paths.
            //
            // Interestingly, UNC paths do not work as local files:
            //          scp host:file \\?\c:\users\me\file
            //          scp host:file \\.\c:\users\me\file
            // Both fail with `ssh: Could not resolve hostname \\\\?\\c: No such host is known`
            // so we won't worry about them.

            if os::Platform::override_path_is_local(s) {
                // Special case
                Ok(Self {
                    user_at_host: None,
                    filename: s.to_owned(),
                })
            } else {
                match s.split_once(':') {
                    Some((host, filename)) => Ok(Self {
                        user_at_host: Some(host.to_string()),
                        filename: filename.to_string(),
                    }),
                    None => Ok(Self {
                        user_at_host: None,
                        filename: s.to_owned(),
                    }),
                }
            }
        }
    }
}

impl std::fmt::Display for FileSpec {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        if let Some(host) = &self.user_at_host {
            write!(f, "{}:{}", host, self.filename)
        } else {
            write!(f, "{}", self.filename)
        }
    }
}

/// Details of a file copy job.
#[derive(Debug, Default, Clone, PartialEq)]
pub struct CopyJobSpec {
    pub(crate) source: FileSpec,
    pub(crate) destination: FileSpec,
    /// The `[user@]host` part of whichever of the source or destination contained one.
    /// (There can be only one.)
    pub(crate) user_at_host: String,
    /// If set, we want to preserve times and permissions as far as possible.
    pub(crate) preserve: bool,
    /// Is this entry for a directory?
    ///
    /// **This flag does not provide any information regarding recursion.** That is up to the caller to determine from context.
    pub(crate) directory: bool,
    /// If present, Unix-style mode bits to apply to the target.
    /// (This currently only applies to directories.)
    pub(crate) mode: Option<u32>,
}

impl CopyJobSpec {
    /// standard constructor
    pub(crate) fn try_new(
        source: FileSpec,
        destination: FileSpec,
        preserve: bool,
        directory: bool,
    ) -> anyhow::Result<Self> {
        if !(source.user_at_host.is_none() ^ destination.user_at_host.is_none()) {
            anyhow::bail!("One file argument must be remote");
        }
        let user_at_host = source
            .user_at_host
            .clone()
            .unwrap_or_else(|| destination.user_at_host.clone().unwrap_or_default());

        Ok(Self {
            source,
            destination,
            user_at_host,
            preserve,
            directory,
            mode: None,
        })
    }

    #[allow(dead_code)] // used by tests and qcp-unsafe-tests
    pub(crate) fn from_parts(
        source: &str,
        destination: &str,
        preserve: bool,
        directory: bool,
    ) -> anyhow::Result<Self> {
        let source = FileSpec::from_str(source)?;
        let destination = FileSpec::from_str(destination)?;
        Self::try_new(source, destination, preserve, directory)
    }

    /// The hostname portion of whichever of the arguments contained one.
    pub(crate) fn remote_host(&self) -> &str {
        hostname_of(&self.user_at_host)
    }

    /// The username portion of whichever of the arguments was remote, if one contained a username.
    #[cfg(test)]
    #[cfg_attr(coverage_nightly, coverage(off))]
    pub(crate) fn remote_user(&self) -> Option<&str> {
        username_of(&self.user_at_host)
    }

    pub(crate) fn direction(&self) -> Direction {
        if self.source.user_at_host.is_some() {
            Direction::ServerToClient
        } else {
            Direction::ClientToServer
        }
    }

    /// The display filename for a job spec. This is the source filename.
    pub(crate) fn display_filename<'a>(&'a self) -> &'a OsStr {
        let s: &'a str = &self.source.filename;
        let p = Path::new(s);
        p.file_name().unwrap_or_default()
    }
}

#[cfg(test)]
#[cfg_attr(coverage_nightly, coverage(off))]
mod test {
    type Res = anyhow::Result<()>;
    use engineering_repr::EngineeringQuantity;
    use pretty_assertions::assert_eq;

    use crate::{CopyJobSpec, protocol::control::Direction, transport::ThroughputMode};

    use super::FileSpec;
    use std::str::FromStr;

    #[test]
    fn filename_no_host() -> Res {
        let fs = FileSpec::from_str("/dir/file")?;
        assert!(fs.user_at_host.is_none());
        assert_eq!(fs.filename, "/dir/file");
        Ok(())
    }

    #[test]
    fn host_no_file() -> Res {
        let fs = FileSpec::from_str("host:")?;
        assert_eq!(fs.user_at_host.unwrap(), "host");
        assert_eq!(fs.filename, "");
        Ok(())
    }

    #[test]
    fn host_and_file() -> Res {
        let fs = FileSpec::from_str("host:file")?;
        assert_eq!(fs.user_at_host.unwrap(), "host");
        assert_eq!(fs.filename, "file");
        Ok(())
    }

    #[test]
    fn bare_ipv4() -> Res {
        let fs = FileSpec::from_str("1.2.3.4:file")?;
        assert_eq!(fs.user_at_host.unwrap(), "1.2.3.4");
        assert_eq!(fs.filename, "file");
        Ok(())
    }

    #[test]
    fn bare_ipv6() -> Res {
        let fs = FileSpec::from_str("[1:2:3:4::5]:file")?;
        assert_eq!(fs.user_at_host.unwrap(), "1:2:3:4::5");
        assert_eq!(fs.filename, "file");
        Ok(())
    }
    #[test]
    fn bare_ipv6_localhost() -> Res {
        let fs = FileSpec::from_str("[::1]:file")?;
        assert_eq!(fs.user_at_host.unwrap(), "::1");
        assert_eq!(fs.filename, "file");
        Ok(())
    }
    #[test]
    fn not_really_ipv6() {
        let spec = FileSpec::from_str("[1:2:3:4::5").unwrap();
        assert_eq!(spec.user_at_host, None);
        assert_eq!(spec.filename, "[1:2:3:4::5");
    }

    #[test]
    fn size_is_kb_not_kib() {
        // same mechanism that clap uses
        let q = "1k".parse::<EngineeringQuantity<u64>>().unwrap();
        assert_eq!(u64::from(q), 1000);
    }

    #[test]
    fn direction() {
        let js = CopyJobSpec::from_parts("server:file", "file", false, false).unwrap();
        assert_eq!(js.direction(), Direction::ServerToClient);
        assert_eq!(js.direction().server_mode(), ThroughputMode::Tx);
        assert_eq!(js.direction().client_mode(), ThroughputMode::Rx);
        let js = CopyJobSpec::from_parts("file", "server:file", false, false).unwrap();
        assert_eq!(js.direction(), Direction::ClientToServer);
        assert_eq!(js.direction().server_mode(), ThroughputMode::Rx);
        assert_eq!(js.direction().client_mode(), ThroughputMode::Tx);
        let dir = Direction::Both;
        assert_eq!(dir.server_mode(), ThroughputMode::Both);
        assert_eq!(dir.client_mode(), ThroughputMode::Both);
    }

    #[test]
    fn display_filename() {
        let js = CopyJobSpec::from_parts("server:somedir/file1", "otherdir/file2", false, false)
            .unwrap();
        assert_eq!(js.display_filename(), "file1");
    }

    #[test]
    #[cfg(windows)]
    fn windows_local_paths() {
        // This test requires littertray@1.1.0
        let remote_cases = &[
            // Syntax: ("qcp job arg","expected path extracted from it")
            ("server:C:\\dir\\file", "C:\\dir\\file"),
            ("server:C:/dir/file", "C:/dir/file"),
            ("server:\\\\?\\C:\\dir\\file", "\\\\?\\C:\\dir\\file"),
        ];
        for (arg, expected_path) in remote_cases {
            let js = CopyJobSpec::from_parts(arg, "otherdir/file2", false, false)
                .unwrap_or_else(|_| panic!("failed to create jobspec; input case is {arg}"));
            assert_eq!(js.source.filename, *expected_path, "remove case is {arg}");
        }

        let local_cases = &[
            // Local cases are tougher as the drive letter can be mistaken for a filename
            ("C:\\dir\\file", "C:\\dir\\file"),
            // Windows scp supports forward slashes for local paths, so we'd better support them too.
            ("C:/dir/file", "C:/dir/file"),
            // but we do not support UNC paths as local files, because the Windows build of scp does not support them.
        ];
        for (arg, expected_path) in local_cases {
            let js = CopyJobSpec::from_parts(arg, "server:otherdir/file2", false, false)
                .unwrap_or_else(|_| panic!("failed to create jobspec; local case is {arg}"));
            assert_eq!(js.source.filename, *expected_path, "input case is {arg}");
        }
    }
}