namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions; /// /// A driver-config field naming a node-local file that holds key material (lmxopcua#836 — OpcUaClient /// UserCertificatePath). The node loads the file and USES its private key against the author-chosen endpoint, /// so an unrestricted path lets a Designer make the node sign a hostile server's nonce with any PFX the service /// account can read. The field is therefore confined to one node-configured directory. /// /// The JSON property name (matched case-insensitively, as the driver binds it). /// How the operator configures the directory (for messages). /// /// Optional sibling key that switches the field on (OpcUaClient AuthType): the field is checked UNLESS the same /// JSON object sets this key to something other than / /// . An absent key keeps the field checked (conservative — the driver's merge may /// supply the switch from another config), so only an EXPLICITLY inactive object is exempt. /// /// The string value of that activates the field. /// The numeric (enum ordinal) value that activates the field. public sealed record CredentialPathField( string Name, string DirectoryDescription, string? ActiveWhenKey = null, string? ActiveWhenValue = null, int? ActiveWhenOrdinal = null) { /// Whether the field is in force in (the JSON object holding it). /// The object. /// False only when the object explicitly switches the field off. public bool IsActiveIn(System.Text.Json.JsonElement owner) { if (ActiveWhenKey is null || owner.ValueKind != System.Text.Json.JsonValueKind.Object) return true; foreach (var p in owner.EnumerateObject()) { if (!string.Equals(p.Name, ActiveWhenKey, StringComparison.OrdinalIgnoreCase)) continue; return p.Value.ValueKind switch { System.Text.Json.JsonValueKind.Null => true, System.Text.Json.JsonValueKind.String => string.Equals(p.Value.GetString(), ActiveWhenValue, StringComparison.OrdinalIgnoreCase), System.Text.Json.JsonValueKind.Number => !p.Value.TryGetInt32(out var n) || n == ActiveWhenOrdinal, _ => true, }; } return true; } } /// /// The one rule for values (lmxopcua#836): authored as a RELATIVE path inside a /// node-configured directory, checked syntactically at every admin seam (save, deploy gate, Test Connect, browse) and /// canonicalised — including symbolic links / junctions — at load time by . /// /// /// The admin seams cannot see the driver node's directory, so the authored form carries no directory at all: an /// absolute, rooted, drive-qualified or UNC path is refused there, as is any . / .. / empty segment or a /// character outside [A-Za-z0-9._-]. Resolution re-checks the syntax (fail-closed), joins it to the node's /// directory, and requires both the lexical result and the real (link-resolved) result to stay inside the real /// directory. /// public static class CredentialFilePaths { private const int MaxLength = 260; /// True when is blank or an acceptable relative path. /// The authored value. /// Whether it may be stored / deployed. public static bool IsAcceptable(string? value) => string.IsNullOrEmpty(value) || IsAcceptableRelative(value); /// True when is a non-empty relative path of plain segments. /// The authored value. /// Whether it is a relative path the resolver will accept. public static bool IsAcceptableRelative(string? value) { if (string.IsNullOrEmpty(value) || value.Length > MaxLength) return false; if (value[0] is '/' or '\\') return false; var segments = value.Split('/', '\\'); foreach (var seg in segments) { if (seg.Length == 0 || seg is "." or "..") return false; if (!seg.All(c => char.IsAsciiLetterOrDigit(c) || c is '-' or '_' or '.')) return false; } return !Path.IsPathRooted(value); } /// The one wording of the rule — names the field and the directory setting, never the value. /// The field path. /// How the directory is configured. /// The message. public static string RuleMessage(string field, string directoryDescription) => $"{field} must be blank or a relative path (e.g. 'plant1/user.pfx') inside the node's certificate directory " + $"({directoryDescription}) — an absolute, drive or UNC path, a '.'/'..' segment, or a character outside " + "[A-Za-z0-9._-] is refused (lmxopcua#836)."; /// /// Resolves an authored relative path inside , fail-closed: throws /// when the value is not an acceptable relative path, or when the /// result — lexically or after resolving every symbolic link / junction on the way — leaves the directory. /// /// The authored relative path. /// The node's configured directory. /// Field label for messages. /// How the directory is configured (for messages). /// The full path to load (the lexical path inside the directory). public static string ResolveWithin(string? value, string directory, string field, string directoryDescription) { ArgumentException.ThrowIfNullOrWhiteSpace(directory); if (!IsAcceptableRelative(value)) throw new CredentialReferenceException(RuleMessage(field, directoryDescription) + " (fail-closed)"); var root = Path.TrimEndingDirectorySeparator(Path.GetFullPath(directory)); var full = Path.GetFullPath(Path.Combine(root, value!)); if (!IsUnder(full, root)) throw new CredentialReferenceException($"{field} resolves outside the certificate directory (fail-closed)."); var realRoot = RealPath(root); var realFull = RealPath(full); if (!IsUnder(realFull, realRoot)) throw new CredentialReferenceException( $"{field} resolves (through a symbolic link or junction) outside the certificate directory (fail-closed)."); return full; } private static StringComparison PathComparison => OperatingSystem.IsWindows() || OperatingSystem.IsMacOS() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; private static bool IsUnder(string path, string root) { var prefix = root.EndsWith(Path.DirectorySeparatorChar) ? root : root + Path.DirectorySeparatorChar; return path.StartsWith(prefix, PathComparison); } /// /// The path with every existing symbolic link / junction component replaced by its final target, walked from the /// root (a link's target may itself sit under another link). Components that do not exist are kept verbatim. /// private static string RealPath(string fullPath) { var root = Path.GetPathRoot(fullPath) ?? ""; var current = root; var rest = fullPath[root.Length..].Split( [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], StringSplitOptions.RemoveEmptyEntries); for (var i = 0; i < rest.Length; i++) { current = Path.Combine(current, rest[i]); FileSystemInfo info = Directory.Exists(current) ? new DirectoryInfo(current) : new FileInfo(current); if (!info.Exists || info.LinkTarget is null) continue; var target = info.ResolveLinkTarget(returnFinalTarget: true); if (target is null) continue; // Re-walk the target itself: its own parents may be links too. current = RealPath(Path.GetFullPath(target.FullName)); } return current; } }