From 8bce969d982a743561d90040f827d219c4ded0b1 Mon Sep 17 00:00:00 2001 From: Roman Lutz Date: Tue, 26 May 2026 14:29:42 -0700 Subject: [PATCH 1/3] MAINT: Standardize path handling on pathlib Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pyrit/backend/mappers/attack_mappers.py | 6 ++--- pyrit/backend/routes/media.py | 32 ++++++++++++------------- pyrit/models/storage_io.py | 7 +++--- pyrit/output/conversation/markdown.py | 31 +++++++++++++----------- tests/unit/backend/test_media_route.py | 24 +++++++++++++++++++ tests/unit/models/test_storage_io.py | 12 +++++----- tests/unit/output/test_blur_images.py | 18 +++++++------- 7 files changed, 79 insertions(+), 51 deletions(-) diff --git a/pyrit/backend/mappers/attack_mappers.py b/pyrit/backend/mappers/attack_mappers.py index 9fe88ecf6c..acebb40e60 100644 --- a/pyrit/backend/mappers/attack_mappers.py +++ b/pyrit/backend/mappers/attack_mappers.py @@ -13,10 +13,10 @@ import logging import mimetypes -import os import time import uuid from datetime import datetime, timedelta, timezone +from pathlib import Path from typing import TYPE_CHECKING, Optional, cast from urllib.parse import quote, urlparse @@ -178,7 +178,7 @@ def _resolve_media_url(*, value: Optional[str], data_type: str) -> Optional[str] if value.startswith(("http://", "https://", "data:")): return value # Local file path — construct a media endpoint URL - if os.path.isfile(value): + if Path(value).is_file(): return f"/api/media?path={quote(str(value))}" return value @@ -373,7 +373,7 @@ def _build_filename( source = value if source.startswith("http"): source = urlparse(source).path - ext = os.path.splitext(source)[1] # e.g. ".png" + ext = Path(source).suffix # e.g. ".png" if not ext: # Fallback: guess from mime type based on data type prefix diff --git a/pyrit/backend/routes/media.py b/pyrit/backend/routes/media.py index 6eafb6b5a3..e8969bbfeb 100644 --- a/pyrit/backend/routes/media.py +++ b/pyrit/backend/routes/media.py @@ -12,7 +12,6 @@ import logging import mimetypes -import os from pathlib import Path from fastapi import APIRouter, HTTPException, Query @@ -61,18 +60,18 @@ } -def _validate_media_path(*, path: str, allowed_root: str) -> str: +def _validate_media_path(*, path: str, allowed_root: Path) -> Path: """ Validate and sanitize a user-provided file path against an allowed root directory. - Uses ``os.path.realpath`` to resolve symlinks and ``..`` components, then - verifies the canonical path starts with the allowed root prefix. This is - the standard sanitization pattern recognized by static analysis tools - (e.g. CodeQL ``py/path-injection``). + Uses ``Path.resolve()`` to resolve symlinks and ``..`` components, then + verifies the canonical path is under the allowed root. This is the standard + sanitization pattern recognized by static analysis tools (e.g. CodeQL + ``py/path-injection``). Args: path: The user-provided file path to validate. - allowed_root: The canonical (``realpath``-resolved) allowed root directory. + allowed_root: The canonical (``resolve``-d) allowed root directory. Returns: The canonical, validated file path. @@ -80,20 +79,21 @@ def _validate_media_path(*, path: str, allowed_root: str) -> str: Raises: HTTPException 403: If the path fails any validation check. """ - real_path = os.path.realpath(path) - allowed_prefix = allowed_root + os.sep + real_path = Path(path).resolve(strict=False) - if not real_path.startswith(allowed_prefix): - raise HTTPException(status_code=403, detail="Access denied: path is outside the allowed results directory.") + try: + relative_parts = real_path.relative_to(allowed_root).parts + except ValueError as exc: + raise HTTPException( + status_code=403, detail="Access denied: path is outside the allowed results directory." + ) from exc # Restrict to known media subdirectories (e.g. prompt-memory-entries/) - relative_parts = Path(os.path.relpath(real_path, allowed_root)).parts if not relative_parts or relative_parts[0] not in _ALLOWED_SUBDIRECTORIES: raise HTTPException(status_code=403, detail="Access denied: path is not in a media subdirectory.") # Only allow known media file extensions - _, ext = os.path.splitext(real_path) - if ext.lower() not in _ALLOWED_EXTENSIONS: + if real_path.suffix.lower() not in _ALLOWED_EXTENSIONS: raise HTTPException(status_code=403, detail="Access denied: file type is not allowed.") return real_path @@ -125,13 +125,13 @@ async def serve_media_async( memory = CentralMemory.get_memory_instance() if not memory.results_path: raise HTTPException(status_code=500, detail="Memory results_path is not configured.") - allowed_root = os.path.realpath(memory.results_path) + allowed_root = Path(memory.results_path).resolve(strict=False) except Exception as exc: raise HTTPException(status_code=500, detail="Memory not initialized; cannot determine results path.") from exc validated_path = _validate_media_path(path=path, allowed_root=allowed_root) - if not os.path.isfile(validated_path): + if not validated_path.is_file(): raise HTTPException(status_code=404, detail="File not found.") mime_type, _ = mimetypes.guess_type(validated_path) diff --git a/pyrit/models/storage_io.py b/pyrit/models/storage_io.py index 3491b4b749..17abf5fb89 100644 --- a/pyrit/models/storage_io.py +++ b/pyrit/models/storage_io.py @@ -4,7 +4,6 @@ from __future__ import annotations import logging -import os from abc import ABC, abstractmethod from enum import Enum from pathlib import Path @@ -110,7 +109,7 @@ async def path_exists(self, path: Union[Path, str]) -> bool: """ path = self._convert_to_path(path) - return os.path.exists(path) + return path.exists() async def is_file(self, path: Union[Path, str]) -> bool: """ @@ -124,7 +123,7 @@ async def is_file(self, path: Union[Path, str]) -> bool: """ path = self._convert_to_path(path) - return os.path.isfile(path) + return path.is_file() async def create_directory_if_not_exists(self, path: Union[Path, str]) -> None: """ @@ -136,7 +135,7 @@ async def create_directory_if_not_exists(self, path: Union[Path, str]) -> None: """ directory_path = self._convert_to_path(path) if not directory_path.exists(): - os.makedirs(directory_path, exist_ok=True) + directory_path.mkdir(parents=True, exist_ok=True) def _convert_to_path(self, path: Union[Path, str]) -> Path: """ diff --git a/pyrit/output/conversation/markdown.py b/pyrit/output/conversation/markdown.py index c48843a7b0..d13fe64b69 100644 --- a/pyrit/output/conversation/markdown.py +++ b/pyrit/output/conversation/markdown.py @@ -4,6 +4,7 @@ import contextlib import logging import os +from pathlib import Path from pyrit.models import Message, MessagePiece, Score from pyrit.output.conversation.base import ConversationPrinterBase @@ -224,11 +225,13 @@ def _format_image_content(self, *, image_path: str) -> list[str]: @staticmethod def _format_link_path(path: str) -> str: """Return a markdown-friendly link (POSIX separators, relative if possible).""" + path_obj = Path(path) try: - relative_path = os.path.relpath(path) + relative_path = str(path_obj.relative_to(Path.cwd())) except ValueError: - # Different mount/drive than cwd (Windows). Fall back to the absolute path. - relative_path = os.path.abspath(path) + # Path is not under cwd (different drive on Windows, or simply outside cwd). + # Fall back to the absolute path. + relative_path = str(path_obj.resolve()) return relative_path.replace("\\", "/") def _maybe_blur_image_on_disk(self, *, image_path: str) -> str | None: @@ -251,12 +254,12 @@ def _maybe_blur_image_on_disk(self, *, image_path: str) -> str | None: str | None: The path to the blurred image, or ``None`` on failure. """ try: - blurred_path = self._blurred_destination(image_path=image_path) - if os.path.exists(blurred_path): + blurred_path = Path(self._blurred_destination(image_path=image_path)) + if blurred_path.exists(): logger.debug(f"Reusing cached blurred image at {blurred_path}") - return blurred_path + return str(blurred_path) - os.makedirs(os.path.dirname(blurred_path) or ".", exist_ok=True) + blurred_path.parent.mkdir(parents=True, exist_ok=True) from pyrit.output._image_utils import blur_image_bytes @@ -264,17 +267,17 @@ def _maybe_blur_image_on_disk(self, *, image_path: str) -> str | None: original_bytes = f.read() blurred_bytes = blur_image_bytes(image_bytes=original_bytes, radius=self._blur_radius) - temp_path = f"{blurred_path}.tmp.{os.getpid()}" + temp_path = blurred_path.parent / f"{blurred_path.name}.tmp.{os.getpid()}" try: with open(temp_path, "wb") as f: f.write(blurred_bytes) os.replace(temp_path, blurred_path) except Exception: - if os.path.exists(temp_path): + if temp_path.exists(): with contextlib.suppress(OSError): - os.remove(temp_path) + temp_path.unlink() raise - return blurred_path + return str(blurred_path) except Exception as exc: logger.warning(f"Failed to write blurred image for {image_path}; falling back to a text link. Error: {exc}") return None @@ -289,9 +292,9 @@ def _blurred_destination(self, *, image_path: str) -> str: Returns: str: Path to the blurred file (sibling by default, or under ``blurred_dir``). """ - directory = self._blurred_dir if self._blurred_dir is not None else os.path.dirname(image_path) - stem = os.path.splitext(os.path.basename(image_path))[0] - return os.path.join(directory, f"{stem}_blurred.png") + image_path_obj = Path(image_path) + directory = Path(self._blurred_dir) if self._blurred_dir is not None else image_path_obj.parent + return str(directory / f"{image_path_obj.stem}_blurred.png") def _format_audio_content(self, *, audio_path: str) -> list[str]: """ diff --git a/tests/unit/backend/test_media_route.py b/tests/unit/backend/test_media_route.py index 89b031f179..f234016922 100644 --- a/tests/unit/backend/test_media_route.py +++ b/tests/unit/backend/test_media_route.py @@ -70,6 +70,30 @@ def test_rejects_path_traversal(self, client: TestClient, _mock_memory: Path) -> response = client.get("/api/media", params={"path": traversal_path}) assert response.status_code == 403 + def test_rejects_symlink_pointing_outside_results(self, client: TestClient, _mock_memory: Path) -> None: + """A symlink under an allowed subdirectory that points outside the results dir is rejected. + + ``Path.resolve()`` resolves symlinks, so a symlink that targets a file outside the + allowed root must be rejected just like a plain path traversal attempt. + """ + # Create a file outside the allowed directory + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp: + tmp.write(b"\x89PNG\r\n\x1a\n") + outside_target = tmp.name + + try: + symlink_path = _mock_memory / "prompt-memory-entries" / "evil_symlink.png" + try: + os.symlink(outside_target, symlink_path) + except (OSError, NotImplementedError) as exc: + # Symlink creation may fail on Windows without admin / developer mode. + pytest.skip(f"Cannot create symlink in this environment: {exc}") + + response = client.get("/api/media", params={"path": str(symlink_path)}) + assert response.status_code == 403 + finally: + os.unlink(outside_target) + def test_returns_404_for_nonexistent_file(self, client: TestClient, _mock_memory: Path) -> None: """Non-existent files under allowed subdirectory return 404.""" file_path = _mock_memory / "prompt-memory-entries" / "nonexistent.png" diff --git a/tests/unit/models/test_storage_io.py b/tests/unit/models/test_storage_io.py index af69d46baf..223b36e710 100644 --- a/tests/unit/models/test_storage_io.py +++ b/tests/unit/models/test_storage_io.py @@ -51,30 +51,30 @@ async def test_disk_storage_io_path_exists(): storage = DiskStorageIO() path = "sample.txt" - with patch("os.path.exists", return_value=True) as mock_exists: + with patch("pathlib.Path.exists", return_value=True) as mock_exists: result = await storage.path_exists(path) assert result is True - mock_exists.assert_called_once_with(Path(path)) + mock_exists.assert_called_once() async def test_disk_storage_io_is_file(): storage = DiskStorageIO() path = "sample.txt" - with patch("os.path.isfile", return_value=True) as mock_isfile: + with patch("pathlib.Path.is_file", return_value=True) as mock_isfile: result = await storage.is_file(path) assert result is True - mock_isfile.assert_called_once_with(Path(path)) + mock_isfile.assert_called_once() async def test_disk_storage_io_create_directory_if_not_exists(): storage = DiskStorageIO() directory_path = "sample_dir" - with patch("os.makedirs") as mock_mkdir, patch("pathlib.Path.exists", return_value=False) as mock_exists: + with patch("pathlib.Path.mkdir") as mock_mkdir, patch("pathlib.Path.exists", return_value=False) as mock_exists: await storage.create_directory_if_not_exists(directory_path) mock_exists.assert_called_once() - mock_mkdir.assert_called_once_with(Path(directory_path), exist_ok=True) + mock_mkdir.assert_called_once_with(parents=True, exist_ok=True) async def test_azure_blob_storage_io_read_file(azure_blob_storage_io): diff --git a/tests/unit/output/test_blur_images.py b/tests/unit/output/test_blur_images.py index 7639cb58da..e1cdc9b37c 100644 --- a/tests/unit/output/test_blur_images.py +++ b/tests/unit/output/test_blur_images.py @@ -4,7 +4,7 @@ """Tests for the ``blur_images`` flag across the pyrit.output module.""" import io -import os +from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch from PIL import Image @@ -112,10 +112,12 @@ async def test_pretty_does_not_blur_by_default(tmp_path, patch_central_database) def _expected_link(path: str) -> str: + """Mirror ``MarkdownConversationPrinter._format_link_path`` for assertions.""" + path_obj = Path(path) try: - rel = os.path.relpath(path) + rel = str(path_obj.relative_to(Path.cwd())) except ValueError: - rel = os.path.abspath(path) + rel = str(path_obj.resolve()) return rel.replace("\\", "/") @@ -183,16 +185,16 @@ def test_markdown_blur_failure_emits_text_link_to_original(tmp_path, caplog): def test_markdown_format_image_content_handles_cross_drive_path(tmp_path): - """``os.path.relpath`` raises ValueError on Windows for paths on a different - mount than cwd. The formatter must fall back to the absolute path instead of - propagating the error.""" + """``Path.relative_to`` raises ValueError when the path is not under cwd (e.g., + on Windows when paths are on a different drive). The formatter must fall back + to the absolute path instead of propagating the error.""" image_path = str(tmp_path / "img.png") printer = _ConcreteMarkdown() - with patch("pyrit.output.conversation.markdown.os.path.relpath", side_effect=ValueError("cross-drive")): + with patch("pathlib.Path.relative_to", side_effect=ValueError("cross-drive")): lines = printer._format_image_content(image_path=image_path) - expected = os.path.abspath(image_path).replace("\\", "/") + expected = str(Path(image_path).resolve()).replace("\\", "/") assert lines[0] == f"![Image]({expected})\n" From 8f86269ac3874d0f661bd50f44749d8a16f64594 Mon Sep 17 00:00:00 2001 From: Roman Lutz Date: Tue, 26 May 2026 16:31:00 -0700 Subject: [PATCH 2/3] Tighten media-route 403 assertions and add outside-cwd test Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/unit/backend/test_media_route.py | 5 +++++ tests/unit/output/test_blur_images.py | 25 +++++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/tests/unit/backend/test_media_route.py b/tests/unit/backend/test_media_route.py index f234016922..77732cc254 100644 --- a/tests/unit/backend/test_media_route.py +++ b/tests/unit/backend/test_media_route.py @@ -61,6 +61,7 @@ def test_rejects_path_outside_results_directory(self, client: TestClient, _mock_ try: response = client.get("/api/media", params={"path": outside_path}) assert response.status_code == 403 + assert "outside the allowed results directory" in response.json()["detail"] finally: os.unlink(outside_path) @@ -69,6 +70,7 @@ def test_rejects_path_traversal(self, client: TestClient, _mock_memory: Path) -> traversal_path = str(_mock_memory / ".." / ".." / "etc" / "passwd") response = client.get("/api/media", params={"path": traversal_path}) assert response.status_code == 403 + assert "outside the allowed results directory" in response.json()["detail"] def test_rejects_symlink_pointing_outside_results(self, client: TestClient, _mock_memory: Path) -> None: """A symlink under an allowed subdirectory that points outside the results dir is rejected. @@ -91,6 +93,9 @@ def test_rejects_symlink_pointing_outside_results(self, client: TestClient, _moc response = client.get("/api/media", params={"path": str(symlink_path)}) assert response.status_code == 403 + # Confirm the rejection reason is the symlink-escape check specifically, + # not one of the other 403 paths (subdirectory / extension). + assert "outside the allowed results directory" in response.json()["detail"] finally: os.unlink(outside_target) diff --git a/tests/unit/output/test_blur_images.py b/tests/unit/output/test_blur_images.py index e1cdc9b37c..83f22b590e 100644 --- a/tests/unit/output/test_blur_images.py +++ b/tests/unit/output/test_blur_images.py @@ -198,6 +198,31 @@ def test_markdown_format_image_content_handles_cross_drive_path(tmp_path): assert lines[0] == f"![Image]({expected})\n" +def test_markdown_format_link_path_falls_back_to_absolute_when_outside_cwd(tmp_path, monkeypatch): + """Paths that are not under cwd must render as absolute paths (with POSIX + separators) — ``Path.relative_to`` raises ``ValueError`` in that case and + ``_format_link_path`` falls back to ``Path.resolve()``. + + This is a deliberate behavior change from the previous ``os.path.relpath`` + implementation, which would have produced a ``../../...`` chain. + """ + inside_cwd = tmp_path / "cwd" + inside_cwd.mkdir() + outside_dir = tmp_path / "elsewhere" + outside_dir.mkdir() + outside_path = outside_dir / "img.png" + outside_path.write_bytes(b"") + + monkeypatch.chdir(inside_cwd) + + link = MarkdownConversationPrinter._format_link_path(str(outside_path)) + + expected = str(outside_path.resolve()).replace("\\", "/") + assert link == expected + # Never produces ".." dot-dot relative paths in the fallback branch. + assert ".." not in link + + # --- Helpers / wiring --- From b36f0682309a4d9e14d6a759f63f1aea807f0878 Mon Sep 17 00:00:00 2001 From: Roman Lutz Date: Tue, 26 May 2026 16:32:52 -0700 Subject: [PATCH 3/3] Replace _expected_link helper with chdir + literal expected strings Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/unit/output/test_blur_images.py | 65 ++++++++++++++------------- 1 file changed, 35 insertions(+), 30 deletions(-) diff --git a/tests/unit/output/test_blur_images.py b/tests/unit/output/test_blur_images.py index 83f22b590e..c5d4744e1c 100644 --- a/tests/unit/output/test_blur_images.py +++ b/tests/unit/output/test_blur_images.py @@ -111,33 +111,37 @@ async def test_pretty_does_not_blur_by_default(tmp_path, patch_central_database) ipython_display.Image.assert_called_once_with(data=image_bytes) -def _expected_link(path: str) -> str: - """Mirror ``MarkdownConversationPrinter._format_link_path`` for assertions.""" - path_obj = Path(path) - try: - rel = str(path_obj.relative_to(Path.cwd())) - except ValueError: - rel = str(path_obj.resolve()) - return rel.replace("\\", "/") +def _resolved_chdir(monkeypatch, tmp_path: Path) -> Path: + """``chdir`` into ``tmp_path`` and return the resolved path so callers can + construct file paths that compare equal to ``Path.cwd()`` afterwards. + + macOS's ``/var`` -> ``/private/var`` symlink causes ``os.getcwd()`` (and + therefore ``Path.cwd()``) to return the resolved form, so unresolved + ``tmp_path`` children would otherwise fail ``relative_to`` lookups. + """ + work_dir = tmp_path.resolve() + monkeypatch.chdir(work_dir) + return work_dir # --- Markdown path --- -def test_markdown_writes_blurred_sibling_and_links_to_it(tmp_path): +def test_markdown_writes_blurred_sibling_and_links_to_it(tmp_path, monkeypatch): + work_dir = _resolved_chdir(monkeypatch, tmp_path) image_bytes = _make_image_bytes() - image_path = tmp_path / "img.png" + image_path = work_dir / "img.png" image_path.write_bytes(image_bytes) printer = _ConcreteMarkdown(blur_images=True, blur_radius=5) lines = printer._format_image_content(image_path=str(image_path)) - blurred_path = tmp_path / "img_blurred.png" + blurred_path = work_dir / "img_blurred.png" assert blurred_path.exists() assert blurred_path.read_bytes() != image_bytes assert len(lines) == 1 - assert lines[0] == f"![Image]({_expected_link(str(blurred_path))})\n" + assert lines[0] == "![Image](img_blurred.png)\n" def test_markdown_blur_is_idempotent(tmp_path): @@ -157,29 +161,30 @@ def test_markdown_blur_is_idempotent(tmp_path): assert blurred_path.stat().st_mtime_ns == first_mtime -def test_markdown_default_does_not_blur(tmp_path): +def test_markdown_default_does_not_blur(tmp_path, monkeypatch): + work_dir = _resolved_chdir(monkeypatch, tmp_path) image_bytes = _make_image_bytes() - image_path = tmp_path / "img.png" + image_path = work_dir / "img.png" image_path.write_bytes(image_bytes) printer = _ConcreteMarkdown() lines = printer._format_image_content(image_path=str(image_path)) - blurred_path = tmp_path / "img_blurred.png" + blurred_path = work_dir / "img_blurred.png" assert not blurred_path.exists() - assert lines[0] == f"![Image]({_expected_link(str(image_path))})\n" + assert lines[0] == "![Image](img.png)\n" -def test_markdown_blur_failure_emits_text_link_to_original(tmp_path, caplog): +def test_markdown_blur_failure_emits_text_link_to_original(tmp_path, monkeypatch, caplog): # Point at a path that does not exist — blurring should fail gracefully and emit # a text link to the original (NOT an inline image of the original). - bogus_path = str(tmp_path / "does_not_exist.png") + work_dir = _resolved_chdir(monkeypatch, tmp_path) + bogus_path = str(work_dir / "does_not_exist.png") printer = _ConcreteMarkdown(blur_images=True, blur_radius=5) lines = printer._format_image_content(image_path=bogus_path) - expected = _expected_link(bogus_path) - assert lines[0] == f"[image (blur failed — original)]({expected})\n" + assert lines[0] == "[image (blur failed — original)](does_not_exist.png)\n" # Crucially, no inline-image rendering of the unblurred original assert not lines[0].startswith("!") @@ -246,12 +251,13 @@ def test_markdown_attack_result_memory_printer_forwards_blur_flag(patch_central_ # --- Round 2: configurable destination --- -def test_markdown_blurred_dir_redirects_output(tmp_path): +def test_markdown_blurred_dir_redirects_output(tmp_path, monkeypatch): + work_dir = _resolved_chdir(monkeypatch, tmp_path) image_bytes = _make_image_bytes() - image_path = tmp_path / "src" / "img.png" + image_path = work_dir / "src" / "img.png" image_path.parent.mkdir() image_path.write_bytes(image_bytes) - blurred_dir = tmp_path / "blurred" + blurred_dir = work_dir / "blurred" printer = _ConcreteMarkdown(blur_images=True, blur_radius=5, blurred_dir=str(blurred_dir)) lines = printer._format_image_content(image_path=str(image_path)) @@ -260,16 +266,16 @@ def test_markdown_blurred_dir_redirects_output(tmp_path): assert blurred_path.exists() # Original directory must not contain the blurred copy assert not (image_path.parent / "img_blurred.png").exists() - expected_rel = _expected_link(str(blurred_path)) - assert lines[0] == f"![Image]({expected_rel})\n" + assert lines[0] == "![Image](blurred/img_blurred.png)\n" # --- Round 2: atomic write --- -def test_markdown_atomic_write_leaves_no_temp_on_failure(tmp_path): +def test_markdown_atomic_write_leaves_no_temp_on_failure(tmp_path, monkeypatch): + work_dir = _resolved_chdir(monkeypatch, tmp_path) image_bytes = _make_image_bytes() - image_path = tmp_path / "img.png" + image_path = work_dir / "img.png" image_path.write_bytes(image_bytes) printer = _ConcreteMarkdown(blur_images=True, blur_radius=5) @@ -279,11 +285,10 @@ def test_markdown_atomic_write_leaves_no_temp_on_failure(tmp_path): with patch("pyrit.output.conversation.markdown.os.replace", side_effect=OSError("boom")): lines = printer._format_image_content(image_path=str(image_path)) - expected_rel = _expected_link(str(image_path)) - assert lines[0] == f"[image (blur failed — original)]({expected_rel})\n" + assert lines[0] == "[image (blur failed — original)](img.png)\n" # No temp files left behind, no blurred file produced - leftovers = [p.name for p in tmp_path.iterdir() if p.name != "img.png"] + leftovers = [p.name for p in work_dir.iterdir() if p.name != "img.png"] assert leftovers == [], f"Unexpected leftover files: {leftovers}"