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