From a1307700dd6f64341b5e863f28807874d7c3ad9f Mon Sep 17 00:00:00 2001 From: =?utf8?q?Bj=C3=B8rn=20Erik=20Pedersen?= Date: Tue, 21 Oct 2025 11:09:29 +0200 Subject: [PATCH] config: Clone language map entries before modifying them Now, with YAML anchor and alias support, these can point to shared data, which must not be modified in place. Fixes #14072 --- common/hdebug/debug.go | 61 +++++++++++++++++++++++++++++++++++ common/maps/params.go | 37 ++++++++++++++++++--- config/allconfig/allconfig.go | 13 ++++++-- hugolib/config_test.go | 3 +- 4 files changed, 104 insertions(+), 10 deletions(-) create mode 100644 common/hdebug/debug.go diff --git a/common/hdebug/debug.go b/common/hdebug/debug.go new file mode 100644 index 000000000..a9773676d --- /dev/null +++ b/common/hdebug/debug.go @@ -0,0 +1,61 @@ +// Copyright 2025 The Hugo Authors. All rights reserved. +// +// 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 hdebug + +import ( + "fmt" + "strings" + + "github.com/gohugoio/hugo/common/types" + "github.com/gohugoio/hugo/htesting" +) + +// Printf is a debug print function that should be removed before committing code to the repository. +func Printf(format string, args ...any) { + panicIfRealCI() + if len(args) == 1 && !strings.Contains(format, "%") { + format = format + ": %v" + } + if !strings.HasSuffix(format, "\n") { + format = format + "\n" + } + fmt.Printf(format, args...) +} + +func AssertNotNil(a ...any) { + panicIfRealCI() + for _, v := range a { + if types.IsNil(v) { + panic("hdebug.AssertNotNil: value is nil") + } + } +} + +func Panicf(format string, args ...any) { + panicIfRealCI() + // fmt.Println(stack()) + if len(args) == 1 && !strings.Contains(format, "%") { + format = format + ": %v" + } + if !strings.HasSuffix(format, "\n") { + format = format + "\n" + } + panic(fmt.Sprintf(format, args...)) +} + +func panicIfRealCI() { + if htesting.IsRealCI() { + panic("This debug statement should be removed before committing code!") + } +} diff --git a/common/maps/params.go b/common/maps/params.go index aa1dce20c..ce03b73f1 100644 --- a/common/maps/params.go +++ b/common/maps/params.go @@ -16,7 +16,6 @@ package maps import ( "errors" "fmt" - xmaps "maps" "strings" "github.com/spf13/cast" @@ -42,6 +41,14 @@ func (p Params) GetNested(indices ...string) any { // SetParams overwrites values in dst with values in src for common or new keys. // This is done recursively. func SetParams(dst, src Params) { + setParams(dst, src, 0) +} + +func setParams(dst, src Params, depth int) { + const maxDepth = 1000 + if depth > maxDepth { + panic(errors.New("max depth exceeded")) + } for k, v := range src { vv, found := dst[k] if !found { @@ -50,7 +57,7 @@ func SetParams(dst, src Params) { switch vvv := vv.(type) { case Params: if pv, ok := v.(Params); ok { - SetParams(vvv, pv) + setParams(vvv, pv, depth+1) } else { dst[k] = v } @@ -116,9 +123,6 @@ func (p Params) merge(ps ParamsMergeStrategy, pp Params) { } } } else if !noUpdate { - if vvv, ok := v.(Params); ok { - v = xmaps.Clone(vvv) - } p[k] = v } @@ -356,6 +360,29 @@ func PrepareParams(m Params) { } } +// CloneParamsDeep does a deep clone of the given Params, +// meaning that any nested Params will be cloned as well. +func CloneParamsDeep(m Params) Params { + return cloneParamsDeep(m, 0) +} + +func cloneParamsDeep(m Params, depth int) Params { + const maxDepth = 1000 + if depth > maxDepth { + panic(errors.New("max depth exceeded")) + } + m2 := make(Params) + for k, v := range m { + switch vv := v.(type) { + case Params: + m2[k] = cloneParamsDeep(vv, depth+1) + default: + m2[k] = v + } + } + return m2 +} + // PrepareParamsClone is like PrepareParams, but it does not modify the input. func PrepareParamsClone(m Params) Params { m2 := make(Params) diff --git a/config/allconfig/allconfig.go b/config/allconfig/allconfig.go index c21a18f94..4e9cbcc5a 100644 --- a/config/allconfig/allconfig.go +++ b/config/allconfig/allconfig.go @@ -55,8 +55,6 @@ import ( "github.com/gohugoio/hugo/resources/page" "github.com/gohugoio/hugo/resources/page/pagemeta" "github.com/spf13/afero" - - xmaps "maps" ) // InternalConfig is the internal configuration for Hugo, not read from any user provided config file. @@ -1071,6 +1069,14 @@ func fromLoadConfigResult(fs afero.Fs, logger loggers.Logger, res config.LoadCon // baseURL configure don the language level is a multihost setup. isMultihost = true } + + if p, ok := vv.(maps.Params); ok { + // With the introduction of YAML anchor and alias support, language config entries + // may be contain shared references. + // This also break potential cycles. + vv = maps.CloneParamsDeep(p) + } + mergedConfig.Set(kk, vv) rootv := cfg.Get(kk) if rootv != nil && cfg.IsSet(kk) { @@ -1081,7 +1087,8 @@ func fromLoadConfigResult(fs afero.Fs, logger loggers.Logger, res config.LoadCon differentRootKeys = append(differentRootKeys, kk) // Use the language value as base. - mergedConfigEntry := xmaps.Clone(vvv) + // Note that this is already cloned above. + mergedConfigEntry := vvv // Merge in the root value. maps.MergeParams(mergedConfigEntry, rootv.(maps.Params)) diff --git a/hugolib/config_test.go b/hugolib/config_test.go index 189cec7b6..27e5f6d41 100644 --- a/hugolib/config_test.go +++ b/hugolib/config_test.go @@ -1619,7 +1619,6 @@ params: *params } func TestConfigYAMLAnchorsCyclicReference(t *testing.T) { - t.Skip("Skip flaky test for now, will be fixed in issue 14072.") t.Parallel() files := ` @@ -1643,7 +1642,7 @@ Params: {{ site.Params }}| ` - for range 3 { + for range 4 { b := Test(t, files) b.AssertFileContent("public/index.html", "Params: map[p3:map[p1:p1alias]]|") b.AssertFileContent("public/sv/index.html", "Params: map[p1:p1alias p3:map[p1:p1alias]]|") -- 2.39.5