This commit is contained in:
@@ -6,8 +6,11 @@
|
|||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod connection;
|
pub mod connection;
|
||||||
pub mod feature;
|
pub mod feature;
|
||||||
|
pub mod plugin;
|
||||||
|
pub mod secret;
|
||||||
pub mod shortcut;
|
pub mod shortcut;
|
||||||
pub mod terminal_model;
|
pub mod terminal_model;
|
||||||
|
pub mod theme;
|
||||||
pub mod transfer;
|
pub mod transfer;
|
||||||
pub mod workspace;
|
pub mod workspace;
|
||||||
|
|
||||||
@@ -18,7 +21,10 @@ pub use connection::{
|
|||||||
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 plugin::{PluginManifest, PluginPermission};
|
||||||
|
pub use secret::{SecretKind, SecretRef, SecretString};
|
||||||
pub use shortcut::{validate_unique_bindings, CommandBinding, KeyChord, ShortcutSequence};
|
pub use shortcut::{validate_unique_bindings, CommandBinding, KeyChord, ShortcutSequence};
|
||||||
|
pub use theme::Theme;
|
||||||
pub use transfer::{TransferDirection, TransferEvent, TransferProtocol, TransferRequest};
|
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};
|
||||||
|
|
||||||
|
|||||||
@@ -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