#![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 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;
use zeroize::Zeroizing;
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());
}
std::env::var("USER").map_err(|_| "no user specified and $USER is unset".into())
}
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> {
let (user, host_with_port) = parse_userhost(target);
if host_with_port.is_empty() {
return Err("empty host".into());
}
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))
}
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))
}
pub fn read_password_from_stdin() -> std::io::Result<Zeroizing<String>> {
if let Some(out) = try_ssh_askpass()? {
return Ok(out);
}
eprint!("password: ");
std::io::stderr().flush()?;
#[cfg(unix)]
{
if let Some(out) = read_password_no_echo_unix()? {
return Ok(out);
}
}
eprintln!();
eprintln!("(warning: terminal echo could not be disabled; password will be visible)");
let mut buf = String::new();
read_one_line(&mut buf, 4096)?;
Ok(Zeroizing::new(buf))
}
pub fn read_kbdint_response(prompt: &str, echo: bool) -> std::io::Result<Zeroizing<String>> {
let prompt = sanitize_terminal_str(prompt);
eprint!("{prompt}");
std::io::stderr().flush()?;
if !echo {
#[cfg(unix)]
{
if let Some(out) = read_password_no_echo_unix()? {
return Ok(out);
}
}
eprintln!();
eprintln!("(warning: terminal echo could not be disabled; input will be visible)");
}
let mut buf = String::new();
read_one_line(&mut buf, 4096)?;
Ok(Zeroizing::new(buf))
}
fn read_one_line(buf: &mut String, max_len: usize) -> std::io::Result<()> {
let mut byte = [0u8; 1];
let mut stdin = std::io::stdin();
loop {
let n = stdin.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_password_no_echo_unix() -> std::io::Result<Option<Zeroizing<String>>> {
use std::os::unix::io::AsRawFd;
let fd = std::io::stdin().as_raw_fd();
let mut term: libc::termios = unsafe { core::mem::zeroed() };
if unsafe { libc::tcgetattr(fd, &mut term as *mut _) } != 0 {
return Ok(None);
}
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;
if unsafe { libc::tcsetattr(fd, libc::TCSANOW, &term) } != 0 {
return Ok(None);
}
let _guard = EchoGuard { fd, original };
let mut buf = String::new();
let res = read_one_line(&mut buf, 4096);
eprintln!();
res?;
Ok(Some(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() -> std::io::Result<Option<Zeroizing<String>>> {
let askpass = match std::env::var_os("SSH_ASKPASS") {
Some(v) if !v.is_empty() => v,
_ => return Ok(None),
};
if let Some(req) = std::env::var_os("SSH_ASKPASS_REQUIRE")
&& req == "never"
{
return Ok(None);
}
let mut cmd = std::process::Command::new(askpass);
cmd.arg("password: ");
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 load_identity(path: &str) -> Result<PrivateKey, String> {
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)"))
}
#[cfg(unix)]
pub fn connect_agent_credentials() -> Result<Vec<ClientCredential>, String> {
let agent = match Agent::connect_env().map_err(|e| format!("connect: {e}"))? {
Some(a) => a,
None => return Ok(Vec::new()),
};
let agent = Arc::new(Mutex::new(agent));
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
}
pub fn tofu_prompt(host: &str, port: u16, key_type: &str, key_blob: &[u8]) -> bool {
let fp = fingerprint_b64_sha256(key_blob);
let target = if port == 22 {
host.to_string()
} else {
format!("[{host}]:{port}")
};
eprintln!("The authenticity of host '{target}' can't be established.");
eprintln!("{key_type} key fingerprint is {fp}.");
eprint!("Are you sure you want to continue connecting (yes/no)? ");
let _ = std::io::stderr().flush();
let answer = read_short_stdin_line();
matches!(answer.as_str(), "yes" | "y")
}
pub fn tofu_mismatch_prompt(host: &str, port: u16, key_type: &str, key_blob: &[u8]) -> bool {
let fp = fingerprint_b64_sha256(key_blob);
let target = if port == 22 {
host.to_string()
} else {
format!("[{host}]:{port}")
};
eprintln!(
"Host key verification for '{target}' FAILED: the {key_type} key the server presented \
({fp}) does not match any entry in your known_hosts file."
);
eprintln!(
"If you are absolutely sure this is the new legitimate key for this host, type `yes` \
to accept it and overwrite the trusted entry. Anything else (including just pressing \
Enter) will refuse the connection."
);
eprint!("Accept the new key and replace the trusted entry (type `yes` to confirm)? ");
let _ = std::io::stderr().flush();
let answer = read_short_stdin_line();
answer == "yes"
}
fn read_short_stdin_line() -> String {
let mut line = String::new();
let mut byte = [0u8; 1];
let mut stdin = std::io::stdin();
while let Ok(n) = stdin.read(&mut byte) {
if n == 0 || byte[0] == b'\n' {
break;
}
if byte[0] == b'\r' {
continue;
}
line.push(byte[0] as char);
if line.len() > 16 {
break;
}
}
line.trim().to_ascii_lowercase()
}
pub fn build_host_key_policy(
strict: StrictMode,
explicit_path: Option<PathBuf>,
hash_known_hosts: bool,
) -> 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) = match strict {
StrictMode::Yes => (TofuAction::Reject, TofuAction::Reject),
StrictMode::AcceptNew => (
TofuAction::Accept,
TofuAction::Prompt(Arc::new(tofu_mismatch_prompt)),
),
StrictMode::Ask => (
TofuAction::Prompt(Arc::new(tofu_prompt)),
TofuAction::Prompt(Arc::new(tofu_mismatch_prompt)),
),
StrictMode::No => (TofuAction::Accept, TofuAction::AcceptWithWarning),
};
Ok(HostKeyPolicy::KnownHosts(KnownHostsPolicy {
store: Arc::new(Mutex::new(store)),
save_path: Some(path),
hash_new: hash_known_hosts,
on_unknown,
on_mismatch,
}))
}
static VERBOSE: AtomicU8 = AtomicU8::new(0);
pub fn set_verbose(level: u8) {
VERBOSE.store(level.min(3), Ordering::Relaxed);
}
pub fn verbose_level() -> u8 {
VERBOSE.load(Ordering::Relaxed)
}
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_ed25519", "id_ecdsa", "id_rsa"]
.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())),
};
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>,
) -> Result<puressh::config::SshClientConfig, String> {
use puressh::config::SshClientConfig;
if let Some(path) = explicit {
return SshClientConfig::load(path).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() {
cfg =
Some(SshClientConfig::load(&user).map_err(|e| format!("{}: {e}", user.display()))?);
}
}
let system = Path::new("/etc/ssh/ssh_config");
if system.exists() {
let sys =
SshClientConfig::load(system).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())
}
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(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_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();
}
}