From e368b52f7dd936af256ecf903e1c71516a2a24ae Mon Sep 17 00:00:00 2001 From: mikecarper Date: Sat, 3 Oct 2026 20:48:49 -0700 Subject: [PATCH] Bind resumed firmware packages to their effective build recipe --- .github/workflows/run-unit-tests.yml | 1 + build_legacy.sh | 122 +++- scripts/firmware_build_recipe.py | 264 ++++++++ test/test_firmware_build_recipe.py | 684 +++++++++++++++++++++ test/test_full_infrastructure_selection.sh | 36 +- 5 files changed, 1092 insertions(+), 15 deletions(-) create mode 100644 scripts/firmware_build_recipe.py create mode 100644 test/test_firmware_build_recipe.py diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index ec883df67..0dff20bef 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -296,6 +296,7 @@ jobs: python3 -B test/test_full_build_flag_dedup.py python3 -B test/test_full_logging_compile.py python3 -B test/test_full_logging_resume.py + python3 -B test/test_firmware_build_recipe.py -v python3 -B test/test_firmware_capabilities.py MESHCORE_TEST_REAL_PIO_PATHS=1 python3 -B test/test_esp32_partition_migration_build_dir.py -v bash test/test_build_profiles.sh diff --git a/build_legacy.sh b/build_legacy.sh index 12973fb34..aa10a2ab1 100755 --- a/build_legacy.sh +++ b/build_legacy.sh @@ -107,7 +107,7 @@ FALLBACK_VERSION_PREFIX="dev" FALLBACK_VERSION_DATE_FORMAT='+%Y-%m-%d-%H-%M' # External programs invoked by this script: -# bash, cat, cp, date, env, find, flock, git, grep, head, mkdir, mv, pgrep, +# bash, cat, cp, date, env, find, flock, git, grep, head, mkdir, mktemp, mv, pgrep, # pio, python3, rm, sed, sleep, sort, systemctl, systemd-run, tee, wc # Keep this list in sync when adding or removing non-builtin command usage. @@ -4332,7 +4332,10 @@ write_build_capability_manifest() { checker_args+=(--expect-application "$item") done - python3 scripts/check_firmware_capabilities.py "${checker_args[@]}" + python3 scripts/check_firmware_capabilities.py "${checker_args[@]}" || return $? + python3 scripts/firmware_build_recipe.py attach \ + "${OUTPUT_DIR}/${firmware_filename}.capabilities.json" \ + "$BUILD_RECIPE_SHA256" } collect_esp32_artifacts() { @@ -4414,6 +4417,26 @@ output_artifact_exists() { [ -s "${OUTPUT_DIR}/$1" ] } +compute_build_recipe_digest() { + local env_name=$1 env_platform=$2 pio_env_name=$3 embedded_version=$4 + local original_flags=$5 full_source_commit=$6 artifact_target checkout_dir + artifact_target=${FIRMWARE_OUTPUT_ENV_NAME:-$env_name} + if [ -n "${NRF52_OTA_SENSOR_PROFILE:-}" ] \ + && is_nrf52_sensor_ota_pair_target "$env_name"; then + artifact_target+="-${NRF52_OTA_SENSOR_PROFILE}-ota" + fi + checkout_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P) || return 1 + MESHCORE_RECIPE_ORIGINAL_FLAGS="$original_flags" \ + python3 "${checkout_dir}/scripts/firmware_build_recipe.py" digest \ + --root "$checkout_dir" --output "$OUTPUT_DIR" \ + --source-commit "$full_source_commit" \ + --target "$env_name" --artifact-target "$artifact_target" \ + --pio-env "$pio_env_name" --platform "$env_platform" \ + --embedded-version "$embedded_version" --profile "$BUILD_PROFILE_FOR_TARGET" \ + --sensor-profile "${NRF52_OTA_SENSOR_PROFILE:-}" \ + --ota-policy "${REQUIRE_OTA_UPDATES:-}" <<<"$PIO_CONFIG_JSON" +} + nrf52_sensor_profile_manifest_matches() { python3 - "$1" "$2" "$3" "$4" <<'PY' import json, sys @@ -4676,6 +4699,7 @@ build_firmware_one_profile() { local pio_env_name local env_platform local commit_hash + local full_source_commit local firmware_build_date local firmware_build_epoch local firmware_version @@ -4694,6 +4718,9 @@ build_firmware_one_profile() { local had_platformio_build_src_filter=0 local had_platformio_extra_scripts=0 local build_status + local BUILD_RECIPE_SHA256 + local recipe_build_flags + local completed_recipe local platformio_package_lock_fd="" local -a pio_run_args=() local -a BUILD_CAPABILITIES=() @@ -4798,8 +4825,8 @@ build_firmware_one_profile() { # Publication tools bind artifacts to an explicit eight-character prefix # of the full source commit. Git's adaptive --short length can grow (or be # configured differently), which otherwise makes valid releases look stale. - commit_hash=$(git rev-parse HEAD) - commit_hash=${commit_hash:0:8} + full_source_commit=$(git rev-parse HEAD) || return 1 + commit_hash=${full_source_commit:0:8} firmware_build_date=$(date -u '+%d-%b-%Y') firmware_build_epoch=$(date -u '+%s') firmware_version=${FIRMWARE_VERSION:-} @@ -4845,11 +4872,6 @@ build_firmware_one_profile() { local embedded_version_string="${firmware_version}${embedded_build_suffix}${embedded_variant_tag}-${commit_hash}" declare_full_logging_application_contract "$env_name" - if [ "$RESUME_BUILD_OUTPUT" == "1" ] && build_artifacts_exist "$env_name" "$env_platform" "$firmware_filename"; then - echo "Skipping ${env_name}; existing artifacts found for ${firmware_filename}." - return 0 - fi - if [ "${PLATFORMIO_BUILD_FLAGS+x}" ]; then had_platformio_build_flags=1 original_platformio_build_flags=$PLATFORMIO_BUILD_FLAGS @@ -4875,7 +4897,23 @@ build_firmware_one_profile() { original_platformio_extra_scripts="" fi - export PLATFORMIO_BUILD_FLAGS="${original_platformio_build_flags} -DFIRMWARE_BUILD_DATE='\"${firmware_build_date}\"' -DFIRMWARE_BUILD_EPOCH=${firmware_build_epoch} -DFIRMWARE_VERSION='\"${embedded_version_string}\"' -DOTA_VARIANT='\"${env_name}\"'${mota_target_flag}${mota_migration_flag}" + # Scope the ambient values and their export attributes to this target. In + # particular, a resume skip/conflict must not leak its computed profile into + # the next target or convert an unset/unexported caller variable to exported. + local PLATFORMIO_BUILD_FLAGS="$original_platformio_build_flags" + local PLATFORMIO_BUILD_UNFLAGS="$original_platformio_build_unflags" + local PLATFORMIO_BUILD_SRC_FILTER="$original_platformio_build_src_filter" + local PLATFORMIO_EXTRA_SCRIPTS="$original_platformio_extra_scripts" + local MESHCORE_ESP32_FULL_BUILD="${MESHCORE_ESP32_FULL_BUILD-}" + local MESHCORE_REQUIRE_PACKET_LOGGING="${MESHCORE_REQUIRE_PACKET_LOGGING-}" + local MESHCORE_COMPANION_RADIO_FULL="${MESHCORE_COMPANION_RADIO_FULL-}" + local MESHCORE_ESP32_FULL_PARTITION_TABLE="${MESHCORE_ESP32_FULL_PARTITION_TABLE-}" + local MESHCORE_NRF52_INTERNAL_BOOTLOADER_UPDATE="${MESHCORE_NRF52_INTERNAL_BOOTLOADER_UPDATE-}" + local MESHCORE_FORCE_LORA_OTA="${MESHCORE_FORCE_LORA_OTA-}" + + # Only builder-generated wall-clock stamps are excluded from the recipe. + # User-supplied flags remain verbatim, including any timestamp definitions. + export PLATFORMIO_BUILD_FLAGS="${original_platformio_build_flags} -DFIRMWARE_VERSION='\"${embedded_version_string}\"' -DOTA_VARIANT='\"${env_name}\"'${mota_target_flag}${mota_migration_flag}" disable_debug_flags "$env_name" apply_debug_overrides "$env_name" apply_mqtt_bridge_override "$env_name" @@ -4943,6 +4981,29 @@ build_firmware_one_profile() { append_platformio_extra_script "post:scripts/check_firmware_ram.py" fi + recipe_build_flags=$PLATFORMIO_BUILD_FLAGS + BUILD_RECIPE_SHA256=$(compute_build_recipe_digest "$env_name" "$env_platform" \ + "$pio_env_name" "$embedded_version_string" "$original_platformio_build_flags" \ + "$full_source_commit") || return 1 + if [ "$RESUME_BUILD_OUTPUT" == "1" ]; then + if python3 scripts/firmware_build_recipe.py occupied "${OUTPUT_DIR}/${firmware_filename}"; then + if python3 scripts/firmware_build_recipe.py matches \ + "${OUTPUT_DIR}/${firmware_filename}.capabilities.json" "$BUILD_RECIPE_SHA256" \ + >/dev/null 2>&1 \ + && build_artifacts_exist "$env_name" "$env_platform" "$firmware_filename"; then + echo "Skipping ${env_name}; matching recipe and qualified artifacts found for ${firmware_filename}." + return 0 + fi + echo "Cannot resume ${firmware_filename}: existing package has a missing/different recipe or failed qualification." >&2 + echo "Existing files were preserved. Use a fresh OUTPUT_DIR or a different --firmware-version." >&2 + return 1 + elif [ "$?" -ne 1 ]; then + echo "Cannot inspect the existing package safely; refusing to build over OUTPUT_DIR." >&2 + return 1 + fi + fi + + export PLATFORMIO_BUILD_FLAGS="${recipe_build_flags} -DFIRMWARE_BUILD_DATE='\"${firmware_build_date}\"' -DFIRMWARE_BUILD_EPOCH=${firmware_build_epoch}" print_build_flags "$pio_env_name" "$env_name" build_status=0 if [ "$env_platform" = "ESP32_PLATFORM" ]; then @@ -4978,6 +5039,16 @@ build_firmware_one_profile() { run_pio_with_size_detection "${pio_run_args[@]}" build_status=$? fi + if [ "$build_status" -eq 0 ]; then + completed_recipe=$(PLATFORMIO_BUILD_FLAGS="$recipe_build_flags" \ + compute_build_recipe_digest "$env_name" "$env_platform" "$pio_env_name" \ + "$embedded_version_string" "$original_platformio_build_flags" \ + "$full_source_commit") || build_status=1 + if [ "$build_status" -eq 0 ] && [ "$completed_recipe" != "$BUILD_RECIPE_SHA256" ]; then + echo "Source or build inputs changed during compilation; refusing to publish this package." >&2 + build_status=1 + fi + fi if [ "$build_status" -eq 0 ]; then collect_build_artifacts "$env_name" "$env_platform" "$pio_env_name" "$firmware_filename" build_status=$? @@ -5926,15 +5997,26 @@ run_logged_build_targets() { for env in "${targets[@]}"; do log_path="${log_dir}/${env}-${profile}.log" log_tmp="${log_path}.tmp" + if [ "$RESUME_BUILD_OUTPUT" = "1" ]; then + log_tmp=$(mktemp "${log_path%.log}-resume-attempt.XXXXXX.log") || { + BATCH_BUILD_MODE=$previous_batch_build_mode + return 1 + } + fi preserved_log=0 echo "Building ${env} (${profile}); log: ${log_path}" build_firmware "$env" > "$log_tmp" 2>&1 build_status=$? if [ "$build_status" -eq 0 ] \ - && grep -q "^Skipping ${env}; existing artifacts found" "$log_tmp" \ + && grep -Eq "^Skipping ${env}; (existing artifacts found|matching recipe and qualified artifacts found)" "$log_tmp" \ && [ -s "$log_path" ]; then rm -f -- "$log_tmp" preserved_log=1 + elif [ "$build_status" -ne 0 ] \ + && [ "$RESUME_BUILD_OUTPUT" = "1" ] && [ -s "$log_path" ]; then + # Keep the original compile log. This already-unique attempt file is + # the failure's real diagnostic log, including in summary/report paths. + log_path=$log_tmp else mv -f -- "$log_tmp" "$log_path" fi @@ -5948,7 +6030,7 @@ run_logged_build_targets() { elif [ "$build_status" -ne 0 ]; then overall_status=1 LOGGING_MATRIX_FAILURES+=("${env} (${profile}) -> ${log_path}") - echo "FAILED: ${env} (${profile}), status ${build_status}" + echo "FAILED: ${env} (${profile}), status ${build_status}; log: ${log_path}" echo "FAILED: ${env} (${profile}), status ${build_status}" >> "$log_path" else echo "SUCCEEDED: ${env} (${profile})" @@ -5993,6 +6075,15 @@ run_logged_build_targets() { pio_env_key=$candidate_key log_path="${log_dir}/${env}-${profile}.log" log_tmp="${log_path}.tmp" + if [ "$RESUME_BUILD_OUTPUT" = "1" ]; then + log_tmp=$(mktemp "${log_path%.log}-resume-attempt.XXXXXX.log") || { + for interrupt_pid in "${running_pids[@]}"; do terminate_process_tree "$interrupt_pid"; done + for interrupt_pid in "${running_pids[@]}"; do wait "$interrupt_pid" 2>/dev/null || true; done + BATCH_BUILD_MODE=$previous_batch_build_mode + trap - INT TERM + return 1 + } + fi echo "Building ${env} (${profile}); log: ${log_path}" ( BATCH_BUILD_MODE=1 @@ -6050,10 +6141,13 @@ run_logged_build_targets() { log_tmp=${job_tmp_by_pid[$pid]} preserved_log=0 if [ "$build_status" -eq 0 ] \ - && grep -q "^Skipping ${env}; existing artifacts found" "$log_tmp" \ + && grep -Eq "^Skipping ${env}; (existing artifacts found|matching recipe and qualified artifacts found)" "$log_tmp" \ && [ -s "$log_path" ]; then rm -f -- "$log_tmp" preserved_log=1 + elif [ "$build_status" -ne 0 ] \ + && [ "$RESUME_BUILD_OUTPUT" = "1" ] && [ -s "$log_path" ]; then + log_path=$log_tmp else mv -f -- "$log_tmp" "$log_path" fi @@ -6067,7 +6161,7 @@ run_logged_build_targets() { elif [ "$build_status" -ne 0 ]; then overall_status=1 LOGGING_MATRIX_FAILURES+=("${env} (${profile}) -> ${log_path}") - echo "FAILED: ${env} (${profile}), status ${build_status}" + echo "FAILED: ${env} (${profile}), status ${build_status}; log: ${log_path}" echo "FAILED: ${env} (${profile}), status ${build_status}" >> "$log_path" else echo "SUCCEEDED: ${env} (${profile})" diff --git a/scripts/firmware_build_recipe.py b/scripts/firmware_build_recipe.py new file mode 100644 index 000000000..bdd067216 --- /dev/null +++ b/scripts/firmware_build_recipe.py @@ -0,0 +1,264 @@ +#!/usr/bin/env python3 +"""Bind resumable firmware to its effective inputs without publishing secrets.""" + +import argparse +import configparser +import glob +import hashlib +import json +import os +from pathlib import Path +import re +import stat +import subprocess +import sys + +SCHEMA = 1 +RECIPE_ENVIRONMENT = ( + "PLATFORMIO_BUILD_FLAGS", "PLATFORMIO_BUILD_UNFLAGS", + "PLATFORMIO_BUILD_SRC_FILTER", "PLATFORMIO_EXTRA_SCRIPTS", + "MESHCORE_ESP32_FULL_BUILD", "MESHCORE_REQUIRE_PACKET_LOGGING", + "MESHCORE_COMPANION_RADIO_FULL", "MESHCORE_ESP32_FULL_PARTITION_TABLE", + "MESHCORE_NRF52_INTERNAL_BOOTLOADER_UPDATE", "MESHCORE_FORCE_LORA_OTA", + "MESHCORE_NRF52_LEGACY_FLASH_OTA", "MESHCORE_REDUCED_TLS", +) +PACKAGE_SUFFIXES = (".bin", "-merged.bin", ".uf2", ".zip", ".hex", + ".capabilities.json", ".memory.json", ".font-license.txt") + + +def canonical_digest(value): + payload = json.dumps(value, sort_keys=True, separators=(",", ":"), + ensure_ascii=True).encode("ascii") + return hashlib.sha256(payload).hexdigest() + + +def git(root, *arguments): + return subprocess.check_output(["git", "-C", str(root), *arguments], + stderr=subprocess.PIPE) + + +def file_digest(path): + result = hashlib.sha256() + with path.open("rb") as source: + for block in iter(lambda: source.read(1024 * 1024), b""): + result.update(block) + return result.hexdigest() + + +def source_digest(root, output, ancestors=(), expected_commit=None): + """Read actual tracked bytes, including staged/unstaged and missing files.""" + root = root.resolve() + output = Path(os.path.abspath(output)) + output_paths = (output, output.resolve()) + if any(selected == root or selected in root.parents for selected in output_paths): + raise ValueError("output overlaps the source checkout or its ancestors") + if root in ancestors: + raise ValueError("recursive source checkout") + commit = git(root, "rev-parse", "HEAD").decode("ascii").strip() + if expected_commit is not None and commit != expected_commit: + raise ValueError("source commit no longer matches artifact identity") + entries = {} + for record in git(root, "ls-files", "--stage", "-z").split(b"\0"): + if not record: + continue + metadata, name = record.split(b"\t", 1) + mode, object_id, stage = metadata.split() + if stage != b"0": + raise ValueError("unresolved source merge") + entries[os.fsdecode(name)] = (mode.decode("ascii"), object_id.decode("ascii")) + for name in git(root, "ls-files", "--others", "--exclude-standard", "-z").split(b"\0"): + if name: + entries.setdefault(os.fsdecode(name), ("untracked", "")) + # The project imports this Git-ignored file. Its absence is an input too; + # cached resolved options alone cannot see edits during a long compile. + entries.setdefault("platformio.local.ini", ("local-config", "")) + content = [] + for name, (mode, object_id) in sorted(entries.items()): + path = root / name + # Only generated, untracked output may be excluded. Tracked sources + # and imported configuration must always bind their actual bytes, + # even if a caller incorrectly places output within their subtree. + if mode == "untracked" and any(path == selected or selected in path.parents + for selected in output_paths): + continue + item = [name, mode] + if mode == "160000": + item.append(object_id) + if path.is_dir() and (path / ".git").exists(): + item.append(source_digest(path, output, (*ancestors, root))) + else: + if path.is_dir() and any(path.iterdir()): + raise ValueError("nonempty source checkout lacks Git metadata") + item.append("uninitialized") + elif path.is_symlink(): + item.extend(("symlink", os.readlink(path))) + if path.is_file(): + item.append(file_digest(path)) + elif path.exists(): + raise ValueError("source symlink is not a regular file") + else: + item.append("missing-target") + elif path.is_file(): + item.extend(("file", bool(path.stat().st_mode & stat.S_IXUSR), file_digest(path))) + elif not path.exists(): + item.append("missing") + else: + raise ValueError("source is not a regular file") + content.append(item) + if git(root, "rev-parse", "HEAD").decode("ascii").strip() != commit: + raise ValueError("source commit changed during snapshot") + return canonical_digest({"commit": commit, "files": content}) + + +def configuration_inputs(root, options): + """Bind config imports, not package caches; unresolved imports fail closed.""" + patterns = options.get("extra_configs", []) + if isinstance(patterns, str): + patterns = [line.strip() for line in patterns.splitlines() if line.strip()] + if not isinstance(patterns, list) or not all(isinstance(value, str) for value in patterns): + raise ValueError("invalid imported configuration paths") + # Resolved options may contain only the final imported setting. Include + # literal imports from the project and each loaded file too, so an + # intermediate external config cannot disappear from the receipt. + pending = ["platformio.ini", "platformio.local.ini", *patterns] + seen_patterns = set() + parsed_files = set() + inputs = [] + while pending: + pattern = pending.pop(0) + expanded = os.path.expanduser(os.path.expandvars(pattern)) + if "$" in expanded or "%(" in expanded: + raise ValueError("unresolved imported configuration path") + absolute = str(root.resolve() / expanded) + if absolute in seen_patterns: + continue + seen_patterns.add(absolute) + files = [] + for name in sorted(glob.glob(absolute, recursive=True)): + path = Path(name) + if not path.is_file(): + raise ValueError("imported configuration is not a regular file") + files.append({"path": name, "resolved": str(path.resolve()), + "link": os.readlink(path) if path.is_symlink() else None, + "sha256": file_digest(path)}) + resolved = path.resolve() + if resolved not in parsed_files: + parsed_files.add(resolved) + parser = configparser.RawConfigParser(inline_comment_prefixes=("#", ";")) + with path.open(encoding="utf-8") as stream: + parser.read_file(stream) + imports = parser.get("platformio", "extra_configs", fallback="") + if imports: + pending.extend(value.strip() for value in imports.split( + "\n" if "\n" in imports else ", ") if value.strip()) + # An unmatched optional file/glob is retained, so later creation or + # removal is visible even when the resolved PIO options are cached. + inputs.append({"pattern": pattern, "expanded": absolute, "files": files}) + return inputs + + +def recipe_digest(args, options): + if not isinstance(options, list): + raise ValueError("invalid resolved PlatformIO configuration") + selected = {} + for value in options: + if not isinstance(value, list) or len(value) != 2: + raise ValueError("invalid PlatformIO section") + section, pairs = value + if section not in ("platformio", "env:" + args.pio_env): + continue + if section in selected or not isinstance(pairs, list): + raise ValueError("invalid PlatformIO options") + selected[section] = {} + for pair in pairs: + if not isinstance(pair, list) or len(pair) != 2 or not isinstance(pair[0], str): + raise ValueError("invalid PlatformIO option") + key, setting = pair + if key in selected[section]: + raise ValueError("duplicate PlatformIO option") + selected[section][key] = setting + if "env:" + args.pio_env not in selected: + raise ValueError("missing resolved PlatformIO environment") + names = set(RECIPE_ENVIRONMENT) | {name for name in os.environ + if name.startswith(("PLATFORMIO_", "MESHCORE_"))} + names.discard("MESHCORE_RECIPE_ORIGINAL_FLAGS") + environment = {name: os.environ.get(name) for name in names} + return canonical_digest({ + "schema_version": SCHEMA, + "source": source_digest(args.root, args.output, expected_commit=args.source_commit), + "target": args.target, "artifact_target": args.artifact_target, + "platformio_env": args.pio_env, "platform": args.platform, + "embedded_version": args.embedded_version, "profile": args.profile, + "sensor_profile": args.sensor_profile, "ota_policy": args.ota_policy, + "pio_options": selected, "environment": environment, + "config_inputs": configuration_inputs(args.root, selected.get("platformio", {})), + "original_flags": os.environ.get("MESHCORE_RECIPE_ORIGINAL_FLAGS", ""), + }) + + +def recipe_record(digest): + if not re.fullmatch(r"[0-9a-f]{64}", digest): + raise ValueError("invalid recipe digest") + return {"schema_version": SCHEMA, "sha256": digest} + + +def attach_recipe(manifest_path, digest): + manifest = json.loads(manifest_path.read_text()) + if not isinstance(manifest, dict): + raise ValueError("invalid capability manifest") + manifest["build_recipe"] = recipe_record(digest) + temporary = manifest_path.with_name(f".{manifest_path.name}.{os.getpid()}.tmp") + try: + temporary.write_text(json.dumps(manifest, indent=2, sort_keys=True, ensure_ascii=True) + "\n") + temporary.replace(manifest_path) + finally: + temporary.unlink(missing_ok=True) + + +def matches_recipe(manifest_path, digest): + manifest = json.loads(manifest_path.read_text()) + record = manifest.get("build_recipe") + return (isinstance(record, dict) and type(record.get("schema_version")) is int + and record == recipe_record(digest)) + + +def package_occupied(stem): + return any(os.path.lexists(str(stem) + suffix) for suffix in PACKAGE_SUFFIXES) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + digest = commands.add_parser("digest") + digest.add_argument("--root", required=True, type=Path) + digest.add_argument("--output", required=True, type=Path) + for name in ("target", "artifact-target", "pio-env", "platform", "source-commit", "embedded-version", + "profile", "sensor-profile", "ota-policy"): + digest.add_argument("--" + name, required=True) + for name in ("attach", "matches"): + action = commands.add_parser(name) + action.add_argument("manifest", type=Path) + action.add_argument("digest") + occupied = commands.add_parser("occupied") + occupied.add_argument("stem", type=Path) + args = parser.parse_args() + try: + if args.command == "digest": + print(recipe_digest(args, json.load(sys.stdin))) + elif args.command == "attach": + attach_recipe(args.manifest, args.digest) + elif args.command == "matches": + return 0 if matches_recipe(args.manifest, args.digest) else 1 + else: + return 0 if package_occupied(args.stem) else 1 + except (OSError, RuntimeError, ValueError, TypeError, AttributeError, + configparser.Error, subprocess.CalledProcessError): + # Raw options, flags, paths and nested Git diagnostics can contain + # credentials. Report a fixed diagnostic, never their exception text. + print("firmware build recipe: cannot qualify recipe inputs or manifest", file=sys.stderr) + return 2 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/test/test_firmware_build_recipe.py b/test/test_firmware_build_recipe.py new file mode 100644 index 000000000..39fc07072 --- /dev/null +++ b/test/test_firmware_build_recipe.py @@ -0,0 +1,684 @@ +#!/usr/bin/env python3 +"""Exercise real package qualification and safe recipe-bound resume, without PIO.""" + +import argparse +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import struct +import subprocess +import tempfile +import unittest +from unittest import mock + +ROOT = Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location("firmware_build_recipe", ROOT / "scripts/firmware_build_recipe.py") +RECIPE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(RECIPE) +SECRET = "private-credential-not-for-manifests" +TARGET = "test_companion_radio_usb" +OPTIONS = [["env:" + TARGET, [["build_flags", ["-DESP32_PLATFORM"]], + ["board", "test-board"], ["extra_scripts", []]]]] + + +def sha256(data): + return hashlib.sha256(data).hexdigest() + + +class RecipeTests(unittest.TestCase): + def source_fixture(self, root, *, untracked=(), stage=b"0", tracked="source.cpp"): + def git(_root, *arguments): + if arguments == ("rev-parse", "HEAD"): + return b"a" * 40 + b"\n" + if arguments == ("ls-files", "--stage", "-z"): + return b"100644 " + b"b" * 40 + b" " + stage + b"\t" + os.fsencode(tracked) + b"\0" + if arguments == ("ls-files", "--others", "--exclude-standard", "-z"): + return b"\0".join(os.fsencode(name) for name in untracked) + raise AssertionError(arguments) + return mock.patch.object(RECIPE, "git", side_effect=git) + + def test_actual_tracked_bytes_deletions_and_untracked_source_change_digest(self): + with tempfile.TemporaryDirectory(prefix="mesh-recipe-source-") as temporary: + root = Path(temporary) + path = root / "source.cpp"; path.write_bytes(b"original tracked source") + with self.source_fixture(root): + original = RECIPE.source_digest(root, root / "output") + path.write_bytes(b"changed without changing HEAD or index") + self.assertNotEqual(original, RECIPE.source_digest(root, root / "output")) + path.unlink() + self.assertNotEqual(original, RECIPE.source_digest(root, root / "output")) + path.write_bytes(b"original tracked source") + extra = root / "added.cpp"; extra.write_bytes(b"uncommitted source") + with self.source_fixture(root, untracked=(extra.name,)): + self.assertNotEqual(original, RECIPE.source_digest(root, root / "output")) + with self.source_fixture(root, stage=b"2"): + with self.assertRaises(ValueError): + RECIPE.source_digest(root, root / "output") + + def test_source_commit_must_match_filename_and_remain_stable_during_snapshot(self): + with tempfile.TemporaryDirectory(prefix="mesh-recipe-source-") as temporary: + root = Path(temporary) + (root / "source.cpp").write_bytes(b"source") + with self.source_fixture(root): + with self.assertRaises(ValueError): + RECIPE.source_digest(root, root / "output", expected_commit="d" * 40) + commits = iter((b"a" * 40, b"b" * 40)) + def git(_root, *arguments): + return next(commits) if arguments == ("rev-parse", "HEAD") else b"" + with mock.patch.object(RECIPE, "git", side_effect=git): + with self.assertRaises(ValueError): + RECIPE.source_digest(root, root / "output", expected_commit="a" * 40) + + def test_output_root_aliases_and_ancestors_are_rejected_instead_of_hiding_source(self): + with tempfile.TemporaryDirectory(prefix="mesh-recipe-source-") as temporary: + root = Path(temporary) + (root / "source.cpp").write_bytes(b"source") + link = root / "root-link"; link.symlink_to(root, target_is_directory=True) + with self.source_fixture(root): + for output in (root, root / "child/..", root.parent, Path("/"), link): + with self.subTest(output=output), self.assertRaises(ValueError): + RECIPE.source_digest(root, output) + + def test_tracked_sources_inside_output_are_hashed_but_untracked_artifacts_are_not(self): + with tempfile.TemporaryDirectory(prefix="mesh-recipe-source-") as temporary: + root = Path(temporary) + output = root / "src"; output.mkdir() + source = output / "source.cpp"; source.write_bytes(b"tracked source") + artifact = output / "generated.bin"; artifact.write_bytes(b"old artifact") + with self.source_fixture(root, tracked="src/source.cpp", untracked=("src/generated.bin",)): + first = RECIPE.source_digest(root, output) + artifact.write_bytes(b"new artifact") + self.assertEqual(first, RECIPE.source_digest(root, output)) + source.write_bytes(b"changed tracked source") + self.assertNotEqual(first, RECIPE.source_digest(root, output)) + + def test_ignored_local_config_absence_presence_and_bytes_are_source_inputs(self): + with tempfile.TemporaryDirectory(prefix="mesh-recipe-source-") as temporary: + root = Path(temporary) + (root / "source.cpp").write_bytes(b"source") + local = root / "platformio.local.ini" + with self.source_fixture(root): + absent = RECIPE.source_digest(root, root / "output") + local.write_text("[env:test]\nbuild_flags=-DONE=1\n") + present = RECIPE.source_digest(root, root / "output") + self.assertNotEqual(absent, present) + local.write_text("[env:test]\nbuild_flags=-DONE=2\n") + self.assertNotEqual(present, RECIPE.source_digest(root, root / "output")) + + def test_external_config_imports_globs_nested_files_and_missing_matches_are_bound(self): + with tempfile.TemporaryDirectory(prefix="mesh-recipe-config-") as temporary: + directory = Path(temporary) + root = directory / "project"; root.mkdir() + external = directory / "external.ini" + nested = directory / "nested.ini" + overrides = directory / "overrides"; overrides.mkdir() + external.write_text("[platformio]\nextra_configs = ../nested.ini\n") + nested.write_text("[env:test]\nbuild_flags=-DPRIVATE=" + SECRET + "\n") + # An intermediate external file is not necessarily listed in the + # final resolved extra_configs value, but remains an input. + (root / "platformio.ini").write_text("[platformio]\nextra_configs=../external.ini\n") + options = {"extra_configs": [str(directory / "overrides/*.ini"), "../nested.ini"]} + before = RECIPE.canonical_digest(RECIPE.configuration_inputs(root, options)) + external.write_text("[platformio]\nextra_configs=../nested.ini\n[env:test]\nboard=changed\n") + after = RECIPE.canonical_digest(RECIPE.configuration_inputs(root, options)) + self.assertNotEqual(before, after) + (overrides / "added.ini").write_text("[env:test]\nboard=added\n") + self.assertNotEqual(after, RECIPE.canonical_digest(RECIPE.configuration_inputs(root, options))) + for pattern in ("${sysenv.UNRESOLVED_RECIPE_CONFIG}", "%(unresolved_config)s"): + with self.subTest(pattern=pattern), self.assertRaises(ValueError): + RECIPE.configuration_inputs(root, {"extra_configs": [pattern]}) + + def test_custom_output_and_output_link_do_not_hash_generated_files(self): + with tempfile.TemporaryDirectory(prefix="mesh-recipe-source-") as temporary: + root = Path(temporary) + (root / "source.cpp").write_bytes(b"source") + output = root / "unignored-artifacts"; output.mkdir() + generated = output / "firmware.bin"; generated.write_bytes(b"old artifact") + with self.source_fixture(root, untracked=("unignored-artifacts/firmware.bin",)): + initial = RECIPE.source_digest(root, output) + generated.write_bytes(b"new artifact") + self.assertEqual(initial, RECIPE.source_digest(root, output)) + link = root / "output-link"; link.symlink_to(output, target_is_directory=True) + with self.source_fixture(root, untracked=("output-link", "unignored-artifacts/firmware.bin")): + self.assertEqual(initial, RECIPE.source_digest(root, link)) + + def test_gitlinks_and_source_symlink_targets_do_not_hide_local_changes(self): + with tempfile.TemporaryDirectory(prefix="mesh-recipe-source-") as temporary: + root = Path(temporary) + nested = root / "nested"; nested.mkdir() + gitlink = b"160000 " + b"b" * 40 + b" 0\tnested\0" + def git(where, *arguments): + if arguments == ("rev-parse", "HEAD"): + return b"a" * 40 + b"\n" + if arguments == ("ls-files", "--stage", "-z"): + return gitlink if where == root else b"100644 " + b"c" * 40 + b" 0\tfile.cpp\0" + return b"" + with mock.patch.object(RECIPE, "git", side_effect=git): + RECIPE.source_digest(root, root / "output") + source = nested / "file.cpp"; source.write_bytes(b"local source") + with self.assertRaises(ValueError): + RECIPE.source_digest(root, root / "output") + (nested / ".git").write_text("fixture metadata marker") + first = RECIPE.source_digest(root, root / "output") + source.write_bytes(b"changed local source") + self.assertNotEqual(first, RECIPE.source_digest(root, root / "output")) + external = root / "external.txt"; external.write_bytes(b"initial target") + (root / "source.cpp").symlink_to(external) + with self.source_fixture(root): + first = RECIPE.source_digest(root, root / "output") + external.write_bytes(b"changed target") + self.assertNotEqual(first, RECIPE.source_digest(root, root / "output")) + + def test_every_package_suffix_and_broken_symlink_counts_as_occupied(self): + with tempfile.TemporaryDirectory(prefix="mesh-recipe-occupied-") as temporary: + stem = Path(temporary) / "firmware" + self.assertFalse(RECIPE.package_occupied(stem)) + for suffix in RECIPE.PACKAGE_SUFFIXES: + path = Path(str(stem) + suffix) + path.write_bytes(b"") + self.assertTrue(RECIPE.package_occupied(stem)) + path.unlink() + path.symlink_to(stem.parent / "missing") + self.assertTrue(RECIPE.package_occupied(stem)) + + def test_selected_options_are_canonical_but_ordered_flags_and_source_are_bound(self): + args = argparse.Namespace(root=ROOT, output=ROOT / "out", target=TARGET, + artifact_target=TARGET, pio_env=TARGET, platform="ESP32_PLATFORM", + source_commit="a" * 40, + embedded_version="vtest.12-abcdef00", profile="auto", + sensor_profile="", ota_policy="0") + environment = {"PLATFORMIO_BUILD_FLAGS": "-DONE=1 -UTWO -DTWO=2", + "MESHCORE_RECIPE_ORIGINAL_FLAGS": "-DWIFI_PWD=" + SECRET} + with mock.patch.object(RECIPE, "source_digest", return_value="c" * 64), \ + mock.patch.dict(os.environ, environment, clear=True): + first = RECIPE.recipe_digest(args, OPTIONS) + reordered = [[OPTIONS[0][0], list(reversed(OPTIONS[0][1]))], + ["env:unrelated", [["build_flags", ["not compiled"]]]]] + self.assertEqual(first, RECIPE.recipe_digest(args, reordered)) + os.environ["PLATFORMIO_BUILD_FLAGS"] = "-DONE=1 -DTWO=2 -UTWO" + self.assertNotEqual(first, RECIPE.recipe_digest(args, OPTIONS)) + os.environ.update(environment) + args.embedded_version = "vtest.13-abcdef00" + self.assertNotEqual(first, RECIPE.recipe_digest(args, OPTIONS)) + args.embedded_version = "vtest.12-abcdef00" + os.environ["MESHCORE_NEW_RECIPE_OPTION"] = "1" + self.assertNotEqual(first, RECIPE.recipe_digest(args, OPTIONS)) + + def test_stored_recipe_is_digest_only_and_has_strict_schema(self): + with tempfile.TemporaryDirectory(prefix="mesh-recipe-manifest-") as temporary: + manifest = Path(temporary) / "capabilities.json" + manifest.write_text(json.dumps({"verified": True})) + digest = RECIPE.canonical_digest({"credentials": SECRET}) + RECIPE.attach_recipe(manifest, digest) + text = manifest.read_text() + self.assertNotIn(SECRET, text) + self.assertTrue(RECIPE.matches_recipe(manifest, digest)) + self.assertEqual(json.loads(text)["build_recipe"], + {"schema_version": 1, "sha256": digest}) + for record in (None, {}, {"schema_version": True, "sha256": digest}, + {"schema_version": 1, "sha256": "broken"}, + {"schema_version": 1, "sha256": digest, "flags": SECRET}): + manifest.write_text(json.dumps({"build_recipe": record})) + self.assertFalse(RECIPE.matches_recipe(manifest, digest)) + + def test_malformed_inputs_cannot_echo_credentials_in_diagnostics(self): + command = ["python3", "-B", str(ROOT / "scripts/firmware_build_recipe.py"), "digest", + "--root", str(ROOT), "--output", str(ROOT / "out"), "--target", TARGET, + "--artifact-target", TARGET, "--pio-env", TARGET, "--platform", "ESP32_PLATFORM", + "--source-commit", "a" * 40, + "--embedded-version", "vtest", "--profile", "auto", "--sensor-profile", "", + "--ota-policy", "0"] + result = subprocess.run(command, input=SECRET, capture_output=True, text=True, timeout=10) + self.assertNotEqual(result.returncode, 0) + self.assertNotIn(SECRET, result.stdout + result.stderr) + self.assertEqual(result.stdout, "") + + +class ResumeTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(prefix="mesh-recipe-resume-") + self.addCleanup(self.temporary.cleanup) + self.work = Path(self.temporary.name) + (self.work / "scripts").symlink_to(ROOT / "scripts", target_is_directory=True) + self.output = self.work / "artifacts"; self.output.mkdir() + self.build_dir = self.work / "current-builds" + build = self.build_dir / TARGET; build.mkdir(parents=True) + image = (b"qualified fixture\0ERR usage: tempradio\0OTA: status\0" + b"image_hash MISMATCH after decode\0") + (build / "firmware.elf").write_bytes(image) + (build / "firmware.bin").write_bytes(image) + (build / "firmware-merged.bin").write_bytes(b"merged fixture\0" + image) + table = struct.pack("&2; return 99; } +prepare_esp32_arduino3_framework() { :; } +print_build_flags() { :; } +PIO_ENV_PLATFORM_BY_NAME[test_companion_radio_usb]=ESP32_PLATFORM +PIO_CONFIG_JSON=$4 +OUTPUT_DIR=$2 +PIO_BUILD_DIR_OVERRIDE=$3 +FIRMWARE_VERSION=$5 +RESUME_BUILD_OUTPUT=1 +RADIO_FREQ_OVERRIDE=868.1 +RADIO_BW_OVERRIDE=125 +RADIO_SF_OVERRIDE=8 +RADIO_CR_OVERRIDE=6 +FIRMWARE_PROFILE_OVERRIDE=default +REQUIRE_OTA_UPDATES=0 +recipe_source=$6 +if [ -n "$recipe_source" ]; then + fixture_git_dir=$(command git -C "$recipe_root" rev-parse --absolute-git-dir) + compute_build_recipe_digest() { + GIT_DIR="$fixture_git_dir" GIT_WORK_TREE="$recipe_source" \ + MESHCORE_RECIPE_ORIGINAL_FLAGS="$5" \ + command python3 "$recipe_root/scripts/firmware_build_recipe.py" digest \ + --root "$recipe_source" --output "$OUTPUT_DIR" --source-commit "$6" \ + --target "$1" --artifact-target "$1" --pio-env "$3" --platform "$2" \ + --embedded-version "$4" --profile "$BUILD_PROFILE_FOR_TARGET" \ + --sensor-profile "${NRF52_OTA_SENSOR_PROFILE:-}" --ota-policy "$REQUIRE_OTA_UPDATES" \ + <<<"$PIO_CONFIG_JSON" + } +fi +''' + values = {"PLATFORMIO_BUILD_FLAGS": "-DWIFI_PWD='\\\"" + SECRET + "\\\"'", + "PLATFORMIO_BUILD_UNFLAGS": "-DOLD_FLAG", + "PLATFORMIO_BUILD_SRC_FILTER": "+", + "PLATFORMIO_EXTRA_SCRIPTS": "pre:scripts/original.py"} + if ambient == "unset": + script += "unset " + " ".join(values) + "\n" + else: + prefix = "export " if ambient == "exported" else "" + for name, value in values.items(): + script += prefix + name + "=" + self.shell_quote(value) + "\n" + script += settings + "\n" + clock + "\n" + script += r''' +ambient_before=$(for name in PLATFORMIO_BUILD_FLAGS PLATFORMIO_BUILD_UNFLAGS PLATFORMIO_BUILD_SRC_FILTER PLATFORMIO_EXTRA_SCRIPTS BUILD_RECIPE_SHA256 recipe_build_flags completed_recipe; do declare -p "$name" 2>/dev/null || :; done) +run_pio_with_size_detection() { + printf 'STUB_BUILD_REQUIRED\n' +''' + script += after_build + "\nreturn " + ("0" if succeed else "89") + "\n}\n" + script += r''' +build_firmware_one_profile test_companion_radio_usb +build_result=$? +ambient_after=$(for name in PLATFORMIO_BUILD_FLAGS PLATFORMIO_BUILD_UNFLAGS PLATFORMIO_BUILD_SRC_FILTER PLATFORMIO_EXTRA_SCRIPTS BUILD_RECIPE_SHA256 recipe_build_flags completed_recipe; do declare -p "$name" 2>/dev/null || :; done) +if [ "$ambient_before" != "$ambient_after" ]; then printf 'AMBIENT_CHANGED\n' >&2; exit 77; fi +exit "$build_result" +''' + if logged: + script = script.replace("build_firmware_one_profile test_companion_radio_usb\n", + "run_logged_build_targets test_companion_radio_usb\n") + script = script.replace('exit "$build_result"\n', + 'for failure in "${LOGGING_MATRIX_FAILURES[@]}"; do ' + 'printf "RECORDED_FAILURE:%s\\n" "$failure"; done\n' + 'exit "$build_result"\n') + result = subprocess.run(["bash", "-c", script, "recipe-test", str(ROOT), str(self.output), + str(self.build_dir), json.dumps(OPTIONS), version, + str(source_root) if source_root is not None else ""], + cwd=self.work, text=True, capture_output=True, timeout=45) + self.assertNotIn("FORBIDDEN_PLATFORMIO", result.stdout + result.stderr) + self.assertNotIn(SECRET, result.stdout + result.stderr) + self.assertNotIn("AMBIENT_CHANGED", result.stdout + result.stderr) + return result + + @staticmethod + def shell_quote(value): + return "'" + value.replace("'", "'\"'\"'") + "'" + + def qualified_fixture(self, **kwargs): + result = self.build(succeed=True, **kwargs) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("STUB_BUILD_REQUIRED", result.stdout) + capabilities = next(self.output.glob("*.capabilities.json")) + self.stem = Path(str(capabilities).removesuffix(".capabilities.json")) + self.capabilities = capabilities + self.memory = Path(str(self.stem) + ".memory.json") + self.assertNotIn(SECRET, capabilities.read_text() + self.memory.read_text()) + self.validate_fixture() + + def validate_fixture(self): + result = subprocess.run(["python3", "-B", str(ROOT / "scripts/firmware_memory_manifest.py"), + "validate-package", str(self.stem)], cwd=ROOT, + text=True, capture_output=True, timeout=10) + self.assertEqual(result.returncode, 0, result.stderr) + + def snapshot(self): + return {str(path.relative_to(self.output)): + (os.readlink(path) if path.is_symlink() else path.read_bytes() if path.is_file() else None, + path.lstat().st_ino, path.lstat().st_mtime_ns) + for path in self.output.rglob("*")} + + def reseal_capabilities(self): + report = json.loads(self.memory.read_text()) + report["files"][self.capabilities.name] = sha256(self.capabilities.read_bytes()) + self.memory.write_text(json.dumps(report)) + self.validate_fixture() + + def assert_conflict(self, **kwargs): + before = self.snapshot() + result = self.build(**kwargs) + self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertNotIn("STUB_BUILD_REQUIRED", result.stdout) + self.assertNotIn("Skipping", result.stdout) + self.assertIn("fresh OUTPUT_DIR", result.stderr) + self.assertEqual(before, self.snapshot()) + + def test_fresh_build_attaches_recipe_and_exact_qualified_match_skips(self): + self.qualified_fixture() + before = self.snapshot() + result = self.build() + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("matching recipe and qualified artifacts", result.stdout) + self.assertNotIn("STUB_BUILD_REQUIRED", result.stdout) + self.assertEqual(before, self.snapshot()) + + def test_generated_clock_changes_do_not_invalidate_recipe(self): + self.qualified_fixture() + result = self.build(clock=r'''date() { case "$*" in *+%s*) printf '9999999999\n';; *) printf '01-Jan-2099\n';; esac; }''') + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("Skipping", result.stdout) + + def test_real_logged_recipe_resume_preserves_original_compile_log(self): + self.qualified_fixture() + logs = self.output / "build-logs"; logs.mkdir() + log = logs / (TARGET + "-standard.log") + log.write_bytes(b"original compiler output and memory qualification\n") + before = (log.read_bytes(), log.stat().st_ino, log.stat().st_mtime_ns) + artifacts = {name: value for name, value in self.snapshot().items() if not name.startswith("build-logs")} + result = self.build(logged=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertNotIn("STUB_BUILD_REQUIRED", result.stdout + log.read_text()) + self.assertEqual(before, (log.read_bytes(), log.stat().st_ino, log.stat().st_mtime_ns)) + self.assertEqual(artifacts, {name: value for name, value in self.snapshot().items() + if not name.startswith("build-logs")}) + self.assertFalse(Path(str(log) + ".tmp").exists()) + + def test_real_logged_resume_conflict_preserves_prior_log_and_points_to_new_attempt(self): + self.qualified_fixture() + logs = self.output / "build-logs"; logs.mkdir() + log = logs / (TARGET + "-standard.log") + log.write_bytes(b"original compiler output and memory qualification\n") + before = (log.read_bytes(), log.stat().st_ino, log.stat().st_mtime_ns) + artifacts = {name: value for name, value in self.snapshot().items() if not name.startswith("build-logs")} + result = self.build(logged=True, settings="RADIO_FREQ_OVERRIDE=910.525") + self.assertEqual(result.returncode, 1, result.stdout + result.stderr) + self.assertEqual(before, (log.read_bytes(), log.stat().st_ino, log.stat().st_mtime_ns)) + self.assertEqual(artifacts, {name: value for name, value in self.snapshot().items() + if not name.startswith("build-logs")}) + attempts = list(logs.glob(TARGET + "-standard-resume-attempt.*.log")) + self.assertEqual(len(attempts), 1) + attempt = attempts[0] + self.assertIn("Cannot resume", attempt.read_text()) + self.assertNotIn("STUB_BUILD_REQUIRED", result.stdout + attempt.read_text()) + self.assertNotIn(SECRET, attempt.read_text()) + self.assertIn("status 1; log: " + str(attempt), result.stdout) + self.assertIn("RECORDED_FAILURE:" + TARGET + " (standard) -> " + str(attempt), result.stdout) + + def test_changed_radio_profile_build_number_and_ambient_options_stop_without_overwrite(self): + self.qualified_fixture() + for settings in ("RADIO_FREQ_OVERRIDE=910.525", "RADIO_BW_OVERRIDE=62.5", + "RADIO_SF_OVERRIDE=7", "RADIO_CR_OVERRIDE=5", + "FIRMWARE_PROFILE_OVERRIDE=cascade", "FIRMWARE_BUILD_NUMBER=2", + "export PLATFORMIO_BUILD_FLAGS+=' -DCHANGED_OPTION=1'", + "export PLATFORMIO_BUILD_UNFLAGS+=' -DANOTHER_OPTION'", + "export PLATFORMIO_BUILD_SRC_FILTER+=' +'", + "export PLATFORMIO_EXTRA_SCRIPTS+=' post:scripts/changed.py'", + "export MESHCORE_REDUCED_TLS=1", + "PIO_CONFIG_JSON=" + self.shell_quote(json.dumps( + [[OPTIONS[0][0], OPTIONS[0][1] + [["board_build.partitions", "other.csv"]]]]))): + with self.subTest(settings=settings): + self.assert_conflict(settings=settings) + + def test_missing_and_malformed_recipes_stop_even_when_package_hashes_are_valid(self): + self.qualified_fixture() + original = json.loads(self.capabilities.read_text()) + for recipe in (None, {}, {"schema_version": True, "sha256": "a" * 64}, + {"schema_version": 1, "sha256": "broken"}, + {"schema_version": 1, "sha256": "a" * 64}): + with self.subTest(recipe=recipe): + manifest = dict(original) + if recipe is None: + manifest.pop("build_recipe") + else: + manifest["build_recipe"] = recipe + self.capabilities.write_text(json.dumps(manifest)) + self.reseal_capabilities() + self.assert_conflict() + + def test_corrupt_and_partial_occupied_packages_stop_without_pio_or_writes(self): + self.qualified_fixture() + application = Path(str(self.stem) + ".bin") + application.write_bytes(b"tampered application") + self.assert_conflict() + for path in list(self.output.iterdir()): + if path != application: + path.unlink() + self.assert_conflict() + application.write_bytes(b"") + self.assert_conflict() + application.unlink() + application.symlink_to(self.work / "missing-image") + self.assert_conflict() + + def test_memory_seal_prevents_recipe_marker_tampering(self): + self.qualified_fixture() + old_output = self.output + self.output = self.work / "other-artifacts"; self.output.mkdir() + result = self.build(succeed=True, settings="RADIO_FREQ_OVERRIDE=910.525") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + new_manifest = json.loads(next(self.output.glob("*.capabilities.json")).read_text()) + self.output = old_output + manifest = json.loads(self.capabilities.read_text()) + # Marker now matches the changed settings, but cannot be substituted + # into the old image's sealed capability manifest to bypass the gate. + manifest["build_recipe"] = new_manifest["build_recipe"] + self.capabilities.write_text(json.dumps(manifest)) + self.assert_conflict(settings="RADIO_FREQ_OVERRIDE=910.525") + + def test_ambient_unset_unexported_and_exported_state_is_restored_on_skip_and_conflict(self): + for ambient in ("unset", "unexported", "exported"): + with self.subTest(ambient=ambient): + self.qualified_fixture(ambient=ambient, version="vtest-" + ambient) + result = self.build(ambient=ambient, version="vtest-" + ambient) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("Skipping", result.stdout) + self.assert_conflict(ambient=ambient, version="vtest-" + ambient, + settings="RADIO_FREQ_OVERRIDE=910.525") + + def test_empty_different_stem_may_build_without_overwriting_previous_version(self): + self.qualified_fixture() + before = self.snapshot() + result = self.build(version="v1.17.1.10") + self.assertEqual(result.returncode, 89, result.stdout + result.stderr) + self.assertIn("STUB_BUILD_REQUIRED", result.stdout) + self.assertEqual(before, self.snapshot()) + + def test_input_change_during_stub_compile_prevents_artifact_publication(self): + result = self.build(succeed=True, after_build="export PLATFORMIO_BUILD_UNFLAGS+=' -DCHANGED_DURING_BUILD'") + self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("changed during compilation", result.stderr) + self.assertEqual(self.snapshot(), {}) + + def test_ignored_local_config_change_during_stub_compile_prevents_publication(self): + source = self.work / "source-fixture"; source.mkdir() + # Read-only Git metadata from the real checkout plus a scratch working + # tree avoids initializing/changing Git or touching the real private + # local config. The helper reads only Git inventory/HEAD commands. + (source / ".gitignore").write_bytes((ROOT / ".gitignore").read_bytes()) + result = self.build(succeed=True, source_root=source, after_build=r''' +python3 - "$recipe_source/platformio.local.ini" <<'PY' +from pathlib import Path +import sys +Path(sys.argv[1]).write_text("[env:test]\nbuild_flags=-DLOCAL_RECIPE_REPRO=1\n") +PY +''') + self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("changed during compilation", result.stderr) + self.assertEqual(self.snapshot(), {}) + + def test_external_config_change_during_stub_compile_prevents_publication(self): + source = self.work / "source-fixture"; source.mkdir() + (source / ".gitignore").write_bytes((ROOT / ".gitignore").read_bytes()) + (source / "platformio.ini").write_text("[platformio]\nextra_configs=../external.ini\n") + external = self.work / "external.ini" + external.write_text("[env:test]\nbuild_flags=-DEXTERNAL_RECIPE_REPRO=1\n") + result = self.build(succeed=True, source_root=source, after_build=r''' +python3 - "$recipe_source/../external.ini" <<'PY' +from pathlib import Path +import sys +Path(sys.argv[1]).write_text("[env:test]\nbuild_flags=-DEXTERNAL_RECIPE_REPRO=2\n") +PY +''') + self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("changed during compilation", result.stderr) + self.assertEqual(self.snapshot(), {}) + + def test_occupied_inspection_error_fails_closed_before_stub_compile(self): + result = self.build(settings=r''' +python3() { + if [ "$1" = scripts/firmware_build_recipe.py ] && [ "$2" = occupied ]; then return 2; fi + command python3 "$@" +} +''') + self.assertNotEqual(result.returncode, 0) + self.assertNotIn("STUB_BUILD_REQUIRED", result.stdout) + self.assertIn("Cannot inspect", result.stderr) + self.assertEqual(self.snapshot(), {}) + + +class LogPreservationTests(unittest.TestCase): + def test_serial_and_dormant_worker_consumers_preserve_new_and_legacy_skip_logs(self): + for parallel in (False, True): + for message in ("existing artifacts found", "matching recipe and qualified artifacts found"): + with self.subTest(parallel=parallel, message=message), \ + tempfile.TemporaryDirectory(prefix="mesh-recipe-logs-") as temporary: + work = Path(temporary) + output = work / "artifacts" + logs = output / "build-logs"; logs.mkdir(parents=True) + paths = [logs / (name + "-standard.log") for name in ("first", "second")] + for path in paths: + path.write_bytes(b"original compile log\n") + before = [(path.read_bytes(), path.stat().st_ino, path.stat().st_mtime_ns) + for path in paths] + script = r''' +source "$1/build_legacy.sh" +OUTPUT_DIR=$2 +skip_message=$3 +pio() { printf 'FORBIDDEN_PLATFORMIO\n' >&2; return 99; } +build_firmware() { printf 'Skipping %s; %s for qualified-fixture.\n' "$1" "$skip_message"; } +if [ "$4" = parallel ]; then + # Exercise the retained consumer code using harmless shell-only workers. + # The production policy stays at one worker; no PIO process is launched. + consumer=$(declare -f run_logged_build_targets) + consumer=${consumer/local worker_limit=1/local worker_limit=2} + eval "$consumer" +fi +run_logged_build_targets first second +''' + result = subprocess.run(["bash", "-c", script, "log-test", str(ROOT), str(output), + message, "parallel" if parallel else "serial"], + cwd=work, text=True, capture_output=True, timeout=15) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertNotIn("FORBIDDEN_PLATFORMIO", result.stdout + result.stderr) + self.assertEqual(before, [(path.read_bytes(), path.stat().st_ino, path.stat().st_mtime_ns) + for path in paths]) + self.assertEqual(list(logs.glob("*.tmp")), []) + + def test_failed_resume_attempts_get_unique_logs_but_other_build_results_keep_old_policy(self): + for parallel in (False, True): + for resume, status in ((True, 1), (False, 1), (True, 0), (False, 0)): + with self.subTest(parallel=parallel, resume=resume, status=status), \ + tempfile.TemporaryDirectory(prefix="mesh-recipe-log-failures-") as temporary: + work = Path(temporary) + output = work / "artifacts" + logs = output / "build-logs"; logs.mkdir(parents=True) + paths = [logs / (name + "-standard.log") for name in ("first", "second")] + for path in paths: + path.write_bytes(b"original compile log\n") + before = [(path.read_bytes(), path.stat().st_ino, path.stat().st_mtime_ns) + for path in paths] + script = r''' +source "$1/build_legacy.sh" +OUTPUT_DIR=$2 +RESUME_BUILD_OUTPUT=$3 +stub_status=$4 +pio() { printf 'FORBIDDEN_PLATFORMIO\n' >&2; return 99; } +build_firmware() { printf 'current attempt diagnostics\n'; return "$stub_status"; } +if [ "$5" = parallel ]; then + consumer=$(declare -f run_logged_build_targets) + consumer=${consumer/local worker_limit=1/local worker_limit=2} + eval "$consumer" +fi +run_logged_build_targets first second +first_result=$? +run_logged_build_targets first second +second_result=$? +for failure in "${LOGGING_MATRIX_FAILURES[@]}"; do printf 'RECORDED_FAILURE:%s\n' "$failure"; done +printf 'RESULTS:%s/%s\n' "$first_result" "$second_result" +''' + result = subprocess.run(["bash", "-c", script, "failed-log-test", str(ROOT), str(output), + "1" if resume else "0", str(status), + "parallel" if parallel else "serial"], + cwd=work, text=True, capture_output=True, timeout=15) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertNotIn("FORBIDDEN_PLATFORMIO", result.stdout + result.stderr) + self.assertIn(f"RESULTS:{status}/{status}", result.stdout) + attempts = list(logs.glob("*-resume-attempt.*.log")) + failed_paths = {line.split(" -> ", 1)[1] for line in result.stdout.splitlines() + if line.startswith("RECORDED_FAILURE:")} + if resume and status: + self.assertEqual(before, [(path.read_bytes(), path.stat().st_ino, path.stat().st_mtime_ns) + for path in paths]) + self.assertEqual(len(attempts), 4) + self.assertEqual(failed_paths, set(map(str, attempts))) + for attempt in attempts: + self.assertIn("current attempt diagnostics", attempt.read_text()) + self.assertIn("FAILED:", attempt.read_text()) + self.assertIn("status 1; log: " + str(attempt), result.stdout) + else: + self.assertEqual(attempts, []) + for path in paths: + self.assertIn("current attempt diagnostics", path.read_text()) + self.assertNotIn("original compile log", path.read_text()) + self.assertEqual(failed_paths, set(map(str, paths)) if status else set()) + self.assertEqual(list(logs.glob("*.tmp")), []) + + def test_failed_attempt_log_allocation_preserves_prior_log_and_never_starts_build(self): + with tempfile.TemporaryDirectory(prefix="mesh-recipe-log-allocation-") as temporary: + work = Path(temporary) + output = work / "artifacts" + logs = output / "build-logs"; logs.mkdir(parents=True) + original = logs / "first-standard.log"; original.write_bytes(b"original compile log\n") + before = (original.read_bytes(), original.stat().st_ino, original.stat().st_mtime_ns) + script = r''' +source "$1/build_legacy.sh" +OUTPUT_DIR=$2 +RESUME_BUILD_OUTPUT=1 +mktemp() { return 70; } +build_firmware() { printf 'SHOULD_NOT_BUILD\n'; } +run_logged_build_targets first second +status=$? +printf 'RESULT:%s BATCH:%s\n' "$status" "$BATCH_BUILD_MODE" +''' + result = subprocess.run(["bash", "-c", script, "allocation-log-test", str(ROOT), str(output)], + cwd=work, text=True, capture_output=True, timeout=10) + self.assertNotIn("SHOULD_NOT_BUILD", result.stdout) + self.assertIn("RESULT:1 BATCH:0", result.stdout) + self.assertEqual(before, (original.read_bytes(), original.stat().st_ino, original.stat().st_mtime_ns)) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_full_infrastructure_selection.sh b/test/test_full_infrastructure_selection.sh index fabfa6b71..afe921c65 100644 --- a/test/test_full_infrastructure_selection.sh +++ b/test/test_full_infrastructure_selection.sh @@ -8,6 +8,36 @@ fail() { echo "test_full_infrastructure_selection: $*" >&2; exit 1; } # Resolve real board inheritance, but never compile or flash firmware. init_project_context >/dev/null +# No fixture assertion is allowed to fall through into a firmware build. The +# sole PlatformIO operation above resolves board metadata; all later calls are +# errors, including dependency preflight before the compiler entry point. +pio() { fail "selection fixture attempted PlatformIO after metadata resolution"; } +prepare_esp32_arduino3_framework() { fail "selection fixture reached build preflight"; } +run_pio_with_size_detection() { fail "selection fixture attempted compilation"; } +collect_build_artifacts() { fail "selection fixture attempted artifact publication"; } + +use_qualified_resume_fixture() { + fixture_output_dir=$(mktemp -d "${TMPDIR:-/tmp}/mesh-full-selection.XXXXXX") + OUTPUT_DIR=$fixture_output_dir + trap 'rm -rf -- "$fixture_output_dir"' EXIT + # These tests mock package qualification to inspect target selection. Supply + # a real, schema-valid receipt for the new recipe gate too; its actual + # occupied/matches helpers run normally. Recipe hashing and package sealing + # are exercised independently by test_firmware_build_recipe.py. + compute_build_recipe_digest() { + python3 - "${OUTPUT_DIR}/${firmware_filename}.capabilities.json" "$1" "$3" <<'PY' +import json +from pathlib import Path +import sys +digest = "a" * 64 +manifest = {"target": sys.argv[2], "platformio_env": sys.argv[3], + "build_recipe": {"schema_version": 1, "sha256": digest}} +Path(sys.argv[1]).write_text(json.dumps(manifest) + "\n") +print(digest) +PY + } +} + assert_auto_unified() ( local plain=$1 expected=$2 BUILD_PROFILE_OVERRIDE=auto @@ -180,8 +210,9 @@ done # Direct observer promotion must use the matrix's exact output recipe even if # interactive flags previously requested MQTT without USB logging. Stop at the -# existing-artifact check to exercise the real build entry point without pio run. +# matching-recipe/qualified-artifact check without entering compilation. ( + use_qualified_resume_fixture ESP32_FULL_BUILD=0 BUILD_PROFILE_EFFECTIVE=standard BUILD_PROFILE_OVERRIDE=auto @@ -211,6 +242,7 @@ done # A canonical bulk build promotes an audited target in-place, retaining its # environment/mOTA identity. Explicit standard builds remain untouched above. ( + use_qualified_resume_fixture ESP32_FULL_BUILD=0 BUILD_PROFILE_EFFECTIVE=standard BUILD_PROFILE_OVERRIDE=auto @@ -232,6 +264,7 @@ done || fail "FULL-only bulk target lost its exact identity" ) ( + use_qualified_resume_fixture ESP32_FULL_BUILD=0 BUILD_PROFILE_EFFECTIVE=standard BUILD_PROFILE_OVERRIDE=auto @@ -250,6 +283,7 @@ done || fail "V4 Full did not keep its identity and unified feature recipe" ) ( + use_qualified_resume_fixture ESP32_FULL_BUILD=0 BUILD_PROFILE_EFFECTIVE=standard BUILD_PROFILE_OVERRIDE=standard