#![allow(dead_code)]
use core::sync::atomic::{AtomicU8, Ordering};
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use purecrypto::zeroize::Zeroizing;
use puressh::Error;
#[cfg(unix)]
use puressh::agent::{Agent, AgentHostKey};
use puressh::auth::ClientCredential;
use puressh::client::{HostKeyPolicy, KnownHostsPolicy, TofuAction};
use puressh::key::PrivateKey;
use puressh::known_hosts::KnownHosts;
#[cfg(unix)]
#[path = "unix_sys.rs"]
pub mod unix_sys;
pub use puressh::config::StrictMode;
pub fn sanitize_terminal_str(src: &str) -> String {
src.chars()
.map(|c| {
if (c as u32) < 0x20 || c == '\u{7f}' {
'?'
} else {
c
}
})
.collect()
}
pub fn resolve_user(cli_user: Option<&str>, user_in_host: Option<&str>) -> Result<String, String> {
if let Some(u) = cli_user {
return Ok(u.to_string());
}
if let Some(u) = user_in_host {
return Ok(u.to_string());
}
local_user_name().ok_or_else(|| "no user specified and the local user name is unknown".into())
}
pub fn local_user_name() -> Option<String> {
#[cfg(unix)]
if let Some((name, _)) = unix_sys::passwd_entry(unix_sys::getuid()) {
return Some(name);
}
std::env::var("USER")
.or_else(|_| std::env::var("LOGNAME"))
.or_else(|_| std::env::var("USERNAME"))
.ok()
.filter(|u| !u.is_empty())
}
pub fn parse_userhost(target: &str) -> (Option<String>, String) {
match target.split_once('@') {
Some((u, h)) => (Some(u.to_string()), h.to_string()),
None => (None, target.to_string()),
}
}
pub fn parse_target(target: &str) -> Result<(Option<String>, String, Option<u16>), String> {
if let Some(rest) = target.strip_prefix("ssh://") {
let rest = rest.strip_suffix('/').unwrap_or(rest);
if rest.contains('/') {
return Err(format!("{target}: a path is not allowed in an ssh:// URI"));
}
let (user, hostport) = match rest.rfind('@') {
Some(i) => {
let userinfo = rest[..i].split(';').next().unwrap_or("");
(Some(uri_decode(userinfo)), &rest[i + 1..])
}
None => (None, rest),
};
let (_, host, port) = parse_target(hostport)?;
return Ok((user.filter(|u| !u.is_empty()), host, port));
}
let (user, host_with_port) = parse_userhost(target);
if host_with_port.is_empty() {
return Err("empty host".into());
}
if let Some((inner, after)) = host_with_port
.strip_prefix('[')
.and_then(|r| r.split_once(']'))
&& !inner.is_empty()
&& inner.parse::<std::net::Ipv6Addr>().is_err()
{
let port = match after {
"" => None,
a => Some(
a.strip_prefix(':')
.and_then(|p| p.parse::<u16>().ok())
.ok_or_else(|| format!("bad port in {host_with_port:?}"))?,
),
};
return Ok((user, inner.to_string(), port));
}
let (host, port) =
puressh::config::parse_host_port(&host_with_port, 0).map_err(|e| format!("{e}"))?;
let port_opt = if port == 0 { None } else { Some(port) };
Ok((user, host, port_opt))
}
fn uri_decode(s: &str) -> String {
let b = s.as_bytes();
let mut out = Vec::with_capacity(b.len());
let mut i = 0;
while i < b.len() {
if b[i] == b'%'
&& let Some(v) = b
.get(i + 1..i + 3)
.and_then(|h| std::str::from_utf8(h).ok())
.and_then(|h| u8::from_str_radix(h, 16).ok())
{
out.push(v);
i += 3;
continue;
}
out.push(b[i]);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
pub fn parse_userhost_path(target: &str) -> Option<(Option<String>, String, String)> {
if target.starts_with('/') {
return None;
}
let (user, after_user) = parse_userhost(target);
let (host, path) = if let Some(rest) = after_user.strip_prefix('[') {
let close = rest.find(']')?;
let host = rest[..close].to_string();
let after_bracket = &rest[close + 1..];
let path = after_bracket.strip_prefix(':')?;
(host, path.to_string())
} else {
let (h, p) = after_user.split_once(':')?;
(h.to_string(), p.to_string())
};
if host.is_empty() {
return None;
}
Some((user, host, path))
}
struct PromptTty {
input: Box<dyn Read>,
output: Box<dyn Write>,
#[cfg(unix)]
fd: libc::c_int,
}
fn open_prompt_tty() -> Option<PromptTty> {
#[cfg(unix)]
{
use std::os::unix::io::AsRawFd;
let file = std::fs::OpenOptions::new()
.read(true)
.write(true)
.open("/dev/tty")
.ok()?;
let output = file.try_clone().ok()?;
let fd = file.as_raw_fd();
Some(PromptTty {
input: Box::new(file),
output: Box::new(output),
fd,
})
}
#[cfg(not(unix))]
{
use std::io::IsTerminal;
if !std::io::stdin().is_terminal() {
return None;
}
Some(PromptTty {
input: Box::new(std::io::stdin()),
output: Box::new(std::io::stderr()),
})
}
}
fn askpass_configured() -> bool {
let set = matches!(std::env::var_os("SSH_ASKPASS"), Some(v) if !v.is_empty());
let vetoed = matches!(std::env::var_os("SSH_ASKPASS_REQUIRE"), Some(req) if req == "never");
set && !vetoed
}
pub fn secret_prompt_available() -> bool {
askpass_configured() || open_prompt_tty().is_some()
}
pub fn read_password_from_stdin() -> std::io::Result<Zeroizing<String>> {
const PROMPT: &str = "password: ";
if let Some(out) = try_ssh_askpass(PROMPT)? {
return Ok(out);
}
let mut tty = open_prompt_tty().ok_or_else(no_tty_error)?;
tty.output.write_all(PROMPT.as_bytes())?;
tty.output.flush()?;
read_secret_line(&mut tty)
}
pub fn read_kbdint_response(prompt: &str, echo: bool) -> std::io::Result<Zeroizing<String>> {
let prompt = sanitize_terminal_str(prompt);
let Some(mut tty) = open_prompt_tty() else {
return try_ssh_askpass(&prompt)?.ok_or_else(no_tty_error);
};
tty.output.write_all(prompt.as_bytes())?;
tty.output.flush()?;
if echo {
let mut buf = String::new();
read_one_line(&mut tty.input, &mut buf, 4096)?;
Ok(Zeroizing::new(buf))
} else {
read_secret_line(&mut tty)
}
}
#[allow(dead_code)] pub fn read_secret(prompt: &str) -> std::io::Result<Zeroizing<String>> {
if let Some(mut tty) = open_prompt_tty() {
tty.output.write_all(prompt.as_bytes())?;
tty.output.flush()?;
return read_secret_line(&mut tty);
}
try_ssh_askpass(prompt)?.ok_or_else(no_tty_error)
}
fn no_tty_error() -> std::io::Error {
std::io::Error::new(
std::io::ErrorKind::Unsupported,
"no controlling terminal to prompt on and $SSH_ASKPASS is not set \
(refusing to read a secret from a redirected stdin)",
)
}
fn read_one_line(r: &mut dyn Read, buf: &mut String, max_len: usize) -> std::io::Result<()> {
let mut byte = [0u8; 1];
loop {
let n = r.read(&mut byte)?;
if n == 0 || byte[0] == b'\n' {
break;
}
if byte[0] == b'\r' {
continue;
}
buf.push(byte[0] as char);
if buf.len() > max_len {
break;
}
}
Ok(())
}
#[cfg(unix)]
fn read_secret_line(tty: &mut PromptTty) -> std::io::Result<Zeroizing<String>> {
let fd = tty.fd;
let mut term: libc::termios = unsafe { core::mem::zeroed() };
let got = unsafe { libc::tcgetattr(fd, &mut term as *mut _) } == 0;
let original = term;
struct EchoGuard {
fd: libc::c_int,
original: libc::termios,
}
impl Drop for EchoGuard {
fn drop(&mut self) {
unsafe { libc::tcsetattr(self.fd, libc::TCSANOW, &self.original) };
}
}
term.c_lflag &= !libc::ECHO;
let no_echo = got && unsafe { libc::tcsetattr(fd, libc::TCSANOW, &term) } == 0;
let _guard = no_echo.then_some(EchoGuard { fd, original });
if !no_echo {
let _ = writeln!(
tty.output,
"\n(warning: terminal echo could not be disabled; input will be visible)"
);
}
let mut buf = String::new();
let res = read_one_line(&mut tty.input, &mut buf, 4096);
let _ = tty.output.write_all(b"\n");
res?;
Ok(Zeroizing::new(buf))
}
#[cfg(not(unix))]
fn read_secret_line(tty: &mut PromptTty) -> std::io::Result<Zeroizing<String>> {
let _ = writeln!(
tty.output,
"\n(warning: terminal echo could not be disabled; input will be visible)"
);
let mut buf = String::new();
read_one_line(&mut tty.input, &mut buf, 4096)?;
Ok(Zeroizing::new(buf))
}
#[cfg(unix)]
pub struct TermiosRawGuard {
fd: libc::c_int,
original: libc::termios,
owns_handler: bool,
}
#[cfg(unix)]
mod termios_signal {
use core::cell::UnsafeCell;
use core::mem::MaybeUninit;
use core::sync::atomic::{AtomicBool, AtomicI32, Ordering};
pub(super) static INSTALLED: AtomicBool = AtomicBool::new(false);
pub(super) static SAVED_FD: AtomicI32 = AtomicI32::new(-1);
pub(super) struct TermiosCell(pub UnsafeCell<MaybeUninit<libc::termios>>);
unsafe impl Sync for TermiosCell {}
pub(super) static SAVED_TERMIOS: TermiosCell =
TermiosCell(UnsafeCell::new(MaybeUninit::uninit()));
pub(super) struct SigactionCell(pub UnsafeCell<MaybeUninit<libc::sigaction>>);
unsafe impl Sync for SigactionCell {}
pub(super) static PREV_SIGACTIONS: [SigactionCell; 4] = [
SigactionCell(UnsafeCell::new(MaybeUninit::uninit())),
SigactionCell(UnsafeCell::new(MaybeUninit::uninit())),
SigactionCell(UnsafeCell::new(MaybeUninit::uninit())),
SigactionCell(UnsafeCell::new(MaybeUninit::uninit())),
];
pub(super) const SIGNALS: [libc::c_int; 4] =
[libc::SIGINT, libc::SIGTERM, libc::SIGHUP, libc::SIGQUIT];
pub(super) extern "C" fn handler(sig: libc::c_int) {
let fd = SAVED_FD.load(Ordering::Relaxed);
if fd >= 0 {
unsafe {
let ptr = SAVED_TERMIOS.0.get();
libc::tcsetattr(fd, libc::TCSANOW, (*ptr).as_ptr());
}
}
unsafe {
libc::raise(sig);
}
}
}
#[cfg(unix)]
impl TermiosRawGuard {
pub fn install(original: &libc::termios) -> Self {
let fd: libc::c_int = 0;
let mut raw = *original;
raw.c_lflag &= !(libc::ICANON
| libc::ECHO
| libc::ECHOE
| libc::ECHOK
| libc::ECHONL
| libc::ISIG
| libc::IEXTEN);
raw.c_iflag &= !(libc::IXON | libc::ICRNL | libc::BRKINT | libc::INPCK | libc::ISTRIP);
raw.c_oflag &= !libc::OPOST;
raw.c_cc[libc::VMIN] = 1;
raw.c_cc[libc::VTIME] = 0;
unsafe {
libc::tcsetattr(fd, libc::TCSANOW, &raw);
}
let owns_handler = install_signal_handler(fd, original);
TermiosRawGuard {
fd,
original: *original,
owns_handler,
}
}
}
#[cfg(unix)]
impl Drop for TermiosRawGuard {
fn drop(&mut self) {
if self.owns_handler {
uninstall_signal_handler();
}
unsafe {
libc::tcsetattr(self.fd, libc::TCSANOW, &self.original);
}
}
}
#[cfg(unix)]
fn install_signal_handler(fd: libc::c_int, original: &libc::termios) -> bool {
use core::sync::atomic::Ordering;
if termios_signal::INSTALLED
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.is_err()
{
return false;
}
unsafe {
(*termios_signal::SAVED_TERMIOS.0.get()).write(*original);
}
termios_signal::SAVED_FD.store(fd, Ordering::Release);
let mut all_ok = true;
unsafe {
for (idx, &sig) in termios_signal::SIGNALS.iter().enumerate() {
let mut sa: libc::sigaction = core::mem::zeroed();
sa.sa_sigaction = termios_signal::handler as *const () as usize;
sa.sa_flags = libc::SA_RESETHAND;
libc::sigemptyset(&mut sa.sa_mask);
let prev_ptr = termios_signal::PREV_SIGACTIONS[idx].0.get();
if libc::sigaction(sig, &sa, (*prev_ptr).as_mut_ptr()) != 0 {
(*prev_ptr).write(core::mem::zeroed());
all_ok = false;
}
}
}
let _ = all_ok;
true
}
#[cfg(unix)]
fn uninstall_signal_handler() {
use core::sync::atomic::Ordering;
termios_signal::SAVED_FD.store(-1, Ordering::Release);
unsafe {
for (idx, &sig) in termios_signal::SIGNALS.iter().enumerate() {
let prev_ptr = termios_signal::PREV_SIGACTIONS[idx].0.get();
libc::sigaction(sig, (*prev_ptr).as_ptr(), core::ptr::null_mut());
}
}
termios_signal::INSTALLED.store(false, Ordering::Release);
}
fn try_ssh_askpass(prompt: &str) -> std::io::Result<Option<Zeroizing<String>>> {
if !askpass_configured() {
return Ok(None);
}
let askpass = match std::env::var_os("SSH_ASKPASS") {
Some(v) => v,
None => return Ok(None),
};
let mut cmd = std::process::Command::new(askpass);
cmd.arg(prompt);
cmd.stdin(std::process::Stdio::null());
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::inherit());
let out = match cmd.output() {
Ok(o) => o,
Err(_) => return Ok(None),
};
if !out.status.success() {
return Ok(None);
}
let mut s = String::from_utf8_lossy(&out.stdout).into_owned();
if let Some(idx) = s.find('\n') {
s.truncate(idx);
}
if s.ends_with('\r') {
s.pop();
}
Ok(Some(Zeroizing::new(s)))
}
pub fn check_identity_perms(path: &Path) -> Result<(), String> {
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
let md = std::fs::metadata(path).map_err(|e| format!("read {}: {e}", path.display()))?;
let euid = unix_sys::geteuid();
let mode = md.mode() & 0o777;
if md.uid() == euid && (mode & 0o077) != 0 {
return Err(format!(
"@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@\n\
@ WARNING: UNPROTECTED PRIVATE KEY FILE! @\n\
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@\n\
Permissions {mode:04o} for '{}' are too open.\n\
It is required that your private key files are NOT accessible by others.\n\
This private key will be ignored.",
path.display()
));
}
}
#[cfg(not(unix))]
{
let _ = path;
}
Ok(())
}
pub fn load_identity(path: &str) -> Result<PrivateKey, String> {
check_identity_perms(Path::new(path))?;
let pem = std::fs::read_to_string(path).map_err(|e| format!("read {path}: {e}"))?;
PrivateKey::parse_pem(&pem, None)
.map_err(|e| format!("parse {path}: {e} (passphrase-protected keys not supported here)"))
}
pub struct LazyIdentity {
path: String,
pem: Zeroizing<String>,
algorithm: &'static str,
public_blob: Vec<u8>,
loaded: Mutex<Option<Box<dyn puressh::hostkey::HostKey + Send + Sync>>>,
}
impl puressh::hostkey::HostKey for LazyIdentity {
fn algorithm(&self) -> &'static str {
self.algorithm
}
fn public_blob(&self) -> Vec<u8> {
self.public_blob.clone()
}
fn sign(&self, msg: &[u8]) -> puressh::Result<Vec<u8>> {
let mut loaded = self
.loaded
.lock()
.map_err(|_| Error::Config("identity lock poisoned"))?;
if loaded.is_none() {
for _ in 0..3 {
let pass = read_secret(&format!("Enter passphrase for key '{}': ", self.path))
.map_err(Error::Io)?;
if pass.is_empty() {
break;
}
match PrivateKey::parse_pem(&self.pem, Some(pass.as_bytes())) {
Ok(k) => {
*loaded = Some(k.into_host_key_sync()?);
break;
}
Err(Error::Crypto(_)) => {}
Err(e) => return Err(e),
}
}
}
match loaded.as_ref() {
Some(k) => k.sign(msg),
None => Err(Error::Crypto("no passphrase for the identity")),
}
}
}
pub fn identity_credential(
path: &str,
is_default: bool,
batch: bool,
) -> Result<Option<ClientCredential>, String> {
Ok(identity_signer(path, is_default, batch)?.map(|s| ClientCredential::PublicKey(s)))
}
pub fn identity_signer(
path: &str,
is_default: bool,
batch: bool,
) -> Result<Option<Box<dyn puressh::hostkey::HostKey + Send + Sync>>, String> {
let pem = match std::fs::read_to_string(path) {
Ok(s) => Zeroizing::new(s),
Err(e) if is_default && e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(format!("read {path}: {e}")),
};
if let Err(banner) = check_identity_perms(Path::new(path)) {
if is_default {
eprintln!("{banner}");
return Ok(None);
}
return Err(banner);
}
match PrivateKey::parse_pem(&pem, None) {
Ok(k) => Ok(Some(
k.into_host_key_sync().map_err(|e| format!("{path}: {e}"))?,
)),
Err(Error::Crypto(_)) => {
if batch || !secret_prompt_available() {
vlog(
1,
&format!("{path}: encrypted, and no way to ask for its passphrase"),
);
return Ok(None);
}
let public = PrivateKey::public_key_from_pem(&pem)
.ok()
.or_else(|| {
let line = std::fs::read_to_string(format!("{path}.pub")).ok()?;
puressh::key::PublicKey::parse_authorized_keys_line(line.trim()).ok()
})
.ok_or_else(|| format!("{path}: encrypted key without a readable {path}.pub"))?;
let algorithm = match public.algorithm() {
"ssh-rsa" => "rsa-sha2-512",
other => other,
};
Ok(Some(Box::new(LazyIdentity {
path: path.to_string(),
pem,
algorithm,
public_blob: public.wire_blob(),
loaded: Mutex::new(None),
})))
}
Err(e) => Err(format!("parse {path}: {e}")),
}
}
#[cfg(unix)]
static AUTH_AGENTS: Mutex<Vec<Arc<Mutex<Agent>>>> = Mutex::new(Vec::new());
#[cfg(unix)]
#[allow(dead_code)] pub fn bind_auth_agents(binding: &puressh::client::SessionBinding) {
let agents = AUTH_AGENTS.lock().map(|g| g.clone()).unwrap_or_default();
for agent in agents {
if let Ok(mut a) = agent.lock() {
match a.bind_session(
&binding.host_key,
&binding.session_id,
&binding.signature,
false,
) {
Ok(true) => vlog(1, "bound agent to hostkey"),
Ok(false) => vlog(1, "agent does not support session binding"),
Err(e) => vlog(1, &format!("agent session binding: {e}")),
}
}
}
}
#[cfg(unix)]
#[allow(dead_code)] pub fn agent_signer_for(blob: &[u8]) -> Option<AgentHostKey> {
let agent = Agent::connect_env().ok()??;
let agent = Arc::new(Mutex::new(agent));
if let Ok(mut g) = AUTH_AGENTS.lock() {
g.push(agent.clone());
}
let held = agent
.lock()
.ok()?
.identities()
.ok()?
.into_iter()
.any(|i| i.key_blob == blob);
if !held {
return None;
}
AgentHostKey::from_identity(agent, blob.to_vec()).ok()
}
#[cfg(unix)]
pub fn connect_agent_credentials() -> Result<Vec<ClientCredential>, String> {
let agent = match Agent::connect_env() {
Ok(Some(a)) => a,
Ok(None) => return Ok(Vec::new()),
Err(e) => {
vlog(1, &format!("agent: {e}"));
return Ok(Vec::new());
}
};
let agent = Arc::new(Mutex::new(agent));
if let Ok(mut g) = AUTH_AGENTS.lock() {
g.push(agent.clone());
}
let identities = {
let mut a = agent
.lock()
.map_err(|_| "agent mutex poisoned".to_string())?;
a.identities().map_err(|e| format!("identities: {e}"))?
};
let mut creds: Vec<ClientCredential> = Vec::with_capacity(identities.len());
for ident in identities {
match AgentHostKey::from_identity(Arc::clone(&agent), ident.key_blob.clone()) {
Ok(hk) => creds.push(ClientCredential::PublicKey(Box::new(hk))),
Err(e) => eprintln!(
"warning: agent identity {:?}: skipping: {e}",
ident.comment()
),
}
}
Ok(creds)
}
#[cfg(not(unix))]
pub fn connect_agent_credentials() -> Result<Vec<ClientCredential>, String> {
Ok(Vec::new())
}
pub fn default_known_hosts_path() -> Option<PathBuf> {
let home = std::env::var_os("HOME")?;
Some(PathBuf::from(home).join(".ssh").join("known_hosts"))
}
pub fn fingerprint_b64_sha256(blob: &[u8]) -> String {
use purecrypto::hash::{Digest, Sha256};
let digest = Sha256::digest(blob);
let s = base64_no_pad(digest.as_ref());
format!("SHA256:{s}")
}
pub fn base64_no_pad(bytes: &[u8]) -> String {
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
let mut i = 0;
while i + 3 <= bytes.len() {
let b = ((bytes[i] as u32) << 16) | ((bytes[i + 1] as u32) << 8) | (bytes[i + 2] as u32);
out.push(ALPHABET[((b >> 18) & 0x3F) as usize] as char);
out.push(ALPHABET[((b >> 12) & 0x3F) as usize] as char);
out.push(ALPHABET[((b >> 6) & 0x3F) as usize] as char);
out.push(ALPHABET[(b & 0x3F) as usize] as char);
i += 3;
}
let rem = bytes.len() - i;
if rem == 1 {
let b = (bytes[i] as u32) << 16;
out.push(ALPHABET[((b >> 18) & 0x3F) as usize] as char);
out.push(ALPHABET[((b >> 12) & 0x3F) as usize] as char);
} else if rem == 2 {
let b = ((bytes[i] as u32) << 16) | ((bytes[i + 1] as u32) << 8);
out.push(ALPHABET[((b >> 18) & 0x3F) as usize] as char);
out.push(ALPHABET[((b >> 12) & 0x3F) as usize] as char);
out.push(ALPHABET[((b >> 6) & 0x3F) as usize] as char);
}
out
}
static PEER_ADDR: Mutex<Option<std::net::SocketAddr>> = Mutex::new(None);
#[allow(dead_code)] pub fn set_peer_addr(addr: Option<std::net::SocketAddr>) {
if let Ok(mut g) = PEER_ADDR.lock() {
*g = addr;
}
}
fn host_target(host: &str, port: u16) -> String {
if port == 22 {
host.to_string()
} else {
format!("[{host}]:{port}")
}
}
fn known_hosts_lines(files: &[PathBuf]) -> Vec<(PathBuf, usize, KnownHosts)> {
let mut out = Vec::new();
for f in files {
let Ok(text) = std::fs::read_to_string(f) else {
continue;
};
for (i, line) in text.lines().enumerate() {
let t = line.trim_start();
if t.is_empty() || t.starts_with('#') {
continue;
}
out.push((f.clone(), i + 1, KnownHosts::from_bytes(line.as_bytes())));
}
}
out
}
fn openssh_host_key_actions(strict: StrictMode, files: Vec<PathBuf>) -> (TofuAction, TofuAction) {
let files = Arc::new(files);
let unknown_files = Arc::clone(&files);
let unknown =
TofuAction::PromptDetailed(Arc::new(move |p: &puressh::client::HostKeyPrompt| {
let label = puressh::client::host_key_type_label(p.key_blob);
let target = host_target(p.host, p.port);
let accepted = match strict {
StrictMode::Yes => {
eprintln!(
"No {label} host key is known for {target} and you have requested strict \
checking."
);
false
}
StrictMode::Ask => ask_unknown_host(p, &label, &target, &unknown_files),
StrictMode::AcceptNew | StrictMode::No => true,
_ => false,
};
if accepted && !is_quiet() {
eprintln!(
"Warning: Permanently added '{target}' ({label}) to the list of known hosts."
);
}
accepted
}));
let changed =
TofuAction::PromptDetailed(Arc::new(move |p: &puressh::client::HostKeyPrompt| {
let label = puressh::client::host_key_type_label(p.key_blob);
let target = host_target(p.host, p.port);
eprintln!("@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@");
eprintln!("@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @");
eprintln!("@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@");
eprintln!("IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!");
eprintln!(
"Someone could be eavesdropping on you right now (man-in-the-middle attack)!"
);
eprintln!("It is also possible that a host key has just been changed.");
eprintln!(
"The fingerprint for the {label} key sent by the remote host is\n{}.",
p.fingerprint
);
eprintln!("Please contact your system administrator.");
if let Some(first) = files.first() {
eprintln!(
"Add correct host key in {} to get rid of this message.",
first.display()
);
}
for (file, line, store) in known_hosts_lines(&files) {
for e in store.find(p.host, p.port) {
if e.marker.is_none() {
eprintln!(
"Offending {} key in {}:{line}",
puressh::client::host_key_type_label(&e.key_blob),
file.display()
);
}
}
}
eprintln!("Host key for {target} has changed and you have requested strict checking.");
false
}));
let changed = if strict == StrictMode::No {
TofuAction::AcceptWithWarning
} else {
changed
};
(unknown, changed)
}
fn ask_unknown_host(
p: &puressh::client::HostKeyPrompt,
label: &str,
target: &str,
files: &[PathBuf],
) -> bool {
let addr = PEER_ADDR
.lock()
.ok()
.and_then(|g| *g)
.map(|a| host_target(&a.ip().to_canonical().to_string(), a.port()))
.unwrap_or_else(|| target.to_string());
let mut banner = format!(
"The authenticity of host '{target} ({addr})' can't be established.\n\
{label} key fingerprint is {}.\n",
p.fingerprint
);
let others: Vec<String> = known_hosts_lines(files)
.into_iter()
.flat_map(|(file, line, store)| {
store
.find_key(p.key_blob)
.into_iter()
.map(move |name| format!(" {}:{line}: {name}", file.display()))
.collect::<Vec<_>>()
})
.collect();
if others.is_empty() {
banner.push_str("This key is not known by any other names.\n");
} else {
banner.push_str("This host key is known by the following other names/addresses:\n");
for o in others.iter().take(8) {
banner.push_str(o);
banner.push('\n');
}
}
let Some(mut tty) = open_prompt_tty() else {
eprint!("{banner}");
return false;
};
let mut question = "Are you sure you want to continue connecting (yes/no/[fingerprint])? ";
if tty.output.write_all(banner.as_bytes()).is_err() {
return false;
}
loop {
if tty
.output
.write_all(question.as_bytes())
.and_then(|()| tty.output.flush())
.is_err()
{
return false;
}
let mut line = String::new();
if read_one_line(&mut tty.input, &mut line, 256).is_err() || line.is_empty() {
return false;
}
let answer = line.trim();
if answer.eq_ignore_ascii_case("yes") || answer == p.fingerprint {
return true;
}
if answer.eq_ignore_ascii_case("no") {
return false;
}
question = "Please type 'yes', 'no' or the fingerprint: ";
}
}
pub fn build_host_key_policy(
strict: StrictMode,
explicit_path: Option<PathBuf>,
hash_known_hosts: bool,
user_files: Vec<PathBuf>,
) -> Result<HostKeyPolicy, String> {
let path = match explicit_path {
Some(p) => p,
None => default_known_hosts_path()
.ok_or_else(|| "no $HOME, cannot locate default known_hosts".to_string())?,
};
let store = KnownHosts::load(&path).map_err(|e| format!("load {}: {e}", path.display()))?;
let (on_unknown, on_mismatch) = openssh_host_key_actions(strict, user_files);
let mut policy = KnownHostsPolicy::strict(Arc::new(Mutex::new(store)));
policy.save_path = Some(path);
policy.hash_new = hash_known_hosts;
policy.on_unknown = on_unknown;
policy.on_mismatch = on_mismatch;
Ok(HostKeyPolicy::KnownHosts(policy))
}
pub fn apply_known_hosts_options(
policy: &mut HostKeyPolicy,
cfg: &puressh::config::ClientOptions,
) -> Result<(), String> {
let HostKeyPolicy::KnownHosts(kh) = policy else {
return Ok(());
};
let globals: Vec<String> = match &cfg.global_known_hosts_files {
Some(files) if files.iter().any(|f| f.eq_ignore_ascii_case("none")) => Vec::new(),
Some(files) => files.iter().map(|f| expand_tilde(f)).collect(),
None => vec![
"/etc/ssh/ssh_known_hosts".into(),
"/etc/ssh/ssh_known_hosts2".into(),
],
};
for path in globals {
match KnownHosts::load(&path) {
Ok(store) => kh.global.push(Arc::new(store)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => vlog(1, &format!("global known_hosts {path}: {e}")),
}
}
kh.host_key_alias = cfg.host_key_alias.clone();
if let Some(path) = cfg
.revoked_host_keys
.as_deref()
.filter(|p| !p.eq_ignore_ascii_case("none"))
{
let path = expand_tilde(path);
let revoked = puressh::known_hosts::RevokedHostKeys::load(&path)
.map_err(|e| format!("RevokedHostKeys {path}: {e}"))?;
kh.revoked = Some(Arc::new(revoked));
}
Ok(())
}
static VERBOSE: AtomicU8 = AtomicU8::new(0);
static QUIET: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
#[allow(dead_code)] pub fn set_quiet(on: bool) {
QUIET.store(on, Ordering::Relaxed);
}
#[allow(dead_code)] pub fn io_reason(e: &std::io::Error) -> String {
if e.kind() == std::io::ErrorKind::TimedOut && e.raw_os_error().is_none() {
return "Connection timed out".to_string();
}
let s = e.to_string();
match s.find(" (os error ") {
Some(i) => s[..i].to_string(),
None => s,
}
}
#[allow(dead_code)]
pub fn is_quiet() -> bool {
QUIET.load(Ordering::Relaxed)
}
pub fn set_verbose(level: u8) {
VERBOSE.store(level.min(3), Ordering::Relaxed);
}
pub fn verbose_level() -> u8 {
VERBOSE.load(Ordering::Relaxed)
}
#[cfg(unix)]
static ORIGINAL_STDERR: std::sync::OnceLock<std::fs::File> = std::sync::OnceLock::new();
#[cfg(unix)]
#[allow(dead_code)] pub fn log_to_file(path: &str) -> Result<(), String> {
let f = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)
.map_err(|e| format!("-E {path}: {e}"))?;
use std::os::fd::AsFd;
let saved = std::io::stderr()
.as_fd()
.try_clone_to_owned()
.map_err(|e| format!("-E {path}: {e}"))?;
let _ = ORIGINAL_STDERR.set(std::fs::File::from(saved));
unix_sys::dup2(&f, 2).map_err(|e| format!("-E {path}: {e}"))?;
Ok(())
}
#[allow(dead_code)] pub fn remote_stderr() -> Box<dyn std::io::Write + Send> {
#[cfg(unix)]
if let Some(f) = ORIGINAL_STDERR.get().and_then(|f| f.try_clone().ok()) {
return Box::new(f);
}
Box::new(std::io::stderr())
}
#[allow(dead_code)] pub fn restore_stderr() {
#[cfg(unix)]
if let Some(f) = ORIGINAL_STDERR.get() {
let _ = unix_sys::dup2(f, 2);
}
}
pub fn vlog(level: u8, msg: &str) {
let level = level.clamp(1, 3);
if VERBOSE.load(Ordering::Relaxed) >= level {
eprintln!("debug{level}: {msg}");
}
}
pub fn default_identity_paths() -> Vec<PathBuf> {
let Some(home) = std::env::var_os("HOME") else {
return Vec::new();
};
let dot_ssh = PathBuf::from(home).join(".ssh");
["id_rsa", "id_ecdsa", "id_ed25519"]
.iter()
.map(|name| dot_ssh.join(name))
.collect()
}
pub fn try_load_default_identity(path: &Path) -> Result<Option<PrivateKey>, String> {
let pem = match std::fs::read_to_string(path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(format!("read {}: {e}", path.display())),
};
if let Err(banner) = check_identity_perms(path) {
eprintln!("{banner}");
return Ok(None);
}
match PrivateKey::parse_pem(&pem, None) {
Ok(pk) => Ok(Some(pk)),
Err(Error::Crypto("passphrase required")) => Ok(None),
Err(e) => Err(format!("parse {}: {e}", path.display())),
}
}
pub fn load_client_config(
explicit: Option<&Path>,
expand: &dyn Fn(&str) -> String,
) -> Result<puressh::config::SshClientConfig, String> {
use puressh::config::SshClientConfig;
if let Some(path) = explicit {
if path == Path::new("none") {
return Ok(SshClientConfig::default());
}
return SshClientConfig::load_expanding(path, expand)
.map_err(|e| format!("{}: {e}", path.display()));
}
let mut cfg: Option<SshClientConfig> = None;
if let Some(home) = std::env::var_os("HOME") {
let user = PathBuf::from(home).join(".ssh").join("config");
if user.exists() {
check_user_config_perms(&user)?;
cfg = Some(
SshClientConfig::load_expanding(&user, expand)
.map_err(|e| format!("{}: {e}", user.display()))?,
);
}
}
let system = Path::new("/etc/ssh/ssh_config");
if system.exists() {
let sys = SshClientConfig::load_expanding(system, expand)
.map_err(|e| format!("{}: {e}", system.display()))?;
match cfg.as_mut() {
Some(existing) => existing.append(sys),
None => cfg = Some(sys),
}
}
Ok(cfg.unwrap_or_default())
}
fn check_user_config_perms(path: &Path) -> Result<(), String> {
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
let md = std::fs::metadata(path).map_err(|e| format!("{}: {e}", path.display()))?;
let euid = unix_sys::geteuid();
if (md.uid() != 0 && md.uid() != euid) || (md.mode() & 0o022) != 0 {
return Err(format!("Bad owner or permissions on {}", path.display()));
}
}
#[cfg(not(unix))]
{
let _ = path;
}
Ok(())
}
pub fn load_server_config(path: &Path) -> Result<puressh::config::SshServerConfig, String> {
puressh::config::SshServerConfig::load(path).map_err(|e| format!("{}: {e}", path.display()))
}
pub fn pick<T>(cli: Option<T>, cfg: Option<T>, default: T) -> T {
cli.or(cfg).unwrap_or(default)
}
pub fn expand_tilde(path: &str) -> String {
if path == "~" {
return std::env::var("HOME").unwrap_or_else(|_| "~".into());
}
if let Some(rest) = path.strip_prefix("~/")
&& let Ok(home) = std::env::var("HOME")
{
return format!("{home}/{rest}");
}
path.to_string()
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum TickAction {
SendData(Vec<u8>),
SendChaff,
WindowExpired { chaff_sent: u64 },
Idle,
}
pub struct KeystrokeObfuscator {
interval_ms: u32,
tail_ms: u32,
max_chunk: usize,
queue: Vec<u8>,
window_until: Option<u64>,
window_active: bool,
chaff_sent: u64,
started_logged: bool,
}
impl KeystrokeObfuscator {
pub const DEFAULT_MAX_CHUNK: usize = 256;
pub fn new(interval_ms: u32, tail_ms: u32) -> Self {
Self {
interval_ms: interval_ms.max(1),
tail_ms,
max_chunk: Self::DEFAULT_MAX_CHUNK,
queue: Vec::new(),
window_until: None,
window_active: false,
chaff_sent: 0,
started_logged: false,
}
}
pub fn interval_ms(&self) -> u32 {
self.interval_ms
}
pub fn window_open(&self) -> bool {
self.window_active
}
pub fn take_started_log(&mut self) -> bool {
if self.window_active && !self.started_logged {
self.started_logged = true;
true
} else {
false
}
}
pub fn enqueue(&mut self, data: &[u8], now_ms: u64) {
if data.is_empty() {
return;
}
self.queue.extend_from_slice(data);
self.open_or_extend_window(now_ms);
}
fn open_or_extend_window(&mut self, now_ms: u64) {
let deadline = now_ms.saturating_add(self.tail_ms as u64);
self.window_until = Some(deadline);
if !self.window_active {
self.window_active = true;
self.chaff_sent = 0;
self.started_logged = false;
}
}
pub fn tick(&mut self, now_ms: u64) -> TickAction {
if !self.queue.is_empty() {
let take = self.queue.len().min(self.max_chunk);
let chunk: Vec<u8> = self.queue.drain(..take).collect();
self.open_or_extend_window(now_ms);
return TickAction::SendData(chunk);
}
if !self.window_active {
return TickAction::Idle;
}
match self.window_until {
Some(until) if now_ms < until => {
self.chaff_sent = self.chaff_sent.saturating_add(1);
TickAction::SendChaff
}
_ => {
let chaff_sent = self.chaff_sent;
self.window_active = false;
self.window_until = None;
self.started_logged = false;
self.chaff_sent = 0;
TickAction::WindowExpired { chaff_sent }
}
}
}
}
#[cfg(test)]
mod keystroke_obfuscator_tests {
use super::*;
#[test]
fn idle_when_no_activity() {
let mut o = KeystrokeObfuscator::new(20, 1000);
assert_eq!(o.tick(0), TickAction::Idle);
assert_eq!(o.tick(20), TickAction::Idle);
assert!(!o.window_open());
}
#[test]
fn enqueue_opens_window_and_data_drains_on_ticks() {
let mut o = KeystrokeObfuscator::new(20, 1000);
o.enqueue(b"abc", 0);
assert!(o.window_open());
assert_eq!(o.tick(20), TickAction::SendData(b"abc".to_vec()));
assert_eq!(o.tick(40), TickAction::SendChaff);
}
#[test]
fn large_burst_is_chunked_across_ticks() {
let mut o = KeystrokeObfuscator::new(20, 1000);
let big = vec![b'x'; KeystrokeObfuscator::DEFAULT_MAX_CHUNK * 2 + 5];
o.enqueue(&big, 0);
let a = o.tick(20);
let b = o.tick(40);
let c = o.tick(60);
match (&a, &b, &c) {
(TickAction::SendData(x), TickAction::SendData(y), TickAction::SendData(z)) => {
assert_eq!(x.len(), KeystrokeObfuscator::DEFAULT_MAX_CHUNK);
assert_eq!(y.len(), KeystrokeObfuscator::DEFAULT_MAX_CHUNK);
assert_eq!(z.len(), 5);
}
other => panic!("expected three SendData chunks, got {other:?}"),
}
}
#[test]
fn window_expires_after_tail_with_chaff_count() {
let mut o = KeystrokeObfuscator::new(20, 50);
o.enqueue(b"k", 0);
assert_eq!(o.tick(20), TickAction::SendData(b"k".to_vec()));
assert_eq!(o.tick(40), TickAction::SendChaff);
assert_eq!(o.tick(60), TickAction::SendChaff);
assert_eq!(o.tick(70), TickAction::WindowExpired { chaff_sent: 2 });
assert_eq!(o.tick(90), TickAction::Idle);
assert!(!o.window_open());
}
#[test]
fn new_keystroke_reopens_window_after_expiry() {
let mut o = KeystrokeObfuscator::new(20, 30);
o.enqueue(b"a", 0);
assert_eq!(o.tick(20), TickAction::SendData(b"a".to_vec()));
assert!(matches!(o.tick(50), TickAction::WindowExpired { .. }));
assert_eq!(o.tick(70), TickAction::Idle);
o.enqueue(b"b", 100);
assert!(o.window_open());
assert!(o.take_started_log(), "starting line logged once per window");
assert!(!o.take_started_log(), "starting line not logged twice");
assert_eq!(o.tick(120), TickAction::SendData(b"b".to_vec()));
}
#[test]
fn started_log_fires_once_per_window() {
let mut o = KeystrokeObfuscator::new(20, 30);
o.enqueue(b"a", 0);
assert!(o.take_started_log());
assert!(!o.take_started_log());
}
}
#[cfg(all(test, unix))]
mod perm_tests {
use super::*;
use std::os::unix::fs::PermissionsExt;
struct Scratch(PathBuf);
impl Scratch {
fn new(tag: &str) -> Self {
use std::time::{SystemTime, UNIX_EPOCH};
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let dir = std::env::temp_dir()
.join(format!("puressh-perm-{tag}-{}-{nanos}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
Self(dir)
}
fn file(&self, name: &str, mode: u32) -> PathBuf {
let p = self.0.join(name);
std::fs::write(&p, b"x").unwrap();
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(mode)).unwrap();
p
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[test]
fn identity_too_open_is_refused_with_openssh_banner() {
let s = Scratch::new("id");
for mode in [0o644, 0o640, 0o604, 0o660, 0o777] {
let p = s.file(&format!("key-{mode:o}"), mode);
let err = check_identity_perms(&p).unwrap_err();
assert!(
err.contains("UNPROTECTED PRIVATE KEY FILE"),
"{mode:o}: {err}"
);
assert!(err.contains(&format!("Permissions {mode:04o}")), "{err}");
assert!(err.contains("will be ignored"), "{err}");
}
for mode in [0o600, 0o400, 0o700] {
let p = s.file(&format!("key-{mode:o}"), mode);
assert!(check_identity_perms(&p).is_ok(), "{mode:o}");
}
}
#[test]
fn identity_too_open_is_rejected_by_both_loaders() {
let s = Scratch::new("load");
let p = s.file("id_ed25519", 0o644);
let err = load_identity(p.to_str().unwrap()).unwrap_err();
assert!(err.contains("UNPROTECTED"), "{err}");
assert!(matches!(try_load_default_identity(&p), Ok(None)));
}
#[test]
fn identity_missing_is_a_read_error_not_a_perm_error() {
let s = Scratch::new("missing");
let err = check_identity_perms(&s.0.join("nope")).unwrap_err();
assert!(err.starts_with("read "), "{err}");
}
#[test]
fn user_config_group_or_world_writable_is_refused() {
let s = Scratch::new("cfg");
for mode in [0o664, 0o646, 0o666, 0o622] {
let p = s.file(&format!("config-{mode:o}"), mode);
let err = check_user_config_perms(&p).unwrap_err();
assert!(
err.starts_with("Bad owner or permissions on "),
"{mode:o}: {err}"
);
}
for mode in [0o644, 0o600, 0o444, 0o755] {
let p = s.file(&format!("config-{mode:o}"), mode);
assert!(check_user_config_perms(&p).is_ok(), "{mode:o}");
}
}
#[test]
fn user_config_missing_is_an_error() {
let s = Scratch::new("cfgmissing");
assert!(check_user_config_perms(&s.0.join("config")).is_err());
}
}
#[cfg(test)]
mod target_tests {
use super::*;
#[test]
fn sanitize_strips_control_and_del() {
let evil = "ok\x1b]0;pwned\x07\x1b[2Jline2\r\n\ttab\x7fend";
let out = sanitize_terminal_str(evil);
assert!(!out.contains('\x1b'), "ESC survived: {out:?}");
assert!(!out.contains('\x07'), "BEL survived: {out:?}");
assert!(!out.contains('\r') && !out.contains('\n'), "CR/LF survived");
assert!(!out.contains('\t'), "TAB survived");
assert!(!out.contains('\x7f'), "DEL survived");
assert!(out.starts_with("ok?]0;pwned?"));
assert!(out.ends_with("end"));
}
#[test]
fn sanitize_preserves_printable_and_unicode() {
let s = "Password for café (日本語): ";
assert_eq!(sanitize_terminal_str(s), s);
}
#[test]
fn parse_target_plain_host_no_port() {
let (u, h, p) = parse_target("example.com").unwrap();
assert_eq!(u, None);
assert_eq!(h, "example.com");
assert_eq!(p, None);
}
#[test]
fn parse_target_user_at_host() {
let (u, h, p) = parse_target("alice@example.com").unwrap();
assert_eq!(u.as_deref(), Some("alice"));
assert_eq!(h, "example.com");
assert_eq!(p, None);
}
#[test]
fn parse_target_host_with_port() {
let (u, h, p) = parse_target("example.com:2222").unwrap();
assert_eq!(u, None);
assert_eq!(h, "example.com");
assert_eq!(p, Some(2222));
}
#[test]
fn parse_target_user_host_port() {
let (u, h, p) = parse_target("alice@example.com:2222").unwrap();
assert_eq!(u.as_deref(), Some("alice"));
assert_eq!(h, "example.com");
assert_eq!(p, Some(2222));
}
#[test]
fn parse_target_bare_v6() {
let (u, h, p) = parse_target("2001:db8::1").unwrap();
assert_eq!(u, None);
assert_eq!(h, "2001:db8::1");
assert_eq!(p, None);
}
#[test]
fn parse_target_bracketed_v6_with_port() {
let (u, h, p) = parse_target("[2001:db8::1]:2222").unwrap();
assert_eq!(u, None);
assert_eq!(h, "2001:db8::1");
assert_eq!(p, Some(2222));
}
#[test]
fn parse_target_user_at_bracketed_v6() {
let (u, h, p) = parse_target("alice@[2001:db8::1]:2222").unwrap();
assert_eq!(u.as_deref(), Some("alice"));
assert_eq!(h, "2001:db8::1");
assert_eq!(p, Some(2222));
}
#[test]
fn parse_target_user_at_bare_v6() {
let (u, h, p) = parse_target("alice@2001:db8::1").unwrap();
assert_eq!(u.as_deref(), Some("alice"));
assert_eq!(h, "2001:db8::1");
assert_eq!(p, None);
}
#[test]
fn parse_target_rejects_empty_host() {
assert!(parse_target("alice@").is_err());
assert!(parse_target("").is_err());
}
#[test]
fn parse_target_rejects_unmatched_bracket() {
assert!(parse_target("[2001:db8::1").is_err());
}
#[test]
fn parse_target_brackets_around_any_host() {
assert_eq!(
parse_target("deploy@[192.0.2.1]").unwrap(),
(Some("deploy".into()), "192.0.2.1".into(), None)
);
assert_eq!(
parse_target("[web.example]:2222").unwrap(),
(None, "web.example".into(), Some(2222))
);
assert!(parse_target("[web.example]:x").is_err());
}
#[test]
fn parse_userhost_path_plain() {
let (u, h, p) = parse_userhost_path("alice@example.com:/etc/motd").unwrap();
assert_eq!(u.as_deref(), Some("alice"));
assert_eq!(h, "example.com");
assert_eq!(p, "/etc/motd");
}
#[test]
fn parse_userhost_path_no_user() {
let (u, h, p) = parse_userhost_path("example.com:/etc/motd").unwrap();
assert_eq!(u, None);
assert_eq!(h, "example.com");
assert_eq!(p, "/etc/motd");
}
#[test]
fn parse_userhost_path_local_rejects_absolute() {
assert!(parse_userhost_path("/etc/motd").is_none());
}
#[test]
fn parse_userhost_path_local_rejects_no_colon() {
assert!(parse_userhost_path("relative/path").is_none());
}
#[test]
fn parse_userhost_path_bracketed_v6() {
let (u, h, p) = parse_userhost_path("[2001:db8::1]:/etc/motd").unwrap();
assert_eq!(u, None);
assert_eq!(h, "2001:db8::1");
assert_eq!(p, "/etc/motd");
}
#[test]
fn parse_userhost_path_user_at_bracketed_v6() {
let (u, h, p) = parse_userhost_path("alice@[2001:db8::1]:/etc/motd").unwrap();
assert_eq!(u.as_deref(), Some("alice"));
assert_eq!(h, "2001:db8::1");
assert_eq!(p, "/etc/motd");
}
#[test]
fn parse_userhost_path_preserves_colons_in_path() {
let (u, h, p) = parse_userhost_path("host:/a:b:c").unwrap();
assert_eq!(u, None);
assert_eq!(h, "host");
assert_eq!(p, "/a:b:c");
}
#[test]
fn parse_userhost_path_bracketed_v6_path_with_colons() {
let (u, h, p) = parse_userhost_path("[2001:db8::1]:/a:b").unwrap();
assert_eq!(u, None);
assert_eq!(h, "2001:db8::1");
assert_eq!(p, "/a:b");
}
#[test]
fn parse_userhost_path_bracketed_v6_unmatched_bracket() {
assert!(parse_userhost_path("[2001:db8::1/path").is_none());
}
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
use core::sync::atomic::Ordering;
fn current_handler_ptr(sig: libc::c_int) -> usize {
unsafe {
let mut prev: libc::sigaction = core::mem::zeroed();
let rc = libc::sigaction(sig, core::ptr::null(), &mut prev);
assert_eq!(rc, 0, "sigaction(read) failed");
prev.sa_sigaction
}
}
#[test]
fn signal_handler_install_cycle_is_clean() {
let pre = [
current_handler_ptr(libc::SIGINT),
current_handler_ptr(libc::SIGTERM),
current_handler_ptr(libc::SIGHUP),
current_handler_ptr(libc::SIGQUIT),
];
let original: libc::termios = unsafe { core::mem::zeroed() };
let installed = install_signal_handler(0, &original);
assert!(installed, "first install should succeed");
assert!(
termios_signal::INSTALLED.load(Ordering::Acquire),
"INSTALLED must be true after first install"
);
let our_handler = termios_signal::handler as *const () as usize;
for &sig in &termios_signal::SIGNALS {
assert_eq!(
current_handler_ptr(sig),
our_handler,
"signal {sig} should now point at our handler"
);
}
let second = install_signal_handler(0, &original);
assert!(!second, "second install while one is active must fail");
uninstall_signal_handler();
assert!(
!termios_signal::INSTALLED.load(Ordering::Acquire),
"INSTALLED must be false after uninstall"
);
assert_eq!(
termios_signal::SAVED_FD.load(Ordering::Acquire),
-1,
"SAVED_FD must be cleared after uninstall"
);
for (sig, &prev_ptr) in termios_signal::SIGNALS.iter().zip(pre.iter()) {
let now = current_handler_ptr(*sig);
assert_ne!(
now, our_handler,
"signal {sig} still points at our handler after uninstall"
);
assert_eq!(
now, prev_ptr,
"signal {sig} disposition was not restored to its pre-install value"
);
}
let reinstall = install_signal_handler(0, &original);
assert!(reinstall, "fresh install after uninstall must succeed");
uninstall_signal_handler();
}
}