Compare commits

...
7 Commits
Author SHA1 Message Date
jaap-jan a4b81da597 Desktop: show the right vault's people, leave settings on lock, say when an import fell short, and stop host names being cut to four letters
ci / build and test (push) Successful in 3m26s
ci / android head (push) Successful in 3m50s
ci / desktop nightly (push) Failing after 37s
ci / api image (push) Successful in 17s
- Choosing a vault while the screen was busy went through a gate that did nothing, so the members
  panel kept showing the previous vault's people under the new name. The read now starts regardless;
  the selection generation discards answers for an earlier choice.
- Locking or signing out from Settings left the window in settings mode under the lock card, with no
  title bar to drag, minimise or close, and unlock returned to Settings. Leaving the unlocked state
  leaves Settings and closes the members panel; Escape closes that panel before leaving Settings.
- Both importers reported success when the keychain was busy and skipped the batch, or failed partway.
  ImportOutcome now carries refused hosts and why a batch stopped, and the screens say so.
- Host cards: the sync badge took 70 of 214 pixels from the name ("bastion" drew as "bast…", two k8s
  workers both as "k8s…"); it now sits on the address line. Tag chips wrap and trim inside the card.
  Keychain names trim inside their column instead of printing over TYPE, and the Security page's
  caption wraps inside its card.
2026-10-02 13:16:55 +02:00
jaap-jan 0e8fe4fc80 Give each profile its own device key, and make the Linux PIN window able to open at all
- The Windows CNG key and the macOS keychain tag were one name for every profile, so signing out of
  the nightly deleted the key the release build's device unlock was sealed to. DeviceKeyNames keeps the
  existing name for the default profile and gives every other profile its own; sign-out deletes only
  its own key, and a profile sealed before this keeps reading the shared one.
- DevicePinWindow named two palette resources that do not exist and loaded itself with
  AvaloniaXamlLoader.Load, which never assigns the named fields, so it threw on construction and no
  PIN prompt was ever shown. A layout test now builds it against the real palette.
2026-10-02 13:16:48 +02:00
jaap-jan 2ff9dff560 Terminal: deliver large pastes whole, keep keystrokes in order, and keep one session's failure off the shared socket
- A message larger than the 64 KiB receive buffer was skipped and its tail dispatched as a frame of its
  own. Messages are now reassembled up to 16 MiB; one beyond that is dropped whole.
- Input frames are handed to the pump without waiting, and ShellStream takes no lock around its write
  buffer, so a snippet's text and its carriage return could interleave or lose bytes. Writes are
  serialised per pump in arrival order.
- A resize reaching a shell SSH.NET had already disposed threw out of the receive loop and closed the
  socket every tab shares. Each frame's failure now stays with its session.
2026-10-02 13:16:48 +02:00
jaap-jan 6846f67c20 Server: send each item at the revision its ciphertext is sealed at, keep team archiving with the owner, refuse suspended accounts, and stop enrolment failing after an hour
- Pull hydrated every historical change entry with the item's current ciphertext under that entry's
  old revision. The AAD binds the revision, so a client opened it at the wrong version, failed, and
  parked its own pending edit as undecryptable. Entries an item has since moved past are left out; the
  later entry carries the same payload under the right number, and a fresh device no longer downloads
  an item once per edit it ever had.
- Deleting a team's last vault archived the team whoever deleted it, so an admin could archive it out
  from under its owner. Only the owner's delete archives it now, and TeamService.ArchiveAsync checks.
- UserStatus was checked nowhere on the request path. ActiveAccountRequirement on both policies answers
  403 account-not-active for a suspended or deprovisioned account.
- The hourly last-seen update moves xmin under the tracked user, and enrolment then saved that user
  against the stale token: a 500 for anybody enrolling more than an hour after first signing in. The
  entity is reloaded after the update.
2026-10-02 13:16:48 +02:00
jaap-jan f332d9a56f Stop a finished push deleting the edit made while it was in flight, and pull a newly synced type from the start
- The outbox coalesces a new save into the row a push already read, under a new operation id, but
  Complete, Revise and Park found the row by sequence alone. The acknowledgement of the earlier state
  deleted the row holding the later one and the edit was gone without a word. They now take the
  PendingOperation and act only while the row still holds it; a later edit is rebased onto the version
  just accepted, so it pushes cleanly instead of conflicting with its own earlier save.
- A pull filtered by type moves the cursor past changes of types it did not ask for, so a build that
  added a type (SpelunkKey, ObjectStore) never received the items of that type made before it.
  sync_state.pulled_types records the list a cursor was read under, and a different list starts the
  pull over once. Migration AddSyncStatePulledTypes.
2026-10-02 13:16:47 +02:00
jaap-jan 03c8e8589a Keep downloads inside the folder they were sent to, never run a file opened for editing, and let a bucket upload be retried
- A remote name went straight into Path.Combine. A POSIX name may hold backslashes, a colon or a drive
  letter, and an S3 key may hold "../", so a name chosen by whoever could write to the host decided
  where on this machine a download landed. LocalDirectory.ChildPath cleans the name and requires the
  resolved parent to be the target folder; every download path and EDIT use it, and a name that cannot
  be a file there is left on the host and reported.
- EDIT handed the copy to the shell's default action, which for .bat, .hta, .js and friends is to run
  it. LocalEditor opens such a file with its own "edit" verb or Notepad on Windows and `open -t` on
  macOS, and refuses a .desktop launcher on Linux.
- A failed bucket upload leaves its part object, and a retry asked for an offset no bucket accepts, so
  the row could never finish. IRemoteFileStore.CanWriteFromAnOffset is false for buckets and the queue
  starts those over.
- The SFTP host picker re-found its selection from the empty moment of every vault reload and landed
  on the first host once a minute; the chosen host is now remembered, and a typed password is only
  sent to the host it was typed for.
2026-10-02 13:16:30 +02:00
jaap-jan 4c7f47c026 Refuse a pinned host that answers with a key of another type, rather than asking whether to trust it
Pins are stored per algorithm, so an endpoint pinned for ssh-ed25519 that was answered with only an
ECDSA or RSA key found no pin and raised SshHostKeyUnknownException: the first-use question, where a
pin exists to produce a hard refusal. Anybody in the middle needed nothing more than a key of a
different type.

The connection factory now reads every pin for the endpoint before the handshake, offers the pinned
algorithms first so a server that still holds its pinned key negotiates it, and in the gate accepts a
key under an unpinned algorithm only when its fingerprint matches one of the endpoint's pins (one RSA
key signs as rsa-sha2-256 and rsa-sha2-512). Anything else is a mismatch. IKnownHostStore gains
FindForEndpointAsync for this.
2026-10-02 13:16:30 +02:00
64 changed files with 2545 additions and 229 deletions

No files matched your search

