diff --git a/Makefile b/Makefile index bf39759..e679db0 100644 --- a/Makefile +++ b/Makefile @@ -23,6 +23,11 @@ BIN := skeletonkey CORE_SRCS := core/registry.c core/kernel_range.c core/offsets.c core/finisher.c core/host.c CORE_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(CORE_SRCS)) +# Register-every-module helper. Lives in its own translation unit so +# the kernel_range unit-test binary can link just CORE_OBJS without +# pulling in every module symbol via registry_all.o. +REGISTRY_ALL_OBJ := $(BUILD)/core/registry_all.o + # Family: copy_fail_family # All DIRTYFAIL .c files contribute; skeletonkey_modules.c is the bridge. CFF_DIR := modules/copy_fail_family @@ -186,16 +191,26 @@ MODULE_OBJS := $(CFF_OBJS) $(DP_OBJS) $(EB_OBJS) $(PK_OBJS) $(NFT_OBJS) \ $(SAM_OBJS) $(SEQ_OBJS) $(SUE_OBJS) $(VMW_OBJS) \ $(DDC_OBJS) $(FGN_OBJS) $(P2TR_OBJS) -ALL_OBJS := $(TOP_OBJ) $(CORE_OBJS) $(MODULE_OBJS) +ALL_OBJS := $(TOP_OBJ) $(CORE_OBJS) $(REGISTRY_ALL_OBJ) $(MODULE_OBJS) -# Tests — `make test` builds and runs the detect() unit-test harness. -# Links against the same module objects as the main binary minus the -# top-level dispatcher (which provides main(); the test has its own). +# Tests — `make test` builds and runs both unit-test binaries. +# +# skeletonkey-test — detect() integration tests against +# synthetic host fingerprints. Links +# the full module corpus. +# skeletonkey-test-kr — pure unit tests for kernel_range + +# host comparison helpers. Tiny binary +# (core/ only); runs cross-platform. TEST_DIR := tests TEST_SRCS := $(TEST_DIR)/test_detect.c TEST_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(TEST_SRCS)) TEST_BIN := skeletonkey-test -TEST_ALL_OBJS := $(TEST_OBJS) $(CORE_OBJS) $(MODULE_OBJS) +TEST_ALL_OBJS := $(TEST_OBJS) $(CORE_OBJS) $(REGISTRY_ALL_OBJ) $(MODULE_OBJS) + +TEST_KR_SRCS := $(TEST_DIR)/test_kernel_range.c +TEST_KR_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(TEST_KR_SRCS)) +TEST_KR_BIN := skeletonkey-test-kr +TEST_KR_ALL_OBJS := $(TEST_KR_OBJS) $(CORE_OBJS) .PHONY: all clean debug static help test @@ -207,8 +222,14 @@ $(BIN): $(ALL_OBJS) $(TEST_BIN): $(TEST_ALL_OBJS) $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $^ -lpthread $(P2TR_LIBS) -test: $(TEST_BIN) - @echo "[*] running test suite ($(TEST_BIN))" +$(TEST_KR_BIN): $(TEST_KR_ALL_OBJS) + $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $^ + +test: $(TEST_BIN) $(TEST_KR_BIN) + @echo "[*] running kernel_range unit tests ($(TEST_KR_BIN))" + ./$(TEST_KR_BIN) + @echo + @echo "[*] running detect() integration tests ($(TEST_BIN))" ./$(TEST_BIN) # Generic compile: any .c → corresponding .o under build/ @@ -223,7 +244,7 @@ static: LDFLAGS += -static static: clean $(BIN) clean: - rm -rf $(BUILD) $(BIN) $(TEST_BIN) + rm -rf $(BUILD) $(BIN) $(TEST_BIN) $(TEST_KR_BIN) help: @echo "Targets:" diff --git a/core/registry.c b/core/registry.c index de37bce..69a0771 100644 --- a/core/registry.c +++ b/core/registry.c @@ -3,6 +3,11 @@ * * Simple flat array. Resized in chunks of 16. We never expect more * than a few dozen modules, so this is fine. + * + * The canonical "register every family" enumeration lives in + * registry_all.c — kept separate so this file links into the + * standalone kernel_range unit-test binary without pulling in every + * module's symbol. */ #include "registry.h" diff --git a/core/registry.h b/core/registry.h index 043b9c8..328f680 100644 --- a/core/registry.h +++ b/core/registry.h @@ -48,4 +48,11 @@ void skeletonkey_register_dirtydecrypt(void); void skeletonkey_register_fragnesia(void); void skeletonkey_register_pack2theroot(void); +/* Call every skeletonkey_register_() above in canonical order. + * Single source of truth so the main binary and the test binary stay + * in sync — adding a new module is one register_* declaration here + * and one call inside skeletonkey_register_all_modules() in + * core/registry.c (the test harness picks it up automatically). */ +void skeletonkey_register_all_modules(void); + #endif /* SKELETONKEY_REGISTRY_H */ diff --git a/core/registry_all.c b/core/registry_all.c new file mode 100644 index 0000000..38a50db --- /dev/null +++ b/core/registry_all.c @@ -0,0 +1,46 @@ +/* + * SKELETONKEY — canonical "register every module family" enumeration. + * + * Kept in its own translation unit so registry.c stays standalone: + * the kernel_range unit-test binary links registry.c (for the basic + * register / count / find API) without pulling in every module's + * symbol. The main binary and detect-integration test link this + * file too and get the full lineup. + * + * Adding a new module is one new register_() declaration in + * registry.h plus one call below — the integration test picks it up + * via skeletonkey_register_all_modules() in its main(). + */ + +#include "registry.h" + +void skeletonkey_register_all_modules(void) +{ + skeletonkey_register_copy_fail_family(); + skeletonkey_register_dirty_pipe(); + skeletonkey_register_entrybleed(); + skeletonkey_register_pwnkit(); + skeletonkey_register_nf_tables(); + skeletonkey_register_overlayfs(); + skeletonkey_register_cls_route4(); + skeletonkey_register_dirty_cow(); + skeletonkey_register_ptrace_traceme(); + skeletonkey_register_netfilter_xtcompat(); + skeletonkey_register_af_packet(); + skeletonkey_register_fuse_legacy(); + skeletonkey_register_stackrot(); + skeletonkey_register_af_packet2(); + skeletonkey_register_cgroup_release_agent(); + skeletonkey_register_overlayfs_setuid(); + skeletonkey_register_nft_set_uaf(); + skeletonkey_register_af_unix_gc(); + skeletonkey_register_nft_fwd_dup(); + skeletonkey_register_nft_payload(); + skeletonkey_register_sudo_samedit(); + skeletonkey_register_sequoia(); + skeletonkey_register_sudoedit_editor(); + skeletonkey_register_vmwgfx(); + skeletonkey_register_dirtydecrypt(); + skeletonkey_register_fragnesia(); + skeletonkey_register_pack2theroot(); +} diff --git a/skeletonkey.c b/skeletonkey.c index abbb44c..9783e33 100644 --- a/skeletonkey.c +++ b/skeletonkey.c @@ -1035,35 +1035,11 @@ static int cmd_one(const struct skeletonkey_module *m, const char *op, int main(int argc, char **argv) { - /* Bring up the module registry. As new families land, add their - * register_* call here. */ - skeletonkey_register_copy_fail_family(); - skeletonkey_register_dirty_pipe(); - skeletonkey_register_entrybleed(); - skeletonkey_register_pwnkit(); - skeletonkey_register_nf_tables(); - skeletonkey_register_overlayfs(); - skeletonkey_register_cls_route4(); - skeletonkey_register_dirty_cow(); - skeletonkey_register_ptrace_traceme(); - skeletonkey_register_netfilter_xtcompat(); - skeletonkey_register_af_packet(); - skeletonkey_register_fuse_legacy(); - skeletonkey_register_stackrot(); - skeletonkey_register_af_packet2(); - skeletonkey_register_cgroup_release_agent(); - skeletonkey_register_overlayfs_setuid(); - skeletonkey_register_nft_set_uaf(); - skeletonkey_register_af_unix_gc(); - skeletonkey_register_nft_fwd_dup(); - skeletonkey_register_nft_payload(); - skeletonkey_register_sudo_samedit(); - skeletonkey_register_sequoia(); - skeletonkey_register_sudoedit_editor(); - skeletonkey_register_vmwgfx(); - skeletonkey_register_dirtydecrypt(); - skeletonkey_register_fragnesia(); - skeletonkey_register_pack2theroot(); + /* Bring up the module registry. New module families register + * themselves via skeletonkey_register_all_modules() in + * core/registry.c — add the new register_*() call there so the + * test binary picks it up automatically. */ + skeletonkey_register_all_modules(); enum mode mode = MODE_SCAN; struct skeletonkey_ctx ctx = {0}; diff --git a/tests/test_detect.c b/tests/test_detect.c index 86a2378..8e93851 100644 --- a/tests/test_detect.c +++ b/tests/test_detect.c @@ -24,6 +24,7 @@ #include "../core/module.h" #include "../core/host.h" +#include "../core/registry.h" #include #include @@ -62,6 +63,23 @@ extern const struct skeletonkey_module pwnkit_module; static int g_pass = 0; static int g_fail = 0; +/* Record which modules at least one test row touched, so the harness + * can print a "modules without direct coverage" warning at the end. + * Linear append + scan is fine; we have <50 modules. The list is + * static-sized at SKELETONKEY_MAX_TESTED_MODULES; bump if we ever + * exceed it. */ +#define SKELETONKEY_MAX_TESTED_MODULES 128 +static const char *g_tested_modules[SKELETONKEY_MAX_TESTED_MODULES]; +static size_t g_tested_count = 0; + +static void mark_tested(const char *name) +{ + for (size_t i = 0; i < g_tested_count; i++) + if (strcmp(g_tested_modules[i], name) == 0) return; + if (g_tested_count < SKELETONKEY_MAX_TESTED_MODULES) + g_tested_modules[g_tested_count++] = name; +} + static const char *result_str(skeletonkey_result_t r) { switch (r) { @@ -89,6 +107,7 @@ static void run_one(const char *test_name, ctx.json = true; /* silence per-module log lines */ skeletonkey_result_t got = m->detect(&ctx); + mark_tested(m->name); if (got == want) { printf("[+] PASS %-40s %s → %s\n", test_name, m->name, result_str(got)); @@ -102,6 +121,31 @@ static void run_one(const char *test_name, } } +/* mk_host: derive a fingerprint from a base + a kernel override. + * + * The most common new-test shape is "I want fingerprint X but with a + * specific (major, minor, patch) — to nail a backport-boundary or + * predates-the-bug case". Doing this with a fresh struct literal each + * time obscures the *one* thing that's different. mk_host() does the + * copy + overlay, named release string included. + * + * Returns a struct VALUE so the caller stores it in a stack local and + * passes &h. No heap. The release string is the caller's responsibility + * (we don't synthesize from numerics to avoid implying a real release + * naming convention). */ +#ifdef __linux__ +static struct skeletonkey_host +mk_host(struct skeletonkey_host base, int major, int minor, int patch, + const char *release) +{ + base.kernel.major = major; + base.kernel.minor = minor; + base.kernel.patch = patch; + base.kernel.release = release; + return base; +} +#endif + /* ── fingerprints ────────────────────────────────────────────────── */ /* Linux 6.12.76 (Debian 13), no userns, no D-Bus, not Ubuntu — a @@ -486,6 +530,108 @@ static void run_all(void) run_one("nft_payload: vuln kernel 5.14 + userns ok → VULNERABLE", &nft_payload_module, &h_kernel_5_14_userns_ok, SKELETONKEY_VULNERABLE); + + /* ── drift-entry boundary coverage ──────────────────────────── + * These tests guard the kernel_patched_from entries added by the + * tools/refresh-kernel-ranges.py drift batch (commit 8de46e2). + * Each entry has a "just-below" + "exact" pair so a regression + * that drops or off-by-ones the entry is caught immediately. */ + + /* af_unix_gc {6, 4, 13} — Debian forky stable backport. The bug is + * reachable as a plain unprivileged user (AF_UNIX needs no caps and + * no userns), so 6.4.12 returns VULNERABLE rather than + * PRECOND_FAIL — the just-below-boundary verdict the table + * decides. */ + struct skeletonkey_host h_af_unix_6_4_12 = + mk_host(h_kernel_5_14_no_userns, 6, 4, 12, "6.4.12-test"); + run_one("af_unix_gc: 6.4.12 (one below new entry) → VULNERABLE", + &af_unix_gc_module, &h_af_unix_6_4_12, + SKELETONKEY_VULNERABLE); + + struct skeletonkey_host h_af_unix_6_4_13 = + mk_host(h_kernel_5_14_no_userns, 6, 4, 13, "6.4.13-test"); + run_one("af_unix_gc: 6.4.13 (exact new entry) → OK via patch table", + &af_unix_gc_module, &h_af_unix_6_4_13, + SKELETONKEY_OK); + + /* vmwgfx {5, 10, 127} — Debian bullseye stable backport. Below the + * entry, detect proceeds past the version check and fails the + * AF_VSOCK / /dev/dri probe in CI → PRECOND_FAIL. At the exact + * entry, kernel_range_is_patched short-circuits → OK. */ + struct skeletonkey_host h_vmwgfx_5_10_127 = + mk_host(h_kernel_5_14_no_userns, 5, 10, 127, "5.10.127-test"); + run_one("vmwgfx: 5.10.127 (exact new entry) → OK via patch table", + &vmwgfx_module, &h_vmwgfx_5_10_127, + SKELETONKEY_OK); + + /* nft_set_uaf {5, 10, 179} (harmonised from 5.10.180) — exact entry + * patches via table. */ + struct skeletonkey_host h_nft_set_5_10_179 = + mk_host(h_kernel_5_14_no_userns, 5, 10, 179, "5.10.179-test"); + run_one("nft_set_uaf: 5.10.179 (harmonised entry) → OK via patch table", + &nft_set_uaf_module, &h_nft_set_5_10_179, + SKELETONKEY_OK); + + /* nft_set_uaf {6, 1, 27} (harmonised from 6.1.28) — exact entry + * patches via table. */ + struct skeletonkey_host h_nft_set_6_1_27 = + mk_host(h_kernel_5_14_no_userns, 6, 1, 27, "6.1.27-test"); + run_one("nft_set_uaf: 6.1.27 (harmonised entry) → OK via patch table", + &nft_set_uaf_module, &h_nft_set_6_1_27, + SKELETONKEY_OK); + + /* nft_payload {5, 10, 162} (harmonised from 5.10.163) — exact entry. */ + struct skeletonkey_host h_nft_payload_5_10_162 = + mk_host(h_kernel_5_14_no_userns, 5, 10, 162, "5.10.162-test"); + run_one("nft_payload: 5.10.162 (harmonised entry) → OK via patch table", + &nft_payload_module, &h_nft_payload_5_10_162, + SKELETONKEY_OK); + + /* nf_tables {5, 10, 209} (harmonised from 5.10.210) — exact entry. */ + struct skeletonkey_host h_nf_tables_5_10_209 = + mk_host(h_kernel_5_14_no_userns, 5, 10, 209, "5.10.209-test"); + run_one("nf_tables: 5.10.209 (harmonised entry) → OK via patch table", + &nf_tables_module, &h_nf_tables_5_10_209, + SKELETONKEY_OK); + + /* ── coverage report ───────────────────────────────────────── + * Iterate the runtime registry (populated by skeletonkey_register_* + * calls in main()) and warn for any module that was not touched + * by at least one run_one() row above. Doesn't fail CI — listing + * is informational so we can grow coverage incrementally without + * blocking the build. */ + { + size_t n_reg = skeletonkey_module_count(); + size_t missing = 0; + for (size_t i = 0; i < n_reg; i++) { + const struct skeletonkey_module *m = + skeletonkey_module_at(i); + if (!m) continue; + bool found = false; + for (size_t j = 0; j < g_tested_count; j++) { + if (strcmp(g_tested_modules[j], m->name) == 0) { + found = true; break; + } + } + if (!found) { + if (missing++ == 0) { + fprintf(stderr, + "\n[i] coverage: module(s) without " + "a direct detect() test row:\n"); + } + fprintf(stderr, " - %s\n", m->name); + } + } + if (missing) { + fprintf(stderr, "[i] coverage: total %zu module(s) " + "need test rows (registry has %zu, tests touched %zu)\n", + missing, n_reg, g_tested_count); + } else { + fprintf(stderr, "[i] coverage: every registered module " + "has at least one direct test row (%zu/%zu)\n", + g_tested_count, n_reg); + } + } #else fprintf(stderr, "[i] non-Linux platform: detect() bodies are stubbed; " "tests skipped (would tautologically pass).\n"); @@ -495,6 +641,12 @@ static void run_all(void) int main(void) { fprintf(stderr, "=== SKELETONKEY detect() unit tests ===\n\n"); + + /* Populate the runtime registry so the post-run coverage report + * can iterate every module the main binary would. Same call used + * by skeletonkey.c main(). */ + skeletonkey_register_all_modules(); + run_all(); fprintf(stderr, "\n=== RESULTS: %d passed, %d failed ===\n", g_pass, g_fail); diff --git a/tests/test_kernel_range.c b/tests/test_kernel_range.c new file mode 100644 index 0000000..1f59b9f --- /dev/null +++ b/tests/test_kernel_range.c @@ -0,0 +1,222 @@ +/* + * tests/test_kernel_range.c — unit tests for the central kernel + * version-comparison helpers in core/kernel_range.c and core/host.c. + * + * These helpers are the foundation of the host-fingerprint pattern: + * every module that gates on kernel version routes through + * skeletonkey_host_kernel_at_least(), + * skeletonkey_host_kernel_in_range(), or kernel_range_is_patched(). + * A regression in any of them silently mis-classifies entire CVE + * families. The detect() integration tests in test_detect.c exercise + * these indirectly via real modules; this file pins them down with + * direct boundary-condition assertions so failures point at the right + * file. + * + * Cross-platform: pure logic, no Linux syscalls. Runs identically on + * macOS dev builds and Linux CI. + */ + +#include "../core/kernel_range.h" +#include "../core/host.h" + +#include +#include +#include + +static int g_pass = 0; +static int g_fail = 0; + +#define EXPECT(name, cond) do { \ + if (cond) { \ + printf("[+] PASS %s\n", (name)); \ + g_pass++; \ + } else { \ + fprintf(stderr, "[-] FAIL %s\n", (name)); \ + g_fail++; \ + } \ +} while (0) + +/* ── kernel_range_is_patched ────────────────────────────────────────── */ + +static void test_kernel_range_is_patched(void) +{ + /* Common single-branch-plus-mainline table: backport on 5.15.42, + * mainline fix at 5.17.0. */ + static const struct kernel_patched_from pf_5_15_5_17[] = { + {5, 15, 42}, + {5, 17, 0}, + }; + const struct kernel_range r1 = { pf_5_15_5_17, 2 }; + + struct kernel_version v; + + v = (struct kernel_version){5, 15, 42, NULL}; + EXPECT("range: exact backport boundary (5.15.42) → patched", + kernel_range_is_patched(&r1, &v)); + + v = (struct kernel_version){5, 15, 41, NULL}; + EXPECT("range: one below backport (5.15.41) → vulnerable", + !kernel_range_is_patched(&r1, &v)); + + v = (struct kernel_version){5, 15, 100, NULL}; + EXPECT("range: well above backport on same branch (5.15.100) → patched", + kernel_range_is_patched(&r1, &v)); + + v = (struct kernel_version){5, 17, 0, NULL}; + EXPECT("range: mainline fix exact (5.17.0) → patched", + kernel_range_is_patched(&r1, &v)); + + v = (struct kernel_version){5, 16, 0, NULL}; + EXPECT("range: between branches (5.16.0) → vulnerable", + !kernel_range_is_patched(&r1, &v)); + + v = (struct kernel_version){5, 14, 999, NULL}; + EXPECT("range: branch below all entries (5.14.999) → vulnerable", + !kernel_range_is_patched(&r1, &v)); + + v = (struct kernel_version){6, 12, 0, NULL}; + EXPECT("range: newer mainline branch (6.12.0) → patched via inheritance", + kernel_range_is_patched(&r1, &v)); + + /* Mainline-only entry — common pattern for a fresh CVE with no + * stable backports yet. */ + static const struct kernel_patched_from pf_7_0_only[] = { + {7, 0, 0}, + }; + const struct kernel_range r2 = { pf_7_0_only, 1 }; + + v = (struct kernel_version){6, 19, 99, NULL}; + EXPECT("mainline-only: kernel below mainline (6.19.99) → vulnerable", + !kernel_range_is_patched(&r2, &v)); + + v = (struct kernel_version){7, 0, 0, NULL}; + EXPECT("mainline-only: at mainline (7.0.0) → patched", + kernel_range_is_patched(&r2, &v)); + + v = (struct kernel_version){7, 5, 0, NULL}; + EXPECT("mainline-only: above mainline (7.5.0) → patched", + kernel_range_is_patched(&r2, &v)); + + /* Multi-LTS table mirroring real af_unix_gc layout. */ + static const struct kernel_patched_from pf_multi[] = { + {4, 14, 326}, + {4, 19, 295}, + {5, 4, 257}, + {5, 10, 197}, + {5, 15, 130}, + {6, 1, 51}, + {6, 4, 13}, + {6, 5, 0}, + }; + const struct kernel_range r3 = { pf_multi, 8 }; + + v = (struct kernel_version){5, 10, 196, NULL}; + EXPECT("multi-LTS: 5.10.196 (one below backport) → vulnerable", + !kernel_range_is_patched(&r3, &v)); + + v = (struct kernel_version){5, 10, 197, NULL}; + EXPECT("multi-LTS: 5.10.197 (exact backport) → patched", + kernel_range_is_patched(&r3, &v)); + + v = (struct kernel_version){6, 4, 12, NULL}; + EXPECT("multi-LTS: 6.4.12 (just-added entry, below) → vulnerable", + !kernel_range_is_patched(&r3, &v)); + + v = (struct kernel_version){6, 4, 13, NULL}; + EXPECT("multi-LTS: 6.4.13 (just-added entry, exact) → patched", + kernel_range_is_patched(&r3, &v)); + + v = (struct kernel_version){6, 2, 0, NULL}; + EXPECT("multi-LTS: 6.2.0 (between LTS branches, no match) → vulnerable", + !kernel_range_is_patched(&r3, &v)); + + v = (struct kernel_version){5, 8, 0, NULL}; + EXPECT("multi-LTS: 5.8.0 (between LTS branches) → vulnerable", + !kernel_range_is_patched(&r3, &v)); + + /* NULL safety. */ + v = (struct kernel_version){5, 15, 42, NULL}; + EXPECT("null safety: NULL range → false", + !kernel_range_is_patched(NULL, &v)); + EXPECT("null safety: NULL version → false", + !kernel_range_is_patched(&r1, NULL)); +} + +/* ── skeletonkey_host_kernel_at_least ───────────────────────────────── */ + +static void test_host_kernel_at_least(void) +{ + struct skeletonkey_host h = {0}; + h.kernel.major = 6; h.kernel.minor = 12; h.kernel.patch = 5; + + EXPECT("at_least: 6.12.5 ≥ 6.12.5 → true (exact)", + skeletonkey_host_kernel_at_least(&h, 6, 12, 5)); + EXPECT("at_least: 6.12.5 ≥ 6.12.4 → true", + skeletonkey_host_kernel_at_least(&h, 6, 12, 4)); + EXPECT("at_least: 6.12.5 ≥ 6.12.6 → false", + !skeletonkey_host_kernel_at_least(&h, 6, 12, 6)); + EXPECT("at_least: 6.12.5 ≥ 6.11.999 → true (lower minor)", + skeletonkey_host_kernel_at_least(&h, 6, 11, 999)); + EXPECT("at_least: 6.12.5 ≥ 6.13.0 → false (higher minor)", + !skeletonkey_host_kernel_at_least(&h, 6, 13, 0)); + EXPECT("at_least: 6.12.5 ≥ 5.0.0 → true (lower major)", + skeletonkey_host_kernel_at_least(&h, 5, 0, 0)); + EXPECT("at_least: 6.12.5 ≥ 7.0.0 → false (higher major)", + !skeletonkey_host_kernel_at_least(&h, 7, 0, 0)); + + /* NULL host → false (don't crash). */ + EXPECT("at_least: NULL host → false", + !skeletonkey_host_kernel_at_least(NULL, 5, 0, 0)); + + /* Unpopulated host (major == 0) → false on any positive threshold: + * a zero kernel version means we never probed; modules should + * fail-safe by treating "unknown" as "below". */ + struct skeletonkey_host h_zero = {0}; + EXPECT("at_least: zeroed host (major=0) → false on any threshold", + !skeletonkey_host_kernel_at_least(&h_zero, 5, 0, 0)); +} + +/* ── skeletonkey_host_kernel_in_range ───────────────────────────────── */ + +static void test_host_kernel_in_range(void) +{ + struct skeletonkey_host h = {0}; + + /* Window [5.8.0, 5.17.0) — the classic mainline introduction/fix + * pattern used by dirty_pipe and several others. */ + + h.kernel = (struct kernel_version){5, 8, 0, NULL}; + EXPECT("in_range: 5.8.0 in [5.8.0, 5.17.0) → true (lo inclusive)", + skeletonkey_host_kernel_in_range(&h, 5, 8, 0, 5, 17, 0)); + + h.kernel = (struct kernel_version){5, 16, 999, NULL}; + EXPECT("in_range: 5.16.999 in [5.8.0, 5.17.0) → true (inside)", + skeletonkey_host_kernel_in_range(&h, 5, 8, 0, 5, 17, 0)); + + h.kernel = (struct kernel_version){5, 17, 0, NULL}; + EXPECT("in_range: 5.17.0 in [5.8.0, 5.17.0) → false (hi exclusive)", + !skeletonkey_host_kernel_in_range(&h, 5, 8, 0, 5, 17, 0)); + + h.kernel = (struct kernel_version){5, 7, 999, NULL}; + EXPECT("in_range: 5.7.999 below 5.8.0 → false", + !skeletonkey_host_kernel_in_range(&h, 5, 8, 0, 5, 17, 0)); + + h.kernel = (struct kernel_version){6, 0, 0, NULL}; + EXPECT("in_range: 6.0.0 above 5.17 → false", + !skeletonkey_host_kernel_in_range(&h, 5, 8, 0, 5, 17, 0)); + + /* NULL host. */ + EXPECT("in_range: NULL host → false", + !skeletonkey_host_kernel_in_range(NULL, 5, 8, 0, 5, 17, 0)); +} + +int main(void) +{ + fprintf(stderr, "=== SKELETONKEY kernel_range unit tests ===\n\n"); + test_kernel_range_is_patched(); + test_host_kernel_at_least(); + test_host_kernel_in_range(); + fprintf(stderr, "\n=== RESULTS: %d passed, %d failed ===\n", + g_pass, g_fail); + return g_fail ? 1 : 0; +}