mirror of
https://github.com/navidrome/navidrome.git
synced 2025-05-22 04:51:25 +03:00
* Add Scanner.FollowSymlinks option (default: true) - Fixes #4060 * fix(mockMusicFS): improve symlink handling in Open, Stat, and ReadDir methods Signed-off-by: Deluan <deluan@navidrome.org> * refactor(tests): enhance walkDirTree tests with symlink handling and cleanup Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org>
301 lines
7.5 KiB
Go
301 lines
7.5 KiB
Go
package scanner
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"testing/fstest"
|
|
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/conf/configtest"
|
|
"github.com/navidrome/navidrome/core/storage"
|
|
"github.com/navidrome/navidrome/model"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
"golang.org/x/sync/errgroup"
|
|
)
|
|
|
|
var _ = Describe("walk_dir_tree", func() {
|
|
Describe("walkDirTree", func() {
|
|
var (
|
|
fsys storage.MusicFS
|
|
job *scanJob
|
|
ctx context.Context
|
|
)
|
|
|
|
BeforeEach(func() {
|
|
DeferCleanup(configtest.SetupConfig())
|
|
ctx = GinkgoT().Context()
|
|
fsys = &mockMusicFS{
|
|
FS: fstest.MapFS{
|
|
"root/a/.ndignore": {Data: []byte("ignored/*")},
|
|
"root/a/f1.mp3": {},
|
|
"root/a/f2.mp3": {},
|
|
"root/a/ignored/bad.mp3": {},
|
|
"root/b/cover.jpg": {},
|
|
"root/c/f3": {},
|
|
"root/d": {},
|
|
"root/d/.ndignore": {},
|
|
"root/d/f1.mp3": {},
|
|
"root/d/f2.mp3": {},
|
|
"root/d/f3.mp3": {},
|
|
"root/e/original/f1.mp3": {},
|
|
"root/e/symlink": {Mode: fs.ModeSymlink, Data: []byte("root/e/original")},
|
|
},
|
|
}
|
|
job = &scanJob{
|
|
fs: fsys,
|
|
lib: model.Library{Path: "/music"},
|
|
}
|
|
})
|
|
|
|
// Helper function to call walkDirTree and collect folders from the results channel
|
|
getFolders := func() map[string]*folderEntry {
|
|
results, err := walkDirTree(ctx, job)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
folders := map[string]*folderEntry{}
|
|
g := errgroup.Group{}
|
|
g.Go(func() error {
|
|
for folder := range results {
|
|
folders[folder.path] = folder
|
|
}
|
|
return nil
|
|
})
|
|
_ = g.Wait()
|
|
return folders
|
|
}
|
|
|
|
DescribeTable("symlink handling",
|
|
func(followSymlinks bool, expectedFolderCount int) {
|
|
conf.Server.Scanner.FollowSymlinks = followSymlinks
|
|
folders := getFolders()
|
|
|
|
Expect(folders).To(HaveLen(expectedFolderCount + 2)) // +2 for `.` and `root`
|
|
|
|
// Basic folder structure checks
|
|
Expect(folders["root/a"].audioFiles).To(SatisfyAll(
|
|
HaveLen(2),
|
|
HaveKey("f1.mp3"),
|
|
HaveKey("f2.mp3"),
|
|
))
|
|
Expect(folders["root/a"].imageFiles).To(BeEmpty())
|
|
Expect(folders["root/b"].audioFiles).To(BeEmpty())
|
|
Expect(folders["root/b"].imageFiles).To(SatisfyAll(
|
|
HaveLen(1),
|
|
HaveKey("cover.jpg"),
|
|
))
|
|
Expect(folders["root/c"].audioFiles).To(BeEmpty())
|
|
Expect(folders["root/c"].imageFiles).To(BeEmpty())
|
|
Expect(folders).ToNot(HaveKey("root/d"))
|
|
|
|
// Symlink specific checks
|
|
if followSymlinks {
|
|
Expect(folders["root/e/symlink"].audioFiles).To(HaveLen(1))
|
|
} else {
|
|
Expect(folders).ToNot(HaveKey("root/e/symlink"))
|
|
}
|
|
},
|
|
Entry("with symlinks enabled", true, 7),
|
|
Entry("with symlinks disabled", false, 6),
|
|
)
|
|
})
|
|
|
|
Describe("helper functions", func() {
|
|
dir, _ := os.Getwd()
|
|
fsys := os.DirFS(dir)
|
|
baseDir := filepath.Join("tests", "fixtures")
|
|
|
|
Describe("isDirOrSymlinkToDir", func() {
|
|
BeforeEach(func() {
|
|
DeferCleanup(configtest.SetupConfig())
|
|
})
|
|
|
|
Context("with symlinks enabled", func() {
|
|
BeforeEach(func() {
|
|
conf.Server.Scanner.FollowSymlinks = true
|
|
})
|
|
|
|
DescribeTable("returns expected result",
|
|
func(dirName string, expected bool) {
|
|
dirEntry := getDirEntry("tests/fixtures", dirName)
|
|
Expect(isDirOrSymlinkToDir(fsys, baseDir, dirEntry)).To(Equal(expected))
|
|
},
|
|
Entry("normal dir", "empty_folder", true),
|
|
Entry("symlink to dir", "symlink2dir", true),
|
|
Entry("regular file", "test.mp3", false),
|
|
Entry("symlink to file", "symlink", false),
|
|
)
|
|
})
|
|
|
|
Context("with symlinks disabled", func() {
|
|
BeforeEach(func() {
|
|
conf.Server.Scanner.FollowSymlinks = false
|
|
})
|
|
|
|
DescribeTable("returns expected result",
|
|
func(dirName string, expected bool) {
|
|
dirEntry := getDirEntry("tests/fixtures", dirName)
|
|
Expect(isDirOrSymlinkToDir(fsys, baseDir, dirEntry)).To(Equal(expected))
|
|
},
|
|
Entry("normal dir", "empty_folder", true),
|
|
Entry("symlink to dir", "symlink2dir", false),
|
|
Entry("regular file", "test.mp3", false),
|
|
Entry("symlink to file", "symlink", false),
|
|
)
|
|
})
|
|
})
|
|
|
|
Describe("isDirIgnored", func() {
|
|
DescribeTable("returns expected result",
|
|
func(dirName string, expected bool) {
|
|
Expect(isDirIgnored(dirName)).To(Equal(expected))
|
|
},
|
|
Entry("normal dir", "empty_folder", false),
|
|
Entry("hidden dir", ".hidden_folder", true),
|
|
Entry("dir starting with ellipsis", "...unhidden_folder", false),
|
|
Entry("recycle bin", "$Recycle.Bin", true),
|
|
Entry("snapshot dir", "#snapshot", true),
|
|
)
|
|
})
|
|
|
|
Describe("fullReadDir", func() {
|
|
var (
|
|
fsys fakeFS
|
|
ctx context.Context
|
|
)
|
|
|
|
BeforeEach(func() {
|
|
ctx = GinkgoT().Context()
|
|
fsys = fakeFS{MapFS: fstest.MapFS{
|
|
"root/a/f1": {},
|
|
"root/b/f2": {},
|
|
"root/c/f3": {},
|
|
}}
|
|
})
|
|
|
|
DescribeTable("reading directory entries",
|
|
func(failOn string, expectedErr error, expectedNames []string) {
|
|
fsys.failOn = failOn
|
|
fsys.err = expectedErr
|
|
dir, _ := fsys.Open("root")
|
|
entries := fullReadDir(ctx, dir.(fs.ReadDirFile))
|
|
Expect(entries).To(HaveLen(len(expectedNames)))
|
|
for i, name := range expectedNames {
|
|
Expect(entries[i].Name()).To(Equal(name))
|
|
}
|
|
},
|
|
Entry("reads all entries", "", nil, []string{"a", "b", "c"}),
|
|
Entry("skips entries with permission error", "b", nil, []string{"a", "c"}),
|
|
Entry("aborts on fs.ErrNotExist", "", fs.ErrNotExist, []string{}),
|
|
)
|
|
})
|
|
})
|
|
})
|
|
|
|
type fakeFS struct {
|
|
fstest.MapFS
|
|
failOn string
|
|
err error
|
|
}
|
|
|
|
func (f *fakeFS) Open(name string) (fs.File, error) {
|
|
dir, err := f.MapFS.Open(name)
|
|
return &fakeDirFile{File: dir, fail: f.failOn, err: f.err}, err
|
|
}
|
|
|
|
type fakeDirFile struct {
|
|
fs.File
|
|
entries []fs.DirEntry
|
|
pos int
|
|
fail string
|
|
err error
|
|
}
|
|
|
|
// Only works with n == -1
|
|
func (fd *fakeDirFile) ReadDir(int) ([]fs.DirEntry, error) {
|
|
if fd.err != nil {
|
|
return nil, fd.err
|
|
}
|
|
if fd.entries == nil {
|
|
fd.entries, _ = fd.File.(fs.ReadDirFile).ReadDir(-1)
|
|
}
|
|
var dirs []fs.DirEntry
|
|
for {
|
|
if fd.pos >= len(fd.entries) {
|
|
break
|
|
}
|
|
e := fd.entries[fd.pos]
|
|
fd.pos++
|
|
if e.Name() == fd.fail {
|
|
return dirs, &fs.PathError{Op: "lstat", Path: e.Name(), Err: fs.ErrPermission}
|
|
}
|
|
dirs = append(dirs, e)
|
|
}
|
|
return dirs, nil
|
|
}
|
|
|
|
func getDirEntry(baseDir, name string) os.DirEntry {
|
|
dirEntries, _ := os.ReadDir(baseDir)
|
|
for _, entry := range dirEntries {
|
|
if entry.Name() == name {
|
|
return entry
|
|
}
|
|
}
|
|
panic(fmt.Sprintf("Could not find %s in %s", name, baseDir))
|
|
}
|
|
|
|
// mockMusicFS is a mock implementation of the MusicFS interface that supports symlinks
|
|
type mockMusicFS struct {
|
|
storage.MusicFS
|
|
fs.FS
|
|
}
|
|
|
|
// Open resolves symlinks
|
|
func (m *mockMusicFS) Open(name string) (fs.File, error) {
|
|
f, err := m.FS.Open(name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
info, err := f.Stat()
|
|
if err != nil {
|
|
f.Close()
|
|
return nil, err
|
|
}
|
|
|
|
if info.Mode()&fs.ModeSymlink != 0 {
|
|
// For symlinks, read the target path from the Data field
|
|
target := string(m.FS.(fstest.MapFS)[name].Data)
|
|
f.Close()
|
|
return m.FS.Open(target)
|
|
}
|
|
|
|
return f, nil
|
|
}
|
|
|
|
// Stat uses Open to resolve symlinks
|
|
func (m *mockMusicFS) Stat(name string) (fs.FileInfo, error) {
|
|
f, err := m.Open(name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer f.Close()
|
|
return f.Stat()
|
|
}
|
|
|
|
// ReadDir uses Open to resolve symlinks
|
|
func (m *mockMusicFS) ReadDir(name string) ([]fs.DirEntry, error) {
|
|
f, err := m.Open(name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer f.Close()
|
|
if dirFile, ok := f.(fs.ReadDirFile); ok {
|
|
return dirFile.ReadDir(-1)
|
|
}
|
|
return nil, fmt.Errorf("not a directory")
|
|
}
|