coliru

A minimal, flexible, dotfile installer
git clone https://git.ashermorgan.net/coliru/
Log | Files | Refs | README

ssh.rs (15931B)


      1 //! Remote dotfile installation utilities
      2 //!
      3 //! To send files to a remote machine via SCP, first stage them using
      4 //! [`stage_file`], then transfer them using [`send_staged_files`].
      5 //!
      6 //! ```
      7 //! let staging_dir = Path::new("/tmp/staging");
      8 //! let host = "user@hostname";
      9 //! stage_file("foo.sh", "~/foo.sh", staging_dir);
     10 //! send_staged_files(staging_dir, host);
     11 //! send_command("bash ~/foo.sh", host);
     12 //! ```
     13 
     14 use anyhow::{bail, anyhow, Context, Result};
     15 use std::env;
     16 use shellexpand::tilde_with_context;
     17 use std::fs::{read_dir, remove_dir_all};
     18 use std::path::{MAIN_SEPARATOR_STR, Path, PathBuf};
     19 use std::process::{Command, Stdio};
     20 use super::local::copy_file;
     21 
     22 /// Makes a relative path absolute according to a certain base directory
     23 ///
     24 /// Paths begining with tildes are interpreted as absolute paths.
     25 ///
     26 /// ```
     27 /// assert_eq!(resolve_path("dir1/foo", "~/dir2"), "~/dir2/dir1/foo");
     28 /// assert_eq!(resolve_path("/dir1/foo", "~/dir2"), "/dir1/foo");
     29 /// assert_eq!(resolve_path("~/dir1/foo", "~/dir2"), "~/dir1/foo");
     30 /// ```
     31 pub fn resolve_path(src: &str, dir: &str) -> String {
     32     if !src.starts_with("~") && Path::new(src).is_relative() {
     33         return format!("{dir}/{src}")
     34     }
     35     src.to_owned()
     36 }
     37 
     38 /// Copies a file to an SCP staging directory
     39 ///
     40 /// Tildes are expanded and relative paths are interpreted relative to the
     41 /// remote user's home directory.
     42 ///
     43 /// ```
     44 /// // Prepare to transfer foo to ~/foo, bar to /bar, and baz to ~/baz
     45 /// let staging_dir = Path::new("/tmp/staging");
     46 /// stage_file("foo", "~/foo", staging_dir);
     47 /// stage_file("bar", "/bar", staging_dir);
     48 /// stage_file("baz", "baz", staging_dir);
     49 /// ```
     50 pub fn stage_file(src: &str, dst: &str, staging_dir: &Path) -> Result<()> {
     51     // Staging directories are used to copy multiple files at once while
     52     // automatically creating missing directories on the remote machine. The
     53     // example code above produces the following staging directory layout:
     54     //
     55     // /tmp/staging/
     56     // ├── home/
     57     // │   ├── baz
     58     // │   └── foo
     59     // └── root/
     60     //     └── bar
     61 
     62     let home_dir = staging_dir.join("home");
     63     let root_dir = staging_dir.join("root");
     64     let get_home_dir = || {
     65         Some::<String>(home_dir.to_string_lossy().into())
     66     };
     67 
     68     // Resolve ~/... paths to home staging directory:
     69     let mut _dst: PathBuf = (&tilde_with_context(dst, get_home_dir).to_mut())
     70                             .into();
     71 
     72     // Resolve relative paths to home staging directory:
     73     _dst = home_dir.join(_dst);
     74 
     75     // Resolve other absolute paths to root staging directory:
     76     if !_dst.starts_with(home_dir) {
     77         // Root should be / and C:\ on Unix and Windows respectively, but
     78         // iter().next() will return / and C:, so we must manually add another
     79         // path separator. (Duplicate slashes are ignored on Unix).
     80         let root = PathBuf::from(match _dst.iter().next() {
     81             Some(x) => Ok(x),
     82             None => Err(anyhow!("Failed to get root of {}", _dst.display())),
     83         }?).join(MAIN_SEPARATOR_STR);
     84 
     85         let dst_without_root = _dst.strip_prefix(root).with_context(|| {
     86             format!("Failed to strip root from {}", _dst.display())
     87         })?;
     88         _dst = root_dir.join(dst_without_root);
     89     }
     90 
     91     copy_file(src, _dst.to_string_lossy().to_mut())
     92 }
     93 
     94 /// Transfers the files in an SCP staging directory to a remote machine
     95 ///
     96 /// `host` may be an SSH alias or a string in the form `user@hostname`. Use
     97 /// [`stage_file`] to produce a staging directory. The contents of the staging
     98 /// directory are deleted after they are successfully transferred.
     99 ///
    100 /// ```
    101 /// send_staged_files(Path::new("/tmp/staging"), "user@hostname");
    102 /// ```
    103 pub fn send_staged_files(staging_dir: &Path, host: &str) -> Result<()> {
    104     let home_dir = staging_dir.join("home");
    105     if home_dir.exists() {
    106         send_dir(home_dir.to_string_lossy().to_mut(), "~", host)?;
    107         remove_dir_all(&home_dir).with_context(|| {
    108             format!("Failed to remove staging dir {} after use",
    109                     &home_dir.display())
    110         })?;
    111     }
    112     let root_dir = staging_dir.join("root");
    113     if root_dir.exists() {
    114         send_dir(root_dir.to_string_lossy().to_mut(), "/", host)?;
    115         remove_dir_all(&root_dir).with_context(|| {
    116             format!("Failed to remove staging dir {} after use",
    117                     &root_dir.display())
    118         })?;
    119     }
    120     Ok(())
    121 }
    122 
    123 /// Copies a directory to another machine via SCP and merges it with a
    124 /// destination directory
    125 ///
    126 /// `host` may be an SSH alias or a string in the form `user@hostname`.
    127 ///
    128 /// ```
    129 /// send_dir("new_home", "~/", "user@hostname");
    130 /// ```
    131 fn send_dir(src: &str, dst: &str, host: &str) -> Result<()> {
    132     // To avoid the source directory being copied as a subdirectory of the
    133     // destination directory, we must send the contents of the directory
    134     // item by item.
    135     let items = read_dir(&src).with_context(|| {
    136         format!("Failed to list contents of {}", src)
    137     })?;
    138     for item in items {
    139         let _src = item.with_context(|| {
    140             format!("Failed to list contents of {}", src)
    141         })?.path();
    142 
    143         let mut cmd = Command::new("scp");
    144         cmd.stdout(Stdio::null());
    145 
    146         if env::var("COLIRU_TEST").is_ok() {
    147             cmd.args(["-o", "StrictHostKeyChecking=no", "-P", "2222"]);
    148         }
    149         cmd.args(["-r", &_src.to_string_lossy(), &format!("{host}:{dst}")]);
    150 
    151         let status = cmd.status().with_context(|| {
    152             format!("Failed to execute {:?}", cmd)
    153         })?;
    154         if !status.success() {
    155             bail!("SCP terminated unsuccessfully: {}", status);
    156         }
    157     }
    158     Ok(())
    159 }
    160 
    161 /// Executes a command on another machine via SSH
    162 ///
    163 /// `host` may be an SSH alias or a string in the form `user@hostname`.
    164 ///
    165 /// ```
    166 /// send_command("echo 'Hello World'");
    167 /// ```
    168 pub fn send_command(command: &str, host: &str) -> Result<()> {
    169     let mut cmd = Command::new("ssh");
    170     if env::var("COLIRU_TEST").is_ok() {
    171         cmd.args(["-o", "StrictHostKeyChecking=no", "-p", "2222"]);
    172     }
    173     cmd.args([host, command]);
    174 
    175     let status = cmd.status().with_context(|| {
    176         format!("Failed to execute {:?}", cmd)
    177     })?;
    178     if !status.success() {
    179         bail!("SSH terminated unsuccessfully: {}", status);
    180     }
    181     Ok(())
    182 }
    183 
    184 #[cfg(test)]
    185 mod tests {
    186     #![allow(unused_imports)]
    187 
    188     use super::*;
    189     use crate::test_utils::{SSH_HOST, read_file, setup_integration, write_file};
    190 
    191     use regex::Regex;
    192     use std::fs;
    193 
    194     #[test]
    195     fn test_resolve_path_relative() {
    196         let result = resolve_path("dir1/foo", "~/dir2");
    197 
    198         assert_eq!(result, "~/dir2/dir1/foo");
    199     }
    200 
    201     #[test]
    202     fn test_resolve_path_tilde() {
    203         let result = resolve_path("~/dir1/foo", "~/dir2");
    204 
    205         assert_eq!(result, "~/dir1/foo");
    206     }
    207 
    208     #[test]
    209     #[cfg(target_family = "unix")]
    210     fn test_resolve_path_absolute() {
    211         let result = resolve_path("/dir1/foo", "~/dir2");
    212 
    213         assert_eq!(result, "/dir1/foo");
    214     }
    215 
    216     #[test]
    217     #[cfg(target_family = "windows")]
    218     fn test_resolve_path_absolute() {
    219         let result = resolve_path("C:\\dir1\\foo", "~/dir2");
    220 
    221         assert_eq!(result, "C:\\dir1\\foo");
    222     }
    223 
    224     #[test]
    225     fn test_stage_file_tilde() {
    226         let tmp = setup_integration("test_stage_file_tilde");
    227 
    228         let src = tmp.local.join("foo");
    229         let dst = "~/dir/bar";
    230         let dst_real = tmp.local.join("home").join("dir").join("bar");
    231         let staging  = &tmp.local;
    232         write_file(&src, "contents of foo");
    233 
    234         let result = stage_file(src.to_str().unwrap(), dst, staging);
    235 
    236         assert_eq!(result.is_ok(), true);
    237         assert_eq!(dst_real.exists(), true);
    238         assert_eq!(read_file(&dst_real), "contents of foo");
    239     }
    240 
    241     #[test]
    242     fn test_stage_file_relative() {
    243         let tmp = setup_integration("test_stage_file_relative");
    244 
    245         let src = tmp.local.join("foo");
    246         let dst = "dir/bar";
    247         let dst_real = tmp.local.join("home").join("dir")
    248             .join("bar");
    249         let staging  = &tmp.local;
    250         write_file(&src, "contents of foo");
    251 
    252         let result = stage_file(src.to_str().unwrap(), dst, staging);
    253 
    254         assert_eq!(result.is_ok(), true);
    255         assert_eq!(dst_real.exists(), true);
    256         assert_eq!(read_file(&dst_real), "contents of foo");
    257     }
    258 
    259     #[test]
    260     fn test_stage_file_absolute() {
    261         let tmp = setup_integration("test_stage_file_absolute");
    262 
    263         let src = tmp.local.join("foo");
    264         let dst = "/dir/bar";
    265         let dst_real = tmp.local.join("root").join("dir").join("bar");
    266         let staging  = &tmp.local;
    267         write_file(&src, "contents of foo");
    268 
    269         let result = stage_file(src.to_str().unwrap(), dst, staging);
    270 
    271         assert_eq!(result.is_ok(), true);
    272         assert_eq!(dst_real.exists(), true);
    273         assert_eq!(read_file(&dst_real), "contents of foo");
    274     }
    275 
    276     #[test]
    277     #[cfg(target_family = "unix")]
    278     fn test_send_staged_files_no_files() {
    279         let tmp = setup_integration("test_send_staged_files_no_files");
    280 
    281         let result = send_staged_files(&tmp.local, SSH_HOST);
    282 
    283         assert_eq!(result.is_ok(), true);
    284     }
    285 
    286     #[test]
    287     #[cfg(target_family = "unix")]
    288     fn test_send_staged_files_home() {
    289         let tmp = setup_integration("test_send_staged_files_home");
    290 
    291         let src = tmp.local.join("home").join("test_send_staged_files_home");
    292         let src_foo = src.join("foo");
    293         let src_bar = src.join("dir").join("bar");
    294         fs::create_dir_all(&src_bar.parent().unwrap()).unwrap();
    295         write_file(&src_foo, "contents of foo");
    296         write_file(&src_bar, "contents of bar");
    297 
    298         let result = send_staged_files(&tmp.local, SSH_HOST);
    299 
    300         let dst_foo = tmp.ssh.join("foo");
    301         let dst_bar = tmp.ssh.join("dir").join("bar");
    302         assert_eq!(result.is_ok(), true);
    303         assert_eq!(dst_foo.exists(), true);
    304         assert_eq!(read_file(&dst_foo), "contents of foo");
    305         assert_eq!(dst_bar.exists(), true);
    306         assert_eq!(read_file(&dst_bar), "contents of bar");
    307         assert_eq!(tmp.local.join("home").exists(), false);
    308         assert_eq!(tmp.local.join("root").exists(), false);
    309     }
    310 
    311     #[test]
    312     #[cfg(target_family = "unix")]
    313     fn test_send_staged_files_root() {
    314         let tmp = setup_integration("test_send_staged_files_root");
    315 
    316         let src = tmp.local.join("root").join("home").join("test")
    317             .join("test_send_staged_files_root");
    318         let src_foo = src.join("foo");
    319         let src_bar = src.join("dir").join("bar");
    320         fs::create_dir_all(&src_bar.parent().unwrap()).unwrap();
    321         write_file(&src_foo, "contents of foo");
    322         write_file(&src_bar, "contents of bar");
    323 
    324         let result = send_staged_files(&tmp.local, SSH_HOST);
    325 
    326         let dst_foo = tmp.ssh.join("foo");
    327         let dst_bar = tmp.ssh.join("dir").join("bar");
    328         assert_eq!(result.is_ok(), true);
    329         assert_eq!(dst_foo.exists(), true);
    330         assert_eq!(read_file(&dst_foo), "contents of foo");
    331         assert_eq!(dst_bar.exists(), true);
    332         assert_eq!(read_file(&dst_bar), "contents of bar");
    333         assert_eq!(tmp.local.join("home").exists(), false);
    334         assert_eq!(tmp.local.join("root").exists(), false);
    335     }
    336 
    337     #[test]
    338     #[cfg(target_family = "unix")]
    339     fn test_send_dir_basic() {
    340         let tmp = setup_integration("test_send_dir_basic");
    341 
    342         write_file(&tmp.local.join("foo"), "contents of foo");
    343         write_file(&tmp.local.join("bar"), "contents of bar");
    344 
    345         let dst = "~/test_send_dir_basic";
    346         let dst_foo = tmp.ssh.join("foo");
    347         let dst_bar = tmp.ssh.join("bar");
    348 
    349         let result = send_dir(tmp.local.to_str().unwrap(), dst, SSH_HOST);
    350 
    351         assert_eq!(result.is_ok(), true);
    352         assert_eq!(dst_foo.exists(), true);
    353         assert_eq!(read_file(&dst_foo), "contents of foo");
    354         assert_eq!(dst_bar.exists(), true);
    355         assert_eq!(read_file(&dst_bar), "contents of bar");
    356     }
    357 
    358     #[test]
    359     #[cfg(target_family = "unix")]
    360     fn test_send_dir_nested_dir() {
    361         let tmp = setup_integration("test_send_dir_nested_dir");
    362 
    363         let src_foo = tmp.local.join("foo");
    364         let src_bar = tmp.local.join("dir").join("bar");
    365         write_file(&src_foo, "contents of foo");
    366         fs::create_dir_all(&src_bar.parent().unwrap()).unwrap();
    367         write_file(&src_bar, "contents of bar");
    368 
    369         let dst = "~/test_send_dir_nested_dir";
    370         let dst_foo = tmp.ssh.join("foo");
    371         let dst_bar = tmp.ssh.join("dir").join("bar");
    372 
    373         let result = send_dir(tmp.local.to_str().unwrap(), dst, SSH_HOST);
    374 
    375         assert_eq!(result.is_ok(), true);
    376         assert_eq!(dst_foo.exists(), true);
    377         assert_eq!(read_file(&dst_foo), "contents of foo");
    378         assert_eq!(dst_bar.exists(), true);
    379         assert_eq!(read_file(&dst_bar), "contents of bar");
    380     }
    381 
    382     #[test]
    383     #[cfg(target_family = "unix")]
    384     fn test_send_dir_merge_dir() {
    385         let tmp = setup_integration("test_send_dir_merge_dir");
    386 
    387         let src_bar = tmp.local.join("dir").join("bar");
    388         fs::create_dir_all(src_bar.parent().unwrap()).unwrap();
    389         write_file(&src_bar, "new contents of bar");
    390 
    391         let dst = "~/test_send_dir_merge_dir";
    392         let dst_foo = tmp.ssh.join("foo");
    393         let dst_bar = tmp.ssh.join("dir").join("bar");
    394         let dst_baz = tmp.ssh.join("dir").join("baz");
    395         write_file(&dst_foo, "old contents of foo");
    396         fs::create_dir_all(&dst_bar.parent().unwrap()).unwrap();
    397         write_file(&dst_bar, "old contents of bar");
    398         write_file(&dst_baz, "old contents of baz");
    399 
    400         let result = send_dir(tmp.local.to_str().unwrap(), dst, SSH_HOST);
    401 
    402         assert_eq!(result.is_ok(), true);
    403         assert_eq!(dst_foo.exists(), true);
    404         assert_eq!(read_file(&dst_foo), "old contents of foo");
    405         assert_eq!(dst_bar.exists(), true);
    406         assert_eq!(read_file(&dst_bar), "new contents of bar");
    407         assert_eq!(dst_baz.exists(), true);
    408         assert_eq!(read_file(&dst_baz), "old contents of baz");
    409     }
    410 
    411     #[test]
    412     fn test_send_dir_bad_host() {
    413         let tmp = setup_integration("test_send_dir_bad_host");
    414 
    415         write_file(&tmp.local.join("foo"), "contents of foo");
    416         write_file(&tmp.local.join("bar"), "contents of bar");
    417 
    418         let dst = "~/test_send_dir_bad_host";
    419         let bad_host = "fake@coliru.test.internal"; // Will be a DNS error
    420 
    421         let result = send_dir(tmp.local.to_str().unwrap(), dst, bad_host);
    422         let expected = Regex::new("SCP terminated unsuccessfully: \
    423                                    exit (status|code): \\d+").unwrap();
    424 
    425         assert_eq!(result.is_ok(), false);
    426         assert_eq!(expected.is_match(&result.unwrap_err().to_string()), true);
    427     }
    428 
    429     #[test]
    430     #[cfg(target_family = "unix")]
    431     fn test_send_command_basic() {
    432         let tmp = setup_integration("test_send_command_basic");
    433 
    434         let dst = "~/test_send_command_basic/foo";
    435         let dst_real = tmp.ssh.join("foo");
    436         let cmd = format!("echo 'contents of foo' > {}", dst);
    437 
    438         let result = send_command(&cmd, SSH_HOST);
    439 
    440         assert_eq!(result.is_ok(), true);
    441         assert_eq!(dst_real.exists(), true);
    442         assert_eq!(read_file(&dst_real), "contents of foo\n");
    443     }
    444 
    445     #[test]
    446     fn test_send_command_bad_host() {
    447         let _tmp = setup_integration("test_send_command_bad_host");
    448 
    449         let cmd = format!("echo Hello World");
    450         let bad_host = "fake@coliru.test.internal"; // Will be a DNS error
    451 
    452         let result = send_command(&cmd, bad_host);
    453         let expected = Regex::new("SSH terminated unsuccessfully: \
    454                                    exit (status|code): \\d+").unwrap();
    455 
    456         assert_eq!(result.is_ok(), false);
    457         assert_eq!(expected.is_match(&result.unwrap_err().to_string()), true);
    458     }
    459 }