rust: port recording, sshconfig, terminalenv, validation, updater, keyfiles

This commit is contained in:
Andy Dixon
2026-10-11 16:17:03 +01:00
parent 38dff53485
commit fd54c988b6
9 changed files with 2072 additions and 2 deletions
+1
View File
@@ -13,4 +13,5 @@ argon2 = "0.5"
aes-gcm = "0.10"
rand = "0.8"
base64 = "0.22"
ureq = "3"
keyring = { version = "3", features = ["apple-native", "windows-native", "async-secret-service", "tokio", "crypto-rust"] }
+145
View File
@@ -0,0 +1,145 @@
//! SSH key file helpers that need no SSH crypto: spotting private keys, listing `~/.ssh`, and
//! sanitising key and upload file names.
use crate::Result;
use std::io::Read;
use std::path::{Path, PathBuf};
const PRIVATE_KEY_MARKERS: [&str; 6] = [
"BEGIN OPENSSH PRIVATE KEY",
"BEGIN RSA PRIVATE KEY",
"BEGIN DSA PRIVATE KEY",
"BEGIN EC PRIVATE KEY",
"BEGIN ED25519 PRIVATE KEY",
"BEGIN PRIVATE KEY",
];
const IGNORED: [&str; 8] = [
"authorized_keys",
"authorized_keys2",
"config",
"known_hosts",
"known_hosts.old",
"ssh_config",
"ssh_known_hosts",
"ssh_known_hosts2",
];
/// Whether the first 512 bytes look like a private key. Unreadable or empty paths are `false`
/// (this is also Go's `FileIsPrivateKey`).
pub fn is_likely_private_key(path: &Path) -> bool {
let mut head = Vec::with_capacity(512);
let Ok(f) = std::fs::File::open(path) else { return false };
if f.take(512).read_to_end(&mut head).is_err() || head.is_empty() {
return false;
}
head.starts_with(b"openssh-key-v1")
|| PRIVATE_KEY_MARKERS.iter().any(|m| head.windows(m.len()).any(|w| w == m.as_bytes()))
}
/// Candidate private keys in `~/.ssh`, sorted. A missing home or `.ssh` gives an empty list.
pub fn list_ssh_keys() -> Result<Vec<PathBuf>> {
match std::env::home_dir() {
Some(home) => list_ssh_keys_in(&home.join(".ssh")),
None => Ok(vec![]),
}
}
pub fn list_ssh_keys_in(dir: &Path) -> Result<Vec<PathBuf>> {
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vec![]),
Err(e) => return Err(e.into()),
};
let mut keys = Vec::new();
for entry in entries {
let entry = entry?;
if entry.file_type()?.is_dir() {
continue;
}
let name = entry.file_name().to_string_lossy().into_owned();
if IGNORED.contains(&name.as_str()) || name.ends_with(".pub") || name.ends_with(".old") {
continue;
}
let path = dir.join(&name);
if is_likely_private_key(&path) {
keys.push(path);
}
}
keys.sort();
Ok(keys)
}
/// Trimmed name of ASCII letters, digits, `-` and `_`.
pub fn validate_ssh_key_name(name: &str) -> Result<String> {
let name = name.trim();
if name.is_empty() {
return Err("key name is required".into());
}
if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') {
return Err("key name contains invalid characters (use alphanumeric, hyphens, underscores)".into());
}
Ok(name.into())
}
/// The base name of an uploaded file (either slash style), rejecting `.`, `..` and NULs.
pub fn safe_sftp_upload_name(file_name: &str) -> Result<String> {
let name = file_name.trim().replace('\\', "/");
if name.is_empty() {
return Err("invalid file name".into());
}
// Go's path.Base: trailing slashes dropped; all slashes gives "/".
let trimmed = name.trim_end_matches('/');
let base = if trimmed.is_empty() { "/" } else { trimmed.rsplit('/').next().unwrap_or(trimmed) };
if matches!(base, "" | "." | ".." | "/") || base.contains('\0') {
return Err("invalid file name".into());
}
Ok(base.into())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn upload_names() {
assert_eq!(safe_sftp_upload_name("../report <final>.txt").unwrap(), "report <final>.txt");
assert_eq!(safe_sftp_upload_name(r"C:\Users\me\a.txt").unwrap(), "a.txt");
assert_eq!(safe_sftp_upload_name("dir/b.txt/").unwrap(), "b.txt");
for bad in ["..", "folder/..", r"folder\..", "", " ", "///", ".", "a\0b"] {
assert!(safe_sftp_upload_name(bad).is_err(), "{bad:?}");
}
}
#[test]
fn key_names() {
assert_eq!(validate_ssh_key_name(" id_ed25519-work ").unwrap(), "id_ed25519-work");
for bad in ["", " ", "a/b", "../x", "a b", "kéy"] {
assert!(validate_ssh_key_name(bad).is_err(), "{bad:?}");
}
}
#[test]
fn lists_private_keys() {
let dir = std::env::temp_dir().join(format!("nexus-keys-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("subdir")).unwrap();
let key = "-----BEGIN OPENSSH PRIVATE KEY-----\nabc\n";
for (name, body) in [
("id_ed25519", key),
("id_rsa", "-----BEGIN RSA PRIVATE KEY-----\n"),
("id_ed25519.pub", key),
("config", key),
("key.old", key),
("notes.txt", "hello"),
("empty", ""),
] {
std::fs::write(dir.join(name), body).unwrap();
}
let keys = list_ssh_keys_in(&dir).unwrap();
assert_eq!(keys, vec![dir.join("id_ed25519"), dir.join("id_rsa")]);
assert!(!is_likely_private_key(Path::new("")));
assert!(list_ssh_keys_in(&dir.join("missing")).unwrap().is_empty());
std::fs::remove_dir_all(dir).unwrap();
}
}
+6
View File
@@ -4,9 +4,15 @@
pub mod clients;
pub mod config;
pub mod exportimport;
pub mod keyfiles;
pub mod models;
pub mod recording;
pub mod secrets;
pub mod sshconfig;
pub mod storage;
pub mod terminalenv;
pub mod updater;
pub mod validation;
pub type Error = Box<dyn std::error::Error + Send + Sync>;
pub type Result<T> = std::result::Result<T, Error>;
+477
View File
@@ -0,0 +1,477 @@
//! Session recording in asciicast v2 (https://docs.asciinema.org/manual/asciicast/v2/): a JSON
//! header line, then one `[elapsed_secs, type, data]` array per line. Same files as the Go app.
use crate::Result;
use crate::models::Time;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fs::File;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::Instant;
pub const EVENT_OUTPUT: &str = "o";
pub const EVENT_INPUT: &str = "i";
/// Bounds a single active recording file.
pub const MAX_RECORDING_BYTES: u64 = 250 * 1024 * 1024;
/// Bounds the file size loaded for playback.
pub const MAX_PLAYBACK_BYTES: u64 = 100 * 1024 * 1024;
/// Bounds one asciicast line during playback parsing.
pub const MAX_PLAYBACK_LINE_BYTES: usize = 8 * 1024 * 1024;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct Header {
pub version: i64,
pub width: i64,
pub height: i64,
pub timestamp: i64,
#[serde(skip_serializing_if = "String::is_empty")]
pub title: String,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub env: BTreeMap<String, String>,
}
/// One recorded I/O event; `kind` is `"o"` or `"i"` (other types are kept as read).
#[derive(Clone, Debug, PartialEq)]
pub struct Event {
pub elapsed: f64,
pub kind: String,
pub data: String,
}
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RecordingMeta {
pub id: String,
pub session_id: String,
pub title: String,
pub width: i64,
pub height: i64,
/// seconds
pub duration: f64,
/// file size in bytes
pub size: u64,
pub created_at: Time,
pub file_path: PathBuf,
}
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct StorageInfo {
pub count: usize,
pub total_bytes: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub oldest_created_at: Option<Time>,
#[serde(skip_serializing_if = "Option::is_none")]
pub newest_created_at: Option<Time>,
pub max_recording_bytes: u64,
pub max_playback_bytes: u64,
}
#[derive(Clone, Debug, PartialEq, Default, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CleanupResult {
pub deleted: usize,
pub freed_bytes: u64,
}
struct Inner {
file: Option<File>,
closed: bool,
event_count: usize,
bytes_written: u64,
}
/// Captures terminal I/O for one session. Writes are best-effort: errors are dropped and the
/// recording stops (file closed) once it would exceed its size limit.
pub struct Recorder {
inner: Mutex<Inner>,
start: Instant,
file_path: PathBuf,
max_bytes: u64,
}
fn mkdir_private(dir: &Path) -> std::io::Result<()> {
let mut b = std::fs::DirBuilder::new();
b.recursive(true);
#[cfg(unix)]
std::os::unix::fs::DirBuilderExt::mode(&mut b, 0o700);
b.create(dir)
}
fn json_line<T: Serialize>(v: &T) -> Result<Vec<u8>> {
let mut line = serde_json::to_vec(v)?;
line.push(b'\n');
Ok(line)
}
impl Recorder {
/// Creates `<dir>/<id>.cast` and writes the header.
pub fn new(dir: &Path, id: &str, title: &str, cols: i64, rows: i64) -> Result<Recorder> {
Self::with_limit(dir, id, title, cols, rows, MAX_RECORDING_BYTES)
}
fn with_limit(dir: &Path, id: &str, title: &str, cols: i64, rows: i64, max_bytes: u64) -> Result<Recorder> {
mkdir_private(dir).map_err(|e| format!("recording: failed to create directory: {e}"))?;
let path = dir.join(format!("{id}.cast"));
let mut f = File::create(&path).map_err(|e| format!("recording: failed to create file: {e}"))?;
let start = Instant::now();
let hdr = Header {
version: 2,
width: cols,
height: rows,
timestamp: chrono::Utc::now().timestamp(),
title: title.into(),
env: BTreeMap::from([("TERM".into(), "xterm-256color".into())]),
};
let line = json_line(&hdr)?;
if let Err(e) = f.write_all(&line) {
drop(f);
let _ = std::fs::remove_file(&path);
return Err(format!("recording: failed to write header: {e}").into());
}
Ok(Recorder {
inner: Mutex::new(Inner { file: Some(f), closed: false, event_count: 0, bytes_written: line.len() as u64 }),
start,
file_path: path,
max_bytes,
})
}
/// Records data from the remote host.
pub fn write_output(&self, data: &[u8]) {
self.write_event(EVENT_OUTPUT, data)
}
/// Records data from the user.
pub fn write_input(&self, data: &[u8]) {
self.write_event(EVENT_INPUT, data)
}
fn write_event(&self, kind: &str, data: &[u8]) {
let mut g = self.inner.lock().unwrap();
if g.closed || g.file.is_none() {
return;
}
let elapsed = self.start.elapsed().as_secs_f64();
let Ok(line) = json_line(&(elapsed, kind, String::from_utf8_lossy(data))) else { return };
if self.max_bytes > 0 && g.bytes_written + line.len() as u64 > self.max_bytes {
g.closed = true;
if let Some(f) = g.file.take() {
let _ = f.sync_all();
}
return;
}
if g.file.as_mut().unwrap().write_all(&line).is_ok() {
g.bytes_written += line.len() as u64;
g.event_count += 1;
}
}
/// Flushes and closes the file. Idempotent.
pub fn close(&self) -> Result<()> {
let mut g = self.inner.lock().unwrap();
if g.closed {
return Ok(());
}
g.closed = true;
if let Some(f) = g.file.take() {
f.sync_all().map_err(|e| format!("recording: sync failed: {e}"))?;
}
Ok(())
}
/// Seconds since recording started.
pub fn duration(&self) -> f64 {
self.start.elapsed().as_secs_f64()
}
pub fn file_path(&self) -> &Path {
&self.file_path
}
pub fn event_count(&self) -> usize {
self.inner.lock().unwrap().event_count
}
}
/// The recordings directory: listing, playback, deletion and retention.
#[derive(Clone, Debug)]
pub struct Store {
dir: PathBuf,
}
impl Store {
pub fn new(dir: impl Into<PathBuf>) -> Store {
Store { dir: dir.into() }
}
pub fn dir(&self) -> &Path {
&self.dir
}
/// Metadata for every readable `.cast` file, newest first.
pub fn list(&self) -> Result<Vec<RecordingMeta>> {
mkdir_private(&self.dir)?;
let entries = std::fs::read_dir(&self.dir).map_err(|e| format!("recording: failed to read directory: {e}"))?;
let mut out: Vec<RecordingMeta> = entries
.flatten()
.filter(|e| !e.file_type().is_ok_and(|t| t.is_dir()))
.filter_map(|e| e.file_name().into_string().ok())
.filter(|n| n.ends_with(".cast"))
.filter_map(|n| self.read_meta(&n).ok()) // skip unreadable files
.collect();
out.sort_by_key(|m| std::cmp::Reverse(m.created_at));
Ok(out)
}
/// Reads the full recording (header + events).
pub fn get(&self, id: &str) -> Result<(Header, Vec<Event>)> {
self.get_with_limit(id, MAX_PLAYBACK_BYTES)
}
fn get_with_limit(&self, id: &str, max_bytes: u64) -> Result<(Header, Vec<Event>)> {
let path = self.recording_path(id)?;
let size = std::fs::metadata(&path).map_err(|e| format!("recording: failed to stat file: {e}"))?.len();
if max_bytes > 0 && size > max_bytes {
return Err(format!("recording: file is too large for playback (max {max_bytes} bytes)").into());
}
let raw = std::fs::read(&path).map_err(|e| format!("recording: failed to open file: {e}"))?;
let text = String::from_utf8_lossy(&raw);
let mut lines = text.lines();
let Some(first) = lines.next() else { return Err("recording: empty file".into()) };
if first.len() > MAX_PLAYBACK_LINE_BYTES {
return Err("recording: failed to scan header: token too long".into());
}
let hdr: Header = serde_json::from_str(first.trim()).map_err(|e| format!("recording: invalid header: {e}"))?;
let mut events = Vec::new();
for line in lines {
if line.len() > MAX_PLAYBACK_LINE_BYTES {
return Err("recording: failed to scan events: token too long".into());
}
let line = line.trim();
if line.is_empty() {
continue;
}
if let Ok((elapsed, kind, data)) = serde_json::from_str::<(f64, String, String)>(line) {
events.push(Event { elapsed, kind, data });
}
}
Ok((hdr, events))
}
pub fn delete(&self, id: &str) -> Result<()> {
let path = self.recording_path(id)?;
std::fs::remove_file(path).map_err(|e| format!("recording: failed to delete: {e}"))?;
Ok(())
}
pub fn storage_info(&self) -> Result<StorageInfo> {
let metas = self.list()?;
Ok(StorageInfo {
count: metas.len(),
total_bytes: metas.iter().map(|m| m.size).sum(),
oldest_created_at: metas.iter().map(|m| m.created_at).min(),
newest_created_at: metas.iter().map(|m| m.created_at).max(),
max_recording_bytes: MAX_RECORDING_BYTES,
max_playback_bytes: MAX_PLAYBACK_BYTES,
})
}
/// Removes recordings older than `max_age_days`, then the oldest until the total is at most
/// `max_total_bytes`. Zero disables either limit.
pub fn cleanup(&self, max_age_days: u32, max_total_bytes: u64) -> Result<CleanupResult> {
let mut metas = self.list()?;
let mut result = CleanupResult::default();
let mut deleted = std::collections::HashSet::new();
let mut total: u64 = metas.iter().map(|m| m.size).sum();
let mut delete = |m: &RecordingMeta, total: &mut u64| {
if !deleted.contains(&m.id) && self.delete(&m.id).is_ok() {
deleted.insert(m.id.clone());
result.deleted += 1;
result.freed_bytes += m.size;
*total -= m.size;
}
};
if max_age_days > 0 {
let cutoff = chrono::Utc::now() - chrono::Duration::days(max_age_days.into());
for m in &metas {
if m.created_at < cutoff {
delete(m, &mut total);
}
}
}
if max_total_bytes > 0 && total > max_total_bytes {
metas.sort_by_key(|m| m.created_at);
for m in &metas {
if total <= max_total_bytes {
break;
}
delete(m, &mut total);
}
}
Ok(result)
}
fn recording_path(&self, id: &str) -> Result<PathBuf> {
let id = id.trim();
if id.is_empty() || id == "." || id == ".." || id.contains(['/', '\\']) {
return Err("recording: invalid recording ID".into());
}
Ok(self.dir.join(format!("{id}.cast")))
}
/// Header fields plus the last event's timestamp as the duration.
fn read_meta(&self, filename: &str) -> Result<RecordingMeta> {
let path = self.dir.join(filename);
let mut f = File::open(&path)?;
let size = f.metadata()?.len();
let mut raw = Vec::new();
f.read_to_end(&mut raw)?;
let mut values = serde_json::Deserializer::from_slice(&raw).into_iter::<serde_json::Value>();
let hdr: Header = serde_json::from_value(values.next().ok_or("recording: empty file")??)?;
let mut duration = 0.0;
for v in values {
let Ok(v) = v else { break };
if let Some(e) = v.get(0).and_then(|e| e.as_f64()) {
duration = e;
}
}
let id = filename.trim_end_matches(".cast").to_string();
let created_at = chrono::DateTime::from_timestamp(hdr.timestamp, 0)
.ok_or("recording: invalid timestamp")?
.with_timezone(&chrono::Local)
.fixed_offset();
Ok(RecordingMeta {
session_id: id.clone(), // recording ID matches session ID by default
id,
title: hdr.title,
width: hdr.width,
height: hdr.height,
duration,
size,
created_at,
file_path: path,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("nexus-rec-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
d
}
#[test]
fn reads_go_fixture() {
let store = Store::new(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/recordings"));
let (hdr, events) = store.get("rec1").unwrap();
assert_eq!((hdr.version, hdr.width, hdr.height, hdr.timestamp), (2, 80, 24, 1791633486));
assert_eq!(hdr.title, "Fixture — host");
assert_eq!(hdr.env["TERM"], "xterm-256color");
assert_eq!(events.len(), 2);
assert_eq!((events[0].kind.as_str(), events[0].data.as_str()), ("o", "hello \x1b[1mworld\x1b[0m\r\n"));
assert_eq!((events[1].kind.as_str(), events[1].data.as_str()), ("i", "ls\r"));
let metas = store.list().unwrap();
assert_eq!(metas.len(), 1);
assert_eq!(metas[0].duration, 0.000212149);
}
#[test]
fn write_and_close() {
let dir = tmp("write");
let rec = Recorder::new(&dir, "test-session-001", "Test Recording", 80, 24).unwrap();
rec.write_output(b"Welcome to the server\r\n");
rec.write_output(b"$ ");
rec.write_input(b"ls -la\r\n");
rec.write_output(b"total 42\r\n");
assert_eq!(rec.event_count(), 4);
rec.close().unwrap();
rec.close().unwrap(); // double close is a no-op
let text = std::fs::read_to_string(dir.join("test-session-001.cast")).unwrap();
let lines: Vec<&str> = text.trim().lines().collect();
assert_eq!(lines.len(), 5);
let hdr: Header = serde_json::from_str(lines[0]).unwrap();
assert_eq!((hdr.version, hdr.width, hdr.height), (2, 80, 24));
let (_, kind, _): (f64, String, String) = serde_json::from_str(lines[1]).unwrap();
assert_eq!(kind, "o");
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn list_get_delete() {
let dir = tmp("list");
let rec = Recorder::new(&dir, "session-abc", "My Session", 120, 40).unwrap();
rec.write_output(b"hello world\r\n");
rec.write_output(b"done\r\n");
rec.close().unwrap();
let store = Store::new(&dir);
let metas = store.list().unwrap();
assert_eq!(metas.len(), 1);
assert_eq!((metas[0].id.as_str(), metas[0].width, metas[0].height), ("session-abc", 120, 40));
let (hdr, events) = store.get("session-abc").unwrap();
assert_eq!(hdr.title, "My Session");
assert_eq!(events.len(), 2);
store.delete("session-abc").unwrap();
assert!(store.list().unwrap().is_empty());
assert!(store.delete("../outside").is_err());
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn stops_at_size_limit() {
let dir = tmp("limit");
let rec = Recorder::with_limit(&dir, "limited", "Limited", 80, 24, 512).unwrap();
for _ in 0..50 {
rec.write_output("x".repeat(80).as_bytes());
}
assert!(rec.event_count() < 50);
rec.close().unwrap();
assert!(std::fs::metadata(dir.join("limited.cast")).unwrap().len() <= 512);
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn rejects_oversized_playback() {
let dir = tmp("huge");
std::fs::create_dir_all(&dir).unwrap();
let body = format!("{{\"version\":2,\"width\":80,\"height\":24,\"timestamp\":1}}\n{}", "x".repeat(256));
std::fs::write(dir.join("huge.cast"), body).unwrap();
let err = Store::new(&dir).get_with_limit("huge", 128).unwrap_err();
assert!(err.to_string().contains("too large"));
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn cleanup_by_age_and_size() {
let dir = tmp("cleanup");
let make = |id: &str, age_secs: i64, payload: &str| {
let rec = Recorder::new(&dir, id, id, 80, 24).unwrap();
rec.write_output(payload.as_bytes());
rec.close().unwrap();
let path = dir.join(format!("{id}.cast"));
let text = std::fs::read_to_string(&path).unwrap();
let (first, rest) = text.split_once('\n').unwrap();
let mut hdr: Header = serde_json::from_str(first).unwrap();
hdr.timestamp = chrono::Utc::now().timestamp() - age_secs;
let mut out = json_line(&hdr).unwrap();
out.extend(rest.as_bytes());
std::fs::write(&path, out).unwrap();
};
make("old", 45 * 86400, "old");
make("recent", 2 * 3600, &"recent".repeat(50));
let store = Store::new(&dir);
assert_eq!(store.cleanup(30, 0).unwrap().deleted, 1);
assert!(!dir.join("old.cast").exists());
let info = store.storage_info().unwrap();
assert_eq!(info.count, 1);
assert_eq!(store.cleanup(0, info.total_bytes - 1).unwrap().deleted, 1);
std::fs::remove_dir_all(dir).unwrap();
}
}
+281
View File
@@ -0,0 +1,281 @@
//! Parses OpenSSH `~/.ssh/config` Host blocks into SSH profiles. Supports HostName, Port, User,
//! IdentityFile, ProxyJump (first hop), ForwardAgent and Compression. Match blocks, wildcard-only
//! hosts, Include and `%h`-style tokens are ignored, as in the Go app.
use crate::Result;
use crate::models::{HostKeyPolicy, JumpHost, Profile, Protocol, SecretMode, SshConfig};
use std::path::{Path, PathBuf};
const MAX_CONFIG_FILE_BYTES: u64 = 5 * 1024 * 1024;
const MAX_CONFIG_LINE_BYTES: usize = 256 * 1024;
const MAX_HOST_BLOCKS: usize = 5000;
#[derive(Clone, Debug, PartialEq, Default)]
pub struct HostBlock {
pub aliases: Vec<String>,
pub host_name: String,
pub port: i64,
pub user: String,
pub identity_file: String,
pub proxy_jump: String,
pub forward_agent: bool,
pub compression: bool,
}
pub fn parse_file(path: &Path) -> Result<Vec<HostBlock>> {
let p = path.display();
let meta = std::fs::metadata(path).map_err(|e| format!("sshconfig: failed to open {p}: {e}"))?;
if meta.is_dir() {
return Err(format!("sshconfig: {p} is a directory").into());
}
if meta.len() > MAX_CONFIG_FILE_BYTES {
return Err(format!(
"sshconfig: config file is too large: {} bytes exceeds {MAX_CONFIG_FILE_BYTES} bytes",
meta.len()
)
.into());
}
let raw = std::fs::read(path).map_err(|e| format!("sshconfig: failed to open {p}: {e}"))?;
parse(&String::from_utf8_lossy(&raw))
}
fn parse(text: &str) -> Result<Vec<HostBlock>> {
let mut blocks = Vec::new();
let mut current: Option<HostBlock> = None;
let mut commit = |cur: &mut Option<HostBlock>| -> Result<()> {
if let Some(b) = cur.take() {
if blocks.len() >= MAX_HOST_BLOCKS {
return Err(format!("sshconfig: too many Host blocks (max {MAX_HOST_BLOCKS})").into());
}
blocks.push(b);
}
Ok(())
};
for line in text.lines() {
if line.len() > MAX_CONFIG_LINE_BYTES {
return Err("sshconfig: read error: token too long".into());
}
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let (key, value) = split_directive(line);
match key.to_lowercase().as_str() {
"host" => {
commit(&mut current)?;
let aliases: Vec<String> = value.split_whitespace().map(String::from).collect();
// Skip wildcard-only hosts (e.g. "Host *").
let wildcard = aliases.iter().any(|a| a.contains(['*', '?']));
if !(wildcard && aliases.len() == 1) {
current = Some(HostBlock { aliases, port: 22, ..Default::default() });
}
}
"match" => commit(&mut current)?,
key => {
let Some(b) = current.as_mut() else { continue };
match key {
"hostname" => b.host_name = value.into(),
"port" => {
if let Ok(p) = value.parse() {
b.port = p
}
}
"user" => b.user = value.into(),
"identityfile" => b.identity_file = expand_tilde(value),
"proxyjump" => b.proxy_jump = value.into(),
"forwardagent" => b.forward_agent = is_trueish(value),
"compression" => b.compression = is_trueish(value),
_ => {}
}
}
}
}
commit(&mut current)?;
Ok(blocks)
}
/// `~/.ssh/config`, if the home directory is known.
pub fn default_config_path() -> Option<PathBuf> {
std::env::home_dir().map(|h| h.join(".ssh").join("config"))
}
/// One SSH profile per block; the first alias becomes the display name.
pub fn to_profiles(blocks: &[HostBlock], folder: &str) -> Vec<Profile> {
blocks
.iter()
.filter(|b| !b.aliases.is_empty())
.map(|b| {
let mut ssh = SshConfig {
auth_methods: vec!["publickey".into(), "password".into()],
secret_mode: SecretMode::AskEveryTime,
host_key_policy: HostKeyPolicy::Ask,
terminal_type: "xterm-256color".into(),
agent_auth: b.forward_agent,
agent_forwarding: b.forward_agent,
compression: b.compression,
private_key_path: b.identity_file.clone(),
..Default::default()
};
if !b.proxy_jump.is_empty() && b.proxy_jump != "none" {
ssh.jump_hosts = parse_proxy_jump(&b.proxy_jump).into_iter().collect();
}
let mut p = Profile::new(Protocol::Ssh);
p.display_name = b.aliases[0].clone();
p.host = if b.host_name.is_empty() { b.aliases[0].clone() } else { b.host_name.clone() };
p.port = b.port;
p.username = b.user.clone();
p.folder = folder.into();
p.ssh = Some(ssh);
p.tags = vec!["ssh-config-import".into()];
p
})
.collect()
}
/// `[user@]host[:port]`; only the first hop of a comma-separated chain.
fn parse_proxy_jump(value: &str) -> Option<JumpHost> {
let mut value = value.split(',').next().unwrap_or("").trim();
if value.is_empty() || value == "none" {
return None;
}
let mut jh =
JumpHost { port: 22, auth_method: "publickey".into(), secret_mode: SecretMode::AskEveryTime, ..Default::default() };
if let Some((user, rest)) = value.split_once('@') {
jh.username = user.into();
value = rest;
}
if let Some((host, port)) = value.rsplit_once(':')
&& let Ok(p) = port.parse()
{
jh.port = p;
value = host;
}
jh.host = value.into();
Some(jh)
}
/// `Key Value` or `Key=Value`.
fn split_directive(line: &str) -> (&str, &str) {
if let Some((k, v)) = line.split_once('=') {
return (k.trim(), v.trim());
}
match line.split_once(' ').or_else(|| line.split_once('\t')) {
Some((k, v)) => (k.trim(), v.trim()),
None => (line, ""),
}
}
fn expand_tilde(path: &str) -> String {
match (path.strip_prefix("~/"), std::env::home_dir()) {
(Some(rest), Some(home)) => home.join(rest).to_string_lossy().into_owned(),
_ => path.into(),
}
}
fn is_trueish(v: &str) -> bool {
matches!(v.to_lowercase().as_str(), "yes" | "true" | "on" | "1")
}
#[cfg(test)]
mod tests {
use super::*;
const TEST_CONFIG: &str = "# Test SSH config
Host *
ServerAliveInterval 60
Host webserver
HostName web.example.com
Port 2222
User deploy
IdentityFile ~/.ssh/deploy_key
ForwardAgent yes
Compression yes
Host dbserver
HostName db.internal.example.com
User postgres
ProxyJump bastion@jump.example.com:2200
Host simple
HostName 10.0.0.5
Host wildcard-*
User admin
";
#[test]
fn parses_file() {
let path = std::env::temp_dir().join(format!("nexus-sshconfig-{}", std::process::id()));
std::fs::write(&path, TEST_CONFIG).unwrap();
let blocks = parse_file(&path).unwrap();
std::fs::remove_file(&path).unwrap();
assert_eq!(blocks.len(), 3);
let wb = &blocks[0];
assert_eq!(wb.aliases[0], "webserver");
assert_eq!((wb.host_name.as_str(), wb.port, wb.user.as_str()), ("web.example.com", 2222, "deploy"));
assert!(wb.forward_agent && wb.compression);
assert!(wb.identity_file.ends_with(".ssh/deploy_key") && !wb.identity_file.starts_with('~'));
assert_eq!(blocks[1].aliases[0], "dbserver");
assert_eq!(blocks[1].proxy_jump, "bastion@jump.example.com:2200");
assert_eq!((blocks[2].host_name.as_str(), blocks[2].port), ("10.0.0.5", 22));
}
#[test]
fn rejects_oversized_file() {
let path = std::env::temp_dir().join(format!("nexus-sshconfig-big-{}", std::process::id()));
std::fs::File::create(&path).unwrap().set_len(MAX_CONFIG_FILE_BYTES + 1).unwrap();
let err = parse_file(&path).unwrap_err();
std::fs::remove_file(&path).unwrap();
assert!(err.to_string().contains("too large"));
}
#[test]
fn rejects_too_many_host_blocks() {
let text: String = (0..=MAX_HOST_BLOCKS).map(|i| format!("Host host{i}\n HostName example.com\n")).collect();
assert!(parse(&text).unwrap_err().to_string().contains("too many Host blocks"));
}
#[test]
fn converts_to_profiles() {
let blocks = vec![
HostBlock {
aliases: vec!["myserver".into()],
host_name: "srv.example.com".into(),
port: 22,
user: "admin".into(),
forward_agent: true,
..Default::default()
},
HostBlock {
aliases: vec!["jumpbox".into()],
host_name: "jump.example.com".into(),
port: 2200,
user: "jumper".into(),
proxy_jump: "gateway@gw.example.com".into(),
..Default::default()
},
];
let ps = to_profiles(&blocks, "Imported");
assert_eq!(ps.len(), 2);
assert_eq!((ps[0].display_name.as_str(), ps[0].host.as_str()), ("myserver", "srv.example.com"));
assert_eq!(ps[0].folder, "Imported");
assert!(ps[0].ssh.as_ref().unwrap().agent_auth);
let jh = &ps[1].ssh.as_ref().unwrap().jump_hosts[0];
assert_eq!((jh.host.as_str(), jh.username.as_str()), ("gw.example.com", "gateway"));
}
#[test]
fn parses_proxy_jump() {
for (input, host, user, port) in [
("bastion.example.com", "bastion.example.com", "", 22),
("user@bastion.example.com", "bastion.example.com", "user", 22),
("user@bastion.example.com:2200", "bastion.example.com", "user", 2200),
("host:443", "host", "", 443),
] {
let jh = parse_proxy_jump(input).unwrap();
assert_eq!((jh.host.as_str(), jh.username.as_str(), jh.port), (host, user, port), "{input}");
}
}
}
+109
View File
@@ -0,0 +1,109 @@
//! Environment variables for terminal sessions: TERM and a UTF-8 character locale.
use std::collections::BTreeMap;
const LANG: &str = "LANG";
const LC_ALL: &str = "LC_ALL";
const LC_CTYPE: &str = "LC_CTYPE";
const TERM: &str = "TERM";
const DEFAULT_TERM: &str = "xterm-256color";
/// `KEY=VALUE` entries for a local interactive PTY.
pub fn local_shell_env(env: Vec<String>) -> Vec<String> {
ensure_utf8_character_locale(set_env(env, TERM, DEFAULT_TERM), std::env::consts::OS)
}
/// Variables to request for an SSH session. A locale set by the profile is kept as is; otherwise
/// a UTF-8 `LC_CTYPE` is added. Keys are trimmed and empty keys dropped. Iteration order is sorted.
pub fn ssh_env_vars(profile_env: &BTreeMap<String, String>) -> BTreeMap<String, String> {
let mut out: BTreeMap<String, String> = profile_env
.iter()
.map(|(k, v)| (k.trim().to_string(), v.clone()))
.filter(|(k, _)| !k.is_empty())
.collect();
if ![LC_ALL, LC_CTYPE, LANG].iter().any(|k| out.contains_key(*k)) {
out.insert(LC_CTYPE.into(), default_utf8_locale(std::env::consts::OS).into());
}
out
}
/// A UTF-8 locale broadly available on `os` (Go's `darwin` or Rust's `macos` get `en_US.UTF-8`).
pub fn default_utf8_locale(os: &str) -> &'static str {
if os == "darwin" || os == "macos" { "en_US.UTF-8" } else { "C.UTF-8" }
}
fn ensure_utf8_character_locale(env: Vec<String>, os: &str) -> Vec<String> {
// The first of LC_ALL, LC_CTYPE, LANG that is set decides the character locale.
let effective = [LC_ALL, LC_CTYPE, LANG].iter().find_map(|k| get_env(&env, k));
if effective.is_some_and(is_utf8_locale) {
return env;
}
let locale = default_utf8_locale(os);
if get_env(&env, LC_ALL).is_some() {
set_env(env, LC_ALL, locale)
} else if get_env(&env, LC_CTYPE).is_some() || get_env(&env, LANG).is_some() {
set_env(env, LC_CTYPE, locale)
} else {
set_env(env, LANG, locale)
}
}
fn is_utf8_locale(v: &str) -> bool {
v.replace('-', "").to_lowercase().contains("utf8")
}
fn get_env<'a>(env: &'a [String], key: &str) -> Option<&'a str> {
env.iter().rev().find_map(|e| e.strip_prefix(key)?.strip_prefix('='))
}
fn set_env(env: Vec<String>, key: &str, value: &str) -> Vec<String> {
let mut out: Vec<String> = env.into_iter().filter(|e| get_env(std::slice::from_ref(e), key).is_none()).collect();
out.push(format!("{key}={value}"));
out
}
#[cfg(test)]
mod tests {
use super::*;
fn s(v: &[&str]) -> Vec<String> {
v.iter().map(|x| x.to_string()).collect()
}
#[test]
fn local_env_sets_term_and_utf8_when_missing() {
let got = local_shell_env(s(&["SHELL=/bin/zsh"]));
assert_eq!(get_env(&got, TERM), Some(DEFAULT_TERM));
assert!(get_env(&got, LANG).is_some_and(is_utf8_locale));
}
#[test]
fn local_env_uses_lc_ctype_when_lang_is_not_utf8() {
let got = ensure_utf8_character_locale(s(&["LANG=C"]), "linux");
assert_eq!(get_env(&got, LANG), Some("C"));
assert_eq!(get_env(&got, LC_CTYPE), Some("C.UTF-8"));
}
#[test]
fn local_env_overrides_non_utf8_lc_all() {
let got = ensure_utf8_character_locale(s(&["LC_ALL=C", "LANG=C"]), "darwin");
assert_eq!(get_env(&got, LC_ALL), Some("en_US.UTF-8"));
}
#[test]
fn ssh_env_preserves_profile_locale() {
let got = ssh_env_vars(&BTreeMap::from([("LANG".into(), "de_DE.UTF-8".into()), ("FOO".into(), "bar".into())]));
assert_eq!((got["LANG"].as_str(), got["FOO"].as_str()), ("de_DE.UTF-8", "bar"));
assert!(!got.contains_key(LC_CTYPE));
let got = ssh_env_vars(&BTreeMap::from([("LANG".into(), "C".into())]));
assert_eq!(got["LANG"], "C");
assert!(!got.contains_key(LC_CTYPE));
}
#[test]
fn ssh_env_adds_utf8_lc_ctype_by_default() {
let got = ssh_env_vars(&BTreeMap::from([("FOO".into(), "bar".into())]));
assert_eq!(got["FOO"], "bar");
assert!(is_utf8_locale(&got[LC_CTYPE]));
}
}
+302
View File
@@ -0,0 +1,302 @@
//! Update checks against a self-hosted manifest (or, as a fallback, GitHub's latest release).
//! Opt-in and check-only: nothing is downloaded or installed. Calls block; run them off the UI
//! thread.
use crate::Result;
use serde::{Deserialize, Serialize};
use std::io::Read;
use std::time::Duration;
/// Manifest URL template; expand with [`expand_update_url`].
pub const DEFAULT_UPDATE_URL: &str = "https://alchementary.co.uk/download/link/manifest-{platform}-{arch}.json";
const GITHUB_OWNER: &str = "nexus-terminal";
const GITHUB_REPO: &str = "nexus-terminal";
const USER_AGENT: &str = "NexusTerminal-UpdateChecker";
const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
const MAX_UPDATE_RESPONSE_BYTES: usize = 1 << 20;
#[derive(Clone, Debug, PartialEq, Default, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateInfo {
pub available: bool,
pub current_version: String,
pub latest_version: String,
#[serde(rename = "releaseURL")]
pub release_url: String,
pub release_name: String,
pub release_notes: String,
pub published_at: String,
}
#[derive(Deserialize, Default)]
#[serde(default)]
struct Release {
tag_name: String,
name: String,
html_url: String,
published_at: String,
body: String,
prerelease: bool,
draft: bool,
}
#[derive(Deserialize, Default)]
#[serde(default, rename_all = "camelCase")]
struct Manifest {
latest_version: String,
version: String,
#[serde(rename = "releaseURL")]
release_url: String,
release_name: String,
release_notes: String,
published_at: String,
}
/// Fills `{platform}`/`{os}` and `{arch}` with Go's GOOS/GOARCH names, which the published
/// manifest file names use.
pub fn expand_update_url(template: &str) -> String {
let os = match std::env::consts::OS {
"macos" => "darwin",
o => o,
};
let arch = match std::env::consts::ARCH {
"x86_64" => "amd64",
"aarch64" => "arm64",
"x86" => "386",
a => a,
};
template.replace("{platform}", os).replace("{os}", os).replace("{arch}", arch)
}
/// GET with the updater headers; the body is read (bounded) only on 200.
fn fetch(url: &str, accept: &str) -> Result<(u16, Vec<u8>)> {
let agent: ureq::Agent = ureq::Agent::config_builder()
.timeout_global(Some(REQUEST_TIMEOUT))
.http_status_as_error(false)
.build()
.into();
let resp = agent
.get(url)
.header("Accept", accept)
.header("User-Agent", USER_AGENT)
.call()
.map_err(|e| format!("updater: request failed: {e}"))?;
let status = resp.status().as_u16();
if status != 200 {
return Ok((status, vec![]));
}
let mut body = Vec::new();
resp.into_body()
.into_reader()
.take(MAX_UPDATE_RESPONSE_BYTES as u64 + 1)
.read_to_end(&mut body)
.map_err(|e| format!("updater: failed to read response: {e}"))?;
if body.len() > MAX_UPDATE_RESPONSE_BYTES {
return Err(format!("updater: failed to read response: response exceeds {MAX_UPDATE_RESPONSE_BYTES} bytes").into());
}
Ok((status, body))
}
/// Checks a manifest URL. The body is either a JSON manifest (`latestVersion` or `version`, plus
/// optional `releaseURL`, `releaseName`, `releaseNotes`, `publishedAt`) or a bare version string.
pub fn check_url(url: &str, current_version: &str) -> Result<UpdateInfo> {
if url.is_empty() {
return Err("updater: update manifest URL is empty".into());
}
let current = normalise_version(current_version);
let (status, body) = fetch(url, "application/json, text/plain")?;
if status != 200 {
return Err(format!("updater: update server returned {status}").into());
}
if let Some(info) = parse_manifest(&body, &current) {
return Ok(info);
}
let latest = normalise_version(&String::from_utf8_lossy(&body));
if latest.is_empty() {
return Err("updater: update manifest is empty".into());
}
Ok(UpdateInfo { available: is_newer(&latest, &current), current_version: current, latest_version: latest, ..Default::default() })
}
/// Checks the latest GitHub release of `nexus-terminal/nexus-terminal`.
pub fn check_github(current_version: &str) -> Result<UpdateInfo> {
let current = normalise_version(current_version);
let url = format!("https://api.github.com/repos/{GITHUB_OWNER}/{GITHUB_REPO}/releases/latest");
let (status, body) = fetch(&url, "application/vnd.github+json")?;
if status == 404 {
// No releases yet.
return Ok(UpdateInfo { latest_version: current.clone(), current_version: current, ..Default::default() });
}
if status != 200 {
return Err(format!("updater: GitHub API returned {status}").into());
}
let release: Release =
serde_json::from_slice(&body).map_err(|e| format!("updater: failed to decode response: {e}"))?;
let latest = normalise_version(&release.tag_name);
if release.draft || release.prerelease {
return Ok(UpdateInfo { current_version: current, latest_version: latest, ..Default::default() });
}
Ok(UpdateInfo {
available: is_newer(&latest, &current),
current_version: current,
latest_version: latest,
release_url: release.html_url,
release_name: release.name,
release_notes: truncate_notes(&release.body, 500),
published_at: release.published_at,
})
}
fn normalise_version(v: &str) -> String {
let v = v.trim();
v.strip_prefix('v').unwrap_or(v).to_string()
}
fn parse_manifest(body: &[u8], current: &str) -> Option<UpdateInfo> {
let m: Manifest = serde_json::from_slice(body).ok()?;
let latest = normalise_version(if m.latest_version.is_empty() { &m.version } else { &m.latest_version });
if latest.is_empty() {
return None;
}
Some(UpdateInfo {
available: is_newer(&latest, current),
current_version: current.into(),
latest_version: latest,
release_url: m.release_url,
release_name: m.release_name,
release_notes: m.release_notes,
published_at: m.published_at,
})
}
/// Compares dot-separated numeric parts (leading integer of each part, else 0). Dev builds and an
/// empty current version never see updates.
fn is_newer(latest: &str, current: &str) -> bool {
if current.is_empty() || current == "dev" {
return false;
}
let l: Vec<&str> = latest.split('.').collect();
let c: Vec<&str> = current.split('.').collect();
for i in 0..l.len().max(c.len()) {
let (lv, cv) = (leading_int(l.get(i)), leading_int(c.get(i)));
if lv != cv {
return lv > cv;
}
}
false
}
/// Go's `fmt.Sscanf(s, "%d")`: optional sign then digits; anything else is 0.
fn leading_int(s: Option<&&str>) -> i64 {
let s = s.map_or("", |s| s.trim_start());
let end = s
.char_indices()
.find(|&(i, c)| !(c.is_ascii_digit() || (i == 0 && (c == '+' || c == '-'))))
.map_or(s.len(), |(i, _)| i);
s[..end].parse().unwrap_or(0)
}
/// Cuts at `max` bytes (backing off to a char boundary) and appends "…".
fn truncate_notes(body: &str, max: usize) -> String {
if body.len() <= max {
return body.into();
}
let mut i = max;
while !body.is_char_boundary(i) {
i -= 1;
}
format!("{}…", &body[..i])
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
/// Serves one HTTP response on a random local port; returns its URL.
fn serve_once(content_type: &'static str, body: Vec<u8>) -> String {
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let url = format!("http://{}/", l.local_addr().unwrap());
std::thread::spawn(move || {
let (mut s, _) = l.accept().unwrap();
let mut req: Vec<u8> = Vec::new();
let mut buf = [0u8; 1024];
while !req.windows(4).any(|w| w == b"\r\n\r\n") {
let n = s.read(&mut buf).unwrap();
if n == 0 {
break;
}
req.extend(&buf[..n]);
}
let head = format!(
"HTTP/1.1 200 OK\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
);
let _ = s.write_all(head.as_bytes());
let _ = s.write_all(&body);
});
url
}
#[test]
fn normalises_versions() {
for (i, w) in [("v1.2.3", "1.2.3"), ("1.2.3", "1.2.3"), (" v0.5.0 ", "0.5.0"), ("dev", "dev")] {
assert_eq!(normalise_version(i), w);
}
}
#[test]
fn compares_versions() {
for (l, c, w) in [
("1.1.0", "1.0.0", true),
("2.0.0", "1.9.9", true),
("1.0.1", "1.0.0", true),
("1.0.0", "1.0.0", false),
("0.9.0", "1.0.0", false),
("1.0.0", "dev", false),
("1.0.0", "", false),
("1.2", "1.1.0", true),
("1.0.0", "1.0.0.1", false),
("1.3.0-beta", "1.2.9", true),
] {
assert_eq!(is_newer(l, c), w, "{l} vs {c}");
}
}
#[test]
fn truncates_notes() {
assert_eq!(truncate_notes("Short note", 100), "Short note");
let got = truncate_notes(&"x".repeat(600), 500);
assert!(got.ends_with('…'));
assert_eq!(got.chars().count(), 501);
assert_eq!(truncate_notes("aé", 2), "a…");
}
#[test]
fn expands_url_template() {
let u = expand_update_url(DEFAULT_UPDATE_URL);
assert!(!u.contains('{') && !u.contains("macos") && !u.contains("x86_64"), "{u}");
}
#[test]
fn url_manifest_json() {
let url = serve_once("application/json", br#"{"latestVersion":"1.2.3","releaseURL":"https://example.com/release"}"#.to_vec());
let info = check_url(&url, "1.2.0").unwrap();
assert!(info.available);
assert_eq!(info.latest_version, "1.2.3");
assert_eq!(info.release_url, "https://example.com/release");
}
#[test]
fn url_plain_text() {
let url = serve_once("text/plain", b"2.0.0".to_vec());
assert!(check_url(&url, "1.9.0").unwrap().available);
}
#[test]
fn url_rejects_oversized_manifest() {
let url = serve_once("text/plain", vec![b'x'; MAX_UPDATE_RESPONSE_BYTES + 1]);
let err = check_url(&url, "1.9.0").unwrap_err();
assert!(err.to_string().contains("response exceeds"), "{err}");
}
}
+573
View File
@@ -0,0 +1,573 @@
//! Field-level validation of profiles and their protocol configs. Enum-typed fields (secret modes,
//! host-key policy, line endings, forward direction, protocol) can't hold invalid values in Rust,
//! so the Go checks for those are enforced by the types instead.
use crate::models::{GitHubConfig, IrcConfig, Profile, Protocol, RawConfig, SerialConfig, SshConfig, TelnetConfig, VncConfig};
use serde::Serialize;
use std::fmt;
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct ValidationError {
pub field: String,
pub message: String,
}
impl fmt::Display for ValidationError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{}: {}", self.field, self.message)
}
}
impl std::error::Error for ValidationError {}
#[derive(Clone, Debug, PartialEq, Default, Serialize)]
pub struct ValidationErrors {
pub errors: Vec<ValidationError>,
}
impl fmt::Display for ValidationErrors {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
let msgs: Vec<String> = self.errors.iter().map(|e| e.to_string()).collect();
write!(f, "validation failed: {}", msgs.join("; "))
}
}
impl std::error::Error for ValidationErrors {}
impl ValidationErrors {
pub fn add(&mut self, field: impl Into<String>, message: impl Into<String>) {
self.errors.push(ValidationError { field: field.into(), message: message.into() });
}
fn into_result(self) -> Result<(), ValidationErrors> {
if self.errors.is_empty() { Ok(()) } else { Err(self) }
}
fn merge(&mut self, r: Result<(), ValidationErrors>) {
if let Err(e) = r {
self.errors.extend(e.errors);
}
}
/// Adds `r`'s message (if any) under `field`.
fn check(&mut self, field: &str, r: Result<(), ValidationError>) {
if let Err(e) = r {
self.add(field, e.message);
}
}
}
fn err(field: &str, message: &str) -> Result<(), ValidationError> {
Err(ValidationError { field: field.into(), message: message.into() })
}
pub fn validate_profile(p: &Profile) -> Result<(), ValidationErrors> {
let mut errs = ValidationErrors::default();
if p.id.trim().is_empty() {
errs.add("id", "profile ID must not be empty");
}
if p.display_name.trim().is_empty() {
errs.add("displayName", "display name must not be empty");
}
match p.protocol {
Protocol::Ssh => {
host_port(&mut errs, &p.host, p.port);
if p.username.trim().is_empty() {
errs.add("username", "username is required for SSH connections");
} else {
errs.check("username", validate_username(&p.username));
}
match &p.ssh {
None => errs.add("ssh", "SSH configuration is required for SSH protocol"),
Some(c) => errs.merge(validate_ssh_config(c)),
}
}
Protocol::Telnet => {
host_port(&mut errs, &p.host, p.port);
match &p.telnet {
None => errs.add("telnet", "Telnet configuration is required for Telnet protocol"),
Some(c) => errs.merge(validate_telnet_config(c)),
}
}
Protocol::Raw => {
host_port(&mut errs, &p.host, p.port);
match &p.raw {
None => errs.add("raw", "RAW configuration is required for RAW protocol"),
Some(c) => errs.merge(validate_raw_config(c)),
}
}
Protocol::Github => match &p.github {
None => errs.add("github", "GitHub configuration is required for GitHub protocol"),
Some(c) => errs.merge(validate_github_config(c)),
},
Protocol::Vnc => match &p.vnc {
None => errs.add("vnc", "VNC configuration is required for VNC protocol"),
Some(c) => errs.merge(validate_vnc_config(&p.host, c)),
},
Protocol::Irc => {
host_port(&mut errs, &p.host, p.port);
match &p.irc {
None => errs.add("irc", "IRC configuration is required for IRC protocol"),
Some(c) => errs.merge(validate_irc_config(c)),
}
}
Protocol::Serial => {
if p.host.trim().is_empty() {
errs.add("host", "serial device path must not be empty");
}
match &p.serial {
None => errs.add("serial", "Serial configuration is required for Serial protocol"),
Some(c) => errs.merge(validate_serial_config(c)),
}
}
Protocol::Local => errs.add("protocol", format!("unsupported protocol {:?}", p.protocol.id())),
}
if p.connect_timeout < 0 {
errs.add("connectTimeout", "connect timeout must not be negative");
}
if p.read_timeout < 0 {
errs.add("readTimeout", "read timeout must not be negative");
}
if !(1..=72).contains(&p.terminal_font_size) {
errs.add("terminalFontSize", "font size must be between 1 and 72");
}
if p.terminal_scrollback < 0 {
errs.add("terminalScrollback", "scrollback must not be negative");
}
errs.into_result()
}
fn host_port(errs: &mut ValidationErrors, host: &str, port: i64) {
errs.check("host", validate_host(host));
errs.check("port", validate_port(port));
}
/// Non-empty, no whitespace, no URI scheme, no leading hyphen.
pub fn validate_host(host: &str) -> Result<(), ValidationError> {
let h = host.trim();
if h.is_empty() {
return err("host", "host must not be empty");
}
if h.contains([' ', '\t', '\n', '\r']) {
return err("host", "host must not contain whitespace");
}
if h.contains("://") {
return err("host", "host must not include a URI scheme");
}
// An IP address never starts with '-', so this only rejects hostnames (as in Go).
if h.starts_with('-') {
return err("host", "host must not begin with a hyphen");
}
Ok(())
}
pub fn validate_port(port: i64) -> Result<(), ValidationError> {
if !(1..=65535).contains(&port) {
return err("port", "port must be between 1 and 65535");
}
Ok(())
}
/// Non-empty and free of control characters.
pub fn validate_username(username: &str) -> Result<(), ValidationError> {
let u = username.trim();
if u.is_empty() {
return err("username", "username must not be empty");
}
if u.chars().any(is_control) {
return err("username", "username must not contain control characters");
}
Ok(())
}
fn is_control(c: char) -> bool {
c < '\x20' || c == '\x7f'
}
pub fn validate_ssh_config(cfg: &SshConfig) -> Result<(), ValidationErrors> {
let mut errs = ValidationErrors::default();
if cfg.auth_methods.is_empty() {
errs.add("ssh.authMethods", "at least one authentication method is required");
}
for m in &cfg.auth_methods {
if !matches!(m.as_str(), "password" | "publickey" | "keyboard-interactive") {
errs.add("ssh.authMethods", format!("unsupported authentication method {m:?}"));
}
}
if cfg.keepalive_interval < 0 {
errs.add("ssh.keepaliveInterval", "keepalive interval must not be negative");
}
if cfg.keepalive_count_max < 0 {
errs.add("ssh.keepaliveCountMax", "keepalive count max must not be negative");
}
for (i, jh) in cfg.jump_hosts.iter().enumerate() {
let prefix = format!("ssh.jumpHosts[{i}]");
if jh.host.trim().is_empty() {
errs.add(format!("{prefix}.host"), "jump host address must not be empty");
}
if !(1..=65535).contains(&jh.port) {
errs.add(format!("{prefix}.port"), "jump host port must be between 1 and 65535");
}
if jh.username.trim().is_empty() {
errs.add(format!("{prefix}.username"), "jump host username must not be empty");
}
}
for (i, pf) in cfg.port_forwards.iter().enumerate() {
let prefix = format!("ssh.portForwards[{i}]");
if !(1..=65535).contains(&pf.bind_port) {
errs.add(format!("{prefix}.bindPort"), "bind port must be between 1 and 65535");
}
if !(1..=65535).contains(&pf.dest_port) {
errs.add(format!("{prefix}.destPort"), "destination port must be between 1 and 65535");
}
if pf.dest_host.trim().is_empty() {
errs.add(format!("{prefix}.destHost"), "destination host must not be empty");
}
}
errs.into_result()
}
pub fn validate_telnet_config(cfg: &TelnetConfig) -> Result<(), ValidationErrors> {
let mut errs = ValidationErrors::default();
if cfg.encoding.trim().is_empty() {
errs.add("telnet.encoding", "encoding must not be empty");
}
errs.into_result()
}
pub fn validate_raw_config(cfg: &RawConfig) -> Result<(), ValidationErrors> {
let mut errs = ValidationErrors::default();
if cfg.encoding.trim().is_empty() {
errs.add("raw.encoding", "encoding must not be empty");
}
for (i, preset) in cfg.payload_presets.iter().enumerate() {
let prefix = format!("raw.payloadPresets[{i}]");
if preset.name.trim().is_empty() {
errs.add(format!("{prefix}.name"), "payload preset name must not be empty");
}
if !matches!(preset.format.as_str(), "text" | "hex" | "escaped") {
errs.add(format!("{prefix}.format"), format!("unsupported payload format {:?}", preset.format));
}
}
errs.into_result()
}
/// The API base URL must be absolute HTTPS (HTTP only to loopback) without credentials.
pub fn validate_github_config(cfg: &GitHubConfig) -> Result<(), ValidationErrors> {
let mut errs = ValidationErrors::default();
match split_url(cfg.base_url.trim()) {
None => errs.add("github.baseURL", "API base URL must be an absolute HTTPS URL"),
Some((scheme, has_user, hostname)) => {
if has_user {
errs.add("github.baseURL", "API base URL must not include credentials");
}
if scheme != "https" && !(scheme == "http" && is_loopback_host(hostname)) {
errs.add("github.baseURL", "API base URL must use HTTPS unless it points to loopback");
}
}
}
if cfg.refresh_interval_secs != 0 && cfg.refresh_interval_secs < 60 {
errs.add("github.refreshIntervalSecs", "refresh interval must be at least 60 seconds");
}
errs.into_result()
}
/// (lowercased scheme, has userinfo, hostname without port/brackets) of an absolute URL.
fn split_url(s: &str) -> Option<(String, bool, &str)> {
let (scheme, rest) = s.split_once("://")?;
if scheme.is_empty() {
return None;
}
let authority = &rest[..rest.find(['/', '?', '#']).unwrap_or(rest.len())];
let (has_user, host) = match authority.rsplit_once('@') {
Some((_, h)) => (true, h),
None => (false, authority),
};
if host.is_empty() {
return None;
}
let hostname = match host.strip_prefix('[') {
Some(h) => h.split(']').next().unwrap_or(h),
None => host.rsplit_once(':').map_or(host, |(h, _)| h),
};
Some((scheme.to_ascii_lowercase(), has_user, hostname))
}
fn is_loopback_host(host: &str) -> bool {
let h = host.trim().to_lowercase();
h == "localhost" || h.parse::<std::net::IpAddr>().is_ok_and(|ip| ip.to_canonical().is_loopback())
}
/// The VNC host falls back to the profile host.
pub fn validate_vnc_config(profile_host: &str, cfg: &VncConfig) -> Result<(), ValidationErrors> {
let mut errs = ValidationErrors::default();
let host = if cfg.vnc_host.trim().is_empty() { profile_host } else { &cfg.vnc_host };
errs.check("vnc.vncHost", validate_host(host));
errs.check("vnc.vncPort", validate_port(cfg.vnc_port));
if !(0..=9).contains(&cfg.quality) {
errs.add("vnc.quality", "quality must be between 0 and 9");
}
if cfg.ssh_tunnel {
ssh_tunnel(&mut errs, "vnc", &cfg.ssh_host, cfg.ssh_port, &cfg.ssh_username, &cfg.ssh_auth_method);
}
errs.into_result()
}
fn ssh_tunnel(errs: &mut ValidationErrors, p: &str, host: &str, port: i64, user: &str, auth: &str) {
errs.check(&format!("{p}.sshHost"), validate_host(host));
errs.check(&format!("{p}.sshPort"), validate_port(port));
if user.trim().is_empty() {
errs.add(format!("{p}.sshUsername"), "SSH username is required when tunnelling");
} else {
errs.check(&format!("{p}.sshUsername"), validate_username(user));
}
if auth != "password" && auth != "publickey" {
errs.add(format!("{p}.sshAuthMethod"), format!("unsupported SSH auth method {auth:?}"));
}
}
pub fn validate_irc_config(cfg: &IrcConfig) -> Result<(), ValidationErrors> {
let mut errs = ValidationErrors::default();
let nick = cfg.nick.trim();
if nick.is_empty() {
errs.add("irc.nick", "nickname is required");
} else if !is_valid_nick(nick) {
errs.add("irc.nick", "nickname is not valid for IRC");
}
if cfg.real_name.contains(['\r', '\n', '\0']) {
errs.add("irc.realName", "real name must not contain control characters");
}
if cfg.nick_serv_identify && cfg.nick_serv_account.chars().any(is_control) {
errs.add("irc.nickServAccount", "NickServ account must not contain control characters");
}
let mut seen = std::collections::HashSet::new();
for (i, room) in cfg.auto_join_rooms.iter().enumerate() {
let field = format!("irc.autoJoinRooms[{i}]");
let name = room.trim();
if name.is_empty() {
errs.add(field, "room name must not be empty");
continue;
}
if !is_valid_channel(name) {
errs.add(field.clone(), "room name must start with a valid IRC channel prefix");
}
if !seen.insert(name.to_lowercase()) {
errs.add(field, "room is listed more than once");
}
}
if cfg.ssh_tunnel {
ssh_tunnel(&mut errs, "irc", &cfg.ssh_host, cfg.ssh_port, &cfg.ssh_username, &cfg.ssh_auth_method);
}
if cfg.max_reconnect_tries < 0 {
errs.add("irc.maxReconnectTries", "max reconnect attempts must not be negative");
}
errs.into_result()
}
/// Device path lives in `Profile::host` and is checked by `validate_profile`.
pub fn validate_serial_config(cfg: &SerialConfig) -> Result<(), ValidationErrors> {
let mut errs = ValidationErrors::default();
if cfg.baud_rate == 0 {
errs.add("serial.baudRate", "baud rate must be greater than 0");
}
if !(5..=8).contains(&cfg.data_bits) {
errs.add("serial.dataBits", "data bits must be between 5 and 8");
}
if cfg.stop_bits != 1 && cfg.stop_bits != 2 {
errs.add("serial.stopBits", "stop bits must be 1 or 2");
}
errs.into_result()
}
/// girc's IsValidNick: first byte in 'A'..='}' or '?', the rest also digits or '-'.
fn is_valid_nick(nick: &str) -> bool {
let b = nick.as_bytes();
let Some((&first, rest)) = b.split_first() else { return false };
((b'A'..=b'}').contains(&first) || first == b'?')
&& rest.iter().all(|c| (b'A'..=b'}').contains(c) || c.is_ascii_digit() || *c == b'-')
}
/// girc's IsValidChannel.
fn is_valid_channel(ch: &str) -> bool {
let b = ch.as_bytes();
if b.len() <= 1 || b.len() > 50 || !b"!#&*~+".contains(&b[0]) {
return false;
}
// !<5-char ID><name>
if b[0] == b'!' && (b.len() < 7 || !b[1..6].iter().all(|c| c.is_ascii_digit() || c.is_ascii_uppercase())) {
return false;
}
!b[1..].iter().any(|c| [0x00, 0x07, 0x0d, 0x0a, 0x20, 0x2c, 0x3a].contains(c))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::{JumpHost, PayloadPreset};
#[test]
fn hosts() {
for (h, ok) in [
("example.com", true),
("192.168.1.1", true),
("::1", true),
("", false),
(" ", false),
("host name", false),
("host\tname", false),
("ssh://example.com", false),
("-bad-host", false),
("server.internal.corp", true),
] {
assert_eq!(validate_host(h).is_ok(), ok, "{h:?}");
}
}
#[test]
fn ports() {
for (p, ok) in [(1, true), (22, true), (443, true), (65535, true), (0, false), (-1, false), (65536, false), (99999, false)] {
assert_eq!(validate_port(p).is_ok(), ok, "{p}");
}
}
#[test]
fn usernames() {
for (u, ok) in [
("admin", true),
("", false),
(" ", false),
("my user", true),
("abcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnopqrstuvwxyz0123456789", true),
("bad\x01user", false),
("bad\x7fuser", false),
] {
assert_eq!(validate_username(u).is_ok(), ok, "{u:?}");
}
}
fn ssh() -> Profile {
let mut p = Profile::new(Protocol::Ssh);
p.display_name = "Test SSH".into();
p.host = "example.com".into();
p.username = "root".into();
p
}
#[test]
fn profiles() {
let mut telnet = Profile::new(Protocol::Telnet);
telnet.display_name = "Test Telnet".into();
telnet.host = "switch.local".into();
let mut raw = Profile::new(Protocol::Raw);
raw.display_name = "Test RAW".into();
raw.host = "device.local".into();
let mut vnc = Profile::new(Protocol::Vnc);
vnc.display_name = "Test VNC".into();
vnc.vnc.as_mut().unwrap().vnc_host = "desktop.local".into();
for p in [ssh(), telnet, raw, vnc] {
assert!(validate_profile(&p).is_ok(), "{:?}", validate_profile(&p));
}
let bad: [fn(&mut Profile); 7] = [
|p| p.host.clear(),
|p| p.port = 0,
|p| p.protocol = Protocol::Local,
|p| p.username.clear(),
|p| p.ssh = None,
|p| p.connect_timeout = -1,
|p| p.terminal_font_size = 0,
];
for (i, f) in bad.iter().enumerate() {
let mut p = ssh();
f(&mut p);
assert!(validate_profile(&p).is_err(), "case {i}");
}
}
#[test]
fn serial_profiles() {
let mut p = Profile::new(Protocol::Serial);
p.display_name = "Console".into();
p.host = "/dev/ttyUSB0".into();
assert!(validate_profile(&p).is_ok());
let bad: [fn(&mut Profile); 6] = [
|p| p.host = " ".into(),
|p| p.serial = None,
|p| p.serial.as_mut().unwrap().baud_rate = 0,
|p| p.serial.as_mut().unwrap().data_bits = 4,
|p| p.serial.as_mut().unwrap().data_bits = 9,
|p| p.serial.as_mut().unwrap().stop_bits = 3,
];
for (i, f) in bad.iter().enumerate() {
let mut q = p.clone();
f(&mut q);
assert!(validate_profile(&q).is_err(), "case {i}");
}
}
#[test]
fn github() {
let cfg = |url: &str, secs| GitHubConfig { base_url: url.into(), refresh_interval_secs: secs, ..Default::default() };
for (c, ok) in [
(cfg("https://api.github.com", 300), true),
(cfg("http://localhost:3000/api/v3", 60), true),
(cfg("http://[::1]:3000/api/v3", 60), true),
(cfg("http://127.0.0.1/api/v3", 0), true),
(cfg("http://evil.example/api/v3", 300), false),
(cfg("https://token@example.com/api/v3", 300), false),
(cfg("https://api.github.com", 1), false),
(cfg("api.github.com", 300), false),
] {
assert_eq!(validate_github_config(&c).is_ok(), ok, "{c:?}");
}
}
#[test]
fn vnc() {
let base = VncConfig { vnc_port: 5900, quality: 6, ..Default::default() };
let named = VncConfig { vnc_host: "desktop.local".into(), ..base.clone() };
assert!(validate_vnc_config("", &named).is_ok());
assert!(validate_vnc_config("desktop.local", &base).is_ok());
assert!(validate_vnc_config("", &base).is_err());
let tunnel = VncConfig {
ssh_tunnel: true,
ssh_host: "jump.local".into(),
ssh_port: 22,
ssh_username: "admin".into(),
ssh_auth_method: "token".into(),
..base
};
assert!(validate_vnc_config("desktop.local", &tunnel).is_err());
}
#[test]
fn ssh_configs() {
let valid = || SshConfig { auth_methods: vec!["password".into()], keepalive_interval: 30, keepalive_count_max: 3, ..Default::default() };
assert!(validate_ssh_config(&valid()).is_ok());
assert!(validate_ssh_config(&SshConfig { auth_methods: vec![], ..valid() }).is_err());
assert!(validate_ssh_config(&SshConfig { auth_methods: vec!["oauth".into()], ..valid() }).is_err());
assert!(validate_ssh_config(&SshConfig { jump_hosts: vec![JumpHost::default()], ..valid() }).is_err());
}
#[test]
fn telnet_and_raw() {
assert!(validate_telnet_config(&TelnetConfig { encoding: "utf-8".into(), ..Default::default() }).is_ok());
assert!(validate_telnet_config(&TelnetConfig::default()).is_err());
let raw = |presets| RawConfig { encoding: "utf-8".into(), payload_presets: presets, ..Default::default() };
let preset = |n: &str, f: &str| PayloadPreset { name: n.into(), format: f.into(), ..Default::default() };
assert!(validate_raw_config(&raw(vec![])).is_ok());
assert!(validate_raw_config(&RawConfig::default()).is_err());
assert!(validate_raw_config(&raw(vec![preset("ping", "binary")])).is_err());
assert!(validate_raw_config(&raw(vec![preset("", "text")])).is_err());
}
#[test]
fn irc() {
let ok = IrcConfig { nick: "nexus".into(), auto_join_rooms: vec!["#rust".into(), "&local".into()], ..Default::default() };
assert!(validate_irc_config(&ok).is_ok());
for nick in ["", "1abc", "bad nick"] {
assert!(validate_irc_config(&IrcConfig { nick: nick.into(), ..ok.clone() }).is_err(), "{nick:?}");
}
for rooms in [vec!["rust"], vec!["#"], vec!["#a,b"], vec!["#rust", "#RUST"], vec![" "]] {
let c = IrcConfig { auto_join_rooms: rooms.iter().map(|r| r.to_string()).collect(), ..ok.clone() };
assert!(validate_irc_config(&c).is_err(), "{rooms:?}");
}
}
}