432 lines
15 KiB
Rust
432 lines
15 KiB
Rust
//! 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());
|
|
}
|
|
}
|