Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 24 additions & 8 deletions pkg/kvfile/kvfile.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
//
Expand Down Expand Up @@ -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)
Expand All @@ -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)
}
Expand All @@ -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
}

Expand Down
75 changes: 75 additions & 0 deletions pkg/kvfile/kvfile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}