package scanner
import (
"context"
"fmt"
"io/fs"
"maps"
"os"
"path/filepath"
"slices"
"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/navidrome/navidrome/tests"
. "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
)
Context("full library", func() {
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("original")},
"root/f/realsong.mp3": {Data: []byte("AUDIO")},
"root/f/legit.mp3": {Mode: fs.ModeSymlink, Data: []byte("realsong.mp3")},
"root/f/secret": {Data: []byte("TOPSECRET")},
"root/f/evil.mp3": {Mode: fs.ModeSymlink, Data: []byte("secret")},
"root/g/.Hack Sign Original Soundtrack/track.mp3": {},
"root/h/.hidden.mp3": {},
"root/i/.git/config": {},
"root/i/.streams/stream.mp3": {},
},
}
job = &scanJob{
fs: fsys,
lib: model.Library{Path: "/music"},
}
})
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))
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"))
Expect(folders).ToNot(HaveKey("root/g/.Hack Sign Original Soundtrack"))
Expect(folders["root/h"].audioFiles).To(BeEmpty())
Expect(folders).ToNot(HaveKey("root/i/.git"))
Expect(folders).ToNot(HaveKey("root/i/.streams"))
if followSymlinks {
Expect(folders["root/e/symlink"].audioFiles).To(HaveLen(1))
Expect(folders["root/f"].audioFiles).To(HaveKey("legit.mp3"))
Expect(folders["root/f"].audioFiles).To(HaveKey("realsong.mp3"))
Expect(folders["root/f"].audioFiles).ToNot(HaveKey("evil.mp3"))
} else {
Expect(folders).ToNot(HaveKey("root/e/symlink"))
Expect(folders["root/f"].audioFiles).To(HaveKey("realsong.mp3"))
Expect(folders["root/f"].audioFiles).ToNot(HaveKey("legit.mp3"))
Expect(folders["root/f"].audioFiles).ToNot(HaveKey("evil.mp3"))
}
},
Entry("with symlinks enabled", true, 11),
Entry("with symlinks disabled", false, 10),
)
DescribeTable("dot-prefixed folders with IgnoreDotFolders disabled",
func(followSymlinks bool) {
conf.Server.Scanner.FollowSymlinks = followSymlinks
conf.Server.Scanner.IgnoreDotFolders = false
folders := getFolders()
Expect(folders["root/g/.Hack Sign Original Soundtrack"].audioFiles).To(SatisfyAll(
HaveLen(1),
HaveKey("track.mp3"),
))
Expect(folders["root/h"].audioFiles).To(BeEmpty())
Expect(folders).ToNot(HaveKey("root/i/.git"))
Expect(folders).ToNot(HaveKey("root/i/.streams"))
},
Entry("with symlinks enabled", true),
Entry("with symlinks disabled", false),
)
})
Context("with target folders", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
ctx = GinkgoT().Context()
fsys = &mockMusicFS{
FS: fstest.MapFS{
"Artist/Album1/track1.mp3": {},
"Artist/Album1/track2.mp3": {},
"Artist/Album2/track1.mp3": {},
"Artist/Album2/track2.mp3": {},
"Artist/Album2/Sub/track3.mp3": {},
"OtherArtist/Album3/track1.mp3": {},
},
}
job = &scanJob{
fs: fsys,
lib: model.Library{Path: "/music"},
}
})
It("should recursively walk all subdirectories of target folders", func() {
results, err := walkDirTree(ctx, job, "Artist")
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()
Expect(folders).To(SatisfyAll(
HaveKey("Artist"),
HaveKey("Artist/Album1"),
HaveKey("Artist/Album2"),
HaveKey("Artist/Album2/Sub"),
))
Expect(folders).ToNot(HaveKey("OtherArtist"))
Expect(folders).ToNot(HaveKey("OtherArtist/Album3"))
Expect(folders["Artist/Album1"].audioFiles).To(HaveLen(2))
Expect(folders["Artist/Album2"].audioFiles).To(HaveLen(2))
Expect(folders["Artist/Album2/Sub"].audioFiles).To(HaveLen(1))
})
It("should handle multiple target folders", func() {
results, err := walkDirTree(ctx, job, "Artist/Album1", "OtherArtist")
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()
Expect(folders).To(SatisfyAll(
HaveKey("Artist/Album1"),
HaveKey("OtherArtist"),
HaveKey("OtherArtist/Album3"),
))
Expect(folders).ToNot(HaveKey("Artist"))
Expect(folders).ToNot(HaveKey("Artist/Album2"))
Expect(folders).ToNot(HaveKey("Artist/Album2/Sub"))
})
It("should skip non-existent target folders and preserve them in lastUpdates", func() {
job.lastUpdates = map[string]model.FolderUpdateInfo{
model.FolderID(job.lib, "Artist/Album1"): {},
model.FolderID(job.lib, "NonExistent/DeletedFolder"): {},
model.FolderID(job.lib, "OtherArtist/Album3"): {},
}
results, err := walkDirTree(ctx, job, "Artist/Album1", "NonExistent/DeletedFolder")
Expect(err).ToNot(HaveOccurred())
folders := map[string]struct{}{}
for folder := range results {
folders[folder.path] = struct{}{}
}
Expect(folders).To(HaveKey("Artist/Album1"))
Expect(folders).ToNot(HaveKey("NonExistent/DeletedFolder"))
Expect(job.lastUpdates).To(HaveKey(model.FolderID(job.lib, "NonExistent/DeletedFolder")))
Expect(job.lastUpdates).ToNot(HaveKey(model.FolderID(job.lib, "Artist/Album1")))
Expect(job.lastUpdates).To(HaveKey(model.FolderID(job.lib, "OtherArtist/Album3")))
})
Context("symlinked target folders (production local storage FS)", func() {
BeforeEach(func() {
libRoot := GinkgoT().TempDir()
Expect(os.MkdirAll(filepath.Join(libRoot, "Mozart", "Album1"), 0755)).To(Succeed())
Expect(os.WriteFile(filepath.Join(libRoot, "Mozart", "Album1", "track.mp3"), []byte("AUDIO"), 0600)).To(Succeed())
Expect(os.Symlink("Mozart", filepath.Join(libRoot, "Wolfgang Amadeus Mozart"))).To(Succeed())
job = &scanJob{fs: newLocalMusicFS(libRoot), lib: model.Library{Path: libRoot}}
})
walkTargets := func(targets ...string) map[string]*folderEntry {
results, err := walkDirTree(ctx, job, targets...)
Expect(err).ToNot(HaveOccurred())
folders := map[string]*folderEntry{}
for folder := range results {
folders[folder.path] = folder
}
return folders
}
DescribeTable("with FollowSymlinks disabled",
func(target string, expected ...string) {
conf.Server.Scanner.FollowSymlinks = false
Expect(slices.Collect(maps.Keys(walkTargets(target)))).To(ConsistOf(expected))
},
Entry("skips a target that is a symlink", "Wolfgang Amadeus Mozart"),
Entry("skips a target under a symlinked folder", "Wolfgang Amadeus Mozart/Album1"),
Entry("walks a regular target", "Mozart", "Mozart", "Mozart/Album1"),
)
It("walks a symlinked target when FollowSymlinks is enabled", func() {
conf.Server.Scanner.FollowSymlinks = true
folders := walkTargets("Wolfgang Amadeus Mozart")
Expect(folders).To(HaveKey("Wolfgang Amadeus Mozart/Album1"))
Expect(folders["Wolfgang Amadeus Mozart/Album1"].audioFiles).To(HaveKey("track.mp3"))
})
})
})
})
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() {
tests.SkipOnWindows("symlink semantics")
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("resolveEntryName", func() {
var fsys fs.FS
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
fsys = fstest.MapFS{
"dir/real.mp3": {Data: []byte("AUDIO")},
"dir/mid.mp3": {Mode: fs.ModeSymlink, Data: []byte("real.mp3")},
"dir/chain.mp3": {Mode: fs.ModeSymlink, Data: []byte("mid.mp3")},
"dir/audio.mp3": {Mode: fs.ModeSymlink, Data: []byte("real.mp3")},
"dir/evil.mp3": {Mode: fs.ModeSymlink, Data: []byte("../outside/passwd")},
"dir/loop1.mp3": {Mode: fs.ModeSymlink, Data: []byte("loop2.mp3")},
"dir/loop2.mp3": {Mode: fs.ModeSymlink, Data: []byte("loop1.mp3")},
"dir/dangle.mp3": {Mode: fs.ModeSymlink, Data: []byte("missing.mp3")},
}
})
resolve := func(name string) (string, bool) {
entries, err := fs.ReadDir(fsys, "dir")
Expect(err).ToNot(HaveOccurred())
for _, e := range entries {
if e.Name() == name {
return resolveEntryName(GinkgoT().Context(), fsys, "dir", e)
}
}
Fail("entry not found: " + name)
return "", false
}
Context("with symlinks enabled", func() {
BeforeEach(func() { conf.Server.Scanner.FollowSymlinks = true })
It("returns the entry name for a plain file", func() {
name, ok := resolve("real.mp3")
Expect(ok).To(BeTrue())
Expect(name).To(Equal("real.mp3"))
})
It("resolves a direct symlink to its audio target name", func() {
name, ok := resolve("audio.mp3")
Expect(ok).To(BeTrue())
Expect(name).To(Equal("real.mp3"))
})
It("resolves a symlink CHAIN to the final target name", func() {
name, ok := resolve("chain.mp3")
Expect(ok).To(BeTrue())
Expect(name).To(Equal("real.mp3"))
})
It("resolves a symlink to a non-audio target name (so caller can reject it)", func() {
name, ok := resolve("evil.mp3")
Expect(ok).To(BeTrue())
Expect(name).To(Equal("passwd"))
})
It("rejects a symlink loop", func() {
_, ok := resolve("loop1.mp3")
Expect(ok).To(BeFalse())
})
})
Context("with symlinks disabled", func() {
BeforeEach(func() { conf.Server.Scanner.FollowSymlinks = false })
It("returns the entry name for a plain file", func() {
name, ok := resolve("real.mp3")
Expect(ok).To(BeTrue())
Expect(name).To(Equal("real.mp3"))
})
It("skips any file symlink", func() {
_, ok := resolve("audio.mp3")
Expect(ok).To(BeFalse())
})
})
})
Describe("symlink chain (real fs)", func() {
BeforeEach(func() {
tests.SkipOnWindows("symlink semantics")
DeferCleanup(configtest.SetupConfig())
})
classify := func(fsys fs.FS, dirPath, name string) (string, bool) {
entries, err := fs.ReadDir(fsys, dirPath)
Expect(err).ToNot(HaveOccurred())
for _, e := range entries {
if e.Name() == name {
return resolveEntryName(GinkgoT().Context(), fsys, dirPath, e)
}
}
Fail("entry not found: " + name)
return "", false
}
Context("committed 3-level fixtures", func() {
var fsys fs.FS
BeforeEach(func() {
conf.Server.Scanner.FollowSymlinks = true
wd, err := os.Getwd()
Expect(err).ToNot(HaveOccurred())
fsys = os.DirFS(wd)
})
It("keeps a 3-level chain that resolves to real audio", func() {
name, ok := classify(fsys, "tests/fixtures/symlink_chain", "level3.mp3")
Expect(ok).To(BeTrue())
Expect(name).To(Equal("test.mp3"))
Expect(model.IsAudioFile(name)).To(BeTrue())
})
It("rejects a 3-level chain that resolves to a non-audio file", func() {
name, ok := classify(fsys, "tests/fixtures/symlink_chain", "evil3.mp3")
Expect(ok).To(BeTrue())
Expect(name).To(Equal("index.html"))
Expect(model.IsAudioFile(name)).To(BeFalse())
})
It("skips the chain entirely when FollowSymlinks is disabled", func() {
conf.Server.Scanner.FollowSymlinks = false
_, ok := classify(fsys, "tests/fixtures/symlink_chain", "level3.mp3")
Expect(ok).To(BeFalse())
_, ok = classify(fsys, "tests/fixtures/symlink_chain", "evil3.mp3")
Expect(ok).To(BeFalse())
})
})
Context("production local storage FS", func() {
var libRoot string
var musicFS storage.MusicFS
BeforeEach(func() {
conf.Server.Scanner.FollowSymlinks = true
base := GinkgoT().TempDir()
pool := filepath.Join(base, "pool")
libRoot = filepath.Join(base, "userlib")
Expect(os.MkdirAll(pool, 0755)).To(Succeed())
Expect(os.MkdirAll(libRoot, 0755)).To(Succeed())
Expect(os.WriteFile(filepath.Join(pool, "real.mp3"), []byte("AUDIO"), 0600)).To(Succeed())
Expect(os.WriteFile(filepath.Join(pool, "secrets.txt"), []byte("TOPSECRET"), 0600)).To(Succeed())
Expect(os.Symlink(filepath.Join(pool, "secrets.txt"), filepath.Join(pool, "mid.wav"))).To(Succeed())
Expect(os.Symlink("../pool/real.mp3", filepath.Join(libRoot, "relative.mp3"))).To(Succeed())
Expect(os.Symlink(filepath.Join(pool, "real.mp3"), filepath.Join(libRoot, "absolute.mp3"))).To(Succeed())
Expect(os.Symlink(filepath.Join(pool, "mid.wav"), filepath.Join(libRoot, "evil.wav"))).To(Succeed())
Expect(os.Symlink(filepath.Join(pool, "missing.mp3"), filepath.Join(libRoot, "broken.mp3"))).To(Succeed())
musicFS = newLocalMusicFS(libRoot)
})
walkRoot := func() *folderEntry {
job := &scanJob{fs: musicFS, lib: model.Library{Path: libRoot}}
results, err := walkDirTree(GinkgoT().Context(), job)
Expect(err).ToNot(HaveOccurred())
var root *folderEntry
for folder := range results {
if folder.path == "." {
root = folder
}
}
Expect(root).ToNot(BeNil())
return root
}
It("imports symlinks to out-of-library audio files", func() {
root := walkRoot()
Expect(root.audioFiles).To(HaveKey("relative.mp3"))
Expect(root.audioFiles).To(HaveKey("absolute.mp3"))
})
It("rejects a chain that ends in a non-audio file, even through an audio-named intermediate", func() {
root := walkRoot()
Expect(root.audioFiles).ToNot(HaveKey("evil.wav"))
})
It("skips broken symlinks", func() {
root := walkRoot()
Expect(root.audioFiles).ToNot(HaveKey("broken.mp3"))
})
It("skips all file symlinks when FollowSymlinks is disabled", func() {
conf.Server.Scanner.FollowSymlinks = false
root := walkRoot()
Expect(root.audioFiles).To(BeEmpty())
})
})
Context("out-of-tree escape (temp dir)", func() {
var root string
BeforeEach(func() {
conf.Server.Scanner.FollowSymlinks = true
root = GinkgoT().TempDir()
outside := GinkgoT().TempDir()
Expect(os.WriteFile(filepath.Join(outside, "passwd"), []byte("TOPSECRET"), 0600)).To(Succeed())
Expect(os.WriteFile(filepath.Join(outside, "real.flac"), []byte("AUDIO"), 0600)).To(Succeed())
Expect(os.WriteFile(filepath.Join(root, "song.mp3"), []byte("AUDIO"), 0600)).To(Succeed())
Expect(os.Symlink(filepath.Join(outside, "passwd"), filepath.Join(root, "evil.mp3"))).To(Succeed())
Expect(os.Symlink(filepath.Join(outside, "real.flac"), filepath.Join(root, "legit.flac"))).To(Succeed())
})
It("rejects the absolute-path escape but keeps legit out-of-tree audio", func() {
fsys := os.DirFS(root)
name, ok := classify(fsys, ".", "song.mp3")
Expect(ok).To(BeTrue())
Expect(model.IsAudioFile(name)).To(BeTrue())
name, ok = classify(fsys, ".", "legit.flac")
Expect(ok).To(BeTrue())
Expect(model.IsAudioFile(name)).To(BeTrue())
name, ok = classify(fsys, ".", "evil.mp3")
Expect(ok).To(BeTrue())
Expect(model.IsAudioFile(name)).To(BeFalse())
})
It("skips all file symlinks when FollowSymlinks is disabled", func() {
conf.Server.Scanner.FollowSymlinks = false
fsys := os.DirFS(root)
entries, err := fs.ReadDir(fsys, ".")
Expect(err).ToNot(HaveOccurred())
for _, e := range entries {
_, ok := resolveEntryName(GinkgoT().Context(), fsys, ".", e)
if e.Type()&fs.ModeSymlink != 0 {
Expect(ok).To(BeFalse(), e.Name())
} else {
Expect(ok).To(BeTrue(), e.Name())
}
}
})
})
})
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("dot-prefixed album dir", ".Hack Sign Original Soundtrack", false),
Entry("git dir", ".git", true),
Entry("streams dir", ".streams", true),
Entry("dir starting with ellipsis", "...unhidden_folder", false),
Entry("recycle bin", "$Recycle.Bin", true),
Entry("snapshot dir", "#snapshot", true),
Entry("synology metadata dir", "@eaDir", true),
)
})
Describe("isIgnoredEntry", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
})
DescribeTable("with IgnoreDotFolders enabled (default)",
func(name string, isDir, expected bool) {
conf.Server.Scanner.IgnoreDotFolders = true
Expect(isIgnoredEntry(name, isDir)).To(Equal(expected))
},
Entry("normal dir", "Album", true, false),
Entry("normal file", "track.mp3", false, false),
Entry("dot folder", ".Hack Sign Original Soundtrack", true, true),
Entry("dot file", ".hidden.mp3", false, true),
Entry("blocklisted dir", ".git", true, true),
Entry("ellipsis dir", "...unhidden", true, false),
)
DescribeTable("with IgnoreDotFolders disabled",
func(name string, isDir, expected bool) {
conf.Server.Scanner.IgnoreDotFolders = false
Expect(isIgnoredEntry(name, isDir)).To(Equal(expected))
},
Entry("normal dir", "Album", true, false),
Entry("normal file", "track.mp3", false, false),
Entry("dot folder is allowed", ".Hack Sign Original Soundtrack", true, false),
Entry("dot file is still ignored", ".hidden.mp3", false, true),
Entry("blocklisted dir still ignored", ".git", true, true),
)
})
Describe("isDotEntry", func() {
DescribeTable("returns expected result",
func(name string, expected bool) {
Expect(isDotEntry(name)).To(Equal(expected))
},
Entry("dot folder", ".Hidden", true),
Entry("dot file", ".hidden.mp3", true),
Entry("current dir", ".", false),
Entry("parent dir", "..", false),
Entry("two leading dots", "..foo", false),
Entry("ellipsis", "...unhidden", false),
Entry("normal name", "Album", false),
)
})
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
}
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))
}
func newLocalMusicFS(libRoot string) storage.MusicFS {
u, err := storage.LocalPathToURL(libRoot)
Expect(err).ToNot(HaveOccurred())
s, err := storage.For(u.String())
Expect(err).ToNot(HaveOccurred())
musicFS, err := s.FS()
Expect(err).ToNot(HaveOccurred())
return musicFS
}
type mockMusicFS struct {
storage.MusicFS
fs.FS
}
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 {
target := string(m.FS.(fstest.MapFS)[name].Data)
f.Close()
return m.FS.Open(target)
}
return f, nil
}
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()
}
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")
}
func (m *mockMusicFS) ReadLink(name string) (string, error) {
mapFS := m.FS.(fstest.MapFS)
entry, ok := mapFS[name]
if !ok {
return "", &fs.PathError{Op: "readlink", Path: name, Err: fs.ErrNotExist}
}
if entry.Mode&fs.ModeSymlink == 0 {
return "", &fs.PathError{Op: "readlink", Path: name, Err: fmt.Errorf("not a symlink")}
}
return string(entry.Data), nil
}
func (m *mockMusicFS) Lstat(name string) (fs.FileInfo, error) {
mapFS := m.FS.(fstest.MapFS)
if _, ok := mapFS[name]; !ok {
return nil, &fs.PathError{Op: "lstat", Path: name, Err: fs.ErrNotExist}
}
f, err := m.FS.Open(name)
if err != nil {
return nil, err
}
defer f.Close()
return f.Stat()
}