diff --git a/Cargo.lock b/Cargo.lock index 312ef3c..efd158e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2223,7 +2223,10 @@ version = "0.1.0" name = "ely_domain" version = "0.1.0" dependencies = [ + "semver", + "serde", "thiserror 2.0.18", + "toml 1.1.2+spec-1.1.0", "url", "uuid", ] diff --git a/Cargo.toml b/Cargo.toml index 2c23358..6fdfb0a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,10 @@ gpui-component = "0.5.1" gpui-component-assets = "0.5.1" servo = "0.1.0" sha2 = "0.10.9" +semver = "1.0.28" +serde = { version = "1.0.228", features = ["derive"] } thiserror = "2.0.12" +toml = "1.1.2" url = "2.5.4" uuid = { version = "1.12.1", features = ["v7"] } diff --git a/crates/ely_domain/Cargo.toml b/crates/ely_domain/Cargo.toml index a150fff..c63251f 100644 --- a/crates/ely_domain/Cargo.toml +++ b/crates/ely_domain/Cargo.toml @@ -6,7 +6,10 @@ license.workspace = true rust-version.workspace = true [dependencies] +semver.workspace = true +serde.workspace = true thiserror.workspace = true +toml.workspace = true url.workspace = true uuid.workspace = true diff --git a/crates/ely_domain/src/error.rs b/crates/ely_domain/src/error.rs index dc271db..5d74c7d 100644 --- a/crates/ely_domain/src/error.rs +++ b/crates/ely_domain/src/error.rs @@ -25,4 +25,37 @@ pub enum DomainError { #[error("download progress {received_bytes} exceeds total {total_bytes}")] InvalidDownloadProgress { received_bytes: u64, total_bytes: u64 }, + + #[error("invalid plugin manifest: {reason}")] + InvalidPluginManifest { reason: String }, + + #[error("invalid plugin id: {value}")] + InvalidPluginId { value: String }, + + #[error("invalid plugin homepage: {value}")] + InvalidPluginHomepage { value: String }, + + #[error("invalid plugin permission: {value}")] + InvalidPluginPermission { value: String }, + + #[error("duplicate plugin permission: {value}")] + DuplicatePluginPermission { value: String }, + + #[error("invalid plugin contribution: {value}")] + InvalidPluginContribution { value: String }, + + #[error("duplicate plugin contribution: {value}")] + DuplicatePluginContribution { value: String }, + + #[error("invalid plugin build requirement: {value}")] + InvalidPluginBuildRequirement { value: String }, + + #[error("invalid plugin checksum: {value}")] + InvalidPluginChecksum { value: String }, + + #[error("invalid plugin signature algorithm: {value}")] + InvalidPluginSignatureAlgorithm { value: String }, + + #[error("invalid plugin signature: {value}")] + InvalidPluginSignature { value: String }, } diff --git a/crates/ely_domain/src/lib.rs b/crates/ely_domain/src/lib.rs index 7208a47..8aa14c6 100644 --- a/crates/ely_domain/src/lib.rs +++ b/crates/ely_domain/src/lib.rs @@ -4,6 +4,7 @@ mod download; mod error; mod history; mod identifiers; +mod plugin; mod profile; mod space; mod split; @@ -19,6 +20,10 @@ pub use download::{ pub use error::DomainError; pub use history::HistoryEntry; pub use identifiers::{DownloadId, ProfileId, SpaceId, SplitId, TabId, WebViewId}; +pub use plugin::{ + PluginContributionPoint, PluginId, PluginManifest, PluginPermission, PluginSignature, + PluginSignatureAlgorithm, +}; pub use profile::{Profile, ProfileKind}; pub use space::{ArchivePolicy, Space}; pub use split::{SplitAxis, SplitLayout, SplitPane}; diff --git a/crates/ely_domain/src/plugin.rs b/crates/ely_domain/src/plugin.rs new file mode 100644 index 0000000..cbbf648 --- /dev/null +++ b/crates/ely_domain/src/plugin.rs @@ -0,0 +1,417 @@ +use std::collections::BTreeSet; + +use semver::Version; +use serde::Deserialize; +use url::Url; + +use crate::DomainError; + +#[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 PluginContributionPoint { + CommandBarCommand, + TabContextMenu, + PageContextMenu, + SidebarPanel, + SettingsPage, + StatusBarIndicator, + DownloadAction, + BookmarkAction, + ReadingModeExporter, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum PluginSignatureAlgorithm { + Ed25519, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PluginSignature { + algorithm: PluginSignatureAlgorithm, + value: String, +} + +#[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, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawPluginSignature { + algorithm: String, + value: String, +} + +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 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 + } + + #[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", + } + } +} + +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", + } + } +} + +impl PluginSignature { + fn from_raw(raw: RawPluginSignature) -> Result { + let algorithm = PluginSignatureAlgorithm::parse(raw.algorithm.as_str())?; + let value = plugin_signature_value(&algorithm, raw.value)?; + Ok(Self { algorithm, value }) + } + + #[must_use] + pub fn algorithm(&self) -> &PluginSignatureAlgorithm { + &self.algorithm + } + + #[must_use] + pub fn value(&self) -> &str { + &self.value + } +} + +impl PluginSignatureAlgorithm { + fn parse(value: &str) -> Result { + match value { + "ed25519" => Ok(Self::Ed25519), + _ => Err(DomainError::InvalidPluginSignatureAlgorithm { value: value.to_string() }), + } + } + + #[must_use] + pub fn as_str(&self) -> &'static str { + match self { + Self::Ed25519 => "ed25519", + } + } +} + +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 plugin_signature_value( + algorithm: &PluginSignatureAlgorithm, + value: impl Into, +) -> Result { + let value = non_empty_plugin_field("signature", value)?; + let valid = match algorithm { + PluginSignatureAlgorithm::Ed25519 => is_hex_of_len(value.as_str(), 128), + }; + if !valid { + return Err(DomainError::InvalidPluginSignature { 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()) +} diff --git a/crates/ely_domain/tests/plugins.rs b/crates/ely_domain/tests/plugins.rs new file mode 100644 index 0000000..1268ffb --- /dev/null +++ b/crates/ely_domain/tests/plugins.rs @@ -0,0 +1,155 @@ +use std::{error::Error, io}; + +use ely_domain::{ + DomainError, PluginContributionPoint, PluginManifest, PluginPermission, + PluginSignatureAlgorithm, +}; + +#[test] +fn parses_signed_plugin_manifest() -> Result<(), Box> { + let manifest = PluginManifest::from_toml(valid_manifest().as_str())?; + + assert_eq!(manifest.id().as_str(), "com.elydora.reader"); + assert_eq!(manifest.name(), "Reader Exporter"); + assert_eq!(manifest.homepage(), "https://elydora.com/plugins/reader"); + assert_eq!( + manifest.permissions(), + &[PluginPermission::PageMetadata, PluginPermission::UiCommand] + ); + assert_eq!( + manifest.contributes(), + &[PluginContributionPoint::CommandBarCommand, PluginContributionPoint::ReadingModeExporter] + ); + assert_eq!(manifest.min_ely_build().to_string(), "0.1.0"); + assert_eq!( + manifest.checksum(), + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ); + assert_eq!(manifest.signature().algorithm(), &PluginSignatureAlgorithm::Ed25519); + assert_eq!( + manifest.signature().value(), + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ); + Ok(()) +} + +#[test] +fn rejects_unknown_plugin_permission() -> Result<(), Box> { + let manifest = valid_manifest().replace("page:metadata", "tabs:admin"); + + let error = parse_error(manifest.as_str())?; + + assert!(matches!( + error, + DomainError::InvalidPluginPermission { value } if value == "tabs:admin" + )); + Ok(()) +} + +#[test] +fn rejects_duplicate_plugin_permission() -> Result<(), Box> { + let manifest = valid_manifest() + .replace("[\"page:metadata\", \"ui:command\"]", "[\"page:metadata\", \"page:metadata\"]"); + + let error = parse_error(manifest.as_str())?; + + assert!(matches!( + error, + DomainError::DuplicatePluginPermission { value } if value == "page:metadata" + )); + Ok(()) +} + +#[test] +fn rejects_unknown_plugin_contribution() -> Result<(), Box> { + let manifest = valid_manifest().replace("reading-mode-exporter", "hidden-root-hook"); + + let error = parse_error(manifest.as_str())?; + + assert!(matches!( + error, + DomainError::InvalidPluginContribution { value } if value == "hidden-root-hook" + )); + Ok(()) +} + +#[test] +fn rejects_unsigned_plugin_manifest() -> Result<(), Box> { + let manifest = valid_manifest().replace( + "value = \"BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB\"", + "value = \"\"", + ); + + let error = parse_error(manifest.as_str())?; + + assert!(matches!(error, DomainError::EmptyField { field } if field == "signature")); + Ok(()) +} + +#[test] +fn rejects_mismatched_plugin_checksum() -> Result<(), Box> { + let manifest = valid_manifest().replace( + "checksum = \"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\"", + "checksum = \"1234\"", + ); + + let error = parse_error(manifest.as_str())?; + + assert!(matches!( + error, + DomainError::InvalidPluginChecksum { value } if value == "1234" + )); + Ok(()) +} + +#[test] +fn rejects_non_http_plugin_homepage() -> Result<(), Box> { + let manifest = valid_manifest().replace( + "homepage = \"https://elydora.com/plugins/reader\"", + "homepage = \"file:///tmp/reader\"", + ); + + let error = parse_error(manifest.as_str())?; + + assert!(matches!( + error, + DomainError::InvalidPluginHomepage { value } if value == "file:///tmp/reader" + )); + Ok(()) +} + +#[test] +fn rejects_extra_manifest_fields() -> Result<(), Box> { + let manifest = valid_manifest().replace("[signature]", "process_escape = true\n\n[signature]"); + + let error = parse_error(manifest.as_str())?; + + assert!(matches!(error, DomainError::InvalidPluginManifest { .. })); + Ok(()) +} + +fn parse_error(manifest: &str) -> Result> { + match PluginManifest::from_toml(manifest) { + Ok(_) => Err(io::Error::other("manifest parsed successfully").into()), + Err(error) => Ok(error), + } +} + +fn valid_manifest() -> String { + r#" +id = "com.elydora.reader" +name = "Reader Exporter" +description = "Exports the active reader view." +author = "Elydora" +homepage = "https://elydora.com/plugins/reader" +permissions = ["page:metadata", "ui:command"] +contributes = ["command-bar-command", "reading-mode-exporter"] +min_ely_build = "0.1.0" +checksum = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + +[signature] +algorithm = "ed25519" +value = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB" +"# + .to_string() +}