use std::{collections::BTreeSet, fmt}; use semver::Version; use serde::Deserialize; use url::Url; use crate::DomainError; mod signature; use signature::RawPluginSignature; pub use signature::{PluginSignature, PluginSignatureAlgorithm}; #[derive(Clone, Debug, Eq, PartialEq)] pub struct PluginId(String); #[derive(Clone, Debug, Eq, PartialEq)] pub struct PluginManifest { id: PluginId, name: String, description: String, author: String, homepage: String, permissions: Vec, contributes: Vec, min_ely_build: Version, checksum: String, signature: PluginSignature, } #[derive(Clone, Debug, Eq, PartialEq)] pub enum PluginPermission { TabsRead, TabsWrite, SpacesRead, SpacesWrite, BookmarksRead, BookmarksWrite, HistoryRead, DownloadsRead, DownloadsWrite, PageMetadata, PageScreenshot, PageScript, ClipboardRead, ClipboardWrite, FilesystemRead, FilesystemWrite, NetworkFetch, SettingsRead, SettingsWrite, SyncPlugin, UiPanel, UiCommand, UiContextMenu, } #[derive(Clone, Debug, Eq, PartialEq)] pub enum PluginPermissionRisk { Standard, High, } #[derive(Clone, Debug, Eq, PartialEq)] pub enum PluginContributionPoint { CommandBarCommand, TabContextMenu, PageContextMenu, SidebarPanel, SettingsPage, StatusBarIndicator, DownloadAction, BookmarkAction, ReadingModeExporter, } #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] struct RawPluginManifest { id: String, name: String, description: String, author: String, homepage: String, permissions: Vec, contributes: Vec, min_ely_build: String, checksum: String, signature: RawPluginSignature, } impl PluginId { pub fn parse(value: impl Into) -> Result { let value = value.into(); let value = value.trim(); if !is_valid_plugin_id(value) { return Err(DomainError::InvalidPluginId { value: value.to_string() }); } Ok(Self(value.to_string())) } #[must_use] pub fn as_str(&self) -> &str { &self.0 } } 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) .map_err(|error| DomainError::InvalidPluginManifest { reason: error.to_string() })?; Self::from_raw(raw) } fn from_raw(raw: RawPluginManifest) -> Result { Ok(Self { id: PluginId::parse(raw.id)?, name: non_empty_plugin_field("name", raw.name)?, description: non_empty_plugin_field("description", raw.description)?, author: non_empty_plugin_field("author", raw.author)?, homepage: plugin_homepage(raw.homepage)?, permissions: parse_unique_permissions(&raw.permissions)?, contributes: parse_unique_contributions(&raw.contributes)?, min_ely_build: parse_ely_build(raw.min_ely_build)?, checksum: plugin_checksum(raw.checksum)?, signature: PluginSignature::from_raw(raw.signature)?, }) } #[must_use] pub fn id(&self) -> &PluginId { &self.id } #[must_use] pub fn name(&self) -> &str { &self.name } #[must_use] pub fn description(&self) -> &str { &self.description } #[must_use] pub fn author(&self) -> &str { &self.author } #[must_use] pub fn homepage(&self) -> &str { &self.homepage } #[must_use] pub fn permissions(&self) -> &[PluginPermission] { &self.permissions } pub fn high_risk_permissions(&self) -> impl Iterator { self.permissions.iter().filter(|permission| permission.requires_separate_confirmation()) } #[must_use] pub fn contributes(&self) -> &[PluginContributionPoint] { &self.contributes } #[must_use] pub fn min_ely_build(&self) -> &Version { &self.min_ely_build } #[must_use] pub fn checksum(&self) -> &str { &self.checksum } #[must_use] pub fn signature(&self) -> &PluginSignature { &self.signature } } impl PluginPermission { pub fn parse(value: &str) -> Result { match value { "tabs:read" => Ok(Self::TabsRead), "tabs:write" => Ok(Self::TabsWrite), "spaces:read" => Ok(Self::SpacesRead), "spaces:write" => Ok(Self::SpacesWrite), "bookmarks:read" => Ok(Self::BookmarksRead), "bookmarks:write" => Ok(Self::BookmarksWrite), "history:read" => Ok(Self::HistoryRead), "downloads:read" => Ok(Self::DownloadsRead), "downloads:write" => Ok(Self::DownloadsWrite), "page:metadata" => Ok(Self::PageMetadata), "page:screenshot" => Ok(Self::PageScreenshot), "page:script" => Ok(Self::PageScript), "clipboard:read" => Ok(Self::ClipboardRead), "clipboard:write" => Ok(Self::ClipboardWrite), "filesystem:read" => Ok(Self::FilesystemRead), "filesystem:write" => Ok(Self::FilesystemWrite), "network:fetch" => Ok(Self::NetworkFetch), "settings:read" => Ok(Self::SettingsRead), "settings:write" => Ok(Self::SettingsWrite), "sync:plugin" => Ok(Self::SyncPlugin), "ui:panel" => Ok(Self::UiPanel), "ui:command" => Ok(Self::UiCommand), "ui:context_menu" => Ok(Self::UiContextMenu), _ => Err(DomainError::InvalidPluginPermission { value: value.to_string() }), } } #[must_use] pub fn as_str(&self) -> &'static str { match self { Self::TabsRead => "tabs:read", Self::TabsWrite => "tabs:write", Self::SpacesRead => "spaces:read", Self::SpacesWrite => "spaces:write", Self::BookmarksRead => "bookmarks:read", Self::BookmarksWrite => "bookmarks:write", Self::HistoryRead => "history:read", Self::DownloadsRead => "downloads:read", Self::DownloadsWrite => "downloads:write", Self::PageMetadata => "page:metadata", Self::PageScreenshot => "page:screenshot", Self::PageScript => "page:script", Self::ClipboardRead => "clipboard:read", Self::ClipboardWrite => "clipboard:write", Self::FilesystemRead => "filesystem:read", Self::FilesystemWrite => "filesystem:write", Self::NetworkFetch => "network:fetch", Self::SettingsRead => "settings:read", Self::SettingsWrite => "settings:write", Self::SyncPlugin => "sync:plugin", Self::UiPanel => "ui:panel", Self::UiCommand => "ui:command", Self::UiContextMenu => "ui:context_menu", } } #[must_use] pub fn risk(&self) -> PluginPermissionRisk { match self { Self::TabsRead | Self::SpacesRead | Self::PageMetadata | Self::SettingsRead | Self::UiPanel | Self::UiCommand | Self::UiContextMenu => PluginPermissionRisk::Standard, Self::TabsWrite | Self::SpacesWrite | Self::BookmarksRead | Self::BookmarksWrite | Self::HistoryRead | Self::DownloadsRead | Self::DownloadsWrite | Self::PageScreenshot | Self::PageScript | Self::ClipboardRead | Self::ClipboardWrite | Self::FilesystemRead | Self::FilesystemWrite | Self::NetworkFetch | Self::SettingsWrite | Self::SyncPlugin => PluginPermissionRisk::High, } } #[must_use] pub fn requires_separate_confirmation(&self) -> bool { self.risk() == PluginPermissionRisk::High } } impl PluginContributionPoint { pub fn parse(value: &str) -> Result { match value { "command-bar-command" => Ok(Self::CommandBarCommand), "tab-context-menu" => Ok(Self::TabContextMenu), "page-context-menu" => Ok(Self::PageContextMenu), "sidebar-panel" => Ok(Self::SidebarPanel), "settings-page" => Ok(Self::SettingsPage), "status-bar-indicator" => Ok(Self::StatusBarIndicator), "download-action" => Ok(Self::DownloadAction), "bookmark-action" => Ok(Self::BookmarkAction), "reading-mode-exporter" => Ok(Self::ReadingModeExporter), _ => Err(DomainError::InvalidPluginContribution { value: value.to_string() }), } } #[must_use] pub fn as_str(&self) -> &'static str { match self { Self::CommandBarCommand => "command-bar-command", Self::TabContextMenu => "tab-context-menu", Self::PageContextMenu => "page-context-menu", Self::SidebarPanel => "sidebar-panel", Self::SettingsPage => "settings-page", Self::StatusBarIndicator => "status-bar-indicator", Self::DownloadAction => "download-action", Self::BookmarkAction => "bookmark-action", Self::ReadingModeExporter => "reading-mode-exporter", } } } fn parse_unique_permissions(values: &[String]) -> Result, DomainError> { let mut seen = BTreeSet::new(); let mut permissions = Vec::with_capacity(values.len()); for value in values { let permission = PluginPermission::parse(value.as_str())?; if !seen.insert(permission.as_str()) { return Err(DomainError::DuplicatePluginPermission { value: permission.as_str().to_string(), }); } permissions.push(permission); } Ok(permissions) } fn parse_unique_contributions( values: &[String], ) -> Result, DomainError> { let mut seen = BTreeSet::new(); let mut contributions = Vec::with_capacity(values.len()); for value in values { let contribution = PluginContributionPoint::parse(value.as_str())?; if !seen.insert(contribution.as_str()) { return Err(DomainError::DuplicatePluginContribution { value: contribution.as_str().to_string(), }); } contributions.push(contribution); } Ok(contributions) } fn non_empty_plugin_field( field: &'static str, value: impl Into, ) -> Result { let value = value.into(); let value = value.trim(); if value.is_empty() { return Err(DomainError::EmptyField { field }); } Ok(value.to_string()) } fn plugin_homepage(value: impl Into) -> Result { let value = non_empty_plugin_field("homepage", value)?; let parsed = Url::parse(value.as_str()) .map_err(|_| DomainError::InvalidPluginHomepage { value: value.clone() })?; if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() { return Err(DomainError::InvalidPluginHomepage { value }); } Ok(parsed.to_string()) } fn parse_ely_build(value: impl Into) -> Result { let value = non_empty_plugin_field("min_ely_build", value)?; Version::parse(value.as_str()).map_err(|_| DomainError::InvalidPluginBuildRequirement { value }) } fn plugin_checksum(value: impl Into) -> Result { let value = non_empty_plugin_field("checksum", value)?; if !is_hex_of_len(value.as_str(), 64) { return Err(DomainError::InvalidPluginChecksum { value }); } Ok(value.to_ascii_lowercase()) } fn is_valid_plugin_id(value: &str) -> bool { (3..=128).contains(&value.len()) && value.split('.').all(|segment| { !segment.is_empty() && segment.chars().all(|ch| { ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-' || ch == '_' }) && segment .chars() .next() .is_some_and(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit()) && segment .chars() .last() .is_some_and(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit()) }) } fn is_hex_of_len(value: &str, len: usize) -> bool { value.len() == len && value.chars().all(|ch| ch.is_ascii_hexdigit()) }