diff --git a/cmd/restic/cmd_restore.go b/cmd/restic/cmd_restore.go index b3f55d959..aea6457bd 100644 --- a/cmd/restic/cmd_restore.go +++ b/cmd/restic/cmd_restore.go @@ -47,6 +47,7 @@ type RestoreOptions struct { includePatternOptions Target string restic.SnapshotFilter + DryRun bool Sparse bool Verify bool Overwrite restorer.OverwriteBehavior @@ -64,6 +65,7 @@ func init() { initIncludePatternOptions(flags, &restoreOptions.includePatternOptions) 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") flags.BoolVar(&restoreOptions.Verify, "verify", false, "verify restored files content") flags.Var(&restoreOptions.Overwrite, "overwrite", "overwrite behavior, one of (always|if-changed|if-newer|never) (default: always)") @@ -99,6 +101,9 @@ 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") + } snapshotIDString := args[0] @@ -140,6 +145,7 @@ func runRestore(ctx context.Context, opts RestoreOptions, gopts GlobalOptions, progress := restoreui.NewProgress(printer, calculateProgressInterval(!gopts.Quiet, gopts.JSON)) res := restorer.NewRestorer(repo, sn, restorer.Options{ + DryRun: opts.DryRun, Sparse: opts.Sparse, Progress: progress, Overwrite: opts.Overwrite, diff --git a/internal/restorer/restorer.go b/internal/restorer/restorer.go index 07356433b..4ecd762b4 100644 --- a/internal/restorer/restorer.go +++ b/internal/restorer/restorer.go @@ -33,6 +33,7 @@ type Restorer struct { var restorerAbortOnAllErrors = func(_ string, err error) error { return err } type Options struct { + DryRun bool Sparse bool Progress *restoreui.Progress Overwrite OverwriteBehavior @@ -220,15 +221,17 @@ func (res *Restorer) traverseTree(ctx context.Context, target, location string, } func (res *Restorer) restoreNodeTo(ctx context.Context, node *restic.Node, target, location string) error { - debug.Log("restoreNode %v %v %v", node.Name, target, location) - if err := fs.Remove(target); err != nil && !errors.Is(err, os.ErrNotExist) { - return errors.Wrap(err, "RemoveNode") - } + if !res.opts.DryRun { + debug.Log("restoreNode %v %v %v", node.Name, target, location) + if err := fs.Remove(target); err != nil && !errors.Is(err, os.ErrNotExist) { + return errors.Wrap(err, "RemoveNode") + } - err := node.CreateAt(ctx, target, res.repo) - if err != nil { - debug.Log("node.CreateAt(%s) error %v", target, err) - return err + err := node.CreateAt(ctx, target, res.repo) + if err != nil { + debug.Log("node.CreateAt(%s) error %v", target, err) + return err + } } res.opts.Progress.AddProgress(location, false, true, 0, 0) @@ -236,6 +239,9 @@ func (res *Restorer) restoreNodeTo(ctx context.Context, node *restic.Node, targe } func (res *Restorer) restoreNodeMetadataTo(node *restic.Node, target, location string) error { + if res.opts.DryRun { + return nil + } debug.Log("restoreNodeMetadata %v %v %v", node.Name, target, location) err := node.RestoreMetadata(target, res.Warn) if err != nil { @@ -245,12 +251,14 @@ func (res *Restorer) restoreNodeMetadataTo(node *restic.Node, target, location s } func (res *Restorer) restoreHardlinkAt(node *restic.Node, target, path, location string) error { - if err := fs.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { - return errors.Wrap(err, "RemoveCreateHardlink") - } - err := fs.Link(target, path) - if err != nil { - return errors.WithStack(err) + if !res.opts.DryRun { + if err := fs.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + return errors.Wrap(err, "RemoveCreateHardlink") + } + err := fs.Link(target, path) + if err != nil { + return errors.WithStack(err) + } } res.opts.Progress.AddProgress(location, false, true, 0, 0) @@ -259,6 +267,10 @@ func (res *Restorer) restoreHardlinkAt(node *restic.Node, target, path, location } func (res *Restorer) ensureDir(target string) error { + if res.opts.DryRun { + return nil + } + fi, err := fs.Lstat(target) if err != nil && !errors.Is(err, os.ErrNotExist) { return fmt.Errorf("failed to check for directory: %w", err) @@ -328,7 +340,12 @@ func (res *Restorer) RestoreTo(ctx context.Context, dst string) error { res.opts.Progress.AddSkippedFile(location, node.Size) } else { res.opts.Progress.AddFile(node.Size) - filerestorer.addFile(location, node.Content, int64(node.Size), matches) + if !res.opts.DryRun { + filerestorer.addFile(location, node.Content, int64(node.Size), matches) + } else { + // immediately mark as completed + res.opts.Progress.AddProgress(location, false, matches == nil, node.Size, node.Size) + } } res.trackFile(location, updateMetadataOnly) return nil @@ -340,9 +357,11 @@ func (res *Restorer) RestoreTo(ctx context.Context, dst string) error { return err } - err = filerestorer.restoreFiles(ctx) - if err != nil { - return err + if !res.opts.DryRun { + err = filerestorer.restoreFiles(ctx) + if err != nil { + return err + } } debug.Log("second pass for %q", dst) diff --git a/internal/restorer/restorer_test.go b/internal/restorer/restorer_test.go index d70f1f162..5eca779c6 100644 --- a/internal/restorer/restorer_test.go +++ b/internal/restorer/restorer_test.go @@ -15,6 +15,7 @@ import ( "time" "github.com/restic/restic/internal/archiver" + "github.com/restic/restic/internal/errors" "github.com/restic/restic/internal/fs" "github.com/restic/restic/internal/repository" "github.com/restic/restic/internal/restic" @@ -1166,3 +1167,32 @@ func TestRestoreIfChanged(t *testing.T) { } } } + +func TestRestoreDryRun(t *testing.T) { + snapshot := Snapshot{ + Nodes: map[string]Node{ + "foo": File{Data: "content: foo\n", Links: 2, Inode: 42}, + "foo2": File{Data: "content: foo\n", Links: 2, Inode: 42}, + "dirtest": Dir{ + Nodes: map[string]Node{ + "file": File{Data: "content: file\n"}, + }, + }, + "link": Symlink{Target: "foo"}, + }, + } + + repo := repository.TestRepository(t) + tempdir := filepath.Join(rtest.TempDir(t), "target") + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + sn, id := saveSnapshot(t, repo, snapshot, noopGetGenericAttributes) + t.Logf("snapshot saved as %v", id.Str()) + + res := NewRestorer(repo, sn, Options{DryRun: true}) + rtest.OK(t, res.RestoreTo(ctx, tempdir)) + + _, err := os.Stat(tempdir) + rtest.Assert(t, errors.Is(err, os.ErrNotExist), "expected no file to be created, got %v", err) +} diff --git a/internal/restorer/restorer_unix_test.go b/internal/restorer/restorer_unix_test.go index 034935c24..2ad28a0f6 100644 --- a/internal/restorer/restorer_unix_test.go +++ b/internal/restorer/restorer_unix_test.go @@ -83,6 +83,14 @@ func (p *printerMock) Finish(s restoreui.State, _ time.Duration) { } func TestRestorerProgressBar(t *testing.T) { + testRestorerProgressBar(t, false) +} + +func TestRestorerProgressBarDryRun(t *testing.T) { + testRestorerProgressBar(t, true) +} + +func testRestorerProgressBar(t *testing.T, dryRun bool) { repo := repository.TestRepository(t) sn, _ := saveSnapshot(t, repo, Snapshot{ @@ -99,7 +107,7 @@ func TestRestorerProgressBar(t *testing.T) { mock := &printerMock{} progress := restoreui.NewProgress(mock, 0) - res := NewRestorer(repo, sn, Options{Progress: progress}) + res := NewRestorer(repo, sn, Options{Progress: progress, DryRun: dryRun}) res.SelectFilter = func(item string, dstpath string, node *restic.Node) (selectedForRestore bool, childMayBeSelected bool) { return true, true }