From 48bb46f2e37e247e7470fb6a62eb01c8c41f211c Mon Sep 17 00:00:00 2001 From: Dean Chen <862469039@qq.com> Date: Mon, 21 Sep 2026 10:40:36 +0500 Subject: [PATCH] configfile: keep unknown JSON keys when saving config.json login, logout, and context use rewrite the whole file from the struct, so keys this CLI version does not know about (experimental on older builds, or anything added later) get dropped. Remember those keys on load and write them back on save. Fixes #5559 Signed-off-by: Dean Chen <862469039@qq.com> --- cli/config/config_test.go | 4 +- cli/config/configfile/file.go | 70 ++++++++++++++++++++++++++++++ cli/config/configfile/file_test.go | 31 +++++++++++++ 3 files changed, 104 insertions(+), 1 deletion(-) diff --git a/cli/config/config_test.go b/cli/config/config_test.go index 922641a726ad..07b86b5e6fb9 100644 --- a/cli/config/config_test.go +++ b/cli/config/config_test.go @@ -378,7 +378,9 @@ func TestLoadDefaultConfigFile(t *testing.T) { expected.CredentialsStore = credStore expected.PsFormat = "format" - assert.Check(t, is.DeepEqual(expected, configFile)) + assert.Check(t, is.Equal(expected.Filename, configFile.Filename)) + assert.Check(t, is.Equal(expected.CredentialsStore, configFile.CredentialsStore)) + assert.Check(t, is.Equal(expected.PsFormat, configFile.PsFormat)) assert.Check(t, is.Equal(buffer.String(), "")) }) diff --git a/cli/config/configfile/file.go b/cli/config/configfile/file.go index e32b1e767cbb..be96aa5d61b6 100644 --- a/cli/config/configfile/file.go +++ b/cli/config/configfile/file.go @@ -12,6 +12,7 @@ import ( "maps" "os" "path/filepath" + "reflect" "strings" "github.com/docker/cli/cli/config/credentials" @@ -75,8 +76,30 @@ type ConfigFile struct { Plugins map[string]map[string]string `json:"plugins,omitempty"` Aliases map[string]string `json:"aliases,omitempty"` Features map[string]string `json:"features,omitempty"` + + // extras keeps JSON keys this version of the CLI does not know about, so a + // later Save does not drop them (login, logout, context switch, …). + extras map[string]json.RawMessage `json:"-"` } +// configFileKnownJSONKeys is the set of json object keys owned by [ConfigFile]. +var configFileKnownJSONKeys = func() map[string]struct{} { + t := reflect.TypeFor[ConfigFile]() + keys := make(map[string]struct{}, t.NumField()) + for i := range t.NumField() { + tag := t.Field(i).Tag.Get("json") + if tag == "" || tag == "-" { + continue + } + name, _, _ := strings.Cut(tag, ",") + if name == "" || name == "-" { + continue + } + keys[name] = struct{}{} + } + return keys +}() + type configEnvAuth struct { Auth string `json:"auth"` } @@ -143,6 +166,53 @@ func (c *ConfigFile) LoadFromReader(configData io.Reader) error { return nil } +// UnmarshalJSON records object keys that are not fields of [ConfigFile]. +func (c *ConfigFile) UnmarshalJSON(data []byte) error { + type plain ConfigFile + if err := json.Unmarshal(data, (*plain)(c)); err != nil { + return err + } + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + extras := make(map[string]json.RawMessage) + for k, v := range raw { + if _, known := configFileKnownJSONKeys[k]; !known { + extras[k] = v + } + } + if len(extras) > 0 { + c.extras = extras + } + return nil +} + +// MarshalJSON writes known fields, then any extra keys captured on load. +func (c *ConfigFile) MarshalJSON() ([]byte, error) { + if c == nil { + return []byte("null"), nil + } + type plain ConfigFile + data, err := json.Marshal((*plain)(c)) + if err != nil { + return nil, err + } + if len(c.extras) == 0 { + return data, nil + } + var encoded map[string]json.RawMessage + if err := json.Unmarshal(data, &encoded); err != nil { + return nil, err + } + for k, v := range c.extras { + if _, exists := encoded[k]; !exists { + encoded[k] = v + } + } + return json.Marshal(encoded) +} + // ContainsAuth returns whether there is authentication configured // in this file or not. func (c *ConfigFile) ContainsAuth() bool { diff --git a/cli/config/configfile/file_test.go b/cli/config/configfile/file_test.go index 92df02c74352..77f3da7e07c5 100644 --- a/cli/config/configfile/file_test.go +++ b/cli/config/configfile/file_test.go @@ -757,3 +757,34 @@ func TestPluginConfig(t *testing.T) { assert.NilError(t, err) golden.Assert(t, string(cfg), "plugin-config-2.golden") } + +func TestSavePreservesUnknownFields(t *testing.T) { + dir := fs.NewDir(t, t.Name()) + defer dir.Remove() + path := dir.Join("config.json") + + const orig = `{ + "experimental": "enabled", + "features": { + "hooks": "true", + "something-else": "false" + }, + "futureThing": {"a": 1} +}` + assert.NilError(t, os.WriteFile(path, []byte(orig), 0o600)) + + cfg := New(path) + assert.NilError(t, cfg.LoadFromReader(bytes.NewReader([]byte(orig)))) + cfg.CurrentContext = "somecontext" + assert.NilError(t, cfg.Save()) + + got, err := os.ReadFile(path) + assert.NilError(t, err) + + var parsed map[string]json.RawMessage + assert.NilError(t, json.Unmarshal(got, &parsed)) + assert.Equal(t, string(parsed["experimental"]), `"enabled"`) + assert.Equal(t, string(parsed["currentContext"]), `"somecontext"`) + assert.Check(t, parsed["features"] != nil, "features should be kept") + assert.Check(t, parsed["futureThing"] != nil, "unknown fields should be kept") +}