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