Skip to content

Audit fixes 2026-07-02: SIM-1..7 - #6

Merged
navado merged 8 commits into
mainfrom
audit/2026-07-02
Jul 2, 2026
Merged

Audit fixes 2026-07-02: SIM-1..7#6
navado merged 8 commits into
mainfrom
audit/2026-07-02

Conversation

@navado

@navado navado commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator

Summary

Fixes for findings SIM-1 through SIM-7 from the 2026-07-02 codebase audit
(docs/audit-2026-07-02/specs/simulator.md). One commit per finding (SIM-3
landed as two commits: ruff config+fixes, then mypy).

  • SIM-1 (high): zip() without strict= at 3 sites could silently
    misalign OpenTopoData elevation results with requested (lat,lon) cells,
    corrupting the persisted geogrid depth cache used for grounding/routing.
    Added strict=True everywhere, made the geogrid writes atomic (materialize
    the zip into a list before writing, so a mismatch can't leave a
    partially-written cache), and added regression tests for both the sync
    sample() and async fetch_loop() paths.
  • SIM-2 (medium): SignalKWriter.connect()'s auth login used
    urllib.request.urlopen(..., timeout=10) synchronously inside an async def, freezing the event loop for up to 10s. Migrated to httpx.AsyncClient,
    preserving the timeout and raise-on-failure semantics. Added a regression
    test mocking httpx's transport + websockets.connect.
  • SIM-3 (medium): ruff only ran the default E4,E7,E9,F rule set (no
    E501, no B905). Broadened to select = ["E","F","B","UP","SIM","C4"] and
    fixed the ~79 resulting violations. Added mypy (non-strict start) to dev
    deps + a CI step, added a py.typed marker + [tool.mypy] config for the
    src/ layout, and fixed the 12 surfaced type errors.
  • SIM-4 (low): deleted the dead Route.load_depth_profile() /
    _fetch_depth_profile() / Route.depth_at() prefetch cluster (zero
    production callers — GeoGrid supersedes it), its test file, and the
    orphaned resources.depth_cache_path().
  • SIM-5 (low): added Settings.warn_if_insecure_credentials() — a loud
    startup WARNING when signalk_password is still "admin" and
    signalk_host != "localhost", wired into Settings.from_env() and
    Controller.apply_config(). Added tests for all four combinations.
  • SIM-6 (low): the docker-smoke CI job now asserts the container is
    actually running (docker inspect ... State.Running) and reached steady
    state ([sink] active: marker in docker logs) instead of just sleeping
    and printing a message.
  • SIM-7 (low): committed uv.lock (33 packages) and switched the CI
    test job to uv sync --frozen + uv run for ruff/mypy/pytest, plus
    uv build --wheel. Documented the uv-based dev setup in CONTRIBUTING.md.

Verification

pytest -q          → 192 passed (186 baseline + 8 new regression tests,
                      -2 removed with the SIM-4 dead-code test file)
ruff check .        → All checks passed! (under the broadened SIM-3 ruleset)
mypy                → Success: no issues found in 53 source files
uv sync --frozen --extra dev   → installs cleanly from uv.lock
uv build --wheel    → builds successfully

Docker-smoke logic was verified locally by running the actual CLI
(yey-boats-sim --sink stdout --no-failover) outside Docker and confirming
the [sink] active: stdout marker appears ~2s after start, well within the
job's 45s window — the sandboxed environment doesn't have a running Docker
daemon to exercise the CI job's docker build/docker run end-to-end.

Test plan

  • pytest -q — 192/192 passing
  • ruff check . — clean under broadened ruleset
  • mypy — clean (non-strict start)
  • uv sync --frozen --extra dev / uv build --wheel — verified locally
  • CI green on this PR (docker-smoke job specifically, since it couldn't
    be exercised locally without a Docker daemon)

navado added 8 commits July 2, 2026 15:08
Bare zip() at geogrid.py fetch_loop()/sample() and route.py's
_fetch_depth_profile() paired external OpenTopoData results with
requested points positionally, with no guard against a
partial/malformed API response. A length mismatch would silently
misalign results instead of raising, and the misaligned depths get
persisted into the geogrid cache used for grounding/routing checks.

Add strict=True at all zip() sites (plus test_heading_wander.py for
consistency) so a mismatch raises loudly instead of corrupting data.
In geogrid.py, materialize zip(..., strict=True) into a list before
writing to self._elev so a mismatch is caught before any partial
write, keeping the cache fully untouched on failure rather than
half-populated.

Add regression tests covering both the sync sample() path and the
async fetch_loop() path with a short fetcher response, asserting the
cache stays empty and misses stay queued for retry.
…yncClient

SignalKWriter.connect() (reached via SignalKSink.open() -> SinkChain.open()
-> runner.py) called urllib.request.urlopen(req, timeout=10) synchronously
inside an async coroutine, freezing the event loop for up to 10s during the
auth login POST. The rest of the codebase avoids blocking I/O in async
paths (geogrid.py uses asyncio.to_thread; get_self_position() below already
uses httpx.AsyncClient).

Migrate the login POST to httpx.AsyncClient, preserving the 10s timeout and
raise-on-failure semantics (non-2xx now raises httpx.HTTPStatusError instead
of urllib.error.HTTPError; callers already catch broad Exception). Drop the
now-unused urllib.request import.

Add a regression test mocking httpx's transport + websockets.connect to
verify connect() performs the login via httpx and never touches urllib.
pyproject.toml only set target-version/line-length, so ruff ran the
default E4,E7,E9,F rule set — E501 (line-too-long) was never enforced
despite a configured 100-char limit, and B905 (zip without strict=,
the SIM-1 bug class) wasn't caught either.

