mirror of
https://github.com/ClassicWasTaken/HaloCE-SteamFrame.git
synced 2026-10-15 20:00:29 +02:00
172 lines
7.6 KiB
Python
172 lines
7.6 KiB
Python
#!/usr/bin/env python3
|
|
"""Copy installed dependency notices and source metadata into release resources.
|
|
|
|
Run with the same Python environment that freezes the installer. No local paths,
|
|
environment values, credentials, game files or game binaries are exported.
|
|
Only --download-sources enables the primary-source network fetches below.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import importlib.metadata as metadata
|
|
import json
|
|
import platform
|
|
import re
|
|
import sys
|
|
from pathlib import Path, PurePosixPath
|
|
from urllib.parse import urlparse
|
|
from urllib.request import Request, urlopen
|
|
|
|
|
|
def digest(data: bytes) -> str:
|
|
return hashlib.sha256(data).hexdigest()
|
|
|
|
|
|
def slug(name: str) -> str:
|
|
value = re.sub(r"[^a-zA-Z0-9_.-]", "-", name)
|
|
if value in {"", ".", ".."}:
|
|
raise ValueError("Invalid distribution name")
|
|
return value
|
|
|
|
|
|
def notice_name(path: PurePosixPath) -> bool:
|
|
name = path.name.lower()
|
|
return name.startswith(("license", "copying", "copyright", "notice", "authors")) and path.suffix.lower() in {"", ".txt", ".md", ".terms", ".apache", ".bsd"}
|
|
|
|
|
|
def write_notice(output: Path, relative: str, data: bytes, source: str | None = None) -> dict:
|
|
path = PurePosixPath(relative)
|
|
if path.is_absolute() or ".." in path.parts:
|
|
raise ValueError("Unsafe notice destination")
|
|
target = output.joinpath(*path.parts)
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
target.write_bytes(data)
|
|
result = {"path": path.as_posix(), "sha256": digest(data), "bytes": len(data)}
|
|
if source is not None:
|
|
result["source"] = source
|
|
return result
|
|
|
|
|
|
def get_https(url: str, limit: int = 32 * 1024 * 1024) -> bytes:
|
|
allowed = {"pypi.org", "files.pythonhosted.org", "raw.githubusercontent.com"}
|
|
parsed = urlparse(url)
|
|
if parsed.scheme != "https" or parsed.hostname not in allowed or parsed.username or parsed.password:
|
|
raise ValueError("Unsupported notice/source URL")
|
|
request = Request(url, headers={"User-Agent": "halo-steam-frame-installer-notices/1"})
|
|
with urlopen(request, timeout=30) as response:
|
|
final = urlparse(response.url)
|
|
if final.scheme != "https" or final.hostname not in allowed:
|
|
raise ValueError("Unsupported source redirect")
|
|
data = response.read(limit + 1)
|
|
if len(data) > limit:
|
|
raise ValueError("Source archive exceeds limit")
|
|
return data
|
|
|
|
|
|
def installed_notices(output: Path) -> list[dict]:
|
|
result = []
|
|
for distribution in sorted(metadata.distributions(), key=lambda d: d.metadata["Name"].lower()):
|
|
name = distribution.metadata["Name"]
|
|
entry = {
|
|
"name": name,
|
|
"version": distribution.version,
|
|
"license_expression": distribution.metadata.get("License-Expression"),
|
|
"declared_license": distribution.metadata.get("License"),
|
|
"project_urls": distribution.metadata.get_all("Project-URL") or [],
|
|
"notices": [],
|
|
}
|
|
for record in sorted(distribution.files or [], key=str):
|
|
path = PurePosixPath(str(record).replace("\\", "/"))
|
|
if ".." in path.parts or not notice_name(path):
|
|
continue
|
|
source = Path(distribution.locate_file(record))
|
|
if source.is_file():
|
|
entry["notices"].append(write_notice(output, f"packages/{slug(name)}/{path.as_posix()}", source.read_bytes()))
|
|
if entry["notices"]:
|
|
result.append(entry)
|
|
elif name.lower() in {"paramiko", "cryptography", "bcrypt", "pynacl", "pyinstaller", "cffi"}:
|
|
raise RuntimeError(f"Installed {name} has no original license notice")
|
|
return result
|
|
|
|
|
|
def python_notices(output: Path) -> list[dict]:
|
|
base = Path(sys.base_prefix)
|
|
license_file = base / "LICENSE.txt"
|
|
if not license_file.is_file():
|
|
license_file = base / "LICENSE"
|
|
if not license_file.is_file():
|
|
raise RuntimeError("Python runtime LICENSE is missing; supply a licensed Python distribution")
|
|
notices = [write_notice(output, "runtime/Python-LICENSE.txt", license_file.read_bytes())]
|
|
tcl_root = base / "tcl"
|
|
if tcl_root.is_dir():
|
|
for candidate in sorted(tcl_root.rglob("license.terms")):
|
|
if "demos" not in candidate.relative_to(tcl_root).parts:
|
|
notices.append(write_notice(output, "runtime/tcl-tk/" + candidate.relative_to(tcl_root).as_posix(), candidate.read_bytes()))
|
|
return notices
|
|
|
|
|
|
def download_sources(output: Path, entries: list[dict]) -> list[dict]:
|
|
paramiko = next((p for p in entries if p["name"].lower() == "paramiko"), None)
|
|
if not paramiko:
|
|
raise RuntimeError("Paramiko is not installed in the build environment")
|
|
api = f"https://pypi.org/pypi/paramiko/{paramiko['version']}/json"
|
|
info = json.loads(get_https(api, 4 * 1024 * 1024))
|
|
files = [p for p in info["urls"] if p["packagetype"] == "sdist"]
|
|
if len(files) != 1:
|
|
raise RuntimeError("Expected one official Paramiko source archive")
|
|
archive = files[0]
|
|
filename = archive["filename"]
|
|
if PurePosixPath(filename).name != filename or not filename.endswith(".tar.gz"):
|
|
raise ValueError("Unsafe Paramiko archive filename")
|
|
data = get_https(archive["url"])
|
|
if digest(data) != archive["digests"]["sha256"]:
|
|
raise RuntimeError("Paramiko source checksum mismatch")
|
|
notices = [write_notice(output, "sources/" + filename, data, archive["url"])]
|
|
notices[0]["metadata_source"] = api
|
|
# cryptography statically links its own OpenSSL; Python ssl may use another.
|
|
import ssl
|
|
from cryptography.hazmat.backends.openssl.backend import backend
|
|
|
|
versions = {ssl.OPENSSL_VERSION, backend.openssl_version_text()}
|
|
for description in sorted(versions):
|
|
match = re.match(r"OpenSSL (\d+\.\d+\.\d+)\b", description)
|
|
if not match:
|
|
raise RuntimeError("Cannot determine bundled OpenSSL version")
|
|
version = match.group(1)
|
|
url = f"https://raw.githubusercontent.com/openssl/openssl/openssl-{version}/LICENSE.txt"
|
|
notices.append(write_notice(output, f"runtime/openssl-{version}-LICENSE.txt", get_https(url, 1024 * 1024), url))
|
|
import tkinter
|
|
|
|
version = str(tkinter.Tcl().call("info", "patchlevel"))
|
|
tag = "core-" + version.replace(".", "-")
|
|
url = f"https://raw.githubusercontent.com/tcltk/tcl/{tag}/license.terms"
|
|
notices.append(write_notice(output, "runtime/tcl-LICENSE.terms", get_https(url, 1024 * 1024), url))
|
|
return notices
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--output", type=Path, default=Path(__file__).resolve().parent.parent / "resources" / "licenses")
|
|
parser.add_argument("--download-sources", action="store_true", help="Include checksum-verified Paramiko source and exact OpenSSL/Tcl license notices")
|
|
args = parser.parse_args()
|
|
output = args.output.resolve()
|
|
output.mkdir(parents=True, exist_ok=True)
|
|
entries = installed_notices(output)
|
|
notices = python_notices(output)
|
|
sources = download_sources(output, entries) if args.download_sources else []
|
|
manifest = {
|
|
"schema": 1,
|
|
"python_version": platform.python_version(),
|
|
"scope": "Installed build-environment notices; includes build/test packages that may not be present in the frozen installer.",
|
|
"packages": entries,
|
|
"runtime_notices": notices,
|
|
"additional_sources_and_notices": sources,
|
|
}
|
|
(output / "manifest.json").write_text(json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
print(f"Collected notices for {len(entries)} packages; Python {manifest['python_version']}; {len(sources)} additional source/notice files.")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|