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
2 changes: 1 addition & 1 deletion .github/workflows/source-platform-smoke.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install "PySide6>=6.5.0" paramiko tzdata pytest
python -m pip install "PySide6>=6.5.0" paramiko tzdata pytest Pillow

- name: Compile modules (bytecode validation)
run: python -m compileall -q .
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ jobs:
run: |
python -m pip install --upgrade pip
python -m pip install -r requirements.txt
python -m pip install pytest
python -m pip install pytest Pillow "tomli>=2.0.0; python_version < '3.11'"

- name: Compile modules
run: python -m compileall -q .
Expand Down
33 changes: 33 additions & 0 deletions COPY_SAFETY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Sicherheit lokaler Dateikopien

Der lokale Kopierhelfer schreibt jede Kopie zunächst in eine exklusiv erzeugte,
zufällig benannte Datei im Zielverzeichnis. Erst nach erfolgreichem `shutil.copy2`
ersetzt `os.replace` das Ziel. Fremde Dateien mit dem früher verwendeten Namen
`<Ziel>.prosync_tmp` werden weder überschrieben noch bereinigt. Auch eine Quelle
mit diesem Namen bleibt erhalten.

Quelle und Ziel mit derselben Dateiidentität werden abgewiesen, einschließlich
Hardlinks und Pfaden durch Verzeichnis-Junctions. Die Identität wird vor der
Reservierung und nach der Kopie geprüft. Fehler bei der Identitätsprüfung führen
zum Abbruch. Parallele Kopierer verwenden unterschiedliche Stagingdateien; unter
Windows können einzelne Veröffentlichungen mit einem Sharingfehler scheitern.

Unter Windows bleibt das Überschreiben einer regulären, einfach verlinkten
schreibgeschützten Zieldatei möglich. Ihr Schreibschutz wird erst unmittelbar vor
dem Ersetzen aufgehoben. Scheitert das Ersetzen, wird der ursprüngliche Modus
wiederhergestellt, sofern das Ziel noch dieselbe Datei ist. Schreibgeschützte
Hardlink- oder Reparse-Ziele werden abgewiesen, damit keine andere Datei über
einen Alias verändert wird. Die Quelle wird niemals beschreibbar gemacht.

Bei Fehlern bereinigt der Helfer ausschließlich seine eigene Stagingdatei und
erhält die ursprüngliche Exception. Eine durch Copy2 schreibgeschützte Stage darf
für diese Bereinigung beschreibbar gemacht werden. Fehler beim Wiederherstellen
oder Bereinigen werden protokolliert; ein extern gesperrtes Objekt kann verbleiben.

Dies ist keine Garantie für einen konsistenten Snapshot einer gleichzeitig
veränderten Quelle oder für Dauerhaftigkeit bei Stromausfall. Externe Dateiaustausche
nach der letzten Prüfung und ein hartes Prozessende bleiben separate Grenzen.
SFTP-Tempdateien und Verzeichnisscans überlappender Syncjobs verwenden andere
Codepfade und sind durch diese Änderung nicht abgesichert.

Die Laufzeitgegenproben stehen in `tests/test_atomic_copy_ownership.py`.
89 changes: 65 additions & 24 deletions ProSyncStart_V3.1.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import uuid
import hashlib
import shutil
import tempfile
import threading
import sqlite3
import time
Expand Down Expand Up @@ -1706,36 +1707,76 @@ def _should_exclude(self, name: str, patterns: list) -> bool:


def _atomic_copy2(src, dst):
"""Bugsweep 26 BUG-copy (KRITISCH): atomar kopieren — tmp + os.replace statt direktem
shutil.copy2. Ein Absturz/kill mitten in copy2 hinterliess sonst eine HALBE Zieldatei (fuer
SQLite-DBs — dem Haupt-Use-Case — fatal: korrupter Header). os.replace ist atomar auf gleichem
Volume; bei Fehler wird die tmp-Datei aufgeraeumt."""
tmp = f"{dst}.prosync_tmp"
"""Kopiert mit Copy2-Metadaten über eine eigene Stage im Zielverzeichnis.

Quelle und bestehendes Ziel bleiben bei Kopierfehlern erhalten. Ein reguläres,
einfach verlinktes Readonly-Ziel darf ersetzt werden; bei einem Fehler wird
sein Modus wiederhergestellt, sofern es noch dieselbe Datei ist. Externe
Dateiaustausche und gleichzeitig veränderte Quellen sind keine Snapshots.
"""
def checked_destination():
source_stat = os.stat(src)
try:
destination_stat = os.stat(dst)
except FileNotFoundError:
return None
if os.path.samestat(source_stat, destination_stat):
raise shutil.SameFileError(f"Quelle und Ziel sind identisch: {src!r}")
return destination_stat

