Skip to content

fix(scheduler): the cache key stays inside its buffer - #171

Merged
srpatcha merged 8 commits into
embeddedos-org:masterfrom
Kartikey1306:fix/cache-key-stays-inside-its-buffer
Oct 2, 2026
Merged

srpatcha merged 8 commits into
embeddedos-org:masterfrom
Kartikey1306:fix/cache-key-stays-inside-its-buffer

Conversation

@Kartikey1306

@Kartikey1306 Kartikey1306 commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

Update: #144 has merged, so this now applies directly to master — there is no longer a parent to merge first. The stacking note below predates that.

Closes #170

Stacked on #144; review 9569226..e72cf77 for this change alone — one commit, three files (+186/−6). #144 repairs master, which does not compile today, so this sits on it rather than on master. It is independent of the rest of the eos stack and can land in any order among them; merge order is #144, then this.

The defect

core/src/scheduler.c:38-56 used snprintf()'s return value as the next write offset. That return is the length it would have written, and the loop guard is checked only before an iteration, so one truncating append leaves len past the end of input[2048]. The hash append then evaluates input + len — a pointer past the end — and sizeof(input) - len, which underflows size_t.

Uniform options never expose it: they either all fit, or the guard stops the loop while room remains. It takes appends that walk len up near the 1920 guard and then one option too large for what is left:

9 options of ":key=value" = 200 bytes -> len = 21 + 1800 = 1821  (< 1920, loop continues)
1 maximal option          = 640 bytes -> 227 bytes left, truncates, but len += 640

len = 2461 into a 2048-byte buffer -> writes 413 bytes past the end,
                                      size argument 2048-2461 = 1.8e19

Those widths are what the config parser already accepts (config.c:329-330: 127-char keys, 511-char values, up to EOS_MAX_OPTIONS = 32), and this runs on every eos build (scheduler.c:105, :185).

The change

core/src/scheduler.c, +40/−6. Appends go through append_clamped(), which clamps to what is actually in the buffer and can never return an offset past the end.

Room for the content hash is reserved up front. The old len < sizeof(input) - 128 guard was reaching for exactly this and did not hold. It matters beyond tidiness: when options crowd the hash out, two packages built from different sources produce the same cache key, and the build reuses the wrong artifact. Reserving makes the hash always present.

Tests

tests/test_scheduler_cache_key.c (new, +146) includes core/src/scheduler.c to reach the static — the same technique test_ed25519_canonical_s.c uses on ed25519_verify.c, and the CMake comment says so. Four cases: the overflowing shape, the reserve boundary, every option count 0..31, and the no-hash path.

The tests assert on the deterministic consequence, not on the sanitizer — a cache-key collision — so they discriminate on an ordinary build:

against the ORIGINAL scheduler.c, plain Debug:
  test_hash_still_separates_packages_when_options_overflow  [FAIL]
      scheduler.c:105: strcmp(key_a, key_b) != 0        <- keys collided
  exit=1

against the ORIGINAL scheduler.c, -fsanitize=address:
  ==39439==ERROR: AddressSanitizer: SEGV on unknown address 0x313131314129
      #1 compute_cache_key scheduler.c:56
  SUMMARY: AddressSanitizer: SEGV scheduler.c:56 in compute_cache_key

against this branch:
  4/4 tests passed; ASan exit 0, 0 findings

My first version of this test used uniform 640-byte options and passed against the unfixed code — it never made an append truncate. It is rewritten to the shape above; the arithmetic is in the file's header comment so the next reader does not have to rediscover which shape reaches the bug.

Verified at e72cf77, gcc/Ninja/Debug, -DEOS_BUILD_TESTS=ON -DEOS_PRODUCT=vbox_test: ctest --no-tests=error --timeout 120 44/44 passed (43 before, plus this one).

Not run here: the Windows/MSVC leg and the ARM cross build — macOS/arm64 only, and CI covers both. Nothing in this change is platform-specific; append_clamped() uses only snprintf and size_t arithmetic.

