merge: SQL browser/probe review findings resolved (Driver.Sql-009, Driver.Sql.Browser-001/-002/-003)

This commit is contained in:
Joseph Doherty
2026-08-10 08:06:02 -04:00
11 changed files with 454 additions and 33 deletions
@@ -63,6 +63,14 @@ internal sealed class SqlBrowseSession : IBrowseSession
/// </summary>
private const string ReadOnlySecurityClass = "ViewOnly";
/// <summary>
/// The one SQL type family that maps to <see cref="DriverDataType.DateTime"/> while carrying <b>no
/// time of day</b> — SQL Server's and PostgreSQL's <c>date</c>. Ranked below the time-bearing
/// families by <see cref="PreferredTimestampColumn"/>; see <see cref="IsDateOnly"/> for the Oracle
/// exception.
/// </summary>
private const string DateOnlyTypeFamily = "date";
private readonly DbConnection _connection;
private readonly ISqlDialect _dialect;
@@ -197,8 +205,18 @@ internal sealed class SqlBrowseSession : IBrowseSession
}
/// <summary>
/// Idempotently closes the owned connection and the gate. Errors are swallowed: the registry's reaper
/// may be racing a server-side disconnect, and a failed close must never surface in the AdminUI.
/// Idempotently closes the owned connection. Errors are swallowed: the registry's reaper may be racing
/// a server-side disconnect, and a failed close must never surface in the AdminUI.
/// <para><b>The gate is deliberately NOT disposed</b> — the same call
/// <c>SqlPollReader</c> makes, and for a sharper reason here. This session is disposed by the
/// registry's TTL reaper, on its own thread, while a picker may have a second call parked in
/// <see cref="SemaphoreSlim.WaitAsync(CancellationToken)"/>. Disposing the semaphore does not complete
/// its pending async waiters, and the in-flight call's <c>Release()</c> would then throw
/// <see cref="ObjectDisposedException"/> from inside the semaphore — so the parked caller is never
/// released and its call never returns at all. Left undisposed, the waiter is admitted and meets the
/// guarded <c>ObjectDisposedException.ThrowIf(_disposed, this)</c> every other member raises.
/// A <see cref="SemaphoreSlim"/> that was never handed out as a <see cref="WaitHandle"/> holds no
/// unmanaged resource, so not disposing it leaks nothing.</para>
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async ValueTask DisposeAsync()
@@ -208,9 +226,6 @@ internal sealed class SqlBrowseSession : IBrowseSession
try { await _connection.DisposeAsync().ConfigureAwait(false); }
catch { /* best-effort: the connection may already be broken or closed. */ }
try { _gate.Dispose(); }
catch { /* best-effort: a concurrent second dispose already tore it down. */ }
}
private async Task<IReadOnlyList<BrowseNode>> ExpandSchemaAsync(string schema, CancellationToken ct)
@@ -274,12 +289,23 @@ internal sealed class SqlBrowseSession : IBrowseSession
/// row sorts last lexically. So the only input is
/// <see cref="ISqlDialect.MapColumnType"/> reporting <see cref="DriverDataType.DateTime"/>, which is
/// the same catalog metadata the browse tree already renders per column, on every dialect.</para>
/// <para><b>Tie-break: FIRST in catalog order.</b> Every dialect's <see cref="ISqlDialect.ListColumnsSql"/>
/// orders by ordinal position (<c>ORDINAL_POSITION</c> / <c>column_id</c>), so "first" means the
/// earliest-declared date/time column and is stable across calls, connections and servers — an
/// unordered "any candidate" would let two operators browsing the same table commit two differently
/// bound tags. On the usual multi-candidate shape (a <c>CreatedUtc</c>/<c>ModifiedUtc</c> pair) it
/// picks the earlier-declared one, which is a preference and not a fact about the data.</para>
/// <para><b>Ranked, because "maps to DateTime" includes DATE-ONLY types.</b>
/// <see cref="ISqlDialect.MapColumnType"/> reports <see cref="DriverDataType.DateTime"/> for SQL
/// Server's and PostgreSQL's <c>date</c> as well — and a wide-row read ordered by a date-only column
/// is <c>ORDER BY &lt;date&gt; DESC</c> limited to one row, which <em>ties for every row written on
/// the same day</em>: "the newest row" silently becomes storage order among the ties, and the reader
/// cannot warn about it because the server has already discarded the others. So a time-bearing
/// candidate always outranks a date-only one, wherever the two are declared.
/// <b>Falling back to a date-only column is deliberate</b> rather than dropping those types from
/// candidacy: a table whose only date/time column is a <c>date</c> stays bindable by browse (with a
/// default the operator can see and change) instead of becoming un-committable.</para>
/// <para><b>Tie-break within a rank: FIRST in catalog order.</b> Every dialect's
/// <see cref="ISqlDialect.ListColumnsSql"/> orders by ordinal position (<c>ORDINAL_POSITION</c> /
/// <c>column_id</c>), so "first" means the earliest-declared candidate and is stable across calls,
/// connections and servers — an unordered "any candidate" would let two operators browsing the same
/// table commit two differently bound tags. On the usual multi-candidate shape (a
/// <c>CreatedUtc</c>/<c>ModifiedUtc</c> pair) it picks the earlier-declared one, which is a preference
/// and not a fact about the data.</para>
/// <para><b>It is a visible, editable DEFAULT, not a hidden binding.</b> The commit writes it into the
/// tag's <c>rowSelector.topByTimestamp</c>, where the typed Sql editor shows it and the operator can
/// change it — to the other timestamp, or to a <c>whereColumn</c>/<c>whereValue</c> pair entirely.
@@ -289,13 +315,42 @@ internal sealed class SqlBrowseSession : IBrowseSession
/// </remarks>
private string? PreferredTimestampColumn(IReadOnlyList<(string Name, string DataType)> columns)
{
// One pass, remembering the best date-only candidate seen so far: the first time-bearing column wins
// outright, and the date-only one is only used if the walk ends without finding one.
string? dateOnlyFallback = null;
foreach (var column in columns)
{
if (string.IsNullOrWhiteSpace(column.Name)) continue;
if (_dialect.MapColumnType(column.DataType) == DriverDataType.DateTime) return column.Name;
if (_dialect.MapColumnType(column.DataType) != DriverDataType.DateTime) continue;
if (!IsDateOnly(column.DataType)) return column.Name;
dateOnlyFallback ??= column.Name;
}
return null;
return dateOnlyFallback;
}
/// <summary>
/// Whether a column's declared type family carries a date but no time of day — the case that makes an
/// <c>ORDER BY … DESC</c> single-row read tie for a whole day's rows.
/// </summary>
/// <param name="sqlDataType">The bare SQL type family name, as the catalog reported it.</param>
/// <returns><see langword="true"/> when the family is date-only on this dialect's backend.</returns>
/// <remarks>
/// <b>Provider-qualified, because the spelling is not the meaning.</b> <c>date</c> is date-only on SQL
/// Server and PostgreSQL, but <b>Oracle's <c>DATE</c> carries a time-of-day component</b> — which is
/// why <c>OracleDialect.MapColumnType</c> calls <see cref="DriverDataType.DateTime"/> the faithful
/// mapping there rather than a widening. Demoting it on Oracle would change the pick on the backend
/// where <c>DATE</c> is the ordinary timestamp type, for no gain.
/// <para>The check is a browse-time <em>preference</em> and belongs here rather than on
/// <see cref="ISqlDialect"/>: nothing on the poll path cares, and the type map is deliberately about
/// what a value IS, not about what it is a good idea to sort by.</para>
/// </remarks>
private bool IsDateOnly(string sqlDataType)
{
if (_dialect.Provider == SqlProvider.Oracle) return false;
return string.Equals(sqlDataType.Trim(), DateOnlyTypeFamily, StringComparison.OrdinalIgnoreCase);
}
/// <summary>
@@ -319,8 +374,12 @@ internal sealed class SqlBrowseSession : IBrowseSession
/// <summary>
/// Runs one catalog query under the gate and projects every row. The disposed check happens
/// <em>inside</em> the gate wait so a dispose racing an in-flight call cannot be missed, and
/// <see cref="LastUsedUtc"/> only advances on a call that actually completed.
/// <em>inside</em> the gate wait so a dispose racing an in-flight call cannot be missed.
/// <para><see cref="LastUsedUtc"/> is stamped <b>on entry as well as on completion</b>. The AdminUI
/// allows a catalog call 20 s (<c>BrowserSessionService.PerCallTimeout</c>) and a queued one waits
/// behind it; stamping only on completion leaves a long expand invisible to the registry's TTL reaper
/// for its whole duration, so a session that idled to the edge of the TTL and was then given one slow
/// call gets reaped mid-call — tearing the connection down under an open reader.</para>
/// </summary>
private async Task<List<T>> QueryAsync<T>(
string sql,
@@ -328,6 +387,7 @@ internal sealed class SqlBrowseSession : IBrowseSession
Func<DbDataReader, T> project,
CancellationToken cancellationToken)
{
LastUsedUtc = DateTime.UtcNow;
ObjectDisposedException.ThrowIf(_disposed, this);
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
@@ -352,10 +412,9 @@ internal sealed class SqlBrowseSession : IBrowseSession
}
finally
{
// A dispose that raced this call has already disposed the gate; releasing it then is harmless to
// ignore and must not mask the real result.
try { _gate.Release(); }
catch (ObjectDisposedException) { }
// Unconditional, and it cannot throw: DisposeAsync deliberately leaves the gate alive precisely
// so this release still reaches whoever is parked behind us.
_gate.Release();
}
}
@@ -26,6 +26,13 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser;
/// the driver factory would drop it — which makes its presence proof that an operator typed it just now.
/// A config carrying both logs a warning (naming the shadowed <em>ref</em>, never any connection text) so
/// the override is visible rather than silent.</para>
/// <para><b>Trust boundary: this is an outbound-connect capability, gated by the AdminUI page policy.</b>
/// Accepting a literal means dialling an arbitrary host and port — plus whatever the provider's keyword
/// set allows — from the AdminUI host, before anything is persisted. The control is that the only
/// surfaces reaching a browse session are <c>/raw</c> and <c>/uns</c>, both
/// <c>[Authorize(Policy = AdminUiPolicies.ConfigEditor)]</c> (Administrator or Designer), with no HTTP
/// endpoint for browse at all; the hygiene below is supporting, not the control. Recorded in
/// <c>docs/security.md</c> §"Address-picker browse is an outbound-connect capability".</para>
/// <para><b>Credential hygiene is the point of this type.</b> A connection string is a secret. It is
/// passed to the provider and to nothing else: it never reaches a log line, an exception message, a field,
/// or anything persisted. Because ADO.NET providers are free to echo connection-string content in their
@@ -19,7 +19,10 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
/// timeout, a cancellation — becomes a red <see cref="DriverProbeResult"/>, so the AdminUI has nothing to
/// catch.</para>
/// <para><b>The resolved connection string never reaches the result message.</b> A red result names the
/// failure class, never the string — the same credential discipline the factory and the driver keep.</para>
/// failure class, never the string — the same credential discipline the factory and the driver keep.
/// That includes the JSON parser: a malformed config is reported as a class, with the parser's own
/// message withheld exactly as <c>SqlDriverBrowser.ParseDocument</c> withholds it, because the malformed
/// text may itself be a pasted connection string.</para>
/// </summary>
public sealed class SqlDriverProbe : IDriverProbe
{
@@ -80,7 +83,16 @@ public sealed class SqlDriverProbe : IDriverProbe
}
catch (Exception ex) when (ex is System.Text.Json.JsonException or ArgumentNullException)
{
return new DriverProbeResult(false, $"Config JSON is invalid: {ex.Message}", null);
// The parser's message is WITHHELD, matching SqlDriverBrowser.ParseDocument. Nothing has been
// extracted at this point, so there is no known secret to redact the message against — and the
// blob both types read is the one the AdminUI Sql picker composes, which carries a top-level
// `connectionString` literal. A malformed paste is therefore exactly the text that reaches here,
// and System.Text.Json quotes the offending token and states the JSON path and byte position.
return new DriverProbeResult(
false,
"Config JSON is not valid JSON. (The parser's message is withheld because the malformed "
+ "text may carry a connection string.)",
null);
}
if (dto is null)