|
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Text.RegularExpressions;
namespace Aspire.Dashboard.Model;
/// <summary>
/// Provides utilities for parsing connection strings to extract host and port information.
/// Supports various connection string formats including URIs, key-value pairs, and delimited lists.
/// </summary>
internal static partial class ConnectionStringParser
{
private static readonly Dictionary<string, int> s_schemeDefaultPorts = new(StringComparer.OrdinalIgnoreCase)
{
["http"] = 80,
["https"] = 443,
["ftp"] = 21,
["ftps"] = 990,
["ssh"] = 22,
["telnet"] = 23,
["smtp"] = 25,
["dns"] = 53,
["dhcp"] = 67,
["tftp"] = 69,
["pop3"] = 110,
["ntp"] = 123,
["imap"] = 143,
["snmp"] = 161,
["ldap"] = 389,
["smtps"] = 465,
["ldaps"] = 636,
["imaps"] = 993,
["pop3s"] = 995,
["mssql"] = 1433,
["mysql"] = 3306,
["postgresql"] = 5432,
["postgres"] = 5432,
["redis"] = 6379,
["mongodb"] = 27017,
["amqp"] = 5672,
["amqps"] = 5671,
["kafka"] = 9092
};
private static readonly string[] s_hostAliases = ["host", "server", "data source", "addr", "address", "endpoint", "contact points"];
private static readonly string[] s_knownProtocols = ["tcp", "udp", "ssl", "tls", "http", "https", "ftp", "ssh"];
/// <summary>
/// Matches host:port or host,port patterns with optional IPv6 bracket notation.
/// Examples: "localhost:5432", "127.0.0.1,1433", "[::1]:6379"
/// </summary>
[GeneratedRegex(@"(\[[^\]]+\]|[^,:;\s]+)[:|,](\d{1,5})", RegexOptions.Compiled)]
private static partial Regex HostPortRegex();
/// <summary>
/// Matches JDBC URLs to extract host and optional port.
/// Examples: "jdbc:postgresql://localhost:5432/db", "jdbc:mysql://server/database"
/// </summary>
[GeneratedRegex(@"^jdbc:[^:]+://([^:/\s]+)(?::(\d+))?(?:/.*)?", RegexOptions.IgnoreCase | RegexOptions.Compiled)]
private static partial Regex JdbcUrlRegex();
/// <summary>
/// Attempts to extract a host and optional port from an arbitrary connection string.
/// Returns <c>true</c> if a host could be identified; otherwise <c>false</c>.
///
/// Supports the following connection string formats:
/// - URIs: "postgres://user:pass@host:5432/db", "redis://host:6379"
/// - Key-value pairs: "Host=localhost;Port=5432", "Server=tcp:host,1433"
/// - Delimited lists: "broker1:9092,broker2:9092" (returns first broker)
/// - Single hostnames: "localhost", "api.example.com"
/// </summary>
/// <param name="connectionString">The connection string to parse.</param>
/// <param name="host">When this method returns <c>true</c>, contains the host part with surrounding brackets removed; otherwise, an empty string.</param>
/// <param name="port">When this method returns <c>true</c>, contains the explicit port, scheme-derived default, or <c>null</c> when unavailable; otherwise, <c>null</c>.</param>
/// <returns><c>true</c> if a host was found; otherwise, <c>false</c>.</returns>
public static bool TryDetectHostAndPort(
string connectionString,
[NotNullWhen(true)] out string? host,
out int? port)
{
host = null;
port = null;
if (string.IsNullOrWhiteSpace(connectionString))
{
return false;
}
// Strategy 1: Parse as URI (including JDBC URLs)
// Examples: "postgres://host:5432/db", "jdbc:mysql://host/db"
if (TryParseAsUri(connectionString, out host, out port))
{
return true;
}
// Strategy 2: Parse as key-value pairs
// Examples: "Host=localhost;Port=5432", "Server=tcp:host,1433"
if (TryParseAsKeyValuePairs(connectionString, out host, out port))
{
return true;
}
// Strategy 3: Use regex heuristic for host:port patterns
// Examples: "localhost:5432", "127.0.0.1,1433", "[::1]:6379"
if (TryParseWithRegexHeuristic(connectionString, out host, out port))
{
return true;
}
// Strategy 4: Treat as single hostname (conservative approach)
// Examples: "localhost", "api.example.com" (but not file paths)
if (TryParseAsSingleHost(connectionString, out host, out port))
{
return true;
}
return false;
}
/// <summary>
/// Attempts to parse the connection string as a URI (including JDBC URLs).
/// </summary>
/// <param name="connectionString">The string to parse as a URI. Examples: "postgres://host:5432/db", "jdbc:mysql://host/db"</param>
/// <param name="host">The extracted host name, or null if parsing failed.</param>
/// <param name="port">The extracted port number, or null if no port was found.</param>
/// <returns>True if a host was successfully extracted; otherwise false.</returns>
private static bool TryParseAsUri(string connectionString, [NotNullWhen(true)] out string? host, out int? port)
{
host = null;
port = null;
// Handle JDBC URLs specially since they're not recognized by Uri.TryCreate
// Example: "jdbc:postgresql://localhost:5432/database"
if (connectionString.StartsWith("jdbc:", StringComparison.OrdinalIgnoreCase))
{
return TryParseJdbcUrl(connectionString, out host, out port);
}
// Standard URI parsing for protocols like postgres://, redis://, etc.
if (Uri.TryCreate(connectionString, UriKind.Absolute, out var uri) && !string.IsNullOrEmpty(uri.Host))
{
host = TrimBrackets(uri.Host);
port = uri.Port != -1 ? uri.Port : DefaultPortFromScheme(uri.Scheme);
return true;
}
return false;
}
/// <summary>
/// Attempts to parse key-value pair connection strings.
/// </summary>
/// <param name="connectionString">The connection string with key-value pairs. Examples: "Host=localhost;Port=5432", "Server=tcp:host,1433"</param>
/// <param name="host">The extracted host name, or null if parsing failed.</param>
/// <param name="port">The extracted port number, or null if no port was found.</param>
/// <returns>True if a host was successfully extracted; otherwise false.</returns>
private static bool TryParseAsKeyValuePairs(string connectionString, [NotNullWhen(true)] out string? host, out int? port)
{
host = null;
port = null;
var keyValuePairs = SplitIntoDictionary(connectionString);
foreach (var hostAlias in s_hostAliases)
{
if (keyValuePairs.TryGetValue(hostAlias, out var token))
{
// First, check if the token is a complete URL
// Example: "Endpoint=https://storage.azure.com"
if (TryParseAsUri(token, out var tokenHost, out var tokenPort))
{
host = tokenHost;
port = tokenPort;
return true;
}
// Handle special case of multiple contact points (should return false to be conservative)
// Example: "contact points=node1,node2,node3" should not be parsed
if (hostAlias.Equals("contact points", StringComparison.OrdinalIgnoreCase) &&
token.Contains(',') && token.Split(',').Length > 1)
{
return false;
}
// Remove protocol prefixes like "tcp:", "udp:", etc.
// Example: "Server=tcp:localhost,1433" becomes "localhost,1433"
token = RemoveProtocolPrefix(token);
if (token.Contains(',') || token.Contains(':'))
{
// Handle host:port or host,port patterns
// Examples: "localhost:5432", "127.0.0.1,1433", "[::1]:6379"
if (TryParseHostPortToken(token, keyValuePairs, out host, out port))
{
return true;
}
}
else if (!string.IsNullOrEmpty(token))
{
// Single hostname without port
// Example: "Host=localhost"
host = TrimBrackets(token);
port = PortFromKV(keyValuePairs);
return true;
}
}
}
return false;
}
/// <summary>
/// Uses regex heuristics to find host:port patterns in the connection string.
/// </summary>
/// <param name="connectionString">The connection string to search. Examples: "broker1:9092,broker2:9092", "localhost:5432"</param>
/// <param name="host">The extracted host name, or null if parsing failed.</param>
/// <param name="port">The extracted port number, or null if no port was found.</param>
/// <returns>True if a host:port pattern was found; otherwise false.</returns>
private static bool TryParseWithRegexHeuristic(string connectionString, [NotNullWhen(true)] out string? host, out int? port)
{
host = null;
port = null;
var match = HostPortRegex().Match(connectionString);
if (match.Success)
{
var hostPart = match.Groups[1].Value;
var portPart = match.Groups[2].Value;
if (!string.IsNullOrEmpty(hostPart))
{
host = TrimBrackets(hostPart);
port = ParseIntSafe(portPart);
return true;
}
}
return false;
}
/// <summary>
/// Attempts to treat the entire connection string as a single hostname (conservative approach).
/// </summary>
/// <param name="connectionString">The string to evaluate as a hostname. Examples: "localhost", "api.example.com"</param>
/// <param name="host">The hostname if it looks valid, or null if it appears to be a file path or other non-hostname.</param>
/// <param name="port">Always null for single hostname parsing.</param>
/// <returns>True if the string looks like a valid hostname; otherwise false.</returns>
private static bool TryParseAsSingleHost(string connectionString, [NotNullWhen(true)] out string? host, out int? port)
{
host = null;
port = null;
if (LooksLikeHost(connectionString))
{
host = TrimBrackets(connectionString);
port = null;
return true;
}
return false;
}
/// <summary>
/// Parses a host:port or host,port token, with special handling for IPv6 addresses.
/// </summary>
/// <param name="token">The token to parse. Examples: "localhost:5432", "[::1]:6379", "host,1433"</param>
/// <param name="keyValuePairs">Additional key-value pairs that might contain a separate port value.</param>
/// <param name="host">The extracted host name, or null if parsing failed.</param>
/// <param name="port">The extracted port number, or null if no port was found.</param>
/// <returns>True if parsing succeeded; otherwise false.</returns>
private static bool TryParseHostPortToken(string token, Dictionary<string, string> keyValuePairs, [NotNullWhen(true)] out string? host, out int? port)
{
host = null;
port = null;
// Special handling for IPv6 addresses in brackets
// Example: "[::1]:6379" or "[::1],6379"
if (token.StartsWith('[') && token.Contains(']'))
{
var bracketEnd = token.IndexOf(']');
if (bracketEnd > 0)
{
host = TrimBrackets(token[..(bracketEnd + 1)]);
// Look for port after the bracket (could be colon or comma separated)
var afterBracket = token[(bracketEnd + 1)..];
if ((afterBracket.StartsWith(':') || afterBracket.StartsWith(',')) && afterBracket.Length > 1)
{
port = ParseIntSafe(afterBracket[1..]) ?? PortFromKV(keyValuePairs);
}
else
{
port = PortFromKV(keyValuePairs);
}
return true;
}
}
// Regular host:port or host,port parsing
var (hostPart, portPart) = SplitOnLast(token);
if (!string.IsNullOrEmpty(hostPart))
{
host = TrimBrackets(hostPart);
port = ParseIntSafe(portPart) ?? PortFromKV(keyValuePairs);
return true;
}
return false;
}
/// <summary>
/// Parses JDBC URLs which have the format: jdbc:subprotocol://host:port/database
/// </summary>
/// <param name="jdbcUrl">The JDBC URL to parse. Examples: "jdbc:postgresql://localhost:5432/db", "jdbc:mysql://server/database"</param>
/// <param name="host">The extracted host name, or null if parsing failed.</param>
/// <param name="port">The extracted port number, or null if no port was specified.</param>
/// <returns>True if the JDBC URL was successfully parsed; otherwise false.</returns>
private static bool TryParseJdbcUrl(string jdbcUrl, [NotNullWhen(true)] out string? host, out int? port)
{
host = null;
port = null;
var match = JdbcUrlRegex().Match(jdbcUrl);
if (match.Success)
{
host = match.Groups[1].Value;
if (match.Groups[2].Success && int.TryParse(match.Groups[2].Value, out var portValue))
{
port = portValue;
}
return true;
}
return false;
}
/// <summary>
/// Removes square brackets from the beginning and end of a string.
/// </summary>
/// <param name="s">The string to trim. Example: "[::1]" becomes "::1"</param>
/// <returns>The string with brackets removed.</returns>
private static string TrimBrackets(string s) => s.Trim('[', ']');
/// <summary>
/// Removes known protocol prefixes from connection string values.
/// </summary>
/// <param name="value">The value to clean. Examples: "tcp:localhost" becomes "localhost", "ssl:host:443" becomes "host:443"</param>
/// <returns>The value with protocol prefix removed, or the original value if no known prefix is found.</returns>
private static string RemoveProtocolPrefix(string value)
{
// Remove common protocol prefixes like "tcp:", "udp:", "ssl:", etc.
if (string.IsNullOrEmpty(value))
{
return value;
}
var colonIndex = value.IndexOf(':');
if (colonIndex > 0 && colonIndex < value.Length - 1)
{
var prefix = value[..colonIndex].ToLowerInvariant();
// Only remove known protocol prefixes, not arbitrary single letters
if (s_knownProtocols.Contains(prefix))
{
return value[(colonIndex + 1)..];
}
}
return value;
}
/// <summary>
/// Gets the default port number for a given URI scheme.
/// </summary>
/// <param name="scheme">The URI scheme. Examples: "postgres", "redis", "https"</param>
/// <returns>The default port number for the scheme, or null if no default is known.</returns>
private static int? DefaultPortFromScheme(string? scheme)
{
if (string.IsNullOrEmpty(scheme))
{
return null;
}
return s_schemeDefaultPorts.TryGetValue(scheme, out var port) ? port : null;
}
/// <summary>
/// Extracts a port value from key-value pairs using the "port" key.
/// </summary>
/// <param name="keyValuePairs">The dictionary of key-value pairs to search.</param>
/// <returns>The port number if found and valid, or null otherwise.</returns>
private static int? PortFromKV(Dictionary<string, string> keyValuePairs)
{
return keyValuePairs.TryGetValue("port", out var portValue) ? ParseIntSafe(portValue) : null;
}
/// <summary>
/// Safely parses a string as an integer port number (0-65535).
/// </summary>
/// <param name="s">The string to parse. Examples: "5432", "443", "invalid"</param>
/// <returns>The parsed port number if valid, or null if parsing failed or the number is out of range.</returns>
private static int? ParseIntSafe(string? s)
{
if (string.IsNullOrEmpty(s))
{
return null;
}
if (int.TryParse(s, NumberStyles.None, CultureInfo.InvariantCulture, out var value) &&
value >= 0 && value <= 65535)
{
return value;
}
return null;
}
/// <summary>
/// Splits a connection string into key-value pairs using semicolon or whitespace delimiters.
/// </summary>
/// <param name="connectionString">The connection string to split. Examples: "Host=localhost;Port=5432", "server=host port=1433"</param>
/// <returns>A dictionary of key-value pairs with case-insensitive keys.</returns>
private static Dictionary<string, string> SplitIntoDictionary(string connectionString)
{
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
// Split by semicolon first, then by whitespace if no semicolons found
var parts = connectionString.Contains(';')
? connectionString.Split(';', StringSplitOptions.RemoveEmptyEntries)
: connectionString.Split([' ', '\t', '\n', '\r'], StringSplitOptions.RemoveEmptyEntries);
foreach (var part in parts)
{
var trimmedPart = part.Trim();
var equalIndex = trimmedPart.IndexOf('=');
if (equalIndex > 0 && equalIndex < trimmedPart.Length - 1)
{
var key = trimmedPart[..equalIndex].Trim();
var value = trimmedPart[(equalIndex + 1)..].Trim();
if (!string.IsNullOrEmpty(key) && !string.IsNullOrEmpty(value))
{
result[key] = value;
}
}
}
return result;
}
/// <summary>
/// Splits a token on the last occurrence of ':' or ',' to separate host and port.
/// </summary>
/// <param name="token">The token to split. Examples: "localhost:5432", "host,1433", "host:8080:extra"</param>
/// <returns>A tuple with the host part and port part. Port part may be empty if no delimiter is found.</returns>
private static (string host, string port) SplitOnLast(string token)
{
// Split on the last occurrence of ':' or ','
var lastColonIndex = token.LastIndexOf(':');
var lastCommaIndex = token.LastIndexOf(',');
var splitIndex = Math.Max(lastColonIndex, lastCommaIndex);
if (splitIndex > 0 && splitIndex < token.Length - 1)
{
return (token[..splitIndex].Trim(), token[(splitIndex + 1)..].Trim());
}
return (token, string.Empty);
}
/// <summary>
/// Determines if a string looks like a hostname rather than a file path or other non-hostname string.
/// Uses URI validation with conservative heuristics to avoid false positives.
/// </summary>
/// <param name="connectionString">The string to evaluate. Examples: "localhost" (valid), "/path/to/file.db" (invalid), "api.example.com" (valid)</param>
/// <returns>True if the string appears to be a hostname; otherwise false.</returns>
private static bool LooksLikeHost(string connectionString)
{
// Reject strings with '=' (likely key-value pairs)
if (connectionString.Contains('='))
{
return false;
}
// Reject obvious file path indicators
if (connectionString.StartsWith('/') || connectionString.StartsWith('\\') ||
connectionString.StartsWith("./") || connectionString.StartsWith("../") ||
(connectionString.Length > 2 && connectionString[1] == ':' && char.IsLetter(connectionString[0])))
{
return false;
}
// Use Uri parsing to validate hostname - create a fake URI and see if it parses
var fakeUri = $"scheme://{connectionString.Trim()}";
return Uri.TryCreate(fakeUri, UriKind.Absolute, out var uri) && !string.IsNullOrEmpty(uri.Host);
}
} |