From 77b2d33c2320c4edaffda66785e27544461c9594 Mon Sep 17 00:00:00 2001 From: Dave Seddon Date: Mon, 19 Oct 2026 13:33:13 -0700 Subject: [PATCH 01/24] Fix chomp() reading before the buffer on all-newline input chomp() walked back over trailing newlines with no lower bound: for a string of only newlines it cleared str[0], stepped to str - 1 and read it. Any IPC client could trigger this by sending "\n". Count down a length instead. Regression input: regress-chomp-all-newlines (fuzz_ipc), which made checks.fuzz-replay fail before this fix. Signed-off-by: Dave Seddon --- src/defs.h | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/defs.h b/src/defs.h index de09ee7..ba7379e 100644 --- a/src/defs.h +++ b/src/defs.h @@ -193,16 +193,16 @@ extern int pidfile(const char *basename); static inline char *chomp(char *str) { - char *p; + size_t len; if (!str || strlen(str) < 1) { errno = EINVAL; return NULL; } - p = str + strlen(str) - 1; - while (*p == '\n') - *p-- = 0; + len = strlen(str); + while (len > 0 && str[len - 1] == '\n') + str[--len] = 0; return str; } From ffafcbe365849940fb39bff6688e093ce21c25b5 Mon Sep 17 00:00:00 2001 From: Dave Seddon Date: Mon, 19 Oct 2026 13:33:13 -0700 Subject: [PATCH 02/24] test: quote tenacious()'s command, give tshark 30 s to start tenacious() ran its command as an unquoted $@, so the capture wait ran `grep -q Capturing on `: a grep for "Capturing" in a file named "on" and the log. It only worked because grep -q exits 0 on a match despite the error. Run it as "$@". On a loaded host tshark can take more than 10 s to start, failing every test that captures; the wait returns as soon as it is up, so allow 30 s. Signed-off-by: Dave Seddon --- test/lib.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/lib.sh b/test/lib.sh index 4e9c348..074f9ae 100755 --- a/test/lib.sh +++ b/test/lib.sh @@ -71,7 +71,7 @@ tenacious() shift while [ $timeout -gt 0 ]; do - $@ && return + "$@" && return timeout=$((timeout - 1)) sleep 1 done @@ -93,7 +93,7 @@ capture() { tshark -lni "$1" -w "$2" 2>"$2.log" & echo $! >> "/tmp/$NM/PIDs" - tenacious 10 grep -q "Capturing on" "$2.log" + tenacious 30 grep -q "Capturing on" "$2.log" } collect() From f93a0febf93fa05648baed01bc5d3da5e26f4c4b Mon Sep 17 00:00:00 2001 From: Dave Seddon Date: Mon, 19 Oct 2026 13:33:13 -0700 Subject: [PATCH 03/24] Fix config lexer overflowing s1 on a long dotted-quad token yylex() scanned any run of digits and dots with sscanf("%[.0-9]") into the global s1, INET_ADDRSTR_LEN (46, or 16 without IPv6) bytes, with no width: a long number in mcd.conf wrote past it. Scan into a local 16-byte buffer with a width of 15; nothing longer is an IPv4 address, and such tokens still fall through to NUMBER/STRING as before. Regression input: regress-long-dotted-quad (fuzz_cfparse), which made checks.fuzz-replay fail before this fix. Signed-off-by: Dave Seddon --- src/cfparse.y | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/cfparse.y b/src/cfparse.y index 4420b7f..a0b8f31 100644 --- a/src/cfparse.y +++ b/src/cfparse.y @@ -313,6 +313,7 @@ static int yylex(void) struct keyword *w; uint32_t addr, n; struct in6_addr in6; + char dq[INET_ADDRSTRLEN]; char *q; q = yytoken(); @@ -340,11 +341,12 @@ static int yylex(void) return GROUP; } - if (sscanf(q, "%[.0-9]/%u%c", s1, &n, s2) == 2) { - addr = inet_parse(s1, 1); + /* dq holds "255.255.255.255" at most; longer is no address anyway */ + if (sscanf(q, "%15[.0-9]/%u%c", dq, &n, s2) == 2) { + addr = inet_parse(dq, 1); /* fall through to returning STRING */ - } else if (sscanf(q, "%[.0-9]%c", s1, s2) == 1) { - addr = inet_parse(s1, 4); + } else if (sscanf(q, "%15[.0-9]%c", dq, s2) == 1) { + addr = inet_parse(dq, 4); if (addr != 0xffffffff) { if (inet_valid_host(addr)) { yylval.addr = addr; From 230350a3030bb4f1a6ac8fa3ddaf50948df8d875 Mon Sep 17 00:00:00 2001 From: Dave Seddon Date: Mon, 19 Oct 2026 13:33:13 -0700 Subject: [PATCH 04/24] Fix config lexer reading past its line buffer yytoken() NUL-terminated each token by writing over the separator and stepping past it. A token that ran to the end of buf (a 1023-byte line with no newline, or a last line without one) had no separator: p was left one past the buffer and the next call read it. Only step over a real separator. Regression input: regress-full-line-no-newline (fuzz_cfparse), which made checks.fuzz-replay fail before this fix. Signed-off-by: Dave Seddon --- src/cfparse.y | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/cfparse.y b/src/cfparse.y index a0b8f31..5702049 100644 --- a/src/cfparse.y +++ b/src/cfparse.y @@ -258,7 +258,8 @@ static char *yytoken(void) q = p; while (*p && *p != ' ' && *p != '\t' && *p != '\n') p++; /* find next whitespace */ - *p++ = '\0'; /* null-terminate string */ + if (*p) /* not at the end of buf */ + *p++ = '\0'; /* null-terminate string */ if (!*q) { p = NULL; From a9e20e6ef5d5cf313dff9f7b56d8f90b79e61ab1 Mon Sep 17 00:00:00 2001 From: Dave Seddon Date: Mon, 19 Oct 2026 13:33:13 -0700 Subject: [PATCH 05/24] Fix NULL dereference on a truncated `bridge mdb show` line populate() read the value after each dev/port/grp/vid keyword with strtok() and passed it straight to strlcpy() or strtol(); a line ending right after a keyword handed them NULL. Stop parsing the line there. Regression inputs: regress-mdb-line-ends-after-keyword and regress-mdb-line-ends-after-vid (fuzz_bridge_mdb), which made checks.fuzz-replay fail before this fix. Signed-off-by: Dave Seddon --- src/bridge.c | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/bridge.c b/src/bridge.c index 04ee2c5..275e9f2 100644 --- a/src/bridge.c +++ b/src/bridge.c @@ -111,6 +111,8 @@ static int populate(void) len = sizeof(group); } else if (!strcmp(tok, "vid")) { tok = strtok(NULL, " \t"); + if (!tok) + break; vid = strtol(tok, NULL, 10); continue; } else { @@ -118,6 +120,8 @@ static int populate(void) } tok = strtok(NULL, " \t"); + if (!tok) + break; strlcpy(dst, tok, len); } From 1f0a157bf5d2c1b88a1f578af8b5f0a352c73a09 Mon Sep 17 00:00:00 2001 From: Dave Seddon Date: Mon, 19 Oct 2026 13:33:13 -0700 Subject: [PATCH 06/24] Fix mdb port list overflow for a group on many ports populate() strcat()ed every port of a group into the 576-byte struct mdb port list unchecked: past about 30 ports (or fewer with JSON quotes and long names) it wrote off the end of the heap entry. Append each port whole if it fits, else warn and leave it out; a name cut short would also break the JSON output. Regression input: regress-mdb-many-ports-one-group (fuzz_bridge_mdb), 40 ports in one group, which made checks.fuzz-replay fail before this fix. Signed-off-by: Dave Seddon --- src/bridge.c | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/bridge.c b/src/bridge.c index 275e9f2..ff4df7f 100644 --- a/src/bridge.c +++ b/src/bridge.c @@ -95,6 +95,7 @@ static int populate(void) while (fgets(buf, sizeof(buf), fp)) { char br[IFNAMSIZ + 1] = { 0 }, port[IFNAMSIZ + 3] = { 0 }, group[64] = { 0 }; + char item[sizeof(port) + 4]; char *tok, *dst; int vid = 0; size_t len; @@ -134,13 +135,12 @@ static int populate(void) } } - if (e->port[0]) - strcat(e->port, ", "); - if (json) - strcat(e->port, "\""); - strcat(e->port, port); - if (json) - strcat(e->port, "\""); + snprintf(item, sizeof(item), "%s%s%s%s", e->port[0] ? ", " : "", + json ? "\"" : "", port, json ? "\"" : ""); + if (strlen(e->port) + strlen(item) < sizeof(e->port)) + strcat(e->port, item); + else + warnx("too many ports in group %s, skipping %s", group, port); strlcpy(e->br, br, sizeof(e->br)); strlcpy(e->group, group, sizeof(e->group)); e->vid = vid; From 0783ac85509fc482fc0e48fafab434cb92142862 Mon Sep 17 00:00:00 2001 From: Dave Seddon Date: Mon, 19 Oct 2026 13:33:14 -0700 Subject: [PATCH 07/24] Drop populate()'s unused counter gcc 16 flags `num` in src/bridge.c populate() as set but never used, which was the only thing keeping a -Werror build from passing. Signed-off-by: Dave Seddon --- src/bridge.c | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/bridge.c b/src/bridge.c index ff4df7f..abca607 100644 --- a/src/bridge.c +++ b/src/bridge.c @@ -87,7 +87,6 @@ static int populate(void) struct mdb *e; FILE *fp; char buf[80]; - int num = 0; fp = popen("bridge mdb show", "r"); if (!fp) @@ -144,7 +143,6 @@ static int populate(void) strlcpy(e->br, br, sizeof(e->br)); strlcpy(e->group, group, sizeof(e->group)); e->vid = vid; - num++; } return pclose(fp); From 5c5da3cd21df5f69779c9b45d6bdc180be282b10 Mon Sep 17 00:00:00 2001 From: Dave Seddon Date: Mon, 19 Oct 2026 13:33:14 -0700 Subject: [PATCH 08/24] Fix IPC error replies going to the listening socket ipc_handle() passed the listening socket sd to ipc_err() instead of the accepted client, so a client sending an unknown command got no reply (and mcd wrote to a socket that cannot carry data). Reply on client, and keep errno across the warn() before it, since ipc_err() picks its message from errno. New test/ipc-errors.sh: a table of bad commands, each expecting "No such command", with control rows that must still be served. It failed before this fix: every bad command got an empty reply. Signed-off-by: Dave Seddon --- src/ipc.c | 4 +++- test/Makefile.am | 3 ++- test/ipc-errors.sh | 53 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 2 deletions(-) create mode 100755 test/ipc-errors.sh diff --git a/src/ipc.c b/src/ipc.c index 08e16e8..70728a0 100644 --- a/src/ipc.c +++ b/src/ipc.c @@ -675,7 +675,9 @@ static void ipc_handle(int sd, void *arg) break; case IPC_ERR: + rc = errno; /* for ipc_err(), past warn() */ warn("Failed reading command from client"); + errno = rc; rc = IPC_ERR; break; @@ -685,7 +687,7 @@ static void ipc_handle(int sd, void *arg) } if (rc == IPC_ERR) - ipc_err(sd, cmd, sizeof(cmd)); + ipc_err(client, cmd, sizeof(cmd)); ipc_close(client); } diff --git a/test/Makefile.am b/test/Makefile.am index 754925e..dbd4819 100644 --- a/test/Makefile.am +++ b/test/Makefile.am @@ -1,4 +1,4 @@ -EXTRA_DIST = lib.sh basic.sh sleepy.sh two.sh ipc.sh late.sh router-ports.sh static-group.sh mld.sh +EXTRA_DIST = lib.sh basic.sh sleepy.sh two.sh ipc.sh ipc-errors.sh late.sh router-ports.sh static-group.sh mld.sh CLEANFILES = *~ *.trs *.log TEST_EXTENSIONS = .sh @@ -7,6 +7,7 @@ TESTS_ENVIRONMENT = unshare -mrun --map-auto TESTS = sleepy.sh TESTS += basic.sh TESTS += ipc.sh +TESTS += ipc-errors.sh TESTS += late.sh TESTS += two.sh TESTS += router-ports.sh diff --git a/test/ipc-errors.sh b/test/ipc-errors.sh new file mode 100755 index 0000000..9e8234b --- /dev/null +++ b/test/ipc-errors.sh @@ -0,0 +1,53 @@ +#!/bin/sh +# Verifies that IPC clients get an error reply for a bad command, and +# that mcd keeps serving afterwards + +# shellcheck source=/dev/null +. "$(dirname "$0")/lib.sh" + +print "Check deps ..." +check_dep socat + +print "Creating world ..." +addif eth0 192.168.0.1/24 + +print "Creating config ..." +cat < "/tmp/$NM/config" +iface eth0 enable igmpv3 +EOF +cat "/tmp/$NM/config" + +print "Starting mcd ..." +../src/mcd -f "/tmp/$NM/config" -p "/tmp/$NM/pid" -l debug -n -u "/tmp/$NM/sock" & +echo $! >> "/tmp/$NM/PIDs" +sleep 2 + +# One row per line: description | command | expected text in the reply. +# The control rows (help, version) are in between, so a bad command that +# took the daemon down fails the row after it. +print "Sending commands ..." +fail=0 +while IFS='|' read -r desc cmd expect; do + reply=$(echo "$cmd" | socat -t 10 - UNIX-CONNECT:"/tmp/$NM/sock" 2>&1) + if printf '%s\n' "$reply" | grep -qF "$expect"; then + echo "ok: $desc" + else + echo "FAIL: $desc: '$cmd' replied '$reply', expected '$expect'" + fail=1 + fi +done < Date: Mon, 19 Oct 2026 13:33:14 -0700 Subject: [PATCH 09/24] Fix mcd dying of SIGPIPE when an IPC client hangs up early mcd did not ignore SIGPIPE, so a reply write() to a client that had already closed its end killed the daemon (exit 141). socat waits only 0.5 s for a reply, so a slow `show` on a loaded host was enough. Send replies with MSG_NOSIGNAL, through ipc_write() everywhere: the failed write then returns EPIPE, which is already handled. The signal stays at its default for the bridge/jq children run by popen(). New test/ipc-disconnect.sh stops mcd while a client sends a command and gives up, then checks mcd is alive and still answers, for each reply path (help, version, show, json show, error). It failed before this fix: mcd died replying to the first one. Signed-off-by: Dave Seddon --- src/ipc.c | 4 +-- test/Makefile.am | 3 +- test/ipc-disconnect.sh | 63 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 3 deletions(-) create mode 100755 test/ipc-disconnect.sh diff --git a/src/ipc.c b/src/ipc.c index 70728a0..50ed4a6 100644 --- a/src/ipc.c +++ b/src/ipc.c @@ -191,7 +191,7 @@ static int ipc_write(int sd, char *msg, size_t sz) // dbg("IPC rpl: '%s'", msg); - while ((len = write(sd, msg, sz))) { + while ((len = send(sd, msg, sz, MSG_NOSIGNAL))) { if (-1 == len) { switch (errno) { case EINTR: @@ -595,7 +595,7 @@ static void ipc_help(int sd, char *buf, size_t len) int sz; sz = snprintf(buf, len, "Cannot create tempfile: %s", strerror(errno)); - if (write(sd, buf, sz) != sz) + if (ipc_write(sd, buf, sz)) warn("Client closed connection"); return; } diff --git a/test/Makefile.am b/test/Makefile.am index dbd4819..6279fdd 100644 --- a/test/Makefile.am +++ b/test/Makefile.am @@ -1,4 +1,4 @@ -EXTRA_DIST = lib.sh basic.sh sleepy.sh two.sh ipc.sh ipc-errors.sh late.sh router-ports.sh static-group.sh mld.sh +EXTRA_DIST = lib.sh basic.sh sleepy.sh two.sh ipc.sh ipc-errors.sh ipc-disconnect.sh late.sh router-ports.sh static-group.sh mld.sh CLEANFILES = *~ *.trs *.log TEST_EXTENSIONS = .sh @@ -8,6 +8,7 @@ TESTS = sleepy.sh TESTS += basic.sh TESTS += ipc.sh TESTS += ipc-errors.sh +TESTS += ipc-disconnect.sh TESTS += late.sh TESTS += two.sh TESTS += router-ports.sh diff --git a/test/ipc-disconnect.sh b/test/ipc-disconnect.sh new file mode 100755 index 0000000..dcc599d --- /dev/null +++ b/test/ipc-disconnect.sh @@ -0,0 +1,63 @@ +#!/bin/sh +# Verifies that mcd survives an IPC client that disconnects before +# reading its reply, i.e., a reply write() to a closed peer (SIGPIPE) + +# shellcheck source=/dev/null +. "$(dirname "$0")/lib.sh" + +print "Check deps ..." +check_dep socat + +print "Creating world ..." +addif eth0 192.168.0.1/24 + +print "Creating config ..." +cat < "/tmp/$NM/config" +iface eth0 enable igmpv3 +EOF +cat "/tmp/$NM/config" + +print "Starting mcd ..." +../src/mcd -f "/tmp/$NM/config" -p "/tmp/$NM/pid" -l debug -n -u "/tmp/$NM/sock" & +PID=$! +echo $PID >> "/tmp/$NM/PIDs" +sleep 2 + +# One row per line: description | command | expected reply afterwards. +# mcd is stopped while the client connects, sends and gives up (socat +# waits 0.1 s), so by the time mcd runs again the peer is gone and the +# reply goes nowhere. mcd must then still be alive and answer 'version'. +print "Disconnecting clients ..." +fail=0 +while IFS='|' read -r desc cmd expect; do + kill -STOP $PID + echo "$cmd" | socat -t 0.1 - UNIX-CONNECT:"/tmp/$NM/sock" >/dev/null 2>&1 + kill -CONT $PID + sleep 1 + + if ! kill -0 $PID 2>/dev/null; then + echo "FAIL: $desc: mcd died replying to '$cmd'" + fail=1 + break + fi + reply=$(echo "version" | socat -t 2 - UNIX-CONNECT:"/tmp/$NM/sock" 2>&1) + if printf '%s\n' "$reply" | grep -qF "$expect"; then + echo "ok: $desc" + else + echo "FAIL: $desc: after '$cmd', 'version' replied '$reply'" + fail=1 + fi +done < Date: Mon, 19 Oct 2026 13:33:14 -0700 Subject: [PATCH 10/24] test: give IPC clients 10 s for a reply socat hangs up 0.5 s after sending the command; on a loaded host, or with mcd under valgrind, `show igmp json` (which runs bridge and jq) takes longer, and the test reads an empty reply. mcd closes the connection after replying, so a longer wait costs nothing when it is fast. Signed-off-by: Dave Seddon --- test/ipc.sh | 8 ++++---- test/late.sh | 6 +++--- test/router-ports.sh | 8 ++++---- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/test/ipc.sh b/test/ipc.sh index b2ca951..67fa518 100755 --- a/test/ipc.sh +++ b/test/ipc.sh @@ -38,11 +38,11 @@ echo $! >> "/tmp/$NM/PIDs" sleep 2 print "IPC output ..." -echo "help" |socat - UNIX-CONNECT:"/tmp/$NM/sock" -echo "version" |socat - UNIX-CONNECT:"/tmp/$NM/sock" +echo "help" | socat -t 10 - UNIX-CONNECT:"/tmp/$NM/sock" +echo "version" | socat -t 10 - UNIX-CONNECT:"/tmp/$NM/sock" echo -echo "status status" |socat - UNIX-CONNECT:"/tmp/$NM/sock" -echo "show igmp" |socat - UNIX-CONNECT:"/tmp/$NM/sock" | tee "/tmp/$NM/show" +echo "status status" | socat -t 10 - UNIX-CONNECT:"/tmp/$NM/sock" +echo "show igmp" | socat -t 10 - UNIX-CONNECT:"/tmp/$NM/sock" | tee "/tmp/$NM/show" print "Analyzing ..." kill_pids diff --git a/test/late.sh b/test/late.sh index 85742a5..b6225cd 100755 --- a/test/late.sh +++ b/test/late.sh @@ -29,17 +29,17 @@ echo $! >> "/tmp/$NM/PIDs" sleep 2 print "Analyzing ..." -echo "show igmp" |socat - UNIX-CONNECT:"/tmp/$NM/sock" | tee "/tmp/$NM/show" +echo "show igmp" | socat -t 10 - UNIX-CONNECT:"/tmp/$NM/sock" | tee "/tmp/$NM/show" grep "eth0" "/tmp/$NM/show" | grep -q "192.168.0.1" || FAIL addif eth1 192.168.1.1/24 -echo "show igmp" |socat - UNIX-CONNECT:"/tmp/$NM/sock" | tee "/tmp/$NM/show" +echo "show igmp" | socat -t 10 - UNIX-CONNECT:"/tmp/$NM/sock" | tee "/tmp/$NM/show" grep "eth0" "/tmp/$NM/show" | grep -q "192.168.0.1" || FAIL grep "eth1" "/tmp/$NM/show" | grep -q "192.168.1.1" || FAIL delif eth0 -echo "show igmp" |socat - UNIX-CONNECT:"/tmp/$NM/sock" | tee "/tmp/$NM/show" +echo "show igmp" | socat -t 10 - UNIX-CONNECT:"/tmp/$NM/sock" | tee "/tmp/$NM/show" grep "eth0" "/tmp/$NM/show" | grep -q "192.168.0.1" && FAIL OK diff --git a/test/router-ports.sh b/test/router-ports.sh index a9af06d..b38bfeb 100755 --- a/test/router-ports.sh +++ b/test/router-ports.sh @@ -40,17 +40,17 @@ echo $! >> "/tmp/$NM/PIDs" sleep 2 print "Querying IPC: help ..." -echo "help" | socat - UNIX-CONNECT:"/tmp/$NM/sock" | tee "/tmp/$NM/help.txt" +echo "help" | socat -t 10 - UNIX-CONNECT:"/tmp/$NM/sock" | tee "/tmp/$NM/help.txt" print "Querying IPC: show router-ports ..." -echo "show router-ports" | socat - UNIX-CONNECT:"/tmp/$NM/sock" | tee "/tmp/$NM/rp.txt" +echo "show router-ports" | socat -t 10 - UNIX-CONNECT:"/tmp/$NM/sock" | tee "/tmp/$NM/rp.txt" print "Querying IPC: show router-ports json ..." -echo "show router-ports json" | socat - UNIX-CONNECT:"/tmp/$NM/sock" | tee "/tmp/$NM/rp.json" +echo "show router-ports json" | socat -t 10 - UNIX-CONNECT:"/tmp/$NM/sock" | tee "/tmp/$NM/rp.json" echo print "Querying IPC: show igmp json ..." -echo "show igmp json" | socat - UNIX-CONNECT:"/tmp/$NM/sock" | tee "/tmp/$NM/igmp.json" +echo "show igmp json" | socat -t 10 - UNIX-CONNECT:"/tmp/$NM/sock" | tee "/tmp/$NM/igmp.json" echo kill_pids From 64ea2da5dc55f7e0f804414eaac504113e6bf928 Mon Sep 17 00:00:00 2001 From: Dave Seddon Date: Mon, 19 Oct 2026 13:33:14 -0700 Subject: [PATCH 11/24] Fix default router timeout ignoring the configured intervals The router timeout (other querier present interval) defaults, per RFC 3376 and mcd.conf(5), to robustness * query-interval + response-interval / 2. igmp_init() computed it before the config was read, so it was always 2 * 125 + 10 / 2 = 255 whatever global-query-interval, global-response-interval and robustness said. Leave it unset there and compute it in config_init() once the file is parsed, unless router-timeout was given. New test/router-timeout.sh: a table of global settings, each with the timeout `show igmp` must report. It failed before this fix (255 for every derived row); the explicit-setting rows passed both ways. Signed-off-by: Dave Seddon --- src/cfparse.y | 4 ++++ src/igmp.c | 2 +- test/Makefile.am | 3 ++- test/router-timeout.sh | 52 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 59 insertions(+), 2 deletions(-) create mode 100755 test/router-timeout.sh diff --git a/src/cfparse.y b/src/cfparse.y index 5702049..05e2931 100644 --- a/src/cfparse.y +++ b/src/cfparse.y @@ -387,6 +387,10 @@ void config_init(void) err("cannot open %s", config_file); exit(EX_DATAERR); } + + /* Unless set, follows the intervals and robustness just read */ + if (!router_timeout) + router_timeout = IGMP_OTHER_QUERIER_PRESENT_INTERVAL; } int config_parse(const char *file) diff --git a/src/igmp.c b/src/igmp.c index 23155d5..efe4bb9 100644 --- a/src/igmp.c +++ b/src/igmp.c @@ -30,7 +30,7 @@ void igmp_init(void) igmp_response_interval = IGMP_QUERY_RESPONSE_INTERVAL; igmp_last_member_interval = IGMP_LAST_MEMBER_INTERVAL_DEFAULT; igmp_robustness = IGMP_ROBUSTNESS_DEFAULT; - router_timeout = IGMP_OTHER_QUERIER_PRESENT_INTERVAL; + router_timeout = 0; /* unset, see config_init() */ router_alert = 1; } diff --git a/test/Makefile.am b/test/Makefile.am index 6279fdd..f1b6a87 100644 --- a/test/Makefile.am +++ b/test/Makefile.am @@ -1,4 +1,4 @@ -EXTRA_DIST = lib.sh basic.sh sleepy.sh two.sh ipc.sh ipc-errors.sh ipc-disconnect.sh late.sh router-ports.sh static-group.sh mld.sh +EXTRA_DIST = lib.sh basic.sh sleepy.sh two.sh ipc.sh ipc-errors.sh ipc-disconnect.sh router-timeout.sh late.sh router-ports.sh static-group.sh mld.sh CLEANFILES = *~ *.trs *.log TEST_EXTENSIONS = .sh @@ -9,6 +9,7 @@ TESTS += basic.sh TESTS += ipc.sh TESTS += ipc-errors.sh TESTS += ipc-disconnect.sh +TESTS += router-timeout.sh TESTS += late.sh TESTS += two.sh TESTS += router-ports.sh diff --git a/test/router-timeout.sh b/test/router-timeout.sh new file mode 100755 index 0000000..0a4f937 --- /dev/null +++ b/test/router-timeout.sh @@ -0,0 +1,52 @@ +#!/bin/sh +# Verifies the router timeout (other querier present interval): unless +# set, it follows the configured robustness, query and response interval +# per RFC 3376, robustness * query-interval + response-interval / 2 + +# shellcheck source=/dev/null +. "$(dirname "$0")/lib.sh" + +print "Check deps ..." +check_dep socat + +print "Creating world ..." +addif eth0 192.168.0.1/24 + +# One row per line: description | global settings (';' between lines) | +# expected router timeout. +print "Checking router timeout ..." +fail=0 +while IFS='|' read -r desc globals expect; do + printf '%s\n' "$globals" | tr ';' '\n' > "/tmp/$NM/config" + echo "iface eth0 enable igmpv3" >> "/tmp/$NM/config" + + ../src/mcd -f "/tmp/$NM/config" -p "/tmp/$NM/pid" -l debug -n -u "/tmp/$NM/sock" \ + 2>"/tmp/$NM/mcd.log" & + pid=$! + tenacious 10 test -S "/tmp/$NM/sock" + got=$(echo "show igmp" | socat -t 10 - UNIX-CONNECT:"/tmp/$NM/sock" \ + | sed -n 's/^Router Timeout *: *//p') + kill $pid + wait $pid 2>/dev/null + rm -f "/tmp/$NM/sock" + + if [ "$got" = "$expect" ]; then + echo "ok: $desc ($got)" + else + echo "FAIL: $desc: router timeout '$got', expected '$expect'" + cat "/tmp/$NM/mcd.log" + fail=1 + fi +done < Date: Mon, 19 Oct 2026 13:33:14 -0700 Subject: [PATCH 12/24] Fix misaligned IP headers in the packet buffers mcd overlays struct ip / struct ip6_hdr right after the Ethernet header, 14 bytes (18 with a VLAN tag) into send_buf and recv_buf, so every query built and every packet parsed accessed 4-byte fields misaligned: harmless on x86, a fault or a slow trap on strict-alignment targets. Start frames SOCK_BUF_ALIGN (2) bytes into each buffer, as the kernel's NET_IP_ALIGN does, which aligns the IP header in both cases. With -fsanitize=alignment, checks.fuzz-replay failed at build_ipv4() and vm-check-asan reported 302 misaligned accesses; both are clean now. Signed-off-by: Dave Seddon --- src/sock.c | 15 ++++++++++----- src/sock.h | 3 +++ 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/sock.c b/src/sock.c index 3912708..c189f41 100644 --- a/src/sock.c +++ b/src/sock.c @@ -12,6 +12,8 @@ uint8_t *recv_buf; /* input packet buffer */ uint8_t *send_buf; /* output packet buffer */ +static uint8_t *recv_mem; /* allocations behind them */ +static uint8_t *send_mem; static int join_sd = -1; /* IPv4 static group memberships */ static int join_sd6 = -1; /* IPv6 static group memberships */ static int reloading; /* between sock_reload_begin/end */ @@ -83,12 +85,14 @@ static int set_filter(int sd, int family) void sock_init(void) { - recv_buf = calloc(1, RECV_BUF_SIZE); - send_buf = calloc(1, RECV_BUF_SIZE); - if (!recv_buf || !send_buf) { + recv_mem = calloc(1, SOCK_BUF_ALIGN + RECV_BUF_SIZE); + send_mem = calloc(1, SOCK_BUF_ALIGN + RECV_BUF_SIZE); + if (!recv_mem || !send_mem) { err("failed allocating Rx/Tx buffers"); exit(EX_OSERR); } + recv_buf = recv_mem + SOCK_BUF_ALIGN; + send_buf = send_mem + SOCK_BUF_ALIGN; /* Never bound, so they never receive data, only hold memberships */ join_sd = socket(AF_INET, SOCK_DGRAM | SOCK_CLOEXEC, 0); @@ -124,8 +128,9 @@ void sock_exit(void) close(join_sd6); join_sd = join_sd6 = -1; - free(recv_buf); - free(send_buf); + free(recv_mem); + free(send_mem); + recv_buf = send_buf = recv_mem = send_mem = NULL; } /* diff --git a/src/sock.h b/src/sock.h index f4a7cdd..e2ede6e 100644 --- a/src/sock.h +++ b/src/sock.h @@ -5,6 +5,9 @@ #define RECV_BUF_SIZE 1536 +/* Frame offset in recv_buf/send_buf, 4-byte aligns the IP header */ +#define SOCK_BUF_ALIGN 2 + extern uint8_t *recv_buf; extern uint8_t *send_buf; From 3659ab097463ee5b5d8b1eae6d3e2778aea91fd2 Mon Sep 17 00:00:00 2001 From: Dave Seddon Date: Mon, 19 Oct 2026 13:33:14 -0700 Subject: [PATCH 13/24] Fix bridge_prop() reading past its JSON tokens jsmn is built non-strict, so an object's size counts keys that have no value, e.g. {"a" "b"} has size 2. The key/value walk trusted size * 2 tokens to follow the object, and on an object ending the token array it read tokens[256] and tokens[257] past the 256 parsed. Found by fuzz-run (fuzz_bridge_mdb, UBSan index out of bounds). Bound the walk by the number of parsed tokens too. The regression input, regress-json-keys-without-values, fails fuzz-replay without the fix. Signed-off-by: Dave Seddon --- src/bridge.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bridge.c b/src/bridge.c index abca607..3713e8c 100644 --- a/src/bridge.c +++ b/src/bridge.c @@ -341,7 +341,7 @@ void bridge_prop(FILE *fp, const char *brname, const char *prop) continue; /* Skip top-level element, it is always an array '[' or start of object '{' */ - for (int j = i + 1; j < i + tokens[i].size * 2 + 1; j += 2) { + for (int j = i + 1; j < i + tokens[i].size * 2 + 1 && j + 1 < rc; j += 2) { jsmntok_t *key = &tokens[j]; jsmntok_t *val = &tokens[j + 1]; From eec2f0fda58b37569487dbeaa7b46f21b0a67bf2 Mon Sep 17 00:00:00 2001 From: Dave Seddon Date: Mon, 19 Oct 2026 13:33:14 -0700 Subject: [PATCH 14/24] Fix bridge_prop() writing its NUL past the JSON buffer bridge_prop() reads up to BUFSIZ bytes of `bridge -j link | jq` output into a BUFSIZ buffer and then terminates it at data[len], one byte past the end when the output fills the buffer, e.g. a bridge with many ports. Found in review; libFuzzer's default max input length (4096) is too short to get there. Allocate the terminator's byte. The regression input, regress-json-fills-read-buffer, fails fuzz-replay without the fix. Signed-off-by: Dave Seddon --- src/bridge.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bridge.c b/src/bridge.c index 3713e8c..91644a0 100644 --- a/src/bridge.c +++ b/src/bridge.c @@ -311,7 +311,7 @@ void bridge_prop(FILE *fp, const char *brname, const char *prop) jsmn_init(&parser); - data = malloc(BUFSIZ); + data = malloc(BUFSIZ + 1); /* + NUL */ if (!data) { err("failed allocating json data buffer"); exit(EX_OSERR); From f441ff81bbee9840e08d8c634855ecca00964135 Mon Sep 17 00:00:00 2001 From: Dave Seddon Date: Mon, 19 Oct 2026 13:33:14 -0700 Subject: [PATCH 15/24] Fix shell command injection through bridge names bridge_router_ports() pasted the bridge name unquoted into the shell pipeline it popen()s, twice: into the jq filter and as the `bridge vlan global show dev` argument. The names come from the kernel (`ip link show type bridge`), and Linux allows ; $ ` | & > and ' in interface names, so creating a bridge named e.g. `b;>pwned` ran a command as root mcd the next time an IPC client asked for `show router-ports` or `show igmp json`. Creating a bridge takes only CAP_NET_ADMIN in mcd's network namespace. Found reviewing analysis-deep's popen() findings. Pass the name to jq with --arg and shell quote it. This also fixes the router ports of bridges named like br-0, never listed: jq parsed .router.br-0 as a subtraction. test/bridge-names.sh creates a bridge per hostile name, each with a permanent router port, and fails if any name ran as a command or a bridge lacks its router port in `show router-ports json` and `show igmp json`. Before the fix all six hostile names ran and br-ok had no ports. Signed-off-by: Dave Seddon --- src/bridge.c | 28 ++++++++++++-- test/Makefile.am | 3 +- test/bridge-names.sh | 91 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 118 insertions(+), 4 deletions(-) create mode 100755 test/bridge-names.sh diff --git a/src/bridge.c b/src/bridge.c index 91644a0..8d53e96 100644 --- a/src/bridge.c +++ b/src/bridge.c @@ -416,19 +416,41 @@ void bridge_router_ports_all(FILE *fp) pclose(pp); } +/* Quote src as one shell word, len must be 4 * strlen(src) + 3 */ +static char *shquote(char *dst, size_t len, const char *src) +{ + size_t i = 0; + + dst[i++] = '\''; + for (; *src && i + 5 < len; src++) { + if (*src == '\'') { + memcpy(&dst[i], "'\\''", 4); + i += 4; + } else + dst[i++] = *src; + } + dst[i++] = '\''; + dst[i] = 0; + + return dst; +} + /* * Standardized way of reading out routers ports is now 'bridge -d mdb show' */ void bridge_router_ports(FILE *fp, const char *brname) { - const char *fmt = "(bridge -d -j mdb show | jq -r '.[] .router%s%s | .[] .port';" + const char *fmt = "(bridge -d -j mdb show | jq -r --arg br %s '.[] .router%s | .[] .port';" "bridge -j -d vlan global show %s%s | jq -r '.[].vlans[].router_ports[].port')" "| sort -uV | tr '\n' ' '"; - char cmd[strlen(fmt) + 64], buf[128 * (IFNAMSIZ + 2)]; /* 128 ports should be enough */ + char qbr[4 * IFNAMSIZ + 3], buf[128 * (IFNAMSIZ + 2)]; /* 128 ports should be enough */ + char cmd[strlen(fmt) + 2 * sizeof(qbr) + 16]; FILE *pp; + shquote(qbr, sizeof(qbr), brname ?: ""); + /* Read mrouter ports from brname, or all bridges. */ - snprintf(cmd, sizeof(cmd), fmt, brname ? "." : "", brname ?: "[]", brname ? "dev " : "", brname ?: ""); + snprintf(cmd, sizeof(cmd), fmt, qbr, brname ? "[$br]" : "[]", brname ? "dev " : "", brname ? qbr : ""); pp = popen(cmd, "r"); if (!pp) { warn("popen to query bridge router ports | jq"); diff --git a/test/Makefile.am b/test/Makefile.am index f1b6a87..96cae03 100644 --- a/test/Makefile.am +++ b/test/Makefile.am @@ -1,4 +1,4 @@ -EXTRA_DIST = lib.sh basic.sh sleepy.sh two.sh ipc.sh ipc-errors.sh ipc-disconnect.sh router-timeout.sh late.sh router-ports.sh static-group.sh mld.sh +EXTRA_DIST = lib.sh basic.sh sleepy.sh two.sh ipc.sh ipc-errors.sh ipc-disconnect.sh router-timeout.sh late.sh router-ports.sh static-group.sh mld.sh bridge-names.sh CLEANFILES = *~ *.trs *.log TEST_EXTENSIONS = .sh @@ -15,3 +15,4 @@ TESTS += two.sh TESTS += router-ports.sh TESTS += static-group.sh TESTS += mld.sh +TESTS += bridge-names.sh diff --git a/test/bridge-names.sh b/test/bridge-names.sh new file mode 100755 index 0000000..23d4cae --- /dev/null +++ b/test/bridge-names.sh @@ -0,0 +1,91 @@ +#!/bin/sh +# Verifies that bridge names are data, never shell: Linux allows ; $ ` | +# & > and ' in interface names, and mcd queries each bridge's router +# ports with a shell pipeline. A name that ran as a command creates its +# pwn* file in mcd's working directory. + +# shellcheck source=/dev/null +. "$(dirname "$0")/lib.sh" + +print "Check deps ..." +check_dep socat +check_dep jq +check_dep bridge + +# One row per line: description # bridge name # its router port. +# Every bridge gets a permanent router port, which must be listed. +ROWS=/tmp/$NM/rows +mkdir -p "/tmp/$NM" +cat <<'EOF' > "$ROWS" +control: plain name#br0#rp0 +control: name jq cannot take as a .key#br-ok#rp1 +command separator#b;>pwn1#rp2 +command substitution#b$(>pwn2)#rp3 +backtick substitution#b`>pwn3`#rp4 +pipe#b|>pwn4#rp5 +single quotes closing the quoting#b';>pwn5;'#rp6 +background#b&>pwn6#rp7 +EOF + +print "Creating bridges ..." +addif eth0 192.168.0.1/24 +while IFS='#' read -r desc br port; do + ip link add "$br" type bridge + ip link add "$port" type veth peer "$port"p + ip link set "$port" master "$br" + ip link set "$br" up + ip link set "$port" up + ip link set "$port"p up + bridge link set dev "$port" mcast_router 2 +done < "$ROWS" +ip -br l + +print "Creating config ..." +cat < "/tmp/$NM/config" +iface eth0 enable igmpv3 +EOF +cat "/tmp/$NM/config" + +print "Starting mcd ..." +mcd=$(pwd)/../src/mcd +(cd "/tmp/$NM" && exec "$mcd" -f "/tmp/$NM/config" -p "/tmp/$NM/pid" -l debug -n -u "/tmp/$NM/sock") & +echo $! >> "/tmp/$NM/PIDs" +sleep 2 + +for cmd in "show router-ports" "show router-ports json" "show igmp json"; do + print "Querying IPC: $cmd ..." + echo "$cmd" | socat -t 10 - UNIX-CONNECT:"/tmp/$NM/sock" > "/tmp/$NM/$(echo "$cmd" | tr ' ' -).out" + cat "/tmp/$NM/$(echo "$cmd" | tr ' ' -).out" +done + +kill_pids + +print "Analyzing ..." +fail=0 +while IFS='#' read -r desc br port; do + pwn=$(echo "$br" | sed -n 's/.*\(pwn[0-9]\).*/\1/p') + if [ -n "$pwn" ] && [ -e "/tmp/$NM/$pwn" ]; then + echo "FAIL: $desc: bridge name '$br' ran as a shell command" + fail=1 + continue + fi + if ! jq -e --arg br "$br" --arg port "$port" \ + 'any(.[]; .bridge == $br and .ports == [$port])' \ + "/tmp/$NM/show-router-ports-json.out" >/dev/null; then + echo "FAIL: $desc: show router-ports json lacks $br with router port $port" + fail=1 + continue + fi + if ! jq -e --arg br "$br" --arg port "$port" \ + '.["multicast-router-ports"] | any(.[]; .bridge == $br and .ports == [$port])' \ + "/tmp/$NM/show-igmp-json.out" >/dev/null; then + echo "FAIL: $desc: show igmp json lacks $br with router port $port" + fail=1 + continue + fi + echo "ok: $desc" +done < "$ROWS" + +[ "$fail" -eq 0 ] || FAIL + +OK From 01289b10fe68c007e3baa9bd7969f711e1a52ffd Mon Sep 17 00:00:00 2001 From: Dave Seddon Date: Mon, 19 Oct 2026 13:33:14 -0700 Subject: [PATCH 16/24] Fix timer expiry left unnormalized on a whole second timer_run() carried the expiry's tv_nsec into tv_sec only when it was above 1000000000, so an expiry landing exactly on a second kept tv_nsec at 1000000000. If the clock then read exactly that next second when timer_start() armed the itimer, it computed tv_usec 1000000, setitimer() failed with EINVAL, and as the return value is ignored no timer fired again. Very unlikely in practice; found reviewing analysis-deep's clang-tidy findings in pev.c. Carry on >= 1000000000. The Nix checks.unit test_pev #includes pev.c, wraps clock_gettime() and setitimer() to feed chosen clock readings, and checks each timer's expiry and the itimer armed from it. Before the fix both rows landing on a whole second failed, and so did arming at the next second. Signed-off-by: Dave Seddon --- src/pev.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pev.c b/src/pev.c index d4f1f90..bcdadd8 100644 --- a/src/pev.c +++ b/src/pev.c @@ -345,7 +345,7 @@ static void timer_run(int signo, void *arg) usec = timeout % 1000000; entry->expiry.tv_sec = now.tv_sec + sec; entry->expiry.tv_nsec = now.tv_nsec + (usec * 1000); - if (entry->expiry.tv_nsec > 1000000000) { + if (entry->expiry.tv_nsec >= 1000000000) { entry->expiry.tv_sec++; entry->expiry.tv_nsec -= 1000000000; } From 3044603d3b08642609b9b878a7f7b9bec75fd61c Mon Sep 17 00:00:00 2001 From: Dave Seddon Date: Mon, 19 Oct 2026 13:33:14 -0700 Subject: [PATCH 17/24] Fix mctl overflowing its argv on a long daemon command mctl builds its command table from the daemon's 'help' reply, one word per argument, into a struct cmd with room for MAXARGS (32). add_cmd() never checked, so a command of more than 32 words ran past argv[] into the rest of the heap allocated struct; at a few hundred words mctl segfaulted. The reply comes from whatever answers on the socket mctl connects to (-u / MCD_SOCK). Found reading add_cmd() while reviewing analysis-deep's clang-analyzer leak report for ipc_fetch(). Stop at MAXARGS words, with a warning. test/mctl-help.sh runs mctl -h against a fake daemon (socat) answering with commands of 1, 2, 32, 33 and 300 words, each followed by a 'version' command mctl must still list. Before the fix the 300 word row crashed mctl (exit 139); vm-check-asan is clean with it. Signed-off-by: Dave Seddon --- src/mctl.c | 5 ++++ test/Makefile.am | 3 ++- test/mctl-help.sh | 58 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 1 deletion(-) create mode 100755 test/mctl-help.sh diff --git a/src/mctl.c b/src/mctl.c index 6a11b58..2a90d12 100644 --- a/src/mctl.c +++ b/src/mctl.c @@ -89,6 +89,11 @@ static void add_cmd(char *cmd, char *opt, char *desc) if (strlen(token) < 1) continue; + if (c->argc >= MAXARGS) { + warnx("too many words in command, ignoring '%s' and on", token); + break; + } + c->argv[c->argc++] = token; } diff --git a/test/Makefile.am b/test/Makefile.am index 96cae03..afad882 100644 --- a/test/Makefile.am +++ b/test/Makefile.am @@ -1,4 +1,4 @@ -EXTRA_DIST = lib.sh basic.sh sleepy.sh two.sh ipc.sh ipc-errors.sh ipc-disconnect.sh router-timeout.sh late.sh router-ports.sh static-group.sh mld.sh bridge-names.sh +EXTRA_DIST = lib.sh basic.sh sleepy.sh two.sh ipc.sh ipc-errors.sh ipc-disconnect.sh router-timeout.sh late.sh router-ports.sh static-group.sh mld.sh bridge-names.sh mctl-help.sh CLEANFILES = *~ *.trs *.log TEST_EXTENSIONS = .sh @@ -16,3 +16,4 @@ TESTS += router-ports.sh TESTS += static-group.sh TESTS += mld.sh TESTS += bridge-names.sh +TESTS += mctl-help.sh diff --git a/test/mctl-help.sh b/test/mctl-help.sh new file mode 100755 index 0000000..ee387b1 --- /dev/null +++ b/test/mctl-help.sh @@ -0,0 +1,58 @@ +#!/bin/sh +# Verifies that mctl survives any command list from the daemon: mctl +# builds its commands from the 'help' reply, one word per argument, and +# stores up to MAXARGS (32) of them. A fake daemon (socat) answers +# with a command of N words, then a 'version' command mctl must list. + +# shellcheck source=/dev/null +. "$(dirname "$0")/lib.sh" + +print "Check deps ..." +check_dep socat + +SOCK=/tmp/$NM/sock +REPLY=/tmp/$NM/reply +mkdir -p "/tmp/$NM" +: > "$REPLY" + +print "Starting fake mcd ..." +socat UNIX-LISTEN:"$SOCK",fork SYSTEM:"cat $REPLY" & +echo $! >> "/tmp/$NM/PIDs" +tenacious 10 test -S "$SOCK" + +# One row per line: description # words in the command # mctl exit code +print "Checking mctl -h ..." +fail=0 +while IFS='#' read -r desc words expect; do + { seq -s ' ' -f 'w%g' 1 "$words" | tr -d '\n' + printf '\tLong command\n' + printf 'version\tShow daemon version\n' + } > "$REPLY" + + ../src/mctl -u "$SOCK" -h > "/tmp/$NM/out" 2>&1 + rc=$? + + if [ "$rc" -ne "$expect" ]; then + echo "FAIL: $desc: mctl -h exited $rc, expected $expect" + tail -5 "/tmp/$NM/out" + fail=1 + elif ! grep -q '^ version *Show daemon version' "/tmp/$NM/out"; then + echo "FAIL: $desc: the command after it is not listed" + cat "/tmp/$NM/out" + fail=1 + else + echo "ok: $desc" + fi +done < Date: Mon, 19 Oct 2026 13:33:14 -0700 Subject: [PATCH 18/24] Fix mcd exiting 0 when it cannot fork to daemonize daemonize took any non-zero fork() return as being the parent, so when fork() failed (-1, e.g. EAGAIN at the process limit, ENOMEM) the only process exited 0: mcd never ran, and its supervisor saw success. Found reading the daemonize code behind analysis-deep's gcc-analyzer dup2() reports. Exit 1 with an error when fork() fails, and leave the terminal only in the child. The Nix checks.unit test_daemonize runs the built mcd under an LD_PRELOAD shim whose fork() fails, and whose geteuid() says root to pass mcd's root check in the sandbox. Before the fix both fork failure rows exited 0. Signed-off-by: Dave Seddon --- src/main.c | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/main.c b/src/main.c index 4dffbc9..623d8b9 100644 --- a/src/main.c +++ b/src/main.c @@ -180,9 +180,16 @@ int main(int argc, char *argv[]) #endif /* Detach from the terminal */ - haveterminal = 0; - if (fork()) + switch (fork()) { + case -1: + err("failed daemonizing"); + exit(1); + case 0: + break; + default: exit(0); + } + haveterminal = 0; (void)close(0); (void)close(1); From 4e808fbccb0981f12506d68d0582395087d9ccf1 Mon Sep 17 00:00:00 2001 From: Dave Seddon Date: Mon, 19 Oct 2026 13:33:14 -0700 Subject: [PATCH 19/24] Update ChangeLog with the fixes since v3.0 Signed-off-by: Dave Seddon --- ChangeLog.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/ChangeLog.md b/ChangeLog.md index cb5200f..1520d1b 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -3,6 +3,31 @@ Change Log All relevant, user visible, changes are documented in this file. +[UNRELEASED][] +-------------- + +### Fixes + - Fix `mctl show router-ports` and `show igmp json` missing the router + ports of bridges with a hyphen in their name, e.g. `br-0` + - Fix shell command injection through bridge names when listing router + ports. Linux allows characters like `;` and `$` in interface names + - Fix IPC error replies, e.g. for an unknown command, never reaching + the client + - Fix `mcd` dying of `SIGPIPE` when an IPC client hangs up before + reading its reply + - Fix default router timeout (other querier present interval) ignoring + the configured `robustness`, query and response intervals, it was + always 255 sec unless `router-timeout` was set + - Fix `mcd` exiting 0 when it cannot fork to daemonize + - Fix `mctl` crashing on a daemon command of more than 32 words + - Fix timers stopping if an expiry landed exactly on a whole second + - Fix misaligned IP header access in the packet buffers, a fault on + strict-alignment targets + - Fix crashes and buffer overruns on malformed input in the `.conf` + parser, IPC commands, and `bridge mdb show` / `bridge -j` output, as + well as for a group on many bridge ports + + [v3.0][] - 2026-10-16 --------------------- From b8a02b634126062cc5c5dd74eb329727d7a7ecf0 Mon Sep 17 00:00:00 2001 From: Dave Seddon Date: Mon, 19 Oct 2026 13:33:14 -0700 Subject: [PATCH 20/24] nix: add flake with dev shell, native/cross builds and OCI images A slim flake.nix wires single-purpose modules under nix/, sharing one context from nix/context.nix: the version read from configure.ac, the runtime configure paths (/etc, /run, /var), and lib.fileset sources split so that a test script or doc edit rebuilds only what reads it. - package.nix: autotools build of mcd + mctl as one callPackage function; mcd-clang and mcd-werror are .overrides of it - cross.nix: aarch64, armv7l, riscv64 and ppc64le via pkgsCross..callPackage - oci.nix: streamLayeredImage, minimal and debug, per arch, with a slim iproute2 + jq - devshell.nix: build, test-suite and debug tools, plus mcd-configure, mcd-build, mcd-check, mcd-clean and mcd-help as writeShellApplications; mcd-help's targets come from nix/targets.nix Checks: build-gcc, build-clang and build-werror. Signed-off-by: Dave Seddon --- .gitignore | 5 + flake.lock | 61 +++++++++++ flake.nix | 138 +++++++++++++++++++++++++ nix/commands/ascii-art.nix | 16 +++ nix/commands/build.nix | 90 ++++++++++++++++ nix/commands/clean.nix | 29 ++++++ nix/commands/help.nix | 34 +++++++ nix/context.nix | 203 +++++++++++++++++++++++++++++++++++++ nix/cross.nix | 39 +++++++ nix/devshell.nix | 64 ++++++++++++ nix/oci.nix | 102 +++++++++++++++++++ nix/package.nix | 113 +++++++++++++++++++++ nix/packages.nix | 73 +++++++++++++ nix/targets.nix | 50 +++++++++ 14 files changed, 1017 insertions(+) create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 nix/commands/ascii-art.nix create mode 100644 nix/commands/build.nix create mode 100644 nix/commands/clean.nix create mode 100644 nix/commands/help.nix create mode 100644 nix/context.nix create mode 100644 nix/cross.nix create mode 100644 nix/devshell.nix create mode 100644 nix/oci.nix create mode 100644 nix/package.nix create mode 100644 nix/packages.nix create mode 100644 nix/targets.nix diff --git a/.gitignore b/.gitignore index 2350a96..e70bdc7 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,8 @@ configure Makefile Makefile.in stamp-h1 + +# Nix +result +result-* +.direnv/ diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..9f6b98c --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1791914995, + "narHash": "sha256-Zf/xfQEAasnwnmEfHqW/4hjDE/s44yn2hD4RuVIZRjA=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "b7a79d3fea1e214f6d668d521cecec0c01e26544", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1792094642, + "narHash": "sha256-zp8ToDMnqZcu4o4IH9pdwXFXGp7yLm/M0dEnNWnyd7I=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "fe13242281a67641c0102cdb1087f0ebe586c7df", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..676b291 --- /dev/null +++ b/flake.nix @@ -0,0 +1,138 @@ +# +# flake.nix for mcd +# +# A reproducible development environment, hermetic native and cross builds, +# and OCI images for the mcd IGMP/MLD querier. The design is modular: this +# slim flake.nix wires together small single-purpose modules under nix/, +# all sharing one context built in nix/context.nix. +# mcd is one callPackage function (nix/package.nix); every variant is an +# .override of it and every cross build a pkgsCross..callPackage. +# +# Enter the development shell: +# nix develop # then 'mcd-help' +# +# Build mcd + mctl: +# nix build # -> ./result/bin/{mcd,mctl} +# +# Cross-compile (from an x86_64-linux host): +# nix build .#mcd-aarch64 # also: -armv7l, -riscv64, -ppc64le +# +# Build an OCI image (streamLayeredImage: the result is a loader script): +# nix build .#oci && ./result | docker load # also: oci-debug, oci-[-debug] +# +# If flakes are not enabled, prefix commands with: +# nix --extra-experimental-features 'nix-command flakes' +# +# NOTE: flakes only see git-tracked files. After adding or editing files +# under nix/ (or this flake), `git add` them before `nix build`/`nix +# develop`, or the changes are invisible to the evaluation. +# +{ + description = "mcd — IGMP/MLD querier for Linux bridges"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + # Linux-only: mcd uses AF_PACKET and netlink. eachSystem (not + # eachDefaultSystem) keeps the flake from generating darwin attributes + # that could only fail. + outputs = + { + self, + nixpkgs, + flake-utils, + }: + flake-utils.lib.eachSystem [ "x86_64-linux" "aarch64-linux" ] ( + system: + let + pkgs = nixpkgs.legacyPackages.${system}; + lib = nixpkgs.lib; + + # Shared build context: sources, package sets, version, configure + # paths, and mcd itself. Built once, threaded into every module. + ctx = import ./nix/context.nix { inherit pkgs lib; }; + + # The package, and its variants as overrides of the same recipe. + inherit (ctx) mcd; + mcd-clang = mcd.override { + stdenv = pkgs.clangStdenv; + pnameSuffix = "-clang"; + }; + mcd-werror = mcd.override { + werror = true; + pnameSuffix = "-werror"; + }; + + devshell = import ./nix/devshell.nix { inherit ctx; }; + + # OCI images (nix/oci.nix, a callPackage function), minimal and + # debug: oci[-debug] for the native arch, oci-[-debug] per + # cross arch, the tag suffixed with the arch so they don't collide. + mkOciPair = + callPackage: mcd: archSuffix: + lib.mapAttrs' + ( + variant: suffix: + lib.nameValuePair "oci${archSuffix}${suffix}" ( + callPackage ./nix/oci.nix { inherit mcd archSuffix variant; } + ) + ) + { + minimal = ""; + debug = "-debug"; + }; + + # Only from an x86_64-linux host: + # cross mcd per arch ({ pkgs, mcd }); on a native aarch64 + # host the default package already covers arm64 + isX86_64 = system == "x86_64-linux"; + x86_64 = lib.optionalAttrs isX86_64 ( + let + cross = import ./nix/cross.nix { inherit ctx; }; + in + { + packages = + lib.mapAttrs' (arch: r: lib.nameValuePair "mcd-${arch}" r.mcd) cross + // lib.concatMapAttrs (arch: r: mkOciPair r.pkgs.callPackage r.mcd "-${arch}") cross; + } + ); + + packages = { + inherit mcd mcd-clang mcd-werror; + default = mcd; + } + // mkOciPair pkgs.callPackage mcd "" + // x86_64.packages or { }; + in + { + inherit packages; + + checks = { + build-gcc = mcd; + build-clang = mcd-clang; + build-werror = mcd-werror; + }; + + devShells.default = devshell; + + # `nix fmt` with no args formats every tracked .nix file (nixfmt + # alone would read stdin); with args it formats just those. + formatter = pkgs.writeShellApplication { + name = "mcd-fmt"; + runtimeInputs = [ + pkgs.nixfmt + pkgs.git + ]; + text = '' + if [ "$#" -eq 0 ]; then + git ls-files -z '*.nix' | xargs -0 -r nixfmt + else + nixfmt "$@" + fi + ''; + }; + } + ); +} diff --git a/nix/commands/ascii-art.nix b/nix/commands/ascii-art.nix new file mode 100644 index 0000000..05971ce --- /dev/null +++ b/nix/commands/ascii-art.nix @@ -0,0 +1,16 @@ +# nix/commands/ascii-art.nix +# +# ASCII banner printed on entry to `nix develop`. Returns a bash snippet +# spliced into devshell.nix's welcome. Each line is a single-quoted printf +# argument, so backslashes are reproduced verbatim. +{ }: +'' + printf '\033[0;32m%s\033[0m\n' \ + ' _ __ ___ ___ __| |' \ + ' | '"'"'_ ` _ \ / __/ _` |' \ + ' | | | | | | (_| (_| |' \ + ' |_| |_| |_|\___\__,_|' \ + "" \ + ' querier --- query ---> [ br0 ] ---> hosts' \ + ' IGMP / MLD for Linux bridges' +'' diff --git a/nix/commands/build.nix b/nix/commands/build.nix new file mode 100644 index 0000000..16d3ccd --- /dev/null +++ b/nix/commands/build.nix @@ -0,0 +1,90 @@ +# nix/commands/build.nix +# +# Build-related dev-shell commands for mcd, each a writeShellApplication: +# shellchecked, with exactly the tools it needs on PATH. All of them work +# in-tree from the repo root (autotools builds in place, like CI). +# +# Usage: +# build = import ./commands/build.nix { inherit ctx root; }; +# [ build.mcd-configure build.mcd-build build.mcd-check ] +{ ctx, root }: + +let + inherit (ctx) + pkgs + lib + packages + runtimePaths + ; +in +rec { + # Generate and run configure. Defaults to the runtime paths the Nix + # package uses plus --enable-test; extra args are forwarded, e.g. + # mcd-configure --with-group=wheel + # CC=clang mcd-configure + mcd-configure = pkgs.writeShellApplication { + name = "mcd-configure"; + runtimeInputs = [ + pkgs.autoconf + pkgs.automake + pkgs.libtool + pkgs.pkg-config + pkgs.bison + pkgs.gnumake + pkgs.gcc + pkgs.coreutils + pkgs.gnused + pkgs.gnugrep + pkgs.gawk + pkgs.git + ]; + text = '' + ${root} + cd "$(mcd_root)" + ./autogen.sh + ./configure ${lib.escapeShellArgs runtimePaths} --enable-test "$@" + ''; + }; + + # Build. Configures first if needed. Extra args go to make. + mcd-build = pkgs.writeShellApplication { + name = "mcd-build"; + runtimeInputs = [ + mcd-configure + pkgs.gnumake + pkgs.gcc + pkgs.bison + pkgs.coreutils + pkgs.git + ]; + text = '' + ${root} + cd "$(mcd_root)" + if [ ! -f Makefile ]; then + mcd-configure + fi + make -j"$(nproc)" "$@" + ''; + }; + + # Run the network test suite on the host, as CI does. It runs under + # `unshare -mrun --map-auto`, so it needs unprivileged user namespaces; + # on Ubuntu with AppArmor first run: + # sudo sysctl kernel.apparmor_restrict_unprivileged_userns=0 + # `nix run .#vm-check` runs the same suite in a microVM instead. + mcd-check = pkgs.writeShellApplication { + name = "mcd-check"; + runtimeInputs = [ + mcd-build + pkgs.coreutils + pkgs.git + ] + ++ packages.testTools; + text = '' + ${root} + cd "$(mcd_root)" + mcd-build + make check "$@" + ''; + }; +} diff --git a/nix/commands/clean.nix b/nix/commands/clean.nix new file mode 100644 index 0000000..29ffb8e --- /dev/null +++ b/nix/commands/clean.nix @@ -0,0 +1,29 @@ +# nix/commands/clean.nix +# +# mcd-clean, a writeShellApplication: removes build output and everything +# ./autogen.sh + ./configure generated (the same set .gitignore lists). +# +# Usage: +# mcd-clean = import ./commands/clean.nix { inherit pkgs root; }; +{ pkgs, root }: + +pkgs.writeShellApplication { + name = "mcd-clean"; + runtimeInputs = [ + pkgs.gnumake + pkgs.coreutils + pkgs.findutils + pkgs.git + ]; + text = '' + ${root} + cd "$(mcd_root)" + if [ -f Makefile ]; then + make distclean >/dev/null + fi + rm -rf autom4te.cache .aux aclocal.m4 configure config.h.in config.h.in~ \ + compile_commands.json + find . -name Makefile.in -not -path './.git/*' -delete + echo "Cleaned $(pwd)" + ''; +} diff --git a/nix/commands/help.nix b/nix/commands/help.nix new file mode 100644 index 0000000..20cf379 --- /dev/null +++ b/nix/commands/help.nix @@ -0,0 +1,34 @@ +# nix/commands/help.nix +# +# mcd-help, a writeShellApplication printing the dev-shell commands and the +# flake targets, from nix/targets.nix. +# +# Usage: +# mcd-help = import ./commands/help.nix { inherit pkgs lib targets; }; +{ + pkgs, + lib, + targets, +}: + +let + # The command, left-aligned in 32 columns, then its description. + row = r: " ${r.cmd}${lib.strings.replicate (32 - lib.stringLength r.cmd) " "}${r.desc}"; + section = s: "\n${s.title}:\n" + lib.concatMapStringsSep "\n" row s.rows + "\n"; +in +pkgs.writeShellApplication { + name = "mcd-help"; + text = '' + cat <<'EOF' + mcd dev shell — available commands (each a nix-built command): + + mcd-configure [configure args] autogen + configure (--enable-test, /etc, /run) + mcd-build [make args] Build in-tree (auto-configures if needed) + mcd-check [make args] Run the network test suite (needs userns) + mcd-clean Remove build and autotools output + mcd-help Show this help + + Compiler: CC=gcc by default; `CC=clang mcd-configure` for clang. + ${lib.concatMapStrings section targets}EOF + ''; +} diff --git a/nix/context.nix b/nix/context.nix new file mode 100644 index 0000000..bcd707b --- /dev/null +++ b/nix/context.nix @@ -0,0 +1,203 @@ +# nix/context.nix +# +# Shared build context for the mcd flake. It is built once in flake.nix and +# threaded (as `ctx`) into every module, so none of them re-imports +# packages.nix, re-hardcodes the version or rebuilds mcd's recipe. +# +# Fields: +# pkgs, lib the flake's per-system inputs, passed through +# srcBuild the source configure + make read (no test scripts) +# srcTest srcBuild + test/, for the --enable-test trees +# srcAnalysis srcBuild without the docs, for the analysis builds +# srcCode src/ + lib/ only, for the source scanners +# srcConf the sample mcd.conf alone +# mcd the package (package.nix); variants are mcd.override +# packages dependency / tool sets (nix/packages.nix) +# version read from AC_INIT in configure.ac — the single source +# runtimeDirs the runtime dirs baked into mcd (/etc, /var, /run) +# runtimePaths runtimeDirs as configure flags +# configureFlags runtimePaths + sbindir=$out/bin, for the package build +# configureVpath shell snippet: autoreconf + out-of-tree configure (analysis) +# scrubStore sed program stripping /nix/store/-/ +# sanitizerFlags ASan + UBSan, aborting on the first finding +# inTreeDiagnostics installPhase snippet: build check + in-tree report.txt +# mkAnalysisDrv base derivation for the analysis builds +# +# Usage: +# ctx = import ./nix/context.nix { inherit pkgs lib; }; + +{ pkgs, lib }: + +let + packages = import ./packages.nix { inherit pkgs; }; + + # AC_INIT([mcd], [3.0], [...]) -> "3.0" + version = + let + matches = lib.filter (m: m != null) ( + map (builtins.match "AC_INIT[(][[]mcd[]], *[[]([0-9A-Za-z.~-]+)[]].*") ( + lib.splitString "\n" (builtins.readFile ../configure.ac) + ) + ); + in + if matches == [ ] then + throw "context.nix: cannot parse AC_INIT version from configure.ac" + else + lib.head (lib.head matches); + + # Sources, as small as each consumer allows, so an edit only rebuilds + # what reads it. None includes nix/ or the flake. + # srcBuild what configure + make read: the autotools files, src/, + # lib/, man/, the installed docs (doc_DATA, EXTRA_DIST) and, + # from test/, only the Makefile.am configure needs + # srcTest srcBuild + test/, for the --enable-test trees + # srcAnalysis configure + make -C src, for the analysis and fuzz + # builds: srcBuild without the docs (automake is `foreign`) + # srcCode src/ + lib/, for the tools that only scan C source + fs = lib.fileset; + srcAnalysisFiles = fs.unions [ + ../configure.ac + ../Makefile.am + ../src + ../lib + ../man + ../test/Makefile.am + ]; + srcAnalysis = fs.toSource { + root = ../.; + fileset = srcAnalysisFiles; + }; + srcBuildFiles = fs.unions [ + srcAnalysisFiles + ../README.md + ../ChangeLog.md + ../LICENSE + ../mcd.conf + (fs.fileFilter (f: f.hasExt "svg") ../doc) + ]; + srcBuild = fs.toSource { + root = ../.; + fileset = srcBuildFiles; + }; + srcTest = fs.toSource { + root = ../.; + fileset = fs.union srcBuildFiles ../test; + }; + # The sample mcd.conf alone (the fuzz config corpus seeds from it). + srcConf = fs.toSource { + root = ../.; + fileset = ../mcd.conf; + }; + srcCode = fs.toSource { + root = ../.; + fileset = fs.unions [ + ../src + ../lib + ]; + }; + + # Bake the real runtime paths into the binary (SYSCONFDIR, RUNSTATEDIR, + # PRESERVEDIR in src/Makefile.am) instead of $out/etc, $out/var/run. The + # sample mcd.conf still installs under $out/share/doc. sbindir=$out/bin so + # `nix run` and lib.getExe find mcd. Builds that skip configure (the fuzz + # harnesses) get the same values as -D flags from runtimeDirs. + runtimeDirs = { + sysconfdir = "/etc"; + localstatedir = "/var"; + runstatedir = "/run"; + }; + runtimePaths = lib.mapAttrsToList (n: v: "--${n}=${v}") runtimeDirs; + configureFlags = runtimePaths ++ [ "--sbindir=${placeholder "out"}/bin" ]; + + # The analysis builds configure out of tree, in $srcTop/_build, invoking + # configure by absolute path. automake then hands the compiler absolute + # source paths, so every diagnostic reads $srcTop/src/foo.c and strips to + # the repo-relative src/foo.c. Expects `srcTop` set by the caller; extra + # configure arguments (already quoted) go in `extra`. + configureVpath = extra: '' + (cd "$srcTop" && autoreconf -fi) + mkdir -p "$srcTop/_build" + cd "$srcTop/_build" + "$srcTop/configure" ${lib.escapeShellArgs runtimePaths} ${extra} + ''; + + # Strip /nix/store/-/ prefixes so paths in a report read as + # repo-relative. + scrubStore = ''sed 's|/nix/store/[a-z0-9]\{32\}-[^/]*/||g' ''; + + # The sanitizers the unit tests and fuzz harnesses build with: any + # finding aborts the run. + sanitizerFlags = "-fsanitize=address,undefined -fno-sanitize-recover=undefined"; + + # installPhase snippet shared by the in-tree analysis builds (gcc, + # clang-analyzer): fail unless the build produced mcd, then write + # $out/report.txt with the repo-relative src/ and lib/ diagnostics from + # `log` (cfparse.y lines via bison's #line directives). + inTreeDiagnostics = name: log: '' + if [ ! -x "$srcTop/_build/src/mcd" ]; then + tail -30 "${log}" >&2 + echo "${name}: build failed; report is incomplete" >&2 + exit 1 + fi + mkdir -p $out + grep -E ': warning:|: error:' "${log}" \ + | sed "s|$srcTop/||g" \ + | grep -E '^(src|lib)/' > $out/report.txt || true + ''; + + # Base derivation for the analysis builds: srcAnalysis (they `make -C + # src`, which builds the lib/ replacements too), autotools without the + # setup hook (configureVpath runs autoreconf itself), and no + # install/fixup machinery. Callers supply buildPhase / installPhase and may + # add tools via `extraNativeBuildInputs`; anything they pass wins. + mkAnalysisDrv = + { + name, + extraNativeBuildInputs ? [ ], + ... + }@args: + pkgs.stdenv.mkDerivation ( + { + pname = "mcd-analysis-${name}"; + inherit version; + src = srcAnalysis; + nativeBuildInputs = [ + pkgs.autoconf + pkgs.automake + pkgs.bison + ] + ++ extraNativeBuildInputs; + dontConfigure = true; + dontFixup = true; + doCheck = false; + } + // removeAttrs args [ + "name" + "extraNativeBuildInputs" + ] + ); + + ctx = { + inherit + pkgs + lib + srcBuild + srcAnalysis + srcTest + srcCode + srcConf + packages + version + runtimeDirs + runtimePaths + configureFlags + configureVpath + scrubStore + sanitizerFlags + inTreeDiagnostics + mkAnalysisDrv + ; + mcd = pkgs.callPackage ./package.nix { inherit ctx; }; + }; +in +ctx diff --git a/nix/cross.nix b/nix/cross.nix new file mode 100644 index 0000000..7df8a8d --- /dev/null +++ b/nix/cross.nix @@ -0,0 +1,39 @@ +# nix/cross.nix +# +# Cross-compiled mcd for each non-native target architecture. +# +# mcd is plain autotools + libc with no host code generator, so nixpkgs' +# ready-made cross package sets (pkgs.pkgsCross.) do everything: +# callPackage on package.nix there splices autoreconfHook and bison to +# build-machine tools and stdenv to the cross compiler. +# +# Returns { = { pkgs; mcd; }; }: the cross package set (for the OCI +# images) and the cross-built mcd. +# +# Usage (from flake.nix, on an x86_64-linux host): +# cross = import ./nix/cross.nix { inherit ctx; }; +# cross.aarch64.mcd + +{ ctx }: + +let + # Short arch names -> pkgsCross targets (lib.systems.examples), which pin + # the ABI/endianness explicitly: + # armv7l -> hard-float EABI ppc64le -> 64-bit little-endian ELFv2 + targets = { + aarch64 = "aarch64-multiplatform"; + armv7l = "armv7l-hf-multiplatform"; + riscv64 = "riscv64"; + ppc64le = "powernv"; + }; +in +builtins.mapAttrs ( + _: target: + let + pkgs = ctx.pkgs.pkgsCross.${target}; + in + { + inherit pkgs; + mcd = pkgs.callPackage ./package.nix { inherit ctx; }; + } +) targets diff --git a/nix/devshell.nix b/nix/devshell.nix new file mode 100644 index 0000000..d6693fc --- /dev/null +++ b/nix/devshell.nix @@ -0,0 +1,64 @@ +# nix/devshell.nix +# +# Development shell for mcd: build tools, the test suite's tools, and the +# analysis/debug tools, plus the mcd-* commands from nix/commands/*.nix: +# writeShellApplications, so each is shellchecked and carries the tools it +# needs, and works in `nix develop -c mcd-build` too. +# +# Usage: +# devshell = import ./nix/devshell.nix { inherit ctx; }; +# devShells.default = devshell; + +{ ctx }: + +let + inherit (ctx) pkgs lib packages; + + # Shared by the commands: the repo root, from git or the current dir. + root = '' + mcd_root() { + git rev-parse --show-toplevel 2>/dev/null || pwd + } + ''; + + build = import ./commands/build.nix { inherit ctx root; }; + commands = [ + build.mcd-configure + build.mcd-build + build.mcd-check + (import ./commands/clean.nix { inherit pkgs root; }) + (import ./commands/help.nix { + inherit pkgs lib; + targets = import ./targets.nix; + }) + ]; + asciiArt = import ./commands/ascii-art.nix { }; + + colored-prompt = '' + export PS1="\[\033[0;32m\][mcd] \[\033[01;34m\][\u@\h:\w]\$ \[\033[0m\]" + ''; + + welcome = '' + ${asciiArt} + echo " mcd ${ctx.version}, $(gcc --version | head -1)" + echo " Tests: tshark, socat, ethtool, unshare, iproute2" + echo " Analysis: cppcheck, clang-tidy, flawfinder, semgrep, scan-build, bear" + echo " Type 'mcd-help' for commands." + ''; +in +pkgs.mkShell { + packages = packages.allPackages ++ commands; + + # Symbolized ASan/UBSan/libFuzzer traces. + ASAN_SYMBOLIZER_PATH = "${pkgs.llvm}/bin/llvm-symbolizer"; + + shellHook = '' + # Pin the in-tree build to gcc, matching the Nix package and CI. Set in + # shellHook because pkgs.clang's setup hook exports CC=clang during shell + # init; `CC=clang mcd-configure` still works for a clang build. + export CC=gcc + + ${colored-prompt} + ${welcome} + ''; +} diff --git a/nix/oci.nix b/nix/oci.nix new file mode 100644 index 0000000..3428c7f --- /dev/null +++ b/nix/oci.nix @@ -0,0 +1,102 @@ +# nix/oci.nix +# +# Reproducible OCI (Docker) image for mcd built straight from its Nix +# closure — no Dockerfile. `dockerTools.streamLayeredImage` produces a script +# that streams content-addressed layers to `docker load`, so nothing large is +# materialized in the store and layers dedupe across rebuilds. +# +# Two variants (the `variant` arg): +# "minimal" mcd + mctl + the tools mcd shells out to (iproute2, jq). +# No shell; use `docker exec mctl show`. +# "debug" minimal + bash + coreutils. Tagged `-debug`. +# +# A callPackage function: the image architecture follows the package set +# it is called from, native pkgs for amd64 or a nix/cross.nix pkgsCross set +# with that set's cross-built mcd. +# +# Runtime requirements an image cannot express: mcd opens AF_PACKET sockets +# (NET_RAW), uses netlink and multicast membership (NET_ADMIN), and must see +# the bridge it serves, so run with +# docker run --network host --cap-add NET_ADMIN --cap-add NET_RAW mcd: +# +# Usage (from flake.nix): +# oci = pkgs.callPackage ./nix/oci.nix { inherit mcd; }; +# oci-debug = pkgs.callPackage ./nix/oci.nix { inherit mcd; variant = "debug"; }; + +{ + lib, + dockerTools, + iproute2, + db, + libmnl, + jq, + bashInteractive, + coreutils, + mcd, + variant ? "minimal", + # Appended to the tag so cross images don't overwrite the native one in + # the local image store: mcd:3.0-aarch64, mcd:3.0-aarch64-debug. + archSuffix ? "", + tag ? null, +}: + +let + isDebug = variant == "debug"; + inherit (mcd) version; + imageTag = + if tag != null then tag else "${version}${archSuffix}${lib.optionalString isDebug "-debug"}"; + + # mcd only runs `ip` and `bridge`. Dropping iptables (tc xt), libbpf/elfutils + # (BPF loading) and python3 (routel) removes curl/openssl/krb5 from the + # closure and avoids iptables -> libpcap -> rdma-core, which does not + # cross-build for armv7l. + iproute2Lean = iproute2.overrideAttrs (_: { + buildInputs = [ + db + libmnl + ]; + }); + + contents = [ + mcd + iproute2Lean + jq + dockerTools.fakeNss + ] + ++ lib.optionals isDebug [ + bashInteractive + coreutils + ]; +in +dockerTools.streamLayeredImage { + name = "mcd"; + tag = imageTag; + inherit contents; + + # Writable runtime dirs (pid file + IPC socket in /run, /tmp for popen'd + # tools) and the sample config at the path baked into the binary. Paths are + # relative to the image root. + extraCommands = '' + mkdir -p tmp run etc + chmod 1777 tmp + cp ${mcd.src}/mcd.conf etc/mcd.conf + ''; + + config = { + # Foreground, logging to stderr; extra args (e.g. `-l debug`) append. + Entrypoint = [ + "${mcd}/bin/mcd" + "-n" + ]; + Env = [ "PATH=/bin" ]; + WorkingDir = "/"; + Labels = { + "org.opencontainers.image.title" = "mcd"; + "org.opencontainers.image.description" = "IGMP/MLD querier for Linux bridges"; + "org.opencontainers.image.source" = "https://github.com/kernelkit/mcd"; + "org.opencontainers.image.licenses" = "ISC"; + "org.opencontainers.image.version" = version; + "org.opencontainers.image.variant" = variant; + }; + }; +} diff --git a/nix/package.nix b/nix/package.nix new file mode 100644 index 0000000..9daa5f0 --- /dev/null +++ b/nix/package.nix @@ -0,0 +1,113 @@ +# nix/package.nix +# +# Autotools build of mcd (the querier daemon) and mctl (its client), as a +# callPackage-style package: nixpkgs supplies stdenv and the build tools, +# and every variant is an .override of the one recipe (ctx.mcd): +# +# ctx.mcd gcc, as CI +# ctx.mcd.override { stdenv = pkgs.clangStdenv; } clang +# ctx.mcd.override { werror = true; } +# ctx.mcd.override { sanitizers = [ "address" "undefined" ]; } +# ctx.mcd.override { enableTest = true; installBuildTree = true; } +# pkgs.pkgsCross..callPackage ./package.nix { inherit ctx; } +# +# autoreconfHook stands in for ./autogen.sh; the generic configure / make / +# make install phases do the rest. Taking autoreconfHook and bison as +# arguments lets nixpkgs pick build-machine copies when cross-compiling. +# +# The network test suite is not run here (doCheck = false): it needs network +# namespaces, bridges and raw sockets, which the build sandbox lacks. It runs +# in the microVM instead (nix/microvms/), against an `installBuildTree` build. + +{ + lib, + stdenv, + autoreconfHook, + bison, + # The flake's shared context (nix/context.nix): sources, version and + # configure flags, all system-independent. + ctx, + pnameSuffix ? "", + werror ? false, + # -fsanitize= values, e.g. [ "address" "undefined" ] or [ "thread" ]. + sanitizers ? [ ], + # -fno-sanitize= values, e.g. [ "alignment" ]. + noSanitize ? [ ], + # --enable-test: adds test/ to SUBDIRS so `make check` runs the suite. + enableTest ? false, + # Install the configured + built tree under $out/share/mcd/build, so the + # microVM can run `make check` against exactly these binaries. (Not + # keepBuildTree: callPackage would fill that from nixpkgs' setup hook.) + installBuildTree ? false, + extraConfigureFlags ? [ ], +}: + +let + inherit (ctx) version; + + sanFlags = lib.optionalString (sanitizers != [ ]) ( + "-fsanitize=${lib.concatStringsSep "," sanitizers} -fno-omit-frame-pointer -g" + + lib.optionalString (noSanitize != [ ]) " -fno-sanitize=${lib.concatStringsSep "," noSanitize}" + ); +in +stdenv.mkDerivation { + pname = "mcd${pnameSuffix}"; + inherit version; + # Test scripts only reach the --enable-test trees, so editing one does + # not rebuild the package. + src = if enableTest then ctx.srcTest else ctx.srcBuild; + + nativeBuildInputs = [ + autoreconfHook + bison + ]; + + configureFlags = + ctx.configureFlags ++ lib.optional enableTest "--enable-test" ++ extraConfigureFlags; + + env = { + NIX_CFLAGS_COMPILE = lib.concatStringsSep " " ( + lib.optional werror "-Werror" ++ lib.optional (sanFlags != "") sanFlags + ); + } + // lib.optionalAttrs (sanFlags != "") { + NIX_CFLAGS_LINK = sanFlags; + }; + + # _FORTIFY_SOURCE interposes on the libc calls the sanitizers intercept, + # hiding bugs from ASan and confusing TSan. + hardeningDisable = lib.optional (sanitizers != [ ]) "fortify"; + + enableParallelBuilding = true; + + doCheck = false; + + # A kept tree is for running under sanitizers / valgrind in the microVM: + # keep its symbols so the reports are readable. + dontStrip = installBuildTree; + + postInstall = lib.optionalString installBuildTree '' + mkdir -p "$out/share/mcd" + cp -a . "$out/share/mcd/build" + ''; + + # Same smoke test as CI: both binaries start and print usage. + doInstallCheck = true; + installCheckPhase = '' + runHook preInstallCheck + # `mcd -h` exits without freeing its startup allocations; that is not + # what a sanitizer build is for. + export ASAN_OPTIONS=detect_leaks=0 + "$out/bin/mcd" -h >/dev/null + "$out/bin/mctl" -h >/dev/null + runHook postInstallCheck + ''; + + meta = { + description = "IGMP/MLD querier for Linux bridges"; + homepage = "https://github.com/kernelkit/mcd"; + license = lib.licenses.isc; + platforms = lib.platforms.linux; + mainProgram = "mcd"; + }; +} diff --git a/nix/packages.nix b/nix/packages.nix new file mode 100644 index 0000000..caf1023 --- /dev/null +++ b/nix/packages.nix @@ -0,0 +1,73 @@ +# nix/packages.nix +# +# Package sets for mcd, split into: +# - buildTools: autotools + bison for the dev shell's in-tree build +# (package.nix takes its own through callPackage) +# - testTools: what test/*.sh needs at run time (dev shell + microVM) +# - runtimeTools: what mcd shells out to (ip, bridge, jq) — OCI + microVM +# - devTools: extra tools for the interactive dev shell only +# +# mcd links against nothing but libc, so there is no buildInputs list. +# +# Usage: +# packages = import ./nix/packages.nix { inherit pkgs; }; + +{ pkgs }: + +rec { + # autoreconfHook brings autoconf, automake and libtool for ./autogen.sh; + # bison provides the yacc AC_PROG_YACC finds for src/cfparse.y. + buildTools = [ + pkgs.autoreconfHook + pkgs.bison + ]; + + # mcd runs `ip`, `bridge` and `jq` via popen (src/bridge.c, src/ipc.c). + runtimeTools = [ + pkgs.iproute2 + pkgs.jq + ]; + + # test/*.sh: namespaces (unshare/nsenter), capture (tshark), IPC (socat), + # link tweaks (ethtool), plus the runtime tools above. + testTools = runtimeTools ++ [ + pkgs.util-linux + pkgs.tshark + pkgs.socat + pkgs.ethtool + pkgs.gnumake + ]; + + # Development-only tools (the dev workflow and the analysis framework). + devTools = [ + # Debugging / dynamic analysis + pkgs.gdb + pkgs.valgrind + pkgs.strace + pkgs.ltrace + + # Compilers / tooling + pkgs.gcc + pkgs.clang + pkgs.clang-tools # clang-tidy, clang-format + pkgs.clang-analyzer # scan-build + pkgs.llvm # llvm-symbolizer for sanitizer / fuzzer traces + pkgs.bear # compile_commands.json for autotools + + # Static analysis + pkgs.cppcheck + pkgs.flawfinder + pkgs.semgrep + + # Scripts and VM console drivers + pkgs.shellcheck + pkgs.expect + + # Misc + pkgs.git + pkgs.nixfmt + ]; + + # Combined list for the dev shell. + allPackages = buildTools ++ testTools ++ devTools; +} diff --git a/nix/targets.nix b/nix/targets.nix new file mode 100644 index 0000000..52069de --- /dev/null +++ b/nix/targets.nix @@ -0,0 +1,50 @@ +# nix/targets.nix +# +# The flake targets mcd-help lists: the help text is generated from this +# list. +# +# Each section: { title; rows = [ { cmd; desc; target ? null; } ]; }, with +# `target` the package or app name the row runs (null: no single one). +# +# Usage: +# targets = import ./nix/targets.nix; + +[ + { + title = "Nix build targets (from the repo root; `nix flake show` lists everything)"; + rows = [ + { + cmd = "nix build"; + desc = "mcd + mctl -> ./result/bin"; + } + { + cmd = "nix build .#mcd-clang"; + desc = "built with clang"; + target = "mcd-clang"; + } + { + cmd = "nix build .#mcd-werror"; + desc = "built with -Werror"; + target = "mcd-werror"; + } + { + cmd = "nix build .#mcd-aarch64"; + desc = "cross: also -armv7l, -riscv64, -ppc64le"; + target = "mcd-aarch64"; + } + { + cmd = "nix build .#oci"; + desc = "OCI image (also: oci-debug, oci-[-debug])"; + target = "oci"; + } + { + cmd = ""; + desc = "load with: ./result | docker load"; + } + { + cmd = "nix flake check"; + desc = "hermetic checks"; + } + ]; + } +] From 46ebd0452dece0b9ece637ff5a2a31d447d08762 Mon Sep 17 00:00:00 2001 From: Dave Seddon Date: Mon, 19 Oct 2026 13:33:14 -0700 Subject: [PATCH 21/24] nix: add static analysis with triage, and C unit tests Per-tool report derivations (clang-tidy, cppcheck, flawfinder, semgrep, scan-build, gcc warnings, gcc -fanalyzer) and quick/standard/deep aggregates that run the triage prioritiser, adapted from rocm-ernic for autotools: - the compile DB is recorded with bear around a real out-of-tree build, so it includes the bison-generated cfparse.c - build-driven tools configure by absolute path in _build/, so every diagnostic strips to a repo-relative src/ path - each runner fails when its run is incomplete instead of reporting 0 - a new semgrep rule, strtok-result-unchecked, with fixtures Every report is a package, analysis- or analysis-. checks.unit holds table-driven tests of mcd internals built with gcc + ASan/UBSan: test_pev (#includes pev.c, wraps clock_gettime() and setitimer() to check timer expiry and the itimer armed from it) and test_daemonize (mcd under an LD_PRELOAD shim whose fork() fails). Checks: analysis-runners (table-driven tests of the triage parsers, filters and semgrep-report.jq), semgrep-rules and unit. Signed-off-by: Dave Seddon --- .gitignore | 2 + flake.nix | 18 +- nix/analysis/clang-analyzer.nix | 94 +++++ nix/analysis/clang-tidy.nix | 59 ++++ nix/analysis/compile-db.nix | 57 +++ nix/analysis/cppcheck.nix | 64 ++++ nix/analysis/default.nix | 185 ++++++++++ nix/analysis/flawfinder.nix | 47 +++ nix/analysis/gcc.nix | 84 +++++ nix/analysis/semgrep-report.jq | 21 ++ nix/analysis/semgrep-rules.yaml | 332 ++++++++++++++++++ nix/analysis/semgrep-tests/goto-usage.c | 60 ++++ .../semgrep-tests/malloc-no-null-check.c | 298 ++++++++++++++++ .../semgrep-tests/strtok-result-unchecked.c | 94 +++++ nix/analysis/semgrep.nix | 117 ++++++ nix/analysis/test_runners.py | 251 +++++++++++++ nix/analysis/triage/__main__.py | 71 ++++ nix/analysis/triage/filters.py | 123 +++++++ nix/analysis/triage/finding.py | 48 +++ nix/analysis/triage/parsers/__init__.py | 49 +++ nix/analysis/triage/parsers/clang.py | 36 ++ nix/analysis/triage/parsers/cppcheck.py | 35 ++ nix/analysis/triage/parsers/flawfinder.py | 37 ++ nix/analysis/triage/parsers/semgrep.py | 45 +++ nix/analysis/triage/reports.py | 170 +++++++++ nix/analysis/triage/scoring.py | 172 +++++++++ nix/analysis/unit.nix | 80 +++++ nix/analysis/unit/fork_fail.c | 24 ++ nix/analysis/unit/test_daemonize.sh | 40 +++ nix/analysis/unit/test_pev.c | 140 ++++++++ nix/targets.nix | 20 ++ 31 files changed, 2872 insertions(+), 1 deletion(-) create mode 100644 nix/analysis/clang-analyzer.nix create mode 100644 nix/analysis/clang-tidy.nix create mode 100644 nix/analysis/compile-db.nix create mode 100644 nix/analysis/cppcheck.nix create mode 100644 nix/analysis/default.nix create mode 100644 nix/analysis/flawfinder.nix create mode 100644 nix/analysis/gcc.nix create mode 100644 nix/analysis/semgrep-report.jq create mode 100644 nix/analysis/semgrep-rules.yaml create mode 100644 nix/analysis/semgrep-tests/goto-usage.c create mode 100644 nix/analysis/semgrep-tests/malloc-no-null-check.c create mode 100644 nix/analysis/semgrep-tests/strtok-result-unchecked.c create mode 100644 nix/analysis/semgrep.nix create mode 100644 nix/analysis/test_runners.py create mode 100644 nix/analysis/triage/__main__.py create mode 100644 nix/analysis/triage/filters.py create mode 100644 nix/analysis/triage/finding.py create mode 100644 nix/analysis/triage/parsers/__init__.py create mode 100644 nix/analysis/triage/parsers/clang.py create mode 100644 nix/analysis/triage/parsers/cppcheck.py create mode 100644 nix/analysis/triage/parsers/flawfinder.py create mode 100644 nix/analysis/triage/parsers/semgrep.py create mode 100644 nix/analysis/triage/reports.py create mode 100644 nix/analysis/triage/scoring.py create mode 100644 nix/analysis/unit.nix create mode 100644 nix/analysis/unit/fork_fail.c create mode 100644 nix/analysis/unit/test_daemonize.sh create mode 100644 nix/analysis/unit/test_pev.c diff --git a/.gitignore b/.gitignore index e70bdc7..f9faab6 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,5 @@ stamp-h1 result result-* .direnv/ +compile_commands.json +__pycache__/ diff --git a/flake.nix b/flake.nix index 676b291..0784915 100644 --- a/flake.nix +++ b/flake.nix @@ -4,7 +4,7 @@ # A reproducible development environment, hermetic native and cross builds, # and OCI images for the mcd IGMP/MLD querier. The design is modular: this # slim flake.nix wires together small single-purpose modules under nix/, -# all sharing one context built in nix/context.nix. +# all sharing one context built in nix/context.nix (see nix/README.md). # mcd is one callPackage function (nix/package.nix); every variant is an # .override of it and every cross build a pkgsCross..callPackage. # @@ -17,6 +17,9 @@ # Cross-compile (from an x86_64-linux host): # nix build .#mcd-aarch64 # also: -armv7l, -riscv64, -ppc64le # +# Run the static analysis (nix/README.md lists every target): +# nix build .#analysis-deep && cat result/summary.txt +# # Build an OCI image (streamLayeredImage: the result is a loader script): # nix build .#oci && ./result | docker load # also: oci-debug, oci-[-debug] # @@ -67,6 +70,12 @@ devshell = import ./nix/devshell.nix { inherit ctx; }; + # Static analysis (nix/analysis/default.nix): per-tool reports, + # quick/standard/deep aggregates with triage, and their checks. + # Every report is a package analysis-. + analysis = import ./nix/analysis { inherit ctx; }; + analysisPackages = lib.mapAttrs' (n: lib.nameValuePair "analysis-${n}") analysis.reports; + # OCI images (nix/oci.nix, a callPackage function), minimal and # debug: oci[-debug] for the native arch, oci-[-debug] per # cross arch, the tag suffixed with the arch so they don't collide. @@ -102,7 +111,11 @@ packages = { inherit mcd mcd-clang mcd-werror; default = mcd; + + # Compilation database (consumed by clang-tidy / cppcheck). + compile-db = analysis.compileDb; } + // analysisPackages // mkOciPair pkgs.callPackage mcd "" // x86_64.packages or { }; in @@ -113,6 +126,9 @@ build-gcc = mcd; build-clang = mcd-clang; build-werror = mcd-werror; + analysis-runners = analysis.runnerTests; + semgrep-rules = analysis.semgrepRuleTests; + unit = analysis.unit; }; devShells.default = devshell; diff --git a/nix/analysis/clang-analyzer.nix b/nix/analysis/clang-analyzer.nix new file mode 100644 index 0000000..d5afc80 --- /dev/null +++ b/nix/analysis/clang-analyzer.nix @@ -0,0 +1,94 @@ +# nix/analysis/clang-analyzer.nix +# +# Clang Static Analyzer (scan-build) over the autotools build. scan-build +# wraps both configure and make, so configure records its ccc-analyzer as +# $CC and every translation unit is analysed. C-specific checkers only. +# +# Fails (rather than reporting 0) if the wrapped build did not produce mcd. +{ ctx }: + +let + inherit (ctx) + pkgs + lib + mkAnalysisDrv + configureVpath + inTreeDiagnostics + ; + + scanBuildCheckers = lib.concatStringsSep " " [ + "-enable-checker core.NullDereference" + "-enable-checker core.DivideZero" + "-enable-checker core.UndefinedBinaryOperatorResult" + "-enable-checker core.uninitialized.Assign" + "-enable-checker security.insecureAPI.getpw" + "-enable-checker security.insecureAPI.gets" + "-enable-checker security.insecureAPI.vfork" + "-enable-checker unix.Malloc" + "-enable-checker unix.MallocSizeof" + "-enable-checker unix.MismatchedDeallocator" + "-enable-checker alpha.security.ArrayBoundV2" + "-enable-checker alpha.unix.SimpleStream" + ]; + + # configure + make in one script so both run under scan-build's compiler + # interception. $1 = source root. + analyzeScript = pkgs.writeShellApplication { + name = "analyze-build"; + runtimeInputs = [ + pkgs.autoconf + pkgs.automake + pkgs.bison + pkgs.gnumake + ]; + text = '' + srcTop="$1" + ${configureVpath ""} + make -j"$NIX_BUILD_CORES" -C src + ''; + }; +in +mkAnalysisDrv { + name = "clang-analyzer"; + extraNativeBuildInputs = [ + pkgs.clang + pkgs.clang-analyzer + ]; + + buildPhase = '' + runHook preBuild + srcTop="$PWD" + + # Use clang-analyzer's scan-build explicitly: the copy that pkgs.clang + # exposes has a /usr/bin/env shebang that fails in the sandbox. + ${pkgs.clang-analyzer}/bin/scan-build \ + --use-analyzer=${pkgs.clang}/bin/clang \ + ${scanBuildCheckers} \ + -o "$NIX_BUILD_TOP/scan-results" \ + ${analyzeScript}/bin/analyze-build "$srcTop" \ + 2>&1 | tee "$NIX_BUILD_TOP/scan-build.log" || true + + runHook postBuild + ''; + + installPhase = '' + # This also drops clang's complaints about gcc-only flags in AM_CFLAGS + # (-Wformat-overflow=2) from report.txt. + ${inTreeDiagnostics "clang-analyzer" "$NIX_BUILD_TOP/scan-build.log"} + + if [ -n "$(ls -A "$NIX_BUILD_TOP/scan-results" 2>/dev/null)" ]; then + cp -r "$NIX_BUILD_TOP/scan-results" $out/html-report + fi + + # scan-build prints "scan-build: N bugs found." or "No bugs found." + findings=$(grep -oP '\d+(?= bugs? found)' "$NIX_BUILD_TOP/scan-build.log" | head -1 || true) + echo "''${findings:-0}" > $out/count.txt + + { + echo "=== Clang Static Analyzer (C) ===" + echo "" + echo "Path-sensitive analysis with C-specific checkers." + echo "Findings: $(cat $out/count.txt)" + } > $out/summary.txt + ''; +} diff --git a/nix/analysis/clang-tidy.nix b/nix/analysis/clang-tidy.nix new file mode 100644 index 0000000..c06ccb1 --- /dev/null +++ b/nix/analysis/clang-tidy.nix @@ -0,0 +1,59 @@ +# nix/analysis/clang-tidy.nix +# +# clang-tidy over the compilation database: every translation unit bear +# recorded (src/*.c, the generated _build/src/cfparse.c, and the lib/ +# replacements). --header-filter restricts header diagnostics to the repo. +# +# The DB holds gcc command lines; -Wno-unknown-warning-option keeps clang +# from reporting gcc-only flags such as -Wformat-overflow=2 as diagnostics. +# +# Fails (rather than reporting 0) if clang-tidy could not process a file. +{ + ctx, + mkReport, + compileDb, +}: + +let + inherit (ctx) pkgs; + runner = pkgs.writeShellApplication { + name = "run-clang-tidy-analysis"; + runtimeInputs = with pkgs; [ + clang-tools + coreutils + findutils + gnugrep + jq + ]; + text = '' + compile_db="$1" + output_dir="$2" + + echo "=== clang-tidy Analysis (C) ===" + + jq -r '.[] | if (.file | startswith("/")) then .file else "\(.directory)/\(.file)" end' \ + "$compile_db/compile_commands.json" | sort -u > "$output_dir/files.txt" + echo "translation units: $(wc -l < "$output_dir/files.txt")" + + xargs -a "$output_dir/files.txt" -P "$(nproc)" -I@ \ + clang-tidy \ + -p "$compile_db" \ + --quiet \ + --config='WarningsAsErrors: ""' \ + --extra-arg=-Wno-unknown-warning-option \ + --header-filter='.*/(src|lib)/.*' \ + --checks='-*,bugprone-*,cert-*,clang-analyzer-*,misc-*,readability-*' \ + @ \ + > "$output_dir/report.txt" 2>&1 || true + + if grep -q 'Error while processing' "$output_dir/report.txt"; then + grep -B5 'Error while processing' "$output_dir/report.txt" >&2 + echo "clang-tidy: a translation unit failed to parse; report is incomplete" >&2 + exit 1 + fi + + grep -c ': warning:\|: error:' "$output_dir/report.txt" > "$output_dir/count.txt" || true + ''; + }; +in +mkReport "clang-tidy" compileDb runner diff --git a/nix/analysis/compile-db.nix b/nix/analysis/compile-db.nix new file mode 100644 index 0000000..09b28dd --- /dev/null +++ b/nix/analysis/compile-db.nix @@ -0,0 +1,57 @@ +# nix/analysis/compile-db.nix +# +# Self-contained compilation database for clang-tidy and cppcheck. +# +# autotools has no compile_commands.json exporter, so the DB is recorded by +# `bear` around a real out-of-tree build (ctx.configureVpath). A real build +# (not a configure-only pass) is needed anyway: src/cfparse.c is generated +# from src/cfparse.y by bison, and config.h by configure. +# +# The output is a store path containing: +# $out/ the source tree, plus _build/ with the +# generated config.h and cfparse.c (objects +# and binaries removed) +# $out/compile_commands.json with the sandbox source root rewritten to +# $out, so every 'file', 'directory' and -I +# path is readable and immutable +# +# The source sits at $out root so triage's normalize_path, which strips +# /nix/store/-/, yields repo-relative paths like src/igmp.c. + +{ ctx }: + +let + inherit (ctx) pkgs mkAnalysisDrv configureVpath; +in +mkAnalysisDrv { + name = "compile-db"; + extraNativeBuildInputs = [ + pkgs.bear + pkgs.jq + ]; + + buildPhase = '' + runHook preBuild + srcTop="$PWD" + ${configureVpath ""} + bear --output "$srcTop/compile_commands.json" -- make -j"$NIX_BUILD_CORES" -C src + runHook postBuild + ''; + + installPhase = '' + runHook preInstall + find "$srcTop/_build" \( -name '*.o' -o -name mcd -o -name mctl \) -type f -delete + mkdir -p "$out" + cp -r "$srcTop"/. "$out/" + sed "s|$srcTop|$out|g" "$srcTop/compile_commands.json" > "$out/compile_commands.json" + + entries=$(jq length "$out/compile_commands.json") + echo "compile_commands.json entries: $entries" + # An empty DB would make every DB-driven tool report 0 findings. + if [ "$entries" -lt 10 ]; then + echo "compile-db: only $entries entries; bear did not see the build" >&2 + exit 1 + fi + runHook postInstall + ''; +} diff --git a/nix/analysis/cppcheck.nix b/nix/analysis/cppcheck.nix new file mode 100644 index 0000000..c872f84 --- /dev/null +++ b/nix/analysis/cppcheck.nix @@ -0,0 +1,64 @@ +# nix/analysis/cppcheck.nix +# +# cppcheck over the compilation database. --project gives cppcheck the +# exact include paths and defines for every translation unit. One XML run +# (consumed by triage); report.txt is rendered from the same run with +# --template, so the two can never disagree. +# +# Fails (rather than reporting 0) if the XML is not a complete document. +{ + ctx, + mkReport, + compileDb, +}: + +let + inherit (ctx) pkgs; + runner = pkgs.writeShellApplication { + name = "run-cppcheck-analysis"; + runtimeInputs = with pkgs; [ + cppcheck + coreutils + gnugrep + python3 + ]; + text = '' + compile_db="$1" + output_dir="$2" + + echo "=== cppcheck Analysis (C) ===" + + # --project cannot be combined with explicit source args. + cppcheck \ + --project="$compile_db/compile_commands.json" \ + --enable=all \ + --std=c11 \ + --inline-suppr \ + -j "$(nproc)" \ + --suppress=missingInclude \ + --suppress=missingIncludeSystem \ + --suppress=unusedFunction \ + --suppress=unmatchedSuppression \ + --suppress=checkersReport \ + --xml \ + 2> "$output_dir/report.xml" || true + + if ! grep -q '' "$output_dir/report.xml"; then + tail -20 "$output_dir/report.xml" >&2 + echo "cppcheck: XML report is incomplete" >&2 + exit 1 + fi + + python3 - "$output_dir/report.xml" > "$output_dir/report.txt" <<'EOF' + import sys, xml.etree.ElementTree as ET + for e in ET.parse(sys.argv[1]).iter("error"): + loc = e.find("location") + where = f'{loc.get("file")}:{loc.get("line")}' if loc is not None else "nofile:0" + print(f'{where}: {e.get("severity")}: {e.get("msg")} [{e.get("id")}]') + EOF + + wc -l < "$output_dir/report.txt" > "$output_dir/count.txt" + ''; + }; +in +mkReport "cppcheck" compileDb runner diff --git a/nix/analysis/default.nix b/nix/analysis/default.nix new file mode 100644 index 0000000..1dbbf8b --- /dev/null +++ b/nix/analysis/default.nix @@ -0,0 +1,185 @@ +# nix/analysis/default.nix +# +# Static-analysis entry point for mcd (adapted from the rocm-ernic / xdp2 +# framework for this autotools project). +# +# Tools (src/ and lib/): +# clang-tidy, cppcheck via the bear-recorded compilation database +# flawfinder, semgrep raw-source scanners +# gcc-warnings, gcc-analyzer full builds with extra diagnostics +# clang-analyzer scan-build path-sensitive analysis +# +# Aggregate levels (each also runs the triage prioritiser): +# quick = clang-tidy + cppcheck +# standard = + flawfinder + clang-analyzer + gcc-warnings +# deep = + gcc-analyzer + semgrep +# +# Every target is a derivation whose $out holds report.{txt,xml,json} and +# count.txt. Findings never fail a build; an incomplete run does. +# +# Usage: +# nix build .#analysis-quick | .#analysis-standard | .#analysis-deep +# nix build .#analysis-cppcheck (etc. per-tool) + +{ ctx }: + +let + inherit (ctx) pkgs lib; + python = pkgs.python3; + + # ── Compilation database ──────────────────────────────────────── + compileDb = import ./compile-db.nix { inherit ctx; }; + + # ── Helpers ───────────────────────────────────────────────────── + # One report: `script INPUT OUT` fills OUT, where INPUT is the + # compilation DB (clang-tidy, cppcheck) or the bare source (flawfinder, + # semgrep). + mkReport = + name: input: script: + pkgs.runCommand "mcd-analysis-${name}" { } '' + mkdir -p $out + ${lib.getExe script} ${input} $out + ''; + toolArgs = { inherit ctx mkReport compileDb; }; + + # ── Individual tool targets ───────────────────────────────────── + clang-tidy = import ./clang-tidy.nix toolArgs; + cppcheck = import ./cppcheck.nix toolArgs; + flawfinder = import ./flawfinder.nix toolArgs; + semgrepTargets = import ./semgrep.nix toolArgs; + semgrep = semgrepTargets.report; + gccTargets = import ./gcc.nix { inherit ctx; }; + clang-analyzer = import ./clang-analyzer.nix { inherit ctx; }; + + # ── Unit tests ────────────────────────────────────────────────── + unit = import ./unit.nix { inherit ctx; }; + + # ── Aggregates ────────────────────────────────────────────────── + # Symlink each tool's output under $out/, run the triage + # prioritiser, and print a summary table. + mkAggregate = + level: tools: summaryExtra: + let + # A writeShellApplication (shellchecked, with its own tools): + # mcd-analysis- OUT fills OUT. + script = pkgs.writeShellApplication { + name = "mcd-analysis-${level}"; + runtimeInputs = [ + python + pkgs.coreutils + ]; + text = '' + out=$1 + mkdir -p "$out" + ${lib.concatMapStringsSep "\n" (t: "ln -s ${t.drv} \"$out\"/${t.name}") tools} + + python3 ${./triage} "$out" --output-dir "$out"/triage + + { + echo "=== Analysis Summary (${level}) ===" + echo "" + ${lib.concatMapStringsSep "\n" (t: '' + echo "${lib.fixedWidthString 16 " " (t.name + ":")} $(cat ${t.drv}/count.txt) findings" + if [ -f ${t.drv}/coverage.txt ]; then cat ${t.drv}/coverage.txt; fi + '') tools} + echo "${ + lib.fixedWidthString 16 " " "triage:" + } $(cat "$out"/triage/count.txt 2>/dev/null || echo 0) high-confidence findings" + echo "" + ${summaryExtra} + } > "$out"/summary.txt + cat "$out"/summary.txt + ''; + }; + in + pkgs.runCommand "mcd-analysis-${level}" { } '' + ${lib.getExe script} $out + ''; + + quickTools = [ + { + name = "clang-tidy"; + drv = clang-tidy; + } + { + name = "cppcheck"; + drv = cppcheck; + } + ]; + standardTools = quickTools ++ [ + { + name = "flawfinder"; + drv = flawfinder; + } + { + name = "clang-analyzer"; + drv = clang-analyzer; + } + { + name = "gcc-warnings"; + drv = gccTargets.gcc-warnings; + } + ]; + deepTools = standardTools ++ [ + { + name = "gcc-analyzer"; + drv = gccTargets.gcc-analyzer; + } + { + name = "semgrep"; + drv = semgrep; + } + ]; + + quick = + mkAggregate "quick" quickTools + ''echo "Run 'nix build .#analysis-standard' for a broader sweep."''; + standard = + mkAggregate "standard" standardTools + ''echo "Run 'nix build .#analysis-deep' for gcc -fanalyzer + semgrep."''; + deep = mkAggregate "deep" deepTools ''echo "All analysis tools completed."''; + + # ── Checks ────────────────────────────────────────────────────── + # Table-driven tests of the triage parsers/filters and the semgrep report + # validator (nix/analysis/test_runners.py). + # Only what the tests read, so a harness or rule edit does not rerun them. + runnerTestSrc = lib.fileset.toSource { + root = ./.; + fileset = lib.fileset.unions [ + ./triage + ./test_runners.py + ./semgrep-report.jq + ]; + }; + runnerTests = + pkgs.runCommand "mcd-analysis-runner-tests" + { + nativeBuildInputs = [ + python + pkgs.jq + ]; + } + '' + python3 ${runnerTestSrc}/test_runners.py -v + touch $out + ''; +in +{ + # The per-tool reports and the aggregate levels, by target name; the + # flake exposes each as analysis-. + reports = { + inherit + clang-tidy + cppcheck + flawfinder + semgrep + clang-analyzer + quick + standard + deep + ; + inherit (gccTargets) gcc-warnings gcc-analyzer; + }; + inherit compileDb runnerTests unit; + semgrepRuleTests = semgrepTargets.ruleTests; +} diff --git a/nix/analysis/flawfinder.nix b/nix/analysis/flawfinder.nix new file mode 100644 index 0000000..a7820a0 --- /dev/null +++ b/nix/analysis/flawfinder.nix @@ -0,0 +1,47 @@ +# nix/analysis/flawfinder.nix +# +# flawfinder source scanner — pattern-matches risky C library calls. +# Works on raw source (no compile DB needed); scans src/ and lib/. +# +# Fails (rather than reporting 0) if flawfinder printed no summary. +{ + ctx, + mkReport, + ... +}: + +let + inherit (ctx) pkgs; + runner = pkgs.writeShellApplication { + name = "run-flawfinder-analysis"; + runtimeInputs = with pkgs; [ + flawfinder + coreutils + gnugrep + ]; + text = '' + source_dir="$1" + output_dir="$2" + + echo "=== flawfinder Analysis ===" + + flawfinder \ + --minlevel=1 \ + --columns \ + --context \ + --singleline \ + "$source_dir/src" \ + "$source_dir/lib" \ + > "$output_dir/report.txt" 2>&1 || true + + # flawfinder prints a summary line "Hits = N" + if ! findings=$(grep -oP 'Hits = \K[0-9]+' "$output_dir/report.txt"); then + tail -20 "$output_dir/report.txt" >&2 + echo "flawfinder: no summary line; scan did not complete" >&2 + exit 1 + fi + echo "$findings" > "$output_dir/count.txt" + ''; + }; +in +mkReport "flawfinder" ctx.srcCode runner diff --git a/nix/analysis/gcc.nix b/nix/analysis/gcc.nix new file mode 100644 index 0000000..d3e16d8 --- /dev/null +++ b/nix/analysis/gcc.nix @@ -0,0 +1,84 @@ +# nix/analysis/gcc.nix +# +# GCC-based analysis as two full out-of-tree builds: +# gcc-warnings extra -W… flags, capture the diagnostics +# gcc-analyzer -fanalyzer path-sensitive analysis +# +# Extra flags go in CFLAGS, which automake places after AM_CFLAGS, so they +# override the project's -Wno-* defaults. configureVpath makes every +# diagnostic path absolute; the report keeps in-tree lines only +# (ctx.inTreeDiagnostics). +# +# Fails (rather than reporting 0) if the build did not produce mcd. +{ ctx }: + +let + inherit (ctx) + lib + mkAnalysisDrv + configureVpath + inTreeDiagnostics + ; + + gccWarningFlags = [ + "-Wall" + "-Wextra" + "-Wpedantic" + "-Wformat=2" + "-Wformat-security" + "-Wshadow" + "-Wcast-qual" + "-Wcast-align" + "-Wwrite-strings" + "-Wpointer-arith" + "-Wconversion" + "-Wsign-conversion" + "-Wduplicated-cond" + "-Wduplicated-branches" + "-Wlogical-op" + "-Wnull-dereference" + "-Wdouble-promotion" + "-Wfloat-equal" + "-Walloca" + "-Wvla" + # C-specific + "-Wstrict-prototypes" + "-Wold-style-definition" + "-Wmissing-prototypes" + "-Wbad-function-cast" + ]; + + mkGccAnalysisBuild = + name: extraFlags: + mkAnalysisDrv { + inherit name; + + buildPhase = '' + runHook preBuild + srcTop="$PWD" + ${configureVpath ''CFLAGS="-g -O2 ${lib.concatStringsSep " " extraFlags}"''} + make -j"$NIX_BUILD_CORES" -C src 2>&1 | tee "$NIX_BUILD_TOP/build-output.log" || true + runHook postBuild + ''; + + installPhase = '' + ${inTreeDiagnostics name "$NIX_BUILD_TOP/build-output.log"} + findings=$(wc -l < $out/report.txt) + echo "$findings" > $out/count.txt + cp "$NIX_BUILD_TOP/build-output.log" $out/full-build.log + { + echo "=== ${name} Analysis ===" + echo "" + echo "Extra flags: ${lib.concatStringsSep " " extraFlags}" + echo "Findings: $findings warnings/errors" + } > $out/summary.txt + ''; + }; +in +{ + gcc-warnings = mkGccAnalysisBuild "gcc-warnings" gccWarningFlags; + gcc-analyzer = mkGccAnalysisBuild "gcc-analyzer" [ + "-fanalyzer" + "-fdiagnostics-plain-output" + ]; +} diff --git a/nix/analysis/semgrep-report.jq b/nix/analysis/semgrep-report.jq new file mode 100644 index 0000000..d4c68aa --- /dev/null +++ b/nix/analysis/semgrep-report.jq @@ -0,0 +1,21 @@ +# Copyright (c) Advanced Micro Devices, Inc. All rights reserved. +# +# SPDX-License-Identifier: MIT +# +# Validates semgrep's --json output (read with jq -s, so the input is an +# array of the documents it printed) and prints the number of findings. +# Fails on anything but one well-formed scan of at least one file whose +# only errors are partial-parsing warnings. +if length != 1 then error("semgrep: expected one JSON document") else .[0] end | +if type != "object" then error("semgrep: expected an object") +elif (.results | type) != "array" then error("semgrep: missing results") +elif (.errors | type) != "array" then error("semgrep: missing errors") +# PartialParsing warnings are coverage limitations, not engine failures. +# The runner publishes them prominently; every other diagnostic fails closed. +elif any(.errors[]; .level != "warn" or (.type | type) != "array" or .type[0] != "PartialParsing") +then error("semgrep: engine errors: \(.errors)") +elif (.paths.scanned | type) != "array" or (.paths.scanned | length) == 0 +then error("semgrep: no files scanned") +elif any(.results[]; (.check_id | type) != "string") +then error("semgrep: malformed finding") +else .results | length end diff --git a/nix/analysis/semgrep-rules.yaml b/nix/analysis/semgrep-rules.yaml new file mode 100644 index 0000000..1c256b4 --- /dev/null +++ b/nix/analysis/semgrep-rules.yaml @@ -0,0 +1,332 @@ +rules: + # ── Category 1: Unsafe C String/Memory Functions ────────────── + - id: dangerous-system-call + pattern: system($ARG) + message: Use of system() is dangerous — consider execve() or posix_spawn() + languages: [c, cpp] + severity: WARNING + - id: unsafe-sprintf + pattern: sprintf($BUF, ...) + message: sprintf() has no bounds checking — use snprintf() instead + languages: [c, cpp] + severity: WARNING + - id: unsafe-strcpy + pattern: strcpy($DST, $SRC) + message: strcpy() has no bounds checking — use strncpy() or strlcpy() + languages: [c, cpp] + severity: WARNING + - id: unsafe-strcat + pattern: strcat($DST, $SRC) + message: strcat() has no bounds checking — use strncat() or strlcat() + languages: [c, cpp] + severity: WARNING + - id: potential-format-string + patterns: + - pattern: printf($FMT) + - pattern-not: printf("...") + message: Potential format string vulnerability — ensure format string is a literal + languages: [c, cpp] + severity: WARNING + - id: unsafe-vsprintf + pattern: vsprintf($BUF, ...) + message: vsprintf() has no bounds checking — use vsnprintf() instead + languages: [c, cpp] + severity: WARNING + - id: unsafe-gets + pattern: gets($BUF) + message: gets() is always unsafe (unbounded read) — use fgets() instead + languages: [c, cpp] + severity: ERROR + - id: unsafe-strncpy-strlen + pattern: strncpy($D, $S, strlen($S)) + message: strncpy with strlen(src) as length defeats the purpose of bounds checking + languages: [c, cpp] + severity: WARNING + + # ── Category 2: Memory Management (C-specific) ───────────────── + - id: memset-zero-length + pattern: memset($B, $V, 0) + message: memset with length 0 is a no-op — check arguments + languages: [c, cpp] + severity: WARNING + - id: memcpy-sizeof-pointer + pattern: memcpy($D, $S, sizeof($PTR)) + message: memcpy with sizeof(pointer) likely copies only pointer size, not data + languages: [c, cpp] + severity: WARNING + # A use (*p, p->f, p[i]) of the pointer malloc() returned, unless a NULL + # guard or a non-NULL branch has checked it first. The rule follows the + # pointer's data flow (taint mode), so a guard covers only the allocation + # it checks: assigning p another malloc() result needs a guard of its own. + # Fixtures: semgrep-tests/malloc-no-null-check.c + - id: malloc-no-null-check + mode: taint + pattern-sources: + # exact: only the call's result is tainted. Otherwise its arguments + # are too, and u->size in malloc(u->size) would be reported. + - exact: true + pattern: malloc(...) + pattern-sanitizers: + # A guard: `if (!p)` or `if (p == NULL)` whose body, braced or not, + # ends in return, goto, exit() or abort(). It clears p from there on + # (by-side-effect). Each body is listed unbraced and braced, since + # `{ ... X; }` doesn't match an unbraced body. + - by-side-effect: true + patterns: + - pattern-either: + - pattern: if ($COND) return ...; + - pattern: if ($COND) { ... return ...; } + - pattern: if ($COND) goto $LABEL; + - pattern: if ($COND) { ... goto $LABEL; } + - pattern: if ($COND) exit(...); + - pattern: if ($COND) { ... exit(...); } + - pattern: if ($COND) abort(); + - pattern: if ($COND) { ... abort(); } + - metavariable-pattern: + metavariable: $COND + pattern-either: + - pattern: "!$PTR" + - pattern: $PTR == NULL + - focus-metavariable: $PTR + # A use inside `if (p)` or `if (p != NULL)`. $PTR is limited to a + # variable or member access: `if ($PTR)` also matches `if (!p)` with + # $PTR bound to `!p`, and clearing that expression keeps the guard + # above from clearing the p inside it. Unlike a guard, this clears p + # only inside the branch, and the else branch too, which semgrep + # can't tell apart from it. + - patterns: + - pattern-either: + - pattern-inside: if ($PTR) { ... } + - pattern-inside: if ($PTR != NULL) { ... } + - pattern: $PTR + - metavariable-regex: + metavariable: $PTR + regex: ^\w+(\s*(->|\.)\s*\w+)*$ + pattern-sinks: + - patterns: + # sizeof(*p) doesn't dereference p. + - pattern-not-inside: sizeof(...) + - pattern-either: + - pattern: "*$PTR" + - pattern: $PTR->$F + - pattern: $PTR[$I] + - focus-metavariable: $PTR + message: malloc() result used without NULL check + languages: [c] + severity: WARNING + - id: realloc-self-assign + pattern: $PTR = realloc($PTR, $SIZE) + message: realloc self-assignment leaks memory on failure — use a temporary pointer + languages: [c, cpp] + severity: WARNING + + # ── Category 3: Race Conditions / TOCTOU ───────────────────── + - id: toctou-access + pattern: access($PATH, ...) + message: access() is prone to TOCTOU races — use faccessat() or open-then-check + languages: [c, cpp] + severity: WARNING + - id: chmod-on-pathname + pattern: chmod($PATH, $MODE) + message: chmod on pathname is TOCTOU-prone — prefer fchmod() on an open fd + languages: [c, cpp] + severity: INFO + - id: chown-on-pathname + patterns: + - pattern-either: + - pattern: chown($PATH, $UID, $GID) + - pattern: lchown($PATH, $UID, $GID) + message: chown/lchown on pathname is TOCTOU-prone — prefer fchown() on an open fd + languages: [c, cpp] + severity: INFO + - id: insecure-rand + patterns: + - pattern-either: + - pattern: rand() + - pattern: srand(...) + message: rand()/srand() are not cryptographically secure — use getrandom() + languages: [c, cpp] + severity: WARNING + - id: toctou-stat + patterns: + - pattern-either: + - pattern: stat($PATH, $BUF) + - pattern: lstat($PATH, $BUF) + message: stat/lstat on pathname is TOCTOU-prone — prefer fstat() on an open fd + languages: [c, cpp] + severity: INFO + + # ── Category 5: Error Handling (C-specific) ──────────────────── + - id: strerror-thread-unsafe + pattern: strerror($E) + message: strerror() is not thread-safe — use strerror_r() + languages: [c, cpp] + severity: INFO + + # ── Category 6: Resource Management (C-specific) ────────────── + - id: fopen-no-close-check + pattern: fopen($P, $M) + message: Raw FILE* from fopen — ensure fclose() is called on all paths + languages: [c] + severity: INFO + - id: signal-not-sigaction + pattern: signal($SIG, $H) + message: signal() has portability issues — prefer sigaction() + languages: [c, cpp] + severity: WARNING + - id: vfork-usage + pattern: vfork() + message: vfork() shares address space with parent — prefer posix_spawn() or fork() + languages: [c, cpp] + severity: WARNING + - id: popen-usage + pattern: popen($CMD, $MODE) + message: popen() invokes shell — risk of command injection, prefer posix_spawn() + languages: [c, cpp] + severity: WARNING + + # ── Category 7: Privilege and Command Execution ─────────────── + - id: setuid-setgid + patterns: + - pattern-either: + - pattern: setuid(...) + - pattern: setgid(...) + message: setuid/setgid changes process privileges — ensure proper error checking + languages: [c, cpp] + severity: WARNING + - id: chroot-usage + pattern: chroot($PATH) + message: chroot alone is not a security boundary — ensure chdir+drop privileges + languages: [c, cpp] + severity: WARNING + - id: getenv-unchecked + pattern: getenv($VAR) + message: getenv() returns nullable pointer — check for NULL before use + languages: [c, cpp] + severity: INFO + - id: exec-family + patterns: + - pattern-either: + - pattern: execvp(...) + - pattern: execv(...) + - pattern: execve(...) + message: exec-family call — ensure arguments are validated and paths are absolute + languages: [c, cpp] + severity: INFO + + # ── Category 9: Code Quality / Defensive Programming ───────── + # Fixtures: semgrep-tests/goto-usage.c + - id: goto-usage + pattern: goto $LABEL; + message: goto usage — consider structured control flow alternatives + languages: [c, cpp] + severity: INFO + - id: assert-side-effect + patterns: + - pattern-either: + - pattern: assert($X = $Y) + - pattern: assert($X++) + message: Side effect in assert() — expression is removed in release builds (NDEBUG) + languages: [c, cpp] + severity: ERROR + - id: fprintf-stderr + pattern: fprintf(stderr, ...) + message: Direct stderr output — consider using the project logging infrastructure + languages: [c, cpp] + severity: INFO + - id: atoi-atol-usage + patterns: + - pattern-either: + - pattern: atoi(...) + - pattern: atol(...) + - pattern: atof(...) + message: atoi/atol/atof have no error checking — use strtol/strtod instead + languages: [c, cpp] + severity: WARNING + - id: alloca-usage + pattern: alloca($SIZE) + message: alloca() allocates on stack with no overflow check — prefer heap allocation + languages: [c, cpp] + severity: WARNING + + # ── Category 10: mcd-specific ──────────────────────────────── + # strtok() returns NULL when the input runs out of tokens. mcd tokenises + # external command output (`bridge mdb show`, src/bridge.c) and IPC + # commands, where a truncated or hostile line ends right after a keyword + # and the follow-up strtok(NULL, ...) yields NULL, which then reaches a + # libc string function. A guard is `if (!tok)` / `if (tok == NULL)` that + # leaves the block (return, goto, break, continue, exit), or a use inside + # `if (tok)` / a loop whose condition is `tok`. + # Fixtures: semgrep-tests/strtok-result-unchecked.c + - id: strtok-result-unchecked + mode: taint + pattern-sources: + - exact: true + pattern: strtok(...) + pattern-sanitizers: + - by-side-effect: true + patterns: + - pattern-either: + - pattern: if ($COND) return ...; + - pattern: if ($COND) { ... return ...; } + - pattern: if ($COND) goto $LABEL; + - pattern: if ($COND) { ... goto $LABEL; } + - pattern: if ($COND) break; + - pattern: if ($COND) { ... break; } + - pattern: if ($COND) continue; + - pattern: if ($COND) { ... continue; } + - pattern: if ($COND) exit(...); + - pattern: if ($COND) { ... exit(...); } + # Only `!tok`, not `!strcmp(tok, ...)`, which would clear the + # very use being checked. + - metavariable-pattern: + metavariable: $COND + patterns: + - pattern-either: + - pattern: "!$PTR" + - pattern: $PTR == NULL + - metavariable-regex: + metavariable: $PTR + regex: ^\w+$ + - focus-metavariable: $PTR + - patterns: + - pattern-either: + - pattern-inside: if ($PTR) { ... } + - pattern-inside: if ($PTR != NULL) { ... } + - pattern-inside: if ($PTR && ...) { ... } + - pattern: $PTR + - metavariable-regex: + metavariable: $PTR + regex: ^\w+$ + # The loop condition `for (...; tok; ...)` checks the value the body + # starts with. by-side-effect so a reassignment inside the body is + # tainted again. + - by-side-effect: true + patterns: + - pattern-either: + - pattern: for ($I; $PTR; $U) { ... } + - pattern: while ($PTR) { ... } + - metavariable-regex: + metavariable: $PTR + regex: ^\w+$ + - focus-metavariable: $PTR + pattern-sinks: + - patterns: + - pattern-either: + - pattern: strtol($PTR, ...) + - pattern: strtoul($PTR, ...) + - pattern: strtonum($PTR, ...) + - pattern: atoi($PTR) + - pattern: strlen($PTR) + - pattern: strcmp($PTR, ...) + - pattern: strcmp($X, $PTR) + - pattern: strncmp($PTR, ...) + - pattern: strncmp($X, $PTR, ...) + - pattern: strcpy($D, $PTR) + - pattern: strlcpy($D, $PTR, ...) + - pattern: strdup($PTR) + - pattern: inet_pton($AF, $PTR, ...) + - focus-metavariable: $PTR + message: strtok() result may be NULL here (input ran out of tokens) — check it before use + languages: [c] + severity: WARNING diff --git a/nix/analysis/semgrep-tests/goto-usage.c b/nix/analysis/semgrep-tests/goto-usage.c new file mode 100644 index 0000000..ea72630 --- /dev/null +++ b/nix/analysis/semgrep-tests/goto-usage.c @@ -0,0 +1,60 @@ +/* Copyright (c) Advanced Micro Devices, Inc. All rights reserved. + * + * SPDX-License-Identifier: MIT + */ + +/* + * semgrep rule test for goto-usage (nix/analysis/semgrep-rules.yaml). + * + * Run by nix/analysis/semgrep.nix as `semgrep --test`: a `ruleid:` comment + * says the next line must be reported, an `ok:` comment says it must not. + * Each case is labelled with its category and the outcome it expects. + */ + +/* positive: a goto to a cleanup label -> reported */ +int goto_cleanup(int fail) +{ + if (fail) + // ruleid: goto-usage + goto out; + return 0; +out: + return 1; +} + +/* negative: a label with no goto (a switch case) -> not reported */ +int switch_labels(int v) +{ + switch (v) { + // ok: goto-usage + case 1: + return 1; + default: + return 0; + } +} + +/* boundary: two gotos to the same label -> both reported */ +int two_gotos(int a, int b) +{ + if (a) + // ruleid: goto-usage + goto done; + if (b) + // ruleid: goto-usage + goto done; + return 0; +done: + return 1; +} + +/* corner: a goto that jumps backwards -> reported */ +int backwards(int n) +{ +again: + n--; + if (n > 0) + // ruleid: goto-usage + goto again; + return n; +} diff --git a/nix/analysis/semgrep-tests/malloc-no-null-check.c b/nix/analysis/semgrep-tests/malloc-no-null-check.c new file mode 100644 index 0000000..7fecb81 --- /dev/null +++ b/nix/analysis/semgrep-tests/malloc-no-null-check.c @@ -0,0 +1,298 @@ +/* Copyright (c) Advanced Micro Devices, Inc. All rights reserved. + * + * SPDX-License-Identifier: MIT + */ + +/* + * semgrep rule test for malloc-no-null-check (nix/analysis/semgrep-rules.yaml). + * + * Run by nix/analysis/semgrep.nix as `semgrep --test`: a `ruleid:` comment + * says the next line must be reported, an `ok:` comment says it must not. + * Each case is labelled with its category and the outcome it expects. A + * `todoruleid:` comment marks a line the rule should report but can't yet. + * semgrep --test checks every rule against this file, so the gotos in the + * goto guards carry goto-usage annotations too. + */ + +#include + +struct item { + int field; +}; + +void report_oom(void); + +/* positive: assigned, then dereferenced with no check -> reported */ +void assign_then_deref(void) +{ + int *p; + + p = malloc(sizeof(*p)); + // ruleid: malloc-no-null-check + *p = 1; +} + +/* positive: declared with malloc, then used through -> -> reported */ +void declare_then_arrow(void) +{ + struct item *it = malloc(sizeof(*it)); + + // ruleid: malloc-no-null-check + it->field = 1; +} + +/* negative: an early return on !p guards the use -> not reported */ +int guarded_by_early_return(void) +{ + int *p = malloc(8 * sizeof(*p)); + + if (!p) + return -1; + // ok: malloc-no-null-check + p[0] = 1; + free(p); + return 0; +} + +/* negative: a braced p == NULL check guards the use -> not reported */ +int guarded_by_null_compare(void) +{ + int *p = malloc(sizeof(*p)); + + if (p == NULL) { + return -1; + } + // ok: malloc-no-null-check + *p = 2; + free(p); + return 0; +} + +/* negative: a guard that logs, then returns -> not reported */ +int guarded_by_block(void) +{ + struct item *it = malloc(sizeof(*it)); + + if (it == NULL) { + report_oom(); + return -1; + } + // ok: malloc-no-null-check + it->field = 3; + free(it); + return 0; +} + +/* negative: a guard with a bare return -> not reported */ +void guarded_by_bare_return(void) +{ + int *p = malloc(sizeof(*p)); + + if (!p) + return; + // ok: malloc-no-null-check + *p = 4; + free(p); +} + +/* negative: a guard that jumps to a cleanup label -> not reported */ +int guarded_by_goto(void) +{ + int *p = malloc(sizeof(*p)); + int ret = -1; + + if (!p) + // ruleid: goto-usage + goto out; + // ok: malloc-no-null-check + *p = 5; + ret = 0; + free(p); +out: + return ret; +} + +/* negative: a braced p == NULL guard that logs, then jumps -> not reported */ +int guarded_by_block_goto(void) +{ + int *p = malloc(sizeof(*p)); + int ret = -1; + + if (p == NULL) { + report_oom(); + // ruleid: goto-usage + goto out; + } + // ok: malloc-no-null-check + p[0] = 6; + ret = 0; + free(p); +out: + return ret; +} + +/* negative: a guard that ends the process -> not reported */ +void guarded_by_exit(void) +{ + int *p = malloc(sizeof(*p)); + + if (!p) + exit(EXIT_FAILURE); + // ok: malloc-no-null-check + *p = 7; + free(p); +} + +/* negative: a guard that aborts -> not reported */ +void guarded_by_abort(void) +{ + int *p = malloc(sizeof(*p)); + + if (p == NULL) + abort(); + // ok: malloc-no-null-check + *p = 8; + free(p); +} + +/* corner: a NULL check that only logs and falls through -> reported */ +void logged_but_not_guarded(void) +{ + int *p = malloc(sizeof(*p)); + + if (!p) { + report_oom(); + } + // ruleid: malloc-no-null-check + *p = 9; + free(p); +} + +/* corner: the guard tests a different pointer -> reported */ +void guard_on_other_pointer(const int *q) +{ + int *p = malloc(sizeof(*p)); + + if (!q) + return; + // ruleid: malloc-no-null-check + *p = 10; + free(p); +} + +/* corner: a second allocation replaces a checked one -> reported */ +void reallocated_after_guard(void) +{ + int *p = malloc(sizeof(*p)); + + if (!p) + return; + free(p); + p = malloc(sizeof(*p)); + // ruleid: malloc-no-null-check + *p = 11; + free(p); +} + +/* negative: each of two allocations is checked -> not reported */ +void each_allocation_guarded(void) +{ + int *p = malloc(sizeof(*p)); + + if (!p) + return; + free(p); + p = malloc(sizeof(*p)); + if (p == NULL) + return; + // ok: malloc-no-null-check + *p = 12; + free(p); +} + +/* boundary: the use comes one line before the check -> reported */ +int used_before_check(void) +{ + int *p = malloc(sizeof(*p)); + + // ruleid: malloc-no-null-check + p[0] = 3; + if (!p) + return -1; + free(p); + return 0; +} + +/* corner: the use is inside the if that tests the pointer -> not reported */ +void used_inside_guard(void) +{ + int *p = malloc(sizeof(*p)); + + if (p) { + // ok: malloc-no-null-check + *p = 4; + free(p); + } +} + +/* + * corner: the use is in the else branch of if (p), where p is NULL -> + * should be reported, but isn't: the rule can't tell the branches apart + */ +void used_in_else_branch(void) +{ + int *p = malloc(sizeof(*p)); + + if (p) { + free(p); + } else { + // todoruleid: malloc-no-null-check + *p = 13; + } +} + +/* negative: the size read through another pointer is not a use -> not + * reported */ +int size_from_struct(const struct item *it) +{ + // ok: malloc-no-null-check + int *p = malloc(it->field > 0 ? (size_t)it->field : 1); + + if (p == NULL) + return -1; + free(p); + return 0; +} + +/* corner: calloc is outside the rule's scope -> not reported */ +void calloc_not_covered(void) +{ + int *p = calloc(1, sizeof(*p)); + + // ok: malloc-no-null-check + *p = 5; +} + +/* corner: observing a pointer is not a guard for later use -> reported */ +void observed_then_used(void) +{ + int *p = malloc(sizeof(*p)); + + if (p) { + (void)p; + } + // ruleid: malloc-no-null-check + *p = 6; + free(p); +} + +/* corner: returning on success leaves NULL for the use -> reported */ +void reversed_guard(void) +{ + int *p = malloc(sizeof(*p)); + + if (p) + return; + // ruleid: malloc-no-null-check + *p = 7; +} diff --git a/nix/analysis/semgrep-tests/strtok-result-unchecked.c b/nix/analysis/semgrep-tests/strtok-result-unchecked.c new file mode 100644 index 0000000..f9e8fa6 --- /dev/null +++ b/nix/analysis/semgrep-tests/strtok-result-unchecked.c @@ -0,0 +1,94 @@ +/* + * semgrep rule test for strtok-result-unchecked (nix/analysis/semgrep-rules.yaml). + * + * Run by nix/analysis/semgrep.nix as `semgrep --test`: a `ruleid:` comment + * says the next line must be reported, an `ok:` comment says it must not. + * Each case is labelled with its category and the outcome it expects. + */ +#include +#include + +size_t strlcpy(char *dst, const char *src, size_t len); + +/* positive: the src/bridge.c populate() shape — a keyword is the last + * token on the line, so the follow-up strtok() is NULL -> reported */ +int keyword_value(char *buf, char *dst, size_t len) +{ + char *tok; + int vid = 0; + + for (tok = strtok(buf, " \t"); tok; tok = strtok(NULL, " \t")) { + // ok: strtok-result-unchecked + if (!strcmp(tok, "vid")) { + tok = strtok(NULL, " \t"); + // ruleid: strtok-result-unchecked + vid = strtol(tok, NULL, 10); + continue; + } + tok = strtok(NULL, " \t"); + // ruleid: strtok-result-unchecked + strlcpy(dst, tok, len); + } + return vid; +} + +/* negative: guarded with if (!tok) return -> not reported */ +int guarded_return(char *buf) +{ + char *tok = strtok(buf, " "); + + if (!tok) + return -1; + // ok: strtok-result-unchecked + return strtol(tok, NULL, 10); +} + +/* negative: guarded with == NULL and continue in a loop -> not reported */ +int guarded_continue(char *lines[], int n) +{ + int sum = 0; + + for (int i = 0; i < n; i++) { + char *tok = strtok(lines[i], " "); + + if (tok == NULL) + continue; + // ok: strtok-result-unchecked + sum += strlen(tok); + } + return sum; +} + +/* negative: use only inside if (tok) -> not reported */ +size_t inside_if(char *buf) +{ + char *tok = strtok(buf, ","); + + if (tok) { + // ok: strtok-result-unchecked + return strlen(tok); + } + return 0; +} + +/* boundary: compared before the guard, then guarded -> first reported */ +int compare_then_guard(char *buf) +{ + char *tok = strtok(buf, " "); + + // ruleid: strtok-result-unchecked + if (!strcmp(tok, "show")) + return 1; + if (!tok) + return -1; + // ok: strtok-result-unchecked + return strcmp(tok, "help") == 0; +} + +/* corner: strtok() result passed to a function we do not model -> not reported */ +void consume(const char *s); +void unmodelled_sink(char *buf) +{ + // ok: strtok-result-unchecked + consume(strtok(buf, " ")); +} diff --git a/nix/analysis/semgrep.nix b/nix/analysis/semgrep.nix new file mode 100644 index 0000000..9437003 --- /dev/null +++ b/nix/analysis/semgrep.nix @@ -0,0 +1,117 @@ +# nix/analysis/semgrep.nix +# +# semgrep pattern-based scan of src/ and lib/ with the project's C ruleset +# (semgrep-rules.yaml). Runs fully offline. +# +# Before scanning, each rule fixture in semgrep-tests/ is run with +# `semgrep --test` (a `ruleid:` line must be reported, an `ok:` line must +# not), and a rule that fails to parse fails the build: semgrep otherwise +# skips such a rule with only an entry in the JSON "errors" list, so the +# rule would silently find nothing. The same fixtures are also a flake +# check on their own (`ruleTests`), so a broken rule fails `nix flake check`. +{ + ctx, + mkReport, + ... +}: + +let + inherit (ctx) pkgs lib; + rulesFile = ./semgrep-rules.yaml; + testsDir = ./semgrep-tests; + + env = '' + export SEMGREP_ENABLE_VERSION_CHECK=0 + export SEMGREP_SEND_METRICS=off + export SSL_CERT_FILE="${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt" + export OTEL_TRACES_EXPORTER=none + # Keep Semgrep settings/logs writable without changing the caller's HOME. + semgrep_home="$(mktemp -d)" + trap 'rm -rf "$semgrep_home"' EXIT + export SEMGREP_SETTINGS_FILE="$semgrep_home/settings.yml" + export SEMGREP_LOG_FILE="$semgrep_home/semgrep.log" + ''; + + # One fixture per run: semgrep --test pairs a single target file with the + # config, and exits non-zero on a missed or unexpected finding. A + # writeShellApplication: semgrep-rule-tests OUT, results in OUT. + ruleTestRunner = pkgs.writeShellApplication { + name = "semgrep-rule-tests"; + runtimeInputs = with pkgs; [ + semgrep + coreutils + cacert + ]; + text = '' + output_dir="$1" + ${env} + for fixture in ${testsDir}/*.c; do + name=$(basename "$fixture" .c) + echo "semgrep --test $name" + semgrep --test --config ${rulesFile} "$fixture" \ + > "$output_dir/rule-test-$name.txt" 2>&1 || { + cat "$output_dir/rule-test-$name.txt" + echo "semgrep rule test failed: $name" >&2 + exit 1 + } + done + ''; + }; + + runner = pkgs.writeShellApplication { + name = "run-semgrep-analysis"; + runtimeInputs = with pkgs; [ + semgrep + coreutils + gnugrep + cacert + jq + ]; + text = '' + source_dir="$1" + output_dir="$2" + + echo "=== semgrep Analysis ===" + ${env} + ${lib.getExe ruleTestRunner} "$output_dir" + + semgrep \ + --config ${rulesFile} \ + --json \ + --metrics=off \ + --no-git-ignore \ + "$source_dir/src" \ + "$source_dir/lib" \ + > "$output_dir/report.json" 2> "$output_dir/scan-errors.txt" || { + cat "$output_dir/scan-errors.txt" >&2 + echo "semgrep: scan failed" >&2 + exit 1 + } + + # Validate the structure and the engine errors before counting the + # findings. semgrep's stderr goes to scan-errors.txt so report.json is + # only JSON, and report.txt is derived from it rather than a second scan. + jq -esr -f ${./semgrep-report.jq} "$output_dir/report.json" \ + > "$output_dir/count.txt" + warnings=$(jq '.errors | length' "$output_dir/report.json") + if [ "$warnings" -gt 0 ]; then + echo "PARTIAL COVERAGE: $warnings source parsing warnings; findings are incomplete" \ + | tee "$output_dir/coverage.txt" >&2 + else + echo 'Complete scan: no source parsing warnings' > "$output_dir/coverage.txt" + fi + cat "$output_dir/coverage.txt" > "$output_dir/report.txt" + jq -r '.errors[] | .message' "$output_dir/report.json" >> "$output_dir/report.txt" + jq -r '.results[] | "\(.path):\(.start.line): \(.check_id): \(.extra.message)"' \ + "$output_dir/report.json" >> "$output_dir/report.txt" + ''; + }; +in +{ + report = mkReport "semgrep" ctx.srcCode runner; + + ruleTests = pkgs.runCommand "mcd-semgrep-rule-tests" { } '' + mkdir -p $out + ${lib.getExe ruleTestRunner} $out + ''; +} diff --git a/nix/analysis/test_runners.py b/nix/analysis/test_runners.py new file mode 100644 index 0000000..79e4bda --- /dev/null +++ b/nix/analysis/test_runners.py @@ -0,0 +1,251 @@ +"""Table-driven tests for the analysis report plumbing. + +test_scan_report feeds semgrep-report.jq made-up semgrep output and + checks that it fails on anything but a complete, + well-formed scan (adapted from rocm-ernic). +test_normalize_path store prefixes and automake's src/../lib/ paths. +test_clang_parser clang/gcc diagnostic lines -> findings. +test_flawfinder flawfinder --singleline lines -> findings. +test_cppcheck cppcheck XML -> findings. +test_semgrep_rule_id the rule id without semgrep's config-path prefix. +test_filters which findings triage keeps (scope, vendored, + generated, excluded checks). +test_deduplicate one finding per (file, line, check). + +Each row carries a description and the expected result. The +analysis-runners flake check runs this file; it also runs outside Nix +given python3 and jq. +""" + +import json +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + +HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(HERE / "triage")) + +from finding import Finding, normalize_path # noqa: E402 +from filters import deduplicate, filter_findings # noqa: E402 +from parsers import clang, cppcheck, flawfinder, semgrep # noqa: E402 + +STORE = "/nix/store/" + "a" * 32 + "-mcd-analysis-compile-db-3.0" + + +def finding(file="src/igmp.c", line=10, check="core.NullDereference", tool="clang-tidy"): + return Finding(tool=tool, check_id=check, severity="warning", file=file, line=line, message="m") + + +def parse_text(parse, text, *args): + with tempfile.NamedTemporaryFile("w", suffix=".txt", delete=False) as f: + f.write(text) + try: + return parse(f.name, *args) + finally: + Path(f.name).unlink() + + +class RunnerTests(unittest.TestCase): + def test_scan_report(self): + """Empty and failed scans must not masquerade as zero findings.""" + valid = {"results": [], "errors": [], "paths": {"scanned": ["src/a.c"]}} + # (category, description, semgrep's output, expected finding count, + # or None if semgrep-report.jq must fail) + cases = [ + ("positive", "valid empty scan", json.dumps(valid), "0"), + ( + "corner", + "known partial parsing warning", + json.dumps(dict(valid, errors=[{"level": "warn", "type": ["PartialParsing", []]}])), + "0", + ), + ( + "negative", + "unknown warning", + json.dumps(dict(valid, errors=[{"level": "warn", "type": ["Unknown", []]}])), + None, + ), + ("positive", "one finding", json.dumps(dict(valid, results=[{"check_id": "rule"}])), "1"), + ("negative", "engine error", json.dumps(dict(valid, errors=[{"type": "Fatal error"}])), None), + ("negative", "malformed JSON", "{", None), + ("boundary", "empty output", "", None), + ("negative", "missing results", "{}", None), + ("corner", "nothing scanned", json.dumps(dict(valid, paths={"scanned": []})), None), + ("corner", "two documents", json.dumps(valid) + json.dumps(valid), None), + ( + "negative", + "finding without a check id", + json.dumps(dict(valid, results=[{"check_id": 7}])), + None, + ), + ] + for category, description, source, expected in cases: + with self.subTest(category=category, description=description, expected=expected): + result = subprocess.run( + ["jq", "-esr", "-f", str(HERE / "semgrep-report.jq")], + input=source, + capture_output=True, + text=True, + check=False, + ) + if expected is None: + self.assertNotEqual(result.returncode, 0) + else: + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.strip(), expected) + + def test_normalize_path(self): + cases = [ + ("positive", "store prefix stripped", f"{STORE}/src/igmp.c", "src/igmp.c"), + ("positive", "automake lib/ replacement", f"{STORE}/src/../lib/pidfile.c", "lib/pidfile.c"), + ("boundary", "already relative", "src/mld.c", "src/mld.c"), + ("corner", "double slash collapsed", f"{STORE}/src//ipc.c", "src/ipc.c"), + ("negative", "non-store absolute path kept", "/usr/include/stdio.h", "/usr/include/stdio.h"), + ] + for category, description, path, expected in cases: + with self.subTest(category=category, description=description, expected=expected): + self.assertEqual(normalize_path(path), expected) + + def test_clang_parser(self): + # (category, description, line, expected (file, line, check) or None) + cases = [ + ( + "positive", + "clang-tidy warning", + f"{STORE}/src/bridge.c:115:17: warning: Null pointer passed [clang-analyzer-core.NonNullParamChecker]", + ("src/bridge.c", 115, "clang-analyzer-core.NonNullParamChecker"), + ), + ( + "positive", + "gcc warning with -W option", + "src/bridge.c:90:13: warning: variable 'num' set but not used [-Wunused-but-set-variable=]", + ("src/bridge.c", 90, "-Wunused-but-set-variable="), + ), + ( + "positive", + "error severity", + "src/igmp.c:1:2: error: something broke [some-check]", + ("src/igmp.c", 1, "some-check"), + ), + ("negative", "note line ignored", "src/igmp.c:3:4: note: expanded from macro [x]", None), + ("negative", "no check id in brackets", "src/igmp.c:3:4: warning: plain message", None), + ("boundary", "empty line", "", None), + ] + for category, description, line, expected in cases: + with self.subTest(category=category, description=description, expected=expected): + got = parse_text(clang.parse, line + "\n", "clang-tidy") + if expected is None: + self.assertEqual(got, []) + else: + self.assertEqual(len(got), 1) + self.assertEqual((got[0].file, got[0].line, got[0].check_id), expected) + + def test_flawfinder(self): + cases = [ + ( + "positive", + "single-line hit", + f"{STORE}/src/ipc.c:98:7: [4] (shell) popen:This causes a new program to execute", + ("src/ipc.c", 98, "shell.popen", "error"), + ), + ( + "boundary", + "lowest level", + "src/log.c:12:3: [1] (buffer) strlen:Does not handle strings that are not terminated", + ("src/log.c", 12, "buffer.strlen", "style"), + ), + ("negative", "summary line ignored", "Hits = 42", None), + ] + for category, description, line, expected in cases: + with self.subTest(category=category, description=description, expected=expected): + got = parse_text(flawfinder.parse, line + "\n") + if expected is None: + self.assertEqual(got, []) + else: + self.assertEqual(len(got), 1) + f = got[0] + self.assertEqual((f.file, f.line, f.check_id, f.severity), expected) + + def test_cppcheck(self): + def doc(errors): + return f'{errors}' + + cases = [ + ( + "positive", + "one error with location", + doc( + '' + f'' + ), + [("src/bridge.c", 115, "nullPointer", "error")], + ), + ( + "corner", + "only the first location is used", + doc( + '' + '' + ), + [("src/a.c", 5, "uninitvar", "warning")], + ), + ( + "negative", + "error without a location dropped", + doc(''), + [], + ), + ("negative", "malformed XML yields nothing", "", []), + ("boundary", "no errors", doc(""), []), + ] + for category, description, xml, expected in cases: + with self.subTest(category=category, description=description): + got = parse_text(cppcheck.parse, xml) + self.assertEqual([(f.file, f.line, f.check_id, f.severity) for f in got], expected) + + def test_semgrep_rule_id(self): + cases = [ + ("positive", "store config prefix stripped", "nix.store.strtok-result-unchecked", "strtok-result-unchecked"), + ("positive", "relative config prefix stripped", "nix.analysis.goto-usage", "goto-usage"), + ("boundary", "bare id unchanged", "goto-usage", "goto-usage"), + ("corner", "empty id", "", ""), + ] + for category, description, check_id, expected in cases: + with self.subTest(category=category, description=description, expected=expected): + self.assertEqual(semgrep.rule_id(check_id), expected) + + def test_filters(self): + cases = [ + ("positive", "src/ finding kept", finding(), True), + ("positive", "lib/ finding kept", finding(file="lib/strtonum.c"), True), + ("negative", "vendored jsmn.h dropped", finding(file="src/jsmn.h"), False), + ("negative", "vendored queue.h dropped", finding(file="src/queue.h"), False), + ("negative", "generated cfparse.c dropped", finding(file="_build/src/cfparse.c"), False), + ("negative", "system header out of scope", finding(file="/usr/include/x.h"), False), + ("negative", "excluded check id", finding(check="missingIncludeSystem"), False), + ("boundary", "line 0 dropped", finding(line=0), False), + ("boundary", "line 1 kept", finding(line=1), True), + ("corner", "cfparse.y (grammar source) kept", finding(file="src/cfparse.y"), True), + ] + for category, description, f, expected in cases: + with self.subTest(category=category, description=description, expected=expected): + self.assertEqual(filter_findings([f]) == [f], expected) + + def test_deduplicate(self): + a = finding() + cases = [ + ("positive", "exact duplicate collapsed", [a, finding()], 1), + ("negative", "different line kept", [a, finding(line=11)], 2), + ("negative", "different check kept", [a, finding(check="other")], 2), + ("corner", "same site, different tool collapsed", [a, finding(tool="cppcheck")], 1), + ("boundary", "empty input", [], 0), + ] + for category, description, findings, expected in cases: + with self.subTest(category=category, description=description, expected=expected): + self.assertEqual(len(deduplicate(findings)), expected) + + +if __name__ == "__main__": + unittest.main() diff --git a/nix/analysis/triage/__main__.py b/nix/analysis/triage/__main__.py new file mode 100644 index 0000000..27439c7 --- /dev/null +++ b/nix/analysis/triage/__main__.py @@ -0,0 +1,71 @@ +"""CLI entry point for static analysis triage. + +Usage: + python -m triage # Full prioritized report + python triage --summary # Category summary + python triage --high-confidence # Likely real bugs only + python triage --cross-ref # Multi-tool correlations + python triage --category # Drill into category +""" + +import argparse +import os +import sys + +from parsers import load_all_findings +from filters import filter_findings, deduplicate, is_test_code +from reports import ( + print_summary, print_high_confidence, print_cross_ref, + print_category, print_full_report, write_all_reports, +) + + +def main(): + parser = argparse.ArgumentParser( + description='Triage static analysis findings across multiple tools.' + ) + parser.add_argument('result_dir', help='Path to analysis results directory') + parser.add_argument('--summary', action='store_true', + help='Show category summary') + parser.add_argument('--high-confidence', action='store_true', + help='Show only likely-real-bug findings') + parser.add_argument('--cross-ref', action='store_true', + help='Show multi-tool correlations') + parser.add_argument('--category', type=str, + help='Drill into a specific check category') + parser.add_argument('--output-dir', type=str, + help='Write all reports as files to this directory') + parser.add_argument('--include-test', action='store_true', + help='Include test code findings (excluded by default in high-confidence)') + + args = parser.parse_args() + + if not os.path.isdir(args.result_dir): + print(f'Error: {args.result_dir} is not a directory', file=sys.stderr) + sys.exit(1) + + # Load, filter, deduplicate + raw = load_all_findings(args.result_dir) + filtered = filter_findings(raw) + findings = deduplicate(filtered) + + print(f'Loaded {len(raw)} raw -> {len(filtered)} filtered -> {len(findings)} dedup') + + if args.output_dir: + write_all_reports(findings, args.output_dir) + elif args.summary: + print_summary(findings) + elif args.high_confidence: + if not args.include_test: + findings = [f for f in findings if not is_test_code(f.file)] + print_high_confidence(findings) + elif args.cross_ref: + print_cross_ref(findings) + elif args.category: + print_category(findings, args.category) + else: + print_full_report(findings) + + +if __name__ == '__main__': + main() diff --git a/nix/analysis/triage/filters.py b/nix/analysis/triage/filters.py new file mode 100644 index 0000000..c82271a --- /dev/null +++ b/nix/analysis/triage/filters.py @@ -0,0 +1,123 @@ +"""Noise constants, path classifiers, filtering, and deduplication. + +Adapted for mcd's C codebase. Path patterns reflect this repo's layout: + - src/igmp.c, src/mld.c, src/iface.c IGMP/MLD packet parsing and the + querier state machine — untrusted + network input + - src/sock.c raw AF_PACKET receive path + - src/cfparse.y mcd.conf grammar and lexer + - src/ipc.c, src/mctl.c UNIX-socket IPC protocol + - src/bridge.c parses `bridge`/`ip`/jq output + - src/jsmn.h, src/queue.h vendored (jsmn, BSD queue.h) + - lib/ libc replacement functions + - _build/src/cfparse.c bison output (generated) +""" + +from finding import Finding + + +# Vendored headers: findings there are upstream's, not mcd's. +THIRD_PARTY_PATTERNS = ['src/jsmn.h', 'src/queue.h'] + +# Trees whose findings are in scope for triage. +ANALYSIS_ROOTS = ('src/', 'lib/') + +# Generated files. Diagnostics in bison's own skeleton code are not +# actionable; the user actions in cfparse.y map back via #line directives. +GENERATED_FILE_PATTERNS = ['cfparse.c'] + +EXCLUDED_CHECK_IDS = { + # Cppcheck noise — tool limitations, not code bugs + 'missingIncludeSystem', + 'missingInclude', + 'unmatchedSuppression', + 'checkersReport', + 'syntaxError', # Can't parse complex macro constructs + 'preprocessorErrorDirective', # Intentional #error guards / macro failures + 'unknownMacro', # Doesn't understand queue.h style macros + # Cppcheck false positives in idiomatic C + 'arithOperationsOnVoidPointer', # GNU C extension, intentional in networking code + 'subtractPointers', # container_of style pointer arithmetic + # Clang-tidy build errors (not real findings) + 'clang-diagnostic-error', + # _FORTIFY_SOURCE warnings (build config, not code bugs) + '-W#warnings', + '-Wcpp', +} + +EXCLUDED_MESSAGE_PATTERNS = [ + '_FORTIFY_SOURCE', +] + +# mcd keeps its tests as shell scripts under test/; nothing C is test code +# today, but keep the hook for future C test helpers. +TEST_PATH_PATTERNS = ['test/', '/test_'] + +# Security-sensitive locations: code that parses untrusted input (packets +# off the wire, the IPC socket, external command output, the config file). +SECURITY_PATHS = [ + 'src/igmp.', + 'src/mld.', + 'src/iface.', + 'src/sock.', + 'src/inet.', + 'src/ipc.', + 'src/bridge.', + 'src/cfparse.y', + 'src/mctl.c', +] + + +def _match_generated(path: str) -> bool: + """Check if file matches a generated file pattern (supports * glob).""" + import fnmatch + name = path.rsplit('/', 1)[-1] if '/' in path else path + return any(fnmatch.fnmatch(name, pat) for pat in GENERATED_FILE_PATTERNS) + + +def is_generated(path: str) -> bool: + return _match_generated(path) + + +def is_third_party(path: str) -> bool: + for pat in THIRD_PARTY_PATTERNS: + if pat in path: + return True + # Files outside ANALYSIS_ROOTS are out of scope (tests, build). + return not path.startswith(ANALYSIS_ROOTS) + + +def is_test_code(path: str) -> bool: + return any(pat in path for pat in TEST_PATH_PATTERNS) + + +def is_security_sensitive(path: str) -> bool: + return any(pat in path for pat in SECURITY_PATHS) + + +def filter_findings(findings: list) -> list: + """Remove out-of-scope code and known false positive categories.""" + return [ + f for f in findings + if not is_third_party(f.file) + and not is_generated(f.file) + and f.check_id not in EXCLUDED_CHECK_IDS + and f.line > 0 + and not any(pat in f.message for pat in EXCLUDED_MESSAGE_PATTERNS) + ] + + +def deduplicate(findings: list) -> list: + """Deduplicate findings by (file, line, check_id). + + clang-tidy reports the same header finding once per translation unit. + Keep first occurrence only. + """ + seen = set() + result = [] + for f in findings: + key = f.dedup_key() + if key not in seen: + seen.add(key) + result.append(f) + return result diff --git a/nix/analysis/triage/finding.py b/nix/analysis/triage/finding.py new file mode 100644 index 0000000..dcecd43 --- /dev/null +++ b/nix/analysis/triage/finding.py @@ -0,0 +1,48 @@ +"""Finding dataclass and path/severity normalization.""" + +import posixpath +import re +from dataclasses import dataclass + + +@dataclass +class Finding: + tool: str + check_id: str + severity: str # "error", "warning", "style", "info" + file: str # normalized relative path + line: int + message: str + + def location_key(self): + return (self.file, self.line) + + def dedup_key(self): + return (self.file, self.line, self.check_id) + + +_NIX_STORE_RE = re.compile(r'/nix/store/[a-z0-9]{32}-[^/]*/') + + +def normalize_path(path: str) -> str: + """Strip Nix store prefix to get relative source path.""" + path = _NIX_STORE_RE.sub('', path) + # Clean up double slashes (from Makefile $(CURRDIR)//include patterns) + while '//' in path: + path = path.replace('//', '/') + # automake records lib/ replacements as src/../lib/foo.c + if '..' in path.split('/'): + path = posixpath.normpath(path) + return path + + +def normalize_severity(sev: str) -> str: + """Map tool-specific severities to unified levels.""" + sev = sev.lower().strip() + if sev in ('error', 'high', '5', '4'): + return 'error' + if sev in ('warning', 'medium', '3', 'portability', 'performance'): + return 'warning' + if sev in ('style', 'low', '2', '1', '0', 'information', 'info'): + return 'style' + return 'warning' diff --git a/nix/analysis/triage/parsers/__init__.py b/nix/analysis/triage/parsers/__init__.py new file mode 100644 index 0000000..3498744 --- /dev/null +++ b/nix/analysis/triage/parsers/__init__.py @@ -0,0 +1,49 @@ +"""Unified finding loader across all tool parsers.""" + +from pathlib import Path + +from finding import Finding +from parsers import cppcheck, semgrep, clang, flawfinder + + +def load_all_findings(result_dir: str) -> list: + """Load findings from all available tool reports.""" + findings = [] + rd = Path(result_dir) + + # cppcheck XML + p = rd / 'cppcheck' / 'report.xml' + if p.exists(): + findings.extend(cppcheck.parse(str(p))) + + # semgrep JSON + p = rd / 'semgrep' / 'report.json' + if p.exists(): + findings.extend(semgrep.parse(str(p))) + + # clang-tidy + p = rd / 'clang-tidy' / 'report.txt' + if p.exists(): + findings.extend(clang.parse(str(p), 'clang-tidy')) + + # gcc-warnings + p = rd / 'gcc-warnings' / 'report.txt' + if p.exists(): + findings.extend(clang.parse(str(p), 'gcc-warnings')) + + # flawfinder + p = rd / 'flawfinder' / 'report.txt' + if p.exists(): + findings.extend(flawfinder.parse(str(p))) + + # clang-analyzer + p = rd / 'clang-analyzer' / 'report.txt' + if p.exists(): + findings.extend(clang.parse(str(p), 'clang-analyzer')) + + # gcc-analyzer + p = rd / 'gcc-analyzer' / 'report.txt' + if p.exists(): + findings.extend(clang.parse(str(p), 'gcc-analyzer')) + + return findings diff --git a/nix/analysis/triage/parsers/clang.py b/nix/analysis/triage/parsers/clang.py new file mode 100644 index 0000000..65dbf0f --- /dev/null +++ b/nix/analysis/triage/parsers/clang.py @@ -0,0 +1,36 @@ +"""Parse clang-tidy, clang-analyzer, gcc-warnings, and gcc-analyzer reports. + +All four tools share the same text line format: + /path/to/file.c:123:45: warning: message [check-name] +""" + +import re + +from finding import Finding, normalize_path, normalize_severity + + +_LINE_RE = re.compile( + r'^(.+?):(\d+):\d+:\s+(warning|error):\s+(.+?)\s+\[([^\]]+)\]$' +) + + +def parse(path: str, tool_name: str) -> list: + """Parse a clang-style diagnostic text report.""" + findings = [] + try: + with open(path) as f: + for line in f: + line = line.strip() + m = _LINE_RE.match(line) + if m: + findings.append(Finding( + tool=tool_name, + check_id=m.group(5), + severity=normalize_severity(m.group(3)), + file=normalize_path(m.group(1)), + line=int(m.group(2)), + message=m.group(4), + )) + except FileNotFoundError: + pass + return findings diff --git a/nix/analysis/triage/parsers/cppcheck.py b/nix/analysis/triage/parsers/cppcheck.py new file mode 100644 index 0000000..b13cf71 --- /dev/null +++ b/nix/analysis/triage/parsers/cppcheck.py @@ -0,0 +1,35 @@ +"""Parse cppcheck XML reports.""" + +import xml.etree.ElementTree as ET + +from finding import Finding, normalize_path, normalize_severity + + +def parse(path: str) -> list: + """Parse cppcheck XML report.""" + findings = [] + try: + tree = ET.parse(path) + except (ET.ParseError, FileNotFoundError): + return findings + + for error in tree.iter('error'): + check_id = error.get('id', '') + severity = error.get('severity', 'warning') + msg = error.get('msg', '') + + for loc in error.iter('location'): + filepath = normalize_path(loc.get('file', '')) + line = int(loc.get('line', 0)) + if filepath and line > 0: + findings.append(Finding( + tool='cppcheck', + check_id=check_id, + severity=normalize_severity(severity), + file=filepath, + line=line, + message=msg, + )) + break # Only take first location per error + + return findings diff --git a/nix/analysis/triage/parsers/flawfinder.py b/nix/analysis/triage/parsers/flawfinder.py new file mode 100644 index 0000000..834cda1 --- /dev/null +++ b/nix/analysis/triage/parsers/flawfinder.py @@ -0,0 +1,37 @@ +"""Parse flawfinder text reports. + +Flawfinder line format: + /path/to/file.c:123:45: [5] (race) chmod:message +""" + +import re + +from finding import Finding, normalize_path, normalize_severity + + +_LINE_RE = re.compile( + r'^(.+?):(\d+):\d+:\s+\[(\d+)\]\s+\((\w+)\)\s+(\w+):(.+)$' +) + + +def parse(path: str) -> list: + """Parse flawfinder text report.""" + findings = [] + try: + with open(path) as f: + for line in f: + m = _LINE_RE.match(line.strip()) + if m: + category = m.group(4) + func_name = m.group(5) + findings.append(Finding( + tool='flawfinder', + check_id=f'{category}.{func_name}', + severity=normalize_severity(m.group(3)), + file=normalize_path(m.group(1)), + line=int(m.group(2)), + message=m.group(6).strip(), + )) + except FileNotFoundError: + pass + return findings diff --git a/nix/analysis/triage/parsers/semgrep.py b/nix/analysis/triage/parsers/semgrep.py new file mode 100644 index 0000000..7a876b1 --- /dev/null +++ b/nix/analysis/triage/parsers/semgrep.py @@ -0,0 +1,45 @@ +"""Parse semgrep JSON reports.""" + +import json + +from finding import Finding, normalize_path, normalize_severity + + +def rule_id(check_id: str) -> str: + """semgrep prefixes a rule id with its config file's dotted path + (nix.store.strtok-result-unchecked for a rules file in the store). + Rule ids use dashes, never dots, so the id is the last component.""" + return check_id.rsplit('.', 1)[-1] + + +def parse(path: str) -> list: + """Parse semgrep JSON report (may contain multiple JSON objects).""" + findings = [] + try: + with open(path) as f: + content = f.read() + except FileNotFoundError: + return findings + + # Parse all JSON objects in the file (semgrep may output multiple) + decoder = json.JSONDecoder() + pos = 0 + while pos < len(content): + try: + idx = content.index('{', pos) + data, end = decoder.raw_decode(content, idx) + pos = end + except (ValueError, json.JSONDecodeError): + break + + for result in data.get('results', []): + findings.append(Finding( + tool='semgrep', + check_id=rule_id(result.get('check_id', '')), + severity=normalize_severity(result.get('extra', {}).get('severity', 'warning')), + file=normalize_path(result.get('path', '')), + line=result.get('start', {}).get('line', 0), + message=result.get('extra', {}).get('message', ''), + )) + + return findings diff --git a/nix/analysis/triage/reports.py b/nix/analysis/triage/reports.py new file mode 100644 index 0000000..7f25fff --- /dev/null +++ b/nix/analysis/triage/reports.py @@ -0,0 +1,170 @@ +"""Report formatting and output functions.""" + +import io +import os +from collections import defaultdict +from contextlib import redirect_stdout + +from finding import Finding +from filters import is_test_code, is_security_sensitive +from scoring import priority_score, find_cross_tool_hits, get_high_confidence + + +def format_finding(f, score=None) -> str: + score_str = f'[score={score}] ' if score is not None else '' + return f'{score_str}{f.tool}: {f.file}:{f.line}: [{f.severity}] {f.check_id} -- {f.message}' + + +def print_summary(findings: list): + """Print category summary after filtering, sorted by priority.""" + # Count by (tool, check_id) + category_counts = defaultdict(lambda: { + 'count': 0, 'tools': set(), 'severities': set(), + 'prod': 0, 'test': 0, 'security': 0 + }) + + for f in findings: + cat = category_counts[f.check_id] + cat['count'] += 1 + cat['tools'].add(f.tool) + cat['severities'].add(f.severity) + if is_test_code(f.file): + cat['test'] += 1 + else: + cat['prod'] += 1 + if is_security_sensitive(f.file): + cat['security'] += 1 + + # Sort: error first, then by count ascending (anomalies first) + def sort_key(item): + name, cat = item + has_error = 'error' in cat['severities'] + return (not has_error, cat['count'], name) + + print(f'\n{"Category":<50} {"Count":>6} {"Prod":>5} {"Test":>5} {"Sec":>4} {"Sev":<8} {"Tools"}') + print('-' * 110) + + for name, cat in sorted(category_counts.items(), key=sort_key): + sev = '/'.join(sorted(cat['severities'])) + tools = ','.join(sorted(cat['tools'])) + print(f'{name:<50} {cat["count"]:>6} {cat["prod"]:>5} {cat["test"]:>5} ' + f'{cat["security"]:>4} {sev:<8} {tools}') + + total = sum(c['count'] for c in category_counts.values()) + prod = sum(c['prod'] for c in category_counts.values()) + test = sum(c['test'] for c in category_counts.values()) + print(f'\nTotal: {total} findings ({prod} production, {test} test) ' + f'across {len(category_counts)} categories') + + +def print_high_confidence(findings: list): + """Print only likely-real-bug findings.""" + high_conf = get_high_confidence(findings) + + if not high_conf: + print('No high-confidence findings.') + return + + print(f'\n=== High-Confidence Findings ({len(high_conf)}) ===\n') + + for f, score, is_cross in high_conf: + cross_marker = ' [CROSS-TOOL]' if is_cross else '' + loc = 'security' if is_security_sensitive(f.file) else ('test' if is_test_code(f.file) else 'prod') + print(f' [{score:>3}] [{loc:<8}]{cross_marker}') + print(f' {f.tool}: {f.file}:{f.line}') + print(f' {f.check_id} [{f.severity}]') + print(f' {f.message}') + print() + + +def print_cross_ref(findings: list): + """Print multi-tool correlations.""" + clusters = find_cross_tool_hits(findings) + + if not clusters: + print('No cross-tool correlations found.') + return + + print(f'\n=== Cross-Tool Correlations ({len(clusters)} clusters) ===\n') + + for i, cluster in enumerate(clusters, 1): + tools = sorted(set(f.tool for f in cluster)) + print(f' Cluster #{i} -- {cluster[0].file}:{cluster[0].line} ({", ".join(tools)})') + for f in cluster: + print(f' {f.tool}: [{f.severity}] {f.check_id} -- {f.message}') + print() + + +def print_category(findings: list, category: str): + """Print all findings for a specific category.""" + matches = [f for f in findings if f.check_id == category] + + if not matches: + # Try partial match + matches = [f for f in findings if category.lower() in f.check_id.lower()] + + if not matches: + print(f'No findings matching category "{category}".') + return + + # Group by check_id if partial match found multiple + by_check = defaultdict(list) + for f in matches: + by_check[f.check_id].append(f) + + for check_id, check_findings in sorted(by_check.items()): + print(f'\n=== {check_id} ({len(check_findings)} findings) ===\n') + for f in sorted(check_findings, key=lambda x: (x.file, x.line)): + loc = 'test' if is_test_code(f.file) else 'prod' + print(f' [{loc}] {f.file}:{f.line}') + print(f' {f.message}') + print() + + +def print_full_report(findings: list): + """Print all findings sorted by priority.""" + cat_counts = defaultdict(int) + for f in findings: + cat_counts[f.check_id] += 1 + + scored = [(f, priority_score(f, cat_counts)) for f in findings] + scored.sort(key=lambda x: (-x[1], x[0].file, x[0].line)) + + print(f'\n=== All Findings ({len(scored)}) ===\n') + + for f, score in scored[:200]: # Limit output + print(format_finding(f, score)) + + if len(scored) > 200: + print(f'\n... and {len(scored) - 200} more (use --summary or --category to drill in)') + + +def capture_output(func, *args, **kwargs) -> str: + """Capture stdout from a function call and return as string.""" + buf = io.StringIO() + with redirect_stdout(buf): + func(*args, **kwargs) + return buf.getvalue() + + +def write_all_reports(findings: list, output_dir: str): + """Write all report modes as files to output_dir.""" + os.makedirs(output_dir, exist_ok=True) + + with open(os.path.join(output_dir, 'summary.txt'), 'w') as f: + f.write(capture_output(print_summary, findings)) + + prod_findings = [f for f in findings if not is_test_code(f.file)] + + with open(os.path.join(output_dir, 'high-confidence.txt'), 'w') as f: + f.write(capture_output(print_high_confidence, prod_findings)) + + high_conf = get_high_confidence(prod_findings) + with open(os.path.join(output_dir, 'count.txt'), 'w') as f: + f.write(str(len(high_conf))) + + with open(os.path.join(output_dir, 'cross-ref.txt'), 'w') as f: + f.write(capture_output(print_cross_ref, findings)) + + with open(os.path.join(output_dir, 'full-report.txt'), 'w') as f: + f.write(capture_output(print_full_report, findings)) diff --git a/nix/analysis/triage/scoring.py b/nix/analysis/triage/scoring.py new file mode 100644 index 0000000..1e275c6 --- /dev/null +++ b/nix/analysis/triage/scoring.py @@ -0,0 +1,172 @@ +"""Priority scoring, cross-tool correlation, and high-confidence filtering. + +Adapted for mcd's C codebase (from the xdp2 / rocm-ernic framework) — check IDs and noise patterns +are specific to C static analysis tools. +""" + +from collections import defaultdict + +from finding import Finding +from filters import is_security_sensitive, is_test_code + + +# flawfinder check_ids that are intentional patterns in system software +FLAWFINDER_NOISE = { + 'race.chmod', 'race.chown', 'race.access', 'race.vfork', + 'shell.system', 'shell.execl', 'shell.execlp', 'shell.execv', 'shell.execvp', + 'buffer.read', 'buffer.char', 'buffer.equal', 'buffer.memcpy', + 'buffer.strlen', 'buffer.getenv', 'buffer.wchar_t', + 'misc.open', 'random.random', 'tmpfile.mkstemp', 'access.umask', + 'format.snprintf', 'format.vsnprintf', 'misc.chroot', +} + +# Bug-class check IDs that represent real correctness issues (not style) +BUG_CLASS_CHECKS = { + # Correctness bugs + 'uninitMemberVar', 'unsignedLessThanZero', + 'core.UndefinedBinaryOperatorResult', 'core.NullDereference', + 'core.DivideZero', 'core.uninitialized', + 'core.uninitialized.Assign', + # Bugprone clang-tidy checks + 'bugprone-use-after-move', + 'bugprone-sizeof-expression', + 'bugprone-integer-division', + # Memory safety + 'unix.Malloc', 'unix.MismatchedDeallocator', + 'alpha.security.ArrayBoundV2', +} + +# Style checks that shouldn't appear in high-confidence even in security code +STYLE_ONLY_CHECKS = { + 'constParameterPointer', 'constVariablePointer', + 'constParameter', 'constParameterCallback', + 'shadowVariable', 'shadowArgument', 'shadowFunction', + 'knownConditionTrueFalse', 'unusedStructMember', + 'variableScope', # Moving declarations is style, not bugs + 'bugprone-narrowing-conversions', # size_t→ssize_t, uint→int: intentional in C networking code + 'bugprone-signed-char-misuse', # Sign extension is intentional for error codes +} + +# Prefixes excluded from high-confidence output +_HIGH_CONF_EXCLUDED_PREFIXES = ( + 'readability-', + 'misc-include-cleaner', 'misc-use-internal-linkage', + 'misc-use-anonymous-namespace', 'misc-unused-parameters', + 'misc-const-correctness', 'misc-header-include-cycle', + 'misc-no-recursion', + 'cert-', + # High-volume bugprone checks that are style/convention, not bugs + 'bugprone-reserved-identifier', # _GNU_SOURCE-style and queue.h macro names + 'bugprone-easily-swappable-parameters', # Style — can't change existing API + 'bugprone-assignment-in-if-condition', # Intentional C pattern: if ((x = func())) + 'bugprone-macro-parentheses', # Style — many macros are correct without extra parens + 'bugprone-implicit-widening-of-multiplication-result', # False positives in packet offset math +) + + +def priority_score(f, category_counts: dict) -> int: + """Higher score = higher priority. Range roughly 0-100.""" + score = 0 + + # Severity + if f.severity == 'error': + score += 40 + elif f.severity == 'warning': + score += 20 + + # Security-sensitive location + if is_security_sensitive(f.file): + score += 20 + + # Test code is lower priority + if is_test_code(f.file): + score -= 15 + + # Small-count categories are anomalies (more likely real bugs) + count = category_counts.get(f.check_id, 999) + if count <= 3: + score += 30 + elif count <= 10: + score += 20 + elif count <= 30: + score += 10 + + return score + + +def find_cross_tool_hits(findings: list, tolerance: int = 3) -> list: + """Find file:line pairs flagged by 2+ independent tools. + + Uses a tolerance of +/-N lines to account for minor line number differences. + """ + # Group findings by file + by_file = defaultdict(list) + for f in findings: + by_file[f.file].append(f) + + clusters = [] + for filepath, file_findings in by_file.items(): + # Sort by line + file_findings.sort(key=lambda f: f.line) + + # For each pair of findings from different tools, check proximity + for i, f1 in enumerate(file_findings): + cluster = [f1] + for f2 in file_findings[i + 1:]: + if f2.line > f1.line + tolerance: + break + if f2.tool != f1.tool: + cluster.append(f2) + if len(set(f.tool for f in cluster)) >= 2: + clusters.append(cluster) + + # Deduplicate overlapping clusters + seen_keys = set() + unique_clusters = [] + for cluster in clusters: + key = frozenset((f.tool, f.file, f.line) for f in cluster) + if key not in seen_keys: + seen_keys.add(key) + unique_clusters.append(cluster) + + return unique_clusters + + +def get_high_confidence(findings: list) -> list: + """Return high-confidence findings as (Finding, score, is_cross) tuples, sorted by score.""" + # Compute category counts + cat_counts = defaultdict(int) + for f in findings: + cat_counts[f.check_id] += 1 + + # Find cross-tool correlations (excluding flawfinder noise) + non_noise = [f for f in findings if f.check_id not in FLAWFINDER_NOISE] + cross_hits = find_cross_tool_hits(non_noise) + cross_locations = set() + for cluster in cross_hits: + for f in cluster: + cross_locations.add((f.file, f.line)) + + high_conf = [] + for f in findings: + # Skip noise categories entirely from high-confidence + if f.check_id in FLAWFINDER_NOISE or f.check_id in STYLE_ONLY_CHECKS: + continue + # Skip excluded prefixes (style, not bugs) + if any(f.check_id.startswith(p) for p in _HIGH_CONF_EXCLUDED_PREFIXES): + continue + + score = priority_score(f, cat_counts) + is_cross = (f.file, f.line) in cross_locations + is_bug_class = f.check_id in BUG_CLASS_CHECKS + is_small_cat = cat_counts[f.check_id] <= 3 + # Only cppcheck/clang-analyzer error-severity in security code + is_security_bug = (is_security_sensitive(f.file) and f.severity == 'error' + and f.tool in ('cppcheck', 'clang-analyzer', 'gcc-analyzer')) + + if is_cross or is_bug_class or (is_small_cat and score >= 60) or is_security_bug: + high_conf.append((f, score, is_cross)) + + # Sort by score descending + high_conf.sort(key=lambda x: -x[1]) + return high_conf diff --git a/nix/analysis/unit.nix b/nix/analysis/unit.nix new file mode 100644 index 0000000..83ae218 --- /dev/null +++ b/nix/analysis/unit.nix @@ -0,0 +1,80 @@ +# nix/analysis/unit.nix +# +# C unit tests of mcd internals the shell tests cannot pin down, e.g. the +# timer arithmetic for a given clock reading. Each nix/analysis/unit/ +# test_.c #includes the mcd source it tests, wraps the libc calls +# it fakes (-Wl,--wrap=...), and is built with gcc + ASan/UBSan and run +# once; a failing row fails the build -> checks.unit +# +# Process tests run the mcd binary itself under an LD_PRELOAD shim +# (unit/.c) faking the libc calls it cannot make fail in the +# sandbox: test_.sh, a writeShellApplication run as +# test_ MCD SHIM. +# +# Tests (table-driven, one row per case): +# pev timer expiry and itimer normalization (src/pev.c) +# daemonize mcd exits non-zero when it cannot fork (src/main.c) + +{ ctx }: + +let + inherit (ctx) + pkgs + lib + version + srcCode + mcd + ; + + unitDir = ./unit; + + # name, and the libc calls the test wraps. + tests = [ + { + name = "pev"; + wrap = [ + "clock_gettime" + "setitimer" + ]; + } + ]; + + # name, and the shim it runs mcd under. + procTests = [ + { + name = "daemonize"; + shim = "fork_fail"; + } + ]; + + # Process test scripts, shellchecked and with their own tools. + mkProcTest = + t: + pkgs.writeShellApplication { + name = "test_${t.name}"; + runtimeInputs = [ + pkgs.coreutils + pkgs.gnugrep + ]; + text = builtins.readFile (unitDir + "/test_${t.name}.sh"); + }; + + flags = "${ctx.sanitizerFlags} -g -O1 -Wall -Wextra"; +in +pkgs.runCommandCC "mcd-unit-${version}" { } '' + export ASAN_OPTIONS="detect_leaks=0:abort_on_error=1" + export UBSAN_OPTIONS="print_stacktrace=1:halt_on_error=1" + ${lib.concatMapStringsSep "\n" (t: '' + echo "=== test_${t.name} ===" + $CC ${flags} -I${srcCode}/src \ + -Wl,${lib.concatMapStringsSep "," (w: "--wrap=${w}") t.wrap} \ + -o test_${t.name} ${unitDir}/test_${t.name}.c + ./test_${t.name} + '') tests} + ${lib.concatMapStringsSep "\n" (t: '' + echo "=== test_${t.name} ===" + $CC -shared -fPIC -Wall -Wextra -o lib${t.shim}.so ${unitDir}/${t.shim}.c + ${lib.getExe (mkProcTest t)} ${mcd}/bin/mcd $PWD/lib${t.shim}.so + '') procTests} + touch $out +'' diff --git a/nix/analysis/unit/fork_fail.c b/nix/analysis/unit/fork_fail.c new file mode 100644 index 0000000..6365939 --- /dev/null +++ b/nix/analysis/unit/fork_fail.c @@ -0,0 +1,24 @@ +/* + * LD_PRELOAD shim: fork() fails, with the errno in $FORK_ERRNO (default + * EAGAIN), and geteuid() says root, to get mcd past its root check in + * the build sandbox. + * + * SPDX-License-Identifier: ISC + */ + +#include +#include +#include + +pid_t fork(void) +{ + const char *e = getenv("FORK_ERRNO"); + + errno = e ? atoi(e) : EAGAIN; + return -1; +} + +uid_t geteuid(void) +{ + return 0; +} diff --git a/nix/analysis/unit/test_daemonize.sh b/nix/analysis/unit/test_daemonize.sh new file mode 100644 index 0000000..c7fe247 --- /dev/null +++ b/nix/analysis/unit/test_daemonize.sh @@ -0,0 +1,40 @@ +# Verifies that mcd fails, loudly, when it cannot fork to daemonize: the +# fork_fail shim makes fork() fail, and mcd must not exit 0 as if the +# daemon were running. +# +# Usage: test_daemonize MCD SHIM +# +# Built with writeShellApplication (errexit, nounset, pipefail): the +# exit code under test is caught with || rc=$?. + +mcd=$1 +shim=$2 +out=$(mktemp) + +# One row per line: description # mcd arguments # FORK_ERRNO # +# expected exit code # expected text on stderr (empty: any) +fail=0 +while IFS='#' read -r desc args errno expect text; do + rc=0 + # shellcheck disable=SC2086 + FORK_ERRNO=$errno LD_PRELOAD=$shim "$mcd" $args -f /dev/null 2>"$out" || rc=$? + + if [ "$rc" -ne "$expect" ]; then + echo "FAIL: $desc: exit $rc, expected $expect" + cat "$out" + fail=1 + elif [ -n "$text" ] && ! grep -q "$text" "$out"; then + echo "FAIL: $desc: stderr lacks '$text'" + cat "$out" + fail=1 + else + echo "ok: $desc" + fi +done <it_value.tv_usec >= 0 && it->it_value.tv_usec < 1000000; + if (!armed_ok) { + errno = EINVAL; /* what the kernel says */ + return -1; + } + return 0; +} + +static void cb(int timeout, void *arg) +{ + (void)timeout; + (void)arg; +} + +static const struct { + const char *description; + struct timespec now; + int timeout; /* usec */ + struct timespec expected; /* expiry */ +} tests[] = { + { "whole second from a whole second", + { 100, 0 }, 1000000, { 101, 0 } }, + { "within the second", + { 100, 0 }, 999999, { 100, 999999000 } }, + { "just short of the next second", + { 100, 499999999 }, 500000, { 100, 999999999 } }, + { "exactly on the next second", + { 100, 500000000 }, 500000, { 101, 0 } }, + { "exactly on a later second, multi-second timeout", + { 100, 250000000 }, 2750000, { 103, 0 } }, + { "carries past the next second", + { 100, 999999999 }, 1, { 101, 999 } }, + { "carries past a later second, multi-second timeout", + { 100, 999000000 }, 1500000, { 102, 499000000 } }, +}; + +int main(void) +{ + int fail = 0; + + for (size_t i = 0; i < sizeof(tests) / sizeof(tests[0]); i++) { + struct pev *entry; + int id; + + fake_now = tests[i].now; + armed_ok = 0; + + id = pev_timer_add(tests[i].timeout, 0, cb, NULL); + timer_run(0, NULL); + + for (entry = pl; entry; entry = entry->next) + if (entry->id == id) + break; + + if (!entry) { + printf("FAIL: %s: timer %d not found\n", tests[i].description, id); + fail = 1; + } else if (entry->expiry.tv_sec != tests[i].expected.tv_sec || + entry->expiry.tv_nsec != tests[i].expected.tv_nsec) { + printf("FAIL: %s: expiry %lld.%09ld, expected %lld.%09ld\n", + tests[i].description, + (long long)entry->expiry.tv_sec, entry->expiry.tv_nsec, + (long long)tests[i].expected.tv_sec, tests[i].expected.tv_nsec); + fail = 1; + } else if (!armed_ok) { + printf("FAIL: %s: armed an invalid itimer %lld.%06ld\n", + tests[i].description, + (long long)armed.it_value.tv_sec, (long)armed.it_value.tv_usec); + fail = 1; + } else + printf("ok: %s\n", tests[i].description); + + /* Retire the timer before the next row */ + if (entry) + entry->active = 0; + pev_cleanup(); + } + + /* + * The consequence: an expiry left at tv_nsec 1000000000 arms an + * invalid itimer once the clock reads the next whole second, and + * timer_start() ignores the EINVAL, so no timer fires again. + */ + { + const struct timespec now = { 100, 500000000 }, later = { 101, 0 }; + int id; + + fake_now = now; + id = pev_timer_add(500000, 0, cb, NULL); + timer_run(0, NULL); + + fake_now = later; + armed_ok = 0; + timer_start(&fake_now); + if (!armed_ok) { + printf("FAIL: timer %d due at the next second: armed an invalid itimer %lld.%06ld\n", + id, (long long)armed.it_value.tv_sec, (long)armed.it_value.tv_usec); + fail = 1; + } else + printf("ok: timer due at the next second arms a valid itimer\n"); + } + + return fail; +} diff --git a/nix/targets.nix b/nix/targets.nix index 52069de..635416b 100644 --- a/nix/targets.nix +++ b/nix/targets.nix @@ -47,4 +47,24 @@ } ]; } + { + title = "Analysis"; + rows = [ + { + cmd = "nix build .#analysis-quick"; + desc = "clang-tidy + cppcheck + triage"; + target = "analysis-quick"; + } + { + cmd = "nix build .#analysis-standard"; + desc = "+ flawfinder, scan-build, gcc-warnings"; + target = "analysis-standard"; + } + { + cmd = "nix build .#analysis-deep"; + desc = "+ gcc -fanalyzer, semgrep"; + target = "analysis-deep"; + } + ]; + } ] From 944071696cb7b4021ce75e5953f6bb5d6f862ea6 Mon Sep 17 00:00:00 2001 From: Dave Seddon Date: Mon, 19 Oct 2026 13:33:14 -0700 Subject: [PATCH 22/24] nix: add libFuzzer harnesses for packet, config, IPC and bridge parsing Six harnesses (igmp, mld, cfparse, ipc, inet_parse, bridge_mdb) link against the objects of a real out-of-tree autotools build, with main/pev/sock replaced by fuzz/stubs.c: a fireable timer table, captured sends, and link-time wraps of exit() (fatal paths longjmp back) and popen()/pclose() (command output comes from the fuzz input). Seed corpora are generated by gen_seeds.py in their own derivation: checksummed IGMP/MLD packets and multi-packet sequences, mcd.conf variants, every IPC command, canned bridge/ip output, and a REGRESSIONS table with one input per fixed bug. Each harness is its own derivation, so an edit rebuilds and reruns only the harnesses it touches. - .#fuzz harnesses + corpora + run-fuzzers - .#fuzz-run 20 s per harness in the sandbox, crashes + symbolized repro; also fuzz-run- - checks.fuzz-replay gcc + ASan/UBSan build replaying every seed Signed-off-by: Dave Seddon --- .gitignore | 5 + flake.nix | 10 + nix/analysis/default.nix | 9 +- nix/analysis/fuzz.nix | 401 ++++++++++++++++++++++++ nix/analysis/fuzz/README.md | 103 ++++++ nix/analysis/fuzz/fuzz_bridge_mdb.c | 70 +++++ nix/analysis/fuzz/fuzz_cfparse.c | 68 ++++ nix/analysis/fuzz/fuzz_harness.h | 73 +++++ nix/analysis/fuzz/fuzz_igmp.c | 47 +++ nix/analysis/fuzz/fuzz_inet_parse.c | 42 +++ nix/analysis/fuzz/fuzz_ipc.c | 88 ++++++ nix/analysis/fuzz/fuzz_mld.c | 120 +++++++ nix/analysis/fuzz/gen_seeds.py | 300 ++++++++++++++++++ nix/analysis/fuzz/packet.h | 63 ++++ nix/analysis/fuzz/replay_main.c | 85 +++++ nix/analysis/fuzz/stubs.c | 469 ++++++++++++++++++++++++++++ nix/targets.nix | 10 + 17 files changed, 1962 insertions(+), 1 deletion(-) create mode 100644 nix/analysis/fuzz.nix create mode 100644 nix/analysis/fuzz/README.md create mode 100644 nix/analysis/fuzz/fuzz_bridge_mdb.c create mode 100644 nix/analysis/fuzz/fuzz_cfparse.c create mode 100644 nix/analysis/fuzz/fuzz_harness.h create mode 100644 nix/analysis/fuzz/fuzz_igmp.c create mode 100644 nix/analysis/fuzz/fuzz_inet_parse.c create mode 100644 nix/analysis/fuzz/fuzz_ipc.c create mode 100644 nix/analysis/fuzz/fuzz_mld.c create mode 100644 nix/analysis/fuzz/gen_seeds.py create mode 100644 nix/analysis/fuzz/packet.h create mode 100644 nix/analysis/fuzz/replay_main.c create mode 100644 nix/analysis/fuzz/stubs.c diff --git a/.gitignore b/.gitignore index f9faab6..da8f802 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,8 @@ result-* .direnv/ compile_commands.json __pycache__/ +# libFuzzer artifacts +crash-* +leak-* +timeout-* +oom-* diff --git a/flake.nix b/flake.nix index 0784915..49e414c 100644 --- a/flake.nix +++ b/flake.nix @@ -20,6 +20,10 @@ # Run the static analysis (nix/README.md lists every target): # nix build .#analysis-deep && cat result/summary.txt # +# Fuzz the packet, config and IPC parsers (libFuzzer): +# nix build .#fuzz && FUZZ_TIME=60 ./result/bin/run-fuzzers +# nix build .#fuzz-run # 20 s per harness; also: fuzz-run- +# # Build an OCI image (streamLayeredImage: the result is a loader script): # nix build .#oci && ./result | docker load # also: oci-debug, oci-[-debug] # @@ -114,7 +118,12 @@ # Compilation database (consumed by clang-tidy / cppcheck). compile-db = analysis.compileDb; + + # Fuzzing. + fuzz = analysis.fuzzers; # harnesses + seed corpora + run-fuzzers + fuzz-run = analysis.fuzz-run; # bounded run, collect crashes } + // lib.mapAttrs' (n: lib.nameValuePair "fuzz-run-${n}") analysis.fuzzRuns // analysisPackages // mkOciPair pkgs.callPackage mcd "" // x86_64.packages or { }; @@ -128,6 +137,7 @@ build-werror = mcd-werror; analysis-runners = analysis.runnerTests; semgrep-rules = analysis.semgrepRuleTests; + fuzz-replay = analysis.fuzz-replay; unit = analysis.unit; }; diff --git a/nix/analysis/default.nix b/nix/analysis/default.nix index 1dbbf8b..3b44571 100644 --- a/nix/analysis/default.nix +++ b/nix/analysis/default.nix @@ -51,7 +51,8 @@ let gccTargets = import ./gcc.nix { inherit ctx; }; clang-analyzer = import ./clang-analyzer.nix { inherit ctx; }; - # ── Unit tests ────────────────────────────────────────────────── + # ── Fuzzing ───────────────────────────────────────────────────── + fuzzTargets = import ./fuzz.nix { inherit ctx; }; unit = import ./unit.nix { inherit ctx; }; # ── Aggregates ────────────────────────────────────────────────── @@ -182,4 +183,10 @@ in }; inherit compileDb runnerTests unit; semgrepRuleTests = semgrepTargets.ruleTests; + inherit (fuzzTargets) + fuzzers + fuzzRuns + fuzz-run + fuzz-replay + ; } diff --git a/nix/analysis/fuzz.nix b/nix/analysis/fuzz.nix new file mode 100644 index 0000000..662c49e --- /dev/null +++ b/nix/analysis/fuzz.nix @@ -0,0 +1,401 @@ +# nix/analysis/fuzz.nix +# +# libFuzzer harnesses for mcd's untrusted-input parsers. Targets: +# fuzzers the harness binaries + seed corpora + run-fuzzers (does not +# run them) -> .#fuzz +# fuzzRuns one bounded run per harness in the sandbox, saving crash +# inputs and a symbolized repro (always succeeds: the +# crashes are the deliverable) -> .#fuzz-run- +# fuzz-run all of them, with one summary -> .#fuzz-run +# fuzz-replay every harness built with gcc + ASan/UBSan against +# replay_main.c and run once over its seed corpus; fails on +# a build error or a crashing seed -> checks.fuzz-replay +# +# Harnesses (nix/analysis/fuzz/fuzz_.c, see fuzz/README.md): +# igmp accept_igmp() + the querier state machine (src/igmp.c, iface.c) +# mld accept_mld() + the querier state machine (src/mld.c, iface.c) +# cfparse mcd.conf lexer and grammar (src/cfparse.y) +# ipc ipc_handle(): IPC command parsing and handlers (src/ipc.c) +# inet_parse inet_parse() (src/inet.c) +# bridge_mdb bridge/ip/jq output parsers (src/bridge.c) +# +# Small derivations, so an edit rebuilds only what reads it: +# objects mcd built once per toolchain (clang/libFuzzer, gcc/replay) +# by a real out-of-tree autotools build, so the objects are +# compiled exactly as mcd's are +# harness one per harness and toolchain: fuzz_.c + stubs.c +# linked against the objects minus main.o, pev.o and sock.o +# (replaced by fuzz/stubs.c) and minus the .c it #includes +# corpus gen_seeds.py's seeds, shared by the runs and replays +# run, replay one per harness + +{ ctx }: + +let + inherit (ctx) + pkgs + lib + version + mkAnalysisDrv + configureVpath + runtimeDirs + sanitizerFlags + ; + + # name, the mcd sources the harness #includes (not linked again), and + # the fuzz/ headers it needs besides fuzz_harness.h. + harnesses = [ + { + name = "igmp"; + includes = [ ]; + headers = [ ./fuzz/packet.h ]; + } + { + name = "mld"; + includes = [ ]; + headers = [ ./fuzz/packet.h ]; + } + { + name = "cfparse"; + includes = [ "cfparse" ]; + headers = [ ]; + } + { + name = "ipc"; + includes = [ "ipc" ]; + headers = [ ]; + } + { + name = "inet_parse"; + includes = [ ]; + headers = [ ]; + } + { + name = "bridge_mdb"; + includes = [ "bridge" ]; + headers = [ ]; + } + ]; + names = map (h: h.name) harnesses; + symbolizer = "${lib.getBin pkgs.llvm}/bin/llvm-symbolizer"; + + # Seed corpora, corpus//, from gen_seeds.py and the sample + # mcd.conf alone: a new seed or REGRESSIONS row re-runs the replay, not + # a compile. + fuzzCorpus = + pkgs.runCommand "mcd-fuzz-corpus-${version}" { nativeBuildInputs = [ pkgs.python3 ]; } + '' + python3 ${./fuzz/gen_seeds.py} $out/corpus ${ctx.srcConf} + ''; + + # mcd compiled as for a harness: a real out-of-tree autotools build + # with the harness compiler and flags, so the objects are built exactly + # as mcd's are. Installs what the harnesses compile against: + # obj/ every mcd and lib/ object but main, pev and sock (replaced + # by fuzz/stubs.c) + # build/ config.h and the generated src/cfparse.c + # src/ the sources, for the harnesses that #include one + mkObjects = + { + name, + cc, + cflags, + extraNativeBuildInputs ? [ ], + }: + mkAnalysisDrv { + name = "${name}-objects"; + inherit extraNativeBuildInputs; + hardeningDisable = [ "fortify" ]; + + buildPhase = '' + runHook preBuild + srcTop="$PWD" + ${configureVpath ''CC=${cc} CFLAGS="${cflags}"''} + make -j"$NIX_BUILD_CORES" -C src + runHook postBuild + ''; + + installPhase = '' + mkdir -p $out/obj $out/build/src + ls "$srcTop"/_build/src/mcd-*.o "$srcTop"/_build/lib/*.o \ + | grep -v -e '/mcd-main\.o$' -e '/mcd-pev\.o$' -e '/mcd-sock\.o$' \ + | xargs cp -t $out/obj + cp "$srcTop"/_build/config.h $out/build/ + cp "$srcTop"/_build/src/*.[ch] $out/build/src/ + cp -r "$srcTop"/src $out/src + ''; + }; + + # One harness linked against `objs`: fuzz_.c, stubs.c and the + # headers it uses (only those, so editing one harness rebuilds only it), + # plus `extraSrc` (e.g. replay_main.c). -> $out/bin/fuzz_. + wrapFlags = "-Wl,--wrap=exit,--wrap=popen,--wrap=pclose"; + mkHarness = + { + objs, + cc, + cflags, + extraSrc ? [ ], + extraNativeBuildInputs ? [ ], + }: + h: + let + src = lib.fileset.toSource { + root = ./fuzz; + fileset = lib.fileset.unions ( + [ + (./fuzz + "/fuzz_${h.name}.c") + ./fuzz/stubs.c + ./fuzz/fuzz_harness.h + ] + ++ h.headers + ++ extraSrc + ); + }; + # Same preprocessor setup as src/Makefile.am, plus the build tree + # for config.h and the generated cfparse.c. + cppFlags = lib.concatStringsSep " " [ + "-DHAVE_CONFIG_H" + "-I${objs}/build" + "-I${objs}/build/src" + "-I${objs}/src" + "-I${src}" + "-D_BSD_SOURCE" + "-D_DEFAULT_SOURCE" + "-D_GNU_SOURCE" + ''-DSYSCONFDIR=\"${runtimeDirs.sysconfdir}\"'' + ''-DRUNSTATEDIR=\"${runtimeDirs.runstatedir}\"'' + ''-DPRESERVEDIR=\"${runtimeDirs.localstatedir}/lib\"'' + ]; + objFilter = lib.concatMapStrings (i: " -e '/mcd-${i}\\.o$'") h.includes; + in + pkgs.runCommandCC "mcd-fuzz_${h.name}-${cc}" + { + nativeBuildInputs = extraNativeBuildInputs; + hardeningDisable = [ "fortify" ]; + } + '' + objs=$(ls ${objs}/obj/*.o ${lib.optionalString (h.includes != [ ]) "| grep -v${objFilter}"}) + mkdir -p $out/bin + # shellcheck disable=SC2086 + ${cc} ${cflags} ${cppFlags} \ + ${src}/fuzz_${h.name}.c ${src}/stubs.c \ + ${lib.concatMapStringsSep " " (f: "${src}/${baseNameOf f}") extraSrc} \ + $objs ${wrapFlags} -o $out/bin/fuzz_${h.name} + ''; + + # clang + libFuzzer: the fuzzers. + libFuzzerFlags = "${sanitizerFlags} -g -O1 -fno-omit-frame-pointer"; + clangObjs = mkObjects { + name = "fuzz"; + cc = "clang"; + extraNativeBuildInputs = [ pkgs.clang ]; + cflags = "-fsanitize=fuzzer-no-link ${libFuzzerFlags} -Wno-unknown-warning-option"; + }; + clangHarness = lib.genAttrs' harnesses ( + h: + lib.nameValuePair h.name ( + mkHarness { + objs = clangObjs; + cc = "clang"; + extraNativeBuildInputs = [ pkgs.clang ]; + cflags = "-fsanitize=fuzzer ${libFuzzerFlags}"; + } h + ) + ); + + # gcc + ASan/UBSan, plain main(): keeps the harnesses building with the + # other toolchain and the seed corpora crash-free. + replayFlags = "${sanitizerFlags} -g -O1"; + gccObjs = mkObjects { + name = "fuzz-replay"; + cc = "gcc"; + cflags = replayFlags; + }; + replayHarness = lib.genAttrs' harnesses ( + h: + lib.nameValuePair h.name ( + mkHarness { + objs = gccObjs; + cc = "gcc"; + cflags = replayFlags; + extraSrc = [ ./fuzz/replay_main.c ]; + } h + ) + ); + + # bin/run-fuzzers in a kit. A writeShellApplication so it is + # shellcheck-checked and self-contained. + # bashOptions is limited to nounset so the "run each harness, never fail + # on a crash" behaviour is preserved. + runFuzzers = pkgs.writeShellApplication { + name = "run-fuzzers"; + runtimeInputs = [ + pkgs.coreutils + pkgs.findutils + ]; + bashOptions = [ "nounset" ]; + text = '' + # Run each fuzzer for FUZZ_TIME seconds (default 60). Crash inputs and + # per-harness logs land in FUZZ_WORKDIR (default: a temp dir). Pass + # harness names to run only those. Never fails on a crash: the caller + # inspects the workdir. + root="$(cd "$(dirname "$0")/.." && pwd)" + time="''${FUZZ_TIME:-60}" + workdir="''${FUZZ_WORKDIR:-$(mktemp -d)}" + export ASAN_SYMBOLIZER_PATH="${symbolizer}" + export UBSAN_SYMBOLIZER_PATH="${symbolizer}" + export ASAN_OPTIONS="abort_on_error=0:detect_leaks=0:symbolize=1" + export UBSAN_OPTIONS="print_stacktrace=1" + + if [ "$#" -gt 0 ]; then set -- "$@"; else set -- ${lib.concatStringsSep " " names}; fi + + echo "Fuzzing for ''${time}s each; workdir=$workdir" + for name in "$@"; do + echo "=== fuzz_$name ===" + mkdir -p "$workdir/$name" + cp "$root/corpus/$name"/* "$workdir/$name/" 2>/dev/null || true + chmod u+w "$workdir/$name"/* 2>/dev/null || true + "$root/bin/fuzz_$name" \ + -max_total_time="$time" \ + -print_final_stats=1 \ + -artifact_prefix="$workdir/$name/" \ + "$workdir/$name" \ + > "$workdir/$name.log" 2>&1 || true + crashes=$(find "$workdir/$name" -maxdepth 1 \( -name 'crash-*' -o -name 'leak-*' -o -name 'timeout-*' -o -name 'oom-*' \) | wc -l) + echo " findings: $crashes (log: $workdir/$name.log)" + done + echo "$workdir" + ''; + }; + + # A fuzzer kit: the clang harnesses `ns`, the corpus and run-fuzzers, + # joined as bin/fuzz_, bin/run-fuzzers and corpus//. + mkKit = + name: ns: + pkgs.symlinkJoin { + inherit name; + paths = map (n: clangHarness.${n}) ns ++ [ + fuzzCorpus + runFuzzers + ]; + }; + fuzzers = mkKit "mcd-fuzzers-${version}" names; + + # Bounded run: exercise a harness briefly, collect its crash inputs and a + # symbolized reproduction of each. Always succeeds. + runSeconds = 20; + + # mcd-fuzz-run KIT OUT NAME...: runs KIT's harnesses NAME..., then copies + # the logs, the crash inputs and a symbolized repro of each into OUT, + # with one summary row per harness in OUT/rows.txt and the total in + # OUT/count.txt. A writeShellApplication (shellchecked, its own tools). + fuzzRunScript = pkgs.writeShellApplication { + name = "mcd-fuzz-run"; + runtimeInputs = [ + pkgs.coreutils + pkgs.findutils + pkgs.gnugrep + ]; + text = '' + kit=$1 + out=$2 + shift 2 + export FUZZ_TIME=${toString runSeconds} + export FUZZ_WORKDIR="$PWD/work" + mkdir -p "$FUZZ_WORKDIR" "$out" + "$kit"/bin/run-fuzzers "$@" || true + work="$FUZZ_WORKDIR" + + total=0 + for name in "$@"; do + found=$(find "$work/$name" -maxdepth 1 \( -name 'crash-*' -o -name 'leak-*' -o -name 'timeout-*' -o -name 'oom-*' \) || true) + n=$(printf '%s' "$found" | grep -c . || true) + total=$((total + n)) + execs=$(grep -o 'stat::number_of_executed_units: *[0-9]*' "$work/$name.log" | grep -o '[0-9]*$' || true) + cov=$(grep -o 'cov: [0-9]*' "$work/$name.log" | tail -1 || true) + printf ' %-12s %s finding(s) [%s, %s runs]\n' "$name:" "$n" "''${cov:-cov: ?}" "''${execs:-?}" \ + >> "$out/rows.txt" + + cp "$work/$name.log" "$out/$name.log" 2>/dev/null || true + if [ "$n" -gt 0 ]; then + mkdir -p "$out/crashes/$name" + printf '%s\n' "$found" | xargs -r cp -t "$out/crashes/$name/" + first=''${found%%$'\n'*} + ASAN_SYMBOLIZER_PATH="${symbolizer}" \ + ASAN_OPTIONS="symbolize=1:detect_leaks=0:abort_on_error=0" \ + "$kit/bin/fuzz_$name" "$first" \ + > "$out/crashes/$name/repro.txt" 2>&1 || true + fi + done + echo "$total" > "$out/count.txt" + cat "$out/rows.txt" + ''; + }; + + # One bounded run per harness, so they run in parallel and a harness + # edit re-runs only its own. -> .#fuzz-run- + fuzzRuns = lib.genAttrs names ( + n: + pkgs.runCommand "mcd-fuzz-run-${n}-${version}" { } '' + ${lib.getExe fuzzRunScript} ${mkKit "mcd-fuzzer-${n}" [ n ]} $out ${n} + '' + ); + + # mcd-fuzz-summary OUT RUN...: joins per-harness runs into OUT as + # .log, crashes//, summary.txt and count.txt. + fuzzSummaryScript = pkgs.writeShellApplication { + name = "mcd-fuzz-summary"; + runtimeInputs = [ pkgs.coreutils ]; + text = '' + out=$1 + shift + mkdir -p "$out" + total=0 + for run in "$@"; do + cp "$run"/*.log "$out"/ 2>/dev/null || true + if [ -d "$run/crashes" ]; then + mkdir -p "$out/crashes" + cp -r "$run"/crashes/. "$out/crashes/" + fi + total=$((total + $(cat "$run/count.txt"))) + done + { + echo "=== Fuzzing summary (${toString runSeconds}s per harness) ===" + echo "" + for run in "$@"; do cat "$run/rows.txt"; done + echo "" + echo "Total findings: $total" + echo "" + echo "Inputs + symbolized repro under crashes//; full logs: .log." + } > "$out/summary.txt" + echo "$total" > "$out/count.txt" + cat "$out/summary.txt" + ''; + }; + + fuzz-run = pkgs.runCommand "mcd-fuzz-run-${version}" { } '' + ${lib.getExe fuzzSummaryScript} $out ${lib.concatMapStringsSep " " (n: fuzzRuns.${n}) names} + ''; + + # Each gcc harness run once over its seed corpus; fails on a crashing + # seed. One derivation per harness, joined for the check. + fuzzReplays = lib.genAttrs names ( + n: + pkgs.runCommand "mcd-fuzz-replay-${n}-${version}" { } '' + export ASAN_OPTIONS="detect_leaks=0:abort_on_error=1" + export UBSAN_OPTIONS="print_stacktrace=1:halt_on_error=1" + ${replayHarness.${n}}/bin/fuzz_${n} ${fuzzCorpus}/corpus/${n} + touch $out + '' + ); + fuzz-replay = pkgs.linkFarm "mcd-fuzz-replay-${version}" fuzzReplays; +in +{ + inherit + fuzzers + fuzzRuns + fuzz-run + fuzz-replay + ; +} diff --git a/nix/analysis/fuzz/README.md b/nix/analysis/fuzz/README.md new file mode 100644 index 0000000..44cff86 --- /dev/null +++ b/nix/analysis/fuzz/README.md @@ -0,0 +1,103 @@ +# mcd fuzz harnesses + +libFuzzer harnesses for the code in mcd that parses untrusted input: +packets off the wire, the config file, IPC commands, and the output of the +commands mcd runs. They are built by `nix/analysis/fuzz.nix`. See the +Fuzzing section of `nix/README.md` for the targets and how to run them. + +| Harness | Entry point | Input | +|---------|-------------|-------| +| `fuzz_igmp` | `accept_igmp()` `src/igmp.c` → `src/iface.c` | mode byte + length-prefixed IPv4/IGMP packets (`packet.h`) | +| `fuzz_mld` | `accept_mld()` `src/mld.c` → `src/iface.c` | mode byte + length-prefixed IPv6/MLD packets; ICMPv6 checksum fixed up unless mode bit `0x40` | +| `fuzz_cfparse` | `config_init()` → `config_parse()` `src/cfparse.y` | an `mcd.conf` | +| `fuzz_ipc` | `ipc_handle()` `src/ipc.c` | one IPC command, as a client writes it to the socket | +| `fuzz_inet_parse` | `inet_parse()` `src/inet.c` | octet-count byte + string | +| `fuzz_bridge_mdb` | `populate()`, `bridge_prop()`, `dumpster()`, … `src/bridge.c` | routine byte + the text/JSON the external command "prints" | + +## How the harnesses are put together + +- **Real objects.** `fuzz.nix` runs an ordinary out-of-tree autotools + build with the harness compiler and flags, then links each harness + against those objects. The only exceptions are `main.o`, `pev.o` and + `sock.o`, plus any `.c` file the harness `#include`s itself to reach its + static functions (`cfparse.c`, `ipc.c`, `bridge.c`). +- **`stubs.c`** replaces those three: + - **Globals.** It provides `main.c`'s globals. + - **Timers.** Timers go into a table and keep pev's semantics: ids are + never reused, delete callbacks run, and one-shot timers stay deletable + after they fire. `fuzz_timers_fire()` fires all pending timers at + once. + - **Sending.** `sock_send()` only counts packets. `send_buf` is exactly + `RECV_BUF_SIZE` on the heap, so ASan catches any overflow while a + query is being built. +- **Link-time wraps.** `exit()`, `popen()` and `pclose()` are wrapped at + link time with `-Wl,--wrap=...`: + - When a harness arms it, `exit()` longjmps back to the harness, so the + `fatal()` / `yyerror()` paths can be fuzzed. + - `popen()` returns the bytes set by `fuzz_popen_set()`, so nothing is + ever executed. +- **Packet framing.** Each IGMP and MLD input brings up a fresh interface, + `fuzz0` (AF_INET plus its AF_INET6 twin, ifindex 4242), configured from + the mode byte (`FUZZ_MODE_*` in `fuzz_harness.h`): + - compat version + - proxy mode + - querier disabled + - raw VLAN + - whether our address wins elections + - whether timers fire after each packet + + It then feeds up to 8 packets and tears everything down. Each packet is + copied into an exact-size heap buffer. +- **Logging.** mcd's logging still formats every message, but nothing is + printed. `FUZZ_VERBOSE=1` prints the daemon's log to stderr, which helps + when reading a crash. + +## Seeds + +`gen_seeds.py` writes the seed corpora at build time. The seeds are valid, +checksummed packets: IGMPv1/2/3 and MLDv1/2 queries, reports, leaves/dones +and v3 group records, plus multi-packet sequences. There are also the +sample and variant `mcd.conf` files, every IPC command, and canned +`bridge mdb show`, `bridge -j` and `ip neigh` output. Seeds must not crash: +`checks.fuzz-replay` runs all of them under ASan/UBSan. + +## Adding a harness + +1. Write `fuzz_.c` and include `fuzz_harness.h`. Call `fuzz_init()` + from `LLVMFuzzerInitialize`, and leave no state behind after each input. +2. Add it to `harnesses` in `../fuzz.nix`. List any mcd `.c` file it + `#include`s under `includes`. +3. Add seeds for it to `gen_seeds.py`. + +`nix flake check` builds every harness with gcc against `replay_main.c` and +replays its corpus, so a harness that stops compiling fails the check. + +## Known findings and suppressions + +The harnesses found these in their first minutes. Each fix adds the input +that reproduced it to `REGRESSIONS` in `gen_seeds.py`; it joins the corpus +as `regress-`, so `checks.fuzz-replay` fails if the bug comes back. + +| Harness | Report | Where | +|---------|--------|-------| +| `fuzz_cfparse` | global-buffer-overflow (write): `sscanf("%[.0-9]...")` into the 46-byte global `s1` | `src/cfparse.y` `yylex()`; fixed, `regress-long-dotted-quad` | +| `fuzz_cfparse` | global-buffer-overflow (read): a 1023-byte line with no newline leaves the lexer pointer one past `buf` | `src/cfparse.y` `yytoken()`; fixed, `regress-full-line-no-newline` | +| `fuzz_ipc` | stack-buffer-overflow: `chomp()` walks before the start of a command that is all newlines | `src/defs.h` `chomp()` via `ipc_read()`; fixed, `regress-chomp-all-newlines` | +| `fuzz_bridge_mdb` | NULL `strtok()` result passed to `strlcpy()` / `strtol()` when a `bridge mdb show` line ends after a keyword | `src/bridge.c` `populate()`; fixed, `regress-mdb-line-ends-after-{keyword,vid}` | +| `fuzz_bridge_mdb` (found in review) | heap-buffer-overflow: `strcat()` of every port in a group into the 576-byte `port` list | `src/bridge.c` `populate()`; fixed, `regress-mdb-many-ports-one-group` | +| `fuzz_bridge_mdb` | stack-buffer-overflow (UBSan index 257 out of bounds): the key/value walk trusted the object size, which the non-strict jsmn parser counts keys without values in | `src/bridge.c` `bridge_prop()`; fixed, `regress-json-keys-without-values` | +| `fuzz_bridge_mdb` (found in review) | heap-buffer-overflow: `data[len] = 0` after `fread()` of a full `BUFSIZ` buffer; libFuzzer's default 4096-byte max length never gets there, the replay reads whole files | `src/bridge.c` `bridge_prop()`; fixed, `regress-json-fills-read-buffer` | +| all packet paths | misaligned `struct ip` / `struct ip6_hdr` at `send_buf + ETH_HLEN` | `src/igmp.c` `build_ipv4()`, `src/mld.c`; fixed, the checks are on | + +**About the second `cfparse` row.** The stack trace for the `yytoken()` +overread can end in the harness's lexer-drain loop rather than in +`yyparse()`. The parse hit `fatal()` right after the long token, so the +drain made the next `yytoken()` call. In mcd, yacc's lookahead makes that +same call. + +**Alignment.** UBSan's alignment check was off at first: it fired on the +first query mcd sent, so the fuzzers never got past it. The misalignment is +harmless on x86 but matters on strict-alignment targets. `sock_init()` now +starts frames `SOCK_BUF_ALIGN` (2) bytes into the packet buffers, so IP +headers are 4-byte aligned, and `stubs.c` lays them out the same way. Every +build checks alignment. diff --git a/nix/analysis/fuzz/fuzz_bridge_mdb.c b/nix/analysis/fuzz/fuzz_bridge_mdb.c new file mode 100644 index 0000000..cc3b921 --- /dev/null +++ b/nix/analysis/fuzz/fuzz_bridge_mdb.c @@ -0,0 +1,70 @@ +/* + * libFuzzer harness for src/bridge.c's parsing of external command output: + * the `bridge mdb show` line tokenizer (populate()), the jsmn-based JSON + * readers behind bridge_prop() / the router-port queries, and the + * `ip neigh` / `bridge fdb` scrapers (dumpster()). + * + * bridge.c is included rather than linked to reach its static functions. + * Nothing is executed: the popen() wrapper hands every command the fuzz + * input as its output. + * + * Input: byte 0 picks the routine; the rest is the command output. + * + * SPDX-License-Identifier: ISC + */ + +#include "bridge.c" + +#include "fuzz_harness.h" + +static FILE *out; + +int LLVMFuzzerInitialize(int *argc, char ***argv) +{ + (void)argc; + (void)argv; + fuzz_init(); + out = fopen("/dev/null", "w"); + return 0; +} + +int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) +{ + char addr[64] = "10.0.0.20", mac[32], port[32]; + + if (size < 1) + return 0; + + fuzz_popen_set(data + 1, size - 1); + + if (setjmp(fuzz_exit_jmp) == 0) { + fuzz_exit_armed = 1; + + switch (data[0] % 6) { + case 0: + populate(); + break; + case 1: + show_bridge_groups(out); + break; + case 2: + bridge_prop(out, "br0", "mcast_querier"); + break; + case 3: + bridge_router_ports(out, "br0"); + break; + case 4: + bridge_router_ports_all(out); + break; + case 5: + dumpster(addr, mac, sizeof(mac), port, sizeof(port)); + break; + } + } + fuzz_exit_armed = 0; + + drop(); + fuzz_popen_set(NULL, 0); + + return 0; +} diff --git a/nix/analysis/fuzz/fuzz_cfparse.c b/nix/analysis/fuzz/fuzz_cfparse.c new file mode 100644 index 0000000..5e7380b --- /dev/null +++ b/nix/analysis/fuzz/fuzz_cfparse.c @@ -0,0 +1,68 @@ +/* + * libFuzzer harness for the mcd.conf parser: the hand-written lexer and + * the yacc grammar in src/cfparse.y, through config_init() as main.c + * calls it, including the config_iface_*() calls the grammar makes. + * + * The generated parser is included rather than linked so the harness can + * reach its static state. fatal() and yyerror() exit(); the exit() wrapper + * longjmps back here, after which the harness closes the file the parser + * left open and drains the lexer's static line buffer, so every input + * starts from a clean parser. + * + * SPDX-License-Identifier: ISC + */ + +#include "cfparse.c" + +#include + +#include "fuzz_harness.h" + +static FILE *devnull; + +int LLVMFuzzerInitialize(int *argc, char ***argv) +{ + (void)argc; + (void)argv; + fuzz_init(); + devnull = fopen("/dev/null", "r"); + return 0; +} + +int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) +{ + static char path[64]; + int fd; + + fd = memfd_create("mcd.conf", 0); + if (fd < 0) + abort(); + if (size && write(fd, data, size) != (ssize_t)size) + abort(); + snprintf(path, sizeof(path), "/proc/self/fd/%d", fd); + config_file = path; + + if (setjmp(fuzz_exit_jmp) == 0) { + fuzz_exit_armed = 1; + config_init(); + } + fuzz_exit_armed = 0; + + /* After a fatal() the parser never got to fclose() its file. */ + if (yyin) { + fclose(yyin); + yyin = NULL; + } + /* ... and yytoken() may still hold the rest of the line it was on. */ + yyin = devnull; + while (yytoken()) + ; + rewind(devnull); + yyin = NULL; + ifi = twin = NULL; + + fuzz_iface_down(); + close(fd); + + return 0; +} diff --git a/nix/analysis/fuzz/fuzz_harness.h b/nix/analysis/fuzz/fuzz_harness.h new file mode 100644 index 0000000..e2299e6 --- /dev/null +++ b/nix/analysis/fuzz/fuzz_harness.h @@ -0,0 +1,73 @@ +/* + * Shared declarations for the mcd libFuzzer harnesses (nix/analysis/fuzz/). + * + * Each harness links the mcd objects it needs, except main.o, pev.o and + * sock.o, which stubs.c replaces: timers go into a table the harness can + * fire, packets "sent" are only counted, and exit() / popen() / pclose() + * are wrapped at link time (-Wl,--wrap=...) so fatal() paths return to + * the harness and external command output comes from the fuzz input. + * + * SPDX-License-Identifier: ISC + */ + +#ifndef FUZZ_HARNESS_H +#define FUZZ_HARNESS_H + +#include +#include +#include + +struct ifi; + +/* libFuzzer entry points. Initialize is optional (replay_main.c checks). */ +int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size); +int LLVMFuzzerInitialize(int *argc, char ***argv); + +/* + * exit() from mcd code longjmps here while armed, e.g. + * + * if (setjmp(fuzz_exit_jmp) == 0) { + * fuzz_exit_armed = 1; + * config_init(); + * } + * fuzz_exit_armed = 0; + */ +extern jmp_buf fuzz_exit_jmp; +extern volatile int fuzz_exit_armed; + +/* One-time setup: quiet logging (FUZZ_VERBOSE=1 for debug), protocol + * defaults, a snapshot of the tunable globals. */ +void fuzz_init(void); + +/* Restore the globals a config file or query may change. */ +void fuzz_reset_globals(void); + +/* Fire every pending timer once, as if all had expired. */ +void fuzz_timers_fire(void); + +/* Drop every timer, running its delete callback (frees callback args). */ +void fuzz_timers_clear(void); + +/* Data every popen() returns until changed (default: empty). */ +void fuzz_popen_set(const uint8_t *data, size_t len); + +/* Packets mcd built and handed to sock_send(). */ +extern size_t fuzz_sent_packets; + +/* + * Bring up interface "fuzz0" (an AF_INET entry and its AF_INET6 twin) + * with an address per family, as netlink would, then start it. `mode` + * bits pick the configuration; see FUZZ_MODE_* below. Returns the entry + * for `family`. fuzz_iface_down() tears everything down again. + */ +#define FUZZ_MODE_COMPAT_MASK 0x03 /* 1: v1, 2: v2, 0/3: v3 */ +#define FUZZ_MODE_PROXY 0x04 /* proxy-mode (0.0.0.0 queries) */ +#define FUZZ_MODE_DISABLED 0x08 /* querier disabled */ +#define FUZZ_MODE_TIMERS 0x10 /* fire timers after each packet */ +#define FUZZ_MODE_VLAN 0x20 /* raw VLAN mode, vid 10 */ +#define FUZZ_MODE_HIGH_ADDR 0x80 /* our address loses elections */ + +struct ifi *fuzz_iface_up(unsigned int mode, int family); +void fuzz_iface_down(void); + +#endif /* FUZZ_HARNESS_H */ diff --git a/nix/analysis/fuzz/fuzz_igmp.c b/nix/analysis/fuzz/fuzz_igmp.c new file mode 100644 index 0000000..2914f7b --- /dev/null +++ b/nix/analysis/fuzz/fuzz_igmp.c @@ -0,0 +1,47 @@ +/* + * libFuzzer harness for IGMP receive: accept_igmp() (src/igmp.c) and the + * querier state machine behind it in src/iface.c (membership queries and + * elections, v1/v2 reports, leaves, v3 reports and their group records). + * + * Input: see packet.h. Each input runs on a fresh "fuzz0" interface; with + * FUZZ_MODE_TIMERS every pending timer fires after each packet, reaching + * the group-timeout, last-member-query and version-fallback paths. + * + * SPDX-License-Identifier: ISC + */ + +#include "defs.h" + +#include "fuzz_harness.h" +#include "packet.h" + +static void one(uint8_t *pkt, size_t len, unsigned int mode, void *ctx) +{ + struct ifi *ifi = ctx; + + accept_igmp(ifi, ifi->ifi_vlan, pkt, len); + if (mode & FUZZ_MODE_TIMERS) + fuzz_timers_fire(); +} + +int LLVMFuzzerInitialize(int *argc, char ***argv) +{ + (void)argc; + (void)argv; + fuzz_init(); + return 0; +} + +int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) +{ + struct ifi *ifi; + + if (size < 1) + return 0; + + ifi = fuzz_iface_up(data[0], AF_INET); + fuzz_packets(data, size, one, ifi); + fuzz_iface_down(); + + return 0; +} diff --git a/nix/analysis/fuzz/fuzz_inet_parse.c b/nix/analysis/fuzz/fuzz_inet_parse.c new file mode 100644 index 0000000..44bbd13 --- /dev/null +++ b/nix/analysis/fuzz/fuzz_inet_parse.c @@ -0,0 +1,42 @@ +/* + * libFuzzer harness for inet_parse() (src/inet.c), the dotted-quad parser + * the config lexer uses for addresses and prefixes, plus the formatting + * helpers its results go through. + * + * Input: byte 0 picks the number of octets inet_parse() requires (1..4); + * the rest, NUL-terminated, is the string. + * + * SPDX-License-Identifier: ISC + */ + +#include "defs.h" + +#include "fuzz_harness.h" + +int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) +{ + char out[INET_ADDRSTR_LEN]; + uint32_t addr; + char *s; + int n; + + if (size < 1) + return 0; + + n = (data[0] & 3) + 1; + s = malloc(size); + if (!s) + abort(); + memcpy(s, data + 1, size - 1); + s[size - 1] = '\0'; + + addr = inet_parse(s, n); + if (addr != 0xffffffff) { + inet_fmt(addr, out, sizeof(out)); + (void)inet_valid_host(addr); + (void)inet_valid_group(addr); + } + + free(s); + return 0; +} diff --git a/nix/analysis/fuzz/fuzz_ipc.c b/nix/analysis/fuzz/fuzz_ipc.c new file mode 100644 index 0000000..b0b5187 --- /dev/null +++ b/nix/analysis/fuzz/fuzz_ipc.c @@ -0,0 +1,88 @@ +/* + * libFuzzer harness for the IPC server: ipc_handle() in src/ipc.c, which + * accepts a client on the UNIX socket, reads one command, matches it + * against the command table, parses the options and runs the handler + * (help, version, show igmp / groups / mdb / router ports / status ...). + * + * ipc.c is included rather than linked to reach the static ipc_handle(). + * Each input is written by a fresh client to a listening socket in the + * abstract namespace, against an interface that has seen one IGMPv2 + * report, so the show handlers have state to print. Commands that would + * run `bridge`/`ip`/`jq` get empty output from the popen() wrapper. + * + * SPDX-License-Identifier: ISC + */ + +#include "ipc.c" + +#include +#include + +#include "fuzz_harness.h" + +static int listen_sd = -1; +static struct sockaddr_un addr; +static socklen_t addrlen; + +/* IGMPv2 report for 239.1.2.3 from 10.0.0.20 (IP header + IGMP). */ +static const uint8_t report[] = { + 0x45, 0x00, 0x00, 0x1c, 0, 0, 0, 0, 0x01, 0x02, 0, 0, + 10, 0, 0, 20, 239, 1, 2, 3, + 0x16, 0x00, 0x00, 0x00, 239, 1, 2, 3, +}; + +int LLVMFuzzerInitialize(int *argc, char ***argv) +{ + (void)argc; + (void)argv; + fuzz_init(); + + listen_sd = socket(AF_UNIX, SOCK_STREAM, 0); + if (listen_sd < 0) + abort(); + addr.sun_family = AF_UNIX; + /* Abstract namespace: nothing on the file system, unique per pid. */ + snprintf(addr.sun_path + 1, sizeof(addr.sun_path) - 1, "mcd-fuzz-ipc-%d", getpid()); + addrlen = offsetof(struct sockaddr_un, sun_path) + 1 + strlen(addr.sun_path + 1); + if (bind(listen_sd, (struct sockaddr *)&addr, addrlen) || listen(listen_sd, 1)) + abort(); + + return 0; +} + +int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) +{ + uint8_t pkt[sizeof(report)]; + char drain[4096]; + struct ifi *ifi; + int client; + + /* ipc_read() takes at most sizeof(cmd) - 1 = 767 bytes in one read. */ + if (size > 767) + size = 767; + + ifi = fuzz_iface_up(0, AF_INET); + memcpy(pkt, report, sizeof(pkt)); + accept_igmp(ifi, 0, pkt, sizeof(pkt)); + + client = socket(AF_UNIX, SOCK_STREAM, 0); + if (client < 0 || connect(client, (struct sockaddr *)&addr, addrlen)) + abort(); + if (size && write(client, data, size) != (ssize_t)size) + abort(); + shutdown(client, SHUT_WR); + + if (setjmp(fuzz_exit_jmp) == 0) { + fuzz_exit_armed = 1; + ipc_handle(listen_sd, NULL); + } + fuzz_exit_armed = 0; + + while (read(client, drain, sizeof(drain)) > 0) + ; + close(client); + + fuzz_iface_down(); + + return 0; +} diff --git a/nix/analysis/fuzz/fuzz_mld.c b/nix/analysis/fuzz/fuzz_mld.c new file mode 100644 index 0000000..859c821 --- /dev/null +++ b/nix/analysis/fuzz/fuzz_mld.c @@ -0,0 +1,120 @@ +/* + * libFuzzer harness for MLD receive: accept_mld() (src/mld.c), including + * its hop-by-hop extension header walk, and the shared querier state + * machine in src/iface.c (MLDv1/v2 queries, reports, done messages). + * + * Input: see packet.h. accept_mld() drops packets with a bad ICMPv6 + * checksum, so unless FUZZ_MODE_RAW_CKSUM is set the harness first + * recomputes it; the fuzzer then reaches the parsers behind the check + * instead of spending its time on the checksum. + * + * SPDX-License-Identifier: ISC + */ + +#include "defs.h" + +#include +#include + +#include "fuzz_harness.h" +#include "packet.h" + +#define FUZZ_MODE_RAW_CKSUM 0x40 /* leave the checksum as given */ + +/* RFC 4443 checksum: pseudo-header (src, dst, length, next header) plus + * the ICMPv6 message. */ +static uint16_t cksum6(const struct ip6_hdr *ip6, const uint8_t *msg, size_t len) +{ + uint32_t sum = 0; + const uint8_t *p; + size_t i; + + p = (const uint8_t *)&ip6->ip6_src; + for (i = 0; i < 32; i += 2) + sum += (uint32_t)p[i] << 8 | p[i + 1]; + sum += (uint32_t)(len >> 16) + (uint32_t)(len & 0xffff); + sum += IPPROTO_ICMPV6; + + for (i = 0; i + 1 < len; i += 2) + sum += (uint32_t)msg[i] << 8 | msg[i + 1]; + if (len & 1) + sum += (uint32_t)msg[len - 1] << 8; + + while (sum >> 16) + sum = (sum & 0xffff) + (sum >> 16); + + return (uint16_t)~sum; +} + +/* Mirror accept_mld()'s header walk to find the ICMPv6 message; leave the + * packet alone wherever accept_mld() would reject it anyway. */ +static void fix_cksum(uint8_t *buf, size_t len) +{ + struct ip6_hdr *ip6 = (struct ip6_hdr *)buf; + struct icmp6_hdr *icmp; + size_t off = sizeof(*ip6), plen; + uint8_t nxt; + uint16_t c; + + if (len < sizeof(*ip6)) + return; + plen = ntohs(ip6->ip6_plen); + if (sizeof(*ip6) + plen > len) + return; + + nxt = ip6->ip6_nxt; + if (nxt == IPPROTO_HOPOPTS) { + struct ip6_hbh *hbh = (struct ip6_hbh *)(buf + off); + size_t hlen; + + if (plen < sizeof(*hbh)) + return; + hlen = (size_t)(hbh->ip6h_len + 1) * 8; + if (hlen > plen) + return; + nxt = hbh->ip6h_nxt; + off += hlen; + plen -= hlen; + } + if (nxt != IPPROTO_ICMPV6 || plen < sizeof(*icmp)) + return; + + icmp = (struct icmp6_hdr *)(buf + off); + icmp->icmp6_cksum = 0; + c = cksum6(ip6, buf + off, plen); + icmp->icmp6_cksum = htons(c); +} + +static void one(uint8_t *pkt, size_t len, unsigned int mode, void *ctx) +{ + struct ifi *ifi = ctx; + + if (!(mode & FUZZ_MODE_RAW_CKSUM)) + fix_cksum(pkt, len); + + accept_mld(ifi, ifi->ifi_vlan, pkt, len); + if (mode & FUZZ_MODE_TIMERS) + fuzz_timers_fire(); +} + +int LLVMFuzzerInitialize(int *argc, char ***argv) +{ + (void)argc; + (void)argv; + fuzz_init(); + return 0; +} + +int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) +{ + struct ifi *ifi; + + if (size < 1) + return 0; + + ifi = fuzz_iface_up(data[0], AF_INET6); + fuzz_packets(data, size, one, ifi); + fuzz_iface_down(); + + return 0; +} diff --git a/nix/analysis/fuzz/gen_seeds.py b/nix/analysis/fuzz/gen_seeds.py new file mode 100644 index 0000000..980a2a8 --- /dev/null +++ b/nix/analysis/fuzz/gen_seeds.py @@ -0,0 +1,300 @@ +"""Write the seed corpora for the mcd fuzz harnesses. + +Usage: python3 gen_seeds.py [] + +Writes // for every harness. Seeds are valid, +protocol-correct inputs, so the fuzzer starts from the interesting paths +(a v3 report with records, a query that wins the election, a config that +sets up VLANs and static groups, ...) instead of having to discover the +framing first. Packet seeds use the framing in packet.h: a mode byte, +then u16 big-endian length-prefixed packets. + +With a repo root, the sample mcd.conf is added to the config corpus. +""" + +import ipaddress +import os +import struct +import sys + +# Mode bits, fuzz_harness.h +V1, V2, V3 = 1, 2, 3 +PROXY, DISABLED, TIMERS, VLAN, HIGH = 0x04, 0x08, 0x10, 0x20, 0x80 + +PEER4 = "10.0.0.20" +PEER6 = "fe80::20" + + +def csum(data): + if len(data) % 2: + data += b"\0" + s = sum(struct.unpack(f"!{len(data) // 2}H", data)) + while s >> 16: + s = (s & 0xFFFF) + (s >> 16) + return ~s & 0xFFFF + + +def ip4(src, dst, payload, ra=True): + opts = b"\x94\x04\x00\x00" if ra else b"" + ihl = (20 + len(opts)) // 4 + hdr = struct.pack( + "!BBHHHBBH4s4s", + 0x40 | ihl, 0xC0, 20 + len(opts) + len(payload), 0, 0, 1, 2, 0, + ipaddress.IPv4Address(src).packed, ipaddress.IPv4Address(dst).packed, + ) + opts + hdr = hdr[:10] + struct.pack("!H", csum(hdr)) + hdr[12:] + return hdr + payload + + +def igmp(type_, code, group, extra=b""): + body = struct.pack("!BBH4s", type_, code, 0, ipaddress.IPv4Address(group).packed) + extra + return body[:2] + struct.pack("!H", csum(body)) + body[4:] + + +def igmpv3_query(group, mrc=100, qqic=125, sources=()): + extra = struct.pack("!BBH", 2, qqic, len(sources)) + extra += b"".join(ipaddress.IPv4Address(s).packed for s in sources) + return igmp(0x11, mrc, group, extra) + + +def igmpv3_report(records): + body = b"" + for rtype, group, sources in records: + body += struct.pack("!BBH4s", rtype, 0, len(sources), ipaddress.IPv4Address(group).packed) + body += b"".join(ipaddress.IPv4Address(s).packed for s in sources) + msg = struct.pack("!BBHHH", 0x22, 0, 0, 0, len(records)) + body + return msg[:2] + struct.pack("!H", csum(msg)) + msg[4:] + + +def ip6(src, dst, icmp, hbh=True): + s = ipaddress.IPv6Address(src).packed + d = ipaddress.IPv6Address(dst).packed + pseudo = s + d + struct.pack("!IxxxB", len(icmp), 58) + icmp = icmp[:2] + struct.pack("!H", csum(pseudo + icmp[:2] + b"\0\0" + icmp[4:])) + icmp[4:] + if hbh: + # Hop-by-hop with Router Alert (MLD), padded to 8 bytes. + ext = struct.pack("!BB", 58, 0) + b"\x05\x02\x00\x00" + b"\x01\x00" + nxt, payload = 0, ext + icmp + else: + nxt, payload = 58, icmp + return struct.pack("!IHBB", 0x60000000, len(payload), nxt, 1) + s + d + payload + + +def mld(type_, group="::", delay=10000, extra=b""): + return struct.pack("!BBHHH16s", type_, 0, 0, delay, 0, ipaddress.IPv6Address(group).packed) + extra + + +def mldv2_query(group="::", mrc=10000, qqic=125, sources=()): + extra = struct.pack("!BBH", 2, qqic, len(sources)) + extra += b"".join(ipaddress.IPv6Address(s).packed for s in sources) + return mld(130, group, mrc, extra) + + +def mldv2_report(records): + body = b"" + for rtype, group, sources in records: + body += struct.pack("!BBH16s", rtype, 0, len(sources), ipaddress.IPv6Address(group).packed) + body += b"".join(ipaddress.IPv6Address(s).packed for s in sources) + return struct.pack("!BBHHH", 143, 0, 0, 0, len(records)) + body + + +def frame(mode, *packets): + return bytes([mode]) + b"".join(struct.pack("!H", len(p)) + p for p in packets) + + +def igmp_seeds(): + q2 = ip4(PEER4, "224.0.0.1", igmp(0x11, 100, "0.0.0.0")) + q1 = ip4(PEER4, "224.0.0.1", igmp(0x11, 0, "0.0.0.0")) + q3 = ip4(PEER4, "224.0.0.1", igmpv3_query("0.0.0.0")) + gsq = ip4(PEER4, "239.1.2.3", igmpv3_query("239.1.2.3", sources=["10.0.0.99"])) + r1 = ip4(PEER4, "239.1.2.3", igmp(0x12, 0, "239.1.2.3")) + r2 = ip4(PEER4, "239.1.2.3", igmp(0x16, 0, "239.1.2.3")) + leave = ip4(PEER4, "224.0.0.2", igmp(0x17, 0, "239.1.2.3")) + r3 = ip4(PEER4, "224.0.0.22", igmpv3_report([ + (1, "239.1.2.3", []), # MODE_IS_INCLUDE + (2, "239.4.5.6", ["10.0.0.99"]), # MODE_IS_EXCLUDE + (4, "239.7.8.9", []), # CHANGE_TO_EXCLUDE (join) + (3, "239.1.2.3", []), # CHANGE_TO_INCLUDE {} (leave) + ])) + proxy_q = ip4("0.0.0.0", "224.0.0.1", igmp(0x11, 100, "0.0.0.0")) + return { + "query-v2": frame(V3, q2), + "query-v1": frame(V1, q1), + "query-v3-wins": frame(V3 | HIGH, q3), + "query-v3-group-specific": frame(V3, gsq), + "report-v1": frame(V1 | TIMERS, r1), + "report-v2-leave": frame(V2 | TIMERS, r2, leave), + "report-v3-records": frame(V3 | TIMERS, r3), + "report-then-query": frame(V3 | HIGH | TIMERS, r2, q3, leave), + "proxy-query": frame(V3 | PROXY, proxy_q, r2), + "vlan-report": frame(V2 | VLAN | TIMERS, r2, leave), + "disabled": frame(V3 | DISABLED, q3, r3), + } + + +def mld_seeds(): + q1 = ip6(PEER6, "ff02::1", mld(130)) + q2 = ip6(PEER6, "ff02::1", mldv2_query()) + gsq = ip6(PEER6, "ff0e::123", mldv2_query("ff0e::123", sources=["fe80::99"])) + r1 = ip6(PEER6, "ff0e::123", mld(131, "ff0e::123")) + done = ip6(PEER6, "ff02::2", mld(132, "ff0e::123")) + r2 = ip6(PEER6, "ff02::16", mldv2_report([ + (1, "ff0e::123", []), + (2, "ff0e::456", ["fe80::99"]), + (4, "ff0e::789", []), + (3, "ff0e::123", []), + ])) + nohbh = ip6(PEER6, "ff02::1", mldv2_query(), hbh=False) + return { + "query-v1": frame(V2, q1), + "query-v2-wins": frame(V3 | HIGH, q2), + "query-v2-group-specific": frame(V3, gsq), + "report-v1-done": frame(V2 | TIMERS, r1, done), + "report-v2-records": frame(V3 | TIMERS, r2), + "report-then-query": frame(V3 | HIGH | TIMERS, r1, q2, done), + "query-no-hop-by-hop": frame(V3, nohbh), + "vlan-report": frame(V3 | VLAN | TIMERS, r2), + } + + +CONFIGS = { + "empty": "", + "globals": ( + "global-query-interval 30\n" + "global-response-interval 5\n" + "global-last-member-interval 1\n" + "robustness 3\n" + "router-timeout 255\n" + "router-alert on\n" + ), + "iface": "iface eth0 enable igmpv2 query-interval 10\n", + "proxy": "iface br0 enable proxy-mode\n", + "vlans": ( + "iface br0 vlan 1 enable igmpv3 mldv2\n" + "iface br0 vlan 2 enable igmpv2 mldv1\n" + ), + "static-groups": ( + "iface br0 enable\n" + " static-group 239.1.2.3\n" + " static-group ff0e::123\n" + ), + "proto-off": "iface br0 enable igmp off\niface br1 enable mld off\n", + "disable": "# comment line\nno router-alert\niface eth1 disable\n", +} + +IPC = [ + "help", "version", "show", "show json", "show igmp", "show igmp json", + "show groups", "show groups json", "show interfaces detail", + "show mdb", "show router-ports", "show compat", "show status", + "check", "show compat detail", +] + +INET = [ + (4, "10.0.0.1"), (4, "255.255.255.255"), (4, "0.0.0.0"), (3, "192.168.1"), + (1, "10"), (2, "172.16"), (4, "1.2.3.4.5"), (4, "256.1.1.1"), (4, "1..2.3"), +] + +BRIDGE_MDB = ( + "dev br0 port eth1 grp 239.1.2.3 temp\n" + "dev br0 port eth2 grp 239.1.2.3 vid 10 temp\n" + "dev br0 port br0 grp ff0e::123 permanent\n" +) +BRIDGE_JSON = '[{"ifname":"br0","linkinfo":{"info_data":{"mcast_querier":1,"mcast_snooping":1,"mcast_router":1}}}]\n' +ROUTER_PORTS = "br0 eth1\nbr0 eth2\n" +NEIGH = "10.0.0.20 dev eth1 lladdr 02:00:00:00:00:20 REACHABLE\n02:00:00:00:00:20 dev eth1 master br0\n" + + +# Inputs that reproduced a bug, one row per bug. They join their harness's +# corpus as regress-, so checks.fuzz-replay fails if the bug returns +# (and fuzzing starts from them). Row: harness, name, description, input. +REGRESSIONS = [ + { + "harness": "ipc", + "name": "chomp-all-newlines", + "description": "a command of only newlines: chomp() read before the buffer", + "input": "\n", + }, + { + "harness": "cfparse", + "name": "long-dotted-quad", + "description": "a 64-digit token: sscanf(\"%[.0-9]\") overflowed the global s1", + "input": "router-timeout " + "1" * 64 + "\n", + }, + { + "harness": "cfparse", + "name": "full-line-no-newline", + "description": "a token filling fgets()'s 1023 bytes: yytoken() stepped past buf", + "input": "x" * 1023, + }, + { + "harness": "bridge_mdb", + "name": "mdb-line-ends-after-keyword", + "description": "`bridge mdb show` line ending after `port`: strlcpy() got strtok()'s NULL", + "input": b"\x00dev br0 port", + }, + { + "harness": "bridge_mdb", + "name": "mdb-line-ends-after-vid", + "description": "`bridge mdb show` line ending after `vid`: strtol() got strtok()'s NULL", + "input": b"\x00dev br0 port eth1 grp 239.1.2.3 vid", + }, + { + "harness": "bridge_mdb", + "name": "mdb-many-ports-one-group", + "description": "40 long port names in one group: strcat() overflowed the 576-byte port list", + "input": b"\x00" + b"".join( + b"dev br0 port p%016d grp 239.1.2.3 temp\n" % i for i in range(40) + ), + }, + { + "harness": "bridge_mdb", + "name": "json-keys-without-values", + "description": "an object of keys with no values ending 256 tokens: " + "bridge_prop() read key/value pairs past the token array", + "input": b"\x02[" + b"1," * 252 + b'{"a" "b"}]\n', + }, + { + "harness": "bridge_mdb", + "name": "json-fills-read-buffer", + "description": "bridge property JSON of BUFSIZ (8192) bytes or more: " + "bridge_prop() terminated it one byte past its buffer", + "input": b"\x02" + b" " * 8192, + }, +] + + +def write(corpus, harness, name, data): + d = os.path.join(corpus, harness) + os.makedirs(d, exist_ok=True) + with open(os.path.join(d, name), "wb") as f: + f.write(data if isinstance(data, bytes) else data.encode()) + + +def main(): + corpus = sys.argv[1] + root = sys.argv[2] if len(sys.argv) > 2 else None + + for name, data in igmp_seeds().items(): + write(corpus, "igmp", name, data) + for name, data in mld_seeds().items(): + write(corpus, "mld", name, data) + for name, text in CONFIGS.items(): + write(corpus, "cfparse", name, text) + if root: + with open(os.path.join(root, "mcd.conf"), "rb") as f: + write(corpus, "cfparse", "sample-mcd.conf", f.read()) + for i, cmd in enumerate(IPC): + write(corpus, "ipc", f"cmd{i:02d}", cmd + "\n") + for i, (n, s) in enumerate(INET): + write(corpus, "inet_parse", f"addr{i:02d}", bytes([n - 1]) + s.encode()) + for sel, (name, text) in enumerate([ + ("mdb", BRIDGE_MDB), ("groups", BRIDGE_MDB), ("prop-json", BRIDGE_JSON), + ("router-ports", ROUTER_PORTS), ("router-ports-all", ROUTER_PORTS), ("dumpster", NEIGH), + ]): + write(corpus, "bridge_mdb", name, bytes([sel]) + text.encode()) + for r in REGRESSIONS: + write(corpus, r["harness"], "regress-" + r["name"], r["input"]) + + +if __name__ == "__main__": + main() diff --git a/nix/analysis/fuzz/packet.h b/nix/analysis/fuzz/packet.h new file mode 100644 index 0000000..ddef851 --- /dev/null +++ b/nix/analysis/fuzz/packet.h @@ -0,0 +1,63 @@ +/* + * Input framing shared by the IGMP and MLD harnesses. + * + * byte 0 mode: FUZZ_MODE_* bits (fuzz_harness.h), the interface + * configuration the packets arrive on + * then up to FUZZ_MAX_PACKETS records of + * u16 big-endian length, then that many packet bytes + * (IP header onward, as sock_read() passes them). A short + * final record takes whatever bytes remain. + * + * Several packets per input let the fuzzer drive the querier state + * machine (report, then leave, then a competing query, ...). + * + * SPDX-License-Identifier: ISC + */ + +#ifndef FUZZ_PACKET_H +#define FUZZ_PACKET_H + +#include +#include +#include +#include + +#define FUZZ_MAX_PACKETS 8 + +typedef void (*fuzz_packet_fn)(uint8_t *pkt, size_t len, unsigned int mode, void *ctx); + +/* Calls fn once per record, each in its own exact-size heap copy so ASan + * sees any read past the packet. Returns the mode byte. */ +static inline unsigned int fuzz_packets(const uint8_t *data, size_t size, fuzz_packet_fn fn, void *ctx) +{ + unsigned int mode; + size_t off = 1; + + if (size < 1) + return 0; + mode = data[0]; + + for (int n = 0; n < FUZZ_MAX_PACKETS && off < size; n++) { + size_t len; + uint8_t *pkt; + + if (size - off < 2) + break; + len = (size_t)data[off] << 8 | data[off + 1]; + off += 2; + if (len > size - off) + len = size - off; + + pkt = malloc(len ? len : 1); + if (!pkt) + abort(); + memcpy(pkt, data + off, len); + fn(pkt, len, mode, ctx); + free(pkt); + off += len; + } + + return mode; +} + +#endif /* FUZZ_PACKET_H */ diff --git a/nix/analysis/fuzz/replay_main.c b/nix/analysis/fuzz/replay_main.c new file mode 100644 index 0000000..7e54056 --- /dev/null +++ b/nix/analysis/fuzz/replay_main.c @@ -0,0 +1,85 @@ +/* + * A plain main() for the fuzz harnesses, used by checks.fuzz-replay: it + * feeds every file named on the command line (directories are walked one + * level) to LLVMFuzzerTestOneInput() once. Built with gcc and no libFuzzer, + * it keeps every harness compiling and its seed corpus crash-free under + * ASan/UBSan, without needing clang's fuzzer runtime. + * + * SPDX-License-Identifier: ISC + */ + +#include +#include +#include +#include +#include + +#include "fuzz_harness.h" + +#pragma weak LLVMFuzzerInitialize + +static int run_file(const char *path) +{ + uint8_t *buf; + long len; + FILE *fp; + + fp = fopen(path, "rb"); + if (!fp || fseek(fp, 0, SEEK_END) || (len = ftell(fp)) < 0) { + perror(path); + return 1; + } + rewind(fp); + + buf = malloc(len ? (size_t)len : 1); + if (!buf || fread(buf, 1, (size_t)len, fp) != (size_t)len) { + perror(path); + return 1; + } + fclose(fp); + + LLVMFuzzerTestOneInput(buf, (size_t)len); + free(buf); + + return 0; +} + +int main(int argc, char *argv[]) +{ + int rc = 0, runs = 0; + + if (LLVMFuzzerInitialize) + LLVMFuzzerInitialize(&argc, &argv); + + for (int i = 1; i < argc; i++) { + struct stat st; + + if (stat(argv[i], &st)) { + perror(argv[i]); + return 1; + } + + if (S_ISDIR(st.st_mode)) { + struct dirent *d; + DIR *dir = opendir(argv[i]); + + while (dir && (d = readdir(dir))) { + char path[4096]; + + if (d->d_name[0] == '.') + continue; + snprintf(path, sizeof(path), "%s/%s", argv[i], d->d_name); + rc |= run_file(path); + runs++; + } + if (dir) + closedir(dir); + } else { + rc |= run_file(argv[i]); + runs++; + } + } + + printf("replayed %d input(s)\n", runs); + return rc || !runs; +} diff --git a/nix/analysis/fuzz/stubs.c b/nix/analysis/fuzz/stubs.c new file mode 100644 index 0000000..f1c315a --- /dev/null +++ b/nix/analysis/fuzz/stubs.c @@ -0,0 +1,469 @@ +/* + * Stand-ins for main.c, pev.c and sock.c, linked into every fuzz harness. + * See fuzz_harness.h for the contract. + * + * SPDX-License-Identifier: ISC + */ + +#include "defs.h" +#include "pev.h" +#include "sock.h" + +#include +#include +#include + +#include "fuzz_harness.h" + +/* ── main.c globals ─────────────────────────────────────────────── */ +int haveterminal = 0; +int running = 1; +int use_syslog = 0; +time_t mcd_init_time; + +char *config_file = "fuzz.conf"; +char *pid_file = NULL; +char *sock_file = NULL; + +char *ident = PACKAGE_NAME; +char *prognm = PACKAGE_NAME; +const char *versionstring = "mcd version " PACKAGE_VERSION; + +void restart(void) +{ +} + +/* ── exit() / popen() wrappers (-Wl,--wrap=exit,--wrap=popen,...) ── */ +jmp_buf fuzz_exit_jmp; +volatile int fuzz_exit_armed; + +void __real_exit(int status) __attribute__((noreturn)); +void __wrap_exit(int status) __attribute__((noreturn)); + +void __wrap_exit(int status) +{ + if (fuzz_exit_armed) { + fuzz_exit_armed = 0; + longjmp(fuzz_exit_jmp, 1); + } + __real_exit(status); +} + +static uint8_t *popen_data; +static size_t popen_len; + +void fuzz_popen_set(const uint8_t *data, size_t len) +{ + free(popen_data); + popen_data = NULL; + popen_len = 0; + if (!len) + return; + + popen_data = malloc(len); + if (!popen_data) + abort(); + memcpy(popen_data, data, len); + popen_len = len; +} + +FILE *__wrap_popen(const char *cmd, const char *mode); +int __wrap_pclose(FILE *fp); + +/* Every command "prints" the configured data; nothing is executed. */ +FILE *__wrap_popen(const char *cmd, const char *mode) +{ + (void)cmd; + (void)mode; + + if (!popen_len) + return fopen("/dev/null", "r"); + + return fmemopen(popen_data, popen_len, "r"); +} + +int __wrap_pclose(FILE *fp) +{ + return fclose(fp); +} + +/* ── sock.c ─────────────────────────────────────────────────────── */ +uint8_t *recv_buf; +uint8_t *send_buf; +size_t fuzz_sent_packets; + +void sock_init(void) +{ +} + +void sock_exit(void) +{ +} + +void sock_iface_init(struct ifi *ifi) +{ + (void)ifi; +} + +void sock_iface_exit(struct ifi *ifi) +{ + (void)ifi; +} + +void sock_reload_begin(void) +{ +} + +void sock_reload_end(void) +{ +} + +/* Same frame layout as sock.c's build_ether(), including the 802.1Q tag + * of raw VLAN mode. */ +size_t build_ether(uint8_t *buf, const struct ifi *ifi, const uint8_t *dmac, uint16_t proto) +{ + struct ether_header *eh = (struct ether_header *)buf; + size_t len = sizeof(*eh); + + memcpy(eh->ether_dhost, dmac, sizeof(eh->ether_dhost)); + memcpy(eh->ether_shost, ifi->ifi_hwaddr, sizeof(eh->ether_shost)); + + if (ifi->ifi_vlan) { + uint16_t tag[2]; + + eh->ether_type = htons(ETH_P_8021Q); + tag[0] = htons((6 << 13) | ifi->ifi_vlan); + tag[1] = htons(proto); + memcpy(buf + len, tag, sizeof(tag)); + len += sizeof(tag); + } else + eh->ether_type = htons(proto); + + return len; +} + +int sock_send(const struct ifi *ifi, size_t len) +{ + (void)ifi; + + /* send_buf ends exactly RECV_BUF_SIZE on, so ASan has already + * caught any overflow while building; this catches a bad length. */ + if (len > RECV_BUF_SIZE) + abort(); + fuzz_sent_packets++; + + return 0; +} + +int mcast_join(const struct ifi *ifi, const inet_addr_t *group) +{ + (void)ifi; + (void)group; + + return 0; +} + +/* ── pev.c ──────────────────────────────────────────────────────── */ +/* + * Timers keep pev.c's observable semantics: ids are never reused, a + * one-shot timer stays deletable after it fires, deleting runs the + * cb_del registered for it. Nothing is scheduled; fuzz_timers_fire() + * runs every live timer once. + */ +#define MAX_TIMERS 8192 + +struct ftimer { + int id; + int live; /* not deleted */ + int armed; /* will fire */ + int timeout, period; + void (*cb)(int, void *); + void (*cb_del)(void *); + void *arg; +}; + +static struct ftimer timers[MAX_TIMERS]; +static int next_id = 1; + +static struct ftimer *timer_find(int id) +{ + for (int i = 0; i < MAX_TIMERS; i++) { + if (timers[i].live && timers[i].id == id) + return &timers[i]; + } + errno = ENOENT; + return NULL; +} + +int pev_init(void) +{ + return 0; +} + +int pev_exit(int status) +{ + return status; +} + +int pev_run(void) +{ + return 0; +} + +int pev_sig_add(int signo, void (*cb)(int, void *), void *arg) +{ + (void)signo; + (void)cb; + (void)arg; + return next_id++; +} + +int pev_sig_del(int id) +{ + (void)id; + return 0; +} + +int pev_sig_set_cb_del(int id, void (*cb)(void *)) +{ + (void)id; + (void)cb; + return 0; +} + +int pev_sock_add(int sd, void (*cb)(int, void *), void *arg) +{ + (void)sd; + (void)cb; + (void)arg; + return next_id++; +} + +int pev_sock_del(int id) +{ + (void)id; + return 0; +} + +int pev_sock_open(int domain, int type, int proto, void (*cb)(int, void *), void *arg) +{ + (void)cb; + (void)arg; + return socket(domain, type, proto); +} + +int pev_sock_close(int sd) +{ + return close(sd); +} + +int pev_sock_set_cb_del(int id, void (*cb)(void *)) +{ + (void)id; + (void)cb; + return 0; +} + +int pev_timer_add(int timeout, int period, void (*cb)(int, void *), void *arg) +{ + if ((timeout <= 0 && period <= 0) || !cb) { + errno = EINVAL; + return -1; + } + + for (int i = 0; i < MAX_TIMERS; i++) { + struct ftimer *t = &timers[i]; + + if (t->live) + continue; + + *t = (struct ftimer){ + .id = next_id++, .live = 1, .armed = 1, + .timeout = timeout, .period = period, + .cb = cb, .arg = arg, + }; + return t->id; + } + + errno = ENOMEM; + return -1; +} + +int pev_timer_del(int id) +{ + struct ftimer *t = timer_find(id); + + if (!t) + return -1; + + t->live = t->armed = 0; + if (t->cb_del) + t->cb_del(t->arg); + + return 0; +} + +int pev_timer_set(int id, int timeout) +{ + struct ftimer *t = timer_find(id); + + if (!t) + return -1; + + t->timeout = timeout; + t->armed = 1; + + return 0; +} + +int pev_timer_get(int id) +{ + struct ftimer *t = timer_find(id); + + if (!t) + return -1; + + return t->timeout ? t->timeout : t->period; +} + +int pev_timer_set_cb_del(int id, void (*cb)(void *)) +{ + struct ftimer *t = timer_find(id); + + if (!t) + return -1; + + t->cb_del = cb; + + return 0; +} + +void fuzz_timers_fire(void) +{ + int last = next_id; + + /* Only timers that existed on entry; callbacks may add/delete. */ + for (int i = 0; i < MAX_TIMERS; i++) { + struct ftimer *t = &timers[i]; + int timeout; + + if (!t->live || !t->armed || t->id >= last) + continue; + + timeout = t->timeout ? t->timeout : t->period; + t->timeout = 0; + t->cb(timeout, t->arg); + + /* The callback may have deleted or re-armed the slot. */ + if (t->live && !t->period && !t->timeout) + t->armed = 0; + } +} + +void fuzz_timers_clear(void) +{ + for (int i = 0; i < MAX_TIMERS; i++) { + if (timers[i].live) + pev_timer_del(timers[i].id); + } +} + +/* ── globals and interfaces ─────────────────────────────────────── */ +static struct { + int router_alert; + uint32_t router_timeout, qi, qri, lmi, robustness; +} saved; + +void fuzz_init(void) +{ + /* + * logging == 0 prints everything to stderr, unfiltered (prinl()). + * logging == 1 goes through logger(), which formats every message, + * so the format strings are still exercised, and then syslog(), which + * the mask reduces to a no-op: mcd never logs LOG_EMERG. + */ + if (getenv("FUZZ_VERBOSE")) { + logging = 0; + } else { + logging = 1; + loglevel = LOG_DEBUG; + setlogmask(LOG_UPTO(LOG_EMERG)); + } + + /* Laid out as sock_init() does, aligned IP headers included */ + recv_buf = calloc(1, SOCK_BUF_ALIGN + RECV_BUF_SIZE); + send_buf = calloc(1, SOCK_BUF_ALIGN + RECV_BUF_SIZE); + if (!recv_buf || !send_buf) + abort(); + recv_buf += SOCK_BUF_ALIGN; + send_buf += SOCK_BUF_ALIGN; + + igmp_init(); + + saved.router_alert = router_alert; + saved.router_timeout = router_timeout; + saved.qi = igmp_query_interval; + saved.qri = igmp_response_interval; + saved.lmi = igmp_last_member_interval; + saved.robustness = igmp_robustness; +} + +void fuzz_reset_globals(void) +{ + router_alert = saved.router_alert; + router_timeout = saved.router_timeout; + igmp_query_interval = saved.qi; + igmp_response_interval = saved.qri; + igmp_last_member_interval = saved.lmi; + igmp_robustness = saved.robustness; +} + +#define FUZZ_IFINDEX 4242 /* no such interface in the sandbox */ + +struct ifi *fuzz_iface_up(unsigned int mode, int family) +{ + static const uint8_t mac[6] = { 0x02, 0, 0, 0, 0, 0x01 }; + struct sockaddr_in sin = { .sin_family = AF_INET }; + struct sockaddr_in6 sin6 = { .sin6_family = AF_INET6 }; + struct ifi *ifi, *twin, *e; + int compat; + + compat = mode & FUZZ_MODE_COMPAT_MASK; + if (!compat) + compat = GEN_V3; + + ifi = config_iface_add("fuzz0"); + twin = config_iface_twin(ifi); + + for (e = ifi; e; e = (e == ifi ? twin : NULL)) { + e->ifi_index = FUZZ_IFINDEX; + e->ifi_compat = (e->ifi_family == AF_INET6 && compat == GEN_V1) ? GEN_V2 : compat; + memcpy(e->ifi_hwaddr, mac, sizeof(mac)); + e->ifi_flags &= ~IFIF_DISABLED; + if (mode & FUZZ_MODE_DISABLED) + e->ifi_flags |= IFIF_DISABLED; + if (mode & FUZZ_MODE_PROXY) + e->ifi_flags |= IFIF_PROXY_QUERIER; + if (mode & FUZZ_MODE_VLAN) + e->ifi_vlan = 10; + } + + /* 10.0.0.10 / fe80::10 win elections against the seeds' 10.0.0.20 / + * fe80::20; .200 / ::200 lose them. */ + sin.sin_addr.s_addr = htonl((mode & FUZZ_MODE_HIGH_ADDR) ? 0x0a0000c8 : 0x0a00000a); + inet_pton(AF_INET6, (mode & FUZZ_MODE_HIGH_ADDR) ? "fe80::200" : "fe80::10", &sin6.sin6_addr); + config_iface_addr_add(FUZZ_IFINDEX, (struct sockaddr *)&sin, IFF_UP | IFF_MULTICAST); + config_iface_addr_add(FUZZ_IFINDEX, (struct sockaddr *)&sin6, IFF_UP | IFF_MULTICAST); + + iface_check(FUZZ_IFINDEX, IFF_UP | IFF_MULTICAST); + + return family == AF_INET6 && twin ? twin : ifi; +} + +void fuzz_iface_down(void) +{ + iface_exit(); + fuzz_timers_clear(); + fuzz_reset_globals(); + fuzz_sent_packets = 0; +} diff --git a/nix/targets.nix b/nix/targets.nix index 635416b..87c4ecc 100644 --- a/nix/targets.nix +++ b/nix/targets.nix @@ -65,6 +65,16 @@ desc = "+ gcc -fanalyzer, semgrep"; target = "analysis-deep"; } + { + cmd = "nix build .#fuzz"; + desc = "libFuzzer harnesses; ./result/bin/run-fuzzers"; + target = "fuzz"; + } + { + cmd = "nix build .#fuzz-run"; + desc = "20 s per harness, crashes + repro"; + target = "fuzz-run"; + } ]; } ] From 8ec02aa2aa02a4449074c1603018b18dd6dc69a2 Mon Sep 17 00:00:00 2001 From: Dave Seddon Date: Mon, 19 Oct 2026 13:33:14 -0700 Subject: [PATCH 23/24] nix: run the test suite in microVMs, and a three-VM topology test nix run .#vm-check boots a NixOS microVM (qemu_kvm, the host store over 9p, serial console on TCP) and runs `make check` from a kept --enable-test build tree, driven over the serial console by expect. vm-check-asan and vm-check-valgrind do the same against an ASan + UBSan tree and with mcd wrapped in valgrind, and fail on any finding in the test logs. nix run .#vm-topo: a switch VM bridges two host VMs over QEMU dgram unix-socket links; mcd on the switch is checked for querier election, IGMPv3/MLDv2 general queries and their interval, snooped joins and leaves in the mdb, losing the election to a lower-addressed rival mcd, taking over again when the rival goes away, and the router timeout following the configured intervals. The runner (runner.sh) boots any number of VMs from one JSON config, tails their consoles, and maps the outcome to an exit code: 0 pass, 1 test failure, 2 no console, 3 setup, 124 timeout. The expect library and drivers are ported from partybus, and checks.vm-lib-exp tests them hermetically against a bash pty, including make-check.exp against fake build trees. Signed-off-by: Dave Seddon --- flake.lock | 38 +++ flake.nix | 27 +- nix/microvms/console/check.sh | 85 +++++ nix/microvms/console/default.nix | 173 ++++++++++ nix/microvms/console/node-diag.exp | 42 +++ nix/microvms/console/node-ready.exp | 59 ++++ nix/microvms/console/vm-lib-test.exp | 247 ++++++++++++++ nix/microvms/console/vm-lib.exp | 435 ++++++++++++++++++++++++ nix/microvms/constants.nix | 90 +++++ nix/microvms/default.nix | 178 ++++++++++ nix/microvms/mkVm.nix | 198 +++++++++++ nix/microvms/runner.sh | 143 ++++++++ nix/microvms/runners.nix | 110 ++++++ nix/microvms/scripts/make-check.exp | 161 +++++++++ nix/microvms/scripts/multicast-topo.exp | 300 ++++++++++++++++ nix/targets.nix | 25 ++ 16 files changed, 2307 insertions(+), 4 deletions(-) create mode 100644 nix/microvms/console/check.sh create mode 100644 nix/microvms/console/default.nix create mode 100644 nix/microvms/console/node-diag.exp create mode 100644 nix/microvms/console/node-ready.exp create mode 100644 nix/microvms/console/vm-lib-test.exp create mode 100644 nix/microvms/console/vm-lib.exp create mode 100644 nix/microvms/constants.nix create mode 100644 nix/microvms/default.nix create mode 100644 nix/microvms/mkVm.nix create mode 100644 nix/microvms/runner.sh create mode 100644 nix/microvms/runners.nix create mode 100644 nix/microvms/scripts/make-check.exp create mode 100644 nix/microvms/scripts/multicast-topo.exp diff --git a/flake.lock b/flake.lock index 9f6b98c..688b9c8 100644 --- a/flake.lock +++ b/flake.lock @@ -18,6 +18,27 @@ "type": "github" } }, + "microvm": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ], + "spectrum": "spectrum" + }, + "locked": { + "lastModified": 1791551030, + "narHash": "sha256-jTx3O3MKqPjv+cDfBFQ5zlKECBEwzF91scC+mSumZyE=", + "owner": "microvm-nix", + "repo": "microvm.nix", + "rev": "f13928fe9900e91709d7ce581eefa47eaafa79e1", + "type": "github" + }, + "original": { + "owner": "microvm-nix", + "repo": "microvm.nix", + "type": "github" + } + }, "nixpkgs": { "locked": { "lastModified": 1792094642, @@ -37,9 +58,26 @@ "root": { "inputs": { "flake-utils": "flake-utils", + "microvm": "microvm", "nixpkgs": "nixpkgs" } }, + "spectrum": { + "flake": false, + "locked": { + "lastModified": 1786032750, + "narHash": "sha256-+Ba/kIn5BjvV7PN8bWOo7RoqgTY/Yv2M7qeMf3wS2Ag=", + "ref": "refs/heads/main", + "rev": "77d2389874bb092b8dbf05ecc5b1030372e80e98", + "revCount": 1417, + "type": "git", + "url": "https://spectrum-os.org/git/spectrum" + }, + "original": { + "type": "git", + "url": "https://spectrum-os.org/git/spectrum" + } + }, "systems": { "locked": { "lastModified": 1681028828, diff --git a/flake.nix b/flake.nix index 49e414c..fbea34b 100644 --- a/flake.nix +++ b/flake.nix @@ -4,7 +4,7 @@ # A reproducible development environment, hermetic native and cross builds, # and OCI images for the mcd IGMP/MLD querier. The design is modular: this # slim flake.nix wires together small single-purpose modules under nix/, -# all sharing one context built in nix/context.nix (see nix/README.md). +# all sharing one context built in nix/context.nix. # mcd is one callPackage function (nix/package.nix); every variant is an # .override of it and every cross build a pkgsCross..callPackage. # @@ -17,13 +17,16 @@ # Cross-compile (from an x86_64-linux host): # nix build .#mcd-aarch64 # also: -armv7l, -riscv64, -ppc64le # -# Run the static analysis (nix/README.md lists every target): +# Run the static analysis: # nix build .#analysis-deep && cat result/summary.txt # # Fuzz the packet, config and IPC parsers (libFuzzer): # nix build .#fuzz && FUZZ_TIME=60 ./result/bin/run-fuzzers # nix build .#fuzz-run # 20 s per harness; also: fuzz-run- # +# Run the network test suite in a KVM microVM (needs /dev/kvm): +# nix run .#vm-check # also: vm-check-asan, vm-check-valgrind +# # Build an OCI image (streamLayeredImage: the result is a loader script): # nix build .#oci && ./result | docker load # also: oci-debug, oci-[-debug] # @@ -40,6 +43,13 @@ inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; flake-utils.url = "github:numtide/flake-utils"; + + # microvm.nix: the KVM guest that runs the network test suite + # (nix/microvms/). + microvm = { + url = "github:microvm-nix/microvm.nix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; }; # Linux-only: mcd uses AF_PACKET and netlink. eachSystem (not @@ -50,6 +60,7 @@ self, nixpkgs, flake-utils, + microvm, }: flake-utils.lib.eachSystem [ "x86_64-linux" "aarch64-linux" ] ( system: @@ -100,15 +111,21 @@ # Only from an x86_64-linux host: # cross mcd per arch ({ pkgs, mcd }); on a native aarch64 # host the default package already covers arm64 + # microvms the network test suite as root on a real kernel + # under KVM (nix/microvms/): packages, apps, checks isX86_64 = system == "x86_64-linux"; x86_64 = lib.optionalAttrs isX86_64 ( let cross = import ./nix/cross.nix { inherit ctx; }; + microvms = import ./nix/microvms { inherit ctx nixpkgs microvm; }; in { packages = lib.mapAttrs' (arch: r: lib.nameValuePair "mcd-${arch}" r.mcd) cross - // lib.concatMapAttrs (arch: r: mkOciPair r.pkgs.callPackage r.mcd "-${arch}") cross; + // lib.concatMapAttrs (arch: r: mkOciPair r.pkgs.callPackage r.mcd "-${arch}") cross + // microvms.packages; + inherit (microvms) apps; + inherit (microvms) checks; } ); @@ -130,6 +147,7 @@ in { inherit packages; + apps = x86_64.apps or { }; checks = { build-gcc = mcd; @@ -139,7 +157,8 @@ semgrep-rules = analysis.semgrepRuleTests; fuzz-replay = analysis.fuzz-replay; unit = analysis.unit; - }; + } + // x86_64.checks or { }; devShells.default = devshell; diff --git a/nix/microvms/console/check.sh b/nix/microvms/console/check.sh new file mode 100644 index 0000000..3981706 --- /dev/null +++ b/nix/microvms/console/check.sh @@ -0,0 +1,85 @@ +# Hermetic check of the console library and drivers (checks.vm-lib-exp), +# run by mcd-vm-lib-check, a writeShellApplication (errexit, nounset, +# pipefail; shellchecked): +# +# mcd-vm-lib-check OUT SCRIPTS ROWS +# +# starts a bash on a pty behind a local TCP port, runs vm-lib-test.exp +# against it, then each row of ROWS (JSON: description, cmd, rc, expect; +# see default.nix) and copies the library transcript to OUT. A row's cmd +# runs under bash -c with S (SCRIPTS), T (fake build trees), PORT, DEAD +# and FLAG in the environment. + +out=$1 +S=$2 +rows=$3 +# The sandbox has a private network namespace: fixed ports are free. +# Exported for the row commands, which run under bash -c. +export S PORT=17767 DEAD=17768 FLAG=$PWD/flag + +# bash on a pty is a faithful stand-in for the guest's autologin +# shell: job control, line editing and terminal echo all depend on +# stdin being a tty. One fresh shell per connection (fork). +socat "TCP-LISTEN:$PORT,reuseaddr,fork" \ + EXEC:'bash --norc --noprofile -i',pty,setsid,ctty,stderr >socat.log 2>&1 & +socat_pid=$! +for _ in $(seq 1 50); do + if (exec 3<>/dev/tcp/127.0.0.1/"$PORT") 2>/dev/null; then break; fi + sleep 0.1 +done + +rc=0 +expect "$S"/vm-lib-test.exp "$PORT" >lib.log 2>&1 || rc=$? +grep -E '^(PASS|FAIL|===)' lib.log || true +if [[ $rc -ne 0 ]] || ! grep -q VMLIB_TEST_PASS lib.log; then + echo "vm-lib.exp regressed (expect exited $rc); transcript:" >&2 + cat lib.log >&2 + exit 1 +fi + +# Fake build trees for make-check.exp: a Makefile whose `check` +# prints automake-style result lines. +export T=$PWD/trees +mk() { + mkdir -p "$T/$1" + printf 'check:\n\t@%s\n' "$2" > "$T/$1/Makefile" +} +mk pass 'echo "PASS: a.sh"; sleep 1; echo "PASS: b.sh"' +mk fail 'echo "PASS: a.sh"; echo "FAIL: b.sh"; exit 1' +mk slow 'echo "PASS: a.sh"; sleep 60' +# Two reports in a.log, one copy in test-suite.log (not counted). +mk asan 'mkdir -p test; printf "ERROR: AddressSanitizer: fake\nx.c:1:2: runtime error: fake\n" > test/a.log; cp test/a.log test/test-suite.log; echo "PASS: a.sh"' + +echo "=== drivers ===" +pass=0 +fail=0 +(sleep 3; touch "$FLAG") & +i=0 +while IFS= read -r r; do + i=$((i + 1)) + desc=$(jq -r .description <<<"$r") + cmd=$(jq -r .cmd <<<"$r") + want_rc=$(jq -r .rc <<<"$r") + want=$(jq -r .expect <<<"$r") + rc=0 + got=$(bash -c "$cmd" "row-$i.err") || rc=$? + if [[ "$want" == '!'* ]]; then + re=${want#!} + if [[ "$got" =~ $re ]]; then matched=0; else matched=1; fi + else + if [[ "$got" =~ $want ]]; then matched=1; else matched=0; fi + fi + if [[ $rc -eq $want_rc && $matched -eq 1 ]]; then + echo "PASS: $desc" + pass=$((pass + 1)) + else + echo "FAIL: $desc (rc=$rc want=$want_rc)" + printf ' out: [%s]\n' "$got" + sed 's/^/ err: /' "row-$i.err" + fail=$((fail + 1)) + fi +done < <(jq -c '.[]' "$rows") +kill "$socat_pid" 2>/dev/null || true +echo "=== drivers: $pass passed, $fail failed ===" +if (( fail > 0 )); then exit 1; fi +cp lib.log "$out" diff --git a/nix/microvms/console/default.nix b/nix/microvms/console/default.nix new file mode 100644 index 0000000..b24c897 --- /dev/null +++ b/nix/microvms/console/default.nix @@ -0,0 +1,173 @@ +# nix/microvms/console/default.nix +# +# Serial-console automation for the mcd microVMs (expect, ported from +# xtcp2 by way of partybus). `scripts` bundles the library and drivers in +# one store directory, because drivers source vm-lib.exp from beside +# themselves; the mcd drivers (../scripts/*.exp, the `drivers` argument) +# are installed alongside: +# +# vm-lib.exp marker-framed run_out / run / must over a TCP console +# node-ready.exp console up, then poll readiness steps +# node-diag.exp dump diagnostics when a test fails +# +# `check` (checks.vm-lib-exp) drives all three against a local bash on a +# pty, with no VM: vm-lib-test.exp's library rows, then table rows for the +# two drivers. What it cannot cover - QEMU's chardev, getty autologin, boot +# timing - is covered by `nix run .#vm-check`. +# +{ + pkgs, + lib, + # Extra drivers (name -> path) installed next to the library. + drivers ? { }, +}: +let + scripts = pkgs.runCommand "mcd-console-scripts" { } '' + mkdir -p $out + cp ${./vm-lib.exp} $out/vm-lib.exp + cp ${./vm-lib-test.exp} $out/vm-lib-test.exp + cp ${./node-ready.exp} $out/node-ready.exp + cp ${./node-diag.exp} $out/node-diag.exp + ${lib.concatStrings (lib.mapAttrsToList (n: p: "cp ${p} $out/${n}\n") drivers)} + ''; + + # Driver rows: description, shell command run in the check, expected exit + # status, and a bash ERE its whole stdout must (or, with !, must not) + # match. $PORT is the pty shell, $DEAD a port nothing listens on, $FLAG a + # file the check creates 3s after the rows start. + row = description: cmd: rc: expect: { + inherit + description + cmd + rc + expect + ; + }; + driverRows = [ + (row "positive: ready reports the console and each step in order" + ''expect "$S"/node-ready.exp "$PORT" 30 first true second "test 1 -eq 1"'' + 0 + "^MCD_STEP console\nMCD_STEP first\nMCD_STEP second$" + ) + (row "positive: ready polls a step until it passes" + ''expect "$S"/node-ready.exp "$PORT" 30 flag "test -f $FLAG"'' + 0 + "MCD_STEP flag$" + ) + (row "negative: a step that never passes fails the driver with exit 1" + "expect \"$S\"/node-ready.exp \"$PORT\" 8 never false" + 1 + "^MCD_STEP console$" + ) + (row "negative: steps after a failed step are not reported" + "expect \"$S\"/node-ready.exp \"$PORT\" 8 never false later true" + 1 + "!MCD_STEP later" + ) + (row "negative: no console within the budget is exit 2" "expect \"$S\"/node-ready.exp \"$DEAD\" 3" 2 + "!MCD_STEP" + ) + (row "boundary: no steps is console-only and passes" "expect \"$S\"/node-ready.exp \"$PORT\" 30" 0 + "^MCD_STEP console$" + ) + (row "boundary: an unpaired step argument is a usage error" + "expect \"$S\"/node-ready.exp \"$PORT\" 30 lonely" + 2 + "!MCD_STEP" + ) + (row "positive: diag prints each command under its header" + ''expect "$S"/node-diag.exp "$PORT" 30 "echo one" "echo two"'' + 0 + "^--- \\$ echo one\none\n--- \\$ echo two\ntwo$" + ) + (row "negative: diag reports a failing command's status and carries on" + ''expect "$S"/node-diag.exp "$PORT" 30 "(exit 3)" "echo after"'' + 0 + "\\(exit 3\\)\n--- \\$ echo after\nafter$" + ) + (row "corner: diag survives a command with no output" + ''expect "$S"/node-diag.exp "$PORT" 30 true "echo end"'' + 0 + "^--- \\$ true\n--- \\$ echo end\nend$" + ) + (row "negative: diag with no console is exit 2" "expect \"$S\"/node-diag.exp \"$DEAD\" 3 true" 2 + "!---" + ) + + # make-check.exp against fake build trees ($T/, made below) whose + # `make check` passes, fails, hangs, or leaves a sanitizer report. + # MCD_VM_KEEP skips the poweroff, which a pty shell cannot do. + (row "positive: a passing suite streams its results and exits 0" + "MCD_VM_KEEP=1 expect \"$S\"/make-check.exp \"$PORT\" 30 60 \"$T\"/pass plain" + 0 + "^PASS: a.sh\nPASS: b.sh\nMCD_CHECK tests=pass exercise=clean findings=0$" + ) + (row "negative: a failing suite exits 1 and shows the log" + "MCD_VM_KEEP=1 expect \"$S\"/make-check.exp \"$PORT\" 30 60 \"$T\"/fail plain" + 1 + "FAIL: b.sh(.|\n)*=== /tmp/check.log \\(tail\\) ===(.|\n)*MCD_CHECK tests=fail exercise=clean findings=0$" + ) + (row "negative: a suite that outlives its budget is incomplete, exit 1" + "MCD_VM_KEEP=1 expect \"$S\"/make-check.exp \"$PORT\" 30 4 \"$T\"/slow plain" + 1 + "MCD_CHECK tests=fail exercise=incomplete findings=0$" + ) + (row "negative: asan findings print the reports and fail a passing suite, exit 1" + "MCD_VM_KEEP=1 expect \"$S\"/make-check.exp \"$PORT\" 30 60 \"$T\"/asan asan" + 1 + "--- a.log\nERROR: AddressSanitizer: fake(.|\n)*MCD_CHECK tests=pass exercise=clean findings=2$" + ) + (row "negative: asan mode with clean test logs reports no findings" + "MCD_VM_KEEP=1 expect \"$S\"/make-check.exp \"$PORT\" 30 60 \"$T\"/pass asan" + 0 + "^PASS: a.sh\nPASS: b.sh\nMCD_CHECK tests=pass exercise=clean findings=0$" + ) + (row "boundary: an unknown mode is a usage error" + "expect \"$S\"/make-check.exp \"$PORT\" 30 60 \"$T\"/pass bogus" + 2 + "!MCD_CHECK" + ) + # multicast-topo.exp needs three real guests; here only its argument + # handling and the no-console path. + (row "boundary: multicast-topo with too few arguments is a usage error" + "expect \"$S\"/multicast-topo.exp \"$PORT\" \"$PORT\" 30" + 2 + "!MCD_TOPO" + ) + (row "negative: multicast-topo with no console is exit 2" + "expect \"$S\"/multicast-topo.exp \"$DEAD\" \"$DEAD\" \"$DEAD\" /nonexistent 3" + 2 + "!MCD_TOPO" + ) + (row "negative: make-check with no console is exit 2" + "expect \"$S\"/make-check.exp \"$DEAD\" 3 60 \"$T\"/pass plain" + 2 + "!MCD_CHECK" + ) + ]; + + # The check's driver (check.sh), a writeShellApplication (shellchecked, + # with its own tools): starts the pty shell, runs the library tests and + # the driver rows, and copies the library transcript to OUT. + checkScript = pkgs.writeShellApplication { + name = "mcd-vm-lib-check"; + runtimeInputs = with pkgs; [ + bashInteractive + coreutils + expect + gnugrep + gnused + gnumake + socat + jq + ]; + text = builtins.readFile ./check.sh; + }; + + check = pkgs.runCommand "mcd-vm-lib-exp" { } '' + ${lib.getExe checkScript} $out ${scripts} ${pkgs.writeText "vm-lib-rows.json" (builtins.toJSON driverRows)} + ''; +in +{ + inherit scripts check; +} diff --git a/nix/microvms/console/node-diag.exp b/nix/microvms/console/node-diag.exp new file mode 100644 index 0000000..70d441c --- /dev/null +++ b/nix/microvms/console/node-diag.exp @@ -0,0 +1,42 @@ +#!/usr/bin/env expect +############################################################################### +# node-diag.exp - collect diagnostics from a node over its serial console +# +# Usage: node-diag.exp ... +# +# The mcd runners run this on the guest when a test fails. The +# console works when the network does not (a test that wedged it, a crash-looping +# unit holding the boot), so this is the one place a failure report can +# always come from. Each command's output goes to stdout under a +# `--- $ ` header, followed by its exit status if non-zero. +# +# Exit codes: 0 diagnostics collected (whatever the commands returned), +# 2 no console. A command that times out is reported and skipped. +############################################################################### + +source [file dirname [info script]]/vm-lib.exp + +if {$argc < 2} { + puts stderr "usage: node-diag.exp ..." + exit 2 +} +set port [lindex $argv 0] +set budget [lindex $argv 1] + +vmlib::init +vmlib::connect $port $budget +vmlib::run "dmesg -n 1; stty cols 250; export SYSTEMD_PAGER= SYSTEMD_COLORS=0 COLUMNS=250" 10 + +foreach cmd [lrange $argv 2 end] { + puts "--- \$ $cmd" + lassign [vmlib::run_out $cmd 30] rc out + if {$out ne ""} { + puts $out + } + if {$rc == -1} { + puts "(timed out)" + } elseif {$rc != 0} { + puts "(exit $rc)" + } +} +exit 0 diff --git a/nix/microvms/console/node-ready.exp b/nix/microvms/console/node-ready.exp new file mode 100644 index 0000000..9d3f9c7 --- /dev/null +++ b/nix/microvms/console/node-ready.exp @@ -0,0 +1,59 @@ +#!/usr/bin/env expect +############################################################################### +# node-ready.exp - poll readiness steps over a guest's serial console +# +# Usage: node-ready.exp ... +# +# Attaches to one node's serial console, waits for a shell that actually +# executes commands (vmlib::connect), then polls each in order +# until it exits 0. Everything shares one deadline: from start. +# +# stdout carries one line per finished step, for the lifecycle's timing: +# MCD_STEP console +# MCD_STEP +# and nothing else; vmlib logs go to stderr. +# +# Exit codes (vmlib's): 0 all steps passed, 1 a step never passed (its last +# output is on stderr), 2 no console or the console closed. +############################################################################### + +source [file dirname [info script]]/vm-lib.exp + +if {$argc < 2 || $argc % 2 != 0} { + puts stderr "usage: node-ready.exp \[ \]..." + exit 2 +} +set port [lindex $argv 0] +set budget [lindex $argv 1] +set deadline [expr {[clock seconds] + $budget}] + +vmlib::init +vmlib::connect $port $budget +puts "MCD_STEP console" + +# Kernel messages still reach the journal; on the console they would only +# interleave with command output. Wide columns stop systemctl ellipsizing. +vmlib::must "dmesg -n 1; stty cols 250; export SYSTEMD_PAGER= SYSTEMD_COLORS=0 COLUMNS=250" 10 + +foreach {description cmd} [lrange $argv 2 end] { + set out "" + while 1 { + set left [expr {$deadline - [clock seconds]}] + if {$left <= 0} { + vmlib::log "FAIL: '$description' did not pass within ${budget}s: $cmd" + if {$out ne ""} { + send_error "$out\n" + } + exit 1 + } + # A step is one quick probe, so cap each attempt well below the + # budget; a probe that hangs then counts as one failed attempt. + lassign [vmlib::run_out $cmd [expr {min($left, 20)}] 1] rc out + if {$rc == 0} { + break + } + after 2000 + } + puts "MCD_STEP $description" +} +exit 0 diff --git a/nix/microvms/console/vm-lib-test.exp b/nix/microvms/console/vm-lib-test.exp new file mode 100644 index 0000000..8650849 --- /dev/null +++ b/nix/microvms/console/vm-lib-test.exp @@ -0,0 +1,247 @@ +#!/usr/bin/env expect +############################################################################### +# vm-lib-test.exp - table-driven test for vm-lib.exp, with no microVM involved +# +# Usage: vm-lib-test.exp +# +# The caller must already have a shell listening on , which +# nix/microvms/console/default.nix (checks.vm-lib-exp) arranges with: +# +# socat TCP-LISTEN:,reuseaddr,fork \ +# EXEC:'bash --norc --noprofile -i',pty,setsid,ctty,stderr +# +# WHY A LOCAL SHELL AND NOT A GUEST +# +# vm-lib.exp's job is to turn a byte stream carrying a shell session into +# {exit status, output}. Nothing in that job is specific to a microVM: the +# guest side of the console is a bash on a pty, and so is this. Testing it +# against a local pty means the library is covered by a hermetic check that +# needs no /dev/kvm, no root and no boot, so a regression in the marker +# protocol fails in seconds instead of surfacing as a mysterious +# lifecycle timeout twenty minutes into a VM run. +# +# What this therefore does NOT cover, stated so nobody reads more into a green +# result than is there: qemu's serial console, the guest's autologin, boot +# timing. Those need the VM (nix run .#vm-check). +# +# Rows are table-driven with a description, a class and an expected outcome, +# per the repo's test standard, and the class prefix makes the output read as +# a checklist. +############################################################################### + +source [file dirname [info script]]/vm-lib.exp + +if {$argc < 1} { + puts "usage: vm-lib-test.exp " + exit 2 +} +set port [lindex $argv 0] + +vmlib::init +set con1 [vmlib::connect $port 60] + +set pass 0 +set fail 0 + +############################################################################### +# check - one row of the table. +# +# description class-prefixed, printed verbatim so -v output is a checklist +# cmd the command to hand vmlib::run_out +# want_rc expected exit status, or -1 for "the marker never arrived" +# want_re regexp the captured output must match ("" = do not check) +# want_len exact expected length of the captured output (-1 = skip) +# timeout per-row timeout, "" for the library default +# quiet pass 1 to suppress the library's timeout log line +# +# want_len exists because Tcl's regexp caps a bounded repetition at 255, so +# `x{4000}` is a compile error rather than a long match. Asserting the length +# separately is both legal and a stronger statement than a regexp anyway. +############################################################################### +proc check {description cmd want_rc want_re {want_len -1} {row_timeout ""} {quiet 0}} { + global pass fail + + lassign [vmlib::run_out $cmd $row_timeout $quiet] rc out + + set ok 1 + set why "" + + if {$rc != $want_rc} { + set ok 0 + append why " rc=$rc want=$want_rc" + } + if {$want_re ne "" && ![regexp $want_re $out]} { + set ok 0 + append why " output did not match /$want_re/" + } + if {$want_len >= 0 && [string length $out] != $want_len} { + set ok 0 + append why " length=[string length $out] want=$want_len" + } + + if {$ok} { + puts "PASS: $description" + incr pass + } else { + puts "FAIL: $description -$why" + puts " cmd: $cmd" + puts " out: \[$out\]" + incr fail + } +} + +############################################################################### +# positive - the ordinary path +############################################################################### + +check "positive: a successful command reports rc 0 and its stdout" \ + {echo hello-from-the-shell} 0 {hello-from-the-shell} + +check "positive: multi-line output is returned whole, not just the last line" \ + {printf 'alpha\nbeta\ngamma\n'} 0 {alpha\nbeta\ngamma} + +check "positive: stderr is interleaved into the captured output" \ + {echo to-stderr >&2} 0 {to-stderr} + +check "positive: a 400-line output survives line-by-line accumulation" \ + {seq 1 400} 0 {(?n)^400$} + +############################################################################### +# negative - failures must be reported as failures, not silently swallowed +############################################################################### + +check "negative: a failing command reports its real exit status" \ + {false} 1 {} + +# A subshell, not a bare `exit 42`: the console IS the shell, so `exit` ends +# the session and every later row fails with "console closed". The same trap +# exists in the real guest, which is why this is worth saying out loud. +check "negative: a specific non-zero status is preserved, not normalized to 1" \ + {(exit 42)} 42 {} + +check "negative: a command that does not exist reports rc 127" \ + {this-command-does-not-exist-mcd} 127 {} + +# The marker is what proves a command finished. A command that outlives its +# timeout must report -1 rather than inventing a status, and - the part that +# actually matters - must not let the NEXT row match its leftovers. +check "negative: a command that outruns its timeout reports rc -1" \ + {sleep 30} -1 {} -1 2 + +check "negative: the row after a timeout is not satisfied by the stale marker" \ + {echo recovered} 0 {recovered} -1 45 + +# `quiet` is what connect's readiness probe uses, where a timeout per attempt +# is the expected case. It must suppress only the log line: if it also changed +# the returned status, connect would read a still-booting guest as ready, which +# is the exact failure it was written to remove. +check "negative: quiet suppresses the log line but not the -1 status" \ + {sleep 30} -1 {} -1 2 1 + +check "negative: the row after a quiet timeout also recovers cleanly" \ + {echo recovered-after-quiet} 0 {recovered-after-quiet} -1 45 + +############################################################################### +# boundary - the edges of the marker protocol +############################################################################### + +check "boundary: a command with no output at all still yields its status" \ + {true} 0 {} + +check "boundary: output with no trailing newline is still captured" \ + {printf 'no-trailing-newline'} 0 {no-trailing-newline} + +check "boundary: a single 4000-byte line exceeds the default match buffer" \ + {printf 'x%.0s' $(seq 1 4000); echo} 0 {^x+$} 4000 + +check "boundary: an empty command is a no-op with status 0" \ + {:} 0 {} + +# The front of the output is where the old positional framing went wrong: it +# dropped "the first line" as the command echo, so a command whose real output +# opened with a blank line lost it. Assert the length, because a regexp cannot +# distinguish "kept the blank" from "did not". +check "boundary: output that begins with a blank line keeps that line" \ + {printf '\nfirst-real-line\n'} 0 {first-real-line} 16 + +############################################################################### +# corner - things that look like the protocol but are not +############################################################################### + +# A command whose own output spells a marker for a DIFFERENT sequence number +# must not terminate this call. This is the failure mode prompt matching has +# and the counter exists to remove; assert it directly. +check "corner: output containing another call's marker does not end the call" \ + {echo MCD_RC_1=0; echo after-the-decoy} 0 {after-the-decoy} + +# ANSI escapes are stripped, so a colorized guest does not corrupt the text a +# caller compares against a committed sidecar. +# The brackets are NOT backslash-escaped: this is a Tcl braced string, so it +# is passed through verbatim, and `\[` would reach printf as a literal +# backslash-bracket - producing ESC followed by `\[`, which is not a CSI +# sequence and so would not be stripped. The row would then fail for a reason +# that has nothing to do with the library. +check "corner: ANSI color escapes are stripped from captured output" \ + {printf '\033[1;31mred-text\033[0m\n'} 0 {^red-text$} + +check "corner: a CR-LF line ending leaves no stray carriage return" \ + {printf 'crlf-line\r\n'} 0 {^crlf-line$} + +check "corner: quoting survives the round trip to the shell" \ + {echo "a b c"} 0 {a b c} + +# The command we send CONTAINS the text of its own BEGIN marker, inside the +# printf that emits it, and the guest echoes that line back before running +# anything. If framing searched for the marker rather than comparing whole +# lines, the echo would open the output and the captured result would start +# with the prompt and the command. The exact length is the assertion; a regexp +# would pass either way. +check "corner: the echoed command line is not mistaken for output" \ + {echo only-this-line} 0 {^only-this-line$} 14 + +# The mirror image: once framing HAS opened, a line that happens to equal a +# BEGIN marker is ordinary output and must be kept. Nothing may treat a second +# marker-shaped line as a reason to restart or truncate. +check "corner: a marker-shaped line inside the output is kept, not swallowed" \ + {echo MCD_BEGIN_1; echo after-the-decoy} 0 {after-the-decoy} 27 + +############################################################################### +# several consoles - connect returns an id and `use` switches. The check's +# socat forks one bash per connection, so two connects are two shells. +############################################################################### + +vmlib::must {export MCD_WHO=first} 10 +set con2 [vmlib::connect $port 60] + +check "positive: a second connect returns a distinct id and becomes current" \ + "test [list $con1] != [list $con2] && echo \${MCD_WHO:-unset}" 0 {^unset$} + +vmlib::must {export MCD_WHO=second} 10 +vmlib::use $con1 +check "positive: use switches back to the first console's shell" \ + {echo $MCD_WHO} 0 {^first$} + +vmlib::use $con2 +check "positive: use switches to the second console's shell" \ + {echo $MCD_WHO} 0 {^second$} + +# A timeout on one console leaves its marker in flight; the next call on +# the OTHER console must not be satisfied or disturbed by it. +check "corner: a timeout on one console does not leak into the other" \ + {sleep 30} -1 {} -1 2 1 +vmlib::use $con1 +check "corner: the other console answers normally after it" \ + {echo still-first} 0 {^still-first$} + +############################################################################### +# summary +############################################################################### + +puts "" +puts "=== vm-lib.exp: $pass passed, $fail failed ===" +if {$fail > 0} { + puts "VMLIB_TEST_FAIL" + exit 1 +} +puts "VMLIB_TEST_PASS" +exit 0 diff --git a/nix/microvms/console/vm-lib.exp b/nix/microvms/console/vm-lib.exp new file mode 100644 index 0000000..f385f3c --- /dev/null +++ b/nix/microvms/console/vm-lib.exp @@ -0,0 +1,435 @@ +#!/usr/bin/env expect +############################################################################### +# vm-lib.exp - expect library for driving a mcd microVM serial console +# +# Ported from xtcp2 (nix/microvms/scripts/vm-lib.exp) by way of partybus. +# Changed: the marker prefix (MCD_), these notes, and `use` with connect +# returning its spawn id, for drivers of several VMs. Tested hermetically by +# vm-lib-test.exp (the vm-lib-exp flake check). +# +# Source it from a driver script: +# source [file dirname [info script]]/vm-lib.exp +# +# Environment variables: +# EXPECT_DEBUG non-empty turns on exp_internal (very verbose) +# VMLIB_TIMEOUT default per-command timeout in seconds (default 60) +# +# WHY A CONSOLE AT ALL +# +# mcd's runners (nix/microvms/runners.nix) drive the guest entirely over its +# serial console. It answers as soon as getty autologin runs, needs no guest +# network (the tests build their own bridges and namespaces), and keeps +# answering when the test under it has wedged the network - exactly when a +# network-based channel could only time out. Driving it with expect replaces +# "wait and hope" with "wait until the command exited, and tell me its +# status". +# +# WHY NOT PROMPT MATCHING +# +# The obvious expect idiom - and what the xdp2 and pcp versions of this file +# do - is to match the shell prompt: `root@host:~#`. We deliberately do not. +# +# - The prompt's exact shape is NixOS's `programs.bash.promptInit` default, +# which brackets it (`[root@host:~]#`) and wraps it in ANSI color escapes. +# Matching it means encoding a distribution default into a test harness. +# - A prompt tells you the shell is ready. It does not tell you what the +# command's exit status was, and for a readiness step "did it actually +# succeed" is the whole question. +# - Worst of all, a prompt pattern is not unique in time. If one command +# times out and the driver moves on, the NEXT expect can be satisfied by +# the PREVIOUS command's prompt, and the driver silently runs ahead of the +# guest while reporting success. +# +# So every command is instead sent as: +# +# printf '\nMCD_BEGIN_\n'; ; printf '\nMCD_RC_=%d\n' "$?" +# +# where is a counter unique to this session. Output is whatever arrives +# between those two markers, and the exit status comes out of the second one. +# Readiness is established with that same machinery rather than by prompt: run +# a trivial command until one of them reports rc 0. +# +# WHY THERE IS A BEGIN MARKER AND NOT JUST "SKIP THE ECHOED COMMAND LINE" +# +# The obvious framing is to take everything after the guest's terminal echo of +# the command, on the grounds that the echo is exactly one line. Measured +# against a real guest, it is not: after the RC marker the guest emits +# `ESC[?2004h CR CR LF` - an empty line, from bash turning bracketed paste back +# on - and that line is still unread when the NEXT call starts. So "skip one +# line" skips that, and the prompt-plus-echo line lands in the output. Every +# captured result was shifted by one line, and nothing in a local pty test +# noticed, because the leak is at the FRONT of the output and the assertions +# were regex matches over the whole of it. +# +# Positional framing is the wrong shape regardless of that particular artifact: +# it assumes the stream contains nothing but this call's traffic, and a console +# shared with a booting kernel, a getty and systemd's status escapes does not +# honor that. A marker the guest PRINTS, alone on its line, is unambiguous. +# It also survives the echoed command line being wrapped by readline, which a +# 124-column capture command on an 80-column console would be: a wrapped +# fragment is not equal to the marker, so it is discarded like any other +# leftover rather than being mistaken for output. +############################################################################### + +package require Tcl 8.5 + +namespace eval vmlib { + variable console_id "" + variable default_timeout 60 + variable debug 0 + # Monotonic counter behind the per-command marker. Never reset, so a + # marker value is unique for the life of the driver process. + variable seq 0 + + ########################################################################### + # init - read the environment. Call once, first. + ########################################################################### + proc init {} { + variable debug + variable default_timeout + + if {[info exists ::env(EXPECT_DEBUG)] && $::env(EXPECT_DEBUG) ne ""} { + set debug 1 + exp_internal 1 + } + if {[info exists ::env(VMLIB_TIMEOUT)] && $::env(VMLIB_TIMEOUT) ne ""} { + set default_timeout $::env(VMLIB_TIMEOUT) + } + + # Silence expect's own echo of the guest stream unless debugging. + # Left on, the raw console - prompts, escapes, partial lines - is + # interleaved with the driver's own stdout on the SAME lines, so a + # result line arrives as `ESC[?2004hbash-5.3$ PASS: ...` and anything + # grepping for `^PASS` sees nothing. Callers that want the console + # should read the runner's transcript, which is captured separately. + if {!$debug} { + log_user 0 + } + } + + ########################################################################### + # log - driver-side progress, on stderr so it cannot be confused with + # captured guest output on stdout. + ########################################################################### + proc log {msg} { + send_error "vmlib: $msg\n" + } + + ########################################################################### + # resolve_line - turn one raw console line into what a terminal would have + # shown. Shared by run_out and the readiness probe so both agree on what + # "a line equal to the marker" means. + # + # Resolve CRs the way a terminal would rather than deleting them: drop the + # CR-LF ending, then keep only what follows the last remaining CR, since a + # bare CR returns the cursor to column 0 and what comes after overwrites + # what came before. A real guest line is + # `ESC[?2004l CR hello-world CR LF`, which yields `hello-world`. + # + # `\r+$`, not `\r$`: a guest command that itself ends a line with CR sends + # CR CR LF through the pty, and stripping only one leaves a trailing CR + # that the last-CR rule then reads as "everything was overwritten", + # returning an empty line for output that was plainly there. + # + # Then the escapes. CSI covers the color and cursor sequences; OSC covers + # the window title the guest's PROMPT_COMMAND sets, which is terminated by + # BEL and would otherwise leave the title text in the line. + ########################################################################### + proc resolve_line {line} { + regsub {\r+$} $line "" line + set cr [string last "\r" $line] + if {$cr >= 0} { + set line [string range $line [expr {$cr + 1}] end] + } + regsub -all {\x1b\[[0-9;?]*[a-zA-Z]} $line "" line + regsub -all {\x1b\][^\x07]*\x07} $line "" line + return $line + } + + ########################################################################### + # connect - attach to the guest's serial console and wait for a shell. + # + # port host TCP port qemu's serial console is bound to + # boot_timeout total seconds to wait for a responding shell + # + # A TCP CHANNEL, NOT A SPAWNED PROCESS ON A PTY + # + # The obvious implementation, and what both xdp2's and pcp's versions do, + # is `spawn socat -,rawer tcp:...` (or nc). Measured against a local pty + # shell, that does not work here: `spawn` gives socat a pty whose line + # discipline echoes, so every command appears in the stream TWICE - once + # from the local pty and once from the guest's own terminal echo. run_out + # drops exactly one echo line, consumes the second copy as output, and the + # marker never matches. The symptom is a timeout on a command that plainly + # succeeded, which is a bad way to spend an afternoon. + # + # `spawn -open` over a Tcl socket has no pty and no local line discipline + # at all, so the only echo in the stream is the guest's. It also removes + # socat from the runtime inputs. + # + # The guest autologins root on the serial console + # (substrates/microvm.nix: services.getty.autologinUser = "root"), so there is no login + # or password step here - unlike pcp's vmlib::login, which this omits on + # purpose rather than by oversight. + # + # THE SOCKET OPEN IS RETRIED, not attempted once + # + # qemu's serial chardev is `tcp:...,server,nowait` and accepts ONE + # connection at a time, so the host runner cannot probe the port with + # `nc -z` first: the probe's own connect-and-close would leave a window in + # which qemu has not gone back to listening. Failing fast on the first + # ECONNREFUSED would then be a coin flip on how quickly qemu got to + # opening its listener. Retrying here means the caller hands us a port and + # a deadline and nothing else has to know about the race. + ########################################################################### + proc connect {port {boot_timeout 180}} { + variable console_id + + log "connecting to console on 127.0.0.1:$port" + # ONE deadline for both phases below - opening the socket and getting a + # shell to answer are two halves of the same wait, and boot_timeout is + # the caller's budget for the whole of it, not for each. + set deadline [expr {[clock seconds] + $boot_timeout}] + set sock "" + set last_error "no attempt made" + while {[clock seconds] < $deadline} { + if {![catch {socket 127.0.0.1 $port} result]} { + set sock $result + break + } + set last_error $result + # A second, not a tight spin: this is waiting on qemu creating a + # listening socket, which is not something that gets faster by + # being asked more often. + after 1000 + } + if {$sock eq ""} { + log "ERROR: cannot connect to 127.0.0.1:$port within ${boot_timeout}s: $last_error" + exit 2 + } + fconfigure $sock -translation binary -buffering none -blocking 0 + spawn -open $sock + set console_id $spawn_id + + # expect's default match buffer is 2000 bytes, and run_out matches one + # WHOLE line at a time. A line longer than the buffer can never match, + # so expect discards the oldest half of it and the line is lost - with + # no error. `ip -d addr show` on a busy namespace stays well under + # this, but a base64 blob does not, which is why the guest side wraps + # base64 instead of using `-w0`. Raise the ceiling anyway so a long + # line is a performance question rather than a correctness one. + match_max -i $console_id 1000000 + + # READINESS IS `run_out` REPORTING rc 0, NOT A MARKER COMING BACK + # + # The previous version of this echoed a marker and waited for a line + # equal to it. That is still forgeable, and the guest forged it: a tty + # echoes what is typed at it whether or not anything is reading, and + # before getty starts the echo can arrive as a line of its own, so the + # probe passed at t=0.14s of boot. This proc then returned a shell that + # did not exist - after which every command was typed into a tty buffer, + # replayed all at once when bash finally started, and the whole session + # ran blind, and the driver reported success the entire way (seen in + # xtcp2). + # + # There is no way to make a bare marker unforgeable, because the tty + # echoes it verbatim by construction. So probe with the thing that only + # a shell can satisfy: the marker protocol itself. run_out sends + # + # printf '\nMCD_BEGIN_\n'; :; printf '\nMCD_RC_=%d\n' "$?" + # + # and accepts only a line matching `^MCD_RC_=([0-9]+)$`. The echo + # of that line contains the printf syntax and the literal `%d`, so it + # cannot match - the guest has to have EXECUTED the line. Each probe + # carries its own sequence number, so a late reply to probe N cannot be + # read as the reply to probe N+1 either. + # + # The queued-input replay is then harmless rather than fatal: the + # probes bash replays when it starts are `printf ...; :; printf ...`, + # which run, print their markers, and are matched or discarded by + # sequence number like any other leftover. + while {[clock seconds] < $deadline} { + # A bare CR first, to end any partial line already sitting in + # readline's buffer from boot-time noise. Without it a leftover + # fragment would be prefixed to the probe, bash would reject the + # whole line as one bad command, and the printfs would never run. + send -i $console_id "\r" + # quiet: a failed probe is the expected case while the guest boots, + # and one ERROR line per probe would bury the real failure. + lassign [run_out ":" 5 1] rc _out + if {$rc == 0} { + log "console is up" + return $console_id + } + } + + log "ERROR: no shell on port $port within ${boot_timeout}s" + exit 2 + } + + ########################################################################### + # use - make an earlier connect's console the current one. + # + # connect returns its console's spawn id and makes it current; a driver + # for several VMs keeps the ids and switches with this. The command + # counter is shared, so markers stay unique across consoles, and a + # leftover marker from one console can never close a call on another. + ########################################################################### + proc use {id} { + variable console_id + set console_id $id + return $id + } + + ########################################################################### + # run_out - run one command, return {rc output}. + # + # rc is the guest command's real exit status, or -1 if the marker never + # arrived within the timeout. output is everything the guest printed + # between the two markers, resolved the way a terminal would have shown it + # (see resolve_line). + # + # Output is accumulated line by line rather than in one big `expect` + # buffer. That is xdp2's vm-expect.exp trick and it is load-bearing here: + # `ip -d link show` on a populated namespace, or a base64 blob, will + # exceed expect's default match buffer and the tail would be silently + # discarded. + # + # quiet suppresses the timeout log line only. It exists for connect's + # readiness probe, where a timeout is the expected outcome on every attempt + # but the last; everywhere else a timeout is the thing you want to read + # about, so it defaults to off. + ########################################################################### + proc run_out {cmd {cmd_timeout ""} {quiet 0}} { + variable console_id + variable default_timeout + variable seq + + if {$console_id eq ""} { + error "vmlib::run_out called before vmlib::connect" + } + if {$cmd_timeout eq ""} { + set cmd_timeout $default_timeout + } + + incr seq + set begin "MCD_BEGIN_$seq" + + # printf, not echo, at both ends: the leading newline guarantees each + # marker begins a line of its own, whatever the surrounding output did + # or did not write. + send -i $console_id \ + "printf '\\nMCD_BEGIN_$seq\\n'; $cmd; printf '\\nMCD_RC_$seq=%d\\n' \"\$?\"\r" + + set lines {} + set rc -1 + set saw_marker 0 + set in_output 0 + set timeout $cmd_timeout + + expect { + # Match up to the LINE FEED, not up to a CR-LF pair. + # + # This looked like it should be `^(\[^\r\n\]*)\r?\n`, and that + # version deadlocks. A bash console with bracketed paste on emits + # a BARE CR in the middle of a line - the real stream is + # `ESC[?2004l CR hello-world CR LF` - so `\[^\r\n\]*` stops at the + # inner CR, `\r?\n` then fails on the character after it, and + # because expect anchors `^` at the start of the buffer there is + # no other position to try. The line never matches, the marker is + # never reached, and a command that obviously succeeded reports a + # timeout. + -i $console_id -re "^(\[^\n\]*)\n" { + set line [resolve_line $expect_out(1,string)] + + if {[regexp "^MCD_RC_$seq=(\[0-9\]+)$" $line -> status]} { + set rc $status + set saw_marker 1 + } elseif {!$in_output} { + # Everything before this call's BEGIN marker is leftover: + # the empty line bash emits after the previous marker, the + # prompt, the echo of the command we just sent, and - on + # the first call - whatever the kernel and getty printed. + # Compared with `eq`, not searched for, so the echoed + # command line (which contains the marker's text as part of + # the printf) cannot be mistaken for the marker itself. + if {$line eq $begin} { + set in_output 1 + } + exp_continue -continue_timer + } else { + lappend lines $line + exp_continue -continue_timer + } + } + -i $console_id timeout { + if {!$quiet} { + log "ERROR: timeout after ${cmd_timeout}s: $cmd" + } + } + -i $console_id eof { + log "ERROR: console closed during: $cmd" + exit 2 + } + } + + # The marker's printf starts with a newline so it always begins a line + # of its own, which costs one empty line at the end of every capture. + # Trim trailing empties so a caller can compare captured output + # against a committed sidecar without allowing for harness artifacts. + while {[llength $lines] > 0 && [lindex $lines end] eq ""} { + set lines [lrange $lines 0 end-1] + } + + if {!$saw_marker} { + return [list -1 [join $lines "\n"]] + } + return [list $rc [join $lines "\n"]] + } + + ########################################################################### + # run - run one command, return its exit status, discarding output. + ########################################################################### + proc run {cmd {cmd_timeout ""}} { + lassign [run_out $cmd $cmd_timeout] rc _out + return $rc + } + + ########################################################################### + # must - run one command and abort the driver if it fails. + # + # For steps with no meaningful partial result: if a node has no + # network there is nothing to salvage by continuing. + ########################################################################### + proc must {cmd {cmd_timeout ""}} { + lassign [run_out $cmd $cmd_timeout] rc out + if {$rc != 0} { + log "FATAL: rc=$rc: $cmd" + if {$out ne ""} { + send_error "$out\n" + } + exit 3 + } + return $out + } + + ########################################################################### + # poweroff - ask the guest to shut down. Best effort; the host runner + # kills qemu if this does not take. + ########################################################################### + proc poweroff {} { + variable console_id + if {$console_id eq ""} { + return + } + log "powering off" + send -i $console_id "systemctl poweroff\r" + # Do not wait for a marker: the shell is going away and the marker + # would never arrive. + set timeout 10 + expect { + -i $console_id eof {} + -i $console_id timeout {} + } + } +} diff --git a/nix/microvms/constants.nix b/nix/microvms/constants.nix new file mode 100644 index 0000000..44c7bb3 --- /dev/null +++ b/nix/microvms/constants.nix @@ -0,0 +1,90 @@ +# nix/microvms/constants.nix +# +# Runtime constants for the mcd microVMs. x86_64 + KVM only: the suite is +# about kernel bridge/multicast behaviour, not the CPU, and the cross +# builds are covered by nix/cross.nix. +{ + hostname = "mcd-vm"; + + # Serial console (ttyS0, driven by expect) and virtio console (hvc0, + # journal + kernel log, tailed into the transcript). Clear of xtcp2 + # (12055) and rocm-ernic (23600). + serialPort = 23700; + virtioPort = 23701; + + # Avoid exactly 2048: microvm.nix #171, QEMU hangs at boot. + mem = 1024; + vcpu = 4; + + # Seconds. The suite takes a few minutes on KVM; valgrind slows mcd and + # the suite's own sleeps dominate either way. + bootTimeout = 300; + suiteTimeout = 1800; + + # The multicast topology (vm-topo): a switch VM bridging two host VMs, + # each joined to it by a point-to-point dgram link. + # + # h1 lan0 ──link1── p1 ┐ + # sw: br0 10.0.0.10, mcd + # h2 lan0 ──link2── p2 ┘ + # + # h1 (10.0.0.11) is the listener; h2 (10.0.0.2) runs a rival mcd whose + # lower address wins the querier election. Console ports 23700 + 10·n. + # `links` name each NIC inside the guest, its MAC, and the unix socket + # pair (relative: every qemu starts in the runner's one temp directory). + topo = { + mem = 768; + vcpu = 2; + # Boot of three VMs at once, then the scenario's own waits (election, + # other-querier-present, leave) - about two minutes on an idle host. + bootTimeout = 300; + scenarioTimeout = 600; + nodes = { + sw = { + hostname = "mcd-sw"; + serialPort = 23710; + virtioPort = 23711; + links = [ + { + name = "p1"; + mac = "02:00:00:00:10:01"; + local = "link1-sw.sock"; + remote = "link1-h1.sock"; + } + { + name = "p2"; + mac = "02:00:00:00:10:02"; + local = "link2-sw.sock"; + remote = "link2-h2.sock"; + } + ]; + }; + h1 = { + hostname = "mcd-h1"; + serialPort = 23720; + virtioPort = 23721; + links = [ + { + name = "lan0"; + mac = "02:00:00:00:20:01"; + local = "link1-h1.sock"; + remote = "link1-sw.sock"; + } + ]; + }; + h2 = { + hostname = "mcd-h2"; + serialPort = 23730; + virtioPort = 23731; + links = [ + { + name = "lan0"; + mac = "02:00:00:00:30:01"; + local = "link2-h2.sock"; + remote = "link2-sw.sock"; + } + ]; + }; + }; + }; +} diff --git a/nix/microvms/default.nix b/nix/microvms/default.nix new file mode 100644 index 0000000..f096ab7 --- /dev/null +++ b/nix/microvms/default.nix @@ -0,0 +1,178 @@ +# nix/microvms/default.nix +# +# MicroVM integration tests: mcd's network test suite (test/*.sh, via +# `make check`) run as root on a real kernel in a KVM guest, driven over +# the serial console with expect (nix/microvms/console/). +# +# Outputs (x86_64-linux only; KVM needs no other host privilege): +# packages.microvm the microvm.nix runner (bin/microvm-run) +# packages/apps.vm-check the suite against the plain build +# packages/apps.vm-check-asan the suite against an ASan + UBSan build, +# sanitizer reports counted and printed +# packages/apps.vm-check-valgrind the suite with mcd under valgrind +# packages/apps.vm-topo three VMs (switch + two hosts) on dgram +# links: election, snooping, leave, takeover +# checks.vm-lib-exp the expect library + drivers, hermetic +# +# One VM serves all three make-check runners, and each driver picks its +# build tree by store path: the guest sees the host's whole store over 9p, +# so neither the trees nor the topology's mcd are in a guest closure, and +# a source edit rebuilds no guest. vm-topo has its own three. + +{ + ctx, + nixpkgs, + microvm, +}: + +let + inherit (ctx) pkgs lib; + constants = import ./constants.nix; + + inherit (ctx) mcd; + + # Configured --enable-test build trees, installed under share/mcd/build. + testTree = mcd.override { + pnameSuffix = "-test-tree"; + enableTest = true; + installBuildTree = true; + }; + asanTree = mcd.override { + pnameSuffix = "-asan-test-tree"; + enableTest = true; + installBuildTree = true; + sanitizers = [ + "address" + "undefined" + ]; + }; + treePath = t: "${t}/share/mcd/build"; + + console = import ./console { + inherit pkgs lib; + drivers = { + "make-check.exp" = ./scripts/make-check.exp; + "multicast-topo.exp" = ./scripts/multicast-topo.exp; + }; + }; + + mkVm = + args: + import ./mkVm.nix ( + { + inherit + pkgs + lib + nixpkgs + microvm + ; + inherit (ctx.packages) testTools; + } + // args + ); + + vm = mkVm { + node = { + inherit (constants) + hostname + serialPort + virtioPort + mem + vcpu + ; + }; + }; + + # The multicast topology: three VMs, each running mcd from the store. + topo = constants.topo; + topoVms = lib.mapAttrs ( + _: n: + mkVm { + node = n // { + inherit (topo) mem vcpu; + }; + inherit (n) links; + uplink = false; + } + ) topo.nodes; + + inherit + (import ./runners.nix { + inherit pkgs lib constants; + inherit (console) scripts; + }) + mkRunner + mkCheckRunner + ; + + mkSuite = + name: tree: mode: description: + mkCheckRunner { + inherit name vm description; + driver = "make-check.exp"; + driverArgs = [ + (toString constants.bootTimeout) + (toString constants.suiteTimeout) + (treePath tree) + mode + ]; + }; + + # sw first: h1 and h2 send to its sockets, and a datagram to a socket + # that is not bound yet is dropped, which the scenario's own waits cover. + vm-topo = mkRunner { + name = "vm-topo"; + description = "multicast topology: querier election, snooping, leave, takeover"; + vms = + map + (n: { + name = n; + vm = topoVms.${n}; + inherit (topo.nodes.${n}) serialPort virtioPort; + }) + [ + "sw" + "h1" + "h2" + ]; + driver = "multicast-topo.exp"; + driverArgs = map toString [ + topo.nodes.sw.serialPort + topo.nodes.h1.serialPort + topo.nodes.h2.serialPort + "${mcd}/bin" + topo.bootTimeout + ]; + summary = "MCD_TOPO"; + timeout = topo.bootTimeout + topo.scenarioTimeout; + }; + + # The host-side runners, each a package and an app of the same name. + runners = { + vm-check = mkSuite "vm-check" testTree "plain" "make check in the microVM"; + vm-check-asan = + mkSuite "vm-check-asan" asanTree "asan" + "make check in the microVM against an ASan + UBSan build"; + vm-check-valgrind = + mkSuite "vm-check-valgrind" testTree "valgrind" + "make check in the microVM with mcd under valgrind"; + inherit vm-topo; + }; + + app = drv: { + type = "app"; + program = lib.getExe drv; + }; +in +{ + packages = runners // { + microvm = vm; + microvm-topo-sw = topoVms.sw; + microvm-topo-h1 = topoVms.h1; + microvm-topo-h2 = topoVms.h2; + mcd-test-tree = testTree; + mcd-asan-test-tree = asanTree; + }; + apps = lib.mapAttrs (_: app) runners; + checks.vm-lib-exp = console.check; +} diff --git a/nix/microvms/mkVm.nix b/nix/microvms/mkVm.nix new file mode 100644 index 0000000..3fdd4fe --- /dev/null +++ b/nix/microvms/mkVm.nix @@ -0,0 +1,198 @@ +# nix/microvms/mkVm.nix +# +# The NixOS guest that runs mcd's network test suite (test/*.sh) as root on +# a real kernel. Shape follows xtcp2's microVMs: +# +# - qemu_kvm, -cpu host; SLiRP `user` NIC (no host root, no taps) +# - the host /nix/store shared over 9p, so the build tree and every tool +# is the host's store path; nothing is copied into an image +# - ttyS0 on a TCP chardev for expect, hvc0 (virtconsole) for the +# journal and kernel log; root autologin on both +# - bridge / veth / 8021q / dummy modules, plus everything test/lib.sh +# calls (iproute2, tshark, socat, ethtool, jq, util-linux) +# +# The same guest serves the multicast topology's nodes (vm-topo): those +# drop the SLiRP NIC and get point-to-point `-netdev dgram` links instead, +# one virtio-net NIC per link, renamed by MAC to the name in constants.nix +# (p1, lan0, ...). The links are unix datagram socket pairs, as in xtcp2's +# lacp pair: each end binds its own socket and sends to its peer's, so a +# link is a wire with exactly two ends - no tap, no host bridge, no root. +# +# Returns the microvm.nix runner (bin/microvm-run). + +{ + pkgs, + lib, + nixpkgs, + microvm, + # hostname, serialPort, virtioPort, mem, vcpu + node, + # Point-to-point links: [ { name mac local remote } ], see constants.nix. + links ? [ ], + # The SLiRP NIC; the topology nodes need no outside network. + uplink ? true, + # packages.nix testTools: what test/*.sh needs. + testTools, +}: + +let + # The SLiRP NIC is enp0s3 or similar; networkd matches it by MAC. + uplinkMac = "02:00:00:00:37:01"; + + # Pinned PCI slots so the NICs enumerate in a fixed order. + linkArgs = lib.concatLists ( + lib.imap0 (i: l: [ + "-netdev" + "dgram,id=${l.name},local.type=unix,local.path=${l.local},remote.type=unix,remote.path=${l.remote}" + "-device" + "virtio-net-pci,netdev=${l.name},mac=${l.mac},addr=0x${lib.toHexString (16 + i)},romfile=" + ]) links + ); + + # make-check.exp's valgrind mode moves the tree's src/mcd to src/mcd.real + # and symlinks this in its place: every mcd the tests start then runs + # under valgrind, one log per process in /tmp/vg. Errors do not change + # mcd's exit status; the driver counts them from the logs. + mcdValgrind = pkgs.writeShellApplication { + name = "mcd-valgrind"; + runtimeInputs = [ pkgs.valgrind ]; + text = '' + exec valgrind -q --log-file=/tmp/vg/mcd.%p --leak-check=full \ + --error-exitcode=0 "$0.real" "$@" + ''; + }; +in +(nixpkgs.lib.nixosSystem { + inherit pkgs; + modules = [ + microvm.nixosModules.microvm + ( + { config, ... }: + { + system.stateVersion = "26.05"; + networking.hostName = node.hostname; + + # Trim the guest. + documentation.enable = false; + security.polkit.enable = false; + services.udisks2.enable = false; + programs.command-not-found.enable = false; + fonts.fontconfig.enable = false; + nix.enable = false; + xdg.mime.enable = false; + hardware.enableRedistributableFirmware = false; + boot.supportedFilesystems = lib.mkForce [ "ext4" ]; + + # The tests bring up their own interfaces; nothing else should + # touch them (or send IGMP/MLD on them). + networking.firewall.enable = false; + networking.useDHCP = false; + systemd.network.enable = true; + # networkd takes the first .network file that matches, in lexical + # order: the uplink, then everything else unmanaged. + systemd.network.networks."05-uplink" = lib.mkIf uplink { + matchConfig.MACAddress = uplinkMac; + networkConfig.DHCP = "yes"; + }; + systemd.network.networks."99-unmanaged" = { + matchConfig.Name = "*"; + linkConfig.Unmanaged = true; + }; + systemd.network.links = lib.listToAttrs ( + map (l: { + name = "10-${l.name}"; + value = { + matchConfig.MACAddress = l.mac; + linkConfig.Name = l.name; + }; + }) links + ); + + microvm = { + hypervisor = "qemu"; + inherit (node) mem vcpu; + cpu = null; # -enable-kvm -cpu host + interfaces = lib.optional uplink { + type = "user"; + id = "eth0"; + mac = uplinkMac; + }; + shares = [ + { + source = "/nix/store"; + mountPoint = "/nix/.ro-store"; + tag = "ro-store"; + proto = "9p"; + } + ]; + qemu = { + serialConsole = false; + package = pkgs.qemu_kvm; + extraArgs = [ + "-name" + "${node.hostname},process=${node.hostname}" + "-serial" + "tcp:127.0.0.1:${toString node.serialPort},server,nowait" + "-device" + "virtio-serial-pci" + "-chardev" + "socket,id=virtcon,port=${toString node.virtioPort},host=127.0.0.1,server=on,wait=off" + "-device" + "virtconsole,chardev=virtcon" + ] + ++ linkArgs + ++ [ + "-append" + (lib.concatStringsSep " " ( + [ + "console=ttyS0,115200" + "console=hvc0" + "reboot=t" + "panic=-1" + "loglevel=4" + "init=${config.system.build.toplevel}/init" + ] + ++ config.boot.kernelParams + )) + ]; + }; + }; + + boot.kernelPackages = pkgs.linuxPackages_latest; + boot.kernelParams = [ + "console=ttyS0,115200" + "console=hvc0" + "systemd.show_status=false" + ]; + boot.initrd.availableKernelModules = [ + "9p" + "9pnet" + "9pnet_virtio" + "virtio_pci" + "virtio_console" + ]; + boot.kernelModules = [ + "bridge" + "veth" + "8021q" + "dummy" + ]; + + services.getty.autologinUser = "root"; + systemd.enableEmergencyMode = false; + + # What test/*.sh calls (packages.nix testTools), plus valgrind + # for make-check.exp's valgrind mode and the coreutils-adjacent + # tools the drivers use. + environment.systemPackages = testTools ++ [ + pkgs.valgrind + mcdValgrind + pkgs.procps + pkgs.gnugrep + pkgs.gnused + pkgs.gawk + ]; + } + ) + ]; +}).config.microvm.declaredRunner diff --git a/nix/microvms/runner.sh b/nix/microvms/runner.sh new file mode 100644 index 0000000..328d194 --- /dev/null +++ b/nix/microvms/runner.sh @@ -0,0 +1,143 @@ +# Host-side runner for the mcd microVMs (see runners.nix): boots the VMs, +# tails their virtio consoles, runs the expect driver under a timeout, +# collects diagnostics if it died early, and prints the verdict. +# +# A writeShellApplication (errexit, nounset, pipefail, linted at +# build), with `config` set ahead of this text to a JSON file: +# name, description, summary, timeout +# vms [ { name, run, serialPort, virtioPort } ], run = microvm-run +# driver the expect driver, and driverArgs its arguments +# nodeDiag node-diag.exp, and diagCmds the commands it runs per VM +# +# Exit codes: 0 pass, 1 test failure, 2 no console, 3 setup (no KVM, port +# busy, a driver setup step failed), 124 overall timeout. + +cfg() { jq -r "$1" "$config"; } + +name=$(cfg .name) +description=$(cfg .description) +summary=$(cfg .summary) +driver=$(cfg .driver) +nodeDiag=$(cfg .nodeDiag) +mapfile -t driverArgs < <(cfg '.driverArgs[]') +mapfile -t diagCmds < <(cfg '.diagCmds[]') +mapfile -t vmNames < <(cfg '.vms[].name') +mapfile -t vmRuns < <(cfg '.vms[].run') +mapfile -t vmSerial < <(cfg '.vms[].serialPort') +mapfile -t vmVirtio < <(cfg '.vms[].virtioPort') + +TIMEOUT=$(cfg .timeout) +KEEP="" + +while [ $# -gt 0 ]; do + case "$1" in + --keep) KEEP=1; shift ;; + --timeout) TIMEOUT="$2"; shift 2 ;; + -h|--help) + echo "usage: $name [--keep] [--timeout ]" + echo " $description" + echo " --keep leaves the VM(s) running at the end; attach with" + for i in "${!vmNames[@]}"; do + echo " socat -,raw,echo=0 tcp:127.0.0.1:${vmSerial[$i]} # ${vmNames[$i]}" + done + exit 0 + ;; + *) echo "$name: unknown argument: $1" >&2; exit 3 ;; + esac +done + +if [ ! -r /dev/kvm ] || [ ! -w /dev/kvm ]; then + echo "$name: /dev/kvm is not accessible (KVM required)" >&2 + exit 3 +fi +for p in "${vmSerial[@]}" "${vmVirtio[@]}"; do + if nc -z 127.0.0.1 "$p" 2>/dev/null; then + echo "$name: port $p is in use (another mcd VM running?)" >&2 + exit 3 + fi +done + +LOGDIR=$(mktemp -d -t mcd-vm-XXXXXX) +echo "================================================" +echo " $name: $description" +echo " logs: $LOGDIR (qemu-.log console-.log driver.log)" +echo "================================================" + +vm_pids=() +tail_pids=() +# shellcheck disable=SC2329 # invoked by the EXIT trap +cleanup() { + for p in "${tail_pids[@]}"; do kill "$p" 2>/dev/null || true; done + for p in "${vm_pids[@]}"; do + if kill -0 "$p" 2>/dev/null; then + kill "$p" 2>/dev/null || true + wait "$p" 2>/dev/null || true + fi + done +} +trap cleanup EXIT + +# Every qemu runs in $LOGDIR: link sockets and QMP sockets land there. +for i in "${!vmNames[@]}"; do + (cd "$LOGDIR" && exec "${vmRuns[$i]}") > "$LOGDIR/qemu-${vmNames[$i]}.log" 2>&1 & + vm_pids+=("$!") +done + +# attach_virtcon +attach_virtcon() { + for _ in $(seq 1 60); do + if nc -z 127.0.0.1 "$2" 2>/dev/null; then + nc 127.0.0.1 "$2" > "$LOGDIR/console-$1.log" 2>&1 & + tail_pids+=("$!") + return 0 + fi + if ! kill -0 "$3" 2>/dev/null; then + echo "$name: QEMU for $1 exited during startup:" >&2 + tail -20 "$LOGDIR/qemu-$1.log" >&2 + exit 3 + fi + sleep 1 + done +} +for i in "${!vmNames[@]}"; do + attach_virtcon "${vmNames[$i]}" "${vmVirtio[$i]}" "${vm_pids[$i]}" +done + +rc=0 +MCD_VM_KEEP="$KEEP" timeout "$TIMEOUT" \ + expect "$driver" "${driverArgs[@]}" 2>&1 \ + | tee "$LOGDIR/driver.log" || rc=$? + +# A driver that reached its summary line has already dumped what it +# needed and (without --keep) powered its guests off. +if [ "$rc" -ne 0 ] && [ "$rc" -ne 2 ] && ! grep -q "^$summary " "$LOGDIR/driver.log"; then + echo "=== diagnostics ===" + for i in "${!vmNames[@]}"; do + echo "--- ${vmNames[$i]}" + expect "$nodeDiag" "${vmSerial[$i]}" 30 "${diagCmds[@]}" \ + 2>&1 | tee -a "$LOGDIR/diag.log" || true + done +fi + +case "$rc" in + 0) verdict=PASS ;; + 1) verdict=FAIL ;; + 2) verdict="NO CONSOLE" ;; + 124) verdict="TIMEOUT after ${TIMEOUT}s" ;; + *) verdict="ERROR (exit $rc)" ;; +esac +echo "================================================" +echo " $name: $verdict" +grep -h "^$summary " "$LOGDIR/driver.log" || true +echo " logs: $LOGDIR" +echo "================================================" + +if [ -n "$KEEP" ]; then + echo "VM(s) left running (--keep). Ctrl-C here stops them." + for i in "${!vmNames[@]}"; do + echo " ${vmNames[$i]}: socat -,raw,echo=0 tcp:127.0.0.1:${vmSerial[$i]}" + done + wait "${vm_pids[@]}" || true +fi + +exit "$rc" diff --git a/nix/microvms/runners.nix b/nix/microvms/runners.nix new file mode 100644 index 0000000..f21beba --- /dev/null +++ b/nix/microvms/runners.nix @@ -0,0 +1,110 @@ +# nix/microvms/runners.nix +# +# Host-side runners for the mcd microVMs. mkRunner wraps one expect driver +# and any number of VMs in a writeShellApplication: runner.sh, given the +# runner's JSON config (VMs, driver, arguments, timeout). It: +# +# 1. checks /dev/kvm and that every console port is free +# 2. boots every VM in the background (microvm-run), all from ONE fresh +# directory: the topology's dgram links are relative unix socket +# paths, so the qemus find each other there, and a fresh directory +# means no stale socket from a killed run can fail a bind +# 3. tails each virtio console (journal, kernel log) into the logs; the +# serial consoles take one client each and the driver owns them +# 4. runs the driver under an overall timeout +# 5. if the driver died before printing its summary line, runs +# node-diag.exp on each VM for dmesg / links / the journal +# 6. tears every VM down in an EXIT trap (unless --keep) +# +# mkCheckRunner is the one-VM case, for make-check.exp. +# +# Exit codes: 0 pass, 1 test failure, 2 no console, 3 setup (no KVM, port +# busy, a driver setup step failed), 124 overall timeout. + +{ + pkgs, + lib, + constants, + scripts, +}: + +let + mkRunner = + { + name, + description, + # [ { name vm serialPort virtioPort } ] + vms, + # Driver script in `scripts` and its arguments (ports included). + driver, + driverArgs, + # The prefix of the driver's summary line, e.g. "MCD_CHECK". + summary, + timeout, + }: + let + config = pkgs.writeText "${name}-config.json" ( + builtins.toJSON { + inherit + name + description + summary + timeout + driverArgs + ; + vms = map (v: { + inherit (v) name serialPort virtioPort; + run = "${v.vm}/bin/microvm-run"; + }) vms; + driver = "${scripts}/${driver}"; + nodeDiag = "${scripts}/node-diag.exp"; + diagCmds = [ + "uname -a" + "dmesg | tail -n 40" + "ip -d -br link" + "bridge -d link" + "journalctl -b --no-pager -p warning | tail -n 40" + ]; + } + ); + in + pkgs.writeShellApplication { + inherit name; + runtimeInputs = with pkgs; [ + coreutils + expect + netcat-gnu + gnugrep + jq + ]; + text = '' + config=${config} + '' + + builtins.readFile ./runner.sh; + }; +in +{ + inherit mkRunner; + + mkCheckRunner = + { + name, + vm, + description, + driver, + driverArgs, + }: + mkRunner { + inherit name description driver; + vms = [ + { + inherit (constants) serialPort virtioPort; + name = constants.hostname; + inherit vm; + } + ]; + driverArgs = [ (toString constants.serialPort) ] ++ driverArgs; + summary = "MCD_CHECK"; + timeout = constants.bootTimeout + constants.suiteTimeout + 120; + }; +} diff --git a/nix/microvms/scripts/make-check.exp b/nix/microvms/scripts/make-check.exp new file mode 100644 index 0000000..f9bc87f --- /dev/null +++ b/nix/microvms/scripts/make-check.exp @@ -0,0 +1,161 @@ +#!/usr/bin/env expect +############################################################################### +# make-check.exp - run mcd's network test suite in the microVM +# +# Usage: make-check.exp +# +# the configured --enable-test build tree in the store +# (package.nix installBuildTree), copied to /tmp/mcd in the guest +# plain make check as is +# asan the tree is an ASan/UBSan build; reports land on +# mcd's stderr, i.e. in test/*.log, and are counted +# and printed from there +# valgrind src/mcd is replaced by a valgrind wrapper; reports +# land in /tmp/vg, errors and definite leaks counted +# +# The suite runs in the background in the guest and is polled, so each +# test's PASS/FAIL line is printed as it finishes instead of after the +# whole run. TESTS_ENVIRONMENT drops the user namespace from the suite's +# `unshare -mrun --map-auto`: the guest shell is root already, and every +# test still gets fresh mount, UTS and network namespaces. +# +# With MCD_VM_KEEP set the guest is left running at the end. +# +# stdout: the suite's progress, then (on failure) test-suite.log and each +# failing test's log, then the analysis reports, then one summary line +# MCD_CHECK tests= exercise= findings= +# +# Exit codes (vmlib's): 0 suite passed with no findings, 1 suite failed, +# did not finish, or a sanitizer / valgrind finding, 2 no console, 3 a +# setup step failed. +############################################################################### + +source [file dirname [info script]]/vm-lib.exp + +if {$argc != 5} { + puts stderr "usage: make-check.exp " + exit 2 +} +lassign $argv port boot suite tree mode +if {$mode ni {plain asan valgrind}} { + puts stderr "make-check.exp: unknown mode '$mode'" + exit 2 +} + +vmlib::init +vmlib::connect $port $boot +vmlib::must "dmesg -n 1; stty cols 250; export SYSTEMD_PAGER= SYSTEMD_COLORS=0 COLUMNS=250" 10 +vmlib::log "console up; kernel [lindex [vmlib::run_out {uname -r} 10 1] 1]" + +# A writable copy of the build tree; cp -a keeps the store's timestamps, so +# make sees everything as up to date and builds nothing. +vmlib::must "rm -rf /tmp/mcd /tmp/vg /tmp/check.log /tmp/check.rc && cp -a $tree /tmp/mcd && chmod -R u+w /tmp/mcd && mkdir -p /tmp/vg" 120 + +# Warm the page cache: every binary the tests run comes over 9p, and a +# cold first tshark (all its dissector libraries) can miss lib.sh's 10s +# "Capturing on" wait, more so beside an ASan or valgrind mcd. +vmlib::must "tshark -G protocols > /dev/null 2>&1; bridge -V > /dev/null; jq --version > /dev/null; socat -V > /dev/null" 180 + +set runenv "" +switch $mode { + asan { + # No log_path: gcc's separate libasan / libubsan runtimes do not + # reliably honour it, and the test logs capture stderr anyway. + set runenv "ASAN_OPTIONS=detect_leaks=1:abort_on_error=0 UBSAN_OPTIONS=print_stacktrace=1" + } + valgrind { + # mcd-valgrind (mkVm.nix) runs "$0.real" under valgrind. + vmlib::must {mv /tmp/mcd/src/mcd /tmp/mcd/src/mcd.real && ln -s "$(command -v mcd-valgrind)" /tmp/mcd/src/mcd} 10 + } +} + +# Backgrounded inside an outer subshell: run_out appends `; printf `, +# and a bare trailing `&` would make that `&;`, a syntax error. The outer +# subshell also keeps the interactive shell's job control quiet. +vmlib::must "( (cd /tmp/mcd && $runenv make check TESTS_ENVIRONMENT='unshare -mun' > /tmp/check.log 2>&1; echo \$? > /tmp/check.rc) > /dev/null 2>&1 & )" 10 +vmlib::log "make check started ($mode); polling" + +# Poll: print result lines as they appear, until check.rc exists. +set seen 0 +set deadline [expr {[clock seconds] + $suite}] +set finished 0 +while {[clock seconds] < $deadline} { + lassign [vmlib::run_out "grep -E '^(PASS|FAIL|SKIP|XFAIL|XPASS|ERROR):' /tmp/check.log 2>/dev/null | tail -n +[expr {$seen + 1}]" 20 1] rc out + if {$out ne ""} { + foreach line [split $out "\n"] { + puts $line + incr seen + } + } + if {[vmlib::run "test -f /tmp/check.rc" 10] == 0} { + set finished 1 + break + } + after 3000 +} + +# Pick up any lines written between the last poll and the end. +lassign [vmlib::run_out "grep -E '^(PASS|FAIL|SKIP|XFAIL|XPASS|ERROR):' /tmp/check.log 2>/dev/null | tail -n +[expr {$seen + 1}]" 20 1] rc out +if {$out ne ""} { + puts $out +} + +set tests fail +if {$finished} { + lassign [vmlib::run_out "cat /tmp/check.rc" 10 1] rc status + if {[string trim $status] eq "0"} { + set tests pass + } +} else { + vmlib::log "FAIL: make check did not finish within ${suite}s" +} + +if {$tests ne "pass"} { + puts "=== /tmp/check.log (tail) ===" + puts [lindex [vmlib::run_out "tail -n 40 /tmp/check.log" 20 1] 1] + puts "=== failing test logs ===" + puts [lindex [vmlib::run_out {cd /tmp/mcd/test && for t in $(grep -l '^:test-result: \(FAIL\|ERROR\)' *.trs 2>/dev/null); do l=${t%.trs}.log; echo "--- $l"; tail -n 60 "$l"; done} 60 1] 1] + # Diagnostics here, while the guest is still up: the runner's + # node-diag.exp only runs when this driver died before the summary. + puts "=== diagnostics ===" + foreach cmd {"dmesg | tail -n 40" "ip -d -br link" "journalctl -b --no-pager -p warning | tail -n 40"} { + puts "--- \$ $cmd" + puts [lindex [vmlib::run_out $cmd 30 1] 1] + } +} + +set findings 0 +switch $mode { + asan { + # One finding per report header; test-suite.log repeats the + # failing tests' logs, so it is left out. /dev/null keeps grep off + # stdin (the console) when there are no logs at all. + set re {runtime error:|ERROR: [A-Za-z]+Sanitizer} + lassign [vmlib::run_out "cd /tmp/mcd/test 2>/dev/null; grep -h -E '$re' /dev/null \$(ls *.log 2>/dev/null | grep -v '^test-suite.log\$') | wc -l" 30 1] rc n + set findings [string trim $n] + if {![string is integer -strict $findings]} { set findings 0 } + if {$findings > 0} { + puts "=== sanitizer reports (test/*.log) ===" + puts [lindex [vmlib::run_out "cd /tmp/mcd/test && for l in \$(grep -l -E '$re' *.log | grep -v '^test-suite.log\$'); do echo \"--- \$l\"; grep -A30 -E '$re' \"\$l\" | head -n 200; done" 60 1] 1] + } + } + valgrind { + lassign [vmlib::run_out {grep -l -E 'ERROR SUMMARY: [1-9]|definitely lost: [1-9]' /tmp/vg/* 2>/dev/null | wc -l} 10 1] rc n + set findings [string trim $n] + if {$findings > 0} { + puts "=== valgrind reports with errors (/tmp/vg) ===" + puts [lindex [vmlib::run_out {for f in $(grep -l -E 'ERROR SUMMARY: [1-9]|definitely lost: [1-9]' /tmp/vg/*); do echo "--- $f"; cat "$f"; done} 60 1] 1] + } + lassign [vmlib::run_out {ls /tmp/vg | wc -l} 10 1] rc runs + vmlib::log "valgrind: [string trim $runs] mcd run(s), $findings with errors" + } +} + +set exercise [expr {$finished ? "clean" : "incomplete"}] +puts "MCD_CHECK tests=$tests exercise=$exercise findings=$findings" + +if {![info exists ::env(MCD_VM_KEEP)] || $::env(MCD_VM_KEEP) eq ""} { + vmlib::poweroff +} + +exit [expr {$tests eq "pass" && $findings == 0 ? 0 : 1}] diff --git a/nix/microvms/scripts/multicast-topo.exp b/nix/microvms/scripts/multicast-topo.exp new file mode 100644 index 0000000..205c62b --- /dev/null +++ b/nix/microvms/scripts/multicast-topo.exp @@ -0,0 +1,300 @@ +#!/usr/bin/env expect +############################################################################### +# multicast-topo.exp - mcd as querier on a real bridge, between two hosts +# +# Usage: multicast-topo.exp +# +# Three VMs on point-to-point dgram links (nix/microvms/constants.nix): +# +# h1 lan0 10.0.0.11 ──link1── p1 ┐ +# sw: br0 10.0.0.10, snooping, mcd +# h2 lan0 10.0.0.2 ──link2── p2 ┘ +# +# h1 captures every IGMP / ICMPv6 packet it sees, as text, for the whole +# run; the timing assertions are read off that capture with h1's clock. +# Steps, in order (each needs the ones before it): +# +# links h1 and h2 reach each other through sw's bridge +# querier mcd on sw is elected and h1 sees IGMPv3 + MLDv2 queries +# interval general queries every global-query-interval (10 s) +# join a socat join on h1 lands in sw's mdb and mcd's groups, on +# p1 only +# leave the leave draws a group-specific query; mcd and the bridge +# drop the group +# election mcd on h2, with the lower address, wins; sw stops querying +# takeover h2's mcd stops; sw takes over after router-timeout (15 s) +# +# A step that fails ends the run: diagnostics from every node, poweroff. +# A KNOWN step is a recorded mcd bug: it reports XFAIL while the bug is +# there and XPASS once it is fixed, and neither fails the run. +# +# stdout: one line per step, PASS: / FAIL: / XFAIL: / XPASS: , then +# MCD_TOPO steps= pass= fail= xfail= xpass= +# +# With MCD_VM_KEEP set the guests are left running at the end. +# +# Exit codes (vmlib's): 0 no step failed, 1 a step failed, 2 no console, +# 3 a setup command failed. +############################################################################### + +source [file dirname [info script]]/vm-lib.exp + +if {$argc != 5} { + puts stderr "usage: multicast-topo.exp " + exit 2 +} +# mcd-bin: the store path of mcd's bin/ (the guest sees the host store), put +# first on each node's PATH, so a new mcd needs no new guest. +lassign $argv port(sw) port(h1) port(h2) mcdbin boot + +vmlib::init + +set nodes {sw h1 h2} +array set count {steps 0 pass 0 fail 0 xfail 0 xpass 0} + +############################################################################### +# helpers +############################################################################### + +proc on {n} { + vmlib::use $::con($n) +} + +# sh - a setup command on node n that must succeed. +proc sh {n cmd {t 30}} { + on $n + return [vmlib::must $cmd $t] +} + +# probe - one attempt, {rc output}, timeouts not logged. +proc probe {n cmd {t 20}} { + on $n + return [vmlib::run_out $cmd $t 1] +} + +# poll - repeat a probe until it passes or the budget runs out. +proc poll {n cmd budget} { + set deadline [expr {[clock seconds] + $budget}] + while 1 { + set left [expr {$deadline - [clock seconds]}] + if {$left <= 0} { + return 0 + } + lassign [probe $n $cmd [expr {min($left, 20)}]] rc out + if {$rc == 0} { + return 1 + } + after 1000 + } +} + +proc now_h1 {} { + lassign [probe h1 {date +%s.%N} 10] rc out + return [string trim $out] +} + +# queries - epochs (h1's clock) of the queries h1 captured from src for +# group (0.0.0.0 = general) after t, in capture order. tshark prints +# igmp.type as 0x11 or 17 depending on its version; both are accepted. +proc queries {src group after {version ""}} { + set v [expr {$version eq "" ? "" : " && \$5==\"$version\""}] + lassign [probe h1 "awk -F, -v s=$src -v g=$group -v t=$after '\$2==s && (\$4==\"0x11\" || \$4==\"17\") && \$6==g && \$1>t$v {print \$1}' /tmp/cap.txt" 20] rc out + set r {} + foreach l [split $out "\n"] { + set l [string trim $l] + if {$l ne ""} { + lappend r $l + } + } + return $r +} + +proc wait_query {src group after budget {version ""}} { + set deadline [expr {[clock seconds] + $budget}] + while {[clock seconds] < $deadline} { + set q [queries $src $group $after $version] + if {[llength $q] > 0} { + return [lindex $q 0] + } + after 1000 + } + return "" +} + +proc diagnostics {} { + puts "=== diagnostics ===" + set cmds(sw) { + "tail -n 40 /tmp/mcd.log" "mctl -p show igmp" "bridge -d mdb show" + "ip -d -br link" "dmesg | tail -n 20" + } + set cmds(h1) {"tail -n 40 /tmp/cap.txt" "cat /tmp/cap.log" "ip -br addr"} + set cmds(h2) {"tail -n 40 /tmp/mcd.log" "ip -br addr"} + foreach n $::nodes { + foreach cmd $cmds($n) { + puts "--- $n \$ $cmd" + puts [lindex [probe $n "$cmd 2>&1" 30] 1] + } + } +} + +proc finish {} { + global count + puts "MCD_TOPO steps=$count(steps) pass=$count(pass) fail=$count(fail) xfail=$count(xfail) xpass=$count(xpass)" + if {![info exists ::env(MCD_VM_KEEP)] || $::env(MCD_VM_KEEP) eq ""} { + foreach n $::nodes { + on $n + vmlib::poweroff + } + } + exit [expr {$count(fail) > 0 ? 1 : 0}] +} + +# result - record a step; a FAIL ends the run. +proc result {status name {note ""}} { + global count + incr count(steps) + incr count([string tolower $status]) + puts "$status: $name[expr {$note eq "" ? "" : " ($note)"}]" + if {$status eq "FAIL"} { + diagnostics + finish + } +} + +proc step {name n cmd budget} { + result [expr {[poll $n $cmd $budget] ? "PASS" : "FAIL"}] $name +} + +proc known {name n cmd budget why} { + if {[poll $n $cmd $budget]} { + result XPASS $name "fixed? was: $why" + } else { + result XFAIL $name "known: $why" + } +} + +proc check {name ok {note ""}} { + result [expr {$ok ? "PASS" : "FAIL"}] $name $note +} + +############################################################################### +# consoles and links +############################################################################### + +foreach n $nodes { + set con($n) [vmlib::connect $port($n) $boot] + vmlib::must "dmesg -n 1; stty cols 250; export SYSTEMD_PAGER= SYSTEMD_COLORS=0 COLUMNS=250 PATH=$mcdbin:\$PATH" 10 + vmlib::must "command -v mcd mctl" 10 +} +vmlib::log "consoles up: $nodes" + +sh sw {ip link show p1 >/dev/null && ip link show p2 >/dev/null} +sh sw {ip link add br0 type bridge mcast_snooping 1 mcast_querier 0 && for p in p1 p2; do ip link set $p master br0 && ip link set $p up; done && ip addr add 10.0.0.10/24 dev br0 && ip link set br0 up} +sh h1 {ip addr add 10.0.0.11/24 dev lan0 && ip link set lan0 up} +sh h2 {ip addr add 10.0.0.2/24 dev lan0 && ip link set lan0 up} + +step "links: h1 reaches sw over link1" h1 {ping -c1 -W1 10.0.0.10} 60 +step "links: h2 reaches h1 through the bridge" h2 {ping -c1 -W1 10.0.0.11} 60 + +# The capture runs for the rest of the scenario. Started before mcd, so +# the startup queries are in it. MLD rides behind a hop-by-hop (router +# alert) header, which BPF's `icmp6` does not chase: `ip6 proto 0` it is. +sh h1 {(tshark -lni lan0 -f 'igmp or icmp6 or ip6 proto 0' -T fields -E separator=, -E occurrence=f -e frame.time_epoch -e ip.src -e ipv6.src -e igmp.type -e igmp.version -e igmp.maddr -e icmpv6.type > /tmp/cap.txt 2>/tmp/cap.log &)} +if {![poll h1 {grep -q 'Capturing on' /tmp/cap.log} 60]} { + vmlib::log "FATAL: tshark did not start on h1" + exit 3 +} +set t0 [now_h1] + +############################################################################### +# querier +############################################################################### + +sh sw {printf 'global-query-interval 10\nrouter-timeout 15\niface br0 enable\n' > /etc/mcd.conf && (mcd -n -l debug > /tmp/mcd.log 2>&1 &)} + +step "querier: mcd on sw is elected IGMP querier" sw \ + {mctl show igmp json | jq -e '.["multicast-queriers"][] | select(.interface=="br0") | .querier=="10.0.0.10"'} 30 +check "querier: h1 sees IGMPv3 general queries from 10.0.0.10" \ + [expr {[wait_query 10.0.0.10 0.0.0.0 $t0 30 3] ne ""}] +step "querier: h1 sees MLDv2 queries from sw" h1 \ + {awk -F, '$7=="130" && $3 ~ /^fe80:/' /tmp/cap.txt | grep -q .} 30 + +############################################################################### +# interval: startup queries come robustness (2) times at a quarter of the +# interval, then every interval. Four general queries cover both. +############################################################################### + +set deadline [expr {[clock seconds] + 45}] +while {[llength [set q [queries 10.0.0.10 0.0.0.0 $t0]]] < 4 && [clock seconds] < $deadline} { + after 2000 +} +if {[llength $q] >= 4} { + set d [expr {[lindex $q end] - [lindex $q end-1]}] + check "interval: general queries 10 s apart" [expr {$d >= 8.0 && $d <= 12.0}] [format "%.1f s" $d] +} else { + check "interval: general queries 10 s apart" 0 "[llength $q] queries in 45 s" +} + +############################################################################### +# join and leave +############################################################################### + +sh h1 {(socat -u UDP4-RECV:5000,ip-add-membership=239.1.2.3:lan0 /dev/null > /dev/null 2>&1 & echo $! > /tmp/join.pid)} + +step "join: sw's mdb has 239.1.2.3 on p1 only" sw \ + {bridge -j mdb show dev br0 | jq -e '[.[].mdb[]? | select(.grp=="239.1.2.3") | .port] == ["p1"]'} 30 +step "join: mcd lists 239.1.2.3 on p1" sw \ + {mctl show igmp json | jq -e 'any(.["multicast-groups"][]; .group=="239.1.2.3" and .ports==["p1"])'} 30 + +set tl [now_h1] +sh h1 {kill $(cat /tmp/join.pid)} + +check "leave: draws a group-specific query for 239.1.2.3" \ + [expr {[wait_query 10.0.0.10 239.1.2.3 $tl 15] ne ""}] +step "leave: mcd drops 239.1.2.3" sw \ + {mctl show igmp json | jq -e 'any(.["multicast-groups"][]; .group=="239.1.2.3") | not'} 20 +step "leave: sw's mdb drops 239.1.2.3" sw \ + {bridge -j mdb show dev br0 | jq -e '[.[].mdb[]? | select(.grp=="239.1.2.3")] == []'} 30 + +############################################################################### +# election +############################################################################### + +sh h2 {printf 'global-query-interval 10\niface lan0 enable\n' > /etc/mcd.conf && (mcd -n -l debug > /tmp/mcd.log 2>&1 &)} + +step "election: sw sees 10.0.0.2 as querier" sw \ + {mctl show igmp json | jq -e '.["multicast-queriers"][] | select(.interface=="br0") | .querier=="10.0.0.2"'} 30 + +# One full interval and some: sw must have gone quiet, h2 must be querying. +set te [now_h1] +after 13000 +set from_sw [queries 10.0.0.10 0.0.0.0 [expr {$te + 2}]] +set from_h2 [queries 10.0.0.2 0.0.0.0 $te] +check "election: sw stops querying, h2 queries" \ + [expr {[llength $from_sw] == 0 && [llength $from_h2] > 0}] \ + "[llength $from_sw] from sw, [llength $from_h2] from h2 in 13 s" + +# man mcd.conf(5): when unset, router-timeout is robustness * interval + +# response-interval / 2. h2 sets no router-timeout. +step "election: default router-timeout follows the query interval (2*10+10/2 = 25)" h2 \ + {mctl show igmp json | jq -e '.["router-timeout"] == 25'} 5 + +############################################################################### +# takeover: sw's other-querier-present timer (router-timeout 15) runs from +# h2's last query, which was at most one interval before the kill. +############################################################################### + +# A subshell: a bare `exit` would end the console's shell. +sh h2 {(pkill -x mcd; for i in $(seq 1 20); do pgrep -x mcd >/dev/null || exit 0; sleep 0.5; done; exit 1)} +set tk [now_h1] +set first [wait_query 10.0.0.10 0.0.0.0 $tk 45] +if {$first ne ""} { + set d [expr {$first - $tk}] + check "takeover: sw resumes querying within router-timeout" [expr {$d <= 20.0}] [format "after %.1f s" $d] +} else { + check "takeover: sw resumes querying within router-timeout" 0 "no query from sw in 45 s" +} +step "takeover: sw is querier again" sw \ + {mctl show igmp json | jq -e '.["multicast-queriers"][] | select(.interface=="br0") | .querier=="10.0.0.10"'} 15 + +finish diff --git a/nix/targets.nix b/nix/targets.nix index 87c4ecc..7e1ff3b 100644 --- a/nix/targets.nix +++ b/nix/targets.nix @@ -77,4 +77,29 @@ } ]; } + { + title = "MicroVM tests (x86_64, needs /dev/kvm)"; + rows = [ + { + cmd = "nix run .#vm-check"; + desc = "the network test suite as root in a VM"; + target = "vm-check"; + } + { + cmd = "nix run .#vm-check-asan"; + desc = "against an ASan + UBSan build"; + target = "vm-check-asan"; + } + { + cmd = "nix run .#vm-check-valgrind"; + desc = "with mcd under valgrind"; + target = "vm-check-valgrind"; + } + { + cmd = "nix run .#vm-topo"; + desc = "three-VM querier election and snooping"; + target = "vm-topo"; + } + ]; + } ] From 5037c41fbcf70dd76f4cf3b22256c3e342282152 Mon Sep 17 00:00:00 2001 From: Dave Seddon Date: Mon, 19 Oct 2026 13:33:14 -0700 Subject: [PATCH 24/24] nix: add nix/README.md, and check mcd-help's targets against it nix/README.md documents the flake: the dev shell and builds, static analysis and triage, fuzzing, the microVM tests, how the sources are split so edits rebuild little (with measured rebuild counts), a module map, and the bugs found so far with the test that covers each. checks.help-targets fails if a target mcd-help lists (nix/targets.nix) is not a flake output or is not mentioned in nix/README.md. Signed-off-by: Dave Seddon --- flake.nix | 25 +- nix/README.md | 620 ++++++++++++++++++++++++++++++++++++++++++ nix/commands/help.nix | 3 +- nix/help-targets.sh | 25 ++ nix/targets.nix | 8 +- 5 files changed, 674 insertions(+), 7 deletions(-) create mode 100644 nix/README.md create mode 100644 nix/help-targets.sh diff --git a/flake.nix b/flake.nix index fbea34b..4a171ff 100644 --- a/flake.nix +++ b/flake.nix @@ -4,7 +4,7 @@ # A reproducible development environment, hermetic native and cross builds, # and OCI images for the mcd IGMP/MLD querier. The design is modular: this # slim flake.nix wires together small single-purpose modules under nix/, -# all sharing one context built in nix/context.nix. +# all sharing one context built in nix/context.nix (see nix/README.md). # mcd is one callPackage function (nix/package.nix); every variant is an # .override of it and every cross build a pkgsCross..callPackage. # @@ -17,7 +17,7 @@ # Cross-compile (from an x86_64-linux host): # nix build .#mcd-aarch64 # also: -armv7l, -riscv64, -ppc64le # -# Run the static analysis: +# Run the static analysis (nix/README.md lists every target): # nix build .#analysis-deep && cat result/summary.txt # # Fuzz the packet, config and IPC parsers (libFuzzer): @@ -125,10 +125,29 @@ // lib.concatMapAttrs (arch: r: mkOciPair r.pkgs.callPackage r.mcd "-${arch}") cross // microvms.packages; inherit (microvms) apps; - inherit (microvms) checks; + checks = microvms.checks // { + help-targets = helpTargets; + }; } ); + # mcd-help's targets (nix/targets.nix) are flake outputs and are + # in nix/README.md. On x86_64, where every target exists. + targetNames = lib.concatMap (s: lib.filter (t: t != null) (map (r: r.target or null) s.rows)) ( + import ./nix/targets.nix + ); + helpTargetsCheck = pkgs.writeShellApplication { + name = "mcd-help-targets"; + runtimeInputs = [ pkgs.gnugrep ]; + text = builtins.readFile ./nix/help-targets.sh; + }; + helpTargets = pkgs.runCommand "mcd-help-targets" { } '' + ${lib.getExe helpTargetsCheck} ${./nix/README.md} \ + ${lib.escapeShellArg (toString (lib.attrNames (packages // (x86_64.apps or { }))))} \ + ${lib.escapeShellArgs targetNames} + touch $out + ''; + packages = { inherit mcd mcd-clang mcd-werror; default = mcd; diff --git a/nix/README.md b/nix/README.md new file mode 100644 index 0000000..ac88ed9 --- /dev/null +++ b/nix/README.md @@ -0,0 +1,620 @@ +# Nix flake: dev shell, builds, analysis, fuzzing and VM tests for mcd + +This flake gives mcd a reproducible development environment, hermetic native +and cross builds, OCI container images, a suite of static, dynamic and fuzz +analysis targets, and KVM microVM integration tests: the real `make check` +network suite run as root inside a guest, and a three-VM multicast topology. The design is modular: a +slim top-level `flake.nix` wires together small single-purpose modules under +`nix/`, all sharing one context (`ctx`) built in `nix/context.nix`. + +> **Status.** Everything described here is built and tested; the +> [Roadmap](#roadmap) lists the phases and the bugs found so far. + +## New to Nix? + +[Nix](https://nixos.org) is a package manager that builds software in +isolation from pinned inputs. `nix develop` drops you into a shell with the +exact compilers and tools this project needs, with nothing installed +system-wide, and `nix build` produces the identical binary on any machine. + +**Install Nix** (multi-user, recommended): + +``` +sh <(curl -L https://nixos.org/nix/install) --daemon +``` + +Single-user (no root, e.g. in a container): use `--no-daemon` instead. Full +instructions: . + +**Enable flakes** (once). Add this line to `/etc/nix/nix.conf` (or +`~/.config/nix/nix.conf`): + +``` +experimental-features = nix-command flakes +``` + +…or prefix each command with +`--extra-experimental-features 'nix-command flakes'`. + +**Two commands to get started**, from the repo root: + +``` +nix develop # enter the dev shell; type 'mcd-help' +nix build # build mcd -> ./result/bin/mcd, ./result/bin/mctl +``` + +> Flakes only see git-tracked files. After adding or editing files under +> `nix/`, `git add` them before `nix build` / `nix develop`. + +## Dev shell & build + +``` +nix develop # gcc, autotools, bison, test + analysis tools; 'mcd-help' +nix build .#mcd # default package (gcc) -> ./result/bin/{mcd,mctl} +nix build .#mcd-clang # same, built with clang (mirrors the CI matrix) +nix build .#mcd-werror # gcc with -Werror (checks.build-werror) +``` + +In the dev shell (each a `writeShellApplication`, so also `nix develop -c mcd-build`): + +| Command | What it does | +|-----------------|-----------------------------------------------------------------| +| `mcd-configure` | `./autogen.sh && ./configure --enable-test …` (args forwarded) | +| `mcd-build` | configures if needed, then `make` | +| `mcd-check` | `make check` on the host, as CI does (needs unprivileged userns) | +| `mcd-clean` | `make distclean` and remove autotools output | +| `mcd-help` | list the commands and every flake target | + +The shell pins `CC=gcc` to match CI; `CC=clang mcd-configure` switches. + +**Variants.** `nix/package.nix` is a `callPackage` function, and every +variant is an `.override` of the one package (`ctx.mcd`), e.g. in +`flake.nix`: + +```nix +mcd-clang = mcd.override { stdenv = pkgs.clangStdenv; pnameSuffix = "-clang"; }; +mcd-werror = mcd.override { werror = true; pnameSuffix = "-werror"; }; +``` + +The microVM test trees are `override { enableTest = true; installBuildTree += true; }` (plus `sanitizers` for ASan), and the cross builds call the same +file with `pkgsCross..callPackage`. + +**`-Werror`.** `checks.build-werror` keeps the gcc build warning-free, so a +new warning fails `nix flake check`. + +**Configure paths.** The package is configured with `--sysconfdir=/etc`, +`--localstatedir=/var` and `--runstatedir=/run`, so the paths baked into the +binary are the runtime ones (`/etc/mcd.conf`, `/run/mcd.sock`, +`/var/lib/…`), not store paths. Binaries, man pages and docs still install +into `$out`. + +**Runtime dependencies.** mcd itself only needs libc, but `mctl show` and +some IPC commands shell out to `ip`, `bridge` (iproute2) and `jq`. The OCI +image and the microVM include them. A bare `nix build` does not wrap them. + +**Host `mcd-check`.** The test suite runs under `unshare -mrun --map-auto` +and uses tshark, socat, ethtool and jq, all of which the dev shell provides. +On Ubuntu hosts with AppArmor, set +`sysctl kernel.apparmor_restrict_unprivileged_userns=0` first. The microVM +runner ([below](#microvm-integration-tests)) avoids host setup entirely. + +## Cross-compilation + +mcd is pure libc, so every nixpkgs cross target should work. Cross outputs are +built on `x86_64-linux` only. + +``` +nix build .#mcd-aarch64 # aarch64-multiplatform +nix build .#mcd-armv7l # armv7l-hf-multiplatform +nix build .#mcd-riscv64 # riscv64 +nix build .#mcd-ppc64le # powernv +file result/bin/mcd +``` + +| Arch | nixpkgs `lib.systems.examples` | Build | OCI | +|----------|--------------------------------|-------|-----| +| aarch64 | `aarch64-multiplatform` | ok | ok (`mcd:3.0-aarch64`) | +| armv7l | `armv7l-hf-multiplatform` | ok | ok (`mcd:3.0-armv7l`) | +| riscv64 | `riscv64` | ok | ok (`mcd:3.0-riscv64`) | +| ppc64le | `powernv` | ok | ok (`mcd:3.0-ppc64le`) | + +"ok" means it builds and `file` reports the right ELF machine. Cross +binaries are not executed: `doInstallCheck` is skipped automatically when +the build host cannot run the target. + +`nix/cross.nix` maps each arch to a nixpkgs `pkgsCross` set and calls +`nix/package.nix` there with `callPackage`, unchanged. `autoreconfHook` +and `bison` are package arguments in `nativeBuildInputs`, so splicing +gives the build machine's copies. + +## Container images (OCI) + +``` +nix build .#oci # minimal image, x86_64 +nix build .#oci-debug # + bash, coreutils +nix build .#oci-aarch64 # cross images: oci-[-debug] +./result | docker load # streamLayeredImage: the result is a script +docker run --rm --network host \ + --cap-add NET_ADMIN --cap-add NET_RAW \ + -v ./mcd.conf:/etc/mcd.conf:ro \ + mcd:3.0 +docker exec -it mctl show # mctl talks to /run/mcd.sock inside the ctr +``` + +Images are built with `dockerTools.streamLayeredImage`. Contents: + +- mcd and mctl +- jq, and a slim iproute2 (`ip`, `bridge`) built without iptables, BPF or + python. That keeps curl, openssl and krb5 out of the image (native minimal + image ≈ 60 MB). It also avoids the iptables → libpcap → rdma-core chain, + which does not cross-build for armv7l. +- `dockerTools.fakeNss` for `/etc/passwd`/`group` +- an empty `/run` +- the sample `mcd.conf` at `/etc/mcd.conf` + +The entrypoint is `mcd -n` (foreground, log to stderr); extra `docker run` +arguments append, e.g. `-l debug`. Tags are `3.0` and `3.0-debug`. Cross +images carry the arch, `3.0-[-debug]`, so loading one does not replace +the native image. + +**Capabilities.** An image cannot grant capabilities. mcd needs `NET_RAW` +for its AF_PACKET sockets and `NET_ADMIN` for netlink and multicast +membership, and it must see the bridge, hence `--network host` (or run it +inside the target network namespace). Use +`podman` (`nix shell nixpkgs#podman`) if docker isn't installed. + +## Static analysis + +Each tool is a build target that produces a report. The aggregates also run +the triage prioritiser. + +``` +nix build .#analysis-quick # clang-tidy + cppcheck + triage +nix build .#analysis-standard # + flawfinder, clang-analyzer (scan-build), gcc-warnings +nix build .#analysis-deep # + gcc-analyzer (-fanalyzer), semgrep +cat result/summary.txt +cat result/triage/high-confidence.txt +``` + +| Target | Tool / mode | Input | +|----------------------------|----------------------------------------------------------|--------------| +| `compile-db` | `bear -- make` → `compile_commands.json` | configured tree | +| `analysis-clang-tidy` | bugprone, cert, clang-analyzer, misc, readability checks | compile DB | +| `analysis-cppcheck` | `--enable=all --std=c11`, text + XML | compile DB | +| `analysis-flawfinder` | `--minlevel=1` | source | +| `analysis-semgrep` | `nix/analysis/semgrep-rules.yaml`, offline | source | +| `analysis-clang-analyzer` | `scan-build ./configure && make`, HTML report | source | +| `analysis-gcc-warnings` | full build with extra `-W…` flags | source | +| `analysis-gcc-analyzer` | full build with `-fanalyzer` | source | + +**Output layout.** Every tool's `$out` has `report.{txt,xml,json}`, +`count.txt`, and optionally `summary.txt`. Aggregates symlink each tool +under `$out//` and add `summary.txt` (a count table) and `triage/`. + +**Compile DB.** The DB is produced by `bear` during a real build, so it +includes the bison-generated `src/cfparse.c`. The tree is copied into +`$out` and the DB paths are rewritten to point there, so tools that consume +it see a stable, immutable source tree. + +**Scope.** Analysis covers `src/` and `lib/`. The vendored `src/jsmn.h` and +`src/queue.h` are filtered out by triage, not hidden from the tools. + +**Out-of-tree build.** The build-driven tools (compile DB, gcc, scan-build) +configure in `_build/` by calling `configure` with an absolute path. automake +then passes absolute source paths to the compiler, so every diagnostic strips +cleanly to `src/foo.c`. + +**Semgrep rules.** `semgrep-rules.yaml` holds the generic C ruleset from +rocm-ernic, plus one rule specific to mcd: `strtok-result-unchecked`. It flags +a `strtok()` result that reaches a libc string function with no NULL check, +which is the `bridge mdb show` parsing pattern in `src/bridge.c`. Every rule +has a fixture in `semgrep-tests/` with `ruleid:` / `ok:` annotations. Semgrep +reports `PARTIAL COVERAGE` for a handful of files because its C parser +doesn't understand the `queue.h` list macros; those functions are covered by +the other tools. + +**Triage** (`nix/analysis/triage/`) does the following: +1. Loads every tool's report into one finding format. +2. Drops excluded checks and out-of-scope paths. +3. Deduplicates by (file, line, check). +4. Scores findings by bug class and cross-tool agreement. +5. Writes `summary.txt`, `high-confidence.txt`, `cross-ref.txt` and `full-report.txt`. + +**Failure semantics.** Findings never fail a build. A run that could not +complete does fail. Examples: a build that broke, semgrep rule tests that +failed, or malformed tool output. A "0 findings" result therefore always +means the tool actually ran. Reading and acting on the findings is a later +phase (see [Roadmap](#roadmap)). + +## Dynamic analysis + +mcd opens AF_PACKET raw sockets, joins multicast groups and needs bridges, +so it cannot be exercised inside the Nix build sandbox. Dynamic analysis is +split into two parts: + +1. **Build** (hermetic): the trees are ordinary derivations, configured with + `--enable-test` and installed whole under `share/mcd/build`. + + ``` + nix build .#mcd-test-tree # plain build tree (vm-check, vm-check-valgrind) + nix build .#mcd-asan-test-tree # ASan + LSan + UBSan tree + ``` + +2. **Exercise** (microVM): the runners boot the guest and run the full + `make check` suite against the chosen tree. + + ``` + nix run .#vm-check-asan # reports grepped from test/*.log, counted and printed + nix run .#vm-check-valgrind # src/mcd wrapped in valgrind --leak-check=full, logs in /tmp/vg + ``` + + For ASan, a finding is a `runtime error:` or `ERROR: *Sanitizer` line in + a test log, since mcd's stderr goes there. `log_path` is not used: + gcc's separate libasan and libubsan runtimes ignored it in the guest. + UBSan's alignment check is on: the packet buffers keep IP headers + 4-byte aligned (`SOCK_BUF_ALIGN`). + + For valgrind, a finding is a log with `ERROR SUMMARY: [1-9]` or + `definitely lost: [1-9]`. Each run ends with one line: + `MCD_CHECK tests= exercise= findings=`. + Any finding fails the runner (exit 1), even when every test passed, so + the ASan and valgrind runners are regression tests. A suite that does + not finish within its budget is `incomplete` and fails it too, so + `findings=0` can be trusted. + +**First results.** The first ASan runs turned up two IPC bugs, both left +for the results phase: +- **SIGPIPE kills the daemon.** mcd does not ignore SIGPIPE or use + `MSG_NOSIGNAL`. A client that disconnects before reading its reply makes + the reply `write()` fatal: mcd exits with status 141 and leaves no + sanitizer report. socat waits only 0.5 s for a reply, so a slow + `show igmp` under ASan in the VM was enough to trigger it (`ipc.sh`). + Deterministic repro: SIGSTOP mcd, run `echo "show igmp" | socat - + UNIX-CONNECT:`, wait 1.5 s, then SIGCONT. + Fixed: replies are sent with `MSG_NOSIGNAL`, and + `test/ipc-disconnect.sh` runs this repro for each reply path. Valgrind + itself reports no errors across 68 mcd runs. +- **Error replies are lost.** `ipc_handle()` passes the listening socket + `sd`, not `client`, to `ipc_err()` (`src/ipc.c`). The "No such command" + text never reaches the client. Fixed; `test/ipc-errors.sh` covers it. + +mcd is single-threaded (one `pev` event loop), so there is no ThreadSanitizer +target. + +## Fuzzing + +The harnesses use libFuzzer, built with clang +`-fsanitize=fuzzer,address,undefined -g -O1`. + +Each harness links against mcd objects from a real out-of-tree autotools +build, so they are compiled exactly as mcd is. Three objects are replaced: +`main.o`, `pev.o` and `sock.o`. Their stand-in is +`nix/analysis/fuzz/stubs.c`: +- **Timers.** Timers go into a table that the harness can fire on demand. +- **Sending.** `sock_send()` counts packets instead of sending them. +- **Wrapped calls.** `exit()`, `popen()` and `pclose()` are wrapped with + `-Wl,--wrap=...`: + - `fatal()` longjmps back to the harness instead of exiting. + - External command output comes from the fuzz input, so nothing is + executed. + +| Harness | Entry point | Covers | +|--------------------|-----------------------------------------------|--------| +| `fuzz_igmp` | `accept_igmp()` `src/igmp.c` | IGMPv1/2/3 queries, reports, leaves, v3 group records, elections and timers (`src/iface.c`) | +| `fuzz_mld` | `accept_mld()` `src/mld.c` | MLDv1/2, hop-by-hop header walk, the same state machine | +| `fuzz_cfparse` | `config_init()` → `config_parse()` `src/cfparse.y` | `mcd.conf` lexer and yacc grammar | +| `fuzz_ipc` | `ipc_handle()` `src/ipc.c`, over a UNIX socket | IPC command matching, options, show handlers | +| `fuzz_inet_parse` | `inet_parse()` `src/inet.c` | dotted-quad / prefix parsing | +| `fuzz_bridge_mdb` | `populate()`, `bridge_prop()`, `dumpster()` `src/bridge.c` | `bridge mdb show`, `bridge -j`/jq JSON, `ip neigh` output | + +**Packet harnesses.** Each input starts with a mode byte, which configures a +fresh `fuzz0` interface: compat version, proxy mode, querier disabled, raw +VLAN, whether our address wins elections, and whether timers fire after each +packet. Up to 8 length-prefixed packets follow, each copied into an +exact-size heap buffer so ASan catches overreads. The MLD harness +recomputes the ICMPv6 checksum, unless the mode byte says not to, so the +fuzzer gets past the checksum check. `nix/analysis/fuzz/README.md` has the +details. + +``` +nix build .#fuzz # harness binaries + seed corpora + run-fuzzers +FUZZ_TIME=300 ./result/bin/run-fuzzers # per-harness time (default 60 s) +./result/bin/run-fuzzers igmp mld # just these harnesses +nix build .#fuzz-run # 20 s per harness in the sandbox +cat result/summary.txt # findings in result/crashes//, repro.txt +nix build .#fuzz-run-igmp # one harness's 20 s run +``` + +**Build layout.** Each piece is its own derivation, so an edit rebuilds +only what it touches: +- the mcd objects, built once per toolchain (clang + libFuzzer, gcc + + ASan/UBSan for replay); +- one binary per harness, each from just its own `fuzz_.c`, `stubs.c` + and headers; +- the seed corpus, from `gen_seeds.py`; +- one 20 s run per harness (`fuzz-run-`), which run in parallel; + `fuzz-run` joins them into the one summary; +- one replay per harness, joined by `checks.fuzz-replay`. + +`.#fuzz` joins the clang binaries, the corpus and `run-fuzzers` into one +tree (`bin/`, `corpus/`). + +**Corpora.** `nix/analysis/fuzz/gen_seeds.py` writes the seeds at build +time, so the repo carries no binary blobs. They are valid, checksummed +IGMP/MLD packets and multi-packet sequences, the sample and variant +`mcd.conf` files, every IPC command, and canned `bridge`/`ip` output. +`run-fuzzers` copies them into `FUZZ_WORKDIR` (default: a temp dir) and +fuzzes there, so the store copy stays untouched. + +**Reproduce a crash.** Run `./result/bin/fuzz_igmp crash-`. Add +`FUZZ_VERBOSE=1` to see mcd's own log. The dev shell sets +`ASAN_SYMBOLIZER_PATH`, so traces are symbolized. + +**Keeping harnesses honest.** `checks.fuzz-replay` builds every harness with +gcc and ASan/UBSan against `fuzz/replay_main.c`, a plain `main()` that runs +each corpus file once. A harness that stops compiling, or a seed that +crashes, fails `nix flake check`. + +**First results.** Within the first minutes the harnesses found: +- a global buffer overflow in the config lexer (`sscanf` into `s1`) +- a lexer overread on a 1023-byte line +- a stack underflow in `chomp()`, reachable from any IPC client +- the NULL `strtok()` result in `bridge.c` +- a misaligned IP header overlay when building queries + +The list, with locations and fixes, is in `nix/analysis/fuzz/README.md`; +the [Roadmap](#roadmap) table tracks them. + +## MicroVM integration tests + +The test suite (`test/*.sh`) creates bridges, veth pairs, VLANs and +namespaces, and checks query traffic with tshark. The microVM runs it as root +on a real kernel, with no host privileges beyond `/dev/kvm`. + +``` +nix run .#vm-check # boot guest, run make check, exit 0 PASS / 1 FAIL (~1 min) +nix run .#vm-check -- --keep # leave the VM up for poking (serial: port 23700) +nix build .#microvm # just the microvm.nix runner +``` + +``` + host guest (NixOS, qemu_kvm) + ───────────────────────────── ───────────────────────────────── + vm-check (writeShellApplication) + ├─ microvm-run & ─────────────────▶ boots; root autologin on ttyS0 + ├─ nc tail ◀── virtconsole :23701 ── journal + kernel log → transcript + ├─ expect make-check.exp ── :23700 ─▶ ttyS0: cp -a /tmp/mcd + │ make check & (unshare -mun per test) + │ ◀── PASS:/FAIL: lines, polled ─ failing test/*.log dumped + │ ◀── MCD_CHECK tests= ... ────── sanitizer / valgrind counts + ├─ on failure: node-diag.exp ───────▶ dmesg, ip -d link, bridge mdb + └─ trap: poweroff, kill qemu +``` + +**Guest** (`nix/microvms/mkVm.nix`): + +| Setting | Value | +|------------------|-------| +| Hypervisor | qemu_kvm, `-cpu host`, 4 vCPU | +| Memory | 1024 MiB (never exactly 2048, which hangs QEMU) | +| Nix store | host `/nix/store` shared over 9p | +| NIC | SLiRP `user` | +| Serial console | `ttyS0` on `tcp:127.0.0.1:23700`, owned by expect | +| Virtio console | `hvc0` on `:23701`, tailed into the transcript | +| Login | root autologin | +| Kernel | `linuxPackages_latest` | +| Kernel modules | `bridge veth 8021q dummy` | +| Packages | both build trees (via `system.extraDependencies`), tshark, socat, ethtool, jq, iproute2, util-linux, make, valgrind | + +**Expect console library** (`nix/microvms/console/vm-lib.exp`, namespace +`vmlib`). This is generic and shared with other projects: +- `connect port timeout` opens the TCP socket directly with `spawn -open`. + There is no socat or nc pty, so commands are not echoed twice. +- `run_out cmd timeout` wraps the command in `MCD_BEGIN_` / + `MCD_RC_=` markers and returns `{rc output}`. +- `run` returns the rc only. `must` exits 3 when the command fails. +- `poweroff` shuts the guest down. +- `connect` returns a console id; `use id` makes it current, for drivers + that own several consoles. + +The drivers are `make-check.exp` (the suite), `node-ready.exp` (poll a list of +readiness commands) and `node-diag.exp` (dump diagnostics, always exit 0). + +**Exit codes** (runner and drivers): 0 pass, 1 test failure, 2 no console, +3 setup step failed, 124 overall timeout. + +The suite's own `TESTS_ENVIRONMENT` is `unshare -mrun --map-auto`. In the +guest it is overridden with `unshare -mun`: the shell is already root, so +the user namespace is dropped, and each test still gets its own mount, UTS +and network namespaces. The tree is copied with `cp -a`, which keeps the +store timestamps, so `make check` rebuilds nothing. + +These runners are packages and apps, not checks: the build sandbox has no +`/dev/kvm`. The Expect library itself *is* checked hermetically +(`checks.vm-lib-exp`), against a socat-backed bash pty. + +### Multi-VM multicast topology + +``` +nix run .#vm-topo # boot sw, h1, h2; run the scenario (~2 min) +nix run .#vm-topo -- --keep # leave all three up (serial: 23710 / 23720 / 23730) +nix build .#microvm-topo-sw # one guest's microvm.nix runner (also -h1, -h2) +``` + +``` + h1 lan0 ──link1── p1 ┐ + 10.0.0.11 sw: br0 10.0.0.10, mcd (mcast_snooping 1, kernel querier off) + h2 lan0 ──link2── p2 ┘ + 10.0.0.2 (rival mcd, lower address) +``` + +The links are QEMU `-netdev dgram` unix-socket point-to-point pairs. They need +no tap devices, host bridge or sudo, and give deterministic L2. The socket +paths are relative, and the runner starts every qemu in one fresh directory, +so no stale socket from a killed run can fail a bind. Each link NIC is +renamed by MAC (`systemd.network.links`) and left unmanaged by networkd. The +topology guests are smaller (2 vCPU, 768 MiB each) and have no SLiRP uplink. Sizes, ports, MACs and sockets are in +`constants.nix` (`topo`); console ports are 23700 + 10·n. + +`multicast-topo.exp` drives all three serial consoles (`vmlib::use` switches +between them). h1 runs a tshark capture for the whole scenario. The steps: + +| Group | Asserts | +|-------|---------| +| links | h1 reaches sw; h2 reaches h1 through the bridge | +| querier | mcd on sw is elected (`mctl -j`); h1 sees IGMPv3 and MLDv2 general queries | +| interval | general queries 8–12 s apart with `global-query-interval 10` | +| join | after h1 joins 239.1.2.3 (socat), sw's mdb has it on p1 only, and mcd lists it | +| leave | the leave draws a group-specific query; mcd and the mdb drop the group | +| election | h2's mcd (10.0.0.2) wins; sw sees it as querier and stops querying | +| takeover | h2's mcd is killed; sw resumes within its router-timeout and is querier again | + +Each line is `PASS:`, `FAIL:`, or for a known mcd bug `XFAIL:` / `XPASS:` +(the bug is fixed: drop the marker). The first FAIL dumps mcd's log, `mctl +show`, the mdb and links from every node, then stops. The summary is +`MCD_TOPO steps= pass= fail= xfail= xpass=`. + +h2 sets no `router-timeout`, so the election step also checks that the +default follows its `global-query-interval 10` (2·10 + 10/2 = 25 s); this was +an XFAIL until the default was computed after the config is read. sw sets +`router-timeout 15` explicitly so the takeover step stays short. + +## Checks (`nix flake check`) + +Everything here is hermetic and runs without KVM or network privileges: + +| Check | What it does | +|---------------------|--------------| +| `build-gcc`, `build-clang` | the package builds; `mcd -h` / `mctl -h` run | +| `build-werror` | the gcc build with `-Werror` | +| `analysis-runners` | `nix/analysis/test_runners.py`: table-driven tests of triage parsers and the report validators | +| `semgrep-rules` | `semgrep --test` for every fixture in `nix/analysis/semgrep-tests/` | +| `fuzz-replay` | every harness built with gcc + ASan/UBSan and replayed over its seed corpus | +| `unit` | table-driven C unit tests of mcd internals (`nix/analysis/unit/test_.c`, gcc + ASan/UBSan), e.g. `pev.c` timer arithmetic for a chosen clock reading; process tests run mcd under an `LD_PRELOAD` shim, e.g. `fork()` failing | +| `vm-lib-exp` | table-driven tests of the Expect console library | +| `help-targets` | every target `mcd-help` lists (`nix/targets.nix`) is a flake package or app and is named as `.#` in this README (x86_64, where they all exist) | + +**Shell scripts under `nix/` are `writeShellApplication`s.** Each one is +shellchecked when it is built, runs under `errexit`/`nounset`/`pipefail`, +and has exactly the tools its `runtimeInputs` lists on `PATH`. That covers +the dev-shell commands, the analysis runners and aggregates, `run-fuzzers` +and `fuzz-run`, the semgrep rule tests, the unit process tests, the VM +runners, the `vm-lib-exp` driver, and `mcd-valgrind` (the guest's valgrind +wrapper for `src/mcd`). A derivation with shell logic keeps only a one-line +builder that runs its script with `$out`. `mkDerivation` phases and +plain copy builders stay as they are. + +`nix fmt` formats every tracked `.nix` file with `nixfmt`. + +## Caching + +A derivation rebuilds when any of its inputs changes, so each one is given +only the files it reads (`lib.fileset`, in `nix/context.nix`): + +| Fileset | Contents | Used by | +|---------|----------|---------| +| `srcAnalysis` | autotools files, `src/`, `lib/`, `man/`, `test/Makefile.am` | the analysis builds, the fuzz objects (`mkAnalysisDrv`) | +| `srcBuild` | `srcAnalysis` + the docs `make install` ships (`README.md`, …) | the package | +| `srcTest` | `srcBuild` + `test/` | the microVM test trees (`enableTest`) | +| `srcCode` | `src/`, `lib/` | flawfinder, semgrep | +| `srcConf` | `mcd.conf` | the fuzz seed corpus | + +The guest system does not contain mcd: the VMs see the host store over 9p +and the runner passes the tree's path to the driver, so a source edit +rebuilds the tree, not the NixOS system. + +Derivations rebuilt by a one-line edit, over every x86_64 package and +check (`nix build --dry-run`, against the no-edit set): + +| Edit | before | after | +|------|-------:|------:| +| `test/basic.sh` | 121 | 8 | +| `src/igmp.c` | 121 | 127 | +| `nix/analysis/fuzz/fuzz_igmp.c` | 7 | 8 | +| `nix/analysis/fuzz/gen_seeds.py` | 7 | 22 | +| `README.md` | 121 | 78 | + +A source edit rebuilds about as many derivations as before, but smaller +ones. The rise is the fuzz split: one binary, one 20 s run and one replay +per harness. A one-line `fuzz_igmp.c` edit used to rebuild every harness +and rerun all six 20 s campaigns one after another, about 150 s. It now +rebuilds that harness's clang and gcc binaries and reruns its run and +replay, about 22 s. `gen_seeds.py` +reruns every campaign, but they run in parallel. `README.md` is installed +as documentation, so it still rebuilds the packages and OCI images (10 +derivations per image), but no longer any analysis build, fuzzer or NixOS +guest. + +## Module map + +| Path | Purpose | +|------|---------| +| `flake.nix` | inputs (nixpkgs, flake-utils, microvm.nix), wires the modules and outputs | +| `nix/context.nix` | shared `ctx`: pkgs, version, source filesets, package lists, `runtimeDirs`, `configureVpath`, `inTreeDiagnostics`, `mkAnalysisDrv`, and `mcd` itself | +| `nix/packages.nix` | `buildTools`, `testTools`, `runtimeTools`, `devTools` lists | +| `nix/package.nix` | the mcd build, a `callPackage` function; variants are `mcd.override { stdenv, sanitizers, werror, enableTest, installBuildTree, ... }` | +| `nix/devshell.nix` | `nix develop` shell | +| `nix/commands/` | `mcd-*` dev-shell commands (`writeShellApplication`s) and the banner | +| `nix/targets.nix`, `help-targets.sh` | the targets `mcd-help` lists; `checks.help-targets` checks each is a flake output and is in this README | +| `nix/cross.nix` | arch -> `pkgsCross` set, with `package.nix` called in each | +| `nix/oci.nix` | OCI images (minimal / debug), a `callPackage` function, any arch | +| `nix/analysis/default.nix` | `mkReport`, the per-tool reports and quick/standard/deep aggregates (`reports`, each a package `analysis-`) | +| `nix/analysis/*.nix` | one file per tool (compile-db, clang-tidy, cppcheck, flawfinder, semgrep, clang-analyzer, gcc) | +| `nix/analysis/triage/` | Python triage: parsers, filters, scoring, reports | +| `nix/analysis/test_runners.py` | table-driven tests of the triage parsers/filters and `semgrep-report.jq` | +| `nix/analysis/unit.nix`, `unit/` | C unit tests: each `#include`s the mcd source it tests and fakes libc calls with `-Wl,--wrap` | +| `nix/analysis/fuzz.nix`, `fuzz/` | libFuzzer harnesses (per-toolchain objects, per-harness binaries, runs and replays, the seed corpus), `stubs.c`, `gen_seeds.py`, replay main, fuzz README | +| `nix/microvms/` | guest (`mkVm.nix`), constants (ports, memory), host runners: `runners.nix` writes each runner's JSON config for the static `runner.sh` | +| `nix/microvms/console/` | Expect library, generic drivers, hermetic check (rows in `default.nix`, run by `check.sh`) | +| `nix/microvms/scripts/` | mcd-specific Expect drivers (`make-check.exp`, `multicast-topo.exp`) | + +## Roadmap + +| Phase | Scope | Status | +|-------|-------|--------| +| 1 | This design document | done | +| 2 | flake, derivation, dev shell, cross, OCI | done | +| 3 | static analysis, triage, analysis checks | done | +| 4 | fuzz harnesses, corpora, `fuzz-replay` | done | +| 5 | single-VM `vm-check`, Expect library + check, sanitizer/valgrind VM runners | done | +| 6 | multi-VM multicast topology (`vm-topo`) | done | +| 7 | README brought in line with what was built; arch table filled in | done | +| 8 | refactor: `callPackage`/`.override`/`pkgsCross`, filesets, per-harness fuzzing, static runner scripts ([Caching](#caching)) | done | +| later | read and act on the analysis and fuzzing results | next | + +**Bugs found** (all in `src/`). Each fix lands with a test that failed +before it: a regression input in the fuzz corpus (`regress-`, replayed +by `checks.fuzz-replay`), a C unit test (`checks.unit`), a `test/*.sh` +case, or a `vm-topo` step. + +| Bug | Found by | Section | Status | +|-----|----------|---------|--------| +| shell command injection through bridge names in the router ports query | `analysis-deep` (popen review) | [Static analysis](#static-analysis) | fixed; `test/bridge-names.sh` | +| router ports never listed for a bridge named like `br-0` (invalid jq `.key`) | `test/bridge-names.sh` | [Static analysis](#static-analysis) | fixed; `test/bridge-names.sh` | +| timer expiry left at `tv_nsec` 1000000000 (`>` for `>=`); at a clock reading of exactly the next second it arms an invalid itimer, ignored, and timers stop | `analysis-deep` (clang-tidy widening, on review) | [Checks](#checks-nix-flake-check) | fixed; `checks.unit` (`test_pev`); very unlikely in practice | +| `mctl` heap overflow storing a daemon `help` command of more than 32 words | `analysis-deep` (clang-analyzer leak report, on review) | [Static analysis](#static-analysis) | fixed; `test/mctl-help.sh` | +| mcd exits 0, not running, when it cannot fork to daemonize | `analysis-deep` (gcc-analyzer `dup2` report, on review) | [Checks](#checks-nix-flake-check) | fixed; `checks.unit` (`test_daemonize`) | +| SIGPIPE on an IPC reply kills the daemon | `vm-check-asan` | [Dynamic analysis](#dynamic-analysis) | fixed; `test/ipc-disconnect.sh` | +| `ipc_err()` writes to the listening socket, not the client | `vm-check-asan` | [Dynamic analysis](#dynamic-analysis) | fixed; `test/ipc-errors.sh` | +| default router-timeout ignores `global-query-interval` | `vm-topo` (XFAIL) | [Multi-VM multicast topology](#multi-vm-multicast-topology) | fixed; `test/router-timeout.sh`, `vm-topo` step | +| config lexer global overflow (`sscanf` into `s1`) | `fuzz_cfparse` | [Fuzzing](#fuzzing) | fixed; `regress-long-dotted-quad` | +| config lexer overread on a 1023-byte line | `fuzz_cfparse` | [Fuzzing](#fuzzing) | fixed; `regress-full-line-no-newline` | +| `chomp()` stack underflow from any IPC client | `fuzz_ipc` | [Fuzzing](#fuzzing) | fixed; `regress-chomp-all-newlines` | +| NULL `strtok()` result in `bridge.c` | `fuzz_bridge_mdb` | [Fuzzing](#fuzzing) | fixed; `regress-mdb-line-ends-after-{keyword,vid}` | +| `populate()` port list overflow, more than ~30 ports in a group | code review | [Fuzzing](#fuzzing) | fixed; `regress-mdb-many-ports-one-group` | +| `bridge_prop()` reading past its 256 JSON tokens on an object with keys but no values | `fuzz-run` | [Fuzzing](#fuzzing) | fixed; `regress-json-keys-without-values` | +| `bridge_prop()` NUL written one byte past its buffer on 8 KiB or more of JSON | code review | [Fuzzing](#fuzzing) | fixed; `regress-json-fills-read-buffer` | +| misaligned IP header overlay in `build_ipv4()` | fuzzers, `vm-check-asan` | [Fuzzing](#fuzzing) | fixed; alignment checks on in `fuzz-replay`, `vm-check-asan` | +| `num` set but not used in `populate()` (blocks `-Werror`) | gcc 16 | [Dev shell & build](#dev-shell--build) | fixed; `checks.build-werror` | + +The static-analysis reports (`analysis-deep`) are being worked through; the +findings that turned out real are in the table, highest severity first. + +## Provenance + +The analysis framework (tool wrappers, triage, semgrep rules and validator) +is adapted from the Nix layer of rocm-ernic, which was itself ported from +xdp2. The microVM console library comes from partybus. Files copied from +rocm-ernic keep their original `SPDX-License-Identifier: MIT` / +copyright headers. diff --git a/nix/commands/help.nix b/nix/commands/help.nix index 20cf379..b43d733 100644 --- a/nix/commands/help.nix +++ b/nix/commands/help.nix @@ -1,7 +1,8 @@ # nix/commands/help.nix # # mcd-help, a writeShellApplication printing the dev-shell commands and the -# flake targets, from nix/targets.nix. +# flake targets. The targets come from nix/targets.nix, which +# checks.help-targets keeps in step with the flake and nix/README.md. # # Usage: # mcd-help = import ./commands/help.nix { inherit pkgs lib targets; }; diff --git a/nix/help-targets.sh b/nix/help-targets.sh new file mode 100644 index 0000000..9265a6d --- /dev/null +++ b/nix/help-targets.sh @@ -0,0 +1,25 @@ +# Checks mcd-help's targets (nix/targets.nix): each must be a flake +# package or app and be mentioned, as .#, in nix/README.md. +# +# Usage: mcd-help-targets README "OUTPUT..." TARGET... +# +# Built with writeShellApplication (errexit, nounset, pipefail). + +readme=$1 +outputs=" $2 " +shift 2 + +fail=0 +for t in "$@"; do + case $outputs in + *" $t "*) ;; + *) echo "FAIL: $t: not a flake package or app"; fail=1 ;; + esac + if grep -qE "\.#$t([^A-Za-z0-9-]|\$)" "$readme"; then + echo "ok: $t" + else + echo "FAIL: $t: not in nix/README.md as .#$t" + fail=1 + fi +done +exit $fail diff --git a/nix/targets.nix b/nix/targets.nix index 7e1ff3b..8fc8f7e 100644 --- a/nix/targets.nix +++ b/nix/targets.nix @@ -1,7 +1,9 @@ # nix/targets.nix # -# The flake targets mcd-help lists: the help text is generated from this -# list. +# The flake targets mcd-help lists, one place for both: the help text is +# generated from this list, and checks.help-targets fails if a `target` +# here is not a flake output or is not mentioned (as .#) in +# nix/README.md. # # Each section: { title; rows = [ { cmd; desc; target ? null; } ]; }, with # `target` the package or app name the row runs (null: no single one). @@ -48,7 +50,7 @@ ]; } { - title = "Analysis"; + title = "Analysis (see nix/README.md)"; rows = [ { cmd = "nix build .#analysis-quick";