diff --git a/README.md b/README.md index f2b02332..9c21c876 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,7 @@ runtime. | Text-position selector | `@contextualwisdomlab/cwl-editor/text-position-selector` | React-free deterministic W3C `TextPositionSelector` projection core | | Autosave | `@contextualwisdomlab/cwl-editor/autosave` | Provider-neutral bounded single-flight persistence coordination | | Headless Markdown | `@contextualwisdomlab/cwl-editor/markdown` | React-free deterministic Markdown/HTML/email/plain-text conversion | +| DOCX import | `@contextualwisdomlab/cwl-editor/docx` | Active-PR bounded, framework-neutral WordprocessingML import into inert Inkspan document data | | Styles | `@contextualwisdomlab/cwl-editor/styles.css` | Editor layout and theming | | Full fonts | `@contextualwisdomlab/cwl-editor/fonts.css` | KR/EN/JP/SC/TC/VI offline font bundle | | Latin fonts | `@contextualwisdomlab/cwl-editor/fonts-latin.css` | Smaller Latin/Vietnamese-only bundle | @@ -217,7 +218,6 @@ policy. See [`docs/selection-lifecycle.md`](docs/selection-lifecycle.md) and Delayed autosave, AI, template, and review results can be applied under the strong revision from which they started: - ```tsx const result = await editorRef.current?.restoreDocumentEnvelopeIfMatch( expectedRevision.strongEntityTag, diff --git a/docs/package-distribution.md b/docs/package-distribution.md index ddb4df0e..a19fef3a 100644 --- a/docs/package-distribution.md +++ b/docs/package-distribution.md @@ -14,6 +14,7 @@ integrations. | `@contextualwisdomlab/cwl-editor/autosave` | Framework-independent autosave queue/session APIs for bounded local save ordering and host-owned durable concurrency | | `@contextualwisdomlab/cwl-editor/collaboration` | Optional Yjs collaboration surface with host-owned transport and lifecycle | | `@contextualwisdomlab/cwl-editor/converter` | Framework-independent base64 and data-URI utilities | +| `@contextualwisdomlab/cwl-editor/docx` | `implemented_on_active_pr` — framework-independent bounded DOCX/WordprocessingML import into inert Inkspan document data; no transport, credentials, macros, external relationships, or model authority | | `@contextualwisdomlab/cwl-editor/envelope-identity` | Framework-independent identity-only envelope routing for bounded schema identity inspection; migration remains host-owned | | `@contextualwisdomlab/cwl-editor/revision-evidence` | Framework-independent revision evidence and document-transition evidence for local content equality/lineage claims | | `@contextualwisdomlab/cwl-editor/text-position-selector` | `implemented_on_protected_main` — React-free text-position projection core implementing W3C `TextPositionSelector`; interactive capture, revision binding, authorization, persistence, and re-anchoring remain outside this subpath | @@ -57,12 +58,19 @@ embedded in the npm tarball. and collaboration entrypoints. It is declared in Inkspan's package dependencies so the consumer's package manager installs and resolves it; it is not merely a type-only dependency. -- The framework-independent autosave, converter, envelope-identity, +- The framework-independent autosave, converter, DOCX, envelope-identity, revision-evidence, text-position-selector, and Markdown entrypoints do not require React UI, a mounted editor, naruon, contextual-orchestrator, a database, provider credentials, or host transport. Their individual package-consumer gates additionally prevent framework dependencies from leaking into subpaths whose public contracts exclude them. +- The DOCX subpath accepts bounded local ZIP/Office Open XML bytes and converts + supported WordprocessingML structure into inert Inkspan document data. It + validates OPC content types/relationships, rejects active/external authority, + and performs no network fetch, macro execution, credential lookup, model call, + durable persistence, or host authorization. Unsupported document semantics + fail as bounded import errors rather than being executed or silently granted + authority. - The Markdown subpath exposes `markdownToHtml`, `htmlToMarkdown`, `normalizeMarkdown`, `markdownToEmailHtml`, `markdownToPlainText`, and `htmlToPlainText` plus their option types. It bundles deterministic conversion @@ -92,9 +100,9 @@ embedded in the npm tarball. import it, and bundlers can retain the separate dependency boundary. - Importing any JavaScript entrypoint in Node.js must not require a browser DOM. Browser-only work begins when a host mounts the editor or calls APIs that - explicitly consume browser objects such as `File` or `Blob`. The Markdown - conversion surface remains Node-importable; HTML-to-Markdown uses its bounded - non-fetching parser fallback when no browser `document` exists. + explicitly consume browser objects such as `File` or `Blob`. The Markdown and + DOCX conversion surfaces remain Node-importable; HTML-to-Markdown uses its + bounded non-fetching parser fallback when no browser `document` exists. - CSS and font entrypoints resolve as files and are not executable JavaScript. ## Release verification @@ -108,7 +116,7 @@ production library build. The verification chain: 3. confirms required licenses, declarations, styles, and font assets ship; 4. rejects internal source, tests, demos, Office files, coverage output, and workflow files from the npm tarball; -5. imports the root, collaboration, converter, autosave, envelope-identity, +5. imports the root, collaboration, converter, autosave, DOCX, envelope-identity, revision-evidence, text-position-selector, and Markdown surfaces through their dedicated packed-consumer checks, including framework-free isolation where that is part of the public contract; @@ -159,4 +167,4 @@ observable consumer behavior. - npm `pack`, including dry-run and JSON manifest output: - npm package publication and the `files` allowlist: - + \ No newline at end of file diff --git a/package.json b/package.json index 4e55d924..40f9f026 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,11 @@ "import": "./dist/cwl-converter.js", "require": "./dist/cwl-converter.cjs" }, + "./docx": { + "types": "./dist/docx/index.d.ts", + "import": "./dist/cwl-docx.js", + "require": "./dist/cwl-docx.cjs" + }, "./envelope-identity": { "types": "./dist/envelope-identity/index.d.ts", "import": "./dist/cwl-envelope-identity.js", @@ -99,7 +104,7 @@ ], "scripts": { "dev": "vite", - "build": "tsc --noEmit && vite build && vite build --config vite.collaboration.config.ts && vite build --config vite.converter.config.ts && vite build --config vite.envelope-identity.config.ts && vite build --config vite.revision-evidence.config.ts && vite build --config vite.autosave.config.ts && vite build --config vite.text-position-selector.config.ts && vite build --config vite.markdown.config.ts && node ./scripts/copy-styles.mjs", + "build": "tsc --noEmit && vite build && vite build --config vite.collaboration.config.ts && vite build --config vite.converter.config.ts && vite build --config vite.docx.config.ts && vite build --config vite.envelope-identity.config.ts && vite build --config vite.revision-evidence.config.ts && vite build --config vite.autosave.config.ts && vite build --config vite.text-position-selector.config.ts && vite build --config vite.markdown.config.ts && node ./scripts/copy-styles.mjs", "build:demo": "vite build --config vite.demo.config.ts", "fonts": "node ./scripts/fetch-fonts.mjs", "preview": "vite preview", @@ -108,7 +113,7 @@ "test:watch": "vitest", "coverage": "vitest run --coverage", "test:package-config": "node --test ./scripts/revision-evidence-consumer-config.test.mjs ./scripts/release-metadata.test.mjs ./scripts/javascript-runtime-authority.test.mjs", - "verify:package": "pnpm run test:package-config && node ./tests/package/verify-package.mjs && node ./tests/package/verify-editor-placeholder-package.mjs && node ./scripts/verify-canonical-envelope-package.mjs && node ./scripts/verify-revision-evidence-package.mjs && node ./scripts/verify-framework-free-revision-evidence-package.mjs && node ./scripts/verify-framework-free-envelope-identity-package.mjs && node ./tests/package/verify-framework-free-autosave-package.mjs && node ./scripts/verify-text-position-selector-package.mjs && node ./scripts/verify-text-position-selector-subpath-package.mjs && node ./scripts/verify-markdown-subpath-package.mjs" + "verify:package": "pnpm run test:package-config && node ./tests/package/verify-package.mjs && node ./tests/package/verify-editor-placeholder-package.mjs && node ./scripts/verify-canonical-envelope-package.mjs && node ./scripts/verify-revision-evidence-package.mjs && node ./scripts/verify-framework-free-revision-evidence-package.mjs && node ./scripts/verify-framework-free-envelope-identity-package.mjs && node ./tests/package/verify-framework-free-autosave-package.mjs && node ./scripts/verify-text-position-selector-package.mjs && node ./scripts/verify-text-position-selector-subpath-package.mjs && node ./scripts/verify-markdown-subpath-package.mjs && node ./scripts/verify-docx-subpath-package.mjs" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", diff --git a/scripts/verify-docx-subpath-package.mjs b/scripts/verify-docx-subpath-package.mjs new file mode 100644 index 00000000..70fd418c --- /dev/null +++ b/scripts/verify-docx-subpath-package.mjs @@ -0,0 +1,335 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { findRuntimeModuleAuthority } from './javascript-runtime-authority.mjs'; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const packageJson = JSON.parse( + readFileSync(join(repositoryRoot, 'package.json'), 'utf8'), +); +const verificationRoot = mkdtempSync(join(tmpdir(), 'inkspan-docx-')); +const extractionDirectory = join(verificationRoot, 'extracted'); +const consumerDirectory = join(verificationRoot, 'consumer'); +const packageDirectory = join( + consumerDirectory, + 'node_modules', + ...packageJson.name.split('/'), +); +const ambientAuthorityPattern = + /(?:\bfetch\s*\(|\bXMLHttpRequest\b|\bWebSocket\b|\bEventSource\b|\bprocess\.env\b|\bimport\.meta\.env\b|\bDeno\.env\b|\bBun\.env\b|\bindexedDB\b|\blocalStorage\b|\bsessionStorage\b)/u; + +/** Execute one deterministic package-consumer command. */ +function run(command, argumentsList, cwd = repositoryRoot) { + return execFileSync(command, argumentsList, { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'inherit'], + }); +} + +/** Build one real npm tarball and install its files without executing scripts. */ +function preparePackage() { + mkdirSync(extractionDirectory, { recursive: true }); + mkdirSync(dirname(packageDirectory), { recursive: true }); + const packOutput = run('npm', [ + 'pack', + '--json', + '--ignore-scripts', + '--pack-destination', + verificationRoot, + ]); + const packResult = JSON.parse(packOutput)[0]; + assert.equal(packResult.name, packageJson.name); + assert.equal(packResult.version, packageJson.version); + const tarballPath = join(verificationRoot, packResult.filename); + assert.ok(existsSync(tarballPath)); + run('tar', ['-xzf', tarballPath, '-C', extractionDirectory]); + renameSync(join(extractionDirectory, 'package'), packageDirectory); + writeFileSync( + join(consumerDirectory, 'package.json'), + '{"name":"inkspan-docx-consumer","private":true,"type":"module"}\n', + 'utf8', + ); +} + +/** Prove the packed subpath exists and carries no external or ambient authority. */ +function verifyPackedSurface() { + for (const relativePath of [ + 'dist/cwl-docx.js', + 'dist/cwl-docx.cjs', + 'dist/docx/index.d.ts', + ]) { + assert.ok( + existsSync(join(packageDirectory, relativePath)), + `${relativePath} must be present in the packed package`, + ); + } + + for (const filename of ['cwl-docx.js', 'cwl-docx.cjs']) { + const bundlePath = join(packageDirectory, 'dist', filename); + const bundleSource = readFileSync(bundlePath, 'utf8'); + const moduleAuthority = findRuntimeModuleAuthority(bundleSource, filename); + assert.equal( + moduleAuthority.length, + 0, + `${filename} must not import runtime module authority: ${JSON.stringify(moduleAuthority)}`, + ); + assert.doesNotMatch( + bundleSource, + ambientAuthorityPattern, + `${filename} must not reference ambient network, credential, or durable-storage authority`, + ); + } +} + +/** Return one little-endian unsigned 16-bit ZIP field. */ +function uint16(value) { + const bytes = Buffer.alloc(2); + bytes.writeUInt16LE(value); + return bytes; +} + +/** Return one little-endian unsigned 32-bit ZIP field. */ +function uint32(value) { + const bytes = Buffer.alloc(4); + bytes.writeUInt32LE(value >>> 0); + return bytes; +} + +/** Compute ZIP CRC-32 for one deterministic packed-consumer fixture. */ +function crc32(bytes) { + let crc = 0xffffffff; + for (const byte of bytes) { + crc ^= byte; + for (let bit = 0; bit < 8; bit += 1) { + crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0); + } + } + return (crc ^ 0xffffffff) >>> 0; +} + +/** Build the smallest stored-entry ZIP needed for a real DOCX consumer proof. */ +function createStoredZip(entries) { + const localRecords = []; + const centralRecords = []; + let localOffset = 0; + for (const [name, source] of Object.entries(entries)) { + const nameBytes = Buffer.from(name, 'utf8'); + const data = Buffer.from(source, 'utf8'); + const checksum = crc32(data); + const local = Buffer.concat([ + uint32(0x04034b50), + uint16(20), + uint16(0x0800), + uint16(0), + uint16(0), + uint16(0), + uint32(checksum), + uint32(data.byteLength), + uint32(data.byteLength), + uint16(nameBytes.byteLength), + uint16(0), + nameBytes, + data, + ]); + const central = Buffer.concat([ + uint32(0x02014b50), + uint16(20), + uint16(20), + uint16(0x0800), + uint16(0), + uint16(0), + uint16(0), + uint32(checksum), + uint32(data.byteLength), + uint32(data.byteLength), + uint16(nameBytes.byteLength), + uint16(0), + uint16(0), + uint16(0), + uint16(0), + uint32(0), + uint32(localOffset), + nameBytes, + ]); + localRecords.push(local); + centralRecords.push(central); + localOffset += local.byteLength; + } + const centralDirectory = Buffer.concat(centralRecords); + const end = Buffer.concat([ + uint32(0x06054b50), + uint16(0), + uint16(0), + uint16(centralRecords.length), + uint16(centralRecords.length), + uint32(centralDirectory.byteLength), + uint32(localOffset), + uint16(0), + ]); + return Buffer.concat([...localRecords, centralDirectory, end]); +} + +/** Create one valid, local-only DOCX package for the packed runtime consumer. */ +function createMinimalDocxBase64() { + return createStoredZip({ + '[Content_Types].xml': + '', + 'word/document.xml': + 'Packed consumer', + }).toString('base64'); +} + +/** Exercise the exact public ESM and CommonJS subpath from the packed package. */ +function verifyRuntimeConsumers() { + const validDocxBase64 = createMinimalDocxBase64(); + const expectedDocument = JSON.stringify({ + type: 'doc', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'Packed consumer' }], + }, + ], + }); + const esmPath = join(consumerDirectory, 'consumer.mjs'); + writeFileSync( + esmPath, + `import assert from 'node:assert/strict'; +import { + DEFAULT_DOCX_IMPORT_LIMITS, + DocxImportError, + importDocx, + openDocx, +} from '${packageJson.name}/docx'; +assert.equal(typeof DEFAULT_DOCX_IMPORT_LIMITS.maxArchiveBytes, 'number'); +assert.equal(typeof DocxImportError, 'function'); +assert.equal(typeof importDocx, 'function'); +assert.equal(typeof openDocx, 'function'); +const imported = await importDocx(Uint8Array.from(Buffer.from('${validDocxBase64}', 'base64'))); +assert.deepEqual(imported.documentJson, ${expectedDocument}); +assert.deepEqual(imported.warnings, []); +assert.equal(Object.isFrozen(imported), true); +assert.equal(Object.isFrozen(imported.documentJson), true); +await assert.rejects( + importDocx(new Uint8Array()), + (error) => error instanceof DocxImportError && error.code === 'invalid_source', +); +`, + 'utf8', + ); + + const cjsPath = join(consumerDirectory, 'consumer.cjs'); + writeFileSync( + cjsPath, + `const assert = require('node:assert/strict'); +const docx = require('${packageJson.name}/docx'); +assert.equal(typeof docx.DEFAULT_DOCX_IMPORT_LIMITS.maxArchiveBytes, 'number'); +assert.equal(typeof docx.DocxImportError, 'function'); +assert.equal(typeof docx.importDocx, 'function'); +assert.equal(typeof docx.openDocx, 'function'); +(async () => { + const imported = await docx.importDocx(Uint8Array.from(Buffer.from('${validDocxBase64}', 'base64'))); + assert.deepEqual(imported.documentJson, ${expectedDocument}); + assert.deepEqual(imported.warnings, []); + assert.equal(Object.isFrozen(imported), true); + assert.equal(Object.isFrozen(imported.documentJson), true); + await assert.rejects( + docx.importDocx(new Uint8Array()), + (error) => error instanceof docx.DocxImportError && error.code === 'invalid_source', + ); +})().catch((error) => { + console.error(error); + process.exitCode = 1; +}); +`, + 'utf8', + ); + + run(process.execPath, [esmPath], consumerDirectory); + run(process.execPath, [cjsPath], consumerDirectory); +} + +/** Compile one strict TypeScript consumer against only the public subpath. */ +function verifyDeclarationConsumer() { + const sourcePath = join(consumerDirectory, 'consumer.ts'); + const configurationPath = join(consumerDirectory, 'tsconfig.json'); + writeFileSync( + sourcePath, + `import { + DEFAULT_DOCX_IMPORT_LIMITS, + DocxImportError, + importDocx, + openDocx, + type DocxDocumentTarget, + type DocxImportOptions, + type DocxImportResult, + type DocxSource, +} from '${packageJson.name}/docx'; +declare const source: DocxSource; +declare const options: DocxImportOptions; +declare const target: DocxDocumentTarget; +const imported: Promise = importDocx(source, options); +const opened: Promise = openDocx(target, source, options); +const failure = new DocxImportError('invalid_source'); +void [ + imported, + opened, + failure.code, + DEFAULT_DOCX_IMPORT_LIMITS.maxArchiveBytes, +]; +`, + 'utf8', + ); + writeFileSync( + configurationPath, + `${JSON.stringify( + { + compilerOptions: { + noEmit: true, + strict: true, + skipLibCheck: false, + module: 'NodeNext', + moduleResolution: 'NodeNext', + target: 'ES2022', + lib: ['ES2022', 'DOM', 'DOM.Iterable'], + types: [], + }, + files: ['./consumer.ts'], + }, + null, + 2, + )}\n`, + 'utf8', + ); + const compilerPath = join( + repositoryRoot, + 'node_modules', + 'typescript', + 'bin', + 'tsc', + ); + assert.ok(existsSync(compilerPath)); + run(process.execPath, [compilerPath, '--project', configurationPath], consumerDirectory); +} + +try { + preparePackage(); + verifyPackedSurface(); + verifyRuntimeConsumers(); + verifyDeclarationConsumer(); +} finally { + rmSync(verificationRoot, { recursive: true, force: true }); +} \ No newline at end of file diff --git a/src/docx/docxCoverageGaps.test.ts b/src/docx/docxCoverageGaps.test.ts new file mode 100644 index 00000000..c68efa90 --- /dev/null +++ b/src/docx/docxCoverageGaps.test.ts @@ -0,0 +1,678 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + buildZip, + createDocx, + findSignature, + patchUint16, + patchUint32, + PNG_BYTES, + WORD_NAMESPACES, +} from '../../test/docxFixture.js'; +import { + DocxImportError, + type DocxImportErrorCode, +} from './errors.js'; +import { importDocx, openDocx } from './importDocx.js'; +import { DEFAULT_DOCX_IMPORT_LIMITS } from './limits.js'; +import { parseDocxPackage } from './ooxml.js'; +import { readDocxPackageMetadata } from './ooxmlPackage.js'; +import { appendInline, textNode, type InlinePart } from './ooxmlShared.js'; +import { ZipArchive } from './zip.js'; + +const limits = DEFAULT_DOCX_IMPORT_LIMITS; +const LOCAL_SIGNATURE = 0x04034b50; +const CENTRAL_SIGNATURE = 0x02014b50; +const EOCD_SIGNATURE = 0x06054b50; + +function expectCode(operation: () => unknown, code: DocxImportErrorCode): void { + let thrown: unknown; + try { + operation(); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(DocxImportError); + expect(thrown).toMatchObject({ code }); +} + +async function expectAsyncCode( + operation: Promise, + code: DocxImportErrorCode, +): Promise { + await expect(operation).rejects.toMatchObject({ + name: 'DocxImportError', + code, + }); +} + +function zipOffsets(bytes: Uint8Array): { + readonly local: number; + readonly central: number; + readonly eocd: number; +} { + const local = findSignature(bytes, LOCAL_SIGNATURE); + const central = findSignature(bytes, CENTRAL_SIGNATURE); + const eocd = findSignature(bytes, EOCD_SIGNATURE); + expect(local).toBeGreaterThanOrEqual(0); + expect(central).toBeGreaterThan(local); + expect(eocd).toBeGreaterThan(central); + return { local, central, eocd }; +} + +function expectZipCode( + bytes: Uint8Array, + code: DocxImportErrorCode, + customLimits = limits, +): void { + expectCode(() => ZipArchive.parse(bytes, customLimits), code); +} + +function oneStoredEntry(): Uint8Array { + return buildZip({ 'a.txt': 'abc' }, 0); +} + +function blobPart(bytes: Uint8Array): ArrayBuffer { + const copy = new ArrayBuffer(bytes.byteLength); + new Uint8Array(copy).set(bytes); + return copy; +} + +function relationshipXml(entries: string): string { + return ( + '' + + entries + + '' + ); +} + +function imageRelationship(id: string, target: string, type = 'image'): string { + const relationshipType = + type === 'image' + ? 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/image' + : type; + return ``; +} + +function imageParagraph(id: string, alt = ''): string { + return ``; +} + +describe('DOCX ZIP safety coverage', () => { + it('reads stored entries once, reports sizes, and rejects unknown entries', async () => { + const parsed = ZipArchive.parse(oneStoredEntry(), limits); + expect(parsed.has('a.txt')).toBe(true); + expect(parsed.has('missing')).toBe(false); + expect(parsed.size('a.txt')).toBe(3); + expect(parsed.size('missing')).toBeUndefined(); + const first = parsed.read('a.txt'); + const second = parsed.read('a.txt'); + expect(second).toBe(first); + await expect(first.then((bytes) => Array.from(bytes))).resolves.toEqual([97, 98, 99]); + await expectAsyncCode(parsed.read('missing'), 'invalid_docx'); + }); + + it('bounds integer reads and requires a structurally valid EOCD', () => { + expectZipCode(new Uint8Array(), 'invalid_zip'); + expectZipCode(new Uint8Array(21), 'invalid_zip'); + + const base = oneStoredEntry(); + const { eocd } = zipOffsets(base); + expectZipCode(patchUint32(base, eocd, 0), 'invalid_zip'); + expectZipCode(patchUint16(base, eocd + 20, 1), 'invalid_zip'); + }); + + it('rejects multi-disk, Zip64, entry-count, and central-directory boundary shapes', () => { + const base = oneStoredEntry(); + const { eocd } = zipOffsets(base); + for (const offset of [4, 6]) { + expectZipCode(patchUint16(base, eocd + offset, 1), 'unsupported_archive'); + } + expectZipCode(patchUint16(base, eocd + 8, 2), 'unsupported_archive'); + + let zip64Entries = patchUint16(base, eocd + 8, 0xffff); + zip64Entries = patchUint16(zip64Entries, eocd + 10, 0xffff); + expectZipCode(zip64Entries, 'unsupported_archive'); + expectZipCode(patchUint32(base, eocd + 12, 0xffffffff), 'unsupported_archive'); + expectZipCode(patchUint32(base, eocd + 16, 0xffffffff), 'unsupported_archive'); + + expectZipCode(base, 'archive_limit_exceeded', { ...limits, maxEntries: 0 }); + expectZipCode(patchUint32(base, eocd + 16, eocd + 1), 'invalid_zip'); + expectZipCode(patchUint32(base, eocd + 12, eocd + 1), 'invalid_zip'); + }); + + it('rejects malformed central records, flags, methods, Zip64 fields, and foreign disks', () => { + const base = oneStoredEntry(); + const { central } = zipOffsets(base); + expectZipCode(patchUint32(base, central, 0), 'invalid_zip'); + expectZipCode(patchUint16(base, central + 28, 0xffff), 'invalid_zip'); + expectZipCode(patchUint16(base, central + 8, 0x0801), 'encrypted_archive'); + expectZipCode(patchUint16(base, central + 8, 0x0804), 'unsupported_archive'); + expectZipCode(patchUint16(base, central + 10, 9), 'unsupported_archive'); + + for (const offset of [20, 24, 42]) { + expectZipCode(patchUint32(base, central + offset, 0xffffffff), 'unsupported_archive'); + } + expectZipCode(patchUint16(base, central + 34, 0xffff), 'unsupported_archive'); + expectZipCode(patchUint16(base, central + 34, 1), 'unsupported_archive'); + }); + + it('enforces compressed, expanded, ratio, and total archive resource ceilings', () => { + const stored = oneStoredEntry(); + expectZipCode(stored, 'archive_limit_exceeded', { ...limits, maxArchiveBytes: 2 }); + expectZipCode(stored, 'archive_limit_exceeded', { ...limits, maxEntryBytes: 2 }); + expectZipCode(stored, 'archive_limit_exceeded', { + ...limits, + maxTotalUncompressedBytes: 2, + }); + + const { central } = zipOffsets(stored); + expectZipCode(patchUint32(stored, central + 20, 0), 'archive_limit_exceeded'); + + const compressed = buildZip({ 'a.txt': 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' }, 8); + expectZipCode(compressed, 'archive_limit_exceeded', { + ...limits, + maxCompressionRatio: 1, + }); + }); + + it('validates entry names, duplicate logical paths, directories, and central-directory exhaustion', async () => { + for (const centralName of ['', '/root', 'C:drive', 'a\\b', 'a\0b', 'a//b', 'a/./b', 'a/../b']) { + expectZipCode( + buildZip( + { + logical: { + data: 'x', + localName: 'logical', + centralName, + }, + }, + 0, + ), + 'invalid_zip', + ); + } + + expectZipCode( + buildZip( + { + logical: { + data: 'x', + flags: 0, + localName: 'logical', + centralName: 'é', + }, + }, + 0, + ), + 'unsupported_archive', + ); + + const malformedUtf8 = buildZip({ logical: { data: 'x', centralName: 'x' } }, 0); + const malformedCentral = zipOffsets(malformedUtf8).central; + const malformedBytes = malformedUtf8.slice(); + malformedBytes[malformedCentral + 46] = 0xff; + expectZipCode(malformedBytes, 'invalid_zip'); + + expectZipCode( + buildZip( + { + one: { data: '1', centralName: 'dup' }, + two: { data: '2', centralName: 'dup' }, + }, + 0, + ), + 'invalid_zip', + ); + expectZipCode(buildZip({ 'folder/': 'x' }, 0), 'invalid_zip'); + + const withDirectory = ZipArchive.parse( + buildZip({ 'folder/': '', 'folder/a.txt': 'ok' }, 0), + limits, + ); + expect(withDirectory.has('folder/')).toBe(false); + await expect( + withDirectory.read('folder/a.txt').then((bytes) => Array.from(bytes)), + ).resolves.toEqual([111, 107]); + + const base = oneStoredEntry(); + const { eocd } = zipOffsets(base); + const centralSize = new DataView(base.buffer, base.byteOffset, base.byteLength).getUint32( + eocd + 12, + true, + ); + expectZipCode(patchUint32(base, eocd + 12, centralSize + 1), 'invalid_zip'); + }); + + it('validates local headers, metadata agreement, local names, payload bounds, size, and checksum', async () => { + const base = oneStoredEntry(); + const { local, central } = zipOffsets(base); + + const readFailure = async (bytes: Uint8Array): Promise => { + const parsed = ZipArchive.parse(bytes, limits); + await expectAsyncCode(parsed.read('a.txt'), 'invalid_zip'); + }; + + await readFailure(patchUint32(base, local, 0)); + await readFailure(patchUint16(base, local + 6, 0)); + await readFailure(patchUint16(base, local + 8, 8)); + await readFailure(patchUint32(base, local + 14, 0)); + + const localNameMismatch = buildZip( + { 'a.txt': { data: 'abc', localName: 'b.txt', centralName: 'a.txt' } }, + 0, + ); + await readFailure(localNameMismatch); + + const invalidDataStart = patchUint16(base, local + 28, 0xffff); + await readFailure(invalidDataStart); + + let tooLongPayload = buildZip( + { 'a.txt': { data: 'abc', flags: 0x0808 } }, + 0, + ); + const tooLongOffsets = zipOffsets(tooLongPayload); + tooLongPayload = patchUint32(tooLongPayload, tooLongOffsets.central + 20, 1_000); + await readFailure(tooLongPayload); + + let wrongSize = buildZip({ 'a.txt': { data: 'abc', flags: 0x0808 } }, 0); + const wrongSizeOffsets = zipOffsets(wrongSize); + wrongSize = patchUint32(wrongSize, wrongSizeOffsets.central + 24, 4); + await readFailure(wrongSize); + + const wrongCrc = base.slice(); + wrongCrc[local + 30 + 'a.txt'.length] ^= 0xff; + await readFailure(wrongCrc); + + const foreignOffset = patchUint32(base, central + 42, central - 1); + const parsedForeignOffset = ZipArchive.parse(foreignOffset, limits); + await expectAsyncCode(parsedForeignOffset.read('a.txt'), 'invalid_zip'); + + const retry = ZipArchive.parse(wrongCrc, limits); + await expectAsyncCode(retry.read('a.txt'), 'invalid_zip'); + await expectAsyncCode(retry.read('a.txt'), 'invalid_zip'); + }); + + it('fails closed when deflate support is unavailable or decompressed byte counts disagree', async () => { + const originalDecompressionStream = globalThis.DecompressionStream; + const originalReadableStream = globalThis.ReadableStream; + const compressed = buildZip({ 'a.txt': 'abc' }, 8); + + try { + vi.resetModules(); + vi.stubGlobal('DecompressionStream', undefined); + vi.stubGlobal('ReadableStream', originalReadableStream); + const { ZipArchive: MissingDecompressionZipArchive } = await import('./zip.js'); + await expectAsyncCode( + MissingDecompressionZipArchive.parse(compressed, limits).read('a.txt'), + 'decompression_unavailable', + ); + + vi.resetModules(); + vi.stubGlobal('DecompressionStream', originalDecompressionStream); + vi.stubGlobal('ReadableStream', undefined); + const { ZipArchive: MissingReadableZipArchive } = await import('./zip.js'); + await expectAsyncCode( + MissingReadableZipArchive.parse(compressed, limits).read('a.txt'), + 'decompression_unavailable', + ); + + vi.resetModules(); + vi.stubGlobal( + 'DecompressionStream', + class { + constructor() { + throw new Error('unsupported'); + } + }, + ); + vi.stubGlobal('ReadableStream', originalReadableStream); + const { ZipArchive: UnsupportedZipArchive } = await import('./zip.js'); + await expectAsyncCode( + UnsupportedZipArchive.parse(compressed, limits).read('a.txt'), + 'decompression_unavailable', + ); + } finally { + vi.unstubAllGlobals(); + vi.resetModules(); + } + + for (const expectedBytes of [1, 5]) { + let mismatched = buildZip({ 'a.txt': { data: 'abc', method: 8, flags: 0x0808 } }, 8); + const { central } = zipOffsets(mismatched); + mismatched = patchUint32(mismatched, central + 24, expectedBytes); + await expectAsyncCode(ZipArchive.parse(mismatched, limits).read('a.txt'), 'invalid_zip'); + } + }); +}); + +describe('DOCX source and editor boundary coverage', () => { + it('rejects invalid, empty, and oversized binary source shapes', async () => { + await expectAsyncCode(importDocx('not-bytes' as never), 'invalid_source'); + await expectAsyncCode(importDocx(new Uint8Array()), 'invalid_source'); + await expectAsyncCode( + importDocx(new Blob([blobPart(createDocx())]), { limits: { maxArchiveBytes: 8 } }), + 'input_too_large', + ); + }); + + it('uses the bounded FileReader fallback without trusting malformed reader results', async () => { + const bytes = createDocx({ method: 0 }); + const originalBlobArrayBuffer = Object.getOwnPropertyDescriptor( + Blob.prototype, + 'arrayBuffer', + ); + + class SuccessfulReader { + result: ArrayBuffer | string | null = null; + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + readAsArrayBuffer(): void { + this.result = blobPart(bytes); + this.onload?.(); + } + } + + class WrongResultReader extends SuccessfulReader { + override readAsArrayBuffer(): void { + this.result = 'not-an-array-buffer'; + this.onload?.(); + } + } + + class ErrorReader extends SuccessfulReader { + override readAsArrayBuffer(): void { + this.onerror?.(); + } + } + + const importWithReader = async ( + reader: typeof SuccessfulReader | typeof WrongResultReader | typeof ErrorReader | undefined, + ) => { + vi.resetModules(); + Object.defineProperty(Blob.prototype, 'arrayBuffer', { + configurable: true, + writable: true, + value: undefined, + }); + vi.stubGlobal('FileReader', reader); + return import('./importDocx.js'); + }; + + try { + const { importDocx: importWithSuccessfulReader } = await importWithReader(SuccessfulReader); + await expect( + importWithSuccessfulReader(new Blob([blobPart(bytes)])), + ).resolves.toMatchObject({ + documentJson: { type: 'doc' }, + }); + + const { importDocx: importWithWrongResultReader } = await importWithReader(WrongResultReader); + await expectAsyncCode( + importWithWrongResultReader(new Blob([blobPart(bytes)])), + 'invalid_source', + ); + + const { importDocx: importWithErrorReader } = await importWithReader(ErrorReader); + await expectAsyncCode( + importWithErrorReader(new Blob([blobPart(bytes)])), + 'invalid_source', + ); + + const { importDocx: importWithoutFileReader } = await importWithReader(undefined); + await expectAsyncCode( + importWithoutFileReader(new Blob([blobPart(bytes)])), + 'invalid_source', + ); + } finally { + if (originalBlobArrayBuffer === undefined) { + Reflect.deleteProperty(Blob.prototype, 'arrayBuffer'); + } else { + Object.defineProperty( + Blob.prototype, + 'arrayBuffer', + originalBlobArrayBuffer, + ); + } + vi.unstubAllGlobals(); + vi.resetModules(); + } + }); + + it('normalizes source reader failures and every invalid editor target shape', async () => { + class ThrowingBlob extends Blob { + override async arrayBuffer(): Promise { + throw new Error('private failure'); + } + } + await expectAsyncCode(importDocx(new ThrowingBlob(['x'])), 'invalid_zip'); + + const source = createDocx({ method: 0 }); + for (const target of [ + null, + {}, + { validateDocumentJson: () => true }, + { setDocumentJson: () => undefined }, + ]) { + await expectAsyncCode(openDocx(target as never, source), 'editor_rejected_document'); + } + await expectAsyncCode( + openDocx( + { + validateDocumentJson: () => { + throw new Error('private validation failure'); + }, + setDocumentJson: () => undefined, + }, + source, + ), + 'editor_rejected_document', + ); + await expectAsyncCode( + openDocx( + { + validateDocumentJson: () => true, + setDocumentJson: () => { + throw new Error('private mutation failure'); + }, + }, + source, + ), + 'editor_rejected_document', + ); + }); +}); + +describe('DOCX OOXML remaining safety and fidelity branches', () => { + it('requires the main document part after a valid content-types manifest', async () => { + const contentTypes = + '' + + '' + + ''; + const parsed = ZipArchive.parse(buildZip({ '[Content_Types].xml': contentTypes }, 0), limits); + await expectAsyncCode(readDocxPackageMetadata(parsed, limits), 'invalid_docx'); + }); + + it('covers missing numbering format/start and a valid bullet descriptor', async () => { + const numbering = + `` + + '' + + '' + + '' + + '' + + ''; + const result = await importDocx( + createDocx({ + method: 0, + numbering, + body: '', + }), + ); + expect(result.documentJson.content?.[0]).toMatchObject({ type: 'bulletList' }); + }); + + it('recognizes JPEG, GIF, and WEBP signatures and omits unsupported images', async () => { + const jpeg = new Uint8Array([0xff, 0xd8, 0xff, 0x00]); + const gif = new TextEncoder().encode('GIF89a'); + const webp = new Uint8Array([ + 0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50, + ]); + const unsupported = new Uint8Array([1, 2, 3, 4, 5, 6]); + const relationships = relationshipXml( + imageRelationship('jpeg', 'media/jpeg.bin') + + imageRelationship('gif', 'media/gif.bin') + + imageRelationship('webp', 'media/webp.bin') + + imageRelationship('bad', 'media/bad.bin'), + ); + const body = + imageParagraph('jpeg') + + imageParagraph('gif') + + imageParagraph('webp') + + imageParagraph('bad'); + const result = await importDocx( + createDocx({ + method: 0, + relationships, + body, + extraEntries: { + 'word/media/jpeg.bin': { data: jpeg }, + 'word/media/gif.bin': { data: gif }, + 'word/media/webp.bin': { data: webp }, + 'word/media/bad.bin': { data: unsupported }, + }, + }), + ); + const sources = result.documentJson.content + ?.filter((node) => node.type === 'image') + .map((node) => String(node.attrs?.src)); + expect(sources).toEqual([ + expect.stringMatching(/^data:image\/jpeg;base64,/u), + expect.stringMatching(/^data:image\/gif;base64,/u), + expect.stringMatching(/^data:image\/webp;base64,/u), + ]); + expect(result.warnings.map((warning) => warning.code)).toEqual( + expect.arrayContaining(['unsupported_image', 'image_omitted']), + ); + }); + + it('bounds image count, declared bytes, total bytes, missing targets, wrong relationship types, and alternative text', async () => { + const relationships = relationshipXml( + imageRelationship('one', 'media/one.png') + + imageRelationship('two', 'media/two.png') + + imageRelationship('missing', 'media/missing.png') + + imageRelationship('wrong', 'media/one.png', 'urn:not-an-image'), + ); + const source = createDocx({ + method: 0, + relationships, + body: + imageParagraph('one', 'x'.repeat(1_001)) + + imageParagraph('two') + + imageParagraph('missing') + + imageParagraph('wrong') + + '', + media: { + 'word/media/one.png': PNG_BYTES, + 'word/media/two.png': PNG_BYTES, + }, + }); + const parsed = ZipArchive.parse(source, limits); + const rich = await parseDocxPackage(parsed, limits); + expect(rich.warnings.map((warning) => warning.code)).toEqual( + expect.arrayContaining(['image_alt_omitted', 'missing_relationship', 'image_omitted']), + ); + + for (const constrained of [ + { ...limits, maxImageBytes: PNG_BYTES.byteLength - 1 }, + { ...limits, maxImages: 1 }, + { ...limits, maxTotalImageBytes: PNG_BYTES.byteLength - 1 }, + ]) { + await expectAsyncCode( + parseDocxPackage(ZipArchive.parse(source, limits), constrained), + 'document_limit_exceeded', + ); + } + + const real = ZipArchive.parse( + createDocx({ + method: 0, + relationships: relationshipXml(imageRelationship('one', 'media/one.png')), + body: imageParagraph('one'), + media: { 'word/media/one.png': PNG_BYTES }, + }), + limits, + ); + const noDeclaredSize = { + has: real.has.bind(real), + read: real.read.bind(real), + size: (name: string) => + name === 'word/media/one.png' ? undefined : real.size(name), + } as unknown as ZipArchive; + await expectAsyncCode( + parseDocxPackage(noDeclaredSize, limits), + 'document_limit_exceeded', + ); + }); + + it('flattens nonzero/list-in-table and list-with-image cases while retaining table merge/header semantics', async () => { + const numbering = + `` + + '' + + '' + + ''; + const relationships = relationshipXml(imageRelationship('image', 'media/image.png')); + const listProperties = ''; + const body = + `${listProperties}` + + '' + + `${listProperties}cell list` + + ''; + const result = await importDocx( + createDocx({ + method: 0, + numbering, + relationships, + body, + media: { 'word/media/image.png': PNG_BYTES }, + }), + ); + expect(result.documentJson.content?.some((node) => node.type === 'image')).toBe(true); + expect(result.documentJson.content?.some((node) => node.type === 'table')).toBe(true); + expect(result.warnings.map((warning) => warning.code)).toEqual( + expect.arrayContaining(['list_flattened', 'table_span_flattened']), + ); + }); + + it('rejects wrong document namespaces, missing bodies, and overly large output trees', async () => { + await expectAsyncCode( + importDocx( + createDocx({ + method: 0, + document: '', + }), + ), + 'invalid_docx', + ); + await expectAsyncCode( + importDocx( + createDocx({ + method: 0, + document: ``, + }), + ), + 'invalid_docx', + ); + await expectAsyncCode( + importDocx(createDocx({ method: 0 }), { limits: { maxDocumentNodes: 1 } }), + 'document_limit_exceeded', + ); + }); + + it('keeps equal-mark merging sensitive to mark attributes', () => { + const parts: InlinePart[] = []; + appendInline(parts, textNode('a', [{ type: 'link', attrs: { href: 'a' } }])); + appendInline(parts, textNode('b', [{ type: 'link', attrs: { href: 'b' } }])); + appendInline(parts, textNode('c', [{ type: 'bold' }, { type: 'italic' }])); + appendInline(parts, textNode('d', [{ type: 'bold' }])); + expect(parts).toHaveLength(4); + }); +}); \ No newline at end of file diff --git a/src/docx/docxManifestAmbiguity.test.ts b/src/docx/docxManifestAmbiguity.test.ts new file mode 100644 index 00000000..f8e81a6e --- /dev/null +++ b/src/docx/docxManifestAmbiguity.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { createDocx } from '../../test/docxFixture.js'; +import { importDocx } from './importDocx.js'; + +const CONTENT_TYPES_NAMESPACE = + 'http://schemas.openxmlformats.org/package/2006/content-types'; +const MAIN_DOCUMENT_CONTENT_TYPE = + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml'; +const WORD_NAMESPACE = + 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'; + +describe('DOCX OPC manifest ambiguity', () => { + it('rejects duplicate content-type overrides for the main document part', async () => { + const contentTypes = + `` + + `` + + '' + + ''; + + await expect(importDocx(createDocx({ contentTypes }))).rejects.toMatchObject({ + name: 'DocxImportError', + code: 'invalid_docx', + }); + }); + + it('rejects duplicate paragraph style identifiers', async () => { + const styles = + `` + + '' + + '' + + ''; + const body = + 'ambiguous'; + + await expect(importDocx(createDocx({ body, styles }))).rejects.toMatchObject({ + name: 'DocxImportError', + code: 'invalid_docx', + }); + }); +}); diff --git a/src/docx/docxPureCoverage.test.ts b/src/docx/docxPureCoverage.test.ts new file mode 100644 index 00000000..3751b4b1 --- /dev/null +++ b/src/docx/docxPureCoverage.test.ts @@ -0,0 +1,460 @@ +import { describe, expect, it } from 'vitest'; +import { + DocxImportError, + normalizeDocxImportError, + type DocxImportErrorCode, +} from './errors.js'; +import { + DEFAULT_DOCX_IMPORT_LIMITS, + resolveDocxImportLimits, +} from './limits.js'; +import { headingLevelFromLabel } from './ooxmlHeading.js'; +import { classifyNumberFormat } from './ooxmlNumberFormats.js'; +import { + appendInline, + descendantsInNamespaces, + DRAWING_NAMESPACES, + firstWordChild, + hasNamespace, + officeRelationshipAttribute, + onOffValue, + packageAttribute, + parseUnsignedInteger, + resolvePackageTarget, + textNode, + WarningCollector, + wordAttribute, + wordChildren, + WORD_NAMESPACES, + type InlinePart, +} from './ooxmlShared.js'; +import type { DocxImportLimits, DocxImportOptions } from './types.js'; +import { + attribute, + childElements, + descendantElements, + directText, + parseXml, +} from './xml.js'; + +const encode = (value: string): Uint8Array => new TextEncoder().encode(value); + +function expectCode(operation: () => unknown, code: DocxImportErrorCode): void { + let thrown: unknown; + try { + operation(); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(DocxImportError); + expect(thrown).toMatchObject({ name: 'DocxImportError', code }); +} + +function xml( + source: string, + limits: Readonly = DEFAULT_DOCX_IMPORT_LIMITS, +) { + return parseXml(encode(source), limits); +} + +describe('DOCX stable errors', () => { + it('constructs every payload-redacted public error and preserves known errors', () => { + const codes: readonly DocxImportErrorCode[] = [ + 'archive_limit_exceeded', + 'decompression_unavailable', + 'document_limit_exceeded', + 'editor_rejected_document', + 'encrypted_archive', + 'incompatible_editor_schema', + 'input_too_large', + 'invalid_configuration', + 'invalid_docx', + 'invalid_source', + 'invalid_xml', + 'invalid_zip', + 'unsupported_archive', + ]; + for (const code of codes) { + const error = new DocxImportError(code); + expect(error).toMatchObject({ name: 'DocxImportError', code }); + expect(error.message).toBeTruthy(); + expect(error.message).not.toContain('caller-secret'); + expect(normalizeDocxImportError(error, 'invalid_docx')).toBe(error); + } + }); + + it('normalizes unknown failures to the requested stable fallback', () => { + expect(normalizeDocxImportError(new Error('caller-secret'), 'invalid_source')).toMatchObject({ + name: 'DocxImportError', + code: 'invalid_source', + }); + }); +}); + +describe('DOCX strict resource configuration', () => { + it('returns the canonical default object when no override is present', () => { + expect(resolveDocxImportLimits()).toBe(DEFAULT_DOCX_IMPORT_LIMITS); + expect(resolveDocxImportLimits({})).toBe(DEFAULT_DOCX_IMPORT_LIMITS); + }); + + it('accepts a null-prototype partial override and freezes a complete result', () => { + const limits = Object.create(null) as Record; + limits.maxEntries = 17; + limits.maxXmlDepth = 9; + const resolved = resolveDocxImportLimits({ limits } as DocxImportOptions); + expect(resolved).toEqual({ + ...DEFAULT_DOCX_IMPORT_LIMITS, + maxEntries: 17, + maxXmlDepth: 9, + }); + expect(Object.isFrozen(resolved)).toBe(true); + }); + + it.each([ + null, + [], + new Date(), + Object.create({ inherited: true }), + ])('rejects a non-plain options record %#', (options) => { + expectCode( + () => resolveDocxImportLimits(options as unknown as DocxImportOptions), + 'invalid_configuration', + ); + }); + + it('rejects symbols, unknown keys, accessors, and non-enumerable fields', () => { + expectCode( + () => + resolveDocxImportLimits({ + [Symbol('secret')]: 1, + } as unknown as DocxImportOptions), + 'invalid_configuration', + ); + expectCode( + () => + resolveDocxImportLimits({ + unexpected: 1, + } as unknown as DocxImportOptions), + 'invalid_configuration', + ); + + const accessor = {} as Record; + Object.defineProperty(accessor, 'limits', { + enumerable: true, + get: () => ({ maxEntries: 1 }), + }); + expectCode( + () => resolveDocxImportLimits(accessor as DocxImportOptions), + 'invalid_configuration', + ); + + const hidden = {} as Record; + Object.defineProperty(hidden, 'limits', { + enumerable: false, + value: { maxEntries: 1 }, + }); + expectCode( + () => resolveDocxImportLimits(hidden as DocxImportOptions), + 'invalid_configuration', + ); + }); + + it('rejects malformed limit records and invalid numeric values', () => { + for (const limits of [null, [], new Date(), { unknown: 1 }]) { + expectCode( + () => resolveDocxImportLimits({ limits } as unknown as DocxImportOptions), + 'invalid_configuration', + ); + } + for (const value of ['1', 1.5, 0, 20_001]) { + expectCode( + () => + resolveDocxImportLimits({ + limits: { maxEntries: value }, + } as unknown as DocxImportOptions), + 'invalid_configuration', + ); + } + }); + + it('fails closed when reflection itself throws', () => { + const options = new Proxy( + {}, + { + getPrototypeOf() { + throw new Error('caller-secret'); + }, + }, + ); + expectCode( + () => resolveDocxImportLimits(options as DocxImportOptions), + 'invalid_configuration', + ); + }); +}); + +describe('DOCX heading and numbering helpers', () => { + it('normalizes all supported heading labels and rejects ordinary labels', () => { + for (let level = 1; level <= 6; level += 1) { + expect(headingLevelFromLabel(` HeAdInG ${level} `)).toBe(level); + } + expect(headingLevelFromLabel('Heading 7')).toBeUndefined(); + expect(headingLevelFromLabel('Body Text')).toBeUndefined(); + }); + + it('classifies bullet and every supported ordered numbering format', () => { + expect(classifyNumberFormat('bullet')).toBe('bulletList'); + for (const format of [ + 'decimal', + 'decimalZero', + 'lowerLetter', + 'lowerRoman', + 'ordinal', + 'upperLetter', + 'upperRoman', + ]) { + expect(classifyNumberFormat(format)).toBe('orderedList'); + } + expect(classifyNumberFormat(undefined)).toBeUndefined(); + expect(classifyNumberFormat('none')).toBeUndefined(); + }); +}); + +describe('DOCX OOXML shared helpers', () => { + const root = xml( + '' + + 'onetwo' + + '' + + '', + ); + + it('selects Word children, descendants, namespaces, and attributes', () => { + expect(hasNamespace(root, WORD_NAMESPACES)).toBe(true); + expect(hasNamespace(root, DRAWING_NAMESPACES)).toBe(false); + expect(wordChildren(root).map((node) => node.localName)).toEqual(['p']); + expect(wordChildren(root, 'missing')).toEqual([]); + const paragraph = firstWordChild(root, 'p')!; + expect(firstWordChild(root, 'missing')).toBeUndefined(); + expect(descendantsInNamespaces(root, 'blip', DRAWING_NAMESPACES)).toHaveLength(1); + expect(wordAttribute(paragraph, 'val')).toBe('3'); + expect(officeRelationshipAttribute(paragraph, 'id')).toBe('rel'); + expect(packageAttribute(root, 'plain')).toBe('yes'); + expect(packageAttribute(root, 'missing')).toBeUndefined(); + }); + + it('rejects ambiguous namespaced attributes', () => { + const ambiguous = xml( + '', + ); + expectCode(() => wordAttribute(ambiguous, 'val'), 'invalid_docx'); + }); + + it('parses unsigned integers and Word on/off values', () => { + expect(parseUnsignedInteger(undefined)).toBeUndefined(); + expect(parseUnsignedInteger('-1')).toBeUndefined(); + expect(parseUnsignedInteger('12')).toBe(12); + expect(parseUnsignedInteger('999999999999999999999')).toBeUndefined(); + + expect(onOffValue(undefined)).toBe(false); + const values = xml( + '' + + '' + + '', + ); + expect(onOffValue(firstWordChild(values, 'on'))).toBe(true); + expect(onOffValue(firstWordChild(values, 'false'))).toBe(false); + expect(onOffValue(firstWordChild(values, 'true'))).toBe(true); + }); + + it('creates text nodes and merges only adjacent text with equal marks', () => { + expect(textNode('plain', [])).toEqual({ type: 'text', text: 'plain' }); + expect(textNode('bold', [{ type: 'bold' }])).toEqual({ + type: 'text', + text: 'bold', + marks: [{ type: 'bold' }], + }); + + const parts: InlinePart[] = []; + appendInline(parts, textNode('a', [{ type: 'bold' }])); + appendInline(parts, textNode('b', [{ type: 'bold' }])); + appendInline(parts, textNode('c', [{ type: 'italic' }])); + appendInline(parts, { type: 'hardBreak' }); + appendInline(parts, textNode('d', [])); + expect(parts).toEqual([ + { + kind: 'inline', + node: { type: 'text', text: 'ab', marks: [{ type: 'bold' }] }, + }, + { + kind: 'inline', + node: { type: 'text', text: 'c', marks: [{ type: 'italic' }] }, + }, + { kind: 'inline', node: { type: 'hardBreak' } }, + { kind: 'inline', node: { type: 'text', text: 'd' } }, + ]); + }); + + it('resolves safe package targets and rejects every unsafe target shape', () => { + expect(resolvePackageTarget('word/document.xml', 'media/image.png')).toBe( + 'word/media/image.png', + ); + expect(resolvePackageTarget('word/document.xml', '../docProps/core.xml')).toBe( + 'docProps/core.xml', + ); + expect(resolvePackageTarget('word/document.xml', '/word/styles.xml')).toBe( + 'word/styles.xml', + ); + for (const target of [ + '', + 'media\\image.png', + 'media\0image.png', + 'media/image.png?x', + 'media/image.png#x', + 'https://example.test/x', + '//server/share', + 'media//image.png', + './image.png', + '../../escape.xml', + '/', + ]) { + expectCode( + () => resolvePackageTarget('word/document.xml', target), + 'invalid_docx', + ); + } + }); + + it('deduplicates warning categories in first-occurrence order', () => { + const warnings = new WarningCollector(); + warnings.add('unsupported_content'); + warnings.add('image_omitted'); + warnings.add('unsupported_content'); + const snapshot = warnings.snapshot(); + expect(snapshot).toEqual([ + { code: 'unsupported_content', count: 2 }, + { code: 'image_omitted', count: 1 }, + ]); + expect(Object.isFrozen(snapshot)).toBe(true); + expect(snapshot.every(Object.isFrozen)).toBe(true); + }); +}); + +describe('DOCX inert XML parser', () => { + it('parses declarations, comments, namespaces, entities, and traversal helpers', () => { + const root = xml( + '\n' + + '' + + 'A&'><"AB' + + 'tail' + + '' + + '', + ); + expect(root.localName).toBe('r'); + expect(root.namespaceUri).toBe('urn:root'); + expect(attribute(root, 'plain', null)).toBe('x'); + expect(attribute(root, 'lang', 'http://www.w3.org/XML/1998/namespace')).toBe('ko'); + expect(directText(root)).toBe(`A&'><"AB`); + expect(childElements(root, 'item', 'urn:parts')).toHaveLength(2); + expect(childElements(root, 'item', 'urn:missing')).toEqual([]); + expect(descendantElements(root, 'child', 'urn:parts')).toHaveLength(1); + expect(descendantElements(root, 'missing')).toEqual([]); + }); + + it('supports inherited prefixes and an explicitly empty default namespace', () => { + const root = xml( + '', + ); + const child = childElements(root)[0]!; + const leaf = childElements(child)[0]!; + expect(child.namespaceUri).toBeUndefined(); + expect(leaf.namespaceUri).toBe('urn:p'); + expect(attribute(leaf, 'plain')).toBe('v'); + }); + + it('enforces XML byte, node, and depth ceilings', () => { + expectCode( + () => parseXml(new Uint8Array(), DEFAULT_DOCX_IMPORT_LIMITS), + 'archive_limit_exceeded', + ); + expectCode( + () => + xml('', { + ...DEFAULT_DOCX_IMPORT_LIMITS, + maxXmlBytes: 1, + }), + 'archive_limit_exceeded', + ); + expectCode( + () => + xml('', { + ...DEFAULT_DOCX_IMPORT_LIMITS, + maxXmlNodes: 1, + }), + 'archive_limit_exceeded', + ); + expectCode( + () => + xml('', { + ...DEFAULT_DOCX_IMPORT_LIMITS, + maxXmlDepth: 1, + }), + 'archive_limit_exceeded', + ); + }); + + it('rejects malformed UTF-8 and invalid XML scalar values', () => { + expectCode( + () => parseXml(new Uint8Array([0xc3, 0x28]), DEFAULT_DOCX_IMPORT_LIMITS), + 'invalid_xml', + ); + expectCode(() => xml('\0'), 'invalid_xml'); + }); + + it.each([ + '<1r/>', + '<:r/>', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '&unterminated', + '&thisentitynameistoolong;', + '&unknown;', + '&#;', + '&#xZZ;', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + 'outside', + '', + '', + ])('rejects malformed or namespace-unsafe XML %#', (source) => { + expectCode(() => xml(source), 'invalid_xml'); + }); + + it('rejects duplicate local-name attributes when the caller does not disambiguate namespace', () => { + const root = xml(''); + expectCode(() => attribute(root, 'id'), 'invalid_docx'); + expect(attribute(root, 'id', 'urn:a')).toBe('1'); + }); +}); diff --git a/src/docx/docxRemainingCoverage.test.ts b/src/docx/docxRemainingCoverage.test.ts new file mode 100644 index 00000000..efd42c16 --- /dev/null +++ b/src/docx/docxRemainingCoverage.test.ts @@ -0,0 +1,230 @@ +import { describe, expect, it } from 'vitest'; +import { buildZip, createDocx } from '../../test/docxFixture.js'; +import { + DocxImportError, + type DocxImportErrorCode, +} from './errors.js'; +import { importDocx } from './importDocx.js'; +import { DEFAULT_DOCX_IMPORT_LIMITS } from './limits.js'; +import { + appendInline, + resolvePackageTarget, + type InlinePart, +} from './ooxmlShared.js'; +import { parseXml } from './xml.js'; +import { readUint16, readUint32, ZipArchive } from './zip.js'; + +const limits = DEFAULT_DOCX_IMPORT_LIMITS; + +function expectCode(operation: () => unknown, code: DocxImportErrorCode): void { + let thrown: unknown; + try { + operation(); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(DocxImportError); + expect(thrown).toMatchObject({ code }); +} + +function blobPart(bytes: Uint8Array): ArrayBuffer { + const copy = new ArrayBuffer(bytes.byteLength); + new Uint8Array(copy).set(bytes); + return copy; +} + +function relationshipXml(entries: string): string { + return ( + '' + + entries + + '' + ); +} + +function imageRelationship(id: string, target: string): string { + return ``; +} + +describe('DOCX remaining exact coverage boundaries', () => { + it('bounds primitive ZIP integer reads and exercises both legacy-name outcomes', () => { + expectCode(() => readUint16(new Uint8Array(2), -1), 'invalid_zip'); + expectCode(() => readUint16(new Uint8Array(1), 0), 'invalid_zip'); + expectCode(() => readUint32(new Uint8Array(4), -1), 'invalid_zip'); + expectCode(() => readUint32(new Uint8Array(3), 0), 'invalid_zip'); + + expectCode( + () => + ZipArchive.parse( + buildZip( + { + logical: { + data: 'x', + flags: 0, + localName: 'logical', + centralName: '\u001f', + }, + }, + 0, + ), + limits, + ), + 'unsupported_archive', + ); + + const legacyAscii = ZipArchive.parse( + buildZip( + { + 'a.txt': { + data: 'x', + flags: 0, + }, + }, + 0, + ), + limits, + ); + expect(legacyAscii.has('a.txt')).toBe(true); + }); + + it('reads the native Blob arrayBuffer path before parsing a valid package', async () => { + const bytes = createDocx({ method: 0 }); + const source = new Blob([blobPart(bytes)]); + const result = await importDocx(source); + expect(result.documentJson).toMatchObject({ type: 'doc' }); + }); + + it('merges adjacent unmarked text and rejects a target that resolves to package root', () => { + const parts: InlinePart[] = []; + appendInline(parts, { type: 'text', text: 'left' }); + appendInline(parts, { type: 'text', text: 'right' }); + expect(parts).toEqual([ + { + kind: 'inline', + node: { type: 'text', text: 'leftright' }, + }, + ]); + expectCode( + () => resolvePackageTarget('word/document.xml', '..'), + 'invalid_docx', + ); + }); + + it('accepts the remaining legal XML scalar ranges and trailing whitespace', () => { + const source = '\uE000\u{10000} '; + const root = parseXml(new TextEncoder().encode(source), limits); + expect(root.localName).toBe('r'); + expect(root.children).toEqual(['\uE000\u{10000}']); + }); + + it('encodes a two-byte base64 remainder and imports an image without document properties', async () => { + const jpeg = new Uint8Array([0xff, 0xd8, 0xff, 0x00, 0x00]); + const result = await importDocx( + createDocx({ + method: 0, + relationships: relationshipXml( + imageRelationship('jpeg', 'media/jpeg.bin'), + ), + body: + '', + media: { + 'word/media/jpeg.bin': jpeg, + }, + }), + ); + expect(result.documentJson.content?.[0]).toMatchObject({ + type: 'image', + attrs: { + alt: '', + src: expect.stringMatching(/^data:image\/jpeg;base64,/u), + }, + }); + }); + + it('keeps an image returned from a hyperlink run while warning that hyperlink authority is inert', async () => { + const jpeg = new Uint8Array([0xff, 0xd8, 0xff, 0x00, 0x00]); + const result = await importDocx( + createDocx({ + method: 0, + relationships: relationshipXml( + imageRelationship('image', 'media/image.bin'), + ), + body: + '', + media: { + 'word/media/image.bin': jpeg, + }, + }), + ); + expect(result.documentJson.content?.[0]).toMatchObject({ type: 'image' }); + expect(result.warnings.map((warning) => warning.code)).toContain( + 'unsafe_hyperlink', + ); + }); + + it('reports unsupported paragraph children and foreign body namespaces without executing them', async () => { + const result = await importDocx( + createDocx({ + method: 0, + body: + '' + + '', + }), + ); + expect(result.warnings).toContainEqual({ + code: 'unsupported_content', + count: 2, + }); + }); + + it('covers marked text and structural fallbacks without inventing authority', async () => { + const result = await importDocx( + createDocx({ + method: 0, + body: + 'Plain' + + '' + + '' + + 'Cell', + }), + ); + + expect(result.documentJson.content).toEqual([ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'Plain', + marks: [{ type: 'bold' }], + }, + ], + }, + { type: 'heading', attrs: { level: 2 } }, + { type: 'paragraph' }, + { + type: 'table', + content: [ + { + type: 'tableRow', + content: [ + { + type: 'tableCell', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'Cell' }], + }, + ], + }, + { + type: 'tableCell', + content: [{ type: 'paragraph' }], + }, + ], + }, + ], + }, + ]); + expect(result.warnings).toEqual([]); + }); +}); \ No newline at end of file diff --git a/src/docx/errorNormalizationProxy.test.ts b/src/docx/errorNormalizationProxy.test.ts new file mode 100644 index 00000000..51b941ba --- /dev/null +++ b/src/docx/errorNormalizationProxy.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createDocx } from '../../test/docxFixture.js'; +import { openDocx } from './index.js'; + +describe('DOCX public error normalization', () => { + it('redacts hostile callback failures without inspecting the thrown prototype', async () => { + const privateSentinel = new Error('private prototype sentinel'); + const getPrototypeOf = vi.fn(() => { + throw privateSentinel; + }); + const hostileThrownValue = new Proxy(Object.create(null) as object, { + getPrototypeOf, + }); + const setDocumentJson = vi.fn(() => undefined); + + const operation = openDocx( + { + validateDocumentJson() { + throw hostileThrownValue; + }, + setDocumentJson, + }, + createDocx(), + ); + + await expect(operation).rejects.toMatchObject({ + name: 'DocxImportError', + code: 'editor_rejected_document', + message: 'The editor rejected the imported DOCX document.', + }); + expect(getPrototypeOf).not.toHaveBeenCalled(); + expect(setDocumentJson).not.toHaveBeenCalled(); + }); +}); diff --git a/src/docx/errors.ts b/src/docx/errors.ts new file mode 100644 index 00000000..fae11b0c --- /dev/null +++ b/src/docx/errors.ts @@ -0,0 +1,64 @@ +/** Stable machine-readable DOCX import failure categories. */ +export type DocxImportErrorCode = + | 'archive_limit_exceeded' + | 'decompression_unavailable' + | 'document_limit_exceeded' + | 'editor_rejected_document' + | 'encrypted_archive' + | 'incompatible_editor_schema' + | 'input_too_large' + | 'invalid_configuration' + | 'invalid_docx' + | 'invalid_source' + | 'invalid_xml' + | 'invalid_zip' + | 'unsupported_archive'; + +const ERROR_MESSAGES: Readonly> = + Object.freeze({ + archive_limit_exceeded: + 'The DOCX package exceeds the supported archive resource limits.', + decompression_unavailable: + 'The current runtime cannot decompress this DOCX package.', + document_limit_exceeded: + 'The imported DOCX exceeds the supported document resource limits.', + editor_rejected_document: + 'The editor rejected the imported DOCX document.', + encrypted_archive: 'Encrypted DOCX packages are not supported.', + incompatible_editor_schema: + 'The imported DOCX is incompatible with the active editor schema.', + input_too_large: 'The DOCX source exceeds the supported byte limit.', + invalid_configuration: 'DOCX import configuration is invalid.', + invalid_docx: 'The source is not a supported DOCX document.', + invalid_source: 'DOCX input must be a supported binary source.', + invalid_xml: 'The DOCX package contains invalid XML.', + invalid_zip: 'The DOCX package contains an invalid ZIP archive.', + unsupported_archive: + 'The DOCX package uses an unsupported ZIP archive feature.', + }); + +const DOCX_IMPORT_ERROR_BRAND = new WeakSet(); + +/** Payload-redacted error thrown by every public DOCX import failure. */ +export class DocxImportError extends Error { + /** Stable failure category safe for host telemetry. */ + readonly code: DocxImportErrorCode; + + /** Create one stable DOCX import error. */ + constructor(code: DocxImportErrorCode) { + super(ERROR_MESSAGES[code]); + this.name = 'DocxImportError'; + this.code = code; + DOCX_IMPORT_ERROR_BRAND.add(this); + } +} + +/** Preserve one already-redacted error or replace an unknown failure. */ +export function normalizeDocxImportError( + error: unknown, + fallback: DocxImportErrorCode, +): DocxImportError { + return DOCX_IMPORT_ERROR_BRAND.has(error as object) + ? (error as DocxImportError) + : new DocxImportError(fallback); +} diff --git a/src/docx/importDocx.contract.test.ts b/src/docx/importDocx.contract.test.ts new file mode 100644 index 00000000..e9daebba --- /dev/null +++ b/src/docx/importDocx.contract.test.ts @@ -0,0 +1,282 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + createDocx, + PNG_BYTES, + WORD_NAMESPACES, +} from '../../test/docxFixture.js'; +import { + DocxImportError, + importDocx, + openDocx, + type DocxJsonContent, + type DocxSource, +} from './index.js'; + +const SOURCE_CASES: readonly [ + string, + (bytes: Uint8Array) => DocxSource, +][] = [ + ['ArrayBuffer', (bytes) => Uint8Array.from(bytes).buffer], + ['Uint8Array', (bytes) => bytes], + [ + 'DataView', + (bytes) => { + const copied = Uint8Array.from(bytes); + return new DataView(copied.buffer); + }, + ], + ['Blob', (bytes) => new Blob([Uint8Array.from(bytes)])], +]; + +describe('DOCX open/import contract', () => { + it.each(SOURCE_CASES)( + 'imports bounded document content from %s', + async (_label, source) => { + const relationships = + '' + + '' + + ''; + const body = + 'Hello & 안녕' + + '' + + 'Cell'; + const bytes = createDocx({ + body, + relationships, + media: { 'word/media/image.png': PNG_BYTES }, + }); + + const result = await importDocx(source(bytes)); + + expect(result.documentJson).toEqual({ + type: 'doc', + content: [ + { + type: 'heading', + attrs: { level: 2 }, + content: [ + { + type: 'text', + text: 'Hello & 안녕', + marks: [{ type: 'bold' }, { type: 'italic' }], + }, + ], + }, + { + type: 'image', + attrs: { + src: expect.stringMatching(/^data:image\/png;base64,/u), + alt: 'Chart', + }, + }, + { + type: 'table', + content: [ + { + type: 'tableRow', + content: [ + { + type: 'tableHeader', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'Cell' }], + }, + ], + }, + ], + }, + ], + }, + ], + }); + expect(result.warnings).toEqual([]); + expect(Object.isFrozen(result)).toBe(true); + expect(Object.isFrozen(result.documentJson)).toBe(true); + expect(Object.isFrozen(result.documentJson.content)).toBe(true); + }, + ); + + it('uses intrinsic Blob metadata and bytes instead of caller overrides', async () => { + const source = new Blob([Uint8Array.from(createDocx())]); + const sizeGetter = vi.fn(() => { + throw new Error('private size getter'); + }); + const arrayBufferGetter = vi.fn(() => { + throw new Error('private arrayBuffer getter'); + }); + Object.defineProperty(source, 'size', { + configurable: true, + get: sizeGetter, + }); + Object.defineProperty(source, 'arrayBuffer', { + configurable: true, + get: arrayBufferGetter, + }); + + const result = await importDocx(source); + + expect(result.documentJson.type).toBe('doc'); + expect(sizeGetter).not.toHaveBeenCalled(); + expect(arrayBufferGetter).not.toHaveBeenCalled(); + }); + + it('uses a callable platform Blob byte reader captured at module initialization', async () => { + const bytes = createDocx(); + const source = new Blob([Uint8Array.from(bytes)]); + const originalDescriptor = Object.getOwnPropertyDescriptor( + Blob.prototype, + 'arrayBuffer', + ); + const platformArrayBuffer = vi.fn(async function (this: Blob) { + expect(this).toBe(source); + return Uint8Array.from(bytes).buffer; + }); + const callerOverride = vi.fn(async () => new ArrayBuffer(0)); + + Object.defineProperty(Blob.prototype, 'arrayBuffer', { + configurable: true, + value: platformArrayBuffer, + writable: true, + }); + vi.resetModules(); + const { importDocx: importWithCapturedPlatformReader } = await import( + './importDocx.js' + ); + Object.defineProperty(source, 'arrayBuffer', { + configurable: true, + value: callerOverride, + }); + + try { + const result = await importWithCapturedPlatformReader(source); + + expect(result.documentJson.type).toBe('doc'); + expect(platformArrayBuffer).toHaveBeenCalledTimes(1); + expect(callerOverride).not.toHaveBeenCalled(); + } finally { + if (originalDescriptor === undefined) { + Reflect.deleteProperty(Blob.prototype, 'arrayBuffer'); + } else { + Object.defineProperty( + Blob.prototype, + 'arrayBuffer', + originalDescriptor, + ); + } + vi.resetModules(); + } + }); + + it('ignores caller Blob data-function overrides while reading intrinsic bytes', async () => { + const source = new Blob([Uint8Array.from(createDocx())]); + const arrayBufferOverride = vi.fn(async () => new ArrayBuffer(0)); + Object.defineProperty(source, 'arrayBuffer', { + configurable: true, + value: arrayBufferOverride, + }); + + const result = await importDocx(source); + + expect(result.documentJson.type).toBe('doc'); + expect(arrayBufferOverride).not.toHaveBeenCalled(); + }); + + it('uses intrinsic view byte-range metadata instead of caller overrides', async () => { + const source = Uint8Array.from(createDocx()); + const bufferGetter = vi.fn(() => { + throw new Error('private buffer getter'); + }); + const byteOffsetGetter = vi.fn(() => { + throw new Error('private byteOffset getter'); + }); + const byteLengthGetter = vi.fn(() => { + throw new Error('private byteLength getter'); + }); + Object.defineProperty(source, 'buffer', { + configurable: true, + get: bufferGetter, + }); + Object.defineProperty(source, 'byteOffset', { + configurable: true, + get: byteOffsetGetter, + }); + Object.defineProperty(source, 'byteLength', { + configurable: true, + get: byteLengthGetter, + }); + + const result = await importDocx(source); + + expect(result.documentJson.type).toBe('doc'); + expect(bufferGetter).not.toHaveBeenCalled(); + expect(byteOffsetGetter).not.toHaveBeenCalled(); + expect(byteLengthGetter).not.toHaveBeenCalled(); + }); + + it('fails closed for SharedArrayBuffer-backed views', async () => { + const bytes = createDocx(); + const source = new Uint8Array(new SharedArrayBuffer(bytes.byteLength)); + source.set(bytes); + + await expect(importDocx(source)).rejects.toMatchObject({ + name: 'DocxImportError', + code: 'invalid_source', + }); + }); + + it('rejects ambiguous documents with multiple Word bodies', async () => { + const document = + `` + + 'first' + + 'second' + + ''; + + await expect(importDocx(createDocx({ document }))).rejects.toMatchObject({ + name: 'DocxImportError', + code: 'invalid_docx', + }); + }); + + it('validates the complete imported document before one atomic editor mutation', async () => { + const validateDocumentJson = vi.fn( + (documentJson: DocxJsonContent) => documentJson.type === 'doc', + ); + const setDocumentJson = vi.fn((_documentJson: DocxJsonContent) => undefined); + const target = { validateDocumentJson, setDocumentJson }; + + const result = await openDocx(target, createDocx()); + + expect(validateDocumentJson).toHaveBeenCalledTimes(1); + expect(setDocumentJson).toHaveBeenCalledTimes(1); + const imported = validateDocumentJson.mock.calls[0]![0]; + expect(setDocumentJson).toHaveBeenCalledWith(imported); + expect(result.documentJson).toBe(imported); + }); + + it('does not mutate the editor when schema validation rejects the import', async () => { + const setDocumentJson = vi.fn((_documentJson: DocxJsonContent) => undefined); + const operation = openDocx( + { + validateDocumentJson: () => false, + setDocumentJson, + }, + createDocx(), + ); + + await expect(operation).rejects.toMatchObject({ + name: 'DocxImportError', + code: 'incompatible_editor_schema', + }); + expect(setDocumentJson).not.toHaveBeenCalled(); + }); + + it('fails closed for invalid archives and bounded input', async () => { + await expect(importDocx(new Uint8Array([1, 2, 3]))).rejects.toBeInstanceOf( + DocxImportError, + ); + await expect( + importDocx(createDocx(), { limits: { maxArchiveBytes: 8 } }), + ).rejects.toMatchObject({ code: 'input_too_large' }); + }); +}); diff --git a/src/docx/importDocx.ts b/src/docx/importDocx.ts new file mode 100644 index 00000000..d141c98d --- /dev/null +++ b/src/docx/importDocx.ts @@ -0,0 +1,219 @@ +import { DocxImportError, normalizeDocxImportError } from './errors.js'; +import { resolveDocxImportLimits } from './limits.js'; +import { parseDocxPackage } from './ooxml.js'; +import type { + DocxDocumentTarget, + DocxImportOptions, + DocxImportResult, + DocxSource, +} from './types.js'; +import { ZipArchive } from './zip.js'; + +const ARRAY_BUFFER_BYTE_LENGTH_GETTER = Object.getOwnPropertyDescriptor( + ArrayBuffer.prototype, + 'byteLength', +)!.get!; +const ARRAY_BUFFER_IS_VIEW = ArrayBuffer.isView; +const TYPED_ARRAY_PROTOTYPE = Object.getPrototypeOf( + Uint8Array.prototype, +) as object; +const TYPED_ARRAY_BUFFER_GETTER = Object.getOwnPropertyDescriptor( + TYPED_ARRAY_PROTOTYPE, + 'buffer', +)!.get!; +const TYPED_ARRAY_BYTE_OFFSET_GETTER = Object.getOwnPropertyDescriptor( + TYPED_ARRAY_PROTOTYPE, + 'byteOffset', +)!.get!; +const TYPED_ARRAY_BYTE_LENGTH_GETTER = Object.getOwnPropertyDescriptor( + TYPED_ARRAY_PROTOTYPE, + 'byteLength', +)!.get!; +const DATA_VIEW_BUFFER_GETTER = Object.getOwnPropertyDescriptor( + DataView.prototype, + 'buffer', +)!.get!; +const DATA_VIEW_BYTE_OFFSET_GETTER = Object.getOwnPropertyDescriptor( + DataView.prototype, + 'byteOffset', +)!.get!; +const DATA_VIEW_BYTE_LENGTH_GETTER = Object.getOwnPropertyDescriptor( + DataView.prototype, + 'byteLength', +)!.get!; +const BLOB_PROTOTYPE = + typeof Blob === 'undefined' ? undefined : Blob.prototype; +const BLOB_SIZE_GETTER = + BLOB_PROTOTYPE === undefined + ? undefined + : Object.getOwnPropertyDescriptor(BLOB_PROTOTYPE, 'size')?.get; +const BLOB_ARRAY_BUFFER_DESCRIPTOR = + BLOB_PROTOTYPE === undefined + ? undefined + : Object.getOwnPropertyDescriptor(BLOB_PROTOTYPE, 'arrayBuffer'); +const BLOB_ARRAY_BUFFER = + BLOB_ARRAY_BUFFER_DESCRIPTOR !== undefined && + 'value' in BLOB_ARRAY_BUFFER_DESCRIPTOR && + typeof BLOB_ARRAY_BUFFER_DESCRIPTOR.value === 'function' + ? (BLOB_ARRAY_BUFFER_DESCRIPTOR.value as (this: Blob) => Promise) + : undefined; +const FILE_READER_CONSTRUCTOR = + typeof FileReader === 'undefined' ? undefined : FileReader; +const FILE_READER_READ_AS_ARRAY_BUFFER = + FILE_READER_CONSTRUCTOR === undefined + ? undefined + : FILE_READER_CONSTRUCTOR.prototype.readAsArrayBuffer; + +interface ArrayBufferViewRange { + readonly buffer: ArrayBufferLike; + readonly byteOffset: number; + readonly byteLength: number; +} + +/** Identify a genuine ArrayBuffer through its internal slot without prototype traversal. */ +function isIntrinsicArrayBuffer(value: unknown): value is ArrayBuffer { + try { + ARRAY_BUFFER_BYTE_LENGTH_GETTER.call(value); + return true; + } catch { + return false; + } +} + +/** Read Blob size through the captured platform intrinsic without invoking caller overrides. */ +function readBlobSize(blob: Blob): number { + if (BLOB_SIZE_GETTER === undefined) { + throw new DocxImportError('invalid_source'); + } + return BLOB_SIZE_GETTER.call(blob) as number; +} + +/** Identify a genuine Blob and read its size without prototype traversal. */ +function tryReadBlobSize(value: unknown): number | undefined { + try { + return readBlobSize(value as Blob); + } catch { + return undefined; + } +} + +/** Read one proven Blob without requiring Blob.arrayBuffer() in older DOMs. */ +async function readBlobBytes(blob: Blob): Promise { + if (BLOB_ARRAY_BUFFER !== undefined) { + return new Uint8Array(await BLOB_ARRAY_BUFFER.call(blob)); + } + if ( + FILE_READER_CONSTRUCTOR === undefined || + FILE_READER_READ_AS_ARRAY_BUFFER === undefined + ) { + throw new DocxImportError('invalid_source'); + } + return new Promise((resolve, reject) => { + const reader = new FILE_READER_CONSTRUCTOR(); + reader.onload = () => { + const result = reader.result; + if (!isIntrinsicArrayBuffer(result)) { + reject(new DocxImportError('invalid_source')); + return; + } + resolve(new Uint8Array(result)); + }; + reader.onerror = () => reject(new DocxImportError('invalid_source')); + FILE_READER_READ_AS_ARRAY_BUFFER.call(reader, blob); + }); +} + +/** Read one genuine ArrayBuffer view range without invoking caller overrides. */ +function readArrayBufferViewRange(source: ArrayBufferView): ArrayBufferViewRange { + try { + return { + buffer: TYPED_ARRAY_BUFFER_GETTER.call(source) as ArrayBufferLike, + byteOffset: TYPED_ARRAY_BYTE_OFFSET_GETTER.call(source) as number, + byteLength: TYPED_ARRAY_BYTE_LENGTH_GETTER.call(source) as number, + }; + } catch { + return { + buffer: DATA_VIEW_BUFFER_GETTER.call(source) as ArrayBufferLike, + byteOffset: DATA_VIEW_BYTE_OFFSET_GETTER.call(source) as number, + byteLength: DATA_VIEW_BYTE_LENGTH_GETTER.call(source) as number, + }; + } +} + +/** Copy one accepted binary source into an immutable import snapshot. */ +async function snapshotSource( + source: DocxSource, + maxArchiveBytes: number, +): Promise { + try { + let view: Uint8Array; + if (isIntrinsicArrayBuffer(source)) { + view = new Uint8Array(source); + } else if (ARRAY_BUFFER_IS_VIEW(source)) { + const { buffer, byteOffset, byteLength } = readArrayBufferViewRange(source); + if (!isIntrinsicArrayBuffer(buffer)) { + throw new DocxImportError('invalid_source'); + } + view = new Uint8Array(buffer, byteOffset, byteLength); + } else { + const blobSize = tryReadBlobSize(source); + if (blobSize === undefined) { + throw new DocxImportError('invalid_source'); + } + if (blobSize > maxArchiveBytes) { + throw new DocxImportError('input_too_large'); + } + view = await readBlobBytes(source as Blob); + } + if (view.byteLength === 0) throw new DocxImportError('invalid_source'); + if (view.byteLength > maxArchiveBytes) { + throw new DocxImportError('input_too_large'); + } + return view.slice(); + } catch (error) { + throw normalizeDocxImportError(error, 'invalid_source'); + } +} + +/** Import one untrusted DOCX package without mutating an editor. */ +export async function importDocx( + source: DocxSource, + options?: DocxImportOptions, +): Promise { + const limits = resolveDocxImportLimits(options); + const bytes = await snapshotSource(source, limits.maxArchiveBytes); + try { + return await parseDocxPackage(ZipArchive.parse(bytes, limits), limits); + } catch (error) { + throw normalizeDocxImportError(error, 'invalid_docx'); + } +} + +/** Import, schema-check, and atomically replace one compatible editor document. */ +export async function openDocx( + target: DocxDocumentTarget, + source: DocxSource, + options?: DocxImportOptions, +): Promise { + const result = await importDocx(source, options); + try { + if (typeof target !== 'object' || target === null) { + throw new DocxImportError('editor_rejected_document'); + } + const validateDocumentJson = target.validateDocumentJson; + const setDocumentJson = target.setDocumentJson; + if ( + typeof validateDocumentJson !== 'function' || + typeof setDocumentJson !== 'function' + ) { + throw new DocxImportError('editor_rejected_document'); + } + if (validateDocumentJson.call(target, result.documentJson) !== true) { + throw new DocxImportError('incompatible_editor_schema'); + } + setDocumentJson.call(target, result.documentJson); + return result; + } catch (error) { + throw normalizeDocxImportError(error, 'editor_rejected_document'); + } +} diff --git a/src/docx/importDocxBlobCapability.test.ts b/src/docx/importDocxBlobCapability.test.ts new file mode 100644 index 00000000..bb2b5048 --- /dev/null +++ b/src/docx/importDocxBlobCapability.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; + +import { importDocx } from './importDocx.js'; + +function restoreGlobalProperty( + key: 'FileReader', + descriptor: PropertyDescriptor | undefined, +): void { + if (descriptor === undefined) { + Reflect.deleteProperty(globalThis, key); + return; + } + Object.defineProperty(globalThis, key, descriptor); +} + +describe('DOCX Blob capability isolation', () => { + it('does not let an own Blob override route through a replaced global FileReader', async () => { + const originalFileReader = Object.getOwnPropertyDescriptor( + globalThis, + 'FileReader', + ); + let hostileConstructorCalls = 0; + + class HostileFileReader { + constructor() { + hostileConstructorCalls += 1; + throw new Error('private FileReader sentinel'); + } + } + + Object.defineProperty(globalThis, 'FileReader', { + configurable: true, + writable: true, + value: HostileFileReader, + }); + + try { + const source = new Blob([new Uint8Array([0x50, 0x4b])]); + Object.defineProperty(source, 'arrayBuffer', { + configurable: true, + writable: true, + value: undefined, + }); + + await expect(importDocx(source)).rejects.toMatchObject({ + code: 'invalid_zip', + }); + expect(hostileConstructorCalls).toBe(0); + } finally { + restoreGlobalProperty('FileReader', originalFileReader); + } + }); +}); diff --git a/src/docx/importDocxFileReaderCapability.test.ts b/src/docx/importDocxFileReaderCapability.test.ts new file mode 100644 index 00000000..38fae48b --- /dev/null +++ b/src/docx/importDocxFileReaderCapability.test.ts @@ -0,0 +1,65 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const ORIGINAL_BLOB_ARRAY_BUFFER = Object.getOwnPropertyDescriptor( + Blob.prototype, + 'arrayBuffer', +); + +function restoreBlobArrayBuffer(): void { + if (ORIGINAL_BLOB_ARRAY_BUFFER === undefined) { + Reflect.deleteProperty(Blob.prototype, 'arrayBuffer'); + return; + } + Object.defineProperty( + Blob.prototype, + 'arrayBuffer', + ORIGINAL_BLOB_ARRAY_BUFFER, + ); +} + +afterEach(() => { + restoreBlobArrayBuffer(); + vi.unstubAllGlobals(); + vi.resetModules(); +}); + +describe('DOCX FileReader fallback capability isolation', () => { + it('uses the FileReader capability captured when the fallback module initializes', async () => { + const capturedBytes = new Uint8Array([0x50, 0x4b]).buffer; + + class TrustedFileReader { + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + result: ArrayBuffer | null = null; + + readAsArrayBuffer(): void { + this.result = capturedBytes.slice(0); + queueMicrotask(() => this.onload?.()); + } + } + + Object.defineProperty(Blob.prototype, 'arrayBuffer', { + configurable: true, + writable: true, + value: undefined, + }); + vi.stubGlobal('FileReader', TrustedFileReader); + vi.resetModules(); + const { importDocx } = await import('./importDocx.js'); + + let hostileConstructorCalls = 0; + class HostileFileReader { + constructor() { + hostileConstructorCalls += 1; + throw new Error('private fallback FileReader sentinel'); + } + } + vi.stubGlobal('FileReader', HostileFileReader); + + const source = new Blob([new Uint8Array([0x50, 0x4b])]); + await expect(importDocx(source)).rejects.toMatchObject({ + code: 'invalid_zip', + }); + expect(hostileConstructorCalls).toBe(0); + }); +}); diff --git a/src/docx/index.ts b/src/docx/index.ts new file mode 100644 index 00000000..1621290c --- /dev/null +++ b/src/docx/index.ts @@ -0,0 +1,17 @@ +export { + DocxImportError, + type DocxImportErrorCode, +} from './errors.js'; +export { DEFAULT_DOCX_IMPORT_LIMITS } from './limits.js'; +export { importDocx, openDocx } from './importDocx.js'; +export type { + DocxDocumentTarget, + DocxImportLimits, + DocxImportOptions, + DocxImportResult, + DocxImportWarning, + DocxImportWarningCode, + DocxJsonContent, + DocxJsonMark, + DocxSource, +} from './types.js'; diff --git a/src/docx/limits.ts b/src/docx/limits.ts new file mode 100644 index 00000000..cf64ebb0 --- /dev/null +++ b/src/docx/limits.ts @@ -0,0 +1,88 @@ +import { DocxImportError, normalizeDocxImportError } from './errors.js'; +import type { DocxImportLimits, DocxImportOptions } from './types.js'; + +/** Default resource profile for one untrusted DOCX package. */ +export const DEFAULT_DOCX_IMPORT_LIMITS: Readonly = Object.freeze({ + maxArchiveBytes: 32 * 1024 * 1024, + maxEntries: 2_048, + maxEntryBytes: 32 * 1024 * 1024, + maxTotalUncompressedBytes: 128 * 1024 * 1024, + maxCompressionRatio: 200, + maxXmlBytes: 16 * 1024 * 1024, + maxXmlNodes: 200_000, + maxXmlDepth: 128, + maxImages: 256, + maxImageBytes: 10 * 1024 * 1024, + maxTotalImageBytes: 40 * 1024 * 1024, + maxDocumentNodes: 100_000, +}); + +const HARD_LIMITS: Readonly = Object.freeze({ + maxArchiveBytes: 256 * 1024 * 1024, + maxEntries: 20_000, + maxEntryBytes: 128 * 1024 * 1024, + maxTotalUncompressedBytes: 512 * 1024 * 1024, + maxCompressionRatio: 10_000, + maxXmlBytes: 64 * 1024 * 1024, + maxXmlNodes: 1_000_000, + maxXmlDepth: 512, + maxImages: 2_048, + maxImageBytes: 64 * 1024 * 1024, + maxTotalImageBytes: 256 * 1024 * 1024, + maxDocumentNodes: 1_000_000, +}); + +const LIMIT_KEYS = Object.keys(DEFAULT_DOCX_IMPORT_LIMITS) as (keyof DocxImportLimits)[]; + +function rejectConfiguration(): never { + throw new DocxImportError('invalid_configuration'); +} + +function isRecord(value: unknown): value is Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function readDataRecord(value: unknown, allowed: readonly string[]): Record { + if (!isRecord(value) || Object.getOwnPropertySymbols(value).length > 0) rejectConfiguration(); + const descriptors = Object.getOwnPropertyDescriptors(value); + const result: Record = Object.create(null); + for (const [key, descriptor] of Object.entries(descriptors)) { + if (!allowed.includes(key) || !descriptor.enumerable || !('value' in descriptor)) { + rejectConfiguration(); + } + result[key] = descriptor.value; + } + return result; +} + +function resolveLimit(key: keyof DocxImportLimits, value: unknown): number { + if (value === undefined) return DEFAULT_DOCX_IMPORT_LIMITS[key]; + if ( + typeof value !== 'number' || + !Number.isSafeInteger(value) || + value < 1 || + value > HARD_LIMITS[key] + ) { + rejectConfiguration(); + } + return value; +} + +/** Resolve and freeze one strict DOCX import resource profile. */ +export function resolveDocxImportLimits( + options?: DocxImportOptions, +): Readonly { + if (options === undefined) return DEFAULT_DOCX_IMPORT_LIMITS; + try { + const optionRecord = readDataRecord(options, ['limits']); + if (optionRecord.limits === undefined) return DEFAULT_DOCX_IMPORT_LIMITS; + const limitRecord = readDataRecord(optionRecord.limits, LIMIT_KEYS); + const resolved = {} as Record; + for (const key of LIMIT_KEYS) resolved[key] = resolveLimit(key, limitRecord[key]); + return Object.freeze(resolved); + } catch (error) { + throw normalizeDocxImportError(error, 'invalid_configuration'); + } +} diff --git a/src/docx/limitsHostileReflection.test.ts b/src/docx/limitsHostileReflection.test.ts new file mode 100644 index 00000000..e7729867 --- /dev/null +++ b/src/docx/limitsHostileReflection.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it, vi } from 'vitest'; +import { resolveDocxImportLimits } from './limits.js'; +import type { DocxImportOptions } from './types.js'; + +describe('DOCX import limit failure containment', () => { + it('does not inspect a hostile thrown value while redacting reflection failure', () => { + const privateSentinel = new Error('private configuration sentinel'); + const thrownGetPrototypeOf = vi.fn(() => { + throw privateSentinel; + }); + const hostileThrownValue = new Proxy({}, { getPrototypeOf: thrownGetPrototypeOf }); + const options = new Proxy( + {}, + { + getPrototypeOf() { + throw hostileThrownValue; + }, + }, + ); + + let thrown: unknown; + try { + resolveDocxImportLimits(options as DocxImportOptions); + } catch (error) { + thrown = error; + } + + expect(thrownGetPrototypeOf).not.toHaveBeenCalled(); + expect(thrown).toMatchObject({ + name: 'DocxImportError', + code: 'invalid_configuration', + }); + }); +}); diff --git a/src/docx/ooxml.ts b/src/docx/ooxml.ts new file mode 100644 index 00000000..45e9fe9b --- /dev/null +++ b/src/docx/ooxml.ts @@ -0,0 +1,528 @@ +import { DocxImportError } from './errors.js'; +import { readDocxPackageMetadata } from './ooxmlPackage.js'; +import { + appendInline, + descendantsInNamespaces, + DOCUMENT_PATH, + DRAWING_NAMESPACES, + firstWordChild, + hasNamespace, + IMAGE_RELATIONSHIP_TYPES, + MAX_IMAGE_ALT_CODE_UNITS, + officeRelationshipAttribute, + onOffValue, + packageAttribute, + type InlinePart, + type ListDescriptor, + type ParagraphResult, + type ParsingContext, + parseUnsignedInteger, + resolvePackageTarget, + textNode, + WarningCollector, + wordAttribute, + wordChildren, + WORD_NAMESPACES, + WORDPROCESSING_DRAWING_NAMESPACES, +} from './ooxmlShared.js'; +import type { + DocxImportLimits, + DocxImportResult, + DocxJsonContent, + DocxJsonMark, +} from './types.js'; +import { + childElements, + directText, + parseXml, + type XmlElement, +} from './xml.js'; +import { ZipArchive } from './zip.js'; + +const BASE64_ALPHABET = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + +function bytesToBase64(bytes: Uint8Array): string { + let output = ''; + let offset = 0; + while (offset + 2 < bytes.byteLength) { + const value = + (bytes[offset]! << 16) | + (bytes[offset + 1]! << 8) | + bytes[offset + 2]!; + output += + BASE64_ALPHABET[(value >>> 18) & 63]! + + BASE64_ALPHABET[(value >>> 12) & 63]! + + BASE64_ALPHABET[(value >>> 6) & 63]! + + BASE64_ALPHABET[value & 63]!; + offset += 3; + } + const remaining = bytes.byteLength - offset; + if (remaining === 1) { + const value = bytes[offset]! << 16; + output += + BASE64_ALPHABET[(value >>> 18) & 63]! + + BASE64_ALPHABET[(value >>> 12) & 63]! + + '=='; + } else if (remaining === 2) { + const value = (bytes[offset]! << 16) | (bytes[offset + 1]! << 8); + output += + BASE64_ALPHABET[(value >>> 18) & 63]! + + BASE64_ALPHABET[(value >>> 12) & 63]! + + BASE64_ALPHABET[(value >>> 6) & 63]! + + '='; + } + return output; +} + +function imageMimeType(bytes: Uint8Array): string | undefined { + if ( + bytes.byteLength >= 8 && + bytes[0] === 0x89 && + bytes[1] === 0x50 && + bytes[2] === 0x4e && + bytes[3] === 0x47 && + bytes[4] === 0x0d && + bytes[5] === 0x0a && + bytes[6] === 0x1a && + bytes[7] === 0x0a + ) { + return 'image/png'; + } + if ( + bytes.byteLength >= 3 && + bytes[0] === 0xff && + bytes[1] === 0xd8 && + bytes[2] === 0xff + ) { + return 'image/jpeg'; + } + if (bytes.byteLength >= 6) { + const signature = String.fromCharCode(...bytes.subarray(0, 6)); + if (signature === 'GIF87a' || signature === 'GIF89a') return 'image/gif'; + } + if ( + bytes.byteLength >= 12 && + String.fromCharCode(...bytes.subarray(0, 4)) === 'RIFF' && + String.fromCharCode(...bytes.subarray(8, 12)) === 'WEBP' + ) { + return 'image/webp'; + } + return undefined; +} + +function freezeJson(node: DocxJsonContent): DocxJsonContent { + const content = node.content?.map((child) => freezeJson(child)); + const marks = node.marks?.map((mark) => Object.freeze({ type: mark.type })); + return Object.freeze({ + type: node.type!, + ...(node.attrs ? { attrs: Object.freeze({ ...node.attrs }) } : {}), + ...(content ? { content: Object.freeze(content) } : {}), + ...(marks ? { marks: Object.freeze(marks) } : {}), + ...(node.text !== undefined ? { text: node.text } : {}), + }); +} + +function assertDocumentNodeLimit( + documentJson: DocxJsonContent, + maxDocumentNodes: number, +): void { + const pending = [documentJson]; + let count = 0; + while (pending.length > 0) { + const node = pending.pop()!; + count += 1; + if (count > maxDocumentNodes) { + throw new DocxImportError('document_limit_exceeded'); + } + for (const child of node.content ?? []) pending.push(child); + } +} + +function supportedRunMarks(runProperties: XmlElement | undefined): DocxJsonMark[] { + if (!runProperties) return []; + const marks: DocxJsonMark[] = []; + if (onOffValue(firstWordChild(runProperties, 'b'))) marks.push({ type: 'bold' }); + if (onOffValue(firstWordChild(runProperties, 'i'))) marks.push({ type: 'italic' }); + if (onOffValue(firstWordChild(runProperties, 'strike'))) { + marks.push({ type: 'strike' }); + } + return marks; +} + +async function parseImage( + drawing: XmlElement, + context: ParsingContext, +): Promise { + const blip = descendantsInNamespaces( + drawing, + 'blip', + DRAWING_NAMESPACES, + )[0]; + const relationshipId = blip + ? officeRelationshipAttribute(blip, 'embed') + : undefined; + const relationship = relationshipId + ? context.relationships.get(relationshipId) + : undefined; + if ( + !relationship || + !IMAGE_RELATIONSHIP_TYPES.has(relationship.type) || + relationship.targetMode?.toLowerCase() === 'external' + ) { + context.warnings.add('missing_relationship'); + context.warnings.add('image_omitted'); + return undefined; + } + + const target = resolvePackageTarget(DOCUMENT_PATH, relationship.target); + if (!context.archive.has(target)) { + context.warnings.add('missing_relationship'); + context.warnings.add('image_omitted'); + return undefined; + } + const declaredBytes = context.archive.size(target); + if ( + declaredBytes === undefined || + declaredBytes > context.limits.maxImageBytes || + context.imageCount + 1 > context.limits.maxImages || + context.totalImageBytes + declaredBytes > context.limits.maxTotalImageBytes + ) { + throw new DocxImportError('document_limit_exceeded'); + } + const bytes = await context.archive.read(target); + const mimeType = imageMimeType(bytes); + if (!mimeType) { + context.warnings.add('unsupported_image'); + context.warnings.add('image_omitted'); + return undefined; + } + + context.imageCount += 1; + context.totalImageBytes += bytes.byteLength; + const documentProperties = descendantsInNamespaces( + drawing, + 'docPr', + WORDPROCESSING_DRAWING_NAMESPACES, + )[0]; + const authoredAlt = documentProperties + ? (packageAttribute(documentProperties, 'descr') ?? + packageAttribute(documentProperties, 'title') ?? + '') + : ''; + const alt = + authoredAlt.length <= MAX_IMAGE_ALT_CODE_UNITS + ? authoredAlt + : ''; + if (authoredAlt.length > MAX_IMAGE_ALT_CODE_UNITS) { + context.warnings.add('image_alt_omitted'); + } + return { + type: 'image', + attrs: { + src: `data:${mimeType};base64,${bytesToBase64(bytes)}`, + alt, + }, + }; +} + +async function parseRun( + run: XmlElement, + context: ParsingContext, +): Promise { + const runProperties = firstWordChild(run, 'rPr'); + if (onOffValue(runProperties ? firstWordChild(runProperties, 'vanish') : undefined)) { + context.warnings.add('hidden_text_omitted'); + return []; + } + const marks = supportedRunMarks(runProperties); + if ( + runProperties && + ['u', 'vertAlign', 'highlight', 'color'].some((name) => + Boolean(firstWordChild(runProperties, name)), + ) + ) { + context.warnings.add('unsupported_text_formatting'); + } + + const parts: InlinePart[] = []; + for (const child of wordChildren(run)) { + if (child.localName === 'rPr') continue; + if (child.localName === 't') { + const value = directText(child); + if (value.length > 0) appendInline(parts, textNode(value, marks)); + continue; + } + if (child.localName === 'tab') { + appendInline(parts, textNode('\t', marks)); + continue; + } + if (child.localName === 'br' || child.localName === 'cr') { + const breakType = child.localName === 'br' ? wordAttribute(child, 'type') : undefined; + if (breakType === 'page') context.warnings.add('page_break_flattened'); + appendInline(parts, { type: 'hardBreak' }); + continue; + } + if (child.localName === 'drawing') { + const image = await parseImage(child, context); + if (image) parts.push({ kind: 'image', node: image }); + continue; + } + context.warnings.add('unsupported_content'); + } + return parts; +} + +function paragraphHeadingLevel( + paragraphProperties: XmlElement | undefined, + context: ParsingContext, +): number | undefined { + if (!paragraphProperties) return undefined; + const outline = firstWordChild(paragraphProperties, 'outlineLvl'); + const outlineLevel = parseUnsignedInteger( + outline ? wordAttribute(outline, 'val') : undefined, + ); + if (outlineLevel !== undefined && outlineLevel <= 5) return outlineLevel + 1; + const style = firstWordChild(paragraphProperties, 'pStyle'); + const styleId = style ? wordAttribute(style, 'val') : undefined; + return styleId ? context.headingStyles.get(styleId) : undefined; +} + +function paragraphList( + paragraphProperties: XmlElement | undefined, + context: ParsingContext, +): ListDescriptor | undefined { + if (!paragraphProperties) return undefined; + const numberingProperties = firstWordChild(paragraphProperties, 'numPr'); + if (!numberingProperties) return undefined; + const levelNode = firstWordChild(numberingProperties, 'ilvl'); + const level = parseUnsignedInteger( + levelNode ? wordAttribute(levelNode, 'val') : undefined, + ); + const numberIdNode = firstWordChild(numberingProperties, 'numId'); + const numberId = numberIdNode ? wordAttribute(numberIdNode, 'val') : undefined; + if (level !== undefined && level !== 0) { + context.warnings.add('list_flattened'); + return undefined; + } + return numberId ? context.numbering.get(numberId) : undefined; +} + +async function parseParagraph( + paragraph: XmlElement, + context: ParsingContext, +): Promise { + const properties = firstWordChild(paragraph, 'pPr'); + const headingLevel = paragraphHeadingLevel(properties, context); + const parts: InlinePart[] = []; + for (const child of wordChildren(paragraph)) { + if (child.localName === 'pPr') continue; + if (child.localName === 'r') { + for (const part of await parseRun(child, context)) { + if (part.kind === 'inline') appendInline(parts, part.node); + else parts.push(part); + } + continue; + } + if (child.localName === 'hyperlink') { + context.warnings.add('unsafe_hyperlink'); + for (const run of wordChildren(child, 'r')) { + for (const part of await parseRun(run, context)) { + if (part.kind === 'inline') appendInline(parts, part.node); + else parts.push(part); + } + } + continue; + } + context.warnings.add('unsupported_content'); + } + + const blocks: DocxJsonContent[] = []; + let inline: DocxJsonContent[] = []; + const flushInline = (): void => { + if (inline.length === 0) return; + blocks.push({ + type: headingLevel ? 'heading' : 'paragraph', + ...(headingLevel ? { attrs: { level: headingLevel } } : {}), + content: inline, + }); + inline = []; + }; + for (const part of parts) { + if (part.kind === 'inline') inline.push(part.node); + else { + flushInline(); + blocks.push(part.node); + } + } + flushInline(); + if (blocks.length === 0) { + blocks.push({ + type: headingLevel ? 'heading' : 'paragraph', + ...(headingLevel ? { attrs: { level: headingLevel } } : {}), + }); + } + + const list = paragraphList(properties, context); + if ( + list && + (blocks.length !== 1 || blocks[0]!.type !== 'paragraph') + ) { + context.warnings.add('list_flattened'); + return { blocks }; + } + return list ? { blocks, list } : { blocks }; +} + +async function parseTable( + table: XmlElement, + context: ParsingContext, +): Promise { + const rows: DocxJsonContent[] = []; + for (const row of wordChildren(table, 'tr')) { + const rowProperties = firstWordChild(row, 'trPr'); + const header = onOffValue( + rowProperties ? firstWordChild(rowProperties, 'tblHeader') : undefined, + ); + const cells: DocxJsonContent[] = []; + for (const cell of wordChildren(row, 'tc')) { + const cellProperties = firstWordChild(cell, 'tcPr'); + if ( + cellProperties && + (firstWordChild(cellProperties, 'gridSpan') || + firstWordChild(cellProperties, 'vMerge')) + ) { + context.warnings.add('table_span_flattened'); + } + const content: DocxJsonContent[] = []; + for (const child of wordChildren(cell)) { + if (child.localName === 'tcPr') continue; + if (child.localName === 'p') { + const paragraph = await parseParagraph(child, context); + if (paragraph.list) context.warnings.add('list_flattened'); + content.push(...paragraph.blocks); + } else { + context.warnings.add('unsupported_content'); + } + } + if (content.length === 0) content.push({ type: 'paragraph' }); + cells.push({ + type: header ? 'tableHeader' : 'tableCell', + content, + }); + } + if (cells.length > 0) rows.push({ type: 'tableRow', content: cells }); + } + if (rows.length === 0) { + context.warnings.add('unsupported_content'); + return undefined; + } + return { type: 'table', content: rows }; +} + +interface PendingList { + readonly descriptor: ListDescriptor; + readonly items: DocxJsonContent[]; +} + +function flushList( + pending: PendingList | undefined, + blocks: DocxJsonContent[], +): void { + if (!pending) return; + blocks.push({ + type: pending.descriptor.kind, + ...(pending.descriptor.kind === 'orderedList' + ? { attrs: { start: pending.descriptor.start } } + : {}), + content: pending.items.map((paragraph) => ({ + type: 'listItem', + content: [paragraph], + })), + }); +} + +function sameList( + pending: PendingList | undefined, + descriptor: ListDescriptor, +): boolean { + return ( + pending?.descriptor.key === descriptor.key && + pending.descriptor.kind === descriptor.kind && + pending.descriptor.start === descriptor.start + ); +} + +/** Parse one validated OPC package into a detached immutable Inkspan document. */ +export async function parseDocxPackage( + archive: ZipArchive, + limits: Readonly, +): Promise { + const metadata = await readDocxPackageMetadata(archive, limits); + const root = parseXml(metadata.documentBytes, limits); + if (root.localName !== 'document' || !hasNamespace(root, WORD_NAMESPACES)) { + throw new DocxImportError('invalid_docx'); + } + const bodies = wordChildren(root, 'body'); + if (bodies.length !== 1) throw new DocxImportError('invalid_docx'); + const body = bodies[0]!; + + const warnings = new WarningCollector(); + const context: ParsingContext = { + archive, + limits, + warnings, + relationships: metadata.relationships, + headingStyles: metadata.headingStyles, + numbering: metadata.numbering, + imageCache: new Map(), + imageCount: 0, + totalImageBytes: 0, + }; + const blocks: DocxJsonContent[] = []; + let pendingList: PendingList | undefined; + for (const child of childElements(body)) { + if (!hasNamespace(child, WORD_NAMESPACES)) { + warnings.add('unsupported_content'); + continue; + } + if (child.localName === 'p') { + const paragraph = await parseParagraph(child, context); + if (paragraph.list) { + if (!sameList(pendingList, paragraph.list)) { + flushList(pendingList, blocks); + pendingList = { + descriptor: paragraph.list, + items: [], + }; + } + pendingList!.items.push(paragraph.blocks[0]!); + } else { + flushList(pendingList, blocks); + pendingList = undefined; + blocks.push(...paragraph.blocks); + } + continue; + } + flushList(pendingList, blocks); + pendingList = undefined; + if (child.localName === 'tbl') { + const table = await parseTable(child, context); + if (table) blocks.push(table); + } else if (child.localName !== 'sectPr') { + warnings.add('unsupported_content'); + } + } + flushList(pendingList, blocks); + + const documentJson: DocxJsonContent = { + type: 'doc', + content: blocks, + }; + assertDocumentNodeLimit(documentJson, limits.maxDocumentNodes); + const frozenDocument = freezeJson(documentJson); + return Object.freeze({ + documentJson: frozenDocument, + warnings: warnings.snapshot(), + }); +} \ No newline at end of file diff --git a/src/docx/ooxmlHeading.ts b/src/docx/ooxmlHeading.ts new file mode 100644 index 00000000..bf8af687 --- /dev/null +++ b/src/docx/ooxmlHeading.ts @@ -0,0 +1,12 @@ +/** Resolve a Word heading label such as `Heading 2` or `Heading2`. */ +export function headingLevelFromLabel(label: string): number | undefined { + let compact = ''; + for (const character of label) { + if (!character.trim()) continue; + compact += character.toLowerCase(); + } + for (let level = 1; level <= 6; level += 1) { + if (compact === `heading${level}`) return level; + } + return undefined; +} diff --git a/src/docx/ooxmlIntegrationCoverage.test.ts b/src/docx/ooxmlIntegrationCoverage.test.ts new file mode 100644 index 00000000..3eec2a56 --- /dev/null +++ b/src/docx/ooxmlIntegrationCoverage.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, it } from 'vitest'; +import { buildZip, createDocx, PNG_BYTES, WORD_NAMESPACES } from '../../test/docxFixture.js'; +import { DocxImportError } from './errors.js'; +import { importDocx } from './importDocx.js'; +import { DEFAULT_DOCX_IMPORT_LIMITS } from './limits.js'; +import { parseRelationships, validateContentTypes } from './ooxmlManifest.js'; +import { parseNumbering } from './ooxmlNumbering.js'; +import { parseHeadingStyles } from './ooxmlStyles.js'; +import { ZipArchive } from './zip.js'; + +const limits = DEFAULT_DOCX_IMPORT_LIMITS; + +function archive(entries: Readonly>): ZipArchive { + return ZipArchive.parse(buildZip(entries, 0), limits); +} + +async function expectInvalid(operation: Promise): Promise { + await expect(operation).rejects.toMatchObject({ + name: 'DocxImportError', + code: 'invalid_docx', + }); +} + +describe('DOCX OOXML package parsers', () => { + it('parses paragraph heading styles from names and outline levels', async () => { + const styles = await parseHeadingStyles( + archive({ + 'word/styles.xml': + `` + + '' + + '' + + '' + + '' + + '' + + '' + + '', + }), + limits, + ); + expect([...styles]).toEqual([ + ['Named', 3], + ['Outlined', 5], + ]); + expect(await parseHeadingStyles(archive({ 'other.txt': 'x' }), limits)).toEqual(new Map()); + await expectInvalid( + parseHeadingStyles(archive({ 'word/styles.xml': '' }), limits), + ); + }); + + it('parses only supported level-zero numbering instances with safe starts', async () => { + const numbering = await parseNumbering( + archive({ + 'word/numbering.xml': + `` + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '', + }), + limits, + ); + expect([...numbering]).toEqual([ + ['7', { key: '7', kind: 'orderedList', start: 3 }], + ['8', { key: '8', kind: 'orderedList', start: 1 }], + ]); + expect(await parseNumbering(archive({ 'other.txt': 'x' }), limits)).toEqual(new Map()); + await expectInvalid( + parseNumbering(archive({ 'word/numbering.xml': '' }), limits), + ); + }); + + it('validates the content-type manifest and relationship map without following targets', async () => { + const contentTypes = + '' + + '' + + ''; + const relationships = + '' + + '' + + '' + + ''; + const parsed = archive({ + '[Content_Types].xml': contentTypes, + 'word/_rels/document.xml.rels': relationships, + }); + await expect(validateContentTypes(parsed, limits)).resolves.toBeUndefined(); + expect([...(await parseRelationships(parsed, limits))]).toEqual([ + ['one', { type: 'urn:type', target: 'media/a.png' }], + ['two', { type: 'urn:type', target: 'https://example.invalid/x', targetMode: 'External' }], + ]); + expect(await parseRelationships(archive({ 'other.txt': 'x' }), limits)).toEqual(new Map()); + + await expectInvalid(validateContentTypes(archive({ 'other.txt': 'x' }), limits)); + await expectInvalid( + validateContentTypes( + archive({ '[Content_Types].xml': '' }), + limits, + ), + ); + await expectInvalid( + validateContentTypes( + archive({ + '[Content_Types].xml': + '', + }), + limits, + ), + ); + await expectInvalid( + parseRelationships( + archive({ 'word/_rels/document.xml.rels': '' }), + limits, + ), + ); + for (const relationship of [ + '', + '', + '', + '', + ]) { + await expectInvalid( + parseRelationships( + archive({ + 'word/_rels/document.xml.rels': + '' + + relationship + + '', + }), + limits, + ), + ); + } + }); +}); + +describe('DOCX rich OOXML integration coverage', () => { + it('normalizes styles, lists, hyperlinks, run controls, images, tables, and unsupported content deterministically', async () => { + const relationships = + '' + + '' + + '' + + ''; + const styles = + `` + + '' + + ''; + const numbering = + `` + + '' + + '' + + ''; + const body = + 'Rich' + + 'First' + + 'Second' + + 'Flattened' + + 'Link text' + + '' + + '' + + 'Hidden' + + '' + + '' + + ''; + const result = await importDocx( + createDocx({ + body, + relationships, + styles, + numbering, + media: { 'word/media/image.png': PNG_BYTES }, + method: 0, + }), + ); + + expect(result.documentJson.type).toBe('doc'); + expect(result.documentJson.content?.some((node) => node.type === 'heading')).toBe(true); + expect(result.documentJson.content?.some((node) => node.type === 'orderedList')).toBe(true); + expect(result.documentJson.content?.some((node) => node.type === 'image')).toBe(true); + expect(result.documentJson.content?.some((node) => node.type === 'table')).toBe(true); + expect(result.warnings.map((warning) => warning.code)).toEqual( + expect.arrayContaining([ + 'unsupported_text_formatting', + 'page_break_flattened', + 'unsupported_content', + 'list_flattened', + 'unsafe_hyperlink', + 'missing_relationship', + 'image_omitted', + 'hidden_text_omitted', + 'table_span_flattened', + ]), + ); + }); + + it('preserves the public error type when malformed package content is rejected', async () => { + await expect(importDocx(createDocx({ document: '' }))).rejects.toBeInstanceOf( + DocxImportError, + ); + }); +}); diff --git a/src/docx/ooxmlManifest.ts b/src/docx/ooxmlManifest.ts new file mode 100644 index 00000000..04fc0d7c --- /dev/null +++ b/src/docx/ooxmlManifest.ts @@ -0,0 +1,84 @@ +import { DocxImportError } from './errors.js'; +import { + DOCUMENT_PATH, + packageAttribute, + type Relationship, +} from './ooxmlShared.js'; +import type { DocxImportLimits } from './types.js'; +import { childElements, parseXml } from './xml.js'; +import { ZipArchive } from './zip.js'; + +const MAIN_DOCUMENT_CONTENT_TYPE = + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml'; +const RELATIONSHIPS_PATH = 'word/_rels/document.xml.rels'; +const CONTENT_TYPES_PATH = '[Content_Types].xml'; +const CONTENT_TYPES_NAMESPACE = + 'http://schemas.openxmlformats.org/package/2006/content-types'; +const PACKAGE_RELATIONSHIPS_NAMESPACE = + 'http://schemas.openxmlformats.org/package/2006/relationships'; + +/** Assert that the OPC manifest identifies an ordinary Word DOCX. */ +export async function validateContentTypes( + archive: ZipArchive, + limits: Readonly, +): Promise { + if (!archive.has(CONTENT_TYPES_PATH)) { + throw new DocxImportError('invalid_docx'); + } + const root = parseXml(await archive.read(CONTENT_TYPES_PATH), limits); + if ( + root.localName !== 'Types' || + root.namespaceUri !== CONTENT_TYPES_NAMESPACE + ) { + throw new DocxImportError('invalid_docx'); + } + const documentOverrides = childElements( + root, + 'Override', + CONTENT_TYPES_NAMESPACE, + ).filter( + (entry) => + packageAttribute(entry, 'PartName') === `/${DOCUMENT_PATH}`, + ); + if ( + documentOverrides.length !== 1 || + packageAttribute(documentOverrides[0]!, 'ContentType') !== + MAIN_DOCUMENT_CONTENT_TYPE + ) { + throw new DocxImportError('invalid_docx'); + } +} + +/** Parse document relationships without following any target. */ +export async function parseRelationships( + archive: ZipArchive, + limits: Readonly, +): Promise> { + if (!archive.has(RELATIONSHIPS_PATH)) return new Map(); + const root = parseXml(await archive.read(RELATIONSHIPS_PATH), limits); + if ( + root.localName !== 'Relationships' || + root.namespaceUri !== PACKAGE_RELATIONSHIPS_NAMESPACE + ) { + throw new DocxImportError('invalid_docx'); + } + const relationships = new Map(); + for (const node of childElements( + root, + 'Relationship', + PACKAGE_RELATIONSHIPS_NAMESPACE, + )) { + const id = packageAttribute(node, 'Id'); + const type = packageAttribute(node, 'Type'); + const target = packageAttribute(node, 'Target'); + const targetMode = packageAttribute(node, 'TargetMode'); + if (!id || !type || !target || relationships.has(id)) { + throw new DocxImportError('invalid_docx'); + } + relationships.set( + id, + Object.freeze({ type, target, ...(targetMode ? { targetMode } : {}) }), + ); + } + return relationships; +} diff --git a/src/docx/ooxmlNumberFormats.ts b/src/docx/ooxmlNumberFormats.ts new file mode 100644 index 00000000..bb736669 --- /dev/null +++ b/src/docx/ooxmlNumberFormats.ts @@ -0,0 +1,16 @@ +/** Classify one Word list format into an Inkspan list kind. */ +export function classifyNumberFormat( + value: string | undefined, +): 'bulletList' | 'orderedList' | undefined { + if (value === 'bullet') return 'bulletList'; + const orderedFormats = [ + 'decimal', + 'decimalZero', + 'lowerLetter', + 'lowerRoman', + 'ordinal', + 'upperLetter', + 'upperRoman', + ]; + return value && orderedFormats.includes(value) ? 'orderedList' : undefined; +} diff --git a/src/docx/ooxmlNumbering.ts b/src/docx/ooxmlNumbering.ts new file mode 100644 index 00000000..f23c9154 --- /dev/null +++ b/src/docx/ooxmlNumbering.ts @@ -0,0 +1,75 @@ +import { DocxImportError } from './errors.js'; +import { classifyNumberFormat } from './ooxmlNumberFormats.js'; +import { + firstWordChild, + hasNamespace, + type ListDescriptor, + parseUnsignedInteger, + wordAttribute, + wordChildren, + WORD_NAMESPACES, +} from './ooxmlShared.js'; +import type { DocxImportLimits } from './types.js'; +import { parseXml } from './xml.js'; +import { ZipArchive } from './zip.js'; + +const NUMBERING_PATH = 'word/numbering.xml'; + +/** Parse level-zero numbering definitions into flat list descriptors. */ +export async function parseNumbering( + archive: ZipArchive, + limits: Readonly, +): Promise> { + if (!archive.has(NUMBERING_PATH)) return new Map(); + const root = parseXml(await archive.read(NUMBERING_PATH), limits); + if (root.localName !== 'numbering' || !hasNamespace(root, WORD_NAMESPACES)) { + throw new DocxImportError('invalid_docx'); + } + const abstract = new Map< + string, + { readonly kind: 'bulletList' | 'orderedList'; readonly start: number } + >(); + for (const definition of wordChildren(root, 'abstractNum')) { + const id = wordAttribute(definition, 'abstractNumId'); + if (!id) continue; + const level = wordChildren(definition, 'lvl').find( + (candidate) => wordAttribute(candidate, 'ilvl') === '0', + ); + if (!level) continue; + const numberFormat = firstWordChild(level, 'numFmt'); + const kind = classifyNumberFormat( + numberFormat ? wordAttribute(numberFormat, 'val') : undefined, + ); + if (!kind) continue; + const startNode = firstWordChild(level, 'start'); + const declaredStart = parseUnsignedInteger( + startNode ? wordAttribute(startNode, 'val') : undefined, + ); + abstract.set( + id, + Object.freeze({ + kind, + start: declaredStart && declaredStart > 0 ? declaredStart : 1, + }), + ); + } + const result = new Map(); + for (const instance of wordChildren(root, 'num')) { + const numId = wordAttribute(instance, 'numId'); + const abstractIdNode = firstWordChild(instance, 'abstractNumId'); + const abstractId = abstractIdNode + ? wordAttribute(abstractIdNode, 'val') + : undefined; + const definition = abstractId ? abstract.get(abstractId) : undefined; + if (!numId || !definition) continue; + result.set( + numId, + Object.freeze({ + key: numId, + kind: definition.kind, + start: definition.start, + }), + ); + } + return result; +} diff --git a/src/docx/ooxmlPackage.ts b/src/docx/ooxmlPackage.ts new file mode 100644 index 00000000..6b764b84 --- /dev/null +++ b/src/docx/ooxmlPackage.ts @@ -0,0 +1,35 @@ +import { DocxImportError } from './errors.js'; +import { parseRelationships, validateContentTypes } from './ooxmlManifest.js'; +import { parseNumbering } from './ooxmlNumbering.js'; +import { parseHeadingStyles } from './ooxmlStyles.js'; +import { + DOCUMENT_PATH, + type ListDescriptor, + type Relationship, +} from './ooxmlShared.js'; +import type { DocxImportLimits } from './types.js'; +import { ZipArchive } from './zip.js'; + +export interface DocxPackageMetadata { + readonly relationships: ReadonlyMap; + readonly headingStyles: ReadonlyMap; + readonly numbering: ReadonlyMap; + readonly documentBytes: Uint8Array; +} + +/** Validate the package manifest and read bounded optional Word metadata. */ +export async function readDocxPackageMetadata( + archive: ZipArchive, + limits: Readonly, +): Promise { + await validateContentTypes(archive, limits); + if (!archive.has(DOCUMENT_PATH)) throw new DocxImportError('invalid_docx'); + const [relationships, headingStyles, numbering, documentBytes] = + await Promise.all([ + parseRelationships(archive, limits), + parseHeadingStyles(archive, limits), + parseNumbering(archive, limits), + archive.read(DOCUMENT_PATH), + ]); + return { relationships, headingStyles, numbering, documentBytes }; +} diff --git a/src/docx/ooxmlShared.ts b/src/docx/ooxmlShared.ts new file mode 100644 index 00000000..98354bd1 --- /dev/null +++ b/src/docx/ooxmlShared.ts @@ -0,0 +1,272 @@ +import { DocxImportError } from './errors.js'; +import type { + DocxImportLimits, + DocxImportWarning, + DocxImportWarningCode, + DocxJsonContent, + DocxJsonMark, +} from './types.js'; +import { + attribute, + childElements, + descendantElements, + type XmlElement, +} from './xml.js'; +import { ZipArchive } from './zip.js'; + +export const DOCUMENT_PATH = 'word/document.xml'; +export const WORD_NAMESPACES = new Set([ + 'http://schemas.openxmlformats.org/wordprocessingml/2006/main', + 'http://purl.oclc.org/ooxml/wordprocessingml/main', +]); +export const OFFICE_RELATIONSHIP_NAMESPACES = new Set([ + 'http://schemas.openxmlformats.org/officeDocument/2006/relationships', + 'http://purl.oclc.org/ooxml/officeDocument/relationships', +]); +export const DRAWING_NAMESPACES = new Set([ + 'http://schemas.openxmlformats.org/drawingml/2006/main', + 'http://purl.oclc.org/ooxml/drawingml/main', +]); +export const WORDPROCESSING_DRAWING_NAMESPACES = new Set([ + 'http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing', + 'http://purl.oclc.org/ooxml/drawingml/wordprocessingDrawing', +]); +export const HYPERLINK_RELATIONSHIP_TYPES = new Set([ + 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink', + 'http://purl.oclc.org/ooxml/officeDocument/relationships/hyperlink', +]); +export const IMAGE_RELATIONSHIP_TYPES = new Set([ + 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/image', + 'http://purl.oclc.org/ooxml/officeDocument/relationships/image', +]); +export const SUPPORTED_IMAGE_MIME_TYPES = new Set([ + 'image/gif', + 'image/jpeg', + 'image/png', + 'image/webp', +]); +export const MAX_IMAGE_ALT_CODE_UNITS = 1_000; + +export interface Relationship { + readonly type: string; + readonly target: string; + readonly targetMode?: string; +} + +export interface ListDescriptor { + readonly key: string; + readonly kind: 'bulletList' | 'orderedList'; + readonly start: number; +} + +export interface ParagraphResult { + readonly blocks: DocxJsonContent[]; + readonly list?: ListDescriptor; +} + +export interface InlineTextPart { + readonly kind: 'inline'; + readonly node: DocxJsonContent; +} + +export interface InlineImagePart { + readonly kind: 'image'; + readonly node: DocxJsonContent; +} + +export type InlinePart = InlineTextPart | InlineImagePart; + +/** Count and deduplicate payload-free lossy-import warnings. */ +export class WarningCollector { + readonly #counts = new Map(); + + /** Record one occurrence of a stable warning category. */ + add(code: DocxImportWarningCode): void { + this.#counts.set(code, (this.#counts.get(code) ?? 0) + 1); + } + + /** Return an immutable warning snapshot in first-occurrence order. */ + snapshot(): readonly DocxImportWarning[] { + return Object.freeze( + [...this.#counts].map(([code, count]) => Object.freeze({ code, count })), + ); + } +} + +export interface ParsingContext { + readonly archive: ZipArchive; + readonly limits: Readonly; + readonly warnings: WarningCollector; + readonly relationships: ReadonlyMap; + readonly headingStyles: ReadonlyMap; + readonly numbering: ReadonlyMap; + readonly imageCache: Map>; + imageCount: number; + totalImageBytes: number; +} + +export function hasNamespace( + node: XmlElement, + namespaces: ReadonlySet, +): boolean { + return node.namespaceUri !== undefined && namespaces.has(node.namespaceUri); +} + +export function wordChildren( + node: XmlElement, + wantedLocalName?: string, +): XmlElement[] { + return childElements(node, wantedLocalName).filter((child) => + hasNamespace(child, WORD_NAMESPACES), + ); +} + +export function firstWordChild( + node: XmlElement, + wantedLocalName: string, +): XmlElement | undefined { + return wordChildren(node, wantedLocalName)[0]; +} + +export function descendantsInNamespaces( + node: XmlElement, + wantedLocalName: string, + namespaces: ReadonlySet, +): XmlElement[] { + return descendantElements(node, wantedLocalName).filter((child) => + hasNamespace(child, namespaces), + ); +} + +function namespacedAttribute( + node: XmlElement, + wantedLocalName: string, + namespaces: ReadonlySet, +): string | undefined { + let value: string | undefined; + for (const namespaceUri of namespaces) { + const candidate = attribute(node, wantedLocalName, namespaceUri); + if (candidate === undefined) continue; + if (value !== undefined) throw new DocxImportError('invalid_docx'); + value = candidate; + } + return value; +} + +export function wordAttribute( + node: XmlElement, + wantedLocalName: string, +): string | undefined { + return namespacedAttribute(node, wantedLocalName, WORD_NAMESPACES); +} + +export function officeRelationshipAttribute( + node: XmlElement, + wantedLocalName: string, +): string | undefined { + return namespacedAttribute( + node, + wantedLocalName, + OFFICE_RELATIONSHIP_NAMESPACES, + ); +} + +export function packageAttribute( + node: XmlElement, + wantedLocalName: string, +): string | undefined { + return attribute(node, wantedLocalName, null); +} + +export function parseUnsignedInteger( + value: string | undefined, +): number | undefined { + if (value === undefined || !/^[0-9]+$/u.test(value)) return undefined; + const parsed = Number(value); + return Number.isSafeInteger(parsed) ? parsed : undefined; +} + +export function onOffValue(node: XmlElement | undefined): boolean { + if (!node) return false; + const value = wordAttribute(node, 'val'); + return value === undefined || !/^(?:0|false|no|off)$/iu.test(value); +} + +export function textNode( + text: string, + marks: readonly DocxJsonMark[], +): DocxJsonContent { + return marks.length === 0 + ? { type: 'text', text } + : { type: 'text', text, marks: [...marks] }; +} + +function equalMarks( + left: readonly DocxJsonMark[] | undefined, + right: readonly DocxJsonMark[] | undefined, +): boolean { + const leftMarks = left ?? []; + const rightMarks = right ?? []; + if (leftMarks.length !== rightMarks.length) return false; + return leftMarks.every((mark, index) => { + const other = rightMarks[index]!; + return ( + mark.type === other.type && + JSON.stringify(mark.attrs ?? null) === JSON.stringify(other.attrs ?? null) + ); + }); +} + +export function appendInline( + parts: InlinePart[], + node: DocxJsonContent, +): void { + const previous = parts[parts.length - 1]; + if ( + node.type === 'text' && + typeof node.text === 'string' && + previous?.kind === 'inline' && + previous.node.type === 'text' && + typeof previous.node.text === 'string' && + equalMarks(previous.node.marks, node.marks) + ) { + parts[parts.length - 1] = { + kind: 'inline', + node: textNode(previous.node.text + node.text, node.marks ?? []), + }; + return; + } + parts.push({ kind: 'inline', node }); +} + +/** Resolve one internal OPC relationship target against a source part. */ +export function resolvePackageTarget(basePart: string, target: string): string { + if ( + target.length === 0 || + target.includes('\\') || + target.includes('\0') || + target.includes('?') || + target.includes('#') || + /^[A-Za-z][A-Za-z0-9+.-]*:/u.test(target) || + target.startsWith('//') + ) { + throw new DocxImportError('invalid_docx'); + } + const baseSegments = target.startsWith('/') + ? [] + : basePart.split('/').slice(0, -1); + const targetSegments = target.replace(/^\//u, '').split('/'); + for (const segment of targetSegments) { + if (segment.length === 0 || segment === '.') { + throw new DocxImportError('invalid_docx'); + } + if (segment === '..') { + if (baseSegments.length === 0) throw new DocxImportError('invalid_docx'); + baseSegments.pop(); + } else { + baseSegments.push(segment); + } + } + if (baseSegments.length === 0) throw new DocxImportError('invalid_docx'); + return baseSegments.join('/'); +} diff --git a/src/docx/ooxmlStyles.ts b/src/docx/ooxmlStyles.ts new file mode 100644 index 00000000..3a4759b6 --- /dev/null +++ b/src/docx/ooxmlStyles.ts @@ -0,0 +1,55 @@ +import { DocxImportError } from './errors.js'; +import { headingLevelFromLabel } from './ooxmlHeading.js'; +import { + firstWordChild, + hasNamespace, + parseUnsignedInteger, + wordAttribute, + wordChildren, + WORD_NAMESPACES, +} from './ooxmlShared.js'; +import type { DocxImportLimits } from './types.js'; +import { parseXml } from './xml.js'; +import { ZipArchive } from './zip.js'; + +const STYLES_PATH = 'word/styles.xml'; + +/** Map direct paragraph styles to supported heading levels. */ +export async function parseHeadingStyles( + archive: ZipArchive, + limits: Readonly, +): Promise> { + if (!archive.has(STYLES_PATH)) return new Map(); + const root = parseXml(await archive.read(STYLES_PATH), limits); + if (root.localName !== 'styles' || !hasNamespace(root, WORD_NAMESPACES)) { + throw new DocxImportError('invalid_docx'); + } + const styles = new Map(); + const paragraphStyleIds = new Set(); + for (const style of wordChildren(root, 'style')) { + if (wordAttribute(style, 'type') !== 'paragraph') continue; + const styleId = wordAttribute(style, 'styleId'); + if (!styleId) continue; + if (paragraphStyleIds.has(styleId)) { + throw new DocxImportError('invalid_docx'); + } + paragraphStyleIds.add(styleId); + const nameNode = firstWordChild(style, 'name'); + const name = nameNode ? wordAttribute(nameNode, 'val') : undefined; + const paragraphProperties = firstWordChild(style, 'pPr'); + const outlineNode = paragraphProperties + ? firstWordChild(paragraphProperties, 'outlineLvl') + : undefined; + const outlineLevel = parseUnsignedInteger( + outlineNode ? wordAttribute(outlineNode, 'val') : undefined, + ); + const namedLevel = headingLevelFromLabel(name ?? styleId); + const level = + namedLevel ?? + (outlineLevel !== undefined && outlineLevel <= 5 + ? outlineLevel + 1 + : undefined); + if (level !== undefined) styles.set(styleId, level); + } + return styles; +} diff --git a/src/docx/openDocxTargetCapability.test.ts b/src/docx/openDocxTargetCapability.test.ts new file mode 100644 index 00000000..441d9918 --- /dev/null +++ b/src/docx/openDocxTargetCapability.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createDocx } from '../../test/docxFixture.js'; +import { openDocx, type DocxDocumentTarget, type DocxJsonContent } from './index.js'; + +describe('openDocx host capability acquisition', () => { + it('captures each editor capability once before validation and mutation', async () => { + const privateValidateFailure = { secret: 'second-validate-read' }; + const privateSetFailure = { secret: 'second-set-read' }; + const validateDocumentJson = vi.fn( + (documentJson: DocxJsonContent) => documentJson.type === 'doc', + ); + const setDocumentJson = vi.fn((_documentJson: DocxJsonContent) => undefined); + let validateReads = 0; + let setReads = 0; + const target = {} as DocxDocumentTarget; + + Object.defineProperties(target, { + validateDocumentJson: { + configurable: true, + get() { + validateReads += 1; + if (validateReads > 1) throw privateValidateFailure; + return validateDocumentJson; + }, + }, + setDocumentJson: { + configurable: true, + get() { + setReads += 1; + if (setReads > 1) throw privateSetFailure; + return setDocumentJson; + }, + }, + }); + + const result = await openDocx(target, createDocx()); + + expect(result.documentJson.type).toBe('doc'); + expect(validateReads).toBe(1); + expect(setReads).toBe(1); + expect(validateDocumentJson).toHaveBeenCalledTimes(1); + expect(setDocumentJson).toHaveBeenCalledTimes(1); + expect(setDocumentJson).toHaveBeenCalledWith(result.documentJson); + }); +}); diff --git a/src/docx/sourceBrandProxy.test.ts b/src/docx/sourceBrandProxy.test.ts new file mode 100644 index 00000000..f1189844 --- /dev/null +++ b/src/docx/sourceBrandProxy.test.ts @@ -0,0 +1,265 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createDocx } from '../../test/docxFixture.js'; +import { importDocx } from './index.js'; + +function createDocxBlob(): Blob { + const fixture = createDocx(); + const bytes = new Uint8Array(fixture.byteLength); + bytes.set(fixture); + return new Blob([bytes.buffer]); +} + +describe('DOCX binary source branding', () => { + it('rejects hostile proxy sources without invoking prototype traps', async () => { + const getPrototypeOf = vi.fn(() => { + throw new Error('private prototype sentinel'); + }); + const hostileSource = new Proxy(Object.create(null) as object, { + getPrototypeOf, + }); + + await expect( + importDocx(hostileSource as unknown as ArrayBuffer), + ).rejects.toMatchObject({ + name: 'DocxImportError', + code: 'invalid_source', + message: 'DOCX input must be a supported binary source.', + }); + expect(getPrototypeOf).not.toHaveBeenCalled(); + }); + + it('does not consult replaced ArrayBuffer.isView after platform capture', async () => { + const source = createDocx(); + const hostileIsView = vi + .spyOn(ArrayBuffer, 'isView') + .mockImplementation(() => { + throw new Error('private ArrayBuffer.isView sentinel'); + }); + + try { + await expect(importDocx(source)).resolves.toMatchObject({ + documentJson: { type: 'doc' }, + }); + expect(hostileIsView).not.toHaveBeenCalled(); + } finally { + hostileIsView.mockRestore(); + } + }); + + it('does not consult a replaced global TextDecoder after platform capture', async () => { + const source = createDocx(); + const hostileConstructor = vi.fn(); + + class HostileTextDecoder { + constructor() { + hostileConstructor(); + throw new Error('private TextDecoder sentinel'); + } + } + + vi.stubGlobal('TextDecoder', HostileTextDecoder); + try { + await expect(importDocx(source)).resolves.toMatchObject({ + documentJson: { type: 'doc' }, + }); + expect(hostileConstructor).not.toHaveBeenCalled(); + } finally { + vi.unstubAllGlobals(); + } + }); + + it('does not execute post-load Blob size getter interposition', async () => { + const source = createDocxBlob(); + const originalSize = Object.getOwnPropertyDescriptor(Blob.prototype, 'size'); + const hostileSize = vi.fn(() => { + throw new Error('private Blob size sentinel'); + }); + + Object.defineProperty(Blob.prototype, 'size', { + configurable: true, + get: hostileSize, + }); + try { + await expect(importDocx(source)).resolves.toMatchObject({ + documentJson: { type: 'doc' }, + }); + expect(hostileSize).not.toHaveBeenCalled(); + } finally { + Object.defineProperty(Blob.prototype, 'size', originalSize!); + } + }); + + it('does not execute post-load Blob byte-reader interposition', async () => { + const source = createDocxBlob(); + const originalArrayBuffer = Object.getOwnPropertyDescriptor( + Blob.prototype, + 'arrayBuffer', + ); + const hostileArrayBuffer = vi.fn(() => { + throw new Error('private Blob arrayBuffer sentinel'); + }); + + Object.defineProperty(Blob.prototype, 'arrayBuffer', { + configurable: true, + get: hostileArrayBuffer, + }); + try { + await expect(importDocx(source)).resolves.toMatchObject({ + documentJson: { type: 'doc' }, + }); + expect(hostileArrayBuffer).not.toHaveBeenCalled(); + } finally { + if (originalArrayBuffer === undefined) { + Reflect.deleteProperty(Blob.prototype, 'arrayBuffer'); + } else { + Object.defineProperty( + Blob.prototype, + 'arrayBuffer', + originalArrayBuffer, + ); + } + } + }); + + it('does not execute a replaced Blob byte-reader value after platform capture', async () => { + const source = createDocxBlob(); + const originalArrayBuffer = Object.getOwnPropertyDescriptor( + Blob.prototype, + 'arrayBuffer', + ); + const hostileArrayBuffer = vi.fn(() => { + throw new Error('private Blob arrayBuffer value sentinel'); + }); + + Object.defineProperty(Blob.prototype, 'arrayBuffer', { + configurable: true, + writable: true, + value: hostileArrayBuffer, + }); + try { + await expect(importDocx(source)).resolves.toMatchObject({ + documentJson: { type: 'doc' }, + }); + expect(hostileArrayBuffer).not.toHaveBeenCalled(); + } finally { + if (originalArrayBuffer === undefined) { + Reflect.deleteProperty(Blob.prototype, 'arrayBuffer'); + } else { + Object.defineProperty( + Blob.prototype, + 'arrayBuffer', + originalArrayBuffer, + ); + } + } + }); + + it('does not consult a replaced global Blob after platform capture', async () => { + const source = createDocxBlob(); + const originalBlob = globalThis.Blob; + const get = vi.fn((_target: typeof Blob, property: PropertyKey) => { + if (property === 'prototype') { + throw new Error('private global Blob sentinel'); + } + return Reflect.get(originalBlob, property); + }); + + vi.stubGlobal('Blob', new Proxy(originalBlob, { get })); + try { + await expect(importDocx(source)).resolves.toMatchObject({ + documentJson: { type: 'doc' }, + }); + expect(get).not.toHaveBeenCalled(); + } finally { + vi.unstubAllGlobals(); + } + }); + + it('fails closed when Blob support was absent at module initialization', async () => { + const originalBlob = globalThis.Blob; + vi.resetModules(); + vi.stubGlobal('Blob', undefined); + const { importDocx: importWithoutBlob } = await import('./importDocx.js'); + vi.stubGlobal('Blob', originalBlob); + try { + await expect(importWithoutBlob(createDocxBlob())).rejects.toMatchObject({ + name: 'DocxImportError', + code: 'invalid_source', + message: 'DOCX input must be a supported binary source.', + }); + } finally { + vi.unstubAllGlobals(); + vi.resetModules(); + } + }); + + it('rejects hostile FileReader results without invoking prototype traps', async () => { + const originalArrayBuffer = Object.getOwnPropertyDescriptor( + Blob.prototype, + 'arrayBuffer', + ); + const getPrototypeOf = vi.fn(() => { + throw new Error('private FileReader result sentinel'); + }); + const hostileResult = new Proxy(Object.create(null) as object, { + getPrototypeOf, + }); + + class HostileResultReader { + result: ArrayBuffer | string | null = hostileResult as unknown as ArrayBuffer; + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + + readAsArrayBuffer(): void { + this.onload?.(); + } + } + + Object.defineProperty(Blob.prototype, 'arrayBuffer', { + configurable: true, + writable: true, + value: undefined, + }); + vi.stubGlobal('FileReader', HostileResultReader); + vi.resetModules(); + const { importDocx: importWithHostileReader } = await import('./importDocx.js'); + + try { + const source = new Blob([new Uint8Array([1])]); + await expect(importWithHostileReader(source)).rejects.toMatchObject({ + name: 'DocxImportError', + code: 'invalid_source', + message: 'DOCX input must be a supported binary source.', + }); + expect(getPrototypeOf).not.toHaveBeenCalled(); + } finally { + if (originalArrayBuffer === undefined) { + Reflect.deleteProperty(Blob.prototype, 'arrayBuffer'); + } else { + Object.defineProperty( + Blob.prototype, + 'arrayBuffer', + originalArrayBuffer, + ); + } + vi.unstubAllGlobals(); + vi.resetModules(); + } + }); + + it('rejects unsupported sources when Blob is unavailable', async () => { + vi.stubGlobal('Blob', undefined); + try { + await expect( + importDocx(Object.create(null) as unknown as ArrayBuffer), + ).rejects.toMatchObject({ + name: 'DocxImportError', + code: 'invalid_source', + message: 'DOCX input must be a supported binary source.', + }); + } finally { + vi.unstubAllGlobals(); + } + }); +}); diff --git a/src/docx/types.ts b/src/docx/types.ts new file mode 100644 index 00000000..7214bcf3 --- /dev/null +++ b/src/docx/types.ts @@ -0,0 +1,70 @@ +/** Binary source accepted by the bounded DOCX importer. */ +export type DocxSource = Blob | ArrayBuffer | ArrayBufferView; + +/** Framework-neutral TipTap/ProseMirror-compatible mark JSON. */ +export interface DocxJsonMark { + readonly type: string; + readonly attrs?: Readonly>; +} + +/** Framework-neutral TipTap/ProseMirror-compatible node JSON. */ +export interface DocxJsonContent { + readonly type?: string; + readonly attrs?: Readonly>; + readonly content?: readonly DocxJsonContent[]; + readonly marks?: readonly DocxJsonMark[]; + readonly text?: string; +} + +/** Stable, payload-redacted categories reported for lossy but usable imports. */ +export type DocxImportWarningCode = + | 'image_alt_omitted' + | 'image_omitted' + | 'hidden_text_omitted' + | 'list_flattened' + | 'missing_relationship' + | 'page_break_flattened' + | 'table_span_flattened' + | 'unsafe_hyperlink' + | 'unsupported_content' + | 'unsupported_image' + | 'unsupported_text_formatting'; + +/** Deduplicated warning whose count discloses no authored content. */ +export interface DocxImportWarning { + readonly code: DocxImportWarningCode; + readonly count: number; +} + +/** Resource limits enforced before or during DOCX package processing. */ +export interface DocxImportLimits { + readonly maxArchiveBytes: number; + readonly maxEntries: number; + readonly maxEntryBytes: number; + readonly maxTotalUncompressedBytes: number; + readonly maxCompressionRatio: number; + readonly maxXmlBytes: number; + readonly maxXmlNodes: number; + readonly maxXmlDepth: number; + readonly maxImages: number; + readonly maxImageBytes: number; + readonly maxTotalImageBytes: number; + readonly maxDocumentNodes: number; +} + +/** Optional stricter resource profile for one import. */ +export interface DocxImportOptions { + readonly limits?: Partial; +} + +/** Detached result produced before any editor mutation. */ +export interface DocxImportResult { + readonly documentJson: DocxJsonContent; + readonly warnings: readonly DocxImportWarning[]; +} + +/** Minimal atomic document target implemented by {@link CwlEditorHandle}. */ +export interface DocxDocumentTarget { + validateDocumentJson(documentJson: DocxJsonContent): boolean; + setDocumentJson(documentJson: DocxJsonContent): void; +} diff --git a/src/docx/xml.ts b/src/docx/xml.ts new file mode 100644 index 00000000..65020f17 --- /dev/null +++ b/src/docx/xml.ts @@ -0,0 +1,411 @@ +import { DocxImportError, normalizeDocxImportError } from './errors.js'; +import type { DocxImportLimits } from './types.js'; + +const XML_NAMESPACE = 'http://www.w3.org/XML/1998/namespace'; +const XMLNS_NAMESPACE = 'http://www.w3.org/2000/xmlns/'; +const XML_UTF8_DECODER = new TextDecoder('utf-8', { fatal: true }); +const TEXT_DECODER_DECODE = TextDecoder.prototype.decode; + +/** Minimal inert XML tree used only for bounded OOXML interpretation. */ +export interface XmlElement { + readonly name: string; + readonly localName: string; + readonly namespaceUri?: string; + readonly attributes: ReadonlyMap; + readonly attributeNamespaces: ReadonlyMap; + readonly children: readonly (XmlElement | string)[]; +} + +interface NamespaceScope { + readonly parent?: NamespaceScope; + readonly declarations: ReadonlyMap; +} + +interface MutableXmlElement { + name: string; + localName: string; + namespaceUri?: string; + attributes: Map; + attributeNamespaces: Map; + children: (MutableXmlElement | string)[]; + namespaces: NamespaceScope; +} + +function splitQualifiedName(name: string): readonly [string, string] { + const separator = name.indexOf(':'); + if (separator < 0) return ['', name]; + if ( + separator === 0 || + separator === name.length - 1 || + name.indexOf(':', separator + 1) >= 0 + ) { + throw new DocxImportError('invalid_xml'); + } + return [name.slice(0, separator), name.slice(separator + 1)]; +} + +function localName(name: string): string { + return splitQualifiedName(name)[1]; +} + +function isNameStart(character: string): boolean { + return /[A-Za-z_]/u.test(character); +} + +function isNameCharacter(character: string): boolean { + return /[A-Za-z0-9_.:-]/u.test(character); +} + +function readName(source: string, start: number): readonly [string, number] { + if (start >= source.length || !isNameStart(source[start]!)) { + throw new DocxImportError('invalid_xml'); + } + let cursor = start + 1; + while (cursor < source.length && isNameCharacter(source[cursor]!)) cursor += 1; + const name = source.slice(start, cursor); + splitQualifiedName(name); + return [name, cursor]; +} + +function skipWhitespace(source: string, start: number): number { + let cursor = start; + while (cursor < source.length && /[\t\n\r ]/u.test(source[cursor]!)) cursor += 1; + return cursor; +} + +function isXmlScalar(codePoint: number): boolean { + return ( + codePoint === 0x09 || + codePoint === 0x0a || + codePoint === 0x0d || + (codePoint >= 0x20 && codePoint <= 0xd7ff) || + (codePoint >= 0xe000 && codePoint <= 0xfffd) || + (codePoint >= 0x10000 && codePoint <= 0x10ffff) + ); +} + +const NAMED_ENTITIES: Readonly> = Object.freeze({ + amp: '&', + apos: "'", + gt: '>', + lt: '<', + quot: '"', +}); + +function decodeEntities(source: string): string { + if (!source.includes('&')) return source; + let output = ''; + let cursor = 0; + while (cursor < source.length) { + const ampersand = source.indexOf('&', cursor); + if (ampersand < 0) { + output += source.slice(cursor); + break; + } + output += source.slice(cursor, ampersand); + const semicolon = source.indexOf(';', ampersand + 1); + if (semicolon < 0 || semicolon - ampersand > 16) { + throw new DocxImportError('invalid_xml'); + } + const entity = source.slice(ampersand + 1, semicolon); + if (Object.prototype.hasOwnProperty.call(NAMED_ENTITIES, entity)) { + output += NAMED_ENTITIES[entity]!; + } else { + const hexadecimal = entity.startsWith('#x') || entity.startsWith('#X'); + const decimal = entity.startsWith('#') && !hexadecimal; + if (!hexadecimal && !decimal) throw new DocxImportError('invalid_xml'); + const digits = entity.slice(hexadecimal ? 2 : 1); + if ( + digits.length === 0 || + !(hexadecimal ? /^[0-9A-Fa-f]+$/u : /^[0-9]+$/u).test(digits) + ) { + throw new DocxImportError('invalid_xml'); + } + const codePoint = Number.parseInt(digits, hexadecimal ? 16 : 10); + if (!Number.isSafeInteger(codePoint) || !isXmlScalar(codePoint)) { + throw new DocxImportError('invalid_xml'); + } + output += String.fromCodePoint(codePoint); + } + cursor = semicolon + 1; + } + return output; +} + +function appendNode( + parent: MutableXmlElement | undefined, + roots: MutableXmlElement[], + node: MutableXmlElement | string, + state: { count: number }, + limits: Readonly, +): void { + state.count += 1; + if (state.count > limits.maxXmlNodes) { + throw new DocxImportError('archive_limit_exceeded'); + } + if (typeof node === 'string') { + if (parent) parent.children.push(node); + else if (node.trim().length > 0) throw new DocxImportError('invalid_xml'); + return; + } + if (parent) parent.children.push(node); + else roots.push(node); +} + +function lookupNamespace( + scope: NamespaceScope | undefined, + prefix: string, +): string | undefined { + for (let current = scope; current; current = current.parent) { + if (current.declarations.has(prefix)) return current.declarations.get(prefix); + } + return prefix === 'xml' ? XML_NAMESPACE : undefined; +} + +function resolveNamespaces( + name: string, + attributes: ReadonlyMap, + parent: MutableXmlElement | undefined, +): { + readonly namespaceUri?: string; + readonly attributeNamespaces: Map; + readonly namespaces: NamespaceScope; +} { + const declarations = new Map(); + for (const [attributeName, value] of attributes) { + if (attributeName === 'xmlns') { + if (value === XML_NAMESPACE || value === XMLNS_NAMESPACE) { + throw new DocxImportError('invalid_xml'); + } + declarations.set('', value.length === 0 ? undefined : value); + continue; + } + if (!attributeName.startsWith('xmlns:')) continue; + const prefix = attributeName.slice(6); + if ( + prefix.length === 0 || + prefix === 'xmlns' || + (prefix === 'xml' && value !== XML_NAMESPACE) || + (prefix !== 'xml' && + (value.length === 0 || value === XML_NAMESPACE || value === XMLNS_NAMESPACE)) + ) { + throw new DocxImportError('invalid_xml'); + } + declarations.set(prefix, value); + } + const namespaces: NamespaceScope = { + ...(parent ? { parent: parent.namespaces } : {}), + declarations, + }; + + const [prefix] = splitQualifiedName(name); + const namespaceUri = lookupNamespace(namespaces, prefix); + if (prefix.length > 0 && namespaceUri === undefined) { + throw new DocxImportError('invalid_xml'); + } + + const attributeNamespaces = new Map(); + for (const attributeName of attributes.keys()) { + if (attributeName === 'xmlns' || attributeName.startsWith('xmlns:')) { + attributeNamespaces.set(attributeName, XMLNS_NAMESPACE); + continue; + } + const [attributePrefix] = splitQualifiedName(attributeName); + const attributeNamespace = + attributePrefix.length > 0 + ? lookupNamespace(namespaces, attributePrefix) + : undefined; + if (attributePrefix.length > 0 && attributeNamespace === undefined) { + throw new DocxImportError('invalid_xml'); + } + attributeNamespaces.set(attributeName, attributeNamespace); + } + return { namespaceUri, attributeNamespaces, namespaces }; +} + +function asXmlElement(node: MutableXmlElement): XmlElement { + return node as XmlElement; +} + +/** Parse one strict, DTD-free, bounded UTF-8 XML part. */ +export function parseXml( + bytes: Uint8Array, + limits: Readonly, +): XmlElement { + if (bytes.byteLength === 0 || bytes.byteLength > limits.maxXmlBytes) { + throw new DocxImportError('archive_limit_exceeded'); + } + let source: string; + try { + source = TEXT_DECODER_DECODE.call(XML_UTF8_DECODER, bytes); + } catch (error) { + throw normalizeDocxImportError(error, 'invalid_xml'); + } + for (const character of source) { + if (!isXmlScalar(character.codePointAt(0)!)) { + throw new DocxImportError('invalid_xml'); + } + } + + const roots: MutableXmlElement[] = []; + const stack: MutableXmlElement[] = []; + const state = { count: 0 }; + let cursor = 0; + while (cursor < source.length) { + const opening = source.indexOf('<', cursor); + const textEnd = opening < 0 ? source.length : opening; + if (textEnd > cursor) { + const text = decodeEntities(source.slice(cursor, textEnd)); + if (text.length > 0) appendNode(stack[stack.length - 1], roots, text, state, limits); + } + if (opening < 0) break; + cursor = opening; + + if (source.startsWith('', cursor + 4); + if (end < 0 || source.slice(cursor + 4, end).includes('--')) { + throw new DocxImportError('invalid_xml'); + } + cursor = end + 3; + continue; + } + if (source.startsWith('', cursor + 2); + if (end < 0) throw new DocxImportError('invalid_xml'); + cursor = end + 2; + continue; + } + if (source.startsWith('') throw new DocxImportError('invalid_xml'); + const current = stack.pop(); + if (!current || current.name !== name) throw new DocxImportError('invalid_xml'); + cursor = endCursor + 1; + continue; + } + + const [name, afterName] = readName(source, cursor + 1); + const attributes = new Map(); + let tagCursor = afterName; + let selfClosing = false; + for (;;) { + tagCursor = skipWhitespace(source, tagCursor); + if (source.startsWith('/>', tagCursor)) { + selfClosing = true; + tagCursor += 2; + break; + } + if (source[tagCursor] === '>') { + tagCursor += 1; + break; + } + const [attributeName, afterAttributeName] = readName(source, tagCursor); + if (attributes.has(attributeName)) throw new DocxImportError('invalid_xml'); + let attributeCursor = skipWhitespace(source, afterAttributeName); + if (source[attributeCursor] !== '=') throw new DocxImportError('invalid_xml'); + attributeCursor = skipWhitespace(source, attributeCursor + 1); + const quote = source[attributeCursor]; + if (quote !== '"' && quote !== "'") throw new DocxImportError('invalid_xml'); + const valueStart = attributeCursor + 1; + const valueEnd = source.indexOf(quote, valueStart); + if (valueEnd < 0 || source.slice(valueStart, valueEnd).includes('<')) { + throw new DocxImportError('invalid_xml'); + } + attributes.set(attributeName, decodeEntities(source.slice(valueStart, valueEnd))); + tagCursor = valueEnd + 1; + } + const namespaceState = resolveNamespaces(name, attributes, stack[stack.length - 1]); + const node: MutableXmlElement = { + name, + localName: localName(name), + ...(namespaceState.namespaceUri ? { namespaceUri: namespaceState.namespaceUri } : {}), + attributes, + attributeNamespaces: namespaceState.attributeNamespaces, + children: [], + namespaces: namespaceState.namespaces, + }; + appendNode(stack[stack.length - 1], roots, node, state, limits); + if (!selfClosing) { + stack.push(node); + if (stack.length > limits.maxXmlDepth) { + throw new DocxImportError('archive_limit_exceeded'); + } + } + cursor = tagCursor; + } + if (stack.length !== 0 || roots.length !== 1) { + throw new DocxImportError('invalid_xml'); + } + return asXmlElement(roots[0]!); +} + +/** Return direct element children matching one local name and optional namespace. */ +export function childElements( + node: XmlElement, + wantedLocalName?: string, + namespaceUri?: string, +): XmlElement[] { + return node.children.filter( + (child): child is XmlElement => + typeof child !== 'string' && + (wantedLocalName === undefined || child.localName === wantedLocalName) && + (namespaceUri === undefined || child.namespaceUri === namespaceUri), + ); +} + +/** Return all descendant elements matching one local name and namespace. */ +export function descendantElements( + node: XmlElement, + wantedLocalName: string, + namespaceUri?: string, +): XmlElement[] { + const result: XmlElement[] = []; + const stack = [...childElements(node)].reverse(); + while (stack.length > 0) { + const current = stack.pop()!; + if ( + current.localName === wantedLocalName && + (namespaceUri === undefined || current.namespaceUri === namespaceUri) + ) { + result.push(current); + } + const children = childElements(current); + for (let index = children.length - 1; index >= 0; index -= 1) { + stack.push(children[index]!); + } + } + return result; +} + +/** Read one unambiguous attribute by local name and optional namespace. */ +export function attribute( + node: XmlElement, + wantedLocalName: string, + namespaceUri?: string | null, +): string | undefined { + let found = false; + let value: string | undefined; + for (const [name, candidate] of node.attributes) { + if (name === 'xmlns' || name.startsWith('xmlns:')) continue; + if (localName(name) !== wantedLocalName) continue; + if ( + namespaceUri !== undefined && + node.attributeNamespaces.get(name) !== (namespaceUri ?? undefined) + ) { + continue; + } + if (found) throw new DocxImportError('invalid_docx'); + found = true; + value = candidate; + } + return value; +} + +/** Concatenate direct text children without trimming authored text. */ +export function directText(node: XmlElement): string { + return node.children + .filter((child): child is string => typeof child === 'string') + .join(''); +} diff --git a/src/docx/zip.ts b/src/docx/zip.ts new file mode 100644 index 00000000..b6dff405 --- /dev/null +++ b/src/docx/zip.ts @@ -0,0 +1,386 @@ +import { DocxImportError, normalizeDocxImportError } from './errors.js'; +import type { DocxImportLimits } from './types.js'; + +const EOCD_SIGNATURE = 0x06054b50; +const CENTRAL_SIGNATURE = 0x02014b50; +const LOCAL_SIGNATURE = 0x04034b50; +const MAX_EOCD_SEARCH_BYTES = 65_557; +const UTF8_FLAG = 0x0800; +const DATA_DESCRIPTOR_FLAG = 0x0008; +const ENCRYPTION_FLAGS = 0x2041; +const SUPPORTED_FLAGS = UTF8_FLAG | DATA_DESCRIPTOR_FLAG; +const UTF8_ENTRY_NAME_DECODER = new TextDecoder('utf-8', { fatal: true }); +const ASCII_ENTRY_NAME_DECODER = new TextDecoder('ascii', { fatal: true }); +const TEXT_DECODER_DECODE = TextDecoder.prototype.decode; +const DECOMPRESSION_STREAM_CONSTRUCTOR: typeof DecompressionStream | undefined = + typeof DecompressionStream === 'undefined' ? undefined : DecompressionStream; +const READABLE_STREAM_CONSTRUCTOR: typeof ReadableStream | undefined = + typeof ReadableStream === 'undefined' ? undefined : ReadableStream; +const READABLE_STREAM_PIPE_THROUGH = + READABLE_STREAM_CONSTRUCTOR === undefined + ? undefined + : READABLE_STREAM_CONSTRUCTOR.prototype.pipeThrough; +const UINT8_ARRAY_CONSTRUCTOR = Uint8Array; +const UINT8_ARRAY_FROM = Uint8Array.from; + +interface ZipEntry { + readonly name: string; + readonly flags: number; + readonly method: number; + readonly crc32: number; + readonly compressedSize: number; + readonly uncompressedSize: number; + readonly localHeaderOffset: number; +} + +/** @internal Read one little-endian unsigned 16-bit integer after a bounds check. */ +export function readUint16(bytes: Uint8Array, offset: number): number { + if (offset < 0 || offset + 2 > bytes.byteLength) { + throw new DocxImportError('invalid_zip'); + } + return bytes[offset]! | (bytes[offset + 1]! << 8); +} + +/** @internal Read one little-endian unsigned 32-bit integer after a bounds check. */ +export function readUint32(bytes: Uint8Array, offset: number): number { + if (offset < 0 || offset + 4 > bytes.byteLength) { + throw new DocxImportError('invalid_zip'); + } + return ( + bytes[offset]! | + (bytes[offset + 1]! << 8) | + (bytes[offset + 2]! << 16) | + (bytes[offset + 3]! << 24) + ) >>> 0; +} + +function decodeEntryName(nameBytes: Uint8Array, flags: number): string { + if (nameBytes.byteLength === 0) throw new DocxImportError('invalid_zip'); + try { + if ((flags & UTF8_FLAG) !== 0) { + return TEXT_DECODER_DECODE.call(UTF8_ENTRY_NAME_DECODER, nameBytes); + } + for (const byte of nameBytes) { + if (byte < 0x20 || byte > 0x7e) { + throw new DocxImportError('unsupported_archive'); + } + } + return TEXT_DECODER_DECODE.call(ASCII_ENTRY_NAME_DECODER, nameBytes); + } catch (error) { + throw normalizeDocxImportError(error, 'invalid_zip'); + } +} + +function validateEntryName(name: string): boolean { + if ( + name.length === 0 || + name.includes('\\') || + name.includes('\0') || + /[\u0000-\u001f\u007f]/u.test(name) || + name.startsWith('/') || + /^[A-Za-z]:/u.test(name) + ) { + throw new DocxImportError('invalid_zip'); + } + const directory = name.endsWith('/'); + const segments = name.split('/'); + if (directory) segments.pop(); + if ( + segments.length === 0 || + segments.some((segment) => segment.length === 0 || segment === '.' || segment === '..') + ) { + throw new DocxImportError('invalid_zip'); + } + return directory; +} + +function findEndOfCentralDirectory(bytes: Uint8Array): number { + if (bytes.byteLength < 22) throw new DocxImportError('invalid_zip'); + const minimum = Math.max(0, bytes.byteLength - MAX_EOCD_SEARCH_BYTES); + for (let offset = bytes.byteLength - 22; offset >= minimum; offset -= 1) { + if (readUint32(bytes, offset) !== EOCD_SIGNATURE) continue; + const commentLength = readUint16(bytes, offset + 20); + if (offset + 22 + commentLength === bytes.byteLength) return offset; + } + throw new DocxImportError('invalid_zip'); +} + +/** Compute the standard ZIP CRC-32 without mutable global tables. */ +export function crc32(bytes: Uint8Array): number { + let crc = 0xffffffff; + for (const byte of bytes) { + crc ^= byte; + for (let bit = 0; bit < 8; bit += 1) { + crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0); + } + } + return (crc ^ 0xffffffff) >>> 0; +} + +async function inflateRaw( + compressed: Uint8Array, + expectedBytes: number, +): Promise { + if ( + DECOMPRESSION_STREAM_CONSTRUCTOR === undefined || + READABLE_STREAM_CONSTRUCTOR === undefined || + READABLE_STREAM_PIPE_THROUGH === undefined + ) { + throw new DocxImportError('decompression_unavailable'); + } + let transform: DecompressionStream; + try { + transform = new DECOMPRESSION_STREAM_CONSTRUCTOR('deflate-raw'); + } catch { + throw new DocxImportError('decompression_unavailable'); + } + const input = new READABLE_STREAM_CONSTRUCTOR({ + start(controller) { + controller.enqueue( + UINT8_ARRAY_FROM.call(UINT8_ARRAY_CONSTRUCTOR, compressed), + ); + controller.close(); + }, + }); + const decompressed = READABLE_STREAM_PIPE_THROUGH.call( + input, + transform, + ) as ReadableStream; + const reader = decompressed.getReader(); + const output = new Uint8Array(expectedBytes); + let offset = 0; + try { + for (;;) { + const result = await reader.read(); + if (result.done) break; + const chunk = result.value; + if (offset + chunk.byteLength > expectedBytes) { + throw new DocxImportError('invalid_zip'); + } + output.set(chunk, offset); + offset += chunk.byteLength; + } + } catch (error) { + throw normalizeDocxImportError(error, 'invalid_zip'); + } finally { + reader.releaseLock(); + } + if (offset !== expectedBytes) throw new DocxImportError('invalid_zip'); + return output; +} + +/** Bounded random-access reader for the entries of one validated ZIP archive. */ +export class ZipArchive { + readonly #bytes: Uint8Array; + readonly #entries: ReadonlyMap; + readonly #centralDirectoryOffset: number; + readonly #cache = new Map>(); + + private constructor( + bytes: Uint8Array, + entries: ReadonlyMap, + centralDirectoryOffset: number, + ) { + this.#bytes = bytes; + this.#entries = entries; + this.#centralDirectoryOffset = centralDirectoryOffset; + } + + /** Parse and validate one complete single-disk non-Zip64 archive. */ + static parse( + bytes: Uint8Array, + limits: Readonly, + ): ZipArchive { + const eocdOffset = findEndOfCentralDirectory(bytes); + const diskNumber = readUint16(bytes, eocdOffset + 4); + const centralDisk = readUint16(bytes, eocdOffset + 6); + const entriesOnDisk = readUint16(bytes, eocdOffset + 8); + const entryCount = readUint16(bytes, eocdOffset + 10); + const centralSize = readUint32(bytes, eocdOffset + 12); + const centralOffset = readUint32(bytes, eocdOffset + 16); + if (diskNumber !== 0 || centralDisk !== 0 || entriesOnDisk !== entryCount) { + throw new DocxImportError('unsupported_archive'); + } + if ( + entryCount === 0xffff || + centralSize === 0xffffffff || + centralOffset === 0xffffffff + ) { + throw new DocxImportError('unsupported_archive'); + } + if (entryCount < 1 || entryCount > limits.maxEntries) { + throw new DocxImportError('archive_limit_exceeded'); + } + if ( + centralOffset > eocdOffset || + centralSize > eocdOffset - centralOffset + ) { + throw new DocxImportError('invalid_zip'); + } + + const entries = new Map(); + let totalUncompressedBytes = 0; + let cursor = centralOffset; + const centralEnd = centralOffset + centralSize; + for (let index = 0; index < entryCount; index += 1) { + if (cursor + 46 > centralEnd || readUint32(bytes, cursor) !== CENTRAL_SIGNATURE) { + throw new DocxImportError('invalid_zip'); + } + const flags = readUint16(bytes, cursor + 8); + const method = readUint16(bytes, cursor + 10); + const checksum = readUint32(bytes, cursor + 16); + const compressedSize = readUint32(bytes, cursor + 20); + const uncompressedSize = readUint32(bytes, cursor + 24); + const nameLength = readUint16(bytes, cursor + 28); + const extraLength = readUint16(bytes, cursor + 30); + const commentLength = readUint16(bytes, cursor + 32); + const diskStart = readUint16(bytes, cursor + 34); + const localHeaderOffset = readUint32(bytes, cursor + 42); + const recordLength = 46 + nameLength + extraLength + commentLength; + if (cursor + recordLength > centralEnd) { + throw new DocxImportError('invalid_zip'); + } + if ((flags & ENCRYPTION_FLAGS) !== 0) { + throw new DocxImportError('encrypted_archive'); + } + if ((flags & ~SUPPORTED_FLAGS) !== 0) { + throw new DocxImportError('unsupported_archive'); + } + if (method !== 0 && method !== 8) { + throw new DocxImportError('unsupported_archive'); + } + if ( + compressedSize === 0xffffffff || + uncompressedSize === 0xffffffff || + localHeaderOffset === 0xffffffff || + diskStart === 0xffff + ) { + throw new DocxImportError('unsupported_archive'); + } + if (diskStart !== 0) throw new DocxImportError('unsupported_archive'); + if ( + compressedSize > limits.maxArchiveBytes || + uncompressedSize > limits.maxEntryBytes + ) { + throw new DocxImportError('archive_limit_exceeded'); + } + if ( + (compressedSize === 0 && uncompressedSize !== 0) || + (compressedSize > 0 && + uncompressedSize > compressedSize * limits.maxCompressionRatio) + ) { + throw new DocxImportError('archive_limit_exceeded'); + } + totalUncompressedBytes += uncompressedSize; + if (totalUncompressedBytes > limits.maxTotalUncompressedBytes) { + throw new DocxImportError('archive_limit_exceeded'); + } + const nameStart = cursor + 46; + const name = decodeEntryName( + bytes.subarray(nameStart, nameStart + nameLength), + flags, + ); + const isDirectory = validateEntryName(name); + if (!isDirectory) { + if (entries.has(name)) throw new DocxImportError('invalid_zip'); + entries.set( + name, + Object.freeze({ + name, + flags, + method, + crc32: checksum, + compressedSize, + uncompressedSize, + localHeaderOffset, + }), + ); + } + cursor += recordLength; + } + if (cursor !== centralEnd || entries.size === 0) { + throw new DocxImportError('invalid_zip'); + } + return new ZipArchive(bytes, entries, centralOffset); + } + + /** Return whether one exact normalized package path exists. */ + has(name: string): boolean { + return this.#entries.has(name); + } + + /** Return the declared uncompressed byte length for one exact entry. */ + size(name: string): number | undefined { + return this.#entries.get(name)?.uncompressedSize; + } + + /** Read, decompress, size-check, and checksum one exact package entry once. */ + read(name: string): Promise { + const cached = this.#cache.get(name); + if (cached) return cached; + const entry = this.#entries.get(name); + if (!entry) return Promise.reject(new DocxImportError('invalid_docx')); + const pending = this.#readEntry(entry).catch((error: unknown) => { + this.#cache.delete(name); + throw error; + }); + this.#cache.set(name, pending); + return pending; + } + + async #readEntry(entry: ZipEntry): Promise { + const offset = entry.localHeaderOffset; + if ( + offset + 30 > this.#centralDirectoryOffset || + readUint32(this.#bytes, offset) !== LOCAL_SIGNATURE + ) { + throw new DocxImportError('invalid_zip'); + } + const flags = readUint16(this.#bytes, offset + 6); + const method = readUint16(this.#bytes, offset + 8); + const localChecksum = readUint32(this.#bytes, offset + 14); + const localCompressedSize = readUint32(this.#bytes, offset + 18); + const localUncompressedSize = readUint32(this.#bytes, offset + 22); + const nameLength = readUint16(this.#bytes, offset + 26); + const extraLength = readUint16(this.#bytes, offset + 28); + if (flags !== entry.flags || method !== entry.method) { + throw new DocxImportError('invalid_zip'); + } + if ( + (flags & DATA_DESCRIPTOR_FLAG) === 0 && + (localChecksum !== entry.crc32 || + localCompressedSize !== entry.compressedSize || + localUncompressedSize !== entry.uncompressedSize) + ) { + throw new DocxImportError('invalid_zip'); + } + const nameStart = offset + 30; + const dataStart = nameStart + nameLength + extraLength; + if ( + dataStart > this.#centralDirectoryOffset || + entry.compressedSize > this.#centralDirectoryOffset - dataStart + ) { + throw new DocxImportError('invalid_zip'); + } + const localName = decodeEntryName( + this.#bytes.subarray(nameStart, nameStart + nameLength), + flags, + ); + if (localName !== entry.name) throw new DocxImportError('invalid_zip'); + const compressed = this.#bytes.subarray( + dataStart, + dataStart + entry.compressedSize, + ); + const output = + entry.method === 0 + ? compressed.slice() + : await inflateRaw(compressed, entry.uncompressedSize); + if ( + output.byteLength !== entry.uncompressedSize || + crc32(output) !== entry.crc32 + ) { + throw new DocxImportError('invalid_zip'); + } + return output; + } +} diff --git a/src/docx/zipDecompressionCapability.test.ts b/src/docx/zipDecompressionCapability.test.ts new file mode 100644 index 00000000..1c1ab553 --- /dev/null +++ b/src/docx/zipDecompressionCapability.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { buildZip } from '../../test/docxFixture.js'; +import { DEFAULT_DOCX_IMPORT_LIMITS } from './limits.js'; +import { ZipArchive } from './zip.js'; + +describe('DOCX ZIP decompression capability isolation', () => { + it('does not let later global stream replacement redirect deflate reads', async () => { + expect(typeof DecompressionStream).toBe('function'); + expect(typeof ReadableStream).toBe('function'); + + let hostileDecompressionCalls = 0; + let hostileReadableCalls = 0; + + class HostileDecompressionStream { + constructor() { + hostileDecompressionCalls += 1; + throw new Error('private decompression sentinel'); + } + } + class HostileReadableStream { + constructor() { + hostileReadableCalls += 1; + throw new Error('private readable sentinel'); + } + } + + vi.stubGlobal('DecompressionStream', HostileDecompressionStream); + vi.stubGlobal('ReadableStream', HostileReadableStream); + + try { + const archive = ZipArchive.parse( + buildZip({ 'compressed.txt': 'trusted compressed payload' }, 8), + DEFAULT_DOCX_IMPORT_LIMITS, + ); + await expect( + archive.read('compressed.txt').then((bytes) => new TextDecoder().decode(bytes)), + ).resolves.toBe('trusted compressed payload'); + expect(hostileDecompressionCalls).toBe(0); + expect(hostileReadableCalls).toBe(0); + } finally { + vi.unstubAllGlobals(); + } + }); + + it('does not let later Uint8Array.from replacement redirect deflate input copies', async () => { + const archiveBytes = buildZip( + { 'compressed.txt': 'trusted compressed payload' }, + 8, + ); + const fromDescriptor = Object.getOwnPropertyDescriptor(Uint8Array, 'from'); + let hostileFromCalls = 0; + + Object.defineProperty(Uint8Array, 'from', { + configurable: true, + writable: true, + value() { + hostileFromCalls += 1; + throw new Error('private Uint8Array.from sentinel'); + }, + }); + + try { + const archive = ZipArchive.parse(archiveBytes, DEFAULT_DOCX_IMPORT_LIMITS); + await expect( + archive.read('compressed.txt').then((bytes) => new TextDecoder().decode(bytes)), + ).resolves.toBe('trusted compressed payload'); + expect(hostileFromCalls).toBe(0); + } finally { + if (fromDescriptor === undefined) { + Reflect.deleteProperty(Uint8Array, 'from'); + } else { + Object.defineProperty(Uint8Array, 'from', fromDescriptor); + } + } + }); + + it('does not let later ReadableStream.pipeThrough replacement redirect deflate reads', async () => { + const archiveBytes = buildZip( + { 'compressed.txt': 'trusted compressed payload' }, + 8, + ); + const pipeThroughDescriptor = Object.getOwnPropertyDescriptor( + ReadableStream.prototype, + 'pipeThrough', + ); + expect(pipeThroughDescriptor).toBeDefined(); + let hostilePipeThroughCalls = 0; + + Object.defineProperty(ReadableStream.prototype, 'pipeThrough', { + configurable: true, + writable: true, + value() { + hostilePipeThroughCalls += 1; + throw new Error('private pipeThrough sentinel'); + }, + }); + + try { + const archive = ZipArchive.parse(archiveBytes, DEFAULT_DOCX_IMPORT_LIMITS); + await expect( + archive.read('compressed.txt').then((bytes) => new TextDecoder().decode(bytes)), + ).resolves.toBe('trusted compressed payload'); + expect(hostilePipeThroughCalls).toBe(0); + } finally { + Object.defineProperty( + ReadableStream.prototype, + 'pipeThrough', + pipeThroughDescriptor!, + ); + } + }); +}); diff --git a/src/docxPackage.test.ts b/src/docxPackage.test.ts new file mode 100644 index 00000000..0ece5a2a --- /dev/null +++ b/src/docxPackage.test.ts @@ -0,0 +1,79 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; +import { importDocx, openDocx } from './docx/index.js'; + +function repositoryFile(path: string): string { + return readFileSync(resolve(process.cwd(), path), 'utf8'); +} + +const packageMetadata = JSON.parse(repositoryFile('package.json')) as { + exports: Record; + scripts: Record; +}; + +describe('bounded DOCX import package contract', () => { + it('exposes the intended source barrel without host-owned authority', () => { + expect(typeof importDocx).toBe('function'); + expect(typeof openDocx).toBe('function'); + }); + + it('declares one independently built ESM CommonJS and TypeScript subpath', () => { + expect(packageMetadata.exports['./docx']).toEqual({ + types: './dist/docx/index.d.ts', + import: './dist/cwl-docx.js', + require: './dist/cwl-docx.cjs', + }); + expect(packageMetadata.scripts.build).toContain( + 'vite build --config vite.docx.config.ts', + ); + expect(packageMetadata.scripts['verify:package']).toContain( + 'verify-docx-subpath-package.mjs', + ); + expect(existsSync(resolve(process.cwd(), 'vite.docx.config.ts'))).toBe(true); + expect( + existsSync(resolve(process.cwd(), 'scripts/verify-docx-subpath-package.mjs')), + ).toBe(true); + }); + + it('keeps the standalone subpath free of host transport, persistence, credential, model, and UI dependencies', () => { + const sourceFiles = [ + 'src/docx/index.ts', + 'src/docx/importDocx.ts', + 'src/docx/ooxml.ts', + 'src/docx/ooxmlHeading.ts', + 'src/docx/ooxmlManifest.ts', + 'src/docx/ooxmlNumberFormats.ts', + 'src/docx/ooxmlNumbering.ts', + 'src/docx/ooxmlPackage.ts', + 'src/docx/ooxmlShared.ts', + 'src/docx/ooxmlStyles.ts', + 'src/docx/xml.ts', + 'src/docx/zip.ts', + ]; + const forbidden = [ + /\bfetch\s*\(/u, + /\bXMLHttpRequest\b/u, + /\bWebSocket\b/u, + /\bprocess\.env\b/u, + /\bimport\.meta\.env\b/u, + /\bindexedDB\b/u, + /\blocalStorage\b/u, + /\bsessionStorage\b/u, + /\bReact\b/u, + /@tiptap/u, + /\byjs\b/u, + /\bnaruon\b/iu, + /\borchestrator\b/iu, + /\bopenai\b/iu, + /\banthropic\b/iu, + ]; + for (const path of sourceFiles) { + const source = repositoryFile(path); + for (const pattern of forbidden) { + expect(source, `${path} must not match ${pattern}`).not.toMatch(pattern); + } + } + }); +}); diff --git a/test/docxFixture.ts b/test/docxFixture.ts new file mode 100644 index 00000000..ade60be0 --- /dev/null +++ b/test/docxFixture.ts @@ -0,0 +1,209 @@ +import { deflateRawSync } from 'node:zlib'; + +export const WORD_NAMESPACES = [ + 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"', + 'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"', + 'xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"', + 'xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"', +].join(' '); + +export const PNG_BYTES = new Uint8Array([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, + 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, +]); + +export interface ZipEntryInput { + readonly data: string | Uint8Array; + readonly method?: 0 | 8; + readonly flags?: number; + readonly localName?: string; + readonly centralName?: string; +} + +export interface DocxFixtureOptions { + readonly body?: string; + readonly contentTypes?: string | false; + readonly document?: string | false; + readonly relationships?: string | false; + readonly styles?: string | false; + readonly numbering?: string | false; + readonly media?: Readonly>; + readonly extraEntries?: Readonly< + Record + >; + readonly method?: 0 | 8; +} + +function uint16(value: number): Uint8Array { + const bytes = new Uint8Array(2); + new DataView(bytes.buffer).setUint16(0, value, true); + return bytes; +} + +function uint32(value: number): Uint8Array { + const bytes = new Uint8Array(4); + new DataView(bytes.buffer).setUint32(0, value >>> 0, true); + return bytes; +} + +function concatenate(chunks: readonly Uint8Array[]): Uint8Array { + const result = new Uint8Array( + chunks.reduce((total, chunk) => total + chunk.byteLength, 0), + ); + let offset = 0; + for (const chunk of chunks) { + result.set(chunk, offset); + offset += chunk.byteLength; + } + return result; +} + +export function fixtureCrc32(bytes: Uint8Array): number { + let crc = 0xffffffff; + for (const byte of bytes) { + crc ^= byte; + for (let bit = 0; bit < 8; bit += 1) { + crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0); + } + } + return (crc ^ 0xffffffff) >>> 0; +} + +export function buildZip( + entries: Readonly>, + defaultMethod: 0 | 8 = 8, +): Uint8Array { + const localRecords: Uint8Array[] = []; + const centralRecords: Uint8Array[] = []; + let localOffset = 0; + for (const [logicalName, rawInput] of Object.entries(entries)) { + const input: ZipEntryInput = + typeof rawInput === 'string' || rawInput instanceof Uint8Array + ? { data: rawInput } + : rawInput; + const flags = input.flags ?? 0x0800; + const method = input.method ?? defaultMethod; + const localName = new TextEncoder().encode(input.localName ?? logicalName); + const centralName = new TextEncoder().encode(input.centralName ?? logicalName); + const raw = + typeof input.data === 'string' + ? new TextEncoder().encode(input.data) + : input.data; + const compressed = + method === 8 ? new Uint8Array(deflateRawSync(raw)) : raw.slice(); + const checksum = fixtureCrc32(raw); + const local = concatenate([ + uint32(0x04034b50), + uint16(20), + uint16(flags), + uint16(method), + uint16(0), + uint16(0), + uint32(checksum), + uint32(compressed.byteLength), + uint32(raw.byteLength), + uint16(localName.byteLength), + uint16(0), + localName, + compressed, + ]); + const central = concatenate([ + uint32(0x02014b50), + uint16(20), + uint16(20), + uint16(flags), + uint16(method), + uint16(0), + uint16(0), + uint32(checksum), + uint32(compressed.byteLength), + uint32(raw.byteLength), + uint16(centralName.byteLength), + uint16(0), + uint16(0), + uint16(0), + uint16(0), + uint32(0), + uint32(localOffset), + centralName, + ]); + localRecords.push(local); + centralRecords.push(central); + localOffset += local.byteLength; + } + const centralDirectory = concatenate(centralRecords); + const end = concatenate([ + uint32(0x06054b50), + uint16(0), + uint16(0), + uint16(centralRecords.length), + uint16(centralRecords.length), + uint32(centralDirectory.byteLength), + uint32(localOffset), + uint16(0), + ]); + return concatenate([...localRecords, centralDirectory, end]); +} + +export function createDocx(options: DocxFixtureOptions = {}): Uint8Array { + const contentTypes = + options.contentTypes === false + ? undefined + : options.contentTypes ?? + ''; + const document = + options.document === false + ? undefined + : options.document ?? + `${options.body ?? 'Hello'}`; + const entries: Record = {}; + if (contentTypes !== undefined) entries['[Content_Types].xml'] = contentTypes; + if (document !== undefined) entries['word/document.xml'] = document; + if (options.relationships !== false && options.relationships !== undefined) { + entries['word/_rels/document.xml.rels'] = options.relationships; + } + if (options.styles !== false && options.styles !== undefined) { + entries['word/styles.xml'] = options.styles; + } + if (options.numbering !== false && options.numbering !== undefined) { + entries['word/numbering.xml'] = options.numbering; + } + for (const [path, bytes] of Object.entries(options.media ?? {})) { + entries[path] = bytes; + } + Object.assign(entries, options.extraEntries ?? {}); + return buildZip(entries, options.method ?? 8); +} + +export function findSignature( + bytes: Uint8Array, + signature: number, + from = 0, +): number { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + for (let offset = from; offset + 4 <= bytes.byteLength; offset += 1) { + if (view.getUint32(offset, true) === signature) return offset; + } + return -1; +} + +export function patchUint16( + source: Uint8Array, + offset: number, + value: number, +): Uint8Array { + const result = source.slice(); + new DataView(result.buffer).setUint16(offset, value, true); + return result; +} + +export function patchUint32( + source: Uint8Array, + offset: number, + value: number, +): Uint8Array { + const result = source.slice(); + new DataView(result.buffer).setUint32(offset, value >>> 0, true); + return result; +} diff --git a/test/docxTestSupport.ts b/test/docxTestSupport.ts new file mode 100644 index 00000000..a24dcf28 --- /dev/null +++ b/test/docxTestSupport.ts @@ -0,0 +1,26 @@ +import { expect } from 'vitest'; +import { + DocxImportError, + type DocxImportErrorCode, +} from '../src/docx/index.js'; + +export async function expectDocxCode( + operation: Promise, + code: DocxImportErrorCode, +): Promise { + try { + await operation; + throw new Error(`Expected ${code}`); + } catch (error) { + expect(error).toBeInstanceOf(DocxImportError); + expect(error).toMatchObject({ name: 'DocxImportError', code }); + expect((error as Error).message).not.toContain('Hello'); + } +} + +export function exactArrayBuffer(bytes: Uint8Array): ArrayBuffer { + return bytes.buffer.slice( + bytes.byteOffset, + bytes.byteOffset + bytes.byteLength, + ) as ArrayBuffer; +} diff --git a/vite.docx.config.ts b/vite.docx.config.ts new file mode 100644 index 00000000..32ce881a --- /dev/null +++ b/vite.docx.config.ts @@ -0,0 +1,27 @@ +import { resolve } from 'node:path'; +import { defineConfig } from 'vite'; +import dts from 'vite-plugin-dts'; + +// Standalone DOCX importer build: deterministic local parsing with no React, +// TipTap, network, credential, persistence, host, or model runtime authority. +export default defineConfig({ + plugins: [ + dts({ + include: ['src/docx'], + exclude: ['src/**/*.test.ts', 'src/**/*.test.tsx', 'src/**/*.spec.ts'], + rollupTypes: false, + entryRoot: 'src', + }), + ], + build: { + emptyOutDir: false, + lib: { + entry: resolve(__dirname, 'src/docx/index.ts'), + name: 'InkspanDocx', + fileName: (format) => + format === 'es' ? 'cwl-docx.js' : 'cwl-docx.cjs', + formats: ['es', 'cjs'], + }, + sourcemap: true, + }, +});