navidrome/utils/files_test.go
Deluan e42418db77 fix(windows): avoid filepath.Rel hang on UNC share roots
On Windows, filepath.Rel never returns when the base is a UNC share root
(\\host\share) and the target is the same root with a trailing separator
(golang/go#79784, fixed only in Go 1.28 by CL 786100). A user on Windows
Server with MusicFolder='\\server\Music' (discussion #6232) sent CPU
profiles showing 96% of CPU time in filepath.Rel, called from
fromArtistFolder <- resolveArtistFolderPattern <- resolver.resolveArtist.
For an album directly under the library root, loadArtistFolder's
filepath.Dir returns '\\server\Music\', so the artwork worker spins
forever and, because Worker.drain waits for the whole batch, the artist
artwork pool stops.

Add utils.RelPath, which normalizes a UNC volume root followed only by
separators to the volume plus one separator before calling filepath.Rel.
Both sides then compare equal and Rel returns '.' before reaching the
loop. A bare '\\host\share' target under a deeper base now also gives
'..' instead of an error. Drive-letter paths are left unchanged, and on
non-Windows systems the helper behaves exactly like filepath.Rel.

Use it at every production filepath.Rel call site: artist folder
artwork, scanner library-relative paths, the watcher, playlist path
resolution and PlaylistsPath matching, and the cache spread FS.

Windows-only tests cover the UNC cases, with a timeout so a regression
fails instead of hanging CI. Run under Wine, 6 of them fail with plain
filepath.Rel (4 by timeout) and all pass with RelPath. The helper can be
removed after moving to Go 1.28.
2026-09-27 15:25:19 -04:00

248 lines
7.2 KiB
Go

package utils_test
import (
"os"
"path/filepath"
"strings"
"github.com/navidrome/navidrome/utils"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("TempFileName", func() {
It("creates a temporary file name with prefix and suffix", func() {
prefix := "test-"
suffix := ".tmp"
result := utils.TempFileName(prefix, suffix)
Expect(result).To(ContainSubstring(prefix))
Expect(result).To(HaveSuffix(suffix))
Expect(result).To(ContainSubstring(os.TempDir()))
})
It("creates unique file names on multiple calls", func() {
prefix := "unique-"
suffix := ".test"
result1 := utils.TempFileName(prefix, suffix)
result2 := utils.TempFileName(prefix, suffix)
Expect(result1).NotTo(Equal(result2))
})
It("handles empty prefix and suffix", func() {
result := utils.TempFileName("", "")
Expect(result).To(ContainSubstring(os.TempDir()))
Expect(len(result)).To(BeNumerically(">", len(os.TempDir())))
})
It("creates proper file path separators", func() {
prefix := "path-test-"
suffix := ".ext"
result := utils.TempFileName(prefix, suffix)
expectedDir := os.TempDir()
Expect(result).To(HavePrefix(expectedDir))
Expect(strings.Count(result, string(filepath.Separator))).To(BeNumerically(">=", strings.Count(expectedDir, string(filepath.Separator))))
})
})
var _ = Describe("BaseName", func() {
It("extracts basename from a simple filename", func() {
result := utils.BaseName("test.mp3")
Expect(result).To(Equal("test"))
})
It("extracts basename from a file path", func() {
result := utils.BaseName("/path/to/file.txt")
Expect(result).To(Equal("file"))
})
It("handles files without extension", func() {
result := utils.BaseName("/path/to/filename")
Expect(result).To(Equal("filename"))
})
It("handles files with multiple dots", func() {
result := utils.BaseName("archive.tar.gz")
Expect(result).To(Equal("archive.tar"))
})
It("handles hidden files", func() {
// For hidden files without additional extension, path.Ext returns the entire name
// So basename becomes empty string after TrimSuffix
result := utils.BaseName(".hidden")
Expect(result).To(Equal(""))
})
It("handles hidden files with extension", func() {
result := utils.BaseName(".config.json")
Expect(result).To(Equal(".config"))
})
It("handles empty string", func() {
// The actual behavior returns empty string for empty input
result := utils.BaseName("")
Expect(result).To(Equal(""))
})
It("handles path ending with separator", func() {
result := utils.BaseName("/path/to/dir/")
Expect(result).To(Equal("dir"))
})
It("handles complex nested path", func() {
result := utils.BaseName("/very/long/path/to/my/favorite/song.mp3")
Expect(result).To(Equal("song"))
})
})
var _ = Describe("CleanFileName", func() {
It("lowercases and replaces spaces with underscores", func() {
Expect(utils.CleanFileName("My Cool Playlist")).To(Equal("my_cool_playlist"))
})
It("strips special characters", func() {
Expect(utils.CleanFileName("Rock & Roll! (2024)")).To(Equal("rock__roll_2024"))
})
It("handles unicode characters", func() {
Expect(utils.CleanFileName("Música Favorita")).To(Equal("msica_favorita"))
})
It("preserves hyphens", func() {
Expect(utils.CleanFileName("lo-fi beats")).To(Equal("lo-fi_beats"))
})
It("returns empty string for empty input", func() {
Expect(utils.CleanFileName("")).To(BeEmpty())
})
It("returns empty string for whitespace-only input", func() {
Expect(utils.CleanFileName(" ")).To(BeEmpty())
})
It("returns empty string when all characters are stripped", func() {
Expect(utils.CleanFileName("!!!@@@###")).To(BeEmpty())
})
It("truncates to 50 characters", func() {
long := strings.Repeat("abcdefghij", 10) // 100 chars
result := utils.CleanFileName(long)
Expect(len(result)).To(Equal(50))
})
It("trims trailing underscores and hyphens after truncation", func() {
// 49 a's + space + "b" = after clean: 49 a's + "_b" = 51 chars, truncated to 50 = 49 a's + "_"
name := strings.Repeat("a", 49) + " b"
result := utils.CleanFileName(name)
Expect(result).To(Equal(strings.Repeat("a", 49)))
})
})
var _ = Describe("FileExists", func() {
var tempFile *os.File
var tempDir string
BeforeEach(func() {
var err error
tempFile, err = os.CreateTemp("", "fileexists-test-*.txt")
Expect(err).NotTo(HaveOccurred())
tempDir, err = os.MkdirTemp("", "fileexists-test-dir-*")
Expect(err).NotTo(HaveOccurred())
})
AfterEach(func() {
if tempFile != nil {
os.Remove(tempFile.Name())
tempFile.Close()
}
if tempDir != "" {
os.RemoveAll(tempDir)
}
})
It("returns true for existing file", func() {
Expect(utils.FileExists(tempFile.Name())).To(BeTrue())
})
It("returns true for existing directory", func() {
Expect(utils.FileExists(tempDir)).To(BeTrue())
})
It("returns false for non-existing file", func() {
nonExistentPath := filepath.Join(tempDir, "does-not-exist.txt")
Expect(utils.FileExists(nonExistentPath)).To(BeFalse())
})
It("returns false for empty path", func() {
Expect(utils.FileExists("")).To(BeFalse())
})
It("handles nested non-existing path", func() {
nonExistentPath := "/this/path/definitely/does/not/exist/file.txt"
Expect(utils.FileExists(nonExistentPath)).To(BeFalse())
})
Context("when file is deleted after creation", func() {
It("returns false after file deletion", func() {
filePath := tempFile.Name()
Expect(utils.FileExists(filePath)).To(BeTrue())
// Close the file before removing it. On Windows, an open handle
// holds a file lock and os.Remove fails; closing first makes the
// test cross-platform.
Expect(tempFile.Close()).To(Succeed())
err := os.Remove(filePath)
Expect(err).NotTo(HaveOccurred())
tempFile = nil // Prevent cleanup attempt
Expect(utils.FileExists(filePath)).To(BeFalse())
})
})
Context("when directory is deleted after creation", func() {
It("returns false after directory deletion", func() {
dirPath := tempDir
Expect(utils.FileExists(dirPath)).To(BeTrue())
err := os.RemoveAll(dirPath)
Expect(err).NotTo(HaveOccurred())
tempDir = "" // Prevent cleanup attempt
Expect(utils.FileExists(dirPath)).To(BeFalse())
})
})
It("handles permission denied scenarios gracefully", func() {
// This test might be platform specific, but we test the general case
result := utils.FileExists("/root/.ssh/id_rsa") // Likely to not exist or be inaccessible
Expect(result).To(Or(BeTrue(), BeFalse())) // Should not panic
})
})
var _ = Describe("RelPath", func() {
DescribeTable("returns the same result as filepath.Rel",
func(base, target string) {
base, target = filepath.FromSlash(base), filepath.FromSlash(target)
expected, expectedErr := filepath.Rel(base, target)
rel, err := utils.RelPath(base, target)
Expect(rel).To(Equal(expected))
if expectedErr == nil {
Expect(err).ToNot(HaveOccurred())
} else {
Expect(err).To(MatchError(expectedErr.Error()))
}
},
Entry("same path", "/music", "/music"),
Entry("same path with trailing separator", "/music", "/music/"),
Entry("child path", "/music", "/music/artist/album"),
Entry("sibling path", "/music/a", "/music/b"),
Entry("parent path", "/music/artist", "/music"),
Entry("relative paths", "music", "music/artist"),
Entry("absolute and relative paths", "/music", "music"),
)
})