diff options
Diffstat (limited to 'packages/meshbay-node/tests')
| -rw-r--r-- | packages/meshbay-node/tests/test_licensing.py | 257 | ||||
| -rw-r--r-- | packages/meshbay-node/tests/test_root_eject.py | 128 |
2 files changed, 385 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}" diff --git a/packages/meshbay-node/tests/test_root_eject.py b/packages/meshbay-node/tests/test_root_eject.py index d73e71c..b6b50aa 100644 --- a/packages/meshbay-node/tests/test_root_eject.py +++ b/packages/meshbay-node/tests/test_root_eject.py @@ -264,3 +264,131 @@ async def test_the_ejected_key_is_case_folded(tmp_path): assert Roster.root_ejected_key("Films") == Roster.root_ejected_key("FILMS") finally: await roster.close() + + +# ── The safety net's eject undoes itself; the operator's does not ─────────── + +def _vanish(films: Path) -> None: + for f in films.iterdir(): + f.unlink() + films.rmdir() + + +async def test_an_auto_ejected_root_comes_back_with_its_own_files(tmp_path): + """ + The drive that was not mounted yet when the node started (found on a node + started with the session, its USB drives mounted a minute later): the + safety net ejected it, and once the same files are readable at the same + place it is plugged back without anyone having to. + """ + films = tmp_path / "Films" + films.mkdir() + (films / "a.mkv").write_bytes(b"a") + seen: list[tuple[str, bool]] = [] + + async def record(name: str, ejected: bool) -> None: + seen.append((name, ejected)) + + roots = _roots(films) + idx = await _indexer(roots, on_root_ejected=record) + hidden = tmp_path / "unmounted" + films.rename(hidden) + await idx.reconcile() + assert roots.roots[0].ejected is True + + hidden.rename(films) + await idx.reconcile() + assert roots.roots[0].ejected is False + assert roots.roots[0].available is True + assert _names(idx) == {"a.mkv"} + assert seen == [("Films", True), ("Films", False)] + + +@pytest.mark.parametrize("what_came_back", ["empty", "another drive"]) +async def test_an_auto_ejected_root_stays_out_when_its_files_are_not_there( + tmp_path, what_came_back): + """ + What the safety net exists for: an empty mount point, or another drive + mounted at the same place, is not the library. Plugging it back would + rescan it, and the rescan would read the library as erased. + """ + films = tmp_path / "Films" + films.mkdir() + (films / "a.mkv").write_bytes(b"a") + roots = _roots(films) + idx = await _indexer(roots) + _vanish(films) + await idx.reconcile() + + films.mkdir() + if what_came_back == "another drive": + (films / "other.mkv").write_bytes(b"something else") + await idx.reconcile() + assert roots.roots[0].ejected is True + assert _names(idx) == {"a.mkv"}, "the library was treated as erased" + + +async def test_an_operator_eject_is_never_undone_automatically(tmp_path): + films = tmp_path / "Films" + films.mkdir() + (films / "a.mkv").write_bytes(b"a") + roots = _roots(films) + idx = await _indexer(roots) + + idx.eject_root("Films") + await idx.reconcile() + assert roots.roots[0].ejected is True + + +async def test_after_a_restart_the_cache_recognises_the_drive(tmp_path): + """ + A restarted node has no index entry for an ejected root: the index is + rebuilt by scanning, and an ejected root is not scanned. What it does have is + the hash cache, with the size and mtime of every file it read there. + """ + import os + + from meshbay_node.indexer.cache import IndexCache + + films = tmp_path / "Films" + films.mkdir() + movie = films / "a.mkv" + movie.write_bytes(b"a") + st = movie.stat() + + def restarted_ejected() -> RootSet: + return RootSet.build([{"path": str(films), "removable": True, + "ejected": True, "ejected_auto": True}]) + + async with IndexCache(tmp_path / "cache.db") as cache: + await _indexer(_roots(films), cache=cache) + + # Another drive at the same place, with a file of the same name. + movie.write_bytes(b"another drive") + roots = restarted_ejected() + await _indexer(roots, cache=cache) + assert roots.roots[0].ejected is True + + # The drive itself. + movie.write_bytes(b"a") + os.utime(movie, ns=(st.st_atime_ns, st.st_mtime_ns)) + roots = restarted_ejected() + idx = await _indexer(roots, cache=cache) + assert roots.roots[0].ejected is False + assert _names(idx) == {"a.mkv"} + + +async def test_the_roster_keeps_an_auto_eject_apart(tmp_path): + roster = Roster(db_path=tmp_path / "roster.db") + await roster.open() + try: + await roster.set_root_ejected("g1", "Films", True, set_by="op", auto=True) + await roster.set_root_ejected("g1", "Music", True, set_by="op") + assert await roster.ejected_roots("g1") == {"films", "music"} + assert await roster.auto_ejected_roots("g1") == {"films"} + + # An operator eject of the same root replaces the safety net's. + await roster.set_root_ejected("g1", "Films", True, set_by="op") + assert await roster.auto_ejected_roots("g1") == set() + finally: + await roster.close() |