diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/OracleDialect.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/OracleDialect.cs index a6fd198e..8d12a01c 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/OracleDialect.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/OracleDialect.cs @@ -1,5 +1,6 @@ using System.Data.Common; using System.Globalization; +using System.Text; using Oracle.ManagedDataAccess.Client; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; @@ -24,6 +25,9 @@ public sealed class OracleDialect : ISqlDialect /// /// Oracle 12.2+'s identifier ceiling. Enforced because a longer string cannot name a real catalog /// object — it is either a config mistake or padding, and rejecting it keeps the quoted output bounded. + /// The unit is BYTES in the database character set, not characters — see + /// . Practically that means UTF-8 (AL32UTF8) on any modern estate, + /// so a 65-character Cyrillic name is already over the limit. /// internal const int MaxIdentifierLength = 128; @@ -187,8 +191,14 @@ public sealed class OracleDialect : ISqlDialect /// (ALL_TAB_COLUMNS.column_name, i.e. what the server actually stored) before anything reaches /// this method — an operator who authored speed against an Oracle SPEED column resolves /// to SPEED at Initialize, and it is SPEED that gets quoted. + /// The length ceiling is measured in UTF-8 bytes, not characters. Oracle's 128 bounds the + /// name in the database character set (AL32UTF8 on any modern estate), so counting + /// UTF-16 code units would accept a 65-character Cyrillic name — 130 bytes — and let it fail at the + /// server with ORA-00972 rather than here, where the message can name the tag. + /// SqlServerDialect deliberately does not do this: T-SQL's sysname is + /// nvarchar(128), a genuine 128-character limit. /// Rejects (all as ): null / empty / all-whitespace; longer - /// than ; any control character (which covers embedded NUL, and the + /// than bytes; any control character (which covers embedded NUL, and the /// C0/C1 ranges that can truncate or corrupt a logged/audited statement); and any Unicode /// character — bidi overrides, zero-width spaces, soft hyphen, /// BOM. Those last cannot escape the quotes, but they spoof the rendered text of a log line or @@ -209,9 +219,13 @@ public sealed class OracleDialect : ISqlDialect throw new ArgumentException( "A SQL identifier must be a non-empty, non-whitespace name.", nameof(ident)); - if (ident.Length > MaxIdentifierLength) + // Byte count, not ident.Length: Oracle's 128 is measured in the database character set, so a + // 65-character Cyrillic name is 130 UTF-8 bytes and the server answers ORA-00972. Counting UTF-16 + // code units would send it anyway, failing at the server for a reason this dialect could have named. + var byteLength = Encoding.UTF8.GetByteCount(ident); + if (byteLength > MaxIdentifierLength) throw new ArgumentException( - $"An Oracle identifier may not exceed {MaxIdentifierLength} characters (got {ident.Length}).", + $"An Oracle identifier may not exceed {MaxIdentifierLength} bytes (got {byteLength}).", nameof(ident)); foreach (var ch in ident) diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/PostgresDialect.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/PostgresDialect.cs index eaa5a9b6..d2221f3b 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/PostgresDialect.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/PostgresDialect.cs @@ -1,5 +1,6 @@ using System.Data.Common; using System.Globalization; +using System.Text; using Npgsql; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; @@ -20,6 +21,9 @@ public sealed class PostgresDialect : ISqlDialect /// PostgreSQL's identifier ceiling — NAMEDATALEN - 1, 63 by default. Enforced because the /// server silently truncates anything longer, so a 70-character authored name would resolve to /// a different object than it spells; rejecting it surfaces the config mistake instead. + /// The unit is BYTES in the database encoding, not characters — see + /// . Practically that means UTF-8 on any modern estate, so a 32-character + /// Cyrillic name is already over the limit. /// internal const int MaxIdentifierLength = 63; @@ -149,12 +153,19 @@ public sealed class PostgresDialect : ISqlDialect /// Rejects (all as , mirroring /// SqlServerDialect.QuoteIdentifier so a test written against one dialect fails the same way /// against the other): null / empty / all-whitespace; longer than - /// ; any control character (which covers embedded NUL and the C0/C1 + /// bytes; any control character (which covers embedded NUL and the C0/C1 /// ranges that can truncate or corrupt a logged/audited statement); and any Unicode /// character — bidi overrides, zero-width spaces, soft hyphen, /// BOM. Those last cannot escape the quotes, but they spoof the rendered text of a log line /// or an AdminUI label while comparing byte-different from the real catalog name (the Trojan-Source /// class, CVE-2021-42574). + /// The length ceiling is measured in UTF-8 bytes, not characters — NAMEDATALEN + /// bounds the stored name in the database encoding, and every modern estate is UTF-8. Counting UTF-16 + /// code units (ident.Length) would accept a 32-character Cyrillic name, which is 64 bytes and + /// is therefore truncated server-side to a name that denotes a different object — precisely + /// the silent-truncation failure this rule exists to prevent. SqlServerDialect deliberately does + /// not do this: T-SQL's sysname is nvarchar(128), a genuine 128-character limit, + /// so counting characters is correct there and counting bytes would reject legal names. /// NUL deserves its own note here: Postgres rejects a NUL byte anywhere in a query string /// outright, so passing one through would fail the whole statement rather than just the one /// identifier. @@ -175,9 +186,13 @@ public sealed class PostgresDialect : ISqlDialect throw new ArgumentException( "A SQL identifier must be a non-empty, non-whitespace name.", nameof(ident)); - if (ident.Length > MaxIdentifierLength) + // Byte count, not ident.Length: NAMEDATALEN bounds the stored name in the database encoding, so a + // 32-character Cyrillic name is 64 UTF-8 bytes and the server truncates it — the exact silent + // wrong-object outcome this check exists to prevent, and one a UTF-16 code-unit count waves through. + var byteLength = Encoding.UTF8.GetByteCount(ident); + if (byteLength > MaxIdentifierLength) throw new ArgumentException( - $"A PostgreSQL identifier may not exceed {MaxIdentifierLength} characters (got {ident.Length}).", + $"A PostgreSQL identifier may not exceed {MaxIdentifierLength} bytes (got {byteLength}).", nameof(ident)); foreach (var ch in ident) diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/SqlServerDialect.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/SqlServerDialect.cs index aafce4e7..b1dd602f 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/SqlServerDialect.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/SqlServerDialect.cs @@ -18,6 +18,10 @@ public sealed class SqlServerDialect : ISqlDialect /// T-SQL's regular-identifier ceiling (sysname = nvarchar(128)). Enforced because a /// longer string cannot name a real catalog object — it is either a config mistake or padding, and /// rejecting it keeps the quoted output bounded. + /// The unit here is genuinely CHARACTERS (UTF-16 code units, which is what + /// nvarchar(128) counts), deliberately unlike PostgresDialect and OracleDialect, + /// whose ceilings are byte limits in the database encoding. Measuring bytes here would reject a legal + /// 128-character non-ASCII name. /// internal const int MaxIdentifierLength = 128; diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/OracleDialectTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/OracleDialectTests.cs index e8b0c375..1fbe2bfd 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/OracleDialectTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/OracleDialectTests.cs @@ -165,13 +165,31 @@ public class OracleDialectTests => Should.Throw(() => 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(() => d.QuoteIdentifier(new string('a', 129))); } + /// + /// Oracle 12.2's 128 is a byte 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 + /// ORA-00972. Measuring UTF-16 code units here would pass it through to fail at the server + /// instead — for a reason this dialect could have named. + /// + [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(() => d.QuoteIdentifier(new string('б', 65))); + } + // ---- liveness + row limit ---- [Fact] diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/PostgresDialectTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/PostgresDialectTests.cs index 0ca35ec1..d68f0594 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/PostgresDialectTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/PostgresDialectTests.cs @@ -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(() => d.QuoteIdentifier(new string('a', 64))); } + /// + /// NAMEDATALEN - 1 is 63 bytes in the database encoding, 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. + /// + [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(() => d.QuoteIdentifier(new string('б', 32))); + } + + /// A non-BMP character is 4 UTF-8 bytes (and 2 UTF-16 code units) — 16 of them fill the limit. + [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( + () => d.QuoteIdentifier(string.Concat(Enumerable.Repeat(a, 16)))); // 64 bytes + } + [Fact] public void QuoteIdentifier_neutralisesAClassicInjectionPayload() { diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlServerDialectTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlServerDialectTests.cs index e10995bc..8545d423 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlServerDialectTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlServerDialectTests.cs @@ -117,6 +117,19 @@ public class SqlServerDialectTests Should.Throw(() => d.QuoteIdentifier(new string('a', 129))); } + /// + /// The falsifiability control for the Postgres/Oracle byte ceilings: T-SQL's sysname is + /// nvarchar(128), a genuine 128-character (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. + /// + [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() {