finalize server-side plugin system #8
12 changed files with 2784 additions and 36 deletions
pinakes-core: add plugin pipeline; impl signature verification & dependency resolution
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: Ida98135cf868db0f5a46a64b8ac562366a6a6964
commit
4edda201e6
105
Cargo.lock
generated
105
Cargo.lock
generated
|
|
@ -862,6 +862,12 @@ dependencies = [
|
||||||
"crossbeam-utils",
|
"crossbeam-utils",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "const-oid"
|
||||||
|
version = "0.9.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "const-serialize"
|
name = "const-serialize"
|
||||||
version = "0.7.2"
|
version = "0.7.2"
|
||||||
|
|
@ -1374,6 +1380,33 @@ dependencies = [
|
||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "curve25519-dalek"
|
||||||
|
version = "4.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures 0.2.17",
|
||||||
|
"curve25519-dalek-derive",
|
||||||
|
"digest",
|
||||||
|
"fiat-crypto",
|
||||||
|
"rustc_version",
|
||||||
|
"subtle",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "curve25519-dalek-derive"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.117",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "darling"
|
name = "darling"
|
||||||
version = "0.21.3"
|
version = "0.21.3"
|
||||||
|
|
@ -1512,6 +1545,16 @@ version = "0.3.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4"
|
checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "der"
|
||||||
|
version = "0.7.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
|
||||||
|
dependencies = [
|
||||||
|
"const-oid",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deranged"
|
name = "deranged"
|
||||||
version = "0.5.8"
|
version = "0.5.8"
|
||||||
|
|
@ -2273,6 +2316,30 @@ dependencies = [
|
||||||
"cipher",
|
"cipher",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ed25519"
|
||||||
|
version = "2.2.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
|
||||||
|
dependencies = [
|
||||||
|
"pkcs8",
|
||||||
|
"signature",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ed25519-dalek"
|
||||||
|
version = "2.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
|
||||||
|
dependencies = [
|
||||||
|
"curve25519-dalek",
|
||||||
|
"ed25519",
|
||||||
|
"serde",
|
||||||
|
"sha2",
|
||||||
|
"subtle",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "either"
|
name = "either"
|
||||||
version = "1.15.0"
|
version = "1.15.0"
|
||||||
|
|
@ -2444,6 +2511,12 @@ dependencies = [
|
||||||
"simd-adler32",
|
"simd-adler32",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fiat-crypto"
|
||||||
|
version = "0.2.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "field-offset"
|
name = "field-offset"
|
||||||
version = "0.3.6"
|
version = "0.3.6"
|
||||||
|
|
@ -5323,6 +5396,7 @@ dependencies = [
|
||||||
"blake3",
|
"blake3",
|
||||||
"chrono",
|
"chrono",
|
||||||
"deadpool-postgres",
|
"deadpool-postgres",
|
||||||
|
"ed25519-dalek",
|
||||||
"epub",
|
"epub",
|
||||||
"gray_matter",
|
"gray_matter",
|
||||||
"image",
|
"image",
|
||||||
|
|
@ -5338,6 +5412,7 @@ dependencies = [
|
||||||
"pinakes-plugin-api",
|
"pinakes-plugin-api",
|
||||||
"postgres-native-tls",
|
"postgres-native-tls",
|
||||||
"postgres-types",
|
"postgres-types",
|
||||||
|
"rand 0.10.0",
|
||||||
"refinery",
|
"refinery",
|
||||||
"regex",
|
"regex",
|
||||||
"reqwest 0.13.2",
|
"reqwest 0.13.2",
|
||||||
|
|
@ -5351,6 +5426,7 @@ dependencies = [
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
"toml 1.0.6+spec-1.1.0",
|
"toml 1.0.6+spec-1.1.0",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"url",
|
||||||
"urlencoding",
|
"urlencoding",
|
||||||
"uuid",
|
"uuid",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
|
|
@ -5454,6 +5530,16 @@ dependencies = [
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pkcs8"
|
||||||
|
version = "0.10.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
|
||||||
|
dependencies = [
|
||||||
|
"der",
|
||||||
|
"spki",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pkg-config"
|
name = "pkg-config"
|
||||||
version = "0.3.32"
|
version = "0.3.32"
|
||||||
|
|
@ -6856,6 +6942,15 @@ dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "signature"
|
||||||
|
version = "2.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||||
|
dependencies = [
|
||||||
|
"rand_core 0.6.4",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "simd-adler32"
|
name = "simd-adler32"
|
||||||
version = "0.3.8"
|
version = "0.3.8"
|
||||||
|
|
@ -6989,6 +7084,16 @@ dependencies = [
|
||||||
"lock_api",
|
"lock_api",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "spki"
|
||||||
|
version = "0.7.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
|
||||||
|
dependencies = [
|
||||||
|
"base64ct",
|
||||||
|
"der",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "stable_deref_trait"
|
name = "stable_deref_trait"
|
||||||
version = "1.2.1"
|
version = "1.2.1"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
[workspace]
|
[workspace]
|
||||||
members = ["crates/*"]
|
members = ["crates/*"]
|
||||||
|
exclude = ["crates/pinakes-core/tests/fixtures/test-plugin"]
|
||||||
resolver = "3"
|
resolver = "3"
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
|
|
@ -46,6 +47,9 @@ tracing-subscriber = { version = "0.3.22", features = ["env-filter", "json"] }
|
||||||
# Hashing
|
# Hashing
|
||||||
blake3 = "1.8.3"
|
blake3 = "1.8.3"
|
||||||
|
|
||||||
|
# Cryptographic signatures (plugin verification)
|
||||||
|
ed25519-dalek = { version = "2.1.1", features = ["std"] }
|
||||||
|
|
||||||
# Metadata extraction
|
# Metadata extraction
|
||||||
lofty = "0.23.2"
|
lofty = "0.23.2"
|
||||||
lopdf = "0.39.0"
|
lopdf = "0.39.0"
|
||||||
|
|
@ -88,6 +92,7 @@ tower_governor = "0.8.0"
|
||||||
|
|
||||||
# HTTP client
|
# HTTP client
|
||||||
reqwest = { version = "0.13.2", features = ["json", "query", "blocking"] }
|
reqwest = { version = "0.13.2", features = ["json", "query", "blocking"] }
|
||||||
|
url = "2.5"
|
||||||
|
|
||||||
# TUI
|
# TUI
|
||||||
ratatui = "0.30.0"
|
ratatui = "0.30.0"
|
||||||
|
|
@ -136,6 +141,9 @@ http = "1.4.0"
|
||||||
wasmtime = { version = "42.0.1", features = ["component-model"] }
|
wasmtime = { version = "42.0.1", features = ["component-model"] }
|
||||||
wit-bindgen = "0.53.1"
|
wit-bindgen = "0.53.1"
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
tempfile = "3.26.0"
|
||||||
|
|
||||||
# See:
|
# See:
|
||||||
# <https://doc.rust-lang.org/rustc/lints/listing/allowed-by-default.html>
|
# <https://doc.rust-lang.org/rustc/lints/listing/allowed-by-default.html>
|
||||||
[workspace.lints.clippy]
|
[workspace.lints.clippy]
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ kamadak-exif = { workspace = true }
|
||||||
image = { workspace = true }
|
image = { workspace = true }
|
||||||
tokio-util = { workspace = true }
|
tokio-util = { workspace = true }
|
||||||
reqwest = { workspace = true }
|
reqwest = { workspace = true }
|
||||||
|
url = { workspace = true }
|
||||||
argon2 = { workspace = true }
|
argon2 = { workspace = true }
|
||||||
regex = { workspace = true }
|
regex = { workspace = true }
|
||||||
moka = { workspace = true }
|
moka = { workspace = true }
|
||||||
|
|
@ -45,9 +46,11 @@ image_hasher = { workspace = true }
|
||||||
# Plugin system
|
# Plugin system
|
||||||
pinakes-plugin-api.workspace = true
|
pinakes-plugin-api.workspace = true
|
||||||
wasmtime.workspace = true
|
wasmtime.workspace = true
|
||||||
|
ed25519-dalek.workspace = true
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3.25.0"
|
tempfile = { workspace = true }
|
||||||
|
rand = { workspace = true }
|
||||||
|
|
|
||||||
|
|
@ -436,24 +436,69 @@ impl std::fmt::Display for UserRole {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct PluginTimeoutConfig {
|
||||||
|
/// Timeout for capability discovery queries (`supported_types`,
|
||||||
|
/// `interested_events`)
|
||||||
|
#[serde(default = "default_capability_query_timeout")]
|
||||||
|
pub capability_query_secs: u64,
|
||||||
|
/// Timeout for processing calls (`extract_metadata`, `generate_thumbnail`)
|
||||||
|
#[serde(default = "default_processing_timeout")]
|
||||||
|
pub processing_secs: u64,
|
||||||
|
/// Timeout for event handler calls
|
||||||
|
#[serde(default = "default_event_handler_timeout")]
|
||||||
|
pub event_handler_secs: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn default_capability_query_timeout() -> u64 {
|
||||||
|
2
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn default_processing_timeout() -> u64 {
|
||||||
|
30
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn default_event_handler_timeout() -> u64 {
|
||||||
|
10
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for PluginTimeoutConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
capability_query_secs: default_capability_query_timeout(),
|
||||||
|
processing_secs: default_processing_timeout(),
|
||||||
|
event_handler_secs: default_event_handler_timeout(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct PluginsConfig {
|
pub struct PluginsConfig {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
#[serde(default = "default_plugin_data_dir")]
|
#[serde(default = "default_plugin_data_dir")]
|
||||||
pub data_dir: PathBuf,
|
pub data_dir: PathBuf,
|
||||||
#[serde(default = "default_plugin_cache_dir")]
|
#[serde(default = "default_plugin_cache_dir")]
|
||||||
pub cache_dir: PathBuf,
|
pub cache_dir: PathBuf,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub plugin_dirs: Vec<PathBuf>,
|
pub plugin_dirs: Vec<PathBuf>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub enable_hot_reload: bool,
|
pub enable_hot_reload: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub allow_unsigned: bool,
|
pub allow_unsigned: bool,
|
||||||
#[serde(default = "default_max_concurrent_ops")]
|
#[serde(default = "default_max_concurrent_ops")]
|
||||||
pub max_concurrent_ops: usize,
|
pub max_concurrent_ops: usize,
|
||||||
#[serde(default = "default_plugin_timeout")]
|
#[serde(default = "default_plugin_timeout")]
|
||||||
pub plugin_timeout_secs: u64,
|
pub plugin_timeout_secs: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub timeouts: PluginTimeoutConfig,
|
||||||
|
#[serde(default = "default_max_consecutive_failures")]
|
||||||
|
pub max_consecutive_failures: u32,
|
||||||
|
|
||||||
|
/// Hex-encoded Ed25519 public keys trusted for plugin signature
|
||||||
|
/// verification. Each entry is 64 hex characters (32 bytes).
|
||||||
|
#[serde(default)]
|
||||||
|
pub trusted_keys: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_plugin_data_dir() -> PathBuf {
|
fn default_plugin_data_dir() -> PathBuf {
|
||||||
|
|
@ -472,17 +517,24 @@ const fn default_plugin_timeout() -> u64 {
|
||||||
30
|
30
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fn default_max_consecutive_failures() -> u32 {
|
||||||
|
5
|
||||||
|
}
|
||||||
|
|
||||||
impl Default for PluginsConfig {
|
impl Default for PluginsConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
data_dir: default_plugin_data_dir(),
|
data_dir: default_plugin_data_dir(),
|
||||||
cache_dir: default_plugin_cache_dir(),
|
cache_dir: default_plugin_cache_dir(),
|
||||||
plugin_dirs: vec![],
|
plugin_dirs: vec![],
|
||||||
enable_hot_reload: false,
|
enable_hot_reload: false,
|
||||||
allow_unsigned: false,
|
allow_unsigned: false,
|
||||||
max_concurrent_ops: default_max_concurrent_ops(),
|
max_concurrent_ops: default_max_concurrent_ops(),
|
||||||
plugin_timeout_secs: default_plugin_timeout(),
|
plugin_timeout_secs: default_plugin_timeout(),
|
||||||
|
timeouts: PluginTimeoutConfig::default(),
|
||||||
|
max_consecutive_failures: default_max_consecutive_failures(),
|
||||||
|
trusted_keys: vec![],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,9 @@ pub enum PinakesError {
|
||||||
#[error("metadata extraction failed: {0}")]
|
#[error("metadata extraction failed: {0}")]
|
||||||
MetadataExtraction(String),
|
MetadataExtraction(String),
|
||||||
|
|
||||||
|
#[error("thumbnail generation failed: {0}")]
|
||||||
|
ThumbnailGeneration(String),
|
||||||
|
|
||||||
#[error("search query parse error: {0}")]
|
#[error("search query parse error: {0}")]
|
||||||
SearchParse(String),
|
SearchParse(String),
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,14 +19,19 @@ use tokio::sync::RwLock;
|
||||||
use tracing::{debug, error, info, warn};
|
use tracing::{debug, error, info, warn};
|
||||||
|
|
||||||
pub mod loader;
|
pub mod loader;
|
||||||
|
pub mod pipeline;
|
||||||
pub mod registry;
|
pub mod registry;
|
||||||
|
pub mod rpc;
|
||||||
pub mod runtime;
|
pub mod runtime;
|
||||||
pub mod security;
|
pub mod security;
|
||||||
|
pub mod signature;
|
||||||
|
|
||||||
pub use loader::PluginLoader;
|
pub use loader::PluginLoader;
|
||||||
|
pub use pipeline::PluginPipeline;
|
||||||
pub use registry::{PluginRegistry, RegisteredPlugin};
|
pub use registry::{PluginRegistry, RegisteredPlugin};
|
||||||
pub use runtime::{WasmPlugin, WasmRuntime};
|
pub use runtime::{WasmPlugin, WasmRuntime};
|
||||||
pub use security::CapabilityEnforcer;
|
pub use security::CapabilityEnforcer;
|
||||||
|
pub use signature::{SignatureStatus, verify_plugin_signature};
|
||||||
|
|
||||||
/// Plugin manager coordinates plugin lifecycle and operations
|
/// Plugin manager coordinates plugin lifecycle and operations
|
||||||
pub struct PluginManager {
|
pub struct PluginManager {
|
||||||
|
|
@ -69,16 +74,28 @@ pub struct PluginManagerConfig {
|
||||||
|
|
||||||
/// Plugin timeout in seconds
|
/// Plugin timeout in seconds
|
||||||
pub plugin_timeout_secs: u64,
|
pub plugin_timeout_secs: u64,
|
||||||
|
|
||||||
|
/// Timeout configuration for different call types
|
||||||
|
pub timeouts: crate::config::PluginTimeoutConfig,
|
||||||
|
|
||||||
|
/// Max consecutive failures before circuit breaker disables plugin
|
||||||
|
pub max_consecutive_failures: u32,
|
||||||
|
|
||||||
|
/// Trusted Ed25519 public keys for signature verification (hex-encoded)
|
||||||
|
pub trusted_keys: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for PluginManagerConfig {
|
impl Default for PluginManagerConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
plugin_dirs: vec![],
|
plugin_dirs: vec![],
|
||||||
enable_hot_reload: false,
|
enable_hot_reload: false,
|
||||||
allow_unsigned: false,
|
allow_unsigned: false,
|
||||||
max_concurrent_ops: 4,
|
max_concurrent_ops: 4,
|
||||||
plugin_timeout_secs: 30,
|
plugin_timeout_secs: 30,
|
||||||
|
timeouts: crate::config::PluginTimeoutConfig::default(),
|
||||||
|
max_consecutive_failures: 5,
|
||||||
|
trusted_keys: vec![],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -86,11 +103,14 @@ impl Default for PluginManagerConfig {
|
||||||
impl From<crate::config::PluginsConfig> for PluginManagerConfig {
|
impl From<crate::config::PluginsConfig> for PluginManagerConfig {
|
||||||
fn from(cfg: crate::config::PluginsConfig) -> Self {
|
fn from(cfg: crate::config::PluginsConfig) -> Self {
|
||||||
Self {
|
Self {
|
||||||
plugin_dirs: cfg.plugin_dirs,
|
plugin_dirs: cfg.plugin_dirs,
|
||||||
enable_hot_reload: cfg.enable_hot_reload,
|
enable_hot_reload: cfg.enable_hot_reload,
|
||||||
allow_unsigned: cfg.allow_unsigned,
|
allow_unsigned: cfg.allow_unsigned,
|
||||||
max_concurrent_ops: cfg.max_concurrent_ops,
|
max_concurrent_ops: cfg.max_concurrent_ops,
|
||||||
plugin_timeout_secs: cfg.plugin_timeout_secs,
|
plugin_timeout_secs: cfg.plugin_timeout_secs,
|
||||||
|
timeouts: cfg.timeouts,
|
||||||
|
max_consecutive_failures: cfg.max_consecutive_failures,
|
||||||
|
trusted_keys: cfg.trusted_keys,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -127,7 +147,12 @@ impl PluginManager {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Discover and load all plugins from configured directories
|
/// Discover and load all plugins from configured directories.
|
||||||
|
///
|
||||||
|
/// Plugins are loaded in dependency order: if plugin A declares a
|
||||||
|
/// dependency on plugin B, B is loaded first. Cycles and missing
|
||||||
|
/// dependencies are detected and reported as warnings; affected plugins
|
||||||
|
/// are skipped rather than causing a hard failure.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
|
|
@ -136,9 +161,10 @@ impl PluginManager {
|
||||||
info!("Discovering plugins from {:?}", self.config.plugin_dirs);
|
info!("Discovering plugins from {:?}", self.config.plugin_dirs);
|
||||||
|
|
||||||
let manifests = self.loader.discover_plugins()?;
|
let manifests = self.loader.discover_plugins()?;
|
||||||
|
let ordered = Self::resolve_load_order(&manifests);
|
||||||
let mut loaded_plugins = Vec::new();
|
let mut loaded_plugins = Vec::new();
|
||||||
|
|
||||||
for manifest in manifests {
|
for manifest in ordered {
|
||||||
match self.load_plugin_from_manifest(&manifest).await {
|
match self.load_plugin_from_manifest(&manifest).await {
|
||||||
Ok(plugin_id) => {
|
Ok(plugin_id) => {
|
||||||
info!("Loaded plugin: {}", plugin_id);
|
info!("Loaded plugin: {}", plugin_id);
|
||||||
|
|
@ -153,6 +179,93 @@ impl PluginManager {
|
||||||
Ok(loaded_plugins)
|
Ok(loaded_plugins)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Topological sort of manifests by their declared `dependencies`.
|
||||||
|
///
|
||||||
|
/// Uses Kahn's algorithm. Plugins whose dependencies are missing or form
|
||||||
|
/// a cycle are logged as warnings and excluded from the result.
|
||||||
|
fn resolve_load_order(
|
||||||
|
manifests: &[pinakes_plugin_api::PluginManifest],
|
||||||
|
) -> Vec<pinakes_plugin_api::PluginManifest> {
|
||||||
|
use std::collections::{HashMap, HashSet, VecDeque};
|
||||||
|
|
||||||
|
// Index manifests by name for O(1) lookup
|
||||||
|
let by_name: HashMap<&str, usize> = manifests
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, m)| (m.plugin.name.as_str(), i))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Check for missing dependencies and warn early
|
||||||
|
let known: HashSet<&str> = by_name.keys().copied().collect();
|
||||||
|
for manifest in manifests {
|
||||||
|
for dep in &manifest.plugin.dependencies {
|
||||||
|
if !known.contains(dep.as_str()) {
|
||||||
|
warn!(
|
||||||
|
"Plugin '{}' depends on '{}' which was not discovered; it will be \
|
||||||
|
skipped",
|
||||||
|
manifest.plugin.name, dep
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build adjacency: in_degree[i] = number of deps that must load before i
|
||||||
|
let mut in_degree = vec![0usize; manifests.len()];
|
||||||
|
// dependents[i] = indices that depend on i (i must load before them)
|
||||||
|
let mut dependents: Vec<Vec<usize>> = vec![vec![]; manifests.len()];
|
||||||
|
|
||||||
|
for (i, manifest) in manifests.iter().enumerate() {
|
||||||
|
for dep in &manifest.plugin.dependencies {
|
||||||
|
if let Some(&dep_idx) = by_name.get(dep.as_str()) {
|
||||||
|
in_degree[i] += 1;
|
||||||
|
dependents[dep_idx].push(i);
|
||||||
|
} else {
|
||||||
|
// Missing dep: set in_degree impossibly high so it never resolves
|
||||||
|
in_degree[i] = usize::MAX;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kahn's algorithm
|
||||||
|
let mut queue: VecDeque<usize> = VecDeque::new();
|
||||||
|
for (i, °) in in_degree.iter().enumerate() {
|
||||||
|
if deg == 0 {
|
||||||
|
queue.push_back(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut result = Vec::with_capacity(manifests.len());
|
||||||
|
while let Some(idx) = queue.pop_front() {
|
||||||
|
result.push(manifests[idx].clone());
|
||||||
|
for &dependent in &dependents[idx] {
|
||||||
|
if in_degree[dependent] == usize::MAX {
|
||||||
|
continue; // already poisoned by missing dep
|
||||||
|
}
|
||||||
|
in_degree[dependent] -= 1;
|
||||||
|
if in_degree[dependent] == 0 {
|
||||||
|
queue.push_back(dependent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anything not in `result` is part of a cycle or has a missing dep
|
||||||
|
if result.len() < manifests.len() {
|
||||||
|
let loaded: HashSet<&str> =
|
||||||
|
result.iter().map(|m| m.plugin.name.as_str()).collect();
|
||||||
|
for manifest in manifests {
|
||||||
|
if !loaded.contains(manifest.plugin.name.as_str()) {
|
||||||
|
warn!(
|
||||||
|
"Plugin '{}' was skipped due to unresolved dependencies or a \
|
||||||
|
dependency cycle",
|
||||||
|
manifest.plugin.name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
/// Load a plugin from a manifest file
|
/// Load a plugin from a manifest file
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
|
|
@ -217,6 +330,45 @@ impl PluginManager {
|
||||||
|
|
||||||
// Load WASM binary
|
// Load WASM binary
|
||||||
let wasm_path = self.loader.resolve_wasm_path(manifest)?;
|
let wasm_path = self.loader.resolve_wasm_path(manifest)?;
|
||||||
|
|
||||||
|
// Verify plugin signature unless unsigned plugins are allowed
|
||||||
|
if !self.config.allow_unsigned {
|
||||||
|
let plugin_dir = wasm_path
|
||||||
|
.parent()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("WASM path has no parent directory"))?;
|
||||||
|
|
||||||
|
let trusted_keys: Vec<ed25519_dalek::VerifyingKey> = self
|
||||||
|
.config
|
||||||
|
.trusted_keys
|
||||||
|
.iter()
|
||||||
|
.filter_map(|hex| {
|
||||||
|
signature::parse_public_key(hex)
|
||||||
|
.map_err(|e| warn!("Ignoring malformed trusted key: {e}"))
|
||||||
|
.ok()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
match signature::verify_plugin_signature(
|
||||||
|
plugin_dir,
|
||||||
|
&wasm_path,
|
||||||
|
&trusted_keys,
|
||||||
|
)? {
|
||||||
|
SignatureStatus::Valid => {
|
||||||
|
debug!("Plugin '{plugin_id}' signature verified");
|
||||||
|
},
|
||||||
|
SignatureStatus::Unsigned => {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"Plugin '{plugin_id}' is unsigned and allow_unsigned is false"
|
||||||
|
));
|
||||||
|
},
|
||||||
|
SignatureStatus::Invalid(reason) => {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"Plugin '{plugin_id}' has an invalid signature: {reason}"
|
||||||
|
));
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let wasm_plugin = self.runtime.load_plugin(&wasm_path, context)?;
|
let wasm_plugin = self.runtime.load_plugin(&wasm_path, context)?;
|
||||||
|
|
||||||
// Initialize plugin
|
// Initialize plugin
|
||||||
|
|
@ -413,6 +565,40 @@ impl PluginManager {
|
||||||
registry.get(plugin_id).map(|p| p.metadata.clone())
|
registry.get(plugin_id).map(|p| p.metadata.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get enabled plugins of a specific kind, sorted by priority (ascending).
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// `(plugin_id, priority, kinds, wasm_plugin)` tuples.
|
||||||
|
pub async fn get_enabled_by_kind_sorted(
|
||||||
|
&self,
|
||||||
|
kind: &str,
|
||||||
|
) -> Vec<(String, u16, Vec<String>, WasmPlugin)> {
|
||||||
|
let registry = self.registry.read().await;
|
||||||
|
let mut plugins: Vec<_> = registry
|
||||||
|
.get_by_kind(kind)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|p| p.enabled)
|
||||||
|
.map(|p| {
|
||||||
|
(
|
||||||
|
p.id.clone(),
|
||||||
|
p.manifest.plugin.priority,
|
||||||
|
p.manifest.plugin.kind.clone(),
|
||||||
|
p.wasm_plugin.clone(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
drop(registry);
|
||||||
|
plugins.sort_by_key(|(_, priority, ..)| *priority);
|
||||||
|
plugins
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a reference to the capability enforcer.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn enforcer(&self) -> &CapabilityEnforcer {
|
||||||
|
&self.enforcer
|
||||||
|
}
|
||||||
|
|
||||||
/// Check if a plugin is loaded and enabled
|
/// Check if a plugin is loaded and enabled
|
||||||
pub async fn is_plugin_enabled(&self, plugin_id: &str) -> bool {
|
pub async fn is_plugin_enabled(&self, plugin_id: &str) -> bool {
|
||||||
let registry = self.registry.read().await;
|
let registry = self.registry.read().await;
|
||||||
|
|
@ -503,4 +689,137 @@ mod tests {
|
||||||
let plugins = manager.list_plugins().await;
|
let plugins = manager.list_plugins().await;
|
||||||
assert_eq!(plugins.len(), 0);
|
assert_eq!(plugins.len(), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build a minimal manifest for dependency resolution tests
|
||||||
|
fn test_manifest(
|
||||||
|
name: &str,
|
||||||
|
deps: Vec<String>,
|
||||||
|
) -> pinakes_plugin_api::PluginManifest {
|
||||||
|
use pinakes_plugin_api::manifest::{PluginBinary, PluginInfo};
|
||||||
|
|
||||||
|
pinakes_plugin_api::PluginManifest {
|
||||||
|
plugin: PluginInfo {
|
||||||
|
name: name.to_string(),
|
||||||
|
version: "1.0.0".to_string(),
|
||||||
|
api_version: "1.0".to_string(),
|
||||||
|
author: None,
|
||||||
|
description: None,
|
||||||
|
homepage: None,
|
||||||
|
license: None,
|
||||||
|
priority: 500,
|
||||||
|
kind: vec!["media_type".to_string()],
|
||||||
|
binary: PluginBinary {
|
||||||
|
wasm: "plugin.wasm".to_string(),
|
||||||
|
entrypoint: None,
|
||||||
|
},
|
||||||
|
dependencies: deps,
|
||||||
|
},
|
||||||
|
capabilities: Default::default(),
|
||||||
|
config: Default::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_resolve_load_order_no_deps() {
|
||||||
|
let manifests = vec![
|
||||||
|
test_manifest("alpha", vec![]),
|
||||||
|
test_manifest("beta", vec![]),
|
||||||
|
test_manifest("gamma", vec![]),
|
||||||
|
];
|
||||||
|
|
||||||
|
let ordered = PluginManager::resolve_load_order(&manifests);
|
||||||
|
assert_eq!(ordered.len(), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_resolve_load_order_linear_chain() {
|
||||||
|
// gamma depends on beta, beta depends on alpha
|
||||||
|
let manifests = vec![
|
||||||
|
test_manifest("gamma", vec!["beta".to_string()]),
|
||||||
|
test_manifest("alpha", vec![]),
|
||||||
|
test_manifest("beta", vec!["alpha".to_string()]),
|
||||||
|
];
|
||||||
|
|
||||||
|
let ordered = PluginManager::resolve_load_order(&manifests);
|
||||||
|
assert_eq!(ordered.len(), 3);
|
||||||
|
|
||||||
|
let names: Vec<&str> =
|
||||||
|
ordered.iter().map(|m| m.plugin.name.as_str()).collect();
|
||||||
|
let alpha_pos = names.iter().position(|&n| n == "alpha").unwrap();
|
||||||
|
let beta_pos = names.iter().position(|&n| n == "beta").unwrap();
|
||||||
|
let gamma_pos = names.iter().position(|&n| n == "gamma").unwrap();
|
||||||
|
assert!(alpha_pos < beta_pos, "alpha must load before beta");
|
||||||
|
assert!(beta_pos < gamma_pos, "beta must load before gamma");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_resolve_load_order_cycle_detected() {
|
||||||
|
// A -> B -> C -> A (cycle)
|
||||||
|
let manifests = vec![
|
||||||
|
test_manifest("a", vec!["c".to_string()]),
|
||||||
|
test_manifest("b", vec!["a".to_string()]),
|
||||||
|
test_manifest("c", vec!["b".to_string()]),
|
||||||
|
];
|
||||||
|
|
||||||
|
let ordered = PluginManager::resolve_load_order(&manifests);
|
||||||
|
// All three should be excluded due to cycle
|
||||||
|
assert_eq!(ordered.len(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_resolve_load_order_missing_dependency() {
|
||||||
|
let manifests = vec![
|
||||||
|
test_manifest("good", vec![]),
|
||||||
|
test_manifest("bad", vec!["nonexistent".to_string()]),
|
||||||
|
];
|
||||||
|
|
||||||
|
let ordered = PluginManager::resolve_load_order(&manifests);
|
||||||
|
// Only "good" should be loaded; "bad" depends on something missing
|
||||||
|
assert_eq!(ordered.len(), 1);
|
||||||
|
assert_eq!(ordered[0].plugin.name, "good");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_resolve_load_order_partial_cycle() {
|
||||||
|
// "ok" has no deps, "cycle_a" and "cycle_b" form a cycle
|
||||||
|
let manifests = vec![
|
||||||
|
test_manifest("ok", vec![]),
|
||||||
|
test_manifest("cycle_a", vec!["cycle_b".to_string()]),
|
||||||
|
test_manifest("cycle_b", vec!["cycle_a".to_string()]),
|
||||||
|
];
|
||||||
|
|
||||||
|
let ordered = PluginManager::resolve_load_order(&manifests);
|
||||||
|
assert_eq!(ordered.len(), 1);
|
||||||
|
assert_eq!(ordered[0].plugin.name, "ok");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_resolve_load_order_diamond() {
|
||||||
|
// Man look at how beautiful my diamond is...
|
||||||
|
// A
|
||||||
|
// / \
|
||||||
|
// B C
|
||||||
|
// \ /
|
||||||
|
// D
|
||||||
|
let manifests = vec![
|
||||||
|
test_manifest("d", vec!["b".to_string(), "c".to_string()]),
|
||||||
|
test_manifest("b", vec!["a".to_string()]),
|
||||||
|
test_manifest("c", vec!["a".to_string()]),
|
||||||
|
test_manifest("a", vec![]),
|
||||||
|
];
|
||||||
|
|
||||||
|
let ordered = PluginManager::resolve_load_order(&manifests);
|
||||||
|
assert_eq!(ordered.len(), 4);
|
||||||
|
|
||||||
|
let names: Vec<&str> =
|
||||||
|
ordered.iter().map(|m| m.plugin.name.as_str()).collect();
|
||||||
|
let a_pos = names.iter().position(|&n| n == "a").unwrap();
|
||||||
|
let b_pos = names.iter().position(|&n| n == "b").unwrap();
|
||||||
|
let c_pos = names.iter().position(|&n| n == "c").unwrap();
|
||||||
|
let d_pos = names.iter().position(|&n| n == "d").unwrap();
|
||||||
|
assert!(a_pos < b_pos);
|
||||||
|
assert!(a_pos < c_pos);
|
||||||
|
assert!(b_pos < d_pos);
|
||||||
|
assert!(c_pos < d_pos);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1442
crates/pinakes-core/src/plugin/pipeline.rs
Normal file
1442
crates/pinakes-core/src/plugin/pipeline.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -178,8 +178,9 @@ mod tests {
|
||||||
entrypoint: None,
|
entrypoint: None,
|
||||||
},
|
},
|
||||||
dependencies: vec![],
|
dependencies: vec![],
|
||||||
|
priority: 0,
|
||||||
},
|
},
|
||||||
capabilities: Default::default(),
|
capabilities: ManifestCapabilities::default(),
|
||||||
config: HashMap::new(),
|
config: HashMap::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
239
crates/pinakes-core/src/plugin/rpc.rs
Normal file
239
crates/pinakes-core/src/plugin/rpc.rs
Normal file
|
|
@ -0,0 +1,239 @@
|
||||||
|
//! JSON RPC types for structured plugin function calls.
|
||||||
|
//!
|
||||||
|
//! Each extension point maps to well-known exported function names.
|
||||||
|
//! Requests are serialized to JSON, passed to the plugin, and responses
|
||||||
|
//! are deserialized from JSON written by the plugin via `host_set_result`.
|
||||||
|
|
||||||
|
use std::{collections::HashMap, path::PathBuf};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Request to check if a plugin can handle a file
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct CanHandleRequest {
|
||||||
|
pub path: PathBuf,
|
||||||
|
pub mime_type: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response from `can_handle`
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct CanHandleResponse {
|
||||||
|
pub can_handle: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Media type definition returned by `supported_media_types`
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct PluginMediaTypeDefinition {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub category: Option<String>,
|
||||||
|
pub extensions: Vec<String>,
|
||||||
|
pub mime_types: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request to extract metadata from a file
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct ExtractMetadataRequest {
|
||||||
|
pub path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Metadata response from a plugin (all fields optional for partial results)
|
||||||
|
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ExtractMetadataResponse {
|
||||||
|
#[serde(default)]
|
||||||
|
pub title: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub artist: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub album: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub genre: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub year: Option<i32>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub duration_secs: Option<f64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub description: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub extra: HashMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request to generate a thumbnail
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct GenerateThumbnailRequest {
|
||||||
|
pub source_path: PathBuf,
|
||||||
|
pub output_path: PathBuf,
|
||||||
|
pub max_width: u32,
|
||||||
|
pub max_height: u32,
|
||||||
|
pub format: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response from thumbnail generation
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct GenerateThumbnailResponse {
|
||||||
|
pub path: PathBuf,
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
pub format: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Event sent to event handler plugins
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct HandleEventRequest {
|
||||||
|
pub event_type: String,
|
||||||
|
pub payload: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Search request for search backend plugins
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct SearchRequest {
|
||||||
|
pub query: String,
|
||||||
|
pub limit: usize,
|
||||||
|
pub offset: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Search response
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct SearchResponse {
|
||||||
|
pub results: Vec<SearchResultItem>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub total_count: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Individual search result
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct SearchResultItem {
|
||||||
|
pub id: String,
|
||||||
|
pub score: f64,
|
||||||
|
pub snippet: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request to index a media item in a search backend
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct IndexItemRequest {
|
||||||
|
pub id: String,
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub artist: Option<String>,
|
||||||
|
pub album: Option<String>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
pub media_type: String,
|
||||||
|
pub path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request to remove a media item from a search backend
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct RemoveItemRequest {
|
||||||
|
pub id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A theme definition returned by a theme provider plugin
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct PluginThemeDefinition {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub dark: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response from `load_theme`
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct LoadThemeResponse {
|
||||||
|
pub css: Option<String>,
|
||||||
|
pub colors: HashMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_metadata_request_serialization() {
|
||||||
|
let req = ExtractMetadataRequest {
|
||||||
|
path: "/tmp/test.mp3".into(),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&req).unwrap();
|
||||||
|
assert!(json.contains("/tmp/test.mp3"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_metadata_response_partial() {
|
||||||
|
let json = r#"{"title":"My Song","extra":{"bpm":"120"}}"#;
|
||||||
|
let resp: ExtractMetadataResponse = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(resp.title.as_deref(), Some("My Song"));
|
||||||
|
assert_eq!(resp.artist, None);
|
||||||
|
assert_eq!(resp.extra.get("bpm").map(String::as_str), Some("120"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_metadata_response_empty() {
|
||||||
|
let json = "{}";
|
||||||
|
let resp: ExtractMetadataResponse = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(resp.title, None);
|
||||||
|
assert!(resp.extra.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_can_handle_response() {
|
||||||
|
let json = r#"{"can_handle":true}"#;
|
||||||
|
let resp: CanHandleResponse = serde_json::from_str(json).unwrap();
|
||||||
|
assert!(resp.can_handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_can_handle_response_false() {
|
||||||
|
let json = r#"{"can_handle":false}"#;
|
||||||
|
let resp: CanHandleResponse = serde_json::from_str(json).unwrap();
|
||||||
|
assert!(!resp.can_handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_plugin_media_type_definition_round_trip() {
|
||||||
|
let def = PluginMediaTypeDefinition {
|
||||||
|
id: "heif".to_string(),
|
||||||
|
name: "HEIF Image".to_string(),
|
||||||
|
category: Some("image".to_string()),
|
||||||
|
extensions: vec!["heif".to_string(), "heic".to_string()],
|
||||||
|
mime_types: vec!["image/heif".to_string()],
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&def).unwrap();
|
||||||
|
let parsed: PluginMediaTypeDefinition =
|
||||||
|
serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(parsed.id, "heif");
|
||||||
|
assert_eq!(parsed.extensions.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_search_response() {
|
||||||
|
let json =
|
||||||
|
r#"{"results":[{"id":"abc","score":0.95,"snippet":"match here"}]}"#;
|
||||||
|
let resp: SearchResponse = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(resp.results.len(), 1);
|
||||||
|
assert_eq!(resp.results[0].id, "abc");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_generate_thumbnail_request_serialization() {
|
||||||
|
let req = GenerateThumbnailRequest {
|
||||||
|
source_path: "/media/photo.heif".into(),
|
||||||
|
output_path: "/tmp/thumb.jpg".into(),
|
||||||
|
max_width: 256,
|
||||||
|
max_height: 256,
|
||||||
|
format: "jpeg".to_string(),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&req).unwrap();
|
||||||
|
assert!(json.contains("photo.heif"));
|
||||||
|
assert!(json.contains("256"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_handle_event_request_serialization() {
|
||||||
|
let req = HandleEventRequest {
|
||||||
|
event_type: "MediaImported".to_string(),
|
||||||
|
payload: serde_json::json!({"id": "abc-123"}),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&req).unwrap();
|
||||||
|
assert!(json.contains("MediaImported"));
|
||||||
|
assert!(json.contains("abc-123"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,7 +4,17 @@ use std::{path::Path, sync::Arc};
|
||||||
|
|
||||||
use anyhow::{Result, anyhow};
|
use anyhow::{Result, anyhow};
|
||||||
use pinakes_plugin_api::PluginContext;
|
use pinakes_plugin_api::PluginContext;
|
||||||
use wasmtime::{Caller, Config, Engine, Linker, Module, Store, Val, anyhow};
|
use wasmtime::{
|
||||||
|
Caller,
|
||||||
|
Config,
|
||||||
|
Engine,
|
||||||
|
Linker,
|
||||||
|
Module,
|
||||||
|
Store,
|
||||||
|
StoreLimitsBuilder,
|
||||||
|
Val,
|
||||||
|
anyhow,
|
||||||
|
};
|
||||||
|
|
||||||
/// WASM runtime wrapper for executing plugins
|
/// WASM runtime wrapper for executing plugins
|
||||||
pub struct WasmRuntime {
|
pub struct WasmRuntime {
|
||||||
|
|
@ -58,6 +68,8 @@ impl WasmRuntime {
|
||||||
pub struct PluginStoreData {
|
pub struct PluginStoreData {
|
||||||
pub context: PluginContext,
|
pub context: PluginContext,
|
||||||
pub exchange_buffer: Vec<u8>,
|
pub exchange_buffer: Vec<u8>,
|
||||||
|
pub pending_events: Vec<(String, String)>,
|
||||||
|
pub limiter: wasmtime::StoreLimits,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A loaded WASM plugin instance
|
/// A loaded WASM plugin instance
|
||||||
|
|
@ -90,11 +102,23 @@ impl WasmPlugin {
|
||||||
) -> Result<Vec<u8>> {
|
) -> Result<Vec<u8>> {
|
||||||
let engine = self.module.engine();
|
let engine = self.module.engine();
|
||||||
|
|
||||||
|
// Build memory limiter from capabilities
|
||||||
|
let memory_limit = self
|
||||||
|
.context
|
||||||
|
.capabilities
|
||||||
|
.max_memory_bytes
|
||||||
|
.unwrap_or(512 * 1024 * 1024); // default 512 MB
|
||||||
|
|
||||||
|
let limiter = StoreLimitsBuilder::new().memory_size(memory_limit).build();
|
||||||
|
|
||||||
let store_data = PluginStoreData {
|
let store_data = PluginStoreData {
|
||||||
context: self.context.clone(),
|
context: self.context.clone(),
|
||||||
exchange_buffer: Vec::new(),
|
exchange_buffer: Vec::new(),
|
||||||
|
pending_events: Vec::new(),
|
||||||
|
limiter,
|
||||||
};
|
};
|
||||||
let mut store = Store::new(engine, store_data);
|
let mut store = Store::new(engine, store_data);
|
||||||
|
store.limiter(|data| &mut data.limiter);
|
||||||
|
|
||||||
// Set fuel limit based on capabilities
|
// Set fuel limit based on capabilities
|
||||||
if let Some(max_cpu_time_ms) = self.context.capabilities.max_cpu_time_ms {
|
if let Some(max_cpu_time_ms) = self.context.capabilities.max_cpu_time_ms {
|
||||||
|
|
@ -194,6 +218,47 @@ impl WasmPlugin {
|
||||||
Ok(Vec::new())
|
Ok(Vec::new())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Call a plugin function with JSON request/response serialization.
|
||||||
|
///
|
||||||
|
/// Serializes `request` to JSON, calls the named function, deserializes
|
||||||
|
/// the response. Wraps the call with `tokio::time::timeout`.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error if serialization fails, the call times out, the plugin
|
||||||
|
/// traps, or the response is malformed JSON.
|
||||||
|
#[allow(clippy::future_not_send)] // Req doesn't need Sync; called within local tasks
|
||||||
|
pub async fn call_function_json<Req, Resp>(
|
||||||
|
&self,
|
||||||
|
function_name: &str,
|
||||||
|
request: &Req,
|
||||||
|
timeout: std::time::Duration,
|
||||||
|
) -> anyhow::Result<Resp>
|
||||||
|
where
|
||||||
|
Req: serde::Serialize,
|
||||||
|
Resp: serde::de::DeserializeOwned,
|
||||||
|
{
|
||||||
|
let request_bytes = serde_json::to_vec(request)
|
||||||
|
.map_err(|e| anyhow::anyhow!("failed to serialize request: {e}"))?;
|
||||||
|
|
||||||
|
let result = tokio::time::timeout(
|
||||||
|
timeout,
|
||||||
|
self.call_function(function_name, &request_bytes),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| {
|
||||||
|
anyhow::anyhow!(
|
||||||
|
"plugin call '{function_name}' timed out after {timeout:?}"
|
||||||
|
)
|
||||||
|
})??;
|
||||||
|
|
||||||
|
serde_json::from_slice(&result).map_err(|e| {
|
||||||
|
anyhow::anyhow!(
|
||||||
|
"failed to deserialize response from '{function_name}': {e}"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
@ -220,7 +285,8 @@ pub struct HostFunctions;
|
||||||
impl HostFunctions {
|
impl HostFunctions {
|
||||||
/// Registers all host ABI functions (`host_log`, `host_read_file`,
|
/// Registers all host ABI functions (`host_log`, `host_read_file`,
|
||||||
/// `host_write_file`, `host_http_request`, `host_get_config`,
|
/// `host_write_file`, `host_http_request`, `host_get_config`,
|
||||||
/// `host_get_buffer`) into the given linker.
|
/// `host_get_env`, `host_get_buffer`, `host_set_result`,
|
||||||
|
/// `host_emit_event`) into the given linker.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
|
|
@ -423,6 +489,29 @@ impl HostFunctions {
|
||||||
return -2;
|
return -2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check domain whitelist if configured
|
||||||
|
if let Some(ref allowed) =
|
||||||
|
caller.data().context.capabilities.network.allowed_domains
|
||||||
|
{
|
||||||
|
let parsed = match url::Url::parse(&url_str) {
|
||||||
|
Ok(u) => u,
|
||||||
|
_ => {
|
||||||
|
tracing::warn!(url = %url_str, "plugin provided invalid URL");
|
||||||
|
return -1;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let domain = parsed.host_str().unwrap_or("");
|
||||||
|
|
||||||
|
if !allowed.iter().any(|d| d.eq_ignore_ascii_case(domain)) {
|
||||||
|
tracing::warn!(
|
||||||
|
url = %url_str,
|
||||||
|
domain = domain,
|
||||||
|
"plugin domain not in allowlist"
|
||||||
|
);
|
||||||
|
return -3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Use block_in_place to avoid blocking the async runtime's thread pool.
|
// Use block_in_place to avoid blocking the async runtime's thread pool.
|
||||||
// Falls back to a blocking client with timeout if block_in_place is
|
// Falls back to a blocking client with timeout if block_in_place is
|
||||||
// unavailable.
|
// unavailable.
|
||||||
|
|
@ -513,6 +602,66 @@ impl HostFunctions {
|
||||||
},
|
},
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
|
linker.func_wrap(
|
||||||
|
"env",
|
||||||
|
"host_get_env",
|
||||||
|
|mut caller: Caller<'_, PluginStoreData>,
|
||||||
|
key_ptr: i32,
|
||||||
|
key_len: i32|
|
||||||
|
-> i32 {
|
||||||
|
if key_ptr < 0 || key_len < 0 {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
let memory = caller
|
||||||
|
.get_export("memory")
|
||||||
|
.and_then(wasmtime::Extern::into_memory);
|
||||||
|
let Some(mem) = memory else { return -1 };
|
||||||
|
|
||||||
|
let data = mem.data(&caller);
|
||||||
|
let start = u32::try_from(key_ptr).unwrap_or(0) as usize;
|
||||||
|
let end = start + u32::try_from(key_len).unwrap_or(0) as usize;
|
||||||
|
if end > data.len() {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let key_str = match std::str::from_utf8(&data[start..end]) {
|
||||||
|
Ok(s) => s.to_string(),
|
||||||
|
Err(_) => return -1,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check environment capability
|
||||||
|
let env_cap = &caller.data().context.capabilities.environment;
|
||||||
|
if !env_cap.enabled {
|
||||||
|
tracing::warn!(
|
||||||
|
var = %key_str,
|
||||||
|
"plugin environment access denied"
|
||||||
|
);
|
||||||
|
return -2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check against allowed variables list if configured
|
||||||
|
if let Some(ref allowed) = env_cap.allowed_vars
|
||||||
|
&& !allowed.iter().any(|v| v == &key_str)
|
||||||
|
{
|
||||||
|
tracing::warn!(
|
||||||
|
var = %key_str,
|
||||||
|
"plugin env var not in allowlist"
|
||||||
|
);
|
||||||
|
return -2;
|
||||||
|
}
|
||||||
|
|
||||||
|
match std::env::var(&key_str) {
|
||||||
|
Ok(value) => {
|
||||||
|
let bytes = value.into_bytes();
|
||||||
|
let len = i32::try_from(bytes.len()).unwrap_or(i32::MAX);
|
||||||
|
caller.data_mut().exchange_buffer = bytes;
|
||||||
|
len
|
||||||
|
},
|
||||||
|
Err(_) => -1,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
|
||||||
linker.func_wrap(
|
linker.func_wrap(
|
||||||
"env",
|
"env",
|
||||||
"host_get_buffer",
|
"host_get_buffer",
|
||||||
|
|
@ -543,6 +692,83 @@ impl HostFunctions {
|
||||||
},
|
},
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
|
linker.func_wrap(
|
||||||
|
"env",
|
||||||
|
"host_set_result",
|
||||||
|
|mut caller: Caller<'_, PluginStoreData>, ptr: i32, len: i32| {
|
||||||
|
if ptr < 0 || len < 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let memory = caller
|
||||||
|
.get_export("memory")
|
||||||
|
.and_then(wasmtime::Extern::into_memory);
|
||||||
|
let Some(mem) = memory else { return };
|
||||||
|
|
||||||
|
let data = mem.data(&caller);
|
||||||
|
let start = u32::try_from(ptr).unwrap_or(0) as usize;
|
||||||
|
let end = start + u32::try_from(len).unwrap_or(0) as usize;
|
||||||
|
if end <= data.len() {
|
||||||
|
caller.data_mut().exchange_buffer = data[start..end].to_vec();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
|
||||||
|
linker.func_wrap(
|
||||||
|
"env",
|
||||||
|
"host_emit_event",
|
||||||
|
|mut caller: Caller<'_, PluginStoreData>,
|
||||||
|
type_ptr: i32,
|
||||||
|
type_len: i32,
|
||||||
|
payload_ptr: i32,
|
||||||
|
payload_len: i32|
|
||||||
|
-> i32 {
|
||||||
|
const MAX_PENDING_EVENTS: usize = 1000;
|
||||||
|
|
||||||
|
if type_ptr < 0 || type_len < 0 || payload_ptr < 0 || payload_len < 0 {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
let memory = caller
|
||||||
|
.get_export("memory")
|
||||||
|
.and_then(wasmtime::Extern::into_memory);
|
||||||
|
let Some(mem) = memory else { return -1 };
|
||||||
|
|
||||||
|
let type_start = u32::try_from(type_ptr).unwrap_or(0) as usize;
|
||||||
|
let type_end =
|
||||||
|
type_start + u32::try_from(type_len).unwrap_or(0) as usize;
|
||||||
|
let payload_start = u32::try_from(payload_ptr).unwrap_or(0) as usize;
|
||||||
|
let payload_end =
|
||||||
|
payload_start + u32::try_from(payload_len).unwrap_or(0) as usize;
|
||||||
|
|
||||||
|
// Extract owned strings in a block so the immutable borrow of
|
||||||
|
// `caller` (via `mem.data`) is dropped before `caller.data_mut()`.
|
||||||
|
let (event_type, payload) = {
|
||||||
|
let data = mem.data(&caller);
|
||||||
|
if type_end > data.len() || payload_end > data.len() {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
let event_type =
|
||||||
|
match std::str::from_utf8(&data[type_start..type_end]) {
|
||||||
|
Ok(s) => s.to_string(),
|
||||||
|
Err(_) => return -1,
|
||||||
|
};
|
||||||
|
let payload =
|
||||||
|
match std::str::from_utf8(&data[payload_start..payload_end]) {
|
||||||
|
Ok(s) => s.to_string(),
|
||||||
|
Err(_) => return -1,
|
||||||
|
};
|
||||||
|
(event_type, payload)
|
||||||
|
};
|
||||||
|
|
||||||
|
if caller.data().pending_events.len() >= MAX_PENDING_EVENTS {
|
||||||
|
tracing::warn!("plugin exceeded max pending events limit");
|
||||||
|
return -4;
|
||||||
|
}
|
||||||
|
|
||||||
|
caller.data_mut().pending_events.push((event_type, payload));
|
||||||
|
0
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -235,6 +235,54 @@ impl CapabilityEnforcer {
|
||||||
.unwrap_or(self.max_cpu_time_limit)
|
.unwrap_or(self.max_cpu_time_limit)
|
||||||
.min(self.max_cpu_time_limit)
|
.min(self.max_cpu_time_limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Validate that a function call is allowed for a plugin's declared kinds.
|
||||||
|
///
|
||||||
|
/// Defense-in-depth: even though the pipeline filters by kind, this prevents
|
||||||
|
/// bugs from calling wrong functions on plugins. Returns `true` if allowed.
|
||||||
|
#[must_use]
|
||||||
|
pub fn validate_function_call(
|
||||||
|
&self,
|
||||||
|
plugin_kinds: &[String],
|
||||||
|
function_name: &str,
|
||||||
|
) -> bool {
|
||||||
|
match function_name {
|
||||||
|
// Lifecycle functions are always allowed
|
||||||
|
"initialize" | "shutdown" | "health_check" => true,
|
||||||
|
// MediaTypeProvider
|
||||||
|
"supported_media_types" | "can_handle" => {
|
||||||
|
plugin_kinds.iter().any(|k| k == "media_type")
|
||||||
|
},
|
||||||
|
// supported_types is shared by metadata_extractor and thumbnail_generator
|
||||||
|
"supported_types" => {
|
||||||
|
plugin_kinds
|
||||||
|
.iter()
|
||||||
|
.any(|k| k == "metadata_extractor" || k == "thumbnail_generator")
|
||||||
|
},
|
||||||
|
// MetadataExtractor
|
||||||
|
"extract_metadata" => {
|
||||||
|
plugin_kinds.iter().any(|k| k == "metadata_extractor")
|
||||||
|
},
|
||||||
|
// ThumbnailGenerator
|
||||||
|
"generate_thumbnail" => {
|
||||||
|
plugin_kinds.iter().any(|k| k == "thumbnail_generator")
|
||||||
|
},
|
||||||
|
// SearchBackend
|
||||||
|
"search" | "index_item" | "remove_item" | "get_stats" => {
|
||||||
|
plugin_kinds.iter().any(|k| k == "search_backend")
|
||||||
|
},
|
||||||
|
// EventHandler
|
||||||
|
"interested_events" | "handle_event" => {
|
||||||
|
plugin_kinds.iter().any(|k| k == "event_handler")
|
||||||
|
},
|
||||||
|
// ThemeProvider
|
||||||
|
"get_themes" | "load_theme" => {
|
||||||
|
plugin_kinds.iter().any(|k| k == "theme_provider")
|
||||||
|
},
|
||||||
|
// Unknown function names are not allowed
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for CapabilityEnforcer {
|
impl Default for CapabilityEnforcer {
|
||||||
|
|
@ -356,20 +404,70 @@ mod tests {
|
||||||
|
|
||||||
let mut caps = Capabilities::default();
|
let mut caps = Capabilities::default();
|
||||||
|
|
||||||
// No limits specified - use defaults
|
// No limits specified, use the defaults
|
||||||
assert_eq!(enforcer.get_memory_limit(&caps), 100 * 1024 * 1024);
|
assert_eq!(enforcer.get_memory_limit(&caps), 100 * 1024 * 1024);
|
||||||
assert_eq!(enforcer.get_cpu_time_limit(&caps), 30_000);
|
assert_eq!(enforcer.get_cpu_time_limit(&caps), 30_000);
|
||||||
|
|
||||||
// Plugin requests lower limits - use plugin's
|
// Plugin requests lower limits, use plugin's
|
||||||
caps.max_memory_bytes = Some(50 * 1024 * 1024);
|
caps.max_memory_bytes = Some(50 * 1024 * 1024);
|
||||||
caps.max_cpu_time_ms = Some(10_000);
|
caps.max_cpu_time_ms = Some(10_000);
|
||||||
assert_eq!(enforcer.get_memory_limit(&caps), 50 * 1024 * 1024);
|
assert_eq!(enforcer.get_memory_limit(&caps), 50 * 1024 * 1024);
|
||||||
assert_eq!(enforcer.get_cpu_time_limit(&caps), 10_000);
|
assert_eq!(enforcer.get_cpu_time_limit(&caps), 10_000);
|
||||||
|
|
||||||
// Plugin requests higher limits - cap at system max
|
// Plugin requests higher limits, cap at system max
|
||||||
caps.max_memory_bytes = Some(200 * 1024 * 1024);
|
caps.max_memory_bytes = Some(200 * 1024 * 1024);
|
||||||
caps.max_cpu_time_ms = Some(60_000);
|
caps.max_cpu_time_ms = Some(60_000);
|
||||||
assert_eq!(enforcer.get_memory_limit(&caps), 100 * 1024 * 1024);
|
assert_eq!(enforcer.get_memory_limit(&caps), 100 * 1024 * 1024);
|
||||||
assert_eq!(enforcer.get_cpu_time_limit(&caps), 30_000);
|
assert_eq!(enforcer.get_cpu_time_limit(&caps), 30_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_function_call_lifecycle_always_allowed() {
|
||||||
|
let enforcer = CapabilityEnforcer::new();
|
||||||
|
let kinds = vec!["metadata_extractor".to_string()];
|
||||||
|
assert!(enforcer.validate_function_call(&kinds, "initialize"));
|
||||||
|
assert!(enforcer.validate_function_call(&kinds, "shutdown"));
|
||||||
|
assert!(enforcer.validate_function_call(&kinds, "health_check"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_function_call_metadata_extractor() {
|
||||||
|
let enforcer = CapabilityEnforcer::new();
|
||||||
|
let kinds = vec!["metadata_extractor".to_string()];
|
||||||
|
assert!(enforcer.validate_function_call(&kinds, "extract_metadata"));
|
||||||
|
assert!(enforcer.validate_function_call(&kinds, "supported_types"));
|
||||||
|
assert!(!enforcer.validate_function_call(&kinds, "search"));
|
||||||
|
assert!(!enforcer.validate_function_call(&kinds, "generate_thumbnail"));
|
||||||
|
assert!(!enforcer.validate_function_call(&kinds, "can_handle"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_function_call_multi_kind() {
|
||||||
|
let enforcer = CapabilityEnforcer::new();
|
||||||
|
let kinds =
|
||||||
|
vec!["media_type".to_string(), "metadata_extractor".to_string()];
|
||||||
|
assert!(enforcer.validate_function_call(&kinds, "can_handle"));
|
||||||
|
assert!(enforcer.validate_function_call(&kinds, "supported_media_types"));
|
||||||
|
assert!(enforcer.validate_function_call(&kinds, "extract_metadata"));
|
||||||
|
assert!(!enforcer.validate_function_call(&kinds, "search"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_function_call_unknown_function() {
|
||||||
|
let enforcer = CapabilityEnforcer::new();
|
||||||
|
let kinds = vec!["metadata_extractor".to_string()];
|
||||||
|
assert!(!enforcer.validate_function_call(&kinds, "unknown_func"));
|
||||||
|
assert!(!enforcer.validate_function_call(&kinds, ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_function_call_shared_supported_types() {
|
||||||
|
let enforcer = CapabilityEnforcer::new();
|
||||||
|
let extractor = vec!["metadata_extractor".to_string()];
|
||||||
|
let generator = vec!["thumbnail_generator".to_string()];
|
||||||
|
let search = vec!["search_backend".to_string()];
|
||||||
|
assert!(enforcer.validate_function_call(&extractor, "supported_types"));
|
||||||
|
assert!(enforcer.validate_function_call(&generator, "supported_types"));
|
||||||
|
assert!(!enforcer.validate_function_call(&search, "supported_types"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
252
crates/pinakes-core/src/plugin/signature.rs
Normal file
252
crates/pinakes-core/src/plugin/signature.rs
Normal file
|
|
@ -0,0 +1,252 @@
|
||||||
|
//! Plugin signature verification using Ed25519 + BLAKE3
|
||||||
|
//!
|
||||||
|
//! Each plugin directory may contain a `plugin.sig` file alongside its
|
||||||
|
//! `plugin.toml`. The signature covers the BLAKE3 hash of the WASM binary
|
||||||
|
//! referenced by the manifest. Verification uses Ed25519 public keys
|
||||||
|
//! configured as trusted in the server's plugin settings.
|
||||||
|
//!
|
||||||
|
//! When `allow_unsigned` is false, plugins _must_ carry a valid signature
|
||||||
|
//! from one of the trusted keys or they will be rejected at load time.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use anyhow::{Result, anyhow};
|
||||||
|
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
|
||||||
|
|
||||||
|
/// Outcome of a signature check on a plugin package.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum SignatureStatus {
|
||||||
|
/// Signature is present and valid against a trusted key.
|
||||||
|
Valid,
|
||||||
|
/// No signature file found.
|
||||||
|
Unsigned,
|
||||||
|
/// Signature file exists but does not match any trusted key.
|
||||||
|
Invalid(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify the signature of a plugin's WASM binary.
|
||||||
|
///
|
||||||
|
/// Reads `plugin.sig` from `plugin_dir`, computes the BLAKE3 hash of the
|
||||||
|
/// WASM binary at `wasm_path`, and verifies the signature against each of
|
||||||
|
/// the `trusted_keys`. The signature file is raw 64-byte Ed25519 signature
|
||||||
|
/// over the 32-byte BLAKE3 digest.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error only on I/O failures, never for cryptographic rejection,
|
||||||
|
/// which is reported via [`SignatureStatus`] instead.
|
||||||
|
pub fn verify_plugin_signature(
|
||||||
|
plugin_dir: &Path,
|
||||||
|
wasm_path: &Path,
|
||||||
|
trusted_keys: &[VerifyingKey],
|
||||||
|
) -> Result<SignatureStatus> {
|
||||||
|
let sig_path = plugin_dir.join("plugin.sig");
|
||||||
|
if !sig_path.exists() {
|
||||||
|
return Ok(SignatureStatus::Unsigned);
|
||||||
|
}
|
||||||
|
|
||||||
|
let sig_bytes = std::fs::read(&sig_path)
|
||||||
|
.map_err(|e| anyhow!("failed to read plugin.sig: {e}"))?;
|
||||||
|
|
||||||
|
let signature = Signature::from_slice(&sig_bytes).map_err(|e| {
|
||||||
|
// Malformed signature file is an invalid signature, not an I/O error
|
||||||
|
tracing::warn!(path = %sig_path.display(), "malformed plugin.sig: {e}");
|
||||||
|
anyhow!("malformed plugin.sig: {e}")
|
||||||
|
});
|
||||||
|
let Ok(signature) = signature else {
|
||||||
|
return Ok(SignatureStatus::Invalid(
|
||||||
|
"malformed signature file".to_string(),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
// BLAKE3 hash of the WASM binary is the signed message
|
||||||
|
let wasm_bytes = std::fs::read(wasm_path)
|
||||||
|
.map_err(|e| anyhow!("failed to read WASM binary for verification: {e}"))?;
|
||||||
|
let digest = blake3::hash(&wasm_bytes);
|
||||||
|
let message = digest.as_bytes();
|
||||||
|
|
||||||
|
for key in trusted_keys {
|
||||||
|
if key.verify(message, &signature).is_ok() {
|
||||||
|
return Ok(SignatureStatus::Valid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(SignatureStatus::Invalid(
|
||||||
|
"signature did not match any trusted key".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a hex-encoded Ed25519 public key (64 hex characters = 32 bytes).
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error if the string is not valid hex or is the wrong length.
|
||||||
|
pub fn parse_public_key(hex_str: &str) -> Result<VerifyingKey> {
|
||||||
|
let hex_str = hex_str.trim();
|
||||||
|
if hex_str.len() != 64 {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"expected 64 hex characters for Ed25519 public key, got {}",
|
||||||
|
hex_str.len()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut bytes = [0u8; 32];
|
||||||
|
for (i, byte) in bytes.iter_mut().enumerate() {
|
||||||
|
*byte = u8::from_str_radix(&hex_str[i * 2..i * 2 + 2], 16)
|
||||||
|
.map_err(|e| anyhow!("invalid hex in public key: {e}"))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
VerifyingKey::from_bytes(&bytes)
|
||||||
|
.map_err(|e| anyhow!("invalid Ed25519 public key: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use ed25519_dalek::{Signer, SigningKey};
|
||||||
|
use rand::RngExt;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn make_keypair() -> (SigningKey, VerifyingKey) {
|
||||||
|
let secret_bytes: [u8; 32] = rand::rng().random();
|
||||||
|
let signing = SigningKey::from_bytes(&secret_bytes);
|
||||||
|
let verifying = signing.verifying_key();
|
||||||
|
(signing, verifying)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_verify_unsigned_plugin() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let wasm_path = dir.path().join("plugin.wasm");
|
||||||
|
std::fs::write(&wasm_path, b"\0asm\x01\x00\x00\x00").unwrap();
|
||||||
|
|
||||||
|
let (_, vk) = make_keypair();
|
||||||
|
let status =
|
||||||
|
verify_plugin_signature(dir.path(), &wasm_path, &[vk]).unwrap();
|
||||||
|
assert_eq!(status, SignatureStatus::Unsigned);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_verify_valid_signature() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let wasm_path = dir.path().join("plugin.wasm");
|
||||||
|
let wasm_bytes = b"\0asm\x01\x00\x00\x00some_code_here";
|
||||||
|
std::fs::write(&wasm_path, wasm_bytes).unwrap();
|
||||||
|
|
||||||
|
let (sk, vk) = make_keypair();
|
||||||
|
|
||||||
|
// Sign the BLAKE3 hash of the WASM binary
|
||||||
|
let digest = blake3::hash(wasm_bytes);
|
||||||
|
let signature = sk.sign(digest.as_bytes());
|
||||||
|
std::fs::write(dir.path().join("plugin.sig"), signature.to_bytes())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let status =
|
||||||
|
verify_plugin_signature(dir.path(), &wasm_path, &[vk]).unwrap();
|
||||||
|
assert_eq!(status, SignatureStatus::Valid);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_verify_wrong_key() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let wasm_path = dir.path().join("plugin.wasm");
|
||||||
|
let wasm_bytes = b"\0asm\x01\x00\x00\x00some_code";
|
||||||
|
std::fs::write(&wasm_path, wasm_bytes).unwrap();
|
||||||
|
|
||||||
|
let (sk, _) = make_keypair();
|
||||||
|
let (_, wrong_vk) = make_keypair();
|
||||||
|
|
||||||
|
let digest = blake3::hash(wasm_bytes);
|
||||||
|
let signature = sk.sign(digest.as_bytes());
|
||||||
|
std::fs::write(dir.path().join("plugin.sig"), signature.to_bytes())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let status =
|
||||||
|
verify_plugin_signature(dir.path(), &wasm_path, &[wrong_vk]).unwrap();
|
||||||
|
assert!(matches!(status, SignatureStatus::Invalid(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_verify_tampered_wasm() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let wasm_path = dir.path().join("plugin.wasm");
|
||||||
|
let original = b"\0asm\x01\x00\x00\x00original";
|
||||||
|
std::fs::write(&wasm_path, original).unwrap();
|
||||||
|
|
||||||
|
let (sk, vk) = make_keypair();
|
||||||
|
let digest = blake3::hash(original);
|
||||||
|
let signature = sk.sign(digest.as_bytes());
|
||||||
|
std::fs::write(dir.path().join("plugin.sig"), signature.to_bytes())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Tamper with the WASM file after signing
|
||||||
|
std::fs::write(&wasm_path, b"\0asm\x01\x00\x00\x00tampered").unwrap();
|
||||||
|
|
||||||
|
let status =
|
||||||
|
verify_plugin_signature(dir.path(), &wasm_path, &[vk]).unwrap();
|
||||||
|
assert!(matches!(status, SignatureStatus::Invalid(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_verify_malformed_sig_file() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let wasm_path = dir.path().join("plugin.wasm");
|
||||||
|
std::fs::write(&wasm_path, b"\0asm\x01\x00\x00\x00").unwrap();
|
||||||
|
|
||||||
|
// Write garbage to plugin.sig (wrong length)
|
||||||
|
std::fs::write(dir.path().join("plugin.sig"), b"not a signature").unwrap();
|
||||||
|
|
||||||
|
let (_, vk) = make_keypair();
|
||||||
|
let status =
|
||||||
|
verify_plugin_signature(dir.path(), &wasm_path, &[vk]).unwrap();
|
||||||
|
assert!(matches!(status, SignatureStatus::Invalid(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_verify_multiple_trusted_keys() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let wasm_path = dir.path().join("plugin.wasm");
|
||||||
|
let wasm_bytes = b"\0asm\x01\x00\x00\x00multi_key_test";
|
||||||
|
std::fs::write(&wasm_path, wasm_bytes).unwrap();
|
||||||
|
|
||||||
|
let (sk2, vk2) = make_keypair();
|
||||||
|
let (_, vk1) = make_keypair();
|
||||||
|
let (_, vk3) = make_keypair();
|
||||||
|
|
||||||
|
// Sign with key 2
|
||||||
|
let digest = blake3::hash(wasm_bytes);
|
||||||
|
let signature = sk2.sign(digest.as_bytes());
|
||||||
|
std::fs::write(dir.path().join("plugin.sig"), signature.to_bytes())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Verify against [vk1, vk2, vk3]; should find vk2
|
||||||
|
let status =
|
||||||
|
verify_plugin_signature(dir.path(), &wasm_path, &[vk1, vk2, vk3])
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(status, SignatureStatus::Valid);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_public_key_valid() {
|
||||||
|
let (_, vk) = make_keypair();
|
||||||
|
let hex = hex_encode(vk.as_bytes());
|
||||||
|
let parsed = parse_public_key(&hex).unwrap();
|
||||||
|
assert_eq!(parsed, vk);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_public_key_wrong_length() {
|
||||||
|
assert!(parse_public_key("abcdef").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_public_key_invalid_hex() {
|
||||||
|
let bad =
|
||||||
|
"zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz";
|
||||||
|
assert!(parse_public_key(bad).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hex_encode(bytes: &[u8]) -> String {
|
||||||
|
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue