From 1727bba1d127decf29d54f42ed6910befe36d16b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 04:56:38 +0200 Subject: [PATCH 01/19] Add clang-format config + CI format check, reformat sources Introduce a `.clang-format` tuned to the existing hand-written style (4-space indent, spaces-only, K&R braces, 100-col) and a GitHub Actions job that enforces it on our sources (vendored `libs/` and generated `version.h` are excluded). The config keeps `SortIncludes: false` and enables the `AlignConsecutive*` options so most of the author's manual column alignment (aligned #defines, struct members) survives. Remaining reflow is alignment inside call-args and brace-init tables, which clang-format cannot preserve. The accompanying reformat is provably whitespace-only: with all whitespace stripped, every touched file's token stream is byte-identical to HEAD. Co-Authored-By: Claude Opus 4.8 (1M context) --- .clang-format | 16 ++++ .github/workflows/format.yml | 18 +++++ hardware/buzzer.h | 4 +- hardware/clock.c | 7 +- hardware/keypad.c | 16 ++-- hardware/light.c | 1 - lwipopts.h | 133 ++++++++++++++++---------------- main.c | 19 +++-- network/ntp.c | 41 +++++----- network/ntp.h | 8 +- network/wifi.c | 6 +- serial/commands.c | 94 ++++++++++------------- serial/commands_keys.c | 65 +++++++--------- serial/commands_network.c | 2 +- serial/commands_system.c | 15 ++-- serial/console.c | 23 ++++-- shared/random.c | 6 +- storage/backup.c | 43 +++++------ storage/backup.h | 6 +- storage/storage.c | 145 +++++++++++++++++++---------------- storage/storage.h | 10 +-- 21 files changed, 350 insertions(+), 328 deletions(-) create mode 100644 .clang-format create mode 100644 .github/workflows/format.yml diff --git a/.clang-format b/.clang-format new file mode 100644 index 0000000..1d63027 --- /dev/null +++ b/.clang-format @@ -0,0 +1,16 @@ +# Config tuned to the existing hand-written style: +# 4-space indent, spaces only, K&R braces, 100-col. +# The Align* + SortIncludes:false options preserve the author's manual +# column alignment (aligned #defines, struct members, tables) as much as +# clang-format allows. Residual churn is alignment inside call-args and +# brace-init tables, which clang-format cannot preserve. +BasedOnStyle: LLVM +IndentWidth: 4 +TabWidth: 4 +UseTab: Never +ColumnLimit: 100 +SortIncludes: false +AlignConsecutiveMacros: AcrossComments +AlignConsecutiveDeclarations: true +AlignConsecutiveAssignments: true +AllowShortFunctionsOnASingleLine: None diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml new file mode 100644 index 0000000..5dd24db --- /dev/null +++ b/.github/workflows/format.yml @@ -0,0 +1,18 @@ +name: format + +on: + push: + pull_request: + +jobs: + clang-format: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Check formatting + uses: jidicula/clang-format-action@v4.15.0 + with: + clang-format-version: '19' + # Only our own sources; vendored libs/ and the generated + # version.h are left alone. + exclude-regex: '(^|/)(libs|build)/|version\.h' diff --git a/hardware/buzzer.h b/hardware/buzzer.h index dfc8578..f47a8d4 100644 --- a/hardware/buzzer.h +++ b/hardware/buzzer.h @@ -3,9 +3,9 @@ #define BUZZER_PIN 17 -#define BUZZER_SHORT_BEEP_DELAY 100 +#define BUZZER_SHORT_BEEP_DELAY 100 #define BUZZER_MEDIUM_BEEP_DELAY 500 -#define BUZZER_LONG_BEEP_DELAY 2000 +#define BUZZER_LONG_BEEP_DELAY 2000 void buzzer_init(); void buzzer_on(); diff --git a/hardware/clock.c b/hardware/clock.c index 0548bf8..0cebab0 100644 --- a/hardware/clock.c +++ b/hardware/clock.c @@ -2,7 +2,8 @@ uint32_t clock_get_unix_time(void) { datetime_t dt; - if (!rtc_get_datetime(&dt)) return 0; + if (!rtc_get_datetime(&dt)) + return 0; struct tm t = { .tm_year = dt.year - 1900, @@ -17,12 +18,12 @@ uint32_t clock_get_unix_time(void) { } void clock_set_from_unix_time(uint32_t unix_time) { - time_t t = (time_t)unix_time; + time_t t = (time_t)unix_time; struct tm *utc = gmtime(&t); datetime_t dt = { .year = utc->tm_year + 1900, - .month = utc->tm_mon + 1, + .month = utc->tm_mon + 1, .day = utc->tm_mday, .dotw = utc->tm_wday, .hour = utc->tm_hour, diff --git a/hardware/keypad.c b/hardware/keypad.c index 04d1b71..a79cee1 100644 --- a/hardware/keypad.c +++ b/hardware/keypad.c @@ -3,11 +3,7 @@ #include "pico/time.h" static const char KEY_MAP[KEYPAD_ROWS][KEYPAD_COLS] = { - {'1', '2', '3', 'A'}, - {'4', '5', '6', 'B'}, - {'7', '8', '9', 'C'}, - {'*', '0', '#', 'D'} -}; + {'1', '2', '3', 'A'}, {'4', '5', '6', 'B'}, {'7', '8', '9', 'C'}, {'*', '0', '#', 'D'}}; void keypad_init(void) { // Rows: outputs, default HIGH @@ -48,16 +44,16 @@ static char scan_raw(void) { } char keypad_get_key(void) { - static char last_raw = 0; - static char last_stable = 0; - static absolute_time_t stable_at = {0}; + static char last_raw = 0; + static char last_stable = 0; + static absolute_time_t stable_at = {0}; char raw = scan_raw(); if (raw != last_raw) { // Reading changed - restart debounce timer - last_raw = raw; - stable_at = make_timeout_time_ms(KEYPAD_DEBOUNCE_MS); + last_raw = raw; + stable_at = make_timeout_time_ms(KEYPAD_DEBOUNCE_MS); return 0; } diff --git a/hardware/light.c b/hardware/light.c index 23ebe5a..32c565e 100644 --- a/hardware/light.c +++ b/hardware/light.c @@ -3,7 +3,6 @@ #include - void light_init() { gpio_init(LIGHT_PIN); gpio_set_dir(LIGHT_PIN, GPIO_OUT); diff --git a/lwipopts.h b/lwipopts.h index 3cc083c..b92fa67 100644 --- a/lwipopts.h +++ b/lwipopts.h @@ -1,92 +1,91 @@ #ifndef _LWIPOPTS_EXAMPLE_COMMONH_H #define _LWIPOPTS_EXAMPLE_COMMONH_H - // Common settings used in most of the pico_w examples // (see https://www.nongnu.org/lwip/2_1_x/group__lwip__opts.html for details) // allow override in some examples #ifndef NO_SYS -#define NO_SYS 1 +#define NO_SYS 1 #endif // allow override in some examples #ifndef LWIP_SOCKET -#define LWIP_SOCKET 0 +#define LWIP_SOCKET 0 #endif #if PICO_CYW43_ARCH_POLL -#define MEM_LIBC_MALLOC 1 +#define MEM_LIBC_MALLOC 1 #else // MEM_LIBC_MALLOC is incompatible with non polling versions -#define MEM_LIBC_MALLOC 0 +#define MEM_LIBC_MALLOC 0 #endif -#define MEM_ALIGNMENT 4 +#define MEM_ALIGNMENT 4 #ifndef MEM_SIZE -#define MEM_SIZE 4000 +#define MEM_SIZE 4000 #endif -#define MEMP_NUM_TCP_SEG 32 -#define MEMP_NUM_ARP_QUEUE 10 -#define PBUF_POOL_SIZE 24 -#define LWIP_ARP 1 -#define LWIP_ETHERNET 1 -#define LWIP_ICMP 1 -#define LWIP_RAW 1 -#define TCP_WND (8 * TCP_MSS) -#define TCP_MSS 1460 -#define TCP_SND_BUF (8 * TCP_MSS) -#define TCP_SND_QUEUELEN ((4 * (TCP_SND_BUF) + (TCP_MSS - 1)) / (TCP_MSS)) -#define LWIP_NETIF_STATUS_CALLBACK 1 -#define LWIP_NETIF_LINK_CALLBACK 1 -#define LWIP_NETIF_HOSTNAME 1 -#define LWIP_NETCONN 0 -#define MEM_STATS 0 -#define SYS_STATS 0 -#define MEMP_STATS 0 -#define LINK_STATS 0 +#define MEMP_NUM_TCP_SEG 32 +#define MEMP_NUM_ARP_QUEUE 10 +#define PBUF_POOL_SIZE 24 +#define LWIP_ARP 1 +#define LWIP_ETHERNET 1 +#define LWIP_ICMP 1 +#define LWIP_RAW 1 +#define TCP_WND (8 * TCP_MSS) +#define TCP_MSS 1460 +#define TCP_SND_BUF (8 * TCP_MSS) +#define TCP_SND_QUEUELEN ((4 * (TCP_SND_BUF) + (TCP_MSS - 1)) / (TCP_MSS)) +#define LWIP_NETIF_STATUS_CALLBACK 1 +#define LWIP_NETIF_LINK_CALLBACK 1 +#define LWIP_NETIF_HOSTNAME 1 +#define LWIP_NETCONN 0 +#define MEM_STATS 0 +#define SYS_STATS 0 +#define MEMP_STATS 0 +#define LINK_STATS 0 // #define ETH_PAD_SIZE 2 -#define LWIP_CHKSUM_ALGORITHM 3 -#define LWIP_DHCP 1 -#define LWIP_IPV4 1 -#define LWIP_TCP 1 -#define LWIP_UDP 1 -#define LWIP_DNS 1 -#define LWIP_TCP_KEEPALIVE 1 -#define LWIP_NETIF_TX_SINGLE_PBUF 1 -#define DHCP_DOES_ARP_CHECK 0 -#define LWIP_DHCP_DOES_ACD_CHECK 0 +#define LWIP_CHKSUM_ALGORITHM 3 +#define LWIP_DHCP 1 +#define LWIP_IPV4 1 +#define LWIP_TCP 1 +#define LWIP_UDP 1 +#define LWIP_DNS 1 +#define LWIP_TCP_KEEPALIVE 1 +#define LWIP_NETIF_TX_SINGLE_PBUF 1 +#define DHCP_DOES_ARP_CHECK 0 +#define LWIP_DHCP_DOES_ACD_CHECK 0 #ifndef NDEBUG -#define LWIP_DEBUG 1 -#define LWIP_STATS 1 -#define LWIP_STATS_DISPLAY 1 +#define LWIP_DEBUG 1 +#define LWIP_STATS 1 +#define LWIP_STATS_DISPLAY 1 #endif -#define ETHARP_DEBUG LWIP_DBG_OFF -#define NETIF_DEBUG LWIP_DBG_OFF -#define PBUF_DEBUG LWIP_DBG_OFF -#define API_LIB_DEBUG LWIP_DBG_OFF -#define API_MSG_DEBUG LWIP_DBG_OFF -#define SOCKETS_DEBUG LWIP_DBG_OFF -#define ICMP_DEBUG LWIP_DBG_OFF -#define INET_DEBUG LWIP_DBG_OFF -#define IP_DEBUG LWIP_DBG_OFF -#define IP_REASS_DEBUG LWIP_DBG_OFF -#define RAW_DEBUG LWIP_DBG_OFF -#define MEM_DEBUG LWIP_DBG_OFF -#define MEMP_DEBUG LWIP_DBG_OFF -#define SYS_DEBUG LWIP_DBG_OFF -#define TCP_DEBUG LWIP_DBG_OFF -#define TCP_INPUT_DEBUG LWIP_DBG_OFF -#define TCP_OUTPUT_DEBUG LWIP_DBG_OFF -#define TCP_RTO_DEBUG LWIP_DBG_OFF -#define TCP_CWND_DEBUG LWIP_DBG_OFF -#define TCP_WND_DEBUG LWIP_DBG_OFF -#define TCP_FR_DEBUG LWIP_DBG_OFF -#define TCP_QLEN_DEBUG LWIP_DBG_OFF -#define TCP_RST_DEBUG LWIP_DBG_OFF -#define UDP_DEBUG LWIP_DBG_OFF -#define TCPIP_DEBUG LWIP_DBG_OFF -#define PPP_DEBUG LWIP_DBG_OFF -#define SLIP_DEBUG LWIP_DBG_OFF -#define DHCP_DEBUG LWIP_DBG_OFF +#define ETHARP_DEBUG LWIP_DBG_OFF +#define NETIF_DEBUG LWIP_DBG_OFF +#define PBUF_DEBUG LWIP_DBG_OFF +#define API_LIB_DEBUG LWIP_DBG_OFF +#define API_MSG_DEBUG LWIP_DBG_OFF +#define SOCKETS_DEBUG LWIP_DBG_OFF +#define ICMP_DEBUG LWIP_DBG_OFF +#define INET_DEBUG LWIP_DBG_OFF +#define IP_DEBUG LWIP_DBG_OFF +#define IP_REASS_DEBUG LWIP_DBG_OFF +#define RAW_DEBUG LWIP_DBG_OFF +#define MEM_DEBUG LWIP_DBG_OFF +#define MEMP_DEBUG LWIP_DBG_OFF +#define SYS_DEBUG LWIP_DBG_OFF +#define TCP_DEBUG LWIP_DBG_OFF +#define TCP_INPUT_DEBUG LWIP_DBG_OFF +#define TCP_OUTPUT_DEBUG LWIP_DBG_OFF +#define TCP_RTO_DEBUG LWIP_DBG_OFF +#define TCP_CWND_DEBUG LWIP_DBG_OFF +#define TCP_WND_DEBUG LWIP_DBG_OFF +#define TCP_FR_DEBUG LWIP_DBG_OFF +#define TCP_QLEN_DEBUG LWIP_DBG_OFF +#define TCP_RST_DEBUG LWIP_DBG_OFF +#define UDP_DEBUG LWIP_DBG_OFF +#define TCPIP_DEBUG LWIP_DBG_OFF +#define PPP_DEBUG LWIP_DBG_OFF +#define SLIP_DEBUG LWIP_DBG_OFF +#define DHCP_DEBUG LWIP_DBG_OFF #endif /* __LWIPOPTS_H__ */ diff --git a/main.c b/main.c index 778d599..78e2f5c 100644 --- a/main.c +++ b/main.c @@ -19,10 +19,10 @@ static void main1(void) { // allow pausing core 1 while writing to flash multicore_lockout_victim_init(); - multicore_fifo_push_blocking(1); // signal core 0: ready + multicore_fifo_push_blocking(1); // signal core 0: ready keypad_init(); - + while (true) { char key = keypad_get_key(); if (key) { @@ -54,8 +54,7 @@ static void boot_network(void) { // Block until first NTP sync succeeds - beep + retry on failure printf("[main] waiting for NTP sync...\r\n"); while (!ntp_sync()) { - printf("[main] NTP sync failed, retrying in %ds...\r\n", - NTP_RETRY_INTERVAL_S); + printf("[main] NTP sync failed, retrying in %ds...\r\n", NTP_RETRY_INTERVAL_S); buzzer_beep_short(); sleep_ms(NTP_RETRY_INTERVAL_S * 1000); } @@ -65,27 +64,27 @@ static void boot_network(void) { int main(void) { stdio_init_all(); - + buzzer_init(); latch_init(); light_init(); buzzer_beep_short(); - + // Core 1 must be running and ready before any flash writes multicore_launch_core1(main1); - multicore_fifo_pop_blocking(); // wait for core 1 ready signal + multicore_fifo_pop_blocking(); // wait for core 1 ready signal storage_init(); boot_network(); - + // Startup beep - signals boot completed buzzer_beep_short(); buzzer_beep_short(); - + console_init(); - + while (true) { console_task(); ntp_task(); diff --git a/network/ntp.c b/network/ntp.c index d48ebfc..fa28d3a 100644 --- a/network/ntp.c +++ b/network/ntp.c @@ -16,9 +16,9 @@ // Constants // --------------------------------------------------------------------------- -#define NTP_PORT 123 +#define NTP_PORT 123 #define NTP_SERVER "pool.ntp.org" -#define NTP_DELTA 2208988800UL // seconds between 1900 and 1970 epochs +#define NTP_DELTA 2208988800UL // seconds between 1900 and 1970 epochs // --------------------------------------------------------------------------- // State @@ -32,9 +32,9 @@ typedef enum { NTP_STATE_FAILED, } ntp_state_t; -static ntp_state_t ntp_state = NTP_STATE_IDLE; -static struct udp_pcb *ntp_pcb = NULL; -static ip_addr_t server_addr; +static ntp_state_t ntp_state = NTP_STATE_IDLE; +static struct udp_pcb *ntp_pcb = NULL; +static ip_addr_t server_addr; static bool synced = false; static uint32_t last_sync_unix = 0; @@ -45,15 +45,15 @@ static uint64_t last_sync_monotonic_us = 0; // --------------------------------------------------------------------------- static bool rollback_check(uint32_t new_time) { - if (!synced) return true; // no floor before first sync + if (!synced) + return true; // no floor before first sync uint64_t elapsed_us = time_us_64() - last_sync_monotonic_us; uint32_t elapsed_s = (uint32_t)(elapsed_us / 1000000ULL); uint32_t floor = last_sync_unix + elapsed_s - NTP_ROLLBACK_EPSILON_S; if (new_time < floor) { - printf("[ntp] rollback rejected: got %u, floor is %u\r\n", - new_time, floor); + printf("[ntp] rollback rejected: got %u, floor is %u\r\n", new_time, floor); return false; } @@ -76,8 +76,8 @@ static void apply_time(uint32_t unix_time) { // NTP response callback // --------------------------------------------------------------------------- -static void ntp_recv_cb(void *arg, struct udp_pcb *pcb, struct pbuf *p, - const ip_addr_t *addr, u16_t port) { +static void ntp_recv_cb(void *arg, struct udp_pcb *pcb, struct pbuf *p, const ip_addr_t *addr, + u16_t port) { if (p->len < 48) { printf("[ntp] response too short\r\n"); pbuf_free(p); @@ -89,11 +89,8 @@ static void ntp_recv_cb(void *arg, struct udp_pcb *pcb, struct pbuf *p, pbuf_copy_partial(p, buf, 48, 0); pbuf_free(p); - uint32_t seconds_since_1900 = - ((uint32_t)buf[40] << 24) | - ((uint32_t)buf[41] << 16) | - ((uint32_t)buf[42] << 8) | - (uint32_t)buf[43]; + uint32_t seconds_since_1900 = ((uint32_t)buf[40] << 24) | ((uint32_t)buf[41] << 16) | + ((uint32_t)buf[42] << 8) | (uint32_t)buf[43]; uint32_t unix_time = seconds_since_1900 - NTP_DELTA; @@ -119,10 +116,10 @@ static void dns_found_cb(const char *name, const ip_addr_t *ipaddr, void *arg) { server_addr = *ipaddr; - struct pbuf *p = pbuf_alloc(PBUF_TRANSPORT, 48, PBUF_RAM); - uint8_t *req = (uint8_t *)p->payload; + struct pbuf *p = pbuf_alloc(PBUF_TRANSPORT, 48, PBUF_RAM); + uint8_t *req = (uint8_t *)p->payload; memset(req, 0, 48); - req[0] = 0x1B; // LI=0, VN=3, Mode=3 (client) + req[0] = 0x1B; // LI=0, VN=3, Mode=3 (client) udp_sendto(ntp_pcb, p, &server_addr, NTP_PORT); pbuf_free(p); @@ -180,7 +177,7 @@ bool ntp_sync(void) { } } - bool ok = ntp_state == NTP_STATE_SUCCESS; + bool ok = ntp_state == NTP_STATE_SUCCESS; ntp_state = NTP_STATE_IDLE; udp_remove(ntp_pcb); @@ -190,12 +187,14 @@ bool ntp_sync(void) { } void ntp_task(void) { - if (!synced) return; + if (!synced) + return; uint64_t elapsed_us = time_us_64() - last_sync_monotonic_us; uint32_t elapsed_s = (uint32_t)(elapsed_us / 1000000ULL); - if (elapsed_s < NTP_RESYNC_INTERVAL_S) return; + if (elapsed_s < NTP_RESYNC_INTERVAL_S) + return; printf("[ntp] periodic resync...\r\n"); if (!ntp_sync()) { diff --git a/network/ntp.h b/network/ntp.h index 012e2c4..bdd455d 100644 --- a/network/ntp.h +++ b/network/ntp.h @@ -4,10 +4,10 @@ #include #include -#define NTP_RESYNC_INTERVAL_S (30 * 60) // 30 minutes -#define NTP_RETRY_INTERVAL_S 5 // retry on boot failure -#define NTP_TIMEOUT_S 15 // per-sync timeout -#define NTP_ROLLBACK_EPSILON_S 60 // max allowed backward correction +#define NTP_RESYNC_INTERVAL_S (30 * 60) // 30 minutes +#define NTP_RETRY_INTERVAL_S 5 // retry on boot failure +#define NTP_TIMEOUT_S 15 // per-sync timeout +#define NTP_ROLLBACK_EPSILON_S 60 // max allowed backward correction // Call once after WiFi connected - initialises RTC void ntp_init(void); diff --git a/network/wifi.c b/network/wifi.c index 1998726..7114ccc 100644 --- a/network/wifi.c +++ b/network/wifi.c @@ -15,8 +15,7 @@ bool wifi_connect(const char *ssid, const char *password) { } printf("[wifi] connecting to '%s'...\r\n", ssid); - int rc = cyw43_arch_wifi_connect_timeout_ms( - ssid, password, CYW43_AUTH_WPA2_AES_PSK, 15000); + int rc = cyw43_arch_wifi_connect_timeout_ms(ssid, password, CYW43_AUTH_WPA2_AES_PSK, 15000); if (rc) { printf("[wifi] connect failed: %d\r\n", rc); @@ -28,6 +27,7 @@ bool wifi_connect(const char *ssid, const char *password) { } bool wifi_is_connected(void) { - if (!initialised) return false; + if (!initialised) + return false; return cyw43_tcpip_link_status(&cyw43_state, CYW43_ITF_STA) == CYW43_LINK_UP; } diff --git a/serial/commands.c b/serial/commands.c index 6d88c44..e0d66bb 100644 --- a/serial/commands.c +++ b/serial/commands.c @@ -34,7 +34,7 @@ typedef struct { int max_args; const char *usage; const char *description; - void (*handler)(int argc, char **argv); + void (*handler)(int argc, char **argv); } command_t; // --------------------------------------------------------------------------- @@ -42,28 +42,29 @@ typedef struct { // --------------------------------------------------------------------------- static const command_t COMMANDS[] = { - {"help", false, 0, 0, "help", "Print available commands"}, - {"?", false, 0, 0, "?", "Alias for help"}, - {"status", false, 0, 0, "status", "Show system status"}, - {"test", false, 0, 0, "test", "Test LED, buzzer and latch"}, - {"login", false, 1, 1, "login ", "Enable admin mode via TOTP"}, - {"logout", true, 0, 0, "logout", "End admin session"}, - {"reboot", true, 0, 0, "reboot", "Reboot device"}, - {"get-time", false, 0, 0, "get-time", "Show current RTC time and last NTP sync"}, - {"sync-ntp", true, 0, 0, "sync-ntp", "Force immediate NTP resync"}, - {"set-wifi", true, 2, 2, "set-wifi ", "Save WiFi credentials"}, - {"list-keys", true, 0, 0, "list-keys", "List all keys (no secrets)"}, - {"get-key", true, 1, 1, "get-key ", "Show key details (no secret)"}, - {"get-key-secret", true, 1, 1, "get-key-secret ", "Show secret + QR code for key"}, - {"add-key", true, 2, 2, "add-key ", "Generate and save new key"}, - {"rename-key", true, 2, 2, "rename-key ", "Rename a key"}, - {"enable-key", true, 1, 1, "enable-key ", "Enable a disabled key"}, - {"disable-key", true, 1, 1, "disable-key ", "Disable a key without deleting"}, - {"delete-key", true, 1, 1, "delete-key ", "Permanently delete a key"}, - {"set-key-admin", true, 1, 1, "set-key-admin ", "Grant admin flag to key"}, - {"unset-key-admin", true, 1, 1, "unset-key-admin ", "Remove admin flag from key"}, - {"export-keys", true, 0, 0, "export-keys", "Dump all keys as base64"}, - {"import-keys", true, 1, 1, "import-keys ", "Export backup then overwrite with provided data"}, + {"help", false, 0, 0, "help", "Print available commands"}, + {"?", false, 0, 0, "?", "Alias for help"}, + {"status", false, 0, 0, "status", "Show system status"}, + {"test", false, 0, 0, "test", "Test LED, buzzer and latch"}, + {"login", false, 1, 1, "login ", "Enable admin mode via TOTP"}, + {"logout", true, 0, 0, "logout", "End admin session"}, + {"reboot", true, 0, 0, "reboot", "Reboot device"}, + {"get-time", false, 0, 0, "get-time", "Show current RTC time and last NTP sync"}, + {"sync-ntp", true, 0, 0, "sync-ntp", "Force immediate NTP resync"}, + {"set-wifi", true, 2, 2, "set-wifi ", "Save WiFi credentials"}, + {"list-keys", true, 0, 0, "list-keys", "List all keys (no secrets)"}, + {"get-key", true, 1, 1, "get-key ", "Show key details (no secret)"}, + {"get-key-secret", true, 1, 1, "get-key-secret ", "Show secret + QR code for key"}, + {"add-key", true, 2, 2, "add-key ", "Generate and save new key"}, + {"rename-key", true, 2, 2, "rename-key ", "Rename a key"}, + {"enable-key", true, 1, 1, "enable-key ", "Enable a disabled key"}, + {"disable-key", true, 1, 1, "disable-key ", "Disable a key without deleting"}, + {"delete-key", true, 1, 1, "delete-key ", "Permanently delete a key"}, + {"set-key-admin", true, 1, 1, "set-key-admin ", "Grant admin flag to key"}, + {"unset-key-admin", true, 1, 1, "unset-key-admin ", "Remove admin flag from key"}, + {"export-keys", true, 0, 0, "export-keys", "Dump all keys as base64"}, + {"import-keys", true, 1, 1, "import-keys ", + "Export backup then overwrite with provided data"}, }; #define NUM_COMMANDS (sizeof(COMMANDS) / sizeof(COMMANDS[0])) @@ -75,13 +76,14 @@ static const command_t COMMANDS[] = { static void cmd_help(int argc, char **argv) { printf("\r\navailable commands:\r\n\r\n"); printf(" %-32s %s\r\n", "usage", "description"); - printf(" %-32s %s\r\n", - "--------------------------------", + printf(" %-32s %s\r\n", "--------------------------------", "-----------------------------------"); for (size_t i = 0; i < NUM_COMMANDS; i++) { const command_t *cmd = &COMMANDS[i]; - if (cmd->requires_admin && !admin_mode) continue; - if (strcmp(cmd->name, "?") == 0) continue; + if (cmd->requires_admin && !admin_mode) + continue; + if (strcmp(cmd->name, "?") == 0) + continue; printf(" %-32s %s\r\n", cmd->usage, cmd->description); } printf("\r\n"); @@ -89,29 +91,12 @@ static void cmd_help(int argc, char **argv) { } // Handler lookup - must match COMMANDS table order -static void (*const HANDLERS[])(int, char**) = { - cmd_help, - cmd_help, - cmd_status, - cmd_test, - cmd_login, - cmd_logout, - cmd_reboot, - cmd_get_time, - cmd_sync_ntp, - cmd_set_wifi, - cmd_list_keys, - cmd_get_key, - cmd_get_key_secret, - cmd_add_key, - cmd_rename_key, - cmd_enable_key, - cmd_disable_key, - cmd_delete_key, - cmd_set_key_admin, - cmd_unset_key_admin, - cmd_export_keys, - cmd_import_keys, +static void (*const HANDLERS[])(int, char **) = { + cmd_help, cmd_help, cmd_status, cmd_test, cmd_login, + cmd_logout, cmd_reboot, cmd_get_time, cmd_sync_ntp, cmd_set_wifi, + cmd_list_keys, cmd_get_key, cmd_get_key_secret, cmd_add_key, cmd_rename_key, + cmd_enable_key, cmd_disable_key, cmd_delete_key, cmd_set_key_admin, cmd_unset_key_admin, + cmd_export_keys, cmd_import_keys, }; // --------------------------------------------------------------------------- @@ -120,13 +105,13 @@ static void (*const HANDLERS[])(int, char**) = { void commands_dispatch(int argc, char **argv) { for (size_t i = 0; i < NUM_COMMANDS; i++) { - if (strcmp(argv[0], COMMANDS[i].name) != 0) continue; + if (strcmp(argv[0], COMMANDS[i].name) != 0) + continue; const command_t *cmd = &COMMANDS[i]; if (cmd->requires_admin && !admin_mode) { - printf("error: '%s' requires admin mode - use login \r\n", - cmd->name); + printf("error: '%s' requires admin mode - use login \r\n", cmd->name); buzzer_play_command_ack(); return; } @@ -142,7 +127,6 @@ void commands_dispatch(int argc, char **argv) { return; } - printf("error: unknown command '%s' - type 'help' for available commands\r\n", - argv[0]); + printf("error: unknown command '%s' - type 'help' for available commands\r\n", argv[0]); buzzer_play_command_ack(); } diff --git a/serial/commands_keys.c b/serial/commands_keys.c index c216200..f158349 100644 --- a/serial/commands_keys.c +++ b/serial/commands_keys.c @@ -13,7 +13,7 @@ void cmd_list_keys(int argc, char **argv) { key_record_t keys[BACKUP_MAX_KEYS]; - int count = storage_key_list(keys, BACKUP_MAX_KEYS); + int count = storage_key_list(keys, BACKUP_MAX_KEYS); if (count < 0) { printf("error: failed to read keys\r\n"); @@ -26,8 +26,8 @@ void cmd_list_keys(int argc, char **argv) { return; } - printf("%-6s %-2s %-24s %-7s %-5s %s\r\n", - "id", "ok", "name", "enabled", "admin", "created"); + printf("%-6s %-2s %-24s %-7s %-5s %s\r\n", "id", "ok", "name", "enabled", "admin", + "created"); printf("------ -- ------------------------ ------- ----- -------------------\r\n"); for (int i = 0; i < count; i++) { @@ -35,18 +35,13 @@ void cmd_list_keys(int argc, char **argv) { char created[20] = "unknown"; if (k->created_at > 0) { - time_t t = (time_t)k->created_at; + time_t t = (time_t)k->created_at; struct tm *tm = gmtime(&t); strftime(created, sizeof(created), "%Y-%m-%d %H:%M:%S", tm); } - printf("%-6u %-2s %-24s %-7s %-5s %s\r\n", - k->id, - k->is_checksum_valid ? "v" : "x", - k->name, - k->is_enabled ? "yes" : "no", - k->is_admin ? "yes" : "no", - created); + printf("%-6u %-2s %-24s %-7s %-5s %s\r\n", k->id, k->is_checksum_valid ? "v" : "x", + k->name, k->is_enabled ? "yes" : "no", k->is_admin ? "yes" : "no", created); } printf("\r\n%d key(s)\r\n", count); @@ -71,17 +66,17 @@ void cmd_get_key(int argc, char **argv) { char created[20] = "unknown"; if (key.created_at > 0) { - time_t t = (time_t)key.created_at; + time_t t = (time_t)key.created_at; struct tm *tm = gmtime(&t); strftime(created, sizeof(created), "%Y-%m-%d %H:%M:%S", tm); } - printf("id: %u\r\n", key.id); - printf("ok: %s\r\n", key.is_checksum_valid ? "v" : "x"); - printf("name: %s\r\n", key.name); - printf("enabled: %s\r\n", key.is_enabled ? "yes" : "no"); - printf("admin: %s\r\n", key.is_admin ? "yes" : "no"); - printf("created: %s\r\n", created); + printf("id: %u\r\n", key.id); + printf("ok: %s\r\n", key.is_checksum_valid ? "v" : "x"); + printf("name: %s\r\n", key.name); + printf("enabled: %s\r\n", key.is_enabled ? "yes" : "no"); + printf("admin: %s\r\n", key.is_admin ? "yes" : "no"); + printf("created: %s\r\n", created); printf("secret: "); for (int i = 0; i < KEY_SECRET_LEN; i++) { printf("%02X", key.secret[i]); @@ -92,9 +87,9 @@ void cmd_get_key(int argc, char **argv) { } // Łódź URL-encoded: Ł=%C5%81, ó=%C3%B3, ź=%C5%BA, space=%20 -#define ISSUER_ENC "Hackerspejs%20%C5%81%C3%B3d%C5%BA" -#define QR_BORDER 2 // quiet zone — required for scanners to work -#define SECRET_B32_LEN BASE32_ENCODED_LEN(KEY_SECRET_LEN) // 33 bytes for 20-byte secret +#define ISSUER_ENC "Hackerspejs%20%C5%81%C3%B3d%C5%BA" +#define QR_BORDER 2 // quiet zone — required for scanners to work +#define SECRET_B32_LEN BASE32_ENCODED_LEN(KEY_SECRET_LEN) // 33 bytes for 20-byte secret void cmd_get_key_secret(int argc, char **argv) { uint16_t id = (uint16_t)strtoul(argv[1], NULL, 10); @@ -124,9 +119,8 @@ void cmd_get_key_secret(int argc, char **argv) { // Build otpauth URI char uri[200]; - snprintf(uri, sizeof(uri), - "otpauth://totp/%s:key%%20%u?secret=%s&issuer=%s&digits=6&period=30", - ISSUER_ENC, id, secret_b32, ISSUER_ENC); + snprintf(uri, sizeof(uri), "otpauth://totp/%s:key%%20%u?secret=%s&issuer=%s&digits=6&period=30", + ISSUER_ENC, id, secret_b32, ISSUER_ENC); printf("secret: %s\r\n", secret_b32); printf("uri: %s\r\n\r\n", uri); @@ -135,12 +129,8 @@ void cmd_get_key_secret(int argc, char **argv) { 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); + bool ok = qrcodegen_encodeText(uri, tmp, qr, qrcodegen_Ecc_MEDIUM, qrcodegen_VERSION_MIN, + qrcodegen_VERSION_MAX, qrcodegen_Mask_AUTO, true); if (!ok) { printf("error: QR generation failed\r\n"); buzzer_play_command_ack(); @@ -151,8 +141,7 @@ void cmd_get_key_secret(int argc, char **argv) { int size = qrcodegen_getSize(qr); for (int y = -QR_BORDER; y < size + QR_BORDER; y++) { for (int x = -QR_BORDER; x < size + QR_BORDER; x++) { - bool dark = (x >= 0 && y >= 0 && x < size && y < size) - && qrcodegen_getModule(qr, x, y); + bool dark = (x >= 0 && y >= 0 && x < size && y < size) && qrcodegen_getModule(qr, x, y); printf(dark ? "##" : " "); } printf("\r\n"); @@ -183,12 +172,12 @@ void cmd_add_key(int argc, char **argv) { return; } - key_record_t key = {0}; - key.id = id; - key.is_enabled = true; - key.is_admin = false; - key.is_checksum_valid = true; - key.created_at = clock_get_unix_time(); + key_record_t key = {0}; + key.id = id; + key.is_enabled = true; + key.is_admin = false; + key.is_checksum_valid = true; + key.created_at = clock_get_unix_time(); strncpy(key.name, argv[2], KEY_NAME_MAX - 1); generate_secret(key.secret); diff --git a/serial/commands_network.c b/serial/commands_network.c index 76f96d6..15f2afc 100644 --- a/serial/commands_network.c +++ b/serial/commands_network.c @@ -31,7 +31,7 @@ void cmd_set_wifi(int argc, char **argv) { return; } - strncpy(cfg.ssid, argv[1], WIFI_SSID_MAX - 1); + strncpy(cfg.ssid, argv[1], WIFI_SSID_MAX - 1); strncpy(cfg.password, argv[2], WIFI_PASSWORD_MAX - 1); cfg.ssid[WIFI_SSID_MAX - 1] = '\0'; cfg.password[WIFI_PASSWORD_MAX - 1] = '\0'; diff --git a/serial/commands_system.c b/serial/commands_system.c index 36f7141..5a1dfc9 100644 --- a/serial/commands_system.c +++ b/serial/commands_system.c @@ -24,9 +24,9 @@ void cmd_status(int argc, char **argv) { uint32_t last = ntp_last_sync_time(); if (last > 0) { - time_t lt = (time_t)last; + time_t lt = (time_t)last; struct tm *ltm = gmtime(<); - char lbuf[20]; + char lbuf[20]; strftime(lbuf, sizeof(lbuf), "%Y-%m-%d %H:%M:%S", ltm); printf("last sync: %s UTC\r\n", lbuf); } else { @@ -45,17 +45,17 @@ void cmd_get_time(int argc, char **argv) { return; } - time_t t = (time_t)unix_time; + time_t t = (time_t)unix_time; struct tm *tm = gmtime(&t); - char buf[20]; + char buf[20]; strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", tm); printf("time: %s UTC\r\n", buf); uint32_t last = ntp_last_sync_time(); if (last > 0) { - time_t lt = (time_t)last; + time_t lt = (time_t)last; struct tm *ltm = gmtime(<); - char lbuf[20]; + char lbuf[20]; strftime(lbuf, sizeof(lbuf), "%Y-%m-%d %H:%M:%S", ltm); printf("last sync: %s UTC\r\n", lbuf); } else { @@ -93,5 +93,6 @@ void cmd_reboot(int argc, char **argv) { printf("rebooting...\r\n"); buzzer_play_command_ack(); watchdog_reboot(0, 0, 100); - while (true) tight_loop_contents(); + while (true) + tight_loop_contents(); } diff --git a/serial/console.c b/serial/console.c index 5db95c4..642e786 100644 --- a/serial/console.c +++ b/serial/console.c @@ -9,7 +9,7 @@ #define MAX_ARGS 8 static char input_buf[INPUT_BUF_SIZE]; -static int input_len = 0; +static int input_len = 0; static bool was_connected = false; static void print_prompt(void) { @@ -27,14 +27,19 @@ static void process_line(void) { char *p = input_buf; while (*p && argc < MAX_ARGS) { - while (*p == ' ') p++; // skip whitespace - if (!*p) break; + while (*p == ' ') + p++; // skip whitespace + if (!*p) + break; argv[argc++] = p; - while (*p && *p != ' ') p++; // find end of token - if (*p) *p++ = '\0'; + while (*p && *p != ' ') + p++; // find end of token + if (*p) + *p++ = '\0'; } - if (argc == 0) return; + if (argc == 0) + return; commands_dispatch(argc, argv); } @@ -65,10 +70,12 @@ void console_task(void) { return; } - if (!connected) return; + if (!connected) + return; int c = getchar_timeout_us(0); - if (c == PICO_ERROR_TIMEOUT) return; + if (c == PICO_ERROR_TIMEOUT) + return; if (c == '\r' || c == '\n') { printf("\r\n"); diff --git a/shared/random.c b/shared/random.c index 48ef49c..c6c19de 100644 --- a/shared/random.c +++ b/shared/random.c @@ -5,9 +5,9 @@ void generate_secret(uint8_t *out) { // 20 bytes = 2.5 x uint64_t - fill in 64-bit chunks uint64_t r0 = get_rand_64(); uint64_t r1 = get_rand_64(); - uint64_t r2 = get_rand_64(); // only 4 bytes used from this one + uint64_t r2 = get_rand_64(); // only 4 bytes used from this one - memcpy(out, &r0, 8); - memcpy(out + 8, &r1, 8); + memcpy(out, &r0, 8); + memcpy(out + 8, &r1, 8); memcpy(out + 16, &r2, 4); } \ No newline at end of file diff --git a/storage/backup.c b/storage/backup.c index 04dc1a3..2842eb7 100644 --- a/storage/backup.c +++ b/storage/backup.c @@ -11,7 +11,7 @@ static uint32_t backup_checksum(const backup_key_t *keys, uint32_t count) { uint32_t crc = 0xFFFFFFFF; - crc = lfs_crc(crc, keys, count * sizeof(backup_key_t)); + crc = lfs_crc(crc, keys, count * sizeof(backup_key_t)); return crc; } @@ -25,7 +25,7 @@ static backup_key_t to_backup_key(const key_record_t *k) { b.is_enabled = k->is_enabled; b.is_admin = k->is_admin; b.created_at = k->created_at; - memcpy(b.name, k->name, sizeof(b.name)); + memcpy(b.name, k->name, sizeof(b.name)); memcpy(b.secret, k->secret, sizeof(b.secret)); return b; } @@ -36,37 +36,36 @@ static backup_key_t to_backup_key(const key_record_t *k) { static key_record_t to_key_record(const backup_key_t *b) { key_record_t k; - k.id = b->id; - k.is_enabled = b->is_enabled; - k.is_admin = b->is_admin; - k.created_at = b->created_at; - k.is_checksum_valid = true; - memcpy(k.name, b->name, sizeof(k.name)); + k.id = b->id; + k.is_enabled = b->is_enabled; + k.is_admin = b->is_admin; + k.created_at = b->created_at; + k.is_checksum_valid = true; + memcpy(k.name, b->name, sizeof(k.name)); memcpy(k.secret, b->secret, sizeof(k.secret)); return k; } - // --------------------------------------------------------------------------- // Export // --------------------------------------------------------------------------- int backup_export(uint8_t *buf, size_t buf_size) { static key_record_t records[BACKUP_MAX_KEYS]; - int count = storage_key_list(records, BACKUP_MAX_KEYS); - if (count < 0) return -1; + int count = storage_key_list(records, BACKUP_MAX_KEYS); + if (count < 0) + return -1; - size_t needed = sizeof(backup_header_t) - + count * sizeof(backup_key_t); - if (buf_size < needed) return -1; + size_t needed = sizeof(backup_header_t) + count * sizeof(backup_key_t); + if (buf_size < needed) + return -1; // Populate backup keys - backup_key_t *keys = (backup_key_t *)(buf + sizeof(backup_header_t)); - int exported_key_count = 0; + backup_key_t *keys = (backup_key_t *)(buf + sizeof(backup_header_t)); + int exported_key_count = 0; for (int i = 0; i < count; i++) { if (!records[i].is_checksum_valid) { - printf("[backup] export: key %u has invalid checksum, skipping\r\n", - records[i].id); + printf("[backup] export: key %u has invalid checksum, skipping\r\n", records[i].id); continue; } keys[exported_key_count++] = to_backup_key(&records[i]); @@ -109,15 +108,13 @@ bool backup_import(const uint8_t *buf, size_t size) { return false; } - size_t expected = sizeof(backup_header_t) - + hdr->key_count * sizeof(backup_key_t); + size_t expected = sizeof(backup_header_t) + hdr->key_count * sizeof(backup_key_t); if (size < expected) { printf("[backup] import: truncated data\r\n"); return false; } - const backup_key_t *keys = - (const backup_key_t *)(buf + sizeof(backup_header_t)); + const backup_key_t *keys = (const backup_key_t *)(buf + sizeof(backup_header_t)); // Verify whole-backup checksum uint32_t expected_crc = backup_checksum(keys, hdr->key_count); @@ -128,7 +125,7 @@ bool backup_import(const uint8_t *buf, size_t size) { // Checksum valid - delete existing keys static key_record_t existing[BACKUP_MAX_KEYS]; - int existing_count = storage_key_list(existing, BACKUP_MAX_KEYS); + int existing_count = storage_key_list(existing, BACKUP_MAX_KEYS); for (int i = 0; i < existing_count; i++) { storage_key_delete(existing[i].id); } diff --git a/storage/backup.h b/storage/backup.h index a49718c..afae954 100644 --- a/storage/backup.h +++ b/storage/backup.h @@ -10,7 +10,7 @@ // Binary format (base64-encoded for serial transport) // --------------------------------------------------------------------------- -#define BACKUP_MAGIC 0x4C4C5348U // "HSLL" +#define BACKUP_MAGIC 0x4C4C5348U // "HSLL" #define BACKUP_VERSION 1 #define BACKUP_MAX_KEYS KEY_MAX_COUNT @@ -18,7 +18,7 @@ typedef struct __attribute__((packed)) { uint32_t magic; uint32_t version; uint32_t key_count; - uint32_t checksum; // covers all backup_key_t records + uint32_t checksum; // covers all backup_key_t records } backup_header_t; typedef struct __attribute__((packed)) { @@ -35,7 +35,7 @@ typedef struct __attribute__((packed)) { // --------------------------------------------------------------------------- // Serialise all keys into buf. Returns byte count written, -1 on error. -int backup_export(uint8_t *buf, size_t buf_size); +int backup_export(uint8_t *buf, size_t buf_size); // Overwrite all keys from buf. Returns false on error. bool backup_import(const uint8_t *buf, size_t size); diff --git a/storage/storage.c b/storage/storage.c index de14316..950125c 100644 --- a/storage/storage.c +++ b/storage/storage.c @@ -18,16 +18,16 @@ // The linker places firmware at the START of flash; storage is at the END, // so they never collide as long as firmware stays under ~1.75MB. -#define STORAGE_SIZE_BYTES (256 * 1024) +#define STORAGE_SIZE_BYTES (256 * 1024) #define STORAGE_FLASH_OFFSET (PICO_FLASH_SIZE_BYTES - STORAGE_SIZE_BYTES) // LittleFS block = one flash erase sector -#define LFS_BLOCK_SIZE FLASH_SECTOR_SIZE // 4096 -#define LFS_BLOCK_COUNT (STORAGE_SIZE_BYTES / LFS_BLOCK_SIZE) // 64 -#define LFS_READ_SIZE FLASH_PAGE_SIZE // 256 -#define LFS_PROG_SIZE FLASH_PAGE_SIZE // 256 -#define LFS_CACHE_SIZE FLASH_PAGE_SIZE // 256 -#define LFS_LOOKAHEAD 64 +#define LFS_BLOCK_SIZE FLASH_SECTOR_SIZE // 4096 +#define LFS_BLOCK_COUNT (STORAGE_SIZE_BYTES / LFS_BLOCK_SIZE) // 64 +#define LFS_READ_SIZE FLASH_PAGE_SIZE // 256 +#define LFS_PROG_SIZE FLASH_PAGE_SIZE // 256 +#define LFS_CACHE_SIZE FLASH_PAGE_SIZE // 256 +#define LFS_LOOKAHEAD 64 static uint8_t lfs_file_buf[LFS_CACHE_SIZE]; @@ -51,12 +51,12 @@ typedef struct { static uint32_t key_checksum(const key_record_stored_t *key) { uint32_t crc = 0xFFFFFFFF; - crc = lfs_crc(crc, &key->id, sizeof(key->id)); - crc = lfs_crc(crc, key->name, sizeof(key->name)); - crc = lfs_crc(crc, key->secret, sizeof(key->secret)); - crc = lfs_crc(crc, &key->is_enabled, sizeof(key->is_enabled)); - crc = lfs_crc(crc, &key->is_admin, sizeof(key->is_admin)); - crc = lfs_crc(crc, &key->created_at, sizeof(key->created_at)); + crc = lfs_crc(crc, &key->id, sizeof(key->id)); + crc = lfs_crc(crc, key->name, sizeof(key->name)); + crc = lfs_crc(crc, key->secret, sizeof(key->secret)); + crc = lfs_crc(crc, &key->is_enabled, sizeof(key->is_enabled)); + crc = lfs_crc(crc, &key->is_admin, sizeof(key->is_admin)); + crc = lfs_crc(crc, &key->created_at, sizeof(key->created_at)); return crc; } @@ -66,20 +66,20 @@ static key_record_stored_t to_stored(const key_record_t *k) { s.is_enabled = k->is_enabled; s.is_admin = k->is_admin; s.created_at = k->created_at; - memcpy(s.name, k->name, sizeof(s.name)); + memcpy(s.name, k->name, sizeof(s.name)); memcpy(s.secret, k->secret, sizeof(s.secret)); - s.checksum = key_checksum(&s); + s.checksum = key_checksum(&s); return s; } static key_record_t to_record(const key_record_stored_t *s) { key_record_t k; - k.id = s->id; - k.is_enabled = s->is_enabled; - k.is_admin = s->is_admin; - k.created_at = s->created_at; - k.is_checksum_valid = (s->checksum == key_checksum(s)); - memcpy(k.name, s->name, sizeof(k.name)); + k.id = s->id; + k.is_enabled = s->is_enabled; + k.is_admin = s->is_admin; + k.created_at = s->created_at; + k.is_checksum_valid = (s->checksum == key_checksum(s)); + memcpy(k.name, s->name, sizeof(k.name)); memcpy(k.secret, s->secret, sizeof(k.secret)); return k; } @@ -88,17 +88,23 @@ static key_record_t to_record(const key_record_stored_t *s) { // Flash block device callbacks // --------------------------------------------------------------------------- -static int flash_read(const struct lfs_config *c, lfs_block_t block, - lfs_off_t off, void *buffer, lfs_size_t size) { - uint32_t addr = XIP_BASE + STORAGE_FLASH_OFFSET - + block * LFS_BLOCK_SIZE + off; +static int flash_read(const struct lfs_config *c, lfs_block_t block, lfs_off_t off, void *buffer, + lfs_size_t size) { + uint32_t addr = XIP_BASE + STORAGE_FLASH_OFFSET + block * LFS_BLOCK_SIZE + off; memcpy(buffer, (const void *)addr, size); return LFS_ERR_OK; } // Params struct for flash_safe_execute callbacks (can't pass multiple args) -typedef struct { uint32_t offset; const uint8_t *data; size_t size; } prog_params_t; -typedef struct { uint32_t offset; size_t size; } erase_params_t; +typedef struct { + uint32_t offset; + const uint8_t *data; + size_t size; +} prog_params_t; +typedef struct { + uint32_t offset; + size_t size; +} erase_params_t; // those functions must live in ram so they can be executed while flash is locked static void __no_inline_not_in_flash_func(do_flash_program)(void *param) { @@ -111,37 +117,33 @@ static void __no_inline_not_in_flash_func(do_flash_erase)(void *param) { flash_range_erase(p->offset, p->size); } -static int flash_prog(const struct lfs_config *c, lfs_block_t block, - lfs_off_t off, const void *buffer, lfs_size_t size) { - prog_params_t p = { - .offset = STORAGE_FLASH_OFFSET + block * LFS_BLOCK_SIZE + off, - .data = (const uint8_t *)buffer, - .size = size - }; +static int flash_prog(const struct lfs_config *c, lfs_block_t block, lfs_off_t off, + const void *buffer, lfs_size_t size) { + prog_params_t p = {.offset = STORAGE_FLASH_OFFSET + block * LFS_BLOCK_SIZE + off, + .data = (const uint8_t *)buffer, + .size = size}; // flash_safe_execute pauses core 1 and disables interrupts while writing int rc = flash_safe_execute(do_flash_program, &p, UINT32_MAX); return rc == PICO_OK ? LFS_ERR_OK : LFS_ERR_IO; } static int flash_erase(const struct lfs_config *c, lfs_block_t block) { - erase_params_t p = { - .offset = STORAGE_FLASH_OFFSET + block * LFS_BLOCK_SIZE, - .size = LFS_BLOCK_SIZE - }; - int rc = flash_safe_execute(do_flash_erase, &p, UINT32_MAX); + erase_params_t p = {.offset = STORAGE_FLASH_OFFSET + block * LFS_BLOCK_SIZE, + .size = LFS_BLOCK_SIZE}; + int rc = flash_safe_execute(do_flash_erase, &p, UINT32_MAX); return rc == PICO_OK ? LFS_ERR_OK : LFS_ERR_IO; } static int flash_sync(const struct lfs_config *c) { - return LFS_ERR_OK; // no write buffer on NOR flash + return LFS_ERR_OK; // no write buffer on NOR flash } // --------------------------------------------------------------------------- // LittleFS state // --------------------------------------------------------------------------- -static uint8_t lfs_read_buf [LFS_CACHE_SIZE]; -static uint8_t lfs_prog_buf [LFS_CACHE_SIZE]; +static uint8_t lfs_read_buf[LFS_CACHE_SIZE]; +static uint8_t lfs_prog_buf[LFS_CACHE_SIZE]; static uint8_t lfs_lookahead_buf[LFS_LOOKAHEAD / 8]; static const struct lfs_config LFS_CFG = { @@ -156,7 +158,7 @@ static const struct lfs_config LFS_CFG = { .block_count = LFS_BLOCK_COUNT, .cache_size = LFS_CACHE_SIZE, .lookahead_size = LFS_LOOKAHEAD, - .block_cycles = 500, // wear leveling hint + .block_cycles = 500, // wear leveling hint .read_buffer = lfs_read_buf, .prog_buffer = lfs_prog_buf, @@ -195,10 +197,12 @@ bool storage_init(void) { if (rc < 0) { printf("[storage] mount failed, formatting...\r\n"); rc = lfs_format(&lfs, &LFS_CFG); - if (rc < 0) return false; + if (rc < 0) + return false; rc = lfs_mount(&lfs, &LFS_CFG); - if (rc < 0) return false; + if (rc < 0) + return false; } if (!ensure_dirs()) { @@ -216,7 +220,8 @@ bool storage_init(void) { // --------------------------------------------------------------------------- bool storage_wifi_get(wifi_config_t *out) { - if (!mounted) return false; + if (!mounted) + return false; lfs_file_t f; if (lfs_file_opencfg(&lfs, &f, FILE_WIFI, LFS_O_RDONLY, &LFS_FILE_CFG) < 0) @@ -228,12 +233,14 @@ bool storage_wifi_get(wifi_config_t *out) { } bool storage_wifi_set(const wifi_config_t *cfg) { - if (!mounted) return false; + if (!mounted) + return false; lfs_file_t f; - int flags = LFS_O_WRONLY | LFS_O_CREAT | LFS_O_TRUNC; - int rc = lfs_file_opencfg(&lfs, &f, FILE_WIFI, flags, &LFS_FILE_CFG); - if (rc < 0) return false; + int flags = LFS_O_WRONLY | LFS_O_CREAT | LFS_O_TRUNC; + int rc = lfs_file_opencfg(&lfs, &f, FILE_WIFI, flags, &LFS_FILE_CFG); + if (rc < 0) + return false; lfs_ssize_t n = lfs_file_write(&lfs, &f, cfg, sizeof(wifi_config_t)); lfs_file_close(&lfs, &f); @@ -241,7 +248,8 @@ bool storage_wifi_set(const wifi_config_t *cfg) { } bool storage_wifi_clear(void) { - if (!mounted) return false; + if (!mounted) + return false; return lfs_remove(&lfs, FILE_WIFI) >= 0; } @@ -250,7 +258,8 @@ bool storage_wifi_clear(void) { // --------------------------------------------------------------------------- bool storage_key_exists(uint16_t id) { - if (!mounted) return false; + if (!mounted) + return false; char path[40]; key_path(id, path, sizeof(path)); struct lfs_info info; @@ -258,7 +267,8 @@ bool storage_key_exists(uint16_t id) { } bool storage_key_get(uint16_t id, key_record_t *out) { - if (!mounted) return false; + if (!mounted) + return false; char path[40]; key_path(id, path, sizeof(path)); @@ -267,9 +277,10 @@ bool storage_key_get(uint16_t id, key_record_t *out) { return false; key_record_stored_t stored; - lfs_ssize_t n = lfs_file_read(&lfs, &f, &stored, sizeof(stored)); + lfs_ssize_t n = lfs_file_read(&lfs, &f, &stored, sizeof(stored)); lfs_file_close(&lfs, &f); - if (n != (lfs_ssize_t)sizeof(stored)) return false; + if (n != (lfs_ssize_t)sizeof(stored)) + return false; *out = to_record(&stored); @@ -280,14 +291,15 @@ bool storage_key_get(uint16_t id, key_record_t *out) { } bool storage_key_save(const key_record_t *key) { - if (!mounted) return false; + if (!mounted) + return false; char path[40]; key_path(key->id, path, sizeof(path)); - key_record_stored_t stored = to_stored(key); // checksum computed here + key_record_stored_t stored = to_stored(key); // checksum computed here lfs_file_t f; - int flags = LFS_O_WRONLY | LFS_O_CREAT | LFS_O_TRUNC; + int flags = LFS_O_WRONLY | LFS_O_CREAT | LFS_O_TRUNC; if (lfs_file_opencfg(&lfs, &f, path, flags, &LFS_FILE_CFG) < 0) return false; @@ -297,28 +309,33 @@ bool storage_key_save(const key_record_t *key) { } bool storage_key_delete(uint16_t id) { - if (!mounted) return false; + if (!mounted) + return false; char path[40]; key_path(id, path, sizeof(path)); return lfs_remove(&lfs, path) >= 0; } int storage_key_list(key_record_t *out, int max_count) { - if (!mounted) return -1; + if (!mounted) + return -1; - if (max_count > KEY_MAX_COUNT) max_count = KEY_MAX_COUNT; + if (max_count > KEY_MAX_COUNT) + max_count = KEY_MAX_COUNT; lfs_dir_t dir; if (lfs_dir_open(&lfs, &dir, DIR_KEYS) < 0) return -1; - int count = 0; + int count = 0; struct lfs_info info; while (count < max_count && lfs_dir_read(&lfs, &dir, &info) > 0) { - if (info.type != LFS_TYPE_REG) continue; + if (info.type != LFS_TYPE_REG) + continue; uint16_t id = (uint16_t)strtoul(info.name, NULL, 10); - if (!storage_key_get(id, &out[count])) continue; + if (!storage_key_get(id, &out[count])) + continue; count++; } diff --git a/storage/storage.h b/storage/storage.h index 88629eb..b0ca965 100644 --- a/storage/storage.h +++ b/storage/storage.h @@ -10,10 +10,10 @@ // --------------------------------------------------------------------------- #define KEY_MAX_COUNT 256 -#define KEY_ID_MAX KEY_MAX_COUNT - 1 +#define KEY_ID_MAX KEY_MAX_COUNT - 1 #define KEY_NAME_MAX 32 -#define KEY_SECRET_LEN 20 // HMAC-SHA1 seed, matches old Arduino format +#define KEY_SECRET_LEN 20 // HMAC-SHA1 seed, matches old Arduino format typedef struct { uint16_t id; @@ -21,7 +21,7 @@ typedef struct { uint8_t secret[KEY_SECRET_LEN]; bool is_enabled; bool is_admin; - uint32_t created_at; // unix timestamp + uint32_t created_at; // unix timestamp bool is_checksum_valid; } key_record_t; @@ -53,10 +53,10 @@ bool storage_wifi_clear(void); // Key CRUD bool storage_key_exists(uint16_t id); bool storage_key_get(uint16_t id, key_record_t *out); -bool storage_key_save(const key_record_t *key); // create or update +bool storage_key_save(const key_record_t *key); // create or update bool storage_key_delete(uint16_t id); // List all keys. Returns count written into out[]. -int storage_key_list(key_record_t *out, int max_count); +int storage_key_list(key_record_t *out, int max_count); #endif \ No newline at end of file From 1eacca41ec79974bc55fa1cffab7a370cd3928f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 05:01:32 +0200 Subject: [PATCH 02/19] Preserve keypad KEY_MAP as a 4x4 grid via clang-format off The keypad map mirrors the physical 4x4 button layout; keeping it on one row per matrix row is more readable than the collapsed single line clang-format produces. Guard it with clang-format off/on. Co-Authored-By: Claude Opus 4.8 (1M context) --- hardware/keypad.c | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/hardware/keypad.c b/hardware/keypad.c index a79cee1..10c2cb6 100644 --- a/hardware/keypad.c +++ b/hardware/keypad.c @@ -2,8 +2,15 @@ #include "pico/stdlib.h" #include "pico/time.h" +// clang-format off +// Kept as a 4x4 grid mirroring the physical keypad layout. static const char KEY_MAP[KEYPAD_ROWS][KEYPAD_COLS] = { - {'1', '2', '3', 'A'}, {'4', '5', '6', 'B'}, {'7', '8', '9', 'C'}, {'*', '0', '#', 'D'}}; + {'1', '2', '3', 'A'}, + {'4', '5', '6', 'B'}, + {'7', '8', '9', 'C'}, + {'*', '0', '#', 'D'} +}; +// clang-format on void keypad_init(void) { // Rows: outputs, default HIGH From 2392e3f4be99c7c8411405e9fad52e7ee035ec27 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 05:11:28 +0200 Subject: [PATCH 03/19] Add ./ci entrypoint (lint fix / check) Minimal dispatcher. Default `--check=lint --action=fix` formats sources in place; `--action=check` does a non-zero-on-diff dry run for a git pre-commit hook or CI. Same file set as the workflow (excludes libs/ and version.h). Co-Authored-By: Claude Opus 4.8 (1M context) --- ci | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100755 ci diff --git a/ci b/ci new file mode 100755 index 0000000..936cd0b --- /dev/null +++ b/ci @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Project CI entrypoint. Default: fix formatting locally. +# Use --action=check (dry-run, non-zero on diff) in a git hook / CI. +set -euo pipefail +cd "$(dirname "$0")" + +check=lint action=fix +for a in "$@"; do case $a in + --check=*) check=${a#*=} ;; + --action=*) action=${a#*=} ;; + *) echo "usage: ./ci [--check=lint] [--action=fix|check]" >&2; exit 2 ;; +esac; done + +srcs() { git ls-files '*.c' '*.h' ':!:libs/**' ':!:version.h'; } + +case $check.$action in + lint.fix) srcs | xargs -r clang-format -i ;; + lint.check) srcs | xargs -r clang-format --dry-run --Werror ;; + *) echo "unknown --check=$check --action=$action" >&2; exit 2 ;; +esac From 12f0cccf3c803480d303d77e844021d4b032988e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 05:18:12 +0200 Subject: [PATCH 04/19] Add shellcheck to ./ci lint; unify CI on ./ci --action=check - ./ci now shellchecks shell scripts (discovered by shebang) alongside clang-format, under the same lint check. - build.sh: quote $(nproc) to fix the sole pre-existing SC2046 warning. - CI now runs ./ci --action=check (single source of truth) instead of a separate clang-format action; clang-format is pinned to 19.1.7 via pipx, shellcheck is preinstalled on the runner. Drops format.yml for ci.yml. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/format.yml | 18 ------------------ 1 file changed, 18 deletions(-) delete mode 100644 .github/workflows/format.yml diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml deleted file mode 100644 index 5dd24db..0000000 --- a/.github/workflows/format.yml +++ /dev/null @@ -1,18 +0,0 @@ -name: format - -on: - push: - pull_request: - -jobs: - clang-format: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Check formatting - uses: jidicula/clang-format-action@v4.15.0 - with: - clang-format-version: '19' - # Only our own sources; vendored libs/ and the generated - # version.h are left alone. - exclude-regex: '(^|/)(libs|build)/|version\.h' From 62cab50c9b1db17d1e89631ee9974c55a63763ad Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 05:18:36 +0200 Subject: [PATCH 05/19] Actually add ci.yml + shellcheck integration (fix prior partial commit) The previous commit only recorded the format.yml deletion; the new ci.yml workflow, the shellcheck-enabled ./ci, and the build.sh nproc quoting were left out due to a staging error. Add them now. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 15 +++++++++++++++ build.sh | 2 +- ci | 11 ++++++++--- 3 files changed, 24 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..bc0fc6f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,15 @@ +name: ci + +on: + push: + pull_request: + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + # Pin clang-format to match the version used locally. shellcheck is + # preinstalled on GitHub-hosted ubuntu runners. + - run: pipx install clang-format==19.1.7 + - run: ./ci --action=check diff --git a/build.sh b/build.sh index 8efc766..23c49a9 100755 --- a/build.sh +++ b/build.sh @@ -25,7 +25,7 @@ fi mkdir -p build cd build cmake -DPICO_BOARD=pico_w .. -make -j$(nproc) +make -j"$(nproc)" cd .. cp build/hslock.uf2 hslock.uf2 diff --git a/ci b/ci index 936cd0b..a3f6afe 100755 --- a/ci +++ b/ci @@ -11,10 +11,15 @@ for a in "$@"; do case $a in *) echo "usage: ./ci [--check=lint] [--action=fix|check]" >&2; exit 2 ;; esac; done -srcs() { git ls-files '*.c' '*.h' ':!:libs/**' ':!:version.h'; } +srcs() { git ls-files '*.c' '*.h' ':!:libs/**' ':!:version.h'; } +shells() { git ls-files ':!:libs/**' | while read -r f; do + if head -1 -- "$f" | grep -q '^#!.*sh'; then printf '%s\n' "$f"; fi + done; } case $check.$action in - lint.fix) srcs | xargs -r clang-format -i ;; - lint.check) srcs | xargs -r clang-format --dry-run --Werror ;; + lint.fix) srcs | xargs -r clang-format -i + shells | xargs -r shellcheck ;; # no autofix; reports only + lint.check) srcs | xargs -r clang-format --dry-run --Werror + shells | xargs -r shellcheck ;; *) echo "unknown --check=$check --action=$action" >&2; exit 2 ;; esac From 954206252f49f46fdc3060f128cee5e1e763a0ba Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 05:37:47 +0200 Subject: [PATCH 06/19] Fix signed-overflow UB in base32_encode accumulator The `buffer` accumulator was declared `int`. For a 20-byte input the running value grows past 2^23 and the `buffer << 8` step then shifts into / past the sign bit of a 32-bit `int`, which is undefined behavior in C (UBSan: "left shift of N by 8 places cannot be represented in type 'int'"). Widen the accumulator to `uint32_t`, where left shifts are well-defined modular arithmetic. Output is byte-identical (the low bits consumed via `>> bits_left & 0x1F` are unaffected). The host harness under -fsanitize=undefined -fno-sanitize-recover (test/harness_secret_qr.c) is the regression: it aborted on the old code and passes on the fixed code. Co-Authored-By: Claude Opus 4.8 (1M context) --- libs/base32/base32.c | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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++) { From 2d11fdf3481866c9226be2303772fd3f301b0b9b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 05:37:58 +0200 Subject: [PATCH 07/19] Add host test skeleton (test/): ASan/UBSan/valgrind/coverage harnesses Firmware is cross-compiled for the RP2040 and cannot run natively, but the hardware-independent logic can. This adds a standalone host test suite in test/, separate from the Pico CMake build: - test/stub/pico/rand.h: host stub for the Pico SDK RNG header; each harness supplies a deterministic get_rand_64(). - test/harness_secret_qr.c: mirrors serial/commands_keys.c (secret -> base32 -> otpauth URI -> qrcodegen), asserting base32 len, charset, and QR size, against the real shared/random, libs/base32 and libs/qrcodegen sources. - test/harness_base64.c: libs/base64 encode+decode roundtrip over empty input and every length residue mod 3 (branch coverage of the tail / padding paths) plus invalid-input rejection. - test/Makefile: targets asan (ASan+UBSan, -fno-sanitize-recover, CI gate), valgrind, coverage, clean. Binaries under test/build/. - .gitignore: ignore generated coverage / gcov artifacts. `make -C test asan` is green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 7 +++- test/Makefile | 87 ++++++++++++++++++++++++++++++++++++++++ test/harness_base64.c | 63 +++++++++++++++++++++++++++++ test/harness_secret_qr.c | 54 +++++++++++++++++++++++++ test/stub/pico/rand.h | 15 +++++++ 5 files changed, 225 insertions(+), 1 deletion(-) 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/.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/test/Makefile b/test/Makefile new file mode 100644 index 0000000..8a30923 --- /dev/null +++ b/test/Makefile @@ -0,0 +1,87 @@ +# 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, run to emit .gcda (report is a later step) +# make clean +# +# Invoke from the repo root as `make -C test `. + +# Repo layout, relative to this Makefile (test/). +ROOT := .. +BUILD := build + +# 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 + +INCLUDES := -Istub -I$(SHARED) -I$(BASE32) -I$(BASE64) -I$(QRCODEGEN) + +# 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 + +.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 (minimal; full lcov/genhtml report is a later step) ----------- +coverage: $(BUILD)/cov_secret_qr $(BUILD)/cov_base64 + @echo "== running harnesses with coverage instrumentation ==" + $(BUILD)/cov_secret_qr + $(BUILD)/cov_base64 + @echo "== coverage run OK (.gcda emitted under $(BUILD)) ==" + +$(BUILD)/cov_secret_qr: $(SECRET_QR_SRCS) | $(BUILD) + $(CC) $(CSTD) $(WARN) $(COV_FLAGS) $(INCLUDES) $(SECRET_QR_SRCS) -o $@ + +$(BUILD)/cov_base64: $(BASE64_SRCS) | $(BUILD) + $(CC) $(CSTD) $(WARN) $(COV_FLAGS) $(INCLUDES) $(BASE64_SRCS) -o $@ + +$(BUILD): + mkdir -p $(BUILD) + +clean: + rm -rf $(BUILD) coverage *.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..a7900dd --- /dev/null +++ b/test/harness_secret_qr.c @@ -0,0 +1,54 @@ +/* + * 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 From 16dfbf017eb0b3fa44be944cebceca9a336af5c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 05:50:33 +0200 Subject: [PATCH 08/19] Add whole-codebase host coverage (lcov/genhtml) + valgrind + blind-spot summary test/Makefile coverage target now builds the ENTIRE first-party codebase so untested files surface at their true 0% instead of being filtered out: - real harnesses (base32 / base64 / random) run and report real coverage; - every other host-compilable first-party .c is compiled with --coverage to emit a .gcno (0% blind spot) via cheap Pico-SDK stubs under test/stub/; - lcov --initial baseline + post-run capture, combined, filtered to first-party (qrcodegen/littlefs/test/usr removed), branch coverage on, rendered to coverage/html/ with per-line highlighting. test/coverage-summary.sh (shellcheck-clean) emits coverage/summary.{txt,json} and a summary.html surfaced at the top of the report: overall first-party line%/branch% over all 19 files (blind spots folded in at 0%), the 0%-coverage file list, files below 100% branch coverage worst-first, and an explicit BLIND SPOTS list. Three files remain non-host-compilable blind spots (network/ntp.c, network/wifi.c: cyw43/lwip; storage/storage.c: flash+littlefs). valgrind target already runs each harness under --error-exitcode=99 --leak-check=full -q and is clean. harness_secret_qr.c reflowed to satisfy clang-format (ci lint). Co-Authored-By: Claude Opus 4.8 (1M context) --- test/Makefile | 103 ++++++++++++--- test/coverage-summary.sh | 234 ++++++++++++++++++++++++++++++++++ test/harness_secret_qr.c | 5 +- test/stub/hardware/rtc.h | 26 ++++ test/stub/hardware/watchdog.h | 12 ++ test/stub/pico/multicore.h | 18 +++ test/stub/pico/stdio_usb.h | 10 ++ test/stub/pico/stdlib.h | 52 ++++++++ test/stub/pico/time.h | 20 +++ test/stub/version.h | 8 ++ 10 files changed, 469 insertions(+), 19 deletions(-) create mode 100755 test/coverage-summary.sh create mode 100644 test/stub/hardware/rtc.h create mode 100644 test/stub/hardware/watchdog.h create mode 100644 test/stub/pico/multicore.h create mode 100644 test/stub/pico/stdio_usb.h create mode 100644 test/stub/pico/stdlib.h create mode 100644 test/stub/pico/time.h create mode 100644 test/stub/version.h diff --git a/test/Makefile b/test/Makefile index 8a30923..b213117 100644 --- a/test/Makefile +++ b/test/Makefile @@ -7,7 +7,8 @@ # # make asan ASan + UBSan, aborts on any error (CI gate) # make valgrind plain build under valgrind, fails on error/leak -# make coverage --coverage build, run to emit .gcda (report is a later step) +# make coverage --coverage build over the WHOLE first-party codebase, run, +# lcov + genhtml report (branch coverage) into coverage/html/ # make clean # # Invoke from the repo root as `make -C test `. @@ -15,6 +16,7 @@ # Repo layout, relative to this Makefile (test/). ROOT := .. BUILD := build +COV_DIR := coverage # Real first-party / third-party sources exercised by the harnesses. SHARED := $(ROOT)/shared @@ -22,20 +24,56 @@ BASE32 := $(ROOT)/libs/base32 BASE64 := $(ROOT)/libs/base64 QRCODEGEN := $(ROOT)/libs/qrcodegen/c -INCLUDES := -Istub -I$(SHARED) -I$(BASE32) -I$(BASE64) -I$(QRCODEGEN) +# 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 +# --------------------------------------------------------------------------- +# Whole-codebase coverage denominator (blind-spot visibility) +# --------------------------------------------------------------------------- +# COV_ZERO_SRCS: first-party firmware .c files that COMPILE host-side with the +# stubs above but are NOT exercised by any harness. Compiling them emits a +# .gcno (but no .gcda), so lcov reports their true 0% coverage instead of +# silently dropping them - blind spots stay visible in the report. +COV_ZERO_SRCS := $(ROOT)/hardware/buzzer.c $(ROOT)/hardware/clock.c \ + $(ROOT)/hardware/keypad.c $(ROOT)/hardware/latch.c \ + $(ROOT)/hardware/light.c $(ROOT)/main.c \ + $(ROOT)/serial/commands.c $(ROOT)/serial/commands_backup.c \ + $(ROOT)/serial/commands_keys.c $(ROOT)/serial/commands_network.c \ + $(ROOT)/serial/commands_system.c $(ROOT)/serial/console.c \ + $(ROOT)/storage/backup.c +# +# BLIND SPOTS - first-party files that CANNOT be host-compiled without heavy +# mocks (real Pico SDK / cyw43 / lwip / littlefs block device). They are +# enumerated (with reason) by coverage-summary.sh and counted as 0% toward the +# whole-codebase denominator - never silently excluded: +# network/ntp.c - pico/cyw43_arch.h + lwip/udp.h + lwip/dns.h +# network/wifi.c - pico/cyw43_arch.h +# storage/storage.c- pico/flash.h + hardware/flash.h + hardware/sync.h + lfs cfg + 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 +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. +LCOV_RC := --rc branch_coverage=1 +LCOV_IGNORE := --ignore-errors unused,empty,mismatch,inconsistent,source,gcov,negative,range +LCOV := lcov $(LCOV_RC) $(LCOV_IGNORE) .PHONY: all asan valgrind coverage clean @@ -67,21 +105,54 @@ $(BUILD)/vg_secret_qr: $(SECRET_QR_SRCS) | $(BUILD) $(BUILD)/vg_base64: $(BASE64_SRCS) | $(BUILD) $(CC) $(CSTD) $(WARN) $(VG_FLAGS) $(INCLUDES) $(BASE64_SRCS) -o $@ -# --- Coverage (minimal; full lcov/genhtml report is a later step) ----------- -coverage: $(BUILD)/cov_secret_qr $(BUILD)/cov_base64 - @echo "== running harnesses with coverage instrumentation ==" - $(BUILD)/cov_secret_qr - $(BUILD)/cov_base64 - @echo "== coverage run OK (.gcda emitted under $(BUILD)) ==" - -$(BUILD)/cov_secret_qr: $(SECRET_QR_SRCS) | $(BUILD) - $(CC) $(CSTD) $(WARN) $(COV_FLAGS) $(INCLUDES) $(SECRET_QR_SRCS) -o $@ - -$(BUILD)/cov_base64: $(BASE64_SRCS) | $(BUILD) - $(CC) $(CSTD) $(WARN) $(COV_FLAGS) $(INCLUDES) $(BASE64_SRCS) -o $@ +# --- Coverage (whole-codebase denominator + lcov/genhtml HTML report) ------- +# 1. Build & run the real harnesses (produce .gcda -> true coverage). +# 2. Compile every other host-compilable first-party .c (produce .gcno only +# -> true 0%), so untested files are visible as blind spots. +# 3. lcov: --initial baseline (all .gcno at 0%) + post-run capture, combined, +# filtered to first-party only, rendered to coverage/html/ with branches. +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: compiling untested first-party files (0% blind spots) ==" + @for f in $(COV_ZERO_SRCS); do \ + o=$(COV_DIR)/obj/zero_$$(basename $$f .c).o; \ + echo " CC (gcno) $$f"; \ + $(CC) $(CSTD) $(WARN) $(COV_FLAGS) $(INCLUDES) -c $$f -o $$o || exit 1; \ + done + @echo "== coverage: capturing initial (zero) baseline ==" + $(LCOV) --capture --initial --directory $(COV_DIR)/obj \ + --output-file $(COV_DIR)/base.info + @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 + $(LCOV) --add-tracefile $(COV_DIR)/base.info \ + --add-tracefile $(COV_DIR)/run.info --output-file $(COV_DIR)/total.info + @echo "== coverage: filtering to first-party sources ==" + $(LCOV) --extract $(COV_DIR)/total.info '*/hslock/*' \ + --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) $(LCOV_IGNORE) --branch-coverage \ + --legend --title "hslock host coverage" \ + --output-directory $(COV_DIR)/html $(COV_DIR)/coverage.info + @echo "== coverage: generating summary (txt + json) ==" + ./coverage-summary.sh $(COV_DIR)/coverage.info $(COV_DIR) + @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) coverage *.gcno *.gcda + rm -rf $(BUILD) $(COV_DIR) *.gcno *.gcda diff --git a/test/coverage-summary.sh b/test/coverage-summary.sh new file mode 100755 index 0000000..e3471e1 --- /dev/null +++ b/test/coverage-summary.sh @@ -0,0 +1,234 @@ +#!/usr/bin/env bash +# +# coverage-summary.sh - whole-codebase coverage summary + blind-spot list. +# +# Reads a filtered lcov .info tracefile and the full first-party source list +# (git ls-files, minus third-party submodules and the test harnesses) and emits: +# - overall first-party line% and branch% (blind spots folded into the +# line denominator as 0%, denominator stated explicitly), +# - the list of 0%-coverage files, +# - files below 100% branch coverage, worst-first (searchable blind spots), +# - the explicit BLIND SPOTS list (non-host-compilable files + reason). +# Outputs summary.txt and summary.json into , and a summary.html that +# is surfaced at the top of the genhtml report (linked from index.html). +# +# Usage: ./coverage-summary.sh +set -euo pipefail + +INFO=${1:?usage: coverage-summary.sh } +OUT=${2:?usage: coverage-summary.sh } +REPO=$(git -C .. rev-parse --show-toplevel) + +# --- explicit BLIND SPOTS: files that cannot be host-compiled (with reason) -- +# Keep in sync with test/Makefile COV_ZERO_SRCS commentary. +BLIND_FILES=(network/ntp.c network/wifi.c storage/storage.c) +blind_reason() { + case "$1" in + network/ntp.c) echo "needs pico/cyw43_arch.h + lwip/udp.h + lwip/dns.h (no host stack)" ;; + network/wifi.c) echo "needs pico/cyw43_arch.h (cyw43 driver, no host stack)" ;; + storage/storage.c) echo "needs pico/flash.h + hardware/flash.h/sync.h + littlefs block device" ;; + *) echo "non-host-compilable" ;; + esac +} + +is_blind() { + local f + for f in "${BLIND_FILES[@]}"; do + [ "$f" = "$1" ] && return 0 + done + return 1 +} + +# --- full first-party file list (denominator) ------------------------------- +mapfile -t FIRST_PARTY < <( + git -C "$REPO" ls-files '*.c' \ + ':!:libs/qrcodegen/**' ':!:libs/littlefs/**' ':!:test/**' | sort +) + +# --- parse lcov .info: per repo-relative file -> LF LH BRF BRH --------------- +declare -A LF LH BRF BRH +cur="" +while IFS= read -r line; do + case "$line" in + SF:*) + path=${line#SF:} + cur=${path#"$REPO"/} + LF[$cur]=0 + LH[$cur]=0 + BRF[$cur]=0 + BRH[$cur]=0 + ;; + LF:*) LF[$cur]=${line#LF:} ;; + LH:*) LH[$cur]=${line#LH:} ;; + BRF:*) BRF[$cur]=${line#BRF:} ;; + BRH:*) BRH[$cur]=${line#BRH:} ;; + esac +done <"$INFO" + +# --- approximate instrumentable line count for uncompiled (blind-spot) files - +# Counts non-blank lines that are not pure comments or lone braces. Conservative +# stand-in for gcov line count so blind spots weigh honestly in the denominator. +approx_lines() { + local f=$1 n=0 t + while IFS= read -r t; do + t=${t#"${t%%[![:space:]]*}"} # strip leading whitespace + [ -z "$t" ] && continue + case "$t" in + '//'* | '/*'* | '*'* | '{' | '}' | '};') continue ;; + esac + n=$((n + 1)) + done <"$REPO/$f" + echo "$n" +} + +pct() { # hits found -> percentage string with one decimal + local h=$1 f=$2 + if [ "$f" -eq 0 ]; then + echo "n/a" + else + awk -v h="$h" -v f="$f" 'BEGIN{printf "%.1f", (h*100.0)/f}' + fi +} + +# --- aggregate -------------------------------------------------------------- +tot_lf=0 tot_lh=0 tot_brf=0 tot_brh=0 +n_files=0 n_zero=0 n_blind=0 n_measured=0 n_exercised=0 +zero_list=() # "path" with 0% lines +below_list=() # "brpct|path|BRH|BRF" below 100% branch +for f in "${FIRST_PARTY[@]}"; do + n_files=$((n_files + 1)) + if [ -n "${LF[$f]+x}" ]; then + # measured by lcov + n_measured=$((n_measured + 1)) + lf=${LF[$f]} lh=${LH[$f]} brf=${BRF[$f]} brh=${BRH[$f]} + tot_lf=$((tot_lf + lf)) + tot_lh=$((tot_lh + lh)) + tot_brf=$((tot_brf + brf)) + tot_brh=$((tot_brh + brh)) + if [ "$lh" -eq 0 ]; then + n_zero=$((n_zero + 1)) + zero_list+=("$f") + else + n_exercised=$((n_exercised + 1)) + fi + if [ "$brf" -gt 0 ] && [ "$brh" -lt "$brf" ]; then + p=$(awk -v h="$brh" -v f="$brf" 'BEGIN{printf "%07.3f",(h*100.0)/f}') + below_list+=("$p|$f|$brh|$brf") + fi + else + # not in lcov: blind spot (or otherwise uncompiled) -> 0% lines folded in + al=$(approx_lines "$f") + tot_lf=$((tot_lf + al)) + n_zero=$((n_zero + 1)) + if is_blind "$f"; then + n_blind=$((n_blind + 1)) + fi + zero_list+=("$f (0%, uncompiled ~${al} lines)") + fi +done + +line_pct=$(pct "$tot_lh" "$tot_lf") +br_pct=$(pct "$tot_brh" "$tot_brf") + +# worst-first branch list +mapfile -t below_sorted < <(printf '%s\n' "${below_list[@]}" | sort -t'|' -k1,1n) + +# --- summary.txt ------------------------------------------------------------ +mkdir -p "$OUT" +TXT="$OUT/summary.txt" +{ + echo "hslock host coverage summary" + echo "============================" + echo + echo "First-party files (denominator): $n_files" + echo " host-compiled into coverage build: $n_measured" + echo " actually exercised (>0% lines): $n_exercised" + echo " 0% coverage (untested + blind): $n_zero" + echo " non-host-compilable blind spots: $n_blind" + echo + echo "Overall LINE coverage: ${line_pct}% (${tot_lh}/${tot_lf} lines)" + echo " denominator folds blind-spot / uncompiled files in at 0%" + echo " (their instrumentable lines are approximated from source)." + echo "Overall BRANCH coverage: ${br_pct}% (${tot_brh}/${tot_brf} branches)" + echo " branch denominator counts only host-compiled files; blind-spot" + echo " branches are unmeasured (see the blind-spot list below)." + echo + echo "0%-COVERAGE FILES ($n_zero):" + if [ ${#zero_list[@]} -eq 0 ]; then + echo " (none)" + else + printf ' - %s\n' "${zero_list[@]}" + fi + echo + echo "FILES BELOW 100% BRANCH COVERAGE (worst-first):" + if [ ${#below_sorted[@]} -eq 0 ]; then + echo " (none among measured files)" + else + for row in "${below_sorted[@]}"; do + IFS='|' read -r p f h t <<<"$row" + printf ' - %6.2f%% %s (%s/%s branches)\n' "$p" "$f" "$h" "$t" + done + fi + echo + echo "BLIND SPOTS (non-host-compilable, counted 0% in denominator):" + for f in "${BLIND_FILES[@]}"; do + printf ' - %s -- %s\n' "$f" "$(blind_reason "$f")" + done +} >"$TXT" + +# --- summary.json ----------------------------------------------------------- +JSON="$OUT/summary.json" +{ + printf '{\n' + printf ' "first_party_files": %s,\n' "$n_files" + printf ' "compiled_files": %s,\n' "$n_measured" + printf ' "exercised_files": %s,\n' "$n_exercised" + printf ' "zero_coverage_files": %s,\n' "$n_zero" + printf ' "blind_spot_files": %s,\n' "$n_blind" + printf ' "line_pct": "%s",\n' "$line_pct" + printf ' "lines_hit": %s,\n' "$tot_lh" + printf ' "lines_found": %s,\n' "$tot_lf" + printf ' "branch_pct": "%s",\n' "$br_pct" + printf ' "branches_hit": %s,\n' "$tot_brh" + printf ' "branches_found": %s,\n' "$tot_brf" + printf ' "zero_files": [' + sep="" + for f in "${zero_list[@]}"; do + printf '%s"%s"' "$sep" "$f" + sep=", " + done + printf '],\n' + printf ' "blind_spots": [' + sep="" + for f in "${BLIND_FILES[@]}"; do + printf '%s{"file": "%s", "reason": "%s"}' "$sep" "$f" "$(blind_reason "$f")" + sep=", " + done + printf ']\n}\n' +} >"$JSON" + +# --- summary.html (surfaced at top of the Pages report) --------------------- +if [ -d "$OUT/html" ]; then + HTML="$OUT/html/summary.html" + { + echo 'hslock coverage summary' + echo '' + echo '

