Files
DodoSSH/src/DodoSSH.Client.Domain/HostOptions.cs
T
jaap-jan 8d2416a602 Add the encrypted local cache and the sync client
Three new client projects, and the wire-contract fix they needed.

DodoSSH.Client.Domain holds the decrypted item model and the three-way
merge, with no I/O at all — so the suite that decides whether a
credential can be lost runs in milliseconds with nothing to mock.
Scalars defer to the server on a genuine clash so every replica resolves
the same triple identically and two clients cannot ping-pong; directives
merge per name so two people each adding one both keep theirs; the jump
chain merges as a whole value because its order is the route. Whatever
loses is returned rather than dropped.

DodoSSH.Client.Storage is EF Core on SQLite, no SQLCipher: the rows are
already ciphertext, so an encrypted file would protect protected bytes
at the cost of a native dependency. It keeps the server's state and the
outbox in separate tables, which is what preserves the common ancestor a
merge needs. One pending operation per item, enforced by a unique index.

DodoSSH.Client.Sync is the pull/apply/push loop. Pulling never decrypts
— a change with no local work pending is plumbed as ciphertext — so a
first sync of thousands of items does not run twice as many AEAD
operations for nothing.

Contracts: EncryptedPayload gains WrappedDataKey and DataKeyId. The
specification has required a per-item data key since crypto.md §3, the
columns have existed since the first migration and DshAad.ItemPayload
binds the id, but this record had nowhere to put either — so a
spec-compliant item could not be transmitted at all. Found by writing
the client that has to produce one. Also closes a hole in
AadResourceType, which had no value for the HostTag and HostCredential
that SyncEntityType has always listed.

Four bugs the tests found, not review:

- SQLite refuses to order or compare its own DateTimeOffset mapping, and
  throws at execution rather than model build. Collecting tombstones and
  listing conflicts are both that shape, so this was a crash waiting for
  the first user with a deleted host. Timestamps are integers now, by
  convention so a later field cannot be the one left unconverted.
- SQLitePCLRaw 2.1.11, which EF resolves, is covered by
  GHSA-2m69-gcr7-jv3q. Pinned forward as a family.
- Resurrecting content from a remote deletion cleared the original
  before queueing the copy. Two transactions, so a crash between them
  lost the work; reversed, and the rescued id is derived from the
  tombstone so a replay coalesces instead of duplicating.
- Several equality assertions went through Shouldly's ShouldBe, which
  compares IEnumerable element-wise and so tested nothing about the
  Equals these types exist to provide. Corrected; the falsification that
  caught it went from 2 failures to 6.

The push response's cursor is deliberately ignored. It sits after this
client's own writes, so adopting it skips anything another client
committed at a lower sequence in the window between a pull and a push —
permanently. Re-reading one's own writes is idempotent and costs a page.
The Contracts doc that invited the shortcut now says so.

593 tests, up from 448. The delete-versus-edit rules, the ancestor
retention, the fresh operation id on coalesce and the cursor safeguard
were each verified by breaking them and watching the right test fail.
2026-07-29 10:27:37 +02:00

222 lines
7.3 KiB
C#

