navidrome/plugins/manager_sync_test.go
Deluan Quintão 77726af59c
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.
2026-08-03 13:09:53 -04:00

125 lines
4 KiB
Go

package plugins
import (
"context"
"path/filepath"
"time"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "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()
buffer := tests.CreateMockedScrobbleBufferRepo()
Expect(buffer.Enqueue("my-plugin", "user1", "track1", time.Now())).To(Succeed())
Expect(buffer.Enqueue("other-plugin", "user1", "track2", time.Now())).To(Succeed())
repo := tests.CreateMockPluginRepo()
plugin := model.Plugin{ID: "my-plugin", Enabled: false}
repo.SetData(model.Plugins{plugin})
// No broker: sendPluginRefreshEvent is nil-safe, and testBroker is
// defined in manager_test.go, which is excluded on Windows.
m := &Manager{
ds: &tests.MockDataStore{MockedScrobbleBuffer: buffer},
}
Expect(m.removePluginFromDB(ctx, repo, &plugin)).To(Succeed())
_, err := repo.Get("my-plugin")
Expect(err).To(MatchError(model.ErrNotFound))
remaining, err := buffer.Length()
Expect(err).ToNot(HaveOccurred())
Expect(remaining).To(Equal(int64(1)))
entry, err := buffer.Next("other-plugin", "user1")
Expect(err).ToNot(HaveOccurred())
Expect(entry).ToNot(BeNil(), "entries of other services must be kept")
})
It("keeps buffered scrobbles of a builtin scrobbler sharing the removed plugin's name", func() {
ctx := context.Background()
scrobbler.Register("builtin-svc", func(model.DataStore) scrobbler.Scrobbler { return nil })
buffer := tests.CreateMockedScrobbleBufferRepo()
Expect(buffer.Enqueue("builtin-svc", "user1", "track1", time.Now())).To(Succeed())
repo := tests.CreateMockPluginRepo()
plugin := model.Plugin{ID: "builtin-svc", Enabled: false}
repo.SetData(model.Plugins{plugin})
m := &Manager{
ds: &tests.MockDataStore{MockedScrobbleBuffer: buffer},
}
Expect(m.removePluginFromDB(ctx, repo, &plugin)).To(Succeed())
remaining, err := buffer.Length()
Expect(err).ToNot(HaveOccurred())
Expect(remaining).To(Equal(int64(1)), "builtin scrobbler queue must not be wiped")
})
})
var _ = Describe("ComputeFileSHA256", func() {
It("returns a consistent 64-char lowercase hex hash for the same file", func() {
dir := GinkgoT().TempDir()
ndpPath := filepath.Join(dir, "test.ndp")
err := createTestPackage(ndpPath, &Manifest{Name: "S", Author: "a", Version: "1.0.0"}, []byte{0x00, 0x61, 0x73, 0x6d})
Expect(err).ToNot(HaveOccurred())
hash1, err := ComputeFileSHA256(ndpPath)
Expect(err).ToNot(HaveOccurred())
hash2, err := ComputeFileSHA256(ndpPath)
Expect(err).ToNot(HaveOccurred())
Expect(hash1).To(Equal(hash2))
Expect(hash1).To(MatchRegexp(`^[0-9a-f]{64}$`))
})
It("returns an error for a non-existent path", func() {
_, err := ComputeFileSHA256(filepath.Join(GinkgoT().TempDir(), "does-not-exist.ndp"))
Expect(err).To(HaveOccurred())
})
})