Skip to content

fix(compat): make the POSIX adapter build and run on macOS - #96

Merged
srpatcha merged 2 commits into
embeddedos-org:masterfrom
AshrafAhmed9:fix-macos-posix-adapter
Sep 1, 2026
Merged

srpatcha merged 2 commits into
embeddedos-org:masterfrom
AshrafAhmed9:fix-macos-posix-adapter

Conversation

@AshrafAhmed9

@AshrafAhmed9 AshrafAhmed9 commented Aug 30, 2026 •

Copy link
Copy Markdown

os_adapter_posix.c opens by saying it "runs EoS services on Linux, macOS or
WSL". It does not run on macOS. Darwin does not implement three of the
primitives it reaches for:

primitive on Darwin effect
pthread_mutex_timedlock() absent file does not compile
sem_timedwait() absent file does not compile
sem_init() fails, ENOSYS no unnamed semaphores, so every create fails
sem_getvalue() fails, ENOSYS the count posix_sem_post() checks the ceiling against cannot be read

The first two are compile errors, so Build (macos-latest) cannot pass. The
other two would still fail at runtime once it built.

$ cc -std=c11 -Wall -Wextra -c services/compat/src/os_adapter_posix.c ...
os_adapter_posix.c:330:12: error: call to undeclared function 'pthread_mutex_timedlock'
os_adapter_posix.c:400:18: error: call to undeclared function 'sem_timedwait'

The file had no #ifdef __APPLE__ anywhere, which CONTRIBUTING.md asks for.

The approach

Named semaphores don't help: sem_open() works on Darwin but sem_getvalue()
still doesn't, and posix_sem_post() needs the count to enforce the ceiling.

So on Apple the semaphore is a mutex and a condition variable holding its own
count. That gives an exact sem_getvalue(), and makes the timed wait a real
pthread_cond_timedwait() rather than a poll. The timed mutex lock has no such
equivalent, so it polls pthread_mutex_trylock() on a 1 ms interval until the
deadline — that interval bounds how far a timed lock can overshoot.

Everything else keeps the system primitives. Both sides sit behind the same
compat_* wrappers, so the call sites are unchanged and the non-Apple wrappers
compile to a direct call. The EINTR retry loops moved into the wrappers, where
they were already duplicated at each call site.

Testing

Verified on macOS 15, Apple Clang. Before, the translation unit fails with the
two errors above. After, it compiles clean, no warnings:

$ cc -std=c11 -Wall -Wextra -c services/compat/src/os_adapter_posix.c \
    -I services/compat/include -I include -I kernel/include -I hal/include -I core/include
$

The new primitives were exercised directly rather than assumed:

init(2) -> count=2 ok
trywait -> 0 ok
count=1 ok
trywait on empty -> rc=-1 ok (blocks)
timedwait(100ms) on empty -> rc=-1 after 105ms ok (times out)
timedwait woken by post -> rc=0 after 152ms ok (wakes)
mutex_timedlock(80ms) held -> rc=-1 after 80ms ok (times out)
mutex_timedlock free -> rc=0 ok

That covers the count, the empty case, a timeout that fires, a wait woken by
another thread posting at 150 ms, a timed lock that expires against a held
mutex, and one that succeeds once released.

I couldn't run ctest end to end — master doesn't compile for reasons unrelated
to this file (duplicate test targets in tests/CMakeLists.txt, duplicate
eos_task_set_current_internal in task.c, the unresolved priority-inheritance
merge in sync.c), which #93 and #94 address. Once #94 is in, this file is
reached by the normal build and the suite should run on macOS for the first time.

The file documents itself as running on Linux, macOS or WSL, but Darwin
does not implement three of the primitives it uses:

  - pthread_mutex_timedlock() is absent, so the file does not compile.
  - sem_timedwait() is absent, same.
  - sem_init() fails with ENOSYS. Darwin has no unnamed semaphores, so
    every semaphore creation would fail at runtime even once it built.
  - sem_getvalue() fails with ENOSYS, so the count that posix_sem_post()
    enforces the ceiling against cannot be read back.

Named semaphores are not a way out: sem_open() works but sem_getvalue()
still does not, and the ceiling check needs the count.

On Apple platforms the semaphore is therefore a mutex and a condition
variable holding its own count, which also makes the timed wait exact
rather than polled, and the timed mutex lock polls trylock until the
deadline. Every other POSIX host keeps the system primitives, reached
through the same small compat_* wrappers so the call sites read the same
on both.

CONTRIBUTING.md asks for #ifdef __APPLE__ around Apple-specific code;
this file previously had none.

Verified on macOS 15 with Apple Clang. Before the change the translation
unit fails with two "call to undeclared function" errors; after it
compiles clean under -std=c11 -Wall -Wextra with no warnings. The new
primitives were exercised directly: a 100 ms timed wait on an empty
semaphore returns -1 after 105 ms, the same wait returns 0 after 152 ms
when another thread posts at 150 ms, an 80 ms timed lock on a held mutex
returns -1 after 80 ms, and the same lock succeeds once released.
srpatcha
srpatcha previously approved these changes Aug 30, 2026

@srpatcha srpatcha left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. The Darwin reasoning is right and the Linux path is genuinely
unchanged — I checked that rather than assuming it, because the diff touches
shared call sites, not only #ifdef __APPLE__ blocks.

What I verified

Thirty changed lines sit outside the Apple guards — pthread_mutex_timedlock →
compat_mutex_timedlock, sem_t → compat_sem_t, and the five sem_* calls.
So every POSIX host now goes through the shims, and the question is whether the
#else branch reproduces the originals exactly. It does:

typedef sem_t compat_sem_t;
static int compat_sem_init(compat_sem_t *s, unsigned value) { return sem_init(s, 0, value); }
static int compat_sem_destroy(compat_sem_t *s)              { return sem_destroy(s); }
...

sem_init(s, 0, value) keeps the not-shared 0 from the original call. The
typedef is the same type, so no struct layout moves.

The one thing that looked like a behaviour change is not. compat_sem_wait
carries an EINTR retry loop, but master:393 already had it at the call site:

while ((rc = sem_wait(s)) == -1 && errno == EINTR) { }

It moved into the shim; it was not added.

Built and ran on Linux, applied onto a base where master's duplicate test
targets are repaired:

0 build errors
100% tests passed, 0 tests failed out of 28

What I did not verify

The Darwin behaviour itself. I have no macOS host, so sem_init returning
ENOSYS, sem_getvalue being unavailable even for named semaphores, and the
absence of pthread_mutex_timedlock are all things I am taking from your
description and from the documented Darwin behaviour, not from a run. Saying so
explicitly rather than implying the whole PR is tested.

Two things I would want a macOS run to confirm before relying on it:

  1. COMPAT_LOCK_POLL_NS at 1 ms bounds the overshoot of a timed lock, as your
    comment says. That is the correct trade-off to name, and the value only
    matters if something in the tree takes short timed locks — worth a note in
    the docs if any caller does.
  2. The condition-variable semaphore enforces the ceiling against a count it
    owns. Since that count is now the authority rather than sem_getvalue, a
    post that would exceed max has to be rejected on that path too, not only
    on the POSIX one.

Replacing a polled timed wait with an exact one on Darwin is a real improvement
over what a sem_trywait loop would have given, and the comment block explaining
why named semaphores do not rescue sem_getvalue is exactly the kind of thing
that stops someone "simplifying" this back into a bug later.

Needs #82 or #93 to land first — on master today, CMake cannot generate any
test target, so CI here cannot run its own tests.

@AshrafAhmed9

Copy link
Copy Markdown
Author

Thanks for checking the shared call sites rather than trusting the #ifdef. That was the right thing to be suspicious of, since thirty of the changed lines do sit outside the guards.

On the ceiling. It is rejected on the Apple path. posix_sem_post is shared rather than per-platform, and the check runs through compat_sem_getvalue, which on Darwin reads the count the condvar semaphore owns:

int value = 0;
if (compat_sem_getvalue(&g_sems[handle].sem, &value) == 0 &&
    (uint32_t)value >= g_sems[handle].max) {
    return -1;
}
return compat_sem_post(&g_sems[handle].sem) == 0 ? 0 : -1;

So the count that became the authority is the one the ceiling is tested against.

You are onto something real one level down, though. That check is not atomic. compat_sem_getvalue takes the lock, reads, and releases it; compat_sem_post then takes it again to increment. Two threads posting at count == max - 1 can both see value < max, both succeed, and leave the count at max + 1.

Master has the same shape on POSIX today (sem_getvalue then sem_post, with nothing held across the two), so this is pre-existing rather than something the Apple path introduces. It is now equally broken on both backends instead of just one, which is not much of a defence.

