{"record":{"id":"5e4e3d8bad07898f","repo":"MemPalace/mempalace","slug":"refusing-to-write-path-r-is-a-symbolic-link","errorCode":null,"errorMessage":"refusing to write: {path!r} is a symbolic link.","messagePattern":"refusing to write: (.+?) is a symbolic link\\.","errorType":"validation","errorClass":"ValueError","httpStatus":null,"severity":"warning","filePath":"mempalace/exporter.py","lineNumber":60,"sourceCode":"\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:\n            fd = os.open(path, flags, 0o600)\n        except OSError as e:\n            if e.errno == errno.ELOOP:\n                raise ValueError(f\"refusing to write: {path!r} is a symbolic link.\") from None\n            raise\n        return os.fdopen(fd, mode, encoding=encoding)\n    if os.path.islink(path):\n        raise ValueError(f\"refusing to write: {path!r} is a symbolic link.\")\n    return open(path, mode, encoding=encoding)\n\n\ndef export_palace(palace_path: str, output_dir: str, format: str = \"markdown\") -> dict:\n    \"\"\"Export all palace drawers as markdown files organized by wing/room.\n\n    Streams drawers in batches of 1000 and writes each wing/room file\n    incrementally, keeping memory usage proportional to batch size rather\n    than total palace size.\n\n    Args:\n        palace_path: Path to the ChromaDB palace directory.\n        output_dir: Where to write the exported markdown tree.\n        format: Output format (currently only \"markdown\").","sourceCodeStart":42,"sourceCodeEnd":78,"githubUrl":"https://github.com/MemPalace/mempalace/blob/06cb6987f02610784fefbad4b2bd5d026d164ba6/mempalace/exporter.py#L42-L78","documentation":"Raised by _safe_open_for_write when opening the export target hits ELOOP — on POSIX this happens because the open uses O_NOFOLLOW, so a last-instant symlink at the path makes the kernel reject the open. This closes the TOCTOU window between the earlier islink() check (_reject_symlink) and the actual open: even if a symlink is placed after the check, the O_NOFOLLOW flag makes the open itself fail, and the ELOOP errno is translated into this clear ValueError. On Windows (no O_NOFOLLOW) a pre-check os.path.islink raises the same message.","triggerScenarios":"A race where a symlink is created at the export file path between _reject_symlink's check and the open (concurrent process, malicious local user, or automation tooling that swaps paths); or on Windows, an NTFS symlink/junction at the target file path.","commonSituations":"Multi-process exports to the same directory; watched-folder daemons replacing files with links; Windows directory-junction setups; hostile shared machines; file-sync clients converting files to placeholders/links.","solutions":["Treat this as a security signal: inspect what created the link — ls -la <path> shows the target; remove it (rm <path>) and retry the export","If a sync client or watcher is converting files to links, exclude the export directory from it or export elsewhere and copy after completion","On Windows, remove the junction/symlink: rmdir <link> (junctions) or del <link>, then retry","Export to a private, non-shared directory to avoid concurrent path manipulation"],"exampleFix":"# before: retry loop blindly re-exporting into a swapped path\nexport_palace(palace, output_dir=shared_dir)  # ValueError: refusing to write\nexport_palace(palace, output_dir=shared_dir)  # same failure\n# after: inspect, remove, retry once\nimport os\np = os.path.join(shared_dir, 'wing.md')\nif os.path.islink(p):\n    os.unlink(p)  # investigate what pointed where first: os.readlink(p)\nexport_palace(palace, output_dir=shared_dir)","handlingStrategy":"validation","validationCode":"import os\n\ndef no_symlink_at(path: str) -> bool:\n    return not os.path.islink(path)\n\n# check immediately before export; O_NOFOLLOW still guards the race at open time","typeGuard":null,"tryCatchPattern":"try:\n    export_palace(palace_path, output_dir=out)\nexcept ValueError as e:\n    if \"refusing to write\" in str(e):\n        target = os.readlink(path) if os.path.islink(path) else '(replaced concurrently)'\n        log.warning(\"symlink attack or race detected; link pointed at %s\", target)\n        os.unlink(path)\n        export_palace(palace_path, output_dir=out)","preventionTips":["Export to a private directory no other process watches or rewrites","Exclude export dirs from sync clients and file watchers that swap in links/placeholders","Treat an ELOOP-derived error as a security event: log the link target before removing it","On Windows, avoid NTFS junctions inside the export tree"],"tags":["security","symlink","toctou","export","filesystem"],"backgroundTag":null,"analyzedSha":"06cb6987f02610784fefbad4b2bd5d026d164ba6","analyzedAt":"2026-08-15T03:03:36.213Z","schemaVersion":2},"datasetVersion":"2026-08-15T22:17:37.221Z"}