diff --git a/src/SharpEmu.Libs/AvPlayer/AvPlayerExports.cs b/src/SharpEmu.Libs/AvPlayer/AvPlayerExports.cs index f34c0f3d..5ea7a84f 100644 --- a/src/SharpEmu.Libs/AvPlayer/AvPlayerExports.cs +++ b/src/SharpEmu.Libs/AvPlayer/AvPlayerExports.cs @@ -1109,7 +1109,7 @@ public static class AvPlayerExports return null; } - private static string? ResolveGuestPath(string guestPath) + internal static string? ResolveGuestPath(string guestPath) { if (string.IsNullOrWhiteSpace(guestPath)) { @@ -1117,13 +1117,39 @@ public static class AvPlayerExports } var normalized = guestPath.Replace('\\', '/'); - if (Uri.TryCreate(normalized, UriKind.Absolute, out var uri) && uri.IsFile) + var fileReference = normalized.StartsWith("file:", StringComparison.OrdinalIgnoreCase); + var unrealProjectRelative = false; + if (normalized.StartsWith("file://", StringComparison.OrdinalIgnoreCase) && + Uri.TryCreate(normalized, UriKind.Absolute, out var uri) && + uri.IsFile) { - normalized = uri.LocalPath; + if (!string.IsNullOrEmpty(uri.Host) && + !string.Equals(uri.Host, "localhost", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + normalized = uri.LocalPath.Replace('\\', '/'); } - if (File.Exists(normalized)) + else if (normalized.StartsWith("file://", StringComparison.OrdinalIgnoreCase)) { - return Path.GetFullPath(normalized); + // Some console middleware emits Unreal-style project-relative + // media references such as file://../../../Project/Content/.... + // System.Uri rejects these because the first ".." is parsed as + // an invalid authority. Treat the scheme as a guest-path marker; + // the app0 sandbox below resolves the relative path. + normalized = normalized["file://".Length..]; + unrealProjectRelative = true; + } + else if (normalized.StartsWith("file:", StringComparison.OrdinalIgnoreCase)) + { + normalized = normalized["file:".Length..]; + unrealProjectRelative = true; + } + + if (unrealProjectRelative) + { + normalized = RemoveUnrealLeadingDotSegments(normalized); } var app0 = Environment.GetEnvironmentVariable("SHARPEMU_APP0_DIR"); @@ -1131,19 +1157,232 @@ public static class AvPlayerExports { return null; } - foreach (var prefix in new[] { "app0:/", "/app0/", "app0:", "/app0" }) + + var app0MountedPath = false; + foreach (var prefix in new[] { "app0:/", "/app0/", "app0/", "app0:" }) { if (normalized.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) { normalized = normalized[prefix.Length..]; + app0MountedPath = true; break; } } - var candidate = Path.GetFullPath(Path.Combine(app0, normalized.TrimStart('/'))); - var root = Path.GetFullPath(app0).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar; - return candidate.StartsWith(root, StringComparison.OrdinalIgnoreCase) && File.Exists(candidate) - ? candidate - : null; + + if (!app0MountedPath && + (string.Equals(normalized, "app0:", StringComparison.OrdinalIgnoreCase) || + string.Equals(normalized, "/app0", StringComparison.OrdinalIgnoreCase) || + string.Equals(normalized, "app0", StringComparison.OrdinalIgnoreCase))) + { + normalized = string.Empty; + app0MountedPath = true; + } + + try + { + if (fileReference) + { + if (!TryDecodeFileReference(normalized, out normalized)) + { + return null; + } + } + else if (ContainsInvalidMediaPathCharacters(normalized)) + { + return null; + } + + if ((!fileReference && + !app0MountedPath && + Uri.TryCreate(normalized, UriKind.Absolute, out _)) || + Path.IsPathFullyQualified(normalized) || + normalized.StartsWith("/", StringComparison.Ordinal)) + { + return null; + } + + if (!TryNormalizeApp0RelativePath(normalized, out var relativePath) || + relativePath.Length == 0) + { + return null; + } + + var root = Path.GetFullPath(app0); + var candidate = Path.GetFullPath(Path.Combine(root, relativePath)); + var relativeToRoot = Path.GetRelativePath(root, candidate); + if (Path.IsPathFullyQualified(relativeToRoot) || + string.Equals(relativeToRoot, "..", StringComparison.Ordinal) || + relativeToRoot.StartsWith( + ".." + Path.DirectorySeparatorChar, + StringComparison.Ordinal)) + { + return null; + } + + return TryResolveSandboxedFile(root, relativePath, out var resolved) + ? resolved + : null; + } + catch (Exception exception) when (exception is ArgumentException or + IOException or + NotSupportedException or + UnauthorizedAccessException or + UriFormatException) + { + return null; + } + } + + private static string RemoveUnrealLeadingDotSegments(string guestPath) + { + while (guestPath.StartsWith("../", StringComparison.Ordinal) || + guestPath.StartsWith("./", StringComparison.Ordinal)) + { + guestPath = guestPath[(guestPath.IndexOf('/') + 1)..]; + } + + return guestPath; + } + + private static bool TryDecodeFileReference(string encoded, out string decoded) + { + decoded = string.Empty; + for (var index = 0; index < encoded.Length; index++) + { + if (encoded[index] != '%') + { + continue; + } + + if (index + 2 >= encoded.Length || + !Uri.IsHexDigit(encoded[index + 1]) || + !Uri.IsHexDigit(encoded[index + 2])) + { + return false; + } + + var escapedByte = Convert.ToByte(encoded.Substring(index + 1, 2), 16); + if (escapedByte is (byte)'/' or (byte)'\\') + { + return false; + } + + index += 2; + } + + decoded = Uri.UnescapeDataString(encoded); + return !ContainsInvalidMediaPathCharacters(decoded); + } + + private static bool ContainsInvalidMediaPathCharacters(string path) => + path.IndexOfAny(['?', '#']) >= 0 || path.Any(char.IsControl); + + private static bool TryNormalizeApp0RelativePath( + string guestPath, + out string relativePath) + { + var segments = new List(); + foreach (var segment in guestPath.TrimStart('/').Split( + '/', + StringSplitOptions.RemoveEmptyEntries)) + { + if (segment == ".") + { + continue; + } + + if (segment == "..") + { + if (segments.Count == 0) + { + relativePath = string.Empty; + return false; + } + + segments.RemoveAt(segments.Count - 1); + continue; + } + + segments.Add(segment); + } + + relativePath = string.Join(Path.DirectorySeparatorChar, segments); + return true; + } + + private static bool TryResolveSandboxedFile( + string root, + string relativePath, + out string resolved) + { + resolved = string.Empty; + var current = root; + var segments = relativePath.Split( + Path.DirectorySeparatorChar, + StringSplitOptions.RemoveEmptyEntries); + for (var index = 0; index < segments.Length; index++) + { + var exact = Path.Combine(current, segments[index]); + var finalSegment = index == segments.Length - 1; + string? match; + if (finalSegment ? File.Exists(exact) : Directory.Exists(exact)) + { + match = exact; + } + else + { + if (!Directory.Exists(current)) + { + return false; + } + + match = null; + foreach (var entry in Directory.EnumerateFileSystemEntries(current)) + { + if (!string.Equals( + Path.GetFileName(entry), + segments[index], + StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (match is not null) + { + // A case-sensitive host can contain two names that are + // indistinguishable to the guest. Refuse an ambiguous + // media path instead of selecting one nondeterministically. + return false; + } + + match = entry; + } + } + + if (match is null || + (finalSegment ? !File.Exists(match) : !Directory.Exists(match))) + { + return false; + } + + if ((File.GetAttributes(match) & FileAttributes.ReparsePoint) != 0) + { + // App packages do not need host filesystem links. Refusing + // them keeps media resolution inside the configured app0 + // tree even when a dump contains a symlink or junction. + return false; + } + + current = match; + } + + if (!File.Exists(current)) + { + return false; + } + + resolved = Path.GetFullPath(current); + return true; } private static bool TryReadNullTerminatedUtf8(CpuContext ctx, ulong address, int maxLength, out string value) diff --git a/tests/SharpEmu.Libs.Tests/AvPlayer/AvPlayerPathTests.cs b/tests/SharpEmu.Libs.Tests/AvPlayer/AvPlayerPathTests.cs new file mode 100644 index 00000000..f35e6b79 --- /dev/null +++ b/tests/SharpEmu.Libs.Tests/AvPlayer/AvPlayerPathTests.cs @@ -0,0 +1,154 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using SharpEmu.Libs.AvPlayer; +using Xunit; + +namespace SharpEmu.Libs.Tests.AvPlayer; + +[CollectionDefinition("AvPlayerPathState", DisableParallelization = true)] +public sealed class AvPlayerPathStateCollection; + +[Collection("AvPlayerPathState")] +public sealed class AvPlayerPathTests : IDisposable +{ + private readonly string? _originalApp0; + private readonly string _tempRoot; + private readonly string _app0Root; + + public AvPlayerPathTests() + { + _originalApp0 = Environment.GetEnvironmentVariable("SHARPEMU_APP0_DIR"); + _tempRoot = Path.Combine( + Path.GetTempPath(), + $"sharpemu-avplayer-{Guid.NewGuid():N}"); + _app0Root = Path.Combine(_tempRoot, "app0"); + Directory.CreateDirectory(_app0Root); + Environment.SetEnvironmentVariable("SHARPEMU_APP0_DIR", _app0Root); + } + + [Fact] + public void UnrealRelativeFileUriAnchorsAtApp0AndResolvesMedia() + { + var mediaPath = CreateFile("Project/Content/Movies/Intro.mp4"); + + var resolved = AvPlayerExports.ResolveGuestPath( + "file://../../../Project/Content/Movies/Intro.mp4"); + + Assert.NotNull(resolved); + Assert.Equal(File.ReadAllBytes(mediaPath), File.ReadAllBytes(resolved)); + AssertPathIsInsideApp0(resolved); + } + + [Fact] + public void RelativeFileUriCannotEscapeApp0() + { + var outsidePath = Path.Combine(_tempRoot, "outside.mp4"); + File.WriteAllBytes(outsidePath, [0x7F]); + + var resolved = AvPlayerExports.ResolveGuestPath("file://../outside.mp4"); + + Assert.Null(resolved); + Assert.Null(AvPlayerExports.ResolveGuestPath("file://%2e%2e/outside.mp4")); + Assert.Null(AvPlayerExports.ResolveGuestPath("app0:/../../outside.mp4")); + Assert.Null(AvPlayerExports.ResolveGuestPath("file://..%2foutside.mp4")); + Assert.Null(AvPlayerExports.ResolveGuestPath("file://../outside.mp4?query")); + Assert.Null(AvPlayerExports.ResolveGuestPath("file://../outside%ZZ.mp4")); + Assert.Null(AvPlayerExports.ResolveGuestPath("file://../outside%00.mp4")); + } + + [Fact] + public void AbsoluteHostPathsCannotBypassApp0() + { + var outsidePath = Path.Combine(_tempRoot, "outside.mp4"); + File.WriteAllBytes(outsidePath, [0x7F]); + + Assert.Null(AvPlayerExports.ResolveGuestPath(outsidePath)); + Assert.Null(AvPlayerExports.ResolveGuestPath(new Uri(outsidePath).AbsoluteUri)); + } + + [Fact] + public void NonFileUrisAndApp0LookalikesAreRejected() + { + CreateFile("evil/intro.mp4"); + + Assert.Null(AvPlayerExports.ResolveGuestPath("https://example.test/intro.mp4")); + Assert.Null(AvPlayerExports.ResolveGuestPath("file://server/share/intro.mp4")); + Assert.Null(AvPlayerExports.ResolveGuestPath("/app0evil/intro.mp4")); + } + + [Fact] + public void SymlinkCannotEscapeApp0() + { + if (OperatingSystem.IsWindows()) + { + return; + } + + var outsideDirectory = Path.Combine(_tempRoot, "outside"); + Directory.CreateDirectory(outsideDirectory); + File.WriteAllBytes(Path.Combine(outsideDirectory, "secret.mp4"), [0x7F]); + Directory.CreateSymbolicLink( + Path.Combine(_app0Root, "linked"), + outsideDirectory); + + Assert.Null(AvPlayerExports.ResolveGuestPath("app0:/linked/secret.mp4")); + } + + [Fact] + public void App0UriStillResolvesMedia() + { + var mediaPath = CreateFile("movies/intro.mp4"); + + var resolved = AvPlayerExports.ResolveGuestPath("app0:/movies/intro.mp4"); + + Assert.Equal(Path.GetFullPath(mediaPath), resolved); + Assert.Equal( + Path.GetFullPath(mediaPath), + AvPlayerExports.ResolveGuestPath("movies/intro.mp4")); + Assert.Equal( + Path.GetFullPath(mediaPath), + AvPlayerExports.ResolveGuestPath("file:///app0/movies/intro.mp4")); + Assert.Equal( + Path.GetFullPath(mediaPath), + AvPlayerExports.ResolveGuestPath("app0:movies/intro.mp4")); + } + + [Fact] + public void GuestMediaLookupIsCaseInsensitiveOnCaseSensitiveHosts() + { + var mediaPath = CreateFile("project/content/movies/intro.mp4"); + + var resolved = AvPlayerExports.ResolveGuestPath( + "file://../../../PROJECT/CONTENT/MOVIES/INTRO.MP4"); + + Assert.NotNull(resolved); + Assert.Equal(File.ReadAllBytes(mediaPath), File.ReadAllBytes(resolved)); + AssertPathIsInsideApp0(resolved); + } + + public void Dispose() + { + Environment.SetEnvironmentVariable("SHARPEMU_APP0_DIR", _originalApp0); + Directory.Delete(_tempRoot, recursive: true); + } + + private string CreateFile(string relativePath) + { + var path = Path.Combine(_app0Root, relativePath); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllBytes(path, [0x01, 0x02, 0x03]); + return path; + } + + private void AssertPathIsInsideApp0(string resolved) + { + var rootWithSeparator = + Path.TrimEndingDirectorySeparator(Path.GetFullPath(_app0Root)) + + Path.DirectorySeparatorChar; + Assert.StartsWith( + rootWithSeparator, + Path.GetFullPath(resolved), + StringComparison.OrdinalIgnoreCase); + } +}