using System.Collections;
using System.Diagnostics.CodeAnalysis;
namespace DodoSSH.Client.Domain;
/// <summary>
/// One SSH configuration directive on a host.
/// </summary>
/// <remarks>
/// Equality treats the name case-insensitively, matching how SSH reads keywords. Without that, two
/// clients that resolved the same merge could end up holding <c>ServerAliveInterval</c> and
/// <c>serveraliveinterval</c>, compare their hosts as different, and push over each other forever
/// while agreeing on every actual value.
/// </remarks>
/// <param name="Name">Directive name, for example <c>ServerAliveInterval</c>.</param>
/// <param name="Value">Directive value, verbatim and case-sensitive.</param>
public sealed record HostOption(string Name, string Value)
{
/// <summary>Defines directive identity: SSH keywords are case-insensitive.</summary>
public static StringComparer NameComparer => StringComparer.OrdinalIgnoreCase;
/// <inheritdoc />
public bool Equals(HostOption? other) =>
other is not null
&& NameComparer.Equals(Name, other.Name)
&& string.Equals(Value, other.Value, StringComparison.Ordinal);
/// <inheritdoc />
public override int GetHashCode() =>
HashCode.Combine(NameComparer.GetHashCode(Name), Value.GetHashCode(StringComparison.Ordinal));
}
/// <summary>
/// A host's SSH directives: unique by name, held in name order.
/// </summary>
/// <remarks>
/// <para>
/// Both invariants are load-bearing for the merge. Uniqueness gives every value a stable key, which
/// is what lets two people add different directives to the same host and both survive — a
/// whole-collection comparison would make that a conflict and discard one side. Name order makes the
/// encoding deterministic, so re-encoding an unchanged host produces identical bytes and the sync
/// engine does not push a spurious update on every pass.
/// </para>
/// <para>
/// <b>The cost, stated plainly:</b> real <c>ssh_config</c> permits a directive to repeat, and for
/// most keywords the first occurrence wins. That cannot be represented here. It is a deliberate M1
/// limitation rather than an oversight — a repeated key has no merge key — and the import path must
/// surface it rather than quietly keeping one of the duplicates.
/// </para>
/// </remarks>
public sealed class HostOptions : IReadOnlyList<HostOption>, IEquatable<HostOptions>
{
private readonly HostOption[] items;
private readonly int hash;
private HostOptions(HostOption[] items)
{
this.items = items;
hash = ComputeHash(items);
}
/// <summary>No directives.</summary>
public static HostOptions Empty { get; } = new([]);
/// <inheritdoc />
public int Count => items.Length;
/// <inheritdoc />
public HostOption this[int index] => items[index];
/// <summary>
/// Builds a canonical collection, sorting by name.
/// </summary>
/// <exception cref="ArgumentException">A name repeats, or a name is blank.</exception>
public static HostOptions Create(IEnumerable<HostOption> options)
{
if (!TryCreate(options, out var result, out var error))
{
throw new ArgumentException(error, nameof(options));
}
return result;
}
/// <summary>
/// Builds a canonical collection, reporting rather than throwing on bad input.
/// </summary>
/// <remarks>
/// The non-throwing overload exists because these values arrive from two places neither of which
/// is trusted: a decrypted payload written by another client, and an imported
/// <c>ssh_config</c>. Neither should be able to raise an exception from inside a sync pass.
/// </remarks>
public static bool TryCreate(
IEnumerable<HostOption> options,
[NotNullWhen(true)] out HostOptions? result,
[NotNullWhen(false)] out string? error)
{
ArgumentNullException.ThrowIfNull(options);
result = null;
var ordered = options.ToArray();
if (!Validate(ordered, out error))
{
return false;
}
Array.Sort(
ordered,
static (left, right) => HostOption.NameComparer.Compare(left.Name, right.Name));
result = ordered.Length == 0 ? Empty : new HostOptions(ordered);
return true;
}
/// <summary>Looks up a directive by name, case-insensitively as SSH treats keywords.</summary>
public bool TryGetValue(string name, [NotNullWhen(true)] out string? value)
{
foreach (var option in items)
{
if (HostOption.NameComparer.Equals(option.Name, name))
{
value = option.Value;
return true;
}
}
value = null;
return false;
}
/// <inheritdoc />
public bool Equals(HostOptions? other)
{
if (ReferenceEquals(this, other))
{
return true;
}
if (other is null || other.items.Length != items.Length || other.hash != hash)
{
return false;
}
return items.AsSpan().SequenceEqual(other.items);
}
/// <inheritdoc />
public override bool Equals(object? obj) => Equals(obj as HostOptions);
/// <inheritdoc />
public override int GetHashCode() => hash;
/// <inheritdoc />
public IEnumerator<HostOption> GetEnumerator() => ((IEnumerable<HostOption>)items).GetEnumerator();
/// <inheritdoc />
IEnumerator IEnumerable.GetEnumerator() => items.GetEnumerator();
/// <summary>Contents equality, tolerating nulls on either side.</summary>
[SuppressMessage(
"Usage",
"CA2225:Operator overloads have named alternates",
Justification = "Equals(HostOptions) is the named alternate.")]
public static bool operator ==(HostOptions? left, HostOptions? right) =>
left is null ? right is null : left.Equals(right);
/// <summary>Contents inequality.</summary>
public static bool operator !=(HostOptions? left, HostOptions? right) => !(left == right);
/// <summary>Projects to a name-keyed map, for the per-directive merge.</summary>
internal Dictionary<string, string> ToNameMap()
{
var map = new Dictionary<string, string>(items.Length, HostOption.NameComparer);
foreach (var option in items)
{
map[option.Name] = option.Value;
}
return map;
}
private static bool Validate(HostOption[] ordered, [NotNullWhen(false)] out string? error)
{
foreach (var option in ordered)
{
if (option is null || string.IsNullOrWhiteSpace(option.Name))
{
error = "An SSH directive must have a name.";
return false;
}
}
var duplicate = ordered
.GroupBy(o => o.Name, HostOption.NameComparer)
.FirstOrDefault(g => g.Count() > 1);
if (duplicate is not null)
{
error = $"The directive '{duplicate.Key}' appears more than once; M1 requires unique names.";
return false;
}
error = null;
return true;
}
private static int ComputeHash(HostOption[] items)
{
var accumulator = new HashCode();
accumulator.Add(items.Length);
foreach (var option in items)
{
accumulator.Add(option);
}
return accumulator.ToHashCode();
}
}