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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .github/workflows/fuzz.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,12 @@ on:
- cron: "0 3 * * *"
workflow_dispatch:

# Keyed on the triggering event too, so the nightly cron can never
# cancel a manually dispatched run on the same ref (the release
# checklist in CONTRIBUTING.md relies on one) -- runs of the same kind
# still supersede each other.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }}
cancel-in-progress: true

permissions:
Expand Down
32 changes: 24 additions & 8 deletions .github/workflows/mutation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,12 @@ on:
- cron: "0 4 * * *"
workflow_dispatch:

# Keyed on the triggering event too, so the nightly cron can never
# cancel a manually dispatched run on the same ref (the release
# checklist in CONTRIBUTING.md relies on one) -- runs of the same kind
# still supersede each other.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }}
cancel-in-progress: true

permissions:
Expand Down Expand Up @@ -65,20 +69,32 @@ jobs:
'netprotocols._tlv.*' \
'netprotocols.layer7.dns.x__read_name*' \
'netprotocols.layer7.dns.x__labels*'
# `mutmut run` exits 0 however many mutants survive, so it can
# never turn this job red on its own. This step is what does: it
# fails on any undetected mutant not in the triage record
# (scripts/mutmut_survivors.txt), on a stale entry there, and on
# a scoped file with no checked mutant at all.
- name: Fail on survivors outside the triage record
run: |
uv run --frozen mutmut results --all true > mutmut-results.txt
uv run --frozen python scripts/check_mutmut_survivors.py \
mutmut-results.txt
# A failed scheduled run already notifies repo watchers by
# default (GitHub's standard behavior) — that red run *is* the
# report; nothing here files an issue on top of it. This just
# makes the surviving mutants recoverable without needing repo
# write access to re-run mutmut locally. Covers both the
# sqlite-file cache and the mutated-copies directory since either
# may be what a given mutmut version writes; whichever doesn't
# exist is silently skipped.
# default (GitHub's standard behavior), and with the step above a
# red run means an untriaged survivor or a broken run; nothing
# here files an issue on top of it. This makes the results
# recoverable without needing repo write access to re-run mutmut
# locally. Covers the status listing plus both the sqlite-file
# cache and the mutated-copies directory, since either of the
# latter may be what a given mutmut version writes; whatever
# doesn't exist is silently skipped.
- name: Upload mutmut results on failure
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: mutmut-nightly-failure-${{ github.run_id }}
path: |
mutmut-results.txt
.mutmut-cache
mutants
if-no-files-found: ignore
Expand Down
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,8 @@ benchmark.out
# lockfile-pinned dependency.
scripts/pyodide/node_modules/

# mutmut's mutation-testing workspace/cache
# mutmut's mutation-testing workspace/cache, and the status listing
# scripts/check_mutmut_survivors.py reads
mutants/
.mutmut-cache
mutmut-results.txt
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Development

