This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ForwardRule {
|
||||
pub local_port: u16,
|
||||
pub remote_host: String,
|
||||
pub remote_port: u16,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SshOptions {
|
||||
pub host: String,
|
||||
pub user: String,
|
||||
pub port: u16,
|
||||
pub jump_hosts: Vec<String>,
|
||||
pub agent_forwarding: bool,
|
||||
pub x11_forwarding: bool,
|
||||
pub port_forwards: Vec<ForwardRule>,
|
||||
pub login_script: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct TelnetOptions {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum SerialNewlineMode {
|
||||
Lf,
|
||||
CrLf,
|
||||
Cr,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SerialOptions {
|
||||
pub path: String,
|
||||
pub baud_rate: u32,
|
||||
pub newline_mode: SerialNewlineMode,
|
||||
pub hex_input: bool,
|
||||
pub hexdump_output: bool,
|
||||
pub auto_reconnect: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ConnectionOptions {
|
||||
Local { command: String },
|
||||
Ssh(SshOptions),
|
||||
Telnet(TelnetOptions),
|
||||
Serial(SerialOptions),
|
||||
}
|
||||
|
||||
impl ConnectionOptions {
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
match self {
|
||||
Self::Local { command } if command.trim().is_empty() => {
|
||||
Err("local profile command is required".to_string())
|
||||
}
|
||||
Self::Local { .. } => Ok(()),
|
||||
Self::Ssh(options) => options.validate(),
|
||||
Self::Telnet(options) => options.validate(),
|
||||
Self::Serial(options) => options.validate(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SshOptions {
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
if self.host.trim().is_empty() {
|
||||
return Err("SSH host is required".to_string());
|
||||
}
|
||||
if self.user.trim().is_empty() {
|
||||
return Err("SSH user is required".to_string());
|
||||
}
|
||||
if self.port == 0 {
|
||||
return Err("SSH port is required".to_string());
|
||||
}
|
||||
for forward in &self.port_forwards {
|
||||
if forward.local_port == 0
|
||||
|| forward.remote_port == 0
|
||||
|| forward.remote_host.trim().is_empty()
|
||||
{
|
||||
return Err(
|
||||
"SSH forwarding rule requires local port, remote host, and remote port"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl TelnetOptions {
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
if self.host.trim().is_empty() {
|
||||
return Err("Telnet host is required".to_string());
|
||||
}
|
||||
if self.port == 0 {
|
||||
return Err("Telnet port is required".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl SerialOptions {
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
if self.path.trim().is_empty() {
|
||||
return Err("serial path is required".to_string());
|
||||
}
|
||||
if self.baud_rate == 0 {
|
||||
return Err("serial baud rate is required".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn ssh() -> SshOptions {
|
||||
SshOptions {
|
||||
host: "example.com".to_string(),
|
||||
user: "tom".to_string(),
|
||||
port: 22,
|
||||
jump_hosts: vec!["bastion".to_string()],
|
||||
agent_forwarding: true,
|
||||
x11_forwarding: true,
|
||||
port_forwards: vec![ForwardRule {
|
||||
local_port: 8080,
|
||||
remote_host: "127.0.0.1".to_string(),
|
||||
remote_port: 80,
|
||||
}],
|
||||
login_script: Some("echo ready".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_advanced_ssh_options() {
|
||||
assert!(ConnectionOptions::Ssh(ssh()).validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_incomplete_ssh_forwarding_rule() {
|
||||
let mut options = ssh();
|
||||
options.port_forwards[0].remote_host = " ".to_string();
|
||||
|
||||
let err = ConnectionOptions::Ssh(options).validate().unwrap_err();
|
||||
|
||||
assert!(err.contains("forwarding"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_telnet_host_and_port() {
|
||||
assert!(ConnectionOptions::Telnet(TelnetOptions {
|
||||
host: "router.local".to_string(),
|
||||
port: 23,
|
||||
})
|
||||
.validate()
|
||||
.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_serial_terminal_options() {
|
||||
assert!(ConnectionOptions::Serial(SerialOptions {
|
||||
path: "/dev/ttyUSB0".to_string(),
|
||||
baud_rate: 115_200,
|
||||
newline_mode: SerialNewlineMode::CrLf,
|
||||
hex_input: true,
|
||||
hexdump_output: true,
|
||||
auto_reconnect: true,
|
||||
})
|
||||
.validate()
|
||||
.is_ok());
|
||||
}
|
||||
}
|
||||
@@ -4,14 +4,22 @@
|
||||
//! unit-tested without starting the desktop shell.
|
||||
|
||||
pub mod config;
|
||||
pub mod connection;
|
||||
pub mod feature;
|
||||
pub mod shortcut;
|
||||
pub mod terminal_model;
|
||||
pub mod transfer;
|
||||
pub mod workspace;
|
||||
|
||||
pub use config::{AppConfig, ShortcutBinding};
|
||||
pub use connection::{
|
||||
ConnectionOptions, ForwardRule, SerialNewlineMode, SerialOptions, SshOptions, TelnetOptions,
|
||||
};
|
||||
pub use feature::{
|
||||
all_features, feature_by_key, feature_matrix, FeatureArea, FeatureSpec, FeatureStatus,
|
||||
};
|
||||
pub use shortcut::{validate_unique_bindings, CommandBinding, KeyChord, ShortcutSequence};
|
||||
pub use transfer::{TransferDirection, TransferEvent, TransferProtocol, TransferRequest};
|
||||
pub use workspace::{ConnectionKind, PaneNode, Profile, SplitAxis, Tab, Workspace};
|
||||
|
||||
impl Workspace {
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
use std::fmt;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct KeyChord {
|
||||
pub modifiers: Vec<String>,
|
||||
pub key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ShortcutSequence {
|
||||
pub chords: Vec<KeyChord>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CommandBinding {
|
||||
pub command: String,
|
||||
pub sequence: ShortcutSequence,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ShortcutError {
|
||||
Empty,
|
||||
MissingKey,
|
||||
DuplicateBinding(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for ShortcutError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Empty => write!(f, "shortcut cannot be empty"),
|
||||
Self::MissingKey => write!(f, "shortcut chord must include a key"),
|
||||
Self::DuplicateBinding(binding) => write!(f, "duplicate shortcut binding: {binding}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ShortcutError {}
|
||||
|
||||
impl ShortcutSequence {
|
||||
pub fn parse(input: &str) -> Result<Self, ShortcutError> {
|
||||
let input = input.trim();
|
||||
if input.is_empty() {
|
||||
return Err(ShortcutError::Empty);
|
||||
}
|
||||
let chords = input
|
||||
.split_whitespace()
|
||||
.map(KeyChord::parse)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(Self { chords })
|
||||
}
|
||||
|
||||
pub fn canonical(&self) -> String {
|
||||
self.chords
|
||||
.iter()
|
||||
.map(KeyChord::canonical)
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
}
|
||||
|
||||
impl KeyChord {
|
||||
pub fn parse(input: &str) -> Result<Self, ShortcutError> {
|
||||
let mut parts = input
|
||||
.split('+')
|
||||
.map(str::trim)
|
||||
.filter(|part| !part.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
if parts.is_empty() {
|
||||
return Err(ShortcutError::MissingKey);
|
||||
}
|
||||
let key = parts.pop().unwrap().to_ascii_uppercase();
|
||||
if key.is_empty() {
|
||||
return Err(ShortcutError::MissingKey);
|
||||
}
|
||||
let mut modifiers = parts
|
||||
.into_iter()
|
||||
.map(normalize_modifier)
|
||||
.collect::<Vec<_>>();
|
||||
modifiers.sort();
|
||||
modifiers.dedup();
|
||||
Ok(Self { modifiers, key })
|
||||
}
|
||||
|
||||
pub fn canonical(&self) -> String {
|
||||
let mut parts = self.modifiers.clone();
|
||||
parts.push(self.key.clone());
|
||||
parts.join("+")
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_unique_bindings(bindings: &[CommandBinding]) -> Result<(), ShortcutError> {
|
||||
let mut seen = HashSet::new();
|
||||
for binding in bindings {
|
||||
let canonical = binding.sequence.canonical();
|
||||
if !seen.insert(canonical.clone()) {
|
||||
return Err(ShortcutError::DuplicateBinding(canonical));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn normalize_modifier(input: &str) -> String {
|
||||
match input.to_ascii_lowercase().as_str() {
|
||||
"cmd" | "command" | "meta" | "super" => "Meta".to_string(),
|
||||
"ctrl" | "control" => "Ctrl".to_string(),
|
||||
"alt" | "option" => "Alt".to_string(),
|
||||
"shift" => "Shift".to_string(),
|
||||
other => {
|
||||
let mut chars = other.chars();
|
||||
match chars.next() {
|
||||
Some(first) => format!("{}{}", first.to_ascii_uppercase(), chars.as_str()),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_single_chord_shortcut() {
|
||||
let shortcut = ShortcutSequence::parse("Ctrl+Shift+P").unwrap();
|
||||
|
||||
assert_eq!(shortcut.canonical(), "Ctrl+Shift+P");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_multi_chord_shortcut() {
|
||||
let shortcut = ShortcutSequence::parse("Ctrl+K Ctrl+S").unwrap();
|
||||
|
||||
assert_eq!(shortcut.chords.len(), 2);
|
||||
assert_eq!(shortcut.canonical(), "Ctrl+K Ctrl+S");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_duplicate_shortcut_bindings() {
|
||||
let bindings = vec![
|
||||
CommandBinding {
|
||||
command: "new-tab".to_string(),
|
||||
sequence: ShortcutSequence::parse("Ctrl+T").unwrap(),
|
||||
},
|
||||
CommandBinding {
|
||||
command: "another".to_string(),
|
||||
sequence: ShortcutSequence::parse("Control+T").unwrap(),
|
||||
},
|
||||
];
|
||||
|
||||
assert!(matches!(
|
||||
validate_unique_bindings(&bindings),
|
||||
Err(ShortcutError::DuplicateBinding(binding)) if binding == "Ctrl+T"
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum TransferProtocol {
|
||||
Sftp,
|
||||
Zmodem,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum TransferDirection {
|
||||
Upload,
|
||||
Download,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct TransferRequest {
|
||||
pub protocol: TransferProtocol,
|
||||
pub direction: TransferDirection,
|
||||
pub local_path: String,
|
||||
pub remote_path: String,
|
||||
}
|
||||
|
||||
impl TransferRequest {
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
if self.local_path.trim().is_empty() {
|
||||
return Err("local path is required".to_string());
|
||||
}
|
||||
if self.remote_path.trim().is_empty() {
|
||||
return Err("remote path is required".to_string());
|
||||
}
|
||||
if self.local_path.contains("..") {
|
||||
return Err("local path traversal is not allowed".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum TransferEvent {
|
||||
Queued,
|
||||
Progress { bytes_done: u64, bytes_total: u64 },
|
||||
Completed,
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn validates_sftp_download_request() {
|
||||
let request = TransferRequest {
|
||||
protocol: TransferProtocol::Sftp,
|
||||
direction: TransferDirection::Download,
|
||||
local_path: "downloads/file.txt".to_string(),
|
||||
remote_path: "/home/tom/file.txt".to_string(),
|
||||
};
|
||||
|
||||
assert!(request.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_zmodem_upload_request() {
|
||||
let request = TransferRequest {
|
||||
protocol: TransferProtocol::Zmodem,
|
||||
direction: TransferDirection::Upload,
|
||||
local_path: "uploads/firmware.bin".to_string(),
|
||||
remote_path: "firmware.bin".to_string(),
|
||||
};
|
||||
|
||||
assert!(request.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_local_path_traversal() {
|
||||
let request = TransferRequest {
|
||||
protocol: TransferProtocol::Sftp,
|
||||
direction: TransferDirection::Download,
|
||||
local_path: "../secret".to_string(),
|
||||
remote_path: "/tmp/secret".to_string(),
|
||||
};
|
||||
|
||||
assert!(request.validate().unwrap_err().contains("traversal"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user