diff --git a/rust/Cargo.lock b/rust/Cargo.lock index f9bdaa5..22e6390 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -89,6 +89,12 @@ dependencies = [ "libc", ] +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + [[package]] name = "argon2" version = "0.5.3" @@ -296,6 +302,12 @@ dependencies = [ "sha2 0.11.0", ] +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.13.2" @@ -435,6 +447,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + [[package]] name = "cfg_aliases" version = "0.2.2" @@ -784,6 +802,12 @@ dependencies = [ "ctutils", ] +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "ecdsa" version = "0.17.0" @@ -956,6 +980,17 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + [[package]] name = "find-msvc-tools" version = "0.1.14" @@ -1373,6 +1408,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + [[package]] name = "libc" version = "0.2.190" @@ -1504,6 +1545,7 @@ dependencies = [ "chrono", "hmac 0.13.0", "keyring", + "portable-pty", "rand 0.8.8", "rusqlite", "russh", @@ -1516,15 +1558,27 @@ dependencies = [ "uuid", ] +[[package]] +name = "nix" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases 0.1.1", + "libc", +] + [[package]] name = "nix" version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags", + "bitflags 2.13.2", "cfg-if", - "cfg_aliases", + "cfg_aliases 0.2.2", "libc", "memoffset", ] @@ -1535,9 +1589,9 @@ version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ - "bitflags", + "bitflags 2.13.2", "cfg-if", - "cfg_aliases", + "cfg_aliases 0.2.2", "libc", ] @@ -1702,7 +1756,7 @@ dependencies = [ "log", "rand 0.10.3", "sha2 0.11.0", - "thiserror", + "thiserror 2.0.21", "tokio", "windows", "windows-strings", @@ -1897,6 +1951,27 @@ dependencies = [ "zeroize", ] +[[package]] +name = "portable-pty" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e" +dependencies = [ + "anyhow", + "bitflags 1.3.2", + "downcast-rs", + "filedescriptor", + "lazy_static", + "libc", + "log", + "nix 0.28.0", + "serial2", + "shared_library", + "shell-words", + "winapi", + "winreg", +] + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -2019,7 +2094,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.13.2", ] [[package]] @@ -2071,7 +2146,7 @@ version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f" dependencies = [ - "bitflags", + "bitflags 2.13.2", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -2086,7 +2161,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba61e87b9ec9a39a59a6bbed4c0b8ff7fb07073405b24a767a1f2c8560ac8f58" dependencies = [ "aes 0.9.3", - "bitflags", + "bitflags 2.13.2", "block-padding 0.4.2", "byteorder", "bytes", @@ -2143,7 +2218,7 @@ dependencies = [ "ssh-encoding", "ssh-key", "subtle", - "thiserror", + "thiserror 2.0.21", "tokio", "typenum", "universal-hash 0.6.1", @@ -2168,7 +2243,7 @@ version = "3.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9bb17f72aba4314656b359ffba72c68dcbd70ed9e97497898b75498fe7198042" dependencies = [ - "bitflags", + "bitflags 2.13.2", "bytes", "chrono", "dashmap", @@ -2176,7 +2251,7 @@ dependencies = [ "log", "serde", "serde_bytes", - "thiserror", + "thiserror 2.0.21", "tokio", "tokio-util", "wasm-bindgen-futures", @@ -2209,7 +2284,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" dependencies = [ - "bitflags", + "bitflags 2.13.2", "errno", "libc", "linux-raw-sys", @@ -2289,7 +2364,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags", + "bitflags 2.13.2", "core-foundation 0.9.4", "core-foundation-sys", "libc", @@ -2302,7 +2377,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags", + "bitflags 2.13.2", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -2399,6 +2474,17 @@ dependencies = [ "serde", ] +[[package]] +name = "serial2" +version = "0.2.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b16809bc35793b19ce4e0c53924bc0dce3937f15487997cfdaed936004180730" +dependencies = [ + "cfg-if", + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "sha1" version = "0.10.7" @@ -2464,6 +2550,22 @@ dependencies = [ "sponge-cursor", ] +[[package]] +name = "shared_library" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" +dependencies = [ + "lazy_static", + "libc", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "2.0.1" @@ -2640,13 +2742,33 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -2898,6 +3020,28 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows" version = "0.62.2" @@ -3182,6 +3326,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + [[package]] name = "wnaf" version = "0.14.1" diff --git a/rust/core/Cargo.toml b/rust/core/Cargo.toml index 14a5f94..6a5574e 100644 --- a/rust/core/Cargo.toml +++ b/rust/core/Cargo.toml @@ -20,3 +20,4 @@ tokio = { version = "1.53.2", features = ["full"] } sha2 = "0.11.0" hmac = "0.13.0" sha1 = "0.11.0" +portable-pty = "0.9.0" diff --git a/rust/core/src/clients/local.rs b/rust/core/src/clients/local.rs new file mode 100644 index 0000000..3192906 --- /dev/null +++ b/rust/core/src/clients/local.rs @@ -0,0 +1,226 @@ +//! Local shell in a PTY (forkpty on Unix, ConPTY on Windows), a port of internal/localshell. + +use super::Callbacks; +use crate::Result; +use portable_pty::{CommandBuilder, MasterPty, PtySize, native_pty_system}; +use std::io::{Read, Write}; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; + +pub fn default_shell() -> String { + if cfg!(windows) { + let ps = std::env::var_os("SystemRoot") + .map(|r| PathBuf::from(r).join(r"System32\WindowsPowerShell\v1.0\powershell.exe")) + .filter(|p| p.exists()); + return ps.map_or_else(|| "cmd.exe".into(), |p| p.to_string_lossy().into()); + } + std::env::var("SHELL").ok().filter(|s| !s.is_empty()).unwrap_or_else(|| "/bin/sh".into()) +} + +fn is_utf8_locale(v: &str) -> bool { + let v = v.to_lowercase(); + v.contains("utf-8") || v.contains("utf8") +} + +/// TERM=xterm-256color and a UTF-8 character locale, keeping the user's environment otherwise. +pub fn local_shell_env(env: &mut Vec<(String, String)>) { + let set = |env: &mut Vec<(String, String)>, k: &str, v: &str| { + env.retain(|(ek, _)| ek != k); + env.push((k.into(), v.into())); + }; + set(env, "TERM", "xterm-256color"); + let get = |k: &str| env.iter().find(|(ek, _)| ek == k).map(|(_, v)| v.clone()); + let effective = get("LC_ALL").or_else(|| get("LC_CTYPE")).or_else(|| get("LANG")); + if effective.as_deref().is_some_and(is_utf8_locale) { + return; + } + let locale = if cfg!(target_os = "macos") { "en_US.UTF-8" } else { "C.UTF-8" }; + let key = if get("LC_ALL").is_some() { + "LC_ALL" + } else if get("LC_CTYPE").is_some() || get("LANG").is_some() { + "LC_CTYPE" + } else { + "LANG" + }; + set(env, key, locale); +} + +struct Running { + master: Box, + writer: Box, + killer: Box, + pid: Option, +} + +pub struct LocalShell { + shell: String, + cb: Callbacks, + state: Mutex<(Option, PathBuf, u16, u16, bool)>, // running, cwd, cols, rows, closed +} + +impl LocalShell { + pub fn new(shell: &str, start_dir: Option, cols: u16, rows: u16, cb: Callbacks) -> Arc { + let shell = if shell.is_empty() { default_shell() } else { shell.to_string() }; + let dir = start_dir.filter(|d| d.is_dir()).or_else(home_dir).unwrap_or_else(|| PathBuf::from("/")); + Arc::new(LocalShell { shell, cb, state: Mutex::new((None, dir, cols.max(1), rows.max(1), false)) }) + } + + pub fn start(self: &Arc) -> Result<()> { + let mut st = self.state.lock().unwrap(); + if st.4 { + return Err("shell already closed".into()); + } + if st.0.is_some() { + return Err("shell already running".into()); + } + let pair = native_pty_system() + .openpty(PtySize { rows: st.3, cols: st.2, pixel_width: 0, pixel_height: 0 }) + .map_err(|e| format!("failed to start shell {:?}: {e}", self.shell))?; + let mut cmd = CommandBuilder::new(&self.shell); + let mut env: Vec<(String, String)> = std::env::vars().collect(); + local_shell_env(&mut env); + cmd.env_clear(); + for (k, v) in env { + cmd.env(k, v); + } + cmd.cwd(&st.1); + let mut child = pair.slave.spawn_command(cmd).map_err(|e| format!("failed to start shell {:?}: {e}", self.shell))?; + drop(pair.slave); + let mut reader = pair.master.try_clone_reader()?; + let writer = pair.master.take_writer()?; + let pid = child.process_id(); + st.0 = Some(Running { master: pair.master, writer, killer: child.clone_killer(), pid }); + drop(st); + + let me = self.clone(); + std::thread::Builder::new().name("nexus-local-read".into()).spawn(move || { + let mut buf = vec![0u8; 32 * 1024]; + loop { + match reader.read(&mut buf) { + Ok(0) | Err(_) => break, + Ok(n) => (me.cb.on_data)(&buf[..n]), + } + } + })?; + let me = self.clone(); + std::thread::Builder::new().name("nexus-local-wait".into()).spawn(move || { + let _ = child.wait(); + let was_closed = { + let mut st = me.state.lock().unwrap(); + let was = st.4; + st.4 = true; + st.0 = None; + was + }; + if !was_closed { + (me.cb.on_close)(); + } + })?; + Ok(()) + } + + pub fn write(&self, data: &[u8]) -> Result<()> { + if data.iter().any(|b| matches!(b, b'\r' | b'\n' | 4)) { + self.capture_cwd(); + } + let mut st = self.state.lock().unwrap(); + let r = st.0.as_mut().ok_or("shell not running")?; + r.writer.write_all(data)?; + Ok(()) + } + + pub fn resize(&self, cols: u16, rows: u16) -> Result<()> { + let mut st = self.state.lock().unwrap(); + if cols > 0 { + st.2 = cols; + } + if rows > 0 { + st.3 = rows; + } + let size = PtySize { rows: st.3, cols: st.2, pixel_width: 0, pixel_height: 0 }; + if let Some(r) = &st.0 { + r.master.resize(size)?; + } + Ok(()) + } + + /// Idempotent; does not fire on_close (matches Go: an explicit close is not an exit). + pub fn close(&self) { + let mut st = self.state.lock().unwrap(); + if st.4 { + return; + } + st.4 = true; + if let Some(mut r) = st.0.take() { + let _ = r.killer.kill(); + } + } + + /// The last directory the shell was seen in (used to restart in the same place). + pub fn working_directory(&self) -> PathBuf { + self.state.lock().unwrap().1.clone() + } + + fn capture_cwd(&self) { + let pid = self.state.lock().unwrap().0.as_ref().and_then(|r| r.pid); + if let Some(dir) = pid.and_then(process_cwd).filter(|d| d.is_dir()) { + self.state.lock().unwrap().1 = dir; + } + } +} + +fn home_dir() -> Option { + std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).map(PathBuf::from) +} + +fn process_cwd(pid: u32) -> Option { + if cfg!(target_os = "linux") { + return std::fs::read_link(format!("/proc/{pid}/cwd")).ok(); + } + if cfg!(target_os = "macos") { + let out = std::process::Command::new("/usr/sbin/lsof").args(["-a", "-p", &pid.to_string(), "-d", "cwd", "-Fn"]).output().ok()?; + return String::from_utf8_lossy(&out.stdout).lines().find_map(|l| l.strip_prefix('n').map(PathBuf::from)); + } + None +} + +#[cfg(all(test, unix))] +mod tests { + use super::*; + use std::time::{Duration, Instant}; + + #[test] + fn runs_echoes_and_reports_exit() { + let seen: Arc>> = Arc::default(); + let closed = Arc::new(Mutex::new(0)); + let (s2, c2) = (seen.clone(), closed.clone()); + let cb = Callbacks { + on_data: Arc::new(move |d| s2.lock().unwrap().extend_from_slice(d)), + on_error: Arc::new(|_| {}), + on_close: Arc::new(move || *c2.lock().unwrap() += 1), + }; + let sh = LocalShell::new("/bin/sh", Some("/tmp".into()), 80, 24, cb); + sh.start().unwrap(); + sh.write(b"cd / && echo local-$((2*21))\n").unwrap(); + let t = Instant::now(); + while !String::from_utf8_lossy(&seen.lock().unwrap()).contains("local-42") { + assert!(t.elapsed() < Duration::from_secs(5)); + std::thread::sleep(Duration::from_millis(20)); + } + sh.resize(100, 30).unwrap(); + sh.write(b"exit\n").unwrap(); + while *closed.lock().unwrap() == 0 { + assert!(t.elapsed() < Duration::from_secs(5)); + std::thread::sleep(Duration::from_millis(20)); + } + assert_eq!(sh.working_directory(), PathBuf::from("/")); + } + + #[test] + fn env_gets_term_and_utf8() { + let mut e = vec![("LANG".to_string(), "C".to_string())]; + local_shell_env(&mut e); + assert!(e.contains(&("TERM".into(), "xterm-256color".into()))); + assert!(e.iter().any(|(k, v)| k == "LC_CTYPE" && v.ends_with("UTF-8"))); + } +} diff --git a/rust/core/src/clients/mod.rs b/rust/core/src/clients/mod.rs index af36640..48e5987 100644 --- a/rust/core/src/clients/mod.rs +++ b/rust/core/src/clients/mod.rs @@ -6,6 +6,7 @@ //! - `write`/`resize`/`close` are callable from any thread; `close` is idempotent and must not //! fire `on_close` re-entrantly while holding locks. +pub mod local; pub mod sftp; pub mod ssh;