navidrome/core/storage/local/local_test.go
Deluan Quintão 4b60b21316
fix(scanner): read file birth time via statx on Linux (#6046)
* fix(scanner): read file birth time via statx on Linux

On Linux the file birth time is only reachable through statx(2). We were
reading it with times.Get(), which looks only at the plain stat() result,
where the field does not exist: djherbis/times declares HasBirthTime=false
for Linux, so the check was always false and every file fell back to
time.Now(). This has been the case since #2553 introduced the feature, which
means that PR was a no-op on Linux from day one. macOS and Windows were
never affected, as there the birth time does come back from plain stat.

BirthTime() now tries times.Get() first, which costs no syscall and is
already correct on macOS, Windows and BSD, and only falls back to
times.Stat() on the path when that comes back empty. Ordering matters: on
Windows times.Stat() opens the file asking for FILE_WRITE_ATTRIBUTES, which
fails on a read-only share before falling back.

Not every filesystem stores a birth time. Measured with a probe over real
mounts: ext4, SMB/CIFS and mergerfs report one, while NFS and rclone/FUSE
never do. Asking those on every file is pure overhead, so a miss is
remembered per device on the localFS and skipped from then on. The memo is
keyed by device rather than by library, so a library spanning two mounts
does not lose birth times on the mount that does support them.

Cost of the extra call is ~2us per file against ~52us just to open a file
for tag reading, so 0.23s across a 97k-file library, and only for files
whose tags are actually read.

Existing rows keep their current birth_time: the repository drops that
column on update, so only newly added files get the real value.

* fix(scanner): return the device id opaquely to satisfy unconvert

st.Dev is uint64 on Linux and int32 on darwin, so a uint64() cast is
redundant on one and required on the other. Returning it as an opaque value
drops the cast entirely, which also removes the gosec suppression that came
with it. The value is only ever used as a sync.Map key.
2026-08-29 16:36:08 -04:00

554 lines
17 KiB
Go

package local
import (
"io/fs"
"net/url"
"os"
"path/filepath"
"runtime"
"sync"
"time"
"github.com/djherbis/times"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/storage"
"github.com/navidrome/navidrome/model/metadata"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("LocalStorage", func() {
var tempDir string
var testExtractor *mockTestExtractor
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
// Create a temporary directory for testing
var err error
tempDir, err = os.MkdirTemp("", "navidrome-local-storage-test-")
Expect(err).ToNot(HaveOccurred())
DeferCleanup(func() {
os.RemoveAll(tempDir)
})
// Create and register a test extractor
testExtractor = &mockTestExtractor{
results: make(map[string]metadata.Info),
}
RegisterExtractor("test", func(fs.FS, string) Extractor {
return testExtractor
})
conf.Server.Scanner.Extractor = "test"
})
Describe("newLocalStorage", func() {
Context("with valid path", func() {
It("should create a localStorage instance with correct path", func() {
u, err := storage.LocalPathToURL(tempDir)
Expect(err).ToNot(HaveOccurred())
storage := newLocalStorage(u)
localStorage := storage.(*localStorage)
Expect(localStorage.u.Scheme).To(Equal("file"))
// Check that the path is set correctly (could be resolved to real path on macOS)
Expect(localStorage.u.Path).To(ContainSubstring("navidrome-local-storage-test"))
Expect(localStorage.resolvedPath).To(ContainSubstring("navidrome-local-storage-test"))
Expect(localStorage.extractor).ToNot(BeNil())
})
It("should handle URL-decoded paths correctly", func() {
// Create a directory with spaces to test URL decoding
spacedDir := filepath.Join(tempDir, "test folder")
err := os.MkdirAll(spacedDir, 0755)
Expect(err).ToNot(HaveOccurred())
// Use proper URL construction instead of manual escaping
u := &url.URL{
Scheme: "file",
Path: spacedDir,
}
storage := newLocalStorage(*u)
localStorage, ok := storage.(*localStorage)
Expect(ok).To(BeTrue())
Expect(localStorage.u.Path).To(Equal(spacedDir))
})
It("should resolve symlinks when possible", func() {
// Create a real directory and a symlink to it
realDir := filepath.Join(tempDir, "real")
linkDir := filepath.Join(tempDir, "link")
err := os.MkdirAll(realDir, 0755)
Expect(err).ToNot(HaveOccurred())
err = os.Symlink(realDir, linkDir)
Expect(err).ToNot(HaveOccurred())
u, err := storage.LocalPathToURL(linkDir)
Expect(err).ToNot(HaveOccurred())
storage := newLocalStorage(u)
localStorage, ok := storage.(*localStorage)
Expect(ok).To(BeTrue())
Expect(localStorage.u.Path).To(Equal(linkDir))
// Check that the resolved path contains the real directory name
Expect(localStorage.resolvedPath).To(ContainSubstring("real"))
})
It("should use u.Path as resolvedPath when symlink resolution fails", func() {
// Use a non-existent path to trigger symlink resolution failure
nonExistentPath := filepath.Join(tempDir, "non-existent")
u, err := storage.LocalPathToURL(nonExistentPath)
Expect(err).ToNot(HaveOccurred())
storage := newLocalStorage(u)
localStorage, ok := storage.(*localStorage)
Expect(ok).To(BeTrue())
Expect(localStorage.u.Path).To(Equal(nonExistentPath))
Expect(localStorage.resolvedPath).To(Equal(nonExistentPath))
})
})
Context("with Windows path", func() {
BeforeEach(func() {
if runtime.GOOS != "windows" {
Skip("Windows-specific test")
}
})
It("should handle Windows drive letters correctly", func() {
u, err := url.Parse("file://C:/music")
Expect(err).ToNot(HaveOccurred())
storage := newLocalStorage(*u)
localStorage, ok := storage.(*localStorage)
Expect(ok).To(BeTrue())
// newLocalStorage re-joins the drive letter (u.Host) with u.Path via
// filepath.Join, which yields an OS-native (backslash) path on Windows.
Expect(localStorage.u.Path).To(Equal(filepath.Join("C:", "/music")))
})
})
Context("when the configured extractor is not registered", func() {
var defaultExtractor *mockTestExtractor
BeforeEach(func() {
defaultExtractor = &mockTestExtractor{results: make(map[string]metadata.Info)}
RegisterExtractor(consts.DefaultScannerExtractor, func(fs.FS, string) Extractor {
return defaultExtractor
})
DeferCleanup(func() {
lock.Lock()
delete(extractors, consts.DefaultScannerExtractor)
lock.Unlock()
})
})
It("falls back to the default extractor instead of crashing", func() {
conf.Server.Scanner.Extractor = "nonexistent-extractor"
u, err := storage.LocalPathToURL(tempDir)
Expect(err).ToNot(HaveOccurred())
storage := newLocalStorage(u)
ls, ok := storage.(*localStorage)
Expect(ok).To(BeTrue())
Expect(ls.extractor).To(BeIdenticalTo(defaultExtractor))
})
})
})
Describe("localStorage.FS", func() {
Context("with existing directory", func() {
It("should return a localFS instance", func() {
u, err := storage.LocalPathToURL(tempDir)
Expect(err).ToNot(HaveOccurred())
storage := newLocalStorage(u)
musicFS, err := storage.FS()
Expect(err).ToNot(HaveOccurred())
Expect(musicFS).ToNot(BeNil())
_, ok := musicFS.(*localFS)
Expect(ok).To(BeTrue())
})
})
Context("with non-existent directory", func() {
It("should return an error", func() {
nonExistentPath := filepath.Join(tempDir, "non-existent")
u, err := storage.LocalPathToURL(nonExistentPath)
Expect(err).ToNot(HaveOccurred())
storage := newLocalStorage(u)
_, err = storage.FS()
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring(nonExistentPath))
})
})
})
Describe("localFS.ResolveSymlink", func() {
var musicFS storage.MusicFS
BeforeEach(func() {
if runtime.GOOS == "windows" {
Skip("symlink semantics")
}
u, err := storage.LocalPathToURL(tempDir)
Expect(err).ToNot(HaveOccurred())
musicFS, err = newLocalStorage(u).FS()
Expect(err).ToNot(HaveOccurred())
})
It("implements storage.SymlinkResolverFS", func() {
_, ok := musicFS.(storage.SymlinkResolverFS)
Expect(ok).To(BeTrue())
})
It("resolves a chain that leaves the library folder to its final target", func() {
outside, err := os.MkdirTemp("", "navidrome-symlink-outside-")
Expect(err).ToNot(HaveOccurred())
DeferCleanup(func() { os.RemoveAll(outside) })
target := filepath.Join(outside, "final.txt")
Expect(os.WriteFile(target, []byte("data"), 0600)).To(Succeed())
mid := filepath.Join(outside, "mid.wav")
Expect(os.Symlink(target, mid)).To(Succeed())
Expect(os.Symlink(mid, filepath.Join(tempDir, "link.wav"))).To(Succeed())
resolved, err := musicFS.(storage.SymlinkResolverFS).ResolveSymlink("link.wav")
Expect(err).ToNot(HaveOccurred())
expected, err := filepath.EvalSymlinks(target)
Expect(err).ToNot(HaveOccurred())
Expect(resolved).To(Equal(expected))
})
It("resolves entries in subfolders (slash-separated fs paths)", func() {
Expect(os.MkdirAll(filepath.Join(tempDir, "sub"), 0755)).To(Succeed())
target := filepath.Join(tempDir, "real.mp3")
Expect(os.WriteFile(target, []byte("audio"), 0600)).To(Succeed())
Expect(os.Symlink(target, filepath.Join(tempDir, "sub", "link.mp3"))).To(Succeed())
resolved, err := musicFS.(storage.SymlinkResolverFS).ResolveSymlink("sub/link.mp3")
Expect(err).ToNot(HaveOccurred())
expected, err := filepath.EvalSymlinks(target)
Expect(err).ToNot(HaveOccurred())
Expect(resolved).To(Equal(expected))
})
It("returns an error for a broken symlink", func() {
Expect(os.Symlink(filepath.Join(tempDir, "missing.mp3"), filepath.Join(tempDir, "broken.mp3"))).To(Succeed())
_, err := musicFS.(storage.SymlinkResolverFS).ResolveSymlink("broken.mp3")
Expect(err).To(HaveOccurred())
})
It("rejects names that are not valid fs paths", func() {
for _, name := range []string{"../outside.mp3", "/etc/hosts", "sub/../../outside.mp3", ""} {
_, err := musicFS.(storage.SymlinkResolverFS).ResolveSymlink(name)
Expect(err).To(MatchError(fs.ErrInvalid), name)
}
})
It("returns an error for a symlink loop", func() {
Expect(os.Symlink(filepath.Join(tempDir, "loop2.mp3"), filepath.Join(tempDir, "loop1.mp3"))).To(Succeed())
Expect(os.Symlink(filepath.Join(tempDir, "loop1.mp3"), filepath.Join(tempDir, "loop2.mp3"))).To(Succeed())
_, err := musicFS.(storage.SymlinkResolverFS).ResolveSymlink("loop1.mp3")
Expect(err).To(HaveOccurred())
})
})
Describe("localFS.ReadTags", func() {
var testFile string
BeforeEach(func() {
// Create a test file
testFile = filepath.Join(tempDir, "test.mp3")
err := os.WriteFile(testFile, []byte("test data"), 0600)
Expect(err).ToNot(HaveOccurred())
// Reset extractor state
testExtractor.results = make(map[string]metadata.Info)
testExtractor.err = nil
})
Context("when extractor returns complete metadata", func() {
It("should return the metadata as-is", func() {
expectedInfo := metadata.Info{
Tags: map[string][]string{
"title": {"Test Song"},
"artist": {"Test Artist"},
},
AudioProperties: metadata.AudioProperties{
Duration: 180,
BitRate: 320,
},
FileInfo: &testFileInfo{name: "test.mp3"},
}
testExtractor.results["test.mp3"] = expectedInfo
u, err := storage.LocalPathToURL(tempDir)
Expect(err).ToNot(HaveOccurred())
storage := newLocalStorage(u)
musicFS, err := storage.FS()
Expect(err).ToNot(HaveOccurred())
results, err := musicFS.ReadTags("test.mp3")
Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveKey("test.mp3"))
Expect(results["test.mp3"]).To(Equal(expectedInfo))
})
})
Context("when extractor returns metadata without FileInfo", func() {
It("should populate FileInfo from filesystem", func() {
incompleteInfo := metadata.Info{
Tags: map[string][]string{
"title": {"Test Song"},
},
FileInfo: nil, // Missing FileInfo
}
testExtractor.results["test.mp3"] = incompleteInfo
u, err := storage.LocalPathToURL(tempDir)
Expect(err).ToNot(HaveOccurred())
storage := newLocalStorage(u)
musicFS, err := storage.FS()
Expect(err).ToNot(HaveOccurred())
results, err := musicFS.ReadTags("test.mp3")
Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveKey("test.mp3"))
result := results["test.mp3"]
Expect(result.FileInfo).ToNot(BeNil())
Expect(result.FileInfo.Name()).To(Equal("test.mp3"))
// Should be wrapped in localFileInfo
_, ok := result.FileInfo.(localFileInfo)
Expect(ok).To(BeTrue())
})
})
Context("when filesystem stat fails", func() {
It("should return an error", func() {
incompleteInfo := metadata.Info{
Tags: map[string][]string{"title": {"Test Song"}},
FileInfo: nil,
}
testExtractor.results["non-existent.mp3"] = incompleteInfo
u, err := storage.LocalPathToURL(tempDir)
Expect(err).ToNot(HaveOccurred())
storage := newLocalStorage(u)
musicFS, err := storage.FS()
Expect(err).ToNot(HaveOccurred())
_, err = musicFS.ReadTags("non-existent.mp3")
Expect(err).To(HaveOccurred())
})
})
Context("when extractor fails", func() {
It("should return the extractor error", func() {
testExtractor.err = &extractorError{message: "extractor failed"}
u, err := storage.LocalPathToURL(tempDir)
Expect(err).ToNot(HaveOccurred())
storage := newLocalStorage(u)
musicFS, err := storage.FS()
Expect(err).ToNot(HaveOccurred())
_, err = musicFS.ReadTags("test.mp3")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("extractor failed"))
})
})
Context("with multiple files", func() {
It("should process all files correctly", func() {
// Create another test file
testFile2 := filepath.Join(tempDir, "test2.mp3")
err := os.WriteFile(testFile2, []byte("test data 2"), 0600)
Expect(err).ToNot(HaveOccurred())
info1 := metadata.Info{
Tags: map[string][]string{"title": {"Song 1"}},
FileInfo: &testFileInfo{name: "test.mp3"},
}
info2 := metadata.Info{
Tags: map[string][]string{"title": {"Song 2"}},
FileInfo: nil, // This one needs FileInfo populated
}
testExtractor.results["test.mp3"] = info1
testExtractor.results["test2.mp3"] = info2
u, err := storage.LocalPathToURL(tempDir)
Expect(err).ToNot(HaveOccurred())
storage := newLocalStorage(u)
musicFS, err := storage.FS()
Expect(err).ToNot(HaveOccurred())
results, err := musicFS.ReadTags("test.mp3", "test2.mp3")
Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(2))
Expect(results["test.mp3"].FileInfo).To(Equal(&testFileInfo{name: "test.mp3"}))
Expect(results["test2.mp3"].FileInfo).ToNot(BeNil())
Expect(results["test2.mp3"].FileInfo.Name()).To(Equal("test2.mp3"))
})
})
})
Describe("localFileInfo", func() {
var testFile string
var fileInfo fs.FileInfo
BeforeEach(func() {
testFile = filepath.Join(tempDir, "test.mp3")
err := os.WriteFile(testFile, []byte("test data"), 0600)
Expect(err).ToNot(HaveOccurred())
fileInfo, err = os.Stat(testFile)
Expect(err).ToNot(HaveOccurred())
})
Describe("BirthTime", func() {
It("should return birth time when available", func() {
lfi := localFileInfo{FileInfo: fileInfo}
birthTime := lfi.BirthTime()
// Birth time should be a valid time (not zero value)
Expect(birthTime).ToNot(BeZero())
// Should be around the current time (within last few minutes)
Expect(birthTime).To(BeTemporally("~", time.Now(), 5*time.Minute))
})
It("reads the birth time from the path, not the time of the call", func() {
// On Linux, birth time is only available via statx(2) on the path.
lfi := localFileInfo{FileInfo: fileInfo, path: testFile}
time.Sleep(300 * time.Millisecond)
Expect(lfi.BirthTime()).To(BeTemporally("<", time.Now().Add(-200*time.Millisecond)))
})
It("does not remember filesystems that do report a birth time", func() {
memo := &sync.Map{}
lfi := localFileInfo{FileInfo: fileInfo, path: testFile, noBirthTime: memo}
lfi.BirthTime()
count := 0
memo.Range(func(_, _ any) bool { count++; return true })
Expect(count).To(BeZero())
})
It("skips statx on filesystems already known to have none", func() {
if times.Get(fileInfo).HasBirthTime() {
Skip("this platform reports birth time from FileInfo, so statx is never called")
}
dev, ok := deviceID(fileInfo)
Expect(ok).To(BeTrue())
memo := &sync.Map{}
memo.Store(dev, struct{}{})
lfi := localFileInfo{FileInfo: fileInfo, path: testFile, noBirthTime: memo}
time.Sleep(300 * time.Millisecond)
Expect(lfi.BirthTime()).To(BeTemporally("~", time.Now(), 100*time.Millisecond))
})
})
It("should delegate all other FileInfo methods", func() {
lfi := localFileInfo{FileInfo: fileInfo}
Expect(lfi.Name()).To(Equal(fileInfo.Name()))
Expect(lfi.Size()).To(Equal(fileInfo.Size()))
Expect(lfi.Mode()).To(Equal(fileInfo.Mode()))
Expect(lfi.ModTime()).To(Equal(fileInfo.ModTime()))
Expect(lfi.IsDir()).To(Equal(fileInfo.IsDir()))
Expect(lfi.Sys()).To(Equal(fileInfo.Sys()))
})
})
Describe("Storage registration", func() {
It("should register localStorage for file scheme", func() {
// This tests the init() function indirectly
storage, err := storage.For(tempDir)
Expect(err).ToNot(HaveOccurred())
Expect(storage).To(BeAssignableToTypeOf(&localStorage{}))
})
})
})
// Test extractor for testing
type mockTestExtractor struct {
results map[string]metadata.Info
err error
}
func (m *mockTestExtractor) Parse(files ...string) (map[string]metadata.Info, error) {
if m.err != nil {
return nil, m.err
}
result := make(map[string]metadata.Info)
for _, file := range files {
if info, exists := m.results[file]; exists {
result[file] = info
}
}
return result, nil
}
func (m *mockTestExtractor) Version() string {
return "test-1.0"
}
type extractorError struct {
message string
}
func (e *extractorError) Error() string {
return e.message
}
// Test FileInfo that implements metadata.FileInfo
type testFileInfo struct {
name string
size int64
mode fs.FileMode
modTime time.Time
isDir bool
birthTime time.Time
}
func (t *testFileInfo) Name() string { return t.name }
func (t *testFileInfo) Size() int64 { return t.size }
func (t *testFileInfo) Mode() fs.FileMode { return t.mode }
func (t *testFileInfo) ModTime() time.Time { return t.modTime }
func (t *testFileInfo) IsDir() bool { return t.isDir }
func (t *testFileInfo) Sys() any { return nil }
func (t *testFileInfo) BirthTime() time.Time {
if t.birthTime.IsZero() {
return time.Now()
}
return t.birthTime
}