Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion firmware/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion firmware/release-profile.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
127 changes: 122 additions & 5 deletions scripts/validate_release.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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<body>[^)]*)\)[ \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:
Expand Down Expand Up @@ -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")
Expand All @@ -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")
Expand Down Expand Up @@ -334,6 +448,7 @@ def validate_release(
return {
"profile": profile,
"metadata": metadata,
"project_version": project_version,
"partitions": partitions,
}

Expand All @@ -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}")
Expand Down
Loading
Loading