Skip to main content

lib/mix/tasks/safe/binary.ex

defmodule Safe.Binary do
  @moduledoc """
  Manages downloading, verifying, and caching the SAFE binary.

  The binary is stored at `<project_root>/_build/safe/safe`. A SHA-256
  checksum is verified against the manifest before the tarball is extracted.
  """

  require Logger

  @manifest_url "https://safe-releases.s3.eu-central-1.amazonaws.com/versions.json"
  @build_dir "_build/safe"
  @binary_name "safe"
  @checksum_suffix ".sha256"

  # ---------------------------------------------------------------------------
  # Public API
  # ---------------------------------------------------------------------------

  @doc "Returns the expected path of the SAFE binary for the given project."
  def binary_path(project_dir) do
    Path.join([project_dir, @build_dir, @binary_name])
  end

  @doc """
  Detects the current OS. Returns `{:ok, "linux"}`, `{:ok, "macos"}`, or
  `{:error, :unsupported_platform}`.
  """
  def detect_os do
    case :os.type() do
      {:unix, :linux} -> {:ok, "linux"}
      {:unix, :darwin} -> {:ok, "macos"}
      _ -> {:error, :unsupported_platform}
    end
  end

  @doc """
  Detects the CPU architecture and normalises it to the string used in SAFE
  release filenames. Returns `{:ok, "x86_64"}` on all supported architectures
  (Intel, AMD, ARM64/Apple Silicon), or `{:error, :unsupported_arch}`.

  SAFE ships a universal macOS binary and an x86_64 Linux binary, so all
  common architectures map to "x86_64".
  """
  def detect_arch do
    arch = :erlang.system_info(:system_architecture) |> to_string()
    normalize_arch(arch)
  end

  @doc "Normalises an architecture string to the value used in SAFE release filenames."
  def normalize_arch(arch) do
    cond do
      String.contains?(arch, "x86_64") -> {:ok, "x86_64"}
      String.contains?(arch, "amd64") -> {:ok, "x86_64"}
      String.contains?(arch, "aarch64") -> {:ok, "x86_64"}
      String.contains?(arch, "arm64") -> {:ok, "x86_64"}
      true -> {:error, :unsupported_arch}
    end
  end

  @doc "Computes a lowercase hex SHA-256 digest of `binary_data`."
  def compute_checksum(binary_data) do
    :crypto.hash(:sha256, binary_data)
    |> Base.encode16(case: :lower)
  end

  @doc """
  Returns true when `actual` and `expected` represent the same checksum.
  Comparison is case-insensitive and trims surrounding whitespace.
  """
  def verify_checksum(actual, expected) do
    String.downcase(String.trim(actual)) ==
      String.downcase(String.trim(expected))
  end

  @doc """
  Ensures the SAFE binary is present at `_build/safe/safe`.

  If the binary already exists, returns `:ok` immediately.
  Otherwise resolves the version (from `safe.lock` or the S3 manifest),
  downloads the tarball, verifies its checksum, extracts it, and writes
  `safe.lock`.
  """
  def ensure_binary_available(project_dir) do
    Application.ensure_all_started(:hackney)
    bin_path = binary_path(project_dir)

    case verify_cached_binary(bin_path) do
      :ok ->
        Logger.debug("SAFE binary already present and verified at #{bin_path}")
        :ok

      :stale ->
        with {:ok, os} <- detect_os(),
             {:ok, arch} <- detect_arch(),
             {:ok, version, versions_map} <- resolve_version(project_dir),
             :ok <- reset_build_dir(project_dir),
             {:ok, tar_path} <- download_binary(version, os, arch, project_dir),
             :ok <- verify_and_extract(tar_path, version, os, arch, versions_map, project_dir),
             :ok <- Safe.Version.write_lock(project_dir, version) do
          Logger.debug("SAFE #{version} installed at #{bin_path}")
          :ok
        end
    end
  end

  defp verify_cached_binary(bin_path) do
    sidecar = bin_path <> @checksum_suffix

    with true <- File.exists?(bin_path),
         true <- File.exists?(sidecar),
         {:ok, data} <- File.read(bin_path),
         {:ok, expected} <- File.read(sidecar),
         true <- verify_checksum(compute_checksum(data), expected) do
      :ok
    else
      _ ->
        if File.exists?(bin_path) do
          Logger.debug("Cached SAFE binary at #{bin_path} failed re-verification; re-downloading")
        end

        :stale
    end
  end

  defp reset_build_dir(project_dir) do
    dir = Path.join(project_dir, @build_dir)
    File.rm_rf!(dir)
    File.mkdir_p(dir)
  end

  # ---------------------------------------------------------------------------
  # Private helpers
  # ---------------------------------------------------------------------------

  defp resolve_version(project_dir) do
    case Safe.Version.read_lock(project_dir) do
      {:ok, version} ->
        Logger.debug("Using pinned SAFE version #{version} from safe.lock")

        with {:ok, body} <- http_get(@manifest_url),
             {:ok, versions_map} <- Jason.decode(body) do
          if Map.has_key?(versions_map, version) do
            {:ok, version, versions_map}
          else
            {:error, {:locked_version_not_found, version}}
          end
        end

      {:error, reason}
      when reason in [:not_found] or
             (is_tuple(reason) and elem(reason, 0) == :lock_parse_error) ->
        Logger.debug("No valid safe.lock found, fetching manifest from S3")

        with {:ok, body} <- http_get(@manifest_url),
             {:ok, versions_map} <- Jason.decode(body),
             {:ok, version} <- Safe.Version.resolve_version(versions_map) do
          {:ok, version, versions_map}
        end

      {:error, reason} ->
        {:error, reason}
    end
  end

  defp download_binary(version, os, arch, project_dir) do
    filename = "safe-#{version}-#{os}-#{arch}.tar.gz"
    url = "https://safe-releases.s3.eu-central-1.amazonaws.com/#{version}/#{filename}"
    dest_dir = Path.join(project_dir, @build_dir)
    tar_path = Path.join(dest_dir, filename)

    Logger.debug("Downloading SAFE binary from #{url}")
    Safe.IO.print_status("* downloading SAFE #{version}")

    with :ok <- File.mkdir_p(dest_dir),
         {:ok, body} <- http_get(url) do
      case File.write(tar_path, body) do
        :ok -> {:ok, tar_path}
        {:error, reason} -> {:error, {:write_failed, tar_path, reason}}
      end
    else
      {:error, {:http_error, 404}} -> {:error, {:http_error, 404}}
      {:error, reason} -> {:error, {:download_failed, url, reason}}
    end
  end

  defp verify_and_extract(tar_path, version, os, arch, versions_map, project_dir) do
    platform_key = "#{os}-#{arch}"

    with {:ok, tar_data} <- File.read(tar_path),
         {:ok, expected_checksum} <- get_platform_checksum(versions_map, version, platform_key) do
      actual_checksum = compute_checksum(tar_data)

      if verify_checksum(actual_checksum, expected_checksum) do
        dest_dir = Path.join(project_dir, @build_dir)

        case extract_tar(tar_path, dest_dir) do
          :ok ->
            File.rm(tar_path)
            bin_path = binary_path(project_dir)

            if File.exists?(bin_path) do
              write_binary_checksum(bin_path)
              File.chmod!(bin_path, 0o755)
              :ok
            else
              {:error, :binary_not_found_after_extract}
            end

          {:error, _} = err ->
            err
        end
      else
        File.rm(tar_path)
        {:error, {:checksum_mismatch, tar_path}}
      end
    end
  end

  defp get_platform_checksum(versions_map, version, platform_key) do
    case get_in(versions_map, [version, platform_key]) do
      nil -> {:error, {:no_checksum_for_platform, platform_key}}
      checksum -> {:ok, checksum}
    end
  end

  defp extract_tar(tar_path, dest_dir) do
    tar_charlist = String.to_charlist(tar_path)
    dest_charlist = String.to_charlist(dest_dir)

    case :erl_tar.extract(tar_charlist, [:compressed, {:cwd, dest_charlist}]) do
      :ok -> :ok
      {:error, reason} -> {:error, {:untar_failed, reason}}
    end
  end

  defp write_binary_checksum(bin_path) do
    digest = bin_path |> File.read!() |> compute_checksum()
    File.write!(bin_path <> @checksum_suffix, digest)
  end

  defp http_get(url) do
    http_client().get(url)
  end

  defp http_client do
    Application.get_env(:mix_safe, :http_client, Safe.HttpClient.Hackney)
  end
end