Skip to main content

lib/npm/config.ex

defmodule NPM.Config do
  @moduledoc """
  Read npm configuration from `.npmrc` files.

  Checks for `.npmrc` in the project directory and home directory.
  Environment variables take precedence over file configuration.
  """

  @doc """
  Read the effective registry URL.

  Priority: `NPM_REGISTRY` env var > `config :duskmoon_npm, :registry` > project `.npmrc` > home `.npmrc` > default.
  """
  @spec registry :: String.t()
  def registry do
    (System.get_env("NPM_REGISTRY") ||
       Application.get_env(:duskmoon_npm, :registry) ||
       read_npmrc_value("registry") ||
       "https://registry.npmjs.org")
    |> normalize_registry_url()
  end

  @doc """
  Read the auth token for the configured registry.

  Priority: `NPM_TOKEN` env var > `config :duskmoon_npm, :token` > registry-matched project `.npmrc` > registry-matched home `.npmrc`.
  """
  @spec auth_token(String.t()) :: String.t() | nil
  def auth_token(registry_url \\ registry()) do
    System.get_env("NPM_TOKEN") ||
      Application.get_env(:duskmoon_npm, :token) ||
      read_npmrc_auth_token(registry_url)
  end

  @doc "Read the global package cache directory."
  @spec cache_dir :: String.t()
  def cache_dir do
    System.get_env("NPM_EX_CACHE_DIR") ||
      Application.get_env(:duskmoon_npm, :cache_dir) ||
      Path.join(System.user_home!(), ".npm_ex")
  end

  @doc "Read the runtime install directory for `NPM.install/2`."
  @spec install_dir(String.t()) :: String.t()
  def install_dir(id) do
    root =
      System.get_env("NPM_INSTALL_DIR") ||
        Application.get_env(:duskmoon_npm, :install_dir) ||
        Path.join(cache_dir(), "installs")

    Path.join(root, id)
  end

  @doc "Read the configured registry mirror URL."
  @spec mirror_url :: String.t()
  def mirror_url do
    System.get_env("NPM_MIRROR") ||
      Application.get_env(:duskmoon_npm, :mirror) ||
      NPM.Registry.registry_url()
  end

  @doc "Whether transitive git, file, and URL dependencies are blocked."
  @spec block_exotic_subdeps? :: boolean()
  def block_exotic_subdeps? do
    case System.get_env("NPM_EX_BLOCK_EXOTIC_SUBDEPS") do
      nil -> Application.get_env(:duskmoon_npm, :block_exotic_subdeps, true)
      value -> truthy?(value)
    end
  end

  @doc "Allowed direct exotic dependency specs."
  @spec exotic_deps :: [String.t()]
  def exotic_deps do
    env_list("NPM_EX_EXOTIC_DEPS") || Application.get_env(:duskmoon_npm, :exotic_deps, [])
  end

  @doc "Registry origins allowed for packuments and tarballs."
  @spec allowed_registries :: [String.t()]
  def allowed_registries do
    env_list("NPM_EX_ALLOWED_REGISTRIES") ||
      Application.get_env(:duskmoon_npm, :allowed_registries) ||
      [registry(), mirror_url()]
  end

  @doc "Whether HTTP redirects to different registry origins are allowed."
  @spec allow_registry_redirects? :: boolean()
  def allow_registry_redirects? do
    case System.get_env("NPM_EX_ALLOW_REGISTRY_REDIRECTS") do
      nil -> Application.get_env(:duskmoon_npm, :allow_registry_redirects, false)
      value -> truthy?(value)
    end
  end

  @doc "Warn when a package was created fewer than this many days ago."
  @spec package_age_warning_days :: non_neg_integer()
  def package_age_warning_days do
    env_integer("NPM_EX_PACKAGE_AGE_WARNING_DAYS") ||
      Application.get_env(:duskmoon_npm, :package_age_warning_days, 7)
  end

  @doc "Warn when a package version was published fewer than this many days ago."
  @spec version_age_warning_days :: non_neg_integer()
  def version_age_warning_days do
    env_integer("NPM_EX_VERSION_AGE_WARNING_DAYS") ||
      Application.get_env(:duskmoon_npm, :version_age_warning_days, 3)
  end

  @doc "Path to an OSV-format database of known malicious package reports."
  @spec compromised_db_path :: String.t()
  def compromised_db_path do
    System.get_env("NPM_EX_COMPROMISED_DB_PATH") ||
      Application.get_env(:duskmoon_npm, :compromised_db_path) ||
      Path.join([cache_dir(), "security", "compromised_packages.json"])
  end

  @doc "Path to the bundled seed database of known malicious package reports."
  @spec bundled_compromised_db_path :: String.t()
  def bundled_compromised_db_path do
    Application.app_dir(:duskmoon_npm, "priv/security/compromised_packages.json")
  end

  @doc "Known-compromised package intelligence sources to use."
  @spec compromised_sources :: [atom()]
  def compromised_sources do
    case env_list("NPM_EX_COMPROMISED_SOURCES") do
      nil -> Application.get_env(:duskmoon_npm, :compromised_sources, [:local])
      sources -> Enum.flat_map(sources, &parse_compromised_source/1)
    end
  end

  @doc "Policy for compromised-package findings in security tasks."
  @spec compromised_policy :: :error | :warn | :off
  def compromised_policy do
    case System.get_env("NPM_EX_COMPROMISED_POLICY") do
      nil -> Application.get_env(:duskmoon_npm, :compromised_policy, :error)
      value -> parse_compromised_policy(value)
    end
  end

  @doc """
  Read a value from `.npmrc` files.

  Checks project-level first, then home-level.
  """
  @spec read_npmrc_value(String.t()) :: String.t() | nil
  def read_npmrc_value(key) do
    read_from_file(".npmrc", key) ||
      read_from_file(Path.join(System.user_home!(), ".npmrc"), key)
  end

  @doc "Parse an `.npmrc` file into a map of key-value pairs."
  @spec parse_npmrc(String.t()) :: %{String.t() => String.t()}
  def parse_npmrc(content) do
    content
    |> String.split("\n")
    |> Enum.reject(&(String.starts_with?(String.trim(&1), "#") or String.trim(&1) == ""))
    |> Enum.flat_map(&parse_line/1)
    |> Map.new()
  end

  @doc """
  Gets a config value with fallback to defaults.
  """
  @spec get(map(), String.t(), term()) :: term()
  def get(config, key, default \\ nil) do
    Map.get(config, key, default)
  end

  @doc """
  Merges multiple config maps (later overrides earlier).
  """
  @spec merge([map()]) :: map()
  def merge(configs) do
    Enum.reduce(configs, %{}, &Map.merge(&2, &1))
  end

  @doc """
  Loads config from all levels: project .npmrc then user .npmrc.
  Project values override user values.
  """
  @spec load(String.t()) :: map()
  def load(project_dir \\ ".") do
    user_config = read_file(Path.join(System.user_home!(), ".npmrc"))
    project_config = read_file(Path.join(project_dir, ".npmrc"))
    merge([user_config, project_config])
  end

  @doc """
  Returns the registry URL for a given scope, or the default.
  """
  @spec scoped_registry(map(), String.t()) :: String.t()
  def scoped_registry(config, scope) do
    key = "#{scope}:registry"
    Map.get(config, key, Map.get(config, "registry", "https://registry.npmjs.org"))
  end

  defp read_file(path) do
    case File.read(path) do
      {:ok, content} -> parse_npmrc(content)
      _ -> %{}
    end
  end

  defp read_from_file(path, key) do
    case File.read(path) do
      {:ok, content} -> parse_npmrc(content) |> Map.get(key)
      {:error, _} -> nil
    end
  end

  defp read_npmrc_auth_token(registry_url) do
    auth_keys = npmrc_auth_token_keys(registry_url)

    Enum.find_value(auth_keys, fn key ->
      read_npmrc_value(key)
    end)
  end

  defp npmrc_auth_token_keys(registry_url) do
    uri = URI.parse(normalize_registry_url(registry_url))
    authority = npmrc_authority(uri)
    path = uri.path || ""
    scoped_key = "//#{authority}#{String.trim_trailing(path, "/")}/:_authToken"

    [scoped_key, "//#{authority}/:_authToken"]
    |> Enum.uniq()
  end

  defp npmrc_authority(%URI{host: host, port: nil}), do: host
  defp npmrc_authority(%URI{host: host, port: 80, scheme: "http"}), do: host
  defp npmrc_authority(%URI{host: host, port: 443, scheme: "https"}), do: host
  defp npmrc_authority(%URI{host: host, port: port}), do: "#{host}:#{port}"

  defp parse_line(line) do
    case String.split(String.trim(line), "=", parts: 2) do
      [key, value] -> [{String.trim(key), String.trim(value)}]
      _ -> []
    end
  end

  defp normalize_registry_url(url), do: String.trim_trailing(url, "/")

  defp truthy?(value) when is_binary(value) do
    (value |> String.trim() |> String.downcase()) in ~w(1 true yes on)
  end

  defp parse_compromised_source("local"), do: [:local]
  defp parse_compromised_source("osv"), do: [:osv]
  defp parse_compromised_source(_), do: []

  defp parse_compromised_policy(value) when is_binary(value) do
    case value |> String.trim() |> String.downcase() do
      "error" -> :error
      "warn" -> :warn
      "off" -> :off
      _ -> :error
    end
  end

  defp env_list(name) do
    case System.get_env(name) do
      nil -> nil
      value -> value |> String.split(",", trim: true) |> Enum.map(&String.trim/1)
    end
  end

  defp env_integer(name) do
    case System.get_env(name) do
      nil ->
        nil

      value ->
        case Integer.parse(String.trim(value)) do
          {int, ""} when int >= 0 -> int
          _ -> nil
        end
    end
  end
end