fix: bound the logo download, and stop malformed input reading as a fault

Review findings on the coverage branch.

The download size cap I added checked len(response.content), which has
already buffered the whole body -- it stopped the bytes reaching disk but
not memory, which was the point. A server that omits Content-Length and
never stops sending would still exhaust the process. Stream it instead,
counting as it arrives, into a sibling .part file that is replaced over
the target only once it decodes. A transfer that dies midway now leaves
nothing behind rather than a truncated logo for load_logo() to cache.

The follower's control-message handler caught three exception types, but
two reachable UDP payloads raise others: a bare JSON scalar makes
msg.get() raise AttributeError, and an "sx" carrying a non-numeric x
raises ValueError or TypeError from float(). Those escaped to the outer
handler, skipping the legacy-PNG fallback and -- since this branch added
a backoff there -- charging one malformed packet a 0.1s stall on the
receive path. The legacy-PNG path also decoded without the dimension cap
its TCP counterpart applies, so a crafted 65KB frame could force a large
allocation on the render thread; both paths now share one constant.

Three repo_url handlers called .strip() on client input without checking
it was a string, so {"repo_url": 12345} answered 500. The credentials
upload parsed the same file twice, the second time inside a bare except
that a preceding parse had already made unreachable. And both raw-config
handlers kept a json.JSONDecodeError arm that get_json(silent=True) had
turned into dead code, collapsing "sent something unparseable" into "sent
nothing" -- they now say which.

Two of the new tests were not testing what they claimed. The pruning
round-trip wrote ten backups inside one second, so all ten landed on the
same int(time.time()) filename and overwrote each other; it never reached
the limit it asserted. And the sync clock helper patched attributes on the
stdlib time module, freezing time process-wide for every daemon thread
earlier tests had left running.

Full suite: 3352 passed, coverage 54%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh
This commit is contained in:
Claude
2026-08-14 13:01:29 +00:00
parent 461de4ce90
commit e87797e997
8 changed files with 326 additions and 82 deletions
+18 -6
View File
@@ -22,6 +22,7 @@ import stat
import sys
import time
from pathlib import Path
from types import SimpleNamespace
import pytest
@@ -192,14 +193,25 @@ class TestBackupPruning:
upload(api_v3_client, VALID_CREDENTIALS)
assert len(backups(plugin_dir)) == 3 # 2 seeded + 1 new
def test_repeated_uploads_stay_bounded(self, api_v3_client, plugin_dir):
def test_repeated_uploads_stay_bounded(
self, api_v3_client, plugin_dir, api_v3_module, monkeypatch):
# The backup filename carries int(time.time()), so uploads inside
# the same second all write the same name and overwrite each other.
# Advance a fake clock a second per round — otherwise this never
# reaches six backups and the bound holds for the wrong reason.
clock = {"now": int(time.time())}
monkeypatch.setattr(
api_v3_module, "time", SimpleNamespace(time=lambda: clock["now"]))
for i in range(10):
clock["now"] += 1
upload(api_v3_client, {"installed": {"round": i}})
# Distinct mtimes so ordering is well-defined between rounds.
for path in backups(plugin_dir):
os.utime(path, (path.stat().st_mtime, path.stat().st_mtime))
time.sleep(0.01)
assert len(backups(plugin_dir)) <= 5
os.utime(plugin_dir / "credentials.json",
(clock["now"], clock["now"]))
remaining = backups(plugin_dir)
assert len(remaining) == 5
# And they are the five most recent rounds, not an arbitrary five.
kept = sorted(int(p.name.rsplit(".", 1)[1]) for p in remaining)
assert kept == [clock["now"] - 4 + i for i in range(5)]
def test_unremovable_backup_does_not_fail_the_upload(
self, api_v3_client, plugin_dir, monkeypatch):