ebc493361e
Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
196 lines
8.2 KiB
C#
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);
|
|
}
|