Skip to content

fix(exporter): refuse symlinks at export targets (#1156) - #1405

Merged
igorls merged 2 commits into
developfrom
fix/1156-exporter-reject-symlinks
May 7, 2026
Merged

igorls merged 2 commits into
developfrom
fix/1156-exporter-reject-symlinks

Conversation

@igorls

@igorls igorls commented May 7, 2026

Copy link
Copy Markdown
Member

Summary

A symlink pre-placed at the export output_dir or any wing subdirectory would redirect markdown writes to wherever the symlink points (e.g., system directories). The miner already rejects symlinked inputs via Path.is_symlink(); the exporter should apply the same caution to outputs.

This is defense-in-depth, not an actively exploited path — but it closes the obvious surface and matches the existing input-side hardening.

Changes

  • mempalace/exporter.py: new _reject_symlink() helper raises ValueError if the target path is a symlink. Called before makedirs on output_dir and on each wing_dir.
  • tests/test_exporter.py: two new tests covering pre-placed symlink at output root and at a wing subdirectory. Both verify the decoy target stays empty.

Test plan

  • uv run pytest tests/test_exporter.py -v — 6 passed (2 new)
  • uvx --from 'ruff>=0.4.0,<0.5' ruff check + format check — clean

Closes #1156
Split from #934 (finding 7).

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
Copilot AI review requested due to automatic review settings May 7, 2026 15:40
@igorls igorls added this to the v3.3.5 milestone May 7, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR hardens the exporter against a symlink-based write redirection by refusing to export into an output directory (or wing subdirectory) that is itself a symlink, aligning exporter behavior with the miner’s existing input-side symlink avoidance.

Changes:

  • Add a new mempalace.exporter._reject_symlink() helper and call it before creating output_dir and each wing_dir.
  • Add tests asserting export refuses a symlinked output root and a symlinked wing directory, and that the decoy target remains untouched.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

File Description
mempalace/exporter.py Adds symlink rejection checks for output root and wing directories prior to directory creation.
tests/test_exporter.py Adds regression tests for refusing symlinked export targets (root and wing).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread mempalace/exporter.py
Comment on lines 63 to 67
return {"wings": 0, "rooms": 0, "drawers": 0}

_reject_symlink(output_dir, "output_dir")
os.makedirs(output_dir, exist_ok=True)
try:
Comment thread tests/test_exporter.py
Comment on lines +143 to +152
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)
Comment thread tests/test_exporter.py
Comment on lines +168 to +175
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)

Address Copilot review on #1156:

- Per-file symlink check via new _safe_open_for_write() helper. Uses
  O_NOFOLLOW on POSIX (close TOCTOU window between islink check and
  open) and falls back to islink + open on Windows. Applied to room
  files and index.md, mirroring the existing dir-level check.
- Tests now wrap os.symlink() in _try_symlink_or_skip() so Windows
  without Developer Mode and restricted CI sandboxes skip rather than
  hard-fail. Added two regression tests for the file-level cases
  (room file, index.md).
@igorls
igorls merged commit ba30ab6 into develop May 7, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug: exporter output path follows symlinks

2 participants