Skip to content

Commit

Permalink
restore: support for symlinks (experimental) (#621)
Browse files Browse the repository at this point in the history
  • Loading branch information
jkowalski committed Sep 18, 2020
1 parent 98a2bc6 commit fce9497
Show file tree
Hide file tree
Showing 9 changed files with 143 additions and 20 deletions.
2 changes: 1 addition & 1 deletion cli/command_restore.go
Expand Up @@ -144,7 +144,7 @@ func detectRestoreMode(m string) string {
}

func printRestoreStats(st restore.Stats) {
printStderr("Restored %v files and %v directories (%v)\n", st.FileCount, st.DirCount, units.BytesStringBase10(st.TotalFileSize))
printStderr("Restored %v files, %v directories and %v symbolic links (%v)\n", st.FileCount, st.DirCount, st.SymlinkCount, units.BytesStringBase10(st.TotalFileSize))
}

func runRestoreCommand(ctx context.Context, rep repo.Repository) error {
Expand Down
1 change: 1 addition & 0 deletions go.mod
Expand Up @@ -50,6 +50,7 @@ require (
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a
golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25
golang.org/x/tools v0.0.0-20200521155704-91d71f6c2f04 // indirect
google.golang.org/api v0.25.0
google.golang.org/protobuf v1.23.0
Expand Down
15 changes: 13 additions & 2 deletions internal/fshasher/fshasher.go
Expand Up @@ -9,6 +9,7 @@ import (
"path"
"time"

"github.com/pkg/errors"
"golang.org/x/crypto/blake2s"

"github.com/kopia/kopia/fs"
Expand Down Expand Up @@ -46,7 +47,7 @@ func write(ctx context.Context, tw *tar.Writer, fullpath string, e fs.Entry) err
return err
}

log(ctx).Debugf("%v %v %v %v", e.Mode(), h.ModTime.Format(time.RFC3339), h.Size, h.Name)
log(ctx).Debugf("%v %v %v %v %v", e.Mode(), h.ModTime.Format(time.RFC3339), h.Size, h.Name, h.Linkname)

if err := tw.WriteHeader(h); err != nil {
return err
Expand All @@ -57,7 +58,10 @@ func write(ctx context.Context, tw *tar.Writer, fullpath string, e fs.Entry) err
return writeDirectory(ctx, tw, fullpath, e)
case fs.File:
return writeFile(ctx, tw, e)
default: // fs.Symlink or bare fs.Entry
case fs.Symlink:
// link target is part of the header
return nil
default: // bare fs.Entry
return nil
}
}
Expand Down Expand Up @@ -92,6 +96,13 @@ func header(ctx context.Context, fullpath string, e os.FileInfo) (*tar.Header, e
h.AccessTime = h.ModTime
h.ChangeTime = h.ModTime

if sl, ok := e.(fs.Symlink); ok {
h.Linkname, err = sl.Readlink(ctx)
if err != nil {
return nil, errors.Wrap(err, "error reading link")
}
}

return h, nil
}

Expand Down
37 changes: 31 additions & 6 deletions snapshot/restore/local_fs_output.go
Expand Up @@ -43,7 +43,7 @@ func (o *FilesystemOutput) BeginDirectory(ctx context.Context, relativePath stri
// FinishDirectory implements restore.Output interface.
func (o *FilesystemOutput) FinishDirectory(ctx context.Context, relativePath string, e fs.Directory) error {
path := filepath.Join(o.TargetPath, filepath.FromSlash(relativePath))
if err := o.setAttributes(path, e); err != nil {
if err := o.setAttributes(ctx, path, e); err != nil {
return errors.Wrap(err, "error setting attributes")
}

Expand All @@ -57,14 +57,14 @@ func (o *FilesystemOutput) Close(ctx context.Context) error {

// WriteFile implements restore.Output interface.
func (o *FilesystemOutput) WriteFile(ctx context.Context, relativePath string, f fs.File) error {
log(ctx).Infof("WriteFile %v %v", relativePath, f)
log(ctx).Infof("WriteFile %v %v", relativePath, f.Size())
path := filepath.Join(o.TargetPath, filepath.FromSlash(relativePath))

if err := o.copyFileContent(ctx, path, f); err != nil {
return errors.Wrap(err, "error creating directory")
}

if err := o.setAttributes(path, f); err != nil {
if err := o.setAttributes(ctx, path, f); err != nil {
return errors.Wrap(err, "error setting attributes")
}

Expand All @@ -73,12 +73,28 @@ func (o *FilesystemOutput) WriteFile(ctx context.Context, relativePath string, f

// CreateSymlink implements restore.Output interface.
func (o *FilesystemOutput) CreateSymlink(ctx context.Context, relativePath string, e fs.Symlink) error {
log(ctx).Debugf("create symlink not implemented yet")
targetPath, err := e.Readlink(ctx)
if err != nil {
return errors.Wrap(err, "error reading link target")
}

log(ctx).Infof("CreateSymlink %v => %v", relativePath, targetPath)

path := filepath.Join(o.TargetPath, filepath.FromSlash(relativePath))

if err := os.Symlink(targetPath, path); err != nil {
return errors.Wrap(err, "error creating symlink")
}

if err := o.setAttributes(ctx, path, e); err != nil {
return errors.Wrap(err, "error setting attributes")
}

return nil
}

// set permission, modification time and user/group ids on targetPath.
func (o *FilesystemOutput) setAttributes(targetPath string, e fs.Entry) error {
func (o *FilesystemOutput) setAttributes(ctx context.Context, targetPath string, e fs.Entry) error {
const modBits = os.ModePerm | os.ModeSetgid | os.ModeSetuid | os.ModeSticky

le, err := localfs.NewEntry(targetPath)
Expand All @@ -103,7 +119,16 @@ func (o *FilesystemOutput) setAttributes(targetPath string, e fs.Entry) error {
}

// Set mod time from e
if !le.ModTime().Equal(e.ModTime()) {
if le.ModTime().Equal(e.ModTime()) {
return nil
}

if _, isSymlink := e.(fs.Symlink); isSymlink {
// symbolic links require special handling that is OS-specific and sometimes unsupported.
if err = symlinkChtimes(ctx, targetPath, e.ModTime(), e.ModTime()); err != nil && !os.IsPermission(err) {
return errors.Wrap(err, "could not change mod time on "+targetPath)
}
} else {
// Note: Set atime to ModTime as well
if err = os.Chtimes(targetPath, e.ModTime(), e.ModTime()); err != nil && !os.IsPermission(err) {
return errors.Wrap(err, "could not change mod time on "+targetPath)
Expand Down
17 changes: 17 additions & 0 deletions snapshot/restore/local_fs_output_unix.go
@@ -0,0 +1,17 @@
// +build linux darwin

package restore

import (
"context"
"time"

"golang.org/x/sys/unix"
)

func symlinkChtimes(ctx context.Context, linkPath string, atime, mtime time.Time) error {
return unix.Lutimes(linkPath, []unix.Timeval{
unix.NsecToTimeval(atime.UnixNano()),
unix.NsecToTimeval(mtime.UnixNano()),
})
}
32 changes: 32 additions & 0 deletions snapshot/restore/local_fs_output_windows.go
@@ -0,0 +1,32 @@
package restore

import (
"context"
"time"

"github.com/pkg/errors"
"golang.org/x/sys/windows"
)

func symlinkChtimes(ctx context.Context, linkPath string, atime, mtime time.Time) error {
fta := windows.NsecToFiletime(atime.UnixNano())
ftw := windows.NsecToFiletime(mtime.UnixNano())

fn, err := windows.UTF16PtrFromString(linkPath)
if err != nil {
return errors.Wrap(err, "UTF16PtrFromString")
}

h, err := windows.CreateFile(
fn, windows.GENERIC_READ|windows.GENERIC_WRITE,
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE,
nil, windows.OPEN_EXISTING,
windows.FILE_FLAG_OPEN_REPARSE_POINT, 0)
if err != nil {
return err
}

defer windows.CloseHandle(h) //nolint:errcheck

return windows.SetFileTime(h, &ftw, &fta, &ftw)
}
3 changes: 3 additions & 0 deletions snapshot/restore/restore.go
Expand Up @@ -32,6 +32,7 @@ type Stats struct {
TotalFileSize int64
FileCount int32
DirCount int32
SymlinkCount int32
}

// Snapshot walks a snapshot root with given snapshot ID and restores it to the provided output.
Expand Down Expand Up @@ -90,7 +91,9 @@ func (c *copier) copyEntry(ctx context.Context, e fs.Entry, targetPath string) e

return c.output.WriteFile(ctx, targetPath, e)
case fs.Symlink:
atomic.AddInt32(&c.stats.SymlinkCount, 1)
log(ctx).Debugf("symlink: '%v'", targetPath)

return c.output.CreateSymlink(ctx, targetPath, e)
default:
return errors.Errorf("invalid FS entry type for %q: %#v", targetPath, e)
Expand Down
14 changes: 9 additions & 5 deletions tests/end_to_end_test/restore_test.go
Expand Up @@ -29,8 +29,10 @@ func TestRestoreCommand(t *testing.T) {

source := makeScratchDir(t)
testenv.MustCreateDirectoryTree(t, source, testenv.DirectoryTreeOptions{
Depth: 1,
MaxFilesPerDirectory: 10,
Depth: 1,
MaxFilesPerDirectory: 10,
MaxSymlinksPerDirectory: 4,
NonExistingSymlinkTargetPercentage: 50,
})

restoreDir := makeScratchDir(t)
Expand Down Expand Up @@ -117,9 +119,11 @@ func TestSnapshotRestore(t *testing.T) {

source := makeScratchDir(t)
testenv.MustCreateDirectoryTree(t, source, testenv.DirectoryTreeOptions{
Depth: 5,
MaxSubdirsPerDirectory: 5,
MaxFilesPerDirectory: 5,
Depth: 5,
MaxSubdirsPerDirectory: 5,
MaxFilesPerDirectory: 5,
MaxSymlinksPerDirectory: 4,
NonExistingSymlinkTargetPercentage: 50,
})

restoreDir := makeScratchDir(t)
Expand Down
42 changes: 36 additions & 6 deletions tests/testenv/cli_test_env.go
Expand Up @@ -287,18 +287,21 @@ func mustParseDirectoryEntries(lines []string) []DirEntry {

// DirectoryTreeOptions lists options for CreateDirectoryTree.
type DirectoryTreeOptions struct {
Depth int
MaxSubdirsPerDirectory int
MaxFilesPerDirectory int
MaxFileSize int
MinNameLength int
MaxNameLength int
Depth int
MaxSubdirsPerDirectory int
MaxFilesPerDirectory int
MaxSymlinksPerDirectory int
MaxFileSize int
MinNameLength int
MaxNameLength int
NonExistingSymlinkTargetPercentage int // 0..100
}

// DirectoryTreeCounters stores stats about files and directories created by CreateDirectoryTree.
type DirectoryTreeCounters struct {
Files int
Directories int
Symlinks int
TotalFileSize int64
MaxFileSize int64
}
Expand All @@ -311,6 +314,8 @@ func MustCreateDirectoryTree(t *testing.T, dirname string, options DirectoryTree
if err := createDirectoryTreeInternal(dirname, options, &counters); err != nil {
t.Error(err)
}

t.Logf("created directory tree %#v", counters)
}

// CreateDirectoryTree creates a directory tree of a given depth with random files.
Expand Down Expand Up @@ -361,6 +366,8 @@ func createDirectoryTreeInternal(dirname string, options DirectoryTreeOptions, c
}
}

var fileNames []string

if options.MaxFilesPerDirectory > 0 {
numFiles := rand.Intn(options.MaxFilesPerDirectory) + 1
for i := 0; i < numFiles; i++ {
Expand All @@ -369,6 +376,19 @@ func createDirectoryTreeInternal(dirname string, options DirectoryTreeOptions, c
if err := createRandomFile(filepath.Join(dirname, fileName), options, counters); err != nil {
return errors.Wrap(err, "unable to create random file")
}

fileNames = append(fileNames, fileName)
}
}

if options.MaxSymlinksPerDirectory > 0 {
numSymlinks := rand.Intn(options.MaxSymlinksPerDirectory) + 1
for i := 0; i < numSymlinks; i++ {
fileName := randomName(options)

if err := createRandomSymlink(filepath.Join(dirname, fileName), fileNames, options, counters); err != nil {
return errors.Wrap(err, "unable to create random symlink")
}
}
}

Expand Down Expand Up @@ -401,6 +421,16 @@ func createRandomFile(filename string, options DirectoryTreeOptions, counters *D
return nil
}

func createRandomSymlink(filename string, existingFiles []string, options DirectoryTreeOptions, counters *DirectoryTreeCounters) error {
counters.Symlinks++

if len(existingFiles) == 0 || rand.Intn(100) < options.NonExistingSymlinkTargetPercentage {
return os.Symlink(randomName(options), filename)
}

return os.Symlink(existingFiles[rand.Intn(len(existingFiles))], filename)
}

func mustParseSnapshots(t *testing.T, lines []string) []SourceInfo {
var result []SourceInfo

Expand Down

0 comments on commit fce9497

Please sign in to comment.