fix(trash): allow the mount's own trash when the output folder is a mount point (#543)

An output root that is exactly a mount root may hold that mount's .Trash-$uid
and .Trash/$uid, with the spec's checks intact. Any other trash inside a root
stays refused, and so does a root inside the trash. Media routes no longer
serve files in trash folders, and a review with nothing to move shows a
neutral disabled primary.
This commit is contained in:
Vito Sansevero
2026-10-30 12:46:19 -07:00
parent a6d6129ec6
commit 7eaf13b006
11 changed files with 708 additions and 18 deletions
+8 -1
View File
@@ -37,6 +37,7 @@ from pathlib import Path
from typing import Dict, Any
from .logging_config import get_logger
from .media_folders import in_skipped_folder
try:
from ..database.workflow_documents import DocumentReader, resolve_image_row
@@ -298,7 +299,13 @@ class GalleryDiagnostics:
try:
image_files = []
for ext in [".png", ".jpg", ".jpeg", ".webp"]:
image_files.extend(Path(abs_path).rglob(f"*{ext}"))
image_files.extend(
found
for found in Path(abs_path).rglob(f"*{ext}")
if not in_skipped_folder(
found.relative_to(abs_path).parts[:-1]
)
)
self.logger.info(f" [IMG] Images found: {len(image_files)}")
except Exception as e:
self.logger.error(f" [FAIL] Error scanning: {e}")
+9
View File
@@ -26,6 +26,15 @@ def is_skipped_folder(name: str) -> bool:
return name == PREVIEW_FOLDER or is_trash_folder(name)
def in_trash_folder(relative: str) -> bool:
"""Whether a root-relative file path lies in a trash folder (#543).
Takes either separator. Previews of it (``thumbnails/.Trash-1000/...``) count.
"""
folders = relative.replace("\\", "/").split("/")[:-1]
return any(is_trash_folder(part) for part in folders)
def in_skipped_folder(parts) -> bool:
"""Whether any of the folder names *parts* is skipped."""
return any(is_skipped_folder(part) for part in parts)
+18 -4
View File
@@ -6,7 +6,10 @@ Two rules hold for every backend:
filesystem. Nothing is ever copied, unlinked or overwritten: a file that
cannot be moved recoverably stays where it is, and ``TrashRefused`` says why.
- **Never into a root.** A trash folder inside an output root would be listed
as images, so it is refused (``trash-in-root``).
as images, so it is refused (``trash-in-root``). The one exception (#543): for
an output root that is exactly a mount root, that mount's own ``.Trash-$uid``
and ``.Trash/$uid``, which every walker and media route ignores
(``utils.media_folders``).
Linux and BSD use :class:`FreedesktopBackend`, the freedesktop.org Trash
specification 1.0: the home trash (``$XDG_DATA_HOME/Trash``) for a file on the
@@ -184,10 +187,21 @@ def _canonical(path: str) -> str:
return os.path.normcase(os.path.realpath(path))
def _check_roots(trash_dir: str, roots) -> None:
def _check_roots(trash_dir: str, roots, top: str | None = None) -> None:
"""Refuse a trash that a root would list, or that holds a root.
*top* is the mount a per-drive *trash_dir* was built from. A root that is
exactly that mount may hold it, unless a link makes the trash really live
somewhere else (#543). Any other root holding it refuses, also beside one.
"""
trash_key = _canonical(trash_dir)
top_key = None if top is None else _canonical(top)
unlinked = trash_key == os.path.normcase(trash_dir)
for root in roots:
if _inside(trash_key, _canonical(str(root))):
root_key = _canonical(str(root))
if _inside(root_key, trash_key):
raise TrashRefused(TRASH_IN_ROOT)
if _inside(trash_key, root_key) and not (unlinked and root_key == top_key):
raise TrashRefused(TRASH_IN_ROOT)
@@ -267,7 +281,7 @@ class FreedesktopBackend:
candidates.append(os.path.join(shared, str(self._uid)))
candidates.append(os.path.join(top, f".Trash-{self._uid}"))
for trash_dir in candidates:
_check_roots(trash_dir, roots)
_check_roots(trash_dir, roots, top)
if self._usable(trash_dir, create=create):
label = f"{os.path.relpath(trash_dir, top)} on its drive"
return trash_dir, top, label