#!/usr/bin/env python3 """Erzeugt `THIRD_PARTY_LICENSES` im Repo-Root: je ausgeliefertem Fremdpaket Name, Version, Lizenz und den vollständigen Lizenztext. Was einbezogen wird ------------------- * Python: genau die Pakete aus `deploy/constraints.txt` (das sind die Pakete des Produktions-Images), gelesen aus den INSTALLIERTEN Metadaten (`importlib.metadata`: License-Expression / License / Classifier plus die Lizenzdateien in der dist-info). Ein Paket aus den constraints, das in der laufenden Umgebung fehlt oder in einer anderen Version installiert ist, wird laut gemeldet (Exit-Code 2) -- es wird nie still ausgelassen und nie "irgendeine" Version beschrieben. Einzige Ausnahme: Pakete, die auf dieser Plattform gar nicht installiert WUERDEN (z. B. uvloop unter Windows: `uvicorn[standard]` verlangt es nur mit dem Marker `sys_platform != "win32"`). Das erkennt `platform_skip_reason` an den Requires-Dist- Markern der installierten Pakete; solche Pakete gelten als "uebersprungen", nicht als fehlend. Weil die Datei dann nicht vollständig erzeugt werden kann, schreibt/druckt das Skript dort nichts (Exit-Code 2); `--check` prüft nur noch die Paketliste. Bringt ein Wheel eine SBOM mit (`*.dist-info/sboms/*.json`, CycloneDX), werden die darin genannten einkompilierten Bestandteile (Rust-Crates, statisch gelinktes OpenSSL ...) mit Name, Version, Lizenz und Urheber angehängt, samt Lizenztext, wenn die SBOM einen hat (`_python_sbom_texts`, Ausnahmen in `_SBOM_SKIP`). Bibliotheken, die ein Wheel statisch eingebaut hat, ohne ihren Lizenztext beizulegen (uvloop: libuv, cffi: libffi, dulwich: einige Rust-Crates, alle Rust-Wheels: die Rust-Standardbibliothek), stehen von Hand gepflegt in `_PY_EMBEDDED`, die Texte unverändert unter `scripts/embedded_licenses/`. Ein Eintrag gilt nur für die dort genannten Paketversionen: ändert sich eine davon in `deploy/constraints.txt`, meldet das Skript (Exit-Code 2) und ein Test, dass der Eintrag zu prüfen ist (`scripts/check_licenses.py` meldet es ebenfalls). * npm: die Production-Dependencies des Frontends samt allen transitiven Abhängigkeiten (aus `frontend/package-lock.json`, keine devDependencies), dazu alles, was in die Extension-Bundles (`extensions/*/frontend/dist/index.js`) einkompiliert ist oder von ihnen importiert wird, und Build-Werkzeuge, deren Code im Ergebnis landet (`_NPM_BUILD_OUTPUT`, ohne deren Abhängigkeiten). Lizenztexte kommen aus `frontend/node_modules//`: die Lizenzdateien im Paketordner, die in Unterordnern (mitgelieferter Fremdcode, z. B. noVNC `vendor/pako/LICENSE`) und die Lizenzköpfe der Dateien aus `_NPM_FILE_NOTICES` (im verkleinerten Bundle fehlen diese Kommentare). Lizenzdateien, die kein gültiges UTF-8 sind, werden als Latin-1 gelesen (`_read_text`). Die Ausgabe ist deterministisch (nach Ökosystem und Name sortiert, Zeilenenden und Leerraum normalisiert, keine Zeitstempel). Benutzung (Repo-Root) --------------------- python scripts/third_party_licenses.py # schreibt THIRD_PARTY_LICENSES python scripts/third_party_licenses.py --check # prüft, ob die Datei aktuell ist python scripts/third_party_licenses.py --print # nur auf stdout Exit-Code 0 = ok, 1 = Datei veraltet (nur --check), 2 = Umgebung unvollständig (Paket fehlt, falsche Version, node_modules fehlt, Lizenztext nicht gefunden) bzw. Datei wegen plattformfremder Pakete hier nicht erzeugbar. """ from __future__ import annotations import argparse import base64 import binascii import importlib.metadata as importlib_metadata import json import re import sys import textwrap from dataclasses import dataclass, field from pathlib import Path from packaging.markers import default_environment from packaging.requirements import InvalidRequirement, Requirement REPO_ROOT = Path(__file__).resolve().parent.parent CONSTRAINTS = REPO_ROOT / "deploy" / "constraints.txt" FRONTEND = REPO_ROOT / "frontend" LOCKFILE = FRONTEND / "package-lock.json" OUTPUT = REPO_ROOT / "THIRD_PARTY_LICENSES" DOCKERFILE = REPO_ROOT / "deploy" / "Dockerfile" # Pakete ohne Lizenzangabe in den Metadaten: was ihre mitgelieferte Lizenzdatei sagt, geprüft für genau diese Version # (Version, Lizenz, Anfang der Lizenzdatei). _LICENSE_FROM_FILE: dict[tuple[str, str], tuple[str, str, str]] = { ("python", "pyrage"): ("1.4.0", "MIT", "The MIT License"), } # Bei einer Wahl "A OR B" die Option, unter der Nodvard Deck das Paket nutzt. _CHOSEN_OPTION: dict[tuple[str, str], str] = { ("python", "asyncssh"): "EPL-2.0", ("python", "cryptography"): "Apache-2.0", ("python", "dulwich"): "Apache-2.0", ("python", "packaging"): "Apache-2.0", ("python", "structlog"): "MIT", } # Lizenzdateien im Paketverzeichnis (npm) bzw. in der dist-info (Python). _LICENSE_FILE_RE = re.compile(r"^(licen[cs]e|licen[cs]es|copying|copyright|notice|unlicense)([.\-_].*)?$", re.IGNORECASE) # Länger als das ist es keine Lizenzangabe, sondern ein eingebetteter Lizenztext. _MAX_LICENSE_FIELD = 120 # Plattformabhängige Unterordner in Wheels (pypdfium2: licenses/data/linux_x64/...): # für die Beschriftung vereinheitlichen, damit die Ausgabe auf x86_64 und arm64 gleich ist. _PLATFORM_DIR_RE = re.compile(r"(^|/)data/[^/]+/") @dataclass class Package: ecosystem: str # "python" | "npm" name: str version: str license: str # so, wie das Paket es selbst angibt license_source: str # woher die Angabe stammt (nur für Fehlermeldungen) texts: list[tuple[str, str]] = field(default_factory=list) # (Beschriftung, Text) source_url: str = "" download_url: str = "" # genau diese Version im Paketregister (npm: "resolved" aus dem Lockfile) note: str = "" # warum das Paket hier steht, wenn das nicht offensichtlich ist (Build-Werkzeug) shown_license: str = "" # Angabe für die Datei, wenn sie mehr sagt als `license` (gewählte Option, Lizenzdatei) problems: list[str] = field(default_factory=list) # Umgebung passt nicht (fehlt, andere Version) text_problems: list[str] = field(default_factory=list) # Lizenztext nicht auffindbar skipped: str = "" # Grund, warum das Paket auf dieser Plattform nicht installiert wird (dann kein Problem) # --------------------------------------------------------------------------- # Gemeinsame Helfer # --------------------------------------------------------------------------- def normalize_text(text: str) -> str: """Zeilenenden vereinheitlichen, Leerraum am Zeilenende und um den Text entfernen -- sonst wäre die Datei je nach Checkout (CRLF) und Paketstand verschieden.""" lines = [line.rstrip() for line in text.replace("\r\n", "\n").replace("\r", "\n").split("\n")] return "\n".join(lines).strip("\n") def decode_text(raw: bytes) -> str: """UTF-8, sonst Latin-1. Ältere Lizenzdateien sind oft Latin-1 (pypdfium2: FreeType-Zeile "copyright \\xa9 "); mit `errors="replace"` stünde dort ein Ersatzzeichen statt des (c)-Zeichens. Latin-1 kann jedes Byte lesen, scheitert also nie.""" try: return raw.decode("utf-8") except UnicodeDecodeError: return raw.decode("latin-1") def _read_text(path: Path) -> str: return normalize_text(decode_text(path.read_bytes())) def _dedupe(files: list[tuple[str, str]]) -> list[tuple[str, str]]: """Gleiche Texte (z. B. dieselbe Lizenz je Plattformordner) nur einmal.""" seen: set[str] = set() out = [] for label, text in sorted(files, key=lambda item: (item[0].lower(), item[0])): if text and text not in seen: seen.add(text) out.append((label, text)) return out def pep503(name: str) -> str: return re.sub(r"[-_.]+", "-", name).lower() # --------------------------------------------------------------------------- # Python # --------------------------------------------------------------------------- def read_constraints(path: Path = CONSTRAINTS) -> list[tuple[str, str]]: """[(Name wie in der Datei geschrieben, Version)] -- Kommentare/Leerzeilen weg.""" pins = [] for raw in path.read_text(encoding="utf-8").splitlines(): line = raw.split("#", 1)[0].strip() if not line: continue if "==" not in line: raise ValueError(f"{path.name}: '{line}' ist nicht auf eine feste Version gepinnt (name==version).") name, version = line.split("==", 1) pins.append((name.strip(), version.strip())) return sorted(pins, key=lambda pin: pep503(pin[0])) def python_declared_license(meta) -> tuple[str, str]: """(Lizenzangabe, Quelle). Reihenfolge: License-Expression (SPDX), dann das kurze License-Feld, dann die Trove-Classifier (mehrere gelten als UND: alle müssen erlaubt sein).""" expression = (meta.get("License-Expression") or "").strip() if expression: return expression, "License-Expression" legacy = (meta.get("License") or "").strip() if legacy and "\n" not in legacy and len(legacy) <= _MAX_LICENSE_FIELD and legacy.upper() != "UNKNOWN": return legacy, "License" classifiers = [] for classifier in meta.get_all("Classifier") or []: if classifier.startswith("License ::"): last = classifier.split("::")[-1].strip() if last != "OSI Approved": # Sammelklasse ohne Aussage classifiers.append(last) if classifiers: return " AND ".join(classifiers), "Classifier" return "", "keine Angabe" def _python_license_files(dist) -> list[tuple[str, str]]: files = [] seen_paths: set[str] = set() for entry in dist.files or []: parts = entry.parts dist_info_index = next((i for i, part in enumerate(parts) if part.endswith(".dist-info")), None) if dist_info_index is None: continue inside = parts[dist_info_index + 1 :] if not inside: continue in_licenses_dir = inside[0] in ("licenses", "license", "LICENSES") top_level_match = len(inside) == 1 and _LICENSE_FILE_RE.match(inside[0]) if not (in_licenses_dir or top_level_match): continue full = Path(dist.locate_file(entry)) if not full.is_file() or str(full) in seen_paths: continue seen_paths.add(str(full)) label = "/".join(inside[1:] if in_licenses_dir and len(inside) > 1 else inside) label = _PLATFORM_DIR_RE.sub(r"\1data//", label) files.append((label, _read_text(full))) return _dedupe(files) # SBOMs, die NICHT beschreiben, was im Wheel steckt -- mit Begründung, Schlüssel: Paketname nach PEP 503. _SBOM_SKIP: dict[str, str] = { "pillow": ( "Pillows eigene SBOM beschreibt die Quell-Optionen, auch nicht gebuendelte (libimagequant, FriBiDi), " "die von auditwheel nur eine der gebuendelten Bibliotheken. Die Lizenztexte aller im Wheel gebuendelten " "Bibliotheken stehen schon in Pillows LICENSE (dist-info/licenses/LICENSE)." ), } # Fehlt einer Komponente in der SBOM die Lizenzangabe, aber sie ist eindeutig bekannt. # (Name klein, ab welcher Hauptversion) -> (Lizenz, Begründung, die mit ausgegeben wird) _SBOM_KNOWN_LICENSES: dict[str, tuple[int, str, str]] = { "openssl": (3, "Apache-2.0", "OpenSSL ab Version 3.0; die SBOM nennt keine Lizenz"), } _SBOM_NOTE = ( "Diese Bestandteile sind laut der SBOM, die das Wheel mitbringt (CycloneDX), in das Wheel\n" "einkompiliert (bei Rust-Erweiterungen die Crates; die Liste kann auch Crates enthalten, die nur\n" "beim Bauen oder nur für andere Betriebssysteme gebraucht werden). Name, Version, Lizenz und\n" "Urheber stammen aus ihr.\n" "Die vollständigen Texte der genannten Standardlizenzen stehen unter https://spdx.org/licenses/\n" "(z. B. https://spdx.org/licenses/MIT.html); bei \"A OR B\" gilt eine der Lizenzen nach Wahl." ) def _version_major(version: str) -> int: match = re.match(r"\d+", version or "") return int(match.group(0)) if match else -1 def _sbom_license(component: dict) -> tuple[str, list[str]]: """(Lizenzangabe als ein Ausdruck, [Lizenztexte aus der SBOM]). Mehrere Einträge gelten gemeinsam (UND), so meint es CycloneDX (Pillow: libjpeg-turbo = IJG und BSD-3-Clause).""" parts: list[str] = [] texts: list[str] = [] for entry in component.get("licenses") or []: if not isinstance(entry, dict): continue if entry.get("expression"): parts.append(str(entry["expression"]).strip()) continue lic = entry.get("license") or {} name = str(lic.get("id") or lic.get("name") or "").strip() text = lic.get("text") or {} content = text.get("content") if isinstance(text, dict) else None if content: raw = content.encode("utf-8") if isinstance(text, dict) and str(text.get("encoding", "")).lower() == "base64": try: raw = base64.b64decode(content, validate=True) except (binascii.Error, ValueError) as exc: # Nicht still den Base64-Text als "Lizenztext" ausgeben: laut melden (_python_sbom_texts). raise ValueError(f"Lizenztext von {component.get('name', '?')} ist kein gültiges Base64") from exc texts.append(normalize_text(decode_text(raw))) if not name or name.lower() == "unknown": name = "Lizenztext siehe unten" if name: parts.append(name) if len(parts) > 1: parts = [f"({part})" if re.search(r"\s(OR|AND)\s", part) else part for part in parts] return " AND ".join(parts), texts def _sbom_author(component: dict) -> str: """Urheber laut SBOM, ohne E-Mail-Adressen (die braucht ein Lizenzhinweis nicht).""" author = component.get("author") or "" if not author and isinstance(component.get("authors"), list): author = ", ".join(str(a.get("name", "")) for a in component["authors"] if isinstance(a, dict)) author = re.sub(r"\s*<[^>]*>", "", str(author)) return re.sub(r"\s+", " ", author).strip(" ,") def _sbom_distribution_url(component: dict) -> str: for ref in component.get("externalReferences") or []: if isinstance(ref, dict) and ref.get("type") == "distribution" and ref.get("url"): return str(ref["url"]) return "" def sbom_texts(sbom: dict, label: str) -> list[tuple[str, str]]: """Ein SBOM-Dokument als Textblöcke: die Liste der Bestandteile, danach je Bestandteil mit eigenem Lizenztext in der SBOM dieser Text. Deterministisch (sortiert, ohne Zeitstempel, Build-Pfade und Architektur -- die stehen nur in Feldern, die hier nicht ausgegeben werden).""" rows: dict[tuple[str, str], tuple[str, str]] = {} extra: dict[tuple[str, str], list[str]] = {} for component in sbom.get("components") or []: if not isinstance(component, dict) or not component.get("name"): continue name, version = str(component["name"]).strip(), str(component.get("version") or "").strip() license_, texts = _sbom_license(component) if not license_: known = _SBOM_KNOWN_LICENSES.get(name.lower()) if known and _version_major(version) >= known[0]: license_ = f"{known[1]} ({known[2]})" else: license_ = "(keine Angabe in der SBOM)" details = [] author = _sbom_author(component) if author: details.append(f"Urheber: {author}") url = _sbom_distribution_url(component) if url: details.append(f"Quelle: {url}") rows[(name, version)] = (license_, "; ".join(details)) if texts: extra[(name, version)] = texts if not rows: return [] keys = sorted(rows, key=lambda key: (key[0].lower(), key[0], key[1])) lines = [_SBOM_NOTE, ""] for name, version in keys: license_, details = rows[(name, version)] line = f" {(name + ' ' + version).strip():<34} {license_}" lines.append(f"{line} ({details})" if details else line) count = "1 einkompilierter Bestandteil" if len(keys) == 1 else f"{len(keys)} einkompilierte Bestandteile" out = [(f"{label}: {count}", "\n".join(lines))] for key in keys: for text in extra.get(key, []): out.append((f"Lizenztext aus {label}: {key[0]} {key[1]}".rstrip(), text)) return out def _python_sbom_texts(dist, package_name: str) -> tuple[list[tuple[str, str]], list[str]]: """(Textblöcke, Probleme) für alle SBOMs in der dist-info des Pakets.""" if pep503(package_name) in _SBOM_SKIP: return [], [] found: list[tuple[str, Path]] = [] for entry in dist.files or []: parts = entry.parts dist_info_index = next((i for i, part in enumerate(parts) if part.endswith(".dist-info")), None) if dist_info_index is None: continue inside = parts[dist_info_index + 1 :] if len(inside) < 2 or inside[0] != "sboms" or not inside[-1].lower().endswith(".json"): continue full = Path(dist.locate_file(entry)) if full.is_file(): found.append(("dist-info/" + "/".join(inside), full)) texts: list[tuple[str, str]] = [] problems: list[str] = [] for label, full in sorted(found): try: sbom = json.loads(decode_text(full.read_bytes())) except (OSError, ValueError) as exc: problems.append(f"SBOM {label} nicht lesbar: {exc}") continue if not isinstance(sbom, dict) or sbom.get("bomFormat") != "CycloneDX": problems.append(f"SBOM {label} ist kein CycloneDX-Dokument") continue try: texts += sbom_texts(sbom, label) except ValueError as exc: problems.append(f"SBOM {label}: {exc}") return texts, problems # --------------------------------------------------------------------------- # Fest eingebaute Bibliotheken ohne mitgelieferten Lizenztext (von Hand gepflegt) # --------------------------------------------------------------------------- EMBEDDED_DIR = Path(__file__).resolve().parent / "embedded_licenses" @dataclass(frozen=True) class EmbeddedLibrary: """Eine Bibliothek (C-Bibliothek oder Rust-Crate), die ein Wheel fest eingebaut hat, ohne ihren Lizenztext mitzuliefern.""" package: str # Python-Paket, in dem sie steckt (Name nach PEP 503); dort stehen auch die Lizenztexte package_version: str # NUR für diese Paketversion geprüft; muss zu deploy/constraints.txt passen name: str version: str # leer, wenn sie keine feste Version hat (Rust-Standardbibliothek: kommt mit dem Compiler) license: str # SPDX-Ausdruck für alles, was davon im Binary landet holders: str # Urheber laut Quelltext source_url: str # Quelltext genau dieser Version evidence: str # woran man erkennt, dass und in welcher Version sie eingebaut ist texts: tuple[tuple[str, str], ...] # (Beschriftung, Dateiname unter scripts/embedded_licenses/) # Weitere Pakete (Name nach PEP 503, geprüft für diese Version), in denen dieselbe Bibliothek steckt. # Ihre Texte stehen nur einmal, bei `package`; bei den anderen Paketen steht ein Verweis. also_in: tuple[tuple[str, str], ...] = () note: str = "" # zusätzlicher Hinweis für den Leser (optional) @property def title(self) -> str: return f"{self.name} {self.version}".strip() @property def pins(self) -> tuple[tuple[str, str], ...]: """Alle Pakete samt Version, für die der Eintrag gilt (zuerst das Paket mit den Texten).""" return ((self.package, self.package_version),) + self.also_in # Wie ein Eintrag entsteht: Quelltext (sdist) des Pakets von PyPI holen, dort die Lizenzdateien der # Bibliothek lesen und mit `nm`/`readelf` bzw. `strings` am Wheel prüfen, was davon im Binary steht # (bei Rust-Erweiterungen stehen die Crates mit Name und Version in den Pfaden, z. B. `similar-3.2.0/src/`). # Die Texte liegen UNVERAENDERT im Repo (das Skript läuft offline). Neue Paketversion: prüfen, ob # dieselbe Bibliothek in derselben Version drinsteckt; wenn ja, nur `package_version` anheben, sonst # Eintrag und Texte erneuern. Ein Eintrag für mehrere Pakete (`also_in`) gilt nur, solange JEDE der # genannten Paketversionen zu deploy/constraints.txt passt. # # Nicht eingetragen, weil nichts fehlt: PyYAML (libyaml: gleiche Urheber und MIT-Text wie PyYAML selbst) # und argon2-cffi-bindings (libargon2 unter "CC0-1.0 OR Apache-2.0"; mit der CC0-Wahl ist kein Hinweis nötig). # Nicht eingetragen, weil nicht im Binary: memchr, bstr, portable-atomic und libc stehen zwar in der # Cargo.lock des dulwich-Quelltexts, tauchen aber nicht als Crate in den Erweiterungen auf (die # memchr-Routinen dort stammen aus der Rust-Standardbibliothek). _PY_EMBEDDED: tuple[EmbeddedLibrary, ...] = ( EmbeddedLibrary( package="cffi", package_version="2.1.1", name="libffi", version="3.4.6", license="MIT", holders=( "Anthony Green; Red Hat, Inc.; Free Software Foundation, Inc.; Plausible Labs Cooperative, Inc.; " "Oracle and/or its affiliates; Madhavan T. Venkataraman; Microsoft, Inc.; ARM Ltd.; " "The Written Word, Inc.; Bo Thorsen (laut den Dateiköpfen der Quelltexte)" ), source_url="https://github.com/libffi/libffi/tree/v3.4.6", evidence=( "Die Linux-Wheels von cffi enthalten libffi statisch: Die CI von cffi (Workflow ci.yaml am Tag v2.1.1) " "baut vor dem Wheel libffi 3.4.6 aus dem Quelltext und benennt dabei ffi_call in cffistatic_ffi_call um; " "dieses Symbol steht im Binary (x86_64 und aarch64). Die Dateiköpfe der eingebauten Quelldateien tragen " "denselben MIT-Text wie die LICENSE, die für die Urheber auf die Dateiköpfe verweist. Der in closures.c " "eingebundene Speicherverwalter dlmalloc (Doug Lea) ist vom Autor gemeinfrei gestellt (Public-Domain-Erklärung " "nach creativecommons.org/licenses/publicdomain laut Dateikopf, nicht CC0)." ), texts=(("LICENSE", "libffi-3.4.6-LICENSE.txt"),), ), EmbeddedLibrary( package="uvloop", package_version="0.22.1", name="libuv", version="1.48.0", license="MIT AND BSD-2-Clause AND ISC", holders=( "libuv project contributors; Joyent, Inc. and other Node contributors; Ben Noordhuis (queue.h, " "heap-inl.h); Internet Systems Consortium, Inc. und Internet Software Consortium (inet.c); " "Niels Provos (tree.h)" ), source_url="https://github.com/libuv/libuv/tree/v1.48.0", evidence=( "uvloop bringt libuv als vendor/libuv im Quelltext mit und linkt es statisch; uv_version_string() des " "Wheels meldet 1.48.0. Die meisten Dateien stehen unter dem MIT-Text von libuv bzw. von Joyent. Dateien " "mit eigener Lizenz, die im Binary stehen: include/uv/tree.h (BSD-2-Clause, Niels Provos; die " "Signalverwaltung in src/unix/signal.c und die Dateiüberwachung per inotify in src/unix/linux.c nutzen sie), src/inet.c (ISC, uv_inet_pton und uv_inet_ntop) " "sowie src/idna.c, src/queue.h und src/heap-inl.h (ISC-Text; LICENSE-extra nennt diese drei nicht, " "ihre Dateiköpfe schon)." ), texts=( ("LICENSE (MIT)", "libuv-1.48.0-LICENSE.txt"), ("LICENSE-extra (MIT-Teile aus dem Joyent-Projekt, Liste fremder Dateien)", "libuv-1.48.0-LICENSE-extra.txt"), ("Lizenzkopf aus include/uv/tree.h (BSD-2-Clause)", "libuv-1.48.0-tree.h.txt"), ("Lizenzkopf aus src/inet.c (ISC)", "libuv-1.48.0-inet.c.txt"), ("Lizenzkopf aus src/queue.h und src/heap-inl.h (ISC-Text)", "libuv-1.48.0-queue.h.txt"), ("Lizenzkopf aus src/idna.c und src/idna.h (ISC-Text)", "libuv-1.48.0-idna.c.txt"), ), ), EmbeddedLibrary( package="dulwich", package_version="1.2.19", name="pyo3 und pyo3-ffi (Rust-Crates)", version="0.30.1", license="MIT OR Apache-2.0", holders="PyO3 Project and Contributors", source_url="https://crates.io/crates/pyo3/0.30.1", evidence=( "Die drei Rust-Erweiterungen von dulwich (_objects, _pack, _diff_tree) bringen keine SBOM mit. In allen " "dreien stehen die Crate-Pfade pyo3-0.30.1 und pyo3-ffi-0.30.1 (x86_64 und aarch64). Die Lizenzdateien " "beider Crates sind gleich; ausgegeben ist der MIT-Text (von 'MIT OR Apache-2.0' gilt eine nach Wahl)." ), texts=(("LICENSE-MIT", "pyo3-0.30.1-LICENSE-MIT.txt"),), ), EmbeddedLibrary( package="dulwich", package_version="1.2.19", name="once_cell (Rust-Crate)", version="1.21.4", license="MIT OR Apache-2.0", holders="Aleksey Kladov (laut Cargo.toml; die Lizenzdatei nennt keinen Urheber)", source_url="https://crates.io/crates/once_cell/1.21.4", evidence=( "Der Crate-Pfad once_cell-1.21.4 steht in allen drei Rust-Erweiterungen von dulwich (x86_64 und " "aarch64). Ausgegeben ist der MIT-Text (von 'MIT OR Apache-2.0' gilt eine nach Wahl)." ), texts=(("LICENSE-MIT", "once_cell-1.21.4-LICENSE-MIT.txt"),), ), EmbeddedLibrary( package="dulwich", package_version="1.2.19", name="similar (Rust-Crate)", version="3.2.0", license="Apache-2.0", holders="Armin Ronacher; Pierre-Étienne Meunier; Brandon Williams (laut Cargo.toml; die Lizenzdatei nennt keinen Urheber)", source_url="https://crates.io/crates/similar/3.2.0", evidence=( "Der Crate-Pfad similar-3.2.0 steht in der Rust-Erweiterung _pack von dulwich (x86_64 und aarch64). " "memchr und bstr, die in der Cargo.lock von dulwich stehen, sind nicht als Crate im Binary; die " "memchr-Routinen dort stammen aus der Rust-Standardbibliothek." ), texts=(("LICENSE", "similar-3.2.0-LICENSE.txt"),), ), EmbeddedLibrary( package="cryptography", package_version="50.0.1", also_in=(("dulwich", "1.2.19"), ("pydantic-core", "2.46.5"), ("pyrage", "1.4.0"), ("watchfiles", "1.3.0")), name="Rust-Standardbibliothek (std, core, alloc)", version="", license="MIT OR Apache-2.0", holders=( "The Rust Project Developers (heute: The Rust Project Contributors); in der Standardbibliothek stecken " "außerdem addr2line (The gimli Developers), gimli (The Rust Project Developers), object (The Gimli " "Developers), miniz_oxide (RAD Game Tools and Valve Software; Rich Geldreich and Tenacious Software LLC; " "Frommi; oyvindln), rustc-demangle (Alex Crichton) und hashbrown (Amanieu d'Antras)" ), source_url="https://github.com/rust-lang/rust/tree/master/library", evidence=( "Alle Rust-Erweiterungen dieser Pakete linken die Standardbibliothek statisch ein und nennen sie nicht in " "ihrer SBOM. Im Binary stehen die Pfade der mitgelieferten Crates (/rust/deps/addr2line-0.25.1, " "gimli-0.32.3, miniz_oxide-0.8.9, rustc-demangle-0.1.27 und hashbrown 0.16.1 bzw. 0.17.1) sowie " "Verweise auf den rustc-Quelltext; geprüft an den Wheels für x86_64 und aarch64. Den ELF-Teil der " "Fehlerausgabe (Backtrace) der Standardbibliothek liest die Crate object 0.37.3; ihr Pfad steht nicht " "im Binary, aber ihre Funktionen (z. B. object::read::read_ref::ReadRef) in cryptography, dulwich und " "watchfiles; die Binaries von pydantic-core und pyrage sind ohne Symbole, enthalten aber dieselben " "Crates der Fehlerausgabe (addr2line, gimli). Die Version 0.37.3 steht in der Cargo.lock der " "Standardbibliothek bei allen drei Compiler-Ständen, mit denen die übrigen Wheels gebaut sind " "(Rust 1.96.0, 1.98.0 und 1.98.1). dulwich ist mit Rust 1.99.1 gebaut; dort stehen addr2line-0.27.1, " "gimli-0.34.0, miniz_oxide-0.9.1, rustc-demangle-0.1.28 und hashbrown-0.17.1 im Binary, object hat laut " "Cargo.lock dieses Compilers die Version 0.39.1. Die Lizenzdateien dieser neueren Versionen sind Wort " "für Wort dieselben." ), note=( "Eine feste Version gibt es nicht: Die Standardbibliothek kommt mit dem Compiler, mit dem das jeweilige " "Wheel gebaut wurde. Die Lizenztexte stammen aus dem Rust-Quelltext (Hauptzweig) und aus den genannten " "Crates (miniz_oxide steht unter 'MIT OR Zlib OR Apache-2.0'); ausgegeben ist jeweils der MIT-Text." ), texts=( ("LICENSE-MIT der Standardbibliothek (rust-lang/rust)", "rust-std-LICENSE-MIT.txt"), ("LICENSE-MIT aus addr2line 0.25.1 (0.27.1 ist gleich)", "addr2line-0.25.1-LICENSE-MIT.txt"), ("LICENSE-MIT aus gimli 0.32.3 (0.34.0 ist gleich)", "gimli-0.32.3-LICENSE-MIT.txt"), ("LICENSE-MIT aus object 0.37.3 (0.39.1 ist gleich)", "object-0.37.3-LICENSE-MIT.txt"), ("LICENSE aus miniz_oxide 0.8.9 (MIT; 0.9.1 ist gleich)", "miniz_oxide-0.8.9-LICENSE.txt"), ("LICENSE-MIT aus rustc-demangle 0.1.27 (0.1.28 ist gleich)", "rustc-demangle-0.1.27-LICENSE-MIT.txt"), ("LICENSE-MIT aus hashbrown 0.17.1 (0.16.1 ist gleich)", "hashbrown-0.17.1-LICENSE-MIT.txt"), ), ), ) def embedded_for(package_name: str, table: tuple[EmbeddedLibrary, ...] | None = None) -> list[EmbeddedLibrary]: """Einträge, deren Texte bei diesem Paket stehen.""" return [lib for lib in (_PY_EMBEDDED if table is None else table) if lib.package == pep503(package_name)] def embedded_shared_for(package_name: str, table: tuple[EmbeddedLibrary, ...] | None = None) -> list[EmbeddedLibrary]: """Einträge, die auch in diesem Paket stecken, deren Texte aber bei einem anderen Paket stehen.""" target = pep503(package_name) return [lib for lib in (_PY_EMBEDDED if table is None else table) if any(pkg == target for pkg, _ in lib.also_in)] def embedded_version_problems(name: str, version: str, table: tuple[EmbeddedLibrary, ...] | None = None) -> list[str]: """Der Eintrag gilt nur für die Paketversionen, für die er geprüft wurde.""" target = pep503(name) return [ f"Eintrag für fest eingebaute Bibliothek prüfen: {lib.title} ist für {pkg} " f"{pkg_version} eingetragen, die constraints verlangen {version}. Steckt in der neuen Version dieselbe " f"Bibliothek in derselben Version, nur die Paketversion in _PY_EMBEDDED (scripts/third_party_licenses.py) " f"anheben; sonst Eintrag und Texte unter scripts/embedded_licenses/ erneuern." for lib in (_PY_EMBEDDED if table is None else table) for pkg, pkg_version in lib.pins if pkg == target and pkg_version != version ] def embedded_table_problems(pins: list[tuple[str, str]], table: tuple[EmbeddedLibrary, ...] | None = None) -> list[str]: """Einträge für Pakete, die gar nicht mehr in den constraints stehen (sonst bliebe die Bibliothek für immer in der Liste).""" pinned = {pep503(pin_name) for pin_name, _ in pins} problems = [] for lib in _PY_EMBEDDED if table is None else table: for pkg, _ in lib.pins: if pkg not in pinned: problems.append( f"Eintrag für fest eingebaute Bibliothek prüfen: {lib.title} ist für {pkg} " f"eingetragen, das Paket steht nicht mehr in den constraints (Eintrag bzw. dieses Paket aus " f"_PY_EMBEDDED entfernen, samt nicht mehr gebrauchten Texten unter scripts/embedded_licenses/)." ) return problems def embedded_pin_problems(pins: list[tuple[str, str]], table: tuple[EmbeddedLibrary, ...] | None = None) -> list[str]: """Alles, was zwischen `_PY_EMBEDDED` und den Pins nicht mehr stimmt: neue Paketversion oder Paket entfernt.""" problems = embedded_table_problems(pins, table) for name, version in pins: problems += embedded_version_problems(name, version, table) return problems def _embedded_overview(lib: EmbeddedLibrary) -> str: def field(label: str, value: str) -> list[str]: return textwrap.wrap( value, width=100, initial_indent=f" {label:<10}", subsequent_indent=" " * 12, break_on_hyphens=False ) intro = f"{lib.title} ist in {lib.package} {lib.package_version} fest eingebaut (statisch gelinkt)" if lib.also_in: intro += ", ebenso in " + ", ".join(f"{pkg} {pkg_version}" for pkg, pkg_version in lib.also_in) intro += ( ". Das Wheel bringt dafür keinen eigenen Lizenztext mit, deshalb steht er hier (bei den anderen genannten " "Paketen steht nur ein Verweis hierher). Die Texte darunter stammen unverändert aus dem Quelltext." if lib.also_in else ". Das Wheel bringt dafür keinen eigenen Lizenztext mit, deshalb steht er hier. Die Texte darunter " "stammen unverändert aus dem Quelltext genau dieser Version." ) lines = textwrap.wrap(intro, width=100, break_on_hyphens=False) + [""] lines += field("Lizenz:", lib.license) + field("Urheber:", lib.holders) + field("Quelle:", lib.source_url) lines += field("Erkannt:", lib.evidence) if lib.note: lines += field("Hinweis:", lib.note) return "\n".join(lines) def embedded_texts( name: str, table: tuple[EmbeddedLibrary, ...] | None = None, directory: Path = EMBEDDED_DIR ) -> tuple[list[tuple[str, str]], list[str]]: """(Textblöcke, Probleme) der fest eingebauten Bibliotheken von Paket `name`.""" blocks: list[tuple[str, str]] = [] problems: list[str] = [] for lib in embedded_for(name, table): blocks.append((f"Fest eingebaute Bibliothek: {lib.title}", _embedded_overview(lib))) for label, filename in lib.texts: path = directory / filename if not path.is_file(): problems.append(f"{lib.title}: Textdatei {filename} fehlt unter scripts/embedded_licenses/") continue blocks.append((f"Lizenztext zu {lib.title}: {label}", _read_text(path))) for lib in embedded_shared_for(name, table): pointer = ( f"{lib.title} ist auch in {pep503(name)} fest eingebaut (statisch gelinkt). Lizenz ({lib.license}), " f"Urheber und Lizenztexte stehen im Abschnitt des Pakets {lib.package} {lib.package_version} " f"unter \"Fest eingebaute Bibliothek: {lib.title}\"." ) blocks.append((f"Fest eingebaute Bibliothek: {lib.title}", "\n".join(textwrap.wrap(pointer, width=100)))) return blocks, problems def _python_source_url(meta) -> str: for entry in meta.get_all("Project-URL") or []: label, _, url = entry.partition(",") if label.strip().lower() in ("source", "source code", "repository", "homepage", "home"): return url.strip() return (meta.get("Home-page") or "").strip() def platform_skip_reason(name: str, environment: dict[str, str] | None = None) -> str: """Nicht-leerer Text, wenn `name` auf dieser Plattform gar nicht installiert würde. Begründung der Lösung: Eine feste Liste "plattformgebundener Pakete" würde veralten und könnte ein wirklich fehlendes Paket verdecken. Stattdessen wird gefragt, ob irgendein installiertes Paket `name` per `Requires-Dist` verlangt -- und ob JEDE dieser Anforderungen hier zu False ausgewertet wird (Marker wie `sys_platform != "win32"`). Ein Paket, das niemand verlangt oder dessen Marker hier zutrifft, bleibt "fehlt" (Umgebung kaputt). Extras: `uvloop ; extra == "standard" and sys_platform != "win32"` hängt an einem Extra, das wir nicht kennen. Darum wird der Marker für jedes Extra des verlangenden Pakets (und ohne Extra) ausgewertet; trifft er in einem Fall zu, gilt das Paket als verlangt. `environment` überschreibt die Umgebungswerte (Tests: {"sys_platform": "win32"}). """ target = pep503(name) base = {**default_environment(), **(environment or {})} reasons: list[str] = [] for dist in importlib_metadata.distributions(): extras = [""] + list(dist.metadata.get_all("Provides-Extra") or []) for raw in dist.requires or []: try: req = Requirement(raw) except InvalidRequirement: continue if pep503(req.name) != target: continue if req.marker is None: return "" if any(req.marker.evaluate({**base, "extra": extra}) for extra in extras): return "" reasons.append(f"{dist.metadata['Name']}: {req.marker}") if not reasons: return "" return "nicht für diese Plattform: " + "; ".join(sorted(set(reasons))) def collect_python(pins: list[tuple[str, str]], environment: dict[str, str] | None = None) -> list[Package]: packages = [] for name, version in pins: pkg = Package("python", name, version, "", "") pkg.text_problems += embedded_version_problems(name, version) try: dist = importlib_metadata.distribution(name) except importlib_metadata.PackageNotFoundError: pkg.skipped = platform_skip_reason(name, environment) if pkg.skipped: packages.append(pkg) continue pkg.problems.append(f"nicht installiert (constraints verlangen {version})") packages.append(pkg) continue if dist.version != version: pkg.problems.append(f"installiert ist {dist.version}, die constraints verlangen {version}") meta = dist.metadata pkg.license, pkg.license_source = python_declared_license(meta) pkg.source_url = _python_source_url(meta) pkg.texts = _python_license_files(dist) if not pkg.texts: embedded = (meta.get("License") or "").strip() if embedded and len(embedded) > _MAX_LICENSE_FIELD: pkg.texts = [("License (Metadaten)", normalize_text(embedded))] if not pkg.texts: pkg.text_problems.append("keine Lizenzdatei in der dist-info gefunden") sbom_blocks, sbom_problems = _python_sbom_texts(dist, name) pkg.texts += sbom_blocks pkg.text_problems += sbom_problems embedded_blocks, embedded_problems = embedded_texts(name) pkg.texts += embedded_blocks pkg.text_problems += embedded_problems packages.append(pkg) return packages # --------------------------------------------------------------------------- # npm # --------------------------------------------------------------------------- # Manche npm-Pakete nennen ihre Lizenz nur in package.json/README und liefern keine # Lizenzdatei mit (z. B. react-remove-scroll-bar). Für den häufigsten Fall (MIT) wird # dann der Standardtext ergänzt und als solcher gekennzeichnet; für jede andere # Lizenz bleibt es ein Problem, das der Mensch klären muss. _MIT_STANDARD_TEXT = """Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.""" def _standard_text_fallback(license_id: str, manifest: dict) -> list[tuple[str, str]]: if license_id != "MIT": return [] author = manifest.get("author") author = author.get("name") if isinstance(author, dict) else author author = re.sub(r"\s*[<(].*$", "", author or "").strip() holder = f"Urheber laut package.json: {author}" if author else "Urheber nicht angegeben" return [(f"Standardtext, das Paket liefert keine Lizenzdatei ({holder})", _MIT_STANDARD_TEXT)] def load_lock(path: Path = LOCKFILE) -> dict[str, dict]: return json.loads(path.read_text(encoding="utf-8"))["packages"] def _resolve(packages: dict[str, dict], from_path: str, dep: str) -> str | None: """Wie Node: erst /node_modules/, dann jeweils eine Ebene höher.""" base = from_path while True: candidate = f"{base}/node_modules/{dep}" if base else f"node_modules/{dep}" if candidate in packages: return candidate if not base: return None cut = base.rfind("/node_modules/") base = base[:cut] if cut >= 0 else "" def _package_name(lock_path: str) -> str: return lock_path.rsplit("node_modules/", 1)[1] def bundle_npm_roots(repo: Path = REPO_ROOT) -> set[str]: """npm-Pakete, die die Extension-Bundles einkompilieren (esbuild schreibt `// .../node_modules//...` als Quellkommentar) oder als bare Import erwarten (react ... kommt vom Host).""" names: set[str] = set() for bundle in sorted((repo / "extensions").glob("*/frontend/dist/index.js")): source = bundle.read_text(encoding="utf-8", errors="replace") for match in re.finditer(r"^// .*?node_modules/((?:@[^/\s]+/)?[^/\s]+)/", source, re.MULTILINE): names.add(match.group(1)) for match in re.finditer(r"""^(?:import|export)\b[^;'"]*?from\s*["']([^"']+)["']""", source, re.MULTILINE): spec = match.group(1) if spec.startswith((".", "/", "node:")): continue parts = spec.split("/") names.add("/".join(parts[:2]) if spec.startswith("@") else parts[0]) return names def npm_closure(packages: dict[str, dict], root_names: set[str]) -> tuple[list[str], list[str]]: """(Lock-Pfade aller ausgelieferten Pakete sortiert, Probleme).""" problems: list[str] = [] todo: list[str] = [] for name in sorted(root_names): path = _resolve(packages, "", name) if path is None: problems.append(f"{name}: steht nicht in package-lock.json") else: todo.append(path) seen: set[str] = set() while todo: path = todo.pop() if path in seen: continue seen.add(path) entry = packages[path] for kind in ("dependencies", "optionalDependencies", "peerDependencies"): for dep in entry.get(kind, {}) or {}: target = _resolve(packages, path, dep) if target is None: if kind == "dependencies": problems.append(f"{_package_name(path)} -> {dep}: steht nicht in package-lock.json") continue todo.append(target) return sorted(seen, key=lambda p: (_package_name(p).lower(), p)), problems def npm_root_names(repo: Path = REPO_ROOT) -> set[str]: frontend_pkg = json.loads((repo / "frontend" / "package.json").read_text(encoding="utf-8")) return set(frontend_pkg.get("dependencies", {})) | bundle_npm_roots(repo) # Build-Werkzeuge (devDependencies), deren eigener Code im ausgelieferten Frontend landet. Nur das # Paket selbst, NICHT seine Abhängigkeiten (die laufen nur beim Bauen). Wert: was davon im Bundle steckt. _NPM_BUILD_OUTPUT: dict[str, str] = { "tailwindcss": ( "Build-Werkzeug. Im ausgelieferten CSS stecken seine Grundstile (Preflight, `@tailwind base`, " "beruht auf modern-normalize: Lizenz unter lib/css/LICENSE); den Lizenzkommentar, den Tailwind " "dazuschreibt, entfernt das Verkleinern. Seine eigenen Abhängigkeiten werden nicht mitgeliefert." ), } # Dateien mit EIGENEM Lizenzkopf (fremder Code im Paket oder weitere Urheber), die im Bundle landen. # Das verkleinerte Bundle enthält keine Kommentare mehr, deshalb stehen die Köpfe hier. Ermittelt mit # einem Build mit Quellkarten (`npx vite build --sourcemap`, Liste `sources`): genau diese Dateien # werden gebündelt. Fehlt eine Datei nach einem Update, meldet das Skript ein Problem. _NPM_FILE_NOTICES: dict[str, tuple[str, ...]] = { # DES für die VNC-Anmeldung, aus Acme.Crypto/Flashlight-VNC übernommen (BSD-artige Lizenzen). "@novnc/novnc": ("core/crypto/des.js", "core/decoders/tight.js"), # Teile aus jslinux (Fabrice Bellard) und aus VS Code (Microsoft, MIT). "@xterm/xterm": ("lib/xterm.mjs",), "@xterm/addon-fit": ("lib/addon-fit.mjs",), # Liefert keine Lizenzdatei; der Kopf nennt Urheber und Lizenz (und den Markenhinweis "QR Code"). "qrcode-generator": ("dist/qrcode.mjs",), # Enthält einen Ausschnitt aus Modernizr (MIT). "react-dom": ("cjs/react-dom.production.min.js",), } # Diese Endungen sind Code, keine Lizenzdateien (lucide-react hat ein Symbol `icons/copyright.mjs`). _CODE_SUFFIXES = (".js", ".mjs", ".cjs", ".ts", ".mts", ".cts", ".jsx", ".tsx", ".map", ".json", ".css", ".scss") def leading_comments(source: str) -> str: """Die Kommentare am Dateianfang bis zur ersten Leerzeile nach einem fertigen Kommentar oder bis zum ersten Code -- dort steht bei JavaScript-Dateien der Lizenzkopf. Leer, wenn die Datei nicht mit einem Kommentar beginnt.""" out: list[str] = [] in_block = False for line in source.replace("\r\n", "\n").split("\n"): stripped = line.strip() if in_block: out.append(line) in_block = "*/" not in stripped continue if not stripped: if out: break continue if stripped.startswith("//"): out.append(line) elif stripped.startswith("/*"): out.append(line) in_block = "*/" not in stripped[2:] else: break return normalize_text("\n".join(out)) def _npm_nested_license_files(installed_dir: Path) -> list[tuple[str, str]]: """Lizenzdateien in Unterordnern des Pakets (mitgelieferter Fremdcode wie noVNC `vendor/pako/`, weitere Lizenztexte wie noVNC `docs/LICENSE.*`). Verschachtelte node_modules sind eigene Pakete.""" files = [] for child in sorted(installed_dir.rglob("*")): rel = child.relative_to(installed_dir) if len(rel.parts) < 2 or "node_modules" in rel.parts or not child.is_file(): continue if _LICENSE_FILE_RE.match(child.name) and not child.name.lower().endswith(_CODE_SUFFIXES): files.append((rel.as_posix(), _read_text(child))) return _dedupe(files) def _npm_file_notices(name: str, installed_dir: Path) -> tuple[list[tuple[str, str]], list[str]]: texts, problems = [], [] for rel in _NPM_FILE_NOTICES.get(name, ()): path = installed_dir / rel if not path.is_file(): problems.append(f"{rel} fehlt (Liste _NPM_FILE_NOTICES in scripts/third_party_licenses.py prüfen)") continue header = leading_comments(decode_text(path.read_bytes())) if not header: problems.append(f"{rel} beginnt nicht mit einem Lizenzkopf (Liste _NPM_FILE_NOTICES prüfen)") continue texts.append((f"Lizenzkopf aus {rel}", header)) return texts, problems def _npm_license_from_package_json(data: dict) -> str: value = data.get("license") if isinstance(value, dict): value = value.get("type") if not value and isinstance(data.get("licenses"), list): value = " OR ".join(item.get("type", "") if isinstance(item, dict) else str(item) for item in data["licenses"]) return (value or "").strip() def _npm_repo_url(data: dict) -> str: repo = data.get("repository") url = repo.get("url") if isinstance(repo, dict) else repo url = (url or data.get("homepage") or "").strip() return re.sub(r"^git\+", "", url).removesuffix(".git") def collect_npm(repo: Path = REPO_ROOT, node_modules: Path | None = None, with_texts: bool = True) -> list[Package]: """Die ausgelieferten npm-Pakete. Name, Version und Lizenzangabe kommen aus dem Lockfile (umgebungsunabhängig); die Lizenztexte aus node_modules (nur `with_texts`).""" node_modules = node_modules or (repo / "frontend" / "node_modules") lock = load_lock(repo / "frontend" / "package-lock.json") paths, problems = npm_closure(lock, npm_root_names(repo)) for name in sorted(_NPM_BUILD_OUTPUT): path = _resolve(lock, "", name) if path is None: problems.append(f"{name}: steht nicht in package-lock.json (Liste _NPM_BUILD_OUTPUT prüfen)") elif path not in paths: paths.append(path) paths.sort(key=lambda p: (_package_name(p).lower(), p)) packages = [] for problem in problems: packages.append(Package("npm", "(Lockfile)", "", "", "", problems=[problem])) for lock_path in paths: entry = lock[lock_path] name = _package_name(lock_path) pkg = Package("npm", name, entry.get("version", ""), (entry.get("license") or "").strip(), "package-lock.json") pkg.download_url = (entry.get("resolved") or "").strip() if lock_path == f"node_modules/{name}" and name in _NPM_BUILD_OUTPUT: pkg.note = _NPM_BUILD_OUTPUT[name] installed_dir = node_modules / lock_path.removeprefix("node_modules/") manifest_file = installed_dir / "package.json" manifest = json.loads(manifest_file.read_text(encoding="utf-8")) if manifest_file.is_file() else {} if manifest: pkg.source_url = _npm_repo_url(manifest) if not pkg.license: pkg.license, pkg.license_source = _npm_license_from_package_json(manifest), "package.json" if manifest.get("version") and manifest["version"] != pkg.version: pkg.problems.append(f"installiert ist {manifest['version']}, package-lock.json verlangt {pkg.version}") elif with_texts: pkg.problems.append(f"nicht in {node_modules} installiert ({lock_path}); `npm ci` ausführen") if with_texts and installed_dir.is_dir(): files = [ (child.name, _read_text(child)) for child in installed_dir.iterdir() if child.is_file() and _LICENSE_FILE_RE.match(child.name) ] pkg.texts = _dedupe(files) or _standard_text_fallback(pkg.license, manifest) if not pkg.texts: pkg.text_problems.append("keine Lizenzdatei im Paketordner gefunden") notices, notice_problems = _npm_file_notices(name, installed_dir) seen = {text for _, text in pkg.texts} for label, text in _npm_nested_license_files(installed_dir) + notices: if text not in seen: seen.add(text) pkg.texts.append((label, text)) pkg.text_problems += notice_problems if not pkg.license: pkg.license_source = "keine Angabe" packages.append(pkg) return packages # --------------------------------------------------------------------------- # Ausgabe # --------------------------------------------------------------------------- _RULE = "=" * 78 _THIN = "-" * 78 _MPL_NOTE = ( "Diese Komponenten stehen unter der Mozilla Public License 2.0 (Datei-Copyleft). Sie werden\n" "UNVERÄNDERT aus dem Paketregister übernommen (npm: Prüfsumme in frontend/package-lock.json,\n" "Python: Version in deploy/constraints.txt). Den Quelltext gibt es unter den folgenden Adressen\n" "und über das jeweilige Paketregister (npm bzw. PyPI) in genau dieser Version." ) # Je MPL-Komponente: Adresse des Quelltexts GENAU dieser Version (`{version}` wird ersetzt, leer = # keine bekannt) und wo die Dateien im Image stecken. MPL-2.0 Abschnitt 3.2: wer die ausführbare # Form weitergibt (hier: gebündelt und verkleinert), muss sagen, wie man an den Quelltext kommt. _MPL_DETAILS: dict[tuple[str, str], tuple[str, str]] = { ("npm", "@novnc/novnc"): ( "https://github.com/novnc/noVNC/tree/v{version}", ( "Frontend-Bundle /app/frontend/dist/assets/rfb-*.js (die grafische Konsole). Darin stecken die " "Dateien aus core/ und vendor/pako/ des Pakets: unverändert, nur gebündelt und verkleinert." ), ), ("python", "certifi"): ( "", "Python-Paket unter site-packages/certifi, unverändert.", ), } def _is_mpl(pkg: Package) -> bool: return bool(re.search(r"\bMPL\b|MPL-2|Mozilla Public", pkg.license, re.IGNORECASE)) def _wrap(text: str, indent: str, width: int = 100) -> list[str]: return textwrap.wrap(text, width=width, initial_indent=indent, subsequent_indent=indent, break_on_hyphens=False) def mpl_lines(pkg: Package) -> list[str]: """Hinweiszeilen zu einer MPL-Komponente: Projekt, Quelltext und Paket genau dieser Version, Ort im Image.""" lines = [f" {pkg.name} {pkg.version} ({pkg.ecosystem}): {pkg.source_url or 'Quelle über das Paketregister'}"] tag_url, where = _MPL_DETAILS.get((pkg.ecosystem, pkg.name.lower() if pkg.ecosystem == "npm" else pep503(pkg.name)), ("", "")) if tag_url: lines.append(f" Quelltext genau dieser Version: {tag_url.format(version=pkg.version)}") download = pkg.download_url or (f"https://pypi.org/project/{pkg.name}/{pkg.version}/" if pkg.ecosystem == "python" else "") if download: lines.append(f" Paket genau dieser Version: {download}") if where: lines += _wrap(f"Im Image: {where}", " ") return lines def _key(pkg: Package) -> tuple[str, str]: return pkg.ecosystem, pkg.name.lower() if pkg.ecosystem == "npm" else pep503(pkg.name) def explain_licenses(packages: list[Package]) -> list[str]: """Setzt `shown_license` aus `_LICENSE_FROM_FILE` und `_CHOSEN_OPTION` und gibt zurück, was nicht passt.""" problems = [] for pkg in packages: key = _key(pkg) found = _LICENSE_FROM_FILE.get(key) if found is not None and not pkg.license: version, license_, start = found if version != pkg.version: problems.append(f"{pkg.name}: _LICENSE_FROM_FILE gilt für {version}, installiert ist {pkg.version}") elif not any(text.startswith(start) for _, text in pkg.texts): problems.append(f"{pkg.name}: die Lizenzdatei beginnt nicht mit {start!r}") else: pkg.shown_license = f"{license_} (laut Lizenzdatei, die Metadaten nennen keine)" options = [part.strip() for part in pkg.license.split(" OR ")] if " OR " in pkg.license else [] chosen = _CHOSEN_OPTION.get(key) if options and chosen not in options: problems.append(f"{pkg.name}: {pkg.license!r} ohne gewählte Option in _CHOSEN_OPTION") elif options: pkg.shown_license = f"{pkg.license}, genutzt als {chosen}" present = {_key(p): p for p in packages if not p.skipped} stale = sorted(key[1] for key in _CHOSEN_OPTION if key in present and " OR " not in present[key].license) problems += [f"{name}: in _CHOSEN_OPTION, hat aber keine Wahl (mehr)" for name in stale] return problems def image_system_packages(dockerfile: Path = DOCKERFILE) -> tuple[str, dict[str, list[str]]]: """Basis-Image der letzten Stufe und die Debian-Pakete aus `apt-get install` dort, je Modul ("" für alle Images).""" text = dockerfile.read_text(encoding="utf-8") stages = re.findall(r"^FROM\s+(?:--platform=\S+\s+)?(\S+)", text, re.MULTILINE) final = text[text.rindex("\nFROM "):] found: dict[str, set[str]] = {} # Ein Zweig `*,,*)` installiert nur, wenn das Image mit diesem Modul gebaut wird. for match in re.finditer(r"(?:\*,([\w-]+),\*\)\s*apt-get update\s*&&\s*)?apt-get install([^&;|]*)", final): words = match.group(2).replace("\\", " ").split() found.setdefault(match.group(1) or "", set()).update(word for word in words if not word.startswith("-")) return stages[-1], {module: sorted(names) for module, names in sorted(found.items())} def _system_lines() -> list[str]: base, extra = image_system_packages() parts = [ f"die Debian-Pakete {', '.join(names)}" + (f" (nur in Images mit dem Modul {module})" if module else "") for module, names in extra.items() ] text = ( f"Das Image baut auf {base} auf (Debian mit Python)" + (f", dazu kommen {' und '.join(parts)}" if parts else "") + ". Diese Programme des Betriebssystems sind nicht Teil von Nodvard Deck und stehen unter ihren eigenen " "Lizenzen; den Text zu jedem Paket enthält das Image unter /usr/share/doc//copyright." ) return ["Systempakete im Image", _THIN, *_wrap(text, "", 78), ""] def sorted_packages(packages: list[Package]) -> list[Package]: return sorted(packages, key=lambda p: (p.ecosystem, p.name.lower(), p.version)) def render(python_packages: list[Package], npm_packages: list[Package]) -> str: python_sorted = sorted_packages(python_packages) npm_sorted = sorted_packages(npm_packages) out: list[str] = [] out += [ "THIRD_PARTY_LICENSES", _RULE, "", "Nodvard Deck steht unter der PolyForm Noncommercial License 1.0.0 (siehe LICENSE).", "Diese Datei nennt die Fremdkomponenten, die im Docker-Image bzw. im ausgelieferten", "Frontend stecken, mit Version, Lizenz und vollem Lizenztext. Bei den Paketen", "steht außerdem, was sie selbst mitbringen: einkompilierte Bestandteile laut SBOM", "(z. B. Rust-Crates), fest eingebaute Bibliotheken (libuv, libffi, Rust-Standardbibliothek), mitgelieferter", "Fremdcode und die Lizenzköpfe einzelner Dateien.", "", "Automatisch erzeugt mit `python scripts/third_party_licenses.py` -- nicht von Hand", "ändern. Erlaubt sind nur freizügige Lizenzen; `python scripts/check_licenses.py`", "prüft das bei jedem Testlauf.", "", "Inhalt", _THIN, ] for title, group in (("Python", python_sorted), ("npm", npm_sorted)): out += [f"[{title}] {len(group)} Pakete"] out += [f" {p.name:<34} {p.version:<14} {p.shown_license or p.license or '(keine Angabe)'}" for p in group] out.append("") out += _system_lines() mpl = [p for p in python_sorted + npm_sorted if _is_mpl(p)] if mpl: out += ["Hinweis zu MPL-2.0-Komponenten", _THIN, _MPL_NOTE, ""] for pkg in mpl: out += mpl_lines(pkg) out.append("") for title, group in (("Python-Pakete", python_sorted), ("npm-Pakete", npm_sorted)): out += [_RULE, title, _RULE, ""] for pkg in group: shown = pkg.shown_license or pkg.license or "(keine Angabe)" out += [_THIN, f"{pkg.name} {pkg.version}", f"Lizenz: {shown}", _THIN] if pkg.note: out += _wrap(f"Hinweis: {pkg.note}", "", 90) + [""] for label, text in pkg.texts: if len(pkg.texts) > 1 or label.startswith("Standardtext"): out += [f"[{label}]", ""] out += [text, ""] return "\n".join(out).rstrip("\n") + "\n" def parse_index(text: str) -> dict[str, list[tuple[str, str]]]: """Liest den Inhaltsabschnitt der Datei zurück: {"Python": [(name, version)], "npm": [...]}. Damit prüft der Test, ob die eingecheckte Datei zu constraints/Lockfile passt.""" result: dict[str, list[tuple[str, str]]] = {} current = None for line in text.replace("\r\n", "\n").split("\n"): header = re.match(r"^\[(Python|npm)\] \d+ Pakete$", line) if header: current = header.group(1) result[current] = [] elif current and line.startswith(" "): parts = line.split(None, 2) if len(parts) >= 2: result[current].append((parts[0], parts[1])) elif (current and not line.strip()) or line.startswith(("Hinweis", "=====")): current = None return result def expected_index(repo: Path = REPO_ROOT) -> dict[str, list[tuple[str, str]]]: """Was im Inhaltsabschnitt stehen MUSS -- allein aus constraints.txt und package-lock.json abgeleitet (braucht keine installierten Pakete).""" python = sorted(read_constraints(repo / "deploy" / "constraints.txt"), key=lambda p: p[0].lower()) npm = sorted_packages(collect_npm(repo, with_texts=False)) return {"Python": python, "npm": [(p.name, p.version) for p in npm]} # --------------------------------------------------------------------------- # Kommandozeile # --------------------------------------------------------------------------- def collect_all(repo: Path = REPO_ROOT) -> tuple[list[Package], list[Package]]: return collect_python(read_constraints(repo / "deploy" / "constraints.txt")), collect_npm(repo) def report_problems(packages: list[Package]) -> int: count = 0 for pkg in packages: for problem in pkg.problems + pkg.text_problems: count += 1 print(f"PROBLEM [{pkg.ecosystem}] {pkg.name} {pkg.version}: {problem}", file=sys.stderr) return count def skipped_packages(packages: list[Package]) -> list[Package]: return [pkg for pkg in packages if pkg.skipped] def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description="Erzeugt THIRD_PARTY_LICENSES aus constraints.txt und package-lock.json.") parser.add_argument("--check", action="store_true", help="nur pruefen, ob THIRD_PARTY_LICENSES aktuell ist") parser.add_argument("--print", dest="to_stdout", action="store_true", help="auf stdout statt in die Datei") parser.add_argument("--output", type=Path, default=OUTPUT) args = parser.parse_args(argv) python_packages, npm_packages = collect_all() table_problems = embedded_table_problems(read_constraints()) for problem in table_problems: print(f"PROBLEM [python] _PY_EMBEDDED: {problem}", file=sys.stderr) license_problems = explain_licenses(python_packages + npm_packages) for problem in license_problems: print(f"PROBLEM Lizenzangabe: {problem}", file=sys.stderr) table_problems += license_problems if report_problems(python_packages + npm_packages) + len(table_problems): print( "\nDie Umgebung passt nicht zu deploy/constraints.txt bzw. package-lock.json -- es wurde nichts geschrieben.\n" "Abhilfe: `pip install -c deploy/constraints.txt -e sdk/python -e backend` und\n" "`npm ci` (in frontend/) ausführen, dann erneut starten.", file=sys.stderr, ) return 2 skipped = skipped_packages(python_packages) if skipped: names = ", ".join(f"{pkg.name} {pkg.version}" for pkg in skipped) print(f"Übersprungen (nicht für diese Plattform): {names}") if args.check: # Vollvergleich geht nicht (Lizenztexte der übersprungenen Pakete fehlen); die Paketliste # der Datei wird trotzdem gegen constraints/Lockfile geprüft, den Rest macht Linux/CI. current = args.output.read_text(encoding="utf-8") if args.output.is_file() else "" if parse_index(current) != {k: list(v) for k, v in expected_index().items()}: print(f"{args.output.name} ist veraltet. Neu erzeugen mit: python scripts/third_party_licenses.py (unter Linux)", file=sys.stderr) return 1 print(f"{args.output.name}: Paketliste aktuell (Lizenztexte nur unter Linux/CI vollständig geprüft).") return 0 print( "Die Datei kann hier nicht vollständig erzeugt werden (Texte der übersprungenen Pakete fehlen) -- " "es wurde nichts geschrieben. Bitte unter Linux bzw. im Docker-Build erzeugen.", file=sys.stderr, ) return 2 content = render(python_packages, npm_packages) if args.to_stdout: sys.stdout.write(content) return 0 if args.check: current = args.output.read_text(encoding="utf-8").replace("\r\n", "\n") if args.output.is_file() else "" if current != content: print(f"{args.output.name} ist veraltet. Neu erzeugen mit: python scripts/third_party_licenses.py", file=sys.stderr) return 1 print(f"{args.output.name} ist aktuell.") return 0 args.output.write_text(content, encoding="utf-8", newline="\n") print(f"{args.output} geschrieben ({len(python_packages)} Python-, {len(npm_packages)} npm-Pakete).") return 0 if __name__ == "__main__": sys.exit(main())