aboutsummaryrefslogtreecommitdiff
path: root/fstest/fstest.go
diff options
context:
space:
mode:
Diffstat (limited to 'fstest/fstest.go')
-rw-r--r--fstest/fstest.go651
1 files changed, 651 insertions, 0 deletions
diff --git a/fstest/fstest.go b/fstest/fstest.go
new file mode 100644
index 0000000..a0205a1
--- /dev/null
+++ b/fstest/fstest.go
@@ -0,0 +1,651 @@
+// Package fstest provides utilities for testing the Fs
+package fstest
+
+// FIXME put name of test FS in Fs structure
+
+import (
+ "bytes"
+ "compress/gzip"
+ "context"
+ "flag"
+ "fmt"
+ "io"
+ "os"
+ "path"
+ "path/filepath"
+ "regexp"
+ "runtime"
+ "sort"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/rclone/rclone/fs"
+ "github.com/rclone/rclone/fs/accounting"
+ "github.com/rclone/rclone/fs/config"
+ "github.com/rclone/rclone/fs/config/configfile"
+ "github.com/rclone/rclone/fs/hash"
+ "github.com/rclone/rclone/fs/log"
+ "github.com/rclone/rclone/fs/walk"
+ "github.com/rclone/rclone/fstest/testy"
+ "github.com/rclone/rclone/lib/random"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "golang.org/x/text/unicode/norm"
+)
+
+// Globals
+var (
+ RemoteName = flag.String("remote", "", "Remote to test with, defaults to local filesystem")
+ Verbose = flag.Bool("verbose", false, "Set to enable logging")
+ DumpHeaders = flag.Bool("dump-headers", false, "Set to dump headers (needs -verbose)")
+ DumpBodies = flag.Bool("dump-bodies", false, "Set to dump bodies (needs -verbose)")
+ Individual = flag.Bool("individual", false, "Make individual bucket/container/directory for each test - much slower")
+ LowLevelRetries = flag.Int("low-level-retries", 10, "Number of low level retries")
+ UseListR = flag.Bool("fast-list", false, "Use recursive list if available. Uses more memory but fewer transactions.")
+ // SizeLimit signals tests to skip maximum test file size and skip inappropriate runs
+ SizeLimit = flag.Int64("size-limit", 0, "Limit maximum test file size")
+ // ListRetries is the number of times to retry a listing to overcome eventual consistency
+ ListRetries = flag.Int("list-retries", 3, "Number or times to retry listing")
+ // MatchTestRemote matches the remote names used for testing
+ MatchTestRemote = regexp.MustCompile(`^rclone-test-[abcdefghijklmnopqrstuvwxyz0123456789]{12}$`)
+)
+
+// Initialise rclone for testing
+func Initialise() {
+ ctx := context.Background()
+ ci := fs.GetConfig(ctx)
+ // Never ask for passwords, fail instead.
+ // If your local config is encrypted set environment variable
+ // "RCLONE_CONFIG_PASS=hunter2" (or your password)
+ ci.AskPassword = false
+ // Override the config file from the environment - we don't
+ // parse the flags any more so this doesn't happen
+ // automatically
+ if envConfig := os.Getenv("RCLONE_CONFIG"); envConfig != "" {
+ _ = config.SetConfigPath(envConfig)
+ }
+ if *RemoteName == "local" {
+ *RemoteName = ""
+ }
+ configfile.Install()
+ accounting.Start(ctx)
+ if *Verbose {
+ ci.LogLevel = fs.LogLevelDebug
+ }
+ if *DumpHeaders {
+ ci.Dump |= fs.DumpHeaders
+ }
+ if *DumpBodies {
+ ci.Dump |= fs.DumpBodies
+ }
+ ci.LowLevelRetries = *LowLevelRetries
+ ci.UseListR = *UseListR
+ log.InitLogging()
+ _ = fs.LogReload(ci)
+}
+
+// Item represents an item for checking
+type Item struct {
+ Path string
+ Hashes map[hash.Type]string
+ ModTime time.Time
+ Size int64
+}
+
+// NewItem creates an item from a string content
+func NewItem(Path, Content string, modTime time.Time) Item {
+ i := Item{
+ Path: Path,
+ ModTime: modTime,
+ Size: int64(len(Content)),
+ }
+ hash := hash.NewMultiHasher()
+ buf := bytes.NewBufferString(Content)
+ _, err := io.Copy(hash, buf)
+ if err != nil {
+ fs.Fatalf(nil, "Failed to create item: %v", err)
+ }
+ i.Hashes = hash.Sums()
+ return i
+}
+
+// CheckTimeEqualWithPrecision checks the times are equal within the
+// precision, returns the delta and a flag
+func CheckTimeEqualWithPrecision(t0, t1 time.Time, precision time.Duration) (time.Duration, bool) {
+ dt := t0.Sub(t1)
+ if dt >= precision || dt <= -precision {
+ return dt, false
+ }
+ return dt, true
+}
+
+// AssertTimeEqualWithPrecision checks that want is within precision
+// of got, asserting that with t and logging remote
+func AssertTimeEqualWithPrecision(t *testing.T, remote string, want, got time.Time, precision time.Duration) {
+ dt, ok := CheckTimeEqualWithPrecision(want, got, precision)
+ assert.True(t, ok, fmt.Sprintf("%s: Modification time difference too big |%s| > %s (want %s vs got %s) (precision %s)", remote, dt, precision, want, got, precision))
+}
+
+// CheckModTime checks the mod time to the given precision
+func (i *Item) CheckModTime(t *testing.T, obj fs.Object, modTime time.Time, precision time.Duration) {
+ AssertTimeEqualWithPrecision(t, obj.Remote(), i.ModTime, modTime, precision)
+}
+
+// CheckHashes checks all the hashes the object supports are correct
+func (i *Item) CheckHashes(t *testing.T, obj fs.Object) {
+ require.NotNil(t, obj)
+ types := obj.Fs().Hashes().Array()
+ for _, Hash := range types {
+ // Check attributes
+ sum, err := obj.Hash(context.Background(), Hash)
+ require.NoError(t, err)
+ assert.True(t, hash.Equals(i.Hashes[Hash], sum), fmt.Sprintf("%s/%s: %v hash incorrect - expecting %q got %q", obj.Fs().String(), obj.Remote(), Hash, i.Hashes[Hash], sum))
+ }
+}
+
+// Check checks all the attributes of the object are correct
+func (i *Item) Check(t *testing.T, obj fs.Object, precision time.Duration) {
+ i.CheckHashes(t, obj)
+ assert.Equal(t, i.Size, obj.Size(), fmt.Sprintf("%s: size incorrect file=%d vs obj=%d", i.Path, i.Size, obj.Size()))
+ i.CheckModTime(t, obj, obj.ModTime(context.Background()), precision)
+}
+
+// Normalize runs a utf8 normalization on the string if running on OS
+// X. This is because OS X denormalizes file names it writes to the
+// local file system.
+func Normalize(name string) string {
+ if runtime.GOOS == "darwin" {
+ name = norm.NFC.String(name)
+ }
+ return name
+}
+
+// Items represents all items for checking
+type Items struct {
+ byName map[string]*Item
+ byNameAlt map[string]*Item
+ items []Item
+}
+
+// NewItems makes an Items
+func NewItems(items []Item) *Items {
+ is := &Items{
+ byName: make(map[string]*Item),
+ byNameAlt: make(map[string]*Item),
+ items: items,
+ }
+ // Fill up byName
+ for i := range items {
+ is.byName[Normalize(items[i].Path)] = &items[i]
+ }
+ return is
+}
+
+// Find checks off an item
+func (is *Items) Find(t *testing.T, obj fs.Object, precision time.Duration) {
+ remote := Normalize(obj.Remote())
+ i, ok := is.byName[remote]
+ if !ok {
+ i, ok = is.byNameAlt[remote]
+ assert.True(t, ok, fmt.Sprintf("Unexpected file %q", remote))
+ }
+ if i != nil {
+ delete(is.byName, i.Path)
+ i.Check(t, obj, precision)
+ }
+}
+
+// Done checks all finished
+func (is *Items) Done(t *testing.T) {
+ if len(is.byName) != 0 {
+ for name := range is.byName {
+ t.Logf("Not found %q", name)
+ }
+ }
+ assert.Equal(t, 0, len(is.byName), fmt.Sprintf("%d objects not found", len(is.byName)))
+}
+
+// makeListingFromItems returns a string representation of the items
+//
+// it returns two possible strings, one normal and one for windows
+func makeListingFromItems(items []Item) string {
+ nameLengths := make([]string, len(items))
+ for i, item := range items {
+ remote := Normalize(item.Path)
+ nameLengths[i] = fmt.Sprintf("%s (%d)", remote, item.Size)
+ }
+ sort.Strings(nameLengths)
+ return strings.Join(nameLengths, ", ")
+}
+
+// makeListingFromObjects returns a string representation of the objects
+func makeListingFromObjects(objs []fs.Object) string {
+ nameLengths := make([]string, len(objs))
+ for i, obj := range objs {
+ nameLengths[i] = fmt.Sprintf("%s (%d)", Normalize(obj.Remote()), obj.Size())
+ }
+ sort.Strings(nameLengths)
+ return strings.Join(nameLengths, ", ")
+}
+
+// filterEmptyDirs removes any empty (or containing only directories)
+// directories from expectedDirs
+func filterEmptyDirs(t *testing.T, items []Item, expectedDirs []string) (newExpectedDirs []string) {
+ dirs := map[string]struct{}{"": {}}
+ for _, item := range items {
+ base := item.Path
+ for {
+ base = path.Dir(base)
+ if base == "." || base == "/" {
+ break
+ }
+ dirs[base] = struct{}{}
+ }
+ }
+ for _, expectedDir := range expectedDirs {
+ if _, found := dirs[expectedDir]; found {
+ newExpectedDirs = append(newExpectedDirs, expectedDir)
+ } else {
+ t.Logf("Filtering empty directory %q", expectedDir)
+ }
+ }
+ return newExpectedDirs
+}
+
+// CheckListingWithRoot checks the fs to see if it has the
+// expected contents with the given precision.
+//
+// If expectedDirs is non nil then we check those too. Note that no
+// directories returned is also OK as some remotes don't return
+// directories.
+//
+// dir is the directory used for the listing.
+func CheckListingWithRoot(t *testing.T, f fs.Fs, dir string, items []Item, expectedDirs []string, precision time.Duration) {
+ if expectedDirs != nil && !f.Features().CanHaveEmptyDirectories {
+ expectedDirs = filterEmptyDirs(t, items, expectedDirs)
+ }
+ is := NewItems(items)
+ ctx := context.Background()
+ oldErrors := accounting.Stats(ctx).GetErrors()
+ var objs []fs.Object
+ var dirs []fs.Directory
+ var err error
+ retries := *ListRetries
+ sleep := time.Second / 2
+ wantListing := makeListingFromItems(items)
+ gotListing := "<unset>"
+ listingOK := false
+ for i := 1; i <= retries; i++ {
+ objs, dirs, err = walk.GetAll(ctx, f, dir, true, -1)
+ if err != nil && err != fs.ErrorDirNotFound {
+ t.Fatalf("Error listing: %v", err)
+ }
+ gotListing = makeListingFromObjects(objs)
+
+ listingOK = wantListing == gotListing
+ if listingOK && (expectedDirs == nil || len(dirs) == len(expectedDirs)) {
+ // Put an extra sleep in if we did any retries just to make sure it really
+ // is consistent
+ if i != 1 {
+ extraSleep := 5*time.Second + sleep
+ t.Logf("Sleeping for %v just to make sure", extraSleep)
+ time.Sleep(extraSleep)
+ }
+ break
+ }
+ sleep *= 2
+ t.Logf("Sleeping for %v for list eventual consistency: %d/%d", sleep, i, retries)
+ time.Sleep(sleep)
+ if doDirCacheFlush := f.Features().DirCacheFlush; doDirCacheFlush != nil {
+ t.Logf("Flushing the directory cache")
+ doDirCacheFlush()
+ }
+ }
+ assert.True(t, listingOK, fmt.Sprintf("listing wrong, want\n %s got\n %s", wantListing, gotListing))
+ for _, obj := range objs {
+ require.NotNil(t, obj)
+ is.Find(t, obj, precision)
+ }
+ is.Done(t)
+ // Don't notice an error when listing an empty directory
+ if len(items) == 0 && oldErrors == 0 && accounting.Stats(ctx).GetErrors() == 1 {
+ accounting.Stats(ctx).ResetErrors()
+ }
+ // Check the directories
+ if expectedDirs != nil {
+ expectedDirsCopy := make([]string, len(expectedDirs))
+ for i, dir := range expectedDirs {
+ expectedDirsCopy[i] = Normalize(dir)
+ }
+ actualDirs := []string{}
+ for _, dir := range dirs {
+ actualDirs = append(actualDirs, Normalize(dir.Remote()))
+ }
+ sort.Strings(actualDirs)
+ sort.Strings(expectedDirsCopy)
+ assert.Equal(t, expectedDirsCopy, actualDirs, "directories")
+ }
+}
+
+// CheckListingWithPrecision checks the fs to see if it has the
+// expected contents with the given precision.
+//
+// If expectedDirs is non nil then we check those too. Note that no
+// directories returned is also OK as some remotes don't return
+// directories.
+func CheckListingWithPrecision(t *testing.T, f fs.Fs, items []Item, expectedDirs []string, precision time.Duration) {
+ CheckListingWithRoot(t, f, "", items, expectedDirs, precision)
+}
+
+// CheckListing checks the fs to see if it has the expected contents
+func CheckListing(t *testing.T, f fs.Fs, items []Item) {
+ precision := f.Precision()
+ CheckListingWithPrecision(t, f, items, nil, precision)
+}
+
+// CheckItemsWithPrecision checks the fs with the specified precision
+// to see if it has the expected items.
+func CheckItemsWithPrecision(t *testing.T, f fs.Fs, precision time.Duration, items ...Item) {
+ CheckListingWithPrecision(t, f, items, nil, precision)
+}
+
+// CheckItems checks the fs to see if it has only the items passed in
+// using a precision of fs.Config.ModifyWindow
+func CheckItems(t *testing.T, f fs.Fs, items ...Item) {
+ CheckListingWithPrecision(t, f, items, nil, fs.GetModifyWindow(context.TODO(), f))
+}
+
+// CompareItems compares a set of DirEntries to a slice of items and a list of dirs
+// The modtimes are compared with the precision supplied
+func CompareItems(t *testing.T, entries fs.DirEntries, items []Item, expectedDirs []string, precision time.Duration, what string) {
+ is := NewItems(items)
+ var objs []fs.Object
+ var dirs []fs.Directory
+ wantListing := makeListingFromItems(items)
+ for _, entry := range entries {
+ switch x := entry.(type) {
+ case fs.Directory:
+ dirs = append(dirs, x)
+ case fs.Object:
+ objs = append(objs, x)
+ // do nothing
+ default:
+ t.Fatalf("unknown object type %T", entry)
+ }
+ }
+
+ gotListing := makeListingFromObjects(objs)
+ listingOK := wantListing == gotListing
+ assert.True(t, listingOK, fmt.Sprintf("%s not equal, want\n %s got\n %s", what, wantListing, gotListing))
+ for _, obj := range objs {
+ require.NotNil(t, obj)
+ is.Find(t, obj, precision)
+ }
+ is.Done(t)
+ // Check the directories
+ if expectedDirs != nil {
+ expectedDirsCopy := make([]string, len(expectedDirs))
+ for i, dir := range expectedDirs {
+ expectedDirsCopy[i] = Normalize(dir)
+ }
+ actualDirs := []string{}
+ for _, dir := range dirs {
+ actualDirs = append(actualDirs, Normalize(dir.Remote()))
+ }
+ sort.Strings(actualDirs)
+ sort.Strings(expectedDirsCopy)
+ assert.Equal(t, expectedDirsCopy, actualDirs, "directories not equal")
+ }
+}
+
+// Time parses a time string or logs a fatal error
+func Time(timeString string) time.Time {
+ t, err := time.Parse(time.RFC3339Nano, timeString)
+ if err != nil {
+ fs.Fatalf(nil, "Failed to parse time %q: %v", timeString, err)
+ }
+ return t
+}
+
+// LocalRemote creates a temporary directory name for local remotes
+func LocalRemote() (path string, err error) {
+ path, err = os.MkdirTemp("", "rclone")
+ if err == nil {
+ // Now remove the directory
+ err = os.Remove(path)
+ }
+ path = filepath.ToSlash(path)
+ return
+}
+
+// RandomRemoteName makes a random bucket or subdirectory name
+//
+// Returns a random remote name plus the leaf name
+func RandomRemoteName(remoteName string) (string, string, error) {
+ var err error
+ var leafName string
+
+ // Make a directory if remote name is null
+ if remoteName == "" {
+ remoteName, err = LocalRemote()
+ if err != nil {
+ return "", "", err
+ }
+ } else {
+ if !strings.HasSuffix(remoteName, ":") {
+ remoteName += "/"
+ }
+ leafName = "rclone-test-" + random.String(12)
+ if !MatchTestRemote.MatchString(leafName) {
+ fs.Fatalf(nil, "%q didn't match the test remote name regexp", leafName)
+ }
+ remoteName += leafName
+ }
+ return remoteName, leafName, nil
+}
+
+// RandomRemote makes a random bucket or subdirectory on the remote
+// from the -remote parameter
+//
+// Call the finalise function returned to Purge the fs at the end (and
+// the parent if necessary)
+//
+// Returns the remote, its url, a finaliser and an error
+func RandomRemote() (fs.Fs, string, func(), error) {
+ var err error
+ var parentRemote fs.Fs
+ remoteName := *RemoteName
+
+ remoteName, _, err = RandomRemoteName(remoteName)
+ if err != nil {
+ return nil, "", nil, err
+ }
+
+ remote, err := fs.NewFs(context.Background(), remoteName)
+ if err != nil {
+ return nil, "", nil, err
+ }
+
+ finalise := func() {
+ Purge(remote)
+ if parentRemote != nil {
+ Purge(parentRemote)
+ if err != nil {
+ fs.Logf(nil, "Failed to purge %v: %v", parentRemote, err)
+ }
+ }
+ }
+
+ return remote, remoteName, finalise, nil
+}
+
+// Purge is a simplified re-implementation of operations.Purge for the
+// test routine cleanup to avoid circular dependencies.
+//
+// It logs errors rather than returning them
+func Purge(f fs.Fs) {
+ // Create a stats group here so errors in the cleanup don't
+ // interfere with the global stats.
+ ctx := accounting.WithStatsGroup(context.Background(), "test-cleanup")
+ var err error
+ doFallbackPurge := true
+ if doPurge := f.Features().Purge; doPurge != nil {
+ doFallbackPurge = false
+ fs.Debugf(f, "Purge remote")
+ err = doPurge(ctx, "")
+ if err == fs.ErrorCantPurge {
+ doFallbackPurge = true
+ }
+ }
+ if doFallbackPurge {
+ dirs := []string{""}
+ err = walk.ListR(ctx, f, "", true, -1, walk.ListAll, func(entries fs.DirEntries) error {
+ var err error
+ entries.ForObject(func(obj fs.Object) {
+ fs.Debugf(f, "Purge object %q", obj.Remote())
+ err = obj.Remove(ctx)
+ if err != nil {
+ fs.Logf(nil, "purge failed to remove %q: %v", obj.Remote(), err)
+ }
+ })
+ entries.ForDir(func(dir fs.Directory) {
+ dirs = append(dirs, dir.Remote())
+ })
+ return nil
+ })
+ sort.Strings(dirs)
+ for i := len(dirs) - 1; i >= 0; i-- {
+ dir := dirs[i]
+ fs.Debugf(f, "Purge dir %q", dir)
+ err := f.Rmdir(ctx, dir)
+ if err != nil {
+ fs.Logf(nil, "purge failed to rmdir %q: %v", dir, err)
+ }
+ }
+ }
+ if err != nil {
+ fs.Logf(nil, "purge failed: %v", err)
+ }
+}
+
+// NewObject finds the object on the remote
+func NewObject(ctx context.Context, t *testing.T, f fs.Fs, remote string) fs.Object {
+ var obj fs.Object
+ var err error
+ sleepTime := 1 * time.Second
+ for i := 1; i <= *ListRetries; i++ {
+ obj, err = f.NewObject(ctx, remote)
+ if err == nil {
+ break
+ }
+ t.Logf("Sleeping for %v for findObject eventual consistency: %d/%d (%v)", sleepTime, i, *ListRetries, err)
+ time.Sleep(sleepTime)
+ sleepTime = (sleepTime * 3) / 2
+ }
+ require.NoError(t, err)
+ return obj
+}
+
+// NewDirectoryRetries finds the directory with remote in f
+//
+// If directory can't be found it returns an error wrapping fs.ErrorDirNotFound
+//
+// One day this will be an rclone primitive
+func NewDirectoryRetries(ctx context.Context, t *testing.T, f fs.Fs, remote string, retries int) (fs.Directory, error) {
+ var err error
+ var dir fs.Directory
+ sleepTime := 1 * time.Second
+ root := path.Dir(remote)
+ if root == "." {
+ root = ""
+ }
+ for i := 1; i <= retries; i++ {
+ var entries fs.DirEntries
+ entries, err = f.List(ctx, root)
+ if err != nil {
+ continue
+ }
+ for _, entry := range entries {
+ var ok bool
+ dir, ok = entry.(fs.Directory)
+ if ok && dir.Remote() == remote {
+ return dir, nil
+ }
+ }
+ err = fmt.Errorf("directory %q not found in %q: %w", remote, root, fs.ErrorDirNotFound)
+ if i < retries {
+ t.Logf("Sleeping for %v for NewDirectoryRetries eventual consistency: %d/%d (%v)", sleepTime, i, retries, err)
+ time.Sleep(sleepTime)
+ sleepTime = (sleepTime * 3) / 2
+ }
+ }
+ return dir, err
+}
+
+// NewDirectory finds the directory with remote in f
+//
+// One day this will be an rclone primitive
+func NewDirectory(ctx context.Context, t *testing.T, f fs.Fs, remote string) fs.Directory {
+ dir, err := NewDirectoryRetries(ctx, t, f, remote, *ListRetries)
+ require.NoError(t, err)
+ return dir
+}
+
+// CheckEntryMetadata checks the metadata on the directory
+//
+// This checks a limited set of metadata on the directory
+func CheckEntryMetadata(ctx context.Context, t *testing.T, f fs.Fs, entry fs.DirEntry, wantMeta fs.Metadata) {
+ features := f.Features()
+ do, ok := entry.(fs.Metadataer)
+ require.True(t, ok, "Didn't find expected Metadata() method on %T", entry)
+ gotMeta, err := do.Metadata(ctx)
+ require.NoError(t, err)
+
+ for k, v := range wantMeta {
+ switch k {
+ case "mtime", "atime", "btime", "ctime":
+ // Check the system time Metadata
+ wantT, err := time.Parse(time.RFC3339, v)
+ require.NoError(t, err)
+ gotT, err := time.Parse(time.RFC3339, gotMeta[k])
+ require.NoError(t, err)
+ AssertTimeEqualWithPrecision(t, entry.Remote(), wantT, gotT, f.Precision())
+ default:
+ // Check the User metadata if we can
+ _, isDir := entry.(fs.Directory)
+ if (isDir && features.UserDirMetadata) || (!isDir && features.UserMetadata) {
+ assert.Equal(t, v, gotMeta[k])
+ }
+ }
+ }
+}
+
+// CheckDirModTime checks the modtime on the directory
+func CheckDirModTime(ctx context.Context, t *testing.T, f fs.Fs, dir fs.Directory, wantT time.Time) {
+ if f.Features().DirSetModTime == nil && f.Features().MkdirMetadata == nil {
+ fs.Debugf(f, "Skipping modtime test as remote does not support DirSetModTime or MkdirMetadata")
+ return
+ }
+ gotT := dir.ModTime(ctx)
+ precision := f.Precision()
+ // For unknown reasons the precision of modification times of
+ // directories on the CI is about >15mS. The tests work fine
+ // when run in Virtualbox though so I conjecture this is
+ // something to do with the file system used there.
+ if runtime.GOOS == "windows" && testy.CI() {
+ precision = 100 * time.Millisecond
+ }
+ AssertTimeEqualWithPrecision(t, dir.Remote(), wantT, gotT, precision)
+}
+
+// Gz returns a compressed version of its input string
+func Gz(t *testing.T, s string) string {
+ var buf bytes.Buffer
+ zw := gzip.NewWriter(&buf)
+ _, err := zw.Write([]byte(s))
+ require.NoError(t, err)
+ err = zw.Close()
+ require.NoError(t, err)
+ return buf.String()
+}