Public Access
Let a host carry pinned folders, merged path by path
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user