diff --git a/TESTING.md b/TESTING.md index cb421836..302d457c 100644 --- a/TESTING.md +++ b/TESTING.md @@ -2494,7 +2494,8 @@ files to the system trash. **No code path unlinks or overwrites a media file.** `_media_access_error` is the one gate (by-id, inspect, reveal, link, rescope as `file-missing`, a prompt's media listing); `/images/serve` and the autotag target ask `in_trash_below_roots` too, `media_target` refuses by name (annotations, collections, - whose capture store denies links and aliases), and previews are not made. The delete + whose capture store denies links and aliases), and previews are neither made nor served, the fallback past the preview gate's cap + included (`_quick_source`). A History output in one answers `missing`. The delete and Return flows never use this gate. Windows spellings (trailing dots and spaces, case, 8.3) run for real on the Windows job in `tests/test_trash_gate_routes.py`. - **An output folder that is a mount point** (#543): the mount's own `.Trash-$uid` and diff --git a/py/api/history_current.py b/py/api/history_current.py index bde14c24..4842db5b 100644 --- a/py/api/history_current.py +++ b/py/api/history_current.py @@ -17,6 +17,7 @@ from aiohttp import web from . import thumbnail_settings from .images import _canonical, _public_path, SERVABLE_EXTENSIONS, VIDEO_EXTENSIONS +from .images import in_trash_below_roots from .images import existing_media_thumbnail, media_thumbnail_rel try: @@ -248,6 +249,9 @@ def _open_current_target( A *preview* (a file under ``thumbnails/``) may also be JPEG XL (#317).""" if _canonical(target) != target: raise CurrentLocationUnavailable("not_authorized", stale=True) + if in_trash_below_roots(target, [namespace]): + # An output recorded before #501 skipped trash folders (#543). + raise CurrentLocationUnavailable("missing") if not _authorized(target, namespace, scope): raise CurrentLocationUnavailable("not_authorized") suffix = target.suffix.lower() diff --git a/py/api/thumbnail_auto.py b/py/api/thumbnail_auto.py index a69f8d9b..05af0c2d 100644 --- a/py/api/thumbnail_auto.py +++ b/py/api/thumbnail_auto.py @@ -225,6 +225,8 @@ def _quick_source(root, parts): for name in (base + extension, base + extension.upper()): path = folder / name if _regular_file(path) and _is_within(path, root): + if in_trash_below_roots(path, [root]): + return None # also the fallback past the gate's cap (#543) return path, extension in VIDEO_EXTENSIONS return None diff --git a/tests/test_trash_gate_routes.py b/tests/test_trash_gate_routes.py index 7c8f83df..ede6edee 100644 --- a/tests/test_trash_gate_routes.py +++ b/tests/test_trash_gate_routes.py @@ -11,6 +11,7 @@ import os import unittest import urllib.parse from pathlib import Path +from unittest.mock import AsyncMock, patch try: from tests.test_api_admin import _write_png @@ -20,7 +21,8 @@ except ImportError: # discovered with tests/ as the top-level directory from test_image_delete_routes import ImageDeleteTestCase from py.api.images import media_thumbnail_rel -from py.api.thumbnail_auto import _thumbnail_source +from py.api.history_current import CurrentLocationUnavailable, _open_current_target +from py.api.thumbnail_auto import OVER, _quick_source, _thumbnail_source from utils.media_folders import in_trash_below_roots, in_trash_folder, is_trash_folder ON_CI = bool(os.environ.get("GITHUB_ACTIONS")) @@ -204,6 +206,28 @@ class TestTheGate(_GateCase): kept = Path(media_thumbnail_rel(Path("kept.png"))).parts self.assertIsNotNone(_thumbnail_source(self.root, kept)) + async def test_the_preview_fallback_past_the_cap_is_gated_too(self): + # The gate's OVER branch looks the source up by name and serves it. + self.links() + gate = self.api._preview_gate() + with patch.object(gate, "run", AsyncMock(return_value=OVER)): + linked = media_thumbnail_rel(Path("linkdir/x.png")) + self.assertEqual(await self.serve(linked), 404) + kept = media_thumbnail_rel(Path("kept.png")) + self.assertEqual(await self.serve(kept), 200) + self.assertIsNone( + _quick_source( + self.root, Path(media_thumbnail_rel(Path("linkdir/x.png"))).parts + ) + ) + + async def test_a_history_output_in_a_trash_folder_does_not_open(self): + self.links() + target = self.root / ".Trash-1000" / "files" / "x.png" + with self.assertRaises(CurrentLocationUnavailable) as caught: + _open_current_target(Path(os.path.realpath(target)), self.root, None) + self.assertEqual(caught.exception.reason, "missing") + async def test_a_source_named_like_a_trash_folder_still_reads(self): source = self.root / ".Trash-77" source.mkdir()