mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-13 21:07:10 +02:00
157 lines
4.4 KiB
Go
157 lines
4.4 KiB
Go
|
|
package plugins
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"io/fs"
|
||
|
|
"path/filepath"
|
||
|
|
|
||
|
|
"github.com/navidrome/navidrome/log"
|
||
|
|
"github.com/navidrome/navidrome/model"
|
||
|
|
"github.com/tetratelabs/wazero"
|
||
|
|
experimentalsys "github.com/tetratelabs/wazero/experimental/sys"
|
||
|
|
"github.com/tetratelabs/wazero/experimental/sysfs"
|
||
|
|
"github.com/tetratelabs/wazero/sys"
|
||
|
|
)
|
||
|
|
|
||
|
|
// jailedFS confines a mount to its root: wazero resolves guest paths by bare
|
||
|
|
// concatenation. Named field, not embedding, so a new sys.FS method can't slip through.
|
||
|
|
type jailedFS struct {
|
||
|
|
fs experimentalsys.FS
|
||
|
|
}
|
||
|
|
|
||
|
|
// escapes reports whether a guest path can resolve outside the mount root.
|
||
|
|
// WASI only validates "/"-separated paths, missing `..\`, `C:x` and device names.
|
||
|
|
func escapes(path string) bool {
|
||
|
|
switch path {
|
||
|
|
case "", ".", "/": // how wazero opens the mount root itself
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
return !filepath.IsLocal(path)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (jailedFS) Symlink(string, string) experimentalsys.Errno {
|
||
|
|
return experimentalsys.EPERM
|
||
|
|
}
|
||
|
|
|
||
|
|
func (j jailedFS) OpenFile(path string, flag experimentalsys.Oflag, perm fs.FileMode) (experimentalsys.File, experimentalsys.Errno) {
|
||
|
|
if escapes(path) {
|
||
|
|
return nil, experimentalsys.EPERM
|
||
|
|
}
|
||
|
|
return j.fs.OpenFile(path, flag, perm)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (j jailedFS) Lstat(path string) (sys.Stat_t, experimentalsys.Errno) {
|
||
|
|
if escapes(path) {
|
||
|
|
return sys.Stat_t{}, experimentalsys.EPERM
|
||
|
|
}
|
||
|
|
return j.fs.Lstat(path)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (j jailedFS) Stat(path string) (sys.Stat_t, experimentalsys.Errno) {
|
||
|
|
if escapes(path) {
|
||
|
|
return sys.Stat_t{}, experimentalsys.EPERM
|
||
|
|
}
|
||
|
|
return j.fs.Stat(path)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (j jailedFS) Mkdir(path string, perm fs.FileMode) experimentalsys.Errno {
|
||
|
|
if escapes(path) {
|
||
|
|
return experimentalsys.EPERM
|
||
|
|
}
|
||
|
|
return j.fs.Mkdir(path, perm)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (j jailedFS) Chmod(path string, perm fs.FileMode) experimentalsys.Errno {
|
||
|
|
if escapes(path) {
|
||
|
|
return experimentalsys.EPERM
|
||
|
|
}
|
||
|
|
return j.fs.Chmod(path, perm)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (j jailedFS) Rename(from, to string) experimentalsys.Errno {
|
||
|
|
if escapes(from) || escapes(to) {
|
||
|
|
return experimentalsys.EPERM
|
||
|
|
}
|
||
|
|
return j.fs.Rename(from, to)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (j jailedFS) Rmdir(path string) experimentalsys.Errno {
|
||
|
|
if escapes(path) {
|
||
|
|
return experimentalsys.EPERM
|
||
|
|
}
|
||
|
|
return j.fs.Rmdir(path)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (j jailedFS) Unlink(path string) experimentalsys.Errno {
|
||
|
|
if escapes(path) {
|
||
|
|
return experimentalsys.EPERM
|
||
|
|
}
|
||
|
|
return j.fs.Unlink(path)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (j jailedFS) Link(oldPath, newPath string) experimentalsys.Errno {
|
||
|
|
if escapes(oldPath) || escapes(newPath) {
|
||
|
|
return experimentalsys.EPERM
|
||
|
|
}
|
||
|
|
return j.fs.Link(oldPath, newPath)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (j jailedFS) Readlink(path string) (string, experimentalsys.Errno) {
|
||
|
|
if escapes(path) {
|
||
|
|
return "", experimentalsys.EPERM
|
||
|
|
}
|
||
|
|
return j.fs.Readlink(path)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (j jailedFS) Utimens(path string, atim, mtim int64) experimentalsys.Errno {
|
||
|
|
if escapes(path) {
|
||
|
|
return experimentalsys.EPERM
|
||
|
|
}
|
||
|
|
return j.fs.Utimens(path, atim, mtim)
|
||
|
|
}
|
||
|
|
|
||
|
|
// mount is a host directory exposed to a plugin at guestPath.
|
||
|
|
type mount struct {
|
||
|
|
hostPath string
|
||
|
|
guestPath string
|
||
|
|
readOnly bool
|
||
|
|
}
|
||
|
|
|
||
|
|
// buildMounts lists the libraries the plugin may reach through the filesystem.
|
||
|
|
func buildMounts(ctx context.Context, libraries model.Libraries, allowedLibraryIDs []int, allLibraries, allowWriteAccess bool) []mount {
|
||
|
|
allowedLibrarySet := make(map[int]struct{}, len(allowedLibraryIDs))
|
||
|
|
for _, id := range allowedLibraryIDs {
|
||
|
|
allowedLibrarySet[id] = struct{}{}
|
||
|
|
}
|
||
|
|
var mounts []mount
|
||
|
|
for _, lib := range libraries {
|
||
|
|
_, allowed := allowedLibrarySet[lib.ID]
|
||
|
|
if allLibraries || allowed {
|
||
|
|
m := mount{hostPath: lib.Path, guestPath: toPluginMountPoint(int32(lib.ID)), readOnly: !allowWriteAccess}
|
||
|
|
mounts = append(mounts, m)
|
||
|
|
log.Trace(ctx, "Added library to plugin mounts", "libraryID", lib.ID, "mountPoint", m.guestPath, "readOnly", m.readOnly, "hostPath", m.hostPath)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if allowWriteAccess {
|
||
|
|
log.Info(ctx, "Granting read-write filesystem access to libraries", "libraryCount", len(mounts), "allLibraries", allLibraries)
|
||
|
|
} else {
|
||
|
|
log.Debug(ctx, "Granting read-only filesystem access to libraries", "libraryCount", len(mounts), "allLibraries", allLibraries)
|
||
|
|
}
|
||
|
|
return mounts
|
||
|
|
}
|
||
|
|
|
||
|
|
// buildFSConfig mounts each host directory jailed to its root.
|
||
|
|
func buildFSConfig(mounts []mount) wazero.FSConfig {
|
||
|
|
if len(mounts) == 0 {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
cfg := wazero.NewFSConfig()
|
||
|
|
for _, m := range mounts {
|
||
|
|
var mounted experimentalsys.FS = jailedFS{fs: sysfs.DirFS(m.hostPath)}
|
||
|
|
if m.readOnly {
|
||
|
|
mounted = &sysfs.ReadFS{FS: mounted}
|
||
|
|
}
|
||
|
|
cfg = cfg.(sysfs.FSConfig).WithSysFSMount(mounted, m.guestPath)
|
||
|
|
}
|
||
|
|
return cfg
|
||
|
|
}
|