Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit de9d21d

Browse files
committedAug 25, 2022
Add dsn type for handling datasources
dsn is designed to replace the other uses of dsn as a string in the long term. dsn is designed to be safe to log, properly redacting passwords. The goal is eventually always parse datasource information into a dsn type object which can safely be passed around and logged without worrying about wrapping calls in a redaction function (today this function is loggableDSN(). This should solve the root issue in #648, #677, and #643, although the full fix will require more changes to update all code references over to use the dsn type. Signed-off-by: Joe Adams <[email protected]>
1 parent c84fc4a commit de9d21d

File tree

2 files changed

+400
-0
lines changed

2 files changed

+400
-0
lines changed
 

‎cmd/postgres_exporter/datasource.go

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"os"
2121
"regexp"
2222
"strings"
23+
"unicode"
2324

2425
"github.com/go-kit/log/level"
2526
"github.com/prometheus/client_golang/prometheus"
@@ -172,3 +173,196 @@ func getDataSources() ([]string, error) {
172173

173174
return []string{dsn}, nil
174175
}
176+
177+
// dsn represents a parsed datasource. It contains fields for the individual connection components.
178+
type dsn struct {
179+
scheme string
180+
username string
181+
password string
182+
host string
183+
path string
184+
query string
185+
}
186+
187+
// String makes a dsn safe to print by excluding any passwords. This allows dsn to be used in
188+
// strings and log messages without needing to call a redaction function first.
189+
func (d dsn) String() string {
190+
if d.password != "" {
191+
return fmt.Sprintf("%s://%s:******@%s%s?%s", d.scheme, d.username, d.host, d.path, d.query)
192+
}
193+
194+
if d.username != "" {
195+
return fmt.Sprintf("%s://%s@%s%s?%s", d.scheme, d.username, d.host, d.path, d.query)
196+
}
197+
198+
return fmt.Sprintf("%s://%s%s?%s", d.scheme, d.host, d.path, d.query)
199+
}
200+
201+
// dsnFromString parses a connection string into a dsn. It will attempt to parse the string as
202+
// a URL and as a set of key=value pairs. If both attempts fail, dsnFromString will return an error.
203+
func dsnFromString(in string) (dsn, error) {
204+
if strings.HasPrefix(in, "postgresql://") {
205+
return dsnFromURL(in)
206+
}
207+
208+
// Try to parse as key=value pairs
209+
d, err := dsnFromKeyValue(in)
210+
if err == nil {
211+
return d, nil
212+
}
213+
214+
return dsn{}, fmt.Errorf("could not understand DSN")
215+
}
216+
217+
// dsnFromURL parses the input as a URL and returns the dsn representation.
218+
func dsnFromURL(in string) (dsn, error) {
219+
u, err := url.Parse(in)
220+
if err != nil {
221+
return dsn{}, err
222+
}
223+
pass, _ := u.User.Password()
224+
user := u.User.Username()
225+
226+
query := u.Query()
227+
228+
if queryPass := query.Get("password"); queryPass != "" {
229+
if pass == "" {
230+
pass = queryPass
231+
}
232+
}
233+
query.Del("password")
234+
235+
if queryUser := query.Get("user"); queryUser != "" {
236+
if user == "" {
237+
user = queryUser
238+
}
239+
}
240+
query.Del("user")
241+
242+
d := dsn{
243+
scheme: u.Scheme,
244+
username: user,
245+
password: pass,
246+
host: u.Host,
247+
path: u.Path,
248+
query: query.Encode(),
249+
}
250+
251+
return d, nil
252+
}
253+
254+
// dsnFromKeyValue parses the input as a set of key=value pairs and returns the dsn representation.
255+
func dsnFromKeyValue(in string) (dsn, error) {
256+
// Attempt to confirm at least one key=value pair before starting the rune parser
257+
connstringRe := regexp.MustCompile(`^ *[a-zA-Z0-9]+ *= *[^= ]+`)
258+
if !connstringRe.MatchString(in) {
259+
return dsn{}, fmt.Errorf("input is not a key-value DSN")
260+
}
261+
262+
// Anything other than known fields should be part of the querystring
263+
query := url.Values{}
264+
265+
pairs, err := parseKeyValue(in)
266+
if err != nil {
267+
return dsn{}, fmt.Errorf("failed to parse key-value DSN: %v", err)
268+
}
269+
270+
// Build the dsn from the key=value pairs
271+
d := dsn{
272+
scheme: "postgresql",
273+
}
274+
275+
hostname := ""
276+
port := ""
277+
278+
for k, v := range pairs {
279+
switch k {
280+
case "host":
281+
hostname = v
282+
case "port":
283+
port = v
284+
case "user":
285+
d.username = v
286+
case "password":
287+
d.password = v
288+
default:
289+
query.Set(k, v)
290+
}
291+
}
292+
293+
if hostname == "" {
294+
hostname = "localhost"
295+
}
296+
297+
if port == "" {
298+
d.host = hostname
299+
} else {
300+
d.host = fmt.Sprintf("%s:%s", hostname, port)
301+
}
302+
303+
d.query = query.Encode()
304+
305+
return d, nil
306+
}
307+
308+
// parseKeyValue is a key=value parser. It loops over each rune to split out keys and values
309+
// and attempting to honor quoted values. parseKeyValue will return an error if it is unable
310+
// to properly parse the input.
311+
func parseKeyValue(in string) (map[string]string, error) {
312+
out := map[string]string{}
313+
314+
inPart := false
315+
inQuote := false
316+
part := []rune{}
317+
key := ""
318+
for _, c := range in {
319+
switch {
320+
case unicode.In(c, unicode.Quotation_Mark):
321+
if inQuote {
322+
inQuote = false
323+
} else {
324+
inQuote = true
325+
}
326+
case unicode.In(c, unicode.White_Space):
327+
if inPart {
328+
if inQuote {
329+
part = append(part, c)
330+
} else {
331+
// Are we finishing a key=value?
332+
if key == "" {
333+
return out, fmt.Errorf("invalid input")
334+
}
335+
out[key] = string(part)
336+
inPart = false
337+
part = []rune{}
338+
}
339+
} else {
340+
// Are we finishing a key=value?
341+
if key == "" {
342+
return out, fmt.Errorf("invalid input")
343+
}
344+
out[key] = string(part)
345+
inPart = false
346+
part = []rune{}
347+
// Do something with the value
348+
}
349+
case c == '=':
350+
if inPart {
351+
inPart = false
352+
key = string(part)
353+
part = []rune{}
354+
} else {
355+
return out, fmt.Errorf("invalid input")
356+
}
357+
default:
358+
inPart = true
359+
part = append(part, c)
360+
}
361+
}
362+
363+
if key != "" && len(part) > 0 {
364+
out[key] = string(part)
365+
}
366+
367+
return out, nil
368+
}
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
// Copyright 2022 The Prometheus Authors
2+
// Licensed under the Apache License, Version 2.0 (the "License");
3+
// you may not use this file except in compliance with the License.
4+
// You may obtain a copy of the License at
5+
//
6+
// http://www.apache.org/licenses/LICENSE-2.0
7+
//
8+
// Unless required by applicable law or agreed to in writing, software
9+
// distributed under the License is distributed on an "AS IS" BASIS,
10+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
// See the License for the specific language governing permissions and
12+
// limitations under the License.
13+
14+
package main
15+
16+
import (
17+
"reflect"
18+
"testing"
19+
)
20+
21+
// Test_dsn_String is designed to test different dsn combinations for their string representation.
22+
// dsn.String() is designed to be safe to print, redacting any password information and these test
23+
// cases are intended to cover known cases.
24+
func Test_dsn_String(t *testing.T) {
25+
type fields struct {
26+
scheme string
27+
username string
28+
password string
29+
host string
30+
path string
31+
query string
32+
}
33+
tests := []struct {
34+
name string
35+
fields fields
36+
want string
37+
}{
38+
{
39+
name: "Without Password",
40+
fields: fields{
41+
scheme: "postgresql",
42+
username: "test",
43+
host: "localhost:5432",
44+
query: "",
45+
},
46+
want: "postgresql://test@localhost:5432?",
47+
},
48+
{
49+
name: "With Password",
50+
fields: fields{
51+
scheme: "postgresql",
52+
username: "test",
53+
password: "supersecret",
54+
host: "localhost:5432",
55+
query: "",
56+
},
57+
want: "postgresql://test:******@localhost:5432?",
58+
},
59+
{
60+
name: "With Password and Query String",
61+
fields: fields{
62+
scheme: "postgresql",
63+
username: "test",
64+
password: "supersecret",
65+
host: "localhost:5432",
66+
query: "ssldisable=true",
67+
},
68+
want: "postgresql://test:******@localhost:5432?ssldisable=true",
69+
},
70+
{
71+
name: "With Password, Path, and Query String",
72+
fields: fields{
73+
scheme: "postgresql",
74+
username: "test",
75+
password: "supersecret",
76+
host: "localhost:5432",
77+
path: "/somevalue",
78+
query: "ssldisable=true",
79+
},
80+
want: "postgresql://test:******@localhost:5432/somevalue?ssldisable=true",
81+
},
82+
}
83+
for _, tt := range tests {
84+
t.Run(tt.name, func(t *testing.T) {
85+
d := dsn{
86+
scheme: tt.fields.scheme,
87+
username: tt.fields.username,
88+
password: tt.fields.password,
89+
host: tt.fields.host,
90+
path: tt.fields.path,
91+
query: tt.fields.query,
92+
}
93+
if got := d.String(); got != tt.want {
94+
t.Errorf("dsn.String() = %v, want %v", got, tt.want)
95+
}
96+
})
97+
}
98+
}
99+
100+
// Test_dsnFromString tests the dsnFromString function with known variations
101+
// of connection string inputs to ensure that it properly parses the input into
102+
// a dsn.
103+
func Test_dsnFromString(t *testing.T) {
104+
105+
tests := []struct {
106+
name string
107+
input string
108+
want dsn
109+
wantErr bool
110+
}{
111+
{
112+
name: "Key value with password",
113+
input: "host=host.example.com user=postgres port=5432 password=s3cr3t",
114+
want: dsn{
115+
scheme: "postgresql",
116+
host: "host.example.com:5432",
117+
username: "postgres",
118+
password: "s3cr3t",
119+
},
120+
wantErr: false,
121+
},
122+
{
123+
name: "Key value with quoted password and space",
124+
input: "host=host.example.com user=postgres port=5432 password=\"s3cr 3t\"",
125+
want: dsn{
126+
scheme: "postgresql",
127+
host: "host.example.com:5432",
128+
username: "postgres",
129+
password: "s3cr 3t",
130+
},
131+
wantErr: false,
132+
},
133+
{
134+
name: "Key value with different order",
135+
input: "password=abcde host=host.example.com user=postgres port=5432",
136+
want: dsn{
137+
scheme: "postgresql",
138+
host: "host.example.com:5432",
139+
username: "postgres",
140+
password: "abcde",
141+
},
142+
wantErr: false,
143+
},
144+
{
145+
name: "Key value with different order, quoted password, duplicate password",
146+
input: "password=abcde host=host.example.com user=postgres port=5432 password=\"s3cr 3t\"",
147+
want: dsn{
148+
scheme: "postgresql",
149+
host: "host.example.com:5432",
150+
username: "postgres",
151+
password: "s3cr 3t",
152+
},
153+
wantErr: false,
154+
},
155+
{
156+
name: "URL with user in query string",
157+
input: "postgresql://host.example.com:5432/tsdb?user=postgres",
158+
want: dsn{
159+
scheme: "postgresql",
160+
host: "host.example.com:5432",
161+
path: "/tsdb",
162+
query: "",
163+
username: "postgres",
164+
},
165+
wantErr: false,
166+
},
167+
{
168+
name: "URL with user and password",
169+
input: "postgresql://user:s3cret@host.example.com:5432/tsdb?user=postgres",
170+
want: dsn{
171+
scheme: "postgresql",
172+
host: "host.example.com:5432",
173+
path: "/tsdb",
174+
query: "",
175+
username: "user",
176+
password: "s3cret",
177+
},
178+
wantErr: false,
179+
},
180+
{
181+
name: "URL with user and password in query string",
182+
input: "postgresql://host.example.com:5432/tsdb?user=postgres&password=s3cr3t",
183+
want: dsn{
184+
scheme: "postgresql",
185+
host: "host.example.com:5432",
186+
path: "/tsdb",
187+
query: "",
188+
username: "postgres",
189+
password: "s3cr3t",
190+
},
191+
wantErr: false,
192+
},
193+
}
194+
for _, tt := range tests {
195+
t.Run(tt.name, func(t *testing.T) {
196+
got, err := dsnFromString(tt.input)
197+
if (err != nil) != tt.wantErr {
198+
t.Errorf("dsnFromString() error = %v, wantErr %v", err, tt.wantErr)
199+
return
200+
}
201+
if !reflect.DeepEqual(got, tt.want) {
202+
t.Errorf("dsnFromString() = %+v, want %+v", got, tt.want)
203+
}
204+
})
205+
}
206+
}

0 commit comments

Comments
 (0)
Please sign in to comment.