lib/spf/dns.ex

defmodule Spf.DNS do
  @moduledoc ~S"""
  A simple DNS caching resolver for SPF evaluations.

  During an SPF evaluation all DNS responses are cached.  Since the cache lasts
  only for the duration of the evaluation, TTL values are ignored. The cache
  allows for reporting on DNS data acquired during the evaluation. By
  preloading the cache, using `Spf.DNS.load/2`, new records can be tested.

  The caching resolver also tracks the number of DNS queries made and the
  number of void queries seen.

  ## Example

      iex> zonedata = "
      ...> example.com TXT v=spf1 +all
      ...> "
      iex> ctx = Spf.Context.new("example.com", dns: zonedata)
      iex> {_ctx, result} = Spf.DNS.resolve(ctx, "example.com", type: :txt)
      iex> result
      {:ok, ["v=spf1 +all"]}

  """

  import Spf.Context

  @ldh Enum.concat([?a..?z, [?-], ?0..?9, ?A..?Z])

  @rrtypes %{
    "a" => :a,
    "aaaa" => :aaaa,
    "cname" => :cname,
    "mx" => :mx,
    "ns" => :ns,
    "ptr" => :ptr,
    "soa" => :soa,
    "spf" => :spf,
    "txt" => :txt
  }

  @rgxtypes ~r/\s+(A|AAAA|CNAME|MX|NS|PTR|SOA|SPF|TXT)\s+/i

  @rrerrors %{
    "formerr" => :formerr,
    "nxdomain" => :nxdomain,
    "servfail" => :servfail,
    "timeout" => :timeout,
    "zero_answers" => :zero_answers
  }

  @typedoc """
  A DNS result in the form of an ok/error-tuple.

  In case of succes, this is a list of
  [`dns_data()`](https://www.erlang.org/doc/man/inet_res.html#type-dns_data)
  that is normalized (e.g. charlists are converted to strings as are ip address
  tuples).  Interpretation by caller depends on the `rrtype` used.

  """
  @type dns_result :: {:ok, [any]} | {:error, atom}

  @typedoc """
  An opaque datastructure as returned by `:inet_res.resolve/3` as part of its
  response.

  Interpretation is done using the (erlang internal) `:inet_dns` functions.

  """
  @type dns_msg :: any

  @typedoc """
  An `rrtype` denoted by an atom.

  See also
  [`inet_res.rr_type`](https://www.erlang.org/doc/man/inet_res.html#type-rr_type).

  """
  @type rrtype :: atom

  @typedoc """
  A `domain` is a simply an ascii binary.
  """
  @type domain :: binary

  @typedoc """
  A dns result as returned by `:inet_res.resolve/3`.

  """
  @type res_result :: {:ok, dns_msg} | {:error, any}

  # see also:
  # - https://www.rfc-editor.org/rfc/rfc6895.html
  # - https://erlang.org/doc/man/inet_res.html
  #
  # local cache is map: {domain, type} -> dns_result()

  # API

  @doc """
  Finds a domain `name`'s start of authority and contact.

  SPF evaluation might require evaluating multiple records of different
  domains.  This function allows for reporting the owner and contact for each
  SPF record encountered.

  Returns
  - `{:ok, domain, authority, contact}`, or
  - `{:error, reason}`

  The given `name` does not need to actually exist, the aim is to find the
  owner of the zone the `name` belongs to.  Note that CNAME's are ignored.

  This function should be used *after* evaluation has completed, since it may
  cause void DNS responses. The soa-record is searched by querying for the
  soa-record and dropping the front-label (possibly mulitple times) while
  trying again.

  ## Examples

      iex> Spf.Context.new("example.com")
      ...> |> Spf.DNS.authority("non-existing.example.com")
      {:ok, "non-existing.example.com", "example.com", "noc@dns.icann.org"}

      iex> zonedata = "
      ...> www.example.com CNAME example.org
      ...> "
      iex> Spf.Context.new("some.tld", dns: zonedata)
      ...> |> Spf.DNS.authority("www.example.com")
      {:ok, "www.example.com", "example.com", "noc@dns.icann.org"}

  """
  @spec authority(Spf.Context.t(), binary) :: {:ok, domain, domain, binary} | {:error, atom}
  def authority(ctx, name) do
    labels = normalize(name) |> String.split(".", trim: true)

    # note: since a zone might be delegated, search needs to start with the
    # full domain and drops labels as search continues for the soa record
    for d <- 0..(length(labels) - 2) do
      Enum.drop(labels, d) |> Enum.join(".")
    end
    |> authorityp(ctx)
    |> case do
      {:ok, domain, contact} -> {:ok, name, domain, contact}
      {:error, reason} -> {:error, reason}
    end
  end

  @doc """
  Checks validity of a domain name and returns `{:ok, name}` or `{:error, reason}`

  Given `domain` can be a binary or a charlist.  It is normalized (downcase'd,
  trailing dot removed and, if applicable, charlist is converted to a binary)
  and checked that it:

  - is an ascii string
  - is less than 254 chars long
  - has labels that are 1..63 chars long
  - has no empty labels
  - has at least 2 labels
  - has a valid ldh-toplabel

  ## Examples

      iex> check_domain("com")
      {:error, "not multi-label"}

      iex> check_domain(".example.com")
      {:error, "empty label"}

      iex> check_domain("example..com")
      {:error, "empty label"}

      iex> check_domain(<<128>> <> ".com")
      {:error, "contains non-ascii characters"}

      iex> check_domain("example.-com")
      {:error, "tld starts with hyphen"}

      iex> check_domain("example.com-")
      {:error, "tld ends with hyphen"}

      iex> check_domain("example.c%m")
      {:error, "tld not ldh"}

      # trailing dot is dropped
      iex> check_domain("example.c0m.")
      {:ok, "example.c0m"}

      # returned as lowercase binary without the trailing dot
      iex> check_domain('example.COM.')
      {:ok, "example.com"}

  """
  @spec check_domain(binary) :: {:ok, domain} | {:error, binary}
  def check_domain(domain) do
    domain = normalize(domain)
    lbs = String.split(domain, ".")
    tld = List.last(lbs)

    cond do
      String.length(domain) > 253 ->
        {:error, "domain name too long"}

      length(lbs) < 2 ->
        {:error, "not multi-label"}

      Enum.any?(lbs, fn l -> String.length(l) > 63 end) ->
        {:error, "label too long"}

      Enum.any?(lbs, fn l -> String.length(l) < 1 end) ->
        {:error, "empty label"}

      domain != for(<<c <- domain>>, c < 128, into: "", do: <<c>>) ->
        {:error, "contains non-ascii characters"}

      String.starts_with?(tld, "-") ->
        {:error, "tld starts with hyphen"}

      String.ends_with?(tld, "-") ->
        {:error, "tld ends with hyphen"}

      tld == for(<<c <- tld>>, c in ?0..?9, into: "", do: <<c>>) ->
        {:error, "tld all numeric"}

      tld != for(<<c <- tld>>, c in @ldh, into: "", do: <<c>>) ->
        {:error, "tld not ldh"}

      true ->
        {:ok, domain}
    end
  end

  @doc """
  Returns an updated context, a normalized name and a cache result in a 3-tuple

  The cache result can be one of:
  - `{:error, reason}`
  - `{:ok, rrs}`, for a cache hit (where `rrs` is a list of rrdata's).

  Where `reason` includes:
  - `:cache_miss`, nothing found in the cache
  - `:nxdomain`, a previously cached result
  - `:servfail`, a previously cached result or a cname loop was found
  - `:timeout`, a previously cached result
  - `:zero_answers`, a previously cached result
  - `:illegal_name`, name was not a proper domain name

  Note that this function does not make any real DNS requests and does not
  update any dns counters.  The only time the context is updated is when there
  was an error in either `domain` or the `t:rrtype/0` given.

  ## Example

      iex> zonedata = "
      ...> example.net CNAME example.com
      ...> EXAMPLE.COM. A 1.2.3.4
      ...> "
      iex> {_ctx, result} = Spf.Context.new("some.domain.tld", dns: zonedata)
      ...> |> Spf.DNS.from_cache("example.net", :a)
      iex> result
      {:ok, ["1.2.3.4"]}

  """
  @spec from_cache(Spf.Context.t(), domain, rrtype) :: {Spf.Context.t(), dns_result()}
  def from_cache(context, name, type) do
    with {:ok, name} <- check_domain(name),
         {context, {:ok, name}} <- cname(context, name, type) do
      case context.dns[{name, type}] do
        nil -> {context, {:error, :cache_miss}}
        [{:error, reason}] -> {context, {:error, reason}}
        result -> {context, {:ok, result}}
      end
    else
      {:error, reason} -> {log(context, :dns, :error, "#{reason}"), {:error, :illegal_name}}
      {context, {:error, reason}} -> {context, {:error, reason}}
    end
  end

  @doc """
  Filters the `t:dns_result/0`, keeps only the rrdata's for which `fun` returns
  a truthy value.

  If the `dns_result` is actually an error, it is returned untouched.

  ## Examples

      iex> zonedata = "
      ...> example.com TXT v=spf1 -all
      ...> example.com TXT another txt record
      ...> "
      iex> ctx = Spf.Context.new("example.com", dns: zonedata)
      iex> {_ctx, dns_result} = resolve(ctx, "example.com", type: :txt)
      iex>
      iex> dns_result
      {:ok, ["another txt record", "v=spf1 -all"]}
      iex>
      iex> filter(dns_result, &Spf.Eval.spf?/1)
      {:ok, ["v=spf1 -all"]}

      iex> dns_result = {:error, :nxdomain}
      iex> Spf.DNS.filter(dns_result, &Spf.Eval.spf?/1)
      {:error, :nxdomain}

  """
  @spec filter(dns_result(), function()) :: dns_result()
  def filter(dns_result, fun)

  def filter({:ok, rrdatas}, fun),
    do: filter(rrdatas, fun)

  def filter({:error, {reason, _dns_msg}}, _fun),
    # just in case the DNS result was directly supplied by inet_res.resolve
    # rather than Spf.DNS.resolve ...
    do: {:error, reason}

  def filter({:error, reason}, _fun),
    do: {:error, reason}

  def filter(rrdatas, fun) when is_function(fun, 1),
    do: {:ok, Enum.filter(rrdatas, fn rrdata -> fun.(rrdata) end)}

  @doc """
  Populates the dns cache of given `context`, with `dns`'s zonedata.

  `dns` can be a path to an existing file, a multi-line binary containing
  individual RR-records per line or a list thereof. The cache is held in the
  SPF evaluation `context` under the `:dns` key and is a simple map: `{name,
  rrtype}` -> `[rdata]`.

  Lines should be formatted as
  - `name  rrtype  rdata`, or
  - `name  rrtype  error`

  where
  - `rrtype` is one of: #{inspect(Map.keys(@rrtypes))}
  - `error` is one of #{inspect(Map.keys(@rrerrors))}
  - `rdata` text representation of data suitable for given `rrtype`

  Unknown rr-types or otherwise malformed RR's are ignored and logged as a
  warning during preloading.

  It is possible to load zonedata multiple times, each one adds to the cache.
  Note that when setting errors, they always override other similar RR's
  regardless of ordering.

  ## Examples

      iex> zonedata = "
      ...> example.com TXT v=spf1 +all
      ...> example.com A timeout
      ...> EXAMPLE.NET AAAA servfail
      ...> "
      iex> ctx = Spf.Context.new("some.domain.tld")
      ...> |> Spf.DNS.load(zonedata)
      iex>
      iex> {_ctx, result} = Spf.DNS.resolve(ctx, "example.com", type: :txt)
      iex> result
      {:ok, ["v=spf1 +all"]}
      iex>
      iex> Spf.DNS.resolve(ctx, "example.com", type: :a) |> elem(1)
      {:error, :timeout}
      iex>
      iex> Spf.DNS.resolve(ctx, "example.net", type: :aaaa) |> elem(1)
      {:error, :servfail}
      iex>
      iex> ctx.dns
      %{{"example.com", :a} => [error: :timeout],
        {"example.com", :txt} => ["v=spf1 +all"],
        {"example.net", :aaaa} => [error: :servfail]
      }

      iex> zonedata1 = "
      ...> example.com A 1.2.3.4
      ...> example.com A timeout
      ...> example.net A 9.10.11.12
      ...> "
      iex> zonedata2 = "
      ...> example.com AAAA servfail
      ...> example.com AAAA acdc:1976::1
      ...> example.net A 5.6.7.8
      ...> example.net A 9.10.11.12
      ...> "
      iex> ctx = Spf.Context.new("some.tld")
      ...> |> Spf.DNS.load(zonedata1)
      ...> |> Spf.DNS.load(zonedata2)
      iex> ctx.dns[{"example.com", :a}]
      [{:error, :timeout}]
      iex> ctx.dns[{"example.com", :aaaa}]
      [{:error, :servfail}]
      iex> ctx.dns[{"example.net", :a}]
      ["5.6.7.8", "9.10.11.12"]

  """
  @spec load(Spf.Context.t(), nil | binary | [binary]) :: Spf.Context.t()
  def load(context, dns)

  def load(ctx, nil),
    do: ctx

  def load(ctx, dns) do
    case File.exists?(dns) do
      true -> load_file(ctx, dns)
      false -> load_zonedata(ctx, dns)
    end
  end

  @doc """
  Normalize a domain name by trimming, downcasing and removing any trailing
  dot.

  The validity of the domain name is *not* checked.

  ## Examples

      iex> normalize("Example.COM.")
      "example.com"

      iex> normalize("EXAMPLE.C%M")
      "example.c%m"

  """
  @spec normalize(domain | charlist) :: binary
  def normalize(domain) when is_binary(domain) do
    domain
    |> String.trim()
    |> String.replace(~r/\.$/, "")
    |> String.downcase()
  end

  def normalize(domain) when is_list(domain),
    do: List.to_string(domain) |> normalize()

  @doc """
  Resolves a query, updates the cache and returns a {`ctx`,
  `t:dns_result/0`}-tuple.

  Returns one of:
  - `{ctx, {:error, reason}}` if a DNS error occurred or was cached earlier
  - `{ctx, {:ok, [rrs]}}` where rrs is a list of rrdata's

  Although a result with ZERO answers is technically not a DNS error, it
  will be reported as an error.  Error reasons include:
  - `:zero_answers`
  - `:illegal_name`
  - `:timeout`
  - `:nxdomain`
  - `:servfail`
  - other

  Options include:
  - `type:`, which defaults to `:a`
  - `stats`, which defaults to `true`

  When `stats` is `false`, void DNS responses (`:nxdomain` or `:zero_answers`)
  are not counted.

  """
  @spec resolve(Spf.Context.t(), domain, Keyword.t()) :: {Spf.Context.t(), dns_result}
  def resolve(ctx, name, opts \\ []) do
    stats = Keyword.get(opts, :stats, true)
    type = Keyword.get(opts, :type, Map.get(ctx, :atype, :a))

    case from_cache(ctx, name, type) do
      # note that from_cache may return {ctx, {:error, :illegal_name}}
      {ctx, {:error, :cache_miss}} ->
        # cache miss and a legal name here
        tick(ctx, :num_dnsq)
        |> query(name, type, stats)

      {ctx, result} ->
        # either positive result, or {:error, :illegal_name}
        tick(ctx, :num_dnsq)
        |> do_stats(name, type, result, stats, cached: true)
    end
  end

  @doc ~S"""
  Return all acquired DNS RR's in a flat list of printable lines.

  Note that RR's with multiple entries in their rrdata are listed individually,
  so the output can be copy/paste'd into a local dns.txt pre-cache to
  facilitate experimentation with RR records.

  The lines are sorted such that domains and subdomains are kept together as
  much as possible.

  ## Example

      iex> zonedata = "
      ...> example.com TXT v=spf1 -all
      ...> a.example.com A 1.2.3.4
      ...> b.example.com AaAa timeout
      ...> "
      iex> ctx = Spf.Context.new("example.com", dns: zonedata)
      iex> Spf.DNS.to_list(ctx)
      ...> |> Enum.map(fn x -> String.replace(x, ~r/\s+/, " ") end)
      [
        "example.com TXT \"v=spf1 -all\"",
        "a.example.com A 1.2.3.4",
        "b.example.com AAAA TIMEOUT"
      ]
      iex> to_list(ctx, valid: :true)
      ...> |> Enum.map(fn x -> String.replace(x, ~r/\s+/, " ") end)
      [
        "example.com TXT \"v=spf1 -all\"",
        "a.example.com A 1.2.3.4"
      ]
      iex> to_list(ctx, valid: false)
      ...> |> Enum.map(fn x -> String.replace(x, ~r/\s+/, " ") end)
      [
        "b.example.com AAAA TIMEOUT"
      ]

  """
  @spec to_list(Spf.Context.t(), Keyword.t()) :: [binary]
  def to_list(ctx, opts \\ []) do
    keep =
      case Keyword.get(opts, :valid, :both) do
        false -> fn x -> rr_is_error(x) end
        true -> fn x -> not rr_is_error(x) end
        _ -> fn _ -> true end
      end

    Map.get(ctx, :dns, %{})
    |> Enum.map(fn entry -> rr_flatten(entry) end)
    |> List.flatten()
    |> rrs_sort()
    |> Enum.filter(fn {_domain, _type, data} -> keep.(data) end)
    |> Enum.map(fn {domain, type, data} -> rr_encode(domain, type, data) end)
  end

  # Helpers

  @spec authorityp([binary], Spf.Context.t()) :: {:error, atom} | {:ok, domain, binary}
  defp authorityp([], _ctx), do: {:error, :nxdomain}

  defp authorityp([head | tail], ctx) do
    # note: checks ctx.dns-cache directly for {`head`, :soa} skipping CNAME's
    {ctx, _} = resolve(ctx, head, type: :soa)

    case ctx.dns[{head, :soa}] do
      [{_, contact, _, _, _, _, _}] ->
        {:ok, head, String.replace(contact, ".", "@", global: false)}

      _ ->
        authorityp(tail, ctx)
    end
  end

  @spec do_stats(Spf.Context.t(), domain, rrtype, dns_result, boolean, Keyword.t()) ::
          {Spf.Context.t(), dns_result}
  defp do_stats(ctx, name, type, result, stats, opts \\ []) do
    # log any warnings, possibly update void stats & return {ctx, result}
    # note: num_dnsq is updated in resolve, not here
    qry =
      case Keyword.get(opts, :cached, false) do
        true -> "DNS QUERY (#{ctx.num_dnsq}) [cache] #{type} #{name}"
        false -> "DNS QUERY (#{ctx.num_dnsq}) #{type} #{name}"
      end

    delta = if stats, do: 1, else: 0

    case result do
      {:error, :zero_answers} ->
        # a previous cache_miss, cached as zero answers
        {tick(ctx, :num_dnsv, delta) |> log(:dns, :warn, "#{qry} - ZERO answers"), result}

      {:error, :nxdomain} ->
        # nxdomain is a void query
        {tick(ctx, :num_dnsv, delta) |> log(:dns, :warn, "#{qry} - NXDOMAIN"), result}

      {:error, reason} ->
        # any other error, like :servfail or :illegal_name
        err = String.upcase("#{inspect(reason)}")

        {log(ctx, :dns, :warn, "#{qry} - #{err}"), result}

      {:ok, res} ->
        {log(ctx, :dns, :info, "#{qry} - #{inspect(res)}"), result}
    end
  end

  @spec query(Spf.Context.t(), binary, atom, boolean) :: {Spf.Context.t(), dns_result}
  defp query(ctx, name, type, stats) do
    # query DNS for name, type
    opts = []
    timeout = Map.get(ctx, :dns_timeout, 2000)
    opts = Keyword.put(opts, :timeout, timeout)

    opts =
      case Map.get(ctx, :nameservers) do
        nil -> opts
        list -> Keyword.put(opts, :nameservers, list)
      end

    # resolve and update the cache with dns_msg received
    ctx =
      name
      |> String.to_charlist()
      |> :inet_res.resolve(:in, type, opts)
      |> to_dns_results()
      |> cache(ctx, name, type)

    # get result (or not) from cache
    {ctx, result} =
      case from_cache(ctx, name, type) do
        {ctx, {:error, :cache_miss}} -> {ctx, {:error, :zero_answers}}
        {ctx, result} -> {ctx, result}
      end

    do_stats(ctx, name, type, result, stats)
  rescue
    # query should never see illegal names (like example..com), so donot
    # worry about inet_res.resolve() raising FunctionClauseError because
    # it cannot encode the domain name's labels.

    x in CaseClauseError ->
      error = {:error, :unknown_rr_type}

      ctx =
        update(ctx, {name, type, error})
        |> log(:dns, :error, "DNS error: #{name} #{type}: #{inspect(x)}")

      {ctx, error}
  end

  # DNS->CACHE

  @spec to_dns_results(dns_msg) :: dns_result
  defp to_dns_results(msg) do
    # given a dns_msg {:dns_rec, ...} or error-tuple
    # -> return either: {:ok, [{domain, type, value}, ...]} | {:error, reason}
    # notes:
    # - in an `anlist`, each rrdata in the set has its own rrtype
    # - this happens e.g. when resolving for :A and you get :CNAME + :A back
    with {:ok, record} <- msg,
         answers <- :inet_dns.msg(record, :anlist) do
      rrdatas =
        for answer <- answers do
          domain = :inet_dns.rr(answer, :domain) |> to_string()
          type = :inet_dns.rr(answer, :type)
          data = :inet_dns.rr(answer, :data) |> charlists_tostr(type)
          {domain, type, data}
        end

      {:ok, rrdatas}
    end
  end

  @spec cname(Spf.Context.t(), domain, rrtype, map) :: {Spf.Context.t(), {:ok | :error, any}}
  defp cname(ctx, name, type, seen \\ %{})

  defp cname(ctx, name, :cname, _),
    do: {ctx, {:ok, name}}

  defp cname(ctx, name, type, seen) do
    # return canonical name if present, name otherwise, must follow CNAME's
    if seen[name] do
      ctx = log(ctx, :dns, :error, "DNS SERVFAIL - circular CNAMEs: #{inspect(Map.keys(seen))}")

      {ctx, {:error, :servfail}}
    else
      case ctx.dns[{name, :cname}] do
        nil -> {ctx, {:ok, name}}
        [{:error, reason}] -> {ctx, {:error, reason}}
        [realname] -> cname(ctx, realname, type, Map.put(seen, name, realname))
      end
    end
  end

  # from: https://www.erlang.org/doc/man/inet_res.html
  # inet_res.resolve results are one of:
  # a) {:ok, dns_msg()}
  # b) {:error, Reason}, or
  # c) {:error, {Reason, dns_msg()}}
  #
  # where Reason = inet:posix() | res_error()
  # - inet:posix() = an atom named from the POSIX error codes used in Unix
  # - res_error() = formerr, qfmterror, servfail, nxdomain, notimp, refused, badvers, timeout
  #
  # cache stores either
  # - {name, type} -> {:error, :err_code}, or
  # - {name, type} -> [rrdata]

  @spec cache(dns_result, Spf.Context.t(), binary, rrtype) :: Spf.Context.t()
  defp cache({:error, reason}, ctx, name, type),
    do: update(ctx, {name, type, {:error, reason}})

  defp cache({:ok, []}, ctx, name, type),
    do: update(ctx, {name, type, {:error, :zero_answers}})

  defp cache({:ok, entries}, ctx, _name, _type),
    do: Enum.reduce(entries, ctx, fn entry, acc -> update(acc, entry) end)

  @spec update(Spf.Context.t(), {binary, rrtype, any}) :: Spf.Context.t()
  defp update(ctx, {domain, type, {:error, reason}}) do
    error =
      case reason do
        {error_type, _} -> {:error, error_type}
        reason -> {:error, reason}
      end

    Map.put(ctx, :dns, Map.put(ctx.dns, {domain, type}, [error]))
    |> log(:dns, :debug, "added {#{domain}, #{type} -> #{inspect(error)}")
  end

  defp update(ctx, {domain, type, data}) do
    # update the cache for a single entry
    # - donot use from_cache since that unrolls cnames
    cache = Map.get(ctx, :dns, %{})
    domain = normalize(domain)
    cached = cache[{domain, type}] || []
    data = charlists_tostr(data, type)

    case data in cached do
      true ->
        ctx

      false ->
        Map.put(ctx, :dns, Map.put(cache, {domain, type}, [data | cached]))
        |> log(:dns, :debug, "added {#{domain}, #{type}} -> #{inspect(data)}")
    end
  end

  # charlists_tostr/2
  # note:
  # - charlists_tostr is called on non-error dns results, since update/2 has
  #   separate func to inserting errors into the cache (it overwrites).
  # - given a single rdata & type, turn its charlists into binaries (if any)
  # - e.g. charlists_tostr('some text record', :txt)
  # - relies on the fact that query is used for SPF related DNS queries
  #   and authority (:soa) queries only.
  #   (so the number of rrtypes to support is limited)

  # :a, :aaaa rdata
  defp charlists_tostr(ip, rrtype) when rrtype in [:a, :aaaa],
    do: "#{Pfx.new(ip)}"

  # :mta name to string
  defp charlists_tostr({pref, domain}, :mx),
    do: {pref, to_string(domain)}

  # soa rdata
  defp charlists_tostr({mname, rname, serial, refresh, retry, expiry, ttl}, :soa),
    do: {to_string(mname), to_string(rname), serial, refresh, retry, expiry, ttl}

  # other rdata, including :txt, :spf, :ptr, :cname, :ns
  defp charlists_tostr(val, type) when type in [:txt, :spf, :ptr, :cname, :ns],
    do: to_string(val)

  # LINES->CACHE

  @spec load_file(Spf.Context.t(), binary) :: Spf.Context.t()
  defp load_file(ctx, fpath) when is_binary(fpath) do
    ctx =
      case File.read(fpath) do
        {:ok, binary} ->
          load_zonedata(ctx, binary)

        {:error, reason} ->
          log(ctx, :dns, :error, "failed to read #{fpath}: #{inspect(reason)}")
      end

    log(ctx, :dns, :debug, "DNS cache has #{map_size(ctx.dns)} entries")
  end

  @spec load_zonedata(Spf.Context.t(), binary | [binary]) :: Spf.Context.t()
  defp load_zonedata(ctx, binary) when is_binary(binary),
    do:
      String.split(binary, "\n", trim: true)
      |> Enum.map(&String.trim/1)
      |> Enum.filter(fn line -> not String.match?(line, ~r/\s*#/) end)
      |> then(fn lines -> load_zonedata(ctx, lines) end)

  defp load_zonedata(ctx, lines) when is_list(lines) do
    {malformed, good} =
      lines
      |> Enum.map(&rr_decode/1)
      |> List.flatten()
      |> Enum.split_with(fn {k, _, _} -> k == :error end)

    {errors, normal} =
      Enum.split_with(good, fn {_, _, v} -> is_tuple(v) and elem(v, 0) == :error end)

    ctx =
      Enum.reduce(malformed, ctx, fn {_, reason, line}, ctx ->
        log(ctx, :dns, :warn, "RR ignored: #{reason} - #{line}")
      end)

    ctx = Enum.reduce(normal, ctx, fn entry, ctx -> update(ctx, entry) end)
    Enum.reduce(errors, ctx, fn error, ctx -> update(ctx, error) end)
  end

  defp rr_decode(line) do
    with [name, type, rdata] <- String.split(line, @rgxtypes, parts: 2, include_captures: true),
         {:ok, name} <- check_domain(name),
         {:ok, type} <- rrtype_decode(type),
         {:ok, rdata} <- rrdata_decode(type, rdata) do
      {name, type, rdata}
    else
      {:error, reason} -> {:error, reason, line}
      _ -> {:error, "malformed RR", line}
    end
  end

  defp rrtype_decode(type),
    do: {:ok, @rrtypes[String.trim(type) |> String.downcase()]}

  defp rrdata_decode(type, rdata) do
    error = String.downcase(rdata)

    case @rrerrors[error] do
      nil -> rrdata_type_decode(type, rdata)
      atom -> {:ok, {:error, atom}}
    end
  end

  # Notes: rrdata_type_decode
  # - lines are split using regex @rgxtypes, so only those types need
  #   to be dealt with.
  defp rrdata_type_decode(type, rdata) when type in [:txt, :spf],
    do: {:ok, no_quotes(rdata)}

  defp rrdata_type_decode(type, rdata) when type in [:a, :aaaa] do
    pfx = Pfx.new(rdata)

    case {type, pfx.maxlen} do
      {:a, 32} -> {:ok, rdata}
      {:aaaa, 128} -> {:ok, String.downcase(rdata)}
    end
  rescue
    _ -> {:error, "illegal address"}
  end

  defp rrdata_type_decode(:mx, rdata) do
    with [pref, name] <- String.split(rdata, ~r/\s+/, parts: 2),
         {:ok, domain} <- check_domain(name),
         {pref, ""} <- Integer.parse(pref) do
      {:ok, {pref, domain}}
    else
      :error -> {:error, "illegal pref"}
      error -> error
    end
  end

  defp rrdata_type_decode(type, rdata) when type in [:ptr, :cname, :ns],
    do: check_domain(rdata)

  defp rrdata_type_decode(:soa, rdata) do
    # ns responsible-name serial refresh retry expire nxdomain-ttl
    with [ns, rn, serial, refresh, retry, expire, ttl] <-
           String.split(rdata, ~r/\s+/, parts: 7),
         {:ok, ns} <- check_domain(ns),
         {:ok, rn} <- check_domain(rn),
         {:serial, {serial, ""}} <- {:serial, Integer.parse(serial)},
         {:refresh, {refresh, ""}} <- {:refresh, Integer.parse(refresh)},
         {:retry, {retry, ""}} <- {:retry, Integer.parse(retry)},
         {:expire, {expire, ""}} <- {:expire, Integer.parse(expire)},
         {:ttl, {ttl, ""}} <- {:ttl, Integer.parse(ttl)} do
      {:ok, {ns, rn, serial, refresh, retry, expire, ttl}}
    else
      {number, :error} -> {:error, "illegal #{number}"}
      error -> error
    end
  end

  # CACHE->LINES

  @spec rr_encode(binary, atom, any) :: binary
  defp rr_encode(domain, type, data) do
    rrtype = String.upcase("#{type}")
    rrdata = rrdata_encode(type, data)
    "#{domain} #{rrtype} #{rrdata}"
  end

  @spec rrdata_encode(rrtype, any) :: binary
  defp rrdata_encode(_, {:error, reason}) do
    "#{inspect(reason)}"
    |> String.upcase()
    |> String.trim_leading(":")
  end

  defp rrdata_encode(type, ip) when type in [:a, :aaaa] do
    "#{Pfx.new(ip)}"
  rescue
    # just in case..
    _ -> ip
  end

  defp rrdata_encode(:mx, {pref, domain}),
    do: "#{pref} #{domain}"

  defp rrdata_encode(:txt, txt),
    do: inspect(txt)

  defp rrdata_encode(:soa, {ns, rn, serial, refresh, retry, expiry, ttl}),
    do: "#{ns} #{rn} #{serial} #{refresh} #{retry} #{expiry} #{ttl}"

  defp rrdata_encode(_type, data),
    do: "#{inspect(data)}" |> no_quotes()

  defp rr_flatten({{domain, type}, rrdatas}),
    do: for(rrdata <- rrdatas, do: {domain, type, rrdata})

  defp rrs_sort(rrs) do
    # keeps related records close to each other in report output
    Enum.sort(rrs, fn {domain1, _type, _data}, {domain2, _type1, _data2} ->
      String.reverse(domain1) <= String.reverse(domain2)
    end)
  end

  defp rr_is_error(data) do
    case data do
      {:error, _} -> true
      _ -> false
    end
  end

  @spec no_quotes(binary) :: binary
  defp no_quotes(str) do
    str
    |> String.replace(~r/^\"/, "")
    |> String.replace(~r/\"$/, "")
  end
end