Skip to content

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

// =============================================================================
//  ip-converter — IPv4 ↔ IPv6 conversion (polyglot showcase: Rust)
//  CosmoDev polyglot port of ip-converter, ported from src/lib/ip-converter.ts.
//  Display source — part of CosmoDev's polyglot tool pages.
//
//  Pure, deterministic IPv4/IPv6 address conversion logic. Every parse function
//  returns `None` (or "" for the string renderers) on invalid input rather than
//  panicking, 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.
//
//  Rust's type system lets the value types be precise: octets are `u8`, IPv6
//  16-bit groups are `u16`. Stdlib only — no external crates (the TS regex
//  guards are hand-rolled below as length + charset checks).
// =============================================================================

/// Embedding family for placing an IPv4 quad inside an IPv6 address.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum 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.
#[derive(Debug, Clone)]
pub struct Ipv4ToIpv6Options {
    /// Embedding family. Ignored when `prefix` is `Some`.
    pub mode: EmbedMode,
    /// 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.
    /// `Some("64:ff9b::".into())` yields a NAT64-style `64:ff9b::a.b.c.d`.
    /// Overrides `mode`.
    pub prefix: Option<String>,
}

impl Default for Ipv4ToIpv6Options {
    // Default is the IPv4-mapped form with no custom prefix.
    fn default() -> Self {
        Self {
            mode: EmbedMode::Mapped,
            prefix: None,
        }
    }
}

/// A valid IPv6 group token: 1-4 hex digits, no sign, no underscores.
/// (Equivalent to the TS `/^[0-9a-fA-F]{1,4}$/` regex.)
fn is_hex_group(s: &str) -> bool {
    let len = s.len();
    if !(1..=4).contains(&len) {
        return false;
    }
    s.bytes().all(|b| b.is_ascii_hexdigit())
}

/// A valid IPv4 octet token: 1-3 decimal digits. Range is enforced separately.
/// (Equivalent to the TS `/^\d{1,3}$/` regex.)
fn is_dec3(s: &str) -> bool {
    let len = s.len();
    if !(1..=3).contains(&len) {
        return false;
    }
    s.bytes().all(|b| b.is_ascii_digit())
}

/// Lowercase hex for one 16-bit group, with no leading zeros.
fn hex_group(v: u16) -> String {
    format!("{:x}", v)
}

/// Parse a dotted-decimal IPv4 string into four octets, validating each is
/// 0-255. Returns `None` for anything that is not exactly four numeric octets
/// in range.
pub fn parse_ipv4(s: &str) -> Option<[u8; 4]> {
    let trimmed = s.trim();
    let parts: Vec<&str> = trimmed.split('.').collect();
    if parts.len() != 4 {
        return None;
    }
    let mut octets = [0u8; 4];
    for (i, p) in parts.iter().enumerate() {
        if !is_dec3(p) {
            return None;
        }
        // is_dec3 guarantees ASCII digits only, so base-10 parse cannot fail.
        let n: u32 = p.parse().ok()?;
        if n > 255 {
            return None;
        }
        octets[i] = n as u8;
    }
    Some(octets)
}

/// Render four octets as `a.b.c.d`, or `None` if they are out of range. (The
/// octet type is `u8`, which already guarantees range, but the signature keeps
/// the symmetry with the other ports.)
pub fn ipv4_to_string(octets: &[u8]) -> String {
    format!("{}.{}.{}.{}", octets[0], octets[1], octets[2], octets[3])
}

/// Count non-overlapping occurrences of `needle` in `haystack`. Used to enforce
/// the at-most-one `"::"` rule without pulling in a regex crate.
fn count_overlapping(haystack: &str, needle: &str) -> usize {
    if needle.is_empty() {
        return 0;
    }
    haystack.match_indices(needle).count()
}

