namespace ZB.MOM.WW.Secrets.Replicator.SqlServer;
///
/// Configuration for the shared SQL-Server secret store and its hub replication. Bound from a
/// configuration section by the AddZbSecretsSqlServer* extensions.
///
public sealed class SqlServerSecretsOptions
{
private string _schemaName = SqlServerSecretsSchema.DefaultSchemaName;
///
/// Connection string for the shared secret database. Required. Supply it through a
/// ${secret:} / secret: reference like every other connection string in the family
/// — the shared store holds ciphertext only, but its credentials are still credentials.
///
public string ConnectionString { get; set; } = string.Empty;
///
/// SQL schema the secret tables live in. Defaults to zbsecrets so the secret tables never
/// collide with an application's own dbo objects when they share a database.
///
///
/// The value is not a plain identifier (letters, digits, and underscore, not starting with a
/// digit, at most 128 characters).
///
///
/// Validated on assignment rather than at use: the schema name is interpolated into DDL and
/// query text (T-SQL cannot parameterize an object name), so the allow-list is the injection
/// boundary and it must be impossible to get an unvalidated value into the property at all.
///
public string SchemaName
{
get => _schemaName;
set => _schemaName = ValidateIdentifier(value);
}
///
/// How often the sync service reconciles the local store against the hub, in hub-replication
/// mode. Defaults to 30 seconds. Ignored in shared-store mode (there is nothing to reconcile).
///
public TimeSpan SyncInterval { get; set; } = TimeSpan.FromSeconds(30);
///
/// When (the default), hub-replication mode runs a reconcile at startup
/// before the first interval elapses, so a node that was down while a peer wrote does not serve
/// stale secrets for a whole interval after it comes back.
///
public bool SyncOnStartup { get; set; } = true;
///
/// Command timeout applied to every store operation. Defaults to 30 seconds — long enough for a
/// contended shared database, short enough that a hung hub surfaces as an error rather than
/// stalling a resolve indefinitely.
///
public TimeSpan CommandTimeout { get; set; } = TimeSpan.FromSeconds(30);
///
/// Throws if the options are unusable. Called by the DI extensions at registration time so a
/// misconfigured node fails at startup rather than on the first secret resolve.
///
/// The connection string is missing, or an interval is not positive.
public void Validate()
{
if (string.IsNullOrWhiteSpace(ConnectionString))
{
throw new InvalidOperationException(
"SQL-Server secret store requires a ConnectionString (Secrets:SqlServer:ConnectionString).");
}
if (SyncInterval <= TimeSpan.Zero)
{
throw new InvalidOperationException(
$"SQL-Server secret replication SyncInterval must be positive (was {SyncInterval}).");
}
if (CommandTimeout <= TimeSpan.Zero)
{
throw new InvalidOperationException(
$"SQL-Server secret store CommandTimeout must be positive (was {CommandTimeout}).");
}
}
///
/// Strict allow-list for a SQL identifier that will be interpolated into DDL or query text.
/// Rejects everything that is not [A-Za-z_][A-Za-z0-9_]{0,127} — no brackets, quotes,
/// dots, or spaces — so no bracket-escape (]]) trick can break out of the quoting applied
/// at the use site. Uses the ASCII-only character tests deliberately, so homoglyph and
/// full-width look-alikes are rejected rather than accepted as "letters".
///
/// The candidate identifier.
/// The same value, once validated.
/// The value is not a plain identifier.
internal static string ValidateIdentifier(string value)
{
ArgumentException.ThrowIfNullOrWhiteSpace(value);
if (value.Length > 128)
{
throw new ArgumentException(
$"SQL schema name must be at most 128 characters (was {value.Length}).", nameof(value));
}
if (!char.IsAsciiLetter(value[0]) && value[0] != '_')
{
throw new ArgumentException(
$"SQL schema name must start with a letter or underscore (was '{value}').", nameof(value));
}
foreach (char c in value)
{
if (!char.IsAsciiLetterOrDigit(c) && c != '_')
{
throw new ArgumentException(
$"SQL schema name may contain only letters, digits, and underscore (was '{value}').",
nameof(value));
}
}
return value;
}
}