use std::cell::OnceCell;
use std::io::Write;
use std::path::Path;
use std::sync::Arc;
use anyhow::{Context, Result, bail};
use russh::client::{self, Handle, Msg};
use russh::keys::{PrivateKeyWithHashAlg, PublicKeyOrCertificate, load_secret_key};
use russh::{Channel, ChannelMsg};
use russh_sftp::client::SftpSession;
use russh_sftp::protocol::OpenFlags;
use tokio::runtime::Runtime;
use crate::agent;
use crate::model::{Auth, Credential, Server};
use crate::secrets;
struct AcceptAnyHostKey;
impl client::Handler for AcceptAnyHostKey {
type Error = anyhow::Error;
async fn check_server_key(&mut self, _key: &PublicKeyOrCertificate) -> Result<bool, Self::Error> {
Ok(true)
}
}
pub struct Session {
sftp: OnceCell<SftpSession>,
runtime: Runtime,
handle: Handle<AcceptAnyHostKey>,
}
impl Session {
pub fn connect(server: &Server, credential: &Credential) -> Result<Self> {
let material = auth_material(credential)?;
Self::open(&server.ssh_host(), server.port, &credential.user, material)
}
pub fn open_with_key(host: &str, port: u16, user: &str, key_path: &str, passphrase: Option<&str>) -> Result<Self> {
Self::open(host, port, user, key_material(key_path, passphrase)?)
}
fn open(host: &str, port: u16, user: &str, material: AuthMaterial) -> Result<Self> {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.context("cannot start the SSH runtime")?;
let handle = runtime.block_on(authenticate(host, port, user, material))?;
Ok(Self {
sftp: OnceCell::new(),
runtime,
handle,
})
}
fn sftp(&self) -> Result<&SftpSession> {
if self.sftp.get().is_none() {
let opened = self.runtime.block_on(open_sftp(&self.handle))?;
let _ = self.sftp.set(opened);
}
Ok(self.sftp.get().expect("the SFTP cell was just filled"))
}
pub fn exec(&self, command: &str) -> Result<String> {
self.runtime.block_on(exec(&self.handle, command))
}
pub fn run(&self, command: &str) -> Result<u32> {
let mut stdout = std::io::stdout();
let mut stderr = std::io::stderr();
self.runtime.block_on(run(&self.handle, command, &mut stdout, &mut stderr))
}
pub fn upload(&self, local: &Path, remote: &str, on_chunk: impl FnMut(u64)) -> Result<u64> {
let sftp = self.sftp()?;
self.runtime.block_on(upload(sftp, local, remote, on_chunk))
}
pub fn upload_bytes(&self, bytes: &[u8], remote: &str, on_chunk: impl FnMut(u64)) -> Result<u64> {
let sftp = self.sftp()?;
self.runtime.block_on(upload_bytes(sftp, bytes, remote, on_chunk))
}
pub fn mkdir(&self, remote: &str) -> Result<()> {
let sftp = self.sftp()?;
self.runtime.block_on(async {
if let Err(err) = sftp.create_dir(remote).await {
match sftp.metadata(remote).await {
Ok(existing) if existing.is_dir() => {}
_ => return Err(err).with_context(|| format!("cannot create the directory {remote} on the server")),
}
}
Ok(())
})
}
}
enum AuthMaterial {
Key(PrivateKeyWithHashAlg),
Password(String),
Agent,
}
impl std::fmt::Debug for AuthMaterial {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
AuthMaterial::Key(_) => "AuthMaterial::Key",
AuthMaterial::Password(_) => "AuthMaterial::Password(<redacted>)",
AuthMaterial::Agent => "AuthMaterial::Agent",
})
}
}
fn auth_material(credential: &Credential) -> Result<AuthMaterial> {
match credential.auth {
Auth::Key => {
let path = credential.key.as_deref().ok_or_else(|| {
anyhow::anyhow!(
"credential '{0}' authenticates by key but has no key file - set one with `turnout credential edit {0} --key PATH`",
credential.name
)
})?;
let passphrase = secrets::get(&credential.name).ok();
key_material(path, passphrase.as_deref())
}
Auth::Password => {
let password =
secrets::get(&credential.name).map_err(|_| anyhow::anyhow!("no password stored - save one with `turnout pass set {}`", credential.name))?;
Ok(AuthMaterial::Password(password))
}
Auth::Agent => Ok(AuthMaterial::Agent),
}
}
fn key_material(path: &str, passphrase: Option<&str>) -> Result<AuthMaterial> {
let key = load_secret_key(path, passphrase).with_context(|| format!("cannot read the key file {path}"))?;
Ok(AuthMaterial::Key(PrivateKeyWithHashAlg::new(Arc::new(key), None)))
}
async fn authenticate(host: &str, port: u16, user: &str, material: AuthMaterial) -> Result<Handle<AcceptAnyHostKey>> {
let config = Arc::new(client::Config::default());
let mut handle = client::connect(config, (host, port), AcceptAnyHostKey)
.await
.with_context(|| format!("cannot reach {host}:{port}"))?;
let ok = match material {
AuthMaterial::Key(key) => handle.authenticate_publickey(user, key).await.context("key authentication failed")?,
AuthMaterial::Password(password) => handle.authenticate_password(user, password).await.context("password authentication failed")?,
AuthMaterial::Agent => return authenticate_with_agent(handle, user).await,
};
if !ok.success() {
bail!("SSH authentication failed for '{user}' - the server rejected the credential");
}
Ok(handle)
}
async fn authenticate_with_agent(mut handle: Handle<AcceptAnyHostKey>, user: &str) -> Result<Handle<AcceptAnyHostKey>> {
let mut agent = agent::connect().await?;
let identities = agent::identities(&mut agent).await?;
if identities.is_empty() {
return Err(agent::no_identities());
}
let mut offered = Vec::new();
for identity in &identities {
let public = identity.public_key().into_owned();
offered.push(agent::describe(identity));
if let Ok(result) = handle.authenticate_publickey_with(user, public, None, &mut agent).await
&& result.success()
{
return Ok(handle);
}
}
bail!(
"SSH authentication failed for '{user}' - the agent offered {} key{}, none accepted by the server: {}",
offered.len(),
if offered.len() == 1 { "" } else { "s" },
offered.join(", ")
);
}
async fn exec(handle: &Handle<AcceptAnyHostKey>, command: &str) -> Result<String> {
let mut channel: Channel<Msg> = handle
.channel_open_session()
.await
.with_context(|| format!("cannot open a channel for '{command}'"))?;
channel
.exec(true, command)
.await
.with_context(|| format!("cannot run remote command '{command}'"))?;
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut code = None;
while let Some(msg) = channel.wait().await {
match msg {
ChannelMsg::Data { data } => stdout.extend_from_slice(&data),
ChannelMsg::ExtendedData { data, ext: 1 } => stderr.extend_from_slice(&data),
ChannelMsg::ExitStatus { exit_status } => code = Some(exit_status),
_ => {}
}
}
let Some(code) = code else {
let stderr = String::from_utf8_lossy(&stderr);
let detail = if stderr.trim().is_empty() {
String::new()
} else {
format!(": {}", stderr.trim())
};
bail!("remote command '{command}' ended without an exit status - the connection likely dropped{detail}");
};
if code != 0 {
bail!("remote command '{command}' exited with {code}: {}", String::from_utf8_lossy(&stderr).trim());
}
Ok(String::from_utf8_lossy(&stdout).into_owned())
}
async fn run(handle: &Handle<AcceptAnyHostKey>, command: &str, stdout: &mut impl Write, stderr: &mut impl Write) -> Result<u32> {
let mut channel: Channel<Msg> = handle
.channel_open_session()
.await
.with_context(|| format!("cannot open a channel for '{command}'"))?;
channel
.exec(true, command)
.await
.with_context(|| format!("cannot run remote command '{command}'"))?;
let mut code = None;
while let Some(msg) = channel.wait().await {
match msg {
ChannelMsg::Data { data } => {
stdout.write_all(&data).context("cannot write the remote output")?;
stdout.flush().ok();
}
ChannelMsg::ExtendedData { data, ext: 1 } => {
stderr.write_all(&data).context("cannot write the remote output")?;
stderr.flush().ok();
}
ChannelMsg::ExitStatus { exit_status } => code = Some(exit_status),
_ => {}
}
}
code.ok_or_else(|| anyhow::anyhow!("remote command '{command}' ended without an exit status - the connection likely dropped"))
}
async fn open_sftp(handle: &Handle<AcceptAnyHostKey>) -> Result<SftpSession> {
let channel = handle.channel_open_session().await.context("cannot open an SFTP channel")?;
channel.request_subsystem(true, "sftp").await.context("cannot start the SFTP subsystem")?;
SftpSession::new(channel.into_stream()).await.context("cannot open SFTP")
}
const CHUNK: usize = 64 * 1024;
async fn upload(sftp: &SftpSession, local: &Path, remote: &str, on_chunk: impl FnMut(u64)) -> Result<u64> {
use tokio::io::AsyncReadExt;
let mut file = tokio::fs::File::open(local).await.with_context(|| format!("cannot open {}", local.display()))?;
let mut remote_file = open_remote(sftp, remote).await?;
let mut buffer = vec![0u8; CHUNK];
let mut on_chunk = on_chunk;
let mut total = 0u64;
loop {
let read = file.read(&mut buffer).await.with_context(|| format!("cannot read {}", local.display()))?;
if read == 0 {
break;
}
write_chunk(&mut remote_file, &buffer[..read], remote).await?;
on_chunk(read as u64);
total += read as u64;
}
finish(&mut remote_file, remote).await?;
Ok(total)
}
async fn upload_bytes(sftp: &SftpSession, bytes: &[u8], remote: &str, mut on_chunk: impl FnMut(u64)) -> Result<u64> {
let mut remote_file = open_remote(sftp, remote).await?;
for chunk in bytes.chunks(CHUNK) {
write_chunk(&mut remote_file, chunk, remote).await?;
on_chunk(chunk.len() as u64);
}
finish(&mut remote_file, remote).await?;
Ok(bytes.len() as u64)
}
async fn open_remote(sftp: &SftpSession, remote: &str) -> Result<russh_sftp::client::fs::File> {
sftp.open_with_flags(remote, OpenFlags::CREATE | OpenFlags::TRUNCATE | OpenFlags::WRITE)
.await
.with_context(|| format!("cannot create {remote} on the server"))
}
async fn write_chunk(file: &mut russh_sftp::client::fs::File, chunk: &[u8], remote: &str) -> Result<()> {
use tokio::io::AsyncWriteExt;
file.write_all(chunk).await.with_context(|| format!("cannot upload to {remote}"))
}
async fn finish(file: &mut russh_sftp::client::fs::File, remote: &str) -> Result<()> {
use tokio::io::AsyncWriteExt;
file.flush().await.with_context(|| format!("cannot finish uploading {remote}"))
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use std::io::{Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, mpsc};
use russh::keys::ssh_key::private::{Ed25519Keypair, KeypairData};
use russh::server::{self, Auth as AuthAnswer, ChannelOpenHandle, Msg as ServerMsg, Session as ServerSession};
use russh::{Channel as ServerChannel, ChannelId};
use russh_sftp::protocol::{Attrs, FileAttributes, Handle as SftpHandle, OpenFlags, Status, StatusCode, Version};
use super::*;
const USER: &str = "deploy";
const PASSWORD: &str = "sesame";
fn ed25519_key(seed: u8, comment: &str) -> russh::keys::PrivateKey {
let pair = Ed25519Keypair::from_seed(&[seed; 32]);
russh::keys::PrivateKey::new(KeypairData::Ed25519(pair), comment).expect("an ed25519 key from a fixed seed")
}
struct Stand {
port: u16,
root: tempfile::TempDir,
subsystem_opens: Arc<AtomicUsize>,
}
impl Stand {
fn spawn() -> Self {
Self::spawn_with(true)
}
fn spawn_key_only() -> Self {
Self::spawn_with(false)
}
#[cfg(unix)]
fn spawn_authorizing(authorized: russh::keys::PublicKey) -> Self {
Self::spawn_full(false, Some(authorized))
}
fn spawn_with(passwords_accepted: bool) -> Self {
Self::spawn_full(passwords_accepted, None)
}
fn spawn_full(passwords_accepted: bool, authorized: Option<russh::keys::PublicKey>) -> Self {
let root = tempfile::tempdir().expect("a scratch directory for the SFTP root");
let subsystem_opens = Arc::new(AtomicUsize::new(0));
let served_root = root.path().to_path_buf();
let served_opens = subsystem_opens.clone();
let (report, learn) = mpsc::channel();
std::thread::spawn(move || {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("the test server runtime");
runtime.block_on(async move {
let config = Arc::new(server::Config {
auth_rejection_time: std::time::Duration::ZERO,
auth_rejection_time_initial: Some(std::time::Duration::ZERO),
keys: vec![ed25519_key(7, "test host key")],
..Default::default()
});
let socket = tokio::net::TcpListener::bind(("127.0.0.1", 0)).await.expect("bind a loopback port");
report.send(socket.local_addr().expect("the bound address").port()).expect("report the port");
let mut factory = Factory {
root: served_root,
subsystem_opens: served_opens,
passwords_accepted,
authorized,
};
let _ = server::Server::run_on_socket(&mut factory, config, &socket).await;
});
});
let port = learn.recv().expect("the server reports its port");
Self { port, root, subsystem_opens }
}
fn session(&self) -> Session {
Session::open("127.0.0.1", self.port, USER, AuthMaterial::Password(PASSWORD.into())).expect("password sign-in on loopback")
}
}
struct Factory {
root: PathBuf,
subsystem_opens: Arc<AtomicUsize>,
passwords_accepted: bool,
authorized: Option<russh::keys::PublicKey>,
}
impl server::Server for Factory {
type Handler = TestHandler;
fn new_client(&mut self, _peer: Option<std::net::SocketAddr>) -> TestHandler {
TestHandler {
root: self.root.clone(),
subsystem_opens: self.subsystem_opens.clone(),
channels: HashMap::new(),
passwords_accepted: self.passwords_accepted,
authorized: self.authorized.clone(),
}
}
}
struct TestHandler {
root: PathBuf,
subsystem_opens: Arc<AtomicUsize>,
channels: HashMap<ChannelId, ServerChannel<ServerMsg>>,
passwords_accepted: bool,
authorized: Option<russh::keys::PublicKey>,
}
fn rejected() -> AuthAnswer {
AuthAnswer::Reject {
proceed_with_methods: None,
partial_success: false,
}
}
impl server::Handler for TestHandler {
type Error = anyhow::Error;
async fn auth_password(&mut self, user: &str, password: &str) -> Result<AuthAnswer, Self::Error> {
Ok(if self.passwords_accepted && user == USER && password == PASSWORD {
AuthAnswer::Accept
} else {
rejected()
})
}
async fn auth_publickey(&mut self, user: &str, key: &russh::keys::PublicKey) -> Result<AuthAnswer, Self::Error> {
let key_allowed = match &self.authorized {
Some(authorized) => authorized.key_data() == key.key_data(),
None => true,
};
Ok(if user == USER && key_allowed { AuthAnswer::Accept } else { rejected() })
}
async fn channel_open_session(
&mut self,
channel: ServerChannel<ServerMsg>,
reply: ChannelOpenHandle,
_session: &mut ServerSession,
) -> Result<(), Self::Error> {
self.channels.insert(channel.id(), channel);
reply.accept().await;
Ok(())
}
async fn exec_request(&mut self, channel: ChannelId, data: &[u8], session: &mut ServerSession) -> Result<(), Self::Error> {
let command = String::from_utf8_lossy(data).into_owned();
session.channel_success(channel)?;
if let Some(text) = command.strip_prefix("echo ") {
session.data(channel, format!("{text}\n").into_bytes())?;
session.exit_status_request(channel, 0)?;
} else if command == "fail" {
session.extended_data(channel, 1, &b"boom"[..])?;
session.exit_status_request(channel, 3)?;
} else if command != "vanish" {
session.exit_status_request(channel, 0)?;
}
session.eof(channel)?;
session.close(channel)?;
Ok(())
}
async fn subsystem_request(&mut self, channel_id: ChannelId, name: &str, session: &mut ServerSession) -> Result<(), Self::Error> {
if name != "sftp" {
session.channel_failure(channel_id)?;
return Ok(());
}
self.subsystem_opens.fetch_add(1, Ordering::SeqCst);
let channel = self.channels.remove(&channel_id).expect("the subsystem channel was opened first");
session.channel_success(channel_id)?;
let handler = TestSftp {
root: self.root.clone(),
files: HashMap::new(),
};
tokio::spawn(async move {
russh_sftp::server::run(channel.into_stream(), handler).await;
});
Ok(())
}
}
struct TestSftp {
root: PathBuf,
files: HashMap<String, std::fs::File>,
}
fn resolve(root: &Path, remote: &str) -> PathBuf {
root.join(remote.trim_start_matches('/'))
}
fn done(id: u32) -> Status {
Status {
id,
status_code: StatusCode::Ok,
error_message: String::new(),
language_tag: "en-US".into(),
}
}
impl russh_sftp::server::Handler for TestSftp {
type Error = StatusCode;
fn unimplemented(&self) -> Self::Error {
StatusCode::OpUnsupported
}
async fn init(&mut self, _version: u32, _extensions: HashMap<String, String>) -> Result<Version, Self::Error> {
Ok(Version::new())
}
async fn open(&mut self, id: u32, filename: String, _pflags: OpenFlags, _attrs: FileAttributes) -> Result<SftpHandle, Self::Error> {
let file = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(resolve(&self.root, &filename))
.map_err(|_| StatusCode::Failure)?;
self.files.insert(filename.clone(), file);
Ok(SftpHandle { id, handle: filename })
}
async fn write(&mut self, id: u32, handle: String, offset: u64, data: Vec<u8>) -> Result<Status, Self::Error> {
let file = self.files.get_mut(&handle).ok_or(StatusCode::Failure)?;
file.seek(SeekFrom::Start(offset))
.and_then(|_| file.write_all(&data))
.map_err(|_| StatusCode::Failure)?;
Ok(done(id))
}
async fn close(&mut self, id: u32, handle: String) -> Result<Status, Self::Error> {
self.files.remove(&handle);
Ok(done(id))
}
async fn mkdir(&mut self, id: u32, path: String, _attrs: FileAttributes) -> Result<Status, Self::Error> {
std::fs::create_dir(resolve(&self.root, &path)).map_err(|_| StatusCode::Failure)?;
Ok(done(id))
}
async fn stat(&mut self, id: u32, path: String) -> Result<Attrs, Self::Error> {
let metadata = std::fs::metadata(resolve(&self.root, &path)).map_err(|_| StatusCode::NoSuchFile)?;
Ok(Attrs {
id,
attrs: FileAttributes::from(&metadata),
})
}
}
#[test]
fn exec_returns_stdout_on_a_zero_exit() {
let stand = Stand::spawn();
let session = stand.session();
assert_eq!(session.exec("echo ready").expect("echo succeeds"), "ready\n");
}
#[test]
fn run_streams_both_channels_and_returns_the_code() {
let stand = Stand::spawn();
let session = Session::open("127.0.0.1", stand.port, USER, AuthMaterial::Password(PASSWORD.into())).expect("a session");
let (mut out, mut err) = (Vec::new(), Vec::new());
let code = session
.runtime
.block_on(run(&session.handle, "echo hi there", &mut out, &mut err))
.expect("echo runs");
assert_eq!((code, out.as_slice(), err.as_slice()), (0, &b"hi there\n"[..], &b""[..]));
let (mut out, mut err) = (Vec::new(), Vec::new());
let code = session
.runtime
.block_on(run(&session.handle, "fail", &mut out, &mut err))
.expect("a failing command still returns");
assert_eq!((code, out.as_slice(), err.as_slice()), (3, &b""[..], &b"boom"[..]));
let dropped = session
.runtime
.block_on(run(&session.handle, "vanish", &mut Vec::new(), &mut Vec::new()))
.unwrap_err()
.to_string();
assert!(dropped.contains("without an exit status"), "{dropped}");
}
#[test]
fn a_nonzero_exit_becomes_an_error_carrying_stderr() {
let stand = Stand::spawn();
let session = stand.session();
let error = session.exec("fail").expect_err("exit 3 is a failure").to_string();
assert!(error.contains("exited with 3"), "{error}");
assert!(error.contains("boom"), "{error}");
}
#[test]
fn a_channel_without_an_exit_status_is_a_dropped_connection_not_a_success() {
let stand = Stand::spawn();
let session = stand.session();
let error = session.exec("vanish").expect_err("no exit status must not read as success").to_string();
assert!(error.contains("without an exit status"), "{error}");
assert!(error.contains("connection likely dropped"), "{error}");
}
#[test]
fn a_wrong_password_reports_the_rejection() {
let stand = Stand::spawn();
let error = match Session::open("127.0.0.1", stand.port, USER, AuthMaterial::Password("wrong".into())) {
Ok(_) => panic!("the server must reject a wrong password"),
Err(error) => error.to_string(),
};
assert!(error.contains("rejected the credential"), "{error}");
}
#[test]
fn an_unreachable_server_names_the_address() {
let error = match Session::open("127.0.0.1", 1, USER, AuthMaterial::Password(PASSWORD.into())) {
Ok(_) => panic!("nothing listens on port 1"),
Err(error) => error.to_string(),
};
assert!(error.contains("cannot reach 127.0.0.1:1"), "{error}");
}
#[test]
fn a_key_file_signs_in() {
let stand = Stand::spawn();
let scratch = tempfile::tempdir().expect("a scratch directory for the key");
let key_path = scratch.path().join("id_ed25519");
let openssh = ed25519_key(42, "test client key")
.to_openssh(russh::keys::ssh_key::LineEnding::LF)
.expect("serialize the key");
std::fs::write(&key_path, openssh.as_bytes()).expect("write the key file");
let material = key_material(&key_path.display().to_string(), None).expect("load the key file");
Session::open("127.0.0.1", stand.port, USER, material).expect("key sign-in on loopback");
}
#[cfg(unix)]
struct AgentStand {
socket: PathBuf,
_dir: tempfile::TempDir,
}
#[cfg(unix)]
impl AgentStand {
fn spawn(keys: Vec<russh::keys::PrivateKey>) -> Self {
use tokio_stream::wrappers::UnixListenerStream;
let dir = tempfile::tempdir().expect("a scratch directory for the agent socket");
let socket = dir.path().join("agent.sock");
let served = socket.clone();
let (ready, wait) = mpsc::channel();
std::thread::spawn(move || {
let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build().expect("the agent runtime");
runtime.block_on(async move {
let listener = tokio::net::UnixListener::bind(&served).expect("bind the agent socket");
ready.send(()).expect("report that the agent is listening");
let _ = russh::keys::agent::server::serve(UnixListenerStream::new(listener), ()).await;
});
});
wait.recv().expect("the agent starts listening");
let stand = Self { socket, _dir: dir };
let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build().expect("a runtime");
runtime.block_on(async {
let mut client = russh::keys::agent::client::AgentClient::connect_uds(&stand.socket)
.await
.expect("connect to the test agent");
for key in &keys {
client.add_identity(key, &[]).await.expect("add the key to the agent");
}
});
stand
}
fn with_env<T>(&self, body: impl FnOnce() -> T) -> T {
let _guard = serial_env();
let previous = std::env::var("SSH_AUTH_SOCK").ok();
unsafe { std::env::set_var("SSH_AUTH_SOCK", &self.socket) };
let outcome = body();
match previous {
Some(value) => unsafe { std::env::set_var("SSH_AUTH_SOCK", value) },
None => unsafe { std::env::remove_var("SSH_AUTH_SOCK") },
}
outcome
}
}
#[cfg(unix)]
fn serial_env() -> std::sync::MutexGuard<'static, ()> {
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
LOCK.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
}
#[cfg(unix)]
#[test]
fn an_agent_key_signs_in_where_the_password_cannot() {
let key = ed25519_key(21, "agent key");
let stand = Stand::spawn_authorizing(key.public_key().clone());
let agent = AgentStand::spawn(vec![key]);
let refused = Session::open("127.0.0.1", stand.port, USER, AuthMaterial::Password(PASSWORD.into()));
assert!(refused.is_err(), "this stand must not accept passwords");
agent.with_env(|| {
Session::open("127.0.0.1", stand.port, USER, AuthMaterial::Agent).expect("the agent key signs in");
});
}
#[cfg(unix)]
#[test]
fn the_accepted_key_is_found_behind_keys_the_server_refuses() {
let accepted = ed25519_key(23, "the one that works");
let stand = Stand::spawn_authorizing(accepted.public_key().clone());
let agent = AgentStand::spawn(vec![ed25519_key(24, "wrong one"), ed25519_key(25, "also wrong"), accepted]);
agent.with_env(|| {
Session::open("127.0.0.1", stand.port, USER, AuthMaterial::Agent).expect("the accepted key is reached");
});
}
#[cfg(unix)]
#[test]
fn keys_the_server_refuses_are_named_in_the_failure() {
let stand = Stand::spawn_authorizing(ed25519_key(26, "never offered").public_key().clone());
let agent = AgentStand::spawn(vec![ed25519_key(27, "mine@laptop")]);
let error = agent.with_env(|| match Session::open("127.0.0.1", stand.port, USER, AuthMaterial::Agent) {
Ok(_) => panic!("the server authorizes no key this agent holds"),
Err(error) => format!("{error:#}"),
});
assert!(error.contains("the agent offered 1 key"), "{error}");
assert!(error.contains("none accepted by the server"), "{error}");
assert!(error.contains("ssh-ed25519"), "the offered key is described: {error}");
assert!(!error.contains("cannot reach"), "the agent was reached: {error}");
}
#[cfg(unix)]
#[test]
fn an_agent_holding_nothing_says_so() {
let stand = Stand::spawn_key_only();
let agent = AgentStand::spawn(Vec::new());
let error = agent.with_env(|| match Session::open("127.0.0.1", stand.port, USER, AuthMaterial::Agent) {
Ok(_) => panic!("an empty agent cannot sign in"),
Err(error) => format!("{error:#}"),
});
assert!(error.contains("holds no keys"), "{error}");
assert!(error.contains("ssh-add"), "{error}");
}
#[cfg(unix)]
#[test]
fn no_agent_at_all_names_how_to_start_one() {
let _guard = serial_env();
let previous = std::env::var("SSH_AUTH_SOCK").ok();
unsafe { std::env::remove_var("SSH_AUTH_SOCK") };
let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build().expect("a runtime");
let outcome = runtime.block_on(async { super::agent::connect().await.map(|_| ()) });
if let Some(value) = previous {
unsafe { std::env::set_var("SSH_AUTH_SOCK", value) };
}
let error = match outcome {
Ok(()) => panic!("there is no agent to connect to"),
Err(error) => format!("{error:#}"),
};
assert!(error.contains("SSH_AUTH_SOCK"), "{error}");
assert!(error.contains("ssh-agent"), "{error}");
}
#[test]
fn an_agent_credential_needs_neither_key_file_nor_secret() {
let credential = Credential {
name: "by-agent".into(),
user: USER.into(),
auth: Auth::Agent,
key: None,
};
let material = auth_material(&credential).expect("an agent credential resolves with nothing stored");
assert!(matches!(material, AuthMaterial::Agent), "{material:?}");
}
#[test]
fn a_named_key_file_signs_in_where_the_password_cannot() {
let stand = Stand::spawn_key_only();
let scratch = tempfile::tempdir().expect("a scratch directory for the key");
let key_path = scratch.path().join("id_ed25519");
let openssh = ed25519_key(11, "key-only client")
.to_openssh(russh::keys::ssh_key::LineEnding::LF)
.expect("serialize the key");
std::fs::write(&key_path, openssh.as_bytes()).expect("write the key file");
let refused = Session::open("127.0.0.1", stand.port, USER, AuthMaterial::Password(PASSWORD.into()));
assert!(refused.is_err(), "this stand must not accept passwords");
Session::open_with_key("127.0.0.1", stand.port, USER, &key_path.display().to_string(), None).expect("the key signs in");
}
#[test]
fn a_named_key_that_is_not_there_fails_before_dialing() {
let error = match Session::open_with_key("127.0.0.1", 1, USER, "definitely/not/a/key", None) {
Ok(_) => panic!("there is no such key"),
Err(error) => error.to_string(),
};
assert!(error.contains("cannot read the key file"), "{error}");
assert!(!error.contains("cannot reach"), "the key is resolved before the connection: {error}");
}
#[test]
fn a_key_credential_without_a_key_file_names_the_fix() {
let credential = Credential {
name: "deployer".into(),
user: USER.into(),
auth: Auth::Key,
key: None,
};
let error = auth_material(&credential).expect_err("no key file to load").to_string();
assert!(error.contains("has no key file"), "{error}");
assert!(error.contains("turnout credential edit deployer"), "{error}");
}
#[test]
fn an_unreadable_key_file_is_reported_with_its_path() {
let error = key_material("definitely/not/a/key", None).expect_err("the file does not exist").to_string();
assert!(error.contains("cannot read the key file definitely/not/a/key"), "{error}");
}
#[test]
fn mkdir_tolerates_an_existing_directory_but_not_a_refusal() {
let stand = Stand::spawn();
let session = stand.session();
session.mkdir("/dist").expect("create a fresh directory");
session.mkdir("/dist").expect("an existing directory is not an error");
assert!(stand.root.path().join("dist").is_dir());
let error = session.mkdir("/no/parent/here").expect_err("a missing parent is a refusal").to_string();
assert!(error.contains("cannot create the directory /no/parent/here"), "{error}");
std::fs::write(stand.root.path().join("taken"), b"x").expect("plant a file in the way");
let error = session.mkdir("/taken").expect_err("a file in the way is a refusal").to_string();
assert!(error.contains("cannot create the directory /taken"), "{error}");
}
#[test]
fn upload_streams_a_file_in_bounded_chunks_and_credits_every_byte() {
let stand = Stand::spawn();
let session = stand.session();
let payload: Vec<u8> = (0..200_000u32).map(|i| (i % 251) as u8).collect();
let scratch = tempfile::tempdir().expect("a scratch directory for the payload");
let local = scratch.path().join("payload.bin");
std::fs::write(&local, &payload).expect("write the payload");
let mut chunks = Vec::new();
let total = session.upload(&local, "/payload.bin", |sent| chunks.push(sent)).expect("upload");
assert_eq!(total, payload.len() as u64);
assert_eq!(chunks.iter().sum::<u64>(), payload.len() as u64, "every byte is credited exactly once");
assert!(chunks.len() >= 4, "200 KB must cross the wire in several bounded pieces, got {}", chunks.len());
assert!(chunks.iter().all(|&sent| sent <= CHUNK as u64), "no chunk exceeds the bound");
assert_eq!(std::fs::read(stand.root.path().join("payload.bin")).expect("read what arrived"), payload);
}
#[test]
fn upload_bytes_arrives_intact_over_one_shared_sftp_subsystem() {
let stand = Stand::spawn();
let session = stand.session();
session.upload_bytes(b"alpha", "/a.txt", |_| {}).expect("first upload");
session.upload_bytes(b"beta", "/b.txt", |_| {}).expect("second upload");
assert_eq!(std::fs::read(stand.root.path().join("a.txt")).expect("a.txt arrived"), b"alpha");
assert_eq!(std::fs::read(stand.root.path().join("b.txt")).expect("b.txt arrived"), b"beta");
assert_eq!(stand.subsystem_opens.load(Ordering::SeqCst), 1, "the SFTP subsystem is opened once and shared");
}
}