d68dff0946
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>
558 lines
31 KiB
C#
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__<ref></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/<X>/apikey</c> ⇒ <c>X</c>; <c>secret:otopcua/galaxy/<X></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_<REST></c> ⇒ <c>REST</c>. The per-instance convention is
|
|
/// <c>env:<PREFIX><INSTANCE>_…</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-<12 hex>/…</c>, <c>env:<PREFIX>DRV_<12 HEX>[_…]</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-<12 hex></c> (case-insensitive), or an <c>env:</c> part <c>DRV_<12 HEX></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_<12 HEX></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/<other-instance>/apikey</c> (or
|
|
/// <c>env:OTOPCUA_GALAXY_<OTHER_INSTANCE>_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);
|