mirror of
https://github.com/par274/sharpemu.git
synced 2026-07-22 19:06:15 +08:00
f84d869795
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.
150 lines
5.9 KiB
C#
150 lines
5.9 KiB
C#
// 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);
|
|
}
|
|
}
|