navidrome/scanner/walk_dir_tree_test.go
Deluan Quintão 19d443ec7f
feat(scanner): add Scanner.FollowSymlinks option (#4061)
* 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>
2025-05-15 10:33:28 -04:00

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")
}