Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/).

### Behoben

- Gleichzeitige Speicherungen konnten denselben vermeintlich freien Dateinamen
wählen und sich überschreiben. Text, Markdown, HTML, Bilder und Dateikopien
werden jetzt zunächst privat geschrieben und anschließend unter einem exklusiv
reservierten Namen veröffentlicht. Fehler bereinigen nur Dateien mit eigener
nachgewiesener Identität; vorhandene Dateien erhalten weiterhin `_2`, `_3` usw.

- Der `QShortcut` für Strg+V wurde mit einer `StandardKey` statt einer
`QKeySequence` und einem `activated=`-Schlüsselwort gebaut. Zur Laufzeit
funktionierte das, es traf aber keine dokumentierte Überladung — eine Form,
Expand Down
143 changes: 130 additions & 13 deletions absorber/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,16 @@

from __future__ import annotations

import os
import shutil
import stat
import sys
import tempfile
from collections.abc import Callable
from pathlib import Path

from . import config as config_module
from .naming import build_stem, unique_path
from .naming import build_stem

#: Content kinds, in the order they are probed.
KIND_FILES = "files"
Expand Down Expand Up @@ -56,20 +61,18 @@ def materialize(mime, cfg: dict, namer=None) -> list[Path]:
stem = build_stem(kind, sample, namer)

if kind == KIND_IMAGE:
path = unique_path(target, stem, "png")
image = mime.imageData()
if not image.save(str(path), "PNG"):
raise OSError(f"could not write image to {path}")
return [path]
def write_image(stage: Path) -> None:
if not image.save(str(stage), "PNG"):
raise OSError("Bild konnte nicht vollständig gespeichert werden.")
return [_write_unique(target, stem, "png", write_image)]

if kind == KIND_HTML:
path = unique_path(target, stem, "html")
path.write_text(mime.html(), encoding="utf-8")
return [path]
return [_write_unique(target, stem, "html",
lambda stage: stage.write_text(mime.html(), encoding="utf-8"))]

path = unique_path(target, stem, config_module.text_extension(cfg))
path.write_text(mime.text(), encoding="utf-8")
return [path]
return [_write_unique(target, stem, config_module.text_extension(cfg),
lambda stage: stage.write_text(mime.text(), encoding="utf-8"))]


def _copy_files(mime, target: Path) -> list[Path]:
Expand All @@ -81,12 +84,126 @@ def _copy_files(mime, target: Path) -> list[Path]:
source_path = Path(source)
if not source_path.is_file():
continue # directories are out of scope for the MVP
destination = unique_path(target, source_path.stem, source_path.suffix)
shutil.copy2(source_path, destination)
destination = _write_unique(target, source_path.stem, source_path.suffix,
lambda stage, source=source_path: shutil.copy2(source, stage))
written.append(destination)
return written


def _owned(path: Path, identity: os.stat_result) -> bool:
"""Check the directory entry itself, without following a replacement symlink."""
current = path.lstat()
return stat.S_ISREG(current.st_mode) and os.path.samestat(current, identity)


def _remove_owned(path: Path, identity: os.stat_result) -> None:
try:
if not _owned(path, identity):
return
try:
path.unlink()
except PermissionError:
# copy2 may copy a readonly attribute to our stage, never to its source.
if _owned(path, identity):
path.chmod(stat.S_IREAD | stat.S_IWRITE)
if _owned(path, identity):
path.unlink()
except FileNotFoundError:
pass


def _exclusive_file(path: Path) -> int:
if os.name != "nt":
return os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
# The Windows CRT follows dangling symlinks even with O_EXCL. CREATE_NEW
# together with OPEN_REPARSE_POINT reserves the directory entry itself.
import ctypes
import msvcrt
from ctypes import wintypes

kernel = ctypes.WinDLL("kernel32", use_last_error=True)
create = kernel.CreateFileW
create.argtypes = [wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD,
wintypes.LPVOID, wintypes.DWORD, wintypes.DWORD, wintypes.HANDLE]
create.restype = wintypes.HANDLE
handle = create(os.path.abspath(path), 0x40000000, 0x7, None, 1,
0x80 | 0x00200000, None)
if handle == ctypes.c_void_p(-1).value:
raise ctypes.WinError(ctypes.get_last_error())
try:
# A successful conversion transfers handle ownership to the descriptor.
return msvcrt.open_osfhandle(handle, os.O_WRONLY | os.O_BINARY)
except BaseException:
close = kernel.CloseHandle
close.argtypes = [wintypes.HANDLE]
close.restype = wintypes.BOOL
close(handle)
raise


