lib/paddle/parsing.ex

defmodule Paddle.Parsing do
  @moduledoc ~S"""
  Module composed of utility functions for translating between `:eldap` and
  `Paddle` representation.
  """

  # =====================
  # == DN manipulation ==
  # =====================

  @spec construct_dn(keyword | [{binary, binary}], binary | charlist) :: charlist

  @doc ~S"""
  Construct a DN Erlang string based on a keyword list or a string.

  Examples:

      iex> Paddle.Parsing.construct_dn(uid: "user", ou: "People")
      'uid=user,ou=People'

      iex> Paddle.Parsing.construct_dn([{"uid", "user"}, {"ou", "People"}], "dc=organisation,dc=org")
      'uid=user,ou=People,dc=organisation,dc=org'

      iex> Paddle.Parsing.construct_dn("uid=user,ou=People", "dc=organisation,dc=org")
      'uid=user,ou=People,dc=organisation,dc=org'

  Values are escaped.

  Note: using a map is highly discouraged because the key / values may be
  reordered and because they can be mistaken for a class object (see
  `Paddle.Class`).
  """
  def construct_dn(map, base \\ '')

  def construct_dn(subdn, base) when is_binary(base) do
    construct_dn(subdn, :binary.bin_to_list(base))
  end

  def construct_dn([], base) when is_list(base), do: base

  def construct_dn(subdn, base) when is_binary(subdn) and is_list(base),
    do: :binary.bin_to_list(subdn) ++ ',' ++ base

  def construct_dn(nil, base) when is_list(base), do: base

  def construct_dn(map, '') do
    ',' ++ dn =
      Enum.reduce(
        map,
        '',
        fn {key, value}, acc ->
          acc ++ ',#{key}=#{ldap_escape(value)}'
        end
      )

    dn
  end

  def construct_dn(map, base) when is_list(base) do
    construct_dn(map, '') ++ ',' ++ base
  end

  @spec dn_to_kwlist(charlist | binary) :: [{binary, binary}]

  @doc ~S"""
  Tranform an LDAP DN to a keyword list.

  Well, not exactly a keyword list but a list like this:

      [{"uid", "user"}, {"ou", "People"}, {"dc", "organisation"}, {"dc", "org"}]

  Example:

      iex> Paddle.Parsing.dn_to_kwlist("uid=user,ou=People,dc=organisation,dc=org")
      [{"uid", "user"}, {"ou", "People"}, {"dc", "organisation"}, {"dc", "org"}]
  """
  def dn_to_kwlist(""), do: []
  def dn_to_kwlist(nil), do: []

  def dn_to_kwlist(dn) when is_binary(dn) do
    %{"key" => key, "value" => value, "rest" => rest} =
      Regex.named_captures(~r/^(?<key>.+)=(?<value>.+)(,(?<rest>.+))?$/U, dn)

    [{key, value}] ++ dn_to_kwlist(rest)
  end

  def dn_to_kwlist(dn), do: dn_to_kwlist(List.to_string(dn))

  @spec ldap_escape(charlist | binary) :: charlist

  @doc ~S"""
  Escape special LDAP characters in a string.

  Example:

      iex> Paddle.Parsing.ldap_escape("a=b#c\\")
      'a\\=b\\#c\\\\'
  """
  def ldap_escape(''), do: ''

  def ldap_escape([char | rest]) do
    escaped_char =
      case char do
        ?, -> '\\,'
        ?# -> '\\#'
        ?+ -> '\\+'
        ?< -> '\\<'
        ?> -> '\\>'
        ?; -> '\\;'
        ?" -> '\\\"'
        ?= -> '\\='
        ?\\ -> '\\\\'
        _ -> [char]
      end

    escaped_char ++ ldap_escape(rest)
  end

  def ldap_escape(token), do: ldap_escape(:binary.bin_to_list(token))

  # =============
  # == Entries ==
  # =============

  @type eldap_dn :: charlist
  @type eldap_entry :: {:eldap_entry, eldap_dn, [{charlist, [charlist]}]}

  @spec clean_eldap_search_results(
          {:ok, {:eldap_search_result, [eldap_entry]}}
          | {:error, atom},
          charlist
        ) :: {:ok, [Paddle.ldap_entry()]} | {:error, Paddle.search_ldap_error()}

  @doc ~S"""
  Convert an `:eldap` search result to a `Paddle` representation.

  Also see `clean_entries/1`

  Examples:

      iex> eldap_entry = {:eldap_entry, 'uid=testuser,ou=People', [{'uid', ['testuser']}]}
      iex> Paddle.Parsing.clean_eldap_search_results({:ok, {:eldap_search_result, [eldap_entry], []}}, '')
      {:ok, [%{"dn" => "uid=testuser,ou=People", "uid" => ["testuser"]}]}

      iex> Paddle.Parsing.clean_eldap_search_results({:ok, {:eldap_search_result, [], []}}, '')
      {:error, :noSuchObject}

      iex> Paddle.Parsing.clean_eldap_search_results({:error, :insufficientAccessRights}, '')
      {:error, :insufficientAccessRights}

      iex> eldap_entry = {:eldap_entry, 'uid=testuser,ou=People', [{'uid', ['testuser']}]}
      iex> Paddle.Parsing.clean_eldap_search_results({:ok, {:eldap_search_result, [eldap_entry], []}}, 'ou=People')
      {:ok, [%{"dn" => "uid=testuser", "uid" => ["testuser"]}]}
  """
  def clean_eldap_search_results({:error, error}, _base) do
    {:error, error}
  end

  def clean_eldap_search_results({:ok, {:eldap_search_result, [], []}}, _base) do
    {:error, :noSuchObject}
  end

  def clean_eldap_search_results({:ok, {:eldap_search_result, [], [], :asn1_NOVALUE}}, _base) do
    {:error, :noSuchObject}
  end

  # I have no idea why this is happening.
  def clean_eldap_search_results({:ok, {:eldap_search_result, entries, [], :asn1_NOVALUE}}, base) do
    {:ok, clean_entries(entries, base)}
  end

  def clean_eldap_search_results({:ok, {:eldap_search_result, entries, []}}, base) do
    {:ok, clean_entries(entries, base)}
  end

  @spec entry_to_class_object(Paddle.ldap_entry(), Paddle.Class.t()) :: Paddle.Class.t()

  @doc ~S"""
  Convert a `Paddle` entry to a given `Paddle` class object.

  Example:

      iex> entry = %{"dn" => "uid=testuser,ou=People", "uid" => ["testuser"], "description" => ["hello"]}
      iex> Paddle.Parsing.entry_to_class_object(entry, %MyApp.PosixAccount{})
      %MyApp.PosixAccount{cn: nil, description: ["hello"], gecos: nil,
        gidNumber: nil, homeDirectory: nil, host: nil, l: nil,
        loginShell: nil, o: nil, ou: nil, seeAlso: nil, uid: ["testuser"],
        uidNumber: nil, userPassword: nil}
  """
  def entry_to_class_object(entry, target) do
    entry =
      entry
      |> Map.drop(["dn", "objectClass"])
      |> Enum.map(fn {key, value} -> {String.to_atom(key), value} end)
      |> Enum.into(%{})

    Map.merge(target, entry)
  end

  @spec clean_entries([eldap_entry], charlist) :: [Paddle.ldap_entry()]

  @doc ~S"""
  Get a binary map representation of several eldap entries.

  The `base` argument corresponds to the DN base which should be stripped from
  the result's `"dn"` attribute.

  Example:

      iex> Paddle.Parsing.clean_entries([{:eldap_entry, 'uid=testuser,ou=People', [{'uid', ['testuser']}]}], '')
      [%{"dn" => "uid=testuser,ou=People", "uid" => ["testuser"]}]

      iex> Paddle.Parsing.clean_entries([{:eldap_entry, 'uid=testuser,ou=People', [{'uid', ['testuser']}]}], 'ou=People')
      [%{"dn" => "uid=testuser", "uid" => ["testuser"]}]
  """
  def clean_entries(entries, base) do
    base_length = length(base)

    entries
    |> Enum.map(&clean_entry(&1, base_length))
  end

  @spec clean_entry(eldap_entry, integer) :: Paddle.ldap_entry()

  @doc ~S"""
  Get a binary map representation of a single eldap entry.

  The `base_length` argument corresponds to the DN base length which should be
  stripped from the result's `"dn"` attribute.

  Example:

      iex> Paddle.Parsing.clean_entry({:eldap_entry, 'uid=testuser,ou=People', [{'uid', ['testuser']}]}, 0)
      %{"dn" => "uid=testuser,ou=People", "uid" => ["testuser"]}

      iex> Paddle.Parsing.clean_entry({:eldap_entry, 'uid=testuser,ou=People', [{'uid', ['testuser']}]}, 9)
      %{"dn" => "uid=testuser", "uid" => ["testuser"]}
  """
  def clean_entry({:eldap_entry, dn, attributes}, base_length) do
    %{"dn" => dn |> List.to_string() |> strip_base_from_dn(base_length)}
    |> Map.merge(
      attributes
      |> attributes_to_binary
      |> Enum.into(%{})
    )
  end

  defp strip_base_from_dn(dn, 0) when is_binary(dn), do: dn

  defp strip_base_from_dn(dn, base_length) when is_binary(dn) do
    dn_length = String.length(dn)
    String.slice(dn, 0, dn_length - base_length - 1)
  end

  # ===================
  # == Modifications ==
  # ===================

  @spec mod_convert(Paddle.mod()) :: tuple

  @doc ~S"""
  Convert a user-friendly modify operation to an eldap operation.

  Examples:

      iex> Paddle.Parsing.mod_convert {:add, {"description", "This is a description"}}
      {:ModifyRequest_changes_SEQOF, :add,
       {:PartialAttribute, 'description', ['This is a description']}}

      iex> Paddle.Parsing.mod_convert {:delete, "description"}
      {:ModifyRequest_changes_SEQOF, :delete,
       {:PartialAttribute, 'description', []}}

      iex> Paddle.Parsing.mod_convert {:replace, {"description", "This is a description"}}
      {:ModifyRequest_changes_SEQOF, :replace,
       {:PartialAttribute, 'description', ['This is a description']}}
  """
  def mod_convert(operation)

  def mod_convert({:add, {field, value}}) do
    field = '#{field}'
    value = list_wrap(value)
    :eldap.mod_add(field, value)
  end

  def mod_convert({:delete, field}) do
    field = '#{field}'
    :eldap.mod_delete(field, [])
  end

  def mod_convert({:replace, {field, value}}) do
    field = '#{field}'
    value = list_wrap(value)
    :eldap.mod_replace(field, value)
  end

  # ===================
  # == Miscellaneous ==
  # ===================

  @spec list_wrap(term) :: [charlist]

  @doc ~S"""
  Wrap things in lists and convert binaries / atoms to charlists.

      iex> Paddle.Parsing.list_wrap "hello"
      ['hello']

      iex> Paddle.Parsing.list_wrap :hello
      ['hello']

      iex> Paddle.Parsing.list_wrap ["hello", "world"]
      ['hello', 'world']
  """
  def list_wrap(list) when is_list(list), do: list |> Enum.map(&'#{&1}')
  def list_wrap(thing), do: ['#{thing}']

  # =======================
  # == Private Utilities ==
  # =======================

  @spec attributes_to_binary([{charlist, [charlist]}]) :: [{binary, [binary]}]

  defp attributes_to_binary(attributes) do
    attributes
    |> Enum.map(&attribute_to_binary/1)
  end

  @spec attribute_to_binary({charlist, [charlist]}) :: {binary, [binary]}

  defp attribute_to_binary({key, values}) do
    {List.to_string(key), values |> Enum.map(&:binary.list_to_bin/1)}
  end
end