Repository: restic/restic
Pull request #5129 (2024-11-04 18:30:40)

Allow excluding xattrs at restore time
- also allows including xattrs
- default if no flags are passed include only xattrs in the user namespace (xattrs starting with user.*)

For: https://github.com/restic/restic/issues/5089

<!--
Thank you very much for contributing code or documentation to restic! Please
fill out the following questions to make it easier for us to review your
changes.
-->

What does this PR change? What problem does it solve?
-----------------------------------------------------

This introduces new command line flags for restic restore:  `--exclude-xattr` and `--include-xattr`.  These are mutually exclusive, similar to --exclude and --include.


Was the change previously discussed in an issue or on the forum?
----------------------------------------------------------------

Fixes https://github.com/restic/restic/issues/5089

<!--
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.


tesshuflower: @MichaelEischer Thanks very much for your review - I've had to rebase to get the latest, hopefully I did everything correctly.  Latest updates
- add unit test for xattr select filter
- add changelog entry
- add info in restore doc about the new flags
- set default (for Linux only) to restore only `user.*` xattrs

For the last one is this ok?  Let me know if you would prefer to keep the default of restoring all xattrs for Linux as well.

Patch:
diff --git a/changelog/unreleased/issue-5089 b/changelog/unreleased/issue-5089
new file mode 100644
index 000000000..43c5c8366
--- /dev/null
+++ b/changelog/unreleased/issue-5089
@@ -0,0 +1,13 @@
+Enhancement: Allow including/excluding extended file attributes during restore
+
+Restic restore attempts to restore all extended file attributes.
+Now two new command line flags are added to restore to control which
+extended file attributes will be restored.
+
+The new flags are `--exclude-xattr` and `--include-xattr`.
+
+If the flags are not provided, restic will default to restoring
+all extended file attributes.
+
+https://github.com/restic/restic/issues/5089
+https://github.com/restic/restic/pull/5129
diff --git a/changelog/unreleased/issue-5137 b/changelog/unreleased/issue-5137
deleted file mode 100644
index ba681202c..000000000
--- a/changelog/unreleased/issue-5137
+++ /dev/null
@@ -1,8 +0,0 @@
-Enhancement: Restic tag command returns the modified snapshot information
-
-Restic `tag` command now returns the modified snapshot information in the
-output. Added `--json` option to the command to get the output in JSON format
-for scripting access.
-
-https://github.com/restic/restic/issues/5137
-https://github.com/restic/restic/pull/5144
\ No newline at end of file
diff --git a/cmd/restic/cmd_restore.go b/cmd/restic/cmd_restore.go
index 82dd408a8..7a3b029da 100644
--- a/cmd/restic/cmd_restore.go
+++ b/cmd/restic/cmd_restore.go
@@ -54,11 +54,13 @@ type RestoreOptions struct {
 	filter.IncludePatternOptions
 	Target string
 	restic.SnapshotFilter
-	DryRun    bool
-	Sparse    bool
-	Verify    bool
-	Overwrite restorer.OverwriteBehavior
-	Delete    bool
+	DryRun              bool
+	Sparse              bool
+	Verify              bool
+	Overwrite           restorer.OverwriteBehavior
+	Delete              bool
+	ExcludeXattrPattern []string
+	IncludeXattrPattern []string
 }
 
 var restoreOptions RestoreOptions
