From 1c4dd973c163fe5587de4eaf87d4dd159022774f Mon Sep 17 00:00:00 2001 From: Tom You Date: Wed, 8 Jul 2026 10:43:56 -0500 Subject: [PATCH] feat(core): model shortcuts connections and transfers --- rabby-core/src/connection/mod.rs | 176 +++++++++++++++++++++++++++++++ rabby-core/src/lib.rs | 8 ++ rabby-core/src/shortcut/mod.rs | 157 +++++++++++++++++++++++++++ rabby-core/src/transfer/mod.rs | 85 +++++++++++++++ 4 files changed, 426 insertions(+) create mode 100644 rabby-core/src/connection/mod.rs create mode 100644 rabby-core/src/shortcut/mod.rs create mode 100644 rabby-core/src/transfer/mod.rs diff --git a/rabby-core/src/connection/mod.rs b/rabby-core/src/connection/mod.rs new file mode 100644 index 0000000..d7af05d --- /dev/null +++ b/rabby-core/src/connection/mod.rs @@ -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, + pub agent_forwarding: bool, + pub x11_forwarding: bool, + pub port_forwards: Vec, + pub login_script: Option, +} + +#[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()); + } +} diff --git a/rabby-core/src/lib.rs b/rabby-core/src/lib.rs index 177f096..f9f792a 100644 --- a/rabby-core/src/lib.rs +++ b/rabby-core/src/lib.rs @@ -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 { diff --git a/rabby-core/src/shortcut/mod.rs b/rabby-core/src/shortcut/mod.rs new file mode 100644 index 0000000..89762ff --- /dev/null +++ b/rabby-core/src/shortcut/mod.rs @@ -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, + pub key: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ShortcutSequence { + pub chords: Vec, +} + +#[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 { + let input = input.trim(); + if input.is_empty() { + return Err(ShortcutError::Empty); + } + let chords = input + .split_whitespace() + .map(KeyChord::parse) + .collect::, _>>()?; + Ok(Self { chords }) + } + + pub fn canonical(&self) -> String { + self.chords + .iter() + .map(KeyChord::canonical) + .collect::>() + .join(" ") + } +} + +impl KeyChord { + pub fn parse(input: &str) -> Result { + let mut parts = input + .split('+') + .map(str::trim) + .filter(|part| !part.is_empty()) + .collect::>(); + 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::>(); + 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" + )); + } +} diff --git a/rabby-core/src/transfer/mod.rs b/rabby-core/src/transfer/mod.rs new file mode 100644 index 0000000..148a85f --- /dev/null +++ b/rabby-core/src/transfer/mod.rs @@ -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")); + } +}