#!/usr/bin/env python3 import argparse import hashlib import io import json import os from pathlib import Path import re import subprocess import tarfile import time import xml.etree.ElementTree as ET def digest(path): with path.open("rb") as stream: return hashlib.file_digest(stream, "sha256").hexdigest() def git(source, *arguments): return subprocess.check_output(["git", "-C", str(source), *arguments]) def snapshot(source, build): checkout = (source / ".git").exists() if checkout: paths = git(source, "ls-files", "--cached", "--others", "--exclude-standard", "-z").decode().split("\0") else: paths = [path.relative_to(source).as_posix() for path in source.rglob("*") if (path.is_file() or path.is_symlink()) and not path.is_relative_to(build)] files = {} for name in sorted(set(paths) - {""}): path = source / name if path.is_symlink(): files[name] = hashlib.sha256(os.readlink(path).encode()).hexdigest() elif path.is_file(): files[name] = digest(path) else: raise RuntimeError(f"source input is missing: {name}") return dict(revision=git(source, "rev-parse", "HEAD").decode().strip() if checkout else None, dirty=bool(git(source, "status", "--porcelain", "--untracked-files=all")) if checkout else False, files=files, cache=digest(build / "CMakeCache.txt")) def write_record(path, record): text = json.dumps(record, sort_keys=True, indent=2) + "\n" if not path.exists() or path.read_text() != text: path.write_text(text) def runtime_paths(build): native = build / "native" files = [native / "raven", native / "raven-bootstrap"] names = """comctl32.dll raven-user32.dll raven-shell.dll raven-dwrite.dll raven-d2d1.dll raven-uianimation.dll ucrtbase.dll raven-rtl.dll raven-storage.dll msi.dll cabinet.dll sxs.dll rpcrt4.dll libxslt.dll msxml3.dll atl100.dll oleaut32.dll libpng.dll libjpeg.dll libtiff.dll propsys.dll mlang.dll windowscodecs.dll gdiplus.dll urlmon.dll riched20.dll msftedit.dll msvfw32.dll advpack.dll raven-setupapi.dll xmllite.dll cmd.exe control.exe regedit.exe dism.exe fondue.exe msiexec.exe regsvr32.exe find.exe findstr.exe more.com sort.exe where.exe xcopy.exe attrib.exe timeout.exe choice.exe whoami.exe hostname.exe tasklist.exe taskkill.exe reg.exe rundll32.exe mscoree.dll ucrtbase32.dll l_intl.nls""".split() for root in (native, native / "x86"): files += [root / name for name in names if (root / name).exists()] files += sorted((native / "roots").glob("*.crt")) paths = {path.relative_to(native).as_posix(): path for path in files} font_directory = cache_values(build).get("RAVEN_FONT_DIRECTORY") if font_directory and not font_directory.endswith("-NOTFOUND"): fonts = Path(font_directory) for face in ("Sans", "Mono", "Serif"): name = f"Liberation{face}-Regular.ttf" paths["fonts/" + name] = fonts / name paths.update({"fonts/" + path.name: path for path in sorted(fonts.glob("Liberation*.ttf"))}) for path in paths.values(): if path.is_symlink() or not path.is_file(): raise RuntimeError(f"runtime input must be a regular file: {path}") return paths def runtime_files(build): return {name: digest(path) for name, path in runtime_paths(build).items()} def cache_values(build): values = {} for line in (build / "CMakeCache.txt").read_text().splitlines(): match = re.match(r"([^/#][^:]*):[^=]+=(.*)$", line) if match: values[match[1]] = match[2] return values def required_materials(cache): keys = [key for key in cache if key.startswith("RAVEN_") and ("COMPONENT_DIR" in key or key.endswith(("_COMPONENT_PATH", "_PE32_DIR", "_PE64_DIR", "_FONT_DIRECTORY", "_SKIA_LIBRARY")))] keys += [key for key, value in cache.items() if key.endswith("_LIBRARY_RELEASE") and value.endswith(".a")] keys += [key for key in ("RAVEN_ROOT_CERTIFICATE_DIRECTORY",) if key in cache] return {key for key in keys if cache[key] and not cache[key].endswith("-NOTFOUND")} def add_bytes(archive, name, data, mode=0o644): info = tarfile.TarInfo(name) info.size, info.mode, info.mtime = len(data), mode, 0 archive.addfile(info, io.BytesIO(data)) def bundle(source, build, output, source_output, test_report, materials_path): record = json.loads((build / "native-distribution-build.json").read_text()) current = snapshot(source, build) if current["revision"] is None: raise RuntimeError("release packaging requires a Git checkout; extracted source archives can still be built") if current["dirty"]: raise RuntimeError("distribution requires a clean source tree; local runtime work must not be packaged") if record["inputs"] != current or record["binaries"] != runtime_files(build): raise RuntimeError("source, configuration or runtime files changed after the recorded build; rebuild first") report = ET.parse(test_report).getroot() cases = report.findall(".//testcase") if not cases or report.findall(".//failure") or report.findall(".//error"): raise RuntimeError("a nonempty passing CTest JUnit report is required") if test_report.stat().st_mtime < record["finished"]: raise RuntimeError("test report predates the recorded build") expected = json.loads(subprocess.check_output(["ctest", "--test-dir", str(build), "--show-only=json-v1"])) names = [case.get("name") for case in cases] if len(names) != len(set(names)) or set(names) != {case["name"] for case in expected["tests"]}: raise RuntimeError("test report does not cover the complete configured CTest suite") cache = cache_values(build) review = json.loads((source / "LICENSES.json").read_text()) blockers = [item["name"] for item in review["components"] if item["review"] != "reviewed" and any(cache.get(key) and not cache[key].endswith("-NOTFOUND") for key in item.get("cmake_keys", []))] if blockers: raise RuntimeError("unresolved license review for: " + ", ".join(blockers) + "; see LICENSES.json") materials = json.loads(materials_path.read_text()) if materials_path else [] covered, checked = set(), [] for item in materials: name = item["name"] if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", name) or name in {value["name"] for value in checked}: raise RuntimeError(f"invalid or duplicate source material name: {name}") paths = [Path(item["source"]), Path(item["recipe"]), *map(Path, item["notices"])] notice_root = paths[1].parent / "licenses" paths += [path for path in sorted(notice_root.rglob("*")) if path.is_file() and path not in paths] if not item["notices"] or any(path.is_symlink() or not path.is_file() for path in paths): raise RuntimeError(f"source material needs a source archive, recipe and notices: {name}") if digest(paths[0]) != item["sha256"]: raise RuntimeError(f"source material checksum differs: {name}") if not tarfile.is_tarfile(paths[0]): raise RuntimeError(f"source material must be a source tar archive, not a DLL or binary: {name}") covered.update(item["cmake_keys"]) checked.append(dict(name=name, paths=paths, cmake_keys=item["cmake_keys"], sha256=item["sha256"])) missing = required_materials(cache) - covered if missing: raise RuntimeError("missing corresponding source materials for: " + ", ".join(sorted(missing))) for key in ("CMAKE_EXE_LINKER_FLAGS", "CMAKE_CXX_FLAGS"): if re.search(r"(?:^|\s)-static(?:\s|$)|\.a(?:\s|$)", cache.get(key, "")): raise RuntimeError("custom static linking needs a dependency review before packaging") for destination in (output, source_output): if destination.exists(): raise RuntimeError(f"refusing to overwrite package: {destination}") destination.parent.mkdir(parents=True, exist_ok=True) partial = output.with_name(output.name + ".partial") source_partial = source_output.with_name(source_output.name + ".partial") files, source_files, created, published = {}, {}, [], [] try: stream = partial.open("xb") created.append(partial) source_stream = source_partial.open("xb") created.append(source_partial) with stream, source_stream, tarfile.open(fileobj=stream, mode="w:gz") as archive, \ tarfile.open(fileobj=source_stream, mode="w:gz") as sources: inputs = runtime_paths(build) for name in record["binaries"]: path = inputs[name] data = path.read_bytes() if hashlib.sha256(data).hexdigest() != record["binaries"][name]: raise RuntimeError(f"runtime file changed while packaging: {name}") destination = "runtime/" + name add_bytes(archive, destination, data, path.stat().st_mode & 0o777) files[destination] = hashlib.sha256(data).hexdigest() names = ["LICENSE", "LICENSES.md", "LICENSES.json", "docs/packaging.md"] names += sorted(path.relative_to(source).as_posix() for path in (source / "LICENSES").iterdir() if path.is_file()) for name in names: data = (source / name).read_bytes() add_bytes(archive, name, data) add_bytes(sources, name, data) files[name] = hashlib.sha256(data).hexdigest() source_files[name] = files[name] data = (f"Corresponding source: {source_output.name}\n" f"Revision: {current['revision']}\n" "Download the source archive from the same release as this binary archive.\n" "Runtime: GPL-3.0-or-later. Guest components retain their recorded terms.\n").encode() add_bytes(archive, "SOURCE.txt", data) files["SOURCE.txt"] = hashlib.sha256(data).hexdigest() source_tar = git(source, "archive", "--format=tar", "--prefix=raven-source/", current["revision"]) add_bytes(sources, "raven.tar", source_tar) source_files["raven.tar"] = hashlib.sha256(source_tar).hexdigest() for item in checked: for index, path in enumerate(item["paths"]): destination = f"sources/{item['name']}/{index}-{path.name}" checksum = digest(path) if index == 0 and checksum != item["sha256"]: raise RuntimeError(f"source material changed while packaging: {item['name']}") with path.open("rb") as material: info = tarfile.TarInfo(destination) info.size, info.mode = path.stat().st_size, 0o644 sources.addfile(info, material) if digest(path) != checksum: raise RuntimeError(f"source material changed while packaging: {path}") source_files[destination] = checksum if index > 0: data = path.read_bytes() add_bytes(archive, "notices/" + destination, data) files["notices/" + destination] = checksum info = dict(revision=current["revision"], runtime_license="GPL-3.0-or-later", original_code_license="GPL-3.0-or-later", binaries=record["binaries"], guest_adapter_license="MIT", guest_components=review["components"], source_files=current["files"], configured_materials=sorted(required_materials(cache)), test_cases=len(cases), skipped=sum(case.find("skipped") is not None for case in cases), features={key: bool(value and not value.endswith("-NOTFOUND")) for key, value in cache.items() if key.startswith("RAVEN_") and ("COMPONENT" in key or "SKIA" in key or "PE64_DIR" in key)}) data = (json.dumps(info, indent=2) + "\n").encode() add_bytes(archive, "BUILD-INFO.json", data) add_bytes(sources, "BUILD-INFO.json", data) files["BUILD-INFO.json"] = hashlib.sha256(data).hexdigest() source_files["BUILD-INFO.json"] = files["BUILD-INFO.json"] checksums = "".join(f"{value} {name}\n" for name, value in sorted(files.items())) add_bytes(archive, "SHA256SUMS", checksums.encode()) checksums = "".join(f"{value} {name}\n" for name, value in sorted(source_files.items())) add_bytes(sources, "SHA256SUMS", checksums.encode()) source_hash = digest(source_partial) if snapshot(source, build) != current or runtime_files(build) != record["binaries"]: raise RuntimeError("build inputs changed while packaging") os.link(source_partial, source_output) published.append(source_output) os.link(partial, output) published.append(output) except BaseException: for path in published: path.unlink() raise finally: for path in created: path.unlink(missing_ok=True) print(output) print(source_output) print(f"Corresponding source SHA-256: {source_hash}") def main(): parser = argparse.ArgumentParser(description="Record a native build and package its corresponding sources and licenses") parser.add_argument("mode", choices=("prepare", "seal", "bundle")) parser.add_argument("--source", type=Path, required=True) parser.add_argument("--build", type=Path, required=True) parser.add_argument("--output", type=Path) parser.add_argument("--source-output", type=Path) parser.add_argument("--test-report", type=Path) parser.add_argument("--materials", type=Path) args = parser.parse_args() source, build = args.source.resolve(), args.build.resolve() try: if build.is_relative_to(source): raise RuntimeError("build directory must be outside the source tree") prepared = build / "native-distribution-inputs.json" if args.mode == "prepare": write_record(prepared, snapshot(source, build)) elif args.mode == "seal": inputs = json.loads(prepared.read_text()) if inputs != snapshot(source, build): raise RuntimeError("source or configuration changed during the build") if (build / "native/raven").stat().st_mtime < prepared.stat().st_mtime: raise RuntimeError("runtime predates its source record; rebuild first") record = dict(inputs=inputs, binaries=runtime_files(build), finished=time.time()) write_record(build / "native-distribution-build.json", record) else: if not args.output or not args.source_output or not args.test_report: raise RuntimeError("bundle requires --output, --source-output and --test-report") if args.output.resolve() == args.source_output.resolve(): raise RuntimeError("binary and source outputs must be different files") if any(path.resolve().is_relative_to(source) for path in (args.output, args.source_output)): raise RuntimeError("package output must be outside the source tree") bundle(source, build, args.output.resolve(), args.source_output.resolve(), args.test_report, args.materials) except (OSError, ValueError, KeyError, RuntimeError, subprocess.CalledProcessError, ET.ParseError) as error: parser.exit(1, f"native packaging failed: {error}\n") if __name__ == "__main__": main()