diff --git a/CHANGELOG.md b/CHANGELOG.md index bd62843..8fe4ddc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.1.0/). ## [Unreleased] ### Behoben / Fixed +- Der PDF-Export lehnt das aktive Markdown-Original und dessen Pfad- oder + Datei-Aliase als Ziel ab. Eine erneute Prüfung vor der Veröffentlichung + schützt auch während des Druckens neu entstandene Verknüpfungen. Bei einem + Wechsel der aktiven Datei bleiben beide Originalpfade geschützt. +- PDF export rejects the original Markdown document and its aliases as a + destination, checking again before publication. Confirmed replacement of + an ordinary PDF remains supported. - Bestehende Markdown-Dokumente werden über `QSaveFile` atomar gespeichert. Teil-Schreibfehler und fehlgeschlagene Ersetzungen erhalten das bisherige Dokument und den ungespeicherten Editorzustand. UTF-8 und die bisherigen diff --git a/main.py b/main.py index 3da4dbd..622f478 100644 --- a/main.py +++ b/main.py @@ -1669,6 +1669,19 @@ def _create_pdf_printer() -> QPrinter: pass return QPrinter(QPrinter.PrinterMode.HighResolution) + def _check_pdf_original(self, target: Path, original: Path | None) -> None: + for source in dict.fromkeys((original, self.current_file)): + if source is None: + continue + if target.resolve() == source.resolve(): + raise OSError("PDF destination is an active Markdown document") + try: + same_file = target.samefile(source) + except FileNotFoundError: + same_file = False + if same_file: + raise OSError("PDF destination aliases an active Markdown document") + def export_pdf(self) -> None: auto_saved_path: Path | None = None if self.current_file is None and not self._is_blank_untitled_document(): @@ -1683,6 +1696,7 @@ def export_pdf(self) -> None: self.is_modified = False self._update_window_title() + original = self.current_file target = self._suggested_export_path() if self.settings.export_confirm: file_name, _ = QFileDialog.getSaveFileName(self, self.t("export_title"), str(target), "PDF Files (*.pdf)") @@ -1697,6 +1711,7 @@ def export_pdf(self) -> None: target = Path(file_name) try: + self._check_pdf_original(target, original) target.parent.mkdir(parents=True, exist_ok=True) with TemporaryDirectory(prefix=".cleanmarkdown-pdf-", dir=target.parent) as export_dir: temporary_pdf = Path(export_dir) / "export.pdf" @@ -1721,6 +1736,7 @@ def export_pdf(self) -> None: trailer = output.read().rstrip() if header != b"%PDF-" or not trailer.endswith(b"%%EOF"): raise OSError("Missing or incomplete PDF output") + self._check_pdf_original(target, original) os.replace(temporary_pdf, target) except Exception: QMessageBox.critical(self, self.t("error"), self.t("cannot_export")) diff --git a/tests/test_pdf_original_protection.py b/tests/test_pdf_original_protection.py new file mode 100644 index 0000000..5a01ca1 --- /dev/null +++ b/tests/test_pdf_original_protection.py @@ -0,0 +1,105 @@ +import os +from pathlib import Path + +import pytest + +os.environ.setdefault('QT_QPA_PLATFORM', 'offscreen') +import main + +@pytest.mark.parametrize('alias', ['direct', 'relative', 'hardlink', 'late-hardlink', 'source-change', 'symlink']) +def test_pdf_export_preserves_active_markdown(tmp_path, monkeypatch, alias): + monkeypatch.setenv('APPDATA', str(tmp_path / 'appdata')) + app = main.QApplication.instance() or main.QApplication([]) + window = main.MainWindow() + source = tmp_path / 'original.md' + original = '# Bücher\n\nUnersetzbares Original.'.encode('utf-8') + source.write_bytes(original) + window.load_file(source) + target = source if alias in ('direct', 'source-change') else tmp_path / 'chosen.pdf' + if alias == 'relative': + (tmp_path / 'sub').mkdir() + target = tmp_path / 'sub' / '..' / source.name + if alias == 'hardlink': + os.link(source, target) + if alias == 'symlink': + try: + target.symlink_to(source) + except OSError as exc: + if getattr(exc, 'winerror', None) != 1314: + raise + window.is_modified = False + window.close() + pytest.skip('Windows account lacks symbolic-link creation privilege') + monkeypatch.setattr(main.QFileDialog, 'getSaveFileName', lambda *args: (str(target), '')) + errors = [] + monkeypatch.setattr(main.QMessageBox, 'critical', lambda *args: errors.append(args)) + if alias == 'source-change': + # Change GUI state during the save dialog: the original chosen at + # export start must remain protected as well as the current file. + replacement = tmp_path / 'replacement.md' + replacement.write_text('# Replacement', encoding='utf-8') + def choose(*args): + window.current_file = replacement + return str(target), '' + monkeypatch.setattr(main.QFileDialog, 'getSaveFileName', choose) + if alias == 'late-hardlink': + real_build = window._build_export_document + def build(): + document = real_build() + class Document: + def print_(self, printer): + document.print_(printer) + os.link(source, target) + return Document() + monkeypatch.setattr(window, '_build_export_document', build) + window.settings.export_confirm = True + try: + window.export_pdf() + assert source.read_bytes() == original + assert target.read_bytes() == original + assert len(errors) == 1 + assert window.statusBar().currentMessage() == window.t('cannot_export') + finally: + window.is_modified = False + window.close() + app.processEvents() + +@pytest.mark.parametrize('stat_error', [False, True]) +def test_pdf_export_normal_target_and_stat_error(tmp_path, monkeypatch, stat_error): + monkeypatch.setenv('APPDATA', str(tmp_path / 'appdata')) + app = main.QApplication.instance() or main.QApplication([]) + window = main.MainWindow() + source = tmp_path / 'source.md' + original = '# Prüfung\n\nBücher bleiben erhalten.'.encode('utf-8') + source.write_bytes(original) + window.load_file(source) + target = tmp_path / 'report.pdf' + target.write_bytes(b'previous PDF') + monkeypatch.setattr(main.QFileDialog, 'getSaveFileName', lambda *args: (str(target), '')) + errors = [] + monkeypatch.setattr(main.QMessageBox, 'critical', lambda *args: errors.append(args)) + if stat_error: + original_samefile = Path.samefile + def samefile(path, other): + if path == target: + raise PermissionError('Cannot establish target identity') + return original_samefile(path, other) + monkeypatch.setattr(Path, 'samefile', samefile) + window.settings.export_confirm = True + try: + window.export_pdf() + assert source.read_bytes() == original + if stat_error: + assert errors + assert target.read_bytes() == b'previous PDF' + assert window.statusBar().currentMessage() == window.t('cannot_export') + else: + assert not errors + assert target.read_bytes().startswith(b'%PDF-') + assert window.t('exported') in window.statusBar().currentMessage() + assert not list(tmp_path.glob('.cleanmarkdown-pdf-*')) + finally: + window.is_modified = False + window.close() + app.processEvents() +