From f61d940d68799190e992fd817fc6666039d1075c Mon Sep 17 00:00:00 2001 From: junkerderprovinz Date: Fri, 9 Oct 2026 21:04:02 +0200 Subject: [PATCH] fix(scanner): skip targeted folders reached through a symlink cycle Creating playlists/music -> .. makes the watcher scan the link itself, which walked the whole library again under playlists/music. A full walk never enters such a link, so a targeted one skips it too. Signed-off-by: junkerderprovinz --- scanner/walk_dir_tree.go | 27 ++++++++++++++++++++------- scanner/walk_dir_tree_test.go | 13 +++++++++++++ 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/scanner/walk_dir_tree.go b/scanner/walk_dir_tree.go index b93438a82..119edf7a4 100644 --- a/scanner/walk_dir_tree.go +++ b/scanner/walk_dir_tree.go @@ -50,6 +50,15 @@ func walkDirTree(ctx context.Context, job *scanJob, targetFolders ...string) (<- continue } + // Likewise a full walk never enters a link back into the folders above it, so a target + // reached through one (e.g. a watcher event for playlists/music -> ..) is skipped. + dir := newDirRef(job.fs, folderPath) + ancestors, ok := ancestorsOf(job.fs, dir) + if !ok { + log.Debug(ctx, "Scanner: Skipping target folder reached through a symlink cycle", "path", folderPath) + continue + } + // Create checker and push patterns from root to this folder checker := newIgnoreChecker(job.fs) err = checker.PushAllParents(ctx, folderPath) @@ -59,7 +68,7 @@ func walkDirTree(ctx context.Context, job *scanJob, targetFolders ...string) (<- } // Recursively walk this folder and all its children - err = walkFolder(ctx, job, newDirRef(job.fs, folderPath), checker, results, ancestorsOf(job.fs, folderPath)) + err = walkFolder(ctx, job, dir, checker, results, ancestors) if utils.IsCtxDone(ctx) { return } @@ -272,15 +281,19 @@ func newDirRef(fsys fs.FS, dirPath string) dirRef { return dir } -// ancestorsOf returns the resolved paths of the folders above folderPath up to the library -// root, so walking only folderPath catches links back into them as a full walk would. -func ancestorsOf(fsys fs.FS, folderPath string) map[string]struct{} { +// ancestorsOf returns the resolved paths of the folders above dir up to the library root, or +// false if dir is only reachable through a link back into one of them. +func ancestorsOf(fsys fs.FS, dir dirRef) (map[string]struct{}, bool) { ancestors := map[string]struct{}{} - for p := path.Clean(folderPath); p != "." && p != "/"; { + for p := path.Clean(dir.path); p != "." && p != "/"; { p = path.Dir(p) - ancestors[newDirRef(fsys, p).realPath] = struct{}{} + realPath := newDirRef(fsys, p).realPath + if _, seen := ancestors[realPath]; seen || realPath == dir.realPath { + return nil, false + } + ancestors[realPath] = struct{}{} } - return ancestors + return ancestors, true } // newChildDirRef returns the reference for a subfolder of dir. Only symlinks need resolving, diff --git a/scanner/walk_dir_tree_test.go b/scanner/walk_dir_tree_test.go index 8dcfb7f0a..7a3f909b2 100644 --- a/scanner/walk_dir_tree_test.go +++ b/scanner/walk_dir_tree_test.go @@ -251,6 +251,15 @@ var _ = Describe("walk_dir_tree", func() { Expect(slices.Collect(maps.Keys(folders))).To(ConsistOf("playlists")) }) + It("skips a target folder that is a symlink back to the library root", func() { + folders := walk(fstest.MapFS{ + "artist/album/track1.mp3": {}, + "playlists/music": {Mode: fs.ModeSymlink, Data: []byte("..")}, + }, "playlists/music") + + Expect(folders).To(BeEmpty()) + }) + // localFS resolves symlinks at the OS level instead of through fs.ReadLink Context("production local storage FS", func() { var musicFS storage.MusicFS @@ -289,6 +298,10 @@ var _ = Describe("walk_dir_tree", func() { Expect(slices.Collect(maps.Keys(folders))).To(ConsistOf("playlists")) Expect(folders["playlists"].playlistFiles).To(HaveKey("best.m3u")) }) + + It("skips target folders reached through the symlink", func() { + Expect(walkFS(musicFS, libRoot, "playlists/music", "playlists/music/artist")).To(BeEmpty()) + }) }) })