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 }