mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-04 13:11:35 +02:00
#1558: cached-as-base push_status only forced gcode_state=IDLE while letting the real printer's live-progress fields (mc_percent, stg_cur, layer_num, ...) leak through. Bambu Studio's Send pre-flight read them as busy and refused. The cached branch now overrides the activity-field set the same way it already overrode storage indicators (#1228) and protocol fields. Same bundle ships a multi-round VP audit that found adjacent bugs in the same family: - #1558: cached branch zeroes mc_print_stage / mc_percent / mc_remaining_time / stg / stg_cur / layer_num / total_layer_num / print_error - MQTT auth: per-IP rate-limit (5/60s lockout), hmac.compare_digest, access_code redacted in DEBUG log - FTP cmd_STOR streams chunks to disk + 4 GiB cap (was buffering whole upload) - Sticky-keys allowlist extended with upgrade_state / xcam / hw_switch_state / nozzle_diameter / nozzle_type / online / ams_status - _pending_files cleanup in finally for archive / queue / dispatch handlers - _add_to_print_queue position uses MAX+1 (was hardcoded 1) - DELETE VP removes orphan PendingUpload rows + upload_dir from disk - Per-VP cert regenerates on shared-CA rotation (real signature verification, not DN match) - DHCP target-IP refresh + queue_force_color_match toggle now restart proxy VPs - Per-slicer bridge-response routing (multi-slicer cross-leak fix via sequence_id map) - Child-service readiness barrier (FTP / MQTT / Bind / SSDP) — no false is_running before sockets bind - H2D Pro O1E / O2D model codes added (experimental, needs field confirmation) - FTP passive port range widened 50000-51000; docker-compose + wiki updated - VP refresh_loop crash now unbinds raw_message_handler; tailscale catches asyncio.TimeoutError; SlicerProxyManager lifecycle hardening
144 lines
5.1 KiB
Python
144 lines
5.1 KiB
Python
"""Tests for the FTPSession.cmd_STOR streaming + size-cap behaviour.
|
||
|
||
The original cmd_STOR buffered the entire upload in a ``list[bytes]`` and
|
||
called ``write_bytes`` at the end. For multi-GB ``.gcode.3mf`` files this
|
||
peaked at ~2× the file size in RSS (chunks held + the ``b''.join`` of
|
||
them) and could OOM low-memory hosts. The streaming rewrite writes each
|
||
chunk to disk inline (memory bounded at one chunk) and enforces
|
||
``MAX_UPLOAD_BYTES``. These tests pin both behaviours without standing
|
||
up a real TLS/FTP server.
|
||
"""
|
||
|
||
import asyncio
|
||
import ssl
|
||
from unittest.mock import AsyncMock, MagicMock
|
||
|
||
import pytest
|
||
|
||
from backend.app.services.virtual_printer.ftp_server import MAX_UPLOAD_BYTES, FTPSession
|
||
|
||
|
||
def _make_session(tmp_path, *, data_chunks: list[bytes]) -> FTPSession:
|
||
"""Build an FTPSession primed with a pre-fed StreamReader so cmd_STOR
|
||
can iterate through the chunks without a real TCP connection.
|
||
"""
|
||
control_writer = MagicMock()
|
||
control_writer.write = MagicMock()
|
||
control_writer.drain = AsyncMock()
|
||
control_writer.get_extra_info = MagicMock(return_value=("192.168.1.99", 12345))
|
||
|
||
upload_dir = tmp_path / "uploads"
|
||
upload_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
session = FTPSession(
|
||
reader=asyncio.StreamReader(),
|
||
writer=control_writer,
|
||
upload_dir=upload_dir,
|
||
access_code="deadbeef",
|
||
ssl_context=ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER),
|
||
on_file_received=None,
|
||
bind_address="127.0.0.1",
|
||
vp_name="stor-test",
|
||
)
|
||
session.authenticated = True
|
||
|
||
data_reader = asyncio.StreamReader()
|
||
for chunk in data_chunks:
|
||
data_reader.feed_data(chunk)
|
||
data_reader.feed_eof()
|
||
session._data_reader = data_reader
|
||
|
||
data_writer = MagicMock()
|
||
data_writer.close = MagicMock()
|
||
data_writer.wait_closed = AsyncMock()
|
||
session._data_writer = data_writer
|
||
|
||
session._data_connected.set()
|
||
session.data_server = None
|
||
|
||
return session
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_stor_writes_payload_to_disk(tmp_path):
|
||
"""Happy path: chunks fed to the data reader land in the upload_dir
|
||
with the right content + the slicer gets 226."""
|
||
payload = b"X" * (3 * 64 * 1024 + 123) # 3 chunks + a partial one
|
||
chunks = [payload[i : i + 65536] for i in range(0, len(payload), 65536)]
|
||
session = _make_session(tmp_path, data_chunks=chunks)
|
||
session.send = AsyncMock()
|
||
|
||
await session.cmd_STOR("Untitled.gcode.3mf")
|
||
|
||
saved = session.upload_dir / "Untitled.gcode.3mf"
|
||
assert saved.exists()
|
||
assert saved.stat().st_size == len(payload)
|
||
assert saved.read_bytes() == payload
|
||
|
||
sent_codes = [args[0][0] for args in session.send.call_args_list]
|
||
assert 150 in sent_codes # "Opening data connection"
|
||
assert 226 in sent_codes # "Transfer complete"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_stor_rejects_upload_over_max_upload_bytes(tmp_path, monkeypatch):
|
||
"""A single chunk taking us over the cap must abort with 426 and
|
||
drop the partially-written file so it doesn't masquerade as a
|
||
successful upload."""
|
||
# Lower the cap to 100 KiB so the test doesn't need to allocate
|
||
# 4 GiB to trigger it. The same logic governs the production cap.
|
||
monkeypatch.setattr(
|
||
"backend.app.services.virtual_printer.ftp_server.MAX_UPLOAD_BYTES",
|
||
100 * 1024,
|
||
)
|
||
|
||
over_cap = b"X" * (200 * 1024) # 200 KiB > 100 KiB cap
|
||
session = _make_session(tmp_path, data_chunks=[over_cap])
|
||
session.send = AsyncMock()
|
||
|
||
await session.cmd_STOR("toobig.gcode.3mf")
|
||
|
||
# Partial file must be unlinked.
|
||
assert not (session.upload_dir / "toobig.gcode.3mf").exists()
|
||
# 426 (transfer failed) sent — not 226.
|
||
sent_codes = [args[0][0] for args in session.send.call_args_list]
|
||
assert 426 in sent_codes
|
||
assert 226 not in sent_codes
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_stor_cleans_up_partial_file_on_read_error(tmp_path):
|
||
"""If the data channel raises mid-transfer (slicer RST, TLS error,
|
||
timeout, …), the partial file on disk must be removed so the next
|
||
upload of the same name starts clean and the user doesn't see a
|
||
truncated file in the upload_dir."""
|
||
payload = b"X" * 65536 # one full chunk
|
||
session = _make_session(tmp_path, data_chunks=[payload])
|
||
session.send = AsyncMock()
|
||
|
||
# Inject an OSError on the NEXT read after the first chunk.
|
||
orig_read = session._data_reader.read
|
||
state = {"calls": 0}
|
||
|
||
async def read_then_error(n):
|
||
state["calls"] += 1
|
||
if state["calls"] == 1:
|
||
return await orig_read(n)
|
||
raise OSError("simulated connection reset")
|
||
|
||
session._data_reader.read = read_then_error # type: ignore[assignment]
|
||
|
||
await session.cmd_STOR("aborted.gcode.3mf")
|
||
|
||
# Partial file removed.
|
||
assert not (session.upload_dir / "aborted.gcode.3mf").exists()
|
||
sent_codes = [args[0][0] for args in session.send.call_args_list]
|
||
assert 426 in sent_codes
|
||
|
||
|
||
def test_max_upload_bytes_is_at_least_4_gib():
|
||
"""The cap exists to prevent OOM, but should be high enough that
|
||
legitimate multi-plate .gcode.3mf uploads (~hundreds of MB) succeed
|
||
without bumping up against it. 4 GiB is the documented floor."""
|
||
assert MAX_UPLOAD_BYTES >= 4 * 1024 * 1024 * 1024
|