navidrome/plugins/manager_watcher.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

221 lines
6.4 KiB
Go

package plugins
import (
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/rjeczalik/notify"
)
// debounceDuration is the time to wait before acting on file events
// to handle multiple rapid events for the same file.
const debounceDuration = 2 * time.Second
// startWatcher starts the file watcher for the plugins folder.
// It watches for CREATE, WRITE, and REMOVE events on .wasm files.
func (m *Manager) startWatcher() error {
folder := conf.Server.Plugins.Folder.String()
if folder == "" {
return nil
}
m.watcherEvents = make(chan notify.EventInfo, 10)
m.watcherDone = make(chan struct{})
m.debounceTimers = make(map[string]*time.Timer)
m.debounceMu = sync.Mutex{}
// Watch the plugins folder (not recursive)
// We filter for .wasm files in the event handler
if err := notify.Watch(folder, m.watcherEvents, notify.Create, notify.Write, notify.Remove, notify.Rename); err != nil {
close(m.watcherEvents)
return err
}
log.Info(m.ctx, "Started plugin file watcher", "folder", folder)
go m.watcherLoop()
return nil
}
// stopWatcher stops the file watcher
func (m *Manager) stopWatcher() {
if m.watcherEvents == nil {
return
}
notify.Stop(m.watcherEvents)
close(m.watcherDone)
// Cancel any pending debounce timers
m.debounceMu.Lock()
for _, timer := range m.debounceTimers {
timer.Stop()
}
m.debounceTimers = nil
m.debounceMu.Unlock()
log.Debug(m.ctx, "Stopped plugin file watcher")
}
// watcherLoop processes file watcher events
func (m *Manager) watcherLoop() {
for {
select {
case event, ok := <-m.watcherEvents:
if !ok {
return
}
m.handleWatcherEvent(event)
case <-m.ctx.Done():
return
case <-m.watcherDone:
return
}
}
}
// handleWatcherEvent processes a single file watcher event with debouncing
func (m *Manager) handleWatcherEvent(event notify.EventInfo) {
path := event.Path()
// Only process .ndp package files
if !strings.HasSuffix(path, PackageExtension) {
return
}
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)
// Debounce: cancel any pending timer for this plugin and start a new one
m.debounceMu.Lock()
if timer, exists := m.debounceTimers[pluginName]; exists {
timer.Stop()
}
// Note: We don't capture the event type here. Instead, processPluginEvent
// checks if the file exists when the timer fires. This handles sequences like
// Remove+Create+Rename correctly by checking actual file state after debounce.
m.debounceTimers[pluginName] = time.AfterFunc(debounceDuration, func() {
m.processPluginEvent(pluginName)
})
m.debounceMu.Unlock()
}
// pluginAction represents the action to take on a plugin based on file state
type pluginAction int
const (
actionNone pluginAction = iota // No action needed
actionUpdate // File exists: add new or update existing plugin in DB
actionRemove // File gone: remove plugin from DB (unload if enabled)
)
// determinePluginAction decides what action to take based on file existence.
// We check file existence rather than relying on event type because:
// 1. Events can be coalesced on some systems (macOS FSEvents)
// 2. Rename events can mean either "renamed away" (remove) or "renamed to" (add)
// 3. Build tools often do atomic writes (write temp file, rename to target)
// By checking existence, we handle all these cases correctly.
func determinePluginAction(path string) pluginAction {
if _, err := os.Stat(path); err == nil {
// File exists - treat as add/update
return actionUpdate
}
// File doesn't exist - it was removed
return actionRemove
}
// processPluginEvent handles the actual plugin load/unload/reload after debouncing.
// - If file exists: extract manifest, add or update plugin in DB
// - If file gone: unload if enabled, delete from DB
func (m *Manager) processPluginEvent(pluginName string) {
// Don't process if manager is stopping/stopped (atomic check to avoid race with Stop())
if m.stopped.Load() {
return
}
// Clean up debounce timer entry
m.debounceMu.Lock()
delete(m.debounceTimers, pluginName)
m.debounceMu.Unlock()
folder := conf.Server.Plugins.Folder.String()
ndpPath := filepath.Join(folder, pluginName+PackageExtension)
action := determinePluginAction(ndpPath)
log.Debug(m.ctx, "Plugin event action", "plugin", pluginName, "action", action, "path", ndpPath)
ctx := adminContext(m.ctx)
repo := m.ds.Plugin(ctx)
switch action {
case actionUpdate:
// File changed - check SHA256 first, then extract manifest if needed
sha256Hash, err := ComputeFileSHA256(ndpPath)
if err != nil {
log.Error(m.ctx, "Failed to compute SHA256 for changed plugin", "plugin", pluginName, err)
return
}
dbPlugin, err := repo.Get(pluginName)
if err != nil {
// Plugin not in DB yet, need full manifest extraction to add it
metadata, extractErr := m.extractManifest(ndpPath)
if extractErr != nil {
log.Error(m.ctx, "Failed to extract manifest from new plugin", "plugin", pluginName, extractErr)
return
}
if addErr := m.addPluginToDB(m.ctx, repo, pluginName, ndpPath, metadata); addErr != nil {
log.Error(m.ctx, "Failed to add plugin to DB", "plugin", pluginName, addErr)
}
return
}
// Check if actually changed using lightweight SHA256 comparison
if dbPlugin.SHA256 == sha256Hash {
return // No actual change
}
// Plugin changed - now extract full manifest
metadata, err := m.extractManifest(ndpPath)
if err != nil {
log.Error(m.ctx, "Failed to extract manifest from changed plugin", "plugin", pluginName, err)
// Update error in DB
dbPlugin.LastError = err.Error()
dbPlugin.UpdatedAt = time.Now()
if dbPlugin.Enabled {
_ = m.unloadPlugin(pluginName)
dbPlugin.Enabled = false
}
_ = repo.Put(dbPlugin)
return
}
if err := m.updatePluginInDB(m.ctx, repo, dbPlugin, ndpPath, metadata); err != nil {
log.Error(m.ctx, "Failed to update plugin in DB", "plugin", pluginName, err)
}
case actionRemove:
// File removed - unload if enabled, delete from DB
dbPlugin, err := repo.Get(pluginName)
if err != nil {
log.Debug(m.ctx, "Removed plugin not in DB", "plugin", pluginName)
return
}
if err := m.removePluginFromDB(m.ctx, repo, dbPlugin); err != nil {
log.Error(m.ctx, "Failed to delete plugin from DB", "plugin", pluginName, err)
}
}
}