"""Build provenance and exact-file SHA-256, shared by CCS, Keil and CLI builds. prepare runs BEFORE compiling; seal runs AFTER binary conversion and size checks. The digest is external to the image, avoiding the self-hashing paradox. No MCU protocol encoding lives here: the generated header contains scalar constants. """ from __future__ import annotations import argparse from datetime import datetime, timezone import hashlib import json import os from pathlib import Path import subprocess def git_state(repository: Path) -> dict: """Record full commits, and flag untracked sources as well as tracked edits. Generated outputs must be ignored in the repository. Unlike Climate's older helper, a new, untracked source file must not look like a clean release. """ def git(*args): return subprocess.check_output( ["git", "-C", str(repository), *args], stderr=subprocess.DEVNULL, text=True, encoding="utf-8").strip() try: commit = git("rev-parse", "HEAD") dirty = bool(git("status", "--porcelain", "--untracked-files=normal")) return {"commit": commit, "dirty": dirty} except (OSError, subprocess.CalledProcessError): return {"commit": None, "dirty": None} def write_atomic(path: Path, text: str): path.parent.mkdir(parents=True, exist_ok=True) temporary = path.with_name(path.name + ".tmp") temporary.write_text(text, encoding="utf-8", newline="\n") temporary.replace(path) def prepare(repository: Path, templates: Path, config: Path, output: Path) -> dict: settings = json.loads(config.read_text(encoding="utf-8")) version = settings["version"] if (len(version) != 3 or any(type(v) is not int for v in version) or not all(0 <= v <= limit for v, limit in zip(version, (255, 255, 999)))): raise ValueError("version must be [major 0..255, minor 0..255, patch 0..999]") for field in ("product", "target"): if not isinstance(settings.get(field), str) or not settings[field].strip(): raise ValueError(f"missing {field}") source, dependency = git_state(repository), git_state(templates) # A clean application built with a modified templates checkout is dirty too. known = source["commit"] is not None and dependency["commit"] is not None dirty = bool(source["dirty"] or dependency["dirty"]) build_id = (source["commit"][:7] + "+" if dirty else source["commit"][:8]) if known else "NOGIT000" # UTC has an explicit interpretation across build PCs; SOURCE_DATE_EPOCH is # supported for reproducible release builds, not silently inferred from Git. epoch = os.environ.get("SOURCE_DATE_EPOCH") stamp = datetime.fromtimestamp(int(epoch), timezone.utc) if epoch else datetime.now(timezone.utc) stamp = stamp.replace(microsecond=0) if not 2000 <= stamp.year <= 2199: raise ValueError("build year outside firmware-info v1 range") metadata = {"schema": 1, "product": settings["product"], "target": settings["target"], "version": version, "build_id": build_id, "built_at_utc": stamp.isoformat(), "source": source, "templates": dependency, "identity_contract": 1, "identity_register_count": 12} constants = dict(zip(("FIRMWARE_VERSION_MAJOR", "FIRMWARE_VERSION_MINOR", "FIRMWARE_VERSION_PATCH"), version)) constants.update(dict(zip(("FI_YEAR", "FI_MONTH", "FI_DAY", "FI_HOUR", "FI_MINUTE", "FI_SECOND"), (stamp.year, stamp.month, stamp.day, stamp.hour, stamp.minute, stamp.second)))) constants.update({f"FI_ID_{i}": ord(c) for i, c in enumerate(build_id)}) header = "/* Generated before compilation. Do not edit or commit. UTC stamp. */\n#ifndef FIRMWARE_IDENTITY_H\n#define FIRMWARE_IDENTITY_H\n" header += f'#define FIRMWARE_BUILD_ID "{build_id}"\n' header += "".join(f"#define {key} {value}U\n" for key, value in constants.items()) header += "#endif\n" # Rewriting the header forces the identity compilation unit to be rebuilt. # Sharing this directory between concurrent configurations is unsupported. write_atomic(output / "firmware_identity.h", header) write_atomic(output / "firmware_identity.json", json.dumps(metadata, indent=2) + "\n") return metadata def seal(image: Path, metadata_path: Path) -> dict: metadata = json.loads(metadata_path.read_text(encoding="utf-8")) data = image.read_bytes() if not data: raise ValueError("empty firmware image") # Seal records the exact file sent to the programmer, including SCI8 headers. # It does not claim that the device has measured RAM/Flash or authenticated it. metadata.update({"file": image.name, "size_bytes": len(data), "sha256": hashlib.sha256(data).hexdigest()}) write_atomic(image.with_suffix(image.suffix + ".manifest.json"), json.dumps(metadata, indent=2) + "\n") write_atomic(image.with_suffix(image.suffix + ".sha256"), f'{metadata["sha256"]} {image.name}\n') return metadata def verify(image: Path, manifest: Path) -> dict: metadata = json.loads(manifest.read_text(encoding="utf-8")) data = image.read_bytes() if len(data) != metadata["size_bytes"] or hashlib.sha256(data).hexdigest() != metadata["sha256"]: raise ValueError("firmware size/SHA-256 does not match manifest") return metadata def main(): parser = argparse.ArgumentParser(description=__doc__) sub = parser.add_subparsers(dest="command", required=True) p = sub.add_parser("prepare") for name in ("repository", "templates", "config", "output"): p.add_argument("--" + name, type=Path, required=True) p = sub.add_parser("seal") p.add_argument("--image", type=Path, required=True) p.add_argument("--metadata", dest="metadata_path", type=Path, required=True) p = sub.add_parser("verify") p.add_argument("--image", type=Path, required=True) p.add_argument("--manifest", type=Path, required=True) args = vars(parser.parse_args()) command = args.pop("command") result = {"prepare": prepare, "seal": seal, "verify": verify}[command](**args) print(json.dumps(result, ensure_ascii=False)) if __name__ == "__main__": main()