hslock host coverage summary

' + echo '

→ full per-file / per-line report

' + echo '
'
+		sed 's/&/\&/g; s//\>/g' "$TXT"
+		echo '
' + } >"$HTML" + # Prepend a prominent banner + summary link at the top of genhtml's index. + IDX="$OUT/html/index.html" + if [ -f "$IDX" ] && ! grep -q 'hslock-summary-banner' "$IDX"; then + banner='
Whole-codebase coverage: '"${line_pct}"'% lines, '"${br_pct}"'% branches over '"${n_files}"' first-party files ('"${n_zero}"' at 0%, '"${n_blind}"' non-host-compilable blind spots).  full summary & blind-spot list →
' + awk -v b="$banner" '/"$IDX.tmp" && + mv "$IDX.tmp" "$IDX" + fi +fi + +echo "summary written: $TXT, $JSON" +echo " line ${line_pct}% (${tot_lh}/${tot_lf}), branch ${br_pct}% (${tot_brh}/${tot_brf}), ${n_zero} zero-cov, ${n_blind} blind spots" diff --git a/test/harness_secret_qr.c b/test/harness_secret_qr.c index a7900dd..d22a543 100644 --- a/test/harness_secret_qr.c +++ b/test/harness_secret_qr.c @@ -41,9 +41,8 @@ int main(void) { 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); + 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); diff --git a/test/stub/hardware/rtc.h b/test/stub/hardware/rtc.h new file mode 100644 index 0000000..933fabf --- /dev/null +++ b/test/stub/hardware/rtc.h @@ -0,0 +1,26 @@ +#ifndef HSLOCK_TEST_STUB_HARDWARE_RTC_H +#define HSLOCK_TEST_STUB_HARDWARE_RTC_H +/* Host stub for the Pico SDK "hardware/rtc.h" - datetime_t + no-op RTC. */ +#include +#include + +typedef struct { + int16_t year; + int8_t month; + int8_t day; + int8_t dotw; + int8_t hour; + int8_t min; + int8_t sec; +} datetime_t; + +static inline bool rtc_get_datetime(datetime_t *t) { + (void)t; + return false; +} +static inline bool rtc_set_datetime(const datetime_t *t) { + (void)t; + return true; +} + +#endif diff --git a/test/stub/hardware/watchdog.h b/test/stub/hardware/watchdog.h new file mode 100644 index 0000000..413e678 --- /dev/null +++ b/test/stub/hardware/watchdog.h @@ -0,0 +1,12 @@ +#ifndef HSLOCK_TEST_STUB_HARDWARE_WATCHDOG_H +#define HSLOCK_TEST_STUB_HARDWARE_WATCHDOG_H +/* Host stub for the Pico SDK "hardware/watchdog.h" - no-op reboot. */ +#include + +static inline void watchdog_reboot(uint32_t pc, uint32_t sp, uint32_t delay_ms) { + (void)pc; + (void)sp; + (void)delay_ms; +} + +#endif diff --git a/test/stub/pico/multicore.h b/test/stub/pico/multicore.h new file mode 100644 index 0000000..bb0959d --- /dev/null +++ b/test/stub/pico/multicore.h @@ -0,0 +1,18 @@ +#ifndef HSLOCK_TEST_STUB_PICO_MULTICORE_H +#define HSLOCK_TEST_STUB_PICO_MULTICORE_H +/* Host stub for "pico/multicore.h" - no-op core1 / FIFO shims. */ +#include + +static inline void multicore_lockout_victim_init(void) { +} +static inline void multicore_fifo_push_blocking(uint32_t v) { + (void)v; +} +static inline uint32_t multicore_fifo_pop_blocking(void) { + return 0; +} +static inline void multicore_launch_core1(void (*entry)(void)) { + (void)entry; +} + +#endif diff --git a/test/stub/pico/stdio_usb.h b/test/stub/pico/stdio_usb.h new file mode 100644 index 0000000..5cd6ed1 --- /dev/null +++ b/test/stub/pico/stdio_usb.h @@ -0,0 +1,10 @@ +#ifndef HSLOCK_TEST_STUB_PICO_STDIO_USB_H +#define HSLOCK_TEST_STUB_PICO_STDIO_USB_H +/* Host stub for "pico/stdio_usb.h" - USB CDC never connected on host. */ +#include + +static inline bool stdio_usb_connected(void) { + return false; +} + +#endif diff --git a/test/stub/pico/stdlib.h b/test/stub/pico/stdlib.h new file mode 100644 index 0000000..59c0d87 --- /dev/null +++ b/test/stub/pico/stdlib.h @@ -0,0 +1,52 @@ +#ifndef HSLOCK_TEST_STUB_PICO_STDLIB_H +#define HSLOCK_TEST_STUB_PICO_STDLIB_H +/* + * Host stub for the Pico SDK "pico/stdlib.h". Provides just enough no-op GPIO, + * timing and stdio shims for first-party firmware .c files to COMPILE natively + * so the coverage build can emit a .gcno for each (making untested files show + * up at their true 0%). These are never linked into a running harness. + */ +#include +#include +#include + +typedef unsigned int uint; + +#define GPIO_OUT 1 +#define GPIO_IN 0 +#define PICO_ERROR_TIMEOUT (-1) + +static inline void gpio_init(uint pin) { + (void)pin; +} +static inline void gpio_set_dir(uint pin, int out) { + (void)pin; + (void)out; +} +static inline void gpio_put(uint pin, bool value) { + (void)pin; + (void)value; +} +static inline int gpio_get(uint pin) { + (void)pin; + return 1; +} +static inline void gpio_pull_up(uint pin) { + (void)pin; +} +static inline void sleep_ms(uint32_t ms) { + (void)ms; +} +static inline void sleep_us(uint64_t us) { + (void)us; +} +static inline void stdio_init_all(void) { +} +static inline int getchar_timeout_us(uint32_t timeout) { + (void)timeout; + return PICO_ERROR_TIMEOUT; +} +static inline void tight_loop_contents(void) { +} + +#endif diff --git a/test/stub/pico/time.h b/test/stub/pico/time.h new file mode 100644 index 0000000..09bb068 --- /dev/null +++ b/test/stub/pico/time.h @@ -0,0 +1,20 @@ +#ifndef HSLOCK_TEST_STUB_PICO_TIME_H +#define HSLOCK_TEST_STUB_PICO_TIME_H +/* Host stub for "pico/time.h" - opaque absolute_time_t + no-op timers. */ +#include +#include + +typedef struct { + int64_t _us; +} absolute_time_t; + +static inline absolute_time_t make_timeout_time_ms(uint32_t ms) { + absolute_time_t t = {(int64_t)ms}; + return t; +} +static inline bool time_reached(absolute_time_t t) { + (void)t; + return true; +} + +#endif diff --git a/test/stub/version.h b/test/stub/version.h new file mode 100644 index 0000000..a6d6af0 --- /dev/null +++ b/test/stub/version.h @@ -0,0 +1,8 @@ +#ifndef HSLOCK_TEST_STUB_VERSION_H +#define HSLOCK_TEST_STUB_VERSION_H +/* Host stub for the CMake-generated version.h (see version.h.in). */ +#define GIT_HASH "host-test" +#define GIT_DIRTY 0 +#define BUILD_DATE "host-test" + +#endif From c18957f3d30c9630601a9de15d94cedb147b32d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 05:54:41 +0200 Subject: [PATCH 09/19] Wire --check=test into ./ci (asan+valgrind+coverage) test.check runs asan + valgrind + coverage; the hard gate is ASan/UBSan + Valgrind (correctness). Coverage is report-only: the whole-codebase denominator keeps line% intentionally low (blind spots folded in), so no coverage floor gates CI. test.fix regenerates the HTML report + summary via make -C test coverage. Co-Authored-By: Claude Opus 4.8 (1M context) --- ci | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/ci b/ci index a3f6afe..0eef3c4 100755 --- a/ci +++ b/ci @@ -21,5 +21,12 @@ case $check.$action in shells | xargs -r shellcheck ;; # no autofix; reports only lint.check) srcs | xargs -r clang-format --dry-run --Werror shells | xargs -r shellcheck ;; + # Host tests. The hard gate is ASan+UBSan + Valgrind (correctness); coverage + # is report-only (whole-codebase denominator keeps line% intentionally low), + # so it never fails CI. + test.check) make -C test asan + make -C test valgrind + make -C test coverage ;; + test.fix) make -C test coverage ;; # run + (re)gen report *) echo "unknown --check=$check --action=$action" >&2; exit 2 ;; esac From d2de8fc390fd720957a708cdb276dcc93a4eb74b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 05:56:56 +0200 Subject: [PATCH 10/19] ci: add host test job (asan+valgrind+coverage) Runs ./ci --check=test --action=check on ubuntu-latest with valgrind+lcov installed and submodules checked out recursively (qrcodegen/littlefs are needed for the coverage build). The hard gate is ASan+UBSan + Valgrind; coverage is report-only. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bc0fc6f..10a27fc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,3 +13,15 @@ jobs: # preinstalled on GitHub-hosted ubuntu runners. - run: pipx install clang-format==19.1.7 - run: ./ci --action=check + + 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 + Valgrind. Coverage is report-only. + - run: ./ci --check=test --action=check From 189324e39d3aa3235d3061d895455e3f08d6fe12 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 05:59:02 +0200 Subject: [PATCH 11/19] test: drop 'range' from lcov --ignore-errors for CI lcov 2.0 compat GitHub's ubuntu-latest ships an lcov 2.0 build whose pure-lcov combine step (--add-tracefile) rejects the geninfo-only 'range' error category with 'unknown argument for --ignore-errors: range', failing the coverage step. Removing it keeps coverage identical locally (same 5.5% line / 12.7% branch, no new lcov messages) and works across lcov 1.x/2.x. Co-Authored-By: Claude Opus 4.8 (1M context) --- test/Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/Makefile b/test/Makefile index b213117..057a19d 100644 --- a/test/Makefile +++ b/test/Makefile @@ -72,7 +72,7 @@ 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. LCOV_RC := --rc branch_coverage=1 -LCOV_IGNORE := --ignore-errors unused,empty,mismatch,inconsistent,source,gcov,negative,range +LCOV_IGNORE := --ignore-errors unused,empty,mismatch,inconsistent,source,gcov,negative LCOV := lcov $(LCOV_RC) $(LCOV_IGNORE) .PHONY: all asan valgrind coverage clean From d6ef84e47c4ffb8e3df921c3c25a8d0f707cb6e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 06:09:35 +0200 Subject: [PATCH 12/19] test: separate genhtml --ignore-errors list (drop 'gcov') for CI lcov Ubuntu 24.04's lcov 2.0 genhtml rejects the 'gcov' error category ('unknown argument for --ignore-errors: gcov') because genhtml has no gcov phase, while lcov/geninfo accept it. Split into LCOV_IGNORE (capture/combine, keeps 'gcov') and GENHTML_IGNORE (no 'gcov'). All categories exist in lcov 2.0.0, so it stays robust across Debian and Ubuntu lcov builds. Verified end-to-end in an ubuntu:24.04 container (lcov 2.0-1, gcov 13.3.0) matching the CI runner: ./ci --check=test --action=check exits 0. Co-Authored-By: Claude Opus 4.8 (1M context) --- test/Makefile | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/test/Makefile b/test/Makefile index 057a19d..5a69aaf 100644 --- a/test/Makefile +++ b/test/Makefile @@ -71,9 +71,17 @@ 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. -LCOV_RC := --rc branch_coverage=1 -LCOV_IGNORE := --ignore-errors unused,empty,mismatch,inconsistent,source,gcov,negative -LCOV := lcov $(LCOV_RC) $(LCOV_IGNORE) +# +# 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 @@ -141,7 +149,7 @@ coverage: | $(COV_DIR)/obj '*/libs/qrcodegen/*' '*/libs/littlefs/*' '*/test/*' '/usr/*' \ --output-file $(COV_DIR)/coverage.info @echo "== coverage: generating HTML report ==" - genhtml $(LCOV_RC) $(LCOV_IGNORE) --branch-coverage \ + genhtml $(LCOV_RC) $(GENHTML_IGNORE) --branch-coverage \ --legend --title "hslock host coverage" \ --output-directory $(COV_DIR)/html $(COV_DIR)/coverage.info @echo "== coverage: generating summary (txt + json) ==" From 106ba65fbcd984157012213edd4bb34b82914682 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 06:13:18 +0200 Subject: [PATCH 13/19] ci: publish coverage report to GitHub Pages Add a standalone pages.yml workflow that regenerates the host-test coverage HTML (./ci --check=test) and deploys test/coverage/html/ to GitHub Pages via actions/upload-pages-artifact + actions/deploy-pages. Pages is enabled through the job token (configure-pages enablement), so no admin CLI scope is required. Kept separate from ci.yml. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/pages.yml | 49 +++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 .github/workflows/pages.yml diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..908357c --- /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/clang-format, 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 + # Default action=fix: run harnesses + (re)generate test/coverage/html/. + - run: ./ci --check=test + # 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 From f27cc47a9bdc9fa7ad5b01a87fb96b48160a30d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 06:24:56 +0200 Subject: [PATCH 14/19] test: derive coverage first-party filter from repo root, not literal "hslock" The coverage step extracted first-party files with a hardcoded glob '*/hslock/*', so `./ci --check=test --action=check` only worked when the checkout dir was literally named "hslock". In any other dir the extract matched nothing and genhtml aborted with "unexpected empty worklist" (exit 2). CI happened to use a dir named hslock, masking the fragility. Derive REPO_ROOT at run time (git rev-parse --show-toplevel, falling back to the parent dir for non-git checkouts) and extract "$(REPO_ROOT)/*". Coverage is built with -fprofile-abs-path so the tracefiles carry absolute paths; filtering by the absolute repo root is dir-name-independent. The existing --remove calls (submodules, /usr, test/) are unchanged. coverage-summary.sh already derived the repo root via git, so no change there. Co-Authored-By: Claude Opus 4.8 (1M context) --- test/Makefile | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/test/Makefile b/test/Makefile index 5a69aaf..660fa8d 100644 --- a/test/Makefile +++ b/test/Makefile @@ -18,6 +18,13 @@ 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 @@ -143,7 +150,7 @@ coverage: | $(COV_DIR)/obj $(LCOV) --add-tracefile $(COV_DIR)/base.info \ --add-tracefile $(COV_DIR)/run.info --output-file $(COV_DIR)/total.info @echo "== coverage: filtering to first-party sources ==" - $(LCOV) --extract $(COV_DIR)/total.info '*/hslock/*' \ + $(LCOV) --extract $(COV_DIR)/total.info '$(REPO_ROOT)/*' \ --output-file $(COV_DIR)/first.info $(LCOV) --remove $(COV_DIR)/first.info \ '*/libs/qrcodegen/*' '*/libs/littlefs/*' '*/test/*' '/usr/*' \ From 19d5cb7177522c4c2108f1d0999401b0cb11ce82 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 10:01:40 +0200 Subject: [PATCH 15/19] test: report coverage on every first-party file, drop blind-spot list Add host stubs (cyw43_arch, lwip udp/dns/pbuf/err/ip_addr/arch, pico/flash, hardware/flash, hardware/sync) so network/wifi.c, network/ntp.c and storage/storage.c compile host-side. They now emit a .gcno and appear in the lcov/genhtml report at their true 0% with per-line detail, like every other untested file - so all 19 first-party files are reported uniformly. Drop the separate hand-maintained BLIND SPOTS list and the source-line approximation from coverage-summary.sh: overall line% / branch% are now derived entirely from lcov (991 real instrumented lines, was ~1099 approximated). sleep_ms/sleep_us/time_us_64 move into pico/time.h (mirroring the SDK) so both network/ntp.c and files pulling pico/stdlib.h resolve them without redefinition. Trigger the Pages redeploy from chore/host-tests too. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/pages.yml | 2 +- test/Makefile | 36 ++++----- test/coverage-summary.sh | 147 ++++++++++++++---------------------- test/stub/hardware/flash.h | 40 ++++++++++ test/stub/hardware/rtc.h | 2 + test/stub/hardware/sync.h | 19 +++++ test/stub/lwip/arch.h | 17 +++++ test/stub/lwip/dns.h | 16 ++++ test/stub/lwip/err.h | 11 +++ test/stub/lwip/ip_addr.h | 12 +++ test/stub/lwip/pbuf.h | 38 ++++++++++ test/stub/lwip/udp.h | 26 +++++++ test/stub/pico/cyw43_arch.h | 39 ++++++++++ test/stub/pico/flash.h | 22 ++++++ test/stub/pico/stdlib.h | 8 +- test/stub/pico/time.h | 9 +++ 16 files changed, 328 insertions(+), 116 deletions(-) create mode 100644 test/stub/hardware/flash.h create mode 100644 test/stub/hardware/sync.h create mode 100644 test/stub/lwip/arch.h create mode 100644 test/stub/lwip/dns.h create mode 100644 test/stub/lwip/err.h create mode 100644 test/stub/lwip/ip_addr.h create mode 100644 test/stub/lwip/pbuf.h create mode 100644 test/stub/lwip/udp.h create mode 100644 test/stub/pico/cyw43_arch.h create mode 100644 test/stub/pico/flash.h diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 908357c..ef5c605 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -5,7 +5,7 @@ name: pages # not entangle. The report is regenerated on every push to the branches below. on: push: - branches: [chore/clang-format, master] + branches: [chore/clang-format, chore/host-tests, master] # Least privilege for the Pages deployment via the job's OIDC token. permissions: diff --git a/test/Makefile b/test/Makefile index 660fa8d..d2c5449 100644 --- a/test/Makefile +++ b/test/Makefile @@ -46,27 +46,27 @@ SECRET_QR_SRCS := harness_secret_qr.c $(SHARED)/random.c $(BASE32)/base32.c \ BASE64_SRCS := harness_base64.c $(BASE64)/base64.c # --------------------------------------------------------------------------- -# Whole-codebase coverage denominator (blind-spot visibility) +# Whole-codebase coverage denominator (uniform 0% for every untested file) # --------------------------------------------------------------------------- -# COV_ZERO_SRCS: first-party firmware .c files that COMPILE host-side with the -# stubs above but are NOT exercised by any harness. Compiling them emits a -# .gcno (but no .gcda), so lcov reports their true 0% coverage instead of -# silently dropping them - blind spots stay visible in the report. +# COV_ZERO_SRCS: EVERY first-party firmware .c not exercised by a harness. +# All of them COMPILE host-side against the stubs in test/stub/ (including the +# cyw43 / lwip / flash shims that let network/*.c and storage/storage.c build +# without the real Pico SDK). Compiling emits a .gcno (but no .gcda), so lcov +# reports each file's true 0% coverage with full per-line detail instead of +# silently dropping it - so the report covers the WHOLE codebase uniformly, +# with no separate hand-maintained "blind spot" list or line approximation. COV_ZERO_SRCS := $(ROOT)/hardware/buzzer.c $(ROOT)/hardware/clock.c \ $(ROOT)/hardware/keypad.c $(ROOT)/hardware/latch.c \ $(ROOT)/hardware/light.c $(ROOT)/main.c \ + $(ROOT)/network/ntp.c $(ROOT)/network/wifi.c \ $(ROOT)/serial/commands.c $(ROOT)/serial/commands_backup.c \ $(ROOT)/serial/commands_keys.c $(ROOT)/serial/commands_network.c \ $(ROOT)/serial/commands_system.c $(ROOT)/serial/console.c \ - $(ROOT)/storage/backup.c -# -# BLIND SPOTS - first-party files that CANNOT be host-compiled without heavy -# mocks (real Pico SDK / cyw43 / lwip / littlefs block device). They are -# enumerated (with reason) by coverage-summary.sh and counted as 0% toward the -# whole-codebase denominator - never silently excluded: -# network/ntp.c - pico/cyw43_arch.h + lwip/udp.h + lwip/dns.h -# network/wifi.c - pico/cyw43_arch.h -# storage/storage.c- pico/flash.h + hardware/flash.h + hardware/sync.h + lfs cfg + $(ROOT)/storage/backup.c $(ROOT)/storage/storage.c + +# LittleFS configuration mirrored from the firmware CMake build so storage.c +# compiles against the real lfs.h/lfs_util.h the same way the RP2040 build does. +COV_ZERO_DEFS := -DLFS_NO_MALLOC -DLFS_NO_DEBUG CC := gcc CSTD := -std=c11 @@ -122,8 +122,8 @@ $(BUILD)/vg_base64: $(BASE64_SRCS) | $(BUILD) # --- Coverage (whole-codebase denominator + lcov/genhtml HTML report) ------- # 1. Build & run the real harnesses (produce .gcda -> true coverage). -# 2. Compile every other host-compilable first-party .c (produce .gcno only -# -> true 0%), so untested files are visible as blind spots. +# 2. Compile EVERY other first-party .c (produce .gcno only -> true 0%), so +# the report covers the whole codebase uniformly - no file is left out. # 3. lcov: --initial baseline (all .gcno at 0%) + post-run capture, combined, # filtered to first-party only, rendered to coverage/html/ with branches. coverage: | $(COV_DIR)/obj @@ -132,11 +132,11 @@ coverage: | $(COV_DIR)/obj -o $(COV_DIR)/obj/cov_secret_qr $(CC) $(CSTD) $(WARN) $(COV_FLAGS) $(INCLUDES) $(BASE64_SRCS) \ -o $(COV_DIR)/obj/cov_base64 - @echo "== coverage: compiling untested first-party files (0% blind spots) ==" + @echo "== coverage: compiling every untested first-party file (true 0%) ==" @for f in $(COV_ZERO_SRCS); do \ o=$(COV_DIR)/obj/zero_$$(basename $$f .c).o; \ echo " CC (gcno) $$f"; \ - $(CC) $(CSTD) $(WARN) $(COV_FLAGS) $(INCLUDES) -c $$f -o $$o || exit 1; \ + $(CC) $(CSTD) $(WARN) $(COV_FLAGS) $(COV_ZERO_DEFS) $(INCLUDES) -c $$f -o $$o || exit 1; \ done @echo "== coverage: capturing initial (zero) baseline ==" $(LCOV) --capture --initial --directory $(COV_DIR)/obj \ diff --git a/test/coverage-summary.sh b/test/coverage-summary.sh index e3471e1..04c31f3 100755 --- a/test/coverage-summary.sh +++ b/test/coverage-summary.sh @@ -1,14 +1,19 @@ #!/usr/bin/env bash # -# coverage-summary.sh - whole-codebase coverage summary + blind-spot list. +# coverage-summary.sh - whole-codebase coverage summary. +# +# Every first-party firmware .c is compiled into the coverage build (the ones no +# harness exercises are compiled to a .gcno only, so they show up at their true +# 0% with full per-line detail). This means the lcov tracefile already covers +# the WHOLE first-party codebase - there is no separate hand-maintained blind +# spot list and no source-line approximation. All numbers below are derived +# entirely from the lcov .info. # # Reads a filtered lcov .info tracefile and the full first-party source list # (git ls-files, minus third-party submodules and the test harnesses) and emits: -# - overall first-party line% and branch% (blind spots folded into the -# line denominator as 0%, denominator stated explicitly), -# - the list of 0%-coverage files, -# - files below 100% branch coverage, worst-first (searchable blind spots), -# - the explicit BLIND SPOTS list (non-host-compilable files + reason). +# - overall first-party line% and branch% (straight from lcov), +# - the list of 0%-coverage files (real: lines-hit == 0), +# - files below 100% branch coverage, worst-first (searchable weak spots). # Outputs summary.txt and summary.json into , and a summary.html that # is surfaced at the top of the genhtml report (linked from index.html). # @@ -19,26 +24,6 @@ INFO=${1:?usage: coverage-summary.sh } OUT=${2:?usage: coverage-summary.sh } REPO=$(git -C .. rev-parse --show-toplevel) -# --- explicit BLIND SPOTS: files that cannot be host-compiled (with reason) -- -# Keep in sync with test/Makefile COV_ZERO_SRCS commentary. -BLIND_FILES=(network/ntp.c network/wifi.c storage/storage.c) -blind_reason() { - case "$1" in - network/ntp.c) echo "needs pico/cyw43_arch.h + lwip/udp.h + lwip/dns.h (no host stack)" ;; - network/wifi.c) echo "needs pico/cyw43_arch.h (cyw43 driver, no host stack)" ;; - storage/storage.c) echo "needs pico/flash.h + hardware/flash.h/sync.h + littlefs block device" ;; - *) echo "non-host-compilable" ;; - esac -} - -is_blind() { - local f - for f in "${BLIND_FILES[@]}"; do - [ "$f" = "$1" ] && return 0 - done - return 1 -} - # --- full first-party file list (denominator) ------------------------------- mapfile -t FIRST_PARTY < <( git -C "$REPO" ls-files '*.c' \ @@ -65,22 +50,6 @@ while IFS= read -r line; do esac done <"$INFO" -# --- approximate instrumentable line count for uncompiled (blind-spot) files - -# Counts non-blank lines that are not pure comments or lone braces. Conservative -# stand-in for gcov line count so blind spots weigh honestly in the denominator. -approx_lines() { - local f=$1 n=0 t - while IFS= read -r t; do - t=${t#"${t%%[![:space:]]*}"} # strip leading whitespace - [ -z "$t" ] && continue - case "$t" in - '//'* | '/*'* | '*'* | '{' | '}' | '};') continue ;; - esac - n=$((n + 1)) - done <"$REPO/$f" - echo "$n" -} - pct() { # hits found -> percentage string with one decimal local h=$1 f=$2 if [ "$f" -eq 0 ]; then @@ -90,40 +59,36 @@ pct() { # hits found -> percentage string with one decimal fi } -# --- aggregate -------------------------------------------------------------- +# --- aggregate (entirely from lcov data) ------------------------------------ tot_lf=0 tot_lh=0 tot_brf=0 tot_brh=0 -n_files=0 n_zero=0 n_blind=0 n_measured=0 n_exercised=0 -zero_list=() # "path" with 0% lines -below_list=() # "brpct|path|BRH|BRF" below 100% branch +n_files=0 n_zero=0 n_measured=0 n_exercised=0 n_missing=0 +zero_list=() # paths with 0% lines (real: lines-hit == 0) +missing_list=() # first-party files absent from the lcov data (should be none) +below_list=() # "brpct|path|BRH|BRF" below 100% branch for f in "${FIRST_PARTY[@]}"; do n_files=$((n_files + 1)) - if [ -n "${LF[$f]+x}" ]; then - # measured by lcov - n_measured=$((n_measured + 1)) - lf=${LF[$f]} lh=${LH[$f]} brf=${BRF[$f]} brh=${BRH[$f]} - tot_lf=$((tot_lf + lf)) - tot_lh=$((tot_lh + lh)) - tot_brf=$((tot_brf + brf)) - tot_brh=$((tot_brh + brh)) - if [ "$lh" -eq 0 ]; then - n_zero=$((n_zero + 1)) - zero_list+=("$f") - else - n_exercised=$((n_exercised + 1)) - fi - if [ "$brf" -gt 0 ] && [ "$brh" -lt "$brf" ]; then - p=$(awk -v h="$brh" -v f="$brf" 'BEGIN{printf "%07.3f",(h*100.0)/f}') - below_list+=("$p|$f|$brh|$brf") - fi - else - # not in lcov: blind spot (or otherwise uncompiled) -> 0% lines folded in - al=$(approx_lines "$f") - tot_lf=$((tot_lf + al)) + if [ -z "${LF[$f]+x}" ]; then + # Not in lcov at all - this file failed to compile into the coverage + # build. Flagged (not approximated) so the gap is loud, never hidden. + n_missing=$((n_missing + 1)) + missing_list+=("$f") + continue + fi + n_measured=$((n_measured + 1)) + lf=${LF[$f]} lh=${LH[$f]} brf=${BRF[$f]} brh=${BRH[$f]} + tot_lf=$((tot_lf + lf)) + tot_lh=$((tot_lh + lh)) + tot_brf=$((tot_brf + brf)) + tot_brh=$((tot_brh + brh)) + if [ "$lh" -eq 0 ]; then n_zero=$((n_zero + 1)) - if is_blind "$f"; then - n_blind=$((n_blind + 1)) - fi - zero_list+=("$f (0%, uncompiled ~${al} lines)") + zero_list+=("$f") + else + n_exercised=$((n_exercised + 1)) + fi + if [ "$brf" -gt 0 ] && [ "$brh" -lt "$brf" ]; then + p=$(awk -v h="$brh" -v f="$brf" 'BEGIN{printf "%07.3f",(h*100.0)/f}') + below_list+=("$p|$f|$brh|$brf") fi done @@ -141,19 +106,19 @@ TXT="$OUT/summary.txt" echo "============================" echo echo "First-party files (denominator): $n_files" - echo " host-compiled into coverage build: $n_measured" + echo " in coverage report (lcov): $n_measured" echo " actually exercised (>0% lines): $n_exercised" - echo " 0% coverage (untested + blind): $n_zero" - echo " non-host-compilable blind spots: $n_blind" + echo " compiled but not exercised (0%): $n_zero" + if [ "$n_missing" -gt 0 ]; then + echo " MISSING from coverage build: $n_missing" + fi echo echo "Overall LINE coverage: ${line_pct}% (${tot_lh}/${tot_lf} lines)" - echo " denominator folds blind-spot / uncompiled files in at 0%" - echo " (their instrumentable lines are approximated from source)." echo "Overall BRANCH coverage: ${br_pct}% (${tot_brh}/${tot_brf} branches)" - echo " branch denominator counts only host-compiled files; blind-spot" - echo " branches are unmeasured (see the blind-spot list below)." + echo " every first-party file is in the lcov data; these totals are the" + echo " real instrumented-line / -branch counts, no approximation." echo - echo "0%-COVERAGE FILES ($n_zero):" + echo "0%-COVERAGE FILES ($n_zero) - compiled for visibility, no harness yet:" if [ ${#zero_list[@]} -eq 0 ]; then echo " (none)" else @@ -169,11 +134,11 @@ TXT="$OUT/summary.txt" printf ' - %6.2f%% %s (%s/%s branches)\n' "$p" "$f" "$h" "$t" done fi - echo - echo "BLIND SPOTS (non-host-compilable, counted 0% in denominator):" - for f in "${BLIND_FILES[@]}"; do - printf ' - %s -- %s\n' "$f" "$(blind_reason "$f")" - done + if [ "$n_missing" -gt 0 ]; then + echo + echo "FILES MISSING FROM COVERAGE BUILD ($n_missing) - failed to compile:" + printf ' - %s\n' "${missing_list[@]}" + fi } >"$TXT" # --- summary.json ----------------------------------------------------------- @@ -184,7 +149,7 @@ JSON="$OUT/summary.json" printf ' "compiled_files": %s,\n' "$n_measured" printf ' "exercised_files": %s,\n' "$n_exercised" printf ' "zero_coverage_files": %s,\n' "$n_zero" - printf ' "blind_spot_files": %s,\n' "$n_blind" + printf ' "missing_files": %s,\n' "$n_missing" printf ' "line_pct": "%s",\n' "$line_pct" printf ' "lines_hit": %s,\n' "$tot_lh" printf ' "lines_found": %s,\n' "$tot_lf" @@ -198,10 +163,10 @@ JSON="$OUT/summary.json" sep=", " done printf '],\n' - printf ' "blind_spots": [' + printf ' "missing_files_list": [' sep="" - for f in "${BLIND_FILES[@]}"; do - printf '%s{"file": "%s", "reason": "%s"}' "$sep" "$f" "$(blind_reason "$f")" + for f in "${missing_list[@]}"; do + printf '%s"%s"' "$sep" "$f" sep=", " done printf ']\n}\n' @@ -224,11 +189,11 @@ if [ -d "$OUT/html" ]; then # Prepend a prominent banner + summary link at the top of genhtml's index. IDX="$OUT/html/index.html" if [ -f "$IDX" ] && ! grep -q 'hslock-summary-banner' "$IDX"; then - banner='
Whole-codebase coverage: '"${line_pct}"'% lines, '"${br_pct}"'% branches over '"${n_files}"' first-party files ('"${n_zero}"' at 0%, '"${n_blind}"' non-host-compilable blind spots).  full summary & blind-spot list →
' + banner='
Whole-codebase coverage: '"${line_pct}"'% lines, '"${br_pct}"'% branches over '"${n_files}"' first-party files ('"${n_zero}"' at 0%, all in the report).  full summary →
' awk -v b="$banner" '/"$IDX.tmp" && mv "$IDX.tmp" "$IDX" fi fi echo "summary written: $TXT, $JSON" -echo " line ${line_pct}% (${tot_lh}/${tot_lf}), branch ${br_pct}% (${tot_brh}/${tot_brf}), ${n_zero} zero-cov, ${n_blind} blind spots" +echo " line ${line_pct}% (${tot_lh}/${tot_lf}), branch ${br_pct}% (${tot_brh}/${tot_brf}), ${n_zero} zero-cov, ${n_missing} missing" diff --git a/test/stub/hardware/flash.h b/test/stub/hardware/flash.h new file mode 100644 index 0000000..9548cd7 --- /dev/null +++ b/test/stub/hardware/flash.h @@ -0,0 +1,40 @@ +#ifndef HSLOCK_TEST_STUB_HARDWARE_FLASH_H +#define HSLOCK_TEST_STUB_HARDWARE_FLASH_H +/* + * Host stub for the Pico SDK "hardware/flash.h". Supplies the flash geometry + * macros and no-op erase/program shims that storage/storage.c references, so + * the file COMPILES natively for coverage (.gcno at true 0%). Never linked or + * executed - the values only need to be well-formed, not hardware-accurate. + */ +#include +#include + +// Flash geometry (RP2040 QSPI NOR). +#define FLASH_PAGE_SIZE 256u +#define FLASH_SECTOR_SIZE 4096u +#define FLASH_BLOCK_SIZE 65536u + +// Execute-in-place window base and total flash size (Pico W: 2 MB). +#ifndef XIP_BASE +#define XIP_BASE 0x10000000u +#endif +#ifndef PICO_FLASH_SIZE_BYTES +#define PICO_FLASH_SIZE_BYTES (2u * 1024u * 1024u) +#endif + +// RAM-resident attribute for flash-locked callbacks (no-op on host). +#ifndef __no_inline_not_in_flash_func +#define __no_inline_not_in_flash_func(name) name +#endif + +static inline void flash_range_erase(uint32_t flash_offs, size_t count) { + (void)flash_offs; + (void)count; +} +static inline void flash_range_program(uint32_t flash_offs, const uint8_t *data, size_t count) { + (void)flash_offs; + (void)data; + (void)count; +} + +#endif diff --git a/test/stub/hardware/rtc.h b/test/stub/hardware/rtc.h index 933fabf..f2fb076 100644 --- a/test/stub/hardware/rtc.h +++ b/test/stub/hardware/rtc.h @@ -14,6 +14,8 @@ typedef struct { int8_t sec; } datetime_t; +static inline void rtc_init(void) { +} static inline bool rtc_get_datetime(datetime_t *t) { (void)t; return false; diff --git a/test/stub/hardware/sync.h b/test/stub/hardware/sync.h new file mode 100644 index 0000000..ab13993 --- /dev/null +++ b/test/stub/hardware/sync.h @@ -0,0 +1,19 @@ +#ifndef HSLOCK_TEST_STUB_HARDWARE_SYNC_H +#define HSLOCK_TEST_STUB_HARDWARE_SYNC_H +/* + * Host stub for the Pico SDK "hardware/sync.h" - no-op interrupt save/restore. + * Present only so first-party firmware .c files that include it COMPILE for the + * coverage build; never linked into a running harness. + */ +#include + +static inline uint32_t save_and_disable_interrupts(void) { + return 0; +} +static inline void restore_interrupts(uint32_t status) { + (void)status; +} +static inline void __dmb(void) { +} + +#endif diff --git a/test/stub/lwip/arch.h b/test/stub/lwip/arch.h new file mode 100644 index 0000000..d3b607a --- /dev/null +++ b/test/stub/lwip/arch.h @@ -0,0 +1,17 @@ +#ifndef HSLOCK_TEST_STUB_LWIP_ARCH_H +#define HSLOCK_TEST_STUB_LWIP_ARCH_H +/* + * Host stub for lwIP's "lwip/arch.h" - the fixed-width integer aliases lwIP + * spreads through its public headers. Only enough for network/ntp.c to COMPILE + * natively for the coverage build; never linked into a running harness. + */ +#include + +typedef uint8_t u8_t; +typedef int8_t s8_t; +typedef uint16_t u16_t; +typedef int16_t s16_t; +typedef uint32_t u32_t; +typedef int32_t s32_t; + +#endif diff --git a/test/stub/lwip/dns.h b/test/stub/lwip/dns.h new file mode 100644 index 0000000..60f0a8b --- /dev/null +++ b/test/stub/lwip/dns.h @@ -0,0 +1,16 @@ +#ifndef HSLOCK_TEST_STUB_LWIP_DNS_H +#define HSLOCK_TEST_STUB_LWIP_DNS_H +/* + * Host stub for "lwip/dns.h" - dns_gethostbyname() plus its resolve-callback + * signature, as referenced by network/ntp.c. Compile-only declaration; never + * linked into a running harness. + */ +#include "lwip/err.h" +#include "lwip/ip_addr.h" + +typedef void (*dns_found_callback)(const char *name, const ip_addr_t *ipaddr, void *callback_arg); + +err_t dns_gethostbyname(const char *hostname, ip_addr_t *addr, dns_found_callback found, + void *callback_arg); + +#endif diff --git a/test/stub/lwip/err.h b/test/stub/lwip/err.h new file mode 100644 index 0000000..ea2d5fe --- /dev/null +++ b/test/stub/lwip/err.h @@ -0,0 +1,11 @@ +#ifndef HSLOCK_TEST_STUB_LWIP_ERR_H +#define HSLOCK_TEST_STUB_LWIP_ERR_H +/* Host stub for "lwip/err.h" - err_t plus the codes network/ntp.c checks. */ +#include "lwip/arch.h" + +typedef s8_t err_t; + +#define ERR_OK 0 // no error +#define ERR_INPROGRESS -5 // operation in progress + +#endif diff --git a/test/stub/lwip/ip_addr.h b/test/stub/lwip/ip_addr.h new file mode 100644 index 0000000..84194b7 --- /dev/null +++ b/test/stub/lwip/ip_addr.h @@ -0,0 +1,12 @@ +#ifndef HSLOCK_TEST_STUB_LWIP_IP_ADDR_H +#define HSLOCK_TEST_STUB_LWIP_IP_ADDR_H +/* Host stub for "lwip/ip_addr.h" - opaque ip_addr_t + the type selector. */ +#include "lwip/arch.h" + +typedef struct { + u32_t addr; +} ip_addr_t; + +#define IPADDR_TYPE_ANY 46 + +#endif diff --git a/test/stub/lwip/pbuf.h b/test/stub/lwip/pbuf.h new file mode 100644 index 0000000..4daebdf --- /dev/null +++ b/test/stub/lwip/pbuf.h @@ -0,0 +1,38 @@ +#ifndef HSLOCK_TEST_STUB_LWIP_PBUF_H +#define HSLOCK_TEST_STUB_LWIP_PBUF_H +/* + * Host stub for "lwip/pbuf.h" - the packet buffer type and helpers referenced + * by network/ntp.c. Compile-only: shapes are minimal, functions are declared + * (not defined) since the file is never linked into a running harness. + */ +#include + +#include "lwip/arch.h" + +// Buffer layer / type selectors (values are not load-bearing on the host). +typedef enum { + PBUF_TRANSPORT, + PBUF_IP, + PBUF_LINK, + PBUF_RAW, +} pbuf_layer; + +typedef enum { + PBUF_RAM, + PBUF_POOL, + PBUF_ROM, + PBUF_REF, +} pbuf_type; + +struct pbuf { + struct pbuf *next; + void *payload; + u16_t tot_len; + u16_t len; +}; + +struct pbuf *pbuf_alloc(pbuf_layer layer, u16_t length, pbuf_type type); +u8_t pbuf_free(struct pbuf *p); +u16_t pbuf_copy_partial(const struct pbuf *p, void *dataptr, u16_t len, u16_t offset); + +#endif diff --git a/test/stub/lwip/udp.h b/test/stub/lwip/udp.h new file mode 100644 index 0000000..8397b1c --- /dev/null +++ b/test/stub/lwip/udp.h @@ -0,0 +1,26 @@ +#ifndef HSLOCK_TEST_STUB_LWIP_UDP_H +#define HSLOCK_TEST_STUB_LWIP_UDP_H +/* + * Host stub for "lwip/udp.h" - the UDP PCB type, receive-callback signature and + * the udp_* entry points network/ntp.c uses. Compile-only (declarations, no + * definitions); never linked into a running harness. + */ +#include "lwip/arch.h" +#include "lwip/err.h" +#include "lwip/ip_addr.h" +#include "lwip/pbuf.h" + +struct udp_pcb { + int _dummy; +}; + +typedef void (*udp_recv_fn)(void *arg, struct udp_pcb *pcb, struct pbuf *p, const ip_addr_t *addr, + u16_t port); + +struct udp_pcb *udp_new_ip_type(u8_t type); +void udp_remove(struct udp_pcb *pcb); +void udp_recv(struct udp_pcb *pcb, udp_recv_fn recv, void *recv_arg); +err_t udp_sendto(struct udp_pcb *pcb, struct pbuf *p, const ip_addr_t *dst_ip, + u16_t dst_port); + +#endif diff --git a/test/stub/pico/cyw43_arch.h b/test/stub/pico/cyw43_arch.h new file mode 100644 index 0000000..9c151bd --- /dev/null +++ b/test/stub/pico/cyw43_arch.h @@ -0,0 +1,39 @@ +#ifndef HSLOCK_TEST_STUB_PICO_CYW43_ARCH_H +#define HSLOCK_TEST_STUB_PICO_CYW43_ARCH_H +/* + * Host stub for the Pico SDK "pico/cyw43_arch.h" - the CYW43 WiFi driver arch + * layer. Declares just the entry points, enums and the global driver state that + * network/wifi.c and network/ntp.c reference, so both COMPILE natively for the + * coverage build (.gcno at true 0%). Never linked into a running harness. + */ +#include + +// Auth modes. +#define CYW43_AUTH_OPEN 0 +#define CYW43_AUTH_WPA2_AES_PSK 0x00400004 + +// Interface / link-status enums. +#define CYW43_ITF_STA 0 +#define CYW43_ITF_AP 1 + +#define CYW43_LINK_DOWN 0 +#define CYW43_LINK_JOIN 1 +#define CYW43_LINK_UP 3 + +// Opaque driver state object; only its address is taken on the host. +typedef struct { + int _dummy; +} cyw43_t; +extern cyw43_t cyw43_state; + +int cyw43_arch_init(void); +void cyw43_arch_deinit(void); +void cyw43_arch_enable_sta_mode(void); +int cyw43_arch_wifi_connect_timeout_ms(const char *ssid, const char *pw, uint32_t auth, + uint32_t timeout_ms); +int cyw43_tcpip_link_status(cyw43_t *self, int itf); +void cyw43_arch_poll(void); +void cyw43_arch_lwip_begin(void); +void cyw43_arch_lwip_end(void); + +#endif diff --git a/test/stub/pico/flash.h b/test/stub/pico/flash.h new file mode 100644 index 0000000..06fd7e7 --- /dev/null +++ b/test/stub/pico/flash.h @@ -0,0 +1,22 @@ +#ifndef HSLOCK_TEST_STUB_PICO_FLASH_H +#define HSLOCK_TEST_STUB_PICO_FLASH_H +/* + * Host stub for the Pico SDK "pico/flash.h" - flash_safe_execute() shim used by + * storage/storage.c to run erase/program callbacks with the other core paused. + * On the host it just invokes the callback so the file COMPILES for coverage; + * never linked into a running harness. + */ +#include + +#ifndef PICO_OK +#define PICO_OK 0 +#endif + +static inline int flash_safe_execute(void (*func)(void *), void *param, uint32_t timeout_ms) { + (void)func; + (void)param; + (void)timeout_ms; + return PICO_OK; +} + +#endif diff --git a/test/stub/pico/stdlib.h b/test/stub/pico/stdlib.h index 59c0d87..65656df 100644 --- a/test/stub/pico/stdlib.h +++ b/test/stub/pico/stdlib.h @@ -10,6 +10,8 @@ #include #include +#include "pico/time.h" /* sleep_ms / sleep_us / time_us_64 live here, as in the SDK */ + typedef unsigned int uint; #define GPIO_OUT 1 @@ -34,12 +36,6 @@ static inline int gpio_get(uint pin) { static inline void gpio_pull_up(uint pin) { (void)pin; } -static inline void sleep_ms(uint32_t ms) { - (void)ms; -} -static inline void sleep_us(uint64_t us) { - (void)us; -} static inline void stdio_init_all(void) { } static inline int getchar_timeout_us(uint32_t timeout) { diff --git a/test/stub/pico/time.h b/test/stub/pico/time.h index 09bb068..31c839e 100644 --- a/test/stub/pico/time.h +++ b/test/stub/pico/time.h @@ -16,5 +16,14 @@ static inline bool time_reached(absolute_time_t t) { (void)t; return true; } +static inline uint64_t time_us_64(void) { + return 0; +} +static inline void sleep_ms(uint32_t ms) { + (void)ms; +} +static inline void sleep_us(uint64_t us) { + (void)us; +} #endif From fc8c4f3d818d0c7732035fb0008225cc00be3e8a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 10:03:07 +0200 Subject: [PATCH 16/19] test: clang-format the new cyw43/lwip host stubs Fix macro-alignment and function-signature wrapping in the stubs added in the previous commit so `./ci --check=lint` passes on the now-tracked headers. Co-Authored-By: Claude Opus 4.8 (1M context) --- test/stub/lwip/udp.h | 3 +-- test/stub/pico/cyw43_arch.h | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/test/stub/lwip/udp.h b/test/stub/lwip/udp.h index 8397b1c..b21f736 100644 --- a/test/stub/lwip/udp.h +++ b/test/stub/lwip/udp.h @@ -20,7 +20,6 @@ typedef void (*udp_recv_fn)(void *arg, struct udp_pcb *pcb, struct pbuf *p, cons struct udp_pcb *udp_new_ip_type(u8_t type); void udp_remove(struct udp_pcb *pcb); void udp_recv(struct udp_pcb *pcb, udp_recv_fn recv, void *recv_arg); -err_t udp_sendto(struct udp_pcb *pcb, struct pbuf *p, const ip_addr_t *dst_ip, - u16_t dst_port); +err_t udp_sendto(struct udp_pcb *pcb, struct pbuf *p, const ip_addr_t *dst_ip, u16_t dst_port); #endif diff --git a/test/stub/pico/cyw43_arch.h b/test/stub/pico/cyw43_arch.h index 9c151bd..1176962 100644 --- a/test/stub/pico/cyw43_arch.h +++ b/test/stub/pico/cyw43_arch.h @@ -9,8 +9,8 @@ #include // Auth modes. -#define CYW43_AUTH_OPEN 0 -#define CYW43_AUTH_WPA2_AES_PSK 0x00400004 +#define CYW43_AUTH_OPEN 0 +#define CYW43_AUTH_WPA2_AES_PSK 0x00400004 // Interface / link-status enums. #define CYW43_ITF_STA 0 From cbe63ecf1e53b2c97d9b625b1201517dc2c233f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 10:17:21 +0200 Subject: [PATCH 17/19] test: drop coverage-summary.sh; rely on genhtml's own report The summary script was a pure presentation layer (whole-codebase % headline + curated worst-first blind-spot list) on top of the lcov data. genhtml's own index already shows the 19-file overall totals and a sortable per-file table with the 0% files visible, so the report still covers everything. Removing the 199-line script keeps the patch minimal. Co-Authored-By: Claude Opus 4.8 (1M context) --- test/coverage-summary.sh | 199 --------------------------------------- 1 file changed, 199 deletions(-) delete mode 100755 test/coverage-summary.sh diff --git a/test/coverage-summary.sh b/test/coverage-summary.sh deleted file mode 100755 index 04c31f3..0000000 --- a/test/coverage-summary.sh +++ /dev/null @@ -1,199 +0,0 @@ -#!/usr/bin/env bash -# -# coverage-summary.sh - whole-codebase coverage summary. -# -# Every first-party firmware .c is compiled into the coverage build (the ones no -# harness exercises are compiled to a .gcno only, so they show up at their true -# 0% with full per-line detail). This means the lcov tracefile already covers -# the WHOLE first-party codebase - there is no separate hand-maintained blind -# spot list and no source-line approximation. All numbers below are derived -# entirely from the lcov .info. -# -# Reads a filtered lcov .info tracefile and the full first-party source list -# (git ls-files, minus third-party submodules and the test harnesses) and emits: -# - overall first-party line% and branch% (straight from lcov), -# - the list of 0%-coverage files (real: lines-hit == 0), -# - files below 100% branch coverage, worst-first (searchable weak spots). -# Outputs summary.txt and summary.json into , and a summary.html that -# is surfaced at the top of the genhtml report (linked from index.html). -# -# Usage: ./coverage-summary.sh -set -euo pipefail - -INFO=${1:?usage: coverage-summary.sh } -OUT=${2:?usage: coverage-summary.sh } -REPO=$(git -C .. rev-parse --show-toplevel) - -# --- full first-party file list (denominator) ------------------------------- -mapfile -t FIRST_PARTY < <( - git -C "$REPO" ls-files '*.c' \ - ':!:libs/qrcodegen/**' ':!:libs/littlefs/**' ':!:test/**' | sort -) - -# --- parse lcov .info: per repo-relative file -> LF LH BRF BRH --------------- -declare -A LF LH BRF BRH -cur="" -while IFS= read -r line; do - case "$line" in - SF:*) - path=${line#SF:} - cur=${path#"$REPO"/} - LF[$cur]=0 - LH[$cur]=0 - BRF[$cur]=0 - BRH[$cur]=0 - ;; - LF:*) LF[$cur]=${line#LF:} ;; - LH:*) LH[$cur]=${line#LH:} ;; - BRF:*) BRF[$cur]=${line#BRF:} ;; - BRH:*) BRH[$cur]=${line#BRH:} ;; - esac -done <"$INFO" - -pct() { # hits found -> percentage string with one decimal - local h=$1 f=$2 - if [ "$f" -eq 0 ]; then - echo "n/a" - else - awk -v h="$h" -v f="$f" 'BEGIN{printf "%.1f", (h*100.0)/f}' - fi -} - -# --- aggregate (entirely from lcov data) ------------------------------------ -tot_lf=0 tot_lh=0 tot_brf=0 tot_brh=0 -n_files=0 n_zero=0 n_measured=0 n_exercised=0 n_missing=0 -zero_list=() # paths with 0% lines (real: lines-hit == 0) -missing_list=() # first-party files absent from the lcov data (should be none) -below_list=() # "brpct|path|BRH|BRF" below 100% branch -for f in "${FIRST_PARTY[@]}"; do - n_files=$((n_files + 1)) - if [ -z "${LF[$f]+x}" ]; then - # Not in lcov at all - this file failed to compile into the coverage - # build. Flagged (not approximated) so the gap is loud, never hidden. - n_missing=$((n_missing + 1)) - missing_list+=("$f") - continue - fi - n_measured=$((n_measured + 1)) - lf=${LF[$f]} lh=${LH[$f]} brf=${BRF[$f]} brh=${BRH[$f]} - tot_lf=$((tot_lf + lf)) - tot_lh=$((tot_lh + lh)) - tot_brf=$((tot_brf + brf)) - tot_brh=$((tot_brh + brh)) - if [ "$lh" -eq 0 ]; then - n_zero=$((n_zero + 1)) - zero_list+=("$f") - else - n_exercised=$((n_exercised + 1)) - fi - if [ "$brf" -gt 0 ] && [ "$brh" -lt "$brf" ]; then - p=$(awk -v h="$brh" -v f="$brf" 'BEGIN{printf "%07.3f",(h*100.0)/f}') - below_list+=("$p|$f|$brh|$brf") - fi -done - -line_pct=$(pct "$tot_lh" "$tot_lf") -br_pct=$(pct "$tot_brh" "$tot_brf") - -# worst-first branch list -mapfile -t below_sorted < <(printf '%s\n' "${below_list[@]}" | sort -t'|' -k1,1n) - -# --- summary.txt ------------------------------------------------------------ -mkdir -p "$OUT" -TXT="$OUT/summary.txt" -{ - echo "hslock host coverage summary" - echo "============================" - echo - echo "First-party files (denominator): $n_files" - echo " in coverage report (lcov): $n_measured" - echo " actually exercised (>0% lines): $n_exercised" - echo " compiled but not exercised (0%): $n_zero" - if [ "$n_missing" -gt 0 ]; then - echo " MISSING from coverage build: $n_missing" - fi - echo - echo "Overall LINE coverage: ${line_pct}% (${tot_lh}/${tot_lf} lines)" - echo "Overall BRANCH coverage: ${br_pct}% (${tot_brh}/${tot_brf} branches)" - echo " every first-party file is in the lcov data; these totals are the" - echo " real instrumented-line / -branch counts, no approximation." - echo - echo "0%-COVERAGE FILES ($n_zero) - compiled for visibility, no harness yet:" - if [ ${#zero_list[@]} -eq 0 ]; then - echo " (none)" - else - printf ' - %s\n' "${zero_list[@]}" - fi - echo - echo "FILES BELOW 100% BRANCH COVERAGE (worst-first):" - if [ ${#below_sorted[@]} -eq 0 ]; then - echo " (none among measured files)" - else - for row in "${below_sorted[@]}"; do - IFS='|' read -r p f h t <<<"$row" - printf ' - %6.2f%% %s (%s/%s branches)\n' "$p" "$f" "$h" "$t" - done - fi - if [ "$n_missing" -gt 0 ]; then - echo - echo "FILES MISSING FROM COVERAGE BUILD ($n_missing) - failed to compile:" - printf ' - %s\n' "${missing_list[@]}" - fi -} >"$TXT" - -# --- summary.json ----------------------------------------------------------- -JSON="$OUT/summary.json" -{ - printf '{\n' - printf ' "first_party_files": %s,\n' "$n_files" - printf ' "compiled_files": %s,\n' "$n_measured" - printf ' "exercised_files": %s,\n' "$n_exercised" - printf ' "zero_coverage_files": %s,\n' "$n_zero" - printf ' "missing_files": %s,\n' "$n_missing" - printf ' "line_pct": "%s",\n' "$line_pct" - printf ' "lines_hit": %s,\n' "$tot_lh" - printf ' "lines_found": %s,\n' "$tot_lf" - printf ' "branch_pct": "%s",\n' "$br_pct" - printf ' "branches_hit": %s,\n' "$tot_brh" - printf ' "branches_found": %s,\n' "$tot_brf" - printf ' "zero_files": [' - sep="" - for f in "${zero_list[@]}"; do - printf '%s"%s"' "$sep" "$f" - sep=", " - done - printf '],\n' - printf ' "missing_files_list": [' - sep="" - for f in "${missing_list[@]}"; do - printf '%s"%s"' "$sep" "$f" - sep=", " - done - printf ']\n}\n' -} >"$JSON" - -# --- summary.html (surfaced at top of the Pages report) --------------------- -if [ -d "$OUT/html" ]; then - HTML="$OUT/html/summary.html" - { - echo 'hslock coverage summary' - echo '' - echo '

