Files
jaap-jan 8a77b7ca68
ci / build and test (pull_request) Failing after 2m34s
ci / desktop nightly (pull_request) Skipped
ci / api image (pull_request) Skipped
ci / android head (pull_request) Successful in 3m28s
Say how far a connection has got while it is still being made
The connecting card set its status string once, when the tab was created, and
never touched it again. Every connection therefore looked identical from the
outside: one three seconds into a key exchange, one waiting out a fifteen-second
timeout against a machine that is asleep, and one that had hung all drew the
same "connecting…". The card now draws the five steps of getting there, each lit
at the moment the handshake reports reaching it, over an amber track that fills
as they finish.

◆ NOTHING ON THE LIST IS INVENTED. Every row changes state because a layer below
it said so, at the instant the thing it names actually began.

That is the whole reason it is worth showing, and it is why most of this commit
is plumbing rather than XAML: there was no progress reporting anywhere in the
stack to hook a step list onto, and a card animating plausible progress would
have been indistinguishable from one that had stopped receiving any.

SshConnectionPhase names four phases and deliberately not more. SSH.NET runs the
entire handshake inside one ConnectAsync and raises exactly one event from the
middle of it — HostKeyReceived, once the key exchange has produced a key to show
— so that event is the only interior moment there is to report. Everything
before it is Reaching and everything after it is Authenticating. A fifth phase
in that assembly would have to be a timer, so there is not one. OpeningShell is
reported by TerminalWorkspace instead, because that is where it happens: the
factory's work ends with an authenticated connection, and asking for a
pseudo-terminal on one is a separate round trip. The SFTP path passes null — a
second connection opened behind an already-open shell has nobody watching a step
list for it.

The card's fifth step, "Starting the terminal", is the renderer wait and lives
in the shell rather than in the SSH assembly, which has never heard of a
renderer. On the first connection after a cold start it is a real wait with a
real failure mode of its own — a missing WebView2 runtime — so a list that began
at "reaching the host" would leave the one wait most likely to hang unnamed.

Amber for the step in flight, and that follows the palette's rule rather than
bending it. Green is what is true and purple is what you can press; a step still
happening is neither, and it is exactly the caveat-worth-reading that amber
exists for. Steps behind it go green as they become true. Nothing animates,
which is the argument TransfersScreen.axaml already makes for its own track,
reaching a screen with far more reason to want a spinner: a spinner is furniture
invented to fill a state nobody measured, and these states are measured, so the
track fills to what has finished and then waits there.

A refusal keeps the step it stopped on, in red, with the ones behind it still
green. That is the half a progress bar could not do, and it is the difference
between "that host is not there" and "that host is there and would not have me"
— a question the reason sentence alone frequently does not settle.

The strip's dot goes amber while a tab is connecting, on both heads. It was
grey, and so is a tab whose shell has exited: the two states in that strip with
the least in common, one worth waiting for and one over. PhoneShell's own
comment already recorded half of this — the dot stopped being green before
anything had answered — and this is the other half.

Progress is raised inline rather than through System.Progress<T>, which captures
whatever synchronisation context it was constructed on and posts to it. That
reads like a convenience and is really a second place the marshalling decision
gets made: silently, differently under a test with no context, and out of order
with respect to the failure that follows a phase. The shell marshals once, in
one handler, through a new optional post parameter on MainWindowViewModel — the
same seam TransfersViewModel already uses, and for the reason its own remark
gives. The three Dispatcher.UIThread.Post calls that predate it are the ones
this suite's comments record as out of reach; they are left alone rather than
swept in here.

Both heads draw the list. They differ in one place: Phone.axaml's mono class
sets a colour and a size along with the family, so the caption rule names its
own family instead of composing the two and asking two rules for one Foreground.
The desktop's mono sets the family alone, which is why ConnectingCard does
compose them. Each head also gains SHOW LOGS beside the button that gives up —
the step list is this attempt and the log is every other one, which is what a
connection taking too long actually raises.

