From f3de9d40ee2ad3dddd52cfcc407deebd6197eca1 Mon Sep 17 00:00:00 2001 From: Tai Groot Date: Thu, 22 Oct 2026 08:03:08 +0000 Subject: [PATCH] fix(leaf): preserve quoted secrets across dotenv round trips --- internal/leaf/leaf.go | 19 ++++++++++++++++--- internal/leaf/leaf_test.go | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/internal/leaf/leaf.go b/internal/leaf/leaf.go index 5cd8361..ae23a93 100644 --- a/internal/leaf/leaf.go +++ b/internal/leaf/leaf.go @@ -76,9 +76,22 @@ func Parse(content string) (map[string]string, error) { func unquote(s string) string { if len(s) >= 2 { - if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'') { + if s[0] == '\'' && s[len(s)-1] == '\'' { return s[1 : len(s)-1] } + if s[0] == '"' && s[len(s)-1] == '"' { + // Decode only the escapes emitted by quote. Other backslash + // sequences (such as literal \n in a path) stay untouched. + var value strings.Builder + for index := 1; index < len(s)-1; index++ { + if s[index] == '\\' && index+1 < len(s)-1 && + (s[index+1] == '\\' || s[index+1] == '"') { + index++ + } + value.WriteByte(s[index]) + } + return value.String() + } } return s } @@ -129,8 +142,8 @@ func quote(v string) string { if v == "" { return v } - if strings.ContainsAny(v, " \t\"'#\n") { - return `"` + strings.ReplaceAll(v, `"`, `\"`) + `"` + if strings.ContainsAny(v, " \t\"'#\n\\") { + return `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(v) + `"` } return v } diff --git a/internal/leaf/leaf_test.go b/internal/leaf/leaf_test.go index bc87e9e..d823e2d 100644 --- a/internal/leaf/leaf_test.go +++ b/internal/leaf/leaf_test.go @@ -101,3 +101,37 @@ func TestSerializeWithIgnored(t *testing.T) { t.Fatalf("round trip lost data: %#v", got) } } + +func TestSerializeQuotedValuesRoundTrip(t *testing.T) { + for _, value := range []string{ + `a"b`, `say "hello"`, `C:\Users\name`, `ends with \`, + `literal \n`, `\"`, `single'quote`, " padded ", + } { + t.Run(value, func(t *testing.T) { + for _, ignored := range []bool{false, true} { + values := map[string]string{"SECRET": value} + content := Serialize(values) + if ignored { + content = SerializeWithIgnored(nil, values) + } + got, err := Parse(content) + if err != nil { + t.Fatal(err) + } + if got["SECRET"] != value { + t.Errorf("ignored=%v: got %q, want %q", ignored, got["SECRET"], value) + } + } + }) + } +} + +func TestParseSingleQuotedBackslashes(t *testing.T) { + got, err := Parse(`SECRET='literal\" and \\'`) + if err != nil { + t.Fatal(err) + } + if got["SECRET"] != `literal\" and \\` { + t.Fatalf("single-quoted value changed: %q", got["SECRET"]) + } +}