diff --git a/.gitignore b/.gitignore index 2350a96..da8f802 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,15 @@ configure Makefile Makefile.in stamp-h1 + +# Nix +result +result-* +.direnv/ +compile_commands.json +__pycache__/ +# libFuzzer artifacts +crash-* +leak-* +timeout-* +oom-* 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 --------------------- diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..688b9c8 --- /dev/null +++ b/flake.lock @@ -0,0 +1,99 @@ +{ + "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" + } + }, + "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, + "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", + "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, + "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..4a171ff --- /dev/null +++ b/flake.nix @@ -0,0 +1,202 @@ +# +# 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 (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. +# +# 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 +# +# 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- +# +# 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] +# +# 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"; + + # 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 + # eachDefaultSystem) keeps the flake from generating darwin attributes + # that could only fail. + outputs = + { + self, + nixpkgs, + flake-utils, + microvm, + }: + 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; }; + + # 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. + 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 + # 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 + // microvms.packages; + inherit (microvms) apps; + 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; + + # 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 { }; + in + { + inherit packages; + apps = x86_64.apps or { }; + + checks = { + build-gcc = mcd; + build-clang = mcd-clang; + build-werror = mcd-werror; + analysis-runners = analysis.runnerTests; + semgrep-rules = analysis.semgrepRuleTests; + fuzz-replay = analysis.fuzz-replay; + unit = analysis.unit; + } + // x86_64.checks or { }; + + 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/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/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..3b44571 --- /dev/null +++ b/nix/analysis/default.nix @@ -0,0 +1,192 @@ +# 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; }; + + # ── Fuzzing ───────────────────────────────────────────────────── + fuzzTargets = import ./fuzz.nix { inherit ctx; }; + 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; + inherit (fuzzTargets) + fuzzers + fuzzRuns + fuzz-run + fuzz-replay + ; +} 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/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/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/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..b43d733 --- /dev/null +++ b/nix/commands/help.nix @@ -0,0 +1,35 @@ +# nix/commands/help.nix +# +# mcd-help, a writeShellApplication printing the dev-shell commands and the +# 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; }; +{ + 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/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/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/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..8fc8f7e --- /dev/null +++ b/nix/targets.nix @@ -0,0 +1,107 @@ +# nix/targets.nix +# +# 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). +# +# 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"; + } + ]; + } + { + title = "Analysis (see nix/README.md)"; + 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"; + } + { + 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"; + } + ]; + } + { + 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"; + } + ]; + } +] diff --git a/src/bridge.c b/src/bridge.c index 04ee2c5..8d53e96 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) @@ -95,6 +94,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; @@ -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); } @@ -130,17 +134,15 @@ 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; - num++; } return pclose(fp); @@ -309,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); @@ -339,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]; @@ -414,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/src/cfparse.y b/src/cfparse.y index 4420b7f..05e2931 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; @@ -313,6 +314,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 +342,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; @@ -384,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/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; } 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/src/ipc.c b/src/ipc.c index 08e16e8..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; } @@ -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/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); 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/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; } 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; diff --git a/test/Makefile.am b/test/Makefile.am index 754925e..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 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 mctl-help.sh CLEANFILES = *~ *.trs *.log TEST_EXTENSIONS = .sh @@ -7,8 +7,13 @@ TESTS_ENVIRONMENT = unshare -mrun --map-auto TESTS = sleepy.sh 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 TESTS += static-group.sh TESTS += mld.sh +TESTS += bridge-names.sh +TESTS += mctl-help.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 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 < "/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 <> "/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/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() 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 <> "/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 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 <