use std::{
fmt::Write as _,
fs::{OpenOptions, create_dir_all},
io::Write as _,
net::{TcpStream, ToSocketAddrs},
path::{Path, PathBuf},
sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
},
thread::JoinHandle,
time::Duration,
};
use anyhow::{Context, Result, bail};
use reqwest::Url;
use ssh2::{CheckResult, HashType, HostKeyType, KnownHostFileKind, KnownHostKeyFormat, Session};
pub type SharedSession = Arc<Mutex<Session>>;
pub struct SftpKeepalive {
stop: Arc<AtomicBool>,
handle: Option<JoinHandle<()>>,
}
impl SftpKeepalive {
#[must_use]
pub fn start(session: SharedSession, name: String) -> Self {
let secs = u64::from(super::retry::env_u32("VERSATILES_SFTP_KEEPALIVE_SECS", 15));
let interval = Duration::from_secs(secs.max(1));
let stop = Arc::new(AtomicBool::new(false));
let stop_thread = Arc::clone(&stop);
let handle = std::thread::Builder::new()
.name("sftp-keepalive".into())
.spawn(move || {
let tick = Duration::from_millis(500).min(interval);
let mut waited = Duration::ZERO;
while !stop_thread.load(Ordering::Relaxed) {
std::thread::sleep(tick);
waited += tick;
if waited < interval {
continue;
}
waited = Duration::ZERO;
if stop_thread.load(Ordering::Relaxed) {
break;
}
let session = match session.lock() {
Ok(guard) => guard.clone(),
Err(_) => break, };
match session.keepalive_send() {
Ok(_) => log::trace!("sent SFTP keepalive to '{name}'"),
Err(e) => log::debug!("SFTP keepalive to '{name}' failed (will reconnect on next op): {e}"),
}
}
})
.expect("spawning sftp-keepalive thread");
SftpKeepalive {
stop,
handle: Some(handle),
}
}
}
impl Drop for SftpKeepalive {
fn drop(&mut self) {
self.stop.store(true, Ordering::Relaxed);
if let Some(handle) = self.handle.take() {
let _ = handle.join();
}
}
}
pub fn open_session(url: &Url, identity_file: Option<&Path>) -> Result<Session> {
let host = url.host_str().context("SFTP URL has no host")?;
let port = url.port().unwrap_or(22);
let username = if url.username().is_empty() {
"root"
} else {
url.username()
};
let addr = (host, port)
.to_socket_addrs()
.with_context(|| format!("failed to resolve {host}:{port}"))?
.next()
.with_context(|| format!("no addresses found for {host}:{port}"))?;
#[cfg(not(test))]
let connect_timeout = Duration::from_secs(30);
#[cfg(test)]
let connect_timeout = Duration::from_millis(200);
let tcp = TcpStream::connect_timeout(&addr, connect_timeout)
.with_context(|| format!("failed to connect to {host}:{port}"))?;
#[cfg(not(test))]
{
let ka_secs = u64::from(super::retry::env_u32("VERSATILES_SFTP_KEEPALIVE_SECS", 15));
let keepalive = socket2::TcpKeepalive::new()
.with_time(Duration::from_secs(ka_secs))
.with_interval(Duration::from_secs(ka_secs));
if let Err(e) = socket2::SockRef::from(&tcp).set_tcp_keepalive(&keepalive) {
log::warn!("failed to enable TCP keepalive on SFTP socket: {e}");
}
}
#[cfg(not(test))]
let timeout_ms = super::retry::env_u32("VERSATILES_SFTP_TIMEOUT_MS", 30_000);
#[cfg(test)]
let timeout_ms = url
.query_pairs()
.find(|(k, _)| k == "timeout_ms")
.and_then(|(_, v)| v.parse::<u32>().ok())
.unwrap_or(500);
if timeout_ms > 0 {
let socket_timeout = Duration::from_millis(u64::from(timeout_ms));
if let Err(e) = tcp.set_read_timeout(Some(socket_timeout)) {
log::warn!("failed to set the read timeout on the SFTP socket: {e}");
}
if let Err(e) = tcp.set_write_timeout(Some(socket_timeout)) {
log::warn!("failed to set the write timeout on the SFTP socket: {e}");
}
}
let mut session = Session::new()?;
session.set_tcp_stream(tcp);
session.set_timeout(timeout_ms);
session.handshake()?;
verify_host_key(&session, host, port)?;
#[cfg(not(test))]
session.set_keepalive(true, super::retry::env_u32("VERSATILES_SFTP_KEEPALIVE_SECS", 15));
let target = display_name(url);
let password = url.password();
if let Some(password) = password {
log::debug!("SFTP auth: trying password for {target}");
if session.userauth_password(username, password).is_ok() && session.authenticated() {
log::debug!("SFTP auth: password succeeded");
return Ok(session);
}
log::debug!("SFTP auth: password failed");
}
if let Some(identity) = identity_file {
log::debug!("SFTP auth: trying identity file {identity:?} for {target}");
if identity.exists() {
match session.userauth_pubkey_file(username, None, identity, None) {
Ok(()) if session.authenticated() => {
log::debug!("SFTP auth: identity file succeeded");
return Ok(session);
}
Ok(()) => log::debug!("SFTP auth: identity file returned Ok but not authenticated"),
Err(e) => log::debug!("SFTP auth: identity file failed: {e}"),
}
} else {
log::debug!("SFTP auth: identity file {identity:?} does not exist");
}
}
log::debug!("SFTP auth: trying SSH agent for {target}");
if try_agent_auth(&session, username).is_ok() && session.authenticated() {
log::debug!("SFTP auth: agent succeeded");
return Ok(session);
}
log::debug!("SFTP auth: agent failed");
log::debug!("SFTP auth: trying ~/.ssh/config keys for {target}");
if try_config_key_auth(&session, username, host).is_ok() && session.authenticated() {
log::debug!("SFTP auth: config key succeeded");
return Ok(session);
}
log::debug!("SFTP auth: config key failed");
log::debug!("SFTP auth: trying default key files for {target}");
try_key_auth(&session, username).with_context(|| format!("all authentication methods failed for {target}"))?;
if !session.authenticated() {
bail!("SSH authentication failed for {target}");
}
Ok(session)
}
#[must_use]
pub fn remote_path(url: &Url) -> PathBuf {
PathBuf::from(url.path())
}
#[must_use]
pub fn display_name(url: &Url) -> String {
let host = url.host_str().unwrap_or("unknown");
let port = url.port().unwrap_or(22);
format!("sftp://{host}:{port}{}", url.path())
}
fn try_agent_auth(session: &Session, username: &str) -> Result<()> {
let mut agent = session.agent()?;
agent.connect()?;
agent.list_identities()?;
for identity in agent.identities()? {
if agent.userauth(username, &identity).is_ok() {
return Ok(());
}
}
bail!("SSH agent has no suitable identities for user '{username}'")
}
fn try_config_key_auth(session: &Session, username: &str, host: &str) -> Result<()> {
use std::{fs::File, io::BufReader};
use ssh2_config::{ParseRule, SshConfig};
let home = dirs_home()?;
let config_path = home.join(".ssh/config");
if !config_path.exists() {
bail!("no ~/.ssh/config found");
}
let file = File::open(&config_path).with_context(|| format!("failed to open {config_path:?}"))?;
let mut reader = BufReader::new(file);
let config = SshConfig::default().parse(&mut reader, ParseRule::ALLOW_UNKNOWN_FIELDS)?;
let params = config.query(host);
let identity_files = params.identity_file.unwrap_or_default();
for identity in &identity_files {
let expanded = if identity.starts_with("~") {
home.join(identity.strip_prefix("~").unwrap_or(identity))
} else {
identity.clone()
};
if expanded.exists() && session.userauth_pubkey_file(username, None, &expanded, None).is_ok() {
return Ok(());
}
}
bail!("no suitable SSH key found in ~/.ssh/config for {host}")
}
fn try_key_auth(session: &Session, username: &str) -> Result<()> {
let home = dirs_home()?;
let key_files = [
home.join(".ssh/id_ed25519"),
home.join(".ssh/id_rsa"),
home.join(".ssh/id_ecdsa"),
];
for key_path in &key_files {
if key_path.exists() && session.userauth_pubkey_file(username, None, key_path, None).is_ok() {
return Ok(());
}
}
bail!("no suitable SSH key found in ~/.ssh/")
}
fn verify_host_key(session: &Session, host: &str, port: u16) -> Result<()> {
let Some(path) = known_hosts_path()? else {
log::warn!("SFTP host key verification is disabled for {host}:{port} (VERSATILES_SFTP_KNOWN_HOSTS)");
return Ok(());
};
let (key, key_type) = {
let (key, key_type) = session.host_key().context("the server presented no host key")?;
(key.to_vec(), key_type)
};
let mut known_hosts = session.known_hosts()?;
if path.exists() {
known_hosts
.read_file(&path, KnownHostFileKind::OpenSSH)
.with_context(|| format!("failed to read the known hosts file {}", path.display()))?;
}
match known_hosts.check_port(host, port, &key) {
CheckResult::Match => {
log::debug!("SFTP host key for {host}:{port} matches {}", path.display());
Ok(())
}
CheckResult::NotFound => {
remember_host_key(session, &path, host, port, &key, key_type)
.with_context(|| format!("failed to record the host key for {host}:{port}"))?;
log::warn!(
"SFTP: {host}:{port} was not known; recorded its host key ({}) in {}",
fingerprint(session),
path.display()
);
Ok(())
}
CheckResult::Mismatch => bail!(
"host key verification failed for {host}:{port}: the server presented {}, which is not the key recorded in {}. \
Either the server's key changed, or this connection is being intercepted. \
If the change was expected, remove the stale line from that file.",
fingerprint(session),
path.display()
),
CheckResult::Failure => bail!(
"host key verification failed for {host}:{port}: the key could not be checked against {}",
path.display()
),
}
}
fn remember_host_key(
session: &Session,
path: &Path,
host: &str,
port: u16,
key: &[u8],
key_type: HostKeyType,
) -> Result<()> {
let name = if port == 22 {
host.to_owned()
} else {
format!("[{host}]:{port}")
};
let mut entry = session.known_hosts()?;
entry.add(&name, key, "added by versatiles", KnownHostKeyFormat::from(key_type))?;
let hosts = entry.hosts()?;
let host_entry = hosts.first().context("the host key could not be encoded")?;
let line = entry.write_string(host_entry, KnownHostFileKind::OpenSSH)?;
if let Some(dir) = path.parent() {
create_dir_all(dir).with_context(|| format!("failed to create {}", dir.display()))?;
}
let needs_newline = match std::fs::read(path) {
Ok(bytes) => !bytes.is_empty() && !bytes.ends_with(b"\n"),
Err(_) => false,
};
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(path)
.with_context(|| format!("failed to open {} for appending", path.display()))?;
if needs_newline {
writeln!(file)?;
}
writeln!(file, "{}", line.trim_end())?;
Ok(())
}
fn known_hosts_path() -> Result<Option<PathBuf>> {
if let Ok(value) = std::env::var("VERSATILES_SFTP_KNOWN_HOSTS") {
let value = value.trim();
if value.is_empty() || value.eq_ignore_ascii_case("off") || value.eq_ignore_ascii_case("none") {
return Ok(None);
}
return Ok(Some(PathBuf::from(value)));
}
#[cfg(test)]
{
Ok(None)
}
#[cfg(not(test))]
{
let home = dirs_home().context(
"could not determine the home directory holding ~/.ssh/known_hosts, so the SFTP host key cannot be verified. \
Set VERSATILES_SFTP_KNOWN_HOSTS to a file path, or to 'off' to connect without verifying",
)?;
Ok(Some(home.join(".ssh").join("known_hosts")))
}
}
fn fingerprint(session: &Session) -> String {
session.host_key_hash(HashType::Sha256).map_or_else(
|| "an unknown host key".to_owned(),
|hash| {
let mut out = String::from("SHA256(hex):");
for byte in hash {
let _ = write!(out, "{byte:02x}");
}
out
},
)
}
fn dirs_home() -> Result<PathBuf> {
home_dir().context("could not determine home directory")
}
fn home_dir() -> Option<PathBuf> {
#[cfg(unix)]
{
std::env::var_os("HOME").map(PathBuf::from)
}
#[cfg(not(unix))]
{
std::env::var_os("USERPROFILE").map(PathBuf::from)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_remote_path() {
let url = Url::parse("sftp://host/data/tiles.versatiles").unwrap();
assert_eq!(remote_path(&url), PathBuf::from("/data/tiles.versatiles"));
}
#[test]
fn test_remote_path_root() {
let url = Url::parse("sftp://host/").unwrap();
assert_eq!(remote_path(&url), PathBuf::from("/"));
}
#[test]
fn test_remote_path_nested() {
let url = Url::parse("sftp://host/a/b/c/d/file.tar").unwrap();
assert_eq!(remote_path(&url), PathBuf::from("/a/b/c/d/file.tar"));
}
#[test]
fn test_remote_path_with_credentials() {
let url = Url::parse("sftp://user:pass@host/data/file.versatiles").unwrap();
assert_eq!(remote_path(&url), PathBuf::from("/data/file.versatiles"));
}
#[test]
fn test_remote_path_with_port() {
let url = Url::parse("sftp://host:2222/data/file.versatiles").unwrap();
assert_eq!(remote_path(&url), PathBuf::from("/data/file.versatiles"));
}
#[test]
fn test_display_name_strips_credentials() {
let url = Url::parse("sftp://user:secret@host:2222/data/tiles.versatiles").unwrap();
assert_eq!(display_name(&url), "sftp://host:2222/data/tiles.versatiles");
}
#[test]
fn test_display_name_default_port() {
let url = Url::parse("sftp://host/path/file.tar").unwrap();
assert_eq!(display_name(&url), "sftp://host:22/path/file.tar");
}
#[test]
fn test_display_name_custom_port() {
let url = Url::parse("sftp://host:9922/file.tar").unwrap();
assert_eq!(display_name(&url), "sftp://host:9922/file.tar");
}
#[test]
fn test_display_name_username_only() {
let url = Url::parse("sftp://admin@host/path").unwrap();
assert_eq!(display_name(&url), "sftp://host:22/path");
}
#[test]
fn test_display_name_no_path() {
let url = Url::parse("sftp://host").unwrap();
assert_eq!(display_name(&url), "sftp://host:22");
}
#[test]
fn test_home_dir_returns_some() {
assert!(home_dir().is_some());
}
#[test]
fn test_dirs_home_returns_ok() {
assert!(dirs_home().is_ok());
}
#[test]
fn test_open_session_missing_host() {
let url = Url::parse("sftp:///path/file").unwrap();
let result = open_session(&url, None);
let err = result.err().expect("expected error for missing host");
assert!(err.to_string().contains("no host"));
}
#[test]
fn test_open_session_unreachable_host() {
let url = Url::parse("sftp://192.0.2.1:22222/path").unwrap();
let result = open_session(&url, None);
assert!(result.is_err());
}
#[test]
fn test_open_session_unresolvable_host() {
let url = Url::parse("sftp://this-host-must-not-exist.invalid:22/path").unwrap();
let Err(err) = open_session(&url, None) else {
panic!("expected DNS failure for .invalid TLD");
};
let msg = format!("{err:#}");
assert!(
msg.contains("resolve") || msg.contains("connect") || msg.contains("not known") || msg.contains("lookup"),
"expected DNS / connect error, got: {msg}"
);
}
#[rstest::rstest]
#[case("sftp://host", "")]
#[case("sftp://host/", "/")]
#[case("sftp://host/path", "/path")]
#[case("sftp://user@host:2222", "")]
#[case("sftp://host/a%20b/file.tar", "/a%20b/file.tar")] fn test_remote_path_variants(#[case] url_str: &str, #[case] expected: &str) {
let url = Url::parse(url_str).unwrap();
assert_eq!(remote_path(&url), PathBuf::from(expected));
}
struct KnownHostsEnv(Option<String>);
impl KnownHostsEnv {
fn set(value: &str) -> Self {
let previous = std::env::var("VERSATILES_SFTP_KNOWN_HOSTS").ok();
unsafe { std::env::set_var("VERSATILES_SFTP_KNOWN_HOSTS", value) };
Self(previous)
}
}
impl Drop for KnownHostsEnv {
fn drop(&mut self) {
match self.0.take() {
Some(value) => unsafe { std::env::set_var("VERSATILES_SFTP_KNOWN_HOSTS", value) },
None => unsafe { std::env::remove_var("VERSATILES_SFTP_KNOWN_HOSTS") },
}
}
}
#[test]
#[serial_test::serial]
fn known_hosts_path_from_env() {
{
let _env = KnownHostsEnv::set("off");
assert_eq!(known_hosts_path().unwrap(), None);
}
{
let _env = KnownHostsEnv::set("NONE");
assert_eq!(known_hosts_path().unwrap(), None);
}
{
let _env = KnownHostsEnv::set(" ");
assert_eq!(known_hosts_path().unwrap(), None);
}
{
let _env = KnownHostsEnv::set("/tmp/some/known_hosts");
assert_eq!(
known_hosts_path().unwrap(),
Some(PathBuf::from("/tmp/some/known_hosts"))
);
}
}
#[cfg(all(feature = "ssh2", unix))]
mod sftp_server_tests {
use super::*;
use crate::io::test_sftp_server::TestSftpServer;
#[tokio::test(flavor = "current_thread")]
#[serial_test::serial]
async fn open_session_password_auth() {
let server = TestSftpServer::start().await;
let url = server.url("/");
let session = tokio::task::spawn_blocking(move || open_session(&url, None))
.await
.unwrap();
assert!(session.is_ok(), "expected successful auth: {:?}", session.err());
}
#[test]
fn a_silent_server_does_not_hang_the_client() {
use std::{net::TcpListener, time::Instant};
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let server = std::thread::spawn(move || {
let accepted = listener.accept();
std::thread::sleep(Duration::from_secs(2));
drop(accepted);
});
let url = Url::parse(&format!("sftp://user:pass@127.0.0.1:{port}/?timeout_ms=300")).unwrap();
let started = Instant::now();
let result = open_session(&url, None);
let elapsed = started.elapsed();
assert!(result.is_err(), "a server that never speaks must not authenticate");
assert!(
elapsed < Duration::from_millis(1500),
"open_session waited {elapsed:?}; the socket timeout should have ended it"
);
server.join().ok();
}
#[tokio::test(flavor = "current_thread")]
#[serial_test::serial]
async fn known_hosts_accept_new_records_the_key() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("known_hosts");
let _env = KnownHostsEnv::set(path.to_str().unwrap());
std::fs::write(&path, b"# an existing entry without a trailing newline").unwrap();
let server = TestSftpServer::start().await;
let url = server.url("/");
let port = url.port().unwrap();
let first = tokio::task::spawn_blocking({
let url = url.clone();
move || open_session(&url, None)
})
.await
.unwrap();
assert!(first.is_ok(), "first connection failed: {:?}", first.err());
let recorded = std::fs::read_to_string(&path).unwrap();
assert!(
recorded.contains(&format!("[127.0.0.1]:{port}")),
"host key was not recorded: {recorded:?}"
);
let second = tokio::task::spawn_blocking(move || open_session(&url, None))
.await
.unwrap();
assert!(second.is_ok(), "second connection failed: {:?}", second.err());
let contents = std::fs::read_to_string(&path).unwrap();
let lines = contents
.lines()
.filter(|line| !line.trim().is_empty())
.collect::<Vec<_>>();
assert_eq!(
lines.len(),
2,
"expected the pre-existing line plus one recorded key, got {lines:?}"
);
assert_eq!(lines[0], "# an existing entry without a trailing newline");
}
#[tokio::test(flavor = "current_thread")]
#[serial_test::serial]
async fn known_hosts_mismatch_is_refused() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("known_hosts");
let _env = KnownHostsEnv::set(path.to_str().unwrap());
let server_a = TestSftpServer::start().await;
let url_a = server_a.url("/");
let port_a = url_a.port().unwrap();
let recorded = tokio::task::spawn_blocking(move || open_session(&url_a, None))
.await
.unwrap();
assert!(recorded.is_ok(), "could not record server A: {:?}", recorded.err());
let server_b = TestSftpServer::start().await;
let url_b = server_b.url("/");
let port_b = url_b.port().unwrap();
assert_ne!(port_a, port_b, "the two test servers reused a port");
let entry = std::fs::read_to_string(&path)
.unwrap()
.replace(&format!("[127.0.0.1]:{port_a}"), &format!("[127.0.0.1]:{port_b}"));
std::fs::write(&path, entry).unwrap();
let result = tokio::task::spawn_blocking(move || open_session(&url_b, None))
.await
.unwrap();
let error = match result {
Ok(_) => panic!("a mismatched host key must not connect"),
Err(error) => format!("{error:#}"),
};
assert!(
error.contains("host key verification failed"),
"unexpected error: {error}"
);
}
#[tokio::test(flavor = "current_thread")]
#[serial_test::serial]
async fn open_session_wrong_password() {
let server = TestSftpServer::start().await;
let mut url = server.url("/");
url.set_password(Some("wrongpass")).unwrap();
let result = tokio::task::spawn_blocking(move || open_session(&url, None))
.await
.unwrap();
assert!(result.is_err(), "expected auth failure with wrong password");
}
#[tokio::test(flavor = "current_thread")]
#[serial_test::serial]
async fn open_session_with_unused_identity_file() {
let server = TestSftpServer::start().await;
let url = server.url("/");
let session =
tokio::task::spawn_blocking(move || open_session(&url, Some(std::path::Path::new("/nonexistent/key"))))
.await
.unwrap();
assert!(
session.is_ok(),
"password auth should succeed even with a missing identity file"
);
}
struct ShortKeepalive;
impl ShortKeepalive {
fn set() -> Self {
unsafe { std::env::set_var("VERSATILES_SFTP_KEEPALIVE_SECS", "1") };
ShortKeepalive
}
}
impl Drop for ShortKeepalive {
fn drop(&mut self) {
unsafe { std::env::remove_var("VERSATILES_SFTP_KEEPALIVE_SECS") };
}
}
#[tokio::test(flavor = "current_thread")]
#[serial_test::serial]
async fn the_keepalive_thread_pings_and_stops_on_drop() {
let _interval = ShortKeepalive::set();
let server = TestSftpServer::start().await;
let url = server.url("/");
let session = tokio::task::spawn_blocking(move || open_session(&url, None))
.await
.unwrap()
.expect("test server accepts the password");
let shared: SharedSession = Arc::new(Mutex::new(session));
let keepalive = SftpKeepalive::start(Arc::clone(&shared), "test".to_string());
tokio::time::sleep(Duration::from_millis(2500)).await;
tokio::task::spawn_blocking(move || drop(keepalive)).await.unwrap();
}
#[tokio::test(flavor = "current_thread")]
#[serial_test::serial]
async fn a_poisoned_session_ends_the_keepalive_rather_than_spinning() {
let _interval = ShortKeepalive::set();
let server = TestSftpServer::start().await;
let url = server.url("/");
let session = tokio::task::spawn_blocking(move || open_session(&url, None))
.await
.unwrap()
.expect("test server accepts the password");
let shared: SharedSession = Arc::new(Mutex::new(session));
let keepalive = SftpKeepalive::start(Arc::clone(&shared), "poisoned".to_string());
let poisoner = Arc::clone(&shared);
let _ = std::thread::spawn(move || {
let _guard = poisoner.lock().unwrap();
panic!("owner died holding the session");
})
.join();
assert!(shared.lock().is_err(), "the mutex should now be poisoned");
tokio::time::sleep(Duration::from_millis(2500)).await;
tokio::task::spawn_blocking(move || drop(keepalive)).await.unwrap();
}
#[tokio::test(flavor = "current_thread")]
#[serial_test::serial]
async fn an_identity_file_that_is_not_a_key_falls_through_to_the_next_method() {
let dir = tempfile::tempdir().unwrap();
let not_a_key = dir.path().join("id_rubbish");
std::fs::write(¬_a_key, b"this is not a private key\n").unwrap();
let server = TestSftpServer::start().await;
let url = server.url("/");
let session = tokio::task::spawn_blocking(move || open_session(&url, Some(¬_a_key)))
.await
.unwrap();
assert!(
session.is_ok(),
"password auth should still succeed: {:?}",
session.err()
);
}
#[tokio::test(flavor = "current_thread")]
#[serial_test::serial]
async fn without_a_password_every_key_based_method_is_tried_and_reported() {
let server = TestSftpServer::start().await;
let mut url = server.url("/");
url.set_password(None).unwrap();
url.set_username("nobody").unwrap();
let target = display_name(&url);
let result = tokio::task::spawn_blocking(move || open_session(&url, None))
.await
.unwrap();
let error = format!("{:#}", result.err().expect("no key can authenticate here"));
assert!(error.contains(&target), "should name the target: {error}");
}
struct IsolatedHome {
_dir: tempfile::TempDir,
home: Option<std::ffi::OsString>,
agent: Option<std::ffi::OsString>,
}
impl IsolatedHome {
fn set() -> Self {
let dir = tempfile::tempdir().unwrap();
let home = std::env::var_os("HOME");
let agent = std::env::var_os("SSH_AUTH_SOCK");
unsafe {
std::env::set_var("HOME", dir.path());
std::env::remove_var("SSH_AUTH_SOCK");
}
Self { _dir: dir, home, agent }
}
}
impl Drop for IsolatedHome {
fn drop(&mut self) {
unsafe {
match self.home.take() {
Some(value) => std::env::set_var("HOME", value),
None => std::env::remove_var("HOME"),
}
match self.agent.take() {
Some(value) => std::env::set_var("SSH_AUTH_SOCK", value),
None => std::env::remove_var("SSH_AUTH_SOCK"),
}
}
}
}
#[test]
#[serial_test::serial]
fn the_agent_reports_when_it_has_nothing_for_this_user() {
let _home = IsolatedHome::set();
let session = Session::new().unwrap();
let result = try_agent_auth(&session, "definitely-not-a-real-user");
assert!(result.is_err(), "an unconnected session cannot authenticate");
}
#[test]
#[serial_test::serial]
fn the_default_key_files_are_reported_when_none_authenticates() {
let _home = IsolatedHome::set();
let session = Session::new().unwrap();
let error = format!(
"{:#}",
try_key_auth(&session, "definitely-not-a-real-user").unwrap_err()
);
assert!(error.contains(".ssh"), "should name where it looked: {error}");
}
#[test]
#[serial_test::serial]
fn the_ssh_config_is_reported_when_it_offers_nothing() {
let _home = IsolatedHome::set();
let session = Session::new().unwrap();
let error = format!(
"{:#}",
try_config_key_auth(&session, "definitely-not-a-real-user", "definitely-not-a-real-host").unwrap_err()
);
assert!(
error.contains(".ssh/config") || error.contains("no suitable SSH key"),
"{error}"
);
}
}
}