Skip to main content

lib/npm/lockfile.ex

defmodule NPM.Lockfile do
  @moduledoc """
  Read and write `npm.lock` lockfile.

  The lockfile records resolved versions, integrity hashes, and dependency
  relationships to ensure reproducible installs.
  """

  alias NPM.Config
  alias NPM.Security.RegistryPolicy

  @default_path "npm.lock"

  @type entry :: %{
          version: String.t(),
          integrity: String.t(),
          tarball: String.t(),
          dependencies: %{String.t() => String.t()},
          optional_dependencies: %{String.t() => String.t()},
          has_install_script: boolean()
        }

  @type t :: %{String.t() => entry()}

  @doc "Return the default npm_ex lockfile path."
  @spec default_path :: String.t()
  def default_path, do: @default_path

  @doc "Read the lockfile. Returns empty map if it doesn't exist."
  @spec read(String.t()) :: {:ok, t()} | {:error, term()}
  def read(path \\ @default_path) do
    case File.read(path) do
      {:ok, content} ->
        data = NPM.JSON.decode!(content)
        lockfile = parse(Map.get(data, "packages", %{}))
        {:ok, lockfile}

      {:error, :enoent} ->
        {:ok, %{}}

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

  @doc "Write the lockfile."
  @spec write(t(), String.t()) :: :ok | {:error, term()}
  def write(lockfile, path \\ @default_path) do
    data = %{
      "lockfileVersion" => 1,
      "policy" => current_policy(),
      "packages" => serialize(lockfile)
    }

    File.write(path, NPM.JSON.encode_pretty(data))
  end

  @doc "Read the security policy recorded in the lockfile."
  @spec read_policy(String.t()) :: {:ok, map() | nil} | {:error, term()}
  def read_policy(path \\ @default_path) do
    case File.read(path) do
      {:ok, content} -> {:ok, content |> NPM.JSON.decode!() |> Map.get("policy")}
      {:error, :enoent} -> {:ok, nil}
      {:error, reason} -> {:error, reason}
    end
  end

  @doc "Return the effective lockfile security policy for new locks."
  @spec current_policy :: map()
  def current_policy do
    %{
      "block_exotic_subdeps" => Config.block_exotic_subdeps?(),
      "exotic_deps" => Config.exotic_deps(),
      "allowed_registries" => RegistryPolicy.allowed_origins(),
      "allow_registry_redirects" => Config.allow_registry_redirects?()
    }
  end

  @doc "Whether a recorded lockfile policy is compatible with current settings."
  @spec policy_matches?(map() | nil) :: boolean()
  def policy_matches?(nil), do: false

  def policy_matches?(policy) when is_map(policy) do
    policy["block_exotic_subdeps"] == Config.block_exotic_subdeps?() and
      MapSet.subset?(
        MapSet.new(policy["exotic_deps"] || []),
        MapSet.new(Config.exotic_deps())
      ) and
      MapSet.subset?(
        MapSet.new(policy["allowed_registries"] || []),
        MapSet.new(RegistryPolicy.allowed_origins())
      ) and
      policy["allow_registry_redirects"] == Config.allow_registry_redirects?()
  end

  @doc "Parse a raw packages map into lockfile entries."
  @spec parse_packages(map()) :: t()
  def parse_packages(packages), do: parse(packages)

  defp parse(packages) do
    for {name, info} <- packages, into: %{} do
      {name,
       %{
         version: Map.get(info, "version", ""),
         integrity: Map.get(info, "integrity", ""),
         tarball: Map.get(info, "tarball", ""),
         dependencies: Map.get(info, "dependencies", %{}),
         optional_dependencies: Map.get(info, "optional_dependencies", %{}),
         has_install_script: Map.get(info, "has_install_script", false)
       }}
    end
  end

  @doc "Get the lockfile version from a file."
  @spec version(String.t()) :: integer() | nil
  def version(path \\ @default_path) do
    case File.read(path) do
      {:ok, content} ->
        data = NPM.JSON.decode!(content)
        Map.get(data, "lockfileVersion")

      {:error, _} ->
        nil
    end
  end

  @doc "List all package names in the lockfile."
  @spec package_names(String.t()) :: {:ok, [String.t()]} | {:error, term()}
  def package_names(path \\ @default_path) do
    case read(path) do
      {:ok, lockfile} -> {:ok, Map.keys(lockfile) |> Enum.sort()}
      error -> error
    end
  end

  @doc """
  Check if a specific package is in the lockfile.
  """
  @spec has_package?(String.t(), String.t()) :: boolean()
  def has_package?(name, path \\ @default_path) do
    case read(path) do
      {:ok, lockfile} -> Map.has_key?(lockfile, name)
      _ -> false
    end
  end

  @doc "Get a single package entry from the lockfile."
  @spec get_package(String.t(), String.t()) :: {:ok, entry()} | :error
  def get_package(name, path \\ @default_path) do
    case read(path) do
      {:ok, lockfile} -> Map.fetch(lockfile, name)
      _ -> :error
    end
  end

  defp serialize(lockfile) do
    for {name, entry} <- Enum.sort_by(lockfile, &elem(&1, 0)), into: %{} do
      {name,
       %{
         "version" => entry.version,
         "integrity" => entry.integrity,
         "tarball" => entry.tarball,
         "dependencies" => entry.dependencies,
         "optional_dependencies" => Map.get(entry, :optional_dependencies, %{}),
         "has_install_script" => Map.get(entry, :has_install_script, false)
       }}
    end
  end
end