defmodule DomainName do @moduledoc """ A module to describe Internet domain names, together with some useful functions (testing if a domain is a subdomain of another, etc). This module does not implement the DNS protocol, it its only for domain names, independently of how they are used. You may use packages such as [dns](https://hexdocs.pm/dns/) if you want DNS abilities. ## Examples ``` iex> d = DomainName.new!("something.example.") iex> d2 = DomainName.new!("EXAMPLE") iex> d3 = DomainName.new!("fr") iex> DomainName.ends_with?(d, d2) true iex> DomainName.ends_with?(d, d3) false ``` """ @typedoc """ DomainName is is a domain name. It is an opaque type and you should not access its fields, *much less* modify them. """ # https://elixirschool.com/en/lessons/advanced/typespec#defining-custom-type-3 @opaque t :: %__MODULE__{ name: String.t(), original: String.t(), labels: [String.t()], original_labels: [String.t()] } # Original means: "original case". defstruct [:name, :original, :labels, :original_labels] @hostname_regexp ~r/^[a-z0-9-\.]+$/ defmodule Invalid do defexception message: "Invalid domain name" end # From https://michal.muskala.eu/post/error-handling-in-elixir-libraries/ defmacrop unwrap_or_raise(call) do quote do case unquote(call) do {:ok, value} -> value {:error, reason} -> raise DomainName.Invalid, reason end end end @spec new(String.t(), boolean) :: {:ok, DomainName.t()} | {:error, String.t()} @doc """ Creates a DomainName from a string in dot-separated notation. s is the string, `must_be_hostname` indicates if the name must follow the stricter "host syntax". Currently, the string must be in pure ASCII (no IDN). ## Examples ``` iex> {:ok, _d} = DomainName.new("toto.fr") iex> DomainName.new("toto fr", true) {:error, "Not a hostname (they are restricted to LDH)"} ``` """ def new(s, must_be_hostname \\ false) when not is_nil(s) do # Root is a special case root = s == "." s = if String.last(s) == "." do String.slice(s, 0..(String.length(s) - 2)) else s end idn = Enum.reduce(String.to_charlist(s), false, fn c, acc -> if c > 128 do true else acc end end) ds = String.downcase(s) l = String.split(ds, ".") original_l = String.split(s, ".") empty_labels = Enum.filter(l, fn l -> l == "" end) != [] can_be_hostname = String.match?(ds, @hostname_regexp) cond do idn -> {:error, "We currently don't support IDN"} root -> {:ok, %DomainName{:name => s, :original => s, :labels => [], :original_labels => []}} must_be_hostname and not can_be_hostname -> {:error, "Not a hostname (they are restricted to LDH)"} empty_labels -> {:error, "Two consecutive dots"} true -> {:ok, %DomainName{:name => ds, :original => s, :labels => l, :original_labels => original_l}} end end @spec new!(String.t(), boolean) :: DomainName.t() @doc """ Creates a DomainName from a string in dot-separated notation and raises the exception `DomainName.Invalid` if `must_be_hostname` is true and the name does not have the proper syntax. Apart from that, see the documentation for `new/2`. ## Examples ``` iex> _d = DomainName.new!("toto.fr") iex> _d = DomainName.new!("toto. fr", true) ** (DomainName.Invalid) Not a hostname (they are restricted to LDH) ``` """ def new!(s, must_be_hostname \\ false) do unwrap_or_raise(new(s, must_be_hostname)) end @spec name(DomainName.t()) :: String.t() @doc """ Returns the domain name as a string. ## Examples ``` iex> d = DomainName.new!("toto.fr") iex> DomainName.name(d) "toto.fr" ``` """ def name(d) do d.name end @spec can_be_hostname?(DomainName.t()) :: boolean @doc """ Returns true if the domain name can be a host name (stricter syntax, see RFC 1123, section 2.1) ## Examples ``` iex> d = DomainName.new!("example.com") iex> DomainName.can_be_hostname?(d) true iex> d = DomainName.new!("example$.com") iex> DomainName.can_be_hostname?(d) false ``` """ def can_be_hostname?(d) do String.match?(d.name, @hostname_regexp) end @spec original(DomainName.t()) :: String.t() @doc """ Returns the domain name as a string, in the original case (can be useful for things like case-randomization by resolvers). ## Examples ``` iex> d = DomainName.new!("toTO.Fr") iex> DomainName.name(d) "toto.fr" iex> DomainName.original(d) "toTO.Fr" ``` """ def original(d) do d.original end @spec original_labels(DomainName.t()) :: [String.t()] @doc """ Returns the labels of the domain name, in the original case (can be useful for things like case-randomization by resolvers). ## Examples ``` iex> d = DomainName.new!("toTO.Fr") iex> DomainName.labels(d) ["toto", "fr"] iex> DomainName.original_labels(d) ["toTO", "Fr"] ``` """ def original_labels(d) do d.original_labels end @spec equal?(DomainName.t(), DomainName.t()) :: boolean @doc """ Checks if two domain names are identical (in a case-insensitive way). ## Examples ``` iex> d1 = DomainName.new!("example.com") iex> d2 = DomainName.new!("example.COM") iex> DomainName.equal?(d1, d2) true iex> d3 = DomainName.new!("example.org") iex> DomainName.equal?(d1, d3) false ``` """ def equal?(l, r) do l.name == r.name end @spec list_ends_with?([any], [any]) :: boolean @doc false defp list_ends_with?(l1, []) when is_list(l1) do true end @doc false defp list_ends_with?(l1, l2) when is_list(l1) and is_list(l2) do length(l1) >= length(l2) and (List.last(l1) == List.last(l2) and list_ends_with?( elem(List.pop_at(l1, length(l1) - 1), 1), elem(List.pop_at(l2, length(l2) - 1), 1) )) end @spec ends_with?(DomainName.t(), DomainName.t()) :: boolean @doc """ Checks if a domain is a subdomain of the other one (or a list of others). ## Examples ``` iex> d1 = DomainName.new!("foobar.example.net") iex> d2 = DomainName.new!("example.net") iex> DomainName.ends_with?(d1, d2) true iex> d3 = DomainName.new!("org") iex> DomainName.ends_with?(d1, d3) false iex> d = DomainName.new!("truc.machin.chose") iex> l = [DomainName.new!("example.com"), DomainName.new!("machin.chose"), DomainName.new!("fr")] iex> DomainName.ends_with?(d, l) true iex> l2 = [DomainName.new!("example.com"), DomainName.new!("fr")] iex> DomainName.ends_with?(d, l2) false ``` """ def ends_with?(l = %DomainName{}, r = %DomainName{}) do list_ends_with?(l.labels, r.labels) end def ends_with?(l = %DomainName{}, r) when is_list(r) do Enum.reduce_while(r, false, fn d, acc -> if list_ends_with?(l.labels, d.labels) do {:halt, true} else {:cont, acc} end end) end @spec ends_with(DomainName.t(), [DomainName.t()]) :: {true, DomainName.t()} | false @doc """ Checks if a domain is a subdomain of one of the domains in the second parameter (and, if true, returns that parent domain). ## Examples ``` iex> d = DomainName.new!("truc.machin.chose") iex> l = [DomainName.new!("example.com"), DomainName.new!("machin.chose"), DomainName.new!("fr")] iex> DomainName.ends_with(d, l) {true, %DomainName{ labels: ["machin", "chose"], name: "machin.chose", original: "machin.chose", original_labels: ["machin", "chose"] }} iex> l2 = [DomainName.new!("example.com"), DomainName.new!("fr")] iex> DomainName.ends_with(d, l2) false ``` """ # Yes, the return value can be a tuple {true, base} or an unary # false. See # # for a discussion. # # There is no question mark at the end of the function name since # does not return a boolean. def ends_with(l = %DomainName{}, r) when is_list(r) do Enum.reduce_while(r, false, fn d, acc -> if list_ends_with?(l.labels, d.labels) do {:halt, {true, d}} else {:cont, acc} end end) end @spec is_one_of(DomainName.t(), [DomainName.t()]) :: {true, DomainName.t()} | false @doc """ Checks if a domain is equal to one of the domains in the second parameter (and, if true, returns that domain). ## Examples ``` iex> d = DomainName.new!("machin.chose") iex> l = [DomainName.new!("example.com"), DomainName.new!("machin.chose"), DomainName.new!("fr")] iex> DomainName.is_one_of(d, l) {true, %DomainName{ labels: ["machin", "chose"], name: "machin.chose", original: "machin.chose", original_labels: ["machin", "chose"] }} iex> l2 = [DomainName.new!("example.com"), DomainName.new!("chose")] iex> DomainName.is_one_of(d, l2) false ``` """ def is_one_of(l = %DomainName{}, r) when is_list(r) do Enum.reduce_while(r, false, fn d, acc -> if l.name == d.name do {:halt, {true, d}} else {:cont, acc} end end) end @spec without_suffix(DomainName.t(), DomainName.t()) :: {:ok|:error, DomainName.t()} @doc """ If a domain is a subdomain of the domain in the second parameter returns :ok and the short version of the first parameter. ## Examples ``` iex> d = DomainName.new!("truc.machin.chose") iex> {:ok, s} = DomainName.without_suffix(d, DomainName.new!("machin.chose")) iex> DomainName.name(s) "truc" iex> {:error, _d} = DomainName.without_suffix(d, DomainName.new!("machin.com")) ``` """ def without_suffix(d, suffix) do if not ends_with?(d, suffix) do {:error, d} else {:ok, join!(Enum.slice(d.labels, 0, length(d.labels) - length(suffix.labels)))} end end @spec labels(DomainName.t()) :: [String.t()] @doc """ Returns the list of labels in the domain name. ## Examples ``` iex> {:ok, d} = DomainName.new("foo.bar.example.") iex> DomainName.labels(d) ["foo", "bar", "example"] ``` """ def labels(d = %DomainName{}) do d.labels end @spec tld(DomainName.t()) :: String.t() @doc """ Returns the TLD (Top-Level domain) of the domain name. ## Examples ``` iex> {:ok, d} = DomainName.new("foo.bar.example.") iex> DomainName.tld(d) "example" ``` """ def tld(d = %DomainName{}) do List.last(d.labels) end @spec join(String.t(), DomainName.t() | [String.t()]) :: {:ok, DomainName.t()} | {:error, String.t()} @doc """ Returns the domain name from the subdomain `s` of the domain `d`. ## Examples ``` iex> d = DomainName.new!("thing.example") iex> {:ok, r} = DomainName.join("some", d) iex> DomainName.name(r) "some.thing.example" ``` """ def join(s, d = %DomainName{}) when is_binary(s) do new("#{new!(s)}.#{d}") end @doc """ Returns the domain name from the list of labels `l`. ## Examples ``` iex> {:ok, r} = DomainName.join(["some", "thing", "example"]) iex> DomainName.name(r) "some.thing.example" ``` """ def join(l) when is_list(l) do new( Enum.reduce(l, "", fn label, acc -> if acc == "" do label else acc <> "." <> label end end) ) end @spec join!(String.t(), DomainName.t()) :: DomainName.t() @doc """ Returns the domain name from the subdomain `s` of the domain `d`, and raises an exception if there is a problem. ## Examples ``` iex> d = DomainName.new!("thing.example") iex> r = DomainName.join!("some", d) iex> DomainName.name(r) "some.thing.example" ``` """ def join!(s, d = %DomainName{}) when is_binary(s) do unwrap_or_raise(join(s, d)) end @spec join!([String.t()]) :: DomainName.t() @doc """ Returns the domain name from the list of labels `l`, and raises an exception if there is a problem. ## Examples ``` iex> r = DomainName.join!(["some", "thing", "example"]) iex> DomainName.name(r) "some.thing.example" ``` """ def join!(l) when is_list(l) do unwrap_or_raise(join(l)) end # The functions `encode` and `decode` are useful only when dealing with the DNS. @doc """ Encode a domain name in DNS wire format. """ @spec encode(DomainName.t()) :: [integer] def encode(d = %DomainName{}, original \\ false) do labels = if original do d.original_labels else d.labels end root = [0] cond do # The domain name root is special labels == [] -> root true -> l = Enum.reduce(labels, [], fn label, acc -> acc ++ [byte_size(label)] ++ to_charlist(label) end) l ++ root end end @spec decode([integer]) :: {:ok, DomainName.t()} | {:error, String.t()} @doc """ Decode a domain name from the list of bytes in its wire representation. """ def decode(l) when is_list(l) do result = Enum.reduce(l, {:next, {1, ""}}, fn c, acc -> if c > 128 do {:error, "We currently don't handle names with the 8th bit set"} else case acc do {:next, {_i, s}} -> {:label, {c, s}} {:label, {i, s}} -> case i do 0 -> raise RuntimeError 1 -> {:next, {1, s <> <> <> "."}} _ -> {:label, {i - 1, s <> <>}} end {:error, reason} -> {:error, reason} end end end) case result do {:label, {i, s}} -> if i != 0 do {:error, "Invalid byte string for a domain name"} else new(s) end {:next, _} -> {:error, "Things missing in the byte string?"} {:error, reason} -> {:error, reason} end end @spec decode!([integer]) :: DomainName.t() @doc """ Decode a domain name from the list of bytes in its wire representation, and raises an exception if it is not a proper encoding. """ def decode!(l) when is_list(l) do unwrap_or_raise(decode(l)) end defimpl String.Chars, for: DomainName do def to_string(d) do d.name end end end