Skip to content

Commit 19eac03

Browse files
authored
Merge pull request #672 from 1Password/aditya/mysql-quote-option-file-values
Quote and escape values in generated mysql option file
2 parents 2353440 + dd40820 commit 19eac03

4 files changed

Lines changed: 200 additions & 2 deletions

File tree

‎plugins/mysql/database_credentials.go‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package mysql
33
import (
44
"context"
55
"fmt"
6+
"strings"
67

78
"github.com/1Password/shell-plugins/sdk"
89
"github.com/1Password/shell-plugins/sdk/importer"
@@ -116,6 +117,15 @@ func TryMySQLConfigFile(path string) sdk.Importer {
116117
})
117118
}
118119

120+
// MySQL decodes \" in option files according to its source code.
121+
// Its own docs don't mention it but MariaDB's docs do.
119122
func configFileEntry(key string, value string) string {
120-
return fmt.Sprintf("%s=%s\n", key, value)
123+
return fmt.Sprintf("%s=\"%s\"\n", key, optionValueEscaper.Replace(value))
121124
}
125+
126+
var optionValueEscaper = strings.NewReplacer(
127+
`\`, `\\`,
128+
`"`, `\"`,
129+
"\n", `\n`,
130+
"\r", `\r`,
131+
)

‎plugins/mysql/database_credentials_test.go‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,14 +51,35 @@ func TestDatabaseCredentialsProvisioner(t *testing.T) {
5151
CommandLine: []string{"mysql", "--defaults-file=/tmp/my.cnf"},
5252
Files: map[string]sdk.OutputFile{
5353
"/tmp/my.cnf": {
54-
Contents: []byte(plugintest.LoadFixture(t, "mysql.cnf")),
54+
Contents: []byte(plugintest.LoadFixture(t, "provision.cnf")),
5555
},
5656
},
5757
},
5858
},
5959
})
6060
}
6161

