Allow stripping path components during restore

Restores previously always recreated the full original path under the
target directory. For example, restoring /home/work/foo to
/home/restores would result in /home/restores/home/work/foo.

This change adds support for stripping leading path components during
restore, similar to tar’s --strip-components option. Users can now
skip the first N directories when restoring, making it more convenient to place
files directly at the desired target location.
This commit is contained in:
Paulo Saraiva 2026-01-14 16:40:46 +01:00 committed by Paulo Saraiva
parent 9e2d60e28c
commit 79b294012a
5 changed files with 169 additions and 4 deletions

View file

@ -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

View file

@ -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

View file

@ -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())
}

View file

@ -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 <Snapshot of [/home/user/work] at 2015-05-08 21:40:19.884408621 +0200 CEST> 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
----------------------------------

View file

@ -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