openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
224 lines (223 loc) • 10.5 kB
JavaScript
import { p as readStringValue } from "./string-coerce-mnp54Vah.js";
import { c as isRecord, p as resolveUserPath } from "./utils-CCC-BEJH.js";
import { r as writeRuntimeJson } from "./runtime-B4lgFmsS.js";
import path from "node:path";
import * as tar from "tar";
//#region src/commands/backup-verify.ts
const WINDOWS_ABSOLUTE_ARCHIVE_PATH_RE = /^[A-Za-z]:[\\/]/;
const MAX_MANIFEST_BYTES = 1024 * 1024;
function stripTrailingSlashes(value) {
return value.replace(/\/+$/u, "");
}
function normalizeArchivePath(entryPath, label) {
const trimmed = stripTrailingSlashes(entryPath.trim());
if (!trimmed) throw new Error(`${label} is empty.`);
if (trimmed.startsWith("/") || WINDOWS_ABSOLUTE_ARCHIVE_PATH_RE.test(trimmed)) throw new Error(`${label} must be relative: ${entryPath}`);
if (trimmed.includes("\\")) throw new Error(`${label} must use forward slashes: ${entryPath}`);
if (trimmed.split("/").some((segment) => segment === "." || segment === "..")) throw new Error(`${label} contains path traversal segments: ${entryPath}`);
const normalized = stripTrailingSlashes(path.posix.normalize(trimmed));
if (!normalized || normalized === "." || normalized === ".." || normalized.startsWith("../")) throw new Error(`${label} resolves outside the archive root: ${entryPath}`);
return normalized;
}
function normalizeArchiveRoot(rootName) {
const normalized = normalizeArchivePath(rootName, "Backup manifest archiveRoot");
if (normalized.includes("/")) throw new Error(`Backup manifest archiveRoot must be a single path segment: ${rootName}`);
return normalized;
}
function isArchivePathWithin(child, parent) {
const relative = path.posix.relative(parent, child);
return relative === "" || !relative.startsWith("../") && relative !== "..";
}
function parseManifest(raw) {
let parsed;
try {
parsed = JSON.parse(raw);
} catch (err) {
throw new Error("Backup manifest is not valid JSON.", { cause: err });
}
if (!isRecord(parsed)) throw new Error("Backup manifest must be an object.");
if (parsed.schemaVersion !== 1) throw new Error(`Unsupported backup manifest schemaVersion: ${String(parsed.schemaVersion)}`);
if (typeof parsed.archiveRoot !== "string" || !parsed.archiveRoot.trim()) throw new Error("Backup manifest is missing archiveRoot.");
if (typeof parsed.createdAt !== "string" || !parsed.createdAt.trim()) throw new Error("Backup manifest is missing createdAt.");
if (!Array.isArray(parsed.assets)) throw new Error("Backup manifest is missing assets.");
const assets = [];
for (const asset of parsed.assets) {
if (!isRecord(asset)) throw new Error("Backup manifest contains a non-object asset.");
if (typeof asset.kind !== "string" || !asset.kind.trim()) throw new Error("Backup manifest asset is missing kind.");
if (typeof asset.sourcePath !== "string" || !asset.sourcePath.trim()) throw new Error("Backup manifest asset is missing sourcePath.");
if (typeof asset.archivePath !== "string" || !asset.archivePath.trim()) throw new Error("Backup manifest asset is missing archivePath.");
assets.push({
kind: asset.kind,
sourcePath: asset.sourcePath,
archivePath: asset.archivePath
});
}
return {
schemaVersion: 1,
archiveRoot: parsed.archiveRoot,
createdAt: parsed.createdAt,
runtimeVersion: typeof parsed.runtimeVersion === "string" && parsed.runtimeVersion.trim() ? parsed.runtimeVersion : "unknown",
platform: typeof parsed.platform === "string" ? parsed.platform : "unknown",
nodeVersion: typeof parsed.nodeVersion === "string" ? parsed.nodeVersion : "unknown",
options: isRecord(parsed.options) ? { includeWorkspace: parsed.options.includeWorkspace } : void 0,
paths: isRecord(parsed.paths) ? {
stateDir: readStringValue(parsed.paths.stateDir),
configPath: readStringValue(parsed.paths.configPath),
oauthDir: readStringValue(parsed.paths.oauthDir),
workspaceDirs: Array.isArray(parsed.paths.workspaceDirs) ? parsed.paths.workspaceDirs.filter((entry) => typeof entry === "string") : void 0
} : void 0,
assets,
skipped: Array.isArray(parsed.skipped) ? parsed.skipped : void 0
};
}
async function listArchiveEntries(archivePath) {
const entries = [];
await tar.t({
file: archivePath,
gzip: true,
onentry: (entry) => {
entries.push({
path: entry.path,
...entry.linkpath ? { linkpath: entry.linkpath } : {},
...entry.type ? { type: entry.type } : {}
});
entry.resume();
}
});
return entries;
}
async function extractManifest(params) {
let manifestContentPromise;
await tar.t({
file: params.archivePath,
gzip: true,
onentry: (entry) => {
if (entry.path !== params.manifestEntryPath) {
entry.resume();
return;
}
manifestContentPromise = new Promise((resolve) => {
const chunks = [];
let totalBytes = 0;
let exceededLimit = false;
let settled = false;
const settle = (result) => {
if (settled) return;
settled = true;
resolve(result);
};
entry.on("data", (chunk) => {
if (exceededLimit) return;
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
totalBytes += buffer.byteLength;
if (totalBytes > MAX_MANIFEST_BYTES) {
exceededLimit = true;
chunks.length = 0;
return;
}
chunks.push(buffer);
});
entry.on("error", (error) => {
settle({ error: error instanceof Error ? error : new Error(String(error)) });
});
entry.on("end", () => {
if (exceededLimit) {
settle({ error: /* @__PURE__ */ new Error(`Backup manifest exceeds ${MAX_MANIFEST_BYTES} byte limit.`) });
return;
}
settle({ content: Buffer.concat(chunks, totalBytes).toString("utf8") });
});
});
}
});
if (!manifestContentPromise) throw new Error(`Archive is missing manifest entry: ${params.manifestEntryPath}`);
const result = await manifestContentPromise;
if (result.error) throw result.error;
return result.content ?? "";
}
function isRootManifestEntry(entryPath) {
const parts = entryPath.split("/");
return parts.length === 2 && parts[0] !== "" && parts[1] === "manifest.json";
}
function verifyManifestAgainstEntries(manifest, entries) {
const archiveRoot = normalizeArchiveRoot(manifest.archiveRoot);
const manifestEntryPath = path.posix.join(archiveRoot, "manifest.json");
const normalizedEntries = [...entries];
const normalizedEntrySet = new Set(normalizedEntries);
if (!normalizedEntrySet.has(manifestEntryPath)) throw new Error(`Archive is missing manifest entry: ${manifestEntryPath}`);
for (const entry of normalizedEntries) if (!isArchivePathWithin(entry, archiveRoot)) throw new Error(`Archive entry is outside the declared archive root: ${entry}`);
const payloadRoot = path.posix.join(archiveRoot, "payload");
for (const asset of manifest.assets) {
const assetArchivePath = normalizeArchivePath(asset.archivePath, "Backup manifest asset path");
if (!isArchivePathWithin(assetArchivePath, payloadRoot)) throw new Error(`Manifest asset path is outside payload root: ${asset.archivePath}`);
const exact = normalizedEntrySet.has(assetArchivePath);
const nested = normalizedEntries.some((entry) => entry !== assetArchivePath && isArchivePathWithin(entry, assetArchivePath));
if (!exact && !nested) throw new Error(`Archive is missing payload for manifest asset: ${assetArchivePath}`);
}
}
function verifyHardlinkTargetsAgainstArchiveRoot(hardlinkTargets, archiveRoot, entries) {
const normalizedRoot = normalizeArchiveRoot(archiveRoot);
for (const target of hardlinkTargets) {
const normalizedTarget = isArchivePathWithin(target.normalized, normalizedRoot) ? target.normalized : path.posix.join(normalizedRoot, target.normalized);
if (!isArchivePathWithin(normalizedTarget, normalizedRoot)) throw new Error(`Archive hardlink target is outside the declared archive root: ${target.entryPath} -> ${normalizedTarget}`);
if (!entries.has(normalizedTarget)) throw new Error(`Archive hardlink target is missing from archive entries: ${target.entryPath} -> ${normalizedTarget}`);
}
}
function formatResult(result) {
return [
`Backup archive OK: ${result.archivePath}`,
`Archive root: ${result.archiveRoot}`,
`Created at: ${result.createdAt}`,
`Runtime version: ${result.runtimeVersion}`,
`Assets verified: ${result.assetCount}`,
`Archive entries scanned: ${result.entryCount}`
].join("\n");
}
function findDuplicateNormalizedEntryPath(entries) {
const seen = /* @__PURE__ */ new Set();
for (const entry of entries) {
if (seen.has(entry.normalized)) return entry.normalized;
seen.add(entry.normalized);
}
}
/** Verify a backup archive without extracting payload files to disk. */
async function backupVerifyCommand(runtime, opts) {
const archivePath = resolveUserPath(opts.archive);
const rawEntries = await listArchiveEntries(archivePath);
if (rawEntries.length === 0) throw new Error("Backup archive is empty.");
const entries = rawEntries.map((entry) => ({
raw: entry.path,
normalized: normalizeArchivePath(entry.path, "Archive entry")
}));
const hardlinkTargets = rawEntries.filter((entry) => entry.type === "Link" && entry.linkpath).map((entry) => ({
entryPath: entry.path,
normalized: normalizeArchivePath(entry.linkpath ?? "", `Archive hardlink target for ${entry.path}`)
}));
const normalizedEntrySet = new Set(entries.map((entry) => entry.normalized));
const manifestMatches = entries.filter((entry) => isRootManifestEntry(entry.normalized));
if (manifestMatches.length !== 1) throw new Error(`Expected exactly one backup manifest entry, found ${manifestMatches.length}.`);
const duplicateEntryPath = findDuplicateNormalizedEntryPath(entries);
if (duplicateEntryPath) throw new Error(`Archive contains duplicate entry path: ${duplicateEntryPath}`);
const manifestEntryPath = manifestMatches[0]?.raw;
if (!manifestEntryPath) throw new Error("Backup archive manifest entry could not be resolved.");
const manifest = parseManifest(await extractManifest({
archivePath,
manifestEntryPath
}));
verifyManifestAgainstEntries(manifest, normalizedEntrySet);
verifyHardlinkTargetsAgainstArchiveRoot(hardlinkTargets, manifest.archiveRoot, normalizedEntrySet);
const result = {
ok: true,
archivePath,
archiveRoot: manifest.archiveRoot,
createdAt: manifest.createdAt,
runtimeVersion: manifest.runtimeVersion,
assetCount: manifest.assets.length,
entryCount: rawEntries.length
};
if (opts.json) writeRuntimeJson(runtime, result);
else runtime.log(formatResult(result));
return result;
}
//#endregion
export { backupVerifyCommand as t };