diff --git a/conf/configuration.go b/conf/configuration.go index d15b0de3e..1223ffedf 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -132,6 +132,7 @@ type scannerOptions struct { ArtistJoiner string GenreSeparators string // Deprecated: Use Tags.genre.Split instead GroupAlbumReleases bool // Deprecated: Use PID.Album instead + FollowSymlinks bool // Whether to follow symlinks when scanning directories } type subsonicOptions struct { @@ -499,6 +500,7 @@ func init() { viper.SetDefault("scanner.artistjoiner", consts.ArtistJoiner) viper.SetDefault("scanner.genreseparators", "") viper.SetDefault("scanner.groupalbumreleases", false) + viper.SetDefault("scanner.followsymlinks", true) viper.SetDefault("subsonic.appendsubtitle", true) viper.SetDefault("subsonic.artistparticipations", false) diff --git a/scanner/walk_dir_tree.go b/scanner/walk_dir_tree.go index ba87f2628..4f9f26b1b 100644 --- a/scanner/walk_dir_tree.go +++ b/scanner/walk_dir_tree.go @@ -11,6 +11,7 @@ import ( "strings" "time" + "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/log" @@ -266,6 +267,10 @@ func isDirOrSymlinkToDir(fsys fs.FS, baseDir string, dirEnt fs.DirEntry) (bool, if dirEnt.Type()&fs.ModeSymlink == 0 { return false, nil } + // If symlinks are disabled, return false for symlinks + if !conf.Server.Scanner.FollowSymlinks { + return false, nil + } // Does this symlink point to a directory? fileInfo, err := fs.Stat(fsys, path.Join(baseDir, dirEnt.Name())) if err != nil { diff --git a/scanner/walk_dir_tree_test.go b/scanner/walk_dir_tree_test.go index 9a21b4a92..c4278ef82 100644 --- a/scanner/walk_dir_tree_test.go +++ b/scanner/walk_dir_tree_test.go @@ -8,6 +8,8 @@ import ( "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" @@ -17,8 +19,15 @@ import ( var _ = Describe("walk_dir_tree", func() { Describe("walkDirTree", func() { - var fsys storage.MusicFS + 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/*")}, @@ -32,21 +41,22 @@ var _ = Describe("walk_dir_tree", func() { "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")}, }, } - }) - - It("walks all directories", func() { - job := &scanJob{ + job = &scanJob{ fs: fsys, lib: model.Library{Path: "/music"}, } - ctx := context.Background() + }) + + // 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 { @@ -55,24 +65,42 @@ var _ = Describe("walk_dir_tree", func() { return nil }) _ = g.Wait() + return folders + } - Expect(folders).To(HaveLen(6)) - Expect(folders["root/a/ignored"].audioFiles).To(BeEmpty()) - 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")) - }) + 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() { @@ -81,74 +109,88 @@ var _ = Describe("walk_dir_tree", func() { baseDir := filepath.Join("tests", "fixtures") Describe("isDirOrSymlinkToDir", func() { - It("returns true for normal dirs", func() { - dirEntry := getDirEntry("tests", "fixtures") - Expect(isDirOrSymlinkToDir(fsys, baseDir, dirEntry)).To(BeTrue()) + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) }) - It("returns true for symlinks to dirs", func() { - dirEntry := getDirEntry(baseDir, "symlink2dir") - Expect(isDirOrSymlinkToDir(fsys, baseDir, dirEntry)).To(BeTrue()) - }) - It("returns false for files", func() { - dirEntry := getDirEntry(baseDir, "test.mp3") - Expect(isDirOrSymlinkToDir(fsys, baseDir, dirEntry)).To(BeFalse()) - }) - It("returns false for symlinks to files", func() { - dirEntry := getDirEntry(baseDir, "symlink") - Expect(isDirOrSymlinkToDir(fsys, baseDir, dirEntry)).To(BeFalse()) - }) - }) - Describe("isDirIgnored", func() { - It("returns false for normal dirs", func() { - Expect(isDirIgnored("empty_folder")).To(BeFalse()) - }) - It("returns true when folder name starts with a `.`", func() { - Expect(isDirIgnored(".hidden_folder")).To(BeTrue()) - }) - It("returns false when folder name starts with ellipses", func() { - Expect(isDirIgnored("...unhidden_folder")).To(BeFalse()) - }) - It("returns true when folder name is $Recycle.Bin", func() { - Expect(isDirIgnored("$Recycle.Bin")).To(BeTrue()) - }) - It("returns true when folder name is #snapshot", func() { - Expect(isDirIgnored("#snapshot")).To(BeTrue()) - }) - }) - }) - Describe("fullReadDir", func() { - var fsys fakeFS - var ctx context.Context - BeforeEach(func() { - ctx = context.Background() - fsys = fakeFS{MapFS: fstest.MapFS{ - "root/a/f1": {}, - "root/b/f2": {}, - "root/c/f3": {}, - }} + 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), + ) + }) }) - It("reads all entries", func() { - dir, _ := fsys.Open("root") - entries := fullReadDir(ctx, dir.(fs.ReadDirFile)) - Expect(entries).To(HaveLen(3)) - Expect(entries[0].Name()).To(Equal("a")) - Expect(entries[1].Name()).To(Equal("b")) - Expect(entries[2].Name()).To(Equal("c")) + + 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), + ) }) - It("skips entries with permission error", func() { - fsys.failOn = "b" - dir, _ := fsys.Open("root") - entries := fullReadDir(ctx, dir.(fs.ReadDirFile)) - Expect(entries).To(HaveLen(2)) - Expect(entries[0].Name()).To(Equal("a")) - Expect(entries[1].Name()).To(Equal("c")) - }) - It("aborts if it keeps getting 'readdirent: no such file or directory'", func() { - fsys.err = fs.ErrNotExist - dir, _ := fsys.Open("root") - entries := fullReadDir(ctx, dir.(fs.ReadDirFile)) - Expect(entries).To(BeEmpty()) + + 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{}), + ) }) }) }) @@ -205,11 +247,54 @@ func getDirEntry(baseDir, name string) os.DirEntry { 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) { - return m.FS.Open(name) + 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") }