import fs from "node:fs/promises"; import path from "node:path"; import { DOCX_CONTRACT_VERSION, type DocxCommitRequest, type DocxEditRequest } from "../contracts.ts"; import { assertNotAborted, fail } from "../errors.ts"; import { OpenXmlSidecar } from "../backends/openxml-sidecar.ts"; import { mergeLimits } from "./limits.ts"; import { sha256File } from "./hash.ts"; import { OoxmlPackage } from "../ooxml/package.ts"; import { transplantAllowedParts } from "./ooxml-manifest.ts"; import { allowedPartsForOperations, planOperations } from "./operation-plan.ts"; import { createWorkspace, loadRevision, saveRevision, type RevisionManifest } from "./workspace.ts"; import { canonicalOutputPath, defaultOutputPath, samePath } from "./paths.ts"; import { durableAtomicReplace } from "./io.ts"; import { validateDocument } from "./validation.ts"; export type MutationQueue = (key: string, work: () => Promise) => Promise; export async function stageEdit(sourcePath: string, request: DocxEditRequest, sidecar: OpenXmlSidecar, signal?: AbortSignal): Promise> { assertNotAborted(signal); if (request.schemaVersion && request.schemaVersion !== DOCX_CONTRACT_VERSION) fail("PROTOCOL_ERROR", `Unsupported patch schema version: ${request.schemaVersion}.`); const limits = mergeLimits(request.limits), sourceSha256 = await sha256File(sourcePath); if (request.expectedSourceSha256 && request.expectedSourceSha256.toLowerCase() !== sourceSha256) fail("SOURCE_CHANGED", "Document changed since inspection.", { expectedSourceSha256: request.expectedSourceSha256, actualSourceSha256: sourceSha256 }); if (request.dryRun === false && !request.expectedSourceSha256) fail("SOURCE_CHANGED", "expectedSourceSha256 is mandatory when creating a staged revision.", { actualSourceSha256: sourceSha256 }); const sourceBytes = await fs.readFile(sourcePath), baseline = OoxmlPackage.fromBytes(sourceBytes, request.limits); baseline.assertMutationAllowed(); const plan = planOperations(request.operations, limits.maxOperations, baseline.mainDocumentPart); const planned = await sidecar.request("plan", { sourcePath, operations: request.operations }, signal, request.timeoutMs); if (request.dryRun !== false) { const postPlanSha256 = await sha256File(sourcePath); if (postPlanSha256 !== sourceSha256) fail("SOURCE_CHANGED", "Source changed during dry-run planning.", { expectedSourceSha256: sourceSha256, actualSourceSha256: postPlanSha256 }); return { dryRun: true, sourcePath, sourceSha256, schemaVersion: request.schemaVersion ?? DOCX_CONTRACT_VERSION, plan, sidecarPlan: planned.result, engineVersion: planned.engineVersion, manifest: baseline.manifest(sourceSha256), warnings: planned.warnings ?? [] }; } const workspace = await createWorkspace(), stagedSource = path.join(workspace.dir, "source.docx"), candidate = path.join(workspace.dir, "candidate.docx"); try { await fs.copyFile(sourcePath, stagedSource); const response = await sidecar.request("edit", { sourcePath: stagedSource, outputPath: candidate, operations: request.operations }, signal, request.timeoutMs); assertNotAborted(signal); const currentHash = await sha256File(sourcePath); if (currentHash !== sourceSha256) fail("SOURCE_CHANGED", "Source changed during staged edit; candidate discarded.", { expectedSourceSha256: sourceSha256, actualSourceSha256: currentHash }); const rawCandidatePackage = OoxmlPackage.fromBytes(await fs.readFile(candidate), request.limits), allowed = allowedPartsForOperations(baseline.mainDocumentPart, request.operations); const sidecarDeclared = Array.isArray(response.result?.expectedChangedParts) ? response.result.expectedChangedParts.filter((part): part is string => typeof part === "string") : []; const undeclaredByExtension = sidecarDeclared.filter((part) => !allowed.has(part)); if (undeclaredByExtension.length) fail("PROTOCOL_ERROR", "Sidecar declared changes outside the operation contract.", { undeclaredByExtension }); const surgical = transplantAllowedParts(baseline, rawCandidatePackage, allowed); await fs.writeFile(candidate, surgical.bytes, { mode: 0o600 }); const candidatePackage = OoxmlPackage.fromBytes(surgical.bytes, request.limits), integrity = baseline.compareIntegrity(candidatePackage, allowed); if (!integrity.ok) fail("LOSSY_OPERATION", `Candidate changed undeclared or protected parts: ${integrity.errors.join("; ")}`, { integrity }); const validation = await validateDocument({ path: candidate, baselinePath: stagedSource, expectedChangedParts: [...allowed], limits: request.limits, sidecar, signal, timeoutMs: request.timeoutMs }); if (validation.ok !== true) fail("VALIDATION_FAILED", "Staged candidate failed mandatory validation.", { validation }); const stagedSha256 = await sha256File(candidate), manifest: RevisionManifest = { schemaVersion: "1.0", revisionId: workspace.id, createdAt: new Date().toISOString(), expiresAt: workspace.expiresAt, sourcePath, sourceSha256, stagedPath: candidate, stagedSha256, operations: request.operations, expectedChangedParts: [...allowed].sort(), changedParts: integrity.changedParts, validation }; const manifestPath = await saveRevision(manifest); const warnings = [...(response.warnings ?? [])]; if (surgical.discardedSidecarChanges.length) warnings.push({ code: "SIDECAR_REWRITE_DISCARDED", message: `Discarded rewrites of undeclared parts: ${surgical.discardedSidecarChanges.join(", ")}.`, severity: "warning" }); return { dryRun: false, revisionId: workspace.id, sourcePath, sourceSha256, stagedPath: candidate, stagedSha256, manifestPath, expiresAt: workspace.expiresAt, plan, changedParts: integrity.changedParts, surgicalTransplant: surgical, validation, engineVersion: response.engineVersion, operationResults: response.result, warnings }; } catch (error) { await fs.rm(workspace.dir, { recursive: true, force: true }).catch(() => undefined); throw error; } } export async function commitRevision(request: DocxCommitRequest, cwd: string, queue: MutationQueue, sidecar: OpenXmlSidecar, signal?: AbortSignal): Promise> { const revision = await loadRevision(request.revisionId), sourcePath = revision.sourcePath; if (request.expectedSourceSha256.toLowerCase() !== revision.sourceSha256) fail("SOURCE_CHANGED", "Commit source hash does not match the staged revision."); const destination = await canonicalOutputPath(request.inPlace ? sourcePath : (request.destinationPath ?? defaultOutputPath(sourcePath)), cwd); if (samePath(sourcePath, destination) && !request.inPlace) fail("INVALID_ARGUMENT", "Source overwrite requires inPlace=true and explicit UI confirmation."); if (request.inPlace && !samePath(sourcePath, destination)) fail("INVALID_ARGUMENT", "inPlace=true commits only to the original source path."); if (request.inPlace && !request.overwrite) fail("DESTINATION_EXISTS", "Source overwrite requires overwrite=true."); return queue(destination, async () => { assertNotAborted(signal); const sourceStat = await fs.lstat(sourcePath).catch(() => undefined); if (!sourceStat?.isFile() || sourceStat.isSymbolicLink()) fail("SOURCE_CHANGED", "Source is no longer a regular non-symlink file."); const destinationParent = path.dirname(destination), currentParent = await fs.realpath(destinationParent).catch(() => undefined); if (!currentParent || !samePath(currentParent, destinationParent)) fail("PERMISSION_DENIED", "Destination parent changed after path validation."); const currentSourceHash = await sha256File(sourcePath); if (currentSourceHash !== revision.sourceSha256) fail("SOURCE_CHANGED", "Source changed after staging; commit refused.", { expectedSourceSha256: revision.sourceSha256, actualSourceSha256: currentSourceHash }); const stagedHash = await sha256File(revision.stagedPath).catch(() => ""); if (stagedHash !== revision.stagedSha256) fail("REVISION_STALE", "Staged revision bytes changed or disappeared."); const existing = await fs.stat(destination).then(() => true, () => false); if (existing) { const destinationHash = await sha256File(destination); if (!samePath(sourcePath, destination) && request.overwrite && !request.expectedDestinationSha256) fail("DESTINATION_CHANGED", "expectedDestinationSha256 is mandatory when overwriting an existing destination.", { actualDestinationSha256: destinationHash }); if (request.expectedDestinationSha256 && request.expectedDestinationSha256.toLowerCase() !== destinationHash) fail("DESTINATION_CHANGED", "Destination hash precondition failed.", { expectedDestinationSha256: request.expectedDestinationSha256, actualDestinationSha256: destinationHash }); if (!request.overwrite && !samePath(sourcePath, destination)) fail("DESTINATION_EXISTS", `Destination exists: ${destination}`); } else if (request.expectedDestinationSha256) fail("DESTINATION_CHANGED", "Destination no longer exists but an expectedDestinationSha256 precondition was supplied."); const sourcePackage = OoxmlPackage.fromBytes(await fs.readFile(sourcePath)), stagedPackage = OoxmlPackage.fromBytes(await fs.readFile(revision.stagedPath)), operationPlan = planOperations(revision.operations, mergeLimits().maxOperations, sourcePackage.mainDocumentPart), expectedChangedParts = new Set(operationPlan.expectedChangedParts), integrity = sourcePackage.compareIntegrity(stagedPackage, expectedChangedParts); if (!integrity.ok) fail("REVISION_STALE", "Staged revision no longer satisfies its operation preservation contract.", { integrity }); const validation = await validateDocument({ path: revision.stagedPath, baselinePath: sourcePath, expectedChangedParts: [...expectedChangedParts], sidecar, signal }); if (validation.ok !== true) fail("VALIDATION_FAILED", "Staged revision no longer passes commit validation.", { validation }); const bytes = await fs.readFile(revision.stagedPath), write = await durableAtomicReplace(destination, bytes, { overwrite: Boolean(existing && request.overwrite), permanentRecovery: samePath(sourcePath, destination) }); const outputSha256 = await sha256File(destination); if (outputSha256 !== revision.stagedSha256) { if (write.recoveryPath) await fs.copyFile(write.recoveryPath, destination).catch(() => undefined); fail("VALIDATION_FAILED", "Committed output hash differs from staged revision hash."); } return { revisionId: revision.revisionId, sourcePath, sourceSha256: revision.sourceSha256, destinationPath: destination, outputSha256, recoveryPath: write.recoveryPath, changedParts: integrity.changedParts, validation, committedAt: new Date().toISOString() }; }); }