This commit is contained in:
Aneesh N 2024-02-22 17:52:11 -07:00 committed by GitHub
commit 0e0efafad3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 1206 additions and 30 deletions

View File

@ -0,0 +1,9 @@
Enhancement: Back up and restore Windows Alternate Data Streams
Restic did not back up Alternate Data Streams in Windows. Restic now backs up Alternate Data Streams (ADS) and restores them back to the main files.
The Alternate Data Streams are backed up like any other normal files, and the full name of the stream is stored as the name of the file.
During restore, the ADS are restored and attached to the original files as Alternate Data Streams.
For progress and summary, the ADS are not counted in the file counts, but the sizes of the ADS files are counted.
https://github.com/restic/restic/pull/4614
https://github.com/restic/restic/issues/1401

View File

@ -236,18 +236,20 @@ func (arch *Archiver) SaveDir(ctx context.Context, snPath string, dir string, fi
if err != nil {
return FutureNode{}, err
}
sort.Strings(names)
pathnames := arch.preProcessPaths(dir, names)
sort.Strings(pathnames)
nodes := make([]FutureNode, 0, len(names))
nodes := make([]FutureNode, 0, len(pathnames))
for _, name := range names {
for _, pathname := range pathnames {
// test if context has been cancelled
if ctx.Err() != nil {
debug.Log("context has been cancelled, aborting")
return FutureNode{}, ctx.Err()
}
name := getNameFromPathname(pathname)
pathname := arch.processPath(dir, pathname)
pathname := arch.FS.Join(dir, name)
oldNode := previous.Find(name)
snItem := join(snPath, name)
fn, excluded, err := arch.Save(ctx, snItem, pathname, oldNode)
@ -260,7 +262,7 @@ func (arch *Archiver) SaveDir(ctx context.Context, snPath string, dir string, fi
continue
}
return FutureNode{}, err
return FutureNode{}, errors.Wrap(err, "error saving a target (file or directory)")
}
if excluded {
@ -349,6 +351,11 @@ func (arch *Archiver) Save(ctx context.Context, snPath, target string, previous
if err != nil {
return FutureNode{}, false, err
}
//In case of windows ADS files for checking include and excludes we use the main file which has the ADS files attached.
//For Unix, the main file is the same as there is no ADS. So targetMain is always the same as target.
//After checking the exclusion for actually processing the file, we use the full file name including ads portion if any.
targetMain := fs.SanitizeMainFileName(target)
abstargetMain := fs.SanitizeMainFileName(abstarget)
// exclude files by path before running Lstat to reduce number of lstat calls
if !arch.SelectByName(abstarget) {
@ -357,7 +364,7 @@ func (arch *Archiver) Save(ctx context.Context, snPath, target string, previous
}
// get file info and run remaining select functions that require file information
fi, err := arch.FS.Lstat(target)
fiMain, err := arch.FS.Lstat(targetMain)
if err != nil {
debug.Log("lstat() for %v returned error: %v", target, err)
err = arch.error(abstarget, err)
@ -366,10 +373,15 @@ func (arch *Archiver) Save(ctx context.Context, snPath, target string, previous
}
return FutureNode{}, true, nil
}
if !arch.Select(abstarget, fi) {
if !arch.Select(abstargetMain, fiMain) {
debug.Log("%v is excluded", target)
return FutureNode{}, true, nil
}
var fi os.FileInfo
fi, shouldReturn, fn, excluded, err := arch.processTargets(target, targetMain, abstarget, fiMain)
if shouldReturn {
return fn, excluded, err
}
switch {
case fs.IsRegularFile(fi):
@ -659,8 +671,9 @@ func readdirnames(filesystem fs.FS, dir string, flags int) ([]string, error) {
func resolveRelativeTargets(filesys fs.FS, targets []string) ([]string, error) {
debug.Log("targets before resolving: %v", targets)
result := make([]string, 0, len(targets))
preProcessTargets(filesys, &targets)
for _, target := range targets {
target = filesys.Clean(target)
target = processTarget(filesys, target)
pc, _ := pathComponents(filesys, target, false)
if len(pc) > 0 {
result = append(result, target)

View File

@ -0,0 +1,48 @@
//go:build !windows
// +build !windows
package archiver
import (
"os"
"github.com/restic/restic/internal/fs"
)
// preProcessTargets performs preprocessing of the targets before the loop.
// It is a no-op on non-windows OS as we do not need to do an
// extra iteration on the targets before the loop.
// We process each target inside the loop.
func preProcessTargets(_ fs.FS, _ *[]string) {
// no-op
}
// processTarget processes each target in the loop.
// In case of non-windows OS it uses the passed filesys to clean the target.
func processTarget(filesys fs.FS, target string) string {
return filesys.Clean(target)
}
// preProcessPaths processes paths before looping.
func (arch *Archiver) preProcessPaths(_ string, names []string) (paths []string) {
// In case of non-windows OS this is no-op as we process the paths within the loop
// and avoid the extra looping before hand.
return names
}
// processPath processes the path in the loop.
func (arch *Archiver) processPath(dir string, name string) (path string) {
//In case of non-windows OS we prepare the path in the loop.
return arch.FS.Join(dir, name)
}
// getNameFromPathname gets the name from pathname.
// In case for non-windows the pathname is same as the name.
func getNameFromPathname(pathname string) (name string) {
return pathname
}
// processTargets is no-op for non-windows OS
func (arch *Archiver) processTargets(_ string, _ string, _ string, fiMain os.FileInfo) (fi os.FileInfo, shouldReturn bool, fn FutureNode, excluded bool, err error) {
return fiMain, false, FutureNode{}, false, nil
}

View File

@ -0,0 +1,102 @@
package archiver
import (
"os"
"path/filepath"
"github.com/restic/restic/internal/debug"
"github.com/restic/restic/internal/errors"
"github.com/restic/restic/internal/fs"
)
// preProcessTargets performs preprocessing of the targets before the loop.
// For Windows, it cleans each target and it also adds ads stream for each
// target to the targets array.
// We read the ADS from each file and add them as independent Nodes with
// the full ADS name as the name of the file.
// During restore the ADS files are restored using the ADS name and that
// automatically attaches them as ADS to the main file.
func preProcessTargets(filesys fs.FS, targets *[]string) {
for _, target := range *targets {
target = filesys.Clean(target)
addADSStreams(target, targets)
}
}
// processTarget processes each target in the loop.
// In case of windows the clean up of target is already done
// in preProcessTargets before the loop, hence this is no-op.
func processTarget(_ fs.FS, target string) string {
return target
}
// getNameFromPathname gets the name from pathname.
// In case for windows the pathname is the full path, so it need to get the base name.
func getNameFromPathname(pathname string) (name string) {
return filepath.Base(pathname)
}
// preProcessPaths processes paths before looping.
func (arch *Archiver) preProcessPaths(dir string, names []string) (paths []string) {
// In case of windows we want to add the ADS paths as well before sorting.
return arch.getPathsIncludingADS(dir, names)
}
// processPath processes the path in the loop.
func (arch *Archiver) processPath(_ string, name string) (path string) {
// In case of windows we have already prepared the paths before the loop.
// Hence this is a no-op.
return name
}
// getPathsIncludingADS iterates all passed path names and adds the ads
// contained in those paths before returning all full paths including ads
func (arch *Archiver) getPathsIncludingADS(dir string, names []string) []string {
paths := make([]string, 0, len(names))
for _, name := range names {
pathname := arch.FS.Join(dir, name)
paths = append(paths, pathname)
addADSStreams(pathname, &paths)
}
return paths
}
// addADSStreams gets the ads streams if any in the pathname passed and adds them to the passed paths
func addADSStreams(pathname string, paths *[]string) {
success, adsStreams, err := fs.GetADStreamNames(pathname)
if success {
streamCount := len(adsStreams)
if streamCount > 0 {
debug.Log("ADS Streams for file: %s, streams: %v", pathname, adsStreams)
for i := 0; i < streamCount; i++ {
adsStream := adsStreams[i]
adsPath := pathname + adsStream
*paths = append(*paths, adsPath)
}
}
} else if err != nil {
debug.Log("No ADS found for path: %s, err: %v", pathname, err)
}
}
// processTargets in windows performs Lstat for the ADS files since the file info would not be available for them yet.
func (arch *Archiver) processTargets(target string, targetMain string, abstarget string, fiMain os.FileInfo) (fi os.FileInfo, shouldReturn bool, fn FutureNode, excluded bool, err error) {
if target != targetMain {
//If this is an ADS file we need to Lstat again for the file info.
fi, err = arch.FS.Lstat(target)
if err != nil {
debug.Log("lstat() for %v returned error: %v", target, err)
err = arch.error(abstarget, err)
if err != nil {
return nil, true, FutureNode{}, false, errors.WithStack(err)
}
//If this is an ads file, shouldReturn should be true because we want to
// skip the remaining processing of the file.
return nil, true, FutureNode{}, true, nil
}
} else {
fi = fiMain
}
return fi, false, FutureNode{}, false, nil
}

View File

@ -4,7 +4,18 @@
package archiver
import (
"context"
"os"
"path/filepath"
"sync"
"testing"
"time"
"github.com/restic/restic/internal/checker"
"github.com/restic/restic/internal/filter"
"github.com/restic/restic/internal/fs"
"github.com/restic/restic/internal/restic"
restictest "github.com/restic/restic/internal/test"
)
type wrappedFileInfo struct {
@ -26,3 +37,207 @@ func wrapFileInfo(fi os.FileInfo) os.FileInfo {
return res
}
func TestArchiverSnapshotWithAds(t *testing.T) {
// The toplevel directory is not counted in the ItemStats
var tests = []struct {
name string
src TestDir
targets []string
want TestDir
stat ItemStats
exclude []string
}{
{
name: "Ads_directory_Basic",
src: TestDir{
"dir": TestDir{
"targetfile.txt": TestFile{Content: string("foobar")},
"targetfile.txt:Stream1:$DATA": TestFile{Content: string("stream 1")},
"targetfile.txt:Stream2:$DATA": TestFile{Content: string("stream 2")},
},
},
targets: []string{"dir"},
stat: ItemStats{3, 22, 246 + 22, 2, 0, 768},
},
{
name: "Ads_folder_with_dir_streams",
src: TestDir{
"dir": TestDir{
":Stream1:$DATA": TestFile{Content: string("stream 1")},
":Stream2:$DATA": TestFile{Content: string("stream 2")},
},
},
targets: []string{"dir"},
want: TestDir{
"dir": TestDir{},
"dir:Stream1:$DATA": TestFile{Content: string("stream 1")},
"dir:Stream2:$DATA": TestFile{Content: string("stream 2")},
},
stat: ItemStats{2, 16, 164 + 16, 2, 0, 563},
},
{
name: "single_Ads_file",
src: TestDir{
"targetfile.txt": TestFile{Content: string("foobar")},
"targetfile.txt:Stream1:$DATA": TestFile{Content: string("stream 1")},
"targetfile.txt:Stream2:$DATA": TestFile{Content: string("stream 2")},
},
targets: []string{"targetfile.txt"},
stat: ItemStats{3, 22, 246 + 22, 1, 0, 457},
},
{
name: "Ads_all_types",
src: TestDir{
"dir": TestDir{
"adsfile.txt": TestFile{Content: string("foobar")},
"adsfile.txt:Stream1:$DATA": TestFile{Content: string("stream 1")},
"adsfile.txt:Stream2:$DATA": TestFile{Content: string("stream 2")},
":dirstream1:$DATA": TestFile{Content: string("stream 3")},
":dirstream2:$DATA": TestFile{Content: string("stream 4")},
},
"targetfile.txt": TestFile{Content: string("foobar")},
"targetfile.txt:Stream1:$DATA": TestFile{Content: string("stream 1")},
"targetfile.txt:Stream2:$DATA": TestFile{Content: string("stream 2")},
},
want: TestDir{
"dir": TestDir{
"adsfile.txt": TestFile{Content: string("foobar")},
"adsfile.txt:Stream1:$DATA": TestFile{Content: string("stream 1")},
"adsfile.txt:Stream2:$DATA": TestFile{Content: string("stream 2")},
},
"dir:dirstream1:$DATA": TestFile{Content: string("stream 3")},
"dir:dirstream2:$DATA": TestFile{Content: string("stream 4")},
"targetfile.txt": TestFile{Content: string("foobar")},
"targetfile.txt:Stream1:$DATA": TestFile{Content: string("stream 1")},
"targetfile.txt:Stream2:$DATA": TestFile{Content: string("stream 2")},
},
targets: []string{"targetfile.txt", "dir"},
stat: ItemStats{5, 38, 410 + 38, 2, 0, 1133},
},
{
name: "Ads_directory_exclusion",
src: TestDir{
"dir": TestDir{
"adsfile.txt": TestFile{Content: string("foobar")},
"adsfile.txt:Stream1:$DATA": TestFile{Content: string("stream 1")},
"adsfile.txt:Stream2:$DATA": TestFile{Content: string("stream 2")},
":dirstream1:$DATA": TestFile{Content: string("stream 3")},
":dirstream2:$DATA": TestFile{Content: string("stream 4")},
},
"targetfile.txt": TestFile{Content: string("foobar")},
"targetfile.txt:Stream1:$DATA": TestFile{Content: string("stream 1")},
"targetfile.txt:Stream2:$DATA": TestFile{Content: string("stream 2")},
},
want: TestDir{
"targetfile.txt": TestFile{Content: string("foobar")},
"targetfile.txt:Stream1:$DATA": TestFile{Content: string("stream 1")},
"targetfile.txt:Stream2:$DATA": TestFile{Content: string("stream 2")},
},
targets: []string{"targetfile.txt", "dir"},
exclude: []string{"*\\dir*"},
stat: ItemStats{3, 22, 268, 1, 0, 1133},
},
{
name: "Ads_backup_file_exclusion",
src: TestDir{
"dir": TestDir{
"adsfile.txt": TestFile{Content: string("foobar")},
"adsfile.txt:Stream1:$DATA": TestFile{Content: string("stream 1")},
"adsfile.txt:Stream2:$DATA": TestFile{Content: string("stream 2")},
":dirstream1:$DATA": TestFile{Content: string("stream 3")},
":dirstream2:$DATA": TestFile{Content: string("stream 4")},
},
"targetfile.txt": TestFile{Content: string("foobar")},
"targetfile.txt:Stream1:$DATA": TestFile{Content: string("stream 1")},
"targetfile.txt:Stream2:$DATA": TestFile{Content: string("stream 2")},
},
want: TestDir{
"dir": TestDir{},
"dir:dirstream1:$DATA": TestFile{Content: string("stream 3")},
"dir:dirstream2:$DATA": TestFile{Content: string("stream 4")},
"targetfile.txt": TestFile{Content: string("foobar")},
"targetfile.txt:Stream1:$DATA": TestFile{Content: string("stream 1")},
"targetfile.txt:Stream2:$DATA": TestFile{Content: string("stream 2")},
},
targets: []string{"targetfile.txt", "dir"},
exclude: []string{"*\\dir\\adsfile.txt"},
stat: ItemStats{5, 38, 448, 2, 0, 2150},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
tempdir, repo := prepareTempdirRepoSrc(t, test.src)
testFS := fs.Track{FS: fs.Local{}}
arch := New(repo, testFS, Options{})
if len(test.exclude) != 0 {
parsedPatterns := filter.ParsePatterns(test.exclude)
arch.SelectByName = func(item string) bool {
//if
if matched, err := filter.List(parsedPatterns, item); err == nil && matched {
return false
} else {
return true
}
}
}
var stat *ItemStats = &ItemStats{}
lock := &sync.Mutex{}
arch.CompleteItem = func(item string, previous, current *restic.Node, s ItemStats, d time.Duration) {
lock.Lock()
defer lock.Unlock()
stat.Add(s)
}
back := restictest.Chdir(t, tempdir)
defer back()
var targets []string
for _, target := range test.targets {
targets = append(targets, os.ExpandEnv(target))
}
sn, snapshotID, err := arch.Snapshot(ctx, targets, SnapshotOptions{Time: time.Now(), Excludes: test.exclude})
if err != nil {
t.Fatal(err)
}
t.Logf("saved as %v", snapshotID.Str())
want := test.want
if want == nil {
want = test.src
}
TestEnsureSnapshot(t, repo, snapshotID, want)
checker.TestCheckRepo(t, repo)
// check that the snapshot contains the targets with absolute paths
for i, target := range sn.Paths {
atarget, err := filepath.Abs(test.targets[i])
if err != nil {
t.Fatal(err)
}
if target != atarget {
t.Errorf("wrong path in snapshot: want %v, got %v", atarget, target)
}
}
restictest.Equals(t, uint64(test.stat.DataBlobs), uint64(stat.DataBlobs))
restictest.Equals(t, uint64(test.stat.TreeBlobs), uint64(stat.TreeBlobs))
restictest.Equals(t, test.stat.DataSize, stat.DataSize)
restictest.Equals(t, test.stat.DataSizeInRepo, stat.DataSizeInRepo)
})
}
}

View File

@ -68,11 +68,11 @@ func (s TestSymlink) String() string {
func TestCreateFiles(t testing.TB, target string, dir TestDir) {
t.Helper()
for name, item := range dir {
targetPath := filepath.Join(target, name)
targetPath := getTargetPath(target, name)
switch it := item.(type) {
case TestFile:
err := os.WriteFile(targetPath, []byte(it.Content), 0644)
err := writeFile(t, targetPath, it.Content)
if err != nil {
t.Fatal(err)
}

View File

@ -0,0 +1,20 @@
//go:build !windows
// +build !windows
package archiver
import (
"os"
"path/filepath"
"testing"
)
// getTargetPath gets the target path from the target and the name
func getTargetPath(target string, name string) (targetPath string) {
return filepath.Join(target, name)
}
// writeFile writes the content to the file at the targetPath
func writeFile(_ testing.TB, targetPath string, content string) (err error) {
return os.WriteFile(targetPath, []byte(content), 0644)
}

View File

@ -0,0 +1,43 @@
package archiver
import (
"os"
"path/filepath"
"testing"
"github.com/restic/restic/internal/fs"
)
// getTargetPath gets the target path from the target and the name
func getTargetPath(target string, name string) (targetPath string) {
if name[0] == ':' {
// If the first char of the name is :, append the name to the targetPath.
// This switch is useful for cases like creating directories having ads attributes attached.
// Without this, if we put the directory ads creation at top level, eg. "dir" and "dir:dirstream1:$DATA",
// since they can be created in any order it could first create an empty file called "dir" with the ads
// stream and then the dir creation fails.
targetPath = target + name
} else {
targetPath = filepath.Join(target, name)
}
return targetPath
}
// writeFile writes the content to the file at the targetPath
func writeFile(t testing.TB, targetPath string, content string) (err error) {
//For windows, create file only if it doesn't exist. Otherwise ads streams may get overwritten.
f, err := os.OpenFile(targetPath, os.O_WRONLY|os.O_TRUNC, 0644)
if os.IsNotExist(err) {
f, err = os.OpenFile(targetPath, os.O_WRONLY|fs.O_CREATE|os.O_TRUNC, 0644)
}
if err != nil {
t.Fatal(err)
}
_, err = f.Write([]byte(content))
if err1 := f.Close(); err1 != nil && err == nil {
err = err1
}
return err
}

View File

@ -5,6 +5,7 @@ import (
"strings"
"github.com/restic/restic/internal/errors"
"github.com/restic/restic/internal/fs"
)
// ErrBadString is returned when Match is called with the empty string as the
@ -200,9 +201,9 @@ func match(pattern Pattern, strs []string) (matched bool, err error) {
for i := len(pattern.parts) - 1; i >= 0; i-- {
var ok bool
if pattern.parts[i].isSimple {
ok = pattern.parts[i].pattern == strs[offset+i]
ok = pattern.parts[i].pattern == fs.SanitizeMainFileName(strs[offset+i])
} else {
ok, err = filepath.Match(pattern.parts[i].pattern, strs[offset+i])
ok, err = filepath.Match(pattern.parts[i].pattern, fs.SanitizeMainFileName(strs[offset+i]))
if err != nil {
return false, errors.Wrap(err, "Match")
}

144
internal/fs/ads_windows.go Normal file
View File

@ -0,0 +1,144 @@
//go:build windows
// +build windows
package fs
import (
"path/filepath"
"strings"
"syscall"
"unsafe"
"golang.org/x/sys/windows"
)
var (
kernel32dll = syscall.NewLazyDLL("kernel32.dll")
findFirstStreamW = kernel32dll.NewProc("FindFirstStreamW")
findNextStreamW = kernel32dll.NewProc("FindNextStreamW")
findClose = kernel32dll.NewProc("FindClose")
)
type (
HANDLE uintptr
)
const (
maxPath = 296
streamInfoLevelStandard = 0
invalidFileHandle = ^HANDLE(0)
)
type Win32FindStreamData struct {
size int64
name [maxPath]uint16
}
/*
HANDLE WINAPI FindFirstStreamW(
__in LPCWSTR lpFileName,
__in STREAM_INFO_LEVELS InfoLevel, (0 standard, 1 max infos)
__out LPVOID lpFindStreamData, (return information about file in a WIN32_FIND_STREAM_DATA if 0 is given in infos_level
__reserved DWORD dwFlags (Reserved for future use. This parameter must be zero.) cf: doc
);
https://msdn.microsoft.com/en-us/library/aa364424(v=vs.85).aspx
*/
// GetADStreamNames returns the ads stream names for the passed fileName.
// If success is true, it means ADS files were found.
func GetADStreamNames(fileName string) (success bool, streamNames []string, err error) {
h, success, firstname, err := findFirstStream(fileName)
defer closeHandle(h)
if success {
if !strings.Contains(firstname, "::") {
//If fileName is a directory which has ADS, the ADS name comes in the first stream itself between the two :
//file ads firstname comes as ::$DATA
streamNames = append(streamNames, firstname)
}
for {
endStream, name, err2 := findNextStream(h)
err = err2
if endStream {
break
}
streamNames = append(streamNames, name)
}
}
// If the handle is found successfully, success is true, but the windows api
// still returns an error object. It doesn't mean that an error occurred.
if isHandleEOFError(err) {
// This error is expected, we don't need to expose it.
err = nil
}
return success, streamNames, err
}
// findFirstStream gets the handle and stream type for the first stream
// If the handle is found successfully, success is true, but the windows api
// still returns an error object. It doesn't mean that an error occurred.
func findFirstStream(fileName string) (handle HANDLE, success bool, streamType string, err error) {
fsd := &Win32FindStreamData{}
ptr, err := syscall.UTF16PtrFromString(fileName)
if err != nil {
return invalidFileHandle, false, "<nil>", err
}
ret, _, err := findFirstStreamW.Call(
uintptr(unsafe.Pointer(ptr)),
streamInfoLevelStandard,
uintptr(unsafe.Pointer(fsd)),
0,
)
h := HANDLE(ret)
streamType = windows.UTF16ToString(fsd.name[:])
return h, h != invalidFileHandle, streamType, err
}
// findNextStream finds the next ads stream name
// endStream indicites if this is the last stream, name is the stream name.
// err being returned does not mean an error occurred.
func findNextStream(handle HANDLE) (endStream bool, name string, err error) {
fsd := &Win32FindStreamData{}
ret, _, err := findNextStreamW.Call(
uintptr(handle),
uintptr(unsafe.Pointer(fsd)),
)
name = windows.UTF16ToString(fsd.name[:])
return ret != 1, name, err
}
// closeHandle closes the passed handle
func closeHandle(handle HANDLE) bool {
ret, _, _ := findClose.Call(
uintptr(handle),
)
return ret != 0
}
// TrimAds trims the ads file part from the passed filename and returns the base name.
func TrimAds(str string) string {
dir, filename := filepath.Split(str)
if strings.Contains(filename, ":") {
out := filepath.Join(dir, strings.Split(filename, ":")[0])
return out
} else {
return str
}
}
// IsAds checks if the passed file name is an ads file.
func IsAds(str string) bool {
filename := filepath.Base(str)
// Only ADS filenames can contain ":" in windows.
return strings.Contains(filename, ":")
}
// isHandleEOFError checks if the error is ERROR_HANDLE_EOF
func isHandleEOFError(err error) bool {
// Use a type assertion to check if the error is of type syscall.Errno
if errno, ok := err.(syscall.Errno); ok {
// Compare the error code to the expected value
return errno == syscall.ERROR_HANDLE_EOF
}
return false
}

View File

@ -0,0 +1,188 @@
//go:build windows
// +build windows
package fs_test
import (
"math/rand"
"os"
"strconv"
"sync"
"testing"
"github.com/restic/restic/internal/fs"
rtest "github.com/restic/restic/internal/test"
)
var (
testFileName = "TestingAds.txt"
testFilePath string
adsFileName = ":AdsName"
testData = "This is the main data stream."
testDataAds = "This is an alternate data stream "
goWG sync.WaitGroup
dataSize int
)
const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
func TestAdsFile(t *testing.T) {
// create a temp test file
for i := 0; i < 5; i++ {
dataSize = 10000000 * i
testData = testData + randStringBytesRmndr(dataSize)
testDataAds = testDataAds + randStringBytesRmndr(dataSize)
//Testing with multiple ads streams in sequence.
testAdsForCount(i, t)
}
}
func testAdsForCount(adsTestCount int, t *testing.T) {
makeTestFile(adsTestCount)
defer os.Remove(testFilePath)
success, streams, errGA := fs.GetADStreamNames(testFilePath)
rtest.Assert(t, success, "GetADStreamNames status. error: %v", errGA)
rtest.Assert(t, len(streams) == adsTestCount, "Stream found: %v", streams)
adsCount := len(streams)
goWG.Add(1)
go ReadMain(t)
goWG.Add(adsCount)
for i := 0; i < adsCount; i++ {
//Writing ADS to the file concurrently
go ReadAds(i, t)
}
goWG.Wait()
os.Remove(testFilePath)
}
func ReadMain(t *testing.T) {
defer goWG.Done()
data, errR := os.ReadFile(testFilePath)
rtest.OK(t, errR)
dataString := string(data)
rtest.Assert(t, dataString == testData, "Data read: %v", len(dataString))
}
func ReadAds(i int, t *testing.T) {
defer goWG.Done()
dataAds, errAds := os.ReadFile(testFilePath + adsFileName + strconv.Itoa(i))
rtest.OK(t, errAds)
rtest.Assert(t, errAds == nil, "GetADStreamNames status. error: %v", errAds)
dataStringAds := string(dataAds)
rtest.Assert(t, dataStringAds == testDataAds+strconv.Itoa(i)+".\n", "Ads Data read: %v", len(dataStringAds))
}
func makeTestFile(adsCount int) error {
f, err := os.CreateTemp("", testFileName)
if err != nil {
panic(err)
}
testFilePath = f.Name()
defer f.Close()
if adsCount == 0 || adsCount == 1 {
goWG.Add(1)
//Writing main file
go WriteMain(err, f)
}
goWG.Add(adsCount)
for i := 0; i < adsCount; i++ {
//Writing ADS to the file concurrently while main file also gets written
go WriteADS(i)
if i == 1 {
//Testing some cases where the main file writing may start after the ads streams writing has started.
//These cases are tested when adsCount > 1. In this case we start writing the main file after starting to write ads.
goWG.Add(1)
go WriteMain(err, f)
}
}
goWG.Wait()
return nil
}
func WriteMain(err error, f *os.File) (bool, error) {
defer goWG.Done()
_, err1 := f.Write([]byte(testData))
if err1 != nil {
return true, err
}
return false, err
}
func WriteADS(i int) (bool, error) {
defer goWG.Done()
a, err := os.Create(testFilePath + adsFileName + strconv.Itoa(i))
if err != nil {
return true, err
}
defer a.Close()
_, err = a.Write([]byte(testDataAds + strconv.Itoa(i) + ".\n"))
if err != nil {
return true, err
}
return false, nil
}
func randStringBytesRmndr(n int) string {
b := make([]byte, n)
for i := range b {
b[i] = letterBytes[rand.Int63()%int64(len(letterBytes))]
}
return string(b)
}
func TestTrimAds(t *testing.T) {
tests := []struct {
input string
output string
}{
{input: "d:\\test.txt:stream1:$DATA", output: "d:\\test.txt"},
{input: "test.txt:stream1:$DATA", output: "test.txt"},
{input: "test.txt", output: "test.txt"},
{input: "\\abc\\test.txt:stream1:$DATA", output: "\\abc\\test.txt"},
{input: "\\abc\\", output: "\\abc\\"},
{input: "\\", output: "\\"},
}
for _, test := range tests {
t.Run("", func(t *testing.T) {
output := fs.TrimAds(test.input)
rtest.Equals(t, test.output, output)
})
}
}
func TestIsAds(t *testing.T) {
tests := []struct {
input string
result bool
}{
{input: "d:\\test.txt:stream1:$DATA", result: true},
{input: "test.txt:stream1:$DATA", result: true},
{input: "test.txt", result: false},
{input: "\\abc\\test.txt:stream1:$DATA", result: true},
{input: "\\abc\\", result: false},
{input: "\\", result: false},
}
for _, test := range tests {
t.Run("", func(t *testing.T) {
output := fs.IsAds(test.input)
rtest.Equals(t, test.result, output)
})
}
}

View File

@ -48,3 +48,15 @@ func Chmod(name string, mode os.FileMode) error {
return err
}
// IsMainFile specifies if this is the main file or a secondary attached file (like ADS in case of windows)
func IsMainFile(_ string) bool {
// no-op - In case of OS other than windows this is always true
return true
}
// SanitizeMainFileName will only keep the main file and remove the secondary file.
func SanitizeMainFileName(str string) string {
// no-op - In case of non-windows there is no secondary file
return str
}

View File

@ -77,3 +77,18 @@ func TempFile(dir, prefix string) (f *os.File, err error) {
func Chmod(name string, mode os.FileMode) error {
return os.Chmod(fixpath(name), mode)
}
// IsMainFile specifies if this is the main file or a secondary attached file (like ADS in case of windows)
// This is used for functionalities we want to skip for secondary (ads) files.
// Eg. For Windows we do not want to count the secondary files
func IsMainFile(name string) bool {
return !IsAds(name)
}
// SanitizeMainFileName will only keep the main file and remove the secondary file like ADS from the name.
func SanitizeMainFileName(str string) string {
// The ADS is essentially a part of the main file. So for any functionality that
// needs to consider the main file, like filtering, we need to derive the main file name
// from the ADS name.
return TrimAds(str)
}

View File

@ -51,14 +51,7 @@ func (w *filesWriter) writeToFile(path string, blob []byte, offset int64, create
return wr, nil
}
var flags int
if createSize >= 0 {
flags = os.O_CREATE | os.O_TRUNC | os.O_WRONLY
} else {
flags = os.O_WRONLY
}
f, err := os.OpenFile(path, flags, 0600)
f, err := w.OpenFile(createSize, path)
if err != nil {
return nil, err
}

View File

@ -0,0 +1,21 @@
//go:build !windows
// +build !windows
package restorer
import "os"
// OpenFile opens the file with create, truncate and write only options if
// createSize is specified greater than 0 i.e. if the file hasn't already
// been created. Otherwise it opens the file with only write only option.
func (*filesWriter) OpenFile(createSize int64, path string) (*os.File, error) {
var flags int
if createSize >= 0 {
flags = os.O_CREATE | os.O_TRUNC | os.O_WRONLY
} else {
flags = os.O_WRONLY
}
f, err := os.OpenFile(path, flags, 0600)
return f, err
}

View File

@ -0,0 +1,35 @@
package restorer
import "os"
// OpenFile opens the file with truncate and write only options.
// In case of windows, it first attempts to open an existing file,
// and only if the file does not exist, it opens it with create option
// in order to create the file. This is done, otherwise if the ads stream
// is written first (in which case it automatically creates an empty main
// file before writing the stream) and then when the main file is written
// later, the ads stream can be overwritten.
func (*filesWriter) OpenFile(createSize int64, path string) (*os.File, error) {
var flags int
var f *os.File
var err error
// TODO optimize this. When GenericAttribute change is merged, we can
// leverage that here. If a file has ADS, we will have a GenericAttribute of
// type TypeADS added in the file with values as a string of ads stream names
// and we will do the following only if the TypeADS attribute is found in the node.
// Otherwise we will directly just use the create option while opening the file.
if createSize >= 0 {
flags = os.O_TRUNC | os.O_WRONLY
f, err = os.OpenFile(path, flags, 0600)
if err != nil && os.IsNotExist(err) {
//If file not exists open with create flag
flags = os.O_CREATE | os.O_TRUNC | os.O_WRONLY
f, err = os.OpenFile(path, flags, 0600)
}
} else {
flags = os.O_WRONLY
f, err = os.OpenFile(path, flags, 0600)
}
return f, err
}

View File

@ -242,7 +242,7 @@ func (res *Restorer) RestoreTo(ctx context.Context, dst string) error {
enterDir: func(_ *restic.Node, target, location string) error {
debug.Log("first pass, enterDir: mkdir %q, leaveDir should restore metadata", location)
if res.progress != nil {
res.progress.AddFile(0)
res.addFile(node, 0)
}
// create dir with default permissions
// #leaveDir restores dir metadata after visiting all children
@ -260,14 +260,14 @@ func (res *Restorer) RestoreTo(ctx context.Context, dst string) error {
if node.Type != "file" {
if res.progress != nil {
res.progress.AddFile(0)
res.addFile(node, 0)
}
return nil
}
if node.Size == 0 {
if res.progress != nil {
res.progress.AddFile(node.Size)
res.addFile(node, node.Size)
}
return nil // deal with empty files later
}
@ -276,7 +276,7 @@ func (res *Restorer) RestoreTo(ctx context.Context, dst string) error {
if idx.Has(node.Inode, node.DeviceID) {
if res.progress != nil {
// a hardlinked file does not increase the restore size
res.progress.AddFile(0)
res.addFile(node, 0)
}
return nil
}
@ -284,7 +284,7 @@ func (res *Restorer) RestoreTo(ctx context.Context, dst string) error {
}
if res.progress != nil {
res.progress.AddFile(node.Size)
res.addFile(node, node.Size)
}
filerestorer.addFile(location, node.Content, int64(node.Size))
@ -336,6 +336,15 @@ func (res *Restorer) RestoreTo(ctx context.Context, dst string) error {
return err
}
func (res *Restorer) addFile(node *restic.Node, size uint64) {
if fs.IsMainFile(node.Name) {
res.progress.AddFile(size)
} else {
// If this is not the main file, we just want to update the size and not the count.
res.progress.AddSize(size)
}
}
// Snapshot returns the snapshot this restorer is configured to use.
func (res *Restorer) Snapshot() *restic.Snapshot {
return res.sn

View File

@ -4,12 +4,21 @@
package restorer
import (
"context"
"math"
"os"
"path"
"path/filepath"
"syscall"
"testing"
"time"
"unsafe"
"github.com/restic/restic/internal/errors"
"github.com/restic/restic/internal/repository"
"github.com/restic/restic/internal/restic"
rtest "github.com/restic/restic/internal/test"
"golang.org/x/sync/errgroup"
"golang.org/x/sys/windows"
)
@ -33,3 +42,285 @@ func getBlockCount(t *testing.T, filename string) int64 {
return int64(math.Ceil(float64(result) / 512))
}
type AdsTestInfo struct {
dirName string
fileOrder []int
fileStreamNames []string
Overwrite bool
}
type NamedNode struct {
name string
node Node
}
type OrderedSnapshot struct {
nodes []NamedNode
}
type OrderedDir struct {
Nodes []NamedNode
Mode os.FileMode
ModTime time.Time
}
func TestOrderedAdsFile(t *testing.T) {
files := []string{"mainadsfile.text", "mainadsfile.text:datastream1:$DATA", "mainadsfile.text:datastream2:$DATA"}
dataArray := []string{"Main file data.", "First data stream.", "Second data stream."}
var tests = map[string]AdsTestInfo{
"main-stream-first": {
dirName: "dir", fileStreamNames: files,
fileOrder: []int{0, 1, 2},
},
"second-stream-first": {
dirName: "dir", fileStreamNames: files,
fileOrder: []int{1, 0, 2},
},
"main-stream-first-already-exists": {
dirName: "dir", fileStreamNames: files,
fileOrder: []int{0, 1, 2},
Overwrite: true,
},
"second-stream-first-already-exists": {
dirName: "dir", fileStreamNames: files,
fileOrder: []int{1, 0, 2},
Overwrite: true,
},
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
for name, test := range tests {
t.Run(name, func(t *testing.T) {
tempdir := rtest.TempDir(t)
nodes := getOrderedAdsNodes(test.dirName, test.fileOrder, test.fileStreamNames[:], dataArray)
res := setup(t, nodes)
if test.Overwrite {
os.Mkdir(path.Join(tempdir, test.dirName), os.ModeDir)
//Create existing files
for _, f := range files {
data := []byte("This is some dummy data.")
filepath := path.Join(tempdir, test.dirName, f)
// Write the data to the file
err := os.WriteFile(path.Clean(filepath), data, 0644)
rtest.OK(t, err)
}
}
res.SelectFilter = adsConflictFilter
err := res.RestoreTo(ctx, tempdir)
rtest.OK(t, err)
for _, fileIndex := range test.fileOrder {
currentFile := test.fileStreamNames[fileIndex]
fp := path.Join(tempdir, test.dirName, currentFile)
fi, err1 := os.Stat(fp)
rtest.Assert(t, !errors.Is(err1, os.ErrNotExist), "The file "+currentFile+" does not exist")
size := fi.Size()
rtest.Assert(t, size > 0, "The file "+currentFile+" exists but is empty")
content, err := os.ReadFile(fp)
rtest.OK(t, err)
contentString := string(content)
rtest.Assert(t, contentString == dataArray[fileIndex], "The file "+currentFile+" exists but the content is not overwritten")
}
})
}
}
func getOrderedAdsNodes(dir string, order []int, allFileNames []string, dataArray []string) []NamedNode {
getFileNodes := func() []NamedNode {
nodes := []NamedNode{}
for _, index := range order {
file := allFileNames[index]
nodes = append(nodes, NamedNode{
name: file,
node: File{
ModTime: time.Now(),
Data: dataArray[index],
},
})
}
return nodes
}
return []NamedNode{
{
name: dir,
node: OrderedDir{
Mode: normalizeFileMode(0750 | os.ModeDir),
ModTime: time.Now(),
Nodes: getFileNodes(),
},
},
}
}
func adsConflictFilter(item string, dstpath string, node *restic.Node) (selectedForRestore bool, childMayBeSelected bool) {
switch filepath.ToSlash(item) {
case "/dir":
childMayBeSelected = true
case "/dir/mainadsfile.text":
selectedForRestore = true
childMayBeSelected = false
case "/dir/mainadsfile.text:datastream1:$DATA":
selectedForRestore = true
childMayBeSelected = false
case "/dir/mainadsfile.text:datastream2:$DATA":
selectedForRestore = true
childMayBeSelected = false
case "/dir/dir":
selectedForRestore = true
childMayBeSelected = true
case "/dir/dir:dirstream1:$DATA":
selectedForRestore = true
childMayBeSelected = false
case "/dir/dir:dirstream2:$DATA":
selectedForRestore = true
childMayBeSelected = false
}
return selectedForRestore, childMayBeSelected
}
func setup(t *testing.T, namedNodes []NamedNode) *Restorer {
repo := repository.TestRepository(t)
sn, _ := saveOrderedSnapshot(t, repo, OrderedSnapshot{
nodes: namedNodes,
})
res := NewRestorer(repo, sn, false, nil)
return res
}
func saveDirOrdered(t testing.TB, repo restic.Repository, namedNodes []NamedNode, inode uint64) restic.ID {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
tree := &restic.Tree{}
for _, namedNode := range namedNodes {
name := namedNode.name
n := namedNode.node
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,
})
rtest.OK(t, err)
case Dir:
id := saveDir(t, repo, node.Nodes, inode)
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,
})
rtest.OK(t, err)
case OrderedDir:
id := saveDirOrdered(t, repo, node.Nodes, inode)
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,
})
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 saveOrderedSnapshot(t testing.TB, repo restic.Repository, snapshot OrderedSnapshot) (*restic.Snapshot, restic.ID) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
wg, wgCtx := errgroup.WithContext(ctx)
repo.StartPackUploader(wgCtx, wg)
treeID := saveDirOrdered(t, repo, snapshot.nodes, 1000)
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
}

View File

@ -5,6 +5,7 @@ import (
"time"
"github.com/restic/restic/internal/archiver"
"github.com/restic/restic/internal/fs"
"github.com/restic/restic/internal/restic"
"github.com/restic/restic/internal/ui/progress"
)
@ -180,19 +181,25 @@ func (p *Progress) CompleteItem(item string, previous, current *restic.Node, s a
case previous == nil:
p.printer.CompleteItem("file new", item, s, d)
p.mu.Lock()
p.summary.Files.New++
if fs.IsMainFile(item) {
p.summary.Files.New++
}
p.mu.Unlock()
case previous.Equals(*current):
p.printer.CompleteItem("file unchanged", item, s, d)
p.mu.Lock()
p.summary.Files.Unchanged++
if fs.IsMainFile(item) {
p.summary.Files.Unchanged++
}
p.mu.Unlock()
default:
p.printer.CompleteItem("file modified", item, s, d)
p.mu.Lock()
p.summary.Files.Changed++
if fs.IsMainFile(item) {
p.summary.Files.Changed++
}
p.mu.Unlock()
}
}

View File

@ -4,6 +4,7 @@ import (
"sync"
"time"
"github.com/restic/restic/internal/fs"
"github.com/restic/restic/internal/ui/progress"
)
@ -61,8 +62,15 @@ func (p *Progress) update(runtime time.Duration, final bool) {
func (p *Progress) AddFile(size uint64) {
p.m.Lock()
defer p.m.Unlock()
p.filesTotal++
p.allBytesTotal += size
}
// AddSize starts tracking a new file with the given size
func (p *Progress) AddSize(size uint64) {
p.m.Lock()
defer p.m.Unlock()
p.allBytesTotal += size
}
@ -81,7 +89,9 @@ func (p *Progress) AddProgress(name string, bytesWrittenPortion uint64, bytesTot
p.allBytesWritten += bytesWrittenPortion
if entry.bytesWritten == entry.bytesTotal {
delete(p.progressInfoMap, name)
p.filesFinished++
if fs.IsMainFile(name) {
p.filesFinished++
}
}
}