Skip to content

Commit 5620f2f

Browse files
committed
[red] require C3 post-OI NVS receipt
Signed-off-by: Viwat Vchirawongkwin <viwat.v@chula.ac.th>
1 parent d6d4ccb commit 5620f2f

3 files changed

Lines changed: 234 additions & 13 deletions

File tree

‎tests/firmware_tests/host/test_release_v060_private_gates.py‎

Lines changed: 120 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
PICO_GATES = ("GP0", "GP1", "GP2")
5151
VERSION = "0.6.0"
5252
RELEASE_DIGEST = "ab" * 32
53+
OI1_RAW_SHA256 = "cd" * 32
5354

5455

5556
def load_module(name: str, path: Path):
@@ -87,6 +88,33 @@ def sha256_bytes(value: bytes) -> str:
8788
return hashlib.sha256(value).hexdigest()
8889

8990

91+
def post_oi_nvs_binding(
92+
artifact: bytes,
93+
*,
94+
release_digest: str = RELEASE_DIGEST,
95+
oi1_raw_sha256: str = OI1_RAW_SHA256,
96+
) -> dict[str, object]:
97+
summary = {
98+
"schema_version": 1,
99+
"profile_id": C3_PROFILE,
100+
"candidate_release_json_sha256": release_digest,
101+
"candidate_firmware_sha256": sha256_bytes(artifact),
102+
"oi1_raw_sha256": oi1_raw_sha256,
103+
"reset_samples": 10,
104+
"partition_offset": 0x9000,
105+
"partition_size": 0x6000,
106+
"integrity": "passed",
107+
"written_namespaces": [],
108+
}
109+
receipt = {"summary": summary, "inventory": []}
110+
raw = canonical_json_bytes(receipt)
111+
return {
112+
"receipt_sha256": sha256_bytes(raw),
113+
"receipt_size_bytes": len(raw),
114+
"summary": summary,
115+
}
116+
117+
90118
def private_result(
91119
profile_id: str,
92120
artifact: bytes,
@@ -101,7 +129,7 @@ def private_result(
101129
gate_names = PICO_GATES
102130
else: # pragma: no cover - fixture misuse.
103131
raise AssertionError("unsupported private-gate fixture profile")
104-
return {
132+
result = {
105133
"schema_version": 1,
106134
"status": "passed",
107135
"profile_id": profile_id,
@@ -110,6 +138,12 @@ def private_result(
110138
digest_key: sha256_bytes(artifact),
111139
"gates": {name: "passed" for name in gate_names},
112140
}
141+
if profile_id == C3_PROFILE:
142+
result["post_oi_nvs"] = post_oi_nvs_binding(
143+
artifact,
144+
release_digest=release_digest,
145+
)
146+
return result
113147

114148

115149
def write_private_result(path: Path, value: object) -> None:
@@ -123,6 +157,7 @@ def expected_summary(
123157
artifact: bytes,
124158
) -> dict[str, object]:
125159
value = private_result(profile_id, artifact)
160+
value.pop("post_oi_nvs", None)
126161
value["qualification_result_sha256"] = sha256_bytes(result_path.read_bytes())
127162
return value
128163

@@ -134,6 +169,7 @@ def test_pure_private_gate_module_and_api_exist(self) -> None:
134169
return
135170
for name in (
136171
"validate_result_file",
172+
"validate_c3_result_for_observation",
137173
"validate_public_summary",
138174
"private_result_snapshot",
139175
):
@@ -295,6 +331,80 @@ def test_artifact_kind_digest_and_bytes_cannot_be_substituted(self) -> None:
295331
with self.assertRaises(GATE.QualificationError):
296332
self.validate(profile_id)
297333

334+
def test_c3_post_oi_nvs_binding_is_exact_and_observation_bound(self) -> None:
335+
original = private_result(C3_PROFILE, self.artifacts[C3_PROFILE])
336+
observation = {
337+
"raw_log_sha256": OI1_RAW_SHA256,
338+
"reset_to_service_advertisement_ms": [1000] * 10,
339+
}
340+
summary = GATE.validate_c3_result_for_observation(
341+
self.result_paths[C3_PROFILE],
342+
artifact_path=self.artifact_paths[C3_PROFILE],
343+
expected_version=VERSION,
344+
candidate_release_json_sha256=RELEASE_DIGEST,
345+
observation=observation,
346+
)
347+
self.assertNotIn("post_oi_nvs", summary)
348+
349+
mutations = {
350+
"missing": lambda value: value.pop("post_oi_nvs"),
351+
"receipt-digest": lambda value: value["post_oi_nvs"].__setitem__(
352+
"receipt_sha256", "0" * 64
353+
),
354+
"receipt-size": lambda value: value["post_oi_nvs"].__setitem__(
355+
"receipt_size_bytes", 0
356+
),
357+
"release": lambda value: value["post_oi_nvs"]["summary"].__setitem__(
358+
"candidate_release_json_sha256", "0" * 64
359+
),
360+
"firmware": lambda value: value["post_oi_nvs"]["summary"].__setitem__(
361+
"candidate_firmware_sha256", "0" * 64
362+
),
363+
"raw": lambda value: value["post_oi_nvs"]["summary"].__setitem__(
364+
"oi1_raw_sha256", "0" * 64
365+
),
366+
"resets": lambda value: value["post_oi_nvs"]["summary"].__setitem__(
367+
"reset_samples", 9
368+
),
369+
"offset": lambda value: value["post_oi_nvs"]["summary"].__setitem__(
370+
"partition_offset", 0
371+
),
372+
"size": lambda value: value["post_oi_nvs"]["summary"].__setitem__(
373+
"partition_size", 0x5000
374+
),
375+
"integrity": lambda value: value["post_oi_nvs"]["summary"].__setitem__(
376+
"integrity", "failed"
377+
),
378+
"phy": lambda value: value["post_oi_nvs"]["summary"].__setitem__(
379+
"written_namespaces", ["phy"]
380+
),
381+
}
382+
for name, mutate in mutations.items():
383+
with self.subTest(mutation=name):
384+
changed = copy.deepcopy(original)
385+
mutate(changed)
386+
self.rewrite(C3_PROFILE, changed)
387+
with self.assertRaises(GATE.QualificationError):
388+
self.validate(C3_PROFILE)
389+
390+
self.rewrite(C3_PROFILE, original)
391+
for name, changed_observation in {
392+
"wrong-raw": {**observation, "raw_log_sha256": "0" * 64},
393+
"nine-resets": {
394+
**observation,
395+
"reset_to_service_advertisement_ms": [1000] * 9,
396+
},
397+
}.items():
398+
with self.subTest(observation=name):
399+
with self.assertRaises(GATE.QualificationError):
400+
GATE.validate_c3_result_for_observation(
401+
self.result_paths[C3_PROFILE],
402+
artifact_path=self.artifact_paths[C3_PROFILE],
403+
expected_version=VERSION,
404+
candidate_release_json_sha256=RELEASE_DIGEST,
405+
observation=changed_observation,
406+
)
407+
298408
def test_gate_sets_require_every_exact_gate_passed(self) -> None:
299409
for profile_id, gate_names in (
300410
(C3_PROFILE, C3_GATES),
@@ -446,6 +556,7 @@ def payload(self) -> dict[str, object]:
446556
def summaries(self) -> tuple[dict[str, object], dict[str, object], dict[str, object]]:
447557
waveshare = {"schema_version": 1, "status": "passed"}
448558
c3 = private_result(C3_PROFILE, b"c3")
559+
c3.pop("post_oi_nvs")
449560
c3["qualification_result_sha256"] = "2" * 64
450561
pico = private_result(PICO_PROFILE, b"pico")
451562
pico["qualification_result_sha256"] = "3" * 64
@@ -566,6 +677,14 @@ def test_finalizer_source_snapshots_validates_binds_and_rechecks_both_inputs(sel
566677
"each C3/Pico input must be snapshotted before and after promotion",
567678
)
568679

680+
def test_completion_and_finalizer_bind_c3_receipt_to_the_oi_observation(self) -> None:
681+
seam = "_V060_PROFILE_GATE.validate_c3_result_for_observation"
682+
self.assertIn(
683+
seam,
684+
inspect.getsource(RELEASE.create_hil_completion_fragment),
685+
)
686+
self.assertIn(seam, inspect.getsource(RELEASE.finalize_public_bundle))
687+
569688
def test_public_tree_validator_revalidates_both_derived_summaries(self) -> None:
570689
source = inspect.getsource(RELEASE._validate_hil)
571690
self.assertIn("_V060_PROFILE_GATE.validate_public_summary", source)

‎tests/firmware_tests/host/test_target_runtime_contract.py‎

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1128,24 +1128,19 @@ def test_c3_disables_phy_calibration_storage_without_relaxing_oi1(self):
11281128
)
11291129
thresholds = profile["thresholds"]
11301130
self.assertEqual(
1131+
thresholds,
11311132
{
1132-
key: thresholds[key]
1133-
for key in (
1134-
"gc_free_min_bytes",
1135-
"idf_internal_free_min_bytes",
1136-
"idf_internal_largest_block_min_bytes",
1137-
"idf_internal_minimum_free_min_bytes",
1138-
"reset_to_service_advertisement_max_ms",
1139-
)
1140-
},
1141-
{
1133+
"application_headroom_min_bytes": 352880,
1134+
"application_image_max_bytes": 1678736,
11421135
"gc_free_min_bytes": 117760,
1136+
"get_verified_goodput_min_bytes_per_second": 27200,
11431137
"idf_internal_free_min_bytes": 66560,
11441138
"idf_internal_largest_block_min_bytes": 57344,
11451139
"idf_internal_minimum_free_min_bytes": 63488,
1140+
"put_committed_goodput_min_bytes_per_second": 15300,
11461141
"reset_to_service_advertisement_max_ms": 3000,
11471142
},
1148-
"the PHY policy must not weaken any C3 heap or reset gate",
1143+
"the PHY policy must not weaken any C3 release threshold",
11491144
)
11501145

11511146

‎tests/firmware_tests/host/test_v060_qualification_workflow.py‎

Lines changed: 108 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,35 @@ def canonical_json_bytes(value: object) -> bytes:
103103
).encode("utf-8")
104104

105105

106+
def write_c3_post_oi_nvs_receipt(
107+
path: Path,
108+
*,
109+
release_raw: bytes,
110+
artifact: bytes,
111+
namespaces: list[dict[str, object]] | None = None,
112+
oi1_raw_sha256: str = "d" * 64,
113+
) -> tuple[dict[str, object], bytes]:
114+
inventory = namespaces or []
115+
summary = {
116+
"schema_version": 1,
117+
"profile_id": "esp32-c3-4mb",
118+
"candidate_release_json_sha256": hashlib.sha256(release_raw).hexdigest(),
119+
"candidate_firmware_sha256": hashlib.sha256(artifact).hexdigest(),
120+
"oi1_raw_sha256": oi1_raw_sha256,
121+
"reset_samples": 10,
122+
"partition_offset": 0x9000,
123+
"partition_size": 0x6000,
124+
"integrity": "passed",
125+
"written_namespaces": [item["namespace"] for item in inventory],
126+
}
127+
receipt = {"summary": summary, "inventory": inventory}
128+
raw = canonical_json_bytes(receipt)
129+
path.parent.mkdir(parents=True, exist_ok=True)
130+
path.write_bytes(raw)
131+
path.chmod(0o600)
132+
return summary, raw
133+
134+
106135
def candidate_release(artifact: bytes) -> dict[str, object]:
107136
esp_specs = {
108137
"esp32-4mb": (
@@ -407,7 +436,13 @@ def test_candidate_bound_no_replace_writer_api_exists(self):
407436
return
408437
self.assertEqual(
409438
set(inspect.signature(writer).parameters),
410-
{"candidate_dir", "profile_id", "passed_gates", "output_path"},
439+
{
440+
"candidate_dir",
441+
"profile_id",
442+
"passed_gates",
443+
"output_path",
444+
"post_oi_nvs_receipt_path",
445+
},
411446
)
412447

413448
def test_private_gate_cli_exposes_only_derived_identity_inputs(self):
@@ -420,6 +455,14 @@ def test_private_gate_cli_exposes_only_derived_identity_inputs(self):
420455
self.assertEqual(completed.returncode, 0, completed.stderr)
421456
self.assertIn("create-result", completed.stdout)
422457
self.assertIn("--passed-gate", completed.stdout)
458+
command_help = subprocess.run(
459+
[sys.executable, str(GATE_PATH), "create-result", "--help"],
460+
check=False,
461+
capture_output=True,
462+
text=True,
463+
)
464+
self.assertEqual(command_help.returncode, 0, command_help.stderr)
465+
self.assertIn("--post-oi-nvs-receipt", command_help.stdout)
423466
for forbidden in (
424467
"--status",
425468
"--firmware-version",
@@ -442,13 +485,20 @@ def test_writer_derives_exact_bytes_and_refuses_missing_gates(self):
442485
release_raw = canonical_json_bytes(candidate_release(artifact))
443486
(candidate / "release.json").write_bytes(release_raw)
444487
output = root / "private" / "c3-result.json"
488+
receipt = root / "private" / "c3-post-oi-nvs.json"
489+
receipt_summary, receipt_raw = write_c3_post_oi_nvs_receipt(
490+
receipt,
491+
release_raw=release_raw,
492+
artifact=artifact,
493+
)
445494
gates = ["C3-G%d" % index for index in range(7)]
446495

447496
created = writer(
448497
candidate_dir=candidate,
449498
profile_id="esp32-c3-4mb",
450499
passed_gates=gates,
451500
output_path=output,
501+
post_oi_nvs_receipt_path=receipt,
452502
)
453503
expected = {
454504
"schema_version": 1,
@@ -460,6 +510,11 @@ def test_writer_derives_exact_bytes_and_refuses_missing_gates(self):
460510
).hexdigest(),
461511
"candidate_firmware_sha256": hashlib.sha256(artifact).hexdigest(),
462512
"gates": {name: "passed" for name in gates},
513+
"post_oi_nvs": {
514+
"receipt_sha256": hashlib.sha256(receipt_raw).hexdigest(),
515+
"receipt_size_bytes": len(receipt_raw),
516+
"summary": receipt_summary,
517+
},
463518
}
464519
self.assertEqual(Path(created), output)
465520
self.assertEqual(output.read_bytes(), canonical_json_bytes(expected))
@@ -472,6 +527,7 @@ def test_writer_derives_exact_bytes_and_refuses_missing_gates(self):
472527
profile_id="esp32-c3-4mb",
473528
passed_gates=gates,
474529
output_path=output,
530+
post_oi_nvs_receipt_path=receipt,
475531
)
476532
missing_output = root / "private" / "missing.json"
477533
with self.assertRaises(GATE.QualificationError):
@@ -480,9 +536,53 @@ def test_writer_derives_exact_bytes_and_refuses_missing_gates(self):
480536
profile_id="esp32-c3-4mb",
481537
passed_gates=gates[:-1],
482538
output_path=missing_output,
539+
post_oi_nvs_receipt_path=receipt,
483540
)
484541
self.assertFalse(missing_output.exists())
485542

543+
for name, receipt_path in (
544+
("missing-receipt", None),
545+
("non-exclusive-receipt", receipt),
546+
):
547+
if receipt_path is not None:
548+
receipt.chmod(0o644)
549+
rejected_output = root / "private" / (name + ".json")
550+
with self.subTest(name=name), self.assertRaises(
551+
GATE.QualificationError
552+
):
553+
writer(
554+
candidate_dir=candidate,
555+
profile_id="esp32-c3-4mb",
556+
passed_gates=gates,
557+
output_path=rejected_output,
558+
post_oi_nvs_receipt_path=receipt_path,
559+
)
560+
self.assertFalse(rejected_output.exists())
561+
receipt.chmod(0o600)
562+
563+
phy_receipt = root / "private" / "phy-receipt.json"
564+
write_c3_post_oi_nvs_receipt(
565+
phy_receipt,
566+
release_raw=release_raw,
567+
artifact=artifact,
568+
namespaces=[
569+
{
570+
"namespace": "phy",
571+
"keys": [{"key": "cal_data", "type": "blob"}],
572+
}
573+
],
574+
)
575+
phy_output = root / "private" / "phy-result.json"
576+
with self.assertRaises(GATE.QualificationError):
577+
writer(
578+
candidate_dir=candidate,
579+
profile_id="esp32-c3-4mb",
580+
passed_gates=gates,
581+
output_path=phy_output,
582+
post_oi_nvs_receipt_path=phy_receipt,
583+
)
584+
self.assertFalse(phy_output.exists())
585+
486586
def test_writer_rejects_minimal_or_substituted_candidate_metadata(self):
487587
writer = GATE.create_result_file
488588
gates = ["C3-G%d" % index for index in range(7)]
@@ -660,6 +760,12 @@ def test_candidate_writer_rolls_back_post_write_validation_failure(self):
660760
canonical_json_bytes(candidate_release(artifact))
661761
)
662762
output = root / "private" / "result.json"
763+
receipt = root / "private" / "post-oi-nvs.json"
764+
write_c3_post_oi_nvs_receipt(
765+
receipt,
766+
release_raw=(candidate / "release.json").read_bytes(),
767+
artifact=artifact,
768+
)
663769
with mock.patch.object(
664770
GATE,
665771
"validate_result_file",
@@ -670,6 +776,7 @@ def test_candidate_writer_rolls_back_post_write_validation_failure(self):
670776
profile_id="esp32-c3-4mb",
671777
passed_gates=gates,
672778
output_path=output,
779+
post_oi_nvs_receipt_path=receipt,
673780
)
674781
self.assertFalse(output.exists())
675782

0 commit comments

Comments
 (0)