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)) +}