27 Commits

Author SHA1 Message Date
ecenshu 359bb0ef3b Merge branch 'main' into feature/bugfix_selflock
ci / build_linux (push) Successful in 3m47s
ci / build_linux (pull_request) Successful in 3m45s
# Conflicts:
#	TinfoilVibeServer/Services/ROMArchiveReader.cs
#	TinfoilVibeServer/Services/SnapshotService.cs
2025-12-14 10:10:42 +10:30
ecenshu f710a4cbde Check lock against base archive in multipart scenario
ci / build_linux (push) Has been cancelled
ci / build_linux (pull_request) Successful in 8m8s
Explicitly attempt to close Reader when using ArchiveFactory
2025-12-14 10:05:53 +10:30
ecenshu b048705024 Implement DisposeAsync (#8)
Build & Push Docker image / build-and-push (push) Successful in 15m1s
ci / build_linux (push) Successful in 5m31s
Log when snapshot is added

Reviewed-on: #8
Co-authored-by: Huy Nguyen <ecenshu@gmail.com>
Co-committed-by: Huy Nguyen <ecenshu@gmail.com>
2025-12-13 01:27:05 +00:00
ecenshu eda922c3f3 Implement DisposeAsync
ci / build_linux (pull_request) Has been cancelled
ci / build_linux (push) Has been cancelled
Log when snapshot is added
2025-12-13 11:55:58 +10:30
ecenshu de438f8905 When a file is locked during hash calculation, if retries fails then do not throw exception out but rather return null hash (#7)
Build & Push Docker image / build-and-push (push) Successful in 10m7s
ci / build_linux (push) Successful in 3m42s
Reviewed-on: #7
Co-authored-by: Huy Nguyen <ecenshu@gmail.com>
Co-committed-by: Huy Nguyen <ecenshu@gmail.com>
2025-12-13 00:09:50 +00:00
ecenshu 169cf9ecf9 Merge pull request 'Allow SnapshotService operations to be cancellable' (#6) from feature/cancellable_snapshotbuild into main
Build & Push Docker image / build-and-push (push) Successful in 6m49s
ci / build_linux (push) Failing after 58s
Reviewed-on: #6
2025-12-12 23:30:34 +00:00
ecenshu c8a3a79ce3 Allow SnapshotService operations to be cancellable
ci / build_linux (push) Successful in 8m34s
ci / build_linux (pull_request) Successful in 3m49s
Restart BuildSnapshot on debounced file change
2025-12-13 09:43:09 +10:30
ecenshu 0e2fec8c01 Skip hashed and same location files
Build & Push Docker image / build-and-push (push) Successful in 14m38s
ci / build_linux (push) Successful in 4m43s
Explicit usings
Multipart rar handling
2025-11-23 21:05:58 +10:30
ecenshu cb60d768df Synchronous Snapshot Build and ordered persistence (#5)
Build & Push Docker image / build-and-push (push) Successful in 7m5s
ci / build_linux (push) Successful in 4m53s
Fix build warnings
Snapshot now persisted in lastmodified date descending, hopefully aligns with snapshot simple check against first entry in a directory not existing in the snapshot during build
Building Snapshot should only be done synchrnonously and atomically
Blacklist watched for changes

Reviewed-on: #5
Co-authored-by: Huy Nguyen <ecenshu@gmail.com>
Co-committed-by: Huy Nguyen <ecenshu@gmail.com>
2025-11-16 01:27:43 +00:00
ecenshu 8751a72176 feature/entry_from_filename (#4)
ci / build_linux (push) Failing after 13m35s
Build & Push Docker image / build-and-push (push) Failing after 13m40s
Reviewed-on: #4
Co-authored-by: Huy Nguyen <ecenshu@gmail.com>
Co-committed-by: Huy Nguyen <ecenshu@gmail.com>
2025-11-15 08:55:27 +00:00
ecenshu c260ebd566 If filename can extract to a NcaMetadata entry, don't use nspextractor to pull information (#3)
Build & Push Docker image / build-and-push (push) Successful in 5m39s
ci / build_linux (push) Successful in 4m36s
Scan directories sequentially to reduce memory footprint

Reviewed-on: #3
Co-authored-by: Huy Nguyen <ecenshu@gmail.com>
Co-committed-by: Huy Nguyen <ecenshu@gmail.com>
2025-11-15 06:59:25 +00:00
ecenshu 17d12fef6a Use a resource to initialise appsettings.json in config folder (#2)
Build & Push Docker image / build-and-push (push) Successful in 8m18s
ci / build_linux (push) Successful in 3m36s
Watch for KeySet, initial install will not have a valid value
TitleDatabase will use data folder

Reviewed-on: #2
Co-authored-by: Huy Nguyen <ecenshu@gmail.com>
Co-committed-by: Huy Nguyen <ecenshu@gmail.com>
2025-11-13 23:52:34 +00:00
ecenshu 97fc2dc872 Use sanitized github.ref
Build & Push Docker image / build-and-push (push) Successful in 15m11s
ci / build_linux (push) Successful in 5m29s
2025-11-13 19:44:36 +10:30
ecenshu a1ea34bc01 feature/ci (#1)
Build & Push Docker image / build-and-push (push) Has been cancelled
ci / build_linux (push) Has been cancelled
Consolidate data and config into separate folders that will be expected to be mapped in the container

Reviewed-on: #1
Co-authored-by: Huy Nguyen <ecenshu@gmail.com>
Co-committed-by: Huy Nguyen <ecenshu@gmail.com>
2025-11-13 09:11:21 +00:00
ecenshu 314af37b3d Workflow cache
Build & Push Docker image / build-and-push (push) Successful in 12m21s
ci / build_linux (push) Successful in 5m42s
2025-11-08 10:23:46 +10:30
ecenshu 877db194c2 tabs
Build & Push Docker image / build-and-push (push) Has been cancelled
ci / build_linux (push) Has been cancelled
2025-11-08 10:22:23 +10:30
ecenshu c4c15dbada workflow cache
Build & Push Docker image / build-and-push (push) Has been cancelled
2025-11-08 10:19:58 +10:30
ecenshu 301525e198 dots...
Build & Push Docker image / build-and-push (push) Waiting to run
ci / build_linux (push) Has been cancelled
2025-11-07 23:14:43 +10:30
ecenshu fa8f0d7443 LibHac.dll from Dependencies
Build & Push Docker image / build-and-push (push) Failing after 5m38s
ci / build_linux (push) Has been cancelled
2025-11-07 22:47:48 +10:30
ecenshu 1fdef1bcc8 Merge branch 'feature/gpt-oss-01'
Build & Push Docker image / build-and-push (push) Failing after 6m59s
ci / build_linux (push) Failing after 7m44s
2025-11-07 22:33:10 +10:30
ecenshu 995e4aa518 Allow for cancelling downloads from filesystem
Rebuild request orignally will use setting for constructing the url
Rebuild request from client via no-cache will use httppcontext to get runtime pathing to generate url
Escape the url generated
2025-11-07 16:13:48 +10:30
ecenshu c2ed73e03f TitleDb loading is handled for multithreading
Fix some memory leaks
2025-11-07 14:31:59 +10:30
ecenshu 209b766a1f Build Snapshot from archives
Download from archives
Process XCI files in archives
2025-11-07 13:31:37 +10:30
ecenshu 17be096ae2 Add UnitTests and made code testable with DI 2025-11-04 20:27:51 +10:30
ecenshu e5787c9321 Additional logging 2025-11-04 12:28:38 +10:30
ecenshu 6c276f1de3 Working implementation 2025-11-04 07:40:27 +10:30
ecenshu 09e1924996 Compiles but runs strange 2025-11-02 20:24:58 +10:30
62 changed files with 5032 additions and 613 deletions
+2 -1
View File
@@ -22,4 +22,5 @@
**/secrets.dev.yaml
**/values.dev.yaml
LICENSE
README.md
README.md
data/
+40 -4
View File
@@ -25,6 +25,30 @@ jobs:
# echo "MY_LOWER=$lower_value" >> $GITHUB_ENV
# If you want to use it as an output of this step:
echo "lowercase=$lower_value" >> $GITHUB_OUTPUT
- name: Convert ref to buildx safe value
id: docker_tag_from_ref
shell: bash
run: |
# Grab the raw ref
REF="${{ github.ref }}"
# Strip the "refs/*/" prefix (refs/heads/, refs/tags/…)
TAG=${REF#refs/*/}
# Replace characters that Docker tags disallow
# * "/" → "-"
# * ":" → "-"
# * Any other nonalphanumeric / . / _ / - → "-"
TAG=${TAG//\//-}
TAG=${TAG//:/-}
TAG=${TAG//[^a-zA-Z0-9._-]/-}
# (Optional) force lowercase Docker tags are casesensitive,
# but many people prefer lowercase
TAG=${TAG,,}
# Export to the action's output
echo "docker-tag=${TAG}" >> $GITHUB_OUTPUT
# ------------------------------------------------------------------
# 1. Checkout repository
# ------------------------------------------------------------------
@@ -48,7 +72,15 @@ jobs:
registry: ${{ secrets.REGISTRY_HOST }} # e.g. registry.example.com
username: ${{ secrets.REGISTRY_USER }} # e.g. admin
password: ${{ secrets.REGISTRY_PASSWORD }} # e.g. <apitoken>
- name: Cache Docker layers
uses: actions/cache@v3
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
restore-keys: |
${{ runner.os }}-buildx-
# ------------------------------------------------------------------
# 4. Build the Docker image
# ------------------------------------------------------------------
@@ -62,11 +94,13 @@ jobs:
push: false
tags: |
${{ vars.REGISTRY_HOST }}/${{ steps.github_repository_to_lowercase.outputs.lowercase }}:${{ github.sha }}
${{ vars.REGISTRY_HOST }}/${{ steps.github_repository_to_lowercase.outputs.lowercase }}:${{ github.ref_name }}
${{ vars.REGISTRY_HOST }}/${{ steps.github_repository_to_lowercase.outputs.lowercase }}:${{ steps.docker_tag_from_ref.outputs.docker-tag }}
${{ vars.REGISTRY_HOST }}/${{ steps.github_repository_to_lowercase.outputs.lowercase }}:latest
build-args: |
# Add any build args here
# ARG_NAME=VALUE
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache
# ------------------------------------------------------------------
# 5. Push the image to the registry
@@ -79,8 +113,10 @@ jobs:
push: true
tags: |
${{ vars.REGISTRY_HOST }}/${{ steps.github_repository_to_lowercase.outputs.lowercase }}:${{ github.sha }}
${{ vars.REGISTRY_HOST }}/${{ steps.github_repository_to_lowercase.outputs.lowercase }}:${{ github.ref_name }}
${{ vars.REGISTRY_HOST }}/${{ steps.github_repository_to_lowercase.outputs.lowercase }}:${{ steps.docker_tag_from_ref.outputs.docker-tag }}
${{ vars.REGISTRY_HOST }}/${{ steps.github_repository_to_lowercase.outputs.lowercase }}:latest
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache
# ------------------------------------------------------------------
# 6. (Optional) Clean up local Docker cache
@@ -96,5 +132,5 @@ jobs:
run: |
echo "Pushed image tags:"
echo "- ${{ vars.REGISTRY_HOST }}/${{ steps.github_repository_to_lowercase.outputs.lowercase }}:${{ github.sha }}"
echo "- ${{ vars.REGISTRY_HOST }}/${{ steps.github_repository_to_lowercase.outputs.lowercase }}:${{ github.ref_name }}"
echo "- ${{ vars.REGISTRY_HOST }}/${{ steps.github_repository_to_lowercase.outputs.lowercase }}:${{ steps.docker_tag_from_ref.outputs.docker-tag }}"
echo "- ${{ vars.REGISTRY_HOST }}/${{ steps.github_repository_to_lowercase.outputs.lowercase }}:latest"
+36 -3
View File
@@ -23,7 +23,38 @@ jobs:
# echo "MY_LOWER=$lower_value" >> $GITHUB_ENV
# If you want to use it as an output of this step:
echo "lowercase=$lower_value" >> $GITHUB_OUTPUT
- name: Convert ref to buildx safe value
id: docker_tag_from_ref
shell: bash
run: |
# Grab the raw ref
REF="${{ github.ref }}"
# Strip the "refs/*/" prefix (refs/heads/, refs/tags/…)
TAG=${REF#refs/*/}
# Replace characters that Docker tags disallow
# * "/" → "-"
# * ":" → "-"
# * Any other nonalphanumeric / . / _ / - → "-"
TAG=${TAG//\//-}
TAG=${TAG//:/-}
TAG=${TAG//[^a-zA-Z0-9._-]/-}
# (Optional) force lowercase Docker tags are casesensitive,
# but many people prefer lowercase
TAG=${TAG,,}
# Export to the action's output
echo "docker-tag=${TAG}" >> $GITHUB_OUTPUT
- name: Cache Docker layers
uses: actions/cache@v3
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
restore-keys: |
${{ runner.os }}-buildx-
- name: Build image
id: build
uses: docker/build-push-action@v5
@@ -34,8 +65,10 @@ jobs:
push: false
tags: |
${{ vars.REGISTRY_HOST }}/${{ steps.github_repository_to_lowercase.outputs.lowercase }}:${{ github.sha }}
${{ vars.REGISTRY_HOST }}/${{ steps.github_repository_to_lowercase.outputs.lowercase }}:${{ github.ref_name }}
${{ vars.REGISTRY_HOST }}/${{ steps.github_repository_to_lowercase.outputs.lowercase }}:${{ steps.docker_tag_from_ref.outputs.docker-tag }}
${{ vars.REGISTRY_HOST }}/${{ steps.github_repository_to_lowercase.outputs.lowercase }}:latest
build-args: |
# Add any build args here
# ARG_NAME=VALUE
# ARG_NAME=VALUE
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache
+8 -1
View File
@@ -2,4 +2,11 @@ bin/
obj/
/packages/
riderModule.iml
/_ReSharper.Caches/
/_ReSharper.Caches/
TinfoilVibeServer.sln.DotSettings.user
data/*
!.data/.gitkeep
**/*.local.json
TinfoilVibeServer/config/prod.keys
TinfoilVibeServer/data/*
!TinfoilVibeServer/data/.gitkeep
+1
View File
@@ -2,5 +2,6 @@
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
<mapping directory="$PROJECT_DIR$/libhac" vcs="Git" />
</component>
</project>
+18
View File
@@ -0,0 +1,18 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="TinfoilVibeServer: http" type="LaunchSettings" factoryName=".NET Launch Settings Profile">
<option name="LAUNCH_PROFILE_PROJECT_FILE_PATH" value="$PROJECT_DIR$/TinfoilVibeServer/TinfoilVibeServer.csproj" />
<option name="LAUNCH_PROFILE_TFM" value="net9.0" />
<option name="LAUNCH_PROFILE_NAME" value="http" />
<option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" />
<option name="GENERATE_APPLICATIONHOST_CONFIG" value="1" />
<option name="SHOW_IIS_EXPRESS_OUTPUT" value="0" />
<option name="SEND_DEBUG_REQUEST" value="1" />
<option name="ADDITIONAL_IIS_EXPRESS_ARGUMENTS" value="" />
<option name="AUTO_ATTACH_CHILDREN" value="0" />
<method v="2">
<option name="Build" />
</method>
</configuration>
</component>
+54
View File
@@ -0,0 +1,54 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="TinfoilVibeServer/Dockerfile" type="docker-deploy" factoryName="dockerfile" server-name="Docker WSL">
<deployment type="dockerfile">
<settings>
<option name="imageTag" value="gitea.ecenshu.net/ecenshu/tinfoilvibeserver:dev" />
<option name="containerName" value="tinfoilvibeserver" />
<option name="contextFolderPath" value="." />
<option name="envVars">
<list>
<DockerEnvVarImpl>
<option name="name" value="uid" />
<option name="value" value="1034" />
</DockerEnvVarImpl>
</list>
</option>
<option name="portBindings">
<list>
<DockerPortBindingImpl>
<option name="containerPort" value="8080" />
<option name="hostIp" value="127.0.0.1" />
<option name="hostPort" value="8080" />
</DockerPortBindingImpl>
</list>
</option>
<option name="showCommandPreview" value="true" />
<option name="sourceFilePath" value="TinfoilVibeServer/Dockerfile" />
<option name="volumeBindings">
<list>
<DockerVolumeBindingImpl>
<option name="containerPath" value="/app/data" />
<option name="hostPath" value="D:\Cloud\Git\TinfoilVibeServer\TinfoilVibeServer\bin\Debug\net9.0\data" />
</DockerVolumeBindingImpl>
<DockerVolumeBindingImpl>
<option name="containerPath" value="/app/config" />
<option name="hostPath" value="D:\Cloud\Git\TinfoilVibeServer\TinfoilVibeServer\bin\Debug\net9.0\config" />
</DockerVolumeBindingImpl>
<DockerVolumeBindingImpl>
<option name="containerPath" value="/roms_cold" />
<option name="hostPath" value="Z:\downloads\roms\switch" />
<option name="readOnly" value="true" />
</DockerVolumeBindingImpl>
<DockerVolumeBindingImpl>
<option name="containerPath" value="/roms_hot" />
<option name="hostPath" value="Z:\imgs\roms\Switch" />
<option name="readOnly" value="true" />
</DockerVolumeBindingImpl>
</list>
</option>
</settings>
</deployment>
<EXTENSION ID="com.jetbrains.rider.docker.debug" isFastModeEnabled="true" isSslEnabled="false" />
<method v="2" />
</configuration>
</component>
BIN
View File
Binary file not shown.
+7
View File
@@ -3,10 +3,13 @@ Microsoft Visual Studio Solution File, Format Version 12.00
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{41999D26-405C-4DAB-8991-CBA992117C84}"
ProjectSection(SolutionItems) = preProject
compose.yaml = compose.yaml
compose.overide.yaml = compose.overide.yaml
EndProjectSection
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TinfoilVibeServer", "TinfoilVibeServer\TinfoilVibeServer.csproj", "{DE992FDB-6D13-4152-925D-29D39A23FB75}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TinfoilVibeServerTest", "TinfoilVibeServerTest\TinfoilVibeServerTest.csproj", "{E0A5CACD-E3F9-4420-AA14-4C447CCF430A}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -17,5 +20,9 @@ Global
{DE992FDB-6D13-4152-925D-29D39A23FB75}.Debug|Any CPU.Build.0 = Debug|Any CPU
{DE992FDB-6D13-4152-925D-29D39A23FB75}.Release|Any CPU.ActiveCfg = Release|Any CPU
{DE992FDB-6D13-4152-925D-29D39A23FB75}.Release|Any CPU.Build.0 = Release|Any CPU
{E0A5CACD-E3F9-4420-AA14-4C447CCF430A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E0A5CACD-E3F9-4420-AA14-4C447CCF430A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E0A5CACD-E3F9-4420-AA14-4C447CCF430A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E0A5CACD-E3F9-4420-AA14-4C447CCF430A}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal
+5
View File
@@ -0,0 +1,5 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=IP/@EntryIndexedValue">IP</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=NSP/@EntryIndexedValue">NSP</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=PFS/@EntryIndexedValue">PFS</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=ROM/@EntryIndexedValue">ROM</s:String></wpf:ResourceDictionary>
+166 -1
View File
@@ -1,3 +1,168 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:Boolean x:Key="/Default/AddReferences/RecentPaths/=D_003A_005CCloud_005CGit_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></wpf:ResourceDictionary>
<s:Boolean x:Key="/Default/AddReferences/RecentPaths/=D_003A_005CCloud_005CGit_005CTinfoilVibeServer_005CTinfoilVibeServer_005Clibhac_005Csrc_005CLibHac_005Cbin_005CRelease_005Cnet8_002E0_005CLibHac_002Edll/@EntryIndexedValue">True</s:Boolean>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AAbstractWritableArchive_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F22ddbc5388b085f3bf3bfcf15c58a6938df55f53cc27a762c1e0d3e974da87_003FAbstractWritableArchive_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AAppContext_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F574233347f5bab7d6529a8c4744fb8a213de24439a69fd5c4316ea962144_003FAppContext_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AArchiveFactory_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Feb8857ee63bd2e1fe34fa06c5137343348c6a0dc69a8b6e8d966786f2fe285f7_003FArchiveFactory_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_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_003ABinaryReader_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F6759c9e86085d2e4796e66824d1e6bcde85215244243079466ada44a6cf0_003FBinaryReader_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_003ACancellationTokenSource_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F558c1d46e1e21d2e78ee2ab67a674f6927bf95355b2f245f35d74bb5ec0f92_003FCancellationTokenSource_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ACancellationToken_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F2565b9d99fdde488bc7801b84387b2cc864959cfb63212e1ff576fc9c6bb7e_003FCancellationToken_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_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_003ADefaultHttpContext_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F6af28599e71724c5fc6a617444e2f60b5d57dfdc5be0df4ba43ccfc36977_003FDefaultHttpContext_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_003AEncoding_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F1675dc7b710feeeb3e0bc8728be8a947537155c199480fb23b776e81d459_003FEncoding_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_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fbd1d5c50194fea68ff3559c160230b0ab50f5acf4ce3061bffd6d62958e2182_003FExceptionDispatchInfo_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AExecutionContext_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F53b0d531c06faf86bf2ae111a9a2dbce4c52a9153feb9966ade60289c71bf52_003FExecutionContext_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AExpressionExtensions_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fd785b86024a6dfc7a0de13179d2769fe20f85a33e39e7bfc8dfffba6a44a44_003FExpressionExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFileInfo_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F85a5735906a3a06f39a1422a28e0353e3e317f2d923dcc5731fef07dd436f9_003FFileInfo_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFileStorage_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003Fae_003Fb2d28c5c_003FFileStorage_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFileSystemEnumerator_002EWindows_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F326755fc341d349c24999b4c209d69fbe4317313d563859abe51f4ded75c97b_003FFileSystemEnumerator_002EWindows_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFileSystemEventArgs_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F36b5c2553dd4c6dccec26adf9d5ab4ff493763447f46751b6775ba38a832_003FFileSystemEventArgs_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFileSystemInfo_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F9c93a119672fff4fc4a8405c22a67b8153942a238a226de1266ccc65c652d936_003FFileSystemInfo_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_002E3_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_002E3_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_003AFirst_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_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_002E3_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_003AHashAlgorithm_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_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_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_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_003AIArchive_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F482647feefe97ea0467efea59b1ba1d33b2766c65db7d194aa8ba179edd65_003FIArchive_002Ecs/@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_003AIFile_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_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_003AIStorage_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003F1e_003F73490ea0_003FIStorage_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AJsonConverterOfT_002EReadCore_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fa7e99f3da3fc9d80c1949ce87d548d992a745092d1c720f44952dbbd144437f_003FJsonConverterOfT_002EReadCore_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AJsonSerializerOptions_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F5ef7f3c9db445621211faddebc3c1c9bb48a942f1b8cba4caa2501466f85f_003FJsonSerializerOptions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AJsonSerializer_002EWrite_002EString_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fbd49cb1d7ce3d716547551bbff70eb2084ebe04c491a927531363631f3e46330_003FJsonSerializer_002EWrite_002EString_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ALazyReadOnlyCollection_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F41c2eb564782dcbbd4dbb34c6329c22a7c59cf2e50ce14fc51e348d258bac1_003FLazyReadOnlyCollection_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_003AList_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_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_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_003AMemory_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fbb20bcb6cf86be25708bd683fe862b76c8c4497abe68566865702178cf5e1c_003FMemory_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_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_003AMonitor_002ECoreCLR_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_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_003AMultiVolumeReadOnlyStream_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F53a828451e96fc5fe4fce5b95fb79b85beefa0be2da557dff1be93f39f6e6_003FMultiVolumeReadOnlyStream_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ANcaHeader_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003F9e_003Fdaa64f0f_003FNcaHeader_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ANca_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003F8b_003F8c92e4d1_003FNca_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_003ANxFileStream_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_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_003AObject_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F9a6b1457cbcf17db31a383ba49ef9bcc786cf3ef77146d997eee499b27a46d_003FObject_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AOrderedEnumerable_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F3e15db288878a5c586c176f46b93b148490778ac79eb4529070555aa87415_003FOrderedEnumerable_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AOrderedEnumerable_002ESpeedOpt_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Ff152b848b1c419edb4c8e8d52d371fe584aca143db23ee711341209a6ceb_003FOrderedEnumerable_002ESpeedOpt_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_003APartitionFileSystemCore_00604_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_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_003APartitionFileSystemMetaCore_00603_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_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_003APartitionFileSystem_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_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_002EWindows_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F8bfc57955751d07e3fad13bb97d5f66af412d7a34486ec29ad916547ec8ce6b_003FPath_002EWindows_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_003ARarArchiveEntryFactory_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F8fbd22ad2b9537e1af2f53d1f62a875fd9ed2e2781cd7a3fb7828c5ba2edce3_003FRarArchiveEntryFactory_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_003ARarArchiveEntry_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_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_003ARarEntry_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F48a2e7ca6b54673c580a84a5f84c86d7a8cf2b199df9ca68655d9734e95_003FRarEntry_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ARarHeaderFactory_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F138ca59251bcbcaf4d5e59bd2ce24c21ebcfd721aeb51ff82a71a7151d72ced_003FRarHeaderFactory_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_003ARarStream_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fc35d82af548651a2b21dec4154692cd38619ed1ae76afd1aae437738cde798_003FRarStream_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ARarVolume_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fa2e3d55c2c2cc4d9fc7547b9fe71b8dc6b2b1d2732938296845f16e9d3e532_003FRarVolume_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_003AReadOnlySpan_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_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_003AResult_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003F13_003Faf246217_003FResult_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_003ASeekableZipHeaderFactory_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fc5f3e4d3cedb261911afb1e191b36ed580a783fd7ee73cba3f33d8c6feeb_003FSeekableZipHeaderFactory_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASemaphoreSlim_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F9769f0874b89d9a174c39e6c977aef6d979daefa49c7f3c239ef268493ea12_003FSemaphoreSlim_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_003ASha256PartitionFileSystemFormat_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003F0a_003Fcb570a3a_003FSha256PartitionFileSystemFormat_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASha256PartitionFileSystem_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003Fe1_003Ff97991f9_003FSha256PartitionFileSystem_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASharpCompressStream_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F94cd926a492d1f137d5d538f98f52a3cba4f258bf7f15dcd4ac79daddea9d_003FSharpCompressStream_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_003ASourceStream_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fad60445d5abe6c4fac32d6378f89f1eeba9e73cbdf09554352c1546a8314194_003FSourceStream_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_003ASpan_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_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_003AStorageExtensions_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003F01_003F633a1d70_003FStorageExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AStorageStream_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003Fa2_003F35b2a1db_003FStorageStream_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_003AStreamStorage_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_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_003AStream_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fd1287462d4ec4078c61b8e92a0952fb7de3e7e877d279e390a4c136a6365126_003FStream_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASubStorage_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003Fc1_003F4e56d301_003FSubStorage_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_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_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_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_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_003AToCollection_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_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_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_003AUnpack15_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fad78a77fe5aefabfc0b472fc617df3f42822b80df4d1ebbb3e6bf744ad6f6_003FUnpack15_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_003AUnpack_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_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_003AUriExt_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F25edf3e734d0c76e87b29a9954b8b4a7383648a69396554742e5529205e2dd7_003FUriExt_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AUriSyntax_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F7ed82aa0f48a6bf284b4aba7c70aff35142349e44fb4f6caec3d71611f9929_003FUriSyntax_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AUtilities_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003F57_003F0c15ef69_003FUtilities_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_003AWaitHandle_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fcab54c7c1f8d9b3da5f7878e39faba52467f16d0899a2c4b10086cb2ef73f2b_003FWaitHandle_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AXciHeader_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003Fbd_003F5ce70040_003FXciHeader_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_003AXciPartition_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_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_003AXci_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003Fd8_003F1d4b8551_003FXci_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_002E3_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_002E3_003Fresharper_002Dhost_003FSourcesCache_003F39ececcf144e1f9c884152723ed93931cd232485eaf2824bf5beb526f1f321b_003FZipArchive_002Ecs_002Fz_003A2_002D1/@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_002E3_003Fresharper_002Dhost_003FSourcesCache_003F3bba989a68c21c129712261a87d71e882f467a6767a2dd561e88915c5424bba_003FZipArchive_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AZipFactory_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fd17281d585b64ae689c9f9dc982ec39d77a84e88eca8aecaa9e18643aa213822_003FZipFactory_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/Environment/AssemblyExplorer/XmlDocument/@EntryValue">&lt;AssemblyExplorer&gt;&#xD;
&lt;Assembly Path="D:\Cloud\Git\TinfoilVibeServer\TinfoilVibeServer\libhac\src\LibHac\bin\Release\net8.0\LibHac.dll" /&gt;&#xD;
&lt;Assembly Path="D:\Cloud\Git\TinfoilVibeServer\libhac\src\LibHac\bin\Release\net8.0\LibHac.dll" /&gt;&#xD;
&lt;/AssemblyExplorer&gt;</s:String>
<s:String x:Key="/Default/Environment/UnitTesting/UnitTestSessionStore/Sessions/=03775624_002D513c_002D49dc_002Db839_002De7eafc50ef7f/@EntryIndexedValue">&lt;SessionState ContinuousTestingMode="0" Name="All tests from Solution" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"&gt;&#xD;
&lt;Solution /&gt;&#xD;
&lt;/SessionState&gt;</s:String>
<s:String x:Key="/Default/Environment/UnitTesting/UnitTestSessionStore/Sessions/=ba03b870_002Df09c_002D4f54_002D88ad_002D0910534b40f1/@EntryIndexedValue">&lt;SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from Solution #2" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"&gt;&#xD;
&lt;Solution /&gt;&#xD;
&lt;/SessionState&gt;</s:String></wpf:ResourceDictionary>
@@ -0,0 +1,10 @@
namespace TinfoilVibeServer.Authentication;
/// <summary>
/// Settings for AuthStore loaded from appsettings.json.
/// </summary>
public sealed record AuthSettings(
string CredentialsFile,
string FingerprintsFile,
string BlacklistFile,
int MaxFailedAttempts);
@@ -0,0 +1,383 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using TinfoilVibeServer.Services;
using TinfoilVibeServer.Utilities;
namespace TinfoilVibeServer.Authentication;
public interface IAuthStore
{
void Dispose();
bool TryValidate(string username,
string password,
string? uid,
string ip,
out string? error);
int IncrementFailed(string username, string ip);
bool IsIPBlacklisted(string ipAddress);
}
/// <summary>
/// Holds authentication configuration and runtime state.
/// It watches credentials.json for changes and updates the inmemory
/// user list (including the Verified flag) on the fly.
/// </summary>
public class AuthStore : IDisposable, IAuthStore
{
private readonly ILogger<AuthStore> _logger;
private readonly ConfigManager _configManager;
private readonly IHostEnvironment _env;
public readonly ConcurrentDictionary<string, Credential> Credentials = new();
public readonly ConcurrentDictionary<string, List<string>> Fingerprints = new();
public readonly ConcurrentDictionary<string, int> FailedAttempts = new();
private readonly HashSet<string> _blacklistIPs = new();
private readonly Lock _sync = new();
private readonly FileSystemWatcher _credentialsWatcher;
private readonly FileSystemWatcher _blacklistWatcher;
public AuthStore(ILogger<AuthStore> logger, ConfigManager configManager, IHostEnvironment env)
{
_logger = logger;
_configManager = configManager;
_env = env;
LoadAll();
var credentialsFilePath = DetermineCredentialsPath(_configManager.Settings?.CredentialsFile, env);
var directoryName = Path.GetDirectoryName(Path.Combine(credentialsFilePath));
_credentialsWatcher = new FileSystemWatcher
{
Path = (!string.IsNullOrEmpty(directoryName)) ? directoryName : AppContext.BaseDirectory,
Filter = Path.GetFileName(credentialsFilePath) ?? throw new InvalidOperationException(),
NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size | NotifyFilters.Attributes
};
_credentialsWatcher.Changed += (_, _) => OnCredentialsChanged();
_credentialsWatcher.EnableRaisingEvents = true;
_blacklistWatcher = new FileSystemWatcher
{
Path = (!string.IsNullOrEmpty(directoryName)) ? directoryName : AppContext.BaseDirectory,
Filter = Path.GetFileName(_configManager.Settings?.BlacklistFile) ?? throw new InvalidOperationException(),
NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size | NotifyFilters.Attributes
};
_blacklistWatcher.Changed += (_, _) => OnBlacklistChanged();
_blacklistWatcher.EnableRaisingEvents = true;
_logger.LogInformation("Started watching credentials file {File}", credentialsFilePath);
_logger.LogInformation("Started watching blacklist file {File}", Path.Combine(env.ContentRootPath, "data", Path.GetFileName(_configManager.Settings?.BlacklistFile) ?? throw new InvalidOperationException()));
}
private static string DetermineCredentialsPath(string? settingsCredentialsFile, IHostEnvironment env)
{
if (settingsCredentialsFile == null) return Path.Combine("app","data","credentials.json");
return Path.IsPathRooted(settingsCredentialsFile) ? settingsCredentialsFile : Path.Combine(env.ContentRootPath,"data",settingsCredentialsFile);
}
public void Dispose()
{
_credentialsWatcher.Dispose();
}
#region Loading helpers
private void LoadAll()
{
var credentialsFilePath = DetermineCredentialsPath(_configManager.Settings?.CredentialsFile, _env);
_logger.LogInformation("Loading authentication data from {File}", credentialsFilePath);
// credentials
if (File.Exists(credentialsFilePath))
{
var txt = File.ReadAllText(credentialsFilePath);
var dict = JsonSerializer.Deserialize<Dictionary<string, Credential>>(txt)!;
foreach (var kv in dict)
Credentials[kv.Key] = kv.Value;
}
else
{
FileSystemExtensions.EnsureDirectoryExists(Path.GetDirectoryName(Path.GetFullPath(_configManager.Settings?.CredentialsFile ?? throw new InvalidOperationException())));
}
// fingerprints
if (File.Exists(_configManager.Settings?.FingerprintsFile))
{
var txt = File.ReadAllText(_configManager.Settings.FingerprintsFile);
var dict = JsonSerializer.Deserialize<Dictionary<string, List<string>>>(txt)!;
foreach (var kv in dict)
Fingerprints[kv.Key] = kv.Value;
}
else
{
if (_configManager.Settings?.FingerprintsFile != null)
FileSystemExtensions.EnsureDirectoryExists(Path.GetDirectoryName(Path.GetFullPath(_configManager.Settings.FingerprintsFile)));
}
// blacklist
if (File.Exists(_configManager.Settings?.BlacklistFile))
{
var txt = File.ReadAllText(_configManager.Settings.BlacklistFile);
var arr = JsonSerializer.Deserialize<string[]>(txt)!;
foreach (var ip in arr)
_blacklistIPs.Add(ip);
}
else
{
if (_configManager.Settings?.BlacklistFile != null)
FileSystemExtensions.EnsureDirectoryExists(Path.GetDirectoryName(Path.GetFullPath(_configManager.Settings.BlacklistFile)));
}
_logger.LogInformation("Loaded {UserCount} users, {FpCount} fingerprints, {IpCount} IPs",
Credentials.Count, Fingerprints.Count, _blacklistIPs.Count);
}
#endregion
#region Watcher callbacks
private void OnCredentialsChanged()
{
// Small debounce the file may still be locked by the editor.
Task.Run(async () =>
{
await Task.Delay(200);
ReloadCredentials();
});
}
private void OnBlacklistChanged()
{
// Small debounce the file may still be locked by the editor.
Task.Run(async () =>
{
await Task.Delay(200);
LoadAll();
});
}
private void ReloadCredentials()
{
var credentialsFilePath = DetermineCredentialsPath(_configManager.Settings?.CredentialsFile, _env);
if (!File.Exists(credentialsFilePath))
{
_logger.LogError("Credentials file {File} does not exist", credentialsFilePath);
return;
}
try
{
var txt = File.ReadAllText(credentialsFilePath);
var newDict = JsonSerializer.Deserialize<Dictionary<string, Credential>>(txt)!;
lock (_sync)
{
// Update existing users & add new ones
foreach (var kv in newDict)
{
if (Credentials.TryGetValue(kv.Key, out var existing))
{
existing.PasswordHash = kv.Value.PasswordHash;
existing.AllowedUidCount = kv.Value.AllowedUidCount;
existing.Verified = kv.Value.Verified;
}
else
{
Credentials[kv.Key] = kv.Value;
}
}
// Remove users that were deleted from the file
var toRemove = Credentials.Keys.Except(newDict.Keys).ToList();
foreach (var key in toRemove)
{
Credentials.TryRemove(key, out _);
Fingerprints.TryRemove(key, out _);
FailedAttempts.TryRemove(key, out _);
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to reload credentials from {File}", credentialsFilePath);
// ignore malformed JSON or IO error keep old state
}
}
#endregion
#region Authentication logic
public bool TryValidate(string username, string password, string? uid, string ip, out string? error)
{
error = null;
lock (_sync)
{
if (!Credentials.TryGetValue(username, out var cred))
{
// Create user on the fly
cred = new Credential(username, PasswordHash: ComputeHash(password), 1, Verified: false);
Credentials[username] = cred;
PersistCredentials();
_logger.LogInformation("Created new user {Username} (verified={Verified})", username, cred.Verified);
error = "User not verified";
IncrementFailed(username, ip);
return false;
}
if (!VerifyPasswordHash(password, cred.PasswordHash))
{
error = "Invalid password";
IncrementFailed(username, ip);
_logger.LogWarning("Auth failed for {Username} from {IP} {Error}", username, ip, error);
return false;
}
if (!cred.Verified)
{
error = "User not verified";
IncrementFailed(username, ip);
return false;
}
if (uid != null)
{
var list = Fingerprints.GetOrAdd(username, _ => new List<string>());
if (!list.Contains(uid))
{
if (list.Count < cred.AllowedUidCount)
{
list.Add(uid);
PersistFingerprints();
}
else
{
error = $"UID limit ({cred.AllowedUidCount}) exceeded";
IncrementFailed(username, ip);
return false;
}
}
}
else
{
_logger.LogWarning("No Fingerprint given during authentication for {Username} from {IP}", username, ip);
return false;
}
FailedAttempts[username] = 0;
return true;
}
}
public int IncrementFailed(string username, string ip)
{
lock (_sync)
{
var newCount = FailedAttempts.GetOrAdd(username, 0) + 1;
FailedAttempts[username] = newCount;
_logger.LogInformation("Failed attempts for {Username} increased to {Count}", username, newCount);
if (_configManager.Settings == null)
{
_logger.LogCritical("Settings not set to determine failed login counts");
return int.MaxValue;
}
if (newCount < _configManager.Settings.MaxFailedAttempts + 1) return newCount;
_blacklistIPs.Add(ip);
PersistBlacklist();
lock (_sync)
{
FailedAttempts[username] = 0;
}
_logger.LogWarning("IP {IP} blacklisted after {Count} failures", ip, newCount);
return newCount;
}
}
#endregion
#region Helpers
public static string ComputeHash(string input)
{
using var sha = SHA256.Create();
var hash = sha.ComputeHash(Encoding.UTF8.GetBytes(input));
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
}
private static bool VerifyPasswordHash(string plain, string storedHash)
=> string.Equals(ComputeHash(plain), storedHash, StringComparison.OrdinalIgnoreCase);
private void PersistCredentials()
{
var credentialsFilePath = DetermineCredentialsPath(_configManager.Settings?.CredentialsFile, _env);
var json = JsonSerializer.Serialize(Credentials, new JsonSerializerOptions { WriteIndented = true });
if (_configManager.Settings == null)
{
_logger.LogCritical("Credentials file not set, cannot persist");
}
else
{
File.WriteAllText(credentialsFilePath, json);
}
}
private void PersistFingerprints()
{
var json = JsonSerializer.Serialize(Fingerprints, new JsonSerializerOptions { WriteIndented = true });
if (_configManager.Settings == null)
{
_logger.LogCritical("Fingerprint file not set, cannot persist");
}
else
{
File.WriteAllText(_configManager.Settings.FingerprintsFile, json);
}
}
private void PersistBlacklist()
{
var json = JsonSerializer.Serialize(_blacklistIPs.ToArray(), new JsonSerializerOptions { WriteIndented = true });
if (_configManager.Settings == null)
{
_logger.LogCritical("Blacklist file not set, cannot persist");
}
else
{
File.WriteAllText(_configManager.Settings.BlacklistFile, json);
}
}
#endregion
#region Blacklist helpers
public bool IsIPBlacklisted(string ipAddress)
{
return _blacklistIPs.Contains(ipAddress);
}
public bool UnbanIp(string ipAddress)
{
return _blacklistIPs.Remove(ipAddress);
}
public bool BlacklistActive()
{
return _blacklistIPs.Count > 0;
}
#endregion
}
@@ -0,0 +1,16 @@
namespace TinfoilVibeServer.Authentication;
/// <summary>
/// User credential record.
/// </summary>
public sealed record Credential(
string Username,
string PasswordHash,
int AllowedUidCount,
bool Verified
)
{
public string PasswordHash { get; set; } = PasswordHash;
public int AllowedUidCount{ get; set; } = AllowedUidCount;
public bool Verified { get; set; } = Verified;
};
-70
View File
@@ -1,70 +0,0 @@
using System.Text.Json;
namespace TinfoilVibeServer;
/// <summary>
/// Reads the JSON config file and raises an event whenever it changes.
/// </summary>
public sealed class ConfigManager : IDisposable
{
private readonly string _configPath;
private readonly FileSystemWatcher _watcher;
private readonly object _sync = new();
public AppSettings Settings { get; private set; }
public event Action<AppSettings>? OnChange;
public ConfigManager(string configPath)
{
_configPath = configPath;
Settings = Load();
_watcher = new FileSystemWatcher
{
Path = Path.GetDirectoryName(_configPath)!,
Filter = Path.GetFileName(_configPath),
NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size | NotifyFilters.Attributes,
EnableRaisingEvents = true
};
_watcher.Changed += (_, _) => Reload();
}
private AppSettings Load()
{
var json = File.ReadAllText(_configPath);
return JsonSerializer.Deserialize<AppSettings>(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
}
private void Reload()
{
lock (_sync)
{
try
{
Settings = Load();
OnChange?.Invoke(Settings);
}
catch (Exception ex)
{
Console.Error.WriteLine($"Failed to reload config: {ex.Message}");
}
}
}
public void Dispose() => _watcher.Dispose();
}
/// <summary>
/// POCO that matches appsettings.json.
/// </summary>
public sealed record AppSettings(
string[] RootDirectories,
string[] WhitelistExtensions,
string[] RomExtensions,
string SnapshotFile,
string SnapshotBackupFile,
int ArchiveBufferSize
);
@@ -0,0 +1,53 @@
using Microsoft.AspNetCore.Mvc;
namespace TinfoilVibeServer.Controllers;
public class CancelableFileResult : FileResult
{
private readonly Stream _fileStream;
public CancelableFileResult(string contentType, Stream fileStream)
: base(contentType)
{
_fileStream = fileStream ?? throw new ArgumentNullException(nameof(fileStream));
}
/// <summary>
/// Allows you to set a suggested download name.
/// It will be sent in a “ContentDisposition” header.
/// </summary>
public new string? FileDownloadName { get; set; }
public override async Task ExecuteResultAsync(ActionContext context)
{
var response = context.HttpContext.Response;
if (!string.IsNullOrEmpty(FileDownloadName))
{
// Typical “attachment” disposition most browsers honour it.
response.Headers.Append("Content-Disposition",
$"attachment; filename=\"{FileDownloadName}\"");
}
// The requestaborted token tells the stream copy to stop ASAP
var cancellationToken = context.HttpContext.RequestAborted;
try
{
// Copy the file to the response body in 8KiB chunks
await _fileStream.CopyToAsync(
response.Body,
bufferSize: 81920, // 80KiB default for Stream.CopyToAsync
cancellationToken);
}
catch (OperationCanceledException)
{
// The client disconnected nothing to do.
// Swallowing keeps the API from returning a 500 error.
}
finally
{
await _fileStream.DisposeAsync();
}
}
}
@@ -0,0 +1,197 @@
using Microsoft.AspNetCore.Mvc;
using SharpCompress.Readers;
using TinfoilVibeServer.Models;
using TinfoilVibeServer.Services;
using TinfoilVibeServer.Utilities;
namespace TinfoilVibeServer.Controllers;
[ApiController]
[Route("/")]
public sealed class IndexController(ISnapshotService snapshotService, IndexBuilderService indexBuilderService) : ControllerBase
{
// ------------------------------------------------------------
// GET /
// ------------------------------------------------------------
/// <summary>
/// Return the “index snapshot” e.g. a JSON file that lists
/// every available resource. The snapshot could also be a
/// rendered Razor view simply change the return type.
/// </summary>
public IActionResult Index()
{
if (HttpContext.Request.Headers.CacheControl == "no-cache")
{
indexBuilderService.InvalidateIndex(this, EventArgs.Empty);
}
var index = indexBuilderService.Build(HttpContext);
return Ok(index);
}
// ------------------------------------------------------------
// GET /{*path}
// ------------------------------------------------------------
/// <summary>
/// Catchall action. Any URL that is **not** “/” is routed
/// here. The value of *path* is the relative location you
/// want to stream back to the client.
/// </summary>
/// <param name="path">The relative file path requested.</param>
[HttpGet("{*path}")]
public async Task<IActionResult> Download(string path)
{
if (string.IsNullOrWhiteSpace(path))
return BadRequest("Missing url query parameter.");
// ---- 1️⃣ Parse the brackets --------------------------------
// Expected format: [name][TitleId][v][patchOrApp].nsp
var match = System.Text.RegularExpressions.Regex.Match(path,
@"(?<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);
if (!match.Success)
return BadRequest("Url does not match the expected pattern.");
var titleId = match.Groups["id"].Value.ToUpperInvariant();
// ---- 2️⃣ Find the file that contains this TitleId ------------
var entry = snapshotService.GetSnapshot().Files
.FirstOrDefault(e => { return e.Titles.FirstOrDefault(hash => hash.TitleId == titleId)?.TitleId == titleId; });
if (entry == null)
return NotFound("No file with that TitleId found.");
// ---- 3️⃣ If the file is a normal NSP → send it ----------------
if (Path.GetExtension(entry.Path).Equals(".nsp", StringComparison.OrdinalIgnoreCase)
&& !entry.Path.Contains(snapshotService.GetArchivePathSeparator()))
{
if (System.IO.File.Exists(entry.Path))
{
// 1️⃣ Open the file for async read
var fileStream = new FileStream(
entry.Path,
FileMode.Open,
FileAccess.Read,
FileShare.Read,
bufferSize: 128 * 1024 * 1024, // 81920, // 80 KiB
useAsync: true); // <‑‑ VERY important for scalability
// 2️⃣ Return a cancellationaware result
return new CancelableFileResult(
contentType: "application/octet-stream",
fileStream: fileStream)
{
FileDownloadName = Path.GetFileName(entry.Path) // optional but nice
};
}
}
// 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.");
}
/// <summary>
/// Very lightweight helper decides whether the file path
/// represents a file inside an archive.
/// </summary>
private bool IsInsideArchive(string path)
{
// 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"
// would be inside an archive). For simplicity we only check
// for common archive extensions.
var filePath = path.Split(snapshotService.GetArchivePathSeparator()).First();
return filePath.EndsWith(".zip", StringComparison.OrdinalIgnoreCase) ||
filePath.EndsWith(".7z", StringComparison.OrdinalIgnoreCase) ||
filePath.EndsWith(".rar", StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// If the NSP is inside an archive, this method opens the archive
/// and returns the entry stream. It is deliberately minimal
/// if the archive cant be opened we return null.
/// </summary>
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.
// Only the 3 archive types we support are handled.
try
{
// Check which archive type
if (archivePath.EndsWith(".zip", StringComparison.OrdinalIgnoreCase))
{
var zip = SharpCompress.Archives.Zip.ZipArchive.Open(archivePath, new ReaderOptions { LeaveStreamOpen = true });
streamContainer = zip;
var entry = zip.Entries
.FirstOrDefault(e => e.Key != null && e.Key.Equals(innerFileName,
StringComparison.OrdinalIgnoreCase));
if (entry != null)
return entry.OpenEntryStream();
}
else if (archivePath.EndsWith(".7z", StringComparison.OrdinalIgnoreCase))
{
var sevenZip = SharpCompress.Archives.SevenZip.SevenZipArchive.Open(archivePath, new ReaderOptions { LeaveStreamOpen = true });
streamContainer = sevenZip;
var entry = sevenZip.Entries
.FirstOrDefault(e => e.Key != null && e.Key.Equals(innerFileName,
StringComparison.OrdinalIgnoreCase));
if (entry != null)
return entry.OpenEntryStream();
}
else if (archivePath.EndsWith(".rar", StringComparison.OrdinalIgnoreCase))
{
var rar = SharpCompress.Archives.Rar.RarArchive.Open(archivePath, new ReaderOptions { LeaveStreamOpen = true });
streamContainer = rar;
var entry = rar.Entries
.FirstOrDefault(e => e.Key != null && e.Key.Equals(innerFileName,
StringComparison.OrdinalIgnoreCase));
if (entry != null)
return entry.OpenEntryStream();
}
}
catch
{
// ignore we will just return null and the controller
// will respond with 404.
}
streamContainer = null;
return null;
}
}
@@ -1,32 +1,30 @@
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using System.Text;
using TinfoilVibeServer.Services;
using System.Text;
using TinfoilVibeServer.Authentication;
namespace TinfoilVibeServer.Middleware;
public sealed class BasicAuthMiddleware
/// <summary>
/// Minimal BasicAuth middleware that also checks UID, failure counters, and a blacklist.
/// </summary>
public sealed class BasicAuthMiddleware(RequestDelegate next)
{
private readonly RequestDelegate _next;
private readonly AuthStore _store;
private readonly ILogger<BasicAuthMiddleware> _logger;
public BasicAuthMiddleware(RequestDelegate next, AuthStore store, ILogger<BasicAuthMiddleware> logger)
{
_next = next;
_store = store;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
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);
var ip = context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
// 1) IP blacklist
if (_store.IsBlacklisted(ip))
if (store.IsIPBlacklisted(ip))
{
_logger.LogWarning("Blocked request from blacklisted IP {IP}", ip);
logger.LogWarning("Blocked request from blacklisted IP {IP}", ip);
context.Response.StatusCode = StatusCodes.Status403Forbidden;
await context.Response.WriteAsync("Forbidden");
return;
@@ -35,7 +33,9 @@ public sealed class BasicAuthMiddleware
// 2) Authorization header
if (!context.Request.Headers.TryGetValue("Authorization", out var authHeaders))
{
logger.LogWarning("Missing Authorization header from {IP}", ip);
Challenge(context);
logger.LogTrace("Sent 401 challenge to client");
return;
}
@@ -43,18 +43,20 @@ public sealed class BasicAuthMiddleware
if (!authHeader.StartsWith("Basic ", StringComparison.OrdinalIgnoreCase))
{
Challenge(context);
logger.LogTrace("Sent 401 challenge to client");
return;
}
string decoded;
try
{
var b64 = authHeader[6..].Trim();
var b64 = authHeader.Substring(6).Trim();
decoded = Encoding.UTF8.GetString(Convert.FromBase64String(b64));
}
catch
{
Challenge(context);
logger.LogInformation("Sent 401 challenge to client");
return;
}
@@ -62,6 +64,7 @@ public sealed class BasicAuthMiddleware
if (parts.Length != 2)
{
Challenge(context);
logger.LogInformation("Sent 401 challenge to client");
return;
}
@@ -69,32 +72,29 @@ public sealed class BasicAuthMiddleware
var password = parts[1];
// 3) UID header (optional)
int? uid = null;
string? uid = null;
if (context.Request.Headers.TryGetValue("UID", out var uidHeader))
{
if (int.TryParse(uidHeader.ToString(), out var parsedUid))
uid = parsedUid;
}
uid = uidHeader.FirstOrDefault();
// 4) Validate
if (!_store.TryValidate(username, password, uid, ip, out var error))
if (!store.TryValidate(username, password, uid, ip, out var error))
{
_logger.LogWarning("Auth failed for user {User} from {IP}: {Error}", username, ip, error);
logger.LogWarning("Auth failed for user {User} from {IP}: {Error}", username, ip, error);
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
context.Response.Headers.Add("WWW-Authenticate", "Basic realm=\"FileSnapshot\"");
context.Response.Headers.Append("WWW-Authenticate", "Basic realm=\"FileSnapshot\"");
await context.Response.WriteAsync(error ?? "Unauthorized");
return;
}
// Authentication succeeded attach username for downstream handlers if needed
context.Items["User"] = username;
await _next(context);
logger.LogInformation("User {User} authenticated successfully (UID={UID})", username, uid);
await next(context);
}
private static void Challenge(HttpContext ctx)
{
ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;
ctx.Response.Headers.Add("WWW-Authenticate", "Basic realm=\"FileSnapshot\"");
ctx.Response.Headers.Append("WWW-Authenticate", "Basic realm=\"FileSnapshot\"");
}
}
+14
View File
@@ -0,0 +1,14 @@
namespace TinfoilVibeServer.Models;
/// <summary>
/// Toplevel configuration maps directly to appsettings.json.
/// </summary>
public sealed record AppSettings(
string[] RootDirectories,
string[] WhitelistExtensions,
string[] RomExtensions,
string CredentialsFile,
string FingerprintsFile,
string BlacklistFile,
int MaxFailedAttempts
);
+8 -5
View File
@@ -1,11 +1,14 @@
namespace TinfoilVibeServer.Models;
using System.Collections.Generic;
using TinfoilVibeServer.Services;
namespace TinfoilVibeServer.Models;
/// <summary>
/// One line in the snapshot the JSON will be an array of these.
/// </summary>
public sealed record FileEntry(
string Path,
long Size,
string Hash, // SHA256 hex
TitleInfo? Title // null unless file is an NSP/XCI or an archive containing one
string Path, // nsp or archive path
long Size, // size of nsp or full archive
string? Hash, // SHA256 hex of first NCA of first NCP in NSP or archive
List<NcaMetadataWithHash> Titles // Details of all NSP Roms in the Path
);
@@ -0,0 +1,10 @@
namespace TinfoilVibeServer.Models;
public class IndexBuilderSettings
{
public string CacheFilePath { get; set; } = "indexcache.json";
public string ApiBaseUrl { get; set; } = "http://tinfoil.localhost";
public ICollection<string>? IndexDirectories { get; set; }
public string? LoginMessage { get; set; }
}
+15
View File
@@ -0,0 +1,15 @@
using System.Collections.Generic;
namespace TinfoilVibeServer.Models;
/// <summary>
/// The JSON object that will be returned for the “index” route.
/// </summary>
public sealed record IndexDto(
List<FileDto> Files,
List<string> Directories,
string Success);
public sealed record FileDto(
string Url,
long Size);
+12
View File
@@ -0,0 +1,12 @@
using LibHac.Common.Keys;
namespace TinfoilVibeServer.Models;
/// <summary>
/// A tiny static holder that contains the KeySet loaded from disk.
/// All parts of the application that need keys just read this property.
/// </summary>
public static class KeySetHolder
{
public static KeySet KeySet { get; set; } = new KeySet();
}
@@ -0,0 +1,11 @@
namespace TinfoilVibeServer.Models;
/// <summary>
/// DTO that is returned by the extractor.
/// </summary>
public sealed record NcaMetadataDto(
string TitleId, // 16digit hex, e.g. 0004000000000000
int Version, // header version 0 = application, >0 = patch
bool IsApplication, // true if the NSP is an application
bool IsPatch // true if the NSP is a patch
);
@@ -0,0 +1,26 @@
using LibHac.Ncm;
namespace TinfoilVibeServer.Models;
/// <summary>
/// DTO returned by the extractor contains all data the snapshot needs.
/// </summary>
public sealed class NcaMetadataWithHash
{
public string TitleId { get; }
public string ApplicationTitle { get; set; }
public int Version { get; }
public ContentMetaType ContentMetaType { get; set; }
public string? Hash { get; }
public NcaMetadataWithHash(string titleId, string applicationTitle, int version,
ContentMetaType contentMetaType, string? hash = null)
{
TitleId = titleId;
ApplicationTitle = applicationTitle;
Version = version;
ContentMetaType = contentMetaType;
Hash = hash;
}
}
+125
View File
@@ -0,0 +1,125 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Linq;
namespace TinfoilVibeServer.Models;
public sealed class SnapshotOptions : INotifyPropertyChanged
{
private List<string> _rootDirectories = [];
public List<string> RootDirectories
{
get => _rootDirectories;
set
{
if (_rootDirectories.Except(value) == Array.Empty<string>()) return;
_rootDirectories = value;
OnPropertyChanged(nameof(RootDirectories));
}
}
private List<string> _archiveExtensions = new();
public List<string> ArchiveExtensions
{
get => _archiveExtensions;
set
{
if (_archiveExtensions.Except(value) == Array.Empty<string>()) return;
_archiveExtensions = value;
OnPropertyChanged(nameof(_archiveExtensions));
}
}
private List<string> _romExtensions = new();
[Required(ErrorMessage = "No ROM extension specified")]
public List<string> RomExtensions
{
get => _romExtensions;
set
{
if (_romExtensions != value)
{
_romExtensions = value;
OnPropertyChanged(nameof(_romExtensions));
}
}
}
private TimeSpan _cacheTtl = TimeSpan.FromHours(1);
public TimeSpan CacheTtl
{
get => _cacheTtl;
set
{
if (_cacheTtl != value)
{
_cacheTtl = value;
OnPropertyChanged(nameof(CacheTtl));
}
}
}
private string _snapshotFile = "snapshot.json";
public string SnapshotFile
{
get => _snapshotFile;
set
{
if (string.Equals(_snapshotFile, value, StringComparison.InvariantCultureIgnoreCase)) return;
_snapshotFile = value;
OnPropertyChanged(nameof(SnapshotFile));
}
}
private string _snapshotBackupFile = "snapshot.bak";
public string SnapshotBackupFile
{
get => _snapshotBackupFile;
set
{
if (string.Equals(_snapshotBackupFile, value, StringComparison.InvariantCultureIgnoreCase)) return;
_snapshotBackupFile = value;
OnPropertyChanged(nameof(SnapshotBackupFile));
}
}
#region FileSystemWatcher options
/// <summary>
/// How many times to retry when the snapshot file is locked.
/// </summary>
public int MaxRetryCount { get; set; } = 3;
/// <summary>
/// Base debounce timeout (ms) used by the filewatcher.
/// The retry interval will be this value multiplied by <see cref="RetryMultiplier"/>.
/// </summary>
public int DebounceTimeoutMs { get; set; } = 500; // existing value keep it
/// <summary>
/// Multiplier used to compute the retry wait time: `wait = DebounceTimeoutMs * RetryMultiplier`.
/// </summary>
public double RetryMultiplier { get; set; } = 1.5;
/// <summary>
/// Optional custom path to the snapshot file; the service will try to write there.
/// </summary>
public string SnapshotFilePath { get; set; } = "snapshots/latest.snapshot";
#endregion
public event PropertyChangedEventHandler? PropertyChanged;
private void OnPropertyChanged(string propertyName) =>
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
@@ -0,0 +1,8 @@
namespace TinfoilVibeServer.Models;
public class TitleDbOptions
{
public int TtlSeconds { get; set; } = 60; // fallback
public string LanguageCode { get; set; } = "en";
public string CountryCode { get; set; } = "US";
}
+2 -2
View File
@@ -1,11 +1,11 @@
namespace TinfoilVibeServer.Models;
/// <summary>
/// Metadata extracted from a NSP/XCI archive.
/// Metadata extracted from a Nintendo NSP/XCI archive.
/// </summary>
public sealed record TitleInfo(
string TitleId, // e.g. 0004000000000000
string Name, // title name
string Version, // e.g. 1.02
bool IsApplication // true for applications, false for patches
bool IsApplication // true for applications, false for patches
);
+14
View File
@@ -0,0 +1,14 @@
namespace TinfoilVibeServer.Models;
/// <summary>
/// One entry that is read from the JSON files on GitHub and can also be
/// constructed from an NSP. The key for the dictionary that stores these
/// objects is <see cref="TitleId"/>.
/// </summary>
public sealed record TitleInfoDto(
string TitleId, // 16digit hex “0004000000000000”
string Name,
string Id,
int? ReleaseDate,
long NSUID,
string Version);
+94 -28
View File
@@ -1,42 +1,108 @@
using System;
using System.IO;
using System.Linq;
using System.Text.Json;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using TinfoilVibeServer.Authentication;
using TinfoilVibeServer.Middleware;
using TinfoilVibeServer.Services;
using TinfoilVibeServer.Models;
var builder = WebApplication.CreateBuilder(args);
builder.Logging.ClearProviders();
builder.Logging.AddConsole();
builder.Logging.AddDebug();
// -----------------------------------------------------
// 1. Register AuthStore as a singleton
// -----------------------------------------------------
builder.Services.AddSingleton<AuthStore>();
builder.Services.AddMemoryCache();
var dataRoot = builder.Configuration["CONFIG_ROOT"] ?? "/app/config/";
// 1️⃣ Load the embedded default
var defaultResource = typeof(Program).Assembly
.GetManifestResourceStream("TinfoilVibeServer.appsettings.default.json")!; // adjust namespace
var defaultConfig = JsonDocument.Parse(defaultResource).RootElement;
// -----------------------------------------------------
// 2. Snapshot + other services (unchanged)
// -----------------------------------------------------
// 2️⃣ Try to write the file if it doesn't exist
var configPath = Path.Combine(dataRoot, "appsettings.json");
if (!File.Exists(configPath))
{
// write the embedded JSON straight to disk
try
{
File.WriteAllText(configPath, defaultConfig.GetRawText());
}
catch (Exception e)
{
var tempFactory = LoggerFactory.Create(loggingBuilder =>
{
loggingBuilder.AddConsole();
loggingBuilder.AddDebug();
});
var logger = tempFactory.CreateLogger<Program>();
logger.LogError(e, "Failed to write default config file");
}
}
var config = new ConfigurationBuilder()
.AddJsonFile(Path.Combine(dataRoot,"appsettings.json"), optional: false, reloadOnChange: true)
.AddJsonFile(Path.Combine(dataRoot,$"appsettings.{builder.Environment.EnvironmentName}.json"), optional: true, reloadOnChange: true)
.AddJsonFile(Path.Combine(dataRoot,$"appsettings.{builder.Environment.EnvironmentName}.local.json"), optional: true, reloadOnChange: true)
.AddEnvironmentVariables()
.AddCommandLine(args).Build();
builder.Configuration.AddConfiguration(config);
builder.Services.AddOptions();
builder.Services.Configure<TitleDbOptions>(builder.Configuration.GetSection("TitleDb"));
builder.Services.Configure<NSPExtractorOptions>(builder.Configuration.GetSection("NSPExtractor"));
builder.Services.Configure<AuthSettings>(builder.Configuration.GetSection("AuthSettings"));
builder.Services.Configure<IndexBuilderSettings>(builder.Configuration.GetSection("IndexBuilder"));
builder.Services.AddOptions<SnapshotOptions>().Bind(builder.Configuration.GetSection("Snapshot")).ValidateOnStart();
builder.Services.AddSingleton<ConfigManager>();
builder.Services.AddSingleton<INSPExtractor, NSPExtractor>();
builder.Services.AddSingleton<SnapshotService>();
// … any other services you already have
builder.Services.AddSingleton<ISnapshotService, SnapshotService>(sp => sp.GetRequiredService<SnapshotService>());
builder.Services.AddSingleton<IAuthStore, AuthStore>();
builder.Services.AddSingleton<TitleDatabaseService>();
builder.Services.AddSingleton<IArchiveHandler, ArchiveHandler>();
builder.Services.AddSingleton<IndexBuilderService>();
builder.Services.AddHostedService<IndexBuilderService>(provider => provider.GetRequiredService<IndexBuilderService>());
builder.Services.AddHostedService<SnapshotService>(provider => provider.GetRequiredService<SnapshotService>());
builder.Services.AddHostedService<TitleDatabaseService>(provider => provider.GetRequiredService<TitleDatabaseService>()).AddHttpClient();
builder.Services.AddControllers(); // add MVC
// -------------------------------------------------------------------
// 2) Middleware BasicAuth (verifies username, password, UID, blacklist)
// -------------------------------------------------------------------
var app = builder.Build();
// -----------------------------------------------------
// 3. Apply authentication middleware *before* the
// snapshot endpoints. This guarantees all routes
// are protected.
// -----------------------------------------------------
app.UseMiddleware<BasicAuthMiddleware>();
app.MapControllers(); // routes the /index.json & /download endpoints
// -----------------------------------------------------
// 4. Existing endpoints unchanged
// -----------------------------------------------------
app.MapGet("/", () => Results.Redirect("/index.tfl"));
app.MapGet("/index.tfl", async context =>
{
var jsonPath = Path.Combine(AppContext.BaseDirectory, "index.tfl");
context.Response.ContentType = "application/json";
await context.Response.WriteAsync(await File.ReadAllTextAsync(jsonPath));
});
app.MapGet("/debug", () => new SnapshotService(builder.Configuration).GetSnapshot());
app.MapGet("/stream/{*relativePath}", async context =>
{
// … (unchanged streaming logic same as before)
});
// -------------------------------------------------------------------
// 3) Endpoints
// -------------------------------------------------------------------
app.MapGet("/debug", () => new SnapshotService(
app.Services.GetRequiredService<IMemoryCache>(),
app.Services.GetRequiredService<IOptionsMonitor<SnapshotOptions>>(),
app.Services.GetRequiredService<INSPExtractor>(),
app.Services.GetRequiredService<IArchiveHandler>(),
app.Services.GetRequiredService<ILogger<SnapshotService>>(), app.Services.GetRequiredService<IHostEnvironment>())
.GetSnapshot());
app.Lifetime.ApplicationStarted.Register(() =>
app.Services.GetRequiredService<ILogger<Program>>().LogInformation("Application started. Listening on {Urls}", string.Join(", ", app.Urls)));
app.Use(async (ctx, next) =>
{
var logger = app.Services.GetRequiredService<ILogger<Program>>();
var correlationId = ctx.Request.Headers["X-Correlation-ID"].FirstOrDefault()
?? Guid.NewGuid().ToString();
using (logger.BeginScope("CorrelationId:{CorrelationId}", correlationId))
{
await next();
}
});
app.Run();
@@ -5,9 +5,10 @@
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5253",
"applicationUrl": "http://192.168.1.145:8080;http://tinfoil.localhost:8081;http://tinfoil.ecenshu.net",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
"ASPNETCORE_ENVIRONMENT": "Development",
"CONFIG_ROOT": "./config/"
}
},
"https": {
+125 -56
View File
@@ -1,27 +1,53 @@
using System.IO.Compression;
using FileSnapshot;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Microsoft.Extensions.Logging;
using SharpCompress.Archives;
using SharpCompress.Archives.Zip;
using SharpCompress.Archives.Rar;
using SharpCompress.Archives.SevenZip;
using SharpCompress.Readers;
using SharpCompress.Common;
using TinfoilVibeServer.Models;
using TinfoilVibeServer.Utilities;
using ZipArchive = SharpCompress.Archives.Zip.ZipArchive;
namespace TinfoilVibeServer.Services;
/// <summary>
/// Tries to open a file as an archive and look for an embedded NSP/XCI.
/// The goal is to be as memoryfriendly as possible never load a whole archive into RAM.
/// </summary>
public sealed class ArchiveHandler
public interface IArchiveHandler
{
/// <summary>
/// Return TitleInfo if an embedded Nintendo archive is found; otherwise null.
/// </summary>
public static TitleInfo? 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>
/// Tries to open a file as an archive and look for an embedded NSP/XCI.
/// The extractor is injected so that the hash of the first stream can be accessed
/// while the file is being read.
/// </summary>
public sealed class ArchiveHandler : IArchiveHandler
{
private readonly INSPExtractor _nspExtractor;
private readonly ILogger<ArchiveHandler> _logger;
public ArchiveHandler(INSPExtractor nspExtractor, ILogger<ArchiveHandler> logger)
{
_nspExtractor = nspExtractor;
_logger = logger;
}
/// <summary>
/// Return TitleInfo if an embedded Nintendo archive is found; otherwise null.
/// </summary>
public IEnumerable<(string, long, NcaMetadataWithHash)> TryExtractTitleInfos(string filePath)
{
_logger.LogInformation("Examining archive {File} for embedded NSP", filePath);
var ext = Path.GetExtension(filePath).ToLowerInvariant();
try
@@ -35,91 +61,134 @@ public sealed class ArchiveHandler
case ".rar":
return HandleRar(filePath);
default:
return null;
{
_logger.LogWarning("Unsupported archive type {Extension} skipping", ext);
return [];
}
}
}
catch
catch (Exception ex)
{
_logger.LogError("Error opening archive {File}: {Exception}", filePath, ex.Message);
// Graceful fallback return null
return null;
return [];
}
}
private static TitleInfo? 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);
foreach (var entry in archive.Entries)
{
if (!entry.IsDirectory && IsRomArchive(entry.Key))
{
var temp = Path.GetTempFileName();
entry.WriteToFile(temp);
var title = NSPExtactor.ExtractFromFile(temp);
File.Delete(temp);
return title;
}
if (entry.IsDirectory || entry.Key == null || !IsRomArchive(entry.Key)) continue;
var temp = Path.GetTempFileName();
entry.WriteToFile(temp);
var title = _nspExtractor.ExtractFromFile(temp); // instance call
File.Delete(temp);
if (title != null) yield return (entry.Key, entry.Size, title);
}
return null;
}
private static TitleInfo? Handle7z(string path)
private IEnumerable<(string, long, NcaMetadataWithHash)> Handle7z(string path)
{
using var archive = SevenZipArchive.Open(path);
foreach (var entry in archive.Entries)
{
if (!entry.IsDirectory && IsRomArchive(entry.Key))
if (!entry.IsDirectory && entry.Key != null && IsRomArchive(entry.Key))
{
var temp = Path.GetTempFileName();
entry.WriteToFile(temp);
var title = NSPExtactor.ExtractFromFile(temp);
var title = _nspExtractor.ExtractFromFile(temp); // instance call
File.Delete(temp);
return title;
if (title != null) yield return (entry.Key, entry.Size, title);
}
}
return null;
}
private static TitleInfo? HandleRar(string path)
private IEnumerable<(string, long, NcaMetadataWithHash)> HandleRar(string path)
{
// SharpCompress can handle most RAR5 files fallback to SharpSevenZip if it fails
var titles = new List<(string, long, NcaMetadataWithHash)>();
var entryCount = 0;
try
{
using var archive = RarArchive.Open(path);
using var romArchive = new RomArchiveReader(path);
var archiveEntries = romArchive.GetEntries().ToList();
//using var archive = RarArchive.Open(path);
entryCount = archiveEntries.Count;
foreach (var archiveEntry in archiveEntries)
{
var archiveEntryName = archiveEntry.Name;
try
{
Stream? wrapper = null;
wrapper = new RewindableStream(archiveEntry.Stream, () =>
{
_logger.LogDebug("Rewinding archive entry {ArchiveEntry}", archiveEntryName);
return romArchive.GetEntries().First(romArchiveEntry => romArchiveEntry.Name == archiveEntryName).Stream;
},10*1024*1024, archiveEntry.Stream.Length);
//wrapper = new SeekableBufferedStream(archiveEntry.Stream, archiveEntry.Stream.Length, 10*1024*1024, true);
var title = _nspExtractor.ExtractFromStream(wrapper);
if (title != null)
{
titles.Add((archiveEntry.Name, archiveEntry.Stream.Length, title));
}
wrapper?.Dispose();
}
catch (IncompleteArchiveException incompleteArchiveException)
{
_logger.LogWarning("Incomplete archive {Archive}: {Exception}", path, incompleteArchiveException.Message);
}
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.IsDirectory && IsRomArchive(entry.Key))
if (entry is { IsDirectory: false, Key: not null } && IsRomArchive(entry.Key))
{
var temp = Path.GetTempFileName();
entry.WriteToFile(temp);
var title = NSPExtactor.ExtractFromFile(temp);
var title = _nspExtractor.ExtractFromFile(temp); // instance call
File.Delete(temp);
return title;
if (title != null) titles.Add((entry.Key, entry.Size, title));
}
}
return null;
}
catch (SharpCompress.Common.ArchiveException)
{
// Fallback to SharpSevenZip for RAR5
/*using var stream = File.OpenRead(path);
Stream outStream = new Mem;
using var extractor = SharpSevenZip.SharpSevenZipExtractor.DecompressStream(stream, outStream);
while (extractor.MoveToNextEntry())
{
if (!extractor.IsDirectory && IsRomArchive(extractor.CurrentFileName))
{
var temp = Path.GetTempFileName();
extractor.ExtractFile(temp);
var title = NSPExtactor.ExtractFromFile(temp);
File.Delete(temp);
return title;
}
}*/
return null;
}
return titles;
}
private static bool IsRomArchive(string entryName)
private bool IsRomArchive(string entryName)
{
var ext = Path.GetExtension(entryName).ToLowerInvariant();
return ext is ".xci" or ".nsp" or ".xcz";
-211
View File
@@ -1,211 +0,0 @@
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace TinfoilVibeServer.Services;
/// <summary>
/// Configuration section used by the auth system.
/// </summary>
public sealed class AuthSettings
{
public string CredentialsFile { get; init; } = "credentials.json";
public string FingerprintsFile { get; init; } = "fingerprints.json";
public string BlacklistFile { get; init; } = "blacklist.json";
public int MaxFailedAttempts { get; init; } = 5;
}
/// <summary>
/// One user record stored in *credentials.json*.
/// </summary>
public sealed record Credential(
string Username,
string PasswordHash, // SHA256 hex
int AllowedUidCount = 1,
bool Verified = true); // new flag defaults to true for preexisting users
/// <summary>
/// Threadsafe singleton that keeps the authentication state in memory
/// and writes it back to disk whenever it changes.
/// </summary>
public sealed class AuthStore
{
private readonly AuthSettings _settings;
private readonly object _sync = new();
// Inmemory state
public ConcurrentDictionary<string, Credential> Credentials { get; } = new();
public ConcurrentDictionary<string, List<int>> Fingerprints { get; } = new();
public ConcurrentDictionary<string, int> FailedAttempts { get; } = new();
public HashSet<string> BlacklistIPs { get; } = new();
public AuthStore(IConfiguration config)
{
_settings = new AuthSettings
{
CredentialsFile = config.GetValue<string>("Authentication:CredentialsFile") ?? "credentials.json",
FingerprintsFile = config.GetValue<string>("Authentication:FingerprintsFile") ?? "fingerprints.json",
BlacklistFile = config.GetValue<string>("Authentication:BlacklistFile") ?? "blacklist.json",
MaxFailedAttempts = config.GetValue<int>("Authentication:MaxFailedAttempts", 5)
};
LoadAll();
}
#region Loading / Persisting
private void LoadAll()
{
// Load credentials
if (File.Exists(_settings.CredentialsFile))
{
var txt = File.ReadAllText(_settings.CredentialsFile);
var dict = JsonSerializer.Deserialize<Dictionary<string, Credential>>(txt)!;
foreach (var kv in dict)
Credentials[kv.Key] = kv.Value;
}
// Load fingerprints
if (File.Exists(_settings.FingerprintsFile))
{
var txt = File.ReadAllText(_settings.FingerprintsFile);
var dict = JsonSerializer.Deserialize<Dictionary<string, List<int>>>(txt)!;
foreach (var kv in dict)
Fingerprints[kv.Key] = kv.Value;
}
// Load blacklist
if (File.Exists(_settings.BlacklistFile))
{
var txt = File.ReadAllText(_settings.BlacklistFile);
var arr = JsonSerializer.Deserialize<string[]>(txt)!;
foreach (var ip in arr)
BlacklistIPs.Add(ip);
}
}
private void PersistCredentials()
{
var json = JsonSerializer.Serialize(Credentials, new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(_settings.CredentialsFile, json);
}
private void PersistFingerprints()
{
var json = JsonSerializer.Serialize(Fingerprints, new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(_settings.FingerprintsFile, json);
}
private void PersistBlacklist()
{
var json = JsonSerializer.Serialize(BlacklistIPs.ToArray(), new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(_settings.BlacklistFile, json);
}
#endregion
public bool IsBlacklisted(string ip) => BlacklistIPs.Contains(ip);
/// <summary>
/// Validates username/password/UID, updates fingerprints and blacklists as needed.
/// </summary>
/// <returns>true if the user is authenticated; otherwise false.</returns>
public bool TryValidate(string username, string password, int? uid, string ip, out string? error)
{
error = null;
lock (_sync)
{
// 1) User existence create onthefly if missing
if (!Credentials.TryGetValue(username, out var cred))
{
// Create a *new* user that is not yet verified
cred = new Credential(username, ComputeHash(password), 1, Verified: false);
Credentials[username] = cred;
PersistCredentials();
// Create empty fingerprint list (or preadd the first UID)
var list = Fingerprints.GetOrAdd(username, _ => new List<int>());
if (uid.HasValue && !list.Contains(uid.Value))
{
if (list.Count < cred.AllowedUidCount)
list.Add(uid.Value);
PersistFingerprints();
}
error = "User not verified";
IncrementFailed(username, ip);
return false;
}
// 2) Password check
if (!VerifyPasswordHash(password, cred.PasswordHash))
{
error = "Invalid password";
IncrementFailed(username, ip);
return false;
}
// 3) Verify flag only verified users can pass
if (!cred.Verified)
{
error = "User not verified";
IncrementFailed(username, ip);
return false;
}
// 4) UID handling
if (uid.HasValue)
{
var list = Fingerprints.GetOrAdd(username, _ => new List<int>());
if (!list.Contains(uid.Value))
{
if (list.Count < cred.AllowedUidCount)
{
list.Add(uid.Value);
PersistFingerprints();
}
else
{
error = $"UID limit ({cred.AllowedUidCount}) exceeded";
IncrementFailed(username, ip);
return false;
}
}
}
// 5) Success reset counter
FailedAttempts[username] = 0;
return true;
}
}
private void IncrementFailed(string username, string ip)
{
var newCount = FailedAttempts.GetOrAdd(username, 0) + 1;
FailedAttempts[username] = newCount;
if (newCount >= _settings.MaxFailedAttempts)
{
BlacklistIPs.Add(ip);
PersistBlacklist();
// reset counter for the next session
FailedAttempts[username] = 0;
}
}
#region Helpers
public static string ComputeHash(string input)
{
using var sha = SHA256.Create();
var hash = sha.ComputeHash(Encoding.UTF8.GetBytes(input));
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
}
private static bool VerifyPasswordHash(string plain, string storedHash)
=> string.Equals(ComputeHash(plain), storedHash, StringComparison.OrdinalIgnoreCase);
#endregion
}
@@ -0,0 +1,66 @@
using System;
using System.IO;
using System.Text.Json;
using System.Threading;
using LibHac.Common.Keys;
using TinfoilVibeServer.Models;
namespace TinfoilVibeServer.Services;
/// <summary>
/// Reads the JSON config file on startup, watches it for changes, and also
/// loads the KeySet from the file specified in the config.
/// </summary>
public class ConfigManager
{
public AppSettings? Settings { get; private set; }
public event Action<AppSettings?>? OnChange;
private readonly string _configPath;
private readonly FileSystemWatcher _watcher;
private readonly Lock _sync = new();
public ConfigManager()
{
_configPath = Path.Combine(AppContext.BaseDirectory, "config", "appsettings.json");
Load();
_watcher = new FileSystemWatcher
{
Path = Path.GetDirectoryName(_configPath)!,
Filter = Path.GetFileName(_configPath),
NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size | NotifyFilters.Attributes
};
_watcher.Changed += (_, _) => Reload();
_watcher.EnableRaisingEvents = true;
}
private void Load()
{
if (!File.Exists(_configPath))
{
Settings = new AppSettings(
RootDirectories: [],
WhitelistExtensions: [],
RomExtensions: [],
CredentialsFile: "credentials.json",
FingerprintsFile: "fingerprints.json",
BlacklistFile: "blacklist.json",
MaxFailedAttempts: 5
);
return;
}
var txt = File.ReadAllText(_configPath);
Settings = JsonSerializer.Deserialize<AppSettings>(txt, new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
}
private void Reload()
{
lock (_sync)
{
Load();
OnChange?.Invoke(Settings);
}
}
}
@@ -0,0 +1,201 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using LibHac.Ncm;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using TinfoilVibeServer.Models;
namespace TinfoilVibeServer.Services;
// File: Services/IndexBuilderService.cs
// *** NEW ***
public sealed class IndexBuilderService: IHostedService
{
private readonly IOptionsMonitor<IndexBuilderSettings> _options;
private readonly ISnapshotService _snapshotService;
private readonly TitleDatabaseService _titleDb;
private readonly ILogger<IndexBuilderService> _logger;
private readonly IHostEnvironment _hostEnvironment;
private readonly SemaphoreSlim _lock = new(1, 1);
public IndexBuilderService(
IOptionsMonitor<IndexBuilderSettings> options,
ISnapshotService snapshotService,
TitleDatabaseService titleDb,
ILogger<IndexBuilderService> logger,
IHostEnvironment hostEnvironment)
{
_options = options;
_snapshotService = snapshotService;
_titleDb = titleDb;
_logger = logger;
_hostEnvironment = hostEnvironment;
}
private static string DetermineCachePath(IOptionsMonitor<IndexBuilderSettings> options, IHostEnvironment environment)
{
if (Path.IsPathRooted(options.CurrentValue.CacheFilePath))
{
return options.CurrentValue.CacheFilePath;
}
return Path.Combine(environment.ContentRootPath, "data", options.CurrentValue.CacheFilePath);
}
public IndexDto Build(HttpContext httpContext)
{
// 1️⃣ Load cache if it exists
var cached = LoadCache();
var snapshot = _snapshotService.GetSnapshot();
if (string.IsNullOrEmpty(snapshot.Hash) || snapshot.Files.Count == 0)
{
_snapshotService.BuildSnapshotAsync();
snapshot = _snapshotService.GetSnapshot();
}
// 2️⃣ Rebuild only if the snapshot hash changed
string snapshotHash = ComputeSnapshotHash(snapshot.Files);
if (cached != null && cached.SnapshotHash == snapshotHash)
{
_logger.LogInformation("Using cached index (snapshot hash={Hash}", snapshotHash);
return cached.Index;
}
_logger.LogInformation("Building index (snapshot size={Count})", snapshot.Files.Count);
// 3️⃣ Build new index from snapshot entries
var files = ParseSnapshotFiles(snapshot, new Uri(httpContext.Request.Scheme + "://" + httpContext.Request.Host + httpContext.Request.PathBase));
var directories = _options.CurrentValue.IndexDirectories ?? Array.Empty<string>();
var success = _options.CurrentValue.LoginMessage ?? string.Empty;
var index = new IndexDto(files.SelectMany(inner => inner).ToList(), directories.ToList(), success);
// 4️⃣ Persist cache
PersistCache(snapshotHash, index);
return index;
}
private List<List<FileDto>> ParseSnapshotFiles(SnapshotService.ROMSnapshot snapshot, Uri baseUri)
{
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 || (title.ContentMetaType == ContentMetaType.Application && name == "Unknown"))
{
// 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 fileName = Uri.EscapeDataString($"{name}[{titleId}][v{versionNumberParsed}][{patchOrApp}].nsp");
var url = $"{baseUri.ToString().TrimEnd('/')}/{fileName}";
var isWellFormed = Uri.TryCreate(url, UriKind.Absolute, out var parsedUri);
if (isWellFormed && parsedUri != null)
{
fileDtos.Add(new FileDto(parsedUri.AbsoluteUri, e.Size));
}
else
{
_logger.LogWarning("Invalid URL for {TitleId}: {Url}", titleId, url);
}
}
return fileDtos;
})
.ToList();
return files;
}
private IndexCache? LoadCache()
{
var cachePath = DetermineCachePath(_options, _hostEnvironment);
if (!File.Exists(cachePath)) return null;
_lock.Wait();
var json = File.ReadAllText(cachePath);
_lock.Release();
_logger.LogInformation("Loaded index cache from {Path}", cachePath);
return JsonSerializer.Deserialize<IndexCache>(json);
}
private void PersistCache(string snapshotHash, IndexDto index)
{
var cachePath = DetermineCachePath(_options, _hostEnvironment);
var cache = new IndexCache(snapshotHash, index);
File.WriteAllText(cachePath, JsonSerializer.Serialize(cache, new JsonSerializerOptions{WriteIndented=true}));
}
public void InvalidateIndex(object? sender, EventArgs e)
{
var cachePath = DetermineCachePath(_options, _hostEnvironment);
if (!File.Exists(cachePath)) return;
File.Delete(cachePath);
_logger.LogInformation("Index cache cleared");
}
private static string ComputeSnapshotHash(IEnumerable<FileEntry> entries)
{
var json = JsonSerializer.Serialize(entries);
using var sha256 = SHA256.Create();
return BitConverter.ToString(sha256.ComputeHash(System.Text.Encoding.UTF8.GetBytes(json))).Replace("-", "").ToLowerInvariant();
}
// DTO for the cache file
private sealed record IndexCache(string SnapshotHash, IndexDto Index);
#region IHostedService
public Task StartAsync(CancellationToken cancellationToken)
{
var url = new Uri(_options.CurrentValue.ApiBaseUrl);
var host = url.Host;
Build(new DefaultHttpContext {HttpContext = { Request = { Host = new HostString(host), Scheme = url.Scheme, Path = new PathString(url.AbsolutePath)}}});
_snapshotService.SnapshotRebuilt += InvalidateIndex;
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken)
{
_snapshotService.SnapshotRebuilt -= InvalidateIndex;
return Task.CompletedTask; // nothing special to do on shutdown
}
#endregion
}
+337 -43
View File
@@ -1,65 +1,359 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text.Json;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using LibHac.Common;
using LibHac.Fs;
using LibHac.Fs.Fsa;
using LibHac.FsSystem;
using LibHac.FsSystem.Impl;
using LibHac.Util;
using LibHac.Tools.FsSystem;
using LibHac.Tools.FsSystem.NcaUtils;
using LibHac.Common.Keys;
using LibHac.Ncm;
using LibHac.Tools.Fs;
using LibHac.Tools.Ncm;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using TinfoilVibeServer.Models;
using TinfoilVibeServer.Utilities;
using NcaHeader = LibHac.Tools.FsSystem.NcaUtils.NcaHeader;
using Path = System.IO.Path;
namespace FileSnapshot;
/// <summary>
/// Extracts title information from a Nintendo NSP/XCI file using LibHac 0.20.0.
/// </summary>
public sealed class NSPExtactor
namespace TinfoilVibeServer.Services
{
/// <summary>
/// Return TitleInfo for the file, or null if the file is not a valid Nintendo archive.
/// </summary>
public static TitleInfo? ExtractFromFile(string filePath)
public interface INSPExtractor
{
// LibHac works with byte streams. We open the file once and hand the stream to RomArchiveReader.
try
/// <summary>
/// Public convenience wrapper that opens the file on disk.
/// </summary>
NcaMetadataWithHash? ExtractFromFile(string filePath);
/// <summary>
/// Core implementation works on any seekable stream that contains a full NSP/XCI container.
/// </summary>
NcaMetadataWithHash? ExtractFromStream(Stream stream);
string ExtractHashFromStream(Stream nspStream);
}
/// <summary>
/// Extracts the TitleId, version, type *and* the SHA256 of the first NCA stream.
/// </summary>
public sealed class NSPExtractor : INSPExtractor
{
private KeySet? _keySet;
public KeySet? KeySet
{
using var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
using var reader = new RomArchiveReader(fs, new RomArchiveSettings { UseCache = false });
if (!reader.IsValid)
return null; // Not an NSP/XCI
// The ROM contains one or more NCA headers. For most cases the first one is the title.
// LibHac exposes the *content* list we pick the first NCA that is a title.
foreach (var nca in reader.GetContentInfos())
get
{
// NcaId.Type gives Application / Patch / DLC etc.
// We only care that the type is not null the NCA itself contains the metadata we need.
var meta = nca.GetMetaData();
if (_keySet != null) return _keySet;
if (_options.CurrentValue.KeyFile == null) return null;
var dataRoot = _environment.ContentRootPath ?? "/app/config";
if (Path.IsPathRooted(_options.CurrentValue.KeyFile))
{
_keySet = ExternalKeyReader.ReadKeyFile(_options.CurrentValue.KeyFile);
}
else
{
_keySet = ExternalKeyReader.ReadKeyFile(Path.Combine(dataRoot, "config", _options.CurrentValue.KeyFile));
}
// 1) Title ID
string titleId = nca.Id.ToString("X16");
return _keySet;
}
}
// 2) Name and version
// 0.20.x provides a simple string accessor
string? titleName = meta.GetStringValue("title");
string? versionStr = meta.GetStringValue("version");
private readonly IOptionsMonitor<NSPExtractorOptions> _options;
private readonly ILogger<INSPExtractor> _logger;
private readonly IHostEnvironment _environment;
// 3) Determine if it is an application
bool isApp = meta.GetStringValue("content_type") == "Application";
public NSPExtractor(IOptionsMonitor<NSPExtractorOptions> options, ILogger<INSPExtractor> logger, IHostEnvironment environment)
{
_options = options;
_options.OnChange(o =>
{
if (o.KeyFile == null)
{
_logger?.LogInformation("No KeySet specified, skipping key validation");
}
return new TitleInfo(
titleId,
titleName ?? $"Unknown ({titleId})",
versionStr ?? "0.00",
isApp);
if (!File.Exists(o.KeyFile))
{
_logger?.LogWarning("KeySet file {KeyFile} does not exist", o.KeyFile);
}
});
_logger = logger;
_environment = environment;
}
/// <summary>
/// Public convenience wrapper that opens the file on disk.
/// </summary>
public NcaMetadataWithHash? ExtractFromFile(string filePath)
{
using var stream = File.OpenRead(filePath);
return ExtractFromStream(stream);
}
/// <summary>
/// Core implementation works on any seekable stream that contains a full NSP/XCI container.
/// </summary>
public NcaMetadataWithHash? ExtractFromStream(Stream stream)
{
if (KeySet == null) return null;
if (!stream.CanSeek) return null;
stream.Seek(0, SeekOrigin.Begin);
_logger.LogInformation("Extracting NSP/XCI from stream (length={Length})", stream.Length);
using var storage = new StreamStorage(stream, false);
if (IsPfs0FileSystem(stream))
{
return ExtractNSPFromStream(storage);
}
return null; // No NCA found
if (IsXciFileSystem(stream))
{
var xci = new Xci(KeySet, storage);
if (xci.HasPartition(XciPartitionType.Secure))
{
_logger.LogInformation("Processing as XCI");
using var partition = xci.OpenPartition(XciPartitionType.Secure);
// Find the smallest file
var hashEntry = partition.EnumerateEntries("*", SearchOptions.Default)
.First(e => e.Type == DirectoryEntryType.File && e.Size < 10 * 1024 * 1024);
using var hashFileRef = new UniqueRef<IFile>();
partition.OpenFile(ref hashFileRef.Ref, hashEntry.FullPath.ToU8Span(), OpenMode.Read).ThrowIfFailure();
using var hashFile = hashFileRef.Release();
using var sha256 = SHA256.Create();
using var hashStream = hashFile.AsStream();
var hash = sha256.ComputeHash(hashStream);
List<DirectoryEntryEx> ncaEntries = partition
.EnumerateEntries("*.cnmt.nca", SearchOptions.RecurseSubdirectories)
.Where(e => e.Type == DirectoryEntryType.File)
.ToList();
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 (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} ApplicationTitleId={ApplicationTitleId} Version={Version}", titleId, applicationTitleId.ToStrId(), 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
}
catch
private NcaMetadataWithHash? ExtractNSPFromStream(StreamStorage storage)
{
// Any exception (bad file, invalid archive, etc.) -> treat as nonNXP
if (KeySet == null) return null;
List<DirectoryEntryEx> ncaEntries;
_logger.LogInformation("Processing as NSP");
using var partition = new PartitionFileSystem();
partition.Initialize(storage).ThrowIfFailure();
// Find the first *.nca that contains the meta header
var hashEntry = partition
.EnumerateEntries("*", SearchOptions.Default)
.First(e => e.Type == DirectoryEntryType.File && e.Size < 10 * 1024 * 1024);
// Hash the *first* NCA stream the stream we just opened
using var hashRef = new UniqueRef<IFile>();
partition.OpenFile(ref hashRef.Ref, hashEntry.FullPath.ToU8Span(), OpenMode.Read).ThrowIfFailure();
using var hashFile = hashRef.Release();
using var sha256 = SHA256.Create();
using var hashStream = hashFile.AsStream();
var hash = sha256.ComputeHash(hashStream);
ncaEntries = partition
.EnumerateEntries("*.nca", SearchOptions.RecurseSubdirectories)
.Where(e => e.Type == DirectoryEntryType.File)
.ToList();
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 (nca.Header.ContentType != NcaContentType.Meta)
continue; // only the meta NCA contains title metadata
_logger.LogInformation("Meta NCA found TitleId={TitleId} Version={Version}", nca.Header.TitleId, nca.Header.Version);
string titleId = nca.Header.TitleId.ToString("X16");
var (contentMetaType, applicationTitle, titleVersion) = GetMetaData(nca);
if (contentMetaType != null)
return new NcaMetadataWithHash(titleId, applicationTitle.ToString("X16"), titleVersion.Minor, contentMetaType.Value, BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant());
}
return null;
}
private static (ContentMetaType?, ulong, TitleVersion) GetMetaData(Nca nca)
{
if (nca.Header.ContentType != NcaContentType.Meta) return (null, 0, new TitleVersion(0, true));
using var openFileSystem = nca.OpenFileSystem(0, IntegrityCheckLevel.ErrorOnInvalid);
foreach (var entry in openFileSystem.EnumerateEntries("*.cnmt", SearchOptions.Default))
{
using var fileRef = new UniqueRef<IFile>();
var result = openFileSystem.OpenFile(ref fileRef.Ref, entry.FullPath.ToU8Span(), OpenMode.Read);
if (result.IsFailure()) continue;
using var nacpFile = fileRef.Release();
using var asStream = nacpFile.AsStream();
var cnmt = new Cnmt(asStream);
var applicationTitle = cnmt.ApplicationTitleId;
return (cnmt.Type, applicationTitle, cnmt.TitleVersion);
}
return (null, 0, new TitleVersion(0, true));
}
/// <summary>
/// Quick sanity check that the stream looks like a PFS0 file system.
/// </summary>
private bool IsPfs0FileSystem(Stream stream)
{
try
{
if (!stream.CanSeek) return false;
stream.Seek(0, SeekOrigin.Begin);
using var storage = new StreamStorage(stream, true);
using var partition = new PartitionFileSystem();
partition.Initialize(storage).ThrowIfFailure();
_logger.LogInformation("PFS0 found");
return true;
}
catch
{
// ignored
}
return false;
}
private bool IsXciFileSystem(Stream stream)
{
if (KeySet == null) return false;
try
{
if (!stream.CanSeek) return false;
stream.Seek(0, SeekOrigin.Begin);
using 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)
{
if (KeySet == null) return string.Empty;
var seekableStream = !nspStream.CanSeek ? new SeekableBufferedStream(nspStream, nspStream.Length) : nspStream;
var isNsp = IsPfs0FileSystem(seekableStream);
var isXci = false;
if (!isNsp)
{
isXci = IsXciFileSystem(seekableStream);
}
if (!isNsp && !isXci)
return string.Empty;
seekableStream.Seek(0, SeekOrigin.Begin);
using var storage = new StreamStorage(seekableStream, true);
if (isXci)
{
var xci = new Xci(KeySet, storage);
using IFileSystem partition = xci.OpenPartition(XciPartitionType.Secure);
var hashEntry = partition
.EnumerateEntries("*", SearchOptions.Default)
.First(e => e.Type == DirectoryEntryType.File && e.Size < 10*1024*1024);
using var fileRef = new UniqueRef<IFile>();
partition.OpenFile(ref fileRef.Ref, hashEntry.FullPath.ToU8Span(), OpenMode.Read).ThrowIfFailure();
using var ncaFile = fileRef.Release();
using var sha256 = SHA256.Create();
using var ncaStream = ncaFile.AsStream();
var hash = sha256.ComputeHash(ncaStream);
var extractHashFromStream = BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
_logger.LogInformation("Computed firststream hash {Hash}", extractHashFromStream);
return extractHashFromStream;
}
if (isNsp)
{
using var partition = new PartitionFileSystem();
partition.Initialize(storage).ThrowIfFailure();
var hashEntry = partition
.EnumerateEntries("*", SearchOptions.Default)
.First(e => e.Type == DirectoryEntryType.File && e.Size < 10 * 1024 * 1024);
using var fileRef = new UniqueRef<IFile>();
partition.OpenFile(ref fileRef.Ref, hashEntry.FullPath.ToU8Span(), OpenMode.Read).ThrowIfFailure();
using var ncaFile = fileRef.Release();
try
{
using var ncaStream = ncaFile.AsStream();
using var sha256 = SHA256.Create();
var hash = sha256.ComputeHash(ncaStream);
var extractHashFromStream = BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
_logger.LogInformation("Computed firststream hash {Hash}", extractHashFromStream);
return extractHashFromStream;
}
catch (Exception e)
{
_logger.LogError("Failed to extract NSP: {Exception}", e.Message);
}
}
return string.Empty;
}
}
public class NSPExtractorOptions
{
public string? KeyFile { get; set; }
}
}
@@ -0,0 +1,198 @@
// File: Services/RomArchiveReader.cs
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Text.RegularExpressions;
using SharpCompress.Archives;
using SharpCompress.Archives.Rar;
using SharpCompress.Common;
using SharpCompress.IO;
using SharpCompress.Readers;
using TinfoilVibeServer.Utilities;
namespace TinfoilVibeServer.Services
{
/// <summary>
/// Reads a ROM archive (zip / 7z / rar) from a stream.
/// </summary>
public sealed class RomArchiveReader : IDisposable, IAsyncDisposable
{
private readonly ZipArchive? _zipArchive;
private readonly IArchive? _archive;
private readonly Stream? _archiveStream; // the stream actually handed to SharpCompress
public RomArchiveReader(string path)
{
_archive = DetectAndWrap(path);
}
/// <summary>
/// Opens an archive from a stream.
/// The *fileName* parameter is only used to decide which archive format
/// to open; it can be <c>null</c> if the caller already knows the format.
/// </summary>
public RomArchiveReader(string path, Stream stream)
{
if (stream == null) throw new ArgumentNullException(nameof(stream));
var fileInfo = new FileInfo(path);
switch (fileInfo.Extension)
{
case ".zip":
// System.IO.Compression can use the stream directly
_zipArchive = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: false);
break;
case ".7z":
case ".rar":
// SharpCompress requires a seekable stream; copy if necessary
if (!stream.CanSeek)
{
var ms = new MemoryStream();
stream.CopyTo(ms);
ms.Position = 0;
_archiveStream = ms;
if (stream is IAsyncDisposable asyncDisposable)
{
var disposeAsync = asyncDisposable.DisposeAsync();
disposeAsync.ConfigureAwait(false);
}
else
{
stream.Dispose(); // original nonseekable stream no longer needed
}
}
else
{
_archiveStream = stream;
}
_archive = ArchiveFactory.Open(_archiveStream, new ReaderOptions() { LeaveStreamOpen = false, BufferSize = 10 * 1024 * 1024});
break;
default:
throw new NotSupportedException($"Archive type '{fileInfo.Extension}' is not supported.");
}
}
// Detect whether the file is a multipart RAR and wrap it if necessary
private static IArchive DetectAndWrap(string path)
{
var ext = Path.GetExtension(path).ToLowerInvariant();
if (ext is ".rar" or ".r00" or ".r01" or ".r02")
{
var dir = Path.GetDirectoryName(path)!;
var fileName = Path.GetFileName(path);
// ----- 1️⃣ Determine the base name (everything before the first ".rar" or ".partNN") -----
var baseName = MultiPartRarHelper.GetBaseNameForRarVolume(fileName);
// Any file that ends with .rar or .rNN could be the start of a multipart set
// Let MultiPartRarStream decide which parts belong together.
var volumes = MultiPartRarHelper.DiscoverVolumes(dir, baseName);
if (volumes.Count is 0 or 1)
{
return ArchiveFactory.Open(path, new ReaderOptions { LeaveStreamOpen = false });
}
var streams = new List<Stream>(volumes.Count);
foreach (var volume in volumes)
{
// todo: check all streams for read validity? The rar may be available but the parts are not all downloaded yet
streams.Add(new FileStream(volume, FileMode.Open, FileAccess.Read, FileShare.Read, 10*1024*1024, FileOptions.Asynchronous));
}
return ArchiveFactory.Open(streams, new ReaderOptions { LeaveStreamOpen = false });
}
// Normal singlefile archive (zip, 7z, singlerar, etc.)
using var archiveStream = File.OpenRead(path);
return ArchiveFactory.Open(archiveStream, new ReaderOptions { LeaveStreamOpen = false });
}
private static Stream? GetPart(int arg)
{
throw new NotImplementedException();
}
public Stream? GetEntryStream(string entryName)
{
var entry = _archive?.Entries
.Single(e => e.Key != null && e.Key.Equals(entryName, StringComparison.OrdinalIgnoreCase));
return entry?.OpenEntryStream();
}
/// <summary>
/// Enumerates every file entry inside the archive.
/// </summary>
public IEnumerable<RomArchiveEntry> GetEntries()
{
if (_zipArchive != null)
{
foreach (var entry in _zipArchive.Entries)
{
if (entry.FullName.EndsWith("/", StringComparison.Ordinal))
continue; // skip directories
yield return new RomArchiveEntry(entry.FullName, entry.Open());
}
}
else if (_archive != null)
{
foreach (var entry in _archive.Entries)
{
if (entry.IsDirectory)
continue;
// SharpCompress gives us a stream that must be disposed by the caller
if (!entry.Archive.IsComplete)
{
if (entry.Key != null)
{
var entryStream = GetEntryStream(entry.Key);
if (entryStream != null) yield return new RomArchiveEntry(entry.Key, entryStream);
}
}
else
{
if (entry.Key != null) yield return new RomArchiveEntry(entry.Key, entry.OpenEntryStream());
}
}
}
}
/// <summary>
/// Backcompat wrapper used by SnapshotService.
/// </summary>
public IEnumerable<RomArchiveEntry> GetContentInfos() => GetEntries();
/// <summary>
/// Disposes the underlying archive objects and the stream(s).
/// </summary>
public void Dispose()
{
DisposeAsync().GetAwaiter().GetResult();
}
public async ValueTask DisposeAsync()
{
_zipArchive?.Dispose();
_archive?.Dispose();
// Dispose of the underlying stream (may support async)
if (_archiveStream is IAsyncDisposable asyncStream)
await asyncStream.DisposeAsync().ConfigureAwait(false);
else
// ReSharper disable once MethodHasAsyncOverload
_archiveStream?.Dispose();
}
/// <summary>
/// Lightweight container that holds the entry name and the opened stream.
/// The caller must dispose <c>Stream</c> after it is done.
/// </summary>
public sealed record RomArchiveEntry(string Name, Stream Stream);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,325 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Text.RegularExpressions;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using TinfoilVibeServer.Models;
namespace TinfoilVibeServer.Services;
/// <summary>
/// * Loads the titledatabase JSON that lives on GitHub.
/// * Caches the JSON file on disk (in a configurable “cache” folder).
/// * Builds a dictionary that maps a 16digit hex TitleId → the full
/// filesystem path of the NSP that contains it (for later lookups).
/// * Provides a convenient lookup API (via <c>GetTitleByTitleId</c>).
/// </summary>
public sealed class TitleDatabaseService : IHostedService
{
#region Configuration keys
private const string CacheKey = "TitleDb";
#endregion
#region Fields
private readonly IOptionsMonitor<TitleDbOptions> _options;
private readonly ILogger<TitleDatabaseService> _logger;
private readonly IHttpClientFactory _httpFactory;
private readonly string _cacheFolder; // Where the JSON is cached.
private readonly IMemoryCache _cache;
private readonly ISnapshotService _snapshotService;
private readonly Dictionary<string,string> _titleIdToPath = new Dictionary<string, string>();
// Regex to find a 16digit hex TitleId in a filename
private static readonly Regex TitleIdRegex = new(
@"([0-9a-fA-F]{8})([0-9a-fA-F]{8})", RegexOptions.Compiled);
private readonly SemaphoreSlim _reloadLock = new(1, 1); // protects reload logic
private Task? _reloadTask; // the currently running or last finished reload
#endregion
#region ctor
/// <summary>
/// Register as a singleton IHostedService.
/// The constructor receives the values that are needed to build
/// the GitHub URL (CountryCode + Language) and the root
/// directories that contain the NSP files.
/// </summary>
public TitleDatabaseService(
IOptionsMonitor<TitleDbOptions> options,
ILogger<TitleDatabaseService> logger,
ISnapshotService snapshotService,
IHttpClientFactory httpFactory,
INSPExtractor nspExtractor,
IMemoryCache cache)
{
_options = options;
_logger = logger;
_snapshotService = snapshotService;
_httpFactory = httpFactory;
_cache = cache;
_cacheFolder = Path.Combine(AppContext.BaseDirectory, "data", "titledb-cache");
new List<string>
{
// You can extend this list it is the set of directories that
// are scanned when the service starts up.
Path.Combine(AppContext.BaseDirectory, "Games")
};
// Reload cache immediately when a snapshot rebuild occurs
_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
#region IHostedService
public async Task StartAsync(CancellationToken cancellationToken)
{
// 1️⃣ Load the JSON (download if not cached).
await ReloadCacheAsync();
_logger.LogInformation("Title database ready {Count} entries loaded.",
GetAllAsync().Result.Count);
}
public Task StopAsync(CancellationToken cancellationToken)
=> Task.CompletedTask; // nothing special to do on shutdown
#endregion
/* ---------------------------------------------------------------- */
/* 1️⃣ Cache loading / reloading sliding expiration
/* ---------------------------------------------------------------- */
private async Task ReloadCacheAsync()
{
var ttlSec = _options.CurrentValue.TtlSeconds;
_logger.LogInformation("Reloading title database cache (TTL={TTL}s).", ttlSec);
var entryOptions = new MemoryCacheEntryOptions()
.SetSlidingExpiration(TimeSpan.FromSeconds(ttlSec))
.RegisterPostEvictionCallback((key, value, reason, state) => { _logger.LogInformation("Cache eviction: {Key} ({Reason})", key, reason); });
var dict = await LoadFromDiskAsync() ?? new Dictionary<string, TitleInfoDto>();
_cache.Set(CacheKey, dict, entryOptions);
_logger.LogInformation("Title DB reloaded {Count} items cached (TTL={TTL}s).",
dict.Count, ttlSec);
}
private async Task<Dictionary<string, TitleInfoDto>?> LoadFromDiskAsync()
{
var cacheFile = Path.Combine(_cacheFolder, $"{_options.CurrentValue.CountryCode}.{_options.CurrentValue.LanguageCode}.json");
if (!File.Exists(cacheFile))
{
_logger.LogInformation("Cache miss downloading title DB from {Url}", cacheFile);
await LoadAndCacheTitleDb(CancellationToken.None);
}
return await ReadTitleDbAsync(cacheFile, CancellationToken.None);
}
/* ---------------------------------------------------------------- */
/* 2️⃣ Public API every call slides the cache
/* ---------------------------------------------------------------- */
public async Task<Dictionary<string, TitleInfoDto>> GetAllAsync()
{
await EnsureCacheLoadedAsync().ConfigureAwait(false);
// The cache entry is guaranteed to exist now
_cache.TryGetValue(CacheKey, out Dictionary<string, TitleInfoDto>? dict);
return dict!;
}
public async Task<TitleInfoDto?> GetAsync(string titleId)
{
var all = await GetAllAsync(); // slides the entry
all.TryGetValue(titleId, out var dto);
return dto;
}
/* ---------------------------------------------------------------- */
/* 3️⃣ Persist to disk & notify snapshot service
/* ---------------------------------------------------------------- */
private async Task PersistAsync(Dictionary<string, TitleInfoDto> dict)
{
// Trigger a rebuild so SnapshotService (and any other listeners)
// can pick up the new snapshot.
//_snapshotService.RebuildSnapshot();
await Task.CompletedTask;
}
/* ---------------------------------------------------------------- */
/* 4️⃣ Dispose
/* ---------------------------------------------------------------- */
public void Dispose()
{
_snapshotService.SnapshotRebuilt -= SnapshotServiceOnSnapshotRebuilt;
_reloadLock.Dispose();
}
private async void SnapshotServiceOnSnapshotRebuilt(object? o, EventArgs eventArgs)
{
try
{
await EnsureCacheLoadedAsync();
}
catch (Exception e)
{
_logger.LogCritical(e, "Failed to reload title database cache");
}
}
#region Public API
/// <summary>
/// Return the <c>TitleInfoDto</c> for a known <c>TitleId</c>.
/// </summary>
public bool TryGetTitle(string titleId, out TitleInfoDto? title)
{
title = GetAsync(titleId).GetAwaiter().GetResult();
return title != null;
}
#endregion
#region Private helpers
/// <summary>
/// Makes sure the cache is loaded. If a reload is already in progress,
/// the caller simply awaits the same task. Otherwise it starts a new reload.
/// </summary>
private async Task EnsureCacheLoadedAsync()
{
// Fast path a cache entry exists, no work needed
if (_cache.TryGetValue(CacheKey, out _))
return;
// Fast path a reload is already underway
var existingTask = _reloadTask;
if (existingTask != null)
{
await existingTask.ConfigureAwait(false);
return;
}
// Slow path we need to start the reload
await _reloadLock.WaitAsync().ConfigureAwait(false);
try
{
// Doublecheck after acquiring the lock
if (_cache.TryGetValue(CacheKey, out _))
return;
// If another thread started the reload while we were waiting, use it
existingTask = _reloadTask;
if (existingTask != null)
{
await existingTask.ConfigureAwait(false);
return;
}
// Create the shared task
_reloadTask = ReloadCacheAsync();
await _reloadTask.ConfigureAwait(false);
}
finally
{
_reloadTask = null; // reset for the next miss
_reloadLock.Release();
}
}
/// <summary>
/// Downloads the JSON file from GitHub (raw) if it does not exist
/// or the cached copy is older than the remote one.
/// </summary>
private async Task LoadAndCacheTitleDb(CancellationToken ct)
{
// Build the raw URL
var rawUrl = $"https://raw.githubusercontent.com/blawar/titledb/refs/heads/master/{_options.CurrentValue.CountryCode}.{_options.CurrentValue.LanguageCode}.json";
// Ensure the cache directory exists.
Directory.CreateDirectory(_cacheFolder);
var cacheFile = Path.Combine(_cacheFolder, $"{_options.CurrentValue.CountryCode}.{_options.CurrentValue.LanguageCode}.json");
// If the file exists & is recent no download needed.
if (File.Exists(cacheFile))
{
var fi = new FileInfo(cacheFile);
// If the file is newer than 24h use it.
if (fi.LastWriteTimeUtc > DateTime.UtcNow.AddHours(-24))
{
_logger.LogInformation("Using cached title database {File}", cacheFile);
await LoadAndCacheTitleDb(ct);
return;
}
_logger.LogInformation("Cache miss downloading title DB from {Url}", cacheFile);
}
_logger.LogInformation("Downloading title database from {Url}", rawUrl);
var client = _httpFactory.CreateClient();
using var response = await client.GetAsync(rawUrl, ct);
response.EnsureSuccessStatusCode();
await using var fs = new FileStream(cacheFile, FileMode.Create, FileAccess.Write, FileShare.None);
await response.Content.CopyToAsync(fs, ct);
_logger.LogInformation("Title database cached to {File}", cacheFile);
await ReadTitleDbAsync(cacheFile, ct);
}
/// <summary>
/// Read the JSON file and populate <c>_titleData</c>.
/// Also remap titleId for XCI files based on cnmts.json
/// </summary>
private async Task<Dictionary<string,TitleInfoDto>> ReadTitleDbAsync(string filePath, CancellationToken ct)
{
var json = await File.ReadAllTextAsync(filePath, ct);
var titleInfoDtos = JsonSerializer.Deserialize<Dictionary<string, TitleInfoDto>>(
json,
new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
}) ?? new Dictionary<string, TitleInfoDto>(StringComparer.OrdinalIgnoreCase);
_logger.LogInformation("Loaded {Count} titles from the database.", titleInfoDtos.Count);
var titleData = new Dictionary<string, TitleInfoDto>(StringComparer.OrdinalIgnoreCase);
for (var i =0; i< titleInfoDtos.Values.Count; i++)
{
var entry = titleInfoDtos.Values.ElementAt(i);
var key = titleInfoDtos.Keys.ElementAt(i);
if (!string.IsNullOrWhiteSpace(key))
{
if (entry.Id != null)
{
if (entry.Id.Length == 16)
{
titleData[entry.Id] = entry;
continue;
}
}
}
}
return titleData;
}
#endregion
}
+35 -7
View File
@@ -8,32 +8,60 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.6"/>
<PackageReference Include="Microsoft.AspNetCore.Http" Version="2.3.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.10" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.10" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.10" />
<PackageReference Include="Microsoft.Extensions.Options" Version="9.0.10" />
<PackageReference Include="SharpCompress" Version="0.41.0" />
<PackageReference Include="SharpSevenZip" Version="2.0.32" />
<PackageReference Include="LibHac" Version="0.20.0" />
<PackageReference Include="System.Runtime.Caching" Version="9.0.10" />
</ItemGroup>
<ItemGroup>
<Content Include="..\.dockerignore">
<Link>.dockerignore</Link>
</Content>
<Content Update="appsettings.Development.json">
<DependentUpon>appsettings.json</DependentUpon>
<Content Remove="obj\**" />
<AdditionalFiles Include="..\Dependencies\LibHac.dll">
<Link>LibHac.dll</Link>
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</AdditionalFiles>
<Content Update="Config\appsettings.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
<Content Update="Config\appsettings.development.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
<Content Update="Config\appsettings.local.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
<Content Update="Config\appsettings.production.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
</ItemGroup>
<ItemGroup>
<!-- libhac ships a native helper called Ryujinx.HLE.HOS.Native.dll.
The build script copies it to the output folder automatically. -->
<None Update="..\libhac\src\LibHac\bin\Release\net8.0\LibHac.dll" CopyToOutputDirectory="PreserveNewest" />
<None Update="..\Dependencies\LibHac.dll" CopyToOutputDirectory="PreserveNewest" />
<None Remove="obj\**" />
</ItemGroup>
<ItemGroup>
<Reference Include="LibHac">
<HintPath>..\libhac\src\LibHac\bin\Release\net8.0\LibHac.dll</HintPath>
<HintPath>..\Dependencies\LibHac.dll</HintPath>
</Reference>
</ItemGroup>
<ItemGroup>
<Compile Remove="obj\**" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Remove="obj\**" />
<EmbeddedResource Include="appsettings.default.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</EmbeddedResource>
</ItemGroup>
</Project>
+2 -2
View File
@@ -1,6 +1,6 @@
@TinfoilVibeServer_HostAddress = http://localhost:5253
@TinfoilVibeServer_HostAddress = http://tinfoil.localhost/
GET {{TinfoilVibeServer_HostAddress}}/weatherforecast/
GET {{TinfoilVibeServer_HostAddress}}/
Accept: application/json
###
@@ -0,0 +1,31 @@
namespace TinfoilVibeServer.Utilities;
public class DependentStream(Stream innerStream, IDisposable? parentContainer) : Stream
{
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 Task CopyToAsync(Stream destination, int bufferSize, CancellationToken cancellationToken)
{
return innerStream.CopyToAsync(destination, bufferSize, cancellationToken);
}
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);
}
}
@@ -0,0 +1,46 @@
namespace TinfoilVibeServer.Utilities;
public static class FileLockHelper
{
/// <summary>
/// Checks if a file is locked (open by another process).
/// Works on Windows and Unix (Linux / macOS).
/// </summary>
public static bool IsFileLocked(string filePath, ILogger? logger = null)
{
// Quick sanity check: the file must exist
if (!File.Exists(filePath))
return false;
try
{
using var stream = File.Open(filePath,
FileMode.Open,
FileAccess.ReadWrite,
FileShare.None);
// If we get here, the file is not locked.
stream.Close();
return false;
}
catch (IOException ioEx)
{
// On Windows, error code 32 (sharing violation) or 33 (lock violation)
// On Unix, EINVAL or EACCES
// The .NET exception already hides the native error, so we just log it.
logger?.LogDebug(ioEx, "File '{Path}' is locked.", filePath);
return true;
}
catch (UnauthorizedAccessException uaEx)
{
logger?.LogDebug(uaEx, "File '{Path}' access denied (likely locked).", filePath);
return true;
}
catch (Exception ex)
{
// Unexpected: we conservatively say its locked
logger?.LogError(ex, "Unexpected error while checking lock on '{Path}'. Assuming locked.", filePath);
return true;
}
}
}
@@ -0,0 +1,129 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
namespace TinfoilVibeServer.Utilities;
public static class FileSystemExtensions
{
/// <summary>
/// Returns the most recent lastwrite time (UTC) of any file under the supplied
/// root directories, traversing all subdirectories. If no files are found,
/// <c>null</c> is returned.
/// </summary>
/// <param name="rootDirectories">
/// A collection of absolute paths that must point to existing directories.
/// Paths that do not exist or are inaccessible are silently skipped.
/// </param>
/// <returns>
/// The UTC <see cref="DateTime"/> of the newest file, or <c>null</c> if there are none.
/// </returns>
public static DateTime? GetLatestModifiedUtc(IEnumerable<string> rootDirectories)
{
if (rootDirectories == null) throw new ArgumentNullException(nameof(rootDirectories));
// We keep a mutable variable because we don't want to materialise the entire
// sequence into memory.
DateTime? latest = null;
foreach (var root in rootDirectories)
{
if (string.IsNullOrWhiteSpace(root) || !Directory.Exists(root))
continue; // skip bad paths
try
{
// Enumerate lazily and process each file as soon as its yielded.
foreach (var filePath in Directory.EnumerateFiles(
root,
"*",
SearchOption.AllDirectories))
{
try
{
// Using FileSystemInfo to fetch only the property we need.
var fsi = new FileInfo(filePath);
var lastWrite = fsi.LastWriteTimeUtc;
if (!latest.HasValue || lastWrite > latest.Value)
latest = lastWrite;
}
catch (FileNotFoundException) // file vanished while we were enumerating
{
// ignore and keep going
}
catch (UnauthorizedAccessException)
{
// file exists but we cant read its attributes skip it
}
}
}
catch (UnauthorizedAccessException)
{
// the root directory itself is inaccessible skip it
}
}
return latest;
}
/// <summary>
/// Parallelised version that may be faster on very large directory trees.
/// </summary>
public static DateTime? GetLatestModifiedUtcParallel(IEnumerable<string> rootDirectories)
{
if (rootDirectories == null) throw new ArgumentNullException(nameof(rootDirectories));
// Flatten all file paths into a single stream first (this is the only
// part that needs to be threadsafe).
var allFiles = rootDirectories
.Where(r => !string.IsNullOrWhiteSpace(r) && Directory.Exists(r))
.SelectMany(r => Directory.EnumerateFiles(r, "*", SearchOption.AllDirectories))
.ToArray(); // materialise once, then parallelise
// Now fetch the dates in parallel. The LINQ overload of Max() that takes
// an async selector is not available, so we just use Parallel.ForEach.
DateTime? latest = null;
var lockObj = new object();
Parallel.ForEach(allFiles, filePath =>
{
try
{
var lastWrite = new FileInfo(filePath).LastWriteTimeUtc;
lock (lockObj)
{
if (!latest.HasValue || lastWrite > latest.Value)
latest = lastWrite;
}
}
catch (Exception)
{
// swallow all exceptions the caller only cares about the max date
}
});
return latest;
}
/// <summary>
/// Creates the directory (and all missing parent directories) if it does not already exist.
/// </summary>
/// <param name="path">Absolute or relative path to the directory to create.</param>
/// <exception cref="ArgumentNullException">Thrown if <paramref name="path"/> is null.</exception>
/// <exception cref="ArgumentException">Thrown if <paramref name="path"/> is empty or contains only whitespace.</exception>
/// <exception cref="UnauthorizedAccessException">Thrown if the caller does not have permission.</exception>
/// <exception cref="IOException">Thrown if a file exists at the target path or the directory cannot be created.</exception>
public static void EnsureDirectoryExists(string? path)
{
ArgumentNullException.ThrowIfNull(path);
if (string.IsNullOrWhiteSpace(path))
throw new ArgumentException("Path must not be empty or whitespace.", nameof(path));
// Directory.CreateDirectory is already idempotent it only creates missing parts.
Directory.CreateDirectory(path);
}
}
+82
View File
@@ -0,0 +1,82 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
namespace TinfoilVibeServer.Utilities;
public static class IdHelper
{
public static string ToStrId(this int num)
{
return ToIdFromConvertedNumBytes(BitConverter.GetBytes(num));
}
public static string ToStrId(this uint num)
{
return ToIdFromConvertedNumBytes(BitConverter.GetBytes(num));
}
public static string ToStrId(this long num)
{
return ToIdFromConvertedNumBytes(BitConverter.GetBytes(num));
}
public static string ToStrId(this ulong num)
{
return ToIdFromConvertedNumBytes(BitConverter.GetBytes(num));
}
public static string ToStrId(this IEnumerable<byte> bytes)
{
return bytes.Aggregate("", (current, b) => current + b.ToString("X2"));
}
public static string ToStrId(this Span<byte> bytes)
{
return bytes.ToArray().ToStrId();
}
private static string ToIdFromConvertedNumBytes(IEnumerable<byte> getBytes)
{
return ToStrId(getBytes.Reverse());
}
public static string GetTitleId(this FileInfo fileInfo)
{
var match = Regex.Match(fileInfo.Name, "^.*\\[(\\w{16})\\].*\\.nsp$");
return match is { Length: > 0, Groups.Count: > 1 }?match.Groups[1].Value:string.Empty;
}
#region 1 From hex string byte[] (bigendian)
/// <summary>
/// Turns an evenlength hex string into a byte array in **bigendian** order
/// (the same order that the original <c>ToStrId</c> creates).
/// </summary>
private static byte[] HexStringToBytes(string hex)
{
if (string.IsNullOrWhiteSpace(hex))
throw new ArgumentException("ID string cannot be empty", nameof(hex));
if (hex.Length % 2 != 0)
throw new ArgumentException("ID string must contain an even number of hex digits", nameof(hex));
var bytes = new byte[hex.Length / 2];
for (var i = 0; i < bytes.Length; i++)
{
var chunk = hex.Substring(i * 2, 2);
bytes[i] = byte.Parse(chunk, NumberStyles.HexNumber, CultureInfo.InvariantCulture);
}
return bytes;
}
#endregion
public static long ToLongFromStrId(this string hex) =>
BitConverter.ToInt64(HexStringToBytes(hex).Reverse().ToArray(), 0);
}
@@ -0,0 +1,83 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
namespace TinfoilVibeServer.Utilities;
/// <summary>
/// Streams a multipart RAR as a single, seekable stream.
/// Supports:
/// • myGame.rar (single volume)
/// • myGame.r00 / r01 … (RAR 4.x style)
/// • myGame.part01.rar … (WinRAR “part” style)
/// </summary>
public static class MultiPartRarHelper
{
public static string GetBaseNameForRarVolume(string fileName)
{
string baseName = string.Empty;
// Multipart remove the ".rNN" or ".partNN" suffix
var m = Regex.Match(fileName,
@"^(?<base>.+?)(\.r\d\d|\.part\d\d)\.rar$",
RegexOptions.IgnoreCase);
if (!m.Success)
{
if (fileName.EndsWith(".rar", StringComparison.OrdinalIgnoreCase))
{
// Singlevolume archive just drop the suffix
baseName = fileName.Substring(0, fileName.Length - 4);
}
}
else
{
baseName = m.Groups["base"].Value;
}
return baseName;
}
/// <summary>
/// Returns the list of files that belong to the same multipart set.
/// </summary>
public static List<string> DiscoverVolumes(string dir, string baseName)
{
// Pattern: <base>.(rar | rNN | partNN.rar)
string pattern =
$@"^{Regex.Escape(baseName)}(\.rar|\.r\d\d|\.part\d\d\.rar)$";
return Directory.GetFiles(dir)
.Where(f => Regex.IsMatch(Path.GetFileName(f), pattern, RegexOptions.IgnoreCase))
.OrderBy(f => GetPartNumber(Path.GetFileName(f), baseName))
.ToList();
}
/// <summary>
/// Gives each file a numeric key that guarantees the correct order.
/// 0 → .rar (first volume)
/// 1 → .r00 or .part01
/// 2 → .r01 or .part02
/// … and so on.
/// </summary>
private static int GetPartNumber(string fileName, string baseName)
{
// 1️⃣ ".rar" → 0
if (fileName.Equals($"{baseName}.rar", StringComparison.OrdinalIgnoreCase))
return 0;
// 2️⃣ ".rNN" or ".partNN.rar"
var m = Regex.Match(fileName,
$@"\.r(?<num>\d\d)$|\.part(?<num>\d\d)\.rar$",
RegexOptions.IgnoreCase);
if (m.Success)
{
int partNum = int.Parse(m.Groups["num"].Value);
return partNum + 1; // so r00 → 1, r01 → 2; part01 → 1, part02 → 2
}
// 3️⃣ unknown pattern treat as first part
return 0;
}
}
@@ -0,0 +1,33 @@
using System;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using LibHac.Ncm;
using TinfoilVibeServer.Models;
namespace TinfoilVibeServer.Utilities;
public static class NcaMetadataWithHashHelper
{
public static NcaMetadataWithHash? GetNcaMetadataWithHash(this FileInfo fileInfo)
{
var match = Regex.Match(fileInfo.Name, @"^(.+)\[(\w{16})\]\[v(\d{1,7})\]\[(\w+).*\]\.nsp$");
if (!match.Success) return null;
var titleId = match.Groups[2].Value;
var applicationTitle = titleId;
//var applicationTitle = match.Groups[1].Value.Trim();
var version = int.Parse(match.Groups[3].Value) / 0x10000;
var nspType = match.Groups[4].Value.ToLowerInvariant() switch
{
"base" => ContentMetaType.Application,
"update" => ContentMetaType.Patch,
"dlc" => ContentMetaType.AddOnContent,
_ => ContentMetaType.Patch
};
var bytes = Encoding.UTF8.GetBytes(fileInfo.FullName);
var hash = SHA256.HashData(bytes);
return new NcaMetadataWithHash(titleId, applicationTitle, version, nspType, Convert.ToBase64String(hash));
}
}
@@ -0,0 +1,299 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
namespace TinfoilVibeServer.Utilities;
/// <summary>
/// Wraps a nonseekable stream so that it can be read and seeked.
/// The wrapper keeps a small circular buffer of recently read data.
/// When the caller seeks outside the buffered range the wrapper
/// disposes the current stream, obtains a new instance via a
/// supplied factory, and reads forward from the start again.
/// </summary>
public sealed class RewindableStream : Stream
{
private readonly Func<Stream> _reopenFactory; // function that returns a fresh stream instance
private readonly int _bufferLimit; // maximum bytes to keep in memory
private Stream _source; // the current underlying stream
private MemoryStream _buffer; // holds the cached bytes
private long _bufferStart; // absolute position in the source of the first byte in _buffer
private long _position; // current read position in the virtual stream
private long? _length; // cached length once we discover it (null = unknown)
private bool _disposed;
/// <summary>
/// Creates a new seekable wrapper.
/// </summary>
/// <param name="source">The initial nonseekable stream.</param>
/// <param name="reopenFactory">
/// Factory that returns a *new* instance of the underlying stream.
/// It is called whenever we need to seek beyond the cached range.
/// </param>
/// <param name="bufferLimit">
/// The maximum number of bytes to keep cached in memory.
/// Older data will be discarded as new data is read. Typical value: 64KB.
/// </param>
public RewindableStream(
Stream source,
Func<Stream> reopenFactory,
int bufferLimit = 64 * 1024, long? length = null)
{
if (source == null) throw new ArgumentNullException(nameof(source));
if (!source.CanRead) throw new ArgumentException("Source stream must be readable", nameof(source));
if (reopenFactory == null) throw new ArgumentNullException(nameof(reopenFactory));
if (bufferLimit <= 0) throw new ArgumentOutOfRangeException(nameof(bufferLimit));
if (length.HasValue && length.Value < 0) throw new ArgumentOutOfRangeException(nameof(length));
_length = null; // unknown until we discover it
if (length.HasValue) _length = length;
_source = source;
_reopenFactory = reopenFactory;
_bufferLimit = bufferLimit;
_buffer = new MemoryStream();
_bufferStart = 0;
_position = 0;
_disposed = false;
}
#region Stream overrides
public override bool CanRead => !_disposed && _source.CanRead;
public override bool CanSeek => true; // we expose seek behaviour
public override bool CanWrite => false; // readonly wrapper
public override long Length
{
get
{
EnsureLengthAsync(CancellationToken.None).GetAwaiter().GetResult();
return _length.Value;
}
}
public override long Position
{
get => _position;
set => Seek(value, SeekOrigin.Begin);
}
public override void Flush()
{
// Nothing to do readonly wrapper.
}
public override int Read(byte[] buffer, int offset, int count)
{
ThrowIfDisposed();
if (buffer == null) throw new ArgumentNullException(nameof(buffer));
if (offset < 0 || count < 0 || offset + count > buffer.Length)
throw new ArgumentOutOfRangeException();
if (count == 0) return 0;
int totalRead = 0;
while (count > 0)
{
// Make sure the requested range is buffered.
EnsureBufferedUpTo(_position + count - 1).GetAwaiter().GetResult();
// How many bytes can we copy from the buffer?
long bufferEnd = _bufferStart + _buffer.Length;
long available = bufferEnd - _position;
if (available <= 0)
{
// We are at EOF nothing more to read.
break;
}
int toCopy = (int)Math.Min(count, available);
_buffer.Position = _position - _bufferStart;
int read = _buffer.Read(buffer, offset, toCopy);
offset += read;
count -= read;
totalRead += read;
_position += read;
if (read == 0) break; // EOF
}
return totalRead;
}
public override long Seek(long offset, SeekOrigin origin)
{
ThrowIfDisposed();
long newPos;
if (origin == SeekOrigin.Begin)
{
newPos = offset;
}
else if (origin == SeekOrigin.Current)
{
newPos = _position + offset;
}
else if (origin == SeekOrigin.End)
{
// We need the length first.
EnsureLengthAsync(CancellationToken.None).GetAwaiter().GetResult();
newPos = _length.Value + offset;
}
else
{
throw new ArgumentOutOfRangeException(nameof(origin));
}
if (newPos < 0)
throw new IOException("Cannot seek to a negative position.");
// If the new position lies outside our cached range, we must
// restart the underlying stream and read forward again.
if (newPos < _bufferStart || newPos > _bufferStart + _buffer.Length)
{
ReopenFromStart(); // resets _source, _buffer, etc.
_position = newPos; // restore the requested position
}
else
{
_position = newPos;
}
// Ensure that we actually have bytes buffered up to the new position
// (unless we are at the very end in which case the call will just
// return as we hit EOF).
EnsureBufferedUpTo(_position).GetAwaiter().GetResult();
return _position;
}
public override void SetLength(long value)
{
throw new NotSupportedException("SetLength is not supported on RewindableStream.");
}
public override void Write(byte[] buffer, int offset, int count)
{
throw new NotSupportedException("Write is not supported on RewindableStream.");
}
#endregion
#region Helper methods
/// <summary>
/// Ensures that the buffer contains data up to the specified absolute position.
/// </summary>
private async Task EnsureBufferedUpTo(long position)
{
ThrowIfDisposed();
if (position < _bufferStart) return; // we already have data before our buffer.
// Read from the underlying stream until we have buffered up to 'position'
// or until EOF.
while (_bufferStart + _buffer.Length <= position)
{
int toRead = (int)Math.Min(_bufferLimit,
position - (_bufferStart + _buffer.Length) + 1);
// Allocate a temporary buffer
byte[] temp = new byte[toRead];
int read = await _source.ReadAsync(temp, 0, temp.Length, CancellationToken.None);
if (read == 0) // EOF
{
// Store the final length if we don't already know it.
if (!_length.HasValue)
{
_length = _bufferStart + _buffer.Length;
}
break;
}
// Append to our circular buffer
_buffer.Position = _buffer.Length; // move to end
_buffer.Write(temp, 0, read);
// Trim if we exceeded the buffer limit.
if (_buffer.Length > _bufferLimit)
{
long excess = _buffer.Length - _bufferLimit;
byte[] remaining = new byte[_buffer.Length - excess];
_buffer.Position = excess;
_buffer.Read(remaining, 0, remaining.Length);
_buffer = new MemoryStream();
_buffer.Write(remaining, 0, remaining.Length);
_bufferStart += excess; // first byte in new buffer is now further ahead
}
}
}
/// <summary>
/// Reopens the underlying stream by disposing the current instance
/// and calling the factory again.
/// </summary>
private void ReopenFromStart()
{
_source.Dispose();
_source = _reopenFactory();
_buffer.SetLength(0);
_bufferStart = 0;
_position = 0;
}
/// <summary>
/// Attempts to discover the length of the underlying source if it supports it.
/// If the source does not expose a length we will read to the end once.
/// </summary>
private async Task EnsureLengthAsync(CancellationToken cancellationToken)
{
if (_length.HasValue) return;
if (_source.CanSeek)
{
long current = _source.Position;
_length = _source.Length;
_source.Position = current;
}
else
{
// We need to read until EOF to determine the length
while (true)
{
byte[] temp = new byte[_bufferLimit];
int read = await _source.ReadAsync(temp, 0, temp.Length, cancellationToken);
if (read == 0) break;
}
_length = _bufferStart + _buffer.Length;
}
}
private void ThrowIfDisposed()
{
if (_disposed) throw new ObjectDisposedException(nameof(RewindableStream));
}
#endregion
#region IDisposable
protected override void Dispose(bool disposing)
{
if (!_disposed && disposing)
{
_source?.Dispose();
_buffer?.Dispose();
}
_disposed = true;
base.Dispose(disposing);
}
#endregion
}
@@ -0,0 +1,264 @@
using System;
using System.Buffers;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
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;
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);
}
// SeekableBufferedStream.cs Add IAsyncDisposable support
public override async ValueTask DisposeAsync()
{
Dispose(true);
await Task.CompletedTask;
}
#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;
}
await Task.CompletedTask;
return bytesRead;
}
#endregion
}
@@ -0,0 +1,39 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"CredentialsFile": "/app/data/credentials.json",
"FingerprintsFile": "/app/data/fingerprints.json",
"BlacklistFile": "/app/data/blacklist.json",
"MaxFailedAttempts": 5,
"Snapshot" : {
"RootDirectories": [ ],
"ArchiveExtensions": [ ".zip", ".rar", ".7z" ],
"RomExtensions": [ ".xci", ".nsp", ".xcz" ],
"CacheTtl": 60,
"SnapshotFile": "/app/data/snapshot.json",
"SnapshotBackupFile": "/app/data/snapshot.bak"
},
"NSPExtractor": {
"KeyFile": "/app/config/prod.keys"
},
"IndexBuilder": {
"ApiBaseUrl": "http://tinfoil.localhost:8080",
"IndexDirectories": [
"https://url1",
"sdmc:/url2",
"http://url3"
],
"Success" : "Welcome to Tinfoil Vibe Server!"
},
"TitleDb": {
"CountryCode": "AU",
"Language": "en",
"TtlSeconds" : 90
}
}
-23
View File
@@ -1,23 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"RootDirectories": [
"\\\\NAS\\Games",
"\\\\NAS\\Backups"
],
"WhitelistExtensions": [
".bin", ".jpg", ".png", ".txt"
],
"RomExtensions": [
".xci", ".nsp", ".xcz"
],
"SnapshotFile": "index.tfl",
"SnapshotBackupFile": "snapshot.bin",
"ArchiveBufferSize": 8192
}
+39
View File
@@ -0,0 +1,39 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"CredentialsFile": "/app/data/credentials.json",
"FingerprintsFile": "/app/data/fingerprints.json",
"BlacklistFile": "/app/data/blacklist.json",
"MaxFailedAttempts": 5,
"Snapshot" : {
"RootDirectories": [ ],
"ArchiveExtensions": [ ".zip", ".rar", ".7z" ],
"RomExtensions": [ ".xci", ".nsp", ".xcz" ],
"CacheTtl": 60,
"SnapshotFile": "/app/data/snapshot.json",
"SnapshotBackupFile": "/app/data/snapshot.bak"
},
"NSPExtractor": {
"KeyFile": "/app/config/prod.keys"
},
"IndexBuilder": {
"ApiBaseUrl": "http://tinfoil.localhost:8080",
"IndexDirectories": [
"https://url1",
"sdmc:/url2",
"http://url3"
],
"Success" : "Welcome to Tinfoil Vibe Server!"
},
"TitleDb": {
"CountryCode": "AU",
"Language": "en",
"TtlSeconds" : 90
}
}
@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"System": "Information",
"Microsoft": "Information"
}
}
}
View File
View File
@@ -0,0 +1,113 @@
using Microsoft.Extensions.Hosting.Internal;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moq;
using TinfoilVibeServer.Authentication;
using TinfoilVibeServer.Services;
// <-- adjust namespace
namespace TinfoilVibeServerTest.Tests
{
[TestFixture]
public class AuthStoreTests
{
private Mock<ILogger<AuthStore>> _loggerMock;
private AuthStore _authStore;
[SetUp]
public void SetUp()
{
_loggerMock = new Mock<ILogger<AuthStore>>();
// Assume Settings is static and can be patched for tests
MockConfigManager = new Mock<ConfigManager>();
var env = new Mock<HostingEnvironment>();
_authStore = new AuthStore(_loggerMock.Object, MockConfigManager.Object, env.Object);
}
public Mock<ConfigManager> MockConfigManager { get; set; }
[TearDown]
public void TearDown()
{
_authStore.Dispose();
}
[Test]
public void LoadAll_ShouldPopulateCollections()
{
// Act
var users = _authStore.Credentials.Count;
var fprs = _authStore.Fingerprints.Count;
// Assert
//Assert.That(users, Is.GreaterThan(0), "At least one user must be loaded");
Assert.That(fprs, Is.GreaterThanOrEqualTo(0));
_loggerMock.Verify(
x => x.Log(
LogLevel.Information,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString().Contains("Loaded")),
null,
(Func<It.IsAnyType, Exception, string>)It.IsAny<object>()),
Times.AtLeastOnce);
}
[Test]
public void TryValidate_NewUser_ShouldCreateAndVerify()
{
// Arrange
var newUser = "newuser";
var ip = "127.0.0.1";
var password = "";
var uid = "";
// Act
var result = _authStore.TryValidate(newUser, password, uid, ip, out var cred);
// Assert
Assert.That(result, Is.False, "New user should be not be verified automatically");
Assert.That(_authStore.Credentials[newUser], Is.Not.Null);
Assert.That(_authStore.Credentials[newUser].Verified, Is.False);
// New user should now exist
Assert.That(_authStore.Credentials.Any(u => u.Value.Username == newUser), Is.True);
}
[Test]
public void IncrementFailed_BeforeBlacklist_ShouldNotBlacklist()
{
// Arrange
var ip = "203.0.113.5";
var cred = new Credential("dummy", "hash", 1, false);
_authStore.UnbanIp(ip); // ensure clean
// Act
var counter = _authStore.IncrementFailed(cred.Username, ip);
// Assert
Assert.That(counter, Is.EqualTo(1));
Assert.That(_authStore.IsIPBlacklisted(ip), Is.False);
}
[Test]
public void IncrementFailed_ExceedingThreshold_ShouldBlacklist()
{
// Arrange
var ip = "203.0.113.5";
var cred = new Credential("dummy", "hash", MockConfigManager.Object.Settings.MaxFailedAttempts, false);
int threshold = MockConfigManager.Object.Settings.MaxFailedAttempts;
// Simulate threshold failures
for (int i = 0; i < threshold; i++)
_authStore.IncrementFailed(cred.Username, ip);
// Act
int final = _authStore.IncrementFailed(cred.Username, ip);
// Assert
Assert.That(final, Is.EqualTo(threshold + 1));
Assert.That(_authStore.IsIPBlacklisted(ip), Is.True);
}
}
}
@@ -0,0 +1,117 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Text;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Moq;
using NUnit.Framework;
using TinfoilVibeServer.Authentication;
using TinfoilVibeServer.Middleware;
namespace TinfoilVibeServerTest.Tests
{
[TestFixture]
public class BasicAuthMiddlewareTests
{
private Mock<ILogger<BasicAuthMiddleware>> _loggerMock;
private Mock<IAuthStore> _authMock;
private BasicAuthMiddleware _middleware;
private RequestDelegate _next;
[SetUp]
public void SetUp()
{
_loggerMock = new Mock<ILogger<BasicAuthMiddleware>>();
_authMock = new Mock<IAuthStore>();
_next = (HttpContext ctx) => Task.CompletedTask;
_middleware = new BasicAuthMiddleware(_next);
}
private HttpContext CreateContext(string authHeader = "", string ip = "127.0.0.1", string uid = "")
{
var ctx = new DefaultHttpContext();
ctx.Connection.RemoteIpAddress = IPAddress.Parse(ip);
if (!string.IsNullOrEmpty(authHeader))
{
ctx.Request.Headers["Authorization"] = authHeader;
}
if (uid!= null)
{
ctx.Request.Headers["UID"] = uid.ToString();
}
return ctx;
}
[Test]
public async Task InvokeAsync_NoAuthHeader_ShouldReturn401()
{
// Arrange
var ctx = CreateContext();
ctx.Request.Path = new PathString("/");
// Act
await _middleware.InvokeAsync(ctx, _authMock.Object, _loggerMock.Object);
// Assert
Assert.That(ctx.Response.StatusCode, Is.EqualTo(StatusCodes.Status401Unauthorized));
_loggerMock.Verify(l => l.Log(
LogLevel.Warning,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString().Contains("Missing Authorization header")),
null,
It.IsAny<Func<It.IsAnyType, Exception, string>>()), Times.Once);
}
[Test]
public async Task InvokeAsync_BlacklistedIP_ShouldReturn403()
{
// Arrange
var ctx = CreateContext("Basic dXNlcjpwYXNz");
ctx.Request.Path = new PathString("/");
_authMock.Setup(a => a.IsIPBlacklisted("127.0.0.1")).Returns(true);
// Act
await _middleware.InvokeAsync(ctx, _authMock.Object, _loggerMock.Object);
// Assert
Assert.That(ctx.Response.StatusCode, Is.EqualTo(StatusCodes.Status403Forbidden));
}
[Test]
public async Task InvokeAsync_ValidCredentials_ShouldCallNext()
{
// Arrange
var user = "alice";
var pw = "secret";
var uid = "1234";
var header = $"Basic {Convert.ToBase64String(Encoding.ASCII.GetBytes($"{user}:{pw}"))}";
var ip = "127.0.0.1";
var ctx = CreateContext(header,ip, uid);
string? error;
_authMock.Setup(a =>
a.TryValidate(user, pw, uid, ip, out error))
.Returns(true);
bool nextCalled = false;
_next = (HttpContext _) => { nextCalled = true; return Task.CompletedTask; };
_middleware = new BasicAuthMiddleware(_next);
// Act
await _middleware.InvokeAsync(ctx, _authMock.Object, _loggerMock.Object);
// Assert
Assert.That(nextCalled, Is.True);
Assert.That(ctx.Response.StatusCode, Is.EqualTo(StatusCodes.Status200OK));
}
}
}
@@ -0,0 +1,147 @@
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using LibHac.Ncm;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Hosting.Internal;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moq;
using NUnit.Framework;
using TinfoilVibeServer.Models;
using TinfoilVibeServer.Services;
using TinfoilVibeServer.Utilities;
namespace TinfoilVibeServerTest.Tests
{
[TestFixture]
public class SnapshotServiceTests
{
private Mock<ILogger<SnapshotService>> _loggerMock;
private SnapshotService _service;
private Mock<INSPExtractor> _nspExtractorMock;
private Mock<IArchiveHandler> _archiveHander;
private Mock<IOptionsMonitor<SnapshotOptions>> _mockOptions;
private SnapshotOptions _options;
private MemoryCache _memoryCache;
[SetUp]
public void SetUp()
{
_mockOptions = new Mock<IOptionsMonitor<SnapshotOptions>>();
_options = new SnapshotOptions()
{
RomExtensions = [".nsp"],
RootDirectories = ["TestData/ROMS"],
SnapshotFile = "TestData/snapshot.json",
SnapshotBackupFile = "TestData/snapshot.bak"
};
/*// ensure ROM test directory has test files removed
foreach (var file in Directory.GetFiles("TestData/ROMS"))
{
File.Delete(file);
}*/
_mockOptions.Setup(m => m.CurrentValue).Returns(_options);
_loggerMock = new Mock<ILogger<SnapshotService>>();
_archiveHander = new Mock<IArchiveHandler>();
_nspExtractorMock = new Mock<INSPExtractor>();
var hostEnv = new Mock<HostingEnvironment>();
var memoryCacheOptions = Options.Create(new MemoryCacheOptions());
_memoryCache = new MemoryCache(memoryCacheOptions);
_nspExtractorMock.Setup(extractor => extractor.ExtractHashFromStream(It.IsAny<Stream>())).Returns("HASH");
_nspExtractorMock.Setup(extractor => extractor.ExtractFromStream(It.IsAny<Stream>())).Returns(
new NcaMetadataWithHash(titleId: "0000000000000000","0000000000000000", version: 1, ContentMetaType.Application, "HASH"));
//Settings.RootDirs = new List<string> { "TestData/Root1", "TestData/Root2" };
_service = new SnapshotService(_memoryCache, _mockOptions.Object, _nspExtractorMock.Object, _archiveHander.Object, _loggerMock.Object, hostEnv.Object);
}
[TearDown]
public void TearDown()
{
_service.Dispose();
_memoryCache.Dispose();
}
[Test]
public async Task BuildSnapshot_WhenFilesChanged_ShouldPersist()
{
// Arrange
var initialHash = _service.GetSnapshot()?.Hash;
var rebuilding = false;
var rebuilt = false;
CancellationTokenSource snapshotRebuilding = new();
_service.SnapshotRebuilding += (sender, args) =>
{
rebuilding = true;
snapshotRebuilding.Cancel();
};
CancellationTokenSource snapshotPersisting = new();
_service.SnapshotRebuilt+= (sender, args) =>
{
rebuilt = true;
snapshotPersisting.Cancel();
// Assert
var newHash = _service.GetSnapshot()?.Hash;
Assert.That(newHash, Is.Not.EqualTo(initialHash));
};
Timer timer = new(state =>
{
snapshotPersisting.Cancel();
snapshotRebuilding.Cancel();
}, null, 20*1000, 0);
await File.WriteAllTextAsync(_options.SnapshotFile, "[]", snapshotPersisting.Token);
// Add a file to Root1
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]
public async Task BuildSnapshot_NoChange_ShouldNotPersist()
{
// Act
_service.BuildSnapshotAsync();
// Act again snapshot should be identical
_service.BuildSnapshotAsync();
// Assert
_loggerMock.Verify(
l => l.Log(
LogLevel.Information,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString().Contains("persisting new snapshot")),
null,
It.IsAny<Func<It.IsAnyType, Exception, string>>()), Times.Never);
}
}
}
@@ -0,0 +1,46 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<LangVersion>latest</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<CollectCoverage>true</CollectCoverage>
<CoverletOutputFormat>opencover</CoverletOutputFormat>
<CoverletOutput>../coverage/</CoverletOutput>
<ExcludeByFile>**/Program.cs</ExcludeByFile> <!-- if you dont want the host code -->
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.10" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.0" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="NUnit" Version="4.4.0" />
<PackageReference Include="NUnit.Analyzers" Version="4.11.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="NUnit3TestAdapter" Version="5.2.0" />
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="22.0.16" />
</ItemGroup>
<ItemGroup>
<Using Include="NUnit.Framework"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\TinfoilVibeServer\TinfoilVibeServer.csproj" />
</ItemGroup>
<ItemGroup>
<Folder Include="Data\" />
</ItemGroup>
<ItemGroup>
<Reference Include="LibHac">
<HintPath>..\libhac\src\LibHac\bin\Release\net8.0\LibHac.dll</HintPath>
</Reference>
</ItemGroup>
</Project>
+8
View File
@@ -0,0 +1,8 @@
# docker-compose.override.yml
services:
tinfoilvibeserver:
environment:
- LOG_LEVEL=error
- APP_MODE=production
ports:
- "8080:8080" # expose on host port 80
+19 -10
View File
@@ -1,13 +1,22 @@
services:
consoleapp1:
image: consoleapp1
build:
context: .
dockerfile: ConsoleApp1/Dockerfile
version: "3.9"
services:
tinfoilvibeserver:
image: tinfoilvibeserver
build:
context: .
dockerfile: TinfoilVibeServer/Dockerfile
context: TinfoilVibeServer
dockerfile: Dockerfile
image: gitea.ecenshu.net/ecenshu/tinfoilvibeserver:latest
container_name: tinfoilvibeserver
restart: unless-stopped
user: "1000:1000"
env_file:
- .env
environment:
- ASPNETCORE_ENVIRONMENT=Production # .NETspecific
- LOG_LEVEL=${LOG_LEVEL} # just a doublecheck, uses .env value
volumes:
- ./data:/app/data
- ./config:/app/config
ports:
- ":80"