/// 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 `None` on any malformed input — never panics.
pub fn parse_ipv6(s: &str) -> Option<[u16; 8]> {
    let input = s.trim();
    if input.is_empty() {
        return None;
    }
    // At most one "::" run is legal; reject ambiguous double-compression.
    if count_overlapping(input, "::") > 1 {
        return None;
    }

    // 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.
    if let Some(dc) = input.find("::") {
        let (before, after) = input.split_at(dc);
        let after = &after[2..]; // skip the "::"

        let head_tokens: Vec<&str> = if before.is_empty() {
            Vec::new()
        } else {
            before.split(':').collect()
        };
        let tail_tokens: Vec<&str> = if after.is_empty() {
            Vec::new()
        } else {
            after.split(':').collect()
        };

        let mut head: Vec<u16> = Vec::with_capacity(head_tokens.len());
        for g in &head_tokens {
            if !is_hex_group(g) {
                return None;
            }
            head.push(u16::from_str_radix(g, 16).ok()?);
        }

        let mut tail: Vec<u16> = Vec::with_capacity(tail_tokens.len());
        for (i, g) in tail_tokens.iter().enumerate() {
            // 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 == tail_tokens.len() - 1 && g.contains('.') {
                let oct = parse_ipv4(g)?;
                tail.push(((oct[0] as u16) << 8) | oct[1] as u16);
                tail.push(((oct[2] as u16) << 8) | oct[3] as u16);
            } else {
                if !is_hex_group(g) {
                    return None;
                }
                tail.push(u16::from_str_radix(g, 16).ok()?);
            }
        }

        let total = head.len() + tail.len();
        if total >= 8 {
            return None; // "::" must elide at least one group.
        }
        let mut groups = [0u16; 8];
        for (k, &v) in head.iter().enumerate() {
            groups[k] = v;
        }
        // The middle [head.len() .. 8 - tail.len()] stays zero — that is the
        // elided run "::" stands in for.
        for (k, &v) in tail.iter().enumerate() {
            groups[8 - tail.len() + k] = v;
        }
        Some(groups)
    } else {
        // No compression: split on ':' and parse, allowing a dotted-quad only
        // in the last slot. The result must be exactly eight groups.
        let tokens: Vec<&str> = input.split(':').collect();
        let mut groups = [0u16; 8];
        let mut n = 0usize;
        for (i, g) in tokens.iter().enumerate() {
            if i == tokens.len() - 1 && g.contains('.') {
                let oct = parse_ipv4(g)?;
                if n + 2 > 8 {
                    return None;
                }
                groups[n] = ((oct[0] as u16) << 8) | oct[1] as u16;
                groups[n + 1] = ((oct[2] as u16) << 8) | oct[3] as u16;
                n += 2;
            } else {
                if !is_hex_group(g) {
                    return None;
                }
                if n + 1 > 8 {
                    return None;
                }
                groups[n] = u16::from_str_radix(g, 16).ok()?;
                n += 1;
            }
        }
        if n != 8 {
            return None;
        }
        Some(groups)
    }
}