Seven tests, and the two that matter most run against the container rather than
a fake: a real handshake reports its phases in order, and a host-key refusal
never claims to have authenticated. A fake asserting what it was written to
assert would have established nothing about either. The rest cover the tab
advancing while the connection is gated, the step a refusal stops on, and a
phase reported after the user has given up on the tab. 1,861 tests, none
failing.

The Android head's layout is not verified by anything. It compiles, and
compiled bindings mean every new binding path resolves, but that project is not
in DodoSSH.slnx, there is no test project for it and no device here — so unlike
the desktop card, whose shapes the layout harness measures, these rows have not
been drawn. Vertical fit is reasoned, not observed.
2026-08-10 15:47:45 +02:00

532 lines
24 KiB
C#

using System.Text;
using DodoSSH.Client.Domain;
using DodoSSH.Client.Session;
using DodoSSH.Client.Ssh;
using DodoSSH.Client.Storage;
using DodoSSH.Client.Sync;
using DodoSSH.Contracts;
using DodoSSH.Crypto;
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(DevStack stack) : IClassFixture<DevStack>, IAsyncDisposable
{
private const string Passphrase = "an end to end passphrase";
/// <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 = [];
/// <inheritdoc />
public ValueTask DisposeAsync()
{
foreach (var directory in directories.Where(Directory.Exists))
{
Directory.Delete(directory, recursive: true);
}
return ValueTask.CompletedTask;
}
[Fact]
public async Task TheWholeSlice()
{
// The realm file's own account, deliberately — see DevStack.RealmUser. A runtime-minted one hid a
// sign-in failure that only the committed configuration had.
var account = DevStack.RealmUser;
var browser = new ScriptedBrowser(account.Username, account.Password);
using var connection = await SignInToTheStackAsync(browser);
AssertDiscoveredFromTheServer(connection);
using var laptopCache = await OpenCacheAsync();
await EnrollAsync(connection, laptopCache, browser);
var laptop = await UnlockAsync(laptopCache);
await using var laptopSession = laptop;
// The key first, because the host binds it. A second item type in the same vault and the same
// outbox is what makes this a test of the shared write path rather than of hosts: the server picks a
// table per type, the client picks a cipher per type, and the AAD binds a different resource type
// into each. All three are hand-kept mappings between enums that do not line up, and a swap between
// them encrypts, decrypts and stores perfectly on the machine that made it.
var key = BuildKey();
var keyId = await laptop.SshKeys.CreateAsync(laptop.ActiveVaultId, key, Token);
// Bound to the key, which also makes this host a schema-version-2 payload — so the slice covers a
// payload written at a version older clients will refuse to edit, through the real server.
var host = BuildHost(keyId);
var entityId = await laptop.Hosts.CreateAsync(laptop.ActiveVaultId, host, Token);
var pushed = await laptop.SyncAsync(connection.Sync, laptop.ActiveVaultId, Token);
AssertTheKeyAndTheHostWentUpWithTheirLogEntries(pushed);
await AssertTheServerCannotSeeTheAddressAsync(connection, entityId);
await AssertTheServerLearnsNothingAboutTheKeyAsync(connection, keyId);
// The shell, and the trust decision it produces. Before the second machine reads the vault, so that
// what the second machine pulls includes the host key this one approved — which is the claim the whole
// item type exists to make and the only place it is proved through a real server.
var pin = await OpenAShellAsync(laptop, host);
var trusted = await laptop.SyncAsync(connection.Sync, laptop.ActiveVaultId, Token);
trusted.PushedItems.ShouldBe(1, "the host key the user approved at the prompt");
// And its activity entry. Worth asserting rather than ignoring: a pin is written programmatically at
// connect time and never through a screen, which is exactly the write an activity hook placed in the
// view models would have missed — see IActivityLogSink.
trusted.PushedLogEntries.ShouldBe(1);
trusted.NeedsAttention.ShouldBeFalse();
await AssertTheServerLearnsNothingAboutTheTrustedHostAsync(connection);
await ReadOnASecondMachineAsync(connection, host, entityId, key, keyId, pin);
await AssertUnlocksOfflineAsync(laptopCache);
}
// ---- Steps ----
/// <summary>Signs in against the Keycloak this suite started for itself.</summary>
/// <remarks>
/// <para>
/// The plaintext exemption is stated here rather than inherited from the shape of an address, and
/// saying it out loud is the point. <see cref="ServerConnection"/> allows an <c>http</c> authority
/// only when it is loopback — a sound rule, and not one this suite can lean on. Testcontainers
/// reports the host a caller can actually reach it at, so running these tests directly yields
/// <c>localhost</c> and passes, while running them inside a container yields the bridge gateway
/// <c>172.17.0.1</c> and is refused.
/// </para>
/// <para>
/// That refusal is the product being correct. 172.17.0.1 is genuinely not loopback, and a client
/// that quietly accepted plaintext metadata from a routable address would be a real weakness for
/// everybody who is not a test. So the exemption is claimed here, by the one caller that knows it
/// started the provider itself and that it lives for the length of one test, and the rule stays
/// exactly as strict for everyone else.
/// </para>
/// </remarks>
private Task<ServerConnection> SignInToTheStackAsync(ScriptedBrowser browser) =>
ServerConnection.SignInAsync(
stack.ApiBaseUrl,
browser,
TimeProvider.System,
Token,
configureOidc: options => options with { RequireHttpsMetadata = false });
/// <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 void AssertDiscoveredFromTheServer(ServerConnection connection)
{
connection.Configuration.Oidc.Authority.ToString()
.ShouldStartWith(stack.Authority.ToString());
connection.Configuration.Oidc.ClientId.ShouldBe("dodossh-desktop");
// Server:PublicBaseUrl, which is what a client behind a proxy would follow. Worth asserting
// because it is configuration the server states about itself and nothing else would notice it
// being wrong.
connection.Configuration.ApiBaseUrl.ShouldBe(stack.ApiBaseUrl);
connection.Meta.SyncProtocolVersion.ShouldBe(1);
connection.Meta.CryptoSpecVersion.ShouldBe(1);
}
private 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(ServerUrl, Token);
before.Status.ShouldBe(ProvisionStatus.EnrollmentRequired);
var enrolled = await provisioner.EnrollAsync(
ServerUrl, 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>
/// <summary>
/// Two items and two activity entries, through the real server.
/// </summary>
/// <remarks>
/// Creating a key and creating a host are each recorded, and the entries go up in the same batch as the
/// items they are about. <c>PushedItems</c> is the number this assertion was originally written about —
/// the user's own work — and the log entries are counted apart precisely so that number goes on meaning
/// what it meant before there were any.
/// </remarks>
private static void AssertTheKeyAndTheHostWentUpWithTheirLogEntries(SyncReport pushed)
{
pushed.PushedItems.ShouldBe(2);
pushed.PushedLogEntries.ShouldBe(2);
pushed.Pushed.ShouldBe(4);
pushed.NeedsAttention.ShouldBeFalse();
}
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);
}
/// <remarks>
/// The relay concession is the host's alone. A key has no address to resolve, so the server is given
/// nothing at all about it — not even the public-key fingerprint its own schema has a column for, which
/// it would have accepted. A fingerprint is not secret but it is a stable identifier for a key pair, and
/// nothing in the product reads that column; see the note on <c>SshKeyKind.Fields</c>.
/// </remarks>
private static async Task AssertTheServerLearnsNothingAboutTheKeyAsync(
ServerConnection connection,
Guid keyId)
{
var vaultId = (await connection.Account.GetMeAsync(Token)).Vaults.Single().VaultId;
var page = await connection.Sync.SyncPullAsync(
vaultId, new SyncPullRequest(null, 100, [SyncEntityType.SshKey]), Token);
// Asked for keys, and got only keys back — so the filter the client relies on is honoured by the
// real endpoint and not merely by the in-memory one the unit suites use.
page.Changes.ShouldAllBe(change => change.EntityType == SyncEntityType.SshKey);
var change = page.Changes.Single(c => c.EntityId == keyId);
change.PlaintextFields.ShouldBeNull(
"a key gives the server no plaintext columns, so it hydrates to nothing at all");
change.Payload.ShouldNotBeNull();
change.Payload.WrappedDataKey.ShouldNotBeEmpty();
change.Payload.DataKeyId.ShouldNotBe(Guid.Empty);
}
/// <remarks>
/// Takes the host key presentation the shell step produced, because the point of pinning trust in the
/// vault is that this machine — which has never spoken to that <c>sshd</c> — already knows the fingerprint
/// the other one approved.
/// </remarks>
private async Task ReadOnASecondMachineAsync(
ServerConnection connection,
HostSecret expected,
Guid entityId,
SshKeySecret expectedKey,
Guid keyId,
HostKeyPresentation pin)
{
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(ServerUrl, Token)).Status
.ShouldBe(ProvisionStatus.Ready);
var desktop = await UnlockAsync(desktopCache);
await using var session = desktop;
var pulled = await desktop.SyncAsync(connection.Sync, desktop.ActiveVaultId, Token);
pulled.PulledItems.ShouldBe(3, "the host, the key and the approved host key, in one pass");
// And the three activity entries the first machine wrote about them, which is the claim the log
// exists to make: what somebody did on one machine is readable on another. Once teams land it is an
// administrator reading it rather than the same person, and nothing else about it changes.
pulled.PulledLogEntries.ShouldBe(3);
var listing = await desktop.Hosts.ListAsync(desktop.ActiveVaultId, Token);
var seen = listing.Items.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.Secret.ShouldBe(expected);
var keys = await desktop.SshKeys.ListAsync(desktop.ActiveVaultId, Token);
var seenKey = keys.Items.ShouldHaveSingleItem();
seenKey.EntityId.ShouldBe(keyId);
seenKey.HasUnsyncedChanges.ShouldBeFalse();
// Including the private key itself, byte for byte and unreformatted, and the passphrase stored with
// it. This is the whole promise of a shared vault holding a key: a second machine can use it without
// the key ever having been readable to the thing that carried it.
seenKey.Secret.ShouldBe(expectedKey);
// And the host key trust, which is what stops this machine asking the user to check a fingerprint
// somebody has already checked. Read through the store the SSH handshake actually asks, so what is
// proved here is the answer a connection would get and not merely that a row arrived.
var knownHosts = new VaultKnownHostStore();
await knownHosts.OpenAsync(desktop, Token);
(await knownHosts.FindAsync(pin.Host, pin.Port, pin.Algorithm, Token))
.ShouldBe(pin.Fingerprint, "trust recorded on one machine has to reach the other");
// The algorithm is part of the identity, so a pin must not answer for a key the user never saw.
(await knownHosts.FindAsync(pin.Host, pin.Port, "ssh-rsa-that-was-never-offered", Token))
.ShouldBeNull();
}
/// <remarks>
/// A pin is the item type most likely to be given a plaintext column by mistake — it holds an address the
/// server may already know for a relay-enabled host, and a fingerprint that is public by nature. Together,
/// across a vault, they are the list of machines a user reaches. Asserted against the real endpoint's
/// answer, as the host and the key are.
/// </remarks>
private static async Task AssertTheServerLearnsNothingAboutTheTrustedHostAsync(
ServerConnection connection)
{
var vaultId = (await connection.Account.GetMeAsync(Token)).Vaults.Single().VaultId;
var page = await connection.Sync.SyncPullAsync(
vaultId, new SyncPullRequest(null, 100, [SyncEntityType.KnownHostKey]), Token);
page.Changes.ShouldAllBe(change => change.EntityType == SyncEntityType.KnownHostKey);
var change = page.Changes.ShouldHaveSingleItem();
change.PlaintextFields.ShouldBeNull(
"which endpoints a user has approved is not something the server is told");
change.Payload.ShouldNotBeNull();
change.Payload.WrappedDataKey.ShouldNotBeEmpty();
change.Payload.DataKeyId.ShouldNotBe(Guid.Empty);
}
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>
/// <para>
/// 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.
/// </para>
/// <para>
/// Through the store that ships, so the pin is sealed under the vault key and queued for the server rather
/// than kept in a dictionary. That also means the answer the second handshake gets has been through a
/// real encrypt and decrypt, which is the property an in-memory store cannot exercise.
/// </para>
/// </remarks>
/// <returns>The host key that was approved, so a second machine can be asked whether it knows it.</returns>
private static async Task<HostKeyPresentation> OpenAShellAsync(VaultSession laptop, HostSecret host)
{
var knownHosts = new VaultKnownHostStore();
await knownHosts.OpenAsync(laptop, Token);
var factory = new SshNetConnectionFactory(knownHosts);
// The host this slice built pins its own port, so resolving it against no groups is the identity —
// stated through the resolver anyway, because reading Port directly is the habit that makes an
// inheriting host dial 22 while the rest of the product says otherwise.
var dialled = HostInheritance.Resolve(host, new Dictionary<Guid, HostGroupSecret>()).Port.Value;
var request = new SshConnectionRequest(
host.Hostname, dialled, host.Username!, new SshPasswordCredential(DevStack.SshPassword));
HostKeyPresentation? pin = null;
try
{
await using var first = await factory.ConnectAsync(request, progress: null, Token);
Assert.Fail("An unseen host key must not be trusted silently.");
}
catch (SshHostKeyUnknownException exception)
{
pin = exception.Presentation;
pin.Fingerprint.ShouldStartWith("SHA256:");
await knownHosts.TrustAsync(pin, Token);
}
await using var connection = await factory.ConnectAsync(request, progress: null, 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");
return pin.ShouldNotBeNull();
}
// ---- Helpers ----
private static CancellationToken Token => TestContext.Current.CancellationToken;
/// <remarks>
/// The provisioner takes the URL as a string because it is also the cache's identity — the value an
/// offline unlock compares against to refuse a cache belonging to another server.
/// </remarks>
private string ServerUrl => stack.ApiBaseUrl.ToString();
private HostSecret BuildHost(Guid sshKeyId) =>
new()
{
Label = "e2e-target",
Hostname = stack.SshHostname,
Port = stack.SshHostPort,
Username = DevStack.SshUsername,
Notes = "created by the end-to-end slice",
Options = HostOptions.Create([new HostOption("ServerAliveInterval", "30")]),
SshKeyId = sshKeyId,
};
/// <remarks>
/// Armour of the right shape around material that is not a key. The shell at the end of this test
/// authenticates with a password, because what is under test here is the key's journey through the vault
/// — and a real private key committed to a repository is a real private key on the internet whatever it
/// was for. That SSH.NET can authenticate with a key delivered this way, as bytes rather than a file, is
/// established against a real <c>sshd</c> in <c>KeyAuthenticationTests</c>.
/// </remarks>
private static SshKeySecret BuildKey() =>
new()
{
Label = "e2e-deploy-key",
PrivateKeyPem =
"-----BEGIN OPENSSH PRIVATE KEY-----\nnot-a-real-key\n-----END OPENSSH PRIVATE KEY-----\n",
Passphrase = "an end to end key passphrase",
PublicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5 e2e@dodossh",
Notes = "created by the end-to-end slice",
};
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;
}
}