import path from "node:path"; import { resolveSafeInstallDir, safeDirName, safePathSegmentHashed, unscopedPackageName, } from "../infra/install-safe-path.js"; import { resolveConfigDir, resolveUserPath } from "../utils.js"; export function safePluginInstallFileName(input: string): string { return safeDirName(input); } export function encodePluginInstallDirName(pluginId: string): string { const trimmed = pluginId.trim(); if (!trimmed.includes("/")) { return safeDirName(trimmed); } // Scoped plugin ids need a reserved on-disk namespace so they cannot collide // with valid unscoped ids that happen to match the hashed slug. return `@${safePathSegmentHashed(trimmed)}`; } export function validatePluginId(pluginId: string): string | null { const trimmed = pluginId.trim(); if (!trimmed) { return "invalid plugin name: missing"; } if (trimmed.includes("\\")) { return "invalid plugin name: path separators not allowed"; } const segments = trimmed.split("/"); if (segments.some((segment) => !segment)) { return "invalid plugin name: malformed scope"; } if (segments.some((segment) => segment === "." || segment === "..")) { return "invalid plugin name: reserved path segment"; } if (segments.length === 1) { if (trimmed.startsWith("@")) { return "invalid plugin name: scoped ids must use @scope/name format"; } return null; } if (segments.length !== 2) { return "invalid plugin name: path separators not allowed"; } if (!segments[0]?.startsWith("@") || segments[0].length < 2) { return "invalid plugin name: scoped ids must use @scope/name format"; } return null; } export function matchesExpectedPluginId(params: { expectedPluginId?: string; pluginId: string; manifestPluginId?: string; npmPluginId: string; }): boolean { if (!params.expectedPluginId) { return true; } if (params.expectedPluginId === params.pluginId) { return true; } // Backward compatibility: older install records keyed scoped npm packages by // their unscoped package name. Preserve update-in-place for those records // unless the package declares an explicit manifest id override. return ( !params.manifestPluginId && params.pluginId === params.npmPluginId && params.expectedPluginId === unscopedPackageName(params.npmPluginId) ); } export function resolveDefaultPluginExtensionsDir( env: NodeJS.ProcessEnv = process.env, homedir?: () => string, ): string { return path.join(resolveConfigDir(env, homedir), "extensions"); } export function resolvePluginInstallDir(pluginId: string, extensionsDir?: string): string { const extensionsBase = extensionsDir ? resolveUserPath(extensionsDir) : resolveDefaultPluginExtensionsDir(); const pluginIdError = validatePluginId(pluginId); if (pluginIdError) { throw new Error(pluginIdError); } const targetDirResult = resolveSafeInstallDir({ baseDir: extensionsBase, id: pluginId, invalidNameMessage: "invalid plugin name: path traversal detected", nameEncoder: encodePluginInstallDirName, }); if (!targetDirResult.ok) { throw new Error(targetDirResult.error); } return targetDirResult.path; }