diff --git a/changelog/unreleased/issue-5639 b/changelog/unreleased/issue-5639 new file mode 100644 index 000000000..00033e817 --- /dev/null +++ b/changelog/unreleased/issue-5639 @@ -0,0 +1,10 @@ +Enhancement: Allow stripping directories when restoring + +Restic restore always restored the full backed up path to a target location. +For example restoring `/home/work/foo` to `/home/restores` would create `/home/restores/home/work/foo`. +Restic now, much like `tar` permits trimming the structure on restore allowing to skip first N levels of directories using the `--strip-components` option. +This way you can now restore `/home/work/foo` to `/home/restores/foo` using `--strip-components=2`. +Pathnames with fewer elements will be silently skipped. + +https://github.com/restic/restic/issues/5639 +https://github.com/restic/restic/pull/5681 diff --git a/cmd/restic/cmd_restore.go b/cmd/restic/cmd_restore.go index 49b72c3f9..3c870ac40 100644 --- a/cmd/restic/cmd_restore.go +++ b/cmd/restic/cmd_restore.go @@ -73,6 +73,7 @@ type RestoreOptions struct { ExcludeXattrPattern []string IncludeXattrPattern []string OwnershipByName bool + StripComponents int } func (opts *RestoreOptions) AddFlags(f *pflag.FlagSet) { @@ -90,6 +91,7 @@ func (opts *RestoreOptions) AddFlags(f *pflag.FlagSet) { f.BoolVar(&opts.Verify, "verify", false, "verify restored files content") f.Var(&opts.Overwrite, "overwrite", "overwrite behavior, one of (always|if-changed|if-newer|never)") f.BoolVar(&opts.Delete, "delete", false, "delete files from target directory if they do not exist in snapshot. Use '--dry-run -vv' to check what would be deleted") + f.IntVar(&opts.StripComponents, "strip-components", 0, "number of leading path components to strip when restoring") if runtime.GOOS != "windows" { f.BoolVar(&opts.OwnershipByName, "ownership-by-name", false, "restore file ownership by user name and group name (except POSIX ACLs)") } @@ -178,6 +180,7 @@ func runRestore(ctx context.Context, opts RestoreOptions, gopts global.Options, Overwrite: opts.Overwrite, Delete: opts.Delete, OwnershipByName: opts.OwnershipByName, + StripComponents: opts.StripComponents, }) totalErrors := 0 diff --git a/cmd/restic/cmd_restore_integration_test.go b/cmd/restic/cmd_restore_integration_test.go index 47d611d8b..3eee6ccb7 100644 --- a/cmd/restic/cmd_restore_integration_test.go +++ b/cmd/restic/cmd_restore_integration_test.go @@ -75,6 +75,15 @@ func testRunRestoreExcludesFromFile(t testing.TB, gopts global.Options, dir stri rtest.OK(t, testRunRestoreAssumeFailure(t, snapshotID.String(), opts, gopts)) } +func testRunRestoreStripComponents(t testing.TB, gopts global.Options, dir string, snapshotID string, stripComponents int) { + opts := RestoreOptions{ + Target: dir, + StripComponents: stripComponents, + } + + rtest.OK(t, testRunRestoreAssumeFailure(t, snapshotID, opts, gopts)) +} + func TestRestoreMustFailWhenUsingBothIncludesAndExcludes(t *testing.T) { env, cleanup := withTestEnvironment(t) defer cleanup() @@ -416,3 +425,127 @@ func TestRestoreDefaultLayout(t *testing.T) { rtest.RemoveAll(t, filepath.Join(env.base, "repo")) rtest.RemoveAll(t, target) } + +func TestRestoreStripComponents(t *testing.T) { + testfiles := []struct { + path string + size uint + }{ + {"subdir1/subdir2/file1.txt", 100}, + {"subdir1/file2.txt", 150}, + {"file3.txt", 200}, + } + + env, cleanup := withTestEnvironment(t) + defer cleanup() + + testRunInit(t, env.gopts) + + for _, testFile := range testfiles { + fullPath := filepath.Join(env.testdata, testFile.path) + rtest.OK(t, os.MkdirAll(filepath.Dir(fullPath), 0755)) + rtest.OK(t, appendRandomData(fullPath, testFile.size)) + } + + opts := BackupOptions{} + + testRunBackup(t, filepath.Dir(env.testdata), []string{filepath.Base(env.testdata)}, opts, env.gopts) + testRunCheck(t, env.gopts) + + snapshotID := testListSnapshots(t, env.gopts, 1)[0] + + testCases := []struct { + stripComponents int + expectedFiles []struct { + path string + size uint + } + nonExistentDirs []string + }{ + { + stripComponents: 0, + expectedFiles: []struct { + path string + size uint + }{ + {"testdata/subdir1/subdir2/file1.txt", 100}, + {"testdata/subdir1/file2.txt", 150}, + {"testdata/file3.txt", 200}, + }, + nonExistentDirs: []string{}, + }, + { + stripComponents: 1, + expectedFiles: []struct { + path string + size uint + }{ + {"subdir1/subdir2/file1.txt", 100}, + {"subdir1/file2.txt", 150}, + {"file3.txt", 200}, + }, + nonExistentDirs: []string{"testdata"}, + }, + { + stripComponents: 2, + expectedFiles: []struct { + path string + size uint + }{ + {"subdir2/file1.txt", 100}, + {"file2.txt", 150}, + }, + nonExistentDirs: []string{"testdata", "subdir1"}, + }, + { + stripComponents: 3, + expectedFiles: []struct { + path string + size uint + }{ + {"file1.txt", 100}, + }, + nonExistentDirs: []string{"testdata", "subdir1", "subdir2"}, + }, + } + + for i, tc := range testCases { + restoredir := filepath.Join(env.base, fmt.Sprintf("restore%d", i)) + testRunRestoreStripComponents(t, env.gopts, restoredir, snapshotID.String(), tc.stripComponents) + for _, testFile := range tc.expectedFiles { + restoredFilePath := filepath.Join(restoredir, testFile.path) + rtest.OK(t, testFileSize(restoredFilePath, int64(testFile.size))) + } + for _, dir := range tc.nonExistentDirs { + _, err := os.Stat(filepath.Join(restoredir, dir)) + rtest.Assert(t, os.IsNotExist(err), "%s directory should not exist when stripping %d components", dir, tc.stripComponents) + } + } +} +func TestRestoreStripComponentsMetadata(t *testing.T) { + env, cleanup := withTestEnvironment(t) + defer cleanup() + + testRunInit(t, env.gopts) + + testDir := filepath.Join(env.testdata, "testdir") + rtest.OK(t, os.MkdirAll(testDir, 0755)) + rtest.OK(t, os.Chmod(testDir, 0777)) + + testFile := filepath.Join(testDir, "file.txt") + rtest.OK(t, appendRandomData(testFile, 100)) + + opts := BackupOptions{} + testRunBackup(t, filepath.Dir(env.testdata), []string{filepath.Base(env.testdata)}, opts, env.gopts) + testRunCheck(t, env.gopts) + + snapshotID := testListSnapshots(t, env.gopts, 1)[0] + + restoredir := filepath.Join(env.base, "restore") + testRunRestoreStripComponents(t, env.gopts, restoredir, snapshotID.String(), 1) + + restoredTestDir := filepath.Join(restoredir, "testdir") + fi, err := os.Stat(restoredTestDir) + rtest.OK(t, err) + rtest.Assert(t, fi.Mode().Perm() == 0777, "directory permissions should be restored, expected 0777, got %v", fi.Mode().Perm()) +} diff --git a/doc/050_restore.rst b/doc/050_restore.rst index 980fa7b3d..874fe1676 100644 --- a/doc/050_restore.rst +++ b/doc/050_restore.rst @@ -88,6 +88,17 @@ 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. +Much like ``tar`` restic has a ``--strip-components`` option to remove leading path elements +when restoring files. For example, to restore everything below the ``work`` directory +and place it directly into the target directory, use: +.. code-block:: console + + $ restic -r /srv/restic-repo restore 79766175 --target /tmp/restore-work --strip-components 1 --include /work/foo + enter password for repository: + restoring to /tmp/restore-work + +This will restore the file ``foo`` to ``/tmp/restore-work/foo``. Note that pathnames with fewer elements than specified will be silently skipped. + Restoring extended file attributes ---------------------------------- diff --git a/internal/restorer/restorer.go b/internal/restorer/restorer.go index 22ab196a5..a6cb1bd0e 100644 --- a/internal/restorer/restorer.go +++ b/internal/restorer/restorer.go @@ -46,6 +46,7 @@ type Options struct { Overwrite OverwriteBehavior Delete bool OwnershipByName bool + StripComponents int } type OverwriteBehavior int @@ -143,7 +144,7 @@ func (res *Restorer) traverseTree(ctx context.Context, target string, treeID res return err } } - childFilenames, hasRestored, err := res.traverseTreeInner(ctx, target, location, treeID, visitor) + childFilenames, hasRestored, err := res.traverseTreeInner(ctx, target, location, treeID, visitor, res.opts.StripComponents) if err != nil { return err } @@ -154,7 +155,7 @@ func (res *Restorer) traverseTree(ctx context.Context, target string, treeID res return err } -func (res *Restorer) traverseTreeInner(ctx context.Context, target, location string, treeID restic.ID, visitor treeVisitor) (filenames []string, hasRestored bool, err error) { +func (res *Restorer) traverseTreeInner(ctx context.Context, target, location string, treeID restic.ID, visitor treeVisitor, stripComponents int) (filenames []string, hasRestored bool, err error) { debug.Log("%v %v %v", target, location, treeID) tree, err := data.LoadTree(ctx, res.repo, treeID) if err != nil { @@ -215,6 +216,13 @@ func (res *Restorer) traverseTreeInner(ctx context.Context, target, location str selectedForRestore, childMayBeSelected := res.SelectFilter(nodeLocation, node.Type == data.NodeTypeDir) debug.Log("SelectFilter returned %v %v for %q", selectedForRestore, childMayBeSelected, nodeLocation) + skipNode := stripComponents > 0 + if skipNode { + nodeTarget = target + nodeLocation = location + selectedForRestore = false + } + if selectedForRestore { hasRestored = true } @@ -237,7 +245,7 @@ func (res *Restorer) traverseTreeInner(ctx context.Context, target, location str var childFilenames []string if childMayBeSelected { - childFilenames, childHasRestored, err = res.traverseTreeInner(ctx, nodeTarget, nodeLocation, *node.Subtree, visitor) + childFilenames, childHasRestored, err = res.traverseTreeInner(ctx, nodeTarget, nodeLocation, *node.Subtree, visitor, stripComponents-1) err = res.sanitizeError(nodeLocation, err) if err != nil { return nil, hasRestored, err @@ -250,7 +258,7 @@ func (res *Restorer) traverseTreeInner(ctx context.Context, target, location str // metadata need to be restore when leaving the directory in both cases // selected for restore or any child of any subtree have been restored - if (selectedForRestore || childHasRestored) && visitor.leaveDir != nil { + if (selectedForRestore || childHasRestored) && !skipNode && visitor.leaveDir != nil { err = res.sanitizeError(nodeLocation, visitor.leaveDir(node, nodeTarget, nodeLocation, childFilenames)) if err != nil { return nil, hasRestored, err