puressh 0.2.1

A pure-Rust SSH (Secure Shell) protocol library, in the spirit of libssh, built on purecrypto.
Documentation
//! `ControlPath` `%`-token expansion and `sun_path` length management.
//!
//! OpenSSH's `ControlPath` accepts the same `%`-token vocabulary as
//! `ProxyCommand` plus `%C` — OpenSSH's hash of the local host name, host,
//! port, user and ProxyJump. We reuse the shared
//! [`expand_tokens`](crate::proc_transport::expand_tokens) for `%h`/`%p`/`%r`
//! and add `%C` here.
//!
//! A Unix-domain socket address is bounded by `sun_path` (~108 bytes including
//! the NUL terminator on Linux). An expanded `ControlPath` longer than that
//! cannot be `bind()`ed, so [`socket_path_for`] rejects it with
//! [`ControlPathTooLong`] — the same `ControlPath too long` hard error OpenSSH
//! raises. (An earlier version silently substituted a deterministic name
//! under `std::env::temp_dir()`; that put the socket somewhere the user never
//! chose, at a name any local user could predict and pre-create.)

use std::fmt;
use std::path::PathBuf;

use purecrypto::hash::{Digest, Sha1};

/// Conservative usable length for a `sockaddr_un.sun_path`. The real limit is
/// 108 on Linux / 104 on macOS *including* the trailing NUL; we keep a margin
/// and treat anything longer as "must hash".
const SUN_PATH_MAX: usize = 100;

/// Lowercase-hex encode a byte slice.
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
}

/// The `%C` connection hash, as OpenSSH computes it so both share masters:
/// lowercase hex SHA-1 of the local host name, host, port, remote user and
/// ProxyJump string (`%l%h%p%r%j`), concatenated.
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()))
}

/// Expand a `ControlPath` template's `%`-tokens — `%h`/`%p`/`%r`/`%%` via the
/// shared expander, plus `%C` (the [`connection_hash`]) and `%l` (the local
/// hostname). Tilde (`~` / `~/`) is **not** expanded here; do that first with
/// the binary's `expand_tilde` helper.
pub fn expand_tokens_with_hash(
    template: &str,
    localhost: &str,
    host: &str,
    port: u16,
    user: &str,
) -> String {
    // Substitute %C and %l first (they aren't known to the shared expander),
    // being careful to leave %% alone so the shared pass can collapse it.
    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);
            }
            // %% and every other %x: hand the pair through untouched so the
            // shared expander sees it verbatim.
            Some(&other) => {
                chars.next();
                pre.push('%');
                pre.push(other);
            }
            None => pre.push('%'),
        }
    }
    crate::proc_transport::expand_tokens(&pre, host, port, user)
}

/// Best-effort local hostname for `%C` / `%l`. Tries `$HOSTNAME`, then the
/// kernel hostname file (`/proc/sys/kernel/hostname` on Linux). The `nix`
/// `gethostname` syscall isn't enabled in our feature set, and the library
/// stays `forbid(unsafe_code)` so it can't call `libc::gethostname` directly;
/// the binary passes a hostname in explicitly where one matters. An empty
/// string is acceptable — the `%C` hash only needs to be *stable* per target.
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()
}

/// An expanded `ControlPath` does not fit in a Unix-domain socket address.
///
/// Mirrors OpenSSH's `ControlPath too long` fatal error. Users should shorten
/// the template (e.g. use `%C`, the connection hash, instead of `%r@%h:%p`)
/// or point it at a shorter directory.
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct ControlPathTooLong {
    /// The fully expanded path that was rejected.
    pub path: String,
    /// The maximum byte length accepted.
    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 {}

/// Fully resolve a `ControlPath` template to a concrete filesystem path,
/// applying `~`/token expansion and the `sun_path` length check.
///
/// `tilde` expands a leading `~`/`~/` (pass the binary's `expand_tilde`). If
/// the expanded path's byte length exceeds the conservative `sun_path` limit
/// the result is [`ControlPathTooLong`]; there is deliberately no fallback
/// location (see the module docs).
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)
}

/// Apply the `sun_path` length check to an already-expanded path string.
/// Returns the path unchanged when it fits, otherwise [`ControlPathTooLong`].
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() {
        // `ssh -G -o ControlPath=%C -p 2222 -l bob example.com` on host
        // "mizuki" (OpenSSH 10), then again with `-J jh`.
        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() {
        // Regression: this used to land in `std::env::temp_dir()` under a
        // predictable name any local user could pre-create.
        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());
    }
}