Skip to content

Commit b7f8284

Browse files
address expand dsn edge cases
1 parent 922ea74 commit b7f8284

File tree

2 files changed

+111
-12
lines changed

2 files changed

+111
-12
lines changed

pkg/database/database.go

Lines changed: 98 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -68,19 +68,17 @@ func updateFromEnv(dsn string) (string, error) {
6868
return result, nil
6969
}
7070

71-
// extractPlaceholders replaces ${...} placeholders with unique numeric sentinels
72-
// and looks up the environment variable values immediately.
73-
// Numeric sentinels (999000, 999001, etc.) are valid in all URL components:
74-
// ports (must be numeric), hostnames, userinfo, paths, and query strings.
75-
// This allows us to parse the URL structure before expanding environment variables.
71+
// extractPlaceholders replaces ${...} placeholders with unique sentinels
72+
// using the _PH-999000_ format for all placeholders to avoid collisions.
73+
// Port sentinels are expanded before URL parsing (in expandDSN) since ports must be numeric.
7674
// Returns: the string with sentinels, a mapping of sentinel->value, and any error.
7775
func extractPlaceholders(s string) (string, map[string]string, error) {
7876
mapping := make(map[string]string)
7977
counter := 0
8078
var err error
8179

8280
result := DSNREnvRegex.ReplaceAllStringFunc(s, func(match string) string {
83-
sentinel := fmt.Sprintf("999%03d", counter)
81+
sentinel := fmt.Sprintf("_PH-999%03d_", counter)
8482
varName := match[2 : len(match)-1] // Extract VAR from ${VAR}
8583

8684
// Look up the environment variable immediately
@@ -103,7 +101,8 @@ func extractPlaceholders(s string) (string, map[string]string, error) {
103101
}
104102

105103
// expandWithMapping expands sentinels in a string by replacing them with their
106-
// corresponding values from the mapping.
104+
// corresponding values from the mapping. The sentinel format (_PH-999000_) includes
105+
// a prefix and suffix to avoid collisions with literal patterns in the DSN.
107106
func expandWithMapping(s string, mapping map[string]string) string {
108107
result := s
109108
for sentinel, value := range mapping {
@@ -150,9 +149,27 @@ func expandUserInfo(parsedUrl *url.URL, mapping map[string]string) {
150149
}
151150

152151
// expandHost expands environment variable placeholders in the URL's host component.
152+
// It also handles the case where the host variable contains a path component.
153153
func expandHost(parsedUrl *url.URL, mapping map[string]string) {
154154
if parsedUrl.Host != "" {
155-
parsedUrl.Host = expandWithMapping(parsedUrl.Host, mapping)
155+
expandedHost := expandWithMapping(parsedUrl.Host, mapping)
156+
157+
// Check if the expanded host contains a path (e.g., "localhost:3306/dbname")
158+
// If so, split it into host:port and path
159+
if strings.Contains(expandedHost, "/") {
160+
parts := strings.SplitN(expandedHost, "/", 2)
161+
parsedUrl.Host = parts[0]
162+
// Prepend the path part to the existing path
163+
if parts[1] != "" {
164+
if parsedUrl.Path == "" {
165+
parsedUrl.Path = "/" + parts[1]
166+
} else {
167+
parsedUrl.Path = "/" + parts[1] + parsedUrl.Path
168+
}
169+
}
170+
} else {
171+
parsedUrl.Host = expandedHost
172+
}
156173
}
157174
}
158175

@@ -193,6 +210,49 @@ func expandFragment(parsedUrl *url.URL, mapping map[string]string) {
193210
}
194211
}
195212

213+
// expandPortSentinel expands the port sentinel in the DSN before URL parsing.
214+
// Ports must be numeric for URL parsing, so we detect and expand port sentinels
215+
// (which use _PH-999000_ format) before passing to url.Parse.
216+
// The mapping is modified in place to remove the port sentinel.
217+
func expandPortSentinel(sentinelDSN string, mapping map[string]string) string {
218+
// Pattern: host:_PH-999XXX_/ or host:_PH-999XXX_? or host:_PH-999XXX_
219+
if !strings.Contains(sentinelDSN, "://") {
220+
return sentinelDSN
221+
}
222+
223+
schemeEnd := strings.Index(sentinelDSN, "://")
224+
afterScheme := sentinelDSN[schemeEnd+3:]
225+
226+
// Find the authority part (userinfo@host:port or host:port)
227+
authEnd := strings.IndexAny(afterScheme, "/?#")
228+
if authEnd == -1 {
229+
authEnd = len(afterScheme)
230+
}
231+
authority := afterScheme[:authEnd]
232+
233+
// Find the last colon in authority (this should be the port separator)
234+
atIndex := strings.LastIndex(authority, "@")
235+
colonIndex := strings.LastIndex(authority, ":")
236+
237+
// If there's a colon after @ (or no @), check if what follows is a sentinel
238+
if colonIndex > atIndex {
239+
portPart := authority[colonIndex+1:]
240+
// Check if portPart matches our sentinel pattern
241+
if strings.HasPrefix(portPart, "_PH-") && strings.HasSuffix(portPart, "_") {
242+
if value, ok := mapping[portPart]; ok {
243+
// Replace port sentinel with its value before parsing
244+
beforePort := sentinelDSN[:schemeEnd+3+colonIndex+1]
245+
afterPort := sentinelDSN[schemeEnd+3+len(authority):]
246+
sentinelDSN = beforePort + value + afterPort
247+
// Remove from mapping so it doesn't get expanded again
248+
delete(mapping, portPart)
249+
}
250+
}
251+
}
252+
253+
return sentinelDSN
254+
}
255+
196256
// expandDSN expands environment variable placeholders in a DSN using a three-phase approach:
197257
// 1. Replace ${...} with safe sentinel values and lookup env vars
198258
// 2. Parse the URL structure with sentinels
@@ -212,6 +272,36 @@ func expandDSN(dsn string) (string, error) {
212272
return dsn, nil
213273
}
214274

275+
// Special case: if the entire DSN is a single variable (e.g., "${DSN}"),
276+
// just return the expanded value directly without parsing
277+
matches := DSNREnvRegex.FindAllString(dsn, -1)
278+
if len(matches) == 1 && strings.TrimSpace(dsn) == strings.TrimSpace(matches[0]) {
279+
// Get the variable name
280+
varName := matches[0][2 : len(matches[0])-1]
281+
value, exists := os.LookupEnv(varName)
282+
if !exists {
283+
return "", fmt.Errorf("environment variable %s is not set", varName)
284+
}
285+
return value, nil
286+
}
287+
288+
// Special case: if the scheme is a variable, we need to expand it before parsing
289+
// Check if the DSN starts with a sentinel followed by ://
290+
if strings.Contains(sentinelDSN, "://") {
291+
schemeEnd := strings.Index(sentinelDSN, "://")
292+
schemeSentinel := sentinelDSN[:schemeEnd]
293+
// Check if this is a sentinel we created
294+
if strings.HasPrefix(schemeSentinel, "_PH-") {
295+
if value, ok := mapping[schemeSentinel]; ok {
296+
// Replace the scheme sentinel with the actual scheme
297+
sentinelDSN = value + sentinelDSN[schemeEnd:]
298+
}
299+
}
300+
}
301+
302+
// Special case: Expand port sentinel before parsing (ports must be numeric for URL parsing)
303+
sentinelDSN = expandPortSentinel(sentinelDSN, mapping)
304+
215305
// Phase 2: Parse with sentinels
216306
parsedUrl, err := url.Parse(sentinelDSN)
217307
if err != nil {

pkg/database/database_test.go

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,15 @@ func Test_expandDSN(t *testing.T) {
284284
dsn: "mysql://admin:${DB_PASSWORD}@localhost:3306/db-999000",
285285
want: "mysql://admin:1234@localhost:3306/db-999000",
286286
},
287+
{
288+
name: "Test replacing with port",
289+
env: map[string]string{
290+
"DB_PASSWORD": "1234",
291+
"DB_PORT": "3306",
292+
},
293+
dsn: "mysql://admin:${DB_PASSWORD}@localhost:${DB_PORT}/db-999001",
294+
want: "mysql://admin:1234@localhost:3306/db-999001",
295+
},
287296
{
288297
name: "Test replacing with multiple variables",
289298
env: map[string]string{
@@ -541,7 +550,7 @@ func Test_extractPlaceholders(t *testing.T) {
541550
env: map[string]string{
542551
"PASSWORD": "secret",
543552
},
544-
wantSentinel: "mysql://user:999000@localhost/db",
553+
wantSentinel: "mysql://user:_PH-999000_@localhost/db",
545554
wantMappingSize: 1,
546555
},
547556
{
@@ -552,7 +561,7 @@ func Test_extractPlaceholders(t *testing.T) {
552561
"PASSWORD": "secret",
553562
"HOST": "localhost",
554563
},
555-
wantSentinel: "mysql://999000:999001@999002/db",
564+
wantSentinel: "mysql://_PH-999000_:_PH-999001_@_PH-999002_/db",
556565
wantMappingSize: 3,
557566
},
558567
{
@@ -561,7 +570,7 @@ func Test_extractPlaceholders(t *testing.T) {
561570
env: map[string]string{
562571
"VAR": "value",
563572
},
564-
wantSentinel: "999000:999001",
573+
wantSentinel: "_PH-999000_:_PH-999001_",
565574
wantMappingSize: 2,
566575
},
567576
{
@@ -570,7 +579,7 @@ func Test_extractPlaceholders(t *testing.T) {
570579
env: map[string]string{
571580
"PORT": "3306",
572581
},
573-
wantSentinel: "mysql://user:pass@localhost:999000/db",
582+
wantSentinel: "mysql://user:pass@localhost:_PH-999000_/db",
574583
wantMappingSize: 1,
575584
},
576585
{

0 commit comments

Comments
 (0)