From 2c42cc673f2070e05d584a9fe5468d2ad94e15e3 Mon Sep 17 00:00:00 2001 From: Radek Zikmund Date: Fri, 12 Jun 2026 11:17:43 +0200 Subject: [PATCH 01/12] Add managed Unix DNS resolver implementation Implements the DnsResolver PAL for the unix TFM as a managed stub resolver. It builds and parses DNS wire messages and talks to the configured servers over UDP (with TCP fallback on truncation) using System.Net.Sockets. When no servers are configured, the system servers from /etc/resolv.conf are used, falling back to loopback. - Add internal wire primitives: message header, reader, writer, encoded-name (with IDN/ACE support), and per-type record parsers. - Add DnsResolverPal.Managed.cs query engine with shared sync/async paths; the sync path uses blocking socket calls and returns a completed Task, preserving the task.IsCompleted invariant. - Add ResolvConf.cs nameserver discovery plus parser unit tests. - Wire the new sources into the unix ItemGroup; reference the System.Net.Sockets reference assembly to break the project cycle (Sockets references NameResolution); the implementation resolves from the shared framework at runtime. - Generalize the loopback tests to run on Linux by binding an ephemeral port on non-Windows. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/Resources/Strings.resx | 5 +- .../src/System.Net.NameResolution.csproj | 17 +- .../src/System/Net/DnsEncodedName.cs | 549 +++++++++++ .../src/System/Net/DnsMessageHeader.cs | 111 +++ .../src/System/Net/DnsMessageReader.cs | 158 +++ .../src/System/Net/DnsMessageWriter.cs | 62 ++ .../src/System/Net/DnsRecordParsing.cs | 309 ++++++ .../src/System/Net/DnsResolverPal.Managed.cs | 925 ++++++++++++++++++ .../src/System/Net/DnsWireEnums.cs | 56 ++ .../src/System/Net/ResolvConf.cs | 79 ++ .../DnsResolverLoopbackTest.cs | 48 +- .../tests/PalTests/ResolvConfTests.cs | 118 +++ ...System.Net.NameResolution.Pal.Tests.csproj | 5 + 13 files changed, 2422 insertions(+), 20 deletions(-) create mode 100644 src/libraries/System.Net.NameResolution/src/System/Net/DnsEncodedName.cs create mode 100644 src/libraries/System.Net.NameResolution/src/System/Net/DnsMessageHeader.cs create mode 100644 src/libraries/System.Net.NameResolution/src/System/Net/DnsMessageReader.cs create mode 100644 src/libraries/System.Net.NameResolution/src/System/Net/DnsMessageWriter.cs create mode 100644 src/libraries/System.Net.NameResolution/src/System/Net/DnsRecordParsing.cs create mode 100644 src/libraries/System.Net.NameResolution/src/System/Net/DnsResolverPal.Managed.cs create mode 100644 src/libraries/System.Net.NameResolution/src/System/Net/DnsWireEnums.cs create mode 100644 src/libraries/System.Net.NameResolution/src/System/Net/ResolvConf.cs create mode 100644 src/libraries/System.Net.NameResolution/tests/PalTests/ResolvConfTests.cs diff --git a/src/libraries/System.Net.NameResolution/src/Resources/Strings.resx b/src/libraries/System.Net.NameResolution/src/Resources/Strings.resx index 0182809cb82c9f..4c5979668a47e5 100644 --- a/src/libraries/System.Net.NameResolution/src/Resources/Strings.resx +++ b/src/libraries/System.Net.NameResolution/src/Resources/Strings.resx @@ -90,4 +90,7 @@ Only the InterNetwork and InterNetworkV6 address families are supported. - \ No newline at end of file + + The DNS name '{0}' is not a valid domain name. + + diff --git a/src/libraries/System.Net.NameResolution/src/System.Net.NameResolution.csproj b/src/libraries/System.Net.NameResolution/src/System.Net.NameResolution.csproj index 4cdb503467afc2..26804896d30e9d 100644 --- a/src/libraries/System.Net.NameResolution/src/System.Net.NameResolution.csproj +++ b/src/libraries/System.Net.NameResolution/src/System.Net.NameResolution.csproj @@ -88,7 +88,14 @@ - + + + + + + + + + + + + + + diff --git a/src/libraries/System.Net.NameResolution/src/System/Net/DnsEncodedName.cs b/src/libraries/System.Net.NameResolution/src/System/Net/DnsEncodedName.cs new file mode 100644 index 00000000000000..119212b2a1be72 --- /dev/null +++ b/src/libraries/System.Net.NameResolution/src/System/Net/DnsEncodedName.cs @@ -0,0 +1,549 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Buffers; +using System.Diagnostics; +using System.Globalization; +using System.Text; + +namespace System.Net +{ + // Represents a domain name in DNS wire format (RFC 1035 §4.1.4). + // Works for both the read path (responses with compression pointers) and the + // write path (flat encoded names). + internal readonly ref struct DnsEncodedName + { + private static readonly IdnMapping s_idnMapping = new IdnMapping { AllowUnassigned = false, UseStd3AsciiRules = true }; + + // Maximum wire-format size of any valid domain name (including length + // prefixes and the root label terminator). + public const int MaxEncodedLength = 255; + + // The buffer containing the encoded name. For names parsed from responses, + // this is the full message (needed to follow compression pointers). For + // names created via TryEncode, this is the flat encoded buffer. + private readonly ReadOnlySpan _buffer; + + // Offset within _buffer where this name starts. + private readonly int _offset; + + // Whether any label is ACE-encoded (starts with "xn--"), indicating IDN/Punycode. + private readonly bool _isAce; + + // Whether the wire encoding contains compression pointers. + // False for names created via TryEncode (always flat). + private readonly bool _hasPointers; + + internal DnsEncodedName(ReadOnlySpan buffer, int offset, bool isAce, bool hasPointers) + { + _buffer = buffer; + _offset = offset; + _isAce = isAce; + _hasPointers = hasPointers; + } + + // Attempts to parse a DNS name from a wire-format buffer at the given offset. + // Validates that the name is well-formed (valid label lengths, no truncation). + // The buffer is retained by the returned DnsEncodedName to support compression + // pointer resolution. bytesConsumed receives the number of bytes consumed from + // the buffer at offset (not following compression pointers). + public static bool TryParse(ReadOnlySpan buffer, int offset, out DnsEncodedName name, out int bytesConsumed) + { + name = default; + bytesConsumed = 0; + + if (offset < 0 || offset >= buffer.Length) + { + return false; + } + + if (!ValidateName(buffer, offset, out int wireLen, out _, out bool isAce, out bool hasPointers)) + { + return false; + } + + if (!hasPointers) + { + // Non-pointer names: _buffer is sliced to exactly the encoded bytes. + name = new DnsEncodedName(buffer[offset..(offset + wireLen)], 0, isAce, hasPointers: false); + } + else + { + // Pointer names: full message buffer needed for pointer resolution. + name = new DnsEncodedName(buffer, offset, isAce, hasPointers: true); + } + bytesConsumed = wireLen; + return true; + } + + // Validates a domain name and encodes it into wire format. + public static OperationStatus TryEncode( + ReadOnlySpan name, + Span destination, + out DnsEncodedName result, + out int bytesWritten) + { + result = default; + bytesWritten = 0; + + // Handle root name "." or empty string. + if (name.Length == 0 || (name.Length == 1 && name[0] == '.')) + { + if (destination.Length < 1) + { + return OperationStatus.DestinationTooSmall; + } + destination[0] = 0; // root label + bytesWritten = 1; + result = new DnsEncodedName(destination[..1], 0, isAce: false, hasPointers: false); + return OperationStatus.Done; + } + + // If the name contains non-ASCII characters, convert to ACE (Punycode) + // form per RFC 5891 (IDNA 2008) before wire encoding. + string? aceName = null; + if (!Ascii.IsValid(name)) + { + try + { + aceName = s_idnMapping.GetAscii(name.ToString()); + } + catch (ArgumentException) + { + return OperationStatus.InvalidData; + } + name = aceName; + } + + // Strip trailing dot if present (FQDN notation). + if (name[^1] == '.') + { + name = name[..^1]; + } + + // Wire format length: each '.' becomes a length byte, plus one leading + // length byte and trailing root label. + int wireLen = name.Length + 2; + if (wireLen > MaxEncodedLength) + { + return OperationStatus.InvalidData; // name too long + } + if (wireLen > destination.Length) + { + return OperationStatus.DestinationTooSmall; + } + + // Copy the ASCII name at offset 1, so dots land where length prefixes will go. + OperationStatus asciiStatus = Ascii.FromUtf16(name, destination.Slice(1, name.Length), out _); + Debug.Assert(asciiStatus == OperationStatus.Done); + + // Walk through and replace dots with label lengths, validating labels. + Span body = destination.Slice(1, name.Length); + int labelStart = 0; + bool isAce = aceName != null; + while (true) + { + int dotIdx = body[labelStart..].IndexOf((byte)'.'); + int labelLen = dotIdx >= 0 ? dotIdx : body.Length - labelStart; + + Span label = body.Slice(labelStart, labelLen); + if (!IsValidLabel(label)) + { + return OperationStatus.InvalidData; + } + + if (!isAce && labelLen >= 4) + { + isAce = IsAceLabel(label); + } + + // Overwrite the dot (or the leading slot at destination[0]) with the label length. + destination[labelStart] = (byte)labelLen; + + if (dotIdx < 0) + { + break; + } + + labelStart += labelLen + 1; + } + + // Write root (empty) label. + destination[wireLen - 1] = 0; + + bytesWritten = wireLen; + result = new DnsEncodedName(destination[..wireLen], 0, isAce, hasPointers: false); + return OperationStatus.Done; + } + + // Compares this name to a dotted string representation. Case-insensitive. + // Non-ASCII (Unicode) names are converted to ACE form before comparison. + public bool Equals(ReadOnlySpan name) + { + if (!Ascii.IsValid(name)) + { + try + { + name = s_idnMapping.GetAscii(name.ToString()); + } + catch (ArgumentException) + { + return false; + } + } + + // Strip trailing dot from the comparison name. + if (name.Length > 0 && name[^1] == '.') + { + name = name[..^1]; + } + + DnsLabelEnumerator enumerator = EnumerateLabels(); + int nameIdx = 0; + + while (enumerator.MoveNext()) + { + ReadOnlySpan label = enumerator.Current; + + if (nameIdx > 0) + { + // Expect a dot separator. + if (nameIdx >= name.Length || name[nameIdx] != '.') + { + return false; + } + nameIdx++; + } + + if (nameIdx + label.Length > name.Length) + { + return false; + } + + if (!Ascii.EqualsIgnoreCase(label, name.Slice(nameIdx, label.Length))) + { + return false; + } + nameIdx += label.Length; + } + + return nameIdx == name.Length; + } + + // Decodes the domain name into the destination buffer as a dotted string. + // ACE-encoded labels (starting with "xn--") are converted back to Unicode. + public unsafe bool TryDecode(Span destination, out int charsWritten) + { + charsWritten = 0; + + if (!_isAce) + { + // Fast path for non-ACE names: decode directly to destination. + return TryDecodeAscii(destination, out charsWritten); + } + + // For ACE names, the ASCII intermediate may be longer than the final + // Unicode form. Decode to a local buffer first, then convert. + Span ascii = stackalloc char[256]; + if (!TryDecodeAscii(ascii, out int asciiWritten)) + { + return false; + } + + try + { + string unicode = s_idnMapping.GetUnicode(new string(ascii[..asciiWritten])); + if (unicode.Length <= destination.Length) + { + unicode.AsSpan().CopyTo(destination); + charsWritten = unicode.Length; + return true; + } + } + catch (ArgumentException) + { + // IDN conversion failed, fall through to ACE form. + } + + if (asciiWritten <= destination.Length) + { + ascii[..asciiWritten].CopyTo(destination); + charsWritten = asciiWritten; + return true; + } + + return false; + } + + private static bool IsAceLabel(ReadOnlySpan label) + { + return label.Length >= 4 && + Ascii.EqualsIgnoreCase(label[..4], "xn--"u8); + } + + // Enumerates the individual labels of this domain name. + // Follows compression pointers transparently. + public DnsLabelEnumerator EnumerateLabels() => new DnsLabelEnumerator(_buffer, _offset); + + // Copies the flat wire-format encoding of this name to the destination buffer, + // expanding compression pointers if present. + internal bool TryCopyEncodedTo(Span destination, out int bytesWritten) + { + bytesWritten = 0; + + if (!_hasPointers) + { + // Fast path: _buffer is sliced to exactly the encoded bytes starting at _offset. + ReadOnlySpan encoded = _buffer[_offset..]; + if (encoded.Length > destination.Length) + { + return false; + } + + encoded.CopyTo(destination); + bytesWritten = encoded.Length; + return true; + } + + // Slow path: expand compression pointers by copying labels as we go. + // MaxEncodedLength bounds the output, so we won't overrun a properly sized buffer. + foreach (ReadOnlySpan label in EnumerateLabels()) + { + if (bytesWritten + 1 + label.Length > destination.Length) + { + return false; + } + destination[bytesWritten] = (byte)label.Length; + bytesWritten++; + label.CopyTo(destination[bytesWritten..]); + bytesWritten += label.Length; + } + + if (bytesWritten >= destination.Length) + { + return false; + } + destination[bytesWritten] = 0; // root label + bytesWritten++; + + return true; + } + + public override unsafe string ToString() + { + Span chars = stackalloc char[256]; + bool success = TryDecode(chars, out int charsWritten); + Debug.Assert(success); + return new string(chars[..charsWritten]); + } + + // Decodes the domain name as raw ASCII without IDN conversion. + private bool TryDecodeAscii(Span destination, out int charsWritten) + { + charsWritten = 0; + DnsLabelEnumerator enumerator = EnumerateLabels(); + bool first = true; + + while (enumerator.MoveNext()) + { + ReadOnlySpan label = enumerator.Current; + + if (!first) + { + if (charsWritten >= destination.Length) + { + return false; + } + destination[charsWritten] = '.'; + charsWritten++; + } + first = false; + + if (charsWritten + label.Length > destination.Length) + { + return false; + } + + Ascii.ToUtf16(label, destination.Slice(charsWritten, label.Length), out _); + charsWritten += label.Length; + } + + if (charsWritten == 0) + { + // Root name produces "." in dotted form. + if (destination.Length < 1) + { + return false; + } + destination[0] = '.'; + charsWritten = 1; + } + + return true; + } + + // Validates the name and computes the wire-format byte count, the dotted ASCII + // string length, and whether any label is ACE-encoded or uses compression pointers, + // all in a single pass. Returns false if the name is malformed or exceeds RFC 1035 limits. + // When validateContent is false (response parsing), only structural validation is + // performed (label lengths, pointer safety, total length). When true (outbound + // encoding), label content is also validated for LDH compliance. + private static bool ValidateName(ReadOnlySpan buffer, int offset, + out int wireLength, out int formattedLength, out bool isAce, + out bool hasPointers, bool validateContent = false) + { + wireLength = 0; + formattedLength = 0; + isAce = false; + hasPointers = false; + + int pos = offset; + bool foundWireEnd = false; + int hops = 0; + + while (pos < buffer.Length) + { + byte b = buffer[pos]; + + if (b == 0) + { + // Root label — end of name. + if (!foundWireEnd) + { + wireLength = pos + 1 - offset; + } + return true; + } + + if ((b & 0xC0) == 0xC0) + { + // Compression pointer. + if (pos + 1 >= buffer.Length) + { + return false; // truncated pointer + } + + if (!foundWireEnd) + { + wireLength = pos + 2 - offset; + foundWireEnd = true; + hasPointers = true; + } + + int pointer = ((b & 0x3F) << 8) | buffer[pos + 1]; + if (pointer >= pos) + { + return false; // only backwards jumps allowed + } + pos = pointer; + + if (++hops > 16) + { + return false; // too many pointer hops + } + continue; + } + + if ((b & 0xC0) != 0x00) + { + return false; // one of the upper 2 bits is nonzero, invalid per RFC 1035 + } + Debug.Assert(b <= 63); // enforced by condition above + + if (pos + 1 + b > buffer.Length) + { + return false; // label extends past buffer + } + + // Account for dot separator in formatted length. + formattedLength += formattedLength > 0 ? b + 1 : b; + if (formattedLength > 253) + { + return false; // RFC 1035: max 253 characters in dotted form + } + + // Check for ACE label ("xn--" prefix). + ReadOnlySpan label = buffer.Slice(pos + 1, b); + if (!isAce && b >= 4) + { + isAce = IsAceLabel(label); + } + + // Validate label contents when required (outbound encoding). + if (validateContent && !IsValidLabel(label)) + { + return false; + } + + pos += 1 + b; // skip length byte + label + } + + return false; // ran off the end of buffer without finding root label + } + + private static readonly SearchValues s_ldhBytes = + SearchValues.Create("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_"u8); + + // Validates that a label has valid length (1-63), contains only LDH (Letters, + // Digits, Hyphens) characters and underscores (for SRV, DKIM, etc.), and does + // not start or end with a hyphen. + private static bool IsValidLabel(ReadOnlySpan label) + { + return label.Length > 0 && + label.Length <= 63 && + label[0] != (byte)'-' && + label[^1] != (byte)'-' && + label.IndexOfAnyExcept(s_ldhBytes) < 0; + } + } + + // Enumerates labels of a DNS name, following compression pointers. The name must + // have been validated by DnsEncodedName.TryParse or DnsEncodedName.TryEncode before + // enumeration. + internal ref struct DnsLabelEnumerator + { + private readonly ReadOnlySpan _buffer; + private int _pos; + private ReadOnlySpan _current; + + internal DnsLabelEnumerator(ReadOnlySpan buffer, int offset) + { + _buffer = buffer; + _pos = offset; + _current = default; + } + + public readonly ReadOnlySpan Current => _current; + + public bool MoveNext() + { + byte b = _buffer[_pos]; + + while ((b & 0xC0) == 0xC0) + { + // Compression pointer: follow it. + Debug.Assert(_pos + 1 < _buffer.Length, "Truncated compression pointer"); + int pointer = ((b & 0x3F) << 8) | _buffer[_pos + 1]; + Debug.Assert(pointer < _pos, "Forward or self-referencing compression pointer"); + _pos = pointer; + b = _buffer[_pos]; + } + + if (b == 0) + { + // End, root label. + return false; + } + + Debug.Assert(b <= 63, "Invalid label length byte"); + int labelLen = b; + _pos++; + Debug.Assert(_pos + labelLen <= _buffer.Length, "Label extends past buffer"); + _current = _buffer.Slice(_pos, labelLen); + _pos += labelLen; + return true; + } + + public readonly DnsLabelEnumerator GetEnumerator() => this; + } +} diff --git a/src/libraries/System.Net.NameResolution/src/System/Net/DnsMessageHeader.cs b/src/libraries/System.Net.NameResolution/src/System/Net/DnsMessageHeader.cs new file mode 100644 index 00000000000000..1699e690d9755c --- /dev/null +++ b/src/libraries/System.Net.NameResolution/src/System/Net/DnsMessageHeader.cs @@ -0,0 +1,111 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Buffers.Binary; + +namespace System.Net +{ + // The fixed 12-byte DNS message header (RFC 1035 §4.1.1). + internal struct DnsMessageHeader + { + public ushort Id { get; set; } + public bool IsResponse { get; set; } + public DnsOpCode OpCode { get; set; } + public DnsHeaderFlags Flags { get; set; } + public DnsResponseCode ResponseCode { get; set; } + public ushort QuestionCount { get; set; } + public ushort AnswerCount { get; set; } + public ushort AuthorityCount { get; set; } + public ushort AdditionalCount { get; set; } + + internal const int Size = 12; + + internal bool TryWrite(Span destination) + { + if (destination.Length < Size) + { + return false; + } + + BinaryPrimitives.WriteUInt16BigEndian(destination, Id); + BinaryPrimitives.WriteUInt16BigEndian(destination[2..], EncodeFlagsWord()); + BinaryPrimitives.WriteUInt16BigEndian(destination[4..], QuestionCount); + BinaryPrimitives.WriteUInt16BigEndian(destination[6..], AnswerCount); + BinaryPrimitives.WriteUInt16BigEndian(destination[8..], AuthorityCount); + BinaryPrimitives.WriteUInt16BigEndian(destination[10..], AdditionalCount); + return true; + } + + internal static bool TryRead(ReadOnlySpan source, out DnsMessageHeader header) + { + header = default; + if (source.Length < Size) + { + return false; + } + + ushort id = BinaryPrimitives.ReadUInt16BigEndian(source); + ushort flagsWord = BinaryPrimitives.ReadUInt16BigEndian(source[2..]); + ushort qdCount = BinaryPrimitives.ReadUInt16BigEndian(source[4..]); + ushort anCount = BinaryPrimitives.ReadUInt16BigEndian(source[6..]); + ushort nsCount = BinaryPrimitives.ReadUInt16BigEndian(source[8..]); + ushort arCount = BinaryPrimitives.ReadUInt16BigEndian(source[10..]); + + DecodeFlagsWord(flagsWord, out bool isResponse, out DnsOpCode opCode, + out DnsHeaderFlags flags, out DnsResponseCode responseCode); + + header = new DnsMessageHeader + { + Id = id, + IsResponse = isResponse, + OpCode = opCode, + Flags = flags, + ResponseCode = responseCode, + QuestionCount = qdCount, + AnswerCount = anCount, + AuthorityCount = nsCount, + AdditionalCount = arCount, + }; + return true; + } + + // RFC 1035 §4.1.1 wire format of the flags word (bytes 2-3): + // + // Bit: 15 14 13 12 11 10 9 8 7 6 5 4 3 2 1 0 + // QR | OpCode | AA TC RD RA Z AD CD | RCODE | + // + // DnsHeaderFlags enum values are the wire bit positions shifted right by 4, + // so the enum fits in a byte. Encoding shifts left by 4 to restore wire positions, + // decoding shifts right by 4. The Z bit (wire bit 6) gap is preserved by the shift. + // Wire flag bits: AA(10) TC(9) RD(8) RA(7) AD(5) CD(4) + // Enum bits: AA(6) TC(5) RD(4) RA(3) AD(1) CD(0) + private const int FlagsShift = 4; + private const ushort WireFlagsMask = 0x07F0; // wire bits 10-7 and 5-4 + + private readonly ushort EncodeFlagsWord() + { + ushort word = 0; + + if (IsResponse) + { + word |= 1 << 15; + } + + word |= (ushort)(((int)OpCode & 0xF) << 11); + word |= (ushort)((int)Flags << FlagsShift); + word |= (ushort)((int)ResponseCode & 0xF); + + return word; + } + + private static void DecodeFlagsWord(ushort word, + out bool isResponse, out DnsOpCode opCode, + out DnsHeaderFlags flags, out DnsResponseCode responseCode) + { + isResponse = (word & (1 << 15)) != 0; + opCode = (DnsOpCode)((word >> 11) & 0xF); + responseCode = (DnsResponseCode)(word & 0xF); + flags = (DnsHeaderFlags)((word & WireFlagsMask) >> FlagsShift); + } + } +} diff --git a/src/libraries/System.Net.NameResolution/src/System/Net/DnsMessageReader.cs b/src/libraries/System.Net.NameResolution/src/System/Net/DnsMessageReader.cs new file mode 100644 index 00000000000000..37340206dfacaf --- /dev/null +++ b/src/libraries/System.Net.NameResolution/src/System/Net/DnsMessageReader.cs @@ -0,0 +1,158 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Buffers.Binary; + +namespace System.Net +{ + // A parsed question entry from the question section. + internal readonly ref struct DnsQuestion + { + public DnsEncodedName Name { get; } + public DnsRecordType Type { get; } + public DnsRecordClass Class { get; } + + internal DnsQuestion(DnsEncodedName name, DnsRecordType type, DnsRecordClass @class) + { + Name = name; + Type = type; + Class = @class; + } + } + + // A parsed resource record from any section (answer, authority, additional). + internal readonly ref struct DnsRecord + { + public DnsEncodedName Name { get; } + public DnsRecordType Type { get; } + public DnsRecordClass Class { get; } + public uint TimeToLive { get; } + + // Raw RDATA bytes. + public ReadOnlySpan Data { get; } + + // The full DNS message buffer, for resolving compression pointers in RDATA. + public ReadOnlySpan Message { get; } + + // Offset of Data within Message. + public int DataOffset { get; } + + internal DnsRecord(DnsEncodedName name, DnsRecordType type, DnsRecordClass @class, + uint ttl, ReadOnlySpan data, ReadOnlySpan message, int dataOffset) + { + Name = name; + Type = type; + Class = @class; + TimeToLive = ttl; + Data = data; + Message = message; + DataOffset = dataOffset; + } + } + + // Reads DNS messages from a buffer. Parses sequentially: header, questions, resource records. + internal ref struct DnsMessageReader + { + private readonly ReadOnlySpan _message; + private int _pos; + + public DnsMessageHeader Header { get; } + + private DnsMessageReader(ReadOnlySpan message, DnsMessageHeader header) + { + _message = message; + _pos = DnsMessageHeader.Size; + Header = header; + } + + // Attempts to create a reader over a DNS message. Parses the header eagerly. + // Returns false if the buffer is too small for a valid header. + public static bool TryCreate(ReadOnlySpan message, out DnsMessageReader reader) + { + reader = default; + + if (!DnsMessageHeader.TryRead(message, out DnsMessageHeader header)) + { + return false; + } + + reader = new DnsMessageReader(message, header); + return true; + } + + // Reads the next question from the message. + public bool TryReadQuestion(out DnsQuestion question) + { + question = default; + + if (_pos >= _message.Length) + { + return false; + } + + if (!DnsEncodedName.TryParse(_message, _pos, out DnsEncodedName name, out int nameWireLen)) + { + return false; + } + _pos += nameWireLen; + + // QTYPE (2) + QCLASS (2) = 4 bytes + if (_pos + 4 > _message.Length) + { + return false; + } + + DnsRecordType type = (DnsRecordType)BinaryPrimitives.ReadUInt16BigEndian(_message[_pos..]); + _pos += 2; + DnsRecordClass @class = (DnsRecordClass)BinaryPrimitives.ReadUInt16BigEndian(_message[_pos..]); + _pos += 2; + + question = new DnsQuestion(name, type, @class); + return true; + } + + // Reads the next resource record from the message. + public bool TryReadRecord(out DnsRecord record) + { + record = default; + + if (_pos >= _message.Length) + { + return false; + } + + if (!DnsEncodedName.TryParse(_message, _pos, out DnsEncodedName name, out int nameWireLen)) + { + return false; + } + _pos += nameWireLen; + + // TYPE(2) + CLASS(2) + TTL(4) + RDLENGTH(2) = 10 bytes + if (_pos + 10 > _message.Length) + { + return false; + } + + DnsRecordType type = (DnsRecordType)BinaryPrimitives.ReadUInt16BigEndian(_message[_pos..]); + _pos += 2; + DnsRecordClass @class = (DnsRecordClass)BinaryPrimitives.ReadUInt16BigEndian(_message[_pos..]); + _pos += 2; + uint ttl = BinaryPrimitives.ReadUInt32BigEndian(_message[_pos..]); + _pos += 4; + ushort rdLength = BinaryPrimitives.ReadUInt16BigEndian(_message[_pos..]); + _pos += 2; + + int dataOffset = _pos; + if (dataOffset + rdLength > _message.Length) + { + return false; + } + + ReadOnlySpan data = _message.Slice(dataOffset, rdLength); + _pos += rdLength; + + record = new DnsRecord(name, type, @class, ttl, data, _message, dataOffset); + return true; + } + } +} diff --git a/src/libraries/System.Net.NameResolution/src/System/Net/DnsMessageWriter.cs b/src/libraries/System.Net.NameResolution/src/System/Net/DnsMessageWriter.cs new file mode 100644 index 00000000000000..6c6018efb82770 --- /dev/null +++ b/src/libraries/System.Net.NameResolution/src/System/Net/DnsMessageWriter.cs @@ -0,0 +1,62 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Buffers.Binary; + +namespace System.Net +{ + // Writes DNS query messages into a caller-provided buffer. + // Only supports writing request messages (header + questions). + internal ref struct DnsMessageWriter + { + private readonly Span _destination; + private int _bytesWritten; + + public DnsMessageWriter(Span destination) + { + _destination = destination; + _bytesWritten = 0; + } + + public readonly int BytesWritten => _bytesWritten; + + // Writes the 12-byte message header at the current position. + public bool TryWriteHeader(in DnsMessageHeader header) + { + if (!header.TryWrite(_destination[_bytesWritten..])) + { + return false; + } + _bytesWritten += DnsMessageHeader.Size; + return true; + } + + // Writes a question entry: encoded domain name + type + class. + // Expands compression pointers if present (safe for names from responses). + public bool TryWriteQuestion( + scoped DnsEncodedName name, + DnsRecordType type, + DnsRecordClass @class = DnsRecordClass.Internet) + { + if (!name.TryCopyEncodedTo(_destination[_bytesWritten..], out int nameWritten)) + { + return false; + } + + // type (2) + class (2) + if (_bytesWritten + nameWritten + 4 > _destination.Length) + { + return false; + } + _bytesWritten += nameWritten; + + BinaryPrimitives.WriteUInt16BigEndian(_destination[_bytesWritten..], (ushort)type); + _bytesWritten += 2; + + BinaryPrimitives.WriteUInt16BigEndian(_destination[_bytesWritten..], (ushort)@class); + _bytesWritten += 2; + + return true; + } + } +} diff --git a/src/libraries/System.Net.NameResolution/src/System/Net/DnsRecordParsing.cs b/src/libraries/System.Net.NameResolution/src/System/Net/DnsRecordParsing.cs new file mode 100644 index 00000000000000..5fa1a8c918d217 --- /dev/null +++ b/src/libraries/System.Net.NameResolution/src/System/Net/DnsRecordParsing.cs @@ -0,0 +1,309 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Buffers.Binary; + +namespace System.Net +{ + // Typed RDATA accessors over parsed DNS records. + + internal readonly ref struct DnsARecordData + { + public ReadOnlySpan AddressBytes { get; } + + internal DnsARecordData(ReadOnlySpan addressBytes) + { + AddressBytes = addressBytes; + } + + public IPAddress ToIPAddress() => new IPAddress(AddressBytes); + } + + internal readonly ref struct DnsAAAARecordData + { + public ReadOnlySpan AddressBytes { get; } + + internal DnsAAAARecordData(ReadOnlySpan addressBytes) + { + AddressBytes = addressBytes; + } + + public IPAddress ToIPAddress() => new IPAddress(AddressBytes); + } + + internal readonly ref struct DnsCNameRecordData + { + public DnsEncodedName CName { get; } + + internal DnsCNameRecordData(DnsEncodedName cname) + { + CName = cname; + } + } + + internal readonly ref struct DnsMxRecordData + { + public ushort Preference { get; } + public DnsEncodedName Exchange { get; } + + internal DnsMxRecordData(ushort preference, DnsEncodedName exchange) + { + Preference = preference; + Exchange = exchange; + } + } + + internal readonly ref struct DnsSrvRecordData + { + public ushort Priority { get; } + public ushort Weight { get; } + public ushort Port { get; } + public DnsEncodedName Target { get; } + + internal DnsSrvRecordData(ushort priority, ushort weight, ushort port, DnsEncodedName target) + { + Priority = priority; + Weight = weight; + Port = port; + Target = target; + } + } + + internal readonly ref struct DnsSoaRecordData + { + public DnsEncodedName PrimaryNameServer { get; } + public DnsEncodedName ResponsibleMailbox { get; } + public uint SerialNumber { get; } + public uint RefreshInterval { get; } + public uint RetryInterval { get; } + public uint ExpireLimit { get; } + public uint MinimumTtl { get; } + + internal DnsSoaRecordData(DnsEncodedName primaryNameServer, DnsEncodedName responsibleMailbox, + uint serialNumber, uint refreshInterval, uint retryInterval, + uint expireLimit, uint minimumTtl) + { + PrimaryNameServer = primaryNameServer; + ResponsibleMailbox = responsibleMailbox; + SerialNumber = serialNumber; + RefreshInterval = refreshInterval; + RetryInterval = retryInterval; + ExpireLimit = expireLimit; + MinimumTtl = minimumTtl; + } + } + + internal readonly ref struct DnsTxtRecordData + { + private readonly ReadOnlySpan _data; + + internal DnsTxtRecordData(ReadOnlySpan data) + { + _data = data; + } + + public DnsTxtEnumerator EnumerateStrings() => new DnsTxtEnumerator(_data); + } + + internal ref struct DnsTxtEnumerator + { + private ReadOnlySpan _remaining; + private ReadOnlySpan _current; + + internal DnsTxtEnumerator(ReadOnlySpan data) + { + _remaining = data; + _current = default; + } + + public readonly ReadOnlySpan Current => _current; + + public bool MoveNext() + { + if (_remaining.Length == 0) + { + return false; + } + + int len = _remaining[0]; + if (1 + len > _remaining.Length) + { + return false; + } + + _current = _remaining.Slice(1, len); + _remaining = _remaining[(1 + len)..]; + return true; + } + + public readonly DnsTxtEnumerator GetEnumerator() => this; + } + + internal readonly ref struct DnsPtrRecordData + { + public DnsEncodedName Name { get; } + + internal DnsPtrRecordData(DnsEncodedName name) + { + Name = name; + } + } + + internal readonly ref struct DnsNsRecordData + { + public DnsEncodedName Name { get; } + + internal DnsNsRecordData(DnsEncodedName name) + { + Name = name; + } + } + + internal static class DnsRecordExtensions + { + public static bool TryParseARecord(this DnsRecord record, out DnsARecordData result) + { + result = default; + if (record.Type != DnsRecordType.A || record.Data.Length != 4) + { + return false; + } + result = new DnsARecordData(record.Data); + return true; + } + + public static bool TryParseAAAARecord(this DnsRecord record, out DnsAAAARecordData result) + { + result = default; + if (record.Type != DnsRecordType.AAAA || record.Data.Length != 16) + { + return false; + } + result = new DnsAAAARecordData(record.Data); + return true; + } + + public static bool TryParseCNameRecord(this DnsRecord record, out DnsCNameRecordData result) + { + result = default; + if (record.Type != DnsRecordType.CNAME || record.Data.Length == 0) + { + return false; + } + if (!DnsEncodedName.TryParse(record.Message, record.DataOffset, out DnsEncodedName cname, out _)) + { + return false; + } + result = new DnsCNameRecordData(cname); + return true; + } + + public static bool TryParseMxRecord(this DnsRecord record, out DnsMxRecordData result) + { + result = default; + if (record.Type != DnsRecordType.MX || record.Data.Length < 3) + { + return false; + } + ushort preference = BinaryPrimitives.ReadUInt16BigEndian(record.Data); + if (!DnsEncodedName.TryParse(record.Message, record.DataOffset + 2, out DnsEncodedName exchange, out _)) + { + return false; + } + result = new DnsMxRecordData(preference, exchange); + return true; + } + + public static bool TryParseSrvRecord(this DnsRecord record, out DnsSrvRecordData result) + { + result = default; + if (record.Type != DnsRecordType.SRV || record.Data.Length < 7) + { + return false; + } + ushort priority = BinaryPrimitives.ReadUInt16BigEndian(record.Data); + ushort weight = BinaryPrimitives.ReadUInt16BigEndian(record.Data[2..]); + ushort port = BinaryPrimitives.ReadUInt16BigEndian(record.Data[4..]); + if (!DnsEncodedName.TryParse(record.Message, record.DataOffset + 6, out DnsEncodedName target, out _)) + { + return false; + } + result = new DnsSrvRecordData(priority, weight, port, target); + return true; + } + + public static bool TryParseSoaRecord(this DnsRecord record, out DnsSoaRecordData result) + { + result = default; + if (record.Type != DnsRecordType.SOA || record.Data.Length < 22) + { + return false; + } + + if (!DnsEncodedName.TryParse(record.Message, record.DataOffset, out DnsEncodedName mname, out int mnameLen)) + { + return false; + } + + if (!DnsEncodedName.TryParse(record.Message, record.DataOffset + mnameLen, out DnsEncodedName rname, out int rnameLen)) + { + return false; + } + + ReadOnlySpan fixedData = record.Data[(mnameLen + rnameLen)..]; + if (fixedData.Length < 20) + { + return false; + } + + result = new DnsSoaRecordData(mname, rname, + BinaryPrimitives.ReadUInt32BigEndian(fixedData), + BinaryPrimitives.ReadUInt32BigEndian(fixedData[4..]), + BinaryPrimitives.ReadUInt32BigEndian(fixedData[8..]), + BinaryPrimitives.ReadUInt32BigEndian(fixedData[12..]), + BinaryPrimitives.ReadUInt32BigEndian(fixedData[16..])); + return true; + } + + public static bool TryParseTxtRecord(this DnsRecord record, out DnsTxtRecordData result) + { + result = default; + if (record.Type != DnsRecordType.TXT || record.Data.Length == 0) + { + return false; + } + result = new DnsTxtRecordData(record.Data); + return true; + } + + public static bool TryParsePtrRecord(this DnsRecord record, out DnsPtrRecordData result) + { + result = default; + if (record.Type != DnsRecordType.PTR || record.Data.Length == 0) + { + return false; + } + if (!DnsEncodedName.TryParse(record.Message, record.DataOffset, out DnsEncodedName ptr, out _)) + { + return false; + } + result = new DnsPtrRecordData(ptr); + return true; + } + + public static bool TryParseNsRecord(this DnsRecord record, out DnsNsRecordData result) + { + result = default; + if (record.Type != DnsRecordType.NS || record.Data.Length == 0) + { + return false; + } + if (!DnsEncodedName.TryParse(record.Message, record.DataOffset, out DnsEncodedName ns, out _)) + { + return false; + } + result = new DnsNsRecordData(ns); + return true; + } + } +} diff --git a/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolverPal.Managed.cs b/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolverPal.Managed.cs new file mode 100644 index 00000000000000..1e1d079ceb2c5d --- /dev/null +++ b/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolverPal.Managed.cs @@ -0,0 +1,925 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Buffers; +using System.Buffers.Binary; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Net.Sockets; +using System.Runtime.ExceptionServices; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace System.Net +{ + // Managed stub-resolver implementation of the DNS PAL for Unix platforms. + // + // Builds and parses DNS wire messages and talks to the configured servers over + // UDP (with TCP fallback on truncation) using System.Net.Sockets. When no servers + // are configured, the system servers from /etc/resolv.conf are used. + // + // Each entry point takes a `bool async` flag. When async is false the underlying + // socket operations are issued synchronously (blocking) and the returned Task is + // already completed, so the synchronous public entry points can unwrap it without + // blocking a thread pool thread. + internal static partial class DnsResolverPal + { + // Maximum UDP DNS message size without EDNS0 (RFC 1035 §4.2.1). + private const int MaxUdpResponseSize = 512; + + // Initial buffer size for TCP responses; grown based on the 2-byte length prefix. + private const int InitialTcpBufferSize = 4096; + + // Default per-attempt timeout and retry count (DnsResolverOptions exposes only Servers). + private static readonly TimeSpan s_queryTimeout = TimeSpan.FromSeconds(3); + private const int MaxRetries = 2; + + // ---- Public PAL entry points (one per record type) ---- + + public static async Task> ResolveAddresses(IList servers, bool async, string name, AddressFamily addressFamily, CancellationToken cancellationToken) + { + if (addressFamily == AddressFamily.Unspecified) + { + if (async) + { + Task> aTask = QueryAddresses(servers, async: true, name, DnsRecordType.A, cancellationToken); + Task> aaaaTask = QueryAddresses(servers, async: true, name, DnsRecordType.AAAA, cancellationToken); + DnsResult aRes = await aTask.ConfigureAwait(false); + DnsResult aaaaRes = await aaaaTask.ConfigureAwait(false); + return MergeAddressResults(aRes, aaaaRes); + } + else + { + DnsResult aRes = await QueryAddresses(servers, async: false, name, DnsRecordType.A, cancellationToken).ConfigureAwait(false); + DnsResult aaaaRes = await QueryAddresses(servers, async: false, name, DnsRecordType.AAAA, cancellationToken).ConfigureAwait(false); + return MergeAddressResults(aRes, aaaaRes); + } + } + + DnsRecordType qtype = AddressFamilyToQueryType(addressFamily); + return await QueryAddresses(servers, async, name, qtype, cancellationToken).ConfigureAwait(false); + } + + public static async Task> ResolveSrv(IList servers, bool async, string name, CancellationToken cancellationToken) + { + DnsResponse response = await SendQuery(servers, async, name, DnsRecordType.SRV, cancellationToken).ConfigureAwait(false); + try + { + return ParseSrv(response.Span); + } + finally + { + response.Dispose(); + } + } + + public static async Task> ResolveMx(IList servers, bool async, string name, CancellationToken cancellationToken) + { + DnsResponse response = await SendQuery(servers, async, name, DnsRecordType.MX, cancellationToken).ConfigureAwait(false); + try + { + return ParseMx(response.Span); + } + finally + { + response.Dispose(); + } + } + + public static async Task> ResolveTxt(IList servers, bool async, string name, CancellationToken cancellationToken) + { + DnsResponse response = await SendQuery(servers, async, name, DnsRecordType.TXT, cancellationToken).ConfigureAwait(false); + try + { + return ParseTxt(response.Span); + } + finally + { + response.Dispose(); + } + } + + public static async Task> ResolveCName(IList servers, bool async, string name, CancellationToken cancellationToken) + { + DnsResponse response = await SendQuery(servers, async, name, DnsRecordType.CNAME, cancellationToken).ConfigureAwait(false); + try + { + return ParseCName(response.Span); + } + finally + { + response.Dispose(); + } + } + + public static async Task> ResolvePtr(IList servers, bool async, string name, CancellationToken cancellationToken) + { + DnsResponse response = await SendQuery(servers, async, name, DnsRecordType.PTR, cancellationToken).ConfigureAwait(false); + try + { + return ParsePtr(response.Span); + } + finally + { + response.Dispose(); + } + } + + public static async Task> ResolveNs(IList servers, bool async, string name, CancellationToken cancellationToken) + { + DnsResponse response = await SendQuery(servers, async, name, DnsRecordType.NS, cancellationToken).ConfigureAwait(false); + try + { + return ParseNs(response.Span); + } + finally + { + response.Dispose(); + } + } + + private static async Task> QueryAddresses(IList servers, bool async, string name, DnsRecordType qtype, CancellationToken cancellationToken) + { + DnsResponse response = await SendQuery(servers, async, name, qtype, cancellationToken).ConfigureAwait(false); + try + { + return ParseAddresses(response.Span, qtype); + } + finally + { + response.Dispose(); + } + } + + private static DnsRecordType AddressFamilyToQueryType(AddressFamily addressFamily) => + addressFamily switch + { + AddressFamily.InterNetwork => DnsRecordType.A, + AddressFamily.InterNetworkV6 => DnsRecordType.AAAA, + _ => throw new ArgumentException(SR.net_invalid_ip_addr, nameof(addressFamily)), + }; + + // ---- Response parsers ---- + + private static DnsResult ParseAddresses(ReadOnlySpan response, DnsRecordType qtype) + { + DnsMessageReader reader = CreateReader(response); + DnsMessageHeader header = reader.Header; + if (header.ResponseCode != DnsResponseCode.NoError) + { + return new DnsResult(header.ResponseCode, null, ExtractNegativeCacheTtl(response)); + } + + SkipQuestions(ref reader); + + List records = new List(); + for (int i = 0; i < header.AnswerCount; i++) + { + DnsRecord record = ReadRecord(ref reader); + if (qtype == DnsRecordType.A && record.TryParseARecord(out DnsARecordData a)) + { + records.Add(new AddressRecord(a.ToIPAddress(), TimeSpan.FromSeconds(record.TimeToLive))); + } + else if (qtype == DnsRecordType.AAAA && record.TryParseAAAARecord(out DnsAAAARecordData aaaa)) + { + records.Add(new AddressRecord(aaaa.ToIPAddress(), TimeSpan.FromSeconds(record.TimeToLive))); + } + } + + // NODATA: NoError with no matching records — extract negative TTL from SOA + // in the authority section per RFC 2308 §5. + TimeSpan negTtl = records.Count == 0 ? ExtractNegativeCacheTtl(response) : TimeSpan.Zero; + return new DnsResult(DnsResponseCode.NoError, records, negTtl); + } + + private static DnsResult ParseSrv(ReadOnlySpan response) + { + DnsMessageReader reader = CreateReader(response); + DnsMessageHeader header = reader.Header; + if (header.ResponseCode != DnsResponseCode.NoError) + { + return new DnsResult(header.ResponseCode, null, ExtractNegativeCacheTtl(response)); + } + + SkipQuestions(ref reader); + + // First pass: collect SRV answers (target names captured eagerly as strings). + List<(string Target, ushort Port, ushort Priority, ushort Weight, uint Ttl)> srvs = new(); + for (int i = 0; i < header.AnswerCount; i++) + { + DnsRecord record = ReadRecord(ref reader); + if (record.TryParseSrvRecord(out DnsSrvRecordData srv)) + { + srvs.Add((srv.Target.ToString(), srv.Port, srv.Priority, srv.Weight, record.TimeToLive)); + } + } + + // Skip the authority section. + SkipRecords(ref reader, header.AuthorityCount); + + // Gather additional-section A/AAAA glue addresses keyed by owner name. + Dictionary>? glue = null; + for (int i = 0; i < header.AdditionalCount; i++) + { + DnsRecord record = ReadRecord(ref reader); + IPAddress? address = null; + if (record.TryParseARecord(out DnsARecordData a)) + { + address = a.ToIPAddress(); + } + else if (record.TryParseAAAARecord(out DnsAAAARecordData aaaa)) + { + address = aaaa.ToIPAddress(); + } + + if (address is not null) + { + string owner = record.Name.ToString(); + glue ??= new Dictionary>(StringComparer.OrdinalIgnoreCase); + if (!glue.TryGetValue(owner, out List? list)) + { + list = new List(); + glue[owner] = list; + } + list.Add(new AddressRecord(address, TimeSpan.FromSeconds(record.TimeToLive))); + } + } + + List records = new List(srvs.Count); + foreach ((string target, ushort port, ushort priority, ushort weight, uint ttl) in srvs) + { + IReadOnlyList? attached = null; + if (glue is not null && glue.TryGetValue(target, out List? list)) + { + attached = list; + } + records.Add(new SrvRecord(target, port, priority, weight, TimeSpan.FromSeconds(ttl), attached)); + } + + TimeSpan negTtl = records.Count == 0 ? ExtractNegativeCacheTtl(response) : TimeSpan.Zero; + return new DnsResult(DnsResponseCode.NoError, records, negTtl); + } + + private static DnsResult ParseTxt(ReadOnlySpan response) + { + DnsMessageReader reader = CreateReader(response); + DnsMessageHeader header = reader.Header; + if (header.ResponseCode != DnsResponseCode.NoError) + { + return new DnsResult(header.ResponseCode, null, ExtractNegativeCacheTtl(response)); + } + + SkipQuestions(ref reader); + + List records = new List(); + for (int i = 0; i < header.AnswerCount; i++) + { + DnsRecord record = ReadRecord(ref reader); + if (record.TryParseTxtRecord(out DnsTxtRecordData txt)) + { + List values = new List(); + foreach (ReadOnlySpan str in txt.EnumerateStrings()) + { + values.Add(Encoding.UTF8.GetString(str)); + } + records.Add(new TxtRecord(values, TimeSpan.FromSeconds(record.TimeToLive))); + } + } + + TimeSpan txtNegTtl = records.Count == 0 ? ExtractNegativeCacheTtl(response) : TimeSpan.Zero; + return new DnsResult(DnsResponseCode.NoError, records, txtNegTtl); + } + + private static DnsResult ParseMx(ReadOnlySpan response) + { + DnsMessageReader reader = CreateReader(response); + DnsMessageHeader header = reader.Header; + if (header.ResponseCode != DnsResponseCode.NoError) + { + return new DnsResult(header.ResponseCode, null, ExtractNegativeCacheTtl(response)); + } + + SkipQuestions(ref reader); + + List records = new List(); + for (int i = 0; i < header.AnswerCount; i++) + { + DnsRecord record = ReadRecord(ref reader); + if (record.TryParseMxRecord(out DnsMxRecordData mx)) + { + records.Add(new MxRecord(mx.Exchange.ToString(), mx.Preference, TimeSpan.FromSeconds(record.TimeToLive))); + } + } + + TimeSpan mxNegTtl = records.Count == 0 ? ExtractNegativeCacheTtl(response) : TimeSpan.Zero; + return new DnsResult(DnsResponseCode.NoError, records, mxNegTtl); + } + + private static DnsResult ParseCName(ReadOnlySpan response) + { + DnsMessageReader reader = CreateReader(response); + DnsMessageHeader header = reader.Header; + if (header.ResponseCode != DnsResponseCode.NoError) + { + return new DnsResult(header.ResponseCode, null, ExtractNegativeCacheTtl(response)); + } + + SkipQuestions(ref reader); + + List records = new List(); + for (int i = 0; i < header.AnswerCount; i++) + { + DnsRecord record = ReadRecord(ref reader); + if (record.TryParseCNameRecord(out DnsCNameRecordData cname)) + { + records.Add(new CNameRecord(cname.CName.ToString(), TimeSpan.FromSeconds(record.TimeToLive))); + } + } + + TimeSpan cnameNegTtl = records.Count == 0 ? ExtractNegativeCacheTtl(response) : TimeSpan.Zero; + return new DnsResult(DnsResponseCode.NoError, records, cnameNegTtl); + } + + private static DnsResult ParsePtr(ReadOnlySpan response) + { + DnsMessageReader reader = CreateReader(response); + DnsMessageHeader header = reader.Header; + if (header.ResponseCode != DnsResponseCode.NoError) + { + return new DnsResult(header.ResponseCode, null, ExtractNegativeCacheTtl(response)); + } + + SkipQuestions(ref reader); + + List records = new List(); + for (int i = 0; i < header.AnswerCount; i++) + { + DnsRecord record = ReadRecord(ref reader); + if (record.TryParsePtrRecord(out DnsPtrRecordData ptr)) + { + records.Add(new PtrRecord(ptr.Name.ToString(), TimeSpan.FromSeconds(record.TimeToLive))); + } + } + + TimeSpan ptrNegTtl = records.Count == 0 ? ExtractNegativeCacheTtl(response) : TimeSpan.Zero; + return new DnsResult(DnsResponseCode.NoError, records, ptrNegTtl); + } + + private static DnsResult ParseNs(ReadOnlySpan response) + { + DnsMessageReader reader = CreateReader(response); + DnsMessageHeader header = reader.Header; + if (header.ResponseCode != DnsResponseCode.NoError) + { + return new DnsResult(header.ResponseCode, null, ExtractNegativeCacheTtl(response)); + } + + SkipQuestions(ref reader); + + List records = new List(); + for (int i = 0; i < header.AnswerCount; i++) + { + DnsRecord record = ReadRecord(ref reader); + if (record.TryParseNsRecord(out DnsNsRecordData ns)) + { + records.Add(new NsRecord(ns.Name.ToString(), TimeSpan.FromSeconds(record.TimeToLive))); + } + } + + TimeSpan nsNegTtl = records.Count == 0 ? ExtractNegativeCacheTtl(response) : TimeSpan.Zero; + return new DnsResult(DnsResponseCode.NoError, records, nsNegTtl); + } + + private static DnsResult MergeAddressResults(DnsResult a, DnsResult b) + { + if (a.Records.Count > 0 || b.Records.Count > 0) + { + AddressRecord[] merged = new AddressRecord[a.Records.Count + b.Records.Count]; + int idx = 0; + for (int i = 0; i < a.Records.Count; i++) + { + merged[idx++] = a.Records[i]; + } + for (int i = 0; i < b.Records.Count; i++) + { + merged[idx++] = b.Records[i]; + } + return new DnsResult(DnsResponseCode.NoError, merged, TimeSpan.Zero); + } + + DnsResponseCode chosenRc = a.ResponseCode == DnsResponseCode.NxDomain || b.ResponseCode == DnsResponseCode.NxDomain + ? DnsResponseCode.NxDomain + : (a.ResponseCode != DnsResponseCode.NoError ? a.ResponseCode : b.ResponseCode); + TimeSpan negTtl = a.NegativeCacheTtl > TimeSpan.Zero ? a.NegativeCacheTtl : b.NegativeCacheTtl; + return new DnsResult(chosenRc, null, negTtl); + } + + // Per RFC 2308 §5, the negative cache TTL is the minimum of the SOA record TTL + // and the SOA MINIMUM field of the SOA record in the authority section. + private static TimeSpan ExtractNegativeCacheTtl(ReadOnlySpan response) + { + DnsMessageReader reader = CreateReader(response); + DnsMessageHeader header = reader.Header; + + SkipQuestions(ref reader); + SkipRecords(ref reader, header.AnswerCount); + + for (int i = 0; i < header.AuthorityCount; i++) + { + DnsRecord record = ReadRecord(ref reader); + if (record.TryParseSoaRecord(out DnsSoaRecordData soa)) + { + uint negTtl = Math.Min(record.TimeToLive, soa.MinimumTtl); + return TimeSpan.FromSeconds(negTtl); + } + } + + return TimeSpan.Zero; + } + + // ---- Query engine ---- + + private static async Task SendQuery(IList servers, bool async, string name, DnsRecordType qtype, CancellationToken cancellationToken) + { + IReadOnlyList serverList = GetServers(servers); + Debug.Assert(serverList.Count > 0); + + byte[] queryBytes = ArrayPool.Shared.Rent(MaxUdpResponseSize); + try + { + ushort queryId = (ushort)RandomNumberGenerator.GetInt32(ushort.MaxValue + 1); + int queryLength = WriteQuery(queryId, name, qtype, queryBytes); + ReadOnlyMemory query = queryBytes.AsMemory(0, queryLength); + + byte[] responseBuffer = ArrayPool.Shared.Rent(MaxUdpResponseSize); + Exception? lastException = null; + + foreach (IPEndPoint server in serverList) + { + for (int attempt = 0; attempt <= MaxRetries; attempt++) + { + if (cancellationToken.IsCancellationRequested) + { + // Surface pre-flight cancellation as TaskCanceledException to match the + // Windows PAL (which completes via TaskCompletionSource.TrySetCanceled). + ArrayPool.Shared.Return(responseBuffer); + throw new TaskCanceledException(); + } + try + { + int responseLength = async + ? await SendUdpQueryAsync(query, server, responseBuffer, cancellationToken).ConfigureAwait(false) + : SendUdpQuerySync(query, server, responseBuffer); + + ResponseValidation validation = ValidateResponse( + responseBuffer.AsSpan(0, responseLength), queryId, name, qtype, out Exception? validationError); + + if (validation == ResponseValidation.Retry) + { + lastException = validationError; + continue; + } + + if (validation == ResponseValidation.TcpFallback) + { + (byte[]? tcpBuffer, int tcpLength, Exception? tcpError) = async + ? await TryTcpFallbackAsync(query, server, cancellationToken).ConfigureAwait(false) + : TryTcpFallbackSync(query, server); + + if (tcpBuffer is not null) + { + // Validate the TCP response (ID, QR bit, echoed question). + ResponseValidation tcpValidation = ValidateResponse( + tcpBuffer.AsSpan(0, tcpLength), queryId, name, qtype, out Exception? tcpValidationError); + if (tcpValidation != ResponseValidation.Ok) + { + ArrayPool.Shared.Return(tcpBuffer); + lastException = tcpValidationError ?? new InvalidDataException(); + continue; + } + + ArrayPool.Shared.Return(responseBuffer); + return new DnsResponse(tcpBuffer, tcpLength); + } + + lastException = tcpError; + continue; + } + + return new DnsResponse(responseBuffer, responseLength); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + ArrayPool.Shared.Return(responseBuffer); + throw; + } + catch (OperationCanceledException) + { + lastException = new TimeoutException(); + } + catch (SocketException ex) + { + lastException = ex; + } + catch (IOException ex) + { + lastException = ex; + } + } + } + + ArrayPool.Shared.Return(responseBuffer); + + if (lastException is not null) + { + ExceptionDispatchInfo.Throw(lastException); + } + throw new TimeoutException(); + } + finally + { + ArrayPool.Shared.Return(queryBytes); + } + } + + private enum ResponseValidation + { + Ok, + Retry, + TcpFallback, + } + + private static ResponseValidation ValidateResponse( + ReadOnlySpan response, ushort expectedId, string expectedName, DnsRecordType expectedType, + out Exception? error) + { + error = null; + + if (!DnsMessageHeader.TryRead(response, out DnsMessageHeader header)) + { + error = new InvalidDataException(); + return ResponseValidation.Retry; + } + + if (!header.IsResponse || header.Id != expectedId) + { + return ResponseValidation.Retry; + } + + if (!ValidateResponseQuestion(response, header, expectedName, expectedType)) + { + error = new InvalidDataException(); + return ResponseValidation.Retry; + } + + if ((header.Flags & DnsHeaderFlags.Truncation) != 0) + { + return ResponseValidation.TcpFallback; + } + + return ResponseValidation.Ok; + } + + private static bool ValidateResponseQuestion( + ReadOnlySpan response, DnsMessageHeader header, string expectedName, DnsRecordType expectedType) + { + if (header.QuestionCount != 1) + { + return false; + } + + DnsMessageReader.TryCreate(response, out DnsMessageReader reader); + if (!reader.TryReadQuestion(out DnsQuestion question)) + { + return false; + } + + return question.Type == expectedType && question.Name.Equals(expectedName); + } + + private static unsafe int WriteQuery(ushort queryId, string name, DnsRecordType type, Span destination) + { + Span nameBuffer = stackalloc byte[DnsEncodedName.MaxEncodedLength]; + OperationStatus status = DnsEncodedName.TryEncode(name, nameBuffer, out DnsEncodedName encodedName, out _); + if (status == OperationStatus.InvalidData) + { + throw new ArgumentException(SR.Format(SR.net_invalid_dns_name, name), nameof(name)); + } + Debug.Assert(status == OperationStatus.Done); + + DnsMessageWriter writer = new DnsMessageWriter(destination); + bool ok = writer.TryWriteHeader(new DnsMessageHeader { Id = queryId, Flags = DnsHeaderFlags.RecursionDesired, QuestionCount = 1 }); + Debug.Assert(ok); + ok = writer.TryWriteQuestion(encodedName, type); + Debug.Assert(ok); + return writer.BytesWritten; + } + + private static async Task SendUdpQueryAsync( + ReadOnlyMemory query, IPEndPoint server, byte[] responseBuffer, CancellationToken cancellationToken) + { + using Socket socket = new Socket(server.AddressFamily, SocketType.Dgram, ProtocolType.Udp); + using CancellationTokenSource timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(s_queryTimeout); + + await socket.ConnectAsync(server, timeoutCts.Token).ConfigureAwait(false); + await socket.SendAsync(query, SocketFlags.None, timeoutCts.Token).ConfigureAwait(false); + return await socket.ReceiveAsync(responseBuffer, SocketFlags.None, timeoutCts.Token).ConfigureAwait(false); + } + + private static int SendUdpQuerySync( + ReadOnlyMemory query, IPEndPoint server, byte[] responseBuffer) + { + using Socket socket = new Socket(server.AddressFamily, SocketType.Dgram, ProtocolType.Udp); + socket.SendTimeout = (int)s_queryTimeout.TotalMilliseconds; + socket.ReceiveTimeout = (int)s_queryTimeout.TotalMilliseconds; + + socket.Connect(server); + socket.Send(query.Span, SocketFlags.None); + return socket.Receive(responseBuffer, SocketFlags.None); + } + + private static async Task<(byte[]? Buffer, int Length, Exception? Error)> TryTcpFallbackAsync( + ReadOnlyMemory query, IPEndPoint server, CancellationToken cancellationToken) + { + try + { + (byte[] buffer, int length) = await SendTcpQueryAsync(query, server, cancellationToken).ConfigureAwait(false); + return (buffer, length, null); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (OperationCanceledException) + { + return (null, 0, new TimeoutException()); + } + catch (Exception ex) when (ex is SocketException or IOException) + { + return (null, 0, ex); + } + } + + private static (byte[]? Buffer, int Length, Exception? Error) TryTcpFallbackSync( + ReadOnlyMemory query, IPEndPoint server) + { + try + { + (byte[] buffer, int length) = SendTcpQuerySync(query, server); + return (buffer, length, null); + } + catch (Exception ex) when (ex is SocketException or IOException) + { + return (null, 0, ex); + } + } + + private static async Task<(byte[] Buffer, int Length)> SendTcpQueryAsync( + ReadOnlyMemory query, IPEndPoint server, CancellationToken cancellationToken) + { + using Socket socket = new Socket(server.AddressFamily, SocketType.Stream, ProtocolType.Tcp); + using CancellationTokenSource timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(s_queryTimeout); + + await socket.ConnectAsync(server, timeoutCts.Token).ConfigureAwait(false); + + byte[] buffer = ArrayPool.Shared.Rent(InitialTcpBufferSize); + try + { + BinaryPrimitives.WriteUInt16BigEndian(buffer, (ushort)query.Length); + await SendExactAsync(socket, buffer.AsMemory(0, 2), timeoutCts.Token).ConfigureAwait(false); + await SendExactAsync(socket, query, timeoutCts.Token).ConfigureAwait(false); + + await ReceiveExactAsync(socket, buffer.AsMemory(0, 2), timeoutCts.Token).ConfigureAwait(false); + int responseLength = BinaryPrimitives.ReadUInt16BigEndian(buffer); + + if (responseLength > buffer.Length) + { + ArrayPool.Shared.Return(buffer); + buffer = ArrayPool.Shared.Rent(responseLength); + } + + await ReceiveExactAsync(socket, buffer.AsMemory(0, responseLength), timeoutCts.Token).ConfigureAwait(false); + return (buffer, responseLength); + } + catch + { + ArrayPool.Shared.Return(buffer); + throw; + } + } + + private static (byte[] Buffer, int Length) SendTcpQuerySync( + ReadOnlyMemory query, IPEndPoint server) + { + using Socket socket = new Socket(server.AddressFamily, SocketType.Stream, ProtocolType.Tcp); + socket.SendTimeout = (int)s_queryTimeout.TotalMilliseconds; + socket.ReceiveTimeout = (int)s_queryTimeout.TotalMilliseconds; + + // Connect with explicit timeout to prevent unbounded blocking when + // the server's TCP endpoint is unreachable. + IAsyncResult ar = socket.BeginConnect(server, null, null); + if (!ar.AsyncWaitHandle.WaitOne(s_queryTimeout)) + { + socket.Close(); + throw new SocketException((int)SocketError.TimedOut); + } + socket.EndConnect(ar); + + byte[] buffer = ArrayPool.Shared.Rent(InitialTcpBufferSize); + try + { + BinaryPrimitives.WriteUInt16BigEndian(buffer, (ushort)query.Length); + SendExactSync(socket, buffer.AsSpan(0, 2)); + SendExactSync(socket, query.Span); + + ReceiveExactSync(socket, buffer.AsSpan(0, 2)); + int responseLength = BinaryPrimitives.ReadUInt16BigEndian(buffer); + + if (responseLength > buffer.Length) + { + ArrayPool.Shared.Return(buffer); + buffer = ArrayPool.Shared.Rent(responseLength); + } + + ReceiveExactSync(socket, buffer.AsSpan(0, responseLength)); + return (buffer, responseLength); + } + catch + { + ArrayPool.Shared.Return(buffer); + throw; + } + } + + private static async Task ReceiveExactAsync(Socket socket, Memory buffer, CancellationToken cancellationToken) + { + int totalReceived = 0; + while (totalReceived < buffer.Length) + { + int received = await socket.ReceiveAsync(buffer[totalReceived..], SocketFlags.None, cancellationToken).ConfigureAwait(false); + if (received == 0) + { + ThrowMalformedResponse(); + } + totalReceived += received; + } + } + + private static void ReceiveExactSync(Socket socket, Span buffer) + { + int totalReceived = 0; + while (totalReceived < buffer.Length) + { + int received = socket.Receive(buffer.Slice(totalReceived), SocketFlags.None); + if (received == 0) + { + ThrowMalformedResponse(); + } + totalReceived += received; + } + } + + private static async Task SendExactAsync(Socket socket, ReadOnlyMemory buffer, CancellationToken cancellationToken) + { + int totalSent = 0; + while (totalSent < buffer.Length) + { + int sent = await socket.SendAsync(buffer[totalSent..], SocketFlags.None, cancellationToken).ConfigureAwait(false); + if (sent == 0) + { + throw new IOException(); + } + totalSent += sent; + } + } + + private static void SendExactSync(Socket socket, ReadOnlySpan buffer) + { + int totalSent = 0; + while (totalSent < buffer.Length) + { + int sent = socket.Send(buffer.Slice(totalSent), SocketFlags.None); + if (sent == 0) + { + throw new IOException(); + } + totalSent += sent; + } + } + + private static IReadOnlyList GetServers(IList servers) + { + if (servers.Count > 0) + { + // A port of 0 means "use the default DNS port" (53). + // Avoid allocating if all ports are already non-zero. + bool needsNormalization = false; + for (int i = 0; i < servers.Count; i++) + { + if (servers[i].Port == 0) + { + needsNormalization = true; + break; + } + } + + if (!needsNormalization) + { + // The IList may already be an array or List; wrap in a read-only view. + if (servers is IReadOnlyList readOnlyServers) + { + return readOnlyServers; + } + IPEndPoint[] copy = new IPEndPoint[servers.Count]; + servers.CopyTo(copy, 0); + return copy; + } + + IPEndPoint[] resolved = new IPEndPoint[servers.Count]; + for (int i = 0; i < servers.Count; i++) + { + IPEndPoint server = servers[i]; + resolved[i] = server.Port == 0 ? new IPEndPoint(server.Address, ResolvConf.DefaultDnsPort) : server; + } + return resolved; + } + + List systemServers = ResolvConf.GetNameServers(); + if (systemServers.Count > 0) + { + return systemServers; + } + + return new IPEndPoint[] { new IPEndPoint(IPAddress.Loopback, ResolvConf.DefaultDnsPort) }; + } + + // ---- Message reading helpers ---- + + private static DnsMessageReader CreateReader(ReadOnlySpan response) + { + if (!DnsMessageReader.TryCreate(response, out DnsMessageReader reader)) + { + ThrowMalformedResponse(); + } + return reader; + } + + private static void SkipQuestions(ref DnsMessageReader reader) + { + for (int i = 0; i < reader.Header.QuestionCount; i++) + { + if (!reader.TryReadQuestion(out _)) + { + ThrowMalformedResponse(); + } + } + } + + private static DnsRecord ReadRecord(ref DnsMessageReader reader) + { + if (!reader.TryReadRecord(out DnsRecord record)) + { + ThrowMalformedResponse(); + } + return record; + } + + private static void SkipRecords(ref DnsMessageReader reader, int count) + { + for (int i = 0; i < count; i++) + { + if (!reader.TryReadRecord(out _)) + { + ThrowMalformedResponse(); + } + } + } + + [DoesNotReturn] + private static void ThrowMalformedResponse() => + throw new InvalidDataException(); + + // Holds a response message buffer rented from the shared ArrayPool. + private readonly struct DnsResponse : IDisposable + { + private readonly byte[] _buffer; + private readonly int _length; + + public DnsResponse(byte[] buffer, int length) + { + _buffer = buffer; + _length = length; + } + + public ReadOnlySpan Span => _buffer.AsSpan(0, _length); + + public void Dispose() => ArrayPool.Shared.Return(_buffer); + } + } +} diff --git a/src/libraries/System.Net.NameResolution/src/System/Net/DnsWireEnums.cs b/src/libraries/System.Net.NameResolution/src/System/Net/DnsWireEnums.cs new file mode 100644 index 00000000000000..ca45bbab2f3daf --- /dev/null +++ b/src/libraries/System.Net.NameResolution/src/System/Net/DnsWireEnums.cs @@ -0,0 +1,56 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Net +{ + // DNS record TYPE values (RFC 1035 §3.2.2 and updates). + internal enum DnsRecordType : ushort + { + A = 1, + NS = 2, + CNAME = 5, + SOA = 6, + PTR = 12, + MX = 15, + TXT = 16, + AAAA = 28, + SRV = 33, + NAPTR = 35, + OPT = 41, + SVCB = 64, + HTTPS = 65, + } + + // DNS record CLASS values (RFC 1035 §3.2.4). + internal enum DnsRecordClass : ushort + { + Internet = 1, + Chaos = 3, + Hesiod = 4, + Any = 255, + } + + // DNS OPCODE values (RFC 1035 §4.1.1 and updates). + internal enum DnsOpCode : byte + { + Query = 0, + InverseQuery = 1, + Status = 2, + Notify = 4, + Update = 5, + } + + // DNS header flag bits. The enum values are the wire bit positions shifted + // right by 4 so the set fits in a byte; see DnsMessageHeader for the encoding. + [Flags] + internal enum DnsHeaderFlags : byte + { + None = 0, + AuthoritativeAnswer = 1 << 6, // AA — wire bit 10 + Truncation = 1 << 5, // TC — wire bit 9 + RecursionDesired = 1 << 4, // RD — wire bit 8 + RecursionAvailable = 1 << 3, // RA — wire bit 7 + AuthenticData = 1 << 1, // AD — wire bit 5 (RFC 4035) + CheckingDisabled = 1 << 0, // CD — wire bit 4 (RFC 4035) + } +} diff --git a/src/libraries/System.Net.NameResolution/src/System/Net/ResolvConf.cs b/src/libraries/System.Net.NameResolution/src/System/Net/ResolvConf.cs new file mode 100644 index 00000000000000..985c54b696ce8a --- /dev/null +++ b/src/libraries/System.Net.NameResolution/src/System/Net/ResolvConf.cs @@ -0,0 +1,79 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.IO; + +namespace System.Net +{ + // Parses the system DNS server configuration from /etc/resolv.conf (RFC-style + // "nameserver" directives). Used when DnsResolverOptions.Servers is empty. + internal static class ResolvConf + { + private const string ResolvConfPath = "/etc/resolv.conf"; + internal const int DefaultDnsPort = 53; + + public static List GetNameServers() + { + try + { + using StreamReader reader = new StreamReader(ResolvConfPath); + return Parse(reader); + } + catch (IOException) + { + return new List(); + } + catch (UnauthorizedAccessException) + { + return new List(); + } + } + + // Parses "nameserver
" directives from a resolv.conf-formatted stream. + // Lines beginning with '#' or ';' are comments. Any text following the address + // on a nameserver line is ignored. + internal static List Parse(TextReader reader) + { + List servers = new List(); + + string? line; + while ((line = reader.ReadLine()) is not null) + { + ReadOnlySpan span = line.AsSpan().Trim(); + if (span.IsEmpty || span[0] == '#' || span[0] == ';') + { + continue; + } + + const string Directive = "nameserver"; + if (!span.StartsWith(Directive, StringComparison.Ordinal)) + { + continue; + } + + ReadOnlySpan rest = span[Directive.Length..]; + if (rest.IsEmpty || (rest[0] != ' ' && rest[0] != '\t')) + { + continue; + } + + rest = rest.TrimStart(); + + // The address is the first whitespace-delimited token; ignore anything after it. + int ws = rest.IndexOfAny(' ', '\t'); + if (ws >= 0) + { + rest = rest[..ws]; + } + + if (IPAddress.TryParse(rest, out IPAddress? address)) + { + servers.Add(new IPEndPoint(address, DefaultDnsPort)); + } + } + + return servers; + } + } +} diff --git a/src/libraries/System.Net.NameResolution/tests/FunctionalTests/DnsResolverLoopbackTest.cs b/src/libraries/System.Net.NameResolution/tests/FunctionalTests/DnsResolverLoopbackTest.cs index b5adfdaf42ecf9..a34cba516c0907 100644 --- a/src/libraries/System.Net.NameResolution/tests/FunctionalTests/DnsResolverLoopbackTest.cs +++ b/src/libraries/System.Net.NameResolution/tests/FunctionalTests/DnsResolverLoopbackTest.cs @@ -109,7 +109,7 @@ private static async Task> ResolveNs(bool async, DnsResolver // ---- Address resolution ---- - [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsWindows))] + [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsNotMobile))] [InlineData(false)] [InlineData(true)] public async Task ResolveAddresses_Unspecified_ReturnsBothV4AndV6(bool async) @@ -126,7 +126,7 @@ public async Task ResolveAddresses_Unspecified_ReturnsBothV4AndV6(bool async) Assert.Contains(result.Records, a => a.Address.ToString() == "fd00::1"); } - [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsWindows))] + [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsNotMobile))] [InlineData(false)] [InlineData(true)] public async Task ResolveAddresses_IPv4Only_ReturnsOnlyV4(bool async) @@ -143,7 +143,7 @@ public async Task ResolveAddresses_IPv4Only_ReturnsOnlyV4(bool async) Assert.Equal("10.0.0.2", record.Address.ToString()); } - [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsWindows))] + [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsNotMobile))] [InlineData(false)] [InlineData(true)] public async Task ResolveAddresses_IPv6Only_ReturnsOnlyV6(bool async) @@ -159,7 +159,7 @@ public async Task ResolveAddresses_IPv6Only_ReturnsOnlyV6(bool async) Assert.Equal("fd00::1", record.Address.ToString()); } - [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsWindows))] + [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsNotMobile))] [InlineData(false)] [InlineData(true)] public async Task ResolveAddresses_AddressFamilyV4_QueriesOnlyA(bool async) @@ -174,7 +174,7 @@ public async Task ResolveAddresses_AddressFamilyV4_QueriesOnlyA(bool async) Assert.Equal("192.0.2.7", record.Address.ToString()); } - [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsWindows))] + [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsNotMobile))] [InlineData(false)] [InlineData(true)] public async Task ResolveAddresses_HasTtl(bool async) @@ -191,7 +191,7 @@ public async Task ResolveAddresses_HasTtl(bool async) $"Unexpected TTL: {record.Ttl}"); } - [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsWindows))] + [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsNotMobile))] [InlineData(false)] [InlineData(true)] public async Task ResolveAddresses_Nxdomain_ReturnsNxDomain(bool async) @@ -209,13 +209,19 @@ public async Task ResolveAddresses_Nxdomain_ReturnsNxDomain(bool async) Assert.Equal(DnsResponseCode.NxDomain, result.ResponseCode); Assert.Empty(result.Records); +#if WINDOWS // DnsQueryEx does not surface the authority-section SOA for negative responses // (it returns no records at all), so the negative-cache TTL is unavailable on // Windows and reported as zero. See DnsResult.NegativeCacheTtl remarks. Assert.Equal(TimeSpan.Zero, result.NegativeCacheTtl); +#else + // The managed resolver derives the negative-cache TTL from the authority SOA record (120s). + Assert.True(result.NegativeCacheTtl > TimeSpan.Zero && result.NegativeCacheTtl <= TimeSpan.FromSeconds(120), + $"Unexpected NegativeCacheTtl: {result.NegativeCacheTtl}"); +#endif } - [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsWindows))] + [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsNotMobile))] [InlineData(false)] [InlineData(true)] public async Task ResolveAddresses_NoData_ReturnsNoErrorWithEmptyRecords(bool async) @@ -232,13 +238,19 @@ public async Task ResolveAddresses_NoData_ReturnsNoErrorWithEmptyRecords(bool as Assert.Equal(DnsResponseCode.NoError, result.ResponseCode); Assert.Empty(result.Records); +#if WINDOWS // DnsQueryEx does not surface the authority-section SOA for NODATA responses // (it returns no records at all), so the negative-cache TTL is unavailable on // Windows and reported as zero. See DnsResult.NegativeCacheTtl remarks. Assert.Equal(TimeSpan.Zero, result.NegativeCacheTtl); +#else + // The managed resolver derives the negative-cache TTL from the authority SOA record (30s). + Assert.True(result.NegativeCacheTtl > TimeSpan.Zero && result.NegativeCacheTtl <= TimeSpan.FromSeconds(30), + $"Unexpected NegativeCacheTtl: {result.NegativeCacheTtl}"); +#endif } - [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsWindows))] + [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsNotMobile))] [InlineData(false)] [InlineData(true)] public async Task ResolveAddresses_NoData_And_Nxdomain_AreDistinguishable(bool async) @@ -272,7 +284,7 @@ public async Task ResolveAddresses_NoData_And_Nxdomain_AreDistinguishable(bool a // ---- SRV ---- - [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsWindows))] + [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsNotMobile))] [InlineData(false)] [InlineData(true)] public async Task ResolveSrv_ReturnsRecords(bool async) @@ -297,7 +309,7 @@ public async Task ResolveSrv_ReturnsRecords(bool async) Assert.Equal((ushort)20, s2.Priority); } - [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsWindows))] + [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsNotMobile))] [InlineData(false)] [InlineData(true)] public async Task ResolveSrv_IncludesAdditionalAddresses(bool async) @@ -322,7 +334,7 @@ public async Task ResolveSrv_IncludesAdditionalAddresses(bool async) Assert.Equal(2, s2.Addresses.Count); } - [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsWindows))] + [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsNotMobile))] [InlineData(false)] [InlineData(true)] public async Task ResolveSrv_NoAdditionalAddresses(bool async) @@ -340,7 +352,7 @@ public async Task ResolveSrv_NoAdditionalAddresses(bool async) // ---- MX / TXT / CNAME / PTR / NS ---- - [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsWindows))] + [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsNotMobile))] [InlineData(false)] [InlineData(true)] public async Task ResolveMx_ReturnsRecords(bool async) @@ -360,7 +372,7 @@ public async Task ResolveMx_ReturnsRecords(bool async) Assert.Single(result.Records, m => m.Exchange == "mail2.test" && m.Preference == 20); } - [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsWindows))] + [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsNotMobile))] [InlineData(false)] [InlineData(true)] public async Task ResolveTxt_ReturnsValues(bool async) @@ -378,7 +390,7 @@ public async Task ResolveTxt_ReturnsValues(bool async) Assert.Contains(result.Records, t => t.Values.Count == 2 && t.Values[0] == "part1" && t.Values[1] == "part2"); } - [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsWindows))] + [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsNotMobile))] [InlineData(false)] [InlineData(true)] public async Task ResolveCName_ReturnsCanonicalName(bool async) @@ -394,7 +406,7 @@ public async Task ResolveCName_ReturnsCanonicalName(bool async) Assert.Equal("canonical.test", record.CanonicalName); } - [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsWindows))] + [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsNotMobile))] [InlineData(false)] [InlineData(true)] public async Task ResolvePtr_ReturnsName(bool async) @@ -410,7 +422,7 @@ public async Task ResolvePtr_ReturnsName(bool async) Assert.Equal("host.test", record.Name); } - [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsWindows))] + [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsNotMobile))] [InlineData(false)] [InlineData(true)] public async Task ResolveNs_ReturnsRecords(bool async) @@ -454,7 +466,7 @@ public async Task CustomServer_DefaultPortZero_IsAccepted(bool async) // ---- Cancellation while a query is in flight ---- - [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsWindows))] + [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsNotMobile))] public async Task ResolveAddresses_CancellationInFlight_Throws() { using SemaphoreSlim queryReceived = new(0, 1); @@ -485,7 +497,7 @@ public async Task ResolveAddresses_CancellationInFlight_Throws() // ---- Telemetry ---- - [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsWindows))] + [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsNotMobile))] [InlineData(false)] [InlineData(true)] public async Task ResolveAddresses_RecordsDurationMetric_CoversQueryTime(bool async) diff --git a/src/libraries/System.Net.NameResolution/tests/PalTests/ResolvConfTests.cs b/src/libraries/System.Net.NameResolution/tests/PalTests/ResolvConfTests.cs new file mode 100644 index 00000000000000..d6d8a226f526fc --- /dev/null +++ b/src/libraries/System.Net.NameResolution/tests/PalTests/ResolvConfTests.cs @@ -0,0 +1,118 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.IO; +using Xunit; + +namespace System.Net.NameResolution.PalTests +{ + public class ResolvConfTests + { + private static List Parse(string contents) + { + using StringReader reader = new StringReader(contents); + return ResolvConf.Parse(reader); + } + + [Fact] + public void Parse_SingleNameserver_ReturnsEndpointWithPort53() + { + List servers = Parse("nameserver 192.0.2.1\n"); + + IPEndPoint server = Assert.Single(servers); + Assert.Equal(IPAddress.Parse("192.0.2.1"), server.Address); + Assert.Equal(53, server.Port); + } + + [Fact] + public void Parse_MultipleNameservers_PreservesOrder() + { + List servers = Parse( + "nameserver 192.0.2.1\n" + + "nameserver 192.0.2.2\n" + + "nameserver 192.0.2.3\n"); + + Assert.Equal(3, servers.Count); + Assert.Equal(IPAddress.Parse("192.0.2.1"), servers[0].Address); + Assert.Equal(IPAddress.Parse("192.0.2.2"), servers[1].Address); + Assert.Equal(IPAddress.Parse("192.0.2.3"), servers[2].Address); + } + + [Fact] + public void Parse_IPv6Nameserver_IsParsed() + { + List servers = Parse("nameserver 2001:db8::1\n"); + + IPEndPoint server = Assert.Single(servers); + Assert.Equal(IPAddress.Parse("2001:db8::1"), server.Address); + Assert.Equal(53, server.Port); + } + + [Theory] + [InlineData("# nameserver 192.0.2.1\n")] + [InlineData("; nameserver 192.0.2.1\n")] + [InlineData("\n \n\t\n")] + [InlineData("search example.com\noptions ndots:2\ndomain example.com\n")] + public void Parse_NonNameserverContent_ReturnsEmpty(string contents) + { + Assert.Empty(Parse(contents)); + } + + [Fact] + public void Parse_IgnoresOtherDirectivesAndComments() + { + List servers = Parse( + "# This is a comment\n" + + "domain example.com\n" + + "search example.com sub.example.com\n" + + "nameserver 192.0.2.10\n" + + "; trailing comment\n" + + "options ndots:1 timeout:2\n" + + "nameserver 192.0.2.20\n"); + + Assert.Equal(2, servers.Count); + Assert.Equal(IPAddress.Parse("192.0.2.10"), servers[0].Address); + Assert.Equal(IPAddress.Parse("192.0.2.20"), servers[1].Address); + } + + [Fact] + public void Parse_TextAfterAddress_IsIgnored() + { + List servers = Parse("nameserver 192.0.2.1 # primary resolver\n"); + + IPEndPoint server = Assert.Single(servers); + Assert.Equal(IPAddress.Parse("192.0.2.1"), server.Address); + } + + [Fact] + public void Parse_TabSeparatedNameserver_IsParsed() + { + List servers = Parse("nameserver\t192.0.2.1\n"); + + IPEndPoint server = Assert.Single(servers); + Assert.Equal(IPAddress.Parse("192.0.2.1"), server.Address); + } + + [Theory] + [InlineData("nameserver\n")] + [InlineData("nameserver \n")] + [InlineData("nameserver not-an-ip\n")] + [InlineData("nameserverextra 192.0.2.1\n")] + public void Parse_InvalidNameserverLines_AreIgnored(string contents) + { + Assert.Empty(Parse(contents)); + } + + [Fact] + public void Parse_ValidAndInvalidMixed_ReturnsOnlyValid() + { + List servers = Parse( + "nameserver not-an-ip\n" + + "nameserver 192.0.2.1\n"); + + IPEndPoint server = Assert.Single(servers); + Assert.Equal(IPAddress.Parse("192.0.2.1"), server.Address); + } + } +} diff --git a/src/libraries/System.Net.NameResolution/tests/PalTests/System.Net.NameResolution.Pal.Tests.csproj b/src/libraries/System.Net.NameResolution/tests/PalTests/System.Net.NameResolution.Pal.Tests.csproj index 05e55180110b81..9c35e7c971348d 100644 --- a/src/libraries/System.Net.NameResolution/tests/PalTests/System.Net.NameResolution.Pal.Tests.csproj +++ b/src/libraries/System.Net.NameResolution/tests/PalTests/System.Net.NameResolution.Pal.Tests.csproj @@ -108,4 +108,9 @@ + + + + From 246dc8f51eeca9df254a930464faf0b72818ddcf Mon Sep 17 00:00:00 2001 From: Radek Zikmund Date: Thu, 25 Jun 2026 14:11:12 +0200 Subject: [PATCH 02/12] Add unit test project for managed DNS parsing types Adds System.Net.NameResolution.Unit.Tests covering the internal DNS wire-format types (DnsEncodedName, DnsMessageHeader/Reader/Writer, and typed RDATA parsing). The project links the production parsing sources so the internal types can be exercised directly. Also adds DnsEncodedName.GetFormattedLength() to expose the decoded dotted-string length (already computed internally) for buffer sizing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/System/Net/DnsEncodedName.cs | 31 + .../tests/UnitTests/DnsEncodedNameTests.cs | 616 ++++++++++++++++++ .../tests/UnitTests/DnsMessageHeaderTests.cs | 208 ++++++ .../tests/UnitTests/DnsMessageReaderTests.cs | 377 +++++++++++ .../tests/UnitTests/DnsMessageWriterTests.cs | 122 ++++ .../tests/UnitTests/DnsRecordTypeTests.cs | 412 ++++++++++++ ...ystem.Net.NameResolution.Unit.Tests.csproj | 36 + 7 files changed, 1802 insertions(+) create mode 100644 src/libraries/System.Net.NameResolution/tests/UnitTests/DnsEncodedNameTests.cs create mode 100644 src/libraries/System.Net.NameResolution/tests/UnitTests/DnsMessageHeaderTests.cs create mode 100644 src/libraries/System.Net.NameResolution/tests/UnitTests/DnsMessageReaderTests.cs create mode 100644 src/libraries/System.Net.NameResolution/tests/UnitTests/DnsMessageWriterTests.cs create mode 100644 src/libraries/System.Net.NameResolution/tests/UnitTests/DnsRecordTypeTests.cs create mode 100644 src/libraries/System.Net.NameResolution/tests/UnitTests/System.Net.NameResolution.Unit.Tests.csproj diff --git a/src/libraries/System.Net.NameResolution/src/System/Net/DnsEncodedName.cs b/src/libraries/System.Net.NameResolution/src/System/Net/DnsEncodedName.cs index 119212b2a1be72..8e0efbb8b25bdf 100644 --- a/src/libraries/System.Net.NameResolution/src/System/Net/DnsEncodedName.cs +++ b/src/libraries/System.Net.NameResolution/src/System/Net/DnsEncodedName.cs @@ -281,6 +281,37 @@ private static bool IsAceLabel(ReadOnlySpan label) Ascii.EqualsIgnoreCase(label[..4], "xn--"u8); } + // Returns the character count of the decoded dotted-string representation. + // For names containing ACE-encoded labels, this returns the length of the Unicode form. + // Useful for sizing a destination buffer before calling TryDecode. + public unsafe int GetFormattedLength() + { + if (_isAce) + { + // ACE names need full IDN conversion to determine the Unicode length. + Span chars = stackalloc char[256]; + bool success = TryDecode(chars, out int charsWritten); + Debug.Assert(success); + return charsWritten; + } + + int length = 0; + bool first = true; + + foreach (ReadOnlySpan label in EnumerateLabels()) + { + if (!first) + { + length++; // dot separator + } + first = false; + length += label.Length; + } + + // Root name: no labels, formatted as ".". + return length == 0 ? 1 : length; + } + // Enumerates the individual labels of this domain name. // Follows compression pointers transparently. public DnsLabelEnumerator EnumerateLabels() => new DnsLabelEnumerator(_buffer, _offset); diff --git a/src/libraries/System.Net.NameResolution/tests/UnitTests/DnsEncodedNameTests.cs b/src/libraries/System.Net.NameResolution/tests/UnitTests/DnsEncodedNameTests.cs new file mode 100644 index 00000000000000..5b57d491039ed3 --- /dev/null +++ b/src/libraries/System.Net.NameResolution/tests/UnitTests/DnsEncodedNameTests.cs @@ -0,0 +1,616 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Buffers; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Text; +using Xunit; + +namespace System.Net.NameResolution.Tests; + +public class DnsEncodedNameTests +{ + [Theory] + [InlineData("example.com", new byte[] { 7, (byte)'e', (byte)'x', (byte)'a', (byte)'m', (byte)'p', (byte)'l', (byte)'e', 3, (byte)'c', (byte)'o', (byte)'m', 0 })] + [InlineData("a.b", new byte[] { 1, (byte)'a', 1, (byte)'b', 0 })] + public void TryCreate_ValidName_ProducesExpectedBytes(string name, byte[] expected) + { + Span buffer = stackalloc byte[DnsEncodedName.MaxEncodedLength]; + OperationStatus status = DnsEncodedName.TryEncode(name, buffer, out _, out int bytesWritten); + + Assert.Equal(OperationStatus.Done, status); + Assert.Equal(expected.Length, bytesWritten); + Assert.True(buffer[..bytesWritten].SequenceEqual(expected)); + } + + [Theory] + [InlineData("")] // empty → root + [InlineData(".")] // explicit root + public void TryCreate_Root_ProducesSingleZeroByte(string name) + { + Span buffer = stackalloc byte[DnsEncodedName.MaxEncodedLength]; + OperationStatus status = DnsEncodedName.TryEncode(name, buffer, out _, out int bytesWritten); + + Assert.Equal(OperationStatus.Done, status); + Assert.Equal(1, bytesWritten); + Assert.Equal(0, buffer[0]); + } + + [Fact] + public void TryCreate_TrailingDot_SameAsWithout() + { + Span buf1 = stackalloc byte[DnsEncodedName.MaxEncodedLength]; + Span buf2 = stackalloc byte[DnsEncodedName.MaxEncodedLength]; + + DnsEncodedName.TryEncode("example.com", buf1, out _, out int len1); + DnsEncodedName.TryEncode("example.com.", buf2, out _, out int len2); + + Assert.Equal(len1, len2); + Assert.True(buf1[..len1].SequenceEqual(buf2[..len2])); + } + + [Fact] + public void TryCreate_LabelTooLong_ReturnsInvalidData() + { + string longLabel = new string('a', 64) + ".com"; // 64 > 63 max + Span buffer = stackalloc byte[DnsEncodedName.MaxEncodedLength]; + OperationStatus status = DnsEncodedName.TryEncode(longLabel, buffer, out _, out _); + Assert.Equal(OperationStatus.InvalidData, status); + } + + [Fact] + public void TryCreate_MaxLengthLabel_Succeeds() + { + string maxLabel = new string('a', 63) + ".com"; + Span buffer = stackalloc byte[DnsEncodedName.MaxEncodedLength]; + OperationStatus status = DnsEncodedName.TryEncode(maxLabel, buffer, out _, out _); + Assert.Equal(OperationStatus.Done, status); + } + + [Fact] + public void TryCreate_ConsecutiveDots_ReturnsInvalidData() + { + Span buffer = stackalloc byte[DnsEncodedName.MaxEncodedLength]; + OperationStatus status = DnsEncodedName.TryEncode("example..com", buffer, out _, out _); + Assert.Equal(OperationStatus.InvalidData, status); + } + + [Fact] + public void TryCreate_NameTooLong_ReturnsInvalidData() + { + // Build a name that exceeds 255 wire-format bytes + // Each "a." label takes 3 bytes (1 length + 1 char + will get a dot separator) + // 63 labels of "aaa" = 63 * (1+3) + 1 root = 253 bytes — just fits + // Add one more to overflow + string name = string.Join(".", Enumerable.Repeat("aaaa", 64)); + Span buffer = stackalloc byte[512]; // oversized buffer + OperationStatus status = DnsEncodedName.TryEncode(name, buffer, out _, out _); + Assert.Equal(OperationStatus.InvalidData, status); + } + + [Fact] + public void TryCreate_DestinationTooSmall_ReturnsDestinationTooSmall() + { + Span buffer = stackalloc byte[5]; // too small for "example.com" + OperationStatus status = DnsEncodedName.TryEncode("example.com", buffer, out _, out _); + Assert.Equal(OperationStatus.DestinationTooSmall, status); + } + + [Theory] + [InlineData("example.com", "example.com", true)] + [InlineData("example.com", "EXAMPLE.COM", true)] + [InlineData("example.com", "Example.Com", true)] + [InlineData("example.com", "example.com.", true)] // trailing dot ignored + [InlineData("example.com", "example.org", false)] + [InlineData("example.com", "example", false)] + [InlineData("a.b.c", "a.b.c", true)] + [InlineData("a.b.c", "a.b", false)] + public void Equals_CaseInsensitiveComparison(string create, string compare, bool expected) + { + Span buffer = stackalloc byte[DnsEncodedName.MaxEncodedLength]; + DnsEncodedName.TryEncode(create, buffer, out var name, out _); + Assert.Equal(expected, name.Equals(compare)); + } + + [Fact] + public void TryDecode_ProducesDottedString() + { + Span nameBuffer = stackalloc byte[DnsEncodedName.MaxEncodedLength]; + DnsEncodedName.TryEncode("example.com", nameBuffer, out var name, out _); + + Span chars = stackalloc char[64]; + Assert.True(name.TryDecode(chars, out int written)); + Assert.Equal("example.com", new string(chars[..written])); + } + + [Fact] + public void TryDecode_Root_ProducesSingleDot() + { + Span nameBuffer = stackalloc byte[DnsEncodedName.MaxEncodedLength]; + DnsEncodedName.TryEncode(".", nameBuffer, out var name, out _); + + Span chars = stackalloc char[64]; + Assert.True(name.TryDecode(chars, out int written)); + Assert.Equal(1, written); + Assert.Equal('.', chars[0]); + } + + [Fact] + public void TryDecode_DestinationTooSmall_ReturnsFalse() + { + Span nameBuffer = stackalloc byte[DnsEncodedName.MaxEncodedLength]; + DnsEncodedName.TryEncode("example.com", nameBuffer, out var name, out _); + + Span chars = stackalloc char[5]; // too small + Assert.False(name.TryDecode(chars, out _)); + } + + [Fact] + public void GetFormattedLength_ReturnsCorrectLength() + { + Span nameBuffer = stackalloc byte[DnsEncodedName.MaxEncodedLength]; + DnsEncodedName.TryEncode("example.com", nameBuffer, out var name, out _); + Assert.Equal("example.com".Length, name.GetFormattedLength()); + } + + [Theory] + [InlineData("")] + [InlineData(".")] + public void GetFormattedLength_Root_ReturnsOne(string input) + { + Span nameBuffer = stackalloc byte[DnsEncodedName.MaxEncodedLength]; + DnsEncodedName.TryEncode(input, nameBuffer, out var name, out _); + int length = name.GetFormattedLength(); + Assert.Equal(1, length); + + // Verify that GetFormattedLength is sufficient for TryDecode + Span decoded = stackalloc char[length]; + Assert.True(name.TryDecode(decoded, out int written)); + Assert.Equal(1, written); + Assert.Equal('.', decoded[0]); + } + + [Fact] + public void ToString_ReturnsFormattedName() + { + Span nameBuffer = stackalloc byte[DnsEncodedName.MaxEncodedLength]; + DnsEncodedName.TryEncode("example.com", nameBuffer, out var name, out _); + Assert.Equal("example.com", name.ToString()); + } + + [Fact] + public void ToString_Root_ReturnsDot() + { + Span nameBuffer = stackalloc byte[DnsEncodedName.MaxEncodedLength]; + DnsEncodedName.TryEncode(".", nameBuffer, out var name, out _); + Assert.Equal(".", name.ToString()); + } + + [Fact] + public void EnumerateLabels_ReturnsAllLabels() + { + Span nameBuffer = stackalloc byte[DnsEncodedName.MaxEncodedLength]; + DnsEncodedName.TryEncode("a.bb.ccc", nameBuffer, out var name, out _); + + List labels = new(); + foreach (ReadOnlySpan label in name.EnumerateLabels()) + labels.Add(Encoding.ASCII.GetString(label)); + + Assert.Equal(["a", "bb", "ccc"], labels); + } + + [Fact] + public void EnumerateLabels_Root_ReturnsNoLabels() + { + Span nameBuffer = stackalloc byte[DnsEncodedName.MaxEncodedLength]; + DnsEncodedName.TryEncode(".", nameBuffer, out var name, out _); + + List labels = new(); + foreach (ReadOnlySpan label in name.EnumerateLabels()) + labels.Add(Encoding.ASCII.GetString(label)); + + Assert.Empty(labels); + } + + [Fact] + public void CompressionPointer_FollowedCorrectly() + { + // Simulate a DNS message where a name uses a compression pointer: + // Offset 0: \x07example\x03com\x00 (example.com, 13 bytes) + // Offset 13: \x03www\xC0\x00 (www + pointer to offset 0 = www.example.com) + byte[] message = + [ + 7, (byte)'e', (byte)'x', (byte)'a', (byte)'m', (byte)'p', (byte)'l', (byte)'e', + 3, (byte)'c', (byte)'o', (byte)'m', 0, + 3, (byte)'w', (byte)'w', (byte)'w', 0xC0, 0x00 + ]; + + Assert.True(DnsEncodedName.TryParse(message, 13, out DnsEncodedName name, out _)); + Assert.True(name.Equals("www.example.com")); + Assert.Equal("www.example.com", name.ToString()); + } + + [Fact] + public void CompressionPointer_MidName() + { + // Offset 0: \x03com\x00 (com, 5 bytes) + // Offset 5: \x03foo\xC0\x00 (foo + pointer to offset 0 = foo.com) + byte[] message = + [ + 3, (byte)'c', (byte)'o', (byte)'m', 0, + 3, (byte)'f', (byte)'o', (byte)'o', 0xC0, 0x00 + ]; + + Assert.True(DnsEncodedName.TryParse(message, 5, out DnsEncodedName name, out _)); + Assert.True(name.Equals("foo.com")); + } + + [Fact] + public void TryParse_FlatName_BytesConsumedMatchesEncodedLength() + { + Span buffer = stackalloc byte[DnsEncodedName.MaxEncodedLength]; + DnsEncodedName.TryEncode("example.com", buffer, out _, out int bytesWritten); + Assert.True(DnsEncodedName.TryParse(buffer, 0, out _, out int consumed)); + Assert.Equal(bytesWritten, consumed); + } + + [Fact] + public void TryParse_WithCompressionPointer_BytesConsumedIsPointerSize() + { + // Name at offset 13: \x03www\xC0\x00 — 6 bytes consumed (1+3 label + 2 pointer) + byte[] message = + [ + 7, (byte)'e', (byte)'x', (byte)'a', (byte)'m', (byte)'p', (byte)'l', (byte)'e', + 3, (byte)'c', (byte)'o', (byte)'m', 0, + 3, (byte)'w', (byte)'w', (byte)'w', 0xC0, 0x00 + ]; + + Assert.True(DnsEncodedName.TryParse(message, 13, out _, out int consumed)); + Assert.Equal(6, consumed); + } + + [Fact] + public void CompressionPointer_SelfReferencing_TryParseFails() + { + // Pointer at offset 0 that points to itself + byte[] message = [0xC0, 0x00]; + Assert.False(DnsEncodedName.TryParse(message, 0, out _, out _)); + } + + [Fact] + public void CompressionPointer_ForwardPointer_TryParseFails() + { + // Pointer at offset 0 that points forward to offset 2 (past itself, but within buffer) + // Offset 2 has another pointer back to offset 0 → loop + byte[] message = [0xC0, 0x02, 0xC0, 0x00]; + Assert.False(DnsEncodedName.TryParse(message, 0, out _, out _)); + } + + [Fact] + public void CompressionPointer_ChainedPointers_ResolvesCorrectly() + { + // Chained backwards pointers: offset 5 → offset 3 → offset 0 → label "a" + root + byte[] message = [0x01, (byte)'a', 0x00, 0xC0, 0x00, 0xC0, 0x03]; + Assert.True(DnsEncodedName.TryParse(message, 5, out DnsEncodedName name, out _)); + Assert.True(name.Equals("a")); + } + + [Fact] + public void CompressionPointer_OutOfBounds_TryParseFails() + { + // Pointer to offset 0xFF, far beyond the 4-byte buffer + byte[] message = [0xC0, 0xFF, 0x00, 0x00]; + Assert.False(DnsEncodedName.TryParse(message, 0, out _, out _)); + } + + [Fact] + public void CompressionPointer_ForwardJump_TryParseFails() + { + // Forward pointer: offset 0 points to offset 2 (forward, not allowed) + byte[] message = [0xC0, 0x02, 0x01, (byte)'a', 0x00]; + Assert.False(DnsEncodedName.TryParse(message, 0, out _, out _)); + } + + [Fact] + public void CompressionPointer_SelfJump_TryParseFails() + { + // Self-referencing pointer: offset 0 points to offset 0 + byte[] message = [0xC0, 0x00, 0x00]; + Assert.False(DnsEncodedName.TryParse(message, 0, out _, out _)); + } + + [Fact] + public void TryCreate_TrailingDoubleDot_ReturnsInvalidData() + { + Span nameBuf = stackalloc byte[DnsEncodedName.MaxEncodedLength]; + OperationStatus status = DnsEncodedName.TryEncode("vp..", nameBuf, out _, out _); + Assert.Equal(OperationStatus.InvalidData, status); + } + + [Fact] + public void TryCreate_NullCharsAndConsecutiveDots_ReturnsInvalidData() + { + Span nameChars = ['\0', '\0', '\0', '\0', 'p', '.', '.']; + Span nameBuf = stackalloc byte[DnsEncodedName.MaxEncodedLength]; + OperationStatus status = DnsEncodedName.TryEncode(nameChars, nameBuf, out _, out _); + Assert.Equal(OperationStatus.InvalidData, status); + } + + [Fact] + public void TryParse_ValidRootName_Succeeds() + { + byte[] buffer = [0x00]; + Assert.True(DnsEncodedName.TryParse(buffer, 0, out DnsEncodedName name, out int consumed)); + Assert.Equal(1, consumed); + Assert.Equal(".", name.ToString()); + } + + [Fact] + public void TryParse_ValidFlatName_Succeeds() + { + byte[] buffer = [3, (byte)'w', (byte)'w', (byte)'w', 7, (byte)'e', (byte)'x', (byte)'a', (byte)'m', (byte)'p', (byte)'l', (byte)'e', 3, (byte)'c', (byte)'o', (byte)'m', 0]; + Assert.True(DnsEncodedName.TryParse(buffer, 0, out DnsEncodedName name, out int consumed)); + Assert.Equal(buffer.Length, consumed); + Assert.Equal("www.example.com", name.ToString()); + } + + [Fact] + public void TryParse_AtOffset_Succeeds() + { + // "com" starts at offset 12 in a typical message; simulate with padding + byte[] buffer = new byte[5 + 3 + 3 + 1]; // 5 bytes padding + 3-label "com" + root + buffer[5] = 3; + buffer[6] = (byte)'c'; + buffer[7] = (byte)'o'; + buffer[8] = (byte)'m'; + buffer[9] = 0; + Assert.True(DnsEncodedName.TryParse(buffer, 5, out DnsEncodedName name, out int consumed)); + Assert.Equal(5, consumed); + Assert.Equal("com", name.ToString()); + } + + [Fact] + public void TryParse_WithCompressionPointer_Succeeds() + { + // Buffer: "com\0" at offset 0, then a pointer to offset 0 at offset 4 + byte[] buffer = [3, (byte)'c', (byte)'o', (byte)'m', 0, 0xC0, 0x00]; + Assert.True(DnsEncodedName.TryParse(buffer, 5, out DnsEncodedName name, out int consumed)); + Assert.Equal(2, consumed); // compression pointer is 2 bytes + Assert.Equal("com", name.ToString()); + } + + [Fact] + public void TryParse_Truncated_ReturnsFalse() + { + // Label says length 5 but buffer only has 3 more bytes + byte[] buffer = [5, (byte)'a', (byte)'b']; + Assert.False(DnsEncodedName.TryParse(buffer, 0, out _, out _)); + } + + [Fact] + public void TryParse_LabelTooLong_ReturnsFalse() + { + // Label length byte > 63 and not a pointer (0x40..0xBF range) + byte[] buffer = [0x50, 0x00]; + Assert.False(DnsEncodedName.TryParse(buffer, 0, out _, out _)); + } + + [Fact] + public void TryParse_NegativeOffset_ReturnsFalse() + { + byte[] buffer = [0x00]; + Assert.False(DnsEncodedName.TryParse(buffer, -1, out _, out _)); + } + + [Fact] + public void TryParse_OffsetBeyondBuffer_ReturnsFalse() + { + byte[] buffer = [0x00]; + Assert.False(DnsEncodedName.TryParse(buffer, 5, out _, out _)); + } + + [Fact] + public void TryParse_EmptyBuffer_ReturnsFalse() + { + Assert.False(DnsEncodedName.TryParse(ReadOnlySpan.Empty, 0, out _, out _)); + } + + [Fact] + public void TryParse_CompressionPointerLoop_Fails() + { + // Two pointers that point at each other: offset 0 → offset 2 → offset 0 + // TryParse now rejects this because forward jumps are not allowed. + byte[] buffer = [0xC0, 0x02, 0xC0, 0x00]; + Assert.False(DnsEncodedName.TryParse(buffer, 0, out _, out _)); + } + + [Fact] + public void TryParse_PointerAtEndOfBuffer_ReturnsFalse() + { + // Compression pointer with only 1 byte (missing second byte) + byte[] buffer = [0xC0]; + Assert.False(DnsEncodedName.TryParse(buffer, 0, out _, out _)); + } + + [Fact] + public void TryParse_LabelExtendsPastBuffer_ReturnsFalse() + { + // Label says 5 bytes but buffer only has 2 more bytes after length + byte[] buffer = [0x05, (byte)'a', (byte)'b']; + Assert.False(DnsEncodedName.TryParse(buffer, 0, out _, out _)); + } + + [Fact] + public void TryEncode_RootName_DestinationTooSmall_ReturnsError() + { + Span buffer = Span.Empty; // 0 bytes — can't even fit root + OperationStatus status = DnsEncodedName.TryEncode(".", buffer, out _, out _); + Assert.Equal(OperationStatus.DestinationTooSmall, status); + } + + [Fact] + public void TryEncode_DestinationExactlyFitsRootTerminator() + { + // Name "a" needs 3 bytes: \x01a\x00. Provide exactly 3 bytes. + Span buffer = stackalloc byte[3]; + OperationStatus status = DnsEncodedName.TryEncode("a", buffer, out _, out int written); + Assert.Equal(OperationStatus.Done, status); + Assert.Equal(3, written); + } + + [Fact] + public void TryEncode_DestinationTooSmallForRootTerminator() + { + // Name "a" needs 3 bytes: \x01a\x00. Only provide 2 bytes. + Span buffer = stackalloc byte[2]; + OperationStatus status = DnsEncodedName.TryEncode("a", buffer, out _, out _); + Assert.Equal(OperationStatus.DestinationTooSmall, status); + } + + [Fact] + public void TryEncode_NonAsciiCharacter_ConvertedToAce() + { + Span buffer = stackalloc byte[DnsEncodedName.MaxEncodedLength]; + OperationStatus status = DnsEncodedName.TryEncode("café.test", buffer, out DnsEncodedName name, out _); + Assert.Equal(OperationStatus.Done, status); + // café → xn--caf-dma in ACE + Assert.True(name.Equals("xn--caf-dma.test")); + } + + [Fact] + public void Equals_DifferentLabelCount_ReturnsFalse() + { + Span buffer = stackalloc byte[DnsEncodedName.MaxEncodedLength]; + DnsEncodedName.TryEncode("a.b.c", buffer, out DnsEncodedName name, out _); + Assert.False(name.Equals("a.b")); + Assert.False(name.Equals("a.b.c.d")); + } + + [Fact] + public void Equals_EmptyString_MatchesRoot() + { + Span buffer = stackalloc byte[1]; + DnsEncodedName.TryEncode(".", buffer, out DnsEncodedName name, out _); + Assert.True(name.Equals("")); + Assert.True(name.Equals(".")); + } + + [Fact] + public void TryParse_CompressionPointer_BytesConsumedIsTwo() + { + // "com\0" at offset 0, then pointer at offset 4 + byte[] buffer = [3, (byte)'c', (byte)'o', (byte)'m', 0, 0xC0, 0x00]; + Assert.True(DnsEncodedName.TryParse(buffer, 5, out _, out int consumed)); + // Compression pointer consumes 2 bytes + Assert.Equal(2, consumed); + } + + // === IDN (Internationalized Domain Name) Tests === + + [Theory] + [InlineData("münchen.de", "xn--mnchen-3ya.de")] + [InlineData("例え.jp", "xn--r8jz45g.jp")] + [InlineData("café.test", "xn--caf-dma.test")] + [InlineData("домен.рф", "xn--d1acufc.xn--p1ai")] + public void TryEncode_IdnName_ProducesAceWireFormat(string unicode, string expectedAce) + { + Span buffer = stackalloc byte[DnsEncodedName.MaxEncodedLength]; + OperationStatus status = DnsEncodedName.TryEncode(unicode, buffer, out DnsEncodedName name, out _); + Assert.Equal(OperationStatus.Done, status); + Assert.True(name.Equals(expectedAce)); + } + + [Theory] + [InlineData("münchen.de")] + [InlineData("例え.jp")] + [InlineData("café.test")] + public void TryEncode_IdnName_RoundTripsViaToString(string unicode) + { + Span buffer = stackalloc byte[DnsEncodedName.MaxEncodedLength]; + OperationStatus status = DnsEncodedName.TryEncode(unicode, buffer, out DnsEncodedName name, out _); + Assert.Equal(OperationStatus.Done, status); + Assert.Equal(unicode, name.ToString()); + } + + [Theory] + [InlineData("münchen.de")] + [InlineData("café.test")] + public void TryDecode_IdnName_ProducesUnicode(string unicode) + { + Span buffer = stackalloc byte[DnsEncodedName.MaxEncodedLength]; + DnsEncodedName.TryEncode(unicode, buffer, out DnsEncodedName name, out _); + + Span decoded = stackalloc char[256]; + Assert.True(name.TryDecode(decoded, out int written)); + Assert.Equal(unicode, new string(decoded[..written])); + } + + [Theory] + [InlineData("münchen.de")] + [InlineData("café.test")] + public void Equals_IdnName_MatchesUnicode(string unicode) + { + Span buffer = stackalloc byte[DnsEncodedName.MaxEncodedLength]; + DnsEncodedName.TryEncode(unicode, buffer, out DnsEncodedName name, out _); + Assert.True(name.Equals(unicode)); + } + + [Fact] + public void Equals_IdnName_CaseInsensitive() + { + Span buffer = stackalloc byte[DnsEncodedName.MaxEncodedLength]; + DnsEncodedName.TryEncode("münchen.de", buffer, out DnsEncodedName name, out _); + // ACE form comparison is case-insensitive + Assert.True(name.Equals("XN--MNCHEN-3YA.DE")); + } + + [Fact] + public void TryEncode_MixedAsciiAndIdn_Succeeds() + { + // "www" is ASCII, "münchen" is IDN, "de" is ASCII + Span buffer = stackalloc byte[DnsEncodedName.MaxEncodedLength]; + OperationStatus status = DnsEncodedName.TryEncode("www.münchen.de", buffer, out DnsEncodedName name, out _); + Assert.Equal(OperationStatus.Done, status); + Assert.Equal("www.münchen.de", name.ToString()); + Assert.True(name.Equals("www.xn--mnchen-3ya.de")); + } + + [Fact] + public void TryEncode_AceNamePassesThrough() + { + // Already-ACE input should pass through unchanged + Span buffer = stackalloc byte[DnsEncodedName.MaxEncodedLength]; + OperationStatus status = DnsEncodedName.TryEncode("xn--mnchen-3ya.de", buffer, out DnsEncodedName name, out _); + Assert.Equal(OperationStatus.Done, status); + Assert.Equal("münchen.de", name.ToString()); + } + + [Fact] + public void GetFormattedLength_IdnName_ReturnsUnicodeLength() + { + Span buffer = stackalloc byte[DnsEncodedName.MaxEncodedLength]; + DnsEncodedName.TryEncode("münchen.de", buffer, out DnsEncodedName name, out _); + // "münchen.de" is 10 chars, not "xn--mnchen-3ya.de" (18 chars) + Assert.Equal("münchen.de".Length, name.GetFormattedLength()); + } + + [Fact] + public void TryEncode_IdnWithTrailingDot_Succeeds() + { + Span buffer = stackalloc byte[DnsEncodedName.MaxEncodedLength]; + OperationStatus status = DnsEncodedName.TryEncode("münchen.de.", buffer, out DnsEncodedName name, out _); + Assert.Equal(OperationStatus.Done, status); + Assert.Equal("münchen.de", name.ToString()); + } + + [Fact] + public void Equals_InvalidUnicode_ReturnsFalse() + { + Span buffer = stackalloc byte[DnsEncodedName.MaxEncodedLength]; + DnsEncodedName.TryEncode("example.com", buffer, out DnsEncodedName name, out _); + // Lone surrogate — invalid Unicode, can't convert to ACE + Assert.False(name.Equals("\uD800.com")); + } +} diff --git a/src/libraries/System.Net.NameResolution/tests/UnitTests/DnsMessageHeaderTests.cs b/src/libraries/System.Net.NameResolution/tests/UnitTests/DnsMessageHeaderTests.cs new file mode 100644 index 00000000000000..7ed997ad6ed94d --- /dev/null +++ b/src/libraries/System.Net.NameResolution/tests/UnitTests/DnsMessageHeaderTests.cs @@ -0,0 +1,208 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net; +using Xunit; + +namespace System.Net.NameResolution.Tests; + +public class DnsMessageHeaderTests +{ + // Helper: writes header via DnsMessageWriter, returns the written bytes. + private static byte[] WriteHeader(in DnsMessageHeader header) + { + Span buffer = stackalloc byte[512]; + DnsMessageWriter writer = new(buffer); + Assert.True(writer.TryWriteHeader(in header)); + return buffer[..writer.BytesWritten].ToArray(); + } + + // Helper: writes header then reads it back via DnsMessageReader. + private static DnsMessageHeader RoundTrip(in DnsMessageHeader header) + { + byte[] bytes = WriteHeader(in header); + Assert.True(DnsMessageReader.TryCreate(bytes, out DnsMessageReader reader)); + return reader.Header; + } + + [Fact] + public void StandardQuery_SetsDefaults() + { + DnsMessageHeader header = new() { Id = 0x1234, Flags = DnsHeaderFlags.RecursionDesired, QuestionCount = 1 }; + + Assert.Equal(0x1234, header.Id); + Assert.False(header.IsResponse); + Assert.Equal(DnsOpCode.Query, header.OpCode); + Assert.Equal(DnsHeaderFlags.RecursionDesired, header.Flags); + Assert.Equal(DnsResponseCode.NoError, header.ResponseCode); + Assert.Equal(1, header.QuestionCount); + Assert.Equal(0, header.AnswerCount); + Assert.Equal(0, header.AuthorityCount); + Assert.Equal(0, header.AdditionalCount); + } + + [Fact] + public void RoundTrip_StandardQuery() + { + DnsMessageHeader original = new() { Id = 0xABCD, Flags = DnsHeaderFlags.RecursionDesired, QuestionCount = 2 }; + DnsMessageHeader parsed = RoundTrip(in original); + + Assert.Equal(original.Id, parsed.Id); + Assert.Equal(original.IsResponse, parsed.IsResponse); + Assert.Equal(original.OpCode, parsed.OpCode); + Assert.Equal(original.Flags, parsed.Flags); + Assert.Equal(original.ResponseCode, parsed.ResponseCode); + Assert.Equal(original.QuestionCount, parsed.QuestionCount); + Assert.Equal(original.AnswerCount, parsed.AnswerCount); + Assert.Equal(original.AuthorityCount, parsed.AuthorityCount); + Assert.Equal(original.AdditionalCount, parsed.AdditionalCount); + } + + [Fact] + public void RoundTrip_ResponseWithAllFlags() + { + DnsHeaderFlags flags = DnsHeaderFlags.AuthoritativeAnswer | DnsHeaderFlags.RecursionDesired + | DnsHeaderFlags.RecursionAvailable | DnsHeaderFlags.AuthenticData; + + DnsMessageHeader original = new() + { + Id = 0x5678, + IsResponse = true, + Flags = flags, + QuestionCount = 1, + AnswerCount = 3, + AuthorityCount = 1, + AdditionalCount = 2, + }; + + DnsMessageHeader parsed = RoundTrip(in original); + + Assert.True(parsed.IsResponse); + Assert.Equal(flags, parsed.Flags); + Assert.Equal(3, parsed.AnswerCount); + Assert.Equal(1, parsed.AuthorityCount); + Assert.Equal(2, parsed.AdditionalCount); + } + + [Fact] + public void RoundTrip_AllResponseCodes() + { + foreach (DnsResponseCode rcode in Enum.GetValues()) + { + DnsMessageHeader original = new() { IsResponse = true, ResponseCode = rcode }; + DnsMessageHeader parsed = RoundTrip(in original); + Assert.Equal(rcode, parsed.ResponseCode); + } + } + + [Fact] + public void RoundTrip_OpCodes() + { + foreach (DnsOpCode opcode in Enum.GetValues()) + { + DnsMessageHeader original = new() { OpCode = opcode }; + DnsMessageHeader parsed = RoundTrip(in original); + Assert.Equal(opcode, parsed.OpCode); + } + } + + [Fact] + public void TryWriteHeader_BufferTooSmall_ReturnsFalse() + { + DnsMessageHeader header = new() { Id = 1, Flags = DnsHeaderFlags.RecursionDesired, QuestionCount = 1 }; + Span buffer = stackalloc byte[11]; // one byte short + DnsMessageWriter writer = new(buffer); + Assert.False(writer.TryWriteHeader(in header)); + } + + [Fact] + public void TryCreate_BufferTooSmall_ReturnsFalse() + { + Span buffer = stackalloc byte[11]; + Assert.False(DnsMessageReader.TryCreate(buffer, out _)); + } + + [Fact] + public void WireFormat_KnownBytes() + { + // Hand-crafted standard query: ID=0x1234, RD=1, QDCOUNT=1 + // Flags word: 0x0100 (RD bit at position 8) + byte[] expected = [ + 0x12, 0x34, // ID + 0x01, 0x00, // Flags: RD=1 + 0x00, 0x01, // QDCOUNT=1 + 0x00, 0x00, // ANCOUNT=0 + 0x00, 0x00, // NSCOUNT=0 + 0x00, 0x00, // ARCOUNT=0 + ]; + + DnsMessageHeader header = new() { Id = 0x1234, Flags = DnsHeaderFlags.RecursionDesired, QuestionCount = 1 }; + byte[] written = WriteHeader(in header); + Assert.Equal(expected, written); + } + + [Fact] + public void WireFormat_ResponseWithFlags() + { + // Response: QR=1, AA=1, RD=1, RA=1, RCODE=0 + // Flags word: 1_0000_1_0_1_1_0_0_0_0000 = 0x8580 + byte[] expected = [ + 0x00, 0x01, // ID + 0x85, 0x80, // QR=1, AA=1, RD=1, RA=1 + 0x00, 0x01, // QDCOUNT=1 + 0x00, 0x02, // ANCOUNT=2 + 0x00, 0x00, // NSCOUNT=0 + 0x00, 0x00, // ARCOUNT=0 + ]; + + DnsMessageHeader header = new() + { + Id = 1, + IsResponse = true, + Flags = DnsHeaderFlags.AuthoritativeAnswer | DnsHeaderFlags.RecursionDesired + | DnsHeaderFlags.RecursionAvailable, + QuestionCount = 1, + AnswerCount = 2, + }; + + byte[] written = WriteHeader(in header); + Assert.Equal(expected, written); + } + + [Theory] + [InlineData((byte)DnsHeaderFlags.AuthoritativeAnswer)] + [InlineData((byte)DnsHeaderFlags.Truncation)] + [InlineData((byte)DnsHeaderFlags.RecursionDesired)] + [InlineData((byte)DnsHeaderFlags.RecursionAvailable)] + [InlineData((byte)DnsHeaderFlags.AuthenticData)] + [InlineData((byte)DnsHeaderFlags.CheckingDisabled)] + public void RoundTrip_EachFlagIndividually(byte flagValue) + { + DnsHeaderFlags flag = (DnsHeaderFlags)flagValue; + DnsMessageHeader original = new() { Flags = flag }; + DnsMessageHeader parsed = RoundTrip(in original); + Assert.Equal(flag, parsed.Flags); + } + + [Fact] + public void RoundTrip_AllFlagsCombined() + { + DnsHeaderFlags allFlags = DnsHeaderFlags.AuthoritativeAnswer | DnsHeaderFlags.Truncation + | DnsHeaderFlags.RecursionDesired | DnsHeaderFlags.RecursionAvailable + | DnsHeaderFlags.AuthenticData | DnsHeaderFlags.CheckingDisabled; + + DnsMessageHeader original = new() { IsResponse = true, Flags = allFlags }; + DnsMessageHeader parsed = RoundTrip(in original); + Assert.Equal(allFlags, parsed.Flags); + } + + [Fact] + public void TryWriteHeader_WritesExactly12Bytes() + { + DnsMessageHeader header = new() { Id = 1 }; + Span buffer = stackalloc byte[512]; + DnsMessageWriter writer = new(buffer); + Assert.True(writer.TryWriteHeader(in header)); + Assert.Equal(12, writer.BytesWritten); + } +} diff --git a/src/libraries/System.Net.NameResolution/tests/UnitTests/DnsMessageReaderTests.cs b/src/libraries/System.Net.NameResolution/tests/UnitTests/DnsMessageReaderTests.cs new file mode 100644 index 00000000000000..6217c69e60ddf3 --- /dev/null +++ b/src/libraries/System.Net.NameResolution/tests/UnitTests/DnsMessageReaderTests.cs @@ -0,0 +1,377 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Buffers; +using System.Diagnostics; +using System.Net; +using Xunit; + +namespace System.Net.NameResolution.Tests; + +public class DnsMessageReaderTests +{ + // A complete DNS response for "example.com" A query: + // Header: ID=0x1234, QR=1, RD=1, RA=1, QDCOUNT=1, ANCOUNT=1 + // Question: example.com IN A + // Answer: example.com A 93.184.216.34 TTL=300 + // The answer name uses a compression pointer to offset 12 (the question name) + private static readonly byte[] ExampleComAResponse = + [ + // Header (12 bytes) + 0x12, 0x34, // ID + 0x81, 0x80, // Flags: QR=1, RD=1, RA=1 + 0x00, 0x01, // QDCOUNT=1 + 0x00, 0x01, // ANCOUNT=1 + 0x00, 0x00, // NSCOUNT=0 + 0x00, 0x00, // ARCOUNT=0 + + // Question section: + // example.com IN A + 0x07, (byte)'e', (byte)'x', (byte)'a', (byte)'m', (byte)'p', (byte)'l', (byte)'e', + 0x03, (byte)'c', (byte)'o', (byte)'m', 0x00, + 0x00, 0x01, // QTYPE = A + 0x00, 0x01, // QCLASS = IN + + // Answer section: + // example.com (compression pointer to offset 12) A IN TTL=300 RDATA=93.184.216.34 + 0xC0, 0x0C, // Name: pointer to offset 12 + 0x00, 0x01, // TYPE = A + 0x00, 0x01, // CLASS = IN + 0x00, 0x00, 0x01, 0x2C, // TTL = 300 + 0x00, 0x04, // RDLENGTH = 4 + 0x5D, 0xB8, 0xD8, 0x22, // RDATA: 93.184.216.34 + ]; + + [Fact] + public void ParseHeader_CorrectFields() + { + DnsMessageReader.TryCreate(ExampleComAResponse, out var reader); + + Assert.Equal(0x1234, reader.Header.Id); + Assert.True(reader.Header.IsResponse); + Assert.Equal(DnsOpCode.Query, reader.Header.OpCode); + Assert.True(reader.Header.Flags.HasFlag(DnsHeaderFlags.RecursionDesired)); + Assert.True(reader.Header.Flags.HasFlag(DnsHeaderFlags.RecursionAvailable)); + Assert.Equal(DnsResponseCode.NoError, reader.Header.ResponseCode); + Assert.Equal(1, reader.Header.QuestionCount); + Assert.Equal(1, reader.Header.AnswerCount); + Assert.Equal(0, reader.Header.AuthorityCount); + Assert.Equal(0, reader.Header.AdditionalCount); + } + + [Fact] + public void ParseQuestion_CorrectFields() + { + DnsMessageReader.TryCreate(ExampleComAResponse, out var reader); + + Assert.True(reader.TryReadQuestion(out var question)); + Assert.True(question.Name.Equals("example.com")); + Assert.Equal(DnsRecordType.A, question.Type); + Assert.Equal(DnsRecordClass.Internet, question.Class); + } + + [Fact] + public void ParseAnswer_ARecord() + { + DnsMessageReader.TryCreate(ExampleComAResponse, out var reader); + + // Skip question + Assert.True(reader.TryReadQuestion(out _)); + + // Read answer + Assert.True(reader.TryReadRecord(out var record)); + Assert.True(record.Name.Equals("example.com")); + Assert.Equal(DnsRecordType.A, record.Type); + Assert.Equal(DnsRecordClass.Internet, record.Class); + Assert.Equal(300u, record.TimeToLive); + Assert.Equal(4, record.Data.Length); + Assert.Equal(new byte[] { 0x5D, 0xB8, 0xD8, 0x22 }, record.Data.ToArray()); + } + + [Fact] + public void ParseAnswer_NameUsesCompressionPointer() + { + DnsMessageReader.TryCreate(ExampleComAResponse, out var reader); + reader.TryReadQuestion(out _); + reader.TryReadRecord(out var record); + + // The answer name is a compression pointer to offset 12 (the question name) + Assert.True(record.Name.Equals("example.com")); + Assert.Equal("example.com", record.Name.ToString()); + } + + // Response with multiple answers: example.com CNAME + A + private static readonly byte[] CnameAndAResponse = + [ + // Header + 0x00, 0x01, // ID=1 + 0x81, 0x80, // QR=1, RD=1, RA=1 + 0x00, 0x01, // QDCOUNT=1 + 0x00, 0x02, // ANCOUNT=2 + 0x00, 0x00, // NSCOUNT=0 + 0x00, 0x00, // ARCOUNT=0 + + // Question: www.example.com A IN + 0x03, (byte)'w', (byte)'w', (byte)'w', + 0x07, (byte)'e', (byte)'x', (byte)'a', (byte)'m', (byte)'p', (byte)'l', (byte)'e', + 0x03, (byte)'c', (byte)'o', (byte)'m', 0x00, + 0x00, 0x01, // QTYPE = A + 0x00, 0x01, // QCLASS = IN + + // Answer 1: www.example.com CNAME example.com + 0xC0, 0x0C, // pointer to offset 12 (www.example.com) + 0x00, 0x05, // TYPE = CNAME + 0x00, 0x01, // CLASS = IN + 0x00, 0x00, 0x00, 0x3C, // TTL = 60 + 0x00, 0x02, // RDLENGTH = 2 + 0xC0, 0x10, // RDATA: pointer to offset 16 (example.com) + + // Answer 2: example.com A 93.184.216.34 + 0xC0, 0x10, // pointer to offset 16 (example.com) + 0x00, 0x01, // TYPE = A + 0x00, 0x01, // CLASS = IN + 0x00, 0x00, 0x01, 0x2C, // TTL = 300 + 0x00, 0x04, // RDLENGTH = 4 + 0x5D, 0xB8, 0xD8, 0x22, // 93.184.216.34 + ]; + + [Fact] + public void ParseMultipleAnswers_CnameAndA() + { + DnsMessageReader.TryCreate(CnameAndAResponse, out var reader); + + // Skip question + Assert.True(reader.TryReadQuestion(out var q)); + Assert.True(q.Name.Equals("www.example.com")); + + // CNAME answer + Assert.True(reader.TryReadRecord(out var cname)); + Assert.Equal(DnsRecordType.CNAME, cname.Type); + Assert.Equal(60u, cname.TimeToLive); + Assert.True(cname.Name.Equals("www.example.com")); + + // The CNAME RDATA contains a compression pointer to "example.com" + DnsEncodedName.TryParse(cname.Message, cname.DataOffset, out DnsEncodedName cnameTarget, out _); + Assert.True(cnameTarget.Equals("example.com")); + + // A answer + Assert.True(reader.TryReadRecord(out var a)); + Assert.Equal(DnsRecordType.A, a.Type); + Assert.True(a.Name.Equals("example.com")); + Assert.Equal(300u, a.TimeToLive); + } + + // NXDOMAIN response + private static readonly byte[] NxdomainResponse = + [ + // Header: QR=1, RD=1, RA=1, RCODE=3 (NXDOMAIN) + 0x00, 0x02, // ID=2 + 0x81, 0x83, // QR=1, RD=1, RA=1, RCODE=3 + 0x00, 0x01, // QDCOUNT=1 + 0x00, 0x00, // ANCOUNT=0 + 0x00, 0x00, // NSCOUNT=0 + 0x00, 0x00, // ARCOUNT=0 + + // Question: nonexistent.example.com A IN + 0x0B, (byte)'n', (byte)'o', (byte)'n', (byte)'e', (byte)'x', (byte)'i', + (byte)'s', (byte)'t', (byte)'e', (byte)'n', (byte)'t', + 0x07, (byte)'e', (byte)'x', (byte)'a', (byte)'m', (byte)'p', (byte)'l', (byte)'e', + 0x03, (byte)'c', (byte)'o', (byte)'m', 0x00, + 0x00, 0x01, // QTYPE = A + 0x00, 0x01, // QCLASS = IN + ]; + + [Fact] + public void ParseNxdomain_ResponseCode() + { + DnsMessageReader.TryCreate(NxdomainResponse, out var reader); + + Assert.Equal(DnsResponseCode.NxDomain, reader.Header.ResponseCode); + Assert.Equal(0, reader.Header.AnswerCount); + + Assert.True(reader.TryReadQuestion(out var q)); + Assert.True(q.Name.Equals("nonexistent.example.com")); + + // No records to read + Assert.False(reader.TryReadRecord(out _)); + } + + [Fact] + public void TryCreate_TooSmallBuffer_ReturnsFalse() + { + Assert.False(DnsMessageReader.TryCreate(new byte[11], out _)); + } + + [Fact] + public void TryReadRecord_TruncatedRdata_ReturnsFalse() + { + // Take the valid response and truncate the RDATA + byte[] truncated = ExampleComAResponse[..^2]; // cut off last 2 bytes of RDATA + DnsMessageReader.TryCreate(truncated, out var reader); + reader.TryReadQuestion(out _); + Assert.False(reader.TryReadRecord(out _)); + } + + [Fact] + public void TryReadQuestion_MalformedLabelLength_ReturnsFalse() + { + // Craft a message where the question name has a label length extending past buffer + byte[] malformed = + [ + 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // header + 0xFF, // label length = 255, but no data follows + ]; + DnsMessageReader.TryCreate(malformed, out var reader); + Assert.False(reader.TryReadQuestion(out _)); + } + + [Fact] + public void TryReadRecord_InvalidCompressionPointer_ReturnsFalse() + { + // Craft a message where a record name has a compression pointer to an out-of-bounds offset + byte[] malformed = + [ + 0x00, 0x01, 0x81, 0x80, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, // header: ANCOUNT=1 + 0xC0, 0xFF, // compression pointer to offset 255, way beyond buffer + 0x00, 0x01, // TYPE=A + 0x00, 0x01, // CLASS=IN + 0x00, 0x00, 0x00, 0x3C, // TTL=60 + 0x00, 0x04, // RDLENGTH=4 + 0x01, 0x02, 0x03, 0x04, // RDATA + ]; + DnsMessageReader.TryCreate(malformed, out var reader); + // The record name has an invalid pointer, so TryReadRecord fails + Assert.False(reader.TryReadRecord(out _)); + } + + [Fact] + public void RoundTrip_WriteThenRead() + { + // Build a query with the writer, then parse it with the reader + Span buffer = stackalloc byte[512]; + DnsMessageWriter writer = new(buffer); + + DnsMessageHeader header = new() { Id = 0xBEEF, Flags = DnsHeaderFlags.RecursionDesired, QuestionCount = 2 }; + writer.TryWriteHeader(in header); + + Span nameBuf = stackalloc byte[DnsEncodedName.MaxEncodedLength]; + DnsEncodedName.TryEncode("example.com", nameBuf, out var name1, out _); + writer.TryWriteQuestion(name1, DnsRecordType.A); + + DnsEncodedName.TryEncode("example.org", nameBuf, out var name2, out _); + writer.TryWriteQuestion(name2, DnsRecordType.AAAA); + + // Now parse + DnsMessageReader.TryCreate(buffer[..writer.BytesWritten], out var reader); + Assert.Equal(0xBEEF, reader.Header.Id); + Assert.False(reader.Header.IsResponse); + Assert.Equal(2, reader.Header.QuestionCount); + + Assert.True(reader.TryReadQuestion(out var q1)); + Assert.True(q1.Name.Equals("example.com")); + Assert.Equal(DnsRecordType.A, q1.Type); + + Assert.True(reader.TryReadQuestion(out var q2)); + Assert.True(q2.Name.Equals("example.org")); + Assert.Equal(DnsRecordType.AAAA, q2.Type); + } + + [Fact] + public void CompressionPointerLoop_DoesNotHang() + { + // Message with a compression pointer that loops back, creating a cycle. + // The reader must terminate rather than loop indefinitely. + byte[] data = [0x12, 0x34, 0x81, 0x80, 0x3f, 0x0, 0x1, 0x1, 0x1, 0x0, 0x0, 0x0, + 0x0, 0x63, 0x6f, 0x2b, 0x0, 0x1, 0x0, 0x1, 0xc, 0xc0, 0x0, 0x0, + 0x0, 0x1, 0x2c, 0x0, 0x4, 0xa, 0x0, 0x0, 0x91, 0x1]; + + Stopwatch sw = Stopwatch.StartNew(); + DnsMessageReader.TryCreate(data, out DnsMessageReader reader); + for (int i = 0; i < reader.Header.QuestionCount && i < 32; i++) + { + if (!reader.TryReadQuestion(out DnsQuestion q)) + { + break; + } + q.Name.ToString(); + q.Name.Equals("example.com"); + } + int total = reader.Header.AnswerCount + reader.Header.AuthorityCount + reader.Header.AdditionalCount; + for (int i = 0; i < total && i < 64; i++) + { + if (!reader.TryReadRecord(out DnsRecord r)) + { + break; + } + r.Name.ToString(); + r.TryParseSoaRecord(out _); + } + sw.Stop(); + Assert.True(sw.ElapsedMilliseconds < 1000, $"Took {sw.ElapsedMilliseconds}ms"); + } + + [Fact] + public void TryCreate_ExactHeaderSize_Succeeds() + { + // A 12-byte buffer is the minimum valid header + byte[] data = [0x00, 0x01, 0x81, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]; + Assert.True(DnsMessageReader.TryCreate(data, out DnsMessageReader reader)); + Assert.Equal(0x0001, reader.Header.Id); + Assert.True(reader.Header.IsResponse); + } + + [Fact] + public void TryReadQuestion_TruncatedTypeClass_ReturnsFalse() + { + // Header says QDCOUNT=1, valid name follows, but TYPE/CLASS bytes are missing + byte[] data = + [ + 0x00, 0x01, 0x81, 0x80, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // header + 0x01, (byte)'a', 0x00, // name "a" (3 bytes), no TYPE/CLASS + ]; + DnsMessageReader.TryCreate(data, out DnsMessageReader reader); + Assert.False(reader.TryReadQuestion(out _)); + } + + [Fact] + public void TryReadRecord_TruncatedFixedFields_ReturnsFalse() + { + // Header says ANCOUNT=1, valid name but TYPE/CLASS/TTL/RDLENGTH truncated + byte[] data = + [ + 0x00, 0x01, 0x81, 0x80, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, // header + 0x01, (byte)'a', 0x00, // name "a" (3 bytes) + 0x00, 0x01, // TYPE=A, but missing CLASS/TTL/RDLENGTH + ]; + DnsMessageReader.TryCreate(data, out DnsMessageReader reader); + Assert.False(reader.TryReadRecord(out _)); + } + + [Fact] + public void TryReadQuestion_AtEndOfBuffer_ReturnsFalse() + { + // Header says QDCOUNT=1, but no data follows after the 12-byte header + byte[] data = [0x00, 0x01, 0x81, 0x80, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]; + DnsMessageReader.TryCreate(data, out DnsMessageReader reader); + Assert.False(reader.TryReadQuestion(out _)); + } + + [Fact] + public void TryReadRecord_AtEndOfBuffer_ReturnsFalse() + { + // Header says ANCOUNT=1, but no data follows after the 12-byte header + byte[] data = [0x00, 0x01, 0x81, 0x80, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00]; + DnsMessageReader.TryCreate(data, out DnsMessageReader reader); + Assert.False(reader.TryReadRecord(out _)); + } + + [Fact] + public void TryReadRecord_NoMoreRecords_ReturnsFalse() + { + // Parse a valid response, read the single answer, then try to read another + DnsMessageReader.TryCreate(ExampleComAResponse, out DnsMessageReader reader); + reader.TryReadQuestion(out _); + Assert.True(reader.TryReadRecord(out _)); + Assert.False(reader.TryReadRecord(out _)); // no more records + } +} diff --git a/src/libraries/System.Net.NameResolution/tests/UnitTests/DnsMessageWriterTests.cs b/src/libraries/System.Net.NameResolution/tests/UnitTests/DnsMessageWriterTests.cs new file mode 100644 index 00000000000000..4f768ac11588c5 --- /dev/null +++ b/src/libraries/System.Net.NameResolution/tests/UnitTests/DnsMessageWriterTests.cs @@ -0,0 +1,122 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Buffers; +using System.Net; +using Xunit; + +namespace System.Net.NameResolution.Tests; + +public class DnsMessageWriterTests +{ + [Fact] + public void WriteStandardAQuery_ProducesExpectedBytes() + { + // Expected wire format for: query example.com A IN, ID=0x1234, RD=1 + byte[] expected = + [ + // Header (12 bytes) + 0x12, 0x34, // ID + 0x01, 0x00, // Flags: RD=1 + 0x00, 0x01, // QDCOUNT=1 + 0x00, 0x00, // ANCOUNT=0 + 0x00, 0x00, // NSCOUNT=0 + 0x00, 0x00, // ARCOUNT=0 + // Question: example.com A IN + 0x07, (byte)'e', (byte)'x', (byte)'a', (byte)'m', (byte)'p', (byte)'l', (byte)'e', + 0x03, (byte)'c', (byte)'o', (byte)'m', 0x00, + 0x00, 0x01, // QTYPE = A (1) + 0x00, 0x01, // QCLASS = IN (1) + ]; + + Span buffer = stackalloc byte[512]; + DnsMessageWriter writer = new(buffer); + + DnsMessageHeader header = new() { Id = 0x1234, Flags = DnsHeaderFlags.RecursionDesired, QuestionCount = 1 }; + Assert.True(writer.TryWriteHeader(in header)); + + Span nameBuffer = stackalloc byte[DnsEncodedName.MaxEncodedLength]; + Assert.Equal(OperationStatus.Done, + DnsEncodedName.TryEncode("example.com", nameBuffer, out var name, out _)); + Assert.True(writer.TryWriteQuestion(name, DnsRecordType.A)); + + Assert.Equal(expected.Length, writer.BytesWritten); + Assert.True(buffer[..writer.BytesWritten].SequenceEqual(expected)); + } + + [Fact] + public void WriteMultipleQuestions_ProducesCorrectOutput() + { + Span buffer = stackalloc byte[512]; + DnsMessageWriter writer = new(buffer); + + DnsMessageHeader header = new() { Id = 1, Flags = DnsHeaderFlags.RecursionDesired, QuestionCount = 2 }; + Assert.True(writer.TryWriteHeader(in header)); + + Span nameBuffer = stackalloc byte[DnsEncodedName.MaxEncodedLength]; + + DnsEncodedName.TryEncode("a.com", nameBuffer, out var name1, out _); + Assert.True(writer.TryWriteQuestion(name1, DnsRecordType.A)); + + DnsEncodedName.TryEncode("b.com", nameBuffer, out var name2, out _); + Assert.True(writer.TryWriteQuestion(name2, DnsRecordType.AAAA)); + + // Header(12) + Q1(1+1+3+1+3+1+4) + Q2 (same) = 12 + 11 + 11 = 34 + // name "a.com" = \x01a\x03com\x00 = 7 bytes, + 4 type/class = 11 + Assert.Equal(12 + 11 + 11, writer.BytesWritten); + } + + [Fact] + public void BufferTooSmall_ForHeader_ReturnsFalse() + { + Span buffer = stackalloc byte[11]; // 1 short + DnsMessageWriter writer = new(buffer); + DnsMessageHeader header = new() { Id = 1, Flags = DnsHeaderFlags.RecursionDesired, QuestionCount = 1 }; + Assert.False(writer.TryWriteHeader(in header)); + Assert.Equal(0, writer.BytesWritten); + } + + [Fact] + public void WriteQuestion_WithCompressedName_ExpandsPointers() + { + // Simulate a DnsEncodedName parsed from a response with a compression pointer + byte[] message = + [ + 7, (byte)'e', (byte)'x', (byte)'a', (byte)'m', (byte)'p', (byte)'l', (byte)'e', + 3, (byte)'c', (byte)'o', (byte)'m', 0, + 3, (byte)'w', (byte)'w', (byte)'w', 0xC0, 0x00 // www + pointer to example.com + ]; + Assert.True(DnsEncodedName.TryParse(message, 13, out DnsEncodedName compressedName, out _)); + + Span buffer = stackalloc byte[512]; + DnsMessageWriter writer = new(buffer); + DnsMessageHeader header = new() { Id = 1, Flags = DnsHeaderFlags.RecursionDesired, QuestionCount = 1 }; + Assert.True(writer.TryWriteHeader(in header)); + Assert.True(writer.TryWriteQuestion(compressedName, DnsRecordType.A)); + + // Parse the written message and verify the name was expanded + DnsMessageReader.TryCreate(buffer[..writer.BytesWritten], out var reader); + Assert.True(reader.TryReadQuestion(out var q)); + Assert.True(q.Name.Equals("www.example.com")); + + // Verify no compression pointers in the output (flat encoding) + // The name should be: \x03www\x07example\x03com\x00 = 17 bytes + // Total: 12 header + 17 name + 4 type/class = 33 + Assert.Equal(33, writer.BytesWritten); + } + + [Fact] + public void BufferTooSmall_ForQuestion_ReturnsFalse() + { + Span buffer = stackalloc byte[14]; // header fits (12), question needs more + DnsMessageWriter writer = new(buffer); + + DnsMessageHeader header = new() { Id = 1, Flags = DnsHeaderFlags.RecursionDesired, QuestionCount = 1 }; + Assert.True(writer.TryWriteHeader(in header)); + + Span nameBuffer = stackalloc byte[DnsEncodedName.MaxEncodedLength]; + DnsEncodedName.TryEncode("example.com", nameBuffer, out var name, out _); + Assert.False(writer.TryWriteQuestion(name, DnsRecordType.A)); + Assert.Equal(12, writer.BytesWritten); // only header was written + } +} diff --git a/src/libraries/System.Net.NameResolution/tests/UnitTests/DnsRecordTypeTests.cs b/src/libraries/System.Net.NameResolution/tests/UnitTests/DnsRecordTypeTests.cs new file mode 100644 index 00000000000000..3cb262874b4f1b --- /dev/null +++ b/src/libraries/System.Net.NameResolution/tests/UnitTests/DnsRecordTypeTests.cs @@ -0,0 +1,412 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Buffers.Binary; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Text; +using Xunit; + +namespace System.Net.NameResolution.Tests; + +public class DnsRecordTypeTests +{ + // Helper: builds a minimal DNS response with a single answer record. + // The question is "q.test" and the answer name uses a pointer to it. + private static byte[] BuildResponse(DnsRecordType type, byte[] rdata, uint ttl = 300) + { + // Question name: q.test = \x01q\x04test\x00 (8 bytes) + byte[] questionName = [0x01, (byte)'q', 0x04, (byte)'t', (byte)'e', (byte)'s', (byte)'t', 0x00]; + + using MemoryStream ms = new(); + BinaryWriter bw = new(ms); + + // Header (12 bytes) + bw.Write((byte)0x00); bw.Write((byte)0x01); // ID=1 + bw.Write((byte)0x81); bw.Write((byte)0x80); // QR=1, RD=1, RA=1 + bw.Write((byte)0x00); bw.Write((byte)0x01); // QDCOUNT=1 + bw.Write((byte)0x00); bw.Write((byte)0x01); // ANCOUNT=1 + bw.Write((byte)0x00); bw.Write((byte)0x00); // NSCOUNT=0 + bw.Write((byte)0x00); bw.Write((byte)0x00); // ARCOUNT=0 + + // Question section + bw.Write(questionName); + bw.Write(BinaryPrimitives.ReverseEndianness((ushort)type)); + bw.Write(BinaryPrimitives.ReverseEndianness((ushort)1)); // CLASS=IN + + // Answer: pointer to offset 12 (question name) + bw.Write((byte)0xC0); bw.Write((byte)0x0C); + bw.Write(BinaryPrimitives.ReverseEndianness((ushort)type)); + bw.Write(BinaryPrimitives.ReverseEndianness((ushort)1)); // CLASS=IN + bw.Write(BinaryPrimitives.ReverseEndianness(ttl)); + bw.Write(BinaryPrimitives.ReverseEndianness((ushort)rdata.Length)); + bw.Write(rdata); + + return ms.ToArray(); + } + + private static DnsRecord GetAnswerRecord(byte[] response) + { + DnsMessageReader.TryCreate(response, out var reader); + reader.TryReadQuestion(out _); + reader.TryReadRecord(out var record); + return record; + } + + [Fact] + public void ARecord_ParsesCorrectly() + { + byte[] rdata = [192, 168, 1, 1]; + DnsRecord record = GetAnswerRecord(BuildResponse(DnsRecordType.A, rdata)); + + Assert.True(record.TryParseARecord(out var a)); + Assert.Equal(rdata, a.AddressBytes.ToArray()); + + IPAddress ip = a.ToIPAddress(); + Assert.Equal("192.168.1.1", ip.ToString()); + } + + [Fact] + public void AAAARecord_ParsesCorrectly() + { + // ::1 in 16 bytes + byte[] rdata = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1]; + DnsRecord record = GetAnswerRecord(BuildResponse(DnsRecordType.AAAA, rdata)); + + Assert.True(record.TryParseAAAARecord(out var aaaa)); + Assert.Equal(rdata, aaaa.AddressBytes.ToArray()); + Assert.Equal("::1", aaaa.ToIPAddress().ToString()); + } + + [Fact] + public void CNameRecord_ParsesCorrectly() + { + // RDATA: target.test = \x06target\x04test\x00 + byte[] rdata = [0x06, (byte)'t', (byte)'a', (byte)'r', (byte)'g', (byte)'e', (byte)'t', + 0x04, (byte)'t', (byte)'e', (byte)'s', (byte)'t', 0x00]; + DnsRecord record = GetAnswerRecord(BuildResponse(DnsRecordType.CNAME, rdata)); + + Assert.True(record.TryParseCNameRecord(out var cname)); + Assert.True(cname.CName.Equals("target.test")); + } + + [Fact] + public void MxRecord_ParsesCorrectly() + { + // RDATA: preference=10, exchange=mail.test + byte[] rdata = [0x00, 0x0A, // preference=10 + 0x04, (byte)'m', (byte)'a', (byte)'i', (byte)'l', + 0x04, (byte)'t', (byte)'e', (byte)'s', (byte)'t', 0x00]; + DnsRecord record = GetAnswerRecord(BuildResponse(DnsRecordType.MX, rdata)); + + Assert.True(record.TryParseMxRecord(out var mx)); + Assert.Equal(10, mx.Preference); + Assert.True(mx.Exchange.Equals("mail.test")); + } + + [Fact] + public void SrvRecord_ParsesCorrectly() + { + // RDATA: priority=10, weight=20, port=8080, target=srv.test + byte[] rdata = [0x00, 0x0A, // priority=10 + 0x00, 0x14, // weight=20 + 0x1F, 0x90, // port=8080 + 0x03, (byte)'s', (byte)'r', (byte)'v', + 0x04, (byte)'t', (byte)'e', (byte)'s', (byte)'t', 0x00]; + DnsRecord record = GetAnswerRecord(BuildResponse(DnsRecordType.SRV, rdata)); + + Assert.True(record.TryParseSrvRecord(out var srv)); + Assert.Equal(10, srv.Priority); + Assert.Equal(20, srv.Weight); + Assert.Equal(8080, srv.Port); + Assert.True(srv.Target.Equals("srv.test")); + } + + [Fact] + public void TxtRecord_SingleString() + { + byte[] rdata = [0x05, (byte)'h', (byte)'e', (byte)'l', (byte)'l', (byte)'o']; + DnsRecord record = GetAnswerRecord(BuildResponse(DnsRecordType.TXT, rdata)); + + Assert.True(record.TryParseTxtRecord(out var txt)); + + List strings = new(); + foreach (ReadOnlySpan s in txt.EnumerateStrings()) + strings.Add(Encoding.ASCII.GetString(s)); + + Assert.Equal(["hello"], strings); + } + + [Fact] + public void TxtRecord_MultipleStrings() + { + byte[] rdata = [0x03, (byte)'a', (byte)'b', (byte)'c', + 0x02, (byte)'d', (byte)'e']; + DnsRecord record = GetAnswerRecord(BuildResponse(DnsRecordType.TXT, rdata)); + + Assert.True(record.TryParseTxtRecord(out var txt)); + + List strings = new(); + foreach (ReadOnlySpan s in txt.EnumerateStrings()) + strings.Add(Encoding.ASCII.GetString(s)); + + Assert.Equal(["abc", "de"], strings); + } + + [Fact] + public void PtrRecord_ParsesCorrectly() + { + // RDATA: host.test + byte[] rdata = [0x04, (byte)'h', (byte)'o', (byte)'s', (byte)'t', + 0x04, (byte)'t', (byte)'e', (byte)'s', (byte)'t', 0x00]; + DnsRecord record = GetAnswerRecord(BuildResponse(DnsRecordType.PTR, rdata)); + + Assert.True(record.TryParsePtrRecord(out var ptr)); + Assert.True(ptr.Name.Equals("host.test")); + } + + [Fact] + public void NsRecord_ParsesCorrectly() + { + // RDATA: ns1.test + byte[] rdata = [0x03, (byte)'n', (byte)'s', (byte)'1', + 0x04, (byte)'t', (byte)'e', (byte)'s', (byte)'t', 0x00]; + DnsRecord record = GetAnswerRecord(BuildResponse(DnsRecordType.NS, rdata)); + + Assert.True(record.TryParseNsRecord(out var ns)); + Assert.True(ns.Name.Equals("ns1.test")); + } + + [Fact] + public void SoaRecord_ParsesCorrectly() + { + // RDATA: mname=ns.test, rname=admin.test, serial=2024010101, refresh=3600, retry=900, expire=604800, minimum=86400 + byte[] mname = [0x02, (byte)'n', (byte)'s', 0x04, (byte)'t', (byte)'e', (byte)'s', (byte)'t', 0x00]; + byte[] rname = [0x05, (byte)'a', (byte)'d', (byte)'m', (byte)'i', (byte)'n', 0x04, (byte)'t', (byte)'e', (byte)'s', (byte)'t', 0x00]; + byte[] fixedFields = new byte[20]; + BinaryPrimitives.WriteUInt32BigEndian(fixedFields.AsSpan(0), 2024010101); + BinaryPrimitives.WriteUInt32BigEndian(fixedFields.AsSpan(4), 3600); + BinaryPrimitives.WriteUInt32BigEndian(fixedFields.AsSpan(8), 900); + BinaryPrimitives.WriteUInt32BigEndian(fixedFields.AsSpan(12), 604800); + BinaryPrimitives.WriteUInt32BigEndian(fixedFields.AsSpan(16), 86400); + + byte[] rdata = [.. mname, .. rname, .. fixedFields]; + DnsRecord record = GetAnswerRecord(BuildResponse(DnsRecordType.SOA, rdata)); + + Assert.True(record.TryParseSoaRecord(out var soa)); + Assert.True(soa.PrimaryNameServer.Equals("ns.test")); + Assert.True(soa.ResponsibleMailbox.Equals("admin.test")); + Assert.Equal(2024010101u, soa.SerialNumber); + Assert.Equal(3600u, soa.RefreshInterval); + Assert.Equal(900u, soa.RetryInterval); + Assert.Equal(604800u, soa.ExpireLimit); + Assert.Equal(86400u, soa.MinimumTtl); + } + + [Theory] + [InlineData((ushort)DnsRecordType.A)] + [InlineData((ushort)DnsRecordType.AAAA)] + [InlineData((ushort)DnsRecordType.CNAME)] + [InlineData((ushort)DnsRecordType.MX)] + [InlineData((ushort)DnsRecordType.SRV)] + [InlineData((ushort)DnsRecordType.TXT)] + [InlineData((ushort)DnsRecordType.PTR)] + [InlineData((ushort)DnsRecordType.NS)] + public void TypeMismatch_ReturnsFalse(ushort actualTypeValue) + { + DnsRecordType actualType = (DnsRecordType)actualTypeValue; + + // Use a valid A record, but try to parse as every other type + byte[] rdata = [192, 168, 1, 1]; + DnsRecord record = GetAnswerRecord(BuildResponse(actualType, rdata)); + + // Try parsing as each type — only the matching one should succeed + if (actualType != DnsRecordType.A) Assert.False(record.TryParseARecord(out _)); + if (actualType != DnsRecordType.AAAA) Assert.False(record.TryParseAAAARecord(out _)); + if (actualType != DnsRecordType.CNAME) Assert.False(record.TryParseCNameRecord(out _)); + if (actualType != DnsRecordType.MX) Assert.False(record.TryParseMxRecord(out _)); + if (actualType != DnsRecordType.SRV) Assert.False(record.TryParseSrvRecord(out _)); + if (actualType != DnsRecordType.TXT) Assert.False(record.TryParseTxtRecord(out _)); + if (actualType != DnsRecordType.PTR) Assert.False(record.TryParsePtrRecord(out _)); + if (actualType != DnsRecordType.NS) Assert.False(record.TryParseNsRecord(out _)); + } + + [Fact] + public void CNameRecord_WithCompressionPointer() + { + // Build a response where the CNAME RDATA uses a compression pointer + // back to the question name ("q.test") + byte[] rdata = [0xC0, 0x0C]; // pointer to offset 12 = question name + DnsRecord record = GetAnswerRecord(BuildResponse(DnsRecordType.CNAME, rdata)); + + Assert.True(record.TryParseCNameRecord(out var cname)); + Assert.True(cname.CName.Equals("q.test")); + } + + // --- Malformed RDATA edge cases --- + + [Fact] + public void ARecord_WrongLength_ReturnsFalse() + { + // A record requires exactly 4 bytes of RDATA + byte[] rdata = [192, 168, 1]; // only 3 bytes + DnsRecord record = GetAnswerRecord(BuildResponse(DnsRecordType.A, rdata)); + Assert.False(record.TryParseARecord(out _)); + } + + [Fact] + public void AAAARecord_WrongLength_ReturnsFalse() + { + // AAAA record requires exactly 16 bytes of RDATA + byte[] rdata = new byte[15]; // only 15 bytes + DnsRecord record = GetAnswerRecord(BuildResponse(DnsRecordType.AAAA, rdata)); + Assert.False(record.TryParseAAAARecord(out _)); + } + + [Fact] + public void CNameRecord_EmptyRdata_ReturnsFalse() + { + DnsRecord record = GetAnswerRecord(BuildResponse(DnsRecordType.CNAME, [])); + Assert.False(record.TryParseCNameRecord(out _)); + } + + [Fact] + public void CNameRecord_MalformedName_ReturnsFalse() + { + // RDATA with invalid label length (0x50 = 80, exceeds max 63) + byte[] rdata = [0x50, (byte)'a']; + DnsRecord record = GetAnswerRecord(BuildResponse(DnsRecordType.CNAME, rdata)); + Assert.False(record.TryParseCNameRecord(out _)); + } + + [Fact] + public void MxRecord_TooShortRdata_ReturnsFalse() + { + // MX requires at least 3 bytes (2 for preference + 1 for name) + byte[] rdata = [0x00, 0x0A]; // only 2 bytes, no exchange name + DnsRecord record = GetAnswerRecord(BuildResponse(DnsRecordType.MX, rdata)); + Assert.False(record.TryParseMxRecord(out _)); + } + + [Fact] + public void MxRecord_MalformedExchangeName_ReturnsFalse() + { + // Preference + malformed name (label length exceeds remaining) + byte[] rdata = [0x00, 0x0A, 0x50, (byte)'a']; + DnsRecord record = GetAnswerRecord(BuildResponse(DnsRecordType.MX, rdata)); + Assert.False(record.TryParseMxRecord(out _)); + } + + [Fact] + public void SrvRecord_TooShortRdata_ReturnsFalse() + { + // SRV requires at least 7 bytes (priority+weight+port = 6, + 1 for target name) + byte[] rdata = [0x00, 0x0A, 0x00, 0x14, 0x1F, 0x90]; // only 6 bytes, no target + DnsRecord record = GetAnswerRecord(BuildResponse(DnsRecordType.SRV, rdata)); + Assert.False(record.TryParseSrvRecord(out _)); + } + + [Fact] + public void SrvRecord_MalformedTargetName_ReturnsFalse() + { + // Valid fixed fields but target name label extends past RDATA + byte[] rdata = [0x00, 0x0A, 0x00, 0x14, 0x1F, 0x90, 0x50, (byte)'a']; + DnsRecord record = GetAnswerRecord(BuildResponse(DnsRecordType.SRV, rdata)); + Assert.False(record.TryParseSrvRecord(out _)); + } + + [Fact] + public void SoaRecord_TooShortRdata_ReturnsFalse() + { + // SOA requires at least 22 bytes + byte[] rdata = new byte[21]; + DnsRecord record = GetAnswerRecord(BuildResponse(DnsRecordType.SOA, rdata)); + Assert.False(record.TryParseSoaRecord(out _)); + } + + [Fact] + public void SoaRecord_MalformedMname_ReturnsFalse() + { + // SOA RDATA with invalid mname label (0x50 = 80, > 63 max) + byte[] rdata = new byte[30]; + rdata[0] = 0x50; // invalid label length in mname + DnsRecord record = GetAnswerRecord(BuildResponse(DnsRecordType.SOA, rdata)); + Assert.False(record.TryParseSoaRecord(out _)); + } + + [Fact] + public void SoaRecord_MalformedRname_ReturnsFalse() + { + // Valid mname but malformed rname + byte[] mname = [0x02, (byte)'n', (byte)'s', 0x00]; // ns. + byte[] rdata = new byte[mname.Length + 30]; + mname.CopyTo(rdata, 0); + rdata[mname.Length] = 0x50; // invalid label length in rname + DnsRecord record = GetAnswerRecord(BuildResponse(DnsRecordType.SOA, rdata)); + Assert.False(record.TryParseSoaRecord(out _)); + } + + [Fact] + public void SoaRecord_TruncatedFixedFields_ReturnsFalse() + { + // Valid mname and rname but not enough room for the 20 bytes of fixed fields + byte[] mname = [0x02, (byte)'n', (byte)'s', 0x00]; + byte[] rname = [0x05, (byte)'a', (byte)'d', (byte)'m', (byte)'i', (byte)'n', 0x00]; + byte[] rdata = new byte[mname.Length + rname.Length + 10]; // only 10 bytes for fixed fields + mname.CopyTo(rdata, 0); + rname.CopyTo(rdata, mname.Length); + DnsRecord record = GetAnswerRecord(BuildResponse(DnsRecordType.SOA, rdata)); + Assert.False(record.TryParseSoaRecord(out _)); + } + + [Fact] + public void TxtRecord_EmptyRdata_ReturnsFalse() + { + DnsRecord record = GetAnswerRecord(BuildResponse(DnsRecordType.TXT, [])); + Assert.False(record.TryParseTxtRecord(out _)); + } + + [Fact] + public void TxtRecord_TruncatedString_StopsEnumerating() + { + // String length byte says 10 but only 3 bytes remain + byte[] rdata = [0x0A, (byte)'a', (byte)'b', (byte)'c']; + DnsRecord record = GetAnswerRecord(BuildResponse(DnsRecordType.TXT, rdata)); + Assert.True(record.TryParseTxtRecord(out var txt)); + + // Enumerator should return false (truncated string) + DnsTxtEnumerator enumerator = txt.EnumerateStrings(); + Assert.False(enumerator.MoveNext()); + } + + [Fact] + public void PtrRecord_EmptyRdata_ReturnsFalse() + { + DnsRecord record = GetAnswerRecord(BuildResponse(DnsRecordType.PTR, [])); + Assert.False(record.TryParsePtrRecord(out _)); + } + + [Fact] + public void PtrRecord_MalformedName_ReturnsFalse() + { + byte[] rdata = [0x50, (byte)'a']; // invalid label length + DnsRecord record = GetAnswerRecord(BuildResponse(DnsRecordType.PTR, rdata)); + Assert.False(record.TryParsePtrRecord(out _)); + } + + [Fact] + public void NsRecord_EmptyRdata_ReturnsFalse() + { + DnsRecord record = GetAnswerRecord(BuildResponse(DnsRecordType.NS, [])); + Assert.False(record.TryParseNsRecord(out _)); + } + + [Fact] + public void NsRecord_MalformedName_ReturnsFalse() + { + byte[] rdata = [0x50, (byte)'a']; // invalid label length + DnsRecord record = GetAnswerRecord(BuildResponse(DnsRecordType.NS, rdata)); + Assert.False(record.TryParseNsRecord(out _)); + } +} diff --git a/src/libraries/System.Net.NameResolution/tests/UnitTests/System.Net.NameResolution.Unit.Tests.csproj b/src/libraries/System.Net.NameResolution/tests/UnitTests/System.Net.NameResolution.Unit.Tests.csproj new file mode 100644 index 00000000000000..dd9f3a8b394583 --- /dev/null +++ b/src/libraries/System.Net.NameResolution/tests/UnitTests/System.Net.NameResolution.Unit.Tests.csproj @@ -0,0 +1,36 @@ + + + true + $(NetCoreAppCurrent) + + $(NoWarn);CS3021 + + + + + + + + + + + + + + + + + + + + + + + From ea3d503e9e64f09d79df6fece77fd67fcd7a3d53 Mon Sep 17 00:00:00 2001 From: Radek Zikmund Date: Thu, 9 Jul 2026 14:31:58 +0200 Subject: [PATCH 03/12] Feedback --- .../src/System/Net/DnsEncodedName.cs | 39 +++--- .../src/System/Net/DnsResolverPal.Managed.cs | 112 ++++++------------ 2 files changed, 53 insertions(+), 98 deletions(-) diff --git a/src/libraries/System.Net.NameResolution/src/System/Net/DnsEncodedName.cs b/src/libraries/System.Net.NameResolution/src/System/Net/DnsEncodedName.cs index 8e0efbb8b25bdf..a5b5764717ad1c 100644 --- a/src/libraries/System.Net.NameResolution/src/System/Net/DnsEncodedName.cs +++ b/src/libraries/System.Net.NameResolution/src/System/Net/DnsEncodedName.cs @@ -116,7 +116,7 @@ public static OperationStatus TryEncode( } // Strip trailing dot if present (FQDN notation). - if (name[^1] == '.') + if (name.EndsWith('.')) { name = name[..^1]; } @@ -152,7 +152,7 @@ public static OperationStatus TryEncode( return OperationStatus.InvalidData; } - if (!isAce && labelLen >= 4) + if (!isAce) { isAce = IsAceLabel(label); } @@ -193,41 +193,42 @@ public bool Equals(ReadOnlySpan name) } // Strip trailing dot from the comparison name. - if (name.Length > 0 && name[^1] == '.') + if (name.EndsWith('.')) { name = name[..^1]; } DnsLabelEnumerator enumerator = EnumerateLabels(); - int nameIdx = 0; + bool first = true; while (enumerator.MoveNext()) { ReadOnlySpan label = enumerator.Current; - if (nameIdx > 0) + if (!first) { - // Expect a dot separator. - if (nameIdx >= name.Length || name[nameIdx] != '.') + // Expect a dot separator between labels. + if (!name.StartsWith('.')) { return false; } - nameIdx++; + name = name.Slice(1); } + first = false; - if (nameIdx + label.Length > name.Length) + if (label.Length > name.Length) { return false; } - if (!Ascii.EqualsIgnoreCase(label, name.Slice(nameIdx, label.Length))) + if (!Ascii.EqualsIgnoreCase(label, name.Slice(0, label.Length))) { return false; } - nameIdx += label.Length; + name = name.Slice(label.Length); } - return nameIdx == name.Length; + return name.IsEmpty; } // Decodes the domain name into the destination buffer as a dotted string. @@ -244,7 +245,7 @@ public unsafe bool TryDecode(Span destination, out int charsWritten) // For ACE names, the ASCII intermediate may be longer than the final // Unicode form. Decode to a local buffer first, then convert. - Span ascii = stackalloc char[256]; + Span ascii = stackalloc char[MaxEncodedLength + 1]; if (!TryDecodeAscii(ascii, out int asciiWritten)) { return false; @@ -252,11 +253,8 @@ public unsafe bool TryDecode(Span destination, out int charsWritten) try { - string unicode = s_idnMapping.GetUnicode(new string(ascii[..asciiWritten])); - if (unicode.Length <= destination.Length) + if (s_idnMapping.TryGetUnicode(ascii[..asciiWritten], destination, out charsWritten)) { - unicode.AsSpan().CopyTo(destination); - charsWritten = unicode.Length; return true; } } @@ -272,6 +270,7 @@ public unsafe bool TryDecode(Span destination, out int charsWritten) return true; } + charsWritten = 0; return false; } @@ -289,7 +288,7 @@ public unsafe int GetFormattedLength() if (_isAce) { // ACE names need full IDN conversion to determine the Unicode length. - Span chars = stackalloc char[256]; + Span chars = stackalloc char[MaxEncodedLength + 1]; bool success = TryDecode(chars, out int charsWritten); Debug.Assert(success); return charsWritten; @@ -362,7 +361,7 @@ internal bool TryCopyEncodedTo(Span destination, out int bytesWritten) public override unsafe string ToString() { - Span chars = stackalloc char[256]; + Span chars = stackalloc char[MaxEncodedLength + 1]; bool success = TryDecode(chars, out int charsWritten); Debug.Assert(success); return new string(chars[..charsWritten]); @@ -524,7 +523,7 @@ private static bool IsValidLabel(ReadOnlySpan label) label.Length <= 63 && label[0] != (byte)'-' && label[^1] != (byte)'-' && - label.IndexOfAnyExcept(s_ldhBytes) < 0; + !label.ContainsAnyExcept(s_ldhBytes); } } diff --git a/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolverPal.Managed.cs b/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolverPal.Managed.cs index 1e1d079ceb2c5d..42c413b2e71670 100644 --- a/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolverPal.Managed.cs +++ b/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolverPal.Managed.cs @@ -9,6 +9,7 @@ using System.IO; using System.Net.Sockets; using System.Runtime.ExceptionServices; +using System.Runtime.InteropServices; using System.Security.Cryptography; using System.Text; using System.Threading; @@ -40,6 +41,22 @@ internal static partial class DnsResolverPal // ---- Public PAL entry points (one per record type) ---- + // Validates the configured DNS servers. The managed resolver honors custom ports + // and per-server address families (each query targets its server's endpoint over a + // socket of the matching family), so unlike the Windows PAL it accepts non-default + // ports and mixed IPv4/IPv6 lists. Only address families it can open a socket for + // (InterNetwork / InterNetworkV6) are supported. + public static void ValidateServers(IPEndPoint[] servers) + { + foreach (IPEndPoint server in servers) + { + if (server.AddressFamily is not (AddressFamily.InterNetwork or AddressFamily.InterNetworkV6)) + { + throw new ArgumentException(SR.net_dns_unsupported_address_family, nameof(DnsResolverOptions.Servers)); + } + } + } + public static async Task> ResolveAddresses(IList servers, bool async, string name, AddressFamily addressFamily, CancellationToken cancellationToken) { if (addressFamily == AddressFamily.Unspecified) @@ -66,93 +83,44 @@ public static async Task> ResolveAddresses(IList> ResolveSrv(IList servers, bool async, string name, CancellationToken cancellationToken) { - DnsResponse response = await SendQuery(servers, async, name, DnsRecordType.SRV, cancellationToken).ConfigureAwait(false); - try - { - return ParseSrv(response.Span); - } - finally - { - response.Dispose(); - } + using DnsResponse response = await SendQuery(servers, async, name, DnsRecordType.SRV, cancellationToken).ConfigureAwait(false); + return ParseSrv(response.Span); } public static async Task> ResolveMx(IList servers, bool async, string name, CancellationToken cancellationToken) { - DnsResponse response = await SendQuery(servers, async, name, DnsRecordType.MX, cancellationToken).ConfigureAwait(false); - try - { - return ParseMx(response.Span); - } - finally - { - response.Dispose(); - } + using DnsResponse response = await SendQuery(servers, async, name, DnsRecordType.MX, cancellationToken).ConfigureAwait(false); + return ParseMx(response.Span); } public static async Task> ResolveTxt(IList servers, bool async, string name, CancellationToken cancellationToken) { - DnsResponse response = await SendQuery(servers, async, name, DnsRecordType.TXT, cancellationToken).ConfigureAwait(false); - try - { - return ParseTxt(response.Span); - } - finally - { - response.Dispose(); - } + using DnsResponse response = await SendQuery(servers, async, name, DnsRecordType.TXT, cancellationToken).ConfigureAwait(false); + return ParseTxt(response.Span); } public static async Task> ResolveCName(IList servers, bool async, string name, CancellationToken cancellationToken) { - DnsResponse response = await SendQuery(servers, async, name, DnsRecordType.CNAME, cancellationToken).ConfigureAwait(false); - try - { - return ParseCName(response.Span); - } - finally - { - response.Dispose(); - } + using DnsResponse response = await SendQuery(servers, async, name, DnsRecordType.CNAME, cancellationToken).ConfigureAwait(false); + return ParseCName(response.Span); } public static async Task> ResolvePtr(IList servers, bool async, string name, CancellationToken cancellationToken) { - DnsResponse response = await SendQuery(servers, async, name, DnsRecordType.PTR, cancellationToken).ConfigureAwait(false); - try - { - return ParsePtr(response.Span); - } - finally - { - response.Dispose(); - } + using DnsResponse response = await SendQuery(servers, async, name, DnsRecordType.PTR, cancellationToken).ConfigureAwait(false); + return ParsePtr(response.Span); } public static async Task> ResolveNs(IList servers, bool async, string name, CancellationToken cancellationToken) { - DnsResponse response = await SendQuery(servers, async, name, DnsRecordType.NS, cancellationToken).ConfigureAwait(false); - try - { - return ParseNs(response.Span); - } - finally - { - response.Dispose(); - } + using DnsResponse response = await SendQuery(servers, async, name, DnsRecordType.NS, cancellationToken).ConfigureAwait(false); + return ParseNs(response.Span); } private static async Task> QueryAddresses(IList servers, bool async, string name, DnsRecordType qtype, CancellationToken cancellationToken) { - DnsResponse response = await SendQuery(servers, async, name, qtype, cancellationToken).ConfigureAwait(false); - try - { - return ParseAddresses(response.Span, qtype); - } - finally - { - response.Dispose(); - } + using DnsResponse response = await SendQuery(servers, async, name, qtype, cancellationToken).ConfigureAwait(false); + return ParseAddresses(response.Span, qtype); } private static DnsRecordType AddressFamilyToQueryType(AddressFamily addressFamily) => @@ -240,11 +208,8 @@ private static DnsResult ParseSrv(ReadOnlySpan response) { string owner = record.Name.ToString(); glue ??= new Dictionary>(StringComparer.OrdinalIgnoreCase); - if (!glue.TryGetValue(owner, out List? list)) - { - list = new List(); - glue[owner] = list; - } + ref List? list = ref CollectionsMarshal.GetValueRefOrAddDefault(glue, owner, out _); + list ??= new List(); list.Add(new AddressRecord(address, TimeSpan.FromSeconds(record.TimeToLive))); } } @@ -398,16 +363,7 @@ private static DnsResult MergeAddressResults(DnsResult 0 || b.Records.Count > 0) { - AddressRecord[] merged = new AddressRecord[a.Records.Count + b.Records.Count]; - int idx = 0; - for (int i = 0; i < a.Records.Count; i++) - { - merged[idx++] = a.Records[i]; - } - for (int i = 0; i < b.Records.Count; i++) - { - merged[idx++] = b.Records[i]; - } + AddressRecord[] merged = [.. a.Records, .. b.Records]; return new DnsResult(DnsResponseCode.NoError, merged, TimeSpan.Zero); } From 06a5ddc7e77d60cd6b19c1924a5000f5beaa7aa6 Mon Sep 17 00:00:00 2001 From: Radek Zikmund Date: Thu, 9 Jul 2026 15:42:31 +0200 Subject: [PATCH 04/12] Feedback --- .../src/System/Net/DnsEncodedName.cs | 8 ++- .../src/System/Net/DnsRecordParsing.cs | 22 ++++--- .../src/System/Net/DnsResolverPal.Managed.cs | 59 +++---------------- 3 files changed, 29 insertions(+), 60 deletions(-) diff --git a/src/libraries/System.Net.NameResolution/src/System/Net/DnsEncodedName.cs b/src/libraries/System.Net.NameResolution/src/System/Net/DnsEncodedName.cs index a5b5764717ad1c..8db31697f40e9a 100644 --- a/src/libraries/System.Net.NameResolution/src/System/Net/DnsEncodedName.cs +++ b/src/libraries/System.Net.NameResolution/src/System/Net/DnsEncodedName.cs @@ -447,12 +447,13 @@ private static bool ValidateName(ReadOnlySpan buffer, int offset, if ((b & 0xC0) == 0xC0) { - // Compression pointer. + // Compression pointer, 2 bytes with highest two bits set. if (pos + 1 >= buffer.Length) { return false; // truncated pointer } + // first compression pointer tells us where the wire encoding ends, any subsequent lables/pointers are parts of the preceding message parts if (!foundWireEnd) { wireLength = pos + 2 - offset; @@ -460,10 +461,11 @@ private static bool ValidateName(ReadOnlySpan buffer, int offset, hasPointers = true; } + // compression pointers are offsets *from the start of the entire DNS message*. To prevent cycles, we allow only jumps backward int pointer = ((b & 0x3F) << 8) | buffer[pos + 1]; if (pointer >= pos) { - return false; // only backwards jumps allowed + return false; } pos = pointer; @@ -480,7 +482,7 @@ private static bool ValidateName(ReadOnlySpan buffer, int offset, } Debug.Assert(b <= 63); // enforced by condition above - if (pos + 1 + b > buffer.Length) + if (pos + b >= buffer.Length) { return false; // label extends past buffer } diff --git a/src/libraries/System.Net.NameResolution/src/System/Net/DnsRecordParsing.cs b/src/libraries/System.Net.NameResolution/src/System/Net/DnsRecordParsing.cs index 5fa1a8c918d217..ffdbb283e281ee 100644 --- a/src/libraries/System.Net.NameResolution/src/System/Net/DnsRecordParsing.cs +++ b/src/libraries/System.Net.NameResolution/src/System/Net/DnsRecordParsing.cs @@ -190,7 +190,7 @@ public static bool TryParseCNameRecord(this DnsRecord record, out DnsCNameRecord { return false; } - if (!DnsEncodedName.TryParse(record.Message, record.DataOffset, out DnsEncodedName cname, out _)) + if (!record.TryParseSingleDnsNameRecord(out DnsEncodedName cname)) { return false; } @@ -206,7 +206,8 @@ public static bool TryParseMxRecord(this DnsRecord record, out DnsMxRecordData r return false; } ushort preference = BinaryPrimitives.ReadUInt16BigEndian(record.Data); - if (!DnsEncodedName.TryParse(record.Message, record.DataOffset + 2, out DnsEncodedName exchange, out _)) + + if (!record.TryParseSingleDnsNameRecord(out DnsEncodedName exchange, 2)) { return false; } @@ -224,7 +225,7 @@ public static bool TryParseSrvRecord(this DnsRecord record, out DnsSrvRecordData ushort priority = BinaryPrimitives.ReadUInt16BigEndian(record.Data); ushort weight = BinaryPrimitives.ReadUInt16BigEndian(record.Data[2..]); ushort port = BinaryPrimitives.ReadUInt16BigEndian(record.Data[4..]); - if (!DnsEncodedName.TryParse(record.Message, record.DataOffset + 6, out DnsEncodedName target, out _)) + if (!record.TryParseSingleDnsNameRecord(out DnsEncodedName target, 6)) { return false; } @@ -240,12 +241,12 @@ public static bool TryParseSoaRecord(this DnsRecord record, out DnsSoaRecordData return false; } - if (!DnsEncodedName.TryParse(record.Message, record.DataOffset, out DnsEncodedName mname, out int mnameLen)) + if (!DnsEncodedName.TryParse(record.Message.Slice(0, record.DataOffset + record.Data.Length), record.DataOffset, out DnsEncodedName mname, out int mnameLen)) { return false; } - if (!DnsEncodedName.TryParse(record.Message, record.DataOffset + mnameLen, out DnsEncodedName rname, out int rnameLen)) + if (!DnsEncodedName.TryParse(record.Message.Slice(0, record.DataOffset + record.Data.Length), record.DataOffset + mnameLen, out DnsEncodedName rname, out int rnameLen)) { return false; } @@ -283,7 +284,7 @@ public static bool TryParsePtrRecord(this DnsRecord record, out DnsPtrRecordData { return false; } - if (!DnsEncodedName.TryParse(record.Message, record.DataOffset, out DnsEncodedName ptr, out _)) + if (!record.TryParseSingleDnsNameRecord(out DnsEncodedName ptr)) { return false; } @@ -298,12 +299,19 @@ public static bool TryParseNsRecord(this DnsRecord record, out DnsNsRecordData r { return false; } - if (!DnsEncodedName.TryParse(record.Message, record.DataOffset, out DnsEncodedName ns, out _)) + if (!record.TryParseSingleDnsNameRecord(out DnsEncodedName ns)) { return false; } result = new DnsNsRecordData(ns); return true; } + + private static bool TryParseSingleDnsNameRecord(this DnsRecord record, out DnsEncodedName name, int inRecordOffset = 0) + { + return + DnsEncodedName.TryParse(record.Message.Slice(0, record.DataOffset + record.Data.Length), record.DataOffset + inRecordOffset, out name, out int bytesConsumed) + && bytesConsumed == record.Data.Length - inRecordOffset; + } } } diff --git a/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolverPal.Managed.cs b/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolverPal.Managed.cs index 42c413b2e71670..7a7c6ccc23609f 100644 --- a/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolverPal.Managed.cs +++ b/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolverPal.Managed.cs @@ -59,26 +59,15 @@ public static void ValidateServers(IPEndPoint[] servers) public static async Task> ResolveAddresses(IList servers, bool async, string name, AddressFamily addressFamily, CancellationToken cancellationToken) { - if (addressFamily == AddressFamily.Unspecified) + DnsRecordType qtype = addressFamily switch { - if (async) - { - Task> aTask = QueryAddresses(servers, async: true, name, DnsRecordType.A, cancellationToken); - Task> aaaaTask = QueryAddresses(servers, async: true, name, DnsRecordType.AAAA, cancellationToken); - DnsResult aRes = await aTask.ConfigureAwait(false); - DnsResult aaaaRes = await aaaaTask.ConfigureAwait(false); - return MergeAddressResults(aRes, aaaaRes); - } - else - { - DnsResult aRes = await QueryAddresses(servers, async: false, name, DnsRecordType.A, cancellationToken).ConfigureAwait(false); - DnsResult aaaaRes = await QueryAddresses(servers, async: false, name, DnsRecordType.AAAA, cancellationToken).ConfigureAwait(false); - return MergeAddressResults(aRes, aaaaRes); - } - } + AddressFamily.InterNetwork => DnsRecordType.A, + AddressFamily.InterNetworkV6 => DnsRecordType.AAAA, + _ => throw new ArgumentException(SR.net_invalid_ip_addr, nameof(addressFamily)), + }; - DnsRecordType qtype = AddressFamilyToQueryType(addressFamily); - return await QueryAddresses(servers, async, name, qtype, cancellationToken).ConfigureAwait(false); + using DnsResponse response = await SendQuery(servers, async, name, qtype, cancellationToken).ConfigureAwait(false); + return ParseAddresses(response.Span, qtype); } public static async Task> ResolveSrv(IList servers, bool async, string name, CancellationToken cancellationToken) @@ -117,20 +106,6 @@ public static async Task> ResolveNs(IList server return ParseNs(response.Span); } - private static async Task> QueryAddresses(IList servers, bool async, string name, DnsRecordType qtype, CancellationToken cancellationToken) - { - using DnsResponse response = await SendQuery(servers, async, name, qtype, cancellationToken).ConfigureAwait(false); - return ParseAddresses(response.Span, qtype); - } - - private static DnsRecordType AddressFamilyToQueryType(AddressFamily addressFamily) => - addressFamily switch - { - AddressFamily.InterNetwork => DnsRecordType.A, - AddressFamily.InterNetworkV6 => DnsRecordType.AAAA, - _ => throw new ArgumentException(SR.net_invalid_ip_addr, nameof(addressFamily)), - }; - // ---- Response parsers ---- private static DnsResult ParseAddresses(ReadOnlySpan response, DnsRecordType qtype) @@ -359,21 +334,6 @@ private static DnsResult ParseNs(ReadOnlySpan response) return new DnsResult(DnsResponseCode.NoError, records, nsNegTtl); } - private static DnsResult MergeAddressResults(DnsResult a, DnsResult b) - { - if (a.Records.Count > 0 || b.Records.Count > 0) - { - AddressRecord[] merged = [.. a.Records, .. b.Records]; - return new DnsResult(DnsResponseCode.NoError, merged, TimeSpan.Zero); - } - - DnsResponseCode chosenRc = a.ResponseCode == DnsResponseCode.NxDomain || b.ResponseCode == DnsResponseCode.NxDomain - ? DnsResponseCode.NxDomain - : (a.ResponseCode != DnsResponseCode.NoError ? a.ResponseCode : b.ResponseCode); - TimeSpan negTtl = a.NegativeCacheTtl > TimeSpan.Zero ? a.NegativeCacheTtl : b.NegativeCacheTtl; - return new DnsResult(chosenRc, null, negTtl); - } - // Per RFC 2308 §5, the negative cache TTL is the minimum of the SOA record TTL // and the SOA MINIMUM field of the SOA record in the authority section. private static TimeSpan ExtractNegativeCacheTtl(ReadOnlySpan response) @@ -420,10 +380,8 @@ private static async Task SendQuery(IList servers, bool { if (cancellationToken.IsCancellationRequested) { - // Surface pre-flight cancellation as TaskCanceledException to match the - // Windows PAL (which completes via TaskCompletionSource.TrySetCanceled). ArrayPool.Shared.Return(responseBuffer); - throw new TaskCanceledException(); + cancellationToken.ThrowIfCancellationRequested(); } try { @@ -683,6 +641,7 @@ private static (byte[] Buffer, int Length) SendTcpQuerySync( if (!ar.AsyncWaitHandle.WaitOne(s_queryTimeout)) { socket.Close(); + ar.AsyncWaitHandle.Close(); throw new SocketException((int)SocketError.TimedOut); } socket.EndConnect(ar); From 40d578c7643881cd89e5bcb9b8b2fa1047dd33ba Mon Sep 17 00:00:00 2001 From: Radek Zikmund Date: Mon, 13 Jul 2026 13:36:30 +0200 Subject: [PATCH 05/12] Review feedback --- .../src/System/Net/DnsEncodedName.cs | 3 +- .../src/System/Net/DnsMessageWriter.cs | 12 +++--- .../src/System/Net/DnsResolverPal.Managed.cs | 14 +++++-- .../tests/UnitTests/DnsMessageWriterTests.cs | 4 +- .../tests/UnitTests/DnsRecordTypeTests.cs | 40 +++++++++++++------ 5 files changed, 46 insertions(+), 27 deletions(-) diff --git a/src/libraries/System.Net.NameResolution/src/System/Net/DnsEncodedName.cs b/src/libraries/System.Net.NameResolution/src/System/Net/DnsEncodedName.cs index 8db31697f40e9a..c2dc9afc4be165 100644 --- a/src/libraries/System.Net.NameResolution/src/System/Net/DnsEncodedName.cs +++ b/src/libraries/System.Net.NameResolution/src/System/Net/DnsEncodedName.cs @@ -453,7 +453,8 @@ private static bool ValidateName(ReadOnlySpan buffer, int offset, return false; // truncated pointer } - // first compression pointer tells us where the wire encoding ends, any subsequent lables/pointers are parts of the preceding message parts + // The first compression pointer tells us where the wire encoding ends; any + // subsequent labels/pointers are parts of the preceding message parts. if (!foundWireEnd) { wireLength = pos + 2 - offset; diff --git a/src/libraries/System.Net.NameResolution/src/System/Net/DnsMessageWriter.cs b/src/libraries/System.Net.NameResolution/src/System/Net/DnsMessageWriter.cs index 6c6018efb82770..5aa928e007effc 100644 --- a/src/libraries/System.Net.NameResolution/src/System/Net/DnsMessageWriter.cs +++ b/src/libraries/System.Net.NameResolution/src/System/Net/DnsMessageWriter.cs @@ -38,13 +38,11 @@ public bool TryWriteQuestion( DnsRecordType type, DnsRecordClass @class = DnsRecordClass.Internet) { - if (!name.TryCopyEncodedTo(_destination[_bytesWritten..], out int nameWritten)) - { - return false; - } - - // type (2) + class (2) - if (_bytesWritten + nameWritten + 4 > _destination.Length) + // Reserve the trailing 4 bytes for TYPE + CLASS up front so that encoding the + // name cannot consume the space they require. + Span destination = _destination[_bytesWritten..]; + if (destination.Length < 4 || + !name.TryCopyEncodedTo(destination[..^4], out int nameWritten)) { return false; } diff --git a/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolverPal.Managed.cs b/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolverPal.Managed.cs index 7a7c6ccc23609f..cf0fa9f07b2869 100644 --- a/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolverPal.Managed.cs +++ b/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolverPal.Managed.cs @@ -638,13 +638,19 @@ private static (byte[] Buffer, int Length) SendTcpQuerySync( // Connect with explicit timeout to prevent unbounded blocking when // the server's TCP endpoint is unreachable. IAsyncResult ar = socket.BeginConnect(server, null, null); - if (!ar.AsyncWaitHandle.WaitOne(s_queryTimeout)) + try + { + if (!ar.AsyncWaitHandle.WaitOne(s_queryTimeout)) + { + socket.Close(); + throw new SocketException((int)SocketError.TimedOut); + } + socket.EndConnect(ar); + } + finally { - socket.Close(); ar.AsyncWaitHandle.Close(); - throw new SocketException((int)SocketError.TimedOut); } - socket.EndConnect(ar); byte[] buffer = ArrayPool.Shared.Rent(InitialTcpBufferSize); try diff --git a/src/libraries/System.Net.NameResolution/tests/UnitTests/DnsMessageWriterTests.cs b/src/libraries/System.Net.NameResolution/tests/UnitTests/DnsMessageWriterTests.cs index 4f768ac11588c5..c8fc7ee51fa8cf 100644 --- a/src/libraries/System.Net.NameResolution/tests/UnitTests/DnsMessageWriterTests.cs +++ b/src/libraries/System.Net.NameResolution/tests/UnitTests/DnsMessageWriterTests.cs @@ -55,10 +55,10 @@ public void WriteMultipleQuestions_ProducesCorrectOutput() Span nameBuffer = stackalloc byte[DnsEncodedName.MaxEncodedLength]; - DnsEncodedName.TryEncode("a.com", nameBuffer, out var name1, out _); + Assert.Equal(OperationStatus.Done, DnsEncodedName.TryEncode("a.com", nameBuffer, out var name1, out _)); Assert.True(writer.TryWriteQuestion(name1, DnsRecordType.A)); - DnsEncodedName.TryEncode("b.com", nameBuffer, out var name2, out _); + Assert.Equal(OperationStatus.Done, DnsEncodedName.TryEncode("b.com", nameBuffer, out var name2, out _)); Assert.True(writer.TryWriteQuestion(name2, DnsRecordType.AAAA)); // Header(12) + Q1(1+1+3+1+3+1+4) + Q2 (same) = 12 + 11 + 11 = 34 diff --git a/src/libraries/System.Net.NameResolution/tests/UnitTests/DnsRecordTypeTests.cs b/src/libraries/System.Net.NameResolution/tests/UnitTests/DnsRecordTypeTests.cs index 3cb262874b4f1b..559e89243c28c9 100644 --- a/src/libraries/System.Net.NameResolution/tests/UnitTests/DnsRecordTypeTests.cs +++ b/src/libraries/System.Net.NameResolution/tests/UnitTests/DnsRecordTypeTests.cs @@ -217,19 +217,33 @@ public void TypeMismatch_ReturnsFalse(ushort actualTypeValue) { DnsRecordType actualType = (DnsRecordType)actualTypeValue; - // Use a valid A record, but try to parse as every other type - byte[] rdata = [192, 168, 1, 1]; - DnsRecord record = GetAnswerRecord(BuildResponse(actualType, rdata)); - - // Try parsing as each type — only the matching one should succeed - if (actualType != DnsRecordType.A) Assert.False(record.TryParseARecord(out _)); - if (actualType != DnsRecordType.AAAA) Assert.False(record.TryParseAAAARecord(out _)); - if (actualType != DnsRecordType.CNAME) Assert.False(record.TryParseCNameRecord(out _)); - if (actualType != DnsRecordType.MX) Assert.False(record.TryParseMxRecord(out _)); - if (actualType != DnsRecordType.SRV) Assert.False(record.TryParseSrvRecord(out _)); - if (actualType != DnsRecordType.TXT) Assert.False(record.TryParseTxtRecord(out _)); - if (actualType != DnsRecordType.PTR) Assert.False(record.TryParsePtrRecord(out _)); - if (actualType != DnsRecordType.NS) Assert.False(record.TryParseNsRecord(out _)); + // Build a record with valid RDATA for its own type, then verify each TryParse* + // succeeds only for the matching type and fails for every other type. + DnsRecord record = GetAnswerRecord(BuildResponse(actualType, GetValidRData(actualType))); + + Assert.Equal(actualType == DnsRecordType.A, record.TryParseARecord(out _)); + Assert.Equal(actualType == DnsRecordType.AAAA, record.TryParseAAAARecord(out _)); + Assert.Equal(actualType == DnsRecordType.CNAME, record.TryParseCNameRecord(out _)); + Assert.Equal(actualType == DnsRecordType.MX, record.TryParseMxRecord(out _)); + Assert.Equal(actualType == DnsRecordType.SRV, record.TryParseSrvRecord(out _)); + Assert.Equal(actualType == DnsRecordType.TXT, record.TryParseTxtRecord(out _)); + Assert.Equal(actualType == DnsRecordType.PTR, record.TryParsePtrRecord(out _)); + Assert.Equal(actualType == DnsRecordType.NS, record.TryParseNsRecord(out _)); + + // Returns valid RDATA for the given record type. + static byte[] GetValidRData(DnsRecordType type) => type switch + { + DnsRecordType.A => [192, 168, 1, 1], + DnsRecordType.AAAA => [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1], + DnsRecordType.CNAME or DnsRecordType.PTR or DnsRecordType.NS => + [0x04, (byte)'h', (byte)'o', (byte)'s', (byte)'t', 0x04, (byte)'t', (byte)'e', (byte)'s', (byte)'t', 0x00], + DnsRecordType.MX => + [0x00, 0x0A, 0x04, (byte)'m', (byte)'a', (byte)'i', (byte)'l', 0x04, (byte)'t', (byte)'e', (byte)'s', (byte)'t', 0x00], + DnsRecordType.SRV => + [0x00, 0x0A, 0x00, 0x14, 0x1F, 0x90, 0x03, (byte)'s', (byte)'r', (byte)'v', 0x04, (byte)'t', (byte)'e', (byte)'s', (byte)'t', 0x00], + DnsRecordType.TXT => [0x05, (byte)'h', (byte)'e', (byte)'l', (byte)'l', (byte)'o'], + _ => throw new ArgumentOutOfRangeException(nameof(type)), + }; } [Fact] From 19164062be88440bb5fc7c7fc0d9430349e0eaf0 Mon Sep 17 00:00:00 2001 From: Radek Zikmund Date: Mon, 13 Jul 2026 13:56:33 +0200 Subject: [PATCH 06/12] Remove flaky test --- .../tests/UnitTests/DnsMessageReaderTests.cs | 35 ------------------- 1 file changed, 35 deletions(-) diff --git a/src/libraries/System.Net.NameResolution/tests/UnitTests/DnsMessageReaderTests.cs b/src/libraries/System.Net.NameResolution/tests/UnitTests/DnsMessageReaderTests.cs index 6217c69e60ddf3..1aa4e5d14d7b75 100644 --- a/src/libraries/System.Net.NameResolution/tests/UnitTests/DnsMessageReaderTests.cs +++ b/src/libraries/System.Net.NameResolution/tests/UnitTests/DnsMessageReaderTests.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Buffers; -using System.Diagnostics; using System.Net; using Xunit; @@ -276,40 +275,6 @@ public void RoundTrip_WriteThenRead() Assert.Equal(DnsRecordType.AAAA, q2.Type); } - [Fact] - public void CompressionPointerLoop_DoesNotHang() - { - // Message with a compression pointer that loops back, creating a cycle. - // The reader must terminate rather than loop indefinitely. - byte[] data = [0x12, 0x34, 0x81, 0x80, 0x3f, 0x0, 0x1, 0x1, 0x1, 0x0, 0x0, 0x0, - 0x0, 0x63, 0x6f, 0x2b, 0x0, 0x1, 0x0, 0x1, 0xc, 0xc0, 0x0, 0x0, - 0x0, 0x1, 0x2c, 0x0, 0x4, 0xa, 0x0, 0x0, 0x91, 0x1]; - - Stopwatch sw = Stopwatch.StartNew(); - DnsMessageReader.TryCreate(data, out DnsMessageReader reader); - for (int i = 0; i < reader.Header.QuestionCount && i < 32; i++) - { - if (!reader.TryReadQuestion(out DnsQuestion q)) - { - break; - } - q.Name.ToString(); - q.Name.Equals("example.com"); - } - int total = reader.Header.AnswerCount + reader.Header.AuthorityCount + reader.Header.AdditionalCount; - for (int i = 0; i < total && i < 64; i++) - { - if (!reader.TryReadRecord(out DnsRecord r)) - { - break; - } - r.Name.ToString(); - r.TryParseSoaRecord(out _); - } - sw.Stop(); - Assert.True(sw.ElapsedMilliseconds < 1000, $"Took {sw.ElapsedMilliseconds}ms"); - } - [Fact] public void TryCreate_ExactHeaderSize_Succeeds() { From bc442484d0704ad1517a0984e0f13dde4bba4fe4 Mon Sep 17 00:00:00 2001 From: Radek Zikmund Date: Mon, 13 Jul 2026 18:15:12 +0200 Subject: [PATCH 07/12] Break Sockets<->NameResolution shared-framework cycle via reflection The managed Unix DNS stub resolver used System.Net.Sockets.Socket for UDP/TCP queries. Because System.Net.Sockets already depends on System.Net.NameResolution (Socket.Connect(host, port) resolves names through Dns), the emitted NameResolution.dll carried a real metadata reference back to Sockets, closing a dependency cycle that the shared-framework VerifyClosure task rejects. This built locally but failed the CoreCLR/Mono runtime-pack build legs on CI. Access Socket through a new internal DnsSocket reflection wrapper so NameResolution no longer statically references System.Net.Sockets. The wrapper binds the required Socket members to delegates so exceptions (e.g. SocketException) propagate directly instead of being wrapped in TargetInvocationException, and a DynamicDependency attribute preserves the members for trimming/AOT. SocketException, SocketError and AddressFamily live in System.Net.Primitives and continue to be used directly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/System.Net.NameResolution.csproj | 5 +- .../src/System/Net/DnsResolverPal.Managed.cs | 54 +++--- .../src/System/Net/DnsSocket.cs | 169 ++++++++++++++++++ 3 files changed, 192 insertions(+), 36 deletions(-) create mode 100644 src/libraries/System.Net.NameResolution/src/System/Net/DnsSocket.cs diff --git a/src/libraries/System.Net.NameResolution/src/System.Net.NameResolution.csproj b/src/libraries/System.Net.NameResolution/src/System.Net.NameResolution.csproj index 26804896d30e9d..b4f741a9d8e99e 100644 --- a/src/libraries/System.Net.NameResolution/src/System.Net.NameResolution.csproj +++ b/src/libraries/System.Net.NameResolution/src/System.Net.NameResolution.csproj @@ -89,6 +89,7 @@ + @@ -177,10 +178,6 @@ - - diff --git a/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolverPal.Managed.cs b/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolverPal.Managed.cs index cf0fa9f07b2869..5d8d58ff9564fa 100644 --- a/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolverPal.Managed.cs +++ b/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolverPal.Managed.cs @@ -20,8 +20,11 @@ namespace System.Net // Managed stub-resolver implementation of the DNS PAL for Unix platforms. // // Builds and parses DNS wire messages and talks to the configured servers over - // UDP (with TCP fallback on truncation) using System.Net.Sockets. When no servers - // are configured, the system servers from /etc/resolv.conf are used. + // UDP (with TCP fallback on truncation). Sockets are reached through the DnsSocket + // reflection wrapper because System.Net.NameResolution cannot statically reference + // System.Net.Sockets (that would create a shared-framework dependency cycle, since + // Sockets already depends on NameResolution). When no servers are configured, the + // system servers from /etc/resolv.conf are used. // // Each entry point takes a `bool async` flag. When async is false the underlying // socket operations are issued synchronously (blocking) and the returned Task is @@ -536,25 +539,25 @@ private static unsafe int WriteQuery(ushort queryId, string name, DnsRecordType private static async Task SendUdpQueryAsync( ReadOnlyMemory query, IPEndPoint server, byte[] responseBuffer, CancellationToken cancellationToken) { - using Socket socket = new Socket(server.AddressFamily, SocketType.Dgram, ProtocolType.Udp); + using DnsSocket socket = new DnsSocket(server.AddressFamily, stream: false); using CancellationTokenSource timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); timeoutCts.CancelAfter(s_queryTimeout); await socket.ConnectAsync(server, timeoutCts.Token).ConfigureAwait(false); - await socket.SendAsync(query, SocketFlags.None, timeoutCts.Token).ConfigureAwait(false); - return await socket.ReceiveAsync(responseBuffer, SocketFlags.None, timeoutCts.Token).ConfigureAwait(false); + await socket.SendAsync(query, timeoutCts.Token).ConfigureAwait(false); + return await socket.ReceiveAsync(responseBuffer, timeoutCts.Token).ConfigureAwait(false); } private static int SendUdpQuerySync( ReadOnlyMemory query, IPEndPoint server, byte[] responseBuffer) { - using Socket socket = new Socket(server.AddressFamily, SocketType.Dgram, ProtocolType.Udp); + using DnsSocket socket = new DnsSocket(server.AddressFamily, stream: false); socket.SendTimeout = (int)s_queryTimeout.TotalMilliseconds; socket.ReceiveTimeout = (int)s_queryTimeout.TotalMilliseconds; socket.Connect(server); - socket.Send(query.Span, SocketFlags.None); - return socket.Receive(responseBuffer, SocketFlags.None); + socket.Send(query.Span); + return socket.Receive(responseBuffer); } private static async Task<(byte[]? Buffer, int Length, Exception? Error)> TryTcpFallbackAsync( @@ -596,7 +599,7 @@ private static (byte[]? Buffer, int Length, Exception? Error) TryTcpFallbackSync private static async Task<(byte[] Buffer, int Length)> SendTcpQueryAsync( ReadOnlyMemory query, IPEndPoint server, CancellationToken cancellationToken) { - using Socket socket = new Socket(server.AddressFamily, SocketType.Stream, ProtocolType.Tcp); + using DnsSocket socket = new DnsSocket(server.AddressFamily, stream: true); using CancellationTokenSource timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); timeoutCts.CancelAfter(s_queryTimeout); @@ -631,26 +634,13 @@ private static (byte[]? Buffer, int Length, Exception? Error) TryTcpFallbackSync private static (byte[] Buffer, int Length) SendTcpQuerySync( ReadOnlyMemory query, IPEndPoint server) { - using Socket socket = new Socket(server.AddressFamily, SocketType.Stream, ProtocolType.Tcp); + using DnsSocket socket = new DnsSocket(server.AddressFamily, stream: true); socket.SendTimeout = (int)s_queryTimeout.TotalMilliseconds; socket.ReceiveTimeout = (int)s_queryTimeout.TotalMilliseconds; // Connect with explicit timeout to prevent unbounded blocking when // the server's TCP endpoint is unreachable. - IAsyncResult ar = socket.BeginConnect(server, null, null); - try - { - if (!ar.AsyncWaitHandle.WaitOne(s_queryTimeout)) - { - socket.Close(); - throw new SocketException((int)SocketError.TimedOut); - } - socket.EndConnect(ar); - } - finally - { - ar.AsyncWaitHandle.Close(); - } + socket.ConnectWithTimeout(server, s_queryTimeout); byte[] buffer = ArrayPool.Shared.Rent(InitialTcpBufferSize); try @@ -678,12 +668,12 @@ private static (byte[] Buffer, int Length) SendTcpQuerySync( } } - private static async Task ReceiveExactAsync(Socket socket, Memory buffer, CancellationToken cancellationToken) + private static async Task ReceiveExactAsync(DnsSocket socket, Memory buffer, CancellationToken cancellationToken) { int totalReceived = 0; while (totalReceived < buffer.Length) { - int received = await socket.ReceiveAsync(buffer[totalReceived..], SocketFlags.None, cancellationToken).ConfigureAwait(false); + int received = await socket.ReceiveAsync(buffer[totalReceived..], cancellationToken).ConfigureAwait(false); if (received == 0) { ThrowMalformedResponse(); @@ -692,12 +682,12 @@ private static async Task ReceiveExactAsync(Socket socket, Memory buffer, } } - private static void ReceiveExactSync(Socket socket, Span buffer) + private static void ReceiveExactSync(DnsSocket socket, Span buffer) { int totalReceived = 0; while (totalReceived < buffer.Length) { - int received = socket.Receive(buffer.Slice(totalReceived), SocketFlags.None); + int received = socket.Receive(buffer.Slice(totalReceived)); if (received == 0) { ThrowMalformedResponse(); @@ -706,12 +696,12 @@ private static void ReceiveExactSync(Socket socket, Span buffer) } } - private static async Task SendExactAsync(Socket socket, ReadOnlyMemory buffer, CancellationToken cancellationToken) + private static async Task SendExactAsync(DnsSocket socket, ReadOnlyMemory buffer, CancellationToken cancellationToken) { int totalSent = 0; while (totalSent < buffer.Length) { - int sent = await socket.SendAsync(buffer[totalSent..], SocketFlags.None, cancellationToken).ConfigureAwait(false); + int sent = await socket.SendAsync(buffer[totalSent..], cancellationToken).ConfigureAwait(false); if (sent == 0) { throw new IOException(); @@ -720,12 +710,12 @@ private static async Task SendExactAsync(Socket socket, ReadOnlyMemory buf } } - private static void SendExactSync(Socket socket, ReadOnlySpan buffer) + private static void SendExactSync(DnsSocket socket, ReadOnlySpan buffer) { int totalSent = 0; while (totalSent < buffer.Length) { - int sent = socket.Send(buffer.Slice(totalSent), SocketFlags.None); + int sent = socket.Send(buffer.Slice(totalSent)); if (sent == 0) { throw new IOException(); diff --git a/src/libraries/System.Net.NameResolution/src/System/Net/DnsSocket.cs b/src/libraries/System.Net.NameResolution/src/System/Net/DnsSocket.cs new file mode 100644 index 00000000000000..35d1cde7f95851 --- /dev/null +++ b/src/libraries/System.Net.NameResolution/src/System/Net/DnsSocket.cs @@ -0,0 +1,169 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Net.Sockets; +using System.Reflection; +using System.Runtime.ExceptionServices; +using System.Threading; +using System.Threading.Tasks; + +namespace System.Net +{ + // Thin wrapper over System.Net.Sockets.Socket accessed via reflection. + // + // System.Net.Sockets depends on System.Net.NameResolution (Socket.Connect(host, port) + // resolves names through Dns), so NameResolution cannot statically reference the Sockets + // assembly without introducing a cycle in the shared-framework closure. The managed DNS + // stub resolver still needs raw UDP/TCP sockets, so it reaches Socket through reflection; + // the assembly is resolved from the shared framework at runtime. SocketException, + // SocketError and AddressFamily live in System.Net.Primitives and are used directly. + // + // Instance operations are exposed through delegates bound to the underlying Socket so that + // exceptions (e.g. SocketException) propagate to callers directly instead of being wrapped + // in a TargetInvocationException. + internal sealed class DnsSocket : IDisposable + { + private sealed class SocketReflection + { + public ConstructorInfo Constructor = null!; + public MethodInfo ConnectAsyncMethod = null!; + public MethodInfo SendAsyncMethod = null!; + public MethodInfo ReceiveAsyncMethod = null!; + public MethodInfo ConnectMethod = null!; + public MethodInfo SendMethod = null!; + public MethodInfo ReceiveMethod = null!; + public MethodInfo BeginConnectMethod = null!; + public MethodInfo EndConnectMethod = null!; + public MethodInfo DisposeMethod = null!; + public MethodInfo SetSendTimeoutMethod = null!; + public MethodInfo SetReceiveTimeoutMethod = null!; + public object SocketTypeDgram = null!; + public object SocketTypeStream = null!; + public object ProtocolTypeUdp = null!; + public object ProtocolTypeTcp = null!; + } + + private static readonly SocketReflection s_reflection = CreateReflection(); + + private delegate int SendSpanDelegate(ReadOnlySpan buffer); + private delegate int ReceiveSpanDelegate(Span buffer); + + private readonly Func _connectAsync; + private readonly Func, CancellationToken, ValueTask> _sendAsync; + private readonly Func, CancellationToken, ValueTask> _receiveAsync; + private readonly Action _connect; + private readonly SendSpanDelegate _send; + private readonly ReceiveSpanDelegate _receive; + private readonly Func _beginConnect; + private readonly Action _endConnect; + private readonly Action _setSendTimeout; + private readonly Action _setReceiveTimeout; + private readonly Action _dispose; + + [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2075", + Justification = "The Socket members accessed here are preserved by the DynamicDependency attribute.")] + [DynamicDependency(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.PublicMethods | DynamicallyAccessedMemberTypes.PublicProperties, + "System.Net.Sockets.Socket", "System.Net.Sockets")] + private static SocketReflection CreateReflection() + { + Type socketType = Type.GetType("System.Net.Sockets.Socket, System.Net.Sockets", throwOnError: true)!; + Type socketTypeEnum = Type.GetType("System.Net.Sockets.SocketType, System.Net.Sockets", throwOnError: true)!; + Type protocolTypeEnum = Type.GetType("System.Net.Sockets.ProtocolType, System.Net.Sockets", throwOnError: true)!; + + return new SocketReflection + { + SocketTypeDgram = Enum.Parse(socketTypeEnum, "Dgram"), + SocketTypeStream = Enum.Parse(socketTypeEnum, "Stream"), + ProtocolTypeUdp = Enum.Parse(protocolTypeEnum, "Udp"), + ProtocolTypeTcp = Enum.Parse(protocolTypeEnum, "Tcp"), + Constructor = socketType.GetConstructor(new[] { typeof(AddressFamily), socketTypeEnum, protocolTypeEnum })!, + ConnectAsyncMethod = socketType.GetMethod("ConnectAsync", new[] { typeof(EndPoint), typeof(CancellationToken) })!, + SendAsyncMethod = socketType.GetMethod("SendAsync", new[] { typeof(ReadOnlyMemory), typeof(CancellationToken) })!, + ReceiveAsyncMethod = socketType.GetMethod("ReceiveAsync", new[] { typeof(Memory), typeof(CancellationToken) })!, + ConnectMethod = socketType.GetMethod("Connect", new[] { typeof(EndPoint) })!, + SendMethod = socketType.GetMethod("Send", new[] { typeof(ReadOnlySpan) })!, + ReceiveMethod = socketType.GetMethod("Receive", new[] { typeof(Span) })!, + BeginConnectMethod = socketType.GetMethod("BeginConnect", new[] { typeof(EndPoint), typeof(AsyncCallback), typeof(object) })!, + EndConnectMethod = socketType.GetMethod("EndConnect", new[] { typeof(IAsyncResult) })!, + DisposeMethod = socketType.GetMethod("Dispose", Type.EmptyTypes)!, + SetSendTimeoutMethod = socketType.GetProperty("SendTimeout")!.GetSetMethod()!, + SetReceiveTimeoutMethod = socketType.GetProperty("ReceiveTimeout")!.GetSetMethod()!, + }; + } + + public DnsSocket(AddressFamily addressFamily, bool stream) + { + SocketReflection reflection = s_reflection; + object socket; + try + { + socket = reflection.Constructor.Invoke(new object[] + { + addressFamily, + stream ? reflection.SocketTypeStream : reflection.SocketTypeDgram, + stream ? reflection.ProtocolTypeTcp : reflection.ProtocolTypeUdp, + })!; + } + catch (TargetInvocationException e) when (e.InnerException is not null) + { + ExceptionDispatchInfo.Throw(e.InnerException); + throw; // Unreachable, satisfies definite-assignment. + } + + _connectAsync = reflection.ConnectAsyncMethod.CreateDelegate>(socket); + _sendAsync = reflection.SendAsyncMethod.CreateDelegate, CancellationToken, ValueTask>>(socket); + _receiveAsync = reflection.ReceiveAsyncMethod.CreateDelegate, CancellationToken, ValueTask>>(socket); + _connect = reflection.ConnectMethod.CreateDelegate>(socket); + _send = reflection.SendMethod.CreateDelegate(socket); + _receive = reflection.ReceiveMethod.CreateDelegate(socket); + _beginConnect = reflection.BeginConnectMethod.CreateDelegate>(socket); + _endConnect = reflection.EndConnectMethod.CreateDelegate>(socket); + _setSendTimeout = reflection.SetSendTimeoutMethod.CreateDelegate>(socket); + _setReceiveTimeout = reflection.SetReceiveTimeoutMethod.CreateDelegate>(socket); + _dispose = reflection.DisposeMethod.CreateDelegate(socket); + } + + public int SendTimeout { set => _setSendTimeout(value); } + + public int ReceiveTimeout { set => _setReceiveTimeout(value); } + + public ValueTask ConnectAsync(EndPoint remoteEndPoint, CancellationToken cancellationToken) => + _connectAsync(remoteEndPoint, cancellationToken); + + public ValueTask SendAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken) => + _sendAsync(buffer, cancellationToken); + + public ValueTask ReceiveAsync(Memory buffer, CancellationToken cancellationToken) => + _receiveAsync(buffer, cancellationToken); + + public void Connect(EndPoint remoteEndPoint) => _connect(remoteEndPoint); + + public int Send(ReadOnlySpan buffer) => _send(buffer); + + public int Receive(Span buffer) => _receive(buffer); + + // Connects synchronously with an explicit timeout so an unreachable TCP endpoint cannot + // block indefinitely. Throws a timed-out SocketException when the timeout elapses. + public void ConnectWithTimeout(EndPoint remoteEndPoint, TimeSpan timeout) + { + IAsyncResult asyncResult = _beginConnect(remoteEndPoint, null, null); + try + { + if (!asyncResult.AsyncWaitHandle.WaitOne(timeout)) + { + Dispose(); + throw new SocketException((int)SocketError.TimedOut); + } + _endConnect(asyncResult); + } + finally + { + asyncResult.AsyncWaitHandle.Close(); + } + } + + public void Dispose() => _dispose(); + } +} From f80d5a84fec7b3aecb8b84add1bb5ea91e3d7e00 Mon Sep 17 00:00:00 2001 From: Radek Zikmund <32671551+rzikm@users.noreply.github.com> Date: Tue, 14 Jul 2026 13:07:30 +0200 Subject: [PATCH 08/12] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../src/System/Net/DnsResolverPal.Managed.cs | 2 +- .../tests/FunctionalTests/DnsResolverLoopbackTest.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolverPal.Managed.cs b/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolverPal.Managed.cs index 5d8d58ff9564fa..55bcab4cdcfa40 100644 --- a/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolverPal.Managed.cs +++ b/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolverPal.Managed.cs @@ -66,7 +66,7 @@ public static async Task> ResolveAddresses(IList DnsRecordType.A, AddressFamily.InterNetworkV6 => DnsRecordType.AAAA, - _ => throw new ArgumentException(SR.net_invalid_ip_addr, nameof(addressFamily)), + _ => throw new ArgumentException(SR.net_dns_unsupported_address_family, nameof(addressFamily)), }; using DnsResponse response = await SendQuery(servers, async, name, qtype, cancellationToken).ConfigureAwait(false); diff --git a/src/libraries/System.Net.NameResolution/tests/FunctionalTests/DnsResolverLoopbackTest.cs b/src/libraries/System.Net.NameResolution/tests/FunctionalTests/DnsResolverLoopbackTest.cs index a34cba516c0907..656d3156ae8ea8 100644 --- a/src/libraries/System.Net.NameResolution/tests/FunctionalTests/DnsResolverLoopbackTest.cs +++ b/src/libraries/System.Net.NameResolution/tests/FunctionalTests/DnsResolverLoopbackTest.cs @@ -109,7 +109,7 @@ private static async Task> ResolveNs(bool async, DnsResolver // ---- Address resolution ---- - [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsNotMobile))] + [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsNotMobile), nameof(PlatformDetection.IsNotBrowser), nameof(PlatformDetection.IsNotWasi))] [InlineData(false)] [InlineData(true)] public async Task ResolveAddresses_Unspecified_ReturnsBothV4AndV6(bool async) From ea9b61d59504a6710f8dbff167ab715eb58665f3 Mon Sep 17 00:00:00 2001 From: Radek Zikmund Date: Tue, 14 Jul 2026 18:27:29 +0200 Subject: [PATCH 09/12] Fix build --- .../System.Net.NameResolution/src/System/Net/DnsSocket.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/libraries/System.Net.NameResolution/src/System/Net/DnsSocket.cs b/src/libraries/System.Net.NameResolution/src/System/Net/DnsSocket.cs index 35d1cde7f95851..f428c5f21d8d65 100644 --- a/src/libraries/System.Net.NameResolution/src/System/Net/DnsSocket.cs +++ b/src/libraries/System.Net.NameResolution/src/System/Net/DnsSocket.cs @@ -62,8 +62,6 @@ private sealed class SocketReflection private readonly Action _setReceiveTimeout; private readonly Action _dispose; - [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2075", - Justification = "The Socket members accessed here are preserved by the DynamicDependency attribute.")] [DynamicDependency(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.PublicMethods | DynamicallyAccessedMemberTypes.PublicProperties, "System.Net.Sockets.Socket", "System.Net.Sockets")] private static SocketReflection CreateReflection() From 5dcbe88e0f56248f499f1af23c26f3424e567619 Mon Sep 17 00:00:00 2001 From: Radek Zikmund Date: Wed, 15 Jul 2026 13:59:03 +0200 Subject: [PATCH 10/12] Address review feedback: WASI/Browser test gating and assert reader success - Gate the loopback DNS resolver tests on IsNotBrowser and IsNotWasi in addition to IsNotMobile. The functional test project multi-targets -browser and -wasi where DnsResolverPal is Unsupported, so these socket-based tests would otherwise run and fail there. - Assert success of DnsMessageReader.TryCreate/TryReadQuestion/TryReadRecord in the DnsRecordTypeTests GetAnswerRecord helper so malformed input surfaces clearly instead of failing in less obvious ways. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DnsResolverLoopbackTest.cs | 34 +++++++++---------- .../tests/UnitTests/DnsRecordTypeTests.cs | 6 ++-- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/src/libraries/System.Net.NameResolution/tests/FunctionalTests/DnsResolverLoopbackTest.cs b/src/libraries/System.Net.NameResolution/tests/FunctionalTests/DnsResolverLoopbackTest.cs index 656d3156ae8ea8..e5ccdef2ae023f 100644 --- a/src/libraries/System.Net.NameResolution/tests/FunctionalTests/DnsResolverLoopbackTest.cs +++ b/src/libraries/System.Net.NameResolution/tests/FunctionalTests/DnsResolverLoopbackTest.cs @@ -126,7 +126,7 @@ public async Task ResolveAddresses_Unspecified_ReturnsBothV4AndV6(bool async) Assert.Contains(result.Records, a => a.Address.ToString() == "fd00::1"); } - [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsNotMobile))] + [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsNotMobile), nameof(PlatformDetection.IsNotBrowser), nameof(PlatformDetection.IsNotWasi))] [InlineData(false)] [InlineData(true)] public async Task ResolveAddresses_IPv4Only_ReturnsOnlyV4(bool async) @@ -143,7 +143,7 @@ public async Task ResolveAddresses_IPv4Only_ReturnsOnlyV4(bool async) Assert.Equal("10.0.0.2", record.Address.ToString()); } - [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsNotMobile))] + [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsNotMobile), nameof(PlatformDetection.IsNotBrowser), nameof(PlatformDetection.IsNotWasi))] [InlineData(false)] [InlineData(true)] public async Task ResolveAddresses_IPv6Only_ReturnsOnlyV6(bool async) @@ -159,7 +159,7 @@ public async Task ResolveAddresses_IPv6Only_ReturnsOnlyV6(bool async) Assert.Equal("fd00::1", record.Address.ToString()); } - [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsNotMobile))] + [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsNotMobile), nameof(PlatformDetection.IsNotBrowser), nameof(PlatformDetection.IsNotWasi))] [InlineData(false)] [InlineData(true)] public async Task ResolveAddresses_AddressFamilyV4_QueriesOnlyA(bool async) @@ -174,7 +174,7 @@ public async Task ResolveAddresses_AddressFamilyV4_QueriesOnlyA(bool async) Assert.Equal("192.0.2.7", record.Address.ToString()); } - [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsNotMobile))] + [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsNotMobile), nameof(PlatformDetection.IsNotBrowser), nameof(PlatformDetection.IsNotWasi))] [InlineData(false)] [InlineData(true)] public async Task ResolveAddresses_HasTtl(bool async) @@ -191,7 +191,7 @@ public async Task ResolveAddresses_HasTtl(bool async) $"Unexpected TTL: {record.Ttl}"); } - [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsNotMobile))] + [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsNotMobile), nameof(PlatformDetection.IsNotBrowser), nameof(PlatformDetection.IsNotWasi))] [InlineData(false)] [InlineData(true)] public async Task ResolveAddresses_Nxdomain_ReturnsNxDomain(bool async) @@ -221,7 +221,7 @@ public async Task ResolveAddresses_Nxdomain_ReturnsNxDomain(bool async) #endif } - [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsNotMobile))] + [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsNotMobile), nameof(PlatformDetection.IsNotBrowser), nameof(PlatformDetection.IsNotWasi))] [InlineData(false)] [InlineData(true)] public async Task ResolveAddresses_NoData_ReturnsNoErrorWithEmptyRecords(bool async) @@ -250,7 +250,7 @@ public async Task ResolveAddresses_NoData_ReturnsNoErrorWithEmptyRecords(bool as #endif } - [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsNotMobile))] + [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsNotMobile), nameof(PlatformDetection.IsNotBrowser), nameof(PlatformDetection.IsNotWasi))] [InlineData(false)] [InlineData(true)] public async Task ResolveAddresses_NoData_And_Nxdomain_AreDistinguishable(bool async) @@ -284,7 +284,7 @@ public async Task ResolveAddresses_NoData_And_Nxdomain_AreDistinguishable(bool a // ---- SRV ---- - [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsNotMobile))] + [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsNotMobile), nameof(PlatformDetection.IsNotBrowser), nameof(PlatformDetection.IsNotWasi))] [InlineData(false)] [InlineData(true)] public async Task ResolveSrv_ReturnsRecords(bool async) @@ -309,7 +309,7 @@ public async Task ResolveSrv_ReturnsRecords(bool async) Assert.Equal((ushort)20, s2.Priority); } - [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsNotMobile))] + [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsNotMobile), nameof(PlatformDetection.IsNotBrowser), nameof(PlatformDetection.IsNotWasi))] [InlineData(false)] [InlineData(true)] public async Task ResolveSrv_IncludesAdditionalAddresses(bool async) @@ -334,7 +334,7 @@ public async Task ResolveSrv_IncludesAdditionalAddresses(bool async) Assert.Equal(2, s2.Addresses.Count); } - [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsNotMobile))] + [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsNotMobile), nameof(PlatformDetection.IsNotBrowser), nameof(PlatformDetection.IsNotWasi))] [InlineData(false)] [InlineData(true)] public async Task ResolveSrv_NoAdditionalAddresses(bool async) @@ -352,7 +352,7 @@ public async Task ResolveSrv_NoAdditionalAddresses(bool async) // ---- MX / TXT / CNAME / PTR / NS ---- - [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsNotMobile))] + [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsNotMobile), nameof(PlatformDetection.IsNotBrowser), nameof(PlatformDetection.IsNotWasi))] [InlineData(false)] [InlineData(true)] public async Task ResolveMx_ReturnsRecords(bool async) @@ -372,7 +372,7 @@ public async Task ResolveMx_ReturnsRecords(bool async) Assert.Single(result.Records, m => m.Exchange == "mail2.test" && m.Preference == 20); } - [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsNotMobile))] + [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsNotMobile), nameof(PlatformDetection.IsNotBrowser), nameof(PlatformDetection.IsNotWasi))] [InlineData(false)] [InlineData(true)] public async Task ResolveTxt_ReturnsValues(bool async) @@ -390,7 +390,7 @@ public async Task ResolveTxt_ReturnsValues(bool async) Assert.Contains(result.Records, t => t.Values.Count == 2 && t.Values[0] == "part1" && t.Values[1] == "part2"); } - [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsNotMobile))] + [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsNotMobile), nameof(PlatformDetection.IsNotBrowser), nameof(PlatformDetection.IsNotWasi))] [InlineData(false)] [InlineData(true)] public async Task ResolveCName_ReturnsCanonicalName(bool async) @@ -406,7 +406,7 @@ public async Task ResolveCName_ReturnsCanonicalName(bool async) Assert.Equal("canonical.test", record.CanonicalName); } - [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsNotMobile))] + [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsNotMobile), nameof(PlatformDetection.IsNotBrowser), nameof(PlatformDetection.IsNotWasi))] [InlineData(false)] [InlineData(true)] public async Task ResolvePtr_ReturnsName(bool async) @@ -422,7 +422,7 @@ public async Task ResolvePtr_ReturnsName(bool async) Assert.Equal("host.test", record.Name); } - [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsNotMobile))] + [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsNotMobile), nameof(PlatformDetection.IsNotBrowser), nameof(PlatformDetection.IsNotWasi))] [InlineData(false)] [InlineData(true)] public async Task ResolveNs_ReturnsRecords(bool async) @@ -466,7 +466,7 @@ public async Task CustomServer_DefaultPortZero_IsAccepted(bool async) // ---- Cancellation while a query is in flight ---- - [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsNotMobile))] + [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsNotMobile), nameof(PlatformDetection.IsNotBrowser), nameof(PlatformDetection.IsNotWasi))] public async Task ResolveAddresses_CancellationInFlight_Throws() { using SemaphoreSlim queryReceived = new(0, 1); @@ -497,7 +497,7 @@ public async Task ResolveAddresses_CancellationInFlight_Throws() // ---- Telemetry ---- - [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsNotMobile))] + [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsNotMobile), nameof(PlatformDetection.IsNotBrowser), nameof(PlatformDetection.IsNotWasi))] [InlineData(false)] [InlineData(true)] public async Task ResolveAddresses_RecordsDurationMetric_CoversQueryTime(bool async) diff --git a/src/libraries/System.Net.NameResolution/tests/UnitTests/DnsRecordTypeTests.cs b/src/libraries/System.Net.NameResolution/tests/UnitTests/DnsRecordTypeTests.cs index 559e89243c28c9..20663bd9b290b2 100644 --- a/src/libraries/System.Net.NameResolution/tests/UnitTests/DnsRecordTypeTests.cs +++ b/src/libraries/System.Net.NameResolution/tests/UnitTests/DnsRecordTypeTests.cs @@ -48,9 +48,9 @@ private static byte[] BuildResponse(DnsRecordType type, byte[] rdata, uint ttl = private static DnsRecord GetAnswerRecord(byte[] response) { - DnsMessageReader.TryCreate(response, out var reader); - reader.TryReadQuestion(out _); - reader.TryReadRecord(out var record); + Assert.True(DnsMessageReader.TryCreate(response, out var reader)); + Assert.True(reader.TryReadQuestion(out _)); + Assert.True(reader.TryReadRecord(out var record)); return record; } From 0ea17315d4c07c7cda8c16912c37e0a0b628eb62 Mon Sep 17 00:00:00 2001 From: Radek Zikmund <32671551+rzikm@users.noreply.github.com> Date: Wed, 15 Jul 2026 14:57:59 +0200 Subject: [PATCH 11/12] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../src/System/Net/DnsResolverPal.Managed.cs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolverPal.Managed.cs b/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolverPal.Managed.cs index 55bcab4cdcfa40..f368e788a14297 100644 --- a/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolverPal.Managed.cs +++ b/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolverPal.Managed.cs @@ -381,13 +381,9 @@ private static async Task SendQuery(IList servers, bool { for (int attempt = 0; attempt <= MaxRetries; attempt++) { - if (cancellationToken.IsCancellationRequested) - { - ArrayPool.Shared.Return(responseBuffer); - cancellationToken.ThrowIfCancellationRequested(); - } try { + cancellationToken.ThrowIfCancellationRequested(); int responseLength = async ? await SendUdpQueryAsync(query, server, responseBuffer, cancellationToken).ConfigureAwait(false) : SendUdpQuerySync(query, server, responseBuffer); From 6ebddf0d74613a322ef889b3b598f71f602d3702 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ahmet=20=C4=B0brahim=20Aksoy?= Date: Tue, 21 Jul 2026 16:12:08 +0200 Subject: [PATCH 12/12] Implement DnsResolver for macOS Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f11fb54e-638e-4c9e-ad82-e5d9ff5c20e4 --- .../Common/src/Interop/OSX/Interop.Dnssd.cs | 62 +++ .../src/System.Net.NameResolution.csproj | 52 +- .../src/System/Net/DnsResolverPal.OSX.cs | 500 ++++++++++++++++++ .../DnsResolverLoopbackTest.cs | 16 + .../tests/FunctionalTests/DnsResolverTest.cs | 171 +++++- ...Net.NameResolution.Functional.Tests.csproj | 2 +- 6 files changed, 782 insertions(+), 21 deletions(-) create mode 100644 src/libraries/Common/src/Interop/OSX/Interop.Dnssd.cs create mode 100644 src/libraries/System.Net.NameResolution/src/System/Net/DnsResolverPal.OSX.cs diff --git a/src/libraries/Common/src/Interop/OSX/Interop.Dnssd.cs b/src/libraries/Common/src/Interop/OSX/Interop.Dnssd.cs new file mode 100644 index 00000000000000..3cacb5fa66e9e9 --- /dev/null +++ b/src/libraries/Common/src/Interop/OSX/Interop.Dnssd.cs @@ -0,0 +1,62 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +internal static partial class Interop +{ + internal static unsafe partial class Dnssd + { + internal const uint kDNSServiceFlagsMoreComing = 0x1; + internal const uint kDNSServiceFlagsAdd = 0x2; + internal const uint kDNSServiceFlagsReturnIntermediates = 0x1000; + internal const uint kDNSServiceFlagsTimeout = 0x10000; + + internal const int kDNSServiceErr_NoError = 0; + internal const int kDNSServiceErr_Unknown = -65537; + internal const int kDNSServiceErr_NoSuchName = -65538; + internal const int kDNSServiceErr_NoMemory = -65539; + internal const int kDNSServiceErr_BadParam = -65540; + internal const int kDNSServiceErr_Unsupported = -65544; + internal const int kDNSServiceErr_Refused = -65553; + internal const int kDNSServiceErr_NoSuchRecord = -65554; + internal const int kDNSServiceErr_ServiceNotRunning = -65563; + internal const int kDNSServiceErr_Timeout = -65568; + internal const int kDNSServiceErr_DefunctConnection = -65569; + internal const int kDNSServiceErr_PolicyDenied = -65570; + internal const int kDNSServiceErr_NotPermitted = -65571; + + internal const ushort kDNSServiceClass_IN = 1; + + internal const ushort kDNSServiceType_A = 1; + internal const ushort kDNSServiceType_NS = 2; + internal const ushort kDNSServiceType_CNAME = 5; + internal const ushort kDNSServiceType_PTR = 12; + internal const ushort kDNSServiceType_MX = 15; + internal const ushort kDNSServiceType_TXT = 16; + internal const ushort kDNSServiceType_AAAA = 28; + internal const ushort kDNSServiceType_SRV = 33; + + [LibraryImport(Libraries.libSystem, StringMarshalling = StringMarshalling.Utf8)] + internal static partial int DNSServiceQueryRecord( + out IntPtr sdRef, + uint flags, + uint interfaceIndex, + string fullname, + ushort rrtype, + ushort rrclass, + delegate* unmanaged[Cdecl] callBack, + IntPtr context); + + [LibraryImport(Libraries.libSystem)] + internal static partial int DNSServiceRefSockFD(IntPtr sdRef); + + [LibraryImport(Libraries.libSystem)] + internal static partial int DNSServiceProcessResult(IntPtr sdRef); + + [LibraryImport(Libraries.libSystem)] + internal static partial void DNSServiceRefDeallocate(IntPtr sdRef); + } +} diff --git a/src/libraries/System.Net.NameResolution/src/System.Net.NameResolution.csproj b/src/libraries/System.Net.NameResolution/src/System.Net.NameResolution.csproj index b4f741a9d8e99e..ee248517a0c3fe 100644 --- a/src/libraries/System.Net.NameResolution/src/System.Net.NameResolution.csproj +++ b/src/libraries/System.Net.NameResolution/src/System.Net.NameResolution.csproj @@ -1,7 +1,7 @@ - $(NetCoreAppCurrent)-windows;$(NetCoreAppCurrent)-unix;$(NetCoreAppCurrent)-browser;$(NetCoreAppCurrent)-wasi;$(NetCoreAppCurrent) + $(NetCoreAppCurrent)-windows;$(NetCoreAppCurrent)-unix;$(NetCoreAppCurrent)-osx;$(NetCoreAppCurrent)-browser;$(NetCoreAppCurrent)-wasi;$(NetCoreAppCurrent) true false @@ -86,17 +86,51 @@ Link="Common\Interop\Windows\WinSock\Interop.GetAddrInfoExW.cs" /> - - + - + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -177,7 +219,7 @@ - + diff --git a/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolverPal.OSX.cs b/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolverPal.OSX.cs new file mode 100644 index 00000000000000..de39cc941a6710 --- /dev/null +++ b/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolverPal.OSX.cs @@ -0,0 +1,500 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Buffers.Binary; +using System.Collections.Generic; +using System.Net.Sockets; +using System.Runtime.CompilerServices; +using System.Runtime.ExceptionServices; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Win32.SafeHandles; + +namespace System.Net +{ + // macOS DNS resolver implementation. Queries without explicit servers use + // DNSServiceQueryRecord so macOS resolver policy remains authoritative; queries + // with explicit servers use the unchanged managed PAL overloads. The array overloads + // ensure DnsResolver calls route here first; casting selects the managed IList overload. + internal static partial class DnsResolverPal + { + private const int PollTimeoutMilliseconds = 100; + + public static Task> ResolveAddresses(IPEndPoint[] servers, bool async, string name, AddressFamily addressFamily, CancellationToken cancellationToken) + => servers.Length == 0 + ? Query(servers, async, name, AddressFamilyToQueryType(addressFamily), cancellationToken, TryParseAddress) + : ResolveAddresses((IList)servers, async, name, addressFamily, cancellationToken); + + public static Task> ResolveSrv(IPEndPoint[] servers, bool async, string name, CancellationToken cancellationToken) + => servers.Length == 0 + ? Query(servers, async, name, Interop.Dnssd.kDNSServiceType_SRV, cancellationToken, TryParseSrv) + : ResolveSrv((IList)servers, async, name, cancellationToken); + + public static Task> ResolveMx(IPEndPoint[] servers, bool async, string name, CancellationToken cancellationToken) + => servers.Length == 0 + ? Query(servers, async, name, Interop.Dnssd.kDNSServiceType_MX, cancellationToken, TryParseMx) + : ResolveMx((IList)servers, async, name, cancellationToken); + + public static Task> ResolveTxt(IPEndPoint[] servers, bool async, string name, CancellationToken cancellationToken) + => servers.Length == 0 + ? Query(servers, async, name, Interop.Dnssd.kDNSServiceType_TXT, cancellationToken, TryParseTxt) + : ResolveTxt((IList)servers, async, name, cancellationToken); + + public static Task> ResolveCName(IPEndPoint[] servers, bool async, string name, CancellationToken cancellationToken) + => servers.Length == 0 + ? Query(servers, async, name, Interop.Dnssd.kDNSServiceType_CNAME, cancellationToken, TryParseCName) + : ResolveCName((IList)servers, async, name, cancellationToken); + + public static Task> ResolvePtr(IPEndPoint[] servers, bool async, string name, CancellationToken cancellationToken) + => servers.Length == 0 + ? Query(servers, async, name, Interop.Dnssd.kDNSServiceType_PTR, cancellationToken, TryParsePtr) + : ResolvePtr((IList)servers, async, name, cancellationToken); + + public static Task> ResolveNs(IPEndPoint[] servers, bool async, string name, CancellationToken cancellationToken) + => servers.Length == 0 + ? Query(servers, async, name, Interop.Dnssd.kDNSServiceType_NS, cancellationToken, TryParseNs) + : ResolveNs((IList)servers, async, name, cancellationToken); + + private static ushort AddressFamilyToQueryType(AddressFamily addressFamily) => + addressFamily switch + { + AddressFamily.InterNetwork => Interop.Dnssd.kDNSServiceType_A, + AddressFamily.InterNetworkV6 => Interop.Dnssd.kDNSServiceType_AAAA, + _ => throw new ArgumentException(SR.net_dns_unsupported_address_family, nameof(addressFamily)), + }; + + private static Task> Query( + IPEndPoint[] servers, + bool async, + string name, + ushort queryType, + CancellationToken cancellationToken, + TryParseRecord tryParse) + { + ValidateServers(servers); + + if (name.Contains('\0')) + { + throw new ArgumentException(SR.net_hostname_invalid_character, nameof(name)); + } + + return async + ? Task.Run(() => QueryCore(name, queryType, cancellationToken, tryParse), cancellationToken) + : Task.FromResult(QueryCore(name, queryType, cancellationToken, tryParse)); + } + + private static DnsResult QueryCore( + string name, + ushort queryType, + CancellationToken cancellationToken, + TryParseRecord tryParse) + { + DnsSdQueryResult raw = QueryRecord(name, queryType, cancellationToken); + if (raw.ResponseCode != DnsResponseCode.NoError) + { + return new DnsResult(raw.ResponseCode, null, TimeSpan.Zero); + } + + List records = new(); + foreach (DnsSdRecord rawRecord in raw.Records) + { + if (rawRecord.Type == queryType && tryParse(rawRecord, out TRecord parsed)) + { + records.Add(parsed); + } + } + + return new DnsResult(DnsResponseCode.NoError, records, TimeSpan.Zero); + } + + private static unsafe DnsSdQueryResult QueryRecord(string name, ushort queryType, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + DnsSdQueryState state = new(queryType); + GCHandle stateHandle = GCHandle.Alloc(state); + IntPtr serviceRef = IntPtr.Zero; + + try + { + int status = Interop.Dnssd.DNSServiceQueryRecord( + out serviceRef, + flags: Interop.Dnssd.kDNSServiceFlagsReturnIntermediates | Interop.Dnssd.kDNSServiceFlagsTimeout, + interfaceIndex: 0, + fullname: name, + rrtype: queryType, + rrclass: Interop.Dnssd.kDNSServiceClass_IN, + callBack: &QueryRecordCallback, + context: GCHandle.ToIntPtr(stateHandle)); + + if (status != Interop.Dnssd.kDNSServiceErr_NoError) + { + return DnsSdQueryResult.FromStatus(status); + } + + using SafeDnsServiceHandle dnsService = new(serviceRef); + serviceRef = IntPtr.Zero; + + int fileDescriptor = Interop.Dnssd.DNSServiceRefSockFD(dnsService.DangerousGetHandle()); + if (fileDescriptor < 0) + { + return DnsSdQueryResult.FromStatus(Interop.Dnssd.kDNSServiceErr_DefunctConnection); + } + + using SafeFileHandle fileHandle = new((IntPtr)fileDescriptor, ownsHandle: false); + + while (!state.IsComplete) + { + cancellationToken.ThrowIfCancellationRequested(); + + Interop.Error error = Interop.Sys.Poll(fileHandle, Interop.PollEvents.POLLIN, PollTimeoutMilliseconds, out Interop.PollEvents triggered); + if (error == Interop.Error.EINTR) + { + continue; + } + + if (error != Interop.Error.SUCCESS) + { + return DnsSdQueryResult.FromStatus(Interop.Dnssd.kDNSServiceErr_Unknown); + } + + if ((triggered & (Interop.PollEvents.POLLERR | Interop.PollEvents.POLLHUP | Interop.PollEvents.POLLNVAL)) != 0) + { + return DnsSdQueryResult.FromStatus(Interop.Dnssd.kDNSServiceErr_DefunctConnection); + } + + if ((triggered & Interop.PollEvents.POLLIN) != 0) + { + status = Interop.Dnssd.DNSServiceProcessResult(dnsService.DangerousGetHandle()); + if (status != Interop.Dnssd.kDNSServiceErr_NoError) + { + state.SetError(status); + } + } + } + + return state.ToResult(); + } + finally + { + if (serviceRef != IntPtr.Zero) + { + Interop.Dnssd.DNSServiceRefDeallocate(serviceRef); + } + + stateHandle.Free(); + } + } + +#pragma warning disable CS3016 // Arrays as attribute arguments is not CLS-compliant + [UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })] +#pragma warning restore CS3016 + private static unsafe void QueryRecordCallback( + IntPtr sdRef, + uint flags, + uint interfaceIndex, + int errorCode, + byte* fullname, + ushort rrtype, + ushort rrclass, + ushort rdlen, + void* rdata, + uint ttl, + IntPtr context) + { + DnsSdQueryState? state = null; + try + { + state = (DnsSdQueryState)GCHandle.FromIntPtr(context).Target!; + state.OnRecord(flags, interfaceIndex, errorCode, rrtype, rrclass, rdlen, rdata, ttl); + } + catch (Exception ex) + { + state?.SetException(ex); + } + } + + private delegate bool TryParseRecord(DnsSdRecord record, out TRecord parsed); + + private static bool TryParseAddress(DnsSdRecord record, out AddressRecord parsed) + { + if (record.Data.Length == 4 || record.Data.Length == 16) + { + IPAddress address = new IPAddress(record.Data); + if (address.IsIPv6LinkLocal) + { + address.ScopeId = record.InterfaceIndex; + } + + parsed = new AddressRecord(address, TimeSpan.FromSeconds(record.Ttl)); + return true; + } + + parsed = default; + return false; + } + + private static bool TryParseSrv(DnsSdRecord record, out SrvRecord parsed) + { + ReadOnlySpan data = record.Data; + if (data.Length >= 7 && TryParseDnsName(data.Slice(6), out string target, out _)) + { + parsed = new SrvRecord( + target, + BinaryPrimitives.ReadUInt16BigEndian(data.Slice(4, 2)), + BinaryPrimitives.ReadUInt16BigEndian(data.Slice(0, 2)), + BinaryPrimitives.ReadUInt16BigEndian(data.Slice(2, 2)), + TimeSpan.FromSeconds(record.Ttl), + // DNSServiceQueryRecord exposes only the queried record's rdata, not + // additional-section glue A/AAAA records. + null); + return true; + } + + parsed = default; + return false; + } + + private static bool TryParseMx(DnsSdRecord record, out MxRecord parsed) + { + ReadOnlySpan data = record.Data; + if (data.Length >= 3 && TryParseDnsName(data.Slice(2), out string exchange, out _)) + { + parsed = new MxRecord(exchange, BinaryPrimitives.ReadUInt16BigEndian(data.Slice(0, 2)), TimeSpan.FromSeconds(record.Ttl)); + return true; + } + + parsed = default; + return false; + } + + private static bool TryParseTxt(DnsSdRecord record, out TxtRecord parsed) + { + ReadOnlySpan data = record.Data; + List values = new(); + int offset = 0; + + while (offset < data.Length) + { + int length = data[offset++]; + if (length > data.Length - offset) + { + parsed = default; + return false; + } + + values.Add(Encoding.UTF8.GetString(data.Slice(offset, length))); + offset += length; + } + + parsed = new TxtRecord(values, TimeSpan.FromSeconds(record.Ttl)); + return true; + } + + private static bool TryParseCName(DnsSdRecord record, out CNameRecord parsed) + { + if (TryParseDnsName(record.Data, out string name, out _)) + { + parsed = new CNameRecord(name, TimeSpan.FromSeconds(record.Ttl)); + return true; + } + + parsed = default; + return false; + } + + private static bool TryParsePtr(DnsSdRecord record, out PtrRecord parsed) + { + if (TryParseDnsName(record.Data, out string name, out _)) + { + parsed = new PtrRecord(name, TimeSpan.FromSeconds(record.Ttl)); + return true; + } + + parsed = default; + return false; + } + + private static bool TryParseNs(DnsSdRecord record, out NsRecord parsed) + { + if (TryParseDnsName(record.Data, out string name, out _)) + { + parsed = new NsRecord(name, TimeSpan.FromSeconds(record.Ttl)); + return true; + } + + parsed = default; + return false; + } + + private static bool TryParseDnsName(ReadOnlySpan data, out string name, out int bytesConsumed) + { + StringBuilder builder = new(); + int offset = 0; + + while (offset < data.Length) + { + byte length = data[offset++]; + if (length == 0) + { + name = builder.Length == 0 ? "." : builder.ToString(); + bytesConsumed = offset; + return true; + } + + if ((length & 0xC0) != 0 || length > 63 || length > data.Length - offset) + { + break; + } + + if (builder.Length != 0) + { + builder.Append('.'); + } + + builder.Append(Encoding.UTF8.GetString(data.Slice(offset, length))); + offset += length; + } + + name = string.Empty; + bytesConsumed = 0; + return false; + } + + private readonly struct DnsSdRecord + { + public ushort Type { get; } + public byte[] Data { get; } + public uint Ttl { get; } + public uint InterfaceIndex { get; } + + public DnsSdRecord(ushort type, byte[] data, uint ttl, uint interfaceIndex) + { + Type = type; + Data = data; + Ttl = ttl; + InterfaceIndex = interfaceIndex; + } + } + + private readonly struct DnsSdQueryResult + { + public DnsResponseCode ResponseCode { get; } + public IReadOnlyList Records { get; } + + public DnsSdQueryResult(DnsResponseCode responseCode, IReadOnlyList records) + { + ResponseCode = responseCode; + Records = records; + } + + public static DnsSdQueryResult FromStatus(int status) => + new(MapDnsServiceErrorToResponseCode(status), Array.Empty()); + } + + private sealed unsafe class DnsSdQueryState + { + private readonly ushort _requestedType; + private readonly List _records = new(); + private int _status = Interop.Dnssd.kDNSServiceErr_NoError; + private Exception? _exception; + + public DnsSdQueryState(ushort requestedType) + { + _requestedType = requestedType; + } + + public bool IsComplete { get; private set; } + + public void SetError(int status) + { + _status = status; + IsComplete = true; + } + + public void SetException(Exception exception) + { + _exception ??= exception; + IsComplete = true; + } + + public void OnRecord(uint flags, uint interfaceIndex, int errorCode, ushort rrtype, ushort rrclass, ushort rdlen, void* rdata, uint ttl) + { + if (errorCode != Interop.Dnssd.kDNSServiceErr_NoError) + { + SetError(errorCode); + return; + } + + if (rrclass != Interop.Dnssd.kDNSServiceClass_IN || rrtype != _requestedType) + { + return; + } + + if ((flags & Interop.Dnssd.kDNSServiceFlagsAdd) != 0 && rdata != null) + { + // Best-effort TTL: DNS-SD may return the original TTL for cached answers. + _records.Add(new DnsSdRecord(rrtype, new ReadOnlySpan(rdata, rdlen).ToArray(), ttl, interfaceIndex)); + } + + if ((flags & Interop.Dnssd.kDNSServiceFlagsMoreComing) == 0) + { + IsComplete = true; + } + } + + public DnsSdQueryResult ToResult() + { + Exception? exception = _exception; + if (exception is not null) + { + ExceptionDispatchInfo.Throw(exception); + } + + DnsResponseCode responseCode = MapDnsServiceErrorToResponseCode(_status); + + return new DnsSdQueryResult(responseCode, _records); + } + } + + private sealed class SafeDnsServiceHandle : SafeHandle + { + public SafeDnsServiceHandle(IntPtr handle) + : base(IntPtr.Zero, ownsHandle: true) + { + SetHandle(handle); + } + + public override bool IsInvalid => handle == IntPtr.Zero; + + protected override bool ReleaseHandle() + { + Interop.Dnssd.DNSServiceRefDeallocate(handle); + return true; + } + } + + private static DnsResponseCode MapDnsServiceErrorToResponseCode(int status) => + status switch + { + Interop.Dnssd.kDNSServiceErr_NoError => DnsResponseCode.NoError, + Interop.Dnssd.kDNSServiceErr_NoSuchName => DnsResponseCode.NxDomain, + // DNSServiceQueryRecord reports NODATA as NoSuchRecord, and mDNSResponder + // also uses that code for NXDOMAIN in practice. The callback does not expose + // the authority section needed to distinguish them, so surface the collapsed + // negative result as a successful response with no records. + Interop.Dnssd.kDNSServiceErr_NoSuchRecord => DnsResponseCode.NoError, + // With kDNSServiceFlagsTimeout, DNSServiceQueryRecord uses Timeout as the + // terminal callback when the query times out. + Interop.Dnssd.kDNSServiceErr_Timeout => DnsResponseCode.ServerFailure, + Interop.Dnssd.kDNSServiceErr_BadParam => DnsResponseCode.FormatError, + Interop.Dnssd.kDNSServiceErr_Unsupported => DnsResponseCode.NotImplemented, + Interop.Dnssd.kDNSServiceErr_Refused => DnsResponseCode.Refused, + Interop.Dnssd.kDNSServiceErr_PolicyDenied => DnsResponseCode.Refused, + Interop.Dnssd.kDNSServiceErr_NotPermitted => DnsResponseCode.Refused, + _ => DnsResponseCode.ServerFailure, + }; + } +} diff --git a/src/libraries/System.Net.NameResolution/tests/FunctionalTests/DnsResolverLoopbackTest.cs b/src/libraries/System.Net.NameResolution/tests/FunctionalTests/DnsResolverLoopbackTest.cs index e5ccdef2ae023f..97bca38c913d4c 100644 --- a/src/libraries/System.Net.NameResolution/tests/FunctionalTests/DnsResolverLoopbackTest.cs +++ b/src/libraries/System.Net.NameResolution/tests/FunctionalTests/DnsResolverLoopbackTest.cs @@ -143,6 +143,22 @@ public async Task ResolveAddresses_IPv4Only_ReturnsOnlyV4(bool async) Assert.Equal("10.0.0.2", record.Address.ToString()); } + [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsOSX))] + [InlineData(false)] + [InlineData(true)] + public async Task CustomServer_OSX_UsesManagedResolverWithoutSystemFallback(bool async) + { + const string Name = "www.microsoft.com"; + _server.AddResponse(Name, DnsRecordType.A, b => b.Answer(new byte[] { 192, 0, 2, 42 }, ttl: 120)); + + DnsResult result = + await ResolveAddresses(async, Resolver, Name, AddressFamily.InterNetwork); + + Assert.Equal(DnsResponseCode.NoError, result.ResponseCode); + AddressRecord record = Assert.Single(result.Records); + Assert.Equal(IPAddress.Parse("192.0.2.42"), record.Address); + } + [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsNotMobile), nameof(PlatformDetection.IsNotBrowser), nameof(PlatformDetection.IsNotWasi))] [InlineData(false)] [InlineData(true)] diff --git a/src/libraries/System.Net.NameResolution/tests/FunctionalTests/DnsResolverTest.cs b/src/libraries/System.Net.NameResolution/tests/FunctionalTests/DnsResolverTest.cs index bde5ebc150fc1d..2222d1ab5d9c53 100644 --- a/src/libraries/System.Net.NameResolution/tests/FunctionalTests/DnsResolverTest.cs +++ b/src/libraries/System.Net.NameResolution/tests/FunctionalTests/DnsResolverTest.cs @@ -1,7 +1,9 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics; using System.Net.Sockets; +using System.Reflection; using System.Threading; using System.Threading.Tasks; using System.Linq; @@ -21,6 +23,8 @@ public class DnsResolverTest private const string TestNsHost = "microsoft.com"; private const string NonExistentHost = "this-name-definitely-does-not-exist.dotnet-test.invalid"; + public static bool IsWindowsOrOSX => PlatformDetection.IsWindows || PlatformDetection.IsOSX; + // ---- Cross-platform argument-validation tests ---- [Fact] @@ -71,6 +75,19 @@ public async Task DnsResolver_EmptyName_Throws() Assert.Throws(() => r.ResolveAddresses(string.Empty)); } + [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsOSX))] + [InlineData("\0")] + [InlineData("\0host")] + [InlineData("host\0")] + [InlineData("ho\0st")] + [InlineData("microsoft.com\0.invalid")] + public async Task DnsResolver_NameContainsNull_ThrowsArgumentException(string name) + { + using DnsResolver r = new DnsResolver(); + await Assert.ThrowsAsync(() => r.ResolveAddressesAsync(name)); + Assert.Throws(() => r.ResolveAddresses(name)); + } + [Fact] public async Task DnsResolver_Disposed_Throws() { @@ -99,6 +116,9 @@ public async Task DnsResolver_DisposeAsync_ThrowsOnUse() private static async Task> ResolveAddresses(bool async, DnsResolver resolver, string name, AddressFamily addressFamily = AddressFamily.Unspecified) => async ? await resolver.ResolveAddressesAsync(name, addressFamily) : resolver.ResolveAddresses(name, addressFamily); + private static async Task> ResolveSrv(bool async, DnsResolver resolver, string name) + => async ? await resolver.ResolveSrvAsync(name) : resolver.ResolveSrv(name); + private static async Task> ResolveMx(bool async, DnsResolver resolver, string name) => async ? await resolver.ResolveMxAsync(name) : resolver.ResolveMx(name); @@ -119,7 +139,7 @@ private static async Task> Static_ResolveAddresses(bool // ---- Windows network tests (require outbound DNS) ---- - [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsWindows))] + [ConditionalFact(typeof(DnsResolverTest), nameof(IsWindowsOrOSX))] public async Task DnsResolver_PreCanceledToken_ReturnsCanceled() { using DnsResolver r = new DnsResolver(); @@ -158,7 +178,7 @@ public async Task ResolveAddresses_SynchronouslyCompletingQuery_DoesNotHang(bool } } - [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsWindows))] + [ConditionalTheory(typeof(DnsResolverTest), nameof(IsWindowsOrOSX))] [InlineData(false)] [InlineData(true)] [OuterLoop] @@ -175,7 +195,7 @@ public async Task ResolveAddresses_KnownName_ReturnsRecords(bool async) } } - [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsWindows))] + [ConditionalTheory(typeof(DnsResolverTest), nameof(IsWindowsOrOSX))] [InlineData(false)] [InlineData(true)] [OuterLoop] @@ -184,13 +204,30 @@ public async Task ResolveAddresses_IPv4Only_ReturnsOnlyIPv4(bool async) using DnsResolver r = new DnsResolver(); DnsResult result = await ResolveAddresses(async, r, TestHost, AddressFamily.InterNetwork); Assert.Equal(DnsResponseCode.NoError, result.ResponseCode); + Assert.NotEmpty(result.Records); foreach (AddressRecord rec in result.Records) { Assert.Equal(AddressFamily.InterNetwork, rec.Address.AddressFamily); } } - [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsWindows))] + [ConditionalTheory(typeof(DnsResolverTest), nameof(IsWindowsOrOSX))] + [InlineData(false)] + [InlineData(true)] + [OuterLoop] + public async Task ResolveAddresses_CNameChain_WaitsForAddressRecords(bool async) + { + using DnsResolver resolver = new(); + DnsResult result = + await ResolveAddresses(async, resolver, TestCNameHost, AddressFamily.InterNetwork); + + Assert.Equal(DnsResponseCode.NoError, result.ResponseCode); + Assert.NotEmpty(result.Records); + Assert.All(result.Records, record => + Assert.Equal(AddressFamily.InterNetwork, record.Address.AddressFamily)); + } + + [ConditionalTheory(typeof(DnsResolverTest), nameof(IsWindowsOrOSX))] [InlineData(false)] [InlineData(true)] [OuterLoop] @@ -198,11 +235,115 @@ public async Task ResolveAddresses_NonExistent_ReturnsNxDomain(bool async) { using DnsResolver r = new DnsResolver(); DnsResult result = await ResolveAddresses(async, r, NonExistentHost); - Assert.Equal(DnsResponseCode.NxDomain, result.ResponseCode); + // DNSServiceQueryRecord reports both NXDOMAIN and NODATA as NoSuchRecord, so + // the macOS PAL can only surface the collapsed negative response as NoError. + DnsResponseCode expected = PlatformDetection.IsOSX ? DnsResponseCode.NoError : DnsResponseCode.NxDomain; + Assert.Equal(expected, result.ResponseCode); Assert.Empty(result.Records); } - [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsWindows))] + [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsOSX))] + [InlineData(false)] + [InlineData(true)] + [OuterLoop] + public async Task ResolveAddresses_NonExistent_CompletesPromptly(bool async) + { + using DnsResolver resolver = new(); + string hostName = $"{Guid.NewGuid():N}.{NonExistentHost}"; + Task> query = async + ? resolver.ResolveAddressesAsync(hostName) + : Task.Run(() => resolver.ResolveAddresses(hostName)); + + DnsResult result = await query.WaitAsync(TimeSpan.FromSeconds(10)); + + Assert.Equal(DnsResponseCode.NoError, result.ResponseCode); + Assert.Empty(result.Records); + } + + [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsOSX))] + [InlineData("2001:db8::1", 0)] + [InlineData("fe80::1", 42)] + public void DnsSdAddressParsing_AppliesInterfaceIndexOnlyToLinkLocalIPv6(string addressString, long expectedScopeId) + { + const uint InterfaceIndex = 42; + + Type palType = typeof(DnsResolver).Assembly.GetType("System.Net.DnsResolverPal", throwOnError: true)!; + Type recordType = palType.GetNestedType("DnsSdRecord", BindingFlags.NonPublic)!; + ConstructorInfo? constructor = recordType.GetConstructor( + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, + binder: null, + [typeof(ushort), typeof(byte[]), typeof(uint), typeof(uint)], + modifiers: null); + Assert.NotNull(constructor); + IPAddress address = IPAddress.Parse(addressString); + object dnsSdRecord = constructor.Invoke([(ushort)DnsRecordType.AAAA, address.GetAddressBytes(), (uint)60, InterfaceIndex]); + + MethodInfo parser = palType.GetMethod("TryParseAddress", BindingFlags.Static | BindingFlags.NonPublic)!; + object?[] arguments = [dnsSdRecord, null]; + + Assert.True((bool)parser.Invoke(null, arguments)!); + AddressRecord record = Assert.IsType(arguments[1]); + Assert.Equal(expectedScopeId, record.Address.ScopeId); + } + + [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsOSX))] + [InlineData("TryParseMx")] + [InlineData("TryParseSrv")] + public void DnsSdRecordParsing_RootTarget_ReturnsDot(string parserName) + { + DnsRecordType recordTypeValue = parserName switch + { + "TryParseMx" => DnsRecordType.MX, + "TryParseSrv" => DnsRecordType.SRV, + _ => throw new UnreachableException(), + }; + byte[] data = recordTypeValue switch + { + DnsRecordType.MX => [0, 0, 0], + DnsRecordType.SRV => [0, 0, 0, 0, 0, 0, 0], + _ => throw new UnreachableException(), + }; + + Type palType = typeof(DnsResolver).Assembly.GetType("System.Net.DnsResolverPal", throwOnError: true)!; + Type recordType = palType.GetNestedType("DnsSdRecord", BindingFlags.NonPublic)!; + ConstructorInfo constructor = recordType.GetConstructor( + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, + binder: null, + [typeof(ushort), typeof(byte[]), typeof(uint), typeof(uint)], + modifiers: null)!; + object dnsSdRecord = constructor.Invoke([(ushort)recordTypeValue, data, (uint)60, (uint)0]); + + MethodInfo parser = palType.GetMethod(parserName, BindingFlags.Static | BindingFlags.NonPublic)!; + object?[] arguments = [dnsSdRecord, null]; + + Assert.True((bool)parser.Invoke(null, arguments)!); + string parsedName = arguments[1] switch + { + MxRecord mx => mx.Exchange, + SrvRecord srv => srv.Target, + _ => throw new UnreachableException(), + }; + Assert.Equal(".", parsedName); + } + + [ConditionalTheory(typeof(DnsResolverTest), nameof(IsWindowsOrOSX))] + [InlineData(false)] + [InlineData(true)] + [OuterLoop] + public async Task ResolveSrv_KnownName_ReturnsRecords(bool async) + { + using DnsResolver r = new DnsResolver(); + DnsResult result = await ResolveSrv(async, r, TestSrv); + Assert.Equal(DnsResponseCode.NoError, result.ResponseCode); + Assert.NotEmpty(result.Records); + foreach (SrvRecord rec in result.Records) + { + Assert.False(string.IsNullOrEmpty(rec.Target)); + Assert.NotEqual((ushort)0, rec.Port); + } + } + + [ConditionalTheory(typeof(DnsResolverTest), nameof(IsWindowsOrOSX))] [InlineData(false)] [InlineData(true)] [OuterLoop] @@ -218,7 +359,7 @@ public async Task ResolveMx_KnownName_ReturnsRecords(bool async) } } - [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsWindows))] + [ConditionalTheory(typeof(DnsResolverTest), nameof(IsWindowsOrOSX))] [InlineData(false)] [InlineData(true)] [OuterLoop] @@ -234,7 +375,7 @@ public async Task ResolveTxt_KnownName_ReturnsRecords(bool async) } } - [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsWindows))] + [ConditionalTheory(typeof(DnsResolverTest), nameof(IsWindowsOrOSX))] [InlineData(false)] [InlineData(true)] [OuterLoop] @@ -243,14 +384,14 @@ public async Task ResolveCName_KnownName_ReturnsRecord(bool async) using DnsResolver r = new DnsResolver(); DnsResult result = await ResolveCName(async, r, TestCNameHost); Assert.Equal(DnsResponseCode.NoError, result.ResponseCode); - // CNAME may or may not exist for the target; at minimum the call should succeed. - if (result.Records.Count > 0) + Assert.NotEmpty(result.Records); + foreach (CNameRecord rec in result.Records) { - Assert.False(string.IsNullOrEmpty(result.Records[0].CanonicalName)); + Assert.False(string.IsNullOrEmpty(rec.CanonicalName)); } } - [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsWindows))] + [ConditionalTheory(typeof(DnsResolverTest), nameof(IsWindowsOrOSX))] [InlineData(false)] [InlineData(true)] [OuterLoop] @@ -266,7 +407,7 @@ public async Task ResolveNs_KnownName_ReturnsRecords(bool async) } } - [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsWindows))] + [ConditionalTheory(typeof(DnsResolverTest), nameof(IsWindowsOrOSX))] [InlineData(false)] [InlineData(true)] [OuterLoop] @@ -279,7 +420,7 @@ public async Task ResolvePtr_ByIPAddress_ReturnsRecord(bool async) Assert.False(string.IsNullOrEmpty(result.Records[0].Name)); } - [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsWindows))] + [ConditionalTheory(typeof(DnsResolverTest), nameof(IsWindowsOrOSX))] [InlineData(false)] [InlineData(true)] [OuterLoop] @@ -346,7 +487,7 @@ public void DnsResolver_CustomServers_MixedAddressFamilies_ThrowsArgumentExcepti // ---- Reverse-arpa name building (covers both IPv4 and IPv6 paths used by ResolvePtr(IPAddress)) ---- - [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsWindows))] + [ConditionalTheory(typeof(DnsResolverTest), nameof(IsWindowsOrOSX))] [InlineData(false)] [InlineData(true)] [OuterLoop] diff --git a/src/libraries/System.Net.NameResolution/tests/FunctionalTests/System.Net.NameResolution.Functional.Tests.csproj b/src/libraries/System.Net.NameResolution/tests/FunctionalTests/System.Net.NameResolution.Functional.Tests.csproj index 577477373c2aa9..a68977a99cf952 100644 --- a/src/libraries/System.Net.NameResolution/tests/FunctionalTests/System.Net.NameResolution.Functional.Tests.csproj +++ b/src/libraries/System.Net.NameResolution/tests/FunctionalTests/System.Net.NameResolution.Functional.Tests.csproj @@ -1,6 +1,6 @@ - $(NetCoreAppCurrent)-windows;$(NetCoreAppCurrent)-unix;$(NetCoreAppCurrent)-browser;$(NetCoreAppCurrent)-wasi + $(NetCoreAppCurrent)-windows;$(NetCoreAppCurrent)-unix;$(NetCoreAppCurrent)-osx;$(NetCoreAppCurrent)-browser;$(NetCoreAppCurrent)-wasi true true true