@@ -72,6 +74,9 @@ func init() {
 	restoreOptions.ExcludePatternOptions.Add(flags)
 	restoreOptions.IncludePatternOptions.Add(flags)
 
+	flags.StringArrayVar(&restoreOptions.ExcludeXattrPattern, "exclude-xattr", nil, "exclude xattr by `pattern` (can be specified multiple times)")
+	flags.StringArrayVar(&restoreOptions.IncludeXattrPattern, "include-xattr", nil, "include xattr by `pattern` (can be specified multiple times)")
+
 	initSingleSnapshotFilter(flags, &restoreOptions.SnapshotFilter)
 	flags.BoolVar(&restoreOptions.DryRun, "dry-run", false, "do not write any data, just show what would be done")
 	flags.BoolVar(&restoreOptions.Sparse, "sparse", false, "restore files as sparse")
@@ -110,6 +115,7 @@ func runRestore(ctx context.Context, opts RestoreOptions, gopts GlobalOptions,
 	if hasExcludes && hasIncludes {
 		return errors.Fatal("exclude and include patterns are mutually exclusive")
 	}
+
 	if opts.DryRun && opts.Verify {
 		return errors.Fatal("--dry-run and --verify are mutually exclusive")
 	}
@@ -219,6 +225,11 @@ func runRestore(ctx context.Context, opts RestoreOptions, gopts GlobalOptions,
 		res.SelectFilter = selectIncludeFilter
 	}
 
+	res.XattrSelectFilter, err = getXattrSelectFilter(opts)
+	if err != nil {
+		return err
+	}
+
 	if !gopts.JSON {
 		msg.P("restoring %s to %s\n", res.Snapshot(), opts.Target)
 	}
@@ -257,3 +268,38 @@ func runRestore(ctx context.Context, opts RestoreOptions, gopts GlobalOptions,
 
 	return nil
 }
+
+func getXattrSelectFilter(opts RestoreOptions) (func(xattrName string) bool, error) {
+	hasXattrExcludes := len(opts.ExcludeXattrPattern) > 0
+	hasXattrIncludes := len(opts.IncludeXattrPattern) > 0
+
+	if hasXattrExcludes && hasXattrIncludes {
+		return nil, errors.Fatal("exclude and include xattr patterns are mutually exclusive")
+	}
+
+	if hasXattrExcludes {
+		if err := filter.ValidatePatterns(opts.ExcludeXattrPattern); err != nil {
+			return nil, errors.Fatalf("--exclude-xattr: %s", err)
+		}
+
+		return func(xattrName string) bool {
+			shouldReject := filter.RejectByPattern(opts.ExcludeXattrPattern, Warnf)(xattrName)
+			return !shouldReject
+		}, nil
+	}
+
+	if hasXattrIncludes {
+		// User has either input include xattr pattern(s) or we're using our default include pattern
+		if err := filter.ValidatePatterns(opts.IncludeXattrPattern); err != nil {
+			return nil, errors.Fatalf("--include-xattr: %s", err)
+		}
+
+		return func(xattrName string) bool {
+			shouldInclude, _ := filter.IncludeByPattern(opts.IncludeXattrPattern, Warnf)(xattrName)
+			return shouldInclude
+		}, nil
+	}
+
+	// default to including all xattrs
+	return func(_ string) bool { return true }, nil
+}
diff --git a/cmd/restic/cmd_tag.go b/cmd/restic/cmd_tag.go
index f71e2556c..539a0cc59 100644
--- a/cmd/restic/cmd_tag.go
+++ b/cmd/restic/cmd_tag.go
@@ -9,8 +9,6 @@ import (
 	"github.com/restic/restic/internal/errors"
 	"github.com/restic/restic/internal/repository"
 	"github.com/restic/restic/internal/restic"
-	"github.com/restic/restic/internal/ui"
-	"github.com/restic/restic/internal/ui/termstatus"
 )
 
 var cmdTag = &cobra.Command{
@@ -36,9 +34,7 @@ Exit status is 12 if the password is incorrect.
 	GroupID:           cmdGroupDefault,
 	DisableAutoGenTag: true,
 	RunE: func(cmd *cobra.Command, args []string) error {
-		term, cancel := setupTermstatus()
-		defer cancel()
-		return runTag(cmd.Context(), tagOptions, globalOptions, term, args)
+		return runTag(cmd.Context(), tagOptions, globalOptions, args)
 	},
 }
 
@@ -62,18 +58,7 @@ func init() {
 	initMultiSnapshotFilter(tagFlags, &tagOptions.SnapshotFilter, true)
 }
 
-type changedSnapshot struct {
-	MessageType   string    `json:"message_type"` // changed
-	OldSnapshotID restic.ID `json:"old_snapshot_id"`
-	NewSnapshotID restic.ID `json:"new_snapshot_id"`
-}
-
-type changedSnapshotsSummary struct {
-	MessageType      string `json:"message_type"` // summary
-	ChangedSnapshots int    `json:"changed_snapshots"`
-}
-
-func changeTags(ctx context.Context, repo *repository.Repository, sn *restic.Snapshot, setTags, addTags, removeTags []string, printFunc func(changedSnapshot)) (bool, error) {
+func changeTags(ctx context.Context, repo *repository.Repository, sn *restic.Snapshot, setTags, addTags, removeTags []string) (bool, error) {
 	var changed bool
 
 	if len(setTags) != 0 {
@@ -102,7 +87,7 @@ func changeTags(ctx context.Context, repo *repository.Repository, sn *restic.Sna
 			return false, err
 		}
 
-		debug.Log("old snapshot %v saved as a new snapshot %v", sn.ID(), id)
+		debug.Log("new snapshot saved as %v", id)
 
 		// Remove the old snapshot.
 		if err = repo.RemoveUnpacked(ctx, restic.WriteableSnapshotFile, *sn.ID()); err != nil {
@@ -110,13 +95,11 @@ func changeTags(ctx context.Context, repo *repository.Repository, sn *restic.Sna
 		}
 
 		debug.Log("old snapshot %v removed", sn.ID())
-
-		printFunc(changedSnapshot{MessageType: "changed", OldSnapshotID: *sn.ID(), NewSnapshotID: id})
 	}
 	return changed, nil
 }
 
-func runTag(ctx context.Context, opts TagOptions, gopts GlobalOptions, term *termstatus.Terminal, args []string) error {
+func runTag(ctx context.Context, opts TagOptions, gopts GlobalOptions, args []string) error {
 	if len(opts.SetTags) == 0 && len(opts.AddTags) == 0 && len(opts.RemoveTags) == 0 {
 		return errors.Fatal("nothing to do!")
 	}
@@ -131,44 +114,24 @@ func runTag(ctx context.Context, opts TagOptions, gopts GlobalOptions, term *ter
 	}
 	defer unlock()
 
-	printFunc := func(c changedSnapshot) {
-		Verboseff("old snapshot ID: %v -> new snapshot ID: %v\n", c.OldSnapshotID, c.NewSnapshotID)
-	}
-
-	summary := changedSnapshotsSummary{MessageType: "summary", ChangedSnapshots: 0}
-	printSummary := func(c changedSnapshotsSummary) {
-		if c.ChangedSnapshots == 0 {
-			Verbosef("no snapshots were modified\n")
-		} else {
-			Verbosef("modified %v snapshots\n", c.ChangedSnapshots)
-		}
-	}
-
-	if gopts.JSON {
-		printFunc = func(c changedSnapshot) {
-			term.Print(ui.ToJSONString(c))
-		}
-		printSummary = func(c changedSnapshotsSummary) {
-			term.Print(ui.ToJSONString(c))
-		}
-	}
-
+	changeCnt := 0
 	for sn := range FindFilteredSnapshots(ctx, repo, repo, &opts.SnapshotFilter, args) {
-		changed, err := changeTags(ctx, repo, sn, opts.SetTags.Flatten(), opts.AddTags.Flatten(), opts.RemoveTags.Flatten(), printFunc)
+		changed, err := changeTags(ctx, repo, sn, opts.SetTags.Flatten(), opts.AddTags.Flatten(), opts.RemoveTags.Flatten())
 		if err != nil {
 			Warnf("unable to modify the tags for snapshot ID %q, ignoring: %v\n", sn.ID(), err)
 			continue
 		}
 		if changed {
-			summary.ChangedSnapshots++
+			changeCnt++
 		}
 	}
-
 	if ctx.Err() != nil {
 		return ctx.Err()
 	}
-
-	printSummary(summary)
-
+	if changeCnt == 0 {
+		Verbosef("no snapshots were modified\n")
+	} else {
+		Verbosef("modified tags on %v snapshots\n", changeCnt)
+	}
 	return nil
 }
diff --git a/doc/050_restore.rst b/doc/050_restore.rst
index 9558ab1d4..b37f3c4fb 100644
--- a/doc/050_restore.rst
+++ b/doc/050_restore.rst
@@ -88,6 +88,21 @@ disk space. Note that the exact location of the holes can differ from those in
 the original file, as their location is determined while restoring and is not
 stored explicitly.
 
+Restoring extended file attributes
+----------------------------------
+
+By default, all extended attributes for files are restored.
+
+Use only ``--exclude-xattr`` or ``--include-xattr`` to control which extended
+attributes are restored for files in the snapshot. For example, to restore
+user and security namespaced extended attributes for files:
+
+.. code-block:: console
+
+    $ restic -r /srv/restic-repo restore 79766175 --target /tmp/restore-work --include-xattr user.* --include-xattr security.*
+    enter password for repository:
+    restoring <Snapshot of [/home/user/work] at 2015-05-08 21:40:19.884408621 +0200 CEST> to /tmp/restore-work
+
 Restoring in-place
 ------------------
 
diff --git a/doc/075_scripting.rst b/doc/075_scripting.rst
index 57a8e2872..39a6dbc7f 100644
--- a/doc/075_scripting.rst
+++ b/doc/075_scripting.rst
@@ -722,30 +722,6 @@ The stats command returns a single JSON object.
 | ``compression_space_saving`` | Overall space saving due to compression             |
 +------------------------------+-----------------------------------------------------+
 
-tag
----
-
-The ``tag`` command uses the JSON lines format with the following message types.
-
-Changed
-^^^^^^^
-
-+--------------------------+-------------------------------------------+
-| ``message_type``         | Always "changed"                          |
-+--------------------------+-------------------------------------------+
-| ``old_snapshot_id``      | ID of the snapshot before the change      |
-+--------------------------+-------------------------------------------+
-| ``new_snapshot_id``      | ID of the snapshot after the change       |
-+--------------------------+-------------------------------------------+
-
-Summary
-^^^^^^^
-
-+-----------------------------+-------------------------------------------+
-| ``message_type``            | Always "summary"                          |
-+-----------------------------+-------------------------------------------+
-| ``changed_snapshot_count``  | Total number of changed snapshots         |
-+-----------------------------+-------------------------------------------+
 
 version
 -------
diff --git a/internal/fs/node.go b/internal/fs/node.go
index 058d9cc7b..ab2aca957 100644
--- a/internal/fs/node.go
+++ b/internal/fs/node.go
@@ -230,8 +230,8 @@ func mkfifo(path string, mode uint32) (err error) {
 }
 
 // NodeRestoreMetadata restores node metadata
-func NodeRestoreMetadata(node *restic.Node, path string, warn func(msg string)) error {
-	err := nodeRestoreMetadata(node, path, warn)
+func NodeRestoreMetadata(node *restic.Node, path string, warn func(msg string), xattrSelectFilter func(xattrName string) bool) error {
+	err := nodeRestoreMetadata(node, path, warn, xattrSelectFilter)
 	if err != nil {
 		// It is common to have permission errors for folders like /home
 		// unless you're running as root, so ignore those.
@@ -246,14 +246,14 @@ func NodeRestoreMetadata(node *restic.Node, path string, warn func(msg string))
 	return err
 }
 
-func nodeRestoreMetadata(node *restic.Node, path string, warn func(msg string)) error {
+func nodeRestoreMetadata(node *restic.Node, path string, warn func(msg string), xattrSelectFilter func(xattrName string) bool) error {
 	var firsterr error
 
 	if err := lchown(path, int(node.UID), int(node.GID)); err != nil {
 		firsterr = errors.WithStack(err)
 	}
 
-	if err := nodeRestoreExtendedAttributes(node, path); err != nil {
+	if err := nodeRestoreExtendedAttributes(node, path, xattrSelectFilter); err != nil {
 		debug.Log("error restoring extended attributes for %v: %v", path, err)
 		if firsterr == nil {
 			firsterr = err
diff --git a/internal/fs/node_noxattr.go b/internal/fs/node_noxattr.go
index 726827f62..2dbd72c9d 100644
--- a/internal/fs/node_noxattr.go
+++ b/internal/fs/node_noxattr.go
@@ -8,7 +8,7 @@ import (
 )
 
 // nodeRestoreExtendedAttributes is a no-op
-func nodeRestoreExtendedAttributes(_ *restic.Node, _ string) error {
+func nodeRestoreExtendedAttributes(_ *restic.Node, _ string, _ func(xattrName string) bool) error {
 	return nil
 }
 
diff --git a/internal/fs/node_windows.go b/internal/fs/node_windows.go
index 74cf6c0e5..df0a7ea65 100644
--- a/internal/fs/node_windows.go
+++ b/internal/fs/node_windows.go
@@ -69,15 +69,20 @@ func utimesNano(path string, atime, mtime int64, _ restic.NodeType) error {
 }
 
 // restore extended attributes for windows
-func nodeRestoreExtendedAttributes(node *restic.Node, path string) (err error) {
+func nodeRestoreExtendedAttributes(node *restic.Node, path string, xattrSelectFilter func(xattrName string) bool) error {
 	count := len(node.ExtendedAttributes)
 	if count > 0 {
-		eas := make([]extendedAttribute, count)
-		for i, attr := range node.ExtendedAttributes {
-			eas[i] = extendedAttribute{Name: attr.Name, Value: attr.Value}
+		eas := []extendedAttribute{}
+		for _, attr := range node.ExtendedAttributes {
+			// Filter for xattrs we want to include/exclude
+			if xattrSelectFilter(attr.Name) {
+				eas = append(eas, extendedAttribute{Name: attr.Name, Value: attr.Value})
+			}
 		}
-		if errExt := restoreExtendedAttributes(node.Type, path, eas); errExt != nil {
-			return errExt
+		if len(eas) > 0 {
+			if errExt := restoreExtendedAttributes(node.Type, path, eas); errExt != nil {
+				return errExt
+			}
 		}
 	}
 	return nil
diff --git a/internal/fs/node_xattr.go b/internal/fs/node_xattr.go
index 1bdafc575..2a2b5c0fb 100644
--- a/internal/fs/node_xattr.go
+++ b/internal/fs/node_xattr.go
@@ -65,14 +65,17 @@ func handleXattrErr(err error) error {
 	}
 }
 
-func nodeRestoreExtendedAttributes(node *restic.Node, path string) error {
+func nodeRestoreExtendedAttributes(node *restic.Node, path string, xattrSelectFilter func(xattrName string) bool) error {
 	expectedAttrs := map[string]struct{}{}
 	for _, attr := range node.ExtendedAttributes {
-		err := setxattr(path, attr.Name, attr.Value)
-		if err != nil {
-			return err
+		// Only restore xattrs that match the filter
+		if xattrSelectFilter(attr.Name) {
+			err := setxattr(path, attr.Name, attr.Value)
+			if err != nil {
+				return err
+			}
+			expectedAttrs[attr.Name] = struct{}{}
 		}
-		expectedAttrs[attr.Name] = struct{}{}
 	}
 
 	// remove unexpected xattrs
@@ -84,8 +87,11 @@ func nodeRestoreExtendedAttributes(node *restic.Node, path string) error {
 		if _, ok := expectedAttrs[name]; ok {
 			continue
 		}
-		if err := removexattr(path, name); err != nil {
-			return err
+		// Only attempt to remove xattrs that match the filter
+		if xattrSelectFilter(name) {
+			if err := removexattr(path, name); err != nil {
+				return err
+			}
 		}
 	}
 
diff --git a/internal/restorer/restorer.go b/internal/restorer/restorer.go
index 14a8edeac..cce175ebc 100644
--- a/internal/restorer/restorer.go
+++ b/internal/restorer/restorer.go
@@ -31,6 +31,8 @@ type Restorer struct {
 	// SelectFilter determines whether the item is selectedForRestore or whether a childMayBeSelected.
 	// selectedForRestore must not depend on isDir as `removeUnexpectedFiles` always passes false to isDir.
 	SelectFilter func(item string, isDir bool) (selectedForRestore bool, childMayBeSelected bool)
+
+	XattrSelectFilter func(xattrName string) (xattrSelectedForRestore bool)
 }
 
 var restorerAbortOnAllErrors = func(_ string, err error) error { return err }
@@ -97,12 +99,13 @@ func (c *OverwriteBehavior) Type() string {
 // NewRestorer creates a restorer preloaded with the content from the snapshot id.
 func NewRestorer(repo restic.Repository, sn *restic.Snapshot, opts Options) *Restorer {
 	r := &Restorer{
-		repo:         repo,
-		opts:         opts,
-		fileList:     make(map[string]bool),
-		Error:        restorerAbortOnAllErrors,
-		SelectFilter: func(string, bool) (bool, bool) { return true, true },
-		sn:           sn,
+		repo:              repo,
+		opts:              opts,
+		fileList:          make(map[string]bool),
+		Error:             restorerAbortOnAllErrors,
+		SelectFilter:      func(string, bool) (bool, bool) { return true, true },
+		XattrSelectFilter: func(string) bool { return true },
+		sn:                sn,
 	}
 
 	return r
@@ -288,7 +291,7 @@ func (res *Restorer) restoreNodeMetadataTo(node *restic.Node, target, location s
 		return nil
 	}
 	debug.Log("restoreNodeMetadata %v %v %v", node.Name, target, location)
-	err := fs.NodeRestoreMetadata(node, target, res.Warn)
+	err := fs.NodeRestoreMetadata(node, target, res.Warn, res.XattrSelectFilter)
 	if err != nil {
 		debug.Log("node.RestoreMetadata(%s) error %v", target, err)
 	}


Test patch:
diff --git a/cmd/restic/cmd_tag_integration_test.go b/cmd/restic/cmd_tag_integration_test.go
index 53360ca84..6979f9c11 100644
--- a/cmd/restic/cmd_tag_integration_test.go
+++ b/cmd/restic/cmd_tag_integration_test.go
@@ -9,7 +9,7 @@ import (
 )
 
 func testRunTag(t testing.TB, opts TagOptions, gopts GlobalOptions) {
-	rtest.OK(t, runTag(context.TODO(), opts, gopts, nil, []string{}))
+	rtest.OK(t, runTag(context.TODO(), opts, gopts, []string{}))
 }
 
 // nolint: staticcheck // false positive nil pointer dereference check
diff --git a/internal/fs/node_test.go b/internal/fs/node_test.go
index 15f19adc7..490ab7e40 100644
--- a/internal/fs/node_test.go
+++ b/internal/fs/node_test.go
@@ -13,6 +13,7 @@ import (
 	"github.com/google/go-cmp/cmp"
 	"github.com/restic/restic/internal/errors"
 	"github.com/restic/restic/internal/restic"
+	"github.com/restic/restic/internal/test"
 	rtest "github.com/restic/restic/internal/test"
 )
 
@@ -216,7 +217,9 @@ func TestNodeRestoreAt(t *testing.T) {
 				nodePath = filepath.Join(tempdir, test.Name)
 			}
 			rtest.OK(t, NodeCreateAt(&test, nodePath))
-			rtest.OK(t, NodeRestoreMetadata(&test, nodePath, func(msg string) { rtest.OK(t, fmt.Errorf("Warning triggered for path: %s: %s", nodePath, msg)) }))
+			// Restore metadata, restoring all xattrs
+			rtest.OK(t, NodeRestoreMetadata(&test, nodePath, func(msg string) { rtest.OK(t, fmt.Errorf("Warning triggered for path: %s: %s", nodePath, msg)) },
+				func(_ string) bool { return true }))
 
 			fs := &Local{}
 			meta, err := fs.OpenFile(nodePath, O_NOFOLLOW, true)
@@ -291,6 +294,7 @@ func TestNodeRestoreMetadataError(t *testing.T) {
 	nodePath := filepath.Join(tempdir, node.Name)
 
 	// This will fail because the target file does not exist
-	err := NodeRestoreMetadata(node, nodePath, func(msg string) { rtest.OK(t, fmt.Errorf("Warning triggered for path: %s: %s", nodePath, msg)) })
-	rtest.Assert(t, errors.Is(err, os.ErrNotExist), "failed for an unexpected reason")
+	err := NodeRestoreMetadata(node, nodePath, func(msg string) { rtest.OK(t, fmt.Errorf("Warning triggered for path: %s: %s", nodePath, msg)) },
+		func(_ string) bool { return true })
+	test.Assert(t, errors.Is(err, os.ErrNotExist), "failed for an unexpected reason")
 }
diff --git a/internal/fs/node_windows_test.go b/internal/fs/node_windows_test.go
index f75df54d3..458a7bcb1 100644
--- a/internal/fs/node_windows_test.go
+++ b/internal/fs/node_windows_test.go
@@ -218,7 +218,7 @@ func restoreAndGetNode(t *testing.T, tempDir string, testNode *restic.Node, warn
 			// If warning is not expected, this code should not get triggered.
 			test.OK(t, fmt.Errorf("Warning triggered for path: %s: %s", testPath, msg))
 		}
-	})
+	}, func(_ string) bool { return true })
 	test.OK(t, errors.Wrapf(err, "Failed to restore metadata for: %s", testPath))
 
 	fs := &Local{}
diff --git a/internal/fs/node_xattr_all_test.go b/internal/fs/node_xattr_all_test.go
index 81c931e24..6a9a2e4bf 100644
--- a/internal/fs/node_xattr_all_test.go
+++ b/internal/fs/node_xattr_all_test.go
@@ -4,12 +4,14 @@
 package fs
 
 import (
+	"bytes"
 	"os"
 	"path/filepath"
 	"runtime"
 	"strings"
 	"testing"
 
+	"github.com/restic/restic/internal/filter"
 	"github.com/restic/restic/internal/restic"
 	rtest "github.com/restic/restic/internal/test"
 )
@@ -26,7 +28,8 @@ func setAndVerifyXattr(t *testing.T, file string, attrs []restic.ExtendedAttribu
 		Type:               restic.NodeTypeFile,
 		ExtendedAttributes: attrs,
 	}
-	rtest.OK(t, nodeRestoreExtendedAttributes(node, file))
+	/* restore all xattrs */
+	rtest.OK(t, nodeRestoreExtendedAttributes(node, file, func(_ string) bool { return true }))
 
 	nodeActual := &restic.Node{
 		Type: restic.NodeTypeFile,
@@ -36,6 +39,53 @@ func setAndVerifyXattr(t *testing.T, file string, attrs []restic.ExtendedAttribu
 	rtest.Assert(t, nodeActual.Equals(*node), "xattr mismatch got %v expected %v", nodeActual.ExtendedAttributes, node.ExtendedAttributes)
 }
 
+func setAndVerifyXattrWithSelectFilter(t *testing.T, file string, testAttr []testXattrToRestore, xattrSelectFilter func(_ string) bool) {
+	attrs := make([]restic.ExtendedAttribute, len(testAttr))
+	for i := range testAttr {
+		// windows seems to convert the xattr name to upper case
+		if runtime.GOOS == "windows" {
+			testAttr[i].xattr.Name = strings.ToUpper(testAttr[i].xattr.Name)
+		}
+		attrs[i] = testAttr[i].xattr
+	}
+
+	node := &restic.Node{
+		Type:               restic.NodeTypeFile,
+		ExtendedAttributes: attrs,
+	}
+
+	rtest.OK(t, nodeRestoreExtendedAttributes(node, file, xattrSelectFilter))
+
+	nodeActual := &restic.Node{
+		Type: restic.NodeTypeFile,
+	}
+	rtest.OK(t, nodeFillExtendedAttributes(nodeActual, file, false))
+
+	// Check nodeActual to make sure only xattrs we expect are there
+	for _, testAttr := range testAttr {
+		xattrFound := false
+		xattrRestored := false
+		for _, restoredAttr := range nodeActual.ExtendedAttributes {
+			if restoredAttr.Name == testAttr.xattr.Name {
+				xattrFound = true
+				xattrRestored = bytes.Equal(restoredAttr.Value, testAttr.xattr.Value)
+				break
+			}
+		}
+		if testAttr.shouldRestore {
+			rtest.Assert(t, xattrFound, "xattr %s not restored", testAttr.xattr.Name)
+			rtest.Assert(t, xattrRestored, "xattr %v value not restored", testAttr.xattr)
+		} else {
+			rtest.Assert(t, !xattrFound, "xattr %v should not have been restored", testAttr.xattr)
+		}
+	}
+}
+
+type testXattrToRestore struct {
+	xattr         restic.ExtendedAttribute
+	shouldRestore bool
+}
+
 func TestOverwriteXattr(t *testing.T) {
 	dir := t.TempDir()
 	file := filepath.Join(dir, "file")
@@ -55,3 +105,97 @@ func TestOverwriteXattr(t *testing.T) {
 		},
 	})
 }
+
+func uppercaseOnWindows(patterns []string) []string {
+	// windows seems to convert the xattr name to upper case
+	if runtime.GOOS == "windows" {
+		out := []string{}
+		for _, pattern := range patterns {
+			out = append(out, strings.ToUpper(pattern))
+		}
+		return out
+	}
+	return patterns
+}
+
+func TestOverwriteXattrWithSelectFilter(t *testing.T) {
+	dir := t.TempDir()
+	file := filepath.Join(dir, "file2")
+	rtest.OK(t, os.WriteFile(file, []byte("hello world"), 0o600))
+
+	noopWarnf := func(_ string, _ ...interface{}) {}
+
+	// Set a filter as if the user passed in --include-xattr user.*
+	xattrSelectFilter1 := func(xattrName string) bool {
+		shouldInclude, _ := filter.IncludeByPattern(uppercaseOnWindows([]string{"user.*"}), noopWarnf)(xattrName)
+		return shouldInclude
+	}
+
+	setAndVerifyXattrWithSelectFilter(t, file, []testXattrToRestore{
+		{
+			xattr: restic.ExtendedAttribute{
+				Name:  "user.foo",
+				Value: []byte("bar"),
+			},
+			shouldRestore: true,
+		},
+		{
+			xattr: restic.ExtendedAttribute{
+				Name:  "user.test",
+				Value: []byte("testxattr"),
+			},
+			shouldRestore: true,
+		},
+		{
+			xattr: restic.ExtendedAttribute{
+				Name:  "security.other",
+				Value: []byte("testing"),
+			},
+			shouldRestore: false,
+		},
+	}, xattrSelectFilter1)
+
+	// Set a filter as if the user passed in --include-xattr user.*
+	xattrSelectFilter2 := func(xattrName string) bool {
+		shouldInclude, _ := filter.IncludeByPattern(uppercaseOnWindows([]string{"user.o*", "user.comm*"}), noopWarnf)(xattrName)
+		return shouldInclude
+	}
+
+	setAndVerifyXattrWithSelectFilter(t, file, []testXattrToRestore{
+		{
+			xattr: restic.ExtendedAttribute{
+				Name:  "user.other",
+				Value: []byte("some"),
+			},
+			shouldRestore: true,
+		},
+		{
+			xattr: restic.ExtendedAttribute{
+				Name:  "security.other",
+				Value: []byte("testing"),
+			},
+			shouldRestore: false,
+		},
+		{
+			xattr: restic.ExtendedAttribute{
+				Name:  "user.open",
+				Value: []byte("door"),
+			},
+			shouldRestore: true,
+		},
+		{
+			xattr: restic.ExtendedAttribute{
+				Name:  "user.common",
+				Value: []byte("testing"),
+			},
+			shouldRestore: true,
+		},
+		{
+			xattr: restic.ExtendedAttribute{
+				Name:  "user.bad",
+				Value: []byte("dontincludeme"),
+			},
+			shouldRestore: false,
+		},
+	}, xattrSelectFilter2)
+}