diff options
Diffstat (limited to 'packages/meshbay-node/tests')
| -rw-r--r-- | packages/meshbay-node/tests/test_licensing.py | 257 |
1 files changed, 257 insertions, 0 deletions
diff --git a/packages/meshbay-node/tests/test_licensing.py b/packages/meshbay-node/tests/test_licensing.py new file mode 100644 index 0000000..6e50c80 --- /dev/null +++ b/packages/meshbay-node/tests/test_licensing.py @@ -0,0 +1,257 @@ +""" +Licensing: meshbay-common under the LGPL, everything else under the AGPL, and +every third-party piece a build ships accounted for. + +Reads files and installed metadata; builds nothing. What it guards against is +drift — a licence field nobody updates, a vendored file without its licence, a +GPL dependency creeping into the one package that must stay usable under the +LGPL. +""" + +import importlib.util +import json +import re +import tomllib +from importlib import metadata +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[3] +PACKAGES = ROOT / "packages" +STATIC = PACKAGES / "meshbay-hub" / "src" / "meshbay_hub" / "static" +VENDOR = STATIC / "vendor" +DESKTOP = PACKAGES / "meshbay-client" / "src" +ANDROID = PACKAGES / "meshbay-android" / "app" / "src" / "main" / "kotlin" +KEYS = ANDROID / "org" / "meshbay" / "client" / "keys" +SPDX_LGPL = "// SPDX-License-Identifier: LGPL-3.0-or-later\n" +# The protocol layer in the clients (README, "Licence"): what a program needs to +# speak to a hub and a node, under the LGPL in every language it exists in. +LGPL_FILES = sorted( + [STATIC / f for f in ("keyderive.js", "crypto.js", "playlist-crypto.js")] + + [STATIC / "transport.js"] + + sorted(STATIC.glob("transport-*.js")) + + [DESKTOP / f for f in ("keyring.js", "transcripts.js", "argon2-wasm.js")] + + [KEYS / f for f in ("Kdf.kt", "Keyring.kt", "Transcripts.kt")] +) +EXPECTED = { + "meshbay-common": ("LGPL-3.0-or-later", ["COPYING", "COPYING.LESSER"]), + "meshbay-hub": ("AGPL-3.0-or-later", ["LICENSE"]), + "meshbay-node": ("AGPL-3.0-or-later", ["LICENSE"]), +} + + +def _notices(): + spec = importlib.util.spec_from_file_location( + "third_party_notices", ROOT / "packaging" / "third_party_notices.py" + ) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +def test_each_python_package_declares_its_licence_and_ships_the_text(): + for pkg, (expr, files) in EXPECTED.items(): + project = tomllib.loads((PACKAGES / pkg / "pyproject.toml").read_text())["project"] + assert project["license"] == expr, pkg + assert project["license-files"] == files, pkg + for f in files: + assert (PACKAGES / pkg / f).is_file(), f"{pkg}/{f}" + + +def test_licence_texts_are_the_right_ones(): + agpl = (ROOT / "LICENSE").read_text() + assert "GNU AFFERO GENERAL PUBLIC LICENSE" in agpl and "Version 3" in agpl + # Copies, because a wheel's license-files cannot reach outside its package. + for pkg in ("meshbay-hub", "meshbay-node"): + assert (PACKAGES / pkg / "LICENSE").read_text() == agpl, pkg + common = PACKAGES / "meshbay-common" + assert "GNU LESSER GENERAL PUBLIC LICENSE" in (common / "COPYING.LESSER").read_text() + assert "GNU GENERAL PUBLIC LICENSE" in (common / "COPYING").read_text() + + +def test_rpm_specs_and_the_client_agree_with_the_packages(): + for pkg in ("meshbay-common", "meshbay-hub", "meshbay-node", "meshbay-client"): + spec = (ROOT / "packaging" / "rpm" / f"{pkg}.spec").read_text() + want = EXPECTED.get(pkg, ("AGPL-3.0-or-later",))[0] + assert re.search(rf"^License:\s+{re.escape(want)}\s*$", spec, re.M), pkg + assert "%license %{_licensedir}/%{name}" in spec, pkg + pkg_json = json.loads((PACKAGES / "meshbay-client" / "package.json").read_text()) + assert pkg_json["license"] == "AGPL-3.0-or-later" + + +def test_every_windows_target_ships_the_licence(): + pkg_json = json.loads((PACKAGES / "meshbay-client" / "package.json").read_text()) + assert {"from": "../../LICENSE", "to": "LICENSE.txt"} in pkg_json["build"]["win"][ + "extraResources" + ] + for yml in ("electron-builder.light.yml", "electron-builder.msix.yml"): + text = (ROOT / "packaging" / "win" / yml).read_text() + assert "- from: ../../LICENSE\n to: LICENSE.txt" in text, yml + ps1 = (ROOT / "packaging" / "win" / "build-node-runtime.ps1").read_text() + assert "third_party_notices.py" in ps1 and "THIRD-PARTY-NOTICES.txt" in ps1 + + +def test_common_depends_on_nothing_copyleft(): + """The LGPL is only worth something if the library can be taken alone.""" + for req in metadata.distribution("meshbay-common").requires or []: + if "extra ==" in req: + continue + name = re.split(r"[\s\[<>=!~;(]", req, maxsplit=1)[0] + md = metadata.distribution(name).metadata + label = " ".join( + [md.get("License-Expression") or "", md.get("License") or ""] + + (md.get_all("Classifier") or []) + ) + assert "GPL" not in label, f"{name}: {label[:120]}" + + +def test_notices_follow_what_the_node_actually_ships(): + mod = _notices() + dists = mod._closure(["meshbay-node"]) + assert "mutagen" in dists and "guessit" in dists and "av" in dists + # Extras the node does not ask for, and dev tools, stay out. + assert "pytest" not in dists and "piexif" not in dists + text = mod.render(["meshbay-node"], [], with_python=False) + assert re.search(r"^ mutagen [\d.]+ — GPL", text, re.M) + libs = mod._native_libs(dists["av"]) + if libs: # PyAV's FFmpeg is grafted in; the notice must say which + assert libs[0] in text + + +def test_every_vendored_file_has_its_provenance_and_licence(): + provenance = (VENDOR / "PROVENANCE.md").read_text() + licences = (VENDOR / "LICENSES.txt").read_text() + for f in VENDOR.iterdir(): + if f.name in ("PROVENANCE.md", "LICENSES.txt"): + continue + assert f"## {f.name}" in provenance or f"### {f.name}" in provenance, f.name + assert f.name in licences, f.name + + +def _sources(): + for tree, pattern in ((STATIC, "*.js"), (DESKTOP, "*.js"), (ANDROID, "**/*.kt")): + for f in tree.glob(pattern): + if "vendor" not in f.parts and "locales" not in f.parts: + yield f + + +def test_the_lgpl_files_are_exactly_the_ones_that_say_so(): + marked = sorted(f for f in _sources() if f.read_text().startswith(SPDX_LGPL)) + assert marked == LGPL_FILES + + +def test_every_client_carries_the_licence_texts(): + """static/ is the interface of the web, the desktop and Android alike.""" + texts = STATIC / "licenses" + assert (texts / "AGPL-3.0.txt").read_text() == (ROOT / "LICENSE").read_text() + common = PACKAGES / "meshbay-common" + assert (texts / "LGPL-3.0.txt").read_text() == (common / "COPYING.LESSER").read_text() + assert (texts / "GPL-3.0.txt").read_text() == (common / "COPYING").read_text() + + +def _strip_js(src: str) -> str: + src = re.sub(r"/\*[\s\S]*?\*/|//[^\n]*", "", src) + return re.sub(r"'(?:\\.|[^'\\\n])*'|\"(?:\\.|[^\"\\\n])*\"", "''", src) + + +def test_the_lgpl_layer_depends_on_nothing_under_the_agpl(): + """ + One import of an AGPL module and a client built on the layer is under the + AGPL after all. What a host supplies (window.MeshBayPlatform, a Secrets + store) is an injected interface, and is not looked for here. + """ + lgpl = set(LGPL_FILES) + top = re.compile( + r"^(?:export\s+)?(?:async\s+)?(?:function\*?\s+|(?:const|let|var|class)\s+)" + r"([A-Za-z_$][\w$]*)", + re.M, + ) + defined_in_lgpl = {n for f in lgpl if f.suffix == ".js" for n in top.findall(f.read_text())} + agpl_globals = { + n: f.name + for f in STATIC.glob("*.js") + if f not in lgpl + for n in top.findall(f.read_text()) + if n not in defined_in_lgpl + } + kt_decl = re.compile(r"^\s*(?:\w+\s+)*(?:class|object|interface)\s+(\w+)", re.M) + defined_in_lgpl_kt = { + n for f in lgpl if f.suffix == ".kt" for n in kt_decl.findall(f.read_text()) + } + agpl_kotlin = { + n: f.name + for f in ANDROID.glob("**/*.kt") + if f not in lgpl + for n in kt_decl.findall(f.read_text()) + if n not in defined_in_lgpl_kt + } + for f in LGPL_FILES: + src = f.read_text() + if f.suffix == ".kt": + for imp in re.findall(r"^import (org\.meshbay\.[\w.]+)", src, re.M): + owner = ( + imp.split(".")[-2] if imp.split(".")[-1][0].islower() else imp.split(".")[-1] + ) + assert owner in {g.stem for g in lgpl}, f"{f.name} imports {imp}" + code = _strip_js(src) # Kotlin's comments and strings take the same shapes + for name, owner in agpl_kotlin.items(): + assert not re.search(rf"\b{name}\b", code), f"{f.name} uses {name} ({owner})" + continue + code = _strip_js(src) + uncommented = re.sub(r"/\*[\s\S]*?\*/|^\s*//[^\n]*", "", src, flags=re.M) + for spec in re.findall( + r"""(?:\bfrom|\bimport\(|\brequire\()\s*['"]([^'"\n]+)['"]""", uncommented + ): + if spec.startswith("node:") or "vendor" in spec: + continue + assert (f.parent / spec).resolve() in lgpl, f"{f.name} imports {spec}" + for name, owner in agpl_globals.items(): + assert not re.search(rf"(?<![\w$.]){re.escape(name)}\s*\(", code), ( + f"{f.name} calls {name}() from {owner}" + ) + + +APP_EXCEPTION = STATIC / "licenses" / "APPLICATION-EXCEPTION.txt" +REFERENCE_APP = [STATIC / "helloworld-app.js", STATIC / "helloworld-app-settings.js"] + + +def _interface_modules() -> set[str]: + """The modules the permission names — read from it, the one place they are listed.""" + text = APP_EXCEPTION.read_text() + block = text.split("2. the names exported by these modules", 1)[1].split("3.", 1)[0] + return set(re.findall(r"^\s+([\w-]+\.js)\s*$", block, re.M)) + + +def test_the_application_interface_names_modules_that_exist(): + modules = _interface_modules() + assert modules == {"i18n.js", "icon.js", "file-utils.js", "settings-ui.js", "folder-tree.js"} + for m in modules: + assert (STATIC / m).is_file(), m + assert not (STATIC / m).read_text().startswith(SPDX_LGPL), ( + f"{m} is LGPL already; the permission is for the AGPL part" + ) + + +def test_the_reference_application_is_free_to_copy_and_stays_inside_the_interface(): + """ + Copying helloworld is how an application starts. Were it to import anything + outside the application interface, every application started from it would + be a work based on the AGPL interface without anybody having chosen that. + """ + allowed = _interface_modules() | {f.name for f in LGPL_FILES if f.parent == STATIC} + for f in REFERENCE_APP: + src = f.read_text() + assert src.startswith("// SPDX-License-Identifier: 0BSD\n"), f.name + for spec in re.findall(r"""^import .* from ['"]\./([^'"]+)['"]""", src, re.M): + assert spec.startswith("vendor/") or spec in allowed, f"{f.name} imports {spec}" + assert "import(" not in _strip_js(src), f"{f.name}: a dynamic import escapes this check" + + +def test_every_spdx_line_is_one_of_the_known_licences(): + for f in _sources(): + first = f.read_text().split("\n", 1)[0] + if "SPDX-License-Identifier" not in first: + continue + if f in REFERENCE_APP: + assert first.endswith(": 0BSD"), f.name + else: + assert f in LGPL_FILES, f"{f.name}: {first}" |