def _write_unique(directory: Path, stem: str, extension: str,
write: Callable[[Path], object]) -> Path:
"""Write privately, then replace only an exclusively reserved output name."""
extension = extension.lstrip(".")
name = f"{stem}.{extension}"
if not stem or stem in (".", "..") or any(char in name for char in ("/", "\\")):
raise ValueError("Ausgabename muss ein einzelner Dateiname sein.")
if os.name == "nt" and ":" in name:
raise ValueError("Ausgabename darf keinen alternativen Datenstrom enthalten.")
stage = reservation = None
stage_identity = reservation_identity = None
try:
fd, filename = tempfile.mkstemp(prefix=".absorber-", suffix=".tmp", dir=directory)
stage = Path(filename)
try:
stage_identity = os.fstat(fd)
finally:
os.close(fd)
write(stage)
counter = 1
while True:
suffix = "" if counter == 1 else f"_{counter}"
candidate = directory / f"{stem}{suffix}.{extension}"
try:
fd = _exclusive_file(candidate)
except FileExistsError:
counter += 1
continue
except PermissionError as error:
# Windows reports EACCES for an existing directory under O_EXCL.
# Advance only if a real directory entry can be confirmed.
try:
candidate.lstat()
except OSError:
raise error
counter += 1
continue
reservation = candidate
try:
reservation_identity = os.fstat(fd)
finally:
os.close(fd)
break
if not _owned(reservation, reservation_identity) or not _owned(stage, stage_identity):
raise OSError("Reservierte Ausgabedatei wurde während des Schreibens ersetzt.")
os.replace(stage, reservation)
result = reservation
reservation = None
return result
finally:
for path, identity in ((stage, stage_identity), (reservation, reservation_identity)):
if path is None or identity is None:
continue
try:
_remove_owned(path, identity)
except OSError:
# Keep the original error; never fall back to deleting unknown files.
try:
print("Temporäre Absorber-Datei konnte nicht bereinigt werden.", file=sys.stderr)
except Exception: # noqa: BLE001, S110 -- Preserve the original write error.
pass


def make_namer(cfg: dict):
"""An OllamaNamer when model naming is switched on, else None."""
if not cfg.get("model_naming"):
Expand Down
46 changes: 46 additions & 0 deletions docs/OUTPUT_SAFETY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Sichere Ausgabe bei gleichzeitigen Speicherungen

Absorber schreibt Text, Markdown, HTML, Bilder und Dateikopien zunächst in eine
exklusiv angelegte temporäre Datei im Zielordner. Erst nach erfolgreichem Schreiben
reserviert es den endgültigen Namen exklusiv. Unter Unix geschieht dies mit
`O_CREAT | O_EXCL`. Ist der Name schon
belegt, folgen wie bisher `_2`, `_3` usw. Auch ein defekter symbolischer Link zählt
als belegter Name. Die bisherige Funktion `naming.unique_path` bleibt eine reine
Namensvorhersage; sie reserviert keine Datei.

Unter Windows verwendet die Reservierung `CreateFileW` mit `CREATE_NEW` und
`FILE_FLAG_OPEN_REPARSE_POINT`, da die Windows-CRT bei `O_EXCL` defekten Links
folgen kann. Das eigene Windows-Handle wird nach erfolgreichem Anlegen in einen
Python-Dateideskriptor überführt; schlägt diese Überführung fehl, wird das Handle
geschlossen. Die bisherigen Identitäts- und Bereinigungsprüfungen gelten auch hier.

Vor der Veröffentlichung prüft Absorber, ob temporäre Datei und Reservierung noch
die beim Anlegen über den eigenen Dateideskriptor erfasste Identität besitzen.
Die Prüfung verwendet `lstat` und folgt keinem nachträglich eingesetzten Link.
Anschließend ersetzt `os.replace` ausschließlich die eigene Reservierung. Erst
danach wird der Pfad als Ergebnis zurückgegeben.

Bei einem Fehler versucht Absorber, die eigene temporäre Datei und eigene
Reservierung zu entfernen. Eine fremd ersetzte Datei bleibt erhalten. Wenn eine
Dateikopie ein Schreibschutzattribut übernimmt, wird dieses ausschließlich zur
Bereinigung der eigenen temporären Datei angepasst. Die Quelle wird nicht
verändert. Scheitert die Bereinigung oder lässt sich die Identität nicht sicher
feststellen, können eigene temporäre Dateien zurückbleiben; unbekannte Dateien
werden nicht als Ersatz gelöscht. Ein Bereinigungsfehler verdeckt keinen bereits
aufgetretenen Schreibfehler.

Eine Dateiliste wird wie bisher einzeln verarbeitet. Scheitert eine spätere Kopie,
bleiben bereits erfolgreich veröffentlichte frühere Kopien erhalten. Kopierte
Metadaten und Änderungszeiten werden weiterhin von `copy2` übernommen.

Während des kurzen Abschnitts zwischen Reservierung und Ersetzen kann ein leerer
Platzhalter sichtbar sein. Ein gewaltsamer Prozessabbruch kann Platzhalter oder
temporäre Dateien hinterlassen. Die Identitätsprüfung ist keine Sperre gegen eine
externe Änderung zwischen letzter Prüfung und Ersetzen. Es gibt keine zusätzliche
Garantie bei Stromausfall.

Die Regressionen nutzen synthetische Inhalte, tatsächliche parallele Threads und
eine native Qt-Bildkodierung. Die GUI-Suite läuft mit der Qt-Plattform `offscreen`;
sie ist keine Abnahme der echten Windows-Zwischenablage oder des System-Trays.
Konfigurationsdatei, Recent-Liste und virtuelle Aufbewahrung haben getrennte
Lebenszyklen und sind nicht Bestandteil dieses Ausgabefixes.
Loading
Loading