From 24a223314c7f829e2b0ef2bf12e12e1b2cba881d Mon Sep 17 00:00:00 2001 From: mikecarper Date: Tue, 6 Oct 2026 09:29:48 -0700 Subject: [PATCH] Identify failed resume qualification gates safely --- build_legacy.sh | 23 +++++++-- test/test_firmware_build_recipe.py | 77 ++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 4 deletions(-) diff --git a/build_legacy.sh b/build_legacy.sh index 08272a30a..d4a1639cc 100755 --- a/build_legacy.sh +++ b/build_legacy.sh @@ -4858,6 +4858,8 @@ build_firmware_one_profile() { local BUILD_RECIPE_SHA256 local recipe_build_flags local completed_recipe + local resume_recipe_status + local resume_artifact_status local platformio_package_lock_fd="" local -a pio_run_args=() local -a BUILD_CAPABILITIES=() @@ -5117,13 +5119,26 @@ build_firmware_one_profile() { "$full_source_commit") || return 1 if [ "$RESUME_BUILD_OUTPUT" == "1" ]; then if python3 scripts/firmware_build_recipe.py occupied "${OUTPUT_DIR}/${firmware_filename}"; then + # Keep the recipe-first short circuit while distinguishing its failure + # from the packaged artifact/capability/RAM gate. Report only exit codes; + # build inputs and raw manifests can contain private settings. + resume_artifact_status=not-run 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 + >/dev/null 2>&1; then + resume_recipe_status=0 + if build_artifacts_exist "$env_name" "$env_platform" "$firmware_filename" \ + >/dev/null 2>&1; then + echo "Skipping ${env_name}; matching recipe and qualified artifacts found for ${firmware_filename}." + return 0 + else + resume_artifact_status=$? + fi + else + resume_recipe_status=$? fi + printf 'Resume qualification gates: recipe_exit=%s; artifact_capability_ram_exit=%s.\n' \ + "$resume_recipe_status" "$resume_artifact_status" >&2 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 diff --git a/test/test_firmware_build_recipe.py b/test/test_firmware_build_recipe.py index 6902f3fa5..882fcf18c 100644 --- a/test/test_firmware_build_recipe.py +++ b/test/test_firmware_build_recipe.py @@ -375,6 +375,81 @@ exit "$build_result" self.assertNotIn("Skipping", result.stdout) self.assertIn("fresh OUTPUT_DIR", result.stderr) self.assertEqual(before, self.snapshot()) + return result + + def test_resume_recipe_rejection_does_not_execute_artifact_gate(self): + self.qualified_fixture() + result = self.assert_conflict(settings=r'''RADIO_FREQ_OVERRIDE=910.525 +build_artifacts_exist() { printf 'FORBIDDEN_ARTIFACT_GATE\n' >&2; return 73; } +''') + self.assertIn("Resume qualification gates: recipe_exit=1; artifact_capability_ram_exit=not-run.", + result.stderr) + self.assertNotIn("FORBIDDEN_ARTIFACT_GATE", result.stdout + result.stderr) + self.assertIn("existing package has a missing/different recipe or failed qualification.", + result.stderr) + + def test_resume_recipe_error_status_is_private_and_still_fails_closed(self): + self.qualified_fixture() + result = self.assert_conflict(settings=r''' +python3() { + if [ "$1" = scripts/firmware_build_recipe.py ] && [ "$2" = matches ]; then + printf '%s\n' PRIVATE_FIXTURE_SECRET + printf '%s\n' PRIVATE_FIXTURE_SECRET >&2 + return 73 + fi + command python3 "$@" +} +build_artifacts_exist() { printf 'FORBIDDEN_ARTIFACT_GATE\n' >&2; return 74; } +'''.replace("PRIVATE_FIXTURE_SECRET", SECRET)) + self.assertEqual(result.returncode, 1) + self.assertIn("recipe_exit=73; artifact_capability_ram_exit=not-run.", result.stderr) + self.assertNotIn("FORBIDDEN_ARTIFACT_GATE", result.stdout + result.stderr) + + def test_resume_corrupt_artifact_and_failed_ram_report_identify_qualification_gate(self): + self.qualified_fixture() + application = Path(str(self.stem) + ".bin") + original_application = application.read_bytes() + original_memory = self.memory.read_bytes() + for failure in ("application", "ram"): + with self.subTest(failure=failure): + application.write_bytes(original_application) + self.memory.write_bytes(original_memory) + if failure == "application": + application.write_bytes(b"tampered packaged application") + else: + manifest = json.loads(original_memory) + manifest["passed"] = False + self.memory.write_text(json.dumps(manifest)) + result = self.assert_conflict() + self.assertIn("recipe_exit=0; artifact_capability_ram_exit=1.", result.stderr) + + def test_resume_missing_packaged_marker_identifies_artifact_gate_without_exposing_marker(self): + self.qualified_fixture() + # Add a current requirement after the real contract is declared. The + # cached package remains sealed and its recipe still matches, but its + # actual packaged-application evidence cannot satisfy this requirement. + result = self.assert_conflict(settings=r''' +contract=$(declare -f declare_build_capability_contract) +eval "${contract/declare_build_capability_contract /fixture_original_contract }" +declare_build_capability_contract() { + fixture_original_contract "$@" + BUILD_APPLICATION_EXPECTATIONS+=("fixture.private=''' + SECRET + r'''") +} +''') + self.assertIn("recipe_exit=0; artifact_capability_ram_exit=1.", result.stderr) + self.validate_fixture() + + def test_resume_artifact_error_status_suppresses_private_gate_output(self): + self.qualified_fixture() + result = self.assert_conflict(settings=r''' +build_artifacts_exist() { + printf '%s\n' PRIVATE_FIXTURE_SECRET + printf '%s\n' PRIVATE_FIXTURE_SECRET >&2 + return 74 +} +'''.replace("PRIVATE_FIXTURE_SECRET", SECRET)) + self.assertEqual(result.returncode, 1) + self.assertIn("recipe_exit=0; artifact_capability_ram_exit=74.", result.stderr) def test_fresh_build_attaches_recipe_and_exact_qualified_match_skips(self): self.qualified_fixture() @@ -382,6 +457,7 @@ exit "$build_result" result = self.build() self.assertEqual(result.returncode, 0, result.stdout + result.stderr) self.assertIn("matching recipe and qualified artifacts", result.stdout) + self.assertNotIn("Resume qualification gates:", result.stdout + result.stderr) self.assertNotIn("STUB_BUILD_REQUIRED", result.stdout) self.assertEqual(before, self.snapshot()) @@ -422,6 +498,7 @@ exit "$build_result" self.assertEqual(len(attempts), 1) attempt = attempts[0] self.assertIn("Cannot resume", attempt.read_text()) + self.assertIn("recipe_exit=1; artifact_capability_ram_exit=not-run.", 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)