This commit is contained in:
@@ -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};
|
||||
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user