From 5753fd5df4aaf00bca5d934b0776deceae7a3e2a Mon Sep 17 00:00:00 2001 From: Nick Craig-Wood Date: Tue, 23 Jun 2026 11:55:58 +0100 Subject: [PATCH] config: add config unset command to remove options from a remote - fixes #9541 Previously the only way to remove an option from a remote was to set it to an empty string, which is not the same as deleting it - a present but empty value overrides the option's default whereas a deleted key restores it. Editing the file by hand isn't an option for an encrypted config either. This adds a "config unset" command and a "config/unset" rc endpoint to remove one or more keys from an existing remote. --- cmd/config/config.go | 35 +++++++++++++++++++++++++++++++++++ fs/config/config.go | 28 ++++++++++++++++++++++++++++ fs/config/rc.go | 38 ++++++++++++++++++++++++++++++++++++++ fs/config/rc_test.go | 23 +++++++++++++++++++++++ fs/config/ui_test.go | 34 ++++++++++++++++++++++++++++++++++ 5 files changed, 158 insertions(+) diff --git a/cmd/config/config.go b/cmd/config/config.go index c06dff3e3..df86f0d7c 100644 --- a/cmd/config/config.go +++ b/cmd/config/config.go @@ -32,6 +32,7 @@ func init() { configCommand.AddCommand(configCreateCommand) configCommand.AddCommand(configUpdateCommand) configCommand.AddCommand(configDeleteCommand) + configCommand.AddCommand(configUnsetCommand) configCommand.AddCommand(configPasswordCommand) configCommand.AddCommand(configReconnectCommand) configCommand.AddCommand(configDisconnectCommand) @@ -388,6 +389,40 @@ var configDeleteCommand = &cobra.Command{ }, } +var configUnsetCommand = &cobra.Command{ + Use: "unset name [key]+", + Short: `Unset options in an existing remote.`, + Long: strings.ReplaceAll(`Remove one or more options from an existing remote. The options to +remove should be passed in as a list of key names. + +For example, to remove the |client_id| and |client_secret| options from +a remote of name myremote you would do: + +|||sh +rclone config unset myremote client_id client_secret +||| + +This removes the keys from the config file entirely, which is different +from setting them to an empty string with |config update|. Removing a +key restores rclone's default behaviour for that option, whereas setting +it to an empty string overrides the default with an empty value. + +You can't unset the |type| of a remote - use |config delete| to remove +the whole remote instead.`, "|", "`"), + Annotations: map[string]string{ + "versionIntroduced": "v1.75", + }, + RunE: func(command *cobra.Command, args []string) error { + cmd.CheckArgs(2, 256, command, args) + _, err := config.UnsetRemote(args[0], args[1:]...) + if err != nil { + return err + } + config.ShowRemote(args[0]) + return nil + }, +} + var configPasswordCommand = &cobra.Command{ Use: "password name [key value]+", Short: `Update password in an existing remote.`, diff --git a/fs/config/config.go b/fs/config/config.go index d01306534..c74bea861 100644 --- a/fs/config/config.go +++ b/fs/config/config.go @@ -661,6 +661,34 @@ func PasswordRemote(ctx context.Context, name string, keyValues rc.Params) error return err } +// UnsetRemote removes the named keys from the remote of name. +// +// It returns the keys that were actually removed - keys that didn't +// exist are silently ignored. It returns an error if the remote +// doesn't exist or if an attempt is made to unset the "type" key. +func UnsetRemote(name string, keys ...string) (removed []string, err error) { + err = fspath.CheckConfigName(name) + if err != nil { + return nil, err + } + if GetValue(name, "type") == "" { + return nil, fmt.Errorf("remote %q doesn't exist", name) + } + for _, key := range keys { + if key == "type" { + return nil, errors.New(`can't unset the "type" of a remote - use "config delete" to remove the whole remote`) + } + } + for _, key := range keys { + if FileDeleteKey(name, key) { + removed = append(removed, key) + } + } + SaveConfig() + cache.ClearConfig(name) // remove any remotes based on this config from the cache + return removed, nil +} + // JSONListProviders prints all the providers and options in JSON format func JSONListProviders() error { b, err := json.MarshalIndent(fs.Registry, "", " ") diff --git a/fs/config/rc.go b/fs/config/rc.go index 1f8e1b1d0..3d656dce6 100644 --- a/fs/config/rc.go +++ b/fs/config/rc.go @@ -256,6 +256,44 @@ func rcDelete(ctx context.Context, in rc.Params) (out rc.Params, err error) { return nil, nil } +func init() { + rc.Add(rc.Call{ + Path: "config/unset", + Fn: rcUnset, + Title: "Unset keys in a remote in the config file.", + Help: ` +Parameters: + +- name - name of remote +- keys - a list of key names to remove + +Returns: + +- removed - a list of the keys that were actually removed + +See the [config unset](/commands/rclone_config_unset/) command for more information on the above. +`, + }) +} + +// Remove keys from a remote in the config file +func rcUnset(ctx context.Context, in rc.Params) (out rc.Params, err error) { + name, err := in.GetString("name") + if err != nil { + return nil, err + } + var keys []string + err = in.GetStruct("keys", &keys) + if err != nil { + return nil, err + } + removed, err := UnsetRemote(name, keys...) + if err != nil { + return nil, err + } + return rc.Params{"removed": removed}, nil +} + func init() { rc.Add(rc.Call{ Path: "config/setpath", diff --git a/fs/config/rc_test.go b/fs/config/rc_test.go index 574246c2c..aa0ea48de 100644 --- a/fs/config/rc_test.go +++ b/fs/config/rc_test.go @@ -134,6 +134,29 @@ func TestRc(t *testing.T) { assert.Equal(t, pw2, obscure.MustReveal(config.GetValue(testName, "test_key2"))) }) + t.Run("Unset", func(t *testing.T) { + config.FileSetValue(testName, "unset_key", "to be removed") + call := rc.Calls.Get("config/unset") + assert.NotNil(t, call) + in := rc.Params{ + "name": testName, + "keys": []string{"unset_key", "missing_key"}, + } + out, err := call.Fn(context.Background(), in) + require.NoError(t, err) + require.NotNil(t, out) + + // Only the key that existed is reported as removed + var removed []string + err = out.GetStruct("removed", &removed) + require.NoError(t, err) + assert.Equal(t, []string{"unset_key"}, removed) + + // The key is gone from the config file entirely + _, found := config.FileGetValue(testName, "unset_key") + assert.False(t, found) + }) + // Delete the test remote call = rc.Calls.Get("config/delete") assert.NotNil(t, call) diff --git a/fs/config/ui_test.go b/fs/config/ui_test.go index 7320aca47..22947d1cb 100644 --- a/fs/config/ui_test.go +++ b/fs/config/ui_test.go @@ -250,6 +250,40 @@ func TestCreateUpdatePasswordRemote(t *testing.T) { } } +func TestUnsetRemote(t *testing.T) { + ctx := context.Background() + defer testConfigFile(t, simpleOptions, "unset.conf")() + + _, err := config.CreateRemote(ctx, "test", "config_test_remote", rc.Params{ + "bool": true, + "spare": "spare", + }, config.UpdateRemoteOpt{}) + require.NoError(t, err) + _, found := config.FileGetValue("test", "spare") + require.True(t, found) + + // Unsetting a missing remote is an error + _, err = config.UnsetRemote("notfound", "spare") + assert.Error(t, err) + + // Unsetting the type is an error and removes nothing + _, err = config.UnsetRemote("test", "type") + assert.Error(t, err) + assert.Equal(t, "config_test_remote", config.GetValue("test", "type")) + + // Unset an existing key and a missing one - only the existing one + // is reported as removed + removed, err := config.UnsetRemote("test", "spare", "missing") + require.NoError(t, err) + assert.Equal(t, []string{"spare"}, removed) + + // The key is gone entirely, not just set to empty + _, found = config.FileGetValue("test", "spare") + assert.False(t, found) + // Other keys are untouched + assert.Equal(t, "true", config.GetValue("test", "bool")) +} + func TestDefaultRequired(t *testing.T) { // By default options are optional (sic), regardless if a default value is defined. // Setting Required=true means empty string is no longer allowed, except when