mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-08 02:17:25 +02:00
A symlink pointing at a folder that is already part of the current branch of the walk (e.g. `music/tracks/music -> ..`) made the scanner walk the same folders again and again, re-adding the same files until the OS hit its recursion or path limits. Each folder now carries its resolved path, with symlinks followed, and the walk keeps the resolved paths of the branch it is currently in. A child whose resolved path is already in that branch is a cycle and is skipped. The set is per branch rather than global, so two sibling folders linking to the same target are both walked: that is the same folder reached twice, not a loop. Resolution uses the storage's own resolver where the filesystem is backed by a real one, and falls back to following the link chain with fs.ReadLink, which keeps the comparison inside the FS-relative path space. The tests give the walk a deadline and a folder cap, because an undetected cycle does not fail, it runs forever. Verified both ways: green with the detection, and failing on the deadline without it. Fixes #5334 Signed-off-by: Junker der Provinz <133605895+junkerderprovinz@users.noreply.github.com>
908 lines
30 KiB
Go
908 lines
30 KiB
Go
package scanner
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io/fs"
|
|
"maps"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"testing/fstest"
|
|
"time"
|
|
|
|
"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"},
|
|
}
|
|
})
|
|
|
|
// 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"))
|
|
|
|
// By default (Scanner.IgnoreDotFolders == true), dot-prefixed
|
|
// folders are skipped, dot-prefixed files are not indexed, and
|
|
// the special ignoredDirs (.git, .streams) are never traversed.
|
|
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"))
|
|
|
|
// Symlink specific checks
|
|
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()
|
|
|
|
// Dot-prefixed album folders are now traversed and indexed
|
|
Expect(folders["root/g/.Hack Sign Original Soundtrack"].audioFiles).To(SatisfyAll(
|
|
HaveLen(1),
|
|
HaveKey("track.mp3"),
|
|
))
|
|
|
|
// Dot-prefixed files are still ignored, even with the flag off
|
|
Expect(folders["root/h"].audioFiles).To(BeEmpty())
|
|
|
|
// Special ignoredDirs remain blocked regardless of the flag
|
|
Expect(folders).ToNot(HaveKey("root/i/.git"))
|
|
Expect(folders).ToNot(HaveKey("root/i/.streams"))
|
|
},
|
|
Entry("with symlinks enabled", true),
|
|
Entry("with symlinks disabled", false),
|
|
)
|
|
})
|
|
|
|
// Regression tests for #5334: a symlink pointing back into a folder that is already
|
|
// being walked made the scanner walk the same folders over and over, re-adding the
|
|
// same files until the OS hit its recursion/path limits.
|
|
Context("with symlink cycles", func() {
|
|
// A cycle that is not detected makes the walk run forever, so it gets a deadline
|
|
// and a hard cap on the number of folders. All layouts below are small enough to
|
|
// be walked instantly, and none of them has more folders than the cap.
|
|
const walkTimeout = 3 * time.Second
|
|
const maxFolders = 25
|
|
|
|
walkFS := func(musicFS storage.MusicFS, libPath string) map[string]*folderEntry {
|
|
job := &scanJob{fs: musicFS, lib: model.Library{Path: libPath}}
|
|
ctx, cancel := context.WithTimeout(GinkgoT().Context(), walkTimeout)
|
|
defer cancel()
|
|
|
|
results, err := walkDirTree(ctx, job)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
folders := map[string]*folderEntry{}
|
|
for folder := range results {
|
|
folders[folder.path] = folder
|
|
if len(folders) >= maxFolders {
|
|
cancel()
|
|
}
|
|
}
|
|
Expect(ctx.Err()).ToNot(Equal(context.DeadlineExceeded), "the walk never finished: symlink cycle not detected")
|
|
return folders
|
|
}
|
|
|
|
walk := func(mapFS fstest.MapFS) map[string]*folderEntry {
|
|
return walkFS(&mockMusicFS{FS: mapFS}, "/music")
|
|
}
|
|
|
|
BeforeEach(func() {
|
|
DeferCleanup(configtest.SetupConfig())
|
|
conf.Server.Scanner.FollowSymlinks = true
|
|
})
|
|
|
|
It("skips a symlink pointing to the parent folder", func() {
|
|
folders := walk(fstest.MapFS{
|
|
"music/track1.mp3": {},
|
|
"music/tracks/track2.mp3": {},
|
|
"music/tracks/music": {Mode: fs.ModeSymlink, Data: []byte("..")},
|
|
})
|
|
|
|
Expect(slices.Collect(maps.Keys(folders))).To(ConsistOf(".", "music", "music/tracks"))
|
|
Expect(folders["music/tracks"].audioFiles).To(HaveLen(1))
|
|
})
|
|
|
|
It("skips a symlink pointing to its own folder", func() {
|
|
folders := walk(fstest.MapFS{
|
|
"music/track1.mp3": {},
|
|
"music/music": {Mode: fs.ModeSymlink, Data: []byte(".")},
|
|
})
|
|
|
|
Expect(slices.Collect(maps.Keys(folders))).To(ConsistOf(".", "music"))
|
|
Expect(folders["music"].audioFiles).To(HaveLen(1))
|
|
})
|
|
|
|
It("skips a symlink closing a cycle between two folders", func() {
|
|
folders := walk(fstest.MapFS{
|
|
"lib/a/track1.mp3": {},
|
|
"lib/a/toB": {Mode: fs.ModeSymlink, Data: []byte("../b")},
|
|
"lib/b/track2.mp3": {},
|
|
"lib/b/toA": {Mode: fs.ModeSymlink, Data: []byte("../a")},
|
|
})
|
|
|
|
Expect(slices.Collect(maps.Keys(folders))).To(ConsistOf(".", "lib", "lib/a", "lib/a/toB", "lib/b", "lib/b/toA"))
|
|
})
|
|
|
|
It("still follows a symlink to a folder outside the current branch", func() {
|
|
folders := walk(fstest.MapFS{
|
|
"lib/real/track1.mp3": {},
|
|
"lib/alias": {Mode: fs.ModeSymlink, Data: []byte("real")},
|
|
})
|
|
|
|
Expect(slices.Collect(maps.Keys(folders))).To(ConsistOf(".", "lib", "lib/real", "lib/alias"))
|
|
Expect(folders["lib/alias"].audioFiles).To(HaveKey("track1.mp3"))
|
|
})
|
|
|
|
It("does not follow the cycle when symlinks are disabled", func() {
|
|
conf.Server.Scanner.FollowSymlinks = false
|
|
folders := walk(fstest.MapFS{
|
|
"music/track1.mp3": {},
|
|
"music/tracks/track2.mp3": {},
|
|
"music/tracks/music": {Mode: fs.ModeSymlink, Data: []byte("..")},
|
|
})
|
|
|
|
Expect(slices.Collect(maps.Keys(folders))).To(ConsistOf(".", "music", "music/tracks"))
|
|
})
|
|
|
|
// The production localFS resolves symlinks at the OS level, a different code path
|
|
// than the in-memory filesystem used by the specs above.
|
|
Context("production local storage FS", func() {
|
|
var musicFS storage.MusicFS
|
|
var libRoot string
|
|
|
|
BeforeEach(func() {
|
|
tests.SkipOnWindows("symlink semantics")
|
|
|
|
// The layout reported in #5334: a subfolder linking back to the library root
|
|
libRoot = filepath.Join(GinkgoT().TempDir(), "music")
|
|
Expect(os.MkdirAll(filepath.Join(libRoot, "tracks"), 0755)).To(Succeed())
|
|
Expect(os.WriteFile(filepath.Join(libRoot, "track1.mp3"), []byte("AUDIO"), 0600)).To(Succeed())
|
|
Expect(os.WriteFile(filepath.Join(libRoot, "tracks", "track2.mp3"), []byte("AUDIO"), 0600)).To(Succeed())
|
|
Expect(os.Symlink("..", filepath.Join(libRoot, "tracks", "music"))).To(Succeed())
|
|
|
|
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())
|
|
})
|
|
|
|
It("skips a symlink pointing back into the library", func() {
|
|
folders := walkFS(musicFS, libRoot)
|
|
|
|
Expect(slices.Collect(maps.Keys(folders))).To(ConsistOf(".", "tracks"))
|
|
Expect(folders["."].audioFiles).To(HaveKey("track1.mp3"))
|
|
Expect(folders["tracks"].audioFiles).To(HaveKey("track2.mp3"))
|
|
})
|
|
})
|
|
})
|
|
|
|
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()
|
|
|
|
// Should include the target folder and all its descendants
|
|
Expect(folders).To(SatisfyAll(
|
|
HaveKey("Artist"),
|
|
HaveKey("Artist/Album1"),
|
|
HaveKey("Artist/Album2"),
|
|
HaveKey("Artist/Album2/Sub"),
|
|
))
|
|
|
|
// Should not include folders outside the target
|
|
Expect(folders).ToNot(HaveKey("OtherArtist"))
|
|
Expect(folders).ToNot(HaveKey("OtherArtist/Album3"))
|
|
|
|
// Verify audio files are present
|
|
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()
|
|
|
|
// Should include both target folders and their descendants
|
|
Expect(folders).To(SatisfyAll(
|
|
HaveKey("Artist/Album1"),
|
|
HaveKey("OtherArtist"),
|
|
HaveKey("OtherArtist/Album3"),
|
|
))
|
|
|
|
// Should not include other folders
|
|
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() {
|
|
// Setup job with lastUpdates for both existing and non-existing folders
|
|
job.lastUpdates = map[string]model.FolderUpdateInfo{
|
|
model.FolderID(job.lib, "Artist/Album1"): {},
|
|
model.FolderID(job.lib, "NonExistent/DeletedFolder"): {},
|
|
model.FolderID(job.lib, "OtherArtist/Album3"): {},
|
|
}
|
|
|
|
// Try to scan existing folder and non-existing folder
|
|
results, err := walkDirTree(ctx, job, "Artist/Album1", "NonExistent/DeletedFolder")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Collect results
|
|
folders := map[string]struct{}{}
|
|
for folder := range results {
|
|
folders[folder.path] = struct{}{}
|
|
}
|
|
|
|
// Should only include the existing folder
|
|
Expect(folders).To(HaveKey("Artist/Album1"))
|
|
Expect(folders).ToNot(HaveKey("NonExistent/DeletedFolder"))
|
|
|
|
// The non-existent folder should still be in lastUpdates (not removed by popLastUpdate)
|
|
Expect(job.lastUpdates).To(HaveKey(model.FolderID(job.lib, "NonExistent/DeletedFolder")))
|
|
|
|
// The existing folder should have been removed from lastUpdates
|
|
Expect(job.lastUpdates).ToNot(HaveKey(model.FolderID(job.lib, "Artist/Album1")))
|
|
|
|
// Folders not in targets should remain in lastUpdates
|
|
Expect(job.lastUpdates).To(HaveKey(model.FolderID(job.lib, "OtherArtist/Album3")))
|
|
})
|
|
})
|
|
})
|
|
|
|
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() {
|
|
// tests.Init chdirs to the repo root, so the committed fixtures are at "tests/fixtures".
|
|
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())
|
|
})
|
|
})
|
|
|
|
// Regression for #5752: the production localFS must resolve file symlinks.
|
|
// It wraps os.DirFS behind the fs.FS interface, so fs.ReadLink-based
|
|
// resolution is not available and full OS-level resolution is required.
|
|
Context("production local storage FS", func() {
|
|
var libRoot string
|
|
var musicFS storage.MusicFS
|
|
|
|
BeforeEach(func() {
|
|
conf.Server.Scanner.FollowSymlinks = true
|
|
|
|
// Reproduces the reported layout: a "pool" with the real files and a
|
|
// library containing only symlinks into the pool.
|
|
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())
|
|
// mid.wav lives OUTSIDE the library and has an audio name, but points at a
|
|
// non-audio file. A chain through it must be classified by the FINAL target.
|
|
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())
|
|
|
|
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())
|
|
})
|
|
|
|
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())
|
|
// evil.mp3 escapes to a non-audio target; legit.flac is a valid out-of-tree audio symlink.
|
|
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
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
|
|
// ReadLink returns the target of the named symbolic link (implements fs.ReadLinkFS).
|
|
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
|
|
}
|
|
|
|
// Lstat returns FileInfo for the named file without following symlinks (implements fs.ReadLinkFS).
|
|
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()
|
|
}
|