#!/usr/bin/env python3 """Collect existing creator build/board evidence; never build, publish, or flash. Produces application.bin, metadata.json and evidence.zip for the operator UI. The input hardware record must be an actual report bound to these artifacts. """ from __future__ import annotations import argparse import hashlib import json from pathlib import Path import re import shutil import struct import sys import tempfile import zipfile REQUIRED_CONFIG = ("CONFIG_BOOTLOADER_CACHE_32BIT_ADDR_QUAD_FLASH", "CONFIG_IDF_EXPERIMENTAL_FEATURES") def source_bytes(path, maximum): path = Path(path) if path.is_symlink() or not path.is_file() or not 0 < path.stat().st_size <= maximum: raise ValueError(f"Missing, empty, oversized or symbolic input: {path.name}") return path.read_bytes() def sha256(data): return hashlib.sha256(data).hexdigest() def build_package(args): target = Path(args.output_dir) if target.exists(): raise ValueError("Output directory already exists; choose a new directory") if not re.fullmatch(r"[a-fA-F0-9]{40}", args.build_commit): raise ValueError("Use the full 40-character build Git commit") app = source_bytes(args.app, 8 * 1024 * 1024) # A quick preflight only. The upload service subsequently validates every # ESP image segment/checksum/appended hash and rejects merged/extra data. if len(app) < 304 or app[0] != 0xE9 or struct.unpack_from(" 2147483647 for n in version.split(".")): raise ValueError("Embedded application version must contain 1 to 4 numeric components") files = {name: source_bytes(getattr(args, name), 4 * 1024 * 1024) for name in ("app_config", "bootloader_config", "bootloader", "hardware_record")} for name in ("app_config", "bootloader_config"): config = files[name].decode("utf-8") flags = dict(re.findall(r"^(CONFIG_[A-Z0-9_]+)=(.*)$", config, re.MULTILINE)) if any(flags.get(key) != "y" for key in REQUIRED_CONFIG): raise ValueError(f"{name} must enable 32-bit cache addressing and experimental features") metadata = {"board": args.board, "hardware_model": args.hardware_model, "hardware_versions": sorted(set(args.hardware_version)), "product": "own_creator", "partition_layout": "p4_32m_v2", "build_commit": args.build_commit.lower(), "notes": args.notes} evidence = {"schema": 1, **{key: metadata[key] for key in ("board", "hardware_model", "product", "partition_layout", "build_commit")}, "flash_model": args.flash_model, "record_ref": args.record_ref, "app_sha256": sha256(app), "bootloader_sha256": sha256(files["bootloader"]), "artifacts": {}} record = json.loads(files["hardware_record"]) for field in ("record_ref", "board", "flash_model", "bootloader_sha256", "app_sha256", "build_commit"): if not isinstance(record, dict) or record.get(field) != evidence[field]: raise ValueError(f"Hardware record does not bind this release's {field}") if not isinstance(record.get("report"), str) or len(record["report"].strip()) < 80: raise ValueError("Hardware record needs the original procedure, results and limits; no generated acceptance text") for name, content in files.items(): evidence["artifacts"][name] = {"path": "evidence/" + name, "sha256": sha256(content)} metadata["evidence"] = evidence if not target.parent.is_dir(): raise ValueError("Output parent directory does not exist") temporary = Path(tempfile.mkdtemp(prefix=".creator-package-", dir=target.parent)) try: (temporary / "application.bin").write_bytes(app) (temporary / "metadata.json").write_text(json.dumps(metadata, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") with zipfile.ZipFile(temporary / "evidence.zip", "w", compression=zipfile.ZIP_DEFLATED) as bundle: for name, content in files.items(): bundle.writestr(evidence["artifacts"][name]["path"], content) # mkdir reserves the destination without replacing existing user data. target.mkdir() for file in temporary.iterdir(): shutil.move(str(file), target / file.name) finally: shutil.rmtree(temporary, ignore_errors=True) return {"packaged": True, "version": version, "size": len(app), "sha256": sha256(app), "files": ["application.bin", "metadata.json", "evidence.zip"], "published": False, "hardware_acceptance_performed": False} def main(argv=None): parser = argparse.ArgumentParser(description=__doc__) for name in ("app", "app-config", "bootloader-config", "bootloader", "hardware-record", "board", "hardware-model", "build-commit", "flash-model", "record-ref", "output-dir"): parser.add_argument("--" + name, required=True) parser.add_argument("--hardware-version", required=True, action="append", help="Allowed hardware revision; repeat for multiple") parser.add_argument("--notes", default="") print(json.dumps(build_package(parser.parse_args(argv)), ensure_ascii=False, indent=2)) return 0 if __name__ == "__main__": try: sys.exit(main()) except (ValueError, OSError, UnicodeError) as error: print(str(error), file=sys.stderr) sys.exit(1)