Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqliteDialect.cs
T
Joseph Doherty 4dc2ad8084 feat(sql): implement the design §8.1 catalog identifier-validation gate (#496)
Until now ISqlDialect.QuoteIdentifier was the SOLE defence for authored
table/column names: an identifier went from the TagConfig blob straight into a
command text, bracket-quoted. The residual risk was bounded — a hostile name is
quoted into one nonexistent object, the query fails after the connection opens,
and the tag Bad-codes — but "bounded" is not "filtered", and the design promised
a filter. Both ISqlDialect and SqlServerDialect carried a doc paragraph saying so.

SqlCatalogGate + SqlCatalogLoader now resolve every authored identifier against
the live catalog at Initialize, and REPLACE it with the catalog's own spelling.
The identifier text in an emitted poll query is therefore a string this driver
read back out of ListSchemas/ListTables/ListColumns — not operator input. Quoting
becomes the backstop it was documented to be.

Decisions worth knowing before touching this:

- Substitution, not just validation. Matching is exact-ordinal first, then a
  UNIQUE case-insensitive hit: SQL Server's default collation is CI so case
  variants have always worked, and rejecting them would break valid deployments.
  An ambiguous CI match under a case-sensitive collation is refused rather than
  guessed — picking one would publish another column's data under the operator's
  node, which is worse than rejecting the tag.

- Charset check BEFORE catalog lookup. Each identifier goes through
  QuoteIdentifier for its rejection rules first, so a name carrying a control or
  Unicode format character is rejected WITHOUT its value being echoed into a log
  line (Trojan-Source). A name that passes is safe to render, which is why
  catalog-miss messages do name it — an operator hunting a typo has to see what
  they wrote. Both halves are pinned by tests.

- A rejected tag keeps its node. It is dropped from the POLLED table but stays in
  the AUTHORED table, so it still materializes and reads BadNodeIdUnknown —
  §8.1's specified outcome. My first wiring dropped it from both, which deleted
  the node instead; an existing test (ReinitializeAsync_recoversFromFaulted)
  caught it. A status code can only be published by a node that exists, and a
  missing address-space entry is far harder to diagnose than a Bad quality.

- An unreadable catalog FAULTS Initialize; it does not reject every tag. That is
  the absence of evidence about the tags, not evidence against them — rejecting
  all of them would serve a confidently-empty address space and send the operator
  hunting typos that do not exist. Zero visible schemas is treated the same way,
  because that is exactly what a missing GRANT looks like. Faulting lands
  DriverInstanceActor in Reconnecting with its retry timer running.

- Bounded load: one schema list, one default-schema scalar, then one ListTables
  per distinct authored schema and one ListColumns per distinct authored relation
  — never a full catalog enumeration, and nothing after Initialize. Every
  authored name reaches the catalog queries as a bound @schema/@table parameter,
  so building the allow-list cannot itself be an injection vector.

- ISqlDialect gains DefaultSchemaSql ("SELECT SCHEMA_NAME()"). An unqualified
  `TagValues` must resolve in whatever schema the SERVER reports; hardcoding dbo
  would be a silent lie on any estate that maps service accounts to their own
  default schema. It is a query, not a constant, because the answer is
  per-connection.

- Accepted v1 limitation: a 3-part db.schema.table (or linked-server name)
  addresses a catalog this connection cannot enumerate, so it cannot be
  allow-listed and is rejected with a message pointing at the fix — expose the
  data through a view in the connected database.

VerifyLivenessAsync's wall-clock pattern is extracted to RunBoundedAsync and
reused, so the new I/O gets the same R2-01 / STAB-14 protection rather than a
second hand-rolled copy. (It also fixes a latent bug in the extracted code: the
OperationCanceledException arm detached the wrong task.)

Tests: 24 pure gate tests + 9 end-to-end driver tests against the real SQLite
catalog + 3 new live tests against the real SQL Server on 10.100.0.35 (21 in that
suite now pass, exercising the real SELECT SCHEMA_NAME() + INFORMATION_SCHEMA
path). Verified falsifiable: bypassing the gate turns exactly the three rejection
assertions red and leaves the rest green.

SqlInjectionRegressionTests is deliberately NOT rewritten to expect
BadNodeIdUnknown. It drives SqlPollReader directly, below the gate, and pins the
quoting backstop on its own — defence in depth is only worth the name if each
layer holds independently. Rewriting those assertions would delete the backstop's
only coverage and leave the gate a single point of failure. Its scope note, which
said the gate does not exist, is updated to say why it stays where it is.
2026-07-25 16:07:53 -04:00

161 lines
8.9 KiB
C#

using System.Data.Common;
using Microsoft.Data.Sqlite;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests;
/// <summary>
/// A <b>test-only</b> <see cref="ISqlDialect"/> over SQLite, so the poll reader and the schema browser can
/// be exercised against a real <see cref="DbConnection"/> — real parameter binding, real type coercion,
/// real NULLs — with no SQL Server and no network. <b>No product project references SQLite.</b>
/// <para>It is also the seam's falsifiability control: it is the only non-SQL-Server
/// <see cref="ISqlDialect"/> in the tree, so it is what proves the planner emits <em>dialect</em> SQL
/// rather than T-SQL with a quoting function attached. A SQLite statement built with T-SQL's
/// <c>TOP 1</c> does not parse, and the fixture tests fail loudly if that regresses.</para>
/// <para><b>Kept public and self-contained on purpose:</b> <c>Driver.Sql.Browser.Tests</c> links this file
/// (<c>&lt;Compile Include="..\..\Driver.Sql.Tests\SqliteDialect.cs" Link="SqliteDialect.cs" /&gt;</c>)
/// rather than referencing this project, so it must not depend on anything else defined here.</para>
/// </summary>
public sealed class SqliteDialect : ISqlDialect
{
/// <summary>
/// The only schema name SQLite's main database answers to. SQLite has no schema namespace — the
/// catalog queries accept this one value so the browser's schema level has something real to expand.
/// </summary>
public const string MainSchema = "main";
/// <summary>
/// Reports <see cref="SqlProvider.Odbc"/>.
/// <para><b>Why not a <c>Sqlite</c> member:</b> <see cref="SqlProvider"/> is a shipped product
/// contract, and a test-only dialect must not widen it — every consumer's exhaustive switch would gain
/// a case that can never occur in production.</para>
/// <para><b>Why <see cref="SqlProvider.Odbc"/> of the existing members:</b> it is the enum's generic,
/// not-yet-constructed member, so nothing branches on it today. Crucially it is <em>not</em>
/// <see cref="SqlProvider.SqlServer"/> — any future T-SQL-only code path that keys off
/// <see cref="Provider"/> stays switched off against this dialect and fails visibly here rather than
/// silently applying T-SQL rules to SQLite. Claiming SqlServer would make this dialect a rubber stamp
/// for exactly the assumptions it exists to catch.</para>
/// </summary>
public SqlProvider Provider => SqlProvider.Odbc;
/// <inheritdoc/>
public DbProviderFactory Factory => SqliteFactory.Instance;
/// <inheritdoc/>
public string LivenessSql => "SELECT 1";
/// <summary>
/// SQLite spells the row limit at the <em>end</em> of the statement, so the after-<c>SELECT</c>
/// position is empty — the mirror image of <c>SqlServerDialect</c>, which is the point of having both
/// ends on the seam.
/// </summary>
public string SingleRowLimitPrefix => string.Empty;
/// <summary>
/// <c>" LIMIT 1"</c>, leading space included so it appends straight onto the finished statement
/// (<c>… ORDER BY "sample_ts" DESC LIMIT 1</c>).
/// </summary>
public string SingleRowLimitSuffix => " LIMIT 1";
/// <summary>
/// SQLite has no schema namespace, so the schema level is the single literal
/// <see cref="MainSchema"/> — enough to give the browser a real root to expand.
/// </summary>
public string ListSchemasSql => $"SELECT '{MainSchema}' AS TABLE_SCHEMA";
/// <summary>
/// SQLite has no per-principal default schema, so an unqualified name always resolves in
/// <see cref="MainSchema"/> — the same single schema <see cref="ListSchemasSql"/> reports.
/// </summary>
public string DefaultSchemaSql => $"SELECT '{MainSchema}'";
/// <summary>
/// Tables + views from <c>sqlite_schema</c> (the modern name for <c>sqlite_master</c>), with the
/// internal <c>sqlite_*</c> objects filtered out and the type folded onto the
/// <c>INFORMATION_SCHEMA.TABLES</c> vocabulary the browser already speaks. <c>@schema</c> is bound and
/// honoured — anything but <see cref="MainSchema"/> legitimately lists nothing.
/// </summary>
public string ListTablesSql =>
"SELECT name AS TABLE_NAME, " +
"CASE type WHEN 'view' THEN 'VIEW' ELSE 'BASE TABLE' END AS TABLE_TYPE " +
"FROM sqlite_schema " +
"WHERE type IN ('table', 'view') AND name NOT LIKE 'sqlite\\_%' ESCAPE '\\' " +
$"AND @schema = '{MainSchema}' ORDER BY name";
/// <summary>
/// Columns via the <c>pragma_table_info</c> table-valued function rather than the bare
/// <c>PRAGMA table_info(x)</c> statement, because only the function form lets the table name be a
/// <b>bound parameter</b> — a bare PRAGMA takes its argument as text, which would put an authored name
/// back into a command string and reopen the injection boundary this seam exists to close.
/// <c>DATA_TYPE</c> is the column's <em>declared</em> type (SQLite stores affinity, not a type).
/// </summary>
public string ListColumnsSql =>
"SELECT name AS COLUMN_NAME, type AS DATA_TYPE, " +
"CASE \"notnull\" WHEN 1 THEN 'NO' ELSE 'YES' END AS IS_NULLABLE " +
"FROM pragma_table_info(@table) " +
$"WHERE @schema = '{MainSchema}' ORDER BY cid";
/// <summary>
/// Double-quotes one identifier part, doubling any embedded <c>"</c> — SQLite's escape rule, and the
/// only metacharacter that could close a quoted identifier early.
/// </summary>
/// <remarks>
/// Mirrors <c>SqlServerDialect.QuoteIdentifier</c>'s rejections (null / empty / all-whitespace /
/// control characters) so a test written against one dialect fails the same way against the other.
/// SQLite imposes no identifier length ceiling, so there is no length rule here.
/// </remarks>
/// <param name="ident">The bare, unquoted identifier part.</param>
/// <returns>The double-quote-quoted identifier.</returns>
/// <exception cref="ArgumentException">The value cannot be a valid SQLite identifier.</exception>
public string QuoteIdentifier(string ident)
{
if (string.IsNullOrWhiteSpace(ident))
throw new ArgumentException(
"A SQL identifier must be a non-empty, non-whitespace name.", nameof(ident));
foreach (var ch in ident)
{
if (char.IsControl(ch))
throw new ArgumentException(
"A SQL identifier may not contain control characters (including NUL).", nameof(ident));
}
return string.Concat("\"", ident.Replace("\"", "\"\"", StringComparison.Ordinal), "\"");
}
/// <summary>
/// Folds a SQLite <b>declared</b> column type onto a <see cref="DriverDataType"/>. SQLite is
/// dynamically typed and a column's declared type is advisory, which is exactly why the fixture
/// declares every seeded column explicitly.
/// </summary>
/// <remarks>
/// <para><b>Never throws</b>, mirroring <c>SqlServerDialect</c>: an unrecognised (or blank, or
/// parameterised like <c>VARCHAR(50)</c>) declaration falls back to
/// <see cref="DriverDataType.String"/> so a browse over an oddly-declared table still renders.</para>
/// <para><b>Deliberate divergence from SQL Server:</b> <c>REAL</c> maps to
/// <see cref="DriverDataType.Float64"/> here. SQLite's REAL is an 8-byte IEEE double, where T-SQL's
/// <c>real</c> is 4-byte — mapping it to Float32 would narrow every value the fixture returns.</para>
/// </remarks>
/// <param name="sqlDataType">The declared type as <c>pragma_table_info</c> reports it; case-insensitive.</param>
/// <returns>The mapped driver data type, or <see cref="DriverDataType.String"/> when unrecognised.</returns>
public DriverDataType MapColumnType(string sqlDataType)
{
if (string.IsNullOrWhiteSpace(sqlDataType)) return DriverDataType.String;
return sqlDataType.Trim().ToLowerInvariant() switch
{
"boolean" or "bool" or "bit" => DriverDataType.Boolean,
"tinyint" or "smallint" or "int2" => DriverDataType.Int16,
"int" or "integer" or "mediumint" or "int4" => DriverDataType.Int32,
"bigint" or "int8" => DriverDataType.Int64,
// SQLite's REAL is a double — deliberately NOT Float32 (see remarks).
"real" or "double" or "double precision" or "float"
or "numeric" or "decimal" => DriverDataType.Float64,
"text" or "clob" or "char" or "varchar" or "nchar" or "nvarchar" => DriverDataType.String,
"date" or "datetime" or "timestamp" => DriverDataType.DateTime,
_ => DriverDataType.String,
};
}
}