Repository navigation
test(linux_security): a fixture the harness cannot create is a failure, not a skip - #160
Conversation
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.
…e, not a skip
Twelve tests in tests/test_linux_security_paths.c opened with
"if (!mkdtemp(dir)) { fprintf(stderr, "[SKIP] mkdtemp failed"); return; }"
-- fifteen mkdtemp() calls -- and one mkdir() and one chmod() were handled
the same way. On a read-only or full /tmp each test returned before its
CHECK()s ran, failures stayed 0, the binary exited 0 and ctest was green.
The review on embeddedos-org#144 found it as that PR added the twenty-first site.
The file's policy is right for what it was written for: a missing build
tool -- make, evmctl, setfiles -- or a host that ignores file modes means
the case cannot be exercised here, and [SKIP] is the honest answer. Those
five sites are unchanged. A temp directory the harness cannot create is
not that; it is the harness failing. REQUIRE_TMPDIR() now counts it, names
the file, line, template and errno, says which assertions did not run, and
stops the test. FIXTURE_FAILED() does the same for the mkdir() of the
directory whose name is the injection and for the chmod() of the read-only
rootfs.
Proved both ways with a stub mkdtemp() that always fails, linked into the
suite:
embeddedos-org#144's head, old idiom: exit 0, 12 [SKIP] lines, 6 [PASS] lines --
green, twelve assertions silently gone
this commit: exit 1, 12 [FAIL] lines naming mkdtemp and
EROFS, 6 [PASS] lines
A first attempt at that negative control redefined mkdtemp as a macro and
collided with unistd.h's declaration; rc 127 was "no binary", not a result.
The stub is the mutation that actually ran.
Normal run: build clean, all linux_security path checks passed.
Closes embeddedos-org#159
The macro change only means something if it really does fail where the old code skipped, so this adds the test that shows it. A probe calls REQUIRE_TMPDIR on a template whose parent directory does not exist, which fails with ENOENT for any user -- a read-only directory would not do, because root writes there anyway and CI may run as root. The probe runs in a forked child so the failure it records cannot leak into this process's count, and the child's stderr is captured over a pipe so the assertion is on the message class: the line says [FAIL] and names mkdtemp(tmpl), and there is no [SKIP] in it. The child exits 0 only if exactly one failure was recorded and the macro returned, so "continued past a failed mkdtemp" is caught too. Discriminates: with the macro reverted to its old [SKIP]-and-return body (binary confirmed relinked), the run reports "4 check(s) failed" and exits 1; with the fix, "[PASS] REQUIRE_TMPDIR counts a failed mkdtemp as a failure, reports it as [FAIL], and stops the test" and exit 0. The child's [FAIL] line does not appear in the suite's own stderr. Full tree at this head: cmake --build ok, ctest 43/43, pytest tests/ 47 passed. The target is already under if(UNIX) in tests/CMakeLists.txt, so fork/pipe/waitpid are not reached by the MSVC leg.
|
Added the committed test at
Discriminates, measured at Body updated: range is now |
srpatcha
left a comment
There was a problem hiding this comment.
Review — eos#160 "test(linux_security): a fixture the harness cannot create is a failure, not a skip"
head: 0fced8b author: Kartikey1306 ci: pass — 29 green, 0 red; Create GitHub Release and assign skipped
Verdict: First look at 9569226..0fced8b (two commits, one file — tests/test_linux_security_paths.c, +102/−25) as the body asks; the other nine files belong to #144, reviewed separately at this exact base. The change is correct and the claims hold: I reproduced both negative controls from scratch, and both came out exactly as the body says. This is the right fix for .ai/security.md's fail-closed rule, and the committed probe is what makes it stay fixed. One gap: the sweep converted every [SKIP]-on-fixture-failure site, but two fixture open() calls in the same file never used [SKIP] — they silently ignore their result, and I proved both tests still report [PASS] with the fixture absent.
Findings
| # | Severity | File:line | Finding | Recommended fix |
|---|---|---|---|---|
| 1 | Medium (P2) | tests/test_linux_security_paths.c:418-419, :514-515 |
Two fixture open() results are discarded, so two tests pass with their fixture missing — the same defect class this PR exists to close, in the file it just rewrote. Both sites are fd = open(..., O_CREAT|O_EXCL, 0600); if (fd >= 0) { (void)!write(...); close(fd); } — no CHECK, no failures++, no REQUIRE_. The PR enumerated the [SKIP] sites (15 mkdtemp, 1 mkdir, 1 chmod) and converted all of them; these two never printed [SKIP], so the sweep missed them. Proved by execution, not read: with both open() calls forced to fd = -1 and nothing else changed, the suite still prints [PASS] ima_sign_file reports failure when evmctl is absent and [PASS] selinux_label_rootfs reports a rootfs it did not label, then All linux_security path checks passed, exit 0. eos_ima_sign_file fails because the target file does not exist, and eos_selinux_label_rootfs fails because file_contexts does not exist — each assertion passes for a reason the test was not written to measure. The four (void)!write(...) calls at :419, :515, :640 and :701 discard their result the same way; a short or failed write leaves an empty fixture file behind an assertion that still passes. |
CHECK(fd >= 0); if (fd < 0) { rmdir(dir); return; } at both sites — the pattern already used at :316-317 and :638-639 four lines away, so no new idiom is needed. For the writes, CHECK(write(...) == (ssize_t)n). That makes the file's own stated policy true of every fixture step in it, which is the claim the header comment at :41-55 now makes. |
| 2 | Low (P3) | tests/test_linux_security_paths.c:64-70 |
FIXTURE_FAILED()'s comment says "count it, say so, stop", and the macro does not stop — it counts and returns to its caller, which then has to return itself. All three call sites (:690, :731, and the two in test_require_tmpdir_fails_rather_than_skips) do so correctly, so nothing is wrong today. But its sibling REQUIRE_TMPDIR() five lines above does return, and the two macros read as a pair. The next person to add a fourth call site and trust the comment writes exactly the bug this PR is fixing. |
Either make the comment accurate — "count it and say so; the caller must clean up and return" — or give it the return and drop the explicit ones. The comment is the cheaper change; the cleanup differs per site, which is presumably why it does not return. |
| 3 | Low (P3) | tests/test_linux_security_paths.c:812-815 |
The capture loop is while (total < sizeof out - 1 && (n = read(fds[0], ...)) > 0) total += n;. A read interrupted by a signal returns -1/EINTR, which ends the loop with a partial buffer and fails three of the four CHECKs below it — a flaky failure in a test whose whole purpose is that failures are real. Nothing in ctest sends signals, so this is latent rather than observed. |
while (... && ((n = read(...)) > 0 || (n < 0 && errno == EINTR))) { if (n > 0) total += n; }, or wrap in a retry. errno.h is already included by this commit. |
Not findings, recorded so they are not mistaken for uncovered:
- The committed probe discriminates. With
REQUIRE_TMPDIRreverted to its old
[SKIP]-and-return body and nothing else changed, the suite reports4 check(s) failedand
exits 1 — the author's claim, reproduced exactly, including the count. _exit()in the forked child is right, and matters:printfoutput is fully buffered when
ctestcaptures stdout, and a normalexit()in the child would flush the parent's inherited
buffer and duplicate every[PASS]line printed before the fork. The clean verbose run
confirms no duplication.- Choosing a non-existent parent directory over a read-only one is the right call and the
comment says why: CI may run as root, and root writes into a read-only directory.ENOENT
discriminates for every user. - The probe fails safe if
/nonexistent-eos-parent-3f9c1aever exists.mkdtempwould then
succeed, the probe would printREQUIRE_TMPDIR continued past a failed mkdtemp, and
CHECK(strstr(out, "mkdtemp(tmpl)"))would fail. The test breaks loudly rather than passing
vacuously, which is the correct direction. - The five environmental
[SKIP]s are genuinely untouched —make×2,evmctl,setfiles,
running-as-root — and they are the right five: each names a dependency whose absence means the
case cannot be exercised, not a harness that failed.grep -n "SKIP"on the head returns
exactly those five plus three comment lines and the probe's own assertion. fork/pipe/waitpidare safe here.tests/CMakeLists.txt:362wraps this target in
if(UNIX), so the MSVC leg never compiles it. Confirmed at this head.- No other test in the tree has this idiom.
mkdtempappears in exactly one file under
tests/, and no other.cor.pythere skips on a failed fixture step. Unlike the caller
duplication in ebuild#144, this defect class does not need a second sweep. - Nothing was weakened: no test disabled, no assertion removed, no lint loosened, no
|| true.
The commit adds one test and converts 17 silent returns into counted failures.
Architecture conformance
Conforms. §21 places eos in Tier 1 — Foundation; tests/ is inside the owning repo and
§21.1 is not engaged. §5.1's dependency law is not touched — the diff adds <sys/wait.h> and
<errno.h>, both libc, and no product-repo include, link line or manifest entry. §8's eBoot TCB
rules are not engaged.
What this serves is §28's evidence model directly: test_linux_security_paths is the functional
evidence behind linux_security's "Implemented" status, and until this commit twelve of its
eighteen cases could return before asserting anything while the binary exited 0 and ctest
went green. .ai/security.md's fail-closed rule — "a verification step that cannot run must
fail, not pass" — is the local statement of it, and this is that rule applied one level below
where it is usually applied.
Proposal appended. §28 and the existing draft §28.2 govern the runner — a suite that
collects zero tests, a job that cannot fail, a gate that never runs — and are silent on a test
that runs, returns early having asserted nothing, and reports success. --no-tests=error does
not reach it. See proposals/2026-09.md, 2026-09-14, "Addendum: a test that runs and asserts
nothing satisfies every rule §28.2 has".
Proposed changes
1. CHECK the two fixture open()s at :418 and :514, and the four writes
(:419, :515, :640, :701). Same two-line pattern as :316-317. (finding 1)
2. Fix FIXTURE_FAILED's comment, or give the macro its return. (finding 2)
3. Retry the capture read on EINTR. (finding 3)
None of these blocks the PR. Finding 1 is the one worth doing before merge, because the header
comment this commit adds at :41-55 now claims a policy the file does not quite hold to.
No fix PR opened. All three findings are Medium or Low; the brief's autofix rule is
High-only. They are also in a file as this branch rewrites it, so a branch cut from
origin/master — which is what fix-start.sh produces — would be patching a different body.
Verification performed for this review
The eos checkout has a clean working tree (git status --short empty, before and after)
and was not modified — no worktree, checkout, stash or reset. The PR head was fetched
read-only into refs/autoreview/pr160. Everything below ran against git archive | tar -x into
two temporary directories outside the repository — one at 0fced8b2, one at the base
9569226 for the controls. Nothing was committed or pushed.
| Check | Result |
|---|---|
cmake -B build/host -G Ninja -DCMAKE_BUILD_TYPE=Release -DEOS_BUILD_TESTS=ON -DEOS_PRODUCT=vbox_test |
PASS |
cmake --build build/host --parallel 4 |
PASS, no warnings in the changed file |
ctest --output-on-failure --no-tests=error --timeout 120 --parallel 4 |
PASS — 100%, 43/43, 0 failed |
ctest -R test_linux_security_paths --verbose |
19 [PASS], 0 [SKIP], 0 [FAIL], including [PASS] REQUIRE_TMPDIR counts a failed mkdtemp as a failure…. The new test executes; it is not a function nobody calls |
Control A — REQUIRE_TMPDIR reverted to the old [SKIP]-and-return body, target relinked |
FAIL, exit 1, 4 check(s) failed — the four CHECKs in test_require_tmpdir_fails_rather_than_skips, by line: :820 exit status, :821 [FAIL] present, :822 mkdtemp(tmpl) present, :823 no [SKIP]. The author's claimed count, exactly. Restored; green again |
Control B — mkdtemp() forced to fail (LD_PRELOAD, returns NULL/EROFS), this head |
exit 1, 12 [FAIL] lines naming mkdtemp, 0 [SKIP], 7 [PASS] |
Control B — same stub, base 9569226 (old idiom), rebuilt from a separate export |
exit 0, 0 [FAIL], 12 [SKIP], 6 [PASS] — green with twelve assertions silently gone. The defect, reproduced |
Control C — both fixture open()s forced to fd = -1 (finding 1) |
exit 0, All linux_security path checks passed, and [PASS] printed for both ima_sign_file reports failure when evmctl is absent and selinux_label_rootfs reports a rootfs it did not label. Restored; green again |
grep -n "SKIP" tests/test_linux_security_paths.c at this head |
five printf("[SKIP] …") sites, all environmental (make ×2, evmctl, setfiles, root); the rest are comments and the probe's own assertion |
grep -rln "mkdtemp" --include="*.c" tests/ |
one file — this one. No second sweep needed |
tests/CMakeLists.txt:362 |
if(UNIX) wraps the target; the fork/pipe/waitpid addition cannot reach the MSVC leg |
git diff --stat 9569226 0fced8b2 |
one file, +102/−25 — the body's scope claim is accurate |
CI on this head (checks.txt) |
29 pass, 0 fail, incl. Build & Test (Linux x86_64), Build & Test (Windows MSVC), Host Tests (x86_64), Cross-compile ARM Cortex-M4, Cross-compile ARM64 kernel, QEMU Boot Test, QEMU ARM64 simulation (13 TCs), Static Analysis (cppcheck + clang-tidy), CodeQL, Analyze (C/C++) |
| PR metadata | mergeable: MERGEABLE, mergeStateStatus: BLOCKED, reviewDecision: REVIEW_REQUIRED |
Not checked
pytest tests/— NOT RUN. The author reports 47 passed; the Python legs are green in CI
and this commit changes no Python, so nothing in it is exposed by that gap. Unverified locally.- Cross-compilation and MSVC — NOT RUN locally. Only the host x86_64 build was configured.
The ARM Cortex-M4, ARM64 and Windows legs are green in CI and were not reproduced. The MSVC
leg matters here specifically because of the newfork/pipe/waitpid; I verified the
if(UNIX)guard by readingtests/CMakeLists.txt, not by compiling on Windows. - QEMU simulation and cppcheck/clang-tidy — NOT RUN locally. Green in CI; not reproduced.
- Control B used
LD_PRELOAD, not the author's linked stub. Same effect on the call, a
different mechanism; a statically-resolvedmkdtempwould not be intercepted this way. The
numbers matched the body's on both sides, which is the point of the control. - The real trigger — a read-only or full
/tmp— was not reproduced. Every control forced
the failure at the call rather than creating the filesystem condition. - The
EINTRpath in finding 3 was not provoked. It is read from the loop, not observed. - The #144 half of the 10-file diff is not reviewed here — reviewed separately at head
95692266, which is this PR's base. If #144 changes, the 43/43 figure no longer describes
what will merge. mergeStateStatus: BLOCKED—REVIEW_REQUIREDplus the #144 stack. Land #144 first.
Automated architecture review of 0fced8b2c866 — 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.
…missed Review follow-up on embeddedos-org#160, all three findings. 1. Two fixture open() results were discarded (the ima target and the selinux file_contexts), so those tests passed with their fixture missing -- eos_ima_sign_file failed because the target did not exist, and eos_selinux_label_rootfs failed because file_contexts did not exist, each assertion holding for a reason the test was not written to measure. The sweep that converted the [SKIP] sites missed these because they never printed [SKIP]. Both now CHECK the descriptor and return with cleanup, the pattern already used four lines away, and all four fixture writes CHECK their byte count instead of (void)!-discarding it. 2. FIXTURE_FAILED()'s comment said "count it, say so, stop" and the macro does not stop; its sibling REQUIRE_TMPDIR does. The comment now says the caller must clean up and return, and why the two differ (cleanup is per-site; an uncreated directory needs none). 3. The stderr capture loop ended on any read() error, so a signal between fork and exit would have turned a partial capture into three failed assertions in a test about failures being real. It now retries on EINTR. Discriminates, measured at this commit: with both fixture open() calls forced to -1 (the review's own probe; binary confirmed relinked) the suite reports "2 check(s) failed" and exits 1 where it previously exited 0. Restored: All linux_security path checks passed. Full tree: ctest 43/43, pytest tests/ 47 passed.
|
All three addressed at Finding 1 — both fixture Finding 2 — comment made accurate rather than adding a Finding 3 — the capture loop retries on Verified at Range for this round: |
srpatcha
left a comment
There was a problem hiding this comment.
Reviewed. Approve (test-only). Nit: in the two-directory tests, if the second REQUIRE_TMPDIR fails, the first directory is not removed.
Committed proof that the macro fails rather than skips (
0fced8b). The negative control below was a one-off stubbedmkdtemp()at #144's head — evidence for this PR, but nothing that runs again.test_require_tmpdir_fails_rather_than_skipsmakes it repeatable: a probe callsREQUIRE_TMPDIRon a template whose parent directory does not exist (ENOENT for any user; a read-only directory would not do, root writes there and CI may run as root), in a forked child so the recorded failure cannot leak into the suite's own count, with the child's stderr captured over a pipe so the assertion is on the message class —[FAIL]namingmkdtemp(tmpl), and no[SKIP]. With the macro reverted to its old body (binary confirmed relinked) the run reports4 check(s) failed, exit 1; with the fix,[PASS]and exit 0.Closes #159
Problem
Twelve tests in
tests/test_linux_security_paths.copened withif (!mkdtemp(dir)) { fprintf(stderr, "[SKIP] mkdtemp failed\n"); return; }— fifteenmkdtemp()calls in all — and onemkdir()and onechmod()were handled the same way. On a read-only or full/tmpeach test returned before itsCHECK()s ran,failuresstayed 0, the binary exited 0 andctestwas green. The review on #144 found it (finding 1, Low) as that PR added the twenty-first site; it is deferred here so the repair stays tight.Change
The file's skip policy is kept for what it was written for — a missing build tool (
make,evmctl,setfiles) or a host that ignores file modes means the case cannot be exercised, and[SKIP]is the honest answer. Those five sites are unchanged. A fixture the harness cannot create is the harness failing:REQUIRE_TMPDIR()counts it, names the file, line, template anderrno, says which assertions did not run, and stops the test;FIXTURE_FAILED()does the same for the fixturemkdir()andchmod().Verification
mkdtemp()that always fails, linked in, at #144's head (old idiom)[SKIP]lines, 6[PASS]— green with twelve assertions silently gone[FAIL]lines namingmkdtempandEROFS, 6[PASS]make×2,evmctl,setfiles, running-as-rootA first attempt at the negative control redefined
mkdtempas a macro and collided withunistd.h's declaration; rc 127 was "no binary", not a result. The linked stub is the mutation that actually ran.