[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
@@ -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);
}
}