Skip to main content

lib/npm/resolver.ex

defmodule NPM.Resolver do
  alias NPM.Security.ExoticDeps
  alias NPM.Security.RegistryPolicy

  @moduledoc """
  `HexSolver.Registry` implementation for npm packages.

  Bridges the npm registry to hex_solver's PubGrub dependency resolver.
  Packuments are cached in an ETS table for the duration of a resolution.
  """

  @behaviour HexSolver.Registry

  defmodule PrefetchError do
    @moduledoc "Raised when resolver prefetching fails before PubGrub can run."

    defexception [:reason]

    @impl true
    def message(%__MODULE__{reason: reason}) do
      "npm dependency prefetch failed: #{NPM.Resolver.format_prefetch_reason(reason)}"
    end
  end

  @table :npm_resolver_cache
  @max_nesting_depth 128
  @max_prefetch_depth 10
  @prefetch_concurrency 16
  @fetch_timeout 30_000

  @doc """
  Resolve a set of root dependencies to exact versions.

  Uses a two-phase approach:
  1. Try flat resolution with PubGrub
  2. On conflict, identify conflicting packages and retry with
     those excluded, tracking them as nested dependencies

  Returns `{:ok, %{name => version}}` where the map includes
  a `:nested` key with nested package info when version conflicts exist.
  """
  @spec resolve(%{String.t() => String.t()}, keyword()) ::
          {:ok, %{String.t() => String.t()}} | {:error, String.t()}
  def resolve(root_deps, opts \\ [])
  def resolve(root_deps, _opts) when map_size(root_deps) == 0, do: {:ok, %{}}

  def resolve(root_deps, opts) do
    ensure_cache()
    overrides = Keyword.get(opts, :overrides, %{})
    if overrides != %{}, do: store_overrides(overrides)
    resolve_with_nesting(root_deps, MapSet.new(), %{}, 0)
  rescue
    error in [ExoticDeps.Error, PrefetchError, RegistryPolicy.Error] ->
      {:error, Exception.message(error)}
  end

  defp store_overrides(overrides) do
    :ets.insert(@table, {:__overrides__, overrides})
  end

  defp get_overrides do
    case :ets.lookup(@table, :__overrides__) do
      [{_, overrides}] -> overrides
      [] -> %{}
    end
  end

  defp resolve_with_nesting(_root_deps, _excluded, _nested, depth)
       when depth > @max_nesting_depth do
    {:error, "Too many resolution retries — deeply conflicting dependencies"}
  end

  defp resolve_with_nesting(root_deps, excluded, nested, depth) do
    case prefetch_tree(Map.keys(root_deps)) do
      :ok ->
        dependencies = build_dependencies(root_deps)

        case run_solver(dependencies, excluded) do
          {:ok, result} ->
            final = if nested == %{}, do: result, else: Map.put(result, :nested, nested)
            {:ok, final}

          {:error, message} ->
            conflict_packages =
              message
              |> extract_conflict_packages()
              |> Enum.reject(&MapSet.member?(excluded, &1))

            if conflict_packages != [] do
              new_nested = collect_nested_versions(conflict_packages)
              merged_nested = Map.merge(nested, new_nested)
              new_excluded = Enum.reduce(conflict_packages, excluded, &MapSet.put(&2, &1))
              resolve_with_nesting(root_deps, new_excluded, merged_nested, depth + 1)
            else
              {:error, message}
            end
        end

      {:error, reason} ->
        {:error, prefetch_error_message(reason)}
    end
  end

  defp build_dependencies(root_deps) do
    Enum.map(root_deps, fn {name, range} ->
      {:ok, constraint} = normalize_range(range)

      %{
        repo: nil,
        name: name,
        constraint: constraint,
        optional: false,
        label: name,
        dependencies: []
      }
    end)
  end

  defp run_solver(dependencies, excluded) do
    ensure_cache()
    put_excluded(excluded)

    # Remove excluded packages from the version cache to prevent stale data
    if MapSet.size(excluded) > 0, do: strip_excluded_from_cache(excluded)

    case HexSolver.run(__MODULE__, dependencies, [], []) do
      {:ok, solution} ->
        result =
          for {name, {version, _repo}} <- solution, into: %{}, do: {name, to_string(version)}

        {:ok, result}

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

  defp put_excluded(excluded) do
    ensure_cache()
    :ets.insert(@table, {:__excluded_packages__, excluded})
  end

  defp get_excluded do
    case :ets.lookup(@table, :__excluded_packages__) do
      [{_, excluded}] -> excluded
      [] -> MapSet.new()
    end
  end

  defp strip_excluded_from_cache(excluded) do
    # Delete excluded packages entirely from the cache
    Enum.each(excluded, &:ets.delete(@table, &1))

    # Strip excluded deps from all cached packuments (skip non-packument entries)
    :ets.foldl(
      fn
        {_name, %{versions: _} = packument} = entry, acc ->
          stripped = strip_deps(packument, excluded)
          if stripped != packument, do: :ets.insert(@table, put_elem(entry, 1, stripped))
          acc

        _, acc ->
          acc
      end,
      :ok,
      @table
    )
  end

  defp strip_deps(packument, excluded) do
    versions =
      Map.new(packument.versions, fn {ver, info} ->
        deps = Map.drop(info.dependencies, MapSet.to_list(excluded))
        {ver, %{info | dependencies: deps}}
      end)

    %{packument | versions: versions}
  end

  @doc false
  def extract_conflict_package(message) do
    message
    |> extract_conflict_packages()
    |> List.first()
  end

  @doc false
  def extract_conflict_packages(message) do
    [
      extract_repeated_dependency_targets(message),
      extract_dependency_pair_conflicts(message),
      extract_exact_version_conflicts(message)
    ]
    |> List.flatten()
    |> Enum.uniq()
  end

  defp extract_repeated_dependency_targets(message) do
    message
    |> dependency_target_names()
    |> repeated_package_names()
  end

  defp extract_dependency_pair_conflicts(message) do
    ~r/depends on "([^"]+)" and "[^"]+" depends on "([^"]+)"/
    |> Regex.scan(message)
    |> Enum.flat_map(fn [_, left, right] ->
      with {:ok, left_name} <- package_name_from_term(left),
           {:ok, right_name} <- package_name_from_term(right),
           true <- left_name == right_name do
        [left_name]
      else
        _ -> []
      end
    end)
  end

  defp extract_exact_version_conflicts(message) do
    # Look for patterns like: "ms 2.0.0" and "ms 2.1.3" in the error
    case Regex.scan(~r/"(\S+) (\d+\.\d+\.\d+)"/, message) do
      [_, _ | _] = matches ->
        names = Enum.map(matches, fn [_, name, _] -> name end)
        repeated_package_names(names)

      _ ->
        []
    end
  end

  defp dependency_target_names(message) do
    ~r/depends on "([^"]+)"/
    |> Regex.scan(message)
    |> Enum.map(fn [_, term] -> package_name_from_term(term) end)
    |> Enum.flat_map(fn
      {:ok, name} -> [name]
      :error -> []
    end)
  end

  defp package_name_from_term(term) do
    case Regex.run(~r/^(@[^\/\s]+\/[^\s]+|[^\s]+)\s+.+$/, term) do
      [_, name] -> {:ok, name}
      _ -> :error
    end
  end

  defp repeated_package_names(names) do
    names
    |> Enum.frequencies()
    |> Enum.filter(fn {_, count} -> count >= 2 end)
    |> Enum.map(&elem(&1, 0))
  end

  @doc false
  def get_original_deps(package) do
    ensure_cache()

    case :ets.lookup(@table, {:__original_deps__, package}) do
      [{_, deps}] -> deps
      [] -> %{}
    end
  end

  defp collect_nested_versions(packages) do
    # Before stripping, save the original dependency data
    # so the linker can look up which parents need which version
    Map.new(packages, fn package ->
      save_original_deps(package)
      {package, :nested}
    end)
  end

  defp save_original_deps(package) do
    parent_deps =
      :ets.foldl(
        fn
          {name, %{versions: _} = packument}, acc ->
            find_dependents(name, packument, package, acc)

          _, acc ->
            acc
        end,
        %{},
        @table
      )

    :ets.insert(@table, {{:__original_deps__, package}, parent_deps})
  end

  defp find_dependents(name, %{versions: versions}, package, acc) do
    Enum.reduce(versions, acc, fn {ver, info}, inner_acc ->
      case info |> version_dependencies() |> Map.get(package) do
        nil -> inner_acc
        range -> Map.put(inner_acc, "#{name}@#{ver}", range)
      end
    end)
  end

  defp find_dependents(_name, _packument, _package, acc), do: acc

  defp version_dependencies(%{dependencies: deps}) when is_map(deps), do: deps
  defp version_dependencies(%{"dependencies" => deps}) when is_map(deps), do: deps
  defp version_dependencies(_info), do: %{}

  @doc "Clear the packument cache."
  @spec clear_cache() :: :ok
  def clear_cache do
    if :ets.info(@table) != :undefined, do: :ets.delete_all_objects(@table)
    :ok
  end

  # --- HexSolver.Registry callbacks ---

  @impl true
  def versions(_repo, package) do
    case get_cached_packument(package) do
      {:ok, packument} -> {:ok, parse_sorted_versions(packument)}
      {:error, _} -> :error
    end
  end

  @impl true
  def dependencies(_repo, package, version) do
    case get_cached_packument(package) do
      {:ok, packument} -> deps_for_version(package, packument, to_string(version))
      {:error, _} -> :error
    end
  end

  @impl true
  def prefetch(packages) do
    packages
    |> Enum.map(fn {_repo, name} -> name end)
    |> Enum.reject(&cached?/1)
    |> prefetch_packages()
    |> case do
      :ok -> :ok
      {:error, reason} -> raise PrefetchError, reason: reason
    end
  end

  # --- Helpers ---

  @doc false
  def collect_prefetch_results(results) do
    Enum.reduce_while(results, :ok, fn
      {:ok, {:ok, _packument}}, :ok ->
        {:cont, :ok}

      {:ok, {:error, reason}}, :ok ->
        {:halt, {:error, reason}}

      {:exit, reason}, :ok ->
        {:halt, {:error, reason}}
    end)
  end

  @doc false
  def format_prefetch_reason({%{__exception__: true} = error, _stack}) do
    Exception.message(error)
  end

  def format_prefetch_reason(%{__exception__: true} = error), do: Exception.message(error)
  def format_prefetch_reason(reason), do: inspect(reason)

  defp prefetch_error_message(reason), do: Exception.message(%PrefetchError{reason: reason})

  defp parse_sorted_versions(packument) do
    key = {:__sorted_versions__, packument.name}

    case :ets.lookup(@table, key) do
      [{^key, versions}] ->
        versions

      [] ->
        versions = do_parse_sorted_versions(packument)
        :ets.insert(@table, {key, versions})
        versions
    end
  end

  defp do_parse_sorted_versions(packument) do
    packument.versions
    |> Enum.reject(fn {version_str, info} ->
      exotic_candidate?(packument.name, version_str, info)
    end)
    |> Enum.flat_map(fn {v, _info} ->
      case Version.parse(v) do
        {:ok, version} -> [version]
        :error -> []
      end
    end)
    |> Enum.sort(Version)
  end

  defp exotic_candidate?(package, version_str, info) do
    case validate_version(package, version_str, info) do
      :ok -> false
      {:error, %ExoticDeps.Error{}} -> true
    end
  end

  defp deps_for_version(package, packument, version_str) do
    excluded = get_excluded()

    case Map.get(packument.versions, version_str) do
      nil ->
        :error

      info ->
        validate_version!(package, version_str, info)
        overrides = get_overrides()

        deps =
          info
          |> solver_dependencies(package, excluded, overrides)

        {:ok, deps}
    end
  end

  defp validate_version!(package, version_str, info) do
    case validate_version(package, version_str, info) do
      :ok -> :ok
      {:error, error} -> raise error
    end
  end

  defp validate_version(package, version_str, info) do
    key = {:__exotic_validation__, package, version_str}

    case :ets.lookup(@table, key) do
      [{^key, result}] ->
        result

      [] ->
        result =
          try do
            ExoticDeps.validate!(package, version_str, info)
            :ok
          rescue
            error in ExoticDeps.Error -> {:error, error}
          end

        :ets.insert(@table, {key, result})
        result
    end
  end

  defp solver_dependencies(info, self_name, excluded, overrides) do
    optional_dependency_names = Map.keys(info.optional_dependencies)

    required =
      info.dependencies
      |> Enum.reject(fn {name, _} ->
        name == self_name or name in optional_dependency_names or
          MapSet.member?(excluded, name)
      end)
      |> Enum.map(fn {name, range} -> {name, Map.get(overrides, name, range), false} end)

    optional =
      info.optional_dependencies
      |> NPM.PlatformOptional.select()
      |> Enum.reject(fn {name, _} ->
        name == self_name or MapSet.member?(excluded, name)
      end)
      |> Enum.map(fn {name, range} -> {name, Map.get(overrides, name, range), false} end)

    (required ++ optional)
    |> Enum.flat_map(&to_solver_dep/1)
  end

  defp to_solver_dep({name, range, optional?}) do
    case normalize_range(range) do
      {:ok, constraint} ->
        [
          %{
            repo: nil,
            name: name,
            constraint: constraint,
            optional: optional?,
            label: name,
            dependencies: []
          }
        ]

      :error ->
        []
    end
  end

  defp to_solver_dep({name, range}) do
    to_solver_dep({name, range, false})
  end

  defp normalize_range(range) when range in ["*", "", "latest"] do
    NPMSemver.to_hex_constraint(">=0.0.0")
  end

  defp normalize_range(range), do: NPMSemver.to_hex_constraint(range)

  # --- Cache ---

  defp ensure_cache do
    if :ets.whereis(@table) == :undefined do
      try do
        :ets.new(@table, [:named_table, :set, :public])
      rescue
        ArgumentError -> :ok
      end
    end
  end

  defp cached?(package) do
    :ets.info(@table) != :undefined and :ets.member(@table, package)
  end

  defp get_cached_packument(package) do
    ensure_cache()

    case :ets.lookup(@table, package) do
      [{^package, packument}] -> {:ok, packument}
      [] -> fetch_and_cache(package)
    end
  end

  defp fetch_and_cache(package) do
    case NPM.Registry.get_packument(package) do
      {:ok, packument} ->
        :ets.insert(@table, {package, packument})
        {:ok, packument}

      error ->
        error
    end
  end

  defp prefetch_packages([]), do: :ok

  defp prefetch_packages(packages) do
    packages
    |> Task.async_stream(&safe_fetch_and_cache/1,
      max_concurrency: @prefetch_concurrency,
      timeout: @fetch_timeout,
      ordered: false,
      on_timeout: :kill_task
    )
    |> collect_prefetch_results()
  end

  defp safe_fetch_and_cache(package) do
    fetch_and_cache(package)
  rescue
    error -> {:error, {error, __STACKTRACE__}}
  catch
    kind, reason -> {:error, {kind, reason}}
  end

  defp prefetch_tree(packages, depth \\ 0)
  defp prefetch_tree(_packages, depth) when depth > @max_prefetch_depth, do: :ok

  defp prefetch_tree(packages, depth) do
    to_fetch = Enum.reject(packages, &cached?/1)

    case prefetch_packages(to_fetch) do
      :ok ->
        next_level =
          to_fetch
          |> Enum.flat_map(&dep_names_from_cache/1)
          |> Enum.uniq()
          |> Enum.reject(&cached?/1)

        if next_level != [] do
          prefetch_tree(next_level, depth + 1)
        else
          :ok
        end

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

  defp dep_names_from_cache(package) do
    case :ets.lookup(@table, package) do
      [{_, packument}] ->
        case latest_version_info(packument) do
          nil -> []
          info -> Map.keys(info.dependencies)
        end

      [] ->
        []
    end
  end

  defp latest_version_info(packument) do
    packument.versions
    |> Map.keys()
    |> Enum.flat_map(fn v ->
      case Version.parse(v) do
        {:ok, ver} -> [{v, ver}]
        :error -> []
      end
    end)
    |> Enum.reject(fn {_, ver} -> ver.pre != [] end)
    |> Enum.sort_by(&elem(&1, 1), {:desc, Version})
    |> case do
      [{latest_str, _} | _] -> Map.get(packument.versions, latest_str)
      [] -> nil
    end
  end
end