feat(firmware-info): архивируй сборки по хешу и дате

Создай в postcompile CCS и Keil папку по commit исходников и UTC-времени precompile. Сохрани точные образы, manifest, SHA-256 и метаданные, сохраняя прежние выходные файлы. Опиши копирование каталогов в SETGUI и проверь разные даты, целостность и Git-метаданные.
This commit is contained in:
2026-10-08 15:31:13 +03:00
parent 8fc2e1b5cf
commit da055c2787
5 changed files with 158 additions and 3 deletions

View File

@@ -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()

View File

@@ -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")

View File

@@ -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))

File diff suppressed because one or more lines are too long

View File

@@ -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"