mirror of
https://github.com/kopia/kopia.git
synced 2026-05-11 00:04:46 -04:00
913 lines
27 KiB
Go
913 lines
27 KiB
Go
package endtoend_test
|
|
|
|
import (
|
|
"archive/tar"
|
|
"archive/zip"
|
|
"bytes"
|
|
"compress/gzip"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"runtime"
|
|
"strconv"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/alecthomas/kingpin/v2"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/kopia/kopia/cli"
|
|
"github.com/kopia/kopia/fs/localfs"
|
|
"github.com/kopia/kopia/internal/diff"
|
|
"github.com/kopia/kopia/internal/fshasher"
|
|
"github.com/kopia/kopia/internal/iocopy"
|
|
"github.com/kopia/kopia/internal/stat"
|
|
"github.com/kopia/kopia/internal/testlogging"
|
|
"github.com/kopia/kopia/internal/testutil"
|
|
"github.com/kopia/kopia/snapshot/restore"
|
|
"github.com/kopia/kopia/tests/clitestutil"
|
|
"github.com/kopia/kopia/tests/testdirtree"
|
|
"github.com/kopia/kopia/tests/testenv"
|
|
)
|
|
|
|
const (
|
|
windowsOSName = "windows"
|
|
defaultRestoredFilePermission = 0o600
|
|
|
|
overriddenFilePermissions = 0o651
|
|
overriddenDirPermissions = 0o752
|
|
)
|
|
|
|
type fakeRestoreProgress struct {
|
|
mtx sync.Mutex
|
|
invocations []restore.Stats
|
|
flushesCount int
|
|
invocationAfterFlush bool
|
|
}
|
|
|
|
func (p *fakeRestoreProgress) SetCounters(s restore.Stats) {
|
|
p.mtx.Lock()
|
|
defer p.mtx.Unlock()
|
|
|
|
p.invocations = append(p.invocations, s)
|
|
|
|
if p.flushesCount > 0 {
|
|
p.invocationAfterFlush = true
|
|
}
|
|
}
|
|
|
|
func (p *fakeRestoreProgress) Flush() {
|
|
p.flushesCount++
|
|
}
|
|
|
|
func TestRestoreCommand(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
runner := testenv.NewInProcRunner(t)
|
|
e := testenv.NewCLITest(t, testenv.RepoFormatNotImportant, runner)
|
|
|
|
e.RunAndExpectSuccess(t, "repo", "create", "filesystem", "--path", e.RepoDir)
|
|
|
|
source := filepath.Join(testutil.TempDirectory(t), "source")
|
|
testdirtree.MustCreateDirectoryTree(t, source, testdirtree.DirectoryTreeOptions{
|
|
Depth: 1,
|
|
MaxFilesPerDirectory: 10,
|
|
MaxSymlinksPerDirectory: 4,
|
|
NonExistingSymlinkTargetPercentage: 50,
|
|
})
|
|
|
|
r1 := testutil.TempDirectory(t)
|
|
// Attempt to restore a snapshot from an empty repo.
|
|
e.RunAndExpectFailure(t, "restore", "kffbb7c28ea6c34d6cbe555d1cf80faa9", r1)
|
|
e.RunAndExpectSuccess(t, "snapshot", "create", source)
|
|
|
|
// obtain snapshot root id and use it for restore
|
|
si := clitestutil.ListSnapshotsAndExpectSuccess(t, e, source)
|
|
if got, want := len(si), 1; got != want {
|
|
t.Fatalf("got %v sources, wanted %v", got, want)
|
|
}
|
|
|
|
if got, want := len(si[0].Snapshots), 1; got != want {
|
|
t.Fatalf("got %v snapshots, wanted %v", got, want)
|
|
}
|
|
|
|
snapID := si[0].Snapshots[0].SnapshotID
|
|
rootID := si[0].Snapshots[0].ObjectID
|
|
|
|
r2 := testutil.TempDirectory(t)
|
|
// Attempt to restore a non-existing snapshot.
|
|
e.RunAndExpectFailure(t, "restore", "kffbb7c28ea6c34d6cbe555d1cf80fdd9", r2)
|
|
|
|
// Ensure restored files are created with a different ModTime
|
|
time.Sleep(time.Second)
|
|
|
|
// Attempt to restore using snapshot ID
|
|
restoreFailDir := testutil.TempDirectory(t)
|
|
|
|
// Remember original app cusomization
|
|
origCustomizeApp := runner.CustomizeApp
|
|
|
|
// Prepare fake restore progress and set it when needed
|
|
frp := &fakeRestoreProgress{}
|
|
|
|
runner.CustomizeApp = func(a *cli.App, kp *kingpin.Application) {
|
|
origCustomizeApp(a, kp)
|
|
a.SetRestoreProgress(frp)
|
|
}
|
|
|
|
e.RunAndExpectSuccess(t, "restore", snapID, restoreFailDir, "--progress-update-interval", "1ms")
|
|
|
|
runner.CustomizeApp = origCustomizeApp
|
|
|
|
// Expecting progress to be reported multiple times and flush to be invoked at the end
|
|
require.Greater(t, len(frp.invocations), 2, "expected multiple reports of progress")
|
|
require.Equal(t, 1, frp.flushesCount, "expected to have progress flushed once")
|
|
require.False(t, frp.invocationAfterFlush, "expected not to have reports after flush")
|
|
|
|
// Restore last snapshot
|
|
restoreDir := testutil.TempDirectory(t)
|
|
e.RunAndExpectSuccess(t, "restore", rootID, restoreDir)
|
|
|
|
// Note: restore does not reset the permissions for the top directory due to
|
|
// the way the top FS entry is created in snapshotfs. Force the permissions
|
|
// of the top directory to match those of the source so the recursive
|
|
// directory comparison has a chance of succeeding.
|
|
require.NoError(t, os.Chmod(restoreDir, 0o700))
|
|
compareDirs(t, source, restoreDir)
|
|
|
|
// Attempt to restore into a target directory that already exists
|
|
e.RunAndExpectFailure(t, "restore", rootID, restoreDir, "--no-overwrite-directories")
|
|
|
|
// Very quick incremental restore where all files already exist.
|
|
// Look for status output that indicates files were skipped.
|
|
re := regexp.MustCompile(`Restored (\d+) files.*skipped (\d+) `)
|
|
foundStatus := false
|
|
lastFileCount := 0
|
|
_, stderr := e.RunAndExpectSuccessWithErrOut(t, "restore", rootID, restoreDir, "--skip-existing")
|
|
|
|
for _, l := range stderr {
|
|
if m := re.FindStringSubmatch(l); m != nil {
|
|
fileCount, _ := strconv.Atoi(m[1])
|
|
skippedCount, _ := strconv.Atoi(m[2])
|
|
lastFileCount = fileCount
|
|
|
|
if fileCount == 0 && skippedCount > 0 {
|
|
foundStatus = true
|
|
}
|
|
}
|
|
}
|
|
|
|
if !foundStatus {
|
|
t.Fatalf("expected status line indicating files were skipped, none found: %v", stderr)
|
|
}
|
|
|
|
if lastFileCount != 0 {
|
|
t.Fatalf("not all files were skipped: %v", stderr)
|
|
}
|
|
|
|
// Attempt to restore into a target directory that already exists
|
|
e.RunAndExpectFailure(t, "restore", rootID, restoreDir, "--no-overwrite-files")
|
|
}
|
|
|
|
func compareDirs(t *testing.T, source, restoreDir string) {
|
|
t.Helper()
|
|
|
|
// Restored contents should match source
|
|
s, err := localfs.Directory(source)
|
|
require.NoError(t, err)
|
|
wantHash, err := fshasher.Hash(testlogging.Context(t), s)
|
|
require.NoError(t, err)
|
|
|
|
// check restored contents
|
|
r, err := localfs.Directory(restoreDir)
|
|
require.NoError(t, err)
|
|
|
|
ctx := testlogging.Context(t)
|
|
gotHash, err := fshasher.Hash(ctx, r)
|
|
require.NoError(t, err)
|
|
|
|
if !assert.Equal(t, wantHash, gotHash, "restored directory hash does not match source's hash") {
|
|
cmp, err := diff.NewComparer(os.Stderr)
|
|
require.NoError(t, err)
|
|
|
|
cmp.DiffCommand = "cmp"
|
|
cmp.Compare(ctx, s, r)
|
|
}
|
|
}
|
|
|
|
func TestSnapshotRestore(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
runner := testenv.NewInProcRunner(t)
|
|
e := testenv.NewCLITest(t, testenv.RepoFormatNotImportant, runner)
|
|
|
|
defer e.RunAndExpectSuccess(t, "repo", "disconnect")
|
|
|
|
e.RunAndExpectSuccess(t, "repo", "create", "filesystem", "--path", e.RepoDir)
|
|
|
|
source := testutil.TempDirectory(t)
|
|
testdirtree.MustCreateDirectoryTree(t, filepath.Join(source, "subdir1"), testdirtree.MaybeSimplifyFilesystem(testdirtree.DirectoryTreeOptions{
|
|
Depth: 3,
|
|
MaxSubdirsPerDirectory: 3,
|
|
MaxFilesPerDirectory: 3,
|
|
MaxSymlinksPerDirectory: 4,
|
|
NonExistingSymlinkTargetPercentage: 50,
|
|
}))
|
|
testdirtree.MustCreateDirectoryTree(t, filepath.Join(source, "subdir2"), testdirtree.MaybeSimplifyFilesystem(testdirtree.DirectoryTreeOptions{
|
|
Depth: 2,
|
|
MaxSubdirsPerDirectory: 1,
|
|
MaxFilesPerDirectory: 5,
|
|
MaxSymlinksPerDirectory: 4,
|
|
}))
|
|
|
|
// create a file with well-known name.
|
|
f, err := os.Create(filepath.Join(source, "single-file"))
|
|
require.NoError(t, err)
|
|
|
|
fmt.Fprintf(f, "some-data")
|
|
f.Close()
|
|
|
|
// change file permissions to something unique we can test later
|
|
os.Chmod(filepath.Join(source, "single-file"), overriddenFilePermissions)
|
|
os.Chmod(filepath.Join(source, "subdir1"), overriddenDirPermissions)
|
|
|
|
restoreDir := testutil.TempDirectory(t)
|
|
r1 := testutil.TempDirectory(t)
|
|
// Attempt to restore a snapshot from an empty repo.
|
|
e.RunAndExpectFailure(t, "snapshot", "restore", "kffbb7c28ea6c34d6cbe555d1cf80faa9", r1)
|
|
e.RunAndExpectSuccess(t, "snapshot", "create", source)
|
|
|
|
// obtain snapshot root id and use it for restore
|
|
si := clitestutil.ListSnapshotsAndExpectSuccess(t, e, source)
|
|
if got, want := len(si), 1; got != want {
|
|
t.Fatalf("got %v sources, wanted %v", got, want)
|
|
}
|
|
|
|
if got, want := len(si[0].Snapshots), 1; got != want {
|
|
t.Fatalf("got %v snapshots, wanted %v", got, want)
|
|
}
|
|
|
|
snapID := si[0].Snapshots[0].SnapshotID
|
|
rootID := si[0].Snapshots[0].ObjectID
|
|
|
|
// Attempt to restore a non-existing snapshot.
|
|
r2 := testutil.TempDirectory(t)
|
|
e.RunAndExpectFailure(t, "snapshot", "restore", "kffbb7c28ea6c34d6cbe555d1cf80fdd9", r2)
|
|
|
|
// Ensure restored files are created with a different ModTime
|
|
time.Sleep(time.Second)
|
|
|
|
// Attempt to restore snapshot with root ID
|
|
restoreByObjectIDDir := testutil.TempDirectory(t)
|
|
e.RunAndExpectSuccess(t, "snapshot", "restore", rootID, restoreByObjectIDDir)
|
|
|
|
// restore using <root-id>/subdirectory.
|
|
restoreByOIDSubdir := testutil.TempDirectory(t)
|
|
e.RunAndExpectSuccess(t, "snapshot", "restore", rootID+"/subdir1", restoreByOIDSubdir)
|
|
verifyFileMode(t, restoreByOIDSubdir, os.ModeDir|os.FileMode(overriddenDirPermissions))
|
|
|
|
restoreByOIDSubdir2 := testutil.TempDirectory(t)
|
|
|
|
originalDirInfo, err := os.Stat(restoreByOIDSubdir2)
|
|
if err != nil {
|
|
t.Fatalf("unable to get dir permissions: %v", err)
|
|
}
|
|
|
|
e.RunAndExpectSuccess(t, "snapshot", "restore", "--skip-times", "--skip-owners", "--skip-permissions", rootID+"/subdir1", restoreByOIDSubdir2)
|
|
|
|
currentDirPerm, err := os.Stat(restoreByOIDSubdir2)
|
|
if err != nil {
|
|
t.Fatalf("unable to get current dir permissions: %v", err)
|
|
}
|
|
|
|
if currentDirPerm.Mode() != originalDirInfo.Mode() {
|
|
t.Fatalf("dir mode have changed, original %v, current %v", originalDirInfo.Mode(), currentDirPerm.Mode())
|
|
}
|
|
|
|
// current must be always at or after original, if it's not it must have been restored.
|
|
if currentDirPerm.ModTime().Before(originalDirInfo.ModTime()) {
|
|
t.Fatalf("dir ModTime has been restore, original %v, current %v", originalDirInfo.ModTime(), currentDirPerm.ModTime())
|
|
}
|
|
|
|
// TODO(jkowalski): find a way to verify owners, we currently cannot even change it since the test is running as
|
|
// non-root.
|
|
|
|
restoreByOIDFile := testutil.TempDirectory(t)
|
|
|
|
// restoring single file onto a directory fails
|
|
e.RunAndExpectFailure(t, "snapshot", "restore", rootID+"/single-file", restoreByOIDFile)
|
|
|
|
// restoring single file
|
|
e.RunAndExpectSuccess(t, "snapshot", "restore", rootID+"/single-file", filepath.Join(restoreByOIDFile, "output-file"))
|
|
verifyFileMode(t, filepath.Join(restoreByOIDFile, "output-file"), overriddenFilePermissions)
|
|
|
|
// Restore last snapshot using the snapshot ID
|
|
e.RunAndExpectSuccess(t, "snapshot", "restore", snapID, restoreDir)
|
|
|
|
// Restored contents should match source
|
|
compareDirs(t, source, restoreDir)
|
|
|
|
// Check restore idempotency. Repeat the restore into the already-restored directory.
|
|
// If running the test as non-admin on Windows, there may not be sufficient permissions
|
|
// to overwrite the existing files, so skip this check to avoid "Access is denied" errors.
|
|
if runtime.GOOS != windowsOSName {
|
|
e.RunAndExpectSuccess(t, "snapshot", "restore", snapID, restoreDir)
|
|
}
|
|
|
|
// Restored contents should still match source
|
|
compareDirs(t, source, restoreDir)
|
|
|
|
cases := []struct {
|
|
fname string
|
|
args []string
|
|
validator func(t *testing.T, fname string)
|
|
}{
|
|
// auto-detected formats
|
|
{fname: "output.zip", args: nil, validator: verifyValidZipFile},
|
|
{fname: "output.tar", args: nil, validator: verifyValidTarFile},
|
|
{fname: "output.tar.gz", args: nil, validator: verifyValidTarGzipFile},
|
|
{fname: "output.tgz", args: nil, validator: verifyValidTarGzipFile},
|
|
// forced formats
|
|
{fname: "output.nonzip.blah", args: []string{"--mode=zip"}, validator: verifyValidZipFile},
|
|
{fname: "output.nontar.blah", args: []string{"--mode=tar"}, validator: verifyValidTarFile},
|
|
{fname: "output.notargz.blah", args: []string{"--mode=tgz"}, validator: verifyValidTarGzipFile},
|
|
}
|
|
|
|
restoreArchiveDir := testutil.TempDirectory(t)
|
|
|
|
t.Run("modes", func(t *testing.T) {
|
|
for _, tc := range cases {
|
|
t.Run(tc.fname, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fname := filepath.Join(restoreArchiveDir, tc.fname)
|
|
e.RunAndExpectSuccess(t, append([]string{"snapshot", "restore", snapID, fname}, tc.args...)...)
|
|
tc.validator(t, fname)
|
|
})
|
|
}
|
|
})
|
|
|
|
// create a directory whose name ends with '.zip' and override mode to force treating it as directory.
|
|
zipDir := filepath.Join(restoreArchiveDir, "outputdir.zip")
|
|
e.RunAndExpectSuccess(t, "snapshot", "restore", snapID, zipDir, "--mode=local")
|
|
|
|
// verify we got a directory
|
|
st, err := os.Stat(zipDir)
|
|
if err != nil || !st.IsDir() {
|
|
t.Fatalf("unexpected stat() results on output.zip directory %v %v", st, err)
|
|
}
|
|
|
|
// Attempt to restore snapshot with an already-existing target directory
|
|
// It should fail because the directory is not empty
|
|
e.RunAndExpectFailure(t, "snapshot", "restore", "--no-overwrite-directories", snapID, restoreDir)
|
|
|
|
// Attempt to restore snapshot with an already-existing target directory
|
|
// It should fail because target files already exist
|
|
e.RunAndExpectFailure(t, "snapshot", "restore", "--no-overwrite-files", snapID, restoreDir)
|
|
|
|
// Attempt to restore snapshot with an already-existing target directory
|
|
// It should fail because target symlinks already exist
|
|
e.RunAndExpectFailure(t, "snapshot", "restore", "--no-overwrite-symlinks", snapID, restoreDir)
|
|
}
|
|
|
|
func TestRestoreSymlinkWithoutTarget(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
runner := testenv.NewInProcRunner(t)
|
|
e := testenv.NewCLITest(t, testenv.RepoFormatNotImportant, runner)
|
|
|
|
defer e.RunAndExpectSuccess(t, "repo", "disconnect")
|
|
|
|
e.RunAndExpectSuccess(t, "repo", "create", "filesystem", "--path", e.RepoDir)
|
|
|
|
source := testutil.TempDirectory(t)
|
|
|
|
lnk := filepath.Join(source, "lnk")
|
|
|
|
if err := os.Symlink(".no-such-file", lnk); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
e.RunAndExpectSuccess(t, "snapshot", "create", source)
|
|
|
|
// obtain snapshot root id and use it for restore
|
|
si := clitestutil.ListSnapshotsAndExpectSuccess(t, e, source)
|
|
if got, want := len(si), 1; got != want {
|
|
t.Fatalf("got %v sources, wanted %v", got, want)
|
|
}
|
|
|
|
if got, want := len(si[0].Snapshots), 1; got != want {
|
|
t.Fatalf("got %v snapshots, wanted %v", got, want)
|
|
}
|
|
|
|
snapID := si[0].Snapshots[0].SnapshotID
|
|
|
|
restoredDir := testutil.TempDirectory(t)
|
|
e.RunAndExpectSuccess(t, "snapshot", "restore", "--no-ignore-permission-errors", snapID, restoredDir)
|
|
}
|
|
|
|
func TestRestoreSymlinkWithNonSymlinkOverwrite(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
runner := testenv.NewInProcRunner(t)
|
|
e := testenv.NewCLITest(t, testenv.RepoFormatNotImportant, runner)
|
|
|
|
defer e.RunAndExpectSuccess(t, "repo", "disconnect")
|
|
|
|
e.RunAndExpectSuccess(t, "repo", "create", "filesystem", "--path", e.RepoDir)
|
|
|
|
source := testutil.TempDirectory(t)
|
|
|
|
testLinkName := "lnk"
|
|
lnkPath := filepath.Join(source, testLinkName)
|
|
|
|
if err := os.Symlink(".no-such-file", lnkPath); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
e.RunAndExpectSuccess(t, "snapshot", "create", source)
|
|
|
|
// obtain snapshot root id and use it for restore
|
|
si := clitestutil.ListSnapshotsAndExpectSuccess(t, e, source)
|
|
if got, want := len(si), 1; got != want {
|
|
t.Fatalf("got %v sources, wanted %v", got, want)
|
|
}
|
|
|
|
if got, want := len(si[0].Snapshots), 1; got != want {
|
|
t.Fatalf("got %v snapshots, wanted %v", got, want)
|
|
}
|
|
|
|
snapID := si[0].Snapshots[0].SnapshotID
|
|
|
|
restoreDir := testutil.TempDirectory(t)
|
|
|
|
// Make a directory containing a non-symlink entry named the same as the link captured by the snapshot
|
|
dirWithLinkName := filepath.Join(restoreDir, testLinkName)
|
|
|
|
if err := os.Mkdir(dirWithLinkName, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
e.RunAndExpectFailure(t, "snapshot", "restore", snapID, restoreDir)
|
|
}
|
|
|
|
func TestRestoreSnapshotOfSingleFile(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
runner := testenv.NewInProcRunner(t)
|
|
e := testenv.NewCLITest(t, testenv.RepoFormatNotImportant, runner)
|
|
|
|
defer e.RunAndExpectSuccess(t, "repo", "disconnect")
|
|
|
|
e.RunAndExpectSuccess(t, "repo", "create", "filesystem", "--path", e.RepoDir)
|
|
|
|
sourceDir := testutil.TempDirectory(t)
|
|
sourceFile := filepath.Join(sourceDir, "single-file")
|
|
|
|
f, err := os.Create(sourceFile)
|
|
require.NoError(t, err)
|
|
|
|
fmt.Fprintf(f, "some-data")
|
|
f.Close()
|
|
|
|
// set up unique file mode which will be verified later.
|
|
os.Chmod(sourceFile, 0o653)
|
|
|
|
e.RunAndExpectSuccess(t, "snapshot", "create", sourceFile)
|
|
|
|
// obtain snapshot root id and use it for restore
|
|
si := clitestutil.ListSnapshotsAndExpectSuccess(t, e, sourceFile)
|
|
if got, want := len(si), 1; got != want {
|
|
t.Fatalf("got %v sources, wanted %v", got, want)
|
|
}
|
|
|
|
if got, want := len(si[0].Snapshots), 1; got != want {
|
|
t.Fatalf("got %v snapshots, wanted %v", got, want)
|
|
}
|
|
|
|
snapID := si[0].Snapshots[0].SnapshotID
|
|
rootID := si[0].Snapshots[0].ObjectID
|
|
|
|
restoreDir := testutil.TempDirectory(t)
|
|
|
|
// restoring a file to a directory destination fails.
|
|
e.RunAndExpectFailure(t, "snapshot", "restore", snapID, restoreDir)
|
|
|
|
e.RunAndExpectSuccess(t, "snapshot", "restore", snapID, filepath.Join(restoreDir, "restored-1"))
|
|
verifyFileMode(t, filepath.Join(restoreDir, "restored-1"), os.FileMode(0o653))
|
|
|
|
// we can restore using rootID because it's unambiguous
|
|
e.RunAndExpectSuccess(t, "snapshot", "restore", rootID, filepath.Join(restoreDir, "restored-2"))
|
|
verifyFileMode(t, filepath.Join(restoreDir, "restored-2"), os.FileMode(0o653))
|
|
|
|
if runtime.GOOS != windowsOSName {
|
|
overriddenFilePermissions := 0o654 | os.ModeSetuid
|
|
|
|
// change source file permissions and create one more snapshot
|
|
// at this point we will have multiple snapshot manifests with one root but different attributes.
|
|
os.Chmod(sourceFile, overriddenFilePermissions)
|
|
e.RunAndExpectSuccess(t, "snapshot", "create", sourceFile)
|
|
|
|
// when restoring by root Kopia needs to pick which manifest to apply since they are conflicting
|
|
// We're passing --consistent-attributes which causes it to fail, since otherwise we'd restore arbitrary
|
|
// top-level object permissions.
|
|
e.RunAndExpectFailure(t, "snapshot", "restore", rootID, "--consistent-attributes", filepath.Join(restoreDir, "restored-3"))
|
|
|
|
// Without the flag kopia picks the attributes from the latest snapshot.
|
|
e.RunAndExpectSuccess(t, "snapshot", "restore", rootID, filepath.Join(restoreDir, "restored-3"))
|
|
verifyFileMode(t, filepath.Join(restoreDir, "restored-3"), overriddenFilePermissions)
|
|
}
|
|
|
|
// restoring using snapshot ID is unambiguous and always produces file with 0o653
|
|
e.RunAndExpectSuccess(t, "snapshot", "restore", snapID, filepath.Join(restoreDir, "restored-4"))
|
|
verifyFileMode(t, filepath.Join(restoreDir, "restored-4"), os.FileMode(0o653))
|
|
|
|
// skip permissions when restoring, which results in default defaultRestoredFilePermission
|
|
e.RunAndExpectSuccess(t, "snapshot", "restore", rootID, "--skip-permissions", filepath.Join(restoreDir, "restored-5"))
|
|
|
|
verifyFileMode(t, filepath.Join(restoreDir, "restored-5"), defaultRestoredFilePermission)
|
|
}
|
|
|
|
func TestSnapshotSparseRestore(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// The behavior of the Darwin (APFS) is not published, and sparse restores
|
|
// are not supported on Windows. As such, we cannot (reliably) test them here.
|
|
testutil.TestSkipUnlessLinux(t)
|
|
|
|
runner := testenv.NewInProcRunner(t)
|
|
e := testenv.NewCLITest(t, testenv.RepoFormatNotImportant, runner)
|
|
|
|
e.RunAndExpectSuccess(t, "repo", "create", "filesystem", "--path", e.RepoDir)
|
|
|
|
sourceDir := testutil.TempDirectory(t)
|
|
restoreDir := testutil.TempDirectory(t)
|
|
|
|
bufSize := uint64(iocopy.BufSize)
|
|
|
|
blkSize, err := stat.GetBlockSize(restoreDir)
|
|
if err != nil {
|
|
t.Fatalf("error getting disk block size: %v", err)
|
|
}
|
|
|
|
type chunk struct {
|
|
slice []byte
|
|
off uint64
|
|
rep uint64
|
|
}
|
|
|
|
cases := []struct {
|
|
name string
|
|
data []chunk
|
|
trunc uint64 // Truncate source file to this size
|
|
sLog uint64 // Expected logical size of source file
|
|
sPhys uint64 // Expected physical size of source file
|
|
rLog uint64 // Expected logical size of restored file
|
|
rPhys uint64 // Expected physical size of restored file
|
|
}{
|
|
{
|
|
name: "null_file",
|
|
trunc: 0,
|
|
sLog: 0,
|
|
sPhys: 0,
|
|
rLog: 0,
|
|
rPhys: 0,
|
|
},
|
|
{
|
|
name: "empty_file",
|
|
trunc: 3 * bufSize,
|
|
sLog: 3 * bufSize,
|
|
sPhys: 0,
|
|
rLog: 3 * bufSize,
|
|
rPhys: 0,
|
|
},
|
|
{
|
|
name: "blk",
|
|
data: []chunk{
|
|
{slice: []byte("1"), off: 0, rep: blkSize},
|
|
},
|
|
sLog: blkSize,
|
|
sPhys: blkSize,
|
|
rLog: blkSize,
|
|
rPhys: blkSize,
|
|
},
|
|
{
|
|
name: "blk_real_zeros",
|
|
data: []chunk{
|
|
{slice: []byte{0}, off: 0, rep: blkSize},
|
|
},
|
|
sLog: blkSize,
|
|
sPhys: blkSize,
|
|
rLog: blkSize,
|
|
rPhys: 0,
|
|
},
|
|
{
|
|
name: "buf_real_zeros",
|
|
data: []chunk{
|
|
{slice: []byte{0}, off: 0, rep: bufSize},
|
|
},
|
|
sLog: bufSize,
|
|
sPhys: bufSize,
|
|
rLog: bufSize,
|
|
rPhys: 0,
|
|
},
|
|
{
|
|
name: "buf_full",
|
|
data: []chunk{
|
|
{slice: []byte("1"), off: 0, rep: bufSize},
|
|
},
|
|
sLog: bufSize,
|
|
sPhys: bufSize,
|
|
rLog: bufSize,
|
|
rPhys: bufSize,
|
|
},
|
|
{
|
|
name: "buf_trailing_bytes",
|
|
data: []chunk{
|
|
{slice: []byte("1"), off: bufSize - blkSize - 1, rep: 1},
|
|
{slice: []byte("1"), off: bufSize - 1, rep: 1},
|
|
},
|
|
trunc: bufSize,
|
|
sLog: bufSize,
|
|
sPhys: 2 * blkSize,
|
|
rLog: bufSize,
|
|
rPhys: 2 * blkSize,
|
|
},
|
|
{
|
|
name: "buf_trailing_hole",
|
|
data: []chunk{
|
|
{slice: []byte("1"), off: 0, rep: 1},
|
|
},
|
|
trunc: bufSize,
|
|
sLog: bufSize,
|
|
sPhys: blkSize,
|
|
rLog: bufSize,
|
|
rPhys: blkSize,
|
|
},
|
|
{
|
|
name: "buf_hole_aligned",
|
|
data: []chunk{
|
|
{slice: []byte("1"), off: bufSize, rep: blkSize},
|
|
},
|
|
trunc: bufSize + blkSize,
|
|
sLog: bufSize + blkSize,
|
|
sPhys: blkSize,
|
|
rLog: bufSize + blkSize,
|
|
rPhys: blkSize,
|
|
},
|
|
{
|
|
name: "buf_hole_on_buf_boundary",
|
|
data: []chunk{
|
|
{slice: []byte("1"), off: bufSize / 2, rep: bufSize},
|
|
},
|
|
sLog: bufSize * 3 / 2,
|
|
sPhys: bufSize,
|
|
rLog: bufSize * 3 / 2,
|
|
rPhys: bufSize,
|
|
},
|
|
{
|
|
name: "blk_hole_on_blk_boundary",
|
|
data: []chunk{
|
|
{slice: []byte("1"), off: blkSize / 2, rep: blkSize},
|
|
},
|
|
sLog: blkSize * 3 / 2,
|
|
sPhys: blkSize * 2,
|
|
rLog: blkSize * 3 / 2,
|
|
rPhys: blkSize * 2,
|
|
},
|
|
{
|
|
name: "blk_hole_on_buf_boundary",
|
|
data: []chunk{
|
|
{slice: []byte("1"), off: 0, rep: bufSize - (blkSize / 2)},
|
|
{slice: []byte("1"), off: bufSize + (blkSize / 2), rep: blkSize / 2},
|
|
},
|
|
sLog: bufSize + blkSize,
|
|
sPhys: bufSize + blkSize,
|
|
rLog: bufSize + blkSize,
|
|
rPhys: bufSize + blkSize,
|
|
},
|
|
{
|
|
name: "blk_hole_aligned",
|
|
data: []chunk{
|
|
{slice: []byte("1"), off: 0, rep: bufSize},
|
|
{slice: []byte("1"), off: bufSize + blkSize, rep: bufSize - blkSize},
|
|
},
|
|
trunc: 2 * bufSize,
|
|
sLog: 2 * bufSize,
|
|
sPhys: 2*bufSize - blkSize,
|
|
rLog: 2 * bufSize,
|
|
rPhys: 2*bufSize - blkSize,
|
|
},
|
|
{
|
|
name: "blk_alternating_empty",
|
|
data: []chunk{
|
|
{slice: []byte("1"), off: 0, rep: blkSize},
|
|
{slice: []byte("1"), off: 1 * blkSize, rep: blkSize},
|
|
{slice: []byte("1"), off: 4 * blkSize, rep: blkSize},
|
|
{slice: []byte("1"), off: 6 * blkSize, rep: blkSize},
|
|
{slice: []byte("1"), off: 8 * blkSize, rep: blkSize},
|
|
},
|
|
sLog: 9 * blkSize,
|
|
sPhys: 5 * blkSize,
|
|
rLog: 9 * blkSize,
|
|
rPhys: 5 * blkSize,
|
|
},
|
|
{
|
|
name: "blk_alternating_zero",
|
|
data: []chunk{
|
|
{slice: []byte("1"), off: 0, rep: blkSize},
|
|
{slice: []byte{0}, off: blkSize, rep: blkSize},
|
|
{slice: []byte("1"), off: 2 * blkSize, rep: blkSize},
|
|
{slice: []byte{0}, off: 3 * blkSize, rep: blkSize},
|
|
},
|
|
sLog: 4 * blkSize,
|
|
sPhys: 4 * blkSize,
|
|
rLog: 4 * blkSize,
|
|
rPhys: 2 * blkSize,
|
|
},
|
|
}
|
|
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
if c.name == "blk_hole_on_buf_boundary" && runtime.GOARCH == "arm64" {
|
|
t.Skip("skipping on arm64 due to a failure - https://github.com/kopia/kopia/issues/3178")
|
|
}
|
|
|
|
sourceFile := filepath.Join(sourceDir, c.name+"_source")
|
|
|
|
fd, err := os.Create(sourceFile)
|
|
require.NoError(t, err)
|
|
|
|
err = fd.Truncate(int64(c.trunc))
|
|
require.NoError(t, err)
|
|
|
|
for _, d := range c.data {
|
|
fd.WriteAt(bytes.Repeat(d.slice, int(d.rep)), int64(d.off))
|
|
}
|
|
|
|
verifyFileSize(t, sourceFile, c.sLog, c.sPhys)
|
|
e.RunAndExpectSuccess(t, "snapshot", "create", sourceFile)
|
|
|
|
si := clitestutil.ListSnapshotsAndExpectSuccess(t, e, sourceFile)
|
|
require.Len(t, si, 1)
|
|
require.Len(t, si[0].Snapshots, 1)
|
|
|
|
snapID := si[0].Snapshots[0].SnapshotID
|
|
restoreFile := filepath.Join(restoreDir, c.name+"_restore")
|
|
|
|
e.RunAndExpectSuccess(t, "snapshot", "restore", snapID, "--write-sparse-files", restoreFile)
|
|
verifyFileSize(t, restoreFile, c.rLog, c.rPhys)
|
|
})
|
|
}
|
|
}
|
|
|
|
func verifyFileSize(t *testing.T, fname string, logical, physical uint64) {
|
|
t.Helper()
|
|
|
|
st, err := os.Stat(fname)
|
|
require.NoError(t, err)
|
|
|
|
realLogical := uint64(st.Size())
|
|
|
|
require.Equal(t, logical, realLogical)
|
|
|
|
if runtime.GOOS == windowsOSName {
|
|
t.Logf("getting physical file size is not supported on windows")
|
|
return
|
|
}
|
|
|
|
realPhysical, err := stat.GetFileAllocSize(fname)
|
|
require.NoError(t, err)
|
|
|
|
require.Equal(t, physical, realPhysical)
|
|
}
|
|
|
|
func verifyFileMode(t *testing.T, filename string, want os.FileMode) {
|
|
t.Helper()
|
|
|
|
if runtime.GOOS == windowsOSName {
|
|
// do not compare Unix filemodes on Windows.
|
|
return
|
|
}
|
|
|
|
s, err := os.Lstat(filename)
|
|
require.NoError(t, err)
|
|
|
|
// make sure we restored permissions correctly
|
|
if s.Mode() != want {
|
|
t.Fatalf("invalid mode on %v: %v, want %v", filename, s.Mode(), want)
|
|
}
|
|
}
|
|
|
|
func verifyValidZipFile(t *testing.T, fname string) {
|
|
t.Helper()
|
|
|
|
zr, err := zip.OpenReader(fname)
|
|
require.NoError(t, err)
|
|
|
|
zr.Close()
|
|
}
|
|
|
|
func verifyValidTarFile(t *testing.T, fname string) {
|
|
t.Helper()
|
|
|
|
f, err := os.Open(fname)
|
|
require.NoError(t, err)
|
|
|
|
defer f.Close()
|
|
|
|
verifyValidTarReader(t, tar.NewReader(f))
|
|
}
|
|
|
|
func verifyValidTarReader(t *testing.T, tr *tar.Reader) {
|
|
t.Helper()
|
|
|
|
_, err := tr.Next()
|
|
for err == nil {
|
|
_, err = tr.Next()
|
|
}
|
|
|
|
if !errors.Is(err, io.EOF) {
|
|
t.Fatalf("invalid tar file: %v", err)
|
|
}
|
|
}
|
|
|
|
func verifyValidTarGzipFile(t *testing.T, fname string) {
|
|
t.Helper()
|
|
|
|
f, err := os.Open(fname)
|
|
require.NoError(t, err)
|
|
|
|
defer f.Close()
|
|
|
|
gz, err := gzip.NewReader(f)
|
|
require.NoError(t, err)
|
|
|
|
verifyValidTarReader(t, tar.NewReader(gz))
|
|
}
|
|
|
|
func TestSnapshotRestoreByPath(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
runner := testenv.NewInProcRunner(t)
|
|
e := testenv.NewCLITest(t, testenv.RepoFormatNotImportant, runner)
|
|
|
|
defer e.RunAndExpectSuccess(t, "repo", "disconnect")
|
|
|
|
e.RunAndExpectSuccess(t, "repo", "create", "filesystem", "--path", e.RepoDir)
|
|
|
|
source := testutil.TempDirectory(t)
|
|
|
|
// create a file with well-known name.
|
|
f, err := os.Create(filepath.Join(source, "single-file"))
|
|
require.NoError(t, err)
|
|
|
|
fmt.Fprintf(f, "some-data")
|
|
f.Close()
|
|
|
|
// Create snapshot
|
|
e.RunAndExpectSuccess(t, "snapshot", "create", source)
|
|
|
|
// Restore based on source path
|
|
restoreDir := testutil.TempDirectory(t)
|
|
e.RunAndExpectSuccess(t, "snapshot", "restore", source, restoreDir, "--snapshot-time=latest")
|
|
|
|
// Restored contents should match source
|
|
compareDirs(t, source, restoreDir)
|
|
}
|
|
|
|
func TestRestoreByPathWithoutTarget(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
runner := testenv.NewInProcRunner(t)
|
|
e := testenv.NewCLITest(t, testenv.RepoFormatNotImportant, runner)
|
|
|
|
defer e.RunAndExpectSuccess(t, "repo", "disconnect")
|
|
e.RunAndExpectSuccess(t, "repo", "create", "filesystem", "--path", e.RepoDir)
|
|
|
|
srcdir := testutil.TempDirectory(t)
|
|
file := filepath.Join(srcdir, "a.txt")
|
|
originalData := []byte{1, 2, 3}
|
|
|
|
require.NoError(t, os.WriteFile(file, originalData, 0o755))
|
|
|
|
e.RunAndExpectSuccess(t, "snapshot", "create", srcdir)
|
|
|
|
require.NoError(t, os.WriteFile(file, []byte{4, 5, 6, 7}, 0o755))
|
|
|
|
e.RunAndExpectSuccess(t, "restore", srcdir, "--snapshot-time=latest")
|
|
|
|
data, err := os.ReadFile(file)
|
|
|
|
require.NoError(t, err)
|
|
require.Equal(t, originalData, data)
|
|
|
|
// Defaults to latest snapshot time
|
|
e.RunAndExpectSuccess(t, "restore", srcdir)
|
|
}
|