From fca3bd69047431fc475697b124790166c4f650c2 Mon Sep 17 00:00:00 2001 From: Chip Senkbeil Date: Fri, 24 Sep 2021 13:57:05 -0500 Subject: [PATCH] Add some initial tests to wezterm-ssh, ignoring read/write of file as those are hanging --- Cargo.lock | 207 ++++++++++++- wezterm-ssh/Cargo.toml | 6 + wezterm-ssh/tests/e2e/mod.rs | 1 + wezterm-ssh/tests/e2e/sftp.rs | 67 ++++ wezterm-ssh/tests/e2e/sftp/file.rs | 52 ++++ wezterm-ssh/tests/lib.rs | 2 + wezterm-ssh/tests/sshd.rs | 479 +++++++++++++++++++++++++++++ 7 files changed, 811 insertions(+), 3 deletions(-) create mode 100644 wezterm-ssh/tests/e2e/mod.rs create mode 100644 wezterm-ssh/tests/e2e/sftp.rs create mode 100644 wezterm-ssh/tests/e2e/sftp/file.rs create mode 100644 wezterm-ssh/tests/lib.rs create mode 100644 wezterm-ssh/tests/sshd.rs diff --git a/Cargo.lock b/Cargo.lock index 6c63ae1c4..74c353d0e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -76,7 +76,7 @@ dependencies = [ "encoding_rs", "flate2", "glyph-names", - "itertools", + "itertools 0.8.2", "lazy_static", "libc", "log", @@ -133,6 +133,20 @@ dependencies = [ "libloading 0.7.0", ] +[[package]] +name = "assert_fs" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d7b349de9bde9383966c8d3be1103623620ca34d2c43a41b82360e552661007" +dependencies = [ + "doc-comment", + "globwalk", + "predicates", + "predicates-core", + "predicates-tree", + "tempfile", +] + [[package]] name = "async-channel" version = "1.6.1" @@ -1028,6 +1042,12 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "524cbf6897b527295dff137cec09ecf3a05f4fddffd7dfcd1585403449e74198" +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + [[package]] name = "digest" version = "0.8.1" @@ -1320,6 +1340,15 @@ dependencies = [ "miniz_oxide 0.4.4", ] +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" +dependencies = [ + "num-traits", +] + [[package]] name = "flume" version = "0.10.9" @@ -1799,6 +1828,30 @@ dependencies = [ "takeable-option", ] +[[package]] +name = "globset" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10463d9ff00a2a068db14231982f5132edebad0d7660cd956a1c30292dbcbfbd" +dependencies = [ + "aho-corasick", + "bstr 0.2.16", + "fnv", + "log", + "regex", +] + +[[package]] +name = "globwalk" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93e3af942408868f6934a7b85134a3230832b9977cf66125df2f9edcfce4ddcc" +dependencies = [ + "bitflags", + "ignore", + "walkdir", +] + [[package]] name = "gloo-timers" version = "0.2.1" @@ -1990,6 +2043,24 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "ignore" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "713f1b139373f96a2e0ce3ac931cd01ee973c3c5dd7c40c0c2efe96ad2b6751d" +dependencies = [ + "crossbeam-utils", + "globset", + "lazy_static", + "log", + "memchr", + "regex", + "same-file", + "thread_local", + "walkdir", + "winapi-util", +] + [[package]] name = "image" version = "0.23.14" @@ -2019,6 +2090,15 @@ dependencies = [ "hashbrown 0.11.2", ] +[[package]] +name = "indoc" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5a75aeaaef0ce18b58056d306c27b07436fbb34b8816c53094b76dd81803136" +dependencies = [ + "unindent", +] + [[package]] name = "inotify" version = "0.7.1" @@ -2081,6 +2161,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69ddb889f9d0d08a67338271fa9b62996bc788c7796a5c18cf057420aaed5eaf" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "0.4.8" @@ -2714,6 +2803,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db1b4163932b207be6e3a06412aed4d84cca40dc087419f231b3a38cba2ca8e9" +[[package]] +name = "normalize-line-endings" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" + [[package]] name = "notify" version = "4.0.17" @@ -3013,7 +3108,7 @@ checksum = "f842b1982eb6c2fe34036a4fbfb06dd185a3f5c8edfaacdf7d1ea10b07de6252" dependencies = [ "lock_api 0.3.4", "parking_lot_core 0.6.2", - "rustc_version", + "rustc_version 0.2.3", ] [[package]] @@ -3037,7 +3132,7 @@ dependencies = [ "cloudabi", "libc", "redox_syscall 0.1.57", - "rustc_version", + "rustc_version 0.2.3", "smallvec 0.6.14", "winapi 0.3.9", ] @@ -3288,6 +3383,36 @@ version = "0.2.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac74c624d6b2d21f425f752262f42188365d7b8ff1aff74c82e45136510a4857" +[[package]] +name = "predicates" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c143348f141cc87aab5b950021bac6145d0e5ae754b0591de23244cee42c9308" +dependencies = [ + "difflib", + "float-cmp", + "itertools 0.10.1", + "normalize-line-endings", + "predicates-core", + "regex", +] + +[[package]] +name = "predicates-core" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57e35a3326b75e49aa85f5dc6ec15b41108cf5aee58eabb1f274dd18b73c2451" + +[[package]] +name = "predicates-tree" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7dd0fd014130206c9352efbdc92be592751b2b9274dff685348341082c6ea3d" +dependencies = [ + "predicates-core", + "treeline", +] + [[package]] name = "pretty_assertions" version = "0.6.1" @@ -3763,6 +3888,19 @@ dependencies = [ "petgraph", ] +[[package]] +name = "rstest" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2288c66aeafe3b2ed227c981f364f9968fa952ef0b30e84ada4486e7ee24d00a" +dependencies = [ + "cfg-if 1.0.0", + "proc-macro2", + "quote", + "rustc_version 0.4.0", + "syn", +] + [[package]] name = "rustc-demangle" version = "0.1.21" @@ -3784,6 +3922,15 @@ dependencies = [ "semver 0.9.0", ] +[[package]] +name = "rustc_version" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa0f585226d2e68097d4f95d113b15b83a82e819ab25717ec0590d9584ef366" +dependencies = [ + "semver 1.0.4", +] + [[package]] name = "ryu" version = "1.0.5" @@ -3877,6 +4024,12 @@ dependencies = [ "semver-parser 0.10.2", ] +[[package]] +name = "semver" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "568a8e6258aa33c13358f81fd834adb854c6f7c9468520910a9b1e8fac068012" + [[package]] name = "semver-parser" version = "0.7.0" @@ -4120,6 +4273,27 @@ dependencies = [ "once_cell", ] +[[package]] +name = "smol-potat" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "894ffa61af5c0fab697c8c29b1ab10cb6ec4978a1ccac4a81b5b312df1ffd88e" +dependencies = [ + "async-io", + "smol-potat-macro", +] + +[[package]] +name = "smol-potat-macro" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b7cd8129a18069385b4eadaa81182b1451fab312ad6f58d1d99253082bf3932" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "socket2" version = "0.4.2" @@ -4447,6 +4621,15 @@ dependencies = [ "syn", ] +[[package]] +name = "thread_local" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8018d24e04c95ac8790716a5987d0fec4f8b27249ffa0f7d33f1369bdfb88cbd" +dependencies = [ + "once_cell", +] + [[package]] name = "thunderdome" version = "0.4.1" @@ -4524,6 +4707,12 @@ dependencies = [ "serde", ] +[[package]] +name = "treeline" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7f741b240f1a48843f9b8e0444fb55fb2a4ff67293b50a9179dfd5ea67f8d41" + [[package]] name = "typenum" version = "1.14.0" @@ -4623,6 +4812,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ccb82d61f80a663efe1f787a51b16b5a51e3314d6ac365b08639f52387b33f3" +[[package]] +name = "unindent" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f14ee04d9415b52b3aeab06258a3f07093182b88ba0f9b8d203f211a7a7d41c7" + [[package]] name = "untrusted" version = "0.7.1" @@ -5165,18 +5360,24 @@ name = "wezterm-ssh" version = "0.2.0" dependencies = [ "anyhow", + "assert_fs", "async_ossl", "base64", "dirs-next", "filedescriptor", "filenamegen", + "indoc", "k9", "log", + "once_cell", "portable-pty", + "predicates", "pretty_env_logger", "regex", + "rstest", "shell-words", "smol", + "smol-potat", "ssh2", "structopt", "termwiz", diff --git a/wezterm-ssh/Cargo.toml b/wezterm-ssh/Cargo.toml index 7b2b5b19e..067185388 100644 --- a/wezterm-ssh/Cargo.toml +++ b/wezterm-ssh/Cargo.toml @@ -29,8 +29,14 @@ ssh2 = {version="0.9.3", features=["openssl-on-win32"]} async_ossl = { path = "../async_ossl" } [dev-dependencies] +assert_fs = "1.0.4" +indoc = "1.0.3" k9 = "0.11.0" +once_cell = "1.8.0" +predicates = "2.0.2" pretty_env_logger = "0.4" +rstest = "0.11.0" shell-words = "1.0" +smol-potat = "1.1.2" structopt = "0.3" termwiz = { path = "../termwiz" } diff --git a/wezterm-ssh/tests/e2e/mod.rs b/wezterm-ssh/tests/e2e/mod.rs new file mode 100644 index 000000000..4ce980ac5 --- /dev/null +++ b/wezterm-ssh/tests/e2e/mod.rs @@ -0,0 +1 @@ +mod sftp; diff --git a/wezterm-ssh/tests/e2e/sftp.rs b/wezterm-ssh/tests/e2e/sftp.rs new file mode 100644 index 000000000..8f92703c0 --- /dev/null +++ b/wezterm-ssh/tests/e2e/sftp.rs @@ -0,0 +1,67 @@ +use crate::sshd::session; +use assert_fs::{prelude::*, TempDir}; +use rstest::*; +use ssh2::FileType; +use std::path::PathBuf; +use wezterm_ssh::Session; + +// Sftp file tests +mod file; + +#[inline] +fn file_type_to_str(file_type: FileType) -> &'static str { + if file_type.is_dir() { + "dir" + } else if file_type.is_file() { + "file" + } else { + "symlink" + } +} + +#[rstest] +#[smol_potat::test] +async fn should_support_listing_directory_contents(#[future] session: Session) { + let session = session.await; + + // $TEMP/dir1/ + // $TEMP/dir2/ + // $TEMP/file1 + // $TEMP/file2 + // $TEMP/dir-link -> $TEMP/dir1/ + // $TEMP/file-link -> $TEMP/file1 + let temp = TempDir::new().unwrap(); + let dir1 = temp.child("dir1"); + dir1.create_dir_all().unwrap(); + let dir2 = temp.child("dir2"); + dir2.create_dir_all().unwrap(); + let file1 = temp.child("file1"); + file1.touch().unwrap(); + let file2 = temp.child("file2"); + file2.touch().unwrap(); + let link_dir = temp.child("link-dir"); + link_dir.symlink_to_dir(dir1.path()).unwrap(); + let link_file = temp.child("link-file"); + link_file.symlink_to_file(file1.path()).unwrap(); + + let mut contents = session + .readdir(temp.path().to_path_buf()) + .await + .expect("Failed to read directory") + .into_iter() + .map(|(p, s)| (p, file_type_to_str(s.file_type()))) + .collect::>(); + contents.sort_unstable_by_key(|(p, _)| p.to_path_buf()); + + assert_eq!( + contents, + vec![ + (dir1.path().to_path_buf(), "dir"), + (dir2.path().to_path_buf(), "dir"), + (file1.path().to_path_buf(), "file"), + (file2.path().to_path_buf(), "file"), + (link_dir.path().to_path_buf(), "symlink"), + (link_file.path().to_path_buf(), "symlink"), + ] + ); +} diff --git a/wezterm-ssh/tests/e2e/sftp/file.rs b/wezterm-ssh/tests/e2e/sftp/file.rs new file mode 100644 index 000000000..c312c99e8 --- /dev/null +++ b/wezterm-ssh/tests/e2e/sftp/file.rs @@ -0,0 +1,52 @@ +use crate::sshd::session; +use assert_fs::{prelude::*, TempDir}; +use rstest::*; +use smol::io::{AsyncReadExt, AsyncWriteExt}; +use wezterm_ssh::Session; + +#[rstest] +#[smol_potat::test] +#[ignore] +async fn should_support_async_reading(#[future] session: Session) { + let session = session.await; + + let temp = TempDir::new().unwrap(); + let file = temp.child("test-file"); + file.write_str("some file contents").unwrap(); + + let mut remote_file = session + .open(file.path().to_path_buf()) + .await + .expect("Failed to open remote file"); + + let mut contents = String::new(); + remote_file + .read_to_string(&mut contents) + .await + .expect("Failed to read file to string"); + + assert_eq!(contents, "some file contents"); +} + +#[rstest] +#[smol_potat::test] +#[ignore] +async fn should_support_async_writing(#[future] session: Session) { + let session = session.await; + + let temp = TempDir::new().unwrap(); + let file = temp.child("test-file"); + file.write_str("some file contents").unwrap(); + + let mut remote_file = session + .create(file.path().to_path_buf()) + .await + .expect("Failed to open remote file"); + + remote_file + .write_all(b"new contents for file") + .await + .expect("Failed to write to file"); + + file.assert("new contents for file"); +} diff --git a/wezterm-ssh/tests/lib.rs b/wezterm-ssh/tests/lib.rs new file mode 100644 index 000000000..de22d5be1 --- /dev/null +++ b/wezterm-ssh/tests/lib.rs @@ -0,0 +1,2 @@ +mod e2e; +mod sshd; diff --git a/wezterm-ssh/tests/sshd.rs b/wezterm-ssh/tests/sshd.rs new file mode 100644 index 000000000..94504c3a0 --- /dev/null +++ b/wezterm-ssh/tests/sshd.rs @@ -0,0 +1,479 @@ +use assert_fs::{prelude::*, TempDir}; +use once_cell::sync::OnceCell; +use rstest::*; +use std::{ + collections::HashMap, + fmt, io, + path::Path, + process::{Child, Command}, + sync::atomic::{AtomicU16, Ordering}, + thread, + time::Duration, +}; +use wezterm_ssh::{Config, Session, SessionEvent}; + +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; + +/// NOTE: OpenSSH's sshd requires absolute path +const BIN_PATH_STR: &str = "/usr/sbin/sshd"; + +/// Port range to use when finding a port to bind to (using IANA guidance) +const PORT_RANGE: (u16, u16) = (49152, 65535); + +pub struct SshKeygen; + +impl SshKeygen { + // ssh-keygen -t rsa -f $ROOT/id_rsa -N "" -q + pub fn generate_rsa(path: impl AsRef, passphrase: impl AsRef) -> io::Result { + let res = Command::new("ssh-keygen") + .args(&["-t", "rsa"]) + .arg("-f") + .arg(path.as_ref()) + .arg("-N") + .arg(passphrase.as_ref()) + .arg("-q") + .status() + .map(|status| status.success())?; + + #[cfg(unix)] + if res { + // chmod 600 id_rsa* -> ida_rsa + ida_rsa.pub + std::fs::metadata(path.as_ref().with_extension("pub"))? + .permissions() + .set_mode(0o600); + std::fs::metadata(path)?.permissions().set_mode(0o600); + } + + Ok(res) + } +} + +pub struct SshAgent; + +impl SshAgent { + pub fn generate_shell_env() -> io::Result> { + let output = Command::new("ssh-agent").arg("-s").output()?; + let stdout = String::from_utf8(output.stdout) + .map_err(|x| io::Error::new(io::ErrorKind::InvalidData, x))?; + Ok(stdout + .split(";") + .map(str::trim) + .filter(|s| s.contains("=")) + .map(|s| { + let mut tokens = s.split("="); + let key = tokens.next().unwrap().trim().to_string(); + let rest = tokens + .map(str::trim) + .map(ToString::to_string) + .collect::>() + .join("="); + (key, rest) + }) + .collect::>()) + } + + pub fn update_tests_with_shell_env() -> io::Result<()> { + let env_map = Self::generate_shell_env()?; + for (key, value) in env_map { + std::env::set_var(key, value); + } + + Ok(()) + } +} + +pub struct SshAdd; + +impl SshAdd { + pub fn exec(path: impl AsRef) -> io::Result { + let env_map = SshAgent::generate_shell_env()?; + + Command::new("ssh-add") + .arg(path.as_ref()) + .envs(env_map) + .status() + .map(|status| status.success()) + } +} + +#[derive(Debug)] +pub struct SshdConfig(HashMap>); + +impl Default for SshdConfig { + fn default() -> Self { + let mut config = Self::new(); + + config.set_authentication_methods(vec!["publickey".to_string()]); + config.set_subsystem(true, true); + config.set_use_pam(false); + config.set_x11_forwarding(true); + config.set_use_privilege_separation(false); + config.set_print_motd(true); + config.set_permit_tunnel(true); + config.set_kbd_interactive_authentication(true); + config.set_allow_tcp_forwarding(true); + config.set_max_startups(500, None); + config.set_strict_modes(false); + + config + } +} + +impl SshdConfig { + pub fn new() -> Self { + Self(HashMap::new()) + } + + pub fn set_authentication_methods(&mut self, methods: Vec) { + self.0.insert("AuthenticationMethods".to_string(), methods); + } + + pub fn set_authorized_keys_file(&mut self, path: impl AsRef) { + self.0.insert( + "AuthorizedKeysFile".to_string(), + vec![path.as_ref().to_string_lossy().to_string()], + ); + } + + pub fn set_host_key(&mut self, path: impl AsRef) { + self.0.insert( + "HostKey".to_string(), + vec![path.as_ref().to_string_lossy().to_string()], + ); + } + + pub fn set_pid_file(&mut self, path: impl AsRef) { + self.0.insert( + "PidFile".to_string(), + vec![path.as_ref().to_string_lossy().to_string()], + ); + } + + pub fn set_subsystem(&mut self, sftp: bool, internal_sftp: bool) { + let mut values = Vec::new(); + if sftp { + values.push("sftp".to_string()); + } + if internal_sftp { + values.push("internal-sftp".to_string()); + } + + self.0.insert("Subsystem".to_string(), values); + } + + pub fn set_use_pam(&mut self, yes: bool) { + self.0.insert("UsePAM".to_string(), Self::yes_value(yes)); + } + + pub fn set_x11_forwarding(&mut self, yes: bool) { + self.0 + .insert("X11Forwarding".to_string(), Self::yes_value(yes)); + } + + pub fn set_use_privilege_separation(&mut self, yes: bool) { + self.0 + .insert("UsePrivilegeSeparation".to_string(), Self::yes_value(yes)); + } + + pub fn set_print_motd(&mut self, yes: bool) { + self.0.insert("PrintMotd".to_string(), Self::yes_value(yes)); + } + + pub fn set_permit_tunnel(&mut self, yes: bool) { + self.0 + .insert("PermitTunnel".to_string(), Self::yes_value(yes)); + } + + pub fn set_kbd_interactive_authentication(&mut self, yes: bool) { + self.0.insert( + "KbdInteractiveAuthentication".to_string(), + Self::yes_value(yes), + ); + } + + pub fn set_allow_tcp_forwarding(&mut self, yes: bool) { + self.0 + .insert("AllowTcpForwarding".to_string(), Self::yes_value(yes)); + } + + pub fn set_max_startups(&mut self, start: u16, rate_full: Option<(u16, u16)>) { + let value = format!( + "{}{}", + start, + rate_full + .map(|(r, f)| format!(":{}:{}", r, f)) + .unwrap_or_default(), + ); + + self.0.insert("MaxStartups".to_string(), vec![value]); + } + + pub fn set_strict_modes(&mut self, yes: bool) { + self.0 + .insert("StrictModes".to_string(), Self::yes_value(yes)); + } + + fn yes_value(yes: bool) -> Vec { + vec![Self::yes_string(yes)] + } + + fn yes_string(yes: bool) -> String { + Self::yes_str(yes).to_string() + } + + const fn yes_str(yes: bool) -> &'static str { + if yes { + "yes" + } else { + "no" + } + } +} + +impl fmt::Display for SshdConfig { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for (keyword, values) in self.0.iter() { + writeln!( + f, + "{} {}", + keyword, + values + .iter() + .map(|v| { + let v = v.trim(); + if v.contains(|c: char| c.is_whitespace()) { + format!("\"{}\"", v) + } else { + v.to_string() + } + }) + .collect::>() + .join(" ") + )?; + } + Ok(()) + } +} + +/// Context for some sshd instance +pub struct Sshd { + child: Child, + + /// Port that sshd is listening on + pub port: u16, + + /// Temporary directory used to hold resources for sshd such as its config, keys, and log + pub tmp: TempDir, +} + +impl Sshd { + pub fn spawn(mut config: SshdConfig) -> Result> { + let tmp = TempDir::new()?; + + // Ensure that everything needed for interacting with ssh-agent is set + SshAgent::update_tests_with_shell_env()?; + + // ssh-keygen -t rsa -f $ROOT/id_rsa -N "" -q + let id_rsa_file = tmp.child("id_rsa"); + assert!( + SshKeygen::generate_rsa(id_rsa_file.path(), "")?, + "Failed to ssh-keygen id_rsa" + ); + assert!( + SshAdd::exec(id_rsa_file.path())?, + "Failed to ssh-add id_rsa" + ); + + // cp $ROOT/id_rsa.pub $ROOT/authorized_keys + let authorized_keys_file = tmp.child("authorized_keys"); + std::fs::copy( + id_rsa_file.path().with_extension("pub"), + authorized_keys_file.path(), + )?; + + // ssh-keygen -t rsa -f $ROOT/ssh_host_rsa_key -N "" -q + let ssh_host_rsa_key_file = tmp.child("ssh_host_rsa_key"); + assert!( + SshKeygen::generate_rsa(ssh_host_rsa_key_file.path(), "")?, + "Failed to ssh-keygen ssh_host_rsa_key" + ); + + config.set_authorized_keys_file(id_rsa_file.path().with_extension("pub")); + config.set_host_key(ssh_host_rsa_key_file.path()); + + let sshd_pid_file = tmp.child("sshd.pid"); + config.set_pid_file(sshd_pid_file.path()); + + // Generate $ROOT/sshd_config based on config + let sshd_config_file = tmp.child("sshd_config"); + sshd_config_file.write_str(&config.to_string())?; + + let sshd_log_file = tmp.child("sshd.log"); + + let (child, port) = Self::try_spawn_next(sshd_config_file.path(), sshd_log_file.path()) + .expect("No open port available for sshd"); + + Ok(Self { child, port, tmp }) + } + + fn try_spawn_next( + config_path: impl AsRef, + log_path: impl AsRef, + ) -> io::Result<(Child, u16)> { + static PORT: AtomicU16 = AtomicU16::new(PORT_RANGE.0); + + loop { + let port = PORT.fetch_add(1, Ordering::Relaxed); + + match Self::try_spawn(port, config_path.as_ref(), log_path.as_ref()) { + // If successful, return our spawned server child process + Ok(Ok(child)) => break Ok((child, port)), + + // If the server died when spawned and we reached the final port, we want to exit + Ok(Err((code, msg))) if port == PORT_RANGE.1 => { + break Err(io::Error::new( + io::ErrorKind::Other, + format!( + "{} failed [{}]: {}", + BIN_PATH_STR, + code.map(|x| x.to_string()) + .unwrap_or_else(|| String::from("???")), + msg + ), + )) + } + + // If we've reached the final port in our range to try, we want to exit + Err(x) if port == PORT_RANGE.1 => break Err(x), + + // Otherwise, try next port + Err(_) | Ok(Err(_)) => continue, + } + } + } + + fn try_spawn( + port: u16, + config_path: impl AsRef, + log_path: impl AsRef, + ) -> io::Result, String)>> { + let mut child = Command::new(BIN_PATH_STR) + .arg("-D") + .arg("-p") + .arg(port.to_string()) + .arg("-f") + .arg(config_path.as_ref()) + .arg("-E") + .arg(log_path.as_ref()) + .spawn()?; + + // Pause for couple of seconds to make sure that the server didn't die due to an error + thread::sleep(Duration::from_secs(2)); + + if let Some(exit_status) = child.try_wait()? { + let output = child.wait_with_output()?; + Ok(Err(( + exit_status.code(), + format!( + "{}\n{}", + String::from_utf8(output.stdout).unwrap(), + String::from_utf8(output.stderr).unwrap(), + ), + ))) + } else { + Ok(Ok(child)) + } + } +} + +impl Drop for Sshd { + /// Kills server upon drop + fn drop(&mut self) { + let _ = self.child.kill(); + } +} + +#[fixture] +/// Stand up a singular sshd session and hold onto it for the lifetime +/// of our tests, returning a reference to it with each fixture ref +pub fn sshd() -> &'static Sshd { + static SSHD: OnceCell = OnceCell::new(); + + SSHD.get_or_init(|| Sshd::spawn(Default::default()).unwrap()) +} + +#[fixture] +/// Stand up an sshd instance and then connect to it and perform authentication +pub async fn session(sshd: &'_ Sshd) -> Session { + let port = sshd.port; + + let mut config = Config::new(); + config.add_default_config_files(); + + // Load our config to point to ourselves, using current sshd instance's port, + // generated identity file, and host file + let mut config = config.for_host("localhost"); + config.insert("port".to_string(), port.to_string()); + config.insert( + "identityfile".to_string(), + sshd.tmp + .child("id_rsa") + .path() + .to_str() + .expect("Failed to get string path for id_rsa") + .to_string(), + ); + config.insert( + "userknownhostsfile".to_string(), + sshd.tmp + .child("known_hosts") + .path() + .to_str() + .expect("Failed to get string path for known_hosts") + .to_string(), + ); + + // Perform our actual connection + let (session, events) = Session::connect(config.clone()).expect("Failed to connect to sshd"); + + // Perform automated authentication, assuming that we have a publickey with empty password + while let Ok(event) = events.recv().await { + match event { + SessionEvent::Banner(banner) => { + if let Some(banner) = banner { + log::trace!("{}", banner); + } + } + SessionEvent::HostVerify(verify) => { + eprintln!("{}", verify.message); + + // Automatically verify any host + verify + .answer(true) + .await + .expect("Failed to send host verification"); + } + SessionEvent::Authenticate(auth) => { + if !auth.username.is_empty() { + eprintln!("Authentication for {}", auth.username); + } + if !auth.instructions.is_empty() { + eprintln!("{}", auth.instructions); + } + + // Reply with empty string to all authentication requests + let answers = vec![String::new(); auth.prompts.len()]; + auth.answer(answers) + .await + .expect("Failed to send authenticate response"); + } + SessionEvent::Error(err) => { + panic!("{}", err); + } + SessionEvent::Authenticated => break, + } + } + + session +}