import { createHash } from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; import { isPathInside } from "./path-guards.js"; export function unscopedPackageName(name: string): string { const trimmed = name.trim(); if (!trimmed) { return trimmed; } return trimmed.includes("/") ? (trimmed.split("/").pop() ?? trimmed) : trimmed; } export function safeDirName(input: string): string { const trimmed = input.trim(); if (!trimmed) { return trimmed; } return trimmed.replaceAll("/", "__").replaceAll("\\", "__"); } export function safePathSegmentHashed(input: string): string { const trimmed = input.trim(); const base = trimmed .replaceAll(/[\\/]/g, "-") .replaceAll(/[^a-zA-Z0-9._-]/g, "-") .replaceAll(/-+/g, "-") .replaceAll(/^-+/g, "") .replaceAll(/-+$/g, ""); const normalized = base.length > 0 ? base : "skill"; const safe = normalized === "." || normalized === ".." ? "skill" : normalized; const hash = createHash("sha256").update(trimmed).digest("hex").slice(0, 10); if (safe !== trimmed) { const prefix = safe.length > 50 ? safe.slice(0, 50) : safe; return `${prefix}-${hash}`; } if (safe.length > 60) { return `${safe.slice(0, 50)}-${hash}`; } return safe; } export function resolveSafeInstallDir(params: { baseDir: string; id: string; invalidNameMessage: string; }): { ok: true; path: string } | { ok: false; error: string } { const targetDir = path.join(params.baseDir, safeDirName(params.id)); const resolvedBase = path.resolve(params.baseDir); const resolvedTarget = path.resolve(targetDir); const relative = path.relative(resolvedBase, resolvedTarget); if ( !relative || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative) ) { return { ok: false, error: params.invalidNameMessage }; } return { ok: true, path: targetDir }; } export async function assertCanonicalPathWithinBase(params: { baseDir: string; candidatePath: string; boundaryLabel: string; }): Promise { const baseDir = path.resolve(params.baseDir); const candidatePath = path.resolve(params.candidatePath); if (!isPathInside(baseDir, candidatePath)) { throw new Error(`Invalid path: must stay within ${params.boundaryLabel}`); } const baseLstat = await fs.lstat(baseDir); if (!baseLstat.isDirectory() || baseLstat.isSymbolicLink()) { throw new Error(`Invalid ${params.boundaryLabel}: base directory must be a real directory`); } const baseRealPath = await fs.realpath(baseDir); const validateDirectory = async (dirPath: string): Promise => { const dirLstat = await fs.lstat(dirPath); if (!dirLstat.isDirectory() || dirLstat.isSymbolicLink()) { throw new Error(`Invalid path: must stay within ${params.boundaryLabel}`); } const dirRealPath = await fs.realpath(dirPath); if (!isPathInside(baseRealPath, dirRealPath)) { throw new Error(`Invalid path: must stay within ${params.boundaryLabel}`); } }; try { await validateDirectory(candidatePath); return; } catch (err) { const code = (err as { code?: string }).code; if (code !== "ENOENT") { throw err; } } await validateDirectory(path.dirname(candidatePath)); }