fix: run the signing suite in CI and keep the boot log head consistent on write failure - #91
Conversation
srpatcha
left a comment
There was a problem hiding this comment.
Review — eBoot#91 "fix: run the signing suite in CI and keep the boot log head consistent on write failure"
head: 7521325 author: dhruv-joshi15 ci: none reported
Verdict: Three well-scoped fixes, split one per commit, with an unusually honest body that separates what was fixed from what was found and left alone. Two of the three I could verify statically and both check out. The problems are that no CI ran at all, so none of the extensive local evidence is corroborated; that the boot-log fix is right for an atomic write failure and leaves the partial-write case worse-shaped than it looks; and that one of the "warning cleanups" silences a diagnostic that was reporting a real functional gap.
Findings
| # | Severity | File:line | Finding | Recommended fix |
|---|---|---|---|---|
| 1 | High | — (checks.txt is empty) |
No CI check of any kind reported on this head. checks.txt is 0 bytes, and createdAt / updatedAt are 2026-09-01T17:33:08Z / 17:33:09Z — opened and never touched again. Every other PR in this batch carries 22-26 checks. So the entire verification table in the body — ctest 20/20 in Release and Debug, 36 passed under pytest, 14 passed for the signing suite, 11/11 for test_boot_log, "no new compiler warnings, output diffed against a build of unmodified master" — is a local run by the author with nothing independent behind it. That matters more than usual here because the PR changes core/boot_log.c, which is inside eboot_core and therefore inside the TCB, and because the tests/CMakeLists.txt change removes a library from a link line, which is exactly the class of change only a real build validates. The likely cause is benign — a first-time contributor whose workflow runs need maintainer approval, which dhruv-joshi15 appearing as a new author in this repo would explain — but the effect is a core/ change with zero executed evidence. |
A maintainer approves the workflow runs so the 26 checks execute against this head, before anything else in this review is acted on. If the runs come back green, findings 2-4 are the remaining substance; if they do not, the local table needs re-examining rather than trusting. |
| 2 | Medium | core/boot_menu.c:56-67 |
The two (void)port; lines silence a warning that is telling the truth. Verified: include/eos_hal.h:163 defines #define eos_hal_uart_write(port, data, len) eos_hal_uart_send(data, len) and include/eos_hal.h:129 declares int eos_hal_uart_send(const void *buf, size_t len) — no port parameter anywhere. So eos_boot_menu_config_t::uart_port is inert: a board that configures the recovery menu onto UART 2 gets it on whichever UART the HAL's single-instance API happens to serve, silently. -Wunused-parameter was the only thing in the tree saying so, and this change removes it while leaving the gap. The run brief is explicit that "a disabled test, loosened lint, removed assertion, or widened permission is a finding regardless of the reason given", and a (void)x over a genuinely-ignored configuration parameter is that shape. To be fair to the author, the comment is candid about exactly this — "it starts being honoured the moment the HAL grows a per-port call" — which is why it is Medium rather than higher; the alternative fixes are just all better. |
Fail closed instead of suppressing. Smallest version: in eos_boot_menu_init() (or wherever the config is accepted), if (cfg->uart_port != 0) return EOS_ERR_NOT_SUPPORTED; with a comment pointing at eos_hal.h:163, and mark the field /* unimplemented: the HAL exposes a single UART */ in its header. A board asking for something the HAL cannot do then hears about it at init rather than losing its menu output. Better but larger: give eos_hal_uart_send() a port argument and drop the compatibility macro — that is the actual fix and probably its own PR. |
| 3 | Medium | core/boot_log.c:44-52 |
The fix is correct for a write that fails atomically and leaves the partial-write case in a worse shape than the new test suggests. eos_boot_log_entry_t is {timestamp, event, slot, detail}, four uint32_t with no validity marker or CRC, and readers distinguish "unused" by convention — the new test itself asserts entry.event == 0xFFFFFFFFu for an erased slot. Now consider a NOR write that programs timestamp and then fails before event: the head correctly stays put, the slot still reads as erased because event is untouched, and the next append rewrites the same address. NOR programming can only clear bits, so the second write's timestamp is ANDed with the residue of the first — a silently corrupted timestamp, while event, slot and detail land cleanly. A reader gets an entry that looks entirely valid with a wrong time. The new test cannot see this because its mock fails the write atomically (write_result = EOS_ERR_FLASH with no partial effect), so the retry lands on genuinely erased flash. §8.1 requires "Crash/health information available to update logic"; a plausible-looking wrong timestamp is worse for that than a missing entry. |
Do not change the entry layout — test_entry_layout_is_stable exists precisely because host tooling and application firmware parse this struct, so adding a CRC word is a wire-format change. The smaller fix is to make the retry safe: before writing, read the target slot and, if it is not fully erased, advance the head past it rather than programming over it. Then extend the mock to fail after writing the first word so the partial case is actually covered — that is the test that distinguishes the two behaviours. |
| 4 | Low | core/boot_log.c:26 (signature) |
With this fix a failed write is now silently discarded: eos_boot_log_append() returns void, so the caller cannot learn that a boot event — including EOS_LOG_BOOT_FAIL — never reached flash. Before the change the event was also lost but the head moved, which at least left a visible gap; now the loss is invisible. .ai/security.md is direct about "a security step whose result is discarded", and §8.1 makes boot health information an input to update logic. Pre-existing signature and the PR is a net improvement, but the author's own comment names the constraint ("append() cannot report a failure -- it returns void"), so it should not close as done. |
Change the return type to int and propagate, then audit the call sites — most will legitimately ignore it, but the ones in the failure and rollback paths should not. Its own PR; eos_boot_log_clear() already returns a status, so the precedent is in the file. |
| 5 | Low | .github/workflows/ci.yml:39 |
Three PRs in this batch rewrite this one line — #88 appends cryptography, #90 appends pyyaml, and this one replaces the hand-written list with pip3 install -r requirements.txt pytest-cov. They will conflict pairwise. Worth saying because it is not a three-way tie: this PR's version subsumes both others. requirements.txt already carries pyyaml>=6.0, pytest>=9.0 and pyserial>=3.5, and this PR adds cryptography>=41.0, so installing from the file satisfies #90's pyyaml need and #88's cryptography need at once — and test_jobs_that_run_pytest_install_the_repository_requirements then prevents the parallel-list problem from coming back, which neither of the other two does. |
Land this PR's line and drop the ci.yml hunks from #88 and #90 rather than resolving the conflict by picking one package list. |
Verified clean, and worth recording because two of these are the parts a reviewer would most reasonably doubt:
- The link-line change is safe.
CMakeLists.txt:130istarget_link_libraries(eboot_stage1 PUBLIC eboot_core), so droppingeboot_corefromeboot_test_recovery's explicit list leaves it linked transitively. The comment's reasoning is shakier than the change — a duplicate archive on a link line is normally harmless rather than something "the linker reported" — but the edit itself is correct and thePUBLICkeyword is what makes it so. - The new workflow-scanning test is satisfiable, not aspirational.
test_jobs_that_run_pytest_install_the_repository_requirementsasserts that every job matchingpytest … tests/also installs-r requirements.txt. Across all 16 files in.github/workflows/,ci.yml:69(python3 -m pytest tests/ -v --tb=short) is the only match, and this PR fixes that job's installer;nightly.yml:84already runspip install -r requirements.txt. So the test passes at this head — which, given finding 1, no CI has demonstrated. _strip_comments()earns its place. This PR's own new YAML comment block atci.yml:10-16mentionspytest,tests/unit/test_sign_image.pyandimportorskip; without the comment-stripping it would register as a job that runs pytest and the new test would fail on its own diff. That is a considered detail.- The baseline corroborates #90 independently. Both report 19 passed / 1 skipped for
pytest tests/on unmodifiedmaster, from different authors on different branches. #90's own 25-passed figure is 19 + its 6 new cases, and this PR's 36 is 19 + 14 + 3. The numbers across the two are consistent.
Architecture conformance
Conforms. §21 Tier 1 — Foundation, plus Infrastructure for the workflow and requirements.txt changes. core/ is the right home for both C edits per .ai/architect.md ("core/ shared boot logic") and both are platform-agnostic — no board or SoC specifics leak in, boards/, stage0/, hal/ and stage1/ are untouched, and the body is explicit that secure_boot.c, image_verify.c, ed25519_verify.c, recovery.c, keystore.c and jump_app.c are not touched either. No new include, link line or target_link_libraries entry points up a tier; the only linkage change removes an edge. §5.1's minimal-and-auditable TCB holds: three added lines in eboot_core, no new dependency. §8.1's "Crash/health information available to update logic" is the requirement both the boot-log fix and findings 3 and 4 sit under.
No proposal appended. The CI half of this PR is a fourth instance of a design gap already recorded — .ai/autoreview/proposals/2026-09.md, "The evidence policy is silent on checks that verify nothing" (§28, triggered by eAI#39, eAI#41, eBoot#81) — whose proposed §28.2 already covers it: "A test runner that finds zero tests must fail." An importorskip on an undeclared dependency is the same failure in a different runner, and this PR's test_requirements.py is a good concrete implementation of it. The existing text needs no amendment to cover the case, so I have left it alone rather than adding a near-duplicate entry.
Proposed changes
- Get the workflow runs approved and executed (finding 1). Nothing else should be judged final until they report.
- Replace the
(void)port;suppressions with a rejection of an unsupported port, and mark the config field unimplemented (finding 2). - Add the erased-slot check before the write, and extend the mock to fail mid-entry so the partial-write path is covered (finding 3).
- Follow up separately on
eos_boot_log_append()returning a status (finding 4). - Coordinate the
ci.ymlline with #88 and #90 by keeping this PR's version (finding 5).
Items 2-5 are independent of each other. The commit-per-fix split means 2, 3 and 5 can each be dropped or landed on their own, which the body offers and which is the right structure for this change set.
Not checked
- Nothing was executed, and unusually, nothing was executed by CI either. No
ctest, nopytest, no build, no warning diff. Reproducing needs the PR head checked out, which the run brief forbids — and here there is no CI result to fall back on, so this review rests entirely on reading the diff and the working tree, which for every file this PR touches is identical tomaster(13a7a02) — the local checkout sits on the unmerged branchfix/ed25519-low-order-keysat8b88125, whose single commit touches onlycore/ed25519_verify.candtests/unit/test_ed25519.c, neither of which this PR goes near. That is a materially weaker position than for the other seven PRs in this batch, and finding 1 exists because of it. - Finding 3 is reasoned from NOR flash semantics, not observed. I did not build a partial-write mock, and I did not check whether
eos_hal_flash_write()'s contract on any in-tree board guarantees all-or-nothing behaviour — if some HAL implementation does guarantee it, the finding narrows to the boards that do not.hal/andboards/were not read. - The "4 warnings before → 1 after" claim was not verified, nor was "no new compiler warnings, diffed against a build of unmodified master". I confirmed the cause of the two
-Wunused-parameterwarnings by readingeos_hal.h:129,163, but not the counts, and not what the remaining deliberate warning is. test_sign_image.py's 14 cases were not inspected. The body describes them as pinning the v2 signed-header wire format. I did not read them, so I cannot confirm the count, that they all pass, or that they overlap usefully with the static assertions #87 adds toinclude/eos_image.h. Whether #87 and these 14 tests duplicate each other is an open question I did not answer.- The regex-based workflow parsing was checked for satisfiability, not robustness.
JOB_RE = ^ ([A-Za-z0-9_-]+):$assumes job ids sit at exactly two-space indent and that everything after\njobs:is job content. It holds for the 16 current files as far as the one property I tested goes; I did not look for a workflow that would defeat it. mergeStateStatus: BLOCKED,mergeable: MERGEABLE,reviewDecision: REVIEW_REQUIRED. No merge attempted.tests/CMakeLists.txtcollides with #84, #85, #88 and #89;.github/workflows/ci.ymlcollides with #88 and #90.
Automated architecture review of 7521325ddb47 — scheduled, model claude-opus-5, checked against the EmbeddedOS Master Design v2.0. Advisory only: this reviewer never approves, requests changes, or merges. Reply here to discuss or push back — a wrong finding is a bug worth reporting.
Summary
Three small, independent fixes to test and build hygiene:
tests/unit/test_sign_image.pywas skipping all 14 of its cases because its dependency was declared nowhere.eos_boot_log_append()advanced the head past a slot it never wrote.CONTRIBUTING.md.Nothing here changes boot behaviour on a working device. The value is that failures now surface instead of passing silently.
Each item is a separate commit and can be reviewed or dropped independently.
Context
eBoot is a two-stage bootloader: Stage-0 brings up hardware, Stage-1 scans the A/B slots, verifies an image (SHA-256 / Ed25519 / anti-rollback), and jumps to it.
core/holds the platform-agnostic logic that builds and tests natively on the host;boards/andstage0/hold architecture-specific code that does not.That split makes the host test suite the main safety net for
core/— it is the only part CI can actually execute. Both substantive fixes here are about that net having a hole: one suite was not running at all, and one failure path had a fixture but no test.What changed
b1b6c05fix(ci)— signing tests execute againrequirements.txt,.github/workflows/ci.yml,tests/unit/test_requirements.pydc32112fix(core)— boot log head consistencycore/boot_log.c,tests/unit/test_boot_log.c7521325fix(build)— host build warningscore/boot_menu.c,tests/CMakeLists.txt7 files, +222 / −4. No changes to
secure_boot.c,image_verify.c,ed25519_verify.c,recovery.c,keystore.c,stage0/,boards/,jump_app.c, or the rootCMakeLists.txt.1. The signing test suite was never executing in CI
Problem
tests/unit/test_sign_image.pyguards itself with:cryptographyis imported bytools/sign_image.pyandtools/eos_sign.py, but was declared neither inrequirements.txtnor by the CI job that runs pytest — that job installed a hand-written list,pip3 install pytest pytest-cov. Becauseimportorskipdegrades to a skip rather than a failure, the suite sat quietly in the skipped column and the job still reported success.Impact
The 14 cases that never ran are the ones pinning the v2 signed-header wire format: that the Ed25519 signature covers the whole header prefix, that
entry_addr,load_addr,image_size,image_version,sig_typeandhdr_versionare each bound to it, that clearingEOS_IMG_FLAG_HASH_SHA256is rejected, and that an unsigned image fails--verify.All 14 pass today — nothing is broken. The exposure is that a regression in
imgpack.pyorsign_image.pywould have merged green.Fix
cryptography>=41.0inrequirements.txt, following the file's existingname>=major.minorconvention.pip3 install -r requirements.txt pytest-cov.pytest-covstays separate because it is a CI-only coverage plugin, not a repository dependency.tests/unit/test_requirements.pyso this cannot lapse again.The
importorskipguard stays — a contributor without the signing tools should still get a skip, not a hard error. The new test is what makes the skip impossible in CI.Regression guard
Three checks, layered specific to general, written as static text parsing in the style of
test_cmake_test_registration.py— no cmake, compiler, network, GitHub API or YAML dependency:test_cryptography_is_declared_in_requirements— pins the specific dependency by name.test_every_importorskip_dependency_is_declared— cross-references everyimportorskip(...)againstrequirements.txt, so a future suite with an undeclared guard is caught too.test_jobs_that_run_pytest_install_the_repository_requirements— scans every workflow, finds each job running pytest overtests/, and requires-r requirements.txt. It names the offending job in the failure message.Both failure modes were confirmed by breaking the fix and watching the tests fail:
2.
eos_boot_log_append()advanced the head past a slot it never wroteProblem
When the write fails the head advances anyway. The slot is left as erased flash but skipped permanently.
Impact
log_headis persisted in the boot control block and reloaded byeos_boot_log_init()on the next boot, so the gap survives the reset.eos_boot_log_read()then returns all-0xFFerased flash that a reader cannot distinguish from a real entry, and the slot is never reused. The consumers affected areeos_fw_read_boot_log()and the recovery log-retrieval command, both of which walk the ring fromeos_boot_log_get_head().This is a diagnostics and post-mortem reliability issue — nothing in the verification chain reads the boot log; attestation is separate, in
secure_boot.c.Reproduced before the fix
Fix
Advance the head only after the write succeeds:
eos_boot_log_append()returnsvoid, so a failure still cannot be reported to the caller — but it can decline to advance. This mirrors whateos_boot_log_clear()in the same file already does for a failed erase, and the reasoning documented there applies equally: reporting success would let the next boot walk over entries that are still present.No signature change, no header change, no new API, no new error code, no change to bootctl.
Regression test
test_append_does_not_advance_head_when_write_failsuses thewrite_resultinjection hook that already existed in the fixture but that no test had exercised. It asserts the head does not move, the target slot is still erased, the next successful append lands in that same slot, the head then advances normally, and the entry written before the failure is untouched.Confirmed load-bearing by reverting the fix and rebuilding:
The pre-existing suite genuinely did not cover this path.
3. Pre-existing host build warnings
Fixed in a separate commit, because
CONTRIBUTING.mdasks for a warning-clean-Wall -Wextrabuild.core/boot_menu.c— two-Wunused-parameterwarnings onport. The cause is worth recording:eos_hal_uart_write(port, data, len)is a compatibility macro ineos_hal.hthat expands toeos_hal_uart_send(data, len)and discards the port, because the HAL exposes only a single-UART API. Honouring the port properly would mean adding per-port HAL calls across every board port, well outside this PR. Added(void)port;— the idiom already used throughoutcore/— with a comment recording why it is ignored, so the warning goes without hiding the API gap.tests/CMakeLists.txt—ld: warning: ignoring duplicate libraries: '../libeboot_core.a'.eboot_test_recoverynamedeboot_core eboot_stage1, buteboot_stage1already linkseboot_corePUBLIC, and the explicit copy sat ahead of the target needing it — the wrong order for a left-to-right archive scan — so CMake appended it again and the linker reported the duplicate. Verified withnmbefore removing it that there is no circular dependency:eboot_corerequires zero symbols fromeboot_stage1, whileeboot_stage1requires many fromeboot_core. The transitive link covers it on GNU ld as well as ld64.Testing
Before
CI-equivalent environment (
pip install pytest pytest-cov, matching whatci.ymldid), on unmodifiedmaster:After
Environment built the way CI now builds it (
pip install -r requirements.txt pytest-cov):36 = 19 pre-existing + 14 signing + 3 new guard.Zero skips.Full results
ctest --no-tests=errorctest --no-tests=errorpytest tests/pytest tests/unit/test_sign_image.pypytest tests/unit/test_requirements.pyctest -R test_boot_logmasterContributors without
cryptographyinstalled still get a clean run with a graceful skip:22 passed, 1 skipped.Everything found, and what was done about it
This came out of a targeted review of the host-testable parts of the tree. It was not an exhaustive audit, and it deliberately did not examine the verification, recovery or key-handling paths.
Fixed in this PR
requirements.txt,ci.ymlcore/boot_log.ceos_boot_log_clear()-Wunused-parameterwarningscore/boot_menu.ctests/CMakeLists.txtFound and deliberately left alone
core/fdt_loader.cis in no CMake source list, so it is never compiled; it has no callers and no testsCMakeLists.txtmax_attempts == 0is interpreted two ways:eos_slot_needs_rollback()treats it as "rollback disabled" andtest_slot_manager.cpins that, whileeos_boot_policy_select()has no such guard so zero forces a rollback every boot — anddocs/PRODUCTION_TESTING_REPORT.mdrecords the latter as intendedcore/boot_policy.c,core/slot_manager.c, docsDockerfiletest-runnerstage installs onlypytest, then runspytest tests/— the same gap as finding 1Dockerfile:23core/sha512.candcore/rollback.care each listed twice inadd_library(eboot_core ...)CMakeLists.txtforeachcovers 16 of the 20 suites;test_ecc,test_storage,test_rollbackandtest_secure_bootget no valgrind targettests/CMakeLists.txtCONTRIBUTING.mdsays "19 C unit test suites" and "all 7 unit tests"; there are 20 registered, andtest_secure_bootis missing from the tableCONTRIBUTING.md.github/PULL_REQUEST_TEMPLATE.mdcontains literal control characters that have eaten the first letter offeat,fix,refactor,testandbuildcore/keystore.cemits a#warningon every buildcore/keystore.c:29EBLDR_PRODUCTION_KEY, and the comment above it says the warning is deliberate. It is doing its job. This is why the build reports 1 warning rather than 0.tests/CMakeLists.txtuses CRLF line endings throughout, sogit diff --checkflags any added line in ittests/CMakeLists.txtRelationship to previous work
The history here is a sequence of correct changes that each left one seam; nothing below was done wrongly.
fix(secure-boot): sign the whole image header, not just the payload hash #40 (
8d8ce74) introducedtests/unit/test_sign_image.pyalong with the v2 signed-header format. The suite is correct and all of it passes. What did not follow was declaringcryptographyin the repository requirements or the CI environment —requirements.txtwas last changed in Implement authenticated recovery log retrieval #31 (d3ae185), three days before that suite existed, and no commit has ever namedcryptographyinrequirements.txtor a workflow. This PR closes that gap.test: pin the .efw image header wire format (38 checks) #67 (
ab5a4bf) rewrote the boot-log suite so it exercisescore/boot_log.crather than its own stubs, and added a fixture that can inject both erase and write failures. The erase path is guarded ineos_boot_log_clear()and covered by a test. The write path had the fixture hook but no test, andeos_boot_log_append()advanced the head unconditionally. This PR adds the missing test and the matching guard, following the pattern test: pin the .efw image header wire format (38 checks) #67 established.For completeness on the boot log's provenance: #41 (
cd9793a) touched onlyci.ymlandCMakeLists.txt, and #49 (e3b6d41) was a purestage1/ → core/rename with no content change.eos_boot_log_append()is byte-identical to its form in the initialv0.1.0commit — the unconditional head advance has been there from the start and had only ever been moved.Limitations
cryptography>=41.0is a conservative maintained-release floor, not an API requirement — the APIs used (Ed25519PrivateKey,Ed25519PublicKey,serialization,InvalidSignature) have been stable since 2.6. Manylinux wheels exist for the runner's Python, so no build toolchain should be needed.cppcheck/clang-tidywere not run locally (no toolchains; valgrind is unavailable on arm64 macOS).Recommended follow-ups
In rough order of value, all deliberately kept out of this PR:
max_attempts == 0contract and alignboot_policy.c,slot_manager.cand the docs behind one answer.core/fdt_loader.cis a live module; if so, wire it in together with tests.-r requirements.txtinstall to theDockerfiletest stage.CONTRIBUTING.mdtest counts and repair the PR template's mangled type list.Happy to open issues or follow-up PRs for any of these.
Checklist