Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
  • Loading branch information
bep committed Oct 21, 2025
commit 27e5398c5a5b4f1adb3cdfcf90c8c8f062086e81
61 changes: 61 additions & 0 deletions common/hdebug/debug.go
Original file line number Diff line number Diff line change
@@ -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!")
}
}
37 changes: 32 additions & 5 deletions common/maps/params.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ package maps
import (
"errors"
"fmt"
xmaps "maps"
"strings"

"github.com/spf13/cast"
Expand All @@ -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 {
Expand All @@ -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
}
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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)
Expand Down
13 changes: 10 additions & 3 deletions config/allconfig/allconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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) {
Expand All @@ -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))

Expand Down
3 changes: 1 addition & 2 deletions hugolib/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 := `
Expand All @@ -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]]|")
Expand Down
Loading