4dc2ad8084
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.
355 lines
15 KiB
C#
355 lines
15 KiB
C#
using System.Globalization;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests;
|
|
|
|
/// <summary>
|
|
/// The design §8.1 catalog gate end-to-end through <see cref="SqlDriver"/>, against the real SQLite
|
|
/// catalog the poll fixture creates — real <c>ListSchemas</c>/<c>ListTables</c>/<c>ListColumns</c>
|
|
/// round-trips, not a hand-built <see cref="SqlCatalog"/>.
|
|
/// <para><b>The two facts that matter most here</b> are the ones a pure unit test cannot show: that a
|
|
/// rejected tag still <em>has a node</em> and reads <c>BadNodeIdUnknown</c> (rather than silently
|
|
/// vanishing from the address space), and that a catalog the driver cannot read faults Initialize instead
|
|
/// of rejecting every tag.</para>
|
|
/// </summary>
|
|
public sealed class SqlCatalogGateDriverTests
|
|
{
|
|
private const string DriverInstanceId = "sql-gate";
|
|
private const string ConfigJson = """{"provider":"SqlServer"}""";
|
|
|
|
[Fact]
|
|
public async Task A_tag_naming_a_real_table_and_columns_polls_normally()
|
|
{
|
|
using var fixture = new SqlitePollFixture();
|
|
await using var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey));
|
|
|
|
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
|
|
|
|
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
|
var snapshot = (await driver.ReadAsync(["Speed"], CancellationToken.None)).ShouldHaveSingleItem();
|
|
SqlStatusCodes.IsGood(snapshot.StatusCode).ShouldBeTrue();
|
|
snapshot.Value.ShouldBe(SqlitePollFixture.PresentValue);
|
|
}
|
|
|
|
/// <summary>
|
|
/// §8.1's specified outcome, in full: the tag is refused by the allow-list, so it never reaches a
|
|
/// query — but its node still exists and reads <c>BadNodeIdUnknown</c>. Dropping the node instead would
|
|
/// turn a diagnosable Bad quality into a missing address-space entry.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task A_tag_naming_an_unknown_column_keeps_its_node_and_reads_BadNodeIdUnknown()
|
|
{
|
|
using var fixture = new SqlitePollFixture();
|
|
await using var driver = NewDriver(
|
|
fixture,
|
|
KvEntry("Speed", SqlitePollFixture.PresentKey),
|
|
KvEntry("Bogus", SqlitePollFixture.PresentKey, valueColumn: "no_such_column"));
|
|
|
|
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
|
|
|
|
// The driver is healthy: an authoring typo is not a database fault.
|
|
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
|
|
|
// The node is still materialized...
|
|
var capture = new CapturingBuilder();
|
|
await ((ITagDiscovery)driver).DiscoverAsync(capture, CancellationToken.None);
|
|
capture.Variables.Select(v => v.Info.FullName).ShouldBe(["Speed", "Bogus"], ignoreOrder: true);
|
|
|
|
// ...and it is the rejected tag — and only it — that reads BadNodeIdUnknown.
|
|
var snapshots = await driver.ReadAsync(["Speed", "Bogus"], CancellationToken.None);
|
|
SqlStatusCodes.IsGood(snapshots[0].StatusCode).ShouldBeTrue();
|
|
snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.BadNodeIdUnknown);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_tag_naming_an_unknown_table_reads_BadNodeIdUnknown()
|
|
{
|
|
using var fixture = new SqlitePollFixture();
|
|
await using var driver = NewDriver(
|
|
fixture, KvEntry("Bogus", SqlitePollFixture.PresentKey, table: "NoSuchTable"));
|
|
|
|
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
|
|
|
|
(await driver.ReadAsync(["Bogus"], CancellationToken.None))
|
|
.ShouldHaveSingleItem().StatusCode.ShouldBe(SqlStatusCodes.BadNodeIdUnknown);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The injection shape #496 exists to close. Before the gate, this name was bracket-quoted into a real
|
|
/// query that failed only once the connection was open; now it never reaches a query at all.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task A_hostile_table_name_never_reaches_a_query()
|
|
{
|
|
using var fixture = new SqlitePollFixture();
|
|
var logger = new CapturingLogger();
|
|
await using var driver = NewDriver(
|
|
fixture, logger,
|
|
KvEntry("Evil", SqlitePollFixture.PresentKey, table: "TagValues\"; DROP TABLE TagValues--"));
|
|
|
|
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
|
|
|
|
(await driver.ReadAsync(["Evil"], CancellationToken.None))
|
|
.ShouldHaveSingleItem().StatusCode.ShouldBe(SqlStatusCodes.BadNodeIdUnknown);
|
|
|
|
// The fixture's table is untouched — proven by a second tag still reading through it.
|
|
await using var honest = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey));
|
|
await honest.InitializeAsync(ConfigJson, CancellationToken.None);
|
|
var snapshot = (await honest.ReadAsync(["Speed"], CancellationToken.None)).ShouldHaveSingleItem();
|
|
SqlStatusCodes.IsGood(snapshot.StatusCode).ShouldBeTrue();
|
|
|
|
logger.Entries.ShouldContain(e =>
|
|
e.Level == LogLevel.Warning && e.Message.Contains("rejected by the catalog gate", StringComparison.Ordinal));
|
|
}
|
|
|
|
/// <summary>
|
|
/// A node that goes Bad with nothing in the log is unsupportable, so every drop names the tag, the
|
|
/// field and the reason.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Every_rejection_is_logged_with_the_tag_the_field_and_the_reason()
|
|
{
|
|
using var fixture = new SqlitePollFixture();
|
|
var logger = new CapturingLogger();
|
|
await using var driver = NewDriver(
|
|
fixture, logger, KvEntry("Bogus", SqlitePollFixture.PresentKey, valueColumn: "num_valeu"));
|
|
|
|
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
|
|
|
|
var warning = logger.Entries
|
|
.Where(e => e.Level == LogLevel.Warning)
|
|
.Select(e => e.Message)
|
|
.ShouldHaveSingleItem();
|
|
warning.ShouldContain("Bogus");
|
|
warning.ShouldContain(nameof(SqlTagDefinition.ValueColumn));
|
|
warning.ShouldContain("num_valeu");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Case-insensitive authoring has always worked on SQL Server's default collation, so the gate must
|
|
/// accept it — and it substitutes the catalog's spelling, which is what makes the emitted SQL carry
|
|
/// catalog strings rather than operator input.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task A_case_variant_identifier_is_accepted_and_polls()
|
|
{
|
|
using var fixture = new SqlitePollFixture();
|
|
await using var driver = NewDriver(
|
|
fixture,
|
|
KvEntry(
|
|
"Speed", SqlitePollFixture.PresentKey,
|
|
table: SqlitePollFixture.KeyValueTable.ToUpperInvariant(),
|
|
valueColumn: SqlitePollFixture.ValueColumn.ToUpperInvariant()));
|
|
|
|
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
|
|
|
|
var snapshot = (await driver.ReadAsync(["Speed"], CancellationToken.None)).ShouldHaveSingleItem();
|
|
SqlStatusCodes.IsGood(snapshot.StatusCode).ShouldBeTrue();
|
|
snapshot.Value.ShouldBe(SqlitePollFixture.PresentValue);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A driver with nothing authored has nothing to validate; issuing catalog queries to prove that would
|
|
/// be a round-trip that can only fail.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task A_driver_with_no_authored_tags_initializes_without_touching_the_catalog()
|
|
{
|
|
using var fixture = new SqlitePollFixture();
|
|
await using var driver = NewDriver(fixture);
|
|
|
|
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
|
|
|
|
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
|
}
|
|
|
|
/// <summary>
|
|
/// <b>The fail-closed rule.</b> A catalog that cannot be read is the ABSENCE of evidence about the
|
|
/// tags, not evidence against them. Rejecting every tag would serve a confidently-empty address space
|
|
/// and send the operator hunting typos that do not exist; faulting Initialize instead lands
|
|
/// <c>DriverInstanceActor</c> in Reconnecting with its retry timer running.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task A_catalog_that_cannot_be_read_faults_Initialize_rather_than_rejecting_every_tag()
|
|
{
|
|
using var fixture = new SqlitePollFixture();
|
|
await using var driver = new SqlDriver(
|
|
new SqlDriverOptions
|
|
{
|
|
RawTags = [KvEntry("Speed", SqlitePollFixture.PresentKey)],
|
|
OperationTimeout = TimeSpan.FromSeconds(15),
|
|
CommandTimeout = TimeSpan.FromSeconds(10),
|
|
},
|
|
DriverInstanceId,
|
|
new CatalogSqlOverride("SELECT this is not valid sql"),
|
|
fixture.ConnectionString,
|
|
factory: fixture.Factory,
|
|
logger: NullLogger<SqlDriver>.Instance);
|
|
|
|
var thrown = await Should.ThrowAsync<InvalidOperationException>(
|
|
async () => await driver.InitializeAsync(ConfigJson, CancellationToken.None));
|
|
|
|
// The operator surface names the stage that actually failed — "reached it but could not read the
|
|
// catalog" and "could not reach it" send an operator to different systems.
|
|
thrown.Message.ShouldContain("catalog");
|
|
driver.GetHealth().State.ShouldBe(DriverState.Faulted);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Zero visible schemas is a grant problem, not an empty database, so it must fault rather than reject
|
|
/// every tag for an authoring fault the operator does not have.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task A_catalog_reporting_no_schemas_at_all_faults_Initialize()
|
|
{
|
|
using var fixture = new SqlitePollFixture();
|
|
await using var driver = new SqlDriver(
|
|
new SqlDriverOptions
|
|
{
|
|
RawTags = [KvEntry("Speed", SqlitePollFixture.PresentKey)],
|
|
OperationTimeout = TimeSpan.FromSeconds(15),
|
|
CommandTimeout = TimeSpan.FromSeconds(10),
|
|
},
|
|
DriverInstanceId,
|
|
new CatalogSqlOverride("SELECT 'x' AS TABLE_SCHEMA WHERE 1 = 0"),
|
|
fixture.ConnectionString,
|
|
factory: fixture.Factory,
|
|
logger: NullLogger<SqlDriver>.Instance);
|
|
|
|
await Should.ThrowAsync<InvalidOperationException>(
|
|
async () => await driver.InitializeAsync(ConfigJson, CancellationToken.None));
|
|
|
|
driver.GetHealth().State.ShouldBe(DriverState.Faulted);
|
|
}
|
|
|
|
// ---- helpers ----
|
|
|
|
/// <summary>
|
|
/// Delegates every member to <see cref="SqliteDialect"/> except <see cref="ListSchemasSql"/>, so the
|
|
/// catalog-load failure modes can be driven against an otherwise-real dialect.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// A decorator rather than a subclass: <see cref="SqliteDialect"/> is sealed, and even if it were not,
|
|
/// <c>new</c>-hiding a property would leave interface dispatch calling the base — the substitution
|
|
/// would silently not happen and both tests below would pass for the wrong reason.
|
|
/// </remarks>
|
|
private sealed class CatalogSqlOverride(string listSchemasSql) : ISqlDialect
|
|
{
|
|
private static readonly SqliteDialect Inner = new();
|
|
|
|
public SqlProvider Provider => Inner.Provider;
|
|
|
|
public System.Data.Common.DbProviderFactory Factory => Inner.Factory;
|
|
|
|
public string LivenessSql => Inner.LivenessSql;
|
|
|
|
public string SingleRowLimitPrefix => Inner.SingleRowLimitPrefix;
|
|
|
|
public string SingleRowLimitSuffix => Inner.SingleRowLimitSuffix;
|
|
|
|
public string ListSchemasSql { get; } = listSchemasSql;
|
|
|
|
public string DefaultSchemaSql => Inner.DefaultSchemaSql;
|
|
|
|
public string ListTablesSql => Inner.ListTablesSql;
|
|
|
|
public string ListColumnsSql => Inner.ListColumnsSql;
|
|
|
|
public string QuoteIdentifier(string ident) => Inner.QuoteIdentifier(ident);
|
|
|
|
public DriverDataType MapColumnType(string sqlDataType) => Inner.MapColumnType(sqlDataType);
|
|
}
|
|
|
|
private static SqlDriver NewDriver(SqlitePollFixture fixture, params RawTagEntry[] rawTags)
|
|
=> NewDriver(fixture, new CapturingLogger(), rawTags);
|
|
|
|
private static SqlDriver NewDriver(
|
|
SqlitePollFixture fixture, CapturingLogger logger, params RawTagEntry[] rawTags)
|
|
=> new(
|
|
new SqlDriverOptions
|
|
{
|
|
RawTags = rawTags,
|
|
OperationTimeout = TimeSpan.FromSeconds(15),
|
|
CommandTimeout = TimeSpan.FromSeconds(10),
|
|
},
|
|
DriverInstanceId,
|
|
new SqliteDialect(),
|
|
fixture.ConnectionString,
|
|
factory: fixture.Factory,
|
|
logger: logger);
|
|
|
|
/// <summary>One authored raw tag, with the table and value column overridable so the gate can be exercised.</summary>
|
|
private static RawTagEntry KvEntry(
|
|
string rawPath,
|
|
string keyValue,
|
|
string? table = null,
|
|
string? valueColumn = null)
|
|
=> new(rawPath, string.Create(CultureInfo.InvariantCulture, $$"""
|
|
{
|
|
"driver": "Sql",
|
|
"model": "KeyValue",
|
|
"table": "{{(table ?? SqlitePollFixture.KeyValueTable).Replace("\"", "\\\"", StringComparison.Ordinal)}}",
|
|
"keyColumn": "{{SqlitePollFixture.KeyColumn}}",
|
|
"keyValue": "{{keyValue}}",
|
|
"valueColumn": "{{valueColumn ?? SqlitePollFixture.ValueColumn}}",
|
|
"timestampColumn": "{{SqlitePollFixture.TimestampColumn}}"
|
|
}
|
|
"""), WriteIdempotent: false);
|
|
|
|
/// <summary>Records everything the driver streams into the address space.</summary>
|
|
private sealed class CapturingBuilder : IAddressSpaceBuilder
|
|
{
|
|
/// <summary>The variables registered, in order.</summary>
|
|
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = [];
|
|
|
|
public IAddressSpaceBuilder Folder(string browseName, string displayName) => this;
|
|
|
|
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
|
|
{
|
|
Variables.Add((browseName, attributeInfo));
|
|
return new Handle(attributeInfo.FullName);
|
|
}
|
|
|
|
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
|
|
|
|
private sealed class Handle(string fullReference) : IVariableHandle
|
|
{
|
|
public string FullReference => fullReference;
|
|
|
|
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new Sink();
|
|
|
|
private sealed class Sink : IAlarmConditionSink
|
|
{
|
|
public void OnTransition(AlarmEventArgs args) { }
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>Records every log record, level + rendered message.</summary>
|
|
private sealed class CapturingLogger : ILogger<SqlDriver>
|
|
{
|
|
public List<(LogLevel Level, string Message)> Entries { get; } = [];
|
|
|
|
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
|
|
|
|
public bool IsEnabled(LogLevel logLevel) => true;
|
|
|
|
public void Log<TState>(
|
|
LogLevel logLevel, EventId eventId, TState state, Exception? exception,
|
|
Func<TState, Exception?, string> formatter)
|
|
=> Entries.Add((logLevel, formatter(state, exception)));
|
|
|
|
private sealed class NullScope : IDisposable
|
|
{
|
|
public static NullScope Instance { get; } = new();
|
|
|
|
public void Dispose() { }
|
|
}
|
|
}
|
|
}
|