def cleanup_warning(message, error):
# Die ursprüngliche Exception darf auch bei einem Loggerfehler bestehen bleiben.
try:
log_warning(f"{message}: {error}")
except Exception:
pass

checked_destination()
tmp = None
fd = None
original_target = None
try:
if os.path.exists(tmp):
try:
os.chmod(tmp, stat.S_IWRITE | stat.S_IREAD)
os.remove(tmp)
except OSError:
pass
fd, tmp = tempfile.mkstemp(prefix=".prosync-", suffix=".tmp",
dir=os.path.dirname(os.path.abspath(dst)))
os.close(fd)
fd = None
shutil.copy2(src, tmp)
# Auf Windows: Wenn Ziel existiert und schreibgeschützt ist, schreibbar machen
if os.path.exists(dst):
try:
os.chmod(dst, stat.S_IWRITE | stat.S_IREAD)
except OSError:
pass
target_stat = checked_destination()
if os.name == "nt" and target_stat is not None and not target_stat.st_mode & stat.S_IWRITE:
target_link_stat = os.lstat(dst)
reparse = getattr(target_link_stat, "st_file_attributes", 0) & stat.FILE_ATTRIBUTE_REPARSE_POINT
if (not stat.S_ISREG(target_link_stat.st_mode) or reparse
or target_stat.st_nlink != 1
or not os.path.samestat(target_stat, target_link_stat)):
raise PermissionError("Schreibgeschütztes Ziel besitzt einen Dateialias")
original_target = target_stat
os.chmod(dst, target_stat.st_mode | stat.S_IWRITE)
os.replace(tmp, dst)
except BaseException:
try:
if os.path.exists(tmp):
if fd is not None:
try:
os.close(fd)
except OSError as error:
cleanup_warning("Stage-Handle konnte nicht geschlossen werden", error)
if original_target is not None:
try:
current = os.lstat(dst)
if os.path.samestat(original_target, current):
os.chmod(dst, original_target.st_mode)
except OSError as error:
cleanup_warning("Zielmodus konnte nicht wiederhergestellt werden", error)
if tmp is not None:
try:
# Copy2 kann Readonly übertragen: ausschließlich die eigene Stage ändern.
try:
os.remove(tmp)
except PermissionError:
os.chmod(tmp, stat.S_IWRITE | stat.S_IREAD)
except OSError:
pass
os.remove(tmp)
except OSError:
pass
os.remove(tmp)
except FileNotFoundError:
pass
except OSError as error:
cleanup_warning("Eigene Stage konnte nicht entfernt werden", error)
raise


Expand Down
3 changes: 2 additions & 1 deletion _WARTUNG/generate_store_screenshots.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,6 @@ def _assert_font_rendering(app: QApplication) -> None:


def ensure_app():
_force_native_platform()
app = QApplication.instance()
if app is None:
app = QApplication([])
Expand Down Expand Up @@ -254,6 +253,7 @@ def generate_store_assets(output_dir: Path | None = None) -> list[Path]:


def generate_store_screenshots(output_dir: Path | None = None) -> list[Path]:
_force_native_platform()
app = ensure_app()
_assert_font_rendering(app)
output_dir = output_dir or SCREENSHOT_DIR
Expand Down Expand Up @@ -304,6 +304,7 @@ def generate_store_screenshots(output_dir: Path | None = None) -> list[Path]:


def main() -> int:
_force_native_platform()
asset_targets = generate_store_assets()
screenshot_targets = generate_store_screenshots()
for target in [*asset_targets, *screenshot_targets]:
Expand Down
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ dependencies = [
[project.optional-dependencies]
dev = [
"pytest>=9.1.1",
"Pillow>=10.0.0",
"tomli>=2.0.0; python_version < '3.11'",
"pip-licenses>=4.0.0",
"ruff>=0.9.0",
"pyinstaller>=6.10.0",
Expand Down
6 changes: 5 additions & 1 deletion scripts/check_store_readiness.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,15 @@
import hashlib
import json
import re
import tomllib
import zipfile
from pathlib import Path
from typing import Sequence

try:
import tomllib
except ModuleNotFoundError:
import tomli as tomllib


REQUIRED_DOCUMENTS = (
"PRIVACY_POLICY.md",
Expand Down
Loading
Loading