From 089d5884f7c158d42202f59edc34f3e435bc4bc9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 12:13:53 +0200 Subject: [PATCH] Add standalone host test suite (independent of the format PR) Host-side test suite that builds the hardware-independent logic as a native Linux ELF and exercises it, with no dependency on the format/lint PR: it drives the test Makefile directly and ships its own workflows. - ASan/UBSan + Valgrind harnesses (secret/QR, base64) run as a hard gate - base32 UB fix: encode buffer int -> uint32_t (shift past int width) - own .github/workflows/test.yml (make -C test asan|valgrind|coverage) - pages.yml publishes the lcov/genhtml coverage report to GitHub Pages, built via `make -C test coverage` instead of the format PR's ci script - .gitignore covers test artifacts (build/, coverage/, *.gcno, *.gcda) Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/pages.yml | 49 +++++++++++++ .github/workflows/test.yml | 25 +++++++ .gitignore | 7 +- libs/base32/base32.c | 4 +- test/Makefile | 137 ++++++++++++++++++++++++++++++++++++ test/harness_base64.c | 63 +++++++++++++++++ test/harness_secret_qr.c | 53 ++++++++++++++ test/stub/pico/rand.h | 15 ++++ 8 files changed, 350 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/pages.yml create mode 100644 .github/workflows/test.yml create mode 100644 test/Makefile create mode 100644 test/harness_base64.c create mode 100644 test/harness_secret_qr.c create mode 100644 test/stub/pico/rand.h diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..c611be7 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,49 @@ +name: pages + +# Publishes the host-test coverage HTML report (test/coverage/html/) to GitHub +# Pages. Kept separate from ci.yml so the coverage gate and the publish step do +# not entangle. The report is regenerated on every push to the branches below. +on: + push: + branches: [chore/tests, master] + +# Least privilege for the Pages deployment via the job's OIDC token. +permissions: + contents: read + pages: write + id-token: write + +# Serialize deployments; do not cancel an in-flight publish. +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + # Submodules (qrcodegen/littlefs) are needed for the coverage build. + - uses: actions/checkout@v4 + with: + submodules: recursive + # gcc/make are preinstalled; lcov (and valgrind) are not. + - run: sudo apt-get update && sudo apt-get install -y valgrind lcov + # Run harnesses + (re)generate test/coverage/html/ via the test Makefile. + - run: make -C test coverage + # Enable Pages via the job token (no admin CLI scope needed). + - uses: actions/configure-pages@v5 + with: + enablement: true + - uses: actions/upload-pages-artifact@v3 + with: + path: test/coverage/html + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..b93afc5 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,25 @@ +name: test + +# Standalone host-test workflow. Independent of the format/lint tooling: it +# drives the test Makefile directly (make -C test ...) rather than the format +# PR's ci wrapper script, so this suite shares no files with the format PR. +on: + push: + branches: [chore/tests, master] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + # Submodules (qrcodegen/littlefs) are needed for the coverage build. + - uses: actions/checkout@v4 + with: + submodules: recursive + # gcc/make are preinstalled; valgrind + lcov are not. + - run: sudo apt-get update && sudo apt-get install -y valgrind lcov + # Hard gate: ASan+UBSan, then Valgrind. Coverage is report-only but must + # still build/run cleanly. Each step must exit 0 or the job fails. + - run: make -C test asan + - run: make -C test valgrind + - run: make -C test coverage diff --git a/.gitignore b/.gitignore index 5499017..c303104 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,9 @@ build .vscode -*.uf2 \ No newline at end of file +*.uf2 + +# host test artifacts +test/coverage/ +*.gcno +*.gcda \ No newline at end of file diff --git a/libs/base32/base32.c b/libs/base32/base32.c index ae245fa..1e3a570 100644 --- a/libs/base32/base32.c +++ b/libs/base32/base32.c @@ -3,8 +3,8 @@ static const char B32_CHARS[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; void base32_encode(const uint8_t *in, size_t in_len, char *out) { - int buffer = 0; - int bits_left = 0; + uint32_t buffer = 0; + int bits_left = 0; size_t out_len = 0; for (size_t i = 0; i < in_len; i++) { diff --git a/test/Makefile b/test/Makefile new file mode 100644 index 0000000..0e567ae --- /dev/null +++ b/test/Makefile @@ -0,0 +1,137 @@ +# Host test suite for hslock. +# +# The firmware is cross-compiled for the RP2040 and cannot run natively, but +# the hardware-independent logic can be built as a native Linux ELF and tested. +# This Makefile builds each harness against the REAL first-party sources plus +# cheap host stubs (test/stub/), and runs them under three modes: +# +# make asan ASan + UBSan, aborts on any error (CI gate) +# make valgrind plain build under valgrind, fails on error/leak +# make coverage --coverage build of the harness-exercised modules, run, +# lcov + genhtml report (branch coverage) into coverage/html/ +# make clean +# +# Invoke from the repo root as `make -C test `. + +# Repo layout, relative to this Makefile (test/). +ROOT := .. +BUILD := build +COV_DIR := coverage + +# Absolute repo root, used to filter coverage to first-party sources WITHOUT +# assuming the checkout dir is literally named "hslock". Coverage is built with +# -fprofile-abs-path, so the .info tracefiles carry absolute source paths; +# extracting "$(REPO_ROOT)/*" keeps first-party files regardless of dir name. +# Prefer git's toplevel; fall back to the parent dir for non-git checkouts. +REPO_ROOT := $(or $(shell git -C $(CURDIR) rev-parse --show-toplevel 2>/dev/null),$(abspath $(ROOT))) + +# Real first-party / third-party sources exercised by the harnesses. +SHARED := $(ROOT)/shared +BASE32 := $(ROOT)/libs/base32 +BASE64 := $(ROOT)/libs/base64 +QRCODEGEN := $(ROOT)/libs/qrcodegen/c + +# Include roots. `-I..` resolves module-qualified includes ("hardware/buzzer.h", +# "storage/storage.h", ...); the per-module dirs resolve bare includes +# ("buzzer.h", "base32.h", ...); `-Istub` supplies host shims for Pico-SDK +# headers; `-I$(ROOT)/libs/littlefs` supplies the real lfs_util.h (lfs_crc). +INCLUDES := -Istub -I$(ROOT) \ + -I$(ROOT)/hardware -I$(ROOT)/network -I$(ROOT)/serial \ + -I$(ROOT)/storage -I$(SHARED) -I$(BASE32) -I$(BASE64) \ + -I$(QRCODEGEN) -I$(ROOT)/libs/littlefs + +# Per-harness source lists (harness + its real dependencies). +SECRET_QR_SRCS := harness_secret_qr.c $(SHARED)/random.c $(BASE32)/base32.c \ + $(QRCODEGEN)/qrcodegen.c +BASE64_SRCS := harness_base64.c $(BASE64)/base64.c + +CC := gcc +CSTD := -std=c11 +WARN := -Wall -Wextra + +ASAN_FLAGS := -g -O1 -fsanitize=address,undefined -fno-sanitize-recover=all +VG_FLAGS := -g -O0 +COV_FLAGS := -g -O0 --coverage -fprofile-abs-path + +# lcov 2.x: enable branch coverage everywhere; tolerate the benign +# inconsistencies host coverage of firmware naturally produces. +# +# The valid --ignore-errors category set differs per sub-tool and per lcov +# build: Ubuntu 24.04's lcov 2.0 genhtml rejects 'gcov'/'graph'/'range' +# (there is no gcov phase in genhtml), while lcov/geninfo accept 'gcov' but +# reject 'range'. So keep two lists: the capture/combine list (lcov+geninfo) +# may carry 'gcov'; the genhtml list must not. Every category below exists in +# lcov 2.0.0, so this is robust across lcov 1.x/2.x on Debian and Ubuntu. +LCOV_RC := --rc branch_coverage=1 +LCOV_IGNORE := --ignore-errors unused,empty,mismatch,inconsistent,source,gcov,negative +GENHTML_IGNORE := --ignore-errors unused,empty,mismatch,inconsistent,source,negative +LCOV := lcov $(LCOV_RC) $(LCOV_IGNORE) + +.PHONY: all asan valgrind coverage clean + +all: asan + +# --- ASan + UBSan ----------------------------------------------------------- +asan: $(BUILD)/asan_secret_qr $(BUILD)/asan_base64 + @echo "== running asan/ubsan harnesses ==" + $(BUILD)/asan_secret_qr + $(BUILD)/asan_base64 + @echo "== asan OK ==" + +$(BUILD)/asan_secret_qr: $(SECRET_QR_SRCS) | $(BUILD) + $(CC) $(CSTD) $(WARN) $(ASAN_FLAGS) $(INCLUDES) $(SECRET_QR_SRCS) -o $@ + +$(BUILD)/asan_base64: $(BASE64_SRCS) | $(BUILD) + $(CC) $(CSTD) $(WARN) $(ASAN_FLAGS) $(INCLUDES) $(BASE64_SRCS) -o $@ + +# --- Valgrind --------------------------------------------------------------- +valgrind: $(BUILD)/vg_secret_qr $(BUILD)/vg_base64 + @echo "== running harnesses under valgrind ==" + valgrind --error-exitcode=99 --leak-check=full -q $(BUILD)/vg_secret_qr + valgrind --error-exitcode=99 --leak-check=full -q $(BUILD)/vg_base64 + @echo "== valgrind OK ==" + +$(BUILD)/vg_secret_qr: $(SECRET_QR_SRCS) | $(BUILD) + $(CC) $(CSTD) $(WARN) $(VG_FLAGS) $(INCLUDES) $(SECRET_QR_SRCS) -o $@ + +$(BUILD)/vg_base64: $(BASE64_SRCS) | $(BUILD) + $(CC) $(CSTD) $(WARN) $(VG_FLAGS) $(INCLUDES) $(BASE64_SRCS) -o $@ + +# --- Coverage (lcov/genhtml HTML report for the harness-exercised modules) -- +# Build & run the real harnesses with --coverage (producing .gcda), capture +# the run data, filter to first-party sources (dropping the vendored libs and +# the test harnesses themselves), and render coverage/html/ with branches. +# The report covers exactly the modules the harnesses exercise (base32.c, +# base64.c, random.c); HW-only firmware is not built host-side and is absent. +coverage: | $(COV_DIR)/obj + @echo "== coverage: building + running harnesses ==" + $(CC) $(CSTD) $(WARN) $(COV_FLAGS) $(INCLUDES) $(SECRET_QR_SRCS) \ + -o $(COV_DIR)/obj/cov_secret_qr + $(CC) $(CSTD) $(WARN) $(COV_FLAGS) $(INCLUDES) $(BASE64_SRCS) \ + -o $(COV_DIR)/obj/cov_base64 + @echo "== coverage: running instrumented harnesses ==" + $(COV_DIR)/obj/cov_secret_qr + $(COV_DIR)/obj/cov_base64 + @echo "== coverage: capturing run data ==" + $(LCOV) --capture --directory $(COV_DIR)/obj \ + --output-file $(COV_DIR)/run.info + @echo "== coverage: filtering to first-party sources ==" + $(LCOV) --extract $(COV_DIR)/run.info '$(REPO_ROOT)/*' \ + --output-file $(COV_DIR)/first.info + $(LCOV) --remove $(COV_DIR)/first.info \ + '*/libs/qrcodegen/*' '*/libs/littlefs/*' '*/test/*' '/usr/*' \ + --output-file $(COV_DIR)/coverage.info + @echo "== coverage: generating HTML report ==" + genhtml $(LCOV_RC) $(GENHTML_IGNORE) --branch-coverage \ + --legend --title "hslock host coverage" \ + --output-directory $(COV_DIR)/html $(COV_DIR)/coverage.info + @echo "== coverage OK: report at $(COV_DIR)/html/index.html ==" + +$(BUILD): + mkdir -p $(BUILD) + +$(COV_DIR)/obj: + mkdir -p $(COV_DIR)/obj + +clean: + rm -rf $(BUILD) $(COV_DIR) *.gcno *.gcda diff --git a/test/harness_base64.c b/test/harness_base64.c new file mode 100644 index 0000000..a7d6230 --- /dev/null +++ b/test/harness_base64.c @@ -0,0 +1,63 @@ +/* + * Host harness: libs/base64 encode + decode roundtrip. + * Exercises empty input and every input-length residue mod 3 (0/1/2) so that + * both the full-triple loop and the two tail branches of base64_encode, plus + * the padding branches of base64_decode, are covered. Asserts roundtrip + * identity: decode(encode(x)) == x for all cases. + */ + +#include +#include +#include +#include + +#include "base64.h" + +static void roundtrip(const unsigned char *in, size_t in_len) { + char encoded[BASE64_ENCODED_LEN(64)]; + assert(BASE64_ENCODED_LEN(in_len) <= sizeof encoded); + + base64_encode(in, in_len, encoded); + + size_t enc_len = strlen(encoded); + /* Encoded length is always a multiple of 4 (with padding). */ + assert(enc_len % 4 == 0); + assert(enc_len == (in_len + 2) / 3 * 4); + + unsigned char decoded[64]; + int dec_len = base64_decode(encoded, enc_len, decoded); + + assert(dec_len == (int)in_len); + assert(memcmp(in, decoded, in_len) == 0); +} + +int main(void) { + /* Empty input: encoder emits just a null terminator, decoder returns 0. */ + roundtrip((const unsigned char *)"", 0); + + /* Lengths covering every residue mod 3. */ + const char *samples[] = { + "f", /* 1 -> "==" padding branch (single tail byte) */ + "fo", /* 2 -> "=" padding branch (two tail bytes) */ + "foo", /* 3 -> exact triple, no padding */ + "foob", /* 4 */ + "fooba", /* 5 */ + "foobar", /* 6 */ + "hello world", /* 11 */ + }; + for (size_t i = 0; i < sizeof samples / sizeof samples[0]; i++) { + roundtrip((const unsigned char *)samples[i], strlen(samples[i])); + } + + /* Binary payload including a NUL and high bytes. */ + const unsigned char bin[] = {0x00, 0xff, 0x10, 0x80, 0x7f, 0x01, 0xab, 0xcd}; + roundtrip(bin, sizeof bin); + + /* Invalid inputs: bad length and out-of-alphabet char must be rejected. */ + unsigned char scratch[64]; + assert(base64_decode("abc", 3, scratch) == -1); /* length not multiple of 4 */ + assert(base64_decode("ab*d", 4, scratch) == -1); /* '*' not in alphabet */ + + printf("base64 roundtrip OK\n"); + return 0; +} diff --git a/test/harness_secret_qr.c b/test/harness_secret_qr.c new file mode 100644 index 0000000..d22a543 --- /dev/null +++ b/test/harness_secret_qr.c @@ -0,0 +1,53 @@ +/* + * Host harness: secret -> base32 -> otpauth URI -> QR code. + * Mirrors the flow in serial/commands_keys.c (cmd_get_key_secret) using the + * real shared/random, libs/base32 and libs/qrcodegen sources, with a + * deterministic host RNG so the result is reproducible. + */ + +#include +#include +#include +#include + +#include "random.h" /* shared/random.h -> generate_secret */ +#include "base32.h" /* libs/base32 */ +#include "qrcodegen.h" /* libs/qrcodegen/c */ + +#define KEY_SECRET_LEN 20 + +/* Deterministic stand-in for the RP2040 hardware RNG (LCG). */ +static uint64_t g_seed = 0x123456789abcdef0ULL; +uint64_t get_rand_64(void) { + g_seed = g_seed * 6364136223846793005ULL + 1442695040888963407ULL; + return g_seed; +} + +int main(void) { + uint8_t secret[KEY_SECRET_LEN]; + generate_secret(secret); + + char b32[BASE32_ENCODED_LEN(KEY_SECRET_LEN)]; + base32_encode(secret, KEY_SECRET_LEN, b32); + + /* 20 bytes -> ceil(160/5) = 32 base32 chars, no padding. */ + assert(strlen(b32) == 32); + for (size_t i = 0; b32[i]; i++) { + assert(strchr("ABCDEFGHIJKLMNOPQRSTUVWXYZ234567", b32[i]) != NULL); + } + + char uri[256]; + snprintf(uri, sizeof uri, "otpauth://totp/hslock:admin?secret=%s&issuer=hslock", b32); + + static uint8_t qr[qrcodegen_BUFFER_LEN_MAX]; + static uint8_t tmp[qrcodegen_BUFFER_LEN_MAX]; + bool ok = qrcodegen_encodeText(uri, tmp, qr, qrcodegen_Ecc_MEDIUM, qrcodegen_VERSION_MIN, + qrcodegen_VERSION_MAX, qrcodegen_Mask_AUTO, true); + assert(ok); + + int size = qrcodegen_getSize(qr); + assert(size > 0 && size <= 177); + + printf("secret_b32=%s\nqr_size=%d\nOK\n", b32, size); + return 0; +} diff --git a/test/stub/pico/rand.h b/test/stub/pico/rand.h new file mode 100644 index 0000000..7c270c9 --- /dev/null +++ b/test/stub/pico/rand.h @@ -0,0 +1,15 @@ +#ifndef STUB_PICO_RAND_H +#define STUB_PICO_RAND_H + +/* + * Host stub for the Pico SDK's . + * On real hardware get_rand_64() is the RP2040's ROSC-backed RNG; on the + * host each test harness provides its own deterministic definition so that + * secret generation is reproducible. + */ + +#include + +uint64_t get_rand_64(void); /* provided by the host harness */ + +#endif