aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/tests/test_licensing.py
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-node/tests/test_licensing.py')
-rw-r--r--packages/meshbay-node/tests/test_licensing.py257
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}"