jackwener/OpenCLI · error · ArgumentError

output path must not be a symbolic link: ${resolved}

Error message

output path must not be a symbolic link: ${resolved}

What it means

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).

Source

Thrown at clis/pixiv/novel-download-utils.js:86

  const missingParts = [];
  let ancestorStat;
  while (!ancestorStat) {
    try {
      ancestorStat = fs.lstatSync(ancestor);
    } catch (error) {
      if (error?.code !== 'ENOENT') {
        throw new ArgumentError(`output path is not a safe directory: ${ancestor}`);
      }
      const parent = path.dirname(ancestor);
      if (parent === ancestor) {
        throw new ArgumentError(`output path is not a safe directory: ${resolved}`);
      }
      missingParts.unshift(path.basename(ancestor));
      ancestor = parent;
    }
  }
  if (ancestor === resolved && ancestorStat.isSymbolicLink()) {
    throw new ArgumentError(`output path must not be a symbolic link: ${resolved}`);
  }
  let canonicalAncestor;
  try {
    canonicalAncestor = fs.realpathSync.native(ancestor);
  } catch {
    throw new ArgumentError(`output path is not a safe directory: ${ancestor}`);
  }
  if (!fs.statSync(canonicalAncestor).isDirectory()) {
    throw new ArgumentError(`output path is not a safe directory: ${ancestor}`);
  }
  return path.join(canonicalAncestor, ...missingParts);
}

export function pixivPathEntryExists(target) {
  try {
    fs.lstatSync(target);
    return true;
  } catch (error) {

View on GitHub (pinned to 49907e53dc)

Solutions

  1. Point --output at the real directory (the symlink's target) instead of the symlink itself.
  2. Remove the symlink and replace it with a real directory (mkdir) if the direct path is required.
  3. Use a bind mount or hardlink the contents instead of symlinking the directory.
  4. If you intentionally need symlink support, copy files through the resolved target path yourself.

Example fix

// before (shell)
$ ln -s /mnt/bigdrive/pixiv ~/pixiv
$ pixiv-novel download 12345 -o ~/pixiv   # refused
// after
$ pixiv-novel download 12345 -o /mnt/bigdrive/pixiv
Defensive patterns

Strategy: validation

Validate before calling

import fs from 'node:fs';
const st = fs.lstatSync(path.resolve(output)); // throws if missing; check before calling
if (st.isSymbolicLink()) {
  output = fs.realpathSync(output); // use the real target instead
}

Type guard

function isRealDirectory(p) {
  try { return fs.lstatSync(p).isDirectory(); } catch { return false; }
}

Try / catch

try {
  await downloadNovel(id, { output });
} catch (e) {
  if (e.message.includes('must not be a symbolic link')) {
    output = fs.realpathSync(output);
    await downloadNovel(id, { output });
  } else throw e;
}

Prevention

When it happens

Trigger: 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.

Common situations: 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.

Related errors


AI-assisted analysis of jackwener/OpenCLI@49907e53dc (2026-08-29). Data as JSON: /api/errors/3ced199eac627e7d. Report an issue: GitHub.