Skip to content

IPv4 ↔ IPv6 Converter — Kotlin 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 Kotlin implementation — the same logic the interactive tool runs, in a shareable, citable form.

// ip-converter — IPv4 ↔ IPv6 conversion (polyglot showcase: Kotlin)
//
// Language: Kotlin (1.9, 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.
//
// Octets and 16-bit groups are plain Ints confined to 0..255 / 0..0xFFFF by
// the parsers (mirroring the Python port). Kotlin's String.split keeps
// trailing empty tokens by default, which this parser relies on.

/** Embedding family for placing an IPv4 quad inside an IPv6 address. */
enum class EmbedMode {
    /** `::ffff:a.b.c.d` — the modern, non-deprecated IPv4-mapped form (default). */
    MAPPED,

    /** `::a.b.c.d` — the deprecated IPv4-compatible form. */
    COMPATIBLE,
}

/** Options for embedding an IPv4 octet quad into an IPv6 address. */
data class Ipv4ToIpv6Options(
    /** Embedding family. Ignored when [prefix] is non-null. */
    val mode: EmbedMode = EmbedMode.MAPPED,
    /**
     * 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 [mode].
     */
    val prefix: String? = null,
)

/** Pure IPv4/IPv6 address conversion logic, ported from src/lib/ip-converter.ts. */
object IpConverter {

    /** One IPv6 group: 1-4 hex digits (the TS /^[0-9a-fA-F]{1,4}$/ guard;
     * matches() anchors the whole token). */
    private val HEX = Regex("[0-9a-fA-F]{1,4}")

    /** One IPv4 octet token: 1-3 decimal digits (range checked separately;
     * the TS /^\d{1,3}$/ guard). */
    private val DEC3 = Regex("\\d{1,3}")

    /** Count non-overlapping occurrences of "::" — used to enforce the
     * at-most-one-compression rule. */
    private fun countDoubleColon(s: String): Int {
        var count = 0
        var i = s.indexOf("::")
        while (i >= 0) {
            count++
            i = s.indexOf("::", i + 2)
        }
        return count
    }

    /**
     * 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.
     */
    fun parseIpv4(s: String): IntArray? {
        val parts = s.trim().split(".")
        if (parts.size != 4) return null
        val octets = IntArray(4)
        for (i in parts.indices) {
            if (!DEC3.matches(parts[i])) return null
            // DEC3 guarantees digits only, so toInt() cannot fail.
            val n = parts[i].toInt()
            if (n !in 0..255) return null
            octets[i] = n
        }
        return octets
    }

    /** Render four octets as `a.b.c.d`. (The parser already confines each
     * octet to 0..255, but the signature keeps the symmetry with the other
     * ports.) */
    fun ipv4ToString(octets: IntArray): String = octets.joinToString(".")

    /**
     * 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.
     */
    fun parseIpv6(s: String): IntArray? {
        val input = s.trim()
        if (input.isEmpty()) return null
        // At most one "::" run is legal; reject ambiguous double-compression.
        if (countDoubleColon(input) > 1) return null

        // Branch on the position of "::" (if any). The two arms mirror each
        // other: split into tokens, validate each, allow a dotted-quad only in
        // the final slot, then assemble exactly eight groups.
        val dc = input.indexOf("::")
        if (dc >= 0) {
            val before = input.substring(0, dc)
            val after = input.substring(dc + 2)
            val headTokens = if (before.isEmpty()) emptyList() else before.split(":")
            val tailTokens = if (after.isEmpty()) emptyList() else after.split(":")

            val head = mutableListOf<Int>()
            for (g in headTokens) {
                if (!HEX.matches(g)) return null
                head.add(g.toInt(16))
            }

            val tail = mutableListOf<Int>()
            for (i in tailTokens.indices) {
                val 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.lastIndex && '.' in g) {
                    val oct = parseIpv4(g) ?: return null
                    tail.add((oct[0] shl 8) or oct[1])
                    tail.add((oct[2] shl 8) or oct[3])
                } else {
                    if (!HEX.matches(g)) return null
                    tail.add(g.toInt(16))
                }
            }

            val total = head.size + tail.size
            // "::" must elide at least one group.
            if (total >= 8) return null
            return IntArray(8).also { groups ->
                for (k in head.indices) groups[k] = head[k]
                // The middle [head.size .. 8 - tail.size] stays zero — that is
                // the elided run "::" stands in for.
                for (k in tail.indices) groups[8 - tail.size + k] = tail[k]
            }
        }

