From 4c7a6c26662e4f9551f04d2d55d4e8636f4c326f Mon Sep 17 00:00:00 2001 From: eelke Date: Tue, 2 Sep 2025 18:50:23 +0200 Subject: [PATCH] Improve NpgsqlCodec whitespace and quotation rules --- .../ConnectionStrings/NpgsqlCodecTests.cs | 18 +++---- .../ConnectionStrings/NpgsqlCodec.cs | 47 ++++++++++++++----- 2 files changed, 44 insertions(+), 21 deletions(-) diff --git a/pgLabII.PgUtils.Tests/ConnectionStrings/NpgsqlCodecTests.cs b/pgLabII.PgUtils.Tests/ConnectionStrings/NpgsqlCodecTests.cs index 5f29a89..11fa216 100644 --- a/pgLabII.PgUtils.Tests/ConnectionStrings/NpgsqlCodecTests.cs +++ b/pgLabII.PgUtils.Tests/ConnectionStrings/NpgsqlCodecTests.cs @@ -46,7 +46,7 @@ public class NpgsqlCodecTests { Hosts = new [] { new HostEndpoint{ Host = "db.example.com", Port = 5432 } }, Database = "prod db", - Username = "bob", + Username = "bob ", Password = "p;ss\"word", SslMode = SslMode.VerifyFull, ApplicationName = "cli app", @@ -58,11 +58,12 @@ public class NpgsqlCodecTests var s = res.Value; Assert.Contains("Host=db.example.com", s); Assert.Contains("Port=5432", s); - Assert.Contains("Database=\"prod db\"", s); - Assert.Contains("Username=bob", s); - Assert.Contains("Password=\"p;ss\"\"word\"", s); + Assert.Contains("Database=prod db", s); + Assert.Contains("Username='bob '", s); + // Contains double-quote, no single-quote -> prefer single-quoted per DbConnectionStringBuilder-like behavior + Assert.Contains("Password='p;ss" + '"' + "word'", s); Assert.Contains("SSL Mode=VerifyFull", s); - Assert.Contains("Application Name=\"cli app\"", s); + Assert.Contains("Application Name=cli app", s); Assert.Contains("Timeout=9", s); Assert.Contains("Search Path=public", s); } @@ -77,11 +78,12 @@ public class NpgsqlCodecTests var formatted = codec.TryFormat(parsed.Value); Assert.True(formatted.IsSuccess); var s = formatted.Value; - Assert.Contains("Host=\"my host\"", s); + Assert.Contains("Host=my host", s); Assert.Contains("Database=postgres", s); Assert.Contains("Username=me", s); - Assert.Contains("Password=\"with;quote\"\"\"", s); - Assert.Contains("Application Name=\"my app\"", s); + // Contains double-quote, no single-quote -> prefer single-quoted per DbConnectionStringBuilder-like behavior; parsed value contains one double-quote + Assert.Contains("Password='with;quote" + '"' + "'", s); + Assert.Contains("Application Name=my app", s); Assert.Contains("SSL Mode=Prefer", s); } } diff --git a/pgLabII.PgUtils/ConnectionStrings/NpgsqlCodec.cs b/pgLabII.PgUtils/ConnectionStrings/NpgsqlCodec.cs index 4666c72..79e63d0 100644 --- a/pgLabII.PgUtils/ConnectionStrings/NpgsqlCodec.cs +++ b/pgLabII.PgUtils/ConnectionStrings/NpgsqlCodec.cs @@ -236,24 +236,44 @@ public sealed class NpgsqlCodec : IConnectionStringCodec private static string FormatPair(string key, string? value) { value ??= string.Empty; - var needsQuotes = NeedsQuoting(value); - if (!needsQuotes) return key + "=" + value; - return key + "=\"" + EscapeQuoted(value) + "\""; + // Decide if we need quoting following DbConnectionStringBuilder rules: + // - Empty => quote + // - Leading or trailing whitespace => quote + // - Contains ';' or '=' => quote + // - Otherwise, no quotes, even if it contains internal whitespace + if (!NeedsQuoting(value)) + return key + "=" + value; + + // Choose single or double quotes. Prefer the one not present in the value; if both present, pick double and escape. + bool hasSingle = value.Contains('\''); + bool hasDouble = value.Contains('"'); + if (!hasSingle) + { + // Use single quotes, escape single quotes by doubling when needed (not needed here since !hasSingle) + return key + "='" + value.Replace("'", "''") + "'"; + } + if (!hasDouble) + { + // Use double quotes + return key + "=\"" + value.Replace("\"", "\"\"") + "\""; + } + // Value contains both quote types: default to double quotes and escape doubles by doubling + return key + "=\"" + value.Replace("\"", "\"\"") + "\""; } 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; - } + // Leading or trailing whitespace requires quoting + if (char.IsWhiteSpace(value[0]) || char.IsWhiteSpace(value[^1])) return true; + // Special characters + if (value.IndexOf(';') >= 0 || value.IndexOf('=') >= 0) return true; return false; } private static string EscapeQuoted(string value) { - // Double the quotes per standard DbConnectionString rules + // Retained for compatibility, but not used directly; prefer inlined replacements in FormatPair return value.Replace("\"", "\"\""); } @@ -279,19 +299,19 @@ public sealed class NpgsqlCodec : IConnectionStringCodec // read value string value; - if (i < input.Length && input[i] == '"') + if (i < input.Length && (input[i] == '"' || input[i] == '\'')) { - i++; // skip opening quote + char quote = input[i++]; // opening quote (' or ") var sb = new StringBuilder(); while (i < input.Length) { char c = input[i++]; - if (c == '"') + if (c == quote) { - if (i < input.Length && input[i] == '"') + if (i < input.Length && input[i] == quote) { // doubled quote -> literal quote - sb.Append('"'); + sb.Append(quote); i++; continue; } @@ -311,6 +331,7 @@ public sealed class NpgsqlCodec : IConnectionStringCodec { int valStart = i; while (i < input.Length && input[i] != ';') i++; + // Unquoted value: per DbConnectionStringBuilder, leading/trailing whitespace is ignored value = input.Substring(valStart, i - valStart).Trim(); }