From 0a310cc507535acc10d64906dfe07eddbf8cb3d6 Mon Sep 17 00:00:00 2001 From: Mattia Eleuteri Date: Mon, 9 Nov 2026 11:36:14 +0100 Subject: [PATCH] fix(api): default into typed nil maps and slices A nil map or slice held in an interface is not == nil, so the defaulting walk took it for a present value: writing the first default into a nil map panicked, and a nil slice kept no default. The registry always passes a decoded map, but Apply is now a shared entry point and nothing guarantees that for its other callers. Assisted-by: LLM Signed-off-by: Mattia Eleuteri --- pkg/apis/apps/defaulting/defaulting.go | 4 +- pkg/apis/apps/defaulting/defaulting_test.go | 54 +++++++++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 pkg/apis/apps/defaulting/defaulting_test.go diff --git a/pkg/apis/apps/defaulting/defaulting.go b/pkg/apis/apps/defaulting/defaulting.go index bbec2fa88..f361a7bfd 100644 --- a/pkg/apis/apps/defaulting/defaulting.go +++ b/pkg/apis/apps/defaulting/defaulting.go @@ -101,7 +101,7 @@ func applyDefaults(v any, s *structuralschema.Structural, top bool) (any, error) switch effType { case "object": mv, isMap := v.(map[string]any) - if !isMap || v == nil { + if !isMap || mv == nil { if s.Default.Object != nil && !top { if dm, ok := s.Default.Object.(map[string]any); ok { mv = cloneMap(dm) @@ -144,7 +144,7 @@ func applyDefaults(v any, s *structuralschema.Structural, top bool) (any, error) case "array": sl, isSlice := v.([]any) - if !isSlice || v == nil { + if !isSlice || sl == nil { if s.Default.Object != nil { if ds, ok := s.Default.Object.([]any); ok { sl = cloneSlice(ds) diff --git a/pkg/apis/apps/defaulting/defaulting_test.go b/pkg/apis/apps/defaulting/defaulting_test.go new file mode 100644 index 000000000..0ce580e56 --- /dev/null +++ b/pkg/apis/apps/defaulting/defaulting_test.go @@ -0,0 +1,54 @@ +/* +Copyright 2026 The Cozystack Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package defaulting + +import "testing" + +// A nil map[string]any in an interface is not == nil, so it has to be +// recognised as empty explicitly, at the root and below it, or writing the +// first default into it panics. +func TestApplyDefaultsTypedNilMaps(t *testing.T) { + s, err := BuildSchema(`{"type":"object","properties":{ + "replicas":{"type":"integer","default":2}, + "roles":{"type":"array","default":["worker"],"items":{"type":"string"}}, + "resources":{"type":"object","default":{},"properties":{"preset":{"type":"string","default":"t1.micro"}}}}}`) + if err != nil { + t.Fatal(err) + } + + var root map[string]any + if err := Apply(&root, s); err != nil { + t.Fatal(err) + } + if root["replicas"] != int64(2) { + t.Errorf("root: replicas = %#v, want 2", root["replicas"]) + } + + var nested map[string]any + var roles []any + root = map[string]any{"resources": nested, "roles": roles} + if err := Apply(&root, s); err != nil { + t.Fatal(err) + } + resources, _ := root["resources"].(map[string]any) + if resources["preset"] != "t1.micro" { + t.Errorf("nested: resources = %#v, want the preset default", root["resources"]) + } + if got, _ := root["roles"].([]any); len(got) != 1 || got[0] != "worker" { + t.Errorf("nested: roles = %#v, want the array default", root["roles"]) + } +}