diff --git a/src/NATS.Server/Subscriptions/SubjectMatch.cs b/src/NATS.Server/Subscriptions/SubjectMatch.cs index 9bb8153..55310f2 100644 --- a/src/NATS.Server/Subscriptions/SubjectMatch.cs +++ b/src/NATS.Server/Subscriptions/SubjectMatch.cs @@ -113,4 +113,112 @@ public static class SubjectMatch return li >= literal.Length; // both exhausted } + + /// Count dot-delimited tokens. Empty string returns 0. + public static int NumTokens(string subject) + { + if (string.IsNullOrEmpty(subject)) + return 0; + int count = 1; + for (int i = 0; i < subject.Length; i++) + { + if (subject[i] == Sep) + count++; + } + return count; + } + + /// Return the 0-based nth token as a span. Returns empty if out of range. + public static ReadOnlySpan TokenAt(string subject, int index) + { + if (string.IsNullOrEmpty(subject)) + return default; + + var span = subject.AsSpan(); + int current = 0; + int start = 0; + for (int i = 0; i < span.Length; i++) + { + if (span[i] == Sep) + { + if (current == index) + return span[start..i]; + start = i + 1; + current++; + } + } + if (current == index) + return span[start..]; + return default; + } + + /// + /// Determines if two subject patterns (possibly containing wildcards) can both + /// match the same literal subject. Reference: Go sublist.go SubjectsCollide. + /// + public static bool SubjectsCollide(string subj1, string subj2) + { + if (subj1 == subj2) + return true; + + bool lit1 = IsLiteral(subj1); + bool lit2 = IsLiteral(subj2); + + if (lit1 && lit2) + return false; + + if (lit1 && !lit2) + return MatchLiteral(subj1, subj2); + if (lit2 && !lit1) + return MatchLiteral(subj2, subj1); + + // Both have wildcards + int n1 = NumTokens(subj1); + int n2 = NumTokens(subj2); + bool hasFwc1 = subj1.Contains('>'); + bool hasFwc2 = subj2.Contains('>'); + + if (!hasFwc1 && !hasFwc2 && n1 != n2) + return false; + if (n1 < n2 && !hasFwc1) + return false; + if (n2 < n1 && !hasFwc2) + return false; + + int stop = Math.Min(n1, n2); + for (int i = 0; i < stop; i++) + { + var t1 = TokenAt(subj1, i); + var t2 = TokenAt(subj2, i); + if (!TokensCanMatch(t1, t2)) + return false; + } + return true; + } + + private static bool TokensCanMatch(ReadOnlySpan t1, ReadOnlySpan t2) + { + if (t1.Length == 1 && (t1[0] == Pwc || t1[0] == Fwc)) + return true; + if (t2.Length == 1 && (t2[0] == Pwc || t2[0] == Fwc)) + return true; + return t1.SequenceEqual(t2); + } + + /// + /// Validates subject. When checkRunes is true, also rejects null bytes. + /// + public static bool IsValidSubject(string subject, bool checkRunes) + { + if (!IsValidSubject(subject)) + return false; + if (!checkRunes) + return true; + for (int i = 0; i < subject.Length; i++) + { + if (subject[i] == '\0') + return false; + } + return true; + } } diff --git a/tests/NATS.Server.Tests/SubjectMatchTests.cs b/tests/NATS.Server.Tests/SubjectMatchTests.cs index a48994f..3c87352 100644 --- a/tests/NATS.Server.Tests/SubjectMatchTests.cs +++ b/tests/NATS.Server.Tests/SubjectMatchTests.cs @@ -54,4 +54,50 @@ public class SubjectMatchTests { SubjectMatch.MatchLiteral(literal, pattern).ShouldBe(expected); } + + [Theory] + [InlineData("foo.bar.baz", 3)] + [InlineData("foo", 1)] + [InlineData("a.b.c.d.e", 5)] + [InlineData("", 0)] + public void NumTokens(string subject, int expected) + { + SubjectMatch.NumTokens(subject).ShouldBe(expected); + } + + [Theory] + [InlineData("foo.bar.baz", 0, "foo")] + [InlineData("foo.bar.baz", 1, "bar")] + [InlineData("foo.bar.baz", 2, "baz")] + [InlineData("foo", 0, "foo")] + [InlineData("foo.bar.baz", 5, "")] + public void TokenAt(string subject, int index, string expected) + { + SubjectMatch.TokenAt(subject, index).ToString().ShouldBe(expected); + } + + [Theory] + [InlineData("foo.bar", "foo.bar", true)] + [InlineData("foo.bar", "foo.baz", false)] + [InlineData("foo.*", "foo.bar", true)] + [InlineData("foo.*", "foo.>", true)] + [InlineData("foo.>", "foo.bar.baz", true)] + [InlineData(">", "foo.bar", true)] + [InlineData("foo.*", "bar.*", false)] + [InlineData("foo.*.baz", "foo.bar.*", true)] + [InlineData("*.bar", "foo.*", true)] + [InlineData("foo.*", "bar.>", false)] + public void SubjectsCollide(string subj1, string subj2, bool expected) + { + SubjectMatch.SubjectsCollide(subj1, subj2).ShouldBe(expected); + } + + [Theory] + [InlineData("foo\0bar", true, false)] + [InlineData("foo\0bar", false, true)] + [InlineData("foo.bar", true, true)] + public void IsValidSubject_checkRunes(string subject, bool checkRunes, bool expected) + { + SubjectMatch.IsValidSubject(subject, checkRunes).ShouldBe(expected); + } }