lib/sftp_client/operations/connect.ex

defmodule SFTPClient.Operations.Connect do
  @moduledoc """
  A module containing operations to connect to an SSH/SFTP server.
  """

  import SFTPClient.OperationUtil

  alias SFTPClient.Config
  alias SFTPClient.Conn
  alias SFTPClient.InvalidOptionError
  alias SFTPClient.Operations.Disconnect

  @doc """
  Connects to an SSH server and opens an SFTP channel.

  ## Options

  * `:host` (required) - The host of the SFTP server.
  * `:port` - The port of the SFTP server, defaults to 22.
  * `:user` - The user to authenticate as, when omitted tries to determine the
    current user.
  * `:password` - The password for the user.
  * `:user_dir` - The directory to read private keys from.
  * `:dsa_pass_phrase` - The passphrase for an DSA private key from the
    specified user dir.
  * `:rsa_pass_phrase` - The passphrase for an RSA private key from the
    specified user dir.
  * `:ecdsa_pass_phrase` - The passphrase for an ECDSA private key from the
    specified user dir.
  * `:private_key_path` - The path to the private key to use for authentication.
  * `:private_key_pass_phrase` - The passphrase that is used to decrypt the
    specified private key.
  * `:inet` - The IP version to use, either `:inet` (default) or `:inet6`.
  * `:sftp_vsn` - The SFTP version to be used.
  * `:connect_timeout` - The connection timeout in milliseconds (defaults to
    5000 ms), can be set to `:infinity` to disable timeout.
  * `:operation_timeout` - The operation timeout in milliseconds (defaults to
    5000 ms), can be set to `:infinity` to disable timeout.
  * `:key_cb` - A 2-item tuple containing:
     - A module that implements `:ssh_client_key_api` behaviour.
     - `:ssh_client_key_api` behaviour opts.
  * `:modify_algorithms` - An option passed to the underlying Erlang SSH
    implementation to support non-OTP default encryption algorithms. To
    re-enable `diffie-hellman-group1-sha1`, removed in OTP 23, pass:
    `modify_algorithms: [append: [kex: [:"diffie-hellman-group1-sha1"]]]`
  """
  @spec connect(Config.t() | Keyword.t() | %{optional(atom) => any}) ::
          {:ok, Conn.t()} | {:error, term}
  def connect(config_or_opts) do
    config = Config.new(config_or_opts)

    with :ok <- validate_config(config),
         {:ok, channel_pid, conn_ref} <- do_connect(config) do
      {:ok,
       %Conn{
         config: config,
         channel_pid: channel_pid,
         conn_ref: conn_ref
       }}
    end
  end

  @doc """
  Connects to an SSH server and opens an SFTP channel, then runs the function
  and closes the connection when finished. Accepts the same options as
  `connect/1`.
  """
  @spec connect(
          Config.t() | Keyword.t() | %{optional(atom) => any},
          (Conn.t() -> res)
        ) :: {:ok, res} | {:error, SFTPClient.error()}
        when res: var
  def connect(config_or_opts, fun) do
    with {:ok, conn} <- connect(config_or_opts) do
      {:ok, run_callback(conn, fun)}
    end
  end

  @doc """
  Connects to an SSH server and opens an SFTP channel. Accepts the same options
  as `connect/1`. Raises when the connection fails.
  """
  @spec connect!(Config.t() | Keyword.t() | %{optional(atom) => any}) ::
          Conn.t() | no_return
  def connect!(config_or_opts) do
    config_or_opts |> connect() |> may_bang!()
  end

  @doc """
  Connects to an SSH server and opens an SFTP channel, then runs the function
  and closes the connection when finished. Accepts the same options as
  `connect/1`. Raises when the connection fails.
  """
  @spec connect!(
          Config.t() | Keyword.t() | %{optional(atom) => any},
          (Conn.t() -> res)
        ) :: res | no_return
        when res: var
  def connect!(config_or_opts, fun) do
    config_or_opts
    |> connect!()
    |> run_callback(fun)
  end

  defp run_callback(conn, fun) do
    fun.(conn)
  after
    Disconnect.disconnect(conn)
  end

  defp validate_config(config) do
    config
    |> Map.from_struct()
    |> Enum.find_value(:ok, fn {key, value} ->
      case validate_config_value(key, value) do
        :ok ->
          nil

        {:error, reason} ->
          {:error, %InvalidOptionError{key: key, value: value, reason: reason}}
      end
    end)
  end

  defp validate_config_value(_key, nil), do: :ok

  defp validate_config_value(:private_key_path, path) do
    if File.exists?(Path.expand(path)) do
      :ok
    else
      {:error, :enoent}
    end
  end

  defp validate_config_value(_key, _value), do: :ok

  defp do_connect(config) do
    with {:error, error} <-
           sftp_adapter().start_channel(
             to_charlist(config.host),
             config.port,
             get_opts(config)
           ) do
      {:error, handle_error(error)}
    end
  end

  defp get_opts(config) do
    Enum.sort([
      {:quiet_mode, true},
      {:silently_accept_hosts, true},
      {:user_interaction, false}
      | handle_opts(config)
    ])
  end

  defp handle_opts(config) do
    config
    |> Map.take([
      :user,
      :password,
      :user_dir,
      :system_dir,
      :inet,
      :sftp_vsn,
      :connect_timeout,
      :dsa_pass_phrase,
      :rsa_pass_phrase,
      :ecdsa_pass_phrase,
      :key_cb,
      :modify_algorithms
    ])
    |> Enum.reduce([], fn
      {_key, nil}, opts ->
        opts

      {key, value}, opts ->
        [{key, handle_opt_value(key, value)} | opts]
    end)
  end

  defp handle_opt_value(key, value) do
    key
    |> map_opt_value(value)
    |> dump_opt_value()
  end

  defp map_opt_value(key, value) when key in [:user_dir, :system_dir] do
    Path.expand(value)
  end

  defp map_opt_value(_key, value), do: value

  defp dump_opt_value(value) when is_binary(value) do
    to_charlist(value)
  end

  defp dump_opt_value(value), do: value
end