[Kernel] Match path cache comparisons to host filesystem case sensitivity (#381)

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.
This commit is contained in:
999sian
2026-07-18 11:36:01 +01:00
committed by GitHub
parent 13269797bf
commit f84d869795
2 changed files with 167 additions and 4 deletions
@@ -110,8 +110,18 @@ public static partial class KernelMemoryCompatExports
private static readonly Dictionary<ulong, string> _mappedRegionNames = new();
private static readonly Dictionary<string, string> _guestMounts = new(StringComparer.OrdinalIgnoreCase);
private static readonly HashSet<string> _tracedStatResults = new(StringComparer.Ordinal);
private static readonly HashSet<string> _negativeStatCache = new(StringComparer.OrdinalIgnoreCase);
private static readonly ConcurrentDictionary<string, ulong> _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<string> _negativeStatCache = new(HostFsPathComparer);
private static readonly ConcurrentDictionary<string, ulong> _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;
}
@@ -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<byte> 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<byte> sizeBytes = stackalloc byte[sizeof(ulong)];
Assert.True(memory.TryRead(SizesAddress, sizeBytes));
return BitConverter.ToUInt64(sizeBytes);
}
}