From bc37a32d3c026bbdd603b95a1c0ffb3235530a15 Mon Sep 17 00:00:00 2001 From: Nick Nassiri Date: Sat, 20 Jun 2026 16:30:04 -0700 Subject: [PATCH 1/2] test(#495): make DNS/HTTP tests hermetic; gate live-network tests behind a trait A handful of tests made real outbound DNS/HTTP calls. On a flaky CI runner the in-flight request keeps the event loop alive to the 30s test timeout (false-red), and the matrix's default fail-fast then cancels the other platforms. A prior Skip.If(output.Length==0) band-aid masked genuine no-output regressions. - FetchReturnsPromise: hit the local MockHttpServer instead of example.com. - DnsRecordTypeTests: delete the two *_InvalidDomain_* tests (already covered deterministically by DnsFakeServerModuleTests NXDOMAIN); tag the google.com LiveSmoke tests [Trait Category=LiveNetwork]; drop the Skip-on-empty guard. - DnsModuleTests: tag the two invalid-hostname lookup tests LiveNetwork (dns.lookup uses getaddrinfo - no fake-server seam). - LiveNetworkHosts.cs (new): the single sanctioned home for external host literals. - LiveNetworkHermeticityTests.cs (new): guardrail meta-test - fails the build if an inline external literal appears outside that file, or a file using it isn't tagged. - CI: strategy.fail-fast: false; Test step --filter "Category!=LiveNetwork". - DnsFakeServerModuleTests: answer A/AAAA from the fake server (used by the resolve4 migration in the next commit). Refs #495 --- .github/workflows/ci.yml | 9 +- .../LiveNetworkHermeticityTests.cs | 164 ++++++++++++++++++ .../Infrastructure/LiveNetworkHosts.cs | 37 ++++ .../DnsFakeServerModuleTests.cs | 2 + .../BuiltInModules/DnsModuleTests.cs | 13 +- .../BuiltInModules/DnsRecordTypeTests.cs | 104 +++-------- SharpTS.Tests/SharedTests/HttpModuleTests.cs | 9 +- 7 files changed, 248 insertions(+), 90 deletions(-) create mode 100644 SharpTS.Tests/CompilerTests/LiveNetworkHermeticityTests.cs create mode 100644 SharpTS.Tests/Infrastructure/LiveNetworkHosts.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 43cca099..d1d315d9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,9 @@ jobs: build: runs-on: ${{ matrix.os }} strategy: + # Don't let one platform's failure cancel the others — we want to see each + # platform's real result independently (a flake on one shouldn't hide the rest). + fail-fast: false matrix: os: [ubuntu-latest, windows-latest, macos-latest] @@ -53,6 +56,10 @@ jobs: - name: Build run: dotnet build --no-restore --configuration Release + # Category!=LiveNetwork excludes the opt-in tests that make real outbound + # DNS/HTTP calls (tagged [Trait("Category","LiveNetwork")]); those depend on + # the runner's network and false-red CI (#495). Run them on demand locally: + # dotnet test --filter "Category=LiveNetwork" - name: Test - run: dotnet test --no-build --configuration Release --verbosity normal + run: dotnet test --no-build --configuration Release --verbosity normal --filter "Category!=LiveNetwork" timeout-minutes: 10 diff --git a/SharpTS.Tests/CompilerTests/LiveNetworkHermeticityTests.cs b/SharpTS.Tests/CompilerTests/LiveNetworkHermeticityTests.cs new file mode 100644 index 00000000..5aec27ef --- /dev/null +++ b/SharpTS.Tests/CompilerTests/LiveNetworkHermeticityTests.cs @@ -0,0 +1,164 @@ +using System.Text.RegularExpressions; +using Xunit; + +namespace SharpTS.Tests.CompilerTests; + +/// +/// Guardrail meta-tests that keep the suite hermetic: a test must either make no +/// real outbound network call, or be explicitly opted into the live-network set +/// (tagged [Trait("Category","LiveNetwork")], which CI excludes via +/// --filter "Category!=LiveNetwork"). See issue #495. +/// +/// +/// Modeled on 's source-scanning guards. The earlier +/// band-aid (Skip.If(output.Length==0)) silently masked regressions; this +/// replaces it with a positive, enforced invariant. The two checks below enforce: +/// +/// No external host literal (a non-loopback fetch('http://…'), +/// google.com, or a reserved *.example name) appears anywhere +/// outside — so every live-network +/// host is centralized and auditable, and hermetic tests can't inline one. +/// Any file that uses a +/// constant carries the LiveNetwork trait — so live tests are actually +/// excluded from CI rather than silently false-redding it. +/// +/// +public class LiveNetworkHermeticityTests +{ + // The only files permitted to contain external (real-internet) host literals. + private static readonly HashSet Allowlist = new(StringComparer.OrdinalIgnoreCase) + { + "SharpTS.Tests/Infrastructure/LiveNetworkHosts.cs", // the sanctioned home for the literals + "SharpTS.Tests/CompilerTests/LiveNetworkHermeticityTests.cs", // this guard (contains the patterns) + }; + + // A fetch() call whose URL is a quoted absolute http(s) literal. The host is + // checked against the loopback set in IsExternalHost (loopback fetches are + // hermetic — they hit MockHttpServer / a test-local listener). + private static readonly Regex ExternalFetch = + new(@"fetch\(\s*[""'](?https?://[^""']+)", RegexOptions.Compiled); + + // External host literals that have no legitimate hermetic use. + private static readonly Regex GoogleLiteral = new(@"\bgoogle\.com\b", RegexOptions.Compiled); + // A reserved-TLD name ending in ".example" (RFC 2606), NOT ".example." + // (e.g. the loopback fake-server's "ca.example.net" is fine). + private static readonly Regex ExampleTld = new(@"\.example(?![\w.])", RegexOptions.Compiled); + + private static readonly Regex LiveNetworkHostUse = + new(@"LiveNetworkHosts\.(Stable|Nonexistent)", RegexOptions.Compiled); + private static readonly Regex LiveNetworkTrait = + new(@"""Category""\s*,\s*""LiveNetwork""", RegexOptions.Compiled); + + [Fact] + public void TestSources_ShouldNotInlineExternalNetworkLiterals() + { + var violations = new List(); + + foreach (var (relative, file) in EnumerateTestSources()) + { + if (Allowlist.Contains(relative)) + continue; + + var lines = File.ReadAllLines(file); + for (var i = 0; i < lines.Length; i++) + { + var line = lines[i]; + var trimmed = line.TrimStart(); + if (trimmed.StartsWith("//") || trimmed.StartsWith("/*") || trimmed.StartsWith("*")) + continue; + + foreach (Match m in ExternalFetch.Matches(line)) + { + if (IsExternalHost(m.Groups["url"].Value)) + violations.Add($"{relative}:{i + 1}: non-loopback fetch URL — {trimmed}"); + } + + if (GoogleLiteral.IsMatch(line) || ExampleTld.IsMatch(line)) + violations.Add($"{relative}:{i + 1}: external host literal — {trimmed}"); + } + } + + Assert.True( + violations.Count == 0, + "Found inline external-network literals in test sources. Make the test hermetic " + + "(loopback server / fake DNS), or — if it genuinely needs the live network — tag it " + + "[Trait(\"Category\",\"LiveNetwork\")] and reference SharpTS.Tests.Infrastructure." + + "LiveNetworkHosts.* instead of inlining the host.\n\n" + + string.Join("\n", violations.Take(50))); + } + + [Fact] + public void FilesUsingLiveNetworkHosts_MustBeTaggedLiveNetwork() + { + var violations = new List(); + + foreach (var (relative, file) in EnumerateTestSources()) + { + if (Allowlist.Contains(relative)) + continue; + + var text = File.ReadAllText(file); + if (LiveNetworkHostUse.IsMatch(text) && !LiveNetworkTrait.IsMatch(text)) + violations.Add(relative); + } + + Assert.True( + violations.Count == 0, + "These files use a LiveNetworkHosts constant but contain no " + + "[Trait(\"Category\",\"LiveNetwork\")] — they would run (and can false-red) in CI. " + + "Tag the live test(s) so CI's Category!=LiveNetwork filter excludes them:\n\n" + + string.Join("\n", violations)); + } + + /// Enumerates (repo-relative path, absolute path) for every test .cs file, excluding bin/obj. + private static IEnumerable<(string Relative, string Full)> EnumerateTestSources() + { + var repoRoot = FindRepoRoot(); + var testsDir = Path.Combine(repoRoot, "SharpTS.Tests"); + foreach (var file in Directory.GetFiles(testsDir, "*.cs", SearchOption.AllDirectories)) + { + var relative = Path.GetRelativePath(repoRoot, file).Replace('\\', '/'); + if (relative.Contains("/obj/") || relative.Contains("/bin/")) + continue; + yield return (relative, file); + } + } + + private static bool IsExternalHost(string url) + { + var schemeIdx = url.IndexOf("://", StringComparison.Ordinal); + if (schemeIdx < 0) + return false; + + var host = url[(schemeIdx + 3)..].Split('/', '?', '#')[0]; + if (host.StartsWith('[')) + { + var close = host.IndexOf(']'); + host = close > 1 ? host[1..close] : host; + } + else + { + var colon = host.IndexOf(':'); + if (colon >= 0) + host = host[..colon]; + } + + host = host.Trim().ToLowerInvariant(); + return host is not ("localhost" or "127.0.0.1" or "::1" or "0.0.0.0" or ""); + } + + private static string FindRepoRoot() + { + var dir = AppContext.BaseDirectory; + while (dir != null) + { + if (Directory.Exists(Path.Combine(dir, "Compilation")) && + File.Exists(Path.Combine(dir, "SharpTS.csproj"))) + { + return dir; + } + dir = Path.GetDirectoryName(dir); + } + throw new InvalidOperationException("Could not find repository root"); + } +} diff --git a/SharpTS.Tests/Infrastructure/LiveNetworkHosts.cs b/SharpTS.Tests/Infrastructure/LiveNetworkHosts.cs new file mode 100644 index 00000000..3ceb1c65 --- /dev/null +++ b/SharpTS.Tests/Infrastructure/LiveNetworkHosts.cs @@ -0,0 +1,37 @@ +namespace SharpTS.Tests.Infrastructure; + +/// +/// The single sanctioned home for external (real-internet) host literals used by +/// tests that genuinely exercise live network resolution. +/// +/// +/// +/// Tests that issue a real outbound DNS/HTTP call are inherently non-hermetic: they +/// depend on the CI runner's network, which makes them a recurring false-red source +/// (see issue #495). Such tests MUST be tagged [Trait("Category", "LiveNetwork")] +/// — which CI excludes via --filter "Category!=LiveNetwork" — and they reference +/// the host names from this class rather than inlining literals. +/// +/// +/// The guardrail enforces this: +/// it fails the build if an external host literal (e.g. google.com, a +/// *.example name, or a non-loopback fetch('http://…')) appears anywhere +/// outside this file and the loopback test infrastructure. Keeping the literals here +/// centralizes the (small, deliberate) live-network surface so it can be audited and so +/// hermetic tests can never accidentally depend on the real internet. +/// +/// +public static class LiveNetworkHosts +{ + /// + /// A stable public domain that reliably has MX/NS records, for live DNS smoke tests. + /// + public const string Stable = "google.com"; + + /// + /// A name that does not resolve, for live DNS error-path smoke tests. Relies on the + /// resolver returning NXDOMAIN (the .example TLD is reserved by RFC 2606, but a + /// captive portal / NXDOMAIN-hijacking resolver can still answer — hence LiveNetwork). + /// + public const string Nonexistent = "this.hostname.definitely.does.not.exist.example"; +} diff --git a/SharpTS.Tests/SharedTests/BuiltInModules/DnsFakeServerModuleTests.cs b/SharpTS.Tests/SharedTests/BuiltInModules/DnsFakeServerModuleTests.cs index 8b584d98..428e69dd 100644 --- a/SharpTS.Tests/SharedTests/BuiltInModules/DnsFakeServerModuleTests.cs +++ b/SharpTS.Tests/SharedTests/BuiltInModules/DnsFakeServerModuleTests.cs @@ -45,6 +45,8 @@ private static string RunWithFakeDns(FakeDnsServer server, string source, Execut var qtype = DnsPackets.QueryType(request); var rdata = qtype switch { + DnsWireProtocol.TypeA => DnsPackets.A("93.184.216.34"), + DnsWireProtocol.TypeAAAA => DnsPackets.Aaaa("2606:2800:220:1:248:1893:25c8:1946"), DnsWireProtocol.TypeMX => DnsPackets.Mx(10, DnsPackets.LabelsThenPointer("mail")), DnsWireProtocol.TypeTXT => DnsPackets.Txt("v=spf1 -all"), DnsWireProtocol.TypeNS => DnsPackets.LabelsThenPointer("ns1"), diff --git a/SharpTS.Tests/SharedTests/BuiltInModules/DnsModuleTests.cs b/SharpTS.Tests/SharedTests/BuiltInModules/DnsModuleTests.cs index b84524d3..e78c777e 100644 --- a/SharpTS.Tests/SharedTests/BuiltInModules/DnsModuleTests.cs +++ b/SharpTS.Tests/SharedTests/BuiltInModules/DnsModuleTests.cs @@ -162,16 +162,20 @@ public void Dns_Lookup_WithFamilyOption_IPv4(ExecutionMode mode) Assert.Equal("true\ntrue\n", output); } + // LiveNetwork: dns.lookup uses the OS resolver (getaddrinfo), which has no + // SHARPTS_DNS_SERVER redirect seam, so a real NXDOMAIN answer requires the live + // resolver. Tagged so CI excludes it; run on demand to verify the error path. [Theory] + [Trait("Category", "LiveNetwork")] [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] public void Dns_Lookup_InvalidHostname_Throws(ExecutionMode mode) { var files = new Dictionary { - ["main.ts"] = """ + ["main.ts"] = $$""" import { lookup } from 'dns'; try { - lookup('this.hostname.definitely.does.not.exist.example'); + lookup('{{LiveNetworkHosts.Nonexistent}}'); console.log('no error'); } catch (e) { console.log('error thrown'); @@ -247,14 +251,15 @@ public void Dns_Lookup_Callback_AllOption_ReceivesArray(ExecutionMode mode) } [Theory] + [Trait("Category", "LiveNetwork")] [MemberData(nameof(ExecutionModes.InterpretedOnly), MemberType = typeof(ExecutionModes))] public void Dns_Lookup_Callback_InvalidHostname_ReceivesError(ExecutionMode mode) { var files = new Dictionary { - ["main.ts"] = """ + ["main.ts"] = $$""" import { lookup } from 'dns'; - lookup('this.hostname.definitely.does.not.exist.example', (err: any, address: any) => { + lookup('{{LiveNetworkHosts.Nonexistent}}', (err: any, address: any) => { console.log(err !== null); console.log(address === null); }); diff --git a/SharpTS.Tests/SharedTests/BuiltInModules/DnsRecordTypeTests.cs b/SharpTS.Tests/SharedTests/BuiltInModules/DnsRecordTypeTests.cs index c4c42102..938ecdfa 100644 --- a/SharpTS.Tests/SharedTests/BuiltInModules/DnsRecordTypeTests.cs +++ b/SharpTS.Tests/SharedTests/BuiltInModules/DnsRecordTypeTests.cs @@ -13,50 +13,32 @@ namespace SharpTS.Tests.SharedTests.BuiltInModules; /// public class DnsRecordTypeTests { - /// - /// Runs a DNS test module, skipping (rather than failing) when the live query hung. - /// - /// The network tests below issue a real query to the system resolver. The bodies already - /// tolerate a resolver error (callback err / promise reject), but a hang — - /// the resolver never answering, common on CI agents and especially macOS — drains the - /// event loop with no output, so the harness returns "" and the test false-reds (tracked by - /// #495/#387; the deep fix is a query timeout in the compiled dns runtime). Empty output is - /// the unambiguous hang signature: each test prints at least one line on success or - /// tolerated error, and a genuine regression surfaces as wrong output (still asserted). Skip - /// on that signature so the suite stays deterministic without masking real breakage. The - /// behavioral assertions are pinned by the deterministic fake-server suites regardless. - /// - /// - private static string RunDns(Dictionary files, string entryPoint, ExecutionMode mode) - { - var output = TestHarness.RunModules(files, entryPoint, mode); - Skip.If(output.Length == 0, "live DNS query timed out on this agent (flaky CI resolver); see #495/#387"); - return output; - } - - #region Live smoke tests (network-tolerant) + #region Live smoke tests (opt-in [Trait Category=LiveNetwork], excluded from CI) - // A single callback-based and a single promise-based live query. Success - // asserts the result shape; a transient resolver failure (err / throw) is - // treated as a skip — live DNS on CI runners is a known flake source, and - // the behavioral assertions are pinned by the fake-server suites. + // A single callback-based and a single promise-based live query against a + // stable public domain. Tagged LiveNetwork so CI (which runs + // --filter "Category!=LiveNetwork") never runs them; a developer can run them + // on demand. Exact-value behavioral coverage is pinned by the deterministic + // fake-server suites (DnsFakeServerModuleTests); these only assert the real + // resolver is reachable and returns the right shape. - [SkippableTheory] + [Theory] + [Trait("Category", "LiveNetwork")] [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] public void LiveSmoke_ResolveMx_Callback(ExecutionMode mode) { var files = new Dictionary { - ["main.ts"] = """ + ["main.ts"] = $$""" import * as dns from 'dns'; - dns.resolveMx('google.com', (err: any, addresses: any) => { + dns.resolveMx('{{LiveNetworkHosts.Stable}}', (err: any, addresses: any) => { if (err === null) { console.log(Array.isArray(addresses)); console.log(addresses.length > 0); console.log(typeof addresses[0].exchange === 'string'); console.log(typeof addresses[0].priority === 'number'); } else { - // Transient resolver failure on CI — skip + // Transient resolver failure tolerated — live smoke test. console.log(true); console.log(true); console.log(true); @@ -66,26 +48,27 @@ public void LiveSmoke_ResolveMx_Callback(ExecutionMode mode) """ }; - var output = RunDns(files, "main.ts", mode); + var output = TestHarness.RunModules(files, "main.ts", mode); Assert.Equal("true\ntrue\ntrue\ntrue\n", output); } - [SkippableTheory] + [Theory] + [Trait("Category", "LiveNetwork")] [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] public void LiveSmoke_ResolveNs_Promise(ExecutionMode mode) { var files = new Dictionary { - ["main.ts"] = """ + ["main.ts"] = $$""" import dns from 'dns/promises'; async function main() { try { - const nameservers = await dns.resolveNs('google.com'); + const nameservers = await dns.resolveNs('{{LiveNetworkHosts.Stable}}'); console.log(Array.isArray(nameservers)); console.log(nameservers.length > 0); console.log(typeof nameservers[0] === 'string'); } catch (e) { - // Transient resolver failure on CI — skip + // Transient resolver failure tolerated — live smoke test. console.log(true); console.log(true); console.log(true); @@ -95,58 +78,15 @@ async function main() { """ }; - var output = RunDns(files, "main.ts", mode); + var output = TestHarness.RunModules(files, "main.ts", mode); Assert.Equal("true\ntrue\ntrue\n", output); } #endregion - #region Error Handling Tests - - [SkippableTheory] - [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] - public void ResolveMx_InvalidDomain_CallsBackWithError(ExecutionMode mode) - { - var files = new Dictionary - { - ["main.ts"] = """ - import * as dns from 'dns'; - dns.resolveMx('this.definitely.does.not.exist.example', (err: any, records: any) => { - console.log(err !== null); - console.log(records === null); - }); - """ - }; - - var output = RunDns(files, "main.ts", mode); - Assert.Equal("true\ntrue\n", output); - } - - [SkippableTheory] - [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] - public void ResolveNs_InvalidDomain_Promise_Rejects(ExecutionMode mode) - { - var files = new Dictionary - { - ["main.ts"] = """ - import dns from 'dns/promises'; - async function main() { - try { - await dns.resolveNs('this.definitely.does.not.exist.example'); - console.log('no error'); - } catch (e) { - console.log('error caught'); - } - } - main(); - """ - }; - - var output = RunDns(files, "main.ts", mode); - Assert.Equal("error caught\n", output); - } - - #endregion + // NXDOMAIN error-path coverage (resolveMx callback-error / resolveNs promise-reject) + // lives in DnsFakeServerModuleTests.ResolveMx_Nxdomain_CallsBackWithError and + // ResolveNs_Nxdomain_Promise_Rejects — deterministic, both runtimes, no live network. #region API surface tests (no network) diff --git a/SharpTS.Tests/SharedTests/HttpModuleTests.cs b/SharpTS.Tests/SharedTests/HttpModuleTests.cs index 5d692828..5aa2b46a 100644 --- a/SharpTS.Tests/SharedTests/HttpModuleTests.cs +++ b/SharpTS.Tests/SharedTests/HttpModuleTests.cs @@ -37,9 +37,12 @@ public void FetchIsGlobal(ExecutionMode mode) [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] public void FetchReturnsPromise(ExecutionMode mode) { - // Test that fetch returns something with .then - var source = """ - const p = fetch('http://example.com'); + // Test that fetch returns something with .then. Point at the local server + // (like every sibling fetch test) rather than the real internet: a request + // to an unreachable host keeps the event loop alive until the 30s timeout, + // which false-reds CI on a flaky runner (#495). + var source = $$""" + const p = fetch('{{_server.BaseUrl}}status/200'); console.log(typeof p.then); """; var output = TestHarness.Run(source, mode); From 45a8a42e36732bb0e3aab86b00b94a388de9203e Mon Sep 17 00:00:00 2001 From: Nick Nassiri Date: Sat, 20 Jun 2026 16:45:27 -0700 Subject: [PATCH 2/2] feat(#495): resolve4/resolve6 use the DNS wire protocol (Node/c-ares semantics) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolve4/resolve6 (and resolve() with the default/A/AAAA rrtype) previously used the OS resolver (Dns.GetHostEntry / getaddrinfo), which reads the hosts file and has no SHARPTS_DNS_SERVER redirect seam. That made them non-hermetic in tests and diverged from Node, where resolve* uses c-ares (querying the configured DNS server, not the hosts file). They now go through the existing emitted DNS wire protocol. Behavior change: resolve4('localhost') no longer returns 127.0.0.1 from the hosts file — it queries the DNS server and typically returns ENOTFOUND, matching Node. Use dns.lookup for hosts-file resolution (unchanged — still getaddrinfo). reverse, lookup, and lookupService also stay on getaddrinfo (Node parity). - Interpreter: DnsRecordResolver.ResolveA/ResolveAaaa and the custom-server A/AAAA cases route to DnsWireProtocol.Query; DnsModuleInterpreter.ResolveAddresses too. Widened the resolve4/resolve6 callback catch from SocketException to Exception (the wire protocol throws Exception("Runtime Error: dns.x ECODE host"); ExtractErrorCode already parses both shapes). - Compiled: the three A/AAAA emit sites in RuntimeEmitter.Dns.cs (DnsResolveRecord, DnsWrapper_resolve4/6, DnsWrapper_resolve) route through the emitted wire protocol (DnsDoQuery); removed the now-dead EmitDnsResolveAddresses helper. No new SharpTS.dll late-binding — DnsResolveRecord is already pure emitted IL. - Tests: DnsAsyncTests + DnsResolverTests resolve4/resolve tests now run against a loopback FakeDnsServer (SHARPTS_DNS_SERVER / setServers) with exact-value assertions, in both runtimes. dns.lookup/reverse tests stay on loopback. - Docs: note the resolve* vs lookup distinction (node-modules-api.md, STATUS-NODE.md). Refs #495 --- Compilation/RuntimeEmitter.Dns.cs | 218 ++------------- Runtime/BuiltIns/Modules/DnsRecordResolver.cs | 35 +-- .../Interpreter/DnsModuleInterpreter.cs | 26 +- STATUS-NODE.md | 4 +- .../BuiltInModules/DnsAsyncTests.cs | 255 +++++++++--------- .../BuiltInModules/DnsResolverTests.cs | 46 ++-- docs/node-modules-api.md | 7 + 7 files changed, 214 insertions(+), 377 deletions(-) diff --git a/Compilation/RuntimeEmitter.Dns.cs b/Compilation/RuntimeEmitter.Dns.cs index 412b3a1f..de35262e 100644 --- a/Compilation/RuntimeEmitter.Dns.cs +++ b/Compilation/RuntimeEmitter.Dns.cs @@ -613,6 +613,7 @@ private void EmitDnsResolveRecord(TypeBuilder typeBuilder, EmittedRuntime runtim // Wire protocol query types const int qtMX = 15, qtTXT = 16, qtSRV = 33, qtCNAME = 5, qtNS = 2; const int qtSOA = 6, qtPTR = 12, qtCAA = 257, qtNAPTR = 35; + const int qtA = 1, qtAAAA = 28; // === MX section — DnsDoQuery returns pre-parsed List of dicts === il.MarkLabel(mxLabel); @@ -665,14 +666,14 @@ private void EmitDnsResolveRecord(TypeBuilder typeBuilder, EmittedRuntime runtim EmitDnsResolveViaWireProtocol(il, runtime, hostnameLocal, resultLocal, qtNAPTR, wrapWithConvertList: true); il.Emit(OpCodes.Br, returnLabel); - // === A section (uses System.Net.Dns) === + // === A section — wire protocol (List of IP strings) === il.MarkLabel(aLabel); - EmitDnsResolveAddresses(il, runtime, hostnameLocal, resultLocal, AddressFamily.InterNetwork); + EmitDnsResolveViaWireProtocol(il, runtime, hostnameLocal, resultLocal, qtA, wrapWithConvertList: false); il.Emit(OpCodes.Br, returnLabel); - // === AAAA section (uses System.Net.Dns) === + // === AAAA section — wire protocol (List of IP strings) === il.MarkLabel(aaaaLabel); - EmitDnsResolveAddresses(il, runtime, hostnameLocal, resultLocal, AddressFamily.InterNetworkV6); + EmitDnsResolveViaWireProtocol(il, runtime, hostnameLocal, resultLocal, qtAAAA, wrapWithConvertList: false); il.Emit(OpCodes.Br, returnLabel); // === Unknown rrtype === @@ -724,84 +725,6 @@ private static void EmitRrtypeCheck(ILGenerator il, LocalBuilder rrtypeLocal, st il.Emit(OpCodes.Brtrue, target); } - /// - /// Emits IL for A/AAAA resolution using System.Net.Dns.GetHostEntry. - /// Stores $Array result in resultLocal. - /// - private void EmitDnsResolveAddresses(ILGenerator il, EmittedRuntime runtime, - LocalBuilder hostnameLocal, LocalBuilder resultLocal, AddressFamily family) - { - // Dns.GetHostEntry(hostname) - il.Emit(OpCodes.Ldloc, hostnameLocal); - il.Emit(OpCodes.Call, typeof(Dns).GetMethod("GetHostEntry", [typeof(string)])!); - var hostEntryLocal = il.DeclareLocal(typeof(IPHostEntry)); - il.Emit(OpCodes.Stloc, hostEntryLocal); - - // Get AddressList - il.Emit(OpCodes.Ldloc, hostEntryLocal); - il.Emit(OpCodes.Callvirt, typeof(IPHostEntry).GetProperty("AddressList")!.GetGetMethod()!); - var addressListLocal = il.DeclareLocal(typeof(IPAddress[])); - il.Emit(OpCodes.Stloc, addressListLocal); - - // Build list of matching addresses - il.Emit(OpCodes.Newobj, _types.ListOfObject.GetConstructor(Type.EmptyTypes)!); - var listLocal = il.DeclareLocal(_types.ListOfObject); - il.Emit(OpCodes.Stloc, listLocal); - - var indexLocal = il.DeclareLocal(typeof(int)); - il.Emit(OpCodes.Ldc_I4_0); - il.Emit(OpCodes.Stloc, indexLocal); - var loopStart = il.DefineLabel(); - var loopCond = il.DefineLabel(); - il.Emit(OpCodes.Br, loopCond); - - il.MarkLabel(loopStart); - // Check address family - il.Emit(OpCodes.Ldloc, addressListLocal); - il.Emit(OpCodes.Ldloc, indexLocal); - il.Emit(OpCodes.Ldelem_Ref); - il.Emit(OpCodes.Callvirt, typeof(IPAddress).GetProperty("AddressFamily")!.GetGetMethod()!); - il.Emit(OpCodes.Ldc_I4, (int)family); - var skipLabel = il.DefineLabel(); - il.Emit(OpCodes.Bne_Un, skipLabel); - - // Match - add to list - il.Emit(OpCodes.Ldloc, listLocal); - il.Emit(OpCodes.Ldloc, addressListLocal); - il.Emit(OpCodes.Ldloc, indexLocal); - il.Emit(OpCodes.Ldelem_Ref); - il.Emit(OpCodes.Callvirt, _types.GetMethodNoParams(typeof(IPAddress), "ToString")); - il.Emit(OpCodes.Callvirt, _types.ListOfObject.GetMethod("Add")!); - - il.MarkLabel(skipLabel); - il.Emit(OpCodes.Ldloc, indexLocal); - il.Emit(OpCodes.Ldc_I4_1); - il.Emit(OpCodes.Add); - il.Emit(OpCodes.Stloc, indexLocal); - - il.MarkLabel(loopCond); - il.Emit(OpCodes.Ldloc, indexLocal); - il.Emit(OpCodes.Ldloc, addressListLocal); - il.Emit(OpCodes.Ldlen); - il.Emit(OpCodes.Conv_I4); - il.Emit(OpCodes.Blt, loopStart); - - // Check if empty - il.Emit(OpCodes.Ldloc, listLocal); - il.Emit(OpCodes.Callvirt, _types.GetPropertyGetter(_types.ListOfObject, "Count")); - var notEmptyLabel = il.DefineLabel(); - il.Emit(OpCodes.Brtrue, notEmptyLabel); - - il.Emit(OpCodes.Ldc_I4, (int)SocketError.HostNotFound); - il.Emit(OpCodes.Newobj, typeof(SocketException).GetConstructor([typeof(int)])!); - il.Emit(OpCodes.Throw); - - il.MarkLabel(notEmptyLabel); - il.Emit(OpCodes.Ldloc, listLocal); - il.Emit(OpCodes.Newobj, runtime.TSArrayCtor); - il.Emit(OpCodes.Stloc, resultLocal); - } - /// /// Emits DnsDoQuery: orchestrator that builds query, sends, and parses response. /// Uses emitted wire protocol helpers — no DnsClient dependency. @@ -3477,72 +3400,12 @@ private void EmitDnsResolveByFamily(TypeBuilder typeBuilder, EmittedRuntime runt il.BeginExceptionBlock(); - // Call DnsLookup(hostname, family_number) - returns { address, family } but we need array of strings - // Use System.Net.Dns.GetHostEntry directly for array result - // hostname.ToString() - il.Emit(OpCodes.Ldarg_0); - il.Emit(OpCodes.Callvirt, _types.GetMethodNoParams(_types.Object, "ToString")); - - // Call Dns.GetHostEntry(hostname) -> IPHostEntry - il.Emit(OpCodes.Call, typeof(Dns).GetMethod("GetHostEntry", [typeof(string)])!); - var hostEntryLocal = il.DeclareLocal(typeof(IPHostEntry)); - il.Emit(OpCodes.Stloc, hostEntryLocal); - - // Get AddressList - il.Emit(OpCodes.Ldloc, hostEntryLocal); - il.Emit(OpCodes.Callvirt, typeof(IPHostEntry).GetProperty("AddressList")!.GetGetMethod()!); - var addressListLocal = il.DeclareLocal(typeof(IPAddress[])); - il.Emit(OpCodes.Stloc, addressListLocal); - - // Build List of matching addresses - il.Emit(OpCodes.Newobj, _types.ListOfObject.GetConstructor(Type.EmptyTypes)!); - var listLocal = il.DeclareLocal(_types.ListOfObject); - il.Emit(OpCodes.Stloc, listLocal); - - // for (int i = 0; i < addressList.Length; i++) - var indexLocal = il.DeclareLocal(typeof(int)); - il.Emit(OpCodes.Ldc_I4_0); - il.Emit(OpCodes.Stloc, indexLocal); - var loopStart = il.DefineLabel(); - var loopCond = il.DefineLabel(); - il.Emit(OpCodes.Br, loopCond); - - il.MarkLabel(loopStart); - // if (addressList[i].AddressFamily == target) - il.Emit(OpCodes.Ldloc, addressListLocal); - il.Emit(OpCodes.Ldloc, indexLocal); - il.Emit(OpCodes.Ldelem_Ref); - il.Emit(OpCodes.Callvirt, typeof(IPAddress).GetProperty("AddressFamily")!.GetGetMethod()!); - il.Emit(OpCodes.Ldc_I4, family == 4 ? (int)AddressFamily.InterNetwork : (int)AddressFamily.InterNetworkV6); - var skipLabel = il.DefineLabel(); - il.Emit(OpCodes.Bne_Un, skipLabel); - - // list.Add(address.ToString()) - il.Emit(OpCodes.Ldloc, listLocal); - il.Emit(OpCodes.Ldloc, addressListLocal); - il.Emit(OpCodes.Ldloc, indexLocal); - il.Emit(OpCodes.Ldelem_Ref); - il.Emit(OpCodes.Callvirt, _types.GetMethodNoParams(typeof(IPAddress), "ToString")); - il.Emit(OpCodes.Callvirt, _types.ListOfObject.GetMethod("Add")!); - - il.MarkLabel(skipLabel); - // i++ - il.Emit(OpCodes.Ldloc, indexLocal); - il.Emit(OpCodes.Ldc_I4_1); - il.Emit(OpCodes.Add); - il.Emit(OpCodes.Stloc, indexLocal); - - // i < addressList.Length - il.MarkLabel(loopCond); - il.Emit(OpCodes.Ldloc, indexLocal); - il.Emit(OpCodes.Ldloc, addressListLocal); - il.Emit(OpCodes.Ldlen); - il.Emit(OpCodes.Conv_I4); - il.Emit(OpCodes.Blt, loopStart); - - // Create $Array from list - il.Emit(OpCodes.Ldloc, listLocal); - il.Emit(OpCodes.Newobj, runtime.TSArrayCtor); + // resultLocal = DnsResolveRecord(hostname, "A"|"AAAA") — A/AAAA via the emitted + // DNS wire protocol (honors SHARPTS_DNS_SERVER, Node/c-ares semantics — no + // hosts-file lookup), mirroring the interpreter. Returns a $Array of IP strings. + il.Emit(OpCodes.Ldarg_0); // hostname (object) + il.Emit(OpCodes.Ldstr, family == 4 ? "A" : "AAAA"); + il.Emit(OpCodes.Call, runtime.DnsResolveRecord); il.Emit(OpCodes.Stloc, resultLocal); // callback.Invoke([null, result]) @@ -3720,52 +3583,21 @@ private void EmitDnsResolveWrapper(TypeBuilder typeBuilder, EmittedRuntime runti il.BeginExceptionBlock(); - il.Emit(OpCodes.Ldarg_0); - il.Emit(OpCodes.Callvirt, _types.GetMethodNoParams(_types.Object, "ToString")); - il.Emit(OpCodes.Call, typeof(Dns).GetMethod("GetHostEntry", [typeof(string)])!); - var hostEntryLocal = il.DeclareLocal(typeof(IPHostEntry)); - il.Emit(OpCodes.Stloc, hostEntryLocal); - - // Build list from all addresses - il.Emit(OpCodes.Newobj, _types.ListOfObject.GetConstructor(Type.EmptyTypes)!); - var listLocal = il.DeclareLocal(_types.ListOfObject); - il.Emit(OpCodes.Stloc, listLocal); - - il.Emit(OpCodes.Ldloc, hostEntryLocal); - il.Emit(OpCodes.Callvirt, typeof(IPHostEntry).GetProperty("AddressList")!.GetGetMethod()!); - var addrArrayLocal = il.DeclareLocal(typeof(IPAddress[])); - il.Emit(OpCodes.Stloc, addrArrayLocal); - - var idxLocal = il.DeclareLocal(typeof(int)); - il.Emit(OpCodes.Ldc_I4_0); - il.Emit(OpCodes.Stloc, idxLocal); - var loopS = il.DefineLabel(); - var loopC = il.DefineLabel(); - il.Emit(OpCodes.Br, loopC); - il.MarkLabel(loopS); - - il.Emit(OpCodes.Ldloc, listLocal); - il.Emit(OpCodes.Ldloc, addrArrayLocal); - il.Emit(OpCodes.Ldloc, idxLocal); - il.Emit(OpCodes.Ldelem_Ref); - il.Emit(OpCodes.Callvirt, _types.GetMethodNoParams(typeof(IPAddress), "ToString")); - il.Emit(OpCodes.Callvirt, _types.ListOfObject.GetMethod("Add")!); - - il.Emit(OpCodes.Ldloc, idxLocal); - il.Emit(OpCodes.Ldc_I4_1); - il.Emit(OpCodes.Add); - il.Emit(OpCodes.Stloc, idxLocal); - il.MarkLabel(loopC); - il.Emit(OpCodes.Ldloc, idxLocal); - il.Emit(OpCodes.Ldloc, addrArrayLocal); - il.Emit(OpCodes.Ldlen); - il.Emit(OpCodes.Conv_I4); - il.Emit(OpCodes.Blt, loopS); - - // Create $Array + // resultLocal = DnsResolveRecord(hostname, rrtype ?? "A") — A/AAAA and the + // default rrtype route through the emitted DNS wire protocol (honors + // SHARPTS_DNS_SERVER, Node/c-ares semantics), mirroring the interpreter. + // Returns a $Array of IP strings. + il.Emit(OpCodes.Ldarg_0); // hostname (object) + il.Emit(OpCodes.Ldloc, rrtypeLocal); + il.Emit(OpCodes.Isinst, _types.String); // rrtype string, or null + il.Emit(OpCodes.Dup); + var haveRrtypeLabel = il.DefineLabel(); + il.Emit(OpCodes.Brtrue, haveRrtypeLabel); + il.Emit(OpCodes.Pop); // drop null + il.Emit(OpCodes.Ldstr, "A"); // default rrtype + il.MarkLabel(haveRrtypeLabel); + il.Emit(OpCodes.Call, runtime.DnsResolveRecord); var resultLocal = il.DeclareLocal(_types.Object); - il.Emit(OpCodes.Ldloc, listLocal); - il.Emit(OpCodes.Newobj, runtime.TSArrayCtor); il.Emit(OpCodes.Stloc, resultLocal); // callback(null, result) diff --git a/Runtime/BuiltIns/Modules/DnsRecordResolver.cs b/Runtime/BuiltIns/Modules/DnsRecordResolver.cs index 61e4edd3..18ca660a 100644 --- a/Runtime/BuiltIns/Modules/DnsRecordResolver.cs +++ b/Runtime/BuiltIns/Modules/DnsRecordResolver.cs @@ -1,6 +1,3 @@ -using System.Net; -using System.Net.Sockets; - namespace SharpTS.Runtime.BuiltIns.Modules; /// @@ -73,27 +70,15 @@ public static object ResolveByType(string hostname, string rrtype) public static List ResolveNaptr(string hostname) => (List)DnsWireProtocol.Query(hostname, DnsWireProtocol.TypeNAPTR); - public static List ResolveA(string hostname) - { - var hostEntry = Dns.GetHostEntry(hostname); - var addresses = hostEntry.AddressList - .Where(a => a.AddressFamily == AddressFamily.InterNetwork) - .ToArray(); - if (addresses.Length == 0) - throw new SocketException((int)SocketError.HostNotFound); - return addresses.Select(a => (object?)a.ToString()).ToList(); - } + // resolve4/resolve6 (A/AAAA) use the DNS wire protocol — like Node's c-ares + // resolver, and unlike dns.lookup — so they honor SHARPTS_DNS_SERVER and do NOT + // consult the hosts file. dns.lookup (DnsModuleInterpreter.LookupCore) stays on + // the OS resolver for hosts-file resolution. + public static List ResolveA(string hostname) => + (List)DnsWireProtocol.Query(hostname, DnsWireProtocol.TypeA); - public static List ResolveAaaa(string hostname) - { - var hostEntry = Dns.GetHostEntry(hostname); - var addresses = hostEntry.AddressList - .Where(a => a.AddressFamily == AddressFamily.InterNetworkV6) - .ToArray(); - if (addresses.Length == 0) - throw new SocketException((int)SocketError.HostNotFound); - return addresses.Select(a => (object?)a.ToString()).ToList(); - } + public static List ResolveAaaa(string hostname) => + (List)DnsWireProtocol.Query(hostname, DnsWireProtocol.TypeAAAA); #region Overloads with custom DNS server @@ -112,8 +97,8 @@ public static object Resolve(string hostname, string rrtype, string server) => "PTR" => DnsWireProtocol.Query(hostname, DnsWireProtocol.TypePTR, server), "CAA" => DnsWireProtocol.Query(hostname, DnsWireProtocol.TypeCAA, server), "NAPTR" => DnsWireProtocol.Query(hostname, DnsWireProtocol.TypeNAPTR, server), - "A" => ResolveA(hostname), // A/AAAA use system DNS (Dns.GetHostEntry) - "AAAA" => ResolveAaaa(hostname), + "A" => DnsWireProtocol.Query(hostname, DnsWireProtocol.TypeA, server), + "AAAA" => DnsWireProtocol.Query(hostname, DnsWireProtocol.TypeAAAA, server), _ => throw new Exception($"Runtime Error: dns.resolve unknown rrtype: {rrtype}") }; diff --git a/Runtime/BuiltIns/Modules/Interpreter/DnsModuleInterpreter.cs b/Runtime/BuiltIns/Modules/Interpreter/DnsModuleInterpreter.cs index e6b9b355..c0505f85 100644 --- a/Runtime/BuiltIns/Modules/Interpreter/DnsModuleInterpreter.cs +++ b/Runtime/BuiltIns/Modules/Interpreter/DnsModuleInterpreter.cs @@ -530,13 +530,13 @@ private static SharpTSObject CreateDnsError(string code, string hostname) private static SharpTSArray ResolveAddresses(string hostname, AddressFamily? family) { - var hostEntry = Dns.GetHostEntry(hostname); - var addresses = hostEntry.AddressList; - if (family != null) - addresses = addresses.Where(a => a.AddressFamily == family).ToArray(); - if (addresses.Length == 0) - throw new SocketException((int)SocketError.HostNotFound); - return new SharpTSArray(addresses.Select(a => (object?)a.ToString()).ToList()); + // resolve4/resolve6 go through the DNS wire protocol (honors SHARPTS_DNS_SERVER, + // Node/c-ares semantics — no hosts-file lookup). dns.lookup stays on + // Dns.GetHostEntry. DnsWireProtocol.ParseResponse throws on NXDOMAIN/ENODATA. + var queryType = family == AddressFamily.InterNetworkV6 + ? DnsWireProtocol.TypeAAAA + : DnsWireProtocol.TypeA; + return new SharpTSArray((List)DnsWireProtocol.Query(hostname, queryType)); } /// @@ -682,11 +682,13 @@ private static RuntimeValue Resolve4Async(Interp interpreter, RuntimeValue recei interpreter.Unref(); }, isInterval: false); } - catch (SocketException ex) + // Wire protocol throws Exception ("Runtime Error: dns.x ECODE host"), not + // SocketException; ExtractErrorCode parses both shapes. + catch (Exception ex) { interpreter.ScheduleTimer(0, 0, () => { - interpreter.InvokeGuestCallback(callback, [CreateDnsError(GetErrorCode(ex), hostname), null]); + interpreter.InvokeGuestCallback(callback, [CreateDnsError(ExtractErrorCode(ex), hostname), null]); interpreter.Unref(); }, isInterval: false); } @@ -716,11 +718,13 @@ private static RuntimeValue Resolve6Async(Interp interpreter, RuntimeValue recei interpreter.Unref(); }, isInterval: false); } - catch (SocketException ex) + // Wire protocol throws Exception ("Runtime Error: dns.x ECODE host"), not + // SocketException; ExtractErrorCode parses both shapes. + catch (Exception ex) { interpreter.ScheduleTimer(0, 0, () => { - interpreter.InvokeGuestCallback(callback, [CreateDnsError(GetErrorCode(ex), hostname), null]); + interpreter.InvokeGuestCallback(callback, [CreateDnsError(ExtractErrorCode(ex), hostname), null]); interpreter.Unref(); }, isInterval: false); } diff --git a/STATUS-NODE.md b/STATUS-NODE.md index 27b807a5..16c27b21 100644 --- a/STATUS-NODE.md +++ b/STATUS-NODE.md @@ -716,8 +716,8 @@ This document tracks Node.js module and API implementation status in SharpTS. | `ALL` | ✅ | Return all addresses hint | | **Async Resolution** | | | | `resolve` | ✅ | Async callback-based; supports rrtype parameter (A, AAAA, MX, TXT, SRV, CNAME, NS, SOA, PTR, CAA, NAPTR) | -| `resolve4` | ✅ | Async callback-based; resolves IPv4 addresses | -| `resolve6` | ✅ | Async callback-based; resolves IPv6 addresses | +| `resolve4` | ✅ | Async callback-based; resolves IPv4 via the DNS wire protocol (c-ares-style, honors `SHARPTS_DNS_SERVER`; does not read the hosts file — use `lookup` for that) | +| `resolve6` | ✅ | Async callback-based; resolves IPv6 via the DNS wire protocol (c-ares-style; does not read the hosts file) | | `reverse` | ✅ | Async callback-based; reverse DNS lookup | | `resolveMx` | ✅ | MX records → `[{ exchange, priority }]` | | `resolveTxt` | ✅ | TXT records → `string[][]` (chunks per record) | diff --git a/SharpTS.Tests/SharedTests/BuiltInModules/DnsAsyncTests.cs b/SharpTS.Tests/SharedTests/BuiltInModules/DnsAsyncTests.cs index 9e7fc650..b4d2a458 100644 --- a/SharpTS.Tests/SharedTests/BuiltInModules/DnsAsyncTests.cs +++ b/SharpTS.Tests/SharedTests/BuiltInModules/DnsAsyncTests.cs @@ -1,143 +1,148 @@ +using SharpTS.Runtime.BuiltIns.Modules; using SharpTS.Tests.Infrastructure; using Xunit; namespace SharpTS.Tests.SharedTests.BuiltInModules; /// -/// Tests for async DNS resolution methods: resolve, resolve4, resolve6, reverse. -/// Uses localhost/127.0.0.1 to avoid external DNS dependency. +/// Tests for async DNS resolution: resolve, resolve4, resolve6, reverse. +/// +/// resolve4/resolve6 use the DNS wire protocol (Node/c-ares semantics — they query the +/// configured DNS server, not the hosts file), so they run against a loopback +/// via SHARPTS_DNS_SERVER for deterministic, hermetic +/// results in both runtimes (no live network — see #495). dns.reverse stays on the OS +/// resolver (getaddrinfo) and uses 127.0.0.1, which is hosts-file resolved. +/// /// +[Collection(DnsFakeServerEnvCollection.Name)] public class DnsAsyncTests { - /// - /// Runs a DNS test module, skipping (rather than failing) when the live query hung. - /// - /// dns.resolve*/dns.reverse send a real query to the system resolver - /// (unlike dns.lookup, they don't consult the hosts file), and that query can - /// time out on CI agents — notably macOS — draining the event loop with no output. That - /// is a flaky false-red, not a SharpTS bug (tracked by #495/#387), and the proper deep - /// fix is a query timeout in the compiled dns runtime. Empty output is the - /// unambiguous hang signature here: every test below prints at least one line on success, - /// and a genuine regression surfaces as wrong/partial output (still asserted) or fails - /// identically on Linux/Windows, where the resolver doesn't hang. Skip on that signature - /// so the suite stays deterministic without masking real breakage. - /// - /// - private static string RunDns(Dictionary files, string entryPoint, ExecutionMode mode) + /// Fake server answering A=127.0.0.1 / AAAA=::1 for any name (NXDOMAIN otherwise). + private static FakeDnsServer CreateAddressServer() => new((request, _) => { - var output = TestHarness.RunModules(files, entryPoint, mode); - Skip.If(output.Length == 0, "live DNS query timed out on this agent (flaky CI resolver); see #495/#387"); - return output; - } - - [SkippableTheory] - [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] - public void Resolve4_Localhost(ExecutionMode mode) - { - var files = new Dictionary + var qtype = DnsPackets.QueryType(request); + byte[]? rdata = qtype switch { - ["main.ts"] = """ - import * as dns from 'dns'; - dns.resolve4('localhost', (err: any, addresses: any) => { - console.log(err === null); - console.log(Array.isArray(addresses)); - console.log(addresses.length > 0); - }); - """ + DnsWireProtocol.TypeA => DnsPackets.A("127.0.0.1"), + DnsWireProtocol.TypeAAAA => DnsPackets.Aaaa("::1"), + _ => null }; + return rdata is null + ? DnsPackets.Response(request, 3) // NXDOMAIN + : DnsPackets.Response(request, 0, DnsPackets.Record(qtype, rdata)); + }); - var output = RunDns(files, "main.ts", mode); - Assert.Equal("true\ntrue\ntrue\n", output); + /// Runs main.ts with SHARPTS_DNS_SERVER pointed at the fake server. + private static string RunWithFakeDns(FakeDnsServer server, string source, ExecutionMode mode) + { + Environment.SetEnvironmentVariable("SHARPTS_DNS_SERVER", server.Address); + try + { + return TestHarness.RunModules( + new Dictionary { ["main.ts"] = source }, "main.ts", mode); + } + finally + { + Environment.SetEnvironmentVariable("SHARPTS_DNS_SERVER", null); + } } - [SkippableTheory] + [Theory] [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] - public void Resolve_DefaultRrtypeA(ExecutionMode mode) + public void Resolve4_FakeServer(ExecutionMode mode) { - var files = new Dictionary - { - ["main.ts"] = """ - import * as dns from 'dns'; - dns.resolve('localhost', (err: any, addresses: any) => { - console.log(err === null); - console.log(Array.isArray(addresses)); - }); - """ - }; + using var server = CreateAddressServer(); + var output = RunWithFakeDns(server, """ + import * as dns from 'dns'; + dns.resolve4('fake.test', (err: any, addresses: any) => { + console.log(err === null); + console.log(Array.isArray(addresses)); + console.log(addresses[0]); + }); + """, mode); + + Assert.Equal("true\ntrue\n127.0.0.1\n", output); + } - var output = RunDns(files, "main.ts", mode); - Assert.Equal("true\ntrue\n", output); + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void Resolve_DefaultRrtypeA_FakeServer(ExecutionMode mode) + { + using var server = CreateAddressServer(); + var output = RunWithFakeDns(server, """ + import * as dns from 'dns'; + dns.resolve('fake.test', (err: any, addresses: any) => { + console.log(err === null); + console.log(Array.isArray(addresses)); + console.log(addresses[0]); + }); + """, mode); + + Assert.Equal("true\ntrue\n127.0.0.1\n", output); } - [SkippableTheory] + [Theory] [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] public void Resolve4_NestedInsideCallback_CallbackFires(ExecutionMode mode) { // #239: in compiled mode, dns.* calls inside another callback (or any // function body) resolved the module member dynamically to null and // silently dropped the callback. - var files = new Dictionary - { - ["main.ts"] = """ - import * as dns from 'dns'; - dns.resolve4('localhost', (err: any, a: any) => { - console.log('outer ' + (err === null)); - dns.resolve4('localhost', (err2: any, b: any) => { - console.log('inner ' + (err2 === null)); - }); + using var server = CreateAddressServer(); + var output = RunWithFakeDns(server, """ + import * as dns from 'dns'; + dns.resolve4('fake.test', (err: any, a: any) => { + console.log('outer ' + (err === null)); + dns.resolve4('fake.test', (err2: any, b: any) => { + console.log('inner ' + (err2 === null)); }); - """ - }; + }); + """, mode); - var output = RunDns(files, "main.ts", mode); Assert.Equal("outer true\ninner true\n", output); } - [SkippableTheory] + [Theory] [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] public void Resolve4_InsideTimerCallback_CallbackFires(ExecutionMode mode) { - var files = new Dictionary - { - ["main.ts"] = """ - import * as dns from 'dns'; - setTimeout(() => { - dns.resolve4('localhost', (err: any, a: any) => { - console.log('dns-in-timer ' + (err === null)); - }); - }, 20); - """ - }; + using var server = CreateAddressServer(); + var output = RunWithFakeDns(server, """ + import * as dns from 'dns'; + setTimeout(() => { + dns.resolve4('fake.test', (err: any, a: any) => { + console.log('dns-in-timer ' + (err === null)); + }); + }, 20); + """, mode); - var output = RunDns(files, "main.ts", mode); Assert.Equal("dns-in-timer true\n", output); } - [SkippableTheory] + [Theory] [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] public void Resolve4_InsideFunctionBody_CallbackFires(ExecutionMode mode) { - var files = new Dictionary - { - ["main.ts"] = """ - import * as dns from 'dns'; - function go() { - dns.resolve4('localhost', (err: any, a: any) => { - console.log('fn ' + (err === null)); - }); - } - go(); - """ - }; + using var server = CreateAddressServer(); + var output = RunWithFakeDns(server, """ + import * as dns from 'dns'; + function go() { + dns.resolve4('fake.test', (err: any, a: any) => { + console.log('fn ' + (err === null)); + }); + } + go(); + """, mode); - var output = RunDns(files, "main.ts", mode); Assert.Equal("fn true\n", output); } - [SkippableTheory] + [Theory] [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] public void Reverse_Loopback(ExecutionMode mode) { + // dns.reverse uses the OS resolver (getaddrinfo); 127.0.0.1 reverse-resolves + // from the hosts file — no live network, no fake-server redirect needed. var files = new Dictionary { ["main.ts"] = """ @@ -150,7 +155,7 @@ public void Reverse_Loopback(ExecutionMode mode) """ }; - var output = RunDns(files, "main.ts", mode); + var output = TestHarness.RunModules(files, "main.ts", mode); Assert.Equal("true\ntrue\ntrue\n", output); } @@ -174,25 +179,22 @@ public void DnsConstants_Defined(ExecutionMode mode) Assert.Equal("true\ntrue\ntrue\ntrue\ntrue\n", output); } - [SkippableTheory] + [Theory] [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] - public void DnsPromises_Resolve4(ExecutionMode mode) + public void DnsPromises_Resolve4_FakeServer(ExecutionMode mode) { - var files = new Dictionary - { - ["main.ts"] = """ - import dns from 'dns/promises'; - async function main() { - const addresses = await dns.resolve4('localhost'); - console.log(Array.isArray(addresses)); - console.log(addresses.length > 0); - } - main(); - """ - }; - - var output = RunDns(files, "main.ts", mode); - Assert.Equal("true\ntrue\n", output); + using var server = CreateAddressServer(); + var output = RunWithFakeDns(server, """ + import dns from 'dns/promises'; + async function main() { + const addresses = await dns.resolve4('fake.test'); + console.log(Array.isArray(addresses)); + console.log(addresses[0]); + } + main(); + """, mode); + + Assert.Equal("true\n127.0.0.1\n", output); } [Theory] @@ -200,7 +202,7 @@ async function main() { public void DnsPromises_Lookup(ExecutionMode mode) { // dns.lookup uses the OS resolver (hosts file) rather than a live DNS query, so - // 'localhost' resolves deterministically — no live-network skip guard needed. + // 'localhost' resolves deterministically — no fake-server redirect needed. var files = new Dictionary { ["main.ts"] = """ @@ -218,24 +220,21 @@ async function main() { Assert.Equal("true\ntrue\n", output); } - [SkippableTheory] + [Theory] [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] - public void DnsPromises_ViaModule(ExecutionMode mode) + public void DnsPromises_ViaModule_FakeServer(ExecutionMode mode) { - var files = new Dictionary - { - ["main.ts"] = """ - import * as dns from 'dns'; - async function main() { - const addresses = await dns.promises.resolve4('localhost'); - console.log(Array.isArray(addresses)); - console.log(addresses.length > 0); - } - main(); - """ - }; - - var output = RunDns(files, "main.ts", mode); - Assert.Equal("true\ntrue\n", output); + using var server = CreateAddressServer(); + var output = RunWithFakeDns(server, """ + import * as dns from 'dns'; + async function main() { + const addresses = await dns.promises.resolve4('fake.test'); + console.log(Array.isArray(addresses)); + console.log(addresses[0]); + } + main(); + """, mode); + + Assert.Equal("true\n127.0.0.1\n", output); } } diff --git a/SharpTS.Tests/SharedTests/BuiltInModules/DnsResolverTests.cs b/SharpTS.Tests/SharedTests/BuiltInModules/DnsResolverTests.cs index 4d949423..a766be4a 100644 --- a/SharpTS.Tests/SharedTests/BuiltInModules/DnsResolverTests.cs +++ b/SharpTS.Tests/SharedTests/BuiltInModules/DnsResolverTests.cs @@ -1,3 +1,4 @@ +using SharpTS.Runtime.BuiltIns.Modules; using SharpTS.Tests.Infrastructure; using Xunit; @@ -172,30 +173,39 @@ public void Resolver_HasAllMethods(ExecutionMode mode) #endregion - #region Resolver.resolve4 with default servers (localhost) + #region Resolver.resolve4 against a fake server (setServers) [Theory] [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] - public void Resolver_Resolve4_Localhost_CallbackStyle(ExecutionMode mode) + public void Resolver_Resolve4_FakeServer_CallbackStyle(ExecutionMode mode) { - var files = new Dictionary + // resolve4 uses the DNS wire protocol; point the Resolver at a loopback fake + // server via setServers (no env redirect needed) for a deterministic A record. + using var server = new FakeDnsServer((request, _) => { - ["main.ts"] = """ - import { Resolver } from 'dns'; - const resolver = new Resolver(); - resolver.resolve4('localhost', (err: any, addresses: string[]) => { - if (err) { - console.log('error: ' + err.code); - } else { - console.log(Array.isArray(addresses)); - console.log(addresses.length > 0); - console.log(addresses[0] === '127.0.0.1'); - } - }); - """ - }; + var qtype = DnsPackets.QueryType(request); + return qtype == DnsWireProtocol.TypeA + ? DnsPackets.Response(request, 0, DnsPackets.Record(qtype, DnsPackets.A("127.0.0.1"))) + : DnsPackets.Response(request, 3); + }); + + var source = $$""" + import { Resolver } from 'dns'; + const resolver = new Resolver(); + resolver.setServers(['{{server.Address}}']); + resolver.resolve4('fake.test', (err: any, addresses: string[]) => { + if (err) { + console.log('error: ' + err.code); + } else { + console.log(Array.isArray(addresses)); + console.log(addresses.length > 0); + console.log(addresses[0] === '127.0.0.1'); + } + }); + """; - var output = TestHarness.RunModules(files, "main.ts", mode); + var output = TestHarness.RunModules( + new Dictionary { ["main.ts"] = source }, "main.ts", mode); Assert.Equal("true\ntrue\ntrue\n", output); } diff --git a/docs/node-modules-api.md b/docs/node-modules-api.md index 0e622f58..df8a8905 100644 --- a/docs/node-modules-api.md +++ b/docs/node-modules-api.md @@ -989,6 +989,13 @@ sock.bind(41234); DNS resolution. +> **`resolve*` vs `lookup`:** `resolve`/`resolve4`/`resolve6`/`resolveMx`/… use the DNS +> wire protocol — like Node's c-ares resolver — querying the configured DNS server +> (override with the `SHARPTS_DNS_SERVER` env var). They do **not** consult the OS hosts +> file. `lookup`/`lookupService` use the OS resolver (`getaddrinfo`), which does read the +> hosts file. Consequently `resolve4('localhost')` typically returns `ENOTFOUND`, whereas +> `lookup('localhost')` returns `127.0.0.1` — matching Node. + ### Top-level Methods `lookup`, `lookupService`, `resolve`, `resolve4`, `resolve6`, `resolveCaa`, `resolveCname`, `resolveMx`, `resolveNs`, `resolvePtr`, `resolveSoa`, `resolveSrv`, `resolveTxt`, `resolveAny`, `reverse`, `getServers`, `setServers`.