Repository: restic/restic
Pull request #5120 (2024-11-01 15:14:30)

Reduce checkboxes in PR checklist
<!--
Thank you very much for contributing code or documentation to restic! Please
fill out the following questions to make it easier for us to review your
changes.
-->

What does this PR change? What problem does it solve?
-----------------------------------------------------
The basics around how to format commits and PR settings are primarily relevant when opening a PR for the first time. But for repeated contributors it is tedious to always tick those checkboxes.

<!--
Describe the changes and their purpose here, as detailed as needed.
-->

Was the change previously discussed in an issue or on the forum?
----------------------------------------------------------------
No.
<!--
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].
-->

- [x] I have read the [contribution guidelines](https://github.com/restic/restic/blob/master/CONTRIBUTING.md#providing-patches).
- [x] I have [enabled 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).
- ~~[ ] I have added tests for all code changes.~~
- ~~[ ] I have added documentation for relevant changes (in the manual).~~
- ~~[ ] There's a new file in `changelog/unreleased/` that describes the changes for our users (see [template](https://github.com/restic/restic/blob/master/changelog/TEMPLATE)).~~
- [x] I have run `gofmt` on the code in all commits.
- [x] All commit messages are formatted in the same style as [the other commits in the repo](https://github.com/restic/restic/blob/master/CONTRIBUTING.md#git-commits).
- [x] I'm done! This pull request is ready for review.



Patch:
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index d3c6b0d26..9faeb3a9d 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -28,13 +28,15 @@ 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).
 -->
 
-- [ ] I have read the [contribution guidelines](https://github.com/restic/restic/blob/master/CONTRIBUTING.md#providing-patches).
-- [ ] I have [enabled 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).
 - [ ] I have added tests for all code changes.
 - [ ] I have added documentation for relevant changes (in the manual).
 - [ ] There's a new file in `changelog/unreleased/` that describes the changes for our users (see [template](https://github.com/restic/restic/blob/master/changelog/TEMPLATE)).
-- [ ] I have run `gofmt` on the code in all commits.
-- [ ] All commit messages are formatted in the same style as [the other commits in the repo](https://github.com/restic/restic/blob/master/CONTRIBUTING.md#git-commits).
 - [ ] I'm done! This pull request is ready for review.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c5e638c51..7ab47f11d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,6 @@
 # Table of Contents
 
+* [Changelog for 0.17.3](#changelog-for-restic-0173-2024-11-08)
 * [Changelog for 0.17.2](#changelog-for-restic-0172-2024-10-27)
 * [Changelog for 0.17.1](#changelog-for-restic-0171-2024-09-05)
 * [Changelog for 0.17.0](#changelog-for-restic-0170-2024-07-26)
@@ -37,6 +38,77 @@
 * [Changelog for 0.6.0](#changelog-for-restic-060-2017-05-29)
 
 
+# Changelog for restic 0.17.3 (2024-11-08)
+The following sections list the changes in restic 0.17.3 relevant to
+restic users. The changes are ordered by importance.
+
+## Summary
+
+ * Fix #4971: Fix unusable `mount` on macOS Sonoma
+ * Fix #5003: Fix metadata errors during backup of removable disks on Windows
+ * Fix #5101: Do not retry load/list operation if SFTP connection is broken
+ * Fix #5107: Fix metadata error on Windows for backups using VSS
+ * Enh #5096: Allow `prune --dry-run` without lock
+
+## Details
+
+ * Bugfix #4971: Fix unusable `mount` on macOS Sonoma
+
+   On macOS Sonoma when using FUSE-T, it was not possible to access files in a
+   mounted repository. This issue is now resolved.
+
+   https://github.com/restic/restic/issues/4971
+   https://github.com/restic/restic/pull/5048
+
+ * Bugfix #5003: Fix metadata errors during backup of removable disks on Windows
+
+   Since restic 0.17.0, backing up removable disks on Windows could report errors
+   with retrieving metadata like shown below.
+
+   ```
+   error: incomplete metadata for d:\filename: get named security info failed with: Access is denied.
+   ```
+
+   This has now been fixed.
+
+   https://github.com/restic/restic/issues/5003
+   https://github.com/restic/restic/pull/5123
+   https://forum.restic.net/t/backing-up-a-folder-from-a-veracrypt-volume-brings-up-errors-since-restic-v17-0/8444
+
+ * Bugfix #5101: Do not retry load/list operation if SFTP connection is broken
+
+   When using restic with the SFTP backend, backend operations that load a file or
+   list files were retried even if the SFTP connection was broken. This has now
+   been fixed.
+
+   https://github.com/restic/restic/pull/5101
+   https://forum.restic.net/t/restic-hanging-on-backup/8559
+
+ * Bugfix #5107: Fix metadata error on Windows for backups using VSS
+
+   Since restic 0.17.2, when creating a backup on Windows using
+   `--use-fs-snapshot`, restic would report an error like the following:
+
+   ```
+   error: incomplete metadata for C:\: get EA failed while opening file handle for path \\?\GLOBALROOT\Device\HarddiskVolumeShadowCopyXX\, with: The process cannot access the file because it is being used by another process.
+   ```
+
+   This has now been fixed by correctly handling paths that refer to volume shadow
+   copy snapshots.
+
+   https://github.com/restic/restic/issues/5107
+   https://github.com/restic/restic/pull/5110
+   https://github.com/restic/restic/pull/5112
+
+ * Enhancement #5096: Allow `prune --dry-run` without lock
+
+   The `prune --dry-run --no-lock` now allows performing a dry-run without locking
+   the repository. Note that if the repository is modified concurrently, `prune`
+   may return inaccurate statistics or errors.
+
+   https://github.com/restic/restic/pull/5096
+
+
 # Changelog for restic 0.17.2 (2024-10-27)
 The following sections list the changes in restic 0.17.2 relevant to
 restic users. The changes are ordered by importance.
diff --git a/VERSION b/VERSION
index a79916035..e2d1ad6ac 100644
--- a/VERSION
+++ b/VERSION
@@ -1,1 +1,1 @@
-0.17.2-dev
+0.17.3-dev
diff --git a/changelog/unreleased/issue-4971 b/changelog/0.17.3_2024-11-08/issue-4971
similarity index 55%
rename from changelog/unreleased/issue-4971
rename to changelog/0.17.3_2024-11-08/issue-4971
index 9ab529408..235d18cb5 100644
--- a/changelog/unreleased/issue-4971
+++ b/changelog/0.17.3_2024-11-08/issue-4971
@@ -1,9 +1,7 @@
 Bugfix: Fix unusable `mount` on macOS Sonoma
 
-On macOS Sonoma when using fuse-t, it was not possible to access files in
-a mounted repository.
-
-This issue has been resolved.
+On macOS Sonoma when using FUSE-T, it was not possible to access files in
+a mounted repository. This issue is now resolved.
 
 https://github.com/restic/restic/issues/4971
 https://github.com/restic/restic/pull/5048
diff --git a/changelog/0.17.3_2024-11-08/issue-5003 b/changelog/0.17.3_2024-11-08/issue-5003
new file mode 100644
index 000000000..f88ed3113
--- /dev/null
+++ b/changelog/0.17.3_2024-11-08/issue-5003
@@ -0,0 +1,14 @@
+Bugfix: Fix metadata errors during backup of removable disks on Windows
+
+Since restic 0.17.0, backing up removable disks on Windows could report
+errors with retrieving metadata like shown below.
+
+```
+error: incomplete metadata for d:\filename: get named security info failed with: Access is denied.
+```
+
+This has now been fixed.
+
+https://github.com/restic/restic/issues/5003
+https://github.com/restic/restic/pull/5123
+https://forum.restic.net/t/backing-up-a-folder-from-a-veracrypt-volume-brings-up-errors-since-restic-v17-0/8444
diff --git a/changelog/0.17.3_2024-11-08/issue-5107 b/changelog/0.17.3_2024-11-08/issue-5107
new file mode 100644
index 000000000..13bb380e4
--- /dev/null
+++ b/changelog/0.17.3_2024-11-08/issue-5107
@@ -0,0 +1,15 @@
+Bugfix: Fix metadata error on Windows for backups using VSS
+
+Since restic 0.17.2, when creating a backup on Windows using `--use-fs-snapshot`,
+restic would report an error like the following:
+
+```
+error: incomplete metadata for C:\: get EA failed while opening file handle for path \\?\GLOBALROOT\Device\HarddiskVolumeShadowCopyXX\, with: The process cannot access the file because it is being used by another process.
+```
+
+This has now been fixed by correctly handling paths that refer to volume
+shadow copy snapshots.
+
+https://github.com/restic/restic/issues/5107
+https://github.com/restic/restic/pull/5110
+https://github.com/restic/restic/pull/5112
diff --git a/changelog/0.17.3_2024-11-08/pull-5096 b/changelog/0.17.3_2024-11-08/pull-5096
new file mode 100644
index 000000000..b1cc6edd3
--- /dev/null
+++ b/changelog/0.17.3_2024-11-08/pull-5096
@@ -0,0 +1,8 @@
+Enhancement: Allow `prune --dry-run` without lock
+
+The `prune --dry-run --no-lock` now allows performing a dry-run
+without locking the repository. Note that if the repository is
+modified concurrently, `prune` may return inaccurate statistics
+or errors.
+
+https://github.com/restic/restic/pull/5096
diff --git a/changelog/0.17.3_2024-11-08/pull-5101 b/changelog/0.17.3_2024-11-08/pull-5101
new file mode 100644
index 000000000..4152eb185
--- /dev/null
+++ b/changelog/0.17.3_2024-11-08/pull-5101
@@ -0,0 +1,8 @@
+Bugfix: Do not retry load/list operation if SFTP connection is broken
+
+When using restic with the SFTP backend, backend operations that load a
+file or list files were retried even if the SFTP connection was broken.
+This has now been fixed.
+
+https://github.com/restic/restic/pull/5101
+https://forum.restic.net/t/restic-hanging-on-backup/8559
diff --git a/changelog/unreleased/pull-5096 b/changelog/unreleased/pull-5096
deleted file mode 100644
index d1e1d09b2..000000000
--- a/changelog/unreleased/pull-5096
+++ /dev/null
@@ -1,7 +0,0 @@
-Enhancement: Allow prune dry-run without lock
-
-The `prune --dry-run --no-lock` now allows performing a dry-run without
-taking a lock. If the repository is modified concurrently, `prune` may
-return inaccurate statistics or errors.
-
-https://github.com/restic/restic/pull/5096
diff --git a/changelog/unreleased/pull-5101 b/changelog/unreleased/pull-5101
deleted file mode 100644
index f784d0c47..000000000
--- a/changelog/unreleased/pull-5101
+++ /dev/null
@@ -1,9 +0,0 @@
-Bugfix: Do not retry load/list operation is SFTP connection is broken
-
-When using restic with the SFTP backend, backend operations that load
-a file or list files were retried even if the SFTP connection is broken.
-
-This has been fixed now.
-
-https://github.com/restic/restic/pull/5101
-https://forum.restic.net/t/restic-hanging-on-backup/8559/2
diff --git a/cmd/restic/cmd_ls.go b/cmd/restic/cmd_ls.go
index 2213d8e7a..06ae6cc20 100644
--- a/cmd/restic/cmd_ls.go
+++ b/cmd/restic/cmd_ls.go
@@ -75,17 +75,17 @@ func init() {
 }
 
 type lsPrinter interface {
-	Snapshot(sn *restic.Snapshot)
-	Node(path string, node *restic.Node, isPrefixDirectory bool)
-	LeaveDir(path string)
-	Close()
+	Snapshot(sn *restic.Snapshot) error
+	Node(path string, node *restic.Node, isPrefixDirectory bool) error
+	LeaveDir(path string) error
+	Close() error
 }
 
 type jsonLsPrinter struct {
 	enc *json.Encoder
 }
 
-func (p *jsonLsPrinter) Snapshot(sn *restic.Snapshot) {
+func (p *jsonLsPrinter) Snapshot(sn *restic.Snapshot) error {
 	type lsSnapshot struct {
 		*restic.Snapshot
 		ID          *restic.ID `json:"id"`
@@ -94,27 +94,21 @@ func (p *jsonLsPrinter) Snapshot(sn *restic.Snapshot) {
 		StructType  string     `json:"struct_type"`  // "snapshot", deprecated
 	}
 
-	err := p.enc.Encode(lsSnapshot{
+	return p.enc.Encode(lsSnapshot{
 		Snapshot:    sn,
 		ID:          sn.ID(),
 		ShortID:     sn.ID().Str(),
 		MessageType: "snapshot",
 		StructType:  "snapshot",
 	})
-	if err != nil {
-		Warnf("JSON encode failed: %v\n", err)
-	}
 }
 
 // Print node in our custom JSON format, followed by a newline.
-func (p *jsonLsPrinter) Node(path string, node *restic.Node, isPrefixDirectory bool) {
+func (p *jsonLsPrinter) Node(path string, node *restic.Node, isPrefixDirectory bool) error {
 	if isPrefixDirectory {
-		return
-	}
-	err := lsNodeJSON(p.enc, path, node)
-	if err != nil {
-		Warnf("JSON encode failed: %v\n", err)
+		return nil
 	}
+	return lsNodeJSON(p.enc, path, node)
 }
 
 func lsNodeJSON(enc *json.Encoder, path string, node *restic.Node) error {
@@ -160,8 +154,8 @@ func lsNodeJSON(enc *json.Encoder, path string, node *restic.Node) error {
 	return enc.Encode(n)
 }
 
-func (p *jsonLsPrinter) LeaveDir(_ string) {}
-func (p *jsonLsPrinter) Close()            {}
+func (p *jsonLsPrinter) LeaveDir(_ string) error { return nil }
+func (p *jsonLsPrinter) Close() error            { return nil }
 
 type ncduLsPrinter struct {
 	out   io.Writer
@@ -171,16 +165,17 @@ type ncduLsPrinter struct {
 // lsSnapshotNcdu prints a restic snapshot in Ncdu save format.
 // It opens the JSON list. Nodes are added with lsNodeNcdu and the list is closed by lsCloseNcdu.
 // Format documentation: https://dev.yorhel.nl/ncdu/jsonfmt
-func (p *ncduLsPrinter) Snapshot(sn *restic.Snapshot) {
+func (p *ncduLsPrinter) Snapshot(sn *restic.Snapshot) error {
 	const NcduMajorVer = 1
 	const NcduMinorVer = 2
 
 	snapshotBytes, err := json.Marshal(sn)
 	if err != nil {
-		Warnf("JSON encode failed: %v\n", err)
+		return err
 	}
 	p.depth++
-	fmt.Fprintf(p.out, "[%d, %d, %s, [{\"name\":\"/\"}", NcduMajorVer, NcduMinorVer, string(snapshotBytes))
+	_, err = fmt.Fprintf(p.out, "[%d, %d, %s, [{\"name\":\"/\"}", NcduMajorVer, NcduMinorVer, string(snapshotBytes))
+	return err
 }
 
 func lsNcduNode(_ string, node *restic.Node) ([]byte, error) {
@@ -232,27 +227,30 @@ func lsNcduNode(_ string, node *restic.Node) ([]byte, error) {
 	return json.Marshal(outNode)
 }
 
-func (p *ncduLsPrinter) Node(path string, node *restic.Node, _ bool) {
+func (p *ncduLsPrinter) Node(path string, node *restic.Node, _ bool) error {
 	out, err := lsNcduNode(path, node)
 	if err != nil {
-		Warnf("JSON encode failed: %v\n", err)
+		return err
 	}
 
 	if node.Type == restic.NodeTypeDir {
-		fmt.Fprintf(p.out, ",\n%s[\n%s%s", strings.Repeat("  ", p.depth), strings.Repeat("  ", p.depth+1), string(out))
+		_, err = fmt.Fprintf(p.out, ",\n%s[\n%s%s", strings.Repeat("  ", p.depth), strings.Repeat("  ", p.depth+1), string(out))
 		p.depth++
 	} else {
-		fmt.Fprintf(p.out, ",\n%s%s", strings.Repeat("  ", p.depth), string(out))
+		_, err = fmt.Fprintf(p.out, ",\n%s%s", strings.Repeat("  ", p.depth), string(out))
 	}
+	return err
 }
 
-func (p *ncduLsPrinter) LeaveDir(_ string) {
+func (p *ncduLsPrinter) LeaveDir(_ string) error {
 	p.depth--
-	fmt.Fprintf(p.out, "\n%s]", strings.Repeat("  ", p.depth))
+	_, err := fmt.Fprintf(p.out, "\n%s]", strings.Repeat("  ", p.depth))
+	return err
 }
 
-func (p *ncduLsPrinter) Close() {
-	fmt.Fprint(p.out, "\n]\n]\n")
+func (p *ncduLsPrinter) Close() error {
+	_, err := fmt.Fprint(p.out, "\n]\n]\n")
+	return err
 }
 
 type textLsPrinter struct {
@@ -261,17 +259,23 @@ type textLsPrinter struct {
 	HumanReadable bool
 }
 
-func (p *textLsPrinter) Snapshot(sn *restic.Snapshot) {
+func (p *textLsPrinter) Snapshot(sn *restic.Snapshot) error {
 	Verbosef("%v filtered by %v:\n", sn, p.dirs)
+	return nil
 }
-func (p *textLsPrinter) Node(path string, node *restic.Node, isPrefixDirectory bool) {
+func (p *textLsPrinter) Node(path string, node *restic.Node, isPrefixDirectory bool) error {
 	if !isPrefixDirectory {
 		Printf("%s\n", formatNode(path, node, p.ListLong, p.HumanReadable))
 	}
+	return nil
 }
 
-func (p *textLsPrinter) LeaveDir(_ string) {}
-func (p *textLsPrinter) Close()            {}
+func (p *textLsPrinter) LeaveDir(_ string) error {
+	return nil
+}
+func (p *textLsPrinter) Close() error {
+	return nil
+}
 
 func runLs(ctx context.Context, opts LsOptions, gopts GlobalOptions, args []string) error {
 	if len(args) == 0 {
@@ -374,7 +378,9 @@ func runLs(ctx context.Context, opts LsOptions, gopts GlobalOptions, args []stri
 		return err
 	}
 
-	printer.Snapshot(sn)
+	if err := printer.Snapshot(sn); err != nil {
+		return err
+	}
 
 	processNode := func(_ restic.ID, nodepath string, node *restic.Node, err error) error {
 		if err != nil {
@@ -387,7 +393,9 @@ func runLs(ctx context.Context, opts LsOptions, gopts GlobalOptions, args []stri
 		printedDir := false
 		if withinDir(nodepath) {
 			// if we're within a target path, print the node
-			printer.Node(nodepath, node, false)
+			if err := printer.Node(nodepath, node, false); err != nil {
+				return err
+			}
 			printedDir = true
 
 			// if recursive listing is requested, signal the walker that it
@@ -402,7 +410,7 @@ func runLs(ctx context.Context, opts LsOptions, gopts GlobalOptions, args []stri
 		if approachingMatchingTree(nodepath) {
 			// print node leading up to the target paths
 			if !printedDir {
-				printer.Node(nodepath, node, true)
+				return printer.Node(nodepath, node, true)
 			}
 			return nil
 		}
@@ -412,7 +420,9 @@ func runLs(ctx context.Context, opts LsOptions, gopts GlobalOptions, args []stri
 		if node.Type == restic.NodeTypeDir {
 			// immediately generate leaveDir if the directory is skipped
 			if printedDir {
-				printer.LeaveDir(nodepath)
+				if err := printer.LeaveDir(nodepath); err != nil {
+					return err
+				}
 			}
 			return walker.ErrSkipNode
 		}
@@ -421,11 +431,12 @@ func runLs(ctx context.Context, opts LsOptions, gopts GlobalOptions, args []stri
 
 	err = walker.Walk(ctx, repo, *sn.Tree, walker.WalkVisitor{
 		ProcessNode: processNode,
-		LeaveDir: func(path string) {
+		LeaveDir: func(path string) error {
 			// the root path `/` has no corresponding node and is thus also skipped by processNode
 			if path != "/" {
-				printer.LeaveDir(path)
+				return printer.LeaveDir(path)
 			}
+			return nil
 		},
 	})
 
@@ -433,6 +444,5 @@ func runLs(ctx context.Context, opts LsOptions, gopts GlobalOptions, args []stri
 		return err
 	}
 
-	printer.Close()
-	return nil
+	return printer.Close()
 }
diff --git a/cmd/restic/cmd_rewrite.go b/cmd/restic/cmd_rewrite.go
index a9f664110..b62d1ed95 100644
--- a/cmd/restic/cmd_rewrite.go
+++ b/cmd/restic/cmd_rewrite.go
@@ -2,7 +2,6 @@ package main
 
 import (
 	"context"
-	"fmt"
 	"time"
 
 	"github.com/spf13/cobra"
@@ -141,7 +140,7 @@ func rewriteSnapshot(ctx context.Context, repo *repository.Repository, sn *resti
 			if selectByName(path) {
 				return node
 			}
-			Verbosef(fmt.Sprintf("excluding %s\n", path))
+			Verbosef("excluding %s\n", path)
 			return nil
 		}
 
diff --git a/cmd/restic/cmd_snapshots.go b/cmd/restic/cmd_snapshots.go
index 466f536e0..f935cec86 100644
--- a/cmd/restic/cmd_snapshots.go
+++ b/cmd/restic/cmd_snapshots.go
@@ -296,7 +296,9 @@ func PrintSnapshotGroupHeader(stdout io.Writer, groupKeyJSON string) error {
 	}
 
 	// Info
-	fmt.Fprintf(stdout, "snapshots")
+	if _, err := fmt.Fprintf(stdout, "snapshots"); err != nil {
+		return err
+	}
 	var infoStrings []string
 	if key.Hostname != "" {
 		infoStrings = append(infoStrings, "host ["+key.Hostname+"]")
@@ -308,11 +310,13 @@ func PrintSnapshotGroupHeader(stdout io.Writer, groupKeyJSON string) error {
 		infoStrings = append(infoStrings, "paths ["+strings.Join(key.Paths, ", ")+"]")
 	}
 	if infoStrings != nil {
-		fmt.Fprintf(stdout, " for (%s)", strings.Join(infoStrings, ", "))
+		if _, err := fmt.Fprintf(stdout, " for (%s)", strings.Join(infoStrings, ", ")); err != nil {
+			return err
+		}
 	}
-	fmt.Fprintf(stdout, ":\n")
+	_, err = fmt.Fprintf(stdout, ":\n")
 
-	return nil
+	return err
 }
 
 // Snapshot helps to print Snapshots as JSON with their ID included.
diff --git a/cmd/restic/global.go b/cmd/restic/global.go
index fc52882d7..bea09837f 100644
--- a/cmd/restic/global.go
+++ b/cmd/restic/global.go
@@ -46,7 +46,7 @@ import (
 // to a missing backend storage location or config file
 var ErrNoRepository = errors.New("repository does not exist")
 
-var version = "0.17.2-dev (compiled manually)"
+var version = "0.17.3-dev (compiled manually)"
 
 // TimeFormat is the format used for all timestamps printed by restic.
 const TimeFormat = "2006-01-02 15:04:05"
@@ -308,7 +308,7 @@ func readPasswordTerminal(ctx context.Context, in *os.File, out *os.File, prompt
 	fd := int(out.Fd())
 	state, err := term.GetState(fd)
 	if err != nil {
-		fmt.Fprintf(os.Stderr, "unable to get terminal state: %v\n", err)
+		_, _ = fmt.Fprintf(os.Stderr, "unable to get terminal state: %v\n", err)
 		return "", err
 	}
 
@@ -317,16 +317,22 @@ func readPasswordTerminal(ctx context.Context, in *os.File, out *os.File, prompt
 
 	go func() {
 		defer close(done)
-		fmt.Fprint(out, prompt)
+		_, err = fmt.Fprint(out, prompt)
+		if err != nil {
+			return
+		}
 		buf, err = term.ReadPassword(int(in.Fd()))
-		fmt.Fprintln(out)
+		if err != nil {
+			return
+		}
+		_, err = fmt.Fprintln(out)
 	}()
 
 	select {
 	case <-ctx.Done():
 		err := term.Restore(fd, state)
 		if err != nil {
-			fmt.Fprintf(os.Stderr, "unable to restore terminal state: %v\n", err)
+			_, _ = fmt.Fprintf(os.Stderr, "unable to restore terminal state: %v\n", err)
 		}
 		return "", ctx.Err()
 	case <-done:
diff --git a/cmd/restic/main.go b/cmd/restic/main.go
index 4cb135c48..096c5695c 100644
--- a/cmd/restic/main.go
+++ b/cmd/restic/main.go
@@ -140,7 +140,7 @@ func printExitError(code int, message string) {
 			return
 		}
 	} else {
-		fmt.Fprintf(globalOptions.stderr, "%v\n", message)
+		_, _ = fmt.Fprintf(globalOptions.stderr, "%v\n", message)
 	}
 }
 
@@ -152,10 +152,10 @@ func main() {
 	log.SetOutput(logBuffer)
 
 	err := feature.Flag.Apply(os.Getenv("RESTIC_FEATURES"), func(s string) {
-		fmt.Fprintln(os.Stderr, s)
+		_, _ = fmt.Fprintln(os.Stderr, s)
 	})
 	if err != nil {
-		fmt.Fprintln(os.Stderr, err)
+		_, _ = fmt.Fprintln(os.Stderr, err)
 		Exit(1)
 	}
 
diff --git a/helpers/prepare-release/main.go b/helpers/prepare-release/main.go
index ba3de38a5..607d16936 100644
--- a/helpers/prepare-release/main.go
+++ b/helpers/prepare-release/main.go
@@ -31,7 +31,7 @@ var opts = struct {
 var versionRegex = regexp.MustCompile(`^\d+\.\d+\.\d+$`)
 
 func init() {
-	pflag.BoolVar(&opts.IgnoreBranchName, "ignore-branch-name", false, "allow releasing from other branches as 'master'")
+	pflag.BoolVar(&opts.IgnoreBranchName, "ignore-branch-name", false, "allow releasing from other branches than 'master'")
 	pflag.BoolVar(&opts.IgnoreUncommittedChanges, "ignore-uncommitted-changes", false, "allow uncommitted changes")
 	pflag.BoolVar(&opts.IgnoreChangelogVersion, "ignore-changelog-version", false, "ignore missing entry in CHANGELOG.md")
 	pflag.BoolVar(&opts.IgnoreChangelogReleaseDate, "ignore-changelog-release-date", false, "ignore missing subdir with date in changelog/")
@@ -128,17 +128,22 @@ func uncommittedChanges(dirs ...string) string {
 	return string(changes)
 }
 
-func preCheckBranchMaster() {
-	if opts.IgnoreBranchName {
-		return
-	}
-
+func getBranchName() string {
 	branch, err := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD").Output()
 	if err != nil {
 		die("error running 'git': %v", err)
 	}
 
-	if strings.TrimSpace(string(branch)) != "master" {
+	return strings.TrimSpace(string(branch))
+}
+
+func preCheckBranchMaster() {
+	if opts.IgnoreBranchName {
+		return
+	}
+
+	branch := getBranchName()
+	if branch != "master" {
 		die("wrong branch: %s", branch)
 	}
 }
@@ -449,6 +454,7 @@ func main() {
 	}
 
 	preCheckBranchMaster()
+	branch := getBranchName()
 	preCheckUncommittedChanges()
 	preCheckVersionExists()
 	preCheckDockerBuilderGoVersion()
@@ -485,5 +491,5 @@ func main() {
 
 	msg("done, output dir is %v", opts.OutputDir)
 
-	msg("now run:\n\ngit push --tags origin master\n%s\n\nrm -rf %q", dockerCmds, sourceDir)
+	msg("now run:\n\ngit push --tags origin %s\n%s\n\nrm -rf %q", branch, dockerCmds, sourceDir)
 }
diff --git a/internal/debug/debug.go b/internal/debug/debug.go
index 7bc3291d1..a09d6e74a 100644
--- a/internal/debug/debug.go
+++ b/internal/debug/debug.go
@@ -120,7 +120,7 @@ func goroutineNum() int {
 	runtime.Stack(b, false)
 	var num int
 
-	fmt.Sscanf(string(b), "goroutine %d ", &num)
+	_, _ = fmt.Sscanf(string(b), "goroutine %d ", &num)
 	return num
 }
 
diff --git a/internal/debug/round_tripper.go b/internal/debug/round_tripper.go
index 9dced95c6..4afab7298 100644
--- a/internal/debug/round_tripper.go
+++ b/internal/debug/round_tripper.go
@@ -42,7 +42,7 @@ func (rd *eofDetectReader) Close() error {
 			msg += fmt.Sprintf(", body: %q", buf)
 		}
 
-		fmt.Fprintln(os.Stderr, msg)
+		_, _ = fmt.Fprintln(os.Stderr, msg)
 		Log("%s: %+v", msg, errors.New("Close()"))
 	}
 	return rd.rd.Close()
diff --git a/internal/fs/ea_windows.go b/internal/fs/ea_windows.go
index 6bfe20209..fe9a3c42a 100644
--- a/internal/fs/ea_windows.go
+++ b/internal/fs/ea_windows.go
@@ -8,7 +8,6 @@ import (
 	"encoding/binary"
 	"errors"
 	"fmt"
-	"strings"
 	"syscall"
 	"unsafe"
 
@@ -299,20 +298,3 @@ func pathSupportsExtendedAttributes(path string) (supported bool, err error) {
 	supported = (fileSystemFlags & windows.FILE_SUPPORTS_EXTENDED_ATTRIBUTES) != 0
 	return supported, nil
 }
-
-// getVolumePathName returns the volume path name for the given path.
-func getVolumePathName(path string) (volumeName string, err error) {
-	utf16Path, err := windows.UTF16PtrFromString(path)
-	if err != nil {
-		return "", err
-	}
-	// Get the volume path (e.g., "D:")
-	var volumePath [windows.MAX_PATH + 1]uint16
-	err = windows.GetVolumePathName(utf16Path, &volumePath[0], windows.MAX_PATH+1)
-	if err != nil {
-		return "", err
-	}
-	// Trim any trailing backslashes
-	volumeName = strings.TrimRight(windows.UTF16ToString(volumePath[:]), "\\")
-	return volumeName, nil
-}
diff --git a/internal/fs/file_windows.go b/internal/fs/file_windows.go
index 3d011f719..d7aabf360 100644
--- a/internal/fs/file_windows.go
+++ b/internal/fs/file_windows.go
@@ -18,19 +18,28 @@ func fixpath(name string) string {
 	abspath, err := filepath.Abs(name)
 	if err == nil {
 		// Check if \\?\UNC\ already exist
-		if strings.HasPrefix(abspath, `\\?\UNC\`) {
+		if strings.HasPrefix(abspath, uncPathPrefix) {
+			return abspath
+		}
+		// Check if \\?\GLOBALROOT exists which marks volume shadow copy snapshots
+		if strings.HasPrefix(abspath, globalRootPrefix) {
+			if strings.Count(abspath, `\`) == 5 {
+				// Append slash if this just a volume name, e.g. `\\?\GLOBALROOT\Device\HarddiskVolumeShadowCopyXX`
+				// Without the trailing slash any access to the volume itself will fail.
+				return abspath + string(filepath.Separator)
+			}
 			return abspath
 		}
 		// Check if \\?\ already exist
-		if strings.HasPrefix(abspath, `\\?\`) {
+		if strings.HasPrefix(abspath, extendedPathPrefix) {
 			return abspath
 		}
 		// Check if path starts with \\
 		if strings.HasPrefix(abspath, `\\`) {
-			return strings.Replace(abspath, `\\`, `\\?\UNC\`, 1)
+			return strings.Replace(abspath, `\\`, uncPathPrefix, 1)
 		}
 		// Normal path
-		return `\\?\` + abspath
+		return extendedPathPrefix + abspath
 	}
 	return name
 }
diff --git a/internal/fs/fs_local_vss.go b/internal/fs/fs_local_vss.go
index 1915e2a7c..dcbda2a84 100644
--- a/internal/fs/fs_local_vss.go
+++ b/internal/fs/fs_local_vss.go
@@ -176,7 +176,7 @@ func (fs *LocalVss) snapshotPath(path string) string {
 		return path
 	}
 
-	fixPath = strings.TrimPrefix(fixpath(path), `\\?\`)
+	fixPath = strings.TrimPrefix(fixPath, `\\?\`)
 	fixPathLower := strings.ToLower(fixPath)
 	volumeName := filepath.VolumeName(fixPath)
 	volumeNameLower := strings.ToLower(volumeName)
diff --git a/internal/fs/node_windows.go b/internal/fs/node_windows.go
index 9ea813eb1..837d46428 100644
--- a/internal/fs/node_windows.go
+++ b/internal/fs/node_windows.go
@@ -325,8 +325,11 @@ func nodeFillGenericAttributes(node *restic.Node, path string, stat *ExtendedFil
 		return false, nil
 	}
 
-	if strings.HasSuffix(filepath.Clean(path), `\`) {
-		// filepath.Clean(path) ends with '\' for Windows root volume paths only
+	isVolume, err := isVolumePath(path)
+	if err != nil {
+		return false, 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,
@@ -335,7 +338,7 @@ func nodeFillGenericAttributes(node *restic.Node, path string, stat *ExtendedFil
 		if err != nil {
 			return false, err
 		}
-		return allowExtended, nil
+		return allowExtended, err
 	}
 
 	var sd *[]byte
@@ -420,6 +423,35 @@ func checkAndStoreEASupport(path string) (isEASupportedVolume bool, err error) {
 	return isEASupportedVolume, err
 }
 
+// getVolumePathName returns the volume path name for the given path.
+func getVolumePathName(path string) (volumeName string, err error) {
+	utf16Path, err := windows.UTF16PtrFromString(path)
+	if err != nil {
+		return "", err
+	}
+	// Get the volume path (e.g., "D:")
+	var volumePath [windows.MAX_PATH + 1]uint16
+	err = windows.GetVolumePathName(utf16Path, &volumePath[0], windows.MAX_PATH+1)
+	if err != nil {
+		return "", err
+	}
+	// Trim any trailing backslashes
+	volumeName = strings.TrimRight(windows.UTF16ToString(volumePath[:]), "\\")
+	return volumeName, nil
+}
+
+// isVolumePath returns whether a path refers to a volume
+func isVolumePath(path string) (bool, error) {
+	volName, err := prepareVolumeName(path)
+	if err != nil {
+		return false, err
+	}
+
+	cleanPath := filepath.Clean(path)
+	cleanVolume := filepath.Clean(volName + `\`)
+	return cleanPath == cleanVolume, nil
+}
+
 // prepareVolumeName prepares the volume name for different cases in Windows
 func prepareVolumeName(path string) (volumeName string, err error) {
 	// Check if it's an extended length path
diff --git a/internal/fs/sd_windows.go b/internal/fs/sd_windows.go
index 66d9bcb54..6bffa4fe2 100644
--- a/internal/fs/sd_windows.go
+++ b/internal/fs/sd_windows.go
@@ -54,6 +54,15 @@ func getSecurityDescriptor(filePath string) (securityDescriptor *[]byte, err err
 		sd, err = getNamedSecurityInfoLow(filePath)
 	} else {
 		sd, err = getNamedSecurityInfoHigh(filePath)
+		// Fallback to the low privilege version when receiving an access denied error.
+		// For some reason the ERROR_PRIVILEGE_NOT_HELD error is not returned for removable media
+		// but instead an access denied error is returned. Workaround that by just retrying with
+		// the low privilege version, but don't switch privileges as we cannot distinguish this
+		// case from actual access denied errors.
+		// see https://github.com/restic/restic/issues/5003#issuecomment-2452314191 for details
+		if err != nil && isAccessDeniedError(err) {
+			sd, err = getNamedSecurityInfoLow(filePath)
+		}
 	}
 	if err != nil {
 		if !useLowerPrivileges && isHandlePrivilegeNotHeldError(err) {
@@ -114,6 +123,10 @@ func setSecurityDescriptor(filePath string, securityDescriptor *[]byte) error {
 		err = setNamedSecurityInfoLow(filePath, dacl)
 	} else {
 		err = setNamedSecurityInfoHigh(filePath, owner, group, dacl, sacl)
+		// See corresponding fallback in getSecurityDescriptor for an explanation
+		if err != nil && isAccessDeniedError(err) {
+			err = setNamedSecurityInfoLow(filePath, dacl)
+		}
 	}
 
 	if err != nil {
@@ -174,6 +187,15 @@ func isHandlePrivilegeNotHeldError(err error) bool {
 	return false
 }
 
+// isAccessDeniedError checks if the error is ERROR_ACCESS_DENIED
+func isAccessDeniedError(err error) bool {
+	if errno, ok := err.(syscall.Errno); ok {
+		// Compare the error code to the expected value
+		return errno == windows.ERROR_ACCESS_DENIED
+	}
+	return false
+}
+
 // securityDescriptorBytesToStruct converts the security descriptor bytes representation
 // into a pointer to windows SECURITY_DESCRIPTOR.
 func securityDescriptorBytesToStruct(sd []byte) (*windows.SECURITY_DESCRIPTOR, error) {
diff --git a/internal/ui/termstatus/status.go b/internal/ui/termstatus/status.go
index 39654cc8c..e65330958 100644
--- a/internal/ui/termstatus/status.go
+++ b/internal/ui/termstatus/status.go
@@ -212,7 +212,7 @@ func (t *Terminal) runWithoutStatus(ctx context.Context) {
 			}
 
 			if _, err := io.WriteString(dst, msg.line); err != nil {
-				fmt.Fprintf(os.Stderr, "write failed: %v\n", err)
+				_, _ = fmt.Fprintf(os.Stderr, "write failed: %v\n", err)
 			}
 
 			if flush == nil {
@@ -220,16 +220,18 @@ func (t *Terminal) runWithoutStatus(ctx context.Context) {
 			}
 
 			if err := flush(); err != nil {
-				fmt.Fprintf(os.Stderr, "flush failed: %v\n", err)
+				_, _ = fmt.Fprintf(os.Stderr, "flush failed: %v\n", err)
 			}
 
 		case stat := <-t.status:
 			for _, line := range stat.lines {
 				// Ensure that each message ends with exactly one newline.
-				fmt.Fprintln(t.wr, strings.TrimRight(line, "\n"))
+				if _, err := fmt.Fprintln(t.wr, strings.TrimRight(line, "\n")); err != nil {
+					_, _ = fmt.Fprintf(os.Stderr, "write failed: %v\n", err)
+				}
 			}
 			if err := t.wr.Flush(); err != nil {
-				fmt.Fprintf(os.Stderr, "flush failed: %v\n", err)
+				_, _ = fmt.Fprintf(os.Stderr, "flush failed: %v\n", err)
 			}
 		}
 	}
diff --git a/internal/walker/walker.go b/internal/walker/walker.go
index 8acfed2f2..252bc3530 100644
--- a/internal/walker/walker.go
+++ b/internal/walker/walker.go
@@ -28,7 +28,7 @@ type WalkVisitor struct {
 	// was returned. This function is mandatory
 	ProcessNode WalkFunc
 	// Optional callback
-	LeaveDir func(path string)
+	LeaveDir func(path string) error
 }
 
 // Walk calls walkFn recursively for each node in root. If walkFn returns an
@@ -100,7 +100,7 @@ func walk(ctx context.Context, repo restic.BlobLoader, prefix string, parentTree
 	}
 
 	if visitor.LeaveDir != nil {
-		visitor.LeaveDir(prefix)
+		return visitor.LeaveDir(prefix)
 	}
 
 	return nil


Test patch:
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index bf4e63bb7..5a41723bb 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -13,7 +13,7 @@ permissions:
   contents: read
 
 env:
-  latest_go: "1.22.x"
+  latest_go: "1.23.x"
   GO111MODULE: on
 
 jobs:
@@ -23,27 +23,32 @@ jobs:
         # list of jobs to run:
         include:
           - job_name: Windows
-            go: 1.22.x
+            go: 1.23.x
             os: windows-latest
 
           - job_name: macOS
-            go: 1.22.x
+            go: 1.23.x
             os: macOS-latest
             test_fuse: false
 
           - job_name: Linux
-            go: 1.22.x
+            go: 1.23.x
             os: ubuntu-latest
             test_cloud_backends: true
             test_fuse: true
             check_changelog: true
 
           - job_name: Linux (race)
-            go: 1.22.x
+            go: 1.23.x
             os: ubuntu-latest
             test_fuse: true
             test_opts: "-race"
 
+          - job_name: Linux
+            go: 1.22.x
+            os: ubuntu-latest
+            test_fuse: true
+
           - job_name: Linux
             go: 1.21.x
             os: ubuntu-latest
@@ -254,7 +259,7 @@ jobs:
         uses: golangci/golangci-lint-action@v6
         with:
           # Required: the version of golangci-lint is required and must be specified without patch version: we always use the latest patch version.
-          version: v1.57.1
+          version: v1.61.0
           args: --verbose --timeout 5m
 
         # only run golangci-lint for pull requests, otherwise ALL hints get
diff --git a/cmd/restic/cmd_backup_integration_test.go b/cmd/restic/cmd_backup_integration_test.go
index 5926fdd54..4278f07ca 100644
--- a/cmd/restic/cmd_backup_integration_test.go
+++ b/cmd/restic/cmd_backup_integration_test.go
@@ -31,7 +31,7 @@ func testRunBackupAssumeFailure(t testing.TB, dir string, target []string, opts
 
 func testRunBackup(t testing.TB, dir string, target []string, opts BackupOptions, gopts GlobalOptions) {
 	err := testRunBackupAssumeFailure(t, dir, target, opts, gopts)
-	rtest.Assert(t, err == nil, "Error while backing up")
+	rtest.Assert(t, err == nil, "Error while backing up: %v", err)
 }
 
 func TestBackup(t *testing.T) {
@@ -52,14 +52,14 @@ func testBackup(t *testing.T, useFsSnapshot bool) {
 	opts := BackupOptions{UseFsSnapshot: useFsSnapshot}
 
 	// first backup
-	testRunBackup(t, filepath.Dir(env.testdata), []string{"testdata"}, opts, env.gopts)
+	testRunBackup(t, "", []string{env.testdata}, opts, env.gopts)
 	testListSnapshots(t, env.gopts, 1)
 
 	testRunCheck(t, env.gopts)
 	stat1 := dirStats(env.repo)
 
 	// second backup, implicit incremental
-	testRunBackup(t, filepath.Dir(env.testdata), []string{"testdata"}, opts, env.gopts)
+	testRunBackup(t, "", []string{env.testdata}, opts, env.gopts)
 	snapshotIDs := testListSnapshots(t, env.gopts, 2)
 
 	stat2 := dirStats(env.repo)
@@ -71,7 +71,7 @@ func testBackup(t *testing.T, useFsSnapshot bool) {
 	testRunCheck(t, env.gopts)
 	// third backup, explicit incremental
 	opts.Parent = snapshotIDs[0].String()
-	testRunBackup(t, filepath.Dir(env.testdata), []string{"testdata"}, opts, env.gopts)
+	testRunBackup(t, "", []string{env.testdata}, opts, env.gopts)
 	snapshotIDs = testListSnapshots(t, env.gopts, 3)
 
 	stat3 := dirStats(env.repo)
@@ -84,7 +84,7 @@ func testBackup(t *testing.T, useFsSnapshot bool) {
 	for i, snapshotID := range snapshotIDs {
 		restoredir := filepath.Join(env.base, fmt.Sprintf("restore%d", i))
 		t.Logf("restoring snapshot %v to %v", snapshotID.Str(), restoredir)
-		testRunRestore(t, env.gopts, restoredir, snapshotID)
+		testRunRestore(t, env.gopts, restoredir, snapshotID.String()+":"+toPathInSnapshot(filepath.Dir(env.testdata)))
 		diff := directoriesContentsDiff(env.testdata, filepath.Join(restoredir, "testdata"))
 		rtest.Assert(t, diff == "", "directories are not equal: %v", diff)
 	}
@@ -92,6 +92,20 @@ func testBackup(t *testing.T, useFsSnapshot bool) {
 	testRunCheck(t, env.gopts)
 }
 
+func toPathInSnapshot(path string) string {
+	// use path as is on most platforms, but convert it on windows
+	if runtime.GOOS == "windows" {
+		// the path generated by the test is always local so take the shortcut
+		vol := filepath.VolumeName(path)
+		if vol[len(vol)-1] != ':' {
+			panic(fmt.Sprintf("unexpected path: %q", path))
+		}
+		path = vol[:len(vol)-1] + string(filepath.Separator) + path[len(vol)+1:]
+		path = filepath.ToSlash(path)
+	}
+	return path
+}
+
 func TestBackupWithRelativePath(t *testing.T) {
 	env, cleanup := withTestEnvironment(t)
 	defer cleanup()
@@ -351,12 +365,7 @@ func TestBackupExclude(t *testing.T) {
 	for _, filename := range backupExcludeFilenames {
 		fp := filepath.Join(datadir, filename)
 		rtest.OK(t, os.MkdirAll(filepath.Dir(fp), 0755))
-
-		f, err := os.Create(fp)
-		rtest.OK(t, err)
-
-		fmt.Fprint(f, filename)
-		rtest.OK(t, f.Close())
+		rtest.OK(t, os.WriteFile(fp, []byte(filename), 0o666))
 	}
 
 	snapshots := make(map[string]struct{})
@@ -557,7 +566,7 @@ func TestHardLink(t *testing.T) {
 	for i, snapshotID := range snapshotIDs {
 		restoredir := filepath.Join(env.base, fmt.Sprintf("restore%d", i))
 		t.Logf("restoring snapshot %v to %v", snapshotID.Str(), restoredir)
-		testRunRestore(t, env.gopts, restoredir, snapshotID)
+		testRunRestore(t, env.gopts, restoredir, snapshotID.String())
 		diff := directoriesContentsDiff(env.testdata, filepath.Join(restoredir, "testdata"))
 		rtest.Assert(t, diff == "", "directories are not equal %v", diff)
 
diff --git a/cmd/restic/cmd_backup_test.go b/cmd/restic/cmd_backup_test.go
index 5cbc42436..44e08ff96 100644
--- a/cmd/restic/cmd_backup_test.go
+++ b/cmd/restic/cmd_backup_test.go
@@ -39,21 +39,24 @@ func TestCollectTargets(t *testing.T) {
 	f1, err := os.Create(filepath.Join(dir, "fromfile"))
 	rtest.OK(t, err)
 	// Empty lines should be ignored. A line starting with '#' is a comment.
-	fmt.Fprintf(f1, "\n%s*\n # here's a comment\n", f1.Name())
+	_, err = fmt.Fprintf(f1, "\n%s*\n # here's a comment\n", f1.Name())
+	rtest.OK(t, err)
 	rtest.OK(t, f1.Close())
 
 	f2, err := os.Create(filepath.Join(dir, "fromfile-verbatim"))
 	rtest.OK(t, err)
 	for _, filename := range []string{fooSpace, barStar} {
 		// Empty lines should be ignored. CR+LF is allowed.
-		fmt.Fprintf(f2, "%s\r\n\n", filepath.Join(dir, filename))
+		_, err = fmt.Fprintf(f2, "%s\r\n\n", filepath.Join(dir, filename))
+		rtest.OK(t, err)
 	}
 	rtest.OK(t, f2.Close())
 
 	f3, err := os.Create(filepath.Join(dir, "fromfile-raw"))
 	rtest.OK(t, err)
 	for _, filename := range []string{"baz", "quux"} {
-		fmt.Fprintf(f3, "%s\x00", filepath.Join(dir, filename))
+		_, err = fmt.Fprintf(f3, "%s\x00", filepath.Join(dir, filename))
+		rtest.OK(t, err)
 	}
 	rtest.OK(t, err)
 	rtest.OK(t, f3.Close())
diff --git a/cmd/restic/cmd_copy_integration_test.go b/cmd/restic/cmd_copy_integration_test.go
index 704615870..9ae78ba50 100644
--- a/cmd/restic/cmd_copy_integration_test.go
+++ b/cmd/restic/cmd_copy_integration_test.go
@@ -62,11 +62,11 @@ func TestCopy(t *testing.T) {
 	for i, snapshotID := range snapshotIDs {
 		restoredir := filepath.Join(env.base, fmt.Sprintf("restore%d", i))
 		origRestores[restoredir] = struct{}{}
-		testRunRestore(t, env.gopts, restoredir, snapshotID)
+		testRunRestore(t, env.gopts, restoredir, snapshotID.String())
 	}
 	for i, snapshotID := range copiedSnapshotIDs {
 		restoredir := filepath.Join(env2.base, fmt.Sprintf("restore%d", i))
-		testRunRestore(t, env2.gopts, restoredir, snapshotID)
+		testRunRestore(t, env2.gopts, restoredir, snapshotID.String())
 		foundMatch := false
 		for cmpdir := range origRestores {
 			diff := directoriesContentsDiff(restoredir, cmpdir)
diff --git a/cmd/restic/cmd_ls_test.go b/cmd/restic/cmd_ls_test.go
index b8b074242..3d4e1dbc7 100644
--- a/cmd/restic/cmd_ls_test.go
+++ b/cmd/restic/cmd_ls_test.go
@@ -134,29 +134,29 @@ func TestLsNcdu(t *testing.T) {
 	}
 	modTime := time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC)
 
-	printer.Snapshot(&restic.Snapshot{
+	rtest.OK(t, printer.Snapshot(&restic.Snapshot{
 		Hostname: "host",
 		Paths:    []string{"/example"},
-	})
-	printer.Node("/directory", &restic.Node{
+	}))
+	rtest.OK(t, printer.Node("/directory", &restic.Node{
 		Type:    restic.NodeTypeDir,
 		Name:    "directory",
 		ModTime: modTime,
-	}, false)
-	printer.Node("/directory/data", &restic.Node{
+	}, false))
+	rtest.OK(t, printer.Node("/directory/data", &restic.Node{
 		Type:    restic.NodeTypeFile,
 		Name:    "data",
 		Size:    42,
 		ModTime: modTime,
-	}, false)
-	printer.LeaveDir("/directory")
-	printer.Node("/file", &restic.Node{
+	}, false))
+	rtest.OK(t, printer.LeaveDir("/directory"))
+	rtest.OK(t, printer.Node("/file", &restic.Node{
 		Type:    restic.NodeTypeFile,
 		Name:    "file",
 		Size:    12345,
 		ModTime: modTime,
-	}, false)
-	printer.Close()
+	}, false))
+	rtest.OK(t, printer.Close())
 
 	rtest.Equals(t, `[1, 2, {"time":"0001-01-01T00:00:00Z","tree":null,"paths":["/example"],"hostname":"host"}, [{"name":"/"},
   [
diff --git a/cmd/restic/cmd_restore_integration_test.go b/cmd/restic/cmd_restore_integration_test.go
index 42cd1f87d..945c24a37 100644
--- a/cmd/restic/cmd_restore_integration_test.go
+++ b/cmd/restic/cmd_restore_integration_test.go
@@ -17,17 +17,17 @@ import (
 	"github.com/restic/restic/internal/ui/termstatus"
 )
 
-func testRunRestore(t testing.TB, opts GlobalOptions, dir string, snapshotID restic.ID) {
+func testRunRestore(t testing.TB, opts GlobalOptions, dir string, snapshotID string) {
 	testRunRestoreExcludes(t, opts, dir, snapshotID, nil)
 }
 
-func testRunRestoreExcludes(t testing.TB, gopts GlobalOptions, dir string, snapshotID restic.ID, excludes []string) {
+func testRunRestoreExcludes(t testing.TB, gopts GlobalOptions, dir string, snapshotID string, excludes []string) {
 	opts := RestoreOptions{
 		Target: dir,
 	}
 	opts.Excludes = excludes
 
-	rtest.OK(t, testRunRestoreAssumeFailure(snapshotID.String(), opts, gopts))
+	rtest.OK(t, testRunRestoreAssumeFailure(snapshotID, opts, gopts))
 }
 
 func testRunRestoreAssumeFailure(snapshotID string, opts RestoreOptions, gopts GlobalOptions) error {
@@ -197,7 +197,7 @@ func TestRestoreFilter(t *testing.T) {
 	snapshotID := testListSnapshots(t, env.gopts, 1)[0]
 
 	// no restore filter should restore all files
-	testRunRestore(t, env.gopts, filepath.Join(env.base, "restore0"), snapshotID)
+	testRunRestore(t, env.gopts, filepath.Join(env.base, "restore0"), snapshotID.String())
 	for _, testFile := range testfiles {
 		rtest.OK(t, testFileSize(filepath.Join(env.base, "restore0", "testdata", testFile.name), int64(testFile.size)))
 	}
@@ -219,7 +219,7 @@ func TestRestoreFilter(t *testing.T) {
 
 	// restore with excludes
 	restoredir := filepath.Join(env.base, "restore-with-excludes")
-	testRunRestoreExcludes(t, env.gopts, restoredir, snapshotID, excludePatterns)
+	testRunRestoreExcludes(t, env.gopts, restoredir, snapshotID.String(), excludePatterns)
 	testRestoredFileExclusions(t, restoredir)
 
 	// Create an exclude file with some patterns
@@ -339,7 +339,7 @@ func TestRestoreWithPermissionFailure(t *testing.T) {
 
 	_ = withRestoreGlobalOptions(func() error {
 		globalOptions.stderr = io.Discard
-		testRunRestore(t, env.gopts, filepath.Join(env.base, "restore"), snapshots[0])
+		testRunRestore(t, env.gopts, filepath.Join(env.base, "restore"), snapshots[0].String())
 		return nil
 	})
 
diff --git a/cmd/restic/integration_helpers_unix_test.go b/cmd/restic/integration_helpers_unix_test.go
index df0c4fe63..30852a753 100644
--- a/cmd/restic/integration_helpers_unix_test.go
+++ b/cmd/restic/integration_helpers_unix_test.go
@@ -13,17 +13,17 @@ import (
 
 func (e *dirEntry) equals(out io.Writer, other *dirEntry) bool {
 	if e.path != other.path {
-		fmt.Fprintf(out, "%v: path does not match (%v != %v)\n", e.path, e.path, other.path)
+		_, _ = fmt.Fprintf(out, "%v: path does not match (%v != %v)\n", e.path, e.path, other.path)
 		return false
 	}
 
 	if e.fi.Mode() != other.fi.Mode() {
-		fmt.Fprintf(out, "%v: mode does not match (%v != %v)\n", e.path, e.fi.Mode(), other.fi.Mode())
+		_, _ = fmt.Fprintf(out, "%v: mode does not match (%v != %v)\n", e.path, e.fi.Mode(), other.fi.Mode())
 		return false
 	}
 
 	if !sameModTime(e.fi, other.fi) {
-		fmt.Fprintf(out, "%v: ModTime does not match (%v != %v)\n", e.path, e.fi.ModTime(), other.fi.ModTime())
+		_, _ = fmt.Fprintf(out, "%v: ModTime does not match (%v != %v)\n", e.path, e.fi.ModTime(), other.fi.ModTime())
 		return false
 	}
 
@@ -31,17 +31,17 @@ func (e *dirEntry) equals(out io.Writer, other *dirEntry) bool {
 	stat2, _ := other.fi.Sys().(*syscall.Stat_t)
 
 	if stat.Uid != stat2.Uid {
-		fmt.Fprintf(out, "%v: UID does not match (%v != %v)\n", e.path, stat.Uid, stat2.Uid)
+		_, _ = fmt.Fprintf(out, "%v: UID does not match (%v != %v)\n", e.path, stat.Uid, stat2.Uid)
 		return false
 	}
 
 	if stat.Gid != stat2.Gid {
-		fmt.Fprintf(out, "%v: GID does not match (%v != %v)\n", e.path, stat.Gid, stat2.Gid)
+		_, _ = fmt.Fprintf(out, "%v: GID does not match (%v != %v)\n", e.path, stat.Gid, stat2.Gid)
 		return false
 	}
 
 	if stat.Nlink != stat2.Nlink {
-		fmt.Fprintf(out, "%v: Number of links do not match (%v != %v)\n", e.path, stat.Nlink, stat2.Nlink)
+		_, _ = fmt.Fprintf(out, "%v: Number of links do not match (%v != %v)\n", e.path, stat.Nlink, stat2.Nlink)
 		return false
 	}
 
diff --git a/cmd/restic/integration_test.go b/cmd/restic/integration_test.go
index cb4ccba41..777573f26 100644
--- a/cmd/restic/integration_test.go
+++ b/cmd/restic/integration_test.go
@@ -35,7 +35,7 @@ func TestCheckRestoreNoLock(t *testing.T) {
 	testRunCheck(t, env.gopts)
 
 	snapshotIDs := testListSnapshots(t, env.gopts, 4)
-	testRunRestore(t, env.gopts, filepath.Join(env.base, "restore"), snapshotIDs[0])
+	testRunRestore(t, env.gopts, filepath.Join(env.base, "restore"), snapshotIDs[0].String())
 }
 
 // a listOnceBackend only allows listing once per filetype
diff --git a/internal/backend/rest/rest_test.go b/internal/backend/rest/rest_test.go
index 891f60a87..50560f66d 100644
--- a/internal/backend/rest/rest_test.go
+++ b/internal/backend/rest/rest_test.go
@@ -106,7 +106,7 @@ func runRESTServer(ctx context.Context, t testing.TB, dir, reqListenAddr string)
 					matched = true
 				}
 			}
-			fmt.Fprintln(os.Stdout, line) // print all output to console
+			_, _ = fmt.Fprintln(os.Stdout, line) // print all output to console
 		}
 	}()
 
diff --git a/internal/fs/ea_windows_test.go b/internal/fs/ea_windows_test.go
index 64bc7f7b6..00cbe97f8 100644
--- a/internal/fs/ea_windows_test.go
+++ b/internal/fs/ea_windows_test.go
@@ -10,7 +10,6 @@ import (
 	"os"
 	"path/filepath"
 	"reflect"
-	"strings"
 	"syscall"
 	"testing"
 	"unsafe"
@@ -278,46 +277,3 @@ func TestPathSupportsExtendedAttributes(t *testing.T) {
 		t.Error("Expected an error for non-existent path, but got nil")
 	}
 }
-
-func TestGetVolumePathName(t *testing.T) {
-	tempDirVolume := filepath.VolumeName(os.TempDir())
-	testCases := []struct {
-		name           string
-		path           string
-		expectedPrefix string
-	}{
-		{
-			name:           "Root directory",
-			path:           os.Getenv("SystemDrive") + `\`,
-			expectedPrefix: os.Getenv("SystemDrive"),
-		},
-		{
-			name:           "Nested directory",
-			path:           os.Getenv("SystemDrive") + `\Windows\System32`,
-			expectedPrefix: os.Getenv("SystemDrive"),
-		},
-		{
-			name:           "Temp directory",
-			path:           os.TempDir() + `\`,
-			expectedPrefix: tempDirVolume,
-		},
-	}
-
-	for _, tc := range testCases {
-		t.Run(tc.name, func(t *testing.T) {
-			volumeName, err := getVolumePathName(tc.path)
-			if err != nil {
-				t.Fatalf("Unexpected error: %v", err)
-			}
-			if !strings.HasPrefix(volumeName, tc.expectedPrefix) {
-				t.Errorf("Expected volume name to start with %s, but got %s", tc.expectedPrefix, volumeName)
-			}
-		})
-	}
-
-	// Test with an invalid path
-	_, err := getVolumePathName("Z:\\NonExistentPath")
-	if err == nil {
-		t.Error("Expected an error for non-existent path, but got nil")
-	}
-}
diff --git a/internal/fs/node_windows_test.go b/internal/fs/node_windows_test.go
index 730740fe0..1bb76b204 100644
--- a/internal/fs/node_windows_test.go
+++ b/internal/fs/node_windows_test.go
@@ -451,10 +451,17 @@ func TestPrepareVolumeName(t *testing.T) {
 			expectError:         false,
 			expectedEASupported: false,
 		},
+		{
+			name:                "Volume Shadow Copy root",
+			path:                `\\?\GLOBALROOT\Device\HarddiskVolumeShadowCopy5555`,
+			expectedVolume:      `\\?\GLOBALROOT\Device\HarddiskVolumeShadowCopy5555`,
+			expectError:         false,
+			expectedEASupported: false,
+		},
 		{
 			name:                "Volume Shadow Copy path",
-			path:                `\\?\GLOBALROOT\Device\HarddiskVolumeShadowCopy1\Users\test`,
-			expectedVolume:      `\\?\GLOBALROOT\Device\HarddiskVolumeShadowCopy1`,
+			path:                `\\?\GLOBALROOT\Device\HarddiskVolumeShadowCopy5555\Users\test`,
+			expectedVolume:      `\\?\GLOBALROOT\Device\HarddiskVolumeShadowCopy5555`,
 			expectError:         false,
 			expectedEASupported: false,
 		},
@@ -526,3 +533,46 @@ func getOSVolumeGUIDPath(t *testing.T) string {
 
 	return windows.UTF16ToString(volumeGUID[:])
 }
+
+func TestGetVolumePathName(t *testing.T) {
+	tempDirVolume := filepath.VolumeName(os.TempDir())
+	testCases := []struct {
+		name           string
+		path           string
+		expectedPrefix string
+	}{
+		{
+			name:           "Root directory",
+			path:           os.Getenv("SystemDrive") + `\`,
+			expectedPrefix: os.Getenv("SystemDrive"),
+		},
+		{
+			name:           "Nested directory",
+			path:           os.Getenv("SystemDrive") + `\Windows\System32`,
+			expectedPrefix: os.Getenv("SystemDrive"),
+		},
+		{
+			name:           "Temp directory",
+			path:           os.TempDir() + `\`,
+			expectedPrefix: tempDirVolume,
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			volumeName, err := getVolumePathName(tc.path)
+			if err != nil {
+				t.Fatalf("Unexpected error: %v", err)
+			}
+			if !strings.HasPrefix(volumeName, tc.expectedPrefix) {
+				t.Errorf("Expected volume name to start with %s, but got %s", tc.expectedPrefix, volumeName)
+			}
+		})
+	}
+
+	// Test with an invalid path
+	_, err := getVolumePathName("Z:\\NonExistentPath")
+	if err == nil {
+		t.Error("Expected an error for non-existent path, but got nil")
+	}
+}
diff --git a/internal/walker/walker_test.go b/internal/walker/walker_test.go
index fa377bb8f..3614a2397 100644
--- a/internal/walker/walker_test.go
+++ b/internal/walker/walker_test.go
@@ -93,12 +93,12 @@ func (t TreeMap) Connections() uint {
 
 // checkFunc returns a function suitable for walking the tree to check
 // something, and a function which will check the final result.
-type checkFunc func(t testing.TB) (walker WalkFunc, leaveDir func(path string), final func(testing.TB))
+type checkFunc func(t testing.TB) (walker WalkFunc, leaveDir func(path string) error, final func(testing.TB))
 
 // checkItemOrder ensures that the order of the 'path' arguments is the one passed in as 'want'.
 func checkItemOrder(want []string) checkFunc {
 	pos := 0
-	return func(t testing.TB) (walker WalkFunc, leaveDir func(path string), final func(testing.TB)) {
+	return func(t testing.TB) (walker WalkFunc, leaveDir func(path string) error, final func(testing.TB)) {
 		walker = func(treeID restic.ID, path string, node *restic.Node, err error) error {
 			if err != nil {
 				t.Errorf("error walking %v: %v", path, err)
@@ -117,8 +117,8 @@ func checkItemOrder(want []string) checkFunc {
 			return nil
 		}
 
-		leaveDir = func(path string) {
-			_ = walker(restic.ID{}, "leave: "+path, nil, nil)
+		leaveDir = func(path string) error {
+			return walker(restic.ID{}, "leave: "+path, nil, nil)
 		}
 
 		final = func(t testing.TB) {
@@ -134,7 +134,7 @@ func checkItemOrder(want []string) checkFunc {
 // checkParentTreeOrder ensures that the order of the 'parentID' arguments is the one passed in as 'want'.
 func checkParentTreeOrder(want []string) checkFunc {
 	pos := 0
-	return func(t testing.TB) (walker WalkFunc, leaveDir func(path string), final func(testing.TB)) {
+	return func(t testing.TB) (walker WalkFunc, leaveDir func(path string) error, final func(testing.TB)) {
 		walker = func(treeID restic.ID, path string, node *restic.Node, err error) error {
 			if err != nil {
 				t.Errorf("error walking %v: %v", path, err)
@@ -168,7 +168,7 @@ func checkParentTreeOrder(want []string) checkFunc {
 func checkSkipFor(skipFor map[string]struct{}, wantPaths []string) checkFunc {
 	var pos int
 
-	return func(t testing.TB) (walker WalkFunc, leaveDir func(path string), final func(testing.TB)) {
+	return func(t testing.TB) (walker WalkFunc, leaveDir func(path string) error, final func(testing.TB)) {
 		walker = func(treeID restic.ID, path string, node *restic.Node, err error) error {
 			if err != nil {
 				t.Errorf("error walking %v: %v", path, err)
@@ -192,8 +192,8 @@ func checkSkipFor(skipFor map[string]struct{}, wantPaths []string) checkFunc {
 			return nil
 		}
 
-		leaveDir = func(path string) {
-			_ = walker(restic.ID{}, "leave: "+path, nil, nil)
+		leaveDir = func(path string) error {
+			return walker(restic.ID{}, "leave: "+path, nil, nil)
 		}
 
 		final = func(t testing.TB) {