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
@@ -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]