Repository: restic/restic
Pull request #5153 (2024-11-26 14:20:44)

Do not skip root tree when searching for trees
This fixes an issue where restic cannot find the tree when trying to find the tree id of a snapshot.

<!--
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?
-----------------------------------------------------

`restic find --tree <id>` cannot find the tree referenced by `restic snapshot --json` since it skips the root tree when searching.  

Was the change previously discussed in an issue or on the forum?
----------------------------------------------------------------

<!--
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).
- [x] 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/.github/workflows/docker.yml b/.github/workflows/docker.yml
index a24660b45..1a75604e6 100644
--- a/.github/workflows/docker.yml
+++ b/.github/workflows/docker.yml
@@ -20,6 +20,10 @@ jobs:
       contents: read
       packages: write
 
+    outputs:
+      image: ${{ steps.image.outputs.image }}
+      digest: ${{ steps.build-and-push.outputs.digest }}
+
     steps:
       - name: Checkout repository
         uses: actions/checkout@v4
@@ -37,6 +41,7 @@ jobs:
         with:
           images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
           tags: |
+            type=sha
             type=ref,event=branch
             type=semver,pattern={{version}}
             type=semver,pattern={{major}}.{{minor}}
@@ -55,6 +60,7 @@ jobs:
         if: github.ref != 'refs/heads/master'
 
       - name: Build and push Docker image
+        id: build-and-push
         uses: docker/build-push-action@15560696de535e4014efeff63c48f16952e52dd1
         with:
           push: true
@@ -64,3 +70,26 @@ jobs:
           pull: true
           tags: ${{ steps.meta.outputs.tags }}
           labels: ${{ steps.meta.outputs.labels }}
+        
+      - name: Output image
+        id: image
+        run: |
+          # NOTE: Set the image as an output because the `env` context is not
+          # available to the inputs of a reusable workflow call.
+          image_name="${REGISTRY}/${IMAGE_NAME}"
+          echo "image=$image_name" >> "$GITHUB_OUTPUT"
+        
+  provenance:
+    needs: [build-and-push-image]
+    permissions:
+      actions: read # for detecting the Github Actions environment.
+      id-token: write # for creating OIDC tokens for signing.
+      packages: write # for uploading attestations.
+    if: github.repository == 'restic/restic'
+    uses: slsa-framework/slsa-github-generator/.github/workflows/generator_container_slsa3.yml@v2.0.0
+    with:
+      image: ${{ needs.build-and-push-image.outputs.image }}
+      digest: ${{ needs.build-and-push-image.outputs.digest }}
+      registry-username: ${{ github.actor }}
+    secrets:
+      registry-password: ${{ secrets.GITHUB_TOKEN }}
\ No newline at end of file
diff --git a/changelog/unreleased/issue-2165 b/changelog/unreleased/issue-2165
new file mode 100644
index 000000000..12bc9dfd9
--- /dev/null
+++ b/changelog/unreleased/issue-2165
@@ -0,0 +1,16 @@
+Bugfix: Ignore disappeared backup source files
+
+If during a backup files were removed between restic listing the directory
+content and backing up the file in question, the following error could occur:
+
+```
+error: lstat /some/file/name: no such file or directory
+```
+
+The backup command now ignores this particular error and silently skips the
+removed file.
+
+https://github.com/restic/restic/issues/2165
+https://github.com/restic/restic/issues/3098
+https://github.com/restic/restic/pull/5143
+https://github.com/restic/restic/pull/5145
diff --git a/changelog/unreleased/issue-3697 b/changelog/unreleased/issue-3697
new file mode 100644
index 000000000..514f9d708
--- /dev/null
+++ b/changelog/unreleased/issue-3697
@@ -0,0 +1,12 @@
+Enhancement: Allow excluding online-only cloud files (e.g. OneDrive)
+
+Restic treated OneDrive Files On-Demand as though they were regular files
+for the purpose of backup which caused issues with VSS, could make backup
+incredibly slow (as OneDrive attempted to download files), or could fill
+the source disk (e.g. 1TB of files in OneDrive on a 500GB disk).
+Restic now allows the user to exclude these files when backing up with
+the `--exclude-cloud-files` switch.
+
+https://github.com/restic/restic/issues/3697
+https://github.com/restic/restic/issues/4935
+https://github.com/restic/restic/pull/4990
\ No newline at end of file
diff --git a/changelog/unreleased/issue-4983 b/changelog/unreleased/issue-4983
new file mode 100644
index 000000000..e5292cf5c
--- /dev/null
+++ b/changelog/unreleased/issue-4983
@@ -0,0 +1,8 @@
+Enhancement: add SLSA provenance to the GHCR Container images
+
+Restic's GitHub Container Registry (GHCR) image build workflow now includes
+SLSA provenance generation. This enhancement improves the security and 
+traceability of images built and pushed to GHCR. 
+
+https://github.com/restic/restic/issues/4983
+https://github.com/restic/restic/pull/4999
\ No newline at end of file
diff --git a/changelog/unreleased/issue-5092 b/changelog/unreleased/issue-5092
new file mode 100644
index 000000000..b6a32b68b
--- /dev/null
+++ b/changelog/unreleased/issue-5092
@@ -0,0 +1,8 @@
+Enhancement: Indicate the of deleted files/directories during restore
+
+Restic now indicates the number of deleted files/directories during restore.
+The `--json` output now includes a `files_deleted` field that shows the number
+of files and directories that were deleted during restore.
+
+https://github.com/restic/restic/issues/5092
+https://github.com/restic/restic/pull/5100
diff --git a/changelog/unreleased/issue-5131 b/changelog/unreleased/issue-5131
new file mode 100644
index 000000000..fd38a216d
--- /dev/null
+++ b/changelog/unreleased/issue-5131
@@ -0,0 +1,6 @@
+Enhancement: Add DragonflyBSD support
+
+Restic can now be compiled on DragonflyBSD.
+
+https://github.com/restic/restic/issues/5131
+https://github.com/restic/restic/pull/5138
diff --git a/changelog/unreleased/issue-5174 b/changelog/unreleased/issue-5174
new file mode 100644
index 000000000..7f7922a7a
--- /dev/null
+++ b/changelog/unreleased/issue-5174
@@ -0,0 +1,6 @@
+Enhancement: Enable xattr support, on NetBSD 10+
+
+Restic now supports backing up, and restoring extended attributes, on systems running NetBSD 10, or later.
+
+https://github.com/restic/restic/issues/5174
+https://github.com/restic/restic/pull/5180
diff --git a/changelog/unreleased/pull-5141 b/changelog/unreleased/pull-5141
new file mode 100644
index 000000000..7f71f2269
--- /dev/null
+++ b/changelog/unreleased/pull-5141
@@ -0,0 +1,7 @@
+Enhancement: Provide clear error message if AZURE_ACCOUNT_NAME is not set
+
+If AZURE_ACCOUNT_NAME is not set, any command related to an Azure repository
+would result in a misleading networking error. Restic will now detect this and
+provide a clear warning that the variable is not defined.
+
+https://github.com/restic/restic/pull/5141
diff --git a/changelog/unreleased/pull-5153 b/changelog/unreleased/pull-5153
new file mode 100644
index 000000000..e76d7f2fc
--- /dev/null
+++ b/changelog/unreleased/pull-5153
@@ -0,0 +1,6 @@
+Bugfix: Include root tree when searching using `find --tree`
+
+`restic find --tree` didn't find trees referenced by `restic snapshot --json`.
+It now correctly includes the root tree when searching.
+
+https://github.com/restic/restic/pull/5153
diff --git a/changelog/unreleased/pull-5162 b/changelog/unreleased/pull-5162
new file mode 100644
index 000000000..18e6be2b6
--- /dev/null
+++ b/changelog/unreleased/pull-5162
@@ -0,0 +1,7 @@
+Change: Promote feature flags
+
+The `explicit-s3-anonymous-auth` and `safe-forget-keep-tags` features are
+now stable and can no longer be disabled. The feature flags will be removed
+in restic 0.19.0.
+
+https://github.com/restic/restic/pull/5162
diff --git a/cmd/restic/cmd_backup.go b/cmd/restic/cmd_backup.go
index b7eed1318..42908557e 100644
--- a/cmd/restic/cmd_backup.go
+++ b/cmd/restic/cmd_backup.go
@@ -77,6 +77,7 @@ type BackupOptions struct {
 	ExcludeIfPresent  []string
 	ExcludeCaches     bool
 	ExcludeLargerThan string
+	ExcludeCloudFiles bool
 	Stdin             bool
 	StdinFilename     string
 	StdinCommand      bool
@@ -140,6 +141,7 @@ func init() {
 	f.BoolVar(&backupOptions.NoScan, "no-scan", false, "do not run scanner to estimate size of backup")
 	if runtime.GOOS == "windows" {
 		f.BoolVar(&backupOptions.UseFsSnapshot, "use-fs-snapshot", false, "use filesystem snapshot where possible (currently only Windows VSS)")
+		f.BoolVar(&backupOptions.ExcludeCloudFiles, "exclude-cloud-files", false, "excludes online-only cloud files (such as OneDrive Files On-Demand)")
 	}
 	f.BoolVar(&backupOptions.SkipIfUnchanged, "skip-if-unchanged", false, "skip snapshot creation if identical to parent snapshot")
 
@@ -347,6 +349,17 @@ func collectRejectFuncs(opts BackupOptions, targets []string, fs fs.FS) (funcs [
 		funcs = append(funcs, f)
 	}
 
+	if opts.ExcludeCloudFiles && !opts.Stdin && !opts.StdinCommand {
+		if runtime.GOOS != "windows" {
+			return nil, errors.Fatalf("exclude-cloud-files is only supported on Windows")
+		}
+		f, err := archiver.RejectCloudFiles(Warnf)
+		if err != nil {
+			return nil, err
+		}
+		funcs = append(funcs, f)
+	}
+
 	if opts.ExcludeCaches {
 		opts.ExcludeIfPresent = append(opts.ExcludeIfPresent, "CACHEDIR.TAG:Signature: 8a477f597d28d172789f06886806bc55")
 	}
diff --git a/cmd/restic/cmd_find.go b/cmd/restic/cmd_find.go
index 92e88203f..2e06fa00c 100644
--- a/cmd/restic/cmd_find.go
+++ b/cmd/restic/cmd_find.go
@@ -336,6 +336,26 @@ func (f *Finder) findInSnapshot(ctx context.Context, sn *restic.Snapshot) error
 	}})
 }
 
