Files
nexus/rust/core/src/validation.rs
T
Andy Dixon 13ca82ab2d fix(storage): transactional migrations, purge legacy plaintext secrets, private DB files, stricter ids and hosts
- Each migration runs in a transaction. Several rebuild the profiles
  table (create, copy, DROP, rename); a crash between the DROP and the
  rename left a database that failed to open on every later launch.
- Migration 009 removes the Go-era proxyPassword/totpSecret keys still
  sitting in old ssh_config JSON, then VACUUMs and checkpoints the WAL so
  the old pages leave the file. secure_delete is on from now on.
- nexus.db (and -wal/-shm) are created and kept 0600.
- Profile ids are limited to [A-Za-z0-9._-]{1,64}: ids name keychain and
  vault entries (profile:<id>:<kind>), so a crafted id containing ':'
  could shadow or purge another profile's credentials on import/sync.
- Hosts reject control characters (NUL got through) and >253 bytes; jump
  hosts and forward destination/bind addresses get the same host check.
2026-10-13 06:55:49 +01:00

662 lines
26 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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, RdpConfig, 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");
} else if p.id.len() > 64 || !p.id.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')) {
// Ids name keychain/vault entries (`profile:<id>:<kind>`): a crafted id containing `:`
// could shadow or purge another profile's credentials.
errs.add("id", "profile ID may only contain letters, digits, '-', '_' and '.'");
}
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::Rdp => {
host_port(&mut errs, &p.host, p.port);
match &p.rdp {
None => errs.add("rdp", "RDP configuration is required for RDP protocol"),
Some(c) => errs.merge(validate_rdp_config(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.chars().any(is_control) {
return err("host", "host must not contain control characters");
}
if h.len() > 253 {
return err("host", "host is too long");
}
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 let Err(e) = validate_host(&jh.host) {
errs.add(format!("{prefix}.host"), format!("jump host: {}", e.message));
}
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 let Err(e) = validate_host(&pf.dest_host) {
errs.add(format!("{prefix}.destHost"), format!("destination: {}", e.message));
}
if !pf.bind_host.trim().is_empty()
&& let Err(e) = validate_host(&pf.bind_host)
{
errs.add(format!("{prefix}.bindHost"), format!("bind address: {}", e.message));
}
}
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()
}
pub fn validate_rdp_config(cfg: &RdpConfig) -> Result<(), ValidationErrors> {
let mut errs = ValidationErrors::default();
for (field, v) in [("rdp.width", cfg.width), ("rdp.height", cfg.height)] {
if v != 0 && !(200..=8192).contains(&v) {
errs.add(field, "desktop size must be 0 (fit the tab) or 200–8192 pixels");
}
}
match cfg.proxy_type.as_str() {
"" | "none" => {}
"socks5" | "http" => {
errs.check("rdp.proxyHost", validate_host(&cfg.proxy_host));
if cfg.proxy_port != 0 {
errs.check("rdp.proxyPort", validate_port(cfg.proxy_port));
}
}
t => errs.add("rdp.proxyType", format!("unsupported proxy type {t:?}")),
}
if !cfg.gateway_host.trim().is_empty() {
let host = cfg.gateway_host.trim().rsplit_once(':').filter(|(_, p)| p.parse::<u16>().is_ok()).map_or(cfg.gateway_host.trim(), |(h, _)| h);
errs.check("rdp.gatewayHost", validate_host(host));
if cfg.gateway_username.trim().is_empty() {
errs.add("rdp.gatewayUsername", "the gateway needs a username");
}
if cfg.ssh_tunnel || !matches!(cfg.proxy_type.as_str(), "" | "none") {
errs.add("rdp.gatewayHost", "use a gateway, a proxy or an SSH tunnel — only one");
}
}
let share = cfg.shared_folder.trim();
if !share.is_empty() && !std::path::Path::new(share).is_dir() {
errs.add("rdp.sharedFolder", "the shared folder must be an existing folder");
}
if cfg.ssh_tunnel {
if !matches!(cfg.proxy_type.as_str(), "" | "none") {
errs.add("rdp.proxyType", "use either a proxy or an SSH tunnel, not both");
}
ssh_tunnel(&mut errs, "rdp", &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::*;
#[test]
fn rdp_profiles() {
let mut p = Profile::new(Protocol::Rdp);
p.host = "win.example".into();
p.normalize();
assert!(validate_profile(&p).is_ok(), "{:?}", validate_profile(&p).err().map(|e| e.to_string()));
let c = p.rdp.as_mut().unwrap();
c.width = 100;
c.proxy_type = "socks5".into();
c.ssh_tunnel = true;
let msg = validate_profile(&p).unwrap_err().to_string();
for want in ["rdp.width", "rdp.proxyHost", "rdp.sshHost", "not both"] {
assert!(msg.contains(want), "{want}: {msg}");
}
}
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:?}");
}
}
#[test]
fn hosts_and_ids_reject_control_characters_and_separators() {
assert!(validate_host("a\0b").is_err() && validate_host("a\x1bb").is_err());
assert!(validate_host(&"a".repeat(254)).is_err() && validate_host("db.example.com").is_ok() && validate_host("::1").is_ok());
let mut p = Profile::new(Protocol::Ssh);
p.display_name = "x".into();
p.host = "h".into();
p.username = "u".into();
assert!(validate_profile(&p).is_ok(), "a generated id is valid");
for bad in ["victim:password", "../x", "a b", &"a".repeat(65)] {
p.id = bad.into();
assert!(validate_profile(&p).is_err(), "{bad:?}");
}
p.id = "rdp-test_1.a".into();
assert!(validate_profile(&p).is_ok());
}
}