mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-30 02:42:40 +00:00
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.
125 lines
4 KiB
Go
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())
|
|
})
|
|
})
|