Add UnitTests and made code testable with DI

This commit is contained in:
2025-11-04 20:27:51 +10:30
parent e5787c9321
commit 17be096ae2
22 changed files with 865 additions and 140 deletions
+1
View File
@@ -1,3 +1,4 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=IP/@EntryIndexedValue">IP</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=NSP/@EntryIndexedValue">NSP</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=PFS/@EntryIndexedValue">PFS</s:String></wpf:ResourceDictionary>
+25 -1
View File
@@ -3,28 +3,52 @@
<s:Boolean x:Key="/Default/AddReferences/RecentPaths/=D_003A_005CCloud_005CGit_005CTinfoilVibeServer_005CTinfoilVibeServer_005Clibhac_005Csrc_005CLibHac_005Cbin_005CRelease_005Cnet8_002E0_005CLibHac_002Edll/@EntryIndexedValue">True</s:Boolean>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AAbstractWritableArchive_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F22ddbc5388b085f3bf3bfcf15c58a6938df55f53cc27a762c1e0d3e974da87_003FAbstractWritableArchive_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AAppContext_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F574233347f5bab7d6529a8c4744fb8a213de24439a69fd5c4316ea962144_003FAppContext_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AArgumentNullException_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F79b4b5d07e8c5ac3de172b75667c6bded8e1fa6f42f36d8f6dc2f9e3d568_003FArgumentNullException_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AAssert_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F182ec85d25b779c7118bca2890ca95ae8dd32868c52272faee4a3a304f624b_003FAssert_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AConcurrentDictionary_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F8d7a996932951dc929c6c3855f98f0c58cae8ac5b0d0bbf223f97c6388b3b61f_003FConcurrentDictionary_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AExceptionDispatchInfo_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fbd1d5c50194fea68ff3559c160230b0ab50f5acf4ce3061bffd6d62958e2182_003FExceptionDispatchInfo_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AExecutionContext_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F53b0d531c06faf86bf2ae111a9a2dbce4c52a9153feb9966ade60289c71bf52_003FExecutionContext_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AExpressionExtensions_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fd785b86024a6dfc7a0de13179d2769fe20f85a33e39e7bfc8dfffba6a44a44_003FExpressionExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFileInfo_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F85a5735906a3a06f39a1422a28e0353e3e317f2d923dcc5731fef07dd436f9_003FFileInfo_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFileSystemEnumerator_002EWindows_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F326755fc341d349c24999b4c209d69fbe4317313d563859abe51f4ded75c97b_003FFileSystemEnumerator_002EWindows_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFileSystemEventArgs_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F36b5c2553dd4c6dccec26adf9d5ab4ff493763447f46751b6775ba38a832_003FFileSystemEventArgs_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFileSystemWatcher_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F771dac726bcbf51f1839c45bef2a78c4d834c4bc5420482d9cc3c38eb97535_003FFileSystemWatcher_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFileSystemWatcher_002EWin32_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F52fdc6a26ac6d933b95a413e4fb7bc90d22adb6b85734e5eb08036ab03a_003FFileSystemWatcher_002EWin32_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFile_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F3f31e7e8aa33de883c2ccfa62a9c81bfc246c36e825b489476f9472032e512_003FFile_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFuture_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fb3575a2f41d7c2dbfaa36e866b8a361e11dd7223ff82bc574c1d5d4b7522f735_003FFuture_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AHashSet_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F9f36f47319b32af838b2a5d575986ff83918e428931b1ed44ad662b6bc8a_003FHashSet_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AHttpResponseMessage_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F4cfeb8b377bc81e1fbb5f7d7a02492cb6ac23e88c8c9d7155944f0716f3d4b_003FHttpResponseMessage_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AJsonConverterOfT_002EReadCore_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fa7e99f3da3fc9d80c1949ce87d548d992a745092d1c720f44952dbbd144437f_003FJsonConverterOfT_002EReadCore_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AJsonSerializerOptions_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F5ef7f3c9db445621211faddebc3c1c9bb48a942f1b8cba4caa2501466f85f_003FJsonSerializerOptions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AJsonSerializer_002EWrite_002EString_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fbd49cb1d7ce3d716547551bbff70eb2084ebe04c491a927531363631f3e46330_003FJsonSerializer_002EWrite_002EString_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ALazy_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fafcc64c3432daf776b0c75429fdb3c6b938b876c6daf5d81eee485f119428_003FLazy_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AManualResetEventSlim_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Ff620c361a4199e3e4ceb5b7d542ea2b32cb76c577fb91a2bc16d15c49879ab8_003FManualResetEventSlim_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AMemoryCacheExtensions_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Feabafafbeed23dab31f6a8184bdb1fe5a4c2237f39efe53885612b2ccdd3cd_003FMemoryCacheExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AMemoryMarshal_002ECoreCLR_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F948494fffd27a44b5c55f2f85972545a315f73df6c6278f9e692cbbad86b0_003FMemoryMarshal_002ECoreCLR_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AMethodBaseInvoker_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F69ba5438e03a0e3a5d2f133f37405f9e32f9e0abb1fbe0a8cb7145514ff85f_003FMethodBaseInvoker_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AMock_00601_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F337268366a89be4dda7d47b37063fd21841972237539397997c82c4f924b_003FMock_00601_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APath_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fcf5011822fd54235e86fdc54ee3baa876b4876e5549223ffeadca5607e59f6af_003FPath_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AResourceInvoker_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F117275599da316d4e45c62326f3097e45f8b5b17d8fe17bbcf9ca86b0819b16_003FResourceInvoker_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ARuntimeHelpers_002ECoreCLR_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F54c7fa9daf3ab775e3ebabc3f3948a6e2a484886fa1196f813b98e17e36aa49_003FRuntimeHelpers_002ECoreCLR_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASafeFileHandle_002EWindows_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F6ef8e7d549f6bfb5b9eafaaa0ff48d8cbfc564f32be26323b5f60e63aef4dd1_003FSafeFileHandle_002EWindows_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AServiceProviderServiceExtensions_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F621526284d3e4868b98948aff942206525763e57e194a27623b66c384466_003FServiceProviderServiceExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AServiceProvider_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fce37be1a06b16c6faa02038d2cc477dd3bca5b217ceeb41c5f2ad45c1bf9_003FServiceProvider_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASharpSevenZipExtractor_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fb6d4f34f86579010d492ef5548eca93df749a4e4634ba9c3dee6fbd6c6d74_003FSharpSevenZipExtractor_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ATaskAwaitAdapter_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fd3c187e0b88b1a56f2518fb9f4b5ee5ed6e9116d93a507969a932c16801033_003FTaskAwaitAdapter_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ATask_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F8789586b575d44528c2aa18853f6eb96f3e8f8d3f4fa7eb9d6a56b54b49d7_003FTask_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AThrowHelper_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F6354a7b35d7821629924d3676acd7e67a6f7f94343e0e66ec439aa2bd6ed5_003FThrowHelper_002Ecs_002Fz_003A3_002D2/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AThrowHelper_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fc7102cd0ffb8973777e61b1942c3fffac7e14016a511d055c3adf73ff91748_003FThrowHelper_002Ecs_002Fz_003A2_002D1/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AThrowHelper_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Feb62aa945e5eb84a647a452d1924d7a935f01a6cec552d6fc7ab82a065b9ab8_003FThrowHelper_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AThrowHelper_002ESerialization_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F6f4af9413b11943bfdd164b58f2c3faa476fb6ffee3bb2d63ea7882b139c1_003FThrowHelper_002ESerialization_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AType_002ECoreCLR_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F5cde391207de75962d7bacb899ca2bd3985c86911b152d185b58999a422bf0_003FType_002ECoreCLR_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AZipArchive_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F39ececcf144e1f9c884152723ed93931cd232485eaf2824bf5beb526f1f321b_003FZipArchive_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/Environment/AssemblyExplorer/XmlDocument/@EntryValue">&lt;AssemblyExplorer&gt;&#xD;
&lt;Assembly Path="D:\Cloud\Git\TinfoilVibeServer\TinfoilVibeServer\libhac\src\LibHac\bin\Release\net8.0\LibHac.dll" /&gt;&#xD;
&lt;/AssemblyExplorer&gt;</s:String></wpf:ResourceDictionary>
&lt;/AssemblyExplorer&gt;</s:String>
<s:String x:Key="/Default/Environment/UnitTesting/UnitTestSessionStore/Sessions/=03775624_002D513c_002D49dc_002Db839_002De7eafc50ef7f/@EntryIndexedValue">&lt;SessionState ContinuousTestingMode="0" Name="All tests from Solution" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"&gt;&#xD;
&lt;Solution /&gt;&#xD;
&lt;/SessionState&gt;</s:String>
<s:String x:Key="/Default/Environment/UnitTesting/UnitTestSessionStore/Sessions/=ba03b870_002Df09c_002D4f54_002D88ad_002D0910534b40f1/@EntryIndexedValue">&lt;SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from Solution #2" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"&gt;&#xD;
&lt;Solution /&gt;&#xD;
&lt;/SessionState&gt;</s:String></wpf:ResourceDictionary>
+79 -42
View File
@@ -1,53 +1,55 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using TinfoilVibeServer.Models;
using LibHac.Common;
using LibHac.Common.Keys;
using TinfoilVibeServer.Services;
using TinfoilVibeServer.Utilities;
namespace TinfoilVibeServer.Authentication;
public interface IAuthStore
{
void Dispose();
bool TryValidate(string username,
string password,
int? uid,
string ip,
out string? error);
int IncrementFailed(string username, string ip);
bool IsIPBlacklisted(string ipAddress);
}
/// <summary>
/// Holds authentication configuration and runtime state.
/// It watches credentials.json for changes and updates the inmemory
/// user list (including the Verified flag) on the fly.
/// </summary>
public sealed class AuthStore : IDisposable
public class AuthStore : IDisposable, IAuthStore
{
private readonly ILogger<AuthStore> _logger;
public readonly AuthSettings Settings;
private readonly ConfigManager _configManager;
public readonly ConcurrentDictionary<string, Credential> Credentials = new();
public readonly ConcurrentDictionary<string, List<int>> Fingerprints = new();
public readonly ConcurrentDictionary<string, int> FailedAttempts = new();
public readonly HashSet<string> BlacklistIPs = new();
private readonly HashSet<string> BlacklistIPs = new();
private readonly object _sync = new();
private readonly FileSystemWatcher _credentialsWatcher;
public AuthStore(ILogger<AuthStore> logger)
public AuthStore(ILogger<AuthStore> logger, ConfigManager configManager)
{
_logger = logger;
Settings = new AuthSettings(
"credentials.json",
"fingerprints.json",
"blacklist.json",
5
);
_configManager = configManager;
LoadAll();
var directoryName = Path.GetDirectoryName(Settings.CredentialsFile);
var directoryName = Path.GetDirectoryName(_configManager.Settings.CredentialsFile);
_credentialsWatcher = new FileSystemWatcher
{
Path = (!string.IsNullOrEmpty(directoryName))?directoryName : AppContext.BaseDirectory,
Filter = Path.GetFileName(Settings.CredentialsFile),
Filter = Path.GetFileName(_configManager.Settings.CredentialsFile),
NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size | NotifyFilters.Attributes
};
_credentialsWatcher.Changed += (_, _) => OnCredentialsChanged();
@@ -63,33 +65,45 @@ public sealed class AuthStore : IDisposable
private void LoadAll()
{
_logger.LogInformation("Loading authentication data from {File}", Settings.CredentialsFile);
_logger.LogInformation("Loading authentication data from {File}", _configManager.Settings.CredentialsFile);
// credentials
if (File.Exists(Settings.CredentialsFile))
if (File.Exists(_configManager.Settings.CredentialsFile))
{
var txt = File.ReadAllText(Settings.CredentialsFile);
var txt = File.ReadAllText(_configManager.Settings.CredentialsFile);
var dict = JsonSerializer.Deserialize<Dictionary<string, Credential>>(txt)!;
foreach (var kv in dict)
Credentials[kv.Key] = kv.Value;
}
else
{
FileSystemExtensions.EnsureDirectoryExists(Path.GetDirectoryName(Path.GetFullPath(_configManager.Settings.CredentialsFile)));
}
// fingerprints
if (File.Exists(Settings.FingerprintsFile))
if (File.Exists(_configManager.Settings.FingerprintsFile))
{
var txt = File.ReadAllText(Settings.FingerprintsFile);
var txt = File.ReadAllText(_configManager.Settings.FingerprintsFile);
var dict = JsonSerializer.Deserialize<Dictionary<string, List<int>>>(txt)!;
foreach (var kv in dict)
Fingerprints[kv.Key] = kv.Value;
}
else
{
FileSystemExtensions.EnsureDirectoryExists(Path.GetDirectoryName(Path.GetFullPath(_configManager.Settings.FingerprintsFile)));
}
// blacklist
if (File.Exists(Settings.BlacklistFile))
if (File.Exists(_configManager.Settings.BlacklistFile))
{
var txt = File.ReadAllText(Settings.BlacklistFile);
var txt = File.ReadAllText(_configManager.Settings.BlacklistFile);
var arr = JsonSerializer.Deserialize<string[]>(txt)!;
foreach (var ip in arr)
BlacklistIPs.Add(ip);
}
else
{
FileSystemExtensions.EnsureDirectoryExists(Path.GetDirectoryName(Path.GetFullPath(_configManager.Settings.BlacklistFile)));
}
_logger.LogInformation("Loaded {UserCount} users, {FpCount} fingerprints, {IpCount} IPs",
Credentials.Count, Fingerprints.Count, BlacklistIPs.Count);
}
@@ -110,15 +124,15 @@ public sealed class AuthStore : IDisposable
private void ReloadCredentials()
{
if (!File.Exists(Settings.CredentialsFile))
if (!File.Exists(_configManager.Settings.CredentialsFile))
{
_logger.LogError("Credentials file {File} does not exist", Settings.CredentialsFile);
_logger.LogError("Credentials file {File} does not exist", _configManager.Settings.CredentialsFile);
return;
}
try
{
var txt = File.ReadAllText(Settings.CredentialsFile);
var txt = File.ReadAllText(_configManager.Settings.CredentialsFile);
var newDict = JsonSerializer.Deserialize<Dictionary<string, Credential>>(txt)!;
lock (_sync)
@@ -150,7 +164,7 @@ public sealed class AuthStore : IDisposable
}
catch(Exception ex)
{
_logger.LogError(ex, "Failed to reload credentials from {File}", Settings.CredentialsFile);
_logger.LogError(ex, "Failed to reload credentials from {File}", _configManager.Settings.CredentialsFile);
// ignore malformed JSON or IO error keep old state
}
}
@@ -159,8 +173,6 @@ public sealed class AuthStore : IDisposable
#region Authentication logic
public bool IsBlacklisted(string ip) => BlacklistIPs.Contains(ip);
public bool TryValidate(string username,
string password,
int? uid,
@@ -231,18 +243,25 @@ public sealed class AuthStore : IDisposable
}
}
private void IncrementFailed(string username, string ip)
public int IncrementFailed(string username, string ip)
{
var newCount = FailedAttempts.GetOrAdd(username, 0) + 1;
FailedAttempts[username] = newCount;
lock (_sync)
{
FailedAttempts[username] = newCount;
}
_logger.LogInformation("Failed attempts for {Username} increased to {Count}", username, newCount);
if (newCount < Settings.MaxFailedAttempts) return;
if (newCount < _configManager.Settings.MaxFailedAttempts+1) return newCount;
BlacklistIPs.Add(ip);
PersistBlacklist();
FailedAttempts[username] = 0;
lock (_sync)
{
FailedAttempts[username] = 0;
}
_logger.LogWarning("IP {IP} blacklisted after {Count} failures", ip, newCount);
return newCount;
}
#endregion
@@ -262,19 +281,37 @@ public sealed class AuthStore : IDisposable
private void PersistCredentials()
{
var json = JsonSerializer.Serialize(Credentials, new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(Settings.CredentialsFile, json);
File.WriteAllText(_configManager.Settings.CredentialsFile, json);
}
private void PersistFingerprints()
{
var json = JsonSerializer.Serialize(Fingerprints, new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(Settings.FingerprintsFile, json);
File.WriteAllText(_configManager.Settings.FingerprintsFile, json);
}
private void PersistBlacklist()
{
var json = JsonSerializer.Serialize(BlacklistIPs.ToArray(), new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(Settings.BlacklistFile, json);
File.WriteAllText(_configManager.Settings.BlacklistFile, json);
}
#endregion
#region Blacklist helpers
public bool IsIPBlacklisted(string ipAddress)
{
return BlacklistIPs.Contains(ipAddress);
}
public bool UnbanIp(string ipAddress)
{
return BlacklistIPs.Remove(ipAddress);
}
public bool BlacklistActive()
{
return BlacklistIPs.Count > 0;
}
#endregion
@@ -37,6 +37,10 @@ public sealed class IndexController : ControllerBase
/// </summary>
public IActionResult Index()
{
if (HttpContext.Request.Headers.CacheControl == "no-cache")
{
_snapshotService.RebuildSnapshot();
}
var index = _indexBuilderService.Build();
return Ok(index);
@@ -69,7 +73,7 @@ public sealed class IndexController : ControllerBase
var titleId = match.Groups["id"].Value.ToUpperInvariant();
// ---- 2️⃣ Find the file that contains this TitleId ------------
var entry = _snapshotService.GetSnapshot()
var entry = _snapshotService.GetSnapshot().Files
.FirstOrDefault(e => e.Title?.TitleId == titleId);
if (entry == null)
@@ -15,13 +15,13 @@ public sealed class BasicAuthMiddleware
_next = next;
}
public async Task InvokeAsync(HttpContext context, AuthStore store, ILogger<BasicAuthMiddleware> logger)
public async Task InvokeAsync(HttpContext context, IAuthStore store, ILogger<BasicAuthMiddleware> logger)
{
logger.LogInformation("Incoming request from {IP} {Method} {Path}", context.Connection.RemoteIpAddress, context.Request.Method, context.Request.Path);
var ip = context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
// 1) IP blacklist
if (store.IsBlacklisted(ip))
if (store.IsIPBlacklisted(ip))
{
logger.LogWarning("Blocked request from blacklisted IP {IP}", ip);
context.Response.StatusCode = StatusCodes.Status403Forbidden;
-2
View File
@@ -7,8 +7,6 @@ public sealed record AppSettings(
string[] RootDirectories,
string[] WhitelistExtensions,
string[] RomExtensions,
string SnapshotFile,
string SnapshotBackupFile,
string CredentialsFile,
string FingerprintsFile,
string BlacklistFile,
@@ -0,0 +1,88 @@
using System.ComponentModel;
namespace TinfoilVibeServer.Models;
public sealed class SnapshotOptions : INotifyPropertyChanged
{
private List<string> _rootDirectories = new();
public List<string> RootDirectories
{
get => _rootDirectories;
set
{
if (_rootDirectories != value)
{
_rootDirectories = value;
OnPropertyChanged(nameof(RootDirectories));
}
}
}
private List<string> _whitelistExtensions = new();
public List<string> WhitelistExtensions
{
get => _whitelistExtensions;
set
{
if (_whitelistExtensions != value)
{
_whitelistExtensions = value;
OnPropertyChanged(nameof(_whitelistExtensions));
}
}
}
private List<string> _romExtensions = new();
public List<string> RomExtensions
{
get => _romExtensions;
set
{
if (_romExtensions != value)
{
_romExtensions = value;
OnPropertyChanged(nameof(_romExtensions));
}
}
}
private TimeSpan _cacheTtl = TimeSpan.FromHours(1);
public TimeSpan CacheTtl
{
get => _cacheTtl;
set
{
if (_cacheTtl != value)
{
_cacheTtl = value;
OnPropertyChanged(nameof(CacheTtl));
}
}
}
private string _snapshotFile = "snapshot.json";
public string SnapshotFile
{
get => _snapshotFile;
set
{
if (string.Equals(_snapshotFile,value, StringComparison.InvariantCultureIgnoreCase)) return;
_snapshotFile = value;
OnPropertyChanged(nameof(SnapshotFile));
}
}
private string _snapshotBackupFile = "snapshot.bak";
public string SnapshotBackupFile
{
get => _snapshotBackupFile;
set
{
if (string.Equals(_snapshotBackupFile,value, StringComparison.InvariantCultureIgnoreCase)) return;
_snapshotBackupFile = value;
OnPropertyChanged(nameof(SnapshotBackupFile));
}
}
public event PropertyChangedEventHandler? PropertyChanged;
private void OnPropertyChanged(string propertyName) =>
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
+10 -8
View File
@@ -1,3 +1,4 @@
using Microsoft.Extensions.Options;
using TinfoilVibeServer.Authentication;
using TinfoilVibeServer.Middleware;
using TinfoilVibeServer.Services;
@@ -13,19 +14,20 @@ builder.Logging.AddDebug();
// -------------------------------------------------------------------
builder.Services.AddMemoryCache();
builder.Services.Configure<TitleDbOptions>(builder.Configuration.GetSection("TitleDb"));
builder.Services.Configure<AuthSettings>(builder.Configuration.GetSection("AuthSettings"));
builder.Services.Configure<SnapshotOptions>(builder.Configuration.GetSection("Snapshot"));
builder.Services.AddSingleton<ConfigManager>();
builder.Services.AddSingleton<NSPExtractor>(sp =>
builder.Services.AddSingleton<INSPExtractor, NSPExtractor>(sp =>
{
var config = sp.GetRequiredService<ConfigManager>();
var logger = sp.GetRequiredService<ILogger<NSPExtractor>>();
var logger = sp.GetRequiredService<ILogger<INSPExtractor>>();
var keySet = KeySetHolder.KeySet; // already loaded by ConfigManager
return new NSPExtractor(keySet, logger);
});
builder.Services.AddSingleton<ISnapshotService, SnapshotService>();
builder.Services.AddSingleton<AuthStore>(sp =>
new AuthStore(sp.GetRequiredService<ILogger<AuthStore>>()));
builder.Services.AddSingleton<IAuthStore, AuthStore>();
builder.Services.AddSingleton<TitleDatabaseService>();
builder.Services.AddSingleton<ArchiveHandler>();
builder.Services.AddSingleton<IArchiveHandler, ArchiveHandler>();
builder.Services.AddSingleton<IndexBuilderService>();
builder.Services.AddHostedService<TitleDatabaseService>(provider => provider.GetRequiredService<TitleDatabaseService>()).AddHttpClient();
builder.Services.AddHostedService<IndexBuilderService>(provider => provider.GetRequiredService<IndexBuilderService>());
@@ -45,9 +47,9 @@ app.MapControllers(); // routes the /index.json & /download endpoints
app.MapGet("/debug", () => new SnapshotService(
app.Services.GetRequiredService<ConfigManager>(),
app.Services.GetRequiredService<NSPExtractor>(),
app.Services.GetRequiredService<ArchiveHandler>(),
app.Services.GetRequiredService<IOptionsMonitor<SnapshotOptions>>(),
app.Services.GetRequiredService<INSPExtractor>(),
app.Services.GetRequiredService<IArchiveHandler>(),
app.Services.GetRequiredService<ILogger<SnapshotService>>())
.GetSnapshot());
app.Lifetime.ApplicationStarted.Register(() =>
+11 -3
View File
@@ -8,17 +8,25 @@ using ZipArchive = SharpCompress.Archives.Zip.ZipArchive;
namespace TinfoilVibeServer.Services;
public interface IArchiveHandler
{
/// <summary>
/// Return TitleInfo if an embedded Nintendo archive is found; otherwise null.
/// </summary>
NcaMetadataWithHash? TryExtractTitleInfo(string filePath);
}
/// <summary>
/// Tries to open a file as an archive and look for an embedded NSP/XCI.
/// The extractor is injected so that the hash of the first stream can be accessed
/// while the file is being read.
/// </summary>
public sealed class ArchiveHandler
public sealed class ArchiveHandler : IArchiveHandler
{
private readonly NSPExtractor _nspExtractor;
private readonly INSPExtractor _nspExtractor;
private readonly ILogger<ArchiveHandler> _logger;
public ArchiveHandler(NSPExtractor nspExtractor, ILogger<ArchiveHandler> logger)
public ArchiveHandler(INSPExtractor nspExtractor, ILogger<ArchiveHandler> logger)
{
_nspExtractor = nspExtractor;
_logger = logger;
+1 -3
View File
@@ -9,7 +9,7 @@ namespace TinfoilVibeServer.Services;
/// Reads the JSON config file on startup, watches it for changes, and also
/// loads the KeySet from the file specified in the config.
/// </summary>
public sealed class ConfigManager
public class ConfigManager
{
public AppSettings Settings { get; private set; }
@@ -42,8 +42,6 @@ public sealed class ConfigManager
RootDirectories: Array.Empty<string>(),
WhitelistExtensions: Array.Empty<string>(),
RomExtensions: Array.Empty<string>(),
SnapshotFile: "index.tfl",
SnapshotBackupFile: "snapshot.bin",
CredentialsFile: "credentials.json",
FingerprintsFile: "fingerprints.json",
BlacklistFile: "blacklist.json",
@@ -41,18 +41,23 @@ public sealed class IndexBuilderService: IHostedService
// 1️⃣ Load cache if it exists
var cached = LoadCache();
var snapshot = _snapshotService.GetSnapshot();
if (string.IsNullOrEmpty(snapshot.Hash))
{
_snapshotService.BuildSnapshot();
snapshot = _snapshotService.GetSnapshot();
}
// 2️⃣ Rebuild only if the snapshot hash changed
string snapshotHash = ComputeSnapshotHash(snapshot);
string snapshotHash = ComputeSnapshotHash(snapshot.Files);
if (cached != null && cached.SnapshotHash == snapshotHash)
{
_logger.LogInformation("Using cached index (snapshot hash={Hash}", snapshotHash);
return cached.Index;
}
_logger.LogInformation("Building index (snapshot size={Count})", snapshot.Count);
_logger.LogInformation("Building index (snapshot size={Count})", snapshot.Files.Count);
// 3️⃣ Build new index from snapshot entries
var files = snapshot
var files = snapshot.Files
.Where(e => e.Title != null)
.Select(e =>
{
@@ -63,7 +68,11 @@ public sealed class IndexBuilderService: IHostedService
var vProcessed = e.Title.Version * 0x10000;
var patchOrApp = e.Title.ContentMetaType == ContentMetaType.Application ? "Base" : "Update";
var url = $"{name}[{titleId}][{vProcessed:X}][{patchOrApp}].nsp";
if (e.Title.ContentMetaType == ContentMetaType.Patch)
{
name = _titleDb.TryGetTitle(e.Title.ApplicationTitle, out var appTitle) ? appTitle.Name : "Unknown";
}
var url = $"{name}[{titleId}][{vProcessed}][{patchOrApp}].nsp";
return new FileDto(url, e.Size);
})
+28 -10
View File
@@ -18,15 +18,30 @@ using TinfoilVibeServer.Models;
namespace TinfoilVibeServer.Services
{
public interface INSPExtractor
{
/// <summary>
/// Public convenience wrapper that opens the file on disk.
/// </summary>
NcaMetadataWithHash? ExtractFromFile(string filePath);
/// <summary>
/// Core implementation works on any seekable stream that contains a full NSP/XCI container.
/// </summary>
NcaMetadataWithHash? ExtractFromStream(Stream stream);
string ExtractHashFromStream(Stream nspStream);
}
/// <summary>
/// Extracts the TitleId, version, type *and* the SHA256 of the first NCA stream.
/// </summary>
public sealed class NSPExtractor
public sealed class NSPExtractor : INSPExtractor
{
private readonly KeySet _keySet;
private readonly ILogger<NSPExtractor> _logger;
private readonly ILogger<INSPExtractor> _logger;
public NSPExtractor(KeySet keySet, ILogger<NSPExtractor> logger)
public NSPExtractor(KeySet keySet, ILogger<INSPExtractor> logger)
{
_keySet = keySet;
_logger = logger;
@@ -84,16 +99,16 @@ namespace TinfoilVibeServer.Services
using var sha256 = SHA256.Create();
var hash = sha256.ComputeHash(ncaStream);
var contentMetaType = GetMetaDataType(nca);
var (contentMetaType,applicationTitle) = GetMetaDataType(nca);
if (contentMetaType != null)
return new NcaMetadataWithHash(titleId, version, contentMetaType.Value, BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant());
return new NcaMetadataWithHash(titleId, applicationTitle.ToString("X16"), version, contentMetaType.Value, BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant());
}
return null; // no meta NCA found
}
private static ContentMetaType? GetMetaDataType(Nca nca)
private static (ContentMetaType?,ulong) GetMetaDataType(Nca nca)
{
if (nca.Header.ContentType != NcaContentType.Meta) return null;
if (nca.Header.ContentType != NcaContentType.Meta) return (null,0);
using var openFileSystem = nca.OpenFileSystem(0, IntegrityCheckLevel.ErrorOnInvalid);
foreach (var entry in openFileSystem.EnumerateEntries("*.cnmt", SearchOptions.Default))
{
@@ -105,10 +120,11 @@ namespace TinfoilVibeServer.Services
using var asStream = nacpFile.AsStream();
var cnmt = new Cnmt(asStream);
return cnmt.Type;
var applicationTitle = cnmt.ApplicationTitleId;
return (cnmt.Type,applicationTitle);
}
return null;
return (null,0);
}
/// <summary>
/// Quick sanity check that the stream looks like a PFS0 file system.
@@ -186,14 +202,16 @@ namespace TinfoilVibeServer.Services
public sealed class NcaMetadataWithHash
{
public string TitleId { get; }
public string ApplicationTitle { get; set; }
public int Version { get; }
public ContentMetaType ContentMetaType { get; set; }
public string Hash { get; }
public NcaMetadataWithHash(string titleId, int version, ContentMetaType contentMetaType, string hash)
public NcaMetadataWithHash(string titleId, string applicationTitle, int version, ContentMetaType contentMetaType, string hash)
{
TitleId = titleId;
ApplicationTitle = applicationTitle;
Version = version;
ContentMetaType = contentMetaType;
Hash = hash;
+91 -34
View File
@@ -1,14 +1,17 @@
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.Extensions.Options;
using TinfoilVibeServer.Models;
using TinfoilVibeServer.Utilities;
namespace TinfoilVibeServer.Services;
public interface ISnapshotService
{
event EventHandler SnapshotRebuilt; // raised after a rebuild
void RebuildSnapshot();
IReadOnlyList<FileEntry> GetSnapshot();
SnapshotService.ROMSnapshot GetSnapshot();
void BuildSnapshot();
}
/// <summary>
@@ -17,38 +20,54 @@ public interface ISnapshotService
/// </summary>
public sealed class SnapshotService : IDisposable, ISnapshotService
{
private readonly ConfigManager _config;
private readonly NSPExtractor _nspExtractor;
private readonly ArchiveHandler _archiveHandler;
private readonly SnapshotOptions _options;
private readonly INSPExtractor _nspExtractor;
private readonly IArchiveHandler _archiveHandler;
private readonly ILogger<SnapshotService> _logger;
private readonly string _jsonPath;
private readonly string _snapshotPath;
private readonly List<FileSystemWatcher> _watchers = new();
private readonly ConcurrentDictionary<string, CachedFile> _cache = new();
private string? _currentSnapshotHash;
private readonly Timer _debounceTimer;
public event EventHandler? SnapshotRebuilt;
public SnapshotService(ConfigManager config, NSPExtractor nspExtractor, ArchiveHandler archiveHandler, ILogger<SnapshotService> logger)
public SnapshotService(
IOptionsMonitor<SnapshotOptions> options,
INSPExtractor nspExtractor,
IArchiveHandler archiveHandler,
ILogger<SnapshotService> logger)
{
_config = config;
_options = options.CurrentValue;
_nspExtractor = nspExtractor;
_archiveHandler = archiveHandler;
_logger = logger;
_jsonPath = Path.Combine(AppContext.BaseDirectory, _config.Settings.SnapshotFile);
_snapshotPath = Path.Combine(AppContext.BaseDirectory, _config.Settings.SnapshotBackupFile);
_jsonPath = Path.Combine(AppContext.BaseDirectory, _options.SnapshotFile);
FileSystemExtensions.EnsureDirectoryExists(Path.GetDirectoryName(_jsonPath));
if (!File.Exists(_jsonPath))
{
File.WriteAllText(_jsonPath, "[]");
}
_snapshotPath = Path.Combine(AppContext.BaseDirectory, _options.SnapshotBackupFile);
FileSystemExtensions.EnsureDirectoryExists(Path.GetDirectoryName(_snapshotPath));
// 1️⃣ Register for *property* changes
_options.PropertyChanged += (s, e) => OnOptionsChanged(e.PropertyName);
BuildSnapshot(); // initial scan
File.WriteAllText(_snapshotPath, JsonSerializer.Serialize(GetSnapshot()));
_debounceTimer = new Timer(_ => DebounceElapsed(), null, Timeout.Infinite, Timeout.Infinite);
foreach (var path in _config.Settings.RootDirectories)
foreach (var path in _options.RootDirectories)
{
InitializeFileSystemWatcher(path);
}
_config.OnChange += cfg =>
}
// --------- Private helpers ---------
private void OnOptionsChanged(string propertyName)
{
if (propertyName == nameof(SnapshotOptions.RootDirectories))
{
var fileSystemWatchers = _watchers.Where(watcher => !cfg.RootDirectories.Contains(watcher.Path));
var fileSystemWatchers = _watchers.Where(watcher => !_options.RootDirectories.Contains(watcher.Path));
foreach (var watcher in fileSystemWatchers)
{
watcher.EnableRaisingEvents = false;
@@ -56,7 +75,7 @@ public sealed class SnapshotService : IDisposable, ISnapshotService
_watchers.Remove(watcher);
}
var newWatchedDirectories = cfg.RootDirectories.Where(newWatchedDirectory =>
var newWatchedDirectories = _options.RootDirectories.Where(newWatchedDirectory =>
!_watchers.Any(watcher =>
string.Equals(watcher.Path, newWatchedDirectory, StringComparison.OrdinalIgnoreCase)));
@@ -65,11 +84,11 @@ public sealed class SnapshotService : IDisposable, ISnapshotService
InitializeFileSystemWatcher(newWatchedDirectory);
}
BuildSnapshot(); // rebuild everything
PersistSnapshot();
};
}
}
private void InitializeFileSystemWatcher(string path)
{
if (!Directory.Exists(path)) return;
@@ -96,42 +115,65 @@ public sealed class SnapshotService : IDisposable, ISnapshotService
private void ThrottleSnapshotUpdate(FileSystemEventArgs fileSystemEventArgs)
{
Task.Run(async () =>
lock (_lock)
{
_debounceTimer.Change(_debounceMs, Timeout.Infinite); // reset the timer
_logger.LogDebug("File system event {EventType} on {Path} at {Time}", fileSystemEventArgs.ChangeType, fileSystemEventArgs.FullPath, DateTime.Now.ToString("HH:mm:ss"));
}
/*Task.Run(async () =>
{
await Task.Delay(250);
_logger.LogDebug("File system event {EventType} on {Path}", fileSystemEventArgs.ChangeType, fileSystemEventArgs.FullPath);
UpdateSnapshot();
});
});*/
}
private readonly object _lock = new object();
private int _debounceMs = 200;
private void DebounceElapsed()
{
UpdateSnapshot();
}
#endregion
#region Snapshot logic
private void BuildSnapshot()
public void BuildSnapshot()
{
var cfg = _config.Settings;
_logger.LogInformation("Rebuilding snapshot (root dirs: {Count})", cfg.RootDirectories.Length);
var entries = new List<FileEntry>();
var index = LoadSnapshotIndex();
var latestModifiedUtcParallel = FileSystemExtensions.GetLatestModifiedUtcParallel(_options.RootDirectories);
var fileInfo = new FileInfo(_snapshotPath);
if (latestModifiedUtcParallel.HasValue && latestModifiedUtcParallel.Value < fileInfo.LastWriteTimeUtc)
{
_logger.LogInformation("Snapshot is up to date");
return;
}
_logger.LogInformation("Rebuilding snapshot (root dirs: {Count})", _options.RootDirectories.Count);
var entries = new List<FileEntry>();
var snapshotChanged = false;
foreach (var dir in cfg.RootDirectories)
foreach (var dir in _options.RootDirectories)
{
if (!Directory.Exists(dir)) continue;
foreach (var file in Directory.EnumerateFiles(dir, "*", SearchOption.AllDirectories))
{
var ext = Path.GetExtension(file).ToLowerInvariant();
if (!(cfg.WhitelistExtensions.Contains(ext) || cfg.RomExtensions.Contains(ext)))
if (!(_options.WhitelistExtensions.Contains(ext) || _options.RomExtensions.Contains(ext)))
continue;
if (index.ContainsKey(file)) continue;
if (index.TryGetValue(file, out var value))
{
entries.Add(value);
continue;
}
// 3) extract title if applicable
string hash;
NcaMetadataWithHash? title = null;
if (cfg.RomExtensions.Contains(ext))
if (_options.RomExtensions.Contains(ext))
{
using var nspStream = File.OpenRead(file);
hash = ComputeFirstStreamHash(nspStream);
@@ -170,6 +212,7 @@ public sealed class SnapshotService : IDisposable, ISnapshotService
File.WriteAllText(_jsonPath, JsonSerializer.Serialize(entries));
if (snapshotChanged)
{
_logger.LogInformation("Snapshot rebuilt");
SnapshotRebuilt?.Invoke(this, EventArgs.Empty);
}
}
@@ -183,14 +226,14 @@ public sealed class SnapshotService : IDisposable, ISnapshotService
private void PersistSnapshot()
{
var entries = GetSnapshot();
var newHash = ComputeSnapshotHash(entries);
var snapshot = GetSnapshot();
var newHash = ComputeSnapshotHash(snapshot.Files);
if (_currentSnapshotHash != newHash)
{
_logger.LogInformation("Snapshot hash changed persisting new snapshot");
_currentSnapshotHash = newHash;
File.WriteAllText(_jsonPath, JsonSerializer.Serialize(entries));
File.WriteAllText(_snapshotPath, JsonSerializer.Serialize(entries));
File.WriteAllText(_jsonPath, JsonSerializer.Serialize(snapshot.Files));
File.WriteAllText(_snapshotPath, JsonSerializer.Serialize(snapshot.Files));
}
}
@@ -219,10 +262,18 @@ public sealed class SnapshotService : IDisposable, ISnapshotService
}
#endregion
public IReadOnlyList<FileEntry> GetSnapshot()
public ROMSnapshot GetSnapshot()
{
if (!File.Exists(_jsonPath)) return new();
var json = File.ReadAllText(_jsonPath);
return JsonSerializer.Deserialize<IReadOnlyList<FileEntry>>(json, new JsonSerializerOptions(){IncludeFields = true})!;
var hash = ComputeHash(_jsonPath);
var romSnapshot = new ROMSnapshot()
{
Hash = hash,
Files = JsonSerializer.Deserialize<IReadOnlyList<FileEntry>>(json,
new JsonSerializerOptions() { IncludeFields = true })!
};
return romSnapshot;
}
public void RebuildSnapshot()
@@ -285,4 +336,10 @@ public sealed class SnapshotService : IDisposable, ISnapshotService
var hash = sha256.ComputeHash(stream);
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
}
public class ROMSnapshot
{
public string Hash { get; set; }
public IReadOnlyList<FileEntry> Files { get; set; } = new List<FileEntry>();
}
}
@@ -24,7 +24,7 @@ public sealed class TitleDatabaseService : IHostedService
private readonly IOptionsMonitor<TitleDbOptions> _options;
private readonly ILogger<TitleDatabaseService> _logger;
private readonly IHttpClientFactory _httpFactory;
private readonly NSPExtractor _nspExtractor;
private readonly INSPExtractor _nspExtractor;
private readonly string _cacheFolder; // Where the JSON is cached.
private readonly List<string> _rootDirectories; // directories that contain game files
@@ -62,7 +62,7 @@ public sealed class TitleDatabaseService : IHostedService
ILogger<TitleDatabaseService> logger,
ISnapshotService snapshotService,
IHttpClientFactory httpFactory,
NSPExtractor nspExtractor,
INSPExtractor nspExtractor,
IMemoryCache cache)
{
_options = options;
+1 -1
View File
@@ -9,7 +9,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Http" Version="2.3.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.6"/>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.10" />
<PackageReference Include="SharpCompress" Version="0.41.0" />
<PackageReference Include="SharpSevenZip" Version="2.0.32" />
</ItemGroup>
@@ -0,0 +1,124 @@
namespace TinfoilVibeServer.Utilities;
public static class FileSystemExtensions
{
/// <summary>
/// Returns the most recent lastwrite time (UTC) of any file under the supplied
/// root directories, traversing all subdirectories. If no files are found,
/// <c>null</c> is returned.
/// </summary>
/// <param name="rootDirectories">
/// A collection of absolute paths that must point to existing directories.
/// Paths that do not exist or are inaccessible are silently skipped.
/// </param>
/// <returns>
/// The UTC <see cref="DateTime"/> of the newest file, or <c>null</c> if there are none.
/// </returns>
public static DateTime? GetLatestModifiedUtc(IEnumerable<string> rootDirectories)
{
if (rootDirectories == null) throw new ArgumentNullException(nameof(rootDirectories));
// We keep a mutable variable because we don't want to materialise the entire
// sequence into memory.
DateTime? latest = null;
foreach (var root in rootDirectories)
{
if (string.IsNullOrWhiteSpace(root) || !Directory.Exists(root))
continue; // skip bad paths
try
{
// Enumerate lazily and process each file as soon as its yielded.
foreach (var filePath in Directory.EnumerateFiles(
root,
"*",
SearchOption.AllDirectories))
{
try
{
// Using FileSystemInfo to fetch only the property we need.
var fsi = new FileInfo(filePath);
var lastWrite = fsi.LastWriteTimeUtc;
if (!latest.HasValue || lastWrite > latest.Value)
latest = lastWrite;
}
catch (FileNotFoundException) // file vanished while we were enumerating
{
// ignore and keep going
}
catch (UnauthorizedAccessException)
{
// file exists but we cant read its attributes skip it
}
}
}
catch (UnauthorizedAccessException)
{
// the root directory itself is inaccessible skip it
}
}
return latest;
}
/// <summary>
/// Parallelised version that may be faster on very large directory trees.
/// </summary>
public static DateTime? GetLatestModifiedUtcParallel(IEnumerable<string> rootDirectories)
{
if (rootDirectories == null) throw new ArgumentNullException(nameof(rootDirectories));
// Flatten all file paths into a single stream first (this is the only
// part that needs to be threadsafe).
var allFiles = rootDirectories
.Where(r => !string.IsNullOrWhiteSpace(r) && Directory.Exists(r))
.SelectMany(r => Directory.EnumerateFiles(r, "*", SearchOption.AllDirectories))
.ToArray(); // materialise once, then parallelise
// Now fetch the dates in parallel. The LINQ overload of Max() that takes
// an async selector is not available, so we just use Parallel.ForEach.
DateTime? latest = null;
var lockObj = new object();
Parallel.ForEach(allFiles, filePath =>
{
try
{
var lastWrite = new FileInfo(filePath).LastWriteTimeUtc;
lock (lockObj)
{
if (!latest.HasValue || lastWrite > latest.Value)
latest = lastWrite;
}
}
catch (Exception)
{
// swallow all exceptions the caller only cares about the max date
}
});
return latest;
}
/// <summary>
/// Creates the directory (and all missing parent directories) if it does not already exist.
/// </summary>
/// <param name="path">Absolute or relative path to the directory to create.</param>
/// <exception cref="ArgumentNullException">Thrown if <paramref name="path"/> is null.</exception>
/// <exception cref="ArgumentException">Thrown if <paramref name="path"/> is empty or contains only whitespace.</exception>
/// <exception cref="UnauthorizedAccessException">Thrown if the caller does not have permission.</exception>
/// <exception cref="IOException">Thrown if a file exists at the target path or the directory cannot be created.</exception>
public static void EnsureDirectoryExists(string path)
{
if (path is null)
throw new ArgumentNullException(nameof(path));
if (string.IsNullOrWhiteSpace(path))
throw new ArgumentException("Path must not be empty or whitespace.", nameof(path));
// Directory.CreateDirectory is already idempotent it only creates missing parts.
Directory.CreateDirectory(path);
}
}
+9 -6
View File
@@ -7,16 +7,19 @@
},
"AllowedHosts": "*",
"RootDirectories": [ "\\\\NAS\\Roms\\Switch", "Z:\\imgs\\roms\\Switch" ],
"WhitelistExtensions": [ ".bin", ".jpg", ".png", ".txt" ],
"RomExtensions": [ ".xci", ".nsp", ".xcz" ],
"SnapshotFile": "index.tfl",
"SnapshotBackupFile": "snapshot.bin",
"KeySetFile": "prod.keys",
"CredentialsFile": "credentials.json",
"FingerprintsFile": "fingerprints.json",
"BlacklistFile": "blacklist.json",
"MaxFailedAttempts": 5,
"KeySetFile": "prod.keys",
"Snapshot" : {
"RootDirectories": [ "\\\\NAS\\Roms\\Switch", "Z:\\imgs\\roms\\Switch" ],
"WhitelistExtensions": [ ".bin", ".jpg", ".png", ".txt" ],
"RomExtensions": [ ".xci", ".nsp", ".xcz" ],
"CacheTtl": 60,
"SnapshotFile": "index.tfl",
"SnapshotBackupFile": "snapshot.bin"
},
"TitleDb": {
"CountryCode": "AU",
"Language": "en",
@@ -0,0 +1,111 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moq;
using TinfoilVibeServer.Authentication;
using TinfoilVibeServer.Services;
// <-- adjust namespace
namespace TinfoilVibeServerTest.Tests
{
[TestFixture]
public class AuthStoreTests
{
private Mock<ILogger<AuthStore>> _loggerMock;
private AuthStore _authStore;
[SetUp]
public void SetUp()
{
_loggerMock = new Mock<ILogger<AuthStore>>();
// Assume Settings is static and can be patched for tests
MockConfigManager = new Mock<ConfigManager>();
_authStore = new AuthStore(_loggerMock.Object, MockConfigManager.Object);
}
public Mock<ConfigManager> MockConfigManager { get; set; }
[TearDown]
public void TearDown()
{
_authStore.Dispose();
}
[Test]
public void LoadAll_ShouldPopulateCollections()
{
// Act
var users = _authStore.Credentials.Count;
var fprs = _authStore.Fingerprints.Count;
// Assert
Assert.That(users, Is.GreaterThan(0), "At least one user must be loaded");
Assert.That(fprs, Is.GreaterThanOrEqualTo(0));
_loggerMock.Verify(
x => x.Log(
LogLevel.Information,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString().Contains("Loaded")),
null,
(Func<It.IsAnyType, Exception, string>)It.IsAny<object>()),
Times.AtLeastOnce);
}
[Test]
public void TryValidate_NewUser_ShouldCreateAndVerify()
{
// Arrange
var newUser = "newuser";
var ip = "127.0.0.1";
var password = "";
var uid = null as int?;
// Act
var result = _authStore.TryValidate(newUser, password, uid, ip, out var cred);
// Assert
Assert.That(result, Is.False, "New user should be not be verified automatically");
Assert.That(_authStore.Credentials[newUser], Is.Not.Null);
Assert.That(_authStore.Credentials[newUser].Verified, Is.False);
// New user should now exist
Assert.That(_authStore.Credentials.Any(u => u.Value.Username == newUser), Is.True);
}
[Test]
public void IncrementFailed_BeforeBlacklist_ShouldNotBlacklist()
{
// Arrange
var ip = "203.0.113.5";
var cred = new Credential("dummy", "hash", 1, false);
_authStore.UnbanIp(ip); // ensure clean
// Act
var counter = _authStore.IncrementFailed(cred.Username, ip);
// Assert
Assert.That(counter, Is.EqualTo(1));
Assert.That(_authStore.IsIPBlacklisted(ip), Is.False);
}
[Test]
public void IncrementFailed_ExceedingThreshold_ShouldBlacklist()
{
// Arrange
var ip = "203.0.113.5";
var cred = new Credential("dummy", "hash", MockConfigManager.Object.Settings.MaxFailedAttempts, false);
int threshold = MockConfigManager.Object.Settings.MaxFailedAttempts;
// Simulate threshold failures
for (int i = 0; i < threshold; i++)
_authStore.IncrementFailed(cred.Username, ip);
// Act
int final = _authStore.IncrementFailed(cred.Username, ip);
// Assert
Assert.That(final, Is.EqualTo(threshold + 1));
Assert.That(_authStore.IsIPBlacklisted(ip), Is.True);
}
}
}
@@ -0,0 +1,116 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Text;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Moq;
using NUnit.Framework;
using TinfoilVibeServer.Authentication;
using TinfoilVibeServer.Middleware;
namespace TinfoilVibeServerTest.Tests
{
[TestFixture]
public class BasicAuthMiddlewareTests
{
private Mock<ILogger<BasicAuthMiddleware>> _loggerMock;
private Mock<IAuthStore> _authMock;
private BasicAuthMiddleware _middleware;
private RequestDelegate _next;
[SetUp]
public void SetUp()
{
_loggerMock = new Mock<ILogger<BasicAuthMiddleware>>();
_authMock = new Mock<IAuthStore>();
_next = (HttpContext ctx) => Task.CompletedTask;
_middleware = new BasicAuthMiddleware(_next);
}
private HttpContext CreateContext(string authHeader = null, string ip = "127.0.0.1", int? uid = null)
{
var ctx = new DefaultHttpContext();
ctx.Connection.RemoteIpAddress = IPAddress.Parse(ip);
if (!string.IsNullOrEmpty(authHeader))
{
ctx.Request.Headers["Authorization"] = authHeader;
}
if (uid!= null)
{
ctx.Request.Headers["UID"] = uid.ToString();
}
return ctx;
}
[Test]
public async Task InvokeAsync_NoAuthHeader_ShouldReturn401()
{
// Arrange
var ctx = CreateContext();
// Act
await _middleware.InvokeAsync(ctx, _authMock.Object, _loggerMock.Object);
// Assert
Assert.That(ctx.Response.StatusCode, Is.EqualTo(StatusCodes.Status401Unauthorized));
_loggerMock.Verify(l => l.Log(
LogLevel.Warning,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString().Contains("Missing Authorization header")),
null,
It.IsAny<Func<It.IsAnyType, Exception, string>>()), Times.Once);
}
[Test]
public async Task InvokeAsync_BlacklistedIP_ShouldReturn403()
{
// Arrange
var ctx = CreateContext("Basic dXNlcjpwYXNz");
_authMock.Setup(a => a.IsIPBlacklisted("127.0.0.1")).Returns(true);
// Act
await _middleware.InvokeAsync(ctx, _authMock.Object, _loggerMock.Object);
// Assert
Assert.That(ctx.Response.StatusCode, Is.EqualTo(StatusCodes.Status403Forbidden));
}
[Test]
public async Task InvokeAsync_ValidCredentials_ShouldCallNext()
{
// Arrange
var user = "alice";
var pw = "secret";
var uid = 1234;
var header = $"Basic {Convert.ToBase64String(Encoding.ASCII.GetBytes($"{user}:{pw}"))}";
var ip = "127.0.0.1";
var ctx = CreateContext(header,ip, uid);
string? error;
_authMock.Setup(a =>
a.TryValidate(user, pw, uid, ip, out error))
.Returns(true);
bool nextCalled = false;
_next = (HttpContext _) => { nextCalled = true; return Task.CompletedTask; };
_middleware = new BasicAuthMiddleware(_next);
// Act
await _middleware.InvokeAsync(ctx, _authMock.Object, _loggerMock.Object);
// Assert
Assert.That(nextCalled, Is.True);
Assert.That(ctx.Response.StatusCode, Is.EqualTo(StatusCodes.Status200OK));
}
}
}
@@ -0,0 +1,119 @@
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using LibHac.Ncm;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moq;
using NUnit.Framework;
using TinfoilVibeServer.Models;
using TinfoilVibeServer.Services;
using TinfoilVibeServer.Utilities;
namespace TinfoilVibeServerTest.Tests
{
[TestFixture]
public class SnapshotServiceTests
{
private Mock<ILogger<SnapshotService>> _loggerMock;
private SnapshotService _service;
private Mock<INSPExtractor> _nspExtractorMock;
private Mock<IArchiveHandler> _archiveHander;
private Mock<IOptionsMonitor<SnapshotOptions>> _mockOptions;
private SnapshotOptions _options;
[SetUp]
public void SetUp()
{
_mockOptions = new Mock<IOptionsMonitor<SnapshotOptions>>();
_options = new SnapshotOptions()
{
RomExtensions = [".nsp"],
RootDirectories = ["TestData/ROMS"],
SnapshotFile = "TestData/snapshot.json",
SnapshotBackupFile = "TestData/snapshot.bak"
};
/*// ensure ROM test directory has test files removed
foreach (var file in Directory.GetFiles("TestData/ROMS"))
{
File.Delete(file);
}*/
_mockOptions.Setup(m => m.CurrentValue).Returns(_options);
_loggerMock = new Mock<ILogger<SnapshotService>>();
_archiveHander = new Mock<IArchiveHandler>();
_nspExtractorMock = new Mock<INSPExtractor>();
_nspExtractorMock.Setup(extractor => extractor.ExtractHashFromStream(It.IsAny<Stream>())).Returns("HASH");
_nspExtractorMock.Setup(extractor => extractor.ExtractFromStream(It.IsAny<Stream>())).Returns(
new NcaMetadataWithHash(titleId: "0000000000000000","0000000000000000", version: 1, ContentMetaType.Application, "HASH"));
//Settings.RootDirs = new List<string> { "TestData/Root1", "TestData/Root2" };
_service = new SnapshotService(_mockOptions.Object, _nspExtractorMock.Object, _archiveHander.Object, _loggerMock.Object);
}
[TearDown]
public void TearDown()
{
_service.Dispose();
}
[Test]
public async Task BuildSnapshot_WhenFilesChanged_ShouldPersist()
{
// Arrange
await File.WriteAllTextAsync(_options.SnapshotFile, "[]");
var initialHash = _service.GetSnapshot()?.Hash;
// Add a file to Root1
var newFile = Path.Combine(_options.RootDirectories.First(), "new.nsp");
FileSystemExtensions.EnsureDirectoryExists(Path.GetDirectoryName(newFile));
// Create a new valid NSP file
// copy to temp to touch modified date
foreach (var file in Directory.GetFiles("../../../Data/"))
{
var filename = Path.GetFileName(file);
var destFilename = Path.Combine(Path.GetTempPath(), filename);
File.Copy(file, destFilename, true);
var info = new FileInfo(destFilename)
{
LastWriteTimeUtc = DateTime.UtcNow
};
info.CopyTo(Path.Combine(_options.RootDirectories.First(),filename), true);
}
// Act
_service.SnapshotRebuilt+= (sender, args) =>
{
// Assert
var newHash = _service.GetSnapshot()?.Hash;
Assert.That(newHash, Is.Not.EqualTo(initialHash));
};
Task.Delay(300).Wait();
_loggerMock.Verify(
l => l.Log(
LogLevel.Information,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString().Contains("Snapshot rebuilt")),
null,
It.IsAny<Func<It.IsAnyType, Exception, string>>()), Times.Once);
}
[Test]
public async Task BuildSnapshot_NoChange_ShouldNotPersist()
{
// Act
_service.BuildSnapshot();
// Act again snapshot should be identical
_service.BuildSnapshot();
// Assert
_loggerMock.Verify(
l => l.Log(
LogLevel.Information,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString().Contains("persisting new snapshot")),
null,
It.IsAny<Func<It.IsAnyType, Exception, string>>()), Times.Never);
}
}
}
@@ -6,18 +6,41 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<CollectCoverage>true</CollectCoverage>
<CoverletOutputFormat>opencover</CoverletOutputFormat>
<CoverletOutput>../coverage/</CoverletOutput>
<ExcludeByFile>**/Program.cs</ExcludeByFile> <!-- if you dont want the host code -->
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.2"/>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
<PackageReference Include="NUnit" Version="4.2.2"/>
<PackageReference Include="NUnit.Analyzers" Version="4.4.0"/>
<PackageReference Include="NUnit3TestAdapter" Version="4.6.0"/>
<PackageReference Include="coverlet.collector" Version="6.0.4">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.10" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.0" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="NUnit" Version="4.4.0" />
<PackageReference Include="NUnit.Analyzers" Version="4.11.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="NUnit3TestAdapter" Version="5.2.0" />
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="22.0.16" />
</ItemGroup>
<ItemGroup>
<Using Include="NUnit.Framework"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\TinfoilVibeServer\TinfoilVibeServer.csproj" />
</ItemGroup>
<ItemGroup>
<Folder Include="Data\" />
</ItemGroup>
<ItemGroup>
<Reference Include="LibHac">
<HintPath>..\libhac\src\LibHac\bin\Release\net8.0\LibHac.dll</HintPath>
</Reference>
</ItemGroup>
</Project>
-15
View File
@@ -1,15 +0,0 @@
namespace TinfoilVibeServerTest;
public class Tests
{
[SetUp]
public void Setup()
{
}
[Test]
public void Test1()
{
Assert.Pass();
}
}