{"record":{"id":"283d54b0c73345fa","repo":"santifer/career-ops","slug":"refusing-to-hash-symlink-childrel","errorCode":null,"errorMessage":"refusing to hash symlink: ${childRel}","messagePattern":"refusing to hash symlink: (.+?)","errorType":"validation","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"plugins/_lock.mjs","lineNumber":49,"sourceCode":" * a rug-pull mutate an un-hashed file. Rejects symlinks (a symlinked file would\n * pass the hash while pointing elsewhere). Excludes node_modules + .git.\n *\n * @param {string} dir absolute plugin directory\n * @returns {{ files: Record<string,string>, integrity: string }}\n */\nexport function hashPluginTree(dir) {\n  const files = {};\n  const walk = (abs, rel) => {\n    let entries;\n    try { entries = readdirSync(abs, { withFileTypes: true }); }\n    catch (err) { throw new Error(`cannot read ${rel || '.'}: ${err.message}`); }\n    for (const e of entries.sort((a, b) => a.name.localeCompare(b.name))) {\n      if (e.name === 'node_modules' || e.name === '.git') continue;\n      const childAbs = path.join(abs, e.name);\n      const childRel = rel ? `${rel}/${e.name}` : e.name;\n      // lstat (not stat) so a symlink is detected, never followed.\n      const st = lstatSync(childAbs);\n      if (st.isSymbolicLink()) throw new Error(`refusing to hash symlink: ${childRel}`);\n      if (st.isDirectory()) walk(childAbs, childRel);\n      else if (st.isFile()) files[childRel] = sha256(readFileSync(childAbs));\n      else throw new Error(`refusing to hash non-regular file: ${childRel}`);\n    }\n  };\n  walk(dir, '');\n  // Aggregate integrity = sha256 over the deterministic sorted \"rel:hash\" join.\n  const aggregate = Object.keys(files).sort().map(k => `${k}:${files[k]}`).join('\\n');\n  return { files, integrity: sha256(Buffer.from(aggregate)) };\n}\n\n/** Read plugins.lock (fail-open to an empty lock — like the rest of the engine). */\nexport function readLock(root) {\n  const file = lockPath(root);\n  if (!existsSync(file)) return { lockfileVersion: LOCK_VERSION, plugins: {} };\n  try {\n    const parsed = JSON.parse(readFileSync(file, 'utf8'));\n    if (!parsed || typeof parsed !== 'object' || typeof parsed.plugins !== 'object') return { lockfileVersion: LOCK_VERSION, plugins: {} };","sourceCodeStart":31,"sourceCodeEnd":67,"githubUrl":"https://github.com/santifer/career-ops/blob/9b17a8ac97b398a496b38e423ae24e433b43254f/plugins/_lock.mjs#L31-L67","documentation":"Thrown by `hashPluginTree` (plugins/_lock.mjs:49) when `lstatSync` reports a symbolic link anywhere in the plugin tree. The walker uses `lstat` (not `stat`) deliberately so a symlink is detected and never followed: a symlink could pass the content hash while pointing to an arbitrary target, letting a rug-pull mutate the real file without changing the recorded hash. This is a fail-closed integrity guard for plugins.lock.","triggerScenarios":"A plugin directory contains a symlink — e.g. a developer symlinked `plugins/myplugin/node_modules` to a shared location, or symlinked config files, or a plugin was installed via `ln -s`. The hash walk encounters the symlinked entry and refuses to continue.","commonSituations":"Symlinking node_modules to save disk in a multi-plugin dev setup; a plugin scaffolded with `npm link`; a config symlinked from elsewhere in the repo; macOS/Windows creating symlinks during unzip of a downloaded plugin archive.","solutions":["Replace the symlink with a real copy of the file/dir, then re-run the lock operation.","If the symlink is for node_modules, note that hashPluginTree already skips node_modules entirely — a symlinked node_modules is fine ONLY if it is literally named 'node_modules'; rename any other symlinked dep dir to 'node_modules' so it is skipped.","Remove the offending symlink if it is not needed for the plugin to function.","Run `find plugins/<name> -type l` to locate every symlink before re-hashing."],"exampleFix":"# before: plugins/myplugin/config.yml -> ../../shared/config.yml (symlink)\nfind plugins/myplugin -type l\n# after: copy the real file in\ncp ../../shared/config.yml plugins/myplugin/config.yml","handlingStrategy":"validation","validationCode":"import { lstatSync } from 'node:fs';\nimport path from 'node:path';\n// Reject symlinks before hashing so the error is caught upstream.\nfunction findSymlinks(root) {\n  const found = [];\n  const walk = (dir) => {\n    for (const e of readdirSync(dir, { withFileTypes: true })) {\n      if (e.name === 'node_modules' || e.name === '.git') continue;\n      const p = path.join(dir, e.name);\n      if (lstatSync(p).isSymbolicLink()) found.push(p);\n      else if (lstatSync(p).isDirectory()) walk(p);\n    }\n  };\n  walk(root);\n  return found;\n}\nconst links = findSymlinks(pluginDir);\nif (links.length) throw new Error(`Resolve symlinks first: ${links.join(', ')}`);","typeGuard":null,"tryCatchPattern":"try {\n  hashPluginTree(pluginDir);\n} catch (err) {\n  if (/refusing to hash symlink/.test(err.message)) {\n    console.error(`Replace the symlink with a real file: ${err.message}`);\n  } else throw err;\n}","preventionTips":["Use real copies, not symlinks, inside plugin trees.","Run `find plugins -type l` in CI to catch stray symlinks before lock generation."],"tags":["plugin","lock","integrity","symlink","security"],"backgroundTag":null,"analyzedSha":"9b17a8ac97b398a496b38e423ae24e433b43254f","analyzedAt":"2026-08-13T00:48:39.135Z","schemaVersion":2},"datasetVersion":"2026-08-13T04:17:16.726Z"}