defmodule Safe.Binary do @moduledoc """ Manages downloading, verifying, and caching the SAFE binary. The binary is stored at `/_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