feat(plugins): implement plugin specific storage (#5839)

* implement storage api/hooks

* fine. if the error messages are different, just match error

* rename storageMount

* add independent plugin test

* round 2

* .-.

* one more copypasta fail

* docs(plugins): document the Storage host service

The README is the plugin author reference and every other host service has a
section there, but Storage had none, so the /storage guest path contract only
existed in code.

Documents the mount point and its backing directory, the manifest permission,
the host function, and the two behaviours an author would otherwise discover
the hard way: there is no size limit, and the directory outlives an uninstall.

* docs(plugins): correct the library filesystem security notes

The README stated in three places that library filesystem access is read-only,
which stopped being true when AllowWriteAccess was added: an administrator can
grant a plugin write access to libraries.

Corrects those and gives the library and storage mounts the same wording for
what the sandbox guarantees, since both now go through the same jail.

* docs(plugins): describe symlink handling in mounts accurately

The security notes claimed paths resolving outside a mount are rejected, which
overstates the jail. Only lexical escapes are: '..' and absolute paths. Creating
symlinks is denied, but symlinks already present are followed and do reach
outside the mount, which is what lets music libraries link folders in from
elsewhere. Both behaviours are pinned by tests in the plugins package.

---------

Co-authored-by: Deluan Quintão <deluan@navidrome.org>
This commit is contained in:
Kendall Garner 2026-08-03 11:23:42 -07:00 committed by GitHub
parent 77726af59c
commit 54fe6c254e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 709 additions and 9 deletions

View File

@ -30,6 +30,7 @@ The plugin system is built on **[Extism](https://extism.org/)**, a cross-languag
- [Scheduler](#scheduler)
- [Cache](#cache)
- [KVStore](#kvstore)
- [Storage](#storage)
- [Task](#task)
- [WebSocket](#websocket)
- [Library](#library)
@ -548,6 +549,52 @@ usage, err := host.KVStoreGetStorageUsed()
fmt.Printf("Using %d bytes\n", usage)
```
### Storage
A private read-write directory, mounted into the sandbox at `/storage` and backed by `${DataFolder}/plugins/${pluginID}/storage`. Survives server restarts. Use it for data that doesn't fit a key-value store: caches, downloaded files, generated indexes.
**Manifest permission:**
```json
{
"permissions": {
"storage": {
"reason": "Cache generated playlists between restarts"
}
}
}
```
**Host functions:**
| Function | Parameters | Description |
|--------------------------|------------|------------------------------------|
| `storage_getstoragepath` | | Get the guest path of the mount |
**Usage:**
Normal WASI filesystem calls work inside the mount, so use the `os` package directly:
```go
import (
"os"
"path/filepath"
"github.com/navidrome/navidrome/plugins/pdk/go/host"
)
// The path never changes, so read it once instead of per operation
var storageDir = host.StorageGetStoragePath() // "/storage"
err := os.WriteFile(filepath.Join(storageDir, "cache.json"), data, 0600)
content, err := os.ReadFile(filepath.Join(storageDir, "cache.json"))
entries, err := os.ReadDir(storageDir)
```
> **Security:** Plugins cannot create symlinks inside the mount, and `..` or absolute paths are rejected. Symlinks that already exist in the directory are still followed, so anything linked in from elsewhere remains reachable.
> **Note:** There is no size limit, unlike [KVStore](#kvstore). The directory is not deleted when a plugin is uninstalled.
### Task
Background task queue with retry support. Plugins enqueue tasks and process them by exporting the [`nd_task_execute`](#taskworker) capability function.
@ -644,7 +691,7 @@ Access music library metadata and optionally read files from library directories
}
```
- `filesystem` Set to `true` to enable read-only access to library directories (default: `false`)
- `filesystem` Set to `true` to enable access to library directories, read-only unless an administrator grants write access (default: `false`)
**Host functions:**
@ -683,7 +730,7 @@ content, err := os.ReadFile("/libraries/1/Artist/Album/track.mp3")
entries, err := os.ReadDir("/libraries/1/Artist")
```
> **Security:** Filesystem access is read-only and restricted to configured library paths only.
> **Security:** Plugins cannot create symlinks inside the mount, and `..` or absolute paths are rejected. Symlinks already present in the library are still followed, so folders linked in from elsewhere work as expected. Access is read-only unless an administrator grants the plugin write access (`navidrome plugin edit <name> --write-access`).
**Usage:**
@ -1058,7 +1105,7 @@ See [examples/](examples/) for complete working plugins:
Plugins run in a secure WebAssembly sandbox provided by [Extism](https://extism.org/) and the [Wazero](https://wazero.io/) runtime:
1. **Host Allowlisting** Only explicitly allowed hosts are accessible via HTTP/WebSocket
2. **Limited File System** Read-only access to library directories, only when explicitly granted the `library.filesystem` permission
2. **Limited File System** Plugins cannot create symlinks inside a mount and `..` or absolute paths are rejected, though symlinks already present are followed. Library access requires the `library.filesystem` permission and is read-only unless an administrator grants write access; the `storage` permission grants a read-write directory private to the plugin
3. **No Network Listeners** Plugins cannot bind ports
4. **Config Isolation** Plugins only receive their own config section
5. **Memory Limits** Controlled by the WebAssembly runtime

13
plugins/host/storage.go Normal file
View File

@ -0,0 +1,13 @@
package host
import "context"
// StorageService provides access to a plugin-specific directory with read/write permissions
//
//nd:hostservice name=Storage permission=storage
type StorageService interface {
// GetStoragePath retrieves the persistent storage path, if allowed
//
//nd:hostfunc
GetStoragePath(ctx context.Context) string
}

View File

@ -0,0 +1,67 @@
// Code generated by ndpgen. DO NOT EDIT.
package host
import (
"context"
"encoding/json"
extism "github.com/extism/go-sdk"
)
// StorageGetStoragePathResponse is the response type for Storage.GetStoragePath.
type StorageGetStoragePathResponse struct {
Result string `json:"result,omitempty"`
}
// RegisterStorageHostFunctions registers Storage service host functions.
// The returned host functions should be added to the plugin's configuration.
func RegisterStorageHostFunctions(service StorageService) []extism.HostFunction {
return []extism.HostFunction{
newStorageGetStoragePathHostFunction(service),
}
}
func newStorageGetStoragePathHostFunction(service StorageService) extism.HostFunction {
return extism.NewHostFunctionWithStack(
"storage_getstoragepath",
func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
// Call the service method
result := service.GetStoragePath(ctx)
// Write JSON response to plugin memory
resp := StorageGetStoragePathResponse{
Result: result,
}
storageWriteResponse(p, stack, resp)
},
[]extism.ValueType{extism.ValueTypePTR},
[]extism.ValueType{extism.ValueTypePTR},
)
}
// storageWriteResponse writes a JSON response to plugin memory.
func storageWriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) {
respBytes, err := json.Marshal(resp)
if err != nil {
storageWriteError(p, stack, err)
return
}
respPtr, err := p.WriteBytes(respBytes)
if err != nil {
stack[0] = 0
return
}
stack[0] = respPtr
}
// storageWriteError writes an error response to plugin memory.
func storageWriteError(p *extism.CurrentPlugin, stack []uint64, err error) {
errResp := struct {
Error string `json:"error"`
}{Error: err.Error()}
respBytes, _ := json.Marshal(errResp)
respPtr, _ := p.WriteBytes(respBytes)
stack[0] = respPtr
}

34
plugins/host_storage.go Normal file
View File

@ -0,0 +1,34 @@
package plugins
import (
"context"
"fmt"
"os"
"path/filepath"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/plugins/host"
)
const storageMount = "/storage"
type storageServiceImpl struct{}
func getHostStoragePath(pluginName string) string {
return filepath.Join(conf.Server.DataFolder.String(), "plugins", pluginName, "storage")
}
func newStorageService(pluginName string) (host.StorageService, error) {
dataDir := getHostStoragePath(pluginName)
if err := os.MkdirAll(dataDir, 0700); err != nil {
return nil, fmt.Errorf("creating plugin data directory: %w", err)
}
return &storageServiceImpl{}, nil
}
func (s *storageServiceImpl) GetStoragePath(ctx context.Context) string {
return storageMount
}
var _ host.StorageService = (*storageServiceImpl)(nil)

View File

@ -0,0 +1,270 @@
//go:build !windows
package plugins
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"path/filepath"
extism "github.com/extism/go-sdk"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("utility functions", Ordered, func() {
var tmpDir string
BeforeAll(func() {
var err error
tmpDir, err = os.MkdirTemp("", "storage-test-*")
Expect(err).ToNot(HaveOccurred())
DeferCleanup(configtest.SetupConfig())
conf.Server.DataFolder = conf.NewDir(tmpDir)
DeferCleanup(func() {
_ = os.RemoveAll(tmpDir)
})
})
Describe("GetHostStoragePath", func() {
It("should join data folder, plugins, plugin name, and storage", func() {
actual := getHostStoragePath("plugin-name")
expected := filepath.Join(tmpDir, "plugins", "plugin-name", "storage")
Expect(actual).To(Equal(expected))
})
})
Describe("GetStoragePath", func() {
It("should return the fixed path", func() {
impl := storageServiceImpl{}
Expect(impl.GetStoragePath(context.TODO())).To(Equal("/storage"))
})
})
Describe("netStorageService", func() {
It("should create the directory on init", func() {
svc, err := newStorageService("plugin-name")
Expect(err).ToNot(HaveOccurred())
Expect(svc).ToNot(BeNil())
dataDir := filepath.Join(tmpDir, "plugins", "plugin-name", "storage")
Expect(dataDir).To(BeADirectory())
})
})
})
var _ = Describe("Storage Host Function", Ordered, func() {
const ID = "test-storage-plugin"
var (
manager *Manager
tmpDir string
)
BeforeAll(func() {
var err error
tmpDir, err = os.MkdirTemp("", "storage-test-*")
Expect(err).ToNot(HaveOccurred())
// Setup mock router and data store
router := &fakeSubsonicRouter{}
userRepo := tests.CreateMockUserRepo()
dataStore := &tests.MockDataStore{MockedUser: userRepo}
// Create and configure manager
manager = &Manager{
plugins: make(map[string]*plugin),
ds: dataStore,
}
manager.SetSubsonicRouter(router)
mockPluginRepo := dataStore.Plugin(GinkgoT().Context()).(*tests.MockPluginRepo)
mockPluginRepo.Permitted = true
// Setup config
DeferCleanup(configtest.SetupConfig())
conf.Server.DataFolder = conf.NewDir(tmpDir)
conf.Server.Plugins.Enabled = true
conf.Server.Plugins.Folder = conf.NewDir(tmpDir)
conf.Server.Plugins.AutoReload = false
pluginPaths := []string{ID, ID + "-2"}
plugins := []model.Plugin{}
for idx := range pluginPaths {
path := pluginPaths[idx] + PackageExtension
// Copy test plugin to temp dir
srcPath := filepath.Join(testdataDir, path)
destPath := filepath.Join(tmpDir, path)
data, err := os.ReadFile(srcPath)
Expect(err).ToNot(HaveOccurred())
err = os.WriteFile(destPath, data, 0600)
Expect(err).ToNot(HaveOccurred())
// Pre-enable the plugin in the mock repo so it loads on startup
// Compute SHA256 of the plugin file to match what syncPlugins will compute
pluginPath := filepath.Join(tmpDir, path)
wasmData, err := os.ReadFile(pluginPath)
Expect(err).ToNot(HaveOccurred())
hash := sha256.Sum256(wasmData)
hashHex := hex.EncodeToString(hash[:])
plugins = append(plugins, model.Plugin{
ID: pluginPaths[idx],
Path: pluginPath,
SHA256: hashHex,
Enabled: true,
AllUsers: true, // Allow all users for test plugin
})
}
mockPluginRepo.SetData(plugins)
// Start the manager
err = manager.Start(GinkgoT().Context())
Expect(err).ToNot(HaveOccurred())
DeferCleanup(func() {
_ = manager.Stop()
_ = os.RemoveAll(tmpDir)
})
})
var instance *extism.Plugin
BeforeEach(func() {
var err error
manager.mu.RLock()
plugin := manager.plugins[ID]
manager.mu.RUnlock()
Expect(plugin).ToNot(BeNil())
ctx := GinkgoT().Context()
instance, err = plugin.instance(ctx)
Expect(err).ToNot(HaveOccurred())
DeferCleanup(func() {
instance.Close(ctx)
})
})
Describe("Read", func() {
BeforeAll(func() {
path := filepath.Join(getHostStoragePath(ID), "real")
err := os.WriteFile(path, []byte("1234"), 0600)
Expect(err).ToNot(HaveOccurred())
DeferCleanup(func() {
_ = os.Remove(path)
})
})
It("should fail to read missing file", func() {
exit, _, err := instance.Call("call_read", []byte("missing"))
Expect(exit).To(Equal(uint32(1)))
Expect(err).To(HaveOccurred())
})
It("should read an existing file", func() {
exit, output, err := instance.Call("call_read", []byte("real"))
Expect(exit).To(Equal(uint32(0)))
Expect(err).ToNot(HaveOccurred())
Expect(output).To(Equal([]byte("1234")))
})
It("should not escape read", func() {
path := filepath.Join(getHostStoragePath(ID), "..", "outside")
err := os.WriteFile(path, []byte("outside"), 0600)
Expect(err).ToNot(HaveOccurred())
exit, _, err := instance.Call("call_read", []byte("../outside"))
Expect(exit).To(Equal(uint32(1)))
Expect(err).To(HaveOccurred())
})
})
Describe("Write", func() {
BeforeAll(func() {
path := filepath.Join(getHostStoragePath(ID), "real")
err := os.WriteFile(path, []byte("1234"), 0600)
Expect(err).ToNot(HaveOccurred())
DeferCleanup(func() {
_ = os.Remove(path)
})
})
It("should fail to write to nested file", func() {
exit, _, err := instance.Call("call_write", []byte(`{"path":"nested/file","contents":"1234"}`))
Expect(exit).To(Equal(uint32(1)))
Expect(err).To(HaveOccurred())
})
It("should write to a file", func() {
exit, _, err := instance.Call("call_write", []byte(`{"path":"new","contents":"contents"}`))
Expect(exit).To(Equal(uint32(0)))
Expect(err).ToNot(HaveOccurred())
data, err := os.ReadFile(filepath.Join(getHostStoragePath(ID), "new"))
Expect(err).ToNot(HaveOccurred())
Expect(data).To(Equal([]byte("contents")))
exit, output, err := instance.Call("call_read", []byte("new"))
Expect(exit).To(Equal(uint32(0)))
Expect(err).ToNot(HaveOccurred())
Expect(output).To(Equal([]byte("contents")))
})
It("should not escape writing a file", func() {
path := filepath.Join(getHostStoragePath(ID), "..", "outside")
err := os.WriteFile(path, []byte("outside"), 0600)
Expect(err).ToNot(HaveOccurred())
exit, _, err := instance.Call("call_write", []byte(`{"path":"../new","contents":"contents"}`))
Expect(exit).To(Equal(uint32(1)))
Expect(err).To(HaveOccurred())
})
It("should have independent storage for multiple plugins", func() {
manager.mu.RLock()
plugin2 := manager.plugins[ID+"-2"]
manager.mu.RUnlock()
Expect(plugin2).ToNot(BeNil())
secondInstance, err := plugin2.instance(GinkgoT().Context())
Expect(err).ToNot(HaveOccurred())
defer secondInstance.Close(GinkgoT().Context())
instances := []*extism.Plugin{instance, secondInstance}
names := []string{ID, ID + "-2"}
for idx := range instances {
exit, _, err := instances[idx].Call("call_write", fmt.Appendf(nil, `{"path":"new","contents":"%s"}`, names[idx]))
Expect(exit).To(Equal(uint32(0)))
Expect(err).ToNot(HaveOccurred())
}
for idx := range names {
data, err := os.ReadFile(filepath.Join(getHostStoragePath(names[idx]), "new"))
Expect(err).ToNot(HaveOccurred())
Expect(data).To(Equal([]byte(names[idx])))
}
for idx := range instances {
exit, output, err := instances[idx].Call("call_read", []byte("new"))
Expect(exit).To(Equal(uint32(0)))
Expect(err).ToNot(HaveOccurred())
Expect(output).To(Equal([]byte(names[idx])))
}
})
})
})

View File

@ -166,6 +166,17 @@ var hostServices = []hostServiceEntry{
return host.RegisterTaskHostFunctions(service), service, nil
},
},
{
name: "Storage",
hasPermission: func(p *Permissions) bool { return p != nil && p.Storage != nil },
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) {
service, err := newStorageService(ctx.pluginName)
if err != nil {
return nil, nil, err
}
return host.RegisterStorageHostFunctions(service), nil, nil
},
},
}
// extractManifest reads manifest from an .ndp package and computes its SHA-256 hash.
@ -315,14 +326,29 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
// Configure filesystem access for library permission, applied per instance
var fsConfig wazero.FSConfig
if pkg.Manifest.HasLibraryFilesystemPermission() {
adminCtx := adminContext(ctx)
libraries, err := m.ds.Library(adminCtx).GetAll()
if err != nil {
return fmt.Errorf("failed to get libraries for filesystem access: %w", err)
if pkg.Manifest.HasLibraryFilesystemPermission() || pkg.Manifest.HasStoragePermission() {
mounts := []mount{}
if pkg.Manifest.HasLibraryFilesystemPermission() {
adminCtx := adminContext(ctx)
libraries, err := m.ds.Library(adminCtx).GetAll()
if err != nil {
return fmt.Errorf("failed to get libraries for filesystem access: %w", err)
}
mounts = buildMounts(ctx, libraries, allowedLibraries, p.AllLibraries, p.AllowWriteAccess)
}
fsConfig = buildFSConfig(buildMounts(ctx, libraries, allowedLibraries, p.AllLibraries, p.AllowWriteAccess))
if pkg.Manifest.HasStoragePermission() {
pluginStore := getHostStoragePath(p.ID)
log.Info(ctx, "Granting read-write filesystem access to plugin storage", "path", pluginStore, "id", p.ID)
mounts = append(mounts, mount{
hostPath: pluginStore,
guestPath: storageMount,
})
}
fsConfig = buildFSConfig(mounts)
}
// Build host functions based on permissions from manifest

View File

@ -92,6 +92,9 @@
},
"matcher": {
"$ref": "#/$defs/MatcherPermission"
},
"storage": {
"$ref": "#/$defs/StoragePermission"
}
}
},
@ -244,6 +247,17 @@
"description": "Explanation for why matcher access is needed"
}
}
},
"StoragePermission": {
"type": "object",
"description": "Storage permissions for enabling persistent read-write storage exclusively for the plugin",
"additionalProperties": false,
"properties": {
"reason": {
"type": "string",
"description": "Explanation for why storage access is needed"
}
}
}
}
}

View File

@ -123,3 +123,7 @@ func (m *Manifest) HasLibraryFilesystemPermission() bool {
m.Permissions.Library != nil &&
m.Permissions.Library.Filesystem
}
func (m *Manifest) HasStoragePermission() bool {
return m.Permissions != nil && m.Permissions.Storage != nil
}

View File

@ -178,6 +178,9 @@ type Permissions struct {
// Scheduler corresponds to the JSON schema field "scheduler".
Scheduler *SchedulerPermission `json:"scheduler,omitempty" yaml:"scheduler,omitempty" mapstructure:"scheduler,omitempty"`
// Storage corresponds to the JSON schema field "storage".
Storage *StoragePermission `json:"storage,omitempty" yaml:"storage,omitempty" mapstructure:"storage,omitempty"`
// Subsonicapi corresponds to the JSON schema field "subsonicapi".
Subsonicapi *SubsonicAPIPermission `json:"subsonicapi,omitempty" yaml:"subsonicapi,omitempty" mapstructure:"subsonicapi,omitempty"`
@ -197,6 +200,13 @@ type SchedulerPermission struct {
Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"`
}
// Storage permissions for enabling persistent read-write storage exclusively for
// the plugin
type StoragePermission struct {
// Explanation for why storage access is needed
Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"`
}
// SubsonicAPI service permissions. Requires 'users' permission to be declared.
type SubsonicAPIPermission struct {
// Explanation for why SubsonicAPI access is needed

View File

@ -43,6 +43,7 @@ The following host services are available:
- Library: provides access to music library metadata for plugins.
- Matcher: resolves externally-obtained songs to local library tracks,
- Scheduler: provides task scheduling capabilities for plugins.
- Storage: provides access to a plugin-specific directory with read/write permissions
- SubsonicAPI: provides access to Navidrome's Subsonic API from plugins.
- Task: provides persistent task queues for plugins.
- Users: provides access to user information for plugins.

View File

@ -0,0 +1,46 @@
// Code generated by ndpgen. DO NOT EDIT.
//
// This file contains client wrappers for the Storage host service.
// It is intended for use in Navidrome plugins built with TinyGo.
//
//go:build wasip1
package host
import (
"encoding/json"
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
)
// storage_getstoragepath is the host function provided by Navidrome.
//
//go:wasmimport extism:host/user storage_getstoragepath
func storage_getstoragepath(uint64) uint64
type storageGetStoragePathResponse struct {
Result string `json:"result,omitempty"`
}
// StorageGetStoragePath calls the storage_getstoragepath host function.
// GetStoragePath retrieves the persistent storage path, if allowed
func StorageGetStoragePath() string {
// No parameters - allocate empty JSON object
reqMem := pdk.AllocateBytes([]byte("{}"))
defer reqMem.Free()
// Call the host function
responsePtr := storage_getstoragepath(reqMem.Offset())
// Read the response from memory
responseMem := pdk.FindMemory(responsePtr)
responseBytes := responseMem.ReadBytes()
// Parse the response
var response storageGetStoragePathResponse
if err := json.Unmarshal(responseBytes, &response); err != nil {
return ""
}
return response.Result
}

View File

@ -0,0 +1,34 @@
// Code generated by ndpgen. DO NOT EDIT.
//
// This file contains mock implementations for non-WASM builds.
// These mocks allow IDE support, compilation, and unit testing on non-WASM platforms.
// Plugin authors can use the exported mock instances to set expectations in tests.
//
//go:build !wasip1
package host
import (
"github.com/stretchr/testify/mock"
)
// mockStorageService is the mock implementation for testing.
type mockStorageService struct {
mock.Mock
}
// StorageMock is the auto-instantiated mock instance for testing.
// Use this to set expectations: host.StorageMock.On("MethodName", args...).Return(values...)
var StorageMock = &mockStorageService{}
// GetStoragePath is the mock method for StorageGetStoragePath.
func (m *mockStorageService) GetStoragePath() string {
args := m.Called()
return args.String(0)
}
// StorageGetStoragePath delegates to the mock instance.
// GetStoragePath retrieves the persistent storage path, if allowed
func StorageGetStoragePath() string {
return StorageMock.GetStoragePath()
}

View File

@ -40,6 +40,7 @@
//! - [`library`] - provides access to music library metadata for plugins.
//! - [`matcher`] - resolves externally-obtained songs to local library tracks,
//! - [`scheduler`] - provides task scheduling capabilities for plugins.
//! - [`storage`] - provides access to a plugin-specific directory with read/write permissions
//! - [`subsonicapi`] - provides access to Navidrome's Subsonic API from plugins.
//! - [`task`] - provides persistent task queues for plugins.
//! - [`users`] - provides access to user information for plugins.
@ -101,6 +102,13 @@ pub mod scheduler {
pub use super::nd_host_scheduler::*;
}
#[doc(hidden)]
mod nd_host_storage;
/// provides access to a plugin-specific directory with read/write permissions
pub mod storage {
pub use super::nd_host_storage::*;
}
#[doc(hidden)]
mod nd_host_subsonicapi;
/// provides access to Navidrome's Subsonic API from plugins.

View File

@ -0,0 +1,34 @@
// Code generated by ndpgen. DO NOT EDIT.
//
// This file contains client wrappers for the Storage host service.
// It is intended for use in Navidrome plugins built with extism-pdk.
use extism_pdk::*;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
struct StorageGetStoragePathResponse {
#[serde(default)]
result: String,
}
#[host_fn]
extern "ExtismHost" {
fn storage_getstoragepath(input: Json<serde_json::Value>) -> Json<StorageGetStoragePathResponse>;
}
/// GetStoragePath retrieves the persistent storage path, if allowed
///
/// # Returns
/// The result value.
///
/// # Errors
/// Returns an error if the host function call fails.
pub fn get_storage_path() -> Result<String, Error> {
let response = unsafe {
storage_getstoragepath(Json(serde_json::json!({})))?
};
Ok(response.0.result)
}

1
plugins/testdata/test-storage-plugin-2 vendored Symbolic link
View File

@ -0,0 +1 @@
test-storage-plugin

View File

@ -0,0 +1,16 @@
module test-storage-plugin
go 1.25
require github.com/navidrome/navidrome/plugins/pdk/go v0.0.0
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/extism/go-pdk v1.1.3 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/stretchr/testify v1.11.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
replace github.com/navidrome/navidrome/plugins/pdk/go => ../../pdk/go

View File

@ -0,0 +1,14 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ=
github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@ -0,0 +1,50 @@
// Test plugin for Storage host function integration tests.
// Build with: tinygo build -o ../test-storage-plugin.wasm -target wasip1 -buildmode=c-shared ./main.go
package main
import (
"os"
"path/filepath"
"github.com/navidrome/navidrome/plugins/pdk/go/host"
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
)
//go:wasmexport call_read
func callRead() int32 {
path := filepath.Join(host.StorageGetStoragePath(), pdk.InputString())
content, err := os.ReadFile(path)
if err != nil {
pdk.SetErrorString("failed to read file: " + err.Error())
return 1
}
pdk.Output(content)
return 0
}
type WriteInput struct {
Path string `json:"path"`
Contents string `json:"contents"`
}
//go:wasmexport call_write
func callWrite() int32 {
var config WriteInput
err := pdk.InputJSON(&config)
if err != nil {
pdk.SetErrorString("failed to parse json: " + err.Error())
return 1
}
path := filepath.Join(host.StorageGetStoragePath(), config.Path)
err = os.WriteFile(path, []byte(config.Contents), 0600)
if err != nil {
pdk.SetErrorString("failed to write file: " + err.Error())
return 1
}
return 0
}
func main() {}

View File

@ -0,0 +1,11 @@
{
"name": "Test Storage Plugin",
"author": "Navidrome Test",
"version": "1.0.0",
"description": "Test plugin for storage host function",
"permissions": {
"storage": {
"reason": "persistent storage for testing"
}
}
}