Repository: restic/restic
Pull request #5146 (2024-11-16 15:27:02)

fs: Inline ExtendedFileInfo
<!--
Thank you very much for contributing code or documentation to restic! Please
fill out the following questions to make it easier for us to review your
changes.
-->

What does this PR change? What problem does it solve?
-----------------------------------------------------
The methods of the fs.FS and fs.File interfaces no longer return `os.FileInfo` but instead always `*fs.ExtendedFileInfo`. This obsoletes the `DeviceID(...)` and `ExtendedStat(...)` methods of the FS interface. In total, the FS interface now only contains `OpenFile(...)` and `Lstat(...)` plus several path manipulation functions. 

fs.ExtendedFileInfo no longer embeds os.FileInfo but instead duplicates the `Name`, `Mode` and `sys` fields. This significantly cleans up some archiver tests as os.FileInfo no longer has to be wrapped in the test.

<!--
Describe the changes and their purpose here, as detailed as needed.
-->

Was the change previously discussed in an issue or on the forum?
----------------------------------------------------------------
No.
Related to https://github.com/restic/restic/pull/5104
<!--
Link issues and relevant forum posts here.

If this PR resolves an issue on GitHub, use "Closes #1234" so that the issue
is closed automatically when this PR is merged.
-->

Checklist
---------

<!--
You do not need to check all the boxes below all at once. Feel free to take
your time and add more commits. If you're done and ready for review, please
check the last box. Enable a checkbox by replacing [ ] with [x].

