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