diff --git a/README.rst b/README.rst index 1871f23c36..2157e22236 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. * ``ini``: this is an object. Right now no keys are supported. diff --git a/config/config.go b/config/config.go index 511df1bc15..3b48211bbc 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 9f4acd5e23..6e2d47bb46 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" @@ -44,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 } @@ -61,9 +78,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 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 +126,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 9bac160a10..f9d808ac4e 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,77 @@ 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 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{