From 279ff98e0d5f0639077f730083b0c3fd498c800e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sun, 2 Aug 2026 19:46:05 -0400 Subject: [PATCH 1/4] fix(ui): stop precaching index.html so logins can't show the wrong user (#5882) * fix(ui): stop precaching index.html so logins can't show the wrong user index.html is rendered per-user by the server: it carries __APP_CONFIG__, including the auth payload (user id, name, role, Subsonic token) when authentication comes from a reverse-proxy header. Workbox precached it, and because precacheAndRoute registers its route before the NetworkOnly NavigationRoute, every navigation to /app/ was answered from Cache Storage with a frozen copy of whoever installed the service worker. With ExtAuth, signing out and back in as a different user therefore kept showing the previous user, along with their Subsonic token. Cache Storage ignores the no-store header serve_index.go already sets, and ignores the browser's "disable cache", so only clearing site data recovered. Excluding index.html from the precache manifest lets the NetworkOnly navigation strategy do the job it was written for. Existing poisoned caches heal themselves: the entry is dropped when the new manifest activates. The same stale document caused the create-admin dialog to reappear (#3613), patched then by calling removeHomeCache() after login. That helper only ran on password login and token refresh, so it never covered the ExtAuth path, and a refetch re-poisoned the cache anyway. Fixing the cause makes it dead code, so it is removed. Also serve the offline page on 5xx: it arrives as a normal response, so the existing catch never saw it, and without a precached shell a restarting server would surface a raw gateway error. The offline copy is reworded to fit both causes. * test(ui): cover the service worker navigation fallback rules The handler lived inside sw.js, which only loads in a service worker where workbox arrives via importScripts, so none of it was reachable from vitest. Moving the decision into its own module pins the rules that matter: a 5xx falls back to the offline page like a thrown network error does, while 4xx and 304 still pass through to the app. --- .gitignore | 1 + ui/public/offline.html | 2 +- ui/src/authProvider.js | 2 - ui/src/dataProvider/httpClient.js | 2 - ui/src/sw.js | 20 +++++----- ui/src/swNavigation.js | 14 +++++++ ui/src/swNavigation.test.js | 62 +++++++++++++++++++++++++++++++ ui/src/utils/removeHomeCache.js | 20 ---------- ui/vite.config.js | 3 ++ 9 files changed, 90 insertions(+), 36 deletions(-) create mode 100644 ui/src/swNavigation.js create mode 100644 ui/src/swNavigation.test.js delete mode 100644 ui/src/utils/removeHomeCache.js diff --git a/.gitignore b/.gitignore index fc8eaac69..3567a7d90 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,4 @@ openspec/ .agents go.work* .worktrees/ +.playwright-mcp/ diff --git a/ui/public/offline.html b/ui/public/offline.html index 72f2d04ae..e3c44e8d3 100644 --- a/ui/public/offline.html +++ b/ui/public/offline.html @@ -5,6 +5,6 @@

It looks like we are having trouble connecting.
-Please check your internet connection and try again.

+Please check your internet connection, or try again in a moment.