+func (f *Finder) findTree(treeID restic.ID, nodepath string) error {
+	found := false
+	if _, ok := f.treeIDs[treeID.String()]; ok {
+		found = true
+	} else if _, ok := f.treeIDs[treeID.Str()]; ok {
+		found = true
+	}
+	if found {
+		f.out.PrintObject("tree", treeID.String(), nodepath, "", f.out.newsn)
+		f.itemsFound++
+		// Terminate if we have found all trees (and we are not
+		// looking for blobs)
+		if f.itemsFound >= len(f.treeIDs) && f.blobIDs == nil {
+			// Return an error to terminate the Walk
+			return errors.New("OK")
+		}
+	}
+	return nil
+}
+
 func (f *Finder) findIDs(ctx context.Context, sn *restic.Snapshot) error {
 	debug.Log("searching IDs in snapshot %s", sn.ID())
 
@@ -354,26 +374,17 @@ func (f *Finder) findIDs(ctx context.Context, sn *restic.Snapshot) error {
 		}
 
 		if node == nil {
+			if nodepath == "/" {
+				if err := f.findTree(parentTreeID, "/"); err != nil {
+					return err
+				}
+			}
 			return nil
 		}
 
-		if node.Type == restic.NodeTypeDir && f.treeIDs != nil {
-			treeID := node.Subtree
-			found := false
-			if _, ok := f.treeIDs[treeID.Str()]; ok {
-				found = true
-			} else if _, ok := f.treeIDs[treeID.String()]; ok {
-				found = true
-			}
-			if found {
-				f.out.PrintObject("tree", treeID.String(), nodepath, "", sn)
-				f.itemsFound++
-				// Terminate if we have found all trees (and we are not
-				// looking for blobs)
-				if f.itemsFound >= len(f.treeIDs) && f.blobIDs == nil {
-					// Return an error to terminate the Walk
-					return errors.New("OK")
-				}
+		if node.Type == "dir" && f.treeIDs != nil {
+			if err := f.findTree(*node.Subtree, nodepath); err != nil {
+				return err
 			}
 		}
 
diff --git a/cmd/restic/cmd_forget.go b/cmd/restic/cmd_forget.go
index 58a9d25b7..f770dc365 100644
--- a/cmd/restic/cmd_forget.go
+++ b/cmd/restic/cmd_forget.go
@@ -8,7 +8,6 @@ import (
 	"strconv"
 
 	"github.com/restic/restic/internal/errors"
-	"github.com/restic/restic/internal/feature"
 	"github.com/restic/restic/internal/restic"
 	"github.com/restic/restic/internal/ui/termstatus"
 	"github.com/spf13/cobra"
@@ -271,7 +270,7 @@ func runForget(ctx context.Context, opts ForgetOptions, pruneOptions PruneOption
 
 			keep, remove, reasons := restic.ApplyPolicy(snapshotGroup, policy)
 
-			if feature.Flag.Enabled(feature.SafeForgetKeepTags) && !policy.Empty() && len(keep) == 0 {
+			if !policy.Empty() && len(keep) == 0 {
 				return fmt.Errorf("refusing to delete last snapshot of snapshot group \"%v\"", key.String())
 			}
 			if len(keep) != 0 && !gopts.Quiet && !gopts.JSON {
diff --git a/cmd/restic/cmd_prune.go b/cmd/restic/cmd_prune.go
index a74ba23f7..213714799 100644
--- a/cmd/restic/cmd_prune.go
+++ b/cmd/restic/cmd_prune.go
@@ -74,7 +74,7 @@ func init() {
 func addPruneOptions(c *cobra.Command, pruneOptions *PruneOptions) {
 	f := c.Flags()
 	f.StringVar(&pruneOptions.MaxUnused, "max-unused", "5%", "tolerate given `limit` of unused data (absolute value in bytes with suffixes k/K, m/M, g/G, t/T, a value in % or the word 'unlimited')")
-	f.StringVar(&pruneOptions.MaxRepackSize, "max-repack-size", "", "maximum `size` to repack (allowed suffixes: k/K, m/M, g/G, t/T)")
+	f.StringVar(&pruneOptions.MaxRepackSize, "max-repack-size", "", "stop after repacking this much data in total (allowed suffixes for `size`: k/K, m/M, g/G, t/T)")
 	f.BoolVar(&pruneOptions.RepackCacheableOnly, "repack-cacheable-only", false, "only repack packs which are cacheable")
 	f.BoolVar(&pruneOptions.RepackSmall, "repack-small", false, "repack pack files below 80% of target pack size")
 	f.BoolVar(&pruneOptions.RepackUncompressed, "repack-uncompressed", false, "repack all uncompressed data")
diff --git a/doc/040_backup.rst b/doc/040_backup.rst
index 696b235cc..a30d80402 100644
--- a/doc/040_backup.rst
+++ b/doc/040_backup.rst
@@ -297,7 +297,8 @@ the exclude options are:
 -  ``--exclude-file`` Specified one or more times to exclude items listed in a given file
 -  ``--iexclude-file`` Same as ``exclude-file`` but ignores cases like in ``--iexclude``
 -  ``--exclude-if-present foo`` Specified one or more times to exclude a folder's content if it contains a file called ``foo`` (optionally having a given header, no wildcards for the file name supported)
--  ``--exclude-larger-than size`` Specified once to excludes files larger than the given size
+-  ``--exclude-larger-than size`` Specified once to exclude files larger than the given size
+-  ``--exclude-cloud-files`` Specified once to exclude online-only cloud files (such as OneDrive Files On-Demand), currently only supported on Windows
 
 Please see ``restic help backup`` for more specific information about each exclude option.
 
diff --git a/doc/075_scripting.rst b/doc/075_scripting.rst
index 88fc1f35b..39a6dbc7f 100644
--- a/doc/075_scripting.rst
+++ b/doc/075_scripting.rst
@@ -563,6 +563,8 @@ Status
 +----------------------+------------------------------------------------------------+
 |``files_skipped``     | Files skipped due to overwrite setting                     |
 +----------------------+------------------------------------------------------------+
+|``files_deleted``     | Files deleted                                              |
++----------------------+------------------------------------------------------------+
 |``total_bytes``       | Total number of bytes in restore set                       |
 +----------------------+------------------------------------------------------------+
 |``bytes_restored``    | Number of bytes restored                                   |
@@ -615,6 +617,8 @@ Summary
 +----------------------+------------------------------------------------------------+
 |``files_skipped``     | Files skipped due to overwrite setting                     |
 +----------------------+------------------------------------------------------------+
+|``files_deleted``     | Files deleted                                              |
++----------------------+------------------------------------------------------------+
 |``total_bytes``       | Total number of bytes in restore set                       |
 +----------------------+------------------------------------------------------------+
 |``bytes_restored``    | Number of bytes restored                                   |
diff --git a/doc/developer_information.rst b/doc/developer_information.rst
index f0fe28c32..f1eae3a67 100644
--- a/doc/developer_information.rst
+++ b/doc/developer_information.rst
@@ -113,6 +113,34 @@ The following steps are necessary to build the binaries:
         restic/builder \
         go run helpers/build-release-binaries/main.go --version 0.14.0 --verbose
 
+Verifying SLSA Provenance for Docker Images
+*******************************************
+
+Our Docker images are built with SLSA (Supply-chain Levels for Software Artifacts)
+provenance. 
+
+To verify this provenance:
+
+1. Install the `slsa-verifier` tool from https://github.com/slsa-framework/slsa-verifier
+
+2. Run the following command:
+
+   .. code-block:: console
+
+      $ slsa-verifier verify-image \
+        --source-uri github.com/restic/restic \
+        <image-name>@<digest>
+
+   Replace `<tag>` with the Git tag of the release you're verifying, `<image-name>`
+   with the full name of the Docker image (including the registry), and `<digest>`
+   with the SHA256 digest of the image.
+
+3. If the verification is successful, you'll see output indicating that the provenance 
+is valid.
+
+This verification ensures that the Docker image was built by our official GitHub
+Actions workflow and has not been tampered with since its creation.
+
 Verifying the Official Binaries
 *******************************
 
diff --git a/go.mod b/go.mod
index 007fbd255..2193e738c 100644
--- a/go.mod
+++ b/go.mod
@@ -4,8 +4,9 @@ require (
 	cloud.google.com/go/storage v1.43.0
 	github.com/Azure/azure-sdk-for-go/sdk/azcore v1.16.0
 	github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.0
-	github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.4.0
-	github.com/Backblaze/blazer v0.6.1
+	github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.5.0
+	github.com/Backblaze/blazer v0.7.1
+	github.com/Microsoft/go-winio v0.6.2
 	github.com/anacrolix/fuse v0.3.1
 	github.com/cenkalti/backoff/v4 v4.3.0
 	github.com/cespare/xxhash/v2 v2.3.0
@@ -13,25 +14,25 @@ require (
 	github.com/go-ole/go-ole v1.3.0
 	github.com/google/go-cmp v0.6.0
 	github.com/hashicorp/golang-lru/v2 v2.0.7
-	github.com/klauspost/compress v1.17.9
+	github.com/klauspost/compress v1.17.11
 	github.com/minio/minio-go/v7 v7.0.77
 	github.com/ncw/swift/v2 v2.0.3
 	github.com/peterbourgon/unixtransport v0.0.4
 	github.com/pkg/errors v0.9.1
 	github.com/pkg/profile v1.7.0
-	github.com/pkg/sftp v1.13.6
+	github.com/pkg/sftp v1.13.7
 	github.com/pkg/xattr v0.4.10
 	github.com/restic/chunker v0.4.0
 	github.com/spf13/cobra v1.8.1
 	github.com/spf13/pflag v1.0.5
 	go.uber.org/automaxprocs v1.6.0
-	golang.org/x/crypto v0.28.0
-	golang.org/x/net v0.30.0
+	golang.org/x/crypto v0.32.0
+	golang.org/x/net v0.34.0
 	golang.org/x/oauth2 v0.23.0
-	golang.org/x/sync v0.8.0
-	golang.org/x/sys v0.26.0
-	golang.org/x/term v0.25.0
-	golang.org/x/text v0.19.0
+	golang.org/x/sync v0.10.0
+	golang.org/x/sys v0.29.0
+	golang.org/x/term v0.28.0
+	golang.org/x/text v0.21.0
 	golang.org/x/time v0.7.0
 	google.golang.org/api v0.204.0
 )
diff --git a/go.sum b/go.sum
index 25b0e0698..ef829bd90 100644
--- a/go.sum
+++ b/go.sum
@@ -23,16 +23,18 @@ github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 h1:ywEEhmNahHBihViHepv3xP
 github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0/go.mod h1:iZDifYGJTIgIIkYRNWPENUnqx6bJ2xnSDFI2tjwZNuY=
 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.6.0 h1:PiSrjRPpkQNjrM8H0WwKMnZUdu1RGMtd/LdGKUrOo+c=
 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.6.0/go.mod h1:oDrbWx4ewMylP7xHivfgixbfGBT6APAwsSoHRKotnIc=
-github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.4.0 h1:Be6KInmFEKV81c0pOAEbRYehLMwmmGI1exuFj248AMk=
-github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.4.0/go.mod h1:WCPBHsOXfBVnivScjs2ypRfimjEW0qPVLGgJkZlrIOA=
+github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.5.0 h1:mlmW46Q0B79I+Aj4azKC6xDMFN9a9SyZWESlGWYXbFs=
+github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.5.0/go.mod h1:PXe2h+LKcWTX9afWdZoHyODqR4fBa5boUM/8uJfZ0Jo=
 github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM=
 github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE=
 github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 h1:XHOnouVk1mxXfQidrMEnLlPk9UMeRtyBTnEFtxkV0kU=
 github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
-github.com/Backblaze/blazer v0.6.1 h1:xC9HyC7OcxRzzmtfRiikIEvq4HZYWjU6caFwX2EXw1s=
-github.com/Backblaze/blazer v0.6.1/go.mod h1:7/jrGx4O6OKOto6av+hLwelPR8rwZ+PLxQ5ZOiYAjwY=
+github.com/Backblaze/blazer v0.7.1 h1:J43PbFj6hXLg1jvCNr+rQoAsxzKK0IP7ftl1ReCwpcQ=
+github.com/Backblaze/blazer v0.7.1/go.mod h1:MhntL1nMpIuoqrPP6TnZu/xTydMgOAe/Xm6KongbjKs=
 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
 github.com/Julusian/godocdown v0.0.0-20170816220326-6d19f8ff2df8/go.mod h1:INZr5t32rG59/5xeltqoCJoNY7e5x/3xoY9WSWVWg74=
+github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
+github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
 github.com/anacrolix/envpprof v1.3.0 h1:WJt9bpuT7A/CDCxPOv/eeZqHWlle/Y0keJUvc6tcJDk=
 github.com/anacrolix/envpprof v1.3.0/go.mod h1:7QIG4CaX1uexQ3tqd5+BRa/9e2D02Wcertl6Yh0jCB0=
 github.com/anacrolix/fuse v0.3.1 h1:oT8s3B5HFkBdLe/WKJO5MNo9iIyEtc+BhvTZYp4jhDM=
@@ -134,8 +136,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
 github.com/keybase/go-keychain v0.0.0-20231219164618-57a3676c3af6 h1:IsMZxCuZqKuao2vNdfD82fjjgPLfyHLpR41Z88viRWs=
 github.com/keybase/go-keychain v0.0.0-20231219164618-57a3676c3af6/go.mod h1:3VeWNIJaW+O5xpRQbPp0Ybqu1vJd/pm7s2F473HRrkw=
-github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
-github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
+github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
+github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
 github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
 github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM=
 github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
@@ -170,8 +172,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
 github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA=
 github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo=
-github.com/pkg/sftp v1.13.6 h1:JFZT4XbOU7l77xGSpOdW+pwIMqP044IyjXX6FGyEKFo=
-github.com/pkg/sftp v1.13.6/go.mod h1:tz1ryNURKu77RL+GuCzmoJYxQczL3wLNNpPWagdg4Qk=
+github.com/pkg/sftp v1.13.7 h1:uv+I3nNJvlKZIQGSr8JVQLNHFU9YhhNpvC14Y6KgmSM=
+github.com/pkg/sftp v1.13.7/go.mod h1:KMKI0t3T6hfA+lTR/ssZdunHo+uwq7ghoN09/FSu3DY=
 github.com/pkg/xattr v0.4.10 h1:Qe0mtiNFHQZ296vRgUjRCoPHPqH7VdTOrZx3g0T+pGA=
 github.com/pkg/xattr v0.4.10/go.mod h1:di8WF84zAKk8jzR1UBTEWh9AUlIZZ7M/JNt8e9B6ktU=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -232,9 +234,9 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
 golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
-golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw=
-golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
-golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
+golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
+golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
+golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
 golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
 golang.org/x/exp v0.0.0-20220428152302-39d4317da171 h1:TfdoLivD44QwvssI9Sv1xwa5DcL5XQr4au4sZ2F2NV4=
 golang.org/x/exp v0.0.0-20220428152302-39d4317da171/go.mod h1:lgLbSvA5ygNOMpwM/9anMpWVlVJ7Z+cHWq/eFuinpGE=
@@ -245,6 +247,7 @@ golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
 golang.org/x/mod v0.6.0-dev.0.20211013180041-c96bc1413d57/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY=
 golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
 golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
+golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
 golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -255,10 +258,11 @@ golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwY
 golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
 golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
-golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
 golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
-golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
-golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
+golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
+golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
+golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
+golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
 golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs=
 golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
@@ -268,8 +272,8 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ
 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
-golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
+golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
 golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -285,22 +289,29 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
-golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
+golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
-golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
-golang.org/x/term v0.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24=
-golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M=
+golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
+golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
+golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
+golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
+golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
 golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
-golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
-golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
+golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
+golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
+golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
 golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ=
 golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -312,6 +323,7 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn
 golang.org/x/tools v0.1.8-0.20211029000441-d6a9af8af023/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU=
 golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
 golang.org/x/tools v0.3.0/go.mod h1:/rWhSS2+zyEVwoJf8YAX6L2f0ntZ7Kn/mGgAWcipA5k=
+golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
diff --git a/helpers/build-release-binaries/main.go b/helpers/build-release-binaries/main.go
index 81d126b00..8fe8c24fb 100644
--- a/helpers/build-release-binaries/main.go
+++ b/helpers/build-release-binaries/main.go
@@ -243,14 +243,15 @@ func buildTargets(sourceDir, outputDir string, targets map[string][]string) {
 }
 
 var defaultBuildTargets = map[string][]string{
-	"aix":     {"ppc64"},
-	"darwin":  {"amd64", "arm64"},
-	"freebsd": {"386", "amd64", "arm"},
-	"linux":   {"386", "amd64", "arm", "arm64", "ppc64le", "mips", "mipsle", "mips64", "mips64le", "riscv64", "s390x"},
-	"netbsd":  {"386", "amd64"},
-	"openbsd": {"386", "amd64"},
-	"windows": {"386", "amd64"},
-	"solaris": {"amd64"},
+	"aix":       {"ppc64"},
+	"darwin":    {"amd64", "arm64"},
+	"dragonfly": {"amd64"},
+	"freebsd":   {"386", "amd64", "arm"},
+	"linux":     {"386", "amd64", "arm", "arm64", "ppc64le", "mips", "mipsle", "mips64", "mips64le", "riscv64", "s390x"},
+	"netbsd":    {"386", "amd64"},
+	"openbsd":   {"386", "amd64"},
+	"windows":   {"386", "amd64"},
+	"solaris":   {"amd64"},
 }
 
 func downloadModules(sourceDir string) {
diff --git a/internal/archiver/archiver.go b/internal/archiver/archiver.go
index f7a9f2750..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
@@ -66,6 +66,11 @@ func (s *ItemStats) Add(other ItemStats) {
 	s.TreeSizeInRepo += other.TreeSizeInRepo
 }
 
+// ToNoder returns a restic.Node for a File.
+type ToNoder interface {
+	ToNode(ignoreXattrListError bool) (*restic.Node, error)
+}
+
 type archiverRepo interface {
 	restic.Loader
 	restic.BlobSaver
@@ -184,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(),
 
@@ -257,8 +262,8 @@ func (arch *Archiver) trackItem(item string, previous, current *restic.Node, s I
 }
 
 // nodeFromFileInfo returns the restic node from an os.FileInfo.
-func (arch *Archiver) nodeFromFileInfo(snPath, filename string, fi os.FileInfo, ignoreXattrListError bool) (*restic.Node, error) {
-	node, err := arch.FS.NodeFromFileInfo(filename, fi, ignoreXattrListError)
+func (arch *Archiver) nodeFromFileInfo(snPath, filename string, meta ToNoder, ignoreXattrListError bool) (*restic.Node, error) {
+	node, err := meta.ToNode(ignoreXattrListError)
 	if !arch.WithAtime {
 		node.AccessTime = node.ModTime
 	}
@@ -308,20 +313,14 @@ func (arch *Archiver) wrapLoadTreeError(id restic.ID, err error) error {
 
 // saveDir stores a directory in the repo and returns the node. snPath is the
 // path within the current snapshot.
-func (arch *Archiver) saveDir(ctx context.Context, snPath string, dir string, fi os.FileInfo, previous *restic.Tree, complete fileCompleteFunc) (d futureNode, err error) {
+func (arch *Archiver) saveDir(ctx context.Context, snPath string, dir string, meta fs.File, previous *restic.Tree, complete fileCompleteFunc) (d futureNode, err error) {
 	debug.Log("%v %v", snPath, dir)
 
-	treeNode, err := arch.nodeFromFileInfo(snPath, dir, fi, false)
+	treeNode, names, err := arch.dirToNodeAndEntries(snPath, dir, meta)
 	if err != nil {
 		return futureNode{}, err
 	}
 
-	names, err := fs.Readdirnames(arch.FS, dir, fs.O_NOFOLLOW)
-	if err != nil {
-		return futureNode{}, err
-	}
-	sort.Strings(names)
-
 	nodes := make([]futureNode, 0, len(names))
 
 	for _, name := range names {
@@ -359,6 +358,29 @@ func (arch *Archiver) saveDir(ctx context.Context, snPath string, dir string, fi
 	return fn, nil
 }
 
+func (arch *Archiver) dirToNodeAndEntries(snPath, dir string, meta fs.File) (node *restic.Node, names []string, err error) {
+	err = meta.MakeReadable()
+	if err != nil {
+		return nil, nil, fmt.Errorf("openfile for readdirnames failed: %w", err)
+	}
+
+	node, err = arch.nodeFromFileInfo(snPath, dir, meta, false)
+	if err != nil {
+		return nil, nil, err
+	}
+	if node.Type != restic.NodeTypeDir {
+		return nil, nil, fmt.Errorf("directory %q changed type, refusing to archive", snPath)
+	}
+
+	names, err = meta.Readdirnames(-1)
+	if err != nil {
+		return nil, nil, fmt.Errorf("readdirnames %v failed: %w", dir, err)
+	}
+	sort.Strings(names)
+
+	return node, names, nil
+}
+
 // futureNode holds a reference to a channel that returns a FutureNodeResult
 // or a reference to an already existing result. If the result is available
 // immediately, then storing a reference directly requires less memory than
@@ -435,21 +457,47 @@ func (arch *Archiver) save(ctx context.Context, snPath, target string, previous
 		return futureNode{}, false, err
 	}
 
+	filterError := func(err error) (futureNode, bool, error) {
+		err = arch.error(abstarget, err)
+		if err != nil {
+			return futureNode{}, false, errors.WithStack(err)
+		}
+		return futureNode{}, true, nil
+	}
+	filterNotExist := func(err error) error {
+		if errors.Is(err, os.ErrNotExist) {
+			return nil
+		}
+		return err
+	}
 	// exclude files by path before running Lstat to reduce number of lstat calls
 	if !arch.SelectByName(abstarget) {
 		debug.Log("%v is excluded by path", target)
 		return futureNode{}, true, nil
 	}
 
+	meta, err := arch.FS.OpenFile(target, fs.O_NOFOLLOW, true)
+	if err != nil {
+		debug.Log("open metadata for %v returned error: %v", target, err)
+		// ignore if file disappeared since it was returned by readdir
+		return filterError(filterNotExist(err))
+	}
+	closeFile := true
+	defer func() {
+		if closeFile {
+			cerr := meta.Close()
+			if err == nil {
+				err = cerr
+			}
+		}
+	}()
+
 	// get file info and run remaining select functions that require file information
-	fi, err := arch.FS.Lstat(target)
+	fi, err := meta.Stat()
 	if err != nil {
 		debug.Log("lstat() for %v returned error: %v", target, err)
-		err = arch.error(abstarget, err)
-		if err != nil {
-			return futureNode{}, false, errors.WithStack(err)
-		}
-		return futureNode{}, true, nil
+		// ignore if file disappeared since it was returned by readdir
+		return filterError(filterNotExist(err))
 	}
 	if !arch.Select(abstarget, fi, arch.FS) {
 		debug.Log("%v is excluded", target)
@@ -457,17 +505,17 @@ 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))
 				arch.CompleteBlob(previous.Size)
-				node, err := arch.nodeFromFileInfo(snPath, target, fi, false)
+				node, err := arch.nodeFromFileInfo(snPath, target, meta, false)
 				if err != nil {
 					return futureNode{}, false, err
 				}
@@ -494,40 +542,28 @@ func (arch *Archiver) save(ctx context.Context, snPath, target string, previous
 
 		// reopen file and do an fstat() on the open file to check it is still
 		// a file (and has not been exchanged for e.g. a symlink)
-		file, err := arch.FS.OpenFile(target, fs.O_RDONLY|fs.O_NOFOLLOW, 0)
+		err := meta.MakeReadable()
 		if err != nil {
-			debug.Log("Openfile() for %v returned error: %v", target, err)
-			err = arch.error(abstarget, err)
-			if err != nil {
-				return futureNode{}, false, errors.WithStack(err)
-			}
-			return futureNode{}, true, nil
+			debug.Log("MakeReadable() for %v returned error: %v", target, err)
+			return filterError(err)
 		}
 
-		fi, err = file.Stat()
+		fi, err := meta.Stat()
 		if err != nil {
 			debug.Log("stat() on opened file %v returned error: %v", target, err)
-			_ = file.Close()
-			err = arch.error(abstarget, err)
-			if err != nil {
-				return futureNode{}, false, errors.WithStack(err)
-			}
-			return futureNode{}, true, nil
+			return filterError(err)
 		}
 
 		// make sure it's still a file
-		if !fi.Mode().IsRegular() {
-			err = errors.Errorf("file %v changed type, refusing to archive", fi.Name())
-			_ = file.Close()
-			err = arch.error(abstarget, err)
-			if err != nil {
-				return futureNode{}, false, err
-			}
-			return futureNode{}, true, nil
+		if !fi.Mode.IsRegular() {
+			err = errors.Errorf("file %q changed type, refusing to archive", target)
+			return filterError(err)
 		}
 
+		closeFile = false
+
 		// Save will close the file, we don't need to do that
-		fn = arch.fileSaver.Save(ctx, snPath, target, file, fi, func() {
+		fn = arch.fileSaver.Save(ctx, snPath, target, meta, func() {
 			arch.StartFile(snPath)
 		}, func() {
 			arch.trackItem(snPath, nil, nil, ItemStats{}, 0)
@@ -535,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 + "/"
@@ -547,7 +583,7 @@ func (arch *Archiver) save(ctx context.Context, snPath, target string, previous
 			return futureNode{}, false, err
 		}
 
-		fn, err = arch.saveDir(ctx, snPath, target, fi, oldSubtree,
+		fn, err = arch.saveDir(ctx, snPath, target, meta, oldSubtree,
 			func(node *restic.Node, stats ItemStats) {
 				arch.trackItem(snItem, previous, node, stats, time.Since(start))
 			})
@@ -556,14 +592,14 @@ 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
 
 	default:
 		debug.Log("  %v other", target)
 
-		node, err := arch.nodeFromFileInfo(snPath, target, fi, false)
+		node, err := arch.nodeFromFileInfo(snPath, target, meta, false)
 		if err != nil {
 			return futureNode{}, false, err
 		}
@@ -582,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
 	}
 
@@ -614,22 +649,6 @@ func join(elem ...string) string {
 	return path.Join(elem...)
 }
 
-// statDir returns the file info for the directory. Symbolic links are
-// resolved. If the target directory is not a directory, an error is returned.
-func (arch *Archiver) statDir(dir string) (os.FileInfo, error) {
-	fi, err := arch.FS.Stat(dir)
-	if err != nil {
-		return nil, errors.WithStack(err)
-	}
-
-	tpe := fi.Mode() & (os.ModeType | os.ModeCharDevice)
-	if tpe != os.ModeDir {
-		return fi, errors.Errorf("path is not a directory: %v", dir)
-	}
-
-	return fi, nil
-}
-
 // saveTree stores a Tree in the repo, returned is the tree. snPath is the path
 // within the current snapshot.
 func (arch *Archiver) saveTree(ctx context.Context, snPath string, atree *tree, previous *restic.Tree, complete fileCompleteFunc) (futureNode, int, error) {
@@ -640,15 +659,8 @@ func (arch *Archiver) saveTree(ctx context.Context, snPath string, atree *tree,
 			return futureNode{}, 0, errors.Errorf("FileInfoPath for %v is empty", snPath)
 		}
 
-		fi, err := arch.statDir(atree.FileInfoPath)
-		if err != nil {
-			return futureNode{}, 0, err
-		}
-
-		debug.Log("%v, dir node data loaded from %v", snPath, atree.FileInfoPath)
-		// in some cases reading xattrs for directories above the backup source is not allowed
-		// thus ignore errors for such folders.
-		node, err = arch.nodeFromFileInfo(snPath, atree.FileInfoPath, fi, true)
+		var err error
+		node, err = arch.dirPathToNode(snPath, atree.FileInfoPath)
 		if err != nil {
 			return futureNode{}, 0, err
 		}
@@ -719,6 +731,31 @@ func (arch *Archiver) saveTree(ctx context.Context, snPath string, atree *tree,
 	return fn, len(nodes), nil
 }
 
+func (arch *Archiver) dirPathToNode(snPath, target string) (node *restic.Node, err error) {
+	meta, err := arch.FS.OpenFile(target, 0, true)
+	if err != nil {
+		return nil, err
+	}
+	defer func() {
+		cerr := meta.Close()
+		if err == nil {
+			err = cerr
+		}
+	}()
+
+	debug.Log("%v, reading dir node data from %v", snPath, target)
+	// in some cases reading xattrs for directories above the backup source is not allowed
+	// thus ignore errors for such folders.
+	node, err = arch.nodeFromFileInfo(snPath, target, meta, true)
+	if err != nil {
+		return nil, err
+	}
+	if node.Type != restic.NodeTypeDir {
+		return nil, errors.Errorf("path is not a directory: %v", target)
+	}
+	return node, err
+}
+
 // resolveRelativeTargets replaces targets that only contain relative
 // directories ("." or "../../") with the contents of the directory. Each
 // element of target is processed with fs.Clean().
diff --git a/internal/archiver/exclude.go b/internal/archiver/exclude.go
index 1e855fc3a..c7dff0acb 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
 }
@@ -135,9 +136,9 @@ func isExcludedByFile(filename, tagFilename, header string, rc *rejectionCache,
 	return rejected
 }
 
-func isDirExcludedByFile(dir, tagFilename, header string, fs fs.FS, warnf func(msg string, args ...interface{})) bool {
-	tf := fs.Join(dir, tagFilename)
-	_, err := fs.Lstat(tf)
+func isDirExcludedByFile(dir, tagFilename, header string, fsInst fs.FS, warnf func(msg string, args ...interface{})) bool {
+	tf := fsInst.Join(dir, tagFilename)
+	_, err := fsInst.Lstat(tf)
 	if errors.Is(err, os.ErrNotExist) {
 		return false
 	}
@@ -153,7 +154,7 @@ func isDirExcludedByFile(dir, tagFilename, header string, fs fs.FS, warnf func(m
 	// From this stage, errors mean tagFilename exists but it is malformed.
 	// Warnings will be generated so that the user is informed that the
 	// indented ignore-action is not performed.
-	f, err := fs.OpenFile(tf, os.O_RDONLY, 0)
+	f, err := fsInst.OpenFile(tf, fs.O_RDONLY, false)
 	if err != nil {
 		warnf("could not open exclusion tagfile: %v", err)
 		return false
@@ -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
@@ -330,3 +316,21 @@ func RejectBySize(maxSize int64) (RejectFunc, error) {
 		return false
 	}, nil
 }
+
+// RejectCloudFiles returns a func which rejects files which are online-only cloud files
+func RejectCloudFiles(warnf func(msg string, args ...interface{})) (RejectFunc, error) {
+	return func(item string, fi *fs.ExtendedFileInfo, _ fs.FS) bool {
+		recall, err := fi.RecallOnDataAccess()
+		if err != nil {
+			warnf("item %v: error checking online-only status: %v", item, err)
+			return false
+		}
+
+		if recall {
+			debug.Log("rejecting online-only cloud file %s", item)
+			return true
+		}
+
+		return false
+	}, nil
+}
diff --git a/internal/archiver/file_saver.go b/internal/archiver/file_saver.go
index dccaa9442..ca8ec2fbb 100644
--- a/internal/archiver/file_saver.go
+++ b/internal/archiver/file_saver.go
@@ -4,7 +4,6 @@ import (
 	"context"
 	"fmt"
 	"io"
-	"os"
 	"sync"
 
 	"github.com/restic/chunker"
@@ -29,7 +28,7 @@ type fileSaver struct {
 
 	CompleteBlob func(bytes uint64)
 
-	NodeFromFileInfo func(snPath, filename string, fi os.FileInfo, ignoreXattrListError bool) (*restic.Node, error)
+	NodeFromFileInfo func(snPath, filename string, meta ToNoder, ignoreXattrListError bool) (*restic.Node, error)
 }
 
 // newFileSaver returns a new file saver. A worker pool with fileWorkers is
@@ -71,13 +70,12 @@ type fileCompleteFunc func(*restic.Node, ItemStats)
 // file is closed by Save. completeReading is only called if the file was read
 // successfully. complete is always called. If completeReading is called, then
 // this will always happen before calling complete.
-func (s *fileSaver) Save(ctx context.Context, snPath string, target string, file fs.File, fi os.FileInfo, start func(), completeReading func(), complete fileCompleteFunc) futureNode {
+func (s *fileSaver) Save(ctx context.Context, snPath string, target string, file fs.File, start func(), completeReading func(), complete fileCompleteFunc) futureNode {
 	fn, ch := newFutureNode()
 	job := saveFileJob{
 		snPath: snPath,
 		target: target,
 		file:   file,
-		fi:     fi,
 		ch:     ch,
 
 		start:           start,
@@ -100,7 +98,6 @@ type saveFileJob struct {
 	snPath string
 	target string
 	file   fs.File
-	fi     os.FileInfo
 	ch     chan<- futureNodeResult
 
 	start           func()
@@ -109,7 +106,7 @@ type saveFileJob struct {
 }
 
 // saveFile stores the file f in the repo, then closes it.
-func (s *fileSaver) saveFile(ctx context.Context, chnker *chunker.Chunker, snPath string, target string, f fs.File, fi os.FileInfo, start func(), finishReading func(), finish func(res futureNodeResult)) {
+func (s *fileSaver) saveFile(ctx context.Context, chnker *chunker.Chunker, snPath string, target string, f fs.File, start func(), finishReading func(), finish func(res futureNodeResult)) {
 	start()
 
 	fnr := futureNodeResult{
@@ -156,7 +153,7 @@ func (s *fileSaver) saveFile(ctx context.Context, chnker *chunker.Chunker, snPat
 
 	debug.Log("%v", snPath)
 
-	node, err := s.NodeFromFileInfo(snPath, target, fi, false)
+	node, err := s.NodeFromFileInfo(snPath, target, f, false)
 	if err != nil {
 		_ = f.Close()
 		completeError(err)
@@ -262,7 +259,7 @@ func (s *fileSaver) worker(ctx context.Context, jobs <-chan saveFileJob) {
 			}
 		}
 
-		s.saveFile(ctx, chnker, job.snPath, job.target, job.file, job.fi, job.start, func() {
+		s.saveFile(ctx, chnker, job.snPath, job.target, job.file, job.start, func() {
 			if job.completeReading != nil {
 				job.completeReading()
 			}
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/backend/azure/azure.go b/internal/backend/azure/azure.go
index c1c049a94..27390ee13 100644
--- a/internal/backend/azure/azure.go
+++ b/internal/backend/azure/azure.go
@@ -62,6 +62,11 @@ func open(cfg Config, rt http.RoundTripper) (*Backend, error) {
 	} else {
 		endpointSuffix = "core.windows.net"
 	}
+
+	if cfg.AccountName == "" {
+		return nil, errors.Fatalf("unable to open Azure backend: Account name ($AZURE_ACCOUNT_NAME) is empty")
+	}
+
 	url := fmt.Sprintf("https://%s.blob.%s/%s", cfg.AccountName, endpointSuffix, cfg.Container)
 	opts := &azContainer.ClientOptions{
 		ClientOptions: azcore.ClientOptions{
diff --git a/internal/backend/s3/s3.go b/internal/backend/s3/s3.go
index 2176d289d..e3d4cc499 100644
--- a/internal/backend/s3/s3.go
+++ b/internal/backend/s3/s3.go
@@ -129,12 +129,7 @@ func getCredentials(cfg Config, tr http.RoundTripper) (*credentials.Credentials,
 		// Fail if no credentials were found to prevent repeated attempts to (unsuccessfully) retrieve new credentials.
 		// The first attempt still has to timeout which slows down restic usage considerably. Thus, migrate towards forcing
 		// users to explicitly decide between authenticated and anonymous access.
-		if feature.Flag.Enabled(feature.ExplicitS3AnonymousAuth) {
-			return nil, fmt.Errorf("no credentials found. Use `-o s3.unsafe-anonymous-auth=true` for anonymous authentication")
-		}
-
-		debug.Log("using anonymous access for %#v", cfg.Endpoint)
-		creds = credentials.New(&credentials.Static{})
+		return nil, fmt.Errorf("no credentials found. Use `-o s3.unsafe-anonymous-auth=true` for anonymous authentication")
 	}
 
 	roleArn := os.Getenv("RESTIC_AWS_ASSUME_ROLE_ARN")
diff --git a/internal/feature/registry.go b/internal/feature/registry.go
index 7fe7da965..0773ea136 100644
--- a/internal/feature/registry.go
+++ b/internal/feature/registry.go
@@ -15,7 +15,7 @@ func init() {
 	Flag.SetFlags(map[FlagName]FlagDesc{
 		BackendErrorRedesign:    {Type: Beta, Description: "enforce timeouts for stuck HTTP requests and use new backend error handling design."},
 		DeviceIDForHardlinks:    {Type: Alpha, Description: "store deviceID only for hardlinks to reduce metadata changes for example when using btrfs subvolumes. Will be removed in a future restic version after repository format 3 is available"},
-		ExplicitS3AnonymousAuth: {Type: Beta, Description: "forbid anonymous S3 authentication unless `-o s3.unsafe-anonymous-auth=true` is set"},
-		SafeForgetKeepTags:      {Type: Beta, Description: "prevent deleting all snapshots if the tag passed to `forget --keep-tags tagname` does not exist"},
+		ExplicitS3AnonymousAuth: {Type: Stable, Description: "forbid anonymous S3 authentication unless `-o s3.unsafe-anonymous-auth=true` is set"},
+		SafeForgetKeepTags:      {Type: Stable, Description: "prevent deleting all snapshots if the tag passed to `forget --keep-tags tagname` does not exist"},
 	})
 }
diff --git a/internal/fs/const_unix.go b/internal/fs/const_unix.go
index fe84cda17..e570c2553 100644
--- a/internal/fs/const_unix.go
+++ b/internal/fs/const_unix.go
@@ -7,3 +7,6 @@ import "syscall"
 
 // O_NOFOLLOW instructs the kernel to not follow symlinks when opening a file.
 const O_NOFOLLOW int = syscall.O_NOFOLLOW
+
+// O_DIRECTORY instructs the kernel to only open directories.
+const O_DIRECTORY int = syscall.O_DIRECTORY
diff --git a/internal/fs/const_windows.go b/internal/fs/const_windows.go
index f1b263a54..b2b1bab86 100644
--- a/internal/fs/const_windows.go
+++ b/internal/fs/const_windows.go
@@ -3,5 +3,12 @@
 
 package fs
 
-// O_NOFOLLOW is a noop on Windows.
-const O_NOFOLLOW int = 0
+// TODO honor flags when opening files
+
+// O_NOFOLLOW is currently only interpreted by FS.OpenFile in metadataOnly mode and ignored by OpenFile.
+// The value of the constant is invented and only for use within this fs package. It must not be used in other contexts.
+// It must not conflict with the other O_* values from go/src/syscall/types_windows.go
+const O_NOFOLLOW int = 0x40000000
+
+// O_DIRECTORY is a noop on Windows.
+const O_DIRECTORY int = 0
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/ea_windows.go b/internal/fs/ea_windows.go
index fe9a3c42a..27c2fcdf1 100644
--- a/internal/fs/ea_windows.go
+++ b/internal/fs/ea_windows.go
@@ -4,17 +4,28 @@
 package fs
 
 import (
-	"bytes"
-	"encoding/binary"
-	"errors"
 	"fmt"
 	"syscall"
 	"unsafe"
 
+	"github.com/Microsoft/go-winio"
 	"golang.org/x/sys/windows"
 )
 
-// The code below was adapted from https://github.com/microsoft/go-winio under MIT license.
+// extendedAttribute is a type alias for winio.ExtendedAttribute
+type extendedAttribute = winio.ExtendedAttribute
+
+// encodeExtendedAttributes encodes the extended attributes to a byte slice.
+func encodeExtendedAttributes(attrs []extendedAttribute) ([]byte, error) {
+	return winio.EncodeExtendedAttributes(attrs)
+}
+
+// decodeExtendedAttributes decodes the extended attributes from a byte slice.
+func decodeExtendedAttributes(data []byte) ([]extendedAttribute, error) {
+	return winio.DecodeExtendedAttributes(data)
+}
+
+// The code below was copied over from https://github.com/microsoft/go-winio/blob/main/pipe.go under MIT license.
 
 // The MIT License (MIT)
 
@@ -38,140 +49,6 @@ import (
 // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 // SOFTWARE.
 
-// The code below was copied over from https://github.com/microsoft/go-winio/blob/main/ea.go under MIT license.
-
-type fileFullEaInformation struct {
-	NextEntryOffset uint32
-	Flags           uint8
-	NameLength      uint8
-	ValueLength     uint16
-}
-
-var (
-	fileFullEaInformationSize = binary.Size(&fileFullEaInformation{})
-
-	errInvalidEaBuffer = errors.New("invalid extended attribute buffer")
-	errEaNameTooLarge  = errors.New("extended attribute name too large")
-	errEaValueTooLarge = errors.New("extended attribute value too large")
-)
-
-// extendedAttribute represents a single Windows EA.
-type extendedAttribute struct {
-	Name  string
-	Value []byte
-	Flags uint8
-}
-
-func parseEa(b []byte) (ea extendedAttribute, nb []byte, err error) {
-	var info fileFullEaInformation
-	err = binary.Read(bytes.NewReader(b), binary.LittleEndian, &info)
-	if err != nil {
-		err = errInvalidEaBuffer
-		return ea, nb, err
-	}
-
-	nameOffset := fileFullEaInformationSize
-	nameLen := int(info.NameLength)
-	valueOffset := nameOffset + int(info.NameLength) + 1
-	valueLen := int(info.ValueLength)
-	nextOffset := int(info.NextEntryOffset)
-	if valueLen+valueOffset > len(b) || nextOffset < 0 || nextOffset > len(b) {
-		err = errInvalidEaBuffer
-		return ea, nb, err
-	}
-
-	ea.Name = string(b[nameOffset : nameOffset+nameLen])
-	ea.Value = b[valueOffset : valueOffset+valueLen]
-	ea.Flags = info.Flags
-	if info.NextEntryOffset != 0 {
-		nb = b[info.NextEntryOffset:]
-	}
-	return ea, nb, err
-}
-
-// decodeExtendedAttributes decodes a list of EAs from a FILE_FULL_EA_INFORMATION
-// buffer retrieved from BackupRead, ZwQueryEaFile, etc.
-func decodeExtendedAttributes(b []byte) (eas []extendedAttribute, err error) {
-	for len(b) != 0 {
-		ea, nb, err := parseEa(b)
-		if err != nil {
-			return nil, err
-		}
-
-		eas = append(eas, ea)
-		b = nb
-	}
-	return eas, err
-}
-
-func writeEa(buf *bytes.Buffer, ea *extendedAttribute, last bool) error {
-	if int(uint8(len(ea.Name))) != len(ea.Name) {
-		return errEaNameTooLarge
-	}
-	if int(uint16(len(ea.Value))) != len(ea.Value) {
-		return errEaValueTooLarge
-	}
-	entrySize := uint32(fileFullEaInformationSize + len(ea.Name) + 1 + len(ea.Value))
-	withPadding := (entrySize + 3) &^ 3
-	nextOffset := uint32(0)
-	if !last {
-		nextOffset = withPadding
-	}
-	info := fileFullEaInformation{
-		NextEntryOffset: nextOffset,
-		Flags:           ea.Flags,
-		NameLength:      uint8(len(ea.Name)),
-		ValueLength:     uint16(len(ea.Value)),
-	}
-
-	err := binary.Write(buf, binary.LittleEndian, &info)
-	if err != nil {
-		return err
-	}
-
-	_, err = buf.Write([]byte(ea.Name))
-	if err != nil {
-		return err
-	}
-
-	err = buf.WriteByte(0)
-	if err != nil {
-		return err
-	}
-
-	_, err = buf.Write(ea.Value)
-	if err != nil {
-		return err
-	}
-
-	_, err = buf.Write([]byte{0, 0, 0}[0 : withPadding-entrySize])
-	if err != nil {
-		return err
-	}
-
-	return nil
-}
-
-// encodeExtendedAttributes encodes a list of EAs into a FILE_FULL_EA_INFORMATION
-// buffer for use with BackupWrite, ZwSetEaFile, etc.
-func encodeExtendedAttributes(eas []extendedAttribute) ([]byte, error) {
-	var buf bytes.Buffer
-	for i := range eas {
-		last := false
-		if i == len(eas)-1 {
-			last = true
-		}
-
-		err := writeEa(&buf, &eas[i], last)
-		if err != nil {
-			return nil, err
-		}
-	}
-	return buf.Bytes(), nil
-}
-
-// The code below was copied over from https://github.com/microsoft/go-winio/blob/main/pipe.go under MIT license.
-
 type ntStatus int32
 
 func (status ntStatus) Err() error {
diff --git a/internal/fs/file.go b/internal/fs/file.go
index 8d60ed159..57f1a996a 100644
--- a/internal/fs/file.go
+++ b/internal/fs/file.go
@@ -3,6 +3,7 @@ package fs
 import (
 	"fmt"
 	"os"
+	"runtime"
 )
 
 // MkdirAll creates a directory named path, along with any necessary parents,
@@ -47,6 +48,9 @@ func Lstat(name string) (os.FileInfo, error) {
 // methods on the returned File can be used for I/O.
 // If there is an error, it will be of type *PathError.
 func OpenFile(name string, flag int, perm os.FileMode) (*os.File, error) {
+	if runtime.GOOS == "windows" {
+		flag &^= O_NOFOLLOW
+	}
 	return os.OpenFile(fixpath(name), flag, perm)
 }
 
@@ -64,9 +68,10 @@ func ResetPermissions(path string) error {
 	return nil
 }
 
-// Readdirnames returns a list of file in a directory. Flags are passed to fs.OpenFile. O_RDONLY is implied.
+// Readdirnames returns a list of file in a directory. Flags are passed to fs.OpenFile.
+// O_RDONLY and O_DIRECTORY are implied.
 func Readdirnames(filesystem FS, dir string, flags int) ([]string, error) {
-	f, err := filesystem.OpenFile(dir, O_RDONLY|flags, 0)
+	f, err := filesystem.OpenFile(dir, O_RDONLY|O_DIRECTORY|flags, false)
 	if err != nil {
 		return nil, fmt.Errorf("openfile for readdirnames failed: %w", err)
 	}
diff --git a/internal/fs/fs_local.go b/internal/fs/fs_local.go
index 5fac88dbb..fc6c69cf2 100644
--- a/internal/fs/fs_local.go
+++ b/internal/fs/fs_local.go
@@ -20,47 +20,28 @@ func (fs Local) VolumeName(path string) string {
 	return filepath.VolumeName(path)
 }
 
-// OpenFile is the generalized open call; most users will use Open
-// or Create instead.  It opens the named file with specified flag
-// (O_RDONLY etc.) and perm, (0666 etc.) if applicable.  If successful,
-// methods on the returned File can be used for I/O.
-// If there is an error, it will be of type *PathError.
-func (fs Local) OpenFile(name string, flag int, perm os.FileMode) (File, error) {
-	f, err := os.OpenFile(fixpath(name), flag, perm)
-	if err != nil {
-		return nil, err
-	}
-	_ = setFlags(f)
-	return f, nil
-}
-
-// Stat returns a FileInfo describing the named file. If there is an error, it
-// will be of type *PathError.
-func (fs Local) Stat(name string) (os.FileInfo, error) {
-	return os.Stat(fixpath(name))
+// OpenFile opens a file or directory for reading.
+//
+// If metadataOnly is set, an implementation MUST return a File object for
+// arbitrary file types including symlinks. The implementation may internally use
+// the given file path or a file handle. In particular, an implementation may
+// delay actually accessing the underlying filesystem.
+//
+// Only the O_NOFOLLOW and O_DIRECTORY flags are supported.
+func (fs Local) OpenFile(name string, flag int, metadataOnly bool) (File, error) {
+	return newLocalFile(name, flag, metadataOnly)
 }
 
 // Lstat returns the FileInfo structure describing the named file.
 // 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) NodeFromFileInfo(path string, fi os.FileInfo, ignoreXattrListError bool) (*restic.Node, error) {
-	return nodeFromFileInfo(path, fi, ignoreXattrListError)
+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
@@ -103,3 +84,92 @@ func (fs Local) Base(path string) string {
 func (fs Local) Dir(path string) string {
 	return filepath.Dir(path)
 }
+
+type localFile struct {
+	name string
+	flag int
+	f    *os.File
+	fi   *ExtendedFileInfo
+}
+
+// See the File interface for a description of each method
+var _ File = &localFile{}
+
+func newLocalFile(name string, flag int, metadataOnly bool) (*localFile, error) {
+	var f *os.File
+	if !metadataOnly {
+		var err error
+		f, err = os.OpenFile(fixpath(name), flag, 0)
+		if err != nil {
+			return nil, err
+		}
+		_ = setFlags(f)
+	}
+	return &localFile{
+		name: name,
+		flag: flag,
+		f:    f,
+	}, nil
+}
+
+func (f *localFile) MakeReadable() error {
+	if f.f != nil {
+		panic("file is already readable")
+	}
+
+	newF, err := newLocalFile(f.name, f.flag, false)
+	if err != nil {
+		return err
+	}
+	// replace state and also reset cached FileInfo
+	*f = *newF
+	return nil
+}
+
+func (f *localFile) cacheFI() error {
+	if f.fi != nil {
+		return nil
+	}
+	var fi os.FileInfo
+	var err error
+	if f.f != nil {
+		fi, err = f.f.Stat()
+	} else if f.flag&O_NOFOLLOW != 0 {
+		fi, err = os.Lstat(f.name)
+	} else {
+		fi, err = os.Stat(f.name)
+	}
+	if err != nil {
+		return err
+	}
+	f.fi = extendedStat(fi)
+	return nil
+}
+
+func (f *localFile) Stat() (*ExtendedFileInfo, error) {
+	err := f.cacheFI()
+	// the call to cacheFI MUST happen before reading from f.fi
+	return f.fi, err
+}
+
+func (f *localFile) ToNode(ignoreXattrListError bool) (*restic.Node, error) {
+	if err := f.cacheFI(); err != nil {
+		return nil, err
+	}
+	return nodeFromFileInfo(f.name, f.fi, ignoreXattrListError)
+}
+
+func (f *localFile) Read(p []byte) (n int, err error) {
+	return f.f.Read(p)
+}
+
+func (f *localFile) Readdirnames(n int) ([]string, error) {
+	return f.f.Readdirnames(n)
+}
+
+func (f *localFile) Close() error {
+	if f.f != nil {
+		return f.f.Close()
+	}
+	return nil
+}
diff --git a/internal/fs/fs_local_vss.go b/internal/fs/fs_local_vss.go
index dcbda2a84..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"
@@ -10,7 +9,6 @@ import (
 
 	"github.com/restic/restic/internal/errors"
 	"github.com/restic/restic/internal/options"
-	"github.com/restic/restic/internal/restic"
 )
 
 // VSSConfig holds extended options of windows volume shadow copy service.
@@ -126,25 +124,16 @@ func (fs *LocalVss) DeleteSnapshots() {
 	fs.snapshots = activeSnapshots
 }
 
-// OpenFile wraps the Open method of the underlying file system.
-func (fs *LocalVss) OpenFile(name string, flag int, perm os.FileMode) (File, error) {
-	return fs.FS.OpenFile(fs.snapshotPath(name), flag, perm)
-}
-
-// Stat wraps the Stat method of the underlying file system.
-func (fs *LocalVss) Stat(name string) (os.FileInfo, error) {
-	return fs.FS.Stat(fs.snapshotPath(name))
+// OpenFile wraps the OpenFile method of the underlying file system.
+func (fs *LocalVss) OpenFile(name string, flag int, metadataOnly bool) (File, error) {
+	return fs.FS.OpenFile(fs.snapshotPath(name), flag, metadataOnly)
 }
 
 // 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))
 }
 
-func (fs *LocalVss) NodeFromFileInfo(path string, fi os.FileInfo, ignoreXattrListError bool) (*restic.Node, error) {
-	return fs.FS.NodeFromFileInfo(fs.snapshotPath(path), fi, ignoreXattrListError)
-}
-
 // isMountPointIncluded  is true if given mountpoint included by user.
 func (fs *LocalVss) isMountPointIncluded(mountPoint string) bool {
 	if fs.excludeVolumes == nil {
diff --git a/internal/fs/fs_reader.go b/internal/fs/fs_reader.go
index 97d4e1660..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,21 +41,16 @@ 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,
 	}
 }
 
-// OpenFile is the generalized open call; most users will use Open
-// or Create instead.  It opens the named file with specified flag
-// (O_RDONLY etc.) and perm, (0666 etc.) if applicable.  If successful,
-// methods on the returned File can be used for I/O.
-// If there is an error, it will be of type *os.PathError.
-func (fs *Reader) OpenFile(name string, flag int, _ os.FileMode) (f File, err error) {
+func (fs *Reader) OpenFile(name string, flag int, _ bool) (f File, err error) {
 	if flag & ^(O_RDONLY|O_NOFOLLOW) != 0 {
 		return nil, pathError("open", name,
 			fmt.Errorf("invalid combination of flags 0x%x", flag))
@@ -73,7 +69,7 @@ func (fs *Reader) OpenFile(name string, flag int, _ os.FileMode) (f File, err er
 		return f, nil
 	case "/", ".":
 		f = fakeDir{
-			entries: []os.FileInfo{fs.fi()},
+			entries: []string{fs.fi().Name},
 		}
 		return f, nil
 	}
@@ -81,25 +77,18 @@ func (fs *Reader) OpenFile(name string, flag int, _ os.FileMode) (f File, err er
 	return nil, pathError("open", name, syscall.ENOENT)
 }
 
-// Stat returns a FileInfo describing the named file. If there is an error, it
-// will be of type *os.PathError.
-func (fs *Reader) Stat(name string) (os.FileInfo, error) {
-	return fs.Lstat(name)
-}
-
 // Lstat returns the FileInfo structure describing the named file.
 // 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 {
@@ -123,27 +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,
-	}
-}
-
-func (fs *Reader) NodeFromFileInfo(path string, fi os.FileInfo, _ bool) (*restic.Node, error) {
-	node := buildBasicNode(path, fi)
-
-	// fill minimal info with current values for uid, gid
-	node.UID = uint32(os.Getuid())
-	node.GID = uint32(os.Getgid())
-	node.ChangeTime = node.ModTime
-
-	return node, nil
-}
-
 // 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
@@ -187,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,
 		},
 	}
 }
@@ -235,12 +203,16 @@ var _ File = &readerFile{}
 // except Stat()
 type fakeFile struct {
 	name string
-	os.FileInfo
+	fi   *ExtendedFileInfo
 }
 
 // ensure that fakeFile implements File
 var _ File = fakeFile{}
 
+func (f fakeFile) MakeReadable() error {
+	return nil
+}
+
 func (f fakeFile) Readdirnames(_ int) ([]string, error) {
 	return nil, pathError("readdirnames", f.name, os.ErrInvalid)
 }
@@ -253,13 +225,24 @@ 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.fi)
+
+	// fill minimal info with current values for uid, gid
+	node.UID = uint32(os.Getuid())
+	node.GID = uint32(os.Getgid())
+	node.ChangeTime = node.ModTime
+
+	return node, nil
 }
 
 // fakeDir implements Readdirnames and Readdir, everything else is delegated to fakeFile.
 type fakeDir struct {
-	entries []os.FileInfo
+	entries []string
 	fakeFile
 }
 
@@ -267,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/fs_track.go b/internal/fs/fs_track.go
index 366bbee76..9ebdbb8c4 100644
--- a/internal/fs/fs_track.go
+++ b/internal/fs/fs_track.go
@@ -16,8 +16,8 @@ type Track struct {
 }
 
 // OpenFile wraps the OpenFile method of the underlying file system.
-func (fs Track) OpenFile(name string, flag int, perm os.FileMode) (File, error) {
-	f, err := fs.FS.OpenFile(fixpath(name), flag, perm)
+func (fs Track) OpenFile(name string, flag int, metadataOnly bool) (File, error) {
+	f, err := fs.FS.OpenFile(name, flag, metadataOnly)
 	if err != nil {
 		return nil, err
 	}
@@ -31,7 +31,7 @@ type trackFile struct {
 
 func newTrackFile(stack []byte, filename string, file File) *trackFile {
 	f := &trackFile{file}
-	runtime.SetFinalizer(f, func(_ *trackFile) {
+	runtime.SetFinalizer(f, func(_ any) {
 		fmt.Fprintf(os.Stderr, "file %s not closed\n\nStacktrack:\n%s\n", filename, stack)
 		panic("file " + filename + " not closed")
 	})
diff --git a/internal/fs/interface.go b/internal/fs/interface.go
index 2967429c0..d75b0a91d 100644
--- a/internal/fs/interface.go
+++ b/internal/fs/interface.go
@@ -2,19 +2,22 @@ package fs
 
 import (
 	"io"
-	"os"
 
 	"github.com/restic/restic/internal/restic"
 )
 
 // FS bundles all methods needed for a file system.
 type FS interface {
-	OpenFile(name string, flag int, perm os.FileMode) (File, error)
-	Stat(name string) (os.FileInfo, error)
-	Lstat(name string) (os.FileInfo, error)
-	DeviceID(fi os.FileInfo) (deviceID uint64, err error)
-	ExtendedStat(fi os.FileInfo) ExtendedFileInfo
-	NodeFromFileInfo(path string, fi os.FileInfo, ignoreXattrListError bool) (*restic.Node, error)
+	// OpenFile opens a file or directory for reading.
+	//
+	// If metadataOnly is set, an implementation MUST return a File object for
+	// arbitrary file types including symlinks. The implementation may internally use
+	// the given file path or a file handle. In particular, an implementation may
+	// delay actually accessing the underlying filesystem.
+	//
+	// Only the O_NOFOLLOW and O_DIRECTORY flags are supported.
+	OpenFile(name string, flag int, metadataOnly bool) (File, error)
+	Lstat(name string) (*ExtendedFileInfo, error)
 
 	Join(elem ...string) string
 	Separator() string
@@ -27,11 +30,23 @@ type FS interface {
 	Base(path string) string
 }
 
-// File is an open file on a file system.
+// File is an open file on a file system. When opened as metadataOnly, an
+// implementation may opt to perform filesystem operations using the filepath
+// instead of actually opening the file.
 type File interface {
+	// MakeReadable reopens a File that was opened metadataOnly for reading.
+	// The method must not be called for files that are opened for reading.
+	// If possible, the underlying file should be reopened atomically.
+	// MakeReadable must work for files and directories.
+	MakeReadable() error
+
 	io.Reader
 	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.
+	ToNode(ignoreXattrListError bool) (*restic.Node, error)
 }
diff --git a/internal/fs/node.go b/internal/fs/node.go
index d36194322..058d9cc7b 100644
--- a/internal/fs/node.go
+++ b/internal/fs/node.go
@@ -1,6 +1,7 @@
 package fs
 
 import (
+	"fmt"
 	"os"
 	"os/user"
 	"strconv"
@@ -14,40 +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
 	}
 
-	allowExtended, err := nodeFillGenericAttributes(node, path, &stat)
-	if allowExtended {
-		// Skip processing ExtendedAttributes if allowExtended is false.
-		err = errors.Join(err, nodeFillExtendedAttributes(node, path, ignoreXattrListError))
-	}
+	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:
@@ -296,7 +293,7 @@ func nodeRestoreTimestamps(node *restic.Node, path string) error {
 	mtime := node.ModTime.UnixNano()
 
 	if err := utimesNano(fixpath(path), atime, mtime, node.Type); err != nil {
-		return &os.PathError{Op: "UtimesNano", Path: path, Err: err}
+		return fmt.Errorf("failed to restore timestamp of %q: %w", path, err)
 	}
 	return nil
 }
diff --git a/internal/fs/node_aix.go b/internal/fs/node_aix.go
deleted file mode 100644
index fd185724f..000000000
--- a/internal/fs/node_aix.go
+++ /dev/null
@@ -1,26 +0,0 @@
-//go:build aix
-// +build aix
-
-package fs
-
-import "github.com/restic/restic/internal/restic"
-
-// nodeRestoreExtendedAttributes is a no-op on AIX.
-func nodeRestoreExtendedAttributes(_ *restic.Node, _ string) error {
-	return nil
-}
-
-// nodeFillExtendedAttributes is a no-op on AIX.
-func nodeFillExtendedAttributes(_ *restic.Node, _ string, _ bool) error {
-	return nil
-}
-
-// nodeRestoreGenericAttributes is no-op on AIX.
-func nodeRestoreGenericAttributes(node *restic.Node, _ string, warn func(msg string)) error {
-	return restic.HandleAllUnknownGenericAttributesFound(node.GenericAttributes, warn)
-}
-
-// nodeFillGenericAttributes is a no-op on AIX.
-func nodeFillGenericAttributes(_ *restic.Node, _ string, _ *ExtendedFileInfo) (allowExtended bool, err error) {
-	return true, nil
-}
diff --git a/internal/fs/node_netbsd.go b/internal/fs/node_netbsd.go
deleted file mode 100644
index d295bf579..000000000
--- a/internal/fs/node_netbsd.go
+++ /dev/null
@@ -1,23 +0,0 @@
-package fs
-
-import "github.com/restic/restic/internal/restic"
-
-// nodeRestoreExtendedAttributes is a no-op on netbsd.
-func nodeRestoreExtendedAttributes(_ *restic.Node, _ string) error {
-	return nil
-}
-
-// nodeFillExtendedAttributes is a no-op on netbsd.
-func nodeFillExtendedAttributes(_ *restic.Node, _ string, _ bool) error {
-	return nil
-}
-
-// nodeRestoreGenericAttributes is no-op on netbsd.
-func nodeRestoreGenericAttributes(node *restic.Node, _ string, warn func(msg string)) error {
-	return restic.HandleAllUnknownGenericAttributesFound(node.GenericAttributes, warn)
-}
-
-// nodeFillGenericAttributes is a no-op on netbsd.
-func nodeFillGenericAttributes(_ *restic.Node, _ string, _ *ExtendedFileInfo) (allowExtended bool, err error) {
-	return true, nil
-}
diff --git a/internal/fs/node_noxattr.go b/internal/fs/node_noxattr.go
new file mode 100644
index 000000000..726827f62
--- /dev/null
+++ b/internal/fs/node_noxattr.go
@@ -0,0 +1,18 @@
+//go:build aix || dragonfly || openbsd
+// +build aix dragonfly openbsd
+
+package fs
+
+import (
+	"github.com/restic/restic/internal/restic"
+)
+
+// nodeRestoreExtendedAttributes is a no-op
+func nodeRestoreExtendedAttributes(_ *restic.Node, _ string) error {
+	return nil
+}
+
+// nodeFillExtendedAttributes is a no-op
+func nodeFillExtendedAttributes(_ *restic.Node, _ string, _ bool) error {
+	return nil
+}
diff --git a/internal/fs/node_openbsd.go b/internal/fs/node_openbsd.go
deleted file mode 100644
index 712b144b4..000000000
--- a/internal/fs/node_openbsd.go
+++ /dev/null
@@ -1,23 +0,0 @@
-package fs
-
-import "github.com/restic/restic/internal/restic"
-
-// nodeRestoreExtendedAttributes is a no-op on openbsd.
-func nodeRestoreExtendedAttributes(_ *restic.Node, _ string) error {
-	return nil
-}
-
-// nodeFillExtendedAttributes is a no-op on openbsd.
-func nodeFillExtendedAttributes(_ *restic.Node, _ string, _ bool) error {
-	return nil
-}
-
-// nodeRestoreGenericAttributes is no-op on openbsd.
-func nodeRestoreGenericAttributes(node *restic.Node, _ string, warn func(msg string)) error {
-	return restic.HandleAllUnknownGenericAttributesFound(node.GenericAttributes, warn)
-}
-
-// fillGenericAttributes is a no-op on openbsd.
-func nodeFillGenericAttributes(_ *restic.Node, _ string, _ *ExtendedFileInfo) (allowExtended bool, err error) {
-	return true, nil
-}
diff --git a/internal/fs/node_unix.go b/internal/fs/node_unix.go
index 5f08f3623..e88e54251 100644
--- a/internal/fs/node_unix.go
+++ b/internal/fs/node_unix.go
@@ -5,8 +5,20 @@ package fs
 
 import (
 	"os"
+
+	"github.com/restic/restic/internal/restic"
 )
 
 func lchown(name string, uid, gid int) error {
 	return os.Lchown(name, uid, gid)
 }
+
+// nodeRestoreGenericAttributes is no-op.
+func nodeRestoreGenericAttributes(node *restic.Node, _ string, warn func(msg string)) error {
+	return restic.HandleAllUnknownGenericAttributesFound(node.GenericAttributes, warn)
+}
+
+// nodeFillGenericAttributes is a no-op.
+func nodeFillGenericAttributes(_ *restic.Node, _ string, _ *ExtendedFileInfo) error {
+	return nil
+}
diff --git a/internal/fs/node_windows.go b/internal/fs/node_windows.go
index 837d46428..74cf6c0e5 100644
--- a/internal/fs/node_windows.go
+++ b/internal/fs/node_windows.go
@@ -83,8 +83,28 @@ func nodeRestoreExtendedAttributes(node *restic.Node, path string) (err error) {
 	return nil
 }
 
-// fill extended attributes in the node. This also includes the Generic attributes for windows.
+// fill extended attributes in the node
+// It also checks if the volume supports extended attributes and stores the result in a map
+// so that it does not have to be checked again for subsequent calls for paths in the same volume.
 func nodeFillExtendedAttributes(node *restic.Node, path string, _ bool) (err error) {
+	if strings.Contains(filepath.Base(path), ":") {
+		// Do not process for Alternate Data Streams in Windows
+		return nil
+	}
+
+	// only capture xattrs for file/dir
+	if node.Type != restic.NodeTypeFile && node.Type != restic.NodeTypeDir {
+		return nil
+	}
+
+	allowExtended, err := checkAndStoreEASupport(path)
+	if err != nil {
+		return err
+	}
+	if !allowExtended {
+		return nil
+	}
+
 	var fileHandle windows.Handle
 	if fileHandle, err = openHandleForEA(node.Type, path, false); fileHandle == 0 {
 		return nil
@@ -316,44 +336,32 @@ func decryptFile(pathPointer *uint16) error {
 
 // nodeFillGenericAttributes fills in the generic attributes for windows like File Attributes,
 // Created time and Security Descriptors.
-// It also checks if the volume supports extended attributes and stores the result in a map
-// so that it does not have to be checked again for subsequent calls for paths in the same volume.
-func nodeFillGenericAttributes(node *restic.Node, path string, stat *ExtendedFileInfo) (allowExtended bool, err error) {
+func nodeFillGenericAttributes(node *restic.Node, path string, stat *ExtendedFileInfo) error {
 	if strings.Contains(filepath.Base(path), ":") {
 		// Do not process for Alternate Data Streams in Windows
-		// Also do not allow processing of extended attributes for ADS.
-		return false, nil
+		return nil
 	}
 
 	isVolume, err := isVolumePath(path)
 	if err != nil {
-		return false, err
+		return err
 	}
 	if isVolume {
 		// Do not process file attributes, created time and sd for windows root volume paths
 		// Security descriptors are not supported for root volume paths.
 		// Though file attributes and created time are supported for root volume paths,
 		// we ignore them and we do not want to replace them during every restore.
-		allowExtended, err = checkAndStoreEASupport(path)
-		if err != nil {
-			return false, err
-		}
-		return allowExtended, err
+		return nil
 	}
 
 	var sd *[]byte
 	if node.Type == restic.NodeTypeFile || node.Type == restic.NodeTypeDir {
-		// Check EA support and get security descriptor for file/dir only
-		allowExtended, err = checkAndStoreEASupport(path)
-		if err != nil {
-			return false, err
-		}
 		if sd, err = getSecurityDescriptor(path); err != nil {
-			return allowExtended, err
+			return err
 		}
 	}
 
-	winFI := stat.Sys().(*syscall.Win32FileAttributeData)
+	winFI := stat.sys.(*syscall.Win32FileAttributeData)
 
 	// Add Windows attributes
 	node.GenericAttributes, err = restic.WindowsAttrsToGenericAttributes(restic.WindowsAttributes{
@@ -361,7 +369,7 @@ func nodeFillGenericAttributes(node *restic.Node, path string, stat *ExtendedFil
 		FileAttributes:     &winFI.FileAttributes,
 		SecurityDescriptor: sd,
 	})
-	return allowExtended, err
+	return err
 }
 
 // checkAndStoreEASupport checks if the volume of the path supports extended attributes and stores the result in a map
diff --git a/internal/fs/node_xattr.go b/internal/fs/node_xattr.go
index 1781452f7..1bdafc575 100644
--- a/internal/fs/node_xattr.go
+++ b/internal/fs/node_xattr.go
@@ -1,5 +1,5 @@
-//go:build darwin || freebsd || linux || solaris
-// +build darwin freebsd linux solaris
+//go:build darwin || freebsd || netbsd || linux || solaris
+// +build darwin freebsd netbsd linux solaris
 
 package fs
 
@@ -65,16 +65,6 @@ func handleXattrErr(err error) error {
 	}
 }
 
-// nodeRestoreGenericAttributes is no-op.
-func nodeRestoreGenericAttributes(node *restic.Node, _ string, warn func(msg string)) error {
-	return restic.HandleAllUnknownGenericAttributesFound(node.GenericAttributes, warn)
-}
-
-// nodeFillGenericAttributes is a no-op.
-func nodeFillGenericAttributes(_ *restic.Node, _ string, _ *ExtendedFileInfo) (allowExtended bool, err error) {
-	return true, nil
-}
-
 func nodeRestoreExtendedAttributes(node *restic.Node, path string) error {
 	expectedAttrs := map[string]struct{}{}
 	for _, attr := range node.ExtendedAttributes {
diff --git a/internal/fs/sd_windows.go b/internal/fs/sd_windows.go
index 6bffa4fe2..04623e8d3 100644
--- a/internal/fs/sd_windows.go
+++ b/internal/fs/sd_windows.go
@@ -1,15 +1,13 @@
 package fs
 
 import (
-	"bytes"
-	"encoding/binary"
 	"fmt"
 	"sync"
 	"sync/atomic"
 	"syscall"
-	"unicode/utf16"
 	"unsafe"
 
+	"github.com/Microsoft/go-winio"
 	"github.com/restic/restic/internal/debug"
 	"github.com/restic/restic/internal/errors"
 	"golang.org/x/sys/windows"
@@ -161,6 +159,10 @@ func setNamedSecurityInfoLow(filePath string, dacl *windows.ACL) error {
 	return windows.SetNamedSecurityInfo(fixpath(filePath), windows.SE_FILE_OBJECT, lowRestoreSecurityFlags, nil, nil, dacl, nil)
 }
 
+func enableProcessPrivileges(privileges []string) error {
+	return winio.EnableProcessPrivileges(privileges)
+}
+
 // enableBackupPrivilege enables privilege for backing up security descriptors
 func enableBackupPrivilege() {
 	err := enableProcessPrivileges([]string{seBackupPrivilege})
@@ -212,251 +214,3 @@ func securityDescriptorStructToBytes(sd *windows.SECURITY_DESCRIPTOR) ([]byte, e
 	b := unsafe.Slice((*byte)(unsafe.Pointer(sd)), sd.Length())
 	return b, nil
 }
-
-// The code below was adapted from
-// https://github.com/microsoft/go-winio/blob/3c9576c9346a1892dee136329e7e15309e82fb4f/privilege.go
-// under MIT license.
-
-// The MIT License (MIT)
-
-// Copyright (c) 2015 Microsoft
-
-// Permission is hereby granted, free of charge, to any person obtaining a copy
-// of this software and associated documentation files (the "Software"), to deal
-// in the Software without restriction, including without limitation the rights
-// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-// copies of the Software, and to permit persons to whom the Software is
-// furnished to do so, subject to the following conditions:
-
-// The above copyright notice and this permission notice shall be included in all
-// copies or substantial portions of the Software.
-
-// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-// SOFTWARE.
-var (
-	modadvapi32 = windows.NewLazySystemDLL("advapi32.dll")
-
-	procLookupPrivilegeValueW       = modadvapi32.NewProc("LookupPrivilegeValueW")
-	procAdjustTokenPrivileges       = modadvapi32.NewProc("AdjustTokenPrivileges")
-	procLookupPrivilegeDisplayNameW = modadvapi32.NewProc("LookupPrivilegeDisplayNameW")
-	procLookupPrivilegeNameW        = modadvapi32.NewProc("LookupPrivilegeNameW")
-)
-
-// Do the interface allocations only once for common
-// Errno values.
-const (
-	errnoErrorIOPending = 997
-
-	//revive:disable-next-line:var-naming ALL_CAPS
-	SE_PRIVILEGE_ENABLED = windows.SE_PRIVILEGE_ENABLED
-
-	//revive:disable-next-line:var-naming ALL_CAPS
-	ERROR_NOT_ALL_ASSIGNED windows.Errno = windows.ERROR_NOT_ALL_ASSIGNED
-)
-
-var (
-	errErrorIOPending error = syscall.Errno(errnoErrorIOPending)
-	errErrorEinval    error = syscall.EINVAL
-
-	privNames     = make(map[string]uint64)
-	privNameMutex sync.Mutex
-)
-
-// privilegeError represents an error enabling privileges.
-type privilegeError struct {
-	privileges []uint64
-}
-
-// Error returns the string message for the error.
-func (e *privilegeError) Error() string {
-	s := "Could not enable privilege "
-	if len(e.privileges) > 1 {
-		s = "Could not enable privileges "
-	}
-	for i, p := range e.privileges {
-		if i != 0 {
-			s += ", "
-		}
-		s += `"`
-		s += getPrivilegeName(p)
-		s += `"`
-	}
-	return s
-}
-
-func mapPrivileges(names []string) ([]uint64, error) {
-	privileges := make([]uint64, 0, len(names))
-	privNameMutex.Lock()
-	defer privNameMutex.Unlock()
-	for _, name := range names {
-		p, ok := privNames[name]
-		if !ok {
-			err := lookupPrivilegeValue("", name, &p)
-			if err != nil {
-				return nil, err
-			}
-			privNames[name] = p
-		}
-		privileges = append(privileges, p)
-	}
-	return privileges, nil
-}
-
-// enableProcessPrivileges enables privileges globally for the process.
-func enableProcessPrivileges(names []string) error {
-	return enableDisableProcessPrivilege(names, SE_PRIVILEGE_ENABLED)
-}
-
-func enableDisableProcessPrivilege(names []string, action uint32) error {
-	privileges, err := mapPrivileges(names)
-	if err != nil {
-		return err
-	}
-
-	p := windows.CurrentProcess()
-	var token windows.Token
-	err = windows.OpenProcessToken(p, windows.TOKEN_ADJUST_PRIVILEGES|windows.TOKEN_QUERY, &token)
-	if err != nil {
-		return err
-	}
-
-	defer func() {
-		_ = token.Close()
-	}()
-	return adjustPrivileges(token, privileges, action)
-}
-
-func adjustPrivileges(token windows.Token, privileges []uint64, action uint32) error {
-	var b bytes.Buffer
-	_ = binary.Write(&b, binary.LittleEndian, uint32(len(privileges)))
-	for _, p := range privileges {
-		_ = binary.Write(&b, binary.LittleEndian, p)
-		_ = binary.Write(&b, binary.LittleEndian, action)
-	}
-	prevState := make([]byte, b.Len())
-	reqSize := uint32(0)
-	success, err := adjustTokenPrivileges(token, false, &b.Bytes()[0], uint32(len(prevState)), &prevState[0], &reqSize)
-	if !success {
-		return err
-	}
-	if err == ERROR_NOT_ALL_ASSIGNED { //nolint:errorlint // err is Errno
-		debug.Log("Not all requested privileges were fully set: %v. AdjustTokenPrivileges returned warning: %v", privileges, err)
-	}
-	return nil
-}
-
-func getPrivilegeName(luid uint64) string {
-	var nameBuffer [256]uint16
-	bufSize := uint32(len(nameBuffer))
-	err := lookupPrivilegeName("", &luid, &nameBuffer[0], &bufSize)
-	if err != nil {
-		return fmt.Sprintf("<unknown privilege %d>", luid)
-	}
-
-	var displayNameBuffer [256]uint16
-	displayBufSize := uint32(len(displayNameBuffer))
-	var langID uint32
-	err = lookupPrivilegeDisplayName("", &nameBuffer[0], &displayNameBuffer[0], &displayBufSize, &langID)
-	if err != nil {
-		return fmt.Sprintf("<unknown privilege %s>", string(utf16.Decode(nameBuffer[:bufSize])))
-	}
-
-	return string(utf16.Decode(displayNameBuffer[:displayBufSize]))
-}
-
-// The functions below are copied over from https://github.com/microsoft/go-winio/blob/main/zsyscall_windows.go under MIT license.
-
-// This windows api always returns an error even in case of success, warnings (partial success) and error cases.
-//
-// Full success - When we call this with admin permissions, it returns DNS_ERROR_RCODE_NO_ERROR (0).
-// This gets translated to errErrorEinval and ultimately in adjustTokenPrivileges, it gets ignored.
-//
-// Partial success - If we call this api without admin privileges, privileges related to SACLs do not get set and
-// though the api returns success, it returns an error - golang.org/x/sys/windows.ERROR_NOT_ALL_ASSIGNED (1300)
-func adjustTokenPrivileges(token windows.Token, releaseAll bool, input *byte, outputSize uint32, output *byte, requiredSize *uint32) (success bool, err error) {
-	var _p0 uint32
-	if releaseAll {
-		_p0 = 1
-	}
-	r0, _, e1 := syscall.SyscallN(procAdjustTokenPrivileges.Addr(), uintptr(token), uintptr(_p0), uintptr(unsafe.Pointer(input)), uintptr(outputSize), uintptr(unsafe.Pointer(output)), uintptr(unsafe.Pointer(requiredSize)))
-	success = r0 != 0
-	if true {
-		err = errnoErr(e1)
-	}
-	return
-}
-
-func lookupPrivilegeDisplayName(systemName string, name *uint16, buffer *uint16, size *uint32, languageID *uint32) (err error) {
-	var _p0 *uint16
-	_p0, err = syscall.UTF16PtrFromString(systemName)
-	if err != nil {
-		return
-	}
-	return _lookupPrivilegeDisplayName(_p0, name, buffer, size, languageID)
-}
-
-func _lookupPrivilegeDisplayName(systemName *uint16, name *uint16, buffer *uint16, size *uint32, languageID *uint32) (err error) {
-	r1, _, e1 := syscall.SyscallN(procLookupPrivilegeDisplayNameW.Addr(), uintptr(unsafe.Pointer(systemName)), uintptr(unsafe.Pointer(name)), uintptr(unsafe.Pointer(buffer)), uintptr(unsafe.Pointer(size)), uintptr(unsafe.Pointer(languageID)))
-	if r1 == 0 {
-		err = errnoErr(e1)
-	}
-	return
-}
-
-func lookupPrivilegeName(systemName string, luid *uint64, buffer *uint16, size *uint32) (err error) {
-	var _p0 *uint16
-	_p0, err = syscall.UTF16PtrFromString(systemName)
-	if err != nil {
-		return
-	}
-	return _lookupPrivilegeName(_p0, luid, buffer, size)
-}
-
-func _lookupPrivilegeName(systemName *uint16, luid *uint64, buffer *uint16, size *uint32) (err error) {
-	r1, _, e1 := syscall.SyscallN(procLookupPrivilegeNameW.Addr(), uintptr(unsafe.Pointer(systemName)), uintptr(unsafe.Pointer(luid)), uintptr(unsafe.Pointer(buffer)), uintptr(unsafe.Pointer(size)))
-	if r1 == 0 {
-		err = errnoErr(e1)
-	}
-	return
-}
-
-func lookupPrivilegeValue(systemName string, name string, luid *uint64) (err error) {
-	var _p0 *uint16
-	_p0, err = syscall.UTF16PtrFromString(systemName)
-	if err != nil {
-		return
-	}
-	var _p1 *uint16
-	_p1, err = syscall.UTF16PtrFromString(name)
-	if err != nil {
-		return
-	}
-	return _lookupPrivilegeValue(_p0, _p1, luid)
-}
-
-func _lookupPrivilegeValue(systemName *uint16, name *uint16, luid *uint64) (err error) {
-	r1, _, e1 := syscall.SyscallN(procLookupPrivilegeValueW.Addr(), uintptr(unsafe.Pointer(systemName)), uintptr(unsafe.Pointer(name)), uintptr(unsafe.Pointer(luid)))
-	if r1 == 0 {
-		err = errnoErr(e1)
-	}
-	return
-}
-
-// The code below was copied from https://github.com/microsoft/go-winio/blob/main/tools/mkwinsyscall/mkwinsyscall.go under MIT license.
-
-// errnoErr returns common boxed Errno values, to prevent
-// allocations at runtime.
-func errnoErr(e syscall.Errno) error {
-	switch e {
-	case 0:
-		return errErrorEinval
-	case errnoErrorIOPending:
-		return errErrorIOPending
-	}
-	return e
-}
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..95238be77 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,9 @@ func extendedStat(fi os.FileInfo) ExtendedFileInfo {
 		ModTime:    time.Unix(s.Mtimespec.Unix()),
 		ChangeTime: time.Unix(s.Ctimespec.Unix()),
 	}
+}
 
-	return extFI
+// RecallOnDataAccess checks windows-specific attributes to determine if a file is a cloud-only placeholder.
+func (*ExtendedFileInfo) RecallOnDataAccess() (bool, error) {
+	return false, nil
 }
diff --git a/internal/fs/stat_unix.go b/internal/fs/stat_unix.go
index c55571031..70124658f 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,9 @@ func extendedStat(fi os.FileInfo) ExtendedFileInfo {
 		ModTime:    time.Unix(s.Mtim.Unix()),
 		ChangeTime: time.Unix(s.Ctim.Unix()),
 	}
+}
 
-	return extFI
+// RecallOnDataAccess checks windows-specific attributes to determine if a file is a cloud-only placeholder.
+func (*ExtendedFileInfo) RecallOnDataAccess() (bool, error) {
+	return false, nil
 }
diff --git a/internal/fs/stat_windows.go b/internal/fs/stat_windows.go
index 57f330fb5..a62ddf87f 100644
--- a/internal/fs/stat_windows.go
+++ b/internal/fs/stat_windows.go
@@ -8,18 +8,23 @@ import (
 	"os"
 	"syscall"
 	"time"
+
+	"golang.org/x/sys/windows"
 )
 
 // 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 +36,22 @@ 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
+}
+
+// RecallOnDataAccess checks if a file is available locally on the disk or if the file is
+// just a placeholder which must be downloaded from a remote server. This is typically used
+// in cloud syncing services (e.g. OneDrive) to prevent downloading files from cloud storage
+// until they are accessed.
+func (fi *ExtendedFileInfo) RecallOnDataAccess() (bool, error) {
+	attrs, ok := fi.sys.(*syscall.Win32FileAttributeData)
+	if !ok {
+		return false, fmt.Errorf("could not determine file attributes: %s", fi.Name)
+	}
+
+	if attrs.FileAttributes&windows.FILE_ATTRIBUTE_RECALL_ON_DATA_ACCESS > 0 {
+		return true, nil
+	}
+
+	return false, nil
 }
diff --git a/internal/fs/vss_windows.go b/internal/fs/vss_windows.go
index 7281e0210..840e97107 100644
--- a/internal/fs/vss_windows.go
+++ b/internal/fs/vss_windows.go
@@ -171,6 +171,11 @@ func (h HRESULT) Str() string {
 	return "UNKNOWN"
 }
 
+// Error implements the error interface
+func (h HRESULT) Error() string {
+	return h.Str()
+}
+
 // VssError encapsulates errors returned from calling VSS api.
 type vssError struct {
 	text    string
@@ -195,6 +200,11 @@ func (e *vssError) Error() string {
 	return fmt.Sprintf("VSS error: %s: %s (%#x)", e.text, e.hresult.Str(), e.hresult)
 }
 
+// Unwrap returns the underlying HRESULT error
+func (e *vssError) Unwrap() error {
+	return e.hresult
+}
+
 // vssTextError encapsulates errors returned from calling VSS api.
 type vssTextError struct {
 	text string
@@ -943,10 +953,23 @@ func NewVssSnapshot(provider string,
 			"%s", volume))
 	}
 
-	snapshotSetID, err := iVssBackupComponents.StartSnapshotSet()
-	if err != nil {
-		iVssBackupComponents.Release()
-		return VssSnapshot{}, err
+	const retryStartSnapshotSetSleep = 5 * time.Second
+	var snapshotSetID ole.GUID
+	for {
+		var err error
+		snapshotSetID, err = iVssBackupComponents.StartSnapshotSet()
+		if errors.Is(err, VSS_E_SNAPSHOT_SET_IN_PROGRESS) && time.Now().Add(-retryStartSnapshotSetSleep).Before(deadline) {
+			// retry snapshot set creation while deadline is not reached
+			time.Sleep(retryStartSnapshotSetSleep)
+			continue
+		}
+
+		if err != nil {
+			iVssBackupComponents.Release()
+			return VssSnapshot{}, err
+		} else {
+			break
+		}
 	}
 
 	if err := iVssBackupComponents.AddToSnapshotSet(volume, providerID, &snapshotSetID); err != nil {
diff --git a/internal/restic/tree.go b/internal/restic/tree.go
index c4125653b..f406b489f 100644
--- a/internal/restic/tree.go
+++ b/internal/restic/tree.go
@@ -162,7 +162,7 @@ func NewTreeJSONBuilder() *TreeJSONBuilder {
 
 func (builder *TreeJSONBuilder) AddNode(node *Node) error {
 	if node.Name <= builder.lastName {
-		return fmt.Errorf("node %q, last%q: %w", node.Name, builder.lastName, ErrTreeNotOrdered)
+		return fmt.Errorf("node %q, last %q: %w", node.Name, builder.lastName, ErrTreeNotOrdered)
 	}
 	if builder.lastName != "" {
 		_ = builder.buf.WriteByte(',')
diff --git a/internal/restorer/restorer.go b/internal/restorer/restorer.go
index f28cd0ba3..14a8edeac 100644
--- a/internal/restorer/restorer.go
+++ b/internal/restorer/restorer.go
@@ -511,12 +511,30 @@ func (res *Restorer) removeUnexpectedFiles(ctx context.Context, target, location
 		selectedForRestore, _ := res.SelectFilter(nodeLocation, false)
 		// only delete files that were selected for restore
 		if selectedForRestore {
-			res.opts.Progress.ReportDeletedFile(nodeLocation)
+			// First collect all files that will be deleted
+			var filesToDelete []string
+			err := filepath.Walk(nodeTarget, func(path string, _ os.FileInfo, err error) error {
+				if err != nil {
+					return err
+				}
+				filesToDelete = append(filesToDelete, path)
+				return nil
+			})
+			if err != nil {
+				return err
+			}
+
 			if !res.opts.DryRun {
+				// Perform the deletion
 				if err := fs.RemoveAll(nodeTarget); err != nil {
 					return err
 				}
 			}
+
+			// Report paths as deleted only after successful removal
+			for i := len(filesToDelete) - 1; i >= 0; i-- {
+				res.opts.Progress.ReportDeletion(filesToDelete[i])
+			}
 		}
 	}
 
diff --git a/internal/ui/restore/json.go b/internal/ui/restore/json.go
index 72cc38a6e..f7f7bdd1f 100644
--- a/internal/ui/restore/json.go
+++ b/internal/ui/restore/json.go
@@ -33,6 +33,7 @@ func (t *jsonPrinter) Update(p State, duration time.Duration) {
 		TotalFiles:     p.FilesTotal,
 		FilesRestored:  p.FilesFinished,
 		FilesSkipped:   p.FilesSkipped,
+		FilesDeleted:   p.FilesDeleted,
 		TotalBytes:     p.AllBytesTotal,
 		BytesRestored:  p.AllBytesWritten,
 		BytesSkipped:   p.AllBytesSkipped,
@@ -94,6 +95,7 @@ func (t *jsonPrinter) Finish(p State, duration time.Duration) {
 		TotalFiles:     p.FilesTotal,
 		FilesRestored:  p.FilesFinished,
 		FilesSkipped:   p.FilesSkipped,
+		FilesDeleted:   p.FilesDeleted,
 		TotalBytes:     p.AllBytesTotal,
 		BytesRestored:  p.AllBytesWritten,
 		BytesSkipped:   p.AllBytesSkipped,
@@ -108,6 +110,7 @@ type statusUpdate struct {
 	TotalFiles     uint64  `json:"total_files,omitempty"`
 	FilesRestored  uint64  `json:"files_restored,omitempty"`
 	FilesSkipped   uint64  `json:"files_skipped,omitempty"`
+	FilesDeleted   uint64  `json:"files_deleted,omitempty"`
 	TotalBytes     uint64  `json:"total_bytes,omitempty"`
 	BytesRestored  uint64  `json:"bytes_restored,omitempty"`
 	BytesSkipped   uint64  `json:"bytes_skipped,omitempty"`
@@ -137,6 +140,7 @@ type summaryOutput struct {
 	TotalFiles     uint64 `json:"total_files,omitempty"`
 	FilesRestored  uint64 `json:"files_restored,omitempty"`
 	FilesSkipped   uint64 `json:"files_skipped,omitempty"`
+	FilesDeleted   uint64 `json:"files_deleted,omitempty"`
 	TotalBytes     uint64 `json:"total_bytes,omitempty"`
 	BytesRestored  uint64 `json:"bytes_restored,omitempty"`
 	BytesSkipped   uint64 `json:"bytes_skipped,omitempty"`
diff --git a/internal/ui/restore/progress.go b/internal/ui/restore/progress.go
index 06f4c86aa..41367f346 100644
--- a/internal/ui/restore/progress.go
+++ b/internal/ui/restore/progress.go
@@ -11,6 +11,7 @@ type State struct {
 	FilesFinished   uint64
 	FilesTotal      uint64
 	FilesSkipped    uint64
+	FilesDeleted    uint64
 	AllBytesWritten uint64
 	AllBytesTotal   uint64
 	AllBytesSkipped uint64
@@ -124,11 +125,13 @@ func (p *Progress) AddSkippedFile(name string, size uint64) {
 	p.printer.CompleteItem(ActionFileUnchanged, name, size)
 }
 
-func (p *Progress) ReportDeletedFile(name string) {
+func (p *Progress) ReportDeletion(name string) {
 	if p == nil {
 		return
 	}
 
+	p.s.FilesDeleted++
+
 	p.m.Lock()
 	defer p.m.Unlock()
 
diff --git a/internal/ui/restore/text.go b/internal/ui/restore/text.go
index ba0dcd007..35c9db029 100644
--- a/internal/ui/restore/text.go
+++ b/internal/ui/restore/text.go
@@ -30,6 +30,9 @@ func (t *textPrinter) Update(p State, duration time.Duration) {
 	if p.FilesSkipped > 0 {
 		progress += fmt.Sprintf(", skipped %v files/dirs %v", p.FilesSkipped, ui.FormatBytes(p.AllBytesSkipped))
 	}
+	if p.FilesDeleted > 0 {
+		progress += fmt.Sprintf(", deleted %v files/dirs", p.FilesDeleted)
+	}
 
 	t.terminal.SetStatus([]string{progress})
 }
@@ -82,6 +85,9 @@ func (t *textPrinter) Finish(p State, duration time.Duration) {
 	if p.FilesSkipped > 0 {
 		summary += fmt.Sprintf(", skipped %v files/dirs %v", p.FilesSkipped, ui.FormatBytes(p.AllBytesSkipped))
 	}
+	if p.FilesDeleted > 0 {
+		summary += fmt.Sprintf(", deleted %v files/dirs", p.FilesDeleted)
+	}
 
 	t.terminal.Print(summary)
 }


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/cmd/restic/cmd_find_integration_test.go b/cmd/restic/cmd_find_integration_test.go
index dd8ab87fd..7e35cb141 100644
--- a/cmd/restic/cmd_find_integration_test.go
+++ b/cmd/restic/cmd_find_integration_test.go
@@ -10,11 +10,10 @@ import (
 	rtest "github.com/restic/restic/internal/test"
 )
 
-func testRunFind(t testing.TB, wantJSON bool, gopts GlobalOptions, pattern string) []byte {
+func testRunFind(t testing.TB, wantJSON bool, opts FindOptions, gopts GlobalOptions, pattern string) []byte {
 	buf, err := withCaptureStdout(func() error {
 		gopts.JSON = wantJSON
 
-		opts := FindOptions{}
 		return runFind(context.TODO(), opts, gopts, []string{pattern})
 	})
 	rtest.OK(t, err)
@@ -31,14 +30,14 @@ func TestFind(t *testing.T) {
 	testRunBackup(t, "", []string{env.testdata}, opts, env.gopts)
 	testRunCheck(t, env.gopts)
 
-	results := testRunFind(t, false, env.gopts, "unexistingfile")
+	results := testRunFind(t, false, FindOptions{}, env.gopts, "unexistingfile")
 	rtest.Assert(t, len(results) == 0, "unexisting file found in repo (%v)", datafile)
 
-	results = testRunFind(t, false, env.gopts, "testfile")
+	results = testRunFind(t, false, FindOptions{}, env.gopts, "testfile")
 	lines := strings.Split(string(results), "\n")
 	rtest.Assert(t, len(lines) == 2, "expected one file found in repo (%v)", datafile)
 
-	results = testRunFind(t, false, env.gopts, "testfile*")
+	results = testRunFind(t, false, FindOptions{}, env.gopts, "testfile*")
 	lines = strings.Split(string(results), "\n")
 	rtest.Assert(t, len(lines) == 4, "expected three files found in repo (%v)", datafile)
 }
@@ -67,21 +66,28 @@ func TestFindJSON(t *testing.T) {
 
 	testRunBackup(t, "", []string{env.testdata}, opts, env.gopts)
 	testRunCheck(t, env.gopts)
+	snapshot, _ := testRunSnapshots(t, env.gopts)
 
-	results := testRunFind(t, true, env.gopts, "unexistingfile")
+	results := testRunFind(t, true, FindOptions{}, env.gopts, "unexistingfile")
 	matches := []testMatches{}
 	rtest.OK(t, json.Unmarshal(results, &matches))
 	rtest.Assert(t, len(matches) == 0, "expected no match in repo (%v)", datafile)
 
-	results = testRunFind(t, true, env.gopts, "testfile")
+	results = testRunFind(t, true, FindOptions{}, env.gopts, "testfile")
 	rtest.OK(t, json.Unmarshal(results, &matches))
 	rtest.Assert(t, len(matches) == 1, "expected a single snapshot in repo (%v)", datafile)
 	rtest.Assert(t, len(matches[0].Matches) == 1, "expected a single file to match (%v)", datafile)
 	rtest.Assert(t, matches[0].Hits == 1, "expected hits to show 1 match (%v)", datafile)
 
-	results = testRunFind(t, true, env.gopts, "testfile*")
+	results = testRunFind(t, true, FindOptions{}, env.gopts, "testfile*")
 	rtest.OK(t, json.Unmarshal(results, &matches))
 	rtest.Assert(t, len(matches) == 1, "expected a single snapshot in repo (%v)", datafile)
 	rtest.Assert(t, len(matches[0].Matches) == 3, "expected 3 files to match (%v)", datafile)
 	rtest.Assert(t, matches[0].Hits == 3, "expected hits to show 3 matches (%v)", datafile)
+
+	results = testRunFind(t, true, FindOptions{TreeID: true}, env.gopts, snapshot.Tree.String())
+	rtest.OK(t, json.Unmarshal(results, &matches))
+	rtest.Assert(t, len(matches) == 1, "expected a single snapshot in repo (%v)", matches)
+	rtest.Assert(t, len(matches[0].Matches) == 3, "expected 3 files to match (%v)", matches[0].Matches)
+	rtest.Assert(t, matches[0].Hits == 3, "expected hits to show 3 matches (%v)", datafile)
 }
diff --git a/internal/archiver/archiver_test.go b/internal/archiver/archiver_test.go
index 97d27dc64..fcc3d465d 100644
--- a/internal/archiver/archiver_test.go
+++ b/internal/archiver/archiver_test.go
@@ -76,17 +76,12 @@ func saveFile(t testing.TB, repo archiverRepo, filename string, filesystem fs.FS
 		startCallback = true
 	}
 
-	file, err := arch.FS.OpenFile(filename, fs.O_RDONLY|fs.O_NOFOLLOW, 0)
+	file, err := arch.FS.OpenFile(filename, fs.O_NOFOLLOW, false)
 	if err != nil {
 		t.Fatal(err)
 	}
 
-	fi, err := file.Stat()
-	if err != nil {
-		t.Fatal(err)
-	}
-
-	res := arch.fileSaver.Save(ctx, "/", filename, file, fi, start, completeReading, complete)
+	res := arch.fileSaver.Save(ctx, "/", filename, file, start, completeReading, complete)
 
 	fnr := res.take(ctx)
 	if fnr.err != nil {
@@ -521,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) {
@@ -556,11 +551,12 @@ func rename(t testing.TB, oldname, newname string) {
 	}
 }
 
-func nodeFromFI(t testing.TB, fs fs.FS, filename string, fi os.FileInfo) *restic.Node {
-	node, err := fs.NodeFromFileInfo(filename, fi, false)
-	if err != nil {
-		t.Fatal(err)
-	}
+func nodeFromFile(t testing.TB, localFs fs.FS, filename string) *restic.Node {
+	meta, err := localFs.OpenFile(filename, fs.O_NOFOLLOW, true)
+	rtest.OK(t, err)
+	node, err := meta.ToNode(false)
+	rtest.OK(t, err)
+	rtest.OK(t, meta.Close())
 
 	return node
 }
@@ -664,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,
@@ -687,10 +683,11 @@ func TestFileChanged(t *testing.T) {
 			save(t, filename, content)
 
 			fs := &fs.Local{}
-			fiBefore := lstat(t, filename)
-			node := nodeFromFI(t, fs, filename, fiBefore)
+			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")
 			}
 
@@ -700,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")
 				}
 			}
@@ -722,16 +719,16 @@ 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")
 		}
 	})
 
 	t.Run("type-change", func(t *testing.T) {
 		fi := lstat(t, filename)
-		node := nodeFromFI(t, &fs.Local{}, filename, fi)
-		node.Type = "restic.NodeTypeSymlink"
-		if !fileChanged(&fs.Local{}, fi, node, 0) {
+		node := nodeFromFile(t, &fs.Local{}, filename)
+		node.Type = restic.NodeTypeSymlink
+		if !fileChanged(fi, node, 0) {
 			t.Fatal("node with changed type detected as unchanged")
 		}
 	})
@@ -834,7 +831,8 @@ func TestArchiverSaveDir(t *testing.T) {
 			wg, ctx := errgroup.WithContext(context.Background())
 			repo.StartPackUploader(ctx, wg)
 
-			arch := New(repo, fs.Track{FS: fs.Local{}}, Options{})
+			testFS := fs.Track{FS: fs.Local{}}
+			arch := New(repo, testFS, Options{})
 			arch.runWorkers(ctx, wg)
 			arch.summary = &Summary{}
 
@@ -846,15 +844,11 @@ func TestArchiverSaveDir(t *testing.T) {
 			back := rtest.Chdir(t, chdir)
 			defer back()
 
-			fi, err := os.Lstat(test.target)
-			if err != nil {
-				t.Fatal(err)
-			}
-
-			ft, err := arch.saveDir(ctx, "/", test.target, fi, nil, nil)
-			if err != nil {
-				t.Fatal(err)
-			}
+			meta, err := testFS.OpenFile(test.target, fs.O_NOFOLLOW, true)
+			rtest.OK(t, err)
+			ft, err := arch.saveDir(ctx, "/", test.target, meta, nil, nil)
+			rtest.OK(t, err)
+			rtest.OK(t, meta.Close())
 
 			fnr := ft.take(ctx)
 			node, stats := fnr.node, fnr.stats
@@ -916,19 +910,16 @@ func TestArchiverSaveDirIncremental(t *testing.T) {
 		wg, ctx := errgroup.WithContext(context.TODO())
 		repo.StartPackUploader(ctx, wg)
 
-		arch := New(repo, fs.Track{FS: fs.Local{}}, Options{})
+		testFS := fs.Track{FS: fs.Local{}}
+		arch := New(repo, testFS, Options{})
 		arch.runWorkers(ctx, wg)
 		arch.summary = &Summary{}
 
-		fi, err := os.Lstat(tempdir)
-		if err != nil {
-			t.Fatal(err)
-		}
-
-		ft, err := arch.saveDir(ctx, "/", tempdir, fi, nil, nil)
-		if err != nil {
-			t.Fatal(err)
-		}
+		meta, err := testFS.OpenFile(tempdir, fs.O_NOFOLLOW, true)
+		rtest.OK(t, err)
+		ft, err := arch.saveDir(ctx, "/", tempdir, meta, nil, nil)
+		rtest.OK(t, err)
+		rtest.OK(t, meta.Close())
 
 		fnr := ft.take(ctx)
 		node, stats := fnr.node, fnr.stats
@@ -1530,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
 			},
 		},
@@ -1547,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",
@@ -1574,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"
 			},
 		},
@@ -1598,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"
 			},
 		},
@@ -1607,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)
 			},
 		},
@@ -1665,8 +1656,8 @@ type MockFS struct {
 	bytesRead map[string]int // tracks bytes read from all opened files
 }
 
-func (m *MockFS) OpenFile(name string, flag int, perm os.FileMode) (fs.File, error) {
-	f, err := m.FS.OpenFile(name, flag, perm)
+func (m *MockFS) OpenFile(name string, flag int, metadataOnly bool) (fs.File, error) {
+	f, err := m.FS.OpenFile(name, flag, metadataOnly)
 	if err != nil {
 		return f, err
 	}
@@ -2056,12 +2047,12 @@ type TrackFS struct {
 	m      sync.Mutex
 }
 
-func (m *TrackFS) OpenFile(name string, flag int, perm os.FileMode) (fs.File, error) {
+func (m *TrackFS) OpenFile(name string, flag int, metadataOnly bool) (fs.File, error) {
 	m.m.Lock()
 	m.opened[name]++
 	m.m.Unlock()
 
-	return m.FS.OpenFile(name, flag, perm)
+	return m.FS.OpenFile(name, flag, metadataOnly)
 }
 
 type failSaveRepo struct {
@@ -2210,48 +2201,51 @@ func snapshot(t testing.TB, repo archiverRepo, fs fs.FS, parent *restic.Snapshot
 	return snapshot, node
 }
 
-// StatFS allows overwriting what is returned by the Lstat function.
-type StatFS struct {
+type overrideFS struct {
 	fs.FS
-
-	OverrideLstat    map[string]os.FileInfo
-	OnlyOverrideStat bool
+	overrideFI    *fs.ExtendedFileInfo
+	resetFIOnRead bool
+	overrideNode  *restic.Node
+	overrideErr   error
 }
 
-func (fs *StatFS) Lstat(name string) (os.FileInfo, error) {
-	if !fs.OnlyOverrideStat {
-		if fi, ok := fs.OverrideLstat[fixpath(name)]; ok {
-			return fi, nil
-		}
+func (m *overrideFS) OpenFile(name string, flag int, metadataOnly bool) (fs.File, error) {
+	f, err := m.FS.OpenFile(name, flag, metadataOnly)
+	if err != nil {
+		return f, err
 	}
 
-	return fs.FS.Lstat(name)
+	if filepath.Base(name) == "testfile" || filepath.Base(name) == "testdir" {
+		return &overrideFile{f, m}, nil
+	}
+	return f, nil
 }
 
-func (fs *StatFS) OpenFile(name string, flags int, perm os.FileMode) (fs.File, error) {
-	if fi, ok := fs.OverrideLstat[fixpath(name)]; ok {
-		f, err := fs.FS.OpenFile(name, flags, perm)
-		if err != nil {
-			return nil, err
-		}
+type overrideFile struct {
+	fs.File
+	ofs *overrideFS
+}
 
-		wrappedFile := fileStat{
-			File: f,
-			fi:   fi,
-		}
-		return wrappedFile, nil
+func (f overrideFile) Stat() (*fs.ExtendedFileInfo, error) {
+	if f.ofs.overrideFI == nil {
+		return f.File.Stat()
 	}
+	return f.ofs.overrideFI, nil
 
-	return fs.FS.OpenFile(name, flags, perm)
 }
 
-type fileStat struct {
-	fs.File
-	fi os.FileInfo
+func (f overrideFile) MakeReadable() error {
+	if f.ofs.resetFIOnRead {
+		f.ofs.overrideFI = nil
+	}
+	return f.File.MakeReadable()
 }
 
-func (f fileStat) Stat() (os.FileInfo, error) {
-	return f.fi, nil
+func (f overrideFile) ToNode(ignoreXattrListError bool) (*restic.Node, error) {
+	if f.ofs.overrideNode == nil {
+		return f.File.ToNode(ignoreXattrListError)
+	}
+	return f.ofs.overrideNode, f.ofs.overrideErr
 }
 
 // used by wrapFileInfo, use untyped const in order to avoid having a version
@@ -2279,17 +2273,18 @@ func TestMetadataChanged(t *testing.T) {
 	// get metadata
 	fi := lstat(t, "testfile")
 	localFS := &fs.Local{}
-	want, err := localFS.NodeFromFileInfo("testfile", fi, false)
-	if err != nil {
-		t.Fatal(err)
-	}
+	meta, err := localFS.OpenFile("testfile", fs.O_NOFOLLOW, true)
+	rtest.OK(t, err)
+	want, err := meta.ToNode(false)
+	rtest.OK(t, err)
+	rtest.OK(t, meta.Close())
 
-	fs := &StatFS{
-		FS: localFS,
-		OverrideLstat: map[string]os.FileInfo{
-			"testfile": fi,
-		},
+	fs := &overrideFS{
+		FS:           localFS,
+		overrideFI:   fi,
+		overrideNode: &restic.Node{},
 	}
+	*fs.overrideNode = *want
 
 	sn, node2 := snapshot(t, repo, fs, nil, "testfile")
 
@@ -2308,26 +2303,31 @@ 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.OverrideLstat["testfile"] = wrapFileInfo(fi)
+	// 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
 	}
-	// no user and group name
-	want.User = ""
-	want.Group = ""
+	// update mock node accordingly
+	fs.overrideNode.Mode = want.Mode
+	fs.overrideNode.UID = want.UID
+	fs.overrideNode.GID = want.GID
 
 	// make another snapshot
 	_, node3 := snapshot(t, repo, fs, sn, "testfile")
-	// Override username and group to empty string - in case underlying system has user with UID 51234
-	// See https://github.com/restic/restic/issues/2372
-	node3.User = ""
-	node3.Group = ""
 
 	// make sure that metadata was recorded successfully
 	if !cmp.Equal(want, node3) {
@@ -2340,62 +2340,83 @@ func TestMetadataChanged(t *testing.T) {
 	checker.TestCheckRepo(t, repo, false)
 }
 
-func TestRacyFileSwap(t *testing.T) {
+func TestRacyFileTypeSwap(t *testing.T) {
 	files := TestDir{
-		"file": TestFile{
+		"testfile": TestFile{
 			Content: "foo bar test file",
 		},
+		"testdir": TestDir{},
 	}
 
-	tempdir, repo := prepareTempdirRepoSrc(t, files)
+	for _, dirError := range []bool{false, true} {
+		desc := "file changed type"
+		if dirError {
+			desc = "dir changed type"
+		}
+		t.Run(desc, func(t *testing.T) {
+			tempdir, repo := prepareTempdirRepoSrc(t, files)
 
-	back := rtest.Chdir(t, tempdir)
-	defer back()
+			back := rtest.Chdir(t, tempdir)
+			defer back()
+
+			// get metadata of current folder
+			var fakeName, realName string
+			if dirError {
+				// lstat claims this is a directory, but it's actually a file
+				fakeName = "testdir"
+				realName = "testfile"
+			} else {
+				fakeName = "testfile"
+				realName = "testdir"
+			}
+			fakeFI := lstat(t, fakeName)
+			tempfile := filepath.Join(tempdir, realName)
 
-	// get metadata of current folder
-	fi := lstat(t, ".")
-	tempfile := filepath.Join(tempdir, "file")
+			statfs := &overrideFS{
+				FS:            fs.Local{},
+				overrideFI:    fakeFI,
+				resetFIOnRead: true,
+			}
 
-	statfs := &StatFS{
-		FS: fs.Local{},
-		OverrideLstat: map[string]os.FileInfo{
-			tempfile: fi,
-		},
-		OnlyOverrideStat: true,
-	}
+			ctx, cancel := context.WithCancel(context.Background())
+			defer cancel()
 
-	ctx, cancel := context.WithCancel(context.Background())
-	defer cancel()
+			wg, ctx := errgroup.WithContext(ctx)
+			repo.StartPackUploader(ctx, wg)
 
-	wg, ctx := errgroup.WithContext(ctx)
-	repo.StartPackUploader(ctx, wg)
+			arch := New(repo, fs.Track{FS: statfs}, Options{})
+			arch.Error = func(item string, err error) error {
+				t.Logf("archiver error as expected for %v: %v", item, err)
+				return err
+			}
+			arch.runWorkers(ctx, wg)
 
-	arch := New(repo, fs.Track{FS: statfs}, Options{})
-	arch.Error = func(item string, err error) error {
-		t.Logf("archiver error as expected for %v: %v", item, err)
-		return err
+			// fs.Track will panic if the file was not closed
+			_, excluded, err := arch.save(ctx, "/", tempfile, nil)
+			rtest.Assert(t, err != nil && strings.Contains(err.Error(), "changed type, refusing to archive"), "save() returned wrong error: %v", err)
+			tpe := "file"
+			if dirError {
+				tpe = "directory"
+			}
+			rtest.Assert(t, strings.Contains(err.Error(), tpe+" "), "unexpected item type in error: %v", err)
+			rtest.Assert(t, !excluded, "Save() excluded the node, that's unexpected")
+		})
 	}
-	arch.runWorkers(ctx, wg)
+}
 
-	// fs.Track will panic if the file was not closed
-	_, excluded, err := arch.save(ctx, "/", tempfile, nil)
-	if err == nil {
-		t.Errorf("Save() should have failed")
-	}
+type mockToNoder struct {
+	node *restic.Node
+	err  error
+}
 
-	if excluded {
-		t.Errorf("Save() excluded the node, that's unexpected")
-	}
+func (m *mockToNoder) ToNode(_ bool) (*restic.Node, error) {
+	return m.node, m.err
 }
 
 func TestMetadataBackupErrorFiltering(t *testing.T) {
 	tempdir := t.TempDir()
-	repo := repository.TestRepository(t)
-
 	filename := filepath.Join(tempdir, "file")
-	rtest.OK(t, os.WriteFile(filename, []byte("example"), 0o600))
-	fi, err := os.Stat(filename)
-	rtest.OK(t, err)
+	repo := repository.TestRepository(t)
 
 	arch := New(repo, fs.Local{}, Options{})
 
@@ -2406,15 +2427,24 @@ func TestMetadataBackupErrorFiltering(t *testing.T) {
 		return replacementErr
 	}
 
+	nonExistNoder := &mockToNoder{
+		node: &restic.Node{Type: restic.NodeTypeFile},
+		err:  fmt.Errorf("not found"),
+	}
+
 	// check that errors from reading extended metadata are properly filtered
-	node, err := arch.nodeFromFileInfo("file", filename+"invalid", fi, false)
+	node, err := arch.nodeFromFileInfo("file", filename+"invalid", nonExistNoder, false)
 	rtest.Assert(t, node != nil, "node is missing")
 	rtest.Assert(t, err == replacementErr, "expected %v got %v", replacementErr, err)
 	rtest.Assert(t, filteredErr != nil, "missing inner error")
 
 	// check that errors from reading irregular file are not filtered
 	filteredErr = nil
-	node, err = arch.nodeFromFileInfo("file", filename, wrapIrregularFileInfo(fi), false)
+	nonExistNoder = &mockToNoder{
+		node: &restic.Node{Type: restic.NodeTypeIrregular},
+		err:  fmt.Errorf(`unsupported file type "irregular"`),
+	}
+	node, err = arch.nodeFromFileInfo("file", filename, nonExistNoder, false)
 	rtest.Assert(t, node != nil, "node is missing")
 	rtest.Assert(t, filteredErr == nil, "error for irregular node should not have been filtered")
 	rtest.Assert(t, strings.Contains(err.Error(), "irregular"), "unexpected error %q does not warn about irregular file mode", err)
@@ -2433,18 +2463,22 @@ func TestIrregularFile(t *testing.T) {
 
 	tempfile := filepath.Join(tempdir, "testfile")
 	fi := lstat(t, "testfile")
-
-	statfs := &StatFS{
-		FS: fs.Local{},
-		OverrideLstat: map[string]os.FileInfo{
-			tempfile: wrapIrregularFileInfo(fi),
+	// patch mode to irregular
+	fi.Mode = (fi.Mode &^ os.ModeType) | os.ModeIrregular
+
+	override := &overrideFS{
+		FS:         fs.Local{},
+		overrideFI: fi,
+		overrideNode: &restic.Node{
+			Type: restic.NodeTypeIrregular,
 		},
+		overrideErr: fmt.Errorf(`unsupported file type "irregular"`),
 	}
 
 	ctx, cancel := context.WithCancel(context.Background())
 	defer cancel()
 
-	arch := New(repo, fs.Track{FS: statfs}, Options{})
+	arch := New(repo, fs.Track{FS: override}, Options{})
 	_, excluded, err := arch.save(ctx, "/", tempfile, nil)
 	if err == nil {
 		t.Fatalf("Save() should have failed")
@@ -2455,3 +2489,48 @@ func TestIrregularFile(t *testing.T) {
 		t.Errorf("Save() excluded the node, that's unexpected")
 	}
 }
+
+type missingFS struct {
+	fs.FS
+	errorOnOpen bool
+}
+
+func (fs *missingFS) OpenFile(name string, flag int, metadataOnly bool) (fs.File, error) {
+	if fs.errorOnOpen {
+		return nil, os.ErrNotExist
+	}
+
+	return &missingFile{}, nil
+}
+
+type missingFile struct {
+	fs.File
+}
+
+func (f *missingFile) Stat() (*fs.ExtendedFileInfo, error) {
+	return nil, os.ErrNotExist
+}
+
+func (f *missingFile) Close() error {
+	// prevent segfault in test
+	return nil
+}
+
+func TestDisappearedFile(t *testing.T) {
+	tempdir, repo := prepareTempdirRepoSrc(t, TestDir{})
+
+	back := rtest.Chdir(t, tempdir)
+	defer back()
+
+	ctx, cancel := context.WithCancel(context.Background())
+	defer cancel()
+
+	// depending on the underlying FS implementation a missing file may be detected by OpenFile or
+	// the subsequent file.Stat() call. Thus test both cases.
+	for _, errorOnOpen := range []bool{false, true} {
+		arch := New(repo, fs.Track{FS: &missingFS{FS: &fs.Local{}, errorOnOpen: errorOnOpen}}, Options{})
+		_, excluded, err := arch.save(ctx, "/", filepath.Join(tempdir, "testdir"), nil)
+		rtest.OK(t, err)
+		rtest.Assert(t, excluded, "testfile should have been excluded")
+	}
+}
diff --git a/internal/archiver/archiver_unix_test.go b/internal/archiver/archiver_unix_test.go
index 621f84826..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,55 +12,9 @@ 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) {
-	fi := lstat(t, name)
-	fs := &fs.Local{}
-	want, err := fs.NodeFromFileInfo(name, fi, false)
-	rtest.OK(t, err)
-
-	_, node := snapshot(t, repo, fs, nil, name)
+	want := nodeFromFile(t, &fs.Local{}, name)
+	_, node := snapshot(t, repo, &fs.Local{}, nil, name)
 	return want, node
 }
 
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/file_saver_test.go b/internal/archiver/file_saver_test.go
index 5b17eca37..ce862f6fe 100644
--- a/internal/archiver/file_saver_test.go
+++ b/internal/archiver/file_saver_test.go
@@ -30,7 +30,7 @@ func createTestFiles(t testing.TB, num int) (files []string) {
 	return files
 }
 
-func startFileSaver(ctx context.Context, t testing.TB, fs fs.FS) (*fileSaver, context.Context, *errgroup.Group) {
+func startFileSaver(ctx context.Context, t testing.TB, fsInst fs.FS) (*fileSaver, context.Context, *errgroup.Group) {
 	wg, ctx := errgroup.WithContext(ctx)
 
 	saveBlob := func(ctx context.Context, tpe restic.BlobType, buf *buffer, _ string, cb func(saveBlobResponse)) {
@@ -49,8 +49,8 @@ func startFileSaver(ctx context.Context, t testing.TB, fs fs.FS) (*fileSaver, co
 	}
 
 	s := newFileSaver(ctx, wg, saveBlob, pol, workers, workers)
-	s.NodeFromFileInfo = func(snPath, filename string, fi os.FileInfo, ignoreXattrListError bool) (*restic.Node, error) {
-		return fs.NodeFromFileInfo(filename, fi, ignoreXattrListError)
+	s.NodeFromFileInfo = func(snPath, filename string, meta ToNoder, ignoreXattrListError bool) (*restic.Node, error) {
+		return meta.ToNode(ignoreXattrListError)
 	}
 
 	return s, ctx, wg
@@ -72,17 +72,12 @@ func TestFileSaver(t *testing.T) {
 	var results []futureNode
 
 	for _, filename := range files {
-		f, err := testFs.OpenFile(filename, os.O_RDONLY, 0)
+		f, err := testFs.OpenFile(filename, os.O_RDONLY, false)
 		if err != nil {
 			t.Fatal(err)
 		}
 
-		fi, err := f.Stat()
-		if err != nil {
-			t.Fatal(err)
-		}
-
-		ff := s.Save(ctx, filename, filename, f, fi, startFn, completeReadingFn, completeFn)
+		ff := s.Save(ctx, filename, filename, f, startFn, completeReadingFn, completeFn)
 		results = append(results, ff)
 	}
 
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/file_unix_test.go b/internal/fs/file_unix_test.go
new file mode 100644
index 000000000..00d68abb8
--- /dev/null
+++ b/internal/fs/file_unix_test.go
@@ -0,0 +1,22 @@
+//go:build unix
+
+package fs
+
+import (
+	"path/filepath"
+	"syscall"
+	"testing"
+
+	"github.com/restic/restic/internal/errors"
+	rtest "github.com/restic/restic/internal/test"
+)
+
+func TestReaddirnamesFifo(t *testing.T) {
+	// should not block when reading from a fifo instead of a directory
+	tempdir := t.TempDir()
+	fifoFn := filepath.Join(tempdir, "fifo")
+	rtest.OK(t, mkfifo(fifoFn, 0o600))
+
+	_, err := Readdirnames(&Local{}, fifoFn, 0)
+	rtest.Assert(t, errors.Is(err, syscall.ENOTDIR), "unexpected error %v", err)
+}
diff --git a/internal/fs/fs_local_test.go b/internal/fs/fs_local_test.go
new file mode 100644
index 000000000..8fd8eb136
--- /dev/null
+++ b/internal/fs/fs_local_test.go
@@ -0,0 +1,221 @@
+package fs
+
+import (
+	"io"
+	"os"
+	"path/filepath"
+	"slices"
+	"testing"
+
+	"github.com/restic/restic/internal/restic"
+	rtest "github.com/restic/restic/internal/test"
+)
+
+type fsLocalMetadataTestcase struct {
+	name     string
+	follow   bool
+	setup    func(t *testing.T, path string)
+	nodeType restic.NodeType
+}
+
+func TestFSLocalMetadata(t *testing.T) {
+	for _, test := range []fsLocalMetadataTestcase{
+		{
+			name: "file",
+			setup: func(t *testing.T, path string) {
+				rtest.OK(t, os.WriteFile(path, []byte("example"), 0o600))
+			},
+			nodeType: restic.NodeTypeFile,
+		},
+		{
+			name: "directory",
+			setup: func(t *testing.T, path string) {
+				rtest.OK(t, os.Mkdir(path, 0o600))
+			},
+			nodeType: restic.NodeTypeDir,
+		},
+		{
+			name: "symlink",
+			setup: func(t *testing.T, path string) {
+				rtest.OK(t, os.Symlink(path+"old", path))
+			},
+			nodeType: restic.NodeTypeSymlink,
+		},
+		{
+			name:   "symlink file",
+			follow: true,
+			setup: func(t *testing.T, path string) {
+				rtest.OK(t, os.WriteFile(path+"file", []byte("example"), 0o600))
+				rtest.OK(t, os.Symlink(path+"file", path))
+			},
+			nodeType: restic.NodeTypeFile,
+		},
+	} {
+		runFSLocalTestcase(t, test)
+	}
+}
+
+func runFSLocalTestcase(t *testing.T, test fsLocalMetadataTestcase) {
+	t.Run(test.name, func(t *testing.T) {
+		tmp := t.TempDir()
+		path := filepath.Join(tmp, "item")
+		test.setup(t, path)
+
+		testFs := &Local{}
+		flags := 0
+		if !test.follow {
+			flags |= O_NOFOLLOW
+		}
+		f, err := testFs.OpenFile(path, flags, true)
+		rtest.OK(t, err)
+		checkMetadata(t, f, path, test.follow, test.nodeType)
+		rtest.OK(t, f.Close())
+	})
+
+}
+
+func checkMetadata(t *testing.T, f File, path string, follow bool, nodeType restic.NodeType) {
+	fi, err := f.Stat()
+	rtest.OK(t, err)
+	var fi2 os.FileInfo
+	if follow {
+		fi2, err = os.Stat(path)
+	} else {
+		fi2, err = os.Lstat(path)
+	}
+	rtest.OK(t, err)
+	assertFIEqual(t, fi2, fi)
+
+	node, err := f.ToNode(false)
+	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, nodeType, node.Type, "node Type")
+}
+
+func assertFIEqual(t *testing.T, want os.FileInfo, got *ExtendedFileInfo) {
+	t.Helper()
+	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) {
+	testFSLocalRead(t, false)
+	testFSLocalRead(t, true)
+}
+
+func testFSLocalRead(t *testing.T, makeReadable bool) {
+	tmp := t.TempDir()
+	path := filepath.Join(tmp, "item")
+	testdata := "example"
+	rtest.OK(t, os.WriteFile(path, []byte(testdata), 0o600))
+
+	f := openReadable(t, path, makeReadable)
+	checkMetadata(t, f, path, false, restic.NodeTypeFile)
+
+	data, err := io.ReadAll(f)
+	rtest.OK(t, err)
+	rtest.Equals(t, testdata, string(data), "file content mismatch")
+
+	rtest.OK(t, f.Close())
+}
+
+func openReadable(t *testing.T, path string, useMakeReadable bool) File {
+	testFs := &Local{}
+	f, err := testFs.OpenFile(path, O_NOFOLLOW, useMakeReadable)
+	rtest.OK(t, err)
+	if useMakeReadable {
+		// file was opened as metadataOnly. open for reading
+		rtest.OK(t, f.MakeReadable())
+	}
+	return f
+}
+
+func TestFSLocalReaddir(t *testing.T) {
+	testFSLocalReaddir(t, false)
+	testFSLocalReaddir(t, true)
+}
+
+func testFSLocalReaddir(t *testing.T, makeReadable bool) {
+	tmp := t.TempDir()
+	path := filepath.Join(tmp, "item")
+	rtest.OK(t, os.Mkdir(path, 0o700))
+	entries := []string{"testfile"}
+	rtest.OK(t, os.WriteFile(filepath.Join(path, entries[0]), []byte("example"), 0o600))
+
+	f := openReadable(t, path, makeReadable)
+	checkMetadata(t, f, path, false, restic.NodeTypeDir)
+
+	names, err := f.Readdirnames(-1)
+	rtest.OK(t, err)
+	slices.Sort(names)
+	rtest.Equals(t, entries, names, "directory content mismatch")
+
+	rtest.OK(t, f.Close())
+}
+
+func TestFSLocalReadableRace(t *testing.T) {
+	tmp := t.TempDir()
+	path := filepath.Join(tmp, "item")
+	testdata := "example"
+	rtest.OK(t, os.WriteFile(path, []byte(testdata), 0o600))
+
+	testFs := &Local{}
+	f, err := testFs.OpenFile(path, O_NOFOLLOW, true)
+	rtest.OK(t, err)
+
+	pathNew := path + "new"
+	rtest.OK(t, os.Rename(path, pathNew))
+
+	err = f.MakeReadable()
+	if err == nil {
+		// a file handle based implementation should still work
+		checkMetadata(t, f, pathNew, false, restic.NodeTypeFile)
+
+		data, err := io.ReadAll(f)
+		rtest.OK(t, err)
+		rtest.Equals(t, testdata, string(data), "file content mismatch")
+	}
+
+	rtest.OK(t, f.Close())
+}
+
+func TestFSLocalTypeChange(t *testing.T) {
+	tmp := t.TempDir()
+	path := filepath.Join(tmp, "item")
+	testdata := "example"
+	rtest.OK(t, os.WriteFile(path, []byte(testdata), 0o600))
+
+	testFs := &Local{}
+	f, err := testFs.OpenFile(path, O_NOFOLLOW, true)
+	rtest.OK(t, err)
+	// cache metadata
+	_, err = f.Stat()
+	rtest.OK(t, err)
+
+	pathNew := path + "new"
+	// rename instead of unlink to let the test also work on windows
+	rtest.OK(t, os.Rename(path, pathNew))
+
+	rtest.OK(t, os.Mkdir(path, 0o700))
+	rtest.OK(t, f.MakeReadable())
+
+	fi, err := f.Stat()
+	rtest.OK(t, err)
+	if !fi.Mode.IsDir() {
+		// a file handle based implementation should still reference the file
+		checkMetadata(t, f, pathNew, false, restic.NodeTypeFile)
+
+		data, err := io.ReadAll(f)
+		rtest.OK(t, err)
+		rtest.Equals(t, testdata, string(data), "file content mismatch")
+	}
+	// else:
+	// path-based implementation
+	// nothing to test here. stat returned the new file type
+
+	rtest.OK(t, f.Close())
+}
diff --git a/internal/fs/fs_local_unix_test.go b/internal/fs/fs_local_unix_test.go
new file mode 100644
index 000000000..5bcb5efd0
--- /dev/null
+++ b/internal/fs/fs_local_unix_test.go
@@ -0,0 +1,40 @@
+//go:build unix
+
+package fs
+
+import (
+	"syscall"
+	"testing"
+
+	"github.com/restic/restic/internal/restic"
+	rtest "github.com/restic/restic/internal/test"
+)
+
+func TestFSLocalMetadataUnix(t *testing.T) {
+	for _, test := range []fsLocalMetadataTestcase{
+		{
+			name: "socket",
+			setup: func(t *testing.T, path string) {
+				fd, err := syscall.Socket(syscall.AF_UNIX, syscall.SOCK_STREAM, 0)
+				rtest.OK(t, err)
+				defer func() {
+					_ = syscall.Close(fd)
+				}()
+
+				addr := &syscall.SockaddrUnix{Name: path}
+				rtest.OK(t, syscall.Bind(fd, addr))
+			},
+			nodeType: restic.NodeTypeSocket,
+		},
+		{
+			name: "fifo",
+			setup: func(t *testing.T, path string) {
+				rtest.OK(t, mkfifo(path, 0o600))
+			},
+			nodeType: restic.NodeTypeFifo,
+		},
+		// device files can only be created as root
+	} {
+		runFSLocalTestcase(t, test)
+	}
+}
diff --git a/internal/fs/fs_local_vss_test.go b/internal/fs/fs_local_vss_test.go
index f1a043118..b64897d1c 100644
--- a/internal/fs/fs_local_vss_test.go
+++ b/internal/fs/fs_local_vss_test.go
@@ -317,28 +317,25 @@ func TestVSSFS(t *testing.T) {
 
 	// trigger snapshot creation and
 	// capture FI while file still exists (should already be within the snapshot)
-	origFi, err := localVss.Stat(tempfile)
+	origFi, err := localVss.Lstat(tempfile)
 	rtest.OK(t, err)
 
 	// remove original file
 	rtest.OK(t, os.Remove(tempfile))
 
-	statFi, err := localVss.Stat(tempfile)
-	rtest.OK(t, err)
-	rtest.Equals(t, origFi.Mode(), statFi.Mode())
-
 	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, 0)
+	f, err := localVss.OpenFile(tempfile, os.O_RDONLY, false)
 	rtest.OK(t, err)
 	data, err := io.ReadAll(f)
 	rtest.OK(t, err)
 	rtest.Equals(t, "example", string(data), "unexpected file content")
-	rtest.OK(t, f.Close())
 
-	node, err := localVss.NodeFromFileInfo(tempfile, statFi, false)
+	node, err := f.ToNode(false)
 	rtest.OK(t, err)
-	rtest.Equals(t, node.Mode, statFi.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 442912fe3..257bfbbac 100644
--- a/internal/fs/fs_reader_test.go
+++ b/internal/fs/fs_reader_test.go
@@ -16,7 +16,7 @@ import (
 )
 
 func verifyFileContentOpenFile(t testing.TB, fs FS, filename string, want []byte) {
-	f, err := fs.OpenFile(filename, O_RDONLY, 0)
+	f, err := fs.OpenFile(filename, O_RDONLY, false)
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -37,7 +37,7 @@ func verifyFileContentOpenFile(t testing.TB, fs FS, filename string, want []byte
 }
 
 func verifyDirectoryContents(t testing.TB, fs FS, dir string, want []string) {
-	f, err := fs.OpenFile(dir, os.O_RDONLY, 0)
+	f, err := fs.OpenFile(dir, O_RDONLY, false)
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -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)
 	}
 }
 
@@ -123,7 +123,7 @@ func TestFSReader(t *testing.T) {
 		{
 			name: "file/Stat",
 			f: func(t *testing.T, fs FS) {
-				f, err := fs.OpenFile(filename, os.O_RDONLY, 0)
+				f, err := fs.OpenFile(filename, O_RDONLY, true)
 				if err != nil {
 					t.Fatal(err)
 				}
@@ -295,7 +295,7 @@ func TestFSReaderMinFileSize(t *testing.T) {
 				AllowEmptyFile: test.allowEmpty,
 			}
 
-			f, err := fs.OpenFile("testfile", os.O_RDONLY, 0)
+			f, err := fs.OpenFile("testfile", O_RDONLY, false)
 			if err != nil {
 				t.Fatal(err)
 			}
diff --git a/internal/fs/node_test.go b/internal/fs/node_test.go
index 58facceb1..15f19adc7 100644
--- a/internal/fs/node_test.go
+++ b/internal/fs/node_test.go
@@ -13,60 +13,29 @@ import (
 	"github.com/google/go-cmp/cmp"
 	"github.com/restic/restic/internal/errors"
 	"github.com/restic/restic/internal/restic"
-	"github.com/restic/restic/internal/test"
 	rtest "github.com/restic/restic/internal/test"
 )
 
-func BenchmarkNodeFillUser(t *testing.B) {
-	tempfile, err := os.CreateTemp("", "restic-test-temp-")
-	if err != nil {
-		t.Fatal(err)
-	}
-
-	fi, err := tempfile.Stat()
-	if err != nil {
-		t.Fatal(err)
-	}
-
+func BenchmarkNodeFromFileInfo(t *testing.B) {
+	tempfile, err := os.CreateTemp(t.TempDir(), "restic-test-temp-")
+	rtest.OK(t, err)
 	path := tempfile.Name()
-	fs := Local{}
-
-	t.ResetTimer()
-
-	for i := 0; i < t.N; i++ {
-		_, err := fs.NodeFromFileInfo(path, fi, false)
-		rtest.OK(t, err)
-	}
-
 	rtest.OK(t, tempfile.Close())
-	rtest.RemoveAll(t, tempfile.Name())
-}
-
-func BenchmarkNodeFromFileInfo(t *testing.B) {
-	tempfile, err := os.CreateTemp("", "restic-test-temp-")
-	if err != nil {
-		t.Fatal(err)
-	}
-
-	fi, err := tempfile.Stat()
-	if err != nil {
-		t.Fatal(err)
-	}
 
-	path := tempfile.Name()
 	fs := Local{}
+	f, err := fs.OpenFile(path, O_NOFOLLOW, true)
+	rtest.OK(t, err)
+	_, err = f.Stat()
+	rtest.OK(t, err)
 
 	t.ResetTimer()
 
 	for i := 0; i < t.N; i++ {
-		_, err := fs.NodeFromFileInfo(path, fi, false)
-		if err != nil {
-			t.Fatal(err)
-		}
+		_, err := f.ToNode(false)
+		rtest.OK(t, err)
 	}
 
-	rtest.OK(t, tempfile.Close())
-	rtest.RemoveAll(t, tempfile.Name())
+	rtest.OK(t, f.Close())
 }
 
 func parseTime(s string) time.Time {
@@ -249,14 +218,14 @@ func TestNodeRestoreAt(t *testing.T) {
 			rtest.OK(t, NodeCreateAt(&test, nodePath))
 			rtest.OK(t, NodeRestoreMetadata(&test, nodePath, func(msg string) { rtest.OK(t, fmt.Errorf("Warning triggered for path: %s: %s", nodePath, msg)) }))
 
-			fi, err := os.Lstat(nodePath)
-			rtest.OK(t, err)
-
 			fs := &Local{}
-			n2, err := fs.NodeFromFileInfo(nodePath, fi, false)
+			meta, err := fs.OpenFile(nodePath, O_NOFOLLOW, true)
+			rtest.OK(t, err)
+			n2, err := meta.ToNode(false)
 			rtest.OK(t, err)
-			n3, err := fs.NodeFromFileInfo(nodePath, fi, true)
+			n3, err := meta.ToNode(true)
 			rtest.OK(t, err)
+			rtest.OK(t, meta.Close())
 			rtest.Assert(t, n2.Equals(*n3), "unexpected node info mismatch %v", cmp.Diff(n2, n3))
 
 			rtest.Assert(t, test.Name == n2.Name,
@@ -323,5 +292,5 @@ func TestNodeRestoreMetadataError(t *testing.T) {
 
 	// This will fail because the target file does not exist
 	err := NodeRestoreMetadata(node, nodePath, func(msg string) { rtest.OK(t, fmt.Errorf("Warning triggered for path: %s: %s", nodePath, msg)) })
-	test.Assert(t, errors.Is(err, os.ErrNotExist), "failed for an unexpected reason")
+	rtest.Assert(t, errors.Is(err, os.ErrNotExist), "failed for an unexpected reason")
 }
diff --git a/internal/fs/node_unix_test.go b/internal/fs/node_unix_test.go
index 6b47eafba..1eb1ee506 100644
--- a/internal/fs/node_unix_test.go
+++ b/internal/fs/node_unix_test.go
@@ -114,16 +114,14 @@ func TestNodeFromFileInfo(t *testing.T) {
 				return
 			}
 
-			if fi.Sys() == nil {
-				t.Skip("fi.Sys() is nil")
-				return
-			}
-
 			fs := &Local{}
-			node, err := fs.NodeFromFileInfo(test.filename, fi, false)
-			if err != nil {
-				t.Fatal(err)
-			}
+			meta, err := fs.OpenFile(test.filename, O_NOFOLLOW, true)
+			rtest.OK(t, err)
+			node, err := meta.ToNode(false)
+			rtest.OK(t, err)
+			rtest.OK(t, meta.Close())
+
+			rtest.OK(t, err)
 
 			switch node.Type {
 			case restic.NodeTypeFile, restic.NodeTypeSymlink:
diff --git a/internal/fs/node_windows_test.go b/internal/fs/node_windows_test.go
index 1bb76b204..f75df54d3 100644
--- a/internal/fs/node_windows_test.go
+++ b/internal/fs/node_windows_test.go
@@ -222,11 +222,11 @@ func restoreAndGetNode(t *testing.T, tempDir string, testNode *restic.Node, warn
 	test.OK(t, errors.Wrapf(err, "Failed to restore metadata for: %s", testPath))
 
 	fs := &Local{}
-	fi, err := fs.Lstat(testPath)
-	test.OK(t, errors.Wrapf(err, "Could not Lstat for path: %s", testPath))
-
-	nodeFromFileInfo, err := fs.NodeFromFileInfo(testPath, fi, false)
+	meta, err := fs.OpenFile(testPath, O_NOFOLLOW, true)
+	test.OK(t, err)
+	nodeFromFileInfo, err := meta.ToNode(false)
 	test.OK(t, errors.Wrapf(err, "Could not get NodeFromFileInfo for path: %s", testPath))
+	test.OK(t, meta.Close())
 
 	return testPath, nodeFromFileInfo
 }
diff --git a/internal/fs/node_xattr_all_test.go b/internal/fs/node_xattr_all_test.go
index 690302f70..81c931e24 100644
--- a/internal/fs/node_xattr_all_test.go
+++ b/internal/fs/node_xattr_all_test.go
@@ -1,5 +1,5 @@
-//go:build darwin || freebsd || linux || solaris || windows
-// +build darwin freebsd linux solaris windows
+//go:build darwin || freebsd || netbsd || linux || solaris || windows
+// +build darwin freebsd netbsd linux solaris windows
 
 package fs
 
diff --git a/internal/fs/node_xattr_test.go b/internal/fs/node_xattr_test.go
index 3784dba45..7205e1fbe 100644
--- a/internal/fs/node_xattr_test.go
+++ b/internal/fs/node_xattr_test.go
@@ -1,5 +1,5 @@
-//go:build darwin || freebsd || linux || solaris
-// +build darwin freebsd linux solaris
+//go:build darwin || freebsd || netbsd || linux || solaris
+// +build darwin freebsd netbsd linux solaris
 
 package fs
 
diff --git a/internal/fs/stat_windows_test.go b/internal/fs/stat_windows_test.go
new file mode 100644
index 000000000..4f258d836
--- /dev/null
+++ b/internal/fs/stat_windows_test.go
@@ -0,0 +1,80 @@
+package fs_test
+
+import (
+	iofs "io/fs"
+	"os"
+	"path/filepath"
+	"syscall"
+	"testing"
+	"time"
+
+	"github.com/restic/restic/internal/fs"
+	rtest "github.com/restic/restic/internal/test"
+	"golang.org/x/sys/windows"
+)
+
+func TestRecallOnDataAccessRealFile(t *testing.T) {
+	// create a temp file for testing
+	tempdir := rtest.TempDir(t)
+	filename := filepath.Join(tempdir, "regular-file")
+	err := os.WriteFile(filename, []byte("foobar"), 0640)
+	rtest.OK(t, err)
+
+	fi, err := os.Stat(filename)
+	rtest.OK(t, err)
+
+	xs := fs.ExtendedStat(fi)
+
+	// ensure we can check attrs without error
+	recall, err := xs.RecallOnDataAccess()
+	rtest.Assert(t, err == nil, "err should be nil", err)
+	rtest.Assert(t, recall == false, "RecallOnDataAccess should be false")
+}
+
+// mockFileInfo implements os.FileInfo for mocking file attributes
+type mockFileInfo struct {
+	FileAttributes uint32
+}
+
+func (m mockFileInfo) IsDir() bool {
+	return false
+}
+func (m mockFileInfo) ModTime() time.Time {
+	return time.Now()
+}
+func (m mockFileInfo) Mode() iofs.FileMode {
+	return 0
+}
+func (m mockFileInfo) Name() string {
+	return "test"
+}
+func (m mockFileInfo) Size() int64 {
+	return 0
+}
+func (m mockFileInfo) Sys() any {
+	return &syscall.Win32FileAttributeData{
+		FileAttributes: m.FileAttributes,
+	}
+}
+
+func TestRecallOnDataAccessMockCloudFile(t *testing.T) {
+	fi := mockFileInfo{
+		FileAttributes: windows.FILE_ATTRIBUTE_RECALL_ON_DATA_ACCESS,
+	}
+	xs := fs.ExtendedStat(fi)
+
+	recall, err := xs.RecallOnDataAccess()
+	rtest.Assert(t, err == nil, "err should be nil", err)
+	rtest.Assert(t, recall, "RecallOnDataAccess should be true")
+}
+
+func TestRecallOnDataAccessMockRegularFile(t *testing.T) {
+	fi := mockFileInfo{
+		FileAttributes: windows.FILE_ATTRIBUTE_ARCHIVE,
+	}
+	xs := fs.ExtendedStat(fi)
+
+	recall, err := xs.RecallOnDataAccess()
+	rtest.Assert(t, err == nil, "err should be nil", err)
+	rtest.Assert(t, recall == false, "RecallOnDataAccess should be false")
+}
diff --git a/internal/repository/lock_test.go b/internal/repository/lock_test.go
index bd7cbd5e2..a9ff369c2 100644
--- a/internal/repository/lock_test.go
+++ b/internal/repository/lock_test.go
@@ -13,7 +13,6 @@ import (
 	"github.com/restic/restic/internal/backend/mem"
 	"github.com/restic/restic/internal/debug"
 	"github.com/restic/restic/internal/restic"
-	"github.com/restic/restic/internal/test"
 	rtest "github.com/restic/restic/internal/test"
 )
 
@@ -36,8 +35,8 @@ func openLockTestRepo(t *testing.T, wrapper backendWrapper) (*Repository, backen
 
 func checkedLockRepo(ctx context.Context, t *testing.T, repo *Repository, lockerInst *locker, retryLock time.Duration) (*Unlocker, context.Context) {
 	lock, wrappedCtx, err := lockerInst.Lock(ctx, repo, false, retryLock, func(msg string) {}, func(format string, args ...interface{}) {})
-	test.OK(t, err)
-	test.OK(t, wrappedCtx.Err())
+	rtest.OK(t, err)
+	rtest.OK(t, wrappedCtx.Err())
 	if lock.info.lock.Stale() {
 		t.Fatal("lock returned stale lock")
 	}
@@ -77,13 +76,13 @@ func TestLockConflict(t *testing.T) {
 	repo2 := TestOpenBackend(t, be)
 
 	lock, _, err := Lock(context.Background(), repo, true, 0, func(msg string) {}, func(format string, args ...interface{}) {})
-	test.OK(t, err)
+	rtest.OK(t, err)
 	defer lock.Unlock()
 	_, _, err = Lock(context.Background(), repo2, false, 0, func(msg string) {}, func(format string, args ...interface{}) {})
 	if err == nil {
 		t.Fatal("second lock should have failed")
 	}
-	test.Assert(t, restic.IsAlreadyLocked(err), "unexpected error %v", err)
+	rtest.Assert(t, restic.IsAlreadyLocked(err), "unexpected error %v", err)
 }
 
 type writeOnceBackend struct {
@@ -241,7 +240,7 @@ func TestLockWaitTimeout(t *testing.T) {
 	repo, _ := openLockTestRepo(t, nil)
 
 	elock, _, err := Lock(context.TODO(), repo, true, 0, func(msg string) {}, func(format string, args ...interface{}) {})
-	test.OK(t, err)
+	rtest.OK(t, err)
 	defer elock.Unlock()
 
 	retryLock := 200 * time.Millisecond
@@ -250,11 +249,11 @@ func TestLockWaitTimeout(t *testing.T) {
 	_, _, err = Lock(context.TODO(), repo, false, retryLock, func(msg string) {}, func(format string, args ...interface{}) {})
 	duration := time.Since(start)
 
-	test.Assert(t, err != nil,
+	rtest.Assert(t, err != nil,
 		"create normal lock with exclusively locked repo didn't return an error")
-	test.Assert(t, strings.Contains(err.Error(), "repository is already locked exclusively"),
+	rtest.Assert(t, strings.Contains(err.Error(), "repository is already locked exclusively"),
 		"create normal lock with exclusively locked repo didn't return the correct error")
-	test.Assert(t, retryLock <= duration && duration < retryLock*3/2,
+	rtest.Assert(t, retryLock <= duration && duration < retryLock*3/2,
 		"create normal lock with exclusively locked repo didn't wait for the specified timeout")
 }
 
@@ -263,7 +262,7 @@ func TestLockWaitCancel(t *testing.T) {
 	repo, _ := openLockTestRepo(t, nil)
 
 	elock, _, err := Lock(context.TODO(), repo, true, 0, func(msg string) {}, func(format string, args ...interface{}) {})
-	test.OK(t, err)
+	rtest.OK(t, err)
 	defer elock.Unlock()
 
 	retryLock := 200 * time.Millisecond
@@ -276,11 +275,11 @@ func TestLockWaitCancel(t *testing.T) {
 	_, _, err = Lock(ctx, repo, false, retryLock, func(msg string) {}, func(format string, args ...interface{}) {})
 	duration := time.Since(start)
 
-	test.Assert(t, err != nil,
+	rtest.Assert(t, err != nil,
 		"create normal lock with exclusively locked repo didn't return an error")
-	test.Assert(t, strings.Contains(err.Error(), "context canceled"),
+	rtest.Assert(t, strings.Contains(err.Error(), "context canceled"),
 		"create normal lock with exclusively locked repo didn't return the correct error")
-	test.Assert(t, cancelAfter <= duration && duration < retryLock-10*time.Millisecond,
+	rtest.Assert(t, cancelAfter <= duration && duration < retryLock-10*time.Millisecond,
 		"create normal lock with exclusively locked repo didn't return in time, duration %v", duration)
 }
 
@@ -289,7 +288,7 @@ func TestLockWaitSuccess(t *testing.T) {
 	repo, _ := openLockTestRepo(t, nil)
 
 	elock, _, err := Lock(context.TODO(), repo, true, 0, func(msg string) {}, func(format string, args ...interface{}) {})
-	test.OK(t, err)
+	rtest.OK(t, err)
 
 	retryLock := 200 * time.Millisecond
 	unlockAfter := 40 * time.Millisecond
@@ -299,6 +298,6 @@ func TestLockWaitSuccess(t *testing.T) {
 	})
 
 	lock, _, err := Lock(context.TODO(), repo, false, retryLock, func(msg string) {}, func(format string, args ...interface{}) {})
-	test.OK(t, err)
+	rtest.OK(t, err)
 	lock.Unlock()
 }
diff --git a/internal/repository/repair_pack_test.go b/internal/repository/repair_pack_test.go
index e5f7a7f22..5f02e7d61 100644
--- a/internal/repository/repair_pack_test.go
+++ b/internal/repository/repair_pack_test.go
@@ -10,7 +10,6 @@ import (
 	backendtest "github.com/restic/restic/internal/backend/test"
 	"github.com/restic/restic/internal/repository"
 	"github.com/restic/restic/internal/restic"
-	"github.com/restic/restic/internal/test"
 	rtest "github.com/restic/restic/internal/test"
 	"github.com/restic/restic/internal/ui/progress"
 )
@@ -25,10 +24,10 @@ func listBlobs(repo restic.Repository) restic.BlobSet {
 
 func replaceFile(t *testing.T, be backend.Backend, h backend.Handle, damage func([]byte) []byte) {
 	buf, err := backendtest.LoadAll(context.TODO(), be, h)
-	test.OK(t, err)
+	rtest.OK(t, err)
 	buf = damage(buf)
-	test.OK(t, be.Remove(context.TODO(), h))
-	test.OK(t, be.Save(context.TODO(), h, backend.NewByteReader(buf, be.Hasher())))
+	rtest.OK(t, be.Remove(context.TODO(), h))
+	rtest.OK(t, be.Save(context.TODO(), h, backend.NewByteReader(buf, be.Hasher())))
 }
 
 func TestRepairBrokenPack(t *testing.T) {
diff --git a/internal/repository/repository_test.go b/internal/repository/repository_test.go
index 3467a9cfa..5a6897f8f 100644
--- a/internal/repository/repository_test.go
+++ b/internal/repository/repository_test.go
@@ -21,7 +21,6 @@ import (
 	"github.com/restic/restic/internal/repository"
 	"github.com/restic/restic/internal/repository/index"
 	"github.com/restic/restic/internal/restic"
-	"github.com/restic/restic/internal/test"
 	rtest "github.com/restic/restic/internal/test"
 	"golang.org/x/sync/errgroup"
 )
@@ -145,7 +144,7 @@ func testLoadBlob(t *testing.T, version uint) {
 func TestLoadBlobBroken(t *testing.T) {
 	be := mem.New()
 	repo, _ := repository.TestRepositoryWithBackend(t, &damageOnceBackend{Backend: be}, restic.StableRepoVersion, repository.Options{})
-	buf := test.Random(42, 1000)
+	buf := rtest.Random(42, 1000)
 
 	var wg errgroup.Group
 	repo.StartPackUploader(context.TODO(), &wg)
@@ -421,7 +420,7 @@ func TestInvalidCompression(t *testing.T) {
 func TestListPack(t *testing.T) {
 	be := mem.New()
 	repo, _ := repository.TestRepositoryWithBackend(t, &damageOnceBackend{Backend: be}, restic.StableRepoVersion, repository.Options{})
-	buf := test.Random(42, 1000)
+	buf := rtest.Random(42, 1000)
 
 	var wg errgroup.Group
 	repo.StartPackUploader(context.TODO(), &wg)
@@ -460,12 +459,12 @@ func TestNoDoubleInit(t *testing.T) {
 	rtest.OK(t, err)
 
 	pol := r.Config().ChunkerPolynomial
-	err = repo.Init(context.TODO(), r.Config().Version, test.TestPassword, &pol)
+	err = repo.Init(context.TODO(), r.Config().Version, rtest.TestPassword, &pol)
 	rtest.Assert(t, strings.Contains(err.Error(), "repository master key and config already initialized"), "expected config exist error, got %q", err)
 
 	// must also prevent init if only keys exist
 	rtest.OK(t, be.Remove(context.TODO(), backend.Handle{Type: backend.ConfigFile}))
-	err = repo.Init(context.TODO(), r.Config().Version, test.TestPassword, &pol)
+	err = repo.Init(context.TODO(), r.Config().Version, rtest.TestPassword, &pol)
 	rtest.Assert(t, strings.Contains(err.Error(), "repository already contains keys"), "expected already contains keys error, got %q", err)
 
 	// must also prevent init if a snapshot exists and keys were deleted
@@ -475,6 +474,6 @@ func TestNoDoubleInit(t *testing.T) {
 	rtest.OK(t, be.List(context.TODO(), restic.KeyFile, func(fi backend.FileInfo) error {
 		return be.Remove(context.TODO(), backend.Handle{Type: restic.KeyFile, Name: fi.Name})
 	}))
-	err = repo.Init(context.TODO(), r.Config().Version, test.TestPassword, &pol)
+	err = repo.Init(context.TODO(), r.Config().Version, rtest.TestPassword, &pol)
 	rtest.Assert(t, strings.Contains(err.Error(), "repository already contains snapshots"), "expected already contains snapshots error, got %q", err)
 }
diff --git a/internal/restic/tree_test.go b/internal/restic/tree_test.go
index f1979f135..07ca254f1 100644
--- a/internal/restic/tree_test.go
+++ b/internal/restic/tree_test.go
@@ -83,13 +83,17 @@ func TestNodeMarshal(t *testing.T) {
 	}
 }
 
-func TestNodeComparison(t *testing.T) {
-	fs := &fs.Local{}
-	fi, err := fs.Lstat("tree_test.go")
+func nodeForFile(t *testing.T, name string) *restic.Node {
+	f, err := (&fs.Local{}).OpenFile(name, fs.O_NOFOLLOW, true)
 	rtest.OK(t, err)
-
-	node, err := fs.NodeFromFileInfo("tree_test.go", fi, false)
+	node, err := f.ToNode(false)
 	rtest.OK(t, err)
+	rtest.OK(t, f.Close())
+	return node
+}
+
+func TestNodeComparison(t *testing.T) {
+	node := nodeForFile(t, "tree_test.go")
 
 	n2 := *node
 	rtest.Assert(t, node.Equals(n2), "nodes aren't equal")
@@ -127,11 +131,7 @@ func TestTreeEqualSerialization(t *testing.T) {
 		builder := restic.NewTreeJSONBuilder()
 
 		for _, fn := range files[:i] {
-			fs := &fs.Local{}
-			fi, err := fs.Lstat(fn)
-			rtest.OK(t, err)
-			node, err := fs.NodeFromFileInfo(fn, fi, false)
-			rtest.OK(t, err)
+			node := nodeForFile(t, fn)
 
 			rtest.OK(t, tree.Insert(node))
 			rtest.OK(t, builder.AddNode(node))
diff --git a/internal/ui/restore/json_test.go b/internal/ui/restore/json_test.go
index 917a48070..c7096c246 100644
--- a/internal/ui/restore/json_test.go
+++ b/internal/ui/restore/json_test.go
@@ -17,31 +17,31 @@ func createJSONProgress() (*ui.MockTerminal, ProgressPrinter) {
 
 func TestJSONPrintUpdate(t *testing.T) {
 	term, printer := createJSONProgress()
-	printer.Update(State{3, 11, 0, 29, 47, 0}, 5*time.Second)
+	printer.Update(State{3, 11, 0, 0, 29, 47, 0}, 5*time.Second)
 	test.Equals(t, []string{"{\"message_type\":\"status\",\"seconds_elapsed\":5,\"percent_done\":0.6170212765957447,\"total_files\":11,\"files_restored\":3,\"total_bytes\":47,\"bytes_restored\":29}\n"}, term.Output)
 }
 
 func TestJSONPrintUpdateWithSkipped(t *testing.T) {
 	term, printer := createJSONProgress()
-	printer.Update(State{3, 11, 2, 29, 47, 59}, 5*time.Second)
+	printer.Update(State{3, 11, 2, 0, 29, 47, 59}, 5*time.Second)
 	test.Equals(t, []string{"{\"message_type\":\"status\",\"seconds_elapsed\":5,\"percent_done\":0.6170212765957447,\"total_files\":11,\"files_restored\":3,\"files_skipped\":2,\"total_bytes\":47,\"bytes_restored\":29,\"bytes_skipped\":59}\n"}, term.Output)
 }
 
 func TestJSONPrintSummaryOnSuccess(t *testing.T) {
 	term, printer := createJSONProgress()
-	printer.Finish(State{11, 11, 0, 47, 47, 0}, 5*time.Second)
+	printer.Finish(State{11, 11, 0, 0, 47, 47, 0}, 5*time.Second)
 	test.Equals(t, []string{"{\"message_type\":\"summary\",\"seconds_elapsed\":5,\"total_files\":11,\"files_restored\":11,\"total_bytes\":47,\"bytes_restored\":47}\n"}, term.Output)
 }
 
 func TestJSONPrintSummaryOnErrors(t *testing.T) {
 	term, printer := createJSONProgress()
-	printer.Finish(State{3, 11, 0, 29, 47, 0}, 5*time.Second)
+	printer.Finish(State{3, 11, 0, 0, 29, 47, 0}, 5*time.Second)
 	test.Equals(t, []string{"{\"message_type\":\"summary\",\"seconds_elapsed\":5,\"total_files\":11,\"files_restored\":3,\"total_bytes\":47,\"bytes_restored\":29}\n"}, term.Output)
 }
 
 func TestJSONPrintSummaryOnSuccessWithSkipped(t *testing.T) {
 	term, printer := createJSONProgress()
-	printer.Finish(State{11, 11, 2, 47, 47, 59}, 5*time.Second)
+	printer.Finish(State{11, 11, 2, 0, 47, 47, 59}, 5*time.Second)
 	test.Equals(t, []string{"{\"message_type\":\"summary\",\"seconds_elapsed\":5,\"total_files\":11,\"files_restored\":11,\"files_skipped\":2,\"total_bytes\":47,\"bytes_restored\":47,\"bytes_skipped\":59}\n"}, term.Output)
 }
 
diff --git a/internal/ui/restore/progress_test.go b/internal/ui/restore/progress_test.go
index b01440bee..b6f72726c 100644
--- a/internal/ui/restore/progress_test.go
+++ b/internal/ui/restore/progress_test.go
@@ -72,7 +72,7 @@ func TestNew(t *testing.T) {
 		return false
 	})
 	test.Equals(t, printerTrace{
-		printerTraceEntry{State{0, 0, 0, 0, 0, 0}, 0, false},
+		printerTraceEntry{State{0, 0, 0, 0, 0, 0, 0}, 0, false},
 	}, result)
 	test.Equals(t, itemTrace{}, items)
 }
@@ -85,7 +85,7 @@ func TestAddFile(t *testing.T) {
 		return false
 	})
 	test.Equals(t, printerTrace{
-		printerTraceEntry{State{0, 1, 0, 0, fileSize, 0}, 0, false},
+		printerTraceEntry{State{0, 1, 0, 0, 0, fileSize, 0}, 0, false},
 	}, result)
 	test.Equals(t, itemTrace{}, items)
 }
@@ -100,7 +100,7 @@ func TestFirstProgressOnAFile(t *testing.T) {
 		return false
 	})
 	test.Equals(t, printerTrace{
-		printerTraceEntry{State{0, 1, 0, expectedBytesWritten, expectedBytesTotal, 0}, 0, false},
+		printerTraceEntry{State{0, 1, 0, 0, expectedBytesWritten, expectedBytesTotal, 0}, 0, false},
 	}, result)
 	test.Equals(t, itemTrace{}, items)
 }
@@ -116,7 +116,7 @@ func TestLastProgressOnAFile(t *testing.T) {
 		return false
 	})
 	test.Equals(t, printerTrace{
-		printerTraceEntry{State{1, 1, 0, fileSize, fileSize, 0}, 0, false},
+		printerTraceEntry{State{1, 1, 0, 0, fileSize, fileSize, 0}, 0, false},
 	}, result)
 	test.Equals(t, itemTrace{
 		itemTraceEntry{action: ActionFileUpdated, item: "test", size: fileSize},
@@ -135,7 +135,7 @@ func TestLastProgressOnLastFile(t *testing.T) {
 		return false
 	})
 	test.Equals(t, printerTrace{
-		printerTraceEntry{State{2, 2, 0, 50 + fileSize, 50 + fileSize, 0}, 0, false},
+		printerTraceEntry{State{2, 2, 0, 0, 50 + fileSize, 50 + fileSize, 0}, 0, false},
 	}, result)
 	test.Equals(t, itemTrace{
 		itemTraceEntry{action: ActionFileUpdated, item: "test1", size: 50},
@@ -154,7 +154,7 @@ func TestSummaryOnSuccess(t *testing.T) {
 		return true
 	})
 	test.Equals(t, printerTrace{
-		printerTraceEntry{State{2, 2, 0, 50 + fileSize, 50 + fileSize, 0}, mockFinishDuration, true},
+		printerTraceEntry{State{2, 2, 0, 0, 50 + fileSize, 50 + fileSize, 0}, mockFinishDuration, true},
 	}, result)
 }
 
@@ -169,7 +169,7 @@ func TestSummaryOnErrors(t *testing.T) {
 		return true
 	})
 	test.Equals(t, printerTrace{
-		printerTraceEntry{State{1, 2, 0, 50 + fileSize/2, 50 + fileSize, 0}, mockFinishDuration, true},
+		printerTraceEntry{State{1, 2, 0, 0, 50 + fileSize/2, 50 + fileSize, 0}, mockFinishDuration, true},
 	}, result)
 }
 
@@ -181,7 +181,7 @@ func TestSkipFile(t *testing.T) {
 		return true
 	})
 	test.Equals(t, printerTrace{
-		printerTraceEntry{State{0, 0, 1, 0, 0, fileSize}, mockFinishDuration, true},
+		printerTraceEntry{State{0, 0, 1, 0, 0, 0, fileSize}, mockFinishDuration, true},
 	}, result)
 	test.Equals(t, itemTrace{
 		itemTraceEntry{ActionFileUnchanged, "test", fileSize},
@@ -196,7 +196,7 @@ func TestProgressTypes(t *testing.T) {
 		progress.AddFile(0)
 		progress.AddProgress("dir", ActionDirRestored, fileSize, fileSize)
 		progress.AddProgress("new", ActionFileRestored, 0, 0)
-		progress.ReportDeletedFile("del")
+		progress.ReportDeletion("del")
 		return true
 	})
 	test.Equals(t, itemTrace{
diff --git a/internal/ui/restore/text_test.go b/internal/ui/restore/text_test.go
index 4ffb1615d..746700cd8 100644
--- a/internal/ui/restore/text_test.go
+++ b/internal/ui/restore/text_test.go
@@ -17,31 +17,31 @@ func createTextProgress() (*ui.MockTerminal, ProgressPrinter) {
 
 func TestPrintUpdate(t *testing.T) {
 	term, printer := createTextProgress()
-	printer.Update(State{3, 11, 0, 29, 47, 0}, 5*time.Second)
+	printer.Update(State{3, 11, 0, 0, 29, 47, 0}, 5*time.Second)
 	test.Equals(t, []string{"[0:05] 61.70%  3 files/dirs 29 B, total 11 files/dirs 47 B"}, term.Output)
 }
 
 func TestPrintUpdateWithSkipped(t *testing.T) {
 	term, printer := createTextProgress()
-	printer.Update(State{3, 11, 2, 29, 47, 59}, 5*time.Second)
+	printer.Update(State{3, 11, 2, 0, 29, 47, 59}, 5*time.Second)
 	test.Equals(t, []string{"[0:05] 61.70%  3 files/dirs 29 B, total 11 files/dirs 47 B, skipped 2 files/dirs 59 B"}, term.Output)
 }
 
 func TestPrintSummaryOnSuccess(t *testing.T) {
 	term, printer := createTextProgress()
-	printer.Finish(State{11, 11, 0, 47, 47, 0}, 5*time.Second)
+	printer.Finish(State{11, 11, 0, 0, 47, 47, 0}, 5*time.Second)
 	test.Equals(t, []string{"Summary: Restored 11 files/dirs (47 B) in 0:05"}, term.Output)
 }
 
 func TestPrintSummaryOnErrors(t *testing.T) {
 	term, printer := createTextProgress()
-	printer.Finish(State{3, 11, 0, 29, 47, 0}, 5*time.Second)
+	printer.Finish(State{3, 11, 0, 0, 29, 47, 0}, 5*time.Second)
 	test.Equals(t, []string{"Summary: Restored 3 / 11 files/dirs (29 B / 47 B) in 0:05"}, term.Output)
 }
 
 func TestPrintSummaryOnSuccessWithSkipped(t *testing.T) {
 	term, printer := createTextProgress()
-	printer.Finish(State{11, 11, 2, 47, 47, 59}, 5*time.Second)
+	printer.Finish(State{11, 11, 2, 0, 47, 47, 59}, 5*time.Second)
 	test.Equals(t, []string{"Summary: Restored 11 files/dirs (47 B) in 0:05, skipped 2 files/dirs 59 B"}, term.Output)
 }