Repository: restic/restic
Pull request #4705 (2024-02-22 21:50:51)

Store snapshot statistics & print snapshot size
What does this PR change? What problem does it solve?
-----------------------------------------------------
Store the backup statistics in a snapshot. Sample output:
```
{
  "time": "2024-02-22T22:39:19.991058932+01:00",
  "parent": "2c29113d80ef2f4072bae3095963a9c70f9d7014e893afd74887c8b61d30f143",
  "tree": "c163608e60d5bf1921b296528cd28b9844b508d96d9f4ad2564ced61c6dd0a24",
  "paths": [
    "/home/michael/Projekte/restic/restic"
  ],
  "hostname": "host",
  "username": "michael",
  "uid": 1000,
  "gid": 1000,
  "program_version": "restic 0.16.4-dev (compiled manually)",
  "summary": {
    "backup_start": "2024-02-22T22:39:19.991058932+01:00",
    "backup_end": "2024-02-22T22:39:26.37292521+01:00",
    "files_new": 85,
    "files_changed": 155,
    "files_unmodified": 2493,
    "dirs_new": 3,
    "dirs_changed": 110,
    "dirs_unmodified": 328,
    "data_blobs": 234,
    "tree_blobs": 114,
    "data_added": 39375231,
    "data_added_in_repo": 19236421,
    "total_files_processed": 2733,
    "total_bytes_processed": 152165603
  }
}
```

In addition the `snapshots` command now also prints the snapshot size (based on `total_bytes_processed`).

```
ID        Time                 Host        Tags        Paths                                    Size
-----------------------------------------------------------------------------------------------------------
4e0ff0ef  2024-02-22 22:39:19  host                    /home/michael/Projekte/restic/restic     145.116 MiB
-----------------------------------------------------------------------------------------------------------
```

Was the change previously discussed in an issue or on the forum?
----------------------------------------------------------------
Fixes https://github.com/restic/restic/issues/693
<!--
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).
- [x] I have added tests for all code changes.
- [x] 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 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.


