Fensalir/tests/Aquarium.Engine.Tests/ClientRuntimeLoaderTrustTests.cs
2026-11-07 11:48:52 +00:00

485 lines
20 KiB
C#

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.");
}
}