From 02cf8e5f2346d0d560392425be8a28bd14668342 Mon Sep 17 00:00:00 2001 From: Michael Eischer Date: Sun, 10 May 2026 20:23:14 +0200 Subject: [PATCH 1/3] backup: prevent exclude of backup targets Track backup targets explicitly specified by the user and prevent excluding them. This for example ensures that `restic backup --exclude-if-present .git /home/user/data` backs up the `data` folder even if there is a `.git` folder in `/home/user`. Note that this does not suffice for commands like `restic backup --exclude data /home/user/data` as the exclude pattern will still match every single file within `data`. --- changelog/unreleased/issue-5767 | 14 +++++ internal/archiver/archiver.go | 27 ++++++---- internal/archiver/archiver_test.go | 22 +++++--- internal/archiver/scanner.go | 11 ++-- internal/archiver/tree.go | 35 ++++++++---- internal/archiver/tree_test.go | 86 +++++++++++++++++------------- 6 files changed, 125 insertions(+), 70 deletions(-) create mode 100644 changelog/unreleased/issue-5767 diff --git a/changelog/unreleased/issue-5767 b/changelog/unreleased/issue-5767 new file mode 100644 index 000000000..d7001f7aa --- /dev/null +++ b/changelog/unreleased/issue-5767 @@ -0,0 +1,14 @@ +Change: Prevent excluding paths explicitly passed to `backup` + +When `restic backup --exclude-if-present .git /home/user/data` was run and +`/home/user/.git` existed, restic excluded the `data` directory from the +snapshot. The same applied to `--exclude-caches` or `--one-file-system`. Similarly, +`restic backup --exclude-larger-than 1M large-file.bin` produced an empty +snapshot when the file was larger than one megabyte. + +The `backup` command now tracks which files and directories were specified on +the command line and does not apply excludes to those paths. Content inside a +backed-up directory is still filtered by excludes as before. + +https://github.com/restic/restic/issues/5767 +https://github.com/restic/restic/pull/21797 diff --git a/internal/archiver/archiver.go b/internal/archiver/archiver.go index ceb4be69e..312119884 100644 --- a/internal/archiver/archiver.go +++ b/internal/archiver/archiver.go @@ -334,7 +334,7 @@ func (arch *Archiver) saveDir(ctx context.Context, snPath string, dir string, me return futureNode{}, err } snItem := join(snPath, name) - fn, excluded, err := arch.save(ctx, snItem, pathname, oldNode) + fn, excluded, err := arch.save(ctx, snItem, pathname, oldNode, false) // return error early if possible if err != nil { @@ -449,7 +449,10 @@ func (arch *Archiver) allBlobsPresent(previous *data.Node) bool { // 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 *data.Node) (fn futureNode, excluded bool, err error) { +// +// explicit is true when this path was a backup target (tree leaf with Explicit +// set); Excludes (`SelectByName` and `Select`) are skipped for that path only. +func (arch *Archiver) save(ctx context.Context, snPath, target string, previous *data.Node, explicit bool) (fn futureNode, excluded bool, err error) { start := time.Now() debug.Log("%v target %q, previous %v", snPath, target, previous) @@ -472,7 +475,7 @@ func (arch *Archiver) save(ctx context.Context, snPath, target string, previous return err } // exclude files by path before running Lstat to reduce number of lstat calls - if !arch.SelectByName(abstarget) { + if !explicit && !arch.SelectByName(abstarget) { debug.Log("%v is excluded by path", target) return futureNode{}, true, nil } @@ -500,7 +503,7 @@ func (arch *Archiver) save(ctx context.Context, snPath, target string, previous // ignore if file disappeared since it was returned by readdir return filterError(filterNotExist(err)) } - if !arch.Select(abstarget, fi, arch.FS) { + if !explicit && !arch.Select(abstarget, fi, arch.FS) { debug.Log("%v is excluded", target) return futureNode{}, true, nil } @@ -694,7 +697,7 @@ func (arch *Archiver) saveTree(ctx context.Context, snPath string, atree *tree, if err != nil { return futureNode{}, 0, err } - fn, excluded, err := arch.save(ctx, pathname, subatree.Path, oldNode) + fn, excluded, err := arch.save(ctx, pathname, subatree.Path, oldNode, subatree.Explicit) if err != nil { err = arch.error(subatree.Path, err) @@ -775,9 +778,12 @@ func (arch *Archiver) dirPathToNode(snPath, target string) (node *data.Node, err // resolveRelativeTargets replaces targets that only contain relative // directories ("." or "../../") with the contents of the directory. Each // element of target is processed with fs.Clean(). -func resolveRelativeTargets(filesys fs.FS, targets []string) ([]string, error) { +// +// Paths returned with Explicit true are those the user listed literally; paths +// inserted from directory expansion have Explicit false. +func resolveRelativeTargets(filesys fs.FS, targets []string) ([]BackupTarget, error) { debug.Log("targets before resolving: %v", targets) - result := make([]string, 0, len(targets)) + result := make([]BackupTarget, 0, len(targets)) for _, target := range targets { if target != "" && filesys.VolumeName(target) == target { // special case to allow users to also specify a volume name "C:" instead of a path "C:\" @@ -787,7 +793,7 @@ func resolveRelativeTargets(filesys fs.FS, targets []string) ([]string, error) { } pc, _ := pathComponents(filesys, target, false) if len(pc) > 0 { - result = append(result, target) + result = append(result, BackupTarget{Path: target, Explicit: true}) continue } @@ -799,7 +805,10 @@ func resolveRelativeTargets(filesys fs.FS, targets []string) ([]string, error) { sort.Strings(entries) for _, name := range entries { - result = append(result, filesys.Join(target, name)) + result = append(result, BackupTarget{ + Path: filesys.Join(target, name), + Explicit: false, + }) } } diff --git a/internal/archiver/archiver_test.go b/internal/archiver/archiver_test.go index b13734655..7238b2935 100644 --- a/internal/archiver/archiver_test.go +++ b/internal/archiver/archiver_test.go @@ -223,7 +223,7 @@ func TestArchiverSave(t *testing.T) { wg, ctx := errgroup.WithContext(ctx) arch.runWorkers(ctx, wg, uploader) - node, excluded, err := arch.save(ctx, "/", filepath.Join(tempdir, "file"), nil) + node, excluded, err := arch.save(ctx, "/", filepath.Join(tempdir, "file"), nil, false) if err != nil { t.Fatal(err) } @@ -300,7 +300,7 @@ func TestArchiverSaveReaderFS(t *testing.T) { wg, ctx := errgroup.WithContext(ctx) arch.runWorkers(ctx, wg, uploader) - node, excluded, err := arch.save(ctx, "/", filename, nil) + node, excluded, err := arch.save(ctx, "/", filename, nil, false) t.Logf("Save returned %v %v", node, err) if err != nil { t.Fatal(err) @@ -1106,7 +1106,11 @@ func TestArchiverSaveTree(t *testing.T) { wg, ctx := errgroup.WithContext(ctx) arch.runWorkers(ctx, wg, uploader) - atree, err := newTree(testFS, test.targets) + bt, err := resolveRelativeTargets(testFS, test.targets) + if err != nil { + t.Fatal(err) + } + atree, err := newTree(testFS, bt) if err != nil { t.Fatal(err) } @@ -1485,7 +1489,11 @@ func TestResolveRelativeTargetsSpecial(t *testing.T) { targets, err := resolveRelativeTargets(&fs.Local{}, test.targets) rtest.OK(t, err) - rtest.Equals(t, test.expected, targets) + paths := make([]string, len(targets)) + for i, tgt := range targets { + paths[i] = tgt.Path + } + rtest.Equals(t, test.expected, paths) }) } } @@ -2456,7 +2464,7 @@ func TestRacyFileTypeSwap(t *testing.T) { arch.runWorkers(ctx, wg, uploader) // fs.Track will panic if the file was not closed - _, excluded, err := arch.save(ctx, "/", tempfile, nil) + _, excluded, err := arch.save(ctx, "/", tempfile, nil, false) rtest.Assert(t, err != nil && strings.Contains(err.Error(), "changed type, refusing to archive"), "save() returned wrong error: %v", err) tpe := "file" if dirError { @@ -2545,7 +2553,7 @@ func TestIrregularFile(t *testing.T) { defer cancel() arch := New(repo, fs.Track{FS: override}, Options{}) - _, excluded, err := arch.save(ctx, "/", tempfile, nil) + _, excluded, err := arch.save(ctx, "/", tempfile, nil, false) if err == nil { t.Fatalf("Save() should have failed") } @@ -2595,7 +2603,7 @@ func TestDisappearedFile(t *testing.T) { // the subsequent file.Stat() call. Thus test both cases. for _, errorOnOpen := range []bool{false, true} { arch := New(repo, fs.Track{FS: &missingFS{FS: &fs.Local{}, errorOnOpen: errorOnOpen}}, Options{}) - _, excluded, err := arch.save(ctx, "/", filepath.Join(tempdir, "testdir"), nil) + _, excluded, err := arch.save(ctx, "/", filepath.Join(tempdir, "testdir"), nil, false) rtest.OK(t, err) rtest.Assert(t, excluded, "testfile should have been excluded") } diff --git a/internal/archiver/scanner.go b/internal/archiver/scanner.go index 2e6b7210c..bcfda3c63 100644 --- a/internal/archiver/scanner.go +++ b/internal/archiver/scanner.go @@ -44,7 +44,7 @@ func (s *Scanner) scanTree(ctx context.Context, stats ScanStats, tree tree) (Sca return ScanStats{}, err } - stats, err = s.scan(ctx, stats, abstarget) + stats, err = s.scan(ctx, stats, abstarget, tree.Explicit) if err != nil { return ScanStats{}, err } @@ -96,13 +96,14 @@ func (s *Scanner) Scan(ctx context.Context, targets []string) error { return nil } -func (s *Scanner) scan(ctx context.Context, stats ScanStats, target string) (ScanStats, error) { +// explicit is true when this path was an explicit backup target (same meaning as tree.Explicit on a leaf). +func (s *Scanner) scan(ctx context.Context, stats ScanStats, target string, explicit bool) (ScanStats, error) { if ctx.Err() != nil { return stats, nil } // exclude files by path before running stat to reduce number of lstat calls - if !s.SelectByName(target) { + if !explicit && !s.SelectByName(target) { return stats, nil } @@ -113,7 +114,7 @@ func (s *Scanner) scan(ctx context.Context, stats ScanStats, target string) (Sca } // run remaining select functions that require file information - if !s.Select(target, fi, s.FS) { + if !explicit && !s.Select(target, fi, s.FS) { return stats, nil } @@ -129,7 +130,7 @@ func (s *Scanner) scan(ctx context.Context, stats ScanStats, target string) (Sca sort.Strings(names) for _, name := range names { - stats, err = s.scan(ctx, stats, s.FS.Join(target, name)) + stats, err = s.scan(ctx, stats, s.FS.Join(target, name), false) if err != nil { return stats, err } diff --git a/internal/archiver/tree.go b/internal/archiver/tree.go index f4eb1abde..c7700665a 100644 --- a/internal/archiver/tree.go +++ b/internal/archiver/tree.go @@ -25,6 +25,7 @@ type tree struct { Path string // where the files/dirs to be saved are found FileInfoPath string // where the dir can be found that is not included itself, but its subdirs Root string // parent directory of the tree + Explicit bool // Explicit is true when Path was passed as a backup target } // pathComponents returns all path components of p. If a virtual directory @@ -94,8 +95,9 @@ func rootDirectory(fs fs.FS, target string) string { return rel } -// Add adds a new file or directory to the tree. -func (t *tree) Add(fs fs.FS, path string) error { +// Add adds a new file or directory to the tree. explicit marks a path the user +// named as a backup target (see BackupTarget.Explicit). +func (t *tree) Add(fs fs.FS, path string, explicit bool) error { if path == "" { panic("invalid path (empty string)") } @@ -138,13 +140,14 @@ func (t *tree) Add(fs fs.FS, path string) error { // use the original root dir if this is a virtual directory (volume name on Windows) subroot = root } - err := tree.add(fs, path, subroot, pc[1:]) + err := tree.add(fs, path, subroot, pc[1:], explicit) if err != nil { return err } tree.FileInfoPath = subroot } else { tree.Path = path + tree.Explicit = explicit } t.Nodes[name] = tree @@ -152,7 +155,7 @@ func (t *tree) Add(fs fs.FS, path string) error { } // add adds a new target path into the tree. -func (t *tree) add(fs fs.FS, target, root string, pc []string) error { +func (t *tree) add(fs fs.FS, target, root string, pc []string, explicit bool) error { if len(pc) == 0 { return errors.Errorf("invalid path %q", target) } @@ -167,7 +170,7 @@ func (t *tree) add(fs fs.FS, target, root string, pc []string) error { node, ok := t.Nodes[name] if !ok { - t.Nodes[name] = tree{Path: target} + t.Nodes[name] = tree{Path: target, Explicit: explicit} return nil } @@ -175,6 +178,7 @@ func (t *tree) add(fs fs.FS, target, root string, pc []string) error { return errors.Errorf("path is already set for target %v", target) } node.Path = target + node.Explicit = explicit t.Nodes[name] = node return nil } @@ -187,7 +191,7 @@ func (t *tree) add(fs fs.FS, target, root string, pc []string) error { subroot := fs.Join(root, name) node.FileInfoPath = subroot - err := node.add(fs, target, subroot, pc[1:]) + err := node.add(fs, target, subroot, pc[1:], explicit) if err != nil { return err } @@ -221,7 +225,7 @@ func (t tree) NodeNames() []string { // formatTree returns a text representation of the tree t. func formatTree(t tree, indent string) (s string) { for name, node := range t.Nodes { - s += fmt.Sprintf("%v/%v, root %q, path %q, meta %q\n", indent, name, node.Root, node.Path, node.FileInfoPath) + s += fmt.Sprintf("%v/%v, root %q, path %q, meta %q, explicit %v\n", indent, name, node.Root, node.Path, node.FileInfoPath, node.Explicit) s += formatTree(node, indent+" ") } return s @@ -255,6 +259,7 @@ func unrollTree(f fs.FS, t *tree) error { t.Nodes[entry] = tree{Path: f.Join(t.Path, entry)} } t.Path = "" + t.Explicit = false } for i, subtree := range t.Nodes { @@ -269,13 +274,21 @@ func unrollTree(f fs.FS, t *tree) error { return nil } +// BackupTarget is a resolved backup path and whether the user passed this path +// literally. Paths inserted when expanding a target with no path components +// (for example ".") have Explicit false so include/exclude rules still apply. +type BackupTarget struct { + Path string + Explicit bool +} + // newTree creates a Tree from the target files/directories. -func newTree(fs fs.FS, targets []string) (*tree, error) { +func newTree(fs fs.FS, targets []BackupTarget) (*tree, error) { debug.Log("targets: %v", targets) tree := &tree{} seen := make(map[string]struct{}) - for _, target := range targets { - target = fs.Clean(target) + for _, tgt := range targets { + target := fs.Clean(tgt.Path) // skip duplicate targets if _, ok := seen[target]; ok { @@ -283,7 +296,7 @@ func newTree(fs fs.FS, targets []string) (*tree, error) { } seen[target] = struct{}{} - err := tree.Add(fs, target) + err := tree.Add(fs, target, tgt.Explicit) if err != nil { return nil, err } diff --git a/internal/archiver/tree_test.go b/internal/archiver/tree_test.go index c9fe776b1..e1b8a4b36 100644 --- a/internal/archiver/tree_test.go +++ b/internal/archiver/tree_test.go @@ -14,6 +14,14 @@ import ( // debug.Log requires Tree.String. var _ fmt.Stringer = tree{} +func testBackupTargets(paths []string) []BackupTarget { + tgts := make([]BackupTarget, len(paths)) + for i, p := range paths { + tgts[i] = BackupTarget{Path: p, Explicit: true} + } + return tgts +} + func TestPathComponents(t *testing.T) { var tests = []struct { p string @@ -150,24 +158,24 @@ func TestTree(t *testing.T) { { targets: []string{"foo"}, want: tree{Nodes: map[string]tree{ - "foo": {Path: "foo", Root: "."}, + "foo": {Path: "foo", Root: ".", Explicit: true}, }}, }, { targets: []string{"foo", "bar", "baz"}, want: tree{Nodes: map[string]tree{ - "foo": {Path: "foo", Root: "."}, - "bar": {Path: "bar", Root: "."}, - "baz": {Path: "baz", Root: "."}, + "foo": {Path: "foo", Root: ".", Explicit: true}, + "bar": {Path: "bar", Root: ".", Explicit: true}, + "baz": {Path: "baz", Root: ".", Explicit: true}, }}, }, { targets: []string{"foo/user1", "foo/user2", "foo/other"}, want: tree{Nodes: map[string]tree{ "foo": {Root: ".", FileInfoPath: "foo", Nodes: map[string]tree{ - "user1": {Path: filepath.FromSlash("foo/user1")}, - "user2": {Path: filepath.FromSlash("foo/user2")}, - "other": {Path: filepath.FromSlash("foo/other")}, + "user1": {Path: filepath.FromSlash("foo/user1"), Explicit: true}, + "user2": {Path: filepath.FromSlash("foo/user2"), Explicit: true}, + "other": {Path: filepath.FromSlash("foo/other"), Explicit: true}, }}, }}, }, @@ -176,8 +184,8 @@ func TestTree(t *testing.T) { want: tree{Nodes: map[string]tree{ "foo": {Root: ".", FileInfoPath: "foo", Nodes: map[string]tree{ "work": {FileInfoPath: filepath.FromSlash("foo/work"), Nodes: map[string]tree{ - "user1": {Path: filepath.FromSlash("foo/work/user1")}, - "user2": {Path: filepath.FromSlash("foo/work/user2")}, + "user1": {Path: filepath.FromSlash("foo/work/user1"), Explicit: true}, + "user2": {Path: filepath.FromSlash("foo/work/user2"), Explicit: true}, }}, }}, }}, @@ -186,25 +194,25 @@ func TestTree(t *testing.T) { targets: []string{"foo/user1", "bar/user1", "foo/other"}, want: tree{Nodes: map[string]tree{ "foo": {Root: ".", FileInfoPath: "foo", Nodes: map[string]tree{ - "user1": {Path: filepath.FromSlash("foo/user1")}, - "other": {Path: filepath.FromSlash("foo/other")}, + "user1": {Path: filepath.FromSlash("foo/user1"), Explicit: true}, + "other": {Path: filepath.FromSlash("foo/other"), Explicit: true}, }}, "bar": {Root: ".", FileInfoPath: "bar", Nodes: map[string]tree{ - "user1": {Path: filepath.FromSlash("bar/user1")}, + "user1": {Path: filepath.FromSlash("bar/user1"), Explicit: true}, }}, }}, }, { targets: []string{"../work"}, want: tree{Nodes: map[string]tree{ - "work": {Root: "..", Path: filepath.FromSlash("../work")}, + "work": {Root: "..", Path: filepath.FromSlash("../work"), Explicit: true}, }}, }, { targets: []string{"../work/other"}, want: tree{Nodes: map[string]tree{ "work": {Root: "..", FileInfoPath: filepath.FromSlash("../work"), Nodes: map[string]tree{ - "other": {Path: filepath.FromSlash("../work/other")}, + "other": {Path: filepath.FromSlash("../work/other"), Explicit: true}, }}, }}, }, @@ -212,11 +220,11 @@ func TestTree(t *testing.T) { targets: []string{"foo/user1", "../work/other", "foo/user2"}, want: tree{Nodes: map[string]tree{ "foo": {Root: ".", FileInfoPath: "foo", Nodes: map[string]tree{ - "user1": {Path: filepath.FromSlash("foo/user1")}, - "user2": {Path: filepath.FromSlash("foo/user2")}, + "user1": {Path: filepath.FromSlash("foo/user1"), Explicit: true}, + "user2": {Path: filepath.FromSlash("foo/user2"), Explicit: true}, }}, "work": {Root: "..", FileInfoPath: filepath.FromSlash("../work"), Nodes: map[string]tree{ - "other": {Path: filepath.FromSlash("../work/other")}, + "other": {Path: filepath.FromSlash("../work/other"), Explicit: true}, }}, }}, }, @@ -224,11 +232,11 @@ func TestTree(t *testing.T) { targets: []string{"foo/user1", "../foo/other", "foo/user2"}, want: tree{Nodes: map[string]tree{ "foo": {Root: ".", FileInfoPath: "foo", Nodes: map[string]tree{ - "user1": {Path: filepath.FromSlash("foo/user1")}, - "user2": {Path: filepath.FromSlash("foo/user2")}, + "user1": {Path: filepath.FromSlash("foo/user1"), Explicit: true}, + "user2": {Path: filepath.FromSlash("foo/user2"), Explicit: true}, }}, "foo-1": {Root: "..", FileInfoPath: filepath.FromSlash("../foo"), Nodes: map[string]tree{ - "other": {Path: filepath.FromSlash("../foo/other")}, + "other": {Path: filepath.FromSlash("../foo/other"), Explicit: true}, }}, }}, }, @@ -245,8 +253,8 @@ func TestTree(t *testing.T) { Root: ".", FileInfoPath: "foo", Nodes: map[string]tree{ - "file": {Path: filepath.FromSlash("foo/file")}, - "work": {Path: filepath.FromSlash("foo/work")}, + "file": {Path: filepath.FromSlash("foo/file"), Explicit: false}, + "work": {Path: filepath.FromSlash("foo/work"), Explicit: true}, }, }, }}, @@ -266,8 +274,8 @@ func TestTree(t *testing.T) { Root: ".", FileInfoPath: "foo", Nodes: map[string]tree{ - "file": {Path: filepath.FromSlash("foo/file")}, - "work": {Path: filepath.FromSlash("foo/work")}, + "file": {Path: filepath.FromSlash("foo/file"), Explicit: false}, + "work": {Path: filepath.FromSlash("foo/work"), Explicit: true}, }, }, }}, @@ -287,8 +295,8 @@ func TestTree(t *testing.T) { "work": { FileInfoPath: filepath.FromSlash("foo/work"), Nodes: map[string]tree{ - "user1": {Path: filepath.FromSlash("foo/work/user1")}, - "user2": {Path: filepath.FromSlash("foo/work/user2")}, + "user1": {Path: filepath.FromSlash("foo/work/user1"), Explicit: false}, + "user2": {Path: filepath.FromSlash("foo/work/user2"), Explicit: true}, }, }, }}, @@ -308,8 +316,8 @@ func TestTree(t *testing.T) { "foo": {Root: ".", FileInfoPath: "foo", Nodes: map[string]tree{ "work": {FileInfoPath: filepath.FromSlash("foo/work"), Nodes: map[string]tree{ - "user1": {Path: filepath.FromSlash("foo/work/user1")}, - "user2": {Path: filepath.FromSlash("foo/work/user2")}, + "user1": {Path: filepath.FromSlash("foo/work/user1"), Explicit: false}, + "user2": {Path: filepath.FromSlash("foo/work/user2"), Explicit: true}, }, }, }}, @@ -334,16 +342,17 @@ func TestTree(t *testing.T) { targets: []string{"foo/work/user2/data/secret", "foo"}, want: tree{Nodes: map[string]tree{ "foo": {Root: ".", FileInfoPath: "foo", Nodes: map[string]tree{ - "other": {Path: filepath.FromSlash("foo/other")}, + "other": {Path: filepath.FromSlash("foo/other"), Explicit: false}, "work": {FileInfoPath: filepath.FromSlash("foo/work"), Nodes: map[string]tree{ "user2": {FileInfoPath: filepath.FromSlash("foo/work/user2"), Nodes: map[string]tree{ "data": {FileInfoPath: filepath.FromSlash("foo/work/user2/data"), Nodes: map[string]tree{ "secret": { - Path: filepath.FromSlash("foo/work/user2/data/secret"), + Path: filepath.FromSlash("foo/work/user2/data/secret"), + Explicit: true, }, }}, }}, - "user3": {Path: filepath.FromSlash("foo/work/user3")}, + "user3": {Path: filepath.FromSlash("foo/work/user3"), Explicit: false}, }}, }}, }}, @@ -373,11 +382,12 @@ func TestTree(t *testing.T) { "driveA": {FileInfoPath: filepath.FromSlash("mnt/driveA"), Nodes: map[string]tree{ "work": {FileInfoPath: filepath.FromSlash("mnt/driveA/work"), Nodes: map[string]tree{ "driveB": { - Path: filepath.FromSlash("mnt/driveA/work/driveB"), + Path: filepath.FromSlash("mnt/driveA/work/driveB"), + Explicit: true, }, - "test1": {Path: filepath.FromSlash("mnt/driveA/work/test1")}, + "test1": {Path: filepath.FromSlash("mnt/driveA/work/test1"), Explicit: false}, }}, - "test2": {Path: filepath.FromSlash("mnt/driveA/test2")}, + "test2": {Path: filepath.FromSlash("mnt/driveA/test2"), Explicit: false}, }}, }}, }}, @@ -387,7 +397,7 @@ func TestTree(t *testing.T) { want: tree{Nodes: map[string]tree{ "foo": {Root: ".", FileInfoPath: "foo", Nodes: map[string]tree{ "work": {FileInfoPath: filepath.FromSlash("foo/work"), Nodes: map[string]tree{ - "user": {Path: filepath.FromSlash("foo/work/user")}, + "user": {Path: filepath.FromSlash("foo/work/user"), Explicit: true}, }}, }}, }}, @@ -397,7 +407,7 @@ func TestTree(t *testing.T) { want: tree{Nodes: map[string]tree{ "foo": {Root: ".", FileInfoPath: "foo", Nodes: map[string]tree{ "work": {FileInfoPath: filepath.FromSlash("foo/work"), Nodes: map[string]tree{ - "user": {Path: filepath.FromSlash("foo/work/user")}, + "user": {Path: filepath.FromSlash("foo/work/user"), Explicit: true}, }}, }}, }}, @@ -409,7 +419,7 @@ func TestTree(t *testing.T) { "c": {Root: `c:\`, FileInfoPath: `c:\`, Nodes: map[string]tree{ "users": {FileInfoPath: `c:\users`, Nodes: map[string]tree{ "foobar": {FileInfoPath: `c:\users\foobar`, Nodes: map[string]tree{ - "temp": {Path: `c:\users\foobar\temp`}, + "temp": {Path: `c:\users\foobar\temp`, Explicit: true}, }}, }}, }}, @@ -445,7 +455,7 @@ func TestTree(t *testing.T) { back := rtest.Chdir(t, tempdir) defer back() - tree, err := newTree(fs.Local{}, test.targets) + tree, err := newTree(fs.Local{}, testBackupTargets(test.targets)) if test.mustError { if err == nil { t.Fatal("expected error, got nil") From 32bcd92f60ab7ccfa030b0ed0d4be0fe7b829e9c Mon Sep 17 00:00:00 2001 From: Michael Eischer Date: Sun, 10 May 2026 21:06:43 +0200 Subject: [PATCH 2/3] archiver: test that explicit backup paths ignore excludes --- internal/archiver/archiver_test.go | 90 ++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/internal/archiver/archiver_test.go b/internal/archiver/archiver_test.go index 7238b2935..104818910 100644 --- a/internal/archiver/archiver_test.go +++ b/internal/archiver/archiver_test.go @@ -1646,6 +1646,96 @@ func TestArchiverSnapshotSelect(t *testing.T) { } } +// TestArchiverExplicitBackupTarget checks that tree.Explicit (paths the user +// listed literally, after resolveRelativeTargets) skips Select/SelectByName for +// that path only, while descendants and expanded targets still obey Select. +func TestArchiverExplicitBackupTarget(t *testing.T) { + includeExceptTxtFiles := func(item string, fi *fs.ExtendedFileInfo, _ fs.FS) bool { + if fi.Mode.IsDir() { + return true + } + return filepath.Ext(item) != ".txt" + } + + var tests = []struct { + name string + src TestDir + targets []string + want TestDir + selFn SelectFunc + }{ + { + name: "explicit-file-skips-select-for-that-path", + src: TestDir{ + "important.txt": TestFile{Content: "keep me"}, + }, + targets: []string{filepath.FromSlash("important.txt")}, + want: TestDir{ + "important.txt": TestFile{Content: "keep me"}, + }, + selFn: includeExceptTxtFiles, + }, + { + name: "explicit-dir-children-still-filtered", + src: TestDir{ + "vault": TestDir{ + "keep.bin": TestFile{Content: "bin"}, + "skip.txt": TestFile{Content: "txt"}, + }, + }, + targets: []string{"vault"}, + want: TestDir{ + "vault": TestDir{ + "keep.bin": TestFile{Content: "bin"}, + }, + }, + selFn: includeExceptTxtFiles, + }, + { + name: "expanded-paths-from-dot-stay-filtered", + src: TestDir{ + "work": TestDir{ + "a.txt": TestFile{Content: "a"}, + "b.bin": TestFile{Content: "b"}, + }, + "noise.txt": TestFile{Content: "n"}, + }, + targets: []string{"."}, + want: TestDir{ + "work": TestDir{ + "b.bin": TestFile{Content: "b"}, + }, + }, + selFn: includeExceptTxtFiles, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + tempdir, repo := prepareTempdirRepoSrc(t, test.src) + + arch := New(repo, fs.Track{FS: fs.Local{}}, Options{}) + arch.Select = test.selFn + + back := rtest.Chdir(t, tempdir) + defer back() + + _, snapshotID, _, err := arch.Snapshot(ctx, test.targets, SnapshotOptions{Time: time.Now()}) + if err != nil { + t.Fatal(err) + } + + t.Logf("saved as %v", snapshotID.Str()) + + TestEnsureSnapshot(t, repo, snapshotID, test.want) + checker.TestCheckRepo(t, repo) + }) + } +} + // MockFS keeps track which files are read. type MockFS struct { fs.FS From 7e8f9a9221e8277b9ed1f4f123bc0127827c4adb Mon Sep 17 00:00:00 2001 From: Michael Eischer Date: Sun, 10 May 2026 21:15:36 +0200 Subject: [PATCH 3/3] document excludes for explicit backup sources --- doc/040_backup.rst | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/doc/040_backup.rst b/doc/040_backup.rst index c34670d19..8b4f2011e 100644 --- a/doc/040_backup.rst +++ b/doc/040_backup.rst @@ -355,6 +355,15 @@ the exclude options are: Please see ``restic help backup`` for more specific information about each exclude option. +.. note:: + + Excludes do not apply to backup sources that were explicitly passed to the `backup` + command. That is ``restic backup ~/work.txt`` will always backup the file ``~/work.txt`` + independent of any excludes. + + This only applies to the exact paths that were explicitly passed to the `backup` command. + Content inside a directory you back up is still filtered by the given excludes. + Suppose you have a file called ``excludes.txt`` with the following content: ::