Public Access
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d5ff9f23a | ||
|
|
8209f15741 |
@@ -297,7 +297,7 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
|
||||
> | The host header's OS label (`Ubuntu 24.04 LTS`) and latency reading (`12 ms`) | Neither. The client does not know the remote's OS and nothing measures round-trip time — the same two absences the terminal pane header already recorded before this pass, carried into the new header rather than reopened. |
|
||||
> | A **Port forward** button on the host header | Omitted. The feature does not exist; see the FORWARDING rows earlier in this document. |
|
||||
> | The tab and status dots' third, amber state | ◆ **Still two states**, for the reason the hosts screen's own dot has stayed two states since v3: green means a shell is open (or a session is connected), grey means it is not, and nothing here pings a host to justify a third colour meaning "reachable but not connected". |
|
||||
> | The status bar's negotiated cipher, host-key algorithm and key/credential name | Omitted, and recorded rather than silently dropped. None of the three is reachable from this shell today: `SshNetConnection` holds the negotiated algorithms but nothing reads them off `ISshConnection`; `HostKeyPresentation` exists only for the trust questions a connection asks on the way in, not for a session already open; and a key's display name is never threaded past the connect call into anything the session keeps. What is real and bound is CONNECTED plus a dot, the session's own elapsed timer, and — terminal surface only — `UTF-8`, worded as a fact about this client's own renderer rather than a claim the remote agreed to. Candidates for future plumbing, not a defect closed here. |
|
||||
> | The status bar's negotiated cipher, host-key algorithm and key/credential name | ◆ **Shipped, on both surfaces, with three honest deviations.** `ISshConnection` and `ISftpSession` now both carry `Cipher` — the server-to-client algorithm off SSH.NET's own `ConnectionInfo.CurrentServerEncryption`, captured once at construction because a rekey is not an event SSH.NET raises — and `TerminalWorkspace.GetSessionFacts` hands the cipher and the host key's algorithm back to the shell the moment a session opens; `VaultViewModel.TryBuildAuthentication` now threads the authenticating key's or credential's own `Label` into `HostAuthentication.IdentityLabel`, all the way to `MainWindowViewModel`'s surface-aware `SessionCipher`, `SessionHostKeyAlgorithm` and `SessionIdentityLabel`, composed into one `SessionIdentityText` run for the status bar. Three deviations from the mock, not omissions: the algorithm prints exactly as negotiated (`ssh-ed25519`), not the design's shortened `ed25519`, because trimming it would be an edit to a string this client did not choose; the run is plain text rather than the design's clickable element, because there is no pin-details modal for a session that is already open, and drawing a click target for a screen that does not exist would itself be a fabrication; and a typed-password session — nothing filed in the keychain to name — shows the host-key algorithm alone, with no `·` after it, because there is no item behind the dot. |
|
||||
> | S3 dimmed in the design's own switcher | **Enabled.** The mock leaves S3 as future work; this application already has bucket browsing, so SSH, SFTP and S3 are a true three-way segment, wired to `IsSshShowing`, `IsTransfersShowing` and `IsBucketsShowing` exactly alike. |
|
||||
> | The S3/Buckets screen | **Did not get the session shell in v5b.** `TransfersScreen` serves both SFTP and S3 today and only the SFTP usage in `MainWindow.axaml` sat inside the new tab row/header/status bar/sidebar; the S3 usage was unchanged at the time. **v5c gives it the shell's own look without the machinery** — a 26-pixel padded, bordered, radius-12 container and nothing past that, since a bucket has no tab to close, no host to head a card with and no pin for a sidebar to show; see the v5c section, below. |
|
||||
> | No pins destination in the design at all | **Kept anyway.** The rail still carries Pins — `KnownHostsScreen` — because the mock has no screen for approved host keys and this application's has to stay reachable. |
|
||||
@@ -537,7 +537,7 @@ caption buttons and window title drawn on top of the application's own — two s
|
||||
| `SNIPPETS` panel, `↵` to run | client-domain | A snippet item type (`Snippet = 8`, reserved). | **Shipped**, as a screen rather than a panel. `↵` is per snippet and off by default: inserting types the command at the prompt and stops, because nothing here can tell whether the terminal is at a prompt at all. |
|
||||
| Broadcast to all panes (`⌥↵`) | client-ssh | Input is routed strictly by session id in `TerminalDataPlane.Dispatch`; there is no fan-out. Needs splits first. | Omitted. |
|
||||
| Pane header `24ms` | client-ssh | Round-trip measurement. SSH.NET offers no RTT API. | Omitted. |
|
||||
| Pane header `aes256-gcm` | client-ssh | **The closest miss on this list.** `SshNetConnection` holds the `SshClient`, so `ConnectionInfo.CurrentServerEncryption` is right there — it just is not on `ISshConnection` or surfaced by `TerminalWorkspace`. | Omitted; the session shell's own host header shows the account and endpoint actually dialled (v3–v4: the tab strip did; v5b moved the fact to `SessionHeader`, off `MainWindowViewModel.SessionAddress`, when the strip was retired). |
|
||||
| Pane header `aes256-gcm` | client-ssh | **No longer a miss — the plumbing this row used to lack now exists.** `ISshConnection.Cipher` and `TerminalWorkspace.GetSessionFacts` were built for the v5b session shell's status bar; see that row in the v5b section, above. | Still not on a pane header — this pre-v5b element does not exist as its own piece of chrome any more. The fact it wanted to show is drawn instead where v5b moved it: the status bar's `SessionCipher`, beside CONNECTED. The session shell's host header separately shows the account and endpoint actually dialled (v3–v4: the tab strip did; v5b moved that fact to `SessionHeader`, off `MainWindowViewModel.SessionAddress`, when the strip was retired). |
|
||||
| Pane header showing the running command and `following` | client-ssh | The host moves opaque bytes and never parses terminal output. Would need shell integration (OSC 133) on the remote. | Omitted. |
|
||||
| A `local · zsh` tab | client-ssh | Every session here is an SSH channel. Needs ConPTY and a second session kind. | Omitted. |
|
||||
| Tab strip `+` button | ui | Not missing so much as redundant: the real operation is *select a host, press Connect*, which the hosts grid already is. | **Shipped**, as the palette rather than a menu: it opens what Ctrl+K opens, so the strip and the shortcut are one way of doing one thing. A `MenuFlyout` offering "SSH" and "local shell" is the nicer answer and is not verifiably safe above the terminal's native child window — and there is no local shell to offer. |
|
||||
|
||||
@@ -8,36 +8,56 @@
|
||||
<!--
|
||||
── v5b's session shell status bar ───────────────────────────────────────────────────────────────────────
|
||||
37px, DeepChrome, a 1px top border — the foot of the bordered container both the terminal and the SFTP
|
||||
surface share. The design's own row also carries a negotiated cipher, a host-key algorithm and a
|
||||
key/credential name; none of the three is reachable from this shell today — see the wave B report this
|
||||
control's history carries, and design-notes/v5b-fidelity-notes.md's own decided deviation — so none of
|
||||
them is drawn, omitted rather than faked, per this project's honesty rule.
|
||||
surface share. The design's own row also carries a negotiated cipher and a host-key-plus-identity run
|
||||
(`ed25519 · acme-deploy-key`, clickable to a pin-details modal); both are now real and bound, plumbed all
|
||||
the way from SSH.NET through TerminalWorkspace.GetSessionFacts and TransfersViewModel to
|
||||
MainWindowViewModel's own surface-aware properties — see SessionCipher, SessionHostKeyAlgorithm,
|
||||
SessionIdentityLabel and the composed SessionIdentityText.
|
||||
|
||||
Three honest deviations from the mock. The algorithm prints exactly as negotiated — "ssh-ed25519", not
|
||||
the design's shortened "ed25519" — because trimming it would be a cosmetic edit to a string this client
|
||||
did not choose. The run is plain text, not a button: this application has no pin-details modal for a
|
||||
session that is already open, and drawing a click target for a screen that does not exist would be the
|
||||
fabrication this project's honesty rule forbids, not an honest omission of one. And a typed-password
|
||||
session — nothing filed in the keychain to name — shows the algorithm alone, with no " · " after it,
|
||||
because there is no item behind the dot.
|
||||
|
||||
What is real and bound: the CONNECTED word and dot, off IsSessionConnected, shown only while there is a
|
||||
session/host context to report on at all — SessionAddress null means nothing here has anything to say,
|
||||
the same state the header answers with its own empty-state sentence; the elapsed timer, off
|
||||
SessionElapsedText, which is null and therefore absent whenever there is nothing timed; and, only on the
|
||||
terminal surface — see <see cref="ShowsEncoding"/> — "UTF-8", which is a true fact about this client's own
|
||||
renderer and write path (see TerminalWorkspace's terminal.js and SshShellSessionExtensions.WriteTextAsync)
|
||||
rather than a negotiated session parameter, and is worded plainly rather than as a claim the remote agreed
|
||||
to it.
|
||||
the same state the header answers with its own empty-state sentence; the cipher and the host-key/identity
|
||||
run, each collapsed rather than shown empty or fabricated when the fact behind it is not there — a bucket
|
||||
connection has neither, and a session whose facts could not be read back at the instant they were asked
|
||||
for has neither either; the elapsed timer, off SessionElapsedText, which is null and therefore absent
|
||||
whenever there is nothing timed; and, only on the terminal surface — see <see cref="ShowsEncoding"/> —
|
||||
"UTF-8", which is a true fact about this client's own renderer and write path (see TerminalWorkspace's
|
||||
terminal.js and SshShellSessionExtensions.WriteTextAsync) rather than a negotiated session parameter, and
|
||||
is worded plainly rather than as a claim the remote agreed to it.
|
||||
-->
|
||||
|
||||
<Border Height="37" Background="{StaticResource DeepChrome}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0">
|
||||
<Grid ColumnDefinitions="*,Auto" Margin="24,0">
|
||||
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="7" VerticalAlignment="Center"
|
||||
IsVisible="{Binding SessionAddress, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
||||
<Ellipse Classes="dot" Width="7" Height="7" Classes.live="{Binding IsSessionConnected}"
|
||||
VerticalAlignment="Center" />
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="18" VerticalAlignment="Center">
|
||||
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center"
|
||||
IsVisible="{Binding SessionAddress, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
||||
<Ellipse Classes="dot" Width="7" Height="7" Classes.live="{Binding IsSessionConnected}"
|
||||
VerticalAlignment="Center" />
|
||||
|
||||
<TextBlock FontFamily="Montserrat" FontWeight="SemiBold" FontSize="9.5" LetterSpacing="1.2"
|
||||
VerticalAlignment="Center" Text="CONNECTED" Foreground="{StaticResource Live}"
|
||||
IsVisible="{Binding IsSessionConnected}" />
|
||||
<TextBlock FontFamily="Montserrat" FontWeight="SemiBold" FontSize="9.5" LetterSpacing="1.2"
|
||||
VerticalAlignment="Center" Text="NOT CONNECTED" Foreground="{StaticResource TextFaint}"
|
||||
IsVisible="{Binding !IsSessionConnected}" />
|
||||
<TextBlock FontFamily="Montserrat" FontWeight="SemiBold" FontSize="9.5" LetterSpacing="1.2"
|
||||
VerticalAlignment="Center" Text="CONNECTED" Foreground="{StaticResource Live}"
|
||||
IsVisible="{Binding IsSessionConnected}" />
|
||||
<TextBlock FontFamily="Montserrat" FontWeight="SemiBold" FontSize="9.5" LetterSpacing="1.2"
|
||||
VerticalAlignment="Center" Text="NOT CONNECTED" Foreground="{StaticResource TextFaint}"
|
||||
IsVisible="{Binding !IsSessionConnected}" />
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Classes="mono" FontSize="11.5" Foreground="{StaticResource TextGhost}"
|
||||
VerticalAlignment="Center" Text="{Binding SessionCipher}"
|
||||
IsVisible="{Binding SessionCipher, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||
<TextBlock Classes="mono" FontSize="11.5" Foreground="{StaticResource TextGhost}"
|
||||
VerticalAlignment="Center" Text="{Binding SessionIdentityText}"
|
||||
IsVisible="{Binding SessionIdentityText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="18" VerticalAlignment="Center">
|
||||
|
||||
@@ -4,8 +4,9 @@ using Avalonia.Controls;
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>
|
||||
/// The v5b session shell's status bar: CONNECTED and its dot, the elapsed timer, and — only where it is true
|
||||
/// — UTF-8. See the remark at the top of SessionStatusBar.axaml.
|
||||
/// The v5b session shell's status bar: CONNECTED and its dot, the negotiated cipher, the host key's algorithm
|
||||
/// and — where one authenticated — the identity that did, the elapsed timer, and — only where it is true —
|
||||
/// UTF-8. See the remark at the top of SessionStatusBar.axaml.
|
||||
/// </summary>
|
||||
internal sealed partial class SessionStatusBar : UserControl
|
||||
{
|
||||
|
||||
@@ -3400,7 +3400,13 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
// No tab was opened for this attempt, which means the user closed the connecting tab while the
|
||||
// handshake was still running. The session is real and has to be adopted rather than dropped:
|
||||
// dropping it would leave a shell running with nothing in the window naming it.
|
||||
var adopted = new TerminalTabViewModel(e.SessionId, e.Label, e.Address) { StartedAt = clock.GetUtcNow() };
|
||||
var adopted = new TerminalTabViewModel(e.SessionId, e.Label, e.Address)
|
||||
{
|
||||
StartedAt = clock.GetUtcNow(),
|
||||
Cipher = NullIfEmpty(e.Cipher),
|
||||
HostKeyAlgorithm = NullIfEmpty(e.HostKeyAlgorithm),
|
||||
IdentityLabel = e.IdentityLabel,
|
||||
};
|
||||
AdoptTab(adopted);
|
||||
RefreshConnectedHosts();
|
||||
return;
|
||||
@@ -3411,6 +3417,9 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
// From this moment, not from when the tab first appeared — connecting is not open, and the session
|
||||
// shell's elapsed timer is about a shell that is actually running.
|
||||
tab.StartedAt = clock.GetUtcNow();
|
||||
tab.Cipher = NullIfEmpty(e.Cipher);
|
||||
tab.HostKeyAlgorithm = NullIfEmpty(e.HostKeyAlgorithm);
|
||||
tab.IdentityLabel = e.IdentityLabel;
|
||||
|
||||
// The pane exists from this moment, so what the rectangle should hold has changed — the card goes and
|
||||
// the WebView comes back. Only for the tab being looked at, which is what these flags already ask.
|
||||
@@ -3914,6 +3923,69 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The negotiated server-to-client cipher the status bar shows beside CONNECTED, or null when neither
|
||||
/// surface has a session — or when the one it has has nothing to report.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// One property reading whichever surface is showing, for the same reason <see cref="SessionAddress"/>
|
||||
/// is: <c>SessionStatusBar.axaml</c> is the same markup on both surfaces. The terminal's is
|
||||
/// <see cref="TerminalTabViewModel.Cipher"/>; SFTP's is <see cref="TransfersViewModel.ConnectedCipher"/>.
|
||||
/// </remarks>
|
||||
internal string? SessionCipher => Surface switch
|
||||
{
|
||||
ShellSurface.Terminal => NullIfEmpty(SelectedTab?.Cipher),
|
||||
_ when IsTransfersShowing => NullIfEmpty(Transfers.ConnectedCipher),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// <summary>The accepted host key's algorithm, e.g. <c>ssh-ed25519</c> — printed as negotiated, not shortened.</summary>
|
||||
/// <remarks>See <see cref="SessionCipher"/>; resolved the same way, off <see cref="TerminalTabViewModel.HostKeyAlgorithm"/> or <see cref="TransfersViewModel.ConnectedHostKeyAlgorithm"/>.</remarks>
|
||||
internal string? SessionHostKeyAlgorithm => Surface switch
|
||||
{
|
||||
ShellSurface.Terminal => NullIfEmpty(SelectedTab?.HostKeyAlgorithm),
|
||||
_ when IsTransfersShowing => NullIfEmpty(Transfers.ConnectedHostKeyAlgorithm),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The display name of the key or credential that authenticated, or null when a typed password did or
|
||||
/// nothing is connected.
|
||||
/// </summary>
|
||||
/// <remarks>See <see cref="SessionCipher"/>; resolved the same way, off <see cref="TerminalTabViewModel.IdentityLabel"/> or <see cref="TransfersViewModel.ConnectedIdentityLabel"/>.</remarks>
|
||||
internal string? SessionIdentityLabel => Surface switch
|
||||
{
|
||||
ShellSurface.Terminal => SelectedTab?.IdentityLabel,
|
||||
_ when IsTransfersShowing => Transfers.ConnectedIdentityLabel,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The status bar's one run of text for the host key and the identity that authenticated — the design's
|
||||
/// <c>ed25519 · acme-deploy-key</c> — or null while <see cref="SessionHostKeyAlgorithm"/> is.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Composed here rather than in the view, so <c>SessionStatusBar.axaml</c> binds one <c>TextBlock</c> to
|
||||
/// one string instead of assembling a separator between two bindings that can each be absent on their own.
|
||||
/// The algorithm prints exactly as negotiated — <c>ssh-ed25519</c>, not the design's shortened
|
||||
/// <c>ed25519</c> — because trimming it would be a cosmetic claim about a string this shell has no
|
||||
/// business editing. A typed-password session has no item to name, so it shows the algorithm alone with
|
||||
/// no <c>·</c> — there being nothing after the dot would be a punctuation mark standing in for the fact
|
||||
/// that was never real.
|
||||
/// </remarks>
|
||||
internal string? SessionIdentityText => SessionHostKeyAlgorithm is { } algorithm
|
||||
? SessionIdentityLabel is { } label ? $"{algorithm} · {label}" : algorithm
|
||||
: null;
|
||||
|
||||
/// <summary>Null for an empty string, unchanged otherwise.</summary>
|
||||
/// <remarks>
|
||||
/// <see cref="ISshConnection.Cipher"/> and <see cref="HostKeyPresentation.Algorithm"/> are non-nullable
|
||||
/// strings that this shell nonetheless treats as absent when empty — a session whose facts genuinely
|
||||
/// could not be read back (see <c>TerminalWorkspace.GetSessionFacts</c>) hands over <see cref="string.Empty"/>
|
||||
/// rather than null, and the status bar's collapse bindings only know how to ask about null.
|
||||
/// </remarks>
|
||||
private static string? NullIfEmpty(string? value) => string.IsNullOrEmpty(value) ? null : value;
|
||||
|
||||
/// <summary>Whether the session the header and status bar are describing is actually open.</summary>
|
||||
/// <remarks>
|
||||
/// Not the same question as <see cref="SessionAddress"/> being non-null on the terminal surface: a tab
|
||||
@@ -3981,6 +4053,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
OnPropertyChanged(nameof(SessionAddress));
|
||||
OnPropertyChanged(nameof(IsSessionConnected));
|
||||
OnPropertyChanged(nameof(SessionElapsedText));
|
||||
OnPropertyChanged(nameof(SessionCipher));
|
||||
OnPropertyChanged(nameof(SessionHostKeyAlgorithm));
|
||||
OnPropertyChanged(nameof(SessionIdentityLabel));
|
||||
OnPropertyChanged(nameof(SessionIdentityText));
|
||||
OnPropertyChanged(nameof(ShowsQuickAccessSidebar));
|
||||
}
|
||||
|
||||
@@ -3996,7 +4072,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
if (e.PropertyName is nameof(TransfersViewModel.SelectedHost)
|
||||
or nameof(TransfersViewModel.IsConnected)
|
||||
or nameof(TransfersViewModel.Remote)
|
||||
or nameof(TransfersViewModel.ConnectedTo))
|
||||
or nameof(TransfersViewModel.ConnectedTo)
|
||||
or nameof(TransfersViewModel.ConnectedCipher)
|
||||
or nameof(TransfersViewModel.ConnectedHostKeyAlgorithm)
|
||||
or nameof(TransfersViewModel.ConnectedIdentityLabel))
|
||||
{
|
||||
RaiseSessionState();
|
||||
}
|
||||
|
||||
@@ -28,10 +28,11 @@ internal enum TerminalTabState
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A tab is a session id and two strings. It holds no terminal and owns nothing: the pane, its scrollback
|
||||
/// and the shell behind it all live in the renderer and in <c>TerminalWorkspace</c>, and selecting a tab is
|
||||
/// one frame telling the page which pane to show. That is what makes tabs cheap here — the expensive object
|
||||
/// is the WebView, and there is one of those however many tabs are open.
|
||||
/// A tab is a session id and a handful of strings — the address, and now the three connection facts the
|
||||
/// status bar draws beside CONNECTED. It holds no terminal and owns nothing: the pane, its scrollback and the
|
||||
/// shell behind it all live in the renderer and in <c>TerminalWorkspace</c>, and selecting a tab is one frame
|
||||
/// telling the page which pane to show. That is what makes tabs cheap here — the expensive object is the
|
||||
/// WebView, and there is one of those however many tabs are open.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A tab starts before its session does.</b> Connecting is a network round trip that can take as long as
|
||||
@@ -102,6 +103,31 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
|
||||
/// </remarks>
|
||||
internal DateTimeOffset? StartedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The negotiated server-to-client cipher, for the status bar — or null while there is no session.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Set alongside <see cref="StartedAt"/>, from the same event, and kept for the same reason: a dead tab's
|
||||
/// pane still holds the scrollback of a session that really did negotiate this cipher, and clearing the
|
||||
/// fact when the shell ends would not make it less true of what is on screen.
|
||||
/// </remarks>
|
||||
internal string? Cipher { get; set; }
|
||||
|
||||
/// <summary>The accepted host key's algorithm, e.g. <c>ssh-ed25519</c> — or null while there is no session.</summary>
|
||||
/// <remarks>See <see cref="Cipher"/>; set and kept the same way, for the same reason.</remarks>
|
||||
internal string? HostKeyAlgorithm { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The display name of the key or credential that authenticated, or null when a typed password did, or
|
||||
/// null while there is no session.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Three different reasons collapse to the same null, and that is deliberate: nothing downstream needs to
|
||||
/// tell a session with no identity to name apart from a tab that has not opened one yet — both mean the
|
||||
/// status bar shows the host-key algorithm alone, with no ` · name` after it.
|
||||
/// </remarks>
|
||||
internal string? IdentityLabel { get; set; }
|
||||
|
||||
/// <inheritdoc cref="TerminalTabState" />
|
||||
[ObservableProperty]
|
||||
private TerminalTabState state;
|
||||
|
||||
@@ -606,6 +606,37 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
[ObservableProperty]
|
||||
private string? connectedTo;
|
||||
|
||||
/// <summary>
|
||||
/// The negotiated server-to-client cipher for the open SFTP session, for the v5b status bar — or null
|
||||
/// while nothing is connected, or while what is connected is a bucket rather than a host.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A bucket is <c>IRemoteFileStore</c> with no SSH underneath it at all, so it has no cipher, no host key
|
||||
/// and no identity to name — <see cref="OpenBucketAsync"/> leaves all three null rather than each reading
|
||||
/// as "not yet known", which is the meaning null already carries for a host that has not connected yet.
|
||||
/// Read off the concrete <c>ISftpSession</c> at connect time, the same moment <see cref="ConnectedTo"/> is
|
||||
/// set, because <see cref="session"/> itself is typed as <c>IRemoteFileStore</c> and does not carry it.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private string? connectedCipher;
|
||||
|
||||
/// <summary>The accepted host key's algorithm, e.g. <c>ssh-ed25519</c>. See <see cref="ConnectedCipher"/>.</summary>
|
||||
[ObservableProperty]
|
||||
private string? connectedHostKeyAlgorithm;
|
||||
|
||||
/// <summary>
|
||||
/// The display name of the key or credential that authenticated, or null when a typed password did, or
|
||||
/// null while nothing is connected.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Threaded from <see cref="VaultViewModel.TryBuildConnectionRequest"/>'s own out parameter rather than
|
||||
/// re-resolved here: the label names a keychain item this screen has no authentication ladder of its own
|
||||
/// to climb, and a second lookup would be a second place for a stale binding to answer differently than
|
||||
/// the one that actually authenticated.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private string? connectedIdentityLabel;
|
||||
|
||||
[ObservableProperty]
|
||||
private HostKeyPresentation? pendingHostKey;
|
||||
|
||||
@@ -874,6 +905,12 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
ConnectedTo = string.Create(
|
||||
CultureInfo.InvariantCulture, $"s3://{row.Store.Bucket}");
|
||||
|
||||
// No SSH underneath a bucket, so none of the three has an honest value — see
|
||||
// ConnectedCipher's own remark.
|
||||
ConnectedCipher = null;
|
||||
ConnectedHostKeyAlgorithm = null;
|
||||
ConnectedIdentityLabel = null;
|
||||
|
||||
connected = (ConnectedTo, row.Label, row.EntityId, TimeProvider.System.GetUtcNow());
|
||||
|
||||
await ListRemoteAsync(session.HomeDirectory, cancellationToken).ConfigureAwait(true);
|
||||
@@ -891,7 +928,8 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
return;
|
||||
}
|
||||
|
||||
if (!open.TryBuildConnectionRequest(row.Host, TypedPassword, out var request, out var refusal))
|
||||
if (!open.TryBuildConnectionRequest(
|
||||
row.Host, TypedPassword, out var request, out var identityLabel, out var refusal))
|
||||
{
|
||||
Status = refusal;
|
||||
return;
|
||||
@@ -906,9 +944,11 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
{
|
||||
await CloseSessionAsync().ConfigureAwait(true);
|
||||
|
||||
ISftpSession opened;
|
||||
|
||||
try
|
||||
{
|
||||
session = await sftp.OpenSftpAsync(request, cancellationToken).ConfigureAwait(true);
|
||||
opened = await sftp.OpenSftpAsync(request, cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
catch (SshHostKeyUnknownException exception)
|
||||
{
|
||||
@@ -926,17 +966,9 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
return;
|
||||
}
|
||||
|
||||
session = opened;
|
||||
TypedPassword = string.Empty;
|
||||
IsConnected = true;
|
||||
ConnectedTo = string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"{request.Username}@{request.Host}:{request.Port}");
|
||||
|
||||
// Recorded, and not hidden because it is "only" the file browser. Opening this is a second
|
||||
// login as far as the remote's own auth.log is concerned, so a log of ours that omitted it
|
||||
// would disagree with the host's — and anybody comparing the two would be right to believe
|
||||
// the host.
|
||||
connected = (ConnectedTo, row.Label, row.EntityId, TimeProvider.System.GetUtcNow());
|
||||
MarkHostConnected(opened, request, row, identityLabel);
|
||||
|
||||
await ListRemoteAsync(session.HomeDirectory, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
@@ -944,6 +976,33 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
}).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records that a host connection just succeeded: the address, the negotiated facts, and the ticket the
|
||||
/// disconnect log closes out later.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Split out of <see cref="ConnectToHostAsync"/> for length rather than for reuse — <see cref="OpenBucketAsync"/>
|
||||
/// sets the same four properties its own way, with no SSH underneath to read the last three off. Reads
|
||||
/// the cipher and host key off <paramref name="opened"/> rather than <see cref="session"/>, which is typed
|
||||
/// as <c>IRemoteFileStore</c> and does not carry either.
|
||||
/// </remarks>
|
||||
private void MarkHostConnected(
|
||||
ISftpSession opened, SshConnectionRequest request, HostRowViewModel row, string? identityLabel)
|
||||
{
|
||||
IsConnected = true;
|
||||
ConnectedTo = string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"{request.Username}@{request.Host}:{request.Port}");
|
||||
ConnectedCipher = opened.Cipher;
|
||||
ConnectedHostKeyAlgorithm = opened.HostKey.Algorithm;
|
||||
ConnectedIdentityLabel = identityLabel;
|
||||
|
||||
// Recorded, and not hidden because it is "only" the file browser. Opening this is a second login as
|
||||
// far as the remote's own auth.log is concerned, so a log of ours that omitted it would disagree with
|
||||
// the host's — and anybody comparing the two would be right to believe the host.
|
||||
connected = (ConnectedTo, row.Label, row.EntityId, TimeProvider.System.GetUtcNow());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Closes the file-transfer session.
|
||||
/// </summary>
|
||||
@@ -1713,6 +1772,9 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
|
||||
IsConnected = false;
|
||||
ConnectedTo = null;
|
||||
ConnectedCipher = null;
|
||||
ConnectedHostKeyAlgorithm = null;
|
||||
ConnectedIdentityLabel = null;
|
||||
RemotePath = string.Empty;
|
||||
RemoteEntries.Clear();
|
||||
RemoteTrail.Clear();
|
||||
|
||||
@@ -888,11 +888,19 @@ internal static class ItemBadge
|
||||
/// <param name="sessionId">Identifies the session to the renderer and to the workspace.</param>
|
||||
/// <param name="label">The host's name, as the vault has it.</param>
|
||||
/// <param name="address">The account and endpoint actually dialled.</param>
|
||||
/// <param name="cipher">The negotiated server-to-client cipher; see <see cref="ISshConnection.Cipher"/>.</param>
|
||||
/// <param name="hostKeyAlgorithm">The accepted host key's algorithm, e.g. <c>ssh-ed25519</c>.</param>
|
||||
/// <param name="identityLabel">
|
||||
/// The display name of the key or credential that authenticated, or null when a typed password did.
|
||||
/// </param>
|
||||
internal sealed class TerminalSessionEventArgs(
|
||||
Guid attemptId,
|
||||
uint sessionId,
|
||||
string label,
|
||||
string address) : EventArgs
|
||||
string address,
|
||||
string cipher,
|
||||
string hostKeyAlgorithm,
|
||||
string? identityLabel) : EventArgs
|
||||
{
|
||||
/// <summary>Which attempt this session came out of.</summary>
|
||||
/// <inheritdoc cref="ConnectionAttemptEventArgs.AttemptId" path="/remarks" />
|
||||
@@ -903,6 +911,12 @@ internal sealed class TerminalSessionEventArgs(
|
||||
internal string Label { get; } = label;
|
||||
|
||||
internal string Address { get; } = address;
|
||||
|
||||
internal string Cipher { get; } = cipher;
|
||||
|
||||
internal string HostKeyAlgorithm { get; } = hostKeyAlgorithm;
|
||||
|
||||
internal string? IdentityLabel { get; } = identityLabel;
|
||||
}
|
||||
|
||||
/// <summary>A connection that has been asked for, and has not answered yet.</summary>
|
||||
@@ -11085,6 +11099,13 @@ internal sealed partial class VaultViewModel(
|
||||
|
||||
Status = $"Connected to {target.Label}.";
|
||||
|
||||
// The session the workspace just opened is the only place the negotiated cipher and host-key
|
||||
// algorithm live — an SshConnectionRequest asks for neither and gets no say in either — so they are
|
||||
// read back here rather than carried forward from somewhere upstream that never had them. Absent only
|
||||
// for a session that has already ended in the instant between opening it and asking, which the empty
|
||||
// string collapses to on the status bar exactly as a session with nothing to report would.
|
||||
var facts = workspace.GetSessionFacts(sessionId);
|
||||
|
||||
// Only now, and only on success. The page's own term.focus() focuses the textarea inside the
|
||||
// document, which does nothing while the window's keyboard focus is still on the Connect button — so
|
||||
// without this the first keystrokes of the session go to the shell's UI instead of the remote shell.
|
||||
@@ -11094,7 +11115,10 @@ internal sealed partial class VaultViewModel(
|
||||
attempt.AttemptId,
|
||||
sessionId,
|
||||
target.Label,
|
||||
Dialled(target, authentication)));
|
||||
Dialled(target, authentication),
|
||||
facts?.Cipher ?? string.Empty,
|
||||
facts?.HostKeyAlgorithm ?? string.Empty,
|
||||
authentication.IdentityLabel));
|
||||
|
||||
// Last, and after the tab exists: keeping the password is a favour, and the session the user asked
|
||||
// for must not wait on a vault write to appear.
|
||||
@@ -11279,13 +11303,19 @@ internal sealed partial class VaultViewModel(
|
||||
/// </summary>
|
||||
/// <param name="Username">The account to log in as, after any credential has had its say.</param>
|
||||
/// <param name="Credential">What proves it.</param>
|
||||
/// <param name="IdentityLabel">
|
||||
/// The display name of the keychain item that authenticates — a key's or a credential's own
|
||||
/// <c>Label</c> — or null when the password was typed rather than filed. Carried alongside the credential
|
||||
/// so the session shell's status bar can name what actually authenticated without a second lookup back
|
||||
/// into <see cref="Keys"/> or <see cref="Credentials"/> once the connection has moved on.
|
||||
/// </param>
|
||||
/// <remarks>
|
||||
/// The two travel together because a credential can change both. Returning only the secret and reading the
|
||||
/// username off the host separately is what the connect path used to do, and it would have sent a stored
|
||||
/// credential's password under the host's username — which is the one combination that is wrong in a way
|
||||
/// the server reports as "authentication failed".
|
||||
/// </remarks>
|
||||
private sealed record HostAuthentication(string Username, SshCredential Credential);
|
||||
private sealed record HostAuthentication(string Username, SshCredential Credential, string? IdentityLabel = null);
|
||||
|
||||
/// <summary>
|
||||
/// The machine a connection is being made to, however it was named.
|
||||
@@ -11359,6 +11389,7 @@ internal sealed partial class VaultViewModel(
|
||||
HostSecret host,
|
||||
string typedPassword,
|
||||
[NotNullWhen(true)] out SshConnectionRequest? request,
|
||||
out string? identityLabel,
|
||||
[NotNullWhen(false)] out string? reason)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(host);
|
||||
@@ -11368,6 +11399,7 @@ internal sealed partial class VaultViewModel(
|
||||
if (!TryBuildAuthentication(host, resolved, typedPassword, out var authentication, out reason))
|
||||
{
|
||||
request = null;
|
||||
identityLabel = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -11377,6 +11409,8 @@ internal sealed partial class VaultViewModel(
|
||||
authentication.Username,
|
||||
authentication.Credential);
|
||||
|
||||
identityLabel = authentication.IdentityLabel;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -11428,6 +11462,7 @@ internal sealed partial class VaultViewModel(
|
||||
return Complete(
|
||||
credential.Credential.Username ?? resolved.Username.Value,
|
||||
new SshPasswordCredential(credential.Credential.Password),
|
||||
credential.Label,
|
||||
out authentication,
|
||||
out reason);
|
||||
}
|
||||
@@ -11446,13 +11481,18 @@ internal sealed partial class VaultViewModel(
|
||||
resolved.Username.Value,
|
||||
new SshPrivateKeyCredential(
|
||||
Encoding.UTF8.GetBytes(key.Key.PrivateKeyPem), key.Key.Passphrase),
|
||||
key.Label,
|
||||
out authentication,
|
||||
out reason);
|
||||
}
|
||||
|
||||
// No item to name: a typed password has nothing filed in the keychain, and IdentityLabel stays null —
|
||||
// the default Complete's own signature gives it — rather than the placeholder text a display-only
|
||||
// fallback would tempt someone to invent.
|
||||
return Complete(
|
||||
resolved.Username.Value,
|
||||
new SshPasswordCredential(typedPassword),
|
||||
identityLabel: null,
|
||||
out authentication,
|
||||
out reason);
|
||||
}
|
||||
@@ -11476,6 +11516,7 @@ internal sealed partial class VaultViewModel(
|
||||
private static bool Complete(
|
||||
string? username,
|
||||
SshCredential credential,
|
||||
string? identityLabel,
|
||||
out HostAuthentication? authentication,
|
||||
[NotNullWhen(false)] out string? reason)
|
||||
{
|
||||
@@ -11487,7 +11528,7 @@ internal sealed partial class VaultViewModel(
|
||||
out reason);
|
||||
}
|
||||
|
||||
authentication = new HostAuthentication(username, credential);
|
||||
authentication = new HostAuthentication(username, credential, identityLabel);
|
||||
reason = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -265,6 +265,17 @@ public interface ISftpSession : IRemoteFileStore
|
||||
{
|
||||
/// <summary>The host key that was accepted for this session.</summary>
|
||||
HostKeyPresentation HostKey { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The negotiated server-to-client encryption algorithm, e.g. <c>aes256-gcm@openssh.com</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The same fact <see cref="ISshConnection.Cipher"/> is, read the same way — off SSH.NET's
|
||||
/// <c>ConnectionInfo.CurrentServerEncryption</c> once the handshake this session's own connect performed
|
||||
/// has finished — and for the same reason: <c>SftpClient</c> derives from <c>BaseClient</c> exactly as
|
||||
/// <c>SshClient</c> does, and rekeys are no more visible here than they are there. See that member's remark.
|
||||
/// </remarks>
|
||||
string Cipher { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -98,6 +98,25 @@ public interface ISshConnection : IAsyncDisposable
|
||||
/// <summary>The host key that was accepted for this connection.</summary>
|
||||
HostKeyPresentation HostKey { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The negotiated server-to-client encryption algorithm, e.g. <c>aes256-gcm@openssh.com</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Read once, immediately after the handshake, off SSH.NET's own <c>ConnectionInfo.CurrentServerEncryption</c>.
|
||||
/// The only event that could make this stale is a rekey, and SSH.NET raises no event for one and exposes no
|
||||
/// way to ask again — there is nothing behind this property to go and re-read. A captured value is therefore
|
||||
/// not a snapshot that might drift; it is the only value there has ever been a moment to observe.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Server-to-client, not client-to-server.</b> SSH negotiates the two directions independently and a
|
||||
/// server is free to choose differently for each, so the two can in principle disagree. This is the
|
||||
/// direction the bytes drawn on a terminal pane travelled in, which is the fact a status bar showing what
|
||||
/// the screen is made of should be naming.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
string Cipher { get; }
|
||||
|
||||
/// <summary>Opens an interactive shell with a pseudo-terminal.</summary>
|
||||
Task<ISshShellSession> OpenShellAsync(TerminalSize size, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -274,6 +274,16 @@ internal sealed class SshNetConnection(SshClient client, HostKeyPresentation hos
|
||||
/// <inheritdoc />
|
||||
public HostKeyPresentation HostKey { get; } = hostKey;
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Read at construction rather than lazily: by the time an <see cref="SshNetConnection"/> exists,
|
||||
/// <see cref="SshNetConnectionFactory.ConnectAsync"/> has already awaited <c>client.ConnectAsync</c>, so
|
||||
/// <c>ConnectionInfo</c> is already populated and there is no earlier moment reading it would race. SSH.NET
|
||||
/// types the property as a non-nullable <c>string</c>, so this reads straight through rather than coalescing
|
||||
/// a null that the library's own contract says cannot occur.
|
||||
/// </remarks>
|
||||
public string Cipher { get; } = client.ConnectionInfo.CurrentServerEncryption;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ISshShellSession> OpenShellAsync(TerminalSize size, CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
@@ -20,6 +20,14 @@ internal sealed class SshNetSftpSession(SftpClient client, HostKeyPresentation h
|
||||
/// <inheritdoc />
|
||||
public HostKeyPresentation HostKey { get; } = hostKey;
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Read at construction, the same way and for the same reason as <c>SshNetConnection.Cipher</c>: this type
|
||||
/// is only ever built after <c>SshNetConnectionFactory.OpenSftpAsync</c> has awaited <c>client.ConnectAsync</c>,
|
||||
/// so <c>ConnectionInfo</c> is already populated by the time there is a session to read it from.
|
||||
/// </remarks>
|
||||
public string Cipher { get; } = client.ConnectionInfo.CurrentServerEncryption;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string HomeDirectory { get; } = homeDirectory;
|
||||
|
||||
|
||||
@@ -37,6 +37,18 @@ public sealed class TerminalSessionEndedEventArgs(uint sessionId) : EventArgs
|
||||
public uint SessionId { get; } = sessionId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The negotiated cipher and host-key algorithm for one live session.
|
||||
/// </summary>
|
||||
/// <param name="Cipher">The server-to-client encryption algorithm; see <see cref="ISshConnection.Cipher"/>.</param>
|
||||
/// <param name="HostKeyAlgorithm">The host key's algorithm, e.g. <c>ssh-ed25519</c>.</param>
|
||||
/// <remarks>
|
||||
/// Two facts rather than the whole <see cref="ISshConnection"/>, because that is all a caller outside this
|
||||
/// assembly has any business reading off a session it does not own — everything else on the connection
|
||||
/// (disposal, the shell) belongs to the workspace alone. See <see cref="TerminalWorkspace.GetSessionFacts"/>.
|
||||
/// </remarks>
|
||||
public sealed record SessionFacts(string Cipher, string HostKeyAlgorithm);
|
||||
|
||||
/// <summary>The renderer asking for a different font size.</summary>
|
||||
/// <param name="step">
|
||||
/// How far to move, in points of font size, or zero to go back to the default. It is a step rather than a
|
||||
@@ -180,6 +192,37 @@ public sealed class TerminalWorkspace : IAsyncDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The negotiated cipher and host-key algorithm for one live session, or null when the id names no
|
||||
/// session this workspace still has open.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Additive, and deliberately narrow: the caller this exists for — the shell's own connect path, which
|
||||
/// wants these two facts for its status bar — has no other business with a session it does not own, and a
|
||||
/// method that handed back the <see cref="ISshConnection"/> itself would have handed over the shell,
|
||||
/// disposal and all, to code that already goes through <see cref="CloseSessionAsync"/> for that.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// "No session this workspace still has open" covers two different absences the same way
|
||||
/// <see cref="IsSessionLive"/> already does: an id this workspace never issued, and one whose shell has
|
||||
/// already ended but whose entry has not been removed yet. Both are "nothing to report" to a caller
|
||||
/// asking what a session's transport looks like right now.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public SessionFacts? GetSessionFacts(uint sessionId)
|
||||
{
|
||||
lock (sessionGate)
|
||||
{
|
||||
if (!sessions.TryGetValue(sessionId, out var session) || session.Run.IsCompleted)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new SessionFacts(session.Connection.Cipher, session.Connection.HostKey.Algorithm);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raised with the session id when a shell ends on its own.
|
||||
/// </summary>
|
||||
|
||||
@@ -83,6 +83,9 @@ internal sealed class FakeSftpSession(SshConnectionRequest request) : ISftpSessi
|
||||
public HostKeyPresentation HostKey { get; } =
|
||||
new(request.Host, request.Port, "ssh-ed25519", "SHA256:fake");
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Cipher { get; } = "aes256-gcm@openssh.com";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string HomeDirectory => $"/home/{request.Username}";
|
||||
|
||||
@@ -139,6 +142,9 @@ internal sealed class FakeSshConnection(SshConnectionRequest request) : ISshConn
|
||||
public HostKeyPresentation HostKey { get; } =
|
||||
new(request.Host, request.Port, "ssh-ed25519", "SHA256:fake");
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Cipher { get; } = "aes256-gcm@openssh.com";
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ISshShellSession> OpenShellAsync(
|
||||
TerminalSize size,
|
||||
|
||||
@@ -6221,7 +6221,9 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
/// <remarks>
|
||||
/// The status bar's facts, read off the selected terminal tab. <see cref="MainWindowViewModel.SessionElapsedText"/>
|
||||
/// is real, not fabricated: <c>StartedAt</c> is set from the shell's own clock at the moment the session
|
||||
/// opens, and this reads it back through the same clock.
|
||||
/// opens, and this reads it back through the same clock. The cipher and host-key algorithm are the
|
||||
/// fake's own, and the identity text is the bound key's label — <c>ConnectedHostWithAPinAsync</c> binds
|
||||
/// "deploy" — composed with the real (unshortened) algorithm string.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task SessionFacts_ReflectTheSelectedTerminalTab()
|
||||
@@ -6231,6 +6233,9 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
shell.IsSessionConnected.ShouldBeTrue();
|
||||
shell.SessionAddress.ShouldBe(shell.Tabs[0].Address);
|
||||
shell.SessionElapsedText.ShouldNotBeNull().ShouldStartWith("session ");
|
||||
shell.SessionCipher.ShouldBe("aes256-gcm@openssh.com");
|
||||
shell.SessionHostKeyAlgorithm.ShouldBe("ssh-ed25519");
|
||||
shell.SessionIdentityText.ShouldBe("ssh-ed25519 · deploy");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
@@ -6245,6 +6250,55 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
shell.IsSessionConnected.ShouldBeFalse();
|
||||
shell.SessionAddress.ShouldBeNull();
|
||||
shell.SessionElapsedText.ShouldBeNull();
|
||||
shell.SessionCipher.ShouldBeNull();
|
||||
shell.SessionHostKeyAlgorithm.ShouldBeNull();
|
||||
shell.SessionIdentityText.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A typed password has nothing filed in the keychain to name, so the status bar's identity run is the
|
||||
/// host-key algorithm alone — no " · " and nothing after it, which is what
|
||||
/// <see cref="MainWindowViewModel.SessionIdentityText"/>'s own remark promises rather than a placeholder
|
||||
/// standing in for the item that was never there.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task SessionFacts_ATypedPasswordSession_ShowsTheHostKeyAlgorithmAloneWithNoIdentity()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
|
||||
vault.ManualTarget = "deploy@build.internal:2222";
|
||||
vault.ManualPassword = "hunter2";
|
||||
|
||||
await vault.ConnectManuallyCommand.ExecuteAsync(null);
|
||||
|
||||
shell.IsSessionConnected.ShouldBeTrue();
|
||||
shell.SessionHostKeyAlgorithm.ShouldBe("ssh-ed25519");
|
||||
shell.SessionIdentityText.ShouldBe("ssh-ed25519", "a typed password names no keychain item");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The same facts on the other surface, and one asymmetry worth pinning: the SFTP session is its own
|
||||
/// login through the same resolution ladder, so the status bar names that session's cipher, host key and
|
||||
/// identity — and stops naming them on disconnect, where a terminal tab keeps its facts for the
|
||||
/// scrollback still on screen (see <c>TerminalTabViewModel.Cipher</c>'s remark).
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task SessionFacts_FollowTheSftpSurfacesOwnSession()
|
||||
{
|
||||
await ConnectedHostWithAPinAsync();
|
||||
|
||||
await shell.OpenPinnedPathCommand.ExecuteAsync("/var/www/app");
|
||||
|
||||
shell.IsTransfersShowing.ShouldBeTrue();
|
||||
shell.SessionCipher.ShouldBe("aes256-gcm@openssh.com");
|
||||
shell.SessionIdentityText.ShouldBe("ssh-ed25519 · deploy");
|
||||
|
||||
await shell.Transfers.DisconnectCommand.ExecuteAsync(null);
|
||||
|
||||
shell.SessionCipher.ShouldBeNull("the SFTP surface has no scrollback for a dead session's facts to describe");
|
||||
shell.SessionIdentityText.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -36,6 +36,12 @@ public sealed class KeyAuthenticationTests(SshServerFixture fixture)
|
||||
|
||||
connection.IsConnected.ShouldBeTrue();
|
||||
|
||||
// The one place this suite checks Cipher against a real handshake rather than a fake's fixed string.
|
||||
// SSH.NET negotiates whatever the container's sshd offers first from its own preference list, so the
|
||||
// exact algorithm is not pinned here — only that ConnectionInfo.CurrentServerEncryption came back as
|
||||
// something rather than the empty string a stalled or pre-handshake read would produce.
|
||||
connection.Cipher.ShouldNotBeNullOrEmpty();
|
||||
|
||||
// Authenticated is not the same as usable: a channel has to open on the connection too.
|
||||
await using var shell = await connection.OpenShellAsync(TerminalSize.Default, Token);
|
||||
|
||||
|
||||
@@ -136,6 +136,9 @@ internal sealed class FakeConnection(SshConnectionRequest request, long bytesPer
|
||||
public HostKeyPresentation HostKey { get; } =
|
||||
new(request.Host, request.Port, "ssh-ed25519", "SHA256:fake");
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Cipher { get; } = "aes256-gcm@openssh.com";
|
||||
|
||||
/// <summary>The shell this connection opened, if it opened one.</summary>
|
||||
internal FakeShellSession? Shell { get; private set; }
|
||||
|
||||
|
||||
@@ -149,6 +149,31 @@ public sealed class TerminalWorkspaceTests
|
||||
workspace.IsSessionLive(second).ShouldBeTrue("closing one tab must not disturb another");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The shell's connect path reads these back once, right after <see cref="TerminalWorkspace.OpenSessionAsync"/>
|
||||
/// returns, to fill in the status bar's cipher and host-key facts — see <c>VaultViewModel.ConnectAndAnnounceAsync</c>.
|
||||
/// Asserted the same way <see cref="LivenessIsAnsweredPerSession"/> asserts liveness: per session, and
|
||||
/// null rather than thrown for an id this workspace never issued.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task SessionFactsAreReadPerSession()
|
||||
{
|
||||
var connections = new FakeConnectionFactory();
|
||||
|
||||
await using var workspace = CreateWorkspace(connections);
|
||||
|
||||
var sessionId = await workspace.OpenSessionAsync(
|
||||
Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
|
||||
|
||||
var facts = workspace.GetSessionFacts(sessionId).ShouldNotBeNull();
|
||||
var connection = connections.Connections.ShouldHaveSingleItem();
|
||||
|
||||
facts.Cipher.ShouldBe(connection.Cipher);
|
||||
facts.HostKeyAlgorithm.ShouldBe(connection.HostKey.Algorithm);
|
||||
|
||||
workspace.GetSessionFacts(9999).ShouldBeNull("this workspace never issued that id");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Inserting a snippet has to be able to say whether it arrived, and the transport cannot: it drops
|
||||
|
||||
@@ -23,6 +23,9 @@ internal sealed class FakeSftpSession : ISftpSession
|
||||
/// <inheritdoc />
|
||||
public HostKeyPresentation HostKey { get; } = new("host.internal", 22, "ssh-ed25519", "SHA256:fake");
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Cipher { get; } = "aes256-gcm@openssh.com";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string HomeDirectory => "/home/dodo";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user