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
@@ -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()
{