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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand All @@ -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)")
Expand All @@ -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"
Expand All @@ -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"))
Expand Down
105 changes: 105 additions & 0 deletions tests/test_pdf_original_protection.py
Original file line number Diff line number Diff line change
@@ -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()

Loading