using System.Data.Common;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser;
///
/// Opens a transient database connection from form-supplied JSON for the AdminUI schema picker,
/// and hands it to a . The AdminUI's BrowseSessionRegistry (its idle
/// TTL reaper included) owns the returned session unchanged — nothing about the SQL browse needs a
/// registry of its own.
/// Two ways to name the database, and only one of them is persisted.
/// connectionStringRef is the deployed form: a name resolved in the AdminUI process from
/// the environment as Sql__ConnectionStrings__<ref> (design §4.4), so a deployed artifact
/// never carries credentials. connectionString is an ad-hoc, session-only literal an
/// operator may paste to browse a database that has no ref yet; it is used for this one connection and
/// then forgotten — there is deliberately no cached-config field on this type, and nothing writes it
/// anywhere.
/// Precedence: a pasted literal wins over a ref, and the ref is then not even read. The
/// literal cannot arrive from a persisted blob — has no such property, so
/// 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 ref, never any connection text) so
/// the override is visible rather than silent.
/// Credential hygiene is the point of this type. 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
/// own errors, every failure on the open path goes through before it leaves this
/// class.
///
public sealed class SqlDriverBrowser : IDriverBrowser
{
///
/// Prefix of the environment variable a connectionStringRef resolves through — the
/// double-underscore form .NET configuration uses for the Sql:ConnectionStrings:<ref>
/// key path.
///
public const string ConnectionStringEnvironmentPrefix = "Sql__ConnectionStrings__";
///
/// Hard cap on the connect phase, layered on the caller's token. The AdminUI already bounds each
/// browse call at 20 s; this only stops a pathological provider-side connect from outliving the
/// picker entirely.
///
private static readonly TimeSpan ConnectBudget = TimeSpan.FromSeconds(30);
///
/// Connection-string keys whose values are redacted out of any provider error text. The
/// whole connection string is redacted unconditionally; these are the parts that could survive as a
/// fragment. Server / database names are deliberately not redacted — "server X was not found"
/// is the diagnostic the operator needs, and a hostname is not a credential.
///
private static readonly string[] CredentialKeys =
[
"password", "pwd", "user id", "uid", "user", "username", "accesstoken", "access token",
];
///
/// Kept deliberately identical in shape to the driver factory's options (design §5.1): enum-valued
/// knobs are authored as their names, so the browser parses a given DriverConfig blob
/// exactly as the runtime factory does. also accepts ordinals,
/// so a numeric config still binds.
///
private static readonly JsonSerializerOptions JsonOpts = new()
{
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
PropertyNameCaseInsensitive = true,
Converters = { new JsonStringEnumConverter() },
};
private readonly Func _dialectSelector;
private readonly Func _environmentReader;
private readonly ILogger _logger;
/// Creates a browser. Every dependency has a production default, so DI may construct it bare.
///
/// Maps an authored onto the dialect that browses it, or
/// for a provider this build does not carry. Defaults to
/// (SqlServer only, in v1). A constructor parameter rather than a test hatch: it is also how a P2
/// provider gets added without touching this class.
///
///
/// Reads one environment variable. Defaults to .
/// Injectable because environment variables are process-global, and a test that sets one races every
/// other test in the assembly.
///
/// Optional; defaults to .
public SqlDriverBrowser(
Func? dialectSelector = null,
Func? environmentReader = null,
ILogger? logger = null)
{
_dialectSelector = dialectSelector ?? DefaultDialectSelector;
_environmentReader = environmentReader ?? Environment.GetEnvironmentVariable;
_logger = logger ?? NullLogger.Instance;
}
///
///
/// Sourced from , the driver's interim local constant, rather
/// than from DriverTypeNames — the shared constant set is added by the driver-factory task,
/// because adding a member there reddens DriverTypeNamesGuardTests until a factory is
/// registered against it. Repointing that one constant repoints this too.
///
public string DriverType => SqlDriver.DriverTypeName;
/// The environment variable a connectionStringRef resolves through.
/// The authored reference name.
/// The full environment-variable name, e.g. Sql__ConnectionStrings__MesStaging.
public static string EnvironmentVariableFor(string connectionStringRef) =>
ConnectionStringEnvironmentPrefix + connectionStringRef;
///
/// The providers this build can browse. v1 constructs SQL Server only; every other
/// member is a reserved name, so it resolves to and
/// the caller reports it as unavailable rather than crashing on a null dialect.
///
/// The authored provider.
/// The dialect, or when this build does not carry the provider.
public static ISqlDialect? DefaultDialectSelector(SqlProvider provider) =>
provider == SqlProvider.SqlServer ? new SqlServerDialect() : null;
///
///
/// The configuration is absent / malformed, names a provider this build does not carry, names neither
/// a connectionStringRef nor a literal, resolves a ref that is not set in the environment, or
/// the connection could not be opened.
///
public async Task OpenAsync(string configJson, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(configJson))
{
throw new InvalidOperationException(
"The Sql browser requires a driver configuration; none was supplied.");
}
var literal = ReadPastedConnectionString(configJson);
var config = Deserialize(configJson, literal);
// Provider first: "this build cannot browse Postgres" is true regardless of how the database is
// named, and answering it before touching the environment keeps the two failures from masking.
var dialect = _dialectSelector(config.Provider)
?? throw new InvalidOperationException(
$"Sql provider '{config.Provider}' is not available in this build; v1 browses "
+ $"'{SqlProvider.SqlServer}' only.");
var reference = config.ConnectionStringRef;
var connectionString = ResolveConnectionString(literal, reference);
_logger.LogInformation(
"AdminUI Sql browse session opening (provider {Provider}, connection from {Source})",
config.Provider,
DescribeSource(literal, reference));
return await OpenSessionAsync(dialect, connectionString, cancellationToken).ConfigureAwait(false);
}
///
/// Opens one transient connection and hands ownership to the session. The session closes the
/// connection on its own disposal, so the only disposal this method owns is the failure path —
/// disposing on success would double-close and leave the picker with a dead session.
///
private static async Task OpenSessionAsync(
ISqlDialect dialect, string connectionString, CancellationToken cancellationToken)
{
var connection = dialect.Factory.CreateConnection()
?? throw new InvalidOperationException(
$"The ADO.NET provider for '{dialect.Provider}' returned no connection object.");
try
{
connection.ConnectionString = connectionString;
using var connectCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
connectCts.CancelAfter(ConnectBudget);
await connection.OpenAsync(connectCts.Token).ConfigureAwait(false);
return new SqlBrowseSession(connection, dialect);
}
catch (Exception ex)
{
try { await connection.DisposeAsync().ConfigureAwait(false); }
catch { /* best-effort: the original failure is the useful one. */ }
if (ex is OperationCanceledException) throw;
throw Sanitize(ex, connectionString);
}
}
///
/// Picks the connection string, literal first. A blank literal is treated as absent (an untouched
/// form field), never as an empty connection string.
///
private string ResolveConnectionString(string? literal, string? reference)
{
if (!string.IsNullOrWhiteSpace(literal))
{
if (!string.IsNullOrWhiteSpace(reference))
{
_logger.LogWarning(
"Sql browse: a pasted connection string was supplied alongside connectionStringRef "
+ "'{Ref}'. The pasted value wins for this session only and is not persisted.",
reference);
}
return literal;
}
if (string.IsNullOrWhiteSpace(reference))
{
throw new InvalidOperationException(
"The Sql browser needs a database to browse: set 'connectionStringRef' to a name resolved "
+ $"from the environment as '{ConnectionStringEnvironmentPrefix}[', or paste a "
+ "connection string into 'connectionString' for an ad-hoc browse.");
}
var variable = EnvironmentVariableFor(reference);
var resolved = _environmentReader(variable);
if (string.IsNullOrWhiteSpace(resolved))
{
// Name the exact variable: "the ref did not resolve" is unactionable, and the operator's next
// move is to set this one name on the AdminUI host.
throw new InvalidOperationException(
$"connectionStringRef '{reference}' does not resolve: environment variable "
+ $"'{variable}' is not set on the AdminUI host. Set it there (the AdminUI resolves browse "
+ "connection strings in its own process), or paste a connection string for an ad-hoc "
+ "browse.");
}
return resolved;
}
/// How the connection string was obtained, for the log. Carries no connection text.
private static string DescribeSource(string? literal, string? reference) =>
!string.IsNullOrWhiteSpace(literal)
? "a pasted literal (session-only, not persisted)"
: $"connectionStringRef '{reference}'";
///
/// Pulls the ad-hoc connectionString literal out of the raw JSON. It is read here rather than
/// off the DTO because deliberately has no such property — the
/// persisted contract must not be able to carry a credential — and the DTO's
/// would silently drop it.
///
/// The literal, or when absent, blank, or not a JSON string.
private static string? ReadPastedConnectionString(string configJson)
{
using var document = ParseDocument(configJson);
if (document.RootElement.ValueKind != JsonValueKind.Object)
{
throw new InvalidOperationException(
"The Sql browser's driver configuration must be a JSON object.");
}
foreach (var property in document.RootElement.EnumerateObject())
{
if (!string.Equals(property.Name, "connectionString", StringComparison.OrdinalIgnoreCase))
continue;
if (property.Value.ValueKind != JsonValueKind.String) return null;
var value = property.Value.GetString();
return string.IsNullOrWhiteSpace(value) ? null : value;
}
return null;
}
///
/// Parses the raw configuration. The is not attached or echoed:
/// at this point nothing has been extracted, so there is no known secret to redact against, and the
/// malformed text may itself be a pasted connection string.
///
private static JsonDocument ParseDocument(string configJson)
{
try
{
return JsonDocument.Parse(configJson);
}
catch (JsonException)
{
throw new InvalidOperationException(
"The Sql browser's driver configuration is not valid JSON. (The parser's message is "
+ "withheld because the malformed text may carry a connection string.)");
}
}
///
/// Binds the typed configuration. A bind failure (e.g. an unknown provider name) is reported
/// with the parser's own message redacted against the literal, which is known by now.
///
private static SqlDriverConfigDto Deserialize(string configJson, string? literal)
{
try
{
return JsonSerializer.Deserialize(configJson, JsonOpts)
?? throw new InvalidOperationException(
"The Sql browser's driver configuration deserialized to null.");
}
catch (JsonException ex)
{
throw new InvalidOperationException(
"The Sql browser's driver configuration could not be bound: "
+ Redact(ex.Message, CollectSecrets(literal)));
}
}
///
/// Turns a provider-side open failure into an exception that cannot carry the connection string.
/// Live-probed, not assumed. Microsoft.Data.SqlClient and
/// Microsoft.Data.Sqlite keep connection-string values out of their
/// SqlException/SqliteException text (a failed connect says "the server was not found",
/// never what it was handed). Their connection-string parser is the exception: it reports an
/// unrecognised keyword by quoting it, and a value carrying an unquoted ; — legal inside a
/// password, which ADO.NET expects you to quote — splits, so the tail of the password is parsed as a
/// keyword and echoed verbatim. Password=Sup3r;SecretTail yields
/// Keyword not supported: 'secrettail;connect timeout'. — the credential, in the message,
/// lower-cased and therefore invisible to a naive ordinal search for the value.
/// So the parser's message (an ) is never surfaced. Every
/// other failure is additionally passed through the substring redactor as a backstop, and when that
/// fires anywhere in the exception chain the inner exception is dropped too — keeping it
/// would re-expose through ToString() exactly what the message just removed.
///
private static InvalidOperationException Sanitize(Exception ex, string connectionString)
{
if (ex is ArgumentException)
{
return new InvalidOperationException(
"The Sql browser could not open a connection: the connection string is malformed, or "
+ "carries a keyword this provider does not support. (The provider's own message is "
+ "withheld because it quotes connection-string text verbatim; check for an unquoted ';' "
+ "or '=' inside a value such as the password.)");
}
var secrets = CollectSecrets(connectionString);
var leaked = ContainsAny(ex.ToString(), secrets);
var reason = Redact(ex.Message, secrets);
var message = leaked
? "The Sql browser could not open a connection: " + reason
+ " (the provider's error echoed the connection string, so it was redacted and the inner "
+ "exception withheld)"
: "The Sql browser could not open a connection: " + reason;
return new InvalidOperationException(message, leaked ? null : ex);
}
///
/// The substrings that must never leave this class: the whole connection string, and the values of
/// its credential-bearing keys (which could surface on their own).
///
private static List CollectSecrets(string? connectionString)
{
var secrets = new List();
if (string.IsNullOrWhiteSpace(connectionString)) return secrets;
secrets.Add(connectionString);
try
{
var builder = new DbConnectionStringBuilder { ConnectionString = connectionString };
foreach (var key in CredentialKeys)
{
if (!builder.TryGetValue(key, out var value)) continue;
var text = value?.ToString();
if (!string.IsNullOrWhiteSpace(text)) secrets.Add(text);
}
}
catch (ArgumentException)
{
// A malformed connection string has no parseable parts; the whole-string entry still stands.
}
// Longest first, so redacting a short credential value cannot chop a longer secret into
// unredactable halves.
secrets.Sort(static (left, right) => right.Length.CompareTo(left.Length));
return secrets;
}
private static bool ContainsAny(string text, List secrets)
{
foreach (var secret in secrets)
{
if (text.Contains(secret, StringComparison.OrdinalIgnoreCase)) return true;
}
return false;
}
private static string Redact(string text, List secrets)
{
foreach (var secret in secrets)
{
text = text.Replace(secret, "[redacted]", StringComparison.OrdinalIgnoreCase);
}
return text;
}
}
]