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.
This commit is contained in:
Nick Craig-Wood
2026-06-23 11:55:58 +01:00
parent d1e85a7d9c
commit 5753fd5df4
5 changed files with 158 additions and 0 deletions

View File

@@ -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.`,

View File

@@ -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, "", " ")

View File

@@ -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",

View File

@@ -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)

View File

@@ -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