5.9 KiB · text History b868907
defmodule GitGud.Repositories.License do
  @moduledoc """
  Which licence a repository is under, from the file it keeps it in.

  Not a full text-matching identifier. `licensee` and its kind compare
  a candidate against the normalised body of every licence SPDX knows,
  which means shipping those bodies — a megabyte of corpus to answer a
  question that, in practice, a title line already answers. This
  matches distinctive phrases instead: a title, plus something from the
  body to corroborate it, so a README that merely *mentions* the GPL
  isn't mistaken for one.

  Three rules keep it honest:

    * An `SPDX-License-Identifier:` in the file wins outright. It is the
      author saying exactly what they meant, and no inference beats it.
    * Order is most-specific first. Affero and Lesser both contain
      "GNU General Public License", so the plain GPL patterns can only
      match once those have been ruled out.
    * Nothing recognised means `nil`, not a guess. "Unknown" is a true
      statement about a licence file we can't read; naming the wrong
      licence is a false one, and this ends up in a package index where
      people rely on it.

  ## Why the ids have no `-only` / `-or-later`

  SPDX splits the GPL family that way, and the licence text can't tell
  you which: "either version 3 of the License, or (at your option) any
  later version" appears in the GPL's own *appendix*, in every copy,
  whatever the project chose. Inferring the suffix from the file would
  be reading boilerplate as intent. The deprecated family id is what is
  actually known — unless the author wrote an `SPDX-License-Identifier`
  down, which is exactly the case where the suffix is trustworthy.

  ## Dual licensing

  A repository with `LICENSE-MIT` and `LICENSE-APACHE` is under both,
  and SPDX has a spelling for that: `MIT OR Apache-2.0`. Distinct
  findings are joined rather than one of them being picked.
  """

  # Filenames that hold a licence, matched case-insensitively against
  # the root tree. `COPYING.LESSER` is the LGPL's conventional second
  # file, so the extension is not restricted.
  @filenames ~w(license licence copying unlicense copyright)

  # {spdx id, [phrases that must all appear]}. Ordered: the first match
  # wins, so a longer name is checked before the one it contains.
  @signatures [
    {"AGPL-3.0", ["gnu affero general public license", "version 3"]},
    {"LGPL-3.0", ["gnu lesser general public license", "version 3"]},
    {"LGPL-2.1", ["gnu lesser general public license", "version 2.1"]},
    {"GPL-3.0", ["gnu general public license", "version 3"]},
    {"GPL-2.0", ["gnu general public license", "version 2"]},
    {"Apache-2.0", ["apache license", "version 2.0"]},
    {"MPL-2.0", ["mozilla public license", "version 2.0"]},
    {"EPL-2.0", ["eclipse public license", "v 2.0"]},
    {"CC0-1.0", ["cc0 1.0 universal"]},
    {"Unlicense", ["this is free and unencumbered software released into the public domain"]},
    {"WTFPL", ["do what the fuck you want to public license"]},
    {"ISC", ["permission to use, copy, modify, and/or distribute this software"]},
    {"MIT",
     ["permission is hereby granted, free of charge", "the software is provided \"as is\""]},
    {"BSD-3-Clause",
     ["redistribution and use in source and binary forms", "neither the name of"]},
    {"BSD-2-Clause", ["redistribution and use in source and binary forms"]}
  ]

  @doc """
  True for a filename that would hold a licence.

  A name, not a path: this is applied to the entries of a repository's
  root tree, where a licence lives. `src/license.rs` is a source file
  that happens to be called that, and is none of this function's
  business.
  """
  @spec candidate?(String.t()) :: boolean()
  def candidate?(name) when is_binary(name) do
    base = String.downcase(name)

    not String.contains?(base, "/") and
      Enum.any?(@filenames, fn candidate ->
        base == candidate or String.starts_with?(base, candidate <> ".") or
          String.starts_with?(base, candidate <> "-")
      end)
  end

  def candidate?(_), do: false

  @doc """
  The SPDX id `text` is, or nil.
  """
  @spec detect(binary()) :: String.t() | nil
  def detect(text) when is_binary(text) do
    case declared(text) do
      nil -> matched(text)
      id -> id
    end
  end

  def detect(_), do: nil

  @doc """
  One SPDX expression for a whole set of licence files.

  Takes `{name, contents}` pairs — the root tree, filtered — and gives
  back what the repository is under: a single id, an `A OR B` for a
  dual-licensed one, or nil.
  """
  @spec detect_all([{String.t(), binary()}]) :: String.t() | nil
  def detect_all(files) when is_list(files) do
    files
    |> Enum.filter(fn {name, _contents} -> candidate?(name) end)
    |> Enum.sort_by(fn {name, _contents} -> name end)
    |> Enum.map(fn {_name, contents} -> detect(contents) end)
    |> Enum.reject(&is_nil/1)
    |> Enum.uniq()
    |> case do
      [] -> nil
      [one] -> one
      several -> Enum.join(Enum.sort(several), " OR ")
    end
  end

  # `SPDX-License-Identifier: AGPL-3.0-or-later`, anywhere in the file.
  # Bounded to one line and to characters an id can contain, so a prose
  # mention can't drag a paragraph in.
  defp declared(text) do
    case Regex.run(~r/SPDX-License-Identifier:\s*([A-Za-z0-9\-\.\+ ]+)/, text,
           capture: :all_but_first
         ) do
      [id] -> id |> String.trim() |> String.trim_trailing(".")
      _ -> nil
    end
  end

  defp matched(text) do
    normalized = normalize(text)

    Enum.find_value(@signatures, fn {id, phrases} ->
      if Enum.all?(phrases, &String.contains?(normalized, &1)), do: id
    end)
  end

  # Case and layout carry no meaning here, and a licence file is
  # wrapped at whatever width its author chose — so a phrase that spans
  # a line break has to compare equal to the same phrase that doesn't.
  defp normalize(text) do
    text
    |> String.downcase()
    |> String.replace(~r/\s+/u, " ")
  end
end