lib/domainname.ex

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(), [Keyword.t()]) :: {:ok, DomainName.t()} | {:error, String.t()}
  @doc """
  Creates a DomainName from a string in dot-separated notation. s is
  the string, `options[:must_be_hostname]` indicates if the name must
  follow the stricter "host syntax". Currently, the string must be in
  pure ASCII (no IDN, see issue #2).

  ## Examples
  ```
      iex> {:ok, _d} = DomainName.new("toto.fr")

      iex> DomainName.new("toto fr", [must_be_hostname: true])
      {:error, "Not a hostname (they are restricted to LDH)"}
  ```
  """
  def new(s, options \\ [])

  def new(s, options) when not is_nil(s) and is_binary(s) do
    options = Keyword.merge([must_be_hostname: false], options)
    # 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, see https://framagit.org/bortzmeyer/domainname-elixir/-/issues/2"}

      root ->
        {:ok, %DomainName{:name => s, :original => s, :labels => [], :original_labels => []}}

      options[: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

  # For character lists
  def new(cl, options) when is_list(cl) do
    cl
    |> List.to_string()
    |> new(options)
  end

  @spec new!(String.t(), [Keyword.t()]) :: DomainName.t()
  @doc """
  Creates a DomainName from a string in dot-separated notation and
  raises the exception `DomainName.Invalid` if
  `options[: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", [must_be_hostname: true])
    ** (DomainName.Invalid) Not a hostname (they are restricted to LDH)

  ```
  """
  def new!(s, options \\ []) do
    unwrap_or_raise(new(s, options))
  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
  # <https://elixirforum.com/t/how-is-it-recommended-to-return-result-from-a-boolean-function-which-includes-error-data-in-case-of-fault/>
  # 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.original_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
    join(labels(new!(s)) ++ labels(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"
  ```

  """
  # The empty list is a special case for when without_suffix returns
  # an empty name.
  def join([]) do
    {:ok, %DomainName{:name => "", :original => "", :labels => [], :original_labels => []}}
  end

  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(), [Keyword.t()]) :: [integer]
  def encode(d = %DomainName{}, options \\ []) do
    options = Keyword.merge([original: false], options)

    labels =
      if options[: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 <> <<c::utf8>> <> "."}}

                _ ->
                  {:label, {i - 1, s <> <<c::utf8>>}}
              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

  defimpl List.Chars, for: DomainName do
    def to_charlist(d) do
      Kernel.to_charlist(d.name)
    end
  end

  defimpl Inspect, for: DomainName do
    def inspect(d, _options) do
      "#DomainName<#{d.name}>"
    end
  end
end