Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions .github/workflows/pages.yml
Original file line number Diff line number Diff line change
@@ -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/host-tests-suite, 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
25 changes: 25 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -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/host-tests-suite, 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
7 changes: 6 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,9 @@ build

.vscode

*.uf2
*.uf2

# host test artifacts
test/coverage/
*.gcno
*.gcda
4 changes: 2 additions & 2 deletions libs/base32/base32.c
Original file line number Diff line number Diff line change
Expand Up @@ -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++) {
Expand Down
145 changes: 145 additions & 0 deletions test/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
# 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 <target>`.

# 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 check-submodules

all: asan

# Fail early with a clear message if the git submodules aren't checked out
# (fresh clones have empty libs/qrcodegen and libs/littlefs).
check-submodules:
@test -f $(QRCODEGEN)/qrcodegen.c && test -f $(ROOT)/libs/littlefs/lfs_util.h || { \
echo "ERROR: git submodules not initialized (libs/qrcodegen and/or libs/littlefs are empty)." >&2; \
echo " run: git submodule update --init --recursive" >&2; \
exit 1; }

# --- ASan + UBSan -----------------------------------------------------------
asan: check-submodules $(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: check-submodules $(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: check-submodules | $(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
63 changes: 63 additions & 0 deletions test/harness_base64.c
Original file line number Diff line number Diff line change
@@ -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 <assert.h>
#include <stddef.h>
#include <stdio.h>
#include <string.h>

#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;
}
53 changes: 53 additions & 0 deletions test/harness_secret_qr.c
Original file line number Diff line number Diff line change
@@ -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 <assert.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>

#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;
}
15 changes: 15 additions & 0 deletions test/stub/pico/rand.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
#ifndef STUB_PICO_RAND_H
#define STUB_PICO_RAND_H

/*
* Host stub for the Pico SDK's <pico/rand.h>.
* 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 <stdint.h>

uint64_t get_rand_64(void); /* provided by the host harness */

#endif
Loading