The fix is a per-semaphore mutex held across the check and the increment, on both backends, so the invariant survives contention instead of holding only when posts happen to serialise. That is a real behaviour change to the POSIX path, so I would rather not smuggle it into a PR whose stated job is "make it compile on macOS". Happy either way: say the word and I will add it here, otherwise I will open it separately once this lands.

On the poll interval. Nothing in the tree reaches it. posix_mutex_lock_fn sends timeout_ms == 0 to pthread_mutex_trylock and EOS_OSA_WAIT_FOREVER to plain pthread_mutex_lock, so compat_mutex_timedlock only runs for a finite non-zero timeout, and every eos_mutex_lock call in the repo passes 0 or EOS_NO_WAIT. The 1 ms granularity is unreachable from current callers. It starts to matter the first time somebody adds a finite timed lock, which is exactly the argument for documenting it now rather than after. I will put it in the header comment.

Agreed on #93. CMake cannot generate a test target on master, so CI here has nothing to run until that lands.

COMPAT_LOCK_POLL_NS bounds how far a timed mutex lock can overshoot on
Apple, where pthread_mutex_timedlock() does not exist. No caller reaches
it today: posix_mutex_lock_fn() routes a zero timeout to trylock and
EOS_OSA_WAIT_FOREVER to a plain lock, and every eos_mutex_lock() in the
tree passes 0 or EOS_NO_WAIT. Record the granularity now so the first
caller to want a finite timed lock finds it, rather than after.

Comment only, no behaviour change.

@srpatcha srpatcha left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-approving — my earlier review was auto-dismissed by the follow-up commit, not
withdrawn.

docs(compat): note the timed-lock poll granularity on Darwin addresses the
first of the two things I flagged. Documenting that COMPAT_LOCK_POLL_NS bounds
how far a timed lock can overshoot is the right response: the value is a
trade-off, not a constant someone should tune without knowing what it buys.

Everything in my previous review stands:

  • The Linux path is unchanged. Every compat_* in the #else branch delegates
    to the original call, sem_init(s, 0, value) keeps the not-shared 0, and
    typedef sem_t compat_sem_t moves no struct layout. The EINTR retry loop in
    compat_sem_wait was already at the call site on master:393; it moved rather
    than appearing.
  • Built and ran on Linux against a repaired base: 0 build errors, 28/28
    passed
    .
  • I still have not verified the Darwin behaviour itself — no macOS host here — so
    sem_init returning ENOSYS, sem_getvalue being unavailable even for named
    semaphores, and the absence of pthread_mutex_timedlock are taken from your
    description and the documented Darwin behaviour, not from a run.

The second point from last time is still open, and is the one I would want a
macOS run to settle: the condition-variable semaphore now owns its own count, so
the max ceiling has to be enforced on that path too, not only on the POSIX one.
Worth a test if you have a Darwin machine.

Needs #82 or #93 to land first — on master today CMake cannot generate a single
test target, so CI here cannot run its own tests.

@AshrafAhmed9

Copy link
Copy Markdown
Author

Ran the ceiling on Darwin. It holds.

The coverage you wanted already exists: test_semaphore_ceiling in tests/test_os_adapter.c drives it through the adapter vtable, so on Apple it exercises the condvar semaphore rather than a POSIX one. It just had never been run on a Mac.

macOS 15, Apple Clang, arm64:

=== EoS OS Adapter Tests ===
-- posix semaphore --
  [PASS] semaphore honours its ceiling
...
=== 83/83 checks passed ===

So post beyond max is refused, the count is unchanged after the refusal, and sem_create with initial > max is still rejected, all against the count the condvar semaphore owns.

One thing that test does not prove, and I would rather say it than let the green run imply otherwise: the ceiling check is single-threaded there. posix_sem_post still reads the count and increments it under two separate lock acquisitions, so the race I mentioned earlier is untouched by this result. It is the same on both backends and predates this PR.

Getting it to run took an unrelated fix

eos does not link on macOS at all right now, independently of this PR. #90 wired eos_hal_init() to pick a backend:

#ifdef __linux__
    eos_hal_linux_register();
#else
    eos_hal_rtos_register();
#endif

but CMake picks the source file on a different question:

if(EOS_PLATFORM STREQUAL "linux" OR NOT CMAKE_CROSSCOMPILING)
    target_sources(eos_hal PRIVATE hal/src/hal_linux.c)
else()
    target_sources(eos_hal PRIVATE hal/src/hal_rtos.c)
endif()

CMake asks "am I cross-compiling", the C asks "am I on Linux". On a Mac those disagree. CMake compiles hal_linux.c, whose body is entirely inside #ifdef __linux__ and so emits no symbols, while the preprocessor takes the #else and calls eos_hal_rtos_register(), which lives in hal_rtos.c. That file is not compiled, and cannot be on a host: it contains __asm volatile ("cpsid i").

Undefined symbols for architecture arm64:
  "_eos_hal_rtos_register", referenced from:
      _eos_hal_init in libeos_hal.a[2](hal_common.c.o)

The comment above the declarations in hal.h states the invariant the build does not implement: "Exactly one of these is compiled: hal_linux.c is guarded on __linux__ and hal_rtos.c on its absence."

I widened all three guards to defined(__linux__) || defined(__APPLE__) locally to get the suite running. The hal_linux.c body then compiles clean on Darwin under -Wall -Wextra, so the host backend does appear to be portable rather than Linux-specific. None of that is in this PR. Happy to open it separately if you want it fixed that way, or leave it to you if the intended answer is a third host backend.

Kartikey1306 added a commit to Kartikey1306/eos that referenced this pull request Sep 1, 2026
eos_dmverity_create() has a fallback: if veritysetup is missing or fails, it
runs `sha256sum <image>` and stores the result in dv->root_hash.

A dm-verity root hash is the root of a Merkle tree over the image's blocks --
that structure is what lets the kernel check each block as it is read, at read
time. A flat digest of the whole file is a different thing that happens to be
the same length and shape, so the substitution is invisible in every printout
and comparison. What it is not is usable: eos_dmverity_generate_table() emits
that value into a dm-verity table the kernel will reject, and anything treating
root_hash as an integrity token holds a hash with none of dm-verity's
properties.

A hash device that could not be built is a failure. Reporting one is more
useful than inventing a hash that cannot verify anything. Fallback removed.

Second, in the same function:

    dv->verified = (dv->root_hash[0] != '\0');
    return dv->verified ? 0 : -1;

`verified` means an image was checked against a known-good root hash. Creating
a hash device is not that check -- it establishes the root hash, it does not
verify anything against it. Setting the flag here made every successful
create() indistinguishable from a completed verification to any caller reading
the field, including eos_dmverity_dump(). eos_dmverity_verify() already sets it
correctly from `veritysetup verify`'s exit status, and is now the only place
that sets it. create() returns the same 0/-1 as before, so its own callers are
unaffected.

eos_dmverity_init() memsets the struct, so verified starts false and stays
false until something actually verifies.

