fix(sql-dialect): enforce Postgres/Oracle identifier ceilings in UTF-8 bytes (Driver.Sql.Dialect-001)

NAMEDATALEN-1 (63) and Oracle 12.2's 128 are byte limits in the database
encoding, not character counts, so a 32-character Cyrillic name passed
ident.Length and was then silently truncated (Postgres) or rejected by the
server (Oracle). Both dialects now measure Encoding.UTF8.GetByteCount and say
'bytes' in the rejection. SqlServerDialect deliberately keeps ident.Length —
sysname is nvarchar(128), a real character limit — pinned by a control test.
This commit is contained in:
Joseph Doherty
2026-08-10 07:58:04 -04:00
parent 94b8633e68
commit 74594e6083
6 changed files with 102 additions and 8 deletions
@@ -69,13 +69,43 @@ public class PostgresDialectTests
// NAMEDATALEN - 1: a longer string cannot name a real Postgres object (the server truncates it), so it
// is a config mistake rather than something to quote and send.
[Fact]
public void QuoteIdentifier_acceptsExactly63Chars_andRejects64()
public void QuoteIdentifier_acceptsExactly63Bytes_andRejects64()
{
var d = new PostgresDialect();
d.QuoteIdentifier(new string('a', 63)).ShouldBe("\"" + new string('a', 63) + "\"");
Should.Throw<ArgumentException>(() => d.QuoteIdentifier(new string('a', 64)));
}
/// <summary>
/// <c>NAMEDATALEN - 1</c> is 63 <b>bytes in the database encoding</b>, not 63 characters — so a
/// 32-character Cyrillic name is 64 UTF-8 bytes and the server truncates it to something that names a
/// different object. A ceiling measured in UTF-16 code units lets that name straight through, which
/// is precisely the silent-truncation failure this rule documents itself as preventing.
/// </summary>
[Fact]
public void QuoteIdentifier_measuresTheCeilingInUtf8Bytes_notCharacters()
{
var d = new PostgresDialect();
// 31 Cyrillic characters = 62 UTF-8 bytes — inside the limit, and well inside a character count.
d.QuoteIdentifier(new string('б', 31)).ShouldBe("\"" + new string('б', 31) + "\"");
// 32 characters = 64 bytes. Under a character-counting ceiling this passes; the server truncates it.
Should.Throw<ArgumentException>(() => d.QuoteIdentifier(new string('б', 32)));
}
/// <summary>A non-BMP character is 4 UTF-8 bytes (and 2 UTF-16 code units) — 16 of them fill the limit.</summary>
[Fact]
public void QuoteIdentifier_countsASupplementaryCharacterAsItsFourUtf8Bytes()
{
var d = new PostgresDialect();
// U+1D400 MATHEMATICAL BOLD CAPITAL A, as a surrogate pair.
const string a = "\U0001D400";
d.QuoteIdentifier(string.Concat(Enumerable.Repeat(a, 15))).ShouldNotBeNullOrEmpty(); // 60 bytes
Should.Throw<ArgumentException>(
() => d.QuoteIdentifier(string.Concat(Enumerable.Repeat(a, 16)))); // 64 bytes
}
[Fact]
public void QuoteIdentifier_neutralisesAClassicInjectionPayload()
{