Build Snapshot from archives

Download from archives
Process XCI files in archives
This commit is contained in:
2025-11-07 13:31:37 +10:30
parent 17be096ae2
commit 209b766a1f
17 changed files with 1204 additions and 322 deletions
+1
View File
@@ -2,5 +2,6 @@
<project version="4"> <project version="4">
<component name="VcsDirectoryMappings"> <component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" /> <mapping directory="$PROJECT_DIR$" vcs="Git" />
<mapping directory="$PROJECT_DIR$/libhac" vcs="Git" />
</component> </component>
</project> </project>
+48
View File
@@ -3,9 +3,17 @@
<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: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_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_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_003AArchiveReader_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Ff0a43ad18bad5a87cbf2acea8abe9bb9b0a35a54aaabd6ab2dfea29281543_003FArchiveReader_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_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_003AArrayMemoryPool_002EArrayMemoryPoolBuffer_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F8d472c6e66d6b1c7ae84d34d6a2e85aeee5ad6861eabf9ea9b5e85f7595c96cf_003FArrayMemoryPool_002EArrayMemoryPoolBuffer_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_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_003ABuffer_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F7ee725ee96f111d37690c58fee67515d5d1706bf9892196531efff8346adf_003FBuffer_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ACancellationTokenSource_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F558c1d46e1e21d2e78ee2ab67a674f6927bf95355b2f245f35d74bb5ec0f92_003FCancellationTokenSource_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ACnmt_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003Fb0_003Fa6f99852_003FCnmt_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_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_003AControllerBase_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F6fa05d7dbf2e454ce78b261b422234da1f1ccedee678fd997e5ef4afe6df6e_003FControllerBase_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ACryptoUtil_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003Fd4_003F032ece9d_003FCryptoUtil_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ADictionary_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fe5d623ea960f2c3c9fda144954d339f8d4cd3dad6dd8bd4ab96093a010ab_003FDictionary_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_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_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_003AExpressionExtensions_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fd785b86024a6dfc7a0de13179d2769fe20f85a33e39e7bfc8dfffba6a44a44_003FExpressionExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
@@ -15,35 +23,75 @@
<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_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_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_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_003AFirst_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fbc14b6df5cd75368b65afefb4bd2493c9facd3bbb41af2b1d0eab7e8eee87dbf_003FFirst_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_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_003AHashAlgorithm_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fce7580e8e0f6a2637f8ec8ab41c5fd2f845ad94ea18c76d554db62248d8954_003FHashAlgorithm_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_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_003AHostingAbstractionsHostExtensions_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F41efb8821245d1097e7a593356b9bd4e26a16282ac228833317de7645a9d81_003FHostingAbstractionsHostExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AHost_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fcfb8165d037ea5062589e7be2a322d53397b1abadab026bb319132b6a24556_003FHost_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_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_003AIArchiveEntryExtensions_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fca01a4e200c84eee8955133d13d81dc8c0e00_003F1a_003F8011ac3b_003FIArchiveEntryExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIArchiveEntryExtensions_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fe82cab4cb05dfe89c48ab345f842025771adcc674cc9ede9c399369f8ab8748_003FIArchiveEntryExtensions_002Ecs_002Fz_003A2_002D1/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIFile_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003F9b_003Fef91f762_003FIFile_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_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_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_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_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_003AListeningStream_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F633629cdc36745a741a359d1d83ca239ddb3ac7c7dd49ce6b63025acd5f5e8e2_003FListeningStream_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AList_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F1b81cb3be224213a6a73519b6e340a628d9a1fb8629c351a186a26f6376669_003FList_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_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_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_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_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_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_003AMonitor_002ECoreCLR_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Ff5c5fabf10b2751774ff136491ac41fbc0932fbd1315879ac29f2d13e2ad24b_003FMonitor_002ECoreCLR_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ANxFileStream_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003F35_003F56be8607_003FNxFileStream_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AObject_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F9a6b1457cbcf17db31a383ba49ef9bcc786cf3ef77146d997eee499b27a46d_003FObject_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APartitionFileSystemCore_00604_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003F42_003Ff49d31db_003FPartitionFileSystemCore_00604_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APartitionFileSystemFormat_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003F12_003Ff913ca40_003FPartitionFileSystemFormat_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APartitionFileSystemMetaCore_00603_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003F84_003F3a6a0a14_003FPartitionFileSystemMetaCore_00603_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APartitionFileSystem_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003F94_003F00e38e6a_003FPartitionFileSystem_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_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_003ARandomAccess_002EWindows_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F5cb442cc26c0b982afc257bb62f18d1554630732b2b8245e97107fb51974d_003FRandomAccess_002EWindows_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ARarArchiveEntry_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fc9e95c2dd9e75e585f636790bc74e1484a536fc029954c307fe585a3822129_003FRarArchiveEntry_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ARarArchiveVolumeFactory_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F416daa6b333a5ecff282ed559ac81071113ee993ebcdc662c5cf488d7072575b_003FRarArchiveVolumeFactory_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ARarArchive_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F897247968e1ee1cdab3bf5f342eab3992b66d159a27a99ebb88a7593da4aeb_003FRarArchive_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ARarEntry_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F48a2e7ca6b54673c580a84a5f84c86d7a8cf2b199df9ca68655d9734e95_003FRarEntry_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ARarStream_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fc35d82af548651a2b21dec4154692cd38619ed1ae76afd1aae437738cde798_003FRarStream_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AReadOnlySpan_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F47bfed48817fad7d8e1a89bf3530e4be7277b022a9c7477c5a243031605a5f_003FReadOnlySpan_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_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_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_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_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_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_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_003ASpanHelpers_002EByteMemOps_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F6169f1bb3c3c365c1f886cb89adaa3d156367d5b9d3e238bb46901cffce27_003FSpanHelpers_002EByteMemOps_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASpan_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Faa74b2b4e9988ce29825f798c3ef4254a9bde18b81c60754798e520d5445a27_003FSpan_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AStackFrameIterator_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F2854ce6d56c18d0d837d3a3ef9c4f2c7c77691fa3528c8394986ac7ce7719_003FStackFrameIterator_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AStreamStorage_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003Fa6_003F13a0d744_003FStreamStorage_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AStream_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fd1287462d4ec4078c61b8e92a0952fb7de3e7e877d279e390a4c136a6365126_003FStream_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_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_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_003AThreadPoolWorkQueue_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F478cd37a9cf5552e58e069a6aafb2a112b89b262faef3cfb5054d65b1ea95345_003FThreadPoolWorkQueue_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/@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_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/@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_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_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_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_003ATitleVersion_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003Fa9_003F226f6bd5_003FTitleVersion_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AToCollection_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F439c4ee753b23e743cc14119593bc889751f9eb0b38997577d8e4c47c4fed_003FToCollection_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_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_003AUniqueRef_00601_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003Fa0_003F631946a0_003FUniqueRef_00601_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AUnpack_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F4c375bcb6a2d2378855cad1b1c7cfe7ca1448866f1e8af44226775b5f75df86_003FUnpack_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AUnsafeHelpers_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003Fd1_003Fc59f91c2_003FUnsafeHelpers_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AUtility_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F3ea3ed6216d2412ac7c33016ad940618bcfbcafe1633dc26832be514633b4_003FUtility_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AXciPartition_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003F04_003F4e8815da_003FXciPartition_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/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; <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;Assembly Path="D:\Cloud\Git\TinfoilVibeServer\TinfoilVibeServer\libhac\src\LibHac\bin\Release\net8.0\LibHac.dll" /&gt;&#xD;
&lt;Assembly Path="D:\Cloud\Git\TinfoilVibeServer\libhac\src\LibHac\bin\Release\net8.0\LibHac.dll" /&gt;&#xD;
&lt;/AssemblyExplorer&gt;</s:String> &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; <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;
+104 -44
View File
@@ -3,6 +3,7 @@ using System.IO;
using System.Linq; using System.Linq;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using SharpCompress.Readers;
using TinfoilVibeServer.Models; using TinfoilVibeServer.Models;
using TinfoilVibeServer.Services; using TinfoilVibeServer.Services;
@@ -18,12 +19,12 @@ public sealed class IndexController : ControllerBase
private readonly IndexBuilderService _indexBuilderService; private readonly IndexBuilderService _indexBuilderService;
public IndexController(ISnapshotService snapshotService, public IndexController(ISnapshotService snapshotService,
TitleDatabaseService titleDb, TitleDatabaseService titleDb,
IConfiguration configuration, IndexBuilderService indexBuilderService) IConfiguration configuration, IndexBuilderService indexBuilderService)
{ {
_snapshotService = snapshotService; _snapshotService = snapshotService;
_titleDb = titleDb; _titleDb = titleDb;
_configuration = configuration; _configuration = configuration;
_indexBuilderService = indexBuilderService; _indexBuilderService = indexBuilderService;
} }
@@ -39,8 +40,9 @@ public sealed class IndexController : ControllerBase
{ {
if (HttpContext.Request.Headers.CacheControl == "no-cache") if (HttpContext.Request.Headers.CacheControl == "no-cache")
{ {
_snapshotService.RebuildSnapshot(); _indexBuilderService.InvalidateIndex(this, EventArgs.Empty);
} }
var index = _indexBuilderService.Build(); var index = _indexBuilderService.Build();
return Ok(index); return Ok(index);
@@ -56,7 +58,7 @@ public sealed class IndexController : ControllerBase
/// </summary> /// </summary>
/// <param name="path">The relative file path requested.</param> /// <param name="path">The relative file path requested.</param>
[HttpGet("{*path}")] [HttpGet("{*path}")]
public IActionResult Download(string path) public async Task<IActionResult> Download(string path)
{ {
if (string.IsNullOrWhiteSpace(path)) if (string.IsNullOrWhiteSpace(path))
return BadRequest("Missing url query parameter."); return BadRequest("Missing url query parameter.");
@@ -64,7 +66,7 @@ public sealed class IndexController : ControllerBase
// ---- 1️⃣ Parse the brackets -------------------------------- // ---- 1️⃣ Parse the brackets --------------------------------
// Expected format: [name][TitleId][v][patchOrApp].nsp // Expected format: [name][TitleId][v][patchOrApp].nsp
var match = System.Text.RegularExpressions.Regex.Match(path, var match = System.Text.RegularExpressions.Regex.Match(path,
@"\[(?<name>.*?)\]\[(?<id>[0-9a-fA-F]{8}[0-9a-fA-F]{8})\]\[(?<v>[0-9a-fA-F]+)\]\[(?<app>Base|Update)\]\.nsp", @"(?<name>.*?)\[(?<id>[0-9a-fA-F]{8}[0-9a-fA-F]{8})\]\[v(?<v>[0-9]+)\]\[(?<app>Base|Update)\]\.nsp",
System.Text.RegularExpressions.RegexOptions.IgnoreCase); System.Text.RegularExpressions.RegexOptions.IgnoreCase);
if (!match.Success) if (!match.Success)
@@ -74,41 +76,52 @@ public sealed class IndexController : ControllerBase
// ---- 2️⃣ Find the file that contains this TitleId ------------ // ---- 2️⃣ Find the file that contains this TitleId ------------
var entry = _snapshotService.GetSnapshot().Files var entry = _snapshotService.GetSnapshot().Files
.FirstOrDefault(e => e.Title?.TitleId == titleId); .FirstOrDefault(e => { return e.Titles.FirstOrDefault(hash => hash.TitleId == titleId)?.TitleId == titleId; });
if (entry == null) if (entry == null)
return NotFound("No file with that TitleId found."); return NotFound("No file with that TitleId found.");
// ---- 3️⃣ If the file is a normal NSP → send it ---------------- // ---- 3️⃣ If the file is a normal NSP → send it ----------------
if (Path.GetExtension(entry.Path).Equals(".nsp", StringComparison.OrdinalIgnoreCase))
if (Path.GetExtension(entry.Path).Equals(".nsp", StringComparison.OrdinalIgnoreCase) && !entry.Path.Contains(_snapshotService.GetArchivePathSeparator()))
{ {
// Check if it is inside an archive. if (System.IO.File.Exists(entry.Path))
// If the path contains a slash that is not the root separator
// it might be an entry inside an archive; we simply stream it.
// - For normal files, we can use SendFileAsync.
// - For archives, we stream the entry using ArchiveHandler.
if (IsInsideArchive(entry.Path))
{
// Example: file is inside an archive use ArchiveHandler
var archivePath = Path.GetDirectoryName(entry.Path);
var innerFileName = Path.GetFileName(entry.Path);
var stream = StreamFromArchive(archivePath, innerFileName);
if (stream == null)
return NotFound("Could not stream entry from archive.");
return File(stream, "application/octet-stream",
Path.GetFileName(innerFileName));
}
else
{ {
// Regular file just serve it. // Regular file just serve it.
return PhysicalFile(entry.Path, "application/octet-stream", return PhysicalFile(entry.Path, "application/octet-stream",
Path.GetFileName(entry.Path)); Path.GetFileName(entry.Path));
} }
} }
// Check if it is inside an archive.
// If the path contains a slash that is not the root separator
// it might be an entry inside an archive; we simply stream it.
// - For normal files, we can use SendFileAsync.
// - For archives, we stream the entry using ArchiveHandler.
if (IsInsideArchive(entry.Path))
{
// Example: file is inside an archive use ArchiveHandler
var innerFileName = entry.Path.Split(_snapshotService.GetArchivePathSeparator()).Last();
var stream = StreamFromArchive(entry, titleId, out var streamContainer);
if (stream == null)
return NotFound("Could not stream entry from archive.");
var wrappedStream = new DependentStream(stream, streamContainer);
var contentDisposition = $"inline; filename=\"{innerFileName}\"";
Response.ContentType = "application/octet-stream";
Response.ContentLength = entry.Size;
Response.Headers["Content-Disposition"] = contentDisposition;
await using var entryStream = wrappedStream;
const int bufferSize = 10 * 1024 * 1024;// 81920; // 80 KB default used by CopyToAsync
await entryStream.CopyToAsync(Response.Body, bufferSize, HttpContext.RequestAborted);
// Once the copy completes, the `await using` disposes the archive.
return new EmptyResult(); // We already wrote the stream to Response.Body
}
return NotFound("Requested URL does not reference an NSP file."); return NotFound("Requested URL does not reference an NSP file.");
} }
@@ -116,22 +129,30 @@ public sealed class IndexController : ControllerBase
/// Very lightweight helper decides whether the file path /// Very lightweight helper decides whether the file path
/// represents a file inside an archive. /// represents a file inside an archive.
/// </summary> /// </summary>
private bool IsInsideArchive(string path) => private bool IsInsideArchive(string path)
{
// If the path contains a separator that is not a root separator // If the path contains a separator that is not a root separator
// (e.g. "Games/MyGame.nsp" is a regular file; "archive.7z/mygame.nsp" // (e.g. "Games/MyGame.nsp" is a regular file; "archive.7z/mygame.nsp"
// would be inside an archive). For simplicity we only check // would be inside an archive). For simplicity we only check
// for common archive extensions. // for common archive extensions.
path.EndsWith(".zip", StringComparison.OrdinalIgnoreCase) || var filePath = path.Split(_snapshotService.GetArchivePathSeparator()).First();
path.EndsWith(".7z", StringComparison.OrdinalIgnoreCase) || return filePath.EndsWith(".zip", StringComparison.OrdinalIgnoreCase) ||
path.EndsWith(".rar", StringComparison.OrdinalIgnoreCase); filePath.EndsWith(".7z", StringComparison.OrdinalIgnoreCase) ||
filePath.EndsWith(".rar", StringComparison.OrdinalIgnoreCase);
}
/// <summary> /// <summary>
/// If the NSP is inside an archive, this method opens the archive /// If the NSP is inside an archive, this method opens the archive
/// and returns the entry stream. It is deliberately minimal /// and returns the entry stream. It is deliberately minimal
/// if the archive cant be opened we return null. /// if the archive cant be opened we return null.
/// </summary> /// </summary>
private Stream? StreamFromArchive(string archivePath, string innerFileName) private Stream? StreamFromArchive(FileEntry fileEntry, string titleId, out IDisposable? streamContainer)
{ {
// Example: file is inside an archive use ArchiveHandler
var archivePath = fileEntry.Path.Split(_snapshotService.GetArchivePathSeparator()).First();
_snapshotService.GetArchiveName(titleId);
var innerFileName = Path.GetFileName(fileEntry.Path.Split(_snapshotService.GetArchivePathSeparator()).Last());
// Use SharpCompress to open the archive and find the entry. // Use SharpCompress to open the archive and find the entry.
// Only the 3 archive types we support are handled. // Only the 3 archive types we support are handled.
try try
@@ -139,28 +160,31 @@ public sealed class IndexController : ControllerBase
// Check which archive type // Check which archive type
if (archivePath.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)) if (archivePath.EndsWith(".zip", StringComparison.OrdinalIgnoreCase))
{ {
using var zip = SharpCompress.Archives.Zip.ZipArchive.Open(archivePath); var zip = SharpCompress.Archives.Zip.ZipArchive.Open(archivePath, new ReaderOptions { LeaveStreamOpen = true });
streamContainer = zip;
var entry = zip.Entries var entry = zip.Entries
.FirstOrDefault(e => e.Key.Equals(innerFileName, .FirstOrDefault(e => e.Key != null && e.Key.Equals(innerFileName,
StringComparison.OrdinalIgnoreCase)); StringComparison.OrdinalIgnoreCase));
if (entry != null) if (entry != null)
return entry.OpenEntryStream(); return entry.OpenEntryStream();
} }
else if (archivePath.EndsWith(".7z", StringComparison.OrdinalIgnoreCase)) else if (archivePath.EndsWith(".7z", StringComparison.OrdinalIgnoreCase))
{ {
using var sevenZip = SharpCompress.Archives.SevenZip.SevenZipArchive.Open(archivePath); var sevenZip = SharpCompress.Archives.SevenZip.SevenZipArchive.Open(archivePath, new ReaderOptions { LeaveStreamOpen = true });
streamContainer = sevenZip;
var entry = sevenZip.Entries var entry = sevenZip.Entries
.FirstOrDefault(e => e.Key.Equals(innerFileName, .FirstOrDefault(e => e.Key != null && e.Key.Equals(innerFileName,
StringComparison.OrdinalIgnoreCase)); StringComparison.OrdinalIgnoreCase));
if (entry != null) if (entry != null)
return entry.OpenEntryStream(); return entry.OpenEntryStream();
} }
else if (archivePath.EndsWith(".rar", StringComparison.OrdinalIgnoreCase)) else if (archivePath.EndsWith(".rar", StringComparison.OrdinalIgnoreCase))
{ {
using var rar = SharpCompress.Archives.Rar.RarArchive.Open(archivePath); var rar = SharpCompress.Archives.Rar.RarArchive.Open(archivePath, new ReaderOptions { LeaveStreamOpen = true });
streamContainer = rar;
var entry = rar.Entries var entry = rar.Entries
.FirstOrDefault(e => e.Key.Equals(innerFileName, .FirstOrDefault(e => e.Key != null && e.Key.Equals(innerFileName,
StringComparison.OrdinalIgnoreCase)); StringComparison.OrdinalIgnoreCase));
if (entry != null) if (entry != null)
return entry.OpenEntryStream(); return entry.OpenEntryStream();
} }
@@ -170,6 +194,42 @@ public sealed class IndexController : ControllerBase
// ignore we will just return null and the controller // ignore we will just return null and the controller
// will respond with 404. // will respond with 404.
} }
streamContainer = null;
return null; return null;
} }
public class DependentStream : Stream
{
private readonly Stream _innerStream;
private readonly IDisposable? _parentContainer;
public DependentStream(Stream innerStream, IDisposable? parentContainer)
{
_innerStream = innerStream;
_parentContainer = parentContainer;
}
public override void Flush() => _innerStream.Flush();
public override int Read(byte[] buffer, int offset, int count) => _innerStream.Read(buffer, offset, count);
public override long Seek(long offset, SeekOrigin origin) => _innerStream.Seek(offset, origin);
public override void SetLength(long value) => _innerStream.SetLength(value);
public override void Write(byte[] buffer, int offset, int count) => _innerStream.Write(buffer, offset, count);
public override bool CanRead => _innerStream.CanRead;
public override bool CanSeek => _innerStream.CanSeek;
public override bool CanWrite => _innerStream.CanWrite;
public override long Length => _innerStream.Length;
public override long Position { get => _innerStream.Position; set => _innerStream.Position = value; }
protected override void Dispose(bool disposing)
{
_parentContainer?.Dispose();
base.Dispose(disposing);
}
}
} }
@@ -17,6 +17,14 @@ public sealed class BasicAuthMiddleware
public async Task InvokeAsync(HttpContext context, IAuthStore store, ILogger<BasicAuthMiddleware> logger) public async Task InvokeAsync(HttpContext context, IAuthStore store, ILogger<BasicAuthMiddleware> logger)
{ {
// ------------- 1) Bypass auth for every path except “/” ----------------
// PathString is a struct compare its value directly.
if (!context.Request.Path.Equals("/", StringComparison.Ordinal))
{
await _next(context);
return;
}
logger.LogInformation("Incoming request from {IP} {Method} {Path}", context.Connection.RemoteIpAddress, context.Request.Method, context.Request.Path); logger.LogInformation("Incoming request from {IP} {Method} {Path}", context.Connection.RemoteIpAddress, context.Request.Method, context.Request.Path);
var ip = context.Connection.RemoteIpAddress?.ToString() ?? "unknown"; var ip = context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
+4 -4
View File
@@ -6,8 +6,8 @@ namespace TinfoilVibeServer.Models;
/// One line in the snapshot the JSON will be an array of these. /// One line in the snapshot the JSON will be an array of these.
/// </summary> /// </summary>
public sealed record FileEntry( public sealed record FileEntry(
string Path, string Path, // nsp or archive path
long Size, long Size, // size of nsp or full archive
string Hash, // SHA256 hex string Hash, // SHA256 hex of first NCA of first NCP in NSP or archive
NcaMetadataWithHash? Title // null unless file is an NSP/XCI or an archive containing one List<NcaMetadataWithHash> Titles // Details of all NSP Roms in the Path
); );
+6 -6
View File
@@ -17,16 +17,16 @@ public sealed class SnapshotOptions : INotifyPropertyChanged
} }
} }
} }
private List<string> _whitelistExtensions = new(); private List<string> _archiveExtensions = new();
public List<string> WhitelistExtensions public List<string> ArchiveExtensions
{ {
get => _whitelistExtensions; get => _archiveExtensions;
set set
{ {
if (_whitelistExtensions != value) if (_archiveExtensions != value)
{ {
_whitelistExtensions = value; _archiveExtensions = value;
OnPropertyChanged(nameof(_whitelistExtensions)); OnPropertyChanged(nameof(_archiveExtensions));
} }
} }
} }
+5 -1
View File
@@ -1,3 +1,4 @@
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using TinfoilVibeServer.Authentication; using TinfoilVibeServer.Authentication;
using TinfoilVibeServer.Middleware; using TinfoilVibeServer.Middleware;
@@ -24,11 +25,13 @@ builder.Services.AddSingleton<INSPExtractor, NSPExtractor>(sp =>
var keySet = KeySetHolder.KeySet; // already loaded by ConfigManager var keySet = KeySetHolder.KeySet; // already loaded by ConfigManager
return new NSPExtractor(keySet, logger); return new NSPExtractor(keySet, logger);
}); });
builder.Services.AddSingleton<ISnapshotService, SnapshotService>(); builder.Services.AddSingleton<SnapshotService>();
builder.Services.AddSingleton<ISnapshotService, SnapshotService>(sp => sp.GetRequiredService<SnapshotService>());
builder.Services.AddSingleton<IAuthStore, AuthStore>(); builder.Services.AddSingleton<IAuthStore, AuthStore>();
builder.Services.AddSingleton<TitleDatabaseService>(); builder.Services.AddSingleton<TitleDatabaseService>();
builder.Services.AddSingleton<IArchiveHandler, ArchiveHandler>(); builder.Services.AddSingleton<IArchiveHandler, ArchiveHandler>();
builder.Services.AddSingleton<IndexBuilderService>(); builder.Services.AddSingleton<IndexBuilderService>();
builder.Services.AddHostedService<SnapshotService>(provider => provider.GetRequiredService<SnapshotService>());
builder.Services.AddHostedService<TitleDatabaseService>(provider => provider.GetRequiredService<TitleDatabaseService>()).AddHttpClient(); builder.Services.AddHostedService<TitleDatabaseService>(provider => provider.GetRequiredService<TitleDatabaseService>()).AddHttpClient();
builder.Services.AddHostedService<IndexBuilderService>(provider => provider.GetRequiredService<IndexBuilderService>()); builder.Services.AddHostedService<IndexBuilderService>(provider => provider.GetRequiredService<IndexBuilderService>());
builder.Services.AddControllers(); // add MVC builder.Services.AddControllers(); // add MVC
@@ -47,6 +50,7 @@ app.MapControllers(); // routes the /index.json & /download endpoints
app.MapGet("/debug", () => new SnapshotService( app.MapGet("/debug", () => new SnapshotService(
app.Services.GetRequiredService<IMemoryCache>(),
app.Services.GetRequiredService<IOptionsMonitor<SnapshotOptions>>(), app.Services.GetRequiredService<IOptionsMonitor<SnapshotOptions>>(),
app.Services.GetRequiredService<INSPExtractor>(), app.Services.GetRequiredService<INSPExtractor>(),
app.Services.GetRequiredService<IArchiveHandler>(), app.Services.GetRequiredService<IArchiveHandler>(),
@@ -5,7 +5,7 @@
"commandName": "Project", "commandName": "Project",
"dotnetRunMessages": true, "dotnetRunMessages": true,
"launchBrowser": false, "launchBrowser": false,
"applicationUrl": "http://localhost:5253", "applicationUrl": "http://192.168.1.145:80",
"environmentVariables": { "environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development" "ASPNETCORE_ENVIRONMENT": "Development"
} }
+72 -26
View File
@@ -3,7 +3,9 @@ using SharpCompress.Archives;
using SharpCompress.Archives.Zip; using SharpCompress.Archives.Zip;
using SharpCompress.Archives.Rar; using SharpCompress.Archives.Rar;
using SharpCompress.Archives.SevenZip; using SharpCompress.Archives.SevenZip;
using SharpCompress.Common;
using TinfoilVibeServer.Models; using TinfoilVibeServer.Models;
using TinfoilVibeServer.Utilities;
using ZipArchive = SharpCompress.Archives.Zip.ZipArchive; using ZipArchive = SharpCompress.Archives.Zip.ZipArchive;
namespace TinfoilVibeServer.Services; namespace TinfoilVibeServer.Services;
@@ -13,7 +15,12 @@ public interface IArchiveHandler
/// <summary> /// <summary>
/// Return TitleInfo if an embedded Nintendo archive is found; otherwise null. /// Return TitleInfo if an embedded Nintendo archive is found; otherwise null.
/// </summary> /// </summary>
NcaMetadataWithHash? TryExtractTitleInfo(string filePath); IEnumerable<(string, long, NcaMetadataWithHash)> TryExtractTitleInfos(string filePath);
/// <summary>
/// Return TitleInfo if an embedded Nintendo archive is found; otherwise null.
/// </summary>
IEnumerable<(string, long, NcaMetadataWithHash)> TryExtractTitleInfos(Stream archiveStream, string archiveType);
} }
/// <summary> /// <summary>
@@ -35,7 +42,7 @@ public sealed class ArchiveHandler : IArchiveHandler
/// <summary> /// <summary>
/// Return TitleInfo if an embedded Nintendo archive is found; otherwise null. /// Return TitleInfo if an embedded Nintendo archive is found; otherwise null.
/// </summary> /// </summary>
public NcaMetadataWithHash? TryExtractTitleInfo(string filePath) public IEnumerable<(string, long, NcaMetadataWithHash)> TryExtractTitleInfos(string filePath)
{ {
_logger.LogInformation("Examining archive {File} for embedded NSP", filePath); _logger.LogInformation("Examining archive {File} for embedded NSP", filePath);
var ext = Path.GetExtension(filePath).ToLowerInvariant(); var ext = Path.GetExtension(filePath).ToLowerInvariant();
@@ -53,7 +60,7 @@ public sealed class ArchiveHandler : IArchiveHandler
default: default:
{ {
_logger.LogWarning("Unsupported archive type {Extension} skipping", ext); _logger.LogWarning("Unsupported archive type {Extension} skipping", ext);
return null; return [];
} }
} }
} }
@@ -61,28 +68,31 @@ public sealed class ArchiveHandler : IArchiveHandler
{ {
_logger.LogError("Error opening archive {File}: {Exception}", filePath, ex.Message); _logger.LogError("Error opening archive {File}: {Exception}", filePath, ex.Message);
// Graceful fallback return null // Graceful fallback return null
return null; return [];
} }
} }
private NcaMetadataWithHash? HandleZip(string path) public IEnumerable<(string, long, NcaMetadataWithHash)> TryExtractTitleInfos(Stream archiveStream, string archiveType)
{
throw new NotImplementedException();
}
private IEnumerable<(string, long, NcaMetadataWithHash)> HandleZip(string path)
{ {
using var archive = ZipArchive.Open(path); using var archive = ZipArchive.Open(path);
foreach (var entry in archive.Entries) foreach (var entry in archive.Entries)
{ {
if (!entry.IsDirectory && IsRomArchive(entry.Key)) if (entry.IsDirectory || entry.Key == null || !IsRomArchive(entry.Key)) continue;
{
var temp = Path.GetTempFileName(); var temp = Path.GetTempFileName();
entry.WriteToFile(temp); entry.WriteToFile(temp);
var title = _nspExtractor.ExtractFromFile(temp); // instance call var title = _nspExtractor.ExtractFromFile(temp); // instance call
File.Delete(temp); File.Delete(temp);
return title; if (title != null) yield return (entry.Key, entry.Size, title);
}
} }
return null;
} }
private NcaMetadataWithHash? Handle7z(string path) private IEnumerable<(string, long, NcaMetadataWithHash)> Handle7z(string path)
{ {
using var archive = SevenZipArchive.Open(path); using var archive = SevenZipArchive.Open(path);
foreach (var entry in archive.Entries) foreach (var entry in archive.Entries)
@@ -93,35 +103,71 @@ public sealed class ArchiveHandler : IArchiveHandler
entry.WriteToFile(temp); entry.WriteToFile(temp);
var title = _nspExtractor.ExtractFromFile(temp); // instance call var title = _nspExtractor.ExtractFromFile(temp); // instance call
File.Delete(temp); File.Delete(temp);
return title; if (title != null) yield return (entry.Key, entry.Size, title);
} }
} }
return null;
} }
private NcaMetadataWithHash? HandleRar(string path) private IEnumerable<(string, long, NcaMetadataWithHash)> HandleRar(string path)
{ {
var titles = new List<(string, long, NcaMetadataWithHash)>();
var entryCount = 0;
try try
{ {
using var archive = RarArchive.Open(path); using var archive = RarArchive.Open(path);
entryCount = archive.Entries.Count;
foreach (var entry in archive.Entries) foreach (var entry in archive.Entries)
{ {
if (!entry.IsDirectory && IsRomArchive(entry.Key)) if (entry.IsDirectory || entry.Key == null || !IsRomArchive(entry.Key)) continue;
{
try
{
using var streamWrapper = new SeekableBufferedStream(entry.OpenEntryStream(), entry.Size, 64 * 1024 * 1024, false);
var title = _nspExtractor.ExtractFromStream(streamWrapper);
if (title != null) titles.Add((entry.Key, entry.Size, title));
}
catch (Exception e)
{
if (e.Message.StartsWith("Failed to extract NSP"))
{
_logger.LogError("Failed to extract title info from archive {Archive}: {Exception}", path, e.Message);
}
else
{
throw;
}
}
}
}
}
catch (Exception exception)
{
if (titles.Count > 0 && titles.Count == entryCount)
{
// broken archive but managed to read some titles
_logger.LogInformation("Failed to fully process archive with SharpCompress, but found {Count} titles: {Exception}", titles.Count, exception.Message);
return titles;
}
// Fallback to SharpSevenZip (if needed)
_logger.LogInformation(
"Failed to open archive with SharpCompress, falling back to SharpSevenZip {Exception}",
exception.Message);
using var archive = SevenZipArchive.Open(path);
foreach (var entry in archive.Entries)
{
if (entry is { IsDirectory: false, Key: not null } && IsRomArchive(entry.Key))
{ {
var temp = Path.GetTempFileName(); var temp = Path.GetTempFileName();
entry.WriteToFile(temp); entry.WriteToFile(temp);
var title = _nspExtractor.ExtractFromFile(temp); // instance call var title = _nspExtractor.ExtractFromFile(temp); // instance call
File.Delete(temp); File.Delete(temp);
return title; if (title != null) titles.Add((entry.Key, entry.Size, title));
} }
} }
return null;
}
catch (SharpCompress.Common.ArchiveException)
{
// Fallback to SharpSevenZip (if needed)
return null;
} }
return titles;
} }
private bool IsRomArchive(string entryName) private bool IsRomArchive(string entryName)
@@ -4,6 +4,7 @@ using System.Globalization;
using System.Linq; using System.Linq;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text.Json; using System.Text.Json;
using System.Text.RegularExpressions;
using LibHac.Ncm; using LibHac.Ncm;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -23,6 +24,7 @@ public sealed class IndexBuilderService: IHostedService
private readonly ILogger<IndexBuilderService> _logger; private readonly ILogger<IndexBuilderService> _logger;
private readonly string _cachePath; private readonly string _cachePath;
private readonly SemaphoreSlim _lock = new(1, 1);
public IndexBuilderService( public IndexBuilderService(
ISnapshotService snapshotService, ISnapshotService snapshotService,
TitleDatabaseService titleDb, TitleDatabaseService titleDb,
@@ -41,9 +43,10 @@ public sealed class IndexBuilderService: IHostedService
// 1️⃣ Load cache if it exists // 1️⃣ Load cache if it exists
var cached = LoadCache(); var cached = LoadCache();
var snapshot = _snapshotService.GetSnapshot(); var snapshot = _snapshotService.GetSnapshot();
if (string.IsNullOrEmpty(snapshot.Hash))
if (string.IsNullOrEmpty(snapshot.Hash) || snapshot.Files.Count == 0)
{ {
_snapshotService.BuildSnapshot(); _snapshotService.BuildSnapshotAsync();
snapshot = _snapshotService.GetSnapshot(); snapshot = _snapshotService.GetSnapshot();
} }
@@ -57,33 +60,14 @@ public sealed class IndexBuilderService: IHostedService
_logger.LogInformation("Building index (snapshot size={Count})", snapshot.Files.Count); _logger.LogInformation("Building index (snapshot size={Count})", snapshot.Files.Count);
// 3️⃣ Build new index from snapshot entries // 3️⃣ Build new index from snapshot entries
var files = snapshot.Files var files = ParseSnapshotFiles(snapshot);
.Where(e => e.Title != null)
.Select(e =>
{
var titleId = e.Title.TitleId;
var name = _titleDb.TryGetTitle(titleId, out var t)
? t.Name
: "Unknown";
var vProcessed = e.Title.Version * 0x10000;
var patchOrApp = e.Title.ContentMetaType == ContentMetaType.Application ? "Base" : "Update";
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);
})
.ToList();
var directories = _configuration.GetSection("Directories") var directories = _configuration.GetSection("Directories")
.Get<string[]>() ?? Array.Empty<string>(); .Get<string[]>() ?? Array.Empty<string>();
var success = _configuration["SuccessMessage"] ?? string.Empty; var success = _configuration["SuccessMessage"] ?? string.Empty;
var index = new IndexDto(files, directories.ToList(), success); var index = new IndexDto(files.SelectMany(inner => inner).ToList(), directories.ToList(), success);
// 4️⃣ Persist cache // 4️⃣ Persist cache
PersistCache(snapshotHash, index); PersistCache(snapshotHash, index);
@@ -91,6 +75,57 @@ public sealed class IndexBuilderService: IHostedService
return index; return index;
} }
private List<List<FileDto>> ParseSnapshotFiles(SnapshotService.ROMSnapshot snapshot)
{
var files = snapshot.Files
.Where(e => e.Titles.Count > 0)
.Select(e =>
{
var fileDtos = new List<FileDto>();
foreach (var title in e.Titles)
{
var titleId = title.TitleId;
var name = _titleDb.TryGetTitle(titleId, out var t)
? t.Name
: "Unknown";
var versionNumberParsed = (title.ContentMetaType == ContentMetaType.Application) ? title.Version : title.Version * 0x10000;
var patchOrApp = title.ContentMetaType == ContentMetaType.Application ? "Base" : "Update";
if (title.ContentMetaType == ContentMetaType.Patch ||
title.ContentMetaType == ContentMetaType.AddOnContent)
{
// Patch should use the application title name
name = _titleDb.TryGetTitle(title.ApplicationTitle, out var appTitle)
? appTitle.Name
: "Unknown";
//_logger.LogInformation("Patch title {TitleId} uses application title {ApplicationTitle}", titleId, title.ApplicationTitle);
}
// if still unknown, its probably a demo, use filename extraction
if (name == "Unknown")
{
var match = Regex.Match(Path.GetFileNameWithoutExtension(e.Path), "^(.+?)\\s*(?:\\[.*)?$",
RegexOptions.None);
if (match.Success)
{
name = match.Groups[1].Value;
_logger.LogInformation("Name not found for {TitleId}, using filename: {Name}", titleId,
name);
}
}
var url = $"http://192.168.1.145/{name}[{titleId}][v{versionNumberParsed}][{patchOrApp}].nsp";
fileDtos.Add(new FileDto(url, e.Size));
}
return fileDtos;
})
.ToList();
return files;
}
private IndexCache? LoadCache() private IndexCache? LoadCache()
{ {
if (!File.Exists(_cachePath)) return null; if (!File.Exists(_cachePath)) return null;
@@ -120,11 +155,24 @@ public sealed class IndexBuilderService: IHostedService
public Task StartAsync(CancellationToken cancellationToken) public Task StartAsync(CancellationToken cancellationToken)
{ {
Build(); Build();
this._snapshotService.SnapshotRebuilt += InvalidateIndex;
return Task.CompletedTask; return Task.CompletedTask;
} }
public void InvalidateIndex(object? sender, EventArgs e)
{
if (!File.Exists(_cachePath)) return;
File.Delete(_cachePath);
_logger.LogInformation("Index cache cleared");
}
public Task StopAsync(CancellationToken cancellationToken) public Task StopAsync(CancellationToken cancellationToken)
=> Task.CompletedTask; // nothing special to do on shutdown {
_snapshotService.SnapshotRebuilt -= InvalidateIndex;
return Task.CompletedTask; // nothing special to do on shutdown
}
#endregion #endregion
} }
+110 -36
View File
@@ -1,10 +1,4 @@
// File: Services/NSPExtactor.cs using System.Security.Cryptography;
// *** UPDATED ***
using System;
using System.IO;
using System.Linq;
using System.Collections.Generic;
using System.Security.Cryptography;
using LibHac.Common; using LibHac.Common;
using LibHac.Fs; using LibHac.Fs;
using LibHac.Fs.Fsa; using LibHac.Fs.Fsa;
@@ -13,8 +7,8 @@ using LibHac.Tools.FsSystem;
using LibHac.Tools.FsSystem.NcaUtils; using LibHac.Tools.FsSystem.NcaUtils;
using LibHac.Common.Keys; using LibHac.Common.Keys;
using LibHac.Ncm; using LibHac.Ncm;
using LibHac.Tools.Fs;
using LibHac.Tools.Ncm; using LibHac.Tools.Ncm;
using TinfoilVibeServer.Models;
namespace TinfoilVibeServer.Services namespace TinfoilVibeServer.Services
{ {
@@ -61,22 +55,73 @@ namespace TinfoilVibeServer.Services
/// </summary> /// </summary>
public NcaMetadataWithHash? ExtractFromStream(Stream stream) public NcaMetadataWithHash? ExtractFromStream(Stream stream)
{ {
_logger.LogInformation("Extracting NSP from stream (length={Length}", stream.Length); if (!stream.CanSeek) return null;
if (!IsPfs0FileSystem(stream))
return null;
stream.Seek(0, SeekOrigin.Begin); stream.Seek(0, SeekOrigin.Begin);
_logger.LogInformation("Extracting NSP/XCI from stream (length={Length})", stream.Length);
using var storage = new StreamStorage(stream, false); using var storage = new StreamStorage(stream, false);
var partition = new PartitionFileSystem(); if (IsPfs0FileSystem(stream))
partition.Initialize(storage).ThrowIfFailure(); {
return ExtractNSPFromStream(storage);
}
if (IsXciFileSystem(stream))
{
var xci = new Xci(_keySet, storage);
List<DirectoryEntryEx> ncaEntries;
if (xci.HasPartition(XciPartitionType.Secure))
{
_logger.LogInformation("Processing as XCI");
var partition = xci.OpenPartition(XciPartitionType.Secure);
ncaEntries = partition
.EnumerateEntries("*.cnmt.nca", SearchOptions.RecurseSubdirectories)
.Where(e => e.Type == DirectoryEntryType.File)
.ToList();
byte[]? hash = null;
foreach (var dirEntry in ncaEntries)
{
using var fileRef = new UniqueRef<IFile>();
partition.OpenFile(ref fileRef.Ref, dirEntry.FullPath.ToU8Span(), OpenMode.Read).ThrowIfFailure();
using var ncaFile = fileRef.Release();
using var ncaFileStorage = new FileStorage(ncaFile);
var nca = new Nca(_keySet, ncaFileStorage);
if (hash == null)
{
// Hash the *first* NCA stream the stream we just opened
using var sha256 = SHA256.Create();
using var ncaStream = ncaFile.AsStream();
hash = sha256.ComputeHash(ncaStream);
}
if (nca.Header.ContentType != NcaContentType.Meta)
continue; // only the meta NCA contains title metadata
string titleId = nca.Header.TitleId.ToString("X16");
var (contentMetaType, applicationTitleId, titleVersion) = GetMetaData(nca);
_logger.LogInformation("Meta NCA found TitleId={TitleId} Version={Version}", titleId, titleVersion);
// XCI can never be a patch?
return new NcaMetadataWithHash(titleId, applicationTitleId.ToString("X16"), titleVersion.Major, ContentMetaType.Application, BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant());
}
}
}
return null; // no meta NCA found
}
private NcaMetadataWithHash? ExtractNSPFromStream(StreamStorage storage)
{
List<DirectoryEntryEx> ncaEntries;
_logger.LogInformation("Processing as NSP");
var partition = new PartitionFileSystem();
partition.Initialize(storage).ThrowIfFailure();
// Find the first *.nca that contains the meta header // Find the first *.nca that contains the meta header
var ncaEntries = partition ncaEntries = partition
.EnumerateEntries("*.nca", SearchOptions.RecurseSubdirectories) .EnumerateEntries("*.nca", SearchOptions.RecurseSubdirectories)
.Where(e => e.Type == DirectoryEntryType.File) .Where(e => e.Type == DirectoryEntryType.File)
.ToList(); .ToList();
byte[]? hash = null;
foreach (var dirEntry in ncaEntries) foreach (var dirEntry in ncaEntries)
{ {
using var fileRef = new UniqueRef<IFile>(); using var fileRef = new UniqueRef<IFile>();
@@ -85,30 +130,31 @@ namespace TinfoilVibeServer.Services
using var ncaFileStorage = new FileStorage(ncaFile); using var ncaFileStorage = new FileStorage(ncaFile);
var nca = new Nca(_keySet, ncaFileStorage); var nca = new Nca(_keySet, ncaFileStorage);
if (hash == null)
{
// Hash the *first* NCA stream the stream we just opened
using var sha256 = SHA256.Create();
using var ncaStream = ncaFile.AsStream();
hash = sha256.ComputeHash(ncaStream);
}
if (nca.Header.ContentType != NcaContentType.Meta) if (nca.Header.ContentType != NcaContentType.Meta)
continue; // only the meta NCA contains title metadata continue; // only the meta NCA contains title metadata
_logger.LogInformation("Meta NCA found TitleId={TitleId} Version={Version}", nca.Header.TitleId, nca.Header.Version); _logger.LogInformation("Meta NCA found TitleId={TitleId} Version={Version}", nca.Header.TitleId, nca.Header.Version);
string titleId = nca.Header.TitleId.ToString("X16"); string titleId = nca.Header.TitleId.ToString("X16");
int version = nca.Header.Version;
bool isPatch = nca.IsPatch; var (contentMetaType,applicationTitle,titleVersion) = GetMetaData(nca);
bool isApp = nca.IsProgram && !isPatch;
// Hash the *first* NCA stream the stream we just opened
using var ncaStream = ncaFile.AsStream();
using var sha256 = SHA256.Create();
var hash = sha256.ComputeHash(ncaStream);
var (contentMetaType,applicationTitle) = GetMetaDataType(nca);
if (contentMetaType != null) if (contentMetaType != null)
return new NcaMetadataWithHash(titleId, applicationTitle.ToString("X16"), version, contentMetaType.Value, BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant()); return new NcaMetadataWithHash(titleId, applicationTitle.ToString("X16"), titleVersion.Minor, contentMetaType.Value, BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant());
} }
return null; // no meta NCA found return null;
} }
private static (ContentMetaType?,ulong) GetMetaDataType(Nca nca)
private static (ContentMetaType?,ulong, TitleVersion) GetMetaData(Nca nca)
{ {
if (nca.Header.ContentType != NcaContentType.Meta) return (null,0); if (nca.Header.ContentType != NcaContentType.Meta) return (null,0, new TitleVersion(0, true));
using var openFileSystem = nca.OpenFileSystem(0, IntegrityCheckLevel.ErrorOnInvalid); using var openFileSystem = nca.OpenFileSystem(0, IntegrityCheckLevel.ErrorOnInvalid);
foreach (var entry in openFileSystem.EnumerateEntries("*.cnmt", SearchOptions.Default)) foreach (var entry in openFileSystem.EnumerateEntries("*.cnmt", SearchOptions.Default))
{ {
@@ -121,11 +167,12 @@ namespace TinfoilVibeServer.Services
var cnmt = new Cnmt(asStream); var cnmt = new Cnmt(asStream);
var applicationTitle = cnmt.ApplicationTitleId; var applicationTitle = cnmt.ApplicationTitleId;
return (cnmt.Type,applicationTitle); return (cnmt.Type,applicationTitle, cnmt.TitleVersion);
} }
return (null,0); return (null,0, new TitleVersion(0, true));
} }
/// <summary> /// <summary>
/// Quick sanity check that the stream looks like a PFS0 file system. /// Quick sanity check that the stream looks like a PFS0 file system.
/// </summary> /// </summary>
@@ -136,7 +183,7 @@ namespace TinfoilVibeServer.Services
if (!stream.CanSeek) return false; if (!stream.CanSeek) return false;
stream.Seek(0, SeekOrigin.Begin); stream.Seek(0, SeekOrigin.Begin);
var storage = new StreamStorage(stream, false); var storage = new StreamStorage(stream, true);
var partition = new PartitionFileSystem(); var partition = new PartitionFileSystem();
partition.Initialize(storage).ThrowIfFailure(); partition.Initialize(storage).ThrowIfFailure();
return true; return true;
@@ -147,6 +194,32 @@ namespace TinfoilVibeServer.Services
return false; return false;
} }
} }
private bool IsXciFileSystem(Stream stream)
{
try
{
if (!stream.CanSeek) return false;
stream.Seek(0, SeekOrigin.Begin);
var storage = new StreamStorage(stream, true);
try
{
var xciBlock = new Xci(_keySet, storage);
_logger.LogInformation("XCI found");
return xciBlock.HasPartition(XciPartitionType.Secure);
}
catch
{
// ignored
}
return false;
}
catch (Exception e)
{
_logger.LogError("Failed to extract XCI: {Exception}", e.Message);
return false;
}
}
public string ExtractHashFromStream(Stream nspStream) public string ExtractHashFromStream(Stream nspStream)
{ {
@@ -208,13 +281,14 @@ namespace TinfoilVibeServer.Services
public ContentMetaType ContentMetaType { get; set; } public ContentMetaType ContentMetaType { get; set; }
public string Hash { get; } public string Hash { get; }
public NcaMetadataWithHash(string titleId, string applicationTitle, int version, ContentMetaType contentMetaType, string hash) public NcaMetadataWithHash(string titleId, string applicationTitle, int version,
ContentMetaType contentMetaType, string hash)
{ {
TitleId = titleId; TitleId = titleId;
ApplicationTitle = applicationTitle; ApplicationTitle = applicationTitle;
Version = version; Version = version;
ContentMetaType = contentMetaType; ContentMetaType = contentMetaType;
Hash = hash; Hash = hash;
} }
} }
} }
+434 -118
View File
@@ -1,6 +1,7 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text.Json; using System.Text.Json;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using TinfoilVibeServer.Models; using TinfoilVibeServer.Models;
using TinfoilVibeServer.Utilities; using TinfoilVibeServer.Utilities;
@@ -11,55 +12,81 @@ public interface ISnapshotService
event EventHandler SnapshotRebuilt; // raised after a rebuild event EventHandler SnapshotRebuilt; // raised after a rebuild
void RebuildSnapshot(); void RebuildSnapshot();
SnapshotService.ROMSnapshot GetSnapshot(); SnapshotService.ROMSnapshot GetSnapshot();
void BuildSnapshot();
Task AddToSnapshotAsync(FileEntry entry);
Task BuildSnapshotAsync();
void GetArchiveName(string titleId);
char GetArchivePathSeparator();
} }
/// <summary> /// <summary>
/// Keeps an inmemory snapshot, watches the filesystem for changes, and /// Keeps an inmemory snapshot, watches the filesystem for changes, and
/// only reprocesses a file if its hash changed. /// only reprocesses a file if its hash changed.
/// </summary> /// </summary>
public sealed class SnapshotService : IDisposable, ISnapshotService public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedService
{ {
#region FileSystemWatcher
private readonly List<FileSystemWatcher> _watchers = new();
#endregion
private readonly SnapshotOptions _options; private readonly SnapshotOptions _options;
private readonly INSPExtractor _nspExtractor; private readonly INSPExtractor _nspExtractor;
private readonly IArchiveHandler _archiveHandler; private readonly IArchiveHandler _archiveHandler;
private readonly ILogger<SnapshotService> _logger; private readonly ILogger<SnapshotService> _logger;
private readonly string _jsonPath; private readonly string _jsonPath;
private readonly string _snapshotPath; private readonly string _snapshotPath;
private readonly List<FileSystemWatcher> _watchers = new(); private readonly ConcurrentDictionary<string, SnapshotEntry> _cache = new();
private readonly ConcurrentDictionary<string, CachedFile> _cache = new(); private readonly ConcurrentDictionary<string, string> _hashCache = new();
private string? _currentSnapshotHash; // Archive full path -> FileEntry.Path
private readonly Timer _debounceTimer; private readonly ConcurrentDictionary<string, string> _archiveLookup = new();
// hash -> file size
private readonly ConcurrentDictionary<string, long> _sizeLookup = new();
private readonly IMemoryCache _debouncerCache;
public event EventHandler? SnapshotRebuilt; public event EventHandler? SnapshotRebuilt;
public event EventHandler? SnapshotRebuilding;
private readonly SemaphoreSlim _snapshotFileSemaphore = new(1,1);
private const char ArchivePathSeparator = '|';
public char GetArchivePathSeparator() => ArchivePathSeparator;
public SnapshotService( public SnapshotService(
IMemoryCache debouncerCache,
IOptionsMonitor<SnapshotOptions> options, IOptionsMonitor<SnapshotOptions> options,
INSPExtractor nspExtractor, INSPExtractor nspExtractor,
IArchiveHandler archiveHandler, IArchiveHandler archiveHandler,
ILogger<SnapshotService> logger) ILogger<SnapshotService> logger)
{ {
_options = options.CurrentValue; _options = options.CurrentValue;
_debouncerCache = debouncerCache;
_nspExtractor = nspExtractor; _nspExtractor = nspExtractor;
_archiveHandler = archiveHandler; _archiveHandler = archiveHandler;
_logger = logger; _logger = logger;
_jsonPath = Path.Combine(AppContext.BaseDirectory, _options.SnapshotFile); _jsonPath = Path.Combine(AppContext.BaseDirectory, _options.SnapshotFile);
FileSystemExtensions.EnsureDirectoryExists(Path.GetDirectoryName(_jsonPath));
// Debounce timer for persisting snapshot
long debounceTime = 200;
var entryOptions = new MemoryCacheEntryOptions()
.SetSlidingExpiration(TimeSpan.FromSeconds(debounceTime)).RegisterPostEvictionCallback((key, value, reason,
state) =>
{
_logger.LogInformation("Should persist the snapshot {Key}, {Reason}", key, reason);
}); // <‑‑ sliding!
FileSystemExtensions.EnsureDirectoryExists(Path.GetFullPath(Path.GetDirectoryName(_jsonPath)));
if (!File.Exists(_jsonPath)) if (!File.Exists(_jsonPath))
{ {
_snapshotFileSemaphore.Wait();
File.WriteAllText(_jsonPath, "[]"); File.WriteAllText(_jsonPath, "[]");
_snapshotFileSemaphore.Release();
} }
_snapshotPath = Path.Combine(AppContext.BaseDirectory, _options.SnapshotBackupFile); _snapshotPath = Path.Combine(AppContext.BaseDirectory, _options.SnapshotBackupFile);
FileSystemExtensions.EnsureDirectoryExists(Path.GetDirectoryName(_snapshotPath)); FileSystemExtensions.EnsureDirectoryExists(Path.GetFullPath(Path.GetDirectoryName(_snapshotPath)));
// 1️⃣ Register for *property* changes // 1️⃣ Register for *property* changes
_options.PropertyChanged += (s, e) => OnOptionsChanged(e.PropertyName); _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 _options.RootDirectories) foreach (var path in _options.RootDirectories)
{ {
InitializeFileSystemWatcher(path); AddWatchDirectory(path);
} }
} }
// --------- Private helpers --------- // --------- Private helpers ---------
@@ -81,15 +108,18 @@ public sealed class SnapshotService : IDisposable, ISnapshotService
foreach (var newWatchedDirectory in newWatchedDirectories) foreach (var newWatchedDirectory in newWatchedDirectories)
{ {
InitializeFileSystemWatcher(newWatchedDirectory); AddWatchDirectory(newWatchedDirectory);
} }
BuildSnapshot(); // rebuild everything BuildSnapshotAsync(); // rebuild everything
PersistSnapshot(); PersistSnapshotAsync();
} }
} }
private void InitializeFileSystemWatcher(string path)
#region FileSystemWatcher
private void AddWatchDirectory(string path)
{ {
if (!Directory.Exists(path)) return; if (!Directory.Exists(path)) return;
var watcher = new FileSystemWatcher var watcher = new FileSystemWatcher
@@ -104,32 +134,84 @@ public sealed class SnapshotService : IDisposable, ISnapshotService
watcher.Deleted += OnChanged; watcher.Deleted += OnChanged;
watcher.Renamed += OnRenamed; watcher.Renamed += OnRenamed;
watcher.EnableRaisingEvents = true; watcher.EnableRaisingEvents = true;
_logger.LogInformation("Watching {Path}", path);
_watchers.Add(watcher); _watchers.Add(watcher);
} }
#region FileSystemWatcher private void RemoveWatchDirectory(string path)
{
var fileSystemWatchers = _watchers.FirstOrDefault(watcher => watcher.Path == path);
if (fileSystemWatchers == null) return;
fileSystemWatchers.EnableRaisingEvents = false;
fileSystemWatchers.Dispose();
_logger.LogInformation("Stopped watching {Path}", path);
_watchers.Remove(fileSystemWatchers);
}
private void OnChanged(object? _, FileSystemEventArgs e) => ThrottleSnapshotUpdate(e); private void OnChanged(object? _, FileSystemEventArgs e) => ThrottleSnapshotUpdate(e);
private void OnRenamed(object? _, RenamedEventArgs e) => ThrottleSnapshotUpdate(e); private void OnRenamed(object? _, RenamedEventArgs e) => ThrottleSnapshotUpdate(e);
private void ThrottleSnapshotUpdate(FileSystemEventArgs fileSystemEventArgs) private void ThrottleSnapshotUpdate(FileSystemEventArgs fileSystemEventArgs)
{ {
lock (_lock) SnapshotRebuilding?.Invoke(this, fileSystemEventArgs);
{ using var cacheEntry = _debouncerCache.CreateEntry(fileSystemEventArgs.FullPath)
_debounceTimer.Change(_debounceMs, Timeout.Infinite); // reset the timer //.SetAbsoluteExpiration(TimeSpan.FromMilliseconds(DebounceMs))
_logger.LogDebug("File system event {EventType} on {Path} at {Time}", fileSystemEventArgs.ChangeType, fileSystemEventArgs.FullPath, DateTime.Now.ToString("HH:mm:ss")); .SetValue(fileSystemEventArgs)
} .SetOptions(new MemoryCacheEntryOptions
/*Task.Run(async () => {
{ PostEvictionCallbacks =
await Task.Delay(250); {
_logger.LogDebug("File system event {EventType} on {Path}", fileSystemEventArgs.ChangeType, fileSystemEventArgs.FullPath); new PostEvictionCallbackRegistration
UpdateSnapshot(); {
});*/ EvictionCallback =
(key, value, reason, state) =>
{
if (reason != EvictionReason.Expired) return;
if (value is FileSystemEventArgs args)
{
if (IsFileLocked(args.FullPath))
{
_logger.LogInformation("File {FilePath} is locked, skipping snapshot update", args.FullPath);
using var rebounce = _debouncerCache.CreateEntry(args.FullPath)
.SetAbsoluteExpiration(TimeSpan.FromMilliseconds(DebounceMs))
.SetValue(args);
}
}
RebuildSnapshot();
}
}
}
});
cacheEntry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMilliseconds(DebounceMs);
_logger.LogDebug("File system event {EventType} on {Path} at {Time}", fileSystemEventArgs.ChangeType,
fileSystemEventArgs.FullPath, DateTime.Now.ToString("HH:mm:ss"));
} }
private static bool IsFileLocked(string filePath)
private readonly object _lock = new object(); {
private int _debounceMs = 200; FileStream? stream = null;
var file = new FileInfo(filePath);
try
{
stream = file.Open(FileMode.Open, FileAccess.ReadWrite, FileShare.None);
}
catch (IOException)
{
return true;
}
finally
{
stream?.Close();
}
return false;
}
private const int DebounceMs = 400;
private readonly JsonSerializerOptions _jsonSerializerOptions = new() { IncludeFields = true };
private int SnapshotFileLockTimeout { get; } = 1000;
private void DebounceElapsed() private void DebounceElapsed()
{ {
@@ -140,15 +222,66 @@ public sealed class SnapshotService : IDisposable, ISnapshotService
#region Snapshot logic #region Snapshot logic
public void BuildSnapshot() public Task AddToSnapshotAsync(FileEntry entry)
{ {
// Update lookup tables
_cache[entry.Path] = new SnapshotEntry(entry.Path, entry.Hash, entry.Size, entry.Titles);
_hashCache[entry.Hash] = entry.Path;
_sizeLookup[entry.Hash] = entry.Size;
if (entry.Path.Contains(ArchivePathSeparator))
{
var filename = entry.Path.Split(ArchivePathSeparator)[0];
_archiveLookup[filename] = entry.Path;
}
foreach (var ncaMetadataWithHash in entry.Titles)
{
_hashCache[ncaMetadataWithHash.Hash] = entry.Path;
_sizeLookup[ncaMetadataWithHash.Hash] = entry.Size;
_logger.LogInformation("Added entry {titleId} to snapshot (hash={hash})", ncaMetadataWithHash.TitleId, ncaMetadataWithHash.Hash);
}
// Persist snapshot to disk
PersistSnapshotAsync();
return Task.CompletedTask;
}
/// Builds _cache and _hashCache based on directory configuration
public Task BuildSnapshotAsync()
{
_logger.LogInformation("Building snapshot");
var index = LoadSnapshotIndex(); var index = LoadSnapshotIndex();
var latestModifiedUtcParallel = FileSystemExtensions.GetLatestModifiedUtcParallel(_options.RootDirectories); var latestModifiedUtcParallel = FileSystemExtensions.GetLatestModifiedUtcParallel(_options.RootDirectories);
var fileInfo = new FileInfo(_snapshotPath); var fileInfo = new FileInfo(_snapshotPath);
bool snapshotVerified = true;
if (latestModifiedUtcParallel.HasValue && latestModifiedUtcParallel.Value < fileInfo.LastWriteTimeUtc) if (latestModifiedUtcParallel.HasValue && latestModifiedUtcParallel.Value < fileInfo.LastWriteTimeUtc)
{ {
_logger.LogInformation("Snapshot is up to date"); if (index.Count != 0)
return; {
// directory may have been added with older roms, verify that the snapshot is still up to date
foreach (var dir in _options.RootDirectories)
{
// check first entry is in index
var entry = BuildSnapshot(dir).FirstOrDefault();
if (entry != null)
{
if (!index.TryGetValue(entry.Path, out var cached))
{
snapshotVerified = false;
_logger.LogInformation("Snapshot does not contain first entry in directory {Directory}", dir);
}
}
}
if (snapshotVerified)
{
_logger.LogInformation("Snapshot is up to date");
return Task.CompletedTask;
}
}
else
{
_logger.LogInformation("Snapshot is up to date but index is empty");
}
} }
_logger.LogInformation("Rebuilding snapshot (root dirs: {Count})", _options.RootDirectories.Count); _logger.LogInformation("Rebuilding snapshot (root dirs: {Count})", _options.RootDirectories.Count);
var entries = new List<FileEntry>(); var entries = new List<FileEntry>();
@@ -156,65 +289,122 @@ public sealed class SnapshotService : IDisposable, ISnapshotService
var snapshotChanged = false; var snapshotChanged = false;
foreach (var dir in _options.RootDirectories) foreach (var dir in _options.RootDirectories)
{ {
if (!Directory.Exists(dir)) continue; _ = Task.Run(() =>
foreach (var file in Directory.EnumerateFiles(dir, "*", SearchOption.AllDirectories))
{ {
var ext = Path.GetExtension(file).ToLowerInvariant(); _logger.LogInformation("Rebuilding directory {Directory}", dir);
var buildSnapshot = BuildSnapshot(dir);
if (!(_options.WhitelistExtensions.Contains(ext) || _options.RomExtensions.Contains(ext))) var fileEntries = buildSnapshot.ToList();
continue; snapshotChanged = snapshotChanged || fileEntries.Count != 0;
entries.AddRange(fileEntries.Where(entry => entry != null)!);
if (index.TryGetValue(file, out var value)) });
{
entries.Add(value);
continue;
}
// 3) extract title if applicable
string hash;
NcaMetadataWithHash? title = null;
if (_options.RomExtensions.Contains(ext))
{
using var nspStream = File.OpenRead(file);
hash = ComputeFirstStreamHash(nspStream);
// 2) use cached title if unchanged
if (index.TryGetValue(file, out var cached) && cached.Hash == hash)
{
entries.Add(cached);
continue;
}
title = _nspExtractor.ExtractFromStream(nspStream);
}
else
{
hash = ComputeFirstStreamHash(file);
title = _archiveHandler.TryExtractTitleInfo(file);
}
if (title == null)
{
_logger.LogInformation("Failed to process {File}", file);
}
// 4) update cache
_cache[file] = new CachedFile(file, hash, title);
// 5) add to snapshot
entries.Add(new FileEntry(file, new FileInfo(file).Length, hash, title));
_logger.LogInformation("Added {File} to snapshot (hash={Hash}", file, hash);
snapshotChanged = true;
}
} }
// Replace the entire snapshot // Replace the entire snapshot
_currentSnapshotHash = ComputeSnapshotHash(entries); ComputeSnapshotHash(entries);
File.WriteAllText(_jsonPath, JsonSerializer.Serialize(entries));
if (snapshotChanged) if (snapshotChanged)
{ {
_logger.LogInformation("Snapshot rebuilt"); _logger.LogInformation("Snapshot rebuilt");
SnapshotRebuilt?.Invoke(this, EventArgs.Empty); SnapshotRebuilt?.Invoke(this, EventArgs.Empty);
} }
return Task.CompletedTask;
}
public void GetArchiveName(string titleId)
{
;
}
// Returns List of FileEntry that do not have a hash in the cache
// Each entry that has not been added to the lookup table is added to the cache
private IEnumerable<FileEntry?> BuildSnapshot(string dir)
{
FileEntry entry;
if (!Directory.Exists(dir)) yield break;
foreach (var file in Directory.EnumerateFiles(dir, "*", SearchOption.AllDirectories))
{
string hash = string.Empty;
var ext = Path.GetExtension(file).ToLowerInvariant();
if (!(_options.ArchiveExtensions.Contains(ext) || _options.RomExtensions.Contains(ext)))
continue;
if (_cache.ContainsKey(file) || _hashCache.ContainsKey(hash))
{
continue;
}
// 3) extract title if applicable
var titles = new List<(string, long, NcaMetadataWithHash)>();
if (_options.RomExtensions.Contains(ext))
{
using var nspStream = File.OpenRead(file);
hash = ComputeFirstStreamHash(nspStream);
if (_hashCache.ContainsKey(hash))
{
continue;
}
var nspStreamLength = nspStream.Length;
var title = _nspExtractor.ExtractFromStream(nspStream);
if (title != null)
{
var archiveEntry = new FileEntry(file, nspStreamLength, hash, [title]);
AddToSnapshotAsync(archiveEntry);
titles.Add((title.TitleId, nspStreamLength, title));
yield return archiveEntry;
}
}
else
{
if (_options.ArchiveExtensions.Contains(ext))
{
if (_archiveLookup.ContainsKey(file)) continue;
hash = ComputeFirstStreamHash(file);
if (_hashCache.ContainsKey(hash))
{
yield return null;
}
IEnumerable<(string, long, NcaMetadataWithHash)>? titlesEnumerable = null;
try
{
titlesEnumerable = _archiveHandler.TryExtractTitleInfos(file);
}
catch (Exception e)
{
_logger.LogError(e, "Failed to extract title info from archive {Archive}", file);
}
if (titlesEnumerable == null) continue;
titles = titlesEnumerable.ToList();
foreach (var title in titles)
{
var archiveEntry = new FileEntry(file + ArchivePathSeparator + title.Item1, title.Item2, title.Item3.Hash, [title.Item3]);
AddToSnapshotAsync(archiveEntry);
yield return archiveEntry;
}
/*var fileEntry = new FileEntry(file, new FileInfo(file).Length, hash, titles.Select((tuple, i) => tuple.Item3).ToList());
AddToSnapshotAsync(fileEntry);
yield return fileEntry;*/
}
else
{
continue;
}
}
if (titles.Count == 0)
{
_logger.LogInformation("Failed to process {File}", file);
}
else
{
_logger.LogInformation("Added {File} to snapshot (hash={Hash})", file, hash);
yield return new FileEntry(file, titles.Select((tuple, i) => tuple.Item2).FirstOrDefault(), hash, titles.Select((tuple, i) => tuple.Item3).ToList());
}
}
} }
private string ComputeFirstStreamHash(Stream nspStream) private string ComputeFirstStreamHash(Stream nspStream)
@@ -222,20 +412,65 @@ public sealed class SnapshotService : IDisposable, ISnapshotService
return _nspExtractor.ExtractHashFromStream(nspStream); return _nspExtractor.ExtractHashFromStream(nspStream);
} }
private void UpdateSnapshot() => BuildSnapshot(); private void UpdateSnapshot() => BuildSnapshotAsync();
private void PersistSnapshot() IEnumerable<FileEntry> GetEntries()
{ {
var snapshot = GetSnapshot(); foreach (var snapshotEntry in _cache)
var newHash = ComputeSnapshotHash(snapshot.Files);
if (_currentSnapshotHash != newHash)
{ {
_logger.LogInformation("Snapshot hash changed persisting new snapshot"); _sizeLookup.TryGetValue(snapshotEntry.Value.Hash, out var size);
_currentSnapshotHash = newHash; var fileEntry = new FileEntry(snapshotEntry.Key, snapshotEntry.Value.Size, snapshotEntry.Value.Hash, snapshotEntry.Value.NcaMetadataWithHash);
File.WriteAllText(_jsonPath, JsonSerializer.Serialize(snapshot.Files)); yield return fileEntry;
File.WriteAllText(_snapshotPath, JsonSerializer.Serialize(snapshot.Files));
} }
} }
private Task PersistSnapshotAsync()
{
if (_debouncerCache.TryGetValue(_jsonPath, out var value))
{
_logger.LogInformation("Sliding debounce in progress, skipping snapshot persistence");
return Task.CompletedTask;
}
var snapshot = GetSnapshot();
var entries = GetEntries();
var fileEntries = entries.ToList();
var newHash = ComputeSnapshotHash(fileEntries);
if (snapshot.Hash == newHash) return Task.CompletedTask;
_logger.LogInformation("Snapshot hash changed persisting new snapshot");
using var debouncedPersistence = _debouncerCache.CreateEntry(_jsonPath);
debouncedPersistence.SlidingExpiration = TimeSpan.FromMilliseconds(DebounceMs);
debouncedPersistence.Value = fileEntries;
debouncedPersistence.PostEvictionCallbacks.Add(new PostEvictionCallbackRegistration
{
EvictionCallback = (key, entriesCallback, reason, state) =>
{
if (entriesCallback is IEnumerable<FileEntry> entriesToPersist && key is string filePath)
{
if (_snapshotFileSemaphore.Wait(SnapshotFileLockTimeout))
{
if (IsFileLocked(filePath))
{
_logger.LogInformation("File {FilePath} is locked, skipping snapshot persistence", filePath);
}
else
{
File.WriteAllText(filePath,
JsonSerializer.Serialize(entriesToPersist, _jsonSerializerOptions));
_snapshotFileSemaphore.Release();
_logger.LogInformation("Persisted snapshot");
SnapshotRebuilt?.Invoke(this, EventArgs.Empty);
}
}
else
{
_logger.LogInformation("Failed to persist file {FilePath} due to timeout", filePath);
}
}
}
});
return Task.CompletedTask;
}
private static string ComputeHash(string filePath) private static string ComputeHash(string filePath)
{ {
@@ -252,38 +487,103 @@ public sealed class SnapshotService : IDisposable, ISnapshotService
var hash = sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(json)); var hash = sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(json));
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant(); return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
} }
/// <summary>
/// From filesystem cache, load each entry and build the lookups
/// </summary>
/// <returns></returns>
private Dictionary<string, FileEntry> LoadSnapshotIndex() private Dictionary<string, FileEntry> LoadSnapshotIndex()
{ {
if (!File.Exists(_jsonPath)) return new(); if (!File.Exists(_jsonPath)) return new Dictionary<string, FileEntry>();
_snapshotFileSemaphore.Wait();
var json = File.ReadAllText(_jsonPath); var json = File.ReadAllText(_jsonPath);
var entries = JsonSerializer.Deserialize<List<FileEntry>>(json, new JsonSerializerOptions(){IncludeFields = true})!; _snapshotFileSemaphore.Release();
return entries.ToDictionary(e => e.Path, e => e); var entries = JsonSerializer.Deserialize<List<FileEntry>>(json, _jsonSerializerOptions)!;
try
{
var fileEntries = new Dictionary<string, FileEntry>();
// Reindex the cache
foreach (var fileEntry in entries)
{
if (_hashCache.TryGetValue(fileEntry.Hash, out var value))
{
_logger.LogWarning("Duplicate hash found in snapshot: {Hash}, {OldPath}, {newPath}", fileEntry.Hash, value, fileEntry.Path);
}
if (_options.RomExtensions.Contains(Path.GetExtension(fileEntry.Path)))
{
if (fileEntry.Path.Contains(ArchivePathSeparator))
{
var filename = fileEntry.Path.Split(ArchivePathSeparator)[0];
_cache[fileEntry.Path] = new SnapshotEntry(fileEntry.Path, fileEntry.Hash, fileEntry.Size, fileEntry.Titles);
_archiveLookup[filename] = fileEntry.Path;
}
else
{
_cache[fileEntry.Path] = new SnapshotEntry(fileEntry.Path, fileEntry.Hash, fileEntry.Size, fileEntry.Titles);
fileEntries.TryAdd(fileEntry.Path, fileEntry);
_hashCache[fileEntry.Hash] = fileEntry.Path;
// ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract
if (fileEntry.Titles == null) continue;
foreach (var ncaMetadataWithHash in fileEntry.Titles)
{
_hashCache[ncaMetadataWithHash.Hash] = fileEntry.Path;
}
}
}
}
return fileEntries;
}
catch (ArgumentException e)
{
_logger.LogError(e, "Failed to load snapshot");
return new();
}
}
public void RebuildSnapshot()
{
// Build a fresh snapshot and persist it.
BuildSnapshotAsync(); // private method inside the same class
PersistSnapshotAsync(); // private method inside the same class
SnapshotRebuilt?.Invoke(this, EventArgs.Empty);
} }
#endregion #endregion
public ROMSnapshot GetSnapshot() public ROMSnapshot GetSnapshot()
{ {
if (!File.Exists(_jsonPath)) return new(); if (!File.Exists(_jsonPath)) return new ROMSnapshot();
var json = File.ReadAllText(_jsonPath);
var hash = ComputeHash(_jsonPath); if (_snapshotFileSemaphore.Wait(SnapshotFileLockTimeout))
var romSnapshot = new ROMSnapshot()
{ {
Hash = hash, try
Files = JsonSerializer.Deserialize<IReadOnlyList<FileEntry>>(json, {
new JsonSerializerOptions() { IncludeFields = true })! var json = File.ReadAllText(_jsonPath);
}; var hash = ComputeHash(_jsonPath);
return romSnapshot; var romSnapshot = new ROMSnapshot
} {
Hash = hash,
Files = JsonSerializer.Deserialize<IReadOnlyList<FileEntry>>(json, _jsonSerializerOptions)!
};
return romSnapshot;
}
catch (Exception e)
{
_logger.LogError(e, "Failed to load snapshot");
}
finally
{
_snapshotFileSemaphore.Release();
}
}
else
{
_logger.LogWarning("Failed to load snapshot due to timeout");
}
public void RebuildSnapshot() return new ROMSnapshot();
{
// Build a fresh snapshot and persist it.
BuildSnapshot(); // private method inside the same class
PersistSnapshot(); // private method inside the same class
SnapshotRebuilt?.Invoke(this, EventArgs.Empty);
} }
public void Dispose() public void Dispose()
{ {
foreach (var watcher in _watchers) foreach (var watcher in _watchers)
@@ -292,7 +592,7 @@ public sealed class SnapshotService : IDisposable, ISnapshotService
} }
} }
private sealed record CachedFile(string Path, string Hash, NcaMetadataWithHash? NcaMetadataWithHash); private sealed record SnapshotEntry(string Path, string Hash, long Size, List<NcaMetadataWithHash> NcaMetadataWithHash);
// File: TinfoilVibeServer/Services/SnapshotService.cs (inside SnapshotService class) // File: TinfoilVibeServer/Services/SnapshotService.cs (inside SnapshotService class)
@@ -342,4 +642,20 @@ public sealed class SnapshotService : IDisposable, ISnapshotService
public string Hash { get; set; } public string Hash { get; set; }
public IReadOnlyList<FileEntry> Files { get; set; } = new List<FileEntry>(); public IReadOnlyList<FileEntry> Files { get; set; } = new List<FileEntry>();
} }
public async Task StartAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Starting snapshot service");
_ = Task.Run(async () =>
{
await BuildSnapshotAsync();
await PersistSnapshotAsync();
}, cancellationToken); // initial scan
new Timer(_ => DebounceElapsed(), null, Timeout.Infinite, Timeout.Infinite);
}
public async Task StopAsync(CancellationToken cancellationToken)
{
Dispose();
}
} }
@@ -32,18 +32,9 @@ public sealed class TitleDatabaseService : IHostedService
private readonly ISnapshotService _snapshotService; private readonly ISnapshotService _snapshotService;
private readonly Dictionary<string,string> _titleIdToPath = new Dictionary<string, string>(); private readonly Dictionary<string,string> _titleIdToPath = new Dictionary<string, string>();
// 1️⃣ Cache for the JSON data (key = TitleId)
/*
private readonly ConcurrentDictionary<string, TitleInfoDto> _titleData
= new();
// 2️⃣ Reverse lookup: TitleId → real filesystem path
private readonly ConcurrentDictionary<string, string> _titleIdToPath
= new();
*/
// Regex to find a 16digit hex TitleId in a filename // Regex to find a 16digit hex TitleId in a filename
private static readonly Regex _titleIdRegex = new( private static readonly Regex TitleIdRegex = new(
@"([0-9a-fA-F]{8})([0-9a-fA-F]{8})", RegexOptions.Compiled); @"([0-9a-fA-F]{8})([0-9a-fA-F]{8})", RegexOptions.Compiled);
#endregion #endregion
@@ -80,7 +71,15 @@ public sealed class TitleDatabaseService : IHostedService
Path.Combine(AppContext.BaseDirectory, "Games") Path.Combine(AppContext.BaseDirectory, "Games")
}; };
// Reload cache immediately when a snapshot rebuild occurs // Reload cache immediately when a snapshot rebuild occurs
_snapshotService.SnapshotRebuilt += (_, _) => ReloadCacheAsync(); _snapshotService.SnapshotRebuilt += SnapshotServiceOnSnapshotRebuilt;
_options.OnChange(OptionsChanged);
}
private void OptionsChanged(TitleDbOptions arg1, string? arg2)
{
// todo: handle ttl changes
// todo: handle country code change
// todo: handle language change
} }
#endregion #endregion
@@ -119,9 +118,9 @@ public sealed class TitleDatabaseService : IHostedService
var dict = await LoadFromDiskAsync() ?? new Dictionary<string, TitleInfoDto>(); var dict = await LoadFromDiskAsync() ?? new Dictionary<string, TitleInfoDto>();
// Set the new entry the sliding expiration will now // Set the new entry the sliding expiration will now
// automatically move 30s (or whatever you configured) forward // automatically move 30 s (or whatever you configured) forward
// every time the entry is accessed via Get/Set. // every time the entry is accessed via Get/Set.
var titleInfoDtos = _cache.Set(CacheKey, dict, entryOptions); _cache.Set(CacheKey, dict, entryOptions);
_logger.LogInformation("Title DB reloaded {Count} items cached (TTL={TTL}s).", _logger.LogInformation("Title DB reloaded {Count} items cached (TTL={TTL}s).",
dict.Count, ttlSec); dict.Count, ttlSec);
} }
@@ -158,19 +157,6 @@ public sealed class TitleDatabaseService : IHostedService
return dto; return dto;
} }
/*public async Task AddOrUpdateAsync(string titleId, TitleInfoDto dto)
{
var all = await GetAllAsync(); // slides the entry
all[id] = dto;
await PersistAsync(all); // writes to disk & triggers rebuild
}
public async Task RemoveAsync(string titleId)
{
var all = await GetAllAsync(); // slides the entry
if (all.Remove(id))
await PersistAsync(all); // writes to disk & triggers rebuild
}*/
/* ---------------------------------------------------------------- */ /* ---------------------------------------------------------------- */
/* 3️⃣ Persist to disk & notify snapshot service /* 3️⃣ Persist to disk & notify snapshot service
/* ---------------------------------------------------------------- */ /* ---------------------------------------------------------------- */
@@ -184,7 +170,19 @@ public sealed class TitleDatabaseService : IHostedService
/* ---------------------------------------------------------------- */ /* ---------------------------------------------------------------- */
/* 4️⃣ Dispose /* 4️⃣ Dispose
/* ---------------------------------------------------------------- */ /* ---------------------------------------------------------------- */
public void Dispose() => _snapshotService.SnapshotRebuilt -= (_, _) => ReloadCacheAsync(); public void Dispose() => _snapshotService.SnapshotRebuilt -= SnapshotServiceOnSnapshotRebuilt;
private async void SnapshotServiceOnSnapshotRebuilt(object? o, EventArgs eventArgs)
{
try
{
await ReloadCacheAsync();
}
catch (Exception e)
{
_logger.LogCritical(e, "Failed to reload title database cache");
}
}
#region Public API #region Public API
@@ -240,6 +238,7 @@ public sealed class TitleDatabaseService : IHostedService
/// <summary> /// <summary>
/// Read the JSON file and populate <c>_titleData</c>. /// Read the JSON file and populate <c>_titleData</c>.
/// Also remap titleId for XCI files based on cnmts.json
/// </summary> /// </summary>
private async Task<Dictionary<string,TitleInfoDto>> ReadTitleDbAsync(string filePath, CancellationToken ct) private async Task<Dictionary<string,TitleInfoDto>> ReadTitleDbAsync(string filePath, CancellationToken ct)
{ {
@@ -10,8 +10,10 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Http" Version="2.3.0" /> <PackageReference Include="Microsoft.AspNetCore.Http" Version="2.3.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.10" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.10" />
<PackageReference Include="Microsoft.Extensions.Options" Version="9.0.10" />
<PackageReference Include="SharpCompress" Version="0.41.0" /> <PackageReference Include="SharpCompress" Version="0.41.0" />
<PackageReference Include="SharpSevenZip" Version="2.0.32" /> <PackageReference Include="SharpSevenZip" Version="2.0.32" />
<PackageReference Include="System.Runtime.Caching" Version="9.0.10" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -0,0 +1,251 @@
using System.Buffers;
namespace TinfoilVibeServer.Utilities;
/// <summary>
/// A readonly, seekable wrapper around a nonseekable stream.
/// It buffers the source data on demand in chunks so that you can seek
/// back and forth without reading the whole source at once.
/// </summary>
public sealed class SeekableBufferedStream : Stream
{
private const int DefaultChunkSize = 128 * 1024 * 1024; // 128 MiB
private readonly Stream _source;
private readonly ArrayPool<byte> _pool;
private readonly int _chunkSize;
private readonly bool _disposeSource;
// Buffer block holds a rented byte[] and the number of bytes actually read.
private readonly struct BufferBlock
{
public readonly byte[] Data;
public readonly int Length;
public BufferBlock(byte[] data, int length) { Data = data; Length = length; }
}
private readonly List<BufferBlock> _blocks = new();
private readonly long _specifiedLength = 0;
private long _bufferedLength; // total number of bytes buffered so far
private long _position; // current logical position in the stream
private bool _eof; // true when the source stream has been exhausted
#region ctor / dispose
/// <summary>
/// Creates a new instance.
/// </summary>
/// <param name="source">The underlying source stream. Must be readable.</param>
/// <param name="specifiedLength">Length of underlying stream if known before using</param>
/// <param name="chunkSize">Size of each buffer chunk (bytes). 128 MiB by default.</param>
/// <param name="disposeSource">If true, disposing this wrapper will also dispose the source stream.</param>
public SeekableBufferedStream(Stream source, long specifiedLength = 0, int chunkSize = DefaultChunkSize, bool disposeSource = false)
{
if (source == null) throw new ArgumentNullException(nameof(source));
if (!source.CanRead) throw new ArgumentException("Source stream must be readable.", nameof(source));
if (chunkSize <= 0) throw new ArgumentOutOfRangeException(nameof(chunkSize), "Chunk size must be positive.");
if (specifiedLength <= 0) throw new ArgumentOutOfRangeException(nameof(specifiedLength), "Specified length must be positive.");
_source = source;
_specifiedLength = specifiedLength;
_pool = ArrayPool<byte>.Shared;
_chunkSize = chunkSize;
_disposeSource = disposeSource;
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
foreach (var block in _blocks)
_pool.Return(block.Data, clearArray: true);
_blocks.Clear();
if (_disposeSource)
_source.Dispose();
}
base.Dispose(disposing);
}
#endregion
#region helpers
/// <summary>
/// Ensures that at least <paramref name="requiredOffset"/> bytes are buffered.
/// Reads from the source stream until the requested offset is reached or EOF is hit.
/// </summary>
private void EnsureBuffered(long requiredOffset)
{
if (_eof || _bufferedLength >= requiredOffset)
return;
while (_bufferedLength < requiredOffset && !_eof)
{
var buf = _pool.Rent(_chunkSize);
int read = _source.Read(buf, 0, _chunkSize);
if (read == 0)
{
_eof = true;
_pool.Return(buf, clearArray: true);
break;
}
_blocks.Add(new BufferBlock(buf, read));
_bufferedLength += read;
}
}
/// <summary>
/// Finds the block that contains <paramref name="pos"/> and the offset inside that block.
/// </summary>
private void GetBlockAndOffset(long pos, out int blockIndex, out int offsetInBlock)
{
long accumulated = 0;
for (int i = 0; i < _blocks.Count; i++)
{
int blockLen = _blocks[i].Length;
if (pos < accumulated + blockLen)
{
blockIndex = i;
offsetInBlock = (int)(pos - accumulated);
return;
}
accumulated += blockLen;
}
// This should never happen because we always call EnsureBuffered before accessing.
throw new InvalidOperationException("Requested position is outside buffered range.");
}
#endregion
#region Stream overrides
public override bool CanRead => true;
public override bool CanSeek => true;
public override bool CanWrite => false;
public override long Length
{
get
{
// If we were given a length, we can return that.
if (_specifiedLength > 0) return _specifiedLength;
// If we already hit EOF, we know the length.
if (_eof) return _bufferedLength;
// If the underlying stream is seekable, we can ask it directly.
if (_source.CanSeek)
return _source.Length;
// Otherwise we need to drain the source to discover its length.
while (!_eof)
EnsureBuffered(_bufferedLength + _chunkSize);
return _bufferedLength;
}
}
public override long Position
{
get => _position;
set
{
if (value < 0) throw new ArgumentOutOfRangeException(nameof(value));
if (value > Length) throw new ArgumentOutOfRangeException(nameof(value));
_position = value;
}
}
public override int Read(byte[] buffer, int offset, int count)
{
if (buffer == null) throw new ArgumentNullException(nameof(buffer));
if (offset < 0 || count < 0 || offset + count > buffer.Length)
throw new ArgumentOutOfRangeException();
// If we are already at or beyond the logical end, nothing to read.
if (_position >= Length)
return 0;
// We will read at most `count` bytes but not past the logical end.
long maxRead = Math.Min(count, Length - _position);
EnsureBuffered(_position + maxRead);
int bytesRead = 0;
while (bytesRead < maxRead)
{
GetBlockAndOffset(_position, out int blockIdx, out int blockOffset);
var block = _blocks[blockIdx];
int available = block.Length - blockOffset;
int toCopy = (int)Math.Min(available, maxRead - bytesRead);
Buffer.BlockCopy(block.Data, blockOffset, buffer, offset + bytesRead, toCopy);
_position += toCopy;
bytesRead += toCopy;
}
return bytesRead;
}
public override long Seek(long offset, SeekOrigin origin)
{
long newPos = origin switch
{
SeekOrigin.Begin => offset,
SeekOrigin.Current => _position + offset,
SeekOrigin.End => Length + offset,
_ => throw new ArgumentException("Invalid SeekOrigin", nameof(origin))
};
if (newPos < 0) throw new IOException("Attempted to seek before the beginning of the stream.");
// Make sure we have buffered data up to the new position.
EnsureBuffered(newPos);
_position = newPos;
return _position;
}
public override void SetLength(long value) => throw new NotSupportedException();
public override void Flush() { /* No-op readonly stream */ }
public override void Write(byte[] buffer, int offset, int count) =>
throw new NotSupportedException();
public override void WriteByte(byte value) => throw new NotSupportedException();
#endregion
#region async helpers (optional)
public override async ValueTask<int> ReadAsync(Memory<byte> destination, CancellationToken cancellationToken = default)
{
// If we are already at or beyond the logical end, nothing to read.
if (_position >= Length)
return 0;
long maxRead = Math.Min(destination.Length, Length - _position);
EnsureBuffered(_position + maxRead);
int bytesRead = 0;
while (bytesRead < maxRead)
{
GetBlockAndOffset(_position, out int blockIdx, out int blockOffset);
var block = _blocks[blockIdx];
int available = block.Length - blockOffset;
int toCopy = (int)Math.Min(available, maxRead - bytesRead);
// We copy synchronously no async source involved
destination.Slice(bytesRead, toCopy).Span
.CopyTo(block.Data.AsSpan(blockOffset, toCopy));
_position += toCopy;
bytesRead += toCopy;
}
return bytesRead;
}
#endregion
}
+3 -3
View File
@@ -13,8 +13,8 @@
"BlacklistFile": "blacklist.json", "BlacklistFile": "blacklist.json",
"MaxFailedAttempts": 5, "MaxFailedAttempts": 5,
"Snapshot" : { "Snapshot" : {
"RootDirectories": [ "\\\\NAS\\Roms\\Switch", "Z:\\imgs\\roms\\Switch" ], "RootDirectories": [ "Z:\\downloads\\roms\\switch", "Z:\\imgs\\roms\\Switch" ],
"WhitelistExtensions": [ ".bin", ".jpg", ".png", ".txt" ], "ArchiveExtensions": [ ".zip", ".rar", ".7z" ],
"RomExtensions": [ ".xci", ".nsp", ".xcz" ], "RomExtensions": [ ".xci", ".nsp", ".xcz" ],
"CacheTtl": 60, "CacheTtl": 60,
"SnapshotFile": "index.tfl", "SnapshotFile": "index.tfl",
@@ -23,7 +23,7 @@
"TitleDb": { "TitleDb": {
"CountryCode": "AU", "CountryCode": "AU",
"Language": "en", "Language": "en",
"TtlSeconds" : 30, "TtlSeconds" : 90,
"SnapshotFile" : "snapshot.json" "SnapshotFile" : "snapshot.json"
}, },
@@ -2,6 +2,7 @@
using System.IO; using System.IO;
using System.Threading.Tasks; using System.Threading.Tasks;
using LibHac.Ncm; using LibHac.Ncm;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Moq; using Moq;
@@ -21,6 +22,7 @@ namespace TinfoilVibeServerTest.Tests
private Mock<IArchiveHandler> _archiveHander; private Mock<IArchiveHandler> _archiveHander;
private Mock<IOptionsMonitor<SnapshotOptions>> _mockOptions; private Mock<IOptionsMonitor<SnapshotOptions>> _mockOptions;
private SnapshotOptions _options; private SnapshotOptions _options;
private MemoryCache _memoryCache;
[SetUp] [SetUp]
public void SetUp() public void SetUp()
@@ -42,69 +44,92 @@ namespace TinfoilVibeServerTest.Tests
_loggerMock = new Mock<ILogger<SnapshotService>>(); _loggerMock = new Mock<ILogger<SnapshotService>>();
_archiveHander = new Mock<IArchiveHandler>(); _archiveHander = new Mock<IArchiveHandler>();
_nspExtractorMock = new Mock<INSPExtractor>(); _nspExtractorMock = new Mock<INSPExtractor>();
var memoryCacheOptions = Options.Create(new MemoryCacheOptions());
_memoryCache = new MemoryCache(memoryCacheOptions);
_nspExtractorMock.Setup(extractor => extractor.ExtractHashFromStream(It.IsAny<Stream>())).Returns("HASH"); _nspExtractorMock.Setup(extractor => extractor.ExtractHashFromStream(It.IsAny<Stream>())).Returns("HASH");
_nspExtractorMock.Setup(extractor => extractor.ExtractFromStream(It.IsAny<Stream>())).Returns( _nspExtractorMock.Setup(extractor => extractor.ExtractFromStream(It.IsAny<Stream>())).Returns(
new NcaMetadataWithHash(titleId: "0000000000000000","0000000000000000", version: 1, ContentMetaType.Application, "HASH")); new NcaMetadataWithHash(titleId: "0000000000000000","0000000000000000", version: 1, ContentMetaType.Application, "HASH"));
//Settings.RootDirs = new List<string> { "TestData/Root1", "TestData/Root2" }; //Settings.RootDirs = new List<string> { "TestData/Root1", "TestData/Root2" };
_service = new SnapshotService(_mockOptions.Object, _nspExtractorMock.Object, _archiveHander.Object, _loggerMock.Object); _service = new SnapshotService(_memoryCache, _mockOptions.Object, _nspExtractorMock.Object, _archiveHander.Object, _loggerMock.Object);
} }
[TearDown] [TearDown]
public void TearDown() public void TearDown()
{ {
_service.Dispose(); _service.Dispose();
_memoryCache.Dispose();
} }
[Test] [Test]
public async Task BuildSnapshot_WhenFilesChanged_ShouldPersist() public async Task BuildSnapshot_WhenFilesChanged_ShouldPersist()
{ {
// Arrange // Arrange
await File.WriteAllTextAsync(_options.SnapshotFile, "[]");
var initialHash = _service.GetSnapshot()?.Hash; var initialHash = _service.GetSnapshot()?.Hash;
// Add a file to Root1 var rebuilding = false;
var newFile = Path.Combine(_options.RootDirectories.First(), "new.nsp"); var rebuilt = false;
FileSystemExtensions.EnsureDirectoryExists(Path.GetDirectoryName(newFile)); CancellationTokenSource snapshotRebuilding = new();
// Create a new valid NSP file _service.SnapshotRebuilding += (sender, args) =>
// copy to temp to touch modified date
foreach (var file in Directory.GetFiles("../../../Data/"))
{ {
var filename = Path.GetFileName(file); rebuilding = true;
var destFilename = Path.Combine(Path.GetTempPath(), filename); snapshotRebuilding.Cancel();
File.Copy(file, destFilename, true); };
var info = new FileInfo(destFilename) CancellationTokenSource snapshotPersisting = new();
{
LastWriteTimeUtc = DateTime.UtcNow
};
info.CopyTo(Path.Combine(_options.RootDirectories.First(),filename), true);
}
// Act
_service.SnapshotRebuilt+= (sender, args) => _service.SnapshotRebuilt+= (sender, args) =>
{ {
rebuilt = true;
snapshotPersisting.Cancel();
// Assert // Assert
var newHash = _service.GetSnapshot()?.Hash; var newHash = _service.GetSnapshot()?.Hash;
Assert.That(newHash, Is.Not.EqualTo(initialHash)); Assert.That(newHash, Is.Not.EqualTo(initialHash));
}; };
Task.Delay(300).Wait(); Timer timer = new(state =>
_loggerMock.Verify( {
l => l.Log( snapshotPersisting.Cancel();
LogLevel.Information, snapshotRebuilding.Cancel();
It.IsAny<EventId>(), }, null, 20*1000, 0);
It.Is<It.IsAnyType>((v, t) => v.ToString().Contains("Snapshot rebuilt")), await File.WriteAllTextAsync(_options.SnapshotFile, "[]", snapshotPersisting.Token);
null, // Add a file to Root1
It.IsAny<Func<It.IsAnyType, Exception, string>>()), Times.Once); var newFile = Path.Combine(_options.RootDirectories.First(), "new.nsp");
// Act
await File.WriteAllTextAsync(newFile,"TEST");
Task.Delay(4000).Wait();
try
{
while (_memoryCache.Count > 0)
{
Task.Delay(200).Wait(snapshotRebuilding.Token);
}
}
catch (OperationCanceledException)
{
Assert.That(rebuilding, Is.True);
}
try
{
while (_memoryCache.Count > 0)
{
Task.Delay(200).Wait(snapshotPersisting.Token);
}
}
catch (OperationCanceledException e)
{
Assert.That(rebuilt, Is.True);
}
} }
[Test] [Test]
public async Task BuildSnapshot_NoChange_ShouldNotPersist() public async Task BuildSnapshot_NoChange_ShouldNotPersist()
{ {
// Act // Act
_service.BuildSnapshot(); _service.BuildSnapshotAsync();
// Act again snapshot should be identical // Act again snapshot should be identical
_service.BuildSnapshot(); _service.BuildSnapshotAsync();
// Assert // Assert
_loggerMock.Verify( _loggerMock.Verify(