2
2
mirror of https://github.com/octoleo/restic.git synced 2024-11-27 15:26:37 +00:00
restic/internal/restorer/restorer_test.go
Michael Eischer f4b15fdd96 restore: allow deleting a directory to replace it with a file
When the `--delete` option is specified, recursively delete directories
that should be replaced with a file.
2024-07-05 22:38:39 +02:00

1349 lines
35 KiB
Go

package restorer
import (
"bytes"
"context"
"encoding/json"
"io"
"math"
"os"
"path/filepath"
"reflect"
"runtime"
"strings"
"syscall"
"testing"
"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"
rtest "github.com/restic/restic/internal/test"
"golang.org/x/sync/errgroup"
)
type Node interface{}
type Snapshot struct {
Nodes map[string]Node
}
type File struct {
Data string
Links uint64
Inode uint64
Mode os.FileMode
ModTime time.Time
attributes *FileAttributes
}
type Symlink struct {
Target string
ModTime time.Time
}
type Dir struct {
Nodes map[string]Node
Mode os.FileMode
ModTime time.Time
attributes *FileAttributes
}
type FileAttributes struct {
ReadOnly bool
Hidden bool
System bool
Archive bool
Encrypted bool
}
func saveFile(t testing.TB, repo restic.BlobSaver, node File) restic.ID {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
id, _, _, err := repo.SaveBlob(ctx, restic.DataBlob, []byte(node.Data), restic.ID{}, false)
if err != nil {
t.Fatal(err)
}
return id
}
func saveDir(t testing.TB, repo restic.BlobSaver, nodes map[string]Node, inode uint64, getGenericAttributes func(attr *FileAttributes, isDir bool) (genericAttributes map[restic.GenericAttributeType]json.RawMessage)) restic.ID {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
tree := &restic.Tree{}
for name, n := range nodes {
inode++
switch node := n.(type) {
case File:
fi := n.(File).Inode
if fi == 0 {
fi = inode
}
lc := n.(File).Links
if lc == 0 {
lc = 1
}
fc := []restic.ID{}
if len(n.(File).Data) > 0 {
fc = append(fc, saveFile(t, repo, node))
}
mode := node.Mode
if mode == 0 {
mode = 0644
}
err := tree.Insert(&restic.Node{
Type: "file",
Mode: mode,
ModTime: node.ModTime,
Name: name,
UID: uint32(os.Getuid()),
GID: uint32(os.Getgid()),
Content: fc,
Size: uint64(len(n.(File).Data)),
Inode: fi,
Links: lc,
GenericAttributes: getGenericAttributes(node.attributes, false),
})
rtest.OK(t, err)
case Symlink:
symlink := n.(Symlink)
err := tree.Insert(&restic.Node{
Type: "symlink",
Mode: os.ModeSymlink | 0o777,
ModTime: symlink.ModTime,
Name: name,
UID: uint32(os.Getuid()),
GID: uint32(os.Getgid()),
LinkTarget: symlink.Target,
Inode: inode,
Links: 1,
})
rtest.OK(t, err)
case Dir:
id := saveDir(t, repo, node.Nodes, inode, getGenericAttributes)
mode := node.Mode
if mode == 0 {
mode = 0755
}
err := tree.Insert(&restic.Node{
Type: "dir",
Mode: mode,
ModTime: node.ModTime,
Name: name,
UID: uint32(os.Getuid()),
GID: uint32(os.Getgid()),
Subtree: &id,
GenericAttributes: getGenericAttributes(node.attributes, false),
})
rtest.OK(t, err)
default:
t.Fatalf("unknown node type %T", node)
}
}
id, err := restic.SaveTree(ctx, repo, tree)
if err != nil {
t.Fatal(err)
}
return id
}
func saveSnapshot(t testing.TB, repo restic.Repository, snapshot Snapshot, getGenericAttributes func(attr *FileAttributes, isDir bool) (genericAttributes map[restic.GenericAttributeType]json.RawMessage)) (*restic.Snapshot, restic.ID) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
wg, wgCtx := errgroup.WithContext(ctx)
repo.StartPackUploader(wgCtx, wg)
treeID := saveDir(t, repo, snapshot.Nodes, 1000, getGenericAttributes)
err := repo.Flush(ctx)
if err != nil {
t.Fatal(err)
}
sn, err := restic.NewSnapshot([]string{"test"}, nil, "", time.Now())
if err != nil {
t.Fatal(err)
}
sn.Tree = &treeID
id, err := restic.SaveSnapshot(ctx, repo, sn)
if err != nil {
t.Fatal(err)
}
return sn, id
}
var noopGetGenericAttributes = func(attr *FileAttributes, isDir bool) (genericAttributes map[restic.GenericAttributeType]json.RawMessage) {
// No-op
return nil
}
func TestRestorer(t *testing.T) {
var tests = []struct {
Snapshot
Files map[string]string
ErrorsMust map[string]map[string]struct{}
ErrorsMay map[string]map[string]struct{}
Select func(item string, isDir bool) (selectForRestore bool, childMayBeSelected bool)
}{
// valid test cases
{
Snapshot: Snapshot{
Nodes: map[string]Node{
"foo": File{Data: "content: foo\n"},
"dirtest": Dir{
Nodes: map[string]Node{
"file": File{Data: "content: file\n"},
},
},
},
},
Files: map[string]string{
"foo": "content: foo\n",
"dirtest/file": "content: file\n",
},
},
{
Snapshot: Snapshot{
Nodes: map[string]Node{
"top": File{Data: "toplevel file"},
"dir": Dir{
Nodes: map[string]Node{
"file": File{Data: "file in dir"},
"subdir": Dir{
Nodes: map[string]Node{
"file": File{Data: "file in subdir"},
},
},
},
},
},
},
Files: map[string]string{
"top": "toplevel file",
"dir/file": "file in dir",
"dir/subdir/file": "file in subdir",
},
},
{
Snapshot: Snapshot{
Nodes: map[string]Node{
"dir": Dir{
Mode: 0444,
},
"file": File{Data: "top-level file"},
},
},
Files: map[string]string{
"file": "top-level file",
},
},
{
Snapshot: Snapshot{
Nodes: map[string]Node{
"dir": Dir{
Mode: 0555,
Nodes: map[string]Node{
"file": File{Data: "file in dir"},
},
},
},
},
Files: map[string]string{
"dir/file": "file in dir",
},
},
{
Snapshot: Snapshot{
Nodes: map[string]Node{
"topfile": File{Data: "top-level file"},
},
},
Files: map[string]string{
"topfile": "top-level file",
},
},
{
Snapshot: Snapshot{
Nodes: map[string]Node{
"dir": Dir{
Nodes: map[string]Node{
"file": File{Data: "content: file\n"},
},
},
},
},
Files: map[string]string{
"dir/file": "content: file\n",
},
Select: func(item string, isDir bool) (selectedForRestore bool, childMayBeSelected bool) {
switch item {
case filepath.FromSlash("/dir"):
childMayBeSelected = true
case filepath.FromSlash("/dir/file"):
selectedForRestore = true
childMayBeSelected = true
}
return selectedForRestore, childMayBeSelected
},
},
// test cases with invalid/constructed names
{
Snapshot: Snapshot{
Nodes: map[string]Node{
`..\test`: File{Data: "foo\n"},
`..\..\foo\..\bar\..\xx\test2`: File{Data: "test2\n"},
},
},
ErrorsMay: map[string]map[string]struct{}{
`/`: {
`invalid child node name ..\test`: struct{}{},
`invalid child node name ..\..\foo\..\bar\..\xx\test2`: struct{}{},
},
},
},
{
Snapshot: Snapshot{
Nodes: map[string]Node{
`../test`: File{Data: "foo\n"},
`../../foo/../bar/../xx/test2`: File{Data: "test2\n"},
},
},
ErrorsMay: map[string]map[string]struct{}{
`/`: {
`invalid child node name ../test`: struct{}{},
`invalid child node name ../../foo/../bar/../xx/test2`: struct{}{},
},
},
},
{
Snapshot: Snapshot{
Nodes: map[string]Node{
"top": File{Data: "toplevel file"},
"x": Dir{
Nodes: map[string]Node{
"file1": File{Data: "file1"},
"..": Dir{
Nodes: map[string]Node{
"file2": File{Data: "file2"},
"..": Dir{
Nodes: map[string]Node{
"file2": File{Data: "file2"},
},
},
},
},
},
},
},
},
Files: map[string]string{
"top": "toplevel file",
},
ErrorsMust: map[string]map[string]struct{}{
`/x`: {
`invalid child node name ..`: struct{}{},
},
},
},
}
for _, test := range tests {
t.Run("", func(t *testing.T) {
repo := repository.TestRepository(t)
sn, id := saveSnapshot(t, repo, test.Snapshot, noopGetGenericAttributes)
t.Logf("snapshot saved as %v", id.Str())
res := NewRestorer(repo, sn, Options{})
tempdir := rtest.TempDir(t)
// make sure we're creating a new subdir of the tempdir
tempdir = filepath.Join(tempdir, "target")
res.SelectFilter = func(item string, isDir bool) (selectedForRestore bool, childMayBeSelected bool) {
t.Logf("restore %v", item)
if test.Select != nil {
return test.Select(item, isDir)
}
return true, true
}
errors := make(map[string]map[string]struct{})
res.Error = func(location string, err error) error {
location = filepath.ToSlash(location)
t.Logf("restore returned error for %q: %v", location, err)
if errors[location] == nil {
errors[location] = make(map[string]struct{})
}
errors[location][err.Error()] = struct{}{}
return nil
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
err := res.RestoreTo(ctx, tempdir)
if err != nil {
t.Fatal(err)
}
if len(test.ErrorsMust)+len(test.ErrorsMay) == 0 {
_, err = res.VerifyFiles(ctx, tempdir)
rtest.OK(t, err)
}
for location, expectedErrors := range test.ErrorsMust {
actualErrors, ok := errors[location]
if !ok {
t.Errorf("expected error(s) for %v, found none", location)
continue
}
rtest.Equals(t, expectedErrors, actualErrors)
delete(errors, location)
}
for location, expectedErrors := range test.ErrorsMay {
actualErrors, ok := errors[location]
if !ok {
continue
}
rtest.Equals(t, expectedErrors, actualErrors)
delete(errors, location)
}
for filename, err := range errors {
t.Errorf("unexpected error for %v found: %v", filename, err)
}
for filename, content := range test.Files {
data, err := os.ReadFile(filepath.Join(tempdir, filepath.FromSlash(filename)))
if err != nil {
t.Errorf("unable to read file %v: %v", filename, err)
continue
}
if !bytes.Equal(data, []byte(content)) {
t.Errorf("file %v has wrong content: want %q, got %q", filename, content, data)
}
}
})
}
}
func TestRestorerRelative(t *testing.T) {
var tests = []struct {
Snapshot
Files map[string]string
}{
{
Snapshot: Snapshot{
Nodes: map[string]Node{
"foo": File{Data: "content: foo\n"},
"dirtest": Dir{
Nodes: map[string]Node{
"file": File{Data: "content: file\n"},
},
},
},
},
Files: map[string]string{
"foo": "content: foo\n",
"dirtest/file": "content: file\n",
},
},
}
for _, test := range tests {
t.Run("", func(t *testing.T) {
repo := repository.TestRepository(t)
sn, id := saveSnapshot(t, repo, test.Snapshot, noopGetGenericAttributes)
t.Logf("snapshot saved as %v", id.Str())
res := NewRestorer(repo, sn, Options{})
tempdir := rtest.TempDir(t)
cleanup := rtest.Chdir(t, tempdir)
defer cleanup()
errors := make(map[string]string)
res.Error = func(location string, err error) error {
t.Logf("restore returned error for %q: %v", location, err)
errors[location] = err.Error()
return nil
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
err := res.RestoreTo(ctx, "restore")
if err != nil {
t.Fatal(err)
}
nverified, err := res.VerifyFiles(ctx, "restore")
rtest.OK(t, err)
rtest.Equals(t, len(test.Files), nverified)
for filename, err := range errors {
t.Errorf("unexpected error for %v found: %v", filename, err)
}
for filename, content := range test.Files {
data, err := os.ReadFile(filepath.Join(tempdir, "restore", filepath.FromSlash(filename)))
if err != nil {
t.Errorf("unable to read file %v: %v", filename, err)
continue
}
if !bytes.Equal(data, []byte(content)) {
t.Errorf("file %v has wrong content: want %q, got %q", filename, content, data)
}
}
})
}
}
type TraverseTreeCheck func(testing.TB) treeVisitor
type TreeVisit struct {
funcName string // name of the function
location string // location passed to the function
files []string // file list passed to the function
}
func checkVisitOrder(list []TreeVisit) TraverseTreeCheck {
var pos int
return func(t testing.TB) treeVisitor {
check := func(funcName string) func(*restic.Node, string, string, []string) error {
return func(node *restic.Node, target, location string, expectedFilenames []string) error {
if pos >= len(list) {
t.Errorf("step %v, %v(%v): expected no more than %d function calls", pos, funcName, location, len(list))
pos++
return nil
}
v := list[pos]
if v.funcName != funcName {
t.Errorf("step %v, location %v: want function %v, but %v was called",
pos, location, v.funcName, funcName)
}
if location != filepath.FromSlash(v.location) {
t.Errorf("step %v: want location %v, got %v", pos, list[pos].location, location)
}
if !reflect.DeepEqual(expectedFilenames, v.files) {
t.Errorf("step %v: want files %v, got %v", pos, list[pos].files, expectedFilenames)
}
pos++
return nil
}
}
checkNoFilename := func(funcName string) func(*restic.Node, string, string) error {
f := check(funcName)
return func(node *restic.Node, target, location string) error {
return f(node, target, location, nil)
}
}
return treeVisitor{
enterDir: checkNoFilename("enterDir"),
visitNode: checkNoFilename("visitNode"),
leaveDir: check("leaveDir"),
}
}
}
func TestRestorerTraverseTree(t *testing.T) {
var tests = []struct {
Snapshot
Select func(item string, isDir bool) (selectForRestore bool, childMayBeSelected bool)
Visitor TraverseTreeCheck
}{
{
// select everything
Snapshot: Snapshot{
Nodes: map[string]Node{
"dir": Dir{Nodes: map[string]Node{
"otherfile": File{Data: "x"},
"subdir": Dir{Nodes: map[string]Node{
"file": File{Data: "content: file\n"},
}},
}},
"foo": File{Data: "content: foo\n"},
},
},
Select: func(item string, isDir bool) (selectForRestore bool, childMayBeSelected bool) {
return true, true
},
Visitor: checkVisitOrder([]TreeVisit{
{"enterDir", "/", nil},
{"enterDir", "/dir", nil},
{"visitNode", "/dir/otherfile", nil},
{"enterDir", "/dir/subdir", nil},
{"visitNode", "/dir/subdir/file", nil},
{"leaveDir", "/dir/subdir", []string{"file"}},
{"leaveDir", "/dir", []string{"otherfile", "subdir"}},
{"visitNode", "/foo", nil},
{"leaveDir", "/", []string{"dir", "foo"}},
}),
},
// select only the top-level file
{
Snapshot: Snapshot{
Nodes: map[string]Node{
"dir": Dir{Nodes: map[string]Node{
"otherfile": File{Data: "x"},
"subdir": Dir{Nodes: map[string]Node{
"file": File{Data: "content: file\n"},
}},
}},
"foo": File{Data: "content: foo\n"},
},
},
Select: func(item string, isDir bool) (selectForRestore bool, childMayBeSelected bool) {
if item == "/foo" {
return true, false
}
return false, false
},
Visitor: checkVisitOrder([]TreeVisit{
{"enterDir", "/", nil},
{"visitNode", "/foo", nil},
{"leaveDir", "/", []string{"dir", "foo"}},
}),
},
{
Snapshot: Snapshot{
Nodes: map[string]Node{
"aaa": File{Data: "content: foo\n"},
"dir": Dir{Nodes: map[string]Node{
"otherfile": File{Data: "x"},
"subdir": Dir{Nodes: map[string]Node{
"file": File{Data: "content: file\n"},
}},
}},
},
},
Select: func(item string, isDir bool) (selectForRestore bool, childMayBeSelected bool) {
if item == "/aaa" {
return true, false
}
return false, false
},
Visitor: checkVisitOrder([]TreeVisit{
{"enterDir", "/", nil},
{"visitNode", "/aaa", nil},
{"leaveDir", "/", []string{"aaa", "dir"}},
}),
},
// select dir/
{
Snapshot: Snapshot{
Nodes: map[string]Node{
"dir": Dir{Nodes: map[string]Node{
"otherfile": File{Data: "x"},
"subdir": Dir{Nodes: map[string]Node{
"file": File{Data: "content: file\n"},
}},
}},
"foo": File{Data: "content: foo\n"},
},
},
Select: func(item string, isDir bool) (selectForRestore bool, childMayBeSelected bool) {
if strings.HasPrefix(item, "/dir") {
return true, true
}
return false, false
},
Visitor: checkVisitOrder([]TreeVisit{
{"enterDir", "/", nil},
{"enterDir", "/dir", nil},
{"visitNode", "/dir/otherfile", nil},
{"enterDir", "/dir/subdir", nil},
{"visitNode", "/dir/subdir/file", nil},
{"leaveDir", "/dir/subdir", []string{"file"}},
{"leaveDir", "/dir", []string{"otherfile", "subdir"}},
{"leaveDir", "/", []string{"dir", "foo"}},
}),
},
// select only dir/otherfile
{
Snapshot: Snapshot{
Nodes: map[string]Node{
"dir": Dir{Nodes: map[string]Node{
"otherfile": File{Data: "x"},
"subdir": Dir{Nodes: map[string]Node{
"file": File{Data: "content: file\n"},
}},
}},
"foo": File{Data: "content: foo\n"},
},
},
Select: func(item string, isDir bool) (selectForRestore bool, childMayBeSelected bool) {
switch item {
case "/dir":
return false, true
case "/dir/otherfile":
return true, false
default:
return false, false
}
},
Visitor: checkVisitOrder([]TreeVisit{
{"enterDir", "/", nil},
{"visitNode", "/dir/otherfile", nil},
{"leaveDir", "/dir", []string{"otherfile", "subdir"}},
{"leaveDir", "/", []string{"dir", "foo"}},
}),
},
}
for _, test := range tests {
t.Run("", func(t *testing.T) {
repo := repository.TestRepository(t)
sn, _ := saveSnapshot(t, repo, test.Snapshot, noopGetGenericAttributes)
// set Delete option to enable tracking filenames in a directory
res := NewRestorer(repo, sn, Options{Delete: true})
res.SelectFilter = test.Select
tempdir := rtest.TempDir(t)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// make sure we're creating a new subdir of the tempdir
target := filepath.Join(tempdir, "target")
err := res.traverseTree(ctx, target, *sn.Tree, test.Visitor(t))
if err != nil {
t.Fatal(err)
}
})
}
}
func normalizeFileMode(mode os.FileMode) os.FileMode {
if runtime.GOOS == "windows" {
if mode.IsDir() {
return 0555 | os.ModeDir
}
return os.FileMode(0444)
}
return mode
}
func checkConsistentInfo(t testing.TB, file string, fi os.FileInfo, modtime time.Time, mode os.FileMode) {
if fi.Mode() != mode {
t.Errorf("checking %q, Mode() returned wrong value, want 0%o, got 0%o", file, mode, fi.Mode())
}
if !fi.ModTime().Equal(modtime) {
t.Errorf("checking %s, ModTime() returned wrong value, want %v, got %v", file, modtime, fi.ModTime())
}
}
// test inspired from test case https://github.com/restic/restic/issues/1212
func TestRestorerConsistentTimestampsAndPermissions(t *testing.T) {
timeForTest := time.Date(2019, time.January, 9, 1, 46, 40, 0, time.UTC)
repo := repository.TestRepository(t)
sn, _ := saveSnapshot(t, repo, Snapshot{
Nodes: map[string]Node{
"dir": Dir{
Mode: normalizeFileMode(0750 | os.ModeDir),
ModTime: timeForTest,
Nodes: map[string]Node{
"file1": File{
Mode: normalizeFileMode(os.FileMode(0700)),
ModTime: timeForTest,
Data: "content: file\n",
},
"anotherfile": File{
Data: "content: file\n",
},
"subdir": Dir{
Mode: normalizeFileMode(0700 | os.ModeDir),
ModTime: timeForTest,
Nodes: map[string]Node{
"file2": File{
Mode: normalizeFileMode(os.FileMode(0666)),
ModTime: timeForTest,
Links: 2,
Inode: 1,
},
},
},
},
},
},
}, noopGetGenericAttributes)
res := NewRestorer(repo, sn, Options{})
res.SelectFilter = func(item string, isDir bool) (selectedForRestore bool, childMayBeSelected bool) {
switch filepath.ToSlash(item) {
case "/dir":
childMayBeSelected = true
case "/dir/file1":
selectedForRestore = true
childMayBeSelected = false
case "/dir/subdir":
selectedForRestore = true
childMayBeSelected = true
case "/dir/subdir/file2":
selectedForRestore = true
childMayBeSelected = false
}
return selectedForRestore, childMayBeSelected
}
tempdir := rtest.TempDir(t)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
err := res.RestoreTo(ctx, tempdir)
rtest.OK(t, err)
var testPatterns = []struct {
path string
modtime time.Time
mode os.FileMode
}{
{"dir", timeForTest, normalizeFileMode(0750 | os.ModeDir)},
{filepath.Join("dir", "file1"), timeForTest, normalizeFileMode(os.FileMode(0700))},
{filepath.Join("dir", "subdir"), timeForTest, normalizeFileMode(0700 | os.ModeDir)},
{filepath.Join("dir", "subdir", "file2"), timeForTest, normalizeFileMode(os.FileMode(0666))},
}
for _, test := range testPatterns {
f, err := os.Stat(filepath.Join(tempdir, test.path))
rtest.OK(t, err)
checkConsistentInfo(t, test.path, f, test.modtime, test.mode)
}
}
// VerifyFiles must not report cancellation of its context through res.Error.
func TestVerifyCancel(t *testing.T) {
snapshot := Snapshot{
Nodes: map[string]Node{
"foo": File{Data: "content: foo\n"},
},
}
repo := repository.TestRepository(t)
sn, _ := saveSnapshot(t, repo, snapshot, noopGetGenericAttributes)
res := NewRestorer(repo, sn, Options{})
tempdir := rtest.TempDir(t)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
rtest.OK(t, res.RestoreTo(ctx, tempdir))
err := os.WriteFile(filepath.Join(tempdir, "foo"), []byte("bar"), 0644)
rtest.OK(t, err)
var errs []error
res.Error = func(filename string, err error) error {
errs = append(errs, err)
return err
}
nverified, err := res.VerifyFiles(ctx, tempdir)
rtest.Equals(t, 0, nverified)
rtest.Assert(t, err != nil, "nil error from VerifyFiles")
rtest.Equals(t, 1, len(errs))
rtest.Assert(t, strings.Contains(errs[0].Error(), "Invalid file size for"), "wrong error %q", errs[0].Error())
}
func TestRestorerSparseFiles(t *testing.T) {
repo := repository.TestRepository(t)
var zeros [1<<20 + 13]byte
target := &fs.Reader{
Mode: 0600,
Name: "/zeros",
ReadCloser: io.NopCloser(bytes.NewReader(zeros[:])),
}
sc := archiver.NewScanner(target)
err := sc.Scan(context.TODO(), []string{"/zeros"})
rtest.OK(t, err)
arch := archiver.New(repo, target, archiver.Options{})
sn, _, _, err := arch.Snapshot(context.Background(), []string{"/zeros"},
archiver.SnapshotOptions{})
rtest.OK(t, err)
res := NewRestorer(repo, sn, Options{Sparse: true})
tempdir := rtest.TempDir(t)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
err = res.RestoreTo(ctx, tempdir)
rtest.OK(t, err)
filename := filepath.Join(tempdir, "zeros")
content, err := os.ReadFile(filename)
rtest.OK(t, err)
rtest.Equals(t, len(zeros[:]), len(content))
rtest.Equals(t, zeros[:], content)
blocks := getBlockCount(t, filename)
if blocks < 0 {
return
}
// st.Blocks is the size in 512-byte blocks.
denseBlocks := math.Ceil(float64(len(zeros)) / 512)
sparsity := 1 - float64(blocks)/denseBlocks
// This should report 100% sparse. We don't assert that,
// as the behavior of sparse writes depends on the underlying
// file system as well as the OS.
t.Logf("wrote %d zeros as %d blocks, %.1f%% sparse",
len(zeros), blocks, 100*sparsity)
}
func saveSnapshotsAndOverwrite(t *testing.T, baseSnapshot Snapshot, overwriteSnapshot Snapshot, options Options) string {
repo := repository.TestRepository(t)
tempdir := filepath.Join(rtest.TempDir(t), "target")
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// base snapshot
sn, id := saveSnapshot(t, repo, baseSnapshot, noopGetGenericAttributes)
t.Logf("base snapshot saved as %v", id.Str())
res := NewRestorer(repo, sn, options)
rtest.OK(t, res.RestoreTo(ctx, tempdir))
// overwrite snapshot
sn, id = saveSnapshot(t, repo, overwriteSnapshot, noopGetGenericAttributes)
t.Logf("overwrite snapshot saved as %v", id.Str())
res = NewRestorer(repo, sn, options)
rtest.OK(t, res.RestoreTo(ctx, tempdir))
_, err := res.VerifyFiles(ctx, tempdir)
rtest.OK(t, err)
return tempdir
}
func TestRestorerSparseOverwrite(t *testing.T) {
baseSnapshot := Snapshot{
Nodes: map[string]Node{
"foo": File{Data: "content: new\n"},
},
}
var zero [14]byte
sparseSnapshot := Snapshot{
Nodes: map[string]Node{
"foo": File{Data: string(zero[:])},
},
}
saveSnapshotsAndOverwrite(t, baseSnapshot, sparseSnapshot, Options{Sparse: true, Overwrite: OverwriteAlways})
}
func TestRestorerOverwriteBehavior(t *testing.T) {
baseTime := time.Now()
baseSnapshot := Snapshot{
Nodes: map[string]Node{
"foo": File{Data: "content: foo\n", ModTime: baseTime},
"dirtest": Dir{
Nodes: map[string]Node{
"file": File{Data: "content: file\n", ModTime: baseTime},
},
ModTime: baseTime,
},
},
}
overwriteSnapshot := Snapshot{
Nodes: map[string]Node{
"foo": File{Data: "content: new\n", ModTime: baseTime.Add(time.Second)},
"dirtest": Dir{
Nodes: map[string]Node{
"file": File{Data: "content: file2\n", ModTime: baseTime.Add(-time.Second)},
},
},
},
}
var tests = []struct {
Overwrite OverwriteBehavior
Files map[string]string
}{
{
Overwrite: OverwriteAlways,
Files: map[string]string{
"foo": "content: new\n",
"dirtest/file": "content: file2\n",
},
},
{
Overwrite: OverwriteIfChanged,
Files: map[string]string{
"foo": "content: new\n",
"dirtest/file": "content: file2\n",
},
},
{
Overwrite: OverwriteIfNewer,
Files: map[string]string{
"foo": "content: new\n",
"dirtest/file": "content: file\n",
},
},
{
Overwrite: OverwriteNever,
Files: map[string]string{
"foo": "content: foo\n",
"dirtest/file": "content: file\n",
},
},
}
for _, test := range tests {
t.Run("", func(t *testing.T) {
tempdir := saveSnapshotsAndOverwrite(t, baseSnapshot, overwriteSnapshot, Options{Overwrite: test.Overwrite})
for filename, content := range test.Files {
data, err := os.ReadFile(filepath.Join(tempdir, filepath.FromSlash(filename)))
if err != nil {
t.Errorf("unable to read file %v: %v", filename, err)
continue
}
if !bytes.Equal(data, []byte(content)) {
t.Errorf("file %v has wrong content: want %q, got %q", filename, content, data)
}
}
})
}
}
func TestRestorerOverwriteSpecial(t *testing.T) {
baseTime := time.Now()
baseSnapshot := Snapshot{
Nodes: map[string]Node{
"dirtest": Dir{ModTime: baseTime},
"link": Symlink{Target: "foo", ModTime: baseTime},
"file": File{Data: "content: file\n", Inode: 42, Links: 2, ModTime: baseTime},
"hardlink": File{Data: "content: file\n", Inode: 42, Links: 2, ModTime: baseTime},
"newdir": File{Data: "content: dir\n", ModTime: baseTime},
},
}
overwriteSnapshot := Snapshot{
Nodes: map[string]Node{
"dirtest": Symlink{Target: "foo", ModTime: baseTime},
"link": File{Data: "content: link\n", Inode: 42, Links: 2, ModTime: baseTime.Add(time.Second)},
"file": Symlink{Target: "foo2", ModTime: baseTime},
"hardlink": File{Data: "content: link\n", Inode: 42, Links: 2, ModTime: baseTime.Add(time.Second)},
"newdir": Dir{ModTime: baseTime},
},
}
files := map[string]string{
"link": "content: link\n",
"hardlink": "content: link\n",
}
links := map[string]string{
"dirtest": "foo",
"file": "foo2",
}
tempdir := saveSnapshotsAndOverwrite(t, baseSnapshot, overwriteSnapshot, Options{Overwrite: OverwriteAlways})
for filename, content := range files {
data, err := os.ReadFile(filepath.Join(tempdir, filepath.FromSlash(filename)))
if err != nil {
t.Errorf("unable to read file %v: %v", filename, err)
continue
}
if !bytes.Equal(data, []byte(content)) {
t.Errorf("file %v has wrong content: want %q, got %q", filename, content, data)
}
}
for filename, target := range links {
link, err := fs.Readlink(filepath.Join(tempdir, filepath.FromSlash(filename)))
rtest.OK(t, err)
rtest.Equals(t, link, target, "wrong symlink target")
}
}
func TestRestoreModified(t *testing.T) {
// overwrite files between snapshots and also change their filesize
snapshots := []Snapshot{
{
Nodes: map[string]Node{
"foo": File{Data: "content: foo\n", ModTime: time.Now()},
"bar": File{Data: "content: a\n", ModTime: time.Now()},
},
},
{
Nodes: map[string]Node{
"foo": File{Data: "content: a\n", ModTime: time.Now()},
"bar": File{Data: "content: bar\n", ModTime: time.Now()},
},
},
}
repo := repository.TestRepository(t)
tempdir := filepath.Join(rtest.TempDir(t), "target")
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
for _, snapshot := range snapshots {
sn, id := saveSnapshot(t, repo, snapshot, noopGetGenericAttributes)
t.Logf("snapshot saved as %v", id.Str())
res := NewRestorer(repo, sn, Options{Overwrite: OverwriteIfChanged})
rtest.OK(t, res.RestoreTo(ctx, tempdir))
n, err := res.VerifyFiles(ctx, tempdir)
rtest.OK(t, err)
rtest.Equals(t, 2, n, "unexpected number of verified files")
}
}
func TestRestoreIfChanged(t *testing.T) {
origData := "content: foo\n"
modData := "content: bar\n"
rtest.Equals(t, len(modData), len(origData), "broken testcase")
snapshot := Snapshot{
Nodes: map[string]Node{
"foo": File{Data: origData, ModTime: time.Now()},
},
}
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{})
rtest.OK(t, res.RestoreTo(ctx, tempdir))
// modify file but maintain size and timestamp
path := filepath.Join(tempdir, "foo")
f, err := os.OpenFile(path, os.O_RDWR, 0)
rtest.OK(t, err)
fi, err := f.Stat()
rtest.OK(t, err)
_, err = f.Write([]byte(modData))
rtest.OK(t, err)
rtest.OK(t, f.Close())
var utimes = [...]syscall.Timespec{
syscall.NsecToTimespec(fi.ModTime().UnixNano()),
syscall.NsecToTimespec(fi.ModTime().UnixNano()),
}
rtest.OK(t, syscall.UtimesNano(path, utimes[:]))
for _, overwrite := range []OverwriteBehavior{OverwriteIfChanged, OverwriteAlways} {
res = NewRestorer(repo, sn, Options{Overwrite: overwrite})
rtest.OK(t, res.RestoreTo(ctx, tempdir))
data, err := os.ReadFile(path)
rtest.OK(t, err)
if overwrite == OverwriteAlways {
// restore should notice the changed file content
rtest.Equals(t, origData, string(data), "expected original file content")
} else {
// restore should not have noticed the changed file content
rtest.Equals(t, modData, string(data), "expected modified file content")
}
}
}
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)
}
func TestRestoreOverwriteDirectory(t *testing.T) {
saveSnapshotsAndOverwrite(t,
Snapshot{
Nodes: map[string]Node{
"dir": Dir{
Mode: normalizeFileMode(0755 | os.ModeDir),
Nodes: map[string]Node{
"anotherfile": File{Data: "content: file\n"},
},
},
},
},
Snapshot{
Nodes: map[string]Node{
"dir": File{Data: "content: file\n"},
},
},
Options{Delete: true},
)
}
func TestRestoreDelete(t *testing.T) {
repo := repository.TestRepository(t)
tempdir := rtest.TempDir(t)
sn, _ := saveSnapshot(t, repo, Snapshot{
Nodes: map[string]Node{
"dir": Dir{
Mode: normalizeFileMode(0755 | os.ModeDir),
Nodes: map[string]Node{
"file1": File{Data: "content: file\n"},
"anotherfile": File{Data: "content: file\n"},
},
},
"dir2": Dir{
Mode: normalizeFileMode(0755 | os.ModeDir),
Nodes: map[string]Node{
"anotherfile": File{Data: "content: file\n"},
},
},
"anotherfile": File{Data: "content: file\n"},
},
}, noopGetGenericAttributes)
// should delete files that no longer exist in the snapshot
deleteSn, _ := saveSnapshot(t, repo, Snapshot{
Nodes: map[string]Node{
"dir": Dir{
Mode: normalizeFileMode(0755 | os.ModeDir),
Nodes: map[string]Node{
"file1": File{Data: "content: file\n"},
},
},
},
}, noopGetGenericAttributes)
tests := []struct {
selectFilter func(item string, isDir bool) (selectedForRestore bool, childMayBeSelected bool)
fileState map[string]bool
}{
{
selectFilter: nil,
fileState: map[string]bool{
"dir": true,
filepath.Join("dir", "anotherfile"): false,
filepath.Join("dir", "file1"): true,
"dir2": false,
filepath.Join("dir2", "anotherfile"): false,
"anotherfile": false,
},
},
{
selectFilter: func(item string, isDir bool) (selectedForRestore bool, childMayBeSelected bool) {
return false, false
},
fileState: map[string]bool{
"dir": true,
filepath.Join("dir", "anotherfile"): true,
filepath.Join("dir", "file1"): true,
"dir2": true,
filepath.Join("dir2", "anotherfile"): true,
"anotherfile": true,
},
},
{
selectFilter: func(item string, isDir bool) (selectedForRestore bool, childMayBeSelected bool) {
switch item {
case filepath.FromSlash("/dir"):
selectedForRestore = true
case filepath.FromSlash("/dir2"):
selectedForRestore = true
}
return
},
fileState: map[string]bool{
"dir": true,
filepath.Join("dir", "anotherfile"): true,
filepath.Join("dir", "file1"): true,
"dir2": false,
filepath.Join("dir2", "anotherfile"): false,
"anotherfile": true,
},
},
}
for _, test := range tests {
t.Run("", func(t *testing.T) {
res := NewRestorer(repo, sn, Options{})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
err := res.RestoreTo(ctx, tempdir)
rtest.OK(t, err)
res = NewRestorer(repo, deleteSn, Options{Delete: true})
if test.selectFilter != nil {
res.SelectFilter = test.selectFilter
}
err = res.RestoreTo(ctx, tempdir)
rtest.OK(t, err)
for fn, shouldExist := range test.fileState {
_, err := os.Stat(filepath.Join(tempdir, fn))
if shouldExist {
rtest.OK(t, err)
} else {
rtest.Assert(t, errors.Is(err, os.ErrNotExist), "file %v: unexpected error got %v, expected ErrNotExist", fn, err)
}
}
})
}
}