Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I0d19f04a6222c89b3de94b722171b3fc6a6a6964
9.4 KiB
Plugin System
Pinakes is very powerful on its own, but with a goal as ambitious as "to be the last media management system you will ever need" I recognize the need for a plugin system; not everything belongs in the core of Pinakes. Thus, Pinakes supports WASM-based plugins for extending media type support, metadata extraction, thumbnail generation, search, event handling, and theming. Plugins run in a sandboxed wasmtime runtime with capability-based security.
How It Works
A plugin is a directory containing:
my-plugin/
plugin.toml Manifest declaring name, version, capabilities
my_plugin.wasm Compiled WASM binary (wasm32-wasi target)
The server discovers plugins in configured directories, validates their manifests, checks capabilities against the security policy, compiles the WASM module, and registers the plugin. Plugins are then invoked through exported functions when the system needs their functionality.
Plugin Kinds
A plugin can implement one or more of these roles:
| Kind | Purpose |
|---|---|
media_type |
Register new file formats (extensions, MIME types) |
metadata_extractor |
Extract metadata from files of specific types |
thumbnail_generator |
Generate thumbnails for specific media types |
search_backend |
Provide alternative search implementations |
event_handler |
React to system events (imports, deletes, etc.) |
theme_provider |
Supply UI themes |
general |
General-purpose (uses host functions freely) |
Declare kinds in plugin.toml:
[plugin]
name = "heif-support"
version = "1.0.0"
api_version = "1.0"
kind = ["media_type", "metadata_extractor", "thumbnail_generator"]
Writing a Plugin
1. Create a Rust library
cargo new --lib my-plugin
In Cargo.toml:
[lib]
crate-type = ["cdylib"]
[dependencies]
pinakes-plugin-api = { path = "../path/to/pinakes/crates/pinakes-plugin-api" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
2. Implement the Plugin trait
use pinakes_plugin_api::{Plugin, PluginContext, PluginMetadata};
pub struct MyPlugin;
impl Plugin for MyPlugin {
fn metadata(&self) -> PluginMetadata {
PluginMetadata {
name: "my-plugin".into(),
version: "1.0.0".into(),
description: "My custom plugin".into(),
author: "Your Name".into(),
}
}
fn initialize(&mut self, _ctx: &PluginContext) -> Result<(), String> {
Ok(())
}
fn shutdown(&mut self) -> Result<(), String> {
Ok(())
}
}
Then implement whichever additional traits your plugin needs
(MediaTypeProvider, MetadataExtractor, ThumbnailGenerator, etc.).
3. Build to WASM
rustup target add wasm32-wasi
cargo build --target wasm32-wasi --release
# Optional: strip debug info to reduce binary size
wasm-tools strip target/wasm32-wasi/release/my_plugin.wasm -o target/wasm32-wasi/release/my_plugin.wasm
4. Create the manifest
[plugin]
name = "my-plugin"
version = "1.0.0"
api_version = "1.0"
description = "A custom plugin"
author = "Your Name"
kind = ["metadata_extractor"]
[plugin.binary]
wasm = "my_plugin.wasm"
[capabilities]
network = false
[capabilities.filesystem]
read = ["/media"]
write = []
[capabilities.resources]
max_memory_bytes = 67108864
max_cpu_time_ms = 5000
5. Install
Place the plugin directory in one of the configured plugin directories, or use the API:
# Install from local path
curl -X POST http://localhost:3000/api/v1/plugins/install \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{"source": "/path/to/my-plugin"}'
# Install from HTTPS URL (downloads and extracts tar.gz)
curl -X POST http://localhost:3000/api/v1/plugins/install \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{"source": "https://example.com/my-plugin-1.0.0.tar.gz"}'
Host Functions
Plugins can call these host functions from within WASM:
host_log(level, ptr, len)
Log a message. Levels: 0=error, 1=warn, 2=info, 3+=debug.
host_read_file(path_ptr, path_len) -> i32
Read a file into the exchange buffer. Returns file size on success, -1 on IO
error, -2 if the path is outside the plugin's allowed read paths.
host_write_file(path_ptr, path_len, data_ptr, data_len) -> i32
Write data to a file. Returns 0 on success, -1 on IO error, -2 if the path
is outside the plugin's allowed write paths.
host_http_request(url_ptr, url_len) -> i32
Make an HTTP GET request. Returns response size on success (body in exchange
buffer), -1 on error, -2 if network access is disabled.
host_get_config(key_ptr, key_len) -> i32
Read a plugin configuration value. Returns JSON value length on success (in
exchange buffer), -1 if key not found.
host_get_buffer(dest_ptr, dest_len) -> i32
Copy the exchange buffer into WASM memory. Returns bytes copied. Use this after
host_read_file, host_http_request, or host_get_config to retrieve data.
Security Model
Capabilities
Every plugin declares what it needs in plugin.toml. At load time, the
CapabilityEnforcer validates these against the system policy:
-
Filesystem: Read and write paths must be within allowed directories. Requests for paths outside the allowlist are rejected at load time (the plugin won't load) and at runtime (the host function returns
-2). -
Network: Must be explicitly enabled. Even when enabled, each
host_http_requestcall checks the flag. Domain whitelisting is supported in the capability model but not yet enforced at the host function level. -
Resources: Maximum memory (default 512MB) and CPU time (default 60s) are enforced via wasmtime's fuel metering system. Each function invocation gets a fresh
Storewith a fuel budget proportional to the configured CPU time limit.
Isolation
-
Each
call_functioninvocation creates a fresh wasmtimeStore, so plugins cannot retain state between calls via the WASM runtime (they can use the filesystem or exchange buffer for persistence). -
WASM's linear memory model prevents plugins from accessing host memory.
-
The wasmtime stack is limited to 1MB.
Unsigned Plugins
The allow_unsigned configuration option (default true in development)
permits loading plugins without signature verification. For production
deployments, set this to false and sign your plugins. Note: signature
verification is defined in the configuration but not yet implemented in the
loader. This is a known gap.
Plugin API Endpoints
All plugin endpoints require Admin role.
| Method | Path | Description |
|---|---|---|
GET |
/api/v1/plugins |
List all plugins |
GET |
/api/v1/plugins/:id |
Get plugin details |
POST |
/api/v1/plugins/install |
Install from path or URL |
DELETE |
/api/v1/plugins/:id |
Uninstall a plugin |
POST |
/api/v1/plugins/:id/enable |
Enable a disabled plugin |
POST |
/api/v1/plugins/:id/disable |
Disable a plugin |
POST |
/api/v1/plugins/:id/reload |
Hot-reload a plugin |
Plugin Lifecycle
-
Discovery: On startup, the plugin manager walks configured plugin directories looking for
plugin.tomlfiles. -
Validation: The manifest is parsed, and capabilities are checked against the security policy. If validation fails, the plugin is skipped with a warning.
-
Loading: The WASM binary is compiled via wasmtime. The
initializeexport is called if present. -
Registration: The plugin is added to the in-memory registry and the database.
-
Invocation: When the system needs plugin functionality (e.g., extracting metadata from an unknown file type), it calls the appropriate exported function via
WasmPlugin::call_function(). -
Shutdown: On server shutdown or plugin disable, the
shutdownexport is called if present. -
Hot-reload: The
/plugins/:id/reloadendpoint reloads the WASM binary from disk without restarting the server.
Configuration
In pinakes.toml:
[plugins]
enabled = true
plugin_dirs = ["/etc/pinakes/plugins", "~/.local/share/pinakes/plugins"]
allow_unsigned = false
[plugins.security]
allow_network_default = false
allowed_read_paths = ["/media", "/tmp/pinakes"]
allowed_write_paths = ["/tmp/pinakes/plugin-data"]
Known Limitations
-
Plugin signing: The
allow_unsignedflag is parsed but signature verification is not implemented. All plugins load regardless of this setting. -
Dependency resolution: The manifest supports a
dependenciesfield, but dependency ordering and conflict detection are not implemented. -
Domain whitelisting:
allowed_domainsis part of the capability model buthost_http_requestdoes not check it. -
Environment variables: The
EnvironmentCapabilityis defined in the API but no corresponding host function exists. -
Memory limits: CPU time is enforced via fuel metering. Linear memory limits depend on the WASM module's own declarations; the host does not currently impose an explicit cap beyond wasmtime's defaults.