#![cfg(all(feature = "ssh", unix))]
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::sync::Arc;
use puressh::auth::{AuthAttempt, AuthDecision, Authenticator};
use puressh::hostkey::{Ed25519HostKey, HostKey};
use puressh::server::{CommandHandler, Config, ExecResult, Server, SessionEnv, SubsystemHandler};
use puressh::sftp::{SftpServerOptions, SftpServerSession};
use puressh::stream::ChannelStream;
const USER: &str = "alice";
const PASS: &str = "s3cret";
const BODY: &[u8] = b"hello over sftp\n";
struct PasswordAuth;
impl Authenticator for PasswordAuth {
fn evaluate(&mut self, attempt: AuthAttempt) -> AuthDecision {
match attempt {
AuthAttempt::Password { user, password } if user == USER && password == PASS => {
AuthDecision::Accept
}
_ => AuthDecision::Reject,
}
}
}
struct NoExec;
impl CommandHandler for NoExec {
fn handle(&self, _user: &str, _env: &SessionEnv, _command: &str) -> ExecResult {
ExecResult::new(Vec::new(), b"exec not supported\n".to_vec(), 1)
}
}
struct Sftp {
root: PathBuf,
}
impl SubsystemHandler for Sftp {
fn handle(
&self,
_user: &str,
_env: &SessionEnv,
name: &str,
stream: ChannelStream,
) -> puressh::Result<()> {
if name == "sftp" {
let opts = SftpServerOptions::new(self.root.clone());
let _ = SftpServerSession::new(opts).run(stream);
}
Ok(())
}
}
struct TempDir(PathBuf);
impl TempDir {
fn new(tag: &str) -> Self {
let dir = std::env::temp_dir().join(format!(
"rsurl-ssh-{tag}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
TempDir(dir)
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
struct Fixture {
port: u16,
blob: Vec<u8>,
home: TempDir,
file: String,
_root: TempDir,
}
impl Fixture {
fn start(tag: &str) -> Self {
let root = TempDir::new(&format!("{tag}-root"));
std::fs::write(root.0.join("hello.txt"), BODY).unwrap();
let file = std::fs::canonicalize(root.0.join("hello.txt"))
.unwrap()
.to_str()
.unwrap()
.to_string();
let home = TempDir::new(&format!("{tag}-home"));
std::fs::create_dir_all(home.0.join(".ssh")).unwrap();
let mut seed = [0u8; 32];
for (i, b) in seed.iter_mut().enumerate() {
*b = (i as u8).wrapping_mul(7).wrapping_add(tag.len() as u8);
}
let hk = Ed25519HostKey::from_seed(seed);
let blob = hk.public_blob();
let mut cfg = Config::new(
vec![Box::new(hk)],
Arc::new(|| Box::new(PasswordAuth) as Box<dyn Authenticator>),
vec!["password"],
Arc::new(NoExec),
);
cfg.subsystem_handler = Some(Arc::new(Sftp {
root: root.0.clone(),
}));
let mut server = Server::bind("127.0.0.1:0", cfg).unwrap();
let port = server.local_addr().unwrap().port();
std::thread::spawn(move || {
let _ = server.serve();
});
Fixture {
port,
blob,
home,
file,
_root: root,
}
}
fn url(&self) -> String {
format!("sftp://{USER}:{PASS}@127.0.0.1:{}{}", self.port, self.file)
}
fn known_hosts(&self) -> PathBuf {
self.home.0.join(".ssh").join("known_hosts")
}
fn run(&self, extra: &[&str]) -> Output {
let mut cmd = Command::new(env!("CARGO_BIN_EXE_rsurl"));
for var in [
"http_proxy",
"https_proxy",
"HTTP_PROXY",
"HTTPS_PROXY",
"all_proxy",
"ALL_PROXY",
] {
cmd.env_remove(var);
}
cmd.env("HOME", &self.home.0)
.env_remove("USERPROFILE")
.arg("-sS")
.args(extra)
.arg(self.url());
cmd.output().unwrap()
}
fn sha256_b64(&self) -> String {
use purecrypto::hash::{Digest, Sha256};
b64(Sha256::digest(&self.blob).as_ref())
}
fn md5_hex(&self) -> String {
purecrypto::hash::md5(&self.blob)
.iter()
.map(|b| format!("{b:02x}"))
.collect()
}
}
fn b64(data: &[u8]) -> String {
const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::new();
for chunk in data.chunks(3) {
let n = chunk.len();
let v = (chunk[0] as u32) << 16
| (*chunk.get(1).unwrap_or(&0) as u32) << 8
| *chunk.get(2).unwrap_or(&0) as u32;
for i in 0..4 {
if i <= n {
out.push(T[(v >> (18 - 6 * i) & 63) as usize] as char);
} else {
out.push('=');
}
}
}
out
}
fn stderr(o: &Output) -> String {
String::from_utf8_lossy(&o.stderr).into_owned()
}
fn assert_ok(o: &Output) {
assert_eq!(o.status.code(), Some(0), "stderr: {}", stderr(o));
assert_eq!(o.stdout, BODY);
}
fn assert_hostkey_fail(o: &Output) {
assert_eq!(o.status.code(), Some(60), "stderr: {}", stderr(o));
assert!(o.stdout.is_empty());
assert!(
stderr(o).contains("host key verification failed"),
"stderr: {}",
stderr(o)
);
}
fn exists(p: &Path) -> bool {
p.exists()
}
#[test]
fn unknown_host_is_rejected_with_exit_60() {
let f = Fixture::start("unknown");
let o = f.run(&[]);
assert_hostkey_fail(&o);
assert!(
stderr(&o).contains(&format!("[127.0.0.1]:{}", f.port)),
"stderr: {}",
stderr(&o)
);
assert!(
!exists(&f.known_hosts()),
"strict mode must not write known_hosts"
);
std::fs::write(
f.known_hosts(),
format!("[127.0.0.1]:1 ssh-ed25519 {}\n", b64(&f.blob)),
)
.unwrap();
assert_hostkey_fail(&f.run(&[]));
}
#[test]
fn known_host_is_accepted() {
let f = Fixture::start("known");
std::fs::write(
f.known_hosts(),
format!("[127.0.0.1]:{} ssh-ed25519 {}\n", f.port, b64(&f.blob)),
)
.unwrap();
assert_ok(&f.run(&[]));
}
#[test]
fn changed_host_key_is_rejected_even_with_accept_new() {
let f = Fixture::start("changed");
let other = Ed25519HostKey::from_seed([0x5a; 32]).public_blob();
let line = format!("[127.0.0.1]:{} ssh-ed25519 {}\n", f.port, b64(&other));
std::fs::write(f.known_hosts(), &line).unwrap();
assert_hostkey_fail(&f.run(&[]));
assert_hostkey_fail(&f.run(&["--ssh-accept-new"]));
assert_eq!(std::fs::read_to_string(f.known_hosts()).unwrap(), line);
}
#[test]
fn insecure_skips_check_and_does_not_write_known_hosts() {
let f = Fixture::start("insecure");
assert_ok(&f.run(&["-k"]));
assert!(!exists(&f.known_hosts()), "-k must not write known_hosts");
assert_hostkey_fail(&f.run(&[]));
}
#[test]
fn accept_new_trusts_on_first_use_and_saves() {
let f = Fixture::start("acceptnew");
assert_ok(&f.run(&["--ssh-accept-new"]));
let kh = std::fs::read_to_string(f.known_hosts()).unwrap();
assert!(
kh.contains(&format!("[127.0.0.1]:{} ssh-ed25519", f.port)),
"known_hosts: {kh}"
);
assert_ok(&f.run(&[]));
}
#[test]
fn sha256_pin_accepts_match_and_rejects_mismatch() {
let f = Fixture::start("sha256");
let pin = f.sha256_b64();
assert_ok(&f.run(&["--hostpubsha256", &pin]));
assert_ok(&f.run(&["--hostpubsha256", pin.trim_end_matches('=')]));
assert!(!exists(&f.known_hosts()));
let bad = b64(&[0u8; 32]);
assert_hostkey_fail(&f.run(&["--hostpubsha256", &bad]));
assert_hostkey_fail(&f.run(&["-k", "--hostpubsha256", &bad]));
std::fs::write(
f.known_hosts(),
format!("[127.0.0.1]:{} ssh-ed25519 {}\n", f.port, b64(&f.blob)),
)
.unwrap();
assert_hostkey_fail(&f.run(&["--hostpubsha256", &bad]));
}
#[test]
fn md5_pin_accepts_match_and_rejects_mismatch() {
let f = Fixture::start("md5");
assert_ok(&f.run(&["--hostpubmd5", &f.md5_hex().to_ascii_uppercase()]));
assert_hostkey_fail(&f.run(&["--hostpubmd5", &"0".repeat(32)]));
assert_hostkey_fail(&f.run(&[
"--hostpubsha256",
&f.sha256_b64(),
"--hostpubmd5",
&"0".repeat(32),
]));
assert_eq!(f.run(&["--hostpubmd5", "abc"]).status.code(), Some(2));
}