Skip to content

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

# ip-converter — IPv4 ↔ IPv6 conversion (polyglot showcase: Ruby)
#
# Language: Ruby (3.2, 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 nil (or '' for the string renderers) on invalid input
# rather than raising, 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.
#
# Mirrors the Python port closely (regex guards, integer groups). Ruby notes:
# \A/\z anchor the whole token (Ruby's ^/$ are line anchors), and split needs
# limit -1 to keep trailing empty tokens — both mirror the Python/Rust split
# semantics this parser relies on.

module IpConverter
  module_function

  # One IPv6 group: 1-4 hex digits. (Equivalent to the TS
  # /^[0-9a-fA-F]{1,4}$/ regex.)
  HEX_GROUP = /\A[0-9a-fA-F]{1,4}\z/
  # One IPv4 octet token: 1-3 decimal digits (range checked separately).
  DEC3 = /\A\d{1,3}\z/

  # Count non-overlapping occurrences of "::" — used to enforce the
  # at-most-one-compression rule.
  def count_double_colon(s)
    s.scan('::').size
  end

  # Parse a dotted-decimal IPv4 string into four octets, validating each is
  # 0-255. Returns nil for anything that is not exactly four numeric octets
  # in range.
  def parse_ipv4(s)
    parts = s.strip.split('.', -1)
    return nil unless parts.length == 4

    octets = []
    parts.each do |p|
      return nil unless DEC3.match?(p)
      # DEC3 guarantees digits only, so to_i cannot surprise us.
      n = p.to_i
      return nil if n > 255
      octets << n
    end
    octets
  end

  # Render four octets as "a.b.c.d", or '' if they are out of range.
  def ipv4_to_string(octets)
    return '' unless octets.is_a?(Array) && octets.length == 4 &&
                     octets.all? { |o| o.is_a?(Integer) && o.between?(0, 255) }
    octets.map(&:to_s).join('.')
  end

  # 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 nil on any malformed input — never raises.
  def parse_ipv6(s)
    inp = s.strip
    return nil if inp.empty?
    # At most one "::" run is legal; reject ambiguous double-compression.
    return nil if count_double_colon(inp) > 1

    dc = inp.index('::')
    if dc
      before = inp[0...dc]
      after = inp[(dc + 2)..] || ''
      head_tokens = before.empty? ? [] : before.split(':', -1)
      tail_tokens = after.empty? ? [] : after.split(':', -1)

      head = []
      head_tokens.each do |g|
        return nil unless HEX_GROUP.match?(g)
        head << g.to_i(16)
      end

      tail = []
      tail_tokens.each_with_index do |g, 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 == tail_tokens.length - 1 && g.include?('.')
          oct = parse_ipv4(g)
          return nil if oct.nil?
          tail << ((oct[0] << 8) | oct[1])
          tail << ((oct[2] << 8) | oct[3])
        else
          return nil unless HEX_GROUP.match?(g)
          tail << g.to_i(16)
        end
      end

      total = head.length + tail.length
      # "::" must elide at least one group.
      return nil if total >= 8
      head + [0] * (8 - total) + tail
    else
      # No compression: split on ':' and parse, allowing a dotted-quad only
      # in the last slot. The result must be exactly eight groups.
      tokens = inp.split(':', -1)
      groups = []
      tokens.each_with_index do |g, i|
        if i == tokens.length - 1 && g.include?('.')
          oct = parse_ipv4(g)
          return nil if oct.nil?
          groups << ((oct[0] << 8) | oct[1])
          groups << ((oct[2] << 8) | oct[3])
        else
          return nil unless HEX_GROUP.match?(g)
          groups << g.to_i(16)
        end
      end
      groups.length == 8 ? groups : nil
    end
  end

  # True when the eight groups form an IPv4-mapped ("::ffff:") address.
  def _is_mapped(g)
    g[0, 5].all?(&:zero?) && g[5] == 0xffff
  end

  # True when the eight groups form an IPv4-compatible ("::") address.
  def _is_compatible(g)
    g[0, 6].all?(&:zero?)
  end

  # Lowercase hex for one 16-bit group, with no leading zeros.
  def _hex_group(v)
    v.to_s(16)
  end

  # A slice of groups as colon-separated lowercase hex.
  def _render_slice(slice)
    slice.map { |v| _hex_group(v) }.join(':')
  end

  # 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 array (8 for a whole address, 6 for
  # the high part of an embedded-IPv4 render). Does not emit dotted-decimal;
  # call _render_canonical for that.
  def _compress_groups(groups)
    best_start = -1
    best_len = 0
    cur_start = -1
    cur_len = 0
    # Track the longest run of consecutive zero groups. best_start records
    # the first run of the longest length (strict > keeps earliest).
    groups.each_with_index do |v, i|
      if v.zero?
        cur_start = i if cur_start.negative?
        cur_len += 1
        if cur_len > best_len
          best_len = cur_len
          best_start = cur_start
        end
      else
        cur_start = -1
        cur_len = 0
      end
    end

    return _render_slice(groups) if best_len < 2
    "#{_render_slice(groups[0...best_start])}::#{_render_slice(groups[(best_start + best_len)..])}"
  end

  # 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".
  def _render_with_embedded_tail(high, octets)
    high_str = _compress_groups(high)
    ipv4 = octets.map(&:to_s).join('.')
    if high_str.end_with?('::')
      "#{high_str}#{ipv4}"
    else
      "#{high_str}:#{ipv4}"
    end
  end

  # 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.
  def _render_canonical(groups)
    return _compress_groups(groups) unless _is_mapped(groups)

    octets = [(groups[6] >> 8) & 0xff, groups[6] & 0xff,
              (groups[7] >> 8) & 0xff, groups[7] & 0xff]
    _render_with_embedded_tail(groups[0, 6], octets)
  end

  # Render eight groups as canonical compressed IPv6, or '' if invalid.
  def ipv6_to_string(groups)
    return '' unless groups.is_a?(Array) && groups.length == 8 &&
                     groups.all? { |v| v.is_a?(Integer) && v.between?(0, 0xffff) }
    _render_canonical(groups)
  end

  # Expand an IPv6 string to its full eight-group, four-hex-digit form;
  # '' if invalid.
  def expand_ipv6(s)
    g = parse_ipv6(s)
    return '' if g.nil?
    # '%04x' left-pads each group to a fixed 4-digit width: 0000..ffff.
    g.map { |v| format('%04x', v) }.join(':')
  end

  # Compress an IPv6 string to its RFC 5952 canonical form; '' if invalid.
  def compress_ipv6(s)
    g = parse_ipv6(s)
    return '' if g.nil?
    _render_canonical(g)
  end

  # Embed an IPv4 octet quad into an IPv6 address. By default produces the
  # IPv4-mapped form "::ffff:a.b.c.d"; mode: :compatible yields "::a.b.c.d";
  # a set prefix: overrides both and places the IPv4 after any custom /96
  # prefix (e.g. "64:ff9b::a.b.c.d"). Returns '' for invalid octets or
  # prefix.
  # (The Python/Rust ports carry the mode/prefix pair in an options object;
  # this port uses keyword arguments for the same contract.)
  def ipv4_to_ipv6(octets, mode: :mapped, prefix: nil)
    unless octets.is_a?(Array) && octets.length == 4 &&
           octets.all? { |o| o.is_a?(Integer) && o.between?(0, 255) }
      return ''
    end

    if prefix
      pfx = parse_ipv6(prefix)
      return '' if pfx.nil?
      return _render_with_embedded_tail(pfx[0, 6], octets)
    end
    if mode == :compatible
      _render_with_embedded_tail([0, 0, 0, 0, 0, 0], octets)
    else
      _render_with_embedded_tail([0, 0, 0, 0, 0, 0xffff], octets)
    end
  end

  # 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 nil
  # when the address carries no embedded IPv4 (or is unparseable).
  def ipv6_to_ipv4(s)
    g = parse_ipv6(s)
    return nil if g.nil? || !(_is_mapped(g) || _is_compatible(g))

    octets = [(g[6] >> 8) & 0xff, g[6] & 0xff,
              (g[7] >> 8) & 0xff, g[7] & 0xff]
    octets.map(&:to_s).join('.')
  end
end

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 →