Skip to content

Reduce cognitive complexity in three handlers (group 8, part 2) - #311

Merged
karoltheguy merged 5 commits into
mainfrom
fix/reduce-cognitive-complexity
Aug 4, 2026
Merged

Reduce cognitive complexity in three handlers (group 8, part 2)#311
karoltheguy merged 5 commits into
mainfrom
fix/reduce-cognitive-complexity

Conversation

@karoltheguy

Copy link
Copy Markdown
Owner

Sixth PR in the SonarCloud cleanup series, completing group 8 alongside #310. Clears 3 of the 6 python:S3776 findings. The other 3 are deliberately left open with reasons below.

This is the first PR in the series that changes live control flow rather than adding attributes, constants or types, so the emphasis throughout is on proving nothing moved.

api/sockets.py, two WebSocket handlers, complexity 18 each

_reject_invalid_name(): both handlers opened with an identical validate-and-reject block differing only in regex, log wording and error text. One shared helper deduplicates it rather than relocating it twice. The log text is preserved exactly, !r formatting included, by passing the field label through.

_build_journalctl_command(): lifts the shlex quoting and the since/scope branching out. Since this builds a command string that runs on a remote host, it was checked exhaustively rather than by eye. 96 combinations of unit name, scope and since, covering unknown since values, unrecognised scopes, and unit names requiring shell quoting, compared against HEAD's logic. 0 mismatches.

_cleanup_terminal(): takes the finally block's nested cancel/kill/wait. Every except Exception: pass is preserved deliberately, because cleanup must not mask the original error. Order is unchanged, and manager.disconnect() plus its log stay in the caller's finally.

services/stats_engine.py fetch_server_stats, complexity 18

Depth was the problem: for server into try into if scope_filter into for scope_label into for c into if c["name"] not in seen. Split into _scope_stat_tasks(), _merge_scope_results() and _build_unit_rows(), with code moved verbatim.

The trap here is precedence. if c["name"] not in seen means the first scope reporting a container name wins, and user scope is appended first, so a user container shadows a global one with the same name. Rewriting that merge as a dict comprehension or dict.update would make the last writer win and silently invert it, with no test necessarily catching it. Verified directly:

duplicate 'web' resolved to cpu = 1 -> USER wins (correct)
both scopes kept independently: ['global', 'user']
scope_filter='both' -> ['user', 'global']   'user' -> ['user']   'global' -> ['global']

The ordering check matters too, since the labels are zipped against asyncio.gather results, so a reordering would mislabel every scope.

Verification

  • -m unit: 939 passed, 3 skipped, unchanged
  • unmarked: 47 passed, unchanged
  • socket, terminal and stats focused: 134 passed, 27 skipped
  • pylint -e W0613: no unused arguments on the new helpers
  • the two differential checks described above

Coverage is what made these three safe to touch. api/sockets.py sits at 99% and services/stats_engine.py at 98%, so the existing suite genuinely exercises the paths being restructured.

The three left open, and why

sync_engine.py check_quadlets (complexity 25, the largest in the group) and _fetch_mtimes. Open bug #283 reports that check_quadlets iterates the quadlets table and that table is never populated. Confirmed: the only INSERT INTO quadlets anywhere in the repo is in test files. The function is dead in production.

That is not just an argument about wasted effort. Its uncovered branches are uncoverable by construction while the work set is always empty, so no test can protect a refactor of the most complex function in the group. Whoever fixes #283 restructures it anyway. Refactoring it now would be unprotected churn on code that is about to be rewritten.

scripts/import_servers.py. Not referenced by Dockerfile or start.sh, so not shipped, and not covered. Restructuring untested, unshipped code to satisfy a metric is risk without benefit.

Clears 3 of the 6 python:S3776 findings by extracting helpers, with no
behavior change. The other 3 are deliberately not fixed; see below.

api/sockets.py, both WebSocket handlers (complexity 18 each):

* Both opened with an identical validate-and-reject block differing only
  in regex and wording, so _reject_invalid_name() deduplicates rather than
  merely relocating. Log text is preserved exactly, including the !r
  formatting, by passing the field label through.
* _build_journalctl_command() takes the shlex quoting and the since/scope
  branching out of the handler. Verified against HEAD's logic across 96
  combinations of unit name, scope and since, including unknown since
  values and names needing quoting: 0 mismatches.
* _cleanup_terminal() takes the finally block's nested cancel/kill/wait.
  Every `except Exception: pass` is kept: cleanup must not mask the
  original error. Order is unchanged, and disconnect plus its log stay in
  the caller's finally.

services/stats_engine.py fetch_server_stats (complexity 18): the depth came
from a for/try/if/for/for/if chain. _scope_stat_tasks(), _merge_scope_results()
and _build_unit_rows() flatten it. Code moved verbatim.

