From 4ff6ca19edfd9367538559ef65521ce495c6a365 Mon Sep 17 00:00:00 2001 From: Dirk Date: Sun, 4 Oct 2026 22:34:58 +0200 Subject: [PATCH 1/2] Fix startup build warnings and add filesystem regression tests --- .../Patches/LooseBrsarPatchFileNameTests.cs | 29 ++++++++++ .../Patches/ModPatchCompatibilityTests.cs | 47 ++++++++++++++++ .../Features/Recomp/RecompPlatformTests.cs | 27 +++++++++ .../Settings/SettingsRecoveryTests.cs | 10 ++-- WheelWizard.Test/Shared/IO/PathSafetyTests.cs | 39 +++++++++++++ WheelWizard.UI.Test/ModBrowserPreviewTests.cs | 2 + .../CustomDistributions/RetroRewindBeta.cs | 23 +++++--- .../Launching/RetroRewindLaunchDescriptor.cs | 4 +- .../Services/NativeMiiRenderer.cs | 6 +- .../Patches/LooseBrsarPatchFileName.cs | 4 +- .../Patches/ModPatchConversionService.cs | 56 ++++++++++++------- .../Features/Patches/SzsPatchConverter.cs | 6 +- .../Features/Recomp/RecompInstallService.cs | 8 +-- .../Recomp/RecompLinuxInstallService.cs | 4 +- WheelWizard/Features/Recomp/RecompPlatform.cs | 6 +- WheelWizard/Program.cs | 7 ++- WheelWizard/Shared/IO/PathSafety.cs | 28 +++++----- WheelWizard/Views/Pages/MiiListPage.axaml.cs | 2 +- .../Pages/Settings/OtherSettings.axaml.cs | 13 +++-- .../Pages/Settings/RecompSettings.axaml.cs | 13 +++-- .../Pages/Settings/WhWzSettings.axaml.cs | 38 ++++++++----- .../Popups/ModManagement/ModContent.axaml.cs | 25 +++++---- WheelWizard/Views/Storage/StoragePaths.cs | 4 +- 23 files changed, 300 insertions(+), 101 deletions(-) create mode 100644 WheelWizard.Test/Features/Patches/LooseBrsarPatchFileNameTests.cs create mode 100644 WheelWizard.Test/Features/Patches/ModPatchCompatibilityTests.cs create mode 100644 WheelWizard.Test/Features/Recomp/RecompPlatformTests.cs create mode 100644 WheelWizard.Test/Shared/IO/PathSafetyTests.cs diff --git a/WheelWizard.Test/Features/Patches/LooseBrsarPatchFileNameTests.cs b/WheelWizard.Test/Features/Patches/LooseBrsarPatchFileNameTests.cs new file mode 100644 index 000000000..86b6a2964 --- /dev/null +++ b/WheelWizard.Test/Features/Patches/LooseBrsarPatchFileNameTests.cs @@ -0,0 +1,29 @@ +using WheelWizard.Features.Patches; + +namespace WheelWizard.Test.Features.Patches; + +public class LooseBrsarPatchFileNameTests +{ + [Theory] + [InlineData("[001]sound.BRWSD", "1.brwsd")] + [InlineData("[42].BRBNK", "42.brbnk")] + [InlineData("[0]name.brwsd", "0.brwsd")] + public void TaggedNamesNormalizeIdAndExtension(string name, string expected) + { + Assert.True(LooseBrsarPatchFileName.TryGetNormalizedFileName(name, out var result)); + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("")] + [InlineData("[]sound.brwsd")] + [InlineData("[-1]sound.brwsd")] + [InlineData("[2147483648]sound.brwsd")] + [InlineData("[1]sound.szs")] + [InlineData("1.brwsd")] + public void InvalidNamesAreRejected(string name) + { + Assert.False(LooseBrsarPatchFileName.TryGetNormalizedFileName(name, out var result)); + Assert.Empty(result); + } +} diff --git a/WheelWizard.Test/Features/Patches/ModPatchCompatibilityTests.cs b/WheelWizard.Test/Features/Patches/ModPatchCompatibilityTests.cs new file mode 100644 index 000000000..cccd9abed --- /dev/null +++ b/WheelWizard.Test/Features/Patches/ModPatchCompatibilityTests.cs @@ -0,0 +1,47 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Testably.Abstractions.Testing; +using WheelWizard.Features.Patches; +using WheelWizard.Models.Mods; +using WheelWizard.Mods; + +namespace WheelWizard.Test.Features.Patches; + +public class ModPatchCompatibilityTests +{ + [Theory] + [InlineData("course.szs", true)] + [InlineData("COURSE.SZS", true)] + [InlineData("nested/course.szs", true)] + [InlineData("revo_kart.brsar", true)] + [InlineData("[42]sound.BRWSD", true)] + [InlineData("course.tag.szs", false)] + [InlineData("notes.txt", false)] + [InlineData("sound.brwsd", false)] + public void CompatibilityUsesInjectedFilesAndRecognizesConvertibleArchives(string relativePath, bool expected) + { + var fs = new MockFileSystem(); + var root = fs.Path.GetFullPath("mods/Test"); + var file = fs.Path.GetFullPath(fs.Path.Combine(root, relativePath)); + fs.Directory.CreateDirectory(fs.Path.GetDirectoryName(file)!); + fs.File.WriteAllText(file, "archive"); + var paths = Substitute.For(); + paths.GetModDirectoryPath("Test").Returns(root); + var service = new ModPatchConversionService( + Substitute.For(), + NullLogger.Instance, + fs, + paths, + Substitute.For() + ); + var mod = new Mod { Title = "Test" }; + + service.RefreshCompatibility(mod); + + Assert.Equal(expected, mod.HasIncompatibleFiles); + Assert.Equal(expected ? new[] { file } : [], service.GetConvertibleArchiveFiles(mod)); + fs.Directory.Delete(root, recursive: true); + service.RefreshCompatibility(mod); + Assert.False(mod.HasIncompatibleFiles); + Assert.Empty(service.GetConvertibleArchiveFiles(mod)); + } +} diff --git a/WheelWizard.Test/Features/Recomp/RecompPlatformTests.cs b/WheelWizard.Test/Features/Recomp/RecompPlatformTests.cs new file mode 100644 index 000000000..e5015c0a1 --- /dev/null +++ b/WheelWizard.Test/Features/Recomp/RecompPlatformTests.cs @@ -0,0 +1,27 @@ +using System.Runtime.InteropServices; +using WheelWizard.Recomp; + +namespace WheelWizard.Test.Features.Recomp; + +public class RecompPlatformTests +{ + [Theory] + [InlineData("v1.2/../../../release", "v1-2----------release")] + [InlineData("", "")] + [InlineData("v123", "v123")] + public void CachedSetupNameKeepsTagsWithinSingleFileName(string tag, string sanitized) + { + var result = RecompPlatform.CachedSetupFileName(tag); + Assert.Equal($"WiiCompiled-Setup-{sanitized}{Path.GetExtension(RecompPlatform.SetupFileName)}", result); + Assert.Equal(result, Path.GetFileName(result)); + Assert.Equal($"WiiCompiled-Setup-*{Path.GetExtension(result)}", RecompPlatform.CachedSetupSearchPattern); + } + + [Theory] + [InlineData(Architecture.X64, "WiiCompiled-Setup-x86_64.AppImage")] + [InlineData(Architecture.Arm64, "WiiCompiled-Setup-aarch64.AppImage")] + [InlineData(Architecture.X86, null)] + [InlineData(Architecture.Arm, null)] + public void LinuxAssetsMatchSupportedArchitectures(Architecture architecture, string? expected) => + Assert.Equal(expected, RecompPlatform.LinuxReleaseAssetName(architecture)); +} diff --git a/WheelWizard.Test/Features/Settings/SettingsRecoveryTests.cs b/WheelWizard.Test/Features/Settings/SettingsRecoveryTests.cs index bcd54e20e..509b462b1 100644 --- a/WheelWizard.Test/Features/Settings/SettingsRecoveryTests.cs +++ b/WheelWizard.Test/Features/Settings/SettingsRecoveryTests.cs @@ -28,7 +28,7 @@ public void DolphinEditPreservesExternalValuesAndReloadDoesNotCarryPreviousProfi fs.File.WriteAllText("/config/GFX.ini", "[Settings]\nInternalResolution = invalid\n"); manager.ReloadSettings("/config"); Assert.Equal(1, resolution.Get()); - Assert.Equal(false, fps.Get()); + Assert.False(fps.Get()); Assert.Contains("invalid", fs.File.ReadAllText("/config/GFX.ini")); } @@ -44,10 +44,10 @@ public void RecompReloadMissingKeyUsesDefault() manager.LoadSettings("/config/Config.toml"); fs.File.WriteAllText("/config/Config.toml", "[video]\n"); manager.ReloadSettings("/config/Config.toml"); - Assert.Equal(true, setting.Get()); + Assert.True(setting.Get()); fs.File.Delete("/config/Config.toml"); Assert.False(setting.Set(false)); - Assert.Equal(true, setting.Get()); + Assert.True(setting.Get()); } [Fact] @@ -66,7 +66,7 @@ public void FailedSaveRollsBackAndCanBeRetried() var notifications = 0; setting.Changed += _ => notifications++; Assert.False(setting.Set(true)); - Assert.Equal(false, setting.Get()); + Assert.False(setting.Get()); Assert.Equal(0, notifications); fail = false; Assert.True(setting.Set(true)); @@ -86,7 +86,7 @@ public void JsonRetainsExistingKeysAndUnknownValuesAndPreservesCorruptFile() var setting = new WhWzSetting("EnableAnimations", true, s => manager.SaveSettings(path, s)); manager.RegisterSetting(setting); manager.LoadSettings(path); - Assert.Equal(false, setting.Get()); + Assert.False(setting.Get()); Assert.True(setting.Set(true)); Assert.Contains("FutureSetting", fs.File.ReadAllText(path)); Assert.Equal(original, fs.File.ReadAllText(path + ".bak")); diff --git a/WheelWizard.Test/Shared/IO/PathSafetyTests.cs b/WheelWizard.Test/Shared/IO/PathSafetyTests.cs new file mode 100644 index 000000000..94376f9b3 --- /dev/null +++ b/WheelWizard.Test/Shared/IO/PathSafetyTests.cs @@ -0,0 +1,39 @@ +using WheelWizard.Shared.IO; + +namespace WheelWizard.Test.Shared.IO; + +public class PathSafetyTests +{ + [Theory] + [InlineData("../outside.bin")] + [InlineData("nested/../../outside.bin")] + [InlineData("nested\\..\\outside.bin")] + [InlineData("/absolute.bin")] + [InlineData("C:\\absolute.bin")] + [InlineData(" ")] + public void UnsafeRelativePathsAreRejected(string path) + { + Assert.False(PathSafety.TryGetPathWithinDirectory(Path.GetTempPath(), path, out var fullPath)); + Assert.Empty(fullPath); + } + + [Fact] + public void NestedPathIsNormalizedInsideDirectory() + { + var root = Path.Combine(Path.GetTempPath(), "PathSafetyTests"); + Assert.True(PathSafety.TryGetPathWithinDirectory(root, " ./nested\\file.bin ", out var path)); + Assert.Equal(Path.Combine(root, "nested", "file.bin"), path); + Assert.False(PathSafety.IsPathWithinDirectory(root, root + "-sibling/file.bin")); + Assert.True(PathSafety.IsPathWithinDirectory(root, root)); + } + + [Theory] + [InlineData("folder\\mii.bin", "mii.bin")] + [InlineData("folder/mii.bin", "mii.bin")] + [InlineData("\"mii.bin\"", "mii.bin")] + public void SafeFileNameReturnsOnlyLeaf(string input, string expected) + { + Assert.True(PathSafety.TryGetSafeFileName(input, out var result)); + Assert.Equal(expected, result); + } +} diff --git a/WheelWizard.UI.Test/ModBrowserPreviewTests.cs b/WheelWizard.UI.Test/ModBrowserPreviewTests.cs index 55ef948b2..fe6f184bd 100644 --- a/WheelWizard.UI.Test/ModBrowserPreviewTests.cs +++ b/WheelWizard.UI.Test/ModBrowserPreviewTests.cs @@ -26,6 +26,7 @@ public async Task ClosedBrowser_DiscardsPendingSearchResults() var response = new TaskCompletionSource>(); mods.GetModSearchResults(Arg.Any(), Arg.Any()).Returns(response.Task); var details = new ModContent( + new Testably.Abstractions.Testing.MockFileSystem(), Substitute.For(), mods, Substitute.For(), @@ -154,6 +155,7 @@ public async Task PatchOnlyPaging_StopsAfterAMatchOrFailure(bool fail) private static ModBrowserWindow CreateBrowser(IGameBananaSingletonService mods, IGameBananaMediaService media) => new( new ModContent( + new Testably.Abstractions.Testing.MockFileSystem(), Substitute.For(), mods, Substitute.For(), diff --git a/WheelWizard/Features/CustomDistributions/RetroRewindBeta.cs b/WheelWizard/Features/CustomDistributions/RetroRewindBeta.cs index 80ea10f49..fb7f90ee4 100644 --- a/WheelWizard/Features/CustomDistributions/RetroRewindBeta.cs +++ b/WheelWizard/Features/CustomDistributions/RetroRewindBeta.cs @@ -261,7 +261,9 @@ private static bool IsBadPasswordException(Exception ex) private bool TryGetRelativeExtractionPath(string normalizedPath, out string relativePath) { relativePath = string.Empty; - var archivePath = normalizedPath.Replace(Path.DirectorySeparatorChar, '/').Replace(Path.AltDirectorySeparatorChar, '/'); + var archivePath = normalizedPath + .Replace(_fileSystem.Path.DirectorySeparatorChar, '/') + .Replace(_fileSystem.Path.AltDirectorySeparatorChar, '/'); if (archivePath.Equals(FolderName, StringComparison.OrdinalIgnoreCase)) { @@ -271,7 +273,10 @@ private bool TryGetRelativeExtractionPath(string normalizedPath, out string rela if (archivePath.StartsWith($"{FolderName}/", StringComparison.OrdinalIgnoreCase)) { - relativePath = Path.Combine(FolderName, archivePath.Substring(FolderName.Length + 1).Replace('/', Path.DirectorySeparatorChar)); + relativePath = _fileSystem.Path.Combine( + FolderName, + archivePath.Substring(FolderName.Length + 1).Replace('/', _fileSystem.Path.DirectorySeparatorChar) + ); return true; } @@ -283,9 +288,9 @@ private bool TryGetRelativeExtractionPath(string normalizedPath, out string rela if (archivePath.StartsWith($"{XMLFolderName}/", StringComparison.OrdinalIgnoreCase)) { - relativePath = Path.Combine( + relativePath = _fileSystem.Path.Combine( XMLFolderName, - archivePath.Substring(XMLFolderName.Length + 1).Replace('/', Path.DirectorySeparatorChar) + archivePath.Substring(XMLFolderName.Length + 1).Replace('/', _fileSystem.Path.DirectorySeparatorChar) ); return true; } @@ -331,16 +336,18 @@ private OperationResult> MoveExtractedFiles(string tempExtractionPa return Ok(manifestEntries); } - private static bool IsRiivolutionPath(string relativePath) + private bool IsRiivolutionPath(string relativePath) { - var normalized = relativePath.Replace(Path.DirectorySeparatorChar, '/').Replace(Path.AltDirectorySeparatorChar, '/'); + var normalized = relativePath + .Replace(_fileSystem.Path.DirectorySeparatorChar, '/') + .Replace(_fileSystem.Path.AltDirectorySeparatorChar, '/'); return normalized.StartsWith("riivolution/", StringComparison.OrdinalIgnoreCase) || normalized.Equals("riivolution", StringComparison.OrdinalIgnoreCase); } - private static bool IsBetaRiivolutionFile(string relativePath) + private bool IsBetaRiivolutionFile(string relativePath) { - var fileName = Path.GetFileName(relativePath); + var fileName = _fileSystem.Path.GetFileName(relativePath); return fileName.StartsWith("RRBeta", StringComparison.OrdinalIgnoreCase); } diff --git a/WheelWizard/Features/Launching/RetroRewindLaunchDescriptor.cs b/WheelWizard/Features/Launching/RetroRewindLaunchDescriptor.cs index e05d9dc7e..3992b2d28 100644 --- a/WheelWizard/Features/Launching/RetroRewindLaunchDescriptor.cs +++ b/WheelWizard/Features/Launching/RetroRewindLaunchDescriptor.cs @@ -110,9 +110,9 @@ private static List BuildOptions(string sectionName, int myStuffCh return options; } - private static (string SectionName, int MyStuffChoice, bool EnableSeparateSave) GetLaunchInfo(string xmlFilePath) + private (string SectionName, int MyStuffChoice, bool EnableSeparateSave) GetLaunchInfo(string xmlFilePath) { - var fileName = Path.GetFileName(xmlFilePath); + var fileName = fileSystem.Path.GetFileName(xmlFilePath); if (fileName.Equals("RRBeta.xml", StringComparison.OrdinalIgnoreCase)) return ("Retro Rewind Beta", 0, true); diff --git a/WheelWizard/Features/MiiRendering/Services/NativeMiiRenderer.cs b/WheelWizard/Features/MiiRendering/Services/NativeMiiRenderer.cs index cf1976ab8..3acb627b4 100644 --- a/WheelWizard/Features/MiiRendering/Services/NativeMiiRenderer.cs +++ b/WheelWizard/Features/MiiRendering/Services/NativeMiiRenderer.cs @@ -371,7 +371,7 @@ private static int MapExpression(MiiImageSpecifications.FaceExpression expressio _ => FflNativeInterop.FflExpressionNormal, }; - private static OperationResult GetOrCreateCachedHeadDrawParams( + private OperationResult GetOrCreateCachedHeadDrawParams( ManagedFflResourceArchive archive, FflNativeInterop.FFLiCharInfo charInfo, NativeMiiRenderRequest request, @@ -460,10 +460,10 @@ private static void EvictHeadDrawCacheIfNeeded_NoLock() } } - private static string BuildHeadDrawCacheKey(string studioData, int expressionId, int width, string resourcePath) + private string BuildHeadDrawCacheKey(string studioData, int expressionId, int width, string resourcePath) { var resolutionBucket = width <= 384 ? 256 : 512; - return $"{Path.GetFullPath(resourcePath)}|{resolutionBucket}|{expressionId}|{studioData}"; + return $"{fileSystem.Path.GetFullPath(resourcePath)}|{resolutionBucket}|{expressionId}|{studioData}"; } private static List BuildDecodedDrawMeshes(IReadOnlyList drawParams) diff --git a/WheelWizard/Features/Patches/LooseBrsarPatchFileName.cs b/WheelWizard/Features/Patches/LooseBrsarPatchFileName.cs index 932eaf2b1..78be165da 100644 --- a/WheelWizard/Features/Patches/LooseBrsarPatchFileName.cs +++ b/WheelWizard/Features/Patches/LooseBrsarPatchFileName.cs @@ -2,6 +2,8 @@ namespace WheelWizard.Features.Patches; public static class LooseBrsarPatchFileName { + private static readonly System.IO.Abstractions.IPath NativePath = new Testably.Abstractions.RealFileSystem().Path; + public static bool TryGetNormalizedFileName(string fileName, out string normalizedFileName) { normalizedFileName = string.Empty; @@ -17,7 +19,7 @@ public static bool TryGetNormalizedFileName(string fileName, out string normaliz if (!idText.All(char.IsDigit) || !int.TryParse(idText, out var fileId)) return false; - var extension = Path.GetExtension(fileName); + var extension = NativePath.GetExtension(fileName); if ( !extension.Equals(".brwsd", StringComparison.OrdinalIgnoreCase) && !extension.Equals(".brbnk", StringComparison.OrdinalIgnoreCase) diff --git a/WheelWizard/Features/Patches/ModPatchConversionService.cs b/WheelWizard/Features/Patches/ModPatchConversionService.cs index 1a5b5a6b5..773885aba 100644 --- a/WheelWizard/Features/Patches/ModPatchConversionService.cs +++ b/WheelWizard/Features/Patches/ModPatchConversionService.cs @@ -72,8 +72,12 @@ public async Task> ConvertToPatchesAsy return new ModPatchConversionResult(); } - var tempRoot = Path.Combine(Path.GetTempPath(), "WheelWizardPatchConversion", $"{mod.Title}-{Guid.NewGuid():N}"); - var tempModDirectory = Path.Combine(tempRoot, mod.Title); + var tempRoot = fileSystem.Path.Combine( + fileSystem.Path.GetTempPath(), + "WheelWizardPatchConversion", + $"{mod.Title}-{Guid.NewGuid():N}" + ); + var tempModDirectory = fileSystem.Path.Combine(tempRoot, mod.Title); progress?.Report(new(ModOperationStage.Converting, 0, TotalFiles: sourceFiles.Count)); try @@ -94,7 +98,7 @@ public async Task> ConvertToPatchesAsy { cancellationToken.ThrowIfCancellationRequested(); var file = tempFiles[index]; - var fileName = Path.GetFileName(file); + var fileName = fileSystem.Path.GetFileName(file); progress?.Report( new( @@ -109,7 +113,7 @@ public async Task> ConvertToPatchesAsy { AddArchiveBundleEntry( archiveBundles, - Path.Combine(Path.GetDirectoryName(file)!, $"{archivePrefix}.revo_kart.szs"), + fileSystem.Path.Combine(fileSystem.Path.GetDirectoryName(file)!, $"{archivePrefix}.revo_kart.szs"), normalizedPatchFileName, fileSystem.File.ReadAllBytes(file) ); @@ -140,13 +144,19 @@ public async Task> ConvertToPatchesAsy if (TryGetBundleTarget(conversion.Analysis, out var bundleTarget)) { - var bundleDestination = Path.Combine(Path.GetDirectoryName(file)!, $"{archivePrefix}.{bundleTarget}.szs"); + var bundleDestination = fileSystem.Path.Combine( + fileSystem.Path.GetDirectoryName(file)!, + $"{archivePrefix}.{bundleTarget}.szs" + ); foreach (var entry in conversion.Analysis.Entries) { if (IsDeletionPatchEntry(entry)) { - var deletionDestination = Path.Combine(Path.GetDirectoryName(file)!, entry.ExportPath); - fileSystem.Directory.CreateDirectory(Path.GetDirectoryName(deletionDestination)!); + var deletionDestination = fileSystem.Path.Combine( + fileSystem.Path.GetDirectoryName(file)!, + entry.ExportPath + ); + fileSystem.Directory.CreateDirectory(fileSystem.Path.GetDirectoryName(deletionDestination)!); fileSystem.File.WriteAllBytes(deletionDestination, entry.Bytes); writtenPatchCount++; continue; @@ -162,8 +172,8 @@ public async Task> ConvertToPatchesAsy { foreach (var entry in conversion.Analysis.Entries) { - var destination = Path.Combine(Path.GetDirectoryName(file)!, entry.ExportPath); - var destinationDirectory = Path.GetDirectoryName(destination)!; + var destination = fileSystem.Path.Combine(fileSystem.Path.GetDirectoryName(file)!, entry.ExportPath); + var destinationDirectory = fileSystem.Path.GetDirectoryName(destination)!; if (!fileSystem.Directory.Exists(destinationDirectory)) fileSystem.Directory.CreateDirectory(destinationDirectory); fileSystem.File.WriteAllBytes(destination, entry.Bytes); @@ -216,11 +226,11 @@ private OperationResult ConvertArchiveFile(string file) try { var fileBytes = fileSystem.File.ReadAllBytes(file); - var baseline = SelectBaseline(Path.GetFileName(file), fileBytes); + var baseline = SelectBaseline(fileSystem.Path.GetFileName(file), fileBytes); if (baseline == null) return new ArchiveConversion(null, new PatchConversionAnalysis()); - var analysisResult = AnalyzeArchive(baseline, Path.GetFileName(file), fileBytes); + var analysisResult = AnalyzeArchive(baseline, fileSystem.Path.GetFileName(file), fileBytes); if (analysisResult.IsFailure) return analysisResult.Error; @@ -269,16 +279,16 @@ private int EstimateDifference(BaselineEntry baseline, byte[] fileBytes) => private IReadOnlyList GetConvertibleArchiveFilesInDirectory(string directory) => fileSystem.Directory.EnumerateFiles(directory, "*", SearchOption.AllDirectories).Where(IsConvertibleArchiveFile).ToArray(); - private static bool IsConvertibleArchiveFile(string filePath) + private bool IsConvertibleArchiveFile(string filePath) { - var fileName = Path.GetFileName(filePath); + var fileName = fileSystem.Path.GetFileName(filePath); if (LooseBrsarPatchFileName.TryGetNormalizedFileName(fileName, out _)) return true; if (IsBrsarFileName(fileName)) return true; - return Path.GetExtension(filePath).Equals(".szs", StringComparison.OrdinalIgnoreCase) + return fileSystem.Path.GetExtension(filePath).Equals(".szs", StringComparison.OrdinalIgnoreCase) && !IsModdingArchiveFile(fileName) && !KartSzsAllowList.IsAllowedFullCharacterOrKart(fileName); } @@ -338,10 +348,12 @@ private int WriteArchiveBundles(Dictionary> a if (members.Count == 0) continue; - fileSystem.Directory.CreateDirectory(Path.GetDirectoryName(bundlePath)!); + fileSystem.Directory.CreateDirectory(fileSystem.Path.GetDirectoryName(bundlePath)!); if (fileSystem.File.Exists(bundlePath)) { - warnings.Add($"{Path.GetFileName(bundlePath)} already existed and was replaced with the converted archive bundle."); + warnings.Add( + $"{fileSystem.Path.GetFileName(bundlePath)} already existed and was replaced with the converted archive bundle." + ); fileSystem.File.Delete(bundlePath); } @@ -352,12 +364,12 @@ private int WriteArchiveBundles(Dictionary> a return writtenCount; } - private static bool IsModdingArchiveFile(string fileName) + private bool IsModdingArchiveFile(string fileName) { if (!fileName.EndsWith(".szs", StringComparison.OrdinalIgnoreCase)) return false; - var nameWithoutExtension = Path.GetFileNameWithoutExtension(fileName); + var nameWithoutExtension = fileSystem.Path.GetFileNameWithoutExtension(fileName); var tagSeparator = nameWithoutExtension.LastIndexOf('.'); return tagSeparator > 0 && tagSeparator + 1 < nameWithoutExtension.Length; } @@ -378,14 +390,16 @@ private void CopyDirectory(string sourceDirectory, string destinationDirectory, foreach (var directory in fileSystem.Directory.EnumerateDirectories(sourceDirectory, "*", SearchOption.AllDirectories)) { cancellationToken.ThrowIfCancellationRequested(); - fileSystem.Directory.CreateDirectory(Path.Combine(destinationDirectory, Path.GetRelativePath(sourceDirectory, directory))); + fileSystem.Directory.CreateDirectory( + fileSystem.Path.Combine(destinationDirectory, fileSystem.Path.GetRelativePath(sourceDirectory, directory)) + ); } foreach (var file in fileSystem.Directory.EnumerateFiles(sourceDirectory, "*", SearchOption.AllDirectories)) { cancellationToken.ThrowIfCancellationRequested(); - var destination = Path.Combine(destinationDirectory, Path.GetRelativePath(sourceDirectory, file)); - fileSystem.Directory.CreateDirectory(Path.GetDirectoryName(destination)!); + var destination = fileSystem.Path.Combine(destinationDirectory, fileSystem.Path.GetRelativePath(sourceDirectory, file)); + fileSystem.Directory.CreateDirectory(fileSystem.Path.GetDirectoryName(destination)!); fileSystem.File.Copy(file, destination, true); } } diff --git a/WheelWizard/Features/Patches/SzsPatchConverter.cs b/WheelWizard/Features/Patches/SzsPatchConverter.cs index e40d63553..ab73b04c8 100644 --- a/WheelWizard/Features/Patches/SzsPatchConverter.cs +++ b/WheelWizard/Features/Patches/SzsPatchConverter.cs @@ -5,6 +5,8 @@ namespace WheelWizard.Features.Patches; public sealed class SzsPatchConverter(ISzsArchiveDecoder archiveDecoder) : ISzsPatchConverter { + private static readonly System.IO.Abstractions.IPath NativePath = new Testably.Abstractions.RealFileSystem().Path; + public OperationResult AnalyzeAgainstBaseline(BaselineEntry baseline, string moddedName, byte[] moddedBytes) { // #todo: test conversion against real baselines, including changed, added, removed and unsupported archive members. @@ -197,7 +199,7 @@ private static string BuildTaggedPatchName(string logicalPath, string archiveTag private static bool IsBlockedLooseRawOverrideExtension(string path) { - var extension = Path.GetExtension(path); + var extension = NativePath.GetExtension(path); return extension.Equals(".kcl", StringComparison.OrdinalIgnoreCase) || extension.Equals(".kmp", StringComparison.OrdinalIgnoreCase) || extension.Equals(".slt", StringComparison.OrdinalIgnoreCase); @@ -205,7 +207,7 @@ private static bool IsBlockedLooseRawOverrideExtension(string path) private static string StripExtension(string fileName) { - var extension = Path.GetExtension(fileName); + var extension = NativePath.GetExtension(fileName); return string.IsNullOrEmpty(extension) ? fileName : fileName[..^extension.Length]; } } diff --git a/WheelWizard/Features/Recomp/RecompInstallService.cs b/WheelWizard/Features/Recomp/RecompInstallService.cs index 633fa1767..3844b2ea2 100644 --- a/WheelWizard/Features/Recomp/RecompInstallService.cs +++ b/WheelWizard/Features/Recomp/RecompInstallService.cs @@ -1,4 +1,4 @@ -using System.IO.Abstractions; +using System.IO.Abstractions; using System.Text.Json; using Microsoft.Extensions.Logging; using WheelWizard.Models.Enums; @@ -268,7 +268,7 @@ private bool IsInstallationBusy() { try { - var installRoot = Path.TrimEndingDirectorySeparator(fileSystem.Path.GetFullPath(environment.InstallFolderPath)); + var installRoot = fileSystem.Path.TrimEndingDirectorySeparator(fileSystem.Path.GetFullPath(environment.InstallFolderPath)); var parentFolderPath = fileSystem.Directory.GetParent(installRoot)?.FullName; if (parentFolderPath is null || !fileSystem.Directory.Exists(parentFolderPath)) return false; @@ -846,8 +846,8 @@ private bool PathsMatch(string? first, string? second) try { - var normalizedFirst = Path.TrimEndingDirectorySeparator(fileSystem.Path.GetFullPath(first)); - var normalizedSecond = Path.TrimEndingDirectorySeparator(fileSystem.Path.GetFullPath(second)); + var normalizedFirst = fileSystem.Path.TrimEndingDirectorySeparator(fileSystem.Path.GetFullPath(first)); + var normalizedSecond = fileSystem.Path.TrimEndingDirectorySeparator(fileSystem.Path.GetFullPath(second)); return normalizedFirst.Equals(normalizedSecond, StringComparison.OrdinalIgnoreCase); } catch (Exception exception) diff --git a/WheelWizard/Features/Recomp/RecompLinuxInstallService.cs b/WheelWizard/Features/Recomp/RecompLinuxInstallService.cs index 790325488..4871e809a 100644 --- a/WheelWizard/Features/Recomp/RecompLinuxInstallService.cs +++ b/WheelWizard/Features/Recomp/RecompLinuxInstallService.cs @@ -807,8 +807,8 @@ private bool PathsMatch(string? first, string? second) try { - var normalizedFirst = Path.TrimEndingDirectorySeparator(fileSystem.Path.GetFullPath(first)); - var normalizedSecond = Path.TrimEndingDirectorySeparator(fileSystem.Path.GetFullPath(second)); + var normalizedFirst = fileSystem.Path.TrimEndingDirectorySeparator(fileSystem.Path.GetFullPath(first)); + var normalizedSecond = fileSystem.Path.TrimEndingDirectorySeparator(fileSystem.Path.GetFullPath(second)); return normalizedFirst.Equals(normalizedSecond, StringComparison.Ordinal); } catch (Exception exception) diff --git a/WheelWizard/Features/Recomp/RecompPlatform.cs b/WheelWizard/Features/Recomp/RecompPlatform.cs index 9414e866e..57bd435ee 100644 --- a/WheelWizard/Features/Recomp/RecompPlatform.cs +++ b/WheelWizard/Features/Recomp/RecompPlatform.cs @@ -11,6 +11,8 @@ namespace WheelWizard.Recomp; /// public static class RecompPlatform { + private static readonly System.IO.Abstractions.IPath NativePath = new Testably.Abstractions.RealFileSystem().Path; + /// /// Whether this is a Linux build on an architecture the recomp publishes an AppImage for. The AppImage /// is always run unpacked (see ), so this holds inside a Flatpak @@ -43,9 +45,9 @@ public static class RecompPlatform public static string CachedSetupFileName(string tagName) { var sanitized = new string(tagName.Select(character => char.IsLetterOrDigit(character) ? character : '-').ToArray()); - return $"WiiCompiled-Setup-{sanitized}{Path.GetExtension(SetupFileName)}"; + return $"WiiCompiled-Setup-{sanitized}{NativePath.GetExtension(SetupFileName)}"; } /// The pattern that matches every cached setup of this platform, for pruning superseded ones. - public static string CachedSetupSearchPattern => $"WiiCompiled-Setup-*{Path.GetExtension(SetupFileName)}"; + public static string CachedSetupSearchPattern => $"WiiCompiled-Setup-*{NativePath.GetExtension(SetupFileName)}"; } diff --git a/WheelWizard/Program.cs b/WheelWizard/Program.cs index 32af78b22..942c24169 100644 --- a/WheelWizard/Program.cs +++ b/WheelWizard/Program.cs @@ -128,7 +128,8 @@ private static AppBuilder CreateWheelWizardApp(IServiceProvider services) private static void SetupWorkingDirectory() { - if (new RuntimeEnvironment().IsFlatpakSandboxed(new RealFileSystem())) + var fileSystem = new RealFileSystem(); + if (new RuntimeEnvironment().IsFlatpakSandboxed(fileSystem)) { // In this case, we would not want executable directory-relative paths, since this is in `/app/bin`. // We are going to use the home directory instead (this should be the original working directory anyway). @@ -137,7 +138,7 @@ private static void SetupWorkingDirectory() else { // Resolve all relative paths based on the WheelWizard executable's directory by default - var executableDirectory = Path.GetDirectoryName(Environment.ProcessPath); + var executableDirectory = fileSystem.Path.GetDirectoryName(Environment.ProcessPath); if (!string.IsNullOrWhiteSpace(executableDirectory)) Environment.CurrentDirectory = executableDirectory; } @@ -148,7 +149,7 @@ private static void SetupWorkingDirectory() var whWzBaseDir = Environment.GetEnvironmentVariable("WW_BASEDIR") ?? string.Empty; try { - var whWzBaseDirAbsolute = Path.GetFullPath(whWzBaseDir); + var whWzBaseDirAbsolute = fileSystem.Path.GetFullPath(whWzBaseDir); Environment.CurrentDirectory = whWzBaseDirAbsolute; } catch diff --git a/WheelWizard/Shared/IO/PathSafety.cs b/WheelWizard/Shared/IO/PathSafety.cs index 33dcf234b..245a8f70c 100644 --- a/WheelWizard/Shared/IO/PathSafety.cs +++ b/WheelWizard/Shared/IO/PathSafety.cs @@ -2,6 +2,8 @@ namespace WheelWizard.Shared.IO; public static class PathSafety { + private static readonly System.IO.Abstractions.IPath NativePath = new Testably.Abstractions.RealFileSystem().Path; + public static bool TryGetPathWithinDirectory(string directory, string relativePath, out string fullPath) { fullPath = string.Empty; @@ -9,9 +11,9 @@ public static bool TryGetPathWithinDirectory(string directory, string relativePa if (!TryNormalizeRelativePath(relativePath, out var normalizedRelativePath)) return false; - var destinationPath = Path.Combine(directory, normalizedRelativePath); - var fullDirectory = Path.GetFullPath(directory); - var candidatePath = Path.GetFullPath(destinationPath); + var destinationPath = NativePath.Combine(directory, normalizedRelativePath); + var fullDirectory = NativePath.GetFullPath(directory); + var candidatePath = NativePath.GetFullPath(destinationPath); if (!IsPathWithinDirectory(fullDirectory, candidatePath)) return false; @@ -25,16 +27,16 @@ public static bool IsPathWithinDirectory(string directory, string path) if (string.IsNullOrWhiteSpace(directory) || string.IsNullOrWhiteSpace(path)) return false; - var fullDirectory = Path.GetFullPath(directory); - var fullPath = Path.GetFullPath(path); - var relativePath = Path.GetRelativePath(fullDirectory, fullPath); + var fullDirectory = NativePath.GetFullPath(directory); + var fullPath = NativePath.GetFullPath(path); + var relativePath = NativePath.GetRelativePath(fullDirectory, fullPath); return relativePath == "." || ( - !Path.IsPathRooted(relativePath) + !NativePath.IsPathRooted(relativePath) && !relativePath.Equals("..", StringComparison.Ordinal) - && !relativePath.StartsWith($"..{Path.DirectorySeparatorChar}", StringComparison.Ordinal) - && !relativePath.StartsWith($"..{Path.AltDirectorySeparatorChar}", StringComparison.Ordinal) + && !relativePath.StartsWith($"..{NativePath.DirectorySeparatorChar}", StringComparison.Ordinal) + && !relativePath.StartsWith($"..{NativePath.AltDirectorySeparatorChar}", StringComparison.Ordinal) ); } @@ -46,7 +48,7 @@ public static bool TryNormalizeRelativePath(string path, out string normalizedPa return false; var trimmedPath = path.Trim(); - if (Path.IsPathFullyQualified(trimmedPath) || trimmedPath.StartsWith('/') || trimmedPath.StartsWith('\\')) + if (NativePath.IsPathFullyQualified(trimmedPath) || trimmedPath.StartsWith('/') || trimmedPath.StartsWith('\\')) return false; var segments = trimmedPath.Replace('\\', '/').Split('/', StringSplitOptions.RemoveEmptyEntries).Where(segment => segment != "."); @@ -54,7 +56,7 @@ public static bool TryNormalizeRelativePath(string path, out string normalizedPa var safeSegments = new List(); foreach (var segment in segments) { - if (segment == ".." || segment.Contains(Path.VolumeSeparatorChar)) + if (segment == ".." || segment.Contains(NativePath.VolumeSeparatorChar)) return false; safeSegments.Add(segment); @@ -63,7 +65,7 @@ public static bool TryNormalizeRelativePath(string path, out string normalizedPa if (safeSegments.Count == 0) return false; - normalizedPath = Path.Combine(safeSegments.ToArray()); + normalizedPath = NativePath.Combine(safeSegments.ToArray()); return true; } @@ -78,7 +80,7 @@ public static bool TryGetSafeFileName(string fileName, out string safeFileName) if (string.IsNullOrWhiteSpace(leafName) || leafName is "." or "..") return false; - if (leafName.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0) + if (leafName.IndexOfAny(NativePath.GetInvalidFileNameChars()) >= 0) return false; safeFileName = leafName; diff --git a/WheelWizard/Views/Pages/MiiListPage.axaml.cs b/WheelWizard/Views/Pages/MiiListPage.axaml.cs index 9a6f1fa47..1c162b467 100644 --- a/WheelWizard/Views/Pages/MiiListPage.axaml.cs +++ b/WheelWizard/Views/Pages/MiiListPage.axaml.cs @@ -325,7 +325,7 @@ private void ExportMultipleMiiFiles(Mii[] miis) public static string ReplaceInvalidFileNameChars(string filename) { - var invalid = Path.GetInvalidFileNameChars(); + var invalid = new Testably.Abstractions.RealFileSystem().Path.GetInvalidFileNameChars(); return string.Join("_", filename.Split(invalid, StringSplitOptions.RemoveEmptyEntries)); } diff --git a/WheelWizard/Views/Pages/Settings/OtherSettings.axaml.cs b/WheelWizard/Views/Pages/Settings/OtherSettings.axaml.cs index d42a0cf47..d0e234844 100644 --- a/WheelWizard/Views/Pages/Settings/OtherSettings.axaml.cs +++ b/WheelWizard/Views/Pages/Settings/OtherSettings.axaml.cs @@ -1,3 +1,4 @@ +using System.IO.Abstractions; using Avalonia.Controls; using Avalonia.Interactivity; using WheelWizard.CustomDistributions; @@ -12,6 +13,8 @@ namespace WheelWizard.Views.Pages.Settings; public partial class OtherSettings : UserControl { + private readonly IFileSystem fileSystem; + private readonly bool _settingsAreDisabled; private IFilePickerService FilePicker { get; } @@ -23,12 +26,14 @@ public partial class OtherSettings : UserControl private ISettingsManager SettingsService { get; } public OtherSettings( + IFileSystem fileSystem, IFilePickerService filePicker, ICustomDistributionPaths distributionPaths, ICustomDistributionSingletonService customDistributionSingletonService, ISettingsManager settingsService ) { + this.fileSystem = fileSystem; FilePicker = filePicker; DistributionPaths = distributionPaths; CustomDistributionSingletonService = customDistributionSingletonService; @@ -41,7 +46,7 @@ ISettingsManager settingsService // controls individually so the recomp switch never becomes trapped behind Dolphin setup. LaunchRrOnStartup.IsEnabled = !_settingsAreDisabled; DolphinReinstallButton.IsEnabled = !_settingsAreDisabled; - OpenGameFolderButton.IsEnabled = !_settingsAreDisabled && Directory.Exists(DistributionPaths.RootFolderPath); + OpenGameFolderButton.IsEnabled = !_settingsAreDisabled && fileSystem.Directory.Exists(DistributionPaths.RootFolderPath); OpenSaveFolderButton.IsEnabled = !_settingsAreDisabled; if (!_settingsAreDisabled) LoadSettings(); @@ -57,8 +62,8 @@ private void LoadSettings() { // Only loads when the settings are not disabled (aka when the paths are set up correctly) LaunchRrOnStartup.IsChecked = SettingsService.Get(SettingsService.LAUNCH_RR_ON_STARTUP); - OpenGameFolderButton.IsEnabled = Directory.Exists(DistributionPaths.RootFolderPath); - OpenSaveFolderButton.IsEnabled = Directory.Exists(DistributionPaths.SaveFolderPath); + OpenGameFolderButton.IsEnabled = fileSystem.Directory.Exists(DistributionPaths.RootFolderPath); + OpenSaveFolderButton.IsEnabled = fileSystem.Directory.Exists(DistributionPaths.SaveFolderPath); } private void ForceLoadSettings() @@ -119,7 +124,7 @@ private void OpenSaveFolder_OnClick(object? sender, RoutedEventArgs e) private void GameFileFolder_Click(object? sender, RoutedEventArgs e) { - if (!Directory.Exists(DistributionPaths.RootFolderPath)) + if (!fileSystem.Directory.Exists(DistributionPaths.RootFolderPath)) return; FilePicker.OpenFolderInFileManager(DistributionPaths.RootFolderPath); diff --git a/WheelWizard/Views/Pages/Settings/RecompSettings.axaml.cs b/WheelWizard/Views/Pages/Settings/RecompSettings.axaml.cs index cada035a6..50d0664e8 100644 --- a/WheelWizard/Views/Pages/Settings/RecompSettings.axaml.cs +++ b/WheelWizard/Views/Pages/Settings/RecompSettings.axaml.cs @@ -1,3 +1,4 @@ +using System.IO.Abstractions; using Avalonia.Controls; using Avalonia.Interactivity; using WheelWizard.Recomp; @@ -10,6 +11,8 @@ namespace WheelWizard.Views.Pages.Settings; public partial class RecompSettings : UserControl { + private readonly IFileSystem fileSystem; + private bool _loading; private IFilePickerService FilePicker { get; } @@ -27,6 +30,7 @@ public partial class RecompSettings : UserControl private IRecompPaths RecompPaths { get; } public RecompSettings( + IFileSystem fileSystem, IFilePickerService filePicker, ISettingsManager settingsService, IRecompSettingManager recompSettingsFile, @@ -36,6 +40,7 @@ public RecompSettings( IRecompInstallService? recompInstallService = null ) { + this.fileSystem = fileSystem; FilePicker = filePicker; SettingsService = settingsService; RecompSettingsFile = recompSettingsFile; @@ -72,7 +77,7 @@ private void LoadSettings() var installFolder = RecompEnvironment?.InstallFolderPath ?? RecompPaths.InstallFolderPath; InstallLocationText.Text = installFolder; - OpenInstallFolder.IsEnabled = installed && Directory.Exists(installFolder); + OpenInstallFolder.IsEnabled = installed && fileSystem.Directory.Exists(installFolder); UninstallButton.IsEnabled = installed; WiiCompiledVersionText.Text = t( "helper_text.installed_version", @@ -88,7 +93,7 @@ private void LoadSettings() SharedNandWarningIcon.IsVisible = sharingDolphinData; var cloneFolder = RecompPaths.NandCopyFolderPath; - DolphinCloneStatus.Text = Directory.Exists(cloneFolder) + DolphinCloneStatus.Text = fileSystem.Directory.Exists(cloneFolder) ? t("status.recomp_dolphin_clone_available", new { path = cloneFolder }) : t("status.recomp_dolphin_clone_missing"); } @@ -270,7 +275,7 @@ private async void CloneDolphinData_OnClick(object? sender, RoutedEventArgs e) } var cloneFolder = RecompPaths.NandCopyFolderPath; - if (Directory.Exists(cloneFolder)) + if (fileSystem.Directory.Exists(cloneFolder)) { var overwrite = await new YesNoWindow() .SetMainText(t("question.recomp_overwrite_dolphin_clone.title")) @@ -330,7 +335,7 @@ private async Task ApplyNandSettingAsync() private void OpenInstallFolder_OnClick(object? sender, RoutedEventArgs e) { var installFolder = RecompEnvironment?.InstallFolderPath ?? RecompPaths.InstallFolderPath; - if (Directory.Exists(installFolder)) + if (fileSystem.Directory.Exists(installFolder)) FilePicker.OpenFolderInFileManager(installFolder); } diff --git a/WheelWizard/Views/Pages/Settings/WhWzSettings.axaml.cs b/WheelWizard/Views/Pages/Settings/WhWzSettings.axaml.cs index 2dffb65b2..b919338fe 100644 --- a/WheelWizard/Views/Pages/Settings/WhWzSettings.axaml.cs +++ b/WheelWizard/Views/Pages/Settings/WhWzSettings.axaml.cs @@ -1,3 +1,4 @@ +using System.IO.Abstractions; using System.Runtime.InteropServices; using Avalonia.Controls; using Avalonia.Interactivity; @@ -20,6 +21,8 @@ namespace WheelWizard.Views.Pages.Settings; public partial class WhWzSettings : UserControl { + private readonly IFileSystem fileSystem; + // #todo: move settings state and location-change workflows into a view model so this page mostly handles the controls. private readonly IMainWindowService _mainWindow; private readonly IApplicationLogFiles _logFiles; @@ -49,6 +52,7 @@ private sealed record LanguageDropdownItem(string Key, string DisplayName) private IApplicationDataLocation ApplicationData { get; } public WhWzSettings( + IFileSystem fileSystem, IApplicationLogFiles logFiles, IMainWindowService mainWindow, IFilePickerService filePicker, @@ -60,6 +64,7 @@ public WhWzSettings( IApplicationDataLocation applicationData ) { + this.fileSystem = fileSystem; FilePicker = filePicker; DolphinPaths = dolphinPaths; SettingsService = settingsService; @@ -236,7 +241,7 @@ private async void DolphinExeBrowse_OnClick(object sender, RoutedEventArgs e) if (string.IsNullOrWhiteSpace(resolvedFolder)) return; - var executablePath = Path.Combine(resolvedFolder, "Contents", "MacOS", "Dolphin"); + var executablePath = fileSystem.Path.Combine(resolvedFolder, "Contents", "MacOS", "Dolphin"); await ApplyLocationSettingAsync(SettingsService.DOLPHIN_LOCATION, ShellQuoting.QuoteUnixArgument(executablePath)); } @@ -248,7 +253,7 @@ private async void DolphinExeBrowse_OnClick(object sender, RoutedEventArgs e) { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - if (string.Equals(Path.GetFileName(filePath), "DolphinTool.exe", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(fileSystem.Path.GetFileName(filePath), "DolphinTool.exe", StringComparison.OrdinalIgnoreCase)) { await MessageTranslationHelper.AwaitMessageAsync(MessageTranslation.Warning_DolphinToolSelected); return; @@ -323,7 +328,7 @@ private async void DolphinUserPathBrowse_OnClick(object sender, RoutedEventArgs var currentFolder = (string)SettingsService.USER_FOLDER_PATH.Get(); var topLevel = TopLevel.GetTopLevel(this); // If a current folder exists and is valid, suggest it as the starting location - if (!string.IsNullOrEmpty(currentFolder) && Directory.Exists(currentFolder)) + if (!string.IsNullOrEmpty(currentFolder) && fileSystem.Directory.Exists(currentFolder)) { var folder = await topLevel!.StorageProvider.TryGetFolderFromPathAsync(currentFolder); var folders = await FilePicker.SelectFolderAsync("Select Dolphin User Path", folder); @@ -352,7 +357,7 @@ private async void DolphinUserPathBrowse_OnClick(object sender, RoutedEventArgs private async Task ApplyLocationSettingAsync(Setting setting, string path) { - var normalizedPath = setting == SettingsService.USER_FOLDER_PATH ? Path.TrimEndingDirectorySeparator(path) : path; + var normalizedPath = setting == SettingsService.USER_FOLDER_PATH ? fileSystem.Path.TrimEndingDirectorySeparator(path) : path; if (!SettingsEditing.Set(SettingsService, setting, normalizedPath)) { @@ -373,7 +378,7 @@ private void GameLocationOpen_OnClick(object sender, RoutedEventArgs e) => private void DolphinUserFolderOpen_OnClick(object sender, RoutedEventArgs e) { - if (Directory.Exists(DolphinPaths.UserFolderPath)) + if ((!string.IsNullOrWhiteSpace(DolphinPaths.UserFolderPath) && fileSystem.Directory.Exists(DolphinPaths.UserFolderPath))) FilePicker.OpenFolderInFileManager(DolphinPaths.UserFolderPath); } @@ -382,8 +387,8 @@ private void OpenContainingFolder(string filePath) try { var unquotedPath = filePath.Trim().Trim('\'', '"'); - var folderPath = Path.GetDirectoryName(Path.GetFullPath(unquotedPath)); - if (!string.IsNullOrWhiteSpace(folderPath) && Directory.Exists(folderPath)) + var folderPath = fileSystem.Path.GetDirectoryName(fileSystem.Path.GetFullPath(unquotedPath)); + if (!string.IsNullOrWhiteSpace(folderPath) && fileSystem.Directory.Exists(folderPath)) FilePicker.OpenFolderInFileManager(folderPath); } catch @@ -394,8 +399,8 @@ private void OpenContainingFolder(string filePath) private void AppDataLocationOpen_OnClick(object sender, RoutedEventArgs e) { - if (!Directory.Exists(ApplicationData.DirectoryPath)) - Directory.CreateDirectory(ApplicationData.DirectoryPath); + if (!fileSystem.Directory.Exists(ApplicationData.DirectoryPath)) + fileSystem.Directory.CreateDirectory(ApplicationData.DirectoryPath); FilePicker.OpenFolderInFileManager(ApplicationData.DirectoryPath); } @@ -430,7 +435,9 @@ private void UpdateLocationRows() DolphinExecutableOpenButton.IsEnabled = DolphinExecutableOpenButton.IsVisible && CanOpenContainingFolder(DolphinPaths.ExecutablePath); GameLocationOpenButton.IsEnabled = CanOpenContainingFolder(SettingsService.Get(SettingsService.GAME_LOCATION)); - DolphinUserFolderOpenButton.IsEnabled = Directory.Exists(DolphinPaths.UserFolderPath); + DolphinUserFolderOpenButton.IsEnabled = ( + !string.IsNullOrWhiteSpace(DolphinPaths.UserFolderPath) && fileSystem.Directory.Exists(DolphinPaths.UserFolderPath) + ); } private static void SetLocationRowState(PathIcon completeIcon, PathIcon warningIcon, SettingsButton changeButton, bool isValid) @@ -440,13 +447,13 @@ private static void SetLocationRowState(PathIcon completeIcon, PathIcon warningI changeButton.Variant = isValid ? SettingsButton.ButtonsVariantType.Default : SettingsButton.ButtonsVariantType.Warning; } - private static bool CanOpenContainingFolder(string filePath) + private bool CanOpenContainingFolder(string filePath) { try { var unquotedPath = filePath.Trim().Trim('\'', '"'); - var folderPath = Path.GetDirectoryName(Path.GetFullPath(unquotedPath)); - return !string.IsNullOrWhiteSpace(folderPath) && Directory.Exists(folderPath); + var folderPath = fileSystem.Path.GetDirectoryName(fileSystem.Path.GetFullPath(unquotedPath)); + return !string.IsNullOrWhiteSpace(folderPath) && fileSystem.Directory.Exists(folderPath); } catch { @@ -454,7 +461,8 @@ private static bool CanOpenContainingFolder(string filePath) } } - private static bool IsConfiguredExecutableFile(string value) => File.Exists(value.Trim().Trim('\'', '"')); + private bool IsConfiguredExecutableFile(string value) => + !string.IsNullOrWhiteSpace(value.Trim().Trim('\'', '"')) && fileSystem.File.Exists(value.Trim().Trim('\'', '"')); private void UpdateAppDataLocationUi() { @@ -700,7 +708,7 @@ private async void AppDataLocationChange_OnClick(object sender, RoutedEventArgs IStorageFolder? suggestedStart = null; var currentPath = ApplicationData.DirectoryPath; - if (!string.IsNullOrWhiteSpace(currentPath) && Directory.Exists(currentPath)) + if (!string.IsNullOrWhiteSpace(currentPath) && fileSystem.Directory.Exists(currentPath)) suggestedStart = await topLevel!.StorageProvider.TryGetFolderFromPathAsync(currentPath); var folders = await FilePicker.SelectFolderAsync("Select Wheel Wizard data folder", suggestedStart); diff --git a/WheelWizard/Views/Popups/ModManagement/ModContent.axaml.cs b/WheelWizard/Views/Popups/ModManagement/ModContent.axaml.cs index 7423b2e50..e2a4e68e1 100644 --- a/WheelWizard/Views/Popups/ModManagement/ModContent.axaml.cs +++ b/WheelWizard/Views/Popups/ModManagement/ModContent.axaml.cs @@ -1,3 +1,4 @@ +using System.IO.Abstractions; using Avalonia.Controls; using Avalonia.Interactivity; using Avalonia.Media.Imaging; @@ -15,6 +16,8 @@ public record ModItem(Bitmap FullImageUrl); public partial class ModContent : UserControl { + private readonly IFileSystem fileSystem; + private IModManager ModManager { get; } private IGameBananaSingletonService GameBananaService { get; } @@ -32,6 +35,7 @@ public partial class ModContent : UserControl private string? OverrideDownloadUrl { get; set; } public ModContent( + IFileSystem fileSystem, IModManager modManager, IGameBananaSingletonService gameBananaService, IDownloadService downloads, @@ -40,6 +44,7 @@ public ModContent( IModOperationPresentation modPresentation ) { + this.fileSystem = fileSystem; ModManager = modManager; GameBananaService = gameBananaService; Downloads = downloads; @@ -250,7 +255,7 @@ private async Task DownloadAndInstallCurrentModAsync() var url = downloadUrls.First(); var fileName = GetFileNameFromUrl(url); - var filePath = Path.Combine(ModPaths.DownloadFolderPath, fileName); + var filePath = fileSystem.Path.Combine(ModPaths.DownloadFolderPath, fileName); var downloadResult = await DownloadModFileAsync(url, filePath, progressWindow); progressWindow.Close(); @@ -264,7 +269,7 @@ private async Task DownloadAndInstallCurrentModAsync() return Ok(); } - if (!File.Exists(downloadedFilePath)) + if (!fileSystem.File.Exists(downloadedFilePath)) return Fail(t("message_warning.unable_download_mod.extra")); var popup = new TextInputWindow() @@ -285,7 +290,7 @@ private async Task DownloadAndInstallCurrentModAsync() return Fail("Mod name cannot be empty."); } - var invalidChars = Path.GetInvalidFileNameChars(); + var invalidChars = fileSystem.Path.GetInvalidFileNameChars(); if (modName.Any(c => invalidChars.Contains(c))) { TryDeleteTempModsFolder(); @@ -310,10 +315,10 @@ private async Task PrepareToDownloadFile() try { var tempFolder = ModPaths.DownloadFolderPath; - if (Directory.Exists(tempFolder)) - Directory.Delete(tempFolder, true); + if (fileSystem.Directory.Exists(tempFolder)) + fileSystem.Directory.Delete(tempFolder, true); - Directory.CreateDirectory(tempFolder); + fileSystem.Directory.CreateDirectory(tempFolder); await Task.CompletedTask; return Ok(); } @@ -327,8 +332,8 @@ private OperationResult TryDeleteTempModsFolder() { try { - if (Directory.Exists(ModPaths.DownloadFolderPath)) - Directory.Delete(ModPaths.DownloadFolderPath, true); + if (fileSystem.Directory.Exists(ModPaths.DownloadFolderPath)) + fileSystem.Directory.Delete(ModPaths.DownloadFolderPath, true); return Ok(); } @@ -353,9 +358,9 @@ private OperationResult TryDeleteTempModsFolder() /// /// Extracts the file name from a URL. /// - private static string GetFileNameFromUrl(string url) + private string GetFileNameFromUrl(string url) { - return Path.GetFileName(new Uri(url).AbsolutePath); + return fileSystem.Path.GetFileName(new Uri(url).AbsolutePath); } /// diff --git a/WheelWizard/Views/Storage/StoragePaths.cs b/WheelWizard/Views/Storage/StoragePaths.cs index d5d18f0e4..dc325ba7b 100644 --- a/WheelWizard/Views/Storage/StoragePaths.cs +++ b/WheelWizard/Views/Storage/StoragePaths.cs @@ -4,6 +4,8 @@ namespace WheelWizard.Views.Storage; public static class StoragePaths { + private static readonly System.IO.Abstractions.IPath NativePath = new Testably.Abstractions.RealFileSystem().Path; + public static string? TryResolveLocalPath(IStorageItem? item) { if (item == null) @@ -38,7 +40,7 @@ public static class StoragePaths if (uri.IsAbsoluteUri) return null; var raw = uri.ToString(); - if (!string.IsNullOrWhiteSpace(raw) && Path.IsPathRooted(raw)) + if (!string.IsNullOrWhiteSpace(raw) && NativePath.IsPathRooted(raw)) return raw; } From 8e13096c7c44ba376ab5ada612bf8bfa51ea91e3 Mon Sep 17 00:00:00 2001 From: patchzyy <64382339+patchzyy@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:51:52 +0200 Subject: [PATCH 2/2] allow colons in unix paths --- WheelWizard.Test/Shared/IO/PathSafetyTests.cs | 8 ++++++++ .../Features/Settings/Views/WhWzSettings.axaml.cs | 2 +- WheelWizard/Shared/IO/PathSafety.cs | 13 +++++++++++-- 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/WheelWizard.Test/Shared/IO/PathSafetyTests.cs b/WheelWizard.Test/Shared/IO/PathSafetyTests.cs index 94376f9b3..c755f3800 100644 --- a/WheelWizard.Test/Shared/IO/PathSafetyTests.cs +++ b/WheelWizard.Test/Shared/IO/PathSafetyTests.cs @@ -10,6 +10,7 @@ public class PathSafetyTests [InlineData("nested\\..\\outside.bin")] [InlineData("/absolute.bin")] [InlineData("C:\\absolute.bin")] + [InlineData("C:/absolute.bin")] [InlineData(" ")] public void UnsafeRelativePathsAreRejected(string path) { @@ -27,6 +28,13 @@ public void NestedPathIsNormalizedInsideDirectory() Assert.True(PathSafety.IsPathWithinDirectory(root, root)); } + [Fact] + public void ColonInSegmentFollowsPlatformRules() + { + var accepted = PathSafety.TryGetPathWithinDirectory(Path.GetTempPath(), "nested/data:variant.bin", out _); + Assert.Equal(!OperatingSystem.IsWindows(), accepted); + } + [Theory] [InlineData("folder\\mii.bin", "mii.bin")] [InlineData("folder/mii.bin", "mii.bin")] diff --git a/WheelWizard/Features/Settings/Views/WhWzSettings.axaml.cs b/WheelWizard/Features/Settings/Views/WhWzSettings.axaml.cs index 513d80df3..5c4f795f3 100644 --- a/WheelWizard/Features/Settings/Views/WhWzSettings.axaml.cs +++ b/WheelWizard/Features/Settings/Views/WhWzSettings.axaml.cs @@ -338,7 +338,7 @@ private void GameLocationOpen_OnClick(object sender, RoutedEventArgs e) => private void DolphinUserFolderOpen_OnClick(object sender, RoutedEventArgs e) { - if ((!string.IsNullOrWhiteSpace(DolphinPaths.UserFolderPath) && fileSystem.Directory.Exists(DolphinPaths.UserFolderPath))) + if (!string.IsNullOrWhiteSpace(DolphinPaths.UserFolderPath) && fileSystem.Directory.Exists(DolphinPaths.UserFolderPath)) FilePicker.OpenFolderInFileManager(DolphinPaths.UserFolderPath); } diff --git a/WheelWizard/Shared/IO/PathSafety.cs b/WheelWizard/Shared/IO/PathSafety.cs index 92527f9a1..a008a1ed1 100644 --- a/WheelWizard/Shared/IO/PathSafety.cs +++ b/WheelWizard/Shared/IO/PathSafety.cs @@ -48,7 +48,12 @@ public static bool TryNormalizeRelativePath(string path, out string normalizedPa return false; var trimmedPath = path.Trim(); - if (NativePath.IsPathFullyQualified(trimmedPath) || trimmedPath.StartsWith('/') || trimmedPath.StartsWith('\\')) + if ( + NativePath.IsPathFullyQualified(trimmedPath) + || trimmedPath.StartsWith('/') + || trimmedPath.StartsWith('\\') + || IsWindowsDriveRooted(trimmedPath) + ) return false; var segments = trimmedPath.Replace('\\', '/').Split('/', StringSplitOptions.RemoveEmptyEntries).Where(segment => segment != "."); @@ -56,7 +61,7 @@ public static bool TryNormalizeRelativePath(string path, out string normalizedPa var safeSegments = new List(); foreach (var segment in segments) { - if (segment == ".." || segment.Contains(':')) + if (segment == ".." || segment.Contains(NativePath.VolumeSeparatorChar)) return false; safeSegments.Add(segment); @@ -69,6 +74,10 @@ public static bool TryNormalizeRelativePath(string path, out string normalizedPa return true; } + // Windows drive paths are rejected on every platform, even where ':' is a valid filename character. + private static bool IsWindowsDriveRooted(string path) => + path.Length >= 2 && char.IsAsciiLetter(path[0]) && path[1] == ':' && (path.Length == 2 || path[2] is '/' or '\\'); + public static bool TryGetSafeFileName(string fileName, out string safeFileName) { safeFileName = string.Empty;