From cedc4319ede98e6d6586df8b739f18bfeee651ac Mon Sep 17 00:00:00 2001 From: Tom You Date: Wed, 8 Jul 2026 10:45:28 -0500 Subject: [PATCH] feat(core): model secrets plugins and themes --- rabby-core/src/lib.rs | 6 +++ rabby-core/src/plugin/mod.rs | 72 +++++++++++++++++++++++++++++ rabby-core/src/secret/mod.rs | 90 ++++++++++++++++++++++++++++++++++++ rabby-core/src/theme/mod.rs | 59 +++++++++++++++++++++++ 4 files changed, 227 insertions(+) create mode 100644 rabby-core/src/plugin/mod.rs create mode 100644 rabby-core/src/secret/mod.rs create mode 100644 rabby-core/src/theme/mod.rs diff --git a/rabby-core/src/lib.rs b/rabby-core/src/lib.rs index f9f792a..d3b15f9 100644 --- a/rabby-core/src/lib.rs +++ b/rabby-core/src/lib.rs @@ -6,8 +6,11 @@ pub mod config; pub mod connection; pub mod feature; +pub mod plugin; +pub mod secret; pub mod shortcut; pub mod terminal_model; +pub mod theme; pub mod transfer; pub mod workspace; @@ -18,7 +21,10 @@ pub use connection::{ pub use feature::{ all_features, feature_by_key, feature_matrix, FeatureArea, FeatureSpec, FeatureStatus, }; +pub use plugin::{PluginManifest, PluginPermission}; +pub use secret::{SecretKind, SecretRef, SecretString}; pub use shortcut::{validate_unique_bindings, CommandBinding, KeyChord, ShortcutSequence}; +pub use theme::Theme; pub use transfer::{TransferDirection, TransferEvent, TransferProtocol, TransferRequest}; pub use workspace::{ConnectionKind, PaneNode, Profile, SplitAxis, Tab, Workspace}; diff --git a/rabby-core/src/plugin/mod.rs b/rabby-core/src/plugin/mod.rs new file mode 100644 index 0000000..ac2ba94 --- /dev/null +++ b/rabby-core/src/plugin/mod.rs @@ -0,0 +1,72 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum PluginPermission { + TerminalRead, + TerminalWrite, + ProfileRead, + FileTransfer, + Network, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PluginManifest { + pub id: String, + pub name: String, + pub version: String, + pub permissions: Vec, +} + +impl PluginManifest { + pub fn validate(&self) -> Result<(), String> { + if self.id.trim().is_empty() { + return Err("plugin id is required".to_string()); + } + if self.name.trim().is_empty() { + return Err("plugin name is required".to_string()); + } + if self.version.trim().is_empty() { + return Err("plugin version is required".to_string()); + } + let mut seen = HashSet::new(); + for permission in &self.permissions { + if !seen.insert(format!("{permission:?}")) { + return Err("duplicate plugin permission".to_string()); + } + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validates_plugin_manifest_permissions() { + let manifest = PluginManifest { + id: "sftp-tools".to_string(), + name: "SFTP Tools".to_string(), + version: "0.1.0".to_string(), + permissions: vec![ + PluginPermission::ProfileRead, + PluginPermission::FileTransfer, + ], + }; + + assert!(manifest.validate().is_ok()); + } + + #[test] + fn rejects_duplicate_plugin_permissions() { + let manifest = PluginManifest { + id: "bad".to_string(), + name: "Bad".to_string(), + version: "0.1.0".to_string(), + permissions: vec![PluginPermission::Network, PluginPermission::Network], + }; + + assert!(manifest.validate().unwrap_err().contains("duplicate")); + } +} diff --git a/rabby-core/src/secret/mod.rs b/rabby-core/src/secret/mod.rs new file mode 100644 index 0000000..46517f0 --- /dev/null +++ b/rabby-core/src/secret/mod.rs @@ -0,0 +1,90 @@ +use serde::{Deserialize, Serialize}; +use std::fmt; + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SecretString(String); + +impl SecretString { + pub fn new(value: impl Into) -> Self { + Self(value.into()) + } + + pub fn expose_for_store(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for SecretString { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "SecretString([REDACTED])") + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum SecretKind { + Password, + PrivateKey, + Token, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SecretRef { + pub id: String, + pub kind: SecretKind, +} + +pub trait SecretStore { + fn put(&mut self, id: &str, secret: SecretString) -> Result; + fn get(&self, id: &str) -> Result; + fn delete(&mut self, id: &str) -> Result<(), String>; +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + #[derive(Default)] + struct MemorySecretStore { + values: HashMap, + } + + impl SecretStore for MemorySecretStore { + fn put(&mut self, id: &str, secret: SecretString) -> Result { + self.values.insert(id.to_string(), secret); + Ok(SecretRef { + id: id.to_string(), + kind: SecretKind::Password, + }) + } + + fn get(&self, id: &str) -> Result { + self.values + .get(id) + .cloned() + .ok_or_else(|| format!("unknown secret: {id}")) + } + + fn delete(&mut self, id: &str) -> Result<(), String> { + self.values.remove(id); + Ok(()) + } + } + + #[test] + fn secret_debug_output_is_redacted() { + let secret = SecretString::new("super-secret"); + + assert!(!format!("{secret:?}").contains("super-secret")); + assert!(format!("{secret:?}").contains("REDACTED")); + } + + #[test] + fn secret_store_boundary_round_trips_secret_refs() { + let mut store = MemorySecretStore::default(); + let reference = store.put("prod-password", SecretString::new("pw")).unwrap(); + + assert_eq!(reference.id, "prod-password"); + assert_eq!(store.get("prod-password").unwrap().expose_for_store(), "pw"); + } +} diff --git a/rabby-core/src/theme/mod.rs b/rabby-core/src/theme/mod.rs new file mode 100644 index 0000000..840ac5f --- /dev/null +++ b/rabby-core/src/theme/mod.rs @@ -0,0 +1,59 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Theme { + pub id: String, + pub name: String, + pub background: String, + pub foreground: String, + pub accent: String, +} + +impl Theme { + pub fn rabby_dark() -> Self { + Self { + id: "rabby-dark".to_string(), + name: "Rabby Dark".to_string(), + background: "#0d1117".to_string(), + foreground: "#d7dde8".to_string(), + accent: "#7aa2ff".to_string(), + } + } + + pub fn validate(&self) -> Result<(), String> { + for (name, value) in [ + ("background", &self.background), + ("foreground", &self.foreground), + ("accent", &self.accent), + ] { + if !is_hex_color(value) { + return Err(format!("{name} must be a #RRGGBB color")); + } + } + Ok(()) + } +} + +fn is_hex_color(value: &str) -> bool { + value.len() == 7 + && value.starts_with('#') + && value.chars().skip(1).all(|ch| ch.is_ascii_hexdigit()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn built_in_theme_is_valid() { + assert!(Theme::rabby_dark().validate().is_ok()); + } + + #[test] + fn rejects_invalid_theme_colors() { + let mut theme = Theme::rabby_dark(); + theme.accent = "blue".to_string(); + + assert!(theme.validate().unwrap_err().contains("accent")); + } +}