The risk in that one is precedence: `if c["name"] not in seen` means the
first scope reporting a name wins, and user scope is appended first, so a
user container shadows a global one of the same name. A dict comprehension
or dict.update would silently invert that. Verified directly: with the same
container name in both scopes, the user entry wins, both scopes' unit states
survive independently, and scope_filter still maps to the same labels.

Not fixed, on purpose:
* sync_engine.py check_quadlets (complexity 25) and _fetch_mtimes. Bug #283
  reports check_quadlets iterates the quadlets table, and the only
  INSERT INTO quadlets in the repo is in test files, so it is dead in
  production. Its uncovered branches are uncoverable while the work set is
  always empty, so no test can protect a refactor of the most complex
  function here. Fixing #283 rewrites it anyway.
* scripts/import_servers.py, a one-off script not referenced by Dockerfile
  or start.sh and not covered.

Unit 939 and unmarked 47, both unchanged. pylint reports no unused
arguments on the new helpers.
@codacy-production

codacy-production Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 4 complexity · 0 duplication

Metric Results
Complexity 4
Duplication 0

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

@karoltheguy

Copy link
Copy Markdown
Owner Author

The SonarCloud gate is red on one finding, pythonsecurity:S5145 at api/sockets.py:58, "Change this code to not log user-controlled data". Leaving it, for two reasons that took checking rather than assuming, since it is a security finding.

It is pre-existing, and this PR reduces it

S5145 has 10 open findings on main, including api/sockets.py:53 and :198. Those two are exactly the logger.warning(f"Rejected invalid ...") lines that this PR consolidates into the shared _reject_invalid_name() helper.

So nothing new is introduced. The count goes from 10 to 9. It reports as new only because the consolidated line is new code, the same inherit-the-debt effect that made #304's gate red and that surfaced again on #310.

The !r already blocks the attack

The concrete risk behind S5145 is log forging: a crafted value containing a newline injects a fake log entry. The existing code interpolates with !r, and repr() escapes control characters:

plain interpolation -> output spans 2 log lines (forged entry injected)
with !r             -> output spans 1 log line

The helper preserves that !r exactly, so behavior is unchanged and the attack stays blocked.

Why not fix it here anyway

This is a pure refactor PR whose entire claim is that nothing moved. Reworking a logging call to satisfy the analyzer would be a behavior change smuggled in under that claim, and it would address 1 of 10 sites while leaving the pattern inconsistent.

Filed as #312 instead, which covers all 10 sites. The real problem it identifies is that the !r protection is load-bearing but undocumented and untested: anyone dropping it while tidying an f-string would silently reintroduce log injection with nothing to catch it.

Note the five required checks (unit, unmarked, integration, e2e, Build and Push Docker Image) all pass, so this does not block merge.

karoltheguy and others added 4 commits August 3, 2026 19:27
Log statements in the WebSocket handlers interpolated values taken
straight off the socket (unit_name, container_name, resize dimensions),
so a caller could embed a newline and forge log entries.

Add a _log_safe() helper that escapes control characters via
unicode_escape and route every affected log statement through it.
Escaping rather than stripping keeps the exact input visible, which is
what matters when debugging a rejected name.

Two related fixes in the same area:

- The name allowlists used $, which also matches just before a trailing
  newline, so "myapp\n" passed validation. Switched to \Z. This matters
  beyond logging, since container_name is interpolated into the podman
  exec command unquoted and relies entirely on that pattern.
- exec_terminal_over_websocket logged container_name before validating
  it. Moved the log after the check, matching the log stream handler.
RUF013: `since` is genuinely optional, so annotate it `str | None`. The
propagated annotation on `_build_journalctl_command` follows, since that
helper's `-n 100` branch exists precisely to handle the None case. Guarding
the `_SINCE_PHRASES.get()` lookup keeps the dict's `str` key type honest
without changing behaviour: `.get(None)` already returned None.

BLE001 at the invalid-name rejection: the broad catch is deliberate and
narrowing it would be a regression, because `manager.disconnect()` runs
after the try block, so any unnamed exception type would propagate and leak
the socket into `active_connections`. What was actually wrong is that the
handler was silent. It now logs at debug and carries a `# noqa` with the
rationale, matching the convention already used in tests/podman/conftest.py.
…LE001)

This function was extracted from an inline finally block by the complexity
refactor, so its three broad catches read as new code to the analyzers even
though the behaviour predates this PR.

Narrowing them is not the right fix: teardown must never mask the error that
caused teardown, which is what the docstring already says. The defect is that
all three failed silently, so a leaked remote process or an unreaped exit left
no trace. Each now logs at debug and carries the rationale inline.
@sonarqubecloud

sonarqubecloud Bot commented Aug 4, 2026

Copy link
Copy Markdown

@karoltheguy
karoltheguy merged commit 2bdf188 into main Aug 4, 2026
15 checks passed
@karoltheguy
karoltheguy deleted the fix/reduce-cognitive-complexity branch August 4, 2026 04:42
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.

External modification detection never runs: check_quadlets() polls an always-empty table

1 participant