Verified: eos_linux_security builds clean; full Release suite 25/28 with the
three Not-Run being services/compat's pthread_mutex_timedlock/sem_timedwait gap
on macOS (embeddedos-org#96's subject, unrelated). Linux CI builds all 28.

Not addressed here, deliberately: this file drives veritysetup, cryptsetup and
friends through system()/popen() with paths interpolated into shell strings --
ten call sites. That is a real injection surface and a separate piece of work;
folding it into a correctness fix would bury both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
srpatcha pushed a commit that referenced this pull request Sep 1, 2026
… it (#110)

* fix(hal): let the build system, not the preprocessor, pick the HAL backend

Which backend source CMake compiles and which registrar eos_hal_init() calls
are the same decision made in two places, with two different conditions.

CMakeLists.txt:

    if(EOS_PLATFORM STREQUAL "linux" OR NOT CMAKE_CROSSCOMPILING)
        target_sources(eos_hal PRIVATE hal/src/hal_linux.c)
    else()
        target_sources(eos_hal PRIVATE hal/src/hal_rtos.c)

hal_common.c:

    #ifdef __linux__
        eos_hal_linux_register();
    #else
        eos_hal_rtos_register();

They agree on Linux and on a cross build, and disagree on every other host. A
macOS host build compiles hal_linux.c, then references a symbol from the file
it did not compile:

    Undefined symbols for architecture arm64:
      "_eos_hal_rtos_register", referenced from: _eos_hal_init

CMake now sets EOS_HAL_BACKEND_HOST or EOS_HAL_BACKEND_RTOS next to the
target_sources() call that makes the choice, and hal_common.c keys off that.
__linux__ stays as a fallback for anyone compiling these files without CMake.

Two things had to move with it:

- hal.h declared the registrars under the same #ifdef, so hal_common.c could
  not even name the one CMake had compiled. Both are declared unconditionally
  now; exactly one is still defined per build, and an unreferenced declaration
  costs nothing. The header's own comment already argued for this -- "an
  undeclared registrar is one nobody can find".
- hal_linux.c wraps its whole body in #ifdef __linux__, so on macOS it compiled
  to an empty translation unit and the build had no backend at all. It is the
  host backend rather than the Linux one: the only platform calls in it are
  clock_gettime() and nanosleep(), both POSIX. Guarded on the CMake define,
  with __linux__ as the fallback. hal_rtos.c takes the mirror condition.

Linux behaviour is unchanged: EOS_HAL_BACKEND_HOST and __linux__ are both
defined there, and the same registrar is selected as before. Cross builds get
EOS_HAL_BACKEND_RTOS and are likewise unchanged. eos master CI is green on
Linux and stays that way.

Verified on macOS: the eos_hal link error is gone and ctest goes from 0/28 to
19/28.

This does NOT make macOS a supported host on its own. The remaining 9 failures
are services/compat/src/os_adapter_posix.c calling pthread_mutex_timedlock()
and sem_timedwait(), neither of which macOS provides. That is a separate piece
of work in a different subsystem and I have not attempted it here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* ci: build eBoot for qemu_arm64 with EBLDR_VERIFY_STAGE1 off

With -DEBLDR_BOARD=qemu_arm64 actually taking effect (it was -DBOARD= before
and silently ignored), eBoot's configure now runs the board path and refuses:

    EBLDR_VERIFY_STAGE1 is ON but board qemu_arm64 has no stage-1 linker
    script, so there is no stage-1 image to hash. Configure with
    -DEBLDR_VERIFY_STAGE1=OFF to build stage-0 without the integrity check.

That refusal is correct -- the board ships only board_qemu_arm64.c/.h, no
linker scripts, so there is no stage-1 image for stage-0 to verify -- and the
message names the flag. Passed here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* ci: surface QEMU's error instead of discarding it

The QEMU step ran for ~20ms, left an empty serial log, and reported nothing --
because its stderr went to /dev/null and its exit code into `|| true`. The one
message that says why the guest did not boot was the message being thrown away.

Keep the run non-fatal only for exit 124 (timeout(1) killing a still-running
guest, which is how a bare-metal image "finishes"); any other nonzero exit now
fails the step loudly with QEMU's own stderr printed. Also list the artifact
files before booting, so a missing or truncated eos_qemu.bin is visible.

The TC validation gate below is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* ci: give the virt machine no NIC instead of a NIC with no ROM

With stderr finally visible, the reason the guest never booted is one line:

    qemu-system-aarch64: failed to find romfile "efi-virtio.rom"

The workflow installs qemu-system-arm with --no-install-recommends, which
skips ipxe-qemu -- the package that ships efi-virtio.rom. The virt machine
creates a default virtio-net device, that device wants its boot ROM, and QEMU
exits during machine init. This has been the failure since the job first ran;
it was just invisible behind 2>/dev/null.

The simulation uses no networking, so -nic none removes the device rather
than installing a ROM for a NIC nothing talks to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* ci: bound each C test, and stop cancelling master's runs

The Build & Test (Linux x86_64) job hangs in "Run C unit tests" and holds the
workflow open until GitHub's 6-hour limit. Which test hangs is currently
unknowable from the outside: ctest runs with no per-test timeout, so the job
never reaches the summary that would name it.

--timeout 120 turns the invisible hang into a named failure. 120s is generous
-- the slowest test in this suite takes 1.4s.

Second, related: the concurrency group I added cancels in-progress runs on
every push. Right for PR branches; wrong for master, where each merge
cancelled the previous merge's run -- which is why this job has not been
allowed to COMPLETE on master since 08-30 and the hang went unnoticed.
cancel-in-progress is now conditional on the ref not being master.

Verified locally (macOS): ctest 25/28 pass with the timeout flag; the 3
remaining are Not Run because test_os_adapter/test_os_services/test_ota do not
build here (the known pthread_mutex_timedlock/sem_timedwait gap). Those three
are Linux-only -- exactly the ones this timeout will name if one of them is
the hang.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(tests): test_net must not hide its calls inside assert()

The test named by the new ctest timeout is test_net, and the hang is not a
networking bug. CI builds Release; Release defines NDEBUG; NDEBUG deletes
assert() together with the expression inside it. Half of test_net's checks
wrap the call under test:

    assert(eos_net_connect(c, &addr) == 0);
    assert(eos_net_send(c, "ping", 4) == 4);

With those gone, no client ever connects, the echo thread blocks in accept()
forever, and pthread_join() hangs the suite -- for exactly as long as nothing
bounds the test, which is what the previous commit fixed.

Reproduced locally before fixing: a Release build of the unmodified test hangs
on macOS too (this was never Linux-specific; my earlier local runs simply were
not Release). Replaced every assert() with a CHECK() macro that always
evaluates and exits nonzero on failure.

After: Release test_net passes 19/19 in under a second; full local Release
ctest 27/28 (the 1 is test_os_adapter, Not Run on macOS as known).

Same latent class elsewhere: other C tests also use assert() and silently
check nothing in Release builds -- they pass today because their expressions
have no needed side effects that block, but they are weaker than they look.
Worth a sweep in its own PR rather than burying it here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(hal): keep hal_linux.c an empty TU on bare metal

The ARM Cortex-M4 job caught a regression in my backend-selection change:

    hal/src/hal_linux.c:30:10: fatal error: sys/ioctl.h: No such file or directory

That cross build also compiles hal_linux.c -- EOS_PLATFORM defaults to
"linux", so CMake's host branch is taken even for arm-none-eabi -- and it only
ever built because `#ifdef __linux__` reduced the file to an empty translation
unit there. Widening the guard to EOS_HAL_BACKEND_HOST un-emptied it on a
toolchain with no POSIX headers.

The guard now asks the right question -- "is this a hosted POSIX build?" --
once, in hal.h:

    #if defined(__linux__) || \
        (defined(EOS_HAL_BACKEND_HOST) && (defined(__unix__) || defined(__APPLE__)))
    #define EOS_HAL_HOSTED 1

and hal_linux.c, hal_rtos.c and hal_common.c all key off EOS_HAL_HOSTED
instead of each doing their own arithmetic. arm-none-eabi defines none of
those macros, so the empty-TU behavior the ARM job depends on is restored;
aarch64-linux-gnu defines __linux__, so the QEMU simulation lane is unchanged;
macOS defines __APPLE__, so the original fix stands.

Verified locally by preprocessing hal_linux.c both ways (clang -U__APPLE__
-U__unix__ -U__linux__ simulating bare metal: body absent; hosted: present)
and a full Release build + ctest: 26/28, the 2 remaining being the documented
macOS Not-Runs (pthread_mutex_timedlock/sem_timedwait).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(fuzz): point the OTA harness at a function that exists, and build it

tests/fuzz/fuzz_ota_header.c declared and called

    extern int eos_ota_parse_header(const void *data, size_t len);

There is no such function anywhere in the tree. The harness was also commented
out of tests/fuzz/CMakeLists.txt, so it was dead twice over -- and could not
have compiled if anyone had enabled it. Same shape as the fuzz_devicetree
harness fixed in #50, which declared an eos_dtb_parse() that likewise did not
exist.

Rewritten against the real API, and aimed at the part that actually takes
attacker-controlled bytes. eos_ota_source_t carries a declared size and an
expected digest that travel WITH the update, and eos_ota_write_chunk() streams
the payload, so the harness treats the first 36 bytes of fuzz input as that
control block and the rest as payload -- fed in fuzz-chosen slice widths so
chunk boundaries vary rather than always landing on the same offsets.

It finishes with verify/apply/rollback so the digest comparison and the
authenticity gate are exercised, with an authenticator that refuses
everything: apply() must never install a fuzz-chosen image, and wiring one in
means the run cannot silently stop at the gate added in #75.

Not run here: libFuzzer itself. AppleClang ships no libclang_rt.fuzzer_osx.a,
so `-fsanitize=fuzzer` does not link on this machine. What I did instead was
build LLVMFuzzerTestOneInput against a small driver under
-fsanitize=address,undefined -fno-sanitize-recover=all and feed it random
inputs:

    5 seeds x 40,000 inputs = 200,000 -- no sanitizer reports

That proves the harness body runs and the ingest path survives arbitrary
input; it is not coverage-guided, and CI with a real libFuzzer toolchain is
where that happens.

CMake now reports:
    -- Fuzz targets: fuzz_sha256, fuzz_aes, fuzz_devicetree, fuzz_ota_header

Verified: ctest 26/28, the two Not-Run being targets that need services/compat,
which does not build on macOS (#96's subject).

Stacked on #102, which repairs the macOS HAL link.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
srpatcha pushed a commit that referenced this pull request Sep 1, 2026
… zero (#109)

* fix(hal): let the build system, not the preprocessor, pick the HAL backend

Which backend source CMake compiles and which registrar eos_hal_init() calls
are the same decision made in two places, with two different conditions.

CMakeLists.txt:

    if(EOS_PLATFORM STREQUAL "linux" OR NOT CMAKE_CROSSCOMPILING)
        target_sources(eos_hal PRIVATE hal/src/hal_linux.c)
    else()
        target_sources(eos_hal PRIVATE hal/src/hal_rtos.c)

hal_common.c:

    #ifdef __linux__
        eos_hal_linux_register();
    #else
        eos_hal_rtos_register();

They agree on Linux and on a cross build, and disagree on every other host. A
macOS host build compiles hal_linux.c, then references a symbol from the file
it did not compile:

    Undefined symbols for architecture arm64:
      "_eos_hal_rtos_register", referenced from: _eos_hal_init

CMake now sets EOS_HAL_BACKEND_HOST or EOS_HAL_BACKEND_RTOS next to the
target_sources() call that makes the choice, and hal_common.c keys off that.
__linux__ stays as a fallback for anyone compiling these files without CMake.

Two things had to move with it:

- hal.h declared the registrars under the same #ifdef, so hal_common.c could
  not even name the one CMake had compiled. Both are declared unconditionally
  now; exactly one is still defined per build, and an unreferenced declaration
  costs nothing. The header's own comment already argued for this -- "an
  undeclared registrar is one nobody can find".
- hal_linux.c wraps its whole body in #ifdef __linux__, so on macOS it compiled
  to an empty translation unit and the build had no backend at all. It is the
  host backend rather than the Linux one: the only platform calls in it are
  clock_gettime() and nanosleep(), both POSIX. Guarded on the CMake define,
  with __linux__ as the fallback. hal_rtos.c takes the mirror condition.

Linux behaviour is unchanged: EOS_HAL_BACKEND_HOST and __linux__ are both
defined there, and the same registrar is selected as before. Cross builds get
EOS_HAL_BACKEND_RTOS and are likewise unchanged. eos master CI is green on
Linux and stays that way.

Verified on macOS: the eos_hal link error is gone and ctest goes from 0/28 to
19/28.

This does NOT make macOS a supported host on its own. The remaining 9 failures
are services/compat/src/os_adapter_posix.c calling pthread_mutex_timedlock()
and sem_timedwait(), neither of which macOS provides. That is a separate piece
of work in a different subsystem and I have not attempted it here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* ci: build eBoot for qemu_arm64 with EBLDR_VERIFY_STAGE1 off

With -DEBLDR_BOARD=qemu_arm64 actually taking effect (it was -DBOARD= before
and silently ignored), eBoot's configure now runs the board path and refuses:

    EBLDR_VERIFY_STAGE1 is ON but board qemu_arm64 has no stage-1 linker
    script, so there is no stage-1 image to hash. Configure with
    -DEBLDR_VERIFY_STAGE1=OFF to build stage-0 without the integrity check.

That refusal is correct -- the board ships only board_qemu_arm64.c/.h, no
linker scripts, so there is no stage-1 image for stage-0 to verify -- and the
message names the flag. Passed here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* ci: surface QEMU's error instead of discarding it

The QEMU step ran for ~20ms, left an empty serial log, and reported nothing --
because its stderr went to /dev/null and its exit code into `|| true`. The one
message that says why the guest did not boot was the message being thrown away.

Keep the run non-fatal only for exit 124 (timeout(1) killing a still-running
guest, which is how a bare-metal image "finishes"); any other nonzero exit now
fails the step loudly with QEMU's own stderr printed. Also list the artifact
files before booting, so a missing or truncated eos_qemu.bin is visible.

The TC validation gate below is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* ci: give the virt machine no NIC instead of a NIC with no ROM

With stderr finally visible, the reason the guest never booted is one line:

    qemu-system-aarch64: failed to find romfile "efi-virtio.rom"

The workflow installs qemu-system-arm with --no-install-recommends, which
skips ipxe-qemu -- the package that ships efi-virtio.rom. The virt machine
creates a default virtio-net device, that device wants its boot ROM, and QEMU
exits during machine init. This has been the failure since the job first ran;
it was just invisible behind 2>/dev/null.

The simulation uses no networking, so -nic none removes the device rather
than installing a ROM for a NIC nothing talks to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* ci: bound each C test, and stop cancelling master's runs

The Build & Test (Linux x86_64) job hangs in "Run C unit tests" and holds the
workflow open until GitHub's 6-hour limit. Which test hangs is currently
unknowable from the outside: ctest runs with no per-test timeout, so the job
never reaches the summary that would name it.

--timeout 120 turns the invisible hang into a named failure. 120s is generous
-- the slowest test in this suite takes 1.4s.

Second, related: the concurrency group I added cancels in-progress runs on
every push. Right for PR branches; wrong for master, where each merge
cancelled the previous merge's run -- which is why this job has not been
allowed to COMPLETE on master since 08-30 and the hang went unnoticed.
cancel-in-progress is now conditional on the ref not being master.

Verified locally (macOS): ctest 25/28 pass with the timeout flag; the 3
remaining are Not Run because test_os_adapter/test_os_services/test_ota do not
build here (the known pthread_mutex_timedlock/sem_timedwait gap). Those three
are Linux-only -- exactly the ones this timeout will name if one of them is
the hang.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(tests): test_net must not hide its calls inside assert()

The test named by the new ctest timeout is test_net, and the hang is not a
networking bug. CI builds Release; Release defines NDEBUG; NDEBUG deletes
assert() together with the expression inside it. Half of test_net's checks
wrap the call under test:

    assert(eos_net_connect(c, &addr) == 0);
    assert(eos_net_send(c, "ping", 4) == 4);

With those gone, no client ever connects, the echo thread blocks in accept()
forever, and pthread_join() hangs the suite -- for exactly as long as nothing
bounds the test, which is what the previous commit fixed.

Reproduced locally before fixing: a Release build of the unmodified test hangs
on macOS too (this was never Linux-specific; my earlier local runs simply were
not Release). Replaced every assert() with a CHECK() macro that always
evaluates and exits nonzero on failure.

After: Release test_net passes 19/19 in under a second; full local Release
ctest 27/28 (the 1 is test_os_adapter, Not Run on macOS as known).

Same latent class elsewhere: other C tests also use assert() and silently
check nothing in Release builds -- they pass today because their expressions
have no needed side effects that block, but they are weaker than they look.
Worth a sweep in its own PR rather than burying it here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(hal): keep hal_linux.c an empty TU on bare metal

The ARM Cortex-M4 job caught a regression in my backend-selection change:

    hal/src/hal_linux.c:30:10: fatal error: sys/ioctl.h: No such file or directory

That cross build also compiles hal_linux.c -- EOS_PLATFORM defaults to
"linux", so CMake's host branch is taken even for arm-none-eabi -- and it only
ever built because `#ifdef __linux__` reduced the file to an empty translation
unit there. Widening the guard to EOS_HAL_BACKEND_HOST un-emptied it on a
toolchain with no POSIX headers.

The guard now asks the right question -- "is this a hosted POSIX build?" --
once, in hal.h:

    #if defined(__linux__) || \
        (defined(EOS_HAL_BACKEND_HOST) && (defined(__unix__) || defined(__APPLE__)))
    #define EOS_HAL_HOSTED 1

and hal_linux.c, hal_rtos.c and hal_common.c all key off EOS_HAL_HOSTED
instead of each doing their own arithmetic. arm-none-eabi defines none of
those macros, so the empty-TU behavior the ARM job depends on is restored;
aarch64-linux-gnu defines __linux__, so the QEMU simulation lane is unchanged;
macOS defines __APPLE__, so the original fix stands.

Verified locally by preprocessing hal_linux.c both ways (clang -U__APPLE__
-U__unix__ -U__linux__ simulating bare metal: body absent; hosted: present)
and a full Release build + ctest: 26/28, the 2 remaining being the documented
macOS Not-Runs (pthread_mutex_timedlock/sem_timedwait).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(mpu): reject regions the MPU cannot program, and stop dividing by zero

eos_mpu_validate() returns an error count, and its two hardware-constraint
checks did not contribute to it:

    if (reg->size > 0 && (reg->size & (reg->size - 1)) != 0)
        printf("  WARN: ... size 0x%x not power-of-2\n", ...);
    if (reg->base_addr % reg->size != 0 && reg->size > 0)
        printf("  WARN: ... base 0x%x not aligned to size 0x%x\n", ...);

Neither touches `errors`, so a configuration the hardware cannot program is
reported clean and eos_mpu_generate_config() writes it out.

These are not warnings. ARMv7-M encodes a region's extent as a power-of-two
exponent in RASR.SIZE and requires RBAR's base to be aligned to that extent. A
region violating either cannot be expressed, so what actually gets programmed
covers different memory than was asked for -- and for a task-isolation
service, "isolated, but not over the range you specified" is precisely the
failure this validation exists to catch. Both now count, along with the
32-byte floor RASR.SIZE implies, which nothing checked at all.

Second, in the same expression: `reg->base_addr % reg->size != 0 && reg->size > 0`
divides before it checks. && only guards what is to its right, so a zero-sized
region reaches the modulo. eos_mpu_add_region() never validates size, so this
is reachable from any caller:

    services/rtos/src/rtos_security.c:80:32: runtime error: division by zero
    SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior

Worth knowing why it went unnoticed: on x86 a zero divisor raises SIGFPE and
the process dies, while AArch64's udiv quietly yields 0. The same input
crashes on the CI host and returns silently on an Apple-silicon dev machine.
Size 0 is now rejected first, and the check `continue`s past everything that
divides by or masks with size.

tests/test_mpu_validate.c is new -- this service had no tests. It covers a
programmable region validating clean, then each rejection: non-power-of-two,
misaligned base, zero size, below the 32-byte minimum, and an unprogrammable
region inside a satisfied region budget. Against the unpatched validate() it
fails on the non-power-of-two case, which prints WARN and returns 0.

Verified: ctest 27/29. The two Not-Run are test_os_adapter and
test_os_services, which need services/compat and do not build on macOS
(pthread_mutex_timedlock/sem_timedwait -- #96's subject).

Stacked on #102, which repairs the macOS HAL link.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
srpatcha pushed a commit that referenced this pull request Sep 1, 2026
* fix(hal): let the build system, not the preprocessor, pick the HAL backend

Which backend source CMake compiles and which registrar eos_hal_init() calls
are the same decision made in two places, with two different conditions.

CMakeLists.txt:

    if(EOS_PLATFORM STREQUAL "linux" OR NOT CMAKE_CROSSCOMPILING)
        target_sources(eos_hal PRIVATE hal/src/hal_linux.c)
    else()
        target_sources(eos_hal PRIVATE hal/src/hal_rtos.c)

hal_common.c:

    #ifdef __linux__
        eos_hal_linux_register();
    #else
        eos_hal_rtos_register();

They agree on Linux and on a cross build, and disagree on every other host. A
macOS host build compiles hal_linux.c, then references a symbol from the file
it did not compile:

    Undefined symbols for architecture arm64:
      "_eos_hal_rtos_register", referenced from: _eos_hal_init

CMake now sets EOS_HAL_BACKEND_HOST or EOS_HAL_BACKEND_RTOS next to the
target_sources() call that makes the choice, and hal_common.c keys off that.
__linux__ stays as a fallback for anyone compiling these files without CMake.

Two things had to move with it:

- hal.h declared the registrars under the same #ifdef, so hal_common.c could
  not even name the one CMake had compiled. Both are declared unconditionally
  now; exactly one is still defined per build, and an unreferenced declaration
  costs nothing. The header's own comment already argued for this -- "an
  undeclared registrar is one nobody can find".
- hal_linux.c wraps its whole body in #ifdef __linux__, so on macOS it compiled
  to an empty translation unit and the build had no backend at all. It is the
  host backend rather than the Linux one: the only platform calls in it are
  clock_gettime() and nanosleep(), both POSIX. Guarded on the CMake define,
  with __linux__ as the fallback. hal_rtos.c takes the mirror condition.

Linux behaviour is unchanged: EOS_HAL_BACKEND_HOST and __linux__ are both
defined there, and the same registrar is selected as before. Cross builds get
EOS_HAL_BACKEND_RTOS and are likewise unchanged. eos master CI is green on
Linux and stays that way.

Verified on macOS: the eos_hal link error is gone and ctest goes from 0/28 to
19/28.

This does NOT make macOS a supported host on its own. The remaining 9 failures
are services/compat/src/os_adapter_posix.c calling pthread_mutex_timedlock()
and sem_timedwait(), neither of which macOS provides. That is a separate piece
of work in a different subsystem and I have not attempted it here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* ci: build eBoot for qemu_arm64 with EBLDR_VERIFY_STAGE1 off

With -DEBLDR_BOARD=qemu_arm64 actually taking effect (it was -DBOARD= before
and silently ignored), eBoot's configure now runs the board path and refuses:

    EBLDR_VERIFY_STAGE1 is ON but board qemu_arm64 has no stage-1 linker
    script, so there is no stage-1 image to hash. Configure with
    -DEBLDR_VERIFY_STAGE1=OFF to build stage-0 without the integrity check.

That refusal is correct -- the board ships only board_qemu_arm64.c/.h, no
linker scripts, so there is no stage-1 image for stage-0 to verify -- and the
message names the flag. Passed here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* ci: surface QEMU's error instead of discarding it

The QEMU step ran for ~20ms, left an empty serial log, and reported nothing --
because its stderr went to /dev/null and its exit code into `|| true`. The one
message that says why the guest did not boot was the message being thrown away.

Keep the run non-fatal only for exit 124 (timeout(1) killing a still-running
guest, which is how a bare-metal image "finishes"); any other nonzero exit now
fails the step loudly with QEMU's own stderr printed. Also list the artifact
files before booting, so a missing or truncated eos_qemu.bin is visible.

The TC validation gate below is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* ci: give the virt machine no NIC instead of a NIC with no ROM

With stderr finally visible, the reason the guest never booted is one line:

    qemu-system-aarch64: failed to find romfile "efi-virtio.rom"

The workflow installs qemu-system-arm with --no-install-recommends, which
skips ipxe-qemu -- the package that ships efi-virtio.rom. The virt machine
creates a default virtio-net device, that device wants its boot ROM, and QEMU
exits during machine init. This has been the failure since the job first ran;
it was just invisible behind 2>/dev/null.

The simulation uses no networking, so -nic none removes the device rather
than installing a ROM for a NIC nothing talks to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* ci: bound each C test, and stop cancelling master's runs

The Build & Test (Linux x86_64) job hangs in "Run C unit tests" and holds the
workflow open until GitHub's 6-hour limit. Which test hangs is currently
unknowable from the outside: ctest runs with no per-test timeout, so the job
never reaches the summary that would name it.

--timeout 120 turns the invisible hang into a named failure. 120s is generous
-- the slowest test in this suite takes 1.4s.

Second, related: the concurrency group I added cancels in-progress runs on
every push. Right for PR branches; wrong for master, where each merge
cancelled the previous merge's run -- which is why this job has not been
allowed to COMPLETE on master since 08-30 and the hang went unnoticed.
cancel-in-progress is now conditional on the ref not being master.

Verified locally (macOS): ctest 25/28 pass with the timeout flag; the 3
remaining are Not Run because test_os_adapter/test_os_services/test_ota do not
build here (the known pthread_mutex_timedlock/sem_timedwait gap). Those three
are Linux-only -- exactly the ones this timeout will name if one of them is
the hang.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(tests): test_net must not hide its calls inside assert()

The test named by the new ctest timeout is test_net, and the hang is not a
networking bug. CI builds Release; Release defines NDEBUG; NDEBUG deletes
assert() together with the expression inside it. Half of test_net's checks
wrap the call under test:

    assert(eos_net_connect(c, &addr) == 0);
    assert(eos_net_send(c, "ping", 4) == 4);

With those gone, no client ever connects, the echo thread blocks in accept()
forever, and pthread_join() hangs the suite -- for exactly as long as nothing
bounds the test, which is what the previous commit fixed.

Reproduced locally before fixing: a Release build of the unmodified test hangs
on macOS too (this was never Linux-specific; my earlier local runs simply were
not Release). Replaced every assert() with a CHECK() macro that always
evaluates and exits nonzero on failure.

After: Release test_net passes 19/19 in under a second; full local Release
ctest 27/28 (the 1 is test_os_adapter, Not Run on macOS as known).

Same latent class elsewhere: other C tests also use assert() and silently
check nothing in Release builds -- they pass today because their expressions
have no needed side effects that block, but they are weaker than they look.
Worth a sweep in its own PR rather than burying it here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(hal): keep hal_linux.c an empty TU on bare metal

The ARM Cortex-M4 job caught a regression in my backend-selection change:

    hal/src/hal_linux.c:30:10: fatal error: sys/ioctl.h: No such file or directory

That cross build also compiles hal_linux.c -- EOS_PLATFORM defaults to
"linux", so CMake's host branch is taken even for arm-none-eabi -- and it only
ever built because `#ifdef __linux__` reduced the file to an empty translation
unit there. Widening the guard to EOS_HAL_BACKEND_HOST un-emptied it on a
toolchain with no POSIX headers.

The guard now asks the right question -- "is this a hosted POSIX build?" --
once, in hal.h:

    #if defined(__linux__) || \
        (defined(EOS_HAL_BACKEND_HOST) && (defined(__unix__) || defined(__APPLE__)))
    #define EOS_HAL_HOSTED 1

and hal_linux.c, hal_rtos.c and hal_common.c all key off EOS_HAL_HOSTED
instead of each doing their own arithmetic. arm-none-eabi defines none of
those macros, so the empty-TU behavior the ARM job depends on is restored;
aarch64-linux-gnu defines __linux__, so the QEMU simulation lane is unchanged;
macOS defines __APPLE__, so the original fix stands.

Verified locally by preprocessing hal_linux.c both ways (clang -U__APPLE__
-U__unix__ -U__linux__ simulating bare metal: body absent; hosted: present)
and a full Release build + ctest: 26/28, the 2 remaining being the documented
macOS Not-Runs (pthread_mutex_timedlock/sem_timedwait).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ota): log the refusal through EOS_ERROR, not fprintf

Follow-up to the review on #75:

    "the refusal uses fprintf(stderr, ...) while the tree has EOS_ERROR in
     core/include/eos/log.h, which is what core/src/config.c uses. On a target
     with no hosted stdio this is the one line in the change that will not
     build."

Correct, and it is live on master. eos_ota_apply()'s refusal path was the only
reason services/ota/src/ota.c included <stdio.h> at all, so the include goes
with it -- an OTA service that cannot compile without a hosted stdio is not
much use to the targets this project exists for.

EOS_ERROR expands to eos_log(), which lives in eos_core, so eos_ota now links
it. That is a new edge in the dependency graph and worth being explicit about:
eos_core is core/src/{log,config,graph,cache,lockfile,layer,scheduler}.c and
does not depend on eos_ota, so it introduces no cycle.

Message text is unchanged apart from dropping the trailing newline, which
eos_log adds, and the "eos-ota:" prefix, which the log layer supplies.

Verified: eos_ota builds clean; ctest 26/28, the two Not-Run being
test_os_adapter and test_os_services, which need services/compat and do not
build on macOS (pthread_mutex_timedlock/sem_timedwait -- #96's subject, not
this). test_ota itself is 9/9 including the four authenticator cases that
exercise this exact path.

Stacked on #102, which repairs the macOS HAL link.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@srpatcha
srpatcha merged commit d4f4fb8 into embeddedos-org:master Sep 1, 2026
Kartikey1306 added a commit to Kartikey1306/eos that referenced this pull request Sep 1, 2026
eos_dmverity_create() has a fallback: if veritysetup is missing or fails, it
runs `sha256sum <image>` and stores the result in dv->root_hash.

A dm-verity root hash is the root of a Merkle tree over the image's blocks --
that structure is what lets the kernel check each block as it is read, at read
time. A flat digest of the whole file is a different thing that happens to be
the same length and shape, so the substitution is invisible in every printout
and comparison. What it is not is usable: eos_dmverity_generate_table() emits
that value into a dm-verity table the kernel will reject, and anything treating
root_hash as an integrity token holds a hash with none of dm-verity's
properties.

A hash device that could not be built is a failure. Reporting one is more
useful than inventing a hash that cannot verify anything. Fallback removed.

Second, in the same function:

    dv->verified = (dv->root_hash[0] != '\0');
    return dv->verified ? 0 : -1;

`verified` means an image was checked against a known-good root hash. Creating
a hash device is not that check -- it establishes the root hash, it does not
verify anything against it. Setting the flag here made every successful
create() indistinguishable from a completed verification to any caller reading
the field, including eos_dmverity_dump(). eos_dmverity_verify() already sets it
correctly from `veritysetup verify`'s exit status, and is now the only place
that sets it. create() returns the same 0/-1 as before, so its own callers are
unaffected.

eos_dmverity_init() memsets the struct, so verified starts false and stays
false until something actually verifies.

Verified: eos_linux_security builds clean; full Release suite 25/28 with the
three Not-Run being services/compat's pthread_mutex_timedlock/sem_timedwait gap
on macOS (embeddedos-org#96's subject, unrelated). Linux CI builds all 28.

Not addressed here, deliberately: this file drives veritysetup, cryptsetup and
friends through system()/popen() with paths interpolated into shell strings --
ten call sites. That is a real injection surface and a separate piece of work;
folding it into a correctness fix would bury both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
srpatcha pushed a commit that referenced this pull request Sep 1, 2026
#105)

eos_dmverity_create() has a fallback: if veritysetup is missing or fails, it
runs `sha256sum <image>` and stores the result in dv->root_hash.

A dm-verity root hash is the root of a Merkle tree over the image's blocks --
that structure is what lets the kernel check each block as it is read, at read
time. A flat digest of the whole file is a different thing that happens to be
the same length and shape, so the substitution is invisible in every printout
and comparison. What it is not is usable: eos_dmverity_generate_table() emits
that value into a dm-verity table the kernel will reject, and anything treating
root_hash as an integrity token holds a hash with none of dm-verity's
properties.

A hash device that could not be built is a failure. Reporting one is more
useful than inventing a hash that cannot verify anything. Fallback removed.

Second, in the same function:

    dv->verified = (dv->root_hash[0] != '\0');
    return dv->verified ? 0 : -1;

`verified` means an image was checked against a known-good root hash. Creating
a hash device is not that check -- it establishes the root hash, it does not
verify anything against it. Setting the flag here made every successful
create() indistinguishable from a completed verification to any caller reading
the field, including eos_dmverity_dump(). eos_dmverity_verify() already sets it
correctly from `veritysetup verify`'s exit status, and is now the only place
that sets it. create() returns the same 0/-1 as before, so its own callers are
unaffected.

eos_dmverity_init() memsets the struct, so verified starts false and stays
false until something actually verifies.

Verified: eos_linux_security builds clean; full Release suite 25/28 with the
three Not-Run being services/compat's pthread_mutex_timedlock/sem_timedwait gap
on macOS (#96's subject, unrelated). Linux CI builds all 28.

Not addressed here, deliberately: this file drives veritysetup, cryptsetup and
friends through system()/popen() with paths interpolated into shell strings --
ten call sites. That is a real injection surface and a separate piece of work;
folding it into a correctness fix would bury both.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
srpatcha pushed a commit that referenced this pull request Sep 1, 2026
* fix(hal): let the build system, not the preprocessor, pick the HAL backend

Which backend source CMake compiles and which registrar eos_hal_init() calls
are the same decision made in two places, with two different conditions.

CMakeLists.txt:

    if(EOS_PLATFORM STREQUAL "linux" OR NOT CMAKE_CROSSCOMPILING)
        target_sources(eos_hal PRIVATE hal/src/hal_linux.c)
    else()
        target_sources(eos_hal PRIVATE hal/src/hal_rtos.c)

hal_common.c:

    #ifdef __linux__
        eos_hal_linux_register();
    #else
        eos_hal_rtos_register();

They agree on Linux and on a cross build, and disagree on every other host. A
macOS host build compiles hal_linux.c, then references a symbol from the file
it did not compile:

    Undefined symbols for architecture arm64:
      "_eos_hal_rtos_register", referenced from: _eos_hal_init

CMake now sets EOS_HAL_BACKEND_HOST or EOS_HAL_BACKEND_RTOS next to the
target_sources() call that makes the choice, and hal_common.c keys off that.
__linux__ stays as a fallback for anyone compiling these files without CMake.

Two things had to move with it:

- hal.h declared the registrars under the same #ifdef, so hal_common.c could
  not even name the one CMake had compiled. Both are declared unconditionally
  now; exactly one is still defined per build, and an unreferenced declaration
  costs nothing. The header's own comment already argued for this -- "an
  undeclared registrar is one nobody can find".
- hal_linux.c wraps its whole body in #ifdef __linux__, so on macOS it compiled
  to an empty translation unit and the build had no backend at all. It is the
  host backend rather than the Linux one: the only platform calls in it are
  clock_gettime() and nanosleep(), both POSIX. Guarded on the CMake define,
  with __linux__ as the fallback. hal_rtos.c takes the mirror condition.

Linux behaviour is unchanged: EOS_HAL_BACKEND_HOST and __linux__ are both
defined there, and the same registrar is selected as before. Cross builds get
EOS_HAL_BACKEND_RTOS and are likewise unchanged. eos master CI is green on
Linux and stays that way.

Verified on macOS: the eos_hal link error is gone and ctest goes from 0/28 to
19/28.

This does NOT make macOS a supported host on its own. The remaining 9 failures
are services/compat/src/os_adapter_posix.c calling pthread_mutex_timedlock()
and sem_timedwait(), neither of which macOS provides. That is a separate piece
of work in a different subsystem and I have not attempted it here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* ci: build eBoot for qemu_arm64 with EBLDR_VERIFY_STAGE1 off

With -DEBLDR_BOARD=qemu_arm64 actually taking effect (it was -DBOARD= before
and silently ignored), eBoot's configure now runs the board path and refuses:

    EBLDR_VERIFY_STAGE1 is ON but board qemu_arm64 has no stage-1 linker
    script, so there is no stage-1 image to hash. Configure with
    -DEBLDR_VERIFY_STAGE1=OFF to build stage-0 without the integrity check.

That refusal is correct -- the board ships only board_qemu_arm64.c/.h, no
linker scripts, so there is no stage-1 image for stage-0 to verify -- and the
message names the flag. Passed here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* ci: surface QEMU's error instead of discarding it

The QEMU step ran for ~20ms, left an empty serial log, and reported nothing --
because its stderr went to /dev/null and its exit code into `|| true`. The one
message that says why the guest did not boot was the message being thrown away.

Keep the run non-fatal only for exit 124 (timeout(1) killing a still-running
guest, which is how a bare-metal image "finishes"); any other nonzero exit now
fails the step loudly with QEMU's own stderr printed. Also list the artifact
files before booting, so a missing or truncated eos_qemu.bin is visible.

The TC validation gate below is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* ci: give the virt machine no NIC instead of a NIC with no ROM

With stderr finally visible, the reason the guest never booted is one line:

    qemu-system-aarch64: failed to find romfile "efi-virtio.rom"

The workflow installs qemu-system-arm with --no-install-recommends, which
skips ipxe-qemu -- the package that ships efi-virtio.rom. The virt machine
creates a default virtio-net device, that device wants its boot ROM, and QEMU
exits during machine init. This has been the failure since the job first ran;
it was just invisible behind 2>/dev/null.

The simulation uses no networking, so -nic none removes the device rather
than installing a ROM for a NIC nothing talks to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* ci: bound each C test, and stop cancelling master's runs

The Build & Test (Linux x86_64) job hangs in "Run C unit tests" and holds the
workflow open until GitHub's 6-hour limit. Which test hangs is currently
unknowable from the outside: ctest runs with no per-test timeout, so the job
never reaches the summary that would name it.

--timeout 120 turns the invisible hang into a named failure. 120s is generous
-- the slowest test in this suite takes 1.4s.

Second, related: the concurrency group I added cancels in-progress runs on
every push. Right for PR branches; wrong for master, where each merge
cancelled the previous merge's run -- which is why this job has not been
allowed to COMPLETE on master since 08-30 and the hang went unnoticed.
cancel-in-progress is now conditional on the ref not being master.

Verified locally (macOS): ctest 25/28 pass with the timeout flag; the 3
remaining are Not Run because test_os_adapter/test_os_services/test_ota do not
build here (the known pthread_mutex_timedlock/sem_timedwait gap). Those three
are Linux-only -- exactly the ones this timeout will name if one of them is
the hang.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(tests): test_net must not hide its calls inside assert()

The test named by the new ctest timeout is test_net, and the hang is not a
networking bug. CI builds Release; Release defines NDEBUG; NDEBUG deletes
assert() together with the expression inside it. Half of test_net's checks
wrap the call under test:

    assert(eos_net_connect(c, &addr) == 0);
    assert(eos_net_send(c, "ping", 4) == 4);

With those gone, no client ever connects, the echo thread blocks in accept()
forever, and pthread_join() hangs the suite -- for exactly as long as nothing
bounds the test, which is what the previous commit fixed.

Reproduced locally before fixing: a Release build of the unmodified test hangs
on macOS too (this was never Linux-specific; my earlier local runs simply were
not Release). Replaced every assert() with a CHECK() macro that always
evaluates and exits nonzero on failure.

After: Release test_net passes 19/19 in under a second; full local Release
ctest 27/28 (the 1 is test_os_adapter, Not Run on macOS as known).

Same latent class elsewhere: other C tests also use assert() and silently
check nothing in Release builds -- they pass today because their expressions
have no needed side effects that block, but they are weaker than they look.
Worth a sweep in its own PR rather than burying it here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(hal): keep hal_linux.c an empty TU on bare metal

The ARM Cortex-M4 job caught a regression in my backend-selection change:

    hal/src/hal_linux.c:30:10: fatal error: sys/ioctl.h: No such file or directory

That cross build also compiles hal_linux.c -- EOS_PLATFORM defaults to
"linux", so CMake's host branch is taken even for arm-none-eabi -- and it only
ever built because `#ifdef __linux__` reduced the file to an empty translation
unit there. Widening the guard to EOS_HAL_BACKEND_HOST un-emptied it on a
toolchain with no POSIX headers.

The guard now asks the right question -- "is this a hosted POSIX build?" --
once, in hal.h:

    #if defined(__linux__) || \
        (defined(EOS_HAL_BACKEND_HOST) && (defined(__unix__) || defined(__APPLE__)))
    #define EOS_HAL_HOSTED 1

and hal_linux.c, hal_rtos.c and hal_common.c all key off EOS_HAL_HOSTED
instead of each doing their own arithmetic. arm-none-eabi defines none of
those macros, so the empty-TU behavior the ARM job depends on is restored;
aarch64-linux-gnu defines __linux__, so the QEMU simulation lane is unchanged;
macOS defines __APPLE__, so the original fix stands.

Verified locally by preprocessing hal_linux.c both ways (clang -U__APPLE__
-U__unix__ -U__linux__ simulating bare metal: body absent; hosted: present)
and a full Release build + ctest: 26/28, the 2 remaining being the documented
macOS Not-Runs (pthread_mutex_timedlock/sem_timedwait).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test: keep assertions live in test binaries

CI configures -DCMAKE_BUILD_TYPE=Release. Release defines NDEBUG. NDEBUG
deletes assert() *together with the expression inside it*. These suites express
nearly every check as assert():

    684 assert() calls across 16 test files

All of them compile to nothing in CI, and the tests they belong to pass by
reaching the end of main(). Demonstrated with the exact flags CI uses:

    $ clang -DNDEBUG -o t t.c   # int main(void){ assert(1 == 2); ... }
    REACHED END: the assert did nothing
    exit=0

This is not theoretical. test_net wrapped the calls under test in assert():

    assert(eos_net_connect(c, &addr) == 0);
    assert(eos_net_send(c, "ping", 4) == 4);

Under Release no client ever connected, the echo thread blocked in accept()
forever, and the whole suite hung until ctest killed it -- the hang I fixed in
the previous commit. Every other assert() in the tree is the same construction;
that one was simply the only one whose deleted expression also blocked.

Stripping NDEBUG from test targets is a one-line change that makes all 684
meaningful without rewriting a single call site. It applies to the test
binaries only -- the libraries under test are still compiled exactly as CI
builds them for release, so this does not turn the suite into a debug-build
test of a debug-build library.

Verified on macOS (Release): every test that builds passes with assertions
live -- test_kernel (89 asserts), test_priority_inherit (49), test_power (26),
test_net (32), test_motor_ctrl (15) among them, roughly 211 assertions now
actually executing. The remaining targets do not link here for an unrelated
reason: services/compat/src/os_adapter_posix.c uses pthread_mutex_timedlock()
and sem_timedwait(), which macOS does not provide (that is #96's subject, not
this one). Linux CI builds all 28 and is the real check.

Stacked on #102, which repairs the macOS HAL link so this could be evaluated
locally at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Kartikey1306 added a commit to Kartikey1306/eos that referenced this pull request Sep 14, 2026
eos_dmverity_create() has a fallback: if veritysetup is missing or fails, it
runs `sha256sum <image>` and stores the result in dv->root_hash.

A dm-verity root hash is the root of a Merkle tree over the image's blocks --
that structure is what lets the kernel check each block as it is read, at read
time. A flat digest of the whole file is a different thing that happens to be
the same length and shape, so the substitution is invisible in every printout
and comparison. What it is not is usable: eos_dmverity_generate_table() emits
that value into a dm-verity table the kernel will reject, and anything treating
root_hash as an integrity token holds a hash with none of dm-verity's
properties.

A hash device that could not be built is a failure. Reporting one is more
useful than inventing a hash that cannot verify anything. Fallback removed.

Second, in the same function:

    dv->verified = (dv->root_hash[0] != '\0');
    return dv->verified ? 0 : -1;

`verified` means an image was checked against a known-good root hash. Creating
a hash device is not that check -- it establishes the root hash, it does not
verify anything against it. Setting the flag here made every successful
create() indistinguishable from a completed verification to any caller reading
the field, including eos_dmverity_dump(). eos_dmverity_verify() already sets it
correctly from `veritysetup verify`'s exit status, and is now the only place
that sets it. create() returns the same 0/-1 as before, so its own callers are
unaffected.

eos_dmverity_init() memsets the struct, so verified starts false and stays
false until something actually verifies.

Verified: eos_linux_security builds clean; full Release suite 25/28 with the
three Not-Run being services/compat's pthread_mutex_timedlock/sem_timedwait gap
on macOS (embeddedos-org#96's subject, unrelated). Linux CI builds all 28.

Not addressed here, deliberately: this file drives veritysetup, cryptsetup and
friends through system()/popen() with paths interpolated into shell strings --
ten call sites. That is a real injection surface and a separate piece of work;
folding it into a correctness fix would bury both.
Kartikey1306 added a commit to Kartikey1306/eos that referenced this pull request Sep 14, 2026
… zero

eos_mpu_validate() returns an error count, and its two hardware-constraint
checks did not contribute to it:

    if (reg->size > 0 && (reg->size & (reg->size - 1)) != 0)
        printf("  WARN: ... size 0x%x not power-of-2\n", ...);
    if (reg->base_addr % reg->size != 0 && reg->size > 0)
        printf("  WARN: ... base 0x%x not aligned to size 0x%x\n", ...);

Neither touches `errors`, so a configuration the hardware cannot program is
reported clean and eos_mpu_generate_config() writes it out.

These are not warnings. ARMv7-M encodes a region's extent as a power-of-two
exponent in RASR.SIZE and requires RBAR's base to be aligned to that extent. A
region violating either cannot be expressed, so what actually gets programmed
covers different memory than was asked for -- and for a task-isolation
service, "isolated, but not over the range you specified" is precisely the
failure this validation exists to catch. Both now count, along with the
32-byte floor RASR.SIZE implies, which nothing checked at all.

Second, in the same expression: `reg->base_addr % reg->size != 0 && reg->size > 0`
divides before it checks. && only guards what is to its right, so a zero-sized
region reaches the modulo. eos_mpu_add_region() never validates size, so this
is reachable from any caller:

    services/rtos/src/rtos_security.c:80:32: runtime error: division by zero
    SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior

Worth knowing why it went unnoticed: on x86 a zero divisor raises SIGFPE and
the process dies, while AArch64's udiv quietly yields 0. The same input
crashes on the CI host and returns silently on an Apple-silicon dev machine.
Size 0 is now rejected first, and the check `continue`s past everything that
divides by or masks with size.

tests/test_mpu_validate.c is new -- this service had no tests. It covers a
programmable region validating clean, then each rejection: non-power-of-two,
misaligned base, zero size, below the 32-byte minimum, and an unprogrammable
region inside a satisfied region budget. Against the unpatched validate() it
fails on the non-power-of-two case, which prints WARN and returns 0.

Verified: ctest 27/29. The two Not-Run are test_os_adapter and
test_os_services, which need services/compat and do not build on macOS
(pthread_mutex_timedlock/sem_timedwait -- embeddedos-org#96's subject).

Stacked on embeddedos-org#102, which repairs the macOS HAL link.
Kartikey1306 added a commit to Kartikey1306/eos that referenced this pull request Sep 14, 2026
tests/fuzz/fuzz_ota_header.c declared and called

    extern int eos_ota_parse_header(const void *data, size_t len);

There is no such function anywhere in the tree. The harness was also commented
out of tests/fuzz/CMakeLists.txt, so it was dead twice over -- and could not
have compiled if anyone had enabled it. Same shape as the fuzz_devicetree
harness fixed in embeddedos-org#50, which declared an eos_dtb_parse() that likewise did not
exist.

Rewritten against the real API, and aimed at the part that actually takes
attacker-controlled bytes. eos_ota_source_t carries a declared size and an
expected digest that travel WITH the update, and eos_ota_write_chunk() streams
the payload, so the harness treats the first 36 bytes of fuzz input as that
control block and the rest as payload -- fed in fuzz-chosen slice widths so
chunk boundaries vary rather than always landing on the same offsets.

It finishes with verify/apply/rollback so the digest comparison and the
authenticity gate are exercised, with an authenticator that refuses
everything: apply() must never install a fuzz-chosen image, and wiring one in
means the run cannot silently stop at the gate added in embeddedos-org#75.

Not run here: libFuzzer itself. AppleClang ships no libclang_rt.fuzzer_osx.a,
so `-fsanitize=fuzzer` does not link on this machine. What I did instead was
build LLVMFuzzerTestOneInput against a small driver under
-fsanitize=address,undefined -fno-sanitize-recover=all and feed it random
inputs:

    5 seeds x 40,000 inputs = 200,000 -- no sanitizer reports

That proves the harness body runs and the ingest path survives arbitrary
input; it is not coverage-guided, and CI with a real libFuzzer toolchain is
where that happens.

CMake now reports:
    -- Fuzz targets: fuzz_sha256, fuzz_aes, fuzz_devicetree, fuzz_ota_header

Verified: ctest 26/28, the two Not-Run being targets that need services/compat,
which does not build on macOS (embeddedos-org#96's subject).

Stacked on embeddedos-org#102, which repairs the macOS HAL link.
Kartikey1306 added a commit to Kartikey1306/eos that referenced this pull request Sep 14, 2026
Follow-up to the review on embeddedos-org#75:

    "the refusal uses fprintf(stderr, ...) while the tree has EOS_ERROR in
     core/include/eos/log.h, which is what core/src/config.c uses. On a target
     with no hosted stdio this is the one line in the change that will not
     build."

Correct, and it is live on master. eos_ota_apply()'s refusal path was the only
reason services/ota/src/ota.c included <stdio.h> at all, so the include goes
with it -- an OTA service that cannot compile without a hosted stdio is not
much use to the targets this project exists for.

EOS_ERROR expands to eos_log(), which lives in eos_core, so eos_ota now links
it. That is a new edge in the dependency graph and worth being explicit about:
eos_core is core/src/{log,config,graph,cache,lockfile,layer,scheduler}.c and
does not depend on eos_ota, so it introduces no cycle.

Message text is unchanged apart from dropping the trailing newline, which
eos_log adds, and the "eos-ota:" prefix, which the log layer supplies.

Verified: eos_ota builds clean; ctest 26/28, the two Not-Run being
test_os_adapter and test_os_services, which need services/compat and do not
build on macOS (pthread_mutex_timedlock/sem_timedwait -- embeddedos-org#96's subject, not
this). test_ota itself is 9/9 including the four authenticator cases that
exercise this exact path.

Stacked on embeddedos-org#102, which repairs the macOS HAL link.
Kartikey1306 added a commit to Kartikey1306/eos that referenced this pull request Sep 14, 2026
CI configures -DCMAKE_BUILD_TYPE=Release. Release defines NDEBUG. NDEBUG
deletes assert() *together with the expression inside it*. These suites express
nearly every check as assert():

    684 assert() calls across 16 test files

All of them compile to nothing in CI, and the tests they belong to pass by
reaching the end of main(). Demonstrated with the exact flags CI uses:

    $ clang -DNDEBUG -o t t.c   # int main(void){ assert(1 == 2); ... }
    REACHED END: the assert did nothing
    exit=0

This is not theoretical. test_net wrapped the calls under test in assert():

    assert(eos_net_connect(c, &addr) == 0);
    assert(eos_net_send(c, "ping", 4) == 4);

Under Release no client ever connected, the echo thread blocked in accept()
forever, and the whole suite hung until ctest killed it -- the hang I fixed in
the previous commit. Every other assert() in the tree is the same construction;
that one was simply the only one whose deleted expression also blocked.

Stripping NDEBUG from test targets is a one-line change that makes all 684
meaningful without rewriting a single call site. It applies to the test
binaries only -- the libraries under test are still compiled exactly as CI
builds them for release, so this does not turn the suite into a debug-build
test of a debug-build library.

Verified on macOS (Release): every test that builds passes with assertions
live -- test_kernel (89 asserts), test_priority_inherit (49), test_power (26),
test_net (32), test_motor_ctrl (15) among them, roughly 211 assertions now
actually executing. The remaining targets do not link here for an unrelated
reason: services/compat/src/os_adapter_posix.c uses pthread_mutex_timedlock()
and sem_timedwait(), which macOS does not provide (that is embeddedos-org#96's subject, not
this one). Linux CI builds all 28 and is the real check.

Stacked on embeddedos-org#102, which repairs the macOS HAL link so this could be evaluated
locally at all.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants