defmodule SnmpKit.SnmpLib.HostParser do @moduledoc """ Comprehensive host and port parsing for all possible input formats. This module handles parsing of host and port information from any conceivable input format and returns exactly what gen_udp needs: an IP tuple and integer port. ## Supported Input Formats ### IPv4 - Tuples: `{192, 168, 1, 1}`, `{{192, 168, 1, 1}, 161}` - Strings: `"192.168.1.1"`, `"192.168.1.1:161"` - Charlists: `'192.168.1.1'`, `'192.168.1.1:161'` - Hostnames: `"router.local"`, `"router.local:161"` ### IPv6 - Tuples: `{0x2001, 0xdb8, 0, 0, 0, 0, 0, 1}`, `{{0x2001, 0xdb8, 0, 0, 0, 0, 0, 1}, 161}` - Strings: `"2001:db8::1"`, `"[2001:db8::1]:161"` - Charlists: `'2001:db8::1'`, `'[2001:db8::1]:161'` - Compressed: `"::1"`, `"[::1]:161"` ### Mixed Formats - Maps: `%{host: "192.168.1.1", port: 161}` - Keyword lists: `[host: "192.168.1.1", port: 161]` ## Returns `{:ok, {ip_tuple, port}}` where: - `ip_tuple` is a 4-tuple for IPv4 or 8-tuple for IPv6 - `port` is an integer between 1-65535 `{:error, reason}` for invalid input """ @type ip4_tuple :: {0..255, 0..255, 0..255, 0..255} @type ip6_tuple :: {0..65_535, 0..65_535, 0..65_535, 0..65_535, 0..65_535, 0..65_535, 0..65_535, 0..65_535} @type ip_tuple :: ip4_tuple() | ip6_tuple() @type port_number :: 1..65_535 @type parse_result :: {:ok, {ip_tuple(), port_number()}} | {:error, atom()} @default_port 161 @doc """ Parse host and port from any input format. ## Examples # IPv4 tuples iex> SnmpKit.SnmpLib.HostParser.parse({192, 168, 1, 1}) {:ok, {{192, 168, 1, 1}, 161}} iex> SnmpKit.SnmpLib.HostParser.parse({{192, 168, 1, 1}, 8161}) {:ok, {{192, 168, 1, 1}, 8161}} # IPv4 strings iex> SnmpKit.SnmpLib.HostParser.parse("192.168.1.1") {:ok, {{192, 168, 1, 1}, 161}} iex> SnmpKit.SnmpLib.HostParser.parse("192.168.1.1:8161") {:ok, {{192, 168, 1, 1}, 8161}} # IPv4 charlists iex> SnmpKit.SnmpLib.HostParser.parse('192.168.1.1') {:ok, {{192, 168, 1, 1}, 161}} iex> SnmpKit.SnmpLib.HostParser.parse('192.168.1.1:8161') {:ok, {{192, 168, 1, 1}, 8161}} # IPv6 strings iex> SnmpKit.SnmpLib.HostParser.parse("::1") {:ok, {{0, 0, 0, 0, 0, 0, 0, 1}, 161}} iex> SnmpKit.SnmpLib.HostParser.parse("[::1]:8161") {:ok, {{0, 0, 0, 0, 0, 0, 0, 1}, 8161}} # Error cases iex> SnmpKit.SnmpLib.HostParser.parse("invalid") {:error, :invalid_host} iex> SnmpKit.SnmpLib.HostParser.parse("192.168.1.1:99999") {:error, :invalid_port} """ @spec parse(any()) :: parse_result() def parse(input, default_port \\ @default_port) # IPv4/IPv6 tuple without port def parse({a, b, c, d} = ip_tuple, default_port) when is_integer(a) and is_integer(b) and is_integer(c) and is_integer(d) do case validate_ipv4_tuple(ip_tuple) do :ok -> {:ok, {ip_tuple, default_port}} error -> error end end # IPv6 tuple without port # credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity def parse({a, b, c, d, e, f, g, h} = ip_tuple, default_port) when is_integer(a) and is_integer(b) and is_integer(c) and is_integer(d) and is_integer(e) and is_integer(f) and is_integer(g) and is_integer(h) do case validate_ipv6_tuple(ip_tuple) do :ok -> {:ok, {ip_tuple, default_port}} error -> error end end # Tuple with port: {ip_tuple, port} def parse({{a, b, c, d} = ip_tuple, port}, _default_port) when is_integer(a) and is_integer(b) and is_integer(c) and is_integer(d) and is_integer(port) do with :ok <- validate_ipv4_tuple(ip_tuple), :ok <- validate_port(port) do {:ok, {ip_tuple, port}} end end # IPv6 tuple with port: {{ip6_tuple}, port} def parse({{a, b, c, d, e, f, g, h} = ip_tuple, port}, _default_port) when is_integer(a) and is_integer(b) and is_integer(c) and is_integer(d) and is_integer(e) and is_integer(f) and is_integer(g) and is_integer(h) and is_integer(port) do with :ok <- validate_ipv6_tuple(ip_tuple), :ok <- validate_port(port) do {:ok, {ip_tuple, port}} end end # String input def parse(input, default_port) when is_binary(input) do parse_string(input, default_port) end # Keyword list input: [host: ..., port: ...] def parse([_ | _] = input, default_port) do if Keyword.keyword?(input) do parse_keyword(input, default_port) else parse_charlist(input, default_port) end end # Map input: %{host: ..., port: ...} def parse(%{host: host} = map, default_port) do port = Map.get(map, :port, default_port) with {:ok, {ip_tuple, _}} <- parse(host, default_port), :ok <- validate_port(port) do {:ok, {ip_tuple, port}} end end # Charlist input def parse(input, default_port) when is_list(input) do # Validate charlist contains only valid ASCII/UTF-8 characters if valid_charlist?(input) do try do string_input = List.to_string(input) parse_string(string_input, default_port) rescue # Not a valid charlist _ -> {:error, :invalid_charlist} end else {:error, :invalid_charlist} end end # Atom input (for hostnames like :localhost) def parse(input, default_port) when is_atom(input) do parse_string(Atom.to_string(input), default_port) end # Invalid input def parse(_input, _default_port) do {:error, :unsupported_format} end defp parse_keyword(input, default_port) do host = Keyword.get(input, :host) port = Keyword.get(input, :port, default_port) if host do with {:ok, {ip_tuple, _}} <- parse(host, default_port), :ok <- validate_port(port) do {:ok, {ip_tuple, port}} end else {:error, :missing_host} end end # Private helper functions defp parse_string(input, default_port) do input = String.trim(input) cond do # IPv6 with port: [::1]:8161 String.starts_with?(input, "[") and String.contains?(input, "]:") -> parse_ipv6_with_port(input) # IPv4 with port: 192.168.1.1:8161 String.contains?(input, ":") and not String.contains?(input, "::") -> parse_ipv4_with_port(input, default_port) # IPv6 without port or IPv4 without port true -> parse_ip_without_port(input, default_port) end end defp parse_charlist(input, default_port) do string_input = List.to_string(input) parse_string(string_input, default_port) rescue _ -> {:error, :invalid_charlist} end defp parse_ipv6_with_port(input) do # Format: [2001:db8::1]:8161 case Regex.run(~r/^\[([^\]]+)\]:(\d+)$/, input) do [_full, ipv6_str, port_str] -> with {:ok, ip_tuple} <- parse_ipv6_address(ipv6_str), {port, ""} <- Integer.parse(port_str), :ok <- validate_port(port) do {:ok, {ip_tuple, port}} else _ -> {:error, :invalid_ipv6_with_port} end nil -> {:error, :invalid_ipv6_with_port} end end defp parse_ipv4_with_port(input, default_port) do # Handle multiple colons (might be IPv6) parts = String.split(input, ":") cond do length(parts) == 2 -> # Likely IPv4:port [host_part, port_part] = parts parse_host_with_port(host_part, port_part, input, default_port) length(parts) > 2 -> # Definitely IPv6 parse_ip_without_port(input, default_port) true -> {:error, :invalid_format} end end defp parse_host_with_port(host_part, port_part, input, default_port) do case Integer.parse(port_part) do {port, ""} -> with {:ok, ip_tuple} <- parse_ipv4_address(host_part), :ok <- validate_port(port) do {:ok, {ip_tuple, port}} end _ -> # Port part is not a number, might be IPv6 parse_ip_without_port(input, default_port) end end defp parse_ip_without_port(input, default_port) do parser = cond do String.contains?(input, ".") -> &parse_ipv4_address/1 String.contains?(input, ":") -> &parse_ipv6_address/1 true -> &resolve_hostname/1 end case parser.(input) do {:ok, ip_tuple} -> {:ok, {ip_tuple, default_port}} error -> error end end defp parse_ipv4_address(input) do charlist_input = String.to_charlist(input) case :inet.parse_address(charlist_input) do {:ok, ip_tuple} -> case validate_ipv4_tuple(ip_tuple) do :ok -> {:ok, ip_tuple} error -> error end {:error, _} -> {:error, :invalid_ipv4} end end defp parse_ipv6_address(input) do charlist_input = String.to_charlist(input) case :inet.parse_address(charlist_input) do {:ok, ip_tuple} -> case validate_ipv6_tuple(ip_tuple) do :ok -> {:ok, ip_tuple} error -> error end {:error, _} -> {:error, :invalid_ipv6} end end defp resolve_hostname(hostname) do charlist_hostname = String.to_charlist(hostname) case :inet.gethostbyname(charlist_hostname) do {:ok, {:hostent, _name, _aliases, :inet, 4, [ip_tuple | _]}} -> {:ok, ip_tuple} {:ok, {:hostent, _name, _aliases, :inet6, 16, [ip_tuple | _]}} -> {:ok, ip_tuple} {:error, _reason} -> {:error, :hostname_resolution_failed} end end defp validate_ipv4_tuple({a, b, c, d}) do if valid_ipv4_octet?(a) and valid_ipv4_octet?(b) and valid_ipv4_octet?(c) and valid_ipv4_octet?(d) do :ok else {:error, :invalid_ipv4_tuple} end end defp validate_ipv4_tuple(_), do: {:error, :invalid_ipv4_tuple} defp valid_ipv4_octet?(n), do: is_integer(n) and n >= 0 and n <= 255 defp validate_ipv6_tuple({a, b, c, d, e, f, g, h}) do octets = [a, b, c, d, e, f, g, h] if Enum.all?(octets, &valid_ipv6_segment?/1) do :ok else {:error, :invalid_ipv6_tuple} end end defp validate_ipv6_tuple(_), do: {:error, :invalid_ipv6_tuple} defp valid_ipv6_segment?(n), do: is_integer(n) and n >= 0 and n <= 65_535 defp validate_port(port) when is_integer(port) and port >= 1 and port <= 65_535, do: :ok defp validate_port(_), do: {:error, :invalid_port} # Validate charlist contains only valid characters (0-255 for bytes) defp valid_charlist?(charlist) do # Additional check: ensure it would create valid UTF-8 when converted to string Enum.all?(charlist, fn char -> is_integer(char) and char >= 0 and char <= 255 end) and try do _string = List.to_string(charlist) true rescue _ -> false end end @doc """ Quick validation function to check if input can be parsed. ## Examples iex> SnmpKit.SnmpLib.HostParser.valid?("192.168.1.1") true iex> SnmpKit.SnmpLib.HostParser.valid?("invalid") false """ @spec valid?(any()) :: boolean() def valid?(input) do case parse(input) do {:ok, _} -> true {:error, _} -> false end end @doc """ Parse and return only the IP tuple, using default port. ## Examples iex> SnmpKit.SnmpLib.HostParser.parse_ip("192.168.1.1") {:ok, {192, 168, 1, 1}} """ @spec parse_ip(any()) :: {:ok, ip_tuple()} | {:error, atom()} def parse_ip(input) do case parse(input) do {:ok, {ip_tuple, _port}} -> {:ok, ip_tuple} error -> error end end @doc """ Parse and return only the port, using 161 as default. ## Examples iex> SnmpKit.SnmpLib.HostParser.parse_port("192.168.1.1:8161") {:ok, 8161} iex> SnmpKit.SnmpLib.HostParser.parse_port("192.168.1.1") {:ok, 161} """ @spec parse_port(any()) :: {:ok, port_number()} | {:error, atom()} def parse_port(input) do case parse(input) do {:ok, {_ip_tuple, port}} -> {:ok, port} error -> error end end @doc """ Format an IP tuple and port back to string representation. ## Examples iex> SnmpKit.SnmpLib.HostParser.format({{192, 168, 1, 1}, 161}) "192.168.1.1:161" iex> SnmpKit.SnmpLib.HostParser.format({{0, 0, 0, 0, 0, 0, 0, 1}, 161}) "[::1]:161" """ @spec format({ip_tuple(), port_number()}) :: String.t() def format({{a, b, c, d}, port}) do "#{a}.#{b}.#{c}.#{d}:#{port}" end def format({{a, b, c, d, e, f, g, h}, port}) do ip_str = {a, b, c, d, e, f, g, h} |> :inet.ntoa() |> List.to_string() "[#{ip_str}]:#{port}" end end