diff --git a/src/cmds/restic/cmd_init.go b/src/cmds/restic/cmd_init.go index d134e9376..ffa9cf272 100644 --- a/src/cmds/restic/cmd_init.go +++ b/src/cmds/restic/cmd_init.go @@ -27,7 +27,7 @@ func runInit(gopts GlobalOptions, args []string) error { return errors.Fatal("Please specify repository location (-r)") } - be, err := create(gopts.Repo) + be, err := create(gopts.Repo, gopts.extended) if err != nil { return errors.Fatalf("create backend at %s failed: %v\n", gopts.Repo, err) } diff --git a/src/cmds/restic/global.go b/src/cmds/restic/global.go index ae3b59ecb..45b41cf33 100644 --- a/src/cmds/restic/global.go +++ b/src/cmds/restic/global.go @@ -12,11 +12,12 @@ import ( "syscall" "restic/backend/local" + "restic/backend/location" "restic/backend/rest" "restic/backend/s3" "restic/backend/sftp" "restic/debug" - "restic/location" + "restic/options" "restic/repository" "restic/errors" @@ -38,6 +39,10 @@ type GlobalOptions struct { password string stdout io.Writer stderr io.Writer + + Options []string + + extended options.Options } var globalOptions = GlobalOptions{ @@ -65,6 +70,8 @@ func init() { f.BoolVar(&globalOptions.NoLock, "no-lock", false, "do not lock the repo, this allows some operations on read-only repos") f.BoolVarP(&globalOptions.JSON, "json", "", false, "set output mode to JSON for commands that support it") + f.StringSliceVarP(&globalOptions.Options, "option", "o", []string{}, "set extended option (`key=value`, can be specified multiple times)") + restoreTerminal() } @@ -287,7 +294,7 @@ func OpenRepository(opts GlobalOptions) (*repository.Repository, error) { return nil, errors.Fatal("Please specify repository location (-r)") } - be, err := open(opts.Repo) + be, err := open(opts.Repo, opts.extended) if err != nil { return nil, err } @@ -309,8 +316,61 @@ func OpenRepository(opts GlobalOptions) (*repository.Repository, error) { return s, nil } +func parseConfig(loc location.Location, opts options.Options) (interface{}, error) { + // only apply options for a particular backend here + opts = opts.Extract(loc.Scheme) + + switch loc.Scheme { + case "local": + cfg := loc.Config.(local.Config) + if err := opts.Apply(loc.Scheme, &cfg); err != nil { + return nil, err + } + + debug.Log("opening local repository at %#v", cfg) + return cfg, nil + + case "sftp": + cfg := loc.Config.(sftp.Config) + if err := opts.Apply(loc.Scheme, &cfg); err != nil { + return nil, err + } + + debug.Log("opening sftp repository at %#v", cfg) + return cfg, nil + + case "s3": + cfg := loc.Config.(s3.Config) + if cfg.KeyID == "" { + cfg.KeyID = os.Getenv("AWS_ACCESS_KEY_ID") + } + + if cfg.Secret == "" { + cfg.Secret = os.Getenv("AWS_SECRET_ACCESS_KEY") + } + + if err := opts.Apply(loc.Scheme, &cfg); err != nil { + return nil, err + } + + debug.Log("opening s3 repository at %#v", cfg) + return cfg, nil + + case "rest": + cfg := loc.Config.(rest.Config) + if err := opts.Apply(loc.Scheme, &cfg); err != nil { + return nil, err + } + + debug.Log("opening rest repository at %#v", cfg) + return cfg, nil + } + + return nil, errors.Fatalf("invalid backend: %q", loc.Scheme) +} + // Open the backend specified by a location config. -func open(s string) (restic.Backend, error) { +func open(s string, opts options.Options) (restic.Backend, error) { debug.Log("parsing location %v", s) loc, err := location.Parse(s) if err != nil { @@ -319,27 +379,21 @@ func open(s string) (restic.Backend, error) { var be restic.Backend + cfg, err := parseConfig(loc, opts) + if err != nil { + return nil, err + } + switch loc.Scheme { case "local": - debug.Log("opening local repository at %#v", loc.Config) - be, err = local.Open(loc.Config.(string)) + be, err = local.Open(cfg.(local.Config)) case "sftp": - debug.Log("opening sftp repository at %#v", loc.Config) - be, err = sftp.OpenWithConfig(loc.Config.(sftp.Config)) + be, err = sftp.OpenWithConfig(cfg.(sftp.Config)) case "s3": - cfg := loc.Config.(s3.Config) - if cfg.KeyID == "" { - cfg.KeyID = os.Getenv("AWS_ACCESS_KEY_ID") - - } - if cfg.Secret == "" { - cfg.Secret = os.Getenv("AWS_SECRET_ACCESS_KEY") - } - - debug.Log("opening s3 repository at %#v", cfg) - be, err = s3.Open(cfg) + be, err = s3.Open(cfg.(s3.Config)) case "rest": - be, err = rest.Open(loc.Config.(rest.Config)) + be, err = rest.Open(cfg.(rest.Config)) + default: return nil, errors.Fatalf("invalid backend: %q", loc.Scheme) } @@ -352,34 +406,27 @@ func open(s string) (restic.Backend, error) { } // Create the backend specified by URI. -func create(s string) (restic.Backend, error) { +func create(s string, opts options.Options) (restic.Backend, error) { debug.Log("parsing location %v", s) loc, err := location.Parse(s) if err != nil { return nil, err } + cfg, err := parseConfig(loc, opts) + if err != nil { + return nil, err + } + switch loc.Scheme { case "local": - debug.Log("create local repository at %#v", loc.Config) - return local.Create(loc.Config.(string)) + return local.Create(cfg.(local.Config)) case "sftp": - debug.Log("create sftp repository at %#v", loc.Config) - return sftp.CreateWithConfig(loc.Config.(sftp.Config)) + return sftp.CreateWithConfig(cfg.(sftp.Config)) case "s3": - cfg := loc.Config.(s3.Config) - if cfg.KeyID == "" { - cfg.KeyID = os.Getenv("AWS_ACCESS_KEY_ID") - - } - if cfg.Secret == "" { - cfg.Secret = os.Getenv("AWS_SECRET_ACCESS_KEY") - } - - debug.Log("create s3 repository at %#v", loc.Config) - return s3.Open(cfg) + return s3.Open(cfg.(s3.Config)) case "rest": - return rest.Create(loc.Config.(rest.Config)) + return rest.Create(cfg.(rest.Config)) } debug.Log("invalid repository scheme: %v", s) diff --git a/src/cmds/restic/main.go b/src/cmds/restic/main.go index 03f7f9035..96a508da9 100644 --- a/src/cmds/restic/main.go +++ b/src/cmds/restic/main.go @@ -5,6 +5,7 @@ import ( "os" "restic" "restic/debug" + "restic/options" "github.com/spf13/cobra" @@ -22,10 +23,21 @@ directories in an encrypted repository stored on different backends. SilenceErrors: true, SilenceUsage: true, - // run the debug functions for all subcommands (if build tag "debug" is - // enabled) PersistentPreRunE: func(*cobra.Command, []string) error { - return runDebug() + // parse extended options + opts, err := options.Parse(globalOptions.Options) + if err != nil { + return err + } + globalOptions.extended = opts + + // run the debug functions for all subcommands (if build tag "debug" is + // enabled) + if err := runDebug(); err != nil { + return err + } + + return nil }, PersistentPostRun: func(*cobra.Command, []string) { shutdownDebug() diff --git a/src/restic/backend/local/config.go b/src/restic/backend/local/config.go index 8a5c67a2c..746accd27 100644 --- a/src/restic/backend/local/config.go +++ b/src/restic/backend/local/config.go @@ -6,11 +6,16 @@ import ( "restic/errors" ) +// Config holds all information needed to open a local repository. +type Config struct { + Path string +} + // ParseConfig parses a local backend config. func ParseConfig(cfg string) (interface{}, error) { if !strings.HasPrefix(cfg, "local:") { return nil, errors.New(`invalid format, prefix "local" not found`) } - return cfg[6:], nil + return Config{Path: cfg[6:]}, nil } diff --git a/src/restic/backend/local/local.go b/src/restic/backend/local/local.go index 03d812d99..510569654 100644 --- a/src/restic/backend/local/local.go +++ b/src/restic/backend/local/local.go @@ -16,7 +16,7 @@ import ( // Local is a backend in a local directory. type Local struct { - p string + Config } var _ restic.Backend = &Local{} @@ -34,28 +34,28 @@ func paths(dir string) []string { } // Open opens the local backend as specified by config. -func Open(dir string) (*Local, error) { +func Open(cfg Config) (*Local, error) { // test if all necessary dirs are there - for _, d := range paths(dir) { + for _, d := range paths(cfg.Path) { if _, err := fs.Stat(d); err != nil { return nil, errors.Wrap(err, "Open") } } - return &Local{p: dir}, nil + return &Local{Config: cfg}, 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(dir string) (*Local, error) { +func Create(cfg Config) (*Local, error) { // test if config file already exists - _, err := fs.Lstat(filepath.Join(dir, backend.Paths.Config)) + _, err := fs.Lstat(filepath.Join(cfg.Path, backend.Paths.Config)) if err == nil { return nil, errors.New("config file already exists") } // create paths for data, refs and temp - for _, d := range paths(dir) { + for _, d := range paths(cfg.Path) { err := fs.MkdirAll(d, backend.Modes.Dir) if err != nil { return nil, errors.Wrap(err, "MkdirAll") @@ -63,12 +63,12 @@ func Create(dir string) (*Local, error) { } // open backend - return Open(dir) + return Open(cfg) } // Location returns this backend's location (the directory name). func (b *Local) Location() string { - return b.p + return b.Path } // Construct path for given Type and name. @@ -132,13 +132,13 @@ func (b *Local) Save(h restic.Handle, rd io.Reader) (err error) { return err } - tmpfile, err := copyToTempfile(filepath.Join(b.p, backend.Paths.Temp), rd) + 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.p, h.Type, h.Name) + filename := filename(b.Path, h.Type, h.Name) // test if new path already exists if _, err := fs.Stat(filename); err == nil { @@ -183,7 +183,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.p, h.Type, h.Name)) + f, err := os.Open(filename(b.Path, h.Type, h.Name)) if err != nil { return nil, err } @@ -210,7 +210,7 @@ func (b *Local) Stat(h restic.Handle) (restic.FileInfo, error) { return restic.FileInfo{}, err } - fi, err := fs.Stat(filename(b.p, h.Type, h.Name)) + fi, err := fs.Stat(filename(b.Path, h.Type, h.Name)) if err != nil { return restic.FileInfo{}, errors.Wrap(err, "Stat") } @@ -221,7 +221,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.p, h.Type, h.Name)) + _, err := fs.Stat(filename(b.Path, h.Type, h.Name)) if err != nil { if os.IsNotExist(errors.Cause(err)) { return false, nil @@ -235,7 +235,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.p, h.Type, h.Name) + fn := filename(b.Path, h.Type, h.Name) // reset read-only flag err := fs.Chmod(fn, 0666) @@ -316,7 +316,7 @@ func (b *Local) List(t restic.FileType, done <-chan struct{}) <-chan string { } ch := make(chan string) - items, err := lister(filepath.Join(dirname(b.p, t, ""))) + items, err := lister(filepath.Join(dirname(b.Path, t, ""))) if err != nil { close(ch) return ch @@ -343,7 +343,7 @@ func (b *Local) List(t restic.FileType, done <-chan struct{}) <-chan string { // Delete removes the repository and all files. func (b *Local) Delete() error { debug.Log("Delete()") - return fs.RemoveAll(b.p) + return fs.RemoveAll(b.Path) } // Close closes all open files. diff --git a/src/restic/backend/local/local_test.go b/src/restic/backend/local/local_test.go index 3bae88753..7e7440561 100644 --- a/src/restic/backend/local/local_test.go +++ b/src/restic/backend/local/local_test.go @@ -35,7 +35,7 @@ func init() { if err != nil { return nil, err } - return local.Create(tempBackendDir) + return local.Create(local.Config{Path: tempBackendDir}) } test.OpenFn = func() (restic.Backend, error) { @@ -43,7 +43,7 @@ func init() { if err != nil { return nil, err } - return local.Open(tempBackendDir) + return local.Open(local.Config{Path: tempBackendDir}) } test.CleanupFn = func() error { diff --git a/src/restic/location/location.go b/src/restic/backend/location/location.go similarity index 100% rename from src/restic/location/location.go rename to src/restic/backend/location/location.go diff --git a/src/restic/backend/location/location_test.go b/src/restic/backend/location/location_test.go new file mode 100644 index 000000000..fe07ee506 --- /dev/null +++ b/src/restic/backend/location/location_test.go @@ -0,0 +1,227 @@ +package location + +import ( + "net/url" + "reflect" + "testing" + + "restic/backend/local" + "restic/backend/rest" + "restic/backend/s3" + "restic/backend/sftp" +) + +func parseURL(s string) *url.URL { + u, err := url.Parse(s) + if err != nil { + panic(err) + } + + return u +} + +var parseTests = []struct { + s string + u Location +}{ + { + "local:/srv/repo", + Location{Scheme: "local", + Config: local.Config{ + Path: "/srv/repo", + }, + }, + }, + { + "local:dir1/dir2", + Location{Scheme: "local", + Config: local.Config{ + Path: "dir1/dir2", + }, + }, + }, + { + "local:dir1/dir2", + Location{Scheme: "local", + Config: local.Config{ + Path: "dir1/dir2", + }, + }, + }, + { + "dir1/dir2", + Location{Scheme: "local", + Config: local.Config{ + Path: "dir1/dir2", + }, + }, + }, + { + "local:../dir1/dir2", + Location{Scheme: "local", + Config: local.Config{ + Path: "../dir1/dir2", + }, + }, + }, + { + "/dir1/dir2", + Location{Scheme: "local", + Config: local.Config{ + Path: "/dir1/dir2", + }, + }, + }, + + { + "sftp:user@host:/srv/repo", + Location{Scheme: "sftp", + Config: sftp.Config{ + User: "user", + Host: "host", + Dir: "/srv/repo", + }, + }, + }, + { + "sftp:host:/srv/repo", + Location{Scheme: "sftp", + Config: sftp.Config{ + User: "", + Host: "host", + Dir: "/srv/repo", + }, + }, + }, + { + "sftp://user@host/srv/repo", + Location{Scheme: "sftp", + Config: sftp.Config{ + User: "user", + Host: "host", + Dir: "srv/repo", + }, + }, + }, + { + "sftp://user@host//srv/repo", + Location{Scheme: "sftp", + Config: sftp.Config{ + User: "user", + Host: "host", + Dir: "/srv/repo", + }, + }, + }, + + { + "s3://eu-central-1/bucketname", + Location{Scheme: "s3", + Config: s3.Config{ + Endpoint: "eu-central-1", + Bucket: "bucketname", + Prefix: "restic", + }, + }, + }, + { + "s3://hostname.foo/bucketname", + Location{Scheme: "s3", + Config: s3.Config{ + Endpoint: "hostname.foo", + Bucket: "bucketname", + Prefix: "restic", + }, + }, + }, + { + "s3://hostname.foo/bucketname/prefix/directory", + Location{Scheme: "s3", + Config: s3.Config{ + Endpoint: "hostname.foo", + Bucket: "bucketname", + Prefix: "prefix/directory", + }, + }, + }, + { + "s3:eu-central-1/repo", + Location{Scheme: "s3", + Config: s3.Config{ + Endpoint: "eu-central-1", + Bucket: "repo", + Prefix: "restic", + }, + }, + }, + { + "s3:eu-central-1/repo/prefix/directory", + Location{Scheme: "s3", + Config: s3.Config{ + Endpoint: "eu-central-1", + Bucket: "repo", + Prefix: "prefix/directory", + }, + }, + }, + { + "s3:https://hostname.foo/repo", + Location{Scheme: "s3", + Config: s3.Config{ + Endpoint: "hostname.foo", + Bucket: "repo", + Prefix: "restic", + }, + }, + }, + { + "s3:https://hostname.foo/repo/prefix/directory", + Location{Scheme: "s3", + Config: s3.Config{ + Endpoint: "hostname.foo", + Bucket: "repo", + Prefix: "prefix/directory", + }, + }, + }, + { + "s3:http://hostname.foo/repo", + Location{Scheme: "s3", + Config: s3.Config{ + Endpoint: "hostname.foo", + Bucket: "repo", + Prefix: "restic", + UseHTTP: true, + }, + }, + }, + { + "rest:http://hostname.foo:1234/", + Location{Scheme: "rest", + Config: rest.Config{ + URL: parseURL("http://hostname.foo:1234/"), + }, + }, + }, +} + +func TestParse(t *testing.T) { + for i, test := range parseTests { + t.Run(test.s, func(t *testing.T) { + u, err := Parse(test.s) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if test.u.Scheme != u.Scheme { + t.Errorf("test %d: scheme does not match, want %q, got %q", + i, test.u.Scheme, u.Scheme) + } + + if !reflect.DeepEqual(test.u.Config, u.Config) { + t.Errorf("test %d: cfg map does not match, want:\n %#v\ngot: \n %#v", + i, test.u.Config, u.Config) + } + }) + } +} diff --git a/src/restic/location/location_test.go b/src/restic/location/location_test.go deleted file mode 100644 index bb4ac64c9..000000000 --- a/src/restic/location/location_test.go +++ /dev/null @@ -1,140 +0,0 @@ -package location - -import ( - "net/url" - "reflect" - "testing" - - "restic/backend/rest" - "restic/backend/s3" - "restic/backend/sftp" -) - -func parseURL(s string) *url.URL { - u, err := url.Parse(s) - if err != nil { - panic(err) - } - - return u -} - -var parseTests = []struct { - s string - u Location -}{ - {"local:/srv/repo", Location{Scheme: "local", Config: "/srv/repo"}}, - {"local:dir1/dir2", Location{Scheme: "local", Config: "dir1/dir2"}}, - {"local:dir1/dir2", Location{Scheme: "local", Config: "dir1/dir2"}}, - {"dir1/dir2", Location{Scheme: "local", Config: "dir1/dir2"}}, - {"local:../dir1/dir2", Location{Scheme: "local", Config: "../dir1/dir2"}}, - {"/dir1/dir2", Location{Scheme: "local", Config: "/dir1/dir2"}}, - - {"sftp:user@host:/srv/repo", Location{Scheme: "sftp", - Config: sftp.Config{ - User: "user", - Host: "host", - Dir: "/srv/repo", - }}}, - {"sftp:host:/srv/repo", Location{Scheme: "sftp", - Config: sftp.Config{ - User: "", - Host: "host", - Dir: "/srv/repo", - }}}, - {"sftp://user@host/srv/repo", Location{Scheme: "sftp", - Config: sftp.Config{ - User: "user", - Host: "host", - Dir: "srv/repo", - }}}, - {"sftp://user@host//srv/repo", Location{Scheme: "sftp", - Config: sftp.Config{ - User: "user", - Host: "host", - Dir: "/srv/repo", - }}}, - - {"s3://eu-central-1/bucketname", Location{Scheme: "s3", - Config: s3.Config{ - Endpoint: "eu-central-1", - Bucket: "bucketname", - Prefix: "restic", - }}, - }, - {"s3://hostname.foo/bucketname", Location{Scheme: "s3", - Config: s3.Config{ - Endpoint: "hostname.foo", - Bucket: "bucketname", - Prefix: "restic", - }}, - }, - {"s3://hostname.foo/bucketname/prefix/directory", Location{Scheme: "s3", - Config: s3.Config{ - Endpoint: "hostname.foo", - Bucket: "bucketname", - Prefix: "prefix/directory", - }}, - }, - {"s3:eu-central-1/repo", Location{Scheme: "s3", - Config: s3.Config{ - Endpoint: "eu-central-1", - Bucket: "repo", - Prefix: "restic", - }}, - }, - {"s3:eu-central-1/repo/prefix/directory", Location{Scheme: "s3", - Config: s3.Config{ - Endpoint: "eu-central-1", - Bucket: "repo", - Prefix: "prefix/directory", - }}, - }, - {"s3:https://hostname.foo/repo", Location{Scheme: "s3", - Config: s3.Config{ - Endpoint: "hostname.foo", - Bucket: "repo", - Prefix: "restic", - }}, - }, - {"s3:https://hostname.foo/repo/prefix/directory", Location{Scheme: "s3", - Config: s3.Config{ - Endpoint: "hostname.foo", - Bucket: "repo", - Prefix: "prefix/directory", - }}, - }, - {"s3:http://hostname.foo/repo", Location{Scheme: "s3", - Config: s3.Config{ - Endpoint: "hostname.foo", - Bucket: "repo", - Prefix: "restic", - UseHTTP: true, - }}, - }, - {"rest:http://hostname.foo:1234/", Location{Scheme: "rest", - Config: rest.Config{ - URL: parseURL("http://hostname.foo:1234/"), - }}, - }, -} - -func TestParse(t *testing.T) { - for i, test := range parseTests { - u, err := Parse(test.s) - if err != nil { - t.Errorf("unexpected error: %v", err) - continue - } - - if test.u.Scheme != u.Scheme { - t.Errorf("test %d: scheme does not match, want %q, got %q", - i, test.u.Scheme, u.Scheme) - } - - if !reflect.DeepEqual(test.u.Config, u.Config) { - t.Errorf("test %d: cfg map does not match, want:\n %#v\ngot: \n %#v", - i, test.u.Config, u.Config) - } - } -} diff --git a/src/restic/options/options.go b/src/restic/options/options.go new file mode 100644 index 000000000..c5d9ff3e3 --- /dev/null +++ b/src/restic/options/options.go @@ -0,0 +1,129 @@ +package options + +import ( + "reflect" + "restic/errors" + "strconv" + "strings" + "time" +) + +// Options holds options in the form key=value. +type Options map[string]string + +// splitKeyValue splits at the first equals (=) sign. +func splitKeyValue(s string) (key string, value string) { + data := strings.SplitN(s, "=", 2) + key = strings.ToLower(strings.TrimSpace(data[0])) + if len(data) == 1 { + // no equals sign is treated as the empty value + return key, "" + } + + return key, strings.TrimSpace(data[1]) +} + +// Parse takes a slice of key=value pairs and returns an Options type. +// The key may include namespaces, separated by dots. Example: "foo.bar=value". +// Keys are converted to lower-case. +func Parse(in []string) (Options, error) { + opts := make(Options, len(in)) + + for _, opt := range in { + key, value := splitKeyValue(opt) + + if key == "" { + return Options{}, errors.Fatalf("empty key is not a valid option") + } + + if v, ok := opts[key]; ok && v != value { + return Options{}, errors.Fatalf("key %q present more than once", key) + } + + opts[key] = value + } + + return opts, nil +} + +// Extract returns an Options type with all keys in namespace ns, which is +// also stripped from the keys. ns must end with a dot. +func (o Options) Extract(ns string) Options { + l := len(ns) + if ns[l-1] != '.' { + ns += "." + l++ + } + + opts := make(Options) + + for k, v := range o { + if !strings.HasPrefix(k, ns) { + continue + } + + opts[k[l:]] = v + } + + return opts +} + +// Apply sets the options on dst via reflection, using the struct tag `option`. +// The namespace argument (ns) is only used for error messages. +func (o Options) Apply(ns string, dst interface{}) error { + v := reflect.ValueOf(dst).Elem() + + fields := make(map[string]reflect.StructField) + + for i := 0; i < v.NumField(); i++ { + f := v.Type().Field(i) + tag := f.Tag.Get("option") + + if tag == "" { + continue + } + + if _, ok := fields[tag]; ok { + panic("option tag " + tag + " is not unique in " + v.Type().Name()) + } + + fields[tag] = f + } + + for key, value := range o { + field, ok := fields[key] + if !ok { + if ns != "" { + key = ns + "." + key + } + return errors.Fatalf("option %v is not known", key) + } + + i := field.Index[0] + switch v.Type().Field(i).Type.Name() { + case "string": + v.Field(i).SetString(value) + + case "int": + vi, err := strconv.ParseInt(value, 0, 32) + if err != nil { + return err + } + + v.Field(i).SetInt(vi) + + case "Duration": + d, err := time.ParseDuration(value) + if err != nil { + return err + } + + v.Field(i).SetInt(int64(d)) + + default: + panic("type " + v.Type().Field(i).Type.Name() + " not handled") + } + } + + return nil +} diff --git a/src/restic/options/options_test.go b/src/restic/options/options_test.go new file mode 100644 index 000000000..a5ab83952 --- /dev/null +++ b/src/restic/options/options_test.go @@ -0,0 +1,220 @@ +package options + +import ( + "fmt" + "reflect" + "testing" + "time" +) + +var optsTests = []struct { + input []string + output Options +}{ + { + []string{"foo=bar", "bar=baz ", "k="}, + Options{ + "foo": "bar", + "bar": "baz", + "k": "", + }, + }, + { + []string{"Foo=23", "baR", "k=thing with spaces"}, + Options{ + "foo": "23", + "bar": "", + "k": "thing with spaces", + }, + }, + { + []string{"k=thing with spaces", "k2=more spaces = not evil"}, + Options{ + "k": "thing with spaces", + "k2": "more spaces = not evil", + }, + }, + { + []string{"x=1", "foo=bar", "y=2", "foo=bar"}, + Options{ + "x": "1", + "y": "2", + "foo": "bar", + }, + }, +} + +func TestParseOptions(t *testing.T) { + for i, test := range optsTests { + t.Run(fmt.Sprintf("test-%v", i), func(t *testing.T) { + opts, err := Parse(test.input) + if err != nil { + t.Fatalf("unable to parse options: %v", err) + } + + if !reflect.DeepEqual(opts, test.output) { + t.Fatalf("wrong result, want:\n %#v\ngot:\n %#v", test.output, opts) + } + }) + } +} + +var invalidOptsTests = []struct { + input []string + err string +}{ + { + []string{"=bar", "bar=baz", "k="}, + "empty key is not a valid option", + }, + { + []string{"x=1", "foo=bar", "y=2", "foo=baz"}, + `key "foo" present more than once`, + }, +} + +func TestParseInvalidOptions(t *testing.T) { + for _, test := range invalidOptsTests { + t.Run(test.err, func(t *testing.T) { + _, err := Parse(test.input) + if err == nil { + t.Fatalf("expected error (%v) not found, err is nil", test.err) + } + + if err.Error() != test.err { + t.Fatalf("expected error %q, got %q", test.err, err.Error()) + } + }) + } +} + +var extractTests = []struct { + input Options + ns string + output Options +}{ + { + input: Options{ + "foo.bar:": "baz", + "s3.timeout": "10s", + "sftp.timeout": "5s", + "global": "foobar", + }, + ns: "s3", + output: Options{ + "timeout": "10s", + }, + }, +} + +func TestOptionsExtract(t *testing.T) { + for _, test := range extractTests { + t.Run(test.ns, func(t *testing.T) { + opts := test.input.Extract(test.ns) + + if !reflect.DeepEqual(opts, test.output) { + t.Fatalf("wrong result, want:\n %#v\ngot:\n %#v", test.output, opts) + } + }) + } +} + +// Target is used for Apply() tests +type Target struct { + Name string `option:"name"` + ID int `option:"id"` + Timeout time.Duration `option:"timeout"` + Other string +} + +var setTests = []struct { + input Options + output Target +}{ + { + Options{ + "name": "foobar", + }, + Target{ + Name: "foobar", + }, + }, + { + Options{ + "name": "foobar", + "id": "1234", + }, + Target{ + Name: "foobar", + ID: 1234, + }, + }, + { + Options{ + "timeout": "10m3s", + }, + Target{ + Timeout: time.Duration(10*time.Minute + 3*time.Second), + }, + }, +} + +func TestOptionsApply(t *testing.T) { + for i, test := range setTests { + t.Run(fmt.Sprintf("test-%d", i), func(t *testing.T) { + var dst Target + err := test.input.Apply("", &dst) + if err != nil { + t.Fatal(err) + } + + if dst != test.output { + t.Fatalf("wrong result, want:\n %#v\ngot:\n %#v", test.output, dst) + } + }) + } +} + +var invalidSetTests = []struct { + input Options + namespace string + err string +}{ + { + Options{ + "first_name": "foobar", + }, + "ns", + "option ns.first_name is not known", + }, + { + Options{ + "id": "foobar", + }, + "ns", + `strconv.ParseInt: parsing "foobar": invalid syntax`, + }, + { + Options{ + "timeout": "2134", + }, + "ns", + `time: missing unit in duration 2134`, + }, +} + +func TestOptionsApplyInvalid(t *testing.T) { + for i, test := range invalidSetTests { + t.Run(fmt.Sprintf("test-%d", i), func(t *testing.T) { + var dst Target + err := test.input.Apply(test.namespace, &dst) + if err == nil { + t.Fatalf("expected error %v not found", test.err) + } + + if err.Error() != test.err { + t.Fatalf("expected error %q, got %q", test.err, err.Error()) + } + }) + } +} diff --git a/src/restic/repository/testing.go b/src/restic/repository/testing.go index 7650ad8b9..6f590e13a 100644 --- a/src/restic/repository/testing.go +++ b/src/restic/repository/testing.go @@ -67,7 +67,7 @@ func TestRepository(t testing.TB) (r restic.Repository, cleanup func()) { if dir != "" { _, err := os.Stat(dir) if err != nil { - be, err := local.Create(dir) + be, err := local.Create(local.Config{Path: dir}) if err != nil { t.Fatalf("error creating local backend at %v: %v", dir, err) } @@ -84,7 +84,7 @@ func TestRepository(t testing.TB) (r restic.Repository, cleanup func()) { // TestOpenLocal opens a local repository. func TestOpenLocal(t testing.TB, dir string) (r restic.Repository) { - be, err := local.Open(dir) + be, err := local.Open(local.Config{Path: dir}) if err != nil { t.Fatal(err) }