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> /// <summary>The default SSH port, used when a host does not say otherwise.</summary>
public const int DefaultPort = 22; 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> /// <summary>Display name. The only name this host has anywhere.</summary>
public required string Label { get; init; } public required string Label { get; init; }
@@ -188,6 +204,25 @@ public sealed record HostSecret : IVaultSecret
/// </remarks> /// </remarks>
public TagSet TagIds { get; init; } = TagSet.Empty; 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> /// <summary>
/// The group this host is filed under, or null for none. /// The group this host is filed under, or null for none.
/// </summary> /// </summary>
@@ -279,7 +314,62 @@ public sealed record HostSecret : IVaultSecret
return false; 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> /// <summary>
+28 -1
View File
@@ -102,12 +102,22 @@ public static class HostSecretCodec
/// </remarks> /// </remarks>
public const int TagIdsSchemaVersion = 6; 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> /// <summary>The highest schema version this build can write.</summary>
/// <remarks> /// <remarks>
/// Names the highest constant above, which <see cref="SchemaVersionFor"/> assumes when it takes a /// 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. /// maximum. A new field added below this line has to be named here too.
/// </remarks> /// </remarks>
public const int CurrentSchemaVersion = TagIdsSchemaVersion; public const int CurrentSchemaVersion = PinnedPathsSchemaVersion;
/// <summary>Serialises a host to the bytes that get sealed.</summary> /// <summary>Serialises a host to the bytes that get sealed.</summary>
/// <exception cref="ArgumentException">The host is not valid for storage.</exception> /// <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 // [] 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. // read as though every one of them had changed.
TagIds = host.TagIds.Count == 0 ? null : [.. host.TagIds], 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( return JsonSerializer.SerializeToUtf8Bytes(
@@ -219,6 +233,11 @@ public static class HostSecretCodec
version = Math.Max(version, TagIdsSchemaVersion); version = Math.Max(version, TagIdsSchemaVersion);
} }
if (host.PinnedPaths.Count > 0)
{
version = Math.Max(version, PinnedPathsSchemaVersion);
}
return version; return version;
} }
@@ -295,6 +314,8 @@ public static class HostSecretCodec
AsksForPassword = parsed.AsksForPassword is true ? true : null, AsksForPassword = parsed.AsksForPassword is true ? true : null,
TagIds = TagSet.Create(parsed.TagIds ?? []), TagIds = TagSet.Create(parsed.TagIds ?? []),
PinnedPaths = PinnedPathList.Create(parsed.PinnedPaths ?? []),
}; };
if (!candidate.TryValidate(out _)) if (!candidate.TryValidate(out _))
@@ -369,6 +390,12 @@ internal sealed class HostPayloadDocument
/// which the sync engine would read as every host having changed. /// which the sync engine would read as every host having changed.
/// </remarks> /// </remarks>
public Guid[]? TagIds { get; set; } 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( [JsonSourceGenerationOptions(
@@ -97,6 +97,8 @@ public static class HostSecretMerge
FormatChain), FormatChain),
Options = MergeOptions(ancestor.Options, local.Options, remote.Options, conflicts), Options = MergeOptions(ancestor.Options, local.Options, remote.Options, conflicts),
TagIds = MergeTags(ancestor.TagIds, local.TagIds, remote.TagIds, conflicts), TagIds = MergeTags(ancestor.TagIds, local.TagIds, remote.TagIds, conflicts),
PinnedPaths = MergePinnedPaths(
ancestor.PinnedPaths, local.PinnedPaths, remote.PinnedPaths, conflicts),
RelayEnabled = Field( RelayEnabled = Field(
nameof(HostSecret.RelayEnabled), nameof(HostSecret.RelayEnabled),
ancestor.RelayEnabled, ancestor.RelayEnabled,
@@ -290,6 +292,56 @@ public static class HostSecretMerge
return TagSet.Create(merge.Merged.Keys); 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) => private static string FormatChain(JumpChain chain) =>
chain.Count == 0 ? "(none)" : string.Join(" → ", 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();
}
}
@@ -39,7 +39,8 @@ internal static class HostFactory
Guid? credentialId = null, Guid? credentialId = null,
bool? asksForPassword = null, bool? asksForPassword = null,
Guid? groupId = null, Guid? groupId = null,
Guid[]? tags = null) => Guid[]? tags = null,
string[]? pinnedPaths = null) =>
new() new()
{ {
Label = label, Label = label,
@@ -57,6 +58,7 @@ internal static class HostFactory
AsksForPassword = asksForPassword, AsksForPassword = asksForPassword,
GroupId = groupId, GroupId = groupId,
TagIds = tags is null ? TagSet.Empty : TagSet.Create(tags), TagIds = tags is null ? TagSet.Empty : TagSet.Create(tags),
PinnedPaths = pinnedPaths is null ? PinnedPathList.Empty : PinnedPathList.Create(pinnedPaths),
}; };
/// <summary> /// <summary>
@@ -17,9 +17,9 @@ public sealed class HostSecretCodecTests
/// <remarks> /// <remarks>
/// "Full" cannot mean every field: the two authentication bindings are mutually exclusive, so a host may /// "Full" cannot mean every field: the two authentication bindings are mutually exclusive, so a host may
/// carry a key or a credential and never both, and neither may sit beside <c>AsksForPassword</c>. This /// carry a key or a credential and never both, and neither may sit beside <c>AsksForPassword</c>. This
/// one carries the credential, because that is the newer of the two, plus a group and a pair of tags /// one carries the credential, because that is the newer of the two, plus a group, a pair of tags and a
/// which are orthogonal to the binding and are what make this host reach the highest schema version a /// pair of pinned paths — which are orthogonal to the binding and are what make this host reach the
/// valid host can. /// highest schema version a valid host can.
/// </remarks> /// </remarks>
[Fact] [Fact]
public void AFullHost_RoundTrips() public void AFullHost_RoundTrips()
@@ -37,7 +37,8 @@ public sealed class HostSecretCodecTests
relayEnabled: true, relayEnabled: true,
credentialId: credentialId, credentialId: credentialId,
groupId: Production, groupId: Production,
tags: [Pci, EuWest]); tags: [Pci, EuWest],
pinnedPaths: ["/var/www/app", "/var/log"]);
HostSecretCodec.TryDecode(HostSecretCodec.Encode(host), out var document).ShouldBeTrue(); HostSecretCodec.TryDecode(HostSecretCodec.Encode(host), out var document).ShouldBeTrue();
@@ -219,6 +220,90 @@ public sealed class HostSecretCodecTests
document.SchemaVersion.ShouldBe(HostSecretCodec.BaseSchemaVersion); document.SchemaVersion.ShouldBe(HostSecretCodec.BaseSchemaVersion);
} }
[Fact]
public void AHostPinningPaths_IsWrittenAtTheVersionThatIntroducedThem()
{
HostSecretCodec
.TryDecode(HostSecretCodec.Encode(Host(pinnedPaths: ["/var/www/app"])), out var document)
.ShouldBeTrue();
document.ShouldNotBeNull();
document.SchemaVersion.ShouldBe(HostSecretCodec.PinnedPathsSchemaVersion);
document.Host.PinnedPaths.ShouldBe(PinnedPathList.Create(["/var/www/app"]));
}
[Fact]
public void AHostWithNoPinnedPaths_IsWrittenAtTheVersionItWouldHaveHadWithout()
{
// Pinning nothing is not using the feature, for the same reason an empty tag set is not: bumping
// the version for it would have made every host in every vault read-only on every machine that had
// not upgraded yet.
HostSecretCodec.TryDecode(HostSecretCodec.Encode(Host(pinnedPaths: [])), out var document)
.ShouldBeTrue();
document.ShouldNotBeNull();
document.SchemaVersion.ShouldBe(HostSecretCodec.BaseSchemaVersion);
}
[Fact]
public void ATaggedHostThatAlsoPinsPaths_IsWrittenAtTheHigherOfTheTwoVersions()
{
// Independent fields, so the version is a maximum over both rather than whichever this switch
// happened to check last — the same defect the group-and-binding case guards against above.
HostSecretCodec
.TryDecode(HostSecretCodec.Encode(Host(tags: [Pci], pinnedPaths: ["/var/www/app"])), out var document)
.ShouldBeTrue();
document.ShouldNotBeNull();
document.SchemaVersion.ShouldBe(HostSecretCodec.PinnedPathsSchemaVersion);
}
[Fact]
public void AHostPinningPaths_WritesThemLastAndInListOrder()
{
// Last, so every field that existed before them keeps its bytes; in list order rather than sorted,
// unlike tags — order is the meaning of a pinned-path list, so canonicalising it away would lose
// exactly what the feature is for.
var bytes = HostSecretCodec.Encode(
Host(username: null, notes: null, pinnedPaths: ["/var/www/app", "/var/log"]));
Encoding.UTF8.GetString(bytes).ShouldBe(
"""
{"schemaVersion":7,"label":"prod-db","hostname":"db.internal","port":22,"jumpHostIds":[],"options":{},"relayEnabled":false,"pinnedPaths":["/var/www/app","/var/log"]}
""");
}
[Fact]
public void APayloadWithoutPinnedPaths_DecodesAsPinningNothing()
{
// The compatibility case this field exists to pass: a payload written before pinned paths existed
// must read cleanly as a host that pins nothing, not fail to decode.
var payload = Encoding.UTF8.GetBytes(
"""
{"schemaVersion":6,"label":"prod-db","hostname":"db.internal","port":22,"tagIds":["0192f0c8-8888-7c3d-8e4f-5a6b7c8d9e08"]}
""");
HostSecretCodec.TryDecode(payload, out var document).ShouldBeTrue();
document.ShouldNotBeNull().Host.PinnedPaths.ShouldBe(PinnedPathList.Empty);
}
[Fact]
public void APayloadRepeatingAPinnedPath_DecodesKeepingTheFirstOccurrence()
{
// Written by some other client, and it must not compare unequal to the same list written once —
// or the engine would push this host as changed on every pass for ever.
var payload = Encoding.UTF8.GetBytes(
"""
{"schemaVersion":7,"label":"prod-db","hostname":"db.internal","port":22,"pinnedPaths":["/var/www/app","/var/log","/var/www/app"]}
""");
HostSecretCodec.TryDecode(payload, out var document).ShouldBeTrue();
document.ShouldNotBeNull().Host.PinnedPaths
.ShouldBe(PinnedPathList.Create(["/var/www/app", "/var/log"]));
}
[Fact] [Fact]
public void AddingInheritanceAndTags_DidNotChangeTheBytesOfAHostUsingNeither() public void AddingInheritanceAndTags_DidNotChangeTheBytesOfAHostUsingNeither()
{ {
@@ -58,6 +58,7 @@ public sealed class HostSecretMergeTests
SshKeyId = DeployKey, SshKeyId = DeployKey,
GroupId = Production, GroupId = Production,
TagIds = TagSet.Create([Pci]), TagIds = TagSet.Create([Pci]),
PinnedPaths = PinnedPathList.Create(["/var/www/app"]),
}; };
var result = HostSecretMerge.Merge(ancestor, local, ancestor); var result = HostSecretMerge.Merge(ancestor, local, ancestor);
@@ -160,6 +161,102 @@ public sealed class HostSecretMergeTests
private static Guid[] Wearing(bool tagged) => tagged ? [Pci] : []; private static Guid[] Wearing(bool tagged) => tagged ? [Pci] : [];
[Fact]
public void TwoPeoplePinningDifferentPathsToOneHost_BothKeepTheirs()
{
// The same reason TagIds merges per tag rather than as a whole value: a whole-value merge would
// take one side's list entire and drop the other's.
var ancestor = Host();
var result = HostSecretMerge.Merge(
ancestor,
ancestor with { PinnedPaths = PinnedPathList.Create(["/var/www/app"]) },
ancestor with { PinnedPaths = PinnedPathList.Create(["/var/log"]) });
result.Merged.PinnedPaths.ShouldBe(PinnedPathList.Create(["/var/www/app", "/var/log"]));
result.HasConflicts.ShouldBeFalse();
}
[Fact]
public void PinnedPathAdditions_ComeOutInBaseOrderThenLocalThenRemote()
{
// The order guarantee this merge exists to keep: unlike a tag chip, a pinned path's position is
// part of what the user set, so the merge must produce a deterministic order rather than whatever
// a set union happens to yield.
var ancestor = Host(pinnedPaths: ["/base/one", "/base/two"]);
var result = HostSecretMerge.Merge(
ancestor,
ancestor with
{
PinnedPaths = PinnedPathList.Create(["/base/one", "/base/two", "/local/added"]),
},
ancestor with
{
PinnedPaths = PinnedPathList.Create(["/base/one", "/base/two", "/remote/added"]),
});
result.Merged.PinnedPaths.ShouldBe(
PinnedPathList.Create(["/base/one", "/base/two", "/local/added", "/remote/added"]));
result.HasConflicts.ShouldBeFalse();
}
[Fact]
public void OneSideRemovingAPinnedPathWhileTheOtherAddsAnother_KeepsBothDecisions()
{
// Each path is resolved on its own, so a removal on one side and an addition on the other are two
// independent answers rather than two versions of one. A whole-value merge would have to pick.
var ancestor = Host(pinnedPaths: ["/var/www/app"]);
var result = HostSecretMerge.Merge(
ancestor,
ancestor with { PinnedPaths = PinnedPathList.Empty },
ancestor with
{
PinnedPaths = PinnedPathList.Create(["/var/www/app", "/var/log"]),
});
result.Merged.PinnedPaths.ShouldBe(PinnedPathList.Create(["/var/log"]));
result.HasConflicts.ShouldBeFalse();
}
[Fact]
public void APinnedPathRemovedOnBothSides_IsNotResurrected()
{
var ancestor = Host(pinnedPaths: ["/var/www/app", "/var/log"]);
var withoutApp = ancestor with { PinnedPaths = PinnedPathList.Create(["/var/log"]) };
var result = HostSecretMerge.Merge(ancestor, withoutApp, withoutApp);
result.Merged.PinnedPaths.ShouldBe(PinnedPathList.Create(["/var/log"]));
result.HasConflicts.ShouldBeFalse();
}
/// <inheritdoc cref="NoArrangementOfOneTag_ProducesAConflict" />
[Theory]
[InlineData(true, true, true)]
[InlineData(true, true, false)]
[InlineData(true, false, true)]
[InlineData(true, false, false)]
[InlineData(false, true, true)]
[InlineData(false, true, false)]
[InlineData(false, false, true)]
[InlineData(false, false, false)]
public void NoArrangementOfOnePinnedPath_ProducesAConflict(bool inAncestor, bool inLocal, bool inRemote)
{
var result = HostSecretMerge.Merge(
Host(pinnedPaths: Pinning(inAncestor)),
Host(pinnedPaths: Pinning(inLocal)),
Host(pinnedPaths: Pinning(inRemote)));
result.HasConflicts.ShouldBeFalse();
result.Merged.PinnedPaths.Contains("/var/www/app")
.ShouldBe(inAncestor ? inLocal && inRemote : inLocal || inRemote);
}
private static string[] Pinning(bool pinned) => pinned ? ["/var/www/app"] : [];
[Fact] [Fact]
public void TwoSidesTakingDifferentPorts_NamesTheInheritedOneInTheConflict() public void TwoSidesTakingDifferentPorts_NamesTheInheritedOneInTheConflict()
{ {
@@ -189,6 +189,76 @@ public sealed class ValueSemanticsTests
map[EuWest].ShouldBe(EuWest); map[EuWest].ShouldBe(EuWest);
} }
[Fact]
public void APinnedPathList_ComparesByContentsAndOrder()
{
// The half that differs from a tag set and matches a jump chain: a pinned-path list is what a
// shortcut menu draws top to bottom, so reordering it is a change, not a no-op.
PinnedPathList.Create(["/var/log", "/var/www/app"])
.Equals(PinnedPathList.Create(["/var/log", "/var/www/app"]))
.ShouldBeTrue();
(PinnedPathList.Create(["/var/log", "/var/www/app"])
== PinnedPathList.Create(["/var/log", "/var/www/app"])).ShouldBeTrue();
PinnedPathList.Create(["/var/log", "/var/www/app"])
.Equals(PinnedPathList.Create(["/var/www/app", "/var/log"]))
.ShouldBeFalse();
PinnedPathList.Create(["/var/log"])
.Equals(PinnedPathList.Create(["/var/log", "/var/www/app"]))
.ShouldBeFalse();
PinnedPathList.Create([]).Equals(PinnedPathList.Empty).ShouldBeTrue();
PinnedPathList.Create(["/var/log"]).Equals(null).ShouldBeFalse();
}
[Fact]
public void APinnedPathList_HashesByContentsAndOrder()
{
PinnedPathList.Create(["/var/log", "/var/www/app"]).GetHashCode()
.ShouldBe(PinnedPathList.Create(["/var/log", "/var/www/app"]).GetHashCode());
}
[Fact]
public void APinnedPathList_ComparesPathsOrdinally()
{
// A remote path is meaningful only to the far end, and that end may run a case-sensitive
// filesystem — so folding case here would be a decision this client has no business making.
PinnedPathList.Create(["/var/log"]).Equals(PinnedPathList.Create(["/VAR/LOG"])).ShouldBeFalse();
}
[Fact]
public void APinnedPathList_CollapsesARepeatedPathKeepingTheFirstOccurrence()
{
// A repeat arrives from a payload some other client wrote, or from the same path pinned twice on
// one machine. Left alone it would compare unequal to the same list written once, and the engine
// would push the host as changed on every pass for ever.
PinnedPathList.Create(["/var/log", "/var/log"]).Equals(PinnedPathList.Create(["/var/log"]))
.ShouldBeTrue();
PinnedPathList.Create(["/var/log", "/var/log"]).Count.ShouldBe(1);
// The first occurrence survives rather than the last, so a later repeat cannot silently move an
// earlier entry to a new position in the list.
PinnedPathList.Create(["/var/log", "/var/www/app", "/var/log"])[0].ShouldBe("/var/log");
PinnedPathList.Create(["/var/log", "/var/www/app", "/var/log"]).Count.ShouldBe(2);
}
[Fact]
public void APinnedPathList_MapsToItsOwnPathsInListOrder()
{
// The value repeats the key on purpose, exactly as TagSet.ToIdMap's does: no key can then hold two
// different values, so the only disagreement a keyed merge can report is one side adding what the
// other removed. Unlike TagSet, the map is built in list order rather than a canonical one, which
// is what lets the merge reproduce "base order, then additions".
var map = PinnedPathList.Create(["/var/log", "/var/www/app"]).ToPathMap();
map.Keys.ShouldBe(new[] { "/var/log", "/var/www/app" });
map["/var/log"].ShouldBe("/var/log");
map["/var/www/app"].ShouldBe("/var/www/app");
}
[Fact] [Fact]
public void TryValidate_RejectsWhatCannotBeStored() public void TryValidate_RejectsWhatCannotBeStored()
{ {
@@ -200,11 +270,39 @@ public sealed class ValueSemanticsTests
Host(sshKeyId: Guid.Empty).TryValidate(out _).ShouldBeFalse(); Host(sshKeyId: Guid.Empty).TryValidate(out _).ShouldBeFalse();
Host(credentialId: Guid.Empty).TryValidate(out _).ShouldBeFalse(); Host(credentialId: Guid.Empty).TryValidate(out _).ShouldBeFalse();
Host(tags: [Guid.Empty]).TryValidate(out _).ShouldBeFalse(); Host(tags: [Guid.Empty]).TryValidate(out _).ShouldBeFalse();
Host(pinnedPaths: [" "]).TryValidate(out _).ShouldBeFalse();
Host(pinnedPaths: ["/has\0nul"]).TryValidate(out _).ShouldBeFalse();
// Null is "take the group's port", not an absent one, and it has to be storable — it is the whole // Null is "take the group's port", not an absent one, and it has to be storable — it is the whole
// of what inheritance stores. // of what inheritance stores.
Host(port: null).TryValidate(out _).ShouldBeTrue(); Host(port: null).TryValidate(out _).ShouldBeTrue();
Host().TryValidate(out _).ShouldBeTrue(); Host().TryValidate(out _).ShouldBeTrue();
// A relative path is exactly as valid as an absolute one: it resolves against the account's home,
// which this client never needs to know.
Host(pinnedPaths: ["relative/within/home"]).TryValidate(out _).ShouldBeTrue();
}
[Fact]
public void TryValidate_BoundsThePinnedPaths()
{
Host(pinnedPaths: [new string('a', HostSecret.MaxPinnedPathLength)])
.TryValidate(out _).ShouldBeTrue();
Host(pinnedPaths: [new string('a', HostSecret.MaxPinnedPathLength + 1)])
.TryValidate(out var tooLong).ShouldBeFalse();
tooLong.ShouldNotBeNull().ShouldContain("longer than");
var atLimit = Enumerable.Range(0, HostSecret.MaxPinnedPaths)
.Select(i => $"/path/{i}")
.ToArray();
Host(pinnedPaths: atLimit).TryValidate(out _).ShouldBeTrue();
var overLimit = Enumerable.Range(0, HostSecret.MaxPinnedPaths + 1)
.Select(i => $"/path/{i}")
.ToArray();
Host(pinnedPaths: overLimit).TryValidate(out var tooMany).ShouldBeFalse();
tooMany.ShouldNotBeNull().ShouldContain("cannot pin more than");
} }
[Fact] [Fact]