aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/tests/test_licensing.py
blob: 6e50c806e018891b87ab3f1b6e5e6e65fa865d12 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
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}"