Public Access
Run M1's end-to-end slice, and fix the two bugs it found
The whole vertical slice now runs against a real Keycloak, a real API, a real PostgreSQL and a real sshd: sign in through the browser flow, enroll with the identity-provider key binding, unlock, create a host, sync it, read it back on a second machine, unlock again with no network, accept an unseen host key, and open an interactive shell. Opt-in, because it needs the development stack; skipped with a message naming the commands. It found two bugs on its first run, and both are the same class: two sides of a stub agreeing with each other about something the specification never said. **The API never applied DodoSshJsonContext to its HTTP JSON options.** Minimal APIs therefore used the framework's web defaults, which write an enum as a number. Every request DTO carrying one failed to bind against a client writing the specified string form — which is the entire sync surface, unreachable from the real client, with a 400 naming only the parameter. The documented guarantee that request bodies reject unmapped members was likewise not in effect anywhere. Nothing caught it because the API tests posted with PostAsJsonAsync's defaults, so they and the server had independently settled on integers. Those tests now serialise through the contract, which is the deeper fix: removing the new configuration fails 13 of them. Copying settings into options a host owns is itself the hazard the context warns about, so ApplyTo lives beside the settings it mirrors and ApplyToTests pins the transformation, including that inserting the resolver leaves the caller's own in place. **The realm registered a loopback redirect URI Keycloak rejects.** `http://127.0.0.1:*/callback` looks more explicit than the RFC 8252 form and is broken: Keycloak's wildcards are trailing-only, so the `*` parses as a literal port and every authorization request came back "Invalid parameter: redirect_uri". Providers ignore the port for loopback hosts, which is the whole mechanism, so the correct registration is `http://127.0.0.1/callback` — path pinned, port free. The value the server advertises through the discovery document said the same wrong thing and now says the right one. Two smaller things, both documented in docs/platform-flags.md: - --import-realm skips a realm that already exists, so editing the realm file and restarting Keycloak changes nothing and serves stale configuration. The container has to be recreated. The compose comment claimed the opposite. - Keycloak marks its session cookies Secure even over plain HTTP, because SameSite=None requires it. A spec-conformant client drops them and the login POST answers 400 with no message; browsers complete the flow only because they exempt loopback. Harmless for the product, fatal for automation, so ScriptedBrowser carries the cookies by hand and says why. Also: the server enforces a 64 MiB floor on the passphrase KDF, so this suite cannot use the 8 MiB profile the other client suites take for speed. Those only get away with it because their in-memory servers have no policy — worth knowing rather than rediscovering. 638 tests. The solution-wide run stays green with the stack down: exit code 8 means "no tests ran", which the platform reports as failure, so the opt-in project ignores exactly that code.
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
using System.Globalization;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace DodoSSH.SystemTests;
|
||||
|
||||
/// <summary>
|
||||
/// The development stack this suite talks to, and how to find out whether it is there.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Deliberately targets a stack the developer brought up rather than starting its own containers. That
|
||||
/// makes the suite test the configuration that is actually committed — the realm file, the API's
|
||||
/// appsettings, the migration history — instead of a parallel arrangement assembled for the test, which is
|
||||
/// where a divergence between "works in the suite" and "works when you run it" comes from.
|
||||
/// <para>
|
||||
/// The trade is that it cannot run unattended, so it is opt-in and says exactly what to start.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class DevStack
|
||||
{
|
||||
internal const string ApiBaseUrl = "http://localhost:5233";
|
||||
internal const string KeycloakBaseUrl = "http://localhost:18080";
|
||||
internal const string Realm = "dodossh";
|
||||
|
||||
private const string OptInVariable = "DODOSSH_E2E";
|
||||
|
||||
/// <summary>
|
||||
/// Explains why the suite cannot run, or returns null when it can.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A message rather than a boolean, because a skipped end-to-end suite that does not say what is
|
||||
/// missing is a suite nobody ever runs again.
|
||||
/// </remarks>
|
||||
internal static async Task<string?> WhyUnavailableAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (Environment.GetEnvironmentVariable(OptInVariable) is not "1")
|
||||
{
|
||||
return $"Set {OptInVariable}=1 to run the end-to-end suite. It needs the development stack:"
|
||||
+ "\n docker compose -f deploy/docker-compose.dev.yml up -d"
|
||||
+ "\n dotnet ef database update --project src/DodoSSH.Infrastructure"
|
||||
+ "\n dotnet run --project src/DodoSSH.Api";
|
||||
}
|
||||
|
||||
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(5) };
|
||||
|
||||
if (!await RespondsAsync(http, $"{ApiBaseUrl}/healthz/ready", cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return $"The API is not ready at {ApiBaseUrl}. Start it with "
|
||||
+ "`dotnet run --project src/DodoSSH.Api`, and check that migrations have been applied — "
|
||||
+ "readiness fails while any are pending, by design.";
|
||||
}
|
||||
|
||||
var discovery = $"{KeycloakBaseUrl}/realms/{Realm}/.well-known/openid-configuration";
|
||||
|
||||
if (!await RespondsAsync(http, discovery, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return $"Keycloak is not serving the '{Realm}' realm at {KeycloakBaseUrl}. Bring it up with "
|
||||
+ "`docker compose -f deploy/docker-compose.dev.yml up -d`.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a Keycloak user this test run owns.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A fresh account per run, rather than the realm's <c>alice</c>. Enrollment happens once per account
|
||||
/// and cannot be undone from the client, so reusing an account would mean the second run exercises a
|
||||
/// different path from the first and neither could assert an exact vault state. This way every run
|
||||
/// starts from "no identity key, no vault".
|
||||
/// </remarks>
|
||||
internal static async Task<DevStackUser> CreateUserAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var username = string.Create(
|
||||
CultureInfo.InvariantCulture, $"e2e-{Guid.CreateVersion7():N}");
|
||||
|
||||
const string Password = "e2e-password";
|
||||
|
||||
using var http = new HttpClient { BaseAddress = new Uri(KeycloakBaseUrl) };
|
||||
|
||||
var token = await AdminTokenAsync(http, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
http.DefaultRequestHeaders.Authorization = new("Bearer", token);
|
||||
|
||||
var user = new JsonObject
|
||||
{
|
||||
["username"] = username,
|
||||
["email"] = $"{username}@example.test",
|
||||
["firstName"] = "End",
|
||||
["lastName"] = "ToEnd",
|
||||
["enabled"] = true,
|
||||
["emailVerified"] = true,
|
||||
["credentials"] = new JsonArray
|
||||
{
|
||||
new JsonObject
|
||||
{
|
||||
["type"] = "password",
|
||||
["value"] = Password,
|
||||
["temporary"] = false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
using var response = await http
|
||||
.PostAsJsonAsync($"/admin/realms/{Realm}/users", user, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return new DevStackUser(username, Password);
|
||||
}
|
||||
|
||||
private static async Task<string> AdminTokenAsync(HttpClient http, CancellationToken cancellationToken)
|
||||
{
|
||||
using var form = new FormUrlEncodedContent(
|
||||
new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["client_id"] = "admin-cli",
|
||||
["username"] = "admin",
|
||||
["password"] = "admin",
|
||||
["grant_type"] = "password",
|
||||
});
|
||||
|
||||
using var response = await http
|
||||
.PostAsync("/realms/master/protocol/openid-connect/token", form, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return JsonDocument.Parse(body).RootElement.GetProperty("access_token").GetString()
|
||||
?? throw new InvalidOperationException("Keycloak returned no admin access token.");
|
||||
}
|
||||
|
||||
private static async Task<bool> RespondsAsync(
|
||||
HttpClient http,
|
||||
string url,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var response = await http.GetAsync(url, cancellationToken).ConfigureAwait(false);
|
||||
return response.IsSuccessStatusCode;
|
||||
}
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>An account created for one test run.</summary>
|
||||
internal sealed record DevStackUser(string Username, string Password);
|
||||
@@ -0,0 +1,40 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!--
|
||||
M1's definition of done, automated: a real DodoSSH API, a real PostgreSQL, a real Keycloak and a real
|
||||
sshd, driven by the real client from sign-in to an interactive shell.
|
||||
|
||||
Nothing here is stubbed. Every other suite in the repository substitutes something — a stubbed identity
|
||||
provider, an in-memory vault server, an in-memory cache — and each of those substitutions is a place a
|
||||
misunderstanding of the protocol can hide on both sides at once. This suite exists to catch exactly
|
||||
that, and it already has: the realm file registered a loopback redirect URI that Keycloak rejects,
|
||||
which no stub would ever have noticed.
|
||||
|
||||
It needs the development stack up and is skipped otherwise. The commands are in the README under
|
||||
"End-to-end verification"; a skipped run prints them too. (They cannot be repeated here: an XML
|
||||
comment may not contain a double hyphen, and every one of them has a flag.)
|
||||
-->
|
||||
|
||||
<PropertyGroup>
|
||||
<!--
|
||||
Exit code 8 is "no tests ran". Microsoft.Testing.Platform reports that as a failure, so an assembly
|
||||
whose only test skips would fail the solution-wide `dotnet test` on any machine without the stack up.
|
||||
The minimum-expected-tests option cannot express this: it demands a non-zero integer.
|
||||
|
||||
Narrow enough to be safe here: this project contains exactly one test, so if it runs at all it either
|
||||
passes or fails, and 8 can only mean it skipped for want of the stack.
|
||||
-->
|
||||
<TestingPlatformCommandLineArguments>--ignore-exit-code 8</TestingPlatformCommandLineArguments>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../src/DodoSSH.Client.Session/DodoSSH.Client.Session.csproj" />
|
||||
<ProjectReference Include="../../src/DodoSSH.Client.Ssh/DodoSSH.Client.Ssh.csproj" />
|
||||
<ProjectReference Include="../../src/DodoSSH.Client.Terminal/DodoSSH.Client.Terminal.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Testcontainers" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,371 @@
|
||||
using System.Text;
|
||||
using DodoSSH.Client.Domain;
|
||||
using DodoSSH.Client.Session;
|
||||
using DodoSSH.Client.Ssh;
|
||||
using DodoSSH.Client.Storage;
|
||||
using DodoSSH.Contracts;
|
||||
using DodoSSH.Crypto;
|
||||
using DotNet.Testcontainers.Builders;
|
||||
using DotNet.Testcontainers.Containers;
|
||||
|
||||
namespace DodoSSH.SystemTests;
|
||||
|
||||
/// <summary>
|
||||
/// M1's definition of done: sign in, enroll, unlock, create a host, sync, read it on a second machine,
|
||||
/// and open a shell on it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Nothing is stubbed. A real Keycloak issues the tokens and signs the key binding, a real API stores the
|
||||
/// ciphertext in a real PostgreSQL, real DSH1 crypto seals and opens it, and a real <c>sshd</c> answers at
|
||||
/// the end. Every other suite substitutes at least one of those, and each substitution is a place where a
|
||||
/// misreading of the protocol can be consistent on both sides and still wrong in production — which is
|
||||
/// exactly what this found the first time it ran.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// One test rather than several, because the steps are not independent: you cannot unlock without having
|
||||
/// enrolled, and enrollment happens once per account. Splitting them would mean sharing mutable state
|
||||
/// between tests or repeating a minute of setup per assertion.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class M1VerticalSliceTests : IAsyncLifetime
|
||||
{
|
||||
private const string Passphrase = "an end to end passphrase";
|
||||
private const string SshUsername = "dodo";
|
||||
private const string SshPassword = "correct-horse-battery-staple";
|
||||
private const int SshPort = 2222;
|
||||
|
||||
/// <remarks>
|
||||
/// 64 MiB is the floor <c>EnrollmentLimits</c> enforces, and this suite has to respect it — the other
|
||||
/// client suites use 8 MiB because their in-memory servers have no policy, and a real one rejects that
|
||||
/// outright. Worth knowing rather than discovering: the reduction those suites take for speed is only
|
||||
/// available because nothing is checking, and the difference is a 400 rather than a slow test.
|
||||
/// </remarks>
|
||||
private static readonly Argon2Profile ServerFloorProfile =
|
||||
Argon2Profile.FromStoredParameters(memoryKibibytes: 64 * 1024, passes: 3, parallelism: 1);
|
||||
|
||||
private readonly List<string> directories = [];
|
||||
|
||||
private string? unavailable;
|
||||
private IContainer? sshd;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
unavailable = await DevStack.WhyUnavailableAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
if (unavailable is not null)
|
||||
{
|
||||
// No container either. Paying half a minute of startup for a suite about to skip is how an
|
||||
// opt-in suite becomes one nobody opts into.
|
||||
return;
|
||||
}
|
||||
|
||||
sshd = new ContainerBuilder("linuxserver/openssh-server:latest")
|
||||
.WithEnvironment("PUID", "1000")
|
||||
.WithEnvironment("PGID", "1000")
|
||||
.WithEnvironment("USER_NAME", SshUsername)
|
||||
.WithEnvironment("USER_PASSWORD", SshPassword)
|
||||
.WithEnvironment("PASSWORD_ACCESS", "true")
|
||||
.WithEnvironment("SUDO_ACCESS", "false")
|
||||
.WithPortBinding(SshPort, assignRandomHostPort: true)
|
||||
|
||||
// A published port is not readiness: the entrypoint generates host keys and rewrites
|
||||
// sshd_config first. Both conditions are needed, and the port check is the one that actually
|
||||
// proves sshd is accepting.
|
||||
.WithWaitStrategy(Wait.ForUnixContainer()
|
||||
.UntilCommandIsCompleted("sh", "-c", $"netstat -ltn | grep -q ':{SshPort}'"))
|
||||
|
||||
.Build();
|
||||
|
||||
await sshd.StartAsync(TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (sshd is not null)
|
||||
{
|
||||
await sshd.DisposeAsync();
|
||||
}
|
||||
|
||||
foreach (var directory in directories.Where(Directory.Exists))
|
||||
{
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheWholeSlice()
|
||||
{
|
||||
if (unavailable is not null)
|
||||
{
|
||||
Assert.Skip(unavailable);
|
||||
}
|
||||
|
||||
var account = await DevStack.CreateUserAsync(Token);
|
||||
var browser = new ScriptedBrowser(account.Username, account.Password);
|
||||
|
||||
using var connection = await ServerConnection
|
||||
.SignInAsync(new Uri(DevStack.ApiBaseUrl), browser, TimeProvider.System, Token);
|
||||
|
||||
AssertDiscoveredFromTheServer(connection);
|
||||
|
||||
using var laptopCache = await OpenCacheAsync();
|
||||
await EnrollAsync(connection, laptopCache, browser);
|
||||
|
||||
var laptop = await UnlockAsync(laptopCache);
|
||||
await using var laptopSession = laptop;
|
||||
|
||||
var host = BuildHost();
|
||||
var entityId = await laptop.Hosts.CreateAsync(laptop.ActiveVaultId, host, Token);
|
||||
|
||||
var pushed = await laptop.SyncAsync(connection.Sync, Token);
|
||||
pushed.Pushed.ShouldBe(1);
|
||||
pushed.NeedsAttention.ShouldBeFalse();
|
||||
|
||||
await AssertTheServerCannotSeeTheAddressAsync(connection, entityId);
|
||||
|
||||
var seen = await ReadOnASecondMachineAsync(connection, host, entityId);
|
||||
|
||||
await AssertUnlocksOfflineAsync(laptopCache);
|
||||
|
||||
await OpenAShellAsync(seen);
|
||||
}
|
||||
|
||||
// ---- Steps ----
|
||||
|
||||
/// <remarks>
|
||||
/// The user typed one server URL. Everything about the identity provider — the authority, the client
|
||||
/// id, the scopes — came back from the server, which is the whole onboarding story.
|
||||
/// </remarks>
|
||||
private static void AssertDiscoveredFromTheServer(ServerConnection connection)
|
||||
{
|
||||
connection.Configuration.Oidc.Authority.ToString()
|
||||
.ShouldStartWith($"{DevStack.KeycloakBaseUrl}/realms/{DevStack.Realm}");
|
||||
|
||||
connection.Configuration.Oidc.ClientId.ShouldBe("dodossh-desktop");
|
||||
connection.Meta.SyncProtocolVersion.ShouldBe(1);
|
||||
connection.Meta.CryptoSpecVersion.ShouldBe(1);
|
||||
}
|
||||
|
||||
private static async Task EnrollAsync(
|
||||
ServerConnection connection,
|
||||
ClientCacheFactory caches,
|
||||
ScriptedBrowser browser)
|
||||
{
|
||||
var provisioner = new AccountProvisioner(
|
||||
connection.Account, connection.KeyBinding, caches, TimeProvider.System, ServerFloorProfile);
|
||||
|
||||
var before = await provisioner.RefreshAsync(DevStack.ApiBaseUrl, Token);
|
||||
before.Status.ShouldBe(ProvisionStatus.EnrollmentRequired);
|
||||
|
||||
var enrolled = await provisioner.EnrollAsync(
|
||||
DevStack.ApiBaseUrl, Passphrase, "e2e-laptop", "Personal", Token);
|
||||
|
||||
enrolled.Status.ShouldBe(ProvisionStatus.Ready);
|
||||
enrolled.RecoveryCode.ShouldNotBeNullOrWhiteSpace();
|
||||
|
||||
// Two sign-ins, not one. The second is the identity-provider key binding: an authorization whose
|
||||
// nonce is the key statement's hash, whose ID token the server verified against Keycloak's JWKS
|
||||
// before accepting the key. That is what stops a compromised DodoSSH server fabricating a key for
|
||||
// someone who never enrolled — see ADR 0001 — and it is invisible unless something counts.
|
||||
browser.SignInCount.ShouldBe(
|
||||
2, "enrollment must obtain an identity-provider signature over the published key");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Asserted against what the server hands back, not against the local mirror. With relay off the
|
||||
/// address stays inside the ciphertext; ADR 0004 is the only reason it would ever be otherwise.
|
||||
/// </remarks>
|
||||
private static async Task AssertTheServerCannotSeeTheAddressAsync(
|
||||
ServerConnection connection,
|
||||
Guid entityId)
|
||||
{
|
||||
var vaultId = (await connection.Account.GetMeAsync(Token)).Vaults.Single().VaultId;
|
||||
|
||||
var page = await connection.Sync.SyncPullAsync(
|
||||
vaultId, new SyncPullRequest(null, 100, [SyncEntityType.Host]), Token);
|
||||
|
||||
var change = page.Changes.Single(c => c.EntityId == entityId);
|
||||
|
||||
change.PlaintextFields.ShouldNotBeNull();
|
||||
change.PlaintextFields.RelayEnabled.ShouldBeFalse();
|
||||
change.PlaintextFields.Hostname.ShouldBeNull("the address must not leave the payload");
|
||||
change.PlaintextFields.Port.ShouldBeNull();
|
||||
|
||||
// What it does hold is opaque, and it carries its data key as the specification requires.
|
||||
change.Payload.ShouldNotBeNull();
|
||||
change.Payload.WrappedDataKey.ShouldNotBeEmpty();
|
||||
change.Payload.DataKeyId.ShouldNotBe(Guid.Empty);
|
||||
}
|
||||
|
||||
private async Task<HostSecret> ReadOnASecondMachineAsync(
|
||||
ServerConnection connection,
|
||||
HostSecret expected,
|
||||
Guid entityId)
|
||||
{
|
||||
using var desktopCache = await OpenCacheAsync();
|
||||
|
||||
var provisioner = new AccountProvisioner(
|
||||
connection.Account, connection.KeyBinding, desktopCache, TimeProvider.System, ServerFloorProfile);
|
||||
|
||||
// Already enrolled, so this only caches what an offline unlock will need.
|
||||
(await provisioner.RefreshAsync(DevStack.ApiBaseUrl, Token)).Status
|
||||
.ShouldBe(ProvisionStatus.Ready);
|
||||
|
||||
var desktop = await UnlockAsync(desktopCache);
|
||||
await using var session = desktop;
|
||||
|
||||
var pulled = await desktop.SyncAsync(connection.Sync, Token);
|
||||
pulled.Pulled.ShouldBe(1);
|
||||
|
||||
var listing = await desktop.Hosts.ListAsync(desktop.ActiveVaultId, Token);
|
||||
var seen = listing.Hosts.ShouldHaveSingleItem();
|
||||
|
||||
seen.EntityId.ShouldBe(entityId);
|
||||
seen.HasUnsyncedChanges.ShouldBeFalse();
|
||||
|
||||
// The decrypted host survived a round trip through a server that could read none of it — including
|
||||
// the directives, which merge per name and therefore have to come back in canonical form.
|
||||
seen.Host.ShouldBe(expected);
|
||||
|
||||
return seen.Host;
|
||||
}
|
||||
|
||||
private static async Task AssertUnlocksOfflineAsync(ClientCacheFactory caches)
|
||||
{
|
||||
// Nothing here touches the network: the salt, the parameters and the wrapped bundle are local.
|
||||
var offline = await new SessionOpener(caches, TimeProvider.System).UnlockAsync(Passphrase, Token);
|
||||
|
||||
offline.IsUnlocked.ShouldBeTrue(offline.Message);
|
||||
await offline.Session!.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Goes through the real trust-on-first-use path rather than around it. An unknown host key throws, the
|
||||
/// caller pins it and retries — which is what the interface does, and the only way to prove the
|
||||
/// fingerprint a user would be shown is the one the server actually presented.
|
||||
/// </remarks>
|
||||
private static async Task OpenAShellAsync(HostSecret host)
|
||||
{
|
||||
var knownHosts = new InMemoryKnownHostStore();
|
||||
var factory = new SshNetConnectionFactory(knownHosts);
|
||||
|
||||
var request = new SshConnectionRequest(
|
||||
host.Hostname, host.Port, host.Username!, new SshPasswordCredential(SshPassword));
|
||||
|
||||
try
|
||||
{
|
||||
await using var first = await factory.ConnectAsync(request, Token);
|
||||
Assert.Fail("An unseen host key must not be trusted silently.");
|
||||
}
|
||||
catch (SshHostKeyUnknownException exception)
|
||||
{
|
||||
exception.Presentation.Fingerprint.ShouldStartWith("SHA256:");
|
||||
await knownHosts.TrustAsync(exception.Presentation, Token);
|
||||
}
|
||||
|
||||
await using var connection = await factory.ConnectAsync(request, Token);
|
||||
await using var shell = await connection.OpenShellAsync(TerminalSize.Default, Token);
|
||||
|
||||
await shell.WriteTextAsync("echo dodossh-e2e-ok\n", Token);
|
||||
|
||||
var output = await ReadUntilEchoedAsync(shell, "dodossh-e2e-ok");
|
||||
|
||||
output.ShouldContain("dodossh-e2e-ok");
|
||||
}
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
||||
|
||||
private static HostSecret BuildHostFor(string hostname, int port) =>
|
||||
new()
|
||||
{
|
||||
Label = "e2e-target",
|
||||
Hostname = hostname,
|
||||
Port = port,
|
||||
Username = SshUsername,
|
||||
Notes = "created by the end-to-end slice",
|
||||
Options = HostOptions.Create([new HostOption("ServerAliveInterval", "30")]),
|
||||
};
|
||||
|
||||
private HostSecret BuildHost() =>
|
||||
BuildHostFor(sshd!.Hostname, sshd.GetMappedPublicPort(SshPort));
|
||||
|
||||
private async Task<ClientCacheFactory> OpenCacheAsync()
|
||||
{
|
||||
var directory = Path.Combine(Path.GetTempPath(), $"dodossh-e2e-{Guid.CreateVersion7():N}");
|
||||
Directory.CreateDirectory(directory);
|
||||
directories.Add(directory);
|
||||
|
||||
var factory = ClientCacheFactory.ForFile(new ClientPaths(directory).CacheFile);
|
||||
|
||||
try
|
||||
{
|
||||
await factory.MigrateAsync(Token);
|
||||
return factory;
|
||||
}
|
||||
catch
|
||||
{
|
||||
factory.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<VaultSession> UnlockAsync(ClientCacheFactory caches)
|
||||
{
|
||||
var outcome = await new SessionOpener(caches, TimeProvider.System).UnlockAsync(Passphrase, Token);
|
||||
|
||||
outcome.IsUnlocked.ShouldBeTrue(outcome.Message);
|
||||
return outcome.Session!;
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Waits for the marker twice — once as the shell echoes the typed command, once as its output — rather
|
||||
/// than for a fixed time. The login banner arrives first and its length is not something this test
|
||||
/// should have to know.
|
||||
/// </remarks>
|
||||
private static async Task<string> ReadUntilEchoedAsync(ISshShellSession shell, string marker)
|
||||
{
|
||||
var text = new StringBuilder();
|
||||
var buffer = new byte[8192];
|
||||
|
||||
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(Token);
|
||||
deadline.CancelAfter(TimeSpan.FromSeconds(30));
|
||||
|
||||
while (!deadline.IsCancellationRequested)
|
||||
{
|
||||
var read = await shell.ReadAsync(buffer, deadline.Token);
|
||||
|
||||
if (read == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
text.Append(Encoding.UTF8.GetString(buffer, 0, read));
|
||||
|
||||
if (Occurrences(text.ToString(), marker) >= 2)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return text.ToString();
|
||||
}
|
||||
|
||||
private static int Occurrences(string text, string marker)
|
||||
{
|
||||
var count = 0;
|
||||
var index = 0;
|
||||
|
||||
while ((index = text.IndexOf(marker, index, StringComparison.Ordinal)) >= 0)
|
||||
{
|
||||
count++;
|
||||
index += marker.Length;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Web;
|
||||
using DodoSSH.Client.Auth;
|
||||
|
||||
namespace DodoSSH.SystemTests;
|
||||
|
||||
/// <summary>
|
||||
/// Signs in to a real Keycloak by driving its login form over HTTP.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Stands in for the system browser, and only for the browser — everything else in the flow is the real
|
||||
/// thing. The authorization request, the login form, the redirect back to the loopback listener, the code
|
||||
/// exchange and PKCE verification all happen exactly as they would for a person clicking through.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Everything up to the redirect is awaited; the final hop is not.</b> That split is not tidiness.
|
||||
/// <c>OidcClient</c> awaits the launcher before it awaits the callback, and the last hop of this flow is a
|
||||
/// request <em>to</em> that callback — completing it inline would block on a request nobody is reading yet
|
||||
/// and the sign-in would deadlock instead of failing. Awaiting the earlier steps is what makes a rejected
|
||||
/// authorization request surface immediately, with Keycloak's own words, rather than as a five-minute wait
|
||||
/// for a browser that was never going to arrive.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed partial class ScriptedBrowser(string username, string password) : IBrowserLauncher
|
||||
{
|
||||
/// <summary>How many times a sign-in was driven, so the key binding's second one is visible.</summary>
|
||||
internal int SignInCount { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task OpenAsync(Uri url, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(url);
|
||||
|
||||
SignInCount++;
|
||||
|
||||
// Cookies are carried by hand, and automatic handling is off. That is not a shortcut — it is
|
||||
// required, and the reason is worth knowing:
|
||||
//
|
||||
// Keycloak marks its authentication-session cookies `Secure; SameSite=None`, because SameSite=None
|
||||
// is only legal alongside Secure. Over a development stack served on plain HTTP, a
|
||||
// spec-conformant client refuses to store a Secure cookie from an insecure origin, so
|
||||
// CookieContainer silently drops every one of them and the login POST comes back 400 with no
|
||||
// explanation. Browsers do complete this flow, because they treat loopback as a trustworthy
|
||||
// origin and make the exception. Carrying the cookies manually emulates that exception
|
||||
// deliberately, in one visible place, rather than looking like broken cookie handling.
|
||||
var handler = new HttpClientHandler { AllowAutoRedirect = false, UseCookies = false };
|
||||
|
||||
var http = new HttpClient(handler);
|
||||
|
||||
try
|
||||
{
|
||||
var redirect = await SubmitCredentialsAsync(http, url, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// Deliberately not awaited: this is the request the loopback listener is waiting for, and it
|
||||
// is only read after this method returns. Ownership of the client passes to the continuation.
|
||||
_ = DeliverAsync(http, handler, redirect, cancellationToken);
|
||||
|
||||
}
|
||||
catch
|
||||
{
|
||||
http.Dispose();
|
||||
handler.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Fetches the login page and posts the credentials, returning where Keycloak sends us.</summary>
|
||||
private async Task<Uri> SubmitCredentialsAsync(
|
||||
HttpClient http,
|
||||
Uri authorizationUrl,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, authorizationUrl);
|
||||
using var opened = await http.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var cookies = CollectCookies(opened);
|
||||
|
||||
var loginPage = await opened.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
var action = ExtractLoginAction(loginPage);
|
||||
|
||||
using var credentials = new FormUrlEncodedContent(
|
||||
new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["username"] = username,
|
||||
["password"] = password,
|
||||
["credentialId"] = string.Empty,
|
||||
});
|
||||
|
||||
using var login = new HttpRequestMessage(HttpMethod.Post, action) { Content = credentials };
|
||||
login.Headers.Add("Cookie", cookies);
|
||||
|
||||
using var posted = await http.SendAsync(login, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (posted.Headers.Location is { } redirect)
|
||||
{
|
||||
return redirect;
|
||||
}
|
||||
|
||||
// Keycloak answers a rejected login with another page rather than a header, so the reason is only
|
||||
// in the body. Reporting the status alone would be almost useless.
|
||||
var body = await posted.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"Keycloak answered the login form with {(int)posted.StatusCode} and no redirect. It said: "
|
||||
+ Summarise(body));
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Only the <c>name=value</c> part of each <c>Set-Cookie</c>, which is all a request may send back.
|
||||
/// </remarks>
|
||||
private static string CollectCookies(HttpResponseMessage response)
|
||||
{
|
||||
if (!response.Headers.TryGetValues("Set-Cookie", out var values))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var pairs = values
|
||||
.Select(value => value.Split(';', 2)[0].Trim())
|
||||
.Where(pair => pair.Length > 0);
|
||||
|
||||
return string.Join("; ", pairs);
|
||||
}
|
||||
|
||||
private static async Task DeliverAsync(
|
||||
HttpClient http,
|
||||
HttpClientHandler handler,
|
||||
Uri redirect,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var delivered = await http.GetAsync(redirect, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Nothing useful to do here. If the code never arrives, the sign-in fails on its own timeout
|
||||
// and rethrowing on an unobserved task would take the test host down with it instead.
|
||||
}
|
||||
finally
|
||||
{
|
||||
http.Dispose();
|
||||
handler.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The form's action carries the session code and the execution id, so it cannot be constructed — it
|
||||
/// has to be read back out of the page, and it arrives HTML-escaped.
|
||||
/// </remarks>
|
||||
private static Uri ExtractLoginAction(string loginPage)
|
||||
{
|
||||
var match = LoginFormAction().Match(loginPage);
|
||||
|
||||
if (!match.Success)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Could not find Keycloak's login form, so the authorization request was rejected before a "
|
||||
+ $"login was ever offered. Keycloak said: {Summarise(loginPage)}");
|
||||
}
|
||||
|
||||
return new Uri(HttpUtility.HtmlDecode(match.Groups["action"].Value), UriKind.Absolute);
|
||||
}
|
||||
|
||||
/// <remarks>Surfaces Keycloak's own error text, which is the only useful part of a rejection page.</remarks>
|
||||
private static string Summarise(string page)
|
||||
{
|
||||
var messages = ErrorMessage().Matches(page)
|
||||
.Select(match => match.Groups["text"].Value.Trim())
|
||||
.Where(text => text.Length > 0)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
if (messages.Length > 0)
|
||||
{
|
||||
return string.Join(" / ", messages.Select(text => $"\"{text}\""));
|
||||
}
|
||||
|
||||
var title = PageTitle().Match(page);
|
||||
|
||||
return title.Success
|
||||
? $"a page titled \"{title.Groups["text"].Value.Trim()}\" with no error text"
|
||||
: $"nothing recognisable, in {page.Length} characters";
|
||||
}
|
||||
|
||||
// A timeout because the input is a page from a server, and an unbounded backtrack on untrusted
|
||||
// input is a hang rather than a failure.
|
||||
[GeneratedRegex(
|
||||
"""<form[^>]*id="kc-form-login"[^>]*action="(?<action>[^"]+)""",
|
||||
RegexOptions.IgnoreCase,
|
||||
matchTimeoutMilliseconds: 2000)]
|
||||
private static partial Regex LoginFormAction();
|
||||
|
||||
// Matches both shapes Keycloak uses: the per-field "input-error" spans on a login page, and the
|
||||
// "kc-feedback" / alert text on a rejected request.
|
||||
[GeneratedRegex(
|
||||
"""(?:id|class)="[^"]*(?:input-error|kc-feedback-text|pf-v5-c-alert__title)[^"]*"[^>]*>(?<text>[^<]{1,300})<""",
|
||||
RegexOptions.IgnoreCase,
|
||||
matchTimeoutMilliseconds: 2000)]
|
||||
private static partial Regex ErrorMessage();
|
||||
|
||||
[GeneratedRegex("""<title>(?<text>[^<]{0,200})</title>""", RegexOptions.IgnoreCase,
|
||||
matchTimeoutMilliseconds: 2000)]
|
||||
private static partial Regex PageTitle();
|
||||
}
|
||||
@@ -0,0 +1,564 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"Testcontainers": {
|
||||
"type": "Direct",
|
||||
"requested": "[4.13.0, )",
|
||||
"resolved": "4.13.0",
|
||||
"contentHash": "j8vi9jPBNSwaraGGx8w+2gtZyWrlbKxdhiGMS3nektg+KiwjFWx9ghCjs57EoQfvI+IAbzti0oQJupQChwgMog==",
|
||||
"dependencies": {
|
||||
"Docker.DotNet.Enhanced": "4.3.3",
|
||||
"Docker.DotNet.Enhanced.X509": "4.3.3",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.3",
|
||||
"SSH.NET": "2025.1.0",
|
||||
"SharpZipLib": "1.4.2"
|
||||
}
|
||||
},
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"Docker.DotNet.Enhanced": {
|
||||
"type": "Transitive",
|
||||
"resolved": "4.3.3",
|
||||
"contentHash": "nGicLwvd42FhRk+khY5uS6cx49ErNdwYKnYBg0F4m4BDKLp/R77AVmmN9xAiqI3W/wN5ZCHkdUhgxf5ORkZuFQ==",
|
||||
"dependencies": {
|
||||
"Docker.DotNet.Enhanced.Handler.Abstractions": "4.3.3",
|
||||
"Docker.DotNet.Enhanced.LegacyHttp": "4.3.3",
|
||||
"Docker.DotNet.Enhanced.NPipe": "4.3.3",
|
||||
"Docker.DotNet.Enhanced.NativeHttp": "4.3.3",
|
||||
"Docker.DotNet.Enhanced.Unix": "4.3.3",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
|
||||
}
|
||||
},
|
||||
"Docker.DotNet.Enhanced.Handler.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "4.3.3",
|
||||
"contentHash": "9Cp8hOgtynixcDoAs9lnEaQosluojSYmiW3fsLsLIVfZjlq/fznSIZNUhnmyT4Xo1Iyuok/y49WL/25O47u0Pw==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
|
||||
}
|
||||
},
|
||||
"Docker.DotNet.Enhanced.LegacyHttp": {
|
||||
"type": "Transitive",
|
||||
"resolved": "4.3.3",
|
||||
"contentHash": "7j3M16emv9PAQN7VwFn23xLYNj8GJmwPOcogveHkaWnOCqiC+anRaNKQwqIBNApM1AuwZKivehTKTPmmrjUUnw==",
|
||||
"dependencies": {
|
||||
"Docker.DotNet.Enhanced.Handler.Abstractions": "4.3.3"
|
||||
}
|
||||
},
|
||||
"Docker.DotNet.Enhanced.NativeHttp": {
|
||||
"type": "Transitive",
|
||||
"resolved": "4.3.3",
|
||||
"contentHash": "iNzK+jRFeEMobSA7l/h4ARwCKOOefOWtVN5/RB0ft6/6H6IQXvVUuOgGyZAjYLBT7TsyClRYno2B904f3dtBuQ==",
|
||||
"dependencies": {
|
||||
"Docker.DotNet.Enhanced.Handler.Abstractions": "4.3.3"
|
||||
}
|
||||
},
|
||||
"Docker.DotNet.Enhanced.NPipe": {
|
||||
"type": "Transitive",
|
||||
"resolved": "4.3.3",
|
||||
"contentHash": "ZTLYufuEfY0e6qLOgeH9QgXx2KYuoABRVaY5A8rsggyLgYqbDj9rCRfVAhHPCUv83S7pVxDHy+Tvm/BnxjWVpg==",
|
||||
"dependencies": {
|
||||
"Docker.DotNet.Enhanced.Handler.Abstractions": "4.3.3"
|
||||
}
|
||||
},
|
||||
"Docker.DotNet.Enhanced.Unix": {
|
||||
"type": "Transitive",
|
||||
"resolved": "4.3.3",
|
||||
"contentHash": "ypo8qNbmvHw1t9VfpRTMogCw2vht6VjkXzlGYUUeP2H2bf83USURdla1maW1njn2oq2rfLUFOGMfmt+A37QU2w==",
|
||||
"dependencies": {
|
||||
"Docker.DotNet.Enhanced.Handler.Abstractions": "4.3.3"
|
||||
}
|
||||
},
|
||||
"Docker.DotNet.Enhanced.X509": {
|
||||
"type": "Transitive",
|
||||
"resolved": "4.3.3",
|
||||
"contentHash": "oBDibWezEv4hgj3RIQxI3DVcxkNV1MdrD0d/jhjUu+h3DL+qc0wlkQva15kkwMatXmC/hWp1VP0DMoFXe+BmEw==",
|
||||
"dependencies": {
|
||||
"Docker.DotNet.Enhanced.Handler.Abstractions": "4.3.3"
|
||||
}
|
||||
},
|
||||
"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.Data.Sqlite.Core": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "TPCs0ldm7AWqcKmp6f/Xr+14sat7hx4rHfRlS4RgCURBH2thEWbAKEyX7cCWr63zVJVOJIJZTg2cBiUXa8ys6g==",
|
||||
"dependencies": {
|
||||
"SQLitePCLRaw.core": "2.1.11"
|
||||
}
|
||||
},
|
||||
"Microsoft.EntityFrameworkCore.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "bOzrFCl6uZCjaSh2bG1ToRQRdx+iXvxosCg9hFyG9OWeAzOFI4xev9OqKeWfKf/kAHyox2JnbcvLVf2ceA7sqA=="
|
||||
},
|
||||
"Microsoft.EntityFrameworkCore.Analyzers": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "2gLDordUCGf3aNOOuqtTbP5mxhiP9nk6TnvGiE3RnqT891O+Zf/qKu1PIREubs1M16A0SImr4vULBfU5BTDs1Q=="
|
||||
},
|
||||
"Microsoft.EntityFrameworkCore.Sqlite.Core": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "YbVWMIouzwTKBiLms8boa7xeRT88wI14R1msv3XExFk9n0/sa8nU7MwDa1CKtfLGMJs7O7QWuS9/xhcQ72AD2A==",
|
||||
"dependencies": {
|
||||
"Microsoft.Data.Sqlite.Core": "10.0.10",
|
||||
"Microsoft.EntityFrameworkCore.Relational": "10.0.10",
|
||||
"Microsoft.Extensions.Caching.Memory": "10.0.10",
|
||||
"Microsoft.Extensions.Configuration.Abstractions": "10.0.10",
|
||||
"Microsoft.Extensions.DependencyModel": "10.0.10",
|
||||
"Microsoft.Extensions.Logging": "10.0.10",
|
||||
"SQLitePCLRaw.core": "2.1.11"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Caching.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "4ZFBNE+jzR+CrWWlhOesnmywCW7pYKT0dxyAQRdL11yJwxe4jvcAu31eorFtEkoFeCDcUTeNssgPv2yaRRptaQ==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Primitives": "10.0.10"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Caching.Memory": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "N1w5H7uK6gCTnCBZAWzE0/EQYSPysij/uYwDqntqBVvBa6bjMmBKitsnEFd6yh/SX3wLm67nO6+OnZ84K+gZWg==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Caching.Abstractions": "10.0.10",
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "10.0.10",
|
||||
"Microsoft.Extensions.Options": "10.0.10",
|
||||
"Microsoft.Extensions.Primitives": "10.0.10"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Configuration.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "5Vnd2I75DmZCVEjSynIdJ/0EGafgnLQwgR3t2C2/fkjx/nRG+cLwxLLdInoHeCEpkD5K4Ov/g9ZCRYrl4TRsaA==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Primitives": "10.0.10"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.DependencyInjection": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "ANyvsgkNBRvcJh2XLgn8veGmajf+8m0AbKK+HPWdRL1yraSNVVSmQhFntLtdz/C795jxqqup+k05cs/3jZQPOA==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "z/2xXlFw2aLGjHyEm6E0tQ+In6VfzQzTrtArbQ2c0TQE16ZbyDCMGPvaUT9I0s8rgy9sRWlU2P9waW37qV04qA=="
|
||||
},
|
||||
"Microsoft.Extensions.DependencyModel": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "rfZA1RjR021RPqSmIPovfz2aOd79TGqJ9BengbjnzIISOVwjLmuSDnhCMmiY/1c6iYvGolQ1iNGzkav0u11XEA=="
|
||||
},
|
||||
"Microsoft.Extensions.Logging": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "Tf6z5HsL0VDYRTfvsoNrTGHGheCwkTsZBA2FFh5ATJUbkAwug+FFNISJK2gjpUNemlAOoWllAK52HOWCjto3EQ==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection": "10.0.10",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "10.0.10",
|
||||
"Microsoft.Extensions.Options": "10.0.10"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Logging.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "zkFxGYUvdxAvIKTyXHrmW+Sux53D4SezD9dMyZ6hrwwzPQJNuwCRy1f5W7AvYTqacEGhWF2XderRQG1OvbV8og==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Options": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "srnhnk7nE8krBiIXp71LvBmKBtraBONWSRzdjJgRv1Ko9Mp8IVNqv4vIS9hGeVteBig8aQkva9ZG+sC+o5sVcA==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10",
|
||||
"Microsoft.Extensions.Primitives": "10.0.10"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Primitives": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "5wu/GrYVd8mG2DVUw3vFJzF+O336TyTGg/Kmcgw9bfwYhCoFiV5lR5QeEmKecJyrW4W54nMfD3p3589E8a7czQ=="
|
||||
},
|
||||
"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=="
|
||||
},
|
||||
"SharpZipLib": {
|
||||
"type": "Transitive",
|
||||
"resolved": "1.4.2",
|
||||
"contentHash": "yjj+3zgz8zgXpiiC3ZdF/iyTBbz2fFvMxZFEBPUcwZjIvXOf37Ylm+K58hqMfIBt5JgU/Z2uoUS67JmTLe973A=="
|
||||
},
|
||||
"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.client.api": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"DodoSSH.Client.Auth": "[1.0.0, )",
|
||||
"DodoSSH.Contracts": "[1.0.0, )",
|
||||
"DodoSSH.Crypto": "[1.0.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.client.auth": {
|
||||
"type": "Project"
|
||||
},
|
||||
"dodossh.client.domain": {
|
||||
"type": "Project"
|
||||
},
|
||||
"dodossh.client.session": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"DodoSSH.Client.Api": "[1.0.0, )",
|
||||
"DodoSSH.Client.Auth": "[1.0.0, )",
|
||||
"DodoSSH.Client.Domain": "[1.0.0, )",
|
||||
"DodoSSH.Client.Storage": "[1.0.0, )",
|
||||
"DodoSSH.Client.Sync": "[1.0.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.client.ssh": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"SSH.NET": "[2025.1.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.client.storage": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"DodoSSH.Contracts": "[1.0.0, )",
|
||||
"DodoSSH.Crypto": "[1.0.0, )",
|
||||
"EFCore.NamingConventions": "[10.0.1, )",
|
||||
"Microsoft.EntityFrameworkCore.Sqlite": "[10.0.10, )"
|
||||
}
|
||||
},
|
||||
"dodossh.client.sync": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"DodoSSH.Client.Api": "[1.0.0, )",
|
||||
"DodoSSH.Client.Domain": "[1.0.0, )",
|
||||
"DodoSSH.Client.Storage": "[1.0.0, )",
|
||||
"DodoSSH.Contracts": "[1.0.0, )",
|
||||
"DodoSSH.Crypto": "[1.0.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.client.terminal": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"DodoSSH.Client.Ssh": "[1.0.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.contracts": {
|
||||
"type": "Project"
|
||||
},
|
||||
"dodossh.crypto": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"NSec.Cryptography": "[26.4.0, )"
|
||||
}
|
||||
},
|
||||
"BouncyCastle.Cryptography": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.6.2, )",
|
||||
"resolved": "2.6.2",
|
||||
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
|
||||
},
|
||||
"EFCore.NamingConventions": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[10.0.1, )",
|
||||
"resolved": "10.0.1",
|
||||
"contentHash": "Xs5k8XfNKPkkQSkGmZkmDI1je0prLTdxse+s8PgTFZxyBrlrTLzTBUTVJtQKSsbvu4y+luAv8DdtO5SALJE++A==",
|
||||
"dependencies": {
|
||||
"Microsoft.EntityFrameworkCore": "[10.0.1, 11.0.0)",
|
||||
"Microsoft.EntityFrameworkCore.Relational": "[10.0.1, 11.0.0)",
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1"
|
||||
}
|
||||
},
|
||||
"libsodium": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[1.0.22, )",
|
||||
"resolved": "1.0.22",
|
||||
"contentHash": "KPD9SloJFclrsjnhABu7dzWrcyYkwPbvx5l1gRSPAX/0n+OBtSiVCKtGFv4n+ecWUHU0tCG9LSSwoZZx673zBQ=="
|
||||
},
|
||||
"Microsoft.EntityFrameworkCore": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[10.0.10, )",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "a0V7zj/VbYP6dTdWpUgE/r2PuLKtUGe2aJ0lVKkn/wP9ZhaxUz2kQydVfvOjCv2SKxlrqdBfHhPD4Cvlf+4ffA==",
|
||||
"dependencies": {
|
||||
"Microsoft.EntityFrameworkCore.Abstractions": "10.0.10",
|
||||
"Microsoft.EntityFrameworkCore.Analyzers": "10.0.10",
|
||||
"Microsoft.Extensions.Caching.Memory": "10.0.10",
|
||||
"Microsoft.Extensions.Logging": "10.0.10"
|
||||
}
|
||||
},
|
||||
"Microsoft.EntityFrameworkCore.Relational": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[10.0.10, )",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "wNonj40aZxia+GtuBiiD6ZqVh4h6y5Nje1bGdmzZ8/ui0QRsAN+S0SIrLHFCEGbG9cDbeaE40sh+Lr7o9rRs6g==",
|
||||
"dependencies": {
|
||||
"Microsoft.EntityFrameworkCore": "10.0.10",
|
||||
"Microsoft.Extensions.Caching.Memory": "10.0.10",
|
||||
"Microsoft.Extensions.Configuration.Abstractions": "10.0.10",
|
||||
"Microsoft.Extensions.Logging": "10.0.10"
|
||||
}
|
||||
},
|
||||
"Microsoft.EntityFrameworkCore.Sqlite": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[10.0.10, )",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "kzg9MuQNJvZQxAU+piSkEzc7/1tpW6n1nVSGGMObu2GgxLK8Nf+6fvZundaznTZ+O2KhfPZ8HFNCzMH3PWDUmA==",
|
||||
"dependencies": {
|
||||
"Microsoft.EntityFrameworkCore.Sqlite.Core": "10.0.10",
|
||||
"Microsoft.Extensions.Caching.Memory": "10.0.10",
|
||||
"Microsoft.Extensions.Configuration.Abstractions": "10.0.10",
|
||||
"Microsoft.Extensions.DependencyModel": "10.0.10",
|
||||
"Microsoft.Extensions.Logging": "10.0.10",
|
||||
"SQLitePCLRaw.bundle_e_sqlite3": "2.1.11",
|
||||
"SQLitePCLRaw.core": "2.1.11"
|
||||
}
|
||||
},
|
||||
"NSec.Cryptography": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[26.4.0, )",
|
||||
"resolved": "26.4.0",
|
||||
"contentHash": "0vsCtY5f+YgQROiWNqzgWp+l2pddfk9FkWoGV/bEo0MuEYPKlJWuoA8aOfO6qp3f+EnObKE3zSJhn1PspJeJVg==",
|
||||
"dependencies": {
|
||||
"libsodium": "[1.0.22, 1.0.23)"
|
||||
}
|
||||
},
|
||||
"SQLitePCLRaw.bundle_e_sqlite3": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.1.12, )",
|
||||
"resolved": "2.1.12",
|
||||
"contentHash": "mAgscpQMLw5/nfA1Q5oJVAT29yROUo1ifZGbbTpx/lwZpSxMUGoYbKfmvdm8oXER+RzxqBmmQzeBEVKfeHv2nw==",
|
||||
"dependencies": {
|
||||
"SQLitePCLRaw.lib.e_sqlite3": "2.1.12",
|
||||
"SQLitePCLRaw.provider.e_sqlite3": "2.1.12"
|
||||
}
|
||||
},
|
||||
"SQLitePCLRaw.core": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.1.12, )",
|
||||
"resolved": "2.1.12",
|
||||
"contentHash": "ETpNw9DY3ckWLgRRAeCHj+GKOuPi61aeczkXhgHexUvqoZBAYg8RYESE2J7O1M7+o6QbdSEZwrw9bfqztUVWXg=="
|
||||
},
|
||||
"SQLitePCLRaw.lib.e_sqlite3": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.1.12, )",
|
||||
"resolved": "2.1.12",
|
||||
"contentHash": "fWi8Dbknuhgg72fWinIdjXVaqO1hHL4YBBwVLnr7e1c9TAZwJ0QE38j9syW1hwx6HaqEVTwI+O07WPdZn8Rp0w=="
|
||||
},
|
||||
"SQLitePCLRaw.provider.e_sqlite3": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.1.12, )",
|
||||
"resolved": "2.1.12",
|
||||
"contentHash": "W3oH4XIfCzFrgUSDKHhN6N+dgzA5YHOR2VxX8GB6Qy7CyrJJgxPEG8NirgYWlPQC5P2jz2knSsexWu4tDUL33g==",
|
||||
"dependencies": {
|
||||
"SQLitePCLRaw.core": "2.1.12"
|
||||
}
|
||||
},
|
||||
"SSH.NET": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2025.1.0, )",
|
||||
"resolved": "2025.1.0",
|
||||
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
|
||||
"dependencies": {
|
||||
"BouncyCastle.Cryptography": "2.6.2",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user