@@ -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.
7775func 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.
107106func 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.
153153func 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 {
0 commit comments