using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests;
///
/// lmxopcua#836: a key-material file field (OpcUaClient UserCertificatePath) is a relative path confined to a
/// node directory — syntactically at the admin seams, lexically + through links at load time.
///
[Trait("Category", "Unit")]
public sealed class CredentialFilePathsTests
{
[Theory]
[InlineData("user.pfx")]
[InlineData("plant1/user.pfx")]
[InlineData("plant1\\user.pfx")]
[InlineData("a/b-c_d.e/f.pfx")]
public void Plain_relative_paths_are_acceptable(string path) => CredentialFilePaths.IsAcceptableRelative(path).ShouldBeTrue();
[Theory]
[InlineData("/etc/ssl/private/host.pfx")]
[InlineData("\\\\server\\share\\x.pfx")]
[InlineData("\\x.pfx")]
[InlineData("C:\\certs\\user.pfx")]
[InlineData("C:user.pfx")]
[InlineData("../user.pfx")]
[InlineData("plant1/../../user.pfx")]
[InlineData("./user.pfx")]
[InlineData("plant1//user.pfx")]
[InlineData("plant1/")]
[InlineData("~/user.pfx")]
[InlineData("user pfx")]
[InlineData("secret:opcuaclient/x")]
public void Absolute_traversal_and_odd_paths_are_refused(string path)
{
CredentialFilePaths.IsAcceptableRelative(path).ShouldBeFalse();
CredentialFilePaths.IsAcceptable(path).ShouldBeFalse();
}
[Fact]
public void Blank_is_acceptable_but_not_a_relative_path()
{
CredentialFilePaths.IsAcceptable(null).ShouldBeTrue();
CredentialFilePaths.IsAcceptable("").ShouldBeTrue();
CredentialFilePaths.IsAcceptableRelative("").ShouldBeFalse();
}
[Theory]
[InlineData("""{"UserCertificatePath":"/etc/ssl/private/host.pfx"}""", 1)]
[InlineData("""{"userCertificatePath":"..\\..\\Windows\\x.pfx"}""", 1)]
[InlineData("""{"Devices":[{"UserCertificatePath":"C:\\x.pfx"}]}""", 1)]
[InlineData("""{"UserCertificatePath":42}""", 1)]
[InlineData("""{"UserCertificatePath":"plant1/user.pfx"}""", 0)]
[InlineData("""{"UserCertificatePath":null}""", 0)]
public void The_driver_policy_scans_the_OpcUaClient_path_field(string config, int expected)
{
var problems = DriverCredentialPolicy.DescribeProblems(DriverTypeNames.OpcUaClient, config);
problems.Count.ShouldBe(expected);
if (expected > 0)
{
problems[0].ShouldContain("lmxopcua#836");
problems[0].ShouldContain(DriverCredentialPolicy.OpcUaClientUserCertificateDirectoryKey);
problems[0].ShouldNotContain("host.pfx");
}
}
[Fact]
public void Other_drivers_do_not_scan_the_path_field() =>
DriverCredentialPolicy.DescribeProblems(DriverTypeNames.Galaxy, """{"UserCertificatePath":"/etc/x.pfx"}""").ShouldBeEmpty();
[Fact]
public void ResolveWithin_returns_the_path_inside_the_directory()
{
using var dir = new TempDir();
var resolved = CredentialFilePaths.ResolveWithin("plant1/user.pfx", dir.Path, "F", "D");
resolved.ShouldBe(Path.Combine(Path.GetFullPath(dir.Path), "plant1", "user.pfx"));
}
[Theory]
[InlineData("../escape.pfx")]
[InlineData("/etc/passwd")]
public void ResolveWithin_refuses_a_bad_value_fail_closed(string value)
{
using var dir = new TempDir();
Should.Throw(() => CredentialFilePaths.ResolveWithin(value, dir.Path, "F", "D"))
.Message.ShouldContain("fail-closed");
}
[Fact]
public void ResolveWithin_refuses_a_file_symlink_that_escapes_the_directory()
{
using var dir = new TempDir();
using var outside = new TempDir();
var target = Path.Combine(outside.Path, "victim.pfx");
File.WriteAllText(target, "x");
var link = Path.Combine(dir.Path, "user.pfx");
if (!TryCreateLink(() => File.CreateSymbolicLink(link, target))) return;
Should.Throw(() => CredentialFilePaths.ResolveWithin("user.pfx", dir.Path, "F", "D"))
.Message.ShouldContain("symbolic link");
}
[Fact]
public void ResolveWithin_refuses_a_directory_symlink_that_escapes_the_directory()
{
using var dir = new TempDir();
using var outside = new TempDir();
File.WriteAllText(Path.Combine(outside.Path, "victim.pfx"), "x");
if (!TryCreateLink(() => Directory.CreateSymbolicLink(Path.Combine(dir.Path, "plant1"), outside.Path))) return;
Should.Throw(() => CredentialFilePaths.ResolveWithin("plant1/victim.pfx", dir.Path, "F", "D"));
}
[Fact]
public void ResolveWithin_accepts_a_symlink_that_stays_inside_the_directory()
{
using var dir = new TempDir();
Directory.CreateDirectory(Path.Combine(dir.Path, "real"));
File.WriteAllText(Path.Combine(dir.Path, "real", "user.pfx"), "x");
if (!TryCreateLink(() => File.CreateSymbolicLink(Path.Combine(dir.Path, "alias.pfx"), Path.Combine(dir.Path, "real", "user.pfx")))) return;
CredentialFilePaths.ResolveWithin("alias.pfx", dir.Path, "F", "D").ShouldEndWith("alias.pfx");
}
[Fact]
public void ResolveWithin_accepts_a_directory_that_is_itself_reached_through_a_link()
{
// e.g. macOS /var -> /private/var: the configured directory's own parents may be links.
using var outside = new TempDir();
using var holder = new TempDir();
var linkedDir = Path.Combine(holder.Path, "certs");
if (!TryCreateLink(() => Directory.CreateSymbolicLink(linkedDir, outside.Path))) return;
File.WriteAllText(Path.Combine(outside.Path, "user.pfx"), "x");
CredentialFilePaths.ResolveWithin("user.pfx", linkedDir, "F", "D").ShouldEndWith("user.pfx");
}
private static bool TryCreateLink(Action create)
{
try
{
create();
return true;
}
catch (Exception e) when (e is UnauthorizedAccessException or IOException)
{
return false; // Windows without the symlink privilege: the lexical tests still cover the rule.
}
}
private sealed class TempDir : IDisposable
{
public TempDir()
{
Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "otopcua-836-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(Path);
}
public string Path { get; }
public void Dispose()
{
try { Directory.Delete(Path, recursive: true); } catch (IOException) { } catch (UnauthorizedAccessException) { }
}
}
}