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 <jdp@braethoria.com>
This commit is contained in:
junkerderprovinz 2026-10-09 21:04:02 +02:00
parent 09249ed1c6
commit f61d940d68
2 changed files with 33 additions and 7 deletions

View File

@ -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,

View File

@ -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())
})
})
})