🏡 index : quine-global/quintodrome.git

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().(*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])))
			}
		})
	})
})