diff --git a/CHANGELOG.md b/CHANGELOG.md index ec6c018..eb3eb8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,14 @@ Versions follow [Semantic Versioning](https://semver.org/). ## [Unreleased] +## [0.19.4] — 2026-07-02 + +### Fixed + +- **Core (Mutator):** Hardened the Atomic Write Barrier to correctly follow symlinks during auto-fix operations, preventing the accidental overwriting of the symlink file itself. +- **Core (Mutator):** Implemented a robust `try...finally` cleanup routine to guarantee the removal of transient `.zenzic-tmp-*` files even if the process receives a `KeyboardInterrupt` (SIGINT) during an atomic write. +- **Diagnostics (Z108):** Enhanced the Z108 (Empty Link Text) regex and AST mutator to correctly detect and patch "formatted empty links" (e.g., `[**](url)` or `` [` `](url) ``), eliminating an AST drift edge-case. + ## [0.19.3] — 2026-07-02 ### 🔒 Security Advisory diff --git a/src/zenzic/cli/_fix.py b/src/zenzic/cli/_fix.py index d50ae64..850dc9a 100644 --- a/src/zenzic/cli/_fix.py +++ b/src/zenzic/cli/_fix.py @@ -19,17 +19,23 @@ def _atomic_write(file_path: Path, content: str) -> None: """Atomic Write Barrier.""" + file_path = file_path.resolve() dir_path = file_path.parent - with tempfile.NamedTemporaryFile( - mode="w", encoding="utf-8", dir=dir_path, delete=False, prefix=".zenzic-tmp-" - ) as tmp: - tmp.write(content) - temp_path = Path(tmp.name) + temp_path: Path | None = None try: + with tempfile.NamedTemporaryFile( + mode="w", encoding="utf-8", dir=dir_path, delete=False, prefix=".zenzic-tmp-" + ) as tmp: + tmp.write(content) + temp_path = Path(tmp.name) os.replace(temp_path, file_path) - except Exception: - temp_path.unlink(missing_ok=True) - raise + temp_path = None + finally: + if temp_path is not None: + try: + os.unlink(temp_path) + except OSError: + pass def fix( diff --git a/src/zenzic/core/mutator.py b/src/zenzic/core/mutator.py index b3fb1a3..99812c8 100644 --- a/src/zenzic/core/mutator.py +++ b/src/zenzic/core/mutator.py @@ -7,7 +7,7 @@ import copy from typing import Protocol -from zenzic.core.ast import LinkNode, Node, TextNode +from zenzic.core.ast import CodeSpanNode, LinkNode, Node, TextNode class Mutation(Protocol): @@ -21,17 +21,28 @@ def apply(self, node: Node) -> bool: ... +def _has_text_content(node: Node) -> bool: + """Returns True if the node contains any non-whitespace text or code content recursively.""" + if isinstance(node, TextNode): + stripped = node.text + for char in ("*", "_", "~", "`"): + stripped = stripped.replace(char, "") + return bool(stripped.strip()) + if isinstance(node, CodeSpanNode): + stripped = node.code + for char in ("*", "_", "~", "`"): + stripped = stripped.replace(char, "") + return bool(stripped.strip()) + return any(_has_text_content(child) for child in node.children) + + class EmptyLinkTextMutation: """Z108 Auto-Fix: Injects placeholder text into empty links.""" def apply(self, node: Node) -> bool: mutated = False if isinstance(node, LinkNode): - is_empty = False - if not node.children: - is_empty = True - elif all(isinstance(c, TextNode) and not c.text.strip() for c in node.children): - is_empty = True + is_empty = not any(_has_text_content(child) for child in node.children) if is_empty: node.children = [TextNode(text="MISSING LINK LABEL")] diff --git a/src/zenzic/core/validator.py b/src/zenzic/core/validator.py index dd0178d..1a5fe9d 100644 --- a/src/zenzic/core/validator.py +++ b/src/zenzic/core/validator.py @@ -107,8 +107,8 @@ def _construct_undefined(loader: yaml.SafeLoader, tag_suffix: str, node: yaml.No # Empty link-label detectors used for Z108. Images are excluded; Z403 covers # missing image alt text separately. -_EMPTY_INLINE_LINK_TEXT_RE = re.compile(r"\[\s*\]\(([^)]*)\)") -_EMPTY_REF_LINK_TEXT_RE = re.compile(r"\[\s*\]\[[^\]]*\]") +_EMPTY_INLINE_LINK_TEXT_RE = re.compile(r"\[[\s*_~`]*\]\(([^)]*)\)") +_EMPTY_REF_LINK_TEXT_RE = re.compile(r"\[[\s*_~`]*\]\[[^\]]*\]") class LinkInfo(NamedTuple): diff --git a/tests/test_fix.py b/tests/test_fix.py new file mode 100644 index 0000000..d1bd15f --- /dev/null +++ b/tests/test_fix.py @@ -0,0 +1,82 @@ +# SPDX-FileCopyrightText: 2026 PythonWoods +# SPDX-License-Identifier: Apache-2.0 +"""Tests for Atomic Write Barrier and AST Auto-Fix Hardening.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import patch + +import pytest + +from zenzic.cli._fix import _atomic_write +from zenzic.core.mutator import EmptyLinkTextMutation, Mutator +from zenzic.core.parser import parse, serialize +from zenzic.core.validator import _extract_empty_link_texts + + +def test_atomic_write_symlink_preservation(tmp_path: Path) -> None: + """The Symlink Trap: atomic write resolves symlinks, leaving the link intact and updating target.""" + target_file = tmp_path / "real_file.md" + symlink_file = tmp_path / "symlink_file.md" + + target_file.write_text("Original content", encoding="utf-8") + symlink_file.symlink_to(target_file) + + assert symlink_file.is_symlink() + + # Call _atomic_write on the symlink + _atomic_write(symlink_file, "Updated content") + + # Verify the target file got the new content + assert target_file.read_text(encoding="utf-8") == "Updated content" + + # Verify the symlink itself remains a symlink and is not replaced by a regular file + assert symlink_file.is_symlink() + assert symlink_file.resolve() == target_file.resolve() + + +def test_atomic_write_keyboard_interrupt_cleanup(tmp_path: Path) -> None: + """Permission/Termination Denial: KeyboardInterrupt does not leak temporary files.""" + test_file = tmp_path / "test_file.md" + test_file.write_text("Original content", encoding="utf-8") + + # Mock os.replace to raise KeyboardInterrupt + with patch("os.replace", side_effect=KeyboardInterrupt("Simulated Ctrl-C")): + with pytest.raises(KeyboardInterrupt, match="Simulated Ctrl-C"): + _atomic_write(test_file, "New content") + + # Check that no temp files starting with .zenzic-tmp- exist in the directory + temp_files = list(tmp_path.glob(".zenzic-tmp-*")) + assert len(temp_files) == 0, f"Leaked temporary files found: {temp_files}" + + +def test_formatted_empty_link_validation_and_mutation() -> None: + """AST Drift / Empty Link Bypass: Formatted empty links are correctly flagged and mutated.""" + empty_formats = [ + "[](url)", + "[ ](url)", + "[**](url)", + "[*_~` `~_*](url)", + "[*](url)", + "[**][ref]", + ] + + # 1. Test Validator Flags them + for text in empty_formats: + findings = _extract_empty_link_texts(text) + assert len(findings) == 1, f"Expected validator to flag: {text}" + + # 2. Test Mutator Fixes them + mutator = Mutator([EmptyLinkTextMutation()]) + + for text in empty_formats: + if "ref" in text: + # References aren't inline links in standard parsing as LinkNode, skip mutation test + continue + ast = parse(text) + new_ast, changed = mutator.mutate(ast) + assert changed, f"Expected mutator to change: {text}" + + serialized = serialize(new_ast) + assert serialized == "[MISSING LINK LABEL](url)", f"Got: {serialized}"