From 55030eaa86e793083592e2b3fe12e7bd1778482d Mon Sep 17 00:00:00 2001 From: Pablo Garcia Date: Fri, 7 Aug 2026 14:53:18 +0200 Subject: [PATCH] fix(hcl): coerce non-string object keys to strings instead of panicking (#2795) HCL object keys may be non-string literals (e.g. a number like 1). The decoder called cty.Value.AsString on the evaluated key, which panics with 'not a string' for any non-string key type. Mirror OpenTofu/Terragrunt by silently converting non-string keys to their string representation via cty/convert. Add decode scenarios covering numeric and boolean keys. --- pkg/yqlib/decoder_hcl.go | 31 ++++++++++++++++++++++++++++++- pkg/yqlib/hcl_test.go | 14 ++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/pkg/yqlib/decoder_hcl.go b/pkg/yqlib/decoder_hcl.go index 11cd0030..dcd01557 100644 --- a/pkg/yqlib/decoder_hcl.go +++ b/pkg/yqlib/decoder_hcl.go @@ -13,6 +13,7 @@ import ( "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/hclsyntax" "github.com/zclconf/go-cty/cty" + "github.com/zclconf/go-cty/cty/convert" ) type hclDecoder struct { @@ -252,6 +253,22 @@ func addBlockToMapping(parent *CandidateNode, block *hclsyntax.Block, src []byte } } +// hclKeyValueAsString converts an evaluated HCL key expression to its string +// representation. HCL object keys may be non-string literals (e.g. a number +// like `1`); calling AsString on those panics. Mirroring OpenTofu/Terragrunt, +// we silently coerce non-string keys to their string form ("1" -> "1", +// true -> "true") instead of panicking. +func hclKeyValueAsString(keyVal cty.Value) (string, error) { + if keyVal.Type() == cty.String { + return keyVal.AsString(), nil + } + strVal, err := convert.Convert(keyVal, cty.String) + if err != nil { + return "", err + } + return strVal.AsString(), nil +} + func convertHclExprToNode(expr hclsyntax.Expression, src []byte) *CandidateNode { // handle literal values directly switch e := expr.(type) { @@ -338,7 +355,19 @@ func convertHclExprToNode(expr hclsyntax.Expression, src []byte) *CandidateNode } continue } - keyStr := keyVal.AsString() + keyStr, err := hclKeyValueAsString(keyVal) + if err != nil { + // fallback: try to extract key from source + r := item.KeyExpr.Range() + start := r.Start.Byte + end := r.End.Byte + if start >= 0 && end >= start && end <= len(src) { + keyNode := createStringScalarNode(strings.TrimSpace(string(src[start:end]))) + valNode := convertHclExprToNode(item.ValueExpr, src) + m.AddKeyValueChild(keyNode, valNode) + } + continue + } keyNode := createStringScalarNode(keyStr) valNode := convertHclExprToNode(item.ValueExpr, src) m.AddKeyValueChild(keyNode, valNode) diff --git a/pkg/yqlib/hcl_test.go b/pkg/yqlib/hcl_test.go index a8f22065..d543fd03 100644 --- a/pkg/yqlib/hcl_test.go +++ b/pkg/yqlib/hcl_test.go @@ -472,6 +472,20 @@ var hclFormatScenarios = []formatScenario{ expected: "service {\n optional_field = null\n}\n", scenarioType: "roundtrip", }, + { + description: "Non-string object keys are coerced to strings", + skipDoc: true, + input: `intdict = { 1 = {} }`, + expected: "intdict: {\"1\": {}}\n", + scenarioType: "decode", + }, + { + description: "Mixed non-string object keys are coerced to strings", + skipDoc: true, + input: `d = { 1 = "a", 2 = "b", true = "c" }`, + expected: "d: {\"1\": \"a\", \"2\": \"b\", \"true\": \"c\"}\n", + scenarioType: "decode", + }, } func testHclScenario(t *testing.T, s formatScenario) {