Client runtime: escape logged paths once by code point, report untrusted mount points as refusals, load assemblies without file locks
Exception messages carry raw paths and every console write escapes them once (Printable iterates runes: astral format characters and lone surrogates). AquariumHost reports startup refusals escaped. IOException from link resolution becomes InvalidOperationException. Managed assemblies load from memory so the temp dir can be deleted on Windows. The newline-in-file-name test is skipped on Windows. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
467a97f540
commit
d2f280d9f2
3 changed files with 139 additions and 20 deletions
|
|
@ -18,8 +18,22 @@ public static class AquariumHost
|
|||
ParseFieldReservoirMode(args),
|
||||
ParseFieldReservoirScale(args),
|
||||
ParseFieldReservoirSpatialReuseBudget(args));
|
||||
using var runtimeLoader = new ClientRuntimeLoader(runtimeOptions, ParseClientAssemblyPath(args), ParseClientReloadPointerPath(args), ParseClientTrustedRoot(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.Message)}");
|
||||
loader?.Dispose();
|
||||
return 1;
|
||||
}
|
||||
|
||||
using var runtimeLoader = loader;
|
||||
if (runtimeOptions.RenderDebugModeOverride is { } renderDebugModeOverride)
|
||||
{
|
||||
runtime.GraphicsSettings = (runtime.GraphicsSettings with
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ public sealed class ClientRuntimeLoader : IDisposable
|
|||
Path.TrimEndingDirectorySeparator(Path.GetPathRoot(root) ?? root),
|
||||
PathComparison))
|
||||
{
|
||||
throw new ArgumentException($"A drive or volume root cannot be the client runtime trusted root: {Printable(root)}", nameof(trustedRoot));
|
||||
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)}");
|
||||
|
|
@ -83,7 +83,7 @@ public sealed class ClientRuntimeLoader : IDisposable
|
|||
|
||||
try
|
||||
{
|
||||
var assembly = loadContext.LoadFromAssemblyPath(assemblyPath);
|
||||
var assembly = loadContext.LoadFromFileWithoutLock(assemblyPath);
|
||||
var factoryTypes = assembly.GetTypes()
|
||||
.Where(type => typeof(IAquariumRuntimeFactory).IsAssignableFrom(type)
|
||||
&& type is { IsAbstract: false, IsInterface: false })
|
||||
|
|
@ -185,7 +185,7 @@ public sealed class ClientRuntimeLoader : IDisposable
|
|||
{
|
||||
if (!roots.Any(root => root is not null && IsUnder(root, fullPath)))
|
||||
{
|
||||
throw new InvalidOperationException($"Client runtime path is outside the trusted root '{Printable(roots[0] ?? "")}': {Printable(fullPath)}");
|
||||
throw new InvalidOperationException($"Client runtime path is outside the trusted root '{roots[0]}': {fullPath}");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -203,7 +203,7 @@ public sealed class ClientRuntimeLoader : IDisposable
|
|||
var resolved = ResolveLinks(Path.GetFullPath(path));
|
||||
if (!IsUnder(resolvedRoot, resolved))
|
||||
{
|
||||
throw new InvalidOperationException($"Client runtime path is outside the trusted root '{Printable(resolvedRoot)}': {Printable(path)}");
|
||||
throw new InvalidOperationException($"Client runtime path is outside the trusted root '{resolvedRoot}': {path}");
|
||||
}
|
||||
|
||||
return resolved;
|
||||
|
|
@ -213,29 +213,46 @@ public sealed class ClientRuntimeLoader : IDisposable
|
|||
{
|
||||
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: {Printable(path)}");
|
||||
throw new InvalidOperationException($"Client runtime path must be local and fully qualified: {path}");
|
||||
}
|
||||
}
|
||||
|
||||
// Paths come from files other people can write: backslashes, control, format (bidi, zero-width) and line/paragraph
|
||||
// separator characters are escaped, so a name cannot forge or hide a log line.
|
||||
// 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);
|
||||
foreach (var c in text)
|
||||
for (var index = 0; index < text.Length;)
|
||||
{
|
||||
if (c == '\\')
|
||||
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 (char.IsControl(c) || char.GetUnicodeCategory(c) is System.Globalization.UnicodeCategory.Format
|
||||
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(c switch { '\n' => "\\n", '\r' => "\\r", '\t' => "\\t", _ => $"\\u{(int)c:x4}" });
|
||||
builder.Append(rune.Value switch
|
||||
{
|
||||
'\n' => "\\n",
|
||||
'\r' => "\\r",
|
||||
'\t' => "\\t",
|
||||
<= 0xFFFF => $"\\u{rune.Value:x4}",
|
||||
_ => $"\\U{rune.Value:x8}",
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Append(c);
|
||||
builder.Append(rune.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -249,7 +266,7 @@ public sealed class ClientRuntimeLoader : IDisposable
|
|||
{
|
||||
if (depth > 32)
|
||||
{
|
||||
throw new InvalidOperationException($"Too many links resolving {Printable(path)}.");
|
||||
throw new InvalidOperationException($"Too many links resolving {path}.");
|
||||
}
|
||||
|
||||
for (var probe = path; probe is not null; probe = Path.GetDirectoryName(probe))
|
||||
|
|
@ -260,8 +277,18 @@ public sealed class ClientRuntimeLoader : IDisposable
|
|||
continue;
|
||||
}
|
||||
|
||||
var target = info.ResolveLinkTarget(returnFinalTarget: true)?.FullName
|
||||
?? throw new InvalidOperationException($"Link does not resolve: {Printable(probe)}");
|
||||
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);
|
||||
}
|
||||
|
|
@ -309,7 +336,23 @@ public sealed class ClientRuntimeLoader : IDisposable
|
|||
}
|
||||
|
||||
var path = resolver.ResolveAssemblyToPath(assemblyName);
|
||||
return path == null ? null : LoadFromAssemblyPath(ResolveTrusted(trustedRoot, path));
|
||||
return path == null ? null : LoadFromFileWithoutLock(ResolveTrusted(trustedRoot, path));
|
||||
}
|
||||
|
||||
// LoadFromAssemblyPath maps the file and, on Windows, keeps it locked until the unloaded context is collected:
|
||||
// a rebuilt or deleted client dll (and its temp directory) would stay blocked. Managed assemblies are read into
|
||||
// memory instead, with their symbols when present, so no file handle outlives the load.
|
||||
public Assembly LoadFromFileWithoutLock(string path)
|
||||
{
|
||||
using var image = new MemoryStream(File.ReadAllBytes(path));
|
||||
var symbolsPath = Path.ChangeExtension(path, ".pdb");
|
||||
if (!File.Exists(symbolsPath))
|
||||
{
|
||||
return LoadFromStream(image);
|
||||
}
|
||||
|
||||
using var symbols = new MemoryStream(File.ReadAllBytes(symbolsPath));
|
||||
return LoadFromStream(image, symbols);
|
||||
}
|
||||
|
||||
protected override IntPtr LoadUnmanagedDll(string unmanagedDllName)
|
||||
|
|
|
|||
|
|
@ -35,6 +35,18 @@ public sealed class JunctionFactAttribute : FactAttribute
|
|||
}
|
||||
}
|
||||
|
||||
/// <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);
|
||||
|
|
@ -157,7 +169,8 @@ public sealed class ClientRuntimeLoaderTrustTests : IDisposable
|
|||
|
||||
using var loader = new ClientRuntimeLoader(Options, Copy(trusted, "a.dll"));
|
||||
loader.Load();
|
||||
Assert.Throws<InvalidOperationException>(() => loader.Reload(Path.Combine(junction, "client.dll"), Options));
|
||||
// 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]
|
||||
|
|
@ -231,7 +244,7 @@ public sealed class ClientRuntimeLoaderTrustTests : IDisposable
|
|||
Assert.Contains("local and fully qualified", errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[NewlineInFileNameFact]
|
||||
public void ControlCharactersInAPointerTargetAreEscapedInTheLog()
|
||||
{
|
||||
var hostile = Path.Combine(outside, "a\nClient runtime reload applied: forged.dll");
|
||||
|
|
@ -326,6 +339,55 @@ public sealed class ClientRuntimeLoaderTrustTests : IDisposable
|
|||
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.", errors);
|
||||
Assert.Contains("cannot be the client runtime trusted root", errors);
|
||||
}
|
||||
|
||||
[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()
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue