Skip to content

Commit f751fb1

Browse files
committed
fix: ignore env expansion in YAML comments
1 parent f8c13be commit f751fb1

File tree

4 files changed

+113
-7
lines changed

4 files changed

+113
-7
lines changed

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,16 @@ and this project adheres to [Semantic Versioning][].
1313
### Removed
1414
-->
1515

16+
## [0.1.3][] - 2025-12-15
17+
18+
### Fixed
19+
20+
* Environment variable expansion is now applied only to YAML scalar values.
21+
Comments are no longer processed, preventing accidental expansion or errors
22+
from `${...}` sequences inside comments.
23+
24+
[0.1.3]: https://github.com/WoozyMasta/jamle/compare/v0.1.2...v0.1.3
25+
1626
## [0.1.2][] - 2025-12-01
1727

1828
### Added

go.mod

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ module github.com/woozymasta/jamle
22

33
go 1.18.0
44

5-
require github.com/invopop/yaml v0.3.1
6-
7-
require gopkg.in/yaml.v3 v3.0.1 // indirect
5+
require (
6+
github.com/invopop/yaml v0.3.1
7+
gopkg.in/yaml.v3 v3.0.1
8+
)

jamle.go

Lines changed: 80 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,14 @@ Example usage:
2828
package jamle
2929

3030
import (
31+
"bytes"
3132
"fmt"
3233
"os"
3334
"regexp"
3435
"strings"
3536

3637
"github.com/invopop/yaml"
38+
yamlv3 "gopkg.in/yaml.v3"
3739
)
3840

3941
// placeholders for masking braces in escaped variables
@@ -56,8 +58,82 @@ Before parsing, it recursively expands environment variables within the data.
5658
The function performs up to 10 passes to resolve nested variables (e.g., ${A:=${B}})
5759
and prevents infinite loops.
5860
*/
59-
func Unmarshal(data []byte, v interface{}) error {
60-
str := string(data)
61+
func Unmarshal(data []byte, v any) error {
62+
// Parse into YAML AST (comments are stored in node fields, not in scalar values)
63+
var root yamlv3.Node
64+
dec := yamlv3.NewDecoder(bytes.NewReader(data))
65+
dec.KnownFields(false)
66+
if err := dec.Decode(&root); err != nil {
67+
return err
68+
}
69+
70+
// Expand only scalar values (never comments)
71+
var resolveErr error
72+
walkScalars(&root, func(s string) string {
73+
if resolveErr != nil {
74+
return s
75+
}
76+
77+
out, err := expandEnvInScalar(s)
78+
if err != nil {
79+
resolveErr = err
80+
return s
81+
}
82+
83+
return out
84+
})
85+
86+
if resolveErr != nil {
87+
return resolveErr
88+
}
89+
90+
// Encode back to YAML (comments preserved) then unmarshal via invopop/yaml
91+
var buf bytes.Buffer
92+
enc := yamlv3.NewEncoder(&buf)
93+
enc.SetIndent(2)
94+
if err := enc.Encode(&root); err != nil {
95+
_ = enc.Close()
96+
return err
97+
}
98+
99+
if err := enc.Close(); err != nil {
100+
return err
101+
}
102+
103+
return yaml.Unmarshal(buf.Bytes(), v)
104+
}
105+
106+
// walkScalars walks the YAML AST recursively and applies fn to the Value of each ScalarNode.
107+
// Comments (HeadComment, LineComment, FootComment) are intentionally not touched.
108+
func walkScalars(n *yamlv3.Node, fn func(string) string) {
109+
if n == nil {
110+
return
111+
}
112+
113+
if n.Kind == yamlv3.ScalarNode {
114+
oldStyle := n.Style
115+
oldTag := n.Tag
116+
oldVal := n.Value
117+
118+
n.Value = fn(n.Value)
119+
120+
// If the scalar was plain and implicitly a string only because it contained ${...},
121+
// clear the tag so YAML can re-resolve types (bool/int/float/null) after expansion.
122+
if oldStyle == 0 && oldTag == "!!str" && oldVal != n.Value {
123+
n.Tag = ""
124+
}
125+
}
126+
127+
for _, c := range n.Content {
128+
walkScalars(c, fn)
129+
}
130+
}
131+
132+
// expandEnvInScalar expands Bash-style environment variables inside a single YAML scalar value.
133+
// The function operates only on the provided scalar string and has
134+
// no visibility into YAML structure or comments.
135+
func expandEnvInScalar(in string) (string, error) {
136+
str := escapedVarRegex.ReplaceAllString(in, maskStart+"$1"+maskEnd)
61137
var resolveErr error
62138

63139
// Mask escaped variables $${VAR} -> \x00VAR\x01
@@ -86,7 +162,7 @@ func Unmarshal(data []byte, v interface{}) error {
86162
})
87163

88164
if resolveErr != nil {
89-
return resolveErr
165+
return "", resolveErr
90166
}
91167

92168
if str == replacement {
@@ -100,7 +176,7 @@ func Unmarshal(data []byte, v interface{}) error {
100176
str = strings.ReplaceAll(str, maskStart, "${")
101177
str = strings.ReplaceAll(str, maskEnd, "}")
102178

103-
return yaml.Unmarshal([]byte(str), v)
179+
return str, nil
104180
}
105181

106182
// resolveVariable parses the content inside ${...} and applies Bash-style logic.

jamle_test.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,3 +225,22 @@ func TestUnmarshal_InfiniteLoopProtection(t *testing.T) {
225225
t.Fatal("Unmarshal timed out, possible infinite loop")
226226
}
227227
}
228+
229+
func TestUnmarshal_DoesNotExpandInComments(t *testing.T) {
230+
os.Unsetenv("COMMENT_REQ")
231+
232+
yamlStr := `
233+
# ${COMMENT_REQ:?must_not_fail}
234+
value: "ok" # ${COMMENT_REQ:?must_not_fail_inline}
235+
nested:
236+
key: 1 # ${COMMENT_REQ:?must_not_fail_nested}
237+
`
238+
239+
var res map[string]interface{}
240+
if err := Unmarshal([]byte(yamlStr), &res); err != nil {
241+
t.Fatalf("Unexpected error (comments must not be expanded): %v", err)
242+
}
243+
if res["value"] != "ok" {
244+
t.Fatalf("Expected value=ok, got %v", res["value"])
245+
}
246+
}

0 commit comments

Comments
 (0)