diff --git a/lib/mix_dependency_submission/application.ex b/lib/mix_dependency_submission/application.ex index 1626da1..59a8afa 100644 --- a/lib/mix_dependency_submission/application.ex +++ b/lib/mix_dependency_submission/application.ex @@ -12,6 +12,10 @@ defmodule MixDependencySubmission.Application do def start(_start_type, _start_args) do Mix.Hex.start() + Mix.SCM.delete(Hex.SCM) + Mix.SCM.append(MixDependencySubmission.SCM.System) + Mix.SCM.append(Hex.SCM) + if Burrito.Util.running_standalone?() do exit_code = Submit.run(Args.argv()) diff --git a/lib/mix_dependency_submission/fetcher.ex b/lib/mix_dependency_submission/fetcher.ex index 2c8f325..f259d19 100644 --- a/lib/mix_dependency_submission/fetcher.ex +++ b/lib/mix_dependency_submission/fetcher.ex @@ -55,6 +55,27 @@ defmodule MixDependencySubmission.Fetcher do @manifest_fetchers [__MODULE__.MixFile, __MODULE__.MixLock, __MODULE__.MixRuntime] + @static_deps %{ + elixir: %{ + scm: MixDependencySubmission.SCM.System, + mix_dep: {:elixir, nil, []}, + relationship: :direct, + scope: :runtime + }, + stdlib: %{ + scm: MixDependencySubmission.SCM.System, + mix_dep: {:stdlib, nil, []}, + relationship: :direct, + scope: :runtime + }, + kernel: %{ + scm: MixDependencySubmission.SCM.System, + mix_dep: {:kernel, nil, []}, + relationship: :direct, + scope: :runtime + } + } + @doc """ Fetches and merges dependencies from all registered fetchers. @@ -87,13 +108,26 @@ defmodule MixDependencySubmission.Fetcher do Map.merge(acc || %{}, dependencies, &merge/3) end) |> case do - nil -> nil - %{} = deps -> transform_all(deps) + nil -> + nil + + %{} = deps -> + deps = Map.merge(deps, @static_deps, &merge/3) + + transform_all(deps) end end @spec merge(app_name(), left :: dependency(), right :: dependency()) :: dependency() - defp merge(_app, left, right), do: Map.merge(left, right) + defp merge(_app, left, right), do: Map.merge(left, right, &merge_property/3) + + @spec merge_property(key :: atom(), left :: value, right :: value) :: value when value: term() + defp merge_property(key, left, right) + defp merge_property(_key, value, value), do: value + defp merge_property(:relationship, :direct, _right), do: :direct + defp merge_property(:relationship, _left, :direct), do: :direct + defp merge_property(:dependencies, left, right), do: Enum.uniq(left ++ right) + defp merge_property(_key, _left, right), do: right @spec transform_all(dependencies :: %{app_name() => dependency()}) :: %{ String.t() => Dependency.t() diff --git a/lib/mix_dependency_submission/fetcher/mix_file.ex b/lib/mix_dependency_submission/fetcher/mix_file.ex index b353e82..753b2e7 100644 --- a/lib/mix_dependency_submission/fetcher/mix_file.ex +++ b/lib/mix_dependency_submission/fetcher/mix_file.ex @@ -35,7 +35,25 @@ defmodule MixDependencySubmission.Fetcher.MixFile do """ @impl Fetcher def fetch do - Mix.Project.config()[:deps] |> List.wrap() |> Map.new(&normalize_dep/1) + app_config = get_application_config() + + [ + Mix.Project.config()[:deps] || [], + [{:elixir, Mix.Project.config()[:elixir], []}], + Enum.map(app_config[:applications] || [], &{&1, []}), + Enum.map(app_config[:extra_applications] || [], &{&1, []}), + Enum.map(app_config[:included_applications] || [], &{&1, included: true}) + ] + |> Enum.concat() + |> Enum.uniq_by(&elem(&1, 0)) + |> Map.new(&normalize_dep/1) + end + + @spec get_application_config() :: Keyword.t() + defp get_application_config do + Mix.Project.get!().application() + rescue + UndefinedFunctionError -> [] end @spec normalize_dep( diff --git a/lib/mix_dependency_submission/fetcher/mix_runtime.ex b/lib/mix_dependency_submission/fetcher/mix_runtime.ex index 3b75251..f2d6a4e 100644 --- a/lib/mix_dependency_submission/fetcher/mix_runtime.ex +++ b/lib/mix_dependency_submission/fetcher/mix_runtime.ex @@ -22,7 +22,7 @@ defmodule MixDependencySubmission.Fetcher.MixRuntime do iex> %{ ...> burrito: %{ ...> scm: Hex.SCM, - ...> dependencies: [:jason, :req, :typed_struct], + ...> dependencies: [:jason, :req, :typed_struct, :kernel, :stdlib, :elixir, :logger, :eex], ...> mix_config: _config, ...> relationship: :direct, ...> scope: :runtime, @@ -35,11 +35,83 @@ defmodule MixDependencySubmission.Fetcher.MixRuntime do """ @impl Fetcher def fetch do - root_deps = [depth: 1] |> Mix.Project.deps_tree() |> Map.keys() - deps_paths = Mix.Project.deps_paths() - deps_scms = Mix.Project.deps_scms() + app = Mix.Project.config()[:app] - Map.new(Mix.Project.deps_tree(), &resolve_dep(&1, root_deps, deps_paths, deps_scms)) + root_deps = + [depth: 1] + |> Mix.Project.deps_tree() + |> Map.keys() + |> Enum.concat(get_app_dependencies(app, true)) + |> Enum.uniq() + + deps_tree = full_runtime_tree(app) + + deps_paths = + deps_tree + |> Map.keys() + |> Enum.reduce(Mix.Project.deps_paths(), fn dep, deps_paths -> + try do + app_dir = Application.app_dir(dep) + Map.put_new(deps_paths, dep, app_dir) + rescue + ArgumentError -> + deps_paths + end + end) + + deps_scms = + deps_tree + |> Map.keys() + |> Enum.reduce(Mix.Project.deps_scms(), fn dep, deps_scms -> + Map.put_new(deps_scms, dep, MixDependencySubmission.SCM.System) + end) + + Map.new(deps_tree, &resolve_dep(&1, root_deps, deps_paths, deps_scms)) + end + + @spec full_runtime_tree(app :: Fetcher.app_name()) :: %{ + Fetcher.app_name() => [Fetcher.app_name()] + } + defp full_runtime_tree(app) do + app_dependencies = app |> get_app_dependencies(true) |> Enum.map(&{&1, []}) + + Mix.Project.deps_tree() + |> Enum.concat(app_dependencies) + |> Enum.group_by(&elem(&1, 0), &elem(&1, 1)) + |> Enum.flat_map(fn {app, dependencies} -> + dependencies = + [dependencies | get_app_dependencies(app, false)] |> List.flatten() |> Enum.uniq() + + [{app, dependencies} | Enum.map(dependencies, &{&1, []})] + end) + |> Enum.group_by(&elem(&1, 0), &elem(&1, 1)) + |> Map.new(fn {app, dependencies} -> + {app, dependencies |> List.flatten() |> Enum.uniq()} + end) + end + + @spec get_app_dependencies(app :: Fetcher.app_name(), root? :: boolean()) :: [ + Fetcher.app_name() + ] + defp get_app_dependencies(app, root?) + defp get_app_dependencies(nil, _root?), do: [] + + defp get_app_dependencies(app, root?) do + case Application.spec(app) do + nil -> + [] + + spec -> + included = spec[:included_applications] || [] + applications = spec[:applications] || [] + optional = spec[:optional_applications] || [] + + if root? do + Enum.uniq(included ++ applications ++ optional) + else + Enum.uniq(included ++ (applications -- optional)) + end + end end @spec resolve_dep( diff --git a/lib/mix_dependency_submission/scm/system.ex b/lib/mix_dependency_submission/scm/system.ex new file mode 100644 index 0000000..c2aa37e --- /dev/null +++ b/lib/mix_dependency_submission/scm/system.ex @@ -0,0 +1,106 @@ +defmodule MixDependencySubmission.SCM.System do + @moduledoc """ + A `Mix.SCM` implementation that looks up the system dependencies. + (Erlang, Elixir, Hex, etc.) + """ + + @behaviour Mix.SCM + + @elixir_applications ~w[eex elixir ex_unit iex logger mix]a + defguard is_elixir_app(app) when app in @elixir_applications + + {:ok, dirs} = :file.list_dir_all(:code.lib_dir()) + + @erlang_applications Enum.map(dirs, fn dir -> + [name, _version] = dir |> List.to_string() |> String.split("-", parts: 2) + String.to_atom(name) + end) + + defguard is_erlang_app(app) when app in @erlang_applications + + defguard is_hex_app(app) when app == :hex + defguard is_system_app(app) when is_elixir_app(app) or is_erlang_app(app) or is_hex_app(app) + + @impl Mix.SCM + def accepts_options(app, opts) + + def accepts_options(app, opts) when is_system_app(app), + do: Keyword.merge(opts, app: app, build: Application.app_dir(app), dest: Application.app_dir(app)) + + def accepts_options(_app, _opts), do: nil + + @impl Mix.SCM + def fetchable?, do: false + + @impl Mix.SCM + def format(opts), do: opts[:app] + + @impl Mix.SCM + def format_lock(_opts), do: nil + + @impl Mix.SCM + def checked_out?(_opts), do: true + + @impl Mix.SCM + def lock_status(_opts), do: :ok + + @impl Mix.SCM + def equal?(opts1, opts2), do: opts1[:app] == opts2[:app] + + @impl Mix.SCM + def managers(_opts), do: [] + + @impl Mix.SCM + @dialyzer {:no_return, checkout: 1} + def checkout(_opts), do: Mix.raise("System SCM does not support checkout.") + + @impl Mix.SCM + @dialyzer {:no_return, update: 1} + def update(_opts), do: Mix.raise("System SCM does not support update.") +end + +defmodule MixDependencySubmission.SCM.MixDependencySubmission.SCM.System do + @moduledoc """ + `MixDependencySubmission.SCM` implementation for system dependencies. + """ + + @behaviour MixDependencySubmission.SCM + + import MixDependencySubmission.SCM.System, + only: [is_elixir_app: 1, is_erlang_app: 1, is_hex_app: 1] + + @impl MixDependencySubmission.SCM + def mix_dep_to_purl(app, version) + + def mix_dep_to_purl({app, _version_requirement, _opts}, _version) when is_elixir_app(app) do + Purl.new!(%Purl{ + type: "generic", + # TODO: Use once the spec is merged + # type: "otp", + name: to_string(app), + subpath: ["lib", to_string(app)], + qualifiers: %{"vcs_url" => "git+https://github.com/elixir-lang/elixir.git"} + }) + end + + def mix_dep_to_purl({app, _version_requirement, _opts}, _version) when is_erlang_app(app) do + Purl.new!(%Purl{ + type: "generic", + # TODO: Use once the spec is merged + # type: "otp", + name: to_string(app), + subpath: ["lib", to_string(app)], + qualifiers: %{"vcs_url" => "git+https://github.com/erlang/otp.git"} + }) + end + + def mix_dep_to_purl({app, _version_requirement, _opts}, _version) when is_hex_app(app) do + Purl.new!(%Purl{ + type: "generic", + # TODO: Use once the spec is merged + # type: "otp", + name: to_string(app), + qualifiers: %{"vcs_url" => "git+https://github.com/hexpm/hex.git"} + }) + end +end diff --git a/test/fixtures/app_locked/mix.exs b/test/fixtures/app_locked/mix.exs index a288123..571f2ea 100644 --- a/test/fixtures/app_locked/mix.exs +++ b/test/fixtures/app_locked/mix.exs @@ -5,6 +5,7 @@ defmodule AppNameToReplace.MixProject do [ app: :app_name_to_replace, version: "0.0.0-dev", + elixir: "1.18.4", deps: [ {:credo, "~> 1.7"}, {:mime, "~> 2.0"}, @@ -14,4 +15,10 @@ defmodule AppNameToReplace.MixProject do ] ] end + + def application do + [ + extra_applications: [:logger, :public_key] + ] + end end diff --git a/test/mix_dependency_submission/fetcher/mix_file_test.exs b/test/mix_dependency_submission/fetcher/mix_file_test.exs index 55d3c7d..81eaff6 100644 --- a/test/mix_dependency_submission/fetcher/mix_file_test.exs +++ b/test/mix_dependency_submission/fetcher/mix_file_test.exs @@ -55,6 +55,24 @@ defmodule MixDependencySubmission.Fetcher.MixFileTest do depth: 1 ]}, relationship: :direct + }, + elixir: %{ + scope: :runtime, + scm: MixDependencySubmission.SCM.System, + mix_dep: {:elixir, "1.18.4", [app: :elixir, build: _elixir_build, dest: _elixir_dest]}, + relationship: :direct + }, + logger: %{ + scope: :runtime, + scm: MixDependencySubmission.SCM.System, + mix_dep: {:logger, nil, [app: :logger, build: _logger_build, dest: _logger_dest]}, + relationship: :direct + }, + public_key: %{ + scope: :runtime, + scm: MixDependencySubmission.SCM.System, + mix_dep: {:public_key, nil, [app: :public_key, build: _public_key_build, dest: _public_key_dest]}, + relationship: :direct } } = MixFile.fetch() end) diff --git a/test/mix_dependency_submission/fetcher/mix_runtime_test.exs b/test/mix_dependency_submission/fetcher/mix_runtime_test.exs index bcf7ac6..cbcf96d 100644 --- a/test/mix_dependency_submission/fetcher/mix_runtime_test.exs +++ b/test/mix_dependency_submission/fetcher/mix_runtime_test.exs @@ -58,8 +58,16 @@ defmodule MixDependencySubmission.Fetcher.MixRuntimeTest do version: nil, mix_config: [], scm: Hex.SCM, - dependencies: [], + dependencies: [:kernel, :stdlib, :elixir, :logger], relationship: :direct + }, + logger: %{ + scope: :runtime, + version: nil, + mix_config: [{:app, :logger} | _logger_rest], + scm: MixDependencySubmission.SCM.System, + dependencies: [], + relationship: :indirect } } = MixRuntime.fetch() end) diff --git a/test/mix_dependency_submission_test.exs b/test/mix_dependency_submission_test.exs index e414c58..7271183 100644 --- a/test/mix_dependency_submission_test.exs +++ b/test/mix_dependency_submission_test.exs @@ -199,6 +199,34 @@ defmodule MixDependencySubmissionTest do %Purl{type: "hex", name: "file_system", version: "0.2.10"}, %Purl{type: "hex", name: "jason", version: "1.4.0"} ] + }, + "elixir" => %Dependency{ + scope: :runtime, + metadata: %{}, + dependencies: [], + relationship: :direct, + package_url: %Purl{ + type: "generic", + # TODO: Use once the spec is merged + # type: "otp", + name: "elixir", + qualifiers: %{"vcs_url" => "git+https://github.com/elixir-lang/elixir.git"}, + subpath: ["lib", "elixir"] + } + }, + "stdlib" => %Dependency{ + scope: :runtime, + metadata: %{}, + dependencies: [], + relationship: :direct, + package_url: %Purl{ + type: "generic", + # TODO: Use once the spec is merged + # type: "otp", + name: "stdlib", + qualifiers: %{"vcs_url" => "git+https://github.com/erlang/otp.git"}, + subpath: ["lib", "stdlib"] + } } } = resolved end