mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 18:10:45 +00:00
* refactor: extract filesystem safety primitives * refactor: use fs-safe for file access helpers * refactor: reuse fs-safe for media reads * refactor: use fs-safe for image reads * refactor: reuse fs-safe in qqbot media opener * refactor: reuse fs-safe for local media checks * refactor: consume cleaner fs-safe api * refactor: align fs-safe json option names * fix: preserve fs-safe migration contracts * refactor: use fs-safe primitive subpaths * refactor: use grouped fs-safe subpaths * refactor: align fs-safe api usage * refactor: adapt private state store api * chore: refresh proof gate * refactor: follow fs-safe json api split * refactor: follow reduced fs-safe surface * build: default fs-safe python helper off * fix: preserve fs-safe plugin sdk aliases * refactor: consolidate fs-safe usage * refactor: unify fs-safe store usage * refactor: trim fs-safe temp workspace usage * refactor: hide low-level fs-safe primitives * build: use published fs-safe package * fix: preserve outbound recovery durability after rebase * chore: refresh pr checks
134 lines
3.9 KiB
TypeScript
134 lines
3.9 KiB
TypeScript
import fsp from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { isPathInside } from "../infra/path-guards.js";
|
|
|
|
export type DiagnosticSupportBundleFile = {
|
|
path: string;
|
|
mediaType: string;
|
|
content: string;
|
|
};
|
|
|
|
export type DiagnosticSupportBundleContent = {
|
|
path: string;
|
|
mediaType: string;
|
|
bytes: number;
|
|
};
|
|
|
|
function supportBundleByteLength(content: string): number {
|
|
return Buffer.byteLength(content, "utf8");
|
|
}
|
|
|
|
export function jsonSupportBundleFile(
|
|
pathName: string,
|
|
value: unknown,
|
|
): DiagnosticSupportBundleFile {
|
|
return {
|
|
path: assertSafeBundleRelativePath(pathName),
|
|
mediaType: "application/json",
|
|
content: `${JSON.stringify(value, null, 2)}\n`,
|
|
};
|
|
}
|
|
|
|
export function jsonlSupportBundleFile(
|
|
pathName: string,
|
|
lines: readonly string[],
|
|
): DiagnosticSupportBundleFile {
|
|
return {
|
|
path: assertSafeBundleRelativePath(pathName),
|
|
mediaType: "application/x-ndjson",
|
|
content: `${lines.join("\n")}\n`,
|
|
};
|
|
}
|
|
|
|
export function textSupportBundleFile(
|
|
pathName: string,
|
|
content: string,
|
|
): DiagnosticSupportBundleFile {
|
|
return {
|
|
path: assertSafeBundleRelativePath(pathName),
|
|
mediaType: "text/plain; charset=utf-8",
|
|
content: content.endsWith("\n") ? content : `${content}\n`,
|
|
};
|
|
}
|
|
|
|
export function supportBundleContents(
|
|
files: readonly DiagnosticSupportBundleFile[],
|
|
): DiagnosticSupportBundleContent[] {
|
|
return files.map((file) => ({
|
|
path: file.path,
|
|
mediaType: file.mediaType,
|
|
bytes: supportBundleByteLength(file.content),
|
|
}));
|
|
}
|
|
|
|
function assertSafeBundleRelativePath(pathName: string): string {
|
|
const normalized = pathName.replaceAll("\\", "/");
|
|
if (
|
|
!normalized ||
|
|
normalized.startsWith("/") ||
|
|
normalized.split("/").some((part) => part === "" || part === "." || part === "..")
|
|
) {
|
|
throw new Error(`Invalid bundle file path: ${pathName}`);
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
async function prepareSupportBundleDirectory(outputDir: string): Promise<void> {
|
|
await fsp.mkdir(path.dirname(outputDir), { recursive: true, mode: 0o700 });
|
|
await fsp.mkdir(outputDir, { mode: 0o700 });
|
|
}
|
|
|
|
function resolveSupportBundleFilePath(outputDir: string, pathName: string): string {
|
|
const safePath = assertSafeBundleRelativePath(pathName);
|
|
const resolvedBase = path.resolve(outputDir);
|
|
const resolvedFile = path.resolve(resolvedBase, safePath);
|
|
if (resolvedFile === resolvedBase || !isPathInside(resolvedBase, resolvedFile)) {
|
|
throw new Error(`Bundle file path escaped output directory: ${pathName}`);
|
|
}
|
|
return resolvedFile;
|
|
}
|
|
|
|
async function writeSupportBundleFile(
|
|
outputDir: string,
|
|
file: DiagnosticSupportBundleFile,
|
|
): Promise<void> {
|
|
const filePath = resolveSupportBundleFilePath(outputDir, file.path);
|
|
await fsp.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
await fsp.writeFile(filePath, file.content, {
|
|
encoding: "utf8",
|
|
flag: "wx",
|
|
mode: 0o600,
|
|
});
|
|
}
|
|
|
|
export async function writeSupportBundleDirectory(params: {
|
|
outputDir: string;
|
|
files: readonly DiagnosticSupportBundleFile[];
|
|
}): Promise<DiagnosticSupportBundleContent[]> {
|
|
await prepareSupportBundleDirectory(params.outputDir);
|
|
for (const file of params.files) {
|
|
await writeSupportBundleFile(params.outputDir, file);
|
|
}
|
|
return supportBundleContents(params.files);
|
|
}
|
|
|
|
export async function writeSupportBundleZip(params: {
|
|
outputPath: string;
|
|
files: readonly DiagnosticSupportBundleFile[];
|
|
compressionLevel?: number;
|
|
}): Promise<number> {
|
|
const { default: JSZip } = await import("jszip");
|
|
const zip = new JSZip();
|
|
for (const file of params.files) {
|
|
zip.file(assertSafeBundleRelativePath(file.path), file.content);
|
|
}
|
|
const buffer = await zip.generateAsync({
|
|
type: "nodebuffer",
|
|
compression: "DEFLATE",
|
|
compressionOptions: { level: params.compressionLevel ?? 6 },
|
|
});
|
|
await fsp.mkdir(path.dirname(params.outputPath), { recursive: true, mode: 0o700 });
|
|
await fsp.writeFile(params.outputPath, buffer, { mode: 0o600 });
|
|
return buffer.length;
|
|
}
|