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 →