mirror of https://github.com/openclaw/openclaw.git
107 lines
3.3 KiB
TypeScript
107 lines
3.3 KiB
TypeScript
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;
|
|
nameEncoder?: (id: string) => string;
|
|
}): { ok: true; path: string } | { ok: false; error: string } {
|
|
const encodedName = (params.nameEncoder ?? safeDirName)(params.id);
|
|
const targetDir = path.join(params.baseDir, encodedName);
|
|
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<void> {
|
|
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<void> => {
|
|
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));
|
|
}
|