diff --git a/README.md b/README.md index df0bea5..7e8f76c 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,7 @@ python3 epub2md.py buch.epub output/buch --force --report berichte/buch.json | `--force` | aus | Bestehendes Ausgabeverzeichnis ersetzen – siehe [Sicherheit](#sicherheit-und-fehlerbehandlung) | | `--verbose`, `-v` | aus | Verarbeitungsschritte und Warnungen auf stderr | | `--dry-run` | aus | EPUB analysieren, geplante Kapiteldateien anzeigen, **nichts** schreiben (auch kein `--report`) | -| `--report DATEI.json` | – | Bericht zusätzlich an diesen Ort schreiben. `OUTPUT/conversion-report.json` entsteht immer | +| `--report DATEI.json` | – | Bericht zusätzlich an diesen Ort schreiben. `OUTPUT/conversion-report.json` entsteht immer. Eine bestehende Datei `DATEI.json` wird ersetzt; Namen, die epub2md selbst im Ausgabeverzeichnis erzeugt (z. B. `manifest.json`), werden abgelehnt (Exit 1). Kann der Bericht nicht geschrieben werden: Exit 1, die Ausgabe ist trotzdem vollständig | | `--version` | – | Programmversion | ### Rückgabecodes diff --git a/epub2md/pipeline.py b/epub2md/pipeline.py index 60c01df..7c47213 100644 --- a/epub2md/pipeline.py +++ b/epub2md/pipeline.py @@ -141,14 +141,32 @@ def _convert(reader: EpubReader, input_path: Path, output_dir: Path, opts: Optio if opts.dry_run: return Result(report=report, chapters=all_chapters, files=files, output_dir=output_dir) + if opts.report: + _check_report_path(Path(opts.report), output_dir, files, rendered.media, opts) _write_output(reader, input_path, output_dir, opts, files, rendered.media) if opts.report: report_path = Path(opts.report) - report_path.parent.mkdir(parents=True, exist_ok=True) - report_path.write_text(writer.to_json(report), encoding="utf-8") + try: + report_path.parent.mkdir(parents=True, exist_ok=True) + report_path.write_text(writer.to_json(report), encoding="utf-8") + except OSError as exc: + raise InputError(f"Bericht (--report) konnte nicht geschrieben werden: {report_path} " + f"({exc.strerror or exc}); die Ausgabe in {output_dir} ist vollständig.") from exc return Result(report=report, chapters=all_chapters, files=files, output_dir=output_dir) +def _check_report_path(report_path: Path, output_dir: Path, files: dict[str, str], media: MediaRegistry, + opts: Options) -> None: + """--report must not replace a file that epub2md itself writes into the output directory.""" + if not is_inside(report_path, output_dir): + return + rel = report_path.resolve().relative_to(output_dir.resolve()).as_posix() + generated = set(files) | ({mf.output for mf in media.files.values()} if opts.extract_media else set()) + if rel in generated: + raise InputError(f"--report {report_path} würde die erzeugte Datei {rel} überschreiben; " + "bitte einen anderen Dateinamen wählen.") + + @dataclass class _Rendered: renderer: Renderer diff --git a/tests/test_qa_regressions.py b/tests/test_qa_regressions.py index 45ecd40..b79ed17 100644 --- a/tests/test_qa_regressions.py +++ b/tests/test_qa_regressions.py @@ -107,3 +107,33 @@ def test_multiple_blank_lines_inside_code_are_kept(convert): text = convert(Book(docs=[Doc("c.xhtml", body)])).chapter("001") assert f"```python\n{code}\n```" in text, text assert "\n\n\n" not in text.replace(code, "") # outside code blank lines are still normalized + + +def _simple() -> Book: + return Book(docs=[Doc("c.xhtml", "
Text.
")]) + + +def test_report_must_not_overwrite_generated_output_file(convert, tmp_path): + """QA2-07: --report OUTPUT/manifest.json silently replaced the manifest with the report.""" + out = tmp_path / "ziel" + run = convert(_simple(), "--report", str(out / "manifest.json"), out=out) + assert run.rc == 1 + assert "--report" in run.stderr + assert not out.exists() + + +def test_report_write_error_is_input_error_not_internal(convert, tmp_path): + """QA2-07: an unwritable --report target ended with exit 4 ("Interner Fehler").""" + import os + import stat + + ro = tmp_path / "ro" + ro.mkdir() + os.chmod(ro, stat.S_IRUSR | stat.S_IXUSR) + try: + run = convert(_simple(), "--report", str(ro / "bericht.json"), out=tmp_path / "ziel") + finally: + os.chmod(ro, stat.S_IRWXU) + assert run.rc == 1, run.stderr + assert "Interner Fehler" not in run.stderr + assert "Bericht" in run.stderr