/// True when the eight groups form an IPv4-mapped ("::ffff:") address.
fn is_mapped(g: &[u16; 8]) -> bool {
    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.
fn is_compatible(g: &[u16; 8]) -> bool {
    g[0] == 0 && g[1] == 0 && g[2] == 0 && g[3] == 0 && g[4] == 0 && g[5] == 0
}

/// 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. Does not emit dotted-decimal; call `render_canonical` for that.
fn compress_groups(groups: &[u16; 8]) -> String {
    let mut best_start: i32 = -1;
    let mut best_len = 0usize;
    let mut cur_start: i32 = -1;
    let mut cur_len = 0usize;
    // Track the longest run of consecutive zero groups. best_start records the
    // first run of the longest length encountered (strict ">" keeps earliest).
    for (i, &v) in groups.iter().enumerate() {
        if v == 0 {
            if cur_start < 0 {
                cur_start = i as i32;
            }
            cur_len += 1;
            if cur_len > best_len {
                best_len = cur_len;
                best_start = cur_start;
            }
        } else {
            cur_start = -1;
            cur_len = 0;
        }
    }

    // Helper to render a slice of groups as colon-separated lowercase hex.
    let render = |slice: &[u16]| -> String {
        slice
            .iter()
            .map(|v| hex_group(*v))
            .collect::<Vec<_>>()
            .join(":")
    };

    if best_len < 2 {
        return render(groups);
    }
    let start = best_start as usize;
    let before = render(&groups[..start]);
    let after = render(&groups[start + best_len..]);
    format!("{}::{}", before, after)
}

/// 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".
fn render_with_embedded_tail(high: &[u16], octets: &[u8; 4]) -> String {
    let high_str = compress_groups_high(high);
    let ipv4 = ipv4_to_string(octets);
    if high_str.ends_with("::") {
        format!("{}{}", high_str, ipv4)
    } else {
        format!("{}:{}", high_str, ipv4)
    }
}

// Specialization of compress_groups for a slice (the embedded-tail case works
// on a 6-group high part, not the full 8). Same longest-run + first-wins logic.
fn compress_groups_high(high: &[u16]) -> String {
    let mut best_start: i32 = -1;
    let mut best_len = 0usize;
    let mut cur_start: i32 = -1;
    let mut cur_len = 0usize;
    for (i, &v) in high.iter().enumerate() {
        if v == 0 {
            if cur_start < 0 {
                cur_start = i as i32;
            }
            cur_len += 1;
            if cur_len > best_len {
                best_len = cur_len;
                best_start = cur_start;
            }
        } else {
            cur_start = -1;
            cur_len = 0;
        }
    }
    let render = |slice: &[u16]| -> String {
        slice
            .iter()
            .map(|v| hex_group(*v))
            .collect::<Vec<_>>()
            .join(":")
    };
    if best_len < 2 {
        return render(high);
    }
    let start = best_start as usize;
    let before = render(&high[..start]);
    let after = render(&high[start + best_len..]);
    format!("{}::{}", before, after)
}

/// 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
/// `ipv6_to_ipv4`; on-demand compatible generation via `ipv4_to_ipv6` is
/// untouched.
fn render_canonical(groups: &[u16; 8]) -> String {
    if is_mapped(groups) {
        let octets: [u8; 4] = [
            (groups[6] >> 8) as u8,
            (groups[6] & 0xff) as u8,
            (groups[7] >> 8) as u8,
            (groups[7] & 0xff) as u8,
        ];
        let high: [u16; 6] = [groups[0], groups[1], groups[2], groups[3], groups[4], groups[5]];
        render_with_embedded_tail(&high, &octets)
    } else {
        compress_groups(groups)
    }
}

/// Render eight groups as canonical compressed IPv6, or `None` if the group
/// count is wrong.
pub fn ipv6_to_string(groups: &[u16; 8]) -> String {
    render_canonical(groups)
}

/// Expand an IPv6 string to its full eight-group, four-hex-digit form; `None`
/// if invalid.
pub fn expand_ipv6(s: &str) -> Option<String> {
    let g = parse_ipv6(s)?;
    let parts: Vec<String> = g.iter().map(|v| format!("{:04x}", v)).collect();
    Some(parts.join(":"))
}

/// Compress an IPv6 string to its RFC 5952 canonical form; `None` if invalid.
pub fn compress_ipv6(s: &str) -> Option<String> {
    let g = parse_ipv6(s)?;
    Some(render_canonical(&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 `Some(prefix)` overrides both and places the IPv4 after any
/// custom /96 prefix (e.g. "64:ff9b::a.b.c.d"). Returns `None` for invalid
/// octets or prefix.
pub fn ipv4_to_ipv6(octets: &[u8; 4], opts: &Ipv4ToIpv6Options) -> Option<String> {
    if let Some(prefix) = &opts.prefix {
        let p = parse_ipv6(prefix)?;
        let high: [u16; 6] = [p[0], p[1], p[2], p[3], p[4], p[5]];
        return Some(render_with_embedded_tail(&high, octets));
    }
    let high = match opts.mode {
        EmbedMode::Compatible => [0u16, 0, 0, 0, 0, 0],
        EmbedMode::Mapped => [0u16, 0, 0, 0, 0, 0xffff],
    };
    Some(render_with_embedded_tail(&high, 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 `None`
/// when the address carries no embedded IPv4 (or is unparseable).
pub fn ipv6_to_ipv4(s: &str) -> Option<String> {
    let g = parse_ipv6(s)?;
    if !(is_mapped(&g) || is_compatible(&g)) {
        return None;
    }
    let octets: [u8; 4] = [
        (g[6] >> 8) as u8,
        (g[6] & 0xff) as u8,
        (g[7] >> 8) as u8,
        (g[7] & 0xff) as u8,
    ];
    Some(ipv4_to_string(&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 →