Skip to content

Commit b35d76e

Browse files
vkuttypclaude
andcommitted
fix(sql): pin culture-sensitive ToString to InvariantCulture in parameter encoding
Two latent bugs that surfaced today on a Mac with ar-SA culture (UmAlQura / Hijri calendar) running CosmoMailAdmin against a prod Postgres: 1. PgMessage.cs line 411: SqlValueKind.Date encoded via `DateValue.ToString("yyyy-MM-dd HH:mm:ss.ffffff")` without an explicit culture. The format string only specifies *fields*; the *calendar* comes from CurrentThread.CurrentCulture. Under ar-SA, year 2026 formats as Hijri 1447, so a row inserted today gets stored with `created_at = 1447-11-10`, ~579 years in the past. Real example: the chalyar.com mail user created via the wizard on 2026-04-27 has `created_at = 1447-11-10 00:32:09 UTC`. 2. SqlValue.cs AsString() / ToString() called .ToString() on numeric values (Int8..Int64, Float, Double, Decimal) without a culture. Under de-DE/fr-FR/etc., decimal separators flip to ',' which Postgres rejects as malformed numeric. Hadn't bitten in production because the deployed servers are Linux with en-US default, but any local-Mac admin or German-locale CI run would hit it. Fix: pass `CultureInfo.InvariantCulture` everywhere a numeric or custom date format is rendered for SQL parameter encoding. The DateTime "O" / "o" specifier is documented as culture-independent + Gregorian-fixed, so existing AsString uses of "O" stay as-is. Tests pin the behavior under deliberately wrong cultures: - AsString_Date_UsesGregorianRegardlessOfCulture (ar-SA) - AsString_Decimal_UsesPeriodSeparatorRegardlessOfCulture (de-DE) - AsString_Double_UsesPeriodSeparatorRegardlessOfCulture (de-DE) - AsString_Int64_StableAcrossCultures (ar-SA) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2d90d3e commit b35d76e

3 files changed

Lines changed: 90 additions & 15 deletions

File tree

src/CosmoSQLClient.Core/SqlValue.cs

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
using System.Globalization;
2+
13
namespace CosmoSQLClient.Core;
24

35
/// <summary>Discriminator for the <see cref="SqlValue"/> tagged union.</summary>
@@ -132,13 +134,18 @@ public enum SqlValueKind
132134
SqlValueKind.Text => TextValue,
133135
SqlValueKind.EmptyString => string.Empty,
134136
SqlValueKind.Bool => _bits != 0 ? "true" : "false",
135-
SqlValueKind.Int8 => Int8Value.ToString(),
136-
SqlValueKind.Int16 => Int16Value.ToString(),
137-
SqlValueKind.Int32 => Int32Value.ToString(),
138-
SqlValueKind.Int64 => Int64Value.ToString(),
139-
SqlValueKind.Float => FloatValue.ToString(),
140-
SqlValueKind.Double => DoubleValue.ToString(),
141-
SqlValueKind.Decimal => DecimalValue.ToString(),
137+
// InvariantCulture on every numeric ToString — without it, decimal
138+
// separators flip to ',' on de-DE/fr-FR/etc. and Postgres rejects
139+
// "1234,5" as malformed numeric. DateTime "O" / "o" is hardcoded
140+
// invariant + Gregorian per .NET docs, so it doesn't need an
141+
// explicit culture argument, but every other ToString does.
142+
SqlValueKind.Int8 => Int8Value.ToString(CultureInfo.InvariantCulture),
143+
SqlValueKind.Int16 => Int16Value.ToString(CultureInfo.InvariantCulture),
144+
SqlValueKind.Int32 => Int32Value.ToString(CultureInfo.InvariantCulture),
145+
SqlValueKind.Int64 => Int64Value.ToString(CultureInfo.InvariantCulture),
146+
SqlValueKind.Float => FloatValue.ToString("R", CultureInfo.InvariantCulture),
147+
SqlValueKind.Double => DoubleValue.ToString("R", CultureInfo.InvariantCulture),
148+
SqlValueKind.Decimal => DecimalValue.ToString(CultureInfo.InvariantCulture),
142149
SqlValueKind.Uuid => GuidValue.ToString(),
143150
SqlValueKind.Date => DateValue.ToString("O"),
144151
SqlValueKind.DateTimeOffset => DateTimeOffsetValue.ToString("O"),
@@ -236,13 +243,13 @@ SqlValueKind.Int16 or SqlValueKind.Int32 or
236243
SqlValueKind.Null => "NULL",
237244
SqlValueKind.EmptyString => string.Empty,
238245
SqlValueKind.Bool => _bits != 0 ? "True" : "False",
239-
SqlValueKind.Int8 => Int8Value.ToString(),
240-
SqlValueKind.Int16 => Int16Value.ToString(),
241-
SqlValueKind.Int32 => Int32Value.ToString(),
242-
SqlValueKind.Int64 => Int64Value.ToString(),
243-
SqlValueKind.Float => FloatValue.ToString(),
244-
SqlValueKind.Double => DoubleValue.ToString(),
245-
SqlValueKind.Decimal => DecimalValue.ToString(),
246+
SqlValueKind.Int8 => Int8Value.ToString(CultureInfo.InvariantCulture),
247+
SqlValueKind.Int16 => Int16Value.ToString(CultureInfo.InvariantCulture),
248+
SqlValueKind.Int32 => Int32Value.ToString(CultureInfo.InvariantCulture),
249+
SqlValueKind.Int64 => Int64Value.ToString(CultureInfo.InvariantCulture),
250+
SqlValueKind.Float => FloatValue.ToString("R", CultureInfo.InvariantCulture),
251+
SqlValueKind.Double => DoubleValue.ToString("R", CultureInfo.InvariantCulture),
252+
SqlValueKind.Decimal => DecimalValue.ToString(CultureInfo.InvariantCulture),
246253
SqlValueKind.Text => TextValue,
247254
SqlValueKind.Bytes => System.Convert.ToBase64String(BytesValue),
248255
SqlValueKind.Uuid => GuidValue.ToString(),

