test_archive_delete_no_3mf_dirs_2968.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336
  1. """Deleting a no-3MF archive used to leave every file it owned on disk (#2968).
  2. An archive created without a 3MF carries ``file_path == ""``. Both delete paths
  3. derived the directory to remove from that path, found nothing, and logged
  4. SECURITY: Refusing to delete files for archive 7 - file_path is empty or invalid: ''
  5. at ERROR. That was accurate once, when such an archive really was an empty row.
  6. It stopped being accurate when a no-3MF archive gained places to put things:
  7. ``<archive_dir>/<id>/`` for its timelapse and finish photos (the shared helper
  8. in ``utils.archive_paths``, #1820), and ``archive/no_source/<id>/`` for a source
  9. 3MF uploaded onto it afterwards (#1531). Neither was ever removed, so deleting
  10. the archive freed the row and kept the video -- on an H2-series or P2S printer,
  11. where a print sent from Bambu Studio always archives without a 3MF, that is most
  12. of the library.
  13. Reported by @ceasley, whose log carries three of those ERROR lines from a single
  14. afternoon of deleting no-3MF archives.
  15. **The trap this file exists to hold shut.** ``<archive_dir>/<id>`` shares a
  16. namespace with the per-printer folders: a normal archive lives at
  17. ``<archive_dir>/<printer_id>/<timestamp>_<name>/``, so ``archive/1`` is printer
  18. 1's folder *and* the directory ``resolve_archive_dir`` hands archive id 1.
  19. Archive ids and printer ids are small integers from unrelated sequences, so on
  20. every install the first few archives collide with the printers. An ``rmtree``
  21. there deletes every print that printer ever made. The first draft of this fix
  22. did exactly that, and passed a full suite before the collision was found by
  23. reading the archive layout rather than the tests. Nothing in the delete path may
  24. remove a directory one level under ``archive_dir``.
  25. """
  26. from __future__ import annotations
  27. import logging
  28. from pathlib import Path
  29. import pytest
  30. from backend.app.models.archive import PrintArchive
  31. from backend.app.models.printer import Printer
  32. from backend.app.services.archive import ArchiveService
  33. @pytest.fixture
  34. def archive_root(tmp_path, monkeypatch):
  35. """A data directory both settings bindings agree on.
  36. ``services.archive`` and ``utils.archive_paths`` each hold their own
  37. module-level ``settings``; patching one and not the other is how an earlier
  38. change to this code wrote outside tmp_path and littered a working tree.
  39. """
  40. from backend.app.services import archive as archive_module
  41. from backend.app.utils import archive_paths
  42. for module in (archive_module, archive_paths):
  43. monkeypatch.setattr(module.settings, "base_dir", tmp_path, raising=False)
  44. monkeypatch.setattr(module.settings, "archive_dir", tmp_path / "archive", raising=False)
  45. (tmp_path / "archive").mkdir(parents=True, exist_ok=True)
  46. return tmp_path
  47. def _service() -> ArchiveService:
  48. """The resolvers need no database; ``None`` keeps the test to one subject."""
  49. return ArchiveService(None) # type: ignore[arg-type]
  50. def _archive(archive_id: int, file_path: str = "") -> PrintArchive:
  51. return PrintArchive(id=archive_id, file_path=file_path)
  52. def _printer_folder_with_a_print(archive_root, printer_id: int) -> Path:
  53. """A printer folder laid out exactly as ``_create_archive`` builds it."""
  54. directory = archive_root / "archive" / str(printer_id) / "20260828_193000_Benchy"
  55. directory.mkdir(parents=True)
  56. (directory / "Benchy.3mf").write_bytes(b"a real archived print")
  57. return directory
  58. class TestItCannotDeleteAPrinterFolder:
  59. """The collision above. Every one of these would have destroyed real data."""
  60. def test_a_no_3mf_archive_whose_id_matches_a_printer(self, archive_root):
  61. real = _printer_folder_with_a_print(archive_root, 1)
  62. assert _service()._resolve_archive_dirs_for_delete(_archive(1)) == []
  63. assert real.exists()
  64. def test_and_the_purge_leaves_it_standing(self, archive_root):
  65. """The id-named directory is cleaned in place rather than removed, so
  66. the purge has to survive the folder being somebody else's."""
  67. real = _printer_folder_with_a_print(archive_root, 1)
  68. _service()._purge_id_named_dir(1, (None, None))
  69. assert (real / "Benchy.3mf").exists()
  70. assert (archive_root / "archive" / "1").is_dir()
  71. @pytest.mark.asyncio
  72. async def test_end_to_end_through_delete_archive(self, archive_root, db_session):
  73. """Not just the resolver: the whole delete, against real rows."""
  74. printer = Printer(name="H2D", ip_address="192.0.2.9", access_code="12345678", serial_number="COLLIDE")
  75. db_session.add(printer)
  76. await db_session.flush()
  77. real = archive_root / "archive" / str(printer.id) / "20260828_193000_Benchy"
  78. real.mkdir(parents=True)
  79. (real / "Benchy.3mf").write_bytes(b"a real archived print")
  80. archive = PrintArchive(
  81. printer_id=printer.id, filename="Cleaner_PRO", file_path="", file_size=0, status="completed"
  82. )
  83. db_session.add(archive)
  84. await db_session.commit()
  85. if archive.id != printer.id:
  86. pytest.skip(f"ids did not collide in this fixture (archive {archive.id}, printer {printer.id})")
  87. assert await ArchiveService(db_session).delete_archive(archive.id) is True
  88. assert (real / "Benchy.3mf").exists(), "deleting the archive took the printer's whole folder"
  89. def test_a_corrupted_row_pointing_at_a_printer_folder(self, archive_root, caplog):
  90. """``archive/1/Benchy.3mf`` -- a file_path that lost a path component.
  91. Its parent is the printer folder. Refused on depth, and said out loud."""
  92. real = _printer_folder_with_a_print(archive_root, 1)
  93. (archive_root / "archive" / "1" / "Benchy.3mf").write_bytes(b"x")
  94. with caplog.at_level(logging.ERROR):
  95. dirs = _service()._resolve_archive_dirs_for_delete(_archive(7, "archive/1/Benchy.3mf"))
  96. assert dirs == []
  97. assert (real / "Benchy.3mf").exists()
  98. assert any("not deep enough" in r.getMessage() for r in caplog.records)
  99. def test_the_archive_root_itself_is_refused(self, archive_root, caplog):
  100. (archive_root / "archive" / "Benchy.3mf").write_bytes(b"x")
  101. with caplog.at_level(logging.ERROR):
  102. dirs = _service()._resolve_archive_dirs_for_delete(_archive(7, "archive/Benchy.3mf"))
  103. assert dirs == []
  104. assert (archive_root / "archive").exists()
  105. def test_nothing_it_returns_is_ever_one_level_deep(self, archive_root):
  106. """The invariant, stated once against every shape a row can take."""
  107. _printer_folder_with_a_print(archive_root, 1)
  108. (archive_root / "archive" / "no_source" / "1").mkdir(parents=True)
  109. for file_path in ("", "archive/1/x.3mf", "archive/x.3mf", "../escape/x.3mf", "/absolute/x.3mf"):
  110. for directory in _service()._resolve_archive_dirs_for_delete(_archive(1, file_path)):
  111. relative = Path(directory).resolve().relative_to((archive_root / "archive").resolve())
  112. assert len(relative.parts) >= 2, f"{file_path} resolved to {relative}"
  113. class TestANo3mfArchivesFiles:
  114. def test_its_timelapse_and_photos_are_removed(self, archive_root):
  115. """The files it really owns, taken by name rather than by rmtree."""
  116. directory = archive_root / "archive" / "7"
  117. (directory / "photos").mkdir(parents=True)
  118. (directory / "photos" / "finish.jpg").write_bytes(b"p")
  119. (directory / "video_2026-08-27_08-35-49.mp4").write_bytes(b"v")
  120. _service()._purge_id_named_dir(7, ("archive/7/video_2026-08-27_08-35-49.mp4", None))
  121. assert not directory.exists()
  122. def test_its_uploaded_source_directory_is_removed(self, archive_root):
  123. """``archive/no_source/<id>/`` is two levels down and nested under a
  124. name no printer id can take, so it is safe to remove whole."""
  125. source_dir = archive_root / "archive" / "no_source" / "7"
  126. source_dir.mkdir(parents=True)
  127. (source_dir / "Cleaner_PRO.3mf").write_bytes(b"x")
  128. assert _service()._resolve_archive_dirs_for_delete(_archive(7)) == [source_dir]
  129. def test_no_error_is_logged_for_an_ordinary_empty_path(self, archive_root, caplog):
  130. """It is the normal shape of a Studio-sent print, not a security event.
  131. Three of these were the only ERRORs in the reporter's whole log."""
  132. with caplog.at_level(logging.ERROR):
  133. _service()._resolve_archive_dirs_for_delete(_archive(7))
  134. _service()._purge_id_named_dir(7, (None, None))
  135. assert not [r for r in caplog.records if "SECURITY" in r.getMessage()]
  136. def test_an_unrecognised_file_keeps_the_directory(self, archive_root):
  137. """Leaking beats guessing: something this archive did not record stops
  138. the rmdir, and nothing is removed on a hunch."""
  139. directory = archive_root / "archive" / "7"
  140. directory.mkdir(parents=True)
  141. (directory / "something_else.bin").write_bytes(b"?")
  142. _service()._purge_id_named_dir(7, (None, None))
  143. assert (directory / "something_else.bin").exists()
  144. def test_a_recorded_path_outside_the_directory_is_not_followed(self, archive_root):
  145. """A row whose timelapse_path names another archive's file must not
  146. take it with this delete."""
  147. elsewhere = archive_root / "archive" / "1" / "20260828_193000_Benchy"
  148. elsewhere.mkdir(parents=True)
  149. (elsewhere / "video.mp4").write_bytes(b"v")
  150. (archive_root / "archive" / "7").mkdir(parents=True)
  151. _service()._purge_id_named_dir(7, ("archive/1/20260828_193000_Benchy/video.mp4", None))
  152. assert (elsewhere / "video.mp4").exists()
  153. def test_the_shared_legacy_photo_directory_is_never_touched(self, archive_root):
  154. """``<base_dir>/photos`` was written to by *every* no-3MF archive at
  155. once. Removing it on one delete would take the others' photos too."""
  156. shared = archive_root / "photos"
  157. shared.mkdir(parents=True)
  158. (shared / "finish_7.jpg").write_bytes(b"x")
  159. assert shared not in _service()._resolve_archive_dirs_for_delete(_archive(7))
  160. _service()._purge_id_named_dir(7, (None, None))
  161. assert (shared / "finish_7.jpg").exists()
  162. class TestAnArchiveWithA3mf:
  163. def test_its_own_directory_is_removed(self, archive_root):
  164. archive_dir = archive_root / "archive" / "1" / "20260828_193000_Benchy"
  165. archive_dir.mkdir(parents=True)
  166. (archive_dir / "Benchy.3mf").write_bytes(b"x")
  167. dirs = _service()._resolve_archive_dirs_for_delete(_archive(7, "archive/1/20260828_193000_Benchy/Benchy.3mf"))
  168. assert dirs == [archive_dir]
  169. def test_a_missing_3mf_no_longer_strands_the_directory(self, archive_root):
  170. """The old code keyed on the 3MF still being there, so an archive whose
  171. 3MF had gone kept its thumbnail and timelapse forever."""
  172. archive_dir = archive_root / "archive" / "1" / "20260828_193000_Benchy"
  173. archive_dir.mkdir(parents=True)
  174. (archive_dir / "thumbnail.png").write_bytes(b"x")
  175. dirs = _service()._resolve_archive_dirs_for_delete(_archive(7, "archive/1/20260828_193000_Benchy/Benchy.3mf"))
  176. assert dirs == [archive_dir]
  177. def test_a_directory_that_does_not_exist_is_not_offered(self, archive_root):
  178. assert _service()._resolve_archive_dirs_for_delete(_archive(7, "archive/1/gone/Benchy.3mf")) == []
  179. def test_a_file_where_a_directory_should_be_is_not_offered(self, archive_root):
  180. """``is_dir()`` rather than ``exists()``: rmtree on a file raises, and
  181. the delete would take the whole request down with it."""
  182. (archive_root / "archive" / "no_source").mkdir(parents=True)
  183. (archive_root / "archive" / "no_source" / "7").write_bytes(b"not a directory")
  184. assert _service()._resolve_archive_dirs_for_delete(_archive(7)) == []
  185. def test_a_path_outside_the_archive_tree_is_refused_and_logged(self, archive_root, caplog):
  186. """Only a corrupted import or hand-edited SQL produces this."""
  187. outside = archive_root / "elsewhere" / "deep"
  188. outside.mkdir(parents=True)
  189. (outside / "Benchy.3mf").write_bytes(b"x")
  190. with caplog.at_level(logging.ERROR):
  191. dirs = _service()._resolve_archive_dirs_for_delete(_archive(7, "elsewhere/deep/Benchy.3mf"))
  192. assert dirs == []
  193. assert (outside / "Benchy.3mf").exists()
  194. assert any("outside archive directory" in r.getMessage() for r in caplog.records)
  195. class TestBothDeletePathsUseIt:
  196. """Hard delete kept its own copy of these rules and had already diverged
  197. from the helper whose docstring said it was extracted to prevent that."""
  198. async def _no_3mf_archive_with_files(self, archive_root, db_session, serial: str, ip: str):
  199. printer = Printer(name="H2D", ip_address=ip, access_code="12345678", serial_number=serial)
  200. db_session.add(printer)
  201. await db_session.flush()
  202. archive = PrintArchive(
  203. printer_id=printer.id, filename="Cleaner_PRO", file_path="", file_size=0, status="completed"
  204. )
  205. db_session.add(archive)
  206. await db_session.commit()
  207. video_dir = archive_root / "archive" / str(archive.id)
  208. video_dir.mkdir(parents=True, exist_ok=True)
  209. (video_dir / "video.mp4").write_bytes(b"v")
  210. archive.timelapse_path = f"archive/{archive.id}/video.mp4"
  211. source_dir = archive_root / "archive" / "no_source" / str(archive.id)
  212. source_dir.mkdir(parents=True, exist_ok=True)
  213. (source_dir / "Cleaner_PRO.3mf").write_bytes(b"x")
  214. await db_session.commit()
  215. return archive, video_dir, source_dir
  216. @pytest.mark.asyncio
  217. async def test_soft_delete_removes_the_video_and_the_upload(self, archive_root, db_session):
  218. archive, video_dir, source_dir = await self._no_3mf_archive_with_files(
  219. archive_root, db_session, "SOFT1", "192.0.2.1"
  220. )
  221. assert await ArchiveService(db_session).soft_delete_archive(archive.id) is True
  222. assert not video_dir.exists()
  223. assert not source_dir.exists()
  224. @pytest.mark.asyncio
  225. async def test_hard_delete_removes_the_video_and_the_upload(self, archive_root, db_session):
  226. archive, video_dir, source_dir = await self._no_3mf_archive_with_files(
  227. archive_root, db_session, "HARD1", "192.0.2.2"
  228. )
  229. assert await ArchiveService(db_session).delete_archive(archive.id) is True
  230. assert not video_dir.exists()
  231. assert not source_dir.exists()
  232. @pytest.mark.asyncio
  233. async def test_hard_delete_still_removes_the_row_when_a_guard_trips(self, archive_root, db_session):
  234. """A row pointing outside the tree must still be deletable, or the
  235. archive becomes permanently stuck in the UI."""
  236. printer = Printer(name="H2D", ip_address="192.0.2.3", access_code="12345678", serial_number="GUARD1")
  237. db_session.add(printer)
  238. await db_session.flush()
  239. outside = archive_root / "elsewhere" / "deep"
  240. outside.mkdir(parents=True)
  241. (outside / "Benchy.3mf").write_bytes(b"x")
  242. archive = PrintArchive(
  243. printer_id=printer.id,
  244. filename="Benchy",
  245. file_path="elsewhere/deep/Benchy.3mf",
  246. file_size=0,
  247. status="completed",
  248. )
  249. db_session.add(archive)
  250. await db_session.commit()
  251. archive_id = archive.id
  252. assert await ArchiveService(db_session).delete_archive(archive_id) is True
  253. assert await ArchiveService(db_session).get_archive(archive_id) is None
  254. assert (outside / "Benchy.3mf").exists()