Let a host carry pinned folders, merged path by path

This commit is contained in:
2026-08-07 15:25:51 +02:00
parent 82966af37b
commit 49645db680
8 changed files with 633 additions and 7 deletions
+91 -1
View File
@@ -29,6 +29,22 @@ public sealed record HostSecret : IVaultSecret
/// <summary>The default SSH port, used when a host does not say otherwise.</summary>
public const int DefaultPort = 22;
/// <summary>The longest a single pinned path may be.</summary>
/// <remarks>
/// A remote path is opaque to this client, so nothing about its shape is checked beyond this — but
/// opaque is not unbounded. This is inside an encrypted payload every device decrypts and renders, and
/// a length nobody would type by hand is the shape a corrupted or hostile payload takes.
/// </remarks>
public const int MaxPinnedPathLength = 1024;
/// <summary>The most paths a host may pin.</summary>
/// <remarks>
/// A quick-access list a person actually uses tops out well short of this; the limit exists to bound
/// the payload rather than to constrain anyone's workflow. See <see cref="MaxPinnedPathLength"/> for
/// the parallel reasoning about a single entry.
/// </remarks>
public const int MaxPinnedPaths = 32;
/// <summary>Display name. The only name this host has anywhere.</summary>
public required string Label { get; init; }
@@ -188,6 +204,25 @@ public sealed record HostSecret : IVaultSecret
/// </remarks>
public TagSet TagIds { get; init; } = TagSet.Empty;
/// <summary>
/// The remote directories pinned on this host, in the order the user arranged them.
/// </summary>
/// <remarks>
/// <para>
/// Plain strings rather than any parsed path type, because the path means something only to the
/// remote shell — this client never resolves it, never lists it, never checks that it exists. What
/// "pinned" buys is a shortcut into wherever the user already knows to go: a project checkout, a log
/// directory, the place a deploy lands.
/// </para>
/// <para>
/// Order is kept rather than sorted, unlike <see cref="TagIds"/>, because order is what a pinned-path
/// list is for — it is what a quick-access menu draws top to bottom, and a user who moves their
/// most-used path to the top has expressed something a sorted set would erase. See
/// <see cref="PinnedPathList"/> for how that survives the merge.
/// </para>
/// </remarks>
public PinnedPathList PinnedPaths { get; init; } = PinnedPathList.Empty;
/// <summary>
/// The group this host is filed under, or null for none.
/// </summary>
@@ -279,7 +314,62 @@ public sealed record HostSecret : IVaultSecret
return false;
}
return ReferencesAreStorable(out reason);
return PinnedPathsAreStorable(out reason) && ReferencesAreStorable(out reason);
}
/// <summary>
/// Checks the paths pinned on this host.
/// </summary>
/// <remarks>
/// <para>
/// A path here is opaque to this client: it is resolved by the remote shell, not by this one, so
/// nothing about leading slashes or <c>.</c> components is checked, and a relative path — one meant to
/// resolve against the account's home directory — is exactly as valid as an absolute one.
/// </para>
/// <para>
/// What is refused is narrower. A path that is blank once trimmed pins nothing to jump to. A control
/// character, NUL included, is never part of a real path and is the shape a corrupted or hand-crafted
/// payload takes — this client renders these directly in a shortcut list, and a stray escape sequence
/// or embedded NUL there is a bug at best. And <see cref="MaxPinnedPathLength"/> and
/// <see cref="MaxPinnedPaths"/> bound the payload the same way <see cref="HostOptions"/> bounds
/// directives: not because a real workflow approaches either limit, but because an encrypted payload
/// every device must decrypt and render should not be allowed to demand an unbounded amount of either.
/// </para>
/// </remarks>
private bool PinnedPathsAreStorable([NotNullWhen(false)] out string? reason)
{
if (PinnedPaths.Count > MaxPinnedPaths)
{
reason = $"A host cannot pin more than {MaxPinnedPaths} paths.";
return false;
}
foreach (var path in PinnedPaths)
{
if (string.IsNullOrWhiteSpace(path))
{
reason = "A pinned path cannot be blank.";
return false;
}
if (path.Length > MaxPinnedPathLength)
{
reason = $"A pinned path cannot be longer than {MaxPinnedPathLength} characters.";
return false;
}
foreach (var ch in path)
{
if (char.IsControl(ch))
{
reason = "A pinned path cannot contain a control character.";
return false;
}
}
}
reason = null;
return true;
}
/// <summary>
+28 -1
View File
@@ -102,12 +102,22 @@ public static class HostSecretCodec
/// </remarks>
public const int TagIdsSchemaVersion = 6;
/// <summary>The version that introduced <see cref="HostSecret.PinnedPaths"/>.</summary>
/// <remarks>
/// One past tags rather than sharing with them, for the reason <see cref="TagIdsSchemaVersion"/> gives
/// for sharing with inheritance instead of standing alone: the two fields are independent, a host can
/// pin paths without wearing a single tag, and stating the version such a host would actually need
/// keeps the rule in <see cref="SchemaVersionFor"/> a maximum over what is genuinely present rather
/// than a coincidence of what shipped together.
/// </remarks>
public const int PinnedPathsSchemaVersion = 7;
/// <summary>The highest schema version this build can write.</summary>
/// <remarks>
/// Names the highest constant above, which <see cref="SchemaVersionFor"/> assumes when it takes a
/// maximum. A new field added below this line has to be named here too.
/// </remarks>
public const int CurrentSchemaVersion = TagIdsSchemaVersion;
public const int CurrentSchemaVersion = PinnedPathsSchemaVersion;
/// <summary>Serialises a host to the bytes that get sealed.</summary>
/// <exception cref="ArgumentException">The host is not valid for storage.</exception>
@@ -149,6 +159,10 @@ public static class HostSecretCodec
// [] here would land in every host in every vault and make the first sync after the upgrade
// read as though every one of them had changed.
TagIds = host.TagIds.Count == 0 ? null : [.. host.TagIds],
// Last, and null for the same reason TagIds is: a host that pins nothing must encode exactly
// as it did before this field existed.
PinnedPaths = host.PinnedPaths.Count == 0 ? null : [.. host.PinnedPaths],
};
return JsonSerializer.SerializeToUtf8Bytes(
@@ -219,6 +233,11 @@ public static class HostSecretCodec
version = Math.Max(version, TagIdsSchemaVersion);
}
if (host.PinnedPaths.Count > 0)
{
version = Math.Max(version, PinnedPathsSchemaVersion);
}
return version;
}
@@ -295,6 +314,8 @@ public static class HostSecretCodec
AsksForPassword = parsed.AsksForPassword is true ? true : null,
TagIds = TagSet.Create(parsed.TagIds ?? []),
PinnedPaths = PinnedPathList.Create(parsed.PinnedPaths ?? []),
};
if (!candidate.TryValidate(out _))
@@ -369,6 +390,12 @@ internal sealed class HostPayloadDocument
/// which the sync engine would read as every host having changed.
/// </remarks>
public Guid[]? TagIds { get; set; }
/// <remarks>
/// Last, and null rather than <c>[]</c>, for exactly the reasons <see cref="TagIds"/> gives — it is the
/// newer of the two and follows the same rule.
/// </remarks>
public string[]? PinnedPaths { get; set; }
}
[JsonSourceGenerationOptions(
@@ -97,6 +97,8 @@ public static class HostSecretMerge
FormatChain),
Options = MergeOptions(ancestor.Options, local.Options, remote.Options, conflicts),
TagIds = MergeTags(ancestor.TagIds, local.TagIds, remote.TagIds, conflicts),
PinnedPaths = MergePinnedPaths(
ancestor.PinnedPaths, local.PinnedPaths, remote.PinnedPaths, conflicts),
RelayEnabled = Field(
nameof(HostSecret.RelayEnabled),
ancestor.RelayEnabled,
@@ -290,6 +292,56 @@ public static class HostSecretMerge
return TagSet.Create(merge.Merged.Keys);
}
/// <summary>
/// Merges the pinned paths per path, so two people each pinning a different one both keep theirs.
/// </summary>
/// <remarks>
/// <para>
/// The same reasoning as <see cref="MergeTags"/>, and the same shape: a whole-value merge would take
/// one side's list entire and drop the other's, so a colleague pinning <c>/var/log</c> while you pinned
/// <c>/var/www/app</c> would silently lose one of the two.
/// </para>
/// <para>
/// <b>Unlike a tag set, order survives.</b> <see cref="PinnedPathList.ToPathMap"/> is built in list
/// order rather than a canonical one, and <see cref="ThreeWayMerge.Map"/> unions keys ancestor first,
/// then local, then remote — so the merged keys come out base order, then this machine's additions,
/// then the server's, which is what <see cref="PinnedPathList.Create(IEnumerable{string})"/> then
/// preserves rather than re-sorting.
/// </para>
/// <para>
/// <b>No conflict is reachable here either, for the reason <see cref="MergeTags"/> gives.</b> The value
/// in the map is the key, so a path can only be present or absent, and the "both sides moved
/// differently" branch of the keyed merge needs one key to hold two values — which this map cannot
/// express. The loop stays for the same reason it stays there: the proof depends on
/// <see cref="PinnedPathList.ToPathMap"/> keying by value, and that is one edit away from no longer
/// being true.
/// </para>
/// </remarks>
private static PinnedPathList MergePinnedPaths(
PinnedPathList ancestor,
PinnedPathList local,
PinnedPathList remote,
List<HostFieldConflict> conflicts)
{
var merge = ThreeWayMerge.Map(
ancestor.ToPathMap(),
local.ToPathMap(),
remote.ToPathMap(),
StringComparer.Ordinal);
foreach (var conflict in merge.Conflicts)
{
conflicts.Add(new HostFieldConflict(
$"{nameof(HostSecret.PinnedPaths)}[{conflict.Key}]",
conflict.DiscardedSide,
conflict.Kept is null ? "not pinned" : "pinned",
conflict.DiscardedWasRemoval ? "not pinned" : "pinned",
conflict.DiscardedWasRemoval));
}
return PinnedPathList.Create(merge.Merged.Keys);
}
private static string FormatChain(JumpChain chain) =>
chain.Count == 0 ? "(none)" : string.Join(" → ", chain);
}
+175
View File
@@ -0,0 +1,175 @@
using System.Collections;
using System.Diagnostics.CodeAnalysis;
namespace DodoSSH.Client.Domain;
/// <summary>
/// The remote directory paths pinned on a host, in the order the user arranged them.
/// </summary>
/// <remarks>
/// <para>
/// A dedicated type rather than a list of strings, for the reason <see cref="JumpChain"/> and
/// <see cref="TagSet"/> both give: a plain <see cref="IReadOnlyList{T}"/> on a record gets reference
/// equality from the compiler-generated <c>Equals</c>, so every host would read as changed on every sync
/// pass and two identical edits would register as a conflict.
/// </para>
/// <para>
/// <b>Ordered, unlike <see cref="TagSet"/>; deduplicated, unlike <see cref="JumpChain"/>.</b> Order is
/// exactly what a pinned-path list is for: it is what a quick-access menu draws top to bottom, and a user
/// who drags their most-used path to the top has expressed something a sorted set would erase. So this
/// preserves insertion order rather than sorting at construction. But two pins of the same path are not
/// two shortcuts, only one typed twice, so this still dedupes — keeping the first occurrence — by ordinal
/// comparison, because the path is meaningful only to the remote shell and that shell may well be running
/// on a case-sensitive filesystem; folding case here would silently collapse two different directories
/// into one entry.
/// </para>
/// <para>
/// Empty and over-length paths are not rejected at construction, for the reason <see cref="TagSet"/>
/// gives for its empty ids: they arrive from a decrypted payload written by another client, and a
/// constructor that threw would turn one bad item into a failed sync pass for every other item behind it.
/// <see cref="HostSecret.TryValidate"/> is where that is caught.
/// </para>
/// </remarks>
public sealed class PinnedPathList : IReadOnlyList<string>, IEquatable<PinnedPathList>
{
private readonly string[] paths;
private readonly int hash;
private PinnedPathList(string[] paths)
{
this.paths = paths;
hash = ComputeHash(paths);
}
/// <summary>No pinned paths.</summary>
public static PinnedPathList Empty { get; } = new([]);
/// <inheritdoc />
public int Count => paths.Length;
/// <inheritdoc />
public string this[int index] => paths[index];
/// <summary>Copies a sequence of paths, keeping order and the first occurrence of any repeat.</summary>
public static PinnedPathList Create(IEnumerable<string> paths)
{
ArgumentNullException.ThrowIfNull(paths);
return Canonicalise([.. paths]);
}
/// <summary>Copies a span of paths, keeping order and the first occurrence of any repeat.</summary>
public static PinnedPathList Create(ReadOnlySpan<string> paths) => Canonicalise(paths.ToArray());
/// <summary>Whether this host pins the given path, compared ordinally.</summary>
public bool Contains(string path) => Array.IndexOf(paths, path) >= 0;
/// <summary>
/// The list as a map from path to path, in list order, which is the shape
/// <see cref="ThreeWayMerge.Map"/> takes.
/// </summary>
/// <remarks>
/// <para>
/// The value repeats the key for the reason <see cref="TagSet.ToIdMap"/>'s does: a per-key merge
/// resolves presence and absence independently, which is set semantics with removals, so two machines
/// pinning different paths on the same host both keep theirs, and a removal on one side is reported
/// rather than silently undone.
/// </para>
/// <para>
/// Built in list order rather than any canonical one, and that is load-bearing rather than incidental.
/// <see cref="ThreeWayMerge.Map"/> unions keys by walking the ancestor's map, then the local side's,
/// then the remote's, each in the order its dictionary enumerates — so an ordered map here is what lets
/// <see cref="HostSecretMerge"/> reproduce "base order, then additions" after the merge without doing
/// any ordering of its own.
/// </para>
/// </remarks>
public IReadOnlyDictionary<string, string> ToPathMap()
{
var map = new Dictionary<string, string>(paths.Length, StringComparer.Ordinal);
foreach (var path in paths)
{
map[path] = path;
}
return map;
}
/// <inheritdoc />
public bool Equals(PinnedPathList? other)
{
if (ReferenceEquals(this, other))
{
return true;
}
return other is not null
&& other.hash == hash
&& paths.AsSpan().SequenceEqual(other.paths);
}
/// <inheritdoc />
public override bool Equals(object? obj) => Equals(obj as PinnedPathList);
/// <inheritdoc />
public override int GetHashCode() => hash;
/// <inheritdoc />
public IEnumerator<string> GetEnumerator() => ((IEnumerable<string>)paths).GetEnumerator();
/// <inheritdoc />
IEnumerator IEnumerable.GetEnumerator() => paths.GetEnumerator();
/// <summary>The paths, without copying, in list order.</summary>
public ReadOnlySpan<string> AsSpan() => paths;
/// <summary>Contents equality, tolerating nulls on either side.</summary>
[SuppressMessage(
"Usage",
"CA2225:Operator overloads have named alternates",
Justification = "Equals(PinnedPathList) is the named alternate.")]
public static bool operator ==(PinnedPathList? left, PinnedPathList? right) =>
left is null ? right is null : left.Equals(right);
/// <summary>Contents inequality.</summary>
public static bool operator !=(PinnedPathList? left, PinnedPathList? right) => !(left == right);
/// <remarks>
/// Order-preserving, unlike <see cref="TagSet"/>'s canonicalisation: the first occurrence of a path is
/// kept in place and later repeats are dropped, rather than the whole array being sorted.
/// </remarks>
private static PinnedPathList Canonicalise(string[] paths)
{
if (paths.Length == 0)
{
return Empty;
}
var seen = new HashSet<string>(paths.Length, StringComparer.Ordinal);
var written = 0;
for (var read = 0; read < paths.Length; read++)
{
if (seen.Add(paths[read]))
{
paths[written++] = paths[read];
}
}
return new PinnedPathList(written == paths.Length ? paths : paths[..written]);
}
private static int ComputeHash(string[] paths)
{
// Order-sensitive, because order is the thing this type exists to preserve.
var accumulator = new HashCode();
accumulator.Add(paths.Length);
foreach (var path in paths)
{
accumulator.Add(path, StringComparer.Ordinal);
}
return accumulator.ToHashCode();
}
}