diff --git a/docs/plugins.md b/docs/plugins.md new file mode 100644 index 0000000..aaecd2b --- /dev/null +++ b/docs/plugins.md @@ -0,0 +1,296 @@ +# 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: + +```plaintext +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`: + +```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 + +```bash +cargo new --lib my-plugin +``` + +In `Cargo.toml`: + +```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 + +```rust +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 + +```bash +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 + +```toml +[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: + +```bash +# 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_request` call 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 `Store` with a fuel budget proportional to the configured CPU time + limit. + +### Isolation + +- Each `call_function` invocation creates a fresh wasmtime `Store`, 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 + +1. **Discovery**: On startup, the plugin manager walks configured plugin + directories looking for `plugin.toml` files. + +2. **Validation**: The manifest is parsed, and capabilities are checked against + the security policy. If validation fails, the plugin is skipped with a + warning. + +3. **Loading**: The WASM binary is compiled via wasmtime. The `initialize` + export is called if present. + +4. **Registration**: The plugin is added to the in-memory registry and the + database. + +5. **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()`. + +6. **Shutdown**: On server shutdown or plugin disable, the `shutdown` export is + called if present. + +7. **Hot-reload**: The `/plugins/:id/reload` endpoint reloads the WASM binary + from disk without restarting the server. + +## Configuration + +In `pinakes.toml`: + +```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_unsigned` flag is parsed but signature + verification is not implemented. All plugins load regardless of this setting. + +- **Dependency resolution**: The manifest supports a `dependencies` field, but + dependency ordering and conflict detection are not implemented. + +- **Domain whitelisting**: `allowed_domains` is part of the capability model but + `host_http_request` does not check it. + +- **Environment variables**: The `EnvironmentCapability` is 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.