From 95c354fe817e2e45c169ab80177c504bdbd0ab81 Mon Sep 17 00:00:00 2001 From: Alexander Neumann Date: Sat, 25 Mar 2017 10:52:07 +0100 Subject: [PATCH 01/47] doc: s3 backend deviations, cloud repo layout --- doc/Design.md | 86 +++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 70 insertions(+), 16 deletions(-) diff --git a/doc/Design.md b/doc/Design.md index 52a228a93..b2ec7db47 100644 --- a/doc/Design.md +++ b/doc/Design.md @@ -34,20 +34,14 @@ in a repository are only written once and never modified afterwards. This allows accessing and even writing to the repository with multiple clients in parallel. Only the delete operation removes data from the repository. -At the time of writing, the only implemented repository type is based on -directories and files. Such repositories can be accessed locally on the same -system or via the integrated SFTP client (or any other storage back end). -The directory layout is the same for both access methods. -This repository type is described in the following section. - -Repositories consist of several directories and a file called `config`. For -all other files stored in the repository, the name for the file is the lower -case hexadecimal representation of the storage ID, which is the SHA-256 hash of -the file's contents. This allows for easy verification of files for accidental -modifications, like disk read errors, by simply running the program `sha256sum` -and comparing its output to the file name. If the prefix of a filename is -unique amongst all the other files in the same directory, the prefix may be -used instead of the complete filename. +Repositories consist of several directories and a top-level file called +`config`. For all other files stored in the repository, the name for the file +is the lower case hexadecimal representation of the storage ID, which is the +SHA-256 hash of the file's contents. This allows for easy verification of files +for accidental modifications, like disk read errors, by simply running the +program `sha256sum` on the file and comparing its output to the file name. If +the prefix of a filename is unique amongst all the other files in the same +directory, the prefix may be used instead of the complete filename. Apart from the files stored within the `keys` directory, all files are encrypted with AES-256 in counter mode (CTR). The integrity of the encrypted data is @@ -78,7 +72,15 @@ regardless if it is accessed via SFTP or locally. The field `chunker_polynomial` contains a parameter that is used for splitting large files into smaller chunks (see below). -The basic layout of a sample restic repository is shown here: +Filesystem-Based Repositories +----------------------------- + +The `local` and `sftp` backends are implemented using files and directories +stored in a file system. The directory layout is the same for both backend +types. + +The basic layout of a repository stored in a `local` or `sftp` backend is shown +here: /tmp/restic-repo ├── config @@ -102,12 +104,64 @@ The basic layout of a sample restic repository is shown here: │ └── 22a5af1bdc6e616f8a29579458c49627e01b32210d09adb288d1ecda7c5711ec └── tmp -A repository can be initialized with the `restic init` command, e.g.: +A local repository can be initialized with the `restic init` command, e.g.: ```console $ restic -r /tmp/restic-repo init ``` +The local backend will also accept the repository layout described in the +following section, so that remote repositories mounted locally e.g. via fuse +can be accessed. + +Object-Storage-Based Repositories +--------------------------------- + +Repositories in a backend based on an object store (e.g. Amazon s3) have the +same basic layout, with the exception that all data pack files are directly +saved in the `data` path, without the sub-directories listed for the +filesystem-based backends as listed in the previous section. The layout looks +like this: + + /config + /data + ├── 2159dd48f8a24f33c307b750592773f8b71ff8d11452132a7b2e2a6a01611be1 + ├── 32ea976bc30771cebad8285cd99120ac8786f9ffd42141d452458089985043a5 + ├── 59fe4bcde59bd6222eba87795e35a90d82cd2f138a27b6835032b7b58173a426 + ├── 73d04e6125cf3c28a299cc2f3cca3b78ceac396e4fcf9575e34536b26782413c + [...] + /index + ├── c38f5fb68307c6a3e3aa945d556e325dc38f5fb68307c6a3e3aa945d556e325d + └── ca171b1b7394d90d330b265d90f506f9984043b342525f019788f97e745c71fd + /keys + └── b02de829beeb3c01a63e6b25cbd421a98fef144f03b9a02e46eff9e2ca3f0bd7 + /locks + /snapshots + └── 22a5af1bdc6e616f8a29579458c49627e01b32210d09adb288d1ecda7c5711ec + +Unfortunately during development the s3 backend uses slightly different paths +(directory names use singular instead of plural for `key`, `lock`, and +`snapshot` files), for s3 the repository layout looks like this: + + /config + /data + ├── 2159dd48f8a24f33c307b750592773f8b71ff8d11452132a7b2e2a6a01611be1 + ├── 32ea976bc30771cebad8285cd99120ac8786f9ffd42141d452458089985043a5 + ├── 59fe4bcde59bd6222eba87795e35a90d82cd2f138a27b6835032b7b58173a426 + ├── 73d04e6125cf3c28a299cc2f3cca3b78ceac396e4fcf9575e34536b26782413c + [...] + /index + ├── c38f5fb68307c6a3e3aa945d556e325dc38f5fb68307c6a3e3aa945d556e325d + └── ca171b1b7394d90d330b265d90f506f9984043b342525f019788f97e745c71fd + /key + └── b02de829beeb3c01a63e6b25cbd421a98fef144f03b9a02e46eff9e2ca3f0bd7 + /lock + /snapshot + └── 22a5af1bdc6e616f8a29579458c49627e01b32210d09adb288d1ecda7c5711ec + +The s3 backend understands and accepts both forms, new backends are always +created with the former layout for compatibility reasons. + Pack Format ----------- From c8eea49909155e6921f82cb8d2de28810d1dc25c Mon Sep 17 00:00:00 2001 From: Alexander Neumann Date: Sat, 25 Mar 2017 18:00:42 +0100 Subject: [PATCH 02/47] debug: Allow creating insecure repositories Uses low-security KDF parameters for scrypt(). Do not use in production! --- src/cmds/restic/global_debug.go | 13 +++++++++++++ src/restic/repository/testing.go | 6 +++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/cmds/restic/global_debug.go b/src/cmds/restic/global_debug.go index 6e443f2a0..998f349b2 100644 --- a/src/cmds/restic/global_debug.go +++ b/src/cmds/restic/global_debug.go @@ -8,6 +8,7 @@ import ( _ "net/http/pprof" "os" "restic/errors" + "restic/repository" "github.com/pkg/profile" ) @@ -16,6 +17,7 @@ var ( listenMemoryProfile string memProfilePath string cpuProfilePath string + insecure bool prof interface { Stop() @@ -27,6 +29,13 @@ func init() { f.StringVar(&listenMemoryProfile, "listen-profile", "", "listen on this `address:port` for memory profiling") f.StringVar(&memProfilePath, "mem-profile", "", "write memory profile to `dir`") f.StringVar(&cpuProfilePath, "cpu-profile", "", "write cpu profile to `dir`") + f.BoolVar(&insecure, "insecure-kdf", false, "use insecure KDF settings") +} + +type fakeTestingTB struct{} + +func (fakeTestingTB) Logf(msg string, args ...interface{}) { + fmt.Fprintf(os.Stderr, msg, args...) } func runDebug() error { @@ -50,6 +59,10 @@ func runDebug() error { prof = profile.Start(profile.Quiet, profile.CPUProfile, profile.ProfilePath(memProfilePath)) } + if insecure { + repository.TestUseLowSecurityKDFParameters(fakeTestingTB{}) + } + return nil } diff --git a/src/restic/repository/testing.go b/src/restic/repository/testing.go index 6f590e13a..a24912257 100644 --- a/src/restic/repository/testing.go +++ b/src/restic/repository/testing.go @@ -19,8 +19,12 @@ var testKDFParams = crypto.KDFParams{ P: 1, } +type logger interface { + Logf(format string, args ...interface{}) +} + // TestUseLowSecurityKDFParameters configures low-security KDF parameters for testing. -func TestUseLowSecurityKDFParameters(t testing.TB) { +func TestUseLowSecurityKDFParameters(t logger) { t.Logf("using low-security KDF parameters for test") KDFParams = &testKDFParams } From 80a864c52c57344e673ed9ef25740c2f55261b20 Mon Sep 17 00:00:00 2001 From: Alexander Neumann Date: Sun, 26 Mar 2017 20:40:45 +0200 Subject: [PATCH 03/47] test: Add TempDir() helper --- src/restic/test/helpers.go | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/restic/test/helpers.go b/src/restic/test/helpers.go index 4e19000e8..a6dffc0a4 100644 --- a/src/restic/test/helpers.go +++ b/src/restic/test/helpers.go @@ -170,3 +170,21 @@ func RemoveAll(t testing.TB, path string) { ResetReadOnly(t, path) OK(t, os.RemoveAll(path)) } + +// TempDir returns a temporary directory that is removed when cleanup is +// called, except if TestCleanupTempDirs is set to false. +func TempDir(t testing.TB) (path string, cleanup func()) { + tempdir, err := ioutil.TempDir(TestTempDir, "restic-test-") + if err != nil { + t.Fatal(err) + } + + return tempdir, func() { + if !TestCleanupTempDirs { + t.Logf("leaving temporary directory %v used for test", tempdir) + return + } + + RemoveAll(t, tempdir) + } +} From 6a201f7962b22d9f3a75aa04f1de676d735183b1 Mon Sep 17 00:00:00 2001 From: Alexander Neumann Date: Sun, 26 Mar 2017 21:52:49 +0200 Subject: [PATCH 04/47] backend: Add Layout --- src/restic/backend/layout.go | 58 +++++++++++++++++++++++ src/restic/backend/layout_test.go | 79 +++++++++++++++++++++++++++++++ 2 files changed, 137 insertions(+) create mode 100644 src/restic/backend/layout.go create mode 100644 src/restic/backend/layout_test.go diff --git a/src/restic/backend/layout.go b/src/restic/backend/layout.go new file mode 100644 index 000000000..d14f47f89 --- /dev/null +++ b/src/restic/backend/layout.go @@ -0,0 +1,58 @@ +package backend + +import ( + "restic" +) + +// Layout computes paths for file name storage. +type Layout interface { + Filename(restic.Handle) string + Dirname(restic.Handle) string + Paths() []string +} + +// DefaultLayout implements the default layout for local and sftp backends, as +// described in the Design document. The `data` directory has one level of +// subdirs, two characters each (taken from the first two characters of the +// file name). +type DefaultLayout struct { + Path string + Join func(...string) string +} + +var defaultLayoutPaths = map[restic.FileType]string{ + restic.DataFile: "data", + restic.SnapshotFile: "snapshots", + restic.IndexFile: "index", + restic.LockFile: "locks", + restic.KeyFile: "keys", +} + +// Dirname returns the directory path for a given file type and name. +func (l *DefaultLayout) Dirname(h restic.Handle) string { + p := defaultLayoutPaths[h.Type] + + if h.Type == restic.DataFile && len(h.Name) > 2 { + p = l.Join(p, h.Name[:2]) + } + + return l.Join(l.Path, p) +} + +// Filename returns a path to a file, including its name. +func (l *DefaultLayout) Filename(h restic.Handle) string { + name := h.Name + if h.Type == restic.ConfigFile { + name = "config" + } + + return l.Join(l.Dirname(h), name) +} + +// Paths returns all directory names +func (l *DefaultLayout) Paths() (dirs []string) { + for _, p := range defaultLayoutPaths { + dirs = append(dirs, l.Join(l.Path, p)) + } + return dirs +} diff --git a/src/restic/backend/layout_test.go b/src/restic/backend/layout_test.go new file mode 100644 index 000000000..17d3a30a3 --- /dev/null +++ b/src/restic/backend/layout_test.go @@ -0,0 +1,79 @@ +package backend + +import ( + "fmt" + "path/filepath" + "reflect" + "restic" + "restic/test" + "sort" + "testing" +) + +func TestDefaultLayout(t *testing.T) { + path, cleanup := test.TempDir(t) + defer cleanup() + + var tests = []struct { + restic.Handle + filename string + }{ + { + restic.Handle{Type: restic.DataFile, Name: "0123456"}, + filepath.Join(path, "data", "01", "0123456"), + }, + { + restic.Handle{Type: restic.ConfigFile, Name: "CFG"}, + filepath.Join(path, "config"), + }, + { + restic.Handle{Type: restic.SnapshotFile, Name: "123456"}, + filepath.Join(path, "snapshots", "123456"), + }, + { + restic.Handle{Type: restic.IndexFile, Name: "123456"}, + filepath.Join(path, "index", "123456"), + }, + { + restic.Handle{Type: restic.LockFile, Name: "123456"}, + filepath.Join(path, "locks", "123456"), + }, + { + restic.Handle{Type: restic.KeyFile, Name: "123456"}, + filepath.Join(path, "keys", "123456"), + }, + } + + l := &DefaultLayout{ + Path: path, + Join: filepath.Join, + } + + t.Run("Paths", func(t *testing.T) { + dirs := l.Paths() + + want := []string{ + filepath.Join(path, "data"), + filepath.Join(path, "snapshots"), + filepath.Join(path, "index"), + filepath.Join(path, "locks"), + filepath.Join(path, "keys"), + } + + sort.Sort(sort.StringSlice(want)) + sort.Sort(sort.StringSlice(dirs)) + + if !reflect.DeepEqual(dirs, want) { + t.Fatalf("wrong paths returned, want:\v %v\ngot:\n %v", want, dirs) + } + }) + + for _, test := range tests { + t.Run(fmt.Sprintf("%v/%v", test.Type, test.Handle.Name), func(t *testing.T) { + filename := l.Filename(test.Handle) + if filename != test.filename { + t.Fatalf("wrong filename, want %v, got %v", test.filename, filename) + } + }) + } +} From 3fd6fa6f86605fb17b2bc42815a9e59192c23471 Mon Sep 17 00:00:00 2001 From: Alexander Neumann Date: Sun, 26 Mar 2017 21:53:26 +0200 Subject: [PATCH 05/47] local: Use Layout for filename generation --- src/restic/backend/local/local.go | 106 ++++++++++++++---------------- 1 file changed, 48 insertions(+), 58 deletions(-) diff --git a/src/restic/backend/local/local.go b/src/restic/backend/local/local.go index 510569654..72c3f6e54 100644 --- a/src/restic/backend/local/local.go +++ b/src/restic/backend/local/local.go @@ -17,6 +17,7 @@ import ( // Local is a backend in a local directory. type Local struct { Config + backend.Layout } var _ restic.Backend = &Local{} @@ -35,35 +36,49 @@ func paths(dir string) []string { // Open opens the local backend as specified by config. func Open(cfg Config) (*Local, error) { + be := &Local{Config: cfg} + + be.Layout = &backend.DefaultLayout{ + Path: cfg.Path, + Join: filepath.Join, + } + // test if all necessary dirs are there - for _, d := range paths(cfg.Path) { + for _, d := range be.Paths() { if _, err := fs.Stat(d); err != nil { return nil, errors.Wrap(err, "Open") } } - return &Local{Config: cfg}, nil + return be, nil } // Create creates all the necessary files and directories for a new local // backend at dir. Afterwards a new config blob should be created. func Create(cfg Config) (*Local, error) { + be := &Local{ + Config: cfg, + Layout: &backend.DefaultLayout{ + Path: cfg.Path, + Join: filepath.Join, + }, + } + // test if config file already exists - _, err := fs.Lstat(filepath.Join(cfg.Path, backend.Paths.Config)) + _, err := fs.Lstat(be.Filename(restic.Handle{Type: restic.ConfigFile})) if err == nil { return nil, errors.New("config file already exists") } // create paths for data, refs and temp - for _, d := range paths(cfg.Path) { + for _, d := range be.Paths() { err := fs.MkdirAll(d, backend.Modes.Dir) if err != nil { return nil, errors.Wrap(err, "MkdirAll") } } - // open backend - return Open(cfg) + return be, nil } // Location returns this backend's location (the directory name). @@ -71,36 +86,6 @@ func (b *Local) Location() string { return b.Path } -// Construct path for given Type and name. -func filename(base string, t restic.FileType, name string) string { - if t == restic.ConfigFile { - return filepath.Join(base, "config") - } - - return filepath.Join(dirname(base, t, name), name) -} - -// Construct directory for given Type. -func dirname(base string, t restic.FileType, name string) string { - var n string - switch t { - case restic.DataFile: - n = backend.Paths.Data - if len(name) > 2 { - n = filepath.Join(n, name[:2]) - } - case restic.SnapshotFile: - n = backend.Paths.Snapshots - case restic.IndexFile: - n = backend.Paths.Index - case restic.LockFile: - n = backend.Paths.Locks - case restic.KeyFile: - n = backend.Paths.Keys - } - return filepath.Join(base, n) -} - // copyToTempfile saves p into a tempfile in tempdir. func copyToTempfile(tempdir string, rd io.Reader) (filename string, err error) { tmpfile, err := ioutil.TempFile(tempdir, "temp-") @@ -132,18 +117,7 @@ func (b *Local) Save(h restic.Handle, rd io.Reader) (err error) { return err } - tmpfile, err := copyToTempfile(filepath.Join(b.Path, backend.Paths.Temp), rd) - debug.Log("saved %v to %v", h, tmpfile) - if err != nil { - return err - } - - filename := filename(b.Path, h.Type, h.Name) - - // test if new path already exists - if _, err := fs.Stat(filename); err == nil { - return errors.Errorf("Rename(): file %v already exists", filename) - } + filename := b.Filename(h) // create directories if necessary, ignore errors if h.Type == restic.DataFile { @@ -153,12 +127,28 @@ func (b *Local) Save(h restic.Handle, rd io.Reader) (err error) { } } - err = fs.Rename(tmpfile, filename) - debug.Log("save %v: rename %v -> %v: %v", - h, filepath.Base(tmpfile), filepath.Base(filename), err) - + // create new file + f, err := fs.OpenFile(filename, os.O_CREATE|os.O_EXCL|os.O_WRONLY, backend.Modes.File) if err != nil { - return errors.Wrap(err, "Rename") + return errors.Wrap(err, "OpenFile") + } + + // save data, then sync + _, err = io.Copy(f, rd) + if err != nil { + f.Close() + return errors.Wrap(err, "Write") + } + + if err = f.Sync(); err != nil { + f.Close() + return errors.Wrap(err, "Sync") + } + + err = f.Close() + if err != nil { + f.Close() + return errors.Wrap(err, "Close") } // set mode to read-only @@ -183,7 +173,7 @@ func (b *Local) Load(h restic.Handle, length int, offset int64) (io.ReadCloser, return nil, errors.New("offset is negative") } - f, err := os.Open(filename(b.Path, h.Type, h.Name)) + f, err := fs.Open(b.Filename(h)) if err != nil { return nil, err } @@ -210,7 +200,7 @@ func (b *Local) Stat(h restic.Handle) (restic.FileInfo, error) { return restic.FileInfo{}, err } - fi, err := fs.Stat(filename(b.Path, h.Type, h.Name)) + fi, err := fs.Stat(b.Filename(h)) if err != nil { return restic.FileInfo{}, errors.Wrap(err, "Stat") } @@ -221,7 +211,7 @@ func (b *Local) Stat(h restic.Handle) (restic.FileInfo, error) { // Test returns true if a blob of the given type and name exists in the backend. func (b *Local) Test(h restic.Handle) (bool, error) { debug.Log("Test %v", h) - _, err := fs.Stat(filename(b.Path, h.Type, h.Name)) + _, err := fs.Stat(b.Filename(h)) if err != nil { if os.IsNotExist(errors.Cause(err)) { return false, nil @@ -235,7 +225,7 @@ func (b *Local) Test(h restic.Handle) (bool, error) { // Remove removes the blob with the given name and type. func (b *Local) Remove(h restic.Handle) error { debug.Log("Remove %v", h) - fn := filename(b.Path, h.Type, h.Name) + fn := b.Filename(h) // reset read-only flag err := fs.Chmod(fn, 0666) @@ -316,7 +306,7 @@ func (b *Local) List(t restic.FileType, done <-chan struct{}) <-chan string { } ch := make(chan string) - items, err := lister(filepath.Join(dirname(b.Path, t, ""))) + items, err := lister(b.Dirname(restic.Handle{Type: t})) if err != nil { close(ch) return ch From 782b740c95e2f591448d9a0bc83928ea799bf44c Mon Sep 17 00:00:00 2001 From: Alexander Neumann Date: Sun, 26 Mar 2017 22:14:00 +0200 Subject: [PATCH 06/47] local: Remove unused code --- src/restic/backend/local/local.go | 38 +------------------------------ 1 file changed, 1 insertion(+), 37 deletions(-) diff --git a/src/restic/backend/local/local.go b/src/restic/backend/local/local.go index 72c3f6e54..4ddada847 100644 --- a/src/restic/backend/local/local.go +++ b/src/restic/backend/local/local.go @@ -2,7 +2,6 @@ package local import ( "io" - "io/ioutil" "os" "path/filepath" "restic" @@ -20,20 +19,9 @@ type Local struct { backend.Layout } +// ensure statically that *Local implements restic.Backend. var _ restic.Backend = &Local{} -func paths(dir string) []string { - return []string{ - dir, - filepath.Join(dir, backend.Paths.Data), - filepath.Join(dir, backend.Paths.Snapshots), - filepath.Join(dir, backend.Paths.Index), - filepath.Join(dir, backend.Paths.Locks), - filepath.Join(dir, backend.Paths.Keys), - filepath.Join(dir, backend.Paths.Temp), - } -} - // Open opens the local backend as specified by config. func Open(cfg Config) (*Local, error) { be := &Local{Config: cfg} @@ -86,30 +74,6 @@ func (b *Local) Location() string { return b.Path } -// copyToTempfile saves p into a tempfile in tempdir. -func copyToTempfile(tempdir string, rd io.Reader) (filename string, err error) { - tmpfile, err := ioutil.TempFile(tempdir, "temp-") - if err != nil { - return "", errors.Wrap(err, "TempFile") - } - - _, err = io.Copy(tmpfile, rd) - if err != nil { - return "", errors.Wrap(err, "Write") - } - - if err = tmpfile.Sync(); err != nil { - return "", errors.Wrap(err, "Syncn") - } - - err = tmpfile.Close() - if err != nil { - return "", errors.Wrap(err, "Close") - } - - return tmpfile.Name(), nil -} - // Save stores data in the backend at the handle. func (b *Local) Save(h restic.Handle, rd io.Reader) (err error) { debug.Log("Save %v", h) From 3e81dcdfc27060d756fb5c3e5f613aa262e321e5 Mon Sep 17 00:00:00 2001 From: Alexander Neumann Date: Sun, 26 Mar 2017 22:20:10 +0200 Subject: [PATCH 07/47] Add cloud and s3 layout --- src/restic/backend/layout.go | 46 --------- src/restic/backend/layout_cloud.go | 36 +++++++ src/restic/backend/layout_default.go | 49 ++++++++++ src/restic/backend/layout_s3.go | 42 ++++++++ src/restic/backend/layout_test.go | 138 ++++++++++++++++++++++++++- 5 files changed, 264 insertions(+), 47 deletions(-) create mode 100644 src/restic/backend/layout_cloud.go create mode 100644 src/restic/backend/layout_default.go create mode 100644 src/restic/backend/layout_s3.go diff --git a/src/restic/backend/layout.go b/src/restic/backend/layout.go index d14f47f89..43fa223b7 100644 --- a/src/restic/backend/layout.go +++ b/src/restic/backend/layout.go @@ -10,49 +10,3 @@ type Layout interface { Dirname(restic.Handle) string Paths() []string } - -// DefaultLayout implements the default layout for local and sftp backends, as -// described in the Design document. The `data` directory has one level of -// subdirs, two characters each (taken from the first two characters of the -// file name). -type DefaultLayout struct { - Path string - Join func(...string) string -} - -var defaultLayoutPaths = map[restic.FileType]string{ - restic.DataFile: "data", - restic.SnapshotFile: "snapshots", - restic.IndexFile: "index", - restic.LockFile: "locks", - restic.KeyFile: "keys", -} - -// Dirname returns the directory path for a given file type and name. -func (l *DefaultLayout) Dirname(h restic.Handle) string { - p := defaultLayoutPaths[h.Type] - - if h.Type == restic.DataFile && len(h.Name) > 2 { - p = l.Join(p, h.Name[:2]) - } - - return l.Join(l.Path, p) -} - -// Filename returns a path to a file, including its name. -func (l *DefaultLayout) Filename(h restic.Handle) string { - name := h.Name - if h.Type == restic.ConfigFile { - name = "config" - } - - return l.Join(l.Dirname(h), name) -} - -// Paths returns all directory names -func (l *DefaultLayout) Paths() (dirs []string) { - for _, p := range defaultLayoutPaths { - dirs = append(dirs, l.Join(l.Path, p)) - } - return dirs -} diff --git a/src/restic/backend/layout_cloud.go b/src/restic/backend/layout_cloud.go new file mode 100644 index 000000000..6f65be484 --- /dev/null +++ b/src/restic/backend/layout_cloud.go @@ -0,0 +1,36 @@ +package backend + +import "restic" + +// CloudLayout implements the default layout for cloud storage backends, as +// described in the Design document. +type CloudLayout struct { + Path string + Join func(...string) string +} + +var cloudLayoutPaths = defaultLayoutPaths + +// Dirname returns the directory path for a given file type and name. +func (l *CloudLayout) Dirname(h restic.Handle) string { + return l.Join(l.Path, cloudLayoutPaths[h.Type]) +} + +// Filename returns a path to a file, including its name. +func (l *CloudLayout) Filename(h restic.Handle) string { + name := h.Name + + if h.Type == restic.ConfigFile { + name = "config" + } + + return l.Join(l.Dirname(h), name) +} + +// Paths returns all directory names +func (l *CloudLayout) Paths() (dirs []string) { + for _, p := range cloudLayoutPaths { + dirs = append(dirs, l.Join(l.Path, p)) + } + return dirs +} diff --git a/src/restic/backend/layout_default.go b/src/restic/backend/layout_default.go new file mode 100644 index 000000000..fd6364b80 --- /dev/null +++ b/src/restic/backend/layout_default.go @@ -0,0 +1,49 @@ +package backend + +import "restic" + +// DefaultLayout implements the default layout for local and sftp backends, as +// described in the Design document. The `data` directory has one level of +// subdirs, two characters each (taken from the first two characters of the +// file name). +type DefaultLayout struct { + Path string + Join func(...string) string +} + +var defaultLayoutPaths = map[restic.FileType]string{ + restic.DataFile: "data", + restic.SnapshotFile: "snapshots", + restic.IndexFile: "index", + restic.LockFile: "locks", + restic.KeyFile: "keys", +} + +// Dirname returns the directory path for a given file type and name. +func (l *DefaultLayout) Dirname(h restic.Handle) string { + p := defaultLayoutPaths[h.Type] + + if h.Type == restic.DataFile && len(h.Name) > 2 { + p = l.Join(p, h.Name[:2]) + } + + return l.Join(l.Path, p) +} + +// Filename returns a path to a file, including its name. +func (l *DefaultLayout) Filename(h restic.Handle) string { + name := h.Name + if h.Type == restic.ConfigFile { + name = "config" + } + + return l.Join(l.Dirname(h), name) +} + +// Paths returns all directory names +func (l *DefaultLayout) Paths() (dirs []string) { + for _, p := range defaultLayoutPaths { + dirs = append(dirs, l.Join(l.Path, p)) + } + return dirs +} diff --git a/src/restic/backend/layout_s3.go b/src/restic/backend/layout_s3.go new file mode 100644 index 000000000..571d36335 --- /dev/null +++ b/src/restic/backend/layout_s3.go @@ -0,0 +1,42 @@ +package backend + +import "restic" + +// S3Layout implements the old layout used for s3 cloud storage backends, as +// described in the Design document. +type S3Layout struct { + Path string + Join func(...string) string +} + +var s3LayoutPaths = map[restic.FileType]string{ + restic.DataFile: "data", + restic.SnapshotFile: "snapshot", + restic.IndexFile: "index", + restic.LockFile: "lock", + restic.KeyFile: "key", +} + +// Dirname returns the directory path for a given file type and name. +func (l *S3Layout) Dirname(h restic.Handle) string { + return l.Join(l.Path, s3LayoutPaths[h.Type]) +} + +// Filename returns a path to a file, including its name. +func (l *S3Layout) Filename(h restic.Handle) string { + name := h.Name + + if h.Type == restic.ConfigFile { + name = "config" + } + + return l.Join(l.Dirname(h), name) +} + +// Paths returns all directory names +func (l *S3Layout) Paths() (dirs []string) { + for _, p := range s3LayoutPaths { + dirs = append(dirs, l.Join(l.Path, p)) + } + return dirs +} diff --git a/src/restic/backend/layout_test.go b/src/restic/backend/layout_test.go index 17d3a30a3..806329b71 100644 --- a/src/restic/backend/layout_test.go +++ b/src/restic/backend/layout_test.go @@ -64,7 +64,143 @@ func TestDefaultLayout(t *testing.T) { sort.Sort(sort.StringSlice(dirs)) if !reflect.DeepEqual(dirs, want) { - t.Fatalf("wrong paths returned, want:\v %v\ngot:\n %v", want, dirs) + t.Fatalf("wrong paths returned, want:\n %v\ngot:\n %v", want, dirs) + } + }) + + for _, test := range tests { + t.Run(fmt.Sprintf("%v/%v", test.Type, test.Handle.Name), func(t *testing.T) { + filename := l.Filename(test.Handle) + if filename != test.filename { + t.Fatalf("wrong filename, want %v, got %v", test.filename, filename) + } + }) + } +} + +func TestCloudLayout(t *testing.T) { + path, cleanup := test.TempDir(t) + defer cleanup() + + var tests = []struct { + restic.Handle + filename string + }{ + { + restic.Handle{Type: restic.DataFile, Name: "0123456"}, + filepath.Join(path, "data", "0123456"), + }, + { + restic.Handle{Type: restic.ConfigFile, Name: "CFG"}, + filepath.Join(path, "config"), + }, + { + restic.Handle{Type: restic.SnapshotFile, Name: "123456"}, + filepath.Join(path, "snapshots", "123456"), + }, + { + restic.Handle{Type: restic.IndexFile, Name: "123456"}, + filepath.Join(path, "index", "123456"), + }, + { + restic.Handle{Type: restic.LockFile, Name: "123456"}, + filepath.Join(path, "locks", "123456"), + }, + { + restic.Handle{Type: restic.KeyFile, Name: "123456"}, + filepath.Join(path, "keys", "123456"), + }, + } + + l := &CloudLayout{ + Path: path, + Join: filepath.Join, + } + + t.Run("Paths", func(t *testing.T) { + dirs := l.Paths() + + want := []string{ + filepath.Join(path, "data"), + filepath.Join(path, "snapshots"), + filepath.Join(path, "index"), + filepath.Join(path, "locks"), + filepath.Join(path, "keys"), + } + + sort.Sort(sort.StringSlice(want)) + sort.Sort(sort.StringSlice(dirs)) + + if !reflect.DeepEqual(dirs, want) { + t.Fatalf("wrong paths returned, want:\n %v\ngot:\n %v", want, dirs) + } + }) + + for _, test := range tests { + t.Run(fmt.Sprintf("%v/%v", test.Type, test.Handle.Name), func(t *testing.T) { + filename := l.Filename(test.Handle) + if filename != test.filename { + t.Fatalf("wrong filename, want %v, got %v", test.filename, filename) + } + }) + } +} + +func TestS3Layout(t *testing.T) { + path, cleanup := test.TempDir(t) + defer cleanup() + + var tests = []struct { + restic.Handle + filename string + }{ + { + restic.Handle{Type: restic.DataFile, Name: "0123456"}, + filepath.Join(path, "data", "0123456"), + }, + { + restic.Handle{Type: restic.ConfigFile, Name: "CFG"}, + filepath.Join(path, "config"), + }, + { + restic.Handle{Type: restic.SnapshotFile, Name: "123456"}, + filepath.Join(path, "snapshot", "123456"), + }, + { + restic.Handle{Type: restic.IndexFile, Name: "123456"}, + filepath.Join(path, "index", "123456"), + }, + { + restic.Handle{Type: restic.LockFile, Name: "123456"}, + filepath.Join(path, "lock", "123456"), + }, + { + restic.Handle{Type: restic.KeyFile, Name: "123456"}, + filepath.Join(path, "key", "123456"), + }, + } + + l := &S3Layout{ + Path: path, + Join: filepath.Join, + } + + t.Run("Paths", func(t *testing.T) { + dirs := l.Paths() + + want := []string{ + filepath.Join(path, "data"), + filepath.Join(path, "snapshot"), + filepath.Join(path, "index"), + filepath.Join(path, "lock"), + filepath.Join(path, "key"), + } + + sort.Sort(sort.StringSlice(want)) + sort.Sort(sort.StringSlice(dirs)) + + if !reflect.DeepEqual(dirs, want) { + t.Fatalf("wrong paths returned, want:\n %v\ngot:\n %v", want, dirs) } }) From c6b8ffbb61956bd7e5a9d2e567746ba2b56ab478 Mon Sep 17 00:00:00 2001 From: Alexander Neumann Date: Sun, 2 Apr 2017 17:25:22 +0200 Subject: [PATCH 08/47] Add layout auto detection --- src/restic/backend/layout.go | 138 ++++++++++++++++++ src/restic/backend/layout_test.go | 45 +++++- .../backend/testdata/repo-layout-cloud.tar.gz | Bin 0 -> 38095 bytes .../backend/testdata/repo-layout-local.tar.gz | Bin 0 -> 38257 bytes .../testdata/repo-layout-s3-old.tar.gz | Bin 0 -> 38096 bytes 5 files changed, 179 insertions(+), 4 deletions(-) create mode 100644 src/restic/backend/testdata/repo-layout-cloud.tar.gz create mode 100644 src/restic/backend/testdata/repo-layout-local.tar.gz create mode 100644 src/restic/backend/testdata/repo-layout-s3-old.tar.gz diff --git a/src/restic/backend/layout.go b/src/restic/backend/layout.go index 43fa223b7..c8b795348 100644 --- a/src/restic/backend/layout.go +++ b/src/restic/backend/layout.go @@ -1,7 +1,13 @@ package backend import ( + "fmt" + "os" + "path/filepath" + "regexp" "restic" + "restic/errors" + "restic/fs" ) // Layout computes paths for file name storage. @@ -10,3 +16,135 @@ type Layout interface { Dirname(restic.Handle) string Paths() []string } + +// Filesystem is the abstraction of a file system used for a backend. +type Filesystem interface { + Join(...string) string + ReadDir(string) ([]os.FileInfo, error) +} + +// ensure statically that *LocalFilesystem implements Filesystem. +var _ Filesystem = &LocalFilesystem{} + +// LocalFilesystem implements Filesystem in a local path. +type LocalFilesystem struct { +} + +// ReadDir returns all entries of a directory. +func (l *LocalFilesystem) ReadDir(dir string) ([]os.FileInfo, error) { + f, err := fs.Open(dir) + if err != nil { + return nil, err + } + + entries, err := f.Readdir(-1) + if err != nil { + return nil, err + } + + err = f.Close() + if err != nil { + return nil, err + } + + return entries, nil +} + +// Join combines several path components to one. +func (l *LocalFilesystem) Join(paths ...string) string { + return filepath.Join(paths...) +} + +var backendFilenameLength = len(restic.ID{}) * 2 +var backendFilename = regexp.MustCompile(fmt.Sprintf("^[a-fA-F0-9]{%d}$", backendFilenameLength)) + +func hasBackendFile(fs Filesystem, dir string) (bool, error) { + entries, err := fs.ReadDir(dir) + if err != nil && os.IsNotExist(errors.Cause(err)) { + return false, nil + } + + if err != nil { + return false, errors.Wrap(err, "ReadDir") + } + + for _, e := range entries { + if backendFilename.MatchString(e.Name()) { + return true, nil + } + } + + return false, nil +} + +var dataSubdirName = regexp.MustCompile("^[a-fA-F0-9]{2}$") + +func hasSubdirBackendFile(fs Filesystem, dir string) (bool, error) { + entries, err := fs.ReadDir(dir) + if err != nil && os.IsNotExist(errors.Cause(err)) { + return false, nil + } + + if err != nil { + return false, errors.Wrap(err, "ReadDir") + } + + for _, subdir := range entries { + if !dataSubdirName.MatchString(subdir.Name()) { + continue + } + + present, err := hasBackendFile(fs, fs.Join(dir, subdir.Name())) + if err != nil { + return false, err + } + + if present { + return true, nil + } + } + + return false, nil +} + +// DetectLayout tries to find out which layout is used in a local (or sftp) +// filesystem at the given path. +func DetectLayout(repo Filesystem, dir string) (Layout, error) { + // key file in the "keys" dir (DefaultLayout or CloudLayout) + foundKeysFile, err := hasBackendFile(repo, repo.Join(dir, defaultLayoutPaths[restic.KeyFile])) + if err != nil { + return nil, err + } + + // key file in the "key" dir (S3Layout) + foundKeyFile, err := hasBackendFile(repo, repo.Join(dir, s3LayoutPaths[restic.KeyFile])) + if err != nil { + return nil, err + } + + // data file in "data" directory (S3Layout or CloudLayout) + foundDataFile, err := hasBackendFile(repo, repo.Join(dir, s3LayoutPaths[restic.DataFile])) + if err != nil { + return nil, err + } + + // data file in subdir of "data" directory (DefaultLayout) + foundDataSubdirFile, err := hasSubdirBackendFile(repo, repo.Join(dir, s3LayoutPaths[restic.DataFile])) + if err != nil { + return nil, err + } + + if foundKeysFile && foundDataFile && !foundKeyFile && !foundDataSubdirFile { + return &CloudLayout{}, nil + } + + if foundKeysFile && foundDataSubdirFile && !foundKeyFile && !foundDataFile { + return &DefaultLayout{}, nil + } + + if foundKeyFile && foundDataFile && !foundKeysFile && !foundDataSubdirFile { + return &S3Layout{}, nil + } + + return nil, errors.New("auto-detecting the filesystem layout failed") +} diff --git a/src/restic/backend/layout_test.go b/src/restic/backend/layout_test.go index 806329b71..c08829ea4 100644 --- a/src/restic/backend/layout_test.go +++ b/src/restic/backend/layout_test.go @@ -5,13 +5,13 @@ import ( "path/filepath" "reflect" "restic" - "restic/test" + . "restic/test" "sort" "testing" ) func TestDefaultLayout(t *testing.T) { - path, cleanup := test.TempDir(t) + path, cleanup := TempDir(t) defer cleanup() var tests = []struct { @@ -79,7 +79,7 @@ func TestDefaultLayout(t *testing.T) { } func TestCloudLayout(t *testing.T) { - path, cleanup := test.TempDir(t) + path, cleanup := TempDir(t) defer cleanup() var tests = []struct { @@ -147,7 +147,7 @@ func TestCloudLayout(t *testing.T) { } func TestS3Layout(t *testing.T) { - path, cleanup := test.TempDir(t) + path, cleanup := TempDir(t) defer cleanup() var tests = []struct { @@ -213,3 +213,40 @@ func TestS3Layout(t *testing.T) { }) } } + +func TestDetectLayout(t *testing.T) { + path, cleanup := TempDir(t) + defer cleanup() + + var tests = []struct { + filename string + want string + }{ + {"repo-layout-local.tar.gz", "*backend.DefaultLayout"}, + {"repo-layout-cloud.tar.gz", "*backend.CloudLayout"}, + {"repo-layout-s3-old.tar.gz", "*backend.S3Layout"}, + } + + var fs = &LocalFilesystem{} + for _, test := range tests { + t.Run(test.filename, func(t *testing.T) { + SetupTarTestFixture(t, path, filepath.Join("testdata", test.filename)) + + layout, err := DetectLayout(fs, filepath.Join(path, "repo")) + if err != nil { + t.Fatal(err) + } + + if layout == nil { + t.Fatal("wanted some layout, but detect returned nil") + } + + layoutName := fmt.Sprintf("%T", layout) + if layoutName != test.want { + t.Fatalf("want layout %v, got %v", test.want, layoutName) + } + + RemoveAll(t, filepath.Join(path, "repo")) + }) + } +} diff --git a/src/restic/backend/testdata/repo-layout-cloud.tar.gz b/src/restic/backend/testdata/repo-layout-cloud.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..189832589efda01218982691fed121bf50d3d296 GIT binary patch literal 38095 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 literal 0 HcmV?d00001 diff --git a/src/restic/backend/testdata/repo-layout-local.tar.gz b/src/restic/backend/testdata/repo-layout-local.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..e38deb54b7ffd74163d705149489c52d0c3ac59b GIT binary patch literal 38257 zcmV(*K;FL}iwFR?uhv)q1MFIPG*xT=S1A;cD?_7>CLw#?=O`7KWu7V7=bU{wgkzpE zq(Ko4QWUvJ5}8Vdk})AP${ZPrR5y)M@jLq6^}hGE?!B%1t~cGaUY|eCv-Y$0+26gN z&-3{{pYOA`JIQfXP1JRl5UC<@^yj`9E?fIt9`H2R(I z>kGU*JPCJcX&x36_{MppyT@eQ(B#5>f`1Peg5%-f>gn-)X2Rd$ADie0Aq2*!_@4}X z#Xl1!5ST%OFqmY}P!OY{D2*fu459;6fJQ(#iw>eRlnH|nm5JjZj-wEZ1P~IX!w3Td z|1ib`!N_0m4`cA}`G+7JfO(|S;>sVZc)KVZvUPI>q~m%g-3J#u*^H;;$& z&Uk*es=7pqa*7e&pRLtNRGn085lWS}jZq}oV?Ql=ms=aD#oH)nW5qltA8<@(dOpC< zyZ_-_xLo%1lOqNOqY)>gpJqih&+hO)Z4zv8*yHZ7srv2C^5adne1}t11sn6*Hw)Y< z4hk*bCNp$cv@K$}JeCfLx5_-z7ng|Ge$39Q>X5{JUfa}`n5DgIY3r+()yxuklbSew z;(B<4aA1G@D%l8*X+CGcitUdEe`j)}P#ANXix@7K6^i@@TJ zQ<|L{rhDeCh$bU4?B!QK6pB)CZatr(bS^k}cIVBIP3SnEWWjM>mdj6SnhLVd1lS|J z!`%hxoY!x4u|b8EhLOe5nI=z0t?W-L#0Ulq z&;c5q#0eUejuJ3{QfbJ98$g*%goe>EoCY!wfSU_Y2u2YMh5j(cxAczy_`mcIKUK{wD(`*FU(ldy$ibwy#ah#f39#(j2Qpb}!PR%g73*pGjY9SSTD5)j zJ*F90cIlna>)vF24#6qTP(P|us#wFYN(*``ys2l+I-$hvkDGbtbSr?+t9e*Z9!#;BxqYDTPg9$tug(o z-y`cTaTS`A;Q?>qmgti|nuZ^SFZ$o%%5eHlwciB(e*Fic@KpUjDfq&_6Y2LI>Hk~& zbKT$s|1gS9@jn^(H~%OVVKGsV3K29W17VUR4W@!L0H+cp6N8y_5J#9S5@#S7gM>(~ z8Q|9cOa_ynG7ubKFpxiw@vZeAW~~D#a=e|5-8=|af7srFlrwiVb=Csder$#( z?B{E2z_Nh7d_dgP($auq539mf#(Em0rml$!;9`jRIJlc&DjY(cWejO}>ft1&v12NvF-m0SNmgpOaI(*|LgpZ;=q*tCj%EtuRbW3 zlA@m~*57iQ=~@($oDiY!p!}ieik-s+duN4AR}o*JEid z(IH~+zGC&_#LQT2mT)dr~Kcf;0yn37bfZZ&#M0*WQzaEz;F0RF_cM#F^rBwOoWQj zPy#^!3`R&0hY1K~Fi99>VRQn=aBe98&{-(KtqU*&0zoE(0MtK?@vZeAMX;~l|A82e zO|AcvfrJyHS<~$e6~ncQ4@F$eoSvQ>)y(UrcrsYH@X!abs^@bqzdTZecAJZzv3x(f zS+1BkE__#}P&m8PZTA(6Sz1~9_h1XB&vW<9P!W3bbBe&*!mVOg50>dyb)}nJ9)2A6 zn&)L&MR>wG<*o{mH>0mBd-4*;ucep@9?C!Q_(XP{G9jkWVgz09o@f0MKeKNDG`aOM zuqNAFdY4Jw;^1v_JL7Z*6r`&Z-WAs`U_UGqq}`qAA!He6pxdk>uM@H8SZDGftDA4) z4T;y$I!WHo-dg&HSi9|&_1BS*Q*G2dIlk}sJX0@+`WrU6qWN|S_xSTZu)NPGx!=>< z)NXRxzan~H-Oo(xafQo#ab^k~50(oQ@!HqkI#g&DM}JruHR$GYP3XN(jr^wD`8%U~ zE-+(lV;kGR^5RcEJngc3E-rP`PI?JxdkG(t65Hlim{D6Dp0NBS>nJ5N-J}3Q;;mB6 zLmIBl)aPer{AiZ{9KX>&NoBH_EEYtAaEPl0j0VzZbP(pI1O^EJG_LLm78AvoFcn3) znStA|Xeas1Ey+BftMqUdkl|NsB~KZHP2`kxHctTfiRTpxD!*n5T5!N=t7 zmt7YLHnnPc(W#eshrVN7bcS5;5idg#ifHy?sa$iL5`}sp>9yi%r>b9fAIwQ&PS4n` z(2ysRytKnTtk(8$VMW~M@n5929OxQ9p4JH4xd+N^Q1j3^B?RU!$oBbo??U92yJGQ= z2KES^eBLGhSwKv4Mds_d8MP`!Pu0dgKJ~dnA6l1|?3txx-*$!vvjno(jlMI>JPJ|_PR(ct^8(HNnk)rn zZFDkEwS@-xijNAOmpRb%zM3>Oe{7fWlrN@Nry|aDl&{Stt?HVJVt;e0jg6UZb7($) zbZ6bNfNMIjtQ~6i-WXN!Jy0si91ju?7x{Dr@)m2=4^&*dK_*E=}P!p9bR zCob?;=RHaWmNp!e9+WO=9ul>ixu;J3j^nL_rKw@cd542f&lOR>>Ydqs^_TYiH19ns z+LA`z^iUyr@Q88l&1(aui%Nr22C%&%cLlpcObgh{)^GP?Zu?NEak;OLSMp}z#<}w@ z)xRuF=B>7o43D9&<~{QKyy%*tE5^0jgV?ZlZP``xW4wD+Lo)e7JQHUNM;Wxg85Ql+ zZ`0Hccm{sjmux%^W{4M;2F~b{iAYLX@@RdTN|Ald10y{5hRr&sW`*U?_x1%GMxvU^ zZm&2f#dh$TpDKA(euv1b+`=LO>;7Veh$m>E%7&KC0%-TscWw8x3Qyl?+Ui`MXkFV{ zwSMoeC+kC%wDS|+W@Pu+m{`<41W+g;H}dVdw}&V-c^y?j-I52A^KXuA+UT@)xm>TU z?g_r8mg|uc!r8u%?vJo!zx;*Z!yMc(q8CFW(rFEKt<0VGa+riUc+ssT!C*=TRhW z)j7LSiSAi9r0>pS-SM77$*5Q$mwr(=kzZl?4USV_T!cnfzTI}It^KR%v!sOLPT0Mx zcv3a0K1N^iaK5s)_IR%a-zn4c-tl+bqbjrG`RDcyJdPT$IThK}qIx>Cu*cBzM@#(= z@g@IHY?S>&_WytN|1gA2`TxnlzxgM=+Z6Z@_{V{%?>{F6zvG`E5DXz{T$e|qvPc91 zAcO{y+_Dd6u?QTZl2nLAP+uBt(7@_)rCOgaVkPBvQ={NV*EL;38tDzuYs>O-3`^r*3-o-O2(lAl$8=HI2 z$&W~e{H$E>;SpfkU1Vuhuj%pvxu0!e6T6DCCPv{E&nmN|-P*g*o1a=dosdE5h-B`z zgCpf;O7FX0L_F!`w7t8tBMtQR^@-Nl*eVPz|Ap9l{J?6fa@M_dDJw_BZ&(E8*>z{6!s3Ush;;So(vj`L#XClVV^U7kZVJ)8^r(cFUte-yaCX$kEr?lWj)P{5 zz27P&igvz9)B9dtV`tsa9jLE&e;p8XC$Bi-nEb#yo2Jjng`}V~i1UCux1q#@0SpT58Qu$7mBQaYtOSq2I;JIM*MlvAJ7XTdsYG->?oWwOo|F z)d+#r?H;F4FmCSds4=fes25)l}OroZ~ini_PX-DBXtW6J5J6o_01YM zyRW)h_ysP$_55+8rqvfOuQ`#a8nhP!71xrVXc}s?biNNYvGoQ4qAz}eOGgd@*}YB= z#i+;1>c%$Qbg5#@Nc`lM;@e?U`J(e8GvJC*peQw_;2EgiWKkp=zo?(Tg(mKEC-K(G z*EbwLm(A84r+9!!oggM;#4e0qZV@#S8l@ z?>s6xBGayDRdYvP`pA7*BU`1w)NM9KTfHJ?8MsH!-Ep)rX{MRlh!VU}NJm_Y1^}lUZ6RV6r zP`>g1$Zw~ZVSuK=^0D5>wr$(CZQHhO+qP}nwrv~x{gd5I=3;hlX69zjO?4-obfr(H zQuP*C3$>Cm9(tsP zuHa#}+iVal#pW`z_1pE>F_&BjZH9w}wP~l&^Nxlhb*PAPfL>Qm#GXA#OeJ2C1qTUM>uun-NB{|~bF+HO$s0IrG7n#J!I={GX zUH1MopzF=u^prsMjs>($7q73N#wj`4ODRmLY7lrK1FTkV?Pm!e|p#hlT7O7am%PosEM<IK^dR?kpUs}`5`spTuDTe1> zR3SkI%>xqKbi?m{SLWGvp_C_C;o1lF?&1HLR29X#0; zmVelhH`@8~OXZ*Er-kryB@KkzB8b;^`Q5r4fJCle5szS0j41m6!|RMtoCSDE#x*KJe|$NZqQKGl>G<0 zL07Zz661Xc|>~-$0c)ZliL;-zdrPk!(@3a|g`T#XEs{zdT zB}3EL$BJkIJY*0A4&(Nt#`UOlye(b^W!&7Afakha}*CdO=_*@Q^qH z$d<2~{qFTGBhSaV}1;iZeON~cYa_WQHL5+D{@?R6PvMz zha68%t(vlcI3LI_$F2U}dA~5FL$`Dlxfcu#CdH;ysZF)Ib49*ZhlhT#=r$GdjhCP` zwoL9QkDpfFc@-8fD`UAOZS?7Hhl~>9C{=p^gl|{cHs5HR8o`U08~;N3R9|UHH1ZRU zAV+O{dZip2s(@(PaaR~Q&WTN_3X#LKidauiwo31(uEv?`OeG_S53Y2ipHuLg7)3Qa ztd5AM!tZRrapejJ3tLX7WTQI-7rHAiLE^mZw3ppPZ!GxxD$u`Bbh2%v^psQ{t{Civ zM+6@dR9_EFl?tC%IW9L}V``sP&*~eCHo_H_w%>)|>1IH=F-Rq+v60m{Tqb>8z*7~t z@Up&8J9R?+fnzMwA%qadR2|4HCJk2K?X-ZIEj%XJ3tdPfCf-L}nVvG(F=9+Kxv>G^bFl5{ zY$;J}Z5u~fI?(w~|CZyl=ac%ZbEG8nv(8}qt0iv*tR^DTdP-t_WGoV=766Sk^>0&nk$WYTkDNa-WpoOGE0mw0$)1pSMv%4S!;+kk#=~;l@TVZn; z7I*xM+SN?bA~r(CPIWCq#^CD%T9}++kmVfX`~-%I1DS>M#XAI}q5vn+{XWwd-c2(H z{iEOC-X)7!y#lMElFu7UK?Jedrb}rfwVn z+;a8ZHw7J0krOTb!5K2Wxyi*Vw-w{n_`!UJH%nW~mRKHyhV_1`2D?>HkoCWsveNaL zE*I6ZmG*ukf*sQFq$PZ_CVZvsEhcnpVH+o$mCo9SPo;A&h++t4RY?8zwzw_%TBgB> zX*q2P_8ULiXtL^9l^yU30bssoPt##wYPh8Fr1yx^{p#p|ouz77Tm_Pu?nz?a#9cmu z^yHUEe;q>-M!Qm`GO5(7X%nP&F-kF zq{C573IHcPSnGsICqJ@h#A2#kmG%Vrmkl8g&L*k6f@mA%Q^lP%n`C%Cj9e{_^4vr$ zBPJcen)=+tS~aoVmYbvRNZdLdQ3i$cNwl3noCxO0cR%*m{&C6BW8ywSvN0X@H+e01 z>GAa+k!o2-wCaNQ_R+BHOj|E&^I;AvP{2-snRnl2P64+8TOEM z!{zMtoBclD>TlKJ5=nD5R(*@~Zg0WCJE&QoN=;WSLOf=PkRNAeLK zl1F)jfXLlkQSb-_@@DCBK4s^(D62-D!u{@RGzN+m>3TwIEe~_!xI;k(%A;K6ViV~g zwcEe<^u*y^Ae(g)->L8fH-%_Vd0f_CGe^ge3Zx=$IQW;?cdl|I?z@awv)gGbopBS{ z#KU>?vI(#8eBeWXxOV-ktH64g4@|737pL1WOUa{#cs%eantO7j?>K^qIJHPqV2zR0 zak7Bhe>)9#UC(XEh2R)|n5pt9+FU8$DI+@sL!D?8a|9&84Yt5~@L+*Z#f^G6*L_Wn z0;%58r;I-1@gjm`Wy!6KwhtorP-P8dTi({`PVsn6Sw8ljl~-ni-wBU5ULPk^aH!pL zXD$be1EB{KQtgwKIp1v@{&DHN*||+tOAAszM^O$I#3K5oVqZ}q(u)!fnI7%(hQt>a zJzzP<>~L2aFyy{~%5zDoaH2*ghuqG7&-6(_`%lJrOSJIpcrM?R((D#8>JYdY30D&^ z7p-B&U};sb91(pXP+kEJ*xxpM@V7BHGTB`4ZcO$w>j0}Py_!I4j@aTq6}i0ZFA`DS zv$AqfibfXI@~_P0%a&Q(eYwUamY2kCtKJRv;^y+4?q_J^Kf+}-TQLF@0!4y@`mx9| zSN5@;7%`CRuwijaZeH$sK-+gm`+-DC1p;hJ&dRZ0ILighmBubd42#pTW1#@xH3kF_s>GZ3pFO%3hA5k9d4<#S4&tQ9H?Ev_2swC;b+SQ~ZAVSnnA_5_DOLNMd$5L#|lz=8{<#PS}VCD-)a3f$}|ZfVVb>laWQMnC|+7!n?{NS)79W%YUC%i!X4zDNdY*i zBz&RH2h~Ld>Hgvj%oXh_4F=)ZG=2TCQE-6D2jM(!n&o;S25|`F2?DKt#U@IXmzqzq zpbRD`6Apdge}00RI>ggMTSU7=@ILJ-`e_MQEx8%^XsRv@ILY`(<0p|$2wFK2jhx!d zIEooFd+A^bdFsEO44!(BY`VJBdM3-KN1Jxren)JGm_9lPlVoz{7;GDeqnl}Xa`V8Z zrK8dN!M|!~HN)h`9xOOt#cX?{xQ7zGYM;Aq_hJU}nU;G#eBnut>uoxVlln*qfE3%c zm|9yWEnlmg$F-m}whO&{C-yB|jF#lRWInY(XdlPWta2ZR+pa^q8V|Y*8hLe>etk5?Ij} z^wx0QBB8>nz{|;%+}YQYG_-PG5CTIxh00a8i&3JZy{o}2m$KTbPEC_aseZ0}Zm&?@ z?xG{BH0BHodK11cA_Y>IJt7G8+B+C03CYl($FxxIK%pOc=_V6P&^^Yz}eI^sBz z`Mr{>T5M}V0$ziE4XLaRbgOWh{+zyX2aCFB;fLNuO7Nt#XQNPbcdyLUz_GJZaYgiY z9$82#Gt5RvZD}0g(ZHN*%YA24M1N?OU3f&I??tnsPH+V9!N9k}sudw0c7gn4GI>ow zRJN8$DEUrBA*Ru?6BD-M+f>xwn}32sYhXww$RvUOGJehtjiA ziBnU<*|QYG_`zW`(A=G86Ve+bUwe-kk?kf9(~H=mKV0W{jn@fV$dhZ7qgAe!TYZ0ws!yYgqMGt_Z3a8 z=a2CeCt99TSAn$?!)dSI(QQO{p zrEz#~(}C1@D$~qoMtsF1_T{cT6LYg1??AwphV%Wp;!FlsGj1bxdJKpKfioJ!PW%4j z7VXgC`NNd6p?UCpf zf;ilDb`9-y1eRI|-5pzYSl>%lVDR$~YS+xtc)<tZU zpS1^`Q#@Y4!g?uLlRNdJ+P_4uElj>_B0b*H5gCRj#32M&VGEoSE8QljpE|?Um`c40 z1*ZMu)w8{tX{y&|({#2GAuJ`XABT=-FhZ4fzv>LiMWZjV_`Fb z5w9r{3}mR1A~Irhtz?GMFCrKq35kJ_;vx))avo(#62xf6D#eFP$9obkO@^`c9vUl~ zrX8j2gg1B$qy>C%Kj5VSA$;*a%&((QcBcX5P@04|Ot%pApo_$T){;4%qnxL(C_0ww zc3y!79$MHty}1(8=!^%x8k3B`cF;FWLxCj_#Hx*b`;0VVO z%sAT5Uk$Z<_Dc1lmZ&E+XN{-Q8o?ZB8(=eQa{*aot?(@$*T{Mlum@<`5@nVz=dW5U z=TcH1Idad<UVTG479)0zQoK2*gb zF~ax`3bRGaUhf0<@w=lKU7^r$l8;R;wC}Yp##vgQ>>c9HGF1GWT)8iBmp2j{J*U{VdZ5Y`EQJDLcF2`jhl?qt7cwCv;3p6Hw1RbsbHOUjNl=Gns7($W}CBaPCv`g051h3g^?0T6|8{DNa3#>a( z_f=#<#CS-&?20x_oh~abb}h?Hi)wvd;pJBRO@A95bG)U6;+cPf{6|b?0s4Oz!d((j#Rz-9d3L59K@u$I!jpz-%? z-O-jBo#Vix~69)V&ZWXLO~ebW~!d;2*1t}8&;4k>rz)Y#GiMd zR&LUxE*Y7Oy)trf(kDMTedeyEzsfIzL-aWwck&8JkEe~zDP(G zVfBm>9VA@m!2~$f)mB}Cz8++uxYFOOJ-pwgQANz6kUl$&j$w&Y!5o>yL4ymT74JpF zzLJrl?b_kkAcCe4+k=$%vv6O$tmz~W!fiiRpfA9NTEr>Rot%*S9zxPx@9%b+30Qa- z1KZAE*r`lXwDz$0d1QD`sk>}WYc|4B3XtNt`*lm^DE8UH#h%t$f5!IpByEQ1;!WTVe#BQjL0H&HzBFn_fmWzE<`b1l{e+SC3x!=m~zx=!9!X z1LTHTf#Zv7t00UHk%e&A6J{LL3KbpRv(IAV`!2&QO6h)DCaEa{^=Pb7Ho=h3NChAE z-u6q~omatd>2ZOwz3)gBU_{S|9JA$kLP2DNelwKlN>KI~lhcRot}~O$;ISr$#^d1Y zqcoxW!yLqMiDh z@QxDCH6(og;iP;hckJ}NN)Q_iB(9zJLv`c9TY*L^aq{FeRKx%&ie0yZB{)NqO|gg^ zC~GH7^tB0paM-FpXYZbl)exCloFj09b80w_a@EkPi(QJP03B*wHlTGuq=gTU%AHO zF$16Gp;AQKyb3u9B? zLHtEGLXUa*`gSlmyXJvrbPDIZ`IdNXK}fwcq*N0QGYML8$J&du-7f}{a4KN~PwEPR zWW2SN6NrrUub>^CpQX&Y_`F?tF`E~#_Ter~wtVa1%)txO_bZEWLEglZF@m<9;+~W? zV+yw-Dcw2kWJh6V?@-G$r?dO5W-*lUZ^8D=$#0zMP&ISEpli04z({lur5|~b4s?J6 zvjkXgOZki`@p`*aJIQjrY(g*7;;d;|%Uaaas9cLkA|rj#+%QD?k07OFK1@aztvgg& zlxMVQL50O}PW$$h$>Y%=wGB=Dp*cp_>nh>cj-1SsFGoA&u}6Y2sE|0)Sv$0J=W-&q zZjikM^Rhxzl+A-;>dTmS%dcf8MdGTfZtLY6Cly7lKMr7-F(}N;rofgRp04&|D>^H< zIA|=FD7cOQS`?oJy*|(A2NtNf}mfb$u1Gs2NOe8 zqaui?Mw9n2E{TN}LKsRSX7ORb3*O+tc@&iNP}v=15)g9Z(}=`P??F!g1xtODpLU#2 z4v`Q)O0(JX{;Y?zq0lmbSMG2HS)@WLnw%-gsaz|DSI4U7^F~a0Ngn7DqBADW z{D;LD+>!_)P;S`QUlVdR9?rUy-BPH|-%2)H-1?m~4^p|Ke$yp4MFez-ImIOL`V_nu zq&3+hkIwr)!xaT{ISFhV3iMAQO#KHDIz6$gDj|F?**LuxmC^)W4JD42s5^5kJ+4X` zqyTVc_;wLj`(LRroI2or6fNfU->}jsC*f$iQN7R|`{Ni?FC9)>=mpV7>=SYg4M4?K zue(;y1ao2K9q4-BFgNWsYY=Ub;#a(=73`}$VX!LZa2sK;P*>xG2(r*;xa$}^EN4*a z3Go^1#n-WfD-#~pKpRdzIS0z-+(MW|4vy4u02JndBOWTO6y?DC-`{xrg>s&{R)GpX zpA;1MoC%j3jl1HohQ7*3f2~g0^@&z(PH17?Y7}>maM5nnW1;{>$-*o1J+zAvbzkLgz%d0JrCspy zKA`+fzC0;u_-!jvVi)76vyH$i!W@sW>2?6oE*dvPp@mN;AAJvKMBnlG$;(CjwjrMf+Rp+q{Gau=%=+xXU67z($17#M*a}zvk zXgZhhIC>~|0tSLQ(m5SCj*V=N2Tjyscp5{0{ia5LcDaLXQiEudIJ{atL33S~Cn@E2 zmYR18r{rI4b6wdz@F1t+-~aU6YTezVSf4aI@g_4o>Vv-1QUFuSPKC~MXsjSV*W#3> zzt8561LvIn*sVs`ccQB>`R)nt=T!8aWqTB*+4*WVDWVhU%lh?WYy3%Yb6v2MQhK7m z%NW2@v%=~$&e5yc6Z4)M`ycd0Oxv-5*I)h}lRwVbH|;~h0rk|WAfvpJZ${A0s0rl~^5*JR4E?-B1$92aE1NSPdSI>MvmAj2zJx;zuO7x zc32*1rB7@pqQ7Aa1}I%XM!OaXq4)vGV(MoL&*c1bl_dzoC+KMjKbWNo(X2l7ohMAJ zlZP1T{~%}x!0^##vqL!|Q&q+SnG5{e1Mn2+iuU$L&(~uX8IC@M>zO5J?dYIP?FNkj zPnmZV?2blq*{v@{0M?e4&1(|-479r~r;|S2$lbzld__vN-Z^lTkihHbJ0voD)<-yr zuc`h9tSt@Ps*4cR*MQSQ#;oi><3?a&?Wo=U-nGGMza+O2kE@mt9#UisRo7yy9b$32 zy56Dw9&0{e+(A125d|d1I?qYQPWmL@0Xu40#F*eKAiBR$?>%S@hBWI2Q>M3|X9cD| z9#iWizvP$^tRKnkP^?gD!_*qPvYa<2vSW8;Ou%duC%x|J*=h)mp5<(tH?;BIghY<( z2}-CQZ(51LX(O?U3RZOw)WKArIGdk|?|LMdItuizl>I|GI2oFjiqqvOtrj+tp06{{ zgbDt_pqW}R!q*@~sML@)K zA4wvD$aSI6t@!bm6+=(kYZ{twwRT{$-`OE&uqOprEn^p|yBbOKZTQz%?RW7rec^|M zlN2G)OkzFtn9KUE8r{nwTGhocEwrDoyVY&(>Lh=Vj6K-RYcAbmm!a#Ro(saZX9_q? zq`{0j%#lgsnn>pvER#_?Cwzo*-u~bnt(cPuR^V6N-2a_5rBFk+Wuf9P*2Y;~xK71r z8SLF@1tffwh+ee2LB{4ah>}B#Nl|~-(IIaxdo!v4$F$FH7Tl~YaLKc~!FW z-H1ehR+6iUAvq0#gE4BrvPYu#{?tDOmB;>q(Kj!QpLUi8A*ND3GC*cDG-wK?EXopr z5qTtr`=4_F9yZoWxLh#Oe{i8W~y9;M7Ef^G+BXbt>}u?{ub2 z4l)VmIA8<>ke6;bsn!W}pE*=XHR!dE!h53ErFiyrw%K*m>4O#-?}|53xuw0cHuohP z!<%|h@-=pLw3ZWT{kjeX6aF@EkJ9nD52~t5T6hi!6d}8M4SFBZ^TP$20OqfN3jyLF ziZFHFFSF)C+bafhx~Pe+!Wz1>@SHQgUoK8lEzg4@+nB5@i4CPWdk#63P`JT9(gy=< z)giwFsKNoJ+V(&eZyIWq+;_Zr2~P(ebywYxb(3PsEb#pnGM;0g)5P;{9QdUdU|2Jj z)!LTz>4G&7`O^va#Mee!DT?v63?Glinuk-{pn-x z7R6ScmNh+eVr1)$cW34|N}OvH_&ifzFq}=0 zY4gQ0#tbdS1*b4H1b#NBxwBrDG8UJhogI7)c%$Tp9`EoU4R|%Egd(M|guX_i-_~CL z%kcpQu1;>!^)7u83g^3GxMxyl*S=7D^@>%t{c?2W6SzzSq^uU3*Q`Z))@lcSm~-yZ zy!b<@K!_GsdFO zz50i8?mX=0S;whenCRa}=Ta1op6S}#BW%TVbnb<;SRnb(TFNfxrBGbBclq72(Bfb+ zPE5$SN%)sgUNsoc9sIzsx_6E=e$gSYx-t&NQaxORRO@#r#_b`F@wl+usa_Lwz)+CS zG=(tiy+cygU)Xdk))LX!OiEFT-;YwEhI^D2M&%A zpk~CP9U8`2oqhcmz^Y7BJxJgZm$7E2`XLzU`qeR3X6#S8RbiL92H1O z5miXN4k>e&1qQBh@FLew{a}2FglOJD(_TGml>N_n`A?8U1M<=2)#Y9c-vuMo2looiH`U8iGJ?0k{(J znwJt^M;%=kGb}gU1Jf@Jm6l{F!2x7Z)URnK+vMJ$`QtGE02uB28`8Wp|D8yc!*s%Y z856}6@5HRTMz`$w$S;VXce^@tBSP6LuPS?fb#-UR^U4GyGr=|%)Q?} zx%N^*Y_Md4a)(M2V+fLYZxdj6y}zdYDZBowRn*HCVDq@qHHR5jIC_0mB{n(c9p6!( zbz#hx2o(qwoY_ywr?{4&Rg1+71}}jGq14X$ieKzZ3>2%y6QUQuy{eYY!79rgaDB`0 z8xV(aY$c7r&tkzKI96Dnx#E9#+(H$tkxIEDzhr<~k@Myc(mi#^sZLpr72flv(gpw_ zzh75TePWfYN9u@G0mmK7!4Ee2UM`q576yEDSXnjq;g>x(`xhhgc1qZJxjK0qO#)(( zRSW$hRBguH+xtJTHv)o!m1jzBQ~OTh=rQ{L_?ZN6OI&MJ5>jdWGe%Kj-}C z9L!plMZpE#ln2ZTIks5891>jTa<(^P>>nMooZ4o!24`jY?Xz+Q0SdwD-wVw@6V+-m zayZSnpNi1>Pc~3}Q+6#BEXZ*AuzcLXN()m+Tx)dk(?spK3)-ehY)dJnsCqYg=^Dpi zkCcd*8BE(y84D;PH9~E#$m+;mm$5xc?M9T7VoLwQqT!RBpmDKvV(`nj-otGA@~G5p z+As(xz@v%M;Y?iK@*|7O-HiC7@jO2&MCIm0Cj+&siQT(GHP~~}BAVSjt4?x2loD<< z5q9%D@v~jvajAE^KM{g6nfbjyZNZ`d+q46u$Jx&2@79gMof}>2vvop<@_|*aptPvJ zLMVevo{PBk+IVGim|+9+jW5(Gpg^{V|0e0PI-cO5D5~vv<4+OZ6m;Et0yQmHM4X?| zV%8Ny3mkhs7%hf?e~Ko93#7n>Qaqyx&-H;JRk11%0>E9@uUnY9E z7|63HZ8ghN;GLcQ%=!>Kn@^6<%a27S+%Q*6^|4! zYu2*I@**!Uw@b3vgE_;P)P&U05^>cyS)JQ$^rVT_$R|Ky4dS~g{bJR@6vAR;o+LAb zkCrg%n|X^q96&oH(!RgrA$ABOxMW%OO!T~Y|KL5aK1FT_L~6jdcFJeKxG0xg;nD-k z;~@+=8CeI!9j_>zNKm*9YRnOI6$zmWe$EL&l+##i3Dt{vGW9XmJ<%B~JcJ(UD}Tx0 z&UIoNMz6=r+hpV`G+5Y0ozhxNUX!nAUp&-eYZJgzPB#4zy{3r* zZ34)qY4(&C^v&17CR3AP*kz$<6)x@!H12P0B;Ww3 z8o;WG+mP=hZ%220nN+w+mwO3gfT|a|%w9ZJ)LKhR(A^7liXJPdY-ss5!ukXiT6bqy zh3RJCRcs^`p062DP#Cx0mb7^|zIUycI%CJR%Fm!lFQY>QH1$CkA1s8K05?F$ztj_} zYFaoTW>@`25H6Td1{2o`#qGHo3UxwlUM^s29Q`F%TQp8|N24}Qgfg{449Qb{DefVi zO@;Yjk|npGUsMD92=PEG6!38_Jhn+pYf(B%l5GYMjboGDLxC>MhV`5Gtzs-q(@IXT z{fJQR#l;r;MtF42Jc$$GQ%?J;s%U!t8tGk-H%73F@rc-{tuhniMi$GHSo7SiF~Gq# z;V}1?>TEh7v^mfEC}{cv(%LGpD~zwA1ny;y@JHjn6Y&XLhjiK!o&o5UrQ3b^Pvsh# zM0K=pBcGlK@?3;Sw-}V#DpNq$$=`OcR1rTz25RYlkASlEIl(XHA8@ohPXZ8b4*k*< zbn1lH7#|=nerZJEnQwi0jOvNmtW#1e|T~*}yoURV8A|{YqRjzqRD~=fF{b*G*+Hzi+lG&5$$BSEV zCq>t4!pW@UYcE&PdK!4{ki+xNFv%hT8m=6O9??GTlYi0Cw?O}kt)(!azd3i_A=S6)~otc_S46acyQtZ-{PD+%KY+EqHMCBFzX( zAX}RqN2a(kZ@*;?@h1%E)u_o_uh@~tWj zS?r4!q2wV#{naije!)`nWlP4Pme*f{m{&Q}0y6A~j<`fcfAk!C+{7*ddY$Fs(oylm zB=J>+Db#&yM{p5hnm`5JIt|lhfC~T;+29&Ju+>vv*ed+X%=xhE)!B4Br5+wgo zU=S5IaeM2_yk)Bv&NcLtj&qWkF=yI2!MzL6bh+AO9IYXFX1j=b)RI_foURwGUw-<0Sjl zfTg99o7Z%W=B<|T@s_F{I7W`=JLB1!p3y?S3f7T$bP%XYnt)^)(=0G~*0xb-^e-#^ zsTfEp8SJR%bAE1vb)bY~Y$7V&P|;M!hKksn{m4at>}-iEp(NLy75|&xfn;>}BiAt- z9P}t6$8U1x7)KB3Su(W|{_hkKA;jWes?3(|$e3>>GduUPvSyVIncM8!>fO01wkDH$ zEn>w#?}X|-fc2aJop@yB0Fl|}5ii0 zljN8X^;~WKCS5Xc^X<5V4IX26GCe@jvFD4yEm2wuSa!X68$K3UQ=-i*`L0oJAOl0; zyZ-jhM89U%WQ4gEu^R$G9E@wSwo0hJUv>Z&>f+^`ov0Tucyk3sxOPM#k4KJ7i5T~b zz{eA^_^?n&muDQntP>CUNX_`B=xp^@!e+XXIU0}{<)q(@aOdYDK1M=~+GKA&H_TF> zzvo)Jlzry#{xt$jPd7W)(~Ap6aKpvGFnbusA>eT;&V06XZ#3$8&}j|RuJ?5}g5N&+ zU$d#zDsCXOaKp|aiV#k+t1U{-HA5UZ4fQrF!?StC4FCt7=W(w`HocQFNft4RJB=Vu z0ixVp4yJYb0uwG;2G260u9QQ7?u8;lS+(s>SR&biRL`Tf((q(j*N|Y^D*drxEZQmz%(upOKvNKgQbZ;8a1{~=?sn(inn>(S_dPN;6p{_qqX&#tNRFFS z{eU9$vJ_}^q*NT)h`1O_0_=-{C8>ZcW^c(f#M|(A;xGrWLy05ZtPqjUQ^m<%T;2(? zi%p!Ho1`Hkx2IqUE=1*!bU`SP3GT8L)6u4Sb(r2G(W zc$>-y2w5WRkwP`Oz?-@bH^SB4nd2J=QQ%n*?RLN0mI!2o_d9m%VP;)XY$J0he$U?Q zH#myx%S%k-Zb7ItJ(MTp{7l?~lHO?+)btEb0}aK_(rRl{s?hWdNqHDRWz;3em71o$ z*SJcW?k(^**w9}@zdig{&iW8u)yRgtFxrSvPu%+KxM^TE+1A|*d=0Cdkf3aUBswX= ziCsj?A*-r0xUAPFGhGj%rbi7<7GEQ9#&Z8>q-VY<207QA;{fqkU7D^Iqxfaw($$t> z`7r7J;W61s5?E|)FHR^}IZxLhp`XX0IvulL1!T~QN-V4Ab_b<5>s1PBEN4>xH(c8^w{CN z7K03h7U6z=32&@J)VeBp?e_;bR!xdhVgYYrcT&VUAu)D^JWWoZHG12r{iPQ0pdd19 zl1tT1wz)o756UG^#HZ+0(4;W58Cx6H>>}%cx^E2-U>w;*_RiL)Ntf!$j~MqSm;4mi zDsg;*P=ln-N(uH$|1d;e*2)jN$LO@m9r=c#SK%E9FkN&1_e!w{~gc*W)YRsH0xF1O|NI!t86McWSBb1k9 z_mnA_!?~v$MXiuTL8jU4e z*+L~753o0Md-)g^Ma+m0X|`<-m-$5x)w+vr?2tsfNUr zHh#dAM^s;*^j45L!tytZ9i;{Fs*@XLW_Erg%E7R|+$wdI!55q@`}r<4q!oX<0L)EX z$pmiD3hrl;VW7x*1NeJ}%Zx?Sd=H`tj&*}FV@KO7`g#&=dwJa(nS7XY+xaJX#u@}} z%RpRfmF=fp$1KX*7#rr7w_C``)d}@kxVeF#BU3k3Nd2Nv8TXv zl1C~+z3o%dPuXDr$RRa8)Jw!Zc`?NCVUp%D>VjQlNm!~iV?qxF* zAgOY;dQV9z(LpYi2uZ7`uNA5lmCIm#!csc)TNqyK)juyKjFxJJ~9 zN5t!Svw^MDrCp#vQPd4fKcTQ7+S_ayIv|s*_Ii>MwE3#G zMRHOw0s8>k)`xZ`Y(xQt$`O(U-|$$KT6y(|oty#A=VY8%z;VwIA9)4%keUsx%>t)l zRWgE5!D6XY0gajn=sy=a23)DPY?gl8wZHd+3I^UxDy3qj78pfseBH&{)pYgmgz}%0Mwq?TT!k- zBCqFl*D@n~XKIZYJ7G8(rx{OJIo$V~`PktbV<^nbYImE%D9@3-c-8kq*WbCN>Y40P zTfE2gh^v=hlJT7J$%da9%&scK1<;=^<_s@O`kYE>pC5metO?soyUE!S@`kK?E#cO7M0R%W)G+taN(yC_-sb- z=5ZO!E)OS6XYwPUc?O`S4_HV)c4_$8ef}fD;3%3&6w;7sjgU0*zQ7D<|6sDkZ@YZ7>Ft6LD_*Row5*U!ihW@zIx*^{W1pkTwBg-p&4 z85*E8ROVo+{*`7a`DB%-tN35#U%j-Rs(X)_ZT0nTx^{=d$d{r3nbS-|O-F1e;1G?Q z6P-vZP`9?AR6sx)_y*8-ZY<;5%2L=qV4$lpQfrk3$99LPxeiq$-8>!@N?)M1eBDtE zpJ_0-6^L;A@);M$4F1Zp1*qVM#p$Ey%K>98-Dof4%Ecm}uh3GOQ7(|#)qqZ2F^F27 z>yR+a=b3%NJze%^g3puYI93)9WmwPiGQc1EgzENjb7+A%VUsohUW3G2BSg)&RtIUn z-0gxM-x>Me7po;;Kgr{6eqsMN(D}lh;z|d}tRF{dVzedPIYa|R&-r)I?=v(FH+A2x zA(PCY<9dBX{1OsmjvRx{GQoqP$-FZKz`-e`#%j0*Y_99VtH}+)ss#z^tIWNO0wa{5 z_7w1C2g@E2b|wSqTP>?JsaL$T*SGqB80y= zpn%Sd-C+ict@lPraa+}$J{9b}d-G)Rl+Wg01|cWJQPPD>=gIAfJTmukP-*IMB&Y;c zCZs=hrv)&=J`*Sx@aDK6E_E0QL%NxHZ03|`v-v#I>?Ph>e{$_8gl#ZZSU(3#x3=sh zMVk+3qO~pe9a;wxbROoNeKjECwzh+h+BGONqWZ6hk(XycvQ91J{U=)DV>xmgLD#$} z!|R>Dov+OHw9XX9I*)F)A(AKSyIuYhVA1`Vgse~fv>4B_E8fEHjSY%8T9itEyj%}| zZz3N=j0gb&_>KW^OA-`j7?TqlR8Q29GwQ0KJ&vStI?wm63_$=R#TCUuXCg(q%RjO-%od!Cp6aDk7p(Mp8?7dCtiBMg>$YW27GTHI(LXfh|Y~#?i z-5|Ko$*{$R6SMNE1!D;`s9R+R*`#aPYQoO)sc8G_Dp&5G;Kj$&?;+jj;W?d+?~KpF z<1Xvwd_76r%>ga{A7+%=$gPi?=l>L3++p9yOBEi9rNDp6PRh0@FVNbHf*p2rZWd(y z5$`_$RM%#xiH*A3Y{u-7)I_RFywSJ~W3zkZ)H&^otx;MfVZ4vqD2;{--6qYIU(a3= zV<6GTSqwfZy@Rk1r|g@CFY=LBqouy0GE6MZIDBd#y4}WEyJo1o|!APtnJEpy5KY~)Fs!_1kO~?7hx=RLV8ZO2kz8)bgjMAtSN_R&S zN;e(ri_j_6sui&79c$@*R=`1sT%uj}btVBh!WBEjdoa1o*e=Z`Z3FpIerQATBw@An z)_PaFAy3R8M1JjB*@r+xD8VuAlOc5sY6nlP)72fUSZL{sCqZ36_;Lue;5`K?){gR5 zT%C&pp#DT!ZLrqNZ{M#P8L+^E4QMxS_2%>ZDb?O|d`viaaJUAQ@hoXbdKm4ckvGfNtz)BL%r6!`0INAQ$2+ zyt+f;f(f6AB;A;yDLj&2_ZD#x@LAS`MPx|M+u_$bdsu&skykdUJvnVTw#$AMlczEV z09Sm&{9YCQDaOMCVJb=)+X&Kgb%I6l`rd7CF$u5YWGQ4q@o}nTjWqu8&h93ZngE}B zi|%E@Nb}OZuTH|rWJaY$Z0^4TE=G%|TSGc}dUZeGiPgj4cov*zZ8WmlnQ6MMA)KMT zqgpH(XUDSHtl`lG$fIW!KPa%oulqQ?om5<*Q(>Iz493pEbhbpJ_9|CceNO3-Yy#XJ zhU3NAAZ>(8X9``0ijKcXONXf@O7j@<>U9XvlIJDDkXZ4|H2DtYTI$olT;d1`rh5yK zG5q}*L(=?!-G7%xZ&9m;7Nu*o2rQK387Q*_xxbB_=XSWYcu58$Jeri-YrDfD_3a!9 zOxa7)Nf?R;o-S~igpN%@Mcj^AyX2rodCTH`qXLRalFe?^gGITPt~EQ5NtTUmyHd$t zl_6NfB(sfvqIQ&ftgkg2mOdz620!e@IR&u6;D00%<|u>@0$KnzP|tZTR1^^#C?#Q? z1`!u$=RP26`<)u{>k4$Rkl5_5xdPMt& z-XuoSD;=?l3B5`N(2y>CcB>5X6p3b{+W?n(&`&+${3Y-fQ)4ZMYmw1?dZx7ad97Wo zG8*!%ba0V6OC0Xv4%!><{X-^xWXUT2(?GiPz##m0Bp}&1gngZI>O>1tfCTQ9wQgP0 z1R9E1IG%-gSo{p8AirG~RGA`(H&v4L{)D7-o&R!J&L0myb-eQrE?#fPck4mHYxdqX{|?7ctDy}TdJZ| zZ2<#N`<3VBf*+m3`Rj<+EO}T_NIS8qIv&rRBOGSt5YtT;z9d!WBZlMJ%<7O#Hvl|| zf;Wg_@TT5jz&tv<0OVSDW7yC!FFq+Vcd5y&-XLJsTcOf6+Bc**HY z|4l^F^%M4B7;snc!|r^%shej1FjnjAi_QvH8()i;d}QmcJ74|s%#0dD{!0vl0Rrxx zfN7v2`IZB11Ng;)ET5KGpdq?v&Pw7K2A$|0?>VVlI?tVj4-G$(_4D}l9vG(u{KGe7K|)eFt8e|C2}r?FRq|nc zo8oxO`#9M=xhdExp?55y*Jjg4neDqJq_i0ipZjZ{u&KU+Lzv-IqmUHt!OgTY>&kW& zj4u_n{C)ZPM#r@v2Kh# zW}E;}6L>x6iYNWmr`0*+Q>HMCffJGvV(&=DS_9ib4iYt#o2@D!1Dp}{UHnl!YO>Ju%DV zzRZLjQp0ry0ZY~dM-ots7 zmRg8n{N5bM`-wYc`^X_pnf>da+zbEyE*nVXr=T|&8?ox+&$OB7fARN~F%LpAOO4aj zPcNdTPaa%AGQZEf^5&{tUaygpd;?RhG}` zP2m=X%0rDtm$aH1O1j1$u_F4+Huc=rNa}I!KZk5}F73*1f^Dlz{K3~w{=ED0hm?fRXd84|vHK6IA>LIizBRBY-hnT&K+pr5`G%pN4BLv)NjA?GzJM{U7Judo4xx`IvoktWMOmY05@y#j#57_?o`4l z%z$2QwN3)r>;pSlWWp(NIrOJvYjUaF7}ZuDTCOGTv>7*gV)e2{+d1QK#ffl;nYtvo z60XS;YAhfIB*_Ux?m1L~eCT0b{J}X(gXN1=E8o?<>BFU*q0j-i4I&mZWEiTs6vz3h zG3K*)N=cu3;1j6=RD)WRJI`e$k-BepJQ{q7zisr2qojq^EO|LIai{ape6Or4X_h2S zc4TSviaWuJq~;oQ5mxW(G4+x0{fNS+^cVD|GJjJnHb4yv6P#ztblErPMXCp`agKf8 z3SkvtB&}yfRnoK*(`OWVn62KD*1&Yil35n4>E+aUFS#~YThOAxvNW1jU7Ek|G+52S z|IX>qPWHT3A*}^Kc>|hjx>)0_Yg@m|BOWl+S-yKHJC?PsvOUAzu1Rl6;)~H zxFSwifAh`omw`oKS{4z!J^niK4??(q##8p!&kY4_1_hzku&6ockp<)nB`mtVvU+TB zKqD+4Ywx$wH!t>g@a55!mboU=;Rgx&3D-beN#oY@a7k#E9Wk}$TG(l@%x^PtaIgF` z$7O5C5iij|V+OHkLN!3ex_so%{yeWsu61`slL<7<`J^5PPaz;U03goM4zUsiyS++O z1AN^Y{XrIvpv8k~&|hbBOVIN$a@&TWwB1N5J#`YTFq#JTrJ)^tVee-U=Ut2uyL7tz zkjH^E^yWXHQfu}aqx_WUJY_vIcdALao(X>3uL*6~a2vZ_<~8k~sAAxaFFRI|K#)>n zwrTiR`fe0o2WgpAmN@g^1P7Rola$7TI1OS5H&ZNMzAvR@pD|;nyKr&BSPw;NjBM~V zPbVZt;7qf+D5oETQ(mHoXd1sh$)qydz)ZcHC6KVn4FWToZ()oK)!ot>G@@w=GR2ze zJ!9tIx#Yq6VL5PJncdXDM?mozQ`ku^oB$j6y0o1kq7^9Rw)aZsQ!&W@pYfKn+KL;5 zB2dRmT7*ug)3xytcn;TWc;UXIa}LiX@oq0<6gll<)(n7MOW-WWuz54yQV^{CjfHN^ znmX)x{lF-nsrEqsOu3gl19egoc)VG@e(6V48swV(NfWqm(+ib1pdqPUL!eX6VpnimBw@(4 z`t{*md11i{FJ&yGjH3n5ChX`Hzj#5HoV<9{Vx2EXNR}b!C+CeZ zv!6pGvaF!bOPX1gO3Unatt#OT)T!j&tfgAb0Vy07fq-Fm6J}(}r7j2A7GJ44s1eL; zD*9!a3q#mS?5-S+HHjBmD|kJHXQ%TFkPpa{wQL9@Vu+@`_MJ3rHMrk&Sevlx4{$(uDAOLxdDuPfpOj0w19x61}7NW z7d&iP>A6B*ILc%L5bRGjyf^-^bZZfTA5Aydf=xEQQu3VC)WT4*GK?Ns?Sc{R@?Ptv zXK}B9OTF6?djE%;&=?FD9YHihT3H}X6!D*d4XT(nfM=R*Ul zkd)@+RXoN76KX<>ZGP^@`rvaS)gtnixLxUjbrmYsSyyLsS;3TSB@1l%_v25`l!qn> z0Vb4;v;tUlW4^CFjrYRaUIvq3RDbxb*ePu~Fz+I*;}NCkQcW-qm>_gmFiEl#I5ItR)R6tYNrkfcwQO6m7C1V_E5;d)h!Uo}wJ%+( zPO=#c<%q3o;!wgte@%6kFNU;%Vh9H}w1UuB{kXbx3Y+u#00wkvwpnZ&G9TgfUG|a- zQjQB|6h&WEdi&7@0dg#h#H-rEvwX@p$7hF!3%spxmbKk4sYN}3@_=Qhx%R~& zp5Qu*rCeOFQuZ6cI`rJ-%?pL(_F{X&JC{mYV$=n*tQKQk6&R+4=t_=s_h~#B z`SAK!1v1`=krlF+8a&9f6QM;-qv?B)R=vNkUd-pF!fhV|logpsfP%~Rp#fRN5MXOu zph9qJ$CF&0N?-PPh5L8IssYy;3_71<(!vi#)}FAR^?dwW@!GF!`&^( zgIsbG)(9a?Am%YN*n8%H1*blcO9#z6eM>O#h>urY6^S!jgwnVpLL!R>qJj z{LA?h_axWpyeQXw(OQ3*f{VA4qC}p2%(8O%W+0tCBv4TU+Z!J|ZA}YT$VkOk#^%U~ zQ~$KQE1w3p24;`h^mQMz((Kt1wE@;yOiCc?u{xs=nXbm*nR{0hhtnkiQ2ztYsPU5> z{K_O^z3H3CfM1RP)g{^sWOwR)52au91sw40-nr6s?IGe5El39QTI}Le$;8lFo!J7O zcb$L%CDYLdaak=0Z+CyU4d>44CJ4Hd0{&k@eF74*SPdx|RIix{p@IHrx?v>Aj1XIq z#kpZ-qj-i{&xhUag~q%A^WICUD`ZIU=qN-JBX2@=ABJJ=g;X^wklavq#Luw&b%Z}x zu=4X-=NCIJ@qFmT5}Vo*=V61rz681g@Gx$~c|&tk3u|@Mqz)Wgg>(hX;VXG} zEh}O0t|kGu4RpNeIw-?{O-x~_qO6v$hZ)CMo2y?BR!xg#q zA`xk;zxHOqfDNwk$Fn#9nj*%Xr}7kzOq>>w7?FfPf9s`WedqbEdi;afZxcsC-BKQS z=Kk(|8~Fh8k)yR^%QI)pLJTc8&n!krd0ekrWGO$Y=uk&jQwP@tN20=p{m#5Px~4CJ z;I_>1>%NR$sg=|XxDuiIK(Xc=+%CfsRvOp6=BvS%2+sF7fmIv}pUx|({o@vjIG$0| zy_K)BZ#V;+#6KW_A^gE8D1#O4dBHC8uTnxRuH?Hp z;pC2J$2c>^SW}Gg-r{5{J$R9&=(cb$?ROh!DX^ z6gqA-L3k4j)*yr+(%-qnV;3TL7~yd1+1SmMGqVzs`_(UVxkDx;^7=7J#^SsB_J+)2 z2=_)|{zW0Lo*nMpm_?K4wV%_XPe=bW{nf}^HArD9PNCPw^ZV=L`>k3s<_W`+)du>% z=PnEEYgl8ES__(4!9jq(;?dixjJr=4-I?!&GCSi+gW3!j+VPlmp5l{T0U((rCAHY5 z#mi>sTXD8I(>>>1BA!Q3D)QIi%8MpQIo;{u_;WYXjTxHvh3!sC@`fw{^2W&~`4Sca z%*NMDwG*Tdvfw;Kv6X*=&D9u&9%U_M#m{H@Q-b5B(Ks>@OI)IspN5yY6os>ZOjd$d zWriQ2$l`JD81$U99e_Xn-)B9URPaB`6E-+v&G$mLZzCLCW*^*!3|rwj(i}>BG#DOM zLI=Reyobp1>a_rpl^4JJTRFH}D(IVur}o{O&4Pg~jjiwDD_SyWq_<6CcxTEcp*kR_M+kxatK+u%~ zJ;syi!IkGyIa60N_2OZ8PE`(pE8%pOtPi1=~1 ziftM97j1MrgllEzIm+{yCnPk!djXN3kwN8JH2LffFh_HBiq#a80@?Iy zo-BG5RkQI+_t*-Td5tBt36Emax1=^9<;0}&to7fl(N+GuVTlLlv_NgaTI&+1)x_RE zgkoF$QwJKHuDkpv1PL{HTxFhy8Sa6&S|+cQDuB;wbPgOylKTCbzx|-Mf`oCprw>ya zA5xgecg?(I4zTAT4Hujqq4}5}wxY_ee;pU?&2;W{rFXU-OUA|o8C}6BW#{q+2g=OM zI(ZD#NKf}qJFUNCt*W*6c#S8R*nfzAr5G$Z??vlxP`JYhA$2#~R*5H)EaXJS_o|$* zNFYC)LFmH;>@RzGQyQCz`zc=|G(Ohn&|faL7wF*opX4k^UZ&z89r-;kE;Off;8hsx zs{?Zucv#|U+0zbrWQ=Gucf}kZ8(2m9O(9}u{z(yCMdq4z*g`d*Z5YBPk&B?R^o%pZ z`Q`bvHLAWDG2`a5>-+G_=T}IuQZZoh?ct{1AlNO2mMEf5?d~~gUCks$+9w#4YzEpi z!Y>%7oU+FaOAyk2= zr^{z~eH*|p#L$2r67CSHBPgB3EdYUfC%yNGFsZyya*~vxr%e)yBafqTj&9nzIT^iG zZvlVi<-ifKu|*jRyW){%1U(REP{i{|FwiqkhH3#p-+?TcC++xIh=Wc@(|^2aP-F*Rj`&KuGD>=SIU* z%6otSLNd}0CY`GWXh}m?8iqh$C!&~?viy@82(Y;zrvBLu49MVl9;nx4>_J*s5_087 zE_dEcA0Lt0l1^Xl0|)}HzeSI%dwyeTReRfESIYp+i|WRNIW%`Gm<<5|-btb9eh!1R zrC(NI1N@vKTH~ahVdcN?gQRNPsaLql1NRXZ$=oem+3ULm%Z!NK#I{M)d|83*;&p(; zE`Hs3*<-oX#9#2oh9JPuK)wKEV6|xk0p0wa?PgTD?sSQkd_BDLM`tn7imPZo77M0l z#cntmS*tnLx7Q*EgSNH?c{C<+wixU6GqgKH6uv)RattRrJN=kyX|x3y4i(9d47MAX z(O{d)6{i`F`H3e~I$sd`Q)`5*Hb{3&R+Y!HCDiQ6oP#RbL5@-wqeqTO6^i$j+D3S7 zk=Vdp;x4a&6bUq+Wvga(n6`Mm1z1An>4p{~?@|MVcaKH&8N~$Y+B>jemIfll9=!u( zU~HCw{V#o_Cu8nZvwqB)K4~K*5iea;)F+1aG`ovG0RoCPU<@mq+ZYde&iCkwcQnVt zh_8-0jK@EZ0To{MfIhXnxyFW@TYi>NTG@Ly%U{bZ??lqsf1q-SxvU{_dyRZ9M%8}0*$q}`cx7sasHN4r z4PUPRrkoy5>e-%}N(wQ-9!68Bek+A^J+m=D{GQ7Bk#G)8UVLO6PMW&|Y>TW!h=q$> z#L<(#a5n4xS5Z4PWEiHi&9YFK^#Gs9%l2ASd^S6_2bu;SMzyM49o;%!Rxw%nKRC?w z=V_j8m32MKt#!$0z`yr@qzT<=F|7HPp8*1W=~U z?0gaYTi(>&evb9KS+(PM4BPL{3gwtsv6&9dCWt%eIFsroWbP9^{2RxxsZsp1Pw{uE z%RhRXjnhSNw?*!_(^&QvL$7>IepLTbtO~OwGz32_C3DzEpw>4vD+CGrHUJ{Xy zX8=n@U!}9pVmyFR1DkZwE>7Mqysj`h+wsw2KBfi<2`;6MYX@OFonC6I zCSZmfO)z0o%q=>?G7mwZAin^Xl6XcUG0KTQ5o>*5V~n=#*jN!!{llf&MxgbM>T)wA z|H+dCEQ$$Wr{S7Q)^o3~Y6ZJTcudk^VIWUllYWu+#O z@W5DzEGzJlXP*CDp1YsrA#~b5twH$#e1nZg81cyWgg1BbRrN4@`GU`LYy`+a=?C^F zwBdG|w{ln&{D4aem0@{ft8a@3E@FzwHJS(cXq}~od|Xq6d2|(xHcYHm?XRH+M_-eP zkGx&e!n@derR>SkG!5Ww$;STuK*)2fS-%ah8JS=>L~ay+(7`&J4u%BN{7_#&J)x;; zr^Hm2bOeIgdN@EvD)6WJ6HwxZGQUzR{EX6fULUbL_JYn!b#8mivn|QRbrSg8;6S#v z$Z%QD^b*4q?x0HN;iu-8d&?Nx`0eRBuV`uNC5Pgz=MS`z3n?gveZ3k(0WuEt}x6lph*GU;vgt-0z5czb;Is-fu zZ2}AaV1uazEAn2I!OJlEgP6uW=bmI!TDL6*3!$Zk*%M$3<=u-)M?h$r*HAcNeSbEv z_bN$8a`pm-iubN!-FoAEL-x5P%`j=B5+s2mpus!qFmg4euU8tq&=ZL2)F*6R>}gDBAQGxUbPLg@Dp*isK6*-N}xtVU~xRlF^NkYMf^Kzv>c!_ zDi&vD-|ZKUs}R#L37FB+Nljc76sHsi&NG+}k%vi_!{5c~Uax0|4IKVgx0LH;DswjR+oxvfH@CfSx^Fmx#61O5&WTY?9 z`=6>vZTYH)R$b*mP}v9{5VYk3vGB?kLQCwJ|0^m^A_Vq;@7J!PjS?EV#obH`J3-Q; zfE5kPAOOV2 zruATQcPZ+@uVMOh`}4GN1JB}G@!@0UKhOQr)H~cK^a9}?-bv`Cpl=XJVj4wI^Y#pi zXvjA#b7=lyNWaRBUA!ehA>6xh4cWA2(Qjo;aS=xWc^CAXzCO8EdRX+KeBhXHV)pO- zIrEhE4t*TK{BEyO$0dz^bR~8IR1XXb*4@c}47u=Snm^v^3t4rjjt7E+3~*BRGBXqe z+5$>O2VVxgUHS;M&9B zxl)(UIBIBER6|)vMpqMW={T8^T6kE@;tc%|N;h~wzlLeEq8JxGb*6Jdr%cvjTT zQN7eEAhDBX!h>uw=_e3;!%c%S2!wCHbeGn=v`@)y{ZS7Bo=5 zE2#)2U)6LCFisblvEXk$&kEMx zXVdh|&MfQ@b?$p(a<&|5bU18xu6u_Zd9YaFRA5fO7f9o)#({6c#>_GFV8gO#EEMjV zdbE&W?Zrbo_z)m*k9oLnjAb57#DWb}H?n?7SE6Wg(&3q5hIFbL8(b+?Rw?~FntdV-cAw<12ZL3(sZ4i>Rjgd%6GskxWg9E0>zL#qX>hA@JXB`r z&hCj@L`|9#7-Y9)r2P6bX2Oh?a*e*}y$%m~+@?c2XjrFdpdNV0Q|X{Jr&>bi5nn|v z-%>Y9IjgE#5^}}Io#E<60OC(ON8-~Iuu+NpM*ACc-zZ_{Qv1a56)UgBMMik^mAmJ< zqa+>_-@BEIzGZ<5O6gZPi6#&E0sPR_Gwq}3<*x&HP)KeJgG$Qogu&Y4ch^WM1VUB9 zT3uT4T|zqqFODKqN}!(B4L;0})4evBMy{CHY$6a+s7e%T6>JP)uOHWbz!QJTmfF|S z23R0ch9;T}`4;6GnM}^`y$140Y2TtexER$lWFnBzCZG6KR9mSCH2yh%vETXyKGPBr z%d?)Lx9rlzeb~s!&zlt)(k|nE`k2IxCK6KC23FQVe=hZ+BSojHGwZYy53EOT(Ys*O zPDU)xY~$Um01zVP#^ z3Jk0lPMVumNHf$1$G%dw0nd^f1B3ICy(+d7I?WFvN4rb`t!72nKo#%jv04Hk zHW{D*cvL=Z)J7DJc<~^kC1E*PV1dfJ*(ViM>cWEtZH7Qxl2#scPfaN86pCJAfaxT4Z!F zbFnkollYC3M5++k{%1^@%hxpUtj{@nh_wNS0MR9 zp_g8u|Fp%+!q=HfmN*sdR$l<)4%r>a<#1EviSrS4PG$_))j-7~p9n0BL=y61g!>jL zTzEmvxP^CENydwzE^w@6)ewpyAYOi~l$Ma=TpX?~Im?4lCYM~_cXa}`bC9;5qii_0 z+yHY#Q}#RD$KZ}n@pyb;u3Wi;BxS<`Dc{wY^qQrC#W3#XE}3r1f}2GRUZ8DR%8C_J z1Nez5nhh!L2;mlzvJYvjOezQ?X2OUPK1e_`gn%1`$Un(-h(}uIg zNx$Sh047?^j&iqJzh>l6)pyt#zd5Ise#+t+?KcP-w|njvhms8_itC>GZ+DPhWHKQ&m@e{gs=b0Onya!Nl`6xr#2jjHsqSF?UlM1-#*O-;Lne*0%maUygYLh7{R2fLDG8z=f9rYhG>0+xRYH6EdI3-W}h)e#b>K zR^x;z!7x`0Wn(`e44HHrGHpPu0h7vr(I^5Uz^wD6ahXm7F>))u?_RP-rRQ7;{B5?Y z?|ObEI0u?-A0FExt@vfD5oe(_dwYi6BoV8rNGKr9DS(bp_|7MSJTLJQ5-}Q3t;!47 zan)#Am9vzps#9u(FIl z%W|$qVwaj(7#T+iqtX)b=zl6tW852;#}8Ujx?rCoekk!pEJ60_g3=S*(Yba=rL_DN z_Z%^`B*!DWqo~Co^*_=eilp#?Q<|na*UC6*feuZwCE^(Mur%t2hzcxv?DZhDD`CyY zrFnEd~v<0#tr0&j%0s zX~x(qicyy&^p>LQ(Nf!v0c;Qw1N3GEk1 znPBGb{HdOjeueTijvPS2CiPvwVzk3#Xs(H^ncDT1M*sn5jBg=1!we=2G06DGN6l++ zw7YGJLFXbZ1+E()SCh04w>4wUO7drv_u8T*!h0GcZwgHT*0YPtt>200TIc(_t^?Bf zrmWXWjk@@TOKGmDyG7Y@Y+P@0T~tM>Sv>+Xfur6E|yoW<2PfK>7S~cMAkZ(!#l0glrU!)IQ1x;g(E`@f)9>_ zD5XB2eel%`o0{EGeO2`iQchLSVA$0oA;9nwVsa<#@#w^$$V`}{7@drICHK=5>F)nx(rC@4V9d2nOvzxVQo= zqR`|1IJvqNy)kTel*0ulF5x*b*jCEXH0u;@UD_jl%QF<5pshc#%fUOTBCF<4^< zG8Ngx6$2?jwO8WbgMZrw!m7?= z^&F*jC{9@n?WiOi+XyFbx(yI|qv*4o(wuKD27-%cV zk)Voy{!j8gI<+@tKydw-{WEgDqTp!+iqo+8uY7SL7Kn6kQb5VKR-dr*wQBUPb3Yb_{F| z(xkpK(G@Q$&N#jz#ew`*LpW+MDkyA`q9zUIMY%V~ets-mi~UEPNmQV9ybEJCATq;q zyoR*3BvkOnFTeGSM<%IxK$-)=a?ZpO-?<2AJF_*iM8(h0305lZ6qN|7PS%VASD_YV zei5*Q6nq2KV+mP81-PU4PYX$XH4p2xaY5d0oiC^WSk52KBnJ36ht4?yK)#ZU1Xs>Z ze3v3Cl`-6PP6$QPxMd@mSlN#bP-xw^+1KX!3fgi{xa1kI8mD$MQo1r;tz7Q%43_A_ z;*w4u5|ZW@D$Icby_mXED^VoM+RQs`N0P&vzUn7NcWIH`Boj*cL!8lSGkcrZgNKHH zBXS(+DgI(Wn&hAe_1V14c4F@<6!2t}y59eyTxWrE16M4Qp*^3-R%XVcsjiz)I9BAo z*`7&5FG-n1Tgq2zqstbg|Ms)2I%PgNtwO#6jtaX{;IFI%WwuNzs-8s(+gn*VBGe%V z&?>YLTJg&T;o@AZfl$DIC*l^$^7*BZ+`Y7qsAaR&)K?s|{64$EIkoFjC&9cf4riRH3O>68Uh>aeD%%Lrxbssi0PmO`<0GF8Yf0|E6wz9%>Y9o%|Q z8@{8#c@2N5hpzYI0XP+~Yk$XtprcREVZYx2JMo};p-h2JSU{?F|zs<+1Y;Bxw zRH2M9gmg;7Q#tm>cOYZVhKEZHRvZ}ib2-}WSz9gXi^tp>N);Gjf;#nNyUpr{C>@8q z<)qQ+*YpOfhiTFPh*e``n{}^r5arJezhHL|UuyMrW_d$i>rb>9%NYrjINf5CDfp0~ zbCReT-1VWe=PcVM7C5A*{-_sO-vBb-HlblwYY+o={k6|<^IqJvF-QSec1vBLx2+Ub zymzeZv%ExO#^#e94n+lQJej*g8?gS3V+W78BUeCj`(D3YmRF$p`fIcH3?SwWSK<=_Y-i$;LpcS15S2Pz)o?SR9su-5jtR; zLI0(?bUrVY#g(;8`&TkM*8Gm9hTz!BaL)nIl&>Rjr!aLmrd9c!t)bLnVNugCLD;a8 z5+Pfag^s5qtS}gZ#-_o;s`NM+y-`(Mxh<3iXqsMbzy>OY`x*OhUmZ0ZS_6{2XuxVM zJWV=A0PJGolgwH+*byx*dz(S(kS5G`&Hu%EBwEV=?!uDvEps&EpO>ILw;$8_dsld9 zv~E8Y*zq*hM(B`m6jHt*OC1DsGh^7M@Ag(6|8hzDVmj}ROKlzl(IR#ianty;HbD*4 zJpp#Xs}M~+FxDA>?8V66TIyXIlN!tL{>dL^oV}JA?UIb3*Mn|gF*<>(GrQk>c54}g8l_eN-`6yz^5d96>+ zvpfOaPHVJoQdRk)cnfV?_fAZ>Eui1N+J@%{G&9tLrUOLpP96YJerO zkHKmoXxXczaLyIo2Crqp2w@VWZ}kMG)KYyKNh5(UTTRpt*?P8B)6=$PBPg55&eGK@ zcvWD2`>g#{310MEx+3tQTcEW}dyHn_Tz{GYJLLJj#KVU3@L5^{Wl6ZaxGw_M;3NU} zWdQ_r3X#Y`y>@*Q4fU6sobW-P@E`H3DXbejV%bcSE_vzkypzXTH+v!O1w9xHo_OWr}q`2m+Q?Cl!rFyr203w9z*^hfe`38@`+t?ADP zpHQ3}V-DQki(}a0M|RF7X&Qos3&Y1rh}n6b6;R|;pSz<^%s9c@_9rj5ugU(?a2ix` ztVz+5{El!Ce$8?DX|^?xX)=a8k#B3G(V>LNXrg*FE=oI3TSbN;9&G=ZVdr z5JDfgq4~yIpLZby!XwzP!CP#}X}G;|SV#n%XW!)Uvbl`F&dLmT%Z&287 z;F@uU25=gXGu`1xP2^RWkP}$4zsH#tGy}!uOslK_ZXwM6m0^;+j|?z*-gFo555Iwq zP-~@5N1rYK=3Q<34WIO#nzl-ZD@D&HTC}en1nI_}(%FdgQ%l_ykTcMy`;KT8Uruo; zgbvzIHw_+3bDB($1HyE_5sJ2ugB17iV~EhN0Un^$?^sTLORSbP8~gA^|4w7!+Qr_i zo#$8hea0?wZa^mi%v8DZN|OiZ?ryS|B;+vj{v^b(lY*OpWThPK9l zBU{$&FVvQy!pxUt|M(v^C6aH^%q>@_{#Via;tnj6jbu#}Cr9vh0sx@X<+(KwJE|Iy zw9=^ZnQ%n6aZl0)TiaQ7aS(E8&_bjnRVcd=Rra$NAmX6fhtbnL+3zeN@#I&^1Hf<` z(^a9Wj|DOKEjY~MQNSdc+qi0u{Fg%XHPm^N6joCa`^@{I9pjCGy0H3bNAt}u{SdIa zM;lRq;N?kFq`JI#&-gu-d!>7Luy0oB_EZaS>A~GmKT)Q@UQ_FLA5tRYP$5@(MWvM* z0j>-W;jt?U+=my_EhPw|#V8mUQ?Q9e6?~BNU`X{PaQAwF#fGWY-_$4wJHU%;-=MrV z%R9E{lmMT5WKS(lsGxi6n!V#Oq3X|`LG2-$s!*#9kzv+Rc*Q7Ej(SOIbD|%E^#%~= z!XFz4Al8ZfIY!Y0f99vShm<6Bat1PZ3BNjplI#mv#dp<~u!aNgnF7?WlVQ-5_BF@} z$U0`>ctCMIUrd3=y`kg9y=KCC6*s&LMQ1P}s@0p06IsQQa)!ZHaFh?~psqJq_Bl~C zmXG67=q^@S(ANFslpgA1O`m&zu)?ibs)e<~WdL0&0@UWI2^t)l_nkS3gSipeacW!- z*uic*cOE}6t1=9-v?qUUQ_ta-vDx~9kThN#Zz+CUZ-1XttMh6~G-*%qGoq2rc3J&9 z)@>8A9H#9CAHubHYjbOppIV@hHavz5)$2wlP%SF#s z#TI>*8Ctr@9w%S!3RjxmXkoW8u|eGd<9#tErH2?g_Qo* zDCpy)nCuDLxf=lPW8DIUtfvJPc@!2@e?Q;_@RZ$Tk0rqWgQJPP9i6d(vjLr{5gow) z8tDH68w<;SMFut&`v10PU}pYr@&5*Zfr*}#g@uugm4OL>o`Hdlkr@D=<$qB={~az) z&IXS7_yDHH^#4ESOdOs5cO>+`m;cVcsSyVQhXIozGa~~#3%x0;sR1jifiWYaiJ_4x z2ZISS6O#!WhpDNFDI<%aiIFM0fr%-b5t|`{iID*Z11mez|KN`Q63k3b|3CE4#>Vvj z`e*#lnx2vAzXJaMw~+ry{2%ULI-eIjztc9#Jj+FHz5CMbrbs@LH5)Y7C74^MW3_@F zAo<5t)p=!RoJ07zB?igQ0(D}+ZBHZ|QcqbIwBT&Qsg>1ilE@y`St{(!?F1&LuY+bG zRViP#ppE0ZbaWJC*R5;!8Mk!6Th$7=zf1K@!Qq1nMvz~&uc_(OmlnizeUl*CeOh6Q zb{pZWd#B7%vy+Vdn}p@;yi*1ib?yh|HBYV%1{Dx#K5090)3ST@oAtSJso{JVYHa64 zN{}{IxF$k*7G8FJ@Fc=MF|`^7$!U$;oKYUE{r}n?)v8|W@;IiXoyT;P8YXt+XuM^= z%01ctn4DzL^Vb5sWktCyq6^}_0XxeRE^R%pm$6be!+Z7qDf?_SBc`THA7=V~KH{OT z^u^i#w^lT4s8qc(Q|+J8A>T8tym!kVzPRUop?B}jsJ(p^r6x;H7J2-AcgSbq@}CX@ zsm8|zrh3Rs$x7NERejse@ZLuC*vDJV9<(WJxx3Eop>kn@dB0y4i>}j*`E}VDy`qv= z5C16YuaWINw_%%xv6ripYerh|!Pozo^Y=e3{#($uAXYiz>s0Y6CjVq6Y&_MFp5t1V z+S7La_cU1rrnQWU-&hq}=RS-$aPV!Rfu^54wVXteyN2SCJsvcaH;e@Ojr zW^6VZ|MUPT@tW|3r`oRns6ZfId)Y+wqMG&iv{FiSH?GB-{! zPD@TTO|-BuvotU@NVP~YNH$0{#9;@4`rpvN$Os<)2B7?JWMpJ)Fk1i91LSRZVQqEW zY>H<^)UmP;dqk|8CLUFAZYh^haC37FIVawEP-K_Lp_i?V^8*w#p6(U?#jub!ti0vU zVy7a;m+PjT-Ew8szdO1rZsDgMSsnbZtTE-nto`io?yNBP{CU#(zOdUAeuIwttcQ%g zxPOWe_uCd(*PhM!vt@$RH{NY~SG~v*%vhgjbyQn?+Tt@JrD$}}He{$cp{v)TlcA56_Gy;{+s&)pS2$Qo?j=(l)( z@-h$KFTH#@o|YH8HvBAGXRmL$_o<&}_P+SNqvShN*TFlA%Y`O~eC{`7*uC51 zkbxQRH4%jaO$%z651))FyrACuZ$qAeVc4N#e?Rc;c2h9^FugSG-_>P?Q-93UmXx`Y zU-r50YEPA7beQ(L(x1~##8+2TE?j=a=VH0!UZ$&u@|06gH8g0kl=W&ed)TZJyZ2J; zuGyTu{O|ubG`;Rl6R0x^RXwX=E#0wSZ1E1)#!F{Yf>cZvx-Fe+aW3Knui9P5|C~Pz zlW&Aa-4;}o5P5eiI8SWP!O4n&DWY#f&(8YCTm1ZnbNG*AYcs=4>`%-Hw2uA3!+F45 z-#KsoFZP6!59V6E7P=mGwRf}kn(}G2!Bea3-Xu;5+1lCq$NESUPexeLKErK%nwI7Q zRr7K$E)xFtjWx0;_+aPTT&rg7^HD9cG7n$LBAj4G!6+C7qhJ(_f>AK!0RV4c5Uv2o F006r*kh=f? literal 0 HcmV?d00001 diff --git a/src/restic/backend/testdata/repo-layout-s3-old.tar.gz b/src/restic/backend/testdata/repo-layout-s3-old.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..2b7d852cc9ab4cf8da442461bf9b8a0ded688855 GIT binary patch literal 38096 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

yhwo;cMz${J@-o@KYqq( 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 A4FCWD literal 0 HcmV?d00001 From 50dfa64a5484b1089d4cbd749017d157386024af Mon Sep 17 00:00:00 2001 From: Alexander Neumann Date: Sun, 2 Apr 2017 17:57:28 +0200 Subject: [PATCH 09/47] Add layout name parser --- src/restic/backend/layout.go | 40 +++++++++++++++++- src/restic/backend/layout_test.go | 65 +++++++++++++++++++++++++++--- src/restic/backend/local/config.go | 3 +- src/restic/backend/local/local.go | 10 ++--- 4 files changed, 106 insertions(+), 12 deletions(-) diff --git a/src/restic/backend/layout.go b/src/restic/backend/layout.go index c8b795348..cff77e25a 100644 --- a/src/restic/backend/layout.go +++ b/src/restic/backend/layout.go @@ -108,8 +108,13 @@ func hasSubdirBackendFile(fs Filesystem, dir string) (bool, error) { } // DetectLayout tries to find out which layout is used in a local (or sftp) -// filesystem at the given path. +// filesystem at the given path. If repo is nil, an instance of LocalFilesystem +// is used. func DetectLayout(repo Filesystem, dir string) (Layout, error) { + if repo == nil { + repo = &LocalFilesystem{} + } + // key file in the "keys" dir (DefaultLayout or CloudLayout) foundKeysFile, err := hasBackendFile(repo, repo.Join(dir, defaultLayoutPaths[restic.KeyFile])) if err != nil { @@ -148,3 +153,36 @@ func DetectLayout(repo Filesystem, dir string) (Layout, error) { return nil, errors.New("auto-detecting the filesystem layout failed") } + +// ParseLayout parses the config string and returns a Layout. When layout is +// the empty string, DetectLayout is used. If repo is nil, an instance of LocalFilesystem +// is used. +func ParseLayout(repo Filesystem, layout, path string) (l Layout, err error) { + if repo == nil { + repo = &LocalFilesystem{} + } + + switch layout { + case "default": + l = &DefaultLayout{ + Path: path, + Join: repo.Join, + } + case "cloud": + l = &CloudLayout{ + Path: path, + Join: repo.Join, + } + case "s3": + l = &S3Layout{ + Path: path, + Join: repo.Join, + } + case "": + return DetectLayout(repo, path) + default: + return nil, errors.Errorf("unknown backend layout string %q, may be one of default/cloud/s3", layout) + } + + return l, nil +} diff --git a/src/restic/backend/layout_test.go b/src/restic/backend/layout_test.go index c08829ea4..3fe9dcf6e 100644 --- a/src/restic/backend/layout_test.go +++ b/src/restic/backend/layout_test.go @@ -229,10 +229,49 @@ func TestDetectLayout(t *testing.T) { var fs = &LocalFilesystem{} for _, test := range tests { - t.Run(test.filename, func(t *testing.T) { - SetupTarTestFixture(t, path, filepath.Join("testdata", test.filename)) + for _, fs := range []Filesystem{fs, nil} { + t.Run(fmt.Sprintf("%v/fs-%T", test.filename, fs), func(t *testing.T) { + SetupTarTestFixture(t, path, filepath.Join("testdata", test.filename)) - layout, err := DetectLayout(fs, filepath.Join(path, "repo")) + layout, err := DetectLayout(fs, filepath.Join(path, "repo")) + if err != nil { + t.Fatal(err) + } + + if layout == nil { + t.Fatal("wanted some layout, but detect returned nil") + } + + layoutName := fmt.Sprintf("%T", layout) + if layoutName != test.want { + t.Fatalf("want layout %v, got %v", test.want, layoutName) + } + + RemoveAll(t, filepath.Join(path, "repo")) + }) + } + } +} + +func TestParseLayout(t *testing.T) { + path, cleanup := TempDir(t) + defer cleanup() + + var tests = []struct { + layoutName string + want string + }{ + {"default", "*backend.DefaultLayout"}, + {"cloud", "*backend.CloudLayout"}, + {"s3", "*backend.S3Layout"}, + {"", "*backend.CloudLayout"}, + } + + SetupTarTestFixture(t, path, filepath.Join("testdata", "repo-layout-cloud.tar.gz")) + + for _, test := range tests { + t.Run(test.layoutName, func(t *testing.T) { + layout, err := ParseLayout(nil, test.layoutName, filepath.Join(path, "repo")) if err != nil { t.Fatal(err) } @@ -245,8 +284,24 @@ func TestDetectLayout(t *testing.T) { if layoutName != test.want { t.Fatalf("want layout %v, got %v", test.want, layoutName) } - - RemoveAll(t, filepath.Join(path, "repo")) + }) + } +} + +func TestParseLayoutInvalid(t *testing.T) { + path, cleanup := TempDir(t) + defer cleanup() + + var invalidNames = []string{ + "foo", "bar", "local", + } + + for _, name := range invalidNames { + t.Run(name, func(t *testing.T) { + layout, err := ParseLayout(nil, name, path) + if err == nil { + t.Fatalf("expected error not found for layout name %v, layout is %v", name, layout) + } }) } } diff --git a/src/restic/backend/local/config.go b/src/restic/backend/local/config.go index 746accd27..4acd57a13 100644 --- a/src/restic/backend/local/config.go +++ b/src/restic/backend/local/config.go @@ -8,7 +8,8 @@ import ( // Config holds all information needed to open a local repository. type Config struct { - Path string + Path string + Layout string } // ParseConfig parses a local backend config. diff --git a/src/restic/backend/local/local.go b/src/restic/backend/local/local.go index 4ddada847..1f536e7d9 100644 --- a/src/restic/backend/local/local.go +++ b/src/restic/backend/local/local.go @@ -24,13 +24,13 @@ var _ restic.Backend = &Local{} // Open opens the local backend as specified by config. func Open(cfg Config) (*Local, error) { - be := &Local{Config: cfg} - - be.Layout = &backend.DefaultLayout{ - Path: cfg.Path, - Join: filepath.Join, + l, err := backend.ParseLayout(nil, cfg.Layout, cfg.Path) + if err != nil { + return nil, err } + be := &Local{Config: cfg, Layout: l} + // test if all necessary dirs are there for _, d := range be.Paths() { if _, err := fs.Stat(d); err != nil { From f7c4b3a9221af15668c7ed36dfeeb39a775ec428 Mon Sep 17 00:00:00 2001 From: Alexander Neumann Date: Sun, 2 Apr 2017 19:18:03 +0200 Subject: [PATCH 10/47] Fix layout detection --- src/restic/backend/layout.go | 27 ++++++++++++++++++--------- src/restic/backend/layout_test.go | 7 ++++++- 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/src/restic/backend/layout.go b/src/restic/backend/layout.go index cff77e25a..a6087c502 100644 --- a/src/restic/backend/layout.go +++ b/src/restic/backend/layout.go @@ -6,6 +6,7 @@ import ( "path/filepath" "regexp" "restic" + "restic/debug" "restic/errors" "restic/fs" ) @@ -140,28 +141,36 @@ func DetectLayout(repo Filesystem, dir string) (Layout, error) { } if foundKeysFile && foundDataFile && !foundKeyFile && !foundDataSubdirFile { - return &CloudLayout{}, nil + debug.Log("found cloud layout at %v", dir) + return &CloudLayout{ + Path: dir, + Join: repo.Join, + }, nil } if foundKeysFile && foundDataSubdirFile && !foundKeyFile && !foundDataFile { - return &DefaultLayout{}, nil + debug.Log("found default layout at %v", dir) + return &DefaultLayout{ + Path: dir, + Join: repo.Join, + }, nil } if foundKeyFile && foundDataFile && !foundKeysFile && !foundDataSubdirFile { - return &S3Layout{}, nil + debug.Log("found s3 layout at %v", dir) + return &S3Layout{ + Path: dir, + Join: repo.Join, + }, nil } return nil, errors.New("auto-detecting the filesystem layout failed") } // ParseLayout parses the config string and returns a Layout. When layout is -// the empty string, DetectLayout is used. If repo is nil, an instance of LocalFilesystem -// is used. +// the empty string, DetectLayout is used. func ParseLayout(repo Filesystem, layout, path string) (l Layout, err error) { - if repo == nil { - repo = &LocalFilesystem{} - } - + debug.Log("parse layout string %q for backend at %v", layout, path) switch layout { case "default": l = &DefaultLayout{ diff --git a/src/restic/backend/layout_test.go b/src/restic/backend/layout_test.go index 3fe9dcf6e..e336f8178 100644 --- a/src/restic/backend/layout_test.go +++ b/src/restic/backend/layout_test.go @@ -271,7 +271,7 @@ func TestParseLayout(t *testing.T) { for _, test := range tests { t.Run(test.layoutName, func(t *testing.T) { - layout, err := ParseLayout(nil, test.layoutName, filepath.Join(path, "repo")) + layout, err := ParseLayout(&LocalFilesystem{}, test.layoutName, filepath.Join(path, "repo")) if err != nil { t.Fatal(err) } @@ -280,6 +280,11 @@ func TestParseLayout(t *testing.T) { t.Fatal("wanted some layout, but detect returned nil") } + // test that the functions work (and don't panic) + _ = layout.Dirname(restic.Handle{Type: restic.DataFile}) + _ = layout.Filename(restic.Handle{Type: restic.DataFile, Name: "1234"}) + _ = layout.Paths() + layoutName := fmt.Sprintf("%T", layout) if layoutName != test.want { t.Fatalf("want layout %v, got %v", test.want, layoutName) From 54465c92cc3eb74d94a2d1f8c82887bbffd64fbe Mon Sep 17 00:00:00 2001 From: Alexander Neumann Date: Sun, 2 Apr 2017 19:53:55 +0200 Subject: [PATCH 11/47] layout: Allow passing in a default layout --- src/restic/backend/layout.go | 17 +++++++++++++---- src/restic/backend/layout_test.go | 17 +++++++++-------- 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/src/restic/backend/layout.go b/src/restic/backend/layout.go index a6087c502..23685a470 100644 --- a/src/restic/backend/layout.go +++ b/src/restic/backend/layout.go @@ -108,6 +108,10 @@ func hasSubdirBackendFile(fs Filesystem, dir string) (bool, error) { return false, nil } +// ErrLayoutDetectionFailed is returned by DetectLayout() when the layout +// cannot be detected automatically. +var ErrLayoutDetectionFailed = errors.New("auto-detecting the filesystem layout failed") + // DetectLayout tries to find out which layout is used in a local (or sftp) // filesystem at the given path. If repo is nil, an instance of LocalFilesystem // is used. @@ -164,12 +168,12 @@ func DetectLayout(repo Filesystem, dir string) (Layout, error) { }, nil } - return nil, errors.New("auto-detecting the filesystem layout failed") + return nil, ErrLayoutDetectionFailed } // ParseLayout parses the config string and returns a Layout. When layout is -// the empty string, DetectLayout is used. -func ParseLayout(repo Filesystem, layout, path string) (l Layout, err error) { +// the empty string, DetectLayout is used. If that fails, defaultLayout is used. +func ParseLayout(repo Filesystem, layout, defaultLayout, path string) (l Layout, err error) { debug.Log("parse layout string %q for backend at %v", layout, path) switch layout { case "default": @@ -188,7 +192,12 @@ func ParseLayout(repo Filesystem, layout, path string) (l Layout, err error) { Join: repo.Join, } case "": - return DetectLayout(repo, path) + l, err = DetectLayout(repo, path) + + // use the default layout if auto detection failed + if errors.Cause(err) == ErrLayoutDetectionFailed && defaultLayout != "" { + return ParseLayout(repo, defaultLayout, "", path) + } default: return nil, errors.Errorf("unknown backend layout string %q, may be one of default/cloud/s3", layout) } diff --git a/src/restic/backend/layout_test.go b/src/restic/backend/layout_test.go index e336f8178..dc1c231b9 100644 --- a/src/restic/backend/layout_test.go +++ b/src/restic/backend/layout_test.go @@ -258,20 +258,21 @@ func TestParseLayout(t *testing.T) { defer cleanup() var tests = []struct { - layoutName string - want string + layoutName string + defaultLayoutName string + want string }{ - {"default", "*backend.DefaultLayout"}, - {"cloud", "*backend.CloudLayout"}, - {"s3", "*backend.S3Layout"}, - {"", "*backend.CloudLayout"}, + {"default", "", "*backend.DefaultLayout"}, + {"cloud", "", "*backend.CloudLayout"}, + {"s3", "", "*backend.S3Layout"}, + {"", "", "*backend.CloudLayout"}, } SetupTarTestFixture(t, path, filepath.Join("testdata", "repo-layout-cloud.tar.gz")) for _, test := range tests { t.Run(test.layoutName, func(t *testing.T) { - layout, err := ParseLayout(&LocalFilesystem{}, test.layoutName, filepath.Join(path, "repo")) + layout, err := ParseLayout(&LocalFilesystem{}, test.layoutName, test.defaultLayoutName, filepath.Join(path, "repo")) if err != nil { t.Fatal(err) } @@ -303,7 +304,7 @@ func TestParseLayoutInvalid(t *testing.T) { for _, name := range invalidNames { t.Run(name, func(t *testing.T) { - layout, err := ParseLayout(nil, name, path) + layout, err := ParseLayout(nil, name, "", path) if err == nil { t.Fatalf("expected error not found for layout name %v, layout is %v", name, layout) } From 24ebf95f3373a2a977e5fc9cfcbcab8749067d30 Mon Sep 17 00:00:00 2001 From: Alexander Neumann Date: Sun, 2 Apr 2017 19:54:11 +0200 Subject: [PATCH 12/47] local: Automatically detect layout --- src/restic/backend/local/local.go | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/restic/backend/local/local.go b/src/restic/backend/local/local.go index 1f536e7d9..eace14ee0 100644 --- a/src/restic/backend/local/local.go +++ b/src/restic/backend/local/local.go @@ -22,9 +22,12 @@ type Local struct { // ensure statically that *Local implements restic.Backend. var _ restic.Backend = &Local{} +const defaultLayout = "default" + // Open opens the local backend as specified by config. func Open(cfg Config) (*Local, error) { - l, err := backend.ParseLayout(nil, cfg.Layout, cfg.Path) + debug.Log("open local backend at %v (layout %q)", cfg.Path, cfg.Layout) + l, err := backend.ParseLayout(&backend.LocalFilesystem{}, cfg.Layout, defaultLayout, cfg.Path) if err != nil { return nil, err } @@ -44,16 +47,20 @@ func Open(cfg Config) (*Local, error) { // Create creates all the necessary files and directories for a new local // backend at dir. Afterwards a new config blob should be created. func Create(cfg Config) (*Local, error) { + debug.Log("create local backend at %v (layout %q)", cfg.Path, cfg.Layout) + + l, err := backend.ParseLayout(&backend.LocalFilesystem{}, cfg.Layout, defaultLayout, cfg.Path) + if err != nil { + return nil, err + } + be := &Local{ Config: cfg, - Layout: &backend.DefaultLayout{ - Path: cfg.Path, - Join: filepath.Join, - }, + Layout: l, } // test if config file already exists - _, err := fs.Lstat(be.Filename(restic.Handle{Type: restic.ConfigFile})) + _, err = fs.Lstat(be.Filename(restic.Handle{Type: restic.ConfigFile})) if err == nil { return nil, errors.New("config file already exists") } From e3e3a8a6955651d034ffd528ffb41c942b8db46b Mon Sep 17 00:00:00 2001 From: Alexander Neumann Date: Sun, 2 Apr 2017 19:56:33 +0200 Subject: [PATCH 13/47] local: Add layout tests --- src/restic/backend/local/local_layout_test.go | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 src/restic/backend/local/local_layout_test.go diff --git a/src/restic/backend/local/local_layout_test.go b/src/restic/backend/local/local_layout_test.go new file mode 100644 index 000000000..278eac4ba --- /dev/null +++ b/src/restic/backend/local/local_layout_test.go @@ -0,0 +1,43 @@ +package local + +import ( + "path/filepath" + . "restic/test" + "testing" +) + +func TestLocalLayout(t *testing.T) { + path, cleanup := TempDir(t) + defer cleanup() + + var tests = []struct { + filename string + layout string + failureExpected bool + }{ + {"repo-layout-local.tar.gz", "", false}, + {"repo-layout-cloud.tar.gz", "", false}, + {"repo-layout-s3-old.tar.gz", "", false}, + } + + for _, test := range tests { + t.Run(test.filename, func(t *testing.T) { + SetupTarTestFixture(t, path, filepath.Join("..", "testdata", test.filename)) + + repo := filepath.Join(path, "repo") + be, err := Open(Config{ + Path: repo, + Layout: test.layout, + }) + if err != nil { + t.Fatal(err) + } + + if be == nil { + t.Fatalf("Open() returned nil but no error") + } + + RemoveAll(t, filepath.Join(path, "repo")) + }) + } +} From c5eb36fe9d9a96413afa47fdae614e274cccaa1a Mon Sep 17 00:00:00 2001 From: Alexander Neumann Date: Sun, 2 Apr 2017 20:28:42 +0200 Subject: [PATCH 14/47] layout: improve error message for ParseLayout --- src/restic/backend/layout.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/restic/backend/layout.go b/src/restic/backend/layout.go index 23685a470..eb51705f5 100644 --- a/src/restic/backend/layout.go +++ b/src/restic/backend/layout.go @@ -199,7 +199,7 @@ func ParseLayout(repo Filesystem, layout, defaultLayout, path string) (l Layout, return ParseLayout(repo, defaultLayout, "", path) } default: - return nil, errors.Errorf("unknown backend layout string %q, may be one of default/cloud/s3", layout) + return nil, errors.Errorf("unknown backend layout string %q, may be one of: default, cloud, s3", layout) } return l, nil From 95ab5adda181e5a7c5467138295f2f9bba5c67bd Mon Sep 17 00:00:00 2001 From: Alexander Neumann Date: Sun, 2 Apr 2017 20:29:00 +0200 Subject: [PATCH 15/47] local: Expose layout as extended option --- src/restic/backend/local/config.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/restic/backend/local/config.go b/src/restic/backend/local/config.go index 4acd57a13..16fdc3752 100644 --- a/src/restic/backend/local/config.go +++ b/src/restic/backend/local/config.go @@ -9,7 +9,7 @@ import ( // Config holds all information needed to open a local repository. type Config struct { Path string - Layout string + Layout string `option:"layout"` } // ParseConfig parses a local backend config. From d1efdcd78eb341553db728b55886abb42364d376 Mon Sep 17 00:00:00 2001 From: Alexander Neumann Date: Sun, 2 Apr 2017 20:33:24 +0200 Subject: [PATCH 16/47] Add integration test for layouts --- src/cmds/restic/integration_helpers_test.go | 2 + src/cmds/restic/local_layout_test.go | 41 +++++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 src/cmds/restic/local_layout_test.go diff --git a/src/cmds/restic/integration_helpers_test.go b/src/cmds/restic/integration_helpers_test.go index ad6acc8a1..8c58028f6 100644 --- a/src/cmds/restic/integration_helpers_test.go +++ b/src/cmds/restic/integration_helpers_test.go @@ -9,6 +9,7 @@ import ( "runtime" "testing" + "restic/options" "restic/repository" . "restic/test" ) @@ -199,6 +200,7 @@ func withTestEnvironment(t testing.TB, f func(*testEnvironment, GlobalOptions)) password: TestPassword, stdout: os.Stdout, stderr: os.Stderr, + extended: make(options.Options), } // always overwrite global options diff --git a/src/cmds/restic/local_layout_test.go b/src/cmds/restic/local_layout_test.go new file mode 100644 index 000000000..eb6268e72 --- /dev/null +++ b/src/cmds/restic/local_layout_test.go @@ -0,0 +1,41 @@ +package main + +import ( + "path/filepath" + . "restic/test" + "testing" +) + +func TestRestoreLocalLayout(t *testing.T) { + withTestEnvironment(t, func(env *testEnvironment, gopts GlobalOptions) { + var tests = []struct { + filename string + layout string + }{ + {"repo-layout-cloud.tar.gz", ""}, + {"repo-layout-local.tar.gz", ""}, + {"repo-layout-s3-old.tar.gz", ""}, + {"repo-layout-cloud.tar.gz", "cloud"}, + {"repo-layout-local.tar.gz", "default"}, + {"repo-layout-s3-old.tar.gz", "s3"}, + } + + for _, test := range tests { + datafile := filepath.Join("..", "..", "restic", "backend", "testdata", test.filename) + + SetupTarTestFixture(t, env.base, datafile) + + gopts.extended["local.layout"] = test.layout + + // check the repo + testRunCheck(t, gopts) + + // restore latest snapshot + target := filepath.Join(env.base, "restore") + testRunRestoreLatest(t, gopts, target, nil, "") + + RemoveAll(t, filepath.Join(env.base, "repo")) + RemoveAll(t, target) + } + }) +} From d3b6f7584885bece46c611cf2b4c669e4d1c4319 Mon Sep 17 00:00:00 2001 From: Alexander Neumann Date: Mon, 3 Apr 2017 08:57:33 +0200 Subject: [PATCH 17/47] sftp: Add SplitShellArgs --- src/restic/backend/sftp/split.go | 73 ++++++++++++++++++ src/restic/backend/sftp/split_test.go | 105 ++++++++++++++++++++++++++ 2 files changed, 178 insertions(+) create mode 100644 src/restic/backend/sftp/split.go create mode 100644 src/restic/backend/sftp/split_test.go diff --git a/src/restic/backend/sftp/split.go b/src/restic/backend/sftp/split.go new file mode 100644 index 000000000..9be4a5464 --- /dev/null +++ b/src/restic/backend/sftp/split.go @@ -0,0 +1,73 @@ +package sftp + +import ( + "errors" + "unicode" +) + +const data = `"foo" "bar" baz "test argument" another 'test arg' "last \" argument" 'another \" last argument'` + +// shellSplitter splits a command string into separater arguments. It supports +// single and double quoted strings. +type shellSplitter struct { + quote rune + lastChar rune +} + +func (s *shellSplitter) isSplitChar(c rune) bool { + // only test for quotes if the last char was not a backslash + if s.lastChar != '\\' { + + // quote ended + if s.quote != 0 && c == s.quote { + s.quote = 0 + return true + } + + // quote starts + if s.quote == 0 && (c == '"' || c == '\'') { + s.quote = c + return true + } + } + + s.lastChar = c + + // within quote + if s.quote != 0 { + return false + } + + // outside quote + return c == '\\' || unicode.IsSpace(c) +} + +// SplitShellArgs returns the list of arguments from a shell command string. +func SplitShellArgs(data string) (list []string, err error) { + s := &shellSplitter{} + + // derived from strings.SplitFunc + fieldStart := -1 // Set to -1 when looking for start of field. + for i, rune := range data { + if s.isSplitChar(rune) { + if fieldStart >= 0 { + list = append(list, data[fieldStart:i]) + fieldStart = -1 + } + } else if fieldStart == -1 { + fieldStart = i + } + } + if fieldStart >= 0 { // Last field might end at EOF. + list = append(list, data[fieldStart:]) + } + + switch s.quote { + case '\'': + return nil, errors.New("single-quoted string not terminated") + case '"': + return nil, errors.New("double-quoted string not terminated") + } + + return list, nil +} diff --git a/src/restic/backend/sftp/split_test.go b/src/restic/backend/sftp/split_test.go new file mode 100644 index 000000000..f2f3cd5f5 --- /dev/null +++ b/src/restic/backend/sftp/split_test.go @@ -0,0 +1,105 @@ +package sftp + +import ( + "reflect" + "testing" +) + +func TestShellSplitter(t *testing.T) { + var tests = []struct { + data string + want []string + }{ + { + `foo`, + []string{"foo"}, + }, + { + `'foo'`, + []string{"foo"}, + }, + { + `foo bar baz`, + []string{"foo", "bar", "baz"}, + }, + { + `foo 'bar' baz`, + []string{"foo", "bar", "baz"}, + }, + { + `foo 'bar box' baz`, + []string{"foo", "bar box", "baz"}, + }, + { + `"bar 'box'" baz`, + []string{"bar 'box'", "baz"}, + }, + { + `'bar "box"' baz`, + []string{`bar "box"`, "baz"}, + }, + { + `\"bar box baz`, + []string{`"bar`, "box", "baz"}, + }, + { + `"bar/foo/x" "box baz"`, + []string{"bar/foo/x", "box baz"}, + }, + } + + for _, test := range tests { + t.Run("", func(t *testing.T) { + res, err := SplitShellArgs(test.data) + if err != nil { + t.Fatal(err) + } + + if !reflect.DeepEqual(res, test.want) { + t.Fatalf("wrong data returned, want:\n %#v\ngot:\n %#v", + test.want, res) + } + }) + } +} + +func TestShellSplitterInvalid(t *testing.T) { + var tests = []struct { + data string + err string + }{ + { + "foo'", + "single-quoted string not terminated", + }, + { + `foo"`, + "double-quoted string not terminated", + }, + { + "foo 'bar", + "single-quoted string not terminated", + }, + { + `foo "bar`, + "double-quoted string not terminated", + }, + } + + for _, test := range tests { + t.Run("", func(t *testing.T) { + res, err := SplitShellArgs(test.data) + if err == nil { + t.Fatalf("expected error not found: %v", test.err) + } + + if err.Error() != test.err { + t.Fatalf("expected error not found, want:\n %q\ngot:\n %q", test.err, err.Error()) + } + + if len(res) > 0 { + t.Fatalf("splitter returned fields from invalid data: %v", res) + } + }) + } +} From c26dd6b76f5bae8556ddce589a8b2e92e6d17c42 Mon Sep 17 00:00:00 2001 From: Alexander Neumann Date: Mon, 3 Apr 2017 21:05:42 +0200 Subject: [PATCH 18/47] sftp: Integrate command --- src/restic/backend/sftp/config.go | 1 + src/restic/backend/sftp/sftp.go | 41 ++++++++++++----- src/restic/backend/sftp/sftp_backend_test.go | 14 ++++-- src/restic/backend/sftp/split.go | 18 +++++--- src/restic/backend/sftp/split_test.go | 46 ++++++++++++-------- 5 files changed, 83 insertions(+), 37 deletions(-) diff --git a/src/restic/backend/sftp/config.go b/src/restic/backend/sftp/config.go index c5b65e639..3d029a0dd 100644 --- a/src/restic/backend/sftp/config.go +++ b/src/restic/backend/sftp/config.go @@ -11,6 +11,7 @@ import ( // Config collects all information required to connect to an sftp server. type Config struct { User, Host, Dir string + Command string `option:"command"` } // ParseConfig parses the string s and extracts the sftp config. The diff --git a/src/restic/backend/sftp/sftp.go b/src/restic/backend/sftp/sftp.go index 8894cdc85..dbeaf537f 100644 --- a/src/restic/backend/sftp/sftp.go +++ b/src/restic/backend/sftp/sftp.go @@ -37,6 +37,7 @@ type SFTP struct { var _ restic.Backend = &SFTP{} func startClient(program string, args ...string) (*SFTP, error) { + debug.Log("start client %v %v", program, args) // Connect to a remote host and request the sftp subsystem via the 'ssh' // command. This assumes that passwordless login is correctly configured. cmd := exec.Command(program, args...) @@ -114,11 +115,11 @@ func (r *SFTP) clientError() error { return nil } -// Open opens an sftp backend. When the command is started via +// open opens an sftp backend. When the command is started via // exec.Command, it is expected to speak sftp on stdin/stdout. The backend // is expected at the given path. `dir` must be delimited by forward slashes // ("/"), which is required by sftp. -func Open(dir string, program string, args ...string) (*SFTP, error) { +func open(dir string, program string, args ...string) (*SFTP, error) { debug.Log("open backend with program %v, %v at %v", program, args, dir) sftp, err := startClient(program, args...) if err != nil { @@ -155,15 +156,25 @@ func buildSSHCommand(cfg Config) []string { // OpenWithConfig opens an sftp backend as described by the config by running // "ssh" with the appropriate arguments. func OpenWithConfig(cfg Config) (*SFTP, error) { - debug.Log("open with config %v", cfg) - return Open(cfg.Dir, "ssh", buildSSHCommand(cfg)...) + debug.Log("config %#v", cfg) + + if cfg.Command == "" { + return open(cfg.Dir, "ssh", buildSSHCommand(cfg)...) + } + + cmd, args, err := SplitShellArgs(cfg.Command) + if err != nil { + return nil, err + } + + return open(cfg.Dir, cmd, args...) } -// Create creates all the necessary files and directories for a new sftp +// create creates all the necessary files and directories for a new sftp // backend at dir. Afterwards a new config blob should be created. `dir` must // be delimited by forward slashes ("/"), which is required by sftp. -func Create(dir string, program string, args ...string) (*SFTP, error) { - debug.Log("%v %v", program, args) +func create(dir string, program string, args ...string) (*SFTP, error) { + debug.Log("create() %v %v", program, args) sftp, err := startClient(program, args...) if err != nil { return nil, err @@ -178,6 +189,7 @@ func Create(dir string, program string, args ...string) (*SFTP, error) { // create paths for data, refs and temp blobs for _, d := range paths(dir) { err = sftp.mkdirAll(d, backend.Modes.Dir) + debug.Log("mkdirAll %v -> %v", d, err) if err != nil { return nil, err } @@ -189,14 +201,23 @@ func Create(dir string, program string, args ...string) (*SFTP, error) { } // open backend - return Open(dir, program, args...) + return open(dir, program, args...) } // CreateWithConfig creates an sftp backend as described by the config by running // "ssh" with the appropriate arguments. func CreateWithConfig(cfg Config) (*SFTP, error) { - debug.Log("config %v", cfg) - return Create(cfg.Dir, "ssh", buildSSHCommand(cfg)...) + debug.Log("config %#v", cfg) + if cfg.Command == "" { + return create(cfg.Dir, "ssh", buildSSHCommand(cfg)...) + } + + cmd, args, err := SplitShellArgs(cfg.Command) + if err != nil { + return nil, err + } + + return create(cfg.Dir, cmd, args...) } // Location returns this backend's location (the directory name). diff --git a/src/restic/backend/sftp/sftp_backend_test.go b/src/restic/backend/sftp/sftp_backend_test.go index 567b2cf94..4a12438fe 100644 --- a/src/restic/backend/sftp/sftp_backend_test.go +++ b/src/restic/backend/sftp/sftp_backend_test.go @@ -1,6 +1,7 @@ package sftp_test import ( + "fmt" "io/ioutil" "os" "path/filepath" @@ -50,7 +51,9 @@ func init() { return } - args := []string{"-e"} + cfg := sftp.Config{ + Command: fmt.Sprintf("%q -e", sftpserver), + } test.CreateFn = func() (restic.Backend, error) { err := createTempdir() @@ -58,7 +61,9 @@ func init() { return nil, err } - return sftp.Create(tempBackendDir, sftpserver, args...) + cfg.Dir = tempBackendDir + + return sftp.CreateWithConfig(cfg) } test.OpenFn = func() (restic.Backend, error) { @@ -66,7 +71,10 @@ func init() { if err != nil { return nil, err } - return sftp.Open(tempBackendDir, sftpserver, args...) + + cfg.Dir = tempBackendDir + + return sftp.OpenWithConfig(cfg) } test.CleanupFn = func() error { diff --git a/src/restic/backend/sftp/split.go b/src/restic/backend/sftp/split.go index 9be4a5464..b01fb3a93 100644 --- a/src/restic/backend/sftp/split.go +++ b/src/restic/backend/sftp/split.go @@ -43,7 +43,7 @@ func (s *shellSplitter) isSplitChar(c rune) bool { } // SplitShellArgs returns the list of arguments from a shell command string. -func SplitShellArgs(data string) (list []string, err error) { +func SplitShellArgs(data string) (cmd string, args []string, err error) { s := &shellSplitter{} // derived from strings.SplitFunc @@ -51,7 +51,7 @@ func SplitShellArgs(data string) (list []string, err error) { for i, rune := range data { if s.isSplitChar(rune) { if fieldStart >= 0 { - list = append(list, data[fieldStart:i]) + args = append(args, data[fieldStart:i]) fieldStart = -1 } } else if fieldStart == -1 { @@ -59,15 +59,21 @@ func SplitShellArgs(data string) (list []string, err error) { } } if fieldStart >= 0 { // Last field might end at EOF. - list = append(list, data[fieldStart:]) + args = append(args, data[fieldStart:]) } switch s.quote { case '\'': - return nil, errors.New("single-quoted string not terminated") + return "", nil, errors.New("single-quoted string not terminated") case '"': - return nil, errors.New("double-quoted string not terminated") + return "", nil, errors.New("double-quoted string not terminated") } - return list, nil + if len(args) == 0 { + return "", nil, errors.New("command string is empty") + } + + cmd, args = args[0], args[1:] + + return cmd, args, nil } diff --git a/src/restic/backend/sftp/split_test.go b/src/restic/backend/sftp/split_test.go index f2f3cd5f5..06241b29a 100644 --- a/src/restic/backend/sftp/split_test.go +++ b/src/restic/backend/sftp/split_test.go @@ -8,56 +8,62 @@ import ( func TestShellSplitter(t *testing.T) { var tests = []struct { data string - want []string + cmd string + args []string }{ { `foo`, - []string{"foo"}, + "foo", []string{}, }, { `'foo'`, - []string{"foo"}, + "foo", []string{}, }, { `foo bar baz`, - []string{"foo", "bar", "baz"}, + "foo", []string{"bar", "baz"}, }, { `foo 'bar' baz`, - []string{"foo", "bar", "baz"}, + "foo", []string{"bar", "baz"}, }, { - `foo 'bar box' baz`, - []string{"foo", "bar box", "baz"}, + `'bar box' baz`, + "bar box", []string{"baz"}, }, { `"bar 'box'" baz`, - []string{"bar 'box'", "baz"}, + "bar 'box'", []string{"baz"}, }, { `'bar "box"' baz`, - []string{`bar "box"`, "baz"}, + `bar "box"`, []string{"baz"}, }, { `\"bar box baz`, - []string{`"bar`, "box", "baz"}, + `"bar`, []string{"box", "baz"}, }, { `"bar/foo/x" "box baz"`, - []string{"bar/foo/x", "box baz"}, + "bar/foo/x", []string{"box baz"}, }, } for _, test := range tests { t.Run("", func(t *testing.T) { - res, err := SplitShellArgs(test.data) + cmd, args, err := SplitShellArgs(test.data) if err != nil { t.Fatal(err) } - if !reflect.DeepEqual(res, test.want) { - t.Fatalf("wrong data returned, want:\n %#v\ngot:\n %#v", - test.want, res) + if cmd != test.cmd { + t.Fatalf("wrong cmd returned, want:\n %#v\ngot:\n %#v", + test.cmd, cmd) + } + + if !reflect.DeepEqual(args, test.args) { + t.Fatalf("wrong args returned, want:\n %#v\ngot:\n %#v", + test.args, args) } }) } @@ -88,7 +94,7 @@ func TestShellSplitterInvalid(t *testing.T) { for _, test := range tests { t.Run("", func(t *testing.T) { - res, err := SplitShellArgs(test.data) + cmd, args, err := SplitShellArgs(test.data) if err == nil { t.Fatalf("expected error not found: %v", test.err) } @@ -97,8 +103,12 @@ func TestShellSplitterInvalid(t *testing.T) { t.Fatalf("expected error not found, want:\n %q\ngot:\n %q", test.err, err.Error()) } - if len(res) > 0 { - t.Fatalf("splitter returned fields from invalid data: %v", res) + if cmd != "" { + t.Fatalf("splitter returned cmd from invalid data: %v", cmd) + } + + if len(args) > 0 { + t.Fatalf("splitter returned fields from invalid data: %v", args) } }) } From 1086528ab7e7809239d671aaa87ca6c712041395 Mon Sep 17 00:00:00 2001 From: Alexander Neumann Date: Mon, 3 Apr 2017 21:42:41 +0200 Subject: [PATCH 19/47] sftp: Fix errors import --- src/restic/backend/sftp/split.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/restic/backend/sftp/split.go b/src/restic/backend/sftp/split.go index b01fb3a93..9f5174a82 100644 --- a/src/restic/backend/sftp/split.go +++ b/src/restic/backend/sftp/split.go @@ -1,7 +1,7 @@ package sftp import ( - "errors" + "restic/errors" "unicode" ) From 2e53af1b7596bf60a9b1775018fc1a1463579687 Mon Sep 17 00:00:00 2001 From: Alexander Neumann Date: Mon, 10 Apr 2017 22:39:13 +0200 Subject: [PATCH 20/47] sftp: Rename Open/Create --- src/cmds/restic/global.go | 4 ++-- src/restic/backend/sftp/sftp.go | 12 ++++++------ src/restic/backend/sftp/sftp_backend_test.go | 4 ++-- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/cmds/restic/global.go b/src/cmds/restic/global.go index 45b41cf33..b88a25ec8 100644 --- a/src/cmds/restic/global.go +++ b/src/cmds/restic/global.go @@ -388,7 +388,7 @@ func open(s string, opts options.Options) (restic.Backend, error) { case "local": be, err = local.Open(cfg.(local.Config)) case "sftp": - be, err = sftp.OpenWithConfig(cfg.(sftp.Config)) + be, err = sftp.Open(cfg.(sftp.Config)) case "s3": be, err = s3.Open(cfg.(s3.Config)) case "rest": @@ -422,7 +422,7 @@ func create(s string, opts options.Options) (restic.Backend, error) { case "local": return local.Create(cfg.(local.Config)) case "sftp": - return sftp.CreateWithConfig(cfg.(sftp.Config)) + return sftp.Create(cfg.(sftp.Config)) case "s3": return s3.Open(cfg.(s3.Config)) case "rest": diff --git a/src/restic/backend/sftp/sftp.go b/src/restic/backend/sftp/sftp.go index dbeaf537f..c1b54ba1a 100644 --- a/src/restic/backend/sftp/sftp.go +++ b/src/restic/backend/sftp/sftp.go @@ -153,9 +153,9 @@ func buildSSHCommand(cfg Config) []string { return args } -// OpenWithConfig opens an sftp backend as described by the config by running +// Open opens an sftp backend as described by the config by running // "ssh" with the appropriate arguments. -func OpenWithConfig(cfg Config) (*SFTP, error) { +func Open(cfg Config) (*SFTP, error) { debug.Log("config %#v", cfg) if cfg.Command == "" { @@ -171,8 +171,8 @@ func OpenWithConfig(cfg Config) (*SFTP, error) { } // create creates all the necessary files and directories for a new sftp -// backend at dir. Afterwards a new config blob should be created. `dir` must -// be delimited by forward slashes ("/"), which is required by sftp. +// backend at dir. `dir` must be delimited by forward slashes ("/"), which is +// required by sftp. func create(dir string, program string, args ...string) (*SFTP, error) { debug.Log("create() %v %v", program, args) sftp, err := startClient(program, args...) @@ -204,9 +204,9 @@ func create(dir string, program string, args ...string) (*SFTP, error) { return open(dir, program, args...) } -// CreateWithConfig creates an sftp backend as described by the config by running +// Create creates an sftp backend as described by the config by running // "ssh" with the appropriate arguments. -func CreateWithConfig(cfg Config) (*SFTP, error) { +func Create(cfg Config) (*SFTP, error) { debug.Log("config %#v", cfg) if cfg.Command == "" { return create(cfg.Dir, "ssh", buildSSHCommand(cfg)...) diff --git a/src/restic/backend/sftp/sftp_backend_test.go b/src/restic/backend/sftp/sftp_backend_test.go index 4a12438fe..90b96d769 100644 --- a/src/restic/backend/sftp/sftp_backend_test.go +++ b/src/restic/backend/sftp/sftp_backend_test.go @@ -63,7 +63,7 @@ func init() { cfg.Dir = tempBackendDir - return sftp.CreateWithConfig(cfg) + return sftp.Create(cfg) } test.OpenFn = func() (restic.Backend, error) { @@ -74,7 +74,7 @@ func init() { cfg.Dir = tempBackendDir - return sftp.OpenWithConfig(cfg) + return sftp.Open(cfg) } test.CleanupFn = func() error { From ab602c9d145bbb2d64e2b08806aeb61be0feb24f Mon Sep 17 00:00:00 2001 From: Alexander Neumann Date: Mon, 10 Apr 2017 22:40:24 +0200 Subject: [PATCH 21/47] sftp: Add Layout --- src/restic/backend/sftp/config.go | 1 + src/restic/backend/sftp/sftp.go | 64 +++++++++++++++++++------------ 2 files changed, 40 insertions(+), 25 deletions(-) diff --git a/src/restic/backend/sftp/config.go b/src/restic/backend/sftp/config.go index 3d029a0dd..9a1ac35de 100644 --- a/src/restic/backend/sftp/config.go +++ b/src/restic/backend/sftp/config.go @@ -11,6 +11,7 @@ import ( // Config collects all information required to connect to an sftp server. type Config struct { User, Host, Dir string + Layout string `option:"layout"` Command string `option:"command"` } diff --git a/src/restic/backend/sftp/sftp.go b/src/restic/backend/sftp/sftp.go index c1b54ba1a..b0b796df6 100644 --- a/src/restic/backend/sftp/sftp.go +++ b/src/restic/backend/sftp/sftp.go @@ -2,8 +2,6 @@ package sftp import ( "bufio" - "crypto/rand" - "encoding/hex" "fmt" "io" "os" @@ -32,10 +30,14 @@ type SFTP struct { cmd *exec.Cmd result <-chan error + + backend.Layout } var _ restic.Backend = &SFTP{} +const defaultLayout = "default" + func startClient(program string, args ...string) (*SFTP, error) { debug.Log("start client %v %v", program, args) // Connect to a remote host and request the sftp subsystem via the 'ssh' @@ -127,6 +129,11 @@ func open(dir string, program string, args ...string) (*SFTP, error) { return nil, err } + l, err := backend.ParseLayout(sftp, cfg.Layout, defaultLayout, cfg.Path) + if err != nil { + return nil, err + } + // test if all necessary dirs and files are there for _, d := range paths(dir) { if _, err := sftp.c.Lstat(d); err != nil { @@ -225,28 +232,6 @@ func (r *SFTP) Location() string { return r.p } -// Return temp directory in correct directory for this backend. -func (r *SFTP) tempFile() (string, *sftp.File, error) { - // choose random suffix - buf := make([]byte, tempfileRandomSuffixLength) - _, err := io.ReadFull(rand.Reader, buf) - if err != nil { - return "", nil, errors.Errorf("unable to read %d random bytes for tempfile name: %v", - tempfileRandomSuffixLength, err) - } - - // construct tempfile name - name := Join(r.p, backend.Paths.Temp, "temp-"+hex.EncodeToString(buf)) - - // create file in temp dir - f, err := r.c.Create(name) - if err != nil { - return "", nil, errors.Errorf("creating tempfile %q failed: %v", name, err) - } - - return name, f, nil -} - func (r *SFTP) mkdirAll(dir string, mode os.FileMode) error { // check if directory already exists fi, err := r.c.Lstat(dir) @@ -349,7 +334,7 @@ func (r *SFTP) dirname(h restic.Handle) string { // Save stores data in the backend at the handle. func (r *SFTP) Save(h restic.Handle, rd io.Reader) (err error) { - debug.Log("save to %v", h) + debug.Log("Save %v", h) if err := r.clientError(); err != nil { return err } @@ -358,6 +343,35 @@ func (r *SFTP) Save(h restic.Handle, rd io.Reader) (err error) { return err } + filename := r.Filename(h) + + // create directories if necessary + if h.Type == restic.DataFile { + err := r.mkdirAll(path.Dir(filename), backend.Modes.Dir) + if err != nil { + return err + } + } + + // test if new file exists + if _, err := r.c.Lstat(filename); err == nil { + return errors.Errorf("Close(): file %v already exists", filename) + } + + err := r.c.Rename(oldname, filename) + if err != nil { + return errors.Wrap(err, "Rename") + } + + // set mode to read-only + fi, err := r.c.Lstat(filename) + if err != nil { + return errors.Wrap(err, "Lstat") + } + + err = r.c.Chmod(filename, fi.Mode()&os.FileMode(^uint32(0222))) + return errors.Wrap(err, "Chmod") + filename, tmpfile, err := r.tempFile() if err != nil { return err From ae290ab37458515fd7c8da9f5f826aa8d0e0e835 Mon Sep 17 00:00:00 2001 From: Alexander Neumann Date: Mon, 10 Apr 2017 22:41:06 +0200 Subject: [PATCH 22/47] sftp: Rename Dir -> Path --- src/restic/backend/location/location_test.go | 8 +++---- src/restic/backend/sftp/config.go | 8 +++---- src/restic/backend/sftp/config_test.go | 24 ++++++++++---------- src/restic/backend/sftp/sftp.go | 1 + 4 files changed, 21 insertions(+), 20 deletions(-) diff --git a/src/restic/backend/location/location_test.go b/src/restic/backend/location/location_test.go index fe07ee506..47260669a 100644 --- a/src/restic/backend/location/location_test.go +++ b/src/restic/backend/location/location_test.go @@ -79,7 +79,7 @@ var parseTests = []struct { Config: sftp.Config{ User: "user", Host: "host", - Dir: "/srv/repo", + Path: "/srv/repo", }, }, }, @@ -89,7 +89,7 @@ var parseTests = []struct { Config: sftp.Config{ User: "", Host: "host", - Dir: "/srv/repo", + Path: "/srv/repo", }, }, }, @@ -99,7 +99,7 @@ var parseTests = []struct { Config: sftp.Config{ User: "user", Host: "host", - Dir: "srv/repo", + Path: "srv/repo", }, }, }, @@ -109,7 +109,7 @@ var parseTests = []struct { Config: sftp.Config{ User: "user", Host: "host", - Dir: "/srv/repo", + Path: "/srv/repo", }, }, }, diff --git a/src/restic/backend/sftp/config.go b/src/restic/backend/sftp/config.go index 9a1ac35de..4321e71ad 100644 --- a/src/restic/backend/sftp/config.go +++ b/src/restic/backend/sftp/config.go @@ -10,9 +10,9 @@ import ( // Config collects all information required to connect to an sftp server. type Config struct { - User, Host, Dir string - Layout string `option:"layout"` - Command string `option:"command"` + User, Host, Path string + Layout string `option:"layout"` + Command string `option:"command"` } // ParseConfig parses the string s and extracts the sftp config. The @@ -62,6 +62,6 @@ func ParseConfig(s string) (interface{}, error) { return Config{ User: user, Host: host, - Dir: path.Clean(dir), + Path: path.Clean(dir), }, nil } diff --git a/src/restic/backend/sftp/config_test.go b/src/restic/backend/sftp/config_test.go index d0350745c..44439005e 100644 --- a/src/restic/backend/sftp/config_test.go +++ b/src/restic/backend/sftp/config_test.go @@ -9,53 +9,53 @@ var configTests = []struct { // first form, user specified sftp://user@host/dir { "sftp://user@host/dir/subdir", - Config{User: "user", Host: "host", Dir: "dir/subdir"}, + Config{User: "user", Host: "host", Path: "dir/subdir"}, }, { "sftp://host/dir/subdir", - Config{Host: "host", Dir: "dir/subdir"}, + Config{Host: "host", Path: "dir/subdir"}, }, { "sftp://host//dir/subdir", - Config{Host: "host", Dir: "/dir/subdir"}, + Config{Host: "host", Path: "/dir/subdir"}, }, { "sftp://host:10022//dir/subdir", - Config{Host: "host:10022", Dir: "/dir/subdir"}, + Config{Host: "host:10022", Path: "/dir/subdir"}, }, { "sftp://user@host:10022//dir/subdir", - Config{User: "user", Host: "host:10022", Dir: "/dir/subdir"}, + Config{User: "user", Host: "host:10022", Path: "/dir/subdir"}, }, { "sftp://user@host/dir/subdir/../other", - Config{User: "user", Host: "host", Dir: "dir/other"}, + Config{User: "user", Host: "host", Path: "dir/other"}, }, { "sftp://user@host/dir///subdir", - Config{User: "user", Host: "host", Dir: "dir/subdir"}, + Config{User: "user", Host: "host", Path: "dir/subdir"}, }, // second form, user specified sftp:user@host:/dir { "sftp:user@host:/dir/subdir", - Config{User: "user", Host: "host", Dir: "/dir/subdir"}, + Config{User: "user", Host: "host", Path: "/dir/subdir"}, }, { "sftp:host:../dir/subdir", - Config{Host: "host", Dir: "../dir/subdir"}, + Config{Host: "host", Path: "../dir/subdir"}, }, { "sftp:user@host:dir/subdir:suffix", - Config{User: "user", Host: "host", Dir: "dir/subdir:suffix"}, + Config{User: "user", Host: "host", Path: "dir/subdir:suffix"}, }, { "sftp:user@host:dir/subdir/../other", - Config{User: "user", Host: "host", Dir: "dir/other"}, + Config{User: "user", Host: "host", Path: "dir/other"}, }, { "sftp:user@host:dir///subdir", - Config{User: "user", Host: "host", Dir: "dir/subdir"}, + Config{User: "user", Host: "host", Path: "dir/subdir"}, }, } diff --git a/src/restic/backend/sftp/sftp.go b/src/restic/backend/sftp/sftp.go index b0b796df6..356317ba6 100644 --- a/src/restic/backend/sftp/sftp.go +++ b/src/restic/backend/sftp/sftp.go @@ -32,6 +32,7 @@ type SFTP struct { result <-chan error backend.Layout + Config } var _ restic.Backend = &SFTP{} From 27ce6a85e90ded157e94b81d19b005a9ef993d49 Mon Sep 17 00:00:00 2001 From: Alexander Neumann Date: Mon, 10 Apr 2017 22:41:20 +0200 Subject: [PATCH 23/47] sftp: Rework Open/Create --- src/restic/backend/sftp/sftp.go | 95 ++++++++++++++++----------------- 1 file changed, 46 insertions(+), 49 deletions(-) diff --git a/src/restic/backend/sftp/sftp.go b/src/restic/backend/sftp/sftp.go index 356317ba6..61b1bf489 100644 --- a/src/restic/backend/sftp/sftp.go +++ b/src/restic/backend/sftp/sftp.go @@ -118,13 +118,17 @@ func (r *SFTP) clientError() error { return nil } -// open opens an sftp backend. When the command is started via -// exec.Command, it is expected to speak sftp on stdin/stdout. The backend -// is expected at the given path. `dir` must be delimited by forward slashes -// ("/"), which is required by sftp. -func open(dir string, program string, args ...string) (*SFTP, error) { - debug.Log("open backend with program %v, %v at %v", program, args, dir) - sftp, err := startClient(program, args...) +// Open opens an sftp backend as described by the config by running +// "ssh" with the appropriate arguments (or cfg.Command, if set). +func Open(cfg Config) (*SFTP, error) { + debug.Log("open backend with config %#v", cfg) + + cmd, args, err := buildSSHCommand(cfg) + if err != nil { + return nil, err + } + + sftp, err := startClient(cmd, args...) if err != nil { debug.Log("unable to start program: %v", err) return nil, err @@ -136,19 +140,36 @@ func open(dir string, program string, args ...string) (*SFTP, error) { } // test if all necessary dirs and files are there - for _, d := range paths(dir) { + for _, d := range paths(cfg.Path) { if _, err := sftp.c.Lstat(d); err != nil { return nil, errors.Errorf("%s does not exist", d) } } - sftp.p = dir + sftp.Config = cfg + sftp.p = cfg.Path return sftp, nil } -func buildSSHCommand(cfg Config) []string { +// Join combines path components with slashes (according to the sftp spec). +func (r *SFTP) Join(p ...string) string { + return path.Join(p...) +} + +// ReadDir returns the entries for a directory. +func (r *SFTP) ReadDir(dir string) ([]os.FileInfo, error) { + return r.c.ReadDir(dir) +} + +func buildSSHCommand(cfg Config) (cmd string, args []string, err error) { + if cfg.Command != "" { + return SplitShellArgs(cfg.Command) + } + + cmd = "ssh" + hostport := strings.Split(cfg.Host, ":") - args := []string{hostport[0]} + args = []string{hostport[0]} if len(hostport) > 1 { args = append(args, "-p", hostport[1]) } @@ -158,44 +179,36 @@ func buildSSHCommand(cfg Config) []string { } args = append(args, "-s") args = append(args, "sftp") - return args + return cmd, args, nil } -// Open opens an sftp backend as described by the config by running -// "ssh" with the appropriate arguments. -func Open(cfg Config) (*SFTP, error) { - debug.Log("config %#v", cfg) - - if cfg.Command == "" { - return open(cfg.Dir, "ssh", buildSSHCommand(cfg)...) - } - - cmd, args, err := SplitShellArgs(cfg.Command) +// Create creates an sftp backend as described by the config by running +// "ssh" with the appropriate arguments (or cfg.Command, if set). +func create(cfg Config) (*SFTP, error) { + cmd, args, err := buildSSHCommand(cfg) if err != nil { return nil, err } - return open(cfg.Dir, cmd, args...) -} + sftp, err := startClient(cmd, args...) + if err != nil { + debug.Log("unable to start program: %v", err) + return nil, err + } -// create creates all the necessary files and directories for a new sftp -// backend at dir. `dir` must be delimited by forward slashes ("/"), which is -// required by sftp. -func create(dir string, program string, args ...string) (*SFTP, error) { - debug.Log("create() %v %v", program, args) - sftp, err := startClient(program, args...) + l, err := backend.ParseLayout(sftp, cfg.Layout, defaultLayout, cfg.Path) if err != nil { return nil, err } // test if config file already exists - _, err = sftp.c.Lstat(Join(dir, backend.Paths.Config)) + _, err = sftp.c.Lstat(Join(cfg.Path, backend.Paths.Config)) if err == nil { return nil, errors.New("config file already exists") } // create paths for data, refs and temp blobs - for _, d := range paths(dir) { + for _, d := range paths(cfg.Path) { err = sftp.mkdirAll(d, backend.Modes.Dir) debug.Log("mkdirAll %v -> %v", d, err) if err != nil { @@ -209,23 +222,7 @@ func create(dir string, program string, args ...string) (*SFTP, error) { } // open backend - return open(dir, program, args...) -} - -// Create creates an sftp backend as described by the config by running -// "ssh" with the appropriate arguments. -func Create(cfg Config) (*SFTP, error) { - debug.Log("config %#v", cfg) - if cfg.Command == "" { - return create(cfg.Dir, "ssh", buildSSHCommand(cfg)...) - } - - cmd, args, err := SplitShellArgs(cfg.Command) - if err != nil { - return nil, err - } - - return create(cfg.Dir, cmd, args...) + return Open(cfg) } // Location returns this backend's location (the directory name). From 698ba575975c8d19b92d4c329f7cc4db7424f0c8 Mon Sep 17 00:00:00 2001 From: Alexander Neumann Date: Mon, 10 Apr 2017 21:46:41 +0200 Subject: [PATCH 24/47] backend/tests: Print error stacktrace if available --- src/restic/backend/test/tests.go | 42 ++++++++++++++++---------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/src/restic/backend/test/tests.go b/src/restic/backend/test/tests.go index f2e6b2820..be3946296 100644 --- a/src/restic/backend/test/tests.go +++ b/src/restic/backend/test/tests.go @@ -43,7 +43,7 @@ func open(t testing.TB) restic.Backend { if !butInitialized { be, err := CreateFn() if err != nil { - t.Fatalf("Create returned unexpected error: %v", err) + t.Fatalf("Create returned unexpected error: %+v", err) } but = be @@ -54,7 +54,7 @@ func open(t testing.TB) restic.Backend { var err error but, err = OpenFn() if err != nil { - t.Fatalf("Open returned unexpected error: %v", err) + t.Fatalf("Open returned unexpected error: %+v", err) } } @@ -68,7 +68,7 @@ func close(t testing.TB) { err := but.Close() if err != nil { - t.Fatalf("Close returned unexpected error: %v", err) + t.Fatalf("Close returned unexpected error: %+v", err) } but = nil @@ -82,14 +82,14 @@ func TestCreate(t testing.TB) { be, err := CreateFn() if err != nil { - t.Fatalf("Create returned error: %v", err) + t.Fatalf("Create returned error: %+v", err) } butInitialized = true err = be.Close() if err != nil { - t.Fatalf("Close returned error: %v", err) + t.Fatalf("Close returned error: %+v", err) } } @@ -101,12 +101,12 @@ func TestOpen(t testing.TB) { be, err := OpenFn() if err != nil { - t.Fatalf("Open returned error: %v", err) + t.Fatalf("Open returned error: %+v", err) } err = be.Close() if err != nil { - t.Fatalf("Close returned error: %v", err) + t.Fatalf("Close returned error: %+v", err) } } @@ -132,7 +132,7 @@ func TestCreateWithConfig(t testing.TB) { // remove config err = b.Remove(restic.Handle{Type: restic.ConfigFile, Name: ""}) if err != nil { - t.Fatalf("unexpected error removing config: %v", err) + t.Fatalf("unexpected error removing config: %+v", err) } } @@ -162,7 +162,7 @@ func TestConfig(t testing.TB) { err = b.Save(restic.Handle{Type: restic.ConfigFile}, strings.NewReader(testString)) if err != nil { - t.Fatalf("Save() error: %v", err) + t.Fatalf("Save() error: %+v", err) } // try accessing the config with different names, should all return the @@ -171,7 +171,7 @@ func TestConfig(t testing.TB) { h := restic.Handle{Type: restic.ConfigFile, Name: name} buf, err := backend.LoadAll(b, h) if err != nil { - t.Fatalf("unable to read config with name %q: %v", name, err) + t.Fatalf("unable to read config with name %q: %+v", name, err) } if string(buf) != testString { @@ -203,7 +203,7 @@ func TestLoad(t testing.TB) { handle := restic.Handle{Type: restic.DataFile, Name: id.String()} err = b.Save(handle, bytes.NewReader(data)) if err != nil { - t.Fatalf("Save() error: %v", err) + t.Fatalf("Save() error: %+v", err) } rd, err := b.Load(handle, 100, -1) @@ -238,13 +238,13 @@ func TestLoad(t testing.TB) { rd, err := b.Load(handle, getlen, int64(o)) if err != nil { - t.Errorf("Load(%d, %d) returned unexpected error: %v", l, o, err) + t.Errorf("Load(%d, %d) returned unexpected error: %+v", l, o, err) continue } buf, err := ioutil.ReadAll(rd) if err != nil { - t.Errorf("Load(%d, %d) ReadAll() returned unexpected error: %v", l, o, err) + t.Errorf("Load(%d, %d) ReadAll() returned unexpected error: %+v", l, o, err) rd.Close() continue } @@ -269,7 +269,7 @@ func TestLoad(t testing.TB) { err = rd.Close() if err != nil { - t.Errorf("Load(%d, %d) rd.Close() returned unexpected error: %v", l, o, err) + t.Errorf("Load(%d, %d) rd.Close() returned unexpected error: %+v", l, o, err) continue } } @@ -325,7 +325,7 @@ func TestSave(t testing.TB) { err = b.Remove(h) if err != nil { - t.Fatalf("error removing item: %v", err) + t.Fatalf("error removing item: %+v", err) } } @@ -366,7 +366,7 @@ func TestSave(t testing.TB) { err = b.Remove(h) if err != nil { - t.Fatalf("error removing item: %v", err) + t.Fatalf("error removing item: %+v", err) } } @@ -391,13 +391,13 @@ func TestSaveFilenames(t testing.TB) { h := restic.Handle{Name: test.name, Type: restic.DataFile} err := b.Save(h, strings.NewReader(test.data)) if err != nil { - t.Errorf("test %d failed: Save() returned %v", i, err) + t.Errorf("test %d failed: Save() returned %+v", i, err) continue } buf, err := backend.LoadAll(b, h) if err != nil { - t.Errorf("test %d failed: Load() returned %v", i, err) + t.Errorf("test %d failed: Load() returned %+v", i, err) continue } @@ -407,7 +407,7 @@ func TestSaveFilenames(t testing.TB) { err = b.Remove(h) if err != nil { - t.Errorf("test %d failed: Remove() returned %v", i, err) + t.Errorf("test %d failed: Remove() returned %+v", i, err) continue } } @@ -576,7 +576,7 @@ func TestDelete(t testing.TB) { err := be.Delete() if err != nil { - t.Fatalf("error deleting backend: %v", err) + t.Fatalf("error deleting backend: %+v", err) } } @@ -594,6 +594,6 @@ func TestCleanup(t testing.TB) { err := CleanupFn() if err != nil { - t.Fatalf("Cleanup returned error: %v", err) + t.Fatalf("Cleanup returned error: %+v", err) } } From 0cbd59856c9fd91be94bfe04eca78b7f62898358 Mon Sep 17 00:00:00 2001 From: Alexander Neumann Date: Mon, 10 Apr 2017 22:16:50 +0200 Subject: [PATCH 25/47] layout: Add IsNotExist --- src/restic/backend/layout.go | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/src/restic/backend/layout.go b/src/restic/backend/layout.go index eb51705f5..f57e962f6 100644 --- a/src/restic/backend/layout.go +++ b/src/restic/backend/layout.go @@ -22,6 +22,7 @@ type Layout interface { type Filesystem interface { Join(...string) string ReadDir(string) ([]os.FileInfo, error) + IsNotExist(error) bool } // ensure statically that *LocalFilesystem implements Filesystem. @@ -40,12 +41,12 @@ func (l *LocalFilesystem) ReadDir(dir string) ([]os.FileInfo, error) { entries, err := f.Readdir(-1) if err != nil { - return nil, err + return nil, errors.Wrap(err, "Readdir") } err = f.Close() if err != nil { - return nil, err + return nil, errors.Wrap(err, "Close") } return entries, nil @@ -56,12 +57,17 @@ func (l *LocalFilesystem) Join(paths ...string) string { return filepath.Join(paths...) } +// IsNotExist returns true for errors that are caused by not existing files. +func (l *LocalFilesystem) IsNotExist(err error) bool { + return os.IsNotExist(err) +} + var backendFilenameLength = len(restic.ID{}) * 2 var backendFilename = regexp.MustCompile(fmt.Sprintf("^[a-fA-F0-9]{%d}$", backendFilenameLength)) func hasBackendFile(fs Filesystem, dir string) (bool, error) { entries, err := fs.ReadDir(dir) - if err != nil && os.IsNotExist(errors.Cause(err)) { + if err != nil && fs.IsNotExist(errors.Cause(err)) { return false, nil } @@ -82,7 +88,7 @@ var dataSubdirName = regexp.MustCompile("^[a-fA-F0-9]{2}$") func hasSubdirBackendFile(fs Filesystem, dir string) (bool, error) { entries, err := fs.ReadDir(dir) - if err != nil && os.IsNotExist(errors.Cause(err)) { + if err != nil && fs.IsNotExist(errors.Cause(err)) { return false, nil } @@ -116,6 +122,7 @@ var ErrLayoutDetectionFailed = errors.New("auto-detecting the filesystem layout // filesystem at the given path. If repo is nil, an instance of LocalFilesystem // is used. func DetectLayout(repo Filesystem, dir string) (Layout, error) { + debug.Log("detect layout at %v", dir) if repo == nil { repo = &LocalFilesystem{} } @@ -168,6 +175,7 @@ func DetectLayout(repo Filesystem, dir string) (Layout, error) { }, nil } + debug.Log("layout detection failed") return nil, ErrLayoutDetectionFailed } @@ -196,8 +204,14 @@ func ParseLayout(repo Filesystem, layout, defaultLayout, path string) (l Layout, // use the default layout if auto detection failed if errors.Cause(err) == ErrLayoutDetectionFailed && defaultLayout != "" { + debug.Log("error: %v, use default layout %v", defaultLayout) return ParseLayout(repo, defaultLayout, "", path) } + + if err != nil { + return nil, err + } + debug.Log("layout detected: %v", l) default: return nil, errors.Errorf("unknown backend layout string %q, may be one of: default, cloud, s3", layout) } From a849edf19a113b5a9aab8e7538b9270b6590c281 Mon Sep 17 00:00:00 2001 From: Alexander Neumann Date: Mon, 10 Apr 2017 22:17:03 +0200 Subject: [PATCH 26/47] local: remove double Close() --- src/restic/backend/local/local.go | 1 - 1 file changed, 1 deletion(-) diff --git a/src/restic/backend/local/local.go b/src/restic/backend/local/local.go index eace14ee0..a8b9f4501 100644 --- a/src/restic/backend/local/local.go +++ b/src/restic/backend/local/local.go @@ -118,7 +118,6 @@ func (b *Local) Save(h restic.Handle, rd io.Reader) (err error) { err = f.Close() if err != nil { - f.Close() return errors.Wrap(err, "Close") } From 42ea4d257bb78891fdb445020cf758cdc060d84a Mon Sep 17 00:00:00 2001 From: Alexander Neumann Date: Mon, 10 Apr 2017 22:17:50 +0200 Subject: [PATCH 27/47] sftp: first step of conversion to Layout --- src/restic/backend/sftp/sftp.go | 58 ++++++++++---------- src/restic/backend/sftp/sftp_backend_test.go | 4 +- src/restic/backend/sftp/sshcmd_test.go | 52 ++++++++++-------- 3 files changed, 59 insertions(+), 55 deletions(-) diff --git a/src/restic/backend/sftp/sftp.go b/src/restic/backend/sftp/sftp.go index 61b1bf489..81f02f023 100644 --- a/src/restic/backend/sftp/sftp.go +++ b/src/restic/backend/sftp/sftp.go @@ -134,7 +134,7 @@ func Open(cfg Config) (*SFTP, error) { return nil, err } - l, err := backend.ParseLayout(sftp, cfg.Layout, defaultLayout, cfg.Path) + sftp.Layout, err = backend.ParseLayout(sftp, cfg.Layout, defaultLayout, cfg.Path) if err != nil { return nil, err } @@ -146,6 +146,8 @@ func Open(cfg Config) (*SFTP, error) { } } + debug.Log("layout: %v\n", sftp.Layout) + sftp.Config = cfg sftp.p = cfg.Path return sftp, nil @@ -161,6 +163,16 @@ func (r *SFTP) ReadDir(dir string) ([]os.FileInfo, error) { return r.c.ReadDir(dir) } +// IsNotExist returns true if the error is caused by a not existing file. +func (r *SFTP) IsNotExist(err error) bool { + statusError, ok := err.(*sftp.StatusError) + if !ok { + return false + } + + return statusError.Error() == `sftp: "No such file" (SSH_FX_NO_SUCH_FILE)` +} + func buildSSHCommand(cfg Config) (cmd string, args []string, err error) { if cfg.Command != "" { return SplitShellArgs(cfg.Command) @@ -184,7 +196,7 @@ func buildSSHCommand(cfg Config) (cmd string, args []string, err error) { // Create creates an sftp backend as described by the config by running // "ssh" with the appropriate arguments (or cfg.Command, if set). -func create(cfg Config) (*SFTP, error) { +func Create(cfg Config) (*SFTP, error) { cmd, args, err := buildSSHCommand(cfg) if err != nil { return nil, err @@ -196,7 +208,7 @@ func create(cfg Config) (*SFTP, error) { return nil, err } - l, err := backend.ParseLayout(sftp, cfg.Layout, defaultLayout, cfg.Path) + sftp.Layout, err = backend.ParseLayout(sftp, cfg.Layout, defaultLayout, cfg.Path) if err != nil { return nil, err } @@ -351,14 +363,22 @@ func (r *SFTP) Save(h restic.Handle, rd io.Reader) (err error) { } } - // test if new file exists - if _, err := r.c.Lstat(filename); err == nil { - return errors.Errorf("Close(): file %v already exists", filename) + // create new file + f, err := r.c.OpenFile(filename, os.O_CREATE|os.O_EXCL|os.O_WRONLY) + if err != nil { + return errors.Wrap(err, "OpenFile") } - err := r.c.Rename(oldname, filename) + // save data + _, err = io.Copy(f, rd) if err != nil { - return errors.Wrap(err, "Rename") + f.Close() + return errors.Wrap(err, "Write") + } + + err = f.Close() + if err != nil { + return errors.Wrap(err, "Close") } // set mode to read-only @@ -369,28 +389,6 @@ func (r *SFTP) Save(h restic.Handle, rd io.Reader) (err error) { err = r.c.Chmod(filename, fi.Mode()&os.FileMode(^uint32(0222))) return errors.Wrap(err, "Chmod") - - filename, tmpfile, err := r.tempFile() - if err != nil { - return err - } - - n, err := io.Copy(tmpfile, rd) - if err != nil { - return errors.Wrap(err, "Write") - } - - debug.Log("saved %v (%d bytes) to %v", h, n, filename) - - err = tmpfile.Close() - if err != nil { - return errors.Wrap(err, "Close") - } - - err = r.renameFile(filename, h) - debug.Log("save %v: rename %v: %v", - h, path.Base(filename), err) - return err } // Load returns a reader that yields the contents of the file at h at the diff --git a/src/restic/backend/sftp/sftp_backend_test.go b/src/restic/backend/sftp/sftp_backend_test.go index 90b96d769..289c62206 100644 --- a/src/restic/backend/sftp/sftp_backend_test.go +++ b/src/restic/backend/sftp/sftp_backend_test.go @@ -61,7 +61,7 @@ func init() { return nil, err } - cfg.Dir = tempBackendDir + cfg.Path = tempBackendDir return sftp.Create(cfg) } @@ -72,7 +72,7 @@ func init() { return nil, err } - cfg.Dir = tempBackendDir + cfg.Path = tempBackendDir return sftp.Open(cfg) } diff --git a/src/restic/backend/sftp/sshcmd_test.go b/src/restic/backend/sftp/sshcmd_test.go index d98309c67..dea811a35 100644 --- a/src/restic/backend/sftp/sshcmd_test.go +++ b/src/restic/backend/sftp/sshcmd_test.go @@ -1,46 +1,52 @@ package sftp -import "testing" +import ( + "reflect" + "testing" +) var sshcmdTests = []struct { - cfg Config - s []string + cfg Config + cmd string + args []string }{ { - Config{User: "user", Host: "host", Dir: "dir/subdir"}, + Config{User: "user", Host: "host", Path: "dir/subdir"}, + "ssh", []string{"host", "-l", "user", "-s", "sftp"}, }, { - Config{Host: "host", Dir: "dir/subdir"}, + Config{Host: "host", Path: "dir/subdir"}, + "ssh", []string{"host", "-s", "sftp"}, }, { - Config{Host: "host:10022", Dir: "/dir/subdir"}, + Config{Host: "host:10022", Path: "/dir/subdir"}, + "ssh", []string{"host", "-p", "10022", "-s", "sftp"}, }, { - Config{User: "user", Host: "host:10022", Dir: "/dir/subdir"}, + Config{User: "user", Host: "host:10022", Path: "/dir/subdir"}, + "ssh", []string{"host", "-p", "10022", "-l", "user", "-s", "sftp"}, }, } func TestBuildSSHCommand(t *testing.T) { - for i, test := range sshcmdTests { - cmd := buildSSHCommand(test.cfg) - failed := false - if len(cmd) != len(test.s) { - failed = true - } else { - for l := range test.s { - if test.s[l] != cmd[l] { - failed = true - break - } + for _, test := range sshcmdTests { + t.Run("", func(t *testing.T) { + cmd, args, err := buildSSHCommand(test.cfg) + if err != nil { + t.Fatal(err) } - } - if failed { - t.Errorf("test %d: wrong cmd, want:\n %v\ngot:\n %v", - i, test.s, cmd) - } + + if cmd != test.cmd { + t.Fatalf("cmd: want %v, got %v", test.cmd, cmd) + } + + if !reflect.DeepEqual(test.args, args) { + t.Fatalf("wrong args, want:\n %v\ngot:\n %v", test.args, args) + } + }) } } From 74eb2937339ba61a67922835080820ddb603b2db Mon Sep 17 00:00:00 2001 From: Alexander Neumann Date: Mon, 10 Apr 2017 22:26:17 +0200 Subject: [PATCH 28/47] sftp: Remove legacy filename/dirname methods --- src/restic/backend/sftp/sftp.go | 60 +++++---------------------------- 1 file changed, 9 insertions(+), 51 deletions(-) diff --git a/src/restic/backend/sftp/sftp.go b/src/restic/backend/sftp/sftp.go index 81f02f023..f9a8efd14 100644 --- a/src/restic/backend/sftp/sftp.go +++ b/src/restic/backend/sftp/sftp.go @@ -93,18 +93,6 @@ func startClient(program string, args ...string) (*SFTP, error) { return &SFTP{c: client, cmd: cmd, result: ch}, nil } -func paths(dir string) []string { - return []string{ - dir, - Join(dir, backend.Paths.Data), - Join(dir, backend.Paths.Snapshots), - Join(dir, backend.Paths.Index), - Join(dir, backend.Paths.Locks), - Join(dir, backend.Paths.Keys), - Join(dir, backend.Paths.Temp), - } -} - // clientError returns an error if the client has exited. Otherwise, nil is // returned immediately. func (r *SFTP) clientError() error { @@ -140,7 +128,7 @@ func Open(cfg Config) (*SFTP, error) { } // test if all necessary dirs and files are there - for _, d := range paths(cfg.Path) { + for _, d := range sftp.Paths() { if _, err := sftp.c.Lstat(d); err != nil { return nil, errors.Errorf("%s does not exist", d) } @@ -220,7 +208,7 @@ func Create(cfg Config) (*SFTP, error) { } // create paths for data, refs and temp blobs - for _, d := range paths(cfg.Path) { + for _, d := range sftp.Paths() { err = sftp.mkdirAll(d, backend.Modes.Dir) debug.Log("mkdirAll %v -> %v", d, err) if err != nil { @@ -276,7 +264,7 @@ func (r *SFTP) mkdirAll(dir string, mode os.FileMode) error { // Rename temp file to final name according to type and name. func (r *SFTP) renameFile(oldname string, h restic.Handle) error { - filename := r.filename(h) + filename := r.Filename(h) // create directories if necessary if h.Type == restic.DataFile { @@ -312,36 +300,6 @@ func Join(parts ...string) string { return path.Clean(path.Join(parts...)) } -// Construct path for given restic.Type and name. -func (r *SFTP) filename(h restic.Handle) string { - if h.Type == restic.ConfigFile { - return Join(r.p, "config") - } - - return Join(r.dirname(h), h.Name) -} - -// Construct directory for given backend.Type. -func (r *SFTP) dirname(h restic.Handle) string { - var n string - switch h.Type { - case restic.DataFile: - n = backend.Paths.Data - if len(h.Name) > 2 { - n = Join(n, h.Name[:2]) - } - case restic.SnapshotFile: - n = backend.Paths.Snapshots - case restic.IndexFile: - n = backend.Paths.Index - case restic.LockFile: - n = backend.Paths.Locks - case restic.KeyFile: - n = backend.Paths.Keys - } - return Join(r.p, n) -} - // Save stores data in the backend at the handle. func (r *SFTP) Save(h restic.Handle, rd io.Reader) (err error) { debug.Log("Save %v", h) @@ -404,7 +362,7 @@ func (r *SFTP) Load(h restic.Handle, length int, offset int64) (io.ReadCloser, e return nil, errors.New("offset is negative") } - f, err := r.c.Open(r.filename(h)) + f, err := r.c.Open(r.Filename(h)) if err != nil { return nil, err } @@ -435,7 +393,7 @@ func (r *SFTP) Stat(h restic.Handle) (restic.FileInfo, error) { return restic.FileInfo{}, err } - fi, err := r.c.Lstat(r.filename(h)) + fi, err := r.c.Lstat(r.Filename(h)) if err != nil { return restic.FileInfo{}, errors.Wrap(err, "Lstat") } @@ -450,7 +408,7 @@ func (r *SFTP) Test(h restic.Handle) (bool, error) { return false, err } - _, err := r.c.Lstat(r.filename(h)) + _, err := r.c.Lstat(r.Filename(h)) if os.IsNotExist(errors.Cause(err)) { return false, nil } @@ -469,7 +427,7 @@ func (r *SFTP) Remove(h restic.Handle) error { return err } - return r.c.Remove(r.filename(h)) + return r.c.Remove(r.Filename(h)) } // List returns a channel that yields all names of blobs of type t. A @@ -484,7 +442,7 @@ func (r *SFTP) List(t restic.FileType, done <-chan struct{}) <-chan string { if t == restic.DataFile { // read first level - basedir := r.dirname(restic.Handle{Type: t}) + basedir := r.Dirname(restic.Handle{Type: t}) list1, err := r.c.ReadDir(basedir) if err != nil { @@ -517,7 +475,7 @@ func (r *SFTP) List(t restic.FileType, done <-chan struct{}) <-chan string { } } } else { - entries, err := r.c.ReadDir(r.dirname(restic.Handle{Type: t})) + entries, err := r.c.ReadDir(r.Dirname(restic.Handle{Type: t})) if err != nil { return } From 783fd73ea12a9c003e82b23727bd3437c0e164d8 Mon Sep 17 00:00:00 2001 From: Alexander Neumann Date: Mon, 10 Apr 2017 22:34:39 +0200 Subject: [PATCH 29/47] local: Rename local_layout_test --- .../backend/local/{local_layout_test.go => layout_test.go} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename src/restic/backend/local/{local_layout_test.go => layout_test.go} (95%) diff --git a/src/restic/backend/local/local_layout_test.go b/src/restic/backend/local/layout_test.go similarity index 95% rename from src/restic/backend/local/local_layout_test.go rename to src/restic/backend/local/layout_test.go index 278eac4ba..805320206 100644 --- a/src/restic/backend/local/local_layout_test.go +++ b/src/restic/backend/local/layout_test.go @@ -6,7 +6,7 @@ import ( "testing" ) -func TestLocalLayout(t *testing.T) { +func TestLayout(t *testing.T) { path, cleanup := TempDir(t) defer cleanup() From e8780f1ec6927182354f9d7d77d9cdd0bb2bfa76 Mon Sep 17 00:00:00 2001 From: Alexander Neumann Date: Mon, 10 Apr 2017 22:35:03 +0200 Subject: [PATCH 30/47] sftp: Add layout tests --- src/restic/backend/sftp/layout_test.go | 46 ++++++++++++++++++++ src/restic/backend/sftp/sftp_backend_test.go | 13 +++--- 2 files changed, 54 insertions(+), 5 deletions(-) create mode 100644 src/restic/backend/sftp/layout_test.go diff --git a/src/restic/backend/sftp/layout_test.go b/src/restic/backend/sftp/layout_test.go new file mode 100644 index 000000000..4bf0d41ba --- /dev/null +++ b/src/restic/backend/sftp/layout_test.go @@ -0,0 +1,46 @@ +package sftp_test + +import ( + "fmt" + "path/filepath" + "restic/backend/sftp" + . "restic/test" + "testing" +) + +func TestLayout(t *testing.T) { + path, cleanup := TempDir(t) + defer cleanup() + + var tests = []struct { + filename string + layout string + failureExpected bool + }{ + {"repo-layout-local.tar.gz", "", false}, + {"repo-layout-cloud.tar.gz", "", false}, + {"repo-layout-s3-old.tar.gz", "", false}, + } + + for _, test := range tests { + t.Run(test.filename, func(t *testing.T) { + SetupTarTestFixture(t, path, filepath.Join("..", "testdata", test.filename)) + + repo := filepath.Join(path, "repo") + be, err := sftp.Open(sftp.Config{ + Command: fmt.Sprintf("%q -e", sftpserver), + Path: repo, + Layout: test.layout, + }) + if err != nil { + t.Fatal(err) + } + + if be == nil { + t.Fatalf("Open() returned nil but no error") + } + + RemoveAll(t, filepath.Join(path, "repo")) + }) + } +} diff --git a/src/restic/backend/sftp/sftp_backend_test.go b/src/restic/backend/sftp/sftp_backend_test.go index 289c62206..1834beee8 100644 --- a/src/restic/backend/sftp/sftp_backend_test.go +++ b/src/restic/backend/sftp/sftp_backend_test.go @@ -34,18 +34,21 @@ func createTempdir() error { return nil } -func init() { - sftpserver := "" - +func findSFTPServerBinary() string { for _, dir := range strings.Split(TestSFTPPath, ":") { testpath := filepath.Join(dir, "sftp-server") _, err := os.Stat(testpath) if !os.IsNotExist(errors.Cause(err)) { - sftpserver = testpath - break + return testpath } } + return "" +} + +var sftpserver = findSFTPServerBinary() + +func init() { if sftpserver == "" { SkipMessage = "sftp server binary not found, skipping tests" return From c2ee0d9c848dd3eeb3756e71b4032f5064987533 Mon Sep 17 00:00:00 2001 From: Alexander Neumann Date: Mon, 10 Apr 2017 22:51:00 +0200 Subject: [PATCH 31/47] sftp: Skip tests if server binary is not available --- src/restic/backend/sftp/layout_test.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/restic/backend/sftp/layout_test.go b/src/restic/backend/sftp/layout_test.go index 4bf0d41ba..cd2b18783 100644 --- a/src/restic/backend/sftp/layout_test.go +++ b/src/restic/backend/sftp/layout_test.go @@ -9,6 +9,10 @@ import ( ) func TestLayout(t *testing.T) { + if sftpserver == "" { + t.Skip("sftp server binary not available") + } + path, cleanup := TempDir(t) defer cleanup() From 36b1c0898ccb65cc888faf5904f9d254f144b3c7 Mon Sep 17 00:00:00 2001 From: Alexander Neumann Date: Mon, 10 Apr 2017 23:01:06 +0200 Subject: [PATCH 32/47] sftp: Add OS X sftp-server path --- src/restic/test/vars.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/restic/test/vars.go b/src/restic/test/vars.go index bb9f6b13d..908e4bf6b 100644 --- a/src/restic/test/vars.go +++ b/src/restic/test/vars.go @@ -11,7 +11,7 @@ var ( TestTempDir = getStringVar("RESTIC_TEST_TMPDIR", "") RunIntegrationTest = getBoolVar("RESTIC_TEST_INTEGRATION", true) RunFuseTest = getBoolVar("RESTIC_TEST_FUSE", true) - TestSFTPPath = getStringVar("RESTIC_TEST_SFTPPATH", "/usr/lib/ssh:/usr/lib/openssh") + TestSFTPPath = getStringVar("RESTIC_TEST_SFTPPATH", "/usr/lib/ssh:/usr/lib/openssh:/usr/libexec") TestWalkerPath = getStringVar("RESTIC_TEST_PATH", ".") BenchArchiveDirectory = getStringVar("RESTIC_BENCH_DIR", ".") TestS3Server = getStringVar("RESTIC_TEST_S3_SERVER", "") From e2af5890f39e427bb51e92e699f87d5632c42577 Mon Sep 17 00:00:00 2001 From: Alexander Neumann Date: Mon, 10 Apr 2017 23:21:04 +0200 Subject: [PATCH 33/47] backend: Add test for listing files with layouts --- src/restic/backend/local/layout_test.go | 47 +++++++++++++++++++++++-- src/restic/backend/sftp/layout_test.go | 47 +++++++++++++++++++++++-- 2 files changed, 88 insertions(+), 6 deletions(-) diff --git a/src/restic/backend/local/layout_test.go b/src/restic/backend/local/layout_test.go index 805320206..da0b0bfc8 100644 --- a/src/restic/backend/local/layout_test.go +++ b/src/restic/backend/local/layout_test.go @@ -2,6 +2,7 @@ package local import ( "path/filepath" + "restic" . "restic/test" "testing" ) @@ -14,10 +15,23 @@ func TestLayout(t *testing.T) { filename string layout string failureExpected bool + datafiles map[string]bool }{ - {"repo-layout-local.tar.gz", "", false}, - {"repo-layout-cloud.tar.gz", "", false}, - {"repo-layout-s3-old.tar.gz", "", false}, + {"repo-layout-local.tar.gz", "", false, map[string]bool{ + "aa464e9fd598fe4202492ee317ffa728e82fa83a1de1a61996e5bd2d6651646c": false, + "fc919a3b421850f6fa66ad22ebcf91e433e79ffef25becf8aef7c7b1eca91683": false, + "c089d62788da14f8b7cbf77188305c0874906f0b73d3fce5a8869050e8d0c0e1": false, + }}, + {"repo-layout-cloud.tar.gz", "", false, map[string]bool{ + "fc919a3b421850f6fa66ad22ebcf91e433e79ffef25becf8aef7c7b1eca91683": false, + "c089d62788da14f8b7cbf77188305c0874906f0b73d3fce5a8869050e8d0c0e1": false, + "aa464e9fd598fe4202492ee317ffa728e82fa83a1de1a61996e5bd2d6651646c": false, + }}, + {"repo-layout-s3-old.tar.gz", "", false, map[string]bool{ + "fc919a3b421850f6fa66ad22ebcf91e433e79ffef25becf8aef7c7b1eca91683": false, + "c089d62788da14f8b7cbf77188305c0874906f0b73d3fce5a8869050e8d0c0e1": false, + "aa464e9fd598fe4202492ee317ffa728e82fa83a1de1a61996e5bd2d6651646c": false, + }}, } for _, test := range tests { @@ -37,6 +51,33 @@ func TestLayout(t *testing.T) { t.Fatalf("Open() returned nil but no error") } + datafiles := make(map[string]bool) + for id := range be.List(restic.DataFile, nil) { + datafiles[id] = false + } + + if len(datafiles) == 0 { + t.Errorf("List() returned zero data files") + } + + for id := range test.datafiles { + if _, ok := datafiles[id]; !ok { + t.Errorf("datafile with id %v not found", id) + } + + datafiles[id] = true + } + + for id, v := range datafiles { + if !v { + t.Errorf("unexpected id %v found", id) + } + } + + if err = be.Close(); err != nil { + t.Errorf("Close() returned error %v", err) + } + RemoveAll(t, filepath.Join(path, "repo")) }) } diff --git a/src/restic/backend/sftp/layout_test.go b/src/restic/backend/sftp/layout_test.go index cd2b18783..0159976fe 100644 --- a/src/restic/backend/sftp/layout_test.go +++ b/src/restic/backend/sftp/layout_test.go @@ -3,6 +3,7 @@ package sftp_test import ( "fmt" "path/filepath" + "restic" "restic/backend/sftp" . "restic/test" "testing" @@ -20,10 +21,23 @@ func TestLayout(t *testing.T) { filename string layout string failureExpected bool + datafiles map[string]bool }{ - {"repo-layout-local.tar.gz", "", false}, - {"repo-layout-cloud.tar.gz", "", false}, - {"repo-layout-s3-old.tar.gz", "", false}, + {"repo-layout-local.tar.gz", "", false, map[string]bool{ + "aa464e9fd598fe4202492ee317ffa728e82fa83a1de1a61996e5bd2d6651646c": false, + "fc919a3b421850f6fa66ad22ebcf91e433e79ffef25becf8aef7c7b1eca91683": false, + "c089d62788da14f8b7cbf77188305c0874906f0b73d3fce5a8869050e8d0c0e1": false, + }}, + {"repo-layout-cloud.tar.gz", "", false, map[string]bool{ + "fc919a3b421850f6fa66ad22ebcf91e433e79ffef25becf8aef7c7b1eca91683": false, + "c089d62788da14f8b7cbf77188305c0874906f0b73d3fce5a8869050e8d0c0e1": false, + "aa464e9fd598fe4202492ee317ffa728e82fa83a1de1a61996e5bd2d6651646c": false, + }}, + {"repo-layout-s3-old.tar.gz", "", false, map[string]bool{ + "fc919a3b421850f6fa66ad22ebcf91e433e79ffef25becf8aef7c7b1eca91683": false, + "c089d62788da14f8b7cbf77188305c0874906f0b73d3fce5a8869050e8d0c0e1": false, + "aa464e9fd598fe4202492ee317ffa728e82fa83a1de1a61996e5bd2d6651646c": false, + }}, } for _, test := range tests { @@ -44,6 +58,33 @@ func TestLayout(t *testing.T) { t.Fatalf("Open() returned nil but no error") } + datafiles := make(map[string]bool) + for id := range be.List(restic.DataFile, nil) { + datafiles[id] = false + } + + if len(datafiles) == 0 { + t.Errorf("List() returned zero data files") + } + + for id := range test.datafiles { + if _, ok := datafiles[id]; !ok { + t.Errorf("datafile with id %v not found", id) + } + + datafiles[id] = true + } + + for id, v := range datafiles { + if !v { + t.Errorf("unexpected id %v found", id) + } + } + + if err = be.Close(); err != nil { + t.Errorf("Close() returned error %v", err) + } + RemoveAll(t, filepath.Join(path, "repo")) }) } From 320c22f1f53b0fa0b49b6b09ad16d0e012cf9e3b Mon Sep 17 00:00:00 2001 From: Alexander Neumann Date: Mon, 10 Apr 2017 23:21:23 +0200 Subject: [PATCH 34/47] backend/layout: Add Basedir() --- src/restic/backend/layout.go | 1 + src/restic/backend/layout_cloud.go | 5 +++++ src/restic/backend/layout_default.go | 5 +++++ src/restic/backend/layout_s3.go | 5 +++++ 4 files changed, 16 insertions(+) diff --git a/src/restic/backend/layout.go b/src/restic/backend/layout.go index f57e962f6..de72f07d4 100644 --- a/src/restic/backend/layout.go +++ b/src/restic/backend/layout.go @@ -15,6 +15,7 @@ import ( type Layout interface { Filename(restic.Handle) string Dirname(restic.Handle) string + Basedir(restic.FileType) string Paths() []string } diff --git a/src/restic/backend/layout_cloud.go b/src/restic/backend/layout_cloud.go index 6f65be484..6a6947c59 100644 --- a/src/restic/backend/layout_cloud.go +++ b/src/restic/backend/layout_cloud.go @@ -34,3 +34,8 @@ func (l *CloudLayout) Paths() (dirs []string) { } return dirs } + +// Basedir returns the base dir name for files of type t. +func (l *CloudLayout) Basedir(t restic.FileType) string { + return l.Join(l.Path, cloudLayoutPaths[t]) +} diff --git a/src/restic/backend/layout_default.go b/src/restic/backend/layout_default.go index fd6364b80..77cb27508 100644 --- a/src/restic/backend/layout_default.go +++ b/src/restic/backend/layout_default.go @@ -47,3 +47,8 @@ func (l *DefaultLayout) Paths() (dirs []string) { } return dirs } + +// Basedir returns the base dir name for type t. +func (l *DefaultLayout) Basedir(t restic.FileType) string { + return l.Join(l.Path, defaultLayoutPaths[t]) +} diff --git a/src/restic/backend/layout_s3.go b/src/restic/backend/layout_s3.go index 571d36335..42df63c3c 100644 --- a/src/restic/backend/layout_s3.go +++ b/src/restic/backend/layout_s3.go @@ -40,3 +40,8 @@ func (l *S3Layout) Paths() (dirs []string) { } return dirs } + +// Basedir returns the base dir name for type t. +func (l *S3Layout) Basedir(t restic.FileType) string { + return l.Join(l.Path, s3LayoutPaths[t]) +} From e6578857cf18e818f2cfe11cd7fb17ec173a600f Mon Sep 17 00:00:00 2001 From: Alexander Neumann Date: Mon, 10 Apr 2017 23:31:13 +0200 Subject: [PATCH 35/47] sftp/local: Fix listing files --- src/restic/backend/local/local.go | 79 ++++--------------------------- src/restic/backend/sftp/sftp.go | 65 ++++++------------------- 2 files changed, 24 insertions(+), 120 deletions(-) diff --git a/src/restic/backend/local/local.go b/src/restic/backend/local/local.go index a8b9f4501..b140fd8f8 100644 --- a/src/restic/backend/local/local.go +++ b/src/restic/backend/local/local.go @@ -210,91 +210,30 @@ func isFile(fi os.FileInfo) bool { return fi.Mode()&(os.ModeType|os.ModeCharDevice) == 0 } -func readdir(d string) (fileInfos []os.FileInfo, err error) { - f, e := fs.Open(d) - if e != nil { - return nil, errors.Wrap(e, "Open") - } - - defer func() { - e := f.Close() - if err == nil { - err = errors.Wrap(e, "Close") - } - }() - - return f.Readdir(-1) -} - -// listDir returns a list of all files in d. -func listDir(d string) (filenames []string, err error) { - fileInfos, err := readdir(d) - if err != nil { - return nil, err - } - - for _, fi := range fileInfos { - if isFile(fi) { - filenames = append(filenames, fi.Name()) - } - } - - return filenames, nil -} - -// listDirs returns a list of all files in directories within d. -func listDirs(dir string) (filenames []string, err error) { - fileInfos, err := readdir(dir) - if err != nil { - return nil, err - } - - for _, fi := range fileInfos { - if !fi.IsDir() { - continue - } - - files, err := listDir(filepath.Join(dir, fi.Name())) - if err != nil { - continue - } - - filenames = append(filenames, files...) - } - - return filenames, nil -} - // List returns a channel that yields all names of blobs of type t. A // goroutine is started for this. If the channel done is closed, sending // stops. func (b *Local) List(t restic.FileType, done <-chan struct{}) <-chan string { debug.Log("List %v", t) - lister := listDir - if t == restic.DataFile { - lister = listDirs - } ch := make(chan string) - items, err := lister(b.Dirname(restic.Handle{Type: t})) - if err != nil { - close(ch) - return ch - } go func() { defer close(ch) - for _, m := range items { - if m == "" { - continue + + fs.Walk(b.Basedir(t), func(path string, fi os.FileInfo, err error) error { + if !isFile(fi) { + return err } select { - case ch <- m: + case ch <- filepath.Base(path): case <-done: - return + return err } - } + + return err + }) }() return ch diff --git a/src/restic/backend/sftp/sftp.go b/src/restic/backend/sftp/sftp.go index f9a8efd14..297f33f29 100644 --- a/src/restic/backend/sftp/sftp.go +++ b/src/restic/backend/sftp/sftp.go @@ -7,6 +7,7 @@ import ( "os" "os/exec" "path" + "path/filepath" "restic" "strings" "time" @@ -434,64 +435,28 @@ func (r *SFTP) Remove(h restic.Handle) error { // goroutine is started for this. If the channel done is closed, sending // stops. func (r *SFTP) List(t restic.FileType, done <-chan struct{}) <-chan string { - debug.Log("list all %v", t) + debug.Log("List %v", t) + ch := make(chan string) go func() { defer close(ch) - if t == restic.DataFile { - // read first level - basedir := r.Dirname(restic.Handle{Type: t}) + walker := r.c.Walk(r.Basedir(t)) + for walker.Step() { + if walker.Err() != nil { + continue + } - list1, err := r.c.ReadDir(basedir) - if err != nil { + if !walker.Stat().Mode().IsRegular() { + continue + } + + select { + case ch <- filepath.Base(walker.Path()): + case <-done: return } - - dirs := make([]string, 0, len(list1)) - for _, d := range list1 { - dirs = append(dirs, d.Name()) - } - - // read files - for _, dir := range dirs { - entries, err := r.c.ReadDir(Join(basedir, dir)) - if err != nil { - continue - } - - items := make([]string, 0, len(entries)) - for _, entry := range entries { - items = append(items, entry.Name()) - } - - for _, file := range items { - select { - case ch <- file: - case <-done: - return - } - } - } - } else { - entries, err := r.c.ReadDir(r.Dirname(restic.Handle{Type: t})) - if err != nil { - return - } - - items := make([]string, 0, len(entries)) - for _, entry := range entries { - items = append(items, entry.Name()) - } - - for _, file := range items { - select { - case ch <- file: - case <-done: - return - } - } } }() From ccc201ea5fc935f344a981575d868fd971e2b2ad Mon Sep 17 00:00:00 2001 From: Alexander Neumann Date: Tue, 11 Apr 2017 20:08:14 +0200 Subject: [PATCH 36/47] remove unused code --- src/restic/backend/sftp/sftp.go | 36 -------------------------------- src/restic/backend/sftp/split.go | 2 -- 2 files changed, 38 deletions(-) diff --git a/src/restic/backend/sftp/sftp.go b/src/restic/backend/sftp/sftp.go index 297f33f29..b6f71b4c3 100644 --- a/src/restic/backend/sftp/sftp.go +++ b/src/restic/backend/sftp/sftp.go @@ -20,10 +20,6 @@ import ( "github.com/pkg/sftp" ) -const ( - tempfileRandomSuffixLength = 10 -) - // SFTP is a backend in a directory accessed via SFTP. type SFTP struct { c *sftp.Client @@ -263,38 +259,6 @@ func (r *SFTP) mkdirAll(dir string, mode os.FileMode) error { return r.c.Chmod(dir, mode) } -// Rename temp file to final name according to type and name. -func (r *SFTP) renameFile(oldname string, h restic.Handle) error { - filename := r.Filename(h) - - // create directories if necessary - if h.Type == restic.DataFile { - err := r.mkdirAll(path.Dir(filename), backend.Modes.Dir) - if err != nil { - return err - } - } - - // test if new file exists - if _, err := r.c.Lstat(filename); err == nil { - return errors.Errorf("Close(): file %v already exists", filename) - } - - err := r.c.Rename(oldname, filename) - if err != nil { - return errors.Wrap(err, "Rename") - } - - // set mode to read-only - fi, err := r.c.Lstat(filename) - if err != nil { - return errors.Wrap(err, "Lstat") - } - - err = r.c.Chmod(filename, fi.Mode()&os.FileMode(^uint32(0222))) - return errors.Wrap(err, "Chmod") -} - // Join joins the given paths and cleans them afterwards. This always uses // forward slashes, which is required by sftp. func Join(parts ...string) string { diff --git a/src/restic/backend/sftp/split.go b/src/restic/backend/sftp/split.go index 9f5174a82..dd6f68db9 100644 --- a/src/restic/backend/sftp/split.go +++ b/src/restic/backend/sftp/split.go @@ -5,8 +5,6 @@ import ( "unicode" ) -const data = `"foo" "bar" baz "test argument" another 'test arg' "last \" argument" 'another \" last argument'` - // shellSplitter splits a command string into separater arguments. It supports // single and double quoted strings. type shellSplitter struct { From 7b64b890d73544dc8f865c8e33692c3ac3d84c99 Mon Sep 17 00:00:00 2001 From: Alexander Neumann Date: Tue, 11 Apr 2017 20:08:25 +0200 Subject: [PATCH 37/47] Simplify code --- src/restic/backend/test/tests.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/restic/backend/test/tests.go b/src/restic/backend/test/tests.go index be3946296..fd32e0826 100644 --- a/src/restic/backend/test/tests.go +++ b/src/restic/backend/test/tests.go @@ -511,7 +511,7 @@ func TestBackend(t testing.TB) { // test that the blob is gone ok, err := b.Test(h) test.OK(t, err) - test.Assert(t, ok == false, "removed blob still present") + test.Assert(t, !ok, "removed blob still present") // create blob err = b.Save(h, strings.NewReader(ts.data)) @@ -553,6 +553,7 @@ func TestBackend(t testing.TB) { found, err := b.Test(h) test.OK(t, err) + test.Assert(t, found, fmt.Sprintf("id %q not found", id)) test.OK(t, b.Remove(h)) From 5eaa51eeff9f6330e3949095990b3b88743473e1 Mon Sep 17 00:00:00 2001 From: Alexander Neumann Date: Tue, 11 Apr 2017 20:08:45 +0200 Subject: [PATCH 38/47] Remove unused assignments --- src/restic/backend/utils_test.go | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/restic/backend/utils_test.go b/src/restic/backend/utils_test.go index 2996cf494..51481ed0b 100644 --- a/src/restic/backend/utils_test.go +++ b/src/restic/backend/utils_test.go @@ -49,8 +49,7 @@ func TestLoadSmallBuffer(t *testing.T) { err := b.Save(restic.Handle{Name: id.String(), Type: restic.DataFile}, bytes.NewReader(data)) OK(t, err) - buf := make([]byte, len(data)-23) - buf, err = backend.LoadAll(b, restic.Handle{Type: restic.DataFile, Name: id.String()}) + buf, err := backend.LoadAll(b, restic.Handle{Type: restic.DataFile, Name: id.String()}) OK(t, err) if len(buf) != len(data) { @@ -75,8 +74,7 @@ func TestLoadLargeBuffer(t *testing.T) { err := b.Save(restic.Handle{Name: id.String(), Type: restic.DataFile}, bytes.NewReader(data)) OK(t, err) - buf := make([]byte, len(data)+100) - buf, err = backend.LoadAll(b, restic.Handle{Type: restic.DataFile, Name: id.String()}) + buf, err := backend.LoadAll(b, restic.Handle{Type: restic.DataFile, Name: id.String()}) OK(t, err) if len(buf) != len(data) { From 7f3bcdb4cc3af460bcd20a5e38bb3d966d270816 Mon Sep 17 00:00:00 2001 From: Alexander Neumann Date: Tue, 11 Apr 2017 21:28:31 +0200 Subject: [PATCH 39/47] layout: prepare use for REST backend --- src/restic/backend/layout_cloud.go | 9 +++--- src/restic/backend/layout_s3.go | 5 +-- src/restic/backend/layout_test.go | 49 ++++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 6 deletions(-) diff --git a/src/restic/backend/layout_cloud.go b/src/restic/backend/layout_cloud.go index 6a6947c59..dc79130a2 100644 --- a/src/restic/backend/layout_cloud.go +++ b/src/restic/backend/layout_cloud.go @@ -5,6 +5,7 @@ import "restic" // CloudLayout implements the default layout for cloud storage backends, as // described in the Design document. type CloudLayout struct { + URL string Path string Join func(...string) string } @@ -13,7 +14,7 @@ var cloudLayoutPaths = defaultLayoutPaths // Dirname returns the directory path for a given file type and name. func (l *CloudLayout) Dirname(h restic.Handle) string { - return l.Join(l.Path, cloudLayoutPaths[h.Type]) + return l.URL + l.Join(l.Path, "/", cloudLayoutPaths[h.Type]) } // Filename returns a path to a file, including its name. @@ -24,18 +25,18 @@ func (l *CloudLayout) Filename(h restic.Handle) string { name = "config" } - return l.Join(l.Dirname(h), name) + return l.URL + l.Join(l.Path, "/", cloudLayoutPaths[h.Type], name) } // Paths returns all directory names func (l *CloudLayout) Paths() (dirs []string) { for _, p := range cloudLayoutPaths { - dirs = append(dirs, l.Join(l.Path, p)) + dirs = append(dirs, l.URL+l.Join(l.Path, p)) } return dirs } // Basedir returns the base dir name for files of type t. func (l *CloudLayout) Basedir(t restic.FileType) string { - return l.Join(l.Path, cloudLayoutPaths[t]) + return l.URL + l.Join(l.Path, cloudLayoutPaths[t]) } diff --git a/src/restic/backend/layout_s3.go b/src/restic/backend/layout_s3.go index 42df63c3c..d2a034a4c 100644 --- a/src/restic/backend/layout_s3.go +++ b/src/restic/backend/layout_s3.go @@ -5,6 +5,7 @@ import "restic" // S3Layout implements the old layout used for s3 cloud storage backends, as // described in the Design document. type S3Layout struct { + URL string Path string Join func(...string) string } @@ -19,7 +20,7 @@ var s3LayoutPaths = map[restic.FileType]string{ // Dirname returns the directory path for a given file type and name. func (l *S3Layout) Dirname(h restic.Handle) string { - return l.Join(l.Path, s3LayoutPaths[h.Type]) + return l.URL + l.Join(l.Path, "/", s3LayoutPaths[h.Type]) } // Filename returns a path to a file, including its name. @@ -30,7 +31,7 @@ func (l *S3Layout) Filename(h restic.Handle) string { name = "config" } - return l.Join(l.Dirname(h), name) + return l.URL + l.Join(l.Path, "/", s3LayoutPaths[h.Type], name) } // Paths returns all directory names diff --git a/src/restic/backend/layout_test.go b/src/restic/backend/layout_test.go index dc1c231b9..9b270164d 100644 --- a/src/restic/backend/layout_test.go +++ b/src/restic/backend/layout_test.go @@ -2,6 +2,7 @@ package backend import ( "fmt" + "path" "path/filepath" "reflect" "restic" @@ -146,6 +147,54 @@ func TestCloudLayout(t *testing.T) { } } +func TestCloudLayoutURLs(t *testing.T) { + var tests = []struct { + l Layout + h restic.Handle + fn string + }{ + { + &CloudLayout{URL: "https://hostname.foo", Path: "", Join: path.Join}, + restic.Handle{Type: restic.DataFile, Name: "foobar"}, + "https://hostname.foo/data/foobar", + }, + { + &CloudLayout{URL: "https://hostname.foo:1234/prefix/repo", Path: "/", Join: path.Join}, + restic.Handle{Type: restic.LockFile, Name: "foobar"}, + "https://hostname.foo:1234/prefix/repo/locks/foobar", + }, + { + &CloudLayout{URL: "https://hostname.foo:1234/prefix/repo", Path: "/", Join: path.Join}, + restic.Handle{Type: restic.ConfigFile, Name: "foobar"}, + "https://hostname.foo:1234/prefix/repo/config", + }, + { + &S3Layout{URL: "https://hostname.foo", Path: "/", Join: path.Join}, + restic.Handle{Type: restic.DataFile, Name: "foobar"}, + "https://hostname.foo/data/foobar", + }, + { + &S3Layout{URL: "https://hostname.foo:1234/prefix/repo", Path: "", Join: path.Join}, + restic.Handle{Type: restic.LockFile, Name: "foobar"}, + "https://hostname.foo:1234/prefix/repo/lock/foobar", + }, + { + &S3Layout{URL: "https://hostname.foo:1234/prefix/repo", Path: "/", Join: path.Join}, + restic.Handle{Type: restic.ConfigFile, Name: "foobar"}, + "https://hostname.foo:1234/prefix/repo/config", + }, + } + + for _, test := range tests { + t.Run("cloud", func(t *testing.T) { + res := test.l.Filename(test.h) + if res != test.fn { + t.Fatalf("wrong filename, want %v, got %v", test.fn, res) + } + }) + } +} + func TestS3Layout(t *testing.T) { path, cleanup := TempDir(t) defer cleanup() From 0da7264e751891cbc07c4ba9b2d63df85bf50ab1 Mon Sep 17 00:00:00 2001 From: Alexander Neumann Date: Tue, 11 Apr 2017 21:47:57 +0200 Subject: [PATCH 40/47] rest: Convert to Layout --- src/restic/backend/rest/rest.go | 55 +++++++++-------------- src/restic/backend/rest/rest_path_test.go | 48 -------------------- 2 files changed, 20 insertions(+), 83 deletions(-) delete mode 100644 src/restic/backend/rest/rest_path_test.go diff --git a/src/restic/backend/rest/rest.go b/src/restic/backend/rest/rest.go index 63b6a8094..773fad6bd 100644 --- a/src/restic/backend/rest/rest.go +++ b/src/restic/backend/rest/rest.go @@ -22,39 +22,11 @@ const connLimit = 40 // make sure the rest backend implements restic.Backend var _ restic.Backend = &restBackend{} -// restPath returns the path to the given resource. -func restPath(url *url.URL, h restic.Handle) string { - u := *url - - var dir string - - switch h.Type { - case restic.ConfigFile: - dir = "" - h.Name = "config" - case restic.DataFile: - dir = backend.Paths.Data - case restic.SnapshotFile: - dir = backend.Paths.Snapshots - case restic.IndexFile: - dir = backend.Paths.Index - case restic.LockFile: - dir = backend.Paths.Locks - case restic.KeyFile: - dir = backend.Paths.Keys - default: - dir = string(h.Type) - } - - u.Path = path.Join(url.Path, dir, h.Name) - - return u.String() -} - type restBackend struct { url *url.URL connChan chan struct{} client http.Client + backend.Layout } // Open opens the REST backend with the given config. @@ -66,7 +38,20 @@ func Open(cfg Config) (restic.Backend, error) { tr := &http.Transport{MaxIdleConnsPerHost: connLimit} client := http.Client{Transport: tr} - return &restBackend{url: cfg.URL, connChan: connChan, client: client}, nil + // use url without trailing slash for layout + url := cfg.URL.String() + if url[len(url)-1] == '/' { + url = url[:len(url)-1] + } + + be := &restBackend{ + url: cfg.URL, + connChan: connChan, + client: client, + Layout: &backend.CloudLayout{URL: url, Join: path.Join}, + } + + return be, nil } // Create creates a new REST on server configured in config. @@ -124,7 +109,7 @@ func (b *restBackend) Save(h restic.Handle, rd io.Reader) (err error) { rd = backend.Closer{Reader: rd} <-b.connChan - resp, err := b.client.Post(restPath(b.url, h), "binary/octet-stream", rd) + resp, err := b.client.Post(b.Filename(h), "binary/octet-stream", rd) b.connChan <- struct{}{} if resp != nil { @@ -167,7 +152,7 @@ func (b *restBackend) Load(h restic.Handle, length int, offset int64) (io.ReadCl return nil, errors.Errorf("invalid length %d", length) } - req, err := http.NewRequest("GET", restPath(b.url, h), nil) + req, err := http.NewRequest("GET", b.Filename(h), nil) if err != nil { return nil, errors.Wrap(err, "http.NewRequest") } @@ -207,7 +192,7 @@ func (b *restBackend) Stat(h restic.Handle) (restic.FileInfo, error) { } <-b.connChan - resp, err := b.client.Head(restPath(b.url, h)) + resp, err := b.client.Head(b.Filename(h)) b.connChan <- struct{}{} if err != nil { return restic.FileInfo{}, errors.Wrap(err, "client.Head") @@ -249,7 +234,7 @@ func (b *restBackend) Remove(h restic.Handle) error { return err } - req, err := http.NewRequest("DELETE", restPath(b.url, h), nil) + req, err := http.NewRequest("DELETE", b.Filename(h), nil) if err != nil { return errors.Wrap(err, "http.NewRequest") } @@ -275,7 +260,7 @@ func (b *restBackend) Remove(h restic.Handle) error { func (b *restBackend) List(t restic.FileType, done <-chan struct{}) <-chan string { ch := make(chan string) - url := restPath(b.url, restic.Handle{Type: t}) + url := b.Dirname(restic.Handle{Type: t}) if !strings.HasSuffix(url, "/") { url += "/" } diff --git a/src/restic/backend/rest/rest_path_test.go b/src/restic/backend/rest/rest_path_test.go deleted file mode 100644 index 8356abfba..000000000 --- a/src/restic/backend/rest/rest_path_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package rest - -import ( - "net/url" - "restic" - "testing" -) - -var restPathTests = []struct { - Handle restic.Handle - URL *url.URL - Result string -}{ - { - URL: parseURL("https://hostname.foo"), - Handle: restic.Handle{ - Type: restic.DataFile, - Name: "foobar", - }, - Result: "https://hostname.foo/data/foobar", - }, - { - URL: parseURL("https://hostname.foo:1234/prefix/repo"), - Handle: restic.Handle{ - Type: restic.LockFile, - Name: "foobar", - }, - Result: "https://hostname.foo:1234/prefix/repo/locks/foobar", - }, - { - URL: parseURL("https://hostname.foo:1234/prefix/repo"), - Handle: restic.Handle{ - Type: restic.ConfigFile, - Name: "foobar", - }, - Result: "https://hostname.foo:1234/prefix/repo/config", - }, -} - -func TestRESTPaths(t *testing.T) { - for i, test := range restPathTests { - result := restPath(test.URL, test.Handle) - if result != test.Result { - t.Errorf("test %d: resulting URL does not match, want:\n %#v\ngot: \n %#v", - i, test.Result, result) - } - } -} From f531ca3b483b26213cdcbd6b471875df28fa63a9 Mon Sep 17 00:00:00 2001 From: Alexander Neumann Date: Tue, 11 Apr 2017 22:04:04 +0200 Subject: [PATCH 41/47] layout: Fix corner cases --- src/restic/backend/layout_cloud.go | 6 +++++- src/restic/backend/layout_s3.go | 6 +++++- src/restic/backend/layout_test.go | 26 +++++++++++++++++++------- 3 files changed, 29 insertions(+), 9 deletions(-) diff --git a/src/restic/backend/layout_cloud.go b/src/restic/backend/layout_cloud.go index dc79130a2..6331d4709 100644 --- a/src/restic/backend/layout_cloud.go +++ b/src/restic/backend/layout_cloud.go @@ -14,7 +14,11 @@ var cloudLayoutPaths = defaultLayoutPaths // Dirname returns the directory path for a given file type and name. func (l *CloudLayout) Dirname(h restic.Handle) string { - return l.URL + l.Join(l.Path, "/", cloudLayoutPaths[h.Type]) + if h.Type == restic.ConfigFile { + return l.URL + l.Join(l.Path, "/") + } + + return l.URL + l.Join(l.Path, "/", cloudLayoutPaths[h.Type]) + "/" } // Filename returns a path to a file, including its name. diff --git a/src/restic/backend/layout_s3.go b/src/restic/backend/layout_s3.go index d2a034a4c..9db8ae7f3 100644 --- a/src/restic/backend/layout_s3.go +++ b/src/restic/backend/layout_s3.go @@ -20,7 +20,11 @@ var s3LayoutPaths = map[restic.FileType]string{ // Dirname returns the directory path for a given file type and name. func (l *S3Layout) Dirname(h restic.Handle) string { - return l.URL + l.Join(l.Path, "/", s3LayoutPaths[h.Type]) + if h.Type == restic.ConfigFile { + return l.URL + l.Join(l.Path, "/") + } + + return l.URL + l.Join(l.Path, "/", s3LayoutPaths[h.Type]) + "/" } // Filename returns a path to a file, including its name. diff --git a/src/restic/backend/layout_test.go b/src/restic/backend/layout_test.go index 9b270164d..4fee713cb 100644 --- a/src/restic/backend/layout_test.go +++ b/src/restic/backend/layout_test.go @@ -149,47 +149,59 @@ func TestCloudLayout(t *testing.T) { func TestCloudLayoutURLs(t *testing.T) { var tests = []struct { - l Layout - h restic.Handle - fn string + l Layout + h restic.Handle + fn string + dir string }{ { &CloudLayout{URL: "https://hostname.foo", Path: "", Join: path.Join}, restic.Handle{Type: restic.DataFile, Name: "foobar"}, "https://hostname.foo/data/foobar", + "https://hostname.foo/data/", }, { &CloudLayout{URL: "https://hostname.foo:1234/prefix/repo", Path: "/", Join: path.Join}, restic.Handle{Type: restic.LockFile, Name: "foobar"}, "https://hostname.foo:1234/prefix/repo/locks/foobar", + "https://hostname.foo:1234/prefix/repo/locks/", }, { &CloudLayout{URL: "https://hostname.foo:1234/prefix/repo", Path: "/", Join: path.Join}, restic.Handle{Type: restic.ConfigFile, Name: "foobar"}, "https://hostname.foo:1234/prefix/repo/config", + "https://hostname.foo:1234/prefix/repo/", }, { &S3Layout{URL: "https://hostname.foo", Path: "/", Join: path.Join}, restic.Handle{Type: restic.DataFile, Name: "foobar"}, "https://hostname.foo/data/foobar", + "https://hostname.foo/data/", }, { &S3Layout{URL: "https://hostname.foo:1234/prefix/repo", Path: "", Join: path.Join}, restic.Handle{Type: restic.LockFile, Name: "foobar"}, "https://hostname.foo:1234/prefix/repo/lock/foobar", + "https://hostname.foo:1234/prefix/repo/lock/", }, { &S3Layout{URL: "https://hostname.foo:1234/prefix/repo", Path: "/", Join: path.Join}, restic.Handle{Type: restic.ConfigFile, Name: "foobar"}, "https://hostname.foo:1234/prefix/repo/config", + "https://hostname.foo:1234/prefix/repo/", }, } for _, test := range tests { - t.Run("cloud", func(t *testing.T) { - res := test.l.Filename(test.h) - if res != test.fn { - t.Fatalf("wrong filename, want %v, got %v", test.fn, res) + t.Run(fmt.Sprintf("%T", test.l), func(t *testing.T) { + fn := test.l.Filename(test.h) + if fn != test.fn { + t.Fatalf("wrong filename, want %v, got %v", test.fn, fn) + } + + dir := test.l.Dirname(test.h) + if dir != test.dir { + t.Fatalf("wrong dirname, want %v, got %v", test.dir, dir) } }) } From 541484d142a1da0301d650f5aa00e691e3ffa3fc Mon Sep 17 00:00:00 2001 From: Alexander Neumann Date: Tue, 11 Apr 2017 22:04:18 +0200 Subject: [PATCH 42/47] s3: Use Layout --- src/restic/backend/s3/s3.go | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/src/restic/backend/s3/s3.go b/src/restic/backend/s3/s3.go index 684588164..f59a92a15 100644 --- a/src/restic/backend/s3/s3.go +++ b/src/restic/backend/s3/s3.go @@ -27,6 +27,7 @@ type s3 struct { prefix string cacheMutex sync.RWMutex cacheObjSize map[string]int64 + backend.Layout } // Open opens the S3 backend at bucket and region. The bucket is created if it @@ -44,6 +45,7 @@ func Open(cfg Config) (restic.Backend, error) { bucketname: cfg.Bucket, prefix: cfg.Prefix, cacheObjSize: make(map[string]int64), + Layout: &backend.S3Layout{URL: cfg.Endpoint, Join: path.Join}, } tr := &http.Transport{MaxIdleConnsPerHost: connLimit} @@ -68,13 +70,6 @@ func Open(cfg Config) (restic.Backend, error) { return be, nil } -func (be *s3) s3path(h restic.Handle) string { - if h.Type == restic.ConfigFile { - return path.Join(be.prefix, string(h.Type)) - } - return path.Join(be.prefix, string(h.Type), h.Name) -} - func (be *s3) createConnections() { be.connChan = make(chan struct{}, connLimit) for i := 0; i < connLimit; i++ { @@ -95,7 +90,7 @@ func (be *s3) Save(h restic.Handle, rd io.Reader) (err error) { debug.Log("Save %v", h) - objName := be.s3path(h) + objName := be.Filename(h) // Check key does not already exist _, err = be.client.StatObject(be.bucketname, objName) @@ -149,7 +144,7 @@ func (be *s3) Load(h restic.Handle, length int, offset int64) (io.ReadCloser, er var obj *minio.Object var size int64 - objName := be.s3path(h) + objName := be.Filename(h) // get token for connection <-be.connChan @@ -242,7 +237,7 @@ func (be *s3) Load(h restic.Handle, length int, offset int64) (io.ReadCloser, er func (be *s3) Stat(h restic.Handle) (bi restic.FileInfo, err error) { debug.Log("%v", h) - objName := be.s3path(h) + objName := be.Filename(h) var obj *minio.Object obj, err = be.client.GetObject(be.bucketname, objName) @@ -271,7 +266,7 @@ func (be *s3) Stat(h restic.Handle) (bi restic.FileInfo, err error) { // Test returns true if a blob of the given type and name exists in the backend. func (be *s3) Test(h restic.Handle) (bool, error) { found := false - objName := be.s3path(h) + objName := be.Filename(h) _, err := be.client.StatObject(be.bucketname, objName) if err == nil { found = true @@ -283,7 +278,7 @@ func (be *s3) Test(h restic.Handle) (bool, error) { // Remove removes the blob with the given name and type. func (be *s3) Remove(h restic.Handle) error { - objName := be.s3path(h) + objName := be.Filename(h) err := be.client.RemoveObject(be.bucketname, objName) debug.Log("Remove(%v) -> err %v", h, err) return errors.Wrap(err, "client.RemoveObject") @@ -296,7 +291,7 @@ func (be *s3) List(t restic.FileType, done <-chan struct{}) <-chan string { debug.Log("listing %v", t) ch := make(chan string) - prefix := be.s3path(restic.Handle{Type: t}) + "/" + prefix := be.Dirname(restic.Handle{Type: t}) listresp := be.client.ListObjects(be.bucketname, prefix, true, done) From b7671dafc87836f4db5b4875d28b692f40477ef2 Mon Sep 17 00:00:00 2001 From: Alexander Neumann Date: Thu, 13 Apr 2017 23:55:20 +0200 Subject: [PATCH 43/47] options: Allow registering --- src/restic/options/options.go | 75 ++++++++++++++++++++++++ src/restic/options/options_test.go | 92 ++++++++++++++++++++++++++++++ 2 files changed, 167 insertions(+) diff --git a/src/restic/options/options.go b/src/restic/options/options.go index c5d9ff3e3..03d06c32d 100644 --- a/src/restic/options/options.go +++ b/src/restic/options/options.go @@ -3,6 +3,7 @@ package options import ( "reflect" "restic/errors" + "sort" "strconv" "strings" "time" @@ -11,6 +12,80 @@ import ( // Options holds options in the form key=value. type Options map[string]string +var opts []Help + +// Register allows registering options so that they can be listed with List. +func Register(ns string, cfg interface{}) { + opts = appendAllOptions(opts, ns, cfg) +} + +// List returns a list of all registered options (using Register()). +func List() (list []Help) { + list = make([]Help, 0, len(opts)) + for _, opt := range opts { + list = append(list, opt) + } + return list +} + +// appendAllOptions appends all options in cfg to opts, sorted by namespace. +func appendAllOptions(opts []Help, ns string, cfg interface{}) []Help { + for _, opt := range listOptions(cfg) { + opt.Namespace = ns + opts = append(opts, opt) + } + return opts +} + +// listOptions returns a list of options of cfg. +func listOptions(cfg interface{}) (opts []Help) { + // resolve indirection if cfg is a pointer + v := reflect.Indirect(reflect.ValueOf(cfg)) + + for i := 0; i < v.NumField(); i++ { + f := v.Type().Field(i) + + h := Help{ + Name: f.Tag.Get("option"), + Text: f.Tag.Get("help"), + } + + if h.Name == "" { + continue + } + + opts = append(opts, h) + } + + sort.Sort(helpList(opts)) + return opts +} + +// Help contains information about an option. +type Help struct { + Namespace string + Name string + Text string +} + +type helpList []Help + +// Len is the number of elements in the collection. +func (h helpList) Len() int { + return len(h) +} + +// Less reports whether the element with +// index i should sort before the element with index j. +func (h helpList) Less(i, j int) bool { + return h[i].Namespace < h[j].Namespace +} + +// Swap swaps the elements with indexes i and j. +func (h helpList) Swap(i, j int) { + h[i], h[j] = h[j], h[i] +} + // splitKeyValue splits at the first equals (=) sign. func splitKeyValue(s string) (key string, value string) { data := strings.SplitN(s, "=", 2) diff --git a/src/restic/options/options_test.go b/src/restic/options/options_test.go index a5ab83952..7b9eb7db8 100644 --- a/src/restic/options/options_test.go +++ b/src/restic/options/options_test.go @@ -218,3 +218,95 @@ func TestOptionsApplyInvalid(t *testing.T) { }) } } + +func TestListOptions(t *testing.T) { + var teststruct = struct { + Foo string `option:"foo" help:"bar text help"` + }{} + + var tests = []struct { + cfg interface{} + opts []Help + }{ + { + struct { + Foo string `option:"foo" help:"bar text help"` + }{}, + []Help{ + Help{Name: "foo", Text: "bar text help"}, + }, + }, + { + struct { + Foo string `option:"foo" help:"bar text help"` + Bar string `option:"bar" help:"bar text help"` + }{}, + []Help{ + Help{Name: "foo", Text: "bar text help"}, + Help{Name: "bar", Text: "bar text help"}, + }, + }, + { + struct { + Bar string `option:"bar" help:"bar text help"` + Foo string `option:"foo" help:"bar text help"` + }{}, + []Help{ + Help{Name: "bar", Text: "bar text help"}, + Help{Name: "foo", Text: "bar text help"}, + }, + }, + { + &teststruct, + []Help{ + Help{Name: "foo", Text: "bar text help"}, + }, + }, + } + + for _, test := range tests { + t.Run("", func(t *testing.T) { + opts := listOptions(test.cfg) + if !reflect.DeepEqual(opts, test.opts) { + t.Fatalf("wrong opts, want:\n %v\ngot:\n %v", test.opts, opts) + } + }) + } +} + +func TestAppendAllOptions(t *testing.T) { + var tests = []struct { + cfgs map[string]interface{} + opts []Help + }{ + { + map[string]interface{}{ + "local": struct { + Foo string `option:"foo" help:"bar text help"` + }{}, + "sftp": struct { + Foo string `option:"foo" help:"bar text help2"` + Bar string `option:"bar" help:"bar text help"` + }{}, + }, + []Help{ + Help{Namespace: "local", Name: "foo", Text: "bar text help"}, + Help{Namespace: "sftp", Name: "foo", Text: "bar text help2"}, + Help{Namespace: "sftp", Name: "bar", Text: "bar text help"}, + }, + }, + } + + for _, test := range tests { + t.Run("", func(t *testing.T) { + var opts []Help + for ns, cfg := range test.cfgs { + opts = appendAllOptions(opts, ns, cfg) + } + + if !reflect.DeepEqual(opts, test.opts) { + t.Fatalf("wrong list, want:\n %v\ngot:\n %v", test.opts, opts) + } + }) + } +} From 859ee23d2ee62cdb1a22c66a3bb43c50bf28555f Mon Sep 17 00:00:00 2001 From: Alexander Neumann Date: Thu, 13 Apr 2017 23:55:32 +0200 Subject: [PATCH 44/47] options: Register local and sftp backends --- src/restic/backend/local/config.go | 7 ++++++- src/restic/backend/sftp/config.go | 9 +++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/restic/backend/local/config.go b/src/restic/backend/local/config.go index 16fdc3752..e693d8c9e 100644 --- a/src/restic/backend/local/config.go +++ b/src/restic/backend/local/config.go @@ -4,12 +4,17 @@ import ( "strings" "restic/errors" + "restic/options" ) // Config holds all information needed to open a local repository. type Config struct { Path string - Layout string `option:"layout"` + Layout string `option:"layout" help:"use this backend directory layout (default: auto-detect)"` +} + +func init() { + options.Register("local", Config{}) } // ParseConfig parses a local backend config. diff --git a/src/restic/backend/sftp/config.go b/src/restic/backend/sftp/config.go index 4321e71ad..1ba3dcbbd 100644 --- a/src/restic/backend/sftp/config.go +++ b/src/restic/backend/sftp/config.go @@ -6,13 +6,18 @@ import ( "strings" "restic/errors" + "restic/options" ) // Config collects all information required to connect to an sftp server. type Config struct { User, Host, Path string - Layout string `option:"layout"` - Command string `option:"command"` + Layout string `option:"layout" help:"use this backend directory layout (default: auto-detect)"` + Command string `option:"command" help:"specify command to create sftp connection"` +} + +func init() { + options.Register("sftp", Config{}) } // ParseConfig parses the string s and extracts the sftp config. The From a634c22ae0840016c1a9d6f89f768500f7adbf97 Mon Sep 17 00:00:00 2001 From: Alexander Neumann Date: Thu, 13 Apr 2017 23:55:49 +0200 Subject: [PATCH 45/47] Add hidden 'options' command to list all opts --- src/cmds/restic/cmd_options.go | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 src/cmds/restic/cmd_options.go diff --git a/src/cmds/restic/cmd_options.go b/src/cmds/restic/cmd_options.go new file mode 100644 index 000000000..ba78941b0 --- /dev/null +++ b/src/cmds/restic/cmd_options.go @@ -0,0 +1,27 @@ +package main + +import ( + "fmt" + "restic/options" + + "github.com/spf13/cobra" +) + +var optionsCmd = &cobra.Command{ + Use: "options", + Short: "print list of extended options", + Long: ` +The "options" command prints a list of extended options. +`, + Hidden: true, + Run: func(cmd *cobra.Command, args []string) { + fmt.Printf("All Extended Options:\n") + for _, opt := range options.List() { + fmt.Printf(" %-15s %s\n", opt.Namespace+"."+opt.Name, opt.Text) + } + }, +} + +func init() { + cmdRoot.AddCommand(optionsCmd) +} From 55bdc1fa16d88abe02c8d14a51f11c17e0ead01c Mon Sep 17 00:00:00 2001 From: Alexander Neumann Date: Fri, 14 Apr 2017 00:14:11 +0200 Subject: [PATCH 46/47] Add documentation for new options --- doc/Design.md | 8 +++++--- doc/Manual.md | 4 ++++ doc/code.css | 2 +- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/doc/Design.md b/doc/Design.md index b2ec7db47..ff4d5176e 100644 --- a/doc/Design.md +++ b/doc/Design.md @@ -110,9 +110,11 @@ A local repository can be initialized with the `restic init` command, e.g.: $ restic -r /tmp/restic-repo init ``` -The local backend will also accept the repository layout described in the -following section, so that remote repositories mounted locally e.g. via fuse -can be accessed. +The local and sftp backends will also accept the repository layout described in +the following section, so that remote repositories mounted locally e.g. via +fuse can be accessed. The layout auto-detection can be overridden by specifying +the option `-o local.layout=default`, valid values are `default`, `cloud` and +`s3`. The option for the sftp backend is named `sftp.layout`. Object-Storage-Based Repositories --------------------------------- diff --git a/doc/Manual.md b/doc/Manual.md index bbf2e7714..2d841cf94 100644 --- a/doc/Manual.md +++ b/doc/Manual.md @@ -551,6 +551,10 @@ Then use it in the backend specification: $ restic -r sftp:restic-backup-host:/tmp/backup init ``` +Last, if you'd like to use an entirely different program to create the SFTP +connection, you can specify the command to be run with the option +`-o sftp.command="foobar"`. + # Create a REST server repository In order to backup data to the remote server via HTTP or HTTPS protocol, diff --git a/doc/code.css b/doc/code.css index 2a73b3a93..9f19a2c1d 100644 --- a/doc/code.css +++ b/doc/code.css @@ -1,4 +1,4 @@ -code { +code, pre { font-size: 90%; } From be06983c80711e16b64e0485061bb2405b84761a Mon Sep 17 00:00:00 2001 From: Alexander Neumann Date: Fri, 14 Apr 2017 00:45:54 +0200 Subject: [PATCH 47/47] options: Fix sorting (and test) --- src/restic/options/options.go | 7 ++++++- src/restic/options/options_test.go | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/restic/options/options.go b/src/restic/options/options.go index 03d06c32d..55ef59609 100644 --- a/src/restic/options/options.go +++ b/src/restic/options/options.go @@ -34,6 +34,8 @@ func appendAllOptions(opts []Help, ns string, cfg interface{}) []Help { opt.Namespace = ns opts = append(opts, opt) } + + sort.Sort(helpList(opts)) return opts } @@ -57,7 +59,6 @@ func listOptions(cfg interface{}) (opts []Help) { opts = append(opts, h) } - sort.Sort(helpList(opts)) return opts } @@ -78,6 +79,10 @@ func (h helpList) Len() int { // Less reports whether the element with // index i should sort before the element with index j. func (h helpList) Less(i, j int) bool { + if h[i].Namespace == h[j].Namespace { + return h[i].Name < h[j].Name + } + return h[i].Namespace < h[j].Namespace } diff --git a/src/restic/options/options_test.go b/src/restic/options/options_test.go index 7b9eb7db8..f5d43b9a7 100644 --- a/src/restic/options/options_test.go +++ b/src/restic/options/options_test.go @@ -291,8 +291,8 @@ func TestAppendAllOptions(t *testing.T) { }, []Help{ Help{Namespace: "local", Name: "foo", Text: "bar text help"}, - Help{Namespace: "sftp", Name: "foo", Text: "bar text help2"}, Help{Namespace: "sftp", Name: "bar", Text: "bar text help"}, + Help{Namespace: "sftp", Name: "foo", Text: "bar text help2"}, }, }, }