Files
lmxopcua/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverCredentialPolicy.cs
T
Joseph Doherty d68dff0946 fix(security): id-form credential references match by id only; id-shaped driver names refused (#849 review)
A driver named 'drv' (env form DRV) matched every sibling's id-form variable
env:<PREFIX>DRV_<12HEX>_... as 'another instance', breaking the recommended id
convention fleet-wide - including for a name that pre-dates the rule.
ReferenceNames is split into ReferenceNamesId / ReferenceNamesName; a reference
whose instance part is id-shaped (secret: DRV-<12 hex>, env: DRV_<12 HEX>[_...])
is matched by id only. DescribeNameChangeProblemsAsync also refuses new names
whose env form is DRV or starts with an id form (IsReservedInstanceName) at
create, /raw rename and config-modal update.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-11 06:35:52 -05:00

558 lines
31 KiB
C#

using System.Text.Json;
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>One credential field of a driver config and whether it may carry a literal.</summary>
/// <param name="Name">The JSON property name (matched case-insensitively, as every driver binds).</param>
/// <param name="Literals">The literal policy.</param>
public sealed record CredentialField(string Name, CredentialLiteralPolicy Literals);
/// <summary>The credential scope + fields of one driver type.</summary>
/// <param name="DriverType">The <see cref="DriverTypeNames"/> value.</param>
/// <param name="Scope">The namespace its references are confined to.</param>
/// <param name="Fields">The credential fields its DriverConfig/DeviceConfig may carry.</param>
/// <param name="PathFields">Fields naming a node-local key-material file, confined to a node directory (lmxopcua#836).</param>
/// <param name="EndpointFields">
/// The keys (any depth, case-insensitive) that say WHERE the driver sends its credentials — the endpoint a Test Connect
/// or browse dials (#835 review I-1: an unsaved endpoint may not borrow the stored driver's own secrets).
/// </param>
public sealed record DriverCredentialRules(
string DriverType, CredentialScope Scope, IReadOnlyList<CredentialField> Fields, IReadOnlyList<CredentialPathField>? PathFields = null,
IReadOnlyList<string>? EndpointFields = null)
{
/// <summary>The path fields (never null).</summary>
public IReadOnlyList<CredentialPathField> PathFieldList => PathFields ?? [];
/// <summary>The endpoint fields (never null).</summary>
public IReadOnlyList<string> EndpointFieldList => EndpointFields ?? [];
}
/// <summary>The identity of one driver instance, as the cross-instance rule (lmxopcua#835) matches it.</summary>
/// <param name="DriverInstanceId">The instance id.</param>
/// <param name="Name">The instance's display name.</param>
/// <param name="DriverType">The instance's driver type.</param>
public sealed record DriverInstanceIdentity(string DriverInstanceId, string Name, string DriverType);
/// <summary>
/// The per-driver credential namespaces (lmxopcua#815) — the single table the AdminUI save path, the deploy gate,
/// Test Connect, live browse and every driver's resolve-time check read.
/// </summary>
/// <remarks>
/// Every driver that RESOLVES a <c>secret:</c>/<c>env:</c> reference from operator-authored config and sends the
/// value to an operator-chosen endpoint is listed here. Drivers that resolve nothing (literal credentials only —
/// MQTT, MTConnect URI user-info) or whose resolved value carries its own endpoint (Sql's
/// <c>Sql__ConnectionStrings__&lt;ref&gt;</c>, already a fixed prefix) are not.
/// </remarks>
public static class DriverCredentialPolicy
{
/// <summary>Heidenhain LSV2 passwords: <c>secret:heidenhain/…</c> / <c>env:OTOPCUA_HEIDENHAIN_…</c> (#803).</summary>
public static readonly CredentialScope HeidenhainScope = new("Heidenhain", ["heidenhain/"], ["OTOPCUA_HEIDENHAIN_"]);
/// <summary>
/// Galaxy gateway API key + fallback ack password: <c>secret:galaxy/…</c> (or <c>secret:otopcua/galaxy/…</c>, the
/// form the ack-fallback docs used) / <c>env:OTOPCUA_GALAXY_…</c>, plus the
/// legacy <c>env:GALAXY_MXGW_…</c> prefix the docker-dev rig and the parity-rig docs already use
/// (<c>GALAXY_MXGW_API_KEY</c>) — a variable provisioned to be sent to an mxaccessgw.
/// </summary>
public static readonly CredentialScope GalaxyScope = new("Galaxy", ["galaxy/", "otopcua/galaxy/"], ["OTOPCUA_GALAXY_", "GALAXY_MXGW_"]);
/// <summary>OPC UA Client user / certificate passwords: <c>secret:opcuaclient/…</c> / <c>env:OTOPCUA_OPCUACLIENT_…</c>.</summary>
public static readonly CredentialScope OpcUaClientScope = new("OpcUaClient", ["opcuaclient/"], ["OTOPCUA_OPCUACLIENT_"]);
/// <summary>The node config key of the OpcUaClient user-certificate directory (lmxopcua#836).</summary>
public const string OpcUaClientUserCertificateDirectoryKey = "Drivers:OpcUaClient:UserCertificateDirectory";
/// <summary>OpcUaClient <c>UserCertificatePath</c>: a relative path inside the node's user-certificate directory (#836).</summary>
public static readonly CredentialPathField OpcUaClientUserCertificatePathField = new(
"UserCertificatePath",
OpcUaClientUserCertificateDirectoryKey + ", default <LocalAppData>/OtOpcUa/pki/user-certificates",
ActiveWhenKey: "AuthType", ActiveWhenValue: "Certificate", ActiveWhenOrdinal: 2);
/// <summary>Galaxy <c>Gateway.ApiKeySecretRef</c> (literal accepted for back-compat, with a warning at resolve).</summary>
public static readonly CredentialField GalaxyApiKeyField = new("ApiKeySecretRef", CredentialLiteralPolicy.Allowed);
/// <summary>Galaxy <c>AlarmAck.FallbackPasswordSecretRef</c> (reference or explicit <c>dev:</c> only).</summary>
public static readonly CredentialField GalaxyAckPasswordField = new("FallbackPasswordSecretRef", CredentialLiteralPolicy.DevPrefixOnly);
private static readonly IReadOnlyDictionary<string, DriverCredentialRules> Rules =
new Dictionary<string, DriverCredentialRules>(StringComparer.OrdinalIgnoreCase)
{
[DriverTypeNames.Heidenhain] = new(DriverTypeNames.Heidenhain, HeidenhainScope,
[
new("InspectPassword", CredentialLiteralPolicy.Refused),
new("DncPassword", CredentialLiteralPolicy.Refused),
new("DataPassword", CredentialLiteralPolicy.Refused),
], EndpointFields: ["Host", "Port"]),
[DriverTypeNames.Galaxy] = new(DriverTypeNames.Galaxy, GalaxyScope, [GalaxyApiKeyField, GalaxyAckPasswordField],
EndpointFields: ["Endpoint"]),
[DriverTypeNames.OpcUaClient] = new(DriverTypeNames.OpcUaClient, OpcUaClientScope,
[
new("Password", CredentialLiteralPolicy.Allowed),
new("UserCertificatePassword", CredentialLiteralPolicy.Allowed),
], [OpcUaClientUserCertificatePathField], ["EndpointUrl", "EndpointUrls", "DiscoveryUrl"]),
};
/// <summary>The rules for a driver type, if it resolves credential references.</summary>
/// <param name="driverType">The driver type.</param>
/// <param name="rules">The rules.</param>
/// <returns>Whether the type has rules.</returns>
public static bool TryGet(string? driverType, out DriverCredentialRules rules)
{
if (driverType is not null && Rules.TryGetValue(driverType, out var r))
{
rules = r;
return true;
}
rules = null!;
return false;
}
/// <summary>
/// Scans a RAW DriverConfig / DeviceConfig / merged config document for credential fields of
/// <paramref name="driverType"/> whose value is not acceptable — at ANY depth (a field is bound wherever the
/// driver's merge places it), keys matched case-insensitively. Messages name the field path, never the value.
/// Unreadable JSON is reported for a type with rules (it cannot be checked); other types return empty.
/// </summary>
/// <param name="driverType">The owning driver's type.</param>
/// <param name="json">The config JSON.</param>
/// <returns>The problems; empty when acceptable or the type has no credential rules.</returns>
public static IReadOnlyList<string> DescribeProblems(string? driverType, string? json)
{
var problems = new List<string>();
if (!TryGet(driverType, out var rules) || string.IsNullOrWhiteSpace(json)) return problems;
JsonDocument doc;
try
{
doc = JsonDocument.Parse(json, new JsonDocumentOptions { CommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true });
}
catch (JsonException)
{
problems.Add($"{rules.Scope.Label} config is not valid JSON, so its credential fields cannot be checked.");
return problems;
}
using (doc)
{
Scan(doc.RootElement, "", rules, problems, 0);
}
return problems;
}
private static void Scan(JsonElement element, string path, DriverCredentialRules rules, List<string> problems, int depth) =>
Visit(element, path, rules, depth, (fieldPath, value, field, pathField) =>
{
if (field is not null)
{
var ok = value.ValueKind switch
{
JsonValueKind.Null => true,
JsonValueKind.String => CredentialReferences.IsAcceptable(value.GetString(), rules.Scope, field.Literals),
_ => false,
};
if (!ok) problems.Add(CredentialReferences.RuleMessage(fieldPath, rules.Scope, field.Literals));
}
else if (pathField is not null)
{
var ok = value.ValueKind switch
{
JsonValueKind.Null => true,
JsonValueKind.String => CredentialFilePaths.IsAcceptable(value.GetString()),
_ => false,
};
if (!ok) problems.Add(CredentialFilePaths.RuleMessage(fieldPath, pathField.DirectoryDescription));
}
});
/// <summary>Walks every credential / path field of <paramref name="rules"/> at any depth, keys matched case-insensitively.</summary>
private static void Visit(
JsonElement element, string path, DriverCredentialRules rules, int depth,
Action<string, JsonElement, CredentialField?, CredentialPathField?> onField)
{
if (depth > 32) return;
if (element.ValueKind == JsonValueKind.Array)
{
var i = 0;
foreach (var item in element.EnumerateArray())
{
Visit(item, $"{path}[{i}]", rules, depth + 1, onField);
i++;
}
return;
}
if (element.ValueKind != JsonValueKind.Object) return;
foreach (var p in element.EnumerateObject())
{
var fieldPath = path.Length == 0 ? p.Name : $"{path}.{p.Name}";
var field = rules.Fields.FirstOrDefault(f => string.Equals(f.Name, p.Name, StringComparison.OrdinalIgnoreCase));
var pathField = field is null
? rules.PathFieldList.FirstOrDefault(f => string.Equals(f.Name, p.Name, StringComparison.OrdinalIgnoreCase))
: null;
if (field is null && pathField is null)
{
Visit(p.Value, fieldPath, rules, depth + 1, onField);
continue;
}
// #836 review L-1: a path field explicitly switched off in its own object (OpcUaClient AuthType ≠ Certificate)
// is never loaded by the driver, so it is not checked; an absent switch keeps it checked.
if (pathField is not null && !pathField.IsActiveIn(element)) continue;
onField(fieldPath, p.Value, field, pathField);
}
}
// ---------------------------------------------------------------------------------------------------------------
// lmxopcua#835 — cross-instance references inside one namespace
// ---------------------------------------------------------------------------------------------------------------
/// <summary>
/// The instance-identifying segment of an in-scope <c>secret:</c> reference: the first path segment after the
/// matched namespace (<c>secret:galaxy/&lt;X&gt;/apikey</c> ⇒ <c>X</c>; <c>secret:otopcua/galaxy/&lt;X&gt;</c> ⇒
/// <c>X</c>). Null for anything else — <c>env:</c> references use <see cref="EnvironmentInstancePart"/>, and literals
/// and out-of-scope references are the #815 rule's business.
/// </summary>
/// <param name="value">The authored field value.</param>
/// <param name="scope">The driver's scope.</param>
/// <returns>The segment, or null.</returns>
public static string? InstanceSegment(string? value, CredentialScope scope)
{
ArgumentNullException.ThrowIfNull(scope);
if (CredentialReferences.Parse(value, scope, out var name) != CredentialReferenceKind.Secret) return null;
var ns = scope.SecretNamespaces
.Where(n => name.StartsWith(n, StringComparison.Ordinal))
.OrderByDescending(n => n.Length)
.FirstOrDefault();
if (ns is null) return null;
var rest = name[ns.Length..];
var slash = rest.IndexOf('/');
return slash < 0 ? rest : rest[..slash];
}
/// <summary>
/// lmxopcua#849: the part of an in-scope <c>env:</c> reference after the matched (longest) prefix —
/// <c>env:OTOPCUA_GALAXY_&lt;REST&gt;</c> ⇒ <c>REST</c>. The per-instance convention is
/// <c>env:&lt;PREFIX&gt;&lt;INSTANCE&gt;_…</c>, INSTANCE being a driver id or name in
/// <see cref="NormalizeEnvironmentIdentifier"/> form; the part NAMES an instance when it equals that form or starts
/// with it followed by <c>_</c> (see <see cref="ReferenceNamesId"/> / <see cref="ReferenceNamesName"/>). Null for anything but an in-scope <c>env:</c>.
/// </summary>
/// <param name="value">The authored field value.</param>
/// <param name="scope">The driver's scope.</param>
/// <returns>The remainder, or null.</returns>
public static string? EnvironmentInstancePart(string? value, CredentialScope scope)
{
ArgumentNullException.ThrowIfNull(scope);
if (CredentialReferences.Parse(value, scope, out var name) != CredentialReferenceKind.Environment) return null;
var prefix = scope.EnvVariablePrefixes
.Where(p => name.StartsWith(p, StringComparison.Ordinal))
.OrderByDescending(p => p.Length)
.FirstOrDefault();
return prefix is null ? null : name[prefix.Length..];
}
/// <summary>
/// lmxopcua#849: a driver id or name in environment-variable form — ASCII letters upper-cased, digits kept, every
/// other character <c>_</c> (<c>DRV-1a2b</c> ⇒ <c>DRV_1A2B</c>, <c>Line 1</c> ⇒ <c>LINE_1</c>). Empty when the
/// identifier has no letter or digit (such an identifier names no variable).
/// </summary>
/// <param name="identifier">The id or name.</param>
/// <returns>The normalised form.</returns>
public static string NormalizeEnvironmentIdentifier(string? identifier)
{
if (string.IsNullOrEmpty(identifier) || !identifier.Any(char.IsAsciiLetterOrDigit)) return "";
return string.Create(identifier.Length, identifier, static (span, id) =>
{
for (var i = 0; i < id.Length; i++)
span[i] = char.IsAsciiLetterOrDigit(id[i]) ? char.ToUpperInvariant(id[i]) : '_';
});
}
/// <summary>The prefix of every system-generated driver instance id (<c>DRV-</c> + 12 hex, <c>RawTreeService.NewId</c>).</summary>
public const string DriverIdPrefix = "DRV";
private const int DriverIdHexLength = 12;
/// <summary>
/// Whether an instance reference (<see cref="CollectInstanceReferences"/>) names the driver id
/// <paramref name="driverInstanceId"/>: a <c>secret:</c> segment equal to it (case-insensitive), or an <c>env:</c>
/// part equal to its <see cref="NormalizeEnvironmentIdentifier"/> form or starting with that form plus <c>_</c>.
/// </summary>
/// <param name="reference">The reference.</param>
/// <param name="driverInstanceId">A driver instance id.</param>
/// <returns>Whether it names it.</returns>
public static bool ReferenceNamesId(InstanceReference reference, string? driverInstanceId) =>
Matches(reference, driverInstanceId);
/// <summary>
/// Whether an instance reference names the driver NAME <paramref name="name"/> — as <see cref="ReferenceNamesId"/>,
/// except that a reference whose instance part has the shape of a system-generated id
/// (<see cref="IsIdShaped"/>: <c>secret:…/DRV-&lt;12 hex&gt;/…</c>, <c>env:&lt;PREFIX&gt;DRV_&lt;12 HEX&gt;[_…]</c>) is
/// matched by id only. Otherwise a driver NAMED <c>drv</c> (env form <c>DRV</c>) would "own" the id-form variable of
/// every other driver of its type, and the recommended id convention would break fleet-wide — including for a
/// name that pre-dates the rule (lmxopcua#849 review).
/// </summary>
/// <param name="reference">The reference.</param>
/// <param name="name">A driver name.</param>
/// <returns>Whether it names it.</returns>
public static bool ReferenceNamesName(InstanceReference reference, string? name) =>
!IsIdShaped(reference) && Matches(reference, name);
/// <summary>
/// Whether the reference's instance part has the shape of a system-generated driver id: a <c>secret:</c> segment
/// <c>DRV-&lt;12 hex&gt;</c> (case-insensitive), or an <c>env:</c> part <c>DRV_&lt;12 HEX&gt;</c> alone or followed by <c>_</c>.
/// </summary>
/// <param name="reference">The reference.</param>
/// <returns>Whether it is id-shaped.</returns>
public static bool IsIdShaped(InstanceReference reference) =>
reference.Kind == CredentialReferenceKind.Secret
? reference.Part.Length == DriverIdPrefix.Length + 1 + DriverIdHexLength && StartsWithIdForm(reference.Part, '-', ignoreCase: true)
: StartsWithIdForm(reference.Part, '_', ignoreCase: false)
&& (reference.Part.Length == DriverIdPrefix.Length + 1 + DriverIdHexLength
|| reference.Part[DriverIdPrefix.Length + 1 + DriverIdHexLength] == '_');
/// <summary>
/// lmxopcua#849 review: a driver NAME that may not be given to a credential-bearing driver — one whose environment
/// form (<see cref="NormalizeEnvironmentIdentifier"/>) is <c>DRV</c> or starts with an id form <c>DRV_&lt;12 HEX&gt;</c>.
/// Such a name would collide with the system-generated id convention every driver's own references are
/// recommended to use.
/// </summary>
/// <param name="name">The proposed name.</param>
/// <returns>Whether the name is reserved.</returns>
public static bool IsReservedInstanceName(string? name)
{
var env = NormalizeEnvironmentIdentifier(name);
return env == DriverIdPrefix
|| IsIdShaped(new InstanceReference("", CredentialReferenceKind.Environment, env));
}
private static bool StartsWithIdForm(string part, char separator, bool ignoreCase)
{
var length = DriverIdPrefix.Length + 1 + DriverIdHexLength;
if (part.Length < length
|| !part.StartsWith(DriverIdPrefix, ignoreCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal)
|| part[DriverIdPrefix.Length] != separator)
return false;
for (var i = DriverIdPrefix.Length + 1; i < length; i++)
{
var c = part[i];
if (!(char.IsAsciiDigit(c) || c is >= 'A' and <= 'F' || (ignoreCase && c is >= 'a' and <= 'f'))) return false;
}
return true;
}
private static bool Matches(InstanceReference reference, string? identifier)
{
if (string.IsNullOrEmpty(identifier)) return false;
if (reference.Kind == CredentialReferenceKind.Secret)
return string.Equals(reference.Part, identifier, StringComparison.OrdinalIgnoreCase);
var normalized = NormalizeEnvironmentIdentifier(identifier);
return normalized.Length > 0
&& (string.Equals(reference.Part, normalized, StringComparison.Ordinal)
|| reference.Part.StartsWith(normalized + "_", StringComparison.Ordinal));
}
/// <summary>
/// True when the config carries at least one in-scope <c>secret:</c> or <c>env:</c> reference — i.e. the
/// cross-instance rule (<see cref="DescribeOtherInstanceProblems"/>) has something to check, so a seam must load
/// the instance list. Lets seams skip the ConfigDb read otherwise.
/// </summary>
/// <param name="driverType">The owning driver's type.</param>
/// <param name="json">The config JSON.</param>
/// <returns>Whether any instance reference is present.</returns>
public static bool HasInstanceSegments(string? driverType, string? json) =>
CollectInstanceReferences(driverType, json).Count > 0;
/// <summary>
/// lmxopcua#835/#849: inside one namespace, a reference that names (see <see cref="ReferenceNamesId"/> / <see cref="ReferenceNamesName"/>) a DIFFERENT
/// existing instance of the same driver type — by its <see cref="DriverInstanceIdentity.DriverInstanceId"/> or its
/// <see cref="DriverInstanceIdentity.Name"/> — is refused: otherwise a Designer could create (or Test Connect /
/// browse) a driver that points at their own endpoint with <c>secret:galaxy/&lt;other-instance&gt;/apikey</c> (or
/// <c>env:OTOPCUA_GALAXY_&lt;OTHER_INSTANCE&gt;_KEY</c>) and capture that instance's key. Shared names that name no
/// other instance (<c>secret:galaxy/gw/apikey</c>, <c>env:GALAXY_MXGW_API_KEY</c>) stay valid, as does a reference
/// naming the instance itself.
/// </summary>
/// <remarks>
/// A match against ANOTHER instance wins over a match against the owner — names are not unique, and renaming
/// one's own driver after a victim's must not unlock the victim's secret. <paramref name="ownDriverInstanceId"/>
/// is null for a driver that does not exist yet (every listed instance is then "another").
/// </remarks>
/// <param name="driverType">The owning driver's type.</param>
/// <param name="json">The DriverConfig / DeviceConfig / merged config JSON.</param>
/// <param name="ownDriverInstanceId">The driver the config belongs to; null when it is being created.</param>
/// <param name="instances">The known driver instances (any type; the owner may be included).</param>
/// <returns>The problems; empty when acceptable, unreadable, or the type has no rules.</returns>
public static IReadOnlyList<string> DescribeOtherInstanceProblems(
string? driverType, string? json, string? ownDriverInstanceId, IEnumerable<DriverInstanceIdentity> instances)
{
ArgumentNullException.ThrowIfNull(instances);
var problems = new List<string>();
if (!TryGet(driverType, out var rules)) return problems;
var references = CollectInstanceReferences(driverType, json);
if (references.Count == 0) return problems;
var others = instances
.Where(i => string.Equals(i.DriverType, rules.DriverType, StringComparison.OrdinalIgnoreCase)
&& !string.Equals(i.DriverInstanceId, ownDriverInstanceId, StringComparison.Ordinal))
.ToList();
foreach (var reference in references)
{
var other = others.FirstOrDefault(o => ReferenceNamesId(reference, o.DriverInstanceId) || ReferenceNamesName(reference, o.Name));
if (other is null) continue;
problems.Add(reference.Kind == CredentialReferenceKind.Secret
? $"{reference.FieldPath} names a secret under '{reference.Part}/', which is another {rules.Scope.Label} driver "
+ $"instance ('{other.Name}', {other.DriverInstanceId}) — a driver may reference only its own secrets "
+ $"(secret:{rules.Scope.SecretNamespaces[0]}<this-driver-id>/…) or a shared name that is no other "
+ "instance's id or name (lmxopcua#835)."
: $"{reference.FieldPath} names an environment variable of another {rules.Scope.Label} driver instance "
+ $"('{other.Name}', {other.DriverInstanceId}) — a driver may reference only its own variables "
+ $"(env:{rules.Scope.EnvVariablePrefixes[0]}<THIS_DRIVER_ID>_…, the id upper-cased with every other "
+ "character as '_') or a shared name that starts with no other instance's id or name (lmxopcua#849).");
}
return problems;
}
/// <summary>
/// Both rules at once — <see cref="DescribeProblems(string?, string?)"/> (namespace, literals, path fields) followed
/// by <see cref="DescribeOtherInstanceProblems"/> — as every seam that knows the owner and the instance list applies them.
/// </summary>
/// <param name="driverType">The owning driver's type.</param>
/// <param name="json">The config JSON.</param>
/// <param name="ownDriverInstanceId">The owning driver; null when it is being created.</param>
/// <param name="instances">The known driver instances.</param>
/// <returns>The problems.</returns>
public static IReadOnlyList<string> DescribeProblems(
string? driverType, string? json, string? ownDriverInstanceId, IEnumerable<DriverInstanceIdentity> instances) =>
[.. DescribeProblems(driverType, json), .. DescribeOtherInstanceProblems(driverType, json, ownDriverInstanceId, instances)];
/// <summary>
/// True when the config carries at least one in-scope credential REFERENCE (<c>secret:</c> or <c>env:</c>) — the
/// Test Connect / browse audit trigger (#835 review I-1).
/// </summary>
/// <param name="driverType">The driver type.</param>
/// <param name="json">The config JSON.</param>
/// <returns>Whether a reference that would be resolved is present.</returns>
public static bool HasCredentialReferences(string? driverType, string? json)
{
if (!TryGet(driverType, out var rules) || string.IsNullOrWhiteSpace(json)) return false;
var found = false;
WithDocument(json, root => Visit(root, "", rules, 0, (_, value, field, _) =>
{
if (field is null || value.ValueKind != JsonValueKind.String) return;
if (CredentialReferences.Parse(value.GetString(), rules.Scope, out _) is CredentialReferenceKind.Secret or CredentialReferenceKind.Environment)
found = true;
}));
return found;
}
/// <summary>
/// The endpoint values (see <see cref="DriverCredentialRules.EndpointFields"/>) of a config — every string / number
/// under an endpoint key at any depth (arrays flattened), trimmed. Empty for an unreadable config or a type without
/// rules.
/// </summary>
/// <param name="driverType">The driver type.</param>
/// <param name="json">The config JSON.</param>
/// <returns>The values, in document order.</returns>
public static IReadOnlyList<string> EndpointValues(string? driverType, string? json)
{
var values = new List<string>();
if (!TryGet(driverType, out var rules) || rules.EndpointFieldList.Count == 0 || string.IsNullOrWhiteSpace(json)) return values;
WithDocument(json, root => CollectEndpoints(root, rules, values, 0, false));
return values;
}
/// <summary>
/// #835 review I-1: whether every endpoint value of <paramref name="submittedJson"/> (a Test Connect / browse config,
/// possibly carrying UNSAVED edits) is one the driver already stores (its DriverConfig or any of its DeviceConfigs).
/// If not, the caller must not treat the stored driver as the config's owner: its own secrets would otherwise be sent
/// to an endpoint nobody saved. A type without endpoint fields never matches (conservative).
/// </summary>
/// <param name="driverType">The driver type.</param>
/// <param name="submittedJson">The config about to be probed / browsed.</param>
/// <param name="storedJsons">The owner's stored DriverConfig + DeviceConfigs.</param>
/// <returns>Whether the submitted endpoints are all stored ones.</returns>
public static bool EndpointsMatchStored(string? driverType, string? submittedJson, IEnumerable<string?> storedJsons)
{
ArgumentNullException.ThrowIfNull(storedJsons);
if (!TryGet(driverType, out var rules) || rules.EndpointFieldList.Count == 0) return false;
var allowed = new HashSet<string>(storedJsons.SelectMany(s => EndpointValues(driverType, s)), StringComparer.OrdinalIgnoreCase);
return EndpointValues(driverType, submittedJson).All(allowed.Contains);
}
private static void CollectEndpoints(JsonElement element, DriverCredentialRules rules, List<string> values, int depth, bool under)
{
if (depth > 32) return;
switch (element.ValueKind)
{
case JsonValueKind.Array:
foreach (var item in element.EnumerateArray()) CollectEndpoints(item, rules, values, depth + 1, under);
return;
case JsonValueKind.Object:
foreach (var p in element.EnumerateObject())
{
var isEndpoint = rules.EndpointFieldList.Any(f => string.Equals(f, p.Name, StringComparison.OrdinalIgnoreCase));
CollectEndpoints(p.Value, rules, values, depth + 1, isEndpoint);
}
return;
case JsonValueKind.String when under:
if (element.GetString() is { } s && s.Trim().Length > 0) values.Add(s.Trim());
return;
case JsonValueKind.Number when under:
values.Add(element.GetRawText());
return;
}
}
private static void WithDocument(string json, Action<JsonElement> use)
{
JsonDocument doc;
try
{
doc = JsonDocument.Parse(json, new JsonDocumentOptions { CommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true });
}
catch (JsonException)
{
return;
}
using (doc) use(doc.RootElement);
}
/// <summary>
/// Every in-scope instance reference of a config (lmxopcua#835 <c>secret:</c> segments + #849 <c>env:</c> parts), at
/// any depth. Empty for an unreadable config or a type without rules.
/// </summary>
/// <param name="driverType">The owning driver's type.</param>
/// <param name="json">The config JSON.</param>
/// <returns>The references, in document order.</returns>
public static IReadOnlyList<InstanceReference> CollectInstanceReferences(string? driverType, string? json)
{
var found = new List<InstanceReference>();
if (!TryGet(driverType, out var rules) || string.IsNullOrWhiteSpace(json)) return found;
// Unreadable JSON is reported by DescribeProblems.
WithDocument(json, root => Visit(root, "", rules, 0, (fieldPath, value, field, _) =>
{
if (field is null || value.ValueKind != JsonValueKind.String) return;
var text = value.GetString();
if (InstanceSegment(text, rules.Scope) is { Length: > 0 } segment)
found.Add(new(fieldPath, CredentialReferenceKind.Secret, segment));
else if (EnvironmentInstancePart(text, rules.Scope) is { Length: > 0 } part)
found.Add(new(fieldPath, CredentialReferenceKind.Environment, part));
}));
return found;
}
}
/// <summary>The instance-identifying part of one in-scope credential reference (lmxopcua#835 / #849).</summary>
/// <param name="FieldPath">Where it sits in the config (never the value).</param>
/// <param name="Kind"><see cref="CredentialReferenceKind.Secret"/> or <see cref="CredentialReferenceKind.Environment"/>.</param>
/// <param name="Part">
/// The <c>secret:</c> instance segment (<see cref="DriverCredentialPolicy.InstanceSegment"/>) or the <c>env:</c> remainder
/// after the prefix (<see cref="DriverCredentialPolicy.EnvironmentInstancePart"/>).
/// </param>
public readonly record struct InstanceReference(string FieldPath, CredentialReferenceKind Kind, string Part);