mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-10-26 23:33:02 +08:00
155 lines
6.5 KiB
Python
155 lines
6.5 KiB
Python
#!/usr/bin/env python3
|
|
"""Generate iOS notice resources from pinned source archives."""
|
|
|
|
import argparse
|
|
from collections import defaultdict
|
|
from contextlib import contextmanager
|
|
import hashlib
|
|
import json
|
|
from pathlib import Path
|
|
import re
|
|
import sys
|
|
import tarfile
|
|
import urllib.request
|
|
import zipfile
|
|
|
|
IOS = Path(__file__).resolve().parents[1]
|
|
REPO = IOS.parents[1]
|
|
LOCK = IOS / "licenses/sources.json"
|
|
OUTPUT = IOS / "CrossDeskMobile/Resources/ThirdPartyLicenses.json"
|
|
COMMENT = re.compile(rb"/\*.*?\*/|(?://[^\n]*(?:\n|$))+", re.S)
|
|
RIGHTS = re.compile(rb"copyright|SPDX-License|permission is hereby|author disclaims|public domain", re.I)
|
|
|
|
|
|
def digest(data):
|
|
return hashlib.sha256(data).hexdigest()
|
|
|
|
|
|
def json_bytes(value):
|
|
return (json.dumps(value, ensure_ascii=False, indent=2) + "\n").encode()
|
|
|
|
|
|
def checked(data, expected, label):
|
|
if digest(data) != expected:
|
|
raise ValueError(f"Source checksum mismatch: {label}")
|
|
return data
|
|
|
|
|
|
def archive_path(component, cache):
|
|
source = component["source"]
|
|
target = cache / source["sha256"]
|
|
if target.exists():
|
|
checked(target.read_bytes(), source["sha256"], component["id"])
|
|
return target
|
|
cache.mkdir(parents=True, exist_ok=True)
|
|
# Xmake may remove extracted sources or COPYING; read the original archive.
|
|
candidates = (Path.home() / ".xmake/cache/packages").glob(
|
|
f"*/{component['id'][0]}/{component['id']}/{component['version']}/*")
|
|
for candidate in candidates:
|
|
if candidate.is_file() and candidate.name.endswith((".tar.gz", ".tar.xz", ".zip")):
|
|
data = candidate.read_bytes()
|
|
if digest(data) == source["sha256"]:
|
|
target.write_bytes(data)
|
|
return target
|
|
request = urllib.request.Request(source["url"], headers={"User-Agent": "CrossDesk-license-collector"})
|
|
with urllib.request.urlopen(request, timeout=90) as response:
|
|
data = checked(response.read(), source["sha256"], component["id"])
|
|
target.write_bytes(data)
|
|
return target
|
|
|
|
|
|
@contextmanager
|
|
def source_files(component, cache):
|
|
if component["source"]["kind"] == "repository":
|
|
yield {}, lambda name: (REPO / name).read_bytes()
|
|
return
|
|
path = archive_path(component, cache)
|
|
if zipfile.is_zipfile(path):
|
|
with zipfile.ZipFile(path) as archive:
|
|
names = {name.split("/", 1)[1]: name for name in archive.namelist()
|
|
if "/" in name and not name.endswith("/")}
|
|
yield names, lambda name: archive.read(names[name])
|
|
else:
|
|
with tarfile.open(path) as archive:
|
|
names = {member.name.split("/", 1)[1]: member.name for member in archive
|
|
if "/" in member.name and not member.isdir()}
|
|
# No filesystem extraction. tarfile resolves COPYING symlinks within
|
|
# the verified archive, including GLib's LICENSES directory.
|
|
yield names, lambda name: archive.extractfile(names[name]).read()
|
|
|
|
|
|
def component_documents(component, names, read):
|
|
documents = []
|
|
for item in component["documents"]:
|
|
data = read(item["path"])
|
|
text = data.decode(item.get("encoding", "utf-8"))
|
|
if "end_before" in item:
|
|
if item["end_before"] not in text:
|
|
raise ValueError(f"Missing notice boundary: {item['path']}")
|
|
text = text.split(item["end_before"], 1)[0]
|
|
documents.append({"title": item["path"], "text": text})
|
|
notices = defaultdict(list)
|
|
for name in sorted(names):
|
|
path = Path(name)
|
|
selected = any(name.startswith(prefix) for prefix in component.get("notice_prefixes", []))
|
|
selected |= component.get("notice_root_files", False) and "/" not in name
|
|
if not selected or path.suffix not in (".h", ".hpp", ".c", ".cc", ".cpp", ".S", ".s", ".inc"):
|
|
continue
|
|
if any(part.lower() in ("test", "tests", "benchmarks", "examples") for part in path.parts):
|
|
continue
|
|
for block in COMMENT.findall(read(name)[:16384]):
|
|
if RIGHTS.search(block):
|
|
# Preserve complete original comment blocks, including bundled
|
|
# fmt, semaphore and per-file copyright/permission exceptions.
|
|
encoding = component.get("notice_encodings", {}).get(name, "utf-8")
|
|
notices[block.decode(encoding)].append(name)
|
|
if notices:
|
|
documents.append({"title": "Source copyright and license notices", "text": "\n\n".join(
|
|
"Files: " + ", ".join(files) + "\n\n" + text for text, files in notices.items())})
|
|
if not documents:
|
|
raise ValueError(f"No notices collected for {component['id']}")
|
|
return documents
|
|
|
|
|
|
def generate(lock, cache):
|
|
components = []
|
|
for component in lock["components"]:
|
|
with source_files(component, cache) as (names, read):
|
|
documents = component_documents(component, names, read)
|
|
source = component["source"]
|
|
source_url = source["url"]
|
|
if source["kind"] == "repository" and source.get("revision"):
|
|
source_url += "/tree/" + source["revision"]
|
|
entry = {key: component[key] for key in ("id", "name", "version", "license")} | {
|
|
"sourceURL": source_url, "documents": documents}
|
|
recipe = component.get("recipe_path", "")
|
|
if recipe.startswith("deps/submodules/minirtc/"):
|
|
mini = next(c for c in lock["components"] if c["id"] == "minirtc")
|
|
entry["buildSourceURL"] = (mini["source"]["url"] + "/tree/" +
|
|
mini["source"]["revision"] + "/" +
|
|
str(Path(recipe).relative_to("deps/submodules/minirtc").parent))
|
|
components.append(entry)
|
|
print(f"Collected {component['name']} {component['version']}")
|
|
return json_bytes({"schemaVersion": 1, "components": components})
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--generate", action="store_true", required=True,
|
|
help="download pinned sources and regenerate the bundled JSON")
|
|
parser.add_argument("--cache", type=Path, default=Path.home() / ".cache/crossdesk/licenses")
|
|
args = parser.parse_args()
|
|
lock = json.loads(LOCK.read_text())
|
|
data = generate(lock, args.cache)
|
|
OUTPUT.parent.mkdir(parents=True, exist_ok=True)
|
|
OUTPUT.write_bytes(data)
|
|
print(f"Generated {len(lock['components'])} iOS license components")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
main()
|
|
except (OSError, ValueError, KeyError) as error:
|
|
print(f"Third-party notices: {error}", file=sys.stderr)
|
|
sys.exit(1)
|