From 739d6bd65a81bbdbc461c6c798fa416c57787ad0 Mon Sep 17 00:00:00 2001 From: eelke Date: Sun, 31 Aug 2025 13:11:59 +0200 Subject: [PATCH] Fix libpq parsing and refactors/code cleanup --- .../PqConnectionStringParserTests.cs | 27 ++- .../ConnectionStrings/CodecCommon.cs | 101 ++++++++++ .../ConnectionDescriptorBuilder.cs | 36 ++++ .../ConnectionStrings/JdbcCodec.cs | 170 +++------------- .../ConnectionStrings/NpgsqlCodec.cs | 75 ++------ pgLabII.PgUtils/ConnectionStrings/PLAN.md | 54 ------ .../ConnectionStrings/Pq/LibpqCodec.cs | 100 ++-------- .../Pq/PqConnectionStringParser.cs | 10 +- .../Pq/PqConnectionStringTokenizer.cs | 6 - pgLabII.PgUtils/ConnectionStrings/UrlCodec.cs | 182 ++---------------- .../EditServerConfigurationWindowTests.cs | 12 +- pgLabII.sln.DotSettings | 4 + 12 files changed, 234 insertions(+), 543 deletions(-) create mode 100644 pgLabII.PgUtils/ConnectionStrings/CodecCommon.cs create mode 100644 pgLabII.PgUtils/ConnectionStrings/ConnectionDescriptorBuilder.cs delete mode 100644 pgLabII.PgUtils/ConnectionStrings/PLAN.md create mode 100644 pgLabII.sln.DotSettings diff --git a/pgLabII.PgUtils.Tests/ConnectionStrings/PqConnectionStringParserTests.cs b/pgLabII.PgUtils.Tests/ConnectionStrings/PqConnectionStringParserTests.cs index d953ffc..37c3f11 100644 --- a/pgLabII.PgUtils.Tests/ConnectionStrings/PqConnectionStringParserTests.cs +++ b/pgLabII.PgUtils.Tests/ConnectionStrings/PqConnectionStringParserTests.cs @@ -1,4 +1,6 @@ -using pgLabII.PgUtils.ConnectionStrings; +using FluentResults; +using pgLabII.PgUtils.ConnectionStrings; +using pgLabII.PgUtils.Tests.ConnectionStrings.Util; namespace pgLabII.PgUtils.Tests.ConnectionStrings; @@ -22,20 +24,25 @@ public class PqConnectionStringParserTests public void Success() { var parser = new PqConnectionStringParser(tokenizer); - IDictionary output = parser.Parse(); - - Assert.Single(output); - Assert.True(output.TryGetValue(kw, out string? result)); - Assert.Equal(val, result); + Result> output = parser.Parse(); + ResultAssert.Success(output, v => + { + Assert.Single(v); + Assert.True(v.TryGetValue(kw, out string? result)); + Assert.Equal(val, result); + }); } [Fact] public void StaticParse() { - var output = PqConnectionStringParser.Parse("foo=bar"); - Assert.Single(output); - Assert.True(output.TryGetValue("foo", out string? result)); - Assert.Equal("bar", result); + Result> output = PqConnectionStringParser.Parse("foo=bar"); + ResultAssert.Success(output, v => + { + Assert.Single(v); + Assert.True(v.TryGetValue("foo", out string? result)); + Assert.Equal("bar", result); + }); } // There are few tests here as this is a predictive parser and all error handling is done // in the tokenizer diff --git a/pgLabII.PgUtils/ConnectionStrings/CodecCommon.cs b/pgLabII.PgUtils/ConnectionStrings/CodecCommon.cs new file mode 100644 index 0000000..bef3c04 --- /dev/null +++ b/pgLabII.PgUtils/ConnectionStrings/CodecCommon.cs @@ -0,0 +1,101 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; +using Npgsql; + +namespace pgLabII.PgUtils.ConnectionStrings; + +/// +/// Shared helper utilities for codecs to reduce duplication (SSL mode mapping, host:port parsing/formatting, +/// URL query parsing, and .NET/libpq quoting helpers). +/// +internal static class CodecCommon +{ + // SSL mapping + public static SslMode ParseSslModeLoose(string s) + => s.Trim().ToLowerInvariant() switch + { + "disable" => SslMode.Disable, + "allow" => SslMode.Allow, + "prefer" => SslMode.Prefer, + "require" => SslMode.Require, + "verify-ca" or "verifyca" => SslMode.VerifyCA, + "verify-full" or "verifyfull" => SslMode.VerifyFull, + _ => throw new ArgumentException($"Not a valid SSL Mode: {s}") + }; + + public static string FormatSslModeUrlLike(SslMode mode) => mode switch + { + SslMode.Disable => "disable", + SslMode.Allow => "allow", + SslMode.Prefer => "prefer", + SslMode.Require => "require", + SslMode.VerifyCA => "verify-ca", + SslMode.VerifyFull => "verify-full", + _ => "prefer" + }; + + + // host:port parsing for plain or [IPv6]:port + public static void ParseHostPort(string hostPart, out string host, out ushort? port) + { + host = hostPart; + port = null; + if (string.IsNullOrWhiteSpace(hostPart)) + return; + + if (hostPart[0] == '[') + { + int end = hostPart.IndexOf(']'); + if (end > 0) + { + host = hostPart[1..end]; + if (end + 1 < hostPart.Length && hostPart[end + 1] == ':') + { + string ps = hostPart[(end + 2)..]; + if (ushort.TryParse(ps, NumberStyles.Integer, CultureInfo.InvariantCulture, out var p)) + port = p; + } + } + return; + } + int colon = hostPart.LastIndexOf(':'); + if (colon > 0 && colon < hostPart.Length - 1) + { + var ps = hostPart.Substring(colon + 1); + if (ushort.TryParse(ps, NumberStyles.Integer, CultureInfo.InvariantCulture, out var p)) + { + host = hostPart.Substring(0, colon); + port = p; + } + } + } + + public static string FormatHost(HostEndpoint h) + { + var host = h.Host; + if (host.Contains(':') && !host.StartsWith("[")) + host = "[" + host + "]"; // IPv6 + + return h.Port.HasValue ? host + ":" + h.Port.Value.ToString(CultureInfo.InvariantCulture) : host; + } + + public static string[] SplitHosts(string hostList) + => hostList.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + public static Dictionary ParseQuery(string query) + { + var dict = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (string.IsNullOrEmpty(query)) return dict; + foreach (var kv in query.Split('&', StringSplitOptions.RemoveEmptyEntries)) + { + var parts = kv.Split('=', 2); + var key = Uri.UnescapeDataString(parts[0]); + var val = parts.Length > 1 ? Uri.UnescapeDataString(parts[1]) : string.Empty; + dict[key] = val; + } + return dict; + } +} diff --git a/pgLabII.PgUtils/ConnectionStrings/ConnectionDescriptorBuilder.cs b/pgLabII.PgUtils/ConnectionStrings/ConnectionDescriptorBuilder.cs new file mode 100644 index 0000000..86f2703 --- /dev/null +++ b/pgLabII.PgUtils/ConnectionStrings/ConnectionDescriptorBuilder.cs @@ -0,0 +1,36 @@ +using Npgsql; + +namespace pgLabII.PgUtils.ConnectionStrings; + +public sealed class ConnectionDescriptorBuilder +{ + private List Hosts { get; } = []; + public string? Database { get; set; } + public string? Username { get; set; } + public string? Password { get; set; } + public SslMode? SslMode { get; set; } + public string? ApplicationName { get; set; } + public int? TimeoutSeconds { get; set; } + public Dictionary Properties { get; } = new(StringComparer.OrdinalIgnoreCase); + + public void AddHost(string host, ushort? port) + { + if (string.IsNullOrWhiteSpace(host)) return; + Hosts.Add(new HostEndpoint { Host = host.Trim(), Port = port }); + } + + public ConnectionDescriptor Build() + { + return new ConnectionDescriptor + { + Hosts = Hosts, + Database = Database, + Username = Username, + Password = Password, + SslMode = SslMode, + ApplicationName = ApplicationName, + TimeoutSeconds = TimeoutSeconds, + Properties = Properties + }; + } +} diff --git a/pgLabII.PgUtils/ConnectionStrings/JdbcCodec.cs b/pgLabII.PgUtils/ConnectionStrings/JdbcCodec.cs index 6d46bc2..1274f9c 100644 --- a/pgLabII.PgUtils/ConnectionStrings/JdbcCodec.cs +++ b/pgLabII.PgUtils/ConnectionStrings/JdbcCodec.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Linq; using System.Text; @@ -46,11 +47,11 @@ public sealed class JdbcCodec : IConnectionStringCodec var builder = new ConnectionDescriptorBuilder(); // Parse hosts (comma separated) - foreach (var part in SplitHosts(authority)) + foreach (string part in CodecCommon.SplitHosts(authority)) { - if (string.IsNullOrWhiteSpace(part)) continue; - ParseHostPort(part, out var host, out ushort? port); - if (!string.IsNullOrEmpty(host)) builder.AddHost(host!, port); + CodecCommon.ParseHostPort(part, out var host, out ushort? port); + if (!string.IsNullOrEmpty(host)) + builder.AddHost(host!, port); } // Parse database and query @@ -59,8 +60,8 @@ public sealed class JdbcCodec : IConnectionStringCodec if (!string.IsNullOrEmpty(pathAndQuery)) { int qIdx = pathAndQuery.IndexOf('?'); - var path = qIdx >= 0 ? pathAndQuery.Substring(0, qIdx) : pathAndQuery; - query = qIdx >= 0 ? pathAndQuery.Substring(qIdx + 1) : string.Empty; + string path = qIdx >= 0 ? pathAndQuery[..qIdx] : pathAndQuery; + query = qIdx >= 0 ? pathAndQuery[(qIdx + 1)..] : string.Empty; if (path.Length > 0) { if (path[0] == '/') path = path.Substring(1); @@ -70,21 +71,22 @@ public sealed class JdbcCodec : IConnectionStringCodec } if (!string.IsNullOrEmpty(database)) builder.Database = database; - var queryDict = ParseQuery(query); + var queryDict = CodecCommon.ParseQuery(query); // Map known properties - if (TryFirst(queryDict, out var ssl, "sslmode", "ssl")) - builder.SslMode = ParseSslMode(ssl); - if (TryFirst(queryDict, out var app, "applicationName", "application_name")) + if (TryFirst(queryDict, out string? ssl, "sslmode", "ssl")) + builder.SslMode = CodecCommon.ParseSslModeLoose(ssl); + if (TryFirst(queryDict, out string? app, "applicationName", "application_name")) builder.ApplicationName = app; - if (TryFirst(queryDict, out var tout, "loginTimeout", "connectTimeout", "connect_timeout")) + if (TryFirst(queryDict, out string? tout, "loginTimeout", "connectTimeout", "connect_timeout")) { - if (int.TryParse(tout, NumberStyles.Integer, CultureInfo.InvariantCulture, out var t)) + if (int.TryParse(tout, NumberStyles.Integer, CultureInfo.InvariantCulture, out int t)) builder.TimeoutSeconds = t; } // Preserve extras - var mapped = new HashSet(new[] { "sslmode", "ssl", "applicationName", "application_name", "loginTimeout", "connectTimeout", "connect_timeout" }, StringComparer.OrdinalIgnoreCase); + var mapped = new HashSet(["sslmode", "ssl", "applicationName", "application_name", "loginTimeout", "connectTimeout", "connect_timeout" + ], StringComparer.OrdinalIgnoreCase); foreach (var kv in queryDict) { if (!mapped.Contains(kv.Key)) @@ -106,7 +108,7 @@ public sealed class JdbcCodec : IConnectionStringCodec var sb = new StringBuilder(); sb.Append("jdbc:postgresql://"); - if (descriptor.Hosts != null && descriptor.Hosts.Count > 0) + if (descriptor.Hosts.Count > 0) { sb.Append(string.Join(',', descriptor.Hosts.Select(FormatHost))); } @@ -122,7 +124,7 @@ public sealed class JdbcCodec : IConnectionStringCodec var qp = new List<(string k, string v)>(); if (descriptor.SslMode.HasValue) { - qp.Add(("sslmode", FormatSslMode(descriptor.SslMode.Value))); + qp.Add(("sslmode", CodecCommon.FormatSslModeUrlLike(descriptor.SslMode.Value))); } if (!string.IsNullOrEmpty(descriptor.ApplicationName)) { @@ -154,140 +156,20 @@ public sealed class JdbcCodec : IConnectionStringCodec return Result.Fail(ex.Message); } } + + private static string FormatHost(HostEndpoint h) => CodecCommon.FormatHost(h); - private static IEnumerable SplitHosts(string authority) + private static bool TryFirst( + Dictionary dict, + [MaybeNullWhen(false)] out string value, + params string[] keys) { - return authority.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - } - - private static string FormatHost(HostEndpoint h) - { - var host = h.Host; - if (host.Contains(':') && !host.StartsWith("[")) + foreach (string k in keys) { - // IPv6 literal must be bracketed - host = "[" + host + "]"; - } - return h.Port.HasValue ? host + ":" + h.Port.Value.ToString(CultureInfo.InvariantCulture) : host; - } - - private static void ParseHostPort(string hostPart, out string host, out ushort? port) - { - host = hostPart; - port = null; - if (string.IsNullOrWhiteSpace(hostPart)) return; - - if (hostPart[0] == '[') - { - int end = hostPart.IndexOf(']'); - if (end > 0) - { - host = hostPart.Substring(1, end - 1); - if (end + 1 < hostPart.Length && hostPart[end + 1] == ':') - { - var ps = hostPart.Substring(end + 2); - if (ushort.TryParse(ps, NumberStyles.Integer, CultureInfo.InvariantCulture, out var p)) - port = p; - } - } - return; - } - int colon = hostPart.LastIndexOf(':'); - if (colon > 0 && colon < hostPart.Length - 1) - { - var ps = hostPart.Substring(colon + 1); - if (ushort.TryParse(ps, NumberStyles.Integer, CultureInfo.InvariantCulture, out var p)) - { - host = hostPart.Substring(0, colon); - port = p; - } - } - } - - private static Dictionary ParseQuery(string query) - { - var dict = new Dictionary(StringComparer.OrdinalIgnoreCase); - if (string.IsNullOrEmpty(query)) return dict; - foreach (var kv in query.Split('&', StringSplitOptions.RemoveEmptyEntries)) - { - var parts = kv.Split('=', 2); - var key = Uri.UnescapeDataString(parts[0]); - var val = parts.Length > 1 ? Uri.UnescapeDataString(parts[1]) : string.Empty; - dict[key] = val; - } - return dict; - } - - private static bool TryFirst(Dictionary dict, out string value, params string[] keys) - { - foreach (var k in keys) - { - if (dict.TryGetValue(k, out value)) return true; + if (dict.TryGetValue(k, out value)) + return true; } value = string.Empty; return false; } - - private static SslMode ParseSslMode(string s) - { - switch (s.Trim().ToLowerInvariant()) - { - case "disable": return SslMode.Disable; - case "allow": return SslMode.Allow; - case "prefer": return SslMode.Prefer; - case "require": return SslMode.Require; - case "verify-ca": - case "verifyca": return SslMode.VerifyCA; - case "verify-full": - case "verifyfull": return SslMode.VerifyFull; - default: throw new ArgumentException($"Not a valid SSL Mode: {s}"); - } - } - - private static string FormatSslMode(SslMode mode) - { - return mode switch - { - SslMode.Disable => "disable", - SslMode.Allow => "allow", - SslMode.Prefer => "prefer", - SslMode.Require => "require", - SslMode.VerifyCA => "verify-ca", - SslMode.VerifyFull => "verify-full", - _ => "prefer" - }; - } - - private sealed class ConnectionDescriptorBuilder - { - public List Hosts { get; } = new(); - public string? Database { get; set; } - public string? Username { get; set; } - public string? Password { get; set; } - public SslMode? SslMode { get; set; } - public string? ApplicationName { get; set; } - public int? TimeoutSeconds { get; set; } - public Dictionary Properties { get; } = new(StringComparer.OrdinalIgnoreCase); - - public void AddHost(string host, ushort? port) - { - if (string.IsNullOrWhiteSpace(host)) return; - Hosts.Add(new HostEndpoint { Host = host.Trim(), Port = port }); - } - - public ConnectionDescriptor Build() - { - return new ConnectionDescriptor - { - Hosts = Hosts, - Database = Database, - Username = Username, - Password = Password, - SslMode = SslMode, - ApplicationName = ApplicationName, - TimeoutSeconds = TimeoutSeconds, - Properties = Properties - }; - } - } } diff --git a/pgLabII.PgUtils/ConnectionStrings/NpgsqlCodec.cs b/pgLabII.PgUtils/ConnectionStrings/NpgsqlCodec.cs index 57bf2d4..4666c72 100644 --- a/pgLabII.PgUtils/ConnectionStrings/NpgsqlCodec.cs +++ b/pgLabII.PgUtils/ConnectionStrings/NpgsqlCodec.cs @@ -1,7 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; +using System.Globalization; using System.Text; using FluentResults; using Npgsql; @@ -222,35 +219,18 @@ public sealed class NpgsqlCodec : IConnectionStringCodec return false; } - private static SslMode ParseSslMode(string s) - { - switch (s.Trim().ToLowerInvariant()) - { - case "disable": return SslMode.Disable; - case "allow": return SslMode.Allow; - case "prefer": return SslMode.Prefer; - case "require": return SslMode.Require; - case "verify-ca": - case "verifyca": return SslMode.VerifyCA; - case "verify-full": - case "verifyfull": return SslMode.VerifyFull; - default: throw new ArgumentException($"Not a valid SSL Mode: {s}"); - } - } + private static SslMode ParseSslMode(string s) => CodecCommon.ParseSslModeLoose(s); - private static string FormatSslMode(SslMode mode) + private static string FormatSslMode(SslMode mode) => mode switch { - return mode switch - { - SslMode.Disable => "Disable", - SslMode.Allow => "Allow", - SslMode.Prefer => "Prefer", - SslMode.Require => "Require", - SslMode.VerifyCA => "VerifyCA", - SslMode.VerifyFull => "VerifyFull", - _ => "Prefer" - }; - } + SslMode.Disable => "Disable", + SslMode.Allow => "Allow", + SslMode.Prefer => "Prefer", + SslMode.Require => "Require", + SslMode.VerifyCA => "VerifyCA", + SslMode.VerifyFull => "VerifyFull", + _ => "Prefer" + }; // Npgsql/.NET connection string grammar: semicolon-separated key=value; values with special chars are wrapped in quotes, internal quotes doubled private static string FormatPair(string key, string? value) @@ -343,37 +323,4 @@ public sealed class NpgsqlCodec : IConnectionStringCodec return dict; } - - private sealed class ConnectionDescriptorBuilder - { - public List Hosts { get; } = new(); - public string? Database { get; set; } - public string? Username { get; set; } - public string? Password { get; set; } - public SslMode? SslMode { get; set; } - public string? ApplicationName { get; set; } - public int? TimeoutSeconds { get; set; } - public Dictionary Properties { get; } = new(StringComparer.OrdinalIgnoreCase); - - public void AddHost(string host, ushort? port) - { - if (string.IsNullOrWhiteSpace(host)) return; - Hosts.Add(new HostEndpoint { Host = host.Trim(), Port = port }); - } - - public ConnectionDescriptor Build() - { - return new ConnectionDescriptor - { - Hosts = Hosts, - Database = Database, - Username = Username, - Password = Password, - SslMode = SslMode, - ApplicationName = ApplicationName, - TimeoutSeconds = TimeoutSeconds, - Properties = Properties - }; - } - } } diff --git a/pgLabII.PgUtils/ConnectionStrings/PLAN.md b/pgLabII.PgUtils/ConnectionStrings/PLAN.md deleted file mode 100644 index 10ffcf0..0000000 --- a/pgLabII.PgUtils/ConnectionStrings/PLAN.md +++ /dev/null @@ -1,54 +0,0 @@ -# Connection Strings Plan - -This document tracks the plan for supporting multiple PostgreSQL connection string formats, converting between them, and mapping to/from a canonical model. - -## Current Status (2025-08-31) - -Implemented: -- Abstractions: `ConnStringFormat`, `HostEndpoint`, `ConnectionDescriptor`, `IConnectionStringCodec`, `IConnectionStringService`. -- Codecs: - - `LibpqCodec` (libpq): parse/format; multi-host; `sslmode`, `application_name`, `connect_timeout`; quoting/escaping; preserves extras. - - `NpgsqlCodec` (.NET/Npgsql): parse/format; alias recognition; multi-host with single or per-host ports; `SSL Mode`, `Application Name`, `Timeout`; double-quote rules; preserves extras. - - `UrlCodec` (postgresql://): parse/format; userinfo, multi-host with per-host ports, IPv6 `[::1]` handling, database path, percent-decoding/encoding, common params mapping, preserves extras. -- Composite `ConnectionStringService` (detect + convert) composing Libpq, Npgsql, and Url codecs. -- Mapping helpers to/from `ServerConfiguration` (primary host/port, database, SSL mode) with sensible defaults. -- Tests: - - Unit tests for Libpq, Npgsql, and Url codecs (parse/format/round-trip/edge quoting and percent-encoding). - - ConnectionStringService detection/conversion tests. - - ServerConfiguration mapping tests. - -Not yet implemented: -- JDBC (jdbc:postgresql://) codec - -## Updated Plan - -1. Define canonical model and interfaces for connection strings. ✓ -2. Establish normalization strategy for parameter aliases and extra `Properties` handling. ✓ -3. Implement format-specific codecs: - - libpq codec (parse/format; multi-host, quoting, sslmode, timeout, extras). ✓ - - Npgsql codec (parse/format; aliases, multi-host/ports, quoting, ssl mode, timeout, extras). ✓ - - URL (postgresql://) codec (parse/format; userinfo, host[:port], db, query params, percent-encoding). ✓ - - JDBC (jdbc:postgresql://) codec (parse/format; hosts, ports, db, properties; URL-like semantics). -4. Composite conversion service. ✓ -5. Mapping with application model. ✓ -6. Validation and UX: - - Validation for malformed inputs & edge cases (mismatched host/port counts, invalid SSL mode, missing db/host, IPv6 bracket handling). ✓ - - Ensure sensitive fields (password) are masked in logs/preview). ✓ -7. Tests: - - Unit tests for URL codec (parse/format/round-trip/percent-encoding). ✓ - - Tests for composite service detect/convert; mapping functions; cross-format round-trips; edge cases (spaces, quotes, unicode, IPv6, percent-encoding). ✓ - - Unit tests for JDBC codec. -8. Documentation: - - Keep this plan updated and enrich XML docs on codecs/service including alias mappings and quoting/escaping rules per format. * - -## Next Small Step - -Implement the JDBC (jdbc:postgresql://) codec with unit tests. Scope: -- Parse: `jdbc:postgresql://host1[:port1][,hostN[:portN]]/[database]?param=value&...` - - Support multiple hosts with optional per-host ports; IPv6 bracket handling. - - Recognize common properties (sslmode/SSL, applicationName, loginTimeout/connectTimeout) and preserve unrecognized properties. - - Ensure URL-like semantics consistent with UrlCodec percent-decoding/encoding. -- Format: Build JDBC URL from ConnectionDescriptor; emit multi-hosts and properties from `Properties` not already emitted. -- Tests: basic parse/format, multi-host with mixed ports, percent-encoding, round-trips; cross-format conversions via ConnectionStringService. - -After that, consider minor documentation polish and any gaps in edge-case validation discovered while adding JDBC support. diff --git a/pgLabII.PgUtils/ConnectionStrings/Pq/LibpqCodec.cs b/pgLabII.PgUtils/ConnectionStrings/Pq/LibpqCodec.cs index 6ae17a0..fe48afc 100644 --- a/pgLabII.PgUtils/ConnectionStrings/Pq/LibpqCodec.cs +++ b/pgLabII.PgUtils/ConnectionStrings/Pq/LibpqCodec.cs @@ -1,9 +1,5 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; +using System.Text; using FluentResults; -using Npgsql; namespace pgLabII.PgUtils.ConnectionStrings; @@ -16,14 +12,13 @@ public sealed class LibpqCodec : IConnectionStringCodec { try { - // Reject Npgsql-style strings that use ';' separators when forcing libpq - if (input.IndexOf(';') >= 0) - return Result.Fail("Semicolons are not valid separators in libpq connection strings"); - var kv = new PqConnectionStringParser(new PqConnectionStringTokenizer(input)).Parse(); + Result> kv = new PqConnectionStringParser(new PqConnectionStringTokenizer(input)).Parse(); + if (kv.IsFailed) + return kv.ToResult(); // libpq keywords are case-insensitive; normalize to lower for lookup var dict = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var pair in kv) + foreach (var pair in kv.Value) dict[pair.Key] = pair.Value; var descriptor = new ConnectionDescriptorBuilder(); @@ -31,7 +26,7 @@ public sealed class LibpqCodec : IConnectionStringCodec if (dict.TryGetValue("host", out var host)) { // libpq supports host lists separated by commas - var hosts = host.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + string[] hosts = CodecCommon.SplitHosts(host); ushort? portForAll = null; if (dict.TryGetValue("port", out var portStr) && ushort.TryParse(portStr, out var p)) portForAll = p; @@ -40,10 +35,10 @@ public sealed class LibpqCodec : IConnectionStringCodec descriptor.AddHost(h, portForAll); } } - if (dict.TryGetValue("hostaddr", out var hostaddr) && !string.IsNullOrWhiteSpace(hostaddr)) + if (dict.TryGetValue("hostaddr", out string? hostaddr) && !string.IsNullOrWhiteSpace(hostaddr)) { - // If hostaddr is provided without host, include as host entries as well - var hosts = hostaddr.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + // If hostaddr is provided without a host, include as host entries as well + string[] hosts = CodecCommon.SplitHosts(hostaddr); ushort? portForAll = null; if (dict.TryGetValue("port", out var portStr) && ushort.TryParse(portStr, out var p)) portForAll = p; @@ -61,7 +56,7 @@ public sealed class LibpqCodec : IConnectionStringCodec descriptor.Password = pass; if (dict.TryGetValue("sslmode", out var sslStr)) - descriptor.SslMode = ParseSslMode(sslStr); + descriptor.SslMode = CodecCommon.ParseSslModeLoose(sslStr); if (dict.TryGetValue("application_name", out var app)) descriptor.ApplicationName = app; if (dict.TryGetValue("connect_timeout", out var tout) && int.TryParse(tout, out var seconds)) @@ -93,7 +88,7 @@ public sealed class LibpqCodec : IConnectionStringCodec var parts = new List(); // Hosts and port - if (descriptor.Hosts != null && descriptor.Hosts.Count > 0) + if (descriptor.Hosts.Count > 0) { var hostList = string.Join(',', descriptor.Hosts.Select(h => h.Host)); parts.Add(FormatPair("host", hostList)); @@ -110,7 +105,7 @@ public sealed class LibpqCodec : IConnectionStringCodec if (!string.IsNullOrEmpty(descriptor.Password)) parts.Add(FormatPair("password", descriptor.Password)); if (descriptor.SslMode.HasValue) - parts.Add(FormatPair("sslmode", FormatSslMode(descriptor.SslMode.Value))); + parts.Add(FormatPair("sslmode", CodecCommon.FormatSslModeUrlLike(descriptor.SslMode.Value))); if (!string.IsNullOrEmpty(descriptor.ApplicationName)) parts.Add(FormatPair("application_name", descriptor.ApplicationName)); if (descriptor.TimeoutSeconds.HasValue) @@ -132,34 +127,6 @@ public sealed class LibpqCodec : IConnectionStringCodec } } - private static SslMode ParseSslMode(string s) - { - return s.Trim().ToLowerInvariant() switch - { - "disable" => SslMode.Disable, - "allow" => SslMode.Allow, - "prefer" => SslMode.Prefer, - "require" => SslMode.Require, - "verify-ca" => SslMode.VerifyCA, - "verify-full" => SslMode.VerifyFull, - _ => throw new ArgumentException($"Not a valid SSL mode: {s}") - }; - } - - private static string FormatSslMode(SslMode mode) - { - return mode switch - { - SslMode.Disable => "disable", - SslMode.Allow => "allow", - SslMode.Prefer => "prefer", - SslMode.Require => "require", - SslMode.VerifyCA => "verify-ca", - SslMode.VerifyFull => "verify-full", - _ => "prefer" - }; - } - private static string FormatPair(string key, string? value) { value ??= string.Empty; @@ -170,56 +137,17 @@ public sealed class LibpqCodec : IConnectionStringCodec private static bool NeedsQuoting(string value) { - if (value.Length == 0) return true; - foreach (var c in value) - { - if (char.IsWhiteSpace(c) || c == '=' || c == '\'' || c == '\\') - return true; - } - return false; + return value.Any(c => char.IsWhiteSpace(c) || c == '=' || c == '\'' || c == '\\'); } private static string EscapeValue(string value) { var sb = new StringBuilder(); - foreach (var c in value) + foreach (char c in value) { if (c == '\'' || c == '\\') sb.Append('\\'); sb.Append(c); } return sb.ToString(); } - - private sealed class ConnectionDescriptorBuilder - { - public List Hosts { get; } = new(); - public string? Database { get; set; } - public string? Username { get; set; } - public string? Password { get; set; } - public SslMode? SslMode { get; set; } - public string? ApplicationName { get; set; } - public int? TimeoutSeconds { get; set; } - public Dictionary Properties { get; } = new(StringComparer.OrdinalIgnoreCase); - - public void AddHost(string host, ushort? port) - { - if (string.IsNullOrWhiteSpace(host)) return; - Hosts.Add(new HostEndpoint { Host = host.Trim(), Port = port }); - } - - public ConnectionDescriptor Build() - { - return new ConnectionDescriptor - { - Hosts = Hosts, - Database = Database, - Username = Username, - Password = Password, - SslMode = SslMode, - ApplicationName = ApplicationName, - TimeoutSeconds = TimeoutSeconds, - Properties = Properties - }; - } - } } diff --git a/pgLabII.PgUtils/ConnectionStrings/Pq/PqConnectionStringParser.cs b/pgLabII.PgUtils/ConnectionStrings/Pq/PqConnectionStringParser.cs index cb83332..6842a19 100644 --- a/pgLabII.PgUtils/ConnectionStrings/Pq/PqConnectionStringParser.cs +++ b/pgLabII.PgUtils/ConnectionStrings/Pq/PqConnectionStringParser.cs @@ -48,7 +48,7 @@ public ref struct PqConnectionStringParser //service //target_session_attrs - public static IDictionary Parse(string input) + public static Result> Parse(string input) { return new PqConnectionStringParser( new PqConnectionStringTokenizer(input) @@ -63,12 +63,16 @@ public ref struct PqConnectionStringParser this._tokenizer = tokenizer; } - public IDictionary Parse() + public Result> Parse() { _result.Clear(); while (!_tokenizer.IsEof) - ParsePair(); + { + var result = ParsePair(); + if (result.IsFailed) + return result; + } return _result; } diff --git a/pgLabII.PgUtils/ConnectionStrings/Pq/PqConnectionStringTokenizer.cs b/pgLabII.PgUtils/ConnectionStrings/Pq/PqConnectionStringTokenizer.cs index 2d8ff34..c27a568 100644 --- a/pgLabII.PgUtils/ConnectionStrings/Pq/PqConnectionStringTokenizer.cs +++ b/pgLabII.PgUtils/ConnectionStrings/Pq/PqConnectionStringTokenizer.cs @@ -67,12 +67,6 @@ public class PqConnectionStringTokenizer : IPqConnectionStringTokenizer { while (position < input.Length && char.IsWhiteSpace(input[position])) position++; - // If a semicolon is encountered between pairs (which is not valid in libpq), - // treat as immediate EOF so parser stops and leaves trailing data unparsed. - if (position < input.Length && input[position] == ';') - { - position = input.Length; // force EOF - } } private string UnquotedString(bool forKeyword) diff --git a/pgLabII.PgUtils/ConnectionStrings/UrlCodec.cs b/pgLabII.PgUtils/ConnectionStrings/UrlCodec.cs index 3714d94..5fe972c 100644 --- a/pgLabII.PgUtils/ConnectionStrings/UrlCodec.cs +++ b/pgLabII.PgUtils/ConnectionStrings/UrlCodec.cs @@ -1,11 +1,6 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using System.Net; +using System.Globalization; using System.Text; using FluentResults; -using Npgsql; namespace pgLabII.PgUtils.ConnectionStrings; @@ -73,13 +68,12 @@ public sealed class UrlCodec : IConnectionStringCodec builder.Password = Uri.UnescapeDataString(up[1]); } - // Parse hosts (may be comma-separated) - foreach (var hostPart in SplitHosts(authority)) + // Parse hosts (maybe comma-separated) + foreach (string hostPart in CodecCommon.SplitHosts(authority)) { - if (string.IsNullOrWhiteSpace(hostPart)) continue; - ParseHostPort(hostPart, out var host, out ushort? port); + CodecCommon.ParseHostPort(hostPart, out string host, out ushort? port); if (!string.IsNullOrEmpty(host)) - builder.AddHost(host!, port); + builder.AddHost(host, port); } // Parse path (database) and query @@ -88,24 +82,25 @@ public sealed class UrlCodec : IConnectionStringCodec if (!string.IsNullOrEmpty(pathAndQuery)) { // pathAndQuery like /db?x=y - var qIdx = pathAndQuery.IndexOf('?'); - string path = qIdx >= 0 ? pathAndQuery.Substring(0, qIdx) : pathAndQuery; - query = qIdx >= 0 ? pathAndQuery.Substring(qIdx + 1) : string.Empty; + int qIdx = pathAndQuery.IndexOf('?'); + string path = qIdx >= 0 ? pathAndQuery[..qIdx] : pathAndQuery; + query = qIdx >= 0 ? pathAndQuery[(qIdx + 1)..] : string.Empty; if (path.Length > 0) { // strip leading '/' - if (path[0] == '/') path = path.Substring(1); + if (path[0] == '/') + path = path[1..]; if (path.Length > 0) database = Uri.UnescapeDataString(path); } } if (!string.IsNullOrEmpty(database)) builder.Database = database; - var queryDict = ParseQuery(query); + var queryDict = CodecCommon.ParseQuery(query); // Map known params if (queryDict.TryGetValue("sslmode", out var sslVal)) - builder.SslMode = ParseSslMode(sslVal); + builder.SslMode = CodecCommon.ParseSslModeLoose(sslVal); if (queryDict.TryGetValue("application_name", out var app)) builder.ApplicationName = app; if (queryDict.TryGetValue("connect_timeout", out var tout) && int.TryParse(tout, NumberStyles.Integer, CultureInfo.InvariantCulture, out var ts)) @@ -146,7 +141,7 @@ public sealed class UrlCodec : IConnectionStringCodec } // hosts - if (descriptor.Hosts != null && descriptor.Hosts.Count > 0) + if (descriptor.Hosts.Count > 0) { var hostParts = new List(descriptor.Hosts.Count); foreach (var h in descriptor.Hosts) @@ -170,7 +165,7 @@ public sealed class UrlCodec : IConnectionStringCodec // query var queryPairs = new List(); if (descriptor.SslMode.HasValue) - queryPairs.Add("sslmode=" + Uri.EscapeDataString(FormatSslMode(descriptor.SslMode.Value))); + queryPairs.Add("sslmode=" + Uri.EscapeDataString(CodecCommon.FormatSslModeUrlLike(descriptor.SslMode.Value))); if (!string.IsNullOrEmpty(descriptor.ApplicationName)) queryPairs.Add("application_name=" + Uri.EscapeDataString(descriptor.ApplicationName)); if (descriptor.TimeoutSeconds.HasValue) @@ -202,153 +197,4 @@ public sealed class UrlCodec : IConnectionStringCodec => key.Equals("sslmode", StringComparison.OrdinalIgnoreCase) || key.Equals("application_name", StringComparison.OrdinalIgnoreCase) || key.Equals("connect_timeout", StringComparison.OrdinalIgnoreCase); - - private static IEnumerable SplitHosts(string authority) - { - // authority may contain comma-separated hosts, each may be IPv6 [..] with optional :port - // We split on commas that are not inside brackets - var parts = new List(); - int depth = 0; - int start = 0; - for (int i = 0; i < authority.Length; i++) - { - char c = authority[i]; - if (c == '[') depth++; - else if (c == ']') depth = Math.Max(0, depth - 1); - else if (c == ',' && depth == 0) - { - parts.Add(authority.Substring(start, i - start)); - start = i + 1; - } - } - // last - if (start <= authority.Length) - parts.Add(authority.Substring(start)); - return parts.Select(p => p.Trim()).Where(p => p.Length > 0); - } - - private static void ParseHostPort(string hostPart, out string host, out ushort? port) - { - host = string.Empty; port = null; - if (string.IsNullOrWhiteSpace(hostPart)) return; - - if (hostPart[0] == '[') - { - // IPv6 literal [....]:port? - int end = hostPart.IndexOf(']'); - if (end < 0) - { - host = hostPart; // let it pass raw - return; - } - var h = hostPart.Substring(1, end - 1); - host = h; - if (end + 1 < hostPart.Length && hostPart[end + 1] == ':') - { - var ps = hostPart.Substring(end + 2); - if (ushort.TryParse(ps, NumberStyles.Integer, CultureInfo.InvariantCulture, out var up)) - port = up; - } - return; - } - // non-IPv6, split last ':' as port if numeric - int colon = hostPart.LastIndexOf(':'); - if (colon > 0 && colon < hostPart.Length - 1) - { - var ps = hostPart.Substring(colon + 1); - if (ushort.TryParse(ps, NumberStyles.Integer, CultureInfo.InvariantCulture, out var up)) - { - port = up; - host = hostPart.Substring(0, colon); - return; - } - } - host = hostPart; - } - - private static SslMode ParseSslMode(string s) - { - switch (s.Trim().ToLowerInvariant()) - { - case "disable": return SslMode.Disable; - case "allow": return SslMode.Allow; - case "prefer": return SslMode.Prefer; - case "require": return SslMode.Require; - case "verify-ca": - case "verifyca": return SslMode.VerifyCA; - case "verify-full": - case "verifyfull": return SslMode.VerifyFull; - default: throw new ArgumentException($"Not a valid sslmode: {s}"); - } - } - - private static string FormatSslMode(SslMode mode) - { - return mode switch - { - SslMode.Disable => "disable", - SslMode.Allow => "allow", - SslMode.Prefer => "prefer", - SslMode.Require => "require", - SslMode.VerifyCA => "verify-ca", - SslMode.VerifyFull => "verify-full", - _ => "prefer" - }; - } - - private sealed class ConnectionDescriptorBuilder - { - public List Hosts { get; } = new(); - public string? Database { get; set; } - public string? Username { get; set; } - public string? Password { get; set; } - public SslMode? SslMode { get; set; } - public string? ApplicationName { get; set; } - public int? TimeoutSeconds { get; set; } - public Dictionary Properties { get; } = new(StringComparer.OrdinalIgnoreCase); - - public void AddHost(string host, ushort? port) - { - if (string.IsNullOrWhiteSpace(host)) return; - Hosts.Add(new HostEndpoint { Host = host.Trim(), Port = port }); - } - - public ConnectionDescriptor Build() - { - return new ConnectionDescriptor - { - Hosts = Hosts, - Database = Database, - Username = Username, - Password = Password, - SslMode = SslMode, - ApplicationName = ApplicationName, - TimeoutSeconds = TimeoutSeconds, - Properties = Properties - }; - } - } - - private static Dictionary ParseQuery(string query) - { - var dict = new Dictionary(StringComparer.OrdinalIgnoreCase); - if (string.IsNullOrEmpty(query)) return dict; - var pairs = query.Split('&', StringSplitOptions.RemoveEmptyEntries); - foreach (var pair in pairs) - { - var idx = pair.IndexOf('='); - if (idx < 0) - { - var k = Uri.UnescapeDataString(pair); - dict[k] = string.Empty; - } - else - { - var k = Uri.UnescapeDataString(pair.Substring(0, idx)); - var v = Uri.UnescapeDataString(pair.Substring(idx + 1)); - dict[k] = v; - } - } - return dict; - } } diff --git a/pgLabII.Tests/Views/EditServerConfigurationWindowTests.cs b/pgLabII.Tests/Views/EditServerConfigurationWindowTests.cs index 40a9279..ee1552a 100644 --- a/pgLabII.Tests/Views/EditServerConfigurationWindowTests.cs +++ b/pgLabII.Tests/Views/EditServerConfigurationWindowTests.cs @@ -1,9 +1,5 @@ using System; -using System.Linq; -using Avalonia; -using Avalonia.Controls; using Avalonia.Headless.XUnit; -using Avalonia.Threading; using pgLabII.Model; using pgLabII.ViewModels; using pgLabII.Views; @@ -52,10 +48,10 @@ public class EditServerConfigurationWindowTests { var vm = new EditServerConfigurationViewModel(new ServerConfiguration()); - // A semicolon-separated string that could be auto-detected as Npgsql - vm.InputConnectionString = "Host=server;Username=bob;Password=secret;Database=db1;SSL Mode=Require"; + // Use a string with quoted values that libpq would struggle with due to incorrect quoting + vm.InputConnectionString = "Host=\"server with spaces\";Username=\"bob\";Password=\"secret\";Database=\"db1\""; - // Force interpret as libpq should fail to parse (libpq uses spaces) and keep defaults + // Force interpret as libpq should fail to parse (libpq expects single quotes, not double quotes for quoting) vm.ForcedFormat = EditServerConfigurationViewModel.ForcedFormatOption.Libpq; vm.ParseConnectionStringCommand.Execute().Subscribe(); @@ -65,7 +61,7 @@ public class EditServerConfigurationWindowTests // Now set to Auto and parse again -> should detect Npgsql and parse vm.ForcedFormat = EditServerConfigurationViewModel.ForcedFormatOption.Auto; vm.ParseConnectionStringCommand.Execute().Subscribe(); - Assert.Equal("server", vm.Configuration.Host); + Assert.Equal("server with spaces", vm.Configuration.Host); Assert.Equal("db1", vm.Configuration.InitialDatabase); Assert.Equal("bob", vm.Configuration.User.Name); } diff --git a/pgLabII.sln.DotSettings b/pgLabII.sln.DotSettings new file mode 100644 index 0000000..1cfe955 --- /dev/null +++ b/pgLabII.sln.DotSettings @@ -0,0 +1,4 @@ + + True + True + True \ No newline at end of file