using System.Text.Json;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Validation;
///
/// The one place that decides whether a persisted config blob carries a literal Sql
/// connectionString (Gitea #498 / #499).
///
///
///
/// A Sql driver names its credentials indirectly — connectionStringRef resolves from
/// the environment / secret store at Initialize — so nothing persisted should ever contain a
/// database password. The typed SqlDriverConfigDto already drops a connectionString
/// key on read, but discarding a secret on read is not the same as refusing to store it:
/// config blobs are schemaless JSON columns authored through raw-JSON textareas, so the key can
/// be written even though the runtime ignores it.
///
///
/// There are three surfaces a Sql driver's config is persisted on, and they need different
/// enforcement points:
///
/// - DriverInstance.DriverConfig and Device.DeviceConfig — both in the
/// deployed artifact, both visible to DraftSnapshot, both gated by
/// DraftValidator.
/// - ClusterNode.DriverConfigOverridesJson — the per-node override map. It is
/// not in DraftSnapshot, and deliberately does not go there: adding a
/// ClusterNode collection would widen the snapshot (and every builder of one) for a
/// single rule about a value that is not part of the artifact at all. More to the point, a
/// deploy gate is the wrong instrument here — the artifact never carries node overrides, so
/// blocking a deploy would not stop the credential being stored. It is already in the
/// database by then. This surface is therefore gated at the save, which is the only
/// point where "refuse to store it" is literally true.
///
///
///
/// Deliberately narrow. This checks the Sql driver's connectionString key
/// only — not a general "credential-shaped key" sweep over every driver type. A broader rule
/// would start refusing configs that are legitimate today for drivers that never made this
/// guarantee, turning defence-in-depth into a regression. Widen it per driver, as each driver
/// gains an indirect-credential contract of its own.
///
///
public static class SqlCredentialGuard
{
/// The config key a Sql driver must never persist. Matched case-insensitively.
public const string ForbiddenKey = "connectionString";
///
/// Whether is a JSON object carrying at
/// its top level.
///
///
/// Matched case-insensitively: System.Text.Json binds ConnectionString to a
/// connectionString property by default, so a case variant is the same key, not a
/// different one. Only the top level is scanned — the DTO is flat, so a nested occurrence cannot
/// bind and is not the credential-shaped mistake this exists to catch.
///
/// The config blob to inspect.
/// when the key is present at the top level.
public static bool CarriesLiteralConnectionString(string? configJson)
{
if (string.IsNullOrWhiteSpace(configJson)) return false;
try
{
using var doc = JsonDocument.Parse(configJson);
return doc.RootElement.ValueKind == JsonValueKind.Object
&& HasTopLevelKey(doc.RootElement, ForbiddenKey);
}
catch (JsonException)
{
// A malformed blob simply has no keys. Shaping the config JSON is another rule's job, and
// throwing here would turn a formatting mistake into a credential-guard failure.
return false;
}
}
///
/// The DriverInstanceIds in a ClusterNode.DriverConfigOverridesJson map whose
/// override object carries a literal connection string, restricted to instances that are actually
/// Sql drivers.
///
///
/// The override map is shaped { "<DriverInstanceId>": { …driver config keys… } } and is
/// merged onto the cluster-level DriverConfig, so a credential pasted into one lands in
/// exactly the place already refuses on the driver
/// itself.
///
/// The node's override map. Null / blank / malformed yields no
/// violations.
/// The ids of driver instances whose DriverType is
/// Sql. Keys outside this set are ignored — a non-Sql driver never made the indirect-credential
/// guarantee, so flagging it would be a regression, not defence in depth.
/// The offending driver-instance ids, in document order; empty when there are none.
public static IReadOnlyList FindNodeOverrideViolations(
string? overridesJson, IReadOnlyCollection sqlDriverInstanceIds)
{
if (string.IsNullOrWhiteSpace(overridesJson) || sqlDriverInstanceIds.Count == 0)
{
return [];
}
var sqlIds = sqlDriverInstanceIds as IReadOnlySet
?? sqlDriverInstanceIds.ToHashSet(StringComparer.Ordinal);
try
{
using var doc = JsonDocument.Parse(overridesJson);
if (doc.RootElement.ValueKind != JsonValueKind.Object) return [];
List? offenders = null;
foreach (var entry in doc.RootElement.EnumerateObject())
{
if (!sqlIds.Contains(entry.Name)) continue;
if (entry.Value.ValueKind != JsonValueKind.Object) continue;
if (!HasTopLevelKey(entry.Value, ForbiddenKey)) continue;
(offenders ??= []).Add(entry.Name);
}
return (IReadOnlyList?)offenders ?? [];
}
catch (JsonException)
{
return [];
}
}
/// Whether carries at its top level,
/// case-insensitively.
private static bool HasTopLevelKey(JsonElement obj, string key)
{
foreach (var property in obj.EnumerateObject())
{
if (string.Equals(property.Name, key, StringComparison.OrdinalIgnoreCase)) return true;
}
return false;
}
}