Skip to content

IPv4 ↔ IPv6 Converter — C# source

Convert between IPv4 and IPv6 addresses both ways. Parse and validate addresses, expand and compress IPv6 to its canonical RFC 5952 form, map an IPv4 into IPv4-mapped and IPv4-compatible IPv6 (or any custom /96 prefix), and extract an embedded IPv4 back out.

This is the C# implementation — the same logic the interactive tool runs, in a shareable, citable form.

// ip-converter — IPv4 ↔ IPv6 conversion (polyglot showcase: C#)
//
// Language: C# (C# 12 / .NET 8, standard library only)
// Source:   CosmoDev polyglot showcase port of the ip-converter tool,
//           ported from src/lib/ip-converter.ts (the canonical TypeScript
//           implementation).
// License:  display source — part of CosmoDev's polyglot tool pages.
//
// Pure, deterministic IPv4/IPv6 address conversion logic. Every parse
// function returns null (or null for the string renderers) on invalid input
// rather than throwing, so the UI can show a graceful error. IPv6 text
// follows RFC 5952: lowercase hex, no leading zeros, the single longest run
// of zero groups collapsed to "::", and a dotted-decimal tail only for
// IPv4-mapped ("::ffff:") addresses.
//
// The types stay precise the way the Rust port's do: octets are byte, IPv6
// 16-bit groups are ushort. The TS regex guards are hand-rolled as length +
// charset checks (char.IsAsciiHexDigit / digit range), mirroring the Rust
// port; no System.Text.RegularExpressions needed.

using System;
using System.Collections.Generic;
using System.Linq;

namespace CosmoDev.Polyglot;

/// <summary>
/// Embedding family for placing an IPv4 quad inside an IPv6 address.
/// </summary>
public enum EmbedMode
{
    /// <summary>::ffff:a.b.c.d — the modern, non-deprecated IPv4-mapped form (default).</summary>
    Mapped,

    /// <summary>::a.b.c.d — the deprecated IPv4-compatible form.</summary>
    Compatible,
}

/// <summary>
/// Options for embedding an IPv4 octet quad into an IPv6 address.
/// </summary>
public sealed class Ipv4ToIpv6Options
{
    /// <summary>Embedding family. Ignored when <see cref="Prefix"/> is set.</summary>
    public EmbedMode Mode { get; init; } = EmbedMode.Mapped;

    /// <summary>
    /// Optional custom high-96-bit prefix (a valid IPv6 string; its first 6
    /// groups are used and its low 32 bits are overwritten by the IPv4). e.g.
    /// "64:ff9b::" yields a NAT64-style 64:ff9b::a.b.c.d. Overrides
    /// <see cref="Mode"/>.
    /// </summary>
    public string? Prefix { get; init; }
}

/// <summary>
/// Pure IPv4/IPv6 address conversion logic, ported from src/lib/ip-converter.ts.
/// </summary>
public static class IpConverter
{
    /// <summary>
    /// A valid IPv6 group token: 1-4 hex digits, no sign, no underscores.
    /// (Equivalent to the TS /^[0-9a-fA-F]{1,4}$/ regex.)
    /// </summary>
    private static bool IsHexGroup(string s) =>
        s.Length is >= 1 and <= 4 && s.All(char.IsAsciiHexDigit);

    /// <summary>
    /// A valid IPv4 octet token: 1-3 decimal digits. Range is enforced
    /// separately. (Equivalent to the TS /^\d{1,3}$/ regex.)
    /// </summary>
    private static bool IsDec3(string s) =>
        s.Length is >= 1 and <= 3 && s.All(c => c is >= '0' and <= '9');

    /// <summary>Lowercase hex for one 16-bit group, with no leading zeros.</summary>
    private static string HexGroup(ushort v) => Convert.ToString(v, 16);

    /// <summary>
    /// Count non-overlapping occurrences of "::" — used to enforce the
    /// at-most-one-compression rule without a regex.
    /// </summary>
    private static int CountDoubleColon(string s)
    {
        int count = 0;
        int i = s.IndexOf("::", StringComparison.Ordinal);
        while (i >= 0)
        {
            count++;
            i = s.IndexOf("::", i + 2, StringComparison.Ordinal);
        }
        return count;
    }

    /// <summary>
    /// Parse a dotted-decimal IPv4 string into four octets, validating each is
    /// 0-255. Returns null for anything that is not exactly four numeric
    /// octets in range.
    /// </summary>
    public static byte[]? ParseIpv4(string s)
    {
        // String.Split keeps interior and trailing empty tokens, mirroring the
        // Rust/Python split semantics this parser relies on.
        string[] parts = s.Trim().Split('.');
        if (parts.Length != 4)
            return null;

        byte[] octets = new byte[4];
        for (int i = 0; i < 4; i++)
        {
            if (!IsDec3(parts[i]))
                return null;
            // IsDec3 guarantees digits only, so the base-10 parse cannot fail.
            int n = int.Parse(parts[i]);
            if (n > 255)
                return null;
            octets[i] = (byte)n;
        }
        return octets;
    }

