diff --git a/strix/report/writer.py b/strix/report/writer.py index cf858f7d..f3cd8916 100644 --- a/strix/report/writer.py +++ b/strix/report/writer.py @@ -138,10 +138,13 @@ 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") + generated_at = datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S UTC") + report_text = ( + "# Security Penetration Test Report\n\n" + f"**Generated:** {generated_at}\n\n" + f"{final_scan_result}\n" + ) + atomic_write_text(path, report_text) logger.info("Saved final penetration test report to: %s", path) @@ -206,18 +209,23 @@ def atomic_write_text(path: Path, payload: str) -> None: into ``\\r\\r\\n`` on Windows. """ path.parent.mkdir(parents=True, exist_ok=True) - with tempfile.NamedTemporaryFile( - mode="w", - encoding="utf-8", - newline="", - dir=str(path.parent), - prefix=f".{path.name}.", - suffix=".tmp", - delete=False, - ) as tmp: - tmp.write(payload) - tmp_path = Path(tmp.name) - tmp_path.replace(path) + tmp_path: Path | None = None + try: + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + newline="", + dir=str(path.parent), + prefix=f".{path.name}.", + suffix=".tmp", + delete=False, + ) as tmp: + tmp_path = Path(tmp.name) + tmp.write(payload) + tmp_path.replace(path) + finally: + if tmp_path is not None: + tmp_path.unlink(missing_ok=True) def render_vulnerability_md(report: dict[str, Any]) -> str: # noqa: PLR0912, PLR0915 diff --git a/tests/test_report_writer.py b/tests/test_report_writer.py index 9b849010..e9557a2b 100644 --- a/tests/test_report_writer.py +++ b/tests/test_report_writer.py @@ -4,7 +4,8 @@ from __future__ import annotations import csv import json -from typing import TYPE_CHECKING, Any +from pathlib import Path +from typing import Any import pytest @@ -18,10 +19,6 @@ from strix.report.writer import ( ) -if TYPE_CHECKING: - from pathlib import Path - - def _sample_report(**overrides: Any) -> dict[str, Any]: base: dict[str, Any] = { "id": "vuln-0001", @@ -240,6 +237,40 @@ def test_write_executive_report_writes_markdown(tmp_path: Path) -> None: assert "Scan complete. No critical issues." in content +def test_write_executive_report_delegates_to_atomic_write( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + calls: list[tuple[Path, str]] = [] + + def fake_atomic(path: Path, payload: str) -> None: + calls.append((path, payload)) + + monkeypatch.setattr("strix.report.writer.atomic_write_text", fake_atomic) + write_executive_report(tmp_path, "Executive test summary.") + + assert len(calls) == 1 + assert calls[0][0] == tmp_path / "penetration_test_report.md" + assert "Executive test summary." in calls[0][1] + + +def test_atomic_write_text_cleans_up_temp_file_on_replace_failure( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + target = tmp_path / "report.md" + + def fail_replace(_self: Path, _other: Path) -> Path: + raise OSError("Simulated disk replace error") + + monkeypatch.setattr(Path, "replace", fail_replace) + + with pytest.raises(OSError, match="Simulated disk replace error"): + atomic_write_text(target, "content") + + # Assert no leaked .tmp files exist in directory + assert list(tmp_path.glob(".*.tmp")) == [] + + + def test_render_vulnerability_md_surfaces_calibration_metadata() -> None: """Confidence, the case against the finding, and retest status are part of the deliverable — storing them without rendering hides the reasoning."""