Repository navigation
Dev Fuzzing
MowDeck parses a lot of input it does not control: frames of the mower (cloud and Bluetooth bridge), records of the camera source bridge, bearer tokens and relay tickets, NATS messages, browser WebSocket frames, third-party API answers (Open-Meteo, vision models) and what users draw and configure. Two kinds of tests throw generated input at these parsers:
- Coverage-guided fuzzing of the Python code with Atheris (libFuzzer for Python), run continuously by ClusterFuzzLite in GitHub Actions;
- Property-based tests of the web app's TypeScript with fast-check, part of the normal Vitest suite.
Both are what the OpenSSF Scorecard "Fuzzing" check looks for (checks/raw/fuzzing.go: a .clusterfuzzlite/Dockerfile, an import atheris line in a Python file, an import of fast-check in a TypeScript file).
One module per target in fuzz/, each with the properties it checks in its docstring. A target raises (an AssertionError or the unexpected exception) only when the code under test breaks its contract; libFuzzer reports that as a crash.
| Target | Code under test | Properties |
|---|---|---|
fuzz_bridge_protocol |
Camera source bridge protocol v1: agora_bridge.protocol (control lines, records) and the relay's reader camera_relay.adapters.media.bridge_protocol
|
only the protocol errors; the same records however the stream is split; both readers agree (differential); frames round-trip |
fuzz_relay_ticket |
Camera relay tickets (WHEP bearer tokens) | only InvalidTicketError; only tickets of the key verify, in exactly one spelling; signed claims round-trip until they expire |
fuzz_access_token |
mowdeck_auth.JwtVerifier, Identity's parse_api_token, parse_jwks
|
only TokenError for any string and any signed claims; one spelling per API token; a JWKS body raises only ValueError
|
fuzz_subjects |
NATS subject parsers of mowdeck_contracts.v1.subjects, OpenFGA references of mowdeck_authz.refs
|
only ValueError; parsed ids rebuild subjects that parse back and match the subscribers' wildcards; references are stable |
fuzz_contracts |
Wire models of mowdeck_contracts.v1 (Realtime client frames, command bodies and envelopes, MowerState, maps, spot plans, ...) |
only ValidationError; an accepted message survives a JSON round trip |
fuzz_polyline |
The spot plan polyline codec | only ValueError; canonical re-encoding; encoder and decoder agree on the range |
fuzz_vision_answer |
extract_json and AssessmentOut of the vision service, the proposal rules |
only the errors the provider handles; a drive maneuver is proposed only without risk flags, above the confidence threshold, within the joystick and time limits |
fuzz_open_meteo |
parse_forecast of the scheduler |
only the errors the provider turns into "weather unknown" |
fuzz_mammotion_report |
PyMammotion's decoding of LubaMsg frames, then the adapter's mapping (state_of, map_of, capabilities_of, mow_path_of) |
the mapping never raises on whatever device state the frames leave; positions within WGS84, battery within 0..100 % |
fuzz_patterns |
mowdeck_patterns (Area.of, plan, the spot plan contract) as the Planner calls it |
only PatternError; an accepted plan converts to SpotMowPlan and back and its preview builds |
fuzz/harness.py holds the shared helpers (standard library only): Input reads typed values from the fuzz bytes, so the targets need no Atheris API besides instrument_imports, Setup and Fuzz. Each target builds its seed corpus with the code it fuzzes (seeds()), and fuzz/json.dict is the libFuzzer dictionary of all of them: most parsing happens in C or Rust (json, pydantic-core) that Atheris cannot see, so the edge values (huge numbers, NaN, year 1 and 9999 timestamps, ...) come from the dictionary.
tools/repo-checks/tests/test_fuzz_targets.py keeps them honest in the normal test run: every target has the layout above and is listed on this page, and each one replays its seeds plus a few hundred mutated inputs with a stand-in for Atheris (so it runs everywhere, Windows included). A finding that was fixed gets a regression test next to the code and, where useful, stays covered by the seeds.
.clusterfuzzlite/ is the build: Dockerfile (the OSS-Fuzz Python builder pinned by digest, plus CPython 3.13 from uv because the workspace needs it; Atheris, PyInstaller and coverage come from requirements.txt with hashes), build.sh (installs the third-party dependencies of the fuzzed packages exactly as locked in uv.lock, builds one PyInstaller binary per target in parallel and zips its seeds) and project.yaml.
.github/workflows/fuzzing.yml runs it:
| Trigger | Mode | Budget |
|---|---|---|
| every pull request |
code-change: the targets the change can reach |
8 minutes over all targets |
| daily |
batch: grows the corpus |
1 hour |
| weekly (Sunday) |
prune (minimises the corpus), then coverage (report) |
10 minutes each |
A crash fails the run; the crashing input (crash-<sha1>) and the fuzzer output are uploaded as artifacts of the run, and the crash is uploaded to code scanning (SARIF). Corpora and coverage reports are kept as GitHub Actions artifacts (ClusterFuzzLite's default without a storage repository), so the setup needs no token or repository besides this one. workflow_dispatch starts a batch, prune or coverage run by hand.
To update the toolchain: edit .clusterfuzzlite/requirements.in and compile it again (Linux x86-64 wheels, as in the image):
uv pip compile .clusterfuzzlite/requirements.in --generate-hashes --python-version 3.13 \
--python-platform x86_64-manylinux_2_28 --no-header -o .clusterfuzzlite/requirements.txtThe builder image digest is bumped deliberately, like the other images; the uv image is the UV_IMAGE of infra/compose/.env.example (the repo check keeps them equal).
Docker on an x86-64 machine, from the repository root. Build the image and the targets (into the Docker volume cfl-out):
docker build -t mowdeck-cfl -f .clusterfuzzlite/Dockerfile .
docker run --rm -e SANITIZER=address -e FUZZING_LANGUAGE=python -e ARCHITECTURE=x86_64 \
-e FUZZING_ENGINE=libfuzzer -v cfl-out:/out mowdeck-cfl compileFuzz one target for five minutes with its seeds and dictionary, crashes into the volume cfl-crashes:
docker run --rm -e FUZZING_ENGINE=libfuzzer -e FUZZING_LANGUAGE=python -e SANITIZER=address \
-e RUN_FUZZER_MODE=interactive -v cfl-out:/out -v cfl-crashes:/crashes \
gcr.io/oss-fuzz-base/base-runner \
run_fuzzer fuzz_relay_ticket -max_total_time=300 -artifact_prefix=/crashes/Without Docker the targets still run as plain Python (no coverage guidance) through the repo check:
uv run pytest tools/repo-checks/tests/test_fuzz_targets.pyDownload the crash-<sha1> file from the run's artifacts (or take it from /crashes) and feed it to the target:
docker run --rm -e FUZZING_ENGINE=libfuzzer -e FUZZING_LANGUAGE=python -e SANITIZER=address \
-v cfl-out:/out -v "$PWD:/testcase" gcr.io/oss-fuzz-base/base-runner \
reproduce fuzz_relay_ticket -runs=10 /testcase/crash-<sha1>The Python traceback names the failing line. To debug it in the workspace instead, call the target's test_one_input with the file's bytes (atheris replaced as in the repo check), fix the code, add a regression test with the input next to the code's own tests, and keep the input as a seed when it covers a new path.
apps/web/src/**/*.property.test.ts use fast-check inside Vitest (pnpm --filter web test):
| Test | Properties |
|---|---|
lib/legal/markdown.property.test.ts |
the operator's legal Markdown always parses; every rendered link is a web, mail or phone link or stays on this origin (as the browser resolves it) |
lib/config.property.test.ts |
any /config.json parses without throwing; only http(s) tile URLs with the placeholders, zoom 1..22 and an http(s) source link come through |
lib/camera/whep-player.property.test.ts |
a root-relative WHEP Location stays on the page origin and gets the proxy prefix back |
lib/map/heatmap.property.test.ts |
the heat layer window never ends before it starts; the mower id stays inside its path segment; the preference guards accept only known values |
lib/realtime/backoff.property.test.ts |
the reconnect delay stays between zero and the capped exponential bound |
fast-check prints the seed and the shrunk counterexample of a failure; fc.assert(..., { seed, path }) replays exactly that case.
Mirrored from docs/dev/fuzzing.md at commit ab71dc67ed638e27a31b80a1db8629d5f7105ff3. Edit the file in the repository, not this wiki page.
Mirrored from the docs/ directory at commit ab71dc67ed638e27a31b80a1db8629d5f7105ff3. Edit the files in the repository, not this wiki.
MowDeck documentation
- Home
- Phases
- Architecture
-
Decisions (ADR)
- Architecture decisions
- ADR 0001: OpenFGA instead of self-built authorization
- ADR 0002: Self-built Identity service in FastAPI instead of Keycloak or Zitadel
- ADR 0003: SvelteKit for the web app
- ADR 0004: Monorepo with a uv workspace
- ADR 0005: AGPL-3.0 for services and apps, Apache-2.0 for libraries
- ADR 0006: PostgreSQL for all persistence, no SQLite
- ADR 0007: NATS JetStream as event bus
- ADR 0008: Traefik as gateway
- ADR 0009: Command and event envelopes, subject naming and de-duplication
- ADR 0010: Python 3.13 for all services and libraries
- ADR 0011: Separate GPL-3.0 package for the PyMammotion adapter
- ADR 0012: Mower control interface and safety rules
- ADR 0013: Device-Gateway registry, sharding and per-mower concurrency
- ADR 0014: Authorization model, tuple ownership and the mowdeck-authz client
- ADR 0015: Tokens, sessions and key rotation
- ADR 0016: Fleet commands and authorization per command kind
- ADR 0017: Telemetry storage and retention
- ADR 0018: Realtime WebSocket protocol
- ADR 0019: Mammotion adapter transports, manual drive and secrets
- ADR 0020: Web app architecture
- ADR 0021: Browser authentication flow
- ADR 0022: Notifications: rules, recipients and delivery
- ADR 0023: Map rendering and projection
- ADR 0024: Scheduler: recurrence, rain pause and command issuing
- ADR 0025: Home Assistant integration as a pure API client
- ADR 0026: Manual drive channel over the Realtime WebSocket
- ADR 0027: Health history and theft alarm
- ADR 0028: Entitlements: plans as data and enforcement
- ADR 0029: Helm chart and release pipeline
- ADR 0030: Mowing patterns as plug-ins and the Planner
- ADR 0031: Billing with a Stripe adapter
- ADR 0032: Live camera over Agora, joined directly by the browser
- ADR 0033: Vision-assisted recovery with human approval
- ADR 0034: Linked cloud accounts and device discovery
- ADR 0035: Server-side camera frames and automatic AI analysis on errors
- ADR 0036: Native app shells with Capacitor, loading the web app from the server
- ADR 0037: Camera panorama calibration in the browser, with the AI supplying points only
- ADR 0038: Dual link per mower and firmware updates
- ADR 0039: Spot mowing by MowDeck-driven paths
- ADR 0040: Link heartbeat for online status
- ADR 0041: Spot runs from the dock: undock, transit and return
- ADR 0042: Drive calibration
- ADR 0043: Turn styles
- ADR 0044: Drive view with camera
- ADR 0045: Camera relay
- ADR 0046: Device settings as capabilities
- ADR 0047: The RTK base station as a Fleet device
- ADR 0048: Maintenance commands
- Development
- Operations
- Spikes
- Guides
- Original plan (German)