Skip to main content

lib/mix/tasks/safe.ex

defmodule Mix.Tasks.Safe do
  use Mix.Task

  @shortdoc "SAFE security vulnerability scanner"

  @moduledoc """
  Runs SAFE security vulnerability scans on your Elixir/Mix project.

  ## Usage

      mix safe <subcommand> [options]

  ## Subcommands

    * `fingerprint` — run the SAFE fingerprint phase
    * `analyse`     — run the SAFE analysis phase
    * `download`    — download the SAFE binary only
    * `version`     — print plugin and binary versions
    * `help`        — print this help

  ## Examples

      mix safe fingerprint
      mix safe analyse
      mix safe download
      mix safe version

  ## Debug logging

  Detailed debug output is written to `_build/safe/safe.log`. The terminal
  shows only info-level messages.

  """

  require Logger

  @version Mix.Project.config()[:version]

  @impl Mix.Task
  def run(args) do
    {_opts, rest, _} = OptionParser.parse(args, strict: [])

    project_dir = Mix.Project.project_file() |> Path.dirname() |> Path.expand()

    setup_file_logger(project_dir)
    Logger.debug("project_dir=#{project_dir}")

    case rest do
      ["fingerprint"] -> handle_fingerprint(project_dir)
      ["analyse"] -> handle_analyse(project_dir)
      ["download"] -> handle_download(project_dir)
      ["version"] -> handle_version(project_dir)
      ["help"] -> handle_help()
      [] -> error_and_exit("No subcommand specified. Run `mix safe help` for usage.", 1)
      [other | _] -> error_and_exit("Unrecognised subcommand: #{other}. Run `mix safe help`.", 1)
    end
  end

  # ---------------------------------------------------------------------------
  # Subcommand handlers
  # ---------------------------------------------------------------------------

  defp handle_fingerprint(project_dir) do
    Logger.debug("running fingerprint")

    with :ok <- ensure_binary(project_dir),
         {:ok, config_spec} <- resolve_config_for_fingerprint(project_dir) do
      Safe.IO.print_status("* running SAFE fingerprint")

      case Safe.Shell.run_safe("fingerprint", project_dir, config_spec) do
        :ok ->
          Safe.IO.print_status("* SAFE fingerprint complete")

        {:error, {:fingerprint, 2}} ->
          Safe.IO.print_status("* SAFE fingerprint complete - vulnerabilities found.")
          exit_with(2)

        {:error, {:fingerprint, n}} ->
          handle_error({:fingerprint, n})
      end
    else
      {:error, reason} -> handle_error(reason)
    end
  end

  defp handle_analyse(project_dir) do
    Logger.debug("running analyse")

    with :ok <- ensure_binary(project_dir),
         {:ok, config_spec} <- resolve_config_for_analyse(project_dir) do
      Safe.IO.print_status("* running SAFE analysis")

      case Safe.Shell.run_safe("analyse", project_dir, config_spec) do
        :ok ->
          Safe.IO.print_status("* SAFE analysis complete - no vulnerabilities found")

        {:error, {:analyse, 2}} ->
          Safe.IO.print_status("* SAFE analysis complete - vulnerabilities found.")
          exit_with(2)

        {:error, {:analyse, n}} ->
          handle_error({:analyse, n})
      end
    else
      {:error, reason} -> handle_error(reason)
    end
  end

  defp handle_download(project_dir) do
    Logger.debug("running download")
    Safe.IO.print_status("* checking for SAFE binary")

    case Safe.Binary.ensure_binary_available(project_dir) do
      :ok -> Safe.IO.print_status("* SAFE binary ready")
      {:error, reason} -> handle_error(reason)
    end
  end

  defp handle_version(project_dir) do
    Safe.IO.print_info("* safe-mix-plugin version: #{@version}")
    bin_path = Safe.Binary.binary_path(project_dir)

    if File.exists?(bin_path) do
      dir = Path.dirname(bin_path)

      case System.cmd(bin_path, ["version"],
             cd: dir,
             stderr_to_stdout: true,
             into: IO.stream(:stdio, :line)
           ) do
        {_, 0} -> :ok
        {_, n} -> handle_error({:version_failed, n})
      end
    else
      Safe.IO.print_info("* SAFE binary not yet downloaded")
    end
  end

  defp handle_help do
    Mix.Task.moduledoc(__MODULE__) |> Safe.IO.print_info()
  end

  # ---------------------------------------------------------------------------
  # Config resolution
  # ---------------------------------------------------------------------------

  defp resolve_config_for_fingerprint(project_dir) do
    config_path = Safe.Config.config_path(project_dir)

    if File.exists?(config_path) do
      Safe.IO.print_status("* Using config from .safe/config.json")
      {:ok, {:config_path, config_path}}
    else
      safe_fingerprint_no_config(project_dir)
    end
  end

  defp safe_fingerprint_no_config(project_dir) do
    Safe.IO.print_status("* checking your project's structure")

    with {:ok, apps} <- discover_apps(),
         {:ok, config_json} <- Safe.Config.make_config(project_dir) do
      Safe.IO.print_info(
        "* Discovered #{length(apps)} app(s): #{inspect(Enum.map(apps, & &1.name))}"
      )

      Safe.IO.print_info(config_json)

      if Safe.IO.bool_prompt("Would you like to proceed with this configuration?") do
        {:ok, {:config_json, config_json}}
      else
        case Safe.Config.write_config(project_dir, config_json) do
          :ok ->
            Safe.IO.print_info(
              "Config saved to .safe/config.json. Edit it and re-run `mix safe fingerprint`."
            )

            exit_with(0)

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

  defp resolve_config_for_analyse(project_dir) do
    config_path = Safe.Config.config_path(project_dir)

    if File.exists?(config_path) do
      Safe.IO.print_info("* Using config from .safe/config.json")
      {:ok, {:config_path, config_path}}
    else
      {:ok, json} = Safe.Config.make_config(project_dir)
      {:ok, {:config_json, json}}
    end
  end

  defp discover_apps do
    if Mix.Project.umbrella?() do
      apps =
        Mix.Project.apps_paths()
        |> Enum.map(fn {name, _path} -> %{name: name} end)

      {:ok, apps}
    else
      app_name = Mix.Project.config()[:app]
      {:ok, [%{name: app_name}]}
    end
  end

  # ---------------------------------------------------------------------------
  # Error handling
  # ---------------------------------------------------------------------------

  defp ensure_binary(project_dir) do
    case Safe.Binary.ensure_binary_available(project_dir) do
      :ok -> :ok
      {:error, reason} -> {:error, reason}
    end
  end

  @spec handle_error(term()) :: no_return()
  defp handle_error(:unsupported_platform) do
    error_and_exit("Unsupported platform. SAFE supports Linux and macOS only.", 1)
  end

  defp handle_error(:unsupported_arch) do
    error_and_exit("Unsupported CPU architecture. SAFE supports x86_64 and ARM64.", 1)
  end

  defp handle_error({:no_checksum_for_platform, platform}) do
    error_and_exit("No checksum available for platform #{platform}", 1)
  end

  defp handle_error(:no_compatible_version) do
    error_and_exit("No compatible SAFE version found (requires ~> 1.5.0)", 1)
  end

  defp handle_error({:locked_version_not_found, version}) do
    error_and_exit(
      "Version #{version} from safe.lock not found in manifest. Delete safe.lock and re-run.",
      1
    )
  end

  defp handle_error({:checksum_mismatch, _}) do
    error_and_exit("Downloaded file is corrupted (checksum mismatch). Please try again.", 1)
  end

  defp handle_error({:download_failed, url, reason}) do
    error_and_exit("Failed to download SAFE from #{url}: #{inspect(reason)}", 1)
  end

  defp handle_error({:http_error, 404}) do
    error_and_exit("SAFE version not found. Check version and try again.", 1)
  end

  defp handle_error({:http_error, code}) do
    error_and_exit("HTTP error #{code} while contacting SAFE release server.", 1)
  end

  defp handle_error({:config_read_error, reason}) do
    error_and_exit("Failed to read .safe/config.json: #{inspect(reason)}", 1)
  end

  defp handle_error({:fingerprint, 2}) do
    error_and_exit("SAFE analysis complete - vulnerabilities found.", 2)
  end

  defp handle_error({:fingerprint, n}) do
    error_and_exit("SAFE fingerprint failed with exit code #{n}.", 1)
  end

  defp handle_error({:analyse, 2}) do
    error_and_exit("SAFE analysis complete - vulnerabilities found.", 2)
  end

  defp handle_error({:analyse, n}) do
    error_and_exit("SAFE analysis failed with exit code #{n}.", 1)
  end

  defp handle_error({:version_failed, n}) do
    error_and_exit("SAFE binary `version` command failed with exit code #{n}.", 1)
  end

  defp handle_error(:binary_not_found_after_extract) do
    error_and_exit(
      "SAFE binary not found after extraction. The tarball layout may have changed.",
      1
    )
  end

  defp handle_error({:untar_failed, reason}) do
    error_and_exit("Failed to extract SAFE tarball: #{inspect(reason)}", 1)
  end

  defp handle_error(reason) do
    error_and_exit("Unexpected error: #{inspect(reason)}", 1)
  end

  @spec error_and_exit(String.t(), non_neg_integer()) :: no_return()
  defp error_and_exit(message, code) do
    Safe.IO.print_error(message)
    exit_with(code)
  end

  # ---------------------------------------------------------------------------
  # Logger file setup
  # ---------------------------------------------------------------------------

  defp setup_file_logger(project_dir) do
    log_dir = Path.join([project_dir, "_build", "safe"])
    File.mkdir_p!(log_dir)
    log_file = Path.join(log_dir, "safe.log") |> String.to_charlist()

    case :logger.add_handler(:safe_file_handler, :logger_std_h, %{
           level: :debug,
           config: %{type: :file, file: log_file}
         }) do
      :ok ->
        :ok

      {:error, {:already_exist, _}} ->
        :ok

      {:error, reason} ->
        Logger.warning("Could not set up SAFE file logger: #{inspect(reason)}")
    end

    :logger.set_handler_config(:default, :level, :info)
  end

  @spec exit_with(non_neg_integer()) :: no_return()
  defp exit_with(code), do: exit({:shutdown, code})
end