mix.tasks/zig.get.ex

defmodule Zig.Get do
  @moduledoc false

  def os_info do
    :system_architecture
    |> :erlang.system_info()
    |> to_string
    |> String.split("-")
    |> decode_os_info()
  end

  defp decode_os_info([arch, "apple" | _]), do: {"macos", arch}
  defp decode_os_info([arch, _vendor, os | _]), do: {os, arch}
end

defmodule Mix.Tasks.Zig.Get do
  use Mix.Task

  @shortdoc "Obtains the Zig compiler toolchain"

  @moduledoc """
  obtains the Zig compiler toolchain

      $ mix zig.get [--version VERSION] [--from FROM] [--os OS] [--arch ARCH]

  the zigler compiler will be downloaded to ZIG_ARCHIVE_PATH/VERSION

  if unspecified, VERSION defaults to the major/minor version of zig.get

  if FROM is specified, will use the FROM file instead of getting from the internet.

  if unspecified, ZIG_ARCHIVE_PATH defaults to the user cache path given by
  `:filename.basedir/3` with application name `"zigler"`.

  OS and ARCH will be detected from the current build system.  It's not
  recommended to change these arguments.

  ### environment variable options

  - `TAR_COMMAND`: path to a tar executable that is equivalent to gnu tar.
    only useful for non-windows architectures.
  - `NO_VERIFY`: disable signature verification of the downloaded file.
    Not recommended.
  - `ZIG_ARCHIVE_PATH`: path to desired directory to achive the zig compiler toolchain.
  """

  defstruct ~w(version path arch os url file verify hash force)a

  def run(app_opts) do
    # Elixir 1.14 cannot take a list of applications for this function
    Enum.each([:inets, :ssl, :crypto], &Application.ensure_all_started/1)

    :ssl.cipher_suites(:all, :"tlsv1.2")

    app_opts
    |> parse_opts()
    |> set_archive_path()
    |> ensure_tar()
    |> verify()
    |> get_meta()
    |> ensure_destination
    |> request!
    |> verify_hash!
    |> do_extract

    IO.puts("completed download of zig compiler toolchain")
  end

  defp parse_opts(app_opts), do: parse_opts(app_opts, defaults())

  defp parse_opts([], so_far), do: so_far

  defp parse_opts(["--version", version | rest], so_far) do
    parse_opts(rest, %{so_far | version: version})
  end

  defp parse_opts(["--file", file | rest], so_far) do
    parse_opts(rest, %{so_far | file: file})
  end

  defp parse_opts(["--os", os | rest], so_far) do
    parse_opts(rest, %{so_far | os: os})
  end

  defp parse_opts(["--arch", arch | rest], so_far) do
    parse_opts(rest, %{so_far | arch: arch})
  end

  defp parse_opts(["--force" | rest], so_far) do
    parse_opts(rest, %{so_far | force: true})
  end

  defp set_archive_path(opts) do
    case System.get_env("ZIG_ARCHIVE_PATH", "") do
      "" -> opts
      path -> %{opts | path: to_charlist(Path.expand(path))}
    end
  end

  @default_version "0.13.0"

  defp defaults do
    {os, arch} = Zig.Get.os_info()

    %__MODULE__{
      version: @default_version,
      path: :filename.basedir(:user_cache, ~C"zigler"),
      os: os,
      arch: arch,
      force: false
    }
  end

  defp ensure_tar(%{os: "windows"} = opts), do: opts

  defp ensure_tar(opts) do
    cond do
      System.get_env("TAR_COMMAND") ->
        opts

      tar_command = System.find_executable("tar") ->
        System.put_env("TAR_COMMAND", tar_command)
        opts

      true ->
        Mix.raise(
          "tar command is required to install zig on this system architecture but not found.  Please install tar and try again."
        )
    end
  end

  defp ensure_destination(opts) do
    target_directory = Path.join(opts.path, "zig-#{opts.os}-#{opts.arch}-#{opts.version}")

    cond do
      File.exists?(target_directory) && opts.force ->
        File.rm_rf!(target_directory)
      File.exists?(target_directory) ->
        Mix.shell().info("zig is already installed, rerun with --force to overwrite")
        System.halt()
      true -> :nothing_to_do
    end

    File.mkdir_p!(opts.path)

    opts
  end

  defp verify(opts) do
    if System.get_env("VERIFY", "true") == "false" do
      %{opts | verify: false}
    else
      %{opts | verify: true}
    end
  end

  defp get_meta(opts) do
    meta =
      "https://ziglang.org/download/index.json"
      |> http_get!()
      |> Jason.decode!()
      |> Map.fetch!(opts.version)
      |> Map.fetch!("#{opts.arch}-#{opts.os}")

    hash = if opts.verify, do: Map.fetch!(meta, "shasum")

    %{opts | hash: hash, url: Map.fetch!(meta, "tarball")}
  end

  defp request!(%{file: file} = opts) when not is_nil(file) do
    IO.puts("Obtaining Zig compiler toolchain from #{file}")
    {File.read!(file), opts}
  end

  defp request!(%{url: url} = opts) do
    spin_with("Downloading Zig compiler toolchain from #{url} ", fn ->
      {http_get!(url), opts}
    end)
  end

  defp verify_hash!({contents, opts} = state) do
    if opts.verify do
      hashed =
        :sha256
        |> :crypto.hash(contents)
        |> Base.encode16(case: :lower)

      unless hashed == opts.hash do
        Mix.raise("hash mismatch: expected #{opts.hash}, got #{hashed}")
      end

      state
    else
      state
    end
  end

  defp do_extract({bin, opts}) do
    spin_with("Extracting Zig compiler toolchain to #{opts.path} ", fn ->
      extract_mod(opts).extract({:binary, bin}, extract_opts(opts))
    end)
  end

  defp extract_mod(%{os: "windows"}), do: :zip
  defp extract_mod(_), do: __MODULE__

  defp extract_opts(%{path: path}) do
    [cwd: path]
  end

  def extract({:binary, bin}, opts) do
    {:spawn_executable, System.fetch_env!("TAR_COMMAND")}
    |> Port.open(args: ~w(-xJf -), cd: opts[:cwd])
    |> Port.command(bin)

    :ok
  end

  defp spin_with(message, fun) do
    IO.write(message)
    spinner = spawn(&spinner/0)

    result = fun.()

    IO.write("\n")
    Process.exit(spinner, :normal)
    result
  end

  @otp_version :otp_release
               |> :erlang.system_info()
               |> List.to_integer()

  if @otp_version >= 25 do
    defp ssl_opts do
      [
        verify: :verify_peer,
        cacerts: :public_key.cacerts_get()
      ]
    end
  else
    defp ssl_opts do
      # unfortunately in otp 24 there is not a clean way of obtaining cacerts
      []
    end
  end

  defp http_get!(url) do
    {:ok, {{_, 200, _}, _headers, body}} =
      :httpc.request(
        :get,
        {url, []},
        [
          ssl:
            [
              depth: 100,
              customize_hostname_check: [
                match_fun: :public_key.pkix_verify_hostname_match_fun(:https)
              ]
            ] ++ ssl_opts()
        ],
        body_format: :binary
      )

    body
  end

  @spinners ~w(| / - \\)
  defp spinner, do: spinner(@spinners)

  defp spinner([head | tail]) do
    IO.write([head, IO.ANSI.cursor_left(1)])
    Process.sleep(500)
    spinner(tail)
  end

  defp spinner([]), do: spinner(@spinners)
end