fix(exporter): refuse symlinks at export targets
A symlink pre-placed at the export output_dir or any wing subdirectory would redirect markdown writes to wherever the symlink points. The miner already rejects symlinked inputs via Path.is_symlink(); the exporter should apply the same caution to outputs. Add _reject_symlink() helper and call it before makedirs on both output_dir and each wing_dir. Refusal raises ValueError with a clear message rather than silently falling through. Closes #1156
This commit is contained in:
@@ -26,6 +26,20 @@ def _safe_path_component(name: str) -> str:
|
||||
return name or "unknown"
|
||||
|
||||
|
||||
def _reject_symlink(path: str, label: str) -> None:
|
||||
"""Refuse to write into a path that is itself a symlink.
|
||||
|
||||
Defense-in-depth: a pre-placed symlink at the export target would
|
||||
redirect writes to wherever it points (e.g., system directories).
|
||||
Mirrors the miner's input-side caution.
|
||||
"""
|
||||
if os.path.islink(path):
|
||||
raise ValueError(
|
||||
f"refusing to export: {label} is a symbolic link ({path!r}). "
|
||||
f"Remove the symlink or choose a different output path."
|
||||
)
|
||||
|
||||
|
||||
def export_palace(palace_path: str, output_dir: str, format: str = "markdown") -> dict:
|
||||
"""Export all palace drawers as markdown files organized by wing/room.
|
||||
|
||||
@@ -48,6 +62,7 @@ def export_palace(palace_path: str, output_dir: str, format: str = "markdown") -
|
||||
print(" Palace is empty — nothing to export.")
|
||||
return {"wings": 0, "rooms": 0, "drawers": 0}
|
||||
|
||||
_reject_symlink(output_dir, "output_dir")
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
try:
|
||||
os.chmod(output_dir, 0o700)
|
||||
@@ -89,6 +104,7 @@ def export_palace(palace_path: str, output_dir: str, format: str = "markdown") -
|
||||
safe_wing = _safe_path_component(wing)
|
||||
wing_dir = os.path.join(output_dir, safe_wing)
|
||||
if wing_dir not in created_wing_dirs:
|
||||
_reject_symlink(wing_dir, f"wing directory {safe_wing!r}")
|
||||
os.makedirs(wing_dir, exist_ok=True)
|
||||
try:
|
||||
os.chmod(wing_dir, 0o700)
|
||||
|
||||
@@ -134,3 +134,45 @@ def test_export_empty_palace():
|
||||
assert stats == {"wings": 0, "rooms": 0, "drawers": 0}
|
||||
finally:
|
||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||
|
||||
|
||||
def test_export_refuses_symlinked_output_dir():
|
||||
"""A symlink at the output path must not be followed (defense-in-depth)."""
|
||||
import pytest
|
||||
|
||||
tmpdir = tempfile.mkdtemp()
|
||||
try:
|
||||
palace_path = _setup_palace(tmpdir)
|
||||
decoy_target = os.path.join(tmpdir, "decoy_target")
|
||||
os.makedirs(decoy_target)
|
||||
output_dir = os.path.join(tmpdir, "export")
|
||||
os.symlink(decoy_target, output_dir)
|
||||
|
||||
with pytest.raises(ValueError, match="symbolic link"):
|
||||
export_palace(palace_path, output_dir)
|
||||
|
||||
# Decoy target must remain empty — nothing followed the symlink.
|
||||
assert os.listdir(decoy_target) == []
|
||||
finally:
|
||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||
|
||||
|
||||
def test_export_refuses_symlinked_wing_dir():
|
||||
"""A symlink pre-placed at a wing subdirectory must also be refused."""
|
||||
import pytest
|
||||
|
||||
tmpdir = tempfile.mkdtemp()
|
||||
try:
|
||||
palace_path = _setup_palace(tmpdir)
|
||||
decoy_target = os.path.join(tmpdir, "decoy_target")
|
||||
os.makedirs(decoy_target)
|
||||
output_dir = os.path.join(tmpdir, "export")
|
||||
os.makedirs(output_dir)
|
||||
os.symlink(decoy_target, os.path.join(output_dir, "alpha"))
|
||||
|
||||
with pytest.raises(ValueError, match="symbolic link"):
|
||||
export_palace(palace_path, output_dir)
|
||||
|
||||
assert os.listdir(decoy_target) == []
|
||||
finally:
|
||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||
|
||||
Reference in New Issue
Block a user