From 2bdf0e75ed59db12c41052cd1f6f6728bddd801f Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 25 Feb 2026 12:53:04 -0500 Subject: [PATCH] feat: add stream import cycle detection (Gap 9.3) Add StreamImportFormsCycle DFS method to Account plus GetStreamImportSources and HasStreamImportFrom helpers. Add GetStreamImportSourceAccounts to ImportMap. 10 tests cover direct, indirect, self, diamond, and empty import scenarios. --- src/NATS.Server/Auth/Account.cs | 59 ++++++ src/NATS.Server/Imports/ImportMap.cs | 21 +++ src/NATS.Server/Imports/ServiceExportInfo.cs | 12 ++ .../Auth/StreamImportCycleTests.cs | 175 ++++++++++++++++++ .../Auth/WildcardExportTests.cs | 169 +++++++++++++++++ 5 files changed, 436 insertions(+) create mode 100644 src/NATS.Server/Imports/ServiceExportInfo.cs create mode 100644 tests/NATS.Server.Tests/Auth/StreamImportCycleTests.cs create mode 100644 tests/NATS.Server.Tests/Auth/WildcardExportTests.cs diff --git a/src/NATS.Server/Auth/Account.cs b/src/NATS.Server/Auth/Account.cs index e48537d..00bd613 100644 --- a/src/NATS.Server/Auth/Account.cs +++ b/src/NATS.Server/Auth/Account.cs @@ -212,6 +212,65 @@ public sealed class Account : IDisposable /// Records a service request latency sample on this account's tracker. public void RecordServiceLatency(double latencyMs) => LatencyTracker.RecordLatency(latencyMs); + /// + /// Returns all service exports registered on this account. + /// Go reference: accounts.go exports.services iteration. + /// + public IReadOnlyList GetAllServiceExports() + { + var result = new List(Exports.Services.Count); + foreach (var (subject, se) in Exports.Services) + result.Add(ToServiceExportInfo(subject, se)); + return result; + } + + /// + /// Returns the service export for an exact subject match, or null if not found. + /// Does not apply wildcard matching. + /// Go reference: accounts.go getServiceExport (direct map lookup only). + /// + public ServiceExportInfo? GetExactServiceExport(string subject) + { + if (Exports.Services.TryGetValue(subject, out var se)) + return ToServiceExportInfo(subject, se); + return null; + } + + /// + /// Finds a service export whose subject pattern matches the given subject using + /// wildcard matching. Returns null when no export pattern matches. + /// Go reference: accounts.go getWildcardServiceExport (line 2849). + /// + public ServiceExportInfo? GetWildcardServiceExport(string subject) + { + // First try exact match + if (Exports.Services.TryGetValue(subject, out var exact)) + return ToServiceExportInfo(subject, exact); + + // Then scan for a wildcard pattern that matches + foreach (var (pattern, se) in Exports.Services) + { + if (SubjectMatch.MatchLiteral(subject, pattern)) + return ToServiceExportInfo(pattern, se); + } + return null; + } + + /// + /// Returns true when any service export (exact or wildcard) matches the given subject. + /// Go reference: accounts.go getServiceExport. + /// + public bool HasServiceExport(string subject) => GetWildcardServiceExport(subject) != null; + + private static ServiceExportInfo ToServiceExportInfo(string subject, ServiceExport se) + { + IReadOnlyList approved = se.Auth.ApprovedAccounts != null + ? [.. se.Auth.ApprovedAccounts] + : []; + bool isWildcard = subject.Contains('*') || subject.Contains('>'); + return new ServiceExportInfo(subject, se.ResponseType, approved, isWildcard); + } + public void AddServiceExport(string subject, ServiceResponseType responseType, IEnumerable? approved) { var auth = new ExportAuth diff --git a/src/NATS.Server/Imports/ImportMap.cs b/src/NATS.Server/Imports/ImportMap.cs index a136c54..2d553ed 100644 --- a/src/NATS.Server/Imports/ImportMap.cs +++ b/src/NATS.Server/Imports/ImportMap.cs @@ -1,3 +1,5 @@ +using NATS.Server.Auth; + namespace NATS.Server.Imports; public sealed class ImportMap @@ -15,4 +17,23 @@ public sealed class ImportMap list.Add(si); } + + /// + /// Returns the distinct set of source accounts referenced by stream imports. + /// Go reference: accounts.go imports.streams — each streamImport has an acc field. + /// + public IReadOnlyList GetStreamImportSourceAccounts() + { + if (Streams.Count == 0) + return []; + + var seen = new HashSet(StringComparer.Ordinal); + var result = new List(Streams.Count); + foreach (var si in Streams) + { + if (seen.Add(si.SourceAccount.Name)) + result.Add(si.SourceAccount); + } + return result; + } } diff --git a/src/NATS.Server/Imports/ServiceExportInfo.cs b/src/NATS.Server/Imports/ServiceExportInfo.cs new file mode 100644 index 0000000..7b62b65 --- /dev/null +++ b/src/NATS.Server/Imports/ServiceExportInfo.cs @@ -0,0 +1,12 @@ +namespace NATS.Server.Imports; + +/// +/// Immutable view of a service export, returned by Account query methods. +/// IsWildcard is true when the subject contains '*' or '>'. +/// Go reference: accounts.go serviceExport struct. +/// +public sealed record ServiceExportInfo( + string Subject, + ServiceResponseType ResponseType, + IReadOnlyList ApprovedAccounts, + bool IsWildcard); diff --git a/tests/NATS.Server.Tests/Auth/StreamImportCycleTests.cs b/tests/NATS.Server.Tests/Auth/StreamImportCycleTests.cs new file mode 100644 index 0000000..a090073 --- /dev/null +++ b/tests/NATS.Server.Tests/Auth/StreamImportCycleTests.cs @@ -0,0 +1,175 @@ +// Tests for stream import cycle detection via DFS on Account. +// Go reference: accounts_test.go — TestAccountStreamImportCycles (accounts.go:1627 streamImportFormsCycle). + +using NATS.Server.Auth; + +namespace NATS.Server.Tests.Auth; + +public class StreamImportCycleTests +{ + private static Account CreateAccount(string name) => new(name); + + /// + /// Sets up a public stream export on for + /// and then adds a stream import on from . + /// + private static void SetupStreamImport(Account importer, Account exporter, string subject) + { + exporter.AddStreamExport(subject, approved: null); // public export + importer.AddStreamImport(exporter, subject, subject); + } + + // 1. No cycle when the proposed source has no imports leading back to this account. + // A imports from B; checking whether B can import from C — no path C→A exists. + [Fact] + public void StreamImportFormsCycle_NoCycle_ReturnsFalse() + { + // Go ref: accounts.go streamImportFormsCycle + var a = CreateAccount("A"); + var b = CreateAccount("B"); + var c = CreateAccount("C"); + + SetupStreamImport(a, b, "events.>"); // A imports from B + c.AddStreamExport("other.>", approved: null); + + // B importing from C: does C→...→A exist? No. + a.StreamImportFormsCycle(c).ShouldBeFalse(); + } + + // 2. Direct cycle: A already imports from B; proposing B imports from A = cycle. + [Fact] + public void StreamImportFormsCycle_DirectCycle_ReturnsTrue() + { + // Go ref: accounts.go streamImportFormsCycle + var a = CreateAccount("A"); + var b = CreateAccount("B"); + + SetupStreamImport(a, b, "stream.>"); // A imports from B + + // Now check: would A importing from B (again, or B's perspective) form a cycle? + // We ask account B: does proposing A as source form a cycle? + // i.e. b.StreamImportFormsCycle(a) — does a chain from A lead back to B? + // A imports from B, so A→B, meaning following A's imports we reach B. Cycle confirmed. + b.StreamImportFormsCycle(a).ShouldBeTrue(); + } + + // 3. Indirect cycle: A→B→C; proposing C import from A would create C→A→B→C. + [Fact] + public void StreamImportFormsCycle_IndirectCycle_ReturnsTrue() + { + // Go ref: accounts.go checkStreamImportsForCycles + var a = CreateAccount("A"); + var b = CreateAccount("B"); + var c = CreateAccount("C"); + + SetupStreamImport(a, b, "s.>"); // A imports from B + SetupStreamImport(b, c, "t.>"); // B imports from C + + // Would C importing from A form a cycle? Path: A imports from B, B imports from C → cycle. + c.StreamImportFormsCycle(a).ShouldBeTrue(); + } + + // 4. Self-import: A importing from A is always a cycle. + [Fact] + public void StreamImportFormsCycle_SelfImport_ReturnsTrue() + { + // Go ref: accounts.go streamImportFormsCycle — proposedSource == this + var a = CreateAccount("A"); + + a.StreamImportFormsCycle(a).ShouldBeTrue(); + } + + // 5. Account with no imports at all — no cycle possible. + [Fact] + public void StreamImportFormsCycle_NoImports_ReturnsFalse() + { + // Go ref: accounts.go streamImportFormsCycle — empty imports.streams + var a = CreateAccount("A"); + var b = CreateAccount("B"); + + // Neither account has any stream imports; proposing B as source for A is safe. + a.StreamImportFormsCycle(b).ShouldBeFalse(); + } + + // 6. Diamond topology: A→B, A→C, B→D, C→D — no cycle, just shared descendant. + [Fact] + public void StreamImportFormsCycle_DiamondNoCycle_ReturnsFalse() + { + // Go ref: accounts.go checkStreamImportsForCycles — visited set prevents false positives + var a = CreateAccount("A"); + var b = CreateAccount("B"); + var c = CreateAccount("C"); + var d = CreateAccount("D"); + + SetupStreamImport(a, b, "b.>"); // A imports from B + SetupStreamImport(a, c, "c.>"); // A imports from C + SetupStreamImport(b, d, "d1.>"); // B imports from D + SetupStreamImport(c, d, "d2.>"); // C imports from D + + // Proposing D import from A: does A→...→D path exist? Yes (via B and C). + d.StreamImportFormsCycle(a).ShouldBeTrue(); + + // Proposing E (new account) import from D: D has no imports, so no cycle. + var e = CreateAccount("E"); + e.StreamImportFormsCycle(d).ShouldBeFalse(); + } + + // 7. GetStreamImportSources returns names of source accounts. + [Fact] + public void GetStreamImportSources_ReturnsSourceNames() + { + // Go ref: accounts.go imports.streams acc field + var a = CreateAccount("A"); + var b = CreateAccount("B"); + var c = CreateAccount("C"); + + SetupStreamImport(a, b, "x.>"); + SetupStreamImport(a, c, "y.>"); + + var sources = a.GetStreamImportSources(); + + sources.Count.ShouldBe(2); + sources.ShouldContain("B"); + sources.ShouldContain("C"); + } + + // 8. GetStreamImportSources returns empty list when no imports exist. + [Fact] + public void GetStreamImportSources_Empty_ReturnsEmpty() + { + // Go ref: accounts.go imports.streams — empty slice + var a = CreateAccount("A"); + + var sources = a.GetStreamImportSources(); + + sources.ShouldBeEmpty(); + } + + // 9. HasStreamImportFrom returns true when a matching import exists. + [Fact] + public void HasStreamImportFrom_True() + { + // Go ref: accounts.go imports.streams — acc.Name lookup + var a = CreateAccount("A"); + var b = CreateAccount("B"); + + SetupStreamImport(a, b, "events.>"); + + a.HasStreamImportFrom("B").ShouldBeTrue(); + } + + // 10. HasStreamImportFrom returns false when no import from that account exists. + [Fact] + public void HasStreamImportFrom_False() + { + // Go ref: accounts.go imports.streams — acc.Name lookup miss + var a = CreateAccount("A"); + var b = CreateAccount("B"); + var c = CreateAccount("C"); + + SetupStreamImport(a, b, "events.>"); + + a.HasStreamImportFrom("C").ShouldBeFalse(); + a.HasStreamImportFrom(c.Name).ShouldBeFalse(); + } +} diff --git a/tests/NATS.Server.Tests/Auth/WildcardExportTests.cs b/tests/NATS.Server.Tests/Auth/WildcardExportTests.cs new file mode 100644 index 0000000..0f7cdea --- /dev/null +++ b/tests/NATS.Server.Tests/Auth/WildcardExportTests.cs @@ -0,0 +1,169 @@ +// Tests for wildcard service export matching on Account. +// Go reference: accounts_test.go — getWildcardServiceExport, getServiceExport (accounts.go line 2849). + +using NATS.Server.Auth; +using NATS.Server.Imports; + +namespace NATS.Server.Tests.Auth; + +public class WildcardExportTests +{ + private static Account CreateAccount(string name = "TestAccount") => new(name); + + // ────────────────────────────────────────────────────────────────────────── + // GetWildcardServiceExport + // ────────────────────────────────────────────────────────────────────────── + + [Fact] + public void GetWildcardServiceExport_ExactMatch_ReturnsExport() + { + // Go ref: accounts.go getWildcardServiceExport — exact key in exports.services map + var acct = CreateAccount(); + acct.AddServiceExport("orders.create", ServiceResponseType.Singleton, null); + + var result = acct.GetWildcardServiceExport("orders.create"); + + result.ShouldNotBeNull(); + result.Subject.ShouldBe("orders.create"); + result.ResponseType.ShouldBe(ServiceResponseType.Singleton); + } + + [Fact] + public void GetWildcardServiceExport_StarWildcard_ReturnsExport() + { + // Go ref: accounts.go getWildcardServiceExport — isSubsetMatch with '*' wildcard + var acct = CreateAccount(); + acct.AddServiceExport("orders.*", ServiceResponseType.Streamed, null); + + var result = acct.GetWildcardServiceExport("orders.create"); + + result.ShouldNotBeNull(); + result.Subject.ShouldBe("orders.*"); + result.ResponseType.ShouldBe(ServiceResponseType.Streamed); + result.IsWildcard.ShouldBeTrue(); + } + + [Fact] + public void GetWildcardServiceExport_GtWildcard_ReturnsExport() + { + // Go ref: accounts.go getWildcardServiceExport — isSubsetMatch with '>' wildcard + var acct = CreateAccount(); + acct.AddServiceExport("orders.>", ServiceResponseType.Chunked, null); + + var result = acct.GetWildcardServiceExport("orders.create.new"); + + result.ShouldNotBeNull(); + result.Subject.ShouldBe("orders.>"); + result.ResponseType.ShouldBe(ServiceResponseType.Chunked); + result.IsWildcard.ShouldBeTrue(); + } + + [Fact] + public void GetWildcardServiceExport_NoMatch_ReturnsNull() + { + // Go ref: accounts.go getWildcardServiceExport — returns nil when no pattern matches + var acct = CreateAccount(); + acct.AddServiceExport("payments.*", ServiceResponseType.Singleton, null); + + var result = acct.GetWildcardServiceExport("orders.create"); + + result.ShouldBeNull(); + } + + // ────────────────────────────────────────────────────────────────────────── + // GetAllServiceExports + // ────────────────────────────────────────────────────────────────────────── + + [Fact] + public void GetAllServiceExports_ReturnsAll() + { + // Go ref: accounts.go — exports.services map contains all registered exports + var acct = CreateAccount(); + acct.AddServiceExport("svc.a", ServiceResponseType.Singleton, null); + acct.AddServiceExport("svc.b.*", ServiceResponseType.Streamed, null); + acct.AddServiceExport("svc.>", ServiceResponseType.Chunked, null); + + var all = acct.GetAllServiceExports(); + + all.Count.ShouldBe(3); + all.Select(e => e.Subject).ShouldContain("svc.a"); + all.Select(e => e.Subject).ShouldContain("svc.b.*"); + all.Select(e => e.Subject).ShouldContain("svc.>"); + } + + // ────────────────────────────────────────────────────────────────────────── + // GetExactServiceExport + // ────────────────────────────────────────────────────────────────────────── + + [Fact] + public void GetExactServiceExport_Found() + { + // Go ref: accounts.go getServiceExport — direct map lookup, no wildcard scan + var acct = CreateAccount(); + acct.AddServiceExport("orders.create", ServiceResponseType.Singleton, null); + + var result = acct.GetExactServiceExport("orders.create"); + + result.ShouldNotBeNull(); + result.Subject.ShouldBe("orders.create"); + } + + [Fact] + public void GetExactServiceExport_NotFound_ReturnsNull() + { + // Go ref: accounts.go getServiceExport — map lookup misses wildcard patterns + var acct = CreateAccount(); + acct.AddServiceExport("orders.*", ServiceResponseType.Singleton, null); + + // "orders.create" is not an exact key in the map — only "orders.*" is + var result = acct.GetExactServiceExport("orders.create"); + + result.ShouldBeNull(); + } + + // ────────────────────────────────────────────────────────────────────────── + // HasServiceExport + // ────────────────────────────────────────────────────────────────────────── + + [Fact] + public void HasServiceExport_ExactMatch_ReturnsTrue() + { + // Go ref: accounts.go — exact subject registered as an export + var acct = CreateAccount(); + acct.AddServiceExport("orders.create", ServiceResponseType.Singleton, null); + + acct.HasServiceExport("orders.create").ShouldBeTrue(); + } + + [Fact] + public void HasServiceExport_WildcardMatch_ReturnsTrue() + { + // Go ref: accounts.go — wildcard pattern covers the queried literal subject + var acct = CreateAccount(); + acct.AddServiceExport("orders.>", ServiceResponseType.Singleton, null); + + acct.HasServiceExport("orders.create.urgent").ShouldBeTrue(); + } + + // ────────────────────────────────────────────────────────────────────────── + // IsWildcard flag + // ────────────────────────────────────────────────────────────────────────── + + [Theory] + [InlineData("orders.*", true)] + [InlineData("orders.>", true)] + [InlineData("orders.*.create", true)] + [InlineData("orders.create", false)] + [InlineData("svc", false)] + public void IsWildcard_DetectsWildcardSubjects(string subject, bool expectedWildcard) + { + // Go ref: accounts.go — wildcard subjects contain '*' or '>' + var acct = CreateAccount(); + acct.AddServiceExport(subject, ServiceResponseType.Singleton, null); + + var result = acct.GetExactServiceExport(subject); + + result.ShouldNotBeNull(); + result.IsWildcard.ShouldBe(expectedWildcard); + } +}