src/CosmoSQLClient.Postgres/Proto/PgMessage.cs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System.Buffers.Binary;
2+
using System.Globalization;
23
using System.Net;
34
using System.Security.Cryptography;
45
using System.Text;
@@ -408,7 +409,13 @@ private static byte[] EncodeValue(SqlValue v, bool binary)
408409
SqlValueKind.Null => Array.Empty<byte>(),
409410
SqlValueKind.Bool => Encoding.UTF8.GetBytes(v.BoolValue ? "t" : "f"),
410411
SqlValueKind.Bytes => Encoding.UTF8.GetBytes("\\x" + BitConverter.ToString(v.BytesValue).Replace("-", "").ToLowerInvariant()),
411-
SqlValueKind.Date => Encoding.UTF8.GetBytes(v.DateValue.ToString("yyyy-MM-dd HH:mm:ss.ffffff")),
412+
// InvariantCulture is critical: a custom format like "yyyy-MM-dd"
413+
// emits the YEAR component using the current thread's calendar.
414+
// On ar-SA Macs the calendar is UmAlQura (Hijri), so 2026-04-27
415+
// serializes as "1447-11-10" — Postgres parses it as Gregorian and
416+
// stores a date 579 years in the past. See the chalyar.com user
417+
// incident on 2026-04-27 for an example.
418+
SqlValueKind.Date => Encoding.UTF8.GetBytes(v.DateValue.ToString("yyyy-MM-dd HH:mm:ss.ffffff", CultureInfo.InvariantCulture)),
412419
SqlValueKind.Uuid => Encoding.UTF8.GetBytes(v.GuidValue.ToString("D")),
413420
_ => Encoding.UTF8.GetBytes(v.AsString() ?? string.Empty),
414421
};

tests/CosmoSQLClient.Core.Tests/SqlValueTests.cs

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,4 +96,65 @@ public void NullInstance_IsNull_IsTrue()
9696
{
9797
Assert.True(SqlValue.Null_.IsNull);
9898
}
99+
100+
// ── Culture-leak regressions ──────────────────────────────────────────────
101+
// Without InvariantCulture, ToString() in a non-Gregorian / non-period-decimal
102+
// culture corrupts SQL parameter encoding. ar-SA flips the calendar (year
103+
// 2026 becomes 1447 Hijri); de-DE flips the decimal separator (1234.5
104+
// becomes 1234,5 which Postgres rejects as malformed numeric).
105+
106+
[Fact]
107+
public void AsString_Date_UsesGregorianRegardlessOfCulture()
108+
{
109+
var prev = System.Threading.Thread.CurrentThread.CurrentCulture;
110+
try
111+
{
112+
System.Threading.Thread.CurrentThread.CurrentCulture = new System.Globalization.CultureInfo("ar-SA");
113+
var dt = new DateTime(2026, 4, 27, 10, 0, 0, DateTimeKind.Utc);
114+
var v = SqlValue.From(dt);
115+
var s = v.AsString();
116+
Assert.NotNull(s);
117+
Assert.StartsWith("2026-04-27", s); // not "1447-..."
118+
}
119+
finally { System.Threading.Thread.CurrentThread.CurrentCulture = prev; }
120+
}
121+
122+
[Fact]
123+
public void AsString_Decimal_UsesPeriodSeparatorRegardlessOfCulture()
124+
{
125+
var prev = System.Threading.Thread.CurrentThread.CurrentCulture;
126+
try
127+
{
128+
System.Threading.Thread.CurrentThread.CurrentCulture = new System.Globalization.CultureInfo("de-DE");
129+
var v = SqlValue.From(1234.5m);
130+
Assert.Equal("1234.5", v.AsString());
131+
}
132+
finally { System.Threading.Thread.CurrentThread.CurrentCulture = prev; }
133+
}
134+
135+
[Fact]
136+
public void AsString_Double_UsesPeriodSeparatorRegardlessOfCulture()
137+
{
138+
var prev = System.Threading.Thread.CurrentThread.CurrentCulture;
139+
try
140+
{
141+
System.Threading.Thread.CurrentThread.CurrentCulture = new System.Globalization.CultureInfo("de-DE");
142+
var v = SqlValue.From(1234.5);
143+
Assert.Contains(".", v.AsString()!);
144+
Assert.DoesNotContain(",", v.AsString()!);
145+
}
146+
finally { System.Threading.Thread.CurrentThread.CurrentCulture = prev; }
147+
}
148+
149+
[Fact]
150+
public void AsString_Int64_StableAcrossCultures()
151+
{
152+
var prev = System.Threading.Thread.CurrentThread.CurrentCulture;
153+
try
154+
{
155+
System.Threading.Thread.CurrentThread.CurrentCulture = new System.Globalization.CultureInfo("ar-SA");
156+
Assert.Equal("1234567", SqlValue.From(1234567L).AsString());
157+
}
158+
finally { System.Threading.Thread.CurrentThread.CurrentCulture = prev; }
159+
}
99160
}

0 commit comments

Comments
 (0)