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
@@ -39,7 +39,8 @@ internal static class HostFactory
Guid? credentialId = null,
bool? asksForPassword = null,
Guid? groupId = null,
Guid[]? tags = null) =>
Guid[]? tags = null,
string[]? pinnedPaths = null) =>
new()
{
Label = label,
@@ -57,6 +58,7 @@ internal static class HostFactory
AsksForPassword = asksForPassword,
GroupId = groupId,
TagIds = tags is null ? TagSet.Empty : TagSet.Create(tags),
PinnedPaths = pinnedPaths is null ? PinnedPathList.Empty : PinnedPathList.Create(pinnedPaths),
};
/// <summary>
@@ -17,9 +17,9 @@ public sealed class HostSecretCodecTests
/// <remarks>
/// "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
/// one carries the credential, because that is the newer of the two, plus a group and a pair of tags
/// which are orthogonal to the binding and are what make this host reach the highest schema version a
/// valid host can.
/// one carries the credential, because that is the newer of the two, plus a group, a pair of tags and a
/// pair of pinned paths — which are orthogonal to the binding and are what make this host reach the
/// highest schema version a valid host can.
/// </remarks>
[Fact]
public void AFullHost_RoundTrips()
@@ -37,7 +37,8 @@ public sealed class HostSecretCodecTests
relayEnabled: true,
credentialId: credentialId,
groupId: Production,
tags: [Pci, EuWest]);
tags: [Pci, EuWest],
pinnedPaths: ["/var/www/app", "/var/log"]);
HostSecretCodec.TryDecode(HostSecretCodec.Encode(host), out var document).ShouldBeTrue();
@@ -219,6 +220,90 @@ public sealed class HostSecretCodecTests
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]
public void AddingInheritanceAndTags_DidNotChangeTheBytesOfAHostUsingNeither()
{
@@ -58,6 +58,7 @@ public sealed class HostSecretMergeTests
SshKeyId = DeployKey,
GroupId = Production,
TagIds = TagSet.Create([Pci]),
PinnedPaths = PinnedPathList.Create(["/var/www/app"]),
};
var result = HostSecretMerge.Merge(ancestor, local, ancestor);
@@ -160,6 +161,102 @@ public sealed class HostSecretMergeTests
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]
public void TwoSidesTakingDifferentPorts_NamesTheInheritedOneInTheConflict()
{
@@ -189,6 +189,76 @@ public sealed class ValueSemanticsTests
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]
public void TryValidate_RejectsWhatCannotBeStored()
{
@@ -200,11 +270,39 @@ public sealed class ValueSemanticsTests
Host(sshKeyId: Guid.Empty).TryValidate(out _).ShouldBeFalse();
Host(credentialId: 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
// of what inheritance stores.
Host(port: null).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]