blob: a01242f334afda22ec119cc2685cbaa810d9d3ee [file]
#!/usr/bin/env python3
# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements. See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to You under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance with
# the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Verify Python release archives and their legal metadata."""
from __future__ import annotations
import argparse
import base64
import csv
import hashlib
import io
import re
import stat
import sys
import tarfile
import tomllib
import unicodedata
import zipfile
from email import policy
from email.message import Message
from email.parser import BytesParser
from pathlib import Path, PurePosixPath
ROOT = Path(__file__).resolve().parents[1]
PYTHON_DIR = ROOT / "bindings/python"
LEGAL_FILES = ("LICENSE", "NOTICE", "THIRD-PARTY-LICENSES.html")
TARGETS = {
"x86_64-unknown-linux-gnu",
"aarch64-unknown-linux-gnu",
"x86_64-apple-darwin",
"aarch64-apple-darwin",
"x86_64-pc-windows-msvc",
}
PLACEHOLDERS = (
b"<year>",
b"<owner>",
b"<copyright holders>",
b"<year>",
b"<owner>",
b"<copyright holders>",
)
AVRO_NOTICE_MARKERS = (
b"Apache Avro",
b"Flavien Raynaud",
b"donated to the Apache Avro project in 2020.",
)
XZ_LICENSE_ANCHOR = b'id="bundled-xz-utils-0bsd"'
class VerificationError(RuntimeError):
"""A release archive failed verification."""
def require(condition: bool, message: str) -> None:
if not condition:
raise VerificationError(message)
def normalized_relative_path(
value: str, description: str, allow_trailing_slash: bool = False
) -> PurePosixPath:
require(bool(value), f"empty {description}")
require("\\" not in value, f"invalid {description}: {value!r}")
require(
re.match(r"^[A-Za-z]:", value) is None,
f"Windows drive {description}: {value!r}",
)
candidate = value[:-1] if allow_trailing_slash and value.endswith("/") else value
path = PurePosixPath(candidate)
require(not path.is_absolute(), f"absolute {description}: {value!r}")
require(".." not in path.parts, f"unsafe {description}: {value!r}")
canonical = path.as_posix()
require(
value == canonical
or (allow_trailing_slash and value == f"{canonical}/"),
f"non-canonical {description}: {value!r}",
)
return path
def portable_path_key(path: PurePosixPath) -> str:
return unicodedata.normalize("NFC", path.as_posix()).casefold()
def index_tar_members(
members: list[tarfile.TarInfo], description: str
) -> dict[str, tarfile.TarInfo]:
result = {}
portable_names = set()
for member in members:
require(
member.isfile() or member.isdir(),
f"{description} has a link or special member: {member.name}",
)
normalized = normalized_relative_path(
member.name,
f"{description} member",
allow_trailing_slash=member.isdir(),
)
name = normalized.as_posix()
require(
name not in result,
f"{description} has duplicate normalized member: {name}",
)
portable_name = portable_path_key(normalized)
require(
portable_name not in portable_names,
f"{description} has a portable path collision: {name}",
)
result[name] = member
portable_names.add(portable_name)
return result
def verify_record(
archive: zipfile.ZipFile,
names: list[str],
record_member: str,
description: str,
) -> None:
try:
content = archive.read(record_member).decode("utf-8")
rows = list(csv.reader(io.StringIO(content, newline=""), strict=True))
except (UnicodeDecodeError, csv.Error) as error:
raise VerificationError(f"invalid RECORD in {description}: {error}") from error
entries = {}
for row in rows:
require(len(row) == 3, f"invalid RECORD row in {description}: {row}")
name, digest, size = row
normalized_relative_path(name, "RECORD path")
require(name not in entries, f"duplicate RECORD path in {description}: {name}")
entries[name] = (digest, size)
require(
set(entries) == set(names),
f"RECORD members differ in {description}",
)
record_dir = PurePosixPath(record_member).parent
unsigned = {
record_member,
str(record_dir / "RECORD.jws"),
str(record_dir / "RECORD.p7s"),
}
for name in names:
digest, size = entries[name]
if name in unsigned:
require(not digest and not size, f"signed RECORD row in {description}: {name}")
continue
data = archive.read(name)
encoded = base64.urlsafe_b64encode(hashlib.sha256(data).digest()).rstrip(b"=")
require(
digest == f"sha256={encoded.decode()}",
f"RECORD hash differs in {description}: {name}",
)
require(
size.isdecimal() and int(size) == len(data),
f"RECORD size differs in {description}: {name}",
)
def verify_legal_bytes(content: bytes, description: str) -> None:
require(b"\r" not in content, f"{description} contains CRLF line endings")
lowered = content.lower()
for placeholder in PLACEHOLDERS:
require(
placeholder not in lowered,
f"{description} contains license placeholder {placeholder!r}",
)
def verify_avro_notice(content: bytes, description: str) -> None:
for marker in AVRO_NOTICE_MARKERS:
require(marker in content, f"{description} is missing {marker!r}")
def verify_license_report(content: bytes, target: str, description: str) -> None:
markers = (
b"<strong>Artifact:</strong> <code>python</code>",
f"<strong>Rust target:</strong> <code>{target}</code>".encode(),
b"<strong>Root manifest:</strong> <code>bindings/python/Cargo.toml</code>",
XZ_LICENSE_ANCHOR,
)
for marker in markers:
require(marker in content, f"{description} is missing {marker!r}")
def parse_metadata(content: bytes, description: str) -> Message:
try:
metadata = BytesParser(policy=policy.default).parsebytes(content)
except Exception as error:
raise VerificationError(f"invalid {description}: {error}") from error
require(
metadata.get("Metadata-Version") == "2.4",
f"{description} must use Metadata-Version 2.4",
)
require(
metadata.get("Name") == "pypaimon-rust",
f"{description} has an invalid package name",
)
require(bool(metadata.get("Version")), f"{description} has no version")
require(
metadata.get("License-Expression") == "Apache-2.0",
f"{description} has an invalid License-Expression",
)
license_files = metadata.get_all("License-File", [])
require(
sorted(license_files) == sorted(LEGAL_FILES),
f"{description} has invalid License-File entries: {license_files}",
)
return metadata
def workspace_version() -> str:
with (ROOT / "Cargo.toml").open("rb") as source:
return str(tomllib.load(source)["workspace"]["package"]["version"])
def release_pyproject(source: str, version: str, base_version: str) -> str:
if version == base_version:
return source
dynamic_version = 'dynamic = ["version"]\n'
fixed_version = f'version = "{version}"\n'
dynamic_count = source.count(dynamic_version)
fixed_count = source.count(fixed_version)
if dynamic_count == 1 and fixed_count == 0:
return source.replace(dynamic_version, fixed_version, 1)
require(
dynamic_count == 0 and fixed_count == 1,
"unexpected Python release version setting",
)
return source
def expected_sdist_pyproject(version: str) -> bytes:
source = (PYTHON_DIR / "pyproject.toml").read_text(encoding="utf-8")
source = release_pyproject(source, version, workspace_version())
python_source = 'python-source = "python"\n'
dependency_groups = "\n[dependency-groups]\n"
require(source.count(python_source) == 1, "unexpected python-source setting")
require(
source.count(dependency_groups) == 1,
"unexpected dependency-groups section",
)
source = source.replace(python_source, "", 1)
rewrite = (
'manifest-path = "bindings/python/Cargo.toml"\n'
+ python_source
+ dependency_groups
)
return source.replace(dependency_groups, rewrite, 1).encode()
def target_from_wheel_name(path: Path) -> str:
parts = path.name.removesuffix(".whl").rsplit("-", 3)
require(len(parts) == 4, f"invalid wheel filename: {path.name}")
platform_tags = parts[-1].split(".")
targets = set()
for platform_tag in platform_tags:
if platform_tag == "win_amd64":
targets.add("x86_64-pc-windows-msvc")
elif platform_tag.startswith("macosx_") and platform_tag.endswith("_x86_64"):
targets.add("x86_64-apple-darwin")
elif platform_tag.startswith("macosx_") and platform_tag.endswith("_arm64"):
targets.add("aarch64-apple-darwin")
elif re.fullmatch(
r"(?:manylinux(?:\d{4}|_\d+_\d+)|linux)_x86_64", platform_tag
):
targets.add("x86_64-unknown-linux-gnu")
elif re.fullmatch(
r"(?:manylinux(?:\d{4}|_\d+_\d+)|linux)_aarch64", platform_tag
):
targets.add("aarch64-unknown-linux-gnu")
else:
raise VerificationError(
f"unsupported wheel platform tag {platform_tag!r}: {path.name}"
)
require(len(targets) == 1, f"wheel has mixed platform targets: {path.name}")
return targets.pop()
def verify_native_header(content: bytes, target: str, description: str) -> None:
if target.endswith("linux-gnu"):
require(content[:4] == b"\x7fELF", f"{description} is not ELF")
require(len(content) >= 20 and content[4] == 2, f"{description} is not ELF64")
byte_order = "little" if content[5] == 1 else "big"
machine = int.from_bytes(content[18:20], byte_order)
expected = 62 if target.startswith("x86_64") else 183
require(machine == expected, f"{description} has ELF machine {machine}")
return
if target.endswith("windows-msvc"):
require(content[:2] == b"MZ" and len(content) >= 64, f"{description} is not PE")
pe_offset = int.from_bytes(content[60:64], "little")
require(
len(content) >= pe_offset + 6, f"{description} has a truncated PE header"
)
require(
content[pe_offset : pe_offset + 4] == b"PE\0\0", f"{description} is not PE"
)
machine = int.from_bytes(content[pe_offset + 4 : pe_offset + 6], "little")
require(machine == 0x8664, f"{description} has PE machine {machine:#x}")
return
magic = content[:4]
if magic == b"\xcf\xfa\xed\xfe":
byte_order = "little"
elif magic == b"\xfe\xed\xfa\xcf":
byte_order = "big"
else:
raise VerificationError(f"{description} is not 64-bit Mach-O")
require(len(content) >= 8, f"{description} has a truncated Mach-O header")
cpu_type = int.from_bytes(content[4:8], byte_order)
expected = 0x01000007 if target.startswith("x86_64") else 0x0100000C
require(cpu_type == expected, f"{description} has Mach-O CPU type {cpu_type:#x}")
def is_native_library(name: str) -> bool:
filename = PurePosixPath(name).name.lower()
return filename.endswith((".so", ".pyd", ".dylib", ".dll")) or ".so." in filename
def verify_wheel(path: Path) -> tuple[str, str]:
target = target_from_wheel_name(path)
expected_dir = PYTHON_DIR / "licenses" / target
if not expected_dir.is_dir():
expected_dir = PYTHON_DIR
filename_prefix = "pypaimon_rust-"
filename_head = path.name.removesuffix(".whl").rsplit("-", 3)[0]
require(
filename_head.startswith(filename_prefix),
f"invalid wheel distribution name: {path.name}",
)
filename_version = filename_head.removeprefix(filename_prefix)
try:
archive = zipfile.ZipFile(path)
except (OSError, zipfile.BadZipFile) as error:
raise VerificationError(f"cannot open wheel {path}: {error}") from error
with archive:
infos = [info for info in archive.infolist() if not info.is_dir()]
names = [info.filename for info in infos]
portable_names = set()
for info in infos:
mode = info.external_attr >> 16
file_type = stat.S_IFMT(mode)
require(
file_type in {0, stat.S_IFREG},
f"wheel has a link or special member: {info.filename}",
)
normalized = normalized_relative_path(info.filename, "wheel member")
key = portable_path_key(normalized)
require(
key not in portable_names,
f"wheel has a portable path collision: {path.name}:{info.filename}",
)
portable_names.add(key)
bad_member = archive.testzip()
require(bad_member is None, f"wheel has a corrupt member: {bad_member}")
metadata_members = [
name for name in names if name.endswith(".dist-info/METADATA")
]
require(
len(metadata_members) == 1, f"wheel must contain one METADATA: {path.name}"
)
metadata_member = metadata_members[0]
metadata = parse_metadata(archive.read(metadata_member), metadata_member)
version = str(metadata["Version"])
require(
version == filename_version,
f"wheel filename and metadata versions differ: {path.name}",
)
dist_info = PurePosixPath(metadata_member).parent
wheel_members = [name for name in names if name == f"{dist_info}/WHEEL"]
require(len(wheel_members) == 1, f"wheel metadata is incomplete: {path.name}")
wheel_metadata = BytesParser(policy=policy.default).parsebytes(
archive.read(wheel_members[0])
)
require(
wheel_metadata.get("Root-Is-Purelib", "").lower() == "false",
f"wheel is incorrectly marked pure: {path.name}",
)
record_member = f"{dist_info}/RECORD"
require(record_member in names, f"wheel has no RECORD: {path.name}")
verify_record(archive, names, record_member, path.name)
license_report = None
for license_file in metadata.get_all("License-File", []):
relative = normalized_relative_path(license_file, "License-File entry")
members = (
str(relative),
str(dist_info / "licenses" / relative),
)
matches = [
name
for name in names
if PurePosixPath(name).name == relative.name
and relative.name in LEGAL_FILES
]
require(
sorted(matches) == sorted(members),
f"wheel has invalid {relative.name} members: {matches}",
)
expected_path = expected_dir / relative
require(
expected_path.is_file(),
f"missing committed legal file: {expected_path}",
)
expected = expected_path.read_bytes()
for member in members:
actual = archive.read(member)
require(
actual == expected,
f"wheel {member} differs for {target}",
)
verify_legal_bytes(actual, f"{path.name}:{member}")
if relative.name == "NOTICE":
verify_avro_notice(actual, f"{path.name}:{member}")
if relative.name == "THIRD-PARTY-LICENSES.html":
license_report = actual
verify_license_report(actual, target, f"{path.name}:{member}")
native_members = [
name
for name in names
if len(PurePosixPath(name).parts) == 2
and PurePosixPath(name).parts[0] == "pypaimon_rust"
and PurePosixPath(name).name.startswith("pypaimon_rust.")
and PurePosixPath(name).suffix in {".so", ".pyd"}
]
require(
len(native_members) == 1,
f"wheel must contain one native module: {path.name}",
)
native_member = native_members[0]
expected_suffix = ".pyd" if target.endswith("windows-msvc") else ".so"
require(
PurePosixPath(native_member).suffix == expected_suffix,
f"wheel has an invalid native module suffix: {native_member}",
)
with archive.open(native_member) as native_file:
verify_native_header(
native_file.read(65536), target, f"{path.name}:{native_member}"
)
extra_native_members = [
name for name in names if name != native_member and is_native_library(name)
]
if target.endswith("linux-gnu"):
openssl_version = (
b"OpenSSL 1.1.1k FIPS"
if target.startswith("x86_64")
else b"OpenSSL 1.1.1w"
)
openssl_libraries = (
(r"pypaimon_rust\.libs/libcrypto-[^/]+\.so\.1\.1", openssl_version),
(r"pypaimon_rust\.libs/libssl-[^/]+\.so\.1\.1", None),
)
for pattern, version_marker in openssl_libraries:
matches = [
name for name in extra_native_members if re.fullmatch(pattern, name)
]
require(
len(matches) == 1,
f"wheel must contain one {pattern}: {extra_native_members}",
)
with archive.open(matches[0]) as native_file:
content = native_file.read()
verify_native_header(
content,
target,
f"{path.name}:{matches[0]}",
)
if version_marker is not None:
require(
version_marker in content,
f"wheel libcrypto is missing {version_marker!r}",
)
require(
len(extra_native_members) == len(openssl_libraries),
f"wheel has unexpected native libraries: {extra_native_members}",
)
require(
license_report is not None
and b'id="bundled-openssl-1.1.1"' in license_report,
"Linux wheel license report is missing the OpenSSL anchor",
)
else:
require(
not extra_native_members,
f"wheel has unexpected native libraries: {extra_native_members}",
)
return target, version
def verify_sdist(path: Path) -> str:
try:
archive = tarfile.open(path, mode="r:gz")
except (OSError, tarfile.TarError) as error:
raise VerificationError(f"cannot open sdist {path}: {error}") from error
with archive:
members_by_name = index_tar_members(
archive.getmembers(), f"sdist {path.name}"
)
names = list(members_by_name)
roots = {PurePosixPath(name).parts[0] for name in names}
require(len(roots) == 1, f"sdist must have one root directory: {path.name}")
root = roots.pop()
def read_member(name: str) -> bytes:
try:
member = members_by_name[name]
except KeyError as error:
raise VerificationError(
f"sdist is missing {name}: {path.name}"
) from error
require(member.isfile(), f"sdist member is not a file: {name}")
extracted = archive.extractfile(member)
require(extracted is not None, f"cannot read sdist member: {name}")
return extracted.read()
metadata = parse_metadata(read_member(f"{root}/PKG-INFO"), f"{root}/PKG-INFO")
version = str(metadata["Version"])
require(root == f"pypaimon_rust-{version}", f"invalid sdist root: {root}")
require(
path.name == f"pypaimon_rust-{version}.tar.gz",
f"invalid sdist filename: {path.name}",
)
for license_file in metadata.get_all("License-File", []):
relative = normalized_relative_path(license_file, "License-File entry")
member = f"{root}/{relative}"
actual = read_member(member)
expected_path = PYTHON_DIR / relative
require(
expected_path.is_file(), f"missing source legal file: {expected_path}"
)
require(
actual == expected_path.read_bytes(),
f"sdist {relative} differs from source",
)
verify_legal_bytes(actual, f"{path.name}:{member}")
require(
read_member(f"{root}/Cargo.lock") == (ROOT / "Cargo.lock").read_bytes(),
"sdist Cargo.lock differs from source",
)
require(
read_member(f"{root}/pyproject.toml") == expected_sdist_pyproject(version),
"sdist pyproject.toml has an unexpected rewrite",
)
target_reports = [
name for name in names if "/bindings/python/licenses/" in name
]
require(
not target_reports,
f"sdist contains target-specific license reports: {target_reports}",
)
native_members = [name for name in names if is_native_library(name)]
require(
not native_members, f"sdist contains native libraries: {native_members}"
)
return version
def collect_artifacts(inputs: list[str]) -> list[Path]:
candidates = [Path(value) for value in inputs] or [PYTHON_DIR / "dist"]
artifacts = []
for candidate in candidates:
if candidate.is_dir():
artifacts.extend(sorted(candidate.iterdir()))
else:
artifacts.append(candidate)
artifacts = [
path
for path in artifacts
if path.name.endswith(".whl") or path.name.endswith(".tar.gz")
]
unique = list(dict.fromkeys(path.resolve() for path in artifacts))
require(bool(unique), "no wheel or sdist artifacts found")
for path in unique:
require(path.is_file(), f"artifact is not a file: {path}")
return unique
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("artifacts", nargs="*", help="artifact files or directories")
parser.add_argument(
"--allow-partial",
action="store_true",
help="verify supplied artifacts without requiring the full release set",
)
args = parser.parse_args()
try:
artifacts = collect_artifacts(args.artifacts)
targets = {}
sdist_count = 0
versions = set()
for artifact in artifacts:
if artifact.name.endswith(".whl"):
target, version = verify_wheel(artifact)
require(target not in targets, f"duplicate wheel target: {target}")
targets[target] = artifact
versions.add(version)
print(f"verified {artifact.name} ({target})")
else:
versions.add(verify_sdist(artifact))
sdist_count += 1
print(f"verified {artifact.name} (sdist)")
require(len(versions) == 1, f"artifact versions differ: {sorted(versions)}")
require(sdist_count <= 1, f"expected at most one sdist, found {sdist_count}")
if not args.allow_partial:
missing = sorted(TARGETS - targets.keys())
require(not missing, f"missing wheel targets: {missing}")
require(sdist_count == 1, f"expected one sdist, found {sdist_count}")
print(f"verified {len(artifacts)} Python release artifacts")
return 0
except (
KeyError,
OSError,
VerificationError,
tarfile.TarError,
tomllib.TOMLDecodeError,
zipfile.BadZipFile,
) as error:
print(f"Python release verification failed: {error}", file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())