Track plugin registry audits
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
use ely_domain::{DomainError, DownloadId, ProfileId, SpaceId, TabId};
|
use ely_domain::{DomainError, DownloadId, PluginId, ProfileId, SpaceId, TabId};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
#[derive(Clone, Debug, Error, Eq, PartialEq)]
|
#[derive(Clone, Debug, Error, Eq, PartialEq)]
|
||||||
@@ -21,6 +21,15 @@ pub enum CoreError {
|
|||||||
#[error("download target path is unavailable: {id}")]
|
#[error("download target path is unavailable: {id}")]
|
||||||
DownloadTargetPathUnavailable { id: DownloadId },
|
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}")]
|
#[error("favorite limit reached: {limit}")]
|
||||||
FavoriteLimitReached { limit: usize },
|
FavoriteLimitReached { limit: usize },
|
||||||
|
|
||||||
|
|||||||
@@ -3,4 +3,7 @@ mod navigation;
|
|||||||
mod state;
|
mod state;
|
||||||
|
|
||||||
pub use error::CoreError;
|
pub use error::CoreError;
|
||||||
pub use state::{BrowserCore, BrowserSnapshot, InitialBrowserConfig};
|
pub use state::{
|
||||||
|
BrowserCore, BrowserSnapshot, InitialBrowserConfig, InstalledPlugin, PluginAuditAction,
|
||||||
|
PluginAuditEvent,
|
||||||
|
};
|
||||||
|
|||||||
@@ -10,9 +10,12 @@ use crate::CoreError;
|
|||||||
mod commands;
|
mod commands;
|
||||||
mod downloads;
|
mod downloads;
|
||||||
mod history;
|
mod history;
|
||||||
|
mod plugins;
|
||||||
mod profiles;
|
mod profiles;
|
||||||
mod tabs;
|
mod tabs;
|
||||||
|
|
||||||
|
pub use plugins::{InstalledPlugin, PluginAuditAction, PluginAuditEvent};
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct InitialBrowserConfig {
|
pub struct InitialBrowserConfig {
|
||||||
pub space_name: String,
|
pub space_name: String,
|
||||||
@@ -40,6 +43,8 @@ pub struct BrowserSnapshot {
|
|||||||
pub archived_tabs: Vec<ArchivedTab>,
|
pub archived_tabs: Vec<ArchivedTab>,
|
||||||
pub download_entries: Vec<DownloadEntry>,
|
pub download_entries: Vec<DownloadEntry>,
|
||||||
pub history_entries: Vec<HistoryEntry>,
|
pub history_entries: Vec<HistoryEntry>,
|
||||||
|
pub installed_plugins: Vec<InstalledPlugin>,
|
||||||
|
pub plugin_audit_events: Vec<PluginAuditEvent>,
|
||||||
pub spaces: Vec<Space>,
|
pub spaces: Vec<Space>,
|
||||||
pub active_tab_id: TabId,
|
pub active_tab_id: TabId,
|
||||||
pub active_space_id: SpaceId,
|
pub active_space_id: SpaceId,
|
||||||
@@ -57,6 +62,8 @@ pub struct BrowserCore {
|
|||||||
archived_tabs: Vec<ArchivedTab>,
|
archived_tabs: Vec<ArchivedTab>,
|
||||||
download_entries: Vec<DownloadEntry>,
|
download_entries: Vec<DownloadEntry>,
|
||||||
history_entries: Vec<HistoryEntry>,
|
history_entries: Vec<HistoryEntry>,
|
||||||
|
installed_plugins: Vec<InstalledPlugin>,
|
||||||
|
plugin_audit_events: Vec<PluginAuditEvent>,
|
||||||
active_space_id: SpaceId,
|
active_space_id: SpaceId,
|
||||||
active_profile_id: ProfileId,
|
active_profile_id: ProfileId,
|
||||||
active_tab_id: TabId,
|
active_tab_id: TabId,
|
||||||
@@ -99,6 +106,8 @@ impl BrowserCore {
|
|||||||
archived_tabs: Vec::new(),
|
archived_tabs: Vec::new(),
|
||||||
download_entries: Vec::new(),
|
download_entries: Vec::new(),
|
||||||
history_entries: Vec::new(),
|
history_entries: Vec::new(),
|
||||||
|
installed_plugins: Vec::new(),
|
||||||
|
plugin_audit_events: Vec::new(),
|
||||||
command_query: String::new(),
|
command_query: String::new(),
|
||||||
new_tab_url,
|
new_tab_url,
|
||||||
})
|
})
|
||||||
@@ -186,6 +195,8 @@ impl BrowserCore {
|
|||||||
archived_tabs: self.archived_tabs.clone(),
|
archived_tabs: self.archived_tabs.clone(),
|
||||||
download_entries: self.visible_downloads(),
|
download_entries: self.visible_downloads(),
|
||||||
history_entries: self.visible_history(),
|
history_entries: self.visible_history(),
|
||||||
|
installed_plugins: self.installed_plugins.clone(),
|
||||||
|
plugin_audit_events: self.plugin_audit_events.clone(),
|
||||||
spaces: self.spaces.clone(),
|
spaces: self.spaces.clone(),
|
||||||
tabs: self.visible_tabs(),
|
tabs: self.visible_tabs(),
|
||||||
active_tab_id: self.active_tab_id.clone(),
|
active_tab_id: self.active_tab_id.clone(),
|
||||||
|
|||||||
@@ -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<PluginId, CoreError> {
|
||||||
|
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() })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<dyn Error>> {
|
||||||
|
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<dyn Error>> {
|
||||||
|
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<dyn Error>> {
|
||||||
|
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<dyn Error>> {
|
||||||
|
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<dyn Error>> {
|
||||||
|
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<dyn Error>> {
|
||||||
|
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<CoreError, Box<dyn Error>> {
|
||||||
|
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, Box<dyn Error>> {
|
||||||
|
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::<Vec<_>>()
|
||||||
|
.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"
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
use std::collections::BTreeSet;
|
use std::{collections::BTreeSet, fmt};
|
||||||
|
|
||||||
use semver::Version;
|
use semver::Version;
|
||||||
use serde::Deserialize;
|
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 {
|
impl PluginManifest {
|
||||||
pub fn from_toml(value: &str) -> Result<Self, DomainError> {
|
pub fn from_toml(value: &str) -> Result<Self, DomainError> {
|
||||||
let raw: RawPluginManifest = toml::from_str(value)
|
let raw: RawPluginManifest = toml::from_str(value)
|
||||||
|
|||||||
Reference in New Issue
Block a user