test_vp_ftp_stor.py 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210
  1. """Tests for the FTPSession.cmd_STOR streaming + size-cap behaviour.
  2. The original cmd_STOR buffered the entire upload in a ``list[bytes]`` and
  3. called ``write_bytes`` at the end. For multi-GB ``.gcode.3mf`` files this
  4. peaked at ~2× the file size in RSS (chunks held + the ``b''.join`` of
  5. them) and could OOM low-memory hosts. The streaming rewrite writes each
  6. chunk to disk inline (memory bounded at one chunk) and enforces
  7. ``MAX_UPLOAD_BYTES``. These tests pin both behaviours without standing
  8. up a real TLS/FTP server.
  9. """
  10. import asyncio
  11. import io
  12. import ssl
  13. import zipfile
  14. from unittest.mock import AsyncMock, MagicMock
  15. import pytest
  16. from backend.app.services.virtual_printer.ftp_server import MAX_UPLOAD_BYTES, FTPSession
  17. def _valid_3mf_bytes() -> bytes:
  18. """A minimal but structurally valid ZIP (stands in for a .gcode.3mf).
  19. Bambu 3MF files are ZIP containers; the streaming STOR path validates the
  20. received file opens as a ZIP before acking 226 (#1896), so happy-path
  21. tests must feed real ZIP bytes rather than arbitrary filler.
  22. """
  23. buf = io.BytesIO()
  24. with zipfile.ZipFile(buf, "w") as zf:
  25. zf.writestr("Metadata/slice_info.config", "<config/>")
  26. zf.writestr("3D/3dmodel.model", "<model/>")
  27. # Pad an entry so the archive spans several 64 KiB read chunks.
  28. zf.writestr("plate_1.gcode", b"G1 X0 Y0\n" * 40000)
  29. return buf.getvalue()
  30. def _make_session(tmp_path, *, data_chunks: list[bytes]) -> FTPSession:
  31. """Build an FTPSession primed with a pre-fed StreamReader so cmd_STOR
  32. can iterate through the chunks without a real TCP connection.
  33. """
  34. control_writer = MagicMock()
  35. control_writer.write = MagicMock()
  36. control_writer.drain = AsyncMock()
  37. control_writer.get_extra_info = MagicMock(return_value=("192.168.1.99", 12345))
  38. upload_dir = tmp_path / "uploads"
  39. upload_dir.mkdir(parents=True, exist_ok=True)
  40. session = FTPSession(
  41. reader=asyncio.StreamReader(),
  42. writer=control_writer,
  43. upload_dir=upload_dir,
  44. access_code="deadbeef",
  45. ssl_context=ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER),
  46. on_file_received=None,
  47. bind_address="127.0.0.1",
  48. vp_name="stor-test",
  49. )
  50. session.authenticated = True
  51. data_reader = asyncio.StreamReader()
  52. for chunk in data_chunks:
  53. data_reader.feed_data(chunk)
  54. data_reader.feed_eof()
  55. session._data_reader = data_reader
  56. data_writer = MagicMock()
  57. data_writer.close = MagicMock()
  58. data_writer.wait_closed = AsyncMock()
  59. session._data_writer = data_writer
  60. session._data_connected.set()
  61. session.data_server = None
  62. return session
  63. @pytest.mark.asyncio
  64. async def test_stor_writes_payload_to_disk(tmp_path):
  65. """Happy path: chunks fed to the data reader land in the upload_dir
  66. with the right content + the slicer gets 226."""
  67. payload = _valid_3mf_bytes() # spans several 64 KiB chunks, opens as ZIP
  68. chunks = [payload[i : i + 65536] for i in range(0, len(payload), 65536)]
  69. assert len(chunks) > 3 # exercise the multi-chunk read loop
  70. session = _make_session(tmp_path, data_chunks=chunks)
  71. session.send = AsyncMock()
  72. await session.cmd_STOR("Untitled.gcode.3mf")
  73. saved = session.upload_dir / "Untitled.gcode.3mf"
  74. assert saved.exists()
  75. assert saved.stat().st_size == len(payload)
  76. assert saved.read_bytes() == payload
  77. sent_codes = [args[0][0] for args in session.send.call_args_list]
  78. assert 150 in sent_codes # "Opening data connection"
  79. assert 226 in sent_codes # "Transfer complete"
  80. assert 426 not in sent_codes
  81. @pytest.mark.asyncio
  82. async def test_stor_rejects_truncated_3mf(tmp_path):
  83. """#1896: a .3mf whose tail was lost (uvloop ragged-EOF data loss, or any
  84. other silent truncation) must NOT be acked with 226 — the read loop sees a
  85. clean EOF and no write error, so only a ZIP-integrity check catches it.
  86. Reject with 426, drop the file, and never fire the on_file_received
  87. callback that would archive/queue/forward the corrupt job."""
  88. payload = _valid_3mf_bytes()
  89. truncated = payload[: len(payload) - 4096] # drop the EOCD-bearing tail
  90. chunks = [truncated[i : i + 65536] for i in range(0, len(truncated), 65536)]
  91. callback = AsyncMock()
  92. session = _make_session(tmp_path, data_chunks=chunks)
  93. session.on_file_received = callback
  94. session.send = AsyncMock()
  95. await session.cmd_STOR("truncated.gcode.3mf")
  96. # Corrupt file dropped, not left in the upload dir.
  97. assert not (session.upload_dir / "truncated.gcode.3mf").exists()
  98. sent_codes = [args[0][0] for args in session.send.call_args_list]
  99. assert 426 in sent_codes
  100. assert 226 not in sent_codes
  101. # The archive/queue/forward callback must never run for a corrupt upload.
  102. callback.assert_not_called()
  103. @pytest.mark.asyncio
  104. async def test_stor_skips_zip_validation_for_non_3mf(tmp_path):
  105. """The ZIP-integrity gate is scoped to .3mf uploads. A non-3MF file (e.g.
  106. a plain .gcode some slicers still send) is not a ZIP and must keep the
  107. prior pass-through behaviour — 226, not a false-positive 426."""
  108. payload = b"G1 X0 Y0\n" * 5000 # plain text, deliberately not a ZIP
  109. chunks = [payload[i : i + 65536] for i in range(0, len(payload), 65536)]
  110. session = _make_session(tmp_path, data_chunks=chunks)
  111. session.send = AsyncMock()
  112. await session.cmd_STOR("plain.gcode")
  113. saved = session.upload_dir / "plain.gcode"
  114. assert saved.exists()
  115. assert saved.read_bytes() == payload
  116. sent_codes = [args[0][0] for args in session.send.call_args_list]
  117. assert 226 in sent_codes
  118. assert 426 not in sent_codes
  119. @pytest.mark.asyncio
  120. async def test_stor_rejects_upload_over_max_upload_bytes(tmp_path, monkeypatch):
  121. """A single chunk taking us over the cap must abort with 426 and
  122. drop the partially-written file so it doesn't masquerade as a
  123. successful upload."""
  124. # Lower the cap to 100 KiB so the test doesn't need to allocate
  125. # 4 GiB to trigger it. The same logic governs the production cap.
  126. monkeypatch.setattr(
  127. "backend.app.services.virtual_printer.ftp_server.MAX_UPLOAD_BYTES",
  128. 100 * 1024,
  129. )
  130. over_cap = b"X" * (200 * 1024) # 200 KiB > 100 KiB cap
  131. session = _make_session(tmp_path, data_chunks=[over_cap])
  132. session.send = AsyncMock()
  133. await session.cmd_STOR("toobig.gcode.3mf")
  134. # Partial file must be unlinked.
  135. assert not (session.upload_dir / "toobig.gcode.3mf").exists()
  136. # 426 (transfer failed) sent — not 226.
  137. sent_codes = [args[0][0] for args in session.send.call_args_list]
  138. assert 426 in sent_codes
  139. assert 226 not in sent_codes
  140. @pytest.mark.asyncio
  141. async def test_stor_cleans_up_partial_file_on_read_error(tmp_path):
  142. """If the data channel raises mid-transfer (slicer RST, TLS error,
  143. timeout, …), the partial file on disk must be removed so the next
  144. upload of the same name starts clean and the user doesn't see a
  145. truncated file in the upload_dir."""
  146. payload = b"X" * 65536 # one full chunk
  147. session = _make_session(tmp_path, data_chunks=[payload])
  148. session.send = AsyncMock()
  149. # Inject an OSError on the NEXT read after the first chunk.
  150. orig_read = session._data_reader.read
  151. state = {"calls": 0}
  152. async def read_then_error(n):
  153. state["calls"] += 1
  154. if state["calls"] == 1:
  155. return await orig_read(n)
  156. raise OSError("simulated connection reset")
  157. session._data_reader.read = read_then_error # type: ignore[assignment]
  158. await session.cmd_STOR("aborted.gcode.3mf")
  159. # Partial file removed.
  160. assert not (session.upload_dir / "aborted.gcode.3mf").exists()
  161. sent_codes = [args[0][0] for args in session.send.call_args_list]
  162. assert 426 in sent_codes
  163. def test_max_upload_bytes_is_at_least_4_gib():
  164. """The cap exists to prevent OOM, but should be high enough that
  165. legitimate multi-plate .gcode.3mf uploads (~hundreds of MB) succeed
  166. without bumping up against it. 4 GiB is the documented floor."""
  167. assert MAX_UPLOAD_BYTES >= 4 * 1024 * 1024 * 1024