diff --git a/openspec/changes/add-markdoc-document-creation/design.md b/openspec/changes/add-markdoc-document-creation/design.md index 4d7a53a9..d2cdf6ec 100644 --- a/openspec/changes/add-markdoc-document-creation/design.md +++ b/openspec/changes/add-markdoc-document-creation/design.md @@ -288,12 +288,29 @@ only. ```text docx-markdoc create - [--style-profile profile.json] [--no-pdf] [--require-pdf] [--replace] + [--style-profile profile.json] [--no-pdf] [--require-pdf] + [--replace | --dangerously-overwrite-edited-docx] ``` - The output directory may already exist. - Each output path must be new unless `--replace` is given. `--replace` is meant for rebuild loops in a matter's `outbound/draft`. +- **Edited outputs (#1186).** A person may edit `.docx` in Word after a + build. `--replace` rebuilds over an existing DOCX only when its SHA-256 + equals the `docxSha256` of a passing `.verification.json`; otherwise + it fails with `CREATION_OUTPUT_EDITED` and changes nothing. A missing, + unreadable or failed certificate, or one without a fingerprint, cannot show + the DOCX is unedited, so it is refused too. The check runs before the build + and again at publication, on the bytes actually moved aside, so an edit made + during the build is caught and rolled back. + `--dangerously-overwrite-edited-docx` implies `--replace` and accepts such + a DOCX. The displaced bytes, exactly as moved aside, are kept in + `..create-overwritten-/.docx`, and the result and summary + name the path and SHA-256. If that move fails, the staging directory that + holds them is kept and named. Overwriting the certified DOCX keeps nothing. + These checks protect against edits that finish before publication moves + the DOCX aside. A program that still holds the file open and writes after + that move writes into the kept or backed-up copy, not the new output. - Outputs are staged in a private directory inside the output directory and published as one transaction after every check passes: - each existing target, including a stale artifact this build does not diff --git a/openspec/changes/add-markdoc-document-creation/proposal.md b/openspec/changes/add-markdoc-document-creation/proposal.md index 186ba61d..7c0abb1c 100644 --- a/openspec/changes/add-markdoc-document-creation/proposal.md +++ b/openspec/changes/add-markdoc-document-creation/proposal.md @@ -64,6 +64,10 @@ byte-deterministic, which the renderer's is not. What is missing is: layer must match the source word for word; list labels, each page's footer and page number, and repeated table headers are modelled from the source, not excused by shape (#1185). +- `--replace` rebuilds only over the DOCX the last build certified (its + SHA-256 in `.verification.json`); an edited or uncertified DOCX is + refused unless `--dangerously-overwrite-edited-docx`, which keeps its exact + bytes in a named directory (#1186). - A build that fails verification with a mismatch publishes nothing and leaves `.failed-verification.json`, named in the error; the next successful build removes it (#1185). diff --git a/openspec/changes/add-markdoc-document-creation/specs/docx-markdoc/spec.md b/openspec/changes/add-markdoc-document-creation/specs/docx-markdoc/spec.md index 213ba519..64439ed3 100644 --- a/openspec/changes/add-markdoc-document-creation/specs/docx-markdoc/spec.md +++ b/openspec/changes/add-markdoc-document-creation/specs/docx-markdoc/spec.md @@ -124,3 +124,11 @@ any verification fails. - **THEN** it SHALL publish nothing and leave every existing output, including `.verification.json`, byte-identical - **AND** it SHALL write `.failed-verification.json` with the failing checks and mismatches, and the error SHALL name that path or say why it could not be written - **AND** the next successful build SHALL remove the report + +#### Scenario: [SDX-MDOC-CREATE-14] --replace rebuilds only the DOCX the last build certified +- **GIVEN** an output directory whose `.docx` may have been edited after the last build +- **WHEN** `create` runs with `--replace` +- **THEN** it SHALL rebuild only when the DOCX's SHA-256 equals the `docxSha256` of a passing `.verification.json` +- **AND** an edited DOCX, or one whose certificate is missing, unreadable, failed or has no fingerprint, SHALL be refused with `CREATION_OUTPUT_EDITED`, leaving every file unchanged +- **AND** a DOCX edited or appearing during the build SHALL be caught at publication and the publication rolled back +- **AND** with `--dangerously-overwrite-edited-docx` the build SHALL proceed and keep the exact displaced bytes in a named `..create-overwritten-…` directory, and a publication failure SHALL restore the edited DOCX in place diff --git a/openspec/changes/add-markdoc-document-creation/tasks.md b/openspec/changes/add-markdoc-document-creation/tasks.md index e6b08a8f..981e9c3e 100644 --- a/openspec/changes/add-markdoc-document-creation/tasks.md +++ b/openspec/changes/add-markdoc-document-creation/tasks.md @@ -24,6 +24,8 @@ - [x] 3.4 Independent round-trip oracle from the Markdoc source, with negative controls and word-level mismatches (`-11`, #1185). - [x] 3.5 PDF word-level comparison (`-12`) and the failure report (`-13`). +- [x] 3.6 Edited-DOCX guard for `--replace` and + `--dangerously-overwrite-edited-docx` (`-14`, #1186). ## 4. Benchmark and migration diff --git a/packages/docx-markdoc/README.md b/packages/docx-markdoc/README.md index f5848104..5bd99c79 100644 --- a/packages/docx-markdoc/README.md +++ b/packages/docx-markdoc/README.md @@ -536,7 +536,22 @@ replacing the file in that instant. Without including a PDF from an earlier build, and it never overwrites a file that appears while it runs. With `--replace`, the outputs are swapped in together, and a stale artifact this build did not produce (a PDF under `--no-pdf`) is -removed. An output that would overwrite an input, including through a +removed. + +`--replace` rebuilds over `.docx` only when it is the DOCX the last +build certified: its SHA-256 must equal `docxSha256` in a passing +`.verification.json`. If someone edited the DOCX after the build, or +the certificate is missing or unreadable, the build fails with +`CREATION_OUTPUT_EDITED` and changes nothing. The DOCX is checked again when +it is moved aside at publication, so an edit saved during the build is caught +too. To rebuild anyway, pass `--dangerously-overwrite-edited-docx`. It keeps +the displaced DOCX, byte for byte, in +`..create-overwritten-/.docx` and names that path in its +output. These checks see edits saved before publication moves the DOCX aside; +a program still writing to the file after that point writes into the kept +copy. + +An output that would overwrite an input, including through a symlinked directory or a hard link, is refused. Use `--no-pdf` to skip the PDF, or `--require-pdf` to treat missing tools as a failure. `--style-profile house.json` overrides any of `font`, `sizePt`, `spacingAfterPt`, @@ -665,7 +680,9 @@ brackets-are-blanks reading while you convert, add `fill-ins: brackets` to the frontmatter. Leftover pseudo-HTML fails with `LEGACY_MARKUP`, and the error names the tag to use instead. Make later edits through `docx-markdoc import` / `compile`, which produce -tracked changes. +tracked changes. Once someone edits the `.docx` itself, `--replace` refuses +to rebuild over it (`CREATION_OUTPUT_EDITED`), so a rebuild cannot silently +discard those edits. ### Moving from per-matter pseudo-HTML `.mdoc` diff --git a/packages/docx-markdoc/src/cli.ts b/packages/docx-markdoc/src/cli.ts index 18dc1774..376969a0 100644 --- a/packages/docx-markdoc/src/cli.ts +++ b/packages/docx-markdoc/src/cli.ts @@ -26,7 +26,7 @@ function usage(): string { ' [--note-profile profile.json | --external-notes MODE --internal-notes MODE --unspecified-notes MODE]', ' docx-markdoc verify [--external-comments|--no-external-comments]', ' docx-markdoc compile-greenfield [--style-profile profile.json]', - ' docx-markdoc create [--style-profile profile.json] [--no-pdf] [--require-pdf] [--replace]', + ' docx-markdoc create [--style-profile profile.json] [--no-pdf] [--require-pdf] [--replace | --dangerously-overwrite-edited-docx]', ' docx-markdoc export-edits ', ' docx-markdoc comments-to-footnotes [--prefix TEXT] [--prefix-separator TEXT] [--bold-prefix] [--prefix-color RRGGBB] [--prefix-highlight COLOR] [--body-color RRGGBB] [--body-highlight COLOR] [--flatten-threads]', ].join('\n'); diff --git a/packages/docx-markdoc/src/create/cli-create.ts b/packages/docx-markdoc/src/create/cli-create.ts index 61c01bfc..446707f4 100644 --- a/packages/docx-markdoc/src/create/cli-create.ts +++ b/packages/docx-markdoc/src/create/cli-create.ts @@ -1,4 +1,4 @@ -import { randomUUID } from 'node:crypto'; +import { createHash, randomUUID } from 'node:crypto'; import { link, lstat, mkdir, mkdtemp, open, readFile, realpath, rename, rm, stat, unlink, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { renderPlainPdf, type PlainPdfVerdict } from '../pdf/plain-render.js'; @@ -14,7 +14,10 @@ export type CreateCliArgs = { profilePath?: string; pdf: boolean; requirePdf: boolean; + /** Rebuild over existing outputs, but only when the DOCX is the one the last build certified. */ replace: boolean; + /** Also rebuild over an edited or uncertified DOCX, keeping its bytes in a named directory. Implies `replace`. */ + overwriteEditedDocx: boolean; }; /** The PDF verdict as recorded: page texts are dropped, and `words` holds the word-level comparison when the PDF rendered. */ @@ -22,6 +25,8 @@ export type RecordedPdfVerdict = Omit & { words?: export type CreateCliResult = { outputs: { docx: string; text: string; certificate: string; pdf?: string }; + /** Set when `--dangerously-overwrite-edited-docx` displaced a DOCX that was not the last certified build. */ + overwritten?: { docx: string; sha256: string; reason: string }; created: CreatedDocument; pdf: RecordedPdfVerdict | { status: 'skipped' }; summary: string; @@ -50,6 +55,7 @@ export function parseCreateCliArgs(args: string[]): CreateCliArgs { let pdf = true; let requirePdf = false; let replace = false; + let overwriteEditedDocx = false; for (let index = 0; index < args.length; index += 1) { const arg = args[index]!; if (arg === '--style-profile') { @@ -60,13 +66,14 @@ export function parseCreateCliArgs(args: string[]): CreateCliArgs { } else if (arg === '--no-pdf') pdf = false; else if (arg === '--require-pdf') requirePdf = true; else if (arg === '--replace') replace = true; + else if (arg === '--dangerously-overwrite-edited-docx') overwriteEditedDocx = replace = true; else if (arg.startsWith('--')) throw new Error(`Unknown option ${arg}.`); else positional.push(arg); } const [markdocPath, outputDir] = positional; if (!markdocPath || !outputDir || positional.length !== 2) throw new Error('create requires a Markdoc file and an output directory.'); if (!pdf && requirePdf) throw new Error('--no-pdf and --require-pdf are mutually exclusive.'); - return { markdocPath, outputDir, ...(profilePath ? { profilePath } : {}), pdf, requirePdf, replace }; + return { markdocPath, outputDir, ...(profilePath ? { profilePath } : {}), pdf, requirePdf, replace, overwriteEditedDocx }; } async function existingKind(target: string): Promise<'file' | 'symlink' | 'other' | null> { @@ -90,6 +97,45 @@ export function requiredPdfText(created: CreatedDocument): string[] { return [...new Set([title, lastNeedle, ...footers].filter((value): value is string => Boolean(value?.trim())))]; } +const sha256File = async (target: string): Promise => createHash('sha256').update(await readFile(target)).digest('hex'); + +/** + * What the last successful build certified about the existing DOCX: its + * SHA-256 from `.verification.json`, or why there is no usable record. + */ +export type DocxFingerprint = { sha256: string } | { sha256: null; reason: string }; + +async function certifiedDocxFingerprint(certificatePath: string): Promise { + if ((await existingKind(certificatePath)) !== 'file') return { sha256: null, reason: `${path.basename(certificatePath)} is missing` }; + let record: unknown; + try { + record = JSON.parse(await readFile(certificatePath, 'utf8')); + } catch (error) { + return { sha256: null, reason: `${path.basename(certificatePath)} is unreadable (${error instanceof Error ? error.message : String(error)})` }; + } + const { kind, passed, docxSha256 } = (record ?? {}) as { kind?: unknown; passed?: unknown; docxSha256?: unknown }; + if (kind !== 'markdoc-create' || passed !== true || typeof docxSha256 !== 'string' || !/^[0-9a-f]{64}$/u.test(docxSha256)) { + return { sha256: null, reason: `${path.basename(certificatePath)} is not a passing create certificate with a DOCX fingerprint` }; + } + return { sha256: docxSha256 }; +} + +/** + * Guard for the existing DOCX. `certified` is the fingerprint the last build + * recorded (null: none usable). Publication hashes the bytes it actually moves + * aside: unless `force`, anything but the certified bytes is refused; with + * `force`, anything but the certified bytes is kept. + */ +type DocxGuard = { target: string; certified: string | null; force: boolean; displacedSha256?: string }; + +function editedDocxError(target: string, reason: string): DocxMarkdocError { + return new DocxMarkdocError( + 'CREATION_OUTPUT_EDITED', + `Refusing to replace ${target}: ${reason}. It may hold edits made after the last build. Move it aside, or pass --dangerously-overwrite-edited-docx to replace it (its bytes are kept in a ..create-overwritten-… directory).`, + { target, reason }, + ); +} + /** Filesystem operations used to publish outputs; injectable so tests can force a mid-publication failure. */ export type PublishFileOps = { /** Opens the stem lock file (exclusive create). */ @@ -130,6 +176,7 @@ async function publish( recoveryDir: string, replace: boolean, ops: PublishFileOps, + guard: DocxGuard, ): Promise { const backups: Array<{ target: string; backup: string }> = []; const placed: Array<{ target: string; dev: number; ino: number }> = []; @@ -140,6 +187,14 @@ async function publish( const backup = path.join(backupDir, path.basename(target)); await ops.rename(target, backup); backups.push({ target, backup }); + if (target === guard.target) { + // Check the bytes actually moved aside, so an edit made after the + // preflight check is caught too; a failure here restores everything. + guard.displacedSha256 = await sha256File(backup); + if (!guard.force && guard.displacedSha256 !== guard.certified) { + throw editedDocxError(target, 'it changed or appeared during the build and is not the DOCX the last build certified'); + } + } } for (const [target, staged] of produced) { // Ownership is the private staged file's inode, read before linking: the @@ -335,6 +390,16 @@ async function createLocked( throw new DocxMarkdocError('CREATION_OUTPUT_EXISTS', `Output already exists: ${target}. Pass --replace to rebuild in place.`); } } + // --replace rebuilds only over the DOCX the last build certified. A DOCX with + // no usable certificate, or whose bytes differ from it, may hold edits. + const fingerprint = await certifiedDocxFingerprint(outputs.certificate); + let docxReason: string | undefined; + if ((await existingKind(outputs.docx)) === 'file') { + if (fingerprint.sha256 === null) docxReason = `${fingerprint.reason}, so it cannot be shown to be unedited`; + else if (fingerprint.sha256 !== (await sha256File(outputs.docx))) docxReason = 'its SHA-256 differs from the one recorded by the last build'; + if (docxReason && !options.overwriteEditedDocx) throw editedDocxError(outputs.docx, docxReason); + } + const guard: DocxGuard = { target: outputs.docx, certified: fingerprint.sha256, force: options.overwriteEditedDocx }; const profileSource = options.profilePath ? await readFile(options.profilePath) : undefined; let profile: unknown; @@ -396,7 +461,25 @@ async function createLocked( const backupDir = path.join(staging, 'replaced'); await mkdir(backupDir); const retired = Object.values(outputs).filter((target) => !produced.has(target)); - await publish(produced, retired, backupDir, path.join(outputDir, `.${stem}.create-recovery-${randomUUID()}`), options.replace, ops); + await publish(produced, retired, backupDir, path.join(outputDir, `.${stem}.create-recovery-${randomUUID()}`), options.replace, ops, guard); + // A forced overwrite keeps the displaced DOCX: the exact bytes that were moved aside. + let overwritten: CreateCliResult['overwritten']; + if (guard.displacedSha256 !== undefined && guard.displacedSha256 !== guard.certified) { + const keptDir = path.join(outputDir, `.${stem}.create-overwritten-${randomUUID()}`); + const kept = path.join(keptDir, path.basename(outputs.docx)); + try { + await mkdir(keptDir); + await ops.rename(path.join(backupDir, path.basename(outputs.docx)), kept); + } catch (error) { + keepStaging = true; + throw new DocxMarkdocError( + 'CREATION_OVERWRITE_BACKUP_FAILED', + `The new outputs were published, but the replaced DOCX could not be moved to ${kept} (${error instanceof Error ? error.message : String(error)}). Its bytes are kept in ${path.join(backupDir, path.basename(outputs.docx))}.`, + { kept: path.join(backupDir, path.basename(outputs.docx)) }, + ); + } + overwritten = { docx: kept, sha256: guard.displacedSha256, reason: docxReason ?? 'it changed during the build' }; + } const { checks } = created.certificate; const summary = [ `created ${path.basename(outputs.docx)}: ${checks.readback.paragraphs} paragraphs, ${checks.footers.sections} section(s)`, @@ -406,15 +489,18 @@ async function createLocked( `brownfield ok (${checks.brownfield.anchoredParagraphs} anchored)`, `round trip ok (${checks.roundTrip.body.expected} paragraphs; negative controls ok)`, pdf.status === 'skipped' ? 'pdf skipped' : `pdf ${pdf.status}${'pageCount' in pdf && pdf.pageCount ? ` (${pdf.pageCount} pages)` : ''}${pdf.status === 'not_run' ? ` (${pdf.reason})` : ''}${'words' in pdf && pdf.words ? `; pdf words ok (${pdf.words.expectedWords} source words)` : ''}`, + ...(overwritten ? [`WARNING: overwrote an edited ${path.basename(outputs.docx)} (${overwritten.reason}); its previous bytes (sha256 ${overwritten.sha256}) are kept in ${overwritten.docx}`] : []), ].join('; '); return { outputs: { docx: outputs.docx, text: outputs.text, certificate: outputs.certificate, ...(produced.has(outputs.pdf) ? { pdf: outputs.pdf } : {}) }, + ...(overwritten ? { overwritten } : {}), created, pdf, summary, }; } catch (error) { - keepStaging = error instanceof PublishRecoveryError && error.keepStaging; + // Keep staging when it holds originals that could not be restored or moved out. + keepStaging ||= error instanceof PublishRecoveryError && error.keepStaging; throw error; } finally { if (!keepStaging) await rm(staging, { recursive: true, force: true }); diff --git a/packages/docx-markdoc/src/create/edited-output-guard.test.ts b/packages/docx-markdoc/src/create/edited-output-guard.test.ts new file mode 100644 index 00000000..7abd9a45 --- /dev/null +++ b/packages/docx-markdoc/src/create/edited-output-guard.test.ts @@ -0,0 +1,195 @@ +import { createHash } from 'node:crypto'; +import { link, mkdir, mkdtemp, readdir, readFile, realpath, rename, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { describe, expect } from 'vitest'; +import { testAllure } from '../../../docx-core/src/testing/allure-test.js'; +import type { PdfRenderTools } from '../pdf/tools.js'; +import { parseCreateCliArgs, runCreateCommand } from './cli-create.js'; + +const TEST_FEATURE = 'add-markdoc-document-creation'; +const test = testAllure.epic('DOCX Markdoc').withLabels({ + feature: TEST_FEATURE, + story: 'Issue 1186 protect edited create outputs', + severity: 'critical', +}); + +const noPdf: PdfRenderTools = { resolve: () => null, run: async () => ({ code: 1, stdout: '', stderr: '' }) }; +const sha256 = (bytes: Buffer | string) => createHash('sha256').update(bytes).digest('hex'); +/** Stand-in for a DOCX someone edited in Word after the build: any bytes the last build did not certify. */ +const EDITED = Buffer.from('PK edited by a person after the build'); + +async function setup() { + const dir = await realpath(await mkdtemp(path.join(os.tmpdir(), 'sdx-create-edited-'))); + const source = path.join(dir, 'consent.mdoc'); + await writeFile(source, '# CONSENT\n\nThe Board approves the plan.'); + const out = path.join(dir, 'out'); + await runCreateCommand([source, out], { renderTools: noPdf }); + const docx = path.join(out, 'consent.docx'); + const certificate = path.join(out, 'consent.verification.json'); + /** Every file in the output directory, hidden ones included, as base64. */ + const snapshot = async () => Object.fromEntries(await Promise.all((await readdir(out)).sort().map(async (name) => [name, (await readFile(path.join(out, name))).toString('base64')] as const))); + const failure = (promise: Promise) => promise.then(() => undefined, (error: Error & { code?: string }) => error); + return { dir, source, out, docx, certificate, snapshot, failure }; +} + +describe('Traceability: create never silently replaces an edited DOCX', () => { + test.openspec('[SDX-MDOC-CREATE-14] --replace rebuilds only the DOCX the last build certified')( + 'Scenario: --replace rebuilds only the DOCX the last build certified', + async () => { + const { dir, source, out, docx, certificate, snapshot, failure } = await setup(); + try { + // Unchanged since the last build: a normal rebuild. + await writeFile(source, '# CONSENT\n\nThe Board approves the amended plan.'); + const rebuilt = await runCreateCommand([source, out, '--replace'], { renderTools: noPdf }); + expect(rebuilt.overwritten).toBeUndefined(); + expect(JSON.parse(await readFile(certificate, 'utf8')).docxSha256).toBe(sha256(await readFile(docx))); + + // Edited after the build: refused, and nothing in the directory changes. + const certifiedBytes = await readFile(docx); + await writeFile(docx, EDITED); + const before = await snapshot(); + await writeFile(source, '# CONSENT\n\nThe Board approves the restated plan.'); + const edited = await failure(runCreateCommand([source, out, '--replace'], { renderTools: noPdf })); + expect(edited).toMatchObject({ code: 'CREATION_OUTPUT_EDITED' }); + expect(edited!.message).toMatch(/SHA-256 differs.*--dangerously-overwrite-edited-docx/); + expect(await snapshot()).toEqual(before); + // Without --replace the old refusal still comes first. + expect(await failure(runCreateCommand([source, out], { renderTools: noPdf }))).toMatchObject({ code: 'CREATION_OUTPUT_EXISTS' }); + + // A missing, unreadable, failed or foreign certificate cannot vouch for the DOCX. + await writeFile(docx, certifiedBytes); + const good = await readFile(certificate, 'utf8'); + const certified = JSON.parse(good); + expect(certified.docxSha256).toBe(sha256(certifiedBytes)); + for (const [label, record] of [ + ['missing', null], + ['unreadable', '{ not json'], + ['failed', JSON.stringify({ ...certified, passed: false })], + ['foreign', JSON.stringify({ ...certified, docxSha256: 'f'.repeat(64) })], + ['no fingerprint', JSON.stringify({ ...certified, docxSha256: undefined })], + ] as const) { + // The DOCX is the certified one, so only the record decides. + if (record === null) await rm(certificate); + else await writeFile(certificate, record); + const refused = await failure(runCreateCommand([source, out, '--replace'], { renderTools: noPdf })); + expect(refused, label).toMatchObject({ code: 'CREATION_OUTPUT_EDITED' }); + expect(refused!.message, label).toMatch(label === 'foreign' ? /SHA-256 differs/ : /cannot be shown to be unedited/); + await writeFile(certificate, good); + expect(await readFile(docx)).toEqual(certifiedBytes); + } + // With the record back, the same DOCX rebuilds normally. + expect((await runCreateCommand([source, out, '--replace'], { renderTools: noPdf })).overwritten).toBeUndefined(); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }, + ); + + test.openspec('[SDX-MDOC-CREATE-14] --dangerously-overwrite-edited-docx keeps the exact edited bytes')( + 'Scenario: --dangerously-overwrite-edited-docx keeps the exact edited bytes', + async () => { + const { dir, source, out, docx, certificate, failure, snapshot } = await setup(); + try { + expect(parseCreateCliArgs(['a.mdoc', 'out', '--dangerously-overwrite-edited-docx'])).toMatchObject({ replace: true, overwriteEditedDocx: true }); + await writeFile(docx, EDITED); + + // A failure while publishing rolls back: the edited DOCX is back in place, byte for byte. + const before = await snapshot(); + let links = 0; + const failingLink = async (from: string, to: string) => { + links += 1; + if (links === 2) throw Object.assign(new Error('disk full'), { code: 'ENOSPC' }); + await link(from, to); + }; + expect(await failure(runCreateCommand([source, out, '--dangerously-overwrite-edited-docx'], { renderTools: noPdf, fileOps: { link: failingLink } }))).toMatchObject({ message: 'disk full' }); + expect(await snapshot()).toEqual(before); + + // Forced: the new build is published and the edited bytes are kept, named in the result. + const forced = await runCreateCommand([source, out, '--dangerously-overwrite-edited-docx'], { renderTools: noPdf }); + expect(forced.overwritten).toMatchObject({ sha256: sha256(EDITED), reason: expect.stringMatching(/SHA-256 differs/) }); + expect(path.dirname(forced.overwritten!.docx)).toMatch(/\/\.consent\.create-overwritten-[0-9a-f-]+$/); + expect(Buffer.compare(await readFile(forced.overwritten!.docx), EDITED)).toBe(0); + expect(forced.summary).toContain(`WARNING: overwrote an edited consent.docx`); + expect(forced.summary).toContain(forced.overwritten!.docx); + expect(JSON.parse(await readFile(certificate, 'utf8')).docxSha256).toBe(sha256(await readFile(docx))); + // The new build is certified, so the next --replace is a normal rebuild. + expect((await runCreateCommand([source, out, '--replace'], { renderTools: noPdf })).overwritten).toBeUndefined(); + + // Forcing over the certified DOCX keeps nothing: there is nothing unique to keep. + const again = await runCreateCommand([source, out, '--dangerously-overwrite-edited-docx'], { renderTools: noPdf }); + expect(again.overwritten).toBeUndefined(); + expect((await readdir(out)).filter((name) => name.startsWith('.consent.create-overwritten-'))).toHaveLength(1); + + // If the kept copy cannot be moved out, the staging directory holding it is kept and named. + await writeFile(docx, EDITED); + const failingKeep = async (from: string, to: string) => { + if (to.includes('.create-overwritten-')) throw Object.assign(new Error('read-only'), { code: 'EROFS' }); + await rename(from, to); + }; + const kept = await failure(runCreateCommand([source, out, '--dangerously-overwrite-edited-docx'], { renderTools: noPdf, fileOps: { rename: failingKeep } })); + expect(kept).toMatchObject({ code: 'CREATION_OVERWRITE_BACKUP_FAILED' }); + const keptPath = (kept as unknown as { details: { kept: string } }).details.kept; + expect(kept!.message).toContain(keptPath); + expect(Buffer.compare(await readFile(keptPath), EDITED)).toBe(0); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }, + ); + + test.openspec('[SDX-MDOC-CREATE-14] an edit made during the build is caught at publication')( + 'Scenario: an edit made during the build is caught at publication', + async () => { + const { dir, source, out, docx, failure, snapshot } = await setup(); + try { + // The DOCX is certified when checked, then edited before publication moves it aside. + const editingRename = async (from: string, to: string) => { + if (from === docx) await writeFile(docx, EDITED); + await rename(from, to); + }; + const raced = await failure(runCreateCommand([source, out, '--replace'], { renderTools: noPdf, fileOps: { rename: editingRename } })); + expect(raced).toMatchObject({ code: 'CREATION_OUTPUT_EDITED' }); + expect(raced!.message).toMatch(/during the build/); + // Rolled back: the edited file is in place and every other output is unchanged. + expect(Buffer.compare(await readFile(docx), EDITED)).toBe(0); + const afterRace = await snapshot(); + + // Forced, the same race keeps the bytes that were actually moved aside. + const certifiedNow = async () => { + await rm(out, { recursive: true }); + await runCreateCommand([source, out], { renderTools: noPdf }); + }; + await certifiedNow(); + const forced = await runCreateCommand([source, out, '--dangerously-overwrite-edited-docx'], { renderTools: noPdf, fileOps: { rename: editingRename } }); + expect(forced.overwritten).toMatchObject({ sha256: sha256(EDITED), reason: 'it changed during the build' }); + expect(Buffer.compare(await readFile(forced.overwritten!.docx), EDITED)).toBe(0); + + // A DOCX that appears during a build, when there was none to certify, is refused under --replace. + await rm(out, { recursive: true }); + await mkdir(out); + await writeFile(path.join(out, 'consent.txt'), 'stale mirror'); + const appearing: PdfRenderTools = { + resolve: (name) => `/fake/${name}`, + async run(command, args) { + if (command.endsWith('soffice')) { + await writeFile(docx, EDITED); + const outdir = args[args.indexOf('--outdir') + 1]!; + await mkdir(outdir, { recursive: true }); + await writeFile(path.join(outdir, 'input.pdf'), '%PDF-fake'); + return { code: 0, stdout: '', stderr: '' }; + } + return { code: 0, stdout: 'CONSENT\nThe Board approves the plan.\n\f', stderr: '' }; + }, + }; + const appeared = await failure(runCreateCommand([source, out, '--replace'], { renderTools: appearing })); + expect(appeared).toMatchObject({ code: 'CREATION_OUTPUT_EDITED' }); + expect(Buffer.compare(await readFile(docx), EDITED)).toBe(0); + expect(await readFile(path.join(out, 'consent.txt'), 'utf8')).toBe('stale mirror'); + expect(afterRace).toHaveProperty(['consent.docx'], EDITED.toString('base64')); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }, + ); +});