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
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
5.9 KiB · text
History
53f3a6c