darkdragon-001: Does the `--json` output also contain the size? 
aawsome: @MichaelEischer Is there a reason why your implementation deviates from
https://github.com/restic/restic/issues/693#issuecomment-1145295573 ?
aawsome: > Differences to [#693 (comment)](https://github.com/restic/restic/issues/693#issuecomment-1145295573):
> 
>     * "data_added_in_repo" instead of "data_added_packed": the corresponding fields in `ItemStats` are called `DataSizeInRepo` and `TreeSizeInRepo`. Thus that name is more consistent.

If you name them like this you make the snapshot format incompatible to what rustic implemented some 2 years ago. I don't care too much if you omit the other fields, but same things should be named the same, IMO.

Patch:
diff --git a/changelog/unreleased/issue-693 b/changelog/unreleased/issue-693
new file mode 100644
index 000000000..054ae42ed
--- /dev/null
+++ b/changelog/unreleased/issue-693
@@ -0,0 +1,12 @@
+Enhancement: Support printing snapshot size in `snapshots` command
+
+The `snapshots` command now supports printing the snapshot size for snapshots
+created using this or a future restic version. For this, the `backup` command
+now stores the backup summary statistics in the snapshot.
+
+The text output of the `snapshots` command only shows the snapshot size. The
+other statistics are only included in the JSON output. To inspect these
+statistics use `restic snapshots --json` or `restic cat snapshot <snapshotID>`.
+
+https://github.com/restic/restic/issues/693
+https://github.com/restic/restic/pull/4705
diff --git a/changelog/unreleased/pull-4611 b/changelog/unreleased/pull-4611
deleted file mode 100644
index 940de9c26..000000000
--- a/changelog/unreleased/pull-4611
+++ /dev/null
@@ -1,7 +0,0 @@
-Enhancement: Back up windows created time and file attributes like hidden flag
-
-Restic did not back up windows-specific meta-data like created time and file attributes like hidden flag.
-Restic now backs up file created time and file attributes like hidden, readonly and encrypted flag when backing up files and folders on windows.
-
-https://github.com/restic/restic/pull/4611
-
diff --git a/changelog/unreleased/pull-4703 b/changelog/unreleased/pull-4703
deleted file mode 100644
index 4df3385a0..000000000
--- a/changelog/unreleased/pull-4703
+++ /dev/null
@@ -1,9 +0,0 @@
-Bugfix: Shutdown cleanly when SIGTERM is received
-
-Prior, if restic received SIGTERM it'd just immediately terminate skipping
-cleanup- resulting in potential issues like stale locks being left behind.
-
-This primarily effected containerized restic invocations- they use SIGTERM-
-but this could be triggered via a simple `killall restic` in addition.
-
-https://github.com/restic/restic/pull/4703
diff --git a/cmd/restic/cleanup.go b/cmd/restic/cleanup.go
index 5a6cf79e1..75933fe96 100644
--- a/cmd/restic/cleanup.go
+++ b/cmd/restic/cleanup.go
@@ -19,7 +19,7 @@ var cleanupHandlers struct {
 func init() {
 	cleanupHandlers.ch = make(chan os.Signal, 1)
 	go CleanupHandler(cleanupHandlers.ch)
-	signal.Notify(cleanupHandlers.ch, syscall.SIGINT, syscall.SIGTERM)
+	signal.Notify(cleanupHandlers.ch, syscall.SIGINT)
 }
 
 // AddCleanupHandler adds the function f to the list of cleanup handlers so
@@ -56,7 +56,7 @@ func RunCleanupHandlers(code int) int {
 	return code
 }
 
-// CleanupHandler handles the SIGINT and SIGTERM signals.
+// CleanupHandler handles the SIGINT signals.
 func CleanupHandler(c <-chan os.Signal) {
 	for s := range c {
 		debug.Log("signal %v received, cleaning up", s)
@@ -70,7 +70,7 @@ func CleanupHandler(c <-chan os.Signal) {
 
 		code := 0
 
-		if s == syscall.SIGINT || s == syscall.SIGTERM {
+		if s == syscall.SIGINT {
 			code = 130
 		} else {
 			code = 1
diff --git a/cmd/restic/cmd_backup.go b/cmd/restic/cmd_backup.go
index 318d17796..acc4bddb1 100644
--- a/cmd/restic/cmd_backup.go
+++ b/cmd/restic/cmd_backup.go
@@ -451,6 +451,7 @@ func runBackup(ctx context.Context, opts BackupOptions, gopts GlobalOptions, ter
 	}
 
 	timeStamp := time.Now()
+	backupStart := timeStamp
 	if opts.TimeStamp != "" {
 		timeStamp, err = time.ParseInLocation(TimeFormat, opts.TimeStamp, time.Local)
 		if err != nil {
@@ -640,6 +641,7 @@ func runBackup(ctx context.Context, opts BackupOptions, gopts GlobalOptions, ter
 	snapshotOpts := archiver.SnapshotOptions{
 		Excludes:       opts.Excludes,
 		Tags:           opts.Tags.Flatten(),
+		BackupStart:    backupStart,
 		Time:           timeStamp,
 		Hostname:       opts.Host,
 		ParentSnapshot: parentSnapshot,
@@ -649,7 +651,7 @@ func runBackup(ctx context.Context, opts BackupOptions, gopts GlobalOptions, ter
 	if !gopts.JSON {
 		progressPrinter.V("start backup on %v", targets)
 	}
-	_, id, err := arch.Snapshot(ctx, targets, snapshotOpts)
+	_, id, summary, err := arch.Snapshot(ctx, targets, snapshotOpts)
 
 	// cleanly shutdown all running goroutines
 	cancel()
@@ -663,7 +665,7 @@ func runBackup(ctx context.Context, opts BackupOptions, gopts GlobalOptions, ter
 	}
 
 	// Report finished execution
-	progressReporter.Finish(id, opts.DryRun)
+	progressReporter.Finish(id, summary, opts.DryRun)
 	if !gopts.JSON && !opts.DryRun {
 		progressPrinter.P("snapshot %s saved\n", id.Str())
 	}
diff --git a/cmd/restic/cmd_find.go b/cmd/restic/cmd_find.go
index 7ea7c425a..04e6ae3dd 100644
--- a/cmd/restic/cmd_find.go
+++ b/cmd/restic/cmd_find.go
@@ -126,7 +126,6 @@ func (s *statefulOutput) PrintPatternJSON(path string, node *restic.Node) {
 		// Make the following attributes disappear
 		Name               byte `json:"name,omitempty"`
 		ExtendedAttributes byte `json:"extended_attributes,omitempty"`
-		GenericAttributes  byte `json:"generic_attributes,omitempty"`
 		Device             byte `json:"device,omitempty"`
 		Content            byte `json:"content,omitempty"`
 		Subtree            byte `json:"subtree,omitempty"`
diff --git a/cmd/restic/cmd_restore.go b/cmd/restic/cmd_restore.go
index 58f257541..37d304672 100644
--- a/cmd/restic/cmd_restore.go
+++ b/cmd/restic/cmd_restore.go
@@ -178,9 +178,6 @@ func runRestore(ctx context.Context, opts RestoreOptions, gopts GlobalOptions,
 		totalErrors++
 		return nil
 	}
-	res.Warn = func(message string) {
-		msg.E("Warning: %s\n", message)
-	}
 
 	excludePatterns := filter.ParsePatterns(opts.Exclude)
 	insensitiveExcludePatterns := filter.ParsePatterns(opts.InsensitiveExclude)
diff --git a/cmd/restic/cmd_snapshots.go b/cmd/restic/cmd_snapshots.go
index e94f2ed9b..d6199d47a 100644
--- a/cmd/restic/cmd_snapshots.go
+++ b/cmd/restic/cmd_snapshots.go
@@ -9,6 +9,7 @@ import (
 	"strings"
 
 	"github.com/restic/restic/internal/restic"
+	"github.com/restic/restic/internal/ui"
 	"github.com/restic/restic/internal/ui/table"
 	"github.com/spf13/cobra"
 )
@@ -163,6 +164,11 @@ func PrintSnapshots(stdout io.Writer, list restic.Snapshots, reasons []restic.Ke
 			keepReasons[*id] = reasons[i]
 		}
 	}
+	// check if any snapshot contains a summary
+	hasSize := false
+	for _, sn := range list {
+		hasSize = hasSize || (sn.Summary != nil)
+	}
 
 	// always sort the snapshots so that the newer ones are listed last
 	sort.SliceStable(list, func(i, j int) bool {
@@ -198,6 +204,9 @@ func PrintSnapshots(stdout io.Writer, list restic.Snapshots, reasons []restic.Ke
 			tab.AddColumn("Reasons", `{{ join .Reasons "\n" }}`)
 		}
 		tab.AddColumn("Paths", `{{ join .Paths "\n" }}`)
+		if hasSize {
+			tab.AddColumn("Size", `{{ .Size }}`)
+		}
 	}
 
 	type snapshot struct {
@@ -207,6 +216,7 @@ func PrintSnapshots(stdout io.Writer, list restic.Snapshots, reasons []restic.Ke
 		Tags      []string
 		Reasons   []string
 		Paths     []string
+		Size      string
 	}
 
 	var multiline bool
@@ -228,6 +238,10 @@ func PrintSnapshots(stdout io.Writer, list restic.Snapshots, reasons []restic.Ke
 			multiline = true
 		}
 
+		if sn.Summary != nil {
+			data.Size = ui.FormatBytes(sn.Summary.TotalBytesProcessed)
+		}
+
 		tab.AddRow(data)
 	}
 
diff --git a/doc/040_backup.rst b/doc/040_backup.rst
index d0bd4b2e2..550957eeb 100644
--- a/doc/040_backup.rst
+++ b/doc/040_backup.rst
@@ -487,6 +487,7 @@ particular note are:
 * File creation date on Unix platforms
 * Inode flags on Unix platforms
 * File ownership and ACLs on Windows
+* The "hidden" flag on Windows
 
 Reading data from a command
 ***************************
diff --git a/doc/045_working_with_repos.rst b/doc/045_working_with_repos.rst
index 48e5985dc..85c022580 100644
--- a/doc/045_working_with_repos.rst
+++ b/doc/045_working_with_repos.rst
@@ -18,19 +18,21 @@ Working with repositories
 Listing all snapshots
 =====================
 
-Now, you can list all the snapshots stored in the repository:
+Now, you can list all the snapshots stored in the repository. The size column
+only exists for snapshots created using restic 0.17.0 or later. It reflects the
+size of the contained files at the time when the snapshot was created.
 
 .. code-block:: console
 
     $ restic -r /srv/restic-repo snapshots
     enter password for repository:
-    ID        Date                 Host    Tags   Directory
-    ----------------------------------------------------------------------
-    40dc1520  2015-05-08 21:38:30  kasimir        /home/user/work
-    79766175  2015-05-08 21:40:19  kasimir        /home/user/work
-    bdbd3439  2015-05-08 21:45:17  luigi          /home/art
-    590c8fc8  2015-05-08 21:47:38  kazik          /srv
-    9f0bc19e  2015-05-08 21:46:11  luigi          /srv
+    ID        Date                 Host    Tags   Directory        Size
+    -------------------------------------------------------------------------
+    40dc1520  2015-05-08 21:38:30  kasimir        /home/user/work  20.643GiB
+    79766175  2015-05-08 21:40:19  kasimir        /home/user/work  20.645GiB
+    bdbd3439  2015-05-08 21:45:17  luigi          /home/art        3.141GiB
+    590c8fc8  2015-05-08 21:47:38  kazik          /srv             580.200MiB
+    9f0bc19e  2015-05-08 21:46:11  luigi          /srv             572.180MiB
 
 You can filter the listing by directory path:
 
@@ -38,10 +40,10 @@ You can filter the listing by directory path:
 
     $ restic -r /srv/restic-repo snapshots --path="/srv"
     enter password for repository:
-    ID        Date                 Host    Tags   Directory
-    ----------------------------------------------------------------------
-    590c8fc8  2015-05-08 21:47:38  kazik          /srv
-    9f0bc19e  2015-05-08 21:46:11  luigi          /srv
+    ID        Date                 Host    Tags   Directory  Size
+    -------------------------------------------------------------------
+    590c8fc8  2015-05-08 21:47:38  kazik          /srv       580.200MiB
+    9f0bc19e  2015-05-08 21:46:11  luigi          /srv       572.180MiB
 
 Or filter by host:
 
@@ -49,10 +51,10 @@ Or filter by host:
 
     $ restic -r /srv/restic-repo snapshots --host luigi
     enter password for repository:
-    ID        Date                 Host    Tags   Directory
-    ----------------------------------------------------------------------
-    bdbd3439  2015-05-08 21:45:17  luigi          /home/art
-    9f0bc19e  2015-05-08 21:46:11  luigi          /srv
+    ID        Date                 Host    Tags   Directory  Size
+    -------------------------------------------------------------------
+    bdbd3439  2015-05-08 21:45:17  luigi          /home/art  3.141GiB
+    9f0bc19e  2015-05-08 21:46:11  luigi          /srv       572.180MiB
 
 Combining filters is also possible.
 
@@ -64,21 +66,21 @@ Furthermore you can group the output by the same filters (host, paths, tags):
 
     enter password for repository:
     snapshots for (host [kasimir])
-    ID        Date                 Host    Tags   Directory
-    ----------------------------------------------------------------------
-    40dc1520  2015-05-08 21:38:30  kasimir        /home/user/work
-    79766175  2015-05-08 21:40:19  kasimir        /home/user/work
+    ID        Date                 Host    Tags   Directory        Size
+    ------------------------------------------------------------------------
+    40dc1520  2015-05-08 21:38:30  kasimir        /home/user/work  20.643GiB
+    79766175  2015-05-08 21:40:19  kasimir        /home/user/work  20.645GiB
     2 snapshots
     snapshots for (host [luigi])
-    ID        Date                 Host    Tags   Directory
-    ----------------------------------------------------------------------
-    bdbd3439  2015-05-08 21:45:17  luigi          /home/art
-    9f0bc19e  2015-05-08 21:46:11  luigi          /srv
+    ID        Date                 Host    Tags   Directory  Size
+    -------------------------------------------------------------------
+    bdbd3439  2015-05-08 21:45:17  luigi          /home/art  3.141GiB
+    9f0bc19e  2015-05-08 21:46:11  luigi          /srv       572.180MiB
     2 snapshots
     snapshots for (host [kazik])
-    ID        Date                 Host    Tags   Directory
-    ----------------------------------------------------------------------
-    590c8fc8  2015-05-08 21:47:38  kazik          /srv
+    ID        Date                 Host    Tags   Directory  Size
+    -------------------------------------------------------------------
+    590c8fc8  2015-05-08 21:47:38  kazik          /srv       580.200MiB
     1 snapshots
 
 
diff --git a/doc/075_scripting.rst b/doc/075_scripting.rst
index fda4b2d53..d51516cbe 100644
--- a/doc/075_scripting.rst
+++ b/doc/075_scripting.rst
@@ -163,7 +163,9 @@ Summary is the last output line in a successful backup.
 +---------------------------+---------------------------------------------------------+
 | ``tree_blobs``            | Number of tree blobs                                    |
 +---------------------------+---------------------------------------------------------+
-| ``data_added``            | Amount of data added, in bytes                          |
+| ``data_added``            | Amount of (uncompressed) data added, in bytes           |
++---------------------------+---------------------------------------------------------+
+| ``data_added_packed``     | Amount of data added (after compression), in bytes      |
 +---------------------------+---------------------------------------------------------+
 | ``total_files_processed`` | Total number of files processed                         |
 +---------------------------+---------------------------------------------------------+
@@ -551,11 +553,48 @@ The snapshots command returns a single JSON object, an array with objects of the
 +---------------------+--------------------------------------------------+
 | ``program_version`` | restic version used to create snapshot           |
 +---------------------+--------------------------------------------------+
+| ``summary``         | Snapshot statistics, see "Summary object"        |
++---------------------+--------------------------------------------------+
 | ``id``              | Snapshot ID                                      |
 +---------------------+--------------------------------------------------+
 | ``short_id``        | Snapshot ID, short form                          |
 +---------------------+--------------------------------------------------+
 
+Summary object
+
+The contained statistics reflect the information at the point in time when the snapshot
+was created.
+
++---------------------------+---------------------------------------------------------+
+| ``backup_start``          | Time at which the backup was started                    |
++---------------------------+---------------------------------------------------------+
+| ``backup_end``            | Time at which the backup was completed                  |
++---------------------------+---------------------------------------------------------+
+| ``files_new``             | Number of new files                                     |
++---------------------------+---------------------------------------------------------+
+| ``files_changed``         | Number of files that changed                            |
++---------------------------+---------------------------------------------------------+
+| ``files_unmodified``      | Number of files that did not change                     |
++---------------------------+---------------------------------------------------------+
+| ``dirs_new``              | Number of new directories                               |
++---------------------------+---------------------------------------------------------+
+| ``dirs_changed``          | Number of directories that changed                      |
++---------------------------+---------------------------------------------------------+
+| ``dirs_unmodified``       | Number of directories that did not change               |
++---------------------------+---------------------------------------------------------+
+| ``data_blobs``            | Number of data blobs                                    |
++---------------------------+---------------------------------------------------------+
+| ``tree_blobs``            | Number of tree blobs                                    |
++---------------------------+---------------------------------------------------------+
+| ``data_added``            | Amount of (uncompressed) data added, in bytes           |
++---------------------------+---------------------------------------------------------+
+| ``data_added_packed``     | Amount of data added (after compression), in bytes      |
++---------------------------+---------------------------------------------------------+
+| ``total_files_processed`` | Total number of files processed                         |
++---------------------------+---------------------------------------------------------+
+| ``total_bytes_processed`` | Total number of bytes processed                         |
++---------------------------+---------------------------------------------------------+
+
 
 stats
 -----
diff --git a/internal/archiver/archiver.go b/internal/archiver/archiver.go
index 77ddba7c4..73cd7a12e 100644
--- a/internal/archiver/archiver.go
+++ b/internal/archiver/archiver.go
@@ -8,6 +8,7 @@ import (
 	"runtime"
 	"sort"
 	"strings"
+	"sync"
 	"time"
 
 	"github.com/restic/restic/internal/debug"
@@ -40,6 +41,18 @@ type ItemStats struct {
 	TreeSizeInRepo uint64 // sum of the bytes added to the repo (including compression and crypto overhead)
 }
 
+type ChangeStats struct {
+	New       uint
+	Changed   uint
+	Unchanged uint
+}
+
+type Summary struct {
+	Files, Dirs    ChangeStats
+	ProcessedBytes uint64
+	ItemStats
+}
+
 // Add adds other to the current ItemStats.
 func (s *ItemStats) Add(other ItemStats) {
 	s.DataBlobs += other.DataBlobs
@@ -61,6 +74,8 @@ type Archiver struct {
 	blobSaver *BlobSaver
 	fileSaver *FileSaver
 	treeSaver *TreeSaver
+	mu        sync.Mutex
+	summary   *Summary
 
 	// Error is called for all errors that occur during backup.
 	Error ErrorFunc
@@ -182,6 +197,44 @@ func (arch *Archiver) error(item string, err error) error {
 	return errf
 }
 
+func (arch *Archiver) trackItem(item string, previous, current *restic.Node, s ItemStats, d time.Duration) {
+	arch.CompleteItem(item, previous, current, s, d)
+
+	arch.mu.Lock()
+	defer arch.mu.Unlock()
+
+	arch.summary.ItemStats.Add(s)
+
+	if current != nil {
+		arch.summary.ProcessedBytes += current.Size
+	} else {
+		// last item or an error occurred
+		return
+	}
+
+	switch current.Type {
+	case "dir":
+		switch {
+		case previous == nil:
+			arch.summary.Dirs.New++
+		case previous.Equals(*current):
+			arch.summary.Dirs.Unchanged++
+		default:
+			arch.summary.Dirs.Changed++
+		}
+
+	case "file":
+		switch {
+		case previous == nil:
+			arch.summary.Files.New++
+		case previous.Equals(*current):
+			arch.summary.Files.Unchanged++
+		default:
+			arch.summary.Files.Changed++
+		}
+	}
+}
+
 // nodeFromFileInfo returns the restic node from an os.FileInfo.
 func (arch *Archiver) nodeFromFileInfo(snPath, filename string, fi os.FileInfo) (*restic.Node, error) {
 	node, err := restic.NodeFromFileInfo(filename, fi)
@@ -222,9 +275,9 @@ func (arch *Archiver) wrapLoadTreeError(id restic.ID, err error) error {
 	return err
 }
 
-// SaveDir stores a directory in the repo and returns the node. snPath is the
+// 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 CompleteFunc) (d FutureNode, err error) {
+func (arch *Archiver) saveDir(ctx context.Context, snPath string, dir string, fi os.FileInfo, previous *restic.Tree, complete CompleteFunc) (d FutureNode, err error) {
 	debug.Log("%v %v", snPath, dir)
 
 	treeNode, err := arch.nodeFromFileInfo(snPath, dir, fi)
@@ -250,7 +303,7 @@ func (arch *Archiver) SaveDir(ctx context.Context, snPath string, dir string, fi
 		pathname := arch.FS.Join(dir, name)
 		oldNode := previous.Find(name)
 		snItem := join(snPath, name)
-		fn, excluded, err := arch.Save(ctx, snItem, pathname, oldNode)
+		fn, excluded, err := arch.save(ctx, snItem, pathname, oldNode)
 
 		// return error early if possible
 		if err != nil {
@@ -334,14 +387,14 @@ func (arch *Archiver) allBlobsPresent(previous *restic.Node) bool {
 	return true
 }
 
-// Save saves a target (file or directory) to the repo. If the item is
+// save saves a target (file or directory) to the repo. If the item is
 // excluded, this function returns a nil node and error, with excluded set to
 // true.
 //
 // Errors and completion needs to be handled by the caller.
 //
 // snPath is the path within the current snapshot.
-func (arch *Archiver) Save(ctx context.Context, snPath, target string, previous *restic.Node) (fn FutureNode, excluded bool, err error) {
+func (arch *Archiver) save(ctx context.Context, snPath, target string, previous *restic.Node) (fn FutureNode, excluded bool, err error) {
 	start := time.Now()
 
 	debug.Log("%v target %q, previous %v", snPath, target, previous)
@@ -380,7 +433,7 @@ func (arch *Archiver) Save(ctx context.Context, snPath, target string, previous
 		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.CompleteItem(snPath, previous, previous, ItemStats{}, time.Since(start))
+				arch.trackItem(snPath, previous, previous, ItemStats{}, time.Since(start))
 				arch.CompleteBlob(previous.Size)
 				node, err := arch.nodeFromFileInfo(snPath, target, fi)
 				if err != nil {
@@ -445,9 +498,9 @@ func (arch *Archiver) Save(ctx context.Context, snPath, target string, previous
 		fn = arch.fileSaver.Save(ctx, snPath, target, file, fi, func() {
 			arch.StartFile(snPath)
 		}, func() {
-			arch.CompleteItem(snPath, nil, nil, ItemStats{}, 0)
+			arch.trackItem(snPath, nil, nil, ItemStats{}, 0)
 		}, func(node *restic.Node, stats ItemStats) {
-			arch.CompleteItem(snPath, previous, node, stats, time.Since(start))
+			arch.trackItem(snPath, previous, node, stats, time.Since(start))
 		})
 
 	case fi.IsDir():
@@ -462,9 +515,9 @@ 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, fi, oldSubtree,
 			func(node *restic.Node, stats ItemStats) {
-				arch.CompleteItem(snItem, previous, node, stats, time.Since(start))
+				arch.trackItem(snItem, previous, node, stats, time.Since(start))
 			})
 		if err != nil {
 			debug.Log("SaveDir for %v returned error: %v", snPath, err)
@@ -545,9 +598,9 @@ func (arch *Archiver) statDir(dir string) (os.FileInfo, error) {
 	return fi, nil
 }
 
-// SaveTree stores a Tree in the repo, returned is the tree. snPath is the path
+// 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 CompleteFunc) (FutureNode, int, error) {
+func (arch *Archiver) saveTree(ctx context.Context, snPath string, atree *Tree, previous *restic.Tree, complete CompleteFunc) (FutureNode, int, error) {
 
 	var node *restic.Node
 	if snPath != "/" {
@@ -585,7 +638,7 @@ func (arch *Archiver) SaveTree(ctx context.Context, snPath string, atree *Tree,
 
 		// this is a leaf node
 		if subatree.Leaf() {
-			fn, excluded, err := arch.Save(ctx, join(snPath, name), subatree.Path, previous.Find(name))
+			fn, excluded, err := arch.save(ctx, join(snPath, name), subatree.Path, previous.Find(name))
 
 			if err != nil {
 				err = arch.error(subatree.Path, err)
@@ -619,8 +672,8 @@ func (arch *Archiver) SaveTree(ctx context.Context, snPath string, atree *Tree,
 		}
 
 		// not a leaf node, archive subtree
-		fn, _, err := arch.SaveTree(ctx, join(snPath, name), &subatree, oldSubtree, func(n *restic.Node, is ItemStats) {
-			arch.CompleteItem(snItem, oldNode, n, is, time.Since(start))
+		fn, _, err := arch.saveTree(ctx, join(snPath, name), &subatree, oldSubtree, func(n *restic.Node, is ItemStats) {
+			arch.trackItem(snItem, oldNode, n, is, time.Since(start))
 		})
 		if err != nil {
 			return FutureNode{}, 0, err
@@ -688,6 +741,7 @@ type SnapshotOptions struct {
 	Tags           restic.TagList
 	Hostname       string
 	Excludes       []string
+	BackupStart    time.Time
 	Time           time.Time
 	ParentSnapshot *restic.Snapshot
 	ProgramVersion string
@@ -738,15 +792,17 @@ func (arch *Archiver) stopWorkers() {
 }
 
 // Snapshot saves several targets and returns a snapshot.
-func (arch *Archiver) Snapshot(ctx context.Context, targets []string, opts SnapshotOptions) (*restic.Snapshot, restic.ID, error) {
+func (arch *Archiver) Snapshot(ctx context.Context, targets []string, opts SnapshotOptions) (*restic.Snapshot, restic.ID, *Summary, error) {
+	arch.summary = &Summary{}
+
 	cleanTargets, err := resolveRelativeTargets(arch.FS, targets)
 	if err != nil {
-		return nil, restic.ID{}, err
+		return nil, restic.ID{}, nil, err
 	}
 
 	atree, err := NewTree(arch.FS, cleanTargets)
 	if err != nil {
-		return nil, restic.ID{}, err
+		return nil, restic.ID{}, nil, err
 	}
 
 	var rootTreeID restic.ID
@@ -762,8 +818,8 @@ func (arch *Archiver) Snapshot(ctx context.Context, targets []string, opts Snaps
 			arch.runWorkers(wgCtx, wg)
 
 			debug.Log("starting snapshot")
-			fn, nodeCount, err := arch.SaveTree(wgCtx, "/", atree, arch.loadParentTree(wgCtx, opts.ParentSnapshot), func(_ *restic.Node, is ItemStats) {
-				arch.CompleteItem("/", nil, nil, is, time.Since(start))
+			fn, nodeCount, err := arch.saveTree(wgCtx, "/", atree, arch.loadParentTree(wgCtx, opts.ParentSnapshot), func(_ *restic.Node, is ItemStats) {
+				arch.trackItem("/", nil, nil, is, time.Since(start))
 			})
 			if err != nil {
 				return err
@@ -799,12 +855,12 @@ func (arch *Archiver) Snapshot(ctx context.Context, targets []string, opts Snaps
 	})
 	err = wgUp.Wait()
 	if err != nil {
-		return nil, restic.ID{}, err
+		return nil, restic.ID{}, nil, err
 	}
 
 	sn, err := restic.NewSnapshot(targets, opts.Tags, opts.Hostname, opts.Time)
 	if err != nil {
-		return nil, restic.ID{}, err
+		return nil, restic.ID{}, nil, err
 	}
 
 	sn.ProgramVersion = opts.ProgramVersion
@@ -813,11 +869,28 @@ func (arch *Archiver) Snapshot(ctx context.Context, targets []string, opts Snaps
 		sn.Parent = opts.ParentSnapshot.ID()
 	}
 	sn.Tree = &rootTreeID
+	sn.Summary = &restic.SnapshotSummary{
+		BackupStart: opts.BackupStart,
+		BackupEnd:   time.Now(),
+
+		FilesNew:            arch.summary.Files.New,
+		FilesChanged:        arch.summary.Files.Changed,
+		FilesUnmodified:     arch.summary.Files.Unchanged,
+		DirsNew:             arch.summary.Dirs.New,
+		DirsChanged:         arch.summary.Dirs.Changed,
+		DirsUnmodified:      arch.summary.Dirs.Unchanged,
+		DataBlobs:           arch.summary.ItemStats.DataBlobs,
+		TreeBlobs:           arch.summary.ItemStats.TreeBlobs,
+		DataAdded:           arch.summary.ItemStats.DataSize + arch.summary.ItemStats.TreeSize,
+		DataAddedPacked:     arch.summary.ItemStats.DataSizeInRepo + arch.summary.ItemStats.TreeSizeInRepo,
+		TotalFilesProcessed: arch.summary.Files.New + arch.summary.Files.Changed + arch.summary.Files.Unchanged,
+		TotalBytesProcessed: arch.summary.ProcessedBytes,
+	}
 
 	id, err := restic.SaveSnapshot(ctx, arch.Repo, sn)
 	if err != nil {
-		return nil, restic.ID{}, err
+		return nil, restic.ID{}, nil, err
 	}
 
-	return sn, id, nil
+	return sn, id, arch.summary, nil
 }
diff --git a/internal/errors/errors.go b/internal/errors/errors.go
index 3c669f861..0327ea0da 100644
--- a/internal/errors/errors.go
+++ b/internal/errors/errors.go
@@ -2,7 +2,6 @@ package errors
 
 import (
 	stderrors "errors"
-	"fmt"
 
 	"github.com/pkg/errors"
 )
@@ -23,42 +22,12 @@ var Wrap = errors.Wrap
 // nil, Wrapf returns nil.
 var Wrapf = errors.Wrapf
 
-// WithStack annotates err with a stack trace at the point WithStack was called.
-// If err is nil, WithStack returns nil.
 var WithStack = errors.WithStack
 
 // Go 1.13-style error handling.
 
-// As finds the first error in err's tree that matches target, and if one is found,
-// sets target to that error value and returns true. Otherwise, it returns false.
 func As(err error, tgt interface{}) bool { return stderrors.As(err, tgt) }
 
-// Is reports whether any error in err's tree matches target.
 func Is(x, y error) bool { return stderrors.Is(x, y) }
 
-// Unwrap returns the result of calling the Unwrap method on err, if err's type contains
-// an Unwrap method returning error. Otherwise, Unwrap returns nil.
-//
-// Unwrap only calls a method of the form "Unwrap() error". In particular Unwrap does not
-// unwrap errors returned by [Join].
 func Unwrap(err error) error { return stderrors.Unwrap(err) }
-
-// CombineErrors combines multiple errors into a single error.
-func CombineErrors(errors ...error) error {
-	var combinedErrorMsg string
-
-	for _, err := range errors {
-		if err != nil {
-			if combinedErrorMsg != "" {
-				combinedErrorMsg += "; " // Separate error messages with a delimiter
-			}
-			combinedErrorMsg += err.Error()
-		}
-	}
-
-	if combinedErrorMsg == "" {
-		return nil // No errors, return nil
-	}
-
-	return fmt.Errorf("multiple errors occurred: [%s]", combinedErrorMsg)
-}
diff --git a/internal/fs/file.go b/internal/fs/file.go
index 4a236ea09..f35901c06 100644
--- a/internal/fs/file.go
+++ b/internal/fs/file.go
@@ -124,17 +124,3 @@ func RemoveIfExists(filename string) error {
 func Chtimes(name string, atime time.Time, mtime time.Time) error {
 	return os.Chtimes(fixpath(name), atime, mtime)
 }
-
-// IsAccessDenied checks if the error is due to permission error.
-func IsAccessDenied(err error) bool {
-	return os.IsPermission(err)
-}
-
-// ResetPermissions resets the permissions of the file at the specified path
-func ResetPermissions(path string) error {
-	// Set the default file permissions
-	if err := os.Chmod(path, 0600); err != nil {
-		return err
-	}
-	return nil
-}
diff --git a/internal/fs/file_windows.go b/internal/fs/file_windows.go
index 2f0969804..d19a744e1 100644
--- a/internal/fs/file_windows.go
+++ b/internal/fs/file_windows.go
@@ -77,29 +77,3 @@ func TempFile(dir, prefix string) (f *os.File, err error) {
 func Chmod(name string, mode os.FileMode) error {
 	return os.Chmod(fixpath(name), mode)
 }
-
-// ClearSystem removes the system attribute from the file.
-func ClearSystem(path string) error {
-	return ClearAttribute(path, windows.FILE_ATTRIBUTE_SYSTEM)
-}
-
-// ClearAttribute removes the specified attribute from the file.
-func ClearAttribute(path string, attribute uint32) error {
-	ptr, err := windows.UTF16PtrFromString(path)
-	if err != nil {
-		return err
-	}
-	fileAttributes, err := windows.GetFileAttributes(ptr)
-	if err != nil {
-		return err
-	}
-	if fileAttributes&attribute != 0 {
-		// Clear the attribute
-		fileAttributes &= ^uint32(attribute)
-		err = windows.SetFileAttributes(ptr, fileAttributes)
-		if err != nil {
-			return err
-		}
-	}
-	return nil
-}
diff --git a/internal/restic/node.go b/internal/restic/node.go
index cbe9ef363..1d5bb51af 100644
--- a/internal/restic/node.go
+++ b/internal/restic/node.go
@@ -6,9 +6,7 @@ import (
 	"fmt"
 	"os"
 	"os/user"
-	"reflect"
 	"strconv"
-	"strings"
 	"sync"
 	"syscall"
 	"time"
@@ -22,53 +20,12 @@ import (
 	"github.com/restic/restic/internal/fs"
 )
 
-// ExtendedAttribute is a tuple storing the xattr name and value for various filesystems.
+// ExtendedAttribute is a tuple storing the xattr name and value.
 type ExtendedAttribute struct {
 	Name  string `json:"name"`
 	Value []byte `json:"value"`
 }
 
-// GenericAttributeType can be used for OS specific functionalities by defining specific types
-// in node.go to be used by the specific node_xx files.
-// OS specific attribute types should follow the convention <OS>Attributes.
-// GenericAttributeTypes should follow the convention <OS specific attribute type>.<attribute name>
-// The attributes in OS specific attribute types must be pointers as we want to distinguish nil values
-// and not create GenericAttributes for them.
-type GenericAttributeType string
-
-// OSType is the type created to represent each specific OS
-type OSType string
-
-const (
-	// When new GenericAttributeType are defined, they must be added in the init function as well.
-
-	// Below are windows specific attributes.
-
-	// TypeCreationTime is the GenericAttributeType used for storing creation time for windows files within the generic attributes map.
-	TypeCreationTime GenericAttributeType = "windows.creation_time"
-	// TypeFileAttributes is the GenericAttributeType used for storing file attributes for windows files within the generic attributes map.
-	TypeFileAttributes GenericAttributeType = "windows.file_attributes"
-
-	// Generic Attributes for other OS types should be defined here.
-)
-
-// init is called when the package is initialized. Any new GenericAttributeTypes being created must be added here as well.
-func init() {
-	storeGenericAttributeType(TypeCreationTime, TypeFileAttributes)
-}
-
-// genericAttributesForOS maintains a map of known genericAttributesForOS to the OSType
-var genericAttributesForOS = map[GenericAttributeType]OSType{}
-
-// storeGenericAttributeType adds and entry in genericAttributesForOS map
-func storeGenericAttributeType(attributeTypes ...GenericAttributeType) {
-	for _, attributeType := range attributeTypes {
-		// Get the OS attribute type from the GenericAttributeType
-		osAttributeName := strings.Split(string(attributeType), ".")[0]
-		genericAttributesForOS[attributeType] = OSType(osAttributeName)
-	}
-}
-
 // Node is a file, directory or other item in a backup.
 type Node struct {
 	Name       string      `json:"name"`
@@ -90,12 +47,11 @@ type Node struct {
 	// This allows storing arbitrary byte-sequences, which are possible as symlink targets on unix systems,
 	// as LinkTarget without breaking backwards-compatibility.
 	// Must only be set of the linktarget cannot be encoded as valid utf8.
-	LinkTargetRaw      []byte                                   `json:"linktarget_raw,omitempty"`
-	ExtendedAttributes []ExtendedAttribute                      `json:"extended_attributes,omitempty"`
-	GenericAttributes  map[GenericAttributeType]json.RawMessage `json:"generic_attributes,omitempty"`
-	Device             uint64                                   `json:"device,omitempty"` // in case of Type == "dev", stat.st_rdev
-	Content            IDs                                      `json:"content"`
-	Subtree            *ID                                      `json:"subtree,omitempty"`
+	LinkTargetRaw      []byte              `json:"linktarget_raw,omitempty"`
+	ExtendedAttributes []ExtendedAttribute `json:"extended_attributes,omitempty"`
+	Device             uint64              `json:"device,omitempty"` // in case of Type == "dev", stat.st_rdev
+	Content            IDs                 `json:"content"`
+	Subtree            *ID                 `json:"subtree,omitempty"`
 
 	Error string `json:"error,omitempty"`
 
@@ -224,8 +180,8 @@ func (node *Node) CreateAt(ctx context.Context, path string, repo BlobLoader) er
 }
 
 // RestoreMetadata restores node metadata
-func (node Node) RestoreMetadata(path string, warn func(msg string)) error {
-	err := node.restoreMetadata(path, warn)
+func (node Node) RestoreMetadata(path string) error {
+	err := node.restoreMetadata(path)
 	if err != nil {
 		debug.Log("restoreMetadata(%s) error %v", path, err)
 	}
@@ -233,7 +189,7 @@ func (node Node) RestoreMetadata(path string, warn func(msg string)) error {
 	return err
 }
 
-func (node Node) restoreMetadata(path string, warn func(msg string)) error {
+func (node Node) restoreMetadata(path string) error {
 	var firsterr error
 
 	if err := lchown(path, int(node.UID), int(node.GID)); err != nil {
@@ -247,6 +203,14 @@ func (node Node) restoreMetadata(path string, warn func(msg string)) error {
 		}
 	}
 
+	if node.Type != "symlink" {
+		if err := fs.Chmod(path, node.Mode); err != nil {
+			if firsterr != nil {
+				firsterr = errors.WithStack(err)
+			}
+		}
+	}
+
 	if err := node.RestoreTimestamps(path); err != nil {
 		debug.Log("error restoring timestamps for dir %v: %v", path, err)
 		if firsterr != nil {
@@ -261,24 +225,6 @@ func (node Node) restoreMetadata(path string, warn func(msg string)) error {
 		}
 	}
 
-	if err := node.restoreGenericAttributes(path, warn); err != nil {
-		debug.Log("error restoring generic attributes for %v: %v", path, err)
-		if firsterr != nil {
-			firsterr = err
-		}
-	}
-
-	// Moving RestoreTimestamps and restoreExtendedAttributes calls above as for readonly files in windows
-	// calling Chmod below will no longer allow any modifications to be made on the file and the
-	// calls above would fail.
-	if node.Type != "symlink" {
-		if err := fs.Chmod(path, node.Mode); err != nil {
-			if firsterr != nil {
-				firsterr = errors.WithStack(err)
-			}
-		}
-	}
-
 	return firsterr
 }
 
@@ -492,9 +438,6 @@ func (node Node) Equals(other Node) bool {
 	if !node.sameExtendedAttributes(other) {
 		return false
 	}
-	if !node.sameGenericAttributes(other) {
-		return false
-	}
 	if node.Subtree != nil {
 		if other.Subtree == nil {
 			return false
@@ -537,13 +480,8 @@ func (node Node) sameContent(other Node) bool {
 }
 
 func (node Node) sameExtendedAttributes(other Node) bool {
-	ln := len(node.ExtendedAttributes)
-	lo := len(other.ExtendedAttributes)
-	if ln != lo {
+	if len(node.ExtendedAttributes) != len(other.ExtendedAttributes) {
 		return false
-	} else if ln == 0 {
-		// This means lo is also of length 0
-		return true
 	}
 
 	// build a set of all attributes that node has
@@ -587,33 +525,6 @@ func (node Node) sameExtendedAttributes(other Node) bool {
 	return true
 }
 
-func (node Node) sameGenericAttributes(other Node) bool {
-	return deepEqual(node.GenericAttributes, other.GenericAttributes)
-}
-
-func deepEqual(map1, map2 map[GenericAttributeType]json.RawMessage) bool {
-	// Check if the maps have the same number of keys
-	if len(map1) != len(map2) {
-		return false
-	}
-
-	// Iterate over each key-value pair in map1
-	for key, value1 := range map1 {
-		// Check if the key exists in map2
-		value2, ok := map2[key]
-		if !ok {
-			return false
-		}
-
-		// Check if the JSON.RawMessage values are equal byte by byte
-		if !bytes.Equal(value1, value2) {
-			return false
-		}
-	}
-
-	return true
-}
-
 func (node *Node) fillUser(stat *statT) {
 	uid, gid := stat.uid(), stat.gid()
 	node.UID, node.GID = uid, gid
@@ -716,17 +627,7 @@ func (node *Node) fillExtra(path string, fi os.FileInfo) error {
 		return errors.Errorf("unsupported file type %q", node.Type)
 	}
 
-	allowExtended, err := node.fillGenericAttributes(path, fi, stat)
-	if allowExtended {
-		// Skip processing ExtendedAttributes if allowExtended is false.
-		errEx := node.fillExtendedAttributes(path)
-		if err == nil {
-			err = errEx
-		} else {
-			debug.Log("Error filling extended attributes for %v at %v : %v", node.Name, path, errEx)
-		}
-	}
-	return err
+	return node.fillExtendedAttributes(path)
 }
 
 func (node *Node) fillExtendedAttributes(path string) error {
@@ -764,119 +665,3 @@ func (node *Node) fillTimes(stat *statT) {
 	node.ChangeTime = time.Unix(ctim.Unix())
 	node.AccessTime = time.Unix(atim.Unix())
 }
-
-// HandleUnknownGenericAttributesFound is used for handling and distinguing between scenarios related to future versions and cross-OS repositories
-func HandleUnknownGenericAttributesFound(unknownAttribs []GenericAttributeType, warn func(msg string)) {
-	for _, unknownAttrib := range unknownAttribs {
-		handleUnknownGenericAttributeFound(unknownAttrib, warn)
-	}
-}
-
-// handleUnknownGenericAttributeFound is used for handling and distinguing between scenarios related to future versions and cross-OS repositories
-func handleUnknownGenericAttributeFound(genericAttributeType GenericAttributeType, warn func(msg string)) {
-	if checkGenericAttributeNameNotHandledAndPut(genericAttributeType) {
-		// Print the unique error only once for a given execution
-		os, exists := genericAttributesForOS[genericAttributeType]
-
-		if exists {
-			// If genericAttributesForOS contains an entry but we still got here, it means the specific node_xx.go for the current OS did not handle it and the repository may have been originally created on a different OS.
-			// The fact that node.go knows about the attribute, means it is not a new attribute. This may be a common situation if a repo is used across OSs.
-			debug.Log("Ignoring a generic attribute found in the repository: %s which may not be compatible with your OS. Compatible OS: %s", genericAttributeType, os)
-		} else {
-			// If genericAttributesForOS in node.go does not know about this attribute, then the repository may have been created by a newer version which has a newer GenericAttributeType.
-			warn(fmt.Sprintf("Found an unrecognized generic attribute in the repository: %s. You may need to upgrade to latest version of restic.", genericAttributeType))
-		}
-	}
-}
-
-// handleAllUnknownGenericAttributesFound performs validations for all generic attributes in the node.
-// This is not used on windows currently because windows has handling for generic attributes.
-// nolint:unused
-func (node Node) handleAllUnknownGenericAttributesFound(warn func(msg string)) error {
-	for name := range node.GenericAttributes {
-		handleUnknownGenericAttributeFound(name, warn)
-	}
-	return nil
-}
-
-var unknownGenericAttributesHandlingHistory sync.Map
-
-// checkGenericAttributeNameNotHandledAndPut checks if the GenericAttributeType name entry
-// already exists and puts it in the map if not.
-func checkGenericAttributeNameNotHandledAndPut(value GenericAttributeType) bool {
-	// If Key doesn't exist, put the value and return true because it is not already handled
-	_, exists := unknownGenericAttributesHandlingHistory.LoadOrStore(value, "")
-	// Key exists, then it is already handled so return false
-	return !exists
-}
-
-// The functions below are common helper functions which can be used for generic attributes support
-// across different OS.
-
-// genericAttributesToOSAttrs gets the os specific attribute from the generic attribute using reflection
-// nolint:unused
-func genericAttributesToOSAttrs(attrs map[GenericAttributeType]json.RawMessage, attributeType reflect.Type, attributeValuePtr *reflect.Value, keyPrefix string) (unknownAttribs []GenericAttributeType, err error) {
-	attributeValue := *attributeValuePtr
-
-	for key, rawMsg := range attrs {
-		found := false
-		for i := 0; i < attributeType.NumField(); i++ {
-			if getFQKeyByIndex(attributeType, i, keyPrefix) == key {
-				found = true
-				fieldValue := attributeValue.Field(i)
-				// For directly supported types, use json.Unmarshal directly
-				if err := json.Unmarshal(rawMsg, fieldValue.Addr().Interface()); err != nil {
-					return unknownAttribs, errors.Wrap(err, "Unmarshal")
-				}
-				break
-			}
-		}
-		if !found {
-			unknownAttribs = append(unknownAttribs, key)
-		}
-	}
-	return unknownAttribs, nil
-}
-
-// getFQKey gets the fully qualified key for the field
-// nolint:unused
-func getFQKey(field reflect.StructField, keyPrefix string) GenericAttributeType {
-	return GenericAttributeType(fmt.Sprintf("%s.%s", keyPrefix, field.Tag.Get("generic")))
-}
-
-// getFQKeyByIndex gets the fully qualified key for the field index
-// nolint:unused
-func getFQKeyByIndex(attributeType reflect.Type, index int, keyPrefix string) GenericAttributeType {
-	return getFQKey(attributeType.Field(index), keyPrefix)
-}
-
-// osAttrsToGenericAttributes gets the generic attribute from the os specific attribute using reflection
-// nolint:unused
-func osAttrsToGenericAttributes(attributeType reflect.Type, attributeValuePtr *reflect.Value, keyPrefix string) (attrs map[GenericAttributeType]json.RawMessage, err error) {
-	attributeValue := *attributeValuePtr
-	attrs = make(map[GenericAttributeType]json.RawMessage)
-
-	// Iterate over the fields of the struct
-	for i := 0; i < attributeType.NumField(); i++ {
-		field := attributeType.Field(i)
-
-		// Get the field value using reflection
-		fieldValue := attributeValue.FieldByName(field.Name)
-
-		// Check if the field is nil
-		if fieldValue.IsNil() {
-			// If it's nil, skip this field
-			continue
-		}
-
-		// Marshal the field value into a json.RawMessage
-		var fieldBytes []byte
-		if fieldBytes, err = json.Marshal(fieldValue.Interface()); err != nil {
-			return attrs, errors.Wrap(err, "Marshal")
-		}
-
-		// Insert the field into the map
-		attrs[getFQKey(field, keyPrefix)] = json.RawMessage(fieldBytes)
-	}
-	return attrs, nil
-}
diff --git a/internal/restic/node_aix.go b/internal/restic/node_aix.go
index def46bd60..572e33a65 100644
--- a/internal/restic/node_aix.go
+++ b/internal/restic/node_aix.go
@@ -3,12 +3,9 @@
 
 package restic
 
-import (
-	"os"
-	"syscall"
-)
+import "syscall"
 
-func (node Node) restoreSymlinkTimestamps(_ string, _ [2]syscall.Timespec) error {
+func (node Node) restoreSymlinkTimestamps(path string, utimes [2]syscall.Timespec) error {
 	return nil
 }
 
@@ -37,13 +34,3 @@ func Listxattr(path string) ([]string, error) {
 func Setxattr(path, name string, data []byte) error {
 	return nil
 }
-
-// restoreGenericAttributes is no-op on AIX.
-func (node *Node) restoreGenericAttributes(_ string, warn func(msg string)) error {
-	return node.handleAllUnknownGenericAttributesFound(warn)
-}
-
-// fillGenericAttributes is a no-op on AIX.
-func (node *Node) fillGenericAttributes(_ string, _ os.FileInfo, _ *statT) (allowExtended bool, err error) {
-	return true, nil
-}
diff --git a/internal/restic/node_netbsd.go b/internal/restic/node_netbsd.go
index 1a47299be..0eade2f37 100644
--- a/internal/restic/node_netbsd.go
+++ b/internal/restic/node_netbsd.go
@@ -1,11 +1,8 @@
 package restic
 
-import (
-	"os"
-	"syscall"
-)
+import "syscall"
 
-func (node Node) restoreSymlinkTimestamps(_ string, _ [2]syscall.Timespec) error {
+func (node Node) restoreSymlinkTimestamps(path string, utimes [2]syscall.Timespec) error {
 	return nil
 }
 
@@ -13,27 +10,18 @@ func (s statT) atim() syscall.Timespec { return s.Atimespec }
 func (s statT) mtim() syscall.Timespec { return s.Mtimespec }
 func (s statT) ctim() syscall.Timespec { return s.Ctimespec }
 
-// Getxattr is a no-op on netbsd.
+// Getxattr retrieves extended attribute data associated with path.
 func Getxattr(path, name string) ([]byte, error) {
 	return nil, nil
 }
 
-// Listxattr is a no-op on netbsd.
+// Listxattr retrieves a list of names of extended attributes associated with the
+// given path in the file system.
 func Listxattr(path string) ([]string, error) {
 	return nil, nil
 }
 
-// Setxattr is a no-op on netbsd.
+// Setxattr associates name and data together as an attribute of path.
 func Setxattr(path, name string, data []byte) error {
 	return nil
 }
-
-// restoreGenericAttributes is no-op on netbsd.
-func (node *Node) restoreGenericAttributes(_ string, warn func(msg string)) error {
-	return node.handleAllUnknownGenericAttributesFound(warn)
-}
-
-// fillGenericAttributes is a no-op on netbsd.
-func (node *Node) fillGenericAttributes(_ string, _ os.FileInfo, _ *statT) (allowExtended bool, err error) {
-	return true, nil
-}
diff --git a/internal/restic/node_openbsd.go b/internal/restic/node_openbsd.go
index e60eb9dc8..a4ccc7211 100644
--- a/internal/restic/node_openbsd.go
+++ b/internal/restic/node_openbsd.go
@@ -1,11 +1,8 @@
 package restic
 
-import (
-	"os"
-	"syscall"
-)
+import "syscall"
 
-func (node Node) restoreSymlinkTimestamps(_ string, _ [2]syscall.Timespec) error {
+func (node Node) restoreSymlinkTimestamps(path string, utimes [2]syscall.Timespec) error {
 	return nil
 }
 
@@ -13,27 +10,18 @@ func (s statT) atim() syscall.Timespec { return s.Atim }
 func (s statT) mtim() syscall.Timespec { return s.Mtim }
 func (s statT) ctim() syscall.Timespec { return s.Ctim }
 
-// Getxattr is a no-op on openbsd.
+// Getxattr retrieves extended attribute data associated with path.
 func Getxattr(path, name string) ([]byte, error) {
 	return nil, nil
 }
 
-// Listxattr is a no-op on openbsd.
+// Listxattr retrieves a list of names of extended attributes associated with the
+// given path in the file system.
 func Listxattr(path string) ([]string, error) {
 	return nil, nil
 }
 
-// Setxattr is a no-op on openbsd.
+// Setxattr associates name and data together as an attribute of path.
 func Setxattr(path, name string, data []byte) error {
 	return nil
 }
-
-// restoreGenericAttributes is no-op on openbsd.
-func (node *Node) restoreGenericAttributes(_ string, warn func(msg string)) error {
-	return node.handleAllUnknownGenericAttributesFound(warn)
-}
-
-// fillGenericAttributes is a no-op on openbsd.
-func (node *Node) fillGenericAttributes(_ string, _ os.FileInfo, _ *statT) (allowExtended bool, err error) {
-	return true, nil
-}
diff --git a/internal/restic/node_windows.go b/internal/restic/node_windows.go
index 5875c3ccd..fc6439b40 100644
--- a/internal/restic/node_windows.go
+++ b/internal/restic/node_windows.go
@@ -1,47 +1,21 @@
 package restic
 
 import (
-	"encoding/json"
-	"fmt"
-	"os"
-	"path/filepath"
-	"reflect"
-	"runtime"
-	"strings"
 	"syscall"
-	"unsafe"
 
-	"github.com/restic/restic/internal/debug"
 	"github.com/restic/restic/internal/errors"
-	"github.com/restic/restic/internal/fs"
-	"golang.org/x/sys/windows"
-)
-
-// WindowsAttributes are the genericAttributes for Windows OS
-type WindowsAttributes struct {
-	// CreationTime is used for storing creation time for windows files.
-	CreationTime *syscall.Filetime `generic:"creation_time"`
-	// FileAttributes is used for storing file attributes for windows files.
-	FileAttributes *uint32 `generic:"file_attributes"`
-}
-
-var (
-	modAdvapi32     = syscall.NewLazyDLL("advapi32.dll")
-	procEncryptFile = modAdvapi32.NewProc("EncryptFileW")
-	procDecryptFile = modAdvapi32.NewProc("DecryptFileW")
 )
 
 // mknod is not supported on Windows.
-func mknod(_ string, mode uint32, dev uint64) (err error) {
+func mknod(path string, mode uint32, dev uint64) (err error) {
 	return errors.New("device nodes cannot be created on windows")
 }
 
 // Windows doesn't need lchown
-func lchown(_ string, uid int, gid int) (err error) {
+func lchown(path string, uid int, gid int) (err error) {
 	return nil
 }
 
-// restoreSymlinkTimestamps restores timestamps for symlinks
 func (node Node) restoreSymlinkTimestamps(path string, utimes [2]syscall.Timespec) error {
 	// tweaked version of UtimesNano from go/src/syscall/syscall_windows.go
 	pathp, e := syscall.UTF16PtrFromString(path)
@@ -54,14 +28,7 @@ func (node Node) restoreSymlinkTimestamps(path string, utimes [2]syscall.Timespe
 	if e != nil {
 		return e
 	}
-
-	defer func() {
-		err := syscall.Close(h)
-		if err != nil {
-			debug.Log("Error closing file handle for %s: %v\n", path, err)
-		}
-	}()
-
+	defer syscall.Close(h)
 	a := syscall.NsecToFiletime(syscall.TimespecToNsec(utimes[0]))
 	w := syscall.NsecToFiletime(syscall.TimespecToNsec(utimes[1]))
 	return syscall.SetFileTime(h, nil, &a, &w)
@@ -116,188 +83,3 @@ func (s statT) ctim() syscall.Timespec {
 	// Windows does not have the concept of a "change time" in the sense Unix uses it, so we're using the LastWriteTime here.
 	return syscall.NsecToTimespec(s.LastWriteTime.Nanoseconds())
 }
-
-// restoreGenericAttributes restores generic attributes for Windows
-func (node Node) restoreGenericAttributes(path string, warn func(msg string)) (err error) {
-	if len(node.GenericAttributes) == 0 {
-		return nil
-	}
-	var errs []error
-	windowsAttributes, unknownAttribs, err := genericAttributesToWindowsAttrs(node.GenericAttributes)
-	if err != nil {
-		return fmt.Errorf("error parsing generic attribute for: %s : %v", path, err)
-	}
-	if windowsAttributes.CreationTime != nil {
-		if err := restoreCreationTime(path, windowsAttributes.CreationTime); err != nil {
-			errs = append(errs, fmt.Errorf("error restoring creation time for: %s : %v", path, err))
-		}
-	}
-	if windowsAttributes.FileAttributes != nil {
-		if err := restoreFileAttributes(path, windowsAttributes.FileAttributes); err != nil {
-			errs = append(errs, fmt.Errorf("error restoring file attributes for: %s : %v", path, err))
-		}
-	}
-
-	HandleUnknownGenericAttributesFound(unknownAttribs, warn)
-	return errors.CombineErrors(errs...)
-}
-
-// genericAttributesToWindowsAttrs converts the generic attributes map to a WindowsAttributes and also returns a string of unkown attributes that it could not convert.
-func genericAttributesToWindowsAttrs(attrs map[GenericAttributeType]json.RawMessage) (windowsAttributes WindowsAttributes, unknownAttribs []GenericAttributeType, err error) {
-	waValue := reflect.ValueOf(&windowsAttributes).Elem()
-	unknownAttribs, err = genericAttributesToOSAttrs(attrs, reflect.TypeOf(windowsAttributes), &waValue, "windows")
-	return windowsAttributes, unknownAttribs, err
-}
-
-// restoreCreationTime gets the creation time from the data and sets it to the file/folder at
-// the specified path.
-func restoreCreationTime(path string, creationTime *syscall.Filetime) (err error) {
-	pathPointer, err := syscall.UTF16PtrFromString(path)
-	if err != nil {
-		return err
-	}
-	handle, err := syscall.CreateFile(pathPointer,
-		syscall.FILE_WRITE_ATTRIBUTES, syscall.FILE_SHARE_WRITE, nil,
-		syscall.OPEN_EXISTING, syscall.FILE_FLAG_BACKUP_SEMANTICS, 0)
-	if err != nil {
-		return err
-	}
-	defer func() {
-		if err := syscall.Close(handle); err != nil {
-			debug.Log("Error closing file handle for %s: %v\n", path, err)
-		}
-	}()
-	return syscall.SetFileTime(handle, creationTime, nil, nil)
-}
-
-// restoreFileAttributes gets the File Attributes from the data and sets them to the file/folder
-// at the specified path.
-func restoreFileAttributes(path string, fileAttributes *uint32) (err error) {
-	pathPointer, err := syscall.UTF16PtrFromString(path)
-	if err != nil {
-		return err
-	}
-	err = fixEncryptionAttribute(path, fileAttributes, pathPointer)
-	if err != nil {
-		debug.Log("Could not change encryption attribute for path: %s: %v", path, err)
-	}
-	return syscall.SetFileAttributes(pathPointer, *fileAttributes)
-}
-
-// fixEncryptionAttribute checks if a file needs to be marked encrypted and is not already encrypted, it sets
-// the FILE_ATTRIBUTE_ENCRYPTED. Conversely, if the file needs to be marked unencrypted and it is already
-// marked encrypted, it removes the FILE_ATTRIBUTE_ENCRYPTED.
-func fixEncryptionAttribute(path string, attrs *uint32, pathPointer *uint16) (err error) {
-	if *attrs&windows.FILE_ATTRIBUTE_ENCRYPTED != 0 {
-		// File should be encrypted.
-		err = encryptFile(pathPointer)
-		if err != nil {
-			if fs.IsAccessDenied(err) {
-				// If existing file already has readonly or system flag, encrypt file call fails.
-				// We have already cleared readonly flag, clearing system flag if needed.
-				// The readonly and system flags will be set again at the end of this func if they are needed.
-				err = fs.ClearSystem(path)
-				if err != nil {
-					return fmt.Errorf("failed to encrypt file: failed to clear system flag: %s : %v", path, err)
-				}
-				err = encryptFile(pathPointer)
-				if err != nil {
-					return fmt.Errorf("failed to encrypt file: %s : %v", path, err)
-				}
-			} else {
-				return fmt.Errorf("failed to encrypt file: %s : %v", path, err)
-			}
-		}
-	} else {
-		existingAttrs, err := windows.GetFileAttributes(pathPointer)
-		if err != nil {
-			return fmt.Errorf("failed to get file attributes for existing file: %s : %v", path, err)
-		}
-		if existingAttrs&windows.FILE_ATTRIBUTE_ENCRYPTED != 0 {
-			// File should not be encrypted, but its already encrypted. Decrypt it.
-			err = decryptFile(pathPointer)
-			if err != nil {
-				if fs.IsAccessDenied(err) {
-					// If existing file already has readonly or system flag, decrypt file call fails.
-					// We have already cleared readonly flag, clearing system flag if needed.
-					// The readonly and system flags will be set again after this func if they are needed.
-					err = fs.ClearSystem(path)
-					if err != nil {
-						return fmt.Errorf("failed to decrypt file: failed to clear system flag: %s : %v", path, err)
-					}
-					err = decryptFile(pathPointer)
-					if err != nil {
-						return fmt.Errorf("failed to decrypt file: %s : %v", path, err)
-					}
-				} else {
-					return fmt.Errorf("failed to decrypt file: %s : %v", path, err)
-				}
-			}
-		}
-	}
-	return err
-}
-
-// encryptFile set the encrypted flag on the file.
-func encryptFile(pathPointer *uint16) error {
-	// Call EncryptFile function
-	ret, _, err := procEncryptFile.Call(uintptr(unsafe.Pointer(pathPointer)))
-	if ret == 0 {
-		return err
-	}
-	return nil
-}
-
-// decryptFile removes the encrypted flag from the file.
-func decryptFile(pathPointer *uint16) error {
-	// Call DecryptFile function
-	ret, _, err := procDecryptFile.Call(uintptr(unsafe.Pointer(pathPointer)))
-	if ret == 0 {
-		return err
-	}
-	return nil
-}
-
-// fillGenericAttributes fills in the generic attributes for windows like File Attributes,
-// Created time etc.
-func (node *Node) fillGenericAttributes(path string, fi os.FileInfo, stat *statT) (allowExtended bool, err 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
-	}
-	if !strings.HasSuffix(filepath.Clean(path), `\`) {
-		// Do not process file attributes and created time for windows directories like
-		// C:, D:
-		// Filepath.Clean(path) ends with '\' for Windows root drives only.
-
-		// Add Windows attributes
-		node.GenericAttributes, err = WindowsAttrsToGenericAttributes(WindowsAttributes{
-			CreationTime:   getCreationTime(fi, path),
-			FileAttributes: &stat.FileAttributes,
-		})
-	}
-	return true, err
-}
-
-// windowsAttrsToGenericAttributes converts the WindowsAttributes to a generic attributes map using reflection
-func WindowsAttrsToGenericAttributes(windowsAttributes WindowsAttributes) (attrs map[GenericAttributeType]json.RawMessage, err error) {
-	// Get the value of the WindowsAttributes
-	windowsAttributesValue := reflect.ValueOf(windowsAttributes)
-	return osAttrsToGenericAttributes(reflect.TypeOf(windowsAttributes), &windowsAttributesValue, runtime.GOOS)
-}
-
-// getCreationTime gets the value for the WindowsAttribute CreationTime in a windows specific time format.
-// The value is a 64-bit value representing the number of 100-nanosecond intervals since January 1, 1601 (UTC)
-// split into two 32-bit parts: the low-order DWORD and the high-order DWORD for efficiency and interoperability.
-// The low-order DWORD represents the number of 100-nanosecond intervals elapsed since January 1, 1601, modulo
-// 2^32. The high-order DWORD represents the number of times the low-order DWORD has overflowed.
-func getCreationTime(fi os.FileInfo, path string) (creationTimeAttribute *syscall.Filetime) {
-	attrib, success := fi.Sys().(*syscall.Win32FileAttributeData)
-	if success && attrib != nil {
-		return &attrib.CreationTime
-	} else {
-		debug.Log("Could not get create time for path: %s", path)
-		return nil
-	}
-}
diff --git a/internal/restic/node_xattr.go b/internal/restic/node_xattr.go
index 0b2d5d552..ea9eafe94 100644
--- a/internal/restic/node_xattr.go
+++ b/internal/restic/node_xattr.go
@@ -4,7 +4,6 @@
 package restic
 
 import (
-	"os"
 	"syscall"
 
 	"github.com/restic/restic/internal/errors"
@@ -48,13 +47,3 @@ func handleXattrErr(err error) error {
 		return errors.WithStack(e)
 	}
 }
-
-// restoreGenericAttributes is no-op.
-func (node *Node) restoreGenericAttributes(_ string, warn func(msg string)) error {
-	return node.handleAllUnknownGenericAttributesFound(warn)
-}
-
-// fillGenericAttributes is a no-op.
-func (node *Node) fillGenericAttributes(_ string, _ os.FileInfo, _ *statT) (allowExtended bool, err error) {
-	return true, nil
-}
diff --git a/internal/restic/snapshot.go b/internal/restic/snapshot.go
index 8cf651d96..39ed80627 100644
--- a/internal/restic/snapshot.go
+++ b/internal/restic/snapshot.go
@@ -25,11 +25,31 @@ type Snapshot struct {
 	Tags     []string  `json:"tags,omitempty"`
 	Original *ID       `json:"original,omitempty"`
 
-	ProgramVersion string `json:"program_version,omitempty"`
+	ProgramVersion string           `json:"program_version,omitempty"`
+	Summary        *SnapshotSummary `json:"summary,omitempty"`
 
 	id *ID // plaintext ID, used during restore
 }
 
+type SnapshotSummary struct {
+	BackupStart time.Time `json:"backup_start"`
+	BackupEnd   time.Time `json:"backup_end"`
+
+	// statistics from the backup json output
+	FilesNew            uint   `json:"files_new"`
+	FilesChanged        uint   `json:"files_changed"`
+	FilesUnmodified     uint   `json:"files_unmodified"`
+	DirsNew             uint   `json:"dirs_new"`
+	DirsChanged         uint   `json:"dirs_changed"`
+	DirsUnmodified      uint   `json:"dirs_unmodified"`
+	DataBlobs           int    `json:"data_blobs"`
+	TreeBlobs           int    `json:"tree_blobs"`
+	DataAdded           uint64 `json:"data_added"`
+	DataAddedPacked     uint64 `json:"data_added_packed"`
+	TotalFilesProcessed uint   `json:"total_files_processed"`
+	TotalBytesProcessed uint64 `json:"total_bytes_processed"`
+}
+
 // NewSnapshot returns an initialized snapshot struct for the current user and
 // time.
 func NewSnapshot(paths []string, tags []string, hostname string, time time.Time) (*Snapshot, error) {
diff --git a/internal/restorer/fileswriter.go b/internal/restorer/fileswriter.go
index cbe89c30c..589aa502a 100644
--- a/internal/restorer/fileswriter.go
+++ b/internal/restorer/fileswriter.go
@@ -50,26 +50,16 @@ func (w *filesWriter) writeToFile(path string, blob []byte, offset int64, create
 			bucket.files[path].users++
 			return wr, nil
 		}
-		var f *os.File
-		var err error
+
+		var flags int
 		if createSize >= 0 {
-			if f, err = os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0600); err != nil {
-				if fs.IsAccessDenied(err) {
-					// If file is readonly, clear the readonly flag by resetting the
-					// permissions of the file and try again
-					// as the metadata will be set again in the second pass and the
-					// readonly flag will be applied again if needed.
-					if err = fs.ResetPermissions(path); err != nil {
-						return nil, err
-					}
-					if f, err = os.OpenFile(path, os.O_TRUNC|os.O_WRONLY, 0600); err != nil {
-						return nil, err
-					}
-				} else {
-					return nil, err
-				}
-			}
-		} else if f, err = os.OpenFile(path, os.O_WRONLY, 0600); err != nil {
+			flags = os.O_CREATE | os.O_TRUNC | os.O_WRONLY
+		} else {
+			flags = os.O_WRONLY
+		}
+
+		f, err := os.OpenFile(path, flags, 0600)
+		if err != nil {
 			return nil, err
 		}
 
diff --git a/internal/restorer/restorer.go b/internal/restorer/restorer.go
index 9f41f5cf2..3f4fb32e3 100644
--- a/internal/restorer/restorer.go
+++ b/internal/restorer/restorer.go
@@ -24,7 +24,6 @@ type Restorer struct {
 	progress *restoreui.Progress
 
 	Error        func(location string, err error) error
-	Warn         func(message string)
 	SelectFilter func(item string, dstpath string, node *restic.Node) (selectedForRestore bool, childMayBeSelected bool)
 }
 
@@ -179,7 +178,7 @@ func (res *Restorer) restoreNodeTo(ctx context.Context, node *restic.Node, targe
 
 func (res *Restorer) restoreNodeMetadataTo(node *restic.Node, target, location string) error {
 	debug.Log("restoreNodeMetadata %v %v %v", node.Name, target, location)
-	err := node.RestoreMetadata(target, res.Warn)
+	err := node.RestoreMetadata(target)
 	if err != nil {
 		debug.Log("node.RestoreMetadata(%s) error %v", target, err)
 	}
@@ -205,19 +204,11 @@ func (res *Restorer) restoreHardlinkAt(node *restic.Node, target, path, location
 
 func (res *Restorer) restoreEmptyFileAt(node *restic.Node, target, location string) error {
 	wr, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0600)
-	if fs.IsAccessDenied(err) {
-		// If file is readonly, clear the readonly flag by resetting the
-		// permissions of the file and try again
-		// as the metadata will be set again in the second pass and the
-		// readonly flag will be applied again if needed.
-		if err = fs.ResetPermissions(target); err != nil {
-			return err
-		}
-		if wr, err = os.OpenFile(target, os.O_TRUNC|os.O_WRONLY, 0600); err != nil {
-			return err
-		}
+	if err != nil {
+		return err
 	}
-	if err = wr.Close(); err != nil {
+	err = wr.Close()
+	if err != nil {
 		return err
 	}
 
diff --git a/internal/ui/backup/json.go b/internal/ui/backup/json.go
index 10f0e91fa..a14c7ccec 100644
--- a/internal/ui/backup/json.go
+++ b/internal/ui/backup/json.go
@@ -163,7 +163,7 @@ func (b *JSONProgress) ReportTotal(start time.Time, s archiver.ScanStats) {
 }
 
 // Finish prints the finishing messages.
-func (b *JSONProgress) Finish(snapshotID restic.ID, start time.Time, summary *Summary, dryRun bool) {
+func (b *JSONProgress) Finish(snapshotID restic.ID, start time.Time, summary *archiver.Summary, dryRun bool) {
 	b.print(summaryOutput{
 		MessageType:         "summary",
 		FilesNew:            summary.Files.New,
@@ -175,6 +175,7 @@ func (b *JSONProgress) Finish(snapshotID restic.ID, start time.Time, summary *Su
 		DataBlobs:           summary.ItemStats.DataBlobs,
 		TreeBlobs:           summary.ItemStats.TreeBlobs,
 		DataAdded:           summary.ItemStats.DataSize + summary.ItemStats.TreeSize,
+		DataAddedPacked:     summary.ItemStats.DataSizeInRepo + summary.ItemStats.TreeSizeInRepo,
 		TotalFilesProcessed: summary.Files.New + summary.Files.Changed + summary.Files.Unchanged,
 		TotalBytesProcessed: summary.ProcessedBytes,
 		TotalDuration:       time.Since(start).Seconds(),
@@ -230,6 +231,7 @@ type summaryOutput struct {
 	DataBlobs           int     `json:"data_blobs"`
 	TreeBlobs           int     `json:"tree_blobs"`
 	DataAdded           uint64  `json:"data_added"`
+	DataAddedPacked     uint64  `json:"data_added_packed"`
 	TotalFilesProcessed uint    `json:"total_files_processed"`
 	TotalBytesProcessed uint64  `json:"total_bytes_processed"`
 	TotalDuration       float64 `json:"total_duration"` // in seconds
diff --git a/internal/ui/backup/progress.go b/internal/ui/backup/progress.go
index da0d401a3..1d494bf14 100644
--- a/internal/ui/backup/progress.go
+++ b/internal/ui/backup/progress.go
@@ -17,7 +17,7 @@ type ProgressPrinter interface {
 	ScannerError(item string, err error) error
 	CompleteItem(messageType string, item string, s archiver.ItemStats, d time.Duration)
 	ReportTotal(start time.Time, s archiver.ScanStats)
-	Finish(snapshotID restic.ID, start time.Time, summary *Summary, dryRun bool)
+	Finish(snapshotID restic.ID, start time.Time, summary *archiver.Summary, dryRun bool)
 	Reset()
 
 	P(msg string, args ...interface{})
@@ -28,16 +28,6 @@ type Counter struct {
 	Files, Dirs, Bytes uint64
 }
 
-type Summary struct {
-	Files, Dirs struct {
-		New       uint
-		Changed   uint
-		Unchanged uint
-	}
-	ProcessedBytes uint64
-	archiver.ItemStats
-}
-
 // Progress reports progress for the `backup` command.
 type Progress struct {
 	progress.Updater
@@ -52,7 +42,6 @@ type Progress struct {
 	processed, total Counter
 	errors           uint
 
-	summary Summary
 	printer ProgressPrinter
 }
 
@@ -126,16 +115,6 @@ func (p *Progress) CompleteBlob(bytes uint64) {
 // CompleteItem is the status callback function for the archiver when a
 // file/dir has been saved successfully.
 func (p *Progress) CompleteItem(item string, previous, current *restic.Node, s archiver.ItemStats, d time.Duration) {
-	p.mu.Lock()
-	p.summary.ItemStats.Add(s)
-
-	// for the last item "/", current is nil
-	if current != nil {
-		p.summary.ProcessedBytes += current.Size
-	}
-
-	p.mu.Unlock()
-
 	if current == nil {
 		// error occurred, tell the status display to remove the line
 		p.mu.Lock()
@@ -153,21 +132,10 @@ func (p *Progress) CompleteItem(item string, previous, current *restic.Node, s a
 		switch {
 		case previous == nil:
 			p.printer.CompleteItem("dir new", item, s, d)
-			p.mu.Lock()
-			p.summary.Dirs.New++
-			p.mu.Unlock()
-
 		case previous.Equals(*current):
 			p.printer.CompleteItem("dir unchanged", item, s, d)
-			p.mu.Lock()
-			p.summary.Dirs.Unchanged++
-			p.mu.Unlock()
-
 		default:
 			p.printer.CompleteItem("dir modified", item, s, d)
-			p.mu.Lock()
-			p.summary.Dirs.Changed++
-			p.mu.Unlock()
 		}
 
 	case "file":
@@ -179,21 +147,10 @@ func (p *Progress) CompleteItem(item string, previous, current *restic.Node, s a
 		switch {
 		case previous == nil:
 			p.printer.CompleteItem("file new", item, s, d)
-			p.mu.Lock()
-			p.summary.Files.New++
-			p.mu.Unlock()
-
 		case previous.Equals(*current):
 			p.printer.CompleteItem("file unchanged", item, s, d)
-			p.mu.Lock()
-			p.summary.Files.Unchanged++
-			p.mu.Unlock()
-
 		default:
 			p.printer.CompleteItem("file modified", item, s, d)
-			p.mu.Lock()
-			p.summary.Files.Changed++
-			p.mu.Unlock()
 		}
 	}
 }
@@ -213,8 +170,8 @@ func (p *Progress) ReportTotal(item string, s archiver.ScanStats) {
 }
 
 // Finish prints the finishing messages.
-func (p *Progress) Finish(snapshotID restic.ID, dryrun bool) {
+func (p *Progress) Finish(snapshotID restic.ID, summary *archiver.Summary, dryrun bool) {
 	// wait for the status update goroutine to shut down
 	p.Updater.Done()
-	p.printer.Finish(snapshotID, p.start, &p.summary, dryrun)
+	p.printer.Finish(snapshotID, p.start, summary, dryrun)
 }
diff --git a/internal/ui/backup/text.go b/internal/ui/backup/text.go
index 215982cd4..00d025e51 100644
--- a/internal/ui/backup/text.go
+++ b/internal/ui/backup/text.go
@@ -126,7 +126,7 @@ func (b *TextProgress) Reset() {
 }
 
 // Finish prints the finishing messages.
-func (b *TextProgress) Finish(_ restic.ID, start time.Time, summary *Summary, dryRun bool) {
+func (b *TextProgress) Finish(_ restic.ID, start time.Time, summary *archiver.Summary, dryRun bool) {
 	b.P("\n")
 	b.P("Files:       %5d new, %5d changed, %5d unmodified\n", summary.Files.New, summary.Files.Changed, summary.Files.Unchanged)
 	b.P("Dirs:        %5d new, %5d changed, %5d unmodified\n", summary.Dirs.New, summary.Dirs.Changed, summary.Dirs.Unchanged)


Test patch:
diff --git a/internal/archiver/archiver_test.go b/internal/archiver/archiver_test.go
index 46ef44251..5391a1036 100644
--- a/internal/archiver/archiver_test.go
+++ b/internal/archiver/archiver_test.go
@@ -226,8 +226,9 @@ func TestArchiverSave(t *testing.T) {
 				return err
 			}
 			arch.runWorkers(ctx, wg)
+			arch.summary = &Summary{}
 
-			node, excluded, err := arch.Save(ctx, "/", filepath.Join(tempdir, "file"), nil)
+			node, excluded, err := arch.save(ctx, "/", filepath.Join(tempdir, "file"), nil)
 			if err != nil {
 				t.Fatal(err)
 			}
@@ -303,8 +304,9 @@ func TestArchiverSaveReaderFS(t *testing.T) {
 				return err
 			}
 			arch.runWorkers(ctx, wg)
+			arch.summary = &Summary{}
 
-			node, excluded, err := arch.Save(ctx, "/", filename, nil)
+			node, excluded, err := arch.save(ctx, "/", filename, nil)
 			t.Logf("Save returned %v %v", node, err)
 			if err != nil {
 				t.Fatal(err)
@@ -831,6 +833,7 @@ func TestArchiverSaveDir(t *testing.T) {
 
 			arch := New(repo, fs.Track{FS: fs.Local{}}, Options{})
 			arch.runWorkers(ctx, wg)
+			arch.summary = &Summary{}
 
 			chdir := tempdir
 			if test.chdir != "" {
@@ -845,7 +848,7 @@ func TestArchiverSaveDir(t *testing.T) {
 				t.Fatal(err)
 			}
 
-			ft, err := arch.SaveDir(ctx, "/", test.target, fi, nil, nil)
+			ft, err := arch.saveDir(ctx, "/", test.target, fi, nil, nil)
 			if err != nil {
 				t.Fatal(err)
 			}
@@ -912,13 +915,14 @@ func TestArchiverSaveDirIncremental(t *testing.T) {
 
 		arch := New(repo, fs.Track{FS: fs.Local{}}, Options{})
 		arch.runWorkers(ctx, wg)
+		arch.summary = &Summary{}
 
 		fi, err := fs.Lstat(tempdir)
 		if err != nil {
 			t.Fatal(err)
 		}
 
-		ft, err := arch.SaveDir(ctx, "/", tempdir, fi, nil, nil)
+		ft, err := arch.saveDir(ctx, "/", tempdir, fi, nil, nil)
 		if err != nil {
 			t.Fatal(err)
 		}
@@ -982,9 +986,9 @@ func TestArchiverSaveDirIncremental(t *testing.T) {
 
 // bothZeroOrNeither fails the test if only one of exp, act is zero.
 func bothZeroOrNeither(tb testing.TB, exp, act uint64) {
+	tb.Helper()
 	if (exp == 0 && act != 0) || (exp != 0 && act == 0) {
-		_, file, line, _ := runtime.Caller(1)
-		tb.Fatalf("\033[31m%s:%d:\n\n\texp: %#v\n\n\tgot: %#v\033[39m\n\n", filepath.Base(file), line, exp, act)
+		restictest.Equals(tb, exp, act)
 	}
 }
 
@@ -1004,7 +1008,7 @@ func TestArchiverSaveTree(t *testing.T) {
 		prepare func(t testing.TB)
 		targets []string
 		want    TestDir
-		stat    ItemStats
+		stat    Summary
 	}{
 		{
 			src: TestDir{
@@ -1014,7 +1018,12 @@ func TestArchiverSaveTree(t *testing.T) {
 			want: TestDir{
 				"targetfile": TestFile{Content: string("foobar")},
 			},
-			stat: ItemStats{1, 6, 32 + 6, 0, 0, 0},
+			stat: Summary{
+				ItemStats:      ItemStats{1, 6, 32 + 6, 0, 0, 0},
+				ProcessedBytes: 6,
+				Files:          ChangeStats{1, 0, 0},
+				Dirs:           ChangeStats{0, 0, 0},
+			},
 		},
 		{
 			src: TestDir{
@@ -1026,7 +1035,12 @@ func TestArchiverSaveTree(t *testing.T) {
 				"targetfile":  TestFile{Content: string("foobar")},
 				"filesymlink": TestSymlink{Target: "targetfile"},
 			},
-			stat: ItemStats{1, 6, 32 + 6, 0, 0, 0},
+			stat: Summary{
+				ItemStats:      ItemStats{1, 6, 32 + 6, 0, 0, 0},
+				ProcessedBytes: 6,
+				Files:          ChangeStats{1, 0, 0},
+				Dirs:           ChangeStats{0, 0, 0},
+			},
 		},
 		{
 			src: TestDir{
@@ -1046,7 +1060,12 @@ func TestArchiverSaveTree(t *testing.T) {
 					"symlink": TestSymlink{Target: "subdir"},
 				},
 			},
-			stat: ItemStats{0, 0, 0, 1, 0x154, 0x16a},
+			stat: Summary{
+				ItemStats:      ItemStats{0, 0, 0, 1, 0x154, 0x16a},
+				ProcessedBytes: 0,
+				Files:          ChangeStats{0, 0, 0},
+				Dirs:           ChangeStats{1, 0, 0},
+			},
 		},
 		{
 			src: TestDir{
@@ -1070,7 +1089,12 @@ func TestArchiverSaveTree(t *testing.T) {
 					},
 				},
 			},
-			stat: ItemStats{1, 6, 32 + 6, 3, 0x47f, 0x4c1},
+			stat: Summary{
+				ItemStats:      ItemStats{1, 6, 32 + 6, 3, 0x47f, 0x4c1},
+				ProcessedBytes: 6,
+				Files:          ChangeStats{1, 0, 0},
+				Dirs:           ChangeStats{3, 0, 0},
+			},
 		},
 	}
 
@@ -1082,18 +1106,11 @@ func TestArchiverSaveTree(t *testing.T) {
 
 			arch := New(repo, testFS, Options{})
 
-			var stat ItemStats
-			lock := &sync.Mutex{}
-			arch.CompleteItem = func(item string, previous, current *restic.Node, s ItemStats, d time.Duration) {
-				lock.Lock()
-				defer lock.Unlock()
-				stat.Add(s)
-			}
-
 			wg, ctx := errgroup.WithContext(context.TODO())
 			repo.StartPackUploader(ctx, wg)
 
 			arch.runWorkers(ctx, wg)
+			arch.summary = &Summary{}
 
 			back := restictest.Chdir(t, tempdir)
 			defer back()
@@ -1107,7 +1124,7 @@ func TestArchiverSaveTree(t *testing.T) {
 				t.Fatal(err)
 			}
 
-			fn, _, err := arch.SaveTree(ctx, "/", atree, nil, nil)
+			fn, _, err := arch.saveTree(ctx, "/", atree, nil, nil)
 			if err != nil {
 				t.Fatal(err)
 			}
@@ -1134,11 +1151,15 @@ func TestArchiverSaveTree(t *testing.T) {
 				want = test.src
 			}
 			TestEnsureTree(context.TODO(), t, "/", repo, treeID, want)
+			stat := arch.summary
 			bothZeroOrNeither(t, uint64(test.stat.DataBlobs), uint64(stat.DataBlobs))
 			bothZeroOrNeither(t, uint64(test.stat.TreeBlobs), uint64(stat.TreeBlobs))
 			bothZeroOrNeither(t, test.stat.DataSize, stat.DataSize)
 			bothZeroOrNeither(t, test.stat.DataSizeInRepo, stat.DataSizeInRepo)
 			bothZeroOrNeither(t, test.stat.TreeSizeInRepo, stat.TreeSizeInRepo)
+			restictest.Equals(t, test.stat.ProcessedBytes, stat.ProcessedBytes)
+			restictest.Equals(t, test.stat.Files, stat.Files)
+			restictest.Equals(t, test.stat.Dirs, stat.Dirs)
 		})
 	}
 }
@@ -1395,7 +1416,7 @@ func TestArchiverSnapshot(t *testing.T) {
 			}
 
 			t.Logf("targets: %v", targets)
-			sn, snapshotID, err := arch.Snapshot(ctx, targets, SnapshotOptions{Time: time.Now()})
+			sn, snapshotID, _, err := arch.Snapshot(ctx, targets, SnapshotOptions{Time: time.Now()})
 			if err != nil {
 				t.Fatal(err)
 			}
@@ -1543,7 +1564,7 @@ func TestArchiverSnapshotSelect(t *testing.T) {
 			defer back()
 
 			targets := []string{"."}
-			_, snapshotID, err := arch.Snapshot(ctx, targets, SnapshotOptions{Time: time.Now()})
+			_, snapshotID, _, err := arch.Snapshot(ctx, targets, SnapshotOptions{Time: time.Now()})
 			if test.err != "" {
 				if err == nil {
 					t.Fatalf("expected error not found, got %v, wanted %q", err, test.err)
@@ -1616,17 +1637,85 @@ func (f MockFile) Read(p []byte) (int, error) {
 	return n, err
 }
 
+func checkSnapshotStats(t *testing.T, sn *restic.Snapshot, stat Summary) {
+	restictest.Equals(t, stat.Files.New, sn.Summary.FilesNew)
+	restictest.Equals(t, stat.Files.Changed, sn.Summary.FilesChanged)
+	restictest.Equals(t, stat.Files.Unchanged, sn.Summary.FilesUnmodified)
+	restictest.Equals(t, stat.Dirs.New, sn.Summary.DirsNew)
+	restictest.Equals(t, stat.Dirs.Changed, sn.Summary.DirsChanged)
+	restictest.Equals(t, stat.Dirs.Unchanged, sn.Summary.DirsUnmodified)
+	restictest.Equals(t, stat.ProcessedBytes, sn.Summary.TotalBytesProcessed)
+	restictest.Equals(t, stat.Files.New+stat.Files.Changed+stat.Files.Unchanged, sn.Summary.TotalFilesProcessed)
+	bothZeroOrNeither(t, uint64(stat.DataBlobs), uint64(sn.Summary.DataBlobs))
+	bothZeroOrNeither(t, uint64(stat.TreeBlobs), uint64(sn.Summary.TreeBlobs))
+	bothZeroOrNeither(t, uint64(stat.DataSize+stat.TreeSize), uint64(sn.Summary.DataAdded))
+	bothZeroOrNeither(t, uint64(stat.DataSizeInRepo+stat.TreeSizeInRepo), uint64(sn.Summary.DataAddedPacked))
+}
+
 func TestArchiverParent(t *testing.T) {
 	var tests = []struct {
-		src  TestDir
-		read map[string]int // tracks number of times a file must have been read
+		src         TestDir
+		modify      func(path string)
+		statInitial Summary
+		statSecond  Summary
 	}{
 		{
 			src: TestDir{
 				"targetfile": TestFile{Content: string(restictest.Random(888, 2*1024*1024+5000))},
 			},
-			read: map[string]int{
-				"targetfile": 1,
+			statInitial: Summary{
+				Files:          ChangeStats{1, 0, 0},
+				Dirs:           ChangeStats{0, 0, 0},
+				ProcessedBytes: 2102152,
+				ItemStats:      ItemStats{3, 0x201593, 0x201632, 1, 0, 0},
+			},
+			statSecond: Summary{
+				Files:          ChangeStats{0, 0, 1},
+				Dirs:           ChangeStats{0, 0, 0},
+				ProcessedBytes: 2102152,
+			},
+		},
+		{
+			src: TestDir{
+				"targetDir": TestDir{
+					"targetfile":  TestFile{Content: string(restictest.Random(888, 1234))},
+					"targetfile2": TestFile{Content: string(restictest.Random(888, 1235))},
+				},
+			},
+			statInitial: Summary{
+				Files:          ChangeStats{2, 0, 0},
+				Dirs:           ChangeStats{1, 0, 0},
+				ProcessedBytes: 2469,
+				ItemStats:      ItemStats{2, 0xe1c, 0xcd9, 2, 0, 0},
+			},
+			statSecond: Summary{
+				Files:          ChangeStats{0, 0, 2},
+				Dirs:           ChangeStats{0, 0, 1},
+				ProcessedBytes: 2469,
+			},
+		},
+		{
+			src: TestDir{
+				"targetDir": TestDir{
+					"targetfile": TestFile{Content: string(restictest.Random(888, 1234))},
+				},
+				"targetfile2": TestFile{Content: string(restictest.Random(888, 1235))},
+			},
+			modify: func(path string) {
+				remove(t, filepath.Join(path, "targetDir", "targetfile"))
+				save(t, filepath.Join(path, "targetfile2"), []byte("foobar"))
+			},
+			statInitial: Summary{
+				Files:          ChangeStats{2, 0, 0},
+				Dirs:           ChangeStats{1, 0, 0},
+				ProcessedBytes: 2469,
+				ItemStats:      ItemStats{2, 0xe13, 0xcf8, 2, 0, 0},
+			},
+			statSecond: Summary{
+				Files:          ChangeStats{0, 1, 0},
+				Dirs:           ChangeStats{0, 1, 0},
+				ProcessedBytes: 6,
+				ItemStats:      ItemStats{1, 0x305, 0x233, 2, 0, 0},
 			},
 		},
 	}
@@ -1648,7 +1737,7 @@ func TestArchiverParent(t *testing.T) {
 			back := restictest.Chdir(t, tempdir)
 			defer back()
 
-			firstSnapshot, firstSnapshotID, err := arch.Snapshot(ctx, []string{"."}, SnapshotOptions{Time: time.Now()})
+			firstSnapshot, firstSnapshotID, summary, err := arch.Snapshot(ctx, []string{"."}, SnapshotOptions{Time: time.Now()})
 			if err != nil {
 				t.Fatal(err)
 			}
@@ -1673,33 +1762,33 @@ func TestArchiverParent(t *testing.T) {
 				}
 				return nil
 			})
+			restictest.Equals(t, test.statInitial.Files, summary.Files)
+			restictest.Equals(t, test.statInitial.Dirs, summary.Dirs)
+			restictest.Equals(t, test.statInitial.ProcessedBytes, summary.ProcessedBytes)
+			checkSnapshotStats(t, firstSnapshot, test.statInitial)
+
+			if test.modify != nil {
+				test.modify(tempdir)
+			}
 
 			opts := SnapshotOptions{
 				Time:           time.Now(),
 				ParentSnapshot: firstSnapshot,
 			}
-			_, secondSnapshotID, err := arch.Snapshot(ctx, []string{"."}, opts)
+			testFS.bytesRead = map[string]int{}
+			secondSnapshot, secondSnapshotID, summary, err := arch.Snapshot(ctx, []string{"."}, opts)
 			if err != nil {
 				t.Fatal(err)
 			}
 
-			// check that all files still been read exactly once
-			TestWalkFiles(t, ".", test.src, func(filename string, item interface{}) error {
-				file, ok := item.(TestFile)
-				if !ok {
-					return nil
-				}
-
-				n, ok := testFS.bytesRead[filename]
-				if !ok {
-					t.Fatalf("file %v was not read at all", filename)
-				}
-
-				if n != len(file.Content) {
-					t.Fatalf("file %v: read %v bytes, wanted %v bytes", filename, n, len(file.Content))
-				}
-				return nil
-			})
+			if test.modify == nil {
+				// check that no files were read this time
+				restictest.Equals(t, map[string]int{}, testFS.bytesRead)
+			}
+			restictest.Equals(t, test.statSecond.Files, summary.Files)
+			restictest.Equals(t, test.statSecond.Dirs, summary.Dirs)
+			restictest.Equals(t, test.statSecond.ProcessedBytes, summary.ProcessedBytes)
+			checkSnapshotStats(t, secondSnapshot, test.statSecond)
 
 			t.Logf("second backup saved as %v", secondSnapshotID.Str())
 			t.Logf("testfs: %v", testFS)
@@ -1814,7 +1903,7 @@ func TestArchiverErrorReporting(t *testing.T) {
 			arch := New(repo, fs.Track{FS: fs.Local{}}, Options{})
 			arch.Error = test.errFn
 
-			_, snapshotID, err := arch.Snapshot(ctx, []string{"."}, SnapshotOptions{Time: time.Now()})
+			_, snapshotID, _, err := arch.Snapshot(ctx, []string{"."}, SnapshotOptions{Time: time.Now()})
 			if test.mustError {
 				if err != nil {
 					t.Logf("found expected error (%v), skipping further checks", err)
@@ -1887,7 +1976,7 @@ func TestArchiverContextCanceled(t *testing.T) {
 
 	arch := New(repo, fs.Track{FS: fs.Local{}}, Options{})
 
-	_, snapshotID, err := arch.Snapshot(ctx, []string{"."}, SnapshotOptions{Time: time.Now()})
+	_, snapshotID, _, err := arch.Snapshot(ctx, []string{"."}, SnapshotOptions{Time: time.Now()})
 
 	if err != nil {
 		t.Logf("found expected error (%v)", err)
@@ -2026,7 +2115,7 @@ func TestArchiverAbortEarlyOnError(t *testing.T) {
 				SaveBlobConcurrency: 1,
 			})
 
-			_, _, err := arch.Snapshot(ctx, []string{"."}, SnapshotOptions{Time: time.Now()})
+			_, _, _, err := arch.Snapshot(ctx, []string{"."}, SnapshotOptions{Time: time.Now()})
 			if !errors.Is(err, test.err) {
 				t.Errorf("expected error (%v) not found, got %v", test.err, err)
 			}
@@ -2054,7 +2143,7 @@ func snapshot(t testing.TB, repo restic.Repository, fs fs.FS, parent *restic.Sna
 		Time:           time.Now(),
 		ParentSnapshot: parent,
 	}
-	snapshot, _, err := arch.Snapshot(ctx, []string{filename}, sopts)
+	snapshot, _, _, err := arch.Snapshot(ctx, []string{filename}, sopts)
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -2236,7 +2325,7 @@ func TestRacyFileSwap(t *testing.T) {
 	arch.runWorkers(ctx, wg)
 
 	// fs.Track will panic if the file was not closed
-	_, excluded, err := arch.Save(ctx, "/", tempfile, nil)
+	_, excluded, err := arch.save(ctx, "/", tempfile, nil)
 	if err == nil {
 		t.Errorf("Save() should have failed")
 	}
diff --git a/internal/archiver/testing.go b/internal/archiver/testing.go
index 111c1e68c..1feaa82fc 100644
--- a/internal/archiver/testing.go
+++ b/internal/archiver/testing.go
@@ -31,7 +31,7 @@ func TestSnapshot(t testing.TB, repo restic.Repository, path string, parent *res
 		}
 		opts.ParentSnapshot = sn
 	}
-	sn, _, err := arch.Snapshot(context.TODO(), []string{path}, opts)
+	sn, _, _, err := arch.Snapshot(context.TODO(), []string{path}, opts)
 	if err != nil {
 		t.Fatal(err)
 	}
diff --git a/internal/archiver/testing_test.go b/internal/archiver/testing_test.go
index ada7261f1..e48b41ec7 100644
--- a/internal/archiver/testing_test.go
+++ b/internal/archiver/testing_test.go
@@ -473,7 +473,7 @@ func TestTestEnsureSnapshot(t *testing.T) {
 				Hostname: "localhost",
 				Tags:     []string{"test"},
 			}
-			_, id, err := arch.Snapshot(ctx, []string{"."}, opts)
+			_, id, _, err := arch.Snapshot(ctx, []string{"."}, opts)
 			if err != nil {
 				t.Fatal(err)
 			}
diff --git a/internal/dump/common_test.go b/internal/dump/common_test.go
index 3ee9112af..afd19df63 100644
--- a/internal/dump/common_test.go
+++ b/internal/dump/common_test.go
@@ -78,7 +78,7 @@ func WriteTest(t *testing.T, format string, cd CheckDump) {
 			back := rtest.Chdir(t, tmpdir)
 			defer back()
 
-			sn, _, err := arch.Snapshot(ctx, []string{"."}, archiver.SnapshotOptions{})
+			sn, _, _, err := arch.Snapshot(ctx, []string{"."}, archiver.SnapshotOptions{})
 			rtest.OK(t, err)
 
 			tree, err := restic.LoadTree(ctx, repo, *sn.Tree)
diff --git a/internal/restic/node_test.go b/internal/restic/node_test.go
index d9fa02ac8..aae010421 100644
--- a/internal/restic/node_test.go
+++ b/internal/restic/node_test.go
@@ -1,4 +1,4 @@
-package restic
+package restic_test
 
 import (
 	"context"
@@ -11,6 +11,7 @@ import (
 	"testing"
 	"time"
 
+	"github.com/restic/restic/internal/restic"
 	"github.com/restic/restic/internal/test"
 	rtest "github.com/restic/restic/internal/test"
 )
@@ -31,7 +32,7 @@ func BenchmarkNodeFillUser(t *testing.B) {
 	t.ResetTimer()
 
 	for i := 0; i < t.N; i++ {
-		_, err := NodeFromFileInfo(path, fi)
+		_, err := restic.NodeFromFileInfo(path, fi)
 		rtest.OK(t, err)
 	}
 
@@ -55,7 +56,7 @@ func BenchmarkNodeFromFileInfo(t *testing.B) {
 	t.ResetTimer()
 
 	for i := 0; i < t.N; i++ {
-		_, err := NodeFromFileInfo(path, fi)
+		_, err := restic.NodeFromFileInfo(path, fi)
 		if err != nil {
 			t.Fatal(err)
 		}
@@ -74,11 +75,11 @@ func parseTime(s string) time.Time {
 	return t.Local()
 }
 
-var nodeTests = []Node{
+var nodeTests = []restic.Node{
 	{
 		Name:       "testFile",
 		Type:       "file",
-		Content:    IDs{},
+		Content:    restic.IDs{},
 		UID:        uint32(os.Getuid()),
 		GID:        uint32(os.Getgid()),
 		Mode:       0604,
@@ -89,7 +90,7 @@ var nodeTests = []Node{
 	{
 		Name:       "testSuidFile",
 		Type:       "file",
-		Content:    IDs{},
+		Content:    restic.IDs{},
 		UID:        uint32(os.Getuid()),
 		GID:        uint32(os.Getgid()),
 		Mode:       0755 | os.ModeSetuid,
@@ -100,7 +101,7 @@ var nodeTests = []Node{
 	{
 		Name:       "testSuidFile2",
 		Type:       "file",
-		Content:    IDs{},
+		Content:    restic.IDs{},
 		UID:        uint32(os.Getuid()),
 		GID:        uint32(os.Getgid()),
 		Mode:       0755 | os.ModeSetgid,
@@ -111,7 +112,7 @@ var nodeTests = []Node{
 	{
 		Name:       "testSticky",
 		Type:       "file",
-		Content:    IDs{},
+		Content:    restic.IDs{},
 		UID:        uint32(os.Getuid()),
 		GID:        uint32(os.Getgid()),
 		Mode:       0755 | os.ModeSticky,
@@ -147,7 +148,7 @@ var nodeTests = []Node{
 	{
 		Name:       "testFile",
 		Type:       "file",
-		Content:    IDs{},
+		Content:    restic.IDs{},
 		UID:        uint32(os.Getuid()),
 		GID:        uint32(os.Getgid()),
 		Mode:       0604,
@@ -169,14 +170,14 @@ var nodeTests = []Node{
 	{
 		Name:       "testXattrFile",
 		Type:       "file",
-		Content:    IDs{},
+		Content:    restic.IDs{},
 		UID:        uint32(os.Getuid()),
 		GID:        uint32(os.Getgid()),
 		Mode:       0604,
 		ModTime:    parseTime("2005-05-14 21:07:03.111"),
 		AccessTime: parseTime("2005-05-14 21:07:04.222"),
 		ChangeTime: parseTime("2005-05-14 21:07:05.333"),
-		ExtendedAttributes: []ExtendedAttribute{
+		ExtendedAttributes: []restic.ExtendedAttribute{
 			{"user.foo", []byte("bar")},
 		},
 	},
@@ -190,7 +191,7 @@ var nodeTests = []Node{
 		ModTime:    parseTime("2005-05-14 21:07:03.111"),
 		AccessTime: parseTime("2005-05-14 21:07:04.222"),
 		ChangeTime: parseTime("2005-05-14 21:07:05.333"),
-		ExtendedAttributes: []ExtendedAttribute{
+		ExtendedAttributes: []restic.ExtendedAttribute{
 			{"user.foo", []byte("bar")},
 		},
 	},
@@ -218,7 +219,7 @@ func TestNodeRestoreAt(t *testing.T) {
 				nodePath = filepath.Join(tempdir, test.Name)
 			}
 			rtest.OK(t, test.CreateAt(context.TODO(), nodePath, nil))
-			rtest.OK(t, test.RestoreMetadata(nodePath, func(msg string) { rtest.OK(t, fmt.Errorf("Warning triggered for path: %s: %s", nodePath, msg)) }))
+			rtest.OK(t, test.RestoreMetadata(nodePath))
 
 			if test.Type == "dir" {
 				rtest.OK(t, test.RestoreTimestamps(nodePath))
@@ -227,7 +228,7 @@ func TestNodeRestoreAt(t *testing.T) {
 			fi, err := os.Lstat(nodePath)
 			rtest.OK(t, err)
 
-			n2, err := NodeFromFileInfo(nodePath, fi)
+			n2, err := restic.NodeFromFileInfo(nodePath, fi)
 			rtest.OK(t, err)
 
 			rtest.Assert(t, test.Name == n2.Name,
@@ -329,7 +330,7 @@ func TestFixTime(t *testing.T) {
 
 	for _, test := range tests {
 		t.Run("", func(t *testing.T) {
-			res := FixTime(test.src)
+			res := restic.FixTime(test.src)
 			if !res.Equal(test.want) {
 				t.Fatalf("wrong result for %v, want:\n  %v\ngot:\n  %v", test.src, test.want, res)
 			}
@@ -342,12 +343,12 @@ func TestSymlinkSerialization(t *testing.T) {
 		"válîd \t Üñi¢òde \n śẗŕinǵ",
 		string([]byte{0, 1, 2, 0xfa, 0xfb, 0xfc}),
 	} {
-		n := Node{
+		n := restic.Node{
 			LinkTarget: link,
 		}
 		ser, err := json.Marshal(n)
 		test.OK(t, err)
-		var n2 Node
+		var n2 restic.Node
 		err = json.Unmarshal(ser, &n2)
 		test.OK(t, err)
 		fmt.Println(string(ser))
@@ -364,7 +365,7 @@ func TestSymlinkSerializationFormat(t *testing.T) {
 		{`{"linktarget":"test"}`, "test"},
 		{`{"linktarget":"\u0000\u0001\u0002\ufffd\ufffd\ufffd","linktarget_raw":"AAEC+vv8"}`, string([]byte{0, 1, 2, 0xfa, 0xfb, 0xfc})},
 	} {
-		var n2 Node
+		var n2 restic.Node
 		err := json.Unmarshal([]byte(d.ser), &n2)
 		test.OK(t, err)
 		test.Equals(t, d.linkTarget, n2.LinkTarget)
diff --git a/internal/restic/node_windows_test.go b/internal/restic/node_windows_test.go
deleted file mode 100644
index 501d5a98a..000000000
--- a/internal/restic/node_windows_test.go
+++ /dev/null
@@ -1,210 +0,0 @@
-//go:build windows
-// +build windows
-
-package restic
-
-import (
-	"encoding/json"
-	"fmt"
-	"os"
-	"path/filepath"
-	"syscall"
-	"testing"
-
-	"github.com/restic/restic/internal/errors"
-	"github.com/restic/restic/internal/test"
-	"golang.org/x/sys/windows"
-)
-
-func TestRestoreCreationTime(t *testing.T) {
-	t.Parallel()
-	path := t.TempDir()
-	fi, err := os.Lstat(path)
-	test.OK(t, errors.Wrapf(err, "Could not Lstat for path: %s", path))
-	creationTimeAttribute := getCreationTime(fi, path)
-	test.OK(t, errors.Wrapf(err, "Could not get creation time for path: %s", path))
-	//Using the temp dir creation time as the test creation time for the test file and folder
-	runGenericAttributesTest(t, path, TypeCreationTime, WindowsAttributes{CreationTime: creationTimeAttribute}, false)
-}
-
-func TestRestoreFileAttributes(t *testing.T) {
-	t.Parallel()
-	genericAttributeName := TypeFileAttributes
-	tempDir := t.TempDir()
-	normal := uint32(syscall.FILE_ATTRIBUTE_NORMAL)
-	hidden := uint32(syscall.FILE_ATTRIBUTE_HIDDEN)
-	system := uint32(syscall.FILE_ATTRIBUTE_SYSTEM)
-	archive := uint32(syscall.FILE_ATTRIBUTE_ARCHIVE)
-	encrypted := uint32(windows.FILE_ATTRIBUTE_ENCRYPTED)
-	fileAttributes := []WindowsAttributes{
-		//normal
-		{FileAttributes: &normal},
-		//hidden
-		{FileAttributes: &hidden},
-		//system
-		{FileAttributes: &system},
-		//archive
-		{FileAttributes: &archive},
-		//encrypted
-		{FileAttributes: &encrypted},
-	}
-	for i, fileAttr := range fileAttributes {
-		genericAttrs, err := WindowsAttrsToGenericAttributes(fileAttr)
-		test.OK(t, err)
-		expectedNodes := []Node{
-			{
-				Name:              fmt.Sprintf("testfile%d", i),
-				Type:              "file",
-				Mode:              0655,
-				ModTime:           parseTime("2005-05-14 21:07:03.111"),
-				AccessTime:        parseTime("2005-05-14 21:07:04.222"),
-				ChangeTime:        parseTime("2005-05-14 21:07:05.333"),
-				GenericAttributes: genericAttrs,
-			},
-		}
-		runGenericAttributesTestForNodes(t, expectedNodes, tempDir, genericAttributeName, fileAttr, false)
-	}
-	normal = uint32(syscall.FILE_ATTRIBUTE_DIRECTORY)
-	hidden = uint32(syscall.FILE_ATTRIBUTE_DIRECTORY | syscall.FILE_ATTRIBUTE_HIDDEN)
-	system = uint32(syscall.FILE_ATTRIBUTE_DIRECTORY | windows.FILE_ATTRIBUTE_SYSTEM)
-	archive = uint32(syscall.FILE_ATTRIBUTE_DIRECTORY | windows.FILE_ATTRIBUTE_ARCHIVE)
-	encrypted = uint32(syscall.FILE_ATTRIBUTE_DIRECTORY | windows.FILE_ATTRIBUTE_ENCRYPTED)
-	folderAttributes := []WindowsAttributes{
-		//normal
-		{FileAttributes: &normal},
-		//hidden
-		{FileAttributes: &hidden},
-		//system
-		{FileAttributes: &system},
-		//archive
-		{FileAttributes: &archive},
-		//encrypted
-		{FileAttributes: &encrypted},
-	}
-	for i, folderAttr := range folderAttributes {
-		genericAttrs, err := WindowsAttrsToGenericAttributes(folderAttr)
-		test.OK(t, err)
-		expectedNodes := []Node{
-			{
-				Name:              fmt.Sprintf("testdirectory%d", i),
-				Type:              "dir",
-				Mode:              0755,
-				ModTime:           parseTime("2005-05-14 21:07:03.111"),
-				AccessTime:        parseTime("2005-05-14 21:07:04.222"),
-				ChangeTime:        parseTime("2005-05-14 21:07:05.333"),
-				GenericAttributes: genericAttrs,
-			},
-		}
-		runGenericAttributesTestForNodes(t, expectedNodes, tempDir, genericAttributeName, folderAttr, false)
-	}
-}
-
-func runGenericAttributesTest(t *testing.T, tempDir string, genericAttributeName GenericAttributeType, genericAttributeExpected WindowsAttributes, warningExpected bool) {
-	genericAttributes, err := WindowsAttrsToGenericAttributes(genericAttributeExpected)
-	test.OK(t, err)
-	expectedNodes := []Node{
-		{
-			Name:              "testfile",
-			Type:              "file",
-			Mode:              0644,
-			ModTime:           parseTime("2005-05-14 21:07:03.111"),
-			AccessTime:        parseTime("2005-05-14 21:07:04.222"),
-			ChangeTime:        parseTime("2005-05-14 21:07:05.333"),
-			GenericAttributes: genericAttributes,
-		},
-		{
-			Name:              "testdirectory",
-			Type:              "dir",
-			Mode:              0755,
-			ModTime:           parseTime("2005-05-14 21:07:03.111"),
-			AccessTime:        parseTime("2005-05-14 21:07:04.222"),
-			ChangeTime:        parseTime("2005-05-14 21:07:05.333"),
-			GenericAttributes: genericAttributes,
-		},
-	}
-	runGenericAttributesTestForNodes(t, expectedNodes, tempDir, genericAttributeName, genericAttributeExpected, warningExpected)
-}
-func runGenericAttributesTestForNodes(t *testing.T, expectedNodes []Node, tempDir string, genericAttr GenericAttributeType, genericAttributeExpected WindowsAttributes, warningExpected bool) {
-
-	for _, testNode := range expectedNodes {
-		testPath, node := restoreAndGetNode(t, tempDir, testNode, warningExpected)
-		rawMessage := node.GenericAttributes[genericAttr]
-		genericAttrsExpected, err := WindowsAttrsToGenericAttributes(genericAttributeExpected)
-		test.OK(t, err)
-		rawMessageExpected := genericAttrsExpected[genericAttr]
-		test.Equals(t, rawMessageExpected, rawMessage, "Generic attribute: %s got from NodeFromFileInfo not equal for path: %s", string(genericAttr), testPath)
-	}
-}
-
-func restoreAndGetNode(t *testing.T, tempDir string, testNode Node, warningExpected bool) (string, *Node) {
-	testPath := filepath.Join(tempDir, "001", testNode.Name)
-	err := os.MkdirAll(filepath.Dir(testPath), testNode.Mode)
-	test.OK(t, errors.Wrapf(err, "Failed to create parent directories for: %s", testPath))
-
-	if testNode.Type == "file" {
-
-		testFile, err := os.Create(testPath)
-		test.OK(t, errors.Wrapf(err, "Failed to create test file: %s", testPath))
-		testFile.Close()
-	} else if testNode.Type == "dir" {
-
-		err := os.Mkdir(testPath, testNode.Mode)
-		test.OK(t, errors.Wrapf(err, "Failed to create test directory: %s", testPath))
-	}
-
-	err = testNode.RestoreMetadata(testPath, func(msg string) {
-		if warningExpected {
-			test.Assert(t, warningExpected, "Warning triggered as expected: %s", msg)
-		} else {
-			// If warning is not expected, this code should not get triggered.
-			test.OK(t, fmt.Errorf("Warning triggered for path: %s: %s", testPath, msg))
-		}
-	})
-	test.OK(t, errors.Wrapf(err, "Failed to restore metadata for: %s", testPath))
-
-	fi, err := os.Lstat(testPath)
-	test.OK(t, errors.Wrapf(err, "Could not Lstat for path: %s", testPath))
-
-	nodeFromFileInfo, err := NodeFromFileInfo(testPath, fi)
-	test.OK(t, errors.Wrapf(err, "Could not get NodeFromFileInfo for path: %s", testPath))
-
-	return testPath, nodeFromFileInfo
-}
-
-const TypeSomeNewAttribute GenericAttributeType = "MockAttributes.SomeNewAttribute"
-
-func TestNewGenericAttributeType(t *testing.T) {
-	t.Parallel()
-
-	newGenericAttribute := map[GenericAttributeType]json.RawMessage{}
-	newGenericAttribute[TypeSomeNewAttribute] = []byte("any value")
-
-	tempDir := t.TempDir()
-	expectedNodes := []Node{
-		{
-			Name:              "testfile",
-			Type:              "file",
-			Mode:              0644,
-			ModTime:           parseTime("2005-05-14 21:07:03.111"),
-			AccessTime:        parseTime("2005-05-14 21:07:04.222"),
-			ChangeTime:        parseTime("2005-05-14 21:07:05.333"),
-			GenericAttributes: newGenericAttribute,
-		},
-		{
-			Name:              "testdirectory",
-			Type:              "dir",
-			Mode:              0755,
-			ModTime:           parseTime("2005-05-14 21:07:03.111"),
-			AccessTime:        parseTime("2005-05-14 21:07:04.222"),
-			ChangeTime:        parseTime("2005-05-14 21:07:05.333"),
-			GenericAttributes: newGenericAttribute,
-		},
-	}
-	for _, testNode := range expectedNodes {
-		testPath, node := restoreAndGetNode(t, tempDir, testNode, true)
-		_, ua, err := genericAttributesToWindowsAttrs(node.GenericAttributes)
-		test.OK(t, err)
-		// Since this GenericAttribute is unknown to this version of the software, it will not get set on the file.
-		test.Assert(t, len(ua) == 0, "Unkown attributes: %s found for path: %s", ua, testPath)
-	}
-}
diff --git a/internal/restorer/restorer_test.go b/internal/restorer/restorer_test.go
index 5742d7663..29f8920c5 100644
--- a/internal/restorer/restorer_test.go
+++ b/internal/restorer/restorer_test.go
@@ -3,7 +3,6 @@ package restorer
 import (
 	"bytes"
 	"context"
-	"encoding/json"
 	"io"
 	"math"
 	"os"
@@ -28,27 +27,17 @@ type Snapshot struct {
 }
 
 type File struct {
-	Data       string
-	Links      uint64
-	Inode      uint64
-	Mode       os.FileMode
-	ModTime    time.Time
-	attributes *FileAttributes
+	Data    string
+	Links   uint64
+	Inode   uint64
+	Mode    os.FileMode
+	ModTime time.Time
 }
 
 type Dir struct {
-	Nodes      map[string]Node
-	Mode       os.FileMode
-	ModTime    time.Time
-	attributes *FileAttributes
-}
-
-type FileAttributes struct {
-	ReadOnly  bool
-	Hidden    bool
-	System    bool
-	Archive   bool
-	Encrypted bool
+	Nodes   map[string]Node
+	Mode    os.FileMode
+	ModTime time.Time
 }
 
 func saveFile(t testing.TB, repo restic.BlobSaver, node File) restic.ID {
@@ -63,7 +52,7 @@ func saveFile(t testing.TB, repo restic.BlobSaver, node File) restic.ID {
 	return id
 }
 
-func saveDir(t testing.TB, repo restic.BlobSaver, nodes map[string]Node, inode uint64, getGenericAttributes func(attr *FileAttributes, isDir bool) (genericAttributes map[restic.GenericAttributeType]json.RawMessage)) restic.ID {
+func saveDir(t testing.TB, repo restic.BlobSaver, nodes map[string]Node, inode uint64) restic.ID {
 	ctx, cancel := context.WithCancel(context.Background())
 	defer cancel()
 
@@ -89,21 +78,20 @@ func saveDir(t testing.TB, repo restic.BlobSaver, nodes map[string]Node, inode u
 				mode = 0644
 			}
 			err := tree.Insert(&restic.Node{
-				Type:              "file",
-				Mode:              mode,
-				ModTime:           node.ModTime,
-				Name:              name,
-				UID:               uint32(os.Getuid()),
-				GID:               uint32(os.Getgid()),
-				Content:           fc,
-				Size:              uint64(len(n.(File).Data)),
-				Inode:             fi,
-				Links:             lc,
-				GenericAttributes: getGenericAttributes(node.attributes, false),
+				Type:    "file",
+				Mode:    mode,
+				ModTime: node.ModTime,
+				Name:    name,
+				UID:     uint32(os.Getuid()),
+				GID:     uint32(os.Getgid()),
+				Content: fc,
+				Size:    uint64(len(n.(File).Data)),
+				Inode:   fi,
+				Links:   lc,
 			})
 			rtest.OK(t, err)
 		case Dir:
-			id := saveDir(t, repo, node.Nodes, inode, getGenericAttributes)
+			id := saveDir(t, repo, node.Nodes, inode)
 
 			mode := node.Mode
 			if mode == 0 {
@@ -111,14 +99,13 @@ func saveDir(t testing.TB, repo restic.BlobSaver, nodes map[string]Node, inode u
 			}
 
 			err := tree.Insert(&restic.Node{
-				Type:              "dir",
-				Mode:              mode,
-				ModTime:           node.ModTime,
-				Name:              name,
-				UID:               uint32(os.Getuid()),
-				GID:               uint32(os.Getgid()),
-				Subtree:           &id,
-				GenericAttributes: getGenericAttributes(node.attributes, false),
+				Type:    "dir",
+				Mode:    mode,
+				ModTime: node.ModTime,
+				Name:    name,
+				UID:     uint32(os.Getuid()),
+				GID:     uint32(os.Getgid()),
+				Subtree: &id,
 			})
 			rtest.OK(t, err)
 		default:
@@ -134,13 +121,13 @@ func saveDir(t testing.TB, repo restic.BlobSaver, nodes map[string]Node, inode u
 	return id
 }
 
-func saveSnapshot(t testing.TB, repo restic.Repository, snapshot Snapshot, getGenericAttributes func(attr *FileAttributes, isDir bool) (genericAttributes map[restic.GenericAttributeType]json.RawMessage)) (*restic.Snapshot, restic.ID) {
+func saveSnapshot(t testing.TB, repo restic.Repository, snapshot Snapshot) (*restic.Snapshot, restic.ID) {
 	ctx, cancel := context.WithCancel(context.Background())
 	defer cancel()
 
 	wg, wgCtx := errgroup.WithContext(ctx)
 	repo.StartPackUploader(wgCtx, wg)
-	treeID := saveDir(t, repo, snapshot.Nodes, 1000, getGenericAttributes)
+	treeID := saveDir(t, repo, snapshot.Nodes, 1000)
 	err := repo.Flush(ctx)
 	if err != nil {
 		t.Fatal(err)
@@ -160,11 +147,6 @@ func saveSnapshot(t testing.TB, repo restic.Repository, snapshot Snapshot, getGe
 	return sn, id
 }
 
-var noopGetGenericAttributes = func(attr *FileAttributes, isDir bool) (genericAttributes map[restic.GenericAttributeType]json.RawMessage) {
-	// No-op
-	return nil
-}
-
 func TestRestorer(t *testing.T) {
 	var tests = []struct {
 		Snapshot
@@ -340,7 +322,7 @@ func TestRestorer(t *testing.T) {
 	for _, test := range tests {
 		t.Run("", func(t *testing.T) {
 			repo := repository.TestRepository(t)
-			sn, id := saveSnapshot(t, repo, test.Snapshot, noopGetGenericAttributes)
+			sn, id := saveSnapshot(t, repo, test.Snapshot)
 			t.Logf("snapshot saved as %v", id.Str())
 
 			res := NewRestorer(repo, sn, false, nil)
@@ -457,7 +439,7 @@ func TestRestorerRelative(t *testing.T) {
 		t.Run("", func(t *testing.T) {
 			repo := repository.TestRepository(t)
 
-			sn, id := saveSnapshot(t, repo, test.Snapshot, noopGetGenericAttributes)
+			sn, id := saveSnapshot(t, repo, test.Snapshot)
 			t.Logf("snapshot saved as %v", id.Str())
 
 			res := NewRestorer(repo, sn, false, nil)
@@ -687,7 +669,7 @@ func TestRestorerTraverseTree(t *testing.T) {
 	for _, test := range tests {
 		t.Run("", func(t *testing.T) {
 			repo := repository.TestRepository(t)
-			sn, _ := saveSnapshot(t, repo, test.Snapshot, noopGetGenericAttributes)
+			sn, _ := saveSnapshot(t, repo, test.Snapshot)
 
 			res := NewRestorer(repo, sn, false, nil)
 
@@ -763,7 +745,7 @@ func TestRestorerConsistentTimestampsAndPermissions(t *testing.T) {
 				},
 			},
 		},
-	}, noopGetGenericAttributes)
+	})
 
 	res := NewRestorer(repo, sn, false, nil)
 
@@ -818,7 +800,7 @@ func TestVerifyCancel(t *testing.T) {
 	}
 
 	repo := repository.TestRepository(t)
-	sn, _ := saveSnapshot(t, repo, snapshot, noopGetGenericAttributes)
+	sn, _ := saveSnapshot(t, repo, snapshot)
 
 	res := NewRestorer(repo, sn, false, nil)
 
@@ -858,7 +840,7 @@ func TestRestorerSparseFiles(t *testing.T) {
 	rtest.OK(t, err)
 
 	arch := archiver.New(repo, target, archiver.Options{})
-	sn, _, err := arch.Snapshot(context.Background(), []string{"/zeros"},
+	sn, _, _, err := arch.Snapshot(context.Background(), []string{"/zeros"},
 		archiver.SnapshotOptions{})
 	rtest.OK(t, err)
 
diff --git a/internal/restorer/restorer_unix_test.go b/internal/restorer/restorer_unix_test.go
index 0cbfefa92..2c30a6b64 100644
--- a/internal/restorer/restorer_unix_test.go
+++ b/internal/restorer/restorer_unix_test.go
@@ -29,7 +29,7 @@ func TestRestorerRestoreEmptyHardlinkedFileds(t *testing.T) {
 				},
 			},
 		},
-	}, noopGetGenericAttributes)
+	})
 
 	res := NewRestorer(repo, sn, false, nil)
 
@@ -95,7 +95,7 @@ func TestRestorerProgressBar(t *testing.T) {
 			},
 			"file2": File{Links: 1, Inode: 2, Data: "example"},
 		},
-	}, noopGetGenericAttributes)
+	})
 
 	mock := &printerMock{}
 	progress := restoreui.NewProgress(mock, 0)
diff --git a/internal/restorer/restorer_windows_test.go b/internal/restorer/restorer_windows_test.go
index 684d51ace..3ec4b1f11 100644
--- a/internal/restorer/restorer_windows_test.go
+++ b/internal/restorer/restorer_windows_test.go
@@ -4,20 +4,11 @@
 package restorer
 
 import (
-	"context"
-	"encoding/json"
 	"math"
-	"os"
-	"path"
 	"syscall"
 	"testing"
-	"time"
 	"unsafe"
 
-	"github.com/restic/restic/internal/errors"
-	"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"
 	"golang.org/x/sys/windows"
 )
@@ -42,500 +33,3 @@ func getBlockCount(t *testing.T, filename string) int64 {
 
 	return int64(math.Ceil(float64(result) / 512))
 }
-
-type DataStreamInfo struct {
-	name string
-	data string
-}
-
-type NodeInfo struct {
-	DataStreamInfo
-	parentDir   string
-	attributes  FileAttributes
-	Exists      bool
-	IsDirectory bool
-}
-
-func TestFileAttributeCombination(t *testing.T) {
-	testFileAttributeCombination(t, false)
-}
-
-func TestEmptyFileAttributeCombination(t *testing.T) {
-	testFileAttributeCombination(t, true)
-}
-
-func testFileAttributeCombination(t *testing.T, isEmpty bool) {
-	t.Parallel()
-	//Generate combination of 5 attributes.
-	attributeCombinations := generateCombinations(5, []bool{})
-
-	fileName := "TestFile.txt"
-	// Iterate through each attribute combination
-	for _, attr1 := range attributeCombinations {
-
-		//Set up the required file information
-		fileInfo := NodeInfo{
-			DataStreamInfo: getDataStreamInfo(isEmpty, fileName),
-			parentDir:      "dir",
-			attributes:     getFileAttributes(attr1),
-			Exists:         false,
-		}
-
-		//Get the current test name
-		testName := getCombinationTestName(fileInfo, fileName, fileInfo.attributes)
-
-		//Run test
-		t.Run(testName, func(t *testing.T) {
-			mainFilePath := runAttributeTests(t, fileInfo, fileInfo.attributes)
-
-			verifyFileRestores(isEmpty, mainFilePath, t, fileInfo)
-		})
-	}
-}
-
-func generateCombinations(n int, prefix []bool) [][]bool {
-	if n == 0 {
-		// Return a slice containing the current permutation
-		return [][]bool{append([]bool{}, prefix...)}
-	}
-
-	// Generate combinations with True
-	prefixTrue := append(prefix, true)
-	permsTrue := generateCombinations(n-1, prefixTrue)
-
-	// Generate combinations with False
-	prefixFalse := append(prefix, false)
-	permsFalse := generateCombinations(n-1, prefixFalse)
-
-	// Combine combinations with True and False
-	return append(permsTrue, permsFalse...)
-}
-
-func getDataStreamInfo(isEmpty bool, fileName string) DataStreamInfo {
-	var dataStreamInfo DataStreamInfo
-	if isEmpty {
-		dataStreamInfo = DataStreamInfo{
-			name: fileName,
-		}
-	} else {
-		dataStreamInfo = DataStreamInfo{
-			name: fileName,
-			data: "Main file data stream.",
-		}
-	}
-	return dataStreamInfo
-}
-
-func getFileAttributes(values []bool) FileAttributes {
-	return FileAttributes{
-		ReadOnly:  values[0],
-		Hidden:    values[1],
-		System:    values[2],
-		Archive:   values[3],
-		Encrypted: values[4],
-	}
-}
-
-func getCombinationTestName(fi NodeInfo, fileName string, overwriteAttr FileAttributes) string {
-	if fi.attributes.ReadOnly {
-		fileName += "-ReadOnly"
-	}
-	if fi.attributes.Hidden {
-		fileName += "-Hidden"
-	}
-	if fi.attributes.System {
-		fileName += "-System"
-	}
-	if fi.attributes.Archive {
-		fileName += "-Archive"
-	}
-	if fi.attributes.Encrypted {
-		fileName += "-Encrypted"
-	}
-	if fi.Exists {
-		fileName += "-Overwrite"
-		if overwriteAttr.ReadOnly {
-			fileName += "-R"
-		}
-		if overwriteAttr.Hidden {
-			fileName += "-H"
-		}
-		if overwriteAttr.System {
-			fileName += "-S"
-		}
-		if overwriteAttr.Archive {
-			fileName += "-A"
-		}
-		if overwriteAttr.Encrypted {
-			fileName += "-E"
-		}
-	}
-	return fileName
-}
-
-func runAttributeTests(t *testing.T, fileInfo NodeInfo, existingFileAttr FileAttributes) string {
-	testDir := t.TempDir()
-	res, _ := setupWithFileAttributes(t, fileInfo, testDir, existingFileAttr)
-
-	ctx, cancel := context.WithCancel(context.Background())
-	defer cancel()
-
-	err := res.RestoreTo(ctx, testDir)
-	rtest.OK(t, err)
-
-	mainFilePath := path.Join(testDir, fileInfo.parentDir, fileInfo.name)
-	//Verify restore
-	verifyFileAttributes(t, mainFilePath, fileInfo.attributes)
-	return mainFilePath
-}
-
-func setupWithFileAttributes(t *testing.T, nodeInfo NodeInfo, testDir string, existingFileAttr FileAttributes) (*Restorer, []int) {
-	t.Helper()
-	if nodeInfo.Exists {
-		if !nodeInfo.IsDirectory {
-			err := os.MkdirAll(path.Join(testDir, nodeInfo.parentDir), os.ModeDir)
-			rtest.OK(t, err)
-			filepath := path.Join(testDir, nodeInfo.parentDir, nodeInfo.name)
-			if existingFileAttr.Encrypted {
-				err := createEncryptedFileWriteData(filepath, nodeInfo)
-				rtest.OK(t, err)
-			} else {
-				// Write the data to the file
-				file, err := os.OpenFile(path.Clean(filepath), os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0600)
-				rtest.OK(t, err)
-				_, err = file.Write([]byte(nodeInfo.data))
-				rtest.OK(t, err)
-
-				err = file.Close()
-				rtest.OK(t, err)
-			}
-		} else {
-			err := os.MkdirAll(path.Join(testDir, nodeInfo.parentDir, nodeInfo.name), os.ModeDir)
-			rtest.OK(t, err)
-		}
-
-		pathPointer, err := syscall.UTF16PtrFromString(path.Join(testDir, nodeInfo.parentDir, nodeInfo.name))
-		rtest.OK(t, err)
-		syscall.SetFileAttributes(pathPointer, getAttributeValue(&existingFileAttr))
-	}
-
-	index := 0
-
-	order := []int{}
-	streams := []DataStreamInfo{}
-	if !nodeInfo.IsDirectory {
-		order = append(order, index)
-		index++
-		streams = append(streams, nodeInfo.DataStreamInfo)
-	}
-	return setup(t, getNodes(nodeInfo.parentDir, nodeInfo.name, order, streams, nodeInfo.IsDirectory, &nodeInfo.attributes)), order
-}
-
-func createEncryptedFileWriteData(filepath string, fileInfo NodeInfo) (err error) {
-	var ptr *uint16
-	if ptr, err = windows.UTF16PtrFromString(filepath); err != nil {
-		return err
-	}
-	var handle windows.Handle
-	//Create the file with encrypted flag
-	if handle, err = windows.CreateFile(ptr, uint32(windows.GENERIC_READ|windows.GENERIC_WRITE), uint32(windows.FILE_SHARE_READ), nil, uint32(windows.CREATE_ALWAYS), windows.FILE_ATTRIBUTE_ENCRYPTED, 0); err != nil {
-		return err
-	}
-	//Write data to file
-	if _, err = windows.Write(handle, []byte(fileInfo.data)); err != nil {
-		return err
-	}
-	//Close handle
-	return windows.CloseHandle(handle)
-}
-
-func setup(t *testing.T, nodesMap map[string]Node) *Restorer {
-	repo := repository.TestRepository(t)
-	getFileAttributes := func(attr *FileAttributes, isDir bool) (genericAttributes map[restic.GenericAttributeType]json.RawMessage) {
-		if attr == nil {
-			return
-		}
-
-		fileattr := getAttributeValue(attr)
-
-		if isDir {
-			//If the node is a directory add FILE_ATTRIBUTE_DIRECTORY to attributes
-			fileattr |= windows.FILE_ATTRIBUTE_DIRECTORY
-		}
-		attrs, err := restic.WindowsAttrsToGenericAttributes(restic.WindowsAttributes{FileAttributes: &fileattr})
-		test.OK(t, err)
-		return attrs
-	}
-	sn, _ := saveSnapshot(t, repo, Snapshot{
-		Nodes: nodesMap,
-	}, getFileAttributes)
-	res := NewRestorer(repo, sn, false, nil)
-	return res
-}
-
-func getAttributeValue(attr *FileAttributes) uint32 {
-	var fileattr uint32
-	if attr.ReadOnly {
-		fileattr |= windows.FILE_ATTRIBUTE_READONLY
-	}
-	if attr.Hidden {
-		fileattr |= windows.FILE_ATTRIBUTE_HIDDEN
-	}
-	if attr.Encrypted {
-		fileattr |= windows.FILE_ATTRIBUTE_ENCRYPTED
-	}
-	if attr.Archive {
-		fileattr |= windows.FILE_ATTRIBUTE_ARCHIVE
-	}
-	if attr.System {
-		fileattr |= windows.FILE_ATTRIBUTE_SYSTEM
-	}
-	return fileattr
-}
-
-func getNodes(dir string, mainNodeName string, order []int, streams []DataStreamInfo, isDirectory bool, attributes *FileAttributes) map[string]Node {
-	var mode os.FileMode
-	if isDirectory {
-		mode = os.FileMode(2147484159)
-	} else {
-		if attributes != nil && attributes.ReadOnly {
-			mode = os.FileMode(0o444)
-		} else {
-			mode = os.FileMode(0o666)
-		}
-	}
-
-	getFileNodes := func() map[string]Node {
-		nodes := map[string]Node{}
-		if isDirectory {
-			//Add a directory node at the same level as the other streams
-			nodes[mainNodeName] = Dir{
-				ModTime:    time.Now(),
-				attributes: attributes,
-				Mode:       mode,
-			}
-		}
-
-		if len(streams) > 0 {
-			for _, index := range order {
-				stream := streams[index]
-
-				var attr *FileAttributes = nil
-				if mainNodeName == stream.name {
-					attr = attributes
-				} else if attributes != nil && attributes.Encrypted {
-					//Set encrypted attribute
-					attr = &FileAttributes{Encrypted: true}
-				}
-
-				nodes[stream.name] = File{
-					ModTime:    time.Now(),
-					Data:       stream.data,
-					Mode:       mode,
-					attributes: attr,
-				}
-			}
-		}
-		return nodes
-	}
-
-	return map[string]Node{
-		dir: Dir{
-			Mode:    normalizeFileMode(0750 | mode),
-			ModTime: time.Now(),
-			Nodes:   getFileNodes(),
-		},
-	}
-}
-
-func verifyFileAttributes(t *testing.T, mainFilePath string, attr FileAttributes) {
-	ptr, err := windows.UTF16PtrFromString(mainFilePath)
-	rtest.OK(t, err)
-	//Get file attributes using syscall
-	fileAttributes, err := syscall.GetFileAttributes(ptr)
-	rtest.OK(t, err)
-	//Test positive and negative scenarios
-	if attr.ReadOnly {
-		rtest.Assert(t, fileAttributes&windows.FILE_ATTRIBUTE_READONLY != 0, "Expected read only attibute.")
-	} else {
-		rtest.Assert(t, fileAttributes&windows.FILE_ATTRIBUTE_READONLY == 0, "Unexpected read only attibute.")
-	}
-	if attr.Hidden {
-		rtest.Assert(t, fileAttributes&windows.FILE_ATTRIBUTE_HIDDEN != 0, "Expected hidden attibute.")
-	} else {
-		rtest.Assert(t, fileAttributes&windows.FILE_ATTRIBUTE_HIDDEN == 0, "Unexpected hidden attibute.")
-	}
-	if attr.System {
-		rtest.Assert(t, fileAttributes&windows.FILE_ATTRIBUTE_SYSTEM != 0, "Expected system attibute.")
-	} else {
-		rtest.Assert(t, fileAttributes&windows.FILE_ATTRIBUTE_SYSTEM == 0, "Unexpected system attibute.")
-	}
-	if attr.Archive {
-		rtest.Assert(t, fileAttributes&windows.FILE_ATTRIBUTE_ARCHIVE != 0, "Expected archive attibute.")
-	} else {
-		rtest.Assert(t, fileAttributes&windows.FILE_ATTRIBUTE_ARCHIVE == 0, "Unexpected archive attibute.")
-	}
-	if attr.Encrypted {
-		rtest.Assert(t, fileAttributes&windows.FILE_ATTRIBUTE_ENCRYPTED != 0, "Expected encrypted attibute.")
-	} else {
-		rtest.Assert(t, fileAttributes&windows.FILE_ATTRIBUTE_ENCRYPTED == 0, "Unexpected encrypted attibute.")
-	}
-}
-
-func verifyFileRestores(isEmpty bool, mainFilePath string, t *testing.T, fileInfo NodeInfo) {
-	if isEmpty {
-		_, err1 := os.Stat(mainFilePath)
-		rtest.Assert(t, !errors.Is(err1, os.ErrNotExist), "The file "+fileInfo.name+" does not exist")
-	} else {
-
-		verifyMainFileRestore(t, mainFilePath, fileInfo)
-	}
-}
-
-func verifyMainFileRestore(t *testing.T, mainFilePath string, fileInfo NodeInfo) {
-	fi, err1 := os.Stat(mainFilePath)
-	rtest.Assert(t, !errors.Is(err1, os.ErrNotExist), "The file "+fileInfo.name+" does not exist")
-
-	size := fi.Size()
-	rtest.Assert(t, size > 0, "The file "+fileInfo.name+" exists but is empty")
-
-	content, err := os.ReadFile(mainFilePath)
-	rtest.OK(t, err)
-	rtest.Assert(t, string(content) == fileInfo.data, "The file "+fileInfo.name+" exists but the content is not overwritten")
-}
-
-func TestDirAttributeCombination(t *testing.T) {
-	t.Parallel()
-	attributeCombinations := generateCombinations(4, []bool{})
-
-	dirName := "TestDir"
-	// Iterate through each attribute combination
-	for _, attr1 := range attributeCombinations {
-
-		//Set up the required directory information
-		dirInfo := NodeInfo{
-			DataStreamInfo: DataStreamInfo{
-				name: dirName,
-			},
-			parentDir:   "dir",
-			attributes:  getDirFileAttributes(attr1),
-			Exists:      false,
-			IsDirectory: true,
-		}
-
-		//Get the current test name
-		testName := getCombinationTestName(dirInfo, dirName, dirInfo.attributes)
-
-		//Run test
-		t.Run(testName, func(t *testing.T) {
-			mainDirPath := runAttributeTests(t, dirInfo, dirInfo.attributes)
-
-			//Check directory exists
-			_, err1 := os.Stat(mainDirPath)
-			rtest.Assert(t, !errors.Is(err1, os.ErrNotExist), "The directory "+dirInfo.name+" does not exist")
-		})
-	}
-}
-
-func getDirFileAttributes(values []bool) FileAttributes {
-	return FileAttributes{
-		// readonly not valid for directories
-		Hidden:    values[0],
-		System:    values[1],
-		Archive:   values[2],
-		Encrypted: values[3],
-	}
-}
-
-func TestFileAttributeCombinationsOverwrite(t *testing.T) {
-	testFileAttributeCombinationsOverwrite(t, false)
-}
-
-func TestEmptyFileAttributeCombinationsOverwrite(t *testing.T) {
-	testFileAttributeCombinationsOverwrite(t, true)
-}
-
-func testFileAttributeCombinationsOverwrite(t *testing.T, isEmpty bool) {
-	t.Parallel()
-	//Get attribute combinations
-	attributeCombinations := generateCombinations(5, []bool{})
-	//Get overwrite file attribute combinations
-	overwriteCombinations := generateCombinations(5, []bool{})
-
-	fileName := "TestOverwriteFile"
-
-	//Iterate through each attribute combination
-	for _, attr1 := range attributeCombinations {
-
-		fileInfo := NodeInfo{
-			DataStreamInfo: getDataStreamInfo(isEmpty, fileName),
-			parentDir:      "dir",
-			attributes:     getFileAttributes(attr1),
-			Exists:         true,
-		}
-
-		overwriteFileAttributes := []FileAttributes{}
-
-		for _, overwrite := range overwriteCombinations {
-			overwriteFileAttributes = append(overwriteFileAttributes, getFileAttributes(overwrite))
-		}
-
-		//Iterate through each overwrite attribute combination
-		for _, overwriteFileAttr := range overwriteFileAttributes {
-			//Get the test name
-			testName := getCombinationTestName(fileInfo, fileName, overwriteFileAttr)
-
-			//Run test
-			t.Run(testName, func(t *testing.T) {
-				mainFilePath := runAttributeTests(t, fileInfo, overwriteFileAttr)
-
-				verifyFileRestores(isEmpty, mainFilePath, t, fileInfo)
-			})
-		}
-	}
-}
-
-func TestDirAttributeCombinationsOverwrite(t *testing.T) {
-	t.Parallel()
-	//Get attribute combinations
-	attributeCombinations := generateCombinations(4, []bool{})
-	//Get overwrite dir attribute combinations
-	overwriteCombinations := generateCombinations(4, []bool{})
-
-	dirName := "TestOverwriteDir"
-
-	//Iterate through each attribute combination
-	for _, attr1 := range attributeCombinations {
-
-		dirInfo := NodeInfo{
-			DataStreamInfo: DataStreamInfo{
-				name: dirName,
-			},
-			parentDir:   "dir",
-			attributes:  getDirFileAttributes(attr1),
-			Exists:      true,
-			IsDirectory: true,
-		}
-
-		overwriteDirFileAttributes := []FileAttributes{}
-
-		for _, overwrite := range overwriteCombinations {
-			overwriteDirFileAttributes = append(overwriteDirFileAttributes, getDirFileAttributes(overwrite))
-		}
-
-		//Iterate through each overwrite attribute combinations
-		for _, overwriteDirAttr := range overwriteDirFileAttributes {
-			//Get the test name
-			testName := getCombinationTestName(dirInfo, dirName, overwriteDirAttr)
-
-			//Run test
-			t.Run(testName, func(t *testing.T) {
-				mainDirPath := runAttributeTests(t, dirInfo, dirInfo.attributes)
-
-				//Check directory exists
-				_, err1 := os.Stat(mainDirPath)
-				rtest.Assert(t, !errors.Is(err1, os.ErrNotExist), "The directory "+dirInfo.name+" does not exist")
-			})
-		}
-	}
-}
diff --git a/internal/test/helpers.go b/internal/test/helpers.go
index 242da6079..65e3e36ec 100644
--- a/internal/test/helpers.go
+++ b/internal/test/helpers.go
@@ -3,7 +3,6 @@ package test
 import (
 	"compress/bzip2"
 	"compress/gzip"
-	"fmt"
 	"io"
 	"os"
 	"os/exec"
@@ -48,22 +47,10 @@ func OKs(tb testing.TB, errs []error) {
 }
 
 // Equals fails the test if exp is not equal to act.
-// msg is optional message to be printed, first param being format string and rest being arguments.
-func Equals(tb testing.TB, exp, act interface{}, msgs ...string) {
+func Equals(tb testing.TB, exp, act interface{}) {
 	tb.Helper()
 	if !reflect.DeepEqual(exp, act) {
-		var msgString string
-		length := len(msgs)
-		if length == 1 {
-			msgString = msgs[0]
-		} else if length > 1 {
-			args := make([]interface{}, length-1)
-			for i, msg := range msgs[1:] {
-				args[i] = msg
-			}
-			msgString = fmt.Sprintf(msgs[0], args...)
-		}
-		tb.Fatalf("\033[31m\n\n\t"+msgString+"\n\n\texp: %#v\n\n\tgot: %#v\033[39m\n\n", exp, act)
+		tb.Fatalf("\033[31m\n\n\texp: %#v\n\n\tgot: %#v\033[39m\n\n", exp, act)
 	}
 }
 
diff --git a/internal/ui/backup/progress_test.go b/internal/ui/backup/progress_test.go
index 79a56c91e..6b242a0f3 100644
--- a/internal/ui/backup/progress_test.go
+++ b/internal/ui/backup/progress_test.go
@@ -33,11 +33,10 @@ func (p *mockPrinter) CompleteItem(messageType string, _ string, _ archiver.Item
 }
 
 func (p *mockPrinter) ReportTotal(_ time.Time, _ archiver.ScanStats) {}
-func (p *mockPrinter) Finish(id restic.ID, _ time.Time, summary *Summary, _ bool) {
+func (p *mockPrinter) Finish(id restic.ID, _ time.Time, _ *archiver.Summary, _ bool) {
 	p.Lock()
 	defer p.Unlock()
 
-	_ = *summary // Should not be nil.
 	p.id = id
 }
 
@@ -64,7 +63,7 @@ func TestProgress(t *testing.T) {
 
 	time.Sleep(10 * time.Millisecond)
 	id := restic.NewRandomID()
-	prog.Finish(id, false)
+	prog.Finish(id, nil, false)
 
 	if !prnt.dirUnchanged {
 		t.Error(`"dir unchanged" event not seen`)