    /// <summary>
    /// Render four octets as "a.b.c.d". (The octet type is byte, which
    /// already guarantees range, but the signature keeps the symmetry with
    /// the other ports.)
    /// </summary>
    public static string Ipv4ToString(byte[] octets) =>
        $"{octets[0]}.{octets[1]}.{octets[2]}.{octets[3]}";

    /// <summary>
    /// Parse an IPv6 string (with "::" compression, hex groups, and an
    /// optional dotted-decimal IPv4 tail for mapped/compatible forms) into
    /// eight 16-bit groups. Returns null on any malformed input — never
    /// throws.
    /// </summary>
    public static ushort[]? ParseIpv6(string s)
    {
        string input = s.Trim();
        if (input.Length == 0)
            return null;
        // At most one "::" run is legal; reject ambiguous double-compression.
        if (CountDoubleColon(input) > 1)
            return null;

        int dc = input.IndexOf("::", StringComparison.Ordinal);
        if (dc >= 0)
        {
            string before = input[..dc];
            string after = input[(dc + 2)..];
            string[] headTokens = before.Length == 0 ? Array.Empty<string>() : before.Split(':');
            string[] tailTokens = after.Length == 0 ? Array.Empty<string>() : after.Split(':');

            List<ushort> head = new();
            foreach (string g in headTokens)
            {
                if (!IsHexGroup(g))
                    return null;
                head.Add(Convert.ToUInt16(g, 16));
            }

            List<ushort> tail = new();
            for (int i = 0; i < tailTokens.Length; i++)
            {
                string g = tailTokens[i];
                // A dotted-quad IPv4 tail is permitted only in the final slot,
                // where it contributes two groups (high octet pair, low octet pair).
                if (i == tailTokens.Length - 1 && g.Contains('.'))
                {
                    byte[]? oct = ParseIpv4(g);
                    if (oct is null)
                        return null;
                    tail.Add((ushort)((oct[0] << 8) | oct[1]));
                    tail.Add((ushort)((oct[2] << 8) | oct[3]));
                }
                else
                {
                    if (!IsHexGroup(g))
                        return null;
                    tail.Add(Convert.ToUInt16(g, 16));
                }
            }

            int total = head.Count + tail.Count;
            if (total >= 8)
                return null; // "::" must elide at least one group.
            ushort[] groups = new ushort[8];
            for (int k = 0; k < head.Count; k++)
                groups[k] = head[k];
            // The middle [head.Count .. 8 - tail.Count] stays zero — that is
            // the elided run "::" stands in for.
            for (int k = 0; k < tail.Count; k++)
                groups[8 - tail.Count + k] = tail[k];
            return groups;
        }

        // No compression: split on ':' and parse, allowing a dotted-quad only
        // in the last slot. The result must be exactly eight groups.
        string[] tokens = input.Split(':');
        List<ushort> groups2 = new();
        for (int i = 0; i < tokens.Length; i++)
        {
            string g = tokens[i];
            if (i == tokens.Length - 1 && g.Contains('.'))
            {
                byte[]? oct = ParseIpv4(g);
                if (oct is null)
                    return null;
                groups2.Add((ushort)((oct[0] << 8) | oct[1]));
                groups2.Add((ushort)((oct[2] << 8) | oct[3]));
            }
            else
            {
                if (!IsHexGroup(g))
                    return null;
                groups2.Add(Convert.ToUInt16(g, 16));
            }
        }
        return groups2.Count == 8 ? groups2.ToArray() : null;
    }

    /// <summary>True when the eight groups form an IPv4-mapped ("::ffff:") address.</summary>
    private static bool IsMapped(ushort[] g) =>
        g[0] == 0 && g[1] == 0 && g[2] == 0 && g[3] == 0 && g[4] == 0 && g[5] == 0xFFFF;

    /// <summary>True when the eight groups form an IPv4-compatible ("::") address.</summary>
    private static bool IsCompatible(ushort[] g) =>
        g[0] == 0 && g[1] == 0 && g[2] == 0 && g[3] == 0 && g[4] == 0 && g[5] == 0;

    /// <summary>
    /// Collapse the longest run (length &gt;= 2) of zero groups into "::" (first
    /// run wins on ties) and strip leading zeros — RFC 5952 canonical text for
    /// pure-hex IPv6. Works over any group span (8 for a whole address, 6 for
    /// the high part of an embedded-IPv4 render). Does not emit dotted-decimal;
    /// call <see cref="RenderCanonical"/> for that.
    /// </summary>
    private static string CompressGroups(ushort[] groups)
    {
        int bestStart = -1, bestLen = 0, curStart = -1, curLen = 0;
        // Track the longest run of consecutive zero groups. bestStart records
        // the first run of the longest length (strict > keeps earliest).
        for (int i = 0; i < groups.Length; i++)
        {
            if (groups[i] == 0)
            {
                if (curStart < 0)
                    curStart = i;
                curLen++;
                if (curLen > bestLen)
                {
                    bestLen = curLen;
                    bestStart = curStart;
                }
            }
            else
            {
                curStart = -1;
                curLen = 0;
            }
        }

        static string Render(IEnumerable<ushort> gs) =>
            string.Join(":", gs.Select(HexGroup));

        if (bestLen < 2)
            return Render(groups);
        return Render(groups[..bestStart]) + "::" + Render(groups[(bestStart + bestLen)..]);
    }

