Something went wrong. Try again.
TRAMP-style remote editing for nushell: an ssh agent and a nu plugin, in Rust
Something went wrong. Try again.
15 kB · 468 lines
Rust
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469//! TRAMP URI parser.//!//! Parses paths of the form://!//! ```text//! /<backend>:<user>@<host>#<port>:<remote-path>//! ```//!//! Chained (multi-hop) paths use `|` as a separator://!//! ```text//! /ssh:jumpbox|ssh:myvm:/etc/config//! ```//!//! The parser produces a [`TrampPath`] containing a `Vec<Hop>` (to support//! future chaining) and the final remote path.
use crate::errors::{TrampError, TrampResult};use std::fmt;
// ---------------------------------------------------------------------------// Types// ---------------------------------------------------------------------------
/// A fully parsed TRAMP URI.#[derive(Debug, Clone, PartialEq, Eq)]pub struct TrampPath { /// One or more hops (the last hop owns the `remote_path`). pub hops: Vec<Hop>, /// The absolute path on the final remote host. pub remote_path: String,}
/// A single hop in a (possibly chained) TRAMP path.#[derive(Debug, Clone, PartialEq, Eq)]pub struct Hop { pub backend: BackendKind, pub user: Option<String>, pub host: String, pub port: Option<u16>,}
/// Known backend transport types.#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]pub enum BackendKind { Ssh, Docker, Kubernetes, Sudo,}
// ---------------------------------------------------------------------------// Display / formatting (round-trip support)// ---------------------------------------------------------------------------
impl fmt::Display for BackendKind { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { BackendKind::Ssh => write!(f, "ssh"), BackendKind::Docker => write!(f, "docker"), BackendKind::Kubernetes => write!(f, "k8s"), BackendKind::Sudo => write!(f, "sudo"), } }}
impl fmt::Display for Hop { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.backend)?; write!(f, ":")?; if let Some(ref user) = self.user { write!(f, "{user}@")?; } write!(f, "{}", self.host)?; if let Some(port) = self.port { write!(f, "#{port}")?; } Ok(()) }}
impl fmt::Display for TrampPath { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "/")?; for (i, hop) in self.hops.iter().enumerate() { if i > 0 { write!(f, "|")?; } write!(f, "{hop}")?; } write!(f, ":{}", self.remote_path) }}
// ---------------------------------------------------------------------------// Parsing helpers// ---------------------------------------------------------------------------
/// Known backend name strings.const KNOWN_BACKENDS: &[(&str, BackendKind)] = &[ ("ssh", BackendKind::Ssh), ("docker", BackendKind::Docker), ("k8s", BackendKind::Kubernetes), ("kubernetes", BackendKind::Kubernetes), ("sudo", BackendKind::Sudo),];
/// Parse a backend name into a [`BackendKind`].fn parse_backend(name: &str) -> TrampResult<BackendKind> { for &(label, kind) in KNOWN_BACKENDS { if name.eq_ignore_ascii_case(label) { return Ok(kind); } } Err(TrampError::UnknownBackend(name.to_string()))}
/// Returns `true` if `path` looks like a TRAMP URI (quick check without full/// parsing). This is intentionally cheap so callers can bail out early for/// normal local paths.pub fn is_tramp_path(path: &str) -> bool { let Some(path) = path.strip_prefix('/') else { return false; }; // Must start with a known backend name followed by ':' let Some(colon_idx) = path.find(':') else { return false; }; let candidate = &path[..colon_idx]; // The candidate might include a pipe from chaining — take the first // segment before any '|'. let first_segment = candidate.split('|').next().unwrap_or(candidate); KNOWN_BACKENDS .iter() .any(|&(label, _)| first_segment.eq_ignore_ascii_case(label))}
/// Parse a single hop segment like `ssh:user@host#port`.////// The segment must NOT include the leading `/` or any trailing `:<remote-path>`.fn parse_hop(segment: &str) -> TrampResult<Hop> { // Split on the first ':' to get backend and the host-info part. let (backend_str, host_info) = segment .split_once(':') .ok_or_else(|| TrampError::ParseError(format!("expected ':' in hop '{segment}'")))?;
let backend = parse_backend(backend_str)?;
if host_info.is_empty() { return Err(TrampError::MissingHost); }
// host_info is like "user@host#port" or "host#port" or "host" or "user@host" let (user, remainder) = if let Some(at_idx) = host_info.find('@') { let user = &host_info[..at_idx]; if user.is_empty() { (None, &host_info[at_idx + 1..]) } else { (Some(user.to_string()), &host_info[at_idx + 1..]) } } else { (None, host_info) };
// remainder is like "host#port" or "host" let (host, port) = if let Some(hash_idx) = remainder.find('#') { let host = &remainder[..hash_idx]; let port_str = &remainder[hash_idx + 1..]; let port = port_str .parse::<u16>() .map_err(|_parse_err| TrampError::InvalidPort(port_str.to_string()))?; (host.to_string(), Some(port)) } else { (remainder.to_string(), None) };
if host.is_empty() { return Err(TrampError::MissingHost); }
Ok(Hop { backend, user, host, port, })}
// ---------------------------------------------------------------------------// Public API// ---------------------------------------------------------------------------
/// Parse a TRAMP URI string into a [`TrampPath`].////// Returns `Ok(None)` if the path is not a TRAMP URI at all (i.e. a normal/// local path). Returns `Err` if it looks like a TRAMP URI but is malformed.pub fn parse(path: &str) -> TrampResult<Option<TrampPath>> { if !is_tramp_path(path) { return Ok(None); }
// Strip the leading '/' let path = &path[1..];
// Find the *last* unambiguous colon that starts the remote path. // The remote path always starts with '/', so we look for ":/" to locate it. // This avoids confusion with the ':' separators inside hop segments. let remote_sep = path.find(":/").ok_or(TrampError::MissingRemotePath)?;
let hops_str = &path[..remote_sep]; let remote_path = &path[remote_sep + 1..]; // includes the leading '/'
if remote_path.is_empty() { return Err(TrampError::MissingRemotePath); }
// Split hops on '|' let hop_segments: Vec<&str> = hops_str.split('|').collect(); let mut hops = Vec::with_capacity(hop_segments.len()); for seg in hop_segments { hops.push(parse_hop(seg)?); }
if hops.is_empty() { return Err(TrampError::ParseError( "no hops found in tramp path".to_string(), )); }
Ok(Some(TrampPath { hops, remote_path: remote_path.to_string(), }))}
// ---------------------------------------------------------------------------// Tests// ---------------------------------------------------------------------------
#[cfg(test)]mod tests { use super::*;
// -- is_tramp_path -------------------------------------------------------
#[test] fn detect_ssh_path() { assert!(is_tramp_path("/ssh:myvm:/etc/config")); }
#[test] fn detect_docker_path() { assert!(is_tramp_path("/docker:mycontainer:/app")); }
#[test] fn detect_k8s_path() { assert!(is_tramp_path("/k8s:mypod:/tmp")); }
#[test] fn detect_sudo_path() { assert!(is_tramp_path("/sudo:root:/etc/shadow")); }
#[test] fn reject_normal_path() { assert!(!is_tramp_path("/etc/config")); assert!(!is_tramp_path("/home/user/.config")); assert!(!is_tramp_path("relative/path")); assert!(!is_tramp_path("")); }
#[test] fn reject_unknown_backend() { assert!(!is_tramp_path("/ftp:host:/file")); }
#[test] fn detect_chained_path() { assert!(is_tramp_path("/ssh:jumpbox|ssh:myvm:/etc/config")); }
// -- parse: basic --------------------------------------------------------
#[test] fn parse_simple_ssh() { let result = parse("/ssh:myvm:/etc/config").unwrap().unwrap(); assert_eq!(result.hops.len(), 1); assert_eq!(result.hops[0].backend, BackendKind::Ssh); assert_eq!(result.hops[0].host, "myvm"); assert_eq!(result.hops[0].user, None); assert_eq!(result.hops[0].port, None); assert_eq!(result.remote_path, "/etc/config"); }
#[test] fn parse_ssh_with_user() { let result = parse("/ssh:admin@myvm:/etc/config").unwrap().unwrap(); assert_eq!(result.hops[0].user.as_deref(), Some("admin")); assert_eq!(result.hops[0].host, "myvm"); }
#[test] fn parse_ssh_with_user_and_port() { let result = parse("/ssh:admin@myvm#2222:/etc/config").unwrap().unwrap(); assert_eq!(result.hops[0].user.as_deref(), Some("admin")); assert_eq!(result.hops[0].host, "myvm"); assert_eq!(result.hops[0].port, Some(2222)); assert_eq!(result.remote_path, "/etc/config"); }
#[test] fn parse_ssh_with_port_no_user() { let result = parse("/ssh:myvm#22:/etc/config").unwrap().unwrap(); assert_eq!(result.hops[0].user, None); assert_eq!(result.hops[0].host, "myvm"); assert_eq!(result.hops[0].port, Some(22)); }
#[test] fn parse_ssh_ip_address() { let result = parse("/ssh:192.168.1.10:/var/log").unwrap().unwrap(); assert_eq!(result.hops[0].host, "192.168.1.10"); assert_eq!(result.remote_path, "/var/log"); }
#[test] fn parse_deep_remote_path() { let result = parse("/ssh:myvm:/a/b/c/d/e.txt").unwrap().unwrap(); assert_eq!(result.remote_path, "/a/b/c/d/e.txt"); }
#[test] fn parse_root_remote_path() { let result = parse("/ssh:myvm:/").unwrap().unwrap(); assert_eq!(result.remote_path, "/"); }
// -- parse: chained ------------------------------------------------------
#[test] fn parse_two_hops() { let result = parse("/ssh:jumpbox|ssh:myvm:/etc/config").unwrap().unwrap(); assert_eq!(result.hops.len(), 2); assert_eq!(result.hops[0].backend, BackendKind::Ssh); assert_eq!(result.hops[0].host, "jumpbox"); assert_eq!(result.hops[1].backend, BackendKind::Ssh); assert_eq!(result.hops[1].host, "myvm"); assert_eq!(result.remote_path, "/etc/config"); }
#[test] fn parse_mixed_chain() { let result = parse("/ssh:myvm|docker:mycontainer:/app/config.toml") .unwrap() .unwrap(); assert_eq!(result.hops.len(), 2); assert_eq!(result.hops[0].backend, BackendKind::Ssh); assert_eq!(result.hops[0].host, "myvm"); assert_eq!(result.hops[1].backend, BackendKind::Docker); assert_eq!(result.hops[1].host, "mycontainer"); assert_eq!(result.remote_path, "/app/config.toml"); }
#[test] fn parse_three_hops() { let result = parse("/ssh:jumpbox|ssh:myvm|sudo:root:/etc/shadow") .unwrap() .unwrap(); assert_eq!(result.hops.len(), 3); assert_eq!(result.hops[2].backend, BackendKind::Sudo); assert_eq!(result.hops[2].host, "root"); }
// -- parse: case insensitivity -------------------------------------------
#[test] fn parse_case_insensitive_backend() { let result = parse("/SSH:myvm:/etc/config").unwrap().unwrap(); assert_eq!(result.hops[0].backend, BackendKind::Ssh); }
#[test] fn parse_kubernetes_alias() { let result = parse("/kubernetes:mypod:/tmp").unwrap().unwrap(); assert_eq!(result.hops[0].backend, BackendKind::Kubernetes); }
// -- parse: non-tramp paths return None -----------------------------------
#[test] fn parse_normal_path_returns_none() { assert!(parse("/etc/config").unwrap().is_none()); assert!(parse("/home/user/.config").unwrap().is_none()); assert!(parse("relative/path").unwrap().is_none()); }
// -- parse: errors -------------------------------------------------------
#[test] fn parse_missing_remote_path() { let result = parse("/ssh:myvm"); assert!(result.is_err()); let err = result.unwrap_err(); assert!(matches!(err, TrampError::MissingRemotePath)); }
#[test] fn parse_missing_host() { let result = parse("/ssh::/etc/config"); assert!(result.is_err()); let err = result.unwrap_err(); assert!(matches!(err, TrampError::MissingHost)); }
#[test] fn parse_invalid_port() { let result = parse("/ssh:myvm#notaport:/etc/config"); assert!(result.is_err()); let err = result.unwrap_err(); assert!(matches!(err, TrampError::InvalidPort(_))); }
#[test] fn parse_empty_user_treated_as_none() { let result = parse("/ssh:@myvm:/etc/config").unwrap().unwrap(); assert_eq!(result.hops[0].user, None); assert_eq!(result.hops[0].host, "myvm"); }
// -- round-trip ----------------------------------------------------------
#[test] fn round_trip_simple() { let original = "/ssh:myvm:/etc/config"; let parsed = parse(original).unwrap().unwrap(); assert_eq!(parsed.to_string(), original); }
#[test] fn round_trip_with_user() { let original = "/ssh:admin@myvm:/etc/config"; let parsed = parse(original).unwrap().unwrap(); assert_eq!(parsed.to_string(), original); }
#[test] fn round_trip_with_user_and_port() { let original = "/ssh:admin@myvm#2222:/etc/config"; let parsed = parse(original).unwrap().unwrap(); assert_eq!(parsed.to_string(), original); }
#[test] fn round_trip_chained() { let original = "/ssh:jumpbox|ssh:myvm:/etc/config"; let parsed = parse(original).unwrap().unwrap(); assert_eq!(parsed.to_string(), original); }
#[test] fn round_trip_complex_chain() { let original = "/ssh:admin@jumpbox#2222|docker:mycontainer:/app/config.toml"; let parsed = parse(original).unwrap().unwrap(); assert_eq!(parsed.to_string(), original); }}