Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 14 additions & 8 deletions src/zenzic/cli/_fix.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
23 changes: 17 additions & 6 deletions src/zenzic/core/mutator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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")]
Expand Down
4 changes: 2 additions & 2 deletions src/zenzic/core/validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
82 changes: 82 additions & 0 deletions tests/test_fix.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# SPDX-FileCopyrightText: 2026 PythonWoods <dev@pythonwoods.dev>
# 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}"
Loading