mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-18 07:30:29 +00:00
* 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.
554 lines
17 KiB
Go
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
|
|
}
|