ClientRuntimeLoader: client assemblies must resolve under a trusted root #1
7 changed files with 771 additions and 23 deletions
|
|
@ -18,7 +18,11 @@ $liveProjectPath = if ([System.IO.Path]::IsPathRooted($ClientProject)) { $Client
|
|||
$liveProjectPath = (Resolve-Path $liveProjectPath).Path
|
||||
$liveAssemblyName = [System.IO.Path]::GetFileNameWithoutExtension($liveProjectPath)
|
||||
$devRoot = Join-Path $repoRoot "artifacts\dev-reload"
|
||||
$slotRoot = Join-Path $devRoot "slots"
|
||||
# Everything the host may load as client code lives under $assemblyRoot (slots here, live-slots from dev-watch);
|
||||
# it is the host's trusted root, so state, cache and logs in $devRoot are not part of it.
|
||||
$assemblyRoot = Join-Path $devRoot "assemblies"
|
||||
$slotRoot = Join-Path $assemblyRoot "slots"
|
||||
$liveSlotRoot = Join-Path $assemblyRoot "live-slots"
|
||||
$visibleStatePath = Join-Path $devRoot "state.clixml"
|
||||
$headlessStatePath = Join-Path $devRoot "headless-state.clixml"
|
||||
$statePath = if ($Headless) { $headlessStatePath } else { $visibleStatePath }
|
||||
|
|
@ -79,9 +83,12 @@ function Remove-OldSlots {
|
|||
return
|
||||
}
|
||||
|
||||
$slots = Get-ChildItem -Path $slotRoot -Directory -ErrorAction SilentlyContinue |
|
||||
Sort-Object LastWriteTimeUtc -Descending |
|
||||
Select-Object -Skip $RetainSlots
|
||||
# Both roots hold client code the host may load; dev-watch adds a live slot per change and never prunes.
|
||||
$slots = foreach ($root in @($slotRoot, $liveSlotRoot)) {
|
||||
Get-ChildItem -Path $root -Directory -ErrorAction SilentlyContinue |
|
||||
Sort-Object LastWriteTimeUtc -Descending |
|
||||
Select-Object -Skip $RetainSlots
|
||||
}
|
||||
|
||||
foreach ($slot in $slots) {
|
||||
try {
|
||||
|
|
@ -195,7 +202,8 @@ function Start-AquariumProcess {
|
|||
"--cache", $cultCachePath,
|
||||
"--shader-source", (Join-Path $SlotPath "Render\Shaders\D3D12HeightField.hlsl"),
|
||||
"--client-assembly", $LiveAssemblyPath,
|
||||
"--client-reload-pointer", $liveReloadPointerPath
|
||||
"--client-reload-pointer", $liveReloadPointerPath,
|
||||
"--client-trusted-root", $assemblyRoot
|
||||
)
|
||||
if ($RunHeadless) {
|
||||
$arguments += "--headless"
|
||||
|
|
@ -241,6 +249,17 @@ function Reopen-VisibleProcess {
|
|||
$liveAssemblyPath = [string]$state.liveAssembly
|
||||
}
|
||||
|
||||
# Stale state fails here, in words, instead of as a refused load inside the host.
|
||||
$assemblyRootPrefix = [System.IO.Path]::GetFullPath($assemblyRoot).TrimEnd('\') + '\'
|
||||
foreach ($recorded in @($slotPath, $liveAssemblyPath)) {
|
||||
if (-not [System.IO.Path]::GetFullPath($recorded).StartsWith($assemblyRootPrefix, [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw "Visible dev-reload state names '$recorded', outside the assemblies root '$assemblyRoot' (state from before the trusted-root layout, or the root moved). Run dev-reload without -Reopen to rebuild."
|
||||
}
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $liveAssemblyPath)) {
|
||||
throw "Visible dev-reload state names '$liveAssemblyPath', which no longer exists (pruned slot). Run dev-reload without -Reopen to rebuild."
|
||||
}
|
||||
|
||||
$process = Start-AquariumProcess -SlotPath $slotPath -LiveAssemblyPath $liveAssemblyPath -RunHeadless:$false
|
||||
Wait-ForStartedAquarium -Process $process -ExpectWindow:$true
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ $liveProjectPath = (Resolve-Path $liveProjectPath).Path
|
|||
$liveAssemblyName = [System.IO.Path]::GetFileNameWithoutExtension($liveProjectPath)
|
||||
$clientSourceRoot = Split-Path $liveProjectPath
|
||||
$watchLogPath = Join-Path $repoRoot "artifacts\dev-reload\watch.log"
|
||||
$liveSlotRoot = Join-Path $repoRoot "artifacts\dev-reload\live-slots"
|
||||
$liveSlotRoot = Join-Path $repoRoot "artifacts\dev-reload\assemblies\live-slots"
|
||||
$liveReloadPointerPath = Join-Path $repoRoot "artifacts\dev-reload\live-current.txt"
|
||||
$shaderPath = Join-Path $repoRoot "src\Aquarium.Engine\Render\Shaders\Aquarium.hlsl"
|
||||
$engineShaderRoot = Join-Path $repoRoot "src\Aquarium.Engine\Render\Shaders"
|
||||
|
|
@ -455,12 +455,15 @@ function Wait-LiveReloadAcknowledged {
|
|||
$stderrLog = if ($state.stderrLog) { [string]$state.stderrLog } else { $null }
|
||||
$timeoutSeconds = [Math]::Max(1, $LiveReloadAckTimeoutSeconds)
|
||||
$deadline = (Get-Date).AddSeconds($timeoutSeconds)
|
||||
$appliedLine = "Client runtime reload applied: $liveAssemblyPath"
|
||||
# The host logs the resolved path with backslashes doubled, which can differ from the path built here when the
|
||||
# repo sits under a link. The slot directory name is unique, so match its leaf and the assembly name.
|
||||
$slotLeaf = Split-Path -Leaf (Split-Path -Parent $liveAssemblyPath)
|
||||
$appliedSuffix = ("$slotLeaf\" + (Split-Path -Leaf $liveAssemblyPath)).Replace("\", "\\")
|
||||
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
if (Test-Path $stdoutLog) {
|
||||
$stdoutTail = (Get-Content -Path $stdoutLog -Tail 120 -ErrorAction SilentlyContinue) -join "`n"
|
||||
if ($stdoutTail.Contains($appliedLine)) {
|
||||
if ($stdoutTail -match ("(?m)^Client runtime reload applied: .*" + [regex]::Escape($appliedSuffix) + "\s*$")) {
|
||||
Write-WatchLog "Live reload acknowledged by host: $liveAssemblyPath"
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,8 +18,22 @@ public static class AquariumHost
|
|||
ParseFieldReservoirMode(args),
|
||||
ParseFieldReservoirScale(args),
|
||||
ParseFieldReservoirSpatialReuseBudget(args));
|
||||
using var runtimeLoader = new ClientRuntimeLoader(runtimeOptions, ParseClientAssemblyPath(args), ParseClientReloadPointerPath(args));
|
||||
var runtime = runtimeLoader.Load();
|
||||
ClientRuntimeLoader? loader = null;
|
||||
IAquariumRuntime runtime;
|
||||
try
|
||||
{
|
||||
loader = new ClientRuntimeLoader(runtimeOptions, ParseClientAssemblyPath(args), ParseClientReloadPointerPath(args), ParseClientTrustedRoot(args));
|
||||
runtime = loader.Load();
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
// Startup refusals carry raw, attacker-writable paths; the console gets them escaped, once, here.
|
||||
Console.Error.WriteLine($"Client runtime failed to start ({ClientRuntimeLoader.Printable(error.GetType().FullName ?? error.GetType().Name)}). {ClientRuntimeLoader.Printable(error.Message)}");
|
||||
loader?.Dispose();
|
||||
return 1;
|
||||
}
|
||||
|
||||
using var runtimeLoader = loader;
|
||||
if (runtimeOptions.RenderDebugModeOverride is { } renderDebugModeOverride)
|
||||
{
|
||||
runtime.GraphicsSettings = (runtime.GraphicsSettings with
|
||||
|
|
@ -258,6 +272,10 @@ public static class AquariumHost
|
|||
?? Environment.GetEnvironmentVariable("AQUARIUM_LIVE_ASSEMBLY");
|
||||
}
|
||||
|
||||
private static readonly TrustedRootSetting TrustedRoot = TrustedRootSetting.FromProcessEnvironment();
|
||||
|
||||
private static string? ParseClientTrustedRoot(IReadOnlyCollection<string> args) => TrustedRoot.Resolve(args);
|
||||
|
||||
private static string? ParseClientReloadPointerPath(IReadOnlyCollection<string> args)
|
||||
{
|
||||
var values = args.ToArray();
|
||||
|
|
|
|||
|
|
@ -8,17 +8,49 @@ public sealed class ClientRuntimeLoader : IDisposable
|
|||
{
|
||||
private readonly string? clientAssemblyPath;
|
||||
private readonly string? clientReloadPointerPath;
|
||||
private readonly string? trustedRoot;
|
||||
private readonly string? trustedRootAsGiven;
|
||||
private readonly Stopwatch reloadClock = Stopwatch.StartNew();
|
||||
private readonly AquariumRuntimeOptions options;
|
||||
private ReloadedRuntime? current;
|
||||
private TimeSpan lastReloadCheck;
|
||||
private string? currentAssemblyPath;
|
||||
|
||||
public ClientRuntimeLoader(AquariumRuntimeOptions options, string? clientAssemblyPath = null, string? clientReloadPointerPath = null)
|
||||
/// <summary>
|
||||
/// Every assembly this loader runs, and every dependency or native library it resolves, must lie, links followed,
|
||||
/// under <paramref name="trustedRoot"/> (default: the directory of the initial assembly). The root is resolved once
|
||||
/// here and pinned; a volume root is refused. A relative initial path is taken against the working directory;
|
||||
/// a relative or UNC reload target is refused. The boundary this enforces is the root directory's own ACLs.
|
||||
/// </summary>
|
||||
public ClientRuntimeLoader(
|
||||
AquariumRuntimeOptions options,
|
||||
string? clientAssemblyPath = null,
|
||||
string? clientReloadPointerPath = null,
|
||||
string? trustedRoot = null)
|
||||
{
|
||||
this.clientAssemblyPath = clientAssemblyPath;
|
||||
this.clientAssemblyPath = string.IsNullOrWhiteSpace(clientAssemblyPath) ? null : Path.GetFullPath(clientAssemblyPath);
|
||||
this.clientReloadPointerPath = clientReloadPointerPath;
|
||||
this.options = options;
|
||||
|
||||
var root = string.IsNullOrWhiteSpace(trustedRoot)
|
||||
? this.clientAssemblyPath is null ? null : Path.GetDirectoryName(this.clientAssemblyPath)
|
||||
: Path.GetFullPath(trustedRoot);
|
||||
this.trustedRootAsGiven = root;
|
||||
if (root is not null)
|
||||
{
|
||||
root = ResolveLinks(root);
|
||||
if (string.Equals(
|
||||
Path.TrimEndingDirectorySeparator(root),
|
||||
Path.TrimEndingDirectorySeparator(Path.GetPathRoot(root) ?? root),
|
||||
PathComparison))
|
||||
{
|
||||
throw new ArgumentException($"A drive or volume root cannot be the client runtime trusted root: {root}", nameof(trustedRoot));
|
||||
}
|
||||
|
||||
Console.WriteLine($"Client runtime trusted root: {Printable(root)}");
|
||||
}
|
||||
|
||||
this.trustedRoot = root;
|
||||
}
|
||||
|
||||
public IAquariumRuntime Runtime
|
||||
|
|
@ -40,10 +72,13 @@ public sealed class ClientRuntimeLoader : IDisposable
|
|||
return Runtime;
|
||||
}
|
||||
|
||||
public void Reload(string assemblyPath, AquariumRuntimeOptions options)
|
||||
public void Reload(string assemblyPath, AquariumRuntimeOptions options) =>
|
||||
LoadResolved(ResolveTrusted(assemblyPath), options);
|
||||
|
||||
private void LoadResolved(string assemblyPath, AquariumRuntimeOptions options)
|
||||
{
|
||||
var previous = current;
|
||||
var loadContext = new ClientAssemblyLoadContext(assemblyPath);
|
||||
var loadContext = new ClientAssemblyLoadContext(assemblyPath, trustedRoot!);
|
||||
IAquariumRuntime? nextRuntime = null;
|
||||
|
||||
try
|
||||
|
|
@ -103,25 +138,164 @@ public sealed class ClientRuntimeLoader : IDisposable
|
|||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(candidatePath)
|
||||
|| string.Equals(candidatePath, currentAssemblyPath, StringComparison.OrdinalIgnoreCase)
|
||||
|| !File.Exists(candidatePath))
|
||||
if (string.IsNullOrWhiteSpace(candidatePath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
RequireLocalFullyQualified(candidatePath); // string rules first: no filesystem or network probe of a UNC path
|
||||
RequireLexicallyUnderRoot(candidatePath); // and no probe of anything outside the root, whatever a link there points at
|
||||
if (!File.Exists(candidatePath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var resolved = ResolveTrusted(candidatePath);
|
||||
if (string.Equals(resolved, currentAssemblyPath, PathComparison))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
current?.Runtime.FlushState();
|
||||
Reload(candidatePath, options);
|
||||
Console.WriteLine($"Client runtime reload applied: {candidatePath}");
|
||||
LoadResolved(resolved, options);
|
||||
Console.WriteLine($"Client runtime reload applied: {Printable(resolved)}");
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
Console.Error.WriteLine($"Client runtime reload failed; keeping previous runtime. {error.Message}");
|
||||
Console.Error.WriteLine($"Client runtime reload failed; keeping previous runtime. {Printable(error.Message)}");
|
||||
}
|
||||
}
|
||||
|
||||
private static StringComparison PathComparison =>
|
||||
OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
|
||||
|
||||
private string ResolveTrusted(string assemblyPath) =>
|
||||
trustedRoot is null
|
||||
? throw new InvalidOperationException("No trusted root for client runtime assemblies is set.")
|
||||
: ResolveTrusted(trustedRoot, assemblyPath, trustedRootAsGiven);
|
||||
|
||||
private void RequireLexicallyUnderRoot(string path) =>
|
||||
RequireLexicallyUnder(Path.GetFullPath(path), trustedRoot, trustedRootAsGiven);
|
||||
|
||||
// Pure string comparison against the root as resolved and as given: a path outside both is refused before any
|
||||
// filesystem access, so a link there (to a UNC share, a mapped drive) is never followed or probed.
|
||||
private static void RequireLexicallyUnder(string fullPath, params string?[] roots)
|
||||
{
|
||||
if (!roots.Any(root => root is not null && IsUnder(root, fullPath)))
|
||||
{
|
||||
throw new InvalidOperationException($"Client runtime path is outside the trusted root '{roots[0]}': {fullPath}");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsUnder(string root, string path)
|
||||
{
|
||||
var relative = Path.GetRelativePath(root, path);
|
||||
return !(relative == ".." || relative.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal) || Path.IsPathRooted(relative));
|
||||
}
|
||||
|
||||
// The final path of a local, fully qualified file under the already-resolved root; throws otherwise.
|
||||
internal static string ResolveTrusted(string resolvedRoot, string path, string? rootAsGiven = null)
|
||||
{
|
||||
RequireLocalFullyQualified(path);
|
||||
RequireLexicallyUnder(Path.GetFullPath(path), resolvedRoot, rootAsGiven);
|
||||
var resolved = ResolveLinks(Path.GetFullPath(path));
|
||||
if (!IsUnder(resolvedRoot, resolved))
|
||||
{
|
||||
throw new InvalidOperationException($"Client runtime path is outside the trusted root '{resolvedRoot}': {path}");
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
internal static void RequireLocalFullyQualified(string path)
|
||||
{
|
||||
if (!Path.IsPathFullyQualified(path) || (path.Length >= 2 && IsSeparator(path[0]) && IsSeparator(path[1])))
|
||||
{
|
||||
throw new InvalidOperationException($"Client runtime path must be local and fully qualified: {path}");
|
||||
}
|
||||
}
|
||||
|
||||
// Paths come from files other people can write. Exception messages carry them raw; every console write escapes
|
||||
// them here, once: backslashes, control, format (bidi, zero-width, tag characters) and line/paragraph separator
|
||||
// characters, by code point, and lone surrogates.
|
||||
internal static string Printable(string text)
|
||||
{
|
||||
var builder = new System.Text.StringBuilder(text.Length);
|
||||
for (var index = 0; index < text.Length;)
|
||||
{
|
||||
if (!System.Text.Rune.TryGetRuneAt(text, index, out var rune))
|
||||
{
|
||||
builder.Append($"\\u{(int)text[index]:x4}"); // a lone surrogate
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
|
||||
index += rune.Utf16SequenceLength;
|
||||
var category = System.Text.Rune.GetUnicodeCategory(rune);
|
||||
if (rune.Value == '\\')
|
||||
{
|
||||
builder.Append("\\\\");
|
||||
}
|
||||
else if (category is System.Globalization.UnicodeCategory.Control or System.Globalization.UnicodeCategory.Format
|
||||
or System.Globalization.UnicodeCategory.LineSeparator or System.Globalization.UnicodeCategory.ParagraphSeparator)
|
||||
{
|
||||
builder.Append(rune.Value switch
|
||||
{
|
||||
'\n' => "\\n",
|
||||
'\r' => "\\r",
|
||||
'\t' => "\\t",
|
||||
<= 0xFFFF => $"\\u{rune.Value:x4}",
|
||||
_ => $"\\U{rune.Value:x8}",
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Append(rune.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static bool IsSeparator(char c) => c is '/' or '\\';
|
||||
|
||||
// The path with every symlink or junction on it replaced by its final target.
|
||||
internal static string ResolveLinks(string path, int depth = 0)
|
||||
{
|
||||
if (depth > 32)
|
||||
{
|
||||
throw new InvalidOperationException($"Too many links resolving {path}.");
|
||||
}
|
||||
|
||||
for (var probe = path; probe is not null; probe = Path.GetDirectoryName(probe))
|
||||
{
|
||||
FileSystemInfo info = Directory.Exists(probe) ? new DirectoryInfo(probe) : new FileInfo(probe);
|
||||
if (info.LinkTarget is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string target;
|
||||
try
|
||||
{
|
||||
target = info.ResolveLinkTarget(returnFinalTarget: true)?.FullName
|
||||
?? throw new InvalidOperationException($"Link does not resolve: {probe}");
|
||||
}
|
||||
catch (IOException error)
|
||||
{
|
||||
// Windows refuses to traverse a mount point made by a non-administrator ("untrusted mount point"):
|
||||
// that is a refusal like any other, not a crash with a different type.
|
||||
throw new InvalidOperationException($"Link cannot be resolved: {probe} ({error.GetType().Name})", error);
|
||||
}
|
||||
var rest = Path.GetRelativePath(probe, path);
|
||||
return ResolveLinks(rest == "." ? target : Path.GetFullPath(Path.Combine(target, rest)), depth + 1);
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
private string RequireClientAssemblyPath()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(clientAssemblyPath))
|
||||
|
|
@ -150,7 +324,7 @@ public sealed class ClientRuntimeLoader : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
private sealed class ClientAssemblyLoadContext(string assemblyPath) : AssemblyLoadContext(isCollectible: true)
|
||||
private sealed class ClientAssemblyLoadContext(string assemblyPath, string trustedRoot) : AssemblyLoadContext(isCollectible: true)
|
||||
{
|
||||
private readonly AssemblyDependencyResolver resolver = new(assemblyPath);
|
||||
|
||||
|
|
@ -162,13 +336,13 @@ public sealed class ClientRuntimeLoader : IDisposable
|
|||
}
|
||||
|
||||
var path = resolver.ResolveAssemblyToPath(assemblyName);
|
||||
return path == null ? null : LoadFromAssemblyPath(path);
|
||||
return path == null ? null : LoadFromAssemblyPath(ResolveTrusted(trustedRoot, path));
|
||||
}
|
||||
|
||||
protected override IntPtr LoadUnmanagedDll(string unmanagedDllName)
|
||||
{
|
||||
var path = resolver.ResolveUnmanagedDllToPath(unmanagedDllName);
|
||||
return path == null ? IntPtr.Zero : LoadUnmanagedDllFromPath(path);
|
||||
return path == null ? IntPtr.Zero : LoadUnmanagedDllFromPath(ResolveTrusted(trustedRoot, path));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
48
src/Aquarium.Engine/Runtime/TrustedRootSetting.cs
Normal file
48
src/Aquarium.Engine/Runtime/TrustedRootSetting.cs
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
namespace Aquarium.Engine.Runtime;
|
||||
|
||||
/// <summary>
|
||||
/// The client runtime trusted root as given on the command line or in AQUARIUM_CLIENT_TRUSTED_ROOT. The environment
|
||||
/// value is taken (read and cleared) the first time anything is resolved, on every path, and cached: children do not
|
||||
/// inherit it and a second resolution does not silently fall back to the default root. A blank flag value, a flag with
|
||||
/// no value or a blank environment value is refused.
|
||||
/// </summary>
|
||||
internal sealed class TrustedRootSetting(Func<string?> takeEnvironment)
|
||||
{
|
||||
private const string Flag = "--client-trusted-root";
|
||||
private const string Variable = "AQUARIUM_CLIENT_TRUSTED_ROOT";
|
||||
private readonly Lazy<string?> fromEnvironment = new(takeEnvironment);
|
||||
|
||||
public static TrustedRootSetting FromProcessEnvironment() => new(() =>
|
||||
{
|
||||
var value = Environment.GetEnvironmentVariable(Variable);
|
||||
Environment.SetEnvironmentVariable(Variable, null);
|
||||
return value;
|
||||
});
|
||||
|
||||
public string? Resolve(IReadOnlyCollection<string> args)
|
||||
{
|
||||
var environment = fromEnvironment.Value;
|
||||
if (environment is not null && string.IsNullOrWhiteSpace(environment))
|
||||
{
|
||||
throw new ArgumentException($"{Variable} is set but empty; an empty value would fall back to the default root.");
|
||||
}
|
||||
|
||||
var values = args.ToArray();
|
||||
for (var index = 0; index < values.Length; index++)
|
||||
{
|
||||
if (!string.Equals(values[index], Flag, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (index + 1 >= values.Length || string.IsNullOrWhiteSpace(values[index + 1]))
|
||||
{
|
||||
throw new ArgumentException($"{Flag} needs a directory; an empty value would fall back to the default root.");
|
||||
}
|
||||
|
||||
return values[index + 1];
|
||||
}
|
||||
|
||||
return environment;
|
||||
}
|
||||
}
|
||||
|
|
@ -20,6 +20,7 @@
|
|||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Aquarium.Engine\Aquarium.Engine.csproj" />
|
||||
<ProjectReference Include="..\..\src\Aquarium.Sample.Minimal\Aquarium.Sample.Minimal.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
|
|||
485
tests/Aquarium.Engine.Tests/ClientRuntimeLoaderTrustTests.cs
Normal file
485
tests/Aquarium.Engine.Tests/ClientRuntimeLoaderTrustTests.cs
Normal file
|
|
@ -0,0 +1,485 @@
|
|||
using System.Diagnostics;
|
||||
using Aquarium.Engine;
|
||||
using Aquarium.Engine.Input;
|
||||
using Aquarium.Engine.Runtime;
|
||||
|
||||
namespace Aquarium.Engine.Tests;
|
||||
|
||||
/// <summary>Runs only where this process can create symbolic links; otherwise reported as skipped with the reason.</summary>
|
||||
public sealed class SymlinkFactAttribute : FactAttribute
|
||||
{
|
||||
public SymlinkFactAttribute()
|
||||
{
|
||||
var probe = Path.Combine(Path.GetTempPath(), "aquarium-symlink-probe-" + Guid.NewGuid().ToString("N"));
|
||||
try
|
||||
{
|
||||
File.CreateSymbolicLink(probe, Path.GetTempPath());
|
||||
File.Delete(probe);
|
||||
}
|
||||
catch (Exception error) when (error is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
Skip = "This account cannot create symbolic links: " + error.Message;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Junctions exist only on Windows.</summary>
|
||||
public sealed class JunctionFactAttribute : FactAttribute
|
||||
{
|
||||
public JunctionFactAttribute()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
Skip = "NTFS junctions exist only on Windows; run this test there.";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Windows file names cannot contain a newline, so this setup cannot be built there.</summary>
|
||||
public sealed class NewlineInFileNameFactAttribute : FactAttribute
|
||||
{
|
||||
public NewlineInFileNameFactAttribute()
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
Skip = "Windows file names cannot contain a newline; the escaping is covered by the Printable tests.";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ClientRuntimeLoaderTrustTests : IDisposable
|
||||
{
|
||||
private static readonly AquariumRuntimeOptions Options = new(true, null);
|
||||
private readonly string work = Path.Combine(Path.GetTempPath(), "aquarium-trust-" + Guid.NewGuid().ToString("N"));
|
||||
private readonly string trusted;
|
||||
private readonly string outside;
|
||||
|
||||
public ClientRuntimeLoaderTrustTests()
|
||||
{
|
||||
trusted = Directory.CreateDirectory(Path.Combine(work, "trusted")).FullName;
|
||||
outside = Directory.CreateDirectory(Path.Combine(work, "outside")).FullName;
|
||||
}
|
||||
|
||||
// A loaded assembly stays locked on Windows until its unloaded context is collected: tolerate that here.
|
||||
public void Dispose()
|
||||
{
|
||||
for (var attempt = 0; attempt < 5; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(work, recursive: true);
|
||||
return;
|
||||
}
|
||||
catch (Exception error) when (error is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
GC.Collect();
|
||||
GC.WaitForPendingFinalizers();
|
||||
Thread.Sleep(100);
|
||||
}
|
||||
}
|
||||
|
||||
Console.Error.WriteLine($"Warning: test cleanup could not delete (locked loaded assembly?): {ClientRuntimeLoader.Printable(work)}");
|
||||
}
|
||||
|
||||
// A real client assembly: the minimal sample, copied so its location is ours to choose.
|
||||
private static string Copy(string directory, string name = "client.dll")
|
||||
{
|
||||
var path = Path.Combine(directory, name);
|
||||
File.Copy(typeof(Aquarium.Sample.Minimal.MinimalRuntime).Assembly.Location, path);
|
||||
return path;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AssemblyInsideTheTrustedRootLoadsAndReloads()
|
||||
{
|
||||
var first = Copy(trusted, "a.dll");
|
||||
var second = Copy(Directory.CreateDirectory(Path.Combine(trusted, "slot2")).FullName);
|
||||
using var loader = new ClientRuntimeLoader(Options, first);
|
||||
loader.Load();
|
||||
var before = loader.Runtime;
|
||||
loader.Reload(second, Options);
|
||||
Assert.NotSame(before, loader.Runtime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SiblingDirectoryOfTheInitialAssemblyLoadsOnlyInsideTheExplicitRoot()
|
||||
{
|
||||
// dev-watch reloads from live-slots, a sibling of the slot the host started in.
|
||||
var slot = Directory.CreateDirectory(Path.Combine(trusted, "slots", "s1")).FullName;
|
||||
var live = Directory.CreateDirectory(Path.Combine(trusted, "live-slots", "s2")).FullName;
|
||||
var sibling = Copy(live);
|
||||
using var loader = new ClientRuntimeLoader(Options, Copy(slot), trustedRoot: trusted);
|
||||
loader.Load();
|
||||
loader.Reload(sibling, Options);
|
||||
|
||||
using var narrow = new ClientRuntimeLoader(Options, Path.Combine(slot, "client.dll"));
|
||||
narrow.Load();
|
||||
Assert.Throws<InvalidOperationException>(() => narrow.Reload(sibling, Options)); // default root is the initial directory
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InitialAssemblyOutsideTheExplicitRootIsRefused()
|
||||
{
|
||||
using var loader = new ClientRuntimeLoader(Options, Copy(outside), trustedRoot: trusted);
|
||||
Assert.Throws<InvalidOperationException>(() => loader.Load());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReloadOutsideTheRootViaDotDotOrRelativePathIsRefused()
|
||||
{
|
||||
using var loader = new ClientRuntimeLoader(Options, Copy(trusted, "a.dll"));
|
||||
loader.Load();
|
||||
var escaped = Copy(outside);
|
||||
Assert.Throws<InvalidOperationException>(() => loader.Reload(escaped, Options));
|
||||
Assert.Throws<InvalidOperationException>(() => loader.Reload(Path.Combine(trusted, "..", "outside", "client.dll"), Options));
|
||||
Assert.Throws<InvalidOperationException>(() => loader.Reload("a.dll", Options));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("//host/share/client.dll")]
|
||||
[InlineData(@"\\host\share\client.dll")]
|
||||
[InlineData(@"\/host/share\client.dll")]
|
||||
[InlineData(@"/\host\share/client.dll")]
|
||||
public void UncPathsInAnySeparatorMixAreRefused(string path)
|
||||
{
|
||||
Assert.Throws<InvalidOperationException>(() => ClientRuntimeLoader.ResolveTrusted(trusted, path));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AVolumeRootCannotBeTheTrustedRoot()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => new ClientRuntimeLoader(Options, Copy(trusted), trustedRoot: Path.GetPathRoot(trusted)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ADependencyOrNativeLibraryOutsideTheRootIsRefused()
|
||||
{
|
||||
// The load context resolves dependencies through the same check; assert the check it calls.
|
||||
var dependency = Path.Combine(outside, "dep.dll");
|
||||
File.WriteAllText(dependency, "");
|
||||
Assert.Throws<InvalidOperationException>(() => ClientRuntimeLoader.ResolveTrusted(ClientRuntimeLoader.ResolveLinks(trusted), dependency));
|
||||
var inside = Path.Combine(trusted, "dep.so");
|
||||
File.WriteAllText(inside, "");
|
||||
Assert.Equal(inside, ClientRuntimeLoader.ResolveTrusted(ClientRuntimeLoader.ResolveLinks(trusted), inside));
|
||||
}
|
||||
|
||||
[SymlinkFact]
|
||||
public void SymlinkedFileOrDirectoryPointingOutsideTheRootIsRefused()
|
||||
{
|
||||
var fileLink = Path.Combine(trusted, "link.dll");
|
||||
var dirLink = Path.Combine(trusted, "dirlink");
|
||||
File.CreateSymbolicLink(fileLink, Copy(outside));
|
||||
Directory.CreateSymbolicLink(dirLink, outside);
|
||||
|
||||
using var loader = new ClientRuntimeLoader(Options, Copy(trusted, "a.dll"));
|
||||
loader.Load();
|
||||
Assert.Throws<InvalidOperationException>(() => loader.Reload(fileLink, Options));
|
||||
Assert.Throws<InvalidOperationException>(() => loader.Reload(Path.Combine(dirLink, "client.dll"), Options));
|
||||
}
|
||||
|
||||
[JunctionFact]
|
||||
public void JunctionPointingOutsideTheRootIsRefused()
|
||||
{
|
||||
var junction = Path.Combine(trusted, "junction");
|
||||
Copy(outside);
|
||||
var mklink = Process.Start(new ProcessStartInfo("cmd.exe", $"/c mklink /J \"{junction}\" \"{outside}\"") { UseShellExecute = false, CreateNoWindow = true })!;
|
||||
mklink.WaitForExit();
|
||||
Assert.Equal(0, mklink.ExitCode);
|
||||
|
||||
using var loader = new ClientRuntimeLoader(Options, Copy(trusted, "a.dll"));
|
||||
loader.Load();
|
||||
// Windows refuses an untrusted (non-administrator) mount point itself; the loader reports it as a refusal too.
|
||||
Assert.ThrowsAny<InvalidOperationException>(() => loader.Reload(Path.Combine(junction, "client.dll"), Options));
|
||||
}
|
||||
|
||||
[SymlinkFact]
|
||||
public void ALoadedLinkIsStoredByItsResolvedPathSoThePointerDoesNotReloadItEveryPoll()
|
||||
{
|
||||
var real = Copy(Directory.CreateDirectory(Path.Combine(trusted, "real")).FullName);
|
||||
var link = Path.Combine(trusted, "link.dll");
|
||||
File.CreateSymbolicLink(link, real);
|
||||
var pointer = Path.Combine(work, "pointer.txt");
|
||||
File.WriteAllText(pointer, link);
|
||||
using var loader = new ClientRuntimeLoader(Options, Copy(trusted, "a.dll"), pointer);
|
||||
loader.Load();
|
||||
|
||||
Thread.Sleep(600); // the loader polls the pointer at most every 500 ms
|
||||
loader.Update(0.01f, new InputState());
|
||||
var afterFirst = loader.Runtime; // the link was loaded once, by its resolved path
|
||||
Thread.Sleep(600);
|
||||
loader.Update(0.01f, new InputState());
|
||||
|
||||
Assert.Same(afterFirst, loader.Runtime);
|
||||
}
|
||||
|
||||
[SymlinkFact]
|
||||
public void TheTrustedRootIsPinnedAtConstructionAndNotReResolved()
|
||||
{
|
||||
var first = Directory.CreateDirectory(Path.Combine(work, "first")).FullName;
|
||||
var second = Directory.CreateDirectory(Path.Combine(work, "second")).FullName;
|
||||
var rootLink = Path.Combine(work, "root");
|
||||
Directory.CreateSymbolicLink(rootLink, first);
|
||||
using var loader = new ClientRuntimeLoader(Options, Copy(first), trustedRoot: rootLink);
|
||||
loader.Load();
|
||||
|
||||
Directory.Delete(rootLink);
|
||||
Directory.CreateSymbolicLink(rootLink, second); // the root now names another directory
|
||||
var elsewhere = Copy(second, "other.dll");
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => loader.Reload(Path.Combine(rootLink, "other.dll"), Options));
|
||||
Assert.Throws<InvalidOperationException>(() => loader.Reload(elsewhere, Options));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PointerNamingAnAssemblyOutsideTheRootKeepsThePreviousRuntime()
|
||||
{
|
||||
var pointer = Path.Combine(work, "pointer.txt");
|
||||
File.WriteAllText(pointer, Copy(outside));
|
||||
using var loader = new ClientRuntimeLoader(Options, Copy(trusted, "a.dll"), pointer);
|
||||
loader.Load();
|
||||
var before = loader.Runtime;
|
||||
|
||||
Thread.Sleep(600);
|
||||
loader.Update(0.01f, new InputState());
|
||||
|
||||
Assert.Same(before, loader.Runtime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnUncPointerIsRefusedAndReportedBeforeAnyFilesystemProbe()
|
||||
{
|
||||
var pointer = Path.Combine(work, "pointer.txt");
|
||||
File.WriteAllText(pointer, "//no-such-host.invalid/share/client.dll");
|
||||
using var loader = new ClientRuntimeLoader(Options, Copy(trusted, "a.dll"), pointer);
|
||||
loader.Load();
|
||||
|
||||
var errors = CaptureError(() =>
|
||||
{
|
||||
Thread.Sleep(600);
|
||||
loader.Update(0.01f, new InputState());
|
||||
});
|
||||
|
||||
// File.Exists on this path would return false silently; the string rule must speak first.
|
||||
Assert.Contains("local and fully qualified", errors);
|
||||
}
|
||||
|
||||
[NewlineInFileNameFact]
|
||||
public void ControlCharactersInAPointerTargetAreEscapedInTheLog()
|
||||
{
|
||||
var hostile = Path.Combine(outside, "a\nClient runtime reload applied: forged.dll");
|
||||
File.Copy(typeof(Aquarium.Sample.Minimal.MinimalRuntime).Assembly.Location, hostile);
|
||||
var pointer = Path.Combine(work, "pointer.txt");
|
||||
File.WriteAllText(pointer, hostile);
|
||||
using var loader = new ClientRuntimeLoader(Options, Copy(trusted, "a.dll"), pointer);
|
||||
loader.Load();
|
||||
|
||||
var errors = CaptureError(() =>
|
||||
{
|
||||
Thread.Sleep(600);
|
||||
loader.Update(0.01f, new InputState());
|
||||
});
|
||||
|
||||
Assert.Contains("outside the trusted root", errors);
|
||||
Assert.DoesNotContain("\nClient runtime reload applied", errors);
|
||||
Assert.Contains("\\nClient runtime reload applied", errors);
|
||||
Assert.Equal("a\\u0007b", ClientRuntimeLoader.Printable("a\u0007b"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheTrustedRootEnvironmentVariableIsTakenOnceOnEveryPathAndCached()
|
||||
{
|
||||
const string Name = "AQUARIUM_CLIENT_TRUSTED_ROOT";
|
||||
Environment.SetEnvironmentVariable(Name, "/from/env");
|
||||
var setting = TrustedRootSetting.FromProcessEnvironment();
|
||||
Assert.Equal("/from/flag", setting.Resolve(["--client-trusted-root", "/from/flag"]));
|
||||
Assert.Null(Environment.GetEnvironmentVariable(Name)); // cleared although the flag won
|
||||
Assert.Equal("/from/env", setting.Resolve([])); // cached: a second resolution does not fall to the default root
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void ABlankEnvironmentValueIsRefusedLikeABlankFlag(string value)
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => new TrustedRootSetting(() => value).Resolve([]));
|
||||
Assert.Throws<ArgumentException>(() => new TrustedRootSetting(() => null).Resolve(["--client-trusted-root", value]));
|
||||
Assert.Throws<ArgumentException>(() => new TrustedRootSetting(() => null).Resolve(["--client-trusted-root"]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void APathOutsideTheRootIsRefusedBeforeAnyLinkOnItIsFollowed()
|
||||
{
|
||||
// A link loop outside the root: following it would throw "Too many links"; the string check must refuse first.
|
||||
var a = Path.Combine(outside, "a");
|
||||
var b = Path.Combine(outside, "b");
|
||||
try
|
||||
{
|
||||
Directory.CreateSymbolicLink(a, b);
|
||||
Directory.CreateSymbolicLink(b, a);
|
||||
}
|
||||
catch (Exception error) when (error is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// No links here: the check is still exercised by the nonexistent outside path below.
|
||||
}
|
||||
|
||||
var error2 = Assert.Throws<InvalidOperationException>(
|
||||
() => ClientRuntimeLoader.ResolveTrusted(ClientRuntimeLoader.ResolveLinks(trusted), Path.Combine(a, "client.dll")));
|
||||
Assert.Contains("outside the trusted root", error2.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ANonexistentPointerTargetOutsideTheRootIsRefusedAndReportedNotSilentlyIgnored()
|
||||
{
|
||||
var pointer = Path.Combine(work, "pointer.txt");
|
||||
File.WriteAllText(pointer, Path.Combine(outside, "not-there.dll"));
|
||||
using var loader = new ClientRuntimeLoader(Options, Copy(trusted, "a.dll"), pointer);
|
||||
loader.Load();
|
||||
|
||||
var errors = CaptureError(() =>
|
||||
{
|
||||
Thread.Sleep(600);
|
||||
loader.Update(0.01f, new InputState());
|
||||
});
|
||||
|
||||
Assert.Contains("outside the trusted root", errors);
|
||||
}
|
||||
|
||||
[SymlinkFact]
|
||||
public void ARootReachedThroughALinkAsGivenStillAcceptsPathsUnderIt()
|
||||
{
|
||||
var real = Directory.CreateDirectory(Path.Combine(work, "real")).FullName;
|
||||
var rootLink = Path.Combine(work, "rootlink");
|
||||
Directory.CreateSymbolicLink(rootLink, real);
|
||||
Copy(real, "a.dll");
|
||||
var viaLink = Path.Combine(rootLink, "b.dll");
|
||||
File.Copy(typeof(Aquarium.Sample.Minimal.MinimalRuntime).Assembly.Location, viaLink);
|
||||
using var loader = new ClientRuntimeLoader(Options, Path.Combine(rootLink, "a.dll"), trustedRoot: rootLink);
|
||||
loader.Load();
|
||||
loader.Reload(viaLink, Options);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ABackslashInAPointerTargetIsEscapedExactlyOnceInTheLog()
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
return; // a backslash is a separator in a Windows file name; the escaping itself is covered by the Printable tests
|
||||
}
|
||||
|
||||
var hostile = Path.Combine(outside, "x\\y.dll");
|
||||
File.Copy(typeof(Aquarium.Sample.Minimal.MinimalRuntime).Assembly.Location, hostile);
|
||||
var pointer = Path.Combine(work, "pointer.txt");
|
||||
File.WriteAllText(pointer, hostile);
|
||||
using var loader = new ClientRuntimeLoader(Options, Copy(trusted, "a.dll"), pointer);
|
||||
loader.Load();
|
||||
|
||||
var errors = CaptureError(() =>
|
||||
{
|
||||
Thread.Sleep(600);
|
||||
loader.Update(0.01f, new InputState());
|
||||
});
|
||||
|
||||
Assert.Contains("x\\\\y.dll", errors); // one literal backslash is shown as two characters
|
||||
Assert.DoesNotContain("x\\\\\\\\y.dll", errors); // not four: the message is not escaped a second time
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AStartupRefusalIsLoggedByTheHostAndExitsNonzero()
|
||||
{
|
||||
var volumeRoot = Path.GetPathRoot(Path.GetFullPath(work))!;
|
||||
var exit = 0;
|
||||
var errors = CaptureError(() => exit = AquariumHost.Run(["--headless", "--client-trusted-root", volumeRoot]));
|
||||
|
||||
Assert.Equal(1, exit);
|
||||
Assert.Contains("Client runtime failed to start (System.ArgumentException).", errors);
|
||||
Assert.Contains("cannot be the client runtime trusted root", errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ALoadedClientSeesItsOwnFilePathAsAssemblyLocation()
|
||||
{
|
||||
var path = Copy(trusted, "a.dll");
|
||||
using var loader = new ClientRuntimeLoader(Options, path);
|
||||
loader.Load();
|
||||
|
||||
Assert.Equal(ClientRuntimeLoader.ResolveLinks(path), loader.Runtime.GetType().Assembly.Location);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoggedPathsEscapeAstralFormatCharactersAndLoneSurrogatesByCodePoint()
|
||||
{
|
||||
Assert.Equal("a\\U000e0001b", ClientRuntimeLoader.Printable("a\U000E0001b")); // language tag
|
||||
Assert.Equal("a\\U000e0041b", ClientRuntimeLoader.Printable("a\U000E0041b")); // tag letter
|
||||
Assert.Equal("a\\U0001d173b", ClientRuntimeLoader.Printable("a\U0001D173b")); // musical begin beam
|
||||
Assert.Equal("a\\ud800b", ClientRuntimeLoader.Printable("a\ud800b")); // lone high surrogate
|
||||
Assert.Equal("a\\udc00b", ClientRuntimeLoader.Printable("a\udc00b")); // lone low surrogate
|
||||
Assert.Equal("a\\ud800\\ud800b", ClientRuntimeLoader.Printable("a\ud800\ud800b")); // two highs in a row
|
||||
Assert.Equal("a\U0001F600b", ClientRuntimeLoader.Printable("a\U0001F600b")); // an ordinary astral character is kept
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoggedPathsEscapeBackslashesLineSeparatorsAndBidiAndZeroWidthControls()
|
||||
{
|
||||
Assert.Equal("a\\\\b", ClientRuntimeLoader.Printable("a\\b"));
|
||||
Assert.Equal("a\\u2028b", ClientRuntimeLoader.Printable("a\u2028b"));
|
||||
Assert.Equal("a\\u2029b", ClientRuntimeLoader.Printable("a\u2029b"));
|
||||
Assert.Equal("a\\u202eb", ClientRuntimeLoader.Printable("a\u202eb"));
|
||||
Assert.Equal("a\\u200bb", ClientRuntimeLoader.Printable("a\u200bb"));
|
||||
Assert.Equal("plain-name.dll", ClientRuntimeLoader.Printable("plain-name.dll"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DevScriptsKeepEverythingLoadableUnderOneAssembliesRootThatIsNotTheWholeDevRoot()
|
||||
{
|
||||
var scripts = Path.Combine(RepositoryRoot(), "scripts");
|
||||
var reload = File.ReadAllText(Path.Combine(scripts, "dev-reload.ps1"));
|
||||
var watch = File.ReadAllText(Path.Combine(scripts, "dev-watch.ps1"));
|
||||
|
||||
Assert.Contains("\"--client-trusted-root\", $assemblyRoot", reload);
|
||||
Assert.DoesNotContain("\"--client-trusted-root\", $devRoot", reload);
|
||||
Assert.Contains("$slotRoot = Join-Path $assemblyRoot \"slots\"", reload);
|
||||
Assert.Contains("artifacts\\dev-reload\\assemblies\\live-slots", watch);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DevScriptsMatchTheHostAckByItsUniqueSlotLeafPruneLiveSlotsAndRejectStaleReopenState()
|
||||
{
|
||||
var scripts = Path.Combine(RepositoryRoot(), "scripts");
|
||||
var reload = File.ReadAllText(Path.Combine(scripts, "dev-reload.ps1"));
|
||||
var watch = File.ReadAllText(Path.Combine(scripts, "dev-watch.ps1"));
|
||||
|
||||
Assert.Contains(".Replace(\"\\\", \"\\\\\")", watch); // the host logs backslashes doubled
|
||||
Assert.DoesNotContain("Contains($appliedLine)", watch);
|
||||
Assert.Contains("@($slotRoot, $liveSlotRoot)", reload);
|
||||
Assert.Contains("outside the assemblies root", reload);
|
||||
Assert.Contains("no longer exists (pruned slot)", reload);
|
||||
}
|
||||
|
||||
private static string CaptureError(Action action)
|
||||
{
|
||||
var original = Console.Error;
|
||||
using var captured = new StringWriter();
|
||||
Console.SetError(captured);
|
||||
try
|
||||
{
|
||||
action();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.SetError(original);
|
||||
}
|
||||
|
||||
return captured.ToString();
|
||||
}
|
||||
|
||||
private static string RepositoryRoot()
|
||||
{
|
||||
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "Fensalir.sln")))
|
||||
{
|
||||
directory = directory.Parent;
|
||||
}
|
||||
|
||||
return directory?.FullName ?? throw new InvalidOperationException("Fensalir.sln not found above the test binaries.");
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue