fix(trash): trash on an output folder that is a mount point (#543) (#544)

Closes #543
This commit is contained in:
Vito Sansevero
2026-10-30 16:54:50 -07:00
parent 4be48c2ee5
commit cdaf4122de
20 changed files with 1441 additions and 37 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}")
+36 -2
View File
@@ -6,16 +6,22 @@ inside an output root (``D:\\$RECYCLE.BIN`` when ``D:\\`` is a root,
the user threw away: they are not outputs, so they are never listed, scanned,
linked, tagged or previewed. Every walker asks these predicates instead of
testing names itself; only folders *below* a root are tested.
``in_trash_below_roots`` is the check for a path someone names (#543): it
resolves the path first, so a link or another spelling cannot get past it.
"""
import os
TRASH_FOLDER_NAMES = frozenset({".trash", ".trashes", "$recycle.bin"})
PREVIEW_FOLDER = "thumbnails"
def is_trash_folder(name: str) -> bool:
"""A trash folder name, in any case: ``.Trash``, ``.Trash-<uid>``,
``.Trashes`` or ``$RECYCLE.BIN``."""
folded = name.casefold()
``.Trashes`` or ``$RECYCLE.BIN``. Windows ignores a name's trailing dots
and spaces, so ``$RECYCLE.BIN.`` is that folder; they are ignored here too."""
folded = name.rstrip(" .").casefold()
if folded in TRASH_FOLDER_NAMES:
return True
return folded.startswith(".trash-") and folded[7:].isdigit()
@@ -26,6 +32,34 @@ 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_trash_below_roots(path, roots) -> bool:
"""Whether *path*, resolved, is in a trash folder below every root holding it.
A source named like a trash folder is still a source, so a file it holds
with no trash folder below it is not in one. Paths compare resolved and
normcased. A path no root visibly holds tests its whole path: a spelling
difference must not fail open.
"""
real = os.path.normcase(os.path.realpath(path))
below = []
for root in roots:
real_root = os.path.normcase(os.path.realpath(root))
if real.startswith(os.path.join(real_root, "")):
below.append(os.path.relpath(real, real_root))
if not below:
return in_trash_folder(real)
return all(in_trash_folder(relative) for relative in below)
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