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.
|
//! unit-tested without starting the desktop shell.
|
||||||
|
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
pub mod connection;
|
||||||
pub mod feature;
|
pub mod feature;
|
||||||
|
pub mod shortcut;
|
||||||
pub mod terminal_model;
|
pub mod terminal_model;
|
||||||
|
pub mod transfer;
|
||||||
pub mod workspace;
|
pub mod workspace;
|
||||||
|
|
||||||
pub use config::{AppConfig, ShortcutBinding};
|
pub use config::{AppConfig, ShortcutBinding};
|
||||||
|
pub use connection::{
|
||||||
|
ConnectionOptions, ForwardRule, SerialNewlineMode, SerialOptions, SshOptions, TelnetOptions,
|
||||||
|
};
|
||||||
pub use feature::{
|
pub use feature::{
|
||||||
all_features, feature_by_key, feature_matrix, FeatureArea, FeatureSpec, FeatureStatus,
|
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};
|
pub use workspace::{ConnectionKind, PaneNode, Profile, SplitAxis, Tab, Workspace};
|
||||||
|
|
||||||
impl 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