        // No compression: split on ':' and parse, allowing a dotted-quad only
        // in the last slot. The result must be exactly eight groups.
        val tokens = input.split(":")
        val groups = IntArray(8)
        var n = 0
        for (i in tokens.indices) {
            val g = tokens[i]
            if (i == tokens.lastIndex && '.' in g) {
                val oct = parseIpv4(g) ?: return null
                if (n + 2 > 8) return null
                groups[n] = (oct[0] shl 8) or oct[1]
                groups[n + 1] = (oct[2] shl 8) or oct[3]
                n += 2
            } else {
                if (!HEX.matches(g)) return null
                if (n + 1 > 8) return null
                groups[n] = g.toInt(16)
                n += 1
            }
        }
        return if (n == 8) groups else null
    }

    /** True when the eight groups form an IPv4-mapped ("::ffff:") address. */
    private fun isMapped(g: IntArray): Boolean =
        g[0] == 0 && g[1] == 0 && g[2] == 0 && g[3] == 0 && g[4] == 0 && g[5] == 0xFFFF

    /** True when the eight groups form an IPv4-compatible ("::") address. */
    private fun isCompatible(g: IntArray): Boolean =
        g[0] == 0 && g[1] == 0 && g[2] == 0 && g[3] == 0 && g[4] == 0 && g[5] == 0

    /** Lowercase hex for one 16-bit group, with no leading zeros. */
    private fun hexGroup(v: Int): String = v.toString(16)

    /** The slice [from, to) as colon-separated lowercase hex. */
    private fun renderRange(groups: IntArray, from: Int, to: Int): String =
        (from until to).joinToString(":") { hexGroup(groups[it]) }

    /**
     * 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 [renderCanonical] for that.
     */
    private fun compressGroups(groups: IntArray, from: Int = 0, to: Int = groups.size): String {
        var bestStart = -1
        var bestLen = 0
        var curStart = -1
        var curLen = 0
        // Track the longest run of consecutive zero groups. bestStart records
        // the first run of the longest length (strict > keeps earliest).
        for (i in from until to) {
            if (groups[i] == 0) {
                if (curStart < 0) curStart = i
                curLen++
                if (curLen > bestLen) {
                    bestLen = curLen
                    bestStart = curStart
                }
            } else {
                curStart = -1
                curLen = 0
            }
        }

        if (bestLen < 2) return renderRange(groups, from, to)
        return renderRange(groups, from, bestStart) + "::" +
            renderRange(groups, bestStart + bestLen, to)
    }

    /**
     * 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".
     */
    private fun renderWithEmbeddedTail(high: IntArray, octets: IntArray): String {
        val highStr = compressGroups(high)
        val ipv4 = ipv4ToString(octets)
        return if (highStr.endsWith("::")) highStr + ipv4 else "$highStr:$ipv4"
    }

    /**
     * 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 [ipv6ToIpv4]; on-demand compatible generation via
     * [ipv4ToIpv6] is untouched.
     */
    private fun renderCanonical(groups: IntArray): String {
        if (isMapped(groups)) {
            val octets = intArrayOf(
                (groups[6] shr 8) and 0xFF, groups[6] and 0xFF,
                (groups[7] shr 8) and 0xFF, groups[7] and 0xFF,
            )
            return renderWithEmbeddedTail(groups.copyOfRange(0, 6), octets)
        }
        return compressGroups(groups)
    }

    /** Render eight groups as canonical compressed IPv6. */
    fun ipv6ToString(groups: IntArray): String = renderCanonical(groups)

    /**
     * Expand an IPv6 string to its full eight-group, four-hex-digit form;
     * null if invalid.
     */
    fun expandIpv6(s: String): String? {
        val g = parseIpv6(s) ?: return null
        // padStart left-pads each group to a fixed 4-digit width: 0000..ffff.
        return g.joinToString(":") { hexGroup(it).padStart(4, '0') }
    }

    /** Compress an IPv6 string to its RFC 5952 canonical form; null if invalid. */
    fun compressIpv6(s: String): String? {
        val g = parseIpv6(s) ?: return null
        return renderCanonical(g)
    }

    /**
     * Embed an IPv4 octet quad into an IPv6 address. By default produces the
     * IPv4-mapped form "::ffff:a.b.c.d"; [EmbedMode.COMPATIBLE] yields
     * "::a.b.c.d"; a set [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.
     */
    fun ipv4ToIpv6(
        octets: IntArray,
        options: Ipv4ToIpv6Options = Ipv4ToIpv6Options(),
    ): String? {
        if (octets.size != 4 || octets.any { it !in 0..255 }) return null

        options.prefix?.let { prefix ->
            val p = parseIpv6(prefix) ?: return null
            return renderWithEmbeddedTail(p.copyOfRange(0, 6), octets)
        }
        if (options.mode == EmbedMode.COMPATIBLE) {
            return renderWithEmbeddedTail(intArrayOf(0, 0, 0, 0, 0, 0), octets)
        }
        return renderWithEmbeddedTail(intArrayOf(0, 0, 0, 0, 0, 0xFFFF), octets)
    }

    /**
     * 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).
     */
    fun ipv6ToIpv4(s: String): String? {
        val g = parseIpv6(s) ?: return null
        if (!(isMapped(g) || isCompatible(g))) return null
        val octets = intArrayOf(
            (g[6] shr 8) and 0xFF, g[6] and 0xFF,
            (g[7] shr 8) and 0xFF, g[7] and 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 →