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 >= 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 →