    /// <summary>
    /// Render a compressed high part followed by a dotted-decimal IPv4 tail.
    /// When the high part already ends in "::" (its zero run reaches the
    /// boundary) the IPv4 attaches directly; otherwise a single ":" separates
    /// them — so "::ffff:" → "::ffff:a.b.c.d" and "::" → "::a.b.c.d".
    /// </summary>
    private static string RenderWithEmbeddedTail(ushort[] high, byte[] octets)
    {
        string highStr = CompressGroups(high);
        string ipv4 = Ipv4ToString(octets);
        return highStr.EndsWith("::", StringComparison.Ordinal)
            ? highStr + ipv4
            : highStr + ":" + ipv4;
    }

    /// <summary>
    /// Canonical RFC 5952 text for eight groups: a dotted-decimal tail for
    /// IPv4-mapped ("::ffff:") addresses, otherwise pure compressed hex. The
    /// deprecated IPv4-compatible range ("::/96") is NOT rendered dotted here —
    /// that would mis-render the unspecified ("::") and loopback ("::1")
    /// addresses as "::0.0.0.0" / "::0.0.0.1". Compatible extraction is still
    /// available via <see cref="Ipv6ToIpv4"/>; on-demand compatible generation
    /// via <see cref="Ipv4ToIpv6"/> is untouched.
    /// </summary>
    private static string RenderCanonical(ushort[] groups)
    {
        if (IsMapped(groups))
        {
            byte[] octets =
            {
                (byte)(groups[6] >> 8), (byte)(groups[6] & 0xFF),
                (byte)(groups[7] >> 8), (byte)(groups[7] & 0xFF),
            };
            return RenderWithEmbeddedTail(groups[..6], octets);
        }
        return CompressGroups(groups);
    }

    /// <summary>Render eight groups as canonical compressed IPv6.</summary>
    public static string Ipv6ToString(ushort[] groups) => RenderCanonical(groups);

    /// <summary>
    /// Expand an IPv6 string to its full eight-group, four-hex-digit form;
    /// null if invalid.
    /// </summary>
    public static string? ExpandIpv6(string s)
    {
        ushort[]? g = ParseIpv6(s);
        if (g is null)
            return null;
        // "x4" left-pads each group to a fixed 4-digit width: 0000..ffff.
        return string.Join(":", g.Select(v => v.ToString("x4")));
    }

    /// <summary>Compress an IPv6 string to its RFC 5952 canonical form; null if invalid.</summary>
    public static string? CompressIpv6(string s)
    {
        ushort[]? g = ParseIpv6(s);
        if (g is null)
            return null;
        return RenderCanonical(g);
    }

    /// <summary>
    /// Embed an IPv4 octet quad into an IPv6 address. By default produces the
    /// IPv4-mapped form "::ffff:a.b.c.d"; <see cref="EmbedMode.Compatible"/>
    /// yields "::a.b.c.d"; a set <see cref="Ipv4ToIpv6Options.Prefix"/>
    /// overrides both and places the IPv4 after any custom /96 prefix (e.g.
    /// "64:ff9b::a.b.c.d"). Returns null for an invalid octet count or prefix.
    /// </summary>
    public static string? Ipv4ToIpv6(byte[] octets, Ipv4ToIpv6Options? options = null)
    {
        if (octets.Length != 4)
            return null;

        if (options?.Prefix is string prefix)
        {
            ushort[]? p = ParseIpv6(prefix);
            if (p is null)
                return null;
            return RenderWithEmbeddedTail(p[..6], octets);
        }
        ushort[] high = options?.Mode == EmbedMode.Compatible
            ? new ushort[6]
            : new ushort[] { 0, 0, 0, 0, 0, 0xFFFF };
        return RenderWithEmbeddedTail(high, octets);
    }

    /// <summary>
    /// Extract the embedded IPv4 from an IPv4-mapped ("::ffff:a.b.c.d") or
    /// IPv4-compatible ("::a.b.c.d") address, returning dotted-decimal or null
    /// when the address carries no embedded IPv4 (or is unparseable).
    /// </summary>
    public static string? Ipv6ToIpv4(string s)
    {
        ushort[]? g = ParseIpv6(s);
        if (g is null || !(IsMapped(g) || IsCompatible(g)))
            return null;
        byte[] octets =
        {
            (byte)(g[6] >> 8), (byte)(g[6] & 0xFF),
            (byte)(g[7] >> 8), (byte)(g[7] & 0xFF),
        };
        return Ipv4ToString(octets);
    }
}

Also available in 13 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →