{"record":{"id":"3ced199eac627e7d","repo":"jackwener/OpenCLI","slug":"output-path-must-not-be-a-symbolic-link-resolve","errorCode":null,"errorMessage":"output path must not be a symbolic link: ${resolved}","messagePattern":"output path must not be a symbolic link: (.+?)","errorType":"validation","errorClass":"ArgumentError","httpStatus":null,"severity":"error","filePath":"clis/pixiv/novel-download-utils.js","lineNumber":86,"sourceCode":"  const missingParts = [];\n  let ancestorStat;\n  while (!ancestorStat) {\n    try {\n      ancestorStat = fs.lstatSync(ancestor);\n    } catch (error) {\n      if (error?.code !== 'ENOENT') {\n        throw new ArgumentError(`output path is not a safe directory: ${ancestor}`);\n      }\n      const parent = path.dirname(ancestor);\n      if (parent === ancestor) {\n        throw new ArgumentError(`output path is not a safe directory: ${resolved}`);\n      }\n      missingParts.unshift(path.basename(ancestor));\n      ancestor = parent;\n    }\n  }\n  if (ancestor === resolved && ancestorStat.isSymbolicLink()) {\n    throw new ArgumentError(`output path must not be a symbolic link: ${resolved}`);\n  }\n  let canonicalAncestor;\n  try {\n    canonicalAncestor = fs.realpathSync.native(ancestor);\n  } catch {\n    throw new ArgumentError(`output path is not a safe directory: ${ancestor}`);\n  }\n  if (!fs.statSync(canonicalAncestor).isDirectory()) {\n    throw new ArgumentError(`output path is not a safe directory: ${ancestor}`);\n  }\n  return path.join(canonicalAncestor, ...missingParts);\n}\n\nexport function pixivPathEntryExists(target) {\n  try {\n    fs.lstatSync(target);\n    return true;\n  } catch (error) {","sourceCodeStart":68,"sourceCodeEnd":104,"githubUrl":"https://github.com/jackwener/OpenCLI/blob/49907e53dc3ade5c223ff0c4c2c2785687cec4e6/clis/pixiv/novel-download-utils.js#L68-L104","documentation":"If the resolved output path itself exists but is a symbolic link, normalizePixivOutputRoot rejects it with ArgumentError. The library refuses symlinked output roots to prevent writes escaping the intended location (symlink-based path traversal / overwriting files elsewhere).","triggerScenarios":"Calling normalizePixivOutputRoot (via outputRoot/outputDir/output) where the resolved output path is a symlink — e.g. ~/pixiv is a symlink to another disk, or 'latest' symlink pointing to a snapshot directory.","commonSituations":"Users who symlink their downloads folder (common with Dropbox/OneDrive setups or 'current -> releases' patterns) hit this guard; security tooling also intentionally creates such links to test traversal defenses.","solutions":["Point --output at the real directory (the symlink's target) instead of the symlink itself.","Remove the symlink and replace it with a real directory (mkdir) if the direct path is required.","Use a bind mount or hardlink the contents instead of symlinking the directory.","If you intentionally need symlink support, copy files through the resolved target path yourself."],"exampleFix":"// before (shell)\n$ ln -s /mnt/bigdrive/pixiv ~/pixiv\n$ pixiv-novel download 12345 -o ~/pixiv   # refused\n// after\n$ pixiv-novel download 12345 -o /mnt/bigdrive/pixiv","handlingStrategy":"validation","validationCode":"import fs from 'node:fs';\nconst st = fs.lstatSync(path.resolve(output)); // throws if missing; check before calling\nif (st.isSymbolicLink()) {\n  output = fs.realpathSync(output); // use the real target instead\n}","typeGuard":"function isRealDirectory(p) {\n  try { return fs.lstatSync(p).isDirectory(); } catch { return false; }\n}","tryCatchPattern":"try {\n  await downloadNovel(id, { output });\n} catch (e) {\n  if (e.message.includes('must not be a symbolic link')) {\n    output = fs.realpathSync(output);\n    await downloadNovel(id, { output });\n  } else throw e;\n}","preventionTips":["Pass the symlink's target (realpath) as --output, not the link itself.","Check with fs.lstatSync(path).isSymbolicLink() before configuring output.","Prefer bind mounts or config that stores the real directory path.","Avoid 'current/latest' symlink patterns for download output roots."],"tags":["filesystem","symlink","security","path-traversal"],"backgroundTag":"symlink-rejected","analyzedSha":"49907e53dc3ade5c223ff0c4c2c2785687cec4e6","analyzedAt":"2026-08-29T08:14:47.543Z","schemaVersion":2},"datasetVersion":"2026-08-29T12:17:43.993Z"}