Files
framebridge/source/packaging/build_profiles.py
T

194 lines
9.9 KiB
Python

"""Explicit Debian-family candidate targets and offline vendor validation.
This module never installs dependencies or imports the target's native modules.
Profiles describe eligible build inputs, not completed OS/desktop validation.
"""
import base64
import csv
from email.parser import Parser
import hashlib
from pathlib import Path
import re
CANDIDATE_VERSION = "0.3.2"
VENDOR_RELATIVE = Path("original/geranas/FrameBridge-Fleet-Hub/vendor")
SOURCE_VENDOR = Path("sources/original-geranas/FrameBridge-Fleet-Hub/vendor")
GLIBC_BASELINE = (2, 34)
PROFILES = {
"debian13-amd64-py313": {"os_id": "debian", "os_version": "13", "python_minor": "3.13"},
"ubuntu24.04-amd64-py312": {"os_id": "ubuntu", "os_version": "24.04", "python_minor": "3.12"},
"mint22-amd64-py312": {"os_id": "linuxmint", "os_version": "22", "python_minor": "3.12"},
"ubuntu26.04-amd64-py314": {"os_id": "ubuntu", "os_version": "26.04", "python_minor": "3.14"},
"lmde7-amd64-py313": {"os_id": "linuxmint", "os_version": "7", "python_minor": "3.13"},
}
def target_profile(profile_id):
profile = dict(PROFILES[profile_id])
profile.update(schema=1, profile_id=profile_id, architecture="amd64",
python_abi="cp" + profile["python_minor"].replace(".", ""),
validation_status="candidate", glibc_minimum="2.34")
return profile
def _normalized(name):
return re.sub(r"[-_.]+", "-", name).lower()
def distribution_metadata(directory):
directory = Path(directory)
if not directory.is_dir() or directory.is_symlink():
raise RuntimeError("Vendor input must be a real directory: " + str(directory))
for path in directory.rglob("*"):
if path.is_symlink():
raise RuntimeError("Vendor symlinks are not supported: " + str(path))
result = {}
for info in sorted(directory.glob("*.dist-info")):
metadata = info / "METADATA"
if not metadata.is_file():
raise RuntimeError("Missing vendor METADATA: " + str(info))
msg = Parser().parsestr(metadata.read_text(encoding="utf-8"))
name, version = msg.get("Name"), msg.get("Version")
if not name or not version or _normalized(name) in result:
raise RuntimeError("Missing or duplicate vendor distribution identity: " + str(info))
result[_normalized(name)] = (version, info, msg)
if not result:
raise RuntimeError("Vendor input contains no distribution metadata")
return result
def vendor_versions(directory):
return {name: value[0] for name, value in distribution_metadata(directory).items()}
def _python_allowed(requirement, minor):
# Fail closed on unsupported specifier syntax rather than silently omitting it.
version = tuple(map(int, minor.split("."))) + (0,)
for part in requirement.split(","):
match = re.fullmatch(r"\s*(>=|<=|!=|==|>|<)\s*(\d+(?:\.\d+){0,2})(\.\*)?\s*", part)
if not match:
raise RuntimeError("Unsupported Requires-Python specifier: " + requirement)
op, number, wildcard = match.groups()
components = tuple(map(int, number.split(".")))
wanted = components + (0,) * (3 - len(components))
if wildcard:
if op not in ("==", "!="):
raise RuntimeError("Invalid Requires-Python wildcard: " + requirement)
equal = version[:len(components)] == components
allowed = equal if op == "==" else not equal
else:
allowed = {">=": version >= wanted, "<=": version <= wanted,
">": version > wanted, "<": version < wanted,
"==": version == wanted, "!=": version != wanted}[op]
if not allowed:
return False
return True
def _wheel_tag_allowed(tag, profile):
parts = tag.split("-")
if len(parts) != 3:
return False
interpreters, abis, platforms = [part.split(".") for part in parts]
target = profile["python_abi"]
target_number = int(target[2:])
for interpreter in interpreters:
for abi in abis:
if abi == "none" and interpreter in ("py3", target):
python_ok = True
elif abi == target and interpreter == target:
python_ok = True
elif abi == "abi3" and re.fullmatch(r"cp3\d+", interpreter):
python_ok = int(interpreter[2:]) <= target_number
else:
python_ok = False
if not python_ok:
continue
for platform in platforms:
if platform == "any" and abi == "none":
return True
match = re.fullmatch(r"manylinux_(\d+)_(\d+)_x86_64", platform)
legacy = {"manylinux1_x86_64": (2, 5), "manylinux2010_x86_64": (2, 12),
"manylinux2014_x86_64": (2, 17)}
baseline = tuple(map(int, match.groups())) if match else legacy.get(platform)
if baseline and baseline <= GLIBC_BASELINE and abi != "none":
return True
return False
def validate_vendor(directory, profile, expected_versions=None):
directory = Path(directory)
distributions = distribution_metadata(directory)
versions = {name: value[0] for name, value in distributions.items()}
if expected_versions is not None and versions != expected_versions:
raise RuntimeError("Vendor distribution names/versions differ from reviewed source inputs")
owners = {}
native_owners = set()
packages = []
for name, (version, info, metadata) in distributions.items():
requires_python = metadata.get("Requires-Python")
if requires_python and not _python_allowed(requires_python, profile["python_minor"]):
raise RuntimeError("Vendor Requires-Python rejects target: " + name)
wheel, record = info / "WHEEL", info / "RECORD"
if not wheel.is_file() or not record.is_file():
raise RuntimeError("Missing vendor WHEEL/RECORD: " + name)
wheel_metadata = Parser().parsestr(wheel.read_text(encoding="utf-8"))
tags = wheel_metadata.get_all("Tag", [])
if not tags or not any(_wheel_tag_allowed(tag, profile) for tag in tags):
raise RuntimeError("Vendor wheel ABI/platform rejects target: " + name + " " + repr(tags))
if (wheel_metadata.get("Root-Is-Purelib", "").lower() == "false"
and any(_wheel_tag_allowed(tag, profile) and not tag.endswith("-any") for tag in tags)):
native_owners.add(name)
for row in csv.reader(record.read_text(encoding="utf-8").splitlines()):
if row:
owners.setdefault(row[0], []).append(name)
relative = Path(row[0])
if relative.is_absolute():
raise RuntimeError("Absolute vendor RECORD path: " + row[0])
# Wheel entry-point scripts are not shipped in this module tree.
if ".." in relative.parts or "bin" in relative.parts or "__pycache__" in relative.parts:
continue
file = directory / relative
if len(row) < 3 or not file.is_file():
raise RuntimeError("Missing or malformed vendor RECORD file: " + row[0])
if not row[1] and file != record:
raise RuntimeError("Vendor RECORD digest missing: " + row[0])
if row[1]:
algorithm, separator, encoded = row[1].partition("=")
wanted = base64.urlsafe_b64encode(hashlib.sha256(file.read_bytes()).digest()).decode().rstrip("=")
if algorithm != "sha256" or not separator or wanted != encoded:
raise RuntimeError("Vendor RECORD digest mismatch: " + row[0])
if row[2] and int(row[2]) != file.stat().st_size:
raise RuntimeError("Vendor RECORD size mismatch: " + row[0])
packages.append({"name": name, "version": version, "wheel_tags": tags})
native = []
for path in sorted(directory.rglob("*.so")):
relative = path.relative_to(directory).as_posix()
if len(owners.get(relative, [])) != 1:
raise RuntimeError("Native vendor file is not owned by one RECORD: " + relative)
if owners[relative][0] not in native_owners:
raise RuntimeError("Native vendor file lacks matching native WHEEL metadata: " + relative)
match = re.search(r"\.cpython-(\d+)-x86_64-linux-gnu\.so$", path.name)
if match:
if "cp" + match.group(1) != profile["python_abi"]:
raise RuntimeError("Native vendor extension ABI rejects target: " + relative)
elif not path.name.endswith(".abi3.so"):
raise RuntimeError("Native vendor extension has unrecognized ABI suffix: " + relative)
data = path.read_bytes()
if len(data) < 20 or data[:6] != b"\x7fELF\x02\x01" or int.from_bytes(data[18:20], "little") != 62:
raise RuntimeError("Native vendor extension is not ELF amd64: " + relative)
needed = [tuple(map(int, values)) for values in re.findall(rb"GLIBC_(\d+)\.(\d+)", data)]
if needed and max(needed) > GLIBC_BASELINE:
raise RuntimeError("Native vendor extension exceeds glibc 2.34 baseline: " + relative)
native.append(relative)
required = ("_cffi_backend.cpython-", "pydantic_core/_pydantic_core.cpython-", "rpds/rpds.cpython-")
if any(not any(path.startswith(prefix) for path in native) for prefix in required):
raise RuntimeError("Required cffi/pydantic_core/rpds native vendor extensions missing")
digest = hashlib.sha256()
for path in sorted(directory.rglob("*")):
if path.is_file() and "__pycache__" not in path.parts and path.suffix != ".pyc" and "bin" not in path.relative_to(directory).parts and path.name != "FB-ABI-PREPARED.json":
digest.update(path.relative_to(directory).as_posix().encode() + b"\0")
digest.update(hashlib.sha256(path.read_bytes()).digest())
return {"vendor_sha256": digest.hexdigest(), "packages": packages, "native_extensions": native}