Files
scadaproj/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Configuration/SecretsStorePathSpellingTests.cs
T

196 lines
8.2 KiB
C#

using ZB.MOM.WW.Configuration;
using ZB.MOM.WW.Secrets.Configuration;
namespace ZB.MOM.WW.Secrets.Tests.Configuration;
/// <summary>
/// Secrets-005: the store-path rules must compare locations, not spellings. A path that reaches
/// the content root through a symbolic link (or a content root that is itself reached through one)
/// is still inside it; on Windows a drive-relative path is not "absolute", and device-prefixed,
/// loopback-admin-share and 8.3 spellings of a path inside the content root are still inside it.
/// </summary>
public sealed class SecretsStorePathSpellingTests : IDisposable
{
private readonly string _base;
public SecretsStorePathSpellingTests()
{
_base = Path.Combine(Path.GetTempPath(), $"zb-secrets-spelling-{Guid.NewGuid():N}");
Directory.CreateDirectory(Path.Combine(_base, "app", "data"));
Directory.CreateDirectory(Path.Combine(_base, "outside"));
}
public void Dispose()
{
try { Directory.Delete(_base, recursive: true); } catch (IOException) { }
}
private static bool Inside(string value, string root)
{
var builder = new ValidationBuilder();
SecretsStorePathRules.AddIfUnderContentRoot(value, root, "inside", builder);
return !builder.IsValid;
}
private static bool NotRooted(string value)
{
var builder = new ValidationBuilder();
SecretsStorePathRules.AddIfNotRooted(value, "relative", builder);
return !builder.IsValid;
}
private bool TryLink(string link, string target)
{
try
{
Directory.CreateSymbolicLink(link, target);
return true;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
return false; // Windows without the symlink privilege
}
}
[SkippableFact]
public void A_store_reached_through_a_symlink_into_the_content_root_is_inside_it()
{
string root = Path.Combine(_base, "app");
string link = Path.Combine(_base, "alias");
Skip.IfNot(TryLink(link, root), "cannot create a symbolic link here");
// Positive control: the same file spelled directly is caught today.
Assert.True(Inside(Path.Combine(root, "data", "secrets.db"), root));
Assert.True(Inside(Path.Combine(link, "data", "secrets.db"), root));
}
[SkippableFact]
public void A_content_root_reached_through_a_current_symlink_still_contains_its_store()
{
// The "current -> releases/42" deployment shape: the host reports the link, the store is
// configured with the real directory (or the other way round).
string real = Path.Combine(_base, "app");
string current = Path.Combine(_base, "current");
Skip.IfNot(TryLink(current, real), "cannot create a symbolic link here");
Assert.True(Inside(Path.Combine(real, "data", "secrets.db"), current));
Assert.True(Inside(Path.Combine(current, "data", "secrets.db"), real));
}
[SkippableFact]
public void A_store_spelled_through_the_content_root_stays_refused_even_if_a_link_leads_out()
{
// A "durable" link inside the app directory pointing at storage elsewhere: the bytes survive
// a rename, but the next deploy's tree has no such link, so the configured spelling then
// names a fresh empty store inside the new tree — the 2026-08-09 shape. Resolving links
// must ADD cases, never remove the spelled one.
string root = Path.Combine(_base, "app");
string link = Path.Combine(root, "durable");
Skip.IfNot(TryLink(link, Path.Combine(_base, "outside")), "cannot create a symbolic link here");
Assert.True(Inside(Path.Combine(link, "secrets.db"), root));
Assert.False(Inside(Path.Combine(_base, "outside", "secrets.db"), root)); // control
}
[Fact]
public void A_not_yet_created_store_is_compared_as_written()
{
string root = Path.Combine(_base, "app");
Assert.True(Inside(Path.Combine(root, "new", "deeper", "secrets.db"), root));
Assert.False(Inside(Path.Combine(_base, "outside", "new", "secrets.db"), root));
}
[SkippableFact]
public void Drive_and_root_relative_paths_are_not_absolute_on_windows()
{
Skip.IfNot(OperatingSystem.IsWindows(), "Windows path semantics");
Assert.True(NotRooted(@"C:secrets.db"));
Assert.True(NotRooted(@"\secrets.db"));
Assert.False(NotRooted(@"C:\data\secrets.db"));
Assert.False(NotRooted(@"\\server\share\secrets.db"));
}
[SkippableFact]
public void Device_prefixed_and_loopback_share_spellings_are_inside_on_windows()
{
Skip.IfNot(OperatingSystem.IsWindows(), "Windows path semantics");
string root = Path.Combine(_base, "app");
string full = Path.GetFullPath(Path.Combine(root, "data", "secrets.db"));
string drive = full[..1];
string rest = full[3..];
Assert.True(Inside(@"\\?\" + full, root));
Assert.True(Inside(@"\\.\" + full, root));
Assert.True(Inside($@"\\localhost\{drive}$\{rest}", root));
Assert.True(Inside($@"\\127.0.0.1\{drive}$\{rest}", root));
Assert.True(Inside($@"\\?\UNC\localhost\{drive}$\{rest}", root));
}
[SkippableFact]
public void An_8_3_short_name_spelling_is_inside_on_windows()
{
Skip.IfNot(OperatingSystem.IsWindows(), "Windows path semantics");
string longDir = Path.Combine(_base, "Application Directory");
Directory.CreateDirectory(longDir);
string? shortDir = ShortName(longDir);
Skip.If(shortDir is null || !shortDir.Contains('~'), "8.3 names are disabled on this volume");
Assert.True(Inside(Path.Combine(shortDir!, "secrets.db"), longDir));
Assert.True(Inside(Path.Combine(longDir, "secrets.db"), shortDir!));
}
[SkippableFact]
public void An_ordinary_local_share_pointing_at_the_content_root_is_inside_on_windows()
{
Skip.IfNot(OperatingSystem.IsWindows(), "Windows path semantics");
string root = Path.Combine(_base, "app");
string share = "zbtest" + Guid.NewGuid().ToString("N")[..8];
Skip.IfNot(CreateShare(share, root), "cannot create an SMB share here (needs Administrators)");
try
{
Assert.True(Inside($@"\\localhost\{share}\data\secrets.db", root));
Assert.True(Inside($@"\\{Environment.MachineName}\{share}\secrets.db", root));
Assert.False(Inside($@"\\localhost\{share}\secrets.db", Path.Combine(_base, "outside")));
}
finally
{
_ = NetShareDel(null, share, 0);
}
}
private static bool CreateShare(string name, string path)
{
var info = new ShareInfo2 { shi2_netname = name, shi2_type = 0, shi2_max_uses = uint.MaxValue, shi2_path = path };
return NetShareAdd(null, 2, ref info, out _) == 0;
}
[System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential, CharSet = System.Runtime.InteropServices.CharSet.Unicode)]
private struct ShareInfo2
{
public string shi2_netname;
public uint shi2_type;
public string? shi2_remark;
public uint shi2_permissions;
public uint shi2_max_uses;
public uint shi2_current_uses;
public string shi2_path;
public string? shi2_passwd;
}
[System.Runtime.InteropServices.DllImport("netapi32.dll", CharSet = System.Runtime.InteropServices.CharSet.Unicode)]
private static extern uint NetShareAdd(string? server, uint level, ref ShareInfo2 buf, out uint parmErr);
[System.Runtime.InteropServices.DllImport("netapi32.dll", CharSet = System.Runtime.InteropServices.CharSet.Unicode)]
private static extern uint NetShareDel(string? server, string netName, uint reserved);
private static string? ShortName(string path)
{
char[] buffer = new char[1024];
uint n = GetShortPathNameW(path, buffer, (uint)buffer.Length);
return n == 0 ? null : new string(buffer, 0, (int)n);
}
[System.Runtime.InteropServices.DllImport("kernel32.dll", CharSet = System.Runtime.InteropServices.CharSet.Unicode)]
private static extern uint GetShortPathNameW(string longPath, [System.Runtime.InteropServices.Out] char[] shortPath, uint size);
}