Public Access
Free the terminal from the Hosts screen, and fill the room it left
The WebView sat inside the Hosts grid, so navigating to Files or the keychain hid every open terminal and the strip that named them. A connection you had opened was invisible from four of the five screens. The window now has two surfaces rather than one: a nav rail that says which page you are on, and a terminal strip that is always there and switches the whole content area to a shell. Screen keeps meaning "which page" and never becomes a sixth kind of page, which is why this is two properties instead of one enum with a terminal member in it. Every screen lives inside one wrapper panel that collapses when a terminal is showing. That is not tidiness — the WebView hosts a Win32 child window that composites above everything Avalonia draws, so a screen left visible over its rectangle is a screen sliced in half, and this window has shipped that defect once already. One decision point, IsTerminalShowing, and a nested panel rather than five compound bindings nobody would remember to extend. The focus choreography is the part no test in this repo can see. Every reveal path now focuses in the same turn the WebView appeared, so all three of them post at DispatcherPriority.Loaded and let the native control re-push its bounds first. Going the other way had a real bug: the screen-changed branch called a bare Focus() where it had to release the keyboard from the native child, so switching from a terminal to Files silently ate the first keystrokes. Rare before this commit and the primary gesture after it. The tab strip grew a cross inside each tab, a plus that opens the quick-connect palette, and middle-click close. Nested buttons are correct here: Avalonia handles a left press on the cross and deliberately does not handle other buttons, which is exactly what lets middle-click bubble up from the cross as well as the tab. The test is PointerUpdateKind rather than IsMiddleButtonPressed, because the latter reports button state and is also true for a left press made while the middle button happens to be held. The handler is on the tab and not the strip, so the background closes nothing by construction. Plus opens the palette rather than a flyout, since a menu dropping into the WebView's rectangle may or may not composite above a child HWND and this repo does not make rendering claims it has not photographed. Everything a user reads now says keychain. The wire, the database and the cryptographic spec still say vault, deliberately: renaming those is a migration and a protocol change for a word. That split is written down rather than left to be rediscovered as an inconsistency. Four things that were squeezed into the keychain's category rail, or into nothing at all, now have screens. Pinned host keys get one, with fingerprints never truncated and a filter that matches them, because comparing what you have against what the operator published is the whole workflow; the approved date is read out of the item's UUIDv7 rather than added as a column, and says so, since it means first approval and not last use. Keys can be generated in the client, which needed the openssh-key-v1 container written by hand — there is no BCL or NSec helper, and the PKCS#8 route is unverified in the SSH library this uses. The armour carries no passphrase: encrypting it needs bcrypt_pbkdf, which is Blowfish with a swizzle, in a project whose crypto is otherwise entirely libsodium, for a protection the key's own remarks argue is redundant inside a vault. Generation fills the existing editor and stops, so SAVE stays the one thing that writes. ~/.ssh/config can be imported behind a preview that is ticked per row and writes nothing until the button; IdentityFile records the path and imports the key material only on an explicit opt-in, because reading somebody's private key into a vault is precisely the act this product exists to make deliberate. Match blocks and ProxyJump are reported rather than obeyed — one cannot be evaluated statically and the other has nothing behind it to route with, and a preview that implied otherwise would be worse than one that admits it. Files can be dragged in all four directions that are honestly available. Remote to Explorer does not ship and is not pretended to: the shell wants the bytes during the drop, which needs a virtual file and a native COM data object, outside what Avalonia offers. Note for the next person that Avalonia 12 replaced the drag model outright — DataObject and DataFormats are no-op stubs and IDataObject is not in the reference assembly, so every tutorial written for 11 does not compile here. Hosts can be grouped, flat and never nested. A parent id merged as a scalar lets two offline clients each re-parent A under B and B under A, producing a cycle inside an encrypted payload that no server can police and every reader would have to detect for ever. Membership lives in that payload rather than in the one plaintext concession ADR 0001 allows, whose test is that the relay cannot function without it — nothing on the server reads a group, so what plaintext would hand over is a clustering of the estate for nothing. The plaintext column reserved for it is dropped, provably always null, and the server now refuses a client that sends one; it was never populated, was copied on apply, and was not cleared on delete, so a group id would have outlived the host it described. Snippets insert through xterm rather than through the pump, because xterm is the only thing that knows whether the remote has bracketed paste on, and that is what makes a shell treat embedded newlines as text instead of as execute. The host process moves opaque bytes and never parses output, so it would have to guess, and guessing wrong runs every line. Running is off by default and the copy says the text goes into whatever is there — the terminal has no notion of being at a prompt, and may be in vi or at a password prompt with echo off, so the Enter the user presses themselves is the entire safety property. Connections and keychain changes are recorded as synced encrypted items, which is what makes them auditable by a team later and costs the server knowledge of connection rate and timing from row counts alone. ADR 0001 already concedes it cannot hide that class of metadata; the trade is now written into it rather than left implicit. A connection entry is written once, at close, which is what makes a synced log tractable: nothing to merge, one outbox row, no chance of colliding with itself. Live sessions come from memory, not from the log. The write is void by contract and posts to a bounded channel, because putting an encrypt-and-write on the teardown path of every session is how closing the application comes to take four seconds. A ticket opened before a lock still closes afterwards, since a shell outlives the vault. The activity log hooks the one generic repository every kind writes through, so it cannot miss a caller — which is also why the log kinds themselves declare they are not audited, or the first entry would write an entry about writing an entry. It records the names of the fields that changed and never their values; a log with an old password in it would be a plaintext credential store with no vault around it. Retention is 90 days or 5,000 entries, whichever bites first, pruned on the sync loop rather than on a second timer. That log traffic then broke the status line, which is worth recording because the fix is a shape and not a patch: background sync counted its own log rows as pushed items, so the quiet rule stopped being quiet and every action's message was overwritten a second later by a sync report. The report now separates log rows from user items and the rule reads the latter. S3 buckets appear as a remote in the file browser, behind the same interface an SFTP session implements, so the queue and both panes did not have to learn what they are talking to. Uploads go through a pipe, because the queue wants to write and the SDK wants to read; memory is then bounded by the part size instead of buffering a file to disk twice. Finally, the Windows device key store moved out of the session project, which was the one thing keeping it from being portable — everything else in it is platform-neutral, and a Windows CNG dependency in the middle of the vault code meant a second head could not reference it without dragging Windows along. The seam that made the move free was already there. docs/android-port.md is the audit behind that: what ports, what does not, in order of cost, the four decisions taken, and an inventory of every screen and state the interface has to carry, written so a design can be made from it directly. dotnet build, dotnet test and dotnet format --verify-no-changes are all clean: 1240 tests at zero warnings, including the end-to-end suite against real containers. The manual checks that headless Avalonia cannot make — the drag from Explorer, a generated key against a real host, twelve tabs at the minimum window width — are listed in docs/manual-checks.md and are still outstanding.
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace DodoSSH.Client.Domain;
|
||||
|
||||
/// <summary>What was done to an item.</summary>
|
||||
public enum ActivityOperation
|
||||
{
|
||||
/// <summary>It was created.</summary>
|
||||
Created = 0,
|
||||
|
||||
/// <summary>It was changed.</summary>
|
||||
Updated = 1,
|
||||
|
||||
/// <summary>It was deleted.</summary>
|
||||
Deleted = 2,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One create, edit or delete of a keychain item, decrypted.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b><see cref="ChangedFields"/> holds names and never values.</b> That is the rule the whole type is built
|
||||
/// around, and it is the same one ADR 0006 imposes on the server's own <c>detail</c> column: an audit log
|
||||
/// that recorded what a password used to be would be a plaintext credential store with a vault drawn around
|
||||
/// it. "Password" is what somebody needs to see; the old password is what nobody does.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b><see cref="ItemLabel"/> is a copy, taken at the time.</b> Deleting the item is one of the three things
|
||||
/// this records, so a lookup would resolve to nothing in exactly the case the entry matters most. It is also
|
||||
/// what makes a rename readable — an entry saying "renamed 'old-db'" is useful, and one saying "renamed
|
||||
/// 'prod-db'" because that is what it is called now is not.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed record ActivityLogSecret : IVaultSecret
|
||||
{
|
||||
/// <summary>What kind of item this was about, as the sync contract names it.</summary>
|
||||
/// <remarks>
|
||||
/// Stored as the wire enum's name rather than its number, so an entry written by a build that knows a
|
||||
/// kind this one does not still reads as something — an unknown name is shown as itself, where an
|
||||
/// unknown number would have to be shown as a number.
|
||||
/// </remarks>
|
||||
public required string ItemKind { get; init; }
|
||||
|
||||
/// <summary>The item, so an entry can be traced to what it was about.</summary>
|
||||
public required Guid ItemId { get; init; }
|
||||
|
||||
/// <summary>What the item was called at the time.</summary>
|
||||
public required string ItemLabel { get; init; }
|
||||
|
||||
/// <summary>What was done.</summary>
|
||||
public ActivityOperation Operation { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The names of the fields that changed, separated by <c>", "</c>. Never their values.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// One string rather than a collection, and the choice is about equality. A plain
|
||||
/// <see cref="IReadOnlyList{T}"/> on a record gets reference equality from the compiler-generated
|
||||
/// <c>Equals</c>, which is the trap <see cref="JumpChain"/> exists to avoid — and a second type of that
|
||||
/// shape is a lot of machinery for a value that is written once and only ever displayed. The separator is
|
||||
/// unambiguous because these are C# property names, which cannot contain one.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Empty for a create and for a delete, where "which fields" has no meaning — every field arrived, or all
|
||||
/// of them went. Empty is also the honest answer when an update's before and after could not be compared,
|
||||
/// which is why nothing reading this may take empty to mean "nothing changed".
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public string ChangedFields { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>When it happened.</summary>
|
||||
public required DateTimeOffset At { get; init; }
|
||||
|
||||
/// <summary>Which machine it was done from, as that machine calls itself.</summary>
|
||||
public required string DeviceName { get; init; }
|
||||
|
||||
/// <summary>Which account in this organisation did it.</summary>
|
||||
public Guid ActorUserId { get; init; }
|
||||
|
||||
/// <summary>What this entry is called, derived from what it records.</summary>
|
||||
/// <inheritdoc cref="KnownHostSecret.Label" path="/remarks" />
|
||||
public string Label => $"{Operation} {ItemLabel}";
|
||||
|
||||
/// <summary>Whether this is storable, and why not if it is not.</summary>
|
||||
public bool TryValidate([NotNullWhen(false)] out string? reason)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ItemKind))
|
||||
{
|
||||
reason = "An activity log entry needs the kind of item it was about.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (ItemId == Guid.Empty)
|
||||
{
|
||||
reason = "An activity log entry needs the item it was about.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(DeviceName))
|
||||
{
|
||||
reason = "An activity log entry needs the machine it was done from.";
|
||||
return false;
|
||||
}
|
||||
|
||||
// The label is deliberately not checked. An item somebody created and never named has an empty one,
|
||||
// and refusing to record that would mean the log's completeness depended on the user's tidiness.
|
||||
reason = null;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DodoSSH.Client.Domain;
|
||||
|
||||
/// <summary>A decoded activity log payload, together with the schema version it was written at.</summary>
|
||||
/// <param name="Entry">The entry.</param>
|
||||
/// <param name="SchemaVersion">The version the writing client used.</param>
|
||||
public sealed record ActivityLogSecretDocument(ActivityLogSecret Entry, int SchemaVersion)
|
||||
{
|
||||
/// <inheritdoc cref="ConnectionLogSecretDocument.IsReadOnly" />
|
||||
public bool IsReadOnly => SchemaVersion > ActivityLogSecretCodec.CurrentSchemaVersion;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encodes and decodes the plaintext inside an activity log entry's encrypted payload.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <see cref="ActivityLogSecret.ItemKind"/> travels as its name and not its number, which is the one thing
|
||||
/// here worth deciding on purpose: item kinds are an open set, so a build that has not heard of the fifth one
|
||||
/// can still show "PortForward" where a number would leave it showing "9".
|
||||
/// </remarks>
|
||||
public static class ActivityLogSecretCodec
|
||||
{
|
||||
/// <summary>The schema version this build writes.</summary>
|
||||
public const int CurrentSchemaVersion = 1;
|
||||
|
||||
/// <summary>Serialises an entry to the bytes that get sealed.</summary>
|
||||
/// <exception cref="ArgumentException">The entry is not valid for storage.</exception>
|
||||
public static byte[] Encode(ActivityLogSecret entry)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entry);
|
||||
|
||||
if (!entry.TryValidate(out var reason))
|
||||
{
|
||||
throw new ArgumentException(reason, nameof(entry));
|
||||
}
|
||||
|
||||
var document = new ActivityLogPayloadDocument
|
||||
{
|
||||
SchemaVersion = CurrentSchemaVersion,
|
||||
ItemKind = entry.ItemKind,
|
||||
ItemId = entry.ItemId,
|
||||
ItemLabel = entry.ItemLabel,
|
||||
Operation = (int)entry.Operation,
|
||||
ChangedFields = entry.ChangedFields.Length == 0 ? null : entry.ChangedFields,
|
||||
At = entry.At,
|
||||
DeviceName = entry.DeviceName,
|
||||
ActorUserId = entry.ActorUserId,
|
||||
};
|
||||
|
||||
return JsonSerializer.SerializeToUtf8Bytes(
|
||||
document, ActivityLogPayloadJsonContext.Default.ActivityLogPayloadDocument);
|
||||
}
|
||||
|
||||
/// <summary>Parses a decrypted payload.</summary>
|
||||
/// <inheritdoc cref="HostSecretCodec.TryDecode" path="/remarks" />
|
||||
public static bool TryDecode(
|
||||
ReadOnlySpan<byte> payload,
|
||||
[NotNullWhen(true)] out ActivityLogSecretDocument? document)
|
||||
{
|
||||
document = null;
|
||||
|
||||
ActivityLogPayloadDocument? parsed;
|
||||
try
|
||||
{
|
||||
parsed = JsonSerializer.Deserialize(
|
||||
payload, ActivityLogPayloadJsonContext.Default.ActivityLogPayloadDocument);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (parsed is null || parsed.SchemaVersion < 1)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var candidate = new ActivityLogSecret
|
||||
{
|
||||
ItemKind = parsed.ItemKind ?? string.Empty,
|
||||
ItemId = parsed.ItemId,
|
||||
ItemLabel = parsed.ItemLabel ?? string.Empty,
|
||||
Operation = Enum.IsDefined((ActivityOperation)parsed.Operation)
|
||||
? (ActivityOperation)parsed.Operation
|
||||
: default,
|
||||
ChangedFields = parsed.ChangedFields ?? string.Empty,
|
||||
At = parsed.At,
|
||||
DeviceName = parsed.DeviceName ?? string.Empty,
|
||||
ActorUserId = parsed.ActorUserId,
|
||||
};
|
||||
|
||||
if (!candidate.TryValidate(out _))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
document = new ActivityLogSecretDocument(candidate, parsed.SchemaVersion);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The serialised shape. Mutable and nullable because it models untrusted input.</summary>
|
||||
/// <inheritdoc cref="HostPayloadDocument" path="/remarks" />
|
||||
internal sealed class ActivityLogPayloadDocument
|
||||
{
|
||||
public int SchemaVersion { get; set; }
|
||||
|
||||
public string? ItemKind { get; set; }
|
||||
|
||||
public Guid ItemId { get; set; }
|
||||
|
||||
public string? ItemLabel { get; set; }
|
||||
|
||||
public int Operation { get; set; }
|
||||
|
||||
/// <remarks>
|
||||
/// Written as null when empty rather than as <c>""</c>, so that a create and a delete — which have no
|
||||
/// changed fields by definition — omit the property entirely instead of carrying an empty one.
|
||||
/// </remarks>
|
||||
public string? ChangedFields { get; set; }
|
||||
|
||||
public DateTimeOffset At { get; set; }
|
||||
|
||||
public string? DeviceName { get; set; }
|
||||
|
||||
public Guid ActorUserId { get; set; }
|
||||
}
|
||||
|
||||
[JsonSourceGenerationOptions(
|
||||
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)]
|
||||
[JsonSerializable(typeof(ActivityLogPayloadDocument))]
|
||||
internal sealed partial class ActivityLogPayloadJsonContext : JsonSerializerContext;
|
||||
@@ -0,0 +1,147 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Globalization;
|
||||
|
||||
namespace DodoSSH.Client.Domain;
|
||||
|
||||
/// <summary>How a connection ended.</summary>
|
||||
public enum ConnectionOutcome
|
||||
{
|
||||
/// <summary>The session ran and then ended — by the user, by the remote, or by the process closing.</summary>
|
||||
/// <remarks>
|
||||
/// One value for all three, deliberately. From an auditor's side "this person had a shell on that machine
|
||||
/// for eleven minutes" is the fact; which of the two ends hung up first is not something this client can
|
||||
/// establish reliably — a tab close and a remote hangup both arrive as the pump finishing — and a field
|
||||
/// that guessed would be worse than one that does not claim to know.
|
||||
/// </remarks>
|
||||
Closed = 0,
|
||||
|
||||
/// <summary>The connection was attempted and did not open.</summary>
|
||||
Failed = 1,
|
||||
|
||||
/// <summary>The host key was not the pinned one, so the client refused before authenticating.</summary>
|
||||
/// <remarks>
|
||||
/// Its own outcome rather than a kind of <see cref="Failed"/>, because it is the only one that means
|
||||
/// something about the <em>host</em> rather than about the network or the credentials. A run of these on
|
||||
/// one machine is the single most interesting thing a connection log can show.
|
||||
/// </remarks>
|
||||
Refused = 2,
|
||||
}
|
||||
|
||||
/// <summary>What kind of session a log entry is about.</summary>
|
||||
public enum ConnectionKind
|
||||
{
|
||||
/// <summary>An interactive terminal.</summary>
|
||||
Terminal = 0,
|
||||
|
||||
/// <summary>An SFTP session for moving files.</summary>
|
||||
/// <remarks>
|
||||
/// Recorded separately and not hidden. Opening the file browser is a second login as far as the remote's
|
||||
/// own <c>auth.log</c> is concerned, so a log of ours that quietly omitted it would disagree with the
|
||||
/// host's — and the person comparing the two would be right to trust the host.
|
||||
/// </remarks>
|
||||
Sftp = 1,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One connection that was made, decrypted.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>What is here, and what deliberately is not.</b> The host's label, its item id, the address as dialled,
|
||||
/// when it started, how long it lasted, how it ended, and which user on which device did it. An audit log
|
||||
/// with no actor is not an audit log — the whole reason these sync is that an administrator will read them
|
||||
/// once teams land — so the actor is recorded and the SSH username is not. The two are different questions:
|
||||
/// "who in this organisation opened a shell" is what an audit answers, and "which account they logged in as"
|
||||
/// is a detail of the host that the host's own logs already have.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Written once, at close.</b> Every field is known by then, so an entry never needs a second write —
|
||||
/// which is what keeps a synced log from needing a merge, an outbox row per update, or any way to collide
|
||||
/// with itself. A connection that is still running is not in here at all; it is shown from the workspace's
|
||||
/// live state, which is the only place that knows.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed record ConnectionLogSecret : IVaultSecret
|
||||
{
|
||||
/// <summary>What the host was called at the time, or a plain address when nothing named it.</summary>
|
||||
/// <remarks>
|
||||
/// A copy rather than a lookup through <see cref="HostId"/>, and that is the point of it: the bookmark
|
||||
/// can be renamed or deleted, and a history that changed retroactively when somebody tidied their
|
||||
/// keychain would be a history nobody could rely on.
|
||||
/// </remarks>
|
||||
public required string HostLabel { get; init; }
|
||||
|
||||
/// <summary>The address as dialled, <c>user@host:port</c> style, or whatever was typed.</summary>
|
||||
public required string Address { get; init; }
|
||||
|
||||
/// <summary>The host item this was, or null when the connection did not come from one.</summary>
|
||||
public Guid? HostId { get; init; }
|
||||
|
||||
/// <summary>Whether this was a terminal or a file-transfer session.</summary>
|
||||
public ConnectionKind Kind { get; init; }
|
||||
|
||||
/// <summary>When it started.</summary>
|
||||
public required DateTimeOffset StartedAt { get; init; }
|
||||
|
||||
/// <summary>How long it lasted.</summary>
|
||||
/// <remarks>
|
||||
/// A duration rather than an end time, because it is the thing anybody reads — and because the two clocks
|
||||
/// involved are the same one, so storing both would be storing a value and its own arithmetic.
|
||||
/// </remarks>
|
||||
public TimeSpan Duration { get; init; }
|
||||
|
||||
/// <summary>How it ended.</summary>
|
||||
public ConnectionOutcome Outcome { get; init; }
|
||||
|
||||
/// <summary>Which machine it was made from, as that machine calls itself.</summary>
|
||||
public required string DeviceName { get; init; }
|
||||
|
||||
/// <summary>Which account in this organisation made it.</summary>
|
||||
public Guid ActorUserId { get; init; }
|
||||
|
||||
/// <summary>What this entry is called, derived from what it records.</summary>
|
||||
/// <inheritdoc cref="KnownHostSecret.Label" path="/remarks" />
|
||||
public string Label => string.Create(CultureInfo.InvariantCulture, $"{HostLabel} ({Address})");
|
||||
|
||||
/// <summary>Whether this is storable, and why not if it is not.</summary>
|
||||
/// <remarks>
|
||||
/// A negative duration is refused rather than clamped. It can only come from a payload written elsewhere
|
||||
/// — nothing here can produce one — and a log that displayed "-3 hours" would leave a reader unable to
|
||||
/// tell a corrupt entry from a clock they should worry about.
|
||||
/// </remarks>
|
||||
public bool TryValidate([NotNullWhen(false)] out string? reason)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(HostLabel))
|
||||
{
|
||||
reason = "A connection log entry needs the host it was about.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Address))
|
||||
{
|
||||
reason = "A connection log entry needs the address that was dialled.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(DeviceName))
|
||||
{
|
||||
reason = "A connection log entry needs the machine it was made from.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Duration < TimeSpan.Zero)
|
||||
{
|
||||
reason = "A connection cannot have lasted a negative amount of time.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (HostId == Guid.Empty)
|
||||
{
|
||||
reason = "A host reference cannot be an empty id; use no host instead.";
|
||||
return false;
|
||||
}
|
||||
|
||||
reason = null;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DodoSSH.Client.Domain;
|
||||
|
||||
/// <summary>A decoded connection log payload, together with the schema version it was written at.</summary>
|
||||
/// <param name="Entry">The entry.</param>
|
||||
/// <param name="SchemaVersion">The version the writing client used.</param>
|
||||
public sealed record ConnectionLogSecretDocument(ConnectionLogSecret Entry, int SchemaVersion)
|
||||
{
|
||||
/// <inheritdoc cref="HostSecretDocument.IsReadOnly" />
|
||||
/// <remarks>
|
||||
/// Answered for consistency and never acted on: nothing edits a log entry, so there is no re-encode that
|
||||
/// could drop a newer client's field. It stays because the reconciler asks every kind.
|
||||
/// </remarks>
|
||||
public bool IsReadOnly => SchemaVersion > ConnectionLogSecretCodec.CurrentSchemaVersion;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encodes and decodes the plaintext inside a connection log entry's encrypted payload.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Mirrors <see cref="KnownHostSecretCodec"/>. The two enums are written as numbers rather than names,
|
||||
/// unlike <see cref="ActivityLogSecret.ItemKind"/>: they are closed sets this codec owns, where the item kind
|
||||
/// is an open one that a newer build may extend.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// An unknown enum value decodes to the default rather than failing the whole entry. A log written by a
|
||||
/// newer client that has learned a fourth outcome is still worth showing with its host, its times and its
|
||||
/// actor intact — refusing it would lose the entry to save the one field nobody could have acted on anyway.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class ConnectionLogSecretCodec
|
||||
{
|
||||
/// <summary>The schema version this build writes.</summary>
|
||||
public const int CurrentSchemaVersion = 1;
|
||||
|
||||
/// <summary>Serialises an entry to the bytes that get sealed.</summary>
|
||||
/// <exception cref="ArgumentException">The entry is not valid for storage.</exception>
|
||||
public static byte[] Encode(ConnectionLogSecret entry)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entry);
|
||||
|
||||
if (!entry.TryValidate(out var reason))
|
||||
{
|
||||
throw new ArgumentException(reason, nameof(entry));
|
||||
}
|
||||
|
||||
var document = new ConnectionLogPayloadDocument
|
||||
{
|
||||
SchemaVersion = CurrentSchemaVersion,
|
||||
HostLabel = entry.HostLabel,
|
||||
Address = entry.Address,
|
||||
HostId = entry.HostId,
|
||||
Kind = (int)entry.Kind,
|
||||
StartedAt = entry.StartedAt,
|
||||
DurationMs = (long)entry.Duration.TotalMilliseconds,
|
||||
Outcome = (int)entry.Outcome,
|
||||
DeviceName = entry.DeviceName,
|
||||
ActorUserId = entry.ActorUserId,
|
||||
};
|
||||
|
||||
return JsonSerializer.SerializeToUtf8Bytes(
|
||||
document, ConnectionLogPayloadJsonContext.Default.ConnectionLogPayloadDocument);
|
||||
}
|
||||
|
||||
/// <summary>Parses a decrypted payload.</summary>
|
||||
/// <inheritdoc cref="HostSecretCodec.TryDecode" path="/remarks" />
|
||||
public static bool TryDecode(
|
||||
ReadOnlySpan<byte> payload,
|
||||
[NotNullWhen(true)] out ConnectionLogSecretDocument? document)
|
||||
{
|
||||
document = null;
|
||||
|
||||
ConnectionLogPayloadDocument? parsed;
|
||||
try
|
||||
{
|
||||
parsed = JsonSerializer.Deserialize(
|
||||
payload, ConnectionLogPayloadJsonContext.Default.ConnectionLogPayloadDocument);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (parsed is null || parsed.SchemaVersion < 1)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var candidate = new ConnectionLogSecret
|
||||
{
|
||||
HostLabel = parsed.HostLabel ?? string.Empty,
|
||||
Address = parsed.Address ?? string.Empty,
|
||||
HostId = parsed.HostId,
|
||||
Kind = Enum.IsDefined((ConnectionKind)parsed.Kind) ? (ConnectionKind)parsed.Kind : default,
|
||||
StartedAt = parsed.StartedAt,
|
||||
Duration = TimeSpan.FromMilliseconds(parsed.DurationMs),
|
||||
Outcome = Enum.IsDefined((ConnectionOutcome)parsed.Outcome)
|
||||
? (ConnectionOutcome)parsed.Outcome
|
||||
: default,
|
||||
DeviceName = parsed.DeviceName ?? string.Empty,
|
||||
ActorUserId = parsed.ActorUserId,
|
||||
};
|
||||
|
||||
if (!candidate.TryValidate(out _))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
document = new ConnectionLogSecretDocument(candidate, parsed.SchemaVersion);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The serialised shape. Mutable and nullable because it models untrusted input.</summary>
|
||||
/// <inheritdoc cref="HostPayloadDocument" path="/remarks" />
|
||||
internal sealed class ConnectionLogPayloadDocument
|
||||
{
|
||||
public int SchemaVersion { get; set; }
|
||||
|
||||
public string? HostLabel { get; set; }
|
||||
|
||||
public string? Address { get; set; }
|
||||
|
||||
public Guid? HostId { get; set; }
|
||||
|
||||
public int Kind { get; set; }
|
||||
|
||||
public DateTimeOffset StartedAt { get; set; }
|
||||
|
||||
/// <remarks>
|
||||
/// Milliseconds as an integer rather than a <see cref="TimeSpan"/>, which <c>System.Text.Json</c> writes
|
||||
/// as <c>"00:11:03.4560000"</c> — a format whose parsing varies between platforms and whose precision
|
||||
/// invites a round-trip that is nearly but not exactly the value written.
|
||||
/// </remarks>
|
||||
public long DurationMs { get; set; }
|
||||
|
||||
public int Outcome { get; set; }
|
||||
|
||||
public string? DeviceName { get; set; }
|
||||
|
||||
public Guid ActorUserId { get; set; }
|
||||
}
|
||||
|
||||
[JsonSourceGenerationOptions(
|
||||
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)]
|
||||
[JsonSerializable(typeof(ConnectionLogPayloadDocument))]
|
||||
internal sealed partial class ConnectionLogPayloadJsonContext : JsonSerializerContext;
|
||||
@@ -0,0 +1,48 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace DodoSSH.Client.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// A folder hosts can be filed under, decrypted.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// One field, which makes this the smallest secret in the vault, and the small size is the feature. A group
|
||||
/// is a heading in a sidebar; everything else somebody might want from it — which hosts are in it, where it
|
||||
/// sits in a tree, what colour it is — was considered and left out, each for its own reason.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>No member list.</b> Membership is a <see cref="HostSecret.GroupId"/> on each host, so filing two
|
||||
/// different hosts into one group on two machines is two writes to two items. Held here it would be two
|
||||
/// writes to one item, and <see cref="ThreeWayMerge"/> has no set merge — the collision would resolve by one
|
||||
/// side winning outright and the other host silently leaving the group it was just put in.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>No parent.</b> Groups are flat. Two clients can each re-parent A under B and B under A while offline,
|
||||
/// and a scalar merge accepts both: the result is a cycle that no reader can draw and that the server cannot
|
||||
/// even see, because it is inside the payload. One level of nesting is not worth a state with no repair path.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed record HostGroupSecret : IVaultSecret
|
||||
{
|
||||
/// <summary>What the group is called. The only name it has anywhere.</summary>
|
||||
public required string Label { get; init; }
|
||||
|
||||
/// <summary>Whether this is storable, and why not if it is not.</summary>
|
||||
/// <remarks>
|
||||
/// A blank name is refused rather than defaulted. A group is only ever a heading, so a nameless one is
|
||||
/// indistinguishable from the ungrouped heading it would sit next to — and a user cannot select what they
|
||||
/// cannot tell apart.
|
||||
/// </remarks>
|
||||
public bool TryValidate([NotNullWhen(false)] out string? reason)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Label))
|
||||
{
|
||||
reason = "A group needs a name.";
|
||||
return false;
|
||||
}
|
||||
|
||||
reason = null;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DodoSSH.Client.Domain;
|
||||
|
||||
/// <summary>A decoded group payload, together with the schema version it was written at.</summary>
|
||||
/// <param name="Group">The group.</param>
|
||||
/// <param name="SchemaVersion">The version the writing client used.</param>
|
||||
public sealed record HostGroupSecretDocument(HostGroupSecret Group, int SchemaVersion)
|
||||
{
|
||||
/// <inheritdoc cref="HostSecretDocument.IsReadOnly" />
|
||||
public bool IsReadOnly => SchemaVersion > HostGroupSecretCodec.CurrentSchemaVersion;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encodes and decodes the plaintext inside a group item's encrypted payload.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Mirrors <see cref="KnownHostSecretCodec"/>, for the same reasons and with the same guarantees. One field
|
||||
/// makes this look like ceremony around a string, and it is not: what the JSON envelope buys is a schema
|
||||
/// version, which is what lets a later build add a field without every older client silently dropping it on
|
||||
/// the next edit. See <see cref="HostSecretDocument.IsReadOnly"/>.
|
||||
/// </remarks>
|
||||
public static class HostGroupSecretCodec
|
||||
{
|
||||
/// <summary>The schema version this build writes.</summary>
|
||||
public const int CurrentSchemaVersion = 1;
|
||||
|
||||
/// <summary>Serialises a group to the bytes that get sealed.</summary>
|
||||
/// <exception cref="ArgumentException">The group is not valid for storage.</exception>
|
||||
public static byte[] Encode(HostGroupSecret group)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(group);
|
||||
|
||||
if (!group.TryValidate(out var reason))
|
||||
{
|
||||
throw new ArgumentException(reason, nameof(group));
|
||||
}
|
||||
|
||||
var document = new HostGroupPayloadDocument
|
||||
{
|
||||
SchemaVersion = CurrentSchemaVersion,
|
||||
Label = group.Label,
|
||||
};
|
||||
|
||||
return JsonSerializer.SerializeToUtf8Bytes(
|
||||
document, HostGroupPayloadJsonContext.Default.HostGroupPayloadDocument);
|
||||
}
|
||||
|
||||
/// <summary>Parses a decrypted payload.</summary>
|
||||
/// <inheritdoc cref="HostSecretCodec.TryDecode" path="/remarks" />
|
||||
public static bool TryDecode(
|
||||
ReadOnlySpan<byte> payload,
|
||||
[NotNullWhen(true)] out HostGroupSecretDocument? document)
|
||||
{
|
||||
document = null;
|
||||
|
||||
HostGroupPayloadDocument? parsed;
|
||||
try
|
||||
{
|
||||
parsed = JsonSerializer.Deserialize(
|
||||
payload, HostGroupPayloadJsonContext.Default.HostGroupPayloadDocument);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (parsed is null || parsed.SchemaVersion < 1)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var candidate = new HostGroupSecret { Label = parsed.Label ?? string.Empty };
|
||||
|
||||
if (!candidate.TryValidate(out _))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
document = new HostGroupSecretDocument(candidate, parsed.SchemaVersion);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The serialised shape. Mutable and nullable because it models untrusted input.</summary>
|
||||
/// <inheritdoc cref="HostPayloadDocument" path="/remarks" />
|
||||
internal sealed class HostGroupPayloadDocument
|
||||
{
|
||||
public int SchemaVersion { get; set; }
|
||||
|
||||
public string? Label { get; set; }
|
||||
}
|
||||
|
||||
[JsonSourceGenerationOptions(
|
||||
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)]
|
||||
[JsonSerializable(typeof(HostGroupPayloadDocument))]
|
||||
internal sealed partial class HostGroupPayloadJsonContext : JsonSerializerContext;
|
||||
@@ -0,0 +1,63 @@
|
||||
namespace DodoSSH.Client.Domain;
|
||||
|
||||
/// <summary>The merged group, and everything that had to be overridden to produce it.</summary>
|
||||
/// <param name="Merged">The group to store and push.</param>
|
||||
/// <param name="Conflicts">Empty when the two sides were reconcilable field by field.</param>
|
||||
public sealed record HostGroupMergeResult(
|
||||
HostGroupSecret Merged,
|
||||
IReadOnlyList<HostFieldConflict> Conflicts)
|
||||
{
|
||||
/// <summary>Whether anything had to be overridden.</summary>
|
||||
public bool HasConflicts => Conflicts.Count > 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merges two divergent versions of a group against the version they both started from.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// One scalar, so this is the simplest merge in the client and the only interesting thing about it is what it
|
||||
/// does <em>not</em> have to consider. Filing a host into a group does not write to the group, so two people
|
||||
/// organising the same vault at the same time never collide here — the only way to reach this code is for two
|
||||
/// people to rename the same group differently, which is a real disagreement and gets a conflict notice.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Nothing is redacted. A group name is the one thing a group has, and a notice saying only that "the name
|
||||
/// differed" would leave the user unable to tell which of their two names survived.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class HostGroupSecretMerge
|
||||
{
|
||||
/// <summary>Produces the merged group.</summary>
|
||||
/// <param name="ancestor">The version both sides branched from.</param>
|
||||
/// <param name="local">The pending local version.</param>
|
||||
/// <param name="remote">The server's current version.</param>
|
||||
public static HostGroupMergeResult Merge(
|
||||
HostGroupSecret ancestor,
|
||||
HostGroupSecret local,
|
||||
HostGroupSecret remote)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(ancestor);
|
||||
ArgumentNullException.ThrowIfNull(local);
|
||||
ArgumentNullException.ThrowIfNull(remote);
|
||||
|
||||
var conflicts = new List<HostFieldConflict>();
|
||||
|
||||
var merge = ThreeWayMerge.Scalar(
|
||||
ancestor.Label, local.Label, remote.Label, StringComparer.Ordinal);
|
||||
|
||||
if (merge.IsConflicted)
|
||||
{
|
||||
// The local side always loses a scalar clash — see ThreeWayMerge — so the discarded side is
|
||||
// fixed here rather than derived from the outcome.
|
||||
conflicts.Add(new HostFieldConflict(
|
||||
nameof(HostGroupSecret.Label),
|
||||
MergeSide.Local,
|
||||
merge.Value,
|
||||
merge.Discarded,
|
||||
DiscardedWasRemoval: false));
|
||||
}
|
||||
|
||||
return new HostGroupMergeResult(new HostGroupSecret { Label = merge.Value }, conflicts);
|
||||
}
|
||||
}
|
||||
@@ -101,6 +101,32 @@ public sealed record HostSecret : IVaultSecret
|
||||
/// </remarks>
|
||||
public Guid? CredentialId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The group this host is filed under, or null for none.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The pointer lives on the host rather than a member list living on the group, and the reason is the
|
||||
/// merge: filing two different hosts into one group on two machines has to be two writes to two items.
|
||||
/// Held the other way round it would be two writes to one item, and with no set merge available the
|
||||
/// collision would resolve by one side winning and the other host quietly leaving the group.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Inside the payload, and it did not have to be.</b> <c>SyncPlaintextFields</c> has carried a
|
||||
/// <c>GroupId</c> since the contract was frozen and the server had a column for it. Nothing ever wrote
|
||||
/// one, the column is gone, and the server now refuses the field — because what it would hand over is a
|
||||
/// clustering of the estate, and the one plaintext concession the design allows itself is the relay
|
||||
/// address, which the relay genuinely cannot work without. This is not that. See ADR 0004.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The reference may dangle</b>, exactly as <see cref="SshKeyId"/> may: a group deleted on another
|
||||
/// machine leaves this pointing at nothing. That is handled where it is noticed — the host appears under
|
||||
/// the ungrouped heading — rather than prevented here, because preventing it would mean one group delete
|
||||
/// rewriting every host that named it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public Guid? GroupId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this host may be dialled through the server relay.
|
||||
/// </summary>
|
||||
@@ -174,6 +200,12 @@ public sealed record HostSecret : IVaultSecret
|
||||
return false;
|
||||
}
|
||||
|
||||
if (GroupId == Guid.Empty)
|
||||
{
|
||||
reason = "A group reference cannot be an empty id; use no group instead.";
|
||||
return false;
|
||||
}
|
||||
|
||||
reason = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -62,8 +62,11 @@ public static class HostSecretCodec
|
||||
/// <summary>The version that introduced <see cref="HostSecret.CredentialId"/>.</summary>
|
||||
public const int CredentialIdSchemaVersion = 3;
|
||||
|
||||
/// <summary>The version that introduced <see cref="HostSecret.GroupId"/>.</summary>
|
||||
public const int GroupIdSchemaVersion = 4;
|
||||
|
||||
/// <summary>The highest schema version this build can write.</summary>
|
||||
public const int CurrentSchemaVersion = CredentialIdSchemaVersion;
|
||||
public const int CurrentSchemaVersion = GroupIdSchemaVersion;
|
||||
|
||||
/// <summary>Serialises a host to the bytes that get sealed.</summary>
|
||||
/// <exception cref="ArgumentException">The host is not valid for storage.</exception>
|
||||
@@ -95,6 +98,7 @@ public static class HostSecretCodec
|
||||
RelayEnabled = host.RelayEnabled,
|
||||
SshKeyId = host.SshKeyId,
|
||||
CredentialId = host.CredentialId,
|
||||
GroupId = host.GroupId,
|
||||
};
|
||||
|
||||
return JsonSerializer.SerializeToUtf8Bytes(
|
||||
@@ -120,18 +124,40 @@ public static class HostSecretCodec
|
||||
/// did not make every host in every vault look like a change to the sync engine.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The two bindings are mutually exclusive — see <see cref="HostSecret.CredentialId"/> — so this reads as
|
||||
/// a ladder rather than a maximum. If a future field is <em>not</em> exclusive with an older one, this
|
||||
/// becomes the maximum over the versions of the fields present, which is the same rule stated more
|
||||
/// generally.
|
||||
/// <b>A maximum, not a ladder, and the difference arrived with <see cref="HostSecret.GroupId"/>.</b> The
|
||||
/// two authentication bindings are mutually exclusive — see <see cref="HostSecret.CredentialId"/> — so
|
||||
/// while they were the only versioned fields, a <c>switch</c> that returned the first match was
|
||||
/// indistinguishable from the rule and read more clearly. A group is orthogonal to both: a host can name
|
||||
/// a credential <em>and</em> a group, and the ladder would have answered 3 for it, writing a version that
|
||||
/// cannot represent the group it just wrote. An older client would then decode that host as editable and
|
||||
/// drop the field on the next save.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Written as a maximum over the fields actually present, which is the general form of the same rule and
|
||||
/// stays correct however the next field relates to these.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static int SchemaVersionFor(HostSecret host) => host switch
|
||||
private static int SchemaVersionFor(HostSecret host)
|
||||
{
|
||||
{ CredentialId: not null } => CredentialIdSchemaVersion,
|
||||
{ SshKeyId: not null } => SshKeyIdSchemaVersion,
|
||||
_ => BaseSchemaVersion,
|
||||
};
|
||||
var version = BaseSchemaVersion;
|
||||
|
||||
if (host.SshKeyId is not null)
|
||||
{
|
||||
version = Math.Max(version, SshKeyIdSchemaVersion);
|
||||
}
|
||||
|
||||
if (host.CredentialId is not null)
|
||||
{
|
||||
version = Math.Max(version, CredentialIdSchemaVersion);
|
||||
}
|
||||
|
||||
if (host.GroupId is not null)
|
||||
{
|
||||
version = Math.Max(version, GroupIdSchemaVersion);
|
||||
}
|
||||
|
||||
return version;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a decrypted payload.
|
||||
@@ -199,6 +225,7 @@ public static class HostSecretCodec
|
||||
RelayEnabled = parsed.RelayEnabled,
|
||||
SshKeyId = parsed.SshKeyId,
|
||||
CredentialId = parsed.CredentialId,
|
||||
GroupId = parsed.GroupId,
|
||||
};
|
||||
|
||||
if (!candidate.TryValidate(out _))
|
||||
@@ -254,6 +281,9 @@ internal sealed class HostPayloadDocument
|
||||
|
||||
/// <inheritdoc cref="SshKeyId" />
|
||||
public Guid? CredentialId { get; set; }
|
||||
|
||||
/// <inheritdoc cref="SshKeyId" />
|
||||
public Guid? GroupId { get; set; }
|
||||
}
|
||||
|
||||
[JsonSourceGenerationOptions(
|
||||
|
||||
@@ -103,10 +103,35 @@ public static class HostSecretMerge
|
||||
remote.RelayEnabled,
|
||||
conflicts,
|
||||
static enabled => enabled ? "enabled" : "disabled"),
|
||||
};
|
||||
|
||||
// The id is shown in a clash rather than redacted. It is not a secret — it names a vault item,
|
||||
// it is not the key — and hiding it would leave the user unable to tell which of two keys the
|
||||
// merge dropped.
|
||||
return new HostMergeResult(
|
||||
WithReferences(merged, ancestor, local, remote, conflicts), conflicts);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merges the three ids a host can point at: its key, its credential and its group.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Split out for length, and they do belong together: each is a reference to another vault item, each
|
||||
/// merges as a plain scalar, and each can end up dangling because the item it names may be deleted on
|
||||
/// another machine. None of that is the merge's problem — it is handled where the reference is used.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The ids are shown in a clash rather than redacted.</b> An id is not a secret — it names a vault
|
||||
/// item, it is not the key — and hiding it would leave the user unable to tell which of two keys the
|
||||
/// merge dropped.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static HostSecret WithReferences(
|
||||
HostSecret merged,
|
||||
HostSecret ancestor,
|
||||
HostSecret local,
|
||||
HostSecret remote,
|
||||
List<HostFieldConflict> conflicts) =>
|
||||
merged with
|
||||
{
|
||||
SshKeyId = Field(
|
||||
nameof(HostSecret.SshKeyId),
|
||||
ancestor.SshKeyId,
|
||||
@@ -122,10 +147,15 @@ public static class HostSecretMerge
|
||||
remote.CredentialId,
|
||||
conflicts,
|
||||
static id => id?.ToString() ?? "no credential"),
|
||||
};
|
||||
|
||||
return new HostMergeResult(merged, conflicts);
|
||||
}
|
||||
GroupId = Field(
|
||||
nameof(HostSecret.GroupId),
|
||||
ancestor.GroupId,
|
||||
local.GroupId,
|
||||
remote.GroupId,
|
||||
conflicts,
|
||||
static id => id?.ToString() ?? "ungrouped"),
|
||||
};
|
||||
|
||||
private static string Text(
|
||||
string name,
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
namespace DodoSSH.Client.Domain;
|
||||
|
||||
/// <summary>The merged entry, and everything that had to be overridden to produce it.</summary>
|
||||
/// <param name="Merged">The entry to store and push.</param>
|
||||
/// <param name="Conflicts">Empty when the two sides were reconcilable field by field.</param>
|
||||
public sealed record ConnectionLogMergeResult(
|
||||
ConnectionLogSecret Merged,
|
||||
IReadOnlyList<HostFieldConflict> Conflicts)
|
||||
{
|
||||
/// <summary>Whether anything had to be overridden.</summary>
|
||||
public bool HasConflicts => Conflicts.Count > 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="ConnectionLogMergeResult" />
|
||||
public sealed record ActivityLogMergeResult(
|
||||
ActivityLogSecret Merged,
|
||||
IReadOnlyList<HostFieldConflict> Conflicts)
|
||||
{
|
||||
/// <summary>Whether anything had to be overridden.</summary>
|
||||
public bool HasConflicts => Conflicts.Count > 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merges two divergent versions of a connection log entry.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>This exists because the item-kind pipeline requires it, and it should never run.</b> A log entry is
|
||||
/// written once, at the moment a connection closes, and nothing updates one — so there is no second version
|
||||
/// for a first to diverge from. Reaching this code means two clients wrote different records under one
|
||||
/// entity id, and entity ids are v7 GUIDs minted independently on each machine.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It is still a real merge rather than a throw. The reconciler runs inside a sync pass, and an exception
|
||||
/// there would strand every item queued behind this one — for a situation that is a bug in some client and
|
||||
/// not an emergency. So the remote side wins, the difference is recorded like any other, and somebody reads
|
||||
/// a conflict notice about a log entry, which is the loudest signal this could reasonably give.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Nothing is redacted. Every field is already an audit record of something that happened, and a notice that
|
||||
/// hid which of two records was dropped would defeat the point of noticing.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class ConnectionLogSecretMerge
|
||||
{
|
||||
/// <summary>Produces the merged entry.</summary>
|
||||
/// <param name="ancestor">The version both sides branched from.</param>
|
||||
/// <param name="local">The pending local version.</param>
|
||||
/// <param name="remote">The server's current version.</param>
|
||||
public static ConnectionLogMergeResult Merge(
|
||||
ConnectionLogSecret ancestor,
|
||||
ConnectionLogSecret local,
|
||||
ConnectionLogSecret remote)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(ancestor);
|
||||
ArgumentNullException.ThrowIfNull(local);
|
||||
ArgumentNullException.ThrowIfNull(remote);
|
||||
|
||||
// Whole-value, not field by field. The fields of one entry describe one event, and a merge that took
|
||||
// the host from one side and the duration from the other would invent a connection nobody made —
|
||||
// which is a worse outcome than losing the record this machine happened to hold.
|
||||
if (local == remote)
|
||||
{
|
||||
return new ConnectionLogMergeResult(remote, []);
|
||||
}
|
||||
|
||||
return new ConnectionLogMergeResult(
|
||||
remote,
|
||||
[
|
||||
new HostFieldConflict(
|
||||
"Entry",
|
||||
MergeSide.Local,
|
||||
remote.Label,
|
||||
local.Label,
|
||||
DiscardedWasRemoval: false),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merges two divergent versions of an activity log entry.
|
||||
/// </summary>
|
||||
/// <inheritdoc cref="ConnectionLogSecretMerge" path="/remarks" />
|
||||
public static class ActivityLogSecretMerge
|
||||
{
|
||||
/// <inheritdoc cref="ConnectionLogSecretMerge.Merge" />
|
||||
public static ActivityLogMergeResult Merge(
|
||||
ActivityLogSecret ancestor,
|
||||
ActivityLogSecret local,
|
||||
ActivityLogSecret remote)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(ancestor);
|
||||
ArgumentNullException.ThrowIfNull(local);
|
||||
ArgumentNullException.ThrowIfNull(remote);
|
||||
|
||||
if (local == remote)
|
||||
{
|
||||
return new ActivityLogMergeResult(remote, []);
|
||||
}
|
||||
|
||||
return new ActivityLogMergeResult(
|
||||
remote,
|
||||
[
|
||||
new HostFieldConflict(
|
||||
"Entry",
|
||||
MergeSide.Local,
|
||||
remote.Label,
|
||||
local.Label,
|
||||
DiscardedWasRemoval: false),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace DodoSSH.Client.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// An S3-compatible bucket and the credentials that reach it, decrypted.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Named for the protocol rather than for Amazon, because everything here works the same against MinIO, R2,
|
||||
/// Backblaze or Ceph — and for those, <see cref="Endpoint"/> is an address on somebody's own network. The
|
||||
/// interface says S3, which is what people call the protocol; the type says what it is.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b><see cref="SecretAccessKey"/> is a password, and everything this codebase does about passwords applies
|
||||
/// to it.</b> It is inside the encrypted payload, it never appears in a log line — the activity log records
|
||||
/// the field's name and not its value — and the merge reports that it differed rather than what it was.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed record ObjectStoreSecret : IVaultSecret
|
||||
{
|
||||
/// <summary>What the user calls this bucket. The only name it has anywhere.</summary>
|
||||
public required string Label { get; init; }
|
||||
|
||||
/// <summary>The bucket.</summary>
|
||||
public required string Bucket { get; init; }
|
||||
|
||||
/// <summary>The access key id.</summary>
|
||||
public required string AccessKeyId { get; init; }
|
||||
|
||||
/// <summary>The secret access key.</summary>
|
||||
public required string SecretAccessKey { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The region, or null to let the endpoint decide.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Required by AWS and ignored by several S3-compatible services, which is why it is nullable rather than
|
||||
/// defaulted to <c>us-east-1</c>. A default would be a guess presented as configuration, and the guess is
|
||||
/// wrong for exactly the self-hosted case this field exists to support.
|
||||
/// </remarks>
|
||||
public string? Region { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The service endpoint, or null for Amazon's own.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Null means AWS and the SDK resolves the host from <see cref="Region"/>. Anything else is a URL, and it
|
||||
/// is the field that makes this work against a MinIO in a cupboard.
|
||||
/// </remarks>
|
||||
public string? Endpoint { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to address the bucket as a path rather than as a subdomain.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <c>https://endpoint/bucket/key</c> instead of <c>https://bucket.endpoint/key</c>. Off for AWS, on for
|
||||
/// nearly every self-hosted service — MinIO in its default configuration has no wildcard DNS, so
|
||||
/// virtual-host addressing simply does not resolve. It is a setting rather than a guess because getting
|
||||
/// it wrong produces a name-resolution failure that says nothing about buckets.
|
||||
/// </remarks>
|
||||
public bool UsePathStyle { get; init; }
|
||||
|
||||
/// <summary>Free-text notes.</summary>
|
||||
public string? Notes { get; init; }
|
||||
|
||||
/// <summary>Whether this is storable, and why not if it is not.</summary>
|
||||
/// <remarks>
|
||||
/// The endpoint is checked for being a well-formed absolute URL when it is set at all. A relative one, or
|
||||
/// a bare hostname, produces an SDK failure at the first request whose message names neither the field
|
||||
/// nor this bucket — and the person reading it has typically just typed the value.
|
||||
/// </remarks>
|
||||
public bool TryValidate([NotNullWhen(false)] out string? reason)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Label))
|
||||
{
|
||||
reason = "A bucket needs a name.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Bucket))
|
||||
{
|
||||
reason = "A bucket needs the bucket it points at.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(AccessKeyId) || string.IsNullOrWhiteSpace(SecretAccessKey))
|
||||
{
|
||||
reason = "A bucket needs an access key id and a secret access key.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Endpoint is not null)
|
||||
{
|
||||
if (!Uri.TryCreate(Endpoint, UriKind.Absolute, out var endpoint))
|
||||
{
|
||||
reason = "The endpoint has to be a full URL, like https://minio.internal:9000.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.Equals(endpoint.Scheme, Uri.UriSchemeHttps, StringComparison.Ordinal)
|
||||
&& !string.Equals(endpoint.Scheme, Uri.UriSchemeHttp, StringComparison.Ordinal))
|
||||
{
|
||||
reason = "The endpoint has to be http or https.";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (Region is null && Endpoint is null)
|
||||
{
|
||||
// With neither, the SDK has nothing to resolve a host from and fails at the first request with
|
||||
// a message about a missing region rather than about this bucket.
|
||||
reason = "A bucket needs a region, an endpoint, or both.";
|
||||
return false;
|
||||
}
|
||||
|
||||
reason = null;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DodoSSH.Client.Domain;
|
||||
|
||||
/// <summary>A decoded bucket payload, together with the schema version it was written at.</summary>
|
||||
/// <param name="Store">The bucket.</param>
|
||||
/// <param name="SchemaVersion">The version the writing client used.</param>
|
||||
public sealed record ObjectStoreSecretDocument(ObjectStoreSecret Store, int SchemaVersion)
|
||||
{
|
||||
/// <inheritdoc cref="HostSecretDocument.IsReadOnly" />
|
||||
public bool IsReadOnly => SchemaVersion > ObjectStoreSecretCodec.CurrentSchemaVersion;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encodes and decodes the plaintext inside a bucket item's encrypted payload.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Mirrors <see cref="CredentialSecretCodec"/>, for the same reasons and with the same guarantees.
|
||||
/// </remarks>
|
||||
public static class ObjectStoreSecretCodec
|
||||
{
|
||||
/// <summary>The schema version this build writes.</summary>
|
||||
public const int CurrentSchemaVersion = 1;
|
||||
|
||||
/// <summary>Serialises a bucket to the bytes that get sealed.</summary>
|
||||
/// <exception cref="ArgumentException">The bucket is not valid for storage.</exception>
|
||||
public static byte[] Encode(ObjectStoreSecret store)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(store);
|
||||
|
||||
if (!store.TryValidate(out var reason))
|
||||
{
|
||||
throw new ArgumentException(reason, nameof(store));
|
||||
}
|
||||
|
||||
var document = new ObjectStorePayloadDocument
|
||||
{
|
||||
SchemaVersion = CurrentSchemaVersion,
|
||||
Label = store.Label,
|
||||
Bucket = store.Bucket,
|
||||
AccessKeyId = store.AccessKeyId,
|
||||
SecretAccessKey = store.SecretAccessKey,
|
||||
Region = store.Region,
|
||||
Endpoint = store.Endpoint,
|
||||
UsePathStyle = store.UsePathStyle,
|
||||
Notes = store.Notes,
|
||||
};
|
||||
|
||||
return JsonSerializer.SerializeToUtf8Bytes(
|
||||
document, ObjectStorePayloadJsonContext.Default.ObjectStorePayloadDocument);
|
||||
}
|
||||
|
||||
/// <summary>Parses a decrypted payload.</summary>
|
||||
/// <inheritdoc cref="HostSecretCodec.TryDecode" path="/remarks" />
|
||||
public static bool TryDecode(
|
||||
ReadOnlySpan<byte> payload,
|
||||
[NotNullWhen(true)] out ObjectStoreSecretDocument? document)
|
||||
{
|
||||
document = null;
|
||||
|
||||
ObjectStorePayloadDocument? parsed;
|
||||
try
|
||||
{
|
||||
parsed = JsonSerializer.Deserialize(
|
||||
payload, ObjectStorePayloadJsonContext.Default.ObjectStorePayloadDocument);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (parsed is null || parsed.SchemaVersion < 1)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var candidate = new ObjectStoreSecret
|
||||
{
|
||||
Label = parsed.Label ?? string.Empty,
|
||||
Bucket = parsed.Bucket ?? string.Empty,
|
||||
AccessKeyId = parsed.AccessKeyId ?? string.Empty,
|
||||
SecretAccessKey = parsed.SecretAccessKey ?? string.Empty,
|
||||
Region = parsed.Region,
|
||||
Endpoint = parsed.Endpoint,
|
||||
UsePathStyle = parsed.UsePathStyle,
|
||||
Notes = parsed.Notes,
|
||||
};
|
||||
|
||||
if (!candidate.TryValidate(out _))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
document = new ObjectStoreSecretDocument(candidate, parsed.SchemaVersion);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The serialised shape. Mutable and nullable because it models untrusted input.</summary>
|
||||
/// <inheritdoc cref="HostPayloadDocument" path="/remarks" />
|
||||
internal sealed class ObjectStorePayloadDocument
|
||||
{
|
||||
public int SchemaVersion { get; set; }
|
||||
|
||||
public string? Label { get; set; }
|
||||
|
||||
public string? Bucket { get; set; }
|
||||
|
||||
public string? AccessKeyId { get; set; }
|
||||
|
||||
public string? SecretAccessKey { get; set; }
|
||||
|
||||
public string? Region { get; set; }
|
||||
|
||||
public string? Endpoint { get; set; }
|
||||
|
||||
public bool UsePathStyle { get; set; }
|
||||
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
|
||||
[JsonSourceGenerationOptions(
|
||||
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)]
|
||||
[JsonSerializable(typeof(ObjectStorePayloadDocument))]
|
||||
internal sealed partial class ObjectStorePayloadJsonContext : JsonSerializerContext;
|
||||
@@ -0,0 +1,107 @@
|
||||
namespace DodoSSH.Client.Domain;
|
||||
|
||||
/// <summary>The merged bucket, and everything that had to be overridden to produce it.</summary>
|
||||
/// <param name="Merged">The bucket to store and push.</param>
|
||||
/// <param name="Conflicts">Empty when the two sides were reconcilable field by field.</param>
|
||||
public sealed record ObjectStoreMergeResult(
|
||||
ObjectStoreSecret Merged,
|
||||
IReadOnlyList<HostFieldConflict> Conflicts)
|
||||
{
|
||||
/// <summary>Whether anything had to be overridden.</summary>
|
||||
public bool HasConflicts => Conflicts.Count > 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merges two divergent versions of a bucket against the version they both started from.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Every field is a scalar, so this is <see cref="CredentialSecretMerge"/>'s shape and it reuses
|
||||
/// <see cref="HostFieldConflict"/> for the same reason.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The secret access key never reaches the conflict log</b>, exactly as a password does not: a discarded
|
||||
/// one is very often still live on the service it belongs to. The access key <em>id</em> is shown, because it
|
||||
/// is an identifier rather than a secret and knowing which of two key pairs the merge dropped is the whole
|
||||
/// content of the notice.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class ObjectStoreSecretMerge
|
||||
{
|
||||
/// <summary>Produces the merged bucket.</summary>
|
||||
/// <param name="ancestor">The version both sides branched from.</param>
|
||||
/// <param name="local">The pending local version.</param>
|
||||
/// <param name="remote">The server's current version.</param>
|
||||
public static ObjectStoreMergeResult Merge(
|
||||
ObjectStoreSecret ancestor,
|
||||
ObjectStoreSecret local,
|
||||
ObjectStoreSecret remote)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(ancestor);
|
||||
ArgumentNullException.ThrowIfNull(local);
|
||||
ArgumentNullException.ThrowIfNull(remote);
|
||||
|
||||
var conflicts = new List<HostFieldConflict>();
|
||||
|
||||
var merged = new ObjectStoreSecret
|
||||
{
|
||||
// Null-forgiving on the required fields, as the neighbouring merges do: the merge returns one of
|
||||
// its three inputs, and all three are non-null by construction.
|
||||
Label = Resolve(
|
||||
nameof(ObjectStoreSecret.Label), ancestor.Label, local.Label, remote.Label, conflicts)!,
|
||||
Bucket = Resolve(
|
||||
nameof(ObjectStoreSecret.Bucket), ancestor.Bucket, local.Bucket, remote.Bucket, conflicts)!,
|
||||
AccessKeyId = Resolve(
|
||||
nameof(ObjectStoreSecret.AccessKeyId),
|
||||
ancestor.AccessKeyId,
|
||||
local.AccessKeyId,
|
||||
remote.AccessKeyId,
|
||||
conflicts)!,
|
||||
SecretAccessKey = Resolve(
|
||||
nameof(ObjectStoreSecret.SecretAccessKey),
|
||||
ancestor.SecretAccessKey,
|
||||
local.SecretAccessKey,
|
||||
remote.SecretAccessKey,
|
||||
conflicts,
|
||||
redact: true)!,
|
||||
Region = Resolve(
|
||||
nameof(ObjectStoreSecret.Region), ancestor.Region, local.Region, remote.Region, conflicts),
|
||||
Endpoint = Resolve(
|
||||
nameof(ObjectStoreSecret.Endpoint),
|
||||
ancestor.Endpoint,
|
||||
local.Endpoint,
|
||||
remote.Endpoint,
|
||||
conflicts),
|
||||
UsePathStyle = ThreeWayMerge
|
||||
.Scalar(ancestor.UsePathStyle, local.UsePathStyle, remote.UsePathStyle)
|
||||
.Value,
|
||||
Notes = Resolve(
|
||||
nameof(ObjectStoreSecret.Notes), ancestor.Notes, local.Notes, remote.Notes, conflicts),
|
||||
};
|
||||
|
||||
return new ObjectStoreMergeResult(merged, conflicts);
|
||||
}
|
||||
|
||||
private static string? Resolve(
|
||||
string name,
|
||||
string? ancestor,
|
||||
string? local,
|
||||
string? remote,
|
||||
List<HostFieldConflict> conflicts,
|
||||
bool redact = false)
|
||||
{
|
||||
var merge = ThreeWayMerge.Scalar(ancestor, local, remote, StringComparer.Ordinal);
|
||||
|
||||
if (merge.IsConflicted)
|
||||
{
|
||||
conflicts.Add(new HostFieldConflict(
|
||||
name,
|
||||
MergeSide.Local,
|
||||
redact ? "(kept the server's value)" : merge.Value ?? "(none)",
|
||||
redact ? "(a different value was discarded)" : merge.Discarded ?? "(none)",
|
||||
DiscardedWasRemoval: false));
|
||||
}
|
||||
|
||||
return merge.Value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace DodoSSH.Client.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// A saved command, decrypted.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b><see cref="RunsOnInsert"/> is the field this type exists to get right.</b> A terminal is one input
|
||||
/// stream with no notion of "at a prompt": the remote may be inside <c>vi</c>, or at a <c>sudo</c> password
|
||||
/// prompt with echo off, and without shell integration the client cannot tell. So inserting a snippet is
|
||||
/// always "type this into whatever is there", never "run this command" — and whether a newline follows the
|
||||
/// text is the difference between the user reading what appeared and deciding, and something happening.
|
||||
/// It defaults to <see langword="false"/>, which makes that decision the user's Enter key.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b><see cref="Command"/> is stored verbatim.</b> No trimming, no newline normalisation — the same rule
|
||||
/// <see cref="SshKeySecret.PrivateKeyPem"/> follows, for a related reason: a heredoc's trailing newline is
|
||||
/// load-bearing, and a shell that receives a here-document terminator with the whitespace tidied off it hangs
|
||||
/// waiting for one that never comes.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Deliberately not in this version, each with a reason rather than an omission: <b>host scoping</b>, which
|
||||
/// needs a set merge that <see cref="ThreeWayMerge"/> does not have; <b>tags</b>, which are their own reserved
|
||||
/// item kind; and <b>parameter substitution</b>, which would make this a template language expanding into a
|
||||
/// root shell — a second security surface for a feature whose first one is already the hard part.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed record SnippetSecret : IVaultSecret
|
||||
{
|
||||
/// <summary>What the snippet is called. The only name it has anywhere.</summary>
|
||||
public required string Label { get; init; }
|
||||
|
||||
/// <summary>The text to insert. May be several lines.</summary>
|
||||
public required string Command { get; init; }
|
||||
|
||||
/// <summary>Free-text notes.</summary>
|
||||
public string? Notes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether inserting this also presses Enter.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Off unless the user turns it on, per snippet. A vault-wide preference was the alternative and it is
|
||||
/// worse: the setting belongs to the command, because <c>ls -la</c> and <c>rm -rf /var/lib/postgresql</c>
|
||||
/// do not want the same answer, and a single switch would eventually be left on by whoever needed it for
|
||||
/// the first of those.
|
||||
/// </remarks>
|
||||
public bool RunsOnInsert { get; init; }
|
||||
|
||||
/// <summary>Whether this is storable, and why not if it is not.</summary>
|
||||
/// <remarks>
|
||||
/// <see cref="Command"/> is checked for being blank but for nothing else. What makes a valid command is
|
||||
/// the remote shell's business, this client does not know which shell that is, and a validator guessing
|
||||
/// at it would refuse the legitimate cases — a bare <c>\x03</c>, a partial line meant to be completed by
|
||||
/// hand — while catching nothing that matters.
|
||||
/// </remarks>
|
||||
public bool TryValidate([NotNullWhen(false)] out string? reason)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Label))
|
||||
{
|
||||
reason = "A snippet needs a name.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(Command))
|
||||
{
|
||||
reason = "A snippet needs something to insert.";
|
||||
return false;
|
||||
}
|
||||
|
||||
reason = null;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DodoSSH.Client.Domain;
|
||||
|
||||
/// <summary>A decoded snippet payload, together with the schema version it was written at.</summary>
|
||||
/// <param name="Snippet">The snippet.</param>
|
||||
/// <param name="SchemaVersion">The version the writing client used.</param>
|
||||
public sealed record SnippetSecretDocument(SnippetSecret Snippet, int SchemaVersion)
|
||||
{
|
||||
/// <inheritdoc cref="HostSecretDocument.IsReadOnly" />
|
||||
public bool IsReadOnly => SchemaVersion > SnippetSecretCodec.CurrentSchemaVersion;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encodes and decodes the plaintext inside a snippet item's encrypted payload.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Mirrors <see cref="CredentialSecretCodec"/>. The one thing to be careful about here is
|
||||
/// <see cref="SnippetSecret.RunsOnInsert"/>: it is a <see cref="bool"/>, so a payload that omits it decodes
|
||||
/// as <see langword="false"/> — which is the safe direction, and deliberately the one a malformed or
|
||||
/// truncated write falls in.
|
||||
/// </remarks>
|
||||
public static class SnippetSecretCodec
|
||||
{
|
||||
/// <summary>The schema version this build writes.</summary>
|
||||
public const int CurrentSchemaVersion = 1;
|
||||
|
||||
/// <summary>Serialises a snippet to the bytes that get sealed.</summary>
|
||||
/// <exception cref="ArgumentException">The snippet is not valid for storage.</exception>
|
||||
public static byte[] Encode(SnippetSecret snippet)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(snippet);
|
||||
|
||||
if (!snippet.TryValidate(out var reason))
|
||||
{
|
||||
throw new ArgumentException(reason, nameof(snippet));
|
||||
}
|
||||
|
||||
var document = new SnippetPayloadDocument
|
||||
{
|
||||
SchemaVersion = CurrentSchemaVersion,
|
||||
Label = snippet.Label,
|
||||
Command = snippet.Command,
|
||||
Notes = snippet.Notes,
|
||||
RunsOnInsert = snippet.RunsOnInsert,
|
||||
};
|
||||
|
||||
return JsonSerializer.SerializeToUtf8Bytes(
|
||||
document, SnippetPayloadJsonContext.Default.SnippetPayloadDocument);
|
||||
}
|
||||
|
||||
/// <summary>Parses a decrypted payload.</summary>
|
||||
/// <inheritdoc cref="HostSecretCodec.TryDecode" path="/remarks" />
|
||||
public static bool TryDecode(
|
||||
ReadOnlySpan<byte> payload,
|
||||
[NotNullWhen(true)] out SnippetSecretDocument? document)
|
||||
{
|
||||
document = null;
|
||||
|
||||
SnippetPayloadDocument? parsed;
|
||||
try
|
||||
{
|
||||
parsed = JsonSerializer.Deserialize(
|
||||
payload, SnippetPayloadJsonContext.Default.SnippetPayloadDocument);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (parsed is null || parsed.SchemaVersion < 1)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var candidate = new SnippetSecret
|
||||
{
|
||||
Label = parsed.Label ?? string.Empty,
|
||||
Command = parsed.Command ?? string.Empty,
|
||||
Notes = parsed.Notes,
|
||||
RunsOnInsert = parsed.RunsOnInsert,
|
||||
};
|
||||
|
||||
if (!candidate.TryValidate(out _))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
document = new SnippetSecretDocument(candidate, parsed.SchemaVersion);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The serialised shape. Mutable and nullable because it models untrusted input.</summary>
|
||||
/// <inheritdoc cref="HostPayloadDocument" path="/remarks" />
|
||||
internal sealed class SnippetPayloadDocument
|
||||
{
|
||||
public int SchemaVersion { get; set; }
|
||||
|
||||
public string? Label { get; set; }
|
||||
|
||||
public string? Command { get; set; }
|
||||
|
||||
public string? Notes { get; set; }
|
||||
|
||||
/// <remarks>
|
||||
/// Not nullable, so its absence is <see langword="false"/> rather than a third state. The field decides
|
||||
/// whether inserting a snippet also presses Enter, and "we could not tell" has to resolve to the answer
|
||||
/// that does nothing.
|
||||
/// </remarks>
|
||||
public bool RunsOnInsert { get; set; }
|
||||
}
|
||||
|
||||
[JsonSourceGenerationOptions(
|
||||
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)]
|
||||
[JsonSerializable(typeof(SnippetPayloadDocument))]
|
||||
internal sealed partial class SnippetPayloadJsonContext : JsonSerializerContext;
|
||||
@@ -0,0 +1,94 @@
|
||||
namespace DodoSSH.Client.Domain;
|
||||
|
||||
/// <summary>The merged snippet, and everything that had to be overridden to produce it.</summary>
|
||||
/// <param name="Merged">The snippet to store and push.</param>
|
||||
/// <param name="Conflicts">Empty when the two sides were reconcilable field by field.</param>
|
||||
public sealed record SnippetMergeResult(
|
||||
SnippetSecret Merged,
|
||||
IReadOnlyList<HostFieldConflict> Conflicts)
|
||||
{
|
||||
/// <summary>Whether anything had to be overridden.</summary>
|
||||
public bool HasConflicts => Conflicts.Count > 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merges two divergent versions of a snippet against the version they both started from.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Three strings and a flag, so the shape is <see cref="CredentialSecretMerge"/>'s and it reuses
|
||||
/// <see cref="HostFieldConflict"/> for the same reason. Nothing is redacted: a snippet is a command somebody
|
||||
/// wrote down on purpose, and a notice that hid the discarded version would leave the user unable to tell
|
||||
/// whether the one that survived is the one they wanted to keep.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b><see cref="SnippetSecret.RunsOnInsert"/> cannot conflict, and it is worth knowing why rather than
|
||||
/// assuming it.</b> A three-way clash needs local and remote each to differ from the ancestor <em>and</em>
|
||||
/// from one another; with only two possible values, the first two conditions force the third to fail. So this
|
||||
/// field always resolves to whichever side actually changed it, and a merge can never turn a snippet into one
|
||||
/// that runs on its own — the outcome the ordinary rule would have made possible if the field had a third
|
||||
/// state. An earlier draft special-cased it to resolve to <see langword="false"/> on a clash; the branch was
|
||||
/// unreachable, and unreachable safety code is worse than none, because it reads as protection.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class SnippetSecretMerge
|
||||
{
|
||||
/// <summary>Produces the merged snippet.</summary>
|
||||
/// <param name="ancestor">The version both sides branched from.</param>
|
||||
/// <param name="local">The pending local version.</param>
|
||||
/// <param name="remote">The server's current version.</param>
|
||||
public static SnippetMergeResult Merge(
|
||||
SnippetSecret ancestor,
|
||||
SnippetSecret local,
|
||||
SnippetSecret remote)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(ancestor);
|
||||
ArgumentNullException.ThrowIfNull(local);
|
||||
ArgumentNullException.ThrowIfNull(remote);
|
||||
|
||||
var conflicts = new List<HostFieldConflict>();
|
||||
|
||||
var merged = new SnippetSecret
|
||||
{
|
||||
// Null-forgiving on the two required fields, as the neighbouring merges do for the same reason:
|
||||
// the merge returns one of its three inputs, and all three are non-null by construction.
|
||||
Label = Text(
|
||||
nameof(SnippetSecret.Label), ancestor.Label, local.Label, remote.Label, conflicts)!,
|
||||
Command = Text(
|
||||
nameof(SnippetSecret.Command),
|
||||
ancestor.Command,
|
||||
local.Command,
|
||||
remote.Command,
|
||||
conflicts)!,
|
||||
Notes = Text(
|
||||
nameof(SnippetSecret.Notes), ancestor.Notes, local.Notes, remote.Notes, conflicts),
|
||||
RunsOnInsert = ThreeWayMerge
|
||||
.Scalar(ancestor.RunsOnInsert, local.RunsOnInsert, remote.RunsOnInsert)
|
||||
.Value,
|
||||
};
|
||||
|
||||
return new SnippetMergeResult(merged, conflicts);
|
||||
}
|
||||
|
||||
private static string? Text(
|
||||
string name,
|
||||
string? ancestor,
|
||||
string? local,
|
||||
string? remote,
|
||||
List<HostFieldConflict> conflicts)
|
||||
{
|
||||
var merge = ThreeWayMerge.Scalar(ancestor, local, remote, StringComparer.Ordinal);
|
||||
|
||||
if (merge.IsConflicted)
|
||||
{
|
||||
conflicts.Add(new HostFieldConflict(
|
||||
name,
|
||||
MergeSide.Local,
|
||||
merge.Value ?? "(none)",
|
||||
merge.Discarded ?? "(none)",
|
||||
DiscardedWasRemoval: false));
|
||||
}
|
||||
|
||||
return merge.Value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
namespace DodoSSH.Client.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// Reads the moment a version 7 identifier was created back out of it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Why this exists.</b> No vault item carries a timestamp. <c>VaultItem</c> is an id, a secret, a version
|
||||
/// and three sync flags, and the server's <c>created_at</c> is deliberately not handed back — so a screen
|
||||
/// that wants to say when something was added has nothing to read. Every id this client mints goes through
|
||||
/// <see cref="Guid.CreateVersion7()"/>, which is banned-symbol policy rather than preference (see
|
||||
/// <c>BannedSymbols.txt</c>), and RFC 9562 puts 48 bits of Unix milliseconds in the first six bytes of one.
|
||||
/// That is a real creation time, already stored, costing nothing.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>What it is not.</b> It is when the item was <em>created</em>, never when it was last changed — an
|
||||
/// update keeps the id. A screen showing this has to say so, or it is quietly presenting a creation date as
|
||||
/// a modification date. And an id minted anywhere else, by an older client or another implementation, is not
|
||||
/// a v7 at all; that case answers null rather than a number derived from bytes that mean something else.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class Uuid7Timestamp
|
||||
{
|
||||
/// <summary>Where the version nibble lives in the RFC byte order.</summary>
|
||||
private const int VersionByte = 6;
|
||||
|
||||
/// <summary>
|
||||
/// The creation time recorded in a version 7 identifier, or null if it is not one.
|
||||
/// </summary>
|
||||
public static DateTimeOffset? Of(Guid id)
|
||||
{
|
||||
Span<byte> bytes = stackalloc byte[16];
|
||||
|
||||
// Big-endian, which is the whole reason this is not two lines of shifting. Guid's own layout stores
|
||||
// its first three fields in the host's byte order, so the little-endian overload scrambles exactly
|
||||
// the six bytes being read here — and does it silently, producing dates in the year 30000 rather
|
||||
// than an error.
|
||||
if (!id.TryWriteBytes(bytes, bigEndian: true, out _))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if ((bytes[VersionByte] & 0xF0) != 0x70)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
long milliseconds = 0;
|
||||
|
||||
for (var i = 0; i < 6; i++)
|
||||
{
|
||||
milliseconds = (milliseconds << 8) | bytes[i];
|
||||
}
|
||||
|
||||
return DateTimeOffset.FromUnixTimeMilliseconds(milliseconds);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user