\ No newline at end of file diff --git a/ui/src/authProvider.js b/ui/src/authProvider.js index 813a4f5b4..18badbc9c 100644 --- a/ui/src/authProvider.js +++ b/ui/src/authProvider.js @@ -1,7 +1,6 @@ import { jwtDecode } from 'jwt-decode' import { baseUrl } from './utils' import config from './config' -import { removeHomeCache } from './utils/removeHomeCache' // config sent from server may contain authentication info, for example when the user is authenticated // by a reverse proxy request header @@ -49,7 +48,6 @@ const authProvider = { storeAuthenticationInfo(response) // Avoid "going to create admin" dialog after logout/login without a refresh config.firstTime = false - removeHomeCache() return response }) .catch((error) => { diff --git a/ui/src/dataProvider/httpClient.js b/ui/src/dataProvider/httpClient.js index 3820f658d..706486fa6 100644 --- a/ui/src/dataProvider/httpClient.js +++ b/ui/src/dataProvider/httpClient.js @@ -3,7 +3,6 @@ import { v4 as uuidv4 } from 'uuid' import { baseUrl } from '../utils' import config from '../config' import { jwtDecode } from 'jwt-decode' -import { removeHomeCache } from '../utils/removeHomeCache' const customAuthorizationHeader = 'X-ND-Authorization' export const clientUniqueIdHeader = 'X-ND-Client-Unique-Id' @@ -27,7 +26,6 @@ const httpClient = (url, options = {}) => { localStorage.setItem('userId', decoded.uid) // Avoid going to create admin dialog after logout/login without a refresh config.firstTime = false - removeHomeCache() } return response }) diff --git a/ui/src/sw.js b/ui/src/sw.js index f4a5664b8..edc272850 100644 --- a/ui/src/sw.js +++ b/ui/src/sw.js @@ -1,5 +1,7 @@ /* eslint-disable */ +import { createNavigationHandler } from './swNavigation' + // documentation: https://developers.google.com/web/tools/workbox/modules/workbox-sw importScripts('3rdparty/workbox/workbox-sw.js') @@ -36,17 +38,13 @@ self.addEventListener('install', async (event) => { }) const networkOnly = new workbox.strategies.NetworkOnly() -const navigationHandler = async (params) => { - try { - // Attempt a network request. - return await networkOnly.handle(params) - } catch (error) { - // If it fails, return the cached HTML. - return caches.match(FALLBACK_HTML_URL, { - cacheName: CACHE_NAME, - }) - } -} +const fallbackToCachedHtml = () => + caches.match(FALLBACK_HTML_URL, { cacheName: CACHE_NAME }) + +const navigationHandler = createNavigationHandler( + networkOnly, + fallbackToCachedHtml, +) // self.__WB_MANIFEST is default injection point workbox.precaching.precacheAndRoute(self.__WB_MANIFEST) diff --git a/ui/src/swNavigation.js b/ui/src/swNavigation.js new file mode 100644 index 000000000..28d01eaef --- /dev/null +++ b/ui/src/swNavigation.js @@ -0,0 +1,14 @@ +// Lives outside sw.js so it can be unit tested: sw.js only loads inside a +// service worker, where workbox arrives via importScripts. +export const createNavigationHandler = + (networkOnly, offlineFallback) => async (params) => { + try { + // Attempt a network request. + const response = await networkOnly.handle(params) + // A 5xx reaches us as a normal response, but carries no usable app + return response.status >= 500 ? offlineFallback() : response + } catch (error) { + // If it fails, return the cached HTML. + return offlineFallback() + } + } diff --git a/ui/src/swNavigation.test.js b/ui/src/swNavigation.test.js new file mode 100644 index 000000000..8ab749757 --- /dev/null +++ b/ui/src/swNavigation.test.js @@ -0,0 +1,62 @@ +import { describe, it, expect, vi } from 'vitest' +import { createNavigationHandler } from './swNavigation' + +const OFFLINE = { offlinePage: true } + +const setup = (networkResult) => { + const networkOnly = { + handle: vi.fn(() => + networkResult instanceof Error + ? Promise.reject(networkResult) + : Promise.resolve(networkResult), + ), + } + const offlineFallback = vi.fn(() => Promise.resolve(OFFLINE)) + return { + networkOnly, + offlineFallback, + handler: createNavigationHandler(networkOnly, offlineFallback), + } +} + +describe('createNavigationHandler', () => { + it('serves the response from the network', async () => { + const response = { status: 200 } + const { handler, offlineFallback } = setup(response) + + await expect(handler({})).resolves.toBe(response) + expect(offlineFallback).not.toHaveBeenCalled() + }) + + it('falls back to the offline page when the network fails', async () => { + const { handler } = setup(new Error('Failed to fetch')) + + await expect(handler({})).resolves.toBe(OFFLINE) + }) + + it.each([500, 502, 503])( + 'falls back to the offline page on %i', + async (status) => { + const { handler } = setup({ status }) + + await expect(handler({})).resolves.toBe(OFFLINE) + }, + ) + + it.each([304, 401, 404])('passes %i through untouched', async (status) => { + const response = { status } + const { handler, offlineFallback } = setup(response) + + await expect(handler({})).resolves.toBe(response) + expect(offlineFallback).not.toHaveBeenCalled() + }) + + it('passes the navigation params to the network strategy', async () => { + const params = { request: { url: '/app/' } } + const { handler, networkOnly } = setup({ status: 200 }) + + await handler(params) + + expect(networkOnly.handle).toHaveBeenCalledWith(params) + }) +}) diff --git a/ui/src/utils/removeHomeCache.js b/ui/src/utils/removeHomeCache.js deleted file mode 100644 index 08ed720e0..000000000 --- a/ui/src/utils/removeHomeCache.js +++ /dev/null @@ -1,20 +0,0 @@ -export const removeHomeCache = async () => { - try { - const workboxKey = (await caches.keys()).find((key) => - key.startsWith('workbox-precache'), - ) - if (!workboxKey) return - - const workboxCache = await caches.open(workboxKey) - const indexKey = (await workboxCache.keys()).find((key) => - key.url.includes('app/index.html'), - ) - - if (indexKey) { - await workboxCache.delete(indexKey) - } - } catch (e) { - // eslint-disable-next-line no-console - console.error('error reading cache', e) - } -} diff --git a/ui/vite.config.js b/ui/vite.config.js index 9d9c845f1..182e825b0 100644 --- a/ui/vite.config.js +++ b/ui/vite.config.js @@ -16,6 +16,9 @@ export default defineConfig({ filename: 'sw.js', injectManifest: { maximumFileSizeToCacheInBytes: 3 * 1024 * 1024, // 3 MiB + // index.html is rendered per-user by the server, so a precached copy + // would pin one user's config (and auth payload) across logins + globIgnores: ['index.html'], }, devOptions: { enabled: true, From 77726af59ca5808dae08681233b7df98ec6cc7a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Mon, 3 Aug 2026 13:09:53 -0400 Subject: [PATCH 2/4] fix(plugins): reject plugin IDs that are unusable as directory names (#5886) The plugin ID is derived from the package filename and used verbatim as a directory name under DataFolder/plugins by the kvstore and taskqueue host services. A package installed as '..ndp' yields the ID '.', whose data directory resolves to the parent of every other plugin's directory, so it overlaps their private data. On Windows, trailing dots and spaces are dropped during path normalization, so 'foo..ndp' and 'foo.ndp' yield distinct IDs that resolve to the same directory and would share the same SQLite files. Discovery and the file watcher now derive the ID through pluginIDFromPath, which rejects '.', '..', empty names, separators, trailing dots or spaces, and anything filepath.IsLocal refuses (Windows reserved names, drive-relative paths). The loader repeats the check, since a sync failure is non-fatal and could otherwise leave a stale row reaching the host services. --- plugins/manager_loader.go | 4 ++++ plugins/manager_loader_test.go | 13 +++++++++++ plugins/manager_sync.go | 6 ++++- plugins/manager_sync_test.go | 40 ++++++++++++++++++++++++++++++++++ plugins/manager_watcher.go | 6 ++++- plugins/package.go | 22 +++++++++++++++++++ plugins/package_test.go | 30 +++++++++++++++++++++++++ 7 files changed, 119 insertions(+), 2 deletions(-) diff --git a/plugins/manager_loader.go b/plugins/manager_loader.go index fc9296427..5238a4755 100644 --- a/plugins/manager_loader.go +++ b/plugins/manager_loader.go @@ -271,6 +271,10 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error { return fmt.Errorf("manager is stopped") } + if !validPluginID(p.ID) { + return fmt.Errorf("invalid plugin ID %q", p.ID) + } + // Track this operation m.loadWg.Add(1) defer m.loadWg.Done() diff --git a/plugins/manager_loader_test.go b/plugins/manager_loader_test.go index 5e6851481..6326b2218 100644 --- a/plugins/manager_loader_test.go +++ b/plugins/manager_loader_test.go @@ -1,6 +1,7 @@ package plugins import ( + "github.com/navidrome/navidrome/model" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -27,6 +28,18 @@ var _ = Describe("buildExtismManifest", func() { }) }) +var _ = Describe("loadPluginWithConfig", func() { + // Discovery already rejects these, but a row predating that check, or one + // left behind by a failed sync, must not reach the mount setup + It("refuses a plugin whose ID is not usable as a directory name", func() { + m := &Manager{plugins: make(map[string]*plugin)} + + err := m.loadPluginWithConfig(&model.Plugin{ID: "..", Path: "/does/not/matter.ndp"}) + + Expect(err).To(MatchError(ContainSubstring("invalid plugin ID"))) + }) +}) + var _ = Describe("parsePluginConfig", func() { It("returns nil for empty string", func() { result, err := parsePluginConfig("") diff --git a/plugins/manager_sync.go b/plugins/manager_sync.go index 2119f1a5a..480fa1bb9 100644 --- a/plugins/manager_sync.go +++ b/plugins/manager_sync.go @@ -152,7 +152,11 @@ func (m *Manager) syncPlugins(ctx context.Context, folder string) error { log.Trace(ctx, "Skipping non-plugin entry", "name", entry.Name(), "isDir", entry.IsDir()) continue } - name := strings.TrimSuffix(entry.Name(), PackageExtension) + name, ok := pluginIDFromPath(entry.Name()) + if !ok { + log.Warn(ctx, "Skipping plugin with unusable name", "name", entry.Name()) + continue + } filesOnDisk[name] = filepath.Join(folder, entry.Name()) } log.Debug(ctx, "Plugin sync: scanned folder", "folder", folder, "entriesTotal", len(entries), "pluginsFound", len(filesOnDisk)) diff --git a/plugins/manager_sync_test.go b/plugins/manager_sync_test.go index 26da2079b..dd64dcd3f 100644 --- a/plugins/manager_sync_test.go +++ b/plugins/manager_sync_test.go @@ -12,6 +12,46 @@ import ( . "github.com/onsi/gomega" ) +var _ = Describe("syncPlugins", func() { + var m *Manager + var repo *tests.MockPluginRepo + var folder string + + BeforeEach(func() { + folder = GinkgoT().TempDir() + repo = tests.CreateMockPluginRepo() + repo.SetData(model.Plugins{}) + m = &Manager{ds: &tests.MockDataStore{MockedPlugin: repo}} + }) + + writePackage := func(name string) { + GinkgoHelper() + manifest := &Manifest{Name: "Test Plugin", Author: "Test Author", Version: "1.0.0"} + wasm := []byte{0x00, 0x61, 0x73, 0x6d} // Minimal wasm header + Expect(createTestPackage(filepath.Join(folder, name), manifest, wasm)).To(Succeed()) + } + + It("registers a plugin with a usable ID", func() { + writePackage("my-plugin" + PackageExtension) + + Expect(m.syncPlugins(context.Background(), folder)).To(Succeed()) + + _, err := repo.Get("my-plugin") + Expect(err).ToNot(HaveOccurred()) + }) + + It("skips packages whose name yields a path-like ID", func() { + writePackage("." + PackageExtension) + writePackage(".." + PackageExtension) + + Expect(m.syncPlugins(context.Background(), folder)).To(Succeed()) + + all, err := repo.GetAll() + Expect(err).ToNot(HaveOccurred()) + Expect(all).To(BeEmpty()) + }) +}) + var _ = Describe("removePluginFromDB", func() { It("discards buffered scrobbles for the removed plugin", func() { ctx := context.Background() diff --git a/plugins/manager_watcher.go b/plugins/manager_watcher.go index d666d8620..f7f658be9 100644 --- a/plugins/manager_watcher.go +++ b/plugins/manager_watcher.go @@ -89,7 +89,11 @@ func (m *Manager) handleWatcherEvent(event notify.EventInfo) { return } - pluginName := strings.TrimSuffix(filepath.Base(path), PackageExtension) + pluginName, ok := pluginIDFromPath(path) + if !ok { + log.Warn(m.ctx, "Ignoring plugin file with unusable name", "path", path) + return + } log.Trace(m.ctx, "Plugin file event", "plugin", pluginName, "event", event.Event(), "path", path) diff --git a/plugins/package.go b/plugins/package.go index e0405d4d8..77cb6e5ac 100644 --- a/plugins/package.go +++ b/plugins/package.go @@ -5,6 +5,8 @@ import ( "errors" "fmt" "io" + "path/filepath" + "strings" ) const ( @@ -18,6 +20,26 @@ const ( wasmFileName = "plugin.wasm" ) +// validPluginID reports whether id is usable. It names a directory under +// DataFolder/plugins, so a path-like one would point at another plugin's data. +func validPluginID(id string) bool { + if id == "." || id == ".." || strings.ContainsAny(id, `/\`) || !filepath.IsLocal(id) { + return false + } + // Windows drops trailing dots and spaces, so "foo." and "foo" would end up + // sharing a directory + return strings.TrimRight(id, ". ") == id +} + +// pluginIDFromPath derives the plugin ID from a package path. +func pluginIDFromPath(path string) (string, bool) { + id := strings.TrimSuffix(filepath.Base(path), PackageExtension) + if !validPluginID(id) { + return "", false + } + return id, true +} + // ndpPackage represents a loaded .ndp plugin package. // It contains the manifest and wasm bytes read from the archive. type ndpPackage struct { diff --git a/plugins/package_test.go b/plugins/package_test.go index 4a37f4352..953797750 100644 --- a/plugins/package_test.go +++ b/plugins/package_test.go @@ -11,6 +11,36 @@ import ( . "github.com/onsi/gomega" ) +var _ = Describe("pluginIDFromPath", func() { + DescribeTable("derives the ID from the package filename", + func(path, expected string) { + id, ok := pluginIDFromPath(path) + Expect(ok).To(BeTrue()) + Expect(id).To(Equal(expected)) + }, + Entry("plain name", "/plugins/discord-rich-presence.ndp", "discord-rich-presence"), + Entry("name with spaces", "/plugins/My Plugin.ndp", "My Plugin"), + Entry("leading dot", "/plugins/.hidden.ndp", ".hidden"), + Entry("dots inside", "/plugins/v1.2.3.ndp", "v1.2.3"), + ) + + // The ID becomes a directory name under DataFolder/plugins, so a path-like + // one would let a plugin reach another plugin's data + DescribeTable("rejects IDs that are unsafe as a directory name", + func(path string) { + _, ok := pluginIDFromPath(path) + Expect(ok).To(BeFalse()) + }, + Entry("empty", "/plugins/.ndp"), + Entry("current directory", "/plugins/..ndp"), + Entry("parent directory", "/plugins/...ndp"), + // Windows drops trailing dots and spaces, so these would share a + // directory with "foo" + Entry("trailing dot", "/plugins/foo..ndp"), + Entry("trailing space", "/plugins/foo .ndp"), + ) +}) + var _ = Describe("ndpPackage", func() { var tmpDir string From 54fe6c254e5b9eedf105660fe1ca03ee271c24ca Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Mon, 3 Aug 2026 11:23:42 -0700 Subject: [PATCH 3/4] feat(plugins): implement plugin specific storage (#5839) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- plugins/README.md | 53 +++- plugins/host/storage.go | 13 + plugins/host/storage_gen.go | 67 +++++ plugins/host_storage.go | 34 +++ plugins/host_storage_test.go | 270 ++++++++++++++++++ plugins/manager_loader.go | 38 ++- plugins/manifest-schema.json | 14 + plugins/manifest.go | 4 + plugins/manifest_gen.go | 10 + plugins/pdk/go/host/doc.go | 1 + plugins/pdk/go/host/nd_host_storage.go | 46 +++ plugins/pdk/go/host/nd_host_storage_stub.go | 34 +++ plugins/pdk/rust/nd-pdk-host/src/lib.rs | 8 + .../rust/nd-pdk-host/src/nd_host_storage.rs | 34 +++ plugins/testdata/test-storage-plugin-2 | 1 + plugins/testdata/test-storage-plugin/go.mod | 16 ++ plugins/testdata/test-storage-plugin/go.sum | 14 + plugins/testdata/test-storage-plugin/main.go | 50 ++++ .../test-storage-plugin/manifest.json | 11 + 19 files changed, 709 insertions(+), 9 deletions(-) create mode 100644 plugins/host/storage.go create mode 100644 plugins/host/storage_gen.go create mode 100644 plugins/host_storage.go create mode 100644 plugins/host_storage_test.go create mode 100644 plugins/pdk/go/host/nd_host_storage.go create mode 100644 plugins/pdk/go/host/nd_host_storage_stub.go create mode 100644 plugins/pdk/rust/nd-pdk-host/src/nd_host_storage.rs create mode 120000 plugins/testdata/test-storage-plugin-2 create mode 100644 plugins/testdata/test-storage-plugin/go.mod create mode 100644 plugins/testdata/test-storage-plugin/go.sum create mode 100644 plugins/testdata/test-storage-plugin/main.go create mode 100644 plugins/testdata/test-storage-plugin/manifest.json diff --git a/plugins/README.md b/plugins/README.md index 08ab967f3..a2f9532ca 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -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 --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 diff --git a/plugins/host/storage.go b/plugins/host/storage.go new file mode 100644 index 000000000..ed24d7508 --- /dev/null +++ b/plugins/host/storage.go @@ -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 +} diff --git a/plugins/host/storage_gen.go b/plugins/host/storage_gen.go new file mode 100644 index 000000000..d705f0831 --- /dev/null +++ b/plugins/host/storage_gen.go @@ -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 +} diff --git a/plugins/host_storage.go b/plugins/host_storage.go new file mode 100644 index 000000000..e2ead7aae --- /dev/null +++ b/plugins/host_storage.go @@ -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) diff --git a/plugins/host_storage_test.go b/plugins/host_storage_test.go new file mode 100644 index 000000000..d6f895bff --- /dev/null +++ b/plugins/host_storage_test.go @@ -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]))) + } + }) + }) +}) diff --git a/plugins/manager_loader.go b/plugins/manager_loader.go index 5238a4755..0e9e7bb62 100644 --- a/plugins/manager_loader.go +++ b/plugins/manager_loader.go @@ -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 diff --git a/plugins/manifest-schema.json b/plugins/manifest-schema.json index 28adeed79..736677313 100644 --- a/plugins/manifest-schema.json +++ b/plugins/manifest-schema.json @@ -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" + } + } } } } diff --git a/plugins/manifest.go b/plugins/manifest.go index 5e144b5c8..ef70e8d9d 100644 --- a/plugins/manifest.go +++ b/plugins/manifest.go @@ -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 +} diff --git a/plugins/manifest_gen.go b/plugins/manifest_gen.go index c2aa5e298..5e0035bd5 100644 --- a/plugins/manifest_gen.go +++ b/plugins/manifest_gen.go @@ -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 diff --git a/plugins/pdk/go/host/doc.go b/plugins/pdk/go/host/doc.go index ff2c2a07f..e4157e29c 100644 --- a/plugins/pdk/go/host/doc.go +++ b/plugins/pdk/go/host/doc.go @@ -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. diff --git a/plugins/pdk/go/host/nd_host_storage.go b/plugins/pdk/go/host/nd_host_storage.go new file mode 100644 index 000000000..4f37bc8e2 --- /dev/null +++ b/plugins/pdk/go/host/nd_host_storage.go @@ -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 +} diff --git a/plugins/pdk/go/host/nd_host_storage_stub.go b/plugins/pdk/go/host/nd_host_storage_stub.go new file mode 100644 index 000000000..c2213ece9 --- /dev/null +++ b/plugins/pdk/go/host/nd_host_storage_stub.go @@ -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() +} diff --git a/plugins/pdk/rust/nd-pdk-host/src/lib.rs b/plugins/pdk/rust/nd-pdk-host/src/lib.rs index cc1fdc190..3da023f5c 100644 --- a/plugins/pdk/rust/nd-pdk-host/src/lib.rs +++ b/plugins/pdk/rust/nd-pdk-host/src/lib.rs @@ -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. diff --git a/plugins/pdk/rust/nd-pdk-host/src/nd_host_storage.rs b/plugins/pdk/rust/nd-pdk-host/src/nd_host_storage.rs new file mode 100644 index 000000000..336ae565f --- /dev/null +++ b/plugins/pdk/rust/nd-pdk-host/src/nd_host_storage.rs @@ -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) -> Json; +} + +/// 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 { + let response = unsafe { + storage_getstoragepath(Json(serde_json::json!({})))? + }; + + Ok(response.0.result) +} diff --git a/plugins/testdata/test-storage-plugin-2 b/plugins/testdata/test-storage-plugin-2 new file mode 120000 index 000000000..444bafd87 --- /dev/null +++ b/plugins/testdata/test-storage-plugin-2 @@ -0,0 +1 @@ +test-storage-plugin \ No newline at end of file diff --git a/plugins/testdata/test-storage-plugin/go.mod b/plugins/testdata/test-storage-plugin/go.mod new file mode 100644 index 000000000..7b9a2b567 --- /dev/null +++ b/plugins/testdata/test-storage-plugin/go.mod @@ -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 diff --git a/plugins/testdata/test-storage-plugin/go.sum b/plugins/testdata/test-storage-plugin/go.sum new file mode 100644 index 000000000..af880eb51 --- /dev/null +++ b/plugins/testdata/test-storage-plugin/go.sum @@ -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= diff --git a/plugins/testdata/test-storage-plugin/main.go b/plugins/testdata/test-storage-plugin/main.go new file mode 100644 index 000000000..17ffc8e6b --- /dev/null +++ b/plugins/testdata/test-storage-plugin/main.go @@ -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() {} diff --git a/plugins/testdata/test-storage-plugin/manifest.json b/plugins/testdata/test-storage-plugin/manifest.json new file mode 100644 index 000000000..98ac24a47 --- /dev/null +++ b/plugins/testdata/test-storage-plugin/manifest.json @@ -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" + } + } +} From e1b89050df7cb228f04d5870be03af3c985fc3bc Mon Sep 17 00:00:00 2001 From: Deluan Date: Mon, 3 Aug 2026 14:34:53 -0400 Subject: [PATCH 4/4] chore: exclude .worktrees from golangci-lint checks --- .golangci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.golangci.yml b/.golangci.yml index 200fe122f..0804138dc 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -69,6 +69,7 @@ linters: - examples$ - node_modules - _gen\.go$ + - .worktrees formatters: exclusions: generated: lax