Add `[tool.ruff.lint] select = ["E","F","B","UP","SIM","C4"]` and fix
the ~79 resulting violations:
- `ruff check --fix` handled UP017 (datetime.UTC alias), UP035/UP006/
  UP045/UP041/UP037/UP034 (typing-import/annotation modernization),
  SIM114 (merge if/elif arms with identical bodies), and the earlier
  B905 hit already fixed by SIM-1.
- Manual fixes: E501 line wraps across metadata tables, config.py,
  runner.py, engine.py, etc.; SIM105 try/except/pass -> contextlib.
  suppress() in control.py, sinks/signalk.py, signalk_writer.py (x2),
  test_geogrid.py; SIM108 if/else -> ternary in synthetic_ais.py;
  SIM102 nested-if collapse in web/api.py; the remaining B905 in
  passage.py's `zip(leg, leg[1:])` pairwise walk switched to
  itertools.pairwise (strict=True is wrong there since the two
  sequences are intentionally offset by one).

ruff check . is now clean under the broadened ruleset; full suite
(190 tests) still green.
Second half of SIM-3: no static type checking existed despite the
codebase having full type-hint coverage. Add mypy>=1.10 to dev deps
and a `mypy` CI step (after ruff, before pytest).

Config (non-strict start, per spec): mypy_path=src, explicit
packages/package-bases + a py.typed marker for the src/ layout
(yey.boats.simulator sits under implicit-namespace parents yey/,
yey.boats/), ignore_missing_imports=true. Tighten iteratively later.

Fix the 12 errors mypy surfaced:
- config.py: rename shadowed loop variable (k, v reused across two
  for-loops made mypy infer a too-narrow type for the second use).
- web/api.py: explicit dict[str, object]/dict[str, str] annotations
  for changes/errors (were inferred dict[str, str] from first use,
  rejecting the later int/bool values).
- geogrid.py: assert self._cache_path is not None in _load() (only
  called when set); float() the already-None-checked corner values
  before the bilinear interpolation so mypy gets concrete floats.
- signalk_writer.py: annotate self._ws: Any (websockets' connect()
  return type varies; was inferred as None-only from __init__).
- runner.py: annotate ais_source: Any (AISStreamSource/
  SyntheticAISSource are duck-typed, no shared base); assert
  writer.token is not None before use (true by this point — set by
  connect(), which the caller already awaited).
- signalk_command.py: narrow token: str | None -> str (the only
  caller passes a non-None token by the time it gets here).

mypy is green (0 errors); ruff still clean; full suite (190 tests)
still passing.
Route.load_depth_profile()/_fetch_depth_profile() (per-leg OpenTopoData
prefetch into a JSON cache) and Route.depth_at() (its reader) have zero
production call sites — no runner/engine code calls load_depth_profile,
and GeoGrid (lazy per-tick fetch + persistent cache) supersedes this
whole path for depth-based routing/grounding checks. The only consumer
was tests/test_route_depth.py.

Confirmed via repo-wide grep before deleting: no other module reads
Route._depth_profile or calls these methods.

Delete Route.load_depth_profile(), Route.depth_at(), the module-level
_fetch_depth_profile() helper, the now-unused _depth_profile dataclass
field (and the sys/httpx/field imports that only served this cluster),
tests/test_route_depth.py, resources.depth_cache_path() (orphaned with
it — path helper for the same dead cache), and its lone test in
test_resources.py.

Suite green (188 tests, down from 190 — the 2 removed depth-profile
tests); ruff and mypy still clean.
…lhost

config.py defaults signalk_username/password to admin/admin (fine for
localhost dev), but the GHCR publish pipeline makes shared/lab
deployments plausible, and a stale default password would go
unnoticed there.

Add Settings.warn_if_insecure_credentials(): prints a startup WARNING
to stderr when signalk_password is still "admin" and signalk_host is
not "localhost", pointing the operator at SIGNALK_PASSWORD. Wired into
Settings.from_env() (the actual startup path, via cli.build_settings)
and Controller.apply_config() (live web-admin reconfigure can also
point at a non-localhost host).

Add tests covering: warns for non-localhost+default password, silent
for localhost+default, silent for non-localhost+custom password, and
warns via the from_env() startup path.
The job started the container detached (&), slept 45s, and printed a
message — it asserted nothing, so a container that crash-looped or
exited during startup still showed a green check.

Run the container with `docker run -d` (capturing its id) instead of
backgrounding via &, then after the 45s settle:
- fail unless `docker inspect -f '{{.State.Running}}'` reports true
- fail unless `docker logs` contains "[sink] active:" — the marker
  SinkChain.open() prints once a sink is live (adapters/failover.py),
  verified locally (venv run) to appear ~2s after start, well within
  the existing 45s window, followed by steady per-tick output.

Always dump `docker logs` first for debugging, and clean up the
container on both the success and failure paths.
All deps were >=-only with no lockfile, so CI/dev reproducibility
depended on whatever resolved at install time.

Generate and commit uv.lock (uv lock; 33 packages resolved). Switch
the CI `test` job to astral-sh/setup-uv + `uv sync --frozen --extra
dev`, running ruff/mypy/pytest via `uv run` and the wheel build via
`uv build --wheel` (replacing `pip install build && python -m build`).
pyproject.toml dependency bounds stay loose for wheel consumers per
spec — only CI/dev installs are pinned via the lockfile.

Verified locally: `uv sync --frozen --extra dev` installs cleanly from
the lockfile, `uv run pytest -q` (192 passed), `uv run ruff check .`
and `uv run mypy` both clean, `uv build --wheel` succeeds.

Document the uv-based dev setup in CONTRIBUTING.md (uv sync --frozen
as the preferred path, plain pip as a fallback, `uv lock` to
regenerate after a pyproject.toml dependency change).
@navado
navado merged commit 969ebce into main Jul 2, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant