File: System\Net\DnsEncodedName.cs
Web Access
Project: src\runtime\src\libraries\System.Net.NameResolution\src\System.Net.NameResolution.csproj (System.Net.NameResolution)
// 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<byte> _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<byte> 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<byte> 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<char> name,
            Span<byte> 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.EndsWith('.'))
            {
                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<byte> 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<byte> label = body.Slice(labelStart, labelLen);
                if (!IsValidLabel(label))
                {
                    return OperationStatus.InvalidData;
                }

                if (!isAce)
                {
                    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<char> 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.EndsWith('.'))
            {
                name = name[..^1];
            }

            DnsLabelEnumerator enumerator = EnumerateLabels();
            bool first = true;

            while (enumerator.MoveNext())
            {
                ReadOnlySpan<byte> label = enumerator.Current;

                if (!first)
                {
                    // Expect a dot separator between labels.
                    if (!name.StartsWith('.'))
                    {
                        return false;
                    }
                    name = name.Slice(1);
                }
                first = false;

                if (label.Length > name.Length)
                {
                    return false;
                }

                if (!Ascii.EqualsIgnoreCase(label, name.Slice(0, label.Length)))
                {
                    return false;
                }
                name = name.Slice(label.Length);
            }

            return name.IsEmpty;
        }

        // 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<char> 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<char> ascii = stackalloc char[MaxEncodedLength + 1];
            if (!TryDecodeAscii(ascii, out int asciiWritten))
            {
                return false;
            }

            try
            {
                if (s_idnMapping.TryGetUnicode(ascii[..asciiWritten], destination, out charsWritten))
                {
                    return true;
                }
            }
            catch (ArgumentException)
            {
                // IDN conversion failed, fall through to ACE form.
            }

            if (asciiWritten <= destination.Length)
            {
                ascii[..asciiWritten].CopyTo(destination);
                charsWritten = asciiWritten;
                return true;
            }

            charsWritten = 0;
            return false;
        }

        private static bool IsAceLabel(ReadOnlySpan<byte> label)
        {
            return label.Length >= 4 &&
                   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<char> chars = stackalloc char[MaxEncodedLength + 1];
                bool success = TryDecode(chars, out int charsWritten);
                Debug.Assert(success);
                return charsWritten;
            }

            int length = 0;
            bool first = true;

            foreach (ReadOnlySpan<byte> 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);

        // Copies the flat wire-format encoding of this name to the destination buffer,
        // expanding compression pointers if present.
        internal bool TryCopyEncodedTo(Span<byte> destination, out int bytesWritten)
        {
            bytesWritten = 0;

            if (!_hasPointers)
            {
                // Fast path: _buffer is sliced to exactly the encoded bytes starting at _offset.
                ReadOnlySpan<byte> 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<byte> 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<char> chars = stackalloc char[MaxEncodedLength + 1];
            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<char> destination, out int charsWritten)
        {
            charsWritten = 0;
            DnsLabelEnumerator enumerator = EnumerateLabels();
            bool first = true;

            while (enumerator.MoveNext())
            {
                ReadOnlySpan<byte> label = enumerator.Current;

                if (!first)
                {
                    if (charsWritten >= destination.Length)
                    {
                        return false;
                    }
                    destination[charsWritten] = '.';
                    charsWritten++;
                }
                first = false;

                if (charsWritten + label.Length > destination.Length)
                {
                    return false;
                }

                // Response labels are only validated structurally (validateContent: false), so a
                // label may contain bytes outside the ASCII range. Widen each byte directly
                // instead of using Ascii.ToUtf16, whose result would be undefined for such input.
                Span<char> labelChars = destination.Slice(charsWritten, label.Length);
                for (int i = 0; i < label.Length; i++)
                {
                    labelChars[i] = (char)label[i];
                }
                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<byte> 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, 2 bytes with highest two bits set.
                    if (pos + 1 >= buffer.Length)
                    {
                        return false; // truncated pointer
                    }

                    // 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;
                        foundWireEnd = true;
                        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;
                    }
                    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 + 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<byte> 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<byte> 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<byte> label)
        {
            return label.Length > 0 &&
                   label.Length <= 63 &&
                   label[0] != (byte)'-' &&
                   label[^1] != (byte)'-' &&
                   !label.ContainsAnyExcept(s_ldhBytes);
        }
    }

    // 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<byte> _buffer;
        private int _pos;
        private ReadOnlySpan<byte> _current;

        internal DnsLabelEnumerator(ReadOnlySpan<byte> buffer, int offset)
        {
            _buffer = buffer;
            _pos = offset;
            _current = default;
        }

        public readonly ReadOnlySpan<byte> 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;
    }
}