navidrome/utils/files_test.go
Deluan Quintão acd69f6a4f
feat(playlist): support #EXTALBUMARTURL directive and sidecar images (#5131)
* feat(playlist): add migration for playlist image field rename and external URL

* refactor(playlist): rename ImageFile to UploadedImage and ArtworkPath to UploadedImagePath

Rename playlist model fields and methods for clarity in preparation for
adding external image URL and sidecar image support. Add the new
ExternalImageURL field to the Playlist model.

* feat(playlist): parse #EXTALBUMARTURL directive in M3U imports

* feat(playlist): always sync ExternalImageURL on re-scan, preserve UploadedImage

* feat(artwork): add sidecar image discovery and cache invalidation for playlists

Add playlist sidecar image support to the artwork reader fallback chain.
A sidecar image (e.g., MyPlaylist.jpg next to MyPlaylist.m3u) is discovered
via case-insensitive base name matching using model.IsImageFile(). Cache
invalidation uses max(playlist.UpdatedAt, imageFile.ModTime()) to bust
stale artwork when sidecar or ExternalImageURL local files change.

* feat(artwork): add external image URL source to playlist artwork reader

Add fromPlaylistExternalImage source function that resolves playlist
cover art from ExternalImageURL, supporting both HTTP(S) URLs (via
the existing fromURL helper) and local file paths (via os.Open).
Insert it in the Reader() fallback chain between sidecar and tiled cover.

* refactor(artwork): simplify playlist artwork source functions

Extract shared fromLocalFile helper, use url.Parse for scheme check,
and collapse sidecar directory scan conditions.

* test(artwork): remove redundant fromPlaylistSidecar tests

These tests duplicated scenarios already covered by findPlaylistSidecarPath
tests combined with fromLocalFile (tested via fromPlaylistExternalImage).
After refactoring fromPlaylistSidecar to a one-liner composing those two
functions, the wrapper tests add no value.

* fix(playlist): address security review comments from PR #5131:

- Use url.PathUnescape instead of url.QueryUnescape for file:// URLs so
  that '+' in filenames is preserved (not decoded as space).
- Validate all local image paths (file://, absolute, relative) against
  known library boundaries via libraryMatcher, rejecting paths outside
  any configured library.
- Harden #EXTALBUMARTURL against path traversal and SSRF by adding EnableM3UExternalAlbumArt config flag (default false, also
  disabled by EnableExternalServices=false) to gate HTTP(S) URL storage
  at parse time and fetching at read time (defense in depth).
- Log a warning when os.ReadDir fails in findPlaylistSidecarPath for
  diagnosability.
- Extract resolveLocalPath helper to simplify resolveImageURL.

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(playlist): implement human-friendly filename generation for uploaded playlist cover images

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2026-03-02 11:39:59 -05:00

221 lines
6.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())
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
})
})