From 3aea8b05eac2a60d288d91b38557104c9a0480ff Mon Sep 17 00:00:00 2001 From: Varun Chawla Date: Mon, 16 Feb 2026 16:27:17 -0800 Subject: [PATCH] Strip matching surrounding quotes from --env-file values When using a .env file with both `source` on Linux and `docker run --env-file`, quoted values like FOO="bar" would include the literal quotes in Docker, breaking compatibility. This strips matching outer quotes (single or double) from values, consistent with how shells handle sourced env files and matching docker compose behavior. Signed-off-by: Varun Chawla --- pkg/kvfile/kvfile.go | 32 ++++++++++++----- pkg/kvfile/kvfile_test.go | 75 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 8 deletions(-) diff --git a/pkg/kvfile/kvfile.go b/pkg/kvfile/kvfile.go index f6ac8ef4e047..c527db10f20e 100644 --- a/pkg/kvfile/kvfile.go +++ b/pkg/kvfile/kvfile.go @@ -15,11 +15,12 @@ // // # Interpolation, substitution, and escaping // -// Both keys and values are used as-is; no interpolation, substitution or -// escaping is supported, and quotes are considered part of the key or value. -// Whitespace in values (including leading and trailing) is preserved. Given -// that the file format is line-delimited, neither key, nor value, can contain -// newlines. +// Both keys and values are used as-is; no interpolation or substitution +// is supported. Matching surrounding quotes (single or double) on values +// are stripped to be consistent with shell sourcing behavior. Whitespace +// in values (including leading and trailing) is preserved after any quote +// removal. Given that the file format is line-delimited, neither key, nor +// value, can contain newlines. // // # Key/Value pairs // @@ -78,6 +79,19 @@ func ParseFromReader(r io.Reader, lookupFn func(key string) (value string, found const whiteSpaces = " \t" +// trimQuotes removes matching surrounding quotes (single or double) from a +// value string. Quotes are only removed when the first and last characters +// are the same quote character. This matches the behavior of shell sourcing. +func trimQuotes(value string) string { + if len(value) >= 2 { + if (value[0] == '"' && value[len(value)-1] == '"') || + (value[0] == '\'' && value[len(value)-1] == '\'') { + return value[1 : len(value)-1] + } + } + return value +} + func parseKeyValueFile(r io.Reader, lookupFn func(string) (string, bool)) ([]string, error) { lines := []string{} scanner := bufio.NewScanner(r) @@ -100,7 +114,7 @@ func parseKeyValueFile(r io.Reader, lookupFn func(string) (string, bool)) ([]str continue } - key, _, hasValue := strings.Cut(line, "=") + key, value, hasValue := strings.Cut(line, "=") if len(key) == 0 { return []string{}, fmt.Errorf("no variable name on line '%s'", line) } @@ -113,8 +127,10 @@ func parseKeyValueFile(r io.Reader, lookupFn func(string) (string, bool)) ([]str } if hasValue { - // key/value pair is valid and has a value; add the line as-is. - lines = append(lines, line) + // Strip matching surrounding quotes from the value, + // consistent with shell sourcing behavior. + value = trimQuotes(value) + lines = append(lines, key+"="+value) continue } diff --git a/pkg/kvfile/kvfile_test.go b/pkg/kvfile/kvfile_test.go index f1a0e88e6bf5..714462cc0ac8 100644 --- a/pkg/kvfile/kvfile_test.go +++ b/pkg/kvfile/kvfile_test.go @@ -143,3 +143,78 @@ func TestParseFromReaderWithNoName(t *testing.T) { const expectedMessage = "no variable name on line '=blank variable names are an error case'" assert.Check(t, is.ErrorContains(err, expectedMessage)) } + +// Test trimQuotes helper function +func TestTrimQuotes(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {name: "double quotes", input: `"bar"`, expected: "bar"}, + {name: "single quotes", input: "'bar'", expected: "bar"}, + {name: "no quotes", input: "bar", expected: "bar"}, + {name: "empty string", input: "", expected: ""}, + {name: "mismatched quotes double-single", input: `"bar'`, expected: `"bar'`}, + {name: "mismatched quotes single-double", input: `'bar"`, expected: `'bar"`}, + {name: "only opening double quote", input: `"bar`, expected: `"bar`}, + {name: "only closing double quote", input: `bar"`, expected: `bar"`}, + {name: "only opening single quote", input: "'bar", expected: "'bar"}, + {name: "only closing single quote", input: "bar'", expected: "bar'"}, + {name: "empty double quotes", input: `""`, expected: ""}, + {name: "empty single quotes", input: "''", expected: ""}, + {name: "nested double in single", input: `'"bar"'`, expected: `"bar"`}, + {name: "nested single in double", input: `"'bar'"`, expected: "'bar'"}, + {name: "single char", input: "a", expected: "a"}, + {name: "single double quote", input: `"`, expected: `"`}, + {name: "single single quote", input: "'", expected: "'"}, + {name: "spaces inside double quotes", input: `"hello world"`, expected: "hello world"}, + {name: "spaces inside single quotes", input: "'hello world'", expected: "hello world"}, + {name: "value with internal quotes", input: `"say \"hello\""`, expected: `say \"hello\"`}, + {name: "triple double quotes", input: `"""`, expected: `"`}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := trimQuotes(tc.input) + assert.Check(t, is.Equal(result, tc.expected)) + }) + } +} + +// Test ParseFromReader strips surrounding quotes from values +func TestParseFromReaderQuotedValues(t *testing.T) { + content := `# Quoted values should have surrounding quotes stripped +DOUBLE_QUOTED="hello world" +SINGLE_QUOTED='hello world' +NO_QUOTES=hello +EMPTY_DOUBLE="" +EMPTY_SINGLE='' +MISMATCHED="hello' +NESTED_DOUBLE='"hello"' +NESTED_SINGLE="'hello'" +UNQUOTED_SPACES=hello world +INTERNAL_QUOTES=he"ll"o +ONLY_OPENING="hello +VALUE_WITH_EQUALS="foo=bar" +` + + lines, err := ParseFromReader(strings.NewReader(content), nil) + assert.NilError(t, err) + + expectedLines := []string{ + "DOUBLE_QUOTED=hello world", + "SINGLE_QUOTED=hello world", + "NO_QUOTES=hello", + "EMPTY_DOUBLE=", + "EMPTY_SINGLE=", + `MISMATCHED="hello'`, + `NESTED_DOUBLE="hello"`, + "NESTED_SINGLE='hello'", + "UNQUOTED_SPACES=hello world", + `INTERNAL_QUOTES=he"ll"o`, + `ONLY_OPENING="hello`, + "VALUE_WITH_EQUALS=foo=bar", + } + + assert.Check(t, is.DeepEqual(lines, expectedLines)) +}