@@ -0,0 +1,49 @@
using DodoSSH.Domain;
using Microsoft.AspNetCore.Authorization;
namespace DodoSSH.Api.Authorization;
/// <summary>Requires the caller's account to be <see cref="UserStatus.Active"/>.</summary>
/// <remarks>
/// <para>
/// <see cref="UserStatus.Suspended"/> is documented as "sign-in blocked", and nothing blocked it: the identity
/// provider still issues tokens for the person, every endpoint resolved them through the current-user context,
/// and that context looked an account up by issuer, subject and deletion alone. A suspended account could pull
/// and push vaults, take relay tickets and hold the events socket open. This is the check, on both policies, so
/// that no endpoint can be reached without it.
/// </para>
/// <para>
/// A connection already open when the account is suspended — a relay tunnel, the events socket — is not
/// reached by this; it ends on its own idle and lifetime limits.
/// </para>
/// </remarks>
internal sealed class ActiveAccountRequirement : IAuthorizationRequirement;
/// <summary>Satisfies <see cref="ActiveAccountRequirement"/> from the account row.</summary>
internal sealed class ActiveAccountHandler(ICurrentUserContext currentUser, IHttpContextAccessor accessor)
: AuthorizationHandler<ActiveAccountRequirement>
{
/// <inheritdoc />
protected override async Task HandleRequirementAsync(
AuthorizationHandlerContext context,
ActiveAccountRequirement requirement)
{
ArgumentNullException.ThrowIfNull(context);
// The policy's authentication requirement already refuses an anonymous caller with a 401; failing this
// as well would turn that into a 403.
if (context.User.Identity?.IsAuthenticated != true)
{
return;
}
var cancellationToken = accessor.HttpContext?.RequestAborted ?? CancellationToken.None;
var user = await currentUser.GetOrProvisionAsync(cancellationToken).ConfigureAwait(false);
if (user.Status == UserStatus.Active)
{
context.Succeed(requirement);
}
}
}
@@ -102,8 +102,14 @@ internal sealed class CurrentUserContext(
/// read-then-write: <c>user_account</c> carries the xmin concurrency token, so two overlapping
/// requests from one user would each read the row, each set the timestamp, and the second would
/// fail on a version that had moved under it. This writes at most one row and cannot conflict.
/// The tracked entity is deliberately left alone — a value up to an hour stale in memory changes
/// nothing, and marking it modified would enlist the user row in whatever the request saves next.
/// <para>
/// ◆ <b>But the write moves xmin under the tracked entity, so the entity is re-read after it.</b> It is
/// not marked modified — that would enlist the user row in whatever the request saves next — and a
/// stale timestamp in memory would change nothing on its own. A stale concurrency token does: the next
/// save in the same request that touches this user (enrolment stamps <c>EnrolledAtUtc</c>) compared
/// against the version from before the update and failed, so enrolling more than an hour after first
/// signing in answered 500. Only when a row was actually written, which is once an hour at most.
/// </para>
/// </remarks>
private async Task RefreshLastSeenAsync(UserAccount user, CancellationToken cancellationToken)
{
@@ -114,13 +120,18 @@ internal sealed class CurrentUserContext(
return;
}
await database.Users
var written = await database.Users
.Where(u => u.Id == user.Id
&& (u.LastSeenAtUtc == null || u.LastSeenAtUtc < now - LastSeenWindow))
.ExecuteUpdateAsync(
setters => setters.SetProperty(u => u.LastSeenAtUtc, now),
cancellationToken)
.ConfigureAwait(false);
if (written > 0)
{
await database.Entry(user).ReloadAsync(cancellationToken).ConfigureAwait(false);
}
}
private Task<UserAccount?> FindAsync(string issuer, string subject, CancellationToken cancellationToken) =>
@@ -80,6 +80,26 @@ internal sealed class DodoAuthorizationResultHandler : IAuthorizationMiddlewareR
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(authorizeResult);
var accountNotActive = authorizeResult.Forbidden
&& authorizeResult.AuthorizationFailure is { } refusal
&& refusal.FailedRequirements.OfType<ActiveAccountRequirement>().Any();
if (accountNotActive)
{
await TypedResults.Problem(
detail: "This account has been suspended. Ask whoever runs this DodoSSH server.",
statusCode: StatusCodes.Status403Forbidden,
type: ProblemCodes.TypeBaseUri + ProblemCodes.AccountNotActive,
extensions: new Dictionary<string, object?>(StringComparer.Ordinal)
{
["code"] = ProblemCodes.AccountNotActive,
})
.ExecuteAsync(context)
.ConfigureAwait(false);
return;
}
var enrollmentMissing = authorizeResult.Forbidden
&& authorizeResult.AuthorizationFailure is { } failure
&& failure.FailedRequirements.OfType<EnrolledRequirement>().Any();
+19 -4
View File
@@ -536,9 +536,21 @@ internal sealed class SyncService(
/// <summary>Attaches current row state to change-log entries.</summary>
/// <remarks>
/// <para>
/// A page of changes can mix item types, so rows are loaded per type — one query each, not one per
/// change — and then matched back in the log's order. Ordering is the log's, never the load's: a pull
/// that returned changes grouped by type would hand the client a sequence its cursor cannot resume from.
/// </para>
/// <para>
/// ◆ <b>An entry an item has since moved past is left out.</b> The log keeps every revision, but the
/// table keeps only the latest ciphertext — so hydrating an old entry paired that entry's revision number
/// with the newest payload, and the payload's AAD binds the revision it was sealed at. A client opened it
/// at the old number, failed, and parked its own unsent edit to that item as undecryptable. The later
/// entry is in this page or a later one, since the item's <c>ChangeSequence</c> is that entry's sequence;
/// it carries the same payload under the right number, so nothing is lost by sending it once. Leaving
/// the old entries out also stops a fresh device downloading an item once per edit it ever had. The
/// cursor is computed from the unfiltered page by the caller, so it still moves past them.
/// </para>
/// </remarks>
private async Task<List<Contracts.SyncChange>> HydrateAsync(
Vault vault,
@@ -571,10 +583,13 @@ internal sealed class SyncService(
}
}
return [.. changes.Select(change => Describe(
change,
kinds.GetValueOrDefault(change.EntityType),
rows.GetValueOrDefault((change.EntityType, change.EntityId))))];
return [.. changes
.Where(change => rows.GetValueOrDefault((change.EntityType, change.EntityId)) is not { } row
|| row.ChangeSequence <= change.Sequence)
.Select(change => Describe(
change,
kinds.GetValueOrDefault(change.EntityType),
rows.GetValueOrDefault((change.EntityType, change.EntityId))))];
}
/// <summary>Turns one change-log entry and its row into what a client receives.</summary>
@@ -179,8 +179,8 @@ internal sealed class ArchiveTeamEndpoint(ICurrentUserContext currentUser, TeamS
}
// Owner, not admin. An admin the owner promoted must not be able to archive the team out
// from under them — that is the boundary IsOwner exists to draw. Nothing behind this
// re-checks it, unlike the transfer below, so this line is the whole of the guard.
// from under them — that is the boundary IsOwner exists to draw. TeamService.ArchiveAsync
// checks it again, because deleting a team's last vault archives it too.
if (!access.IsOwner)
{
return Problems.Coded(
@@ -244,8 +244,16 @@ internal sealed class TeamService(
Team team,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(actor);
ArgumentNullException.ThrowIfNull(team);
// Both callers check this first and answer the caller properly; this is what stops a third one that
// forgets from letting an admin archive the team out from under its owner.
if (!(await ResolveAsync(actor.Id, team.Id, cancellationToken).ConfigureAwait(false)).IsOwner)
{
throw new InvalidOperationException("Only the owner of a team can archive it.");
}
var vaultCount = await CountVaultsAsync(team.Id, cancellationToken).ConfigureAwait(false);
if (vaultCount > 0)
@@ -191,7 +191,12 @@ internal sealed class DeleteVaultEndpoint(
{
var orphaned = await grants.DeleteVaultAsync(user, access.Vault!, ct).ConfigureAwait(false);
if (orphaned is not null)
// Archived only by its owner. Archiving revokes every membership, the owner's included, and an
// admin the owner promoted must not be able to do that by deleting vaults one at a time when the
// archive endpoint refuses them outright. An admin's delete stands; the empty team is left for
// the owner, who can archive it or make a vault in it.
if (orphaned is not null
&& (await teams.ResolveAsync(user.Id, orphaned.Id, ct).ConfigureAwait(false)).IsOwner)
{
await teams.ArchiveAsync(user, orphaned, ct).ConfigureAwait(false);
}
+1
View File
@@ -55,6 +55,7 @@ builder.Services.AddDodoRelay();
// Scoped rather than the AddAuthorization default of singleton: the handler reads the request's
// DbContext, and a singleton would capture one for the lifetime of the process.
builder.Services.AddScoped<IAuthorizationHandler, EnrolledHandler>();
builder.Services.AddScoped<IAuthorizationHandler, ActiveAccountHandler>();
// Replaces the framework default, which is also registered as a singleton. Last registration wins.
builder.Services.AddSingleton<IAuthorizationMiddlewareResultHandler, DodoAuthorizationResultHandler>();
+9 -2
View File
@@ -62,7 +62,14 @@ internal static class Auth
private static void AddDodoPolicies(AuthorizationOptions options)
{
options.AddPolicy(AuthenticatedPolicy, policy => policy.RequireAuthenticatedUser());
// Both policies also require the account to be active. A suspended or deprovisioned account still
// holds a token the identity provider will vouch for until it expires — the status is this server's
// own decision, so it is this server that has to refuse. See ActiveAccountHandler.
options.AddPolicy(
AuthenticatedPolicy,
policy => policy
.RequireAuthenticatedUser()
.AddRequirements(new Authorization.ActiveAccountRequirement()));
// Enrollment state lives in the database, so this is satisfied by EnrolledHandler.
// An unmet EnrolledRequirement is rewritten into a ProblemDetails carrying
@@ -72,7 +79,7 @@ internal static class Auth
EnrolledPolicy,
policy => policy
.RequireAuthenticatedUser()
.AddRequirements(new Authorization.EnrolledRequirement()));
.AddRequirements(new Authorization.ActiveAccountRequirement(), new Authorization.EnrolledRequirement()));
// Deny by default: an endpoint without an explicit policy still requires a caller.
options.FallbackPolicy = options.GetPolicy(AuthenticatedPolicy);
+1 -31
View File
@@ -352,39 +352,9 @@ internal sealed partial class DodoSshApp : Application
copyToClipboard: ClipboardWriter(desktop),
readClipboard: ClipboardReader(desktop),
updates: updates,
openInEditor: OpenInDefaultEditorAsync,
openInEditor: LocalEditor.OpenAsync,
watchFile: WatchFile);
/// <summary>
/// Hands a local file to whatever this machine opens that kind of file with.
/// </summary>
/// <remarks>
/// <para>
/// One of the two halves of editing a remote file in place, and both are this platform's rather than
/// the shell's: what opens a <c>.conf</c> is a registry association, a desktop entry or a Launch
/// Services binding depending on which desktop this is, and watching a file is a platform object with
/// its own thread. The shell decides <em>when</em>; see <c>TransfersViewModel.EditRemoteAsync</c>.
/// </para>
/// <para>
/// <c>UseShellExecute</c> is the whole of it, and it is what makes the same three lines work on all
/// three desktops: Windows resolves the association, macOS goes through <c>open</c> and Linux through
/// <c>xdg-open</c>, each of which .NET reaches for when the flag is set. Without it the path is treated
/// as an executable to run, which for a <c>.conf</c> fails and for something else would be worse.
/// </para>
/// <para>
/// The process is not waited on and is deliberately not kept: an editor may hand off to an already-open
/// instance and exit at once, so its lifetime says nothing about whether the file is still being
/// edited. What ends an edit session is the user saying so — see the file browser's STOP EDITING.
/// </para>
/// </remarks>
private static Task OpenInDefaultEditorAsync(string path)
{
using var opened = System.Diagnostics.Process.Start(
new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true });
return Task.CompletedTask;
}
/// <summary>
/// Watches one file for saves, until the handle is disposed.
/// </summary>
@@ -0,0 +1,48 @@
using System.Security.Cryptography;
using System.Text;
using DodoSSH.Client.Session;
namespace DodoSSH.Client.App.Platform;
/// <summary>
/// What a platform key store calls the hardware key behind one profile's device unlock.
/// </summary>
/// <remarks>
/// <para>
/// ◆ <b>One key per profile, because the key outlives the file and is shared by name.</b> The CNG key and
/// the Secure Enclave keychain item are found by name in a store that belongs to the user, not to the
/// profile directory. While every profile used the same name, the release build and the nightly — which
/// <see cref="ClientPaths.ForChannel"/> keeps apart precisely so neither can damage the other — shared one
/// key: signing out of either deleted it, and the other's device unlock silently stopped working.
/// </para>
/// <para>
/// <b>The default profile keeps the name it has always had</b>, so an existing install's sealed key still
/// opens. Every other profile gets that name with a digest of its directory on the end — a digest rather
/// than the directory's own name, because a portable copy can live in any folder and two such folders can
/// end in the same name.
/// </para>
/// </remarks>
internal static class DeviceKeyNames
{
/// <summary>The name of the key behind <paramref name="paths"/>' device unlock.</summary>
/// <param name="paths">The profile.</param>
/// <param name="legacy">The name every profile used before names were per profile.</param>
internal static string For(ClientPaths paths, string legacy)
{
ArgumentNullException.ThrowIfNull(paths);
var directory = Normalise(paths.DataDirectory);
if (string.Equals(directory, Normalise(ClientPaths.Default.DataDirectory), StringComparison.OrdinalIgnoreCase))
{
return legacy;
}
var digest = SHA256.HashData(Encoding.UTF8.GetBytes(directory.ToUpperInvariant()));
return $"{legacy}.{Convert.ToHexStringLower(digest.AsSpan(0, 8))}";
}
private static string Normalise(string directory) =>
Path.TrimEndingDirectorySeparator(Path.GetFullPath(directory));
}
@@ -0,0 +1,126 @@
using System.Diagnostics;
namespace DodoSSH.Client.App.Platform;
/// <summary>
/// Opens a local copy of a remote file for editing, without running it.
/// </summary>
/// <remarks>
/// <para>
/// One of the two halves of editing a remote file in place, and both are this platform's rather than the
/// shell's: what opens a <c>.conf</c> is a registry association, a desktop entry or a Launch Services binding
/// depending on which desktop this is. The shell decides <em>when</em>; see
/// <c>TransfersViewModel.EditRemoteAsync</c>.
/// </para>
/// <para>
/// ◆ <b>"Open" is not "edit", and for a file somebody else named the difference is code execution.</b> The
/// file comes from a host, and whoever can write to that host chooses its name. Handing <c>deploy.bat</c>,
/// <c>notes.hta</c> or <c>run.command</c> to the desktop's default action runs it, on this machine, as this
/// user — so a file whose default action is to run is opened with an editor instead. On Windows that is the
/// type's own <c>edit</c> verb where it has one (Notepad, for <c>.bat</c>, <c>.cmd</c>, <c>.js</c>,
/// <c>.vbs</c>, <c>.ps1</c> and <c>.reg</c>) and Notepad where it has not; on macOS it is <c>open -t</c>,
/// which is the default text editor whatever the type. Everything else still goes to its association, which
/// is the point of the feature — an <c>nginx.conf</c> should open in whatever this person edits configs with.
/// </para>
/// <para>
/// Linux is the exception: there is no portable "the text editor", so a <c>.desktop</c> entry — the one type
/// <c>xdg-open</c> may launch rather than open — is refused with a sentence instead. A shell script is opened
/// by every mainstream desktop's file associations in an editor, not run.
/// </para>
/// <para>
/// The process is not waited on and is deliberately not kept: an editor may hand off to an already-open
/// instance and exit at once, so its lifetime says nothing about whether the file is still being edited.
/// What ends an edit session is the user saying so — see the file browser's STOP EDITING.
/// </para>
/// </remarks>
internal static class LocalEditor
{
/// <summary>
/// Extensions Windows runs rather than opens, beyond whatever <c>PATHEXT</c> lists on this machine.
/// </summary>
/// <remarks>
/// <c>PATHEXT</c> covers the console's notion of executable (<c>.exe</c>, <c>.bat</c>, <c>.js</c>,
/// <c>.vbs</c>…) and is read as well, because an interpreter's installer may add its own. These are the
/// ones Explorer acts on that are not in it: shortcuts, installers, HTML applications, control panel
/// items, and the script hosts that installers do not register there.
/// </remarks>
private static readonly string[] WindowsRunsThese =
[
".application", ".appref-ms", ".bat", ".chm", ".cmd", ".com", ".cpl", ".diagcab", ".exe", ".hta",
".inf", ".jar", ".js", ".jse", ".library-ms", ".lnk", ".msc", ".msi", ".msp", ".pif", ".ps1",
".psm1", ".py", ".pyw", ".reg", ".scf", ".scr", ".search-ms", ".settingcontent-ms", ".url",
".vbe", ".vbs", ".website", ".ws", ".wsc", ".wsf", ".wsh", ".xll",
];
/// <summary>Extensions Launch Services runs, or installs, rather than opens.</summary>
private static readonly string[] MacRunsThese =
[
".app", ".command", ".dmg", ".fileloc", ".inetloc", ".jar", ".mpkg", ".pkg", ".scpt", ".terminal",
".tool", ".webloc", ".workflow",
];
/// <summary>Opens <paramref name="path"/> for editing.</summary>
/// <exception cref="InvalidOperationException">
/// This desktop would run the file and has no editor to offer in its place.
/// </exception>
internal static Task OpenAsync(string path)
{
ArgumentNullException.ThrowIfNull(path);
using var opened = Process.Start(StartInfoFor(path));
return Task.CompletedTask;
}
/// <summary>What to start so that <paramref name="path"/> opens in an editor.</summary>
internal static ProcessStartInfo StartInfoFor(string path)
{
if (OperatingSystem.IsWindows() && WouldRunOnWindows(path))
{
var edit = new ProcessStartInfo(path) { UseShellExecute = true, Verb = "edit" };
if (edit.Verbs.Contains("edit", StringComparer.OrdinalIgnoreCase))
{
return edit;
}
return new ProcessStartInfo("notepad.exe") { UseShellExecute = false, ArgumentList = { path } };
}
if (OperatingSystem.IsMacOS() && HasExtension(path, MacRunsThese))
{
return new ProcessStartInfo("open") { UseShellExecute = false, ArgumentList = { "-t", path } };
}
if (OperatingSystem.IsLinux() && HasExtension(path, [".desktop"]))
{
throw new InvalidOperationException(
$"{Path.GetFileName(path)} is a launcher, which this desktop would run rather than open. "
+ "Download it and open it in an editor instead.");
}
return new ProcessStartInfo(path) { UseShellExecute = true };
}
/// <summary>Whether Windows' default action for this file is to run it.</summary>
internal static bool WouldRunOnWindows(string path, string? pathExt = null)
{
var listed = (pathExt ?? Environment.GetEnvironmentVariable("PATHEXT") ?? string.Empty)
.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
return HasExtension(path, WindowsRunsThese) || HasExtension(path, listed);
}
/// <summary>Whether the file's last extension is one of these, ignoring case.</summary>
/// <remarks>
/// Trailing dots and spaces are dropped first, because Windows drops them too: <c>run.bat.</c> is
/// <c>run.bat</c> by the time anything acts on it.
/// </remarks>
private static bool HasExtension(string path, IEnumerable<string> extensions)
{
var extension = Path.GetExtension(Path.GetFileName(path).TrimEnd('.', ' '));
return extension.Length > 0
&& extensions.Contains(extension, StringComparer.OrdinalIgnoreCase);
}
}
@@ -78,11 +78,21 @@ public sealed partial class MacDeviceKeyStore : IDeviceKeyStore
private readonly ClientPaths paths;
/// <summary>This profile's own enclave key; see <see cref="DeviceKeyNames"/>.</summary>
/// <remarks>
/// <see cref="KeyTag"/> for the default profile and a tag of its own for every other, so that signing
/// out of one installation cannot delete the key another's Touch ID unlock is sealed to. A profile
/// registered before this is still sealed to the shared tag and is read from there until it registers
/// again; it is never deleted from a profile that does not own it.
/// </remarks>
private readonly string keyTag;
/// <summary>Creates the store.</summary>
public MacDeviceKeyStore(ClientPaths paths)
{
ArgumentNullException.ThrowIfNull(paths);
this.paths = paths;
keyTag = DeviceKeyNames.For(paths, KeyTag);
}
/// <summary>
@@ -163,7 +173,7 @@ public sealed partial class MacDeviceKeyStore : IDeviceKeyStore
ReadOnlyMemory<byte> devicePrivateKey,
CancellationToken cancellationToken)
{
var sealedKey = Seal(devicePrivateKey.Span)
var sealedKey = Seal(devicePrivateKey.Span, keyTag)
?? throw new InvalidOperationException(
"The Secure Enclave would not seal the device key. Check IsAvailableAsync before offering to register one.");
@@ -184,7 +194,8 @@ public sealed partial class MacDeviceKeyStore : IDeviceKeyStore
var sealedKey = await File.ReadAllBytesAsync(paths.DeviceKeyFile, cancellationToken)
.ConfigureAwait(false);
return Unseal(sealedKey);
// The shared tag only while this profile has no key of its own, so one unlock is one prompt.
return Unseal(sealedKey, !string.Equals(keyTag, KeyTag, StringComparison.Ordinal) && !HasKey(keyTag) ? KeyTag : keyTag);
}
/// <inheritdoc />
@@ -202,7 +213,7 @@ public sealed partial class MacDeviceKeyStore : IDeviceKeyStore
// Discarded, and that is deliberate: there is nothing a caller could do about a failure here,
// and the file deleted above is the half that decides whether unlock will try at all. A key
// left in the enclave with no ciphertext to open is inert.
_ = DeleteKey(symbols, KeyTag);
_ = DeleteKey(symbols, keyTag);
}
return ValueTask.CompletedTask;
@@ -212,7 +223,26 @@ public sealed partial class MacDeviceKeyStore : IDeviceKeyStore
/// Silent: it uses only the public half. Null on every failure, and the caller's answer to all of
/// them is the same — do not offer a device unlock.
/// </remarks>
private static byte[]? Seal(ReadOnlySpan<byte> devicePrivateKey)
/// <summary>Whether a key is filed under this tag, asked without using it and so without a prompt.</summary>
private static bool HasKey(string tag)
{
try
{
using var scope = new CoreFoundationScope();
var symbols = MacSymbols.Resolve();
return symbols.Complete && FindKey(scope, symbols, tag, prompt: null) != IntPtr.Zero;
}
catch (Exception exception) when (exception is DllNotFoundException
or EntryPointNotFoundException
or BadImageFormatException)
{
return false;
}
}
private static byte[]? Seal(ReadOnlySpan<byte> devicePrivateKey, string tag)
{
try
{
@@ -227,11 +257,11 @@ public sealed partial class MacDeviceKeyStore : IDeviceKeyStore
// Created on first use rather than at registration, so that a device key re-registered after
// a ForgetAsync gets a key again without anything having to notice that it had gone.
var privateKey = FindKey(scope, symbols, KeyTag, prompt: null);
var privateKey = FindKey(scope, symbols, tag, prompt: null);
if (privateKey == IntPtr.Zero)
{
privateKey = CreateEnclaveKey(scope, symbols, KeyTag);
privateKey = CreateEnclaveKey(scope, symbols, tag);
}
if (privateKey == IntPtr.Zero)
@@ -281,7 +311,7 @@ public sealed partial class MacDeviceKeyStore : IDeviceKeyStore
/// on a thread that is also expected to draw the window behind it.
/// </para>
/// </remarks>
private static byte[]? Unseal(byte[] sealedKey)
private static byte[]? Unseal(byte[] sealedKey, string tag)
{
try
{
@@ -294,7 +324,7 @@ public sealed partial class MacDeviceKeyStore : IDeviceKeyStore
return null;
}
var privateKey = FindKey(scope, symbols, KeyTag, ConsentPrompt);
var privateKey = FindKey(scope, symbols, tag, ConsentPrompt);
if (privateKey == IntPtr.Zero)
{
@@ -123,6 +123,10 @@ public sealed class WindowsDeviceKeyStore : IDeviceKeyStore
/// rather than failing to open blobs written by a previous build. A device that cannot be opened
/// falls back to the passphrase, which is survivable — but silently, and a user would only notice
/// their gesture had stopped working.
/// <para>
/// This is the default profile's key, and the name every profile shared before keys were per profile;
/// see <see cref="DeviceKeyNames"/> and <see cref="keyName"/>.
/// </para>
/// </remarks>
private const string KeyName = "DodoSSH.DeviceKey.v1";
@@ -133,11 +137,22 @@ public sealed class WindowsDeviceKeyStore : IDeviceKeyStore
private readonly ClientPaths paths;
/// <summary>This profile's own key.</summary>
/// <remarks>
/// <see cref="KeyName"/> for the default profile and a name of its own for every other — so signing out
/// of the nightly no longer deletes the key the release build's device unlock is sealed to. A profile
/// registered before this still has its blob sealed to the shared key, and <see cref="TryLoadAsync"/>
/// keeps opening it there until the next registration re-seals it here. It is never deleted from a
/// profile that does not own it, which is the half of this that matters.
/// </remarks>
private readonly string keyName;
/// <summary>Creates the store.</summary>
public WindowsDeviceKeyStore(ClientPaths paths)
{
ArgumentNullException.ThrowIfNull(paths);
this.paths = paths;
keyName = DeviceKeyNames.For(paths, KeyName);
}
/// <summary>
@@ -182,7 +197,7 @@ public sealed class WindowsDeviceKeyStore : IDeviceKeyStore
ReadOnlyMemory<byte> devicePrivateKey,
CancellationToken cancellationToken)
{
using var key = OpenOrCreate();
using var key = OpenOrCreate(keyName);
using var rsa = new RSACng(key);
var sealedKey = rsa.Encrypt(devicePrivateKey.Span, RSAEncryptionPadding.OaepSHA256);
@@ -204,7 +219,13 @@ public sealed class WindowsDeviceKeyStore : IDeviceKeyStore
var sealedKey = await File.ReadAllBytesAsync(paths.DeviceKeyFile, cancellationToken)
.ConfigureAwait(false);
return Unseal(sealedKey);
// The shared key only while this profile has none of its own: a profile registered before keys were
// per profile is still sealed to it. Asking both would show two consent dialogs for one unlock.
var sealedTo = !string.Equals(keyName, KeyName, StringComparison.Ordinal) && !CngKey.Exists(keyName, new CngProvider(PlatformProvider))
? KeyName
: keyName;
return Unseal(sealedKey, sealedTo);
}
/// <inheritdoc />
@@ -215,9 +236,11 @@ public sealed class WindowsDeviceKeyStore : IDeviceKeyStore
File.Delete(paths.DeviceKeyFile);
}
if (CngKey.Exists(KeyName, new CngProvider(PlatformProvider)))
// This profile's key and never the shared one, unless this is the profile that owns it. Another
// profile's device unlock may still be sealed to it.
if (CngKey.Exists(keyName, new CngProvider(PlatformProvider)))
{
using var key = CngKey.Open(KeyName, new CngProvider(PlatformProvider));
using var key = CngKey.Open(keyName, new CngProvider(PlatformProvider));
key.Delete();
}
@@ -231,16 +254,16 @@ public sealed class WindowsDeviceKeyStore : IDeviceKeyStore
/// user and all have the same remedy, so none of them are worth telling apart here — see
/// <c>UnlockStatus.DeviceKeyUnavailable</c>.
/// </remarks>
private static byte[]? Unseal(byte[] sealedKey)
private static byte[]? Unseal(byte[] sealedKey, string name)
{
try
{
if (!CngKey.Exists(KeyName, new CngProvider(PlatformProvider)))
if (!CngKey.Exists(name, new CngProvider(PlatformProvider)))
{
return null;
}
using var key = CngKey.Open(KeyName, new CngProvider(PlatformProvider));
using var key = CngKey.Open(name, new CngProvider(PlatformProvider));
using var rsa = new RSACng(key);
return rsa.Decrypt(sealedKey, RSAEncryptionPadding.OaepSHA256);
@@ -256,18 +279,18 @@ public sealed class WindowsDeviceKeyStore : IDeviceKeyStore
/// key rather than ever reconfiguring one: a key created without the policy would decrypt silently, and
/// silently is the one behaviour this whole file exists to prevent.
/// </remarks>
private static CngKey OpenOrCreate()
private static CngKey OpenOrCreate(string name)
{
var provider = new CngProvider(PlatformProvider);
if (CngKey.Exists(KeyName, provider))
if (CngKey.Exists(name, provider))
{
return CngKey.Open(KeyName, provider);
return CngKey.Open(name, provider);
}
return CngKey.Create(
CngAlgorithm.Rsa,
KeyName,
name,
new CngKeyCreationParameters
{
Provider = provider,
@@ -4,7 +4,7 @@
Width="420" SizeToContent="Height"
CanResize="False" ShowInTaskbar="False"
WindowStartupLocation="CenterOwner"
Background="{StaticResource Surface}"
Background="{StaticResource Panel}"
Title="DodoSSH">
<!--
@@ -32,7 +32,7 @@
<TextBlock Classes="heading" FontSize="15" x:Name="Heading" TextWrapping="Wrap" />
<TextBlock Foreground="{StaticResource MutedText}" FontSize="12" TextWrapping="Wrap"
<TextBlock Foreground="{StaticResource TextDim}" FontSize="12" TextWrapping="Wrap"
x:Name="Explanation" />
<!--
@@ -39,7 +39,10 @@ public sealed partial class DevicePinWindow : Window
/// <summary>Creates the window. Avalonia's designer needs this; nothing else should call it.</summary>
public DevicePinWindow()
{
AvaloniaXamlLoader.Load(this);
// InitializeComponent, not AvaloniaXamlLoader.Load: only the generated method assigns the x:Name
// fields below, and with Load the first of them was null — so this window threw on construction and
// no Linux PIN prompt was ever shown.
InitializeComponent();
Accept.Click += OnAccept;
Cancel.Click += OnCancel;
+28 -12
View File
@@ -683,7 +683,7 @@
TextTrimming never fires and the name would run on past the card's own
border instead of trimming. See the longer note this carried in v4.
-->
<Grid ColumnDefinitions="*,Auto,Auto">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Text="{Binding Label}" FontSize="17"
FontWeight="Bold" Foreground="{StaticResource Text}"
TextTrimming="CharacterEllipsis" />
@@ -708,17 +708,27 @@
<Ellipse Classes="dot" Classes.live="{Binding IsConnected}"
Width="9" Height="9" />
</StackPanel>
</Grid>
<Border Grid.Column="2" Classes="chip warn" Padding="4,0" Margin="6,0,0,0"
<!--
"not synced" and its kin sit on the address line. Beside the name, where it
used to be, it took seventy of the card's pixels from the one thing a card
is read for: "bastion" drew as "bast…", and k8s-worker-a and k8s-worker-b
both drew as "k8s…". The address can give the room up — it trims, and the
tooltip and the drawer both carry it whole.
-->
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Classes="mono" Text="{Binding Address}" FontSize="12.5"
Foreground="{StaticResource TextGhost}"
TextTrimming="CharacterEllipsis" />
<Border Grid.Column="1" Classes="chip warn" Padding="4,0" Margin="6,0,0,0"
VerticalAlignment="Center"
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<TextBlock Text="{Binding Badge}" FontSize="9.5" />
</Border>
</Grid>
<TextBlock Classes="mono" Text="{Binding Address}" FontSize="12.5"
Foreground="{StaticResource TextGhost}"
TextTrimming="CharacterEllipsis" />
<!--
Which vault this host is in, and only when there is more than one to be
in. It decides who else can see the host and where an edit goes back to,
@@ -734,8 +744,13 @@
<Grid Grid.Row="2" ColumnDefinitions="*,Auto" VerticalAlignment="Bottom"
Margin="0,8,0,0">
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="4"
VerticalAlignment="Bottom">
<!--
A DockPanel rather than a horizontal StackPanel. A horizontal StackPanel
measures its children with infinite width, so the tags' WrapPanel below
never wrapped and ran past the card's edge; the last child of a DockPanel
gets the width that is actually left, and wraps in it.
-->
<DockPanel Grid.Column="0" VerticalAlignment="Bottom">
<!--
The pin badge: how many folders are pinned on this host, reading
@@ -743,8 +758,8 @@
property's own remarks. Only when there is at least one; a badge reading
zero would be a fact nobody asked for.
-->
<Border Classes="chip" Height="19" CornerRadius="5" Padding="6,2"
IsVisible="{Binding HasPins}">
<Border DockPanel.Dock="Left" Classes="chip" Height="19" CornerRadius="5"
Padding="6,2" Margin="0,0,4,4" IsVisible="{Binding HasPins}">
<StackPanel Orientation="Horizontal" Spacing="3">
<TextBlock Text="&#xF10D;" FontFamily="{StaticResource IconFont}"
FontSize="11" Foreground="{StaticResource TextFaint}" />
@@ -767,12 +782,13 @@
<Border Classes="chip" Height="21" CornerRadius="5" Padding="6,2"
Margin="0,0,4,4">
<TextBlock Text="{Binding}" FontSize="11"
Foreground="{StaticResource TextFaint}" />
Foreground="{StaticResource TextFaint}"
TextTrimming="CharacterEllipsis" />
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</DockPanel>
<!--
HostRowViewModel.LastConnectedText is filled from the connection log on
@@ -275,7 +275,7 @@
item this application stores carries a timestamp; see the file-level remark above and
docs/design-import-gaps.md.
-->
<Grid Grid.Row="1" ColumnDefinitions="2,2.2*,1*,1*,1.4*" Margin="0,10,16,6"
<Grid Grid.Row="1" ColumnDefinitions="2,3*,1.1*,0.9*,1.2*" Margin="0,10,16,6"
IsVisible="{Binding HasVaultItems}">
<TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="9.5" LetterSpacing="1"
Margin="12,0,8,0" />
@@ -290,17 +290,23 @@
Margin="8,0,8,10">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:VaultItemRowViewModel">
<Grid ColumnDefinitions="2,2.2*,1*,1*,1.4*" Height="40" Margin="6,0,10,0">
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="10" Margin="10,0,8,0"
VerticalAlignment="Center">
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="15"
<Grid ColumnDefinitions="2,3*,1.1*,0.9*,1.2*" Height="40" Margin="6,0,10,0">
<!--
A Grid rather than a horizontal StackPanel: the StackPanel measured the name with infinite
width, so TextTrimming never fired and a long name printed over the TYPE and USED BY
columns beside it.
-->
<Grid Grid.Column="1" ColumnDefinitions="Auto,*" Margin="10,0,8,0" VerticalAlignment="Center">
<TextBlock Grid.Column="0" FontFamily="{StaticResource IconFont}" FontSize="15"
Text="{Binding IconGlyph}" Foreground="{StaticResource AccentText}"
VerticalAlignment="Center" />
<TextBlock Classes="mono" Text="{Binding Name}" FontSize="12.5" FontWeight="Medium"
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
</StackPanel>
VerticalAlignment="Center" Margin="0,0,10,0" />
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Name}" FontSize="12.5"
FontWeight="Medium" Foreground="{StaticResource Text}"
TextTrimming="CharacterEllipsis" ToolTip.Tip="{Binding Name}" />
</Grid>
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Type}" FontSize="10.5"
Foreground="{StaticResource TextGhost}" VerticalAlignment="Center" />
Foreground="{StaticResource TextGhost}" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" />
<Border Grid.Column="3" Classes="chip" HorizontalAlignment="Left"
VerticalAlignment="Center" IsVisible="{Binding HasVaultBadge}">
<TextBlock Text="{Binding VaultBadge}" FontSize="10" />
@@ -244,6 +244,13 @@ internal sealed partial class MainWindow : Window
viewModel.CloseImportCommand.Execute(null);
e.Handled = true;
}
// The members dialog on Settings → Vaults, by the same rule: it is drawn over the page, so it goes
// first and settings stays.
else if (e.Key == Key.Escape && viewModel.IsSettingsMode && viewModel.Vaults.IsMembersPanelOpen)
{
viewModel.Vaults.CloseMembersPanelCommand.Execute(null);
e.Handled = true;
}
else if (e.Key == Key.Escape && viewModel.IsSettingsMode)
{
viewModel.LeaveSettingsCommand.Execute(null);
@@ -46,19 +46,21 @@
<TextBlock Classes="settingstitle" Text="Security" />
<Border Classes="settingscard" Margin="0,26,0,0" Padding="20,20">
<StackPanel Orientation="Horizontal" Spacing="16">
<Border Width="44" Height="44" CornerRadius="12" Background="#103A2F">
<!-- A Grid, so the caption is measured against the card's width and wraps inside it. -->
<Grid ColumnDefinitions="Auto,*">
<Border Grid.Column="0" Width="44" Height="44" CornerRadius="12" Background="#103A2F"
Margin="0,0,16,0" VerticalAlignment="Top">
<TextBlock FontFamily="{StaticResource IconFont}" Text="&#xE897;" FontSize="20"
Foreground="{StaticResource Live}"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
<StackPanel Spacing="6" VerticalAlignment="Center">
<StackPanel Grid.Column="1" Spacing="6" VerticalAlignment="Center">
<TextBlock Text="End-to-end encrypted" FontSize="15" FontWeight="Bold" LetterSpacing="-0.2"
Foreground="{StaticResource Text}" />
<TextBlock Classes="settingsrowcaption"
<TextBlock Classes="settingsrowcaption" TextWrapping="Wrap"
Text="Every vault item is sealed on your devices. The server and the relay both store ciphertext and timestamps only — neither can read a single field." />
</StackPanel>
</StackPanel>
</Grid>
</Border>
<TextBlock Classes="settingssection" Text="UNLOCKING" />
@@ -283,6 +283,9 @@ internal sealed class S3FileStore : IRemoteFileStore
/// have cost.
/// </para>
/// </remarks>
/// <inheritdoc />
public bool CanWriteFromAnOffset => false;
public Task<Stream> OpenWriteAsync(string path, long offset, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(path);
@@ -145,6 +145,26 @@ public sealed class VaultKnownHostStore : IKnownHostStore
}
}
/// <inheritdoc />
/// <remarks>From the snapshot, like <see cref="FindAsync"/>, and for the same reason.</remarks>
public ValueTask<IReadOnlyList<HostKeyPresentation>> FindForEndpointAsync(
string host,
int port,
CancellationToken cancellationToken)
{
lock (gate)
{
IReadOnlyList<HostKeyPresentation> found = pins.Values
.Where(pin =>
KnownHostIdentity.Comparer.Equals(pin.Secret.Host, host) && pin.Secret.Port == port)
.Select(pin => new HostKeyPresentation(
pin.Secret.Host, pin.Secret.Port, pin.Secret.Algorithm, pin.Secret.Fingerprint))
.ToArray();
return ValueTask.FromResult(found);
}
}
/// <inheritdoc />
/// <exception cref="InvalidOperationException">
/// The vault is not open, so there is nowhere to record trust.
@@ -566,7 +566,7 @@ internal sealed partial class HetznerImportViewModel(
// and visible rather than harmless and confusing.
Refresh(resetSelection: true);
Status = WhatWasStored(outcome.Hosts);
Status = WhatWasStored(outcome.Hosts) + outcome.Caveats;
Step = HetznerImportStep.Done;
}
@@ -22,10 +22,27 @@ namespace DodoSSH.Client.Shell.ViewModels;
/// </param>
internal sealed record ImportedHostRequest(HostSecret Host, SshKeySecret? Key, string? KeyPath);
/// <summary>What an import stored.</summary>
/// <summary>What an import stored, and what it did not.</summary>
/// <param name="Hosts">Hosts written.</param>
/// <param name="Keys">Private keys written. Counts files read, not hosts bound to one.</param>
internal sealed record ImportOutcome(int Hosts, int Keys);
/// <param name="Refused">Hosts skipped because they would not validate — no hostname, a bad port.</param>
/// <param name="Failure">Why the batch stopped before the end, or null if it did not.</param>
/// <remarks>
/// The last two are what the importing screen needs to not claim a success it did not have. The vault's own
/// status line says these things too, but it is hidden in settings mode, which is where both importers live.
/// </remarks>
internal sealed record ImportOutcome(int Hosts, int Keys, int Refused = 0, string? Failure = null)
{
/// <summary>What to add after the screen's own sentence about what was stored.</summary>
internal string Caveats =>
(Refused switch
{
0 => string.Empty,
1 => " 1 was skipped because it is not a host this keychain can connect to.",
_ => $" {Refused} were skipped because they are not hosts this keychain can connect to.",
})
+ (Failure is { } failure ? $" The import stopped early: {failure}" : string.Empty);
}
/// <summary>One host an <c>ssh_config</c> offered, as a row somebody decides about.</summary>
/// <remarks>
@@ -409,7 +426,7 @@ internal sealed partial class ImportViewModel(
_ => $" {outcome.Keys} private keys came with them, and every host that named one is bound to it.",
};
return $"Imported {outcome.Hosts} host(s). They are on the Hosts screen.{material}";
return $"Imported {outcome.Hosts} host(s). They are on the Hosts screen.{material}{outcome.Caveats}";
}
/// <summary>
@@ -1795,6 +1795,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
ActiveSettingsPage = null;
IsImportOpen = false;
IsHetznerImportOpen = false;
// Drawn over Settings → Vaults. Left open, it was still up the next time Settings opened, over
// whichever page that was.
vaults.IsMembersPanelOpen = false;
Screen = settingsReturnScreen;
Surface = settingsReturnSurface;
}
@@ -5107,6 +5111,14 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
Screen = ShellScreen.Hosts;
Surface = ShellSurface.Page;
}
else
{
// Settings is a mode of the unlocked window, and it brings its own title bar while the main one
// is hidden. Locking or signing out from it — LOCK NOW is on Preferences, sign-out on Account —
// left the window in settings mode under the lock card: no title bar, so nothing to drag, minimise
// or close, and an unlock that came back to Settings rather than to Hosts.
LeaveSettings();
}
}
/// <remarks>
@@ -879,6 +879,23 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
[ObservableProperty]
private ObjectStoreRowViewModel? selectedBucket;
/// <summary>The host and bucket somebody chose, as opposed to whatever the pickers show this instant.</summary>
/// <remarks>
/// <see cref="RefreshHosts"/> re-finds the selection by id, and it runs on every change to the vault's
/// lists — including the <c>Clear()</c> a reload starts with, at which point there is nothing to re-find
/// and the picker's own selection becomes null. Re-finding from that null on the next <c>Add</c> moved the
/// picker to the first host on every sync pass, a minute after somebody chose another one, and CONNECT then
/// dialled the first host with the password typed for theirs. So the choice is remembered here, written
/// only when a person changes the picker, and is what every refresh looks for.
/// </remarks>
private Guid? chosenHost;
/// <inheritdoc cref="chosenHost" path="/remarks" />
private Guid? chosenBucket;
/// <summary>Whether a selection change is <see cref="RefreshHosts"/> putting things back, not a choice.</summary>
private bool refreshingPickers;
/// <summary>
/// Whether there is a bucket to open at all.
/// </summary>
@@ -969,6 +986,17 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
[ObservableProperty]
private string typedPassword = string.Empty;
/// <summary>The host showing when <see cref="TypedPassword"/> was typed, which is the only one it is for.</summary>
/// <remarks>
/// The picker can move without anybody touching it — a host deleted elsewhere falls back to the first —
/// and the box does not empty when it does, because it cannot tell that from a reload refilling the list.
/// So the password is checked against this at the moment it would be sent instead, and never goes to a
/// host it was not typed for.
/// </remarks>
private Guid? typedPasswordFor;
partial void OnTypedPasswordChanged(string value) => typedPasswordFor = SelectedHost?.EntityId;
/// <remarks>
/// It opens saying what the state is rather than what to do about it, and that is a v3 change the
/// desktop forced. "Choose a host and connect to browse its files" was this line for two versions, and
@@ -1780,8 +1808,10 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
return;
}
var password = typedPasswordFor == row.EntityId ? TypedPassword : string.Empty;
if (!open.TryBuildConnectionRequest(
row.Host, TypedPassword, out var request, out var identityLabel, out var refusal))
row.Host, password, out var request, out var identityLabel, out var refusal))
{
Status = refusal;
return;
@@ -2677,9 +2707,15 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
foreach (var row in rows)
{
if (LocalDirectory.ChildPath(LocalPath, row.Name) is not { } local)
{
plan.Unsafe++;
continue;
}
if (row.IsFile)
{
plan.Downloads.Add((Path.Combine(LocalPath, row.Name), row.Entry));
plan.Downloads.Add((local, row.Entry));
continue;
}
@@ -2695,11 +2731,19 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
await WalkRemoteAsync(
row.FullPath,
Path.Combine(LocalPath, row.Name),
local,
plan,
cancellationToken).ConfigureAwait(true);
}
EnqueueDownloads(plan);
Status = Describe(plan, "download into", LocalPath);
}
/// <summary>Makes the directories a download plan needs here, then hands its files to the queue.</summary>
private void EnqueueDownloads(TransferPlan plan)
{
foreach (var directory in plan.LocalDirectories.OrderBy(each => each.Length))
{
try
@@ -2719,8 +2763,6 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
plan.Queued++;
}
Status = Describe(plan, "download into", LocalPath);
}
/// <summary>Adds every file under a remote directory to the plan, with where each will land here.</summary>
@@ -2774,30 +2816,48 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
return;
}
switch (entry.Kind)
{
case SftpEntryKind.File:
plan.Downloads.Add((Path.Combine(toDirectory, entry.Name), entry));
break;
case SftpEntryKind.Directory:
pending.Enqueue((entry.FullPath, Path.Combine(toDirectory, entry.Name)));
break;
case SftpEntryKind.SymbolicLink:
plan.Links++;
break;
default:
// A socket, a device, a pipe. There is nothing to copy and no local equivalent to
// create, and counting it as a refusal would overstate what went wrong.
plan.Skipped++;
break;
}
PlanRemoteEntry(plan, pending, entry, toDirectory);
}
}
}
/// <summary>Adds one entry of a remote listing to a download plan.</summary>
private static void PlanRemoteEntry(
TransferPlan plan,
Queue<(string Remote, string Local)> pending,
SftpEntry entry,
string toDirectory)
{
var here = entry.Kind is SftpEntryKind.File or SftpEntryKind.Directory
? LocalDirectory.ChildPath(toDirectory, entry.Name)
: null;
switch (entry.Kind)
{
case SftpEntryKind.File or SftpEntryKind.Directory when here is null:
plan.Unsafe++;
break;
case SftpEntryKind.File:
plan.Downloads.Add((here!, entry));
break;
case SftpEntryKind.Directory:
pending.Enqueue((entry.FullPath, here!));
break;
case SftpEntryKind.SymbolicLink:
plan.Links++;
break;
default:
// A socket, a device, a pipe. There is nothing to copy and no local equivalent to create, and
// counting it as a refusal would overstate what went wrong.
plan.Skipped++;
break;
}
}
/// <summary>What an upload of this local file should carry to the host with it.</summary>
/// <remarks>
/// <para>
@@ -2893,6 +2953,10 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
/// <summary>Sockets, devices and pipes, which have nothing to copy.</summary>
public int Skipped { get; set; }
/// <summary>Remote names that would land outside the directory they were meant for.</summary>
/// <remarks>See <see cref="LocalDirectory.ChildPath"/>.</remarks>
public int Unsafe { get; set; }
/// <summary>Whether the walk stopped at <see cref="Ceiling"/> rather than at the end of the tree.</summary>
public bool Truncated { get; set; }
@@ -2937,6 +3001,12 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
said += $" {other} not a file.";
}
if (plan.Unsafe > 0)
{
var named = plan.Unsafe == 1 ? "1 name points outside the folder and was" : $"{plan.Unsafe} names point outside the folder and were";
said += $" {named} left on the host.";
}
if (plan.Truncated)
{
said += " The walk stopped at 5000 files; queue the rest a folder at a time.";
@@ -3162,13 +3232,18 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
$"Fetching {row.Name}…",
async () =>
{
var copy = Path.Combine(
var folder = Path.Combine(
Path.GetTempPath(),
"dodossh-edits",
Guid.CreateVersion7().ToString("N", CultureInfo.InvariantCulture),
row.Name);
Guid.CreateVersion7().ToString("N", CultureInfo.InvariantCulture));
Directory.CreateDirectory(Path.GetDirectoryName(copy)!);
if (LocalDirectory.ChildPath(folder, row.Name) is not { } copy)
{
Status = $"{row.Name} is not a name this machine can open as a file.";
return;
}
Directory.CreateDirectory(folder);
await FetchAsync(row.FullPath, copy, cancellationToken).ConfigureAwait(true);
@@ -3922,31 +3997,37 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
/// </remarks>
private void RefreshHosts()
{
var host = SelectedHost?.EntityId;
var bucket = SelectedBucket?.EntityId;
refreshingPickers = true;
Hosts.Clear();
if (vault is not { } open)
try
{
return;
}
Hosts.Clear();
foreach (var row in open.Hosts)
if (vault is not { } open)
{
return;
}
foreach (var row in open.Hosts)
{
Hosts.Add(row);
}
SelectedHost = Hosts.FirstOrDefault(row => row.EntityId == chosenHost) ?? Hosts.FirstOrDefault();
Buckets.Clear();
foreach (var row in open.ObjectStores)
{
Buckets.Add(row);
}
SelectedBucket = Buckets.FirstOrDefault(row => row.EntityId == chosenBucket) ?? Buckets.FirstOrDefault();
}
finally
{
Hosts.Add(row);
refreshingPickers = false;
}
SelectedHost = Hosts.FirstOrDefault(row => row.EntityId == host) ?? Hosts.FirstOrDefault();
Buckets.Clear();
foreach (var row in open.ObjectStores)
{
Buckets.Add(row);
}
SelectedBucket = Buckets.FirstOrDefault(row => row.EntityId == bucket) ?? Buckets.FirstOrDefault();
}
/// <summary>The session, or a failure a queue row can carry.</summary>
@@ -4106,8 +4187,30 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
await SettleRemoteListingAsync().ConfigureAwait(true);
}
partial void OnSelectedHostChanged(HostRowViewModel? value) =>
partial void OnSelectedHostChanged(HostRowViewModel? value)
{
if (!refreshingPickers)
{
chosenHost = value?.EntityId;
// A person moving to another host gets an empty box rather than one still showing a password
// that, by typedPasswordFor, would no longer be sent.
if (value is not null && typedPasswordFor is not null && value.EntityId != typedPasswordFor)
{
TypedPassword = string.Empty;
}
}
OnPropertyChanged(nameof(SelectedHostAsksForAPassword));
}
partial void OnSelectedBucketChanged(ObjectStoreRowViewModel? value)
{
if (!refreshingPickers)
{
chosenBucket = value?.EntityId;
}
}
/// <remarks>
/// The password box follows this as well as the host, because it is shown only for a host that asks for
@@ -8838,53 +8838,94 @@ internal sealed partial class VaultViewModel(
ArgumentNullException.ThrowIfNull(requests);
var target = vaultId ?? session.ActiveVaultId;
var hosts = 0;
var keys = 0;
var refused = 0;
var tally = new ImportTally();
var ran = false;
string? failure = null;
await RunAsync(
requests.Count == 1 ? "Importing 1 host…" : $"Importing {requests.Count} hosts…",
async () =>
{
// Keyed on the path rather than on the material, and filled as the batch runs rather than up
// front: the second host naming a file has to land on the id the first one produced, and
// nothing is reloaded until the batch finishes.
var bound = new Dictionary<string, Guid>(StringComparer.OrdinalIgnoreCase);
ran = true;
foreach (var request in requests)
try
{
if (!request.Host.TryValidate(out _))
{
refused++;
continue;
}
var (keyId, created) = await BindImportedKeyAsync(
request, bound, target, cancellationToken)
.ConfigureAwait(true);
if (created)
{
keys++;
}
var host = keyId is { } id ? request.Host with { SshKeyId = id } : request.Host;
await session.Hosts
.CreateAsync(target, host, cancellationToken)
.ConfigureAwait(true);
hosts++;
await ImportEachAsync(requests, target, tally, cancellationToken).ConfigureAwait(true);
}
catch (Exception exception) when (exception is not OutOfMemoryException
and not OperationCanceledException)
{
// Reported, and the hosts written before it are still reloaded below: they are in the
// keychain whether or not the rest followed, and the screen has to say both.
failure = exception.Message;
}
await ReloadAsync(cancellationToken).ConfigureAwait(true);
Status = DescribeImport(hosts, keys, refused);
Status = DescribeImport(tally.Hosts, tally.Keys, tally.Refused)
+ (failure is null ? string.Empty : $" The import stopped early: {failure}");
}).ConfigureAwait(true);
if (!ran)
{
// RunAsync returns at once while anything else is running — a sync pass, a save — and the
// screen asking would otherwise have said it imported nothing as though that were the result.
failure = "the keychain was busy with something else. Nothing was imported; try again.";
}
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
return new ImportOutcome(hosts, keys);
return new ImportOutcome(tally.Hosts, tally.Keys, tally.Refused, failure);
}
/// <summary>Writes each host of an import, and any key it brought, counting as it goes.</summary>
/// <remarks>
/// Counted into <paramref name="tally"/> rather than returned, so that when a write fails partway the
/// caller still knows how many landed before it — which is what it has to tell the person.
/// </remarks>
private async Task ImportEachAsync(
IReadOnlyList<ImportedHostRequest> batch,
Guid into,
ImportTally tally,
CancellationToken token)
{
// Keyed on the path rather than on the material, and filled as the batch runs rather than up
// front: the second host naming a file has to land on the id the first one produced, and nothing
// is reloaded until the batch finishes.
var bound = new Dictionary<string, Guid>(StringComparer.OrdinalIgnoreCase);
foreach (var request in batch)
{
if (!request.Host.TryValidate(out _))
{
tally.Refused++;
continue;
}
var (keyId, created) = await BindImportedKeyAsync(request, bound, into, token)
.ConfigureAwait(true);
if (created)
{
tally.Keys++;
}
var host = keyId is { } id ? request.Host with { SshKeyId = id } : request.Host;
await session.Hosts.CreateAsync(into, host, token).ConfigureAwait(true);
tally.Hosts++;
}
}
/// <summary>What one import has written so far.</summary>
private sealed class ImportTally
{
internal int Hosts { get; set; }
internal int Keys { get; set; }
internal int Refused { get; set; }
}
/// <summary>Finds or stores the key an imported host should be bound to.</summary>
@@ -1639,11 +1639,32 @@ internal sealed partial class VaultsViewModel(
}
// Fire-and-forget on purpose, and the only place in this class that is: selection changes come
// from a list box, which has no cancellation token and no way to await. Wrapped here rather than
// inside the read, because the read is also awaited from inside a reload that already holds the
// busy gate — and a second RunAsync there would find the gate held by its own caller and skip the
// members list entirely.
_ = RunAsync(() => LoadSelectedAsync(CancellationToken.None));
// from a list box, which has no cancellation token and no way to await.
//
// ◆ Not through RunAsync. Its busy gate returns at once while anything else runs — the first load,
// a share, a reload — so a vault chosen in that moment never had its people read, and the panel
// went on showing the previous vault's under the new one's name, with SHARE and REVOKE acting on
// that mixture. The read does not need the gate: LoadSelectedAsync bumps the selection generation,
// and every answer for an earlier selection is discarded by it.
_ = LoadSelectionAsync();
}
/// <summary>Reads the newly selected vault's people, turning a failure into a sentence.</summary>
private async Task LoadSelectionAsync()
{
try
{
await LoadSelectedAsync(CancellationToken.None).ConfigureAwait(true);
}
catch (DodoSshApiException exception)
{
Status = exception.Message;
}
catch (Exception exception) when (exception is not OutOfMemoryException
and not OperationCanceledException)
{
Status = exception.Message;
}
}
partial void OnPendingActionChanged(VaultActionRequest? value)
+32
View File
@@ -64,6 +64,22 @@ public interface IKnownHostStore
/// </remarks>
ValueTask<string?> FindAsync(string host, int port, string algorithm, CancellationToken cancellationToken);
/// <summary>Returns every key pinned for one endpoint, whatever the algorithm.</summary>
/// <remarks>
/// <para>
/// ◆ <b>What stops a pin being sidestepped by changing algorithm.</b> <see cref="FindAsync"/> answers
/// per algorithm, so on its own a host pinned for <c>ssh-ed25519</c> that is answered by somebody offering
/// only <c>ecdsa-sha2-nistp256</c> looks like a host never seen before — and "never seen before" is a
/// question the user is asked, where "changed" is a refusal. Knowing the endpoint has pins at all is what
/// lets the connection factory tell the two apart; see <c>SshNetConnectionFactory</c>.
/// </para>
/// <para>The same rule as <see cref="FindAsync"/>: no I/O.</para>
/// </remarks>
ValueTask<IReadOnlyList<HostKeyPresentation>> FindForEndpointAsync(
string host,
int port,
CancellationToken cancellationToken);
/// <summary>Records a host key as trusted.</summary>
ValueTask TrustAsync(HostKeyPresentation presentation, CancellationToken cancellationToken);
@@ -156,6 +172,22 @@ public sealed class InMemoryKnownHostStore : IKnownHostStore
}
}
/// <inheritdoc />
public ValueTask<IReadOnlyList<HostKeyPresentation>> FindForEndpointAsync(
string host,
int port,
CancellationToken cancellationToken)
{
lock (gate)
{
IReadOnlyList<HostKeyPresentation> found = pins.Values
.Where(pin => KnownHostIdentity.Comparer.Equals(pin.Host, host) && pin.Port == port)
.ToArray();
return ValueTask.FromResult(found);
}
}
/// <inheritdoc />
public ValueTask TrustAsync(HostKeyPresentation presentation, CancellationToken cancellationToken)
{
+8
View File
@@ -523,6 +523,14 @@ public interface IRemoteFileStore : IAsyncDisposable
/// <param name="cancellationToken">Abandons the open.</param>
Task<Stream> OpenWriteAsync(string path, long offset, CancellationToken cancellationToken);
/// <summary>Whether <see cref="OpenWriteAsync"/> can start anywhere but the beginning.</summary>
/// <remarks>
/// True for SFTP, which is a file system. False for a bucket, where an object is written whole: an
/// interrupted upload there starts again rather than resuming, and the transfer queue reads this to know
/// — asking for an offset would be refused on every retry, leaving a row that can never finish.
/// </remarks>
bool CanWriteFromAnOffset => true;
/// <summary>Creates one directory, whose parent must exist.</summary>
Task CreateDirectoryAsync(string path, CancellationToken cancellationToken);
@@ -187,7 +187,13 @@ public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts)
IProgress<SshConnectionPhase>? progress,
CancellationToken cancellationToken)
{
var gate = new HostKeyGate(knownHosts, request, progress, cancellationToken);
var endpointPins = await knownHosts
.FindForEndpointAsync(request.Host, request.Port, cancellationToken)
.ConfigureAwait(false);
PreferPinnedAlgorithms(client.ConnectionInfo, endpointPins);
var gate = new HostKeyGate(knownHosts, request, endpointPins, progress, cancellationToken);
client.HostKeyReceived += gate.OnHostKeyReceived;
@@ -217,17 +223,73 @@ public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts)
return gate;
}
/// <summary>
/// Offers the host key algorithms this endpoint has pins for ahead of every other.
/// </summary>
/// <remarks>
/// <para>
/// The server takes the first algorithm in the client's list that it also supports, so this is what
/// makes a server that still holds its pinned key negotiate that key — rather than, after it gained an
/// <c>ed25519</c> key alongside the <c>rsa</c> one that was pinned, a key nobody has approved. Without it
/// the gate below would have to choose between refusing a legitimate server and prompting for one that
/// may not be, which is exactly the choice it must not be left with.
/// </para>
/// <para>
/// Reordered rather than restricted, so a host whose pinned keys are all gone still completes the key
/// exchange and reaches the gate, which reports it as a changed key with both fingerprints. Restricting
/// would fail the exchange with "no matching host key algorithm", which is true and tells nobody what
/// happened. Cleared and refilled because that is the only way a dictionary promises an order.
/// </para>
/// </remarks>
private static void PreferPinnedAlgorithms(
ConnectionInfo info,
IReadOnlyList<HostKeyPresentation> pins)
{
if (pins.Count == 0)
{
return;
}
var pinned = pins
.Select(pin => pin.Algorithm)
.ToHashSet(StringComparer.Ordinal);
var ordered = info.HostKeyAlgorithms
.OrderBy(algorithm => pinned.Contains(algorithm.Key) ? 0 : 1)
.ToList();
info.HostKeyAlgorithms.Clear();
foreach (var algorithm in ordered)
{
info.HostKeyAlgorithms.Add(algorithm.Key, algorithm.Value);
}
}
/// <summary>
/// Decides host key trust during the handshake, and remembers enough to explain a refusal.
/// </summary>
/// <remarks>
/// <para>
/// Separate from the connect method because the decision is the security-relevant part and reads
/// better on its own: pinned and equal accepts, pinned and different refuses as a mismatch,
/// unpinned refuses as unknown. There is no fourth branch, and there is no prompt.
/// </para>
/// <para>
/// ◆ <b>"Pinned" means pinned for this endpoint, not for this algorithm.</b> Pins are stored per
/// algorithm, and an endpoint whose pins are all for other algorithms used to fall through to "unknown"
/// — so somebody in the middle who simply offered a different key type got the first-use question
/// instead of the hard refusal a pin exists to produce. Now a key under an algorithm with no pin of its
/// own is accepted only if it is the same key as one that is pinned (an RSA key is one key under
/// <c>rsa-sha2-256</c> and <c>rsa-sha2-512</c>, and its fingerprint says so), and is otherwise a
/// mismatch against the endpoint's pin. <see cref="PreferPinnedAlgorithms"/> is what keeps that from
/// refusing a server that still has its pinned key.
/// </para>
/// </remarks>
private sealed class HostKeyGate(
IKnownHostStore knownHosts,
SshConnectionRequest request,
IReadOnlyList<HostKeyPresentation> endpointPins,
IProgress<SshConnectionPhase>? progress,
CancellationToken cancellationToken)
{
@@ -264,13 +326,21 @@ public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts)
.GetAwaiter()
.GetResult();
if (pinned is null)
if (pinned is null && endpointPins.Count == 0)
{
// Refused, not prompted. The caller decides, off the handshake thread.
e.CanTrust = false;
return;
}
// Nothing pinned under this algorithm, but the endpoint has pins: the same key under another
// algorithm's name if any pin's fingerprint is this one, and otherwise a changed key, measured
// against the endpoint's first pin. See the class remarks.
pinned ??= endpointPins
.FirstOrDefault(pin => SshHostKeyFingerprint.Equal(pin.Fingerprint, presentation.Fingerprint))
?.Fingerprint
?? endpointPins[0].Fingerprint;
var matches = SshHostKeyFingerprint.Equal(pinned, presentation.Fingerprint);
mismatch = !matches;
e.CanTrust = matches;
+11
View File
@@ -350,6 +350,17 @@ internal sealed class SyncStateRow
/// decide which edit wins.
/// </remarks>
public long ServerTimeSkewMs { get; set; }
/// <summary>
/// The item types <see cref="Cursor"/> was read under, as this build names them; null before this was kept.
/// </summary>
/// <remarks>
/// A pull asks for the types this build can apply, and the cursor it gets back is past every change it
/// read — including the changes of types it did not ask for. So a build that adds a type to that list
/// and carries on from the old cursor never sees the items of that type made before it arrived. Kept
/// beside the cursor so a change of list starts the pull over; see <c>SyncEngine.PullAsync</c>.
/// </remarks>
public string? PulledTypes { get; set; }
}
/// <summary>Something the merge had to override, or an item that could not be processed.</summary>
@@ -0,0 +1,469 @@
// <auto-generated />
using System;
using DodoSSH.Client.Storage;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace DodoSSH.Client.Storage.Migrations
{
[DbContext(typeof(ClientCacheContext))]
[Migration("20261002104920_AddSyncStatePulledTypes")]
partial class AddSyncStatePulledTypes
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
modelBuilder.Entity("DodoSSH.Client.Storage.CachedItemRow", b =>
{
b.Property<Guid>("VaultId")
.HasColumnType("TEXT")
.HasColumnName("vault_id");
b.Property<int>("EntityType")
.HasColumnType("INTEGER")
.HasColumnName("entity_type");
b.Property<Guid>("EntityId")
.HasColumnType("TEXT")
.HasColumnName("entity_id");
b.Property<byte>("AadVersion")
.HasColumnType("INTEGER")
.HasColumnName("aad_version");
b.Property<long>("ChangeSequence")
.HasColumnType("INTEGER")
.HasColumnName("change_sequence");
b.Property<Guid?>("DataKeyId")
.HasColumnType("TEXT")
.HasColumnName("data_key_id");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER")
.HasColumnName("is_deleted");
b.Property<uint>("KeyGeneration")
.HasColumnType("INTEGER")
.HasColumnName("key_generation");
b.Property<byte[]>("Payload")
.HasColumnType("BLOB")
.HasColumnName("payload");
b.Property<byte[]>("ProtectedFields")
.HasColumnType("BLOB")
.HasColumnName("protected_fields");
b.Property<long>("UpdatedAtUtc")
.HasColumnType("INTEGER")
.HasColumnName("updated_at_utc");
b.Property<int>("Version")
.HasColumnType("INTEGER")
.HasColumnName("version");
b.Property<byte[]>("WrappedDataKey")
.HasColumnType("BLOB")
.HasColumnName("wrapped_data_key");
b.HasKey("VaultId", "EntityType", "EntityId")
.HasName("pk_item");
b.HasIndex("VaultId", "ChangeSequence")
.HasDatabaseName("ix_item_vault_id_change_sequence");
b.HasIndex("VaultId", "EntityType")
.HasDatabaseName("ix_item_vault_id_entity_type");
b.ToTable("item", (string)null);
});
modelBuilder.Entity("DodoSSH.Client.Storage.CachedVaultKeyWrapRow", b =>
{
b.Property<Guid>("VaultId")
.HasColumnType("TEXT")
.HasColumnName("vault_id");
b.Property<uint>("KeyGeneration")
.HasColumnType("INTEGER")
.HasColumnName("key_generation");
b.Property<byte[]>("WrappedKey")
.IsRequired()
.HasColumnType("BLOB")
.HasColumnName("wrapped_key");
b.HasKey("VaultId", "KeyGeneration")
.HasName("pk_vault_key_wrap");
b.ToTable("vault_key_wrap", (string)null);
});
modelBuilder.Entity("DodoSSH.Client.Storage.CachedVaultRow", b =>
{
b.Property<Guid>("VaultId")
.HasColumnType("TEXT")
.HasColumnName("vault_id");
b.Property<bool>("Hidden")
.HasColumnType("INTEGER")
.HasColumnName("hidden");
b.Property<bool>("IsPersonal")
.HasColumnType("INTEGER")
.HasColumnName("is_personal");
b.Property<uint>("KeyGeneration")
.HasColumnType("INTEGER")
.HasColumnName("key_generation");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<int>("Permissions")
.HasColumnType("INTEGER")
.HasColumnName("permissions");
b.Property<bool>("RekeyRequired")
.HasColumnType("INTEGER")
.HasColumnName("rekey_required");
b.Property<Guid?>("TeamId")
.HasColumnType("TEXT")
.HasColumnName("team_id");
b.Property<long>("UpdatedAtUtc")
.HasColumnType("INTEGER")
.HasColumnName("updated_at_utc");
b.Property<byte[]>("WrappedVaultKey")
.HasColumnType("BLOB")
.HasColumnName("wrapped_vault_key");
b.HasKey("VaultId")
.HasName("pk_vault");
b.ToTable("vault", (string)null);
});
modelBuilder.Entity("DodoSSH.Client.Storage.ConflictRow", b =>
{
b.Property<Guid>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Acknowledged")
.HasColumnType("INTEGER")
.HasColumnName("acknowledged");
b.Property<byte[]>("Detail")
.IsRequired()
.HasColumnType("BLOB")
.HasColumnName("detail");
b.Property<long>("DetectedAtUtc")
.HasColumnType("INTEGER")
.HasColumnName("detected_at_utc");
b.Property<Guid>("EntityId")
.HasColumnType("TEXT")
.HasColumnName("entity_id");
b.Property<int>("EntityType")
.HasColumnType("INTEGER")
.HasColumnName("entity_type");
b.Property<int>("Kind")
.HasColumnType("INTEGER")
.HasColumnName("kind");
b.Property<Guid>("VaultId")
.HasColumnType("TEXT")
.HasColumnName("vault_id");
b.HasKey("Id")
.HasName("pk_conflict");
b.HasIndex("VaultId", "Acknowledged")
.HasDatabaseName("ix_conflict_vault_id_acknowledged");
b.HasIndex("VaultId", "EntityType", "EntityId")
.HasDatabaseName("ix_conflict_vault_id_entity_type_entity_id");
b.ToTable("conflict", (string)null);
});
modelBuilder.Entity("DodoSSH.Client.Storage.OutboxRow", b =>
{
b.Property<long>("Sequence")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasColumnName("sequence");
b.Property<byte>("AadVersion")
.HasColumnType("INTEGER")
.HasColumnName("aad_version");
b.Property<byte?>("AncestorAadVersion")
.HasColumnType("INTEGER")
.HasColumnName("ancestor_aad_version");
b.Property<Guid?>("AncestorDataKeyId")
.HasColumnType("TEXT")
.HasColumnName("ancestor_data_key_id");
b.Property<uint?>("AncestorKeyGeneration")
.HasColumnType("INTEGER")
.HasColumnName("ancestor_key_generation");
b.Property<byte[]>("AncestorPayload")
.HasColumnType("BLOB")
.HasColumnName("ancestor_payload");
b.Property<byte[]>("AncestorProtectedFields")
.HasColumnType("BLOB")
.HasColumnName("ancestor_protected_fields");
b.Property<int?>("AncestorVersion")
.HasColumnType("INTEGER")
.HasColumnName("ancestor_version");
b.Property<byte[]>("AncestorWrappedDataKey")
.HasColumnType("BLOB")
.HasColumnName("ancestor_wrapped_data_key");
b.Property<int>("Attempts")
.HasColumnType("INTEGER")
.HasColumnName("attempts");
b.Property<Guid?>("DataKeyId")
.HasColumnType("TEXT")
.HasColumnName("data_key_id");
b.Property<Guid>("EntityId")
.HasColumnType("TEXT")
.HasColumnName("entity_id");
b.Property<int>("EntityType")
.HasColumnType("INTEGER")
.HasColumnName("entity_type");
b.Property<int?>("ExpectedVersion")
.HasColumnType("INTEGER")
.HasColumnName("expected_version");
b.Property<bool>("IsParked")
.HasColumnType("INTEGER")
.HasColumnName("is_parked");
b.Property<uint>("KeyGeneration")
.HasColumnType("INTEGER")
.HasColumnName("key_generation");
b.Property<string>("LastError")
.HasColumnType("TEXT")
.HasColumnName("last_error");
b.Property<int>("Operation")
.HasColumnType("INTEGER")
.HasColumnName("operation");
b.Property<Guid>("OperationId")
.HasColumnType("TEXT")
.HasColumnName("operation_id");
b.Property<byte[]>("Payload")
.HasColumnType("BLOB")
.HasColumnName("payload");
b.Property<byte[]>("ProtectedFields")
.HasColumnType("BLOB")
.HasColumnName("protected_fields");
b.Property<long>("QueuedAtUtc")
.HasColumnType("INTEGER")
.HasColumnName("queued_at_utc");
b.Property<Guid>("VaultId")
.HasColumnType("TEXT")
.HasColumnName("vault_id");
b.Property<byte[]>("WrappedDataKey")
.HasColumnType("BLOB")
.HasColumnName("wrapped_data_key");
b.HasKey("Sequence")
.HasName("pk_outbox");
b.HasIndex("OperationId")
.IsUnique()
.HasDatabaseName("ix_outbox_operation_id");
b.HasIndex("VaultId", "EntityType", "EntityId")
.IsUnique()
.HasDatabaseName("ix_outbox_vault_id_entity_type_entity_id");
b.HasIndex("VaultId", "IsParked", "Sequence")
.HasDatabaseName("ix_outbox_vault_id_is_parked_sequence");
b.ToTable("outbox", (string)null);
});
modelBuilder.Entity("DodoSSH.Client.Storage.RememberedSignInRow", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<byte[]>("SealedRefreshToken")
.IsRequired()
.HasColumnType("BLOB")
.HasColumnName("sealed_refresh_token");
b.Property<long>("UpdatedAtUtc")
.HasColumnType("INTEGER")
.HasColumnName("updated_at_utc");
b.HasKey("Id")
.HasName("pk_remembered_sign_in");
b.ToTable("remembered_sign_in", null, t =>
{
t.HasCheckConstraint("ck_remembered_sign_in_singleton", "id = 1");
});
});
modelBuilder.Entity("DodoSSH.Client.Storage.SyncStateRow", b =>
{
b.Property<Guid>("VaultId")
.HasColumnType("TEXT")
.HasColumnName("vault_id");
b.Property<string>("Cursor")
.HasColumnType("TEXT")
.HasColumnName("cursor");
b.Property<uint>("KeyGeneration")
.HasColumnType("INTEGER")
.HasColumnName("key_generation");
b.Property<long?>("LastPulledAtUtc")
.HasColumnType("INTEGER")
.HasColumnName("last_pulled_at_utc");
b.Property<long?>("LastPushedAtUtc")
.HasColumnType("INTEGER")
.HasColumnName("last_pushed_at_utc");
b.Property<string>("PulledTypes")
.HasColumnType("TEXT")
.HasColumnName("pulled_types");
b.Property<long>("ServerTimeSkewMs")
.HasColumnType("INTEGER")
.HasColumnName("server_time_skew_ms");
b.HasKey("VaultId")
.HasName("pk_sync_state");
b.ToTable("sync_state", (string)null);
});
modelBuilder.Entity("DodoSSH.Client.Storage.UnlockMaterialRow", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<Guid?>("DeviceId")
.HasColumnType("TEXT")
.HasColumnName("device_id");
b.Property<byte[]>("DeviceWrappedPrivateKey")
.HasColumnType("BLOB")
.HasColumnName("device_wrapped_private_key");
b.Property<string>("DisplayName")
.HasColumnType("TEXT")
.HasColumnName("display_name");
b.Property<string>("Email")
.HasColumnType("TEXT")
.HasColumnName("email");
b.Property<string>("Issuer")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("issuer");
b.Property<string>("KdfAlgorithm")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("kdf_algorithm");
b.Property<int>("KdfMemoryKibibytes")
.HasColumnType("INTEGER")
.HasColumnName("kdf_memory_kibibytes");
b.Property<int>("KdfParallelism")
.HasColumnType("INTEGER")
.HasColumnName("kdf_parallelism");
b.Property<int>("KdfPasses")
.HasColumnType("INTEGER")
.HasColumnName("kdf_passes");
b.Property<byte[]>("KdfSalt")
.IsRequired()
.HasColumnType("BLOB")
.HasColumnName("kdf_salt");
b.Property<uint>("KeyGeneration")
.HasColumnType("INTEGER")
.HasColumnName("key_generation");
b.Property<string>("ServerUrl")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("server_url");
b.Property<string>("Subject")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("subject");
b.Property<long>("UpdatedAtUtc")
.HasColumnType("INTEGER")
.HasColumnName("updated_at_utc");
b.Property<Guid>("UserId")
.HasColumnType("TEXT")
.HasColumnName("user_id");
b.Property<byte[]>("WrappedPrivateKey")
.IsRequired()
.HasColumnType("BLOB")
.HasColumnName("wrapped_private_key");
b.HasKey("Id")
.HasName("pk_unlock_material");
b.ToTable("unlock_material", null, t =>
{
t.HasCheckConstraint("ck_unlock_material_singleton", "id = 1");
});
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DodoSSH.Client.Storage.Migrations
{
/// <inheritdoc />
public partial class AddSyncStatePulledTypes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "pulled_types",
table: "sync_state",
type: "TEXT",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "pulled_types",
table: "sync_state");
}
}
}
@@ -362,6 +362,10 @@ namespace DodoSSH.Client.Storage.Migrations
.HasColumnType("INTEGER")
.HasColumnName("last_pushed_at_utc");
b.Property<string>("PulledTypes")
.HasColumnType("TEXT")
.HasColumnName("pulled_types");
b.Property<long>("ServerTimeSkewMs")
.HasColumnType("INTEGER")
.HasColumnName("server_time_skew_ms");
+31 -10
View File
@@ -44,6 +44,14 @@ public sealed record QueuedChange(
/// ancestor that is this client's own earlier edit. That merge finds no disagreement, so it converges
/// on the newest state with nothing for the user to arbitrate.
/// </para>
/// <para>
/// ◆ <b>Which is only true if an answer is applied to the operation it answers.</b> A push reads a row,
/// sends it, and acts on the reply some time later; a user who saves in between coalesces a new state into
/// that same row. So <see cref="CompleteAsync"/>, <see cref="ReviseAsync"/> and <see cref="ParkAsync"/>
/// take the operation that was sent and do nothing when the row has since become a different one. Keyed by
/// sequence alone, an acknowledgement of the earlier state deleted the row holding the later one, and the
/// edit was gone with nothing to say so.
/// </para>
/// </remarks>
public sealed class OutboxStore(
IDbContextFactory<ClientCacheContext> contexts,
@@ -116,7 +124,7 @@ public sealed class OutboxStore(
/// </para>
/// </remarks>
public async Task<PendingOperation?> ReviseAsync(
long sequence,
PendingOperation revising,
SyncOperation operation,
int? expectedVersion,
EncryptedPayload? payload,
@@ -127,12 +135,18 @@ public sealed class OutboxStore(
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
ArgumentNullException.ThrowIfNull(revising);
var row = await context.Set<OutboxRow>()
.SingleOrDefaultAsync(r => r.Sequence == sequence, cancellationToken)
.SingleOrDefaultAsync(
r => r.Sequence == revising.Sequence && r.OperationId == revising.OperationId,
cancellationToken)
.ConfigureAwait(false);
if (row is null)
{
// Completed, or replaced by a newer edit since it was read. Either way the merge was of a state
// that is no longer the one queued, and the next push will meet the server with the current one.
return null;
}
@@ -233,16 +247,19 @@ public sealed class OutboxStore(
/// <summary>Records that an operation has been sent, so a repeated failure can be noticed.</summary>
public Task MarkDispatchedAsync(long sequence, CancellationToken cancellationToken) =>
UpdateAsync(sequence, row => row.Attempts++, cancellationToken);
UpdateAsync(sequence, operationId: null, row => row.Attempts++, cancellationToken);
/// <summary>Removes an operation the server accepted.</summary>
public async Task<bool> CompleteAsync(long sequence, CancellationToken cancellationToken)
/// <summary>Removes an operation the server accepted, unless a newer edit has replaced it since.</summary>
/// <returns>Whether a row was removed.</returns>
public async Task<bool> CompleteAsync(PendingOperation operation, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(operation);
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
var removed = await context.Set<OutboxRow>()
.Where(r => r.Sequence == sequence)
.Where(r => r.Sequence == operation.Sequence && r.OperationId == operation.OperationId)
.ExecuteDeleteAsync(cancellationToken)
.ConfigureAwait(false);
@@ -257,9 +274,10 @@ public sealed class OutboxStore(
/// invalid, or the caller no longer having permission. Retrying either would spin forever and, far
/// worse, would block every change queued behind it in a vault the user can still write to.
/// </remarks>
public Task ParkAsync(long sequence, string reason, CancellationToken cancellationToken) =>
public Task ParkAsync(PendingOperation operation, string reason, CancellationToken cancellationToken) =>
UpdateAsync(
sequence,
operation.Sequence,
operation.OperationId,
row =>
{
row.IsParked = true;
@@ -269,7 +287,7 @@ public sealed class OutboxStore(
/// <summary>Records a transient failure without parking the operation.</summary>
public Task RecordFailureAsync(long sequence, string reason, CancellationToken cancellationToken) =>
UpdateAsync(sequence, row => row.LastError = reason, cancellationToken);
UpdateAsync(sequence, operationId: null, row => row.LastError = reason, cancellationToken);
private static Task<OutboxRow?> FindRowAsync(
ClientCacheContext context,
@@ -302,6 +320,7 @@ public sealed class OutboxStore(
private async Task UpdateAsync(
long sequence,
Guid? operationId,
Action<OutboxRow> mutate,
CancellationToken cancellationToken)
{
@@ -309,7 +328,9 @@ public sealed class OutboxStore(
await using var scope = context.ConfigureAwait(false);
var row = await context.Set<OutboxRow>()
.SingleOrDefaultAsync(r => r.Sequence == sequence, cancellationToken)
.SingleOrDefaultAsync(
r => r.Sequence == sequence && (operationId == null || r.OperationId == operationId),
cancellationToken)
.ConfigureAwait(false);
if (row is null)
+3 -1
View File
@@ -183,13 +183,15 @@ public sealed record PendingOperation(
/// <param name="LastPulledAt">When the last pull completed.</param>
/// <param name="LastPushedAt">When the last push completed.</param>
/// <param name="ServerTimeSkewMs">Observed clock difference, recorded and never acted on.</param>
/// <param name="PulledTypes">The item types the cursor was read under; see <c>SyncStateRow.PulledTypes</c>.</param>
public sealed record StoredSyncState(
Guid VaultId,
string? Cursor,
uint KeyGeneration,
DateTimeOffset? LastPulledAt = null,
DateTimeOffset? LastPushedAt = null,
long ServerTimeSkewMs = 0);
long ServerTimeSkewMs = 0,
string? PulledTypes = null);
/// <summary>Something the merge overrode, or an item that could not be processed.</summary>
/// <param name="Id">This record's own id, which its sealed detail is bound to.</param>
+3 -1
View File
@@ -46,7 +46,8 @@ public sealed class SyncStateStore(IDbContextFactory<ClientCacheContext> context
row.KeyGeneration,
row.LastPulledAtUtc,
row.LastPushedAtUtc,
row.ServerTimeSkewMs);
row.ServerTimeSkewMs,
row.PulledTypes);
}
/// <summary>Records a vault's position.</summary>
@@ -72,6 +73,7 @@ public sealed class SyncStateStore(IDbContextFactory<ClientCacheContext> context
row.LastPulledAtUtc = state.LastPulledAt;
row.LastPushedAtUtc = state.LastPushedAt;
row.ServerTimeSkewMs = state.ServerTimeSkewMs;
row.PulledTypes = state.PulledTypes;
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
+8 -8
View File
@@ -120,7 +120,7 @@ internal sealed class ItemReconciler<TSecret>(
{
return pending.Operation == SyncOperation.Delete
// Both sides deleted it. Nothing to arbitrate and nothing to tell the user.
? outbox.CompleteAsync(pending.Sequence, cancellationToken)
? outbox.CompleteAsync(pending, cancellationToken)
: ResurrectAsync(vaultId, remote, pending, report, cancellationToken);
}
@@ -166,7 +166,7 @@ internal sealed class ItemReconciler<TSecret>(
}
await outbox.ReviseAsync(
pending.Sequence,
pending,
SyncOperation.Upsert,
expectedVersion: null,
kind.Seal(local.Secret, vaultKey.Span, pending.EntityId, generation, itemVersion: 1),
@@ -212,7 +212,7 @@ internal sealed class ItemReconciler<TSecret>(
{
// Field for field the same item: this is our own create coming back. Nothing to do but stop
// trying to send it again.
await outbox.CompleteAsync(pending.Sequence, cancellationToken).ConfigureAwait(false);
await outbox.CompleteAsync(pending, cancellationToken).ConfigureAwait(false);
return;
}
@@ -362,7 +362,7 @@ internal sealed class ItemReconciler<TSecret>(
cancellationToken).ConfigureAwait(false);
// Now the tombstone can stand.
await outbox.CompleteAsync(pending.Sequence, cancellationToken).ConfigureAwait(false);
await outbox.CompleteAsync(pending, cancellationToken).ConfigureAwait(false);
await conflicts.RecordAsync(
vaultId,
@@ -385,7 +385,7 @@ internal sealed class ItemReconciler<TSecret>(
SyncReportBuilder report,
CancellationToken cancellationToken)
{
await outbox.CompleteAsync(pending.Sequence, cancellationToken).ConfigureAwait(false);
await outbox.CompleteAsync(pending, cancellationToken).ConfigureAwait(false);
await conflicts.RecordAsync(
vaultId,
@@ -413,7 +413,7 @@ internal sealed class ItemReconciler<TSecret>(
var nextVersion = SyncVersions.NextVersion(remote.Version);
await outbox.ReviseAsync(
pending.Sequence,
pending,
SyncOperation.Upsert,
expectedVersion: remote.Version,
kind.Seal(secret, vaultKey.Span, remote.EntityId, generation, nextVersion),
@@ -486,7 +486,7 @@ internal sealed class ItemReconciler<TSecret>(
CancellationToken cancellationToken)
{
await outbox.ParkAsync(
pending.Sequence,
pending,
$"Written by a newer version of DodoSSH; update before editing this {kind.Noun}.",
cancellationToken).ConfigureAwait(false);
@@ -512,7 +512,7 @@ internal sealed class ItemReconciler<TSecret>(
CancellationToken cancellationToken)
{
await outbox.ParkAsync(
pending.Sequence,
pending,
$"The local or the server copy of this {kind.Noun} could not be decrypted.",
cancellationToken).ConfigureAwait(false);
+99 -5
View File
@@ -127,7 +127,7 @@ public sealed class SyncEngine
SyncReportBuilder report,
CancellationToken cancellationToken)
{
var state = await syncState.ReadAsync(vaultId, cancellationToken).ConfigureAwait(false);
var state = await ReadStateAsync(vaultId, report, cancellationToken).ConfigureAwait(false);
// At most one restart per pull. A server that rejects the cursor it has just issued is not
// telling this client anything it can act on, and replaying the whole log against it would turn
@@ -245,6 +245,26 @@ public sealed class SyncEngine
/// the beginning as well, rather than meeting the same refusal again.
/// </para>
/// </remarks>
/// <summary>Where the pull should carry on from, which is the start when the types it reads have changed.</summary>
/// <remarks>
/// A cursor read under a different list of types is past changes of the types it did not ask for, so an
/// upgrade that adds one — SpelunkKey, ObjectStore — would never receive the items of that type made
/// before it. Starting over once is the only way to see them; applying is idempotent, which is what makes
/// the restart for a refused cursor safe as well. A state from before the list was kept has none and
/// starts over once for the same reason: it may already have missed some.
/// </remarks>
private async Task<StoredSyncState> ReadStateAsync(
Guid vaultId,
SyncReportBuilder report,
CancellationToken cancellationToken)
{
var state = await syncState.ReadAsync(vaultId, cancellationToken).ConfigureAwait(false);
return state.Cursor is not null && !string.Equals(state.PulledTypes, PulledTypes, StringComparison.Ordinal)
? await StartOverAsync(state, report, cancellationToken).ConfigureAwait(false)
: state;
}
private async Task<StoredSyncState> StartOverAsync(
StoredSyncState state,
SyncReportBuilder report,
@@ -282,9 +302,14 @@ public sealed class SyncEngine
KeyGeneration = response.CurrentKeyGeneration,
LastPulledAt = now,
ServerTimeSkewMs = skew,
PulledTypes = PulledTypes,
};
}
/// <summary>The types this build pulls, in a form a stored state can be compared against.</summary>
private static string PulledTypes { get; } =
string.Join(',', ItemKinds.SyncedTypes.Select(type => (int)type).Order());
private async Task ApplyAsync(
Guid vaultId,
SyncChange change,
@@ -446,7 +471,7 @@ public sealed class SyncEngine
}
await outbox.ReviseAsync(
operation.Sequence,
operation,
operation.Operation,
operation.ExpectedVersion,
resealed,
@@ -551,7 +576,76 @@ public sealed class SyncEngine
cancellationToken).ConfigureAwait(false);
}
await outbox.CompleteAsync(operation.Sequence, cancellationToken).ConfigureAwait(false);
var completed = await outbox.CompleteAsync(operation, cancellationToken).ConfigureAwait(false);
if (!completed && canMirror && !isDelete)
{
await RebaseOntoAcceptedAsync(vaultId, operation, version, cancellationToken).ConfigureAwait(false);
}
}
/// <summary>
/// Moves an edit made while <paramref name="accepted"/> was in flight onto the version that push produced.
/// </summary>
/// <remarks>
/// <para>
/// The user saved again between the push being read and its answer arriving, so the row now holds a
/// later state branched from the same base as the one the server just took. Left alone it is still the
/// user's edit and still goes — but it goes expecting the old version, comes back <c>Conflict</c>, and is
/// merged against an ancestor that predates the save the server already has, which makes every field
/// changed twice look like a disagreement with somebody else. Its true parent is the state just accepted,
/// so that becomes its ancestor and expected version, and its payload is re-sealed at the version it
/// will now produce, because the AAD binds that.
/// </para>
/// <para>
/// Only for a row still branched from the same base. One that is not has been revised by something else
/// since, and the ordinary conflict path is the one that knows what to do with it; so is a row whose
/// payload this session cannot re-seal.
/// </para>
/// </remarks>
private async Task RebaseOntoAcceptedAsync(
Guid vaultId,
PendingOperation accepted,
int version,
CancellationToken cancellationToken)
{
var later = await outbox
.FindAsync(vaultId, accepted.EntityType, accepted.EntityId, cancellationToken)
.ConfigureAwait(false);
if (later is null || later.ExpectedVersion != accepted.ExpectedVersion || accepted.Payload is null)
{
return;
}
EncryptedPayload? payload = null;
if (later.Payload is { } queued)
{
payload = PayloadReseal.TryReseal(
keyring,
vaultId,
later.EntityType,
later.EntityId,
queued,
openAtVersion: SyncVersions.NextVersion(later.ExpectedVersion),
sealAtVersion: SyncVersions.NextVersion(version));
if (payload is null)
{
return;
}
}
await outbox.ReviseAsync(
later,
later.Operation,
version,
payload,
later.Fields,
new StoredAncestor(version, accepted.Payload, accepted.Fields),
cancellationToken)
.ConfigureAwait(false);
}
private async Task<bool> ResolveAsync(
@@ -617,7 +711,7 @@ public sealed class SyncEngine
if (operation.Operation == SyncOperation.Delete)
{
// Nothing there to delete, so the intent is already satisfied.
await outbox.CompleteAsync(operation.Sequence, cancellationToken).ConfigureAwait(false);
await outbox.CompleteAsync(operation, cancellationToken).ConfigureAwait(false);
return false;
}
@@ -645,7 +739,7 @@ public sealed class SyncEngine
SyncReportBuilder report,
CancellationToken cancellationToken)
{
await outbox.ParkAsync(operation.Sequence, reason, cancellationToken).ConfigureAwait(false);
await outbox.ParkAsync(operation, reason, cancellationToken).ConfigureAwait(false);
await conflicts.RecordAsync(
vaultId,
@@ -327,7 +327,7 @@ internal sealed class VaultItemRepository<TSecret>(
if (pending is not null && NeverReachedTheServer(pending))
{
await outbox.CompleteAsync(pending.Sequence, cancellationToken).ConfigureAwait(false);
await outbox.CompleteAsync(pending, cancellationToken).ConfigureAwait(false);
// Recorded even though nothing goes to the server. Somebody created an item and then removed it,
// which is two things they did — and a log that showed only the create would describe a keychain
@@ -56,6 +56,16 @@ public sealed class TerminalDataPlane : ITerminalTransport, IAsyncDisposable
private const int MaximumRequestBytes = 16 * 1024;
private const int ReceiveBufferBytes = 64 * 1024;
/// <summary>The largest message from the page this host will reassemble.</summary>
/// <remarks>
/// A paste is one input frame however long it is, and so is a copied selection, so the receive buffer
/// is the size of a read and not the size of a message. Sixteen megabytes is far beyond anything a
/// person pastes into a shell on purpose and still small enough that a page sending garbage cannot make
/// this process hold an unbounded amount of it. A message beyond it is dropped whole — never its tail,
/// which would be parsed as a frame whose first bytes are somebody's paste.
/// </remarks>
private const int MaximumMessageBytes = 16 * 1024 * 1024;
private readonly TcpListener listener;
private readonly ITerminalAssetProvider assets;
private readonly Dictionary<uint, TerminalSessionPump> pumps = [];
@@ -451,6 +461,11 @@ public sealed class TerminalDataPlane : ITerminalTransport, IAsyncDisposable
{
var buffer = new byte[ReceiveBufferBytes];
// Grown only when a message spans more than one read, and kept for the next one that does.
var message = Array.Empty<byte>();
var length = 0;
var oversized = false;
while (webSocket.State == WebSocketState.Open && !cancellationToken.IsCancellationRequested)
{
var result = await webSocket
@@ -462,14 +477,73 @@ public sealed class TerminalDataPlane : ITerminalTransport, IAsyncDisposable
return;
}
if (!result.EndOfMessage)
// The common case — a keystroke, an acknowledgement — is one read, and goes straight through.
if (result.EndOfMessage && length == 0 && !oversized)
{
// A frame larger than the receive buffer. Terminal input and acknowledgements are tiny,
// so this is either a bug or something hostile; dropping is the safe response.
DispatchSafely(buffer.AsSpan(0, result.Count));
continue;
}
Dispatch(buffer.AsSpan(0, result.Count));
if (!oversized && length + result.Count <= MaximumMessageBytes)
{
if (length + result.Count > message.Length)
{
Array.Resize(ref message, Math.Min(MaximumMessageBytes, Math.Max(message.Length * 2, length + result.Count)));
}
buffer.AsSpan(0, result.Count).CopyTo(message.AsSpan(length));
length += result.Count;
}
else
{
oversized = true;
}
if (!result.EndOfMessage)
{
continue;
}
if (!oversized)
{
DispatchSafely(message.AsSpan(0, length));
}
length = 0;
oversized = false;
}
}
/// <summary>Dispatches one frame, so that what goes wrong with one session stays with that session.</summary>
/// <remarks>
/// Every session shares this socket, and an exception escaping the receive loop closes it — so a resize
/// that reached a shell SSH.NET had already disposed, which is an ordinary moment at the end of every
/// session, used to drop every other tab to "reconnecting" and lose the output sent meanwhile. A frame
/// that cannot be acted on is a frame not acted on; the session it was for reports its own ending.
/// </remarks>
private void DispatchSafely(ReadOnlySpan<byte> frame)
{
try
{
Dispatch(frame);
}
catch (Exception exception) when (exception is not OutOfMemoryException and not OperationCanceledException)
{
// See the remarks.
}
}
/// <summary>Writes one input frame, without letting a session that is going away fault anything.</summary>
private async Task WriteInputAsync(TerminalSessionPump pump, byte[] input)
{
try
{
await pump.WriteInputAsync(input, lifetime.Token).ConfigureAwait(false);
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
// The shell ended or the connection dropped between the keystroke and the write. The session's
// own read loop reports that; a keystroke that had nowhere to go has nothing to add.
}
}
@@ -502,7 +576,7 @@ public sealed class TerminalDataPlane : ITerminalTransport, IAsyncDisposable
case TerminalClientOpcode.Input:
// Fire and forget: a blocked write must not stall acknowledgements for other sessions
// sharing this socket.
_ = pump.WriteInputAsync(payload.ToArray(), lifetime.Token).AsTask();
_ = WriteInputAsync(pump, payload.ToArray());
break;
case TerminalClientOpcode.Acknowledge:
@@ -107,6 +107,19 @@ public sealed class TerminalSessionPump : IAsyncDisposable
private readonly OutputBacklog backlog;
private readonly CancellationTokenSource lifetime = new();
/// <summary>One write to the remote at a time, in the order they were asked for.</summary>
/// <remarks>
/// The data plane hands each input frame over without waiting for the one before it — a blocked write
/// must not hold up the acknowledgements of every other session on the socket — so without this, two
/// frames' writes run at once. SSH.NET's <c>ShellStream</c> takes no lock around its write buffer, and its
/// flush sends what is there and then empties it by length: bytes added mid-flush are discarded unsent.
/// A snippet's RUN is its text and then a carriage return as two frames, which is exactly the shape that lost or
/// reordered keystrokes. Waiters on a <see cref="SemaphoreSlim"/> are released in the order they arrived,
/// and the data plane calls this from one receive loop, so arrival order is the order typed.
/// </remarks>
private readonly SemaphoreSlim inputGate = new(1, 1);
private int disposed;
/// <param name="sessionId">Identifies this session in every frame.</param>
@@ -267,9 +280,20 @@ public sealed class TerminalSessionPump : IAsyncDisposable
}
}
/// <summary>Forwards keystrokes to the remote.</summary>
public ValueTask WriteInputAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken) =>
session.WriteAsync(data, cancellationToken);
/// <summary>Forwards keystrokes to the remote, after any still being written.</summary>
public async ValueTask WriteInputAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken)
{
await inputGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await session.WriteAsync(data, cancellationToken).ConfigureAwait(false);
}
finally
{
inputGate.Release();
}
}
/// <summary>Tells the remote the terminal was resized.</summary>
public void Resize(TerminalSize size) => session.Resize(size);
@@ -701,7 +701,10 @@ public sealed class FileTransferQueue : IAsyncDisposable
var part = destination + PartSuffix;
var existing = await session.StatAsync(part, cancellationToken).ConfigureAwait(false);
var offset = ResumableLength(entry, existing);
// A bucket writes an object whole, so a part left by a failed attempt is started over rather than
// resumed — and replaced, since writing an object replaces it.
var offset = session.CanWriteFromAnOffset ? ResumableLength(entry, existing) : 0;
if (offset == 0 && existing is not null)
{
@@ -94,6 +94,49 @@ public static class LocalDirectory
/// </remarks>
public static string? Parent(string path) => Path.GetDirectoryName(Path.TrimEndingDirectorySeparator(path));
/// <summary>
/// Where a file another machine named should land inside <paramref name="directory"/>, or null when that
/// name cannot be a file directly inside it.
/// </summary>
/// <remarks>
/// <para>
/// ◆ <b>The name is the remote's, and the remote is not trusted to have chosen one that stays put.</b>
/// A POSIX file name may contain a backslash, a colon or a leading drive letter, and on Windows any of
/// those turns <c>Path.Combine(directory, name)</c> into somewhere else: <c>..\..\AppData\…\Startup\x.bat</c>
/// climbs out, and <c>C:\…</c> is rooted, so <see cref="Path.Combine(string, string)"/> drops the
/// directory altogether. An S3 key is worse, because <c>../</c> is an ordinary prefix there on every
/// platform. Whoever can write a name into a shared directory or bucket would otherwise choose where a
/// download writes on this machine.
/// </para>
/// <para>
/// Characters this platform cannot put in a file name are replaced rather than refused, so a Linux file
/// called <c>backup:2026-10-02</c> still downloads to Windows, as <c>backup_2026-10-02</c>. What is
/// refused is a name that is nothing but a reference to here or above. The final check resolves the path
/// and requires its parent to be the directory, which also catches what Windows does to a trailing dot
/// or space — <c>.. </c> is <c>..</c> by the time it reaches the disk.
/// </para>
/// </remarks>
public static string? ChildPath(string directory, string name)
{
ArgumentNullException.ThrowIfNull(directory);
ArgumentNullException.ThrowIfNull(name);
var cleaned = string.Join('_', name.Split(Path.GetInvalidFileNameChars()));
if (cleaned.Trim() is "" or "." or "..")
{
return null;
}
var root = Path.TrimEndingDirectorySeparator(Path.GetFullPath(directory));
var child = Path.GetFullPath(Path.Combine(root, cleaned));
return string.Equals(Path.GetDirectoryName(child), root, StringComparison.Ordinal)
&& !string.Equals(child, root, StringComparison.Ordinal)
? child
: null;
}
/// <summary>
/// Where the local pane can start from: the drives on Windows, and the root elsewhere.
/// </summary>
+3
View File
@@ -28,6 +28,9 @@ public static class ProblemCodes
/// <summary>The caller has not yet enrolled a public key, so no vault is reachable.</summary>
public const string EnrollmentRequired = "enrollment-required";
/// <summary>The caller's account has been suspended or deprovisioned by an operator.</summary>
public const string AccountNotActive = "account-not-active";
/// <summary>Enrollment was attempted for a user who already holds a different current key.</summary>
public const string AlreadyEnrolled = "already-enrolled";
@@ -1,6 +1,7 @@
#nullable enable
const DodoSSH.Contracts.ProblemCodes.AlreadyEnrolled = "already-enrolled" -> string!
const DodoSSH.Contracts.ProblemCodes.ClientTooOld = "client-too-old" -> string!
const DodoSSH.Contracts.ProblemCodes.AccountNotActive = "account-not-active" -> string!
const DodoSSH.Contracts.ProblemCodes.EnrollmentRequired = "enrollment-required" -> string!
const DodoSSH.Contracts.ProblemCodes.EventsUnavailable = "events-unavailable" -> string!
const DodoSSH.Contracts.ProblemCodes.Forbidden = "forbidden" -> string!
@@ -192,6 +192,69 @@ public sealed class IdentityEndpointTests(ApiFixture fixture)
user.EnrolledAtUtc.ShouldNotBeNull();
}
/// <remarks>
/// Suspended means "sign-in blocked", and nothing used to block it: the identity provider still vouches
/// for the person, and every endpoint resolved them by issuer and subject alone. The status is this
/// server's decision, so this server has to be the one refusing — on its own endpoints and on vaults.
/// </remarks>
[Fact]
public async Task ASuspendedAccount_IsRefusedEverywhereWithACodeSayingWhy()
{
using var enrollment = NewEnrollment();
var client = enrollment.CreateClient(fixture);
await EnrollAsync(client, enrollment.Build());
await using (var scope = fixture.CreateScope())
{
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
await database.Users
.Where(u => u.Subject == enrollment.Subject)
.ExecuteUpdateAsync(setters => setters.SetProperty(u => u.Status, UserStatus.Suspended));
}
await ShouldBeProblemAsync(
await client.GetAsync(new Uri(MeUrl, UriKind.Relative)),
HttpStatusCode.Forbidden,
ProblemCodes.AccountNotActive);
await ShouldBeProblemAsync(
await client.PostContractAsync(
$"/api/v1/vaults/{enrollment.VaultId}/sync/pull", new SyncPullRequest(null, null, null)),
HttpStatusCode.Forbidden,
ProblemCodes.AccountNotActive);
}
/// <remarks>
/// The last-seen stamp is written past the change tracker once an hour, which moves the row's
/// concurrency token under the entity the request is holding. Enrolment then saved that same user and
/// failed on the stale token — so anyone who signed in, left, and came back an hour later to finish
/// setting up got a 500 on their first try.
/// </remarks>
[Fact]
public async Task Enroll_MoreThanAnHourAfterFirstSigningIn_Succeeds()
{
using var enrollment = NewEnrollment();
var client = enrollment.CreateClient(fixture);
(await client.GetAsync(new Uri(MeUrl, UriKind.Relative))).EnsureSuccessStatusCode();
await using (var scope = fixture.CreateScope())
{
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
await database.Users
.Where(u => u.Subject == enrollment.Subject)
.ExecuteUpdateAsync(setters => setters.SetProperty(
u => u.LastSeenAtUtc, TimeProvider.System.GetUtcNow().AddHours(-2)));
}
var response = await EnrollAsync(client, enrollment.Build());
response.KeyGeneration.ShouldBe(1);
}
[Fact]
public async Task Enroll_AppendsToTheKeyLogAndTheEntryReproducesItsOwnHash()
{
@@ -276,6 +276,52 @@ public sealed class SyncEndpointTests(ApiFixture fixture)
second.Changes.Count.ShouldBe(1);
}
/// <summary>
/// An item edited several times is pulled once, at the revision its ciphertext is sealed at.
/// </summary>
/// <remarks>
/// The table holds only the newest payload, and its AAD binds the newest version. Hydrating each older
/// log entry with it paired revision 1 or 2 with a payload sealed at 3, which no client can open — and a
/// client that could not open it parked its own pending edit as undecryptable.
/// </remarks>
[Fact]
public async Task Pull_OfAnItemEditedSinceItsEarlierEntries_SendsOnlyItsCurrentRevision()
{
var (subject, vaultId) = await SeedUserWithVaultAsync();
var client = fixture.CreateClientFor(subject);
var create = NewCreateBatch();
var entityId = create.Operations[0].EntityId;
await client.PostContractAsync(PushUrl(vaultId), create);
await client.PostContractAsync(PushUrl(vaultId), new SyncPushRequest(
[
NewOperation(entityId, expectedVersion: 1, envelope: [2, 2, 2]),
]));
await client.PostContractAsync(PushUrl(vaultId), new SyncPushRequest(
[
NewOperation(entityId, expectedVersion: 2, envelope: [3, 3, 3]),
]));
var pulled = await (await client.PostContractAsync(
PullUrl(vaultId),
new SyncPullRequest(null, null, null))).Content.ReadContractAsync<SyncPullResponse>();
pulled.ShouldNotBeNull();
var change = pulled.Changes.ShouldHaveSingleItem();
change.Version.ShouldBe(3);
change.Payload!.Envelope.ShouldBe([3, 3, 3]);
// And the cursor is past all three entries, so the next pull does not offer the old ones again.
var next = await (await client.PostContractAsync(
PullUrl(vaultId),
new SyncPullRequest(pulled.NextCursor, null, null))).Content.ReadContractAsync<SyncPullResponse>();
next!.Changes.ShouldBeEmpty();
}
[Fact]
public async Task Pull_WithACursorFromAnotherVault_Is400()
{
@@ -368,6 +368,42 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
.ShouldNotContain(row => row.TeamId == team.TeamId, "the team existed to carry that vault");
}
/// <remarks>
/// The archive endpoint refuses an admin, and deleting a team's last vault archives the team too — so
/// without the same rule there, an admin could archive the team out from under its owner one vault at a
/// time, revoking the owner's membership on the way. The delete is theirs to make; the team is not.
/// </remarks>
[Fact]
public async Task AnAdminDeletingTheLastVault_DeletesItButLeavesTheTeamToItsOwner()
{
var owner = await EnrolledClientAsync("vault-delete-admin-owner", "vdaowner@example.com");
var admin = await EnrolledClientAsync("vault-delete-admin-admin", "vdaadmin@example.com");
var team = await CreateTeamAsync(owner, "Owned");
var vaultId = await CreateVaultAsync(owner, team.TeamId);
var entry = await LookupAsync(owner, "vdaadmin@example.com");
await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Admin);
await owner.PostContractAsync(
$"/api/v1/vaults/{vaultId}/grants", GrantRequest(entry, generation: 1));
(await DeleteAsync(admin, VaultUrl(vaultId))).StatusCode.ShouldBe(HttpStatusCode.NoContent);
(await ReadAsync<MeResponse>(owner, MeUrl)).Vaults
.ShouldNotContain(vault => vault.VaultId == vaultId);
(await ReadAsync<IReadOnlyList<TeamSummary>>(owner, TeamsUrl))
.ShouldContain(row => row.TeamId == team.TeamId, "only the owner archives a team");
var ownerMe = await ReadAsync<MeResponse>(owner, MeUrl);
(await ReadAsync<IReadOnlyList<TeamMemberSummary>>(owner, MembersUrl(team.TeamId)))
.Where(row => row.UserId == ownerMe.UserId)
.ShouldHaveSingleItem()
.Role.ShouldBe(TeamMemberRole.Owner);
}
/// <remarks>
/// A team carrying several vaults keeps its own life when one of them goes, which is the same line the
/// rename draws — and the other vault has to survive, which is the assertion that a deletion aimed at
@@ -0,0 +1,39 @@
using DodoSSH.Client.App.Views;
namespace DodoSSH.Client.App.Layout.Tests;
/// <summary>
/// The Linux device-key PIN window, which no other test constructs.
/// </summary>
/// <remarks>
/// It named two palette resources that have never existed, <c>Surface</c> and <c>MutedText</c>, and a
/// <c>StaticResource</c> that cannot be found throws when the window is built — so on the one platform that
/// shows it, registering or unlocking with the TPM failed before any PIN was asked for. Only constructing it
/// against the real palette can catch that, which is what the harness's application provides.
/// </remarks>
public sealed class DevicePinWindowTests
{
private static CancellationToken Token => TestContext.Current.CancellationToken;
[Fact]
public async Task TheWindowBuildsAgainstThePalette()
{
await LayoutHarness.OnTheUiThreadAsync(
() =>
{
var window = new DevicePinWindow();
try
{
LayoutHarness.Settle(window, window.Width, 240);
LayoutHarness.Unreachable(window).ShouldBeEmpty();
}
finally
{
window.Close();
}
},
Token);
}
}
@@ -0,0 +1,48 @@
using DodoSSH.Client.App.Platform;
using DodoSSH.Client.Session;
namespace DodoSSH.Client.App.Tests;
/// <summary>
/// <c>DeviceKeyNames</c>: which hardware key a profile's device unlock is sealed to.
/// </summary>
/// <remarks>
/// The stores themselves cannot be exercised here — every call past the probe raises a Windows Hello or
/// Touch ID dialog — so the rule that keeps two installations off one key is tested on its own. It is the
/// whole of what stops signing out of the nightly deleting the key the release build unlocks with.
/// </remarks>
public sealed class DeviceKeyNamesTests
{
private const string Legacy = "DodoSSH.DeviceKey.v1";
[Fact]
public void TheDefaultProfile_KeepsTheNameEveryExistingInstallIsSealedTo() =>
DeviceKeyNames.For(ClientPaths.Default, Legacy).ShouldBe(Legacy);
[Fact]
public void TheNightlyProfile_HasAKeyOfItsOwn()
{
var nightly = DeviceKeyNames.For(ClientPaths.ForChannel("nightly"), Legacy);
nightly.ShouldNotBe(Legacy);
nightly.ShouldStartWith(Legacy + ".");
}
[Fact]
public void TwoProfilesWhoseFoldersShareAName_HaveDifferentKeys()
{
var first = new ClientPaths(Path.Combine(Path.GetTempPath(), "one", "DodoSSH"));
var second = new ClientPaths(Path.Combine(Path.GetTempPath(), "two", "DodoSSH"));
DeviceKeyNames.For(first, Legacy).ShouldNotBe(DeviceKeyNames.For(second, Legacy));
}
[Fact]
public void OneProfile_AlwaysNamesTheSameKey()
{
var directory = Path.Combine(Path.GetTempPath(), "portable", "DodoSSH");
DeviceKeyNames.For(new ClientPaths(directory), Legacy)
.ShouldBe(DeviceKeyNames.For(new ClientPaths(directory + Path.DirectorySeparatorChar), Legacy));
}
}
@@ -0,0 +1,70 @@
using DodoSSH.Client.App.Platform;
namespace DodoSSH.Client.App.Tests;
/// <summary>
/// <c>LocalEditor</c>: a remote file opened for editing is never run.
/// </summary>
/// <remarks>
/// The file's name was chosen by whoever can write to the host, so the question for every name here is
/// whether the desktop's default action would execute it. Only the Windows rule is exercised directly — it
/// is a pure function of the name and <c>PATHEXT</c> — while what each platform starts is checked on the
/// platform the suite is running on.
/// </remarks>
public sealed class LocalEditorTests
{
[Theory]
[InlineData("deploy.bat")]
[InlineData("DEPLOY.CMD")]
[InlineData("notes.hta")]
[InlineData("setup.js")]
[InlineData("link.lnk")]
[InlineData("run.ps1")]
[InlineData("settings.reg")]
[InlineData("deploy.bat.")]
[InlineData("deploy.bat ")]
public void AFileWindowsWouldRun_IsRecognised(string name) =>
LocalEditor.WouldRunOnWindows(name, pathExt: string.Empty).ShouldBeTrue();
[Theory]
[InlineData("nginx.conf")]
[InlineData("authorized_keys")]
[InlineData(".bashrc")]
[InlineData("app.json")]
[InlineData("bat")]
public void AnOrdinaryConfigFile_IsLeftToItsAssociation(string name) =>
LocalEditor.WouldRunOnWindows(name, pathExt: string.Empty).ShouldBeFalse();
[Fact]
public void WhateverPathExtAdds_IsTreatedAsRunnableToo() =>
LocalEditor.WouldRunOnWindows("tool.foo", pathExt: ".COM;.EXE;.FOO").ShouldBeTrue();
[Fact]
public void ARunnableFile_IsNeverHandedToTheDefaultOpenAction()
{
var path = Path.Combine(Path.GetTempPath(), "deploy.bat");
if (OperatingSystem.IsLinux())
{
// No portable editor to name, and a .bat is not something a Linux desktop runs.
LocalEditor.StartInfoFor(path).UseShellExecute.ShouldBeTrue();
Should.Throw<InvalidOperationException>(() => LocalEditor.StartInfoFor("/tmp/app.desktop"));
return;
}
var start = LocalEditor.StartInfoFor(OperatingSystem.IsMacOS() ? "/tmp/run.command" : path);
// Either the type's own "edit" verb, or a named editor with the file as an argument — never the
// file itself under its default verb.
(start.Verb == "edit" || !start.UseShellExecute).ShouldBeTrue();
}
[Fact]
public void AnOrdinaryFile_StillOpensWithItsAssociation()
{
var start = LocalEditor.StartInfoFor(Path.Combine(Path.GetTempPath(), "nginx.conf"));
start.UseShellExecute.ShouldBeTrue();
start.Verb.ShouldBeEmpty();
}
}
@@ -1871,6 +1871,31 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
/// connect and restored afterwards — and the dial itself goes through the ordinary connect path, so the
/// host key gate and the log entry are the ones every other connection gets.
/// </remarks>
/// <remarks>
/// A sync pass reloads the vault, which empties its host list and fills it again — and the picker used to
/// re-find its selection from the empty moment, landing on the first host every minute. CONNECT then
/// dialled that host with the password typed for the one chosen.
/// </remarks>
[Fact]
public async Task TheFilesHostPicker_KeepsTheChosenHostAndItsPasswordAcrossAVaultReload()
{
var vault = await ReadyToConnectAsync();
await AddHostAsync(vault, "staging-db");
shell.Transfers.Attach(vault, knownHosts);
var chosen = shell.Transfers.Hosts.Single(row => row.Host.Label == "staging-db");
shell.Transfers.SelectedHost = chosen;
shell.Transfers.TypedPassword = "typed for staging";
shell.Transfers.Hosts[0].ShouldNotBe(chosen, "otherwise falling back to the first proves nothing");
await vault.LoadAsync(Token);
shell.Transfers.SelectedHost.ShouldNotBeNull().EntityId.ShouldBe(chosen.EntityId);
shell.Transfers.TypedPassword.ShouldBe("typed for staging");
}
[Fact]
public async Task ReconnectingALostFileSession_DialsAgainAndGoesBackToTheDirectoryItWasShowing()
{
@@ -5705,6 +5730,16 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
.Single(row => string.Equals(row.Alias, "web-01", StringComparison.Ordinal))
.IsSelected.ShouldBeTrue();
// A keychain busy with something else — a sync pass, a save — used to skip the whole batch and let this
// screen say "Imported 0 host(s)" as though that were the result.
vault.IsBusy = true;
await import.ImportCommand.ExecuteAsync(null);
vault.IsBusy = false;
vault.Hosts.Count.ShouldBe(1);
import.Status.ShouldContain("Nothing was imported");
await import.ImportCommand.ExecuteAsync(null);
var imported = vault.Hosts.Single(row => string.Equals(row.Label, "web-01", StringComparison.Ordinal));
@@ -10628,6 +10663,35 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
shell.Screen.ShouldBe(ShellScreen.Keychain);
}
/// <remarks>
/// LOCK NOW is on the Preferences page. Settings mode hides the main title bar and draws its own, which
/// the lock card then covers — so a lock that stayed in settings mode left a window with nothing to drag,
/// minimise or close, and an unlock that came back to Settings rather than to Hosts.
/// </remarks>
[Fact]
public async Task LockingFromSettings_LeavesSettingsModeAndUnlockingLandsOnHosts()
{
await ReadyToConnectAsync();
shell.ShowScreenCommand.Execute(ShellScreen.Keychain);
shell.EnterSettingsCommand.Execute(SettingsPage.Vaults);
await shell.Vaults.LoadAsync(Token);
shell.Vaults.OpenMembersPanelCommand.Execute(shell.Vaults.Vaults[0]);
shell.Vaults.IsMembersPanelOpen.ShouldBeTrue("otherwise its closing proves nothing");
await shell.LockCommand.ExecuteAsync(null);
shell.State.ShouldBe(ShellState.Locked);
shell.IsSettingsMode.ShouldBeFalse("the lock card needs the main title bar above it");
shell.Vaults.IsMembersPanelOpen.ShouldBeFalse();
shell.Passphrase = Passphrase;
await shell.UnlockCommand.ExecuteAsync(null);
shell.IsSettingsMode.ShouldBeFalse();
shell.Screen.ShouldBe(ShellScreen.Hosts);
}
/// <remarks>
/// Settings mode collapses the terminal the same way any other page does — <see cref="ShellSurface.Page"/>
/// and <see cref="ShellSurface.Terminal"/> are exclusive by construction — and "Back to application" has
@@ -131,6 +131,35 @@ public sealed class TransferQueueingTests : IDisposable
/// SFTP fake that a real connect produces.
/// </para>
/// </remarks>
/// <summary>
/// A name the host chose cannot choose where on this machine the download lands.
/// </summary>
/// <remarks>
/// Backslashes and colons are ordinary in a POSIX file name and are separators and drive letters on
/// Windows, and a slash arrives in a name from an S3 key — so every one of these would have escaped the
/// folder through <c>Path.Combine</c> somewhere. They are queued inside it under a cleaned name, and the
/// bare <c>..</c>, which no cleaning can make a file name, is left on the host and said so.
/// </remarks>
[Fact]
public void ADownloadNamedToEscapeTheFolder_LandsInsideItOrNotAtAll()
{
Connected();
transfers.QueueDownloads(
[
RemoteFile(@"..\..\AppData\Roaming\Microsoft\Windows\Start Menu\Programs\Startup\x.bat"),
RemoteFile(@"C:\Windows\evil.dll"),
RemoteFile("../../escaped.txt"),
RemoteFile(".."),
]);
var landed = Queued().Select(row => row.Transfer.LocalPath).ToList();
landed.Count.ShouldBe(3);
landed.ShouldAllBe(path => Path.GetDirectoryName(path) == directory);
transfers.Status.ShouldContain("1 name points outside the folder");
}
[Fact]
public void TheDownloadButton_GoesThroughTheSamePathAsADrop()
{
@@ -2177,10 +2177,70 @@ public sealed class VaultSharingTests : IAsyncLifetime
vaults.Members.ShouldHaveSingleItem().Role.ShouldBe("OWNER");
}
/// <remarks>
/// A vault chosen while anything else on the screen was running — a reload, a share — went through a busy
/// gate that answered by doing nothing, so its people were never read and the panel kept showing the
/// previous vault's under the new one's name. SHARE and REVOKE then acted on that mixture.
/// </remarks>
[Fact]
public async Task AVaultChosenWhileAReloadIsRunning_ShowsItsOwnPeople()
{
await UnlockedAsync();
var vaults = shell.Vaults;
server.AddAccount("bob@example.com", "Bob Example");
await CreateVaultAsync(vaults, "Alone");
var alone = vaults.SelectedVault!.VaultId;
await CreateVaultAsync(vaults, "Shared with Bob");
var shared = vaults.SelectedVault!.VaultId;
vaults.NewMemberEmail = "bob@example.com";
await vaults.AddMemberCommand.ExecuteAsync(null);
vaults.Members.Count.ShouldBe(2, vaults.Status);
vaults.SelectedVault = vaults.Vaults.Single(row => row.VaultId == alone);
await EventuallyAsync(() => vaults.Members.Count == 1, "the vault on its own has only its owner");
var gate = new TaskCompletionSource();
server.MemberReadGate = gate;
var reload = vaults.LoadAsync(Token);
vaults.IsBusy.ShouldBeTrue("the reload is what holds the screen busy while its read is held");
vaults.SelectedVault = vaults.Vaults.Single(row => row.VaultId == shared);
gate.SetResult();
server.MemberReadGate = null;
await reload;
await EventuallyAsync(() => vaults.Members.Count == 2, "Bob is in the vault now showing");
}
/// <remarks>
/// Through the form rather than straight at the command, because the name is what the form is for —
/// and because the form is now the only way in: there is no separate "make a team" step behind it.
/// </remarks>
/// <summary>Waits for something a fire-and-forget read will make true, within a bound.</summary>
private static async Task EventuallyAsync(Func<bool> condition, string because)
{
var deadline = TimeProvider.System.GetUtcNow().AddSeconds(10);
while (TimeProvider.System.GetUtcNow() < deadline)
{
if (condition())
{
return;
}
await Task.Delay(TimeSpan.FromMilliseconds(20), Token);
}
throw new ShouldAssertException(because);
}
/// <summary>The armour a key is stored in, which this suite never parses and only round-trips.</summary>
private static string PrivateKey(string body) =>
$"-----BEGIN OPENSSH PRIVATE KEY-----\n{body}\n-----END OPENSSH PRIVATE KEY-----\n";
@@ -0,0 +1,69 @@
namespace DodoSSH.Client.Ssh.Tests;
/// <summary>
/// A pin is a pin for the endpoint, whichever key type the server answers with.
/// </summary>
/// <remarks>
/// Pins are stored per algorithm, and the gap these close was that an endpoint whose only pins were for
/// other algorithms read as a host never seen before. Somebody in the middle needed nothing more than a key
/// of a different type to turn the hard refusal a pin exists for into a first-use question the user is
/// invited to click through.
/// </remarks>
[Collection(SshCollection.Name)]
public sealed class HostKeyPinningTests(SshServerFixture fixture)
{
private static CancellationToken Token => TestContext.Current.CancellationToken;
/// <summary>
/// The endpoint is pinned under a key type this server does not have, which is exactly what a server in
/// the middle looks like from here: the pinned key is not on offer, and a key of another type is.
/// </summary>
[Fact]
public async Task AKeyOfAnotherTypeOnAPinnedEndpoint_IsAMismatchRatherThanAnUnknownHost()
{
var knownHosts = new InMemoryKnownHostStore();
await knownHosts.TrustAsync(
new HostKeyPresentation(fixture.Host, fixture.Port, "ssh-dss", "SHA256:the-key-that-was-approved"),
Token);
var mismatch = await Should.ThrowAsync<SshHostKeyMismatchException>(async () =>
await new SshNetConnectionFactory(knownHosts).ConnectAsync(Request(), progress: null, Token));
mismatch.PinnedFingerprint.ShouldBe("SHA256:the-key-that-was-approved");
}
/// <summary>
/// The same public key under another algorithm's name — an RSA key signs as <c>rsa-sha2-256</c> or
/// <c>rsa-sha2-512</c> — is the key that was approved, and its fingerprint is what says so.
/// </summary>
[Fact]
public async Task TheApprovedKeyUnderAnotherAlgorithmName_IsStillTrusted()
{
var learned = new InMemoryKnownHostStore();
var unknown = await Should.ThrowAsync<SshHostKeyUnknownException>(async () =>
await new SshNetConnectionFactory(learned).ConnectAsync(Request(), progress: null, Token));
var knownHosts = new InMemoryKnownHostStore();
await knownHosts.TrustAsync(unknown.Presentation with { Algorithm = "x-renamed-algorithm" }, Token);
await using var connection = await new SshNetConnectionFactory(knownHosts)
.ConnectAsync(Request(), progress: null, Token);
connection.ShouldNotBeNull();
}
[Fact]
public async Task AnEndpointWithNoPinsAtAll_IsStillAnUnknownHost() =>
await Should.ThrowAsync<SshHostKeyUnknownException>(async () =>
await new SshNetConnectionFactory(new InMemoryKnownHostStore())
.ConnectAsync(Request(), progress: null, Token));
private SshConnectionRequest Request() => new(
fixture.Host,
fixture.Port,
SshServerFixture.Username,
new SshPasswordCredential(SshServerFixture.Password));
}
@@ -173,7 +173,7 @@ public sealed class OutboxStoreTests : IAsyncLifetime
var merged = new StoredAncestor(4, Payload(seed: 20), new SyncPlaintextFields());
var revised = await harness.Outbox.ReviseAsync(
queued.Sequence,
queued,
SyncOperation.Upsert,
expectedVersion: 4,
Payload(seed: 30),
@@ -199,7 +199,7 @@ public sealed class OutboxStoreTests : IAsyncLifetime
var queued = await harness.Outbox.QueueAsync(Change(parked), Token);
await harness.Outbox.QueueAsync(Change(healthy), Token);
await harness.Outbox.ParkAsync(queued.Sequence, "Entity type not supported.", Token);
await harness.Outbox.ParkAsync(queued, "Entity type not supported.", Token);
var pending = await harness.Outbox.TakeAsync(VaultId, 10, Token);
pending.ShouldHaveSingleItem().EntityId.ShouldBe(healthy);
@@ -216,7 +216,7 @@ public sealed class OutboxStoreTests : IAsyncLifetime
var entityId = Guid.CreateVersion7();
var queued = await harness.Outbox.QueueAsync(Change(entityId), Token);
await harness.Outbox.ParkAsync(queued.Sequence, "nope", Token);
await harness.Outbox.ParkAsync(queued, "nope", Token);
var requeued = await harness.Outbox.QueueAsync(Change(entityId, seed: 5), Token);
@@ -230,11 +230,42 @@ public sealed class OutboxStoreTests : IAsyncLifetime
{
var queued = await harness.Outbox.QueueAsync(Change(Guid.CreateVersion7()), Token);
(await harness.Outbox.CompleteAsync(queued.Sequence, Token)).ShouldBeTrue();
(await harness.Outbox.CompleteAsync(queued, Token)).ShouldBeTrue();
(await harness.Outbox.TakeAsync(VaultId, 10, Token)).ShouldBeEmpty();
// Idempotent: a drain that retries after a crash must not fail on an already-cleared row.
(await harness.Outbox.CompleteAsync(queued.Sequence, Token)).ShouldBeFalse();
(await harness.Outbox.CompleteAsync(queued, Token)).ShouldBeFalse();
}
/// <summary>
/// An answer to an operation that has since been replaced by a newer edit applies to nothing.
/// </summary>
/// <remarks>
/// The newer edit coalesced into the same row, so the sequence still matches; only the operation id says
/// the row is no longer the one that was sent. Completing, revising or parking it would act on the newer
/// edit on the strength of an answer about the older one.
/// </remarks>
[Fact]
public async Task AnAnswerToAReplacedOperation_LeavesTheNewerEditAlone()
{
var entityId = Guid.CreateVersion7();
var sent = await harness.Outbox.QueueAsync(Change(entityId, seed: 1), Token);
var newer = await harness.Outbox.QueueAsync(Change(entityId, seed: 2), Token);
newer.Sequence.ShouldBe(sent.Sequence, "the newer edit coalesces into the same row");
(await harness.Outbox.CompleteAsync(sent, Token)).ShouldBeFalse();
await harness.Outbox.ParkAsync(sent, "refused", Token);
(await harness.Outbox.ReviseAsync(
sent, sent.Operation, sent.ExpectedVersion, sent.Payload, sent.Fields, sent.Ancestor, Token))
.ShouldBeNull();
var left = (await harness.Outbox.TakeAsync(VaultId, 10, Token)).ShouldHaveSingleItem();
left.OperationId.ShouldBe(newer.OperationId);
left.IsParked.ShouldBeFalse();
}
[Fact]
@@ -231,4 +231,68 @@ public sealed class SyncEngineTests
async () => await harness.First.Hosts.ListAsync(
unknown, TestContext.Current.CancellationToken));
}
/// <summary>
/// A save made while the previous save of the same host is on its way to the server survives that push
/// being acknowledged, and reaches the server as the newer state rather than as a conflict.
/// </summary>
/// <remarks>
/// The outbox coalesces the second save into the row the push read. Acknowledging by sequence alone
/// deleted that row — the newer state with it — and the server and both machines kept the older one.
/// </remarks>
[Fact]
public async Task ASaveMadeWhileAPushIsInFlight_IsNotLostWhenThatPushIsAcknowledged()
{
using var harness = await CreateAsync();
var id = await harness.First.CreateAsync(Host("prod-db", hostname: "first.internal"));
await harness.First.SyncAsync();
await harness.First.UpdateAsync(id, Host("prod-db", hostname: "second.internal"));
harness.Server.OnPush = () => harness.First
.UpdateAsync(id, Host("prod-db", hostname: "third.internal"))
.GetAwaiter()
.GetResult();
await harness.First.SyncAsync();
await harness.First.SyncAsync();
(await harness.First.FindAsync(id)).Secret.Hostname.ShouldBe("third.internal");
(await harness.First.ConflictsAsync()).ShouldBeEmpty();
await harness.Second.SyncAsync();
(await harness.Second.FindAsync(id)).Secret.Hostname.ShouldBe("third.internal");
}
/// <summary>
/// A cursor read under a shorter list of item types starts the pull over, once.
/// </summary>
/// <remarks>
/// The cursor a pull returns is past every change it read, including the changes of types it did not ask
/// for. A build that adds a type to the list — SpelunkKey did, ObjectStore before it — and carried on from
/// that cursor would never receive the items of the new type made before it arrived.
/// </remarks>
[Fact]
public async Task ACursorReadUnderAnotherListOfTypes_StartsThePullOverOnce()
{
using var harness = await CreateAsync();
await harness.First.CreateAsync(Host("prod-db"));
await harness.First.SyncAsync();
var state = await harness.First.SyncState.ReadAsync(VaultId, TestContext.Current.CancellationToken);
state.Cursor.ShouldNotBeNull();
state.PulledTypes.ShouldNotBeNull();
// What an older build, which pulled fewer types, would have left behind.
await harness.First.SyncState.SaveAsync(
state with { PulledTypes = "1" }, TestContext.Current.CancellationToken);
(await harness.First.SyncAsync()).ResyncedFromStart.ShouldBeTrue();
(await harness.First.SyncAsync()).ResyncedFromStart.ShouldBeFalse("the list is recorded with the cursor");
(await harness.First.ListAsync()).Items.ShouldHaveSingleItem();
}
}
@@ -78,6 +78,14 @@ internal sealed class FakeShellSession : ISshShellSession
/// <summary>The last size the pump forwarded, or null if it forwarded none.</summary>
public TerminalSize? LastResize { get; private set; }
/// <summary>How long the first write takes, so a test can see whether a second one overtakes it.</summary>
public TimeSpan FirstWriteDelay { get; init; }
/// <summary>Whether a resize throws, as SSH.NET's does once it has disposed the shell's stream.</summary>
public bool ResizeThrows { get; init; }
private int writes;
/// <summary>How many resizes were forwarded, so a dropped one is observable.</summary>
public int ResizeCount { get; private set; }
@@ -119,19 +127,27 @@ internal sealed class FakeShellSession : ISshShellSession
}
/// <inheritdoc />
public ValueTask WriteAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken)
public async ValueTask WriteAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken)
{
if (Interlocked.Increment(ref writes) == 1 && FirstWriteDelay > TimeSpan.Zero)
{
await Task.Delay(FirstWriteDelay, cancellationToken).ConfigureAwait(false);
}
lock (gate)
{
written.AddRange(data.ToArray());
}
return ValueTask.CompletedTask;
}
/// <inheritdoc />
public void Resize(TerminalSize size)
{
if (ResizeThrows)
{
throw new ObjectDisposedException("ShellStream");
}
// Mirrors the real session: an unusable size is dropped rather than forwarded.
if (!size.IsUsable)
{
@@ -285,6 +285,86 @@ public sealed class TerminalDataPlaneTests : IAsyncDisposable
Encoding.UTF8.GetString(session.Written).ShouldBe("whoami\r");
}
/// <summary>
/// A paste is one input frame however long it is, and one longer than a single read used to be dropped
/// — or worse, its tail parsed as a frame of its own.
/// </summary>
[Fact]
public async Task APasteLargerThanOneRead_ReachesTheRemoteWhole()
{
Start();
using var socket = await ConnectAsync();
await using var session = new FakeShellSession();
await using var pump = CreatePump(session);
plane.Register(SessionId, pump);
var paste = new byte[300 * 1024];
Random.Shared.NextBytes(paste);
await SendAsync(socket, (byte)TerminalClientOpcode.Input, paste);
await WaitUntilAsync(() => session.Written.Length >= paste.Length);
session.Written.ShouldBe(paste);
}
/// <summary>
/// Frames are handed over without waiting for the write before them, so a slow write must still be
/// followed rather than overtaken: a snippet's RUN is its text and then a carriage return.
/// </summary>
[Fact]
public async Task InputFrames_AreWrittenInTheOrderTheyArrived_EvenWhenAWriteIsSlow()
{
Start();
using var socket = await ConnectAsync();
await using var session = new FakeShellSession { FirstWriteDelay = TimeSpan.FromMilliseconds(300) };
await using var pump = CreatePump(session);
plane.Register(SessionId, pump);
await SendAsync(socket, (byte)TerminalClientOpcode.Input, "sudo systemctl restart api"u8.ToArray());
await SendAsync(socket, (byte)TerminalClientOpcode.Input, "\r"u8.ToArray());
await WaitUntilAsync(() => session.Written.Length >= 27);
Encoding.UTF8.GetString(session.Written).ShouldBe("sudo systemctl restart api\r");
}
/// <summary>
/// A resize that reaches a shell SSH.NET has already disposed is an ordinary moment at the end of a
/// session, and it must not close the socket every other session shares.
/// </summary>
[Fact]
public async Task AResizeThatThrows_LeavesTheSocketWorkingForEverythingElse()
{
Start();
using var socket = await ConnectAsync();
await using var ending = new FakeShellSession { ResizeThrows = true };
await using var endingPump = CreatePump(ending);
plane.Register(SessionId, endingPump);
await using var other = new FakeShellSession();
await using var otherPump = CreatePump(other);
plane.Register(SessionId + 1, otherPump);
await SendAsync(
socket,
(byte)TerminalClientOpcode.Resize,
TerminalFrame.CreateResizePayload(new TerminalSize(80, 24, 800, 600)));
await SendAsync(socket, (byte)TerminalClientOpcode.Input, "still here"u8.ToArray(), SessionId + 1);
await WaitUntilAsync(() => other.Written.Length > 0);
Encoding.UTF8.GetString(other.Written).ShouldBe("still here");
socket.State.ShouldBe(WebSocketState.Open);
}
[Fact]
public async Task AResize_ReachesTheRemote()
{
@@ -93,6 +93,15 @@ internal sealed class FakeSftpSession : ISftpSession
/// </remarks>
public int? FailReadAfter { get; set; }
/// <summary>
/// Whether a write may start anywhere but zero. False makes this behave like a bucket, which refuses an
/// offset — and, like a bucket, still keeps whatever an abandoned write had sent when it is closed.
/// </summary>
public bool CanWriteFromAnOffset { get; set; } = true;
/// <summary>Fails the next write after this many bytes, once, the way a dropped upload does.</summary>
public int? FailWriteAfter { get; set; }
/// <summary>The offsets <see cref="OpenReadAsync"/> was asked to start at, in order.</summary>
/// <remarks>
/// The only direct evidence that a resume resumed. A test can see the right bytes on disk at the end
@@ -195,11 +204,19 @@ internal sealed class FakeSftpSession : ISftpSession
/// <inheritdoc />
public Task<Stream> OpenWriteAsync(string path, long offset, CancellationToken cancellationToken)
{
if (!CanWriteFromAnOffset && offset != 0)
{
throw new SftpPathException(path, "An object cannot be written to from the middle.");
}
lock (gate)
{
var existing = files.GetValueOrDefault(path, []);
var failAfter = FailWriteAfter;
FailWriteAfter = null;
return Task.FromResult<Stream>(new CommittingStream(
failAfter,
existing.AsSpan(0, (int)Math.Min(offset, existing.Length)).ToArray(),
content =>
{
@@ -300,10 +317,42 @@ internal sealed class FakeSftpSession : ISftpSession
}
/// <summary>A write that lands on the fake host when it is disposed.</summary>
private sealed class CommittingStream(byte[] prefix, Action<byte[]> commit) : MemoryStream()
private sealed class CommittingStream(int? failAfter, byte[] prefix, Action<byte[]> commit) : MemoryStream()
{
private bool committed;
public override void Write(ReadOnlySpan<byte> buffer)
{
FailIfDue(buffer.Length);
base.Write(buffer);
}
public override void Write(byte[] buffer, int offset, int count)
{
FailIfDue(count);
base.Write(buffer, offset, count);
}
public override ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, CancellationToken cancellationToken = default)
{
FailIfDue(buffer.Length);
return base.WriteAsync(buffer, cancellationToken);
}
public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
{
FailIfDue(count);
return base.WriteAsync(buffer, offset, count, cancellationToken);
}
private void FailIfDue(int count)
{
if (failAfter is { } limit && Length + count > limit)
{
throw new IOException("The connection dropped mid-upload.");
}
}
public override void Close()
{
if (!committed)
@@ -145,6 +145,37 @@ public sealed class FileTransferQueueTests : IDisposable
host.Exists(Remote("artefact.tar") + FileTransferQueue.PartSuffix).ShouldBeFalse();
}
/// <summary>
/// A bucket writes an object whole, so a retried upload starts again rather than resuming.
/// </summary>
/// <remarks>
/// The failed attempt leaves a part object behind — a bucket keeps what was sent when the stream closes —
/// and the retry used to ask for the offset that part had reached. A bucket refuses any offset, so every
/// retry failed until the row was discarded, under a message saying it would start again.
/// </remarks>
[Fact]
public async Task ARetriedUploadToAStoreThatCannotResume_StartsAgainAndLands()
{
var content = Bytes(300_000);
await File.WriteAllBytesAsync(Local("artefact.tar"), content, Token);
host.CanWriteFromAnOffset = false;
host.FailWriteAfter = 100_000;
await using var queue = NewQueue();
var broken = await RunToCompletionAsync(
queue, TransferDirection.Upload, Local("artefact.tar"), Remote("artefact.tar"), content.Length);
broken.State.ShouldBe(TransferState.Failed);
host.Exists(Remote("artefact.tar") + FileTransferQueue.PartSuffix).ShouldBeTrue("what a bucket keeps");
var retried = await AwaitFinishAsync(queue, () => queue.Retry(broken.Id));
retried.State.ShouldBe(TransferState.Completed, retried.Failure);
host.Read(Remote("artefact.tar")).ShouldBe(content);
}
[Fact]
public async Task AnUploadOntoAFileThatIsAlreadyThere_IsRefusedAndLeavesItAlone()
{
@@ -0,0 +1,68 @@
namespace DodoSSH.Client.Transfer.Tests;
/// <summary>
/// <c>LocalDirectory.ChildPath</c>: where a name another machine chose may land on this one.
/// </summary>
/// <remarks>
/// The assertion that matters is the same in every row — the result's parent is the directory asked about,
/// or there is no result — because that is the property a download relies on, and it is checked against the
/// resolved path rather than the string so that what the file system does to a name counts too.
/// </remarks>
public sealed class LocalDirectoryTests
{
private static readonly string Directory = Path.Combine(Path.GetTempPath(), "dodossh-child-path");
[Theory]
[InlineData("notes.txt")]
[InlineData("backup:2026-10-02")]
[InlineData(@"..\..\AppData\Roaming\Microsoft\Windows\Start Menu\Programs\Startup\x.bat")]
[InlineData(@"C:\Windows\evil.dll")]
[InlineData("../../escaped.txt")]
[InlineData("/etc/passwd")]
[InlineData("...")]
[InlineData(".hidden")]
public void ANameThatCanBeAFileHere_LandsDirectlyInsideTheDirectory(string name)
{
var child = LocalDirectory.ChildPath(Directory, name);
if (child is null)
{
// Only acceptable for "...", which Windows reduces to nothing; everywhere else it is a name.
OperatingSystem.IsWindows().ShouldBeTrue($"'{name}' was refused");
name.ShouldBe("...");
return;
}
Path.GetDirectoryName(child).ShouldBe(Path.GetFullPath(Directory));
}
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData(".")]
[InlineData("..")]
public void ANameThatIsOnlyHereOrAbove_IsRefused(string name) =>
LocalDirectory.ChildPath(Directory, name).ShouldBeNull();
/// <summary>
/// Windows drops a trailing dot or space from a path component, so <c>.. </c> is <c>..</c> by the time
/// it reaches the disk — which a check of the string alone would have let through.
/// </summary>
[Fact]
public void ATrailingSpaceOrDot_DoesNotSmuggleAParentReferencePastTheCheck()
{
foreach (var name in new[] { ".. ", "...", ".. .", ". " })
{
var child = LocalDirectory.ChildPath(Directory, name);
if (child is not null)
{
Path.GetDirectoryName(child).ShouldBe(Path.GetFullPath(Directory), name);
}
}
}
[Fact]
public void AnOrdinaryName_IsKeptAsItIs() =>
Path.GetFileName(LocalDirectory.ChildPath(Directory, "nginx.conf")).ShouldBe("nginx.conf");
}