use std::fmt;
use std::path::PathBuf;
use purecrypto::hash::{Digest, Sha1};
const SUN_PATH_MAX: usize = 100;
fn hex(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
s.push(char::from_digit((b >> 4) as u32, 16).unwrap());
s.push(char::from_digit((b & 0xf) as u32, 16).unwrap());
}
s
}
pub fn connection_hash(localhost: &str, host: &str, port: u16, user: &str, jump: &str) -> String {
let material = format!("{localhost}{host}{port}{user}{jump}");
hex(&Sha1::digest(material.as_bytes()))
}
pub fn expand_tokens_with_hash(
template: &str,
localhost: &str,
host: &str,
port: u16,
user: &str,
) -> String {
let mut pre = String::with_capacity(template.len());
let mut chars = template.chars().peekable();
while let Some(c) = chars.next() {
if c != '%' {
pre.push(c);
continue;
}
match chars.peek() {
Some('C') => {
chars.next();
pre.push_str(&connection_hash(localhost, host, port, user, ""));
}
Some('l') => {
chars.next();
pre.push_str(localhost);
}
Some(&other) => {
chars.next();
pre.push('%');
pre.push(other);
}
None => pre.push('%'),
}
}
crate::proc_transport::expand_tokens(&pre, host, port, user)
}
pub fn local_hostname() -> String {
if let Ok(h) = std::env::var("HOSTNAME")
&& !h.is_empty()
{
return h;
}
if let Ok(h) = std::fs::read_to_string("/proc/sys/kernel/hostname") {
let h = h.trim();
if !h.is_empty() {
return h.to_string();
}
}
String::new()
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct ControlPathTooLong {
pub path: String,
pub max: usize,
}
impl fmt::Display for ControlPathTooLong {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"ControlPath too long ({} bytes, max {}): {}",
self.path.len(),
self.max,
self.path
)
}
}
impl std::error::Error for ControlPathTooLong {}
pub fn expand_control_path(
template: &str,
localhost: &str,
host: &str,
port: u16,
user: &str,
tilde: impl Fn(&str) -> String,
) -> Result<PathBuf, ControlPathTooLong> {
let expanded = tilde(&expand_tokens_with_hash(
template, localhost, host, port, user,
));
socket_path_for(&expanded)
}
pub fn socket_path_for(expanded: &str) -> Result<PathBuf, ControlPathTooLong> {
if expanded.len() <= SUN_PATH_MAX {
Ok(PathBuf::from(expanded))
} else {
Err(ControlPathTooLong {
path: expanded.to_string(),
max: SUN_PATH_MAX,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn connection_hash_is_stable_and_distinct() {
let a = connection_hash("box", "example.com", 22, "alice", "");
let b = connection_hash("box", "example.com", 22, "alice", "");
assert_eq!(a, b, "same tuple ⇒ same hash");
assert_eq!(a.len(), 40);
assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
let c = connection_hash("box", "example.com", 2222, "alice", "");
assert_ne!(a, c, "different port ⇒ different hash");
let d = connection_hash("box", "example.com", 22, "bob", "");
assert_ne!(a, d, "different user ⇒ different hash");
let e = connection_hash("box", "example.com", 22, "alice", "jump");
assert_ne!(a, e, "different ProxyJump ⇒ different hash");
}
#[test]
fn connection_hash_matches_openssh() {
assert_eq!(
connection_hash("mizuki", "example.com", 2222, "bob", ""),
"0147fb14dc3e622c0202e8e9612af8dd607e19d6"
);
assert_eq!(
connection_hash("mizuki", "example.com", 2222, "bob", "jh"),
"d5a1ae91ed600dd3d267c31f11148c8a8eab2592"
);
}
#[test]
fn expand_basic_tokens() {
let out = expand_tokens_with_hash("/tmp/cm-%r@%h:%p", "lh", "example.com", 2222, "alice");
assert_eq!(out, "/tmp/cm-alice@example.com:2222");
}
#[test]
fn expand_percent_c_and_l() {
let h = connection_hash("myhost", "srv", 22, "u", "");
let out = expand_tokens_with_hash("/tmp/%l-%C", "myhost", "srv", 22, "u");
assert_eq!(out, format!("/tmp/myhost-{h}"));
}
#[test]
fn expand_literal_percent() {
let out = expand_tokens_with_hash("100%%-%h", "lh", "h", 22, "u");
assert_eq!(out, "100%-h");
}
#[test]
fn unknown_token_passthrough() {
let out = expand_tokens_with_hash("%z-%h", "lh", "h", 22, "u");
assert_eq!(out, "%z-h");
}
#[test]
fn short_path_unchanged() {
let p = socket_path_for("/tmp/ssh-mux-abc").unwrap();
assert_eq!(p, PathBuf::from("/tmp/ssh-mux-abc"));
}
#[test]
fn overlong_path_is_rejected() {
let long_name: String = "x".repeat(200);
let full = format!("/tmp/{long_name}");
let err = socket_path_for(&full).unwrap_err();
assert_eq!(err.path, full);
assert_eq!(err.max, SUN_PATH_MAX);
assert!(
err.to_string().starts_with("ControlPath too long"),
"OpenSSH-style message: {err}"
);
}
#[test]
fn overlong_parent_is_rejected_not_relocated() {
let deep = format!("/{}/sock", "d".repeat(200));
assert!(socket_path_for(&deep).is_err());
}
#[test]
fn expand_control_path_propagates_length_error() {
let tilde = |s: &str| s.to_string();
assert!(expand_control_path("/tmp/cm-%C", "lh", "h", 22, "u", tilde).is_ok());
let long = format!("/tmp/{}-%C", "z".repeat(150));
assert!(expand_control_path(&long, "lh", "h", 22, "u", tilde).is_err());
}
}