{"record":{"id":"379581d7842bcc83","repo":"MemPalace/mempalace","slug":"refusing-to-export-label-is-a-symbolic-link-p","errorCode":null,"errorMessage":"refusing to export: {label} is a symbolic link ({path!r}). Remove the symlink or choose a different output path.","messagePattern":"refusing to export: (.+?) is a symbolic link \\((.+?)\\)\\. Remove the symlink or choose a different output path\\.","errorType":"validation","errorClass":"ValueError","httpStatus":null,"severity":"warning","filePath":"mempalace/exporter.py","lineNumber":38,"sourceCode":"from .palace import get_collection\n\n\ndef _safe_path_component(name: str) -> str:\n    \"\"\"Sanitize a string for use as a directory/file name component.\"\"\"\n    name = re.sub(r'[/\\\\:*?\"<>|]', \"_\", name)\n    name = name.strip(\". \")\n    return name or \"unknown\"\n\n\ndef _reject_symlink(path: str, label: str) -> None:\n    \"\"\"Refuse to write into a path that is itself a symlink.\n\n    Defense-in-depth: a pre-placed symlink at the export target would\n    redirect writes to wherever it points (e.g., system directories).\n    Mirrors the miner's input-side caution.\n    \"\"\"\n    if os.path.islink(path):\n        raise ValueError(\n            f\"refusing to export: {label} is a symbolic link ({path!r}). \"\n            f\"Remove the symlink or choose a different output path.\"\n        )\n\n\ndef _safe_open_for_write(path: str, mode: str, encoding: str = \"utf-8\"):\n    \"\"\"Open a file for writing, refusing to follow a symlink at the target path.\n\n    On POSIX (O_NOFOLLOW available) the open itself fails with ELOOP if path is\n    a symlink — closing the TOCTOU window between an islink check and the open.\n    On platforms without O_NOFOLLOW (Windows), pre-checks ``os.path.islink``,\n    which is narrower than no check at all.\n    \"\"\"\n    o_nofollow = getattr(os, \"O_NOFOLLOW\", 0)\n    if o_nofollow:\n        flags = os.O_WRONLY | os.O_CREAT | o_nofollow\n        flags |= os.O_APPEND if \"a\" in mode else os.O_TRUNC\n        try:","sourceCodeStart":20,"sourceCodeEnd":56,"githubUrl":"https://github.com/MemPalace/mempalace/blob/06cb6987f02610784fefbad4b2bd5d026d164ba6/mempalace/exporter.py#L20-L56","documentation":"Raised by _reject_symlink before any export write when the target path itself is a symbolic link. This is defense-in-depth against symlink attacks: a pre-placed symlink at the export destination would redirect writes to wherever it points (system directories, other users' files). Exporter refuses and tells the user to remove the link or pick another path; the same caution mirrors the miner's input-side checks.","triggerScenarios":"Calling export_palace(palace_path, output_dir) (or a per-wing/room export path) where output_dir or one of the wing/room file paths is a symlink — e.g. ~/mempalace-export symlinked to a Dropbox/www directory, or a leftover link created by a previous tool.","commonSituations":"Users symlinking the export dir into a synced folder (Dropbox/iCloud) or a web root; shared machines where an attacker pre-plants links; dotfiles managers that manage directories as symlinks; a previous export run left a symlink behind.","solutions":["Replace the symlink with a real directory: rm <link> && mkdir <dir> (or bind-mount if you need the target location)","Or export to a fresh plain directory and copy/sync afterward: export to ./export && rsync -a ./export/ ~/Dropbox/export/","Check for symlinks first: find <output_dir> -type l","If you need files to appear at the linked location, symlink in the other direction (link name -> real export dir) after export completes"],"exampleFix":"# before\nln -s ~/Dropbox/mempalace-export ~/export-target\nexport_palace(palace_path, output_dir=os.path.expanduser('~/export-target'))\n# after\nrm ~/export-target\nmkdir -p ~/export-target\nexport_palace(palace_path, output_dir=os.path.expanduser('~/export-target'))\nrsync -a ~/export-target/ ~/Dropbox/mempalace-export/","handlingStrategy":"validation","validationCode":"import os\n\ndef export_path_is_safe(output_dir: str) -> bool:\n    p = os.path.expanduser(output_dir)\n    return not os.path.islink(p) and all(not os.path.islink(os.path.join(r, d))\n                                         for r, ds, fs in os.walk(p) for d in ds) if os.path.exists(p) else not os.path.islink(p)","typeGuard":null,"tryCatchPattern":"try:\n    export_palace(palace_path, output_dir=out)\nexcept ValueError as e:\n    if \"refusing to export\" in str(e):\n        os.unlink(out); os.mkdir(out)  # after investigating where the link pointed\n        export_palace(palace_path, output_dir=out)","preventionTips":["Export into a plain directory managed by mempalace, then sync copies elsewhere","Avoid symlinked sync-folder roots (Dropbox/iCloud) as export targets","Audit with find <out> -type l before scheduled exports"],"tags":["security","symlink","export","filesystem","defense-in-depth"],"backgroundTag":null,"analyzedSha":"06cb6987f02610784fefbad4b2bd5d026d164ba6","analyzedAt":"2026-08-15T03:03:36.213Z","schemaVersion":2},"datasetVersion":"2026-08-15T17:31:12.345Z"}