diff --git a/src/NATS.Server/Subscriptions/SubListResult.cs b/src/NATS.Server/Subscriptions/SubListResult.cs new file mode 100644 index 0000000..c6e4b64 --- /dev/null +++ b/src/NATS.Server/Subscriptions/SubListResult.cs @@ -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; + } +} diff --git a/src/NATS.Server/Subscriptions/SubjectMatch.cs b/src/NATS.Server/Subscriptions/SubjectMatch.cs new file mode 100644 index 0000000..9bb8153 --- /dev/null +++ b/src/NATS.Server/Subscriptions/SubjectMatch.cs @@ -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); + } + + /// + /// Match a literal subject against a pattern that may contain wildcards. + /// + 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 + } +} diff --git a/src/NATS.Server/Subscriptions/Subscription.cs b/src/NATS.Server/Subscriptions/Subscription.cs new file mode 100644 index 0000000..b77e0ad --- /dev/null +++ b/src/NATS.Server/Subscriptions/Subscription.cs @@ -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 +} diff --git a/tests/NATS.Server.Tests/SubjectMatchTests.cs b/tests/NATS.Server.Tests/SubjectMatchTests.cs new file mode 100644 index 0000000..8bc10c2 --- /dev/null +++ b/tests/NATS.Server.Tests/SubjectMatchTests.cs @@ -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)); + } +}