hslock host coverage summary

' - echo '

→ full per-file / per-line report

' - echo '
'
-		sed 's/&/\&/g; s//\>/g' "$TXT"
-		echo '
' - } >"$HTML" - # Prepend a prominent banner + summary link at the top of genhtml's index. - IDX="$OUT/html/index.html" - if [ -f "$IDX" ] && ! grep -q 'hslock-summary-banner' "$IDX"; then - banner='
Whole-codebase coverage: '"${line_pct}"'% lines, '"${br_pct}"'% branches over '"${n_files}"' first-party files ('"${n_zero}"' at 0%, all in the report).  full summary →
' - awk -v b="$banner" '/"$IDX.tmp" && - mv "$IDX.tmp" "$IDX" - fi -fi - -echo "summary written: $TXT, $JSON" -echo " line ${line_pct}% (${tot_lh}/${tot_lf}), branch ${br_pct}% (${tot_brh}/${tot_brf}), ${n_zero} zero-cov, ${n_missing} missing" From 1fcedcca8bc9b957cc776fbbbc3a311bc166fadc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 10:17:37 +0200 Subject: [PATCH 18/19] test: remove coverage-summary.sh call from Makefile (fix prior partial commit) The previous commit recorded only the script deletion (git add aborted on the already-removed pathspec), leaving the Makefile still invoking the deleted script. Drop the invocation so the coverage target ends at genhtml. Co-Authored-By: Claude Opus 4.8 (1M context) --- test/Makefile | 2 -- 1 file changed, 2 deletions(-) diff --git a/test/Makefile b/test/Makefile index d2c5449..7132bae 100644 --- a/test/Makefile +++ b/test/Makefile @@ -159,8 +159,6 @@ coverage: | $(COV_DIR)/obj genhtml $(LCOV_RC) $(GENHTML_IGNORE) --branch-coverage \ --legend --title "hslock host coverage" \ --output-directory $(COV_DIR)/html $(COV_DIR)/coverage.info - @echo "== coverage: generating summary (txt + json) ==" - ./coverage-summary.sh $(COV_DIR)/coverage.info $(COV_DIR) @echo "== coverage OK: report at $(COV_DIR)/html/index.html ==" $(BUILD): From 59b56e84d0734a2c9a21dbc878601ff354b17af1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 12:02:22 +0200 Subject: [PATCH 19/19] test: slim coverage to harness-exercised modules only Drop the whole-codebase 0%-coverage "fold-in" scaffolding. The coverage build previously compiled every first-party .c (via ~18 host stub headers under test/stub/) purely so untested files surfaced at 0% in the report. Coverage now reports exactly the modules the harnesses exercise: base32.c, base64.c, random.c. The HW-only firmware (ntp/wifi/storage/serial/...) is no longer built host-side and is simply absent from the report -- a focused, honest denominator instead of a padded one. Changes: - Remove COV_ZERO_SRCS / COV_ZERO_DEFS and the loop compiling untested files to .gcno, plus the --initial baseline capture and base/total.info merge that only existed to surface those compiled-but-unrun files. - Delete every test/stub header except pico/rand.h, the only stub the real harnesses need (shared/random.h -> pico/rand.h -> get_rand_64). Kept: REPO_ROOT derivation, branch coverage, lcov 2.x --ignore-errors handling, harness INCLUDES (still -Istub for pico/rand.h), asan/valgrind. Co-Authored-By: Claude Opus 4.8 (1M context) --- test/Makefile | 50 ++++++----------------------------- test/stub/hardware/flash.h | 40 ---------------------------- test/stub/hardware/rtc.h | 28 -------------------- test/stub/hardware/sync.h | 19 ------------- test/stub/hardware/watchdog.h | 12 --------- test/stub/lwip/arch.h | 17 ------------ test/stub/lwip/dns.h | 16 ----------- test/stub/lwip/err.h | 11 -------- test/stub/lwip/ip_addr.h | 12 --------- test/stub/lwip/pbuf.h | 38 -------------------------- test/stub/lwip/udp.h | 25 ------------------ test/stub/pico/cyw43_arch.h | 39 --------------------------- test/stub/pico/flash.h | 22 --------------- test/stub/pico/multicore.h | 18 ------------- test/stub/pico/stdio_usb.h | 10 ------- test/stub/pico/stdlib.h | 48 --------------------------------- test/stub/pico/time.h | 29 -------------------- test/stub/version.h | 8 ------ 18 files changed, 8 insertions(+), 434 deletions(-) delete mode 100644 test/stub/hardware/flash.h delete mode 100644 test/stub/hardware/rtc.h delete mode 100644 test/stub/hardware/sync.h delete mode 100644 test/stub/hardware/watchdog.h delete mode 100644 test/stub/lwip/arch.h delete mode 100644 test/stub/lwip/dns.h delete mode 100644 test/stub/lwip/err.h delete mode 100644 test/stub/lwip/ip_addr.h delete mode 100644 test/stub/lwip/pbuf.h delete mode 100644 test/stub/lwip/udp.h delete mode 100644 test/stub/pico/cyw43_arch.h delete mode 100644 test/stub/pico/flash.h delete mode 100644 test/stub/pico/multicore.h delete mode 100644 test/stub/pico/stdio_usb.h delete mode 100644 test/stub/pico/stdlib.h delete mode 100644 test/stub/pico/time.h delete mode 100644 test/stub/version.h diff --git a/test/Makefile b/test/Makefile index 7132bae..0e567ae 100644 --- a/test/Makefile +++ b/test/Makefile @@ -7,7 +7,7 @@ # # make asan ASan + UBSan, aborts on any error (CI gate) # make valgrind plain build under valgrind, fails on error/leak -# make coverage --coverage build over the WHOLE first-party codebase, run, +# make coverage --coverage build of the harness-exercised modules, run, # lcov + genhtml report (branch coverage) into coverage/html/ # make clean # @@ -45,29 +45,6 @@ SECRET_QR_SRCS := harness_secret_qr.c $(SHARED)/random.c $(BASE32)/base32.c \ $(QRCODEGEN)/qrcodegen.c BASE64_SRCS := harness_base64.c $(BASE64)/base64.c -# --------------------------------------------------------------------------- -# Whole-codebase coverage denominator (uniform 0% for every untested file) -# --------------------------------------------------------------------------- -# COV_ZERO_SRCS: EVERY first-party firmware .c not exercised by a harness. -# All of them COMPILE host-side against the stubs in test/stub/ (including the -# cyw43 / lwip / flash shims that let network/*.c and storage/storage.c build -# without the real Pico SDK). Compiling emits a .gcno (but no .gcda), so lcov -# reports each file's true 0% coverage with full per-line detail instead of -# silently dropping it - so the report covers the WHOLE codebase uniformly, -# with no separate hand-maintained "blind spot" list or line approximation. -COV_ZERO_SRCS := $(ROOT)/hardware/buzzer.c $(ROOT)/hardware/clock.c \ - $(ROOT)/hardware/keypad.c $(ROOT)/hardware/latch.c \ - $(ROOT)/hardware/light.c $(ROOT)/main.c \ - $(ROOT)/network/ntp.c $(ROOT)/network/wifi.c \ - $(ROOT)/serial/commands.c $(ROOT)/serial/commands_backup.c \ - $(ROOT)/serial/commands_keys.c $(ROOT)/serial/commands_network.c \ - $(ROOT)/serial/commands_system.c $(ROOT)/serial/console.c \ - $(ROOT)/storage/backup.c $(ROOT)/storage/storage.c - -# LittleFS configuration mirrored from the firmware CMake build so storage.c -# compiles against the real lfs.h/lfs_util.h the same way the RP2040 build does. -COV_ZERO_DEFS := -DLFS_NO_MALLOC -DLFS_NO_DEBUG - CC := gcc CSTD := -std=c11 WARN := -Wall -Wextra @@ -120,37 +97,26 @@ $(BUILD)/vg_secret_qr: $(SECRET_QR_SRCS) | $(BUILD) $(BUILD)/vg_base64: $(BASE64_SRCS) | $(BUILD) $(CC) $(CSTD) $(WARN) $(VG_FLAGS) $(INCLUDES) $(BASE64_SRCS) -o $@ -# --- Coverage (whole-codebase denominator + lcov/genhtml HTML report) ------- -# 1. Build & run the real harnesses (produce .gcda -> true coverage). -# 2. Compile EVERY other first-party .c (produce .gcno only -> true 0%), so -# the report covers the whole codebase uniformly - no file is left out. -# 3. lcov: --initial baseline (all .gcno at 0%) + post-run capture, combined, -# filtered to first-party only, rendered to coverage/html/ with branches. +# --- 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: compiling every untested first-party file (true 0%) ==" - @for f in $(COV_ZERO_SRCS); do \ - o=$(COV_DIR)/obj/zero_$$(basename $$f .c).o; \ - echo " CC (gcno) $$f"; \ - $(CC) $(CSTD) $(WARN) $(COV_FLAGS) $(COV_ZERO_DEFS) $(INCLUDES) -c $$f -o $$o || exit 1; \ - done - @echo "== coverage: capturing initial (zero) baseline ==" - $(LCOV) --capture --initial --directory $(COV_DIR)/obj \ - --output-file $(COV_DIR)/base.info @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 - $(LCOV) --add-tracefile $(COV_DIR)/base.info \ - --add-tracefile $(COV_DIR)/run.info --output-file $(COV_DIR)/total.info @echo "== coverage: filtering to first-party sources ==" - $(LCOV) --extract $(COV_DIR)/total.info '$(REPO_ROOT)/*' \ + $(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/*' \ diff --git a/test/stub/hardware/flash.h b/test/stub/hardware/flash.h deleted file mode 100644 index 9548cd7..0000000 --- a/test/stub/hardware/flash.h +++ /dev/null @@ -1,40 +0,0 @@ -#ifndef HSLOCK_TEST_STUB_HARDWARE_FLASH_H -#define HSLOCK_TEST_STUB_HARDWARE_FLASH_H -/* - * Host stub for the Pico SDK "hardware/flash.h". Supplies the flash geometry - * macros and no-op erase/program shims that storage/storage.c references, so - * the file COMPILES natively for coverage (.gcno at true 0%). Never linked or - * executed - the values only need to be well-formed, not hardware-accurate. - */ -#include -#include - -// Flash geometry (RP2040 QSPI NOR). -#define FLASH_PAGE_SIZE 256u -#define FLASH_SECTOR_SIZE 4096u -#define FLASH_BLOCK_SIZE 65536u - -// Execute-in-place window base and total flash size (Pico W: 2 MB). -#ifndef XIP_BASE -#define XIP_BASE 0x10000000u -#endif -#ifndef PICO_FLASH_SIZE_BYTES -#define PICO_FLASH_SIZE_BYTES (2u * 1024u * 1024u) -#endif - -// RAM-resident attribute for flash-locked callbacks (no-op on host). -#ifndef __no_inline_not_in_flash_func -#define __no_inline_not_in_flash_func(name) name -#endif - -static inline void flash_range_erase(uint32_t flash_offs, size_t count) { - (void)flash_offs; - (void)count; -} -static inline void flash_range_program(uint32_t flash_offs, const uint8_t *data, size_t count) { - (void)flash_offs; - (void)data; - (void)count; -} - -#endif diff --git a/test/stub/hardware/rtc.h b/test/stub/hardware/rtc.h deleted file mode 100644 index f2fb076..0000000 --- a/test/stub/hardware/rtc.h +++ /dev/null @@ -1,28 +0,0 @@ -#ifndef HSLOCK_TEST_STUB_HARDWARE_RTC_H -#define HSLOCK_TEST_STUB_HARDWARE_RTC_H -/* Host stub for the Pico SDK "hardware/rtc.h" - datetime_t + no-op RTC. */ -#include -#include - -typedef struct { - int16_t year; - int8_t month; - int8_t day; - int8_t dotw; - int8_t hour; - int8_t min; - int8_t sec; -} datetime_t; - -static inline void rtc_init(void) { -} -static inline bool rtc_get_datetime(datetime_t *t) { - (void)t; - return false; -} -static inline bool rtc_set_datetime(const datetime_t *t) { - (void)t; - return true; -} - -#endif diff --git a/test/stub/hardware/sync.h b/test/stub/hardware/sync.h deleted file mode 100644 index ab13993..0000000 --- a/test/stub/hardware/sync.h +++ /dev/null @@ -1,19 +0,0 @@ -#ifndef HSLOCK_TEST_STUB_HARDWARE_SYNC_H -#define HSLOCK_TEST_STUB_HARDWARE_SYNC_H -/* - * Host stub for the Pico SDK "hardware/sync.h" - no-op interrupt save/restore. - * Present only so first-party firmware .c files that include it COMPILE for the - * coverage build; never linked into a running harness. - */ -#include - -static inline uint32_t save_and_disable_interrupts(void) { - return 0; -} -static inline void restore_interrupts(uint32_t status) { - (void)status; -} -static inline void __dmb(void) { -} - -#endif diff --git a/test/stub/hardware/watchdog.h b/test/stub/hardware/watchdog.h deleted file mode 100644 index 413e678..0000000 --- a/test/stub/hardware/watchdog.h +++ /dev/null @@ -1,12 +0,0 @@ -#ifndef HSLOCK_TEST_STUB_HARDWARE_WATCHDOG_H -#define HSLOCK_TEST_STUB_HARDWARE_WATCHDOG_H -/* Host stub for the Pico SDK "hardware/watchdog.h" - no-op reboot. */ -#include - -static inline void watchdog_reboot(uint32_t pc, uint32_t sp, uint32_t delay_ms) { - (void)pc; - (void)sp; - (void)delay_ms; -} - -#endif diff --git a/test/stub/lwip/arch.h b/test/stub/lwip/arch.h deleted file mode 100644 index d3b607a..0000000 --- a/test/stub/lwip/arch.h +++ /dev/null @@ -1,17 +0,0 @@ -#ifndef HSLOCK_TEST_STUB_LWIP_ARCH_H -#define HSLOCK_TEST_STUB_LWIP_ARCH_H -/* - * Host stub for lwIP's "lwip/arch.h" - the fixed-width integer aliases lwIP - * spreads through its public headers. Only enough for network/ntp.c to COMPILE - * natively for the coverage build; never linked into a running harness. - */ -#include - -typedef uint8_t u8_t; -typedef int8_t s8_t; -typedef uint16_t u16_t; -typedef int16_t s16_t; -typedef uint32_t u32_t; -typedef int32_t s32_t; - -#endif diff --git a/test/stub/lwip/dns.h b/test/stub/lwip/dns.h deleted file mode 100644 index 60f0a8b..0000000 --- a/test/stub/lwip/dns.h +++ /dev/null @@ -1,16 +0,0 @@ -#ifndef HSLOCK_TEST_STUB_LWIP_DNS_H -#define HSLOCK_TEST_STUB_LWIP_DNS_H -/* - * Host stub for "lwip/dns.h" - dns_gethostbyname() plus its resolve-callback - * signature, as referenced by network/ntp.c. Compile-only declaration; never - * linked into a running harness. - */ -#include "lwip/err.h" -#include "lwip/ip_addr.h" - -typedef void (*dns_found_callback)(const char *name, const ip_addr_t *ipaddr, void *callback_arg); - -err_t dns_gethostbyname(const char *hostname, ip_addr_t *addr, dns_found_callback found, - void *callback_arg); - -#endif diff --git a/test/stub/lwip/err.h b/test/stub/lwip/err.h deleted file mode 100644 index ea2d5fe..0000000 --- a/test/stub/lwip/err.h +++ /dev/null @@ -1,11 +0,0 @@ -#ifndef HSLOCK_TEST_STUB_LWIP_ERR_H -#define HSLOCK_TEST_STUB_LWIP_ERR_H -/* Host stub for "lwip/err.h" - err_t plus the codes network/ntp.c checks. */ -#include "lwip/arch.h" - -typedef s8_t err_t; - -#define ERR_OK 0 // no error -#define ERR_INPROGRESS -5 // operation in progress - -#endif diff --git a/test/stub/lwip/ip_addr.h b/test/stub/lwip/ip_addr.h deleted file mode 100644 index 84194b7..0000000 --- a/test/stub/lwip/ip_addr.h +++ /dev/null @@ -1,12 +0,0 @@ -#ifndef HSLOCK_TEST_STUB_LWIP_IP_ADDR_H -#define HSLOCK_TEST_STUB_LWIP_IP_ADDR_H -/* Host stub for "lwip/ip_addr.h" - opaque ip_addr_t + the type selector. */ -#include "lwip/arch.h" - -typedef struct { - u32_t addr; -} ip_addr_t; - -#define IPADDR_TYPE_ANY 46 - -#endif diff --git a/test/stub/lwip/pbuf.h b/test/stub/lwip/pbuf.h deleted file mode 100644 index 4daebdf..0000000 --- a/test/stub/lwip/pbuf.h +++ /dev/null @@ -1,38 +0,0 @@ -#ifndef HSLOCK_TEST_STUB_LWIP_PBUF_H -#define HSLOCK_TEST_STUB_LWIP_PBUF_H -/* - * Host stub for "lwip/pbuf.h" - the packet buffer type and helpers referenced - * by network/ntp.c. Compile-only: shapes are minimal, functions are declared - * (not defined) since the file is never linked into a running harness. - */ -#include - -#include "lwip/arch.h" - -// Buffer layer / type selectors (values are not load-bearing on the host). -typedef enum { - PBUF_TRANSPORT, - PBUF_IP, - PBUF_LINK, - PBUF_RAW, -} pbuf_layer; - -typedef enum { - PBUF_RAM, - PBUF_POOL, - PBUF_ROM, - PBUF_REF, -} pbuf_type; - -struct pbuf { - struct pbuf *next; - void *payload; - u16_t tot_len; - u16_t len; -}; - -struct pbuf *pbuf_alloc(pbuf_layer layer, u16_t length, pbuf_type type); -u8_t pbuf_free(struct pbuf *p); -u16_t pbuf_copy_partial(const struct pbuf *p, void *dataptr, u16_t len, u16_t offset); - -#endif diff --git a/test/stub/lwip/udp.h b/test/stub/lwip/udp.h deleted file mode 100644 index b21f736..0000000 --- a/test/stub/lwip/udp.h +++ /dev/null @@ -1,25 +0,0 @@ -#ifndef HSLOCK_TEST_STUB_LWIP_UDP_H -#define HSLOCK_TEST_STUB_LWIP_UDP_H -/* - * Host stub for "lwip/udp.h" - the UDP PCB type, receive-callback signature and - * the udp_* entry points network/ntp.c uses. Compile-only (declarations, no - * definitions); never linked into a running harness. - */ -#include "lwip/arch.h" -#include "lwip/err.h" -#include "lwip/ip_addr.h" -#include "lwip/pbuf.h" - -struct udp_pcb { - int _dummy; -}; - -typedef void (*udp_recv_fn)(void *arg, struct udp_pcb *pcb, struct pbuf *p, const ip_addr_t *addr, - u16_t port); - -struct udp_pcb *udp_new_ip_type(u8_t type); -void udp_remove(struct udp_pcb *pcb); -void udp_recv(struct udp_pcb *pcb, udp_recv_fn recv, void *recv_arg); -err_t udp_sendto(struct udp_pcb *pcb, struct pbuf *p, const ip_addr_t *dst_ip, u16_t dst_port); - -#endif diff --git a/test/stub/pico/cyw43_arch.h b/test/stub/pico/cyw43_arch.h deleted file mode 100644 index 1176962..0000000 --- a/test/stub/pico/cyw43_arch.h +++ /dev/null @@ -1,39 +0,0 @@ -#ifndef HSLOCK_TEST_STUB_PICO_CYW43_ARCH_H -#define HSLOCK_TEST_STUB_PICO_CYW43_ARCH_H -/* - * Host stub for the Pico SDK "pico/cyw43_arch.h" - the CYW43 WiFi driver arch - * layer. Declares just the entry points, enums and the global driver state that - * network/wifi.c and network/ntp.c reference, so both COMPILE natively for the - * coverage build (.gcno at true 0%). Never linked into a running harness. - */ -#include - -// Auth modes. -#define CYW43_AUTH_OPEN 0 -#define CYW43_AUTH_WPA2_AES_PSK 0x00400004 - -// Interface / link-status enums. -#define CYW43_ITF_STA 0 -#define CYW43_ITF_AP 1 - -#define CYW43_LINK_DOWN 0 -#define CYW43_LINK_JOIN 1 -#define CYW43_LINK_UP 3 - -// Opaque driver state object; only its address is taken on the host. -typedef struct { - int _dummy; -} cyw43_t; -extern cyw43_t cyw43_state; - -int cyw43_arch_init(void); -void cyw43_arch_deinit(void); -void cyw43_arch_enable_sta_mode(void); -int cyw43_arch_wifi_connect_timeout_ms(const char *ssid, const char *pw, uint32_t auth, - uint32_t timeout_ms); -int cyw43_tcpip_link_status(cyw43_t *self, int itf); -void cyw43_arch_poll(void); -void cyw43_arch_lwip_begin(void); -void cyw43_arch_lwip_end(void); - -#endif diff --git a/test/stub/pico/flash.h b/test/stub/pico/flash.h deleted file mode 100644 index 06fd7e7..0000000 --- a/test/stub/pico/flash.h +++ /dev/null @@ -1,22 +0,0 @@ -#ifndef HSLOCK_TEST_STUB_PICO_FLASH_H -#define HSLOCK_TEST_STUB_PICO_FLASH_H -/* - * Host stub for the Pico SDK "pico/flash.h" - flash_safe_execute() shim used by - * storage/storage.c to run erase/program callbacks with the other core paused. - * On the host it just invokes the callback so the file COMPILES for coverage; - * never linked into a running harness. - */ -#include - -#ifndef PICO_OK -#define PICO_OK 0 -#endif - -static inline int flash_safe_execute(void (*func)(void *), void *param, uint32_t timeout_ms) { - (void)func; - (void)param; - (void)timeout_ms; - return PICO_OK; -} - -#endif diff --git a/test/stub/pico/multicore.h b/test/stub/pico/multicore.h deleted file mode 100644 index bb0959d..0000000 --- a/test/stub/pico/multicore.h +++ /dev/null @@ -1,18 +0,0 @@ -#ifndef HSLOCK_TEST_STUB_PICO_MULTICORE_H -#define HSLOCK_TEST_STUB_PICO_MULTICORE_H -/* Host stub for "pico/multicore.h" - no-op core1 / FIFO shims. */ -#include - -static inline void multicore_lockout_victim_init(void) { -} -static inline void multicore_fifo_push_blocking(uint32_t v) { - (void)v; -} -static inline uint32_t multicore_fifo_pop_blocking(void) { - return 0; -} -static inline void multicore_launch_core1(void (*entry)(void)) { - (void)entry; -} - -#endif diff --git a/test/stub/pico/stdio_usb.h b/test/stub/pico/stdio_usb.h deleted file mode 100644 index 5cd6ed1..0000000 --- a/test/stub/pico/stdio_usb.h +++ /dev/null @@ -1,10 +0,0 @@ -#ifndef HSLOCK_TEST_STUB_PICO_STDIO_USB_H -#define HSLOCK_TEST_STUB_PICO_STDIO_USB_H -/* Host stub for "pico/stdio_usb.h" - USB CDC never connected on host. */ -#include - -static inline bool stdio_usb_connected(void) { - return false; -} - -#endif diff --git a/test/stub/pico/stdlib.h b/test/stub/pico/stdlib.h deleted file mode 100644 index 65656df..0000000 --- a/test/stub/pico/stdlib.h +++ /dev/null @@ -1,48 +0,0 @@ -#ifndef HSLOCK_TEST_STUB_PICO_STDLIB_H -#define HSLOCK_TEST_STUB_PICO_STDLIB_H -/* - * Host stub for the Pico SDK "pico/stdlib.h". Provides just enough no-op GPIO, - * timing and stdio shims for first-party firmware .c files to COMPILE natively - * so the coverage build can emit a .gcno for each (making untested files show - * up at their true 0%). These are never linked into a running harness. - */ -#include -#include -#include - -#include "pico/time.h" /* sleep_ms / sleep_us / time_us_64 live here, as in the SDK */ - -typedef unsigned int uint; - -#define GPIO_OUT 1 -#define GPIO_IN 0 -#define PICO_ERROR_TIMEOUT (-1) - -static inline void gpio_init(uint pin) { - (void)pin; -} -static inline void gpio_set_dir(uint pin, int out) { - (void)pin; - (void)out; -} -static inline void gpio_put(uint pin, bool value) { - (void)pin; - (void)value; -} -static inline int gpio_get(uint pin) { - (void)pin; - return 1; -} -static inline void gpio_pull_up(uint pin) { - (void)pin; -} -static inline void stdio_init_all(void) { -} -static inline int getchar_timeout_us(uint32_t timeout) { - (void)timeout; - return PICO_ERROR_TIMEOUT; -} -static inline void tight_loop_contents(void) { -} - -#endif diff --git a/test/stub/pico/time.h b/test/stub/pico/time.h deleted file mode 100644 index 31c839e..0000000 --- a/test/stub/pico/time.h +++ /dev/null @@ -1,29 +0,0 @@ -#ifndef HSLOCK_TEST_STUB_PICO_TIME_H -#define HSLOCK_TEST_STUB_PICO_TIME_H -/* Host stub for "pico/time.h" - opaque absolute_time_t + no-op timers. */ -#include -#include - -typedef struct { - int64_t _us; -} absolute_time_t; - -static inline absolute_time_t make_timeout_time_ms(uint32_t ms) { - absolute_time_t t = {(int64_t)ms}; - return t; -} -static inline bool time_reached(absolute_time_t t) { - (void)t; - return true; -} -static inline uint64_t time_us_64(void) { - return 0; -} -static inline void sleep_ms(uint32_t ms) { - (void)ms; -} -static inline void sleep_us(uint64_t us) { - (void)us; -} - -#endif diff --git a/test/stub/version.h b/test/stub/version.h deleted file mode 100644 index a6d6af0..0000000 --- a/test/stub/version.h +++ /dev/null @@ -1,8 +0,0 @@ -#ifndef HSLOCK_TEST_STUB_VERSION_H -#define HSLOCK_TEST_STUB_VERSION_H -/* Host stub for the CMake-generated version.h (see version.h.in). */ -#define GIT_HASH "host-test" -#define GIT_DIRTY 0 -#define BUILD_DATE "host-test" - -#endif