|
| 1 | +#! /usr/bin/env python3 |
| 2 | +""" |
| 3 | +Verify that bundled libexpat files come from a verified release. |
| 4 | +""" |
| 5 | + |
| 6 | +from __future__ import annotations |
| 7 | + |
| 8 | +import json |
| 9 | +import os |
| 10 | +import re |
| 11 | +import tarfile |
| 12 | +from hashlib import sha3_256 |
| 13 | +from pathlib import Path |
| 14 | +from typing import Literal |
| 15 | +from urllib.request import Request, urlopen |
| 16 | + |
| 17 | +GITHUB_ACTIONS = os.getenv("GITHUB_ACTIONS") == "true" |
| 18 | + |
| 19 | +EXPAT_REL_PATH = "Modules/expat/" |
| 20 | +EXPAT_PATH = Path(__file__).parent.parent.parent / EXPAT_REL_PATH |
| 21 | + |
| 22 | + |
| 23 | +def log( |
| 24 | + level: Literal["debug", "notice", "error"], file_path: str | None, message: str |
| 25 | +) -> None: |
| 26 | + if GITHUB_ACTIONS: |
| 27 | + # https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions |
| 28 | + if file_path: |
| 29 | + message = f"::{level} file={file_path}::{message}" |
| 30 | + else: |
| 31 | + message = f"::{level}::{message}" |
| 32 | + print(message) |
| 33 | + |
| 34 | + |
| 35 | +def verify_expat() -> bool: |
| 36 | + has_failed = False |
| 37 | + |
| 38 | + with open(EXPAT_PATH / "expat.h") as headers_file: |
| 39 | + headers = headers_file.read() |
| 40 | + version_pieces = re.findall( |
| 41 | + r"(?<=^#define XML_(?:MAJOR|MINOR|MICRO)_VERSION ).+$", headers, re.MULTILINE |
| 42 | + ) |
| 43 | + |
| 44 | + expat_version = ".".join(version_pieces) |
| 45 | + log("debug", None, f"Verifying bundled files from libexpat {expat_version}.") |
| 46 | + |
| 47 | + # Offload verifying the release tag to GitHub. |
| 48 | + # https://docs.github.com/en/rest/git/commits?apiVersion=2022-11-28#get-a-commit |
| 49 | + tag_name = f"R_{'_'.join(version_pieces)}" |
| 50 | + tag_data_request = Request( |
| 51 | + f"https://api.github.com/repos/libexpat/libexpat/commits/{tag_name}", |
| 52 | + headers={"X-GitHub-Api-Version": "2022-11-28"}, |
| 53 | + ) |
| 54 | + with urlopen(tag_data_request) as tag_data_io: |
| 55 | + tag_data = json.load(tag_data_io) |
| 56 | + if tag_data["commit"]["verification"]["verified"]: |
| 57 | + log( |
| 58 | + "debug", |
| 59 | + None, |
| 60 | + f"The signature in libexpat {expat_version} is considered to be verified.", |
| 61 | + ) |
| 62 | + else: |
| 63 | + log( |
| 64 | + "error", |
| 65 | + None, |
| 66 | + f"The signature in libexpat {expat_version} is not considered to be verified.", |
| 67 | + ) |
| 68 | + has_failed = True |
| 69 | + |
| 70 | + # Download tarball from GitHub and generate hashes for files that |
| 71 | + # have to be bundled. |
| 72 | + tarball_url = ( |
| 73 | + f"https://github.com/libexpat/libexpat/archive/refs/tags/{tag_name}.tar.gz" |
| 74 | + ) |
| 75 | + expected_hashes = {} |
| 76 | + with urlopen(tarball_url) as tarball_io: |
| 77 | + with tarfile.open(fileobj=tarball_io, mode="r:gz") as tarball: |
| 78 | + for member in tarball: |
| 79 | + if re.search( |
| 80 | + r"/expat/lib/\w+\.[ch]$", member.name |
| 81 | + ) or member.name.endswith("/COPYING"): |
| 82 | + file_name = os.path.basename(member.name) |
| 83 | + content = tarball.extractfile(member).read() # type: ignore[union-attr] |
| 84 | + expected_hashes[file_name] = sha3_256(content).hexdigest() |
| 85 | + |
| 86 | + # Compare hashes of bundled libexpat files to the actually released |
| 87 | + # files. |
| 88 | + with os.scandir(EXPAT_PATH) as expat_dir: |
| 89 | + for entry in expat_dir: |
| 90 | + with open(entry, "rb") as expat_file: |
| 91 | + # Skip files that are not a part of libexpat. |
| 92 | + if entry.name in ( |
| 93 | + "expat_config.h", |
| 94 | + "pyexpatns.h", |
| 95 | + ) or entry.name.endswith((".a", ".o")): |
| 96 | + continue |
| 97 | + # Skip a few known lines added to expat_external.h. |
| 98 | + elif entry.name == "expat_external.h": |
| 99 | + for _ in range(4): |
| 100 | + expat_file.readline() |
| 101 | + file_path = os.path.join(EXPAT_REL_PATH, entry.name) |
| 102 | + content = expat_file.read() |
| 103 | + if sha3_256(content).hexdigest() == expected_hashes[entry.name]: |
| 104 | + log( |
| 105 | + "debug", |
| 106 | + file_path, |
| 107 | + f"{entry.name} is the same as in libexpat {expat_version}.", |
| 108 | + ) |
| 109 | + else: |
| 110 | + log( |
| 111 | + "error", |
| 112 | + file_path, |
| 113 | + f"{entry.name} is not the same as in libexpat {expat_version}.", |
| 114 | + ) |
| 115 | + has_failed = True |
| 116 | + del expected_hashes[entry.name] |
| 117 | + |
| 118 | + if expected_hashes: |
| 119 | + log("error", None, f"{expected_hashes.keys()} files were not bundled.") |
| 120 | + has_failed = True |
| 121 | + |
| 122 | + has_succeeded = not has_failed |
| 123 | + if has_succeeded: |
| 124 | + log("notice", None, "Successfully verified bundled libexpat files.") |
| 125 | + return has_succeeded |
| 126 | + |
| 127 | + |
| 128 | +if __name__ == "__main__": |
| 129 | + raise SystemExit(0 if verify_expat() else 1) |
0 commit comments