From da055c2787179bbba2bfcbce3c8e872c747268d3 Mon Sep 17 00:00:00 2001 From: Andrey Date: Thu, 8 Oct 2026 15:31:13 +0300 Subject: [PATCH] =?UTF-8?q?feat(firmware-info):=20=D0=B0=D1=80=D1=85=D0=B8?= =?UTF-8?q?=D0=B2=D0=B8=D1=80=D1=83=D0=B9=20=D1=81=D0=B1=D0=BE=D1=80=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=BF=D0=BE=20=D1=85=D0=B5=D1=88=D1=83=20=D0=B8=20?= =?UTF-8?q?=D0=B4=D0=B0=D1=82=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Создай в postcompile CCS и Keil папку по commit исходников и UTC-времени precompile. Сохрани точные образы, manifest, SHA-256 и метаданные, сохраняя прежние выходные файлы. Опиши копирование каталогов в SETGUI и проверь разные даты, целостность и Git-метаданные. --- .../tests/test_identity_archive.py | 72 +++++++++++++++++++ c/firmware-info/tools/c2000_postbuild.py | 6 +- c/firmware-info/tools/identity.py | 59 ++++++++++++++- tools/firmware-build/README.md | 22 +++++- tools/firmware-build/keil/postcompile.bat | 2 + 5 files changed, 158 insertions(+), 3 deletions(-) create mode 100644 c/firmware-info/tests/test_identity_archive.py diff --git a/c/firmware-info/tests/test_identity_archive.py b/c/firmware-info/tests/test_identity_archive.py new file mode 100644 index 0000000..e7818b3 --- /dev/null +++ b/c/firmware-info/tests/test_identity_archive.py @@ -0,0 +1,72 @@ +"""Tests for preserving exact firmware bytes under pre-build provenance folders.""" +import json +from pathlib import Path +import sys +import tempfile +import unittest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools")) +from identity import archive_build, build_folder_name, verify + + +class ArchiveTests(unittest.TestCase): + def metadata(self, stamp="2026-10-08T12:30:00+00:00"): + return {"source": {"commit": "a1b2c3d4" + "0" * 32, "dirty": False}, + "templates": {"commit": None, "dirty": None}, + "build_id": "NOGIT000", "built_at_utc": stamp, + "version": [1, 2, 3]} + + def test_snapshot_preserves_image_and_records_exact_sha(self): + with tempfile.TemporaryDirectory() as root: + root = Path(root) + image = root / "firmware.bin" + payload = bytes(range(256)) + image.write_bytes(payload) + metadata = root / "metadata.json" + metadata.write_text(json.dumps(self.metadata()), encoding="utf-8") + result = archive_build(image, metadata, root / "out") + folder = Path(result["directory"]) + self.assertEqual(folder.name, "a1b2c3d4_2026-10-08_12-30-00_UTC") + self.assertEqual((folder / image.name).read_bytes(), payload) + self.assertEqual(image.read_bytes(), payload) + self.assertEqual(json.loads((folder / "firmware_identity.json").read_text()), self.metadata()) + manifest = folder / (image.name + ".manifest.json") + self.assertEqual(verify(folder / image.name, manifest)["size_bytes"], 256) + (folder / image.name).write_bytes(b"corrupted") + with self.assertRaises(ValueError): + verify(folder / image.name, manifest) + + def test_prebuild_times_keep_two_builds_separate(self): + with tempfile.TemporaryDirectory() as root: + root = Path(root) + image, metadata = root / "firmware.hex", root / "metadata.json" + image.write_bytes(b":00000001FF\r\n") + folders = [] + for stamp in ("2026-10-08T12:30:00+00:00", "2026-10-08T12:31:00+00:00"): + metadata.write_text(json.dumps(self.metadata(stamp)), encoding="utf-8") + folders.append(Path(archive_build(image, metadata, root / "out")["directory"])) + self.assertNotEqual(folders[0], folders[1]) + self.assertTrue(all((folder / image.name).is_file() for folder in folders)) + + def test_offset_normalizes_to_utc_and_dirty_is_visible(self): + metadata = self.metadata("2026-10-08T15:30:00+03:00") + metadata["source"]["dirty"] = True + self.assertEqual(build_folder_name(metadata), "a1b2c3d4+_2026-10-08_12-30-00_UTC") + + def test_archive_without_git_has_explicit_placeholder(self): + metadata = self.metadata() + metadata["source"]["commit"] = None + self.assertEqual(build_folder_name(metadata), "NOGIT000_2026-10-08_12-30-00_UTC") + + def test_invalid_provenance_cannot_be_a_folder_name(self): + metadata = self.metadata() + metadata["source"]["commit"] = "../outside" + with self.assertRaises(ValueError): + build_folder_name(metadata) + metadata = self.metadata("2026-10-08T12:30:00") + with self.assertRaises(ValueError): + build_folder_name(metadata) + + +if __name__ == "__main__": + unittest.main() diff --git a/c/firmware-info/tools/c2000_postbuild.py b/c/firmware-info/tools/c2000_postbuild.py index 4c9987f..3984452 100644 --- a/c/firmware-info/tools/c2000_postbuild.py +++ b/c/firmware-info/tools/c2000_postbuild.py @@ -12,7 +12,7 @@ import re import subprocess import tempfile -from identity import seal, verify +from identity import archive_build, seal, verify def postbuild(compiler: Path, build_dir: Path, artifact: str, @@ -50,6 +50,10 @@ def postbuild(compiler: Path, build_dir: Path, artifact: str, for item in stage.iterdir(): item.replace(output / item.name) result = output / bin_image.name + archive_build(result, metadata, output, ( + output / hex_image.name, + output / (hex_image.name + ".map"), + output / (bin_image.name + ".map"))) print(f"[firmware-build] BIN: {result}") print(f"[firmware-build] Manifest: {result}.manifest.json") print(f"[firmware-build] SHA-256: {result}.sha256") diff --git a/c/firmware-info/tools/identity.py b/c/firmware-info/tools/identity.py index 8a4c072..954a48c 100644 --- a/c/firmware-info/tools/identity.py +++ b/c/firmware-info/tools/identity.py @@ -11,6 +11,9 @@ from datetime import datetime, timezone import hashlib import json import os +import re +import shutil +import tempfile from pathlib import Path import subprocess @@ -104,6 +107,56 @@ def verify(image: Path, manifest: Path) -> dict: return metadata +def build_folder_name(metadata: dict) -> str: + """Use the firmware commit and pre-build UTC time, never post-build time.""" + source = metadata.get("source") or {} + dependency = metadata.get("templates") or {} + commit = source.get("commit") + if commit is not None: + if not isinstance(commit, str) or not re.fullmatch(r"[0-9a-fA-F]{40,64}", commit): + raise ValueError("invalid firmware commit in pre-build metadata") + identity = commit[:8].lower() + if source.get("dirty") or dependency.get("dirty"): + identity += "+" + else: + identity = metadata.get("build_id", "NOGIT000") + if not isinstance(identity, str) or not re.fullmatch(r"[0-9a-fA-F]{8}|[0-9a-fA-F]{7}\+|NOGIT000", identity): + raise ValueError("invalid build ID in pre-build metadata") + stamp_text = metadata.get("built_at_utc") + if not isinstance(stamp_text, str): + raise ValueError("missing pre-build UTC timestamp") + stamp = datetime.fromisoformat(stamp_text.replace("Z", "+00:00")) + if stamp.tzinfo is None: + raise ValueError("pre-build timestamp must include a timezone") + stamp = stamp.astimezone(timezone.utc) + return identity + "_" + stamp.strftime("%Y-%m-%d_%H-%M-%S_UTC") + + +def archive_build(image: Path, metadata_path: Path, output: Path = None, + extra_files=()) -> dict: + """Keep a byte-identical build snapshot, alongside the existing flat output.""" + image, metadata_path = image.resolve(), metadata_path.resolve() + metadata = json.loads(metadata_path.read_text(encoding="utf-8")) + name = build_folder_name(metadata) + output = (output if output is not None else image.parent / "firmware").resolve() + output.mkdir(parents=True, exist_ok=True) + destination = output / name + # Seal and verify staged copies before publishing files in the build folder. + with tempfile.TemporaryDirectory(prefix=".archive-", dir=output) as temporary: + stage = Path(temporary) + shutil.copy2(image, stage / image.name) + for file in extra_files: + file = Path(file) + shutil.copy2(file, stage / file.name) + shutil.copy2(metadata_path, stage / "firmware_identity.json") + seal(stage / image.name, stage / "firmware_identity.json") + verify(stage / image.name, stage / (image.name + ".manifest.json")) + destination.mkdir(parents=True, exist_ok=True) + for file in stage.iterdir(): + file.replace(destination / file.name) + print(f"[firmware-build] Folder: {destination}") + return {"directory": str(destination), "file": image.name} + def main(): parser = argparse.ArgumentParser(description=__doc__) sub = parser.add_subparsers(dest="command", required=True) @@ -116,9 +169,13 @@ def main(): p = sub.add_parser("verify") p.add_argument("--image", type=Path, required=True) p.add_argument("--manifest", type=Path, required=True) + p = sub.add_parser("archive") + p.add_argument("--image", type=Path, required=True) + p.add_argument("--metadata", dest="metadata_path", type=Path, required=True) + p.add_argument("--output", type=Path) args = vars(parser.parse_args()) command = args.pop("command") - result = {"prepare": prepare, "seal": seal, "verify": verify}[command](**args) + result = {"prepare": prepare, "seal": seal, "verify": verify, "archive": archive_build}[command](**args) print(json.dumps(result, ensure_ascii=False)) diff --git a/tools/firmware-build/README.md b/tools/firmware-build/README.md index 5c7cf7f..7a17e72 100644 --- a/tools/firmware-build/README.md +++ b/tools/firmware-build/README.md @@ -54,8 +54,15 @@ Debug/ MyFirmware_BUILDID.bin MyFirmware_BUILDID.bin.manifest.json MyFirmware_BUILDID.bin.sha256 + HASH_YYYY-MM-DD_HH-MM-SS_UTC/ + MyFirmware.hex + MyFirmware_BUILDID.bin + MyFirmware_BUILDID.bin.manifest.json + MyFirmware_BUILDID.bin.sha256 + firmware_identity.json ``` +Postcompile автоматически создаёт папку `HASH_YYYY-MM-DD_HH-MM-SS_UTC` для каждой сборки. HASH — первые 8 символов commit прошивки из pre-build JSON; при незакоммиченных изменениях добавляется `+`. Дата и время берутся из `built_at_utc`, в UTC. Если commit исходников неизвестен, используется build ID или NOGIT000. Например: `f5c3654e+_2026-10-08_12-30-00_UTC`. Сохраняются точные байты образа, manifest, SHA-256 и firmware_identity.json; в CCS также HEX и map. Повтор postcompile с теми же метаданными обновляет ту же папку. Файлы непосредственно в firmware сохраняются, поэтому существующие команды копирования продолжают работать. Добавьте generated и firmware в игнорируемые Git-каталоги. Обычно вся папка Debug уже игнорируется. Полный Rebuild обязателен для выпуска. BAT сами по себе не подтверждают, что OUT собран именно с этим заголовком. ### 4. Копирование результата в каталог SETGUI @@ -74,6 +81,13 @@ cmd /c call "K:\git_project\newProject\newTEMPL\templates\tools\firmware-build\c В поле CCS вся команда должна быть одной строкой. Папка назначения должна существовать. `&&` запускает следующую операцию только при успешном завершении предыдущей. Маски копируют все соответствующие файлы из каталога firmware, включая результаты прошлых сборок; `/Y` разрешает замену одноимённых файлов. Это локальное копирование, без публикации на сервер. +Чтобы скопировать также папки HASH_DATE вместе с их содержимым, вместо трёх команд copy используйте одну команду xcopy в конце post-build: + +```bat +&& xcopy /E /I /Y "${BuildDirectory}\firmware" "K:\git_project\newProject\SETGUI\..periph_28335\firmware" +``` + +Она копирует весь каталог результатов, включая папки предыдущих сборок; при необходимости создаёт папку назначения. В поле CCS добавляйте её к основной команде в той же строке. ### Версия внутри устройства Это подготовка метаданных сборки и файла. Чтобы версия присутствовала в программе и ответах устройства, подключите заголовок `firmware_identity.h` к модулю версии и обработчику запроса. Include path: `${BuildDirectory}/generated`. Для C2000 с 16-битным char нужна адаптация байтового контракта; обычный 8-битный порт нельзя считать готовым. Смотрите [PORTING.md](../../c/firmware-info/PORTING.md). @@ -134,9 +148,15 @@ cmd /c call "C:\Projects\templates\tools\firmware-build\keil\postcompile.bat" -- Firmware.hex Firmware.hex.manifest.json Firmware.hex.sha256 +firmware/ + HASH_YYYY-MM-DD_HH-MM-SS_UTC/ + Firmware.hex + Firmware.hex.manifest.json + Firmware.hex.sha256 + firmware_identity.json ``` -При успехе Console содержит строки `[firmware-build] Image`, `Manifest`, `SHA-256`. Для готового BIN вместо `#H` передайте его фактический путь; конвертацию AXF в BIN нужно выполнить до postcompile. Сгенерированные файлы исключите через .gitignore. +Папка HASH_DATE создаётся в firmware рядом с исходным HEX/BIN. Для неё используются commit и UTC-время из precompile, как в CCS. При успехе Console содержит строки `[firmware-build] Folder`, `Image`, `Manifest`, `SHA-256`. Для готового BIN вместо `#H` передайте его фактический путь; конвертацию AXF в BIN нужно выполнить до postcompile. Сгенерированные файлы исключите через .gitignore. ## Совместимость со старыми проектами diff --git a/tools/firmware-build/keil/postcompile.bat b/tools/firmware-build/keil/postcompile.bat index de1e9df..5068a01 100644 --- a/tools/firmware-build/keil/postcompile.bat +++ b/tools/firmware-build/keil/postcompile.bat @@ -22,6 +22,8 @@ python "%~dp0..\..\..\c\firmware-info\tools\identity.py" seal --image "%~f2" --m if errorlevel 1 exit /b %errorlevel% python "%~dp0..\..\..\c\firmware-info\tools\identity.py" verify --image "%~f2" --manifest "%~f2.manifest.json" if errorlevel 1 exit /b %errorlevel% +python "%~dp0..\..\..\c\firmware-info\tools\identity.py" archive --image "%~f2" --metadata "%~f3" +if errorlevel 1 exit /b %errorlevel% echo [firmware-build] Image: "%~f2" echo [firmware-build] Manifest: "%~f2.manifest.json" echo [firmware-build] SHA-256: "%~f2.sha256"