- **The nightly mutation run can now fail on a surviving mutant.** `mutmut run` (3.8.0) exits 0 however many mutants survive, so `mutation.yml` was green whatever it found. A new final step, `scripts/check_mutmut_survivors.py`, fails the job on any undetected mutant not accepted in the new triage record `scripts/mutmut_survivors.txt`, on a stale entry there, and on a scoped file with no checked mutant at all; unknown mutmut statuses fail closed. The two accepted survivors and their reasoning moved into that record from a `pyproject.toml` comment.
- **Closed a mutation-testing gap the gate exposed.** A third survivor — `_tlv.walk_kind_length_value` discarding a caller's `min_option_length` when the length byte counts its own header — was neither triaged nor killed, because no call site passes a floor above the structural 2. Two direct tests in `tests/test_tlv.py` now pin it.
- **Mutation runs can no longer report false survivors from warm caches.** mutmut 3 runs the clean suite in-process and then forks once per mutant, so a `functools` cache warmed by the original code (DNS's section parsers) could answer in the child and hide a mutant. `tests/conftest.py` now clears every package-level cache before each test, discovering them rather than listing them.
- **Hypothesis profiles load for every test selection.** They were registered only when `tests/test_fuzz.py` was imported, which mutmut's test selection never does, so property tests under mutation ran randomized and under the default deadline. They now live in `tests/conftest.py`.
- **`fuzz.yml` and `mutation.yml` key their concurrency groups on the triggering event**, so the nightly cron can no longer cancel a manually dispatched run. CONTRIBUTING.md gains *Mutation testing* and *Releasing* sections, the latter making those manual runs a release prerequisite.
- **New guards in `tests/test_workflows.py`**: every harness under `tests/fuzz/` must be run by `fuzz.yml`, the survivor gate must follow `mutmut run` and read the full status listing, and both nightly concurrency groups must be event-keyed.
- **`pyproject.toml`'s mutmut comment now says that decorated functions are never mutated**, so the `@lru_cache` DNS section parsers sit outside the audit whatever the filters say.

- **Tightened three DNS corpus assertions in `tests/test_dns.py` from substring/`endswith` checks to exact equality, closing 3 open CodeQL `py/incomplete-url-substring-sanitization` alerts (#166).** `test_names_decompress_to_real_domains` asserted `"example.com" in names` / `"accounts.youtube.com" in names` against a `set` decoded from a fixed pcap corpus, and `test_answers_parse_to_real_records` asserted `cname.rdata_text.endswith("google.com")` against the same corpus — not security-relevant trust checks, but exactly the substring/suffix pattern the rule flags regardless of what the value's actually validating. Both now compare against the known exact decoded values instead (`names == {"example.com", "accounts.youtube.com"}`, `rdata_text == "www3.l.google.com"`), which removes the pattern CodeQL keys on and is strictly more precise than the checks it replaces.
- **Consolidated six open Dependabot updates and grouped `github/codeql-action` so its sub-actions can no longer be bumped apart (#198, #199, #204, #205, #206, #207).** Dependabot tracks `github/codeql-action/init`, `/analyze`, and `/upload-sarif` as three separate dependencies and opened one PR each; `analyze` refuses a config file written by a different `init` release ("Loaded a configuration file for version '4.37.9', but running version '4.38.1'"), so #204 and #206 each failed CodeQL alone and neither could ever merge. All three now move to v4.38.1 together, alongside `astral-sh/setup-uv` 10.0.1 → 10.2.0, `hypothesis` 6.167.1 → 6.168.0, `ruff` 0.16.6 → 0.16.7, and `mutmut` 3.7.0 → 3.8.0 (lockfile byte-identical to Dependabot's). `dependabot.yml` gains a `codeql-action` group for the `github-actions` ecosystem, and `tests/test_workflows.py` gains `TestCodeQLActionVersionLockstep`, which fails the ordinary test job — naming the mismatched steps — whenever `github/codeql-action/*` steps across all workflows are pinned to more than one commit.

Expand Down
42 changes: 42 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,48 @@ only if the derivation is byte-faithful to the wire and the provenance
is recorded in the MANIFEST; that script's synthesis fallback (splicing
tag shims over a real untagged capture) is the worked example.

## Mutation testing

`.github/workflows/mutation.yml` runs a scoped
[mutmut](https://github.com/boxed/mutmut) audit every night; the scope
and its reasoning live in `pyproject.toml`'s `[tool.mutmut]` table.
`mutmut run` itself exits 0 however many mutants survive, so the job's
final step,
[`scripts/check_mutmut_survivors.py`](scripts/check_mutmut_survivors.py),
is what fails it — on any undetected mutant not accepted in
[`scripts/mutmut_survivors.txt`](scripts/mutmut_survivors.txt). To
reproduce locally:

```bash
uv run mutmut run 'netprotocols.checksum.*' 'netprotocols._tlv.*' \
'netprotocols.layer7.dns.x__read_name*' \
'netprotocols.layer7.dns.x__labels*'
uv run mutmut results --all true > mutmut-results.txt
python3 scripts/check_mutmut_survivors.py mutmut-results.txt
```

A survivor a test *could* kill gets that test. Only a true no-op, or one
out of any test's reach, goes in the triage record, under a comment
saying why. Two properties of the setup are easy to trip over:

- mutmut never mutates a decorated function (other than a lone
`@staticmethod`/`@classmethod`) — an `@lru_cache`'d parser, say — so
logic meant to be audited belongs in undecorated functions. (Methods
of a decorated class such as a `@dataclass` are mutated, as of
mutmut 3.8.0.)
- `tests/conftest.py` clears every package-level `functools` cache
before each test, so a result cached by the original code can't hide
a mutant.

## Releasing

`release.yml` gates publishing on the CI ladder only; the nightly fuzz
and mutation jobs never run on a pull request. Before pushing a release
tag, dispatch both by hand (Actions → the workflow → *Run workflow*,
on `master` at the commit to be tagged) and tag only once both are
green. Their concurrency groups are keyed on the triggering event, so a
nightly run can't cancel a manual one.

## Changelog

This project keeps a [Keep a Changelog](https://keepachangelog.com/)
Expand Down
31 changes: 11 additions & 20 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -132,24 +132,15 @@ pytest_add_cli_args_test_selection = [
# 'netprotocols.layer7.dns.x__read_name*' \
# 'netprotocols.layer7.dns.x__labels*'
# Mutants mutmut generates elsewhere in dns.py (record/RDATA parsing,
# the DNS/DNSOverTCP dataclasses, ...) are out of scope for this audit
# and were not triaged.
# the DNS/DNSOverTCP dataclass methods, ...) are out of scope for this
# audit and were not triaged. mutmut (3.8.0) generates none at all for
# a decorated function other than a lone @staticmethod/@classmethod, so
# the @lru_cache section parsers are never mutated, whatever the
# filters say; logic meant to be audited belongs in undecorated
# functions.
#
# Triage note — accepted-equivalent survivors (two, both true no-op
# mutations rather than gaps in coverage):
#
# 1. `_pseudo_header`'s IPv6 arm packs its 32-bit length field with
# `struct.pack("!IBBBB", ...)`; a mutant swapping "I" for the
# signed "i" only diverges from the original for a payload
# >= 2**31 bytes (2 GiB), which no test in this suite can construct
# or run in reasonable time/memory. The same mutation on the IPv4
# arm's 16-bit length field *is* killed — see
# test_ipv4_pseudo_header_length_field_is_unsigned in
# test_checksum.py, which exercises the exact signed/unsigned
# boundary at a testable (40 KB) scale.
#
# 2. `_labels`' final label decode, `.decode("ascii", "replace")`, has
# a mutant that upper-cases the codec name to "ASCII". Python's
# codec lookup normalizes case, so "ascii" and "ASCII" name the
# exact same codec — every input decodes identically either way,
# with no observable difference for any test to catch.
# Triage record: scripts/mutmut_survivors.txt lists each accepted
# survivor with the reason it is equivalent, and
# scripts/check_mutmut_survivors.py (run by .github/workflows/
# mutation.yml after `mutmut run`) fails the run on any other
# undetected mutant -- `mutmut run` itself exits 0 regardless.
158 changes: 158 additions & 0 deletions scripts/check_mutmut_survivors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
#!/usr/bin/env python3
"""Fail a mutmut run that left an unaccepted mutant undetected.

``mutmut run`` (3.8.0, pinned in uv.lock) exits 0 however many mutants
survive: it only exits non-zero when the stats or clean run itself
fails. Without this check the nightly mutation workflow is green no
matter what it finds. This script reads the full status listing and
fails on any of:

- a mutant that went undetected (survived, had no covering tests, or
ended in a status this script does not recognize, so a mutmut
upgrade that adds one fails closed), unless it is in the allowlist
and its status is exactly ``survived``: the allowlist accepts
equivalent survivors and nothing else;
- an allowlist entry that is no longer undetected, so the list cannot
quietly accumulate entries for mutants a later test kills, or whose
number shifted because the function's source changed (mutant numbers
are positional, so an edit to an allowlisted function usually
renumbers its mutants and the entry has to be re-triaged anyway);
- a file in pyproject.toml's ``[tool.mutmut] only_mutate`` with no
checked mutant at all, so a run that silently checked nothing cannot
pass.

Usage, from the repository root after a ``mutmut run``::

uv run --frozen mutmut results --all true > mutmut-results.txt
python3 scripts/check_mutmut_survivors.py mutmut-results.txt

The allowlist (``scripts/mutmut_survivors.txt`` by default) holds one
mutant name per line; ``#`` starts a comment. Every entry needs a
comment saying why that mutant is equivalent or out of reach: the file
is the triage record, not a mute button.
"""

from __future__ import annotations

import argparse
import re
import sys
import tomllib
from collections.abc import Iterable
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent
DEFAULT_ALLOWLIST = ROOT / "scripts" / "mutmut_survivors.txt"
DEFAULT_PYPROJECT = ROOT / "pyproject.toml"

#: A test failed or hung, or the mutant crashed the interpreter or was
#: rejected by the type checker: in every case the change was noticed.
DETECTED = frozenset({"killed", "timeout", "segfault", "caught by type check"})
#: Outside this run's filters, or skipped by mutmut itself.
UNCHECKED = frozenset({"not checked", "skipped"})

#: The one undetected status an allowlist entry may accept.
SURVIVED = "survived"

_LINE = re.compile(r"^\s*(?P<name>\S+): (?P<status>.+?)\s*$")


def parse_results(lines: Iterable[str]) -> dict[str, str]:
"""Map each mutant name to its status in ``mutmut results`` output."""
statuses: dict[str, str] = {}
for line in lines:
match = _LINE.match(line)
if match:
statuses[match["name"]] = match["status"]
return statuses


def parse_allowlist(lines: Iterable[str]) -> set[str]:
"""The mutant names in an allowlist, ignoring comments and blanks."""
names: set[str] = set()
for line in lines:
entry = line.split("#", 1)[0].strip()
if entry:
names.add(entry)
return names


def mutated_modules(pyproject: str) -> list[str]:
"""Dotted module names of the files ``only_mutate`` scopes to."""
paths = tomllib.loads(pyproject)["tool"]["mutmut"]["only_mutate"]
modules = []
for path in paths:
relative = Path(path).relative_to("src").with_suffix("")
modules.append(".".join(relative.parts))
return modules


def check(
statuses: dict[str, str], allowlist: set[str], modules: list[str]
) -> list[str]:
"""Every reason the run should fail; empty when it passes."""
if not statuses:
return ["no mutant statuses found -- was `mutmut results` empty?"]

undetected = {
name
for name, status in statuses.items()
if status not in DETECTED and status not in UNCHECKED
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
# The allowlist accepts equivalent survivors only: an allowlisted
# mutant in any other undetected status is still reported (once,
# here, not as stale too).
accepted = {name for name in allowlist if statuses.get(name) == SURVIVED}
problems = [
f"undetected mutant: {name} ({statuses[name]})"
for name in sorted(undetected - accepted)
]
problems += [
f"allowlisted mutant is no longer undetected: {name} "
f"({statuses.get(name, 'absent from this run')}) -- remove or "
f"re-triage its entry"
for name in sorted(allowlist - undetected)
]
for module in modules:
checked = any(
name.startswith(f"{module}.") and status not in UNCHECKED
for name, status in statuses.items()
)
if not checked:
problems.append(
f"no mutant of {module} was checked -- the run's filters "
f"no longer reach a file only_mutate lists"
)
return problems


def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument(
"results", type=Path, help="`mutmut results --all true` output"
)
parser.add_argument("--allowlist", type=Path, default=DEFAULT_ALLOWLIST)
parser.add_argument("--pyproject", type=Path, default=DEFAULT_PYPROJECT)
args = parser.parse_args(argv)

statuses = parse_results(
args.results.read_text(encoding="utf-8").splitlines()
)
allowlist = parse_allowlist(
args.allowlist.read_text(encoding="utf-8").splitlines()
)
modules = mutated_modules(args.pyproject.read_text(encoding="utf-8"))
problems = check(statuses, allowlist, modules)

checked = sum(status not in UNCHECKED for status in statuses.values())
print(
f"{checked} mutants checked, {len(allowlist)} accepted survivors "
f"allowlisted"
)
for problem in problems:
print(f"FAIL: {problem}", file=sys.stderr)
return 1 if problems else 0


if __name__ == "__main__":
sys.exit(main())
27 changes: 27 additions & 0 deletions scripts/mutmut_survivors.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Accepted mutmut survivors: the triage record for the scoped audit
# configured in pyproject.toml's [tool.mutmut] table.
#
# scripts/check_mutmut_survivors.py fails the nightly mutation run on
# any undetected mutant not listed here, and on any entry listed here
# that is no longer undetected. Every entry must say why that mutant is
# a true no-op or out of any test's reach; one a test *could* kill gets
# that test instead of an entry. Mutant numbers are positional, so
# editing an allowlisted function usually renumbers its mutants -- the
# gate then reports the entry as stale and it has to be re-triaged.

# checksum._pseudo_header's IPv6 arm packs its 32-bit length field with
# struct.pack("!IBBBB", ...); this mutant swaps "I" for the signed "i",
# which only diverges for a payload >= 2**31 bytes (2 GiB), which no
# test in this suite can construct or run in reasonable time/memory.
# The same mutation on the IPv4 arm's 16-bit length field *is* killed
# -- see test_ipv4_pseudo_header_length_field_is_unsigned in
# tests/test_checksum.py, which exercises the exact signed/unsigned
# boundary at a testable (40 KB) scale.
netprotocols.checksum.x__pseudo_header__mutmut_33

# dns._labels' final label decode, .decode("ascii", "replace"): this
# mutant upper-cases the codec name to "ASCII". Python's codec lookup
# normalizes case, so both name the exact same codec -- every input
# decodes identically either way, with no observable difference for
# any test to catch.
netprotocols.layer7.dns.x__labels__mutmut_122
Loading
Loading