import { createHash, randomUUID } from 'node:crypto'; import { existsSync } from 'node:fs'; import { copyFile, lstat, mkdir, mkdtemp, readFile, readdir, rename, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; import { writeTextFileAtomic } from './atomic-write.js'; import { readCinemaPlanningPackage } from './cinema-planning-store.js'; import { readCinemaDeliveryManifest } from './cinema-delivery.js'; import { readCinemaProductionQueue } from './cinema-production-queue.js'; import { stableCinemaJson } from './cinema-shot-compiler.js'; import { readCinemaGenerationLedger } from './cinema-store.js'; import type { CinemaSha256 } from './cinema-types.js'; import { resolveProjectWorkspace } from './workspace.js'; const execFileAsync = promisify(execFile); const ARCHIVE_META_DIR = '.videoclaw-archive'; const ARCHIVE_MANIFEST = `${ARCHIVE_META_DIR}/manifest.json`; export interface CinemaArchiveFileRecord { path: string; contentHash: CinemaSha256; byteLength: number; } export interface CinemaProjectArchiveManifest { schemaVersion: 1; projectSlug: string; createdAt: string; files: CinemaArchiveFileRecord[]; fileCount: number; totalBytes: number; snapshotHash: CinemaSha256; providerCalls: 0; generationCalls: 0; spendAuthorized: false; contentHash: CinemaSha256; } export interface CinemaProjectArchiveReceipt { schemaVersion: 1; archiveId: string; projectSlug: string; createdAt: string; sourceProjectDir: string; archivePath: string; archiveContentHash: CinemaSha256; archiveByteLength: number; manifestHash: CinemaSha256; snapshotHash: CinemaSha256; fileCount: number; totalBytes: number; providerCalls: 0; generationCalls: 0; spendAuthorized: false; contentHash: CinemaSha256; } export interface CinemaProjectRestoreReceipt { schemaVersion: 1; restoreId: string; projectSlug: string; restoredAt: string; archivePath: string; archiveContentHash: CinemaSha256; archiveReceiptHash: CinemaSha256; destinationRoot: string; restoredProjectDir: string; manifestHash: CinemaSha256; snapshotHash: CinemaSha256; fileCount: number; totalBytes: number; canonicalRevalidation: { planningReceiptHash: CinemaSha256; queueTasks: number; generationAttempts: number; generationOutcomes: number; promotions: number; deliveryHash: CinemaSha256 | null; }; providerCalls: 0; generationCalls: 0; spendAuthorized: false; contentHash: CinemaSha256; } export interface CinemaArchiveResult { created: boolean; archivePath: string; receiptPath: string; manifest: CinemaProjectArchiveManifest; receipt: CinemaProjectArchiveReceipt; } export interface CinemaRestoreResult { restored: true; receiptPath: string; manifest: CinemaProjectArchiveManifest; receipt: CinemaProjectRestoreReceipt; } function sha256(value: string | Uint8Array): CinemaSha256 { return `sha256:${createHash('sha256').update(value).digest('hex')}`; } function contentHash(value: object): CinemaSha256 { const { contentHash: _ignored, ...body } = value as object & { contentHash?: string }; return sha256(stableCinemaJson(body)); } function withHash(value: T): T & { contentHash: CinemaSha256 } { return { ...value, contentHash: contentHash(value) }; } function safeSlug(value: string): string { if (!/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(value)) throw new Error(`Invalid Cinema archive project slug: ${value}`); return value; } function assertSha256(value: unknown, label: string): asserts value is CinemaSha256 { if (typeof value !== 'string' || !/^sha256:[a-f0-9]{64}$/.test(value)) throw new Error(`${label} is not SHA-256`); } async function hashFile(path: string): Promise<{ contentHash: CinemaSha256; byteLength: number }> { const bytes = await readFile(path); return { contentHash: sha256(bytes), byteLength: bytes.byteLength }; } async function inventoryProject(projectDir: string): Promise { const files: CinemaArchiveFileRecord[] = []; const walk = async (directory: string): Promise => { const entries = await readdir(directory, { withFileTypes: true }); entries.sort((left, right) => left.name.localeCompare(right.name)); for (const entry of entries) { const absolute = join(directory, entry.name); const rel = relative(projectDir, absolute).split(sep).join('/'); if (rel === ARCHIVE_META_DIR || rel.startsWith(`${ARCHIVE_META_DIR}/`)) continue; const stats = await lstat(absolute); if (stats.isSymbolicLink()) throw new Error(`Cinema archive refuses symbolic links: ${rel}`); if (stats.isDirectory()) { await walk(absolute); continue; } if (!stats.isFile()) throw new Error(`Cinema archive refuses non-regular filesystem entries: ${rel}`); const hashed = await hashFile(absolute); files.push({ path: rel, ...hashed }); } }; await walk(projectDir); return files.sort((left, right) => left.path.localeCompare(right.path)); } function buildManifest(projectSlug: string, createdAt: string, files: CinemaArchiveFileRecord[]): CinemaProjectArchiveManifest { const totalBytes = files.reduce((sum, file) => sum + file.byteLength, 0); const snapshotHash = sha256(stableCinemaJson({ projectSlug, files })); return withHash({ schemaVersion: 1 as const, projectSlug, createdAt, files, fileCount: files.length, totalBytes, snapshotHash, providerCalls: 0 as const, generationCalls: 0 as const, spendAuthorized: false as const, }); } function timestamp(now: Date): string { return now.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z'); } async function copyInventory(sourceDir: string, destinationDir: string, files: CinemaArchiveFileRecord[]): Promise { for (const file of files) { const target = join(destinationDir, ...file.path.split('/')); await mkdir(dirname(target), { recursive: true }); await copyFile(join(sourceDir, ...file.path.split('/')), target); const copied = await hashFile(target); if (copied.contentHash !== file.contentHash || copied.byteLength !== file.byteLength) { throw new Error(`Cinema archive staging copy changed bytes for ${file.path}`); } } } async function readAndValidateReceipt(path: string): Promise { const receipt = JSON.parse(await readFile(path, 'utf8')) as CinemaProjectArchiveReceipt; assertSha256(receipt.contentHash, 'Cinema archive receipt contentHash'); if (receipt.contentHash !== contentHash(receipt)) throw new Error(`Cinema archive receipt failed content-hash validation: ${path}`); return receipt; } async function readAndValidateManifest(path: string): Promise { const manifest = JSON.parse(await readFile(path, 'utf8')) as CinemaProjectArchiveManifest; assertSha256(manifest.contentHash, 'Cinema archive manifest contentHash'); if (manifest.contentHash !== contentHash(manifest)) throw new Error(`Cinema archive manifest failed content-hash validation: ${path}`); if (manifest.fileCount !== manifest.files.length) throw new Error('Cinema archive manifest file count is inconsistent'); const snapshotHash = sha256(stableCinemaJson({ projectSlug: manifest.projectSlug, files: manifest.files })); if (snapshotHash !== manifest.snapshotHash) throw new Error('Cinema archive manifest snapshot hash is inconsistent'); return manifest; } export async function createCinemaProjectArchive(input: { root: string; projectSlug: string; archiveDir?: string; now?: Date; }): Promise { const projectSlug = safeSlug(input.projectSlug); const workspace = resolveProjectWorkspace(projectSlug, input.root); if (!existsSync(workspace.projectDir)) throw new Error(`Cinema project does not exist: ${workspace.projectDir}`); const planning = await readCinemaPlanningPackage(input.root, projectSlug); if (!planning) throw new Error(`Cinema project ${projectSlug} has no canonical planning package`); await readCinemaDeliveryManifest(input.root, projectSlug); const now = input.now ?? new Date(); const createdAt = now.toISOString(); const files = await inventoryProject(workspace.projectDir); const manifest = buildManifest(projectSlug, createdAt, files); const archiveDir = resolve(input.archiveDir ?? join(workspace.root, 'archives', 'cinema')); const archivePath = join(archiveDir, `${projectSlug}-${timestamp(now)}-${manifest.snapshotHash.slice(7, 19)}.tar.gz`); const receiptPath = `${archivePath}.receipt.json`; await mkdir(archiveDir, { recursive: true }); if (existsSync(archivePath) || existsSync(receiptPath)) { if (!existsSync(archivePath) || !existsSync(receiptPath)) throw new Error(`Cinema archive pair is incomplete: ${archivePath}`); const receipt = await readAndValidateReceipt(receiptPath); const archive = await hashFile(archivePath); if (receipt.projectSlug !== projectSlug || receipt.snapshotHash !== manifest.snapshotHash || receipt.archiveContentHash !== archive.contentHash || receipt.archiveByteLength !== archive.byteLength) { throw new Error(`Existing Cinema archive does not match the current project snapshot: ${archivePath}`); } return { created: false, archivePath, receiptPath, manifest, receipt }; } const stagingRoot = await mkdtemp(join(tmpdir(), 'vclaw-cinema-archive-')); try { const stagedProject = join(stagingRoot, projectSlug); await mkdir(stagedProject, { recursive: true }); await copyInventory(workspace.projectDir, stagedProject, files); await mkdir(join(stagedProject, ARCHIVE_META_DIR), { recursive: true }); await writeTextFileAtomic(join(stagedProject, ARCHIVE_MANIFEST), `${JSON.stringify(manifest, null, 2)}\n`); await execFileAsync('tar', ['-czf', archivePath, '-C', stagingRoot, projectSlug], { encoding: 'utf8' }); } finally { await rm(stagingRoot, { recursive: true, force: true }); } const archive = await hashFile(archivePath); const receipt = withHash({ schemaVersion: 1 as const, archiveId: `cinema-archive-${manifest.snapshotHash.slice(7, 23)}`, projectSlug, createdAt, sourceProjectDir: workspace.projectDir, archivePath, archiveContentHash: archive.contentHash, archiveByteLength: archive.byteLength, manifestHash: manifest.contentHash, snapshotHash: manifest.snapshotHash, fileCount: manifest.fileCount, totalBytes: manifest.totalBytes, providerCalls: 0 as const, generationCalls: 0 as const, spendAuthorized: false as const, }); await writeTextFileAtomic(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`); return { created: true, archivePath, receiptPath, manifest, receipt }; } function assertSafeArchiveEntries(listing: string, projectSlug: string): void { const entries = listing.split('\n').map((entry) => entry.trim()).filter(Boolean); if (entries.length === 0) throw new Error('Cinema archive is empty'); const seen = new Set(); for (const entry of entries) { const normalized = entry.replace(/^\.\//, '').replace(/\/$/, ''); if (!normalized || isAbsolute(normalized) || normalized.includes('\\') || normalized.includes('\0')) throw new Error(`Unsafe Cinema archive entry: ${entry}`); const segments = normalized.split('/'); if (segments.some((segment) => segment === '..' || segment === '.')) throw new Error(`Unsafe Cinema archive entry: ${entry}`); if (segments[0] !== projectSlug) throw new Error(`Cinema archive entry escapes project ${projectSlug}: ${entry}`); if (seen.has(normalized)) throw new Error(`Cinema archive contains duplicate entry: ${entry}`); seen.add(normalized); } if (!seen.has(`${projectSlug}/${ARCHIVE_MANIFEST}`)) throw new Error('Cinema archive lacks its internal manifest'); } function assertInventoryMatches(actual: CinemaArchiveFileRecord[], manifest: CinemaProjectArchiveManifest): void { if (stableCinemaJson(actual) !== stableCinemaJson(manifest.files)) throw new Error('Restored Cinema project bytes do not match the archived inventory'); } async function assertDestinationClean(destinationRoot: string): Promise { if (!existsSync(destinationRoot)) return false; const stats = await lstat(destinationRoot); if (!stats.isDirectory()) throw new Error(`Cinema restore destination is not a directory: ${destinationRoot}`); if ((await readdir(destinationRoot)).length > 0) throw new Error(`Cinema restore destination must be new or empty: ${destinationRoot}`); return true; } export async function restoreCinemaProjectArchive(input: { archivePath: string; destinationRoot: string; now?: Date; }): Promise { const archivePath = resolve(input.archivePath); const receiptPath = `${archivePath}.receipt.json`; if (!existsSync(archivePath) || !existsSync(receiptPath)) throw new Error(`Cinema archive and adjacent receipt are both required: ${archivePath}`); const archiveReceipt = await readAndValidateReceipt(receiptPath); const projectSlug = safeSlug(archiveReceipt.projectSlug); const archive = await hashFile(archivePath); if (archive.contentHash !== archiveReceipt.archiveContentHash || archive.byteLength !== archiveReceipt.archiveByteLength) throw new Error('Cinema archive bytes do not match its receipt'); const { stdout: listing } = await execFileAsync('tar', ['-tzf', archivePath], { encoding: 'utf8' }); assertSafeArchiveEntries(listing, projectSlug); const destinationRoot = resolve(input.destinationRoot); const destinationExisted = await assertDestinationClean(destinationRoot); await mkdir(dirname(destinationRoot), { recursive: true }); const stagingRoot = await mkdtemp(join(dirname(destinationRoot), `.vclaw-cinema-restore-${randomUUID().slice(0, 8)}-`)); try { const projectsDir = join(stagingRoot, 'projects'); await mkdir(projectsDir, { recursive: true }); await execFileAsync('tar', ['-xzf', archivePath, '-C', projectsDir, '--no-same-owner', '--no-same-permissions'], { encoding: 'utf8' }); const stagedProject = join(projectsDir, projectSlug); const manifest = await readAndValidateManifest(join(stagedProject, ARCHIVE_MANIFEST)); if (manifest.projectSlug !== projectSlug || manifest.contentHash !== archiveReceipt.manifestHash || manifest.snapshotHash !== archiveReceipt.snapshotHash) throw new Error('Cinema archive manifest does not match its receipt'); const actual = await inventoryProject(stagedProject); assertInventoryMatches(actual, manifest); const planning = await readCinemaPlanningPackage(stagingRoot, projectSlug); if (!planning) throw new Error(`Restored project ${projectSlug} lacks its canonical Cinema planning package`); const queue = await readCinemaProductionQueue(stagingRoot, projectSlug); const ledger = await readCinemaGenerationLedger(stagingRoot, projectSlug); const delivery = await readCinemaDeliveryManifest(stagingRoot, projectSlug); const restoredAt = (input.now ?? new Date()).toISOString(); const restoreReceipt = withHash({ schemaVersion: 1 as const, restoreId: `cinema-restore-${manifest.snapshotHash.slice(7, 23)}`, projectSlug, restoredAt, archivePath, archiveContentHash: archive.contentHash, archiveReceiptHash: archiveReceipt.contentHash, destinationRoot, restoredProjectDir: join(destinationRoot, 'projects', projectSlug), manifestHash: manifest.contentHash, snapshotHash: manifest.snapshotHash, fileCount: manifest.fileCount, totalBytes: manifest.totalBytes, canonicalRevalidation: { planningReceiptHash: planning.receipt.contentHash, queueTasks: queue?.tasks.length ?? 0, generationAttempts: ledger?.attempts.length ?? 0, generationOutcomes: ledger?.outcomes?.length ?? 0, promotions: ledger?.promotions.length ?? 0, deliveryHash: delivery?.contentHash ?? null, }, providerCalls: 0 as const, generationCalls: 0 as const, spendAuthorized: false as const, }); await mkdir(join(stagingRoot, 'restores'), { recursive: true }); await writeTextFileAtomic(join(stagingRoot, 'restores', `${projectSlug}.json`), `${JSON.stringify(restoreReceipt, null, 2)}\n`); if (destinationExisted) await rm(destinationRoot, { recursive: true, force: true }); await rename(stagingRoot, destinationRoot); return { restored: true, receiptPath: join(destinationRoot, 'restores', `${projectSlug}.json`), manifest, receipt: restoreReceipt, }; } catch (error) { await rm(stagingRoot, { recursive: true, force: true }); throw error; } }