Repository: ariga/atlas
Pull request #3495 (2025-05-10 19:37:33)

schemahcl: escape templates in heredoc


Patch:
diff --git a/schemahcl/extension.go b/schemahcl/extension.go
index e43fc6dc..32062dad 100644
--- a/schemahcl/extension.go
+++ b/schemahcl/extension.go
@@ -449,8 +449,10 @@ func (r *Resource) Scan(ext any) error {
 	}
 	v := indirect(reflect.ValueOf(ext))
 	for _, ft := range specFields(ext) {
-		field := v.FieldByName(ft.Name)
-		switch {
+		if !ft.IsExported() {
+			continue
+		}
+		switch field := v.FieldByName(ft.Name); {
 		case ft.omitempty() && isEmpty(field):
 		case ft.isName():
 			if field.Kind() != reflect.String {
diff --git a/schemahcl/schemahcl.go b/schemahcl/schemahcl.go
index 5f4a37d6..b493f087 100644
--- a/schemahcl/schemahcl.go
+++ b/schemahcl/schemahcl.go
@@ -931,7 +931,7 @@ func (s *State) writeAttr(attr *Attr, body *hclwrite.Body) error {
 			body.SetAttributeRaw(attr.K, hclwrite.Tokens{
 				&hclwrite.Token{
 					Type:  hclsyntax.TokenOHeredoc,
-					Bytes: []byte(attr.V.AsString()),
+					Bytes: escapedHeredoc(attr.V.AsString()),
 				},
 			})
 		} else {
@@ -943,6 +943,22 @@ func (s *State) writeAttr(attr *Attr, body *hclwrite.Body) error {
 	return nil
 }
 
+// escapedHeredoc escapes template introducer symbol when marshaling heredoc.
+// See: hcl/hclwrite#escapeQuotedStringLit for reference.
+func escapedHeredoc(s string) []byte {
+	var b bytes.Buffer
+	for i, r := range s {
+		switch r {
+		case '$', '%':
+			if i < len(s)-1 && s[i+1] == '{' {
+				b.WriteRune(r)
+			}
+		}
+		b.WriteRune(r)
+	}
+	return b.Bytes()
+}
+
 func (s *State) findTypeSpec(t string) (*TypeSpec, bool) {
 	for _, v := range s.config.types {
 		if v.T == t {


Test patch:
diff --git a/schemahcl/schemahcl_test.go b/schemahcl/schemahcl_test.go
index db43a175..5b083a65 100644
--- a/schemahcl/schemahcl_test.go
+++ b/schemahcl/schemahcl_test.go
@@ -1260,3 +1260,66 @@ func TestUseTraversal(t *testing.T) {
 		require.Equal(t, tt.want, got)
 	}
 }
+
+func TestEscapeHeredoc(t *testing.T) {
+	type (
+		Attr struct {
+			K string `spec:",name"`
+			V string `spec:"value"`
+		}
+		Block struct {
+			Attrs []*Attr `spec:"attr"`
+		}
+		doc struct {
+			Blocks []*Block `spec:"block"`
+		}
+	)
+	v := &doc{
+		Blocks: []*Block{
+			{
+				Attrs: []*Attr{
+					{
+						K: "inline",
+						V: "Hello ${username}, welcome! %{ if true }you're in%{ endif }",
+					},
+					{
+						K: "multiline",
+						V: `<<-TEXT
+ Hello ${username}, welcome! %{ if true }you're in%{ endif }
+ ${{text}}, ${{ text }}, ${{- text -}}
+ $${{text}}, %%%{text}
+TEXT`,
+					},
+				},
+			},
+		},
+	}
+	buf, err := Marshal(v)
+	require.NoError(t, err)
+	require.Equal(t, `block {
+  attr "inline" {
+    value = "Hello $${username}, welcome! %%{ if true }you're in%%{ endif }"
+  }
+  attr "multiline" {
+    value = <<-TEXT
+ Hello $${username}, welcome! %%{ if true }you're in%%{ endif }
+ $${{text}}, $${{ text }}, $${{- text -}}
+ $$${{text}}, %%%%{text}
+TEXT
+  }
+}
+`, string(buf))
+	var got doc
+	require.NoError(t, New().EvalBytes(buf, &got, nil))
+	require.Equal(t,
+		"Hello ${username}, welcome! %{ if true }you're in%{ endif }",
+		got.Blocks[0].Attrs[0].V,
+	)
+	require.Equal(t,
+		`Hello ${username}, welcome! %{ if true }you're in%{ endif }
+${{text}}, ${{ text }}, ${{- text -}}
+$${{text}}, %%%{text}
+`,
+		got.Blocks[0].Attrs[1].V,
+	)
+}