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
@@ -165,13 +165,31 @@ public class OracleDialectTests
=> Should.Throw<ArgumentException>(() => new OracleDialect().QuoteIdentifier(ident));
[Fact]
public void QuoteIdentifier_acceptsExactly128Chars_andRejects129()
public void QuoteIdentifier_acceptsExactly128Bytes_andRejects129()
{
var d = new OracleDialect();
d.QuoteIdentifier(new string('a', 128)).ShouldBe("\"" + new string('a', 128) + "\"");
Should.Throw<ArgumentException>(() => d.QuoteIdentifier(new string('a', 129)));
}
/// <summary>
/// Oracle 12.2's 128 is a <b>byte</b> ceiling in the database character set (UTF-8 on any modern
/// estate), so a 65-character Cyrillic name is 130 bytes and the server rejects it with
/// <c>ORA-00972</c>. Measuring UTF-16 code units here would pass it through to fail at the server
/// instead — for a reason this dialect could have named.
/// </summary>
[Fact]
public void QuoteIdentifier_measuresTheCeilingInUtf8Bytes_notCharacters()
{
var d = new OracleDialect();
// 64 Cyrillic characters = 128 UTF-8 bytes — exactly at the limit.
d.QuoteIdentifier(new string('б', 64)).ShouldBe("\"" + new string('б', 64) + "\"");
// 65 characters = 130 bytes. A character-counting ceiling waves this through.
Should.Throw<ArgumentException>(() => d.QuoteIdentifier(new string('б', 65)));
}
// ---- liveness + row limit ----
[Fact]
@@ -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()
{
@@ -117,6 +117,19 @@ public class SqlServerDialectTests
Should.Throw<ArgumentException>(() => d.QuoteIdentifier(new string('a', 129)));
}
/// <summary>
/// The falsifiability control for the Postgres/Oracle byte ceilings: T-SQL's <c>sysname</c> is
/// <c>nvarchar(128)</c>, a genuine <b>128-character</b> (UTF-16 code unit) limit, so a 128-character
/// Cyrillic name — 256 UTF-8 bytes — is legal here and must still be accepted. A byte rule applied
/// uniformly across the three dialects would reject it, and only this test would say so.
/// </summary>
[Fact]
public void QuoteIdentifier_countsCharacters_notBytes_becauseSysnameIsNvarchar128()
{
var ident = new string('б', 128); // 128 chars, 256 UTF-8 bytes
new SqlServerDialect().QuoteIdentifier(ident).ShouldBe("[" + ident + "]");
}
[Fact]
public void QuoteIdentifier_neutralisesAClassicInjectionPayload()
{