From 9c7e4836093b713338fe81c153caf31477cedacd Mon Sep 17 00:00:00 2001 From: Ilia Choly Date: Thu, 7 May 2026 10:48:45 -0400 Subject: [PATCH 1/2] Add 'quote' option to dotenv format Signed-off-by: Ilia Choly --- README.rst | 5 +++- config/config.go | 4 ++- stores/dotenv/store.go | 18 +++++++++++-- stores/dotenv/store_test.go | 52 +++++++++++++++++++++++++++++++++++++ 4 files changed, 75 insertions(+), 4 deletions(-) diff --git a/README.rst b/README.rst index 1871f23c3..c4d1e1f15 100644 --- a/README.rst +++ b/README.rst @@ -2099,7 +2099,10 @@ Stores configuration object The store configuration object can have the following keys: -* ``dotenv``: this is an object. Right now no keys are supported. +* ``dotenv``: this is an object, supporting the following keys: + + * ``quote`` (boolean; default ``false``): when ``true``, values are + double-quoted on emit and must be double-quoted on load. * ``ini``: this is an object. Right now no keys are supported. diff --git a/config/config.go b/config/config.go index 511df1bc1..3b48211bb 100644 --- a/config/config.go +++ b/config/config.go @@ -99,7 +99,9 @@ func FindConfigFile(start string) (string, error) { return result.Path, err } -type DotenvStoreConfig struct{} +type DotenvStoreConfig struct { + Quote bool `yaml:"quote"` +} type INIStoreConfig struct{} diff --git a/stores/dotenv/store.go b/stores/dotenv/store.go index 9f4acd5e2..52e8c90a6 100644 --- a/stores/dotenv/store.go +++ b/stores/dotenv/store.go @@ -3,6 +3,7 @@ package dotenv //import "github.com/getsops/sops/v3/stores/dotenv" import ( "bytes" "fmt" + "strconv" "strings" "github.com/getsops/sops/v3" @@ -61,9 +62,19 @@ func (store *Store) LoadPlainFile(in []byte) (sops.TreeBranches, error) { if pos == -1 { return nil, fmt.Errorf("invalid dotenv input line: %s", line) } + var value string + if store.config.Quote { + var err error + value, err = strconv.Unquote(string(line[pos+1:])) + if err != nil { + return nil, fmt.Errorf("invalid quoted dotenv value for key %q: %w", line[:pos], err) + } + } else { + value = strings.Replace(string(line[pos+1:]), "\\n", "\n", -1) + } branch = append(branch, sops.TreeItem{ Key: string(line[:pos]), - Value: strings.Replace(string(line[pos+1:]), "\\n", "\n", -1), + Value: value, }) } } @@ -99,7 +110,10 @@ func (store *Store) EmitPlainFile(in sops.TreeBranches) ([]byte, error) { value, ok := item.Value.(string) if !ok { value = stores.ValToString(item.Value) - } else { + } + if store.config.Quote { + value = strconv.Quote(value) + } else if ok { value = strings.ReplaceAll(value, "\n", "\\n") } diff --git a/stores/dotenv/store_test.go b/stores/dotenv/store_test.go index 9bac160a1..a94a329f1 100644 --- a/stores/dotenv/store_test.go +++ b/stores/dotenv/store_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/getsops/sops/v3" + "github.com/getsops/sops/v3/config" "github.com/stretchr/testify/assert" ) @@ -88,6 +89,57 @@ func TestEmitEncryptedFileStability(t *testing.T) { } } +var QUOTED_PLAIN = []byte(strings.TrimLeft(` +VAR1="val1" +VAR2="val2" +#comment +VAR3_unencrypted="val3" +VAR4="val4\nval4" +JSON="{ \"app_id\": \"123\" }" +`, "\n")) + +var QUOTED_BRANCH = sops.TreeBranch{ + sops.TreeItem{ + Key: "VAR1", + Value: "val1", + }, + sops.TreeItem{ + Key: "VAR2", + Value: "val2", + }, + sops.TreeItem{ + Key: sops.Comment{Value: "comment"}, + Value: nil, + }, + sops.TreeItem{ + Key: "VAR3_unencrypted", + Value: "val3", + }, + sops.TreeItem{ + Key: "VAR4", + Value: "val4\nval4", + }, + sops.TreeItem{ + Key: "JSON", + Value: `{ "app_id": "123" }`, + }, +} + +func TestQuotedLoadPlainFile(t *testing.T) { + branches, err := (&Store{config: config.DotenvStoreConfig{Quote: true}}).LoadPlainFile(QUOTED_PLAIN) + assert.Nil(t, err) + assert.Equal(t, QUOTED_BRANCH, branches[0]) +} + +func TestQuotedEmitPlainFile(t *testing.T) { + branches := sops.TreeBranches{ + QUOTED_BRANCH, + } + bytes, err := (&Store{config: config.DotenvStoreConfig{Quote: true}}).EmitPlainFile(branches) + assert.Nil(t, err) + assert.Equal(t, QUOTED_PLAIN, bytes) +} + func TestHasSopsTopLevelKey(t *testing.T) { ok := (&Store{}).HasSopsTopLevelKey(sops.TreeBranch{ sops.TreeItem{ From 5d997633a687352109773527332f2007e1459315 Mon Sep 17 00:00:00 2001 From: Ilia Choly Date: Thu, 7 May 2026 21:49:45 -0400 Subject: [PATCH 2/2] Auto-detect dotenv format on load Signed-off-by: Ilia Choly --- README.rst | 2 +- stores/dotenv/store.go | 20 ++++++++++++++++++-- stores/dotenv/store_test.go | 20 ++++++++++++++++++++ 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/README.rst b/README.rst index c4d1e1f15..2157e2223 100644 --- a/README.rst +++ b/README.rst @@ -2102,7 +2102,7 @@ The store configuration object can have the following keys: * ``dotenv``: this is an object, supporting the following keys: * ``quote`` (boolean; default ``false``): when ``true``, values are - double-quoted on emit and must be double-quoted on load. + double-quoted on emit. * ``ini``: this is an object. Right now no keys are supported. diff --git a/stores/dotenv/store.go b/stores/dotenv/store.go index 52e8c90a6..6e2d47bb4 100644 --- a/stores/dotenv/store.go +++ b/stores/dotenv/store.go @@ -45,10 +45,26 @@ func (store *Store) LoadEncryptedFile(in []byte) (sops.Tree, error) { // LoadPlainFile returns the contents of a plaintext file loaded onto a // sops runtime object func (store *Store) LoadPlainFile(in []byte) (sops.TreeBranches, error) { + lines := bytes.Split(in, []byte("\n")) + + // Detect quoted vs unquoted by looking at the first metadata value + quote := store.config.Quote + for _, line := range lines { + if !bytes.HasPrefix(line, []byte(stores.SopsPrefix)) { + continue + } + _, raw, ok := bytes.Cut(line, []byte("=")) + if !ok { + continue + } + quote = bytes.HasPrefix(raw, []byte(`"`)) + break + } + var branches sops.TreeBranches var branch sops.TreeBranch - for _, line := range bytes.Split(in, []byte("\n")) { + for _, line := range lines { if len(line) == 0 { continue } @@ -63,7 +79,7 @@ func (store *Store) LoadPlainFile(in []byte) (sops.TreeBranches, error) { return nil, fmt.Errorf("invalid dotenv input line: %s", line) } var value string - if store.config.Quote { + if quote { var err error value, err = strconv.Unquote(string(line[pos+1:])) if err != nil { diff --git a/stores/dotenv/store_test.go b/stores/dotenv/store_test.go index a94a329f1..f9d808ac4 100644 --- a/stores/dotenv/store_test.go +++ b/stores/dotenv/store_test.go @@ -140,6 +140,26 @@ func TestQuotedEmitPlainFile(t *testing.T) { assert.Equal(t, QUOTED_PLAIN, bytes) } +func TestQuotedLoadDetectsUnquoted(t *testing.T) { + unquoted, err := (&Store{}).EmitEncryptedFile(sops.Tree{ + Branches: sops.TreeBranches{BRANCH}, + }) + assert.Nil(t, err) + branches, err := (&Store{config: config.DotenvStoreConfig{Quote: true}}).LoadPlainFile(unquoted) + assert.Nil(t, err) + assert.Equal(t, BRANCH, branches[0][:len(BRANCH)]) +} + +func TestUnquotedLoadDetectsQuoted(t *testing.T) { + quoted, err := (&Store{config: config.DotenvStoreConfig{Quote: true}}).EmitEncryptedFile(sops.Tree{ + Branches: sops.TreeBranches{BRANCH}, + }) + assert.Nil(t, err) + branches, err := (&Store{}).LoadPlainFile(quoted) + assert.Nil(t, err) + assert.Equal(t, BRANCH, branches[0][:len(BRANCH)]) +} + func TestHasSopsTopLevelKey(t *testing.T) { ok := (&Store{}).HasSopsTopLevelKey(sops.TreeBranch{ sops.TreeItem{