From aafe56cd6f5430b70fb97932f193bcf4c8fd0d0c Mon Sep 17 00:00:00 2001 From: Ali Dowair Date: Wed, 23 Mar 2022 05:09:50 +0300 Subject: [PATCH] feat(snapshots): support restoring sparse files (#1823) * feat(snapshots): support restoring sparse files This commit implements basic support for restoring sparse files from a snapshot. When specifying "--mode=sparse" in a snapshot restore command, Kopia will make a best effort to make sure the underlying filesystem allocates the minimum amount of blocks needed to persist restored files. In other words, enabling this feature will "force" all restored files to be sparse-blocks of zero bytes in the source file should not be allocated. * Address review comments - Separate sparse option into its own bool flag - Implement sparsefile packagewith copySparse method - Truncate once before writing sparse file - Check error from Truncate - Add unit test for copySparse - Invoke GetBlockSize once per file copy - Remove support for Windows and explain why - Add unit test for stat package Co-authored-by: Dave Smith-Uchida --- cli/command_restore.go | 3 + internal/iocopy/copy.go | 5 +- internal/sparsefile/sparsefile.go | 107 ++++++++++ internal/sparsefile/sparsefile_test.go | 98 +++++++++ internal/stat/stat_bsd.go | 37 ++++ internal/stat/stat_test.go | 44 ++++ internal/stat/stat_unix.go | 37 ++++ internal/stat/stat_windows.go | 21 ++ internal/testutil/testutil.go | 9 + snapshot/restore/local_fs_output.go | 31 ++- tests/end_to_end_test/restore_test.go | 268 +++++++++++++++++++++++++ 11 files changed, 650 insertions(+), 10 deletions(-) create mode 100644 internal/sparsefile/sparsefile.go create mode 100644 internal/sparsefile/sparsefile_test.go create mode 100644 internal/stat/stat_bsd.go create mode 100644 internal/stat/stat_test.go create mode 100644 internal/stat/stat_unix.go create mode 100644 internal/stat/stat_windows.go diff --git a/cli/command_restore.go b/cli/command_restore.go index 8b41ce521..f1760dcb5 100644 --- a/cli/command_restore.go +++ b/cli/command_restore.go @@ -101,6 +101,7 @@ type commandRestore struct { restoreOverwriteDirectories bool restoreOverwriteFiles bool restoreOverwriteSymlinks bool + restoreSparse bool restoreConsistentAttributes bool restoreMode string restoreParallel int @@ -125,6 +126,7 @@ func (c *commandRestore) setup(svc appServices, parent commandParent) { cmd.Flag("overwrite-directories", "Overwrite existing directories").Default("true").BoolVar(&c.restoreOverwriteDirectories) cmd.Flag("overwrite-files", "Specifies whether or not to overwrite already existing files").Default("true").BoolVar(&c.restoreOverwriteFiles) cmd.Flag("overwrite-symlinks", "Specifies whether or not to overwrite already existing symlinks").Default("true").BoolVar(&c.restoreOverwriteSymlinks) + cmd.Flag("sparse", "When doing a restore, attempt to write files sparsely-allocating the minimum amount of disk space needed.").Default("false").BoolVar(&c.restoreSparse) cmd.Flag("consistent-attributes", "When multiple snapshots match, fail if they have inconsistent attributes").Envar("KOPIA_RESTORE_CONSISTENT_ATTRIBUTES").BoolVar(&c.restoreConsistentAttributes) cmd.Flag("mode", "Override restore mode").Default(restoreModeAuto).EnumVar(&c.restoreMode, restoreModeAuto, restoreModeLocal, restoreModeZip, restoreModeZipNoCompress, restoreModeTar, restoreModeTgz) cmd.Flag("parallel", "Restore parallelism (1=disable)").Default("8").IntVar(&c.restoreParallel) @@ -220,6 +222,7 @@ func (c *commandRestore) restoreOutput(ctx context.Context) (restore.Output, err SkipOwners: c.restoreSkipOwners, SkipPermissions: c.restoreSkipPermissions, SkipTimes: c.restoreSkipTimes, + Sparse: c.restoreSparse, }, nil case restoreModeZip, restoreModeZipNoCompress: diff --git a/internal/iocopy/copy.go b/internal/iocopy/copy.go index 9f6679a28..e2304ab7f 100644 --- a/internal/iocopy/copy.go +++ b/internal/iocopy/copy.go @@ -6,7 +6,8 @@ "sync" ) -const bufSize = 65536 +// BufSize is the size (in bytes) of the shared copy buffers Kopia uses to copy data. +const BufSize = 65536 var ( mu sync.Mutex //nolint:gochecknoglobals @@ -21,7 +22,7 @@ func GetBuffer() []byte { defer mu.Unlock() if len(buffers) == 0 { - return make([]byte, bufSize) + return make([]byte, BufSize) } var b []byte diff --git a/internal/sparsefile/sparsefile.go b/internal/sparsefile/sparsefile.go new file mode 100644 index 000000000..25ec58529 --- /dev/null +++ b/internal/sparsefile/sparsefile.go @@ -0,0 +1,107 @@ +// Package sparsefile provides wrappers for handling the writing of sparse files (files with holes). +package sparsefile + +import ( + "io" + "os" + + "github.com/pkg/errors" + + "github.com/kopia/kopia/internal/iocopy" + "github.com/kopia/kopia/internal/stat" +) + +// Write writes the contents of src to the given targetPath, omitting any holes. +func Write(targetPath string, src io.Reader, size int64) error { + dst, err := os.OpenFile(targetPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600) //nolint:gosec,gomnd + if err != nil { + return err //nolint:wrapcheck + } + + // ensure we always close f. Note that this does not conflict with the + // close below, as close is idempotent. + defer dst.Close() //nolint:errcheck,gosec + + if err = dst.Truncate(size); err != nil { + return errors.Wrap(err, "error writing sparse file") + } + + s, err := stat.GetBlockSize(targetPath) + if err != nil { + return errors.Wrap(err, "error writing sparse file") + } + + buf := iocopy.GetBuffer() + defer iocopy.ReleaseBuffer(buf) + + w, err := copySparse(dst, src, buf[0:s]) + if err != nil { + return errors.Wrap(err, "error writing sparse file") + } + + if w != size { + return errors.Errorf("") + } + + if err := dst.Close(); err != nil { + return err //nolint:wrapcheck + } + + return nil +} + +func copySparse(dst io.WriteSeeker, src io.Reader, buf []byte) (written int64, err error) { + for { + nr, er := src.Read(buf) + if nr > 0 { // nolint:nestif + // If non-zero data is read, write it. Otherwise, skip forwards. + if isAllZero(buf) { + dst.Seek(int64(nr), os.SEEK_CUR) // nolint:errcheck + written += int64(nr) + + continue + } + + nw, ew := dst.Write(buf[0:nr]) + if nw < 0 || nr < nw { + nw = 0 + + if ew == nil { + ew = errors.New("invalid write result") + } + } + + written += int64(nw) + + if ew != nil { + err = ew + break + } + + if nr != nw { + err = io.ErrShortWrite + break + } + } + + if er != nil { + if er != io.EOF { + err = er + } + + break + } + } + + return written, err +} + +func isAllZero(buf []byte) bool { + for _, b := range buf { + if b != 0 { + return false + } + } + + return true +} diff --git a/internal/sparsefile/sparsefile_test.go b/internal/sparsefile/sparsefile_test.go new file mode 100644 index 000000000..56305fd13 --- /dev/null +++ b/internal/sparsefile/sparsefile_test.go @@ -0,0 +1,98 @@ +package sparsefile + +import ( + "bytes" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/kopia/kopia/internal/stat" +) + +func TestSparseWrite(t *testing.T) { + t.Parallel() + + if runtime.GOOS == "windows" { + t.Skip("sparse files are not supported on windows") + } + + dir := t.TempDir() + + blk, err := stat.GetBlockSize(dir) + if err != nil { + t.Fatal(err) + } + + type chunk struct { + slice []byte + off uint64 + rep uint64 + } + + cases := []struct { + name string + size uint64 + data []chunk + }{ + { + name: "null", + size: 0, + }, + { + name: "empty", + size: blk, + data: []chunk{ + {slice: []byte{0}, off: 0, rep: blk}, + }, + }, + { + name: "hole", + size: 2 * blk, + data: []chunk{ + {slice: []byte{1}, off: blk, rep: blk}, + }, + }, + { + name: "mix", + size: 2 * blk, + data: []chunk{ + {slice: []byte{1}, off: 3, rep: blk - 10}, + {slice: []byte{1}, off: 2*blk - 10, rep: 10}, + }, + }, + } + + for _, c := range cases { + src := filepath.Join(dir, "src"+c.name) + dst := filepath.Join(dir, "dst"+c.name) + + fd, err := os.Create(src) + if err != nil { + t.Fatal(err) + } + + for _, d := range c.data { + fd.WriteAt(bytes.Repeat(d.slice, int(d.rep)), int64(d.off)) + } + + err = Write(dst, fd, int64(c.size)) + if err != nil { + t.Fatalf("error writing %s: %v", dst, err) + } + + s, err := os.ReadFile(src) + if err != nil { + t.Fatal(err) + } + + d, err := os.ReadFile(dst) + if err != nil { + t.Fatal(err) + } + + if !bytes.Equal(s, d) { + t.Fatalf("contents of %s and %s are not identical", src, dst) + } + } +} diff --git a/internal/stat/stat_bsd.go b/internal/stat/stat_bsd.go new file mode 100644 index 000000000..52e27e407 --- /dev/null +++ b/internal/stat/stat_bsd.go @@ -0,0 +1,37 @@ +//go:build openbsd +// +build openbsd + +// Package stat provides a cross-platform abstraction for +// common stat commands. +package stat + +import "syscall" + +const ( + diskBlockSize uint64 = 512 +) + +// GetFileAllocSize gets the space allocated on disk for the file. +// 'fname' in bytes. +func GetFileAllocSize(fname string) (uint64, error) { + var st syscall.Stat_t + + err := syscall.Stat(fname, &st) + if err != nil { + return 0, err // nolint:wrapcheck + } + + return uint64(st.Blocks) * diskBlockSize, nil +} + +// GetBlockSize gets the disk block size of the underlying system. +func GetBlockSize(path string) (uint64, error) { + var st syscall.Statfs_t + + err := syscall.Statfs(path, &st) + if err != nil { + return 0, err // nolint:wrapcheck + } + + return uint64(st.F_bsize), nil +} diff --git a/internal/stat/stat_test.go b/internal/stat/stat_test.go new file mode 100644 index 000000000..4b10ca94b --- /dev/null +++ b/internal/stat/stat_test.go @@ -0,0 +1,44 @@ +//go:build !windows +// +build !windows + +package stat + +import ( + "bytes" + "os" + "path/filepath" + "testing" +) + +func TestGetBlockSize(t *testing.T) { + s, err := GetBlockSize(os.DevNull) + if err != nil { + t.Fatal(err) + } + + if s <= 0 { + t.Fatalf("invalid disk block size: %d, must be greater than 0", s) + } +} + +func TestGetFileAllocSize(t *testing.T) { + const size = 4096 + + d := t.TempDir() + f := filepath.Join(d, "test") + data := bytes.Repeat([]byte{1}, size) + + err := os.WriteFile(f, data, os.ModePerm) + if err != nil { + t.Fatal(err) + } + + s, err := GetFileAllocSize(f) + if err != nil { + t.Fatalf("error getting file alloc size for %s: %v", f, err) + } + + if s < size { + t.Fatalf("invalid allocated file size %d, expected at least %d", s, size) + } +} diff --git a/internal/stat/stat_unix.go b/internal/stat/stat_unix.go new file mode 100644 index 000000000..b7543e71e --- /dev/null +++ b/internal/stat/stat_unix.go @@ -0,0 +1,37 @@ +//go:build linux || freebsd || darwin +// +build linux freebsd darwin + +// Package stat provides a cross-platform abstraction for +// common stat commands. +package stat + +import "syscall" + +const ( + diskBlockSize uint64 = 512 +) + +// GetFileAllocSize gets the space allocated on disk for the file +// 'fname' in bytes. +func GetFileAllocSize(fname string) (uint64, error) { + var st syscall.Stat_t + + err := syscall.Stat(fname, &st) + if err != nil { + return 0, err // nolint:wrapcheck + } + + return uint64(st.Blocks) * diskBlockSize, nil +} + +// GetBlockSize gets the disk block size of the underlying system. +func GetBlockSize(path string) (uint64, error) { + var st syscall.Statfs_t + + err := syscall.Statfs(path, &st) + if err != nil { + return 0, err // nolint:wrapcheck + } + + return uint64(st.Bsize), nil // nolint:unconvert,nolintlint +} diff --git a/internal/stat/stat_windows.go b/internal/stat/stat_windows.go new file mode 100644 index 000000000..e438d4ff7 --- /dev/null +++ b/internal/stat/stat_windows.go @@ -0,0 +1,21 @@ +//go:build windows +// +build windows + +// Package stat provides a cross-platform abstraction for +// common stat commands. +package stat + +import "errors" + +var errNotImplemented = errors.New("not implemented") + +// GetFileAllocSize gets the space allocated on disk for the file +// 'fname' in bytes. +func GetFileAllocSize(fname string) (uint64, error) { + return 0, errNotImplemented +} + +// GetBlockSize gets the disk block size of the underlying system. +func GetBlockSize(path string) (uint64, error) { + return 0, errNotImplemented +} diff --git a/internal/testutil/testutil.go b/internal/testutil/testutil.go index f800dd594..0b90647c4 100644 --- a/internal/testutil/testutil.go +++ b/internal/testutil/testutil.go @@ -48,6 +48,15 @@ func TestSkipUnlessCI(tb testing.TB, msg string, args ...interface{}) { } } +// TestSkipUnlessLinux skips the current test if the test environment is not Linux. +func TestSkipUnlessLinux(tb testing.TB) { + tb.Helper() + + if runtime.GOOS != "linux" { + tb.Skip("test not supported in this environment.") + } +} + // TestSkipOnCIUnlessLinuxAMD64 skips the current test if running on CI unless the environment is Linux/AMD64. func TestSkipOnCIUnlessLinuxAMD64(tb testing.TB) { tb.Helper() diff --git a/snapshot/restore/local_fs_output.go b/snapshot/restore/local_fs_output.go index 35addddaa..89d829227 100644 --- a/snapshot/restore/local_fs_output.go +++ b/snapshot/restore/local_fs_output.go @@ -15,6 +15,7 @@ "github.com/kopia/kopia/fs/localfs" "github.com/kopia/kopia/internal/atomicfile" "github.com/kopia/kopia/internal/iocopy" + "github.com/kopia/kopia/internal/sparsefile" "github.com/kopia/kopia/snapshot" ) @@ -56,6 +57,9 @@ type FilesystemOutput struct { // SkipTimes when set to true causes restore to skip restoring modification times. SkipTimes bool `json:"skipTimes"` + + // Sparse when set to true causes restore files sparsely-not writing any holes (zero regions) to disk. + Sparse bool `json:"sparse"` } // Parallelizable implements restore.Output interface. @@ -146,11 +150,11 @@ func (o *FilesystemOutput) CreateSymlink(ctx context.Context, relativePath strin path := filepath.Join(o.TargetPath, filepath.FromSlash(relativePath)) - switch stat, err := os.Lstat(path); { + switch st, err := os.Lstat(path); { case os.IsNotExist(err): // Proceed to symlink creation case err != nil: return errors.Wrap(err, "lstat error at symlink path") - case fileIsSymlink(stat): + case fileIsSymlink(st): // Throw error if we are not overwriting symlinks if !o.OverwriteSymlinks { return errors.Errorf("will not overwrite existing symlink") @@ -175,8 +179,8 @@ func (o *FilesystemOutput) CreateSymlink(ctx context.Context, relativePath strin return nil } -func fileIsSymlink(stat os.FileInfo) bool { - return stat.Mode()&os.ModeSymlink != 0 +func fileIsSymlink(st os.FileInfo) bool { + return st.Mode()&os.ModeSymlink != 0 } // SymlinkExists implements restore.Output interface. @@ -282,13 +286,13 @@ func isWindows() bool { } func (o *FilesystemOutput) createDirectory(ctx context.Context, path string) error { - switch stat, err := os.Stat(path); { + switch st, err := os.Stat(path); { case os.IsNotExist(err): // nolint:wrapcheck return os.MkdirAll(path, outputDirMode) case err != nil: return errors.Wrap(err, "failed to stat path "+path) - case stat.Mode().IsDir(): + case st.Mode().IsDir(): if !o.OverwriteDirectories { if empty, _ := isEmptyDirectory(path); !empty { return errors.Errorf("non-empty directory already exists, not overwriting it: %q", path) @@ -315,7 +319,8 @@ func write(targetPath string, r fs.Reader) error { name := f.Name() - if err := iocopy.JustCopy(f, r); err != nil { + err = iocopy.JustCopy(f, r) + if err != nil { return errors.Wrap(err, "cannot write data to file %q "+name) } @@ -346,13 +351,23 @@ func (o *FilesystemOutput) copyFileContent(ctx context.Context, targetPath strin defer r.Close() //nolint:errcheck log(ctx).Debugf("copying file contents to: %v", targetPath) + targetPath = atomicfile.MaybePrefixLongFilenameOnWindows(targetPath) if o.WriteFilesAtomically { // nolint:wrapcheck return atomicfile.Write(targetPath, r) } - return write(atomicfile.MaybePrefixLongFilenameOnWindows(targetPath), r) + if o.Sparse { + if isWindows() { + log(ctx).Infof("sparse files are not supported on Windows, restoring normally") + } else { + // nolint:wrapcheck + return sparsefile.Write(targetPath, r, f.Size()) + } + } + + return write(targetPath, r) } func isEmptyDirectory(name string) (bool, error) { diff --git a/tests/end_to_end_test/restore_test.go b/tests/end_to_end_test/restore_test.go index 28c4c48dc..b0fb6224d 100644 --- a/tests/end_to_end_test/restore_test.go +++ b/tests/end_to_end_test/restore_test.go @@ -3,6 +3,7 @@ import ( "archive/tar" "archive/zip" + "bytes" "compress/gzip" "errors" "fmt" @@ -21,6 +22,8 @@ "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/tests/clitestutil" @@ -488,6 +491,271 @@ func TestRestoreSnapshotOfSingleFile(t *testing.T) { 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(sourceDir) + 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 { + sourceFile := filepath.Join(sourceDir, c.name+"_source") + + fd, err := os.Create(sourceFile) + if err != nil { + t.Fatal(err) + } + + err = fd.Truncate(int64(c.trunc)) + if err != nil { + t.Fatal(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) + 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 + restoreFile := filepath.Join(restoreDir, c.name+"_restore") + + e.RunAndExpectSuccess(t, "snapshot", "restore", snapID, "--sparse", 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) + if err != nil { + t.Fatalf("error verifying file size: %v", err) + } + + realLogical := uint64(st.Size()) + + if realLogical != logical { + t.Errorf("%s logical file size incorrect: expected %d, got %d", fname, logical, realLogical) + } + + if runtime.GOOS == windowsOSName { + t.Logf("getting physical file size is not supported on windows") + return + } + + realPhysical, err := stat.GetFileAllocSize(fname) + if err != nil { + t.Fatalf("error verifying file size: %v", err) + } + + if realPhysical != physical { + t.Errorf("%s physical file size incorrect: expected %d, got %d", fname, physical, realPhysical) + } +} + func verifyFileMode(t *testing.T, filename string, want os.FileMode) { t.Helper()