lib/sqlite_vss.ex

defmodule SqliteVss do
  @moduledoc """
  `SqliteVss` module which installs the vector0 and vss0 extensions.
  """
  use Application
  require Logger

  @doc false
  def start(_, _) do
    Supervisor.start_link([], strategy: :one_for_one)
  end

  @doc """
  Returns the configured sqlite_vss version.
  """
  def current_version do
    config = :hex_core.default_config()

    {:ok, _} = Application.ensure_all_started(:inets)
    {:ok, _} = Application.ensure_all_started(:ssl)

    updated_config =
      Map.put(config, :http_adapter, {:hex_http_httpc, %{http_options: http_options()}})

    case :hex_api_package.get(updated_config, "sqlite_vss") do
      {:ok, {200, _headers, response}} ->
        response
        |> Map.get("releases")
        |> Enum.map(& &1["version"])
        |> List.first()

      {:ok, {404, _, _}} ->
        # TODO: remove this fallback once sqlite_vss is published
        "0.1.1-alpha.8"

      {:error, reason} ->
        reason
    end
  end

  @doc """
  Installs sqlite_vss with `current_version/0`.
  """
  def install(base_url \\ default_base_url()) do
    url = get_url(base_url)

    tar_binary = fetch_body!(url)

    bin_path = bin_path()

    with :ok <-
           :erl_tar.extract({:binary, tar_binary}, [
             :compressed,
             cwd: to_charlist(bin_path)
           ]) do
      Logger.debug("Copying artifact from release and extracting to #{bin_path}")
      :ok
    end
  end

  @doc """
  Returns the path to the executable.

  The executable may not be available if it was not yet installed.
  """
  def bin_path do
    {:ok, current_target} = current_target()
    name = "sqlite-vss-#{current_target}"

    Application.get_env(:sqlite_vss, :path) ||
      if Code.ensure_loaded?(Mix.Project) do
        Path.join(Path.dirname(Mix.Project.build_path()), name)
      else
        Path.expand("_build/#{name}")
      end
  end

  def filename(target) do
    "sqlite-vss-v#{current_version()}-loadable-#{target}.tar.gz"
  end

  @doc """
  The default URL to install SqliteVss from.
  """
  def default_base_url do
    "https://github.com/asg017/sqlite-vss/releases/download/v$version/sqlite-vss-v$version-loadable-$target.tar.gz"
  end

  def loadable_path_vector0() do
    SqliteVss.bin_path() <> "/vector0"
  end

  def loadable_path_vss0() do
    SqliteVss.bin_path() <> "/vss0"
  end

  defp target_config do
    current_system_arch = system_arch()

    %{
      os_type: :os.type(),
      target_system: current_system_arch,
      word_size: :erlang.system_info(:wordsize)
    }
  end

  def current_target!(target_config \\ target_config()) do
    current_target(target_config)
    |> elem(1)
  end

  def current_target(target_config \\ target_config()) do
    %{os_type: os_type, target_system: target_system} = target_config
    arch_str = target_system |> Map.values() |> Enum.join("-")

    case os_type do
      {:win32, _} ->
        {:error, "sqlite-vss is not available for architecture: #{arch_str}"}

      {:unix, osname} ->
        osname = if osname == :darwin, do: "macos", else: osname

        case target_config.target_system.arch do
          "x86_64" ->
            {:ok, "#{osname}-x86_64"}

          "aarch64" ->
            {:ok, "#{osname}-aarch64"}

          _ ->
            {:error,
             "precompiled artifact is not available for this target: \"#{arch_str}\".\nThe available targets are:\n - linux-x86_64\n - macos-aarch64\n - macos-x86_64"}
        end
    end
  end

  defp http_options(scheme) do
    http_options()
    |> maybe_add_proxy_auth(scheme)
  end

  defp http_options do
    # https://erlef.github.io/security-wg/secure_coding_and_deployment_hardening/inets
    cacertfile = cacertfile() |> String.to_charlist()

    [
      ssl: [
        verify: :verify_peer,
        cacertfile: cacertfile,
        depth: 2,
        customize_hostname_check: [
          match_fun: :public_key.pkix_verify_hostname_match_fun(:https)
        ]
      ]
    ]
  end

  defp fetch_body!(url) do
    scheme = URI.parse(url).scheme
    url = String.to_charlist(url)
    Logger.debug("Downloading sqlite-vss from #{url}")

    {:ok, _} = Application.ensure_all_started(:inets)
    {:ok, _} = Application.ensure_all_started(:ssl)

    if proxy = proxy_for_scheme(scheme) do
      %{host: host, port: port} = URI.parse(proxy)
      Logger.debug("Using #{String.upcase(scheme)}_PROXY: #{proxy}")
      set_option = if "https" == scheme, do: :https_proxy, else: :proxy
      :httpc.set_options([{set_option, {{String.to_charlist(host), port}, []}}])
    end

    options = [body_format: :binary]

    case :httpc.request(:get, {url, []}, http_options(scheme), options) do
      {:ok, {{_, 200, _}, _headers, body}} ->
        body

      other ->
        raise """
        couldn't fetch #{url}: #{inspect(other)}

        You may also install the "sqlite_vss" executable manually, \
        see the docs: https://hexdocs.pm/sqlite_vss
        """
    end
  end

  defp proxy_for_scheme("http") do
    System.get_env("HTTP_PROXY") || System.get_env("http_proxy")
  end

  defp proxy_for_scheme("https") do
    System.get_env("HTTPS_PROXY") || System.get_env("https_proxy")
  end

  defp maybe_add_proxy_auth(http_options, scheme) do
    case proxy_auth(scheme) do
      nil -> http_options
      auth -> [{:proxy_auth, auth} | http_options]
    end
  end

  defp proxy_auth(scheme) do
    with proxy when is_binary(proxy) <- proxy_for_scheme(scheme),
         %{userinfo: userinfo} when is_binary(userinfo) <- URI.parse(proxy),
         [username, password] <- String.split(userinfo, ":") do
      {String.to_charlist(username), String.to_charlist(password)}
    else
      _ -> nil
    end
  end

  defp cacertfile() do
    Application.get_env(:sqlite_vss, :cacerts_path) || CAStore.file_path()
  end

  defp get_url(base_url, target \\ current_target!()) do
    base_url
    |> String.replace("$version", current_version())
    |> String.replace("$target", target)
  end

  # Returns a map with `:arch`, `:vendor`, `:os` and maybe `:abi`.
  defp system_arch do
    base =
      :erlang.system_info(:system_architecture)
      |> List.to_string()
      |> String.split("-")

    triple_keys =
      case length(base) do
        4 ->
          [:arch, :vendor, :os, :abi]

        3 ->
          [:arch, :vendor, :os]

        _ ->
          # It's too complicated to find out, and we won't support this for now.
          []
      end

    triple_keys
    |> Enum.zip(base)
    |> Enum.into(%{})
  end
end