feat: add Subscription types and subject validation with wildcard matching

This commit is contained in:
Joseph Doherty
2026-02-22 19:53:49 -05:00
parent 05b07407a8
commit 270ab27ce3
4 changed files with 198 additions and 0 deletions

View File

@@ -0,0 +1,15 @@
namespace NATS.Server.Subscriptions;
public sealed class SubListResult
{
public static readonly SubListResult Empty = new([], []);
public Subscription[] PlainSubs { get; }
public Subscription[][] QueueSubs { get; } // outer = groups, inner = members
public SubListResult(Subscription[] plainSubs, Subscription[][] queueSubs)
{
PlainSubs = plainSubs;
QueueSubs = queueSubs;
}
}

View File

@@ -0,0 +1,116 @@
namespace NATS.Server.Subscriptions;
public static class SubjectMatch
{
public const char Pwc = '*'; // partial wildcard
public const char Fwc = '>'; // full wildcard
public const char Sep = '.'; // token separator
public static bool IsValidSubject(string subject)
{
if (string.IsNullOrEmpty(subject))
return false;
bool sawFwc = false;
int start = 0;
for (int i = 0; i <= subject.Length; i++)
{
if (i == subject.Length || subject[i] == Sep)
{
int tokenLen = i - start;
if (tokenLen == 0 || sawFwc)
return false;
if (tokenLen == 1)
{
char c = subject[start];
if (c == Fwc)
sawFwc = true;
else if (c is ' ' or '\t' or '\n' or '\r' or '\f')
return false;
}
else
{
for (int j = start; j < i; j++)
{
char c = subject[j];
if (c is ' ' or '\t' or '\n' or '\r' or '\f')
return false;
}
}
start = i + 1;
}
}
return true;
}
public static bool IsLiteral(string subject)
{
for (int i = 0; i < subject.Length; i++)
{
char c = subject[i];
if (c is Pwc or Fwc)
{
bool atStart = i == 0 || subject[i - 1] == Sep;
bool atEnd = i + 1 == subject.Length || subject[i + 1] == Sep;
if (atStart && atEnd)
return false;
}
}
return true;
}
public static bool IsValidPublishSubject(string subject)
{
return IsValidSubject(subject) && IsLiteral(subject);
}
/// <summary>
/// Match a literal subject against a pattern that may contain wildcards.
/// </summary>
public static bool MatchLiteral(string literal, string pattern)
{
int li = 0, pi = 0;
while (pi < pattern.Length)
{
// Get next pattern token
int pTokenStart = pi;
while (pi < pattern.Length && pattern[pi] != Sep)
pi++;
int pTokenLen = pi - pTokenStart;
if (pi < pattern.Length)
pi++; // skip separator
// Full wildcard -- matches everything remaining
if (pTokenLen == 1 && pattern[pTokenStart] == Fwc)
return li < literal.Length; // must have at least one token left
// Get next literal token
if (li >= literal.Length)
return false;
int lTokenStart = li;
while (li < literal.Length && literal[li] != Sep)
li++;
int lTokenLen = li - lTokenStart;
if (li < literal.Length)
li++; // skip separator
// Partial wildcard -- matches any single token
if (pTokenLen == 1 && pattern[pTokenStart] == Pwc)
continue;
// Literal comparison
if (pTokenLen != lTokenLen)
return false;
if (string.Compare(literal, lTokenStart, pattern, pTokenStart, pTokenLen, StringComparison.Ordinal) != 0)
return false;
}
return li >= literal.Length; // both exhausted
}
}

View File

@@ -0,0 +1,10 @@
namespace NATS.Server.Subscriptions;
public sealed class Subscription
{
public required string Subject { get; init; }
public string? Queue { get; init; }
public required string Sid { get; init; }
public long MessageCount; // Interlocked
public long MaxMessages; // 0 = unlimited
}

View File

@@ -0,0 +1,57 @@
using NATS.Server.Subscriptions;
namespace NATS.Server.Tests;
public class SubjectMatchTests
{
[Theory]
[InlineData("foo", true)]
[InlineData("foo.bar", true)]
[InlineData("foo.bar.baz", true)]
[InlineData("foo.*", true)]
[InlineData("foo.>", true)]
[InlineData(">", true)]
[InlineData("*", true)]
[InlineData("*.bar", true)]
[InlineData("foo.*.baz", true)]
[InlineData("", false)]
[InlineData("foo.", false)]
[InlineData(".foo", false)]
[InlineData("foo..bar", false)]
[InlineData("foo.>.bar", false)] // > must be last token
[InlineData("foo bar", false)] // no spaces
[InlineData("foo\tbar", false)] // no tabs
public void IsValidSubject(string subject, bool expected)
{
Assert.Equal(expected, SubjectMatch.IsValidSubject(subject));
}
[Theory]
[InlineData("foo", true)]
[InlineData("foo.bar.baz", true)]
[InlineData("foo.*", false)]
[InlineData("foo.>", false)]
[InlineData(">", false)]
[InlineData("*", false)]
public void IsValidPublishSubject(string subject, bool expected)
{
Assert.Equal(expected, SubjectMatch.IsValidPublishSubject(subject));
}
[Theory]
[InlineData("foo", "foo", true)]
[InlineData("foo", "bar", false)]
[InlineData("foo.bar", "foo.*", true)]
[InlineData("foo.bar", "*.bar", true)]
[InlineData("foo.bar", "*.*", true)]
[InlineData("foo.bar.baz", "foo.>", true)]
[InlineData("foo.bar.baz", ">", true)]
[InlineData("foo.bar", "foo.>", true)]
[InlineData("foo", "foo.>", false)]
[InlineData("foo.bar.baz", "foo.*", false)]
[InlineData("foo.bar", "foo.bar.>", false)]
public void MatchLiteral(string literal, string pattern, bool expected)
{
Assert.Equal(expected, SubjectMatch.MatchLiteral(literal, pattern));
}
}