From f84d86979525b666ac5906f9eb184e6789010c8d Mon Sep 17 00:00:00 2001 From: 999sian <71638559+999sian@users.noreply.github.com> Date: Sat, 18 Jul 2026 11:36:01 +0100 Subject: [PATCH] [Kernel] Match path cache comparisons to host filesystem case sensitivity (#381) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The negative-stat cache and the apr file-size cache memoize host filesystem probe outcomes, but both were keyed with an ignore-case comparer while the probes themselves (File.Exists/Directory.Exists/ FileInfo) are case-sensitive on Linux. That aliases distinct paths: - stat("/app0/DATA.BIN") fails, the miss is cached, and a later stat("/app0/Data.bin") is answered NOT_FOUND from the cache without ever probing the disk - even though the file exists and the probe would succeed. - sceKernelAprResolveFilepathsToIdsAndFileSizes serves the cached size of a case-distinct sibling file instead of the file's own size. The registered-mount containment guard had the inverse problem: the ignore-case StartsWith accepted a ".." path that resolves into a sibling directory differing from the mount root only by case ("…/Save" vs "…/save"), letting guest I/O escape the mount. All three sites now compare with the host filesystem's semantics: ordinal-ignore-case on Windows, ordinal elsewhere. Windows behavior is unchanged. Tests probe actual host filesystem behavior with real temp files and skip their case-specific sections on case-insensitive hosts. --- .../Kernel/KernelMemoryCompatExports.cs | 22 ++- .../Kernel/KernelPathCaseSensitivityTests.cs | 149 ++++++++++++++++++ 2 files changed, 167 insertions(+), 4 deletions(-) create mode 100644 tests/SharpEmu.Libs.Tests/Kernel/KernelPathCaseSensitivityTests.cs diff --git a/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs b/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs index c14e48c4..7191f302 100644 --- a/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs +++ b/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs @@ -110,8 +110,18 @@ public static partial class KernelMemoryCompatExports private static readonly Dictionary _mappedRegionNames = new(); private static readonly Dictionary _guestMounts = new(StringComparer.OrdinalIgnoreCase); private static readonly HashSet _tracedStatResults = new(StringComparer.Ordinal); - private static readonly HashSet _negativeStatCache = new(StringComparer.OrdinalIgnoreCase); - private static readonly ConcurrentDictionary _aprFileSizeCache = new(StringComparer.OrdinalIgnoreCase); + // Both caches memoize host filesystem probe outcomes, so their key + // equivalence must match the host filesystem's: Windows resolves names + // case-insensitively, but Linux hosts are case-sensitive, and an + // ignore-case cache there aliases distinct paths — a cached miss for + // "/app0/DATA.BIN" keeps answering NOT_FOUND for "/app0/Data.bin" even + // though that file exists and a fresh probe would find it. + private static readonly StringComparer HostFsPathComparer = + OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; + private static readonly StringComparison HostFsPathComparison = + OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + private static readonly HashSet _negativeStatCache = new(HostFsPathComparer); + private static readonly ConcurrentDictionary _aprFileSizeCache = new(HostFsPathComparer); private static long _nextFileDescriptor = 2; internal static int AllocateGuestFileDescriptor() @@ -4706,8 +4716,12 @@ public static partial class KernelMemoryCompatExports matchedHostRoot, NormalizeMountRelativePath(relativePath))); var rootWithSeparator = Path.TrimEndingDirectorySeparator(matchedHostRoot) + Path.DirectorySeparatorChar; - if (!string.Equals(candidate, matchedHostRoot, StringComparison.OrdinalIgnoreCase) && - !candidate.StartsWith(rootWithSeparator, StringComparison.OrdinalIgnoreCase)) + // Host-semantics comparison: an ignore-case check on a case-sensitive + // host would let a relative path escape into a sibling directory that + // differs from the mount root only by case (root ".../Save" vs + // sibling ".../save"). + if (!string.Equals(candidate, matchedHostRoot, HostFsPathComparison) && + !candidate.StartsWith(rootWithSeparator, HostFsPathComparison)) { return false; } diff --git a/tests/SharpEmu.Libs.Tests/Kernel/KernelPathCaseSensitivityTests.cs b/tests/SharpEmu.Libs.Tests/Kernel/KernelPathCaseSensitivityTests.cs new file mode 100644 index 00000000..96933871 --- /dev/null +++ b/tests/SharpEmu.Libs.Tests/Kernel/KernelPathCaseSensitivityTests.cs @@ -0,0 +1,149 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using SharpEmu.HLE; +using SharpEmu.Libs.Kernel; +using Xunit; + +namespace SharpEmu.Libs.Tests.Kernel; + +// The negative-stat and apr-file-size caches memoize host filesystem probe +// results, so their key equivalence must match the host filesystem's. These +// tests pin the case-sensitive-host behavior: a cached miss for one casing +// must never shadow a differently-cased path whose probe would succeed, and +// mount containment must not accept a case-sibling escape. On case-insensitive +// hosts (Windows, default macOS volumes) the aliased paths genuinely are the +// same file, so the case-specific sections skip themselves. +[Collection(KernelMemoryCompatStateCollection.Name)] +public sealed class KernelPathCaseSensitivityTests : IDisposable +{ + private const ulong MemoryBase = 0x1_0000_0000; + private const ulong PathAddress = MemoryBase + 0x100; + private const ulong StatAddress = MemoryBase + 0x400; + private const ulong PathListAddress = MemoryBase + 0x900; + private const ulong PathBytesAddress = MemoryBase + 0xA00; + private const ulong IdsAddress = MemoryBase + 0xB00; + private const ulong SizesAddress = MemoryBase + 0xB40; + + private readonly string _tempRoot; + + public KernelPathCaseSensitivityTests() + { + _tempRoot = Path.Combine( + Path.GetTempPath(), + $"sharpemu-kernel-case-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_tempRoot); + } + + public void Dispose() + { + Directory.Delete(_tempRoot, recursive: true); + } + + [Fact] + public void Stat_CachedMissForOneCasingDoesNotShadowExistingFile() + { + var app0Root = Path.Combine(_tempRoot, "app0"); + var unique = $"case_{Guid.NewGuid():N}"; + Directory.CreateDirectory(Path.Combine(app0Root, unique)); + File.WriteAllBytes(Path.Combine(app0Root, unique, "Data.bin"), [1, 2, 3]); + KernelMemoryCompatExports.RegisterGuestPathMount("/app0", app0Root); + + // Prime the negative-stat cache with the wrongly-cased sibling. On a + // case-sensitive host the probe fails and the miss is cached; on a + // case-insensitive host the probe finds Data.bin and nothing is cached. + var missResult = PosixStat($"/app0/{unique}/DATA.BIN"); + if (HostFsIsCaseSensitive()) + { + Assert.Equal(-1, missResult); + } + + // The correctly-cased path exists; the cached miss above must not be + // served for it. + Assert.Equal(0, PosixStat($"/app0/{unique}/Data.bin")); + } + + [Fact] + public void AprResolve_DistinctlyCasedHostFilesReportTheirOwnSizes() + { + if (!HostFsIsCaseSensitive()) + { + return; + } + + var app0Root = Path.Combine(_tempRoot, "app0"); + var unique = $"apr_{Guid.NewGuid():N}"; + var assetDir = Path.Combine(app0Root, unique); + Directory.CreateDirectory(assetDir); + File.WriteAllBytes(Path.Combine(assetDir, "asset.bin"), new byte[3]); + File.WriteAllBytes(Path.Combine(assetDir, "ASSET.BIN"), new byte[7]); + KernelMemoryCompatExports.RegisterGuestPathMount("/app0", app0Root); + + // Whichever casing resolves first lands in the size cache; the other + // casing is a different host file and must not inherit its size. + Assert.Equal(3UL, AprResolveSize($"/app0/{unique}/asset.bin")); + Assert.Equal(7UL, AprResolveSize($"/app0/{unique}/ASSET.BIN")); + } + + [Fact] + public void MountResolution_RejectsCaseSiblingEscape() + { + if (!HostFsIsCaseSensitive()) + { + return; + } + + var mountRoot = Path.Combine(_tempRoot, "Save"); + var sibling = Path.Combine(_tempRoot, "save"); + Directory.CreateDirectory(mountRoot); + Directory.CreateDirectory(sibling); + File.WriteAllBytes(Path.Combine(sibling, "secret.bin"), [1]); + KernelMemoryCompatExports.RegisterGuestPathMount("/sharpemu_case_mnt", mountRoot); + + // "../save/..." leaves the mount root; only a case-insensitive + // containment check lets it pass by matching the "Save" prefix. + var resolved = KernelMemoryCompatExports.ResolveGuestPath( + "/sharpemu_case_mnt/../save/secret.bin"); + + Assert.False(File.Exists(resolved)); + } + + private bool HostFsIsCaseSensitive() + { + var name = $"probe_{Guid.NewGuid():N}"; + var probe = Path.Combine(_tempRoot, name + ".tmp"); + File.WriteAllText(probe, string.Empty); + return !File.Exists(Path.Combine(_tempRoot, name.ToUpperInvariant() + ".TMP")); + } + + private static int PosixStat(string guestPath) + { + var memory = new FakeCpuMemory(MemoryBase, 0x1000); + var context = new CpuContext(memory, Generation.Gen5); + memory.WriteCString(PathAddress, guestPath); + context[CpuRegister.Rdi] = PathAddress; + context[CpuRegister.Rsi] = StatAddress; + return KernelMemoryCompatExports.PosixStat(context); + } + + private static ulong AprResolveSize(string guestPath) + { + var memory = new FakeCpuMemory(MemoryBase, 0x1000); + var context = new CpuContext(memory, Generation.Gen5); + memory.WriteCString(PathBytesAddress, guestPath); + Span pointerBytes = stackalloc byte[sizeof(ulong)]; + BitConverter.TryWriteBytes(pointerBytes, PathBytesAddress); + Assert.True(memory.TryWrite(PathListAddress, pointerBytes)); + context[CpuRegister.Rdi] = PathListAddress; + context[CpuRegister.Rsi] = 1; + context[CpuRegister.Rdx] = IdsAddress; + context[CpuRegister.Rcx] = SizesAddress; + + var result = KernelMemoryCompatExports.KernelAprResolveFilepathsToIdsAndFileSizes(context); + Assert.Equal(0, result); + + Span sizeBytes = stackalloc byte[sizeof(ulong)]; + Assert.True(memory.TryRead(SizesAddress, sizeBytes)); + return BitConverter.ToUInt64(sizeBytes); + } +}