diff --git a/docs/v4.0.0/plans/2026-11-12-kernel-host-tests.md b/docs/v4.0.0/plans/2026-11-12-kernel-host-tests.md new file mode 100644 index 00000000..65a604a4 --- /dev/null +++ b/docs/v4.0.0/plans/2026-11-12-kernel-host-tests.md @@ -0,0 +1,37 @@ +# Kernel host tests -- plan and progress (kept current, one line per increment) + +Captain Bob, 2026-11-12: "Build an appropriate test harness! Love the idea." -- for the kernel's own +C code, so that a defect QEMU cannot provoke (a USB descriptor no emulated device sends, a write that +fails half-way, a stop that spins) is still tested first and seen to fail. v4 has had this since step +1 (`make -C v4 test`); the kernel had none. + +## The design (my calls, open to a ruling) + +- **`kernel/tests/`**, one program per kernel source under test: `test_.c` compiled on the + host with the kernel's own source file, `-std=c99 -Wall -Wextra -Werror`, and nothing else of the + kernel. `make -C kernel/tests test` builds and runs them all; each prints `N checks, M failures` + as v4's do, and the run fails if any does. +- **Fakes, not mocks** (`kernel/tests/fakes.[ch]`): the console captured line by line (so a test + reads what the kernel said), a deterministic RNG, and a RAM disk whose reads or writes fail at the + 1 KiB blocks a test chooses -- the disk faults QEMU's blkdebug gives the boots, here per block and + without a boot. +- **Code is moved only to be testable, never changed in the move.** Where a unit is buried in a + larger function (the xHCI configuration-descriptor walk inside the event loop), it is moved out + into a function of its own in one commit, the boots and the tests unchanged, and then tested. +- **Not a substitute for acceptance.** The boots stay the only acceptance of the kernel; these run + beside them. Whether they become a stage of the protocol and the PR's gate (`nightly.sh + kernel-tests`, after test-32) is **to rule** -- proposed once there are enough tests to matter. + +## Increments + +- [x] 1. The harness (`Makefile`, `harness.h`, `fakes.[ch]`) and `test_gpt.c`: `gpt_read` and + `gpt_write_one` against a RAM disk -- a valid table; more used entries than `max` (#180); an + entry out of range past `max` (#180); the first write failing and a later one failing (#168). + Seen to fail against `gpt.c` as it was before #168 and #180 (`54311be1~1`): 12 checks, 4 + failures -- each of the two defects caught. Now: 0 failures. +- [ ] 2. The xHCI configuration-descriptor walk moved out of the event loop, then #170 and #171 + tested first and fixed. +- [ ] 3. #176 (seeds wiped on every exit), #177 (the fatal stops halt), #178 (the slot array) -- + each with what it needs moved or faked. +- [ ] 4. #179 once #187 is ruled (the block layer's header read). +- [ ] 5. To rule: the kernel tests as a protocol stage and in the gate. diff --git a/kernel/tests/Makefile b/kernel/tests/Makefile new file mode 100644 index 00000000..9e8ee6af --- /dev/null +++ b/kernel/tests/Makefile @@ -0,0 +1,39 @@ +# SPDX-License-Identifier: LicenseRef-Starship-1.0 +# Copyright (c) 2023-2026 Robert A. James +# +# The kernel host tests (docs/v4.0.0/plans/2026-11-12-kernel-host-tests.md): each test_.c +# built on the host with the kernel source file(s) it tests and the fakes, nothing else of the +# kernel, and run. +# +# make -C kernel/tests test build and run them all; fails if any check fails +# make -C kernel/tests run-test_gpt +# +# A test's sources are SRC_; give another on the command line to run the same test against +# it (how a test is seen to fail against the code before a fix): +# make -C kernel/tests run-test_gpt SRC_test_gpt=/path/to/old/gpt.c + +HERE := $(abspath $(dir $(lastword $(MAKEFILE_LIST)))) +ROOT := $(abspath $(HERE)/../..) +BUILD := $(ROOT)/build/kernel-tests +CC ?= cc +CFLAGS := -std=c99 -Wall -Wextra -Werror -O2 -g -I$(HERE) -I$(ROOT)/kernel/include \ + -I$(ROOT)/kernel/include/starkernel -I$(ROOT)/v3/include + +TESTS := test_gpt +SRC_test_gpt := $(ROOT)/kernel/src/gpt.c + +define TEST_RULE +$(BUILD)/$(1): $(HERE)/$(1).c $(HERE)/fakes.c $(HERE)/fakes.h $(HERE)/harness.h $$(SRC_$(1)) + @mkdir -p $(BUILD) + $(CC) $(CFLAGS) $(HERE)/$(1).c $(HERE)/fakes.c $$(SRC_$(1)) -o $$@ + +.PHONY: run-$(1) +run-$(1): $(BUILD)/$(1) + @echo "$(1)" + @$(BUILD)/$(1) +endef +$(foreach t,$(TESTS),$(eval $(call TEST_RULE,$(t)))) + +.PHONY: test +test: $(addprefix run-,$(TESTS)) + @echo "all kernel host tests passed" diff --git a/kernel/tests/fakes.c b/kernel/tests/fakes.c new file mode 100644 index 00000000..e6ff31d6 --- /dev/null +++ b/kernel/tests/fakes.c @@ -0,0 +1,104 @@ +/* SPDX-License-Identifier: LicenseRef-Starship-1.0 + * Copyright (c) 2023-2026 Robert A. James + */ +/* fakes.c -- see fakes.h. */ +#include "fakes.h" +#include +#include + +/* ---- the console ---- */ +#define FAKE_LINES 256 +#define FAKE_LINE 256 +static char lines[FAKE_LINES][FAKE_LINE]; +static unsigned n_lines; + +void fake_console_reset(void) { n_lines = 0; } +unsigned fake_console_lines(void) { return n_lines; } +const char *fake_console_line(unsigned i) { return i < n_lines ? lines[i] : ""; } +int fake_console_said(const char *text) +{ + unsigned i; + for (i = 0; i < n_lines; i++) if (strstr(lines[i], text)) return 1; + return 0; +} +static void take(const char *s) +{ + if (n_lines < FAKE_LINES) { + strncpy(lines[n_lines], s, FAKE_LINE - 1); + lines[n_lines][FAKE_LINE - 1] = '\0'; + n_lines++; + } +} +void console_println(const char *s) { take(s); } +void console_puts(const char *s) { take(s); } +void console_putc(char c) { (void)c; } + +/* ---- the RNG ---- */ +static uint32_t rng_state = 1u; +static int rng_fails; +void fake_rng_reset(void) { rng_state = 1u; rng_fails = 0; } +void fake_rng_fail(int fail) { rng_fails = fail; } +int rng_get_bytes(uint8_t *buf, size_t n) +{ + size_t i; + if (rng_fails) return -1; + for (i = 0; i < n; i++) { rng_state = rng_state * 1103515245u + 12345u; buf[i] = (uint8_t)(rng_state >> 16); } + return 0; +} + +/* ---- the disk ---- */ +static int listed(const uint32_t *list, uint32_t n, uint32_t block) +{ + uint32_t i; + for (i = 0; i < n; i++) if (list[i] == block) return 1; + return 0; +} +static int d_open(blkio_dev_t *dev, const blkio_params_t *p) { dev->state = p->opaque; return BLKIO_OK; } +static int d_close(blkio_dev_t *dev) { (void)dev; return BLKIO_OK; } +static int d_read(blkio_dev_t *dev, uint32_t fblock, void *dst) +{ + fake_disk *d = (fake_disk *)dev->state; + if (fblock >= d->blocks) return BLKIO_EINVAL; + if (listed(d->fail_read, d->n_fail_read, fblock)) return BLKIO_EIO; + memcpy(dst, d->bytes + (size_t)fblock * BLKIO_FORTH_BLOCK_SIZE, BLKIO_FORTH_BLOCK_SIZE); + return BLKIO_OK; +} +static int d_write(blkio_dev_t *dev, uint32_t fblock, const void *src) +{ + fake_disk *d = (fake_disk *)dev->state; + if (fblock >= d->blocks) return BLKIO_EINVAL; + if (listed(d->fail_write, d->n_fail_write, fblock)) return BLKIO_EIO; + memcpy(d->bytes + (size_t)fblock * BLKIO_FORTH_BLOCK_SIZE, src, BLKIO_FORTH_BLOCK_SIZE); + d->writes++; + return BLKIO_OK; +} +static int d_flush(blkio_dev_t *dev) { fake_disk *d = (fake_disk *)dev->state; d->flushes++; return d->fail_flush ? BLKIO_EIO : BLKIO_OK; } +static int d_info(blkio_dev_t *dev, blkio_info_t *out) +{ + fake_disk *d = (fake_disk *)dev->state; + memset(out, 0, sizeof *out); + out->forth_block_size = BLKIO_FORTH_BLOCK_SIZE; + out->total_blocks = d->blocks; + out->phys_sector_size = 512u; + out->phys_size_bytes = (uint64_t)d->blocks * BLKIO_FORTH_BLOCK_SIZE; + return BLKIO_OK; +} +static const blkio_vtable_t fake_vt = { d_open, d_close, d_read, d_write, d_flush, d_info }; + +int fake_disk_open(blkio_dev_t *dev, fake_disk *d, uint32_t blocks) +{ + blkio_params_t p; + memset(d, 0, sizeof *d); + d->bytes = (uint8_t *)calloc(blocks, BLKIO_FORTH_BLOCK_SIZE); + d->blocks = blocks; + if (!d->bytes) return BLKIO_EIO; + memset(&p, 0, sizeof p); + p.forth_block_size = BLKIO_FORTH_BLOCK_SIZE; + p.total_blocks = blocks; + p.opaque = d; + return blkio_open(dev, &fake_vt, &p); +} +void fake_disk_free(fake_disk *d) { free(d->bytes); d->bytes = 0; } +void fake_disk_fail_read(fake_disk *d, uint32_t block) { if (d->n_fail_read < FAKE_DISK_MAX_FAULTS) d->fail_read[d->n_fail_read++] = block; } +void fake_disk_fail_write(fake_disk *d, uint32_t block) { if (d->n_fail_write < FAKE_DISK_MAX_FAULTS) d->fail_write[d->n_fail_write++] = block; } +uint8_t *fake_disk_sector(fake_disk *d, uint64_t lba) { return d->bytes + (size_t)lba * 512u; } diff --git a/kernel/tests/fakes.h b/kernel/tests/fakes.h new file mode 100644 index 00000000..4ee16c1f --- /dev/null +++ b/kernel/tests/fakes.h @@ -0,0 +1,44 @@ +/* SPDX-License-Identifier: LicenseRef-Starship-1.0 + * Copyright (c) 2023-2026 Robert A. James + */ +/* fakes.h -- what a kernel unit calls, faked on the host: the console (captured), the RNG + * (deterministic), and a RAM disk whose reads or writes fail at chosen 1 KiB blocks + * (docs/v4.0.0/plans/2026-11-12-kernel-host-tests.md). + */ +#ifndef KERNEL_TESTS_FAKES_H +#define KERNEL_TESTS_FAKES_H + +#include +#include +#include "blkio.h" + +/* The console: every console_println/console_puts line, in order. */ +void fake_console_reset(void); +int fake_console_said(const char *text); /* 1 if some line contains `text` */ +unsigned fake_console_lines(void); +const char *fake_console_line(unsigned i); + +/* The RNG: a fixed sequence, so a test's GUIDs are the same every run; fake_rng_fail makes + * rng_get_bytes return -1 (no backend). */ +void fake_rng_reset(void); +void fake_rng_fail(int fail); + +/* A RAM disk of `blocks` 1 KiB blocks (512-byte sectors, so 2 per block). A read or write of a + * block in its fail lists returns BLKIO_EIO and changes nothing; every write is counted. */ +#define FAKE_DISK_MAX_FAULTS 8 +typedef struct { + uint8_t *bytes; + uint32_t blocks; + uint32_t fail_read[FAKE_DISK_MAX_FAULTS], n_fail_read; + uint32_t fail_write[FAKE_DISK_MAX_FAULTS], n_fail_write; + unsigned writes, flushes; + int fail_flush; +} fake_disk; + +int fake_disk_open(blkio_dev_t *dev, fake_disk *d, uint32_t blocks); /* zeroed; BLKIO_OK */ +void fake_disk_free(fake_disk *d); +void fake_disk_fail_read(fake_disk *d, uint32_t block); +void fake_disk_fail_write(fake_disk *d, uint32_t block); +uint8_t *fake_disk_sector(fake_disk *d, uint64_t lba); /* a 512-byte sector */ + +#endif diff --git a/kernel/tests/harness.h b/kernel/tests/harness.h new file mode 100644 index 00000000..d414c85e --- /dev/null +++ b/kernel/tests/harness.h @@ -0,0 +1,21 @@ +/* SPDX-License-Identifier: LicenseRef-Starship-1.0 + * Copyright (c) 2023-2026 Robert A. James + */ +/* harness.h -- the kernel host tests' checks (docs/v4.0.0/plans/2026-11-12-kernel-host-tests.md). + * + * As v4's tests: CHECK(cond, fmt, ...) counts a check, prints FAIL with the file, line and message + * when it fails; DONE() prints "N checks, M failures" and is main's return value. + */ +#ifndef KERNEL_TESTS_HARNESS_H +#define KERNEL_TESTS_HARNESS_H + +#include + +static int harness_checks = 0, harness_failures = 0; + +#define CHECK(c, ...) do { harness_checks++; if (!(c)) { harness_failures++; \ + printf("FAIL %s:%d: ", __FILE__, __LINE__); printf(__VA_ARGS__); printf("\n"); } } while (0) + +#define DONE() (printf(" %d checks, %d failures\n", harness_checks, harness_failures), harness_failures != 0) + +#endif diff --git a/kernel/tests/test_gpt.c b/kernel/tests/test_gpt.c new file mode 100644 index 00000000..90cb1311 --- /dev/null +++ b/kernel/tests/test_gpt.c @@ -0,0 +1,120 @@ +/* SPDX-License-Identifier: LicenseRef-Starship-1.0 + * Copyright (c) 2023-2026 Robert A. James + */ +/* test_gpt.c -- kernel/src/gpt.c on the host: gpt_read and gpt_write_one against a RAM disk whose + * writes can fail at chosen blocks (docs/v4.0.0/plans/2026-11-12-kernel-host-tests.md, increment 1). + * #168: gpt_write_one tells "nothing written" (GPT_EIO) from "the disk is changed" (GPT_PART). + * #180: gpt_read checks every entry and says when there are more than max (GPT_MANY). + */ +#include "harness.h" +#include "fakes.h" +#include "starkernel/gpt.h" +#include + +#define DISK_BLOCKS 8192u /* 8 MiB: 16384 sectors */ +#define SECTORS (DISK_BLOCKS * 2u) + +static void put_le32(uint8_t *p, uint32_t v) { p[0] = (uint8_t)v; p[1] = (uint8_t)(v >> 8); p[2] = (uint8_t)(v >> 16); p[3] = (uint8_t)(v >> 24); } +static void put_le64(uint8_t *p, uint64_t v) { put_le32(p, (uint32_t)v); put_le32(p + 4, (uint32_t)(v >> 32)); } + +/* A GPT written by hand: `used` entries of 64 sectors each from LBA 2048, the first two of them + * the StarshipOS identity and Artemis types when `ours_at` is 0, or at entries ours_at and + * ours_at+1; entry `bad` (if not -1) given a range past the last usable LBA. Both CRCs good. */ +static void make_table(fake_disk *d, unsigned used, unsigned ours_at, int bad) +{ + static const uint8_t linux_t[16] = { 0xAF, 0x3D, 0xC6, 0x0F, 0x83, 0x84, 0x72, 0x47, 0x8E, 0x79, 0x3D, 0x69, 0xD8, 0x47, 0x7D, 0xE4 }; + uint8_t *mbr = fake_disk_sector(d, 0), *h = fake_disk_sector(d, 1), *e = fake_disk_sector(d, 2); + uint64_t last_usable = SECTORS - 34u; + unsigned i; + + memset(d->bytes, 0, (size_t)34 * 512u); + mbr[446 + 4] = 0xEE; put_le32(mbr + 446 + 8, 1u); put_le32(mbr + 446 + 12, SECTORS - 1u); + mbr[510] = 0x55; mbr[511] = 0xAA; + for (i = 0; i < used; i++) { + uint8_t *x = e + (size_t)i * 128u; + const uint8_t *t = (i == ours_at) ? gpt_type_starship_identity : (i == ours_at + 1u) ? gpt_type_starship_artemis : linux_t; + memcpy(x, t, 16); + x[16] = (uint8_t)(i + 1); /* a partition GUID */ + put_le64(x + 32, 2048u + (uint64_t)i * 64u); + put_le64(x + 40, 2048u + (uint64_t)i * 64u + 63u); + if ((int)i == bad) { put_le64(x + 32, last_usable - 8u); put_le64(x + 40, last_usable + 64u); } + } + memcpy(h, "EFI PART", 8); + put_le32(h + 8, 0x00010000u); put_le32(h + 12, 92u); + put_le64(h + 24, 1u); put_le64(h + 32, SECTORS - 1u); + put_le64(h + 40, 34u); put_le64(h + 48, last_usable); + h[56] = 0x5A; /* a disk GUID */ + put_le64(h + 72, 2u); put_le32(h + 80, 128u); put_le32(h + 84, 128u); + put_le32(h + 88, gpt_crc32(e, 128u * 128u)); + put_le32(h + 16, 0u); put_le32(h + 16, gpt_crc32(h, 92u)); +} + +int main(void) +{ + blkio_dev_t dev; + fake_disk d; + gpt_part_t parts[16]; + unsigned n = 0; + uint8_t guid[16]; + int rc; + + fake_rng_reset(); + fake_console_reset(); + + /* gpt_write_one, then gpt_read: the table it writes reads back */ + CHECK(fake_disk_open(&dev, &d, DISK_BLOCKS) == BLKIO_OK, "a RAM disk"); + rc = gpt_write_one(&dev, gpt_type_starship_identity, 4096u, guid); + CHECK(rc == GPT_OK, "gpt_write_one on a blank disk: GPT_OK (%d)", rc); + rc = gpt_read(&dev, parts, 16u, &n); + CHECK(rc == GPT_OK && n == 1u && memcmp(parts[0].type, gpt_type_starship_identity, 16) == 0 + && parts[0].first_lba == 2048u, "and reads back: one identity partition at 2048 (rc %d, n %u)", rc, n); + fake_disk_free(&d); + + /* #180: every entry checked, more than max said */ + fake_disk_open(&dev, &d, DISK_BLOCKS); + make_table(&d, 16u, 0u, -1); + rc = gpt_read(&dev, parts, 16u, &n); + CHECK(rc == GPT_OK && n == 16u, "16 used entries, max 16: GPT_OK, 16 (rc %d, n %u)", rc, n); + make_table(&d, 18u, 16u, -1); + rc = gpt_read(&dev, parts, 16u, &n); + CHECK(rc == GPT_MANY && n == 16u, "#180: 18 used entries, ours the 17th and 18th, max 16: GPT_MANY, not cut to 16 as if they were all (rc %d, n %u)", rc, n); + make_table(&d, 17u, 0u, 16); + rc = gpt_read(&dev, parts, 16u, &n); + CHECK(rc == GPT_BAD, "#180: the 17th entry out of range, max 16: GPT_BAD -- it is checked (rc %d)", rc); + make_table(&d, 4u, 0u, 2); + rc = gpt_read(&dev, parts, 16u, &n); + CHECK(rc == GPT_BAD, "an entry out of range within max: GPT_BAD (rc %d)", rc); + fake_disk_free(&d); + + /* #168: gpt_write_one says whether it changed the disk */ + fake_disk_open(&dev, &d, DISK_BLOCKS); + fake_rng_fail(1); + rc = gpt_write_one(&dev, gpt_type_starship_identity, 4096u, guid); + CHECK(rc == GPT_EIO && d.writes == 0u, "no RNG: GPT_EIO before any write (rc %d, %u writes)", rc, d.writes); + fake_rng_fail(0); + fake_disk_free(&d); + + fake_disk_open(&dev, &d, DISK_BLOCKS); + make_table(&d, 2u, 0u, -1); + fake_disk_fail_write(&d, 0u); /* sector 0: its first write */ + rc = gpt_write_one(&dev, gpt_type_starship_identity, 4096u, guid); + CHECK(rc == GPT_EIO && d.writes == 0u, "#168: its first write fails: GPT_EIO, nothing written (rc %d, %u writes)", rc, d.writes); + rc = gpt_read(&dev, parts, 16u, &n); + CHECK(rc == GPT_OK && n == 2u, "and the old table is still there (rc %d, n %u)", rc, n); + fake_disk_free(&d); + + fake_disk_open(&dev, &d, DISK_BLOCKS); + make_table(&d, 2u, 0u, -1); + fake_disk_fail_write(&d, 1u); /* the primary entries, after the MBR is zeroed */ + rc = gpt_write_one(&dev, gpt_type_starship_identity, 4096u, guid); + CHECK(rc == GPT_PART && d.writes > 0u, "#168: a later write fails: GPT_PART, the disk changed (rc %d, %u writes)", rc, d.writes); + fake_disk_free(&d); + + fake_disk_open(&dev, &d, DISK_BLOCKS); + d.fail_flush = 1; + rc = gpt_write_one(&dev, gpt_type_starship_identity, 4096u, guid); + CHECK(rc == GPT_PART && d.writes == 1u, "#168: the first write made, its flush failing: GPT_PART (rc %d, %u writes)", rc, d.writes); + fake_disk_free(&d); + + return DONE(); +} diff --git a/logs/20261112-175655/kernel-tests/test_gpt.log b/logs/20261112-175655/kernel-tests/test_gpt.log new file mode 100644 index 00000000..881f44a7 --- /dev/null +++ b/logs/20261112-175655/kernel-tests/test_gpt.log @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:61cc427e962ebad3538b3ebcdcdac2d621e3374f434dbb5a6fcb9d744e59d01f +size 795