Nine PRs were merged into master within minutes on 09-08, each on the base it
was written against. Master has not compiled since, and the Python guards
that would have named the rest never ran because the C build failed first.

Compile (every C job, CodeQL, and the ARM64 kernel in the simulation):
- services/linux/src/linux_security.c: embeddedos-org#119 and embeddedos-org#132 each added an #else to
  the same #ifndef _WIN32 in eos_busybox_install_to_rootfs(), so master has
  "#else after #else". The embeddedos-org#132 arm (`(void)bb;`) is the one removed -- the
  embeddedos-org#119 arm already uses bb and reports the unsupported platform.

Guards from embeddedos-org#121 / embeddedos-org#93 that later merges walked back:
- ci.yml: embeddedos-org#132 added windows-test after embeddedos-org#121's gate; the gate did not wait
  for it, so "CI Gate" could be green with the MSVC leg red.
- test_ci_gate.py: embeddedos-org#129 gave eosim-sanity.yml and simulation-test.yml a
  path-filtered pull_request trigger (they test their own edits); a
  path-filtered check cannot be required, so both are recorded in
  NOT_REQUIRED with the book-build.yml reason.
- tests/CMakeLists.txt: test_linux_security_paths (embeddedos-org#119) and test_pkg_fetch
  (embeddedos-org#115) had no add_executable(). embeddedos-org#115's replay replaced embeddedos-org#119's block with
  its own, and embeddedos-org#118's replay replaced that; two suites compiled against
  nothing. Both registered again. 41 -> 43 suites.
- tests/test_kernel.c: four tests from embeddedos-org#130 and embeddedos-org#131 were defined and never
  called -- their RUN() lines did not survive the replay of main().

And the one that was not bookkeeping:
- kernel/src/task.c: embeddedos-org#130 was merged after embeddedos-org#131 from a base that predates it,
  and its copy of task.c replaced embeddedos-org#131's. embeddedos-org#131 had also flattened 574 CRLF
  line endings, so its 1166-line diff hid a 39/21 change and the replay took
  embeddedos-org#130's side wholesale. Master kept embeddedos-org#131's tests and lost its kernel: the
  idle task could be deleted and suspended, a half-initialised TCB was
  published to the scheduler before its stack existed, and eos_schedule()
  pointed g_current_sp at the outgoing task. test_idle_task_is_permanent
  fails on master the moment it is called. embeddedos-org#131's task.c diff re-applied
  on top of embeddedos-org#130's; the result is embeddedos-org#131's file plus embeddedos-org#130's wake_armed hunk
  and nothing else (verified by diff against 3a00bd9).

Verified locally (macOS, clang): Release build clean, 43/43 ctest; the
README default configuration builds; 47/47 pytest.

Not in this PR: the nightly "Upstream drift" job builds eBoot at master and
eBoot master is broken separately (its own repair PR); bump EBOOT_COMMIT in
eos-simulation.yml once that lands.
Re-registering it put it in front of the MSVC leg (embeddedos-org#132) for the first time,
where <unistd.h> does not exist. The suite probes with access(), open(O_CREAT)
and rmdir to prove refused inputs never reach a shell; on Windows the
library's own #else arm refuses every install/build call before
is_path_safe() matters, so there is nothing for it to test there.
… gives 1 MB

Re-registering the POSIX-only suite let the MSVC leg reach ctest for the
first time, and test_firmware died there with SEGFAULT: five tests declare
a stack EosConfig (sizeof 3,953,236 on this ABI), which the 8 MB default
stack on Linux and macOS absorbs and MSVC's 1 MB does not. test_config.c
already keeps its EosConfig static for the same reason; the same here.
kernel.h declared eos_task_delete() and eos_task_suspend() without saying
what handle 0 is. It is the idle task, it is permanent, and both calls
return EOS_KERN_INVALID for it -- as they do for an out-of-range handle or
an empty slot. The doc comments now say so, in the header's Doxygen style.

eos_schedule() has an early return for "no runnable task" that is
unreachable by construction: slot 0 cannot be deleted or suspended, the
idle task only spins or waits for an interrupt and so never blocks, and
find_next_task() can select it because of the "best < 0 ||" term. The
comment records that reasoning, and that the return is self-healing if it
were ever taken: the outgoing task stays READY and the next eos_schedule()
marks it RUNNING.

task.c also carried two stray blank lines at EOF from the re-apply of
embeddedos-org#131; with them gone, the diff against 3a00bd9 is embeddedos-org#130's wake_armed hunk
plus this comment and nothing else.
…rootfs

is_path_safe() refuses NULL, but eos_busybox_install_to_rootfs() read
bb->source_dir to hand it to the predicate, so a NULL bb reached the
dereference before the guard could see it: a crash rather than the -1
every other refusal in this file produces. Both arguments are now checked
first, before either is read.

test_linux_security_paths gains a case for it: (NULL, dir) and (&bb, NULL)
both return -1, and the directory gets no /init. With the guard removed
and the target rebuilt, the suite dies with SIGSEGV (exit 139) on the
first call; with it restored, the suite passes.
Records under Unreleased what embeddedos-org#144 did and why: the "#else after #else"
that stopped master compiling after the 09-08 batch merge, the guards the
replays walked back (two suites re-registered, four test_kernel tests
wired into main(), windows-test in the CI gate, the two path-filtered
simulation workflows classified), and the NULL guard in
eos_busybox_install_to_rootfs().

The scheduler change gets its own Changed entry because it is a contract:
eos_task_delete(0) and eos_task_suspend(0) return EOS_KERN_INVALID again,
as embeddedos-org#131 specified before embeddedos-org#130's replay replaced its task.c. Changed sits
above Fixed, the order the older releases in this file use.
…end checks

The eos_schedule() comment claimed the early return was self-healing. It is
not: find_next_task() scans every slot including the outgoing one, so
next < 0 means nothing is READY, and the return leaves the state exactly as
the canary check left it. The comment now says so, and that recovery is a
fault handler's job rather than the scheduler's.

eos_task_suspend() refuses a slot whose entry is NULL, which is what the
doc comment now says instead of the looser "holds no task"; a slot the
canary check marked DELETED still has its entry set and is accepted.

tests/CMakeLists.txt: the reason test_linux_security_paths is POSIX-only is
that the shell-building bodies are compiled out on Windows, not that the
predicate is skipped there.
compute_cache_key() accumulated snprintf()'s return value as the write
offset. snprintf() returns the length it would have written, so one
option that truncates leaves len past the end of input[2048]; the hash
append then wrote at input+len and passed sizeof(input) - len, which
underflows size_t. With nine 200-byte options followed by one maximal
640-byte option, len reaches 2461 and the write lands 413 bytes past the
buffer -- SEGV in compute_cache_key under AddressSanitizer.

Appends now clamp to what is actually in the buffer, via a helper that
cannot return an offset past the end.

Room for the content hash is also reserved up front. The old
`len < sizeof(input) - 128` guard was reaching for this, but being
checked only before an iteration it did not hold; when options crowded
the hash out, two packages built from different sources produced the
same cache key and the build reused the wrong artifact. That collision
is deterministic, so the regression test asserts on it rather than on
the sanitizer.

tests/test_scheduler_cache_key.c includes scheduler.c to reach the
static, the way test_ed25519_canonical_s.c reaches ed25519_verify.c's.
Against the old code the first case fails on a plain build and ASan
reports the SEGV; against this one, 4/4 pass and ASan is clean.
#include <stdio.h>
#include <string.h>

#include "../core/src/scheduler.c"

@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.

Reviewed. Approve: the hash space is reserved exactly, and the test fails on master (keys collide) and passes here. Nit: options beyond the 2048-byte buffer are still dropped from the key, so two packages that differ only past that point share a cache key. Hashing incrementally would remove that.

@srpatcha
srpatcha merged commit 1ed3ed2 into embeddedos-org:master Oct 2, 2026
38 checks passed
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.

compute_cache_key() writes past its buffer: snprintf's return value is used as the next offset

3 participants