feat(core): model secrets plugins and themes
test / workspace (push) Successful in 12m48s

This commit is contained in:
Tom You
2026-07-08 10:45:28 -05:00
parent 1c4dd973c1
commit cedc4319ed
4 changed files with 227 additions and 0 deletions
+6
View File
@@ -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};
+72
View File
@@ -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<PluginPermission>,
}
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"));
}
}
+90
View File
@@ -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<String>) -> 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<SecretRef, String>;
fn get(&self, id: &str) -> Result<SecretString, String>;
fn delete(&mut self, id: &str) -> Result<(), String>;
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
#[derive(Default)]
struct MemorySecretStore {
values: HashMap<String, SecretString>,
}
impl SecretStore for MemorySecretStore {
fn put(&mut self, id: &str, secret: SecretString) -> Result<SecretRef, String> {
self.values.insert(id.to_string(), secret);
Ok(SecretRef {
id: id.to_string(),
kind: SecretKind::Password,
})
}
fn get(&self, id: &str) -> Result<SecretString, String> {
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");
}
}
+59
View File
@@ -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"));
}
}