Files
openclaw/src/commands/backup-verify.ts
2026-03-08 16:53:44 -04:00

325 lines
10 KiB
TypeScript

import path from "node:path";
import * as tar from "tar";
import type { RuntimeEnv } from "../runtime.js";
import { resolveUserPath } from "../utils.js";
const WINDOWS_ABSOLUTE_ARCHIVE_PATH_RE = /^[A-Za-z]:[\\/]/;
type BackupManifestAsset = {
kind: string;
sourcePath: string;
archivePath: string;
};
type BackupManifest = {
schemaVersion: number;
createdAt: string;
archiveRoot: string;
runtimeVersion: string;
platform: string;
nodeVersion: string;
options?: {
includeWorkspace?: boolean;
};
paths?: {
stateDir?: string;
configPath?: string;
oauthDir?: string;
workspaceDirs?: string[];
};
assets: BackupManifestAsset[];
skipped?: Array<{
kind?: string;
sourcePath?: string;
reason?: string;
coveredBy?: string;
}>;
};
export type BackupVerifyOptions = {
archive: string;
json?: boolean;
};
export type BackupVerifyResult = {
ok: true;
archivePath: string;
archiveRoot: string;
createdAt: string;
runtimeVersion: string;
assetCount: number;
entryCount: number;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function stripTrailingSlashes(value: string): string {
return value.replace(/\/+$/u, "");
}
function normalizeArchivePath(entryPath: string, label: string): string {
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: string): string {
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: string, parent: string): boolean {
const relative = path.posix.relative(parent, child);
return relative === "" || (!relative.startsWith("../") && relative !== "..");
}
function parseManifest(raw: string): BackupManifest {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (err) {
throw new Error(`Backup manifest is not valid JSON: ${String(err)}`, { 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: BackupManifestAsset[] = [];
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 as boolean | undefined }
: undefined,
paths: isRecord(parsed.paths)
? {
stateDir: typeof parsed.paths.stateDir === "string" ? parsed.paths.stateDir : undefined,
configPath:
typeof parsed.paths.configPath === "string" ? parsed.paths.configPath : undefined,
oauthDir: typeof parsed.paths.oauthDir === "string" ? parsed.paths.oauthDir : undefined,
workspaceDirs: Array.isArray(parsed.paths.workspaceDirs)
? parsed.paths.workspaceDirs.filter(
(entry): entry is string => typeof entry === "string",
)
: undefined,
}
: undefined,
assets,
skipped: Array.isArray(parsed.skipped) ? parsed.skipped : undefined,
};
}
async function listArchiveEntries(archivePath: string): Promise<string[]> {
const entries: string[] = [];
await tar.t({
file: archivePath,
gzip: true,
onentry: (entry) => {
entries.push(entry.path);
},
});
return entries;
}
async function extractManifest(params: {
archivePath: string;
manifestEntryPath: string;
}): Promise<string> {
let manifestContentPromise: Promise<string> | undefined;
await tar.t({
file: params.archivePath,
gzip: true,
onentry: (entry) => {
if (entry.path !== params.manifestEntryPath) {
entry.resume();
return;
}
manifestContentPromise = new Promise<string>((resolve, reject) => {
const chunks: Buffer[] = [];
entry.on("data", (chunk: Buffer | string) => {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
});
entry.on("error", reject);
entry.on("end", () => {
resolve(Buffer.concat(chunks).toString("utf8"));
});
});
},
});
if (!manifestContentPromise) {
throw new Error(`Archive is missing manifest entry: ${params.manifestEntryPath}`);
}
return await manifestContentPromise;
}
function isRootManifestEntry(entryPath: string): boolean {
const parts = entryPath.split("/");
return parts.length === 2 && parts[0] !== "" && parts[1] === "manifest.json";
}
function verifyManifestAgainstEntries(manifest: BackupManifest, entries: Set<string>): void {
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 formatResult(result: BackupVerifyResult): string {
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: Array<{ normalized: string }>,
): string | undefined {
const seen = new Set<string>();
for (const entry of entries) {
if (seen.has(entry.normalized)) {
return entry.normalized;
}
seen.add(entry.normalized);
}
return undefined;
}
export async function backupVerifyCommand(
runtime: RuntimeEnv,
opts: BackupVerifyOptions,
): Promise<BackupVerifyResult> {
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,
normalized: normalizeArchivePath(entry, "Archive entry"),
}));
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 manifestRaw = await extractManifest({ archivePath, manifestEntryPath });
const manifest = parseManifest(manifestRaw);
verifyManifestAgainstEntries(manifest, normalizedEntrySet);
const result: BackupVerifyResult = {
ok: true,
archivePath,
archiveRoot: manifest.archiveRoot,
createdAt: manifest.createdAt,
runtimeVersion: manifest.runtimeVersion,
assetCount: manifest.assets.length,
entryCount: rawEntries.length,
};
runtime.log(opts.json ? JSON.stringify(result, null, 2) : formatResult(result));
return result;
}