From 78885b2b2d26dac150b2edf3fb06f523e0919a1d Mon Sep 17 00:00:00 2001 From: James Harton Date: Mon, 9 Oct 2017 16:38:54 +1300 Subject: [PATCH] Implement parsing of strings into prefixes. --- lib/ip/address.ex | 76 ++++++++++++- lib/ip/prefix.ex | 194 +++++++++++++++++++++++++++----- lib/ip/prefix/helpers.ex | 23 ++++ lib/ip/prefix/invalid_prefix.ex | 8 ++ lib/ip/prefix/parser.ex | 103 +++++++++++++++++ test/ip/prefix/helpers_test.exs | 4 + test/ip/prefix/parser_test.exs | 4 + 7 files changed, 381 insertions(+), 31 deletions(-) create mode 100644 lib/ip/prefix/helpers.ex create mode 100644 lib/ip/prefix/invalid_prefix.ex create mode 100644 lib/ip/prefix/parser.ex create mode 100644 test/ip/prefix/helpers_test.exs create mode 100644 test/ip/prefix/parser_test.exs diff --git a/lib/ip/address.ex b/lib/ip/address.ex index ac9c652..3b0bff2 100644 --- a/lib/ip/address.ex +++ b/lib/ip/address.ex @@ -171,7 +171,7 @@ defmodule IP.Address do """ @spec from_string(binary, ip_version) :: {:ok, t} | {:error, term} def from_string(address, 4) when is_binary(address) do - case :inet.parse_ipv4_address(String.to_charlist(address)) do + case :inet.parse_ipv4strict_address(String.to_charlist(address)) do {:ok, addr} -> addr = addr |> Tuple.to_list() @@ -269,6 +269,80 @@ defmodule IP.Address do :: Prefix.t def to_prefix(%Address{} = address, length), do: IP.Prefix.new(address, length) + @doc """ + Returns the IP version of the address. + + ## Examples + + iex> "192.0.2.1" + ...> |> IP.Address.from_string! + ...> |> IP.Address.version() + 4 + + iex> "2001:db8::1" + ...> |> IP.Address.from_string! + ...> |> IP.Address.version() + 6 + """ + @spec version(t) :: 4 | 6 + def version(%Address{version: version}), do: version + + @doc """ + Returns the IP Address as an integer + + ## Examples + + iex> "192.0.2.1" + ...> |> IP.Address.from_string! + ...> |> IP.Address.to_integer() + 3221225985 + + iex> "2001:db8::1" + ...> |> IP.Address.from_string! + ...> |> IP.Address.to_integer() + 42540766411282592856903984951653826561 + """ + @spec to_integer(t) :: ipv4 | ipv6 + def to_integer(%Address{address: address}), do: address + + @doc """ + Returns true if `address` is version 6. + + ## Examples + + iex> "192.0.2.1" + ...> |> IP.Address.from_string!() + ...> |> IP.Address.v6? + false + + iex> "2001:db8::" + ...> |> IP.Address.from_string!() + ...> |> IP.Address.v6? + true + """ + @spec v6?(t) :: true | false + def v6?(%Address{version: 6} = _address), do: true + def v6?(_address), do: false + + @doc """ + Returns true if `address` is version 4. + + ## Examples + + iex> "192.0.2.1" + ...> |> IP.Address.from_string!() + ...> |> IP.Address.v4? + true + + iex> "2001:db8::" + ...> |> IP.Address.from_string!() + ...> |> IP.Address.v4? + false + """ + @spec v4?(t) :: true | false + def v4?(%Address{version: 4} = _address), do: true + def v4?(_address), do: false + defp from_bytes([a, b, c, d]) do (a <<< 24) + (b <<< 16) + (c <<< 8) + d end diff --git a/lib/ip/prefix.ex b/lib/ip/prefix.ex index 933c576..eca4bc9 100644 --- a/lib/ip/prefix.ex +++ b/lib/ip/prefix.ex @@ -1,7 +1,9 @@ defmodule IP.Prefix do alias IP.{Prefix, Address} + alias IP.Prefix.{Parser, InvalidPrefix, Helpers} defstruct ~w(address mask)a use Bitwise + import Helpers @moduledoc """ Defines an IP prefix, otherwise known as a subnet. @@ -36,13 +38,108 @@ defmodule IP.Prefix do %Prefix{address: Address.from_integer!(address &&& mask, 6), mask: mask} end + @doc """ + Create a prefix by attempting to parse a string of unknown version. + + Calling `from_string/2` is faster if you know the IP version of the prefix. + + ## Examples + + iex> "192.0.2.1/24" + ...> |> IP.Prefix.from_string() + ...> |> inspect() + "{:ok, #IP.Prefix<192.0.2.0/24>}" + + iex> "192.0.2.1/255.255.255.0" + ...> |> IP.Prefix.from_string() + ...> |> inspect() + "{:ok, #IP.Prefix<192.0.2.0/24>}" + + iex> "2001:db8::/64" + ...> |> IP.Prefix.from_string() + ...> |> inspect() + "{:ok, #IP.Prefix<2001:db8::/64>}" + """ + @spec from_string(binary) :: {:ok, t} | {:error, term} + def from_string(prefix), do: Parser.parse(prefix) + + @doc """ + Create a prefix by attempting to parse a string of specified IP version. + + ## Examples + + iex> "192.0.2.1/24" + ...> |> IP.Prefix.from_string(4) + ...> |> inspect() + "{:ok, #IP.Prefix<192.0.2.0/24>}" + + iex> "192.0.2.1/255.255.255.0" + ...> |> IP.Prefix.from_string(4) + ...> |> inspect() + "{:ok, #IP.Prefix<192.0.2.0/24>}" + + iex> "2001:db8::/64" + ...> |> IP.Prefix.from_string(4) + {:error, "Error parsing IPv4 prefix"} + """ + @spec from_string(binary, 4 | 6) :: {:ok, t} | {:error, term} + def from_string(prefix, version), do: Parser.parse(prefix, version) + + @doc """ + Create a prefix by attempting to parse a string of unknown version. + + Calling `from_string!/2` is faster if you know the IP version of the prefix. + + ## Examples + + iex> "192.0.2.1/24" + ...> |> IP.Prefix.from_string!() + #IP.Prefix<192.0.2.0/24> + + iex> "192.0.2.1/255.255.255.0" + ...> |> IP.Prefix.from_string!() + #IP.Prefix<192.0.2.0/24> + + iex> "2001:db8::/64" + ...> |> IP.Prefix.from_string!() + #IP.Prefix<2001:db8::/64> + """ + @spec from_string!(binary) :: t + def from_string!(prefix) do + case from_string(prefix) do + {:ok, prefix} -> prefix + {:error, msg} -> raise(InvalidPrefix, message: msg) + end + end + + @doc """ + Create a prefix by attempting to parse a string of specified IP version. + + ## Examples + + iex> "192.0.2.1/24" + ...> |> IP.Prefix.from_string!(4) + #IP.Prefix<192.0.2.0/24> + + iex> "192.0.2.1/255.255.255.0" + ...> |> IP.Prefix.from_string!(4) + #IP.Prefix<192.0.2.0/24> + """ + @spec from_string!(binary, 4 | 6) :: t + def from_string!(prefix, version) do + case from_string(prefix, version) do + {:ok, prefix} -> prefix + {:error, msg} -> raise(InvalidPrefix, message: msg) + end + end + @doc """ Returns the bit-length of the prefix. ## Example - iex> IP.Address.from_string!("192.0.2.1") - ...> |> IP.Address.to_prefix(24) + iex> "192.0.2.1/24" + ...> |> IP.Prefix.from_string!() ...> |> IP.Prefix.length() 24 """ @@ -54,26 +151,57 @@ defmodule IP.Prefix do ## Example - iex> IP.Address.from_string!("192.0.2.1") - ...> |> IP.Address.to_prefix(24) + iex> IP.Prefix.from_string!("192.0.2.1/24") ...> |> IP.Prefix.mask() 0b11111111111111111111111100000000 """ @spec mask(t) :: non_neg_integer def mask(%Prefix{mask: mask}), do: mask + @doc """ + Returns an old-fashioned subnet mask for IPv4 prefixes. + + ## Example + + iex> IP.Prefix.from_string!("192.0.2.0/24") + ...> |> IP.Prefix.subnet_mask() + "255.255.255.0" + """ + @spec subnet_mask(t) :: binary + def subnet_mask(%Prefix{mask: mask, address: %Address{version: 4}}) do + mask + |> Address.from_integer!(4) + |> Address.to_string() + end + + @doc """ + Returns an "cisco style" wildcard mask for IPv4 prefixes. + + ## Example + + iex> IP.Prefix.from_string!("192.0.2.0/24") + ...> |> IP.Prefix.wildcard_mask() + "0.0.0.255" + """ + @spec wildcard_mask(t) :: binary + def wildcard_mask(%Prefix{mask: mask, address: %Address{version: 4}}) do + mask + |> bnot() + |> band(@ipv4_mask) + |> Address.from_integer!(4) + |> Address.to_string() + end + @doc """ Returns the first address in the prefix. ## Examples - iex> IP.Address.from_string!("192.0.2.128") - ...> |> IP.Address.to_prefix(24) + iex> IP.Prefix.from_string!("192.0.2.128/24") ...> |> IP.Prefix.first() #IP.Address<192.0.2.0> - iex> IP.Address.from_string!("2001:db8::128") - ...> |> IP.Address.to_prefix(64) + iex> IP.Prefix.from_string!("2001:db8::128/64") ...> |> IP.Prefix.first() #IP.Address<2001:db8::> """ @@ -87,13 +215,11 @@ defmodule IP.Prefix do ## Examples - iex> IP.Address.from_string!("192.0.2.128") - ...> |> IP.Address.to_prefix(24) + iex> IP.Prefix.from_string!("192.0.2.128/24") ...> |> IP.Prefix.last() #IP.Address<192.0.2.255> - iex> IP.Address.from_string!("2001:db8::128") - ...> |> IP.Address.to_prefix(64) + iex> IP.Prefix.from_string!("2001:db8::128/64") ...> |> IP.Prefix.last() #IP.Address<2001:db8::ffff:ffff:ffff:ffff> """ @@ -106,15 +232,38 @@ defmodule IP.Prefix do Address.from_integer!(address, 6) end - def contains?(%Prefix{address: %Address{address: addr0, version: 4}, mask: mask}, %Address{address: addr1, version: 4}) + @doc """ + Returns `true` or `false` depending on whether the supplied `address` is + contained within `prefix`. + + ## Examples + + iex> IP.Prefix.from_string!("192.0.2.0/24") + ...> |> IP.Prefix.contains?(IP.Address.from_string!("192.0.2.127")) + true + + iex> IP.Prefix.from_string!("192.0.2.0/24") + ...> |> IP.Prefix.contains?(IP.Address.from_string!("198.51.100.1")) + false + + iex> IP.Prefix.from_string!("2001:db8::/64") + ...> |> IP.Prefix.contains?(IP.Address.from_string!("2001:db8::1")) + true + + iex> IP.Prefix.from_string!("2001:db8::/64") + ...> |> IP.Prefix.contains?(IP.Address.from_string!("2001:db8:1::1")) + false + """ + def contains?(%Prefix{address: %Address{address: addr0, version: 4}, mask: mask} = _prefix, + %Address{address: addr1, version: 4} = _address) when (addr0 &&& mask) <= addr1 and ((addr0 &&& mask) + (~~~(mask) &&& @ipv4_mask)) >= addr1 do true end - def contains?(%Prefix{address: %Address{address: addr0, version: 6}, mask: mask}, - %Address{address: addr1, version: 6}) + def contains?(%Prefix{address: %Address{address: addr0, version: 6}, mask: mask} = _prefix, + %Address{address: addr1, version: 6} = _address) when (addr0 &&& mask) <= addr1 and ((addr0 &&& mask) + (~~~(mask) &&& @ipv6_mask)) >= addr1 do @@ -123,19 +272,4 @@ defmodule IP.Prefix do def contains?(_prefix, _address), do: false - defp calculate_mask_from_length(length, mask_length) do - pad = mask_length - length - 0..(length - 1) - |> Enum.reduce(0, fn (i, mask) -> mask + (1 <<< i + pad) end) - end - - defp calculate_length_from_mask(mask) do - mask - |> Integer.digits(2) - |> Stream.filter(fn - 1 -> true - 0 -> false - end) - |> Enum.count() - end end diff --git a/lib/ip/prefix/helpers.ex b/lib/ip/prefix/helpers.ex new file mode 100644 index 0000000..3d55afb --- /dev/null +++ b/lib/ip/prefix/helpers.ex @@ -0,0 +1,23 @@ +defmodule IP.Prefix.Helpers do + use Bitwise + + @moduledoc false + + @doc false + def calculate_mask_from_length(length, mask_length) do + pad = mask_length - length + 0..(length - 1) + |> Enum.reduce(0, fn (i, mask) -> mask + (1 <<< i + pad) end) + end + + @doc false + def calculate_length_from_mask(mask) do + mask + |> Integer.digits(2) + |> Stream.filter(fn + 1 -> true + 0 -> false + end) + |> Enum.count() + end +end diff --git a/lib/ip/prefix/invalid_prefix.ex b/lib/ip/prefix/invalid_prefix.ex new file mode 100644 index 0000000..fd85e31 --- /dev/null +++ b/lib/ip/prefix/invalid_prefix.ex @@ -0,0 +1,8 @@ +defmodule IP.Prefix.InvalidPrefix do + defexception ~w(message)a + + @moduledoc """ + An exception raised by the bang-version functions in `IP.Prefix` when the + supplied value is invalid. + """ +end diff --git a/lib/ip/prefix/parser.ex b/lib/ip/prefix/parser.ex new file mode 100644 index 0000000..e5ab4bc --- /dev/null +++ b/lib/ip/prefix/parser.ex @@ -0,0 +1,103 @@ +defmodule IP.Prefix.Parser do + alias IP.{Prefix, Address} + import IP.Prefix.Helpers + + @moduledoc """ + Used internally by `IP.Prefix.from_string` to parse IP prefixes. + """ + + @doc """ + Attempts to parse a `prefix` of unknown IP version. + + This attempts to parse as IPv4 and then as IPv6. Obviously it's slower + than parsing a specific version if you know that at call time. + + ## Examples + + iex> "192.0.2.1/25" + ...> |> IP.Prefix.Parser.parse() + ...> |> inspect() + "{:ok, #IP.Prefix<192.0.2.0/25>}" + + iex> "2001:db8::/64" + ...> |> IP.Prefix.Parser.parse() + ...> |> inspect() + "{:ok, #IP.Prefix<2001:db8::/64>}" + """ + @spec parse(binary) :: Prefix.t + def parse(prefix) do + case parse(prefix, 4) do + {:ok, prefix} -> {:ok, prefix} + _ -> + case parse(prefix, 6) do + {:ok, prefix} -> {:ok, prefix} + _ -> {:error, "Unable to parse IP prefix"} + end + end + end + + @doc """ + Attempts to parse a `prefix` of a specific IP `version` from a string. + + ## Examples + + iex> "192.0.2.1/25" + ...> |> IP.Prefix.Parser.parse(4) + ...> |> inspect() + "{:ok, #IP.Prefix<192.0.2.0/25>}" + + iex> "2001:db8::/64" + ...> |> IP.Prefix.Parser.parse(6) + ...> |> inspect() + "{:ok, #IP.Prefix<2001:db8::/64>}" + """ + @spec parse(binary, 4 | 6) :: Prefix.t + def parse(prefix, 4 = _version) do + with {:ok, address, mask} <- ensure_contains_slash(prefix), + {:ok, address} <- Address.from_string(address, 4), + {:ok, mask} <- parse_v4_mask(mask) + do + {:ok, Prefix.new(address, mask)} + else + _ -> {:error, "Error parsing IPv4 prefix"} + end + end + + def parse(prefix, 6 = _version) do + with {:ok, address, mask} <- ensure_contains_slash(prefix), + {:ok, address} <- Address.from_string(address, 6), + {:ok, mask} <- parse_v6_mask(mask) + do + {:ok, Prefix.new(address, mask)} + else + _ -> {:error, "Error parsing IPv6 prefix"} + end + end + + defp ensure_contains_slash(prefix) do + case String.split(prefix, "/") do + [address, mask] -> {:ok, address, mask} + _ -> {:error, "Missing \"/\" in IP prefix"} + end + end + + defp parse_v4_mask(mask) do + case Address.from_string(mask, 4) do + {:ok, address} -> + mask = address + |> Address.to_integer() + |> calculate_length_from_mask() + {:ok, mask} + _ -> + {:ok, String.to_integer(mask)} + end + rescue + ArgumentError -> {:error, "Unable to parse IPv4 mask"} + end + + defp parse_v6_mask(mask) do + {:ok, String.to_integer(mask)} + rescue + ArgumentError -> {:error, "Unable to parse IPv6 mask"} + end +end diff --git a/test/ip/prefix/helpers_test.exs b/test/ip/prefix/helpers_test.exs new file mode 100644 index 0000000..eb4911b --- /dev/null +++ b/test/ip/prefix/helpers_test.exs @@ -0,0 +1,4 @@ +defmodule IPPrefixHelpersTest do + use ExUnit.Case + doctest IP.Prefix.Helpers +end diff --git a/test/ip/prefix/parser_test.exs b/test/ip/prefix/parser_test.exs new file mode 100644 index 0000000..52289a1 --- /dev/null +++ b/test/ip/prefix/parser_test.exs @@ -0,0 +1,4 @@ +defmodule IPPrefixParserTest do + use ExUnit.Case + doctest IP.Prefix.Parser +end