From 4756108ef007386c2980a0440517a25c327ab4b1 Mon Sep 17 00:00:00 2001 From: mmaxjr Date: Fri, 31 Jul 2026 15:08:37 -0300 Subject: [PATCH] Make write_executive_report atomic to avoid destroying prior report write_executive_report opened the destination with path.open("w"), which truncates the file the moment it's opened, before any content is written. Every other writer in this module already goes through _atomic_write_text, which writes to a temp file and renames it into place. Align write_executive_report with the same pattern so a failed write can no longer destroy the previous penetration_test_report.md. Closes #828. --- strix/report/writer.py | 10 ++++++---- tests/test_report_writer.py | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/strix/report/writer.py b/strix/report/writer.py index 1b0a8a1df..0d9e4de6d 100644 --- a/strix/report/writer.py +++ b/strix/report/writer.py @@ -115,10 +115,12 @@ def write_run_record(run_dir: Path, run_record: dict[str, Any]) -> None: def write_executive_report(run_dir: Path, final_scan_result: str) -> None: path = run_dir / "penetration_test_report.md" - with path.open("w", encoding="utf-8") as f: - f.write("# Security Penetration Test Report\n\n") - f.write(f"**Generated:** {datetime.now(UTC).strftime('%Y-%m-%d %H:%M:%S UTC')}\n\n") - f.write(f"{final_scan_result}\n") + content = ( + "# Security Penetration Test Report\n\n" + f"**Generated:** {datetime.now(UTC).strftime('%Y-%m-%d %H:%M:%S UTC')}\n\n" + f"{final_scan_result}\n" + ) + _atomic_write_text(path, content) logger.info("Saved final penetration test report to: %s", path) diff --git a/tests/test_report_writer.py b/tests/test_report_writer.py index 05222796f..76d55215b 100644 --- a/tests/test_report_writer.py +++ b/tests/test_report_writer.py @@ -179,3 +179,21 @@ def test_write_executive_report_writes_markdown(tmp_path: Path) -> None: content = (tmp_path / "penetration_test_report.md").read_text(encoding="utf-8") assert "# Security Penetration Test Report" in content assert "Scan complete. No critical issues." in content + + +def test_write_executive_report_preserves_previous_report_on_failure( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + report_path = tmp_path / "penetration_test_report.md" + report_path.write_text("previous report", encoding="utf-8") + + def boom(*_args: Any, **_kwargs: Any) -> None: + raise OSError("disk full") + + monkeypatch.setattr("strix.report.writer.tempfile.NamedTemporaryFile", boom) + + with pytest.raises(OSError, match="disk full"): + write_executive_report(tmp_path, "new content") + + assert report_path.read_text(encoding="utf-8") == "previous report"