diff --git a/fs/config/authorize.go b/fs/config/authorize.go new file mode 100644 index 000000000..4f44df4ee --- /dev/null +++ b/fs/config/authorize.go @@ -0,0 +1,47 @@ +package config + +import ( + "context" + "log" + + "github.com/rclone/rclone/fs" +) + +// Authorize is for remote authorization of headless machines. +// +// It expects 1 or 3 arguments +// +// rclone authorize "fs name" +// rclone authorize "fs name" "client id" "client secret" +func Authorize(ctx context.Context, args []string, noAutoBrowser bool) { + ctx = suppressConfirm(ctx) + switch len(args) { + case 1, 3: + default: + log.Fatalf("Invalid number of arguments: %d", len(args)) + } + newType := args[0] + f := fs.MustFind(newType) + if f.Config == nil { + log.Fatalf("Can't authorize fs %q", newType) + } + // Name used for temporary fs + name := "**temp-fs**" + + // Make sure we delete it + defer DeleteRemote(name) + + // Indicate that we are running rclone authorize + Data.SetValue(name, ConfigAuthorize, "true") + if noAutoBrowser { + Data.SetValue(name, ConfigAuthNoBrowser, "true") + } + + if len(args) == 3 { + Data.SetValue(name, ConfigClientID, args[1]) + Data.SetValue(name, ConfigClientSecret, args[2]) + } + + m := fs.ConfigMap(f, name) + f.Config(ctx, name, m) +} diff --git a/fs/config/config.go b/fs/config/config.go index 9544ae9f2..b42be2e9d 100644 --- a/fs/config/config.go +++ b/fs/config/config.go @@ -2,7 +2,6 @@ package config import ( - "bufio" "context" "encoding/json" "fmt" @@ -12,8 +11,6 @@ import ( "path/filepath" "regexp" "runtime" - "sort" - "strconv" "strings" "time" @@ -21,14 +18,10 @@ import ( "github.com/pkg/errors" "github.com/rclone/rclone/fs" - "github.com/rclone/rclone/fs/config/configmap" - "github.com/rclone/rclone/fs/config/configstruct" "github.com/rclone/rclone/fs/config/obscure" - "github.com/rclone/rclone/fs/driveletter" "github.com/rclone/rclone/fs/fspath" "github.com/rclone/rclone/fs/rc" "github.com/rclone/rclone/lib/random" - "github.com/rclone/rclone/lib/terminal" ) const ( @@ -302,385 +295,6 @@ func FileGetFresh(section, key string) (value string, err error) { return value, nil } -// ShowRemotes shows an overview of the config file -func ShowRemotes() { - remotes := Data.GetSectionList() - if len(remotes) == 0 { - return - } - sort.Strings(remotes) - fmt.Printf("%-20s %s\n", "Name", "Type") - fmt.Printf("%-20s %s\n", "====", "====") - for _, remote := range remotes { - fmt.Printf("%-20s %s\n", remote, FileGet(remote, "type")) - } -} - -// ChooseRemote chooses a remote name -func ChooseRemote() string { - remotes := Data.GetSectionList() - sort.Strings(remotes) - return Choose("remote", remotes, nil, false) -} - -// ReadLine reads some input -var ReadLine = func() string { - buf := bufio.NewReader(os.Stdin) - line, err := buf.ReadString('\n') - if err != nil { - log.Fatalf("Failed to read line: %v", err) - } - return strings.TrimSpace(line) -} - -// ReadNonEmptyLine prints prompt and calls Readline until non empty -func ReadNonEmptyLine(prompt string) string { - result := "" - for result == "" { - fmt.Print(prompt) - result = strings.TrimSpace(ReadLine()) - } - return result -} - -// CommandDefault - choose one. If return is pressed then it will -// chose the defaultIndex if it is >= 0 -func CommandDefault(commands []string, defaultIndex int) byte { - opts := []string{} - for i, text := range commands { - def := "" - if i == defaultIndex { - def = " (default)" - } - fmt.Printf("%c) %s%s\n", text[0], text[1:], def) - opts = append(opts, text[:1]) - } - optString := strings.Join(opts, "") - optHelp := strings.Join(opts, "/") - for { - fmt.Printf("%s> ", optHelp) - result := strings.ToLower(ReadLine()) - if len(result) == 0 && defaultIndex >= 0 { - return optString[defaultIndex] - } - if len(result) != 1 { - continue - } - i := strings.Index(optString, string(result[0])) - if i >= 0 { - return result[0] - } - } -} - -// Command - choose one -func Command(commands []string) byte { - return CommandDefault(commands, -1) -} - -// Confirm asks the user for Yes or No and returns true or false -// -// If the user presses enter then the Default will be used -func Confirm(Default bool) bool { - defaultIndex := 0 - if !Default { - defaultIndex = 1 - } - return CommandDefault([]string{"yYes", "nNo"}, defaultIndex) == 'y' -} - -// ConfirmWithConfig asks the user for Yes or No and returns true or -// false. -// -// If AutoConfirm is set, it will look up the value in m and return -// that, but if it isn't set then it will return the Default value -// passed in -func ConfirmWithConfig(ctx context.Context, m configmap.Getter, configName string, Default bool) bool { - ci := fs.GetConfig(ctx) - if ci.AutoConfirm { - configString, ok := m.Get(configName) - if ok { - configValue, err := strconv.ParseBool(configString) - if err != nil { - fs.Errorf(nil, "Failed to parse config parameter %s=%q as boolean - using default %v: %v", configName, configString, Default, err) - } else { - Default = configValue - } - } - answer := "No" - if Default { - answer = "Yes" - } - fmt.Printf("Auto confirm is set: answering %s, override by setting config parameter %s=%v\n", answer, configName, !Default) - return Default - } - return Confirm(Default) -} - -// Choose one of the defaults or type a new string if newOk is set -func Choose(what string, defaults, help []string, newOk bool) string { - valueDescription := "an existing" - if newOk { - valueDescription = "your own" - } - fmt.Printf("Choose a number from below, or type in %s value\n", valueDescription) - attributes := []string{terminal.HiRedFg, terminal.HiGreenFg} - for i, text := range defaults { - var lines []string - if help != nil { - parts := strings.Split(help[i], "\n") - lines = append(lines, parts...) - } - lines = append(lines, fmt.Sprintf("%q", text)) - pos := i + 1 - terminal.WriteString(attributes[i%len(attributes)]) - if len(lines) == 1 { - fmt.Printf("%2d > %s\n", pos, text) - } else { - mid := (len(lines) - 1) / 2 - for i, line := range lines { - var sep rune - switch i { - case 0: - sep = '/' - case len(lines) - 1: - sep = '\\' - default: - sep = '|' - } - number := " " - if i == mid { - number = fmt.Sprintf("%2d", pos) - } - fmt.Printf("%s %c %s\n", number, sep, line) - } - } - terminal.WriteString(terminal.Reset) - } - for { - fmt.Printf("%s> ", what) - result := ReadLine() - i, err := strconv.Atoi(result) - if err != nil { - if newOk { - return result - } - for _, v := range defaults { - if result == v { - return result - } - } - continue - } - if i >= 1 && i <= len(defaults) { - return defaults[i-1] - } - } -} - -// ChooseNumber asks the user to enter a number between min and max -// inclusive prompting them with what. -func ChooseNumber(what string, min, max int) int { - for { - fmt.Printf("%s> ", what) - result := ReadLine() - i, err := strconv.Atoi(result) - if err != nil { - fmt.Printf("Bad number: %v\n", err) - continue - } - if i < min || i > max { - fmt.Printf("Out of range - %d to %d inclusive\n", min, max) - continue - } - return i - } -} - -// ShowRemote shows the contents of the remote -func ShowRemote(name string) { - fmt.Printf("--------------------\n") - fmt.Printf("[%s]\n", name) - fs := MustFindByName(name) - for _, key := range Data.GetKeyList(name) { - isPassword := false - for _, option := range fs.Options { - if option.Name == key && option.IsPassword { - isPassword = true - break - } - } - value := FileGet(name, key) - if isPassword && value != "" { - fmt.Printf("%s = *** ENCRYPTED ***\n", key) - } else { - fmt.Printf("%s = %s\n", key, value) - } - } - fmt.Printf("--------------------\n") -} - -// OkRemote prints the contents of the remote and ask if it is OK -func OkRemote(name string) bool { - ShowRemote(name) - switch i := CommandDefault([]string{"yYes this is OK", "eEdit this remote", "dDelete this remote"}, 0); i { - case 'y': - return true - case 'e': - return false - case 'd': - Data.DeleteSection(name) - return true - default: - fs.Errorf(nil, "Bad choice %c", i) - } - return false -} - -// MustFindByName finds the RegInfo for the remote name passed in or -// exits with a fatal error. -func MustFindByName(name string) *fs.RegInfo { - fsType := FileGet(name, "type") - if fsType == "" { - log.Fatalf("Couldn't find type of fs for %q", name) - } - return fs.MustFind(fsType) -} - -// RemoteConfig runs the config helper for the remote if needed -func RemoteConfig(ctx context.Context, name string) { - fmt.Printf("Remote config\n") - f := MustFindByName(name) - if f.Config != nil { - m := fs.ConfigMap(f, name) - f.Config(ctx, name, m) - } -} - -// matchProvider returns true if provider matches the providerConfig string. -// -// The providerConfig string can either be a list of providers to -// match, or if it starts with "!" it will be a list of providers not -// to match. -// -// If either providerConfig or provider is blank then it will return true -func matchProvider(providerConfig, provider string) bool { - if providerConfig == "" || provider == "" { - return true - } - negate := false - if strings.HasPrefix(providerConfig, "!") { - providerConfig = providerConfig[1:] - negate = true - } - providers := strings.Split(providerConfig, ",") - matched := false - for _, p := range providers { - if p == provider { - matched = true - break - } - } - if negate { - return !matched - } - return matched -} - -// ChooseOption asks the user to choose an option -func ChooseOption(o *fs.Option, name string) string { - var subProvider = getWithDefault(name, fs.ConfigProvider, "") - fmt.Println(o.Help) - if o.IsPassword { - actions := []string{"yYes type in my own password", "gGenerate random password"} - defaultAction := -1 - if !o.Required { - defaultAction = len(actions) - actions = append(actions, "nNo leave this optional password blank") - } - var password string - var err error - switch i := CommandDefault(actions, defaultAction); i { - case 'y': - password = ChangePassword("the") - case 'g': - for { - fmt.Printf("Password strength in bits.\n64 is just about memorable\n128 is secure\n1024 is the maximum\n") - bits := ChooseNumber("Bits", 64, 1024) - password, err = Password(bits) - if err != nil { - log.Fatalf("Failed to make password: %v", err) - } - fmt.Printf("Your password is: %s\n", password) - fmt.Printf("Use this password? Please note that an obscured version of this \npassword (and not the " + - "password itself) will be stored under your \nconfiguration file, so keep this generated password " + - "in a safe place.\n") - if Confirm(true) { - break - } - } - case 'n': - return "" - default: - fs.Errorf(nil, "Bad choice %c", i) - } - return obscure.MustObscure(password) - } - what := fmt.Sprintf("%T value", o.Default) - switch o.Default.(type) { - case bool: - what = "boolean value (true or false)" - case fs.SizeSuffix: - what = "size with suffix k,M,G,T" - case fs.Duration: - what = "duration s,m,h,d,w,M,y" - case int, int8, int16, int32, int64: - what = "signed integer" - case uint, byte, uint16, uint32, uint64: - what = "unsigned integer" - } - var in string - for { - fmt.Printf("Enter a %s. Press Enter for the default (%q).\n", what, fmt.Sprint(o.Default)) - if len(o.Examples) > 0 { - var values []string - var help []string - for _, example := range o.Examples { - if matchProvider(example.Provider, subProvider) { - values = append(values, example.Value) - help = append(help, example.Help) - } - } - in = Choose(o.Name, values, help, true) - } else { - fmt.Printf("%s> ", o.Name) - in = ReadLine() - } - if in == "" { - if o.Required && fmt.Sprint(o.Default) == "" { - fmt.Printf("This value is required and it has no default.\n") - continue - } - break - } - newIn, err := configstruct.StringToInterface(o.Default, in) - if err != nil { - fmt.Printf("Failed to parse %q: %v\n", in, err) - continue - } - in = fmt.Sprint(newIn) // canonicalise - break - } - return in -} - -// Suppress the confirm prompts by altering the context config -func suppressConfirm(ctx context.Context) context.Context { - newCtx, ci := fs.AddConfig(ctx) - ci.AutoConfirm = true - return newCtx -} - // UpdateRemote adds the keyValues passed in to the remote of name. // keyValues should be key, value pairs. func UpdateRemote(ctx context.Context, name string, keyValues rc.Params, doObscure, noObscure bool) error { @@ -794,241 +408,6 @@ func fsOption() *fs.Option { return o } -// NewRemoteName asks the user for a name for a new remote -func NewRemoteName() (name string) { - for { - fmt.Printf("name> ") - name = ReadLine() - if Data.HasSection(name) { - fmt.Printf("Remote %q already exists.\n", name) - continue - } - err := fspath.CheckConfigName(name) - switch { - case name == "": - fmt.Printf("Can't use empty name.\n") - case driveletter.IsDriveLetter(name): - fmt.Printf("Can't use %q as it can be confused with a drive letter.\n", name) - case err != nil: - fmt.Printf("Can't use %q as %v.\n", name, err) - default: - return name - } - } -} - -// editOptions edits the options. If new is true then it just allows -// entry and doesn't show any old values. -func editOptions(ri *fs.RegInfo, name string, isNew bool) { - fmt.Printf("** See help for %s backend at: https://rclone.org/%s/ **\n\n", ri.Name, ri.FileName()) - hasAdvanced := false - for _, advanced := range []bool{false, true} { - if advanced { - if !hasAdvanced { - break - } - fmt.Printf("Edit advanced config? (y/n)\n") - if !Confirm(false) { - break - } - } - for _, option := range ri.Options { - isVisible := option.Hide&fs.OptionHideConfigurator == 0 - hasAdvanced = hasAdvanced || (option.Advanced && isVisible) - if option.Advanced != advanced { - continue - } - subProvider := getWithDefault(name, fs.ConfigProvider, "") - if matchProvider(option.Provider, subProvider) && isVisible { - if !isNew { - fmt.Printf("Value %q = %q\n", option.Name, FileGet(name, option.Name)) - fmt.Printf("Edit? (y/n)>\n") - if !Confirm(false) { - continue - } - } - FileSet(name, option.Name, ChooseOption(&option, name)) - } - } - } -} - -// NewRemote make a new remote from its name -func NewRemote(ctx context.Context, name string) { - var ( - newType string - ri *fs.RegInfo - err error - ) - - // Set the type first - for { - newType = ChooseOption(fsOption(), name) - ri, err = fs.Find(newType) - if err != nil { - fmt.Printf("Bad remote %q: %v\n", newType, err) - continue - } - break - } - Data.SetValue(name, "type", newType) - - editOptions(ri, name, true) - RemoteConfig(ctx, name) - if OkRemote(name) { - SaveConfig() - return - } - EditRemote(ctx, ri, name) -} - -// EditRemote gets the user to edit a remote -func EditRemote(ctx context.Context, ri *fs.RegInfo, name string) { - ShowRemote(name) - fmt.Printf("Edit remote\n") - for { - editOptions(ri, name, false) - if OkRemote(name) { - break - } - } - SaveConfig() - RemoteConfig(ctx, name) -} - -// DeleteRemote gets the user to delete a remote -func DeleteRemote(name string) { - Data.DeleteSection(name) - SaveConfig() -} - -// copyRemote asks the user for a new remote name and copies name into -// it. Returns the new name. -func copyRemote(name string) string { - newName := NewRemoteName() - // Copy the keys - for _, key := range Data.GetKeyList(name) { - value := getWithDefault(name, key, "") - Data.SetValue(newName, key, value) - } - return newName -} - -// RenameRemote renames a config section -func RenameRemote(name string) { - fmt.Printf("Enter new name for %q remote.\n", name) - newName := copyRemote(name) - if name != newName { - Data.DeleteSection(name) - SaveConfig() - } -} - -// CopyRemote copies a config section -func CopyRemote(name string) { - fmt.Printf("Enter name for copy of %q remote.\n", name) - copyRemote(name) - SaveConfig() -} - -// ShowConfigLocation prints the location of the config file in use -func ShowConfigLocation() { - if _, err := os.Stat(ConfigPath); os.IsNotExist(err) { - fmt.Println("Configuration file doesn't exist, but rclone will use this path:") - } else { - fmt.Println("Configuration file is stored at:") - } - fmt.Printf("%s\n", ConfigPath) -} - -// ShowConfig prints the (unencrypted) config options -func ShowConfig() { - str, err := Data.Serialize() - if err != nil { - log.Fatalf("Failed to serialize config: %v", err) - } - if str == "" { - str = "; empty config\n" - } - fmt.Printf("%s", str) -} - -// EditConfig edits the config file interactively -func EditConfig(ctx context.Context) { - for { - haveRemotes := len(Data.GetSectionList()) != 0 - what := []string{"eEdit existing remote", "nNew remote", "dDelete remote", "rRename remote", "cCopy remote", "sSet configuration password", "qQuit config"} - if haveRemotes { - fmt.Printf("Current remotes:\n\n") - ShowRemotes() - fmt.Printf("\n") - } else { - fmt.Printf("No remotes found - make a new one\n") - // take 2nd item and last 2 items of menu list - what = append(what[1:2], what[len(what)-2:]...) - } - switch i := Command(what); i { - case 'e': - name := ChooseRemote() - fs := MustFindByName(name) - EditRemote(ctx, fs, name) - case 'n': - NewRemote(ctx, NewRemoteName()) - case 'd': - name := ChooseRemote() - DeleteRemote(name) - case 'r': - RenameRemote(ChooseRemote()) - case 'c': - CopyRemote(ChooseRemote()) - case 's': - SetPassword() - case 'q': - return - - } - } -} - -// Authorize is for remote authorization of headless machines. -// -// It expects 1 or 3 arguments -// -// rclone authorize "fs name" -// rclone authorize "fs name" "client id" "client secret" -func Authorize(ctx context.Context, args []string, noAutoBrowser bool) { - ctx = suppressConfirm(ctx) - switch len(args) { - case 1, 3: - default: - log.Fatalf("Invalid number of arguments: %d", len(args)) - } - newType := args[0] - f := fs.MustFind(newType) - if f.Config == nil { - log.Fatalf("Can't authorize fs %q", newType) - } - // Name used for temporary fs - name := "**temp-fs**" - - // Make sure we delete it - defer DeleteRemote(name) - - // Indicate that we are running rclone authorize - Data.SetValue(name, ConfigAuthorize, "true") - if noAutoBrowser { - Data.SetValue(name, ConfigAuthNoBrowser, "true") - } - - if len(args) == 3 { - Data.SetValue(name, ConfigClientID, args[1]) - Data.SetValue(name, ConfigClientSecret, args[2]) - } - - m := fs.ConfigMap(f, name) - f.Config(ctx, name, m) -} - // FileGetFlag gets the config key under section returning the // the value and true if found and or ("", false) otherwise func FileGetFlag(section, key string) (string, bool) { diff --git a/fs/config/config_test.go b/fs/config/config_test.go index 4afa4728d..486416451 100644 --- a/fs/config/config_test.go +++ b/fs/config/config_test.go @@ -1,259 +1,20 @@ +// These are in an external package because we need to import configfile + package config_test import ( "bytes" "context" - "fmt" "io/ioutil" - "os" "testing" - "github.com/rclone/rclone/fs" "github.com/rclone/rclone/fs/config" "github.com/rclone/rclone/fs/config/configfile" - "github.com/rclone/rclone/fs/config/obscure" "github.com/rclone/rclone/fs/rc" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func testConfigFile(t *testing.T, configFileName string) func() { - ctx := context.Background() - ci := fs.GetConfig(ctx) - config.ClearConfigPassword() - _ = os.Unsetenv("_RCLONE_CONFIG_KEY_FILE") - _ = os.Unsetenv("RCLONE_CONFIG_PASS") - // create temp config file - tempFile, err := ioutil.TempFile("", configFileName) - assert.NoError(t, err) - path := tempFile.Name() - assert.NoError(t, tempFile.Close()) - - // temporarily adapt configuration - oldOsStdout := os.Stdout - oldConfigPath := config.ConfigPath - oldConfig := *ci - oldConfigFile := config.Data - oldReadLine := config.ReadLine - oldPassword := config.Password - os.Stdout = nil - config.ConfigPath = path - ci = &fs.ConfigInfo{} - - configfile.LoadConfig(ctx) - assert.Equal(t, []string{}, config.Data.GetSectionList()) - - // Fake a remote - fs.Register(&fs.RegInfo{ - Name: "config_test_remote", - Options: fs.Options{ - { - Name: "bool", - Default: false, - IsPassword: false, - }, - { - Name: "pass", - Default: "", - IsPassword: true, - }, - }, - }) - - // Undo the above - return func() { - err := os.Remove(path) - assert.NoError(t, err) - - os.Stdout = oldOsStdout - config.ConfigPath = oldConfigPath - config.ReadLine = oldReadLine - config.Password = oldPassword - *ci = oldConfig - config.Data = oldConfigFile - - _ = os.Unsetenv("_RCLONE_CONFIG_KEY_FILE") - _ = os.Unsetenv("RCLONE_CONFIG_PASS") - } -} - -// makeReadLine makes a simple readLine which returns a fixed list of -// strings -func makeReadLine(answers []string) func() string { - i := 0 - return func() string { - i = i + 1 - return answers[i-1] - } -} - -func TestCRUD(t *testing.T) { - defer testConfigFile(t, "crud.conf")() - ctx := context.Background() - - // script for creating remote - config.ReadLine = makeReadLine([]string{ - "config_test_remote", // type - "true", // bool value - "y", // type my own password - "secret", // password - "secret", // repeat - "y", // looks good, save - }) - config.NewRemote(ctx, "test") - - assert.Equal(t, []string{"test"}, config.Data.GetSectionList()) - assert.Equal(t, "config_test_remote", config.FileGet("test", "type")) - assert.Equal(t, "true", config.FileGet("test", "bool")) - assert.Equal(t, "secret", obscure.MustReveal(config.FileGet("test", "pass"))) - - // normal rename, test → asdf - config.ReadLine = makeReadLine([]string{ - "asdf", - "asdf", - "asdf", - }) - config.RenameRemote("test") - - assert.Equal(t, []string{"asdf"}, config.Data.GetSectionList()) - assert.Equal(t, "config_test_remote", config.FileGet("asdf", "type")) - assert.Equal(t, "true", config.FileGet("asdf", "bool")) - assert.Equal(t, "secret", obscure.MustReveal(config.FileGet("asdf", "pass"))) - - // delete remote - config.DeleteRemote("asdf") - assert.Equal(t, []string{}, config.Data.GetSectionList()) -} - -func TestChooseOption(t *testing.T) { - defer testConfigFile(t, "crud.conf")() - ctx := context.Background() - - // script for creating remote - config.ReadLine = makeReadLine([]string{ - "config_test_remote", // type - "false", // bool value - "x", // bad choice - "g", // generate password - "1024", // very big - "y", // password OK - "y", // looks good, save - }) - config.Password = func(bits int) (string, error) { - assert.Equal(t, 1024, bits) - return "not very random password", nil - } - config.NewRemote(ctx, "test") - - assert.Equal(t, "false", config.FileGet("test", "bool")) - assert.Equal(t, "not very random password", obscure.MustReveal(config.FileGet("test", "pass"))) - - // script for creating remote - config.ReadLine = makeReadLine([]string{ - "config_test_remote", // type - "true", // bool value - "n", // not required - "y", // looks good, save - }) - config.NewRemote(ctx, "test") - - assert.Equal(t, "true", config.FileGet("test", "bool")) - assert.Equal(t, "", config.FileGet("test", "pass")) -} - -func TestNewRemoteName(t *testing.T) { - defer testConfigFile(t, "crud.conf")() - ctx := context.Background() - - // script for creating remote - config.ReadLine = makeReadLine([]string{ - "config_test_remote", // type - "true", // bool value - "n", // not required - "y", // looks good, save - }) - config.NewRemote(ctx, "test") - - config.ReadLine = makeReadLine([]string{ - "test", // already exists - "", // empty string not allowed - "bad@characters", // bad characters - "newname", // OK - }) - - assert.Equal(t, "newname", config.NewRemoteName()) -} - -func TestCreateUpdatePasswordRemote(t *testing.T) { - ctx := context.Background() - defer testConfigFile(t, "update.conf")() - - for _, doObscure := range []bool{false, true} { - for _, noObscure := range []bool{false, true} { - if doObscure && noObscure { - break - } - t.Run(fmt.Sprintf("doObscure=%v,noObscure=%v", doObscure, noObscure), func(t *testing.T) { - require.NoError(t, config.CreateRemote(ctx, "test2", "config_test_remote", rc.Params{ - "bool": true, - "pass": "potato", - }, doObscure, noObscure)) - - assert.Equal(t, []string{"test2"}, config.Data.GetSectionList()) - assert.Equal(t, "config_test_remote", config.FileGet("test2", "type")) - assert.Equal(t, "true", config.FileGet("test2", "bool")) - gotPw := config.FileGet("test2", "pass") - if !noObscure { - gotPw = obscure.MustReveal(gotPw) - } - assert.Equal(t, "potato", gotPw) - - wantPw := obscure.MustObscure("potato2") - require.NoError(t, config.UpdateRemote(ctx, "test2", rc.Params{ - "bool": false, - "pass": wantPw, - "spare": "spare", - }, doObscure, noObscure)) - - assert.Equal(t, []string{"test2"}, config.Data.GetSectionList()) - assert.Equal(t, "config_test_remote", config.FileGet("test2", "type")) - assert.Equal(t, "false", config.FileGet("test2", "bool")) - gotPw = config.FileGet("test2", "pass") - if doObscure { - gotPw = obscure.MustReveal(gotPw) - } - assert.Equal(t, wantPw, gotPw) - - require.NoError(t, config.PasswordRemote(ctx, "test2", rc.Params{ - "pass": "potato3", - })) - - assert.Equal(t, []string{"test2"}, config.Data.GetSectionList()) - assert.Equal(t, "config_test_remote", config.FileGet("test2", "type")) - assert.Equal(t, "false", config.FileGet("test2", "bool")) - assert.Equal(t, "potato3", obscure.MustReveal(config.FileGet("test2", "pass"))) - }) - } - } - -} - -// Test some error cases -func TestReveal(t *testing.T) { - for _, test := range []struct { - in string - wantErr string - }{ - {"YmJiYmJiYmJiYmJiYmJiYp*gcEWbAw", "base64 decode failed when revealing password - is it obscured?: illegal base64 data at input byte 22"}, - {"aGVsbG8", "input too short when revealing password - is it obscured?"}, - {"", "input too short when revealing password - is it obscured?"}, - } { - gotString, gotErr := obscure.Reveal(test.in) - assert.Equal(t, "", gotString) - assert.Equal(t, test.wantErr, gotErr.Error()) - } -} - func TestConfigLoad(t *testing.T) { oldConfigPath := config.ConfigPath config.ConfigPath = "./testdata/plain.conf" @@ -271,106 +32,6 @@ func TestConfigLoad(t *testing.T) { assert.Equal(t, expect, keys) } -func TestConfigLoadEncrypted(t *testing.T) { - var err error - oldConfigPath := config.ConfigPath - config.ConfigPath = "./testdata/encrypted.conf" - defer func() { - config.ConfigPath = oldConfigPath - config.ClearConfigPassword() - }() - - // Set correct password - err = config.SetConfigPassword("asdf") - require.NoError(t, err) - err = config.Data.Load() - require.NoError(t, err) - sections := config.Data.GetSectionList() - var expect = []string{"nounc", "unc"} - assert.Equal(t, expect, sections) - - keys := config.Data.GetKeyList("nounc") - expect = []string{"type", "nounc"} - assert.Equal(t, expect, keys) -} - -func TestConfigLoadEncryptedWithValidPassCommand(t *testing.T) { - ctx := context.Background() - ci := fs.GetConfig(ctx) - oldConfigPath := config.ConfigPath - oldConfig := *ci - config.ConfigPath = "./testdata/encrypted.conf" - // using ci.PasswordCommand, correct password - ci.PasswordCommand = fs.SpaceSepList{"echo", "asdf"} - defer func() { - config.ConfigPath = oldConfigPath - config.ClearConfigPassword() - *ci = oldConfig - ci.PasswordCommand = nil - }() - - config.ClearConfigPassword() - - err := config.Data.Load() - require.NoError(t, err) - - sections := config.Data.GetSectionList() - var expect = []string{"nounc", "unc"} - assert.Equal(t, expect, sections) - - keys := config.Data.GetKeyList("nounc") - expect = []string{"type", "nounc"} - assert.Equal(t, expect, keys) -} - -func TestConfigLoadEncryptedWithInvalidPassCommand(t *testing.T) { - ctx := context.Background() - ci := fs.GetConfig(ctx) - oldConfigPath := config.ConfigPath - oldConfig := *ci - config.ConfigPath = "./testdata/encrypted.conf" - // using ci.PasswordCommand, incorrect password - ci.PasswordCommand = fs.SpaceSepList{"echo", "asdf-blurfl"} - defer func() { - config.ConfigPath = oldConfigPath - config.ClearConfigPassword() - *ci = oldConfig - ci.PasswordCommand = nil - }() - - config.ClearConfigPassword() - - err := config.Data.Load() - require.Error(t, err) - assert.Contains(t, err.Error(), "using --password-command derived password") -} - -func TestConfigLoadEncryptedFailures(t *testing.T) { - var err error - - // This file should be too short to be decoded. - oldConfigPath := config.ConfigPath - config.ConfigPath = "./testdata/enc-short.conf" - defer func() { config.ConfigPath = oldConfigPath }() - err = config.Data.Load() - require.Error(t, err) - - // This file contains invalid base64 characters. - config.ConfigPath = "./testdata/enc-invalid.conf" - err = config.Data.Load() - require.Error(t, err) - - // This file contains invalid base64 characters. - config.ConfigPath = "./testdata/enc-too-new.conf" - err = config.Data.Load() - require.Error(t, err) - - // This file does not exist. - config.ConfigPath = "./testdata/filenotfound.conf" - err = config.Data.Load() - assert.Equal(t, config.ErrorConfigFileNotFound, err) -} - func TestFileRefresh(t *testing.T) { ctx := context.Background() defer testConfigFile(t, "refresh.conf")() diff --git a/fs/config/crypt.go b/fs/config/crypt.go index 1cf6cb1f8..98015f6c8 100644 --- a/fs/config/crypt.go +++ b/fs/config/crypt.go @@ -14,11 +14,9 @@ import ( "os" "os/exec" "strings" - "unicode/utf8" "github.com/pkg/errors" "golang.org/x/crypto/nacl/secretbox" - "golang.org/x/text/unicode/norm" "github.com/rclone/rclone/fs" "github.com/rclone/rclone/fs/config/obscure" @@ -227,52 +225,6 @@ func Encrypt(src io.Reader, dst io.Writer) error { return enc.Close() } -// checkPassword normalises and validates the password -func checkPassword(password string) (string, error) { - if !utf8.ValidString(password) { - return "", errors.New("password contains invalid utf8 characters") - } - // Check for leading/trailing whitespace - trimmedPassword := strings.TrimSpace(password) - // Warn user if password has leading+trailing whitespace - if len(password) != len(trimmedPassword) { - _, _ = fmt.Fprintln(os.Stderr, "Your password contains leading/trailing whitespace - in previous versions of rclone this was stripped") - } - // Normalize to reduce weird variations. - password = norm.NFKC.String(password) - if len(password) == 0 || len(trimmedPassword) == 0 { - return "", errors.New("no characters in password") - } - return password, nil -} - -// GetPassword asks the user for a password with the prompt given. -func GetPassword(prompt string) string { - _, _ = fmt.Fprintln(PasswordPromptOutput, prompt) - for { - _, _ = fmt.Fprint(PasswordPromptOutput, "password:") - password := ReadPassword() - password, err := checkPassword(password) - if err == nil { - return password - } - _, _ = fmt.Fprintf(os.Stderr, "Bad password: %v\n", err) - } -} - -// ChangePassword will query the user twice for the named password. If -// the same password is entered it is returned. -func ChangePassword(name string) string { - for { - a := GetPassword(fmt.Sprintf("Enter %s password:", name)) - b := GetPassword(fmt.Sprintf("Confirm %s password:", name)) - if a == b { - return a - } - fmt.Println("Passwords do not match!") - } -} - // getConfigPassword will query the user for a password the // first time it is required. func getConfigPassword(q string) { @@ -353,41 +305,3 @@ func changeConfigPassword() { return } } - -// SetPassword will allow the user to modify the current -// configuration encryption settings. -func SetPassword() { - for { - if len(configKey) > 0 { - fmt.Println("Your configuration is encrypted.") - what := []string{"cChange Password", "uUnencrypt configuration", "qQuit to main menu"} - switch i := Command(what); i { - case 'c': - changeConfigPassword() - SaveConfig() - fmt.Println("Password changed") - continue - case 'u': - configKey = nil - SaveConfig() - continue - case 'q': - return - } - - } else { - fmt.Println("Your configuration is not encrypted.") - fmt.Println("If you add a password, you will protect your login information to cloud services.") - what := []string{"aAdd Password", "qQuit to main menu"} - switch i := Command(what); i { - case 'a': - changeConfigPassword() - SaveConfig() - fmt.Println("Password set") - continue - case 'q': - return - } - } - } -} diff --git a/fs/config/crypt_internal_test.go b/fs/config/crypt_internal_test.go new file mode 100644 index 000000000..05dc71e0f --- /dev/null +++ b/fs/config/crypt_internal_test.go @@ -0,0 +1,48 @@ +package config + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func hashedKeyCompare(t *testing.T, a, b string, shouldMatch bool) { + err := SetConfigPassword(a) + require.NoError(t, err) + k1 := configKey + + err = SetConfigPassword(b) + require.NoError(t, err) + k2 := configKey + + if shouldMatch { + assert.Equal(t, k1, k2) + } else { + assert.NotEqual(t, k1, k2) + } +} + +func TestPassword(t *testing.T) { + defer func() { + configKey = nil // reset password + }() + var err error + // Empty password should give error + err = SetConfigPassword(" \t ") + require.Error(t, err) + + // Test invalid utf8 sequence + err = SetConfigPassword(string([]byte{0xff, 0xfe, 0xfd}) + "abc") + require.Error(t, err) + + // Simple check of wrong passwords + hashedKeyCompare(t, "mis", "match", false) + + // Check that passwords match after unicode normalization + hashedKeyCompare(t, "ff\u0041\u030A", "ffÅ", true) + + // Check that passwords preserves case + hashedKeyCompare(t, "abcdef", "ABCDEF", false) + +} diff --git a/fs/config/crypt_test.go b/fs/config/crypt_test.go index 61c0ab5c8..7febbfc6d 100644 --- a/fs/config/crypt_test.go +++ b/fs/config/crypt_test.go @@ -1,48 +1,115 @@ -package config +// These are in an external package because we need to import configfile +// +// Internal tests are in crypt_internal_test.go + +package config_test import ( + "context" "testing" + "github.com/rclone/rclone/fs" + "github.com/rclone/rclone/fs/config" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestPassword(t *testing.T) { - defer func() { - configKey = nil // reset password - }() +func TestConfigLoadEncrypted(t *testing.T) { var err error - // Empty password should give error - err = SetConfigPassword(" \t ") - require.Error(t, err) + oldConfigPath := config.ConfigPath + config.ConfigPath = "./testdata/encrypted.conf" + defer func() { + config.ConfigPath = oldConfigPath + config.ClearConfigPassword() + }() - // Test invalid utf8 sequence - err = SetConfigPassword(string([]byte{0xff, 0xfe, 0xfd}) + "abc") - require.Error(t, err) - - // Simple check of wrong passwords - hashedKeyCompare(t, "mis", "match", false) - - // Check that passwords match after unicode normalization - hashedKeyCompare(t, "ff\u0041\u030A", "ffÅ", true) - - // Check that passwords preserves case - hashedKeyCompare(t, "abcdef", "ABCDEF", false) + // Set correct password + err = config.SetConfigPassword("asdf") + require.NoError(t, err) + err = config.Data.Load() + require.NoError(t, err) + sections := config.Data.GetSectionList() + var expect = []string{"nounc", "unc"} + assert.Equal(t, expect, sections) + keys := config.Data.GetKeyList("nounc") + expect = []string{"type", "nounc"} + assert.Equal(t, expect, keys) } -func hashedKeyCompare(t *testing.T, a, b string, shouldMatch bool) { - err := SetConfigPassword(a) - require.NoError(t, err) - k1 := configKey +func TestConfigLoadEncryptedWithValidPassCommand(t *testing.T) { + ctx := context.Background() + ci := fs.GetConfig(ctx) + oldConfigPath := config.ConfigPath + oldConfig := *ci + config.ConfigPath = "./testdata/encrypted.conf" + // using ci.PasswordCommand, correct password + ci.PasswordCommand = fs.SpaceSepList{"echo", "asdf"} + defer func() { + config.ConfigPath = oldConfigPath + config.ClearConfigPassword() + *ci = oldConfig + ci.PasswordCommand = nil + }() - err = SetConfigPassword(b) - require.NoError(t, err) - k2 := configKey + config.ClearConfigPassword() - if shouldMatch { - assert.Equal(t, k1, k2) - } else { - assert.NotEqual(t, k1, k2) - } + err := config.Data.Load() + require.NoError(t, err) + + sections := config.Data.GetSectionList() + var expect = []string{"nounc", "unc"} + assert.Equal(t, expect, sections) + + keys := config.Data.GetKeyList("nounc") + expect = []string{"type", "nounc"} + assert.Equal(t, expect, keys) +} + +func TestConfigLoadEncryptedWithInvalidPassCommand(t *testing.T) { + ctx := context.Background() + ci := fs.GetConfig(ctx) + oldConfigPath := config.ConfigPath + oldConfig := *ci + config.ConfigPath = "./testdata/encrypted.conf" + // using ci.PasswordCommand, incorrect password + ci.PasswordCommand = fs.SpaceSepList{"echo", "asdf-blurfl"} + defer func() { + config.ConfigPath = oldConfigPath + config.ClearConfigPassword() + *ci = oldConfig + ci.PasswordCommand = nil + }() + + config.ClearConfigPassword() + + err := config.Data.Load() + require.Error(t, err) + assert.Contains(t, err.Error(), "using --password-command derived password") +} + +func TestConfigLoadEncryptedFailures(t *testing.T) { + var err error + + // This file should be too short to be decoded. + oldConfigPath := config.ConfigPath + config.ConfigPath = "./testdata/enc-short.conf" + defer func() { config.ConfigPath = oldConfigPath }() + err = config.Data.Load() + require.Error(t, err) + + // This file contains invalid base64 characters. + config.ConfigPath = "./testdata/enc-invalid.conf" + err = config.Data.Load() + require.Error(t, err) + + // This file contains invalid base64 characters. + config.ConfigPath = "./testdata/enc-too-new.conf" + err = config.Data.Load() + require.Error(t, err) + + // This file does not exist. + config.ConfigPath = "./testdata/filenotfound.conf" + err = config.Data.Load() + assert.Equal(t, config.ErrorConfigFileNotFound, err) } diff --git a/fs/config/obscure/obscure_test.go b/fs/config/obscure/obscure_test.go index ff9dedb04..d7ea43121 100644 --- a/fs/config/obscure/obscure_test.go +++ b/fs/config/obscure/obscure_test.go @@ -58,3 +58,19 @@ func TestReveal(t *testing.T) { } } + +// Test some error cases +func TestRevealErrors(t *testing.T) { + for _, test := range []struct { + in string + wantErr string + }{ + {"YmJiYmJiYmJiYmJiYmJiYp*gcEWbAw", "base64 decode failed when revealing password - is it obscured?: illegal base64 data at input byte 22"}, + {"aGVsbG8", "input too short when revealing password - is it obscured?"}, + {"", "input too short when revealing password - is it obscured?"}, + } { + gotString, gotErr := Reveal(test.in) + assert.Equal(t, "", gotString) + assert.Equal(t, test.wantErr, gotErr.Error()) + } +} diff --git a/fs/config/ui.go b/fs/config/ui.go new file mode 100644 index 000000000..595b99492 --- /dev/null +++ b/fs/config/ui.go @@ -0,0 +1,684 @@ +// Textual user interface parts of the config system + +package config + +import ( + "bufio" + "context" + "fmt" + "log" + "os" + "sort" + "strconv" + "strings" + "unicode/utf8" + + "github.com/pkg/errors" + "github.com/rclone/rclone/fs" + "github.com/rclone/rclone/fs/config/configmap" + "github.com/rclone/rclone/fs/config/configstruct" + "github.com/rclone/rclone/fs/config/obscure" + "github.com/rclone/rclone/fs/driveletter" + "github.com/rclone/rclone/fs/fspath" + "github.com/rclone/rclone/lib/terminal" + "golang.org/x/text/unicode/norm" +) + +// ReadLine reads some input +var ReadLine = func() string { + buf := bufio.NewReader(os.Stdin) + line, err := buf.ReadString('\n') + if err != nil { + log.Fatalf("Failed to read line: %v", err) + } + return strings.TrimSpace(line) +} + +// ReadNonEmptyLine prints prompt and calls Readline until non empty +func ReadNonEmptyLine(prompt string) string { + result := "" + for result == "" { + fmt.Print(prompt) + result = strings.TrimSpace(ReadLine()) + } + return result +} + +// CommandDefault - choose one. If return is pressed then it will +// chose the defaultIndex if it is >= 0 +func CommandDefault(commands []string, defaultIndex int) byte { + opts := []string{} + for i, text := range commands { + def := "" + if i == defaultIndex { + def = " (default)" + } + fmt.Printf("%c) %s%s\n", text[0], text[1:], def) + opts = append(opts, text[:1]) + } + optString := strings.Join(opts, "") + optHelp := strings.Join(opts, "/") + for { + fmt.Printf("%s> ", optHelp) + result := strings.ToLower(ReadLine()) + if len(result) == 0 && defaultIndex >= 0 { + return optString[defaultIndex] + } + if len(result) != 1 { + continue + } + i := strings.Index(optString, string(result[0])) + if i >= 0 { + return result[0] + } + } +} + +// Command - choose one +func Command(commands []string) byte { + return CommandDefault(commands, -1) +} + +// Confirm asks the user for Yes or No and returns true or false +// +// If the user presses enter then the Default will be used +func Confirm(Default bool) bool { + defaultIndex := 0 + if !Default { + defaultIndex = 1 + } + return CommandDefault([]string{"yYes", "nNo"}, defaultIndex) == 'y' +} + +// ConfirmWithConfig asks the user for Yes or No and returns true or +// false. +// +// If AutoConfirm is set, it will look up the value in m and return +// that, but if it isn't set then it will return the Default value +// passed in +func ConfirmWithConfig(ctx context.Context, m configmap.Getter, configName string, Default bool) bool { + ci := fs.GetConfig(ctx) + if ci.AutoConfirm { + configString, ok := m.Get(configName) + if ok { + configValue, err := strconv.ParseBool(configString) + if err != nil { + fs.Errorf(nil, "Failed to parse config parameter %s=%q as boolean - using default %v: %v", configName, configString, Default, err) + } else { + Default = configValue + } + } + answer := "No" + if Default { + answer = "Yes" + } + fmt.Printf("Auto confirm is set: answering %s, override by setting config parameter %s=%v\n", answer, configName, !Default) + return Default + } + return Confirm(Default) +} + +// Choose one of the defaults or type a new string if newOk is set +func Choose(what string, defaults, help []string, newOk bool) string { + valueDescription := "an existing" + if newOk { + valueDescription = "your own" + } + fmt.Printf("Choose a number from below, or type in %s value\n", valueDescription) + attributes := []string{terminal.HiRedFg, terminal.HiGreenFg} + for i, text := range defaults { + var lines []string + if help != nil { + parts := strings.Split(help[i], "\n") + lines = append(lines, parts...) + } + lines = append(lines, fmt.Sprintf("%q", text)) + pos := i + 1 + terminal.WriteString(attributes[i%len(attributes)]) + if len(lines) == 1 { + fmt.Printf("%2d > %s\n", pos, text) + } else { + mid := (len(lines) - 1) / 2 + for i, line := range lines { + var sep rune + switch i { + case 0: + sep = '/' + case len(lines) - 1: + sep = '\\' + default: + sep = '|' + } + number := " " + if i == mid { + number = fmt.Sprintf("%2d", pos) + } + fmt.Printf("%s %c %s\n", number, sep, line) + } + } + terminal.WriteString(terminal.Reset) + } + for { + fmt.Printf("%s> ", what) + result := ReadLine() + i, err := strconv.Atoi(result) + if err != nil { + if newOk { + return result + } + for _, v := range defaults { + if result == v { + return result + } + } + continue + } + if i >= 1 && i <= len(defaults) { + return defaults[i-1] + } + } +} + +// ChooseNumber asks the user to enter a number between min and max +// inclusive prompting them with what. +func ChooseNumber(what string, min, max int) int { + for { + fmt.Printf("%s> ", what) + result := ReadLine() + i, err := strconv.Atoi(result) + if err != nil { + fmt.Printf("Bad number: %v\n", err) + continue + } + if i < min || i > max { + fmt.Printf("Out of range - %d to %d inclusive\n", min, max) + continue + } + return i + } +} + +// ShowRemotes shows an overview of the config file +func ShowRemotes() { + remotes := Data.GetSectionList() + if len(remotes) == 0 { + return + } + sort.Strings(remotes) + fmt.Printf("%-20s %s\n", "Name", "Type") + fmt.Printf("%-20s %s\n", "====", "====") + for _, remote := range remotes { + fmt.Printf("%-20s %s\n", remote, FileGet(remote, "type")) + } +} + +// ChooseRemote chooses a remote name +func ChooseRemote() string { + remotes := Data.GetSectionList() + sort.Strings(remotes) + return Choose("remote", remotes, nil, false) +} + +// mustFindByName finds the RegInfo for the remote name passed in or +// exits with a fatal error. +func mustFindByName(name string) *fs.RegInfo { + fsType := FileGet(name, "type") + if fsType == "" { + log.Fatalf("Couldn't find type of fs for %q", name) + } + return fs.MustFind(fsType) +} + +// ShowRemote shows the contents of the remote +func ShowRemote(name string) { + fmt.Printf("--------------------\n") + fmt.Printf("[%s]\n", name) + fs := mustFindByName(name) + for _, key := range Data.GetKeyList(name) { + isPassword := false + for _, option := range fs.Options { + if option.Name == key && option.IsPassword { + isPassword = true + break + } + } + value := FileGet(name, key) + if isPassword && value != "" { + fmt.Printf("%s = *** ENCRYPTED ***\n", key) + } else { + fmt.Printf("%s = %s\n", key, value) + } + } + fmt.Printf("--------------------\n") +} + +// OkRemote prints the contents of the remote and ask if it is OK +func OkRemote(name string) bool { + ShowRemote(name) + switch i := CommandDefault([]string{"yYes this is OK", "eEdit this remote", "dDelete this remote"}, 0); i { + case 'y': + return true + case 'e': + return false + case 'd': + Data.DeleteSection(name) + return true + default: + fs.Errorf(nil, "Bad choice %c", i) + } + return false +} + +// RemoteConfig runs the config helper for the remote if needed +func RemoteConfig(ctx context.Context, name string) { + fmt.Printf("Remote config\n") + f := mustFindByName(name) + if f.Config != nil { + m := fs.ConfigMap(f, name) + f.Config(ctx, name, m) + } +} + +// matchProvider returns true if provider matches the providerConfig string. +// +// The providerConfig string can either be a list of providers to +// match, or if it starts with "!" it will be a list of providers not +// to match. +// +// If either providerConfig or provider is blank then it will return true +func matchProvider(providerConfig, provider string) bool { + if providerConfig == "" || provider == "" { + return true + } + negate := false + if strings.HasPrefix(providerConfig, "!") { + providerConfig = providerConfig[1:] + negate = true + } + providers := strings.Split(providerConfig, ",") + matched := false + for _, p := range providers { + if p == provider { + matched = true + break + } + } + if negate { + return !matched + } + return matched +} + +// ChooseOption asks the user to choose an option +func ChooseOption(o *fs.Option, name string) string { + var subProvider = getWithDefault(name, fs.ConfigProvider, "") + fmt.Println(o.Help) + if o.IsPassword { + actions := []string{"yYes type in my own password", "gGenerate random password"} + defaultAction := -1 + if !o.Required { + defaultAction = len(actions) + actions = append(actions, "nNo leave this optional password blank") + } + var password string + var err error + switch i := CommandDefault(actions, defaultAction); i { + case 'y': + password = ChangePassword("the") + case 'g': + for { + fmt.Printf("Password strength in bits.\n64 is just about memorable\n128 is secure\n1024 is the maximum\n") + bits := ChooseNumber("Bits", 64, 1024) + password, err = Password(bits) + if err != nil { + log.Fatalf("Failed to make password: %v", err) + } + fmt.Printf("Your password is: %s\n", password) + fmt.Printf("Use this password? Please note that an obscured version of this \npassword (and not the " + + "password itself) will be stored under your \nconfiguration file, so keep this generated password " + + "in a safe place.\n") + if Confirm(true) { + break + } + } + case 'n': + return "" + default: + fs.Errorf(nil, "Bad choice %c", i) + } + return obscure.MustObscure(password) + } + what := fmt.Sprintf("%T value", o.Default) + switch o.Default.(type) { + case bool: + what = "boolean value (true or false)" + case fs.SizeSuffix: + what = "size with suffix k,M,G,T" + case fs.Duration: + what = "duration s,m,h,d,w,M,y" + case int, int8, int16, int32, int64: + what = "signed integer" + case uint, byte, uint16, uint32, uint64: + what = "unsigned integer" + } + var in string + for { + fmt.Printf("Enter a %s. Press Enter for the default (%q).\n", what, fmt.Sprint(o.Default)) + if len(o.Examples) > 0 { + var values []string + var help []string + for _, example := range o.Examples { + if matchProvider(example.Provider, subProvider) { + values = append(values, example.Value) + help = append(help, example.Help) + } + } + in = Choose(o.Name, values, help, true) + } else { + fmt.Printf("%s> ", o.Name) + in = ReadLine() + } + if in == "" { + if o.Required && fmt.Sprint(o.Default) == "" { + fmt.Printf("This value is required and it has no default.\n") + continue + } + break + } + newIn, err := configstruct.StringToInterface(o.Default, in) + if err != nil { + fmt.Printf("Failed to parse %q: %v\n", in, err) + continue + } + in = fmt.Sprint(newIn) // canonicalise + break + } + return in +} + +// NewRemoteName asks the user for a name for a new remote +func NewRemoteName() (name string) { + for { + fmt.Printf("name> ") + name = ReadLine() + if Data.HasSection(name) { + fmt.Printf("Remote %q already exists.\n", name) + continue + } + err := fspath.CheckConfigName(name) + switch { + case name == "": + fmt.Printf("Can't use empty name.\n") + case driveletter.IsDriveLetter(name): + fmt.Printf("Can't use %q as it can be confused with a drive letter.\n", name) + case err != nil: + fmt.Printf("Can't use %q as %v.\n", name, err) + default: + return name + } + } +} + +// editOptions edits the options. If new is true then it just allows +// entry and doesn't show any old values. +func editOptions(ri *fs.RegInfo, name string, isNew bool) { + fmt.Printf("** See help for %s backend at: https://rclone.org/%s/ **\n\n", ri.Name, ri.FileName()) + hasAdvanced := false + for _, advanced := range []bool{false, true} { + if advanced { + if !hasAdvanced { + break + } + fmt.Printf("Edit advanced config? (y/n)\n") + if !Confirm(false) { + break + } + } + for _, option := range ri.Options { + isVisible := option.Hide&fs.OptionHideConfigurator == 0 + hasAdvanced = hasAdvanced || (option.Advanced && isVisible) + if option.Advanced != advanced { + continue + } + subProvider := getWithDefault(name, fs.ConfigProvider, "") + if matchProvider(option.Provider, subProvider) && isVisible { + if !isNew { + fmt.Printf("Value %q = %q\n", option.Name, FileGet(name, option.Name)) + fmt.Printf("Edit? (y/n)>\n") + if !Confirm(false) { + continue + } + } + FileSet(name, option.Name, ChooseOption(&option, name)) + } + } + } +} + +// NewRemote make a new remote from its name +func NewRemote(ctx context.Context, name string) { + var ( + newType string + ri *fs.RegInfo + err error + ) + + // Set the type first + for { + newType = ChooseOption(fsOption(), name) + ri, err = fs.Find(newType) + if err != nil { + fmt.Printf("Bad remote %q: %v\n", newType, err) + continue + } + break + } + Data.SetValue(name, "type", newType) + + editOptions(ri, name, true) + RemoteConfig(ctx, name) + if OkRemote(name) { + SaveConfig() + return + } + EditRemote(ctx, ri, name) +} + +// EditRemote gets the user to edit a remote +func EditRemote(ctx context.Context, ri *fs.RegInfo, name string) { + ShowRemote(name) + fmt.Printf("Edit remote\n") + for { + editOptions(ri, name, false) + if OkRemote(name) { + break + } + } + SaveConfig() + RemoteConfig(ctx, name) +} + +// DeleteRemote gets the user to delete a remote +func DeleteRemote(name string) { + Data.DeleteSection(name) + SaveConfig() +} + +// copyRemote asks the user for a new remote name and copies name into +// it. Returns the new name. +func copyRemote(name string) string { + newName := NewRemoteName() + // Copy the keys + for _, key := range Data.GetKeyList(name) { + value := getWithDefault(name, key, "") + Data.SetValue(newName, key, value) + } + return newName +} + +// RenameRemote renames a config section +func RenameRemote(name string) { + fmt.Printf("Enter new name for %q remote.\n", name) + newName := copyRemote(name) + if name != newName { + Data.DeleteSection(name) + SaveConfig() + } +} + +// CopyRemote copies a config section +func CopyRemote(name string) { + fmt.Printf("Enter name for copy of %q remote.\n", name) + copyRemote(name) + SaveConfig() +} + +// ShowConfigLocation prints the location of the config file in use +func ShowConfigLocation() { + if _, err := os.Stat(ConfigPath); os.IsNotExist(err) { + fmt.Println("Configuration file doesn't exist, but rclone will use this path:") + } else { + fmt.Println("Configuration file is stored at:") + } + fmt.Printf("%s\n", ConfigPath) +} + +// ShowConfig prints the (unencrypted) config options +func ShowConfig() { + str, err := Data.Serialize() + if err != nil { + log.Fatalf("Failed to serialize config: %v", err) + } + if str == "" { + str = "; empty config\n" + } + fmt.Printf("%s", str) +} + +// EditConfig edits the config file interactively +func EditConfig(ctx context.Context) { + for { + haveRemotes := len(Data.GetSectionList()) != 0 + what := []string{"eEdit existing remote", "nNew remote", "dDelete remote", "rRename remote", "cCopy remote", "sSet configuration password", "qQuit config"} + if haveRemotes { + fmt.Printf("Current remotes:\n\n") + ShowRemotes() + fmt.Printf("\n") + } else { + fmt.Printf("No remotes found - make a new one\n") + // take 2nd item and last 2 items of menu list + what = append(what[1:2], what[len(what)-2:]...) + } + switch i := Command(what); i { + case 'e': + name := ChooseRemote() + fs := mustFindByName(name) + EditRemote(ctx, fs, name) + case 'n': + NewRemote(ctx, NewRemoteName()) + case 'd': + name := ChooseRemote() + DeleteRemote(name) + case 'r': + RenameRemote(ChooseRemote()) + case 'c': + CopyRemote(ChooseRemote()) + case 's': + SetPassword() + case 'q': + return + + } + } +} + +// Suppress the confirm prompts by altering the context config +func suppressConfirm(ctx context.Context) context.Context { + newCtx, ci := fs.AddConfig(ctx) + ci.AutoConfirm = true + return newCtx +} + +// checkPassword normalises and validates the password +func checkPassword(password string) (string, error) { + if !utf8.ValidString(password) { + return "", errors.New("password contains invalid utf8 characters") + } + // Check for leading/trailing whitespace + trimmedPassword := strings.TrimSpace(password) + // Warn user if password has leading+trailing whitespace + if len(password) != len(trimmedPassword) { + _, _ = fmt.Fprintln(os.Stderr, "Your password contains leading/trailing whitespace - in previous versions of rclone this was stripped") + } + // Normalize to reduce weird variations. + password = norm.NFKC.String(password) + if len(password) == 0 || len(trimmedPassword) == 0 { + return "", errors.New("no characters in password") + } + return password, nil +} + +// GetPassword asks the user for a password with the prompt given. +func GetPassword(prompt string) string { + _, _ = fmt.Fprintln(PasswordPromptOutput, prompt) + for { + _, _ = fmt.Fprint(PasswordPromptOutput, "password:") + password := ReadPassword() + password, err := checkPassword(password) + if err == nil { + return password + } + _, _ = fmt.Fprintf(os.Stderr, "Bad password: %v\n", err) + } +} + +// ChangePassword will query the user twice for the named password. If +// the same password is entered it is returned. +func ChangePassword(name string) string { + for { + a := GetPassword(fmt.Sprintf("Enter %s password:", name)) + b := GetPassword(fmt.Sprintf("Confirm %s password:", name)) + if a == b { + return a + } + fmt.Println("Passwords do not match!") + } +} + +// SetPassword will allow the user to modify the current +// configuration encryption settings. +func SetPassword() { + for { + if len(configKey) > 0 { + fmt.Println("Your configuration is encrypted.") + what := []string{"cChange Password", "uUnencrypt configuration", "qQuit to main menu"} + switch i := Command(what); i { + case 'c': + changeConfigPassword() + SaveConfig() + fmt.Println("Password changed") + continue + case 'u': + configKey = nil + SaveConfig() + continue + case 'q': + return + } + + } else { + fmt.Println("Your configuration is not encrypted.") + fmt.Println("If you add a password, you will protect your login information to cloud services.") + what := []string{"aAdd Password", "qQuit to main menu"} + switch i := Command(what); i { + case 'a': + changeConfigPassword() + SaveConfig() + fmt.Println("Password set") + continue + case 'q': + return + } + } + } +} diff --git a/fs/config/config_internal_test.go b/fs/config/ui_internal_test.go similarity index 100% rename from fs/config/config_internal_test.go rename to fs/config/ui_internal_test.go diff --git a/fs/config/ui_test.go b/fs/config/ui_test.go new file mode 100644 index 000000000..7bbaeab91 --- /dev/null +++ b/fs/config/ui_test.go @@ -0,0 +1,242 @@ +// These are in an external package because we need to import configfile +// +// Internal tests are in ui_internal_test.go + +package config_test + +import ( + "context" + "fmt" + "io/ioutil" + "os" + "testing" + + "github.com/rclone/rclone/fs" + "github.com/rclone/rclone/fs/config" + "github.com/rclone/rclone/fs/config/configfile" + "github.com/rclone/rclone/fs/config/obscure" + "github.com/rclone/rclone/fs/rc" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func testConfigFile(t *testing.T, configFileName string) func() { + ctx := context.Background() + ci := fs.GetConfig(ctx) + config.ClearConfigPassword() + _ = os.Unsetenv("_RCLONE_CONFIG_KEY_FILE") + _ = os.Unsetenv("RCLONE_CONFIG_PASS") + // create temp config file + tempFile, err := ioutil.TempFile("", configFileName) + assert.NoError(t, err) + path := tempFile.Name() + assert.NoError(t, tempFile.Close()) + + // temporarily adapt configuration + oldOsStdout := os.Stdout + oldConfigPath := config.ConfigPath + oldConfig := *ci + oldConfigFile := config.Data + oldReadLine := config.ReadLine + oldPassword := config.Password + os.Stdout = nil + config.ConfigPath = path + ci = &fs.ConfigInfo{} + + configfile.LoadConfig(ctx) + assert.Equal(t, []string{}, config.Data.GetSectionList()) + + // Fake a remote + fs.Register(&fs.RegInfo{ + Name: "config_test_remote", + Options: fs.Options{ + { + Name: "bool", + Default: false, + IsPassword: false, + }, + { + Name: "pass", + Default: "", + IsPassword: true, + }, + }, + }) + + // Undo the above + return func() { + err := os.Remove(path) + assert.NoError(t, err) + + os.Stdout = oldOsStdout + config.ConfigPath = oldConfigPath + config.ReadLine = oldReadLine + config.Password = oldPassword + *ci = oldConfig + config.Data = oldConfigFile + + _ = os.Unsetenv("_RCLONE_CONFIG_KEY_FILE") + _ = os.Unsetenv("RCLONE_CONFIG_PASS") + } +} + +// makeReadLine makes a simple readLine which returns a fixed list of +// strings +func makeReadLine(answers []string) func() string { + i := 0 + return func() string { + i = i + 1 + return answers[i-1] + } +} + +func TestCRUD(t *testing.T) { + defer testConfigFile(t, "crud.conf")() + ctx := context.Background() + + // script for creating remote + config.ReadLine = makeReadLine([]string{ + "config_test_remote", // type + "true", // bool value + "y", // type my own password + "secret", // password + "secret", // repeat + "y", // looks good, save + }) + config.NewRemote(ctx, "test") + + assert.Equal(t, []string{"test"}, config.Data.GetSectionList()) + assert.Equal(t, "config_test_remote", config.FileGet("test", "type")) + assert.Equal(t, "true", config.FileGet("test", "bool")) + assert.Equal(t, "secret", obscure.MustReveal(config.FileGet("test", "pass"))) + + // normal rename, test → asdf + config.ReadLine = makeReadLine([]string{ + "asdf", + "asdf", + "asdf", + }) + config.RenameRemote("test") + + assert.Equal(t, []string{"asdf"}, config.Data.GetSectionList()) + assert.Equal(t, "config_test_remote", config.FileGet("asdf", "type")) + assert.Equal(t, "true", config.FileGet("asdf", "bool")) + assert.Equal(t, "secret", obscure.MustReveal(config.FileGet("asdf", "pass"))) + + // delete remote + config.DeleteRemote("asdf") + assert.Equal(t, []string{}, config.Data.GetSectionList()) +} + +func TestChooseOption(t *testing.T) { + defer testConfigFile(t, "crud.conf")() + ctx := context.Background() + + // script for creating remote + config.ReadLine = makeReadLine([]string{ + "config_test_remote", // type + "false", // bool value + "x", // bad choice + "g", // generate password + "1024", // very big + "y", // password OK + "y", // looks good, save + }) + config.Password = func(bits int) (string, error) { + assert.Equal(t, 1024, bits) + return "not very random password", nil + } + config.NewRemote(ctx, "test") + + assert.Equal(t, "false", config.FileGet("test", "bool")) + assert.Equal(t, "not very random password", obscure.MustReveal(config.FileGet("test", "pass"))) + + // script for creating remote + config.ReadLine = makeReadLine([]string{ + "config_test_remote", // type + "true", // bool value + "n", // not required + "y", // looks good, save + }) + config.NewRemote(ctx, "test") + + assert.Equal(t, "true", config.FileGet("test", "bool")) + assert.Equal(t, "", config.FileGet("test", "pass")) +} + +func TestNewRemoteName(t *testing.T) { + defer testConfigFile(t, "crud.conf")() + ctx := context.Background() + + // script for creating remote + config.ReadLine = makeReadLine([]string{ + "config_test_remote", // type + "true", // bool value + "n", // not required + "y", // looks good, save + }) + config.NewRemote(ctx, "test") + + config.ReadLine = makeReadLine([]string{ + "test", // already exists + "", // empty string not allowed + "bad@characters", // bad characters + "newname", // OK + }) + + assert.Equal(t, "newname", config.NewRemoteName()) +} + +func TestCreateUpdatePasswordRemote(t *testing.T) { + ctx := context.Background() + defer testConfigFile(t, "update.conf")() + + for _, doObscure := range []bool{false, true} { + for _, noObscure := range []bool{false, true} { + if doObscure && noObscure { + break + } + t.Run(fmt.Sprintf("doObscure=%v,noObscure=%v", doObscure, noObscure), func(t *testing.T) { + require.NoError(t, config.CreateRemote(ctx, "test2", "config_test_remote", rc.Params{ + "bool": true, + "pass": "potato", + }, doObscure, noObscure)) + + assert.Equal(t, []string{"test2"}, config.Data.GetSectionList()) + assert.Equal(t, "config_test_remote", config.FileGet("test2", "type")) + assert.Equal(t, "true", config.FileGet("test2", "bool")) + gotPw := config.FileGet("test2", "pass") + if !noObscure { + gotPw = obscure.MustReveal(gotPw) + } + assert.Equal(t, "potato", gotPw) + + wantPw := obscure.MustObscure("potato2") + require.NoError(t, config.UpdateRemote(ctx, "test2", rc.Params{ + "bool": false, + "pass": wantPw, + "spare": "spare", + }, doObscure, noObscure)) + + assert.Equal(t, []string{"test2"}, config.Data.GetSectionList()) + assert.Equal(t, "config_test_remote", config.FileGet("test2", "type")) + assert.Equal(t, "false", config.FileGet("test2", "bool")) + gotPw = config.FileGet("test2", "pass") + if doObscure { + gotPw = obscure.MustReveal(gotPw) + } + assert.Equal(t, wantPw, gotPw) + + require.NoError(t, config.PasswordRemote(ctx, "test2", rc.Params{ + "pass": "potato3", + })) + + assert.Equal(t, []string{"test2"}, config.Data.GetSectionList()) + assert.Equal(t, "config_test_remote", config.FileGet("test2", "type")) + assert.Equal(t, "false", config.FileGet("test2", "bool")) + assert.Equal(t, "potato3", obscure.MustReveal(config.FileGet("test2", "pass"))) + }) + } + } + +}