Please always follow these steps:
- Read the [contribution guidelines](https://github.com/restic/restic/blob/master/CONTRIBUTING.md#providing-patches).
- Enable [maintainer edits](https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/allowing-changes-to-a-pull-request-branch-created-from-a-fork).
- Run `gofmt` on the code in all commits.
- Format all commit messages in the same style as [the other commits in the repository](https://github.com/restic/restic/blob/master/CONTRIBUTING.md#git-commits).
-->

- [x] I have added tests for all code changes.
- ~~[ ] I have added documentation for relevant changes (in the manual).~~
- ~~[ ] There's a new file in `changelog/unreleased/` that describes the changes for our users (see [template](https://github.com/restic/restic/blob/master/changelog/TEMPLATE)).~~
- [x] I'm done! This pull request is ready for review.



Patch:
diff --git a/internal/archiver/archiver.go b/internal/archiver/archiver.go
index 5d4648e03..55b6ee4b3 100644
--- a/internal/archiver/archiver.go
+++ b/internal/archiver/archiver.go
@@ -25,7 +25,7 @@ type SelectByNameFunc func(item string) bool
 
 // SelectFunc returns true for all items that should be included (files and
 // dirs). If false is returned, files are ignored and dirs are not even walked.
-type SelectFunc func(item string, fi os.FileInfo, fs fs.FS) bool
+type SelectFunc func(item string, fi *fs.ExtendedFileInfo, fs fs.FS) bool
 
 // ErrorFunc is called when an error during archiving occurs. When nil is
 // returned, the archiver continues, otherwise it aborts and passes the error
@@ -189,7 +189,7 @@ func New(repo archiverRepo, filesystem fs.FS, opts Options) *Archiver {
 	arch := &Archiver{
 		Repo:         repo,
 		SelectByName: func(_ string) bool { return true },
-		Select:       func(_ string, _ os.FileInfo, _ fs.FS) bool { return true },
+		Select:       func(_ string, _ *fs.ExtendedFileInfo, _ fs.FS) bool { return true },
 		FS:           filesystem,
 		Options:      opts.ApplyDefaults(),
 
@@ -505,12 +505,12 @@ func (arch *Archiver) save(ctx context.Context, snPath, target string, previous
 	}
 
 	switch {
-	case fi.Mode().IsRegular():
+	case fi.Mode.IsRegular():
 		debug.Log("  %v regular file", target)
 
 		// check if the file has not changed before performing a fopen operation (more expensive, specially
 		// in network filesystems)
-		if previous != nil && !fileChanged(arch.FS, fi, previous, arch.ChangeIgnoreFlags) {
+		if previous != nil && !fileChanged(fi, previous, arch.ChangeIgnoreFlags) {
 			if arch.allBlobsPresent(previous) {
 				debug.Log("%v hasn't changed, using old list of blobs", target)
 				arch.trackItem(snPath, previous, previous, ItemStats{}, time.Since(start))
@@ -555,7 +555,7 @@ func (arch *Archiver) save(ctx context.Context, snPath, target string, previous
 		}
 
 		// make sure it's still a file
-		if !fi.Mode().IsRegular() {
+		if !fi.Mode.IsRegular() {
 			err = errors.Errorf("file %q changed type, refusing to archive", target)
 			return filterError(err)
 		}
@@ -571,7 +571,7 @@ func (arch *Archiver) save(ctx context.Context, snPath, target string, previous
 			arch.trackItem(snPath, previous, node, stats, time.Since(start))
 		})
 
-	case fi.IsDir():
+	case fi.Mode.IsDir():
 		debug.Log("  %v dir", target)
 
 		snItem := snPath + "/"
@@ -592,7 +592,7 @@ func (arch *Archiver) save(ctx context.Context, snPath, target string, previous
 			return futureNode{}, false, err
 		}
 
-	case fi.Mode()&os.ModeSocket > 0:
+	case fi.Mode&os.ModeSocket > 0:
 		debug.Log("  %v is a socket, ignoring", target)
 		return futureNode{}, true, nil
 
@@ -618,27 +618,26 @@ func (arch *Archiver) save(ctx context.Context, snPath, target string, previous
 // fileChanged tries to detect whether a file's content has changed compared
 // to the contents of node, which describes the same path in the parent backup.
 // It should only be run for regular files.
-func fileChanged(fs fs.FS, fi os.FileInfo, node *restic.Node, ignoreFlags uint) bool {
+func fileChanged(fi *fs.ExtendedFileInfo, node *restic.Node, ignoreFlags uint) bool {
 	switch {
 	case node == nil:
 		return true
 	case node.Type != restic.NodeTypeFile:
 		// We're only called for regular files, so this is a type change.
 		return true
-	case uint64(fi.Size()) != node.Size:
+	case uint64(fi.Size) != node.Size:
 		return true
-	case !fi.ModTime().Equal(node.ModTime):
+	case !fi.ModTime.Equal(node.ModTime):
 		return true
 	}
 
 	checkCtime := ignoreFlags&ChangeIgnoreCtime == 0
 	checkInode := ignoreFlags&ChangeIgnoreInode == 0
 
-	extFI := fs.ExtendedStat(fi)
 	switch {
-	case checkCtime && !extFI.ChangeTime.Equal(node.ChangeTime):
+	case checkCtime && !fi.ChangeTime.Equal(node.ChangeTime):
 		return true
-	case checkInode && node.Inode != extFI.Inode:
+	case checkInode && node.Inode != fi.Inode:
 		return true
 	}
 
diff --git a/internal/archiver/exclude.go b/internal/archiver/exclude.go
index 418517fd9..6db62aa20 100644
--- a/internal/archiver/exclude.go
+++ b/internal/archiver/exclude.go
@@ -5,6 +5,7 @@ import (
 	"fmt"
 	"io"
 	"os"
+	"runtime"
 	"strings"
 	"sync"
 
@@ -21,7 +22,7 @@ type RejectByNameFunc func(path string) bool
 // RejectFunc is a function that takes a filename and os.FileInfo of a
 // file that would be included in the backup. The function returns true if it
 // should be excluded (rejected) from the backup.
-type RejectFunc func(path string, fi os.FileInfo, fs fs.FS) bool
+type RejectFunc func(path string, fi *fs.ExtendedFileInfo, fs fs.FS) bool
 
 func CombineRejectByNames(funcs []RejectByNameFunc) SelectByNameFunc {
 	return func(item string) bool {
@@ -35,7 +36,7 @@ func CombineRejectByNames(funcs []RejectByNameFunc) SelectByNameFunc {
 }
 
 func CombineRejects(funcs []RejectFunc) SelectFunc {
-	return func(item string, fi os.FileInfo, fs fs.FS) bool {
+	return func(item string, fi *fs.ExtendedFileInfo, fs fs.FS) bool {
 		for _, reject := range funcs {
 			if reject(item, fi, fs) {
 				return false
@@ -104,7 +105,7 @@ func RejectIfPresent(excludeFileSpec string, warnf func(msg string, args ...inte
 	}
 	debug.Log("using %q as exclusion tagfile", tf)
 	rc := newRejectionCache()
-	return func(filename string, _ os.FileInfo, fs fs.FS) bool {
+	return func(filename string, _ *fs.ExtendedFileInfo, fs fs.FS) bool {
 		return isExcludedByFile(filename, tf, tc, rc, fs, warnf)
 	}, nil
 }
@@ -186,6 +187,10 @@ type deviceMap map[string]uint64
 
 // newDeviceMap creates a new device map from the list of source paths.
 func newDeviceMap(allowedSourcePaths []string, fs fs.FS) (deviceMap, error) {
+	if runtime.GOOS == "windows" {
+		return nil, errors.New("Device IDs are not supported on Windows")
+	}
+
 	deviceMap := make(map[string]uint64)
 
 	for _, item := range allowedSourcePaths {
@@ -199,12 +204,7 @@ func newDeviceMap(allowedSourcePaths []string, fs fs.FS) (deviceMap, error) {
 			return nil, err
 		}
 
-		id, err := fs.DeviceID(fi)
-		if err != nil {
-			return nil, err
-		}
-
-		deviceMap[item] = id
+		deviceMap[item] = fi.DeviceID
 	}
 
 	if len(deviceMap) == 0 {
@@ -254,15 +254,8 @@ func RejectByDevice(samples []string, filesystem fs.FS) (RejectFunc, error) {
 	}
 	debug.Log("allowed devices: %v\n", deviceMap)
 
-	return func(item string, fi os.FileInfo, fs fs.FS) bool {
-		id, err := fs.DeviceID(fi)
-		if err != nil {
-			// This should never happen because gatherDevices() would have
-			// errored out earlier. If it still does that's a reason to panic.
-			panic(err)
-		}
-
-		allowed, err := deviceMap.IsAllowed(fs.Clean(item), id, fs)
+	return func(item string, fi *fs.ExtendedFileInfo, fs fs.FS) bool {
+		allowed, err := deviceMap.IsAllowed(fs.Clean(item), fi.DeviceID, fs)
 		if err != nil {
 			// this should not happen
 			panic(fmt.Sprintf("error checking device ID of %v: %v", item, err))
@@ -274,7 +267,7 @@ func RejectByDevice(samples []string, filesystem fs.FS) (RejectFunc, error) {
 		}
 
 		// reject everything except directories
-		if !fi.IsDir() {
+		if !fi.Mode.IsDir() {
 			return true
 		}
 
@@ -290,14 +283,7 @@ func RejectByDevice(samples []string, filesystem fs.FS) (RejectFunc, error) {
 			return true
 		}
 
-		parentDeviceID, err := fs.DeviceID(parentFI)
-		if err != nil {
-			debug.Log("item %v: getting device ID of parent directory: %v", item, err)
-			// if in doubt, reject
-			return true
-		}
-
-		parentAllowed, err := deviceMap.IsAllowed(parentDir, parentDeviceID, fs)
+		parentAllowed, err := deviceMap.IsAllowed(parentDir, parentFI.DeviceID, fs)
 		if err != nil {
 			debug.Log("item %v: error checking parent directory: %v", item, err)
 			// if in doubt, reject
@@ -315,13 +301,13 @@ func RejectByDevice(samples []string, filesystem fs.FS) (RejectFunc, error) {
 }
 
 func RejectBySize(maxSize int64) (RejectFunc, error) {
-	return func(item string, fi os.FileInfo, _ fs.FS) bool {
+	return func(item string, fi *fs.ExtendedFileInfo, _ fs.FS) bool {
 		// directory will be ignored
-		if fi.IsDir() {
+		if fi.Mode.IsDir() {
 			return false
 		}
 
-		filesize := fi.Size()
+		filesize := fi.Size
 		if filesize > maxSize {
 			debug.Log("file %s is oversize: %d", item, filesize)
 			return true
diff --git a/internal/archiver/scanner.go b/internal/archiver/scanner.go
index debd09aa3..2e6b7210c 100644
--- a/internal/archiver/scanner.go
+++ b/internal/archiver/scanner.go
@@ -2,7 +2,6 @@ package archiver
 
 import (
 	"context"
-	"os"
 	"sort"
 
 	"github.com/restic/restic/internal/debug"
@@ -25,7 +24,7 @@ func NewScanner(filesystem fs.FS) *Scanner {
 	return &Scanner{
 		FS:           filesystem,
 		SelectByName: func(_ string) bool { return true },
-		Select:       func(_ string, _ os.FileInfo, _ fs.FS) bool { return true },
+		Select:       func(_ string, _ *fs.ExtendedFileInfo, _ fs.FS) bool { return true },
 		Error:        func(_ string, err error) error { return err },
 		Result:       func(_ string, _ ScanStats) {},
 	}
@@ -119,10 +118,10 @@ func (s *Scanner) scan(ctx context.Context, stats ScanStats, target string) (Sca
 	}
 
 	switch {
-	case fi.Mode().IsRegular():
+	case fi.Mode.IsRegular():
 		stats.Files++
-		stats.Bytes += uint64(fi.Size())
-	case fi.Mode().IsDir():
+		stats.Bytes += uint64(fi.Size)
+	case fi.Mode.IsDir():
 		names, err := fs.Readdirnames(s.FS, target, fs.O_NOFOLLOW)
 		if err != nil {
 			return stats, s.Error(target, err)
diff --git a/internal/fs/deviceid_unix.go b/internal/fs/deviceid_unix.go
deleted file mode 100644
index 4d5593335..000000000
--- a/internal/fs/deviceid_unix.go
+++ /dev/null
@@ -1,31 +0,0 @@
-//go:build !windows
-// +build !windows
-
-package fs
-
-import (
-	"os"
-	"syscall"
-
-	"github.com/restic/restic/internal/errors"
-)
-
-// deviceID extracts the device ID from an os.FileInfo object by casting it
-// to syscall.Stat_t
-func deviceID(fi os.FileInfo) (deviceID uint64, err error) {
-	if fi == nil {
-		return 0, errors.New("unable to determine device: fi is nil")
-	}
-
-	if fi.Sys() == nil {
-		return 0, errors.New("unable to determine device: fi.Sys() is nil")
-	}
-
-	if st, ok := fi.Sys().(*syscall.Stat_t); ok {
-		// st.Dev is uint32 on Darwin and uint64 on Linux. Just cast
-		// everything to uint64.
-		return uint64(st.Dev), nil
-	}
-
-	return 0, errors.New("Could not cast to syscall.Stat_t")
-}
diff --git a/internal/fs/deviceid_windows.go b/internal/fs/deviceid_windows.go
deleted file mode 100644
index bfb22dc9a..000000000
--- a/internal/fs/deviceid_windows.go
+++ /dev/null
@@ -1,16 +0,0 @@
-//go:build windows
-// +build windows
-
-package fs
-
-import (
-	"os"
-
-	"github.com/restic/restic/internal/errors"
-)
-
-// deviceID extracts the device ID from an os.FileInfo object by casting it
-// to syscall.Stat_t
-func deviceID(_ os.FileInfo) (deviceID uint64, err error) {
-	return 0, errors.New("Device IDs are not supported on Windows")
-}
diff --git a/internal/fs/fs_local.go b/internal/fs/fs_local.go
index 5e6c72d0a..fc6c69cf2 100644
--- a/internal/fs/fs_local.go
+++ b/internal/fs/fs_local.go
@@ -36,19 +36,12 @@ func (fs Local) OpenFile(name string, flag int, metadataOnly bool) (File, error)
 // If the file is a symbolic link, the returned FileInfo
 // describes the symbolic link.  Lstat makes no attempt to follow the link.
 // If there is an error, it will be of type *PathError.
-func (fs Local) Lstat(name string) (os.FileInfo, error) {
-	return os.Lstat(fixpath(name))
-}
-
-// DeviceID extracts the DeviceID from the given FileInfo. If the fs does
-// not support a DeviceID, it returns an error instead
-func (fs Local) DeviceID(fi os.FileInfo) (id uint64, err error) {
-	return deviceID(fi)
-}
-
-// ExtendedStat converts the give FileInfo into ExtendedFileInfo.
-func (fs Local) ExtendedStat(fi os.FileInfo) ExtendedFileInfo {
-	return ExtendedStat(fi)
+func (fs Local) Lstat(name string) (*ExtendedFileInfo, error) {
+	fi, err := os.Lstat(fixpath(name))
+	if err != nil {
+		return nil, err
+	}
+	return extendedStat(fi), nil
 }
 
 // Join joins any number of path elements into a single path, adding a
@@ -96,7 +89,7 @@ type localFile struct {
 	name string
 	flag int
 	f    *os.File
-	fi   os.FileInfo
+	fi   *ExtendedFileInfo
 }
 
 // See the File interface for a description of each method
@@ -137,18 +130,23 @@ func (f *localFile) cacheFI() error {
 	if f.fi != nil {
 		return nil
 	}
+	var fi os.FileInfo
 	var err error
 	if f.f != nil {
-		f.fi, err = f.f.Stat()
+		fi, err = f.f.Stat()
 	} else if f.flag&O_NOFOLLOW != 0 {
-		f.fi, err = os.Lstat(f.name)
+		fi, err = os.Lstat(f.name)
 	} else {
-		f.fi, err = os.Stat(f.name)
+		fi, err = os.Stat(f.name)
 	}
-	return err
+	if err != nil {
+		return err
+	}
+	f.fi = extendedStat(fi)
+	return nil
 }
 
-func (f *localFile) Stat() (os.FileInfo, error) {
+func (f *localFile) Stat() (*ExtendedFileInfo, error) {
 	err := f.cacheFI()
 	// the call to cacheFI MUST happen before reading from f.fi
 	return f.fi, err
diff --git a/internal/fs/fs_local_vss.go b/internal/fs/fs_local_vss.go
index fe82b85e1..dfee31779 100644
--- a/internal/fs/fs_local_vss.go
+++ b/internal/fs/fs_local_vss.go
@@ -1,7 +1,6 @@
 package fs
 
 import (
-	"os"
 	"path/filepath"
 	"runtime"
 	"strings"
@@ -131,7 +130,7 @@ func (fs *LocalVss) OpenFile(name string, flag int, metadataOnly bool) (File, er
 }
 
 // Lstat wraps the Lstat method of the underlying file system.
-func (fs *LocalVss) Lstat(name string) (os.FileInfo, error) {
+func (fs *LocalVss) Lstat(name string) (*ExtendedFileInfo, error) {
 	return fs.FS.Lstat(fs.snapshotPath(name))
 }
 
diff --git a/internal/fs/fs_reader.go b/internal/fs/fs_reader.go
index 8728b274c..bbe5c95ab 100644
--- a/internal/fs/fs_reader.go
+++ b/internal/fs/fs_reader.go
@@ -5,6 +5,7 @@ import (
 	"io"
 	"os"
 	"path"
+	"slices"
 	"sync"
 	"syscall"
 	"time"
@@ -40,12 +41,12 @@ func (fs *Reader) VolumeName(_ string) string {
 	return ""
 }
 
-func (fs *Reader) fi() os.FileInfo {
-	return fakeFileInfo{
-		name:    fs.Name,
-		size:    fs.Size,
-		mode:    fs.Mode,
-		modtime: fs.ModTime,
+func (fs *Reader) fi() *ExtendedFileInfo {
+	return &ExtendedFileInfo{
+		Name:    fs.Name,
+		Mode:    fs.Mode,
+		ModTime: fs.ModTime,
+		Size:    fs.Size,
 	}
 }
 
@@ -68,7 +69,7 @@ func (fs *Reader) OpenFile(name string, flag int, _ bool) (f File, err error) {
 		return f, nil
 	case "/", ".":
 		f = fakeDir{
-			entries: []os.FileInfo{fs.fi()},
+			entries: []string{fs.fi().Name},
 		}
 		return f, nil
 	}
@@ -80,15 +81,14 @@ func (fs *Reader) OpenFile(name string, flag int, _ bool) (f File, err error) {
 // If the file is a symbolic link, the returned FileInfo
 // describes the symbolic link.  Lstat makes no attempt to follow the link.
 // If there is an error, it will be of type *os.PathError.
-func (fs *Reader) Lstat(name string) (os.FileInfo, error) {
-	getDirInfo := func(name string) os.FileInfo {
-		fi := fakeFileInfo{
-			name:    fs.Base(name),
-			size:    0,
-			mode:    os.ModeDir | 0755,
-			modtime: time.Now(),
+func (fs *Reader) Lstat(name string) (*ExtendedFileInfo, error) {
+	getDirInfo := func(name string) *ExtendedFileInfo {
+		return &ExtendedFileInfo{
+			Name:    fs.Base(name),
+			Size:    0,
+			Mode:    os.ModeDir | 0755,
+			ModTime: time.Now(),
 		}
-		return fi
 	}
 
 	switch name {
@@ -112,16 +112,6 @@ func (fs *Reader) Lstat(name string) (os.FileInfo, error) {
 	return nil, pathError("lstat", name, os.ErrNotExist)
 }
 
-func (fs *Reader) DeviceID(_ os.FileInfo) (deviceID uint64, err error) {
-	return 0, errors.New("Device IDs are not supported")
-}
-
-func (fs *Reader) ExtendedStat(fi os.FileInfo) ExtendedFileInfo {
-	return ExtendedFileInfo{
-		FileInfo: fi,
-	}
-}
-
 // Join joins any number of path elements into a single path, adding a
 // Separator if necessary. Join calls Clean on the result; in particular, all
 // empty strings are ignored. On Windows, the result is a UNC path if and only
@@ -165,13 +155,13 @@ func (fs *Reader) Dir(p string) string {
 	return path.Dir(p)
 }
 
-func newReaderFile(rd io.ReadCloser, fi os.FileInfo, allowEmptyFile bool) *readerFile {
+func newReaderFile(rd io.ReadCloser, fi *ExtendedFileInfo, allowEmptyFile bool) *readerFile {
 	return &readerFile{
 		ReadCloser:     rd,
 		AllowEmptyFile: allowEmptyFile,
 		fakeFile: fakeFile{
-			FileInfo: fi,
-			name:     fi.Name(),
+			fi:   fi,
+			name: fi.Name,
 		},
 	}
 }
@@ -213,7 +203,7 @@ var _ File = &readerFile{}
 // except Stat()
 type fakeFile struct {
 	name string
-	os.FileInfo
+	fi   *ExtendedFileInfo
 }
 
 // ensure that fakeFile implements File
@@ -235,12 +225,12 @@ func (f fakeFile) Close() error {
 	return nil
 }
 
-func (f fakeFile) Stat() (os.FileInfo, error) {
-	return f.FileInfo, nil
+func (f fakeFile) Stat() (*ExtendedFileInfo, error) {
+	return f.fi, nil
 }
 
 func (f fakeFile) ToNode(_ bool) (*restic.Node, error) {
-	node := buildBasicNode(f.name, f.FileInfo)
+	node := buildBasicNode(f.name, f.fi)
 
 	// fill minimal info with current values for uid, gid
 	node.UID = uint32(os.Getuid())
@@ -252,7 +242,7 @@ func (f fakeFile) ToNode(_ bool) (*restic.Node, error) {
 
 // fakeDir implements Readdirnames and Readdir, everything else is delegated to fakeFile.
 type fakeDir struct {
-	entries []os.FileInfo
+	entries []string
 	fakeFile
 }
 
@@ -260,44 +250,7 @@ func (d fakeDir) Readdirnames(n int) ([]string, error) {
 	if n > 0 {
 		return nil, pathError("readdirnames", d.name, errors.New("not implemented"))
 	}
-	names := make([]string, 0, len(d.entries))
-	for _, entry := range d.entries {
-		names = append(names, entry.Name())
-	}
-
-	return names, nil
-}
-
-// fakeFileInfo implements the bare minimum of os.FileInfo.
-type fakeFileInfo struct {
-	name    string
-	size    int64
-	mode    os.FileMode
-	modtime time.Time
-}
-
-func (fi fakeFileInfo) Name() string {
-	return fi.name
-}
-
-func (fi fakeFileInfo) Size() int64 {
-	return fi.size
-}
-
-func (fi fakeFileInfo) Mode() os.FileMode {
-	return fi.mode
-}
-
-func (fi fakeFileInfo) ModTime() time.Time {
-	return fi.modtime
-}
-
-func (fi fakeFileInfo) IsDir() bool {
-	return fi.mode&os.ModeDir > 0
-}
-
-func (fi fakeFileInfo) Sys() interface{} {
-	return nil
+	return slices.Clone(d.entries), nil
 }
 
 func pathError(op, name string, err error) *os.PathError {
diff --git a/internal/fs/interface.go b/internal/fs/interface.go
index 7ff777138..d75b0a91d 100644
--- a/internal/fs/interface.go
+++ b/internal/fs/interface.go
@@ -2,7 +2,6 @@ package fs
 
 import (
 	"io"
-	"os"
 
 	"github.com/restic/restic/internal/restic"
 )
@@ -18,9 +17,7 @@ type FS interface {
 	//
 	// Only the O_NOFOLLOW and O_DIRECTORY flags are supported.
 	OpenFile(name string, flag int, metadataOnly bool) (File, error)
-	Lstat(name string) (os.FileInfo, error)
-	DeviceID(fi os.FileInfo) (deviceID uint64, err error)
-	ExtendedStat(fi os.FileInfo) ExtendedFileInfo
+	Lstat(name string) (*ExtendedFileInfo, error)
 
 	Join(elem ...string) string
 	Separator() string
@@ -47,7 +44,7 @@ type File interface {
 	io.Closer
 
 	Readdirnames(n int) ([]string, error)
-	Stat() (os.FileInfo, error)
+	Stat() (*ExtendedFileInfo, error)
 	// ToNode returns a restic.Node for the File. The internally used os.FileInfo
 	// must be consistent with that returned by Stat(). In particular, the metadata
 	// returned by consecutive calls to Stat() and ToNode() must match.
diff --git a/internal/fs/node.go b/internal/fs/node.go
index 065969537..058d9cc7b 100644
--- a/internal/fs/node.go
+++ b/internal/fs/node.go
@@ -15,37 +15,36 @@ import (
 
 // nodeFromFileInfo returns a new node from the given path and FileInfo. It
 // returns the first error that is encountered, together with a node.
-func nodeFromFileInfo(path string, fi os.FileInfo, ignoreXattrListError bool) (*restic.Node, error) {
+func nodeFromFileInfo(path string, fi *ExtendedFileInfo, ignoreXattrListError bool) (*restic.Node, error) {
 	node := buildBasicNode(path, fi)
 
-	stat := ExtendedStat(fi)
-	if err := nodeFillExtendedStat(node, path, &stat); err != nil {
+	if err := nodeFillExtendedStat(node, path, fi); err != nil {
 		return node, err
 	}
 
-	err := nodeFillGenericAttributes(node, path, &stat)
+	err := nodeFillGenericAttributes(node, path, fi)
 	err = errors.Join(err, nodeFillExtendedAttributes(node, path, ignoreXattrListError))
 	return node, err
 }
 
-func buildBasicNode(path string, fi os.FileInfo) *restic.Node {
+func buildBasicNode(path string, fi *ExtendedFileInfo) *restic.Node {
 	mask := os.ModePerm | os.ModeType | os.ModeSetuid | os.ModeSetgid | os.ModeSticky
 	node := &restic.Node{
 		Path:    path,
-		Name:    fi.Name(),
-		Mode:    fi.Mode() & mask,
-		ModTime: fi.ModTime(),
+		Name:    fi.Name,
+		Mode:    fi.Mode & mask,
+		ModTime: fi.ModTime,
 	}
 
-	node.Type = nodeTypeFromFileInfo(fi)
+	node.Type = nodeTypeFromFileInfo(fi.Mode)
 	if node.Type == restic.NodeTypeFile {
-		node.Size = uint64(fi.Size())
+		node.Size = uint64(fi.Size)
 	}
 	return node
 }
 
-func nodeTypeFromFileInfo(fi os.FileInfo) restic.NodeType {
-	switch fi.Mode() & os.ModeType {
+func nodeTypeFromFileInfo(mode os.FileMode) restic.NodeType {
+	switch mode & os.ModeType {
 	case 0:
 		return restic.NodeTypeFile
 	case os.ModeDir:
diff --git a/internal/fs/node_windows.go b/internal/fs/node_windows.go
index c0f8b08b0..74cf6c0e5 100644
--- a/internal/fs/node_windows.go
+++ b/internal/fs/node_windows.go
@@ -361,7 +361,7 @@ func nodeFillGenericAttributes(node *restic.Node, path string, stat *ExtendedFil
 		}
 	}
 
-	winFI := stat.Sys().(*syscall.Win32FileAttributeData)
+	winFI := stat.sys.(*syscall.Win32FileAttributeData)
 
 	// Add Windows attributes
 	node.GenericAttributes, err = restic.WindowsAttrsToGenericAttributes(restic.WindowsAttributes{
diff --git a/internal/fs/stat.go b/internal/fs/stat.go
index e1006fd61..bd3993f41 100644
--- a/internal/fs/stat.go
+++ b/internal/fs/stat.go
@@ -8,7 +8,8 @@ import (
 // ExtendedFileInfo is an extended stat_t, filled with attributes that are
 // supported by most operating systems. The original FileInfo is embedded.
 type ExtendedFileInfo struct {
-	os.FileInfo
+	Name string
+	Mode os.FileMode
 
 	DeviceID  uint64 // ID of device containing the file
 	Inode     uint64 // Inode number
@@ -23,10 +24,13 @@ type ExtendedFileInfo struct {
 	AccessTime time.Time // last access time stamp
 	ModTime    time.Time // last (content) modification time stamp
 	ChangeTime time.Time // last status change time stamp
+
+	// nolint:unused // only used on Windows
+	sys any // Value returned by os.FileInfo.Sys()
 }
 
 // ExtendedStat returns an ExtendedFileInfo constructed from the os.FileInfo.
-func ExtendedStat(fi os.FileInfo) ExtendedFileInfo {
+func ExtendedStat(fi os.FileInfo) *ExtendedFileInfo {
 	if fi == nil {
 		panic("os.FileInfo is nil")
 	}
diff --git a/internal/fs/stat_bsd.go b/internal/fs/stat_bsd.go
index 11e075b50..165064153 100644
--- a/internal/fs/stat_bsd.go
+++ b/internal/fs/stat_bsd.go
@@ -10,11 +10,13 @@ import (
 )
 
 // extendedStat extracts info into an ExtendedFileInfo for unix based operating systems.
-func extendedStat(fi os.FileInfo) ExtendedFileInfo {
+func extendedStat(fi os.FileInfo) *ExtendedFileInfo {
 	s := fi.Sys().(*syscall.Stat_t)
 
-	extFI := ExtendedFileInfo{
-		FileInfo:  fi,
+	return &ExtendedFileInfo{
+		Name: fi.Name(),
+		Mode: fi.Mode(),
+
 		DeviceID:  uint64(s.Dev),
 		Inode:     uint64(s.Ino),
 		Links:     uint64(s.Nlink),
@@ -29,6 +31,4 @@ func extendedStat(fi os.FileInfo) ExtendedFileInfo {
 		ModTime:    time.Unix(s.Mtimespec.Unix()),
 		ChangeTime: time.Unix(s.Ctimespec.Unix()),
 	}
-
-	return extFI
 }
diff --git a/internal/fs/stat_unix.go b/internal/fs/stat_unix.go
index c55571031..723ac8b19 100644
--- a/internal/fs/stat_unix.go
+++ b/internal/fs/stat_unix.go
@@ -10,11 +10,13 @@ import (
 )
 
 // extendedStat extracts info into an ExtendedFileInfo for unix based operating systems.
-func extendedStat(fi os.FileInfo) ExtendedFileInfo {
+func extendedStat(fi os.FileInfo) *ExtendedFileInfo {
 	s := fi.Sys().(*syscall.Stat_t)
 
-	extFI := ExtendedFileInfo{
-		FileInfo:  fi,
+	return &ExtendedFileInfo{
+		Name: fi.Name(),
+		Mode: fi.Mode(),
+
 		DeviceID:  uint64(s.Dev),
 		Inode:     s.Ino,
 		Links:     uint64(s.Nlink),
@@ -29,6 +31,4 @@ func extendedStat(fi os.FileInfo) ExtendedFileInfo {
 		ModTime:    time.Unix(s.Mtim.Unix()),
 		ChangeTime: time.Unix(s.Ctim.Unix()),
 	}
-
-	return extFI
 }
diff --git a/internal/fs/stat_windows.go b/internal/fs/stat_windows.go
index 57f330fb5..a2dfa5f6d 100644
--- a/internal/fs/stat_windows.go
+++ b/internal/fs/stat_windows.go
@@ -11,15 +11,18 @@ import (
 )
 
 // extendedStat extracts info into an ExtendedFileInfo for Windows.
-func extendedStat(fi os.FileInfo) ExtendedFileInfo {
+func extendedStat(fi os.FileInfo) *ExtendedFileInfo {
 	s, ok := fi.Sys().(*syscall.Win32FileAttributeData)
 	if !ok {
 		panic(fmt.Sprintf("conversion to syscall.Win32FileAttributeData failed, type is %T", fi.Sys()))
 	}
 
 	extFI := ExtendedFileInfo{
-		FileInfo: fi,
-		Size:     int64(s.FileSizeLow) | (int64(s.FileSizeHigh) << 32),
+		Name: fi.Name(),
+		Mode: fi.Mode(),
+
+		Size: int64(s.FileSizeLow) | (int64(s.FileSizeHigh) << 32),
+		sys:  fi.Sys(),
 	}
 
 	atime := syscall.NsecToTimespec(s.LastAccessTime.Nanoseconds())
@@ -31,5 +34,5 @@ func extendedStat(fi os.FileInfo) ExtendedFileInfo {
 	// Windows does not have the concept of a "change time" in the sense Unix uses it, so we're using the LastWriteTime here.
 	extFI.ChangeTime = extFI.ModTime
 
-	return extFI
+	return &extFI
 }


Test patch:
diff --git a/cmd/restic/cmd_backup_integration_test.go b/cmd/restic/cmd_backup_integration_test.go
index 4278f07ca..06d71e345 100644
--- a/cmd/restic/cmd_backup_integration_test.go
+++ b/cmd/restic/cmd_backup_integration_test.go
@@ -132,7 +132,7 @@ type vssDeleteOriginalFS struct {
 	hasRemoved bool
 }
 
-func (f *vssDeleteOriginalFS) Lstat(name string) (os.FileInfo, error) {
+func (f *vssDeleteOriginalFS) Lstat(name string) (*fs.ExtendedFileInfo, error) {
 	if !f.hasRemoved {
 		// call Lstat to trigger snapshot creation
 		_, _ = f.FS.Lstat(name)
diff --git a/internal/archiver/archiver_test.go b/internal/archiver/archiver_test.go
index f57c4894b..fcc3d465d 100644
--- a/internal/archiver/archiver_test.go
+++ b/internal/archiver/archiver_test.go
@@ -516,13 +516,13 @@ func chmodTwice(t testing.TB, name string) {
 	rtest.OK(t, err)
 }
 
-func lstat(t testing.TB, name string) os.FileInfo {
+func lstat(t testing.TB, name string) *fs.ExtendedFileInfo {
 	fi, err := os.Lstat(name)
 	if err != nil {
 		t.Fatal(err)
 	}
 
-	return fi
+	return fs.ExtendedStat(fi)
 }
 
 func setTimestamp(t testing.TB, filename string, atime, mtime time.Time) {
@@ -660,7 +660,7 @@ func TestFileChanged(t *testing.T) {
 				rename(t, filename, tempname)
 				save(t, filename, defaultContent)
 				remove(t, tempname)
-				setTimestamp(t, filename, fi.ModTime(), fi.ModTime())
+				setTimestamp(t, filename, fi.ModTime, fi.ModTime)
 			},
 			ChangeIgnore: ChangeIgnoreCtime | ChangeIgnoreInode,
 			SameFile:     true,
@@ -683,10 +683,11 @@ func TestFileChanged(t *testing.T) {
 			save(t, filename, content)
 
 			fs := &fs.Local{}
-			fiBefore := lstat(t, filename)
+			fiBefore, err := fs.Lstat(filename)
+			rtest.OK(t, err)
 			node := nodeFromFile(t, fs, filename)
 
-			if fileChanged(fs, fiBefore, node, 0) {
+			if fileChanged(fiBefore, node, 0) {
 				t.Fatalf("unchanged file detected as changed")
 			}
 
@@ -696,12 +697,12 @@ func TestFileChanged(t *testing.T) {
 
 			if test.SameFile {
 				// file should be detected as unchanged
-				if fileChanged(fs, fiAfter, node, test.ChangeIgnore) {
+				if fileChanged(fiAfter, node, test.ChangeIgnore) {
 					t.Fatalf("unmodified file detected as changed")
 				}
 			} else {
 				// file should be detected as changed
-				if !fileChanged(fs, fiAfter, node, test.ChangeIgnore) && !test.SameFile {
+				if !fileChanged(fiAfter, node, test.ChangeIgnore) && !test.SameFile {
 					t.Fatalf("modified file detected as unchanged")
 				}
 			}
@@ -718,7 +719,7 @@ func TestFilChangedSpecialCases(t *testing.T) {
 
 	t.Run("nil-node", func(t *testing.T) {
 		fi := lstat(t, filename)
-		if !fileChanged(&fs.Local{}, fi, nil, 0) {
+		if !fileChanged(fi, nil, 0) {
 			t.Fatal("nil node detected as unchanged")
 		}
 	})
@@ -727,7 +728,7 @@ func TestFilChangedSpecialCases(t *testing.T) {
 		fi := lstat(t, filename)
 		node := nodeFromFile(t, &fs.Local{}, filename)
 		node.Type = restic.NodeTypeSymlink
-		if !fileChanged(&fs.Local{}, fi, node, 0) {
+		if !fileChanged(fi, node, 0) {
 			t.Fatal("node with changed type detected as unchanged")
 		}
 	})
@@ -1520,7 +1521,7 @@ func TestArchiverSnapshotSelect(t *testing.T) {
 				},
 				"other": TestFile{Content: "another file"},
 			},
-			selFn: func(item string, fi os.FileInfo, _ fs.FS) bool {
+			selFn: func(item string, fi *fs.ExtendedFileInfo, _ fs.FS) bool {
 				return true
 			},
 		},
@@ -1537,7 +1538,7 @@ func TestArchiverSnapshotSelect(t *testing.T) {
 				},
 				"other": TestFile{Content: "another file"},
 			},
-			selFn: func(item string, fi os.FileInfo, _ fs.FS) bool {
+			selFn: func(item string, fi *fs.ExtendedFileInfo, _ fs.FS) bool {
 				return false
 			},
 			err: "snapshot is empty",
@@ -1564,7 +1565,7 @@ func TestArchiverSnapshotSelect(t *testing.T) {
 				},
 				"other": TestFile{Content: "another file"},
 			},
-			selFn: func(item string, fi os.FileInfo, _ fs.FS) bool {
+			selFn: func(item string, fi *fs.ExtendedFileInfo, _ fs.FS) bool {
 				return filepath.Ext(item) != ".txt"
 			},
 		},
@@ -1588,7 +1589,7 @@ func TestArchiverSnapshotSelect(t *testing.T) {
 				},
 				"other": TestFile{Content: "another file"},
 			},
-			selFn: func(item string, fi os.FileInfo, fs fs.FS) bool {
+			selFn: func(item string, fi *fs.ExtendedFileInfo, fs fs.FS) bool {
 				return fs.Base(item) != "subdir"
 			},
 		},
@@ -1597,7 +1598,7 @@ func TestArchiverSnapshotSelect(t *testing.T) {
 			src: TestDir{
 				"foo": TestFile{Content: "foo"},
 			},
-			selFn: func(item string, fi os.FileInfo, fs fs.FS) bool {
+			selFn: func(item string, fi *fs.ExtendedFileInfo, fs fs.FS) bool {
 				return fs.IsAbs(item)
 			},
 		},
@@ -2202,7 +2203,7 @@ func snapshot(t testing.TB, repo archiverRepo, fs fs.FS, parent *restic.Snapshot
 
 type overrideFS struct {
 	fs.FS
-	overrideFI    os.FileInfo
+	overrideFI    *fs.ExtendedFileInfo
 	resetFIOnRead bool
 	overrideNode  *restic.Node
 	overrideErr   error
@@ -2225,7 +2226,7 @@ type overrideFile struct {
 	ofs *overrideFS
 }
 
-func (f overrideFile) Stat() (os.FileInfo, error) {
+func (f overrideFile) Stat() (*fs.ExtendedFileInfo, error) {
 	if f.ofs.overrideFI == nil {
 		return f.File.Stat()
 	}
@@ -2302,19 +2303,26 @@ func TestMetadataChanged(t *testing.T) {
 		t.Fatalf("metadata does not match:\n%v", cmp.Diff(want, node2))
 	}
 
-	// modify the mode by wrapping it in a new struct, uses the consts defined above
-	fs.overrideFI = wrapFileInfo(fi)
-	rtest.Assert(t, !fileChanged(fs, fs.overrideFI, node2, 0), "testfile must not be considered as changed")
+	// modify the mode and UID/GID
+	modFI := *fi
+	modFI.Mode = mockFileInfoMode
+	if runtime.GOOS != "windows" {
+		modFI.UID = mockFileInfoUID
+		modFI.GID = mockFileInfoGID
+	}
+
+	fs.overrideFI = &modFI
+	rtest.Assert(t, !fileChanged(fs.overrideFI, node2, 0), "testfile must not be considered as changed")
 
 	// set the override values in the 'want' node which
-	want.Mode = 0400
+	want.Mode = mockFileInfoMode
 	// ignore UID and GID on Windows
 	if runtime.GOOS != "windows" {
-		want.UID = 51234
-		want.GID = 51235
+		want.UID = mockFileInfoUID
+		want.GID = mockFileInfoGID
 	}
 	// update mock node accordingly
-	fs.overrideNode.Mode = 0400
+	fs.overrideNode.Mode = want.Mode
 	fs.overrideNode.UID = want.UID
 	fs.overrideNode.GID = want.GID
 
@@ -2455,10 +2463,12 @@ func TestIrregularFile(t *testing.T) {
 
 	tempfile := filepath.Join(tempdir, "testfile")
 	fi := lstat(t, "testfile")
+	// patch mode to irregular
+	fi.Mode = (fi.Mode &^ os.ModeType) | os.ModeIrregular
 
 	override := &overrideFS{
 		FS:         fs.Local{},
-		overrideFI: wrapIrregularFileInfo(fi),
+		overrideFI: fi,
 		overrideNode: &restic.Node{
 			Type: restic.NodeTypeIrregular,
 		},
@@ -2497,7 +2507,7 @@ type missingFile struct {
 	fs.File
 }
 
-func (f *missingFile) Stat() (os.FileInfo, error) {
+func (f *missingFile) Stat() (*fs.ExtendedFileInfo, error) {
 	return nil, os.ErrNotExist
 }
 
diff --git a/internal/archiver/archiver_unix_test.go b/internal/archiver/archiver_unix_test.go
index deeab6459..b6cc1ba4e 100644
--- a/internal/archiver/archiver_unix_test.go
+++ b/internal/archiver/archiver_unix_test.go
@@ -4,8 +4,6 @@
 package archiver
 
 import (
-	"os"
-	"syscall"
 	"testing"
 
 	"github.com/restic/restic/internal/feature"
@@ -14,48 +12,6 @@ import (
 	rtest "github.com/restic/restic/internal/test"
 )
 
-type wrappedFileInfo struct {
-	os.FileInfo
-	sys  interface{}
-	mode os.FileMode
-}
-
-func (fi wrappedFileInfo) Sys() interface{} {
-	return fi.sys
-}
-
-func (fi wrappedFileInfo) Mode() os.FileMode {
-	return fi.mode
-}
-
-// wrapFileInfo returns a new os.FileInfo with the mode, owner, and group fields changed.
-func wrapFileInfo(fi os.FileInfo) os.FileInfo {
-	// get the underlying stat_t and modify the values
-	stat := fi.Sys().(*syscall.Stat_t)
-	stat.Mode = mockFileInfoMode
-	stat.Uid = mockFileInfoUID
-	stat.Gid = mockFileInfoGID
-
-	// wrap the os.FileInfo so we can return a modified stat_t
-	res := wrappedFileInfo{
-		FileInfo: fi,
-		sys:      stat,
-		mode:     mockFileInfoMode,
-	}
-
-	return res
-}
-
-// wrapIrregularFileInfo returns a new os.FileInfo with the mode changed to irregular file
-func wrapIrregularFileInfo(fi os.FileInfo) os.FileInfo {
-	// wrap the os.FileInfo so we can return a modified stat_t
-	return wrappedFileInfo{
-		FileInfo: fi,
-		sys:      fi.Sys().(*syscall.Stat_t),
-		mode:     (fi.Mode() &^ os.ModeType) | os.ModeIrregular,
-	}
-}
-
 func statAndSnapshot(t *testing.T, repo archiverRepo, name string) (*restic.Node, *restic.Node) {
 	want := nodeFromFile(t, &fs.Local{}, name)
 	_, node := snapshot(t, repo, &fs.Local{}, nil, name)
diff --git a/internal/archiver/archiver_windows_test.go b/internal/archiver/archiver_windows_test.go
deleted file mode 100644
index ac8a67f2b..000000000
--- a/internal/archiver/archiver_windows_test.go
+++ /dev/null
@@ -1,36 +0,0 @@
-//go:build windows
-// +build windows
-
-package archiver
-
-import (
-	"os"
-)
-
-type wrappedFileInfo struct {
-	os.FileInfo
-	mode os.FileMode
-}
-
-func (fi wrappedFileInfo) Mode() os.FileMode {
-	return fi.mode
-}
-
-// wrapFileInfo returns a new os.FileInfo with the mode, owner, and group fields changed.
-func wrapFileInfo(fi os.FileInfo) os.FileInfo {
-	// wrap the os.FileInfo and return the modified mode, uid and gid are ignored on Windows
-	res := wrappedFileInfo{
-		FileInfo: fi,
-		mode:     mockFileInfoMode,
-	}
-
-	return res
-}
-
-// wrapIrregularFileInfo returns a new os.FileInfo with the mode changed to irregular file
-func wrapIrregularFileInfo(fi os.FileInfo) os.FileInfo {
-	return wrappedFileInfo{
-		FileInfo: fi,
-		mode:     (fi.Mode() &^ os.ModeType) | os.ModeIrregular,
-	}
-}
diff --git a/internal/archiver/exclude_test.go b/internal/archiver/exclude_test.go
index 7eb24b08b..9bfa5d83f 100644
--- a/internal/archiver/exclude_test.go
+++ b/internal/archiver/exclude_test.go
@@ -193,7 +193,7 @@ func TestIsExcludedByFileSize(t *testing.T) {
 			return err
 		}
 
-		excluded := sizeExclude(p, fi, nil)
+		excluded := sizeExclude(p, fs.ExtendedStat(fi), nil)
 		// the log message helps debugging in case the test fails
 		t.Logf("%q: dir:%t; size:%d; excluded:%v", p, fi.IsDir(), fi.Size(), excluded)
 		m[p] = !excluded
diff --git a/internal/archiver/scanner_test.go b/internal/archiver/scanner_test.go
index e4e2c9f59..a47952388 100644
--- a/internal/archiver/scanner_test.go
+++ b/internal/archiver/scanner_test.go
@@ -56,8 +56,8 @@ func TestScanner(t *testing.T) {
 					},
 				},
 			},
-			selFn: func(item string, fi os.FileInfo, fs fs.FS) bool {
-				if fi.IsDir() {
+			selFn: func(item string, fi *fs.ExtendedFileInfo, fs fs.FS) bool {
+				if fi.Mode.IsDir() {
 					return true
 				}
 
diff --git a/internal/fs/fs_local_test.go b/internal/fs/fs_local_test.go
index b1e85de0a..8fd8eb136 100644
--- a/internal/fs/fs_local_test.go
+++ b/internal/fs/fs_local_test.go
@@ -90,17 +90,16 @@ func checkMetadata(t *testing.T, f File, path string, follow bool, nodeType rest
 	rtest.OK(t, err)
 
 	// ModTime is likely unique per file, thus it provides a good indication that it is from the correct file
-	rtest.Equals(t, fi.ModTime(), node.ModTime, "node ModTime")
+	rtest.Equals(t, fi.ModTime, node.ModTime, "node ModTime")
 	rtest.Equals(t, nodeType, node.Type, "node Type")
 }
 
-func assertFIEqual(t *testing.T, want os.FileInfo, got os.FileInfo) {
+func assertFIEqual(t *testing.T, want os.FileInfo, got *ExtendedFileInfo) {
 	t.Helper()
-	rtest.Equals(t, want.Name(), got.Name(), "Name")
-	rtest.Equals(t, want.IsDir(), got.IsDir(), "IsDir")
-	rtest.Equals(t, want.ModTime(), got.ModTime(), "ModTime")
-	rtest.Equals(t, want.Mode(), got.Mode(), "Mode")
-	rtest.Equals(t, want.Size(), got.Size(), "Size")
+	rtest.Equals(t, want.Name(), got.Name, "Name")
+	rtest.Equals(t, want.ModTime(), got.ModTime, "ModTime")
+	rtest.Equals(t, want.Mode(), got.Mode, "Mode")
+	rtest.Equals(t, want.Size(), got.Size, "Size")
 }
 
 func TestFSLocalRead(t *testing.T) {
@@ -206,7 +205,7 @@ func TestFSLocalTypeChange(t *testing.T) {
 
 	fi, err := f.Stat()
 	rtest.OK(t, err)
-	if !fi.IsDir() {
+	if !fi.Mode.IsDir() {
 		// a file handle based implementation should still reference the file
 		checkMetadata(t, f, pathNew, false, restic.NodeTypeFile)
 
diff --git a/internal/fs/fs_local_vss_test.go b/internal/fs/fs_local_vss_test.go
index 33c412fe9..b64897d1c 100644
--- a/internal/fs/fs_local_vss_test.go
+++ b/internal/fs/fs_local_vss_test.go
@@ -325,7 +325,7 @@ func TestVSSFS(t *testing.T) {
 
 	lstatFi, err := localVss.Lstat(tempfile)
 	rtest.OK(t, err)
-	rtest.Equals(t, origFi.Mode(), lstatFi.Mode())
+	rtest.Equals(t, origFi.Mode, lstatFi.Mode)
 
 	f, err := localVss.OpenFile(tempfile, os.O_RDONLY, false)
 	rtest.OK(t, err)
@@ -335,7 +335,7 @@ func TestVSSFS(t *testing.T) {
 
 	node, err := f.ToNode(false)
 	rtest.OK(t, err)
-	rtest.Equals(t, node.Mode, lstatFi.Mode())
+	rtest.Equals(t, node.Mode, lstatFi.Mode)
 
 	rtest.OK(t, f.Close())
 }
diff --git a/internal/fs/fs_reader_test.go b/internal/fs/fs_reader_test.go
index 7e7f6e77c..257bfbbac 100644
--- a/internal/fs/fs_reader_test.go
+++ b/internal/fs/fs_reader_test.go
@@ -60,25 +60,25 @@ func verifyDirectoryContents(t testing.TB, fs FS, dir string, want []string) {
 	}
 }
 
-func checkFileInfo(t testing.TB, fi os.FileInfo, filename string, modtime time.Time, mode os.FileMode, isdir bool) {
-	if fi.IsDir() != isdir {
-		t.Errorf("IsDir returned %t, want %t", fi.IsDir(), isdir)
+func checkFileInfo(t testing.TB, fi *ExtendedFileInfo, filename string, modtime time.Time, mode os.FileMode, isdir bool) {
+	if fi.Mode.IsDir() != isdir {
+		t.Errorf("IsDir returned %t, want %t", fi.Mode.IsDir(), isdir)
 	}
 
-	if fi.Mode() != mode {
-		t.Errorf("Mode() returned wrong value, want 0%o, got 0%o", mode, fi.Mode())
+	if fi.Mode != mode {
+		t.Errorf("Mode has wrong value, want 0%o, got 0%o", mode, fi.Mode)
 	}
 
-	if !modtime.Equal(time.Time{}) && !fi.ModTime().Equal(modtime) {
-		t.Errorf("ModTime() returned wrong value, want %v, got %v", modtime, fi.ModTime())
+	if !modtime.Equal(time.Time{}) && !fi.ModTime.Equal(modtime) {
+		t.Errorf("ModTime has wrong value, want %v, got %v", modtime, fi.ModTime)
 	}
 
-	if path.Base(fi.Name()) != fi.Name() {
-		t.Errorf("Name() returned is not base, want %q, got %q", path.Base(fi.Name()), fi.Name())
+	if path.Base(fi.Name) != fi.Name {
+		t.Errorf("Name is not base, want %q, got %q", path.Base(fi.Name), fi.Name)
 	}
 
-	if fi.Name() != path.Base(filename) {
-		t.Errorf("Name() returned wrong value, want %q, got %q", path.Base(filename), fi.Name())
+	if fi.Name != path.Base(filename) {
+		t.Errorf("Name has wrong value, want %q, got %q", path.Base(filename), fi.Name)
 	}
 }