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;
}
}