fix(vp): stop uvloop from silently truncating VP FTP uploads (#1896)

Native (non-Docker) installs launched uvicorn without --loop asyncio, so
uvicorn[standard] auto-selected uvloop. uvloop's SSL layer drops
already-received but still-buffered data when the client closes the data
connection without a TLS close_notify while the reader is flow-control
paused on slow storage. cmd_STOR writes each chunk to disk inside the read
loop, so a slow consumer falls behind, the tail is lost, read() returns a
clean EOF, and the loop exits with no exception -- the server acked 226 for
a file it truncated itself, then archived, queued, and forwarded the corrupt
3MF to the real printer.

Fix in two independent layers:

1. Remove the trigger: add --loop asyncio to every native launch path,
   matching the Dockerfile -- deploy/bambuddy.service, install/install.sh
   (systemd + launchd), spoolbuddy/install/install.sh, the Windows NSSM
   service, README, CONTRIBUTING dev command.

2. Defense in depth (loop-independent): cmd_STOR now validates that a
   received .3mf opens as a ZIP (reads the central directory, no
   decompression) before replying 226. A truncated/corrupt file is dropped
   and answered with 426, and on_file_received never runs -- so a broken
   upload surfaces as an immediate slicer-side send error instead of being
   archived and pushed to the printer. Scoped to .3mf; other filetypes pass
   through unchanged.
This commit is contained in:
maziggy
2026-07-05 10:32:13 +02:00
parent 168d9d8f8e
commit 7d4dfd5a7d
10 changed files with 125 additions and 10 deletions
@@ -13,6 +13,7 @@ import logging
import os
import random
import ssl
import zipfile
from collections.abc import Callable
from pathlib import Path
@@ -476,6 +477,43 @@ class FTPSession:
await self.send(426, f"Transfer failed: {write_failed}")
return
# Defense in depth (#1896): a clean read-loop EOF does NOT prove the
# upload arrived intact. Under uvloop, the SSL layer can silently drop
# already-received but still-buffered data when the client closes the
# data connection without a TLS close_notify (a "ragged EOF") while the
# transport is flow-control-paused on slow storage — read() then returns
# b"" and we would otherwise reply 226 for a tail-truncated file, archive
# it, queue it, and forward the corrupt job to the real printer.
#
# Bambu 3MF uploads are ZIP containers whose End-Of-Central-Directory
# record sits at the very end of the file, so any lost tail makes the
# archive impossible to open. Verify that before acknowledging success:
# a truncated file is treated exactly like a failed transfer (426 +
# drop) so the slicer surfaces an actionable send error instead of the
# printer choking on a half-written job later. Only ZIP-based (.3mf)
# uploads are validated — other filetypes keep the prior pass-through
# behaviour. Reading the central directory is O(dir), not O(file): no
# decompression, negligible next to the write loop above.
if filename.lower().endswith(".3mf"):
try:
with zipfile.ZipFile(file_path) as zf:
zf.namelist()
except Exception as e:
logger.error(
"FTP upload of %s is a corrupt/truncated 3MF (%s bytes): %s(%s) — "
"rejecting with 426 instead of archiving a broken file",
filename,
total_received,
type(e).__name__,
e,
)
try:
file_path.unlink(missing_ok=True)
except OSError:
pass
await self.send(426, "Transfer failed: uploaded 3MF is incomplete or corrupt")
return
# Confirm + notify
logger.info("FTP saved file: %s (%s bytes)", file_path, total_received)
await self.send(226, "Transfer complete")