using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; namespace ZB.MOM.WW.OtOpcUa.Driver.Sql; /// /// One tag the gate refused, and why. Carried rather than logged in place so the caller decides the log /// shape and the caller's tests can assert on the reason. /// /// The rejected tag's identity — its RawPath, which is trusted structure, not blob content. /// The field that failed (e.g. ValueColumn). /// An operator-actionable explanation, safe to log. public sealed record SqlCatalogRejection(string RawPath, string Field, string Reason); /// The outcome of applying a to a set of authored tags. /// /// The surviving definitions, rewritten to catalog spellings. Safe to hand to /// . /// /// Every tag the gate refused, in input order. public sealed record SqlCatalogGateResult( IReadOnlyList Accepted, IReadOnlyList Rejected); /// /// Design §8.1's identifier gate: validates every authored table/column against the live catalog /// before it can be quoted into SQL text, and replaces it with the catalog's own spelling. /// /// /// What this closes. Until this existed, was the /// sole defence: an identifier went from the authored 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. With the gate in place the identifier text in an emitted query is a /// string this driver read back out of the catalog, so quoting is a backstop behind an allow-list rather /// than the whole story. /// Charset first, catalog second. Each identifier is passed through /// before it is looked up, purely for its rejection rules /// (control characters, Unicode format characters, over-length). That ordering is deliberate: a name that /// fails the charset check is rejected without its value being echoed, so nothing carrying a bidi /// override or a NUL can reach a log line through this type's rejection messages. A name that passes has /// been proven safe to render, which is why the catalog-miss messages can afford to name it — and they /// must, or an operator has no way to find the typo. /// Rejection is per-tag, never driver-wide. A tag naming a dropped column is dropped from the /// driver's table, so its RawPath resolves to nothing and it publishes /// — design §8.1's specified outcome — while every other tag /// on that database keeps polling. This mirrors the "one malformed blob must not take the whole driver /// down" rule the tag-table build already follows. /// public static class SqlCatalogGate { /// /// The most name parts this gate can validate. A three-part db.schema.table (or a /// four-part linked-server name) addresses a catalog this connection's INFORMATION_SCHEMA /// cannot see, so it cannot be allow-listed. /// private const int MaxNameParts = 2; /// /// Applies to , returning the accepted tags /// with catalog-spelled identifiers and the rejected ones with reasons. /// /// The authored definitions, as parsed from their TagConfig blobs. /// The catalog snapshot to validate against. /// Supplies the identifier charset rules via . /// The accepted and rejected sets. /// A required argument is null. public static SqlCatalogGateResult Apply( IEnumerable definitions, SqlCatalog catalog, ISqlDialect dialect) { ArgumentNullException.ThrowIfNull(definitions); ArgumentNullException.ThrowIfNull(catalog); ArgumentNullException.ThrowIfNull(dialect); var accepted = new List(); var rejected = new List(); foreach (var definition in definitions) { ArgumentNullException.ThrowIfNull(definition); if (TryCanonicalize(definition, catalog, dialect, out var canonical, out var rejection)) accepted.Add(canonical!); else rejected.Add(rejection!); } return new SqlCatalogGateResult(accepted, rejected); } /// Resolves one definition's identifiers, or explains the first failure. private static bool TryCanonicalize( SqlTagDefinition definition, SqlCatalog catalog, ISqlDialect dialect, out SqlTagDefinition? canonical, out SqlCatalogRejection? rejection) { canonical = null; rejection = null; if (!TryResolveRelation(definition, catalog, dialect, out var schema, out var table, out rejection)) return false; // Every identifier-bearing field, paired with its name for the rejection message. A field the tag's // model does not use is null and simply skipped — the planner enforces which are required. var resolved = new Dictionary(StringComparer.Ordinal); foreach (var (field, authored) in new (string Field, string? Authored)[] { (nameof(SqlTagDefinition.KeyColumn), definition.KeyColumn), (nameof(SqlTagDefinition.ValueColumn), definition.ValueColumn), (nameof(SqlTagDefinition.TimestampColumn), definition.TimestampColumn), (nameof(SqlTagDefinition.ColumnName), definition.ColumnName), (nameof(SqlTagDefinition.RowSelectorColumn), definition.RowSelectorColumn), (nameof(SqlTagDefinition.RowSelectorTopByTimestamp), definition.RowSelectorTopByTimestamp), }) { if (string.IsNullOrWhiteSpace(authored)) { resolved[field] = authored; continue; } if (!IsRenderableIdentifier(authored, dialect)) { rejection = new SqlCatalogRejection(definition.Name, field, $"the authored {field} is not a usable identifier (it is over-long, or contains control " + "or Unicode format characters); the value is withheld from this message because it " + "cannot be safely rendered"); return false; } if (!catalog.TryResolveColumn(schema, table, authored, out var column)) { rejection = new SqlCatalogRejection(definition.Name, field, $"column '{authored}' does not exist on '{schema}.{table}' (or matches more than one " + "column under a case-sensitive collation)"); return false; } resolved[field] = column; } canonical = definition with { Table = SqlCatalog.QualifiedKey(schema, table), KeyColumn = resolved[nameof(SqlTagDefinition.KeyColumn)], ValueColumn = resolved[nameof(SqlTagDefinition.ValueColumn)], TimestampColumn = resolved[nameof(SqlTagDefinition.TimestampColumn)], ColumnName = resolved[nameof(SqlTagDefinition.ColumnName)], RowSelectorColumn = resolved[nameof(SqlTagDefinition.RowSelectorColumn)], RowSelectorTopByTimestamp = resolved[nameof(SqlTagDefinition.RowSelectorTopByTimestamp)], }; return true; } /// Splits and resolves the authored table to a canonical schema + relation. private static bool TryResolveRelation( SqlTagDefinition definition, SqlCatalog catalog, ISqlDialect dialect, out string schema, out string table, out SqlCatalogRejection? rejection) { schema = string.Empty; table = string.Empty; rejection = null; const string Field = nameof(SqlTagDefinition.Table); if (string.IsNullOrWhiteSpace(definition.Table)) { rejection = new SqlCatalogRejection(definition.Name, Field, "the tag has no 'table'; author the table or view to read"); return false; } var parts = definition.Table.Split('.'); if (parts.Length > MaxNameParts) { rejection = new SqlCatalogRejection(definition.Name, Field, $"'{definition.Table}' is a {parts.Length}-part name. Only 'table' and 'schema.table' can be " + "validated against this connection's catalog, so a cross-database or linked-server name " + "cannot be allow-listed; expose the data through a view in this database instead"); return false; } var authoredSchema = parts.Length == MaxNameParts ? parts[0] : null; var authoredTable = parts[^1]; foreach (var part in parts) { if (IsRenderableIdentifier(part, dialect)) continue; rejection = new SqlCatalogRejection(definition.Name, Field, "the authored table name has a part that is not a usable identifier (empty, over-long, or " + "containing control or Unicode format characters); the value is withheld from this message " + "because it cannot be safely rendered"); return false; } if (!catalog.TryResolveSchema(authoredSchema, out schema)) { rejection = new SqlCatalogRejection(definition.Name, Field, $"schema '{authoredSchema}' does not exist (or matches more than one schema under a " + "case-sensitive collation)"); return false; } if (!catalog.TryResolveTable(schema, authoredTable, out table)) { rejection = new SqlCatalogRejection(definition.Name, Field, $"table or view '{authoredTable}' does not exist in schema '{schema}'" + (authoredSchema is null ? $" (the connection's default schema — qualify the name as 'schema.{authoredTable}' if it lives elsewhere)" : string.Empty)); return false; } return true; } /// /// True when the dialect's quoting rules accept — i.e. it is safe both to /// embed in SQL and to render into a log line or an AdminUI label. /// /// /// Uses as the charset authority rather than duplicating its /// rules, so the two can never disagree about what a legal identifier is. The quoted result is /// discarded: only whether it threw is interesting here. /// private static bool IsRenderableIdentifier(string identifier, ISqlDialect dialect) { try { dialect.QuoteIdentifier(identifier); return true; } catch (ArgumentException) { return false; } } }