62+
// Illustrations, so a format change is visible in review. Correctness lives in
63+
// TestConfigFileEntryRoundTripsThroughMySQL, so any of these may change with it.
64+
func TestConfigFileEntryOutputFormat(t *testing.T) {
65+
tests := []struct {
66+
name string
67+
value string
68+
want string
69+
}{
70+
{"ordinary value", "123456", `password="123456"` + "\n"},
71+
{"'#' would otherwise start a comment", "#4b", `password="#4b"` + "\n"},
72+
{"embedded quotation mark is escaped", `a"b#c`, `password="a\"b#c"` + "\n"},
73+
{"both kinds of quotation mark", `a'b"c#d`, `password="a'b\"c#d"` + "\n"},
74+
}
75+
76+
for _, tt := range tests {
77+
t.Run(tt.name, func(t *testing.T) {
78+
assert.Equal(t, tt.want, configFileEntry("password", tt.value))
79+
})
80+
}
81+
}
82+
6283
func TestMysqlConfigHandleEmptyItemFields(t *testing.T) {
6384
p := sdk.ProvisionInput{
6485
ItemFields: map[sdk.FieldName]string{},

‎plugins/mysql/option_file_test.go‎

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
package mysql
2+
3+
import (
4+
"strings"
5+
"testing"
6+
7+
"github.com/stretchr/testify/assert"
8+
)
9+
10+
// Asserts what MySQL reads back, rather than the generated text.
11+
func TestConfigFileEntryRoundTripsThroughMySQL(t *testing.T) {
12+
tests := []struct {
13+
name string
14+
value string
15+
}{
16+
{"# starts the value", "#4b"},
17+
{"# inside the value", "a#b"},
18+
{"# ends the value", "pass#"},
19+
{"backslash", `back\slash`},
20+
{"backslash escape sequence", `tab\there`},
21+
{"trailing backslash", `secret\`},
22+
{"leading space", " leading"},
23+
{"trailing space", "trailing "},
24+
{"vertical tab and form feed at the ends", "\vsecret\f"},
25+
{"newline", "line\nbreak"},
26+
{"carriage return", "line\rbreak"},
27+
{"double quote", `double"quote`},
28+
{"single quote", "single'quote"},
29+
{"already wrapped in double quotes", `"secret"`},
30+
{"already wrapped in single quotes", "'secret'"},
31+
{"# with a double quote", `a"b#c`},
32+
{"# with a single quote", "a'b#c"},
33+
{"# with both kinds of quotation mark", `a'b"c#d`},
34+
{"semicolon", "semi;colon"},
35+
{"empty", ""},
36+
{"no special characters", "123456"},
37+
}
38+
39+
for _, tt := range tests {
40+
t.Run(tt.name, func(t *testing.T) {
41+
line := configFileEntry("password", tt.value)
42+
43+
assert.Equal(t, tt.value, readOptionFileValue(line),
44+
"mysql reads %q out of %q, but the 1Password item holds %q",
45+
readOptionFileValue(line), line, tt.value)
46+
})
47+
}
48+
}
49+
50+
// Worked examples from the MySQL and MariaDB manuals and mysys/my_default.cc.
51+
// This must pass whatever the encoder does, so a failure above cannot be blamed
52+
// on the reader.
53+
func TestReadOptionFileValueMatchesMySQL(t *testing.T) {
54+
tests := []struct {
55+
line string
56+
want string
57+
}{
58+
{`password=#4b`, ``},
59+
{`password=abc#def`, `abc`},
60+
// ";" only begins a comment at the start of a line.
61+
{`password=a;b`, `a;b`},
62+
{`password = secret `, `secret`},
63+
{`password="#4b"`, `#4b`},
64+
{`password='#4b'`, `#4b`},
65+
{`basedir=C:\\Program\sFiles\\MySQL`, `C:\Program Files\MySQL`},
66+
// An unrecognised sequence keeps its backslash.
67+
{`password=a\Sb`, `a\Sb`},
68+
// \" is decoded by mysys/my_default.cc, and does not close the quoting.
69+
{`password="a\"b"`, `a"b`},
70+
{`password="a\"b#c"`, `a"b#c`},
71+
}
72+
73+
for _, tt := range tests {
74+
assert.Equal(t, tt.want, readOptionFileValue(tt.line+"\n"),
75+
"reading option-file line %s", tt.line)
76+
}
77+
}
78+
79+
// readOptionFileValue follows mysys/my_default.cc, in this order: drop the
80+
// comment, delete surrounding whitespace, unwrap quotation marks, decode escapes.
81+
func readOptionFileValue(line string) string {
82+
// MySQL reads one line at a time, so a raw line break ends the value.
83+
line, _, _ = strings.Cut(line, "\n")
84+
85+
_, value, _ := strings.Cut(line, "=")
86+
87+
// my_isspace() for latin1 accepts exactly this set.
88+
value = strings.Trim(removeOptionFileComment(value), " \t\n\v\f\r")
89+
90+
if len(value) >= 2 && (value[0] == '"' || value[0] == '\'') && value[len(value)-1] == value[0] {
91+
value = value[1 : len(value)-1]
92+
}
93+
94+
return decodeOptionFileEscapes(value)
95+
}
96+
97+
// A '#' is only a comment outside quotation marks, and a backslash inside them
98+
// hides a closing quotation mark.
99+
func removeOptionFileComment(value string) string {
100+
var quote byte
101+
escaped := false
102+
103+
for i := 0; i < len(value); i++ {
104+
c := value[i]
105+
106+
if (c == '\'' || c == '"') && !escaped {
107+
switch quote {
108+
case 0:
109+
quote = c
110+
case c:
111+
quote = 0
112+
}
113+
}
114+
115+
if quote == 0 && c == '#' {
116+
return value[:i]
117+
}
118+
119+
escaped = quote != 0 && c == '\\' && !escaped
120+
}
121+
122+
return value
123+
}
124+
125+
// An unrecognised sequence keeps its backslash.
126+
func decodeOptionFileEscapes(value string) string {
127+
var decoded strings.Builder
128+
129+
for i := 0; i < len(value); i++ {
130+
// A backslash in the final position is not an escape character.
131+
if value[i] != '\\' || i == len(value)-1 {
132+
decoded.WriteByte(value[i])
133+
continue
134+
}
135+
136+
i++
137+
switch value[i] {
138+
case 'b':
139+
decoded.WriteByte('\b')
140+
case 't':
141+
decoded.WriteByte('\t')
142+
case 'n':
143+
decoded.WriteByte('\n')
144+
case 'r':
145+
decoded.WriteByte('\r')
146+
case 's':
147+
decoded.WriteByte(' ')
148+
case '"':
149+
decoded.WriteByte('"')
150+
case '\'':
151+
decoded.WriteByte('\'')
152+
case '\\':
153+
decoded.WriteByte('\\')
154+
default:
155+
decoded.WriteByte('\\')
156+
decoded.WriteByte(value[i])
157+
}
158+
}
159+
160+
return decoded.String()
161+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
[client]
2+
user="root"
3+
password="123456"
4+
host="localhost"
5+
port="3306"
6+
database="test"

0 commit comments

Comments
 (0)