diff --git a/crates/ely_browser_core/src/error.rs b/crates/ely_browser_core/src/error.rs index ea24e57..d989a26 100644 --- a/crates/ely_browser_core/src/error.rs +++ b/crates/ely_browser_core/src/error.rs @@ -1,4 +1,4 @@ -use ely_domain::{DomainError, DownloadId, ProfileId, SpaceId, TabId}; +use ely_domain::{DomainError, DownloadId, PluginId, ProfileId, SpaceId, TabId}; use thiserror::Error; #[derive(Clone, Debug, Error, Eq, PartialEq)] @@ -21,6 +21,15 @@ pub enum CoreError { #[error("download target path is unavailable: {id}")] DownloadTargetPathUnavailable { id: DownloadId }, + #[error("plugin already installed: {id}")] + PluginAlreadyInstalled { id: PluginId }, + + #[error("plugin requires high-risk permission confirmation: {id}")] + PluginHighRiskConfirmationRequired { id: PluginId }, + + #[error("plugin not found: {id}")] + PluginNotFound { id: PluginId }, + #[error("favorite limit reached: {limit}")] FavoriteLimitReached { limit: usize }, diff --git a/crates/ely_browser_core/src/lib.rs b/crates/ely_browser_core/src/lib.rs index 0bd1845..74e3a6b 100644 --- a/crates/ely_browser_core/src/lib.rs +++ b/crates/ely_browser_core/src/lib.rs @@ -3,4 +3,7 @@ mod navigation; mod state; pub use error::CoreError; -pub use state::{BrowserCore, BrowserSnapshot, InitialBrowserConfig}; +pub use state::{ + BrowserCore, BrowserSnapshot, InitialBrowserConfig, InstalledPlugin, PluginAuditAction, + PluginAuditEvent, +}; diff --git a/crates/ely_browser_core/src/state.rs b/crates/ely_browser_core/src/state.rs index ee9e755..5864d19 100644 --- a/crates/ely_browser_core/src/state.rs +++ b/crates/ely_browser_core/src/state.rs @@ -10,9 +10,12 @@ use crate::CoreError; mod commands; mod downloads; mod history; +mod plugins; mod profiles; mod tabs; +pub use plugins::{InstalledPlugin, PluginAuditAction, PluginAuditEvent}; + #[derive(Clone, Debug)] pub struct InitialBrowserConfig { pub space_name: String, @@ -40,6 +43,8 @@ pub struct BrowserSnapshot { pub archived_tabs: Vec, pub download_entries: Vec, pub history_entries: Vec, + pub installed_plugins: Vec, + pub plugin_audit_events: Vec, pub spaces: Vec, pub active_tab_id: TabId, pub active_space_id: SpaceId, @@ -57,6 +62,8 @@ pub struct BrowserCore { archived_tabs: Vec, download_entries: Vec, history_entries: Vec, + installed_plugins: Vec, + plugin_audit_events: Vec, active_space_id: SpaceId, active_profile_id: ProfileId, active_tab_id: TabId, @@ -99,6 +106,8 @@ impl BrowserCore { archived_tabs: Vec::new(), download_entries: Vec::new(), history_entries: Vec::new(), + installed_plugins: Vec::new(), + plugin_audit_events: Vec::new(), command_query: String::new(), new_tab_url, }) @@ -186,6 +195,8 @@ impl BrowserCore { archived_tabs: self.archived_tabs.clone(), download_entries: self.visible_downloads(), history_entries: self.visible_history(), + installed_plugins: self.installed_plugins.clone(), + plugin_audit_events: self.plugin_audit_events.clone(), spaces: self.spaces.clone(), tabs: self.visible_tabs(), active_tab_id: self.active_tab_id.clone(), diff --git a/crates/ely_browser_core/src/state/plugins.rs b/crates/ely_browser_core/src/state/plugins.rs new file mode 100644 index 0000000..b68b668 --- /dev/null +++ b/crates/ely_browser_core/src/state/plugins.rs @@ -0,0 +1,140 @@ +use std::time::SystemTime; + +use ely_domain::{PluginId, PluginManifest}; + +use crate::CoreError; + +use super::BrowserCore; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct InstalledPlugin { + manifest: PluginManifest, + enabled: bool, + high_risk_confirmed: bool, + installed_at: SystemTime, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum PluginAuditAction { + Installed, + Enabled, + Disabled, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PluginAuditEvent { + plugin_id: PluginId, + action: PluginAuditAction, + created_at: SystemTime, +} + +impl InstalledPlugin { + fn new(manifest: PluginManifest, high_risk_confirmed: bool, installed_at: SystemTime) -> Self { + Self { manifest, enabled: true, high_risk_confirmed, installed_at } + } + + fn set_enabled(&mut self, enabled: bool) { + self.enabled = enabled; + } + + #[must_use] + pub fn manifest(&self) -> &PluginManifest { + &self.manifest + } + + #[must_use] + pub fn id(&self) -> &PluginId { + self.manifest.id() + } + + #[must_use] + pub fn enabled(&self) -> bool { + self.enabled + } + + #[must_use] + pub fn high_risk_confirmed(&self) -> bool { + self.high_risk_confirmed + } + + #[must_use] + pub fn installed_at(&self) -> SystemTime { + self.installed_at + } +} + +impl PluginAuditEvent { + fn record(plugin_id: PluginId, action: PluginAuditAction, created_at: SystemTime) -> Self { + Self { plugin_id, action, created_at } + } + + #[must_use] + pub fn plugin_id(&self) -> &PluginId { + &self.plugin_id + } + + #[must_use] + pub fn action(&self) -> &PluginAuditAction { + &self.action + } + + #[must_use] + pub fn created_at(&self) -> SystemTime { + self.created_at + } +} + +impl BrowserCore { + pub fn install_plugin( + &mut self, + manifest: PluginManifest, + high_risk_confirmed: bool, + ) -> Result { + let plugin_id = manifest.id().clone(); + if self.plugin_installed(&plugin_id) { + return Err(CoreError::PluginAlreadyInstalled { id: plugin_id }); + } + if manifest.high_risk_permissions().next().is_some() && !high_risk_confirmed { + return Err(CoreError::PluginHighRiskConfirmationRequired { id: plugin_id }); + } + + self.installed_plugins.push(InstalledPlugin::new( + manifest, + high_risk_confirmed, + SystemTime::now(), + )); + self.record_plugin_audit_event(plugin_id.clone(), PluginAuditAction::Installed); + Ok(plugin_id) + } + + pub fn enable_plugin(&mut self, plugin_id: &PluginId) -> Result<(), CoreError> { + self.plugin_mut(plugin_id)?.set_enabled(true); + self.record_plugin_audit_event(plugin_id.clone(), PluginAuditAction::Enabled); + Ok(()) + } + + pub fn disable_plugin(&mut self, plugin_id: &PluginId) -> Result<(), CoreError> { + self.plugin_mut(plugin_id)?.set_enabled(false); + self.record_plugin_audit_event(plugin_id.clone(), PluginAuditAction::Disabled); + Ok(()) + } + + fn record_plugin_audit_event(&mut self, plugin_id: PluginId, action: PluginAuditAction) { + self.plugin_audit_events.push(PluginAuditEvent::record( + plugin_id, + action, + SystemTime::now(), + )); + } + + fn plugin_installed(&self, plugin_id: &PluginId) -> bool { + self.installed_plugins.iter().any(|plugin| plugin.id() == plugin_id) + } + + fn plugin_mut(&mut self, plugin_id: &PluginId) -> Result<&mut InstalledPlugin, CoreError> { + self.installed_plugins + .iter_mut() + .find(|plugin| plugin.id() == plugin_id) + .ok_or_else(|| CoreError::PluginNotFound { id: plugin_id.clone() }) + } +} diff --git a/crates/ely_browser_core/tests/plugins.rs b/crates/ely_browser_core/tests/plugins.rs new file mode 100644 index 0000000..21ac276 --- /dev/null +++ b/crates/ely_browser_core/tests/plugins.rs @@ -0,0 +1,153 @@ +use std::{error::Error, io}; + +use ely_browser_core::{BrowserCore, CoreError, InitialBrowserConfig, PluginAuditAction}; +use ely_domain::{PluginId, PluginManifest}; + +#[test] +fn installs_standard_plugin_and_records_audit_event() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let manifest = plugin_manifest("com.elydora.reader", &["page:metadata", "ui:command"])?; + + let plugin_id = core.install_plugin(manifest, false)?; + let snapshot = core.snapshot()?; + + assert_eq!(plugin_id.as_str(), "com.elydora.reader"); + assert_eq!(snapshot.installed_plugins.len(), 1); + assert_eq!(snapshot.installed_plugins[0].id(), &plugin_id); + assert!(snapshot.installed_plugins[0].enabled()); + assert!(!snapshot.installed_plugins[0].high_risk_confirmed()); + assert_eq!(snapshot.plugin_audit_events.len(), 1); + assert_eq!(snapshot.plugin_audit_events[0].plugin_id(), &plugin_id); + assert_eq!(snapshot.plugin_audit_events[0].action(), &PluginAuditAction::Installed); + Ok(()) +} + +#[test] +fn requires_confirmation_for_high_risk_plugin_install() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let manifest = plugin_manifest("com.elydora.scripter", &["page:script"])?; + + let error = install_error(&mut core, manifest, false)?; + let snapshot = core.snapshot()?; + + assert!(matches!( + error, + CoreError::PluginHighRiskConfirmationRequired { id } + if id.as_str() == "com.elydora.scripter" + )); + assert!(snapshot.installed_plugins.is_empty()); + assert!(snapshot.plugin_audit_events.is_empty()); + Ok(()) +} + +#[test] +fn installs_high_risk_plugin_after_confirmation() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let manifest = plugin_manifest("com.elydora.scripter", &["page:script"])?; + + let plugin_id = core.install_plugin(manifest, true)?; + let snapshot = core.snapshot()?; + + assert_eq!(snapshot.installed_plugins.len(), 1); + assert_eq!(snapshot.installed_plugins[0].id(), &plugin_id); + assert!(snapshot.installed_plugins[0].high_risk_confirmed()); + assert_eq!(snapshot.plugin_audit_events[0].action(), &PluginAuditAction::Installed); + Ok(()) +} + +#[test] +fn rejects_duplicate_plugin_install() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + core.install_plugin(plugin_manifest("com.elydora.reader", &["page:metadata"])?, false)?; + let duplicate = plugin_manifest("com.elydora.reader", &["page:metadata"])?; + + let error = install_error(&mut core, duplicate, false)?; + let snapshot = core.snapshot()?; + + assert!(matches!( + error, + CoreError::PluginAlreadyInstalled { id } if id.as_str() == "com.elydora.reader" + )); + assert_eq!(snapshot.installed_plugins.len(), 1); + assert_eq!(snapshot.plugin_audit_events.len(), 1); + Ok(()) +} + +#[test] +fn records_plugin_enable_disable_audit_events() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let plugin_id = + core.install_plugin(plugin_manifest("com.elydora.reader", &["page:metadata"])?, false)?; + + core.disable_plugin(&plugin_id)?; + let disabled_snapshot = core.snapshot()?; + + assert!(!disabled_snapshot.installed_plugins[0].enabled()); + assert_eq!(disabled_snapshot.plugin_audit_events.len(), 2); + assert_eq!(disabled_snapshot.plugin_audit_events[1].action(), &PluginAuditAction::Disabled); + + core.enable_plugin(&plugin_id)?; + let enabled_snapshot = core.snapshot()?; + + assert!(enabled_snapshot.installed_plugins[0].enabled()); + assert_eq!(enabled_snapshot.plugin_audit_events.len(), 3); + assert_eq!(enabled_snapshot.plugin_audit_events[2].action(), &PluginAuditAction::Enabled); + Ok(()) +} + +#[test] +fn rejects_unknown_plugin_state_change() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let plugin_id = PluginId::parse("com.elydora.missing")?; + + let error = core + .disable_plugin(&plugin_id) + .err() + .ok_or_else(|| io::Error::other("missing plugin disable succeeded"))?; + + assert!(matches!( + error, + CoreError::PluginNotFound { id } if id.as_str() == "com.elydora.missing" + )); + Ok(()) +} + +fn install_error( + core: &mut BrowserCore, + manifest: PluginManifest, + high_risk_confirmed: bool, +) -> Result> { + core.install_plugin(manifest, high_risk_confirmed) + .err() + .ok_or_else(|| io::Error::other("plugin install succeeded").into()) +} + +fn plugin_manifest(id: &str, permissions: &[&str]) -> Result> { + PluginManifest::from_toml(plugin_manifest_toml(id, permissions).as_str()).map_err(Into::into) +} + +fn plugin_manifest_toml(id: &str, permissions: &[&str]) -> String { + let permission_values = permissions + .iter() + .map(|permission| format!("\"{permission}\"")) + .collect::>() + .join(", "); + + format!( + r#" +id = "{id}" +name = "Reader Exporter" +description = "Exports the active reader view." +author = "Elydora" +homepage = "https://elydora.com/plugins/reader" +permissions = [{permission_values}] +contributes = ["command-bar-command"] +min_ely_build = "0.1.0" +checksum = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + +[signature] +algorithm = "ed25519" +value = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB" +"# + ) +} diff --git a/crates/ely_domain/src/plugin.rs b/crates/ely_domain/src/plugin.rs index 6d0246f..37a3388 100644 --- a/crates/ely_domain/src/plugin.rs +++ b/crates/ely_domain/src/plugin.rs @@ -1,4 +1,4 @@ -use std::collections::BTreeSet; +use std::{collections::BTreeSet, fmt}; use semver::Version; use serde::Deserialize; @@ -119,6 +119,12 @@ impl PluginId { } } +impl fmt::Display for PluginId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + impl PluginManifest { pub fn from_toml(value: &str) -> Result { let raw: RawPluginManifest = toml::from_str(value)