Restructure into src/tests and add build foundation (M0)

Moves the scaffold to src/DodoSSH.Api and establishes the repo conventions the rest
of the milestones build on.

Structure:
- src/{Contracts,Crypto,Domain,Infrastructure,Api}, tests/{Contracts,Crypto,Domain}.Tests
- DodoSSH.slnx rewritten with src/ and tests/ solution folders

Build:
- Directory.Build.props centralises TFM, nullable, deterministic builds and
  TreatWarningsAsErrors; Directory.Packages.props pins every version centrally
- packages.lock.json committed so CI restores in locked mode
- NuGet.config clears machine-level sources, which both fixes NU1507 under central
  package management and makes restore reproducible off this machine
- Microsoft.OpenApi pinned to 2.11.0: ASP.NET Core 10.0.10 resolves 2.0.0, which is
  covered by GHSA-v5pm-xwqc-g5wc (high, patched in 2.7.5)

Analyzers:
- AnalysisLevel is Recommended, not All. With warnings-as-errors, All turns opinionated
  naming rules into build breaks and trains people to blanket-suppress.
- BannedSymbols.txt bans DateTime.UtcNow (TimeProvider), Guid.NewGuid (CreateVersion7),
  sync-over-async, MD5/SHA1, PBKDF2 and SecureString
- CA1711/CA1724 disabled: both are .NET Framework CAS-era naming rules
- PublicApiAnalyzers on Contracts only, since that assembly is the client's real contract

API:
- weather-forecast template removed
- UseHttpsRedirection removed; TLS terminates at the reverse proxy and redirecting
  behind one causes loops
- /healthz/{live,ready,startup}. Liveness deliberately checks no dependencies so a
  transient database outage cannot restart the container and kill live SSH sessions.

Notes:
- No coverage collector yet. Microsoft.Testing.Extensions.CodeCoverage pulls an MTP 1.x
  MSBuild extension that throws TypeLoadException against the MTP 2.3.x xunit.v3 brings.
  Coverage gates are an M3 concern; revisit with an MTP 2.x-aligned version then.

