mirror of
https://github.com/par274/sharpemu.git
synced 2026-08-04 00:49:45 +08:00
Ampr: fix path case on Linux (#750)
This commit is contained in:
@@ -72,7 +72,7 @@ public static class AmprExports
|
|||||||
private const int MaxCachedHostFiles = 1536;
|
private const int MaxCachedHostFiles = 1536;
|
||||||
private static readonly object _hostFileCacheGate = new();
|
private static readonly object _hostFileCacheGate = new();
|
||||||
private static readonly Dictionary<string, LinkedListNode<CachedHostFileEntry>> _hostFileByPath =
|
private static readonly Dictionary<string, LinkedListNode<CachedHostFileEntry>> _hostFileByPath =
|
||||||
new(StringComparer.OrdinalIgnoreCase);
|
new(HostFsPath.Comparer);
|
||||||
private static readonly LinkedList<CachedHostFileEntry> _hostFileLru = new();
|
private static readonly LinkedList<CachedHostFileEntry> _hostFileLru = new();
|
||||||
|
|
||||||
[SysAbiExport(
|
[SysAbiExport(
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ internal static class AmprFileRegistry
|
|||||||
{
|
{
|
||||||
while (true)
|
while (true)
|
||||||
{
|
{
|
||||||
if (string.Equals(_indexedApp0Root, normalizedRoot, StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(_indexedApp0Root, normalizedRoot, HostFsPath.Comparison))
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -123,7 +123,7 @@ internal static class AmprFileRegistry
|
|||||||
if (string.Equals(
|
if (string.Equals(
|
||||||
_indexingApp0Root,
|
_indexingApp0Root,
|
||||||
normalizedRoot,
|
normalizedRoot,
|
||||||
StringComparison.OrdinalIgnoreCase))
|
HostFsPath.Comparison))
|
||||||
{
|
{
|
||||||
Monitor.Wait(_indexGate);
|
Monitor.Wait(_indexGate);
|
||||||
continue;
|
continue;
|
||||||
@@ -174,20 +174,34 @@ internal static class AmprFileRegistry
|
|||||||
}
|
}
|
||||||
|
|
||||||
var relatives = new List<string>(256 * 1024);
|
var relatives = new List<string>(256 * 1024);
|
||||||
foreach (var hostPath in Directory.EnumerateFiles(
|
try
|
||||||
normalizedRoot,
|
|
||||||
"*",
|
|
||||||
SearchOption.AllDirectories))
|
|
||||||
{
|
{
|
||||||
var relative = Path.GetRelativePath(normalizedRoot, hostPath)
|
foreach (var hostPath in Directory.EnumerateFiles(
|
||||||
.Replace('\\', '/');
|
normalizedRoot,
|
||||||
if (string.IsNullOrEmpty(relative) ||
|
"*",
|
||||||
relative.StartsWith("..", StringComparison.Ordinal))
|
SearchOption.AllDirectories))
|
||||||
{
|
{
|
||||||
continue;
|
var relative = Path.GetRelativePath(normalizedRoot, hostPath)
|
||||||
}
|
.Replace('\\', '/');
|
||||||
|
if (string.IsNullOrEmpty(relative) ||
|
||||||
|
relative.StartsWith("..", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
relatives.Add(relative);
|
relatives.Add(relative);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
// The walk is an opportunistic warm-up reached synchronously from
|
||||||
|
// sceAmprCommandBufferConstructor; a dump that moves or a mount
|
||||||
|
// that hiccups must not fault the guest export. The background
|
||||||
|
// preload already swallows this. Leave the root unindexed so a
|
||||||
|
// later call retries.
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
$"[LOADER][WARN] ampr.app0_index_walk_failed root={normalizedRoot}: {exception.Message}");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hash + dictionary fill dominates under Rosetta once the walk is
|
// Hash + dictionary fill dominates under Rosetta once the walk is
|
||||||
@@ -315,7 +329,10 @@ internal static class AmprFileRegistry
|
|||||||
"ampr-index");
|
"ampr-index");
|
||||||
Directory.CreateDirectory(cacheDir);
|
Directory.CreateDirectory(cacheDir);
|
||||||
|
|
||||||
var rootHash = ComputeFileId(normalizedRoot.ToLowerInvariant());
|
// Distinct roots must not share a cache file. Folding case is only
|
||||||
|
// correct where the host filesystem folds it too.
|
||||||
|
var rootKey = OperatingSystem.IsWindows() ? normalizedRoot.ToLowerInvariant() : normalizedRoot;
|
||||||
|
var rootHash = ComputeFileId(rootKey);
|
||||||
return Path.Combine(cacheDir, $"app0-{rootHash:x8}.v{version}.idx");
|
return Path.Combine(cacheDir, $"app0-{rootHash:x8}.v{version}.idx");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,7 +377,7 @@ internal static class AmprFileRegistry
|
|||||||
}
|
}
|
||||||
|
|
||||||
var root = reader.ReadString();
|
var root = reader.ReadString();
|
||||||
if (!string.Equals(root, normalizedRoot, StringComparison.OrdinalIgnoreCase))
|
if (!string.Equals(root, normalizedRoot, HostFsPath.Comparison))
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -470,7 +487,7 @@ internal static class AmprFileRegistry
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var relatives = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
var relatives = new HashSet<string>(HostFsPath.Comparer);
|
||||||
foreach (var hostPath in _hostPathsById.Values)
|
foreach (var hostPath in _hostPathsById.Values)
|
||||||
{
|
{
|
||||||
var relative = Path.GetRelativePath(normalizedRoot, hostPath)
|
var relative = Path.GetRelativePath(normalizedRoot, hostPath)
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
namespace SharpEmu.Libs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Key equivalence for caches and comparisons over <em>host</em> filesystem
|
||||||
|
/// paths. Windows resolves names case-insensitively, but Linux hosts are
|
||||||
|
/// case-sensitive and the guest filesystem is too, so a dump can legitimately
|
||||||
|
/// contain "DATA.BIN" alongside "Data.bin". An ignore-case cache aliases those
|
||||||
|
/// distinct files into one entry there, which silently serves the wrong bytes
|
||||||
|
/// or drops one of them entirely.
|
||||||
|
/// </summary>
|
||||||
|
internal static class HostFsPath
|
||||||
|
{
|
||||||
|
public static readonly StringComparer Comparer =
|
||||||
|
OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal;
|
||||||
|
|
||||||
|
public static readonly StringComparison Comparison =
|
||||||
|
OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
|
||||||
|
}
|
||||||
@@ -117,17 +117,12 @@ public static partial class KernelMemoryCompatExports
|
|||||||
private static readonly Dictionary<string, string> _guestMounts = new(StringComparer.OrdinalIgnoreCase);
|
private static readonly Dictionary<string, string> _guestMounts = new(StringComparer.OrdinalIgnoreCase);
|
||||||
private static readonly HashSet<string> _tracedStatResults = new(StringComparer.Ordinal);
|
private static readonly HashSet<string> _tracedStatResults = new(StringComparer.Ordinal);
|
||||||
// Both caches memoize host filesystem probe outcomes, so their key
|
// Both caches memoize host filesystem probe outcomes, so their key
|
||||||
// equivalence must match the host filesystem's: Windows resolves names
|
// equivalence must match the host filesystem's — see HostFsPath. On a
|
||||||
// case-insensitively, but Linux hosts are case-sensitive, and an
|
// case-sensitive host an ignore-case cache aliases distinct paths: a
|
||||||
// ignore-case cache there aliases distinct paths — a cached miss for
|
// cached miss for "/app0/DATA.BIN" keeps answering NOT_FOUND for
|
||||||
// "/app0/DATA.BIN" keeps answering NOT_FOUND for "/app0/Data.bin" even
|
// "/app0/Data.bin" even though that file exists.
|
||||||
// though that file exists and a fresh probe would find it.
|
private static readonly HashSet<string> _negativeStatCache = new(HostFsPath.Comparer);
|
||||||
private static readonly StringComparer HostFsPathComparer =
|
private static readonly ConcurrentDictionary<string, ulong> _aprFileSizeCache = new(HostFsPath.Comparer);
|
||||||
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;
|
private static long _nextFileDescriptor = 2;
|
||||||
private static string _applicationTitleId = "UNKNOWN";
|
private static string _applicationTitleId = "UNKNOWN";
|
||||||
|
|
||||||
@@ -5203,8 +5198,8 @@ public static partial class KernelMemoryCompatExports
|
|||||||
// host would let a relative path escape into a sibling directory that
|
// host would let a relative path escape into a sibling directory that
|
||||||
// differs from the mount root only by case (root ".../Save" vs
|
// differs from the mount root only by case (root ".../Save" vs
|
||||||
// sibling ".../save").
|
// sibling ".../save").
|
||||||
if (!string.Equals(candidate, matchedHostRoot, HostFsPathComparison) &&
|
if (!string.Equals(candidate, matchedHostRoot, HostFsPath.Comparison) &&
|
||||||
!candidate.StartsWith(rootWithSeparator, HostFsPathComparison))
|
!candidate.StartsWith(rootWithSeparator, HostFsPath.Comparison))
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -5305,8 +5300,8 @@ public static partial class KernelMemoryCompatExports
|
|||||||
|
|
||||||
var rootWithSeparator =
|
var rootWithSeparator =
|
||||||
Path.TrimEndingDirectorySeparator(fullRoot) + Path.DirectorySeparatorChar;
|
Path.TrimEndingDirectorySeparator(fullRoot) + Path.DirectorySeparatorChar;
|
||||||
if (!string.Equals(candidate, fullRoot, HostFsPathComparison) &&
|
if (!string.Equals(candidate, fullRoot, HostFsPath.Comparison) &&
|
||||||
!candidate.StartsWith(rootWithSeparator, HostFsPathComparison))
|
!candidate.StartsWith(rootWithSeparator, HostFsPath.Comparison))
|
||||||
{
|
{
|
||||||
return string.Empty;
|
return string.Empty;
|
||||||
}
|
}
|
||||||
@@ -5332,7 +5327,7 @@ public static partial class KernelMemoryCompatExports
|
|||||||
private static bool EscapesMountViaReparsePoint(string mountRoot, string candidate)
|
private static bool EscapesMountViaReparsePoint(string mountRoot, string candidate)
|
||||||
{
|
{
|
||||||
var rootTrimmed = Path.TrimEndingDirectorySeparator(mountRoot);
|
var rootTrimmed = Path.TrimEndingDirectorySeparator(mountRoot);
|
||||||
if (string.Equals(candidate, rootTrimmed, HostFsPathComparison))
|
if (string.Equals(candidate, rootTrimmed, HostFsPath.Comparison))
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ using Xunit;
|
|||||||
|
|
||||||
namespace SharpEmu.Libs.Tests.Ampr;
|
namespace SharpEmu.Libs.Tests.Ampr;
|
||||||
|
|
||||||
|
// AmprFileRegistry is process-global static state, so the classes that index
|
||||||
|
// or clear it must not run concurrently with each other.
|
||||||
|
[Collection("AmprFileRegistry")]
|
||||||
public class AmprFileRegistryTests
|
public class AmprFileRegistryTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -62,6 +65,79 @@ public class AmprFileRegistryTests
|
|||||||
Assert.Equal(host, d);
|
Assert.Equal(host, d);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void App0_index_cache_keeps_files_that_differ_only_by_case()
|
||||||
|
{
|
||||||
|
var root = Path.Combine(Path.GetTempPath(), "sharpemu-ampr-case-" + Guid.NewGuid().ToString("N"));
|
||||||
|
var cacheDir = Path.Combine(root, "..", "sharpemu-ampr-cache-" + Guid.NewGuid().ToString("N"));
|
||||||
|
var upper = Path.Combine(root, "data", "ASSET.bin");
|
||||||
|
var lower = Path.Combine(root, "data", "asset.bin");
|
||||||
|
var previousCacheDir = Environment.GetEnvironmentVariable("SHARPEMU_AMPR_INDEX_CACHE");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(Path.Combine(root, "sce_sys"));
|
||||||
|
Directory.CreateDirectory(Path.Combine(root, "data"));
|
||||||
|
File.WriteAllText(Path.Combine(root, "sce_sys", "param.json"), "{}");
|
||||||
|
File.WriteAllBytes(upper, [1, 2, 3]);
|
||||||
|
if (File.Exists(lower))
|
||||||
|
{
|
||||||
|
// Case-insensitive host: the two names are one file, so there is
|
||||||
|
// nothing for an ignore-case index to lose.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
File.WriteAllBytes(lower, [4, 5, 6]);
|
||||||
|
Environment.SetEnvironmentVariable("SHARPEMU_AMPR_INDEX_CACHE", cacheDir);
|
||||||
|
|
||||||
|
var normalizedRoot = Path.GetFullPath(root);
|
||||||
|
var expectedUpper = Path.Combine(normalizedRoot, "data", "ASSET.bin");
|
||||||
|
var expectedLower = Path.Combine(normalizedRoot, "data", "asset.bin");
|
||||||
|
|
||||||
|
// Fresh tree walk, which also writes the on-disk index cache.
|
||||||
|
AmprFileRegistry.ClearForTests();
|
||||||
|
AmprFileRegistry.EnsureApp0Indexed(root);
|
||||||
|
AssertResolves(expectedUpper, expectedLower);
|
||||||
|
|
||||||
|
// Second boot: served from the cache the walk just wrote.
|
||||||
|
AmprFileRegistry.ClearForTests();
|
||||||
|
AmprFileRegistry.EnsureApp0Indexed(root);
|
||||||
|
AssertResolves(expectedUpper, expectedLower);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Environment.SetEnvironmentVariable("SHARPEMU_AMPR_INDEX_CACHE", previousCacheDir);
|
||||||
|
AmprFileRegistry.ClearForTests();
|
||||||
|
TryDeleteDirectory(cacheDir);
|
||||||
|
TryDeleteDirectory(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void AssertResolves(string expectedUpper, string expectedLower)
|
||||||
|
{
|
||||||
|
Assert.True(
|
||||||
|
AmprFileRegistry.TryGetHostPath(
|
||||||
|
AmprFileRegistry.ComputeFileId("$/data/ASSET.bin"), out var actualUpper),
|
||||||
|
"data/ASSET.bin is missing from the app0 index.");
|
||||||
|
Assert.True(
|
||||||
|
AmprFileRegistry.TryGetHostPath(
|
||||||
|
AmprFileRegistry.ComputeFileId("$/data/asset.bin"), out var actualLower),
|
||||||
|
"data/asset.bin is missing from the app0 index.");
|
||||||
|
Assert.Equal(expectedUpper, actualUpper);
|
||||||
|
Assert.Equal(expectedLower, actualLower);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void TryDeleteDirectory(string path)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.Delete(path, recursive: true);
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
// Temp cleanup is best-effort.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static uint FnvUtf8(string text)
|
private static uint FnvUtf8(string text)
|
||||||
{
|
{
|
||||||
const uint offset = 2166136261;
|
const uint offset = 2166136261;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ using Xunit;
|
|||||||
|
|
||||||
namespace SharpEmu.Libs.Tests.Ampr;
|
namespace SharpEmu.Libs.Tests.Ampr;
|
||||||
|
|
||||||
|
[Collection("AmprFileRegistry")]
|
||||||
public sealed class AmprWriteAddressTests
|
public sealed class AmprWriteAddressTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ using Xunit;
|
|||||||
|
|
||||||
namespace SharpEmu.Libs.Tests.Ampr;
|
namespace SharpEmu.Libs.Tests.Ampr;
|
||||||
|
|
||||||
|
[Collection("AmprFileRegistry")]
|
||||||
public sealed class AprStreamingContractTests
|
public sealed class AprStreamingContractTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
Reference in New Issue
Block a user