mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-08 15:11:21 +02:00
On bare-metal Raspberry Pi OS bookworm / armv7l / Python 3.11, 3MF
files larger than a few megabytes arrived complete via the
virtual-printer FTP server but the copy into data/archives/ was
silently truncated. The archive row was still written, the printer
card looked fine, and the problem only surfaced later when opening
the archive — the subsequent zipfile.ZipFile() in
GET /archives/{id}/plates raised BadZipFile and the UI came up blank
with no thumbnail, plate list, or filament data.
Two things conspired:
1. archive_print() used shutil.copy2, which takes Python's sendfile()
fast path on Linux. On the reporter's kernel/fs combination
sendfile returned a short count on the first call for the upload
sizes hit in practice and the destination ended up truncated.
Small files completed in one syscall and were fine.
2. ThreeMFParser.parse() caught the resulting BadZipFile in a bare
`except Exception: pass`, so the archive pipeline kept going with
empty metadata and left the bad file on disk — nothing in the
logs hinted anything had gone wrong until a support bundle came
in and the "Failed to parse plates" warning fired much later.
The archive copy is now an explicit chunked read/write with fsync —
sendfile is not in the path. After the copy, if the source was a
valid ZIP but the destination isn't, we refuse to create the archive
row, remove only the truncated file (and the archive directory if
empty — archive_dir is created with exist_ok=True so rmtree would be
unsafe if a same-second same-filename collision happened), and log
both sizes at ERROR so the condition is obvious in future support
bundles. The parser's silent catch now logs at WARNING for the same
reason.
All nine archive_print() call sites already check `if archive:` or
`if not archive:`, so returning None for corrupted ZIPs propagates
cleanly without behaviour changes elsewhere.
Regression tests cover single-chunk and multi-chunk copies, mtime
preservation via copystat, overwrite of an existing destination, a
ZIP roundtrip through a multi-megabyte 3MF, the new parser WARNING,
and a truncation sentinel verifying that zipfile.is_zipfile() flips
to False on a half-written ZIP — the exact post-condition
archive_print now trusts.
134 lines
4.6 KiB
Python
134 lines
4.6 KiB
Python
"""
|
|
Tests for the 3MF archive copy path.
|
|
|
|
Regression guards for #1032 where large 3MF files were silently truncated
|
|
during archiving on Raspberry Pi OS / armv7l, leaving the archive row in
|
|
place but the on-disk file no longer a valid ZIP.
|
|
"""
|
|
|
|
import io
|
|
import logging
|
|
import os
|
|
import zipfile
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from backend.app.services.archive import ThreeMFParser, _copy_and_fsync
|
|
|
|
|
|
def _make_3mf(path: Path, payload_size: int = 0) -> None:
|
|
"""Write a minimal valid 3MF (ZIP) file with an optional large payload."""
|
|
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
zf.writestr("Metadata/slice_info.config", "<config/>")
|
|
if payload_size:
|
|
# Uncompressible payload forces real bytes on disk.
|
|
zf.writestr("blob.bin", os.urandom(payload_size))
|
|
|
|
|
|
class TestCopyAndFsync:
|
|
def test_copies_small_file_byte_for_byte(self, tmp_path: Path) -> None:
|
|
src = tmp_path / "src.bin"
|
|
dst = tmp_path / "dst.bin"
|
|
src.write_bytes(b"hello world")
|
|
|
|
_copy_and_fsync(src, dst)
|
|
|
|
assert dst.read_bytes() == b"hello world"
|
|
|
|
def test_copies_large_file_byte_for_byte(self, tmp_path: Path) -> None:
|
|
"""Spans multiple 1 MiB chunks to exercise the copy loop."""
|
|
src = tmp_path / "src.bin"
|
|
dst = tmp_path / "dst.bin"
|
|
payload = os.urandom(5 * 1024 * 1024 + 123) # 5 MiB + change
|
|
src.write_bytes(payload)
|
|
|
|
_copy_and_fsync(src, dst)
|
|
|
|
assert dst.stat().st_size == len(payload)
|
|
assert dst.read_bytes() == payload
|
|
|
|
def test_preserves_mtime_via_copystat(self, tmp_path: Path) -> None:
|
|
src = tmp_path / "src.bin"
|
|
dst = tmp_path / "dst.bin"
|
|
src.write_bytes(b"x")
|
|
os.utime(src, (1_700_000_000, 1_700_000_000))
|
|
|
|
_copy_and_fsync(src, dst)
|
|
|
|
assert int(dst.stat().st_mtime) == 1_700_000_000
|
|
|
|
def test_overwrites_existing_destination(self, tmp_path: Path) -> None:
|
|
src = tmp_path / "src.bin"
|
|
dst = tmp_path / "dst.bin"
|
|
src.write_bytes(b"new")
|
|
dst.write_bytes(b"old old old")
|
|
|
|
_copy_and_fsync(src, dst)
|
|
|
|
assert dst.read_bytes() == b"new"
|
|
|
|
def test_produces_valid_zip_on_3mf(self, tmp_path: Path) -> None:
|
|
"""The whole point of #1032: copy of a valid 3MF stays a valid ZIP."""
|
|
src = tmp_path / "src.3mf"
|
|
dst = tmp_path / "dst.3mf"
|
|
_make_3mf(src, payload_size=2 * 1024 * 1024) # 2 MiB, multi-chunk
|
|
assert zipfile.is_zipfile(src)
|
|
|
|
_copy_and_fsync(src, dst)
|
|
|
|
assert zipfile.is_zipfile(dst)
|
|
|
|
|
|
class TestThreeMFParserErrorVisibility:
|
|
def test_parse_logs_warning_on_corrupted_zip(
|
|
self,
|
|
tmp_path: Path,
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""Silent `except Exception: pass` was how #1032 escaped detection;
|
|
parse() must now surface the failure at WARNING."""
|
|
corrupted = tmp_path / "bad.3mf"
|
|
corrupted.write_bytes(b"not a zip")
|
|
|
|
with caplog.at_level(logging.WARNING, logger="backend.app.services.archive"):
|
|
result = ThreeMFParser(corrupted).parse()
|
|
|
|
assert result == {}
|
|
assert any("failed to parse" in rec.message and str(corrupted) in rec.message for rec in caplog.records), (
|
|
"Expected a WARNING mentioning the failed parse and file path"
|
|
)
|
|
|
|
def test_parse_returns_partial_metadata_without_raising(
|
|
self,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""A valid-but-minimal 3MF must still parse without raising."""
|
|
p = tmp_path / "ok.3mf"
|
|
with zipfile.ZipFile(p, "w") as zf:
|
|
zf.writestr("Metadata/slice_info.config", "<config/>")
|
|
|
|
result = ThreeMFParser(p).parse()
|
|
|
|
# No assertions about which keys are present — just that it didn't blow up.
|
|
assert isinstance(result, dict)
|
|
|
|
|
|
class TestZipFileSentinel:
|
|
"""Sanity check the sentinel the archive pipeline relies on."""
|
|
|
|
def test_is_zipfile_on_truncated_zip_returns_false(self, tmp_path: Path) -> None:
|
|
"""Truncating a valid ZIP mid-stream must flip is_zipfile() to False.
|
|
This is the exact post-condition archive_print now trusts."""
|
|
src = tmp_path / "src.3mf"
|
|
_make_3mf(src, payload_size=1024 * 1024)
|
|
full = src.read_bytes()
|
|
assert zipfile.is_zipfile(io.BytesIO(full))
|
|
|
|
truncated = tmp_path / "truncated.3mf"
|
|
# Strip the trailing end-of-central-directory record — exactly what a
|
|
# short sendfile return would leave behind.
|
|
truncated.write_bytes(full[: len(full) // 2])
|
|
|
|
assert not zipfile.is_zipfile(truncated)
|