Verified: dotnet build (0 warnings), 17 tests pass, format check clean, API serves
health and OpenAPI endpoints.
This commit is contained in:
2026-07-28 12:25:34 +02:00
parent 1138291d79
commit 3a81f3c90b
43 changed files with 1714 additions and 62 deletions
+13
View File
@@ -0,0 +1,13 @@
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-ef": {
"version": "10.0.10",
"commands": [
"dotnet-ef"
],
"rollForward": false
}
}
}
+114
View File
@@ -0,0 +1,114 @@
# EditorConfig for DodoSSH — https://editorconfig.org
root = true
[*]
charset = utf-8
end_of_line = lf
indent_style = space
indent_size = 4
insert_final_newline = true
trim_trailing_whitespace = true
[*.{json,yml,yaml,js,ts,css,html,axaml,xaml,csproj,props,targets,slnx}]
indent_size = 2
[*.md]
# Two trailing spaces are a hard line break in Markdown.
trim_trailing_whitespace = false
[*.{cmd,bat,ps1}]
end_of_line = crlf
[*.cs]
indent_size = 4
#### Language conventions ####
csharp_style_namespace_declarations = file_scoped:error
csharp_using_directive_placement = outside_namespace:error
csharp_style_var_for_built_in_types = false:suggestion
csharp_style_var_when_type_is_apparent = true:suggestion
csharp_style_var_elsewhere = false:suggestion
csharp_prefer_braces = true:suggestion
csharp_style_prefer_primary_constructors = true:suggestion
csharp_style_expression_bodied_methods = when_on_single_line:suggestion
csharp_style_expression_bodied_properties = true:suggestion
dotnet_sort_system_directives_first = true
dotnet_separate_import_directive_groups = false
dotnet_style_qualification_for_field = false:suggestion
dotnet_style_qualification_for_property = false:suggestion
dotnet_style_qualification_for_method = false:suggestion
dotnet_style_readonly_field = true:warning
dotnet_style_require_accessibility_modifiers = for_non_interface_members:warning
dotnet_style_coalesce_expression = true:suggestion
dotnet_style_null_propagation = true:suggestion
dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion
# Async methods must be suffixed Async (VSTHRD200 equivalent via naming rules below).
dotnet_naming_rule.async_methods_end_in_async.severity = warning
dotnet_naming_rule.async_methods_end_in_async.symbols = any_async_method
dotnet_naming_rule.async_methods_end_in_async.style = ends_with_async
dotnet_naming_symbols.any_async_method.applicable_kinds = method
dotnet_naming_symbols.any_async_method.required_modifiers = async
dotnet_naming_style.ends_with_async.required_suffix = Async
dotnet_naming_style.ends_with_async.capitalization = pascal_case
dotnet_naming_rule.interfaces_start_with_i.severity = warning
dotnet_naming_rule.interfaces_start_with_i.symbols = any_interface
dotnet_naming_rule.interfaces_start_with_i.style = starts_with_i
dotnet_naming_symbols.any_interface.applicable_kinds = interface
dotnet_naming_style.starts_with_i.required_prefix = I
dotnet_naming_style.starts_with_i.capitalization = pascal_case
dotnet_naming_rule.private_fields_are_camel_case.severity = warning
dotnet_naming_rule.private_fields_are_camel_case.symbols = private_field
dotnet_naming_rule.private_fields_are_camel_case.style = camel_case_style
dotnet_naming_symbols.private_field.applicable_kinds = field
dotnet_naming_symbols.private_field.applicable_accessibilities = private
dotnet_naming_style.camel_case_style.capitalization = camel_case
#### Diagnostics ####
# Formatting violations fail the build; `dotnet format --verify-no-changes` gates CI.
dotnet_diagnostic.IDE0055.severity = error
# ConfigureAwait is not meaningful in ASP.NET Core (no SynchronizationContext). It IS
# meaningful in the Avalonia client, which re-enables CA2007 in its own .editorconfig.
dotnet_diagnostic.CA2007.severity = none
# Prefer LoggerMessage source generation over ILogger extension calls — allocation-free
# and gives structured events by construction. Warning, so it is visible but not a wall
# during early development; raised to error once the logging pass lands in M4.
dotnet_diagnostic.CA1848.severity = warning
# Exceptions carry ProblemDetails codes, not localised text.
dotnet_diagnostic.CA1303.severity = none
# CA1711 reserves the suffixes Flags, Permission, Collection, Stream and friends for
# .NET Framework CAS and BCL base types that have no bearing on this codebase. The BCL
# itself ships BindingFlags. PermissionFlags is the clearest name for a [Flags] enum of
# permissions, and contorting domain vocabulary to satisfy a legacy rule costs more than
# it returns.
dotnet_diagnostic.CA1711.severity = none
# CA1724 flags any type whose name collides with a BCL *namespace* (e.g. a type named
# Permissions vs System.Security.Permissions). Namespace-qualified resolution makes this
# a non-issue in practice and it heavily constrains domain naming.
dotnet_diagnostic.CA1724.severity = none
# We use file-scoped namespaces and modern C#; these fire on deliberate style choices.
dotnet_diagnostic.CA1812.severity = none # internal types instantiated by DI
dotnet_diagnostic.CA1849.severity = warning # sync call in async method
[tests/**/*.cs]
# Test classes are instantiated by xunit, and test data is often public static.
dotnet_diagnostic.CA1812.severity = none
dotnet_diagnostic.CA1034.severity = none
[src/DodoSSH.Infrastructure/Migrations/*.cs]
# EF Core generates these; do not lint or format them.
generated_code = true
dotnet_analyzer_diagnostic.severity = none
dotnet_diagnostic.IDE0055.severity = none
+72
View File
@@ -0,0 +1,72 @@
name: ci
on:
push:
branches: [main]
pull_request:
branches: [main]
# Actions are pinned to commit SHAs, not tags: a tag can be moved to point at new code,
# which would let a compromised action run with this workflow's permissions.
permissions:
contents: read
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
DOTNET_NOLOGO: true
DOTNET_CLI_TELEMETRY_OPTOUT: true
DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true
CI: true
jobs:
build:
name: build and test (ubuntu)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
global-json-file: global.json
cache: true
cache-dependency-path: '**/packages.lock.json'
# Locked mode fails if packages.lock.json does not match the project files, so a
# dependency cannot change without the lock file change being reviewed.
- name: restore
run: dotnet restore DodoSSH.slnx --locked-mode
- name: verify formatting
run: dotnet format DodoSSH.slnx --verify-no-changes --no-restore
- name: build
run: dotnet build DodoSSH.slnx --no-restore --configuration Release
- name: test
run: dotnet test DodoSSH.slnx --no-build --configuration Release
# Integration tests land in M1 and need Docker for Testcontainers (PostgreSQL,
# OpenSSH). They run on this ubuntu job because macOS runners have no Docker daemon.
build-windows:
name: build (windows)
runs-on: windows-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
global-json-file: global.json
cache: true
cache-dependency-path: '**/packages.lock.json'
- name: restore
run: dotnet restore DodoSSH.slnx --locked-mode
# Build only. Day-to-day development happens in Rider on Windows, so a
# Windows-specific compile break must fail CI even though the tests run on Linux.
- name: build
run: dotnet build DodoSSH.slnx --no-restore --configuration Release
+4 -1
View File
@@ -4,7 +4,10 @@
[Oo]ut/
[Ll]og/
[Ll]ogs/
artifacts/
# artifacts/* rather than artifacts/ — git does not descend into an excluded directory,
# so a negation under a directory-level ignore never matches. The committed OpenAPI
# document and schema snapshots live here and are CI-diffed.
artifacts/*
!artifacts/openapi/
!artifacts/schema/
+42
View File
@@ -0,0 +1,42 @@
# Banned APIs, enforced by Microsoft.CodeAnalysis.BannedApiAnalyzers (RS0030).
# Format: <documentation-comment-id>;<message>
# See docs/adr/ for the reasoning behind each group.
## Time — everything in DodoSSH is UTC and must be fakeable in tests.
P:System.DateTime.Now;Use TimeProvider.GetUtcNow(). All DodoSSH timestamps are UTC (timestamptz) and must be injectable for tests.
P:System.DateTime.UtcNow;Use TimeProvider.GetUtcNow() so time can be faked in tests.
P:System.DateTime.Today;Use TimeProvider.GetUtcNow().Date.
P:System.DateTimeOffset.Now;Use TimeProvider.GetUtcNow().
P:System.DateTimeOffset.UtcNow;Use TimeProvider.GetUtcNow() so time can be faked in tests.
## Identifiers — UUIDv7 gives sortable PKs with good index locality, and clients
## must be able to mint ids offline.
M:System.Guid.NewGuid;Use Guid.CreateVersion7() for sortable primary keys.
## Randomness — anything key-, token- or nonce-adjacent must be cryptographic.
T:System.Random;Use RandomNumberGenerator for anything security-relevant, or inject a seeded generator for tests.
## Sync-over-async — deadlocks under ASP.NET and stalls the Avalonia UI thread.
P:System.Threading.Tasks.Task`1.Result;Await the task instead; .Result deadlocks and hides exceptions in an AggregateException.
M:System.Threading.Tasks.Task.Wait;Await the task instead.
M:System.Threading.Tasks.Task.WaitAll;Use Task.WhenAll with await.
M:System.Threading.Tasks.Task.WaitAny;Use Task.WhenAny with await.
M:System.Threading.Tasks.Task.GetAwaiter;Await the task directly rather than blocking on the awaiter.
## Encoding — must be explicit, never the ambient codepage.
P:System.Text.Encoding.Default;Specify the encoding explicitly; Encoding.Default varies by platform.
## Culture-sensitive string handling is already covered by CA1304/CA1307/CA1311,
## which AnalysisLevel=latest-All turns on. Not duplicated here.
## Cryptography — the client holds key material in libsodium guarded memory, and
## MD5/SHA1 have no place in this product. Fingerprints are SHA-256.
T:System.Security.Cryptography.MD5;Banned. SSH fingerprints are SHA-256; see docs/crypto.md.
T:System.Security.Cryptography.SHA1;Banned. Use SHA-256 or better.
T:System.Security.Cryptography.Rfc2898DeriveBytes;PBKDF2 is not our KDF. Use Argon2id via DodoSSH.Crypto; see docs/crypto.md.
T:System.Security.SecureString;Deprecated and not cross-platform. Use a pooled byte[] zeroed with CryptographicOperations.ZeroMemory.
# Note: constant-time comparison of secrets (CryptographicOperations.FixedTimeEquals
# over Enumerable.SequenceEqual) is enforced by a BannedSymbols.txt scoped to
# DodoSSH.Crypto, not globally — banning SequenceEqual everywhere is pure noise in
# business logic and tests, and noisy bans just train people to suppress them.
+51
View File
@@ -0,0 +1,51 @@
<Project>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<PropertyGroup Label="Quality gates">
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<!--
Recommended, not All. With TreatWarningsAsErrors on, latest-All turns opinionated
naming rules (CA1711 reserved suffixes, CA1724 namespace collisions) into build
breaks, which trains people to reach for blanket suppressions and devalues the
analyzers that catch real defects. Correctness and security rules we specifically
want beyond Recommended are raised individually in .editorconfig.
-->
<AnalysisLevel>latest-Recommended</AnalysisLevel>
<AnalysisMode>Recommended</AnalysisMode>
<!-- The whole product is UTC-only and container-hosted; no tzdata, no culture-sensitive
formatting. Set here rather than per-project so it cannot drift. -->
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
<PropertyGroup Label="Reproducible builds">
<Deterministic>true</Deterministic>
<ContinuousIntegrationBuild Condition="'$(CI)' == 'true'">true</ContinuousIntegrationBuild>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
<!-- packages.lock.json is committed; CI restores in locked mode. -->
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
</PropertyGroup>
<PropertyGroup Label="Assembly metadata">
<Company>DodoTech</Company>
<Product>DodoSSH</Product>
<NeutralLanguage>en</NeutralLanguage>
</PropertyGroup>
<ItemGroup Label="Analyzers">
<PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" PrivateAssets="all" />
<PackageReference Include="Meziantou.Analyzer" PrivateAssets="all" />
</ItemGroup>
<ItemGroup Label="Banned API list">
<AdditionalFiles Include="$(MSBuildThisFileDirectory)BannedSymbols.txt" Visible="false" />
</ItemGroup>
</Project>
+52
View File
@@ -0,0 +1,52 @@
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
</PropertyGroup>
<!--
Versions are pinned here for the whole solution. Packages are added per milestone
rather than all at once, so that every entry is one we have actually verified and
restored. See docs/adr/ for the choices behind the notable ones.
-->
<ItemGroup Label="ASP.NET Core">
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.10" />
</ItemGroup>
<ItemGroup Label="Pinned transitive dependencies">
<!--
Microsoft.AspNetCore.OpenApi 10.0.10 resolves Microsoft.OpenApi 2.0.0, which is
covered by GHSA-v5pm-xwqc-g5wc (high: circular schema references can terminate
OpenAPI parsing; vulnerable <= 2.7.4, patched in 2.7.5). Pinned forward within the
2.x major that ASP.NET Core 10 targets. Revisit when the ASP.NET Core package
itself moves off 2.0.0.
-->
<PackageVersion Include="Microsoft.OpenApi" Version="2.11.0" />
</ItemGroup>
<ItemGroup Label="Analyzers">
<PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="5.6.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.PublicApiAnalyzers" Version="5.6.0" />
<PackageVersion Include="Meziantou.Analyzer" Version="3.0.134" />
</ItemGroup>
<ItemGroup Label="Testing">
<!--
xunit.v3 runs on Microsoft.Testing.Platform, not VSTest. Microsoft.NET.Test.Sdk and
coverlet.collector are VSTest components: referencing them alongside MTP raises
MTP0001 and their collector never runs, so neither is referenced.
No coverage collector yet. Microsoft.Testing.Extensions.CodeCoverage 18.9.0 pulls
Microsoft.Testing.Platform.MSBuild 1.9.1, which is built against MTP 1.x and throws
TypeLoadException on IDataConsumer against the MTP 2.3.x that xunit.v3 3.2.2 brings.
Coverage gates are an M3 concern (90% on Domain and Authorization); pick a version
aligned with MTP 2.x then rather than carrying a broken dependency until it matters.
-->
<PackageVersion Include="xunit.v3" Version="3.2.2" />
<PackageVersion Include="Shouldly" Version="4.3.0" />
<PackageVersion Include="NSubstitute" Version="6.0.0" />
</ItemGroup>
</Project>
+25 -1
View File
@@ -1,3 +1,27 @@
<Solution>
<Project Path="DodoSSH/DodoSSH.csproj" />
<Folder Name="/solution items/">
<File Path="Directory.Build.props" />
<File Path="Directory.Packages.props" />
<File Path="BannedSymbols.txt" />
<File Path=".editorconfig" />
<File Path="NuGet.config" />
<File Path="global.json" />
<File Path=".gitattributes" />
</Folder>
<Folder Name="/src/">
<Project Path="src/DodoSSH.Contracts/DodoSSH.Contracts.csproj" />
<Project Path="src/DodoSSH.Crypto/DodoSSH.Crypto.csproj" />
<Project Path="src/DodoSSH.Domain/DodoSSH.Domain.csproj" />
<Project Path="src/DodoSSH.Infrastructure/DodoSSH.Infrastructure.csproj" />
<Project Path="src/DodoSSH.Api/DodoSSH.Api.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/DodoSSH.Contracts.Tests/DodoSSH.Contracts.Tests.csproj" />
<Project Path="tests/DodoSSH.Crypto.Tests/DodoSSH.Crypto.Tests.csproj" />
<Project Path="tests/DodoSSH.Domain.Tests/DodoSSH.Domain.Tests.csproj" />
</Folder>
</Solution>
-13
View File
@@ -1,13 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.10"/>
</ItemGroup>
</Project>
-6
View File
@@ -1,6 +0,0 @@
@DodoSSH_HostAddress = http://localhost:5233
GET {{DodoSSH_HostAddress}}/weatherforecast/
Accept: application/json
###
-41
View File
@@ -1,41 +0,0 @@
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
builder.Services.AddOpenApi();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.UseHttpsRedirection();
var summaries = new[]
{
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
};
app.MapGet("/weatherforecast", () =>
{
var forecast = Enumerable.Range(1, 5).Select(index =>
new WeatherForecast
(
DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
Random.Shared.Next(-20, 55),
summaries[Random.Shared.Next(summaries.Length)]
))
.ToArray();
return forecast;
})
.WithName("GetWeatherForecast");
app.Run();
record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary)
{
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
}
+30
View File
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<!--
clear drops any machine- or user-level sources so a restore here resolves the same
packages on every developer machine and in CI. Central package management also
requires either a single source or explicit source mapping (NU1507).
-->
<packageSources>
<clear />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
</packageSources>
<packageSourceMapping>
<packageSource key="nuget.org">
<package pattern="*" />
</packageSource>
</packageSourceMapping>
<config>
<!-- Keep the restore cache inside the repo? No: the default global cache is
correct and shared. This section is left for future proxy settings. -->
</config>
<auditSources>
<clear />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
</auditSources>
</configuration>
+17
View File
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<!--
TargetFramework, Nullable, ImplicitUsings, analyzers and central package
management all come from ../../Directory.Build.props.
-->
<ItemGroup>
<!-- Integration tests reach Program through WebApplicationFactory<Program>. -->
<InternalsVisibleTo Include="DodoSSH.Api.Tests" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
</ItemGroup>
</Project>
+26
View File
@@ -0,0 +1,26 @@
using DodoSSH.Api.Setup;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDodoOpenApi();
builder.Services.AddDodoHealthChecks();
// DateTime.UtcNow is banned repo-wide (see BannedSymbols.txt); everything takes
// TimeProvider so time can be faked in tests.
builder.Services.AddSingleton(TimeProvider.System);
var app = builder.Build();
// Deliberately no UseHttpsRedirection: the API is always fronted by a reverse proxy
// (Caddy in the reference compose stack) which terminates TLS. Redirecting here
// produces redirect loops behind a proxy. HTTPS in development comes from the
// launch profile instead.
app.MapDodoHealthChecks();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
await app.RunAsync().ConfigureAwait(false);
+57
View File
@@ -0,0 +1,57 @@
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.Extensions.Diagnostics.HealthChecks;
namespace DodoSSH.Api.Setup;
/// <summary>
/// Health check wiring.
/// </summary>
/// <remarks>
/// The split between liveness and readiness is deliberate and load-bearing for the
/// relay: <c>/healthz/live</c> checks the process and nothing else, so a transient
/// PostgreSQL outage cannot cause the orchestrator to restart the container and
/// guillotine every live SSH session. Dependency checks belong in
/// <c>/healthz/ready</c>, which only removes the instance from load balancing.
/// </remarks>
internal static class HealthChecks
{
/// <summary>Tag for checks that gate readiness (dependencies).</summary>
internal const string ReadyTag = "ready";
/// <summary>Tag for checks that gate startup completion.</summary>
internal const string StartupTag = "startup";
internal static IServiceCollection AddDodoHealthChecks(this IServiceCollection services)
{
services.AddHealthChecks();
// Dependency checks are registered by the milestone that introduces the
// dependency, each tagged ReadyTag:
// M1 — PostgreSQL, OIDC discovery + JWKS reachability, pending migrations
// M4 — Data Protection key ring readability
return services;
}
internal static WebApplication MapDodoHealthChecks(this WebApplication app)
{
// Liveness: process is running and the pipeline responds. No dependencies.
app.MapHealthChecks("/healthz/live", new HealthCheckOptions
{
Predicate = _ => false,
}).AllowAnonymous();
// Readiness: safe to route traffic here.
app.MapHealthChecks("/healthz/ready", new HealthCheckOptions
{
Predicate = registration => registration.Tags.Contains(ReadyTag),
}).AllowAnonymous();
// Startup: one-time initialisation finished (K8s startupProbe).
app.MapHealthChecks("/healthz/startup", new HealthCheckOptions
{
Predicate = registration => registration.Tags.Contains(StartupTag),
}).AllowAnonymous();
return app;
}
}
+27
View File
@@ -0,0 +1,27 @@
namespace DodoSSH.Api.Setup;
/// <summary>
/// OpenAPI document configuration.
/// </summary>
/// <remarks>
/// The generated document exists for third parties and a future CLI. It is emitted at
/// build time to <c>artifacts/openapi/v1.json</c> and diffed in CI so an unintended
/// contract change fails the pull request. The desktop client's actual contract is the
/// <c>DodoSSH.Contracts</c> assembly, guarded by PublicApiAnalyzers.
/// </remarks>
internal static class OpenApi
{
internal const string DocumentName = "v1";
internal static IServiceCollection AddDodoOpenApi(this IServiceCollection services)
{
services.AddOpenApi(DocumentName);
// Added in M1, once there are endpoints to describe:
// - a document transformer contributing the OAuth2 authorizationCode + PKCE
// security scheme, so the document is usable from a generated client
// - a schema transformer mapping byte[] to {type: string, format: byte},
// since every ciphertext field crosses the wire as base64
return services;
}
}
+34
View File
@@ -0,0 +1,34 @@
{
"version": 2,
"dependencies": {
"net10.0": {
"Meziantou.Analyzer": {
"type": "Direct",
"requested": "[3.0.134, )",
"resolved": "3.0.134",
"contentHash": "tTYCcYKyOko3TMNxmxmA9nakbcHVUgglENmCMIhzIjl9y9FBZO/0tWSxTGC74Sp198FmWih5S5KkjQRBg5ePkQ=="
},
"Microsoft.AspNetCore.OpenApi": {
"type": "Direct",
"requested": "[10.0.10, )",
"resolved": "10.0.10",
"contentHash": "d4Atx9IHq7JgX0F/h7Db+m9zAUzC+cKdI9k+OWnnyQIOUQtfvjIEuhvbjPigVMkAmPUgCbJ8Yp6M9ghUqHtJSQ==",
"dependencies": {
"Microsoft.OpenApi": "2.0.0"
}
},
"Microsoft.CodeAnalysis.BannedApiAnalyzers": {
"type": "Direct",
"requested": "[5.6.0, )",
"resolved": "5.6.0",
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
},
"Microsoft.OpenApi": {
"type": "CentralTransitive",
"requested": "[2.11.0, )",
"resolved": "2.11.0",
"contentHash": "/ignjfdeKT2SGLIR7QEv19KnI0rvoxRG/TYDOZdK9EsWLjKK9IK8i1Mo5NRm9PRV3i64DzlTqnIflWvoyfljLg=="
}
}
}
}
@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
DTOs shared between the API and the desktop client. This assembly, not the generated
OpenAPI document, is the real client contract, so PublicApiAnalyzers is enabled here
and only here: an accidental change to a public member becomes a build error rather
than a runtime deserialisation failure on somebody's laptop.
Track additions in PublicAPI.Unshipped.txt; move them to PublicAPI.Shipped.txt when a
version is released.
-->
<PropertyGroup>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<IsTrimmable>true</IsTrimmable>
<IsAotCompatible>true</IsAotCompatible>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.PublicApiAnalyzers" PrivateAssets="all" />
</ItemGroup>
<ItemGroup>
<AdditionalFiles Include="PublicAPI.Shipped.txt" />
<AdditionalFiles Include="PublicAPI.Unshipped.txt" />
</ItemGroup>
</Project>
+48
View File
@@ -0,0 +1,48 @@
namespace DodoSSH.Contracts;
/// <summary>
/// Stable machine-readable error codes returned in the <c>code</c> extension of an
/// RFC 9457 ProblemDetails response.
/// </summary>
/// <remarks>
/// These live in Contracts so the client switches on constants rather than parsing prose.
/// The values are part of the public contract: add freely, never rename or repurpose.
/// </remarks>
public static class ProblemCodes
{
/// <summary>The base URI that every problem <c>type</c> is formed under.</summary>
public const string TypeBaseUri = "https://dodossh.dev/problems/";
/// <summary>A push operation's <c>expectedVersion</c> did not match the stored row.</summary>
public const string VaultConflict = "vault-conflict";
/// <summary>The caller is authenticated but lacks the required permission.</summary>
public const string Forbidden = "forbidden";
/// <summary>The sync cursor was malformed, or failed its integrity tag.</summary>
public const string InvalidCursor = "invalid-cursor";
/// <summary>An <c>Idempotency-Key</c> was reused with a different request body.</summary>
public const string IdempotencyKeyReuse = "idempotency-key-reuse";
/// <summary>The caller has not yet enrolled a public key, so no vault is reachable.</summary>
public const string EnrollmentRequired = "enrollment-required";
/// <summary>Enrollment was attempted for a user who already holds a current key.</summary>
public const string AlreadyEnrolled = "already-enrolled";
/// <summary>The relay refused the requested target. Never states why, to avoid a probe oracle.</summary>
public const string RelayTargetRejected = "relay-target-rejected";
/// <summary>The relay ticket is expired, already used, or not valid for this node.</summary>
public const string RelayTicketInvalid = "relay-ticket-invalid";
/// <summary>A per-user or per-node relay session limit was reached.</summary>
public const string RelayLimitReached = "relay-limit-reached";
/// <summary>The client is older than the server's <c>minClientVersion</c>.</summary>
public const string ClientTooOld = "client-too-old";
/// <summary>A push batch exceeded the operation count or payload size cap.</summary>
public const string PushBatchTooLarge = "push-batch-too-large";
}
@@ -0,0 +1 @@
#nullable enable
@@ -0,0 +1,14 @@
#nullable enable
DodoSSH.Contracts.ProblemCodes
const DodoSSH.Contracts.ProblemCodes.AlreadyEnrolled = "already-enrolled" -> string!
const DodoSSH.Contracts.ProblemCodes.ClientTooOld = "client-too-old" -> string!
const DodoSSH.Contracts.ProblemCodes.EnrollmentRequired = "enrollment-required" -> string!
const DodoSSH.Contracts.ProblemCodes.Forbidden = "forbidden" -> string!
const DodoSSH.Contracts.ProblemCodes.IdempotencyKeyReuse = "idempotency-key-reuse" -> string!
const DodoSSH.Contracts.ProblemCodes.InvalidCursor = "invalid-cursor" -> string!
const DodoSSH.Contracts.ProblemCodes.PushBatchTooLarge = "push-batch-too-large" -> string!
const DodoSSH.Contracts.ProblemCodes.RelayLimitReached = "relay-limit-reached" -> string!
const DodoSSH.Contracts.ProblemCodes.RelayTargetRejected = "relay-target-rejected" -> string!
const DodoSSH.Contracts.ProblemCodes.RelayTicketInvalid = "relay-ticket-invalid" -> string!
const DodoSSH.Contracts.ProblemCodes.TypeBaseUri = "https://dodossh.dev/problems/" -> string!
const DodoSSH.Contracts.ProblemCodes.VaultConflict = "vault-conflict" -> string!
+31
View File
@@ -0,0 +1,31 @@
{
"version": 2,
"dependencies": {
"net10.0": {
"Meziantou.Analyzer": {
"type": "Direct",
"requested": "[3.0.134, )",
"resolved": "3.0.134",
"contentHash": "tTYCcYKyOko3TMNxmxmA9nakbcHVUgglENmCMIhzIjl9y9FBZO/0tWSxTGC74Sp198FmWih5S5KkjQRBg5ePkQ=="
},
"Microsoft.CodeAnalysis.BannedApiAnalyzers": {
"type": "Direct",
"requested": "[5.6.0, )",
"resolved": "5.6.0",
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
},
"Microsoft.CodeAnalysis.PublicApiAnalyzers": {
"type": "Direct",
"requested": "[5.6.0, )",
"resolved": "5.6.0",
"contentHash": "W4kJGezNIKLzo0Ak5FAQDFvkMf2U7DtGL4THmHyRSApfKsKt5V+eX/bU0ZLKAt/uf9Bb2o1bi0YDKj/GRB/vYQ=="
},
"Microsoft.NET.ILLink.Tasks": {
"type": "Direct",
"requested": "[10.0.10, )",
"resolved": "10.0.10",
"contentHash": "f5VCIE7AJpd5YvzNTeMGVzQIgyE9tX+AreTYwQF+REbu+DZo/2Ae+jNSwhPEYrVz6RRkd7y8ubXjk6Nn6Ka+Cg=="
}
}
}
}
+47
View File
@@ -0,0 +1,47 @@
namespace DodoSSH.Crypto;
/// <summary>
/// Constants of the DodoSSH cryptographic specification.
/// </summary>
/// <remarks>
/// docs/crypto.md is the normative specification; this type must agree with it exactly.
/// The implementation of the envelope, AAD derivation and key wrapping lands in M1, once
/// the specification and its test vectors are frozen. Nothing else may be built on top of
/// an unfrozen AAD: only clients can re-encrypt, so a change after users hold data cannot
/// be migrated server-side.
/// </remarks>
public static class CryptoSpec
{
/// <summary>Magic prefix identifying a DSH1 envelope.</summary>
public const string EnvelopeMagic = "DSH1";
/// <summary>Version of the AAD derivation rule that payloads are bound to.</summary>
/// <remarks>
/// Stored per row as <c>payload_aad_version</c> so a future change can be applied
/// lazily, re-encrypting on next write rather than in a migration.
/// </remarks>
public const short CurrentAadVersion = 1;
/// <summary>Domain-separation prefix for every AAD computation.</summary>
public const string AadDomainPrefix = "dsh1\n";
/// <summary>Identifiers for the algorithms an envelope may declare.</summary>
public enum AlgorithmId : byte
{
/// <summary>Reserved; never written.</summary>
Unspecified = 0,
/// <summary>Symmetric content encryption under a known key.</summary>
XChaCha20Poly1305 = 1,
/// <summary>Symmetric fallback where XChaCha20 is unavailable.</summary>
Aes256Gcm = 2,
/// <summary>Anonymous-sender seal to an X25519 public key.</summary>
SealToX25519 = 3,
// 4 is reserved for a hybrid X25519 + ML-KEM-768 seal. Store-now-decrypt-later is
// a genuine threat for long-lived SSH keys, so the identifier is claimed now even
// though the construction ships later.
}
}
+19
View File
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
The DSH1 envelope format, AAD derivation, key wrapping and the KDF.
Referenced by both the API and the client, but the server only ever uses the format
and fingerprint constants: it never holds a key and never decrypts a payload.
See docs/crypto.md, which is the normative specification for everything here.
-->
<PropertyGroup>
<IsTrimmable>true</IsTrimmable>
<IsAotCompatible>true</IsAotCompatible>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="DodoSSH.Crypto.Tests" />
</ItemGroup>
</Project>
+25
View File
@@ -0,0 +1,25 @@
{
"version": 2,
"dependencies": {
"net10.0": {
"Meziantou.Analyzer": {
"type": "Direct",
"requested": "[3.0.134, )",
"resolved": "3.0.134",
"contentHash": "tTYCcYKyOko3TMNxmxmA9nakbcHVUgglENmCMIhzIjl9y9FBZO/0tWSxTGC74Sp198FmWih5S5KkjQRBg5ePkQ=="
},
"Microsoft.CodeAnalysis.BannedApiAnalyzers": {
"type": "Direct",
"requested": "[5.6.0, )",
"resolved": "5.6.0",
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
},
"Microsoft.NET.ILLink.Tasks": {
"type": "Direct",
"requested": "[10.0.10, )",
"resolved": "10.0.10",
"contentHash": "f5VCIE7AJpd5YvzNTeMGVzQIgyE9tX+AreTYwQF+REbu+DZo/2Ae+jNSwhPEYrVz6RRkd7y8ubXjk6Nn6Ka+Cg=="
}
}
}
}
@@ -0,0 +1,41 @@
namespace DodoSSH.Domain.Authorization;
/// <summary>
/// PermissionFlags a subject (user or team) may hold over a vault or an individual resource.
/// </summary>
/// <remarks>
/// <para>
/// Evaluation is a plain union across the subject's direct grants and the grants held by
/// teams they belong to. There are deliberately no Deny rules: union-only evaluation is
/// monotonic and straightforward to test, and Deny can be added later additively if a
/// real need appears. Express restriction by granting narrowly instead.
/// </para>
/// <para>
/// <see cref="Connect"/> is a user-interface hint, <b>not</b> a security boundary. SSH
/// terminates on the client, so opening a session requires the credential's plaintext on
/// that machine; "may connect but may not view the key" is therefore unenforceable in
/// this architecture. Treat it as an anti-shoulder-surfing convenience and never document
/// it as access control. See docs/adr/0001-e2ee-trust-model.md.
/// </para>
/// </remarks>
[Flags]
public enum PermissionFlags
{
/// <summary>No access.</summary>
None = 0,
/// <summary>May fetch and decrypt the resource. Implies the ability to use it.</summary>
Read = 1 << 0,
/// <summary>May create, modify and soft-delete resources in the vault.</summary>
Write = 1 << 1,
/// <summary>Intent hint that the subject uses this host for sessions. Not a boundary.</summary>
Connect = 1 << 2,
/// <summary>May grant access to other subjects, which requires re-wrapping the vault key.</summary>
Share = 1 << 3,
/// <summary>May administer the vault itself: rename, rekey, manage ACLs.</summary>
Admin = 1 << 4,
}
+12
View File
@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
Entities, enums and invariants. No EF Core reference: persistence concerns live in
DodoSSH.Infrastructure so the domain stays unit-testable with no database.
-->
<ItemGroup>
<InternalsVisibleTo Include="DodoSSH.Domain.Tests" />
</ItemGroup>
</Project>
+19
View File
@@ -0,0 +1,19 @@
{
"version": 2,
"dependencies": {
"net10.0": {
"Meziantou.Analyzer": {
"type": "Direct",
"requested": "[3.0.134, )",
"resolved": "3.0.134",
"contentHash": "tTYCcYKyOko3TMNxmxmA9nakbcHVUgglENmCMIhzIjl9y9FBZO/0tWSxTGC74Sp198FmWih5S5KkjQRBg5ePkQ=="
},
"Microsoft.CodeAnalysis.BannedApiAnalyzers": {
"type": "Direct",
"requested": "[5.6.0, )",
"resolved": "5.6.0",
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
}
}
}
}
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
Persistence: DodoDbContext, IEntityTypeConfiguration implementations, migrations and
query helpers. EF Core and Npgsql arrive in M1 with the first migration.
-->
<ItemGroup>
<ProjectReference Include="../DodoSSH.Domain/DodoSSH.Domain.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="DodoSSH.Infrastructure.Tests" />
<InternalsVisibleTo Include="DodoSSH.Api.Tests" />
</ItemGroup>
</Project>
@@ -0,0 +1,22 @@
{
"version": 2,
"dependencies": {
"net10.0": {
"Meziantou.Analyzer": {
"type": "Direct",
"requested": "[3.0.134, )",
"resolved": "3.0.134",
"contentHash": "tTYCcYKyOko3TMNxmxmA9nakbcHVUgglENmCMIhzIjl9y9FBZO/0tWSxTGC74Sp198FmWih5S5KkjQRBg5ePkQ=="
},
"Microsoft.CodeAnalysis.BannedApiAnalyzers": {
"type": "Direct",
"requested": "[5.6.0, )",
"resolved": "5.6.0",
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
},
"dodossh.domain": {
"type": "Project"
}
}
}
}
+37
View File
@@ -0,0 +1,37 @@
<Project>
<!-- Inherit everything from the repo root, then relax and add what test projects need. -->
<Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '$(MSBuildThisFileDirectory)../'))" />
<PropertyGroup>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<!-- Test projects legitimately do things production code should not: assert on
nulls, construct throwaway objects, use reflection. Keep warnings visible
but non-fatal so a test file never blocks a build. -->
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<!-- xunit.v3 runs on Microsoft.Testing.Platform, which needs an executable host. -->
<OutputType>Exe</OutputType>
<UseMicrosoftTestingPlatformRunner>true</UseMicrosoftTestingPlatformRunner>
<TestingPlatformDotnetTestSupport>true</TestingPlatformDotnetTestSupport>
</PropertyGroup>
<PropertyGroup Label="Analyzer relaxations for tests">
<!-- CA1707: test method names use underscores by convention (Method_State_Expectation).
CA2007: no SynchronizationContext in tests, ConfigureAwait is noise.
CA1861: inline constant arrays in theories are clearer than static fields. -->
<NoWarn>$(NoWarn);CA1707;CA2007;CA1861;CA1052;CA1515</NoWarn>
</PropertyGroup>
<ItemGroup Label="Test framework — every test project gets these">
<PackageReference Include="xunit.v3" />
<PackageReference Include="Shouldly" />
<PackageReference Include="NSubstitute" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
<Using Include="Shouldly" />
</ItemGroup>
</Project>
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
Grows into the shared server/client contract suite: snapshot tests over every
serialised DTO, and the tests/fixtures/sync/*.json golden files that both this suite
and the client's sync tests consume. Those fixtures, not the OpenAPI document, are
the contract test.
-->
<ItemGroup>
<ProjectReference Include="../../src/DodoSSH.Contracts/DodoSSH.Contracts.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,47 @@
using System.Reflection;
using DodoSSH.Contracts;
namespace DodoSSH.Contracts.Tests;
/// <summary>
/// Problem codes are part of the public wire contract: the client switches on them.
/// </summary>
public sealed class ProblemCodesTests
{
private static IReadOnlyList<FieldInfo> CodeFields =>
[.. typeof(ProblemCodes)
.GetFields(BindingFlags.Public | BindingFlags.Static)
.Where(f => f.IsLiteral && f.FieldType == typeof(string))
.Where(f => !string.Equals(f.Name, nameof(ProblemCodes.TypeBaseUri), StringComparison.Ordinal))];
[Fact]
public void AllCodes_AreUnique()
{
// A duplicated value would make two distinct failures indistinguishable to the
// client, which is the whole point of having codes rather than parsing messages.
var values = CodeFields.Select(f => (string)f.GetRawConstantValue()!).ToList();
values.Distinct(StringComparer.Ordinal).Count().ShouldBe(values.Count);
}
[Fact]
public void AllCodes_AreKebabCase()
{
// Codes are appended to TypeBaseUri to form the ProblemDetails type URI, so they
// must be URL-safe and stylistically consistent.
foreach (var field in CodeFields)
{
var value = (string)field.GetRawConstantValue()!;
value.ShouldMatch("^[a-z][a-z0-9]*(-[a-z0-9]+)*$", $"{field.Name} is not kebab-case");
}
}
[Fact]
public void TypeBaseUri_IsAnAbsoluteHttpsUriEndingInSlash()
{
Uri.TryCreate(ProblemCodes.TypeBaseUri, UriKind.Absolute, out var uri).ShouldBeTrue();
uri!.Scheme.ShouldBe(Uri.UriSchemeHttps);
ProblemCodes.TypeBaseUri.ShouldEndWith("/");
}
}
@@ -0,0 +1,202 @@
{
"version": 2,
"dependencies": {
"net10.0": {
"Meziantou.Analyzer": {
"type": "Direct",
"requested": "[3.0.134, )",
"resolved": "3.0.134",
"contentHash": "tTYCcYKyOko3TMNxmxmA9nakbcHVUgglENmCMIhzIjl9y9FBZO/0tWSxTGC74Sp198FmWih5S5KkjQRBg5ePkQ=="
},
"Microsoft.CodeAnalysis.BannedApiAnalyzers": {
"type": "Direct",
"requested": "[5.6.0, )",
"resolved": "5.6.0",
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
},
"NSubstitute": {
"type": "Direct",
"requested": "[6.0.0, )",
"resolved": "6.0.0",
"contentHash": "0gvKMbiJ+/WrfbcfBfqRZZrvfLJcd3rqkqVMjjlY5dtmLRVzMY+o/K/rJUStofQ2haSr9Vd04YDfvZtVVGS3/A==",
"dependencies": {
"Castle.Core": "5.1.1"
}
},
"Shouldly": {
"type": "Direct",
"requested": "[4.3.0, )",
"resolved": "4.3.0",
"contentHash": "sDetrWXrl6YXZ4HeLsdBoNk3uIa7K+V4uvIJ+cqdRa5DrFxeTED7VkjoxCuU1kJWpUuBDZz2QXFzSxBtVXLwRQ==",
"dependencies": {
"DiffEngine": "11.3.0",
"EmptyFiles": "4.4.0"
}
},
"xunit.v3": {
"type": "Direct",
"requested": "[3.2.2, )",
"resolved": "3.2.2",
"contentHash": "L+4/4y0Uqcg8/d6hfnxhnwh4j9FaeULvefTwrk30rr1o4n/vdPfyUQ8k0yzH8VJx7bmFEkDdcRfbtbjEHlaYcA==",
"dependencies": {
"xunit.v3.mtp-v1": "[3.2.2]"
}
},
"Castle.Core": {
"type": "Transitive",
"resolved": "5.1.1",
"contentHash": "rpYtIczkzGpf+EkZgDr9CClTdemhsrwA/W5hMoPjLkRFnXzH44zDLoovXeKtmxb1ykXK9aJVODSpiJml8CTw2g==",
"dependencies": {
"System.Diagnostics.EventLog": "6.0.0"
}
},
"DiffEngine": {
"type": "Transitive",
"resolved": "11.3.0",
"contentHash": "k0ZgZqd09jLZQjR8FyQbSQE86Q7QZnjEzq1LPHtj1R2AoWO8sjV5x+jlSisL7NZAbUOI4y+7Bog8gkr9WIRBGw==",
"dependencies": {
"EmptyFiles": "4.4.0",
"System.Management": "6.0.1"
}
},
"EmptyFiles": {
"type": "Transitive",
"resolved": "4.4.0",
"contentHash": "gwJEfIGS7FhykvtZoscwXj/XwW+mJY6UbAZk+qtLKFUGWC95kfKXnj8VkxsZQnWBxJemM/q664rGLN5nf+OHZw=="
},
"Microsoft.ApplicationInsights": {
"type": "Transitive",
"resolved": "2.23.0",
"contentHash": "nWArUZTdU7iqZLycLKWe0TDms48KKGE6pONH2terYNa8REXiqixrMOkf1sk5DHGMaUTqONU2YkS4SAXBhLStgw=="
},
"Microsoft.Bcl.AsyncInterfaces": {
"type": "Transitive",
"resolved": "6.0.0",
"contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg=="
},
"Microsoft.Testing.Extensions.Telemetry": {
"type": "Transitive",
"resolved": "1.9.1",
"contentHash": "No5AudZMmSb+uNXjlgL2y3/stHD2IT4uxqc5yHwkE+/nNux9jbKcaJMvcp9SwgP4DVD8L9/P3OUz8mmmcvEIdQ==",
"dependencies": {
"Microsoft.ApplicationInsights": "2.23.0",
"Microsoft.Testing.Platform": "1.9.1"
}
},
"Microsoft.Testing.Extensions.TrxReport.Abstractions": {
"type": "Transitive",
"resolved": "1.9.1",
"contentHash": "AL46Xe1WBi85Ntd4mNPvat5ZSsZ2uejiVqoKCypr8J3wK0elA5xJ3AN4G/Q4GIwzUFnggZoH/DBjnr9J18IO/g==",
"dependencies": {
"Microsoft.Testing.Platform": "1.9.1"
}
},
"Microsoft.Testing.Platform": {
"type": "Transitive",
"resolved": "1.9.1",
"contentHash": "QafNtNSmEI0zazdebnsIkDKmFtTSpmx/5PLOjURWwozcPb3tvRxzosQSL8xwYNM1iPhhKiBksXZyRSE2COisrA=="
},
"Microsoft.Testing.Platform.MSBuild": {
"type": "Transitive",
"resolved": "1.9.1",
"contentHash": "oTUtyR4X/s9ytuiNA29FGsNCCH0rNmY5Wdm14NCKLjTM1cT9edVSlA+rGS/mVmusPqcP0l/x9qOnMXg16v87RQ==",
"dependencies": {
"Microsoft.Testing.Platform": "1.9.1"
}
},
"Microsoft.Win32.Registry": {
"type": "Transitive",
"resolved": "5.0.0",
"contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg=="
},
"System.CodeDom": {
"type": "Transitive",
"resolved": "6.0.0",
"contentHash": "CPc6tWO1LAer3IzfZufDBRL+UZQcj5uS207NHALQzP84Vp/z6wF0Aa0YZImOQY8iStY0A2zI/e3ihKNPfUm8XA=="
},
"System.Diagnostics.EventLog": {
"type": "Transitive",
"resolved": "6.0.0",
"contentHash": "lcyUiXTsETK2ALsZrX+nWuHSIQeazhqPphLfaRxzdGaG93+0kELqpgEHtwWOlQe7+jSFnKwaCAgL4kjeZCQJnw=="
},
"System.Management": {
"type": "Transitive",
"resolved": "6.0.1",
"contentHash": "10J1D0h/lioojphfJ4Fuh5ZUThT/xOVHdV9roGBittKKNP2PMjrvibEdbVTGZcPra1399Ja3tqIJLyQrc5Wmhg==",
"dependencies": {
"System.CodeDom": "6.0.0"
}
},
"xunit.analyzers": {
"type": "Transitive",
"resolved": "1.27.0",
"contentHash": "y/pxIQaLvk/kxAoDkZW9GnHLCEqzwl5TW0vtX3pweyQpjizB9y3DXhb9pkw2dGeUqhLjsxvvJM1k89JowU6z3g=="
},
"xunit.v3.assert": {
"type": "Transitive",
"resolved": "3.2.2",
"contentHash": "BPciBghgEEaJN/JG00QfCYDfEfnLgQhfnYEy+j1izoeHVNYd5+3Wm8GJ6JgYysOhpBPYGE+sbf75JtrRc7jrdA=="
},
"xunit.v3.common": {
"type": "Transitive",
"resolved": "3.2.2",
"contentHash": "Hj775PEH6GTbbg0wfKRvG2hNspDCvTH9irXhH4qIWgdrOSV1sQlqPie+DOvFeigsFg2fxSM3ZAaaCDQs+KreFA==",
"dependencies": {
"Microsoft.Bcl.AsyncInterfaces": "6.0.0"
}
},
"xunit.v3.core.mtp-v1": {
"type": "Transitive",
"resolved": "3.2.2",
"contentHash": "Ga5aA2Ca9ktz+5k3g5ukzwfexwoqwDUpV6z7atSEUvqtd6JuybU1XopHqg1oFd78QdTfZgZE9h5sHpO4qYIi5w==",
"dependencies": {
"Microsoft.Testing.Extensions.Telemetry": "1.9.1",
"Microsoft.Testing.Extensions.TrxReport.Abstractions": "1.9.1",
"Microsoft.Testing.Platform": "1.9.1",
"Microsoft.Testing.Platform.MSBuild": "1.9.1",
"xunit.v3.extensibility.core": "[3.2.2]",
"xunit.v3.runner.inproc.console": "[3.2.2]"
}
},
"xunit.v3.extensibility.core": {
"type": "Transitive",
"resolved": "3.2.2",
"contentHash": "srY8z/oMPvh/t8axtO2DwrHajhFMH7tnqKildvYrVQIfICi8fOn3yIBWkVPAcrKmHMwvXRJ/XsQM3VMR6DOYfQ==",
"dependencies": {
"xunit.v3.common": "[3.2.2]"
}
},
"xunit.v3.mtp-v1": {
"type": "Transitive",
"resolved": "3.2.2",
"contentHash": "O41aAzYKBT5PWqATa1oEWVNCyEUypFQ4va6K0kz37dduV3EKzXNMaV2UnEhufzU4Cce1I33gg0oldS8tGL5I0A==",
"dependencies": {
"xunit.analyzers": "1.27.0",
"xunit.v3.assert": "[3.2.2]",
"xunit.v3.core.mtp-v1": "[3.2.2]"
}
},
"xunit.v3.runner.common": {
"type": "Transitive",
"resolved": "3.2.2",
"contentHash": "/hkHkQCzGrugelOAehprm7RIWdsUFVmIVaD6jDH/8DNGCymTlKKPTbGokD5czbAfqfex47mBP0sb0zbHYwrO/g==",
"dependencies": {
"Microsoft.Win32.Registry": "[5.0.0]",
"xunit.v3.common": "[3.2.2]"
}
},
"xunit.v3.runner.inproc.console": {
"type": "Transitive",
"resolved": "3.2.2",
"contentHash": "ulWOdSvCk+bPXijJZ73bth9NyoOHsAs1ZOvamYbCkD4DNLX/Bd29Ve2ZNUwBbK0MqfIYWXHZViy/HKrdEC/izw==",
"dependencies": {
"xunit.v3.extensibility.core": "[3.2.2]",
"xunit.v3.runner.common": "[3.2.2]"
}
},
"dodossh.contracts": {
"type": "Project"
}
}
}
}
@@ -0,0 +1,45 @@
using DodoSSH.Crypto;
namespace DodoSSH.Crypto.Tests;
/// <summary>
/// Pins the specification constants that are written into stored data.
/// </summary>
/// <remarks>
/// These are not busywork. The envelope magic and AAD version are persisted in every
/// ciphertext row, and only clients can re-encrypt: if one of these changes without a
/// deliberate migration path, existing vaults stop decrypting and the server cannot help.
/// </remarks>
public sealed class CryptoSpecTests
{
[Fact]
public void EnvelopeMagic_IsStable()
{
CryptoSpec.EnvelopeMagic.ShouldBe("DSH1");
}
[Fact]
public void CurrentAadVersion_IsStable()
{
// Bumping this requires a lazy re-encrypt-on-write path in the client first.
CryptoSpec.CurrentAadVersion.ShouldBe((short)1);
}
[Theory]
[InlineData(CryptoSpec.AlgorithmId.XChaCha20Poly1305, 1)]
[InlineData(CryptoSpec.AlgorithmId.Aes256Gcm, 2)]
[InlineData(CryptoSpec.AlgorithmId.SealToX25519, 3)]
public void AlgorithmId_HasStableWireValue(CryptoSpec.AlgorithmId algorithm, int expected)
{
((int)algorithm).ShouldBe(expected);
}
[Fact]
public void AlgorithmId_4_IsReservedForHybridPostQuantumSeal()
{
// Reserved for X25519 + ML-KEM-768. Claimed now so the identifier cannot be
// reused: store-now-decrypt-later is a real threat for long-lived SSH keys.
// AlgorithmId is byte-backed, matching the single alg_id byte in the envelope.
Enum.IsDefined(typeof(CryptoSpec.AlgorithmId), (byte)4).ShouldBeFalse();
}
}
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
Once docs/crypto.md is frozen in M1 this project gains, in order of importance:
- frozen golden-vector tests over the DSH1 envelope, so a release can never
silently change the format and brick every existing vault
- AAD derivation vectors, including the negative cases that prove a payload cannot
be moved between rows, resources or key generations
- differential tests running each primitive through both NSec and BouncyCastle
- round-trip property tests
-->
<ItemGroup>
<ProjectReference Include="../../src/DodoSSH.Crypto/DodoSSH.Crypto.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,202 @@
{
"version": 2,
"dependencies": {
"net10.0": {
"Meziantou.Analyzer": {
"type": "Direct",
"requested": "[3.0.134, )",
"resolved": "3.0.134",
"contentHash": "tTYCcYKyOko3TMNxmxmA9nakbcHVUgglENmCMIhzIjl9y9FBZO/0tWSxTGC74Sp198FmWih5S5KkjQRBg5ePkQ=="
},
"Microsoft.CodeAnalysis.BannedApiAnalyzers": {
"type": "Direct",
"requested": "[5.6.0, )",
"resolved": "5.6.0",
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
},
"NSubstitute": {
"type": "Direct",
"requested": "[6.0.0, )",
"resolved": "6.0.0",
"contentHash": "0gvKMbiJ+/WrfbcfBfqRZZrvfLJcd3rqkqVMjjlY5dtmLRVzMY+o/K/rJUStofQ2haSr9Vd04YDfvZtVVGS3/A==",
"dependencies": {
"Castle.Core": "5.1.1"
}
},
"Shouldly": {
"type": "Direct",
"requested": "[4.3.0, )",
"resolved": "4.3.0",
"contentHash": "sDetrWXrl6YXZ4HeLsdBoNk3uIa7K+V4uvIJ+cqdRa5DrFxeTED7VkjoxCuU1kJWpUuBDZz2QXFzSxBtVXLwRQ==",
"dependencies": {
"DiffEngine": "11.3.0",
"EmptyFiles": "4.4.0"
}
},
"xunit.v3": {
"type": "Direct",
"requested": "[3.2.2, )",
"resolved": "3.2.2",
"contentHash": "L+4/4y0Uqcg8/d6hfnxhnwh4j9FaeULvefTwrk30rr1o4n/vdPfyUQ8k0yzH8VJx7bmFEkDdcRfbtbjEHlaYcA==",
"dependencies": {
"xunit.v3.mtp-v1": "[3.2.2]"
}
},
"Castle.Core": {
"type": "Transitive",
"resolved": "5.1.1",
"contentHash": "rpYtIczkzGpf+EkZgDr9CClTdemhsrwA/W5hMoPjLkRFnXzH44zDLoovXeKtmxb1ykXK9aJVODSpiJml8CTw2g==",
"dependencies": {
"System.Diagnostics.EventLog": "6.0.0"
}
},
"DiffEngine": {
"type": "Transitive",
"resolved": "11.3.0",
"contentHash": "k0ZgZqd09jLZQjR8FyQbSQE86Q7QZnjEzq1LPHtj1R2AoWO8sjV5x+jlSisL7NZAbUOI4y+7Bog8gkr9WIRBGw==",
"dependencies": {
"EmptyFiles": "4.4.0",
"System.Management": "6.0.1"
}
},
"EmptyFiles": {
"type": "Transitive",
"resolved": "4.4.0",
"contentHash": "gwJEfIGS7FhykvtZoscwXj/XwW+mJY6UbAZk+qtLKFUGWC95kfKXnj8VkxsZQnWBxJemM/q664rGLN5nf+OHZw=="
},
"Microsoft.ApplicationInsights": {
"type": "Transitive",
"resolved": "2.23.0",
"contentHash": "nWArUZTdU7iqZLycLKWe0TDms48KKGE6pONH2terYNa8REXiqixrMOkf1sk5DHGMaUTqONU2YkS4SAXBhLStgw=="
},
"Microsoft.Bcl.AsyncInterfaces": {
"type": "Transitive",
"resolved": "6.0.0",
"contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg=="
},
"Microsoft.Testing.Extensions.Telemetry": {
"type": "Transitive",
"resolved": "1.9.1",
"contentHash": "No5AudZMmSb+uNXjlgL2y3/stHD2IT4uxqc5yHwkE+/nNux9jbKcaJMvcp9SwgP4DVD8L9/P3OUz8mmmcvEIdQ==",
"dependencies": {
"Microsoft.ApplicationInsights": "2.23.0",
"Microsoft.Testing.Platform": "1.9.1"
}
},
"Microsoft.Testing.Extensions.TrxReport.Abstractions": {
"type": "Transitive",
"resolved": "1.9.1",
"contentHash": "AL46Xe1WBi85Ntd4mNPvat5ZSsZ2uejiVqoKCypr8J3wK0elA5xJ3AN4G/Q4GIwzUFnggZoH/DBjnr9J18IO/g==",
"dependencies": {
"Microsoft.Testing.Platform": "1.9.1"
}
},
"Microsoft.Testing.Platform": {
"type": "Transitive",
"resolved": "1.9.1",
"contentHash": "QafNtNSmEI0zazdebnsIkDKmFtTSpmx/5PLOjURWwozcPb3tvRxzosQSL8xwYNM1iPhhKiBksXZyRSE2COisrA=="
},
"Microsoft.Testing.Platform.MSBuild": {
"type": "Transitive",
"resolved": "1.9.1",
"contentHash": "oTUtyR4X/s9ytuiNA29FGsNCCH0rNmY5Wdm14NCKLjTM1cT9edVSlA+rGS/mVmusPqcP0l/x9qOnMXg16v87RQ==",
"dependencies": {
"Microsoft.Testing.Platform": "1.9.1"
}
},
"Microsoft.Win32.Registry": {
"type": "Transitive",
"resolved": "5.0.0",
"contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg=="
},
"System.CodeDom": {
"type": "Transitive",
"resolved": "6.0.0",
"contentHash": "CPc6tWO1LAer3IzfZufDBRL+UZQcj5uS207NHALQzP84Vp/z6wF0Aa0YZImOQY8iStY0A2zI/e3ihKNPfUm8XA=="
},
"System.Diagnostics.EventLog": {
"type": "Transitive",
"resolved": "6.0.0",
"contentHash": "lcyUiXTsETK2ALsZrX+nWuHSIQeazhqPphLfaRxzdGaG93+0kELqpgEHtwWOlQe7+jSFnKwaCAgL4kjeZCQJnw=="
},
"System.Management": {
"type": "Transitive",
"resolved": "6.0.1",
"contentHash": "10J1D0h/lioojphfJ4Fuh5ZUThT/xOVHdV9roGBittKKNP2PMjrvibEdbVTGZcPra1399Ja3tqIJLyQrc5Wmhg==",
"dependencies": {
"System.CodeDom": "6.0.0"
}
},
"xunit.analyzers": {
"type": "Transitive",
"resolved": "1.27.0",
"contentHash": "y/pxIQaLvk/kxAoDkZW9GnHLCEqzwl5TW0vtX3pweyQpjizB9y3DXhb9pkw2dGeUqhLjsxvvJM1k89JowU6z3g=="
},
"xunit.v3.assert": {
"type": "Transitive",
"resolved": "3.2.2",
"contentHash": "BPciBghgEEaJN/JG00QfCYDfEfnLgQhfnYEy+j1izoeHVNYd5+3Wm8GJ6JgYysOhpBPYGE+sbf75JtrRc7jrdA=="
},
"xunit.v3.common": {
"type": "Transitive",
"resolved": "3.2.2",
"contentHash": "Hj775PEH6GTbbg0wfKRvG2hNspDCvTH9irXhH4qIWgdrOSV1sQlqPie+DOvFeigsFg2fxSM3ZAaaCDQs+KreFA==",
"dependencies": {
"Microsoft.Bcl.AsyncInterfaces": "6.0.0"
}
},
"xunit.v3.core.mtp-v1": {
"type": "Transitive",
"resolved": "3.2.2",
"contentHash": "Ga5aA2Ca9ktz+5k3g5ukzwfexwoqwDUpV6z7atSEUvqtd6JuybU1XopHqg1oFd78QdTfZgZE9h5sHpO4qYIi5w==",
"dependencies": {
"Microsoft.Testing.Extensions.Telemetry": "1.9.1",
"Microsoft.Testing.Extensions.TrxReport.Abstractions": "1.9.1",
"Microsoft.Testing.Platform": "1.9.1",
"Microsoft.Testing.Platform.MSBuild": "1.9.1",
"xunit.v3.extensibility.core": "[3.2.2]",
"xunit.v3.runner.inproc.console": "[3.2.2]"
}
},
"xunit.v3.extensibility.core": {
"type": "Transitive",
"resolved": "3.2.2",
"contentHash": "srY8z/oMPvh/t8axtO2DwrHajhFMH7tnqKildvYrVQIfICi8fOn3yIBWkVPAcrKmHMwvXRJ/XsQM3VMR6DOYfQ==",
"dependencies": {
"xunit.v3.common": "[3.2.2]"
}
},
"xunit.v3.mtp-v1": {
"type": "Transitive",
"resolved": "3.2.2",
"contentHash": "O41aAzYKBT5PWqATa1oEWVNCyEUypFQ4va6K0kz37dduV3EKzXNMaV2UnEhufzU4Cce1I33gg0oldS8tGL5I0A==",
"dependencies": {
"xunit.analyzers": "1.27.0",
"xunit.v3.assert": "[3.2.2]",
"xunit.v3.core.mtp-v1": "[3.2.2]"
}
},
"xunit.v3.runner.common": {
"type": "Transitive",
"resolved": "3.2.2",
"contentHash": "/hkHkQCzGrugelOAehprm7RIWdsUFVmIVaD6jDH/8DNGCymTlKKPTbGokD5czbAfqfex47mBP0sb0zbHYwrO/g==",
"dependencies": {
"Microsoft.Win32.Registry": "[5.0.0]",
"xunit.v3.common": "[3.2.2]"
}
},
"xunit.v3.runner.inproc.console": {
"type": "Transitive",
"resolved": "3.2.2",
"contentHash": "ulWOdSvCk+bPXijJZ73bth9NyoOHsAs1ZOvamYbCkD4DNLX/Bd29Ve2ZNUwBbK0MqfIYWXHZViy/HKrdEC/izw==",
"dependencies": {
"xunit.v3.extensibility.core": "[3.2.2]",
"xunit.v3.runner.common": "[3.2.2]"
}
},
"dodossh.crypto": {
"type": "Project"
}
}
}
}
@@ -0,0 +1,51 @@
using DodoSSH.Domain.Authorization;
namespace DodoSSH.Domain.Tests.Authorization;
/// <summary>
/// Permission algebra. This grows into the full effective-permission suite in M3; for now
/// it pins the flag values, because they are persisted as an integer column and changing
/// one silently reinterprets every stored ACL row.
/// </summary>
public sealed class PermissionFlagsTests
{
[Theory]
[InlineData(PermissionFlags.None, 0)]
[InlineData(PermissionFlags.Read, 1)]
[InlineData(PermissionFlags.Write, 2)]
[InlineData(PermissionFlags.Connect, 4)]
[InlineData(PermissionFlags.Share, 8)]
[InlineData(PermissionFlags.Admin, 16)]
public void Flag_HasStableWireValue(PermissionFlags flag, int expected)
{
// These values are persisted; a change reinterprets existing ACL rows.
((int)flag).ShouldBe(expected);
}
[Fact]
public void Union_CombinesGrantsFromMultipleSubjects()
{
// Effective permissions are a union across direct and team-derived grants.
var direct = PermissionFlags.Read;
var viaTeam = PermissionFlags.Write | PermissionFlags.Connect;
var effective = direct | viaTeam;
effective.HasFlag(PermissionFlags.Read).ShouldBeTrue();
effective.HasFlag(PermissionFlags.Write).ShouldBeTrue();
effective.HasFlag(PermissionFlags.Connect).ShouldBeTrue();
effective.HasFlag(PermissionFlags.Admin).ShouldBeFalse();
effective.HasFlag(PermissionFlags.Share).ShouldBeFalse();
}
[Fact]
public void Union_IsMonotonic()
{
// The deliberate consequence of having no Deny rules: adding a grant can only
// ever widen access, never narrow it. M3's evaluator relies on this.
var before = PermissionFlags.Read;
var after = before | PermissionFlags.Admin;
(after & before).ShouldBe(before);
}
}
@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- Test framework packages come from ../Directory.Build.props. -->
<ItemGroup>
<ProjectReference Include="../../src/DodoSSH.Domain/DodoSSH.Domain.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,202 @@
{
"version": 2,
"dependencies": {
"net10.0": {
"Meziantou.Analyzer": {
"type": "Direct",
"requested": "[3.0.134, )",
"resolved": "3.0.134",
"contentHash": "tTYCcYKyOko3TMNxmxmA9nakbcHVUgglENmCMIhzIjl9y9FBZO/0tWSxTGC74Sp198FmWih5S5KkjQRBg5ePkQ=="
},
"Microsoft.CodeAnalysis.BannedApiAnalyzers": {
"type": "Direct",
"requested": "[5.6.0, )",
"resolved": "5.6.0",
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
},
"NSubstitute": {
"type": "Direct",
"requested": "[6.0.0, )",
"resolved": "6.0.0",
"contentHash": "0gvKMbiJ+/WrfbcfBfqRZZrvfLJcd3rqkqVMjjlY5dtmLRVzMY+o/K/rJUStofQ2haSr9Vd04YDfvZtVVGS3/A==",
"dependencies": {
"Castle.Core": "5.1.1"
}
},
"Shouldly": {
"type": "Direct",
"requested": "[4.3.0, )",
"resolved": "4.3.0",
"contentHash": "sDetrWXrl6YXZ4HeLsdBoNk3uIa7K+V4uvIJ+cqdRa5DrFxeTED7VkjoxCuU1kJWpUuBDZz2QXFzSxBtVXLwRQ==",
"dependencies": {
"DiffEngine": "11.3.0",
"EmptyFiles": "4.4.0"
}
},
"xunit.v3": {
"type": "Direct",
"requested": "[3.2.2, )",
"resolved": "3.2.2",
"contentHash": "L+4/4y0Uqcg8/d6hfnxhnwh4j9FaeULvefTwrk30rr1o4n/vdPfyUQ8k0yzH8VJx7bmFEkDdcRfbtbjEHlaYcA==",
"dependencies": {
"xunit.v3.mtp-v1": "[3.2.2]"
}
},
"Castle.Core": {
"type": "Transitive",
"resolved": "5.1.1",
"contentHash": "rpYtIczkzGpf+EkZgDr9CClTdemhsrwA/W5hMoPjLkRFnXzH44zDLoovXeKtmxb1ykXK9aJVODSpiJml8CTw2g==",
"dependencies": {
"System.Diagnostics.EventLog": "6.0.0"
}
},
"DiffEngine": {
"type": "Transitive",
"resolved": "11.3.0",
"contentHash": "k0ZgZqd09jLZQjR8FyQbSQE86Q7QZnjEzq1LPHtj1R2AoWO8sjV5x+jlSisL7NZAbUOI4y+7Bog8gkr9WIRBGw==",
"dependencies": {
"EmptyFiles": "4.4.0",
"System.Management": "6.0.1"
}
},
"EmptyFiles": {
"type": "Transitive",
"resolved": "4.4.0",
"contentHash": "gwJEfIGS7FhykvtZoscwXj/XwW+mJY6UbAZk+qtLKFUGWC95kfKXnj8VkxsZQnWBxJemM/q664rGLN5nf+OHZw=="
},
"Microsoft.ApplicationInsights": {
"type": "Transitive",
"resolved": "2.23.0",
"contentHash": "nWArUZTdU7iqZLycLKWe0TDms48KKGE6pONH2terYNa8REXiqixrMOkf1sk5DHGMaUTqONU2YkS4SAXBhLStgw=="
},
"Microsoft.Bcl.AsyncInterfaces": {
"type": "Transitive",
"resolved": "6.0.0",
"contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg=="
},
"Microsoft.Testing.Extensions.Telemetry": {
"type": "Transitive",
"resolved": "1.9.1",
"contentHash": "No5AudZMmSb+uNXjlgL2y3/stHD2IT4uxqc5yHwkE+/nNux9jbKcaJMvcp9SwgP4DVD8L9/P3OUz8mmmcvEIdQ==",
"dependencies": {
"Microsoft.ApplicationInsights": "2.23.0",
"Microsoft.Testing.Platform": "1.9.1"
}
},
"Microsoft.Testing.Extensions.TrxReport.Abstractions": {
"type": "Transitive",
"resolved": "1.9.1",
"contentHash": "AL46Xe1WBi85Ntd4mNPvat5ZSsZ2uejiVqoKCypr8J3wK0elA5xJ3AN4G/Q4GIwzUFnggZoH/DBjnr9J18IO/g==",
"dependencies": {
"Microsoft.Testing.Platform": "1.9.1"
}
},
"Microsoft.Testing.Platform": {
"type": "Transitive",
"resolved": "1.9.1",
"contentHash": "QafNtNSmEI0zazdebnsIkDKmFtTSpmx/5PLOjURWwozcPb3tvRxzosQSL8xwYNM1iPhhKiBksXZyRSE2COisrA=="
},
"Microsoft.Testing.Platform.MSBuild": {
"type": "Transitive",
"resolved": "1.9.1",
"contentHash": "oTUtyR4X/s9ytuiNA29FGsNCCH0rNmY5Wdm14NCKLjTM1cT9edVSlA+rGS/mVmusPqcP0l/x9qOnMXg16v87RQ==",
"dependencies": {
"Microsoft.Testing.Platform": "1.9.1"
}
},
"Microsoft.Win32.Registry": {
"type": "Transitive",
"resolved": "5.0.0",
"contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg=="
},
"System.CodeDom": {
"type": "Transitive",
"resolved": "6.0.0",
"contentHash": "CPc6tWO1LAer3IzfZufDBRL+UZQcj5uS207NHALQzP84Vp/z6wF0Aa0YZImOQY8iStY0A2zI/e3ihKNPfUm8XA=="
},
"System.Diagnostics.EventLog": {
"type": "Transitive",
"resolved": "6.0.0",
"contentHash": "lcyUiXTsETK2ALsZrX+nWuHSIQeazhqPphLfaRxzdGaG93+0kELqpgEHtwWOlQe7+jSFnKwaCAgL4kjeZCQJnw=="
},
"System.Management": {
"type": "Transitive",
"resolved": "6.0.1",
"contentHash": "10J1D0h/lioojphfJ4Fuh5ZUThT/xOVHdV9roGBittKKNP2PMjrvibEdbVTGZcPra1399Ja3tqIJLyQrc5Wmhg==",
"dependencies": {
"System.CodeDom": "6.0.0"
}
},
"xunit.analyzers": {
"type": "Transitive",
"resolved": "1.27.0",
"contentHash": "y/pxIQaLvk/kxAoDkZW9GnHLCEqzwl5TW0vtX3pweyQpjizB9y3DXhb9pkw2dGeUqhLjsxvvJM1k89JowU6z3g=="
},
"xunit.v3.assert": {
"type": "Transitive",
"resolved": "3.2.2",
"contentHash": "BPciBghgEEaJN/JG00QfCYDfEfnLgQhfnYEy+j1izoeHVNYd5+3Wm8GJ6JgYysOhpBPYGE+sbf75JtrRc7jrdA=="
},
"xunit.v3.common": {
"type": "Transitive",
"resolved": "3.2.2",
"contentHash": "Hj775PEH6GTbbg0wfKRvG2hNspDCvTH9irXhH4qIWgdrOSV1sQlqPie+DOvFeigsFg2fxSM3ZAaaCDQs+KreFA==",
"dependencies": {
"Microsoft.Bcl.AsyncInterfaces": "6.0.0"
}
},
"xunit.v3.core.mtp-v1": {
"type": "Transitive",
"resolved": "3.2.2",
"contentHash": "Ga5aA2Ca9ktz+5k3g5ukzwfexwoqwDUpV6z7atSEUvqtd6JuybU1XopHqg1oFd78QdTfZgZE9h5sHpO4qYIi5w==",
"dependencies": {
"Microsoft.Testing.Extensions.Telemetry": "1.9.1",
"Microsoft.Testing.Extensions.TrxReport.Abstractions": "1.9.1",
"Microsoft.Testing.Platform": "1.9.1",
"Microsoft.Testing.Platform.MSBuild": "1.9.1",
"xunit.v3.extensibility.core": "[3.2.2]",
"xunit.v3.runner.inproc.console": "[3.2.2]"
}
},
"xunit.v3.extensibility.core": {
"type": "Transitive",
"resolved": "3.2.2",
"contentHash": "srY8z/oMPvh/t8axtO2DwrHajhFMH7tnqKildvYrVQIfICi8fOn3yIBWkVPAcrKmHMwvXRJ/XsQM3VMR6DOYfQ==",
"dependencies": {
"xunit.v3.common": "[3.2.2]"
}
},
"xunit.v3.mtp-v1": {
"type": "Transitive",
"resolved": "3.2.2",
"contentHash": "O41aAzYKBT5PWqATa1oEWVNCyEUypFQ4va6K0kz37dduV3EKzXNMaV2UnEhufzU4Cce1I33gg0oldS8tGL5I0A==",
"dependencies": {
"xunit.analyzers": "1.27.0",
"xunit.v3.assert": "[3.2.2]",
"xunit.v3.core.mtp-v1": "[3.2.2]"
}
},
"xunit.v3.runner.common": {
"type": "Transitive",
"resolved": "3.2.2",
"contentHash": "/hkHkQCzGrugelOAehprm7RIWdsUFVmIVaD6jDH/8DNGCymTlKKPTbGokD5czbAfqfex47mBP0sb0zbHYwrO/g==",
"dependencies": {
"Microsoft.Win32.Registry": "[5.0.0]",
"xunit.v3.common": "[3.2.2]"
}
},
"xunit.v3.runner.inproc.console": {
"type": "Transitive",
"resolved": "3.2.2",
"contentHash": "ulWOdSvCk+bPXijJZ73bth9NyoOHsAs1ZOvamYbCkD4DNLX/Bd29Ve2ZNUwBbK0MqfIYWXHZViy/HKrdEC/izw==",
"dependencies": {
"xunit.v3.extensibility.core": "[3.2.2]",
"xunit.v3.runner.common": "[3.2.2]"
}
},
"dodossh.domain": {
"type": "Project"
}
}
}
}