Merge telnet, raw and serial clients
This commit is contained in:
Generated
+59
@@ -2335,6 +2335,16 @@ dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "io-kit-sys"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "617ee6cf8e3f66f3b4ea67a4058564628cde41901316e19f559e14c7c72c5e7b"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"mach2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.15.0"
|
||||
@@ -2583,6 +2593,15 @@ version = "0.4.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||
|
||||
[[package]]
|
||||
name = "mach2"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "md5"
|
||||
version = "0.8.1"
|
||||
@@ -2782,6 +2801,7 @@ dependencies = [
|
||||
"chrono",
|
||||
"hmac 0.13.0",
|
||||
"keyring",
|
||||
"libc",
|
||||
"portable-pty",
|
||||
"rand 0.8.8",
|
||||
"rusqlite",
|
||||
@@ -2789,6 +2809,7 @@ dependencies = [
|
||||
"russh-sftp",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serialport",
|
||||
"sha1 0.11.0",
|
||||
"sha2 0.11.0",
|
||||
"tokio",
|
||||
@@ -2796,6 +2817,17 @@ dependencies = [
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.26.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"cfg-if",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.28.0"
|
||||
@@ -4423,6 +4455,24 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serialport"
|
||||
version = "4.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ba5f8f29aa20853c4e3e85a33ec580eb66be1f057142e77a333834a318bacf2"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"cfg-if",
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation-sys",
|
||||
"io-kit-sys",
|
||||
"mach2",
|
||||
"nix 0.26.4",
|
||||
"scopeguard",
|
||||
"unescaper",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.10.7"
|
||||
@@ -5018,6 +5068,15 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unescaper"
|
||||
version = "0.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7285e83a80ce76f5e7bce79fa41f68d78ba62d1003cf27bf748ab24413808cf4"
|
||||
dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-general-category"
|
||||
version = "1.1.0"
|
||||
|
||||
@@ -22,3 +22,7 @@ sha2 = "0.11.0"
|
||||
hmac = "0.13.0"
|
||||
sha1 = "0.11.0"
|
||||
portable-pty = "0.9.0"
|
||||
serialport = { version = "4", default-features = false }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dev-dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
@@ -36,3 +36,122 @@ impl Callbacks {
|
||||
Callbacks { on_data: Arc::new(|_| {}), on_error: Arc::new(|_| {}), on_close: Arc::new(|| {}) }
|
||||
}
|
||||
}
|
||||
|
||||
pub mod raw;
|
||||
pub mod serial;
|
||||
pub mod telnet;
|
||||
|
||||
/// Called with protocol negotiation debug messages (Go's `onDebug`).
|
||||
pub type DebugFn = Arc<dyn Fn(String) + Send + Sync>;
|
||||
|
||||
/// TCP dial shared by the Telnet and RAW clients: Go's `net.DialTimeout` with a 30 s default.
|
||||
/// Returns the stream and the `host:port` string used in error messages.
|
||||
pub(crate) fn dial(host: &str, port: i64, timeout_secs: i64) -> (String, std::io::Result<std::net::TcpStream>) {
|
||||
use std::net::{TcpStream, ToSocketAddrs};
|
||||
let timeout = std::time::Duration::from_secs(if timeout_secs > 0 { timeout_secs as u64 } else { 30 });
|
||||
// Go's net.JoinHostPort brackets IPv6 literals.
|
||||
let addr = if host.contains(':') { format!("[{host}]:{port}") } else { format!("{host}:{port}") };
|
||||
let result = (|| {
|
||||
let mut last = std::io::Error::new(std::io::ErrorKind::NotFound, "no addresses found");
|
||||
for sa in addr.to_socket_addrs()? {
|
||||
match TcpStream::connect_timeout(&sa, timeout) {
|
||||
Ok(s) => return Ok(s),
|
||||
Err(e) => last = e,
|
||||
}
|
||||
}
|
||||
Err(last)
|
||||
})();
|
||||
(addr, result)
|
||||
}
|
||||
|
||||
/// The TCP connection lifecycle shared by the Telnet and RAW clients: one writer-side stream behind
|
||||
/// a lock, a reader thread that feeds `on_read`, and Go's error/close semantics (a read error or EOF
|
||||
/// reports `read error: …` then closes; `close` is idempotent and fires `on_close` once).
|
||||
pub(crate) struct TcpConn {
|
||||
stream: std::sync::Mutex<Option<std::net::TcpStream>>,
|
||||
on_error: ErrorFn,
|
||||
on_close: CloseFn,
|
||||
}
|
||||
|
||||
impl TcpConn {
|
||||
pub(crate) fn new(on_error: ErrorFn, on_close: CloseFn) -> TcpConn {
|
||||
TcpConn { stream: std::sync::Mutex::new(None), on_error, on_close }
|
||||
}
|
||||
|
||||
pub(crate) fn connect(
|
||||
self: &Arc<Self>,
|
||||
stream: std::net::TcpStream,
|
||||
mut on_read: impl FnMut(&[u8]) + Send + 'static,
|
||||
) -> crate::Result<()> {
|
||||
let mut reader = stream.try_clone()?;
|
||||
{
|
||||
let mut guard = self.stream.lock().unwrap();
|
||||
if guard.is_some() {
|
||||
return Err("already connected".into());
|
||||
}
|
||||
*guard = Some(stream);
|
||||
}
|
||||
let me = self.clone();
|
||||
std::thread::spawn(move || {
|
||||
use std::io::Read;
|
||||
let mut buf = [0u8; 4096];
|
||||
loop {
|
||||
let err = match reader.read(&mut buf) {
|
||||
Ok(0) => "EOF".to_string(),
|
||||
Ok(n) => {
|
||||
on_read(&buf[..n]);
|
||||
continue;
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
|
||||
Err(e) => e.to_string(),
|
||||
};
|
||||
// A local close also ends the read; Go reported that as an error too, we don't.
|
||||
if me.is_connected() {
|
||||
(me.on_error)(format!("read error: {err}"));
|
||||
me.close();
|
||||
}
|
||||
return;
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn write(&self, data: &[u8]) -> crate::Result<()> {
|
||||
use std::io::Write;
|
||||
let guard = self.stream.lock().unwrap();
|
||||
let mut s = guard.as_ref().ok_or("not connected")?;
|
||||
s.write_all(data)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn is_connected(&self) -> bool {
|
||||
self.stream.lock().unwrap().is_some()
|
||||
}
|
||||
|
||||
pub(crate) fn close(&self) {
|
||||
let taken = self.stream.lock().unwrap().take();
|
||||
if let Some(s) = taken {
|
||||
let _ = s.shutdown(std::net::Shutdown::Both);
|
||||
(self.on_close)();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// True for the encoding names Go mapped to ISO 8859-1.
|
||||
pub(crate) fn is_latin1(encoding: &str) -> bool {
|
||||
matches!(encoding.trim().to_ascii_lowercase().as_str(), "iso-8859-1" | "latin1" | "latin-1")
|
||||
}
|
||||
|
||||
/// UTF-8 → ISO 8859-1. Like Go's charmap encoder, input that isn't valid UTF-8 or holds a
|
||||
/// character above U+00FF is returned unchanged.
|
||||
pub(crate) fn utf8_to_latin1(data: &[u8]) -> Vec<u8> {
|
||||
std::str::from_utf8(data)
|
||||
.ok()
|
||||
.and_then(|s| s.chars().map(|c| u8::try_from(c).ok()).collect())
|
||||
.unwrap_or_else(|| data.to_vec())
|
||||
}
|
||||
|
||||
/// ISO 8859-1 → UTF-8 (every byte maps to the code point of the same value).
|
||||
pub(crate) fn latin1_to_utf8(data: &[u8]) -> String {
|
||||
data.iter().map(|&b| b as char).collect()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,431 @@
|
||||
//! RAW TCP client (port of Go `internal/rawclient`): binary-safe send/receive, a capped history
|
||||
//! of sent ("send") and received ("recv") frames, JSON capture, and the hex/escape encoders the
|
||||
//! RAW view reuses.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use base64::Engine;
|
||||
use serde::Serialize;
|
||||
|
||||
use super::{CloseFn, ErrorFn, TcpConn};
|
||||
use crate::models::{LineEnding, Profile, Time};
|
||||
|
||||
pub const MAX_HISTORY_ENTRIES: usize = 10_000;
|
||||
pub const MAX_HISTORY_BYTES: usize = 10 * 1024 * 1024;
|
||||
|
||||
pub const SEND: &str = "send";
|
||||
pub const RECV: &str = "recv";
|
||||
|
||||
/// `(data, direction, timestamp in unix ms)`; direction is [`SEND`] or [`RECV`].
|
||||
pub type RawDataFn = Arc<dyn Fn(&[u8], &str, i64) + Send + Sync>;
|
||||
|
||||
/// One sent or received frame. Serialises like Go's (RFC 3339 timestamp, base64 data).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
pub struct HistoryEntry {
|
||||
pub timestamp: Time,
|
||||
pub direction: String,
|
||||
#[serde(serialize_with = "ser_b64")]
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
fn ser_b64<S: serde::Serializer>(d: &[u8], s: S) -> Result<S::Ok, S::Error> {
|
||||
s.serialize_str(&base64::engine::general_purpose::STANDARD.encode(d))
|
||||
}
|
||||
|
||||
pub struct RawClient {
|
||||
inner: Arc<Inner>,
|
||||
}
|
||||
|
||||
struct Inner {
|
||||
conn: Arc<TcpConn>,
|
||||
host: String,
|
||||
port: i64,
|
||||
connect_timeout: i64,
|
||||
line_ending: LineEnding,
|
||||
on_data: RawDataFn,
|
||||
history: Mutex<History>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct History {
|
||||
entries: VecDeque<HistoryEntry>,
|
||||
bytes: usize,
|
||||
}
|
||||
|
||||
impl RawClient {
|
||||
pub fn new(profile: &Profile, on_data: RawDataFn, on_error: ErrorFn, on_close: CloseFn) -> RawClient {
|
||||
RawClient {
|
||||
inner: Arc::new(Inner {
|
||||
conn: Arc::new(TcpConn::new(on_error, on_close)),
|
||||
host: profile.host.clone(),
|
||||
port: profile.port,
|
||||
connect_timeout: profile.connect_timeout,
|
||||
line_ending: profile.raw.as_ref().map(|r| r.line_ending).unwrap_or_default(),
|
||||
on_data,
|
||||
history: Mutex::default(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn connect(&self) -> crate::Result<()> {
|
||||
let inner = &self.inner;
|
||||
if inner.conn.is_connected() {
|
||||
return Err("already connected".into());
|
||||
}
|
||||
if inner.port == 0 {
|
||||
return Err("port must be specified for raw TCP connections".into());
|
||||
}
|
||||
let (addr, res) = super::dial(&inner.host, inner.port, inner.connect_timeout);
|
||||
let stream = res.map_err(|e| format!("raw TCP connection to {addr} failed: {e}"))?;
|
||||
let me = inner.clone();
|
||||
inner.conn.connect(stream, move |d| me.record(RECV, d))
|
||||
}
|
||||
|
||||
/// Sends bytes as-is, then records them and reports them through `on_data` as "send".
|
||||
pub fn write(&self, data: &[u8]) -> crate::Result<()> {
|
||||
self.inner.conn.write(data)?;
|
||||
self.inner.record(SEND, data);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sends text with the configured line ending appended (unless it already ends with it).
|
||||
pub fn write_text(&self, text: &str) -> crate::Result<()> {
|
||||
self.write(&apply_line_ending(text, self.inner.line_ending))
|
||||
}
|
||||
|
||||
/// Sends hex such as "48656c6c6f", "48 65 6c" or "0x48 0x65".
|
||||
pub fn write_hex(&self, hex: &str) -> crate::Result<()> {
|
||||
let data = parse_hex_string(hex).map_err(|e| format!("invalid hex input: {e}"))?;
|
||||
self.write(&data)
|
||||
}
|
||||
|
||||
/// Sends text after processing C-style escapes (see [`parse_escaped`]).
|
||||
pub fn write_escaped(&self, text: &str) -> crate::Result<()> {
|
||||
let data = parse_escaped(text).map_err(|e| format!("escape processing failed: {e}"))?;
|
||||
self.write(&data)
|
||||
}
|
||||
|
||||
pub fn close(&self) {
|
||||
self.inner.conn.close();
|
||||
}
|
||||
|
||||
pub fn is_connected(&self) -> bool {
|
||||
self.inner.conn.is_connected()
|
||||
}
|
||||
|
||||
pub fn history(&self) -> Vec<HistoryEntry> {
|
||||
self.inner.history.lock().unwrap().entries.iter().cloned().collect()
|
||||
}
|
||||
|
||||
pub fn clear_history(&self) {
|
||||
*self.inner.history.lock().unwrap() = History::default();
|
||||
}
|
||||
|
||||
/// Writes the history to `path` as indented JSON (mode 0600 on Unix), as Go did.
|
||||
pub fn save_capture(&self, path: &std::path::Path) -> crate::Result<()> {
|
||||
let json = serde_json::to_vec_pretty(&self.history()).map_err(|e| format!("failed to marshal capture data: {e}"))?;
|
||||
let mut opts = std::fs::OpenOptions::new();
|
||||
opts.write(true).create(true).truncate(true);
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::OpenOptionsExt::mode(&mut opts, 0o600);
|
||||
std::io::Write::write_all(&mut opts.open(path)?, &json)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Inner {
|
||||
fn record(&self, direction: &str, data: &[u8]) {
|
||||
let timestamp = crate::models::now();
|
||||
let ms = timestamp.timestamp_millis();
|
||||
self.append_history(HistoryEntry { timestamp, direction: direction.into(), data: data.to_vec() });
|
||||
(self.on_data)(data, direction, ms);
|
||||
}
|
||||
|
||||
fn append_history(&self, mut entry: HistoryEntry) {
|
||||
if entry.data.len() > MAX_HISTORY_BYTES {
|
||||
entry.data.drain(..entry.data.len() - MAX_HISTORY_BYTES);
|
||||
}
|
||||
let mut h = self.history.lock().unwrap();
|
||||
h.bytes += entry.data.len();
|
||||
h.entries.push_back(entry);
|
||||
while h.entries.len() > MAX_HISTORY_ENTRIES || h.bytes > MAX_HISTORY_BYTES {
|
||||
let Some(old) = h.entries.pop_front() else { break };
|
||||
h.bytes -= old.data.len();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_line_ending(text: &str, le: LineEnding) -> Vec<u8> {
|
||||
let suffix = le.bytes();
|
||||
let mut out = text.as_bytes().to_vec();
|
||||
if !suffix.is_empty() && !out.ends_with(suffix) {
|
||||
out.extend_from_slice(suffix);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Parses hex with optional whitespace and 0x/0X prefixes into bytes.
|
||||
pub fn parse_hex_string(s: &str) -> crate::Result<Vec<u8>> {
|
||||
let s = s.trim();
|
||||
if s.is_empty() {
|
||||
return Err("empty hex string".into());
|
||||
}
|
||||
let s = s.replace("0x", "").replace("0X", "");
|
||||
let mut digits = Vec::new();
|
||||
for c in s.chars().filter(|c| !c.is_whitespace()) {
|
||||
let d = c.to_digit(16).ok_or_else(|| format!("invalid character in hex string: {c:?}"))?;
|
||||
digits.push(d as u8);
|
||||
}
|
||||
if digits.len() % 2 != 0 {
|
||||
return Err("hex string has odd number of digits".into());
|
||||
}
|
||||
Ok(digits.chunks(2).map(|p| (p[0] << 4) | p[1]).collect())
|
||||
}
|
||||
|
||||
/// Space-separated lowercase hex, e.g. "48 65 6c 6c 6f".
|
||||
pub fn format_hex(data: &[u8]) -> String {
|
||||
data.iter().map(|b| format!("{b:02x}")).collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
/// Classic 16-bytes-per-line hex dump with offset, hex and ASCII columns.
|
||||
pub fn format_hex_dump(data: &[u8]) -> String {
|
||||
use std::fmt::Write;
|
||||
let mut sb = String::new();
|
||||
for (line, chunk) in data.chunks(16).enumerate() {
|
||||
let _ = write!(sb, "{:08x} ", line * 16);
|
||||
for i in 0..16 {
|
||||
if i == 8 {
|
||||
sb.push(' ');
|
||||
}
|
||||
match chunk.get(i) {
|
||||
Some(b) => {
|
||||
let _ = write!(sb, "{b:02x} ");
|
||||
}
|
||||
None => sb.push_str(" "),
|
||||
}
|
||||
}
|
||||
sb.push_str(" |");
|
||||
sb.extend(chunk.iter().map(|&b| if (0x20..=0x7e).contains(&b) { b as char } else { '.' }));
|
||||
sb.extend(std::iter::repeat_n(' ', 16 - chunk.len()));
|
||||
sb.push_str("|\n");
|
||||
}
|
||||
sb
|
||||
}
|
||||
|
||||
/// Processes C-style escapes: `\r \n \t \\ \0 \xNN` (and `\XNN`); anything else is an error.
|
||||
pub fn parse_escaped(s: &str) -> crate::Result<Vec<u8>> {
|
||||
let b = s.as_bytes();
|
||||
let mut out = Vec::with_capacity(b.len());
|
||||
let mut i = 0;
|
||||
while i < b.len() {
|
||||
if b[i] != b'\\' {
|
||||
out.push(b[i]);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let Some(&next) = b.get(i + 1) else {
|
||||
return Err(format!("trailing backslash at position {i}").into());
|
||||
};
|
||||
let simple = match next {
|
||||
b'r' => Some(b'\r'),
|
||||
b'n' => Some(b'\n'),
|
||||
b't' => Some(b'\t'),
|
||||
b'\\' => Some(b'\\'),
|
||||
b'0' => Some(0),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(v) = simple {
|
||||
out.push(v);
|
||||
i += 2;
|
||||
} else if next == b'x' || next == b'X' {
|
||||
if i + 3 >= b.len() {
|
||||
return Err(format!("incomplete \\x escape at position {i}").into());
|
||||
}
|
||||
let hex = &b[i + 2..i + 4];
|
||||
let v = std::str::from_utf8(hex)
|
||||
.ok()
|
||||
.filter(|h| h.bytes().all(|c| c.is_ascii_hexdigit()))
|
||||
.and_then(|h| u8::from_str_radix(h, 16).ok())
|
||||
.ok_or_else(|| format!("invalid \\x escape at position {i}: {}", String::from_utf8_lossy(hex)))?;
|
||||
out.push(v);
|
||||
i += 4;
|
||||
} else {
|
||||
let c = s[i + 1..].chars().next().unwrap_or('?');
|
||||
return Err(format!("unknown escape sequence \\{c} at position {i}").into());
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Bytes → display text: "ascii" (non-printables except \n \r \t become '.'), "iso-8859-1" /
|
||||
/// "latin1" / "latin-1", otherwise UTF-8 with invalid bytes replaced by U+FFFD.
|
||||
pub fn to_display_encoding(data: &[u8], encoding: &str) -> String {
|
||||
match encoding.trim().to_ascii_lowercase().as_str() {
|
||||
"ascii" => data
|
||||
.iter()
|
||||
.map(|&b| if (0x20..=0x7e).contains(&b) || matches!(b, b'\n' | b'\r' | b'\t') { b as char } else { '.' })
|
||||
.collect(),
|
||||
e if super::is_latin1(e) => super::latin1_to_utf8(data),
|
||||
_ => String::from_utf8_lossy(data).into_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpListener;
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
fn client() -> RawClient {
|
||||
RawClient::new(&Profile::new(crate::models::Protocol::Raw), Arc::new(|_, _, _| {}), Arc::new(|_| {}), Arc::new(|| {}))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_hex_string_cases() {
|
||||
let ok: [(&str, &[u8]); 5] = [
|
||||
("48656c6c6f", b"Hello"),
|
||||
("48 65 6c 6c 6f", b"Hello"),
|
||||
("0x48 0x65 0x6c 0x6c 0x6f", b"Hello"),
|
||||
("0000", &[0, 0]),
|
||||
("ff ff", &[0xff, 0xff]),
|
||||
];
|
||||
for (input, want) in ok {
|
||||
assert_eq!(parse_hex_string(input).unwrap(), want, "{input:?}");
|
||||
}
|
||||
for input in ["", "ZZZZ", "abc"] {
|
||||
assert!(parse_hex_string(input).is_err(), "{input:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_escaped_cases() {
|
||||
let ok: [(&str, &[u8]); 9] = [
|
||||
("hello", b"hello"),
|
||||
(r"\n", b"\n"),
|
||||
(r"\r\n", b"\r\n"),
|
||||
(r"\t", b"\t"),
|
||||
(r"\0", &[0]),
|
||||
(r"\xff", &[0xff]),
|
||||
(r"\\", b"\\"),
|
||||
(r"AT\r\n", b"AT\r\n"),
|
||||
(r"\x00", &[0]),
|
||||
];
|
||||
for (input, want) in ok {
|
||||
assert_eq!(parse_escaped(input).unwrap(), want, "{input:?}");
|
||||
}
|
||||
for input in [r"hello\", r"\q", r"\x0", r"\x+f"] {
|
||||
assert!(parse_escaped(input).is_err(), "{input:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_hex_dump_cases() {
|
||||
assert_eq!(format_hex_dump(&[]), "");
|
||||
let got = format_hex_dump(b"Hi");
|
||||
assert!(got.contains("48 69") && got.contains("|Hi"), "{got}");
|
||||
let got = format_hex_dump(b"ABCDEFGHIJKLMNOPQRST");
|
||||
let lines: Vec<_> = got.trim_end_matches('\n').split('\n').collect();
|
||||
assert_eq!(lines.len(), 2);
|
||||
assert!(lines[0].starts_with("00000000") && lines[1].starts_with("00000010"));
|
||||
assert_eq!(lines[0], "00000000 41 42 43 44 45 46 47 48 49 4a 4b 4c 4d 4e 4f 50 |ABCDEFGHIJKLMNOP|");
|
||||
assert_eq!(lines[1].len(), lines[0].len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_hex_cases() {
|
||||
assert_eq!(format_hex(&[]), "");
|
||||
assert_eq!(format_hex(&[0x42]), "42");
|
||||
assert_eq!(format_hex(b"Hello"), "48 65 6c 6c 6f");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_display_encoding_cases() {
|
||||
assert_eq!(to_display_encoding(b"Hello, World!", "utf-8"), "Hello, World!");
|
||||
assert_eq!(to_display_encoding(&[0x80, 0x41], "ascii"), ".A");
|
||||
assert_eq!(to_display_encoding(&[0xe9], "latin-1"), "é");
|
||||
assert_eq!(to_display_encoding(&[0xe9], "iso-8859-1"), "é");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_is_capped_by_entry_count() {
|
||||
let c = client();
|
||||
for i in 0..MAX_HISTORY_ENTRIES + 25 {
|
||||
c.inner.record(RECV, &[i as u8]);
|
||||
}
|
||||
assert_eq!(c.history().len(), MAX_HISTORY_ENTRIES);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_is_capped_by_bytes() {
|
||||
let c = client();
|
||||
let chunk = vec![b'a'; 1024];
|
||||
for _ in 0..MAX_HISTORY_BYTES / chunk.len() + 10 {
|
||||
c.inner.record(RECV, &chunk);
|
||||
}
|
||||
let total: usize = c.history().iter().map(|e| e.data.len()).sum();
|
||||
assert!(total <= MAX_HISTORY_BYTES);
|
||||
c.clear_history();
|
||||
assert!(c.history().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_ending_is_appended_once() {
|
||||
assert_eq!(apply_line_ending("AT", LineEnding::Crlf), b"AT\r\n");
|
||||
assert_eq!(apply_line_ending("AT\r\n", LineEnding::Crlf), b"AT\r\n");
|
||||
assert_eq!(apply_line_ending("AT", LineEnding::Cr), b"AT\r");
|
||||
assert_eq!(apply_line_ending("AT", LineEnding::None), b"AT");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_receive_history_and_capture() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let profile = Profile { host: "127.0.0.1".into(), port: listener.local_addr().unwrap().port() as i64, ..Profile::new(crate::models::Protocol::Raw) };
|
||||
let (tx, rx) = mpsc::channel::<(Vec<u8>, String, i64)>();
|
||||
let (ctx, crx) = mpsc::channel::<()>();
|
||||
let c = RawClient::new(
|
||||
&profile,
|
||||
Arc::new(move |d, dir, ts| tx.send((d.to_vec(), dir.to_string(), ts)).unwrap()),
|
||||
Arc::new(|_| {}),
|
||||
Arc::new(move || ctx.send(()).unwrap()),
|
||||
);
|
||||
assert!(c.write(b"x").is_err());
|
||||
c.connect().unwrap();
|
||||
let (mut srv, _) = listener.accept().unwrap();
|
||||
srv.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
|
||||
c.write_text("AT").unwrap();
|
||||
c.write_hex("01 ff").unwrap();
|
||||
c.write_escaped(r"\x00z").unwrap();
|
||||
assert!(c.write_hex("zz").unwrap_err().to_string().starts_with("invalid hex input"));
|
||||
let mut buf = [0u8; 8];
|
||||
srv.read_exact(&mut buf).unwrap();
|
||||
assert_eq!(&buf, b"AT\r\n\x01\xff\x00z");
|
||||
|
||||
srv.write_all(b"OK").unwrap();
|
||||
let mut events = Vec::new();
|
||||
while events.iter().map(|(d, dir, _): &(Vec<u8>, String, i64)| if dir == RECV { d.len() } else { 0 }).sum::<usize>() < 2 {
|
||||
events.push(rx.recv_timeout(Duration::from_secs(5)).unwrap());
|
||||
}
|
||||
assert_eq!(events[0].1, SEND);
|
||||
assert!(events[0].2 > 1_600_000_000_000);
|
||||
let hist = c.history();
|
||||
assert_eq!(hist.iter().filter(|e| e.direction == SEND).count(), 3);
|
||||
assert_eq!(hist.last().unwrap().direction, RECV);
|
||||
|
||||
let path = std::env::temp_dir().join(format!("nexus-raw-capture-{}.json", std::process::id()));
|
||||
c.save_capture(&path).unwrap();
|
||||
let v: serde_json::Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
|
||||
std::fs::remove_file(&path).unwrap();
|
||||
assert_eq!(v[0]["direction"], "send");
|
||||
assert_eq!(v[0]["data"], "QVQNCg=="); // "AT\r\n"
|
||||
assert!(v[0]["timestamp"].as_str().unwrap().contains('T'));
|
||||
|
||||
c.close();
|
||||
c.close();
|
||||
crx.recv_timeout(Duration::from_secs(1)).unwrap();
|
||||
assert!(crx.recv_timeout(Duration::from_millis(100)).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
//! Serial client (new protocol, no Go original) on the `serialport` crate. The device path is
|
||||
//! `Profile::host`, line settings come from `Profile::serial`. Same conventions as the other
|
||||
//! clients: blocking `connect`, an own reader thread, `on_close` exactly once (unplug included).
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use serialport::{DataBits, FlowControl, Parity, SerialPort, StopBits};
|
||||
|
||||
use super::Callbacks;
|
||||
use crate::models::{self, LineEnding, Profile, SerialConfig};
|
||||
|
||||
/// The reader polls this often, so `close` stops it within this long.
|
||||
const READ_POLL: Duration = Duration::from_millis(100);
|
||||
const WRITE_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const BREAK_DURATION: Duration = Duration::from_millis(250);
|
||||
|
||||
type OpenPort = (Box<dyn SerialPort>, Arc<AtomicBool>);
|
||||
|
||||
pub struct SerialClient {
|
||||
inner: Arc<Inner>,
|
||||
}
|
||||
|
||||
struct Inner {
|
||||
path: String,
|
||||
cfg: SerialConfig,
|
||||
cb: Callbacks,
|
||||
/// The writer handle plus this connection's stop flag (the reader owns a clone of the port).
|
||||
port: Mutex<Option<OpenPort>>,
|
||||
}
|
||||
|
||||
/// Device paths of the serial ports on this machine (empty if enumeration fails).
|
||||
pub fn list_ports() -> Vec<String> {
|
||||
serialport::available_ports().map(|v| v.into_iter().map(|p| p.port_name).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
impl SerialClient {
|
||||
pub fn new(profile: &Profile, cb: Callbacks) -> SerialClient {
|
||||
SerialClient {
|
||||
inner: Arc::new(Inner {
|
||||
path: profile.host.trim().to_string(),
|
||||
cfg: profile.serial.clone().unwrap_or_default(),
|
||||
cb,
|
||||
port: Mutex::new(None),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens and configures the port, applies DTR/RTS, and starts the reader thread.
|
||||
pub fn connect(&self) -> crate::Result<()> {
|
||||
let inner = &self.inner;
|
||||
if inner.port.lock().unwrap().is_some() {
|
||||
return Err("already connected".into());
|
||||
}
|
||||
if inner.path.is_empty() {
|
||||
return Err("no serial device specified".into());
|
||||
}
|
||||
let c = &inner.cfg;
|
||||
let mut port = serialport::new(&inner.path, c.baud_rate)
|
||||
.data_bits(match c.data_bits {
|
||||
5 => DataBits::Five,
|
||||
6 => DataBits::Six,
|
||||
7 => DataBits::Seven,
|
||||
_ => DataBits::Eight,
|
||||
})
|
||||
.parity(match c.parity {
|
||||
models::Parity::None => Parity::None,
|
||||
models::Parity::Odd => Parity::Odd,
|
||||
models::Parity::Even => Parity::Even,
|
||||
})
|
||||
.stop_bits(if c.stop_bits == 2 { StopBits::Two } else { StopBits::One })
|
||||
.flow_control(match c.flow_control {
|
||||
models::FlowControl::None => FlowControl::None,
|
||||
models::FlowControl::Xonxoff => FlowControl::Software,
|
||||
models::FlowControl::Rtscts => FlowControl::Hardware,
|
||||
})
|
||||
.timeout(WRITE_TIMEOUT)
|
||||
.open()
|
||||
.map_err(|e| open_error(&inner.path, &e))?;
|
||||
// Opening asserts both lines on most OSes, so set them either way. Ptys and some adapters
|
||||
// have no modem lines; that's not fatal.
|
||||
let _ = port.write_data_terminal_ready(c.dtr_on_connect);
|
||||
let _ = port.write_request_to_send(c.rts_on_connect);
|
||||
|
||||
let mut reader = port.try_clone()?;
|
||||
reader.set_timeout(READ_POLL)?;
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
{
|
||||
let mut guard = inner.port.lock().unwrap();
|
||||
if guard.is_some() {
|
||||
return Err("already connected".into());
|
||||
}
|
||||
*guard = Some((port, stop.clone()));
|
||||
}
|
||||
let me = inner.clone();
|
||||
std::thread::spawn(move || me.read_loop(reader, stop));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sends user input: the Enter key's `\r` becomes the configured line ending, text is encoded,
|
||||
/// and with local echo on the input is fed back through `on_data` (Enter shown as CRLF).
|
||||
pub fn write(&self, data: &[u8]) -> crate::Result<()> {
|
||||
let out = translate_enter(data, self.inner.cfg.line_ending);
|
||||
let out = if super::is_latin1(&self.inner.cfg.encoding) { super::utf8_to_latin1(&out) } else { out };
|
||||
self.with_port(|p| Ok(p.write_all(&out)?))?;
|
||||
if self.inner.cfg.local_echo {
|
||||
(self.inner.cb.on_data)(&translate_enter(data, LineEnding::Crlf));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_lines(&self, dtr: bool, rts: bool) -> crate::Result<()> {
|
||||
self.with_port(|p| {
|
||||
p.write_data_terminal_ready(dtr)?;
|
||||
p.write_request_to_send(rts)?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Holds the line in break for ~250 ms (blocks the caller, and other writes, meanwhile).
|
||||
pub fn send_break(&self) -> crate::Result<()> {
|
||||
self.with_port(|p| {
|
||||
p.set_break()?;
|
||||
std::thread::sleep(BREAK_DURATION);
|
||||
p.clear_break()?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn close(&self) {
|
||||
self.inner.close();
|
||||
}
|
||||
|
||||
pub fn is_connected(&self) -> bool {
|
||||
self.inner.port.lock().unwrap().is_some()
|
||||
}
|
||||
|
||||
fn with_port(&self, f: impl FnOnce(&mut Box<dyn SerialPort>) -> crate::Result<()>) -> crate::Result<()> {
|
||||
let mut guard = self.inner.port.lock().unwrap();
|
||||
let (port, _) = guard.as_mut().ok_or("not connected")?;
|
||||
f(port)
|
||||
}
|
||||
}
|
||||
|
||||
impl Inner {
|
||||
fn read_loop(&self, mut reader: Box<dyn SerialPort>, stop: Arc<AtomicBool>) {
|
||||
let mut buf = [0u8; 4096];
|
||||
let latin1 = super::is_latin1(&self.cfg.encoding);
|
||||
while !stop.load(Ordering::SeqCst) {
|
||||
let err = match reader.read(&mut buf) {
|
||||
Ok(0) => "device disconnected".to_string(),
|
||||
Ok(n) if latin1 => {
|
||||
(self.cb.on_data)(super::latin1_to_utf8(&buf[..n]).as_bytes());
|
||||
continue;
|
||||
}
|
||||
Ok(n) => {
|
||||
(self.cb.on_data)(&buf[..n]);
|
||||
continue;
|
||||
}
|
||||
Err(e) if matches!(e.kind(), std::io::ErrorKind::TimedOut | std::io::ErrorKind::Interrupted) => continue,
|
||||
Err(e) => format!("device disconnected: {e}"),
|
||||
};
|
||||
if !stop.load(Ordering::SeqCst) {
|
||||
(self.cb.on_error)(format!("read error: {err}"));
|
||||
self.close();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
fn close(&self) {
|
||||
let taken = self.port.lock().unwrap().take();
|
||||
if let Some((port, stop)) = taken {
|
||||
stop.store(true, Ordering::SeqCst);
|
||||
drop(port);
|
||||
(self.cb.on_close)();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Replaces each `\r` (the Enter key) with `le`; `None` leaves it alone.
|
||||
fn translate_enter(data: &[u8], le: LineEnding) -> Vec<u8> {
|
||||
if le == LineEnding::None {
|
||||
return data.to_vec();
|
||||
}
|
||||
let mut out = Vec::with_capacity(data.len() + 4);
|
||||
for &b in data {
|
||||
if b == b'\r' { out.extend_from_slice(le.bytes()) } else { out.push(b) }
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn open_error(path: &str, e: &serialport::Error) -> crate::Error {
|
||||
let desc = e.description.to_lowercase();
|
||||
let busy = ["busy", "lock", "access is denied", "in use"].iter().any(|s| desc.contains(s));
|
||||
let hint = match e.kind {
|
||||
serialport::ErrorKind::NoDevice if busy => " (the port is in use by another program)",
|
||||
serialport::ErrorKind::Io(std::io::ErrorKind::PermissionDenied) => {
|
||||
" (permission denied: add your user to the dialout (Linux) / uucp group, then log in again)"
|
||||
}
|
||||
_ => "",
|
||||
};
|
||||
format!("cannot open serial port {path}: {}{hint}", e.description).into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::mpsc;
|
||||
|
||||
#[test]
|
||||
fn enter_translation() {
|
||||
assert_eq!(translate_enter(b"AT\r", LineEnding::Crlf), b"AT\r\n");
|
||||
assert_eq!(translate_enter(b"AT\r", LineEnding::Lf), b"AT\n");
|
||||
assert_eq!(translate_enter(b"AT\r", LineEnding::Cr), b"AT\r");
|
||||
assert_eq!(translate_enter(b"AT\r", LineEnding::None), b"AT\r");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_hints() {
|
||||
let perm = serialport::Error::new(serialport::ErrorKind::Io(std::io::ErrorKind::PermissionDenied), "Permission denied");
|
||||
assert!(open_error("/dev/ttyUSB0", &perm).to_string().contains("dialout"));
|
||||
let busy = serialport::Error::new(serialport::ErrorKind::NoDevice, "Device or resource busy");
|
||||
assert!(open_error("/dev/ttyUSB0", &busy).to_string().contains("in use by another program"));
|
||||
let missing = serialport::Error::new(serialport::ErrorKind::Io(std::io::ErrorKind::NotFound), "No such file or directory");
|
||||
assert_eq!(open_error("/dev/x", &missing).to_string(), "cannot open serial port /dev/x: No such file or directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_connected() {
|
||||
let c = SerialClient::new(&Profile::new(models::Protocol::Serial), Callbacks::noop());
|
||||
assert!(c.write(b"x").is_err());
|
||||
assert_eq!(c.connect().unwrap_err().to_string(), "no serial device specified");
|
||||
let _ = list_ports();
|
||||
}
|
||||
|
||||
/// A pty pair: the client opens the slave as its "device", the test plays the far end on the master.
|
||||
#[cfg(target_os = "linux")]
|
||||
mod pty {
|
||||
use super::*;
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::os::fd::{AsRawFd, FromRawFd};
|
||||
|
||||
struct Pty {
|
||||
master: File,
|
||||
slave: File,
|
||||
path: String,
|
||||
}
|
||||
|
||||
fn open_pty() -> Pty {
|
||||
let (mut m, mut s) = (0, 0);
|
||||
let mut name = [0 as libc::c_char; 128];
|
||||
let rc = unsafe { libc::openpty(&mut m, &mut s, name.as_mut_ptr(), std::ptr::null(), std::ptr::null()) };
|
||||
assert_eq!(rc, 0);
|
||||
let path = unsafe { std::ffi::CStr::from_ptr(name.as_ptr()) }.to_string_lossy().into_owned();
|
||||
Pty { master: unsafe { File::from_raw_fd(m) }, slave: unsafe { File::from_raw_fd(s) }, path }
|
||||
}
|
||||
|
||||
/// Reads from the master until something arrives or `timeout` passes.
|
||||
fn read_master(master: &File, timeout: Duration) -> Vec<u8> {
|
||||
let mut pfd = libc::pollfd { fd: master.as_raw_fd(), events: libc::POLLIN, revents: 0 };
|
||||
if unsafe { libc::poll(&mut pfd, 1, timeout.as_millis() as i32) } != 1 {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut buf = [0u8; 256];
|
||||
let n = std::io::Read::read(&mut &*master, &mut buf).unwrap();
|
||||
buf[..n].to_vec()
|
||||
}
|
||||
|
||||
fn master_n(master: &File, n: usize) -> Vec<u8> {
|
||||
let mut got = Vec::new();
|
||||
while got.len() < n {
|
||||
let chunk = read_master(master, Duration::from_secs(5));
|
||||
assert!(!chunk.is_empty(), "timed out");
|
||||
got.extend(chunk);
|
||||
}
|
||||
got
|
||||
}
|
||||
|
||||
fn recv_n(rx: &mpsc::Receiver<Vec<u8>>, n: usize) -> Vec<u8> {
|
||||
let mut got = Vec::new();
|
||||
while got.len() < n {
|
||||
got.extend(rx.recv_timeout(Duration::from_secs(5)).expect("timed out"));
|
||||
}
|
||||
got
|
||||
}
|
||||
|
||||
fn client(pty: &Pty, cfg: SerialConfig) -> (SerialClient, mpsc::Receiver<Vec<u8>>, mpsc::Receiver<String>, mpsc::Receiver<()>) {
|
||||
let (dtx, drx) = mpsc::channel();
|
||||
let (etx, erx) = mpsc::channel();
|
||||
let (ctx, crx) = mpsc::channel();
|
||||
let cb = Callbacks {
|
||||
on_data: Arc::new(move |d| { let _ = dtx.send(d.to_vec()); }),
|
||||
on_error: Arc::new(move |e| { let _ = etx.send(e); }),
|
||||
on_close: Arc::new(move || { let _ = ctx.send(()); }),
|
||||
};
|
||||
let profile = Profile { host: pty.path.clone(), serial: Some(cfg), ..Profile::new(models::Protocol::Serial) };
|
||||
(SerialClient::new(&profile, cb), drx, erx, crx)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_echo_line_endings_and_close() {
|
||||
let mut pty = open_pty();
|
||||
let cfg = SerialConfig { local_echo: true, line_ending: LineEnding::Crlf, ..Default::default() };
|
||||
let (c, data, errors, closed) = client(&pty, cfg);
|
||||
c.connect().unwrap();
|
||||
assert!(c.connect().is_err());
|
||||
|
||||
c.write(b"AT\r").unwrap();
|
||||
assert_eq!(master_n(&pty.master, 4), b"AT\r\n");
|
||||
assert_eq!(recv_n(&data, 4), b"AT\r\n"); // local echo
|
||||
|
||||
pty.master.write_all(b"OK\r\n").unwrap();
|
||||
assert_eq!(recv_n(&data, 4), b"OK\r\n");
|
||||
|
||||
c.close();
|
||||
c.close();
|
||||
closed.recv_timeout(Duration::from_secs(1)).unwrap();
|
||||
assert!(closed.recv_timeout(Duration::from_millis(300)).is_err());
|
||||
assert!(errors.try_recv().is_err());
|
||||
assert!(c.write(b"x").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latin1_encoding() {
|
||||
let mut pty = open_pty();
|
||||
let cfg = SerialConfig { encoding: "latin1".into(), line_ending: LineEnding::Lf, ..Default::default() };
|
||||
let (c, data, _e, _c) = client(&pty, cfg);
|
||||
c.connect().unwrap();
|
||||
c.write("é\r".as_bytes()).unwrap();
|
||||
assert_eq!(master_n(&pty.master, 2), [0xe9, b'\n']);
|
||||
pty.master.write_all(&[0xe9]).unwrap();
|
||||
assert_eq!(recv_n(&data, 2), "é".as_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xon_xoff_flow_control() {
|
||||
let mut pty = open_pty();
|
||||
let cfg = SerialConfig { flow_control: models::FlowControl::Xonxoff, line_ending: LineEnding::None, ..Default::default() };
|
||||
let (c, _d, _e, _c) = client(&pty, cfg);
|
||||
c.connect().unwrap();
|
||||
pty.master.write_all(&[0x13]).unwrap(); // XOFF
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
let c = Arc::new(c);
|
||||
let w = c.clone();
|
||||
let writer = std::thread::spawn(move || w.write(b"x"));
|
||||
assert!(read_master(&pty.master, Duration::from_millis(300)).is_empty(), "output not paused by XOFF");
|
||||
pty.master.write_all(&[0x11]).unwrap(); // XON
|
||||
assert_eq!(master_n(&pty.master, 1), b"x");
|
||||
writer.join().unwrap().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unplug_reports_error_and_closes() {
|
||||
let pty = open_pty();
|
||||
let (c, _d, errors, closed) = client(&pty, SerialConfig::default());
|
||||
c.connect().unwrap();
|
||||
drop(pty.master);
|
||||
drop(pty.slave);
|
||||
let err = errors.recv_timeout(Duration::from_secs(5)).unwrap();
|
||||
assert!(err.starts_with("read error: device disconnected"), "{err}");
|
||||
closed.recv_timeout(Duration::from_secs(1)).unwrap();
|
||||
assert!(!c.is_connected());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
//! Telnet client (port of Go `internal/telnetclient`): RFC 854/855 option negotiation (ECHO, SGA,
|
||||
//! TTYPE, NAWS; everything else refused), IAC escaping, line-ending conversion, ISO 8859-1 output
|
||||
//! encoding and BRK. Go read but never acted on `lineMode`, and neither do we.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use super::{Callbacks, DataFn, DebugFn, TcpConn};
|
||||
use crate::models::{LineEnding, Profile, TelnetConfig};
|
||||
|
||||
pub const IAC: u8 = 255;
|
||||
pub const DONT: u8 = 254;
|
||||
pub const DO: u8 = 253;
|
||||
pub const WONT: u8 = 252;
|
||||
pub const WILL: u8 = 251;
|
||||
pub const SB: u8 = 250;
|
||||
pub const SE: u8 = 240;
|
||||
pub const BRK: u8 = 243;
|
||||
|
||||
pub const OPT_ECHO: u8 = 1;
|
||||
pub const OPT_SGA: u8 = 3;
|
||||
pub const OPT_TERM_TYPE: u8 = 24;
|
||||
pub const OPT_NAWS: u8 = 31;
|
||||
|
||||
const SUB_IS: u8 = 0;
|
||||
const SUB_SEND: u8 = 1;
|
||||
|
||||
/// An unterminated IAC sequence longer than this is dropped rather than buffered forever.
|
||||
const MAX_PENDING: usize = 8192;
|
||||
|
||||
pub struct TelnetClient {
|
||||
inner: Arc<Inner>,
|
||||
}
|
||||
|
||||
struct Inner {
|
||||
conn: Arc<TcpConn>,
|
||||
host: String,
|
||||
port: i64,
|
||||
connect_timeout: i64,
|
||||
cfg: TelnetConfig,
|
||||
encoding: String,
|
||||
on_data: DataFn,
|
||||
on_debug: Option<DebugFn>,
|
||||
state: Mutex<State>,
|
||||
}
|
||||
|
||||
struct State {
|
||||
local_echo: bool,
|
||||
cols: u16,
|
||||
rows: u16,
|
||||
}
|
||||
|
||||
impl TelnetClient {
|
||||
/// `on_debug` receives negotiation messages ("received DO 31", "sending WILL 31", …), always,
|
||||
/// as in Go; the profile's `negotiationDebug` is for the caller to honour.
|
||||
pub fn new(profile: &Profile, cb: Callbacks, on_debug: Option<DebugFn>) -> TelnetClient {
|
||||
let cfg = profile.telnet.clone().unwrap_or_default();
|
||||
let encoding = if cfg.encoding.is_empty() { "utf-8".to_string() } else { cfg.encoding.clone() };
|
||||
let state = State { local_echo: cfg.local_echo, cols: 80, rows: 24 };
|
||||
TelnetClient {
|
||||
inner: Arc::new(Inner {
|
||||
conn: Arc::new(TcpConn::new(cb.on_error, cb.on_close)),
|
||||
host: profile.host.clone(),
|
||||
port: if profile.port == 0 { 23 } else { profile.port },
|
||||
connect_timeout: profile.connect_timeout,
|
||||
cfg,
|
||||
encoding,
|
||||
on_data: cb.on_data,
|
||||
on_debug,
|
||||
state: Mutex::new(state),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn connect(&self) -> crate::Result<()> {
|
||||
let inner = &self.inner;
|
||||
if inner.conn.is_connected() {
|
||||
return Err("already connected".into());
|
||||
}
|
||||
let (addr, res) = super::dial(&inner.host, inner.port, inner.connect_timeout);
|
||||
let stream = res.map_err(|e| format!("Telnet connection to {addr} failed: {e}"))?;
|
||||
let me = inner.clone();
|
||||
let mut pending = Vec::new();
|
||||
inner.conn.connect(stream, move |chunk| me.process(&mut pending, chunk))
|
||||
}
|
||||
|
||||
/// Sends user input: line endings converted, encoded, literal 0xFF doubled.
|
||||
pub fn write(&self, data: &[u8]) -> crate::Result<()> {
|
||||
let converted = convert_line_ending(data, self.inner.cfg.line_ending);
|
||||
let encoded = encode_outgoing(&converted, &self.inner.encoding);
|
||||
self.inner.conn.write(&escape_iac(&encoded))
|
||||
}
|
||||
|
||||
/// Records the size and sends a NAWS update (sent even if the server never asked for NAWS, as in Go).
|
||||
pub fn resize(&self, cols: u16, rows: u16) -> crate::Result<()> {
|
||||
if !self.inner.conn.is_connected() {
|
||||
return Err("not connected".into());
|
||||
}
|
||||
{
|
||||
let mut st = self.inner.state.lock().unwrap();
|
||||
st.cols = cols;
|
||||
st.rows = rows;
|
||||
}
|
||||
self.inner.send_naws(cols, rows)
|
||||
}
|
||||
|
||||
/// Sends IAC BRK.
|
||||
pub fn send_break(&self) -> crate::Result<()> {
|
||||
self.inner.conn.write(&[IAC, BRK])
|
||||
}
|
||||
|
||||
pub fn close(&self) {
|
||||
self.inner.conn.close();
|
||||
}
|
||||
|
||||
pub fn is_connected(&self) -> bool {
|
||||
self.inner.conn.is_connected()
|
||||
}
|
||||
|
||||
/// The negotiated local-echo state: starts at the profile's `localEcho`, cleared by the
|
||||
/// server's WILL ECHO and set by WONT ECHO. Like Go, the client itself never echoes.
|
||||
pub fn local_echo(&self) -> bool {
|
||||
self.inner.state.lock().unwrap().local_echo
|
||||
}
|
||||
}
|
||||
|
||||
impl Inner {
|
||||
/// Splits a received chunk into data and IAC sequences. Unlike Go, a sequence cut off at the
|
||||
/// end of a read is kept for the next read instead of dropped.
|
||||
fn process(&self, pending: &mut Vec<u8>, chunk: &[u8]) {
|
||||
pending.extend_from_slice(chunk);
|
||||
let mut data = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < pending.len() {
|
||||
if pending[i] != IAC {
|
||||
data.push(pending[i]);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if !data.is_empty() {
|
||||
(self.on_data)(&data);
|
||||
data.clear();
|
||||
}
|
||||
let consumed = self.handle_iac(&pending[i..]);
|
||||
if consumed == 0 {
|
||||
break;
|
||||
}
|
||||
i += consumed;
|
||||
}
|
||||
if !data.is_empty() {
|
||||
(self.on_data)(&data);
|
||||
}
|
||||
pending.drain(..i);
|
||||
if pending.len() > MAX_PENDING {
|
||||
pending.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles the IAC sequence at `data[0]`; returns bytes consumed, 0 if incomplete.
|
||||
fn handle_iac(&self, data: &[u8]) -> usize {
|
||||
if data.len() < 2 {
|
||||
return 0;
|
||||
}
|
||||
match data[1] {
|
||||
IAC => {
|
||||
(self.on_data)(&[IAC]);
|
||||
2
|
||||
}
|
||||
DO | DONT | WILL | WONT => {
|
||||
let Some(&opt) = data.get(2) else { return 0 };
|
||||
match data[1] {
|
||||
DO => self.handle_do(opt),
|
||||
DONT => self.handle_dont(opt),
|
||||
WILL => self.handle_will(opt),
|
||||
_ => self.handle_wont(opt),
|
||||
}
|
||||
3
|
||||
}
|
||||
SB => match (2..data.len().saturating_sub(1)).find(|&i| data[i] == IAC && data[i + 1] == SE) {
|
||||
Some(end) => {
|
||||
self.process_subnegotiation(&data[2..end]);
|
||||
end + 2
|
||||
}
|
||||
None => 0,
|
||||
},
|
||||
cmd => {
|
||||
self.debug(format!("received IAC command: {cmd}"));
|
||||
2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_do(&self, opt: u8) {
|
||||
self.debug(format!("received DO {opt}"));
|
||||
match opt {
|
||||
OPT_TERM_TYPE | OPT_SGA => self.negotiate(WILL, opt),
|
||||
OPT_NAWS => {
|
||||
self.negotiate(WILL, opt);
|
||||
let (cols, rows) = {
|
||||
let st = self.state.lock().unwrap();
|
||||
(st.cols, st.rows)
|
||||
};
|
||||
let _ = self.send_naws(cols, rows);
|
||||
}
|
||||
_ => self.negotiate(WONT, opt),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_dont(&self, opt: u8) {
|
||||
self.debug(format!("received DONT {opt}"));
|
||||
self.negotiate(WONT, opt);
|
||||
}
|
||||
|
||||
fn handle_will(&self, opt: u8) {
|
||||
self.debug(format!("received WILL {opt}"));
|
||||
match opt {
|
||||
OPT_ECHO => {
|
||||
self.negotiate(DO, opt);
|
||||
self.state.lock().unwrap().local_echo = false;
|
||||
}
|
||||
OPT_SGA => self.negotiate(DO, opt),
|
||||
_ => self.negotiate(DONT, opt),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_wont(&self, opt: u8) {
|
||||
self.debug(format!("received WONT {opt}"));
|
||||
if opt == OPT_ECHO {
|
||||
self.state.lock().unwrap().local_echo = true;
|
||||
}
|
||||
self.negotiate(DONT, opt);
|
||||
}
|
||||
|
||||
fn process_subnegotiation(&self, payload: &[u8]) {
|
||||
let Some(&opt) = payload.first() else { return };
|
||||
self.debug(format!("sub-negotiation for option {opt}"));
|
||||
if opt == OPT_TERM_TYPE && payload.get(1) == Some(&SUB_SEND) {
|
||||
let tt = if self.cfg.terminal_type.is_empty() { "xterm-256color" } else { &self.cfg.terminal_type };
|
||||
self.debug(format!("sending terminal type: {tt}"));
|
||||
let mut resp = vec![IAC, SB, OPT_TERM_TYPE, SUB_IS];
|
||||
resp.extend_from_slice(tt.to_uppercase().as_bytes());
|
||||
resp.extend_from_slice(&[IAC, SE]);
|
||||
let _ = self.conn.write(&resp);
|
||||
}
|
||||
}
|
||||
|
||||
fn negotiate(&self, cmd: u8, opt: u8) {
|
||||
self.debug(format!("sending {} {opt}", command_name(cmd)));
|
||||
let _ = self.conn.write(&[IAC, cmd, opt]);
|
||||
}
|
||||
|
||||
fn send_naws(&self, cols: u16, rows: u16) -> crate::Result<()> {
|
||||
self.debug(format!("sending NAWS: {cols}x{rows}"));
|
||||
let [c0, c1] = cols.to_be_bytes();
|
||||
let [r0, r1] = rows.to_be_bytes();
|
||||
self.conn.write(&[IAC, SB, OPT_NAWS, c0, c1, r0, r1, IAC, SE])
|
||||
}
|
||||
|
||||
fn debug(&self, msg: String) {
|
||||
if let Some(f) = &self.on_debug {
|
||||
f(msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn command_name(cmd: u8) -> String {
|
||||
match cmd {
|
||||
WILL => "WILL".into(),
|
||||
WONT => "WONT".into(),
|
||||
DO => "DO".into(),
|
||||
DONT => "DONT".into(),
|
||||
_ => format!("CMD({cmd})"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalises CRLF/CR/LF to the configured ending (`None` leaves data untouched).
|
||||
fn convert_line_ending(data: &[u8], le: LineEnding) -> Vec<u8> {
|
||||
if le == LineEnding::None {
|
||||
return data.to_vec();
|
||||
}
|
||||
let mut out = Vec::with_capacity(data.len() + 8);
|
||||
let mut i = 0;
|
||||
while i < data.len() {
|
||||
match data[i] {
|
||||
b'\r' => {
|
||||
if data.get(i + 1) == Some(&b'\n') {
|
||||
i += 1;
|
||||
}
|
||||
out.extend_from_slice(le.bytes());
|
||||
}
|
||||
b'\n' => out.extend_from_slice(le.bytes()),
|
||||
b => out.push(b),
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Doubles literal 0xFF bytes.
|
||||
fn escape_iac(data: &[u8]) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(data.len());
|
||||
for &b in data {
|
||||
out.push(b);
|
||||
if b == IAC {
|
||||
out.push(IAC);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// UTF-8 → ISO 8859-1 for the Latin-1 encodings; everything else is sent as-is. Unlike Go this
|
||||
/// runs before IAC escaping, so a Latin-1 'ÿ' (0xFF) is escaped correctly.
|
||||
fn encode_outgoing(data: &[u8], encoding: &str) -> Vec<u8> {
|
||||
if super::is_latin1(encoding) { super::utf8_to_latin1(data) } else { data.to_vec() }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpListener;
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn line_endings() {
|
||||
let cases = [
|
||||
(LineEnding::Crlf, "a\nb\r\nc\rd", "a\r\nb\r\nc\r\nd"),
|
||||
(LineEnding::Lf, "a\r\nb\rc\n", "a\nb\nc\n"),
|
||||
(LineEnding::Cr, "a\r\nb\n", "a\rb\r"),
|
||||
(LineEnding::None, "a\r\nb\n", "a\r\nb\n"),
|
||||
];
|
||||
for (le, input, want) in cases {
|
||||
assert_eq!(convert_line_ending(input.as_bytes(), le), want.as_bytes(), "{le:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iac_escaping_and_latin1() {
|
||||
assert_eq!(escape_iac(&[1, 0xff, 2]), vec![1, 0xff, 0xff, 2]);
|
||||
assert_eq!(encode_outgoing("é".as_bytes(), "Latin-1"), vec![0xe9]);
|
||||
assert_eq!(encode_outgoing("€".as_bytes(), "latin1"), "€".as_bytes()); // unencodable: unchanged
|
||||
assert_eq!(encode_outgoing("é".as_bytes(), "utf-8"), "é".as_bytes());
|
||||
}
|
||||
|
||||
fn read_n(s: &mut std::net::TcpStream, n: usize) -> Vec<u8> {
|
||||
let mut buf = vec![0; n];
|
||||
s.read_exact(&mut buf).unwrap();
|
||||
buf
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negotiates_and_frames_data() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let profile = Profile { host: "127.0.0.1".into(), port: listener.local_addr().unwrap().port() as i64, ..Profile::new(crate::models::Protocol::Telnet) };
|
||||
let (dtx, drx) = mpsc::channel::<Vec<u8>>();
|
||||
let (etx, erx) = mpsc::channel::<String>();
|
||||
let (ctx, crx) = mpsc::channel::<()>();
|
||||
let debug = Arc::new(Mutex::new(Vec::<String>::new()));
|
||||
let dbg = debug.clone();
|
||||
let cb = Callbacks {
|
||||
on_data: Arc::new(move |d| dtx.send(d.to_vec()).unwrap()),
|
||||
on_error: Arc::new(move |e| etx.send(e).unwrap()),
|
||||
on_close: Arc::new(move || ctx.send(()).unwrap()),
|
||||
};
|
||||
let client = TelnetClient::new(&profile, cb, Some(Arc::new(move |m| dbg.lock().unwrap().push(m))));
|
||||
client.connect().unwrap();
|
||||
let (mut srv, _) = listener.accept().unwrap();
|
||||
srv.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
|
||||
srv.write_all(&[IAC, DO, OPT_TERM_TYPE, IAC, DO, OPT_NAWS, IAC, WILL, OPT_ECHO, IAC, WILL, OPT_SGA, IAC, DO, 99])
|
||||
.unwrap();
|
||||
let mut want = vec![IAC, WILL, OPT_TERM_TYPE, IAC, WILL, OPT_NAWS, IAC, SB, OPT_NAWS, 0, 80, 0, 24, IAC, SE];
|
||||
want.extend_from_slice(&[IAC, DO, OPT_ECHO, IAC, DO, OPT_SGA, IAC, WONT, 99]);
|
||||
assert_eq!(read_n(&mut srv, want.len()), want);
|
||||
assert!(!client.local_echo());
|
||||
|
||||
// Data with an escaped 0xFF, then a TTYPE SEND split across two writes.
|
||||
srv.write_all(b"hi").unwrap();
|
||||
srv.write_all(&[IAC, IAC, IAC, SB, OPT_TERM_TYPE]).unwrap();
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
srv.write_all(&[SUB_SEND, IAC, SE]).unwrap();
|
||||
let mut want = vec![IAC, SB, OPT_TERM_TYPE, SUB_IS];
|
||||
want.extend_from_slice(b"XTERM-256COLOR");
|
||||
want.extend_from_slice(&[IAC, SE]);
|
||||
assert_eq!(read_n(&mut srv, want.len()), want);
|
||||
let mut got = Vec::new();
|
||||
while got.len() < 3 {
|
||||
got.extend(drx.recv_timeout(Duration::from_secs(5)).unwrap());
|
||||
}
|
||||
assert_eq!(got, b"hi\xff");
|
||||
|
||||
srv.write_all(&[IAC, WONT, OPT_ECHO]).unwrap();
|
||||
assert_eq!(read_n(&mut srv, 3), [IAC, DONT, OPT_ECHO]);
|
||||
assert!(client.local_echo());
|
||||
|
||||
client.resize(132, 50).unwrap();
|
||||
assert_eq!(read_n(&mut srv, 9), [IAC, SB, OPT_NAWS, 0, 132, 0, 50, IAC, SE]);
|
||||
client.send_break().unwrap();
|
||||
assert_eq!(read_n(&mut srv, 2), [IAC, BRK]);
|
||||
client.write(b"ls\n\xff").unwrap();
|
||||
assert_eq!(read_n(&mut srv, 6), b"ls\r\n\xff\xff");
|
||||
|
||||
let log = debug.lock().unwrap().clone();
|
||||
for m in ["received DO 24", "sending WILL 31", "sending NAWS: 80x24", "sending WONT 99", "sending terminal type: xterm-256color"] {
|
||||
assert!(log.iter().any(|l| l == m), "missing debug {m:?} in {log:?}");
|
||||
}
|
||||
|
||||
// Remote close: Go reports "read error: EOF" then closes, exactly once.
|
||||
drop(srv);
|
||||
assert_eq!(erx.recv_timeout(Duration::from_secs(5)).unwrap(), "read error: EOF");
|
||||
crx.recv_timeout(Duration::from_secs(5)).unwrap();
|
||||
client.close();
|
||||
assert!(crx.recv_timeout(Duration::from_millis(100)).is_err());
|
||||
assert!(client.write(b"x").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_close_fires_once_without_error() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let profile = Profile { host: "127.0.0.1".into(), port: listener.local_addr().unwrap().port() as i64, ..Profile::new(crate::models::Protocol::Telnet) };
|
||||
let (etx, erx) = mpsc::channel::<String>();
|
||||
let (ctx, crx) = mpsc::channel::<()>();
|
||||
let cb = Callbacks { on_error: Arc::new(move |e| etx.send(e).unwrap()), on_close: Arc::new(move || ctx.send(()).unwrap()), ..Callbacks::noop() };
|
||||
let client = TelnetClient::new(&profile, cb, None);
|
||||
client.connect().unwrap();
|
||||
assert!(client.connect().is_err());
|
||||
let _srv = listener.accept().unwrap();
|
||||
client.close();
|
||||
client.close();
|
||||
crx.recv_timeout(Duration::from_secs(1)).unwrap();
|
||||
assert!(crx.recv_timeout(Duration::from_millis(100)).is_err());
|
||||
assert!(erx.recv_timeout(Duration::from_millis(100)).is_err());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user