pinakes/crates/pinakes-plugin-api/src/manifest.rs
NotAShelf 185e3b562a
treewide: cleanup
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ia01590cdeed872cc8ebd16f6ca95f3cc6a6a6964
2026-03-12 19:41:15 +03:00

795 lines
18 KiB
Rust

//! Plugin manifest parsing and validation
use std::{collections::HashMap, path::Path};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::{
Capabilities,
EnvironmentCapability,
FilesystemCapability,
NetworkCapability,
UiPage,
UiWidget,
};
/// Plugin manifest file format (TOML)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginManifest {
pub plugin: PluginInfo,
#[serde(default)]
pub capabilities: ManifestCapabilities,
#[serde(default)]
pub config: HashMap<String, toml::Value>,
/// UI pages provided by this plugin
#[serde(default)]
pub ui: UiSection,
}
/// UI section of the plugin manifest
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct UiSection {
/// UI pages defined by this plugin
#[serde(default)]
pub pages: Vec<UiPageEntry>,
/// Widgets to inject into existing host pages
#[serde(default)]
pub widgets: Vec<UiWidget>,
/// API endpoint paths this plugin's UI requires.
/// Each must start with `/api/`. Informational; host may check availability.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub required_endpoints: Vec<String>,
/// CSS custom property overrides provided by this plugin.
/// Keys are property names (e.g. `--accent-color`), values are CSS values.
/// The host applies these to `document.documentElement` on startup.
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub theme_extensions: HashMap<String, String>,
}
impl UiSection {
/// Validate that all declared required endpoints start with `/api/`.
///
/// # Errors
///
/// Returns an error string for the first invalid endpoint found.
pub fn validate(&self) -> Result<(), String> {
for ep in &self.required_endpoints {
if !ep.starts_with("/api/") {
return Err(format!("required_endpoint must start with '/api/': {ep}"));
}
}
Ok(())
}
}
/// Entry for a UI page in the manifest - can be inline or file reference
#[derive(Debug, Clone)]
pub enum UiPageEntry {
/// Inline UI page definition (boxed to reduce enum size)
Inline(Box<UiPage>),
/// Reference to a JSON file containing the page definition
File { file: String },
}
impl<'de> Deserialize<'de> for UiPageEntry {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::Error;
// First try to deserialize as a file reference (has "file" key)
// We use toml::Value since the manifest is TOML
let value = toml::Value::deserialize(deserializer)?;
if let Some(file) = value.get("file").and_then(|v| v.as_str()) {
if file.is_empty() {
return Err(D::Error::custom("file path cannot be empty"));
}
return Ok(Self::File {
file: file.to_string(),
});
}
// Otherwise try to deserialize as inline UiPage
// Convert toml::Value back to a deserializer for UiPage
let page: UiPage = UiPage::deserialize(value)
.map_err(|e| D::Error::custom(format!("invalid inline UI page: {e}")))?;
Ok(Self::Inline(Box::new(page)))
}
}
impl Serialize for UiPageEntry {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
match self {
Self::Inline(page) => page.serialize(serializer),
Self::File { file } => {
use serde::ser::SerializeStruct;
let mut state = serializer.serialize_struct("UiPageEntry", 1)?;
state.serialize_field("file", file)?;
state.end()
},
}
}
}
const fn default_priority() -> u16 {
500
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginInfo {
pub name: String,
pub version: String,
pub api_version: String,
pub author: Option<String>,
pub description: Option<String>,
pub homepage: Option<String>,
pub license: Option<String>,
/// Pipeline priority (0-999). Lower values run first. Built-in handlers run
/// at 100. Default: 500.
#[serde(default = "default_priority")]
pub priority: u16,
/// Plugin kind(s) - e.g., `media_type`, `metadata_extractor`
pub kind: Vec<String>,
/// Binary configuration
pub binary: PluginBinary,
/// Dependencies on other plugins
#[serde(default)]
pub dependencies: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginBinary {
/// Path to WASM binary
pub wasm: String,
/// Optional entrypoint function name (default: "_start")
pub entrypoint: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ManifestCapabilities {
#[serde(default)]
pub filesystem: ManifestFilesystemCapability,
#[serde(default)]
pub network: bool,
#[serde(default)]
pub allowed_domains: Option<Vec<String>>,
#[serde(default)]
pub environment: Option<Vec<String>>,
#[serde(default)]
pub max_memory_mb: Option<usize>,
#[serde(default)]
pub max_cpu_time_secs: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ManifestFilesystemCapability {
#[serde(default)]
pub read: Vec<String>,
#[serde(default)]
pub write: Vec<String>,
}
#[derive(Debug, Error)]
pub enum ManifestError {
#[error("Failed to read manifest file: {0}")]
IoError(#[from] std::io::Error),
#[error("Failed to parse manifest: {0}")]
ParseError(#[from] toml::de::Error),
#[error("Invalid manifest: {0}")]
ValidationError(String),
}
impl PluginManifest {
/// Load and parse a plugin manifest from a TOML file
///
/// # Errors
///
/// Returns [`ManifestError::IoError`] if the file cannot be read,
/// [`ManifestError::ParseError`] if the TOML is invalid, or
/// [`ManifestError::ValidationError`] if the manifest fails validation.
pub fn from_file(path: &Path) -> Result<Self, ManifestError> {
let content = std::fs::read_to_string(path)?;
let manifest: Self = toml::from_str(&content)?;
manifest.validate()?;
Ok(manifest)
}
/// Parse a manifest from TOML string
///
/// # Errors
///
/// Returns [`ManifestError::ParseError`] if the TOML is invalid, or
/// [`ManifestError::ValidationError`] if the manifest fails validation.
pub fn parse_str(content: &str) -> Result<Self, ManifestError> {
let manifest: Self = toml::from_str(content)?;
manifest.validate()?;
Ok(manifest)
}
/// Validate the manifest
///
/// # Errors
///
/// Returns [`ManifestError::ValidationError`] if any required field is empty
/// or otherwise invalid.
pub fn validate(&self) -> Result<(), ManifestError> {
// Check API version format
if self.plugin.api_version.is_empty() {
return Err(ManifestError::ValidationError(
"api_version cannot be empty".to_string(),
));
}
// Check version format (basic semver check)
if !self.plugin.version.contains('.') {
return Err(ManifestError::ValidationError(
"version must be in semver format (e.g., 1.0.0)".to_string(),
));
}
// Check that at least one kind is specified
if self.plugin.kind.is_empty() {
return Err(ManifestError::ValidationError(
"at least one plugin kind must be specified".to_string(),
));
}
// Check plugin kinds are valid
let valid_kinds = [
"media_type",
"metadata_extractor",
"thumbnail_generator",
"search_backend",
"event_handler",
"theme_provider",
"ui_page",
];
for kind in &self.plugin.kind {
if !valid_kinds.contains(&kind.as_str()) {
return Err(ManifestError::ValidationError(format!(
"Invalid plugin kind: {}. Must be one of: {}",
kind,
valid_kinds.join(", ")
)));
}
}
// Check WASM binary path is not empty
if self.plugin.binary.wasm.is_empty() {
return Err(ManifestError::ValidationError(
"WASM binary path cannot be empty".to_string(),
));
}
if self.plugin.priority > 999 {
return Err(ManifestError::ValidationError(
"priority must be 0-999".to_string(),
));
}
// Validate UI section (required_endpoints format); non-fatal: warn only
if let Err(e) = self.ui.validate() {
tracing::warn!(
plugin = %self.plugin.name,
error = %e,
"plugin UI section has invalid required_endpoints"
);
}
// Validate UI pages
for (idx, page_entry) in self.ui.pages.iter().enumerate() {
match page_entry {
UiPageEntry::Inline(page) => {
if let Err(e) = page.validate() {
return Err(ManifestError::ValidationError(format!(
"UI page {} validation failed: {e}",
idx + 1
)));
}
},
UiPageEntry::File { file } => {
if file.is_empty() {
return Err(ManifestError::ValidationError(format!(
"UI page {} file path cannot be empty",
idx + 1
)));
}
},
}
}
Ok(())
}
/// Load and resolve all UI page definitions
///
/// For inline pages, returns them directly. For file references, loads
/// and parses the JSON file.
///
/// # Arguments
///
/// * `base_path` - Base directory for resolving relative file paths
///
/// # Errors
///
/// Returns [`ManifestError::IoError`] if a file cannot be read, or
/// [`ManifestError::ValidationError`] if JSON parsing fails.
pub fn load_ui_pages(
&self,
base_path: &Path,
) -> Result<Vec<UiPage>, ManifestError> {
let mut pages = Vec::with_capacity(self.ui.pages.len());
for entry in &self.ui.pages {
let page = match entry {
UiPageEntry::Inline(page) => (**page).clone(),
UiPageEntry::File { file } => {
let file_path = base_path.join(file);
let content = std::fs::read_to_string(&file_path)?;
let page: UiPage = serde_json::from_str(&content).map_err(|e| {
ManifestError::ValidationError(format!(
"Failed to parse UI page from file '{}': {e}",
file_path.display()
))
})?;
if let Err(e) = page.validate() {
return Err(ManifestError::ValidationError(format!(
"UI page validation failed for file '{}': {e}",
file_path.display()
)));
}
page
},
};
pages.push(page);
}
Ok(pages)
}
/// Convert manifest capabilities to API capabilities
#[must_use]
pub fn to_capabilities(&self) -> Capabilities {
Capabilities {
filesystem: FilesystemCapability {
read: self
.capabilities
.filesystem
.read
.iter()
.map(std::convert::Into::into)
.collect(),
write: self
.capabilities
.filesystem
.write
.iter()
.map(std::convert::Into::into)
.collect(),
},
network: NetworkCapability {
enabled: self.capabilities.network,
allowed_domains: self.capabilities.allowed_domains.clone(),
},
environment: EnvironmentCapability {
enabled: self.capabilities.environment.is_some(),
allowed_vars: self.capabilities.environment.clone(),
},
max_memory_bytes: self
.capabilities
.max_memory_mb
.map(|mb| mb.saturating_mul(1024).saturating_mul(1024)),
max_cpu_time_ms: self
.capabilities
.max_cpu_time_secs
.map(|secs| secs.saturating_mul(1000)),
}
}
/// Get plugin ID (derived from name and version)
#[must_use]
pub fn plugin_id(&self) -> String {
format!("{}@{}", self.plugin.name, self.plugin.version)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_valid_manifest() {
let toml = r#"
[plugin]
name = "heif-support"
version = "1.0.0"
api_version = "1.0"
author = "Test Author"
description = "HEIF image support"
kind = ["media_type", "metadata_extractor"]
[plugin.binary]
wasm = "plugin.wasm"
[capabilities.filesystem]
read = ["/tmp/pinakes-thumbnails"]
"#;
let manifest = PluginManifest::parse_str(toml).unwrap();
assert_eq!(manifest.plugin.name, "heif-support");
assert_eq!(manifest.plugin.version, "1.0.0");
assert_eq!(manifest.plugin.kind.len(), 2);
}
#[test]
fn test_invalid_api_version() {
let toml = r#"
[plugin]
name = "test"
version = "1.0.0"
api_version = ""
kind = ["media_type"]
[plugin.binary]
wasm = "plugin.wasm"
"#;
assert!(PluginManifest::parse_str(toml).is_err());
}
#[test]
fn test_invalid_kind() {
let toml = r#"
[plugin]
name = "test"
version = "1.0.0"
api_version = "1.0"
kind = ["invalid_kind"]
[plugin.binary]
wasm = "plugin.wasm"
"#;
assert!(PluginManifest::parse_str(toml).is_err());
}
#[test]
fn test_priority_default() {
let toml = r#"
[plugin]
name = "test"
version = "1.0.0"
api_version = "1.0"
kind = ["media_type"]
[plugin.binary]
wasm = "plugin.wasm"
"#;
let manifest = PluginManifest::parse_str(toml).unwrap();
assert_eq!(manifest.plugin.priority, 500);
}
#[test]
fn test_priority_custom() {
let toml = r#"
[plugin]
name = "test"
version = "1.0.0"
api_version = "1.0"
priority = 50
kind = ["media_type"]
[plugin.binary]
wasm = "plugin.wasm"
"#;
let manifest = PluginManifest::parse_str(toml).unwrap();
assert_eq!(manifest.plugin.priority, 50);
}
#[test]
fn test_priority_out_of_range() {
let toml = r#"
[plugin]
name = "test"
version = "1.0.0"
api_version = "1.0"
priority = 1000
kind = ["media_type"]
[plugin.binary]
wasm = "plugin.wasm"
"#;
assert!(PluginManifest::parse_str(toml).is_err());
}
#[test]
fn test_ui_page_inline() {
let toml = r#"
[plugin]
name = "ui-demo"
version = "1.0.0"
api_version = "1.0"
kind = ["ui_page"]
[plugin.binary]
wasm = "plugin.wasm"
[[ui.pages]]
id = "demo"
title = "Demo Page"
route = "/plugins/demo"
icon = "star"
[ui.pages.layout]
type = "container"
children = []
gap = 16
"#;
let manifest = PluginManifest::parse_str(toml).unwrap();
assert_eq!(manifest.ui.pages.len(), 1);
match &manifest.ui.pages[0] {
UiPageEntry::Inline(page) => {
assert_eq!(page.id, "demo");
assert_eq!(page.title, "Demo Page");
assert_eq!(page.route, "/plugins/demo");
assert_eq!(page.icon, Some("star".to_string()));
},
_ => panic!("Expected inline page"),
}
}
#[test]
fn test_ui_page_file_reference() {
let toml = r#"
[plugin]
name = "ui-demo"
version = "1.0.0"
api_version = "1.0"
kind = ["ui_page"]
[plugin.binary]
wasm = "plugin.wasm"
[[ui.pages]]
file = "pages/demo.json"
"#;
let manifest = PluginManifest::parse_str(toml).unwrap();
assert_eq!(manifest.ui.pages.len(), 1);
match &manifest.ui.pages[0] {
UiPageEntry::File { file } => {
assert_eq!(file, "pages/demo.json");
},
_ => panic!("Expected file reference"),
}
}
#[test]
fn test_ui_page_invalid_kind() {
// ui_page must be in valid_kinds list
let toml = r#"
[plugin]
name = "test"
version = "1.0.0"
api_version = "1.0"
kind = ["ui_page"]
[plugin.binary]
wasm = "plugin.wasm"
"#;
// Should succeed now that ui_page is in valid_kinds
let manifest = PluginManifest::parse_str(toml);
assert!(manifest.is_ok());
}
#[test]
fn test_ui_page_validation_failure() {
let toml = r#"
[plugin]
name = "ui-demo"
version = "1.0.0"
api_version = "1.0"
kind = ["ui_page"]
[plugin.binary]
wasm = "plugin.wasm"
[[ui.pages]]
id = ""
title = "Demo"
route = "/plugins/demo"
[ui.pages.layout]
type = "container"
children = []
gap = 16
"#;
// Empty ID should fail validation
assert!(PluginManifest::parse_str(toml).is_err());
}
#[test]
fn test_ui_page_empty_file() {
let toml = r#"
[plugin]
name = "ui-demo"
version = "1.0.0"
api_version = "1.0"
kind = ["ui_page"]
[plugin.binary]
wasm = "plugin.wasm"
[[ui.pages]]
file = ""
"#;
// Empty file path should fail validation
assert!(PluginManifest::parse_str(toml).is_err());
}
#[test]
fn test_ui_page_multiple_pages() {
let toml = r#"
[plugin]
name = "ui-demo"
version = "1.0.0"
api_version = "1.0"
kind = ["ui_page"]
[plugin.binary]
wasm = "plugin.wasm"
[[ui.pages]]
id = "page1"
title = "Page 1"
route = "/plugins/page1"
[ui.pages.layout]
type = "container"
children = []
gap = 16
[[ui.pages]]
id = "page2"
title = "Page 2"
route = "/plugins/page2"
[ui.pages.layout]
type = "container"
children = []
gap = 16
"#;
let manifest = PluginManifest::parse_str(toml).unwrap();
assert_eq!(manifest.ui.pages.len(), 2);
}
#[test]
fn test_ui_section_validate_accepts_api_paths() {
let section = UiSection {
pages: vec![],
widgets: vec![],
required_endpoints: vec![
"/api/v1/media".to_string(),
"/api/plugins/my-plugin/data".to_string(),
],
theme_extensions: HashMap::new(),
};
assert!(section.validate().is_ok());
}
#[test]
fn test_ui_section_validate_rejects_non_api_path() {
let section = UiSection {
pages: vec![],
widgets: vec![],
required_endpoints: vec!["/not-api/something".to_string()],
theme_extensions: HashMap::new(),
};
assert!(section.validate().is_err());
}
#[test]
fn test_ui_section_validate_rejects_empty_sections_with_bad_path() {
let section = UiSection {
pages: vec![],
widgets: vec![],
required_endpoints: vec!["/api/ok".to_string(), "no-slash".to_string()],
theme_extensions: HashMap::new(),
};
let err = section.validate().unwrap_err();
assert!(
err.contains("no-slash"),
"error should mention the bad endpoint"
);
}
#[test]
fn test_theme_extensions_roundtrip() {
let toml = r##"
[plugin]
name = "theme-plugin"
version = "1.0.0"
api_version = "1.0"
kind = ["theme_provider"]
[plugin.binary]
wasm = "plugin.wasm"
[ui.theme_extensions]
"--accent-color" = "#ff6b6b"
"--sidebar-width" = "280px"
"##;
let manifest = PluginManifest::parse_str(toml).unwrap();
assert_eq!(
manifest
.ui
.theme_extensions
.get("--accent-color")
.map(String::as_str),
Some("#ff6b6b")
);
assert_eq!(
manifest
.ui
.theme_extensions
.get("--sidebar-width")
.map(String::as_str),
Some("280px")
);
}
#[test]
fn test_theme_extensions_empty_by_default() {
let toml = r#"
[plugin]
name = "no-theme"
version = "1.0.0"
api_version = "1.0"
kind = ["media_type"]
[plugin.binary]
wasm = "plugin.wasm"
"#;
let manifest = PluginManifest::parse_str(toml).unwrap();
assert!(manifest.ui.theme_extensions.is_empty());
}
}