diff --git a/firmware/CMakeLists.txt b/firmware/CMakeLists.txt index aa5b630..1755b25 100644 --- a/firmware/CMakeLists.txt +++ b/firmware/CMakeLists.txt @@ -7,4 +7,4 @@ option( ) include($ENV{IDF_PATH}/tools/cmake/project.cmake) -project(m5authenticator VERSION 0.1.0) +project(m5authenticator VERSION 1.0.0) diff --git a/firmware/components/m5auth_core/include/m5auth/core/metadata.hpp b/firmware/components/m5auth_core/include/m5auth/core/metadata.hpp index 1feaaef..02e7d3b 100644 --- a/firmware/components/m5auth_core/include/m5auth/core/metadata.hpp +++ b/firmware/components/m5auth_core/include/m5auth/core/metadata.hpp @@ -2,7 +2,7 @@ namespace m5auth::core { -inline constexpr char kFirmwareVersion[] = "0.1.0"; +inline constexpr char kFirmwareVersion[] = "1.0.0"; inline constexpr int kProtocolVersion = 2; inline constexpr int kStorageSchemaVersion = 2; inline constexpr int kVaultFormatVersion = 1; diff --git a/firmware/release-profile.json b/firmware/release-profile.json index 6aeae96..833279f 100644 --- a/firmware/release-profile.json +++ b/firmware/release-profile.json @@ -3,7 +3,7 @@ "device": "M5StickS3", "chip_family": "ESP32-S3", "flash_size_bytes": 8388608, - "firmware_version": "0.1.0", + "firmware_version": "1.0.0", "protocol_version": 2, "storage_schema_version": 2, "vault_format_version": 1, diff --git a/scripts/validate_release.py b/scripts/validate_release.py index b709100..3714665 100644 --- a/scripts/validate_release.py +++ b/scripts/validate_release.py @@ -13,6 +13,7 @@ REPO_ROOT = Path(__file__).resolve().parents[1] DEFAULT_PROFILE = REPO_ROOT / "firmware" / "release-profile.json" DEFAULT_METADATA = REPO_ROOT / "firmware" / "components" / "m5auth_core" / "include" / "m5auth" / "core" / "metadata.hpp" +DEFAULT_PROJECT_CMAKE = REPO_ROOT / "firmware" / "CMakeLists.txt" DEFAULT_PARTITIONS = REPO_ROOT / "firmware" / "partitions.csv" DEFAULT_BOOTSTRAP = REPO_ROOT / "firmware" / "main" / "app_main.cpp" DEFAULT_SDKCONFIG = REPO_ROOT / "firmware" / "sdkconfig.defaults" @@ -75,6 +76,108 @@ def parse_metadata(path: Path = DEFAULT_METADATA) -> dict[str, Any]: } +def _cmake_bracket_delimiter(source: str, start: int) -> tuple[str, int] | None: + if start >= len(source) or source[start] != "[": + return None + index = start + 1 + while index < len(source) and source[index] == "=": + index += 1 + if index >= len(source) or source[index] != "[": + return None + equals = source[start + 1 : index] + return "]" + equals + "]", index + 1 + + +def _sanitize_cmake_inactive_text(source: str) -> str: + sanitized: list[str] = [] + index = 0 + + def append_inactive(text: str) -> None: + sanitized.extend("\n" if character == "\n" else " " for character in text) + + while index < len(source): + character = source[index] + + if character == "#": + bracket = _cmake_bracket_delimiter(source, index + 1) + if bracket is not None: + closing, content_start = bracket + close_index = source.find(closing, content_start) + _require(close_index >= 0, "unterminated CMake bracket comment") + end = close_index + len(closing) + append_inactive(source[index:end]) + index = end + continue + + line_end = source.find("\n", index) + if line_end < 0: + append_inactive(source[index:]) + break + append_inactive(source[index:line_end]) + index = line_end + continue + + if character == '"': + end = index + 1 + escaped = False + while end < len(source): + current = source[end] + if current == '"' and not escaped: + end += 1 + break + if current == "\\" and not escaped: + escaped = True + else: + escaped = False + end += 1 + _require(end <= len(source) and source[end - 1] == '"', "unterminated CMake quoted string") + append_inactive(source[index:end]) + index = end + continue + + bracket = _cmake_bracket_delimiter(source, index) + if bracket is not None: + closing, content_start = bracket + close_index = source.find(closing, content_start) + _require(close_index >= 0, "unterminated CMake bracket argument") + end = close_index + len(closing) + append_inactive(source[index:end]) + index = end + continue + + sanitized.append(character) + index += 1 + + return "".join(sanitized) + + +def parse_cmake_project_version(path: Path = DEFAULT_PROJECT_CMAKE) -> str: + source = _sanitize_cmake_inactive_text(_read_text(path, "firmware project CMake")) + projects = list( + re.finditer( + r"^[ \t]*project\s*\(\s*m5authenticator\b(?P[^)]*)\)[ \t]*$", + source, + re.IGNORECASE | re.MULTILINE, + ) + ) + _require(len(projects) == 1, "exactly one active m5authenticator CMake project declaration is required") + project = projects[0] + + version_tokens = re.findall( + r"\bVERSION\s+([^\s)]+)", + project.group("body"), + re.IGNORECASE, + ) + _require(len(version_tokens) == 1, "m5authenticator CMake project VERSION must be exactly X.Y.Z") + + version = version_tokens[0] + _require( + re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", version) is not None, + "m5authenticator CMake project VERSION must be exactly X.Y.Z", + ) + return version + + def parse_partitions(path: Path = DEFAULT_PARTITIONS) -> dict[str, dict[str, int | str]]: partitions: dict[str, dict[str, int | str]] = {} try: @@ -262,9 +365,11 @@ def validate_release( sdkconfig_path: Path = DEFAULT_SDKCONFIG, transport_header_path: Path = DEFAULT_TRANSPORT_HEADER, transport_cpp_path: Path = DEFAULT_TRANSPORT_CPP, + project_cmake_path: Path = DEFAULT_PROJECT_CMAKE, ) -> dict[str, Any]: profile = load_profile(profile_path) metadata = parse_metadata(metadata_path) + project_version = parse_cmake_project_version(project_cmake_path) partitions = parse_partitions(partitions_path) _require(profile.get("format") == 2, "unsupported release profile format") @@ -275,6 +380,15 @@ def validate_release( for key in ("firmware_version", "protocol_version", "storage_schema_version", "vault_format_version"): _require(profile.get(key) == metadata[key], f"{key} does not match firmware metadata") + _require( + project_version == metadata["firmware_version"], + "CMake project version does not match firmware metadata", + ) + _require( + project_version == profile.get("firmware_version"), + "CMake project version does not match release profile", + ) + _require(profile.get("protocol_version") == 2, "V1 production contract requires Protocol 2") _require(profile.get("storage_schema_version") == 2, "V1 production contract requires Storage Schema 2") _require(profile.get("vault_format_version") == 1, "V1 production contract requires Vault Format 1") @@ -334,6 +448,7 @@ def validate_release( return { "profile": profile, "metadata": metadata, + "project_version": project_version, "partitions": partitions, } @@ -342,17 +457,19 @@ def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--profile", type=Path, default=DEFAULT_PROFILE) parser.add_argument("--metadata", type=Path, default=DEFAULT_METADATA) + parser.add_argument("--project-cmake", type=Path, default=DEFAULT_PROJECT_CMAKE) parser.add_argument("--partitions", type=Path, default=DEFAULT_PARTITIONS) parser.add_argument("--bootstrap", type=Path, default=DEFAULT_BOOTSTRAP) parser.add_argument("--require-production", action="store_true") args = parser.parse_args() try: result = validate_release( - args.profile, - args.metadata, - args.partitions, - args.require_production, - args.bootstrap, + profile_path=args.profile, + metadata_path=args.metadata, + partitions_path=args.partitions, + require_production=args.require_production, + bootstrap_path=args.bootstrap, + project_cmake_path=args.project_cmake, ) except ReleaseValidationError as exc: print(f"release validation failed: {exc}") diff --git a/tests/release_package_test.py b/tests/release_package_test.py index 1ef1dd0..0125cd6 100644 --- a/tests/release_package_test.py +++ b/tests/release_package_test.py @@ -2,6 +2,7 @@ from __future__ import annotations import json +import subprocess import sys import tempfile import unittest @@ -53,6 +54,217 @@ def test_repository_release_profile_is_v1_contract_and_production_eligible(self) self.assertEqual(profile["post_update_state"], "locked") self.assertTrue(profile["production_release_allowed"]) + def test_product_version_sources_are_exactly_1_0_0(self) -> None: + result = validate_release.validate_release(require_production=True) + self.assertEqual(result["project_version"], "1.0.0") + self.assertEqual(result["metadata"]["firmware_version"], "1.0.0") + self.assertEqual(result["profile"]["firmware_version"], "1.0.0") + + def test_remaining_0_1_0_literals_are_only_classified_noncanonical_values(self) -> None: + legacy_version = "0." + "1.0" + private_npm_metadata = { + "web/package.json", + "web/package-lock.json", + } + historical_compatibility_references = { + "firmware/components/m5auth_vault/include/m5auth/vault.hpp", + "tests/vault_interop_test.cpp", + } + + completed = subprocess.run( + ["git", "ls-files", "-z"], + cwd=REPO_ROOT, + check=True, + stdout=subprocess.PIPE, + ) + classified: dict[str, list[str]] = { + "private npm package metadata": [], + "test/smoke fixture": [], + "historical compatibility reference": [], + } + unexpected: list[str] = [] + + for raw_path in completed.stdout.split(b"\0"): + if not raw_path: + continue + relative = raw_path.decode("utf-8") + path = REPO_ROOT / relative + try: + source = path.read_text(encoding="utf-8") + except UnicodeDecodeError: + continue + + for line_number, line in enumerate(source.splitlines(), start=1): + if legacy_version not in line: + continue + evidence = f"{relative}:{line_number}: {line.strip()}" + + if relative in private_npm_metadata: + classified["private npm package metadata"].append(evidence) + continue + + if relative in historical_compatibility_references: + self.assertIn("v" + legacy_version, line) + self.assertTrue(line.lstrip().startswith("//")) + classified["historical compatibility reference"].append(evidence) + continue + + is_web_unit_fixture = relative.startswith("web/src/") and relative.endswith(".test.ts") + is_browser_smoke_fixture = relative.startswith("web/tests/browser/") and relative.endswith(".ts") + if relative == "web/vite.config.ts" or is_web_unit_fixture or is_browser_smoke_fixture: + classified["test/smoke fixture"].append(evidence) + continue + + unexpected.append(evidence) + + self.assertFalse( + unexpected, + "unexpected current legacy version literal outside classified noncanonical sources:\n" + + "\n".join(unexpected), + ) + self.assertTrue(classified["private npm package metadata"]) + self.assertTrue(classified["test/smoke fixture"]) + self.assertTrue(classified["historical compatibility reference"]) + + canonical_sources = { + "firmware/CMakeLists.txt", + "firmware/components/m5auth_core/include/m5auth/core/metadata.hpp", + "firmware/release-profile.json", + } + all_classified = "\n".join(item for values in classified.values() for item in values) + for canonical in canonical_sources: + self.assertNotIn(canonical + ":", all_classified) + + def test_release_version_consistency_rejects_each_source_divergence(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + + cmake_path = root / "CMakeLists.txt" + cmake_path.write_text( + validate_release.DEFAULT_PROJECT_CMAKE.read_text(encoding="utf-8").replace( + "VERSION 1.0.0", + "VERSION 9.9.9", + ), + encoding="utf-8", + ) + with self.assertRaisesRegex( + validate_release.ReleaseValidationError, + "CMake project version does not match firmware metadata", + ): + validate_release.validate_release(project_cmake_path=cmake_path) + + metadata_path = root / "metadata.hpp" + metadata_path.write_text( + validate_release.DEFAULT_METADATA.read_text(encoding="utf-8").replace( + 'kFirmwareVersion[] = "1.0.0"', + 'kFirmwareVersion[] = "9.9.9"', + ), + encoding="utf-8", + ) + with self.assertRaisesRegex( + validate_release.ReleaseValidationError, + "firmware_version does not match firmware metadata", + ): + validate_release.validate_release(metadata_path=metadata_path) + + profile = validate_release.load_profile() + profile["firmware_version"] = "9.9.9" + profile_path = root / "profile.json" + profile_path.write_text(json.dumps(profile), encoding="utf-8") + with self.assertRaisesRegex( + validate_release.ReleaseValidationError, + "firmware_version does not match firmware metadata", + ): + validate_release.validate_release(profile_path=profile_path) + + def test_cmake_product_version_parser_rejects_missing_or_malformed_version(self) -> None: + invalid_sources = ( + "cmake_minimum_required(VERSION 3.16)\nproject(m5authenticator)\n", + "cmake_minimum_required(VERSION 3.16)\nproject(m5authenticator VERSION 1.0)\n", + "cmake_minimum_required(VERSION 3.16)\nproject(m5authenticator VERSION 1.0.0.1)\n", + "cmake_minimum_required(VERSION 3.16)\nproject(m5authenticator VERSION 1.0.0-beta)\n", + "cmake_minimum_required(VERSION 3.16)\nproject(other VERSION 1.0.0)\n", + ) + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + for index, source in enumerate(invalid_sources): + with self.subTest(source=source): + cmake_path = root / f"CMakeLists-{index}.txt" + cmake_path.write_text(source, encoding="utf-8") + with self.assertRaises(validate_release.ReleaseValidationError): + validate_release.parse_cmake_project_version(cmake_path) + + def test_non_x_y_z_cmake_version_fails_both_validation_paths(self) -> None: + invalid_versions = ("1.0.0.1", "1.0.0-beta") + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + for version in invalid_versions: + cmake_path = root / ("CMakeLists-" + version.replace(".", "_") + ".txt") + cmake_path.write_text( + f"cmake_minimum_required(VERSION 3.16)\n" + f"project(m5authenticator VERSION {version})\n", + encoding="utf-8", + ) + for require_production in (False, True): + with self.subTest(version=version, require_production=require_production): + with self.assertRaisesRegex( + validate_release.ReleaseValidationError, + "VERSION must be exactly X.Y.Z", + ): + validate_release.validate_release( + project_cmake_path=cmake_path, + require_production=require_production, + ) + + def test_inactive_cmake_project_examples_cannot_mask_active_version(self) -> None: + cases = ( + ( + "# project(m5authenticator VERSION 1.0.0)\n" + "project(m5authenticator VERSION 1.0.0.1)\n", + "VERSION must be exactly X.Y.Z", + ), + ( + "# project(m5authenticator VERSION 1.0.0)\n" + "project(m5authenticator VERSION 9.9.9)\n", + "CMake project version does not match firmware metadata", + ), + ( + 'set(EXAMPLE "project(m5authenticator VERSION 1.0.0)")\n' + "project(m5authenticator VERSION 9.9.9)\n", + "CMake project version does not match firmware metadata", + ), + ( + "#[[\n" + "project(m5authenticator VERSION 1.0.0)\n" + "]]\n" + "project(m5authenticator VERSION 9.9.9)\n", + "CMake project version does not match firmware metadata", + ), + ( + "set(EXAMPLE [[project(m5authenticator VERSION 1.0.0)]])\n" + "project(m5authenticator VERSION 9.9.9)\n", + "CMake project version does not match firmware metadata", + ), + ) + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + for index, (source, expected_error) in enumerate(cases): + cmake_path = root / f"CMakeLists-mask-{index}.txt" + cmake_path.write_text(source, encoding="utf-8") + for require_production in (False, True): + with self.subTest( + source=source, + require_production=require_production, + ): + with self.assertRaisesRegex( + validate_release.ReleaseValidationError, + expected_error, + ): + validate_release.validate_release( + project_cmake_path=cmake_path, + require_production=require_production, + ) + def test_production_validation_fails_closed_when_eligibility_is_disabled(self) -> None: with tempfile.TemporaryDirectory() as directory: profile = validate_release.load_profile() @@ -125,6 +337,10 @@ def test_package_metadata_is_secret_free_and_update_is_partition_aware(self) -> self.assertIn("update-manifest-abcdef123456.json", names) self.assertIn("firmware-target.json", names) self.assertIn("SHA256SUMS", names) + self.assertIn("m5authenticator-v1.0.0-abcdef123456-m5sticks3.bin", names) + self.assertIn("m5authenticator-v1.0.0-abcdef123456-m5sticks3-update-bootloader.bin", names) + self.assertIn("m5authenticator-v1.0.0-abcdef123456-m5sticks3-update-partition-table.bin", names) + self.assertIn("m5authenticator-v1.0.0-abcdef123456-m5sticks3-update-ota0.bin", names) self.assertFalse(any(name.endswith("-m5burner.zip") for name in names)) factory = json.loads((root / "out" / "factory-manifest.json").read_text()) @@ -140,6 +356,9 @@ def test_package_metadata_is_secret_free_and_update_is_partition_aware(self) -> self.assertEqual(factory["name"], "M5Authenticator") self.assertEqual(update["name"], "M5Authenticator") self.assertEqual(factory["version"], update["version"]) + self.assertEqual(factory["version"], "1.0.0") + self.assertEqual(update["version"], "1.0.0") + self.assertEqual(target["version"], "1.0.0") self.assertEqual(factory["build_commit"], "abcdef123456") self.assertEqual(update["build_commit"], "abcdef123456") self.assertFalse(factory["exact_release"]) @@ -171,6 +390,7 @@ def test_package_metadata_is_secret_free_and_update_is_partition_aware(self) -> metadata = json.loads((root / "out" / "release-metadata.json").read_text()) self.assertEqual(metadata["format"], 2) + self.assertEqual(metadata["firmware_version"], "1.0.0") self.assertEqual(metadata["protocol_version"], 2) self.assertEqual(metadata["storage_schema_version"], 2) self.assertEqual(metadata["vault_format_version"], 1)