diff --git a/README.md b/README.md index f3f0cab..c81a450 100644 --- a/README.md +++ b/README.md @@ -111,3 +111,4 @@ This project contains 3rd party work as follow: - ASCII table rendering: a [partial copy](./lib/table_rex) of [djm/table_rex](https://github.com/djm/table_rex). - CSV rendering: a [partial copy](./lib/csv) of [beatrichartz/csv](https://github.com/beatrichartz/csv). +- Config parsing: a [partial copy](./lib/credo) of [rrrene/credo](https://github.com/rrrene/credo) \ No newline at end of file diff --git a/lib/credo/LICENSE b/lib/credo/LICENSE new file mode 100644 index 0000000..d1e4f37 --- /dev/null +++ b/lib/credo/LICENSE @@ -0,0 +1,20 @@ +Copyright (c) 2015-2020 René Föhring + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/lib/credo/README.md b/lib/credo/README.md new file mode 100644 index 0000000..f37e110 --- /dev/null +++ b/lib/credo/README.md @@ -0,0 +1,4 @@ +This directory contains a copy of [rrrene/credo](https://github.com/rrrene/credo). + +A hard copy is used instead of a dependency so that `mix archive.install ...`, +which does not recognize the archive's defined dependencies, is supported. diff --git a/lib/credo/exs_loader.ex b/lib/credo/exs_loader.ex new file mode 100644 index 0000000..7c58e4f --- /dev/null +++ b/lib/credo/exs_loader.ex @@ -0,0 +1,76 @@ +defmodule Credo.ExsLoader do + @moduledoc false + + def parse(exs_string) do + case Code.string_to_quoted(exs_string) do + {:ok, ast} -> + {:ok, process_exs(ast)} + + {:error, {line_meta, message, trigger}} when is_list(line_meta) -> + {:error, {line_meta[:line], message, trigger}} + + {:error, value} -> + {:error, value} + end + end + + defp process_exs(v) + when is_atom(v) or is_binary(v) or is_float(v) or is_integer(v), + do: v + + defp process_exs(list) when is_list(list) do + Enum.map(list, &process_exs/1) + end + + defp process_exs({:sigil_w, _, [{:<<>>, _, [list_as_string]}, []]}) do + String.split(list_as_string, ~r/\s+/) + end + + # TODO: support regex modifiers + defp process_exs({:sigil_r, _, [{:<<>>, _, [regex_as_string]}, []]}) do + Regex.compile!(regex_as_string) + end + + defp process_exs({:%{}, _meta, body}) do + process_map(body, %{}) + end + + defp process_exs({:{}, _meta, body}) do + process_tuple(body, {}) + end + + defp process_exs({:__aliases__, _meta, name_list}) do + Module.safe_concat(name_list) + end + + defp process_exs({{:__aliases__, _meta, name_list}, options}) do + {Module.safe_concat(name_list), process_exs(options)} + end + + defp process_exs({key, value}) when is_atom(key) or is_binary(key) do + {process_exs(key), process_exs(value)} + end + + defp process_tuple([], acc), do: acc + + defp process_tuple([head | tail], acc) do + acc = process_tuple_item(head, acc) + process_tuple(tail, acc) + end + + defp process_tuple_item(value, acc) do + Tuple.append(acc, process_exs(value)) + end + + defp process_map([], acc), do: acc + + defp process_map([head | tail], acc) do + acc = process_map_item(head, acc) + process_map(tail, acc) + end + + defp process_map_item({key, value}, acc) + when is_atom(key) or is_binary(key) do + Map.put(acc, key, process_exs(value)) + end +end diff --git a/lib/licensir/config_file.ex b/lib/licensir/config_file.ex new file mode 100644 index 0000000..2771882 --- /dev/null +++ b/lib/licensir/config_file.ex @@ -0,0 +1,28 @@ +defmodule Licensir.ConfigFile do + @moduledoc """ + Parse a project's .licensir.exs file to determine what licenses are acceptable to the user, not acceptable, and projects that are allowed + """ + + @config_filename ".licensir.exs" + + defstruct allowlist: [], denylist: [], allow_deps: [] + + def parse(nil), do: parse(@config_filename) + def parse(file) do + if File.exists?(file) do + {:ok, raw} = + file + |> File.read!() + |> Credo.ExsLoader.parse() + + {:ok, + %__MODULE__{ + allowlist: raw[:allowlist] || [], + denylist: raw[:denylist] || [], + allow_deps: Enum.map(raw[:allow_deps] || [], &Atom.to_string/1) + }} + else + {:ok, %__MODULE__{}} + end + end +end diff --git a/lib/licensir/license.ex b/lib/licensir/license.ex index b2dba3d..be57b01 100644 --- a/lib/licensir/license.ex +++ b/lib/licensir/license.ex @@ -21,6 +21,7 @@ defmodule Licensir.License do license: nil, certainty: 0.0, mix: nil, + status: :unknown, hex_metadata: nil, file: nil @@ -33,6 +34,9 @@ defmodule Licensir.License do certainty: float(), mix: list(String.t()) | nil, hex_metadata: list(String.t()) | nil, + status: status(), file: String.t() | nil } + + @type status :: :allowed | :not_allowed | :unknown end diff --git a/lib/licensir/scanner.ex b/lib/licensir/scanner.ex index 4ceaef4..3e258c4 100644 --- a/lib/licensir/scanner.ex +++ b/lib/licensir/scanner.ex @@ -2,7 +2,7 @@ defmodule Licensir.Scanner do @moduledoc """ Scans the project's dependencies for their license information. """ - alias Licensir.{License, FileAnalyzer, Guesser} + alias Licensir.{License, FileAnalyzer, Guesser, ConfigFile} @human_names %{ apache2: "Apache 2", @@ -25,6 +25,7 @@ defmodule Licensir.Scanner do def scan(opts) do # Make sure the dependencies are loaded Mix.Project.get!() + {:ok, config} = Licensir.ConfigFile.parse(opts[:config_file]) deps() |> to_struct() @@ -32,6 +33,7 @@ defmodule Licensir.Scanner do |> search_hex_metadata() |> search_file() |> Guesser.guess() + |> put_status(config) end @spec deps() :: list(Mix.Dep.t()) @@ -59,6 +61,18 @@ defmodule Licensir.Scanner do } end + defp put_status(licenses, config) when is_list(licenses), + do: Enum.map(licenses, &put_status(&1, config)) + + defp put_status(%License{name: name, license: license_name} = license, %ConfigFile{} = config) do + cond do + name in config.allow_deps -> %{license | status: :allowed} + license_name in config.allowlist -> %{license | status: :allowed} + license_name in config.denylist -> %{license | status: :not_allowed} + true -> license + end + end + defp filter_top_level(deps, opts) do if Keyword.get(opts, :top_level_only) do Enum.filter(deps, &(&1.dep.top_level)) diff --git a/lib/mix/tasks/licenses.ex b/lib/mix/tasks/licenses.ex index c91f8b0..36a8118 100644 --- a/lib/mix/tasks/licenses.ex +++ b/lib/mix/tasks/licenses.ex @@ -19,16 +19,32 @@ defmodule Mix.Tasks.Licenses do def run(argv) do {opts, _argv} = OptionParser.parse!(argv, switches: @switches) - Licensir.Scanner.scan(opts) - |> Enum.sort_by(fn license -> license.name end) + licenses = + opts + |> Licensir.Scanner.scan() + |> Enum.sort_by(fn license -> license.name end) + + licenses |> Enum.map(&to_row/1) |> render(opts) + + exit_status(licenses) + end + + defp exit_status(licenses) do + if Enum.any?(licenses, &(&1.status == :not_allowed)) do + exit({:shutdown, 1}) + end end defp to_row(map) do - [map.name, map.version, map.license] + [map.name, map.version, map.license, license_status(map.status)] end + def license_status(:allowed), do: "Allowed" + def license_status(:not_allowed), do: "Not allowed" + def license_status(_), do: "Unknown" + defp render(rows, opts) do cond do Keyword.get(opts, :csv) -> render_csv(rows) @@ -40,13 +56,13 @@ defmodule Mix.Tasks.Licenses do _ = Mix.Shell.IO.info([:yellow, "Notice: This is not a legal advice. Use the information below at your own risk."]) rows - |> TableRex.quick_render!(["Package", "Version", "License"]) + |> TableRex.quick_render!(["Package", "Version", "License", "Status"]) |> IO.puts() end defp render_csv(rows) do rows - |> List.insert_at(0, ["Package", "Version", "License"]) + |> List.insert_at(0, ["Package", "Version", "License", "Status"]) |> CSV.encode() |> Enum.each(&IO.write/1) end diff --git a/test/fixtures/licensir-config.exs b/test/fixtures/licensir-config.exs new file mode 100644 index 0000000..47a5554 --- /dev/null +++ b/test/fixtures/licensir-config.exs @@ -0,0 +1,5 @@ +%{ + allowlist: ["MIT", "Apache 2.0"], + denylist: ["GPLv2", "Licensir Mock License"], + allow_deps: [:dep_mock_license] +} diff --git a/test/licensir/config_file_test.exs b/test/licensir/config_file_test.exs new file mode 100644 index 0000000..a8b45cd --- /dev/null +++ b/test/licensir/config_file_test.exs @@ -0,0 +1,13 @@ +defmodule Licensir.ConfigFileTest do + use Licensir.Case + alias Licensir.{ConfigFile} + + describe "parse" do + test "returns options" do + assert {:ok, config} = ConfigFile.parse("test/fixtures/licensir-config.exs") + assert config.allowlist == ["MIT", "Apache 2.0"] + assert config.denylist == ["GPLv2", "Licensir Mock License"] + assert config.allow_deps == ["dep_mock_license"] + end + end +end diff --git a/test/licensir/scanner_test.exs b/test/licensir/scanner_test.exs index d5afaf1..22c8572 100644 --- a/test/licensir/scanner_test.exs +++ b/test/licensir/scanner_test.exs @@ -24,6 +24,22 @@ defmodule Licensir.ScannerTest do refute has_license?(licenses, %{app: :dep_of_dep}) end + test "returns the acceptability of the license" do + licenses = Licensir.Scanner.scan([config_file: "test/fixtures/licensir-config.exs"]) + + not_allowed_license = Enum.find(licenses, & &1.name == "dep_one_license") + assert not_allowed_license.status == :not_allowed + + unknown_license = Enum.find(licenses, & &1.name == "dep_license_undefined") + assert unknown_license.status == :unknown + + allowed_license = Enum.find(licenses, & &1.name == "dep_two_variants_same_license") + assert allowed_license.status == :allowed + + allowed_app = Enum.find(licenses, & &1.name == "dep_mock_license") + assert allowed_app.status == :allowed + end + defp has_license?(licenses, search_map) do Enum.any?(licenses, fn license -> Map.merge(license, search_map) == license diff --git a/test/mix/tasks/licenses_test.exs b/test/mix/tasks/licenses_test.exs index e8b7fae..7a0cdb5 100644 --- a/test/mix/tasks/licenses_test.exs +++ b/test/mix/tasks/licenses_test.exs @@ -11,18 +11,19 @@ defmodule Licensir.Mix.Tasks.LicensesTest do expected = IO.ANSI.format_fragment([ [:yellow, "Notice: This is not a legal advice. Use the information below at your own risk."], :reset, "\n", - "+-----------------------------------+---------+----------------------------------------------------+", "\n", - "| Package | Version | License |", "\n", - "+-----------------------------------+---------+----------------------------------------------------+", "\n", - "| dep_license_undefined | | Undefined |", "\n", - "| dep_of_dep | | Undefined |", "\n", - "| dep_one_license | | Licensir Mock License |", "\n", - "| dep_one_unrecognized_license_file | | Unrecognized license file content |", "\n", - "| dep_two_conflicting_licenses | | Unsure (found: License One, Licensir Mock License) |", "\n", - "| dep_two_licenses | | License Two, License Three |", "\n", - "| dep_two_variants_same_license | | Apache 2.0 |", "\n", - "| dep_with_dep | | Undefined |", "\n", - "+-----------------------------------+---------+----------------------------------------------------+", "\n", "\n" + "+-----------------------------------+---------+----------------------------------------------------+---------+", "\n", + "| Package | Version | License | Status |", "\n", + "+-----------------------------------+---------+----------------------------------------------------+---------+", "\n", + "| dep_license_undefined | | Undefined | Unknown |", "\n", + "| dep_mock_license | | Licensir Mock License | Unknown |", "\n", + "| dep_of_dep | | Undefined | Unknown |", "\n", + "| dep_one_license | | Licensir Mock License | Unknown |", "\n", + "| dep_one_unrecognized_license_file | | Unrecognized license file content | Unknown |", "\n", + "| dep_two_conflicting_licenses | | Unsure (found: License One, Licensir Mock License) | Unknown |", "\n", + "| dep_two_licenses | | License Two, License Three | Unknown |", "\n", + "| dep_two_variants_same_license | | Apache 2.0 | Unknown |", "\n", + "| dep_with_dep | | Undefined | Unknown |", "\n", + "+-----------------------------------+---------+----------------------------------------------------+---------+", "\n", "\n" ]) |> to_string() @@ -37,15 +38,16 @@ defmodule Licensir.Mix.Tasks.LicensesTest do expected = """ - Package,Version,License\r - dep_license_undefined,,Undefined\r - dep_of_dep,,Undefined\r - dep_one_license,,Licensir Mock License\r - dep_one_unrecognized_license_file,,Unrecognized license file content\r - dep_two_conflicting_licenses,,"Unsure (found: License One, Licensir Mock License)"\r - dep_two_licenses,,"License Two, License Three"\r - dep_two_variants_same_license,,Apache 2.0\r - dep_with_dep,,Undefined\r + Package,Version,License,Status\r + dep_license_undefined,,Undefined,Unknown\r + dep_mock_license,,Licensir Mock License,Unknown\r + dep_of_dep,,Undefined,Unknown\r + dep_one_license,,Licensir Mock License,Unknown\r + dep_one_unrecognized_license_file,,Unrecognized license file content,Unknown\r + dep_two_conflicting_licenses,,"Unsure (found: License One, Licensir Mock License)",Unknown\r + dep_two_licenses,,"License Two, License Three",Unknown\r + dep_two_variants_same_license,,Apache 2.0,Unknown\r + dep_with_dep,,Undefined,Unknown\r """ assert output == expected diff --git a/test/support/test_app.ex b/test/support/test_app.ex index f331766..e2efc55 100644 --- a/test/support/test_app.ex +++ b/test/support/test_app.ex @@ -8,7 +8,8 @@ defmodule Licensir.TestApp do {:dep_license_undefined, path: "test/fixtures/deps/dep_license_undefined"}, {:dep_two_variants_same_license, path: "test/fixtures/deps/dep_two_variants_same_license"}, {:dep_two_conflicting_licenses, path: "test/fixtures/deps/dep_two_conflicting_licenses"}, - {:dep_one_unrecognized_license_file, path: "test/fixtures/deps/dep_one_unrecognized_license_file"} + {:dep_one_unrecognized_license_file, path: "test/fixtures/deps/dep_one_unrecognized_license_file"}, + {:dep_mock_license, path: "test/fixtures/deps/dep_mock_license"} ] ] end