From 990acdc6753b9249b99bd58bdac8dbbb348ce2d7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 22:58:21 +0900 Subject: [PATCH 001/102] test(docx): add deterministic DOCX fixture builder --- test/docxFixture.ts | 209 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100644 test/docxFixture.ts 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; +} From 08d2a5ed32d327c93c899c93f68d85c4e0fb1122 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 23:00:44 +0900 Subject: [PATCH 002/102] test(docx): share import contract assertions --- test/docxTestSupport.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 test/docxTestSupport.ts 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; +} From 24a12033525d6a1124c71527a1b9161522da1bab Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 23:01:24 +0900 Subject: [PATCH 003/102] test(docx): specify bounded open and import contract --- src/docx/importDocx.contract.test.ts | 126 +++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 src/docx/importDocx.contract.test.ts diff --git a/src/docx/importDocx.contract.test.ts b/src/docx/importDocx.contract.test.ts new file mode 100644 index 00000000..892a7852 --- /dev/null +++ b/src/docx/importDocx.contract.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + createDocx, + PNG_BYTES, + WORD_NAMESPACES, +} from '../../test/docxFixture.js'; +import { + DocxImportError, + importDocx, + openDocx, + type DocxJsonContent, +} from './index.js'; + +describe('DOCX open/import contract', () => { + it.each([ + ['ArrayBuffer', (bytes: Uint8Array) => bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)], + ['Uint8Array', (bytes: Uint8Array) => bytes], + ['Blob', (bytes: Uint8Array) => new Blob([bytes])], + ])('imports bounded document content from %s', async ( + _label: string, + source: (bytes: Uint8Array) => ArrayBuffer | Uint8Array | Blob, + ) => { + 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('validates the complete imported document before one atomic editor mutation', async () => { + const validateDocumentJson = vi.fn(() => ({ valid: true })); + const setDocumentJson = vi.fn(() => true); + 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] as DocxJsonContent; + 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(() => true); + const operation = openDocx( + { + validateDocumentJson: () => ({ valid: false, issues: [] }), + 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' }); + }); +}); From f173a438f9085fec3b8045ab30b9e43a9b7f202b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 23:46:22 +0900 Subject: [PATCH 004/102] feat(docx): add public import contracts --- src/docx/types.ts | 70 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 src/docx/types.ts 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; +} From 532232fd61085128aa3ca2d096d51d4bbb47e928 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 23:46:39 +0900 Subject: [PATCH 005/102] feat(docx): add redacted import failures --- src/docx/errors.ts | 61 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 src/docx/errors.ts diff --git a/src/docx/errors.ts b/src/docx/errors.ts new file mode 100644 index 00000000..ee1abf6f --- /dev/null +++ b/src/docx/errors.ts @@ -0,0 +1,61 @@ +/** 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.', + }); + +/** 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; + } +} + +/** Preserve one already-redacted error or replace an unknown failure. */ +export function normalizeDocxImportError( + error: unknown, + fallback: DocxImportErrorCode, +): DocxImportError { + return error instanceof DocxImportError + ? error + : new DocxImportError(fallback); +} From 6e194e275a7672f7a2d8dce3344ac9760768b76a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 23:51:28 +0900 Subject: [PATCH 006/102] feat(docx): enforce bounded import configuration --- src/docx/limits.ts | 89 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 src/docx/limits.ts diff --git a/src/docx/limits.ts b/src/docx/limits.ts new file mode 100644 index 00000000..b849b746 --- /dev/null +++ b/src/docx/limits.ts @@ -0,0 +1,89 @@ +import { DocxImportError } 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) { + if (error instanceof DocxImportError) throw error; + rejectConfiguration(); + } +} From 92f39bd1582d8422981f89d3fb736760d7a3fd22 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 23:52:33 +0900 Subject: [PATCH 007/102] feat(docx): expose framework-neutral import surface --- src/docx/index.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 src/docx/index.ts 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'; From 4f06b662372f89cafe8bc0f9cd13cf8a22170c4d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 23:54:04 +0900 Subject: [PATCH 008/102] feat(docx): add atomic import handoff --- src/docx/importDocx.ts | 81 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 src/docx/importDocx.ts diff --git a/src/docx/importDocx.ts b/src/docx/importDocx.ts new file mode 100644 index 00000000..75d8a71c --- /dev/null +++ b/src/docx/importDocx.ts @@ -0,0 +1,81 @@ +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'; + +/** Copy one accepted binary source into an immutable import snapshot. */ +async function snapshotSource( + source: DocxSource, + maxArchiveBytes: number, +): Promise { + try { + let view: Uint8Array; + if (source instanceof ArrayBuffer) { + view = new Uint8Array(source); + } else if (ArrayBuffer.isView(source) && source.buffer instanceof ArrayBuffer) { + view = new Uint8Array(source.buffer, source.byteOffset, source.byteLength); + } else if (typeof Blob !== 'undefined' && source instanceof Blob) { + if (source.size > maxArchiveBytes || typeof source.arrayBuffer !== 'function') { + throw new DocxImportError( + source.size > maxArchiveBytes ? 'input_too_large' : 'invalid_source', + ); + } + view = new Uint8Array(await source.arrayBuffer()); + } else { + throw new DocxImportError('invalid_source'); + } + 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 || + typeof target.validateDocumentJson !== 'function' || + typeof target.setDocumentJson !== 'function' + ) { + throw new DocxImportError('editor_rejected_document'); + } + if (target.validateDocumentJson(result.documentJson) !== true) { + throw new DocxImportError('incompatible_editor_schema'); + } + target.setDocumentJson(result.documentJson); + return result; + } catch (error) { + throw normalizeDocxImportError(error, 'editor_rejected_document'); + } +} From eebe8d86091d2583bc116c708b4b4b2e83f6c32b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 23:55:33 +0900 Subject: [PATCH 009/102] feat(docx): add bounded ZIP package reader --- src/docx/zip.ts | 366 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 366 insertions(+) create mode 100644 src/docx/zip.ts diff --git a/src/docx/zip.ts b/src/docx/zip.ts new file mode 100644 index 00000000..65bed491 --- /dev/null +++ b/src/docx/zip.ts @@ -0,0 +1,366 @@ +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; + +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 new TextDecoder('utf-8', { fatal: true }).decode(nameBytes); + } + for (const byte of nameBytes) { + if (byte < 0x20 || byte > 0x7e) { + throw new DocxImportError('unsupported_archive'); + } + } + return new TextDecoder('ascii', { fatal: true }).decode(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 ( + typeof DecompressionStream === 'undefined' || + typeof ReadableStream === 'undefined' + ) { + throw new DocxImportError('decompression_unavailable'); + } + let transform: DecompressionStream; + try { + transform = new DecompressionStream('deflate-raw'); + } catch { + throw new DocxImportError('decompression_unavailable'); + } + const input = new ReadableStream({ + start(controller) { + controller.enqueue(compressed); + controller.close(); + }, + }); + const reader = input.pipeThrough(transform).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; + } +} From cf5fe235629274a56bbfbb13b6a81f7420373ad9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 23:57:05 +0900 Subject: [PATCH 010/102] feat(docx): add inert bounded XML parser --- src/docx/xml.ts | 409 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 409 insertions(+) create mode 100644 src/docx/xml.ts diff --git a/src/docx/xml.ts b/src/docx/xml.ts new file mode 100644 index 00000000..f3f77b32 --- /dev/null +++ b/src/docx/xml.ts @@ -0,0 +1,409 @@ +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/'; + +/** 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 = new TextDecoder('utf-8', { fatal: true }).decode(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(''); +} From c774b0cc1baed35abd614320f5e38e760d0cd519 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 00:05:46 +0900 Subject: [PATCH 011/102] feat(docx): centralize OOXML namespace and warning rules --- src/docx/ooxmlShared.ts | 272 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 272 insertions(+) create mode 100644 src/docx/ooxmlShared.ts 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('/'); +} From 90b51ea97becedd3f3479e5974bcf41404ff7835 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 00:09:46 +0900 Subject: [PATCH 012/102] feat(docx): validate OPC manifest and relationships --- src/docx/ooxmlManifest.ts | 79 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 src/docx/ooxmlManifest.ts diff --git a/src/docx/ooxmlManifest.ts b/src/docx/ooxmlManifest.ts new file mode 100644 index 00000000..f953e285 --- /dev/null +++ b/src/docx/ooxmlManifest.ts @@ -0,0 +1,79 @@ +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 accepted = childElements( + root, + 'Override', + CONTENT_TYPES_NAMESPACE, + ).some( + (entry) => + packageAttribute(entry, 'PartName') === `/${DOCUMENT_PATH}` && + packageAttribute(entry, 'ContentType') === MAIN_DOCUMENT_CONTENT_TYPE, + ); + if (!accepted) 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; +} From ed5f45b0db41c0b236fae8d623d14251ef007802 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 00:13:57 +0900 Subject: [PATCH 013/102] feat(docx): classify Word list formats --- src/docx/ooxmlNumberFormats.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 src/docx/ooxmlNumberFormats.ts 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; +} From d55cdc5b541037f24813dfe22b5fd62675fd9534 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 00:14:30 +0900 Subject: [PATCH 014/102] feat(docx): parse Word list numbering --- src/docx/ooxmlNumbering.ts | 75 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 src/docx/ooxmlNumbering.ts 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; +} From 2f6ce975b935cd8dae8995ce76370d2d6a5e4ba6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 00:15:18 +0900 Subject: [PATCH 015/102] feat(docx): normalize Word heading labels --- src/docx/ooxmlHeading.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 src/docx/ooxmlHeading.ts 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; +} From bf20cfd1452eeb0dba8f5fb71a220f9b79f1ba82 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 00:15:39 +0900 Subject: [PATCH 016/102] feat(docx): map Word heading styles --- src/docx/ooxmlStyles.ts | 50 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 src/docx/ooxmlStyles.ts diff --git a/src/docx/ooxmlStyles.ts b/src/docx/ooxmlStyles.ts new file mode 100644 index 00000000..44161038 --- /dev/null +++ b/src/docx/ooxmlStyles.ts @@ -0,0 +1,50 @@ +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(); + for (const style of wordChildren(root, 'style')) { + if (wordAttribute(style, 'type') !== 'paragraph') continue; + const styleId = wordAttribute(style, 'styleId'); + if (!styleId) continue; + 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; +} From f2725c2d0e25b8829bd01bf9162458171e92035a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 00:15:53 +0900 Subject: [PATCH 017/102] feat(docx): assemble bounded package metadata --- src/docx/ooxmlPackage.ts | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 src/docx/ooxmlPackage.ts 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 }; +} From 0e07c0b9888168c074e9f425575e587e835f9438 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 10:44:36 +0900 Subject: [PATCH 018/102] test(docx): align import contract with editor and binary types --- src/docx/importDocx.contract.test.ts | 158 ++++++++++++++------------- 1 file changed, 81 insertions(+), 77 deletions(-) diff --git a/src/docx/importDocx.contract.test.ts b/src/docx/importDocx.contract.test.ts index 892a7852..ee441340 100644 --- a/src/docx/importDocx.contract.test.ts +++ b/src/docx/importDocx.contract.test.ts @@ -1,108 +1,112 @@ import { describe, expect, it, vi } from 'vitest'; -import { - createDocx, - PNG_BYTES, - WORD_NAMESPACES, -} from '../../test/docxFixture.js'; +import { createDocx, PNG_BYTES } 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], + ['Blob', (bytes) => new Blob([Uint8Array.from(bytes)])], +]; + describe('DOCX open/import contract', () => { - it.each([ - ['ArrayBuffer', (bytes: Uint8Array) => bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)], - ['Uint8Array', (bytes: Uint8Array) => bytes], - ['Blob', (bytes: Uint8Array) => new Blob([bytes])], - ])('imports bounded document content from %s', async ( - _label: string, - source: (bytes: Uint8Array) => ArrayBuffer | Uint8Array | Blob, - ) => { - const relationships = - '' + - '' + - ''; - const body = - 'Hello & 안녕' + - '' + - 'Cell'; - const bytes = createDocx({ - body, - relationships, - media: { 'word/media/image.png': PNG_BYTES }, - }); + 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)); + 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', + expect(result.documentJson).toEqual({ + type: 'doc', + content: [ + { + type: 'heading', + attrs: { level: 2 }, + content: [ + { + type: 'text', + text: 'Hello & 안녕', + marks: [{ type: 'bold' }, { type: 'italic' }], + }, + ], }, - }, - { - type: 'table', - content: [ - { - type: 'tableRow', - content: [ - { - type: 'tableHeader', - content: [ - { - type: 'paragraph', - content: [{ type: 'text', text: 'Cell' }], - }, - ], - }, - ], + { + type: 'image', + attrs: { + src: expect.stringMatching(/^data:image\/png;base64,/u), + alt: 'Chart', }, - ], - }, - ], - }); - 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); - }); + }, + { + 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('validates the complete imported document before one atomic editor mutation', async () => { - const validateDocumentJson = vi.fn(() => ({ valid: true })); - const setDocumentJson = vi.fn(() => true); + 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] as DocxJsonContent; + 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(() => true); + const setDocumentJson = vi.fn((_documentJson: DocxJsonContent) => undefined); const operation = openDocx( { - validateDocumentJson: () => ({ valid: false, issues: [] }), + validateDocumentJson: () => false, setDocumentJson, }, createDocx(), From 85574e5ee4b760e915669d4f7625690e5d65b9f1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 10:45:35 +0900 Subject: [PATCH 019/102] fix(docx): type decompression stream input as BufferSource --- src/docx/zip.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/docx/zip.ts b/src/docx/zip.ts index 65bed491..f281d0e7 100644 --- a/src/docx/zip.ts +++ b/src/docx/zip.ts @@ -120,9 +120,9 @@ async function inflateRaw( } catch { throw new DocxImportError('decompression_unavailable'); } - const input = new ReadableStream({ + const input = new ReadableStream({ start(controller) { - controller.enqueue(compressed); + controller.enqueue(Uint8Array.from(compressed)); controller.close(); }, }); From 97de3935ae34e46701f971ad904d4d616a819e03 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 10:47:20 +0900 Subject: [PATCH 020/102] feat(docx): assemble bounded OOXML document import --- src/docx/ooxml.ts | 533 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 533 insertions(+) create mode 100644 src/docx/ooxml.ts diff --git a/src/docx/ooxml.ts b/src/docx/ooxml.ts new file mode 100644 index 00000000..3e3e00ea --- /dev/null +++ b/src/docx/ooxml.ts @@ -0,0 +1,533 @@ +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 { + attribute, + 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, + ...(mark.attrs ? { attrs: Object.freeze({ ...mark.attrs }) } : {}), + }), + ); + return Object.freeze({ + ...(node.type ? { 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 body = wordChildren(root, 'body')[0]; + if (!body) throw new DocxImportError('invalid_docx'); + + 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(), + }); +} From e74006fe086940dbd598339392cd3a11e315ba6d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 10:49:53 +0900 Subject: [PATCH 021/102] fix(docx): clear aggregate parser typecheck blockers --- src/docx/ooxml.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/docx/ooxml.ts b/src/docx/ooxml.ts index 3e3e00ea..96fba17f 100644 --- a/src/docx/ooxml.ts +++ b/src/docx/ooxml.ts @@ -32,7 +32,6 @@ import type { DocxJsonMark, } from './types.js'; import { - attribute, childElements, directText, parseXml, @@ -501,7 +500,7 @@ export async function parseDocxPackage( items: [], }; } - pendingList.items.push(paragraph.blocks[0]!); + pendingList!.items.push(paragraph.blocks[0]!); } else { flushList(pendingList, blocks); pendingList = undefined; From 15a7174019d23db9c585b25acde765f08d368eb0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 10:53:27 +0900 Subject: [PATCH 022/102] fix(docx): support Blob reads across DOM runtimes --- src/docx/importDocx.ts | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/src/docx/importDocx.ts b/src/docx/importDocx.ts index 75d8a71c..2de936f2 100644 --- a/src/docx/importDocx.ts +++ b/src/docx/importDocx.ts @@ -9,6 +9,28 @@ import type { } from './types.js'; import { ZipArchive } from './zip.js'; +/** Read one proven Blob without requiring Blob.arrayBuffer() in older DOMs. */ +async function readBlobBytes(blob: Blob): Promise { + if (typeof blob.arrayBuffer === 'function') { + return new Uint8Array(await blob.arrayBuffer()); + } + if (typeof FileReader === 'undefined') { + throw new DocxImportError('invalid_source'); + } + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + if (!(reader.result instanceof ArrayBuffer)) { + reject(new DocxImportError('invalid_source')); + return; + } + resolve(new Uint8Array(reader.result)); + }; + reader.onerror = () => reject(new DocxImportError('invalid_source')); + reader.readAsArrayBuffer(blob); + }); +} + /** Copy one accepted binary source into an immutable import snapshot. */ async function snapshotSource( source: DocxSource, @@ -21,12 +43,10 @@ async function snapshotSource( } else if (ArrayBuffer.isView(source) && source.buffer instanceof ArrayBuffer) { view = new Uint8Array(source.buffer, source.byteOffset, source.byteLength); } else if (typeof Blob !== 'undefined' && source instanceof Blob) { - if (source.size > maxArchiveBytes || typeof source.arrayBuffer !== 'function') { - throw new DocxImportError( - source.size > maxArchiveBytes ? 'input_too_large' : 'invalid_source', - ); + if (source.size > maxArchiveBytes) { + throw new DocxImportError('input_too_large'); } - view = new Uint8Array(await source.arrayBuffer()); + view = await readBlobBytes(source); } else { throw new DocxImportError('invalid_source'); } From 95fce7ed9e8caf37bce5e621d621f1893ae2d925 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 11:27:34 +0900 Subject: [PATCH 023/102] test(docx): cover strict parser and configuration boundaries --- src/docx/docxPureCoverage.test.ts | 459 ++++++++++++++++++++++++++++++ 1 file changed, 459 insertions(+) create mode 100644 src/docx/docxPureCoverage.test.ts diff --git a/src/docx/docxPureCoverage.test.ts b/src/docx/docxPureCoverage.test.ts new file mode 100644 index 00000000..e5f3ad9a --- /dev/null +++ b/src/docx/docxPureCoverage.test.ts @@ -0,0 +1,459 @@ +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 { + try { + operation(); + throw new Error(`Expected ${code}`); + } catch (error) { + expect(error).toBeInstanceOf(DocxImportError); + expect(error).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'); + }); +}); From 967b40571e51d998b0e1ed3f84757dc9a4b74f83 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 11:33:16 +0900 Subject: [PATCH 024/102] test(docx): align XML expectations with parser contract --- src/docx/docxPureCoverage.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/docx/docxPureCoverage.test.ts b/src/docx/docxPureCoverage.test.ts index e5f3ad9a..1ce317d8 100644 --- a/src/docx/docxPureCoverage.test.ts +++ b/src/docx/docxPureCoverage.test.ts @@ -355,7 +355,7 @@ describe('DOCX inert XML parser', () => { 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(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); @@ -376,7 +376,7 @@ describe('DOCX inert XML parser', () => { it('enforces XML byte, node, and depth ceilings', () => { expectCode( () => parseXml(new Uint8Array(), DEFAULT_DOCX_IMPORT_LIMITS), - 'archive_limit_exceeded', + 'invalid_xml', ); expectCode( () => From 1f5d2f90f4b6231bab57f4d1293be674c8056ab2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 11:38:10 +0900 Subject: [PATCH 025/102] test(docx): restore empty XML limit expectation --- src/docx/docxPureCoverage.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/docx/docxPureCoverage.test.ts b/src/docx/docxPureCoverage.test.ts index 1ce317d8..cd725115 100644 --- a/src/docx/docxPureCoverage.test.ts +++ b/src/docx/docxPureCoverage.test.ts @@ -376,7 +376,7 @@ describe('DOCX inert XML parser', () => { it('enforces XML byte, node, and depth ceilings', () => { expectCode( () => parseXml(new Uint8Array(), DEFAULT_DOCX_IMPORT_LIMITS), - 'invalid_xml', + 'archive_limit_exceeded', ); expectCode( () => From 779c5df2e70e961044d588c691e9a3fcb209b9f7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 11:44:28 +0900 Subject: [PATCH 026/102] test(docx): make XML limit failures explicit --- src/docx/docxPureCoverage.test.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/docx/docxPureCoverage.test.ts b/src/docx/docxPureCoverage.test.ts index cd725115..3751b4b1 100644 --- a/src/docx/docxPureCoverage.test.ts +++ b/src/docx/docxPureCoverage.test.ts @@ -40,13 +40,14 @@ import { const encode = (value: string): Uint8Array => new TextEncoder().encode(value); function expectCode(operation: () => unknown, code: DocxImportErrorCode): void { + let thrown: unknown; try { operation(); - throw new Error(`Expected ${code}`); } catch (error) { - expect(error).toBeInstanceOf(DocxImportError); - expect(error).toMatchObject({ name: 'DocxImportError', code }); + thrown = error; } + expect(thrown).toBeInstanceOf(DocxImportError); + expect(thrown).toMatchObject({ name: 'DocxImportError', code }); } function xml( @@ -396,7 +397,7 @@ describe('DOCX inert XML parser', () => { ); expectCode( () => - xml('', { + xml('', { ...DEFAULT_DOCX_IMPORT_LIMITS, maxXmlDepth: 1, }), From 8aa21f69d39d85d102087555cbe6356c51ae78cb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 13:14:33 +0900 Subject: [PATCH 027/102] test(docx): cover OOXML package integration paths --- src/docx/ooxmlIntegrationCoverage.test.ts | 207 ++++++++++++++++++++++ 1 file changed, 207 insertions(+) create mode 100644 src/docx/ooxmlIntegrationCoverage.test.ts 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, + ); + }); +}); From 0fb29f397d1d163e79055edd00e20c222e5da028 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 13:20:31 +0900 Subject: [PATCH 028/102] test(docx): close package safety coverage gaps --- src/docx/docxCoverageGaps.test.ts | 621 ++++++++++++++++++++++++++++++ 1 file changed, 621 insertions(+) create mode 100644 src/docx/docxCoverageGaps.test.ts diff --git a/src/docx/docxCoverageGaps.test.ts b/src/docx/docxCoverageGaps.test.ts new file mode 100644 index 00000000..18658992 --- /dev/null +++ b/src/docx/docxCoverageGaps.test.ts @@ -0,0 +1,621 @@ +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 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).resolves.toEqual(new TextEncoder().encode('abc')); + 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')).resolves.toEqual( + new TextEncoder().encode('ok'), + ); + + 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); + + let 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); + + let wrongCrc = base.slice(); + wrongCrc[local + 30 + 'a.txt'.length] ^= 0xff; + await readFailure(wrongCrc); + + let 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.stubGlobal('DecompressionStream', undefined); + await expectAsyncCode(ZipArchive.parse(compressed, limits).read('a.txt'), 'decompression_unavailable'); + + vi.stubGlobal('DecompressionStream', originalDecompressionStream); + vi.stubGlobal('ReadableStream', undefined); + await expectAsyncCode(ZipArchive.parse(compressed, limits).read('a.txt'), 'decompression_unavailable'); + + vi.stubGlobal( + 'ReadableStream', + originalReadableStream, + ); + vi.stubGlobal( + 'DecompressionStream', + class { + constructor() { + throw new Error('unsupported'); + } + }, + ); + await expectAsyncCode(ZipArchive.parse(compressed, limits).read('a.txt'), 'decompression_unavailable'); + } finally { + vi.stubGlobal('DecompressionStream', originalDecompressionStream); + vi.stubGlobal('ReadableStream', originalReadableStream); + } + + 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([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 fallbackBlob = new Blob([bytes]); + Object.defineProperty(fallbackBlob, 'arrayBuffer', { value: undefined }); + const originalFileReader = globalThis.FileReader; + + try { + class SuccessfulReader { + result: ArrayBuffer | string | null = null; + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + readAsArrayBuffer(): void { + this.result = Uint8Array.from(bytes).buffer; + this.onload?.(); + } + } + vi.stubGlobal('FileReader', SuccessfulReader); + await expect(importDocx(fallbackBlob)).resolves.toMatchObject({ + documentJson: { type: 'doc' }, + }); + + class WrongResultReader extends SuccessfulReader { + override readAsArrayBuffer(): void { + this.result = 'not-an-array-buffer'; + this.onload?.(); + } + } + vi.stubGlobal('FileReader', WrongResultReader); + await expectAsyncCode(importDocx(fallbackBlob), 'invalid_source'); + + class ErrorReader extends SuccessfulReader { + override readAsArrayBuffer(): void { + this.onerror?.(); + } + } + vi.stubGlobal('FileReader', ErrorReader); + await expectAsyncCode(importDocx(fallbackBlob), 'invalid_source'); + + vi.stubGlobal('FileReader', undefined); + await expectAsyncCode(importDocx(fallbackBlob), 'invalid_source'); + } finally { + vi.stubGlobal('FileReader', originalFileReader); + } + }); + + 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_source'); + + 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, + media: { + 'word/media/jpeg.bin': jpeg, + 'word/media/gif.bin': gif, + 'word/media/webp.bin': webp, + 'word/media/bad.bin': 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); + }); +}); From d7882380eabd015ae1579cd2328b4b194248cfe7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 13:23:03 +0900 Subject: [PATCH 029/102] test(docx): use ArrayBuffer-backed Blob fixtures --- src/docx/docxCoverageGaps.test.ts | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/src/docx/docxCoverageGaps.test.ts b/src/docx/docxCoverageGaps.test.ts index 18658992..db9c525d 100644 --- a/src/docx/docxCoverageGaps.test.ts +++ b/src/docx/docxCoverageGaps.test.ts @@ -71,6 +71,12 @@ 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 ( '' + @@ -257,7 +263,7 @@ describe('DOCX ZIP safety coverage', () => { ); await readFailure(localNameMismatch); - let invalidDataStart = patchUint16(base, local + 28, 0xffff); + const invalidDataStart = patchUint16(base, local + 28, 0xffff); await readFailure(invalidDataStart); let tooLongPayload = buildZip( @@ -273,11 +279,11 @@ describe('DOCX ZIP safety coverage', () => { wrongSize = patchUint32(wrongSize, wrongSizeOffsets.central + 24, 4); await readFailure(wrongSize); - let wrongCrc = base.slice(); + const wrongCrc = base.slice(); wrongCrc[local + 30 + 'a.txt'.length] ^= 0xff; await readFailure(wrongCrc); - let foreignOffset = patchUint32(base, central + 42, central - 1); + const foreignOffset = patchUint32(base, central + 42, central - 1); const parsedForeignOffset = ZipArchive.parse(foreignOffset, limits); await expectAsyncCode(parsedForeignOffset.read('a.txt'), 'invalid_zip'); @@ -299,10 +305,7 @@ describe('DOCX ZIP safety coverage', () => { vi.stubGlobal('ReadableStream', undefined); await expectAsyncCode(ZipArchive.parse(compressed, limits).read('a.txt'), 'decompression_unavailable'); - vi.stubGlobal( - 'ReadableStream', - originalReadableStream, - ); + vi.stubGlobal('ReadableStream', originalReadableStream); vi.stubGlobal( 'DecompressionStream', class { @@ -331,14 +334,14 @@ describe('DOCX source and editor boundary coverage', () => { await expectAsyncCode(importDocx('not-bytes' as never), 'invalid_source'); await expectAsyncCode(importDocx(new Uint8Array()), 'invalid_source'); await expectAsyncCode( - importDocx(new Blob([createDocx()]), { limits: { maxArchiveBytes: 8 } }), + 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 fallbackBlob = new Blob([bytes]); + const fallbackBlob = new Blob([blobPart(bytes)]); Object.defineProperty(fallbackBlob, 'arrayBuffer', { value: undefined }); const originalFileReader = globalThis.FileReader; @@ -348,7 +351,7 @@ describe('DOCX source and editor boundary coverage', () => { onload: (() => void) | null = null; onerror: (() => void) | null = null; readAsArrayBuffer(): void { - this.result = Uint8Array.from(bytes).buffer; + this.result = blobPart(bytes); this.onload?.(); } } From 9f842f20794a1abcbf502040401bb6383658da70 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 14:02:44 +0900 Subject: [PATCH 030/102] test(docx): repair CI coverage fixtures --- src/docx/docxCoverageGaps.test.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/docx/docxCoverageGaps.test.ts b/src/docx/docxCoverageGaps.test.ts index db9c525d..3f4179db 100644 --- a/src/docx/docxCoverageGaps.test.ts +++ b/src/docx/docxCoverageGaps.test.ts @@ -107,7 +107,7 @@ describe('DOCX ZIP safety coverage', () => { const first = parsed.read('a.txt'); const second = parsed.read('a.txt'); expect(second).toBe(first); - await expect(first).resolves.toEqual(new TextEncoder().encode('abc')); + await expect(first.then((bytes) => Array.from(bytes))).resolves.toEqual([97, 98, 99]); await expectAsyncCode(parsed.read('missing'), 'invalid_docx'); }); @@ -230,9 +230,9 @@ describe('DOCX ZIP safety coverage', () => { limits, ); expect(withDirectory.has('folder/')).toBe(false); - await expect(withDirectory.read('folder/a.txt')).resolves.toEqual( - new TextEncoder().encode('ok'), - ); + await expect( + withDirectory.read('folder/a.txt').then((bytes) => Array.from(bytes)), + ).resolves.toEqual([111, 107]); const base = oneStoredEntry(); const { eocd } = zipOffsets(base); @@ -450,7 +450,7 @@ describe('DOCX OOXML remaining safety and fidelity branches', () => { createDocx({ method: 0, numbering, - body: '', + body: '', }), ); expect(result.documentJson.content?.[0]).toMatchObject({ type: 'bulletList' }); @@ -479,11 +479,11 @@ describe('DOCX OOXML remaining safety and fidelity branches', () => { method: 0, relationships, body, - media: { - 'word/media/jpeg.bin': jpeg, - 'word/media/gif.bin': gif, - 'word/media/webp.bin': webp, - 'word/media/bad.bin': unsupported, + 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 }, }, }), ); From 24d49da5ad9e5faeaaa11328cb7dec108669c5e0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 14:07:40 +0900 Subject: [PATCH 031/102] test(docx): cover remaining importer boundaries --- src/docx/docxRemainingCoverage.test.ts | 106 +++++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 src/docx/docxRemainingCoverage.test.ts diff --git a/src/docx/docxRemainingCoverage.test.ts b/src/docx/docxRemainingCoverage.test.ts new file mode 100644 index 00000000..be84b6cb --- /dev/null +++ b/src/docx/docxRemainingCoverage.test.ts @@ -0,0 +1,106 @@ +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; +} + +describe('DOCX remaining exact coverage boundaries', () => { + it('bounds primitive ZIP integer reads and rejects non-ASCII legacy entry names', () => { + 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', + ); + }); + + it('reads the native Blob arrayBuffer path before parsing a valid package', async () => { + const bytes = createDocx({ method: 0 }); + const result = await importDocx(new Blob([blobPart(bytes)])); + 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('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, + }); + }); +}); From bf8b3522ed9fb866b722cde473536e8bb8bc97a5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 14:11:15 +0900 Subject: [PATCH 032/102] test(docx): close exact importer coverage gaps --- src/docx/docxRemainingCoverage.test.ts | 82 +++++++++++++++++++++++++- 1 file changed, 79 insertions(+), 3 deletions(-) diff --git a/src/docx/docxRemainingCoverage.test.ts b/src/docx/docxRemainingCoverage.test.ts index be84b6cb..3265cdd7 100644 --- a/src/docx/docxRemainingCoverage.test.ts +++ b/src/docx/docxRemainingCoverage.test.ts @@ -33,8 +33,20 @@ function blobPart(bytes: Uint8Array): ArrayBuffer { 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 rejects non-ASCII legacy entry names', () => { + 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'); @@ -58,11 +70,30 @@ describe('DOCX remaining exact coverage boundaries', () => { ), '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 result = await importDocx(new Blob([blobPart(bytes)])); + const source = new Blob([]); + Object.defineProperty(source, 'arrayBuffer', { + configurable: true, + value: async () => blobPart(bytes), + }); + const result = await importDocx(source); expect(result.documentJson).toMatchObject({ type: 'doc' }); }); @@ -89,12 +120,57 @@ describe('DOCX remaining exact coverage boundaries', () => { 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: - '' + + '' + '', }), ); From cd09faf8679ade5830817ce4e39c24171a2f12a1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 14:13:28 +0900 Subject: [PATCH 033/102] test(docx): require packed public import surface --- src/docxPackage.test.ts | 79 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 src/docxPackage.test.ts 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); + } + } + }); +}); From 98fca0607478c0f254dccd5c6f67e7873ee53ade Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 14:17:10 +0900 Subject: [PATCH 034/102] build(docx): add standalone import bundle --- vite.docx.config.ts | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 vite.docx.config.ts 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, + }, +}); From 8aa1befa4630a6215ee8810afa65ba2157375a96 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 14:18:09 +0900 Subject: [PATCH 035/102] test(docx): verify packed standalone consumers --- scripts/verify-docx-subpath-package.mjs | 217 ++++++++++++++++++++++++ 1 file changed, 217 insertions(+) create mode 100644 scripts/verify-docx-subpath-package.mjs diff --git a/scripts/verify-docx-subpath-package.mjs b/scripts/verify-docx-subpath-package.mjs new file mode 100644 index 00000000..fef1a74c --- /dev/null +++ b/scripts/verify-docx-subpath-package.mjs @@ -0,0 +1,217 @@ +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`, + ); + } +} + +/** Exercise the exact public ESM and CommonJS subpath from the packed package. */ +function verifyRuntimeConsumers() { + 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'); +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 () => { + 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', 'redacted'); +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 }); +} From 39140cf536cb958afd645fba7d69e65f3e9b823f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 14:19:05 +0900 Subject: [PATCH 036/102] build(docx): publish bounded import subpath --- package.json | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) 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", From 5a8fc672e36b85d9c6c535f9662d300d1a7a8480 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 14:20:21 +0900 Subject: [PATCH 037/102] fix(docx): compile the public error constructor contract --- scripts/verify-docx-subpath-package.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/verify-docx-subpath-package.mjs b/scripts/verify-docx-subpath-package.mjs index fef1a74c..2082b0ff 100644 --- a/scripts/verify-docx-subpath-package.mjs +++ b/scripts/verify-docx-subpath-package.mjs @@ -165,7 +165,7 @@ 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', 'redacted'); +const failure = new DocxImportError('invalid_source'); void [ imported, opened, From 7ba15681a50c2a2c146c7b6d0f8f48fb55d6a2d2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 15:11:25 +0900 Subject: [PATCH 038/102] docs(docx): publish the bounded import entrypoint --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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, From 7d137b9ec578a4f062e5fbf281c76344eacb87d1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 15:12:16 +0900 Subject: [PATCH 039/102] docs(docx): define active import distribution contract --- docs/package-distribution.md | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) 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 From 733abe7b7f1240bc508c92b00c433aaf122b369d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 15:14:32 +0900 Subject: [PATCH 040/102] test(docx): exercise valid packed consumer import --- scripts/verify-docx-subpath-package.mjs | 120 +++++++++++++++++++++++- 1 file changed, 119 insertions(+), 1 deletion(-) diff --git a/scripts/verify-docx-subpath-package.mjs b/scripts/verify-docx-subpath-package.mjs index 2082b0ff..70fd418c 100644 --- a/scripts/verify-docx-subpath-package.mjs +++ b/scripts/verify-docx-subpath-package.mjs @@ -94,8 +94,116 @@ function verifyPackedSurface() { } } +/** 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, @@ -110,6 +218,11 @@ 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', @@ -128,6 +241,11 @@ 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', @@ -214,4 +332,4 @@ try { verifyDeclarationConsumer(); } finally { rmSync(verificationRoot, { recursive: true, force: true }); -} +} \ No newline at end of file From d1033530262b8b7c6c390ffec96e38d401b8737c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 15:18:03 +0900 Subject: [PATCH 041/102] test(docx): cover structural fallback branches --- src/docx/docxRemainingCoverage.test.ts | 44 +++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/src/docx/docxRemainingCoverage.test.ts b/src/docx/docxRemainingCoverage.test.ts index 3265cdd7..8552c21d 100644 --- a/src/docx/docxRemainingCoverage.test.ts +++ b/src/docx/docxRemainingCoverage.test.ts @@ -179,4 +179,46 @@ describe('DOCX remaining exact coverage boundaries', () => { count: 2, }); }); -}); + + it('covers missing numbering and empty 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' }], + }, + { type: 'heading', attrs: { level: 2 } }, + { type: 'paragraph' }, + { + type: 'table', + content: [ + { + type: 'tableRow', + content: [ + { + type: 'tableCell', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'Cell' }], + }, + ], + }, + ], + }, + ], + }, + ]); + expect(result.warnings).toEqual([]); + }); +}); \ No newline at end of file From 8aeec02fb506e1dc36364fa060791d7e3f930860 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 15:22:58 +0900 Subject: [PATCH 042/102] test(docx): cover marked text and empty cells --- src/docx/docxRemainingCoverage.test.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/docx/docxRemainingCoverage.test.ts b/src/docx/docxRemainingCoverage.test.ts index 8552c21d..96e166b3 100644 --- a/src/docx/docxRemainingCoverage.test.ts +++ b/src/docx/docxRemainingCoverage.test.ts @@ -180,22 +180,28 @@ describe('DOCX remaining exact coverage boundaries', () => { }); }); - it('covers missing numbering and empty structural fallbacks without inventing authority', async () => { + it('covers marked text and structural fallbacks without inventing authority', async () => { const result = await importDocx( createDocx({ method: 0, body: - 'Plain' + + 'Plain' + '' + '' + - 'Cell', + 'Cell', }), ); expect(result.documentJson.content).toEqual([ { type: 'paragraph', - content: [{ type: 'text', text: 'Plain' }], + content: [ + { + type: 'text', + text: 'Plain', + marks: [{ type: 'bold' }], + }, + ], }, { type: 'heading', attrs: { level: 2 } }, { type: 'paragraph' }, @@ -214,6 +220,10 @@ describe('DOCX remaining exact coverage boundaries', () => { }, ], }, + { + type: 'tableCell', + content: [{ type: 'paragraph' }], + }, ], }, ], From 400eb8b94c5eff1694805aff0cfe59361dc23cdd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 15:30:04 +0900 Subject: [PATCH 043/102] fix(docx): freeze only supported mark shape --- src/docx/ooxml.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/docx/ooxml.ts b/src/docx/ooxml.ts index 96fba17f..1c20b8c0 100644 --- a/src/docx/ooxml.ts +++ b/src/docx/ooxml.ts @@ -113,12 +113,7 @@ function imageMimeType(bytes: Uint8Array): string | 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, - ...(mark.attrs ? { attrs: Object.freeze({ ...mark.attrs }) } : {}), - }), - ); + const marks = node.marks?.map((mark) => Object.freeze({ type: mark.type })); return Object.freeze({ ...(node.type ? { type: node.type } : {}), ...(node.attrs ? { attrs: Object.freeze({ ...node.attrs }) } : {}), @@ -529,4 +524,4 @@ export async function parseDocxPackage( documentJson: frozenDocument, warnings: warnings.snapshot(), }); -} +} \ No newline at end of file From f2cf2157f9f437bb4e1931bd9d0b23a732c335cd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 05:29:26 +0900 Subject: [PATCH 044/102] test(docx): require intrinsic Blob ingestion authority --- src/docx/importDocx.contract.test.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/docx/importDocx.contract.test.ts b/src/docx/importDocx.contract.test.ts index ee441340..6c68cd55 100644 --- a/src/docx/importDocx.contract.test.ts +++ b/src/docx/importDocx.contract.test.ts @@ -86,6 +86,30 @@ describe('DOCX open/import contract', () => { }, ); + it('uses intrinsic Blob metadata and bytes instead of caller overrides', async () => { + const source = new Blob([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('validates the complete imported document before one atomic editor mutation', async () => { const validateDocumentJson = vi.fn( (documentJson: DocxJsonContent) => documentJson.type === 'doc', From 27e9696c75ac8ff6662770a90e1c1c0a43048e05 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 05:31:46 +0900 Subject: [PATCH 045/102] test(docx): make Blob authority regression type-safe --- src/docx/importDocx.contract.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/docx/importDocx.contract.test.ts b/src/docx/importDocx.contract.test.ts index 6c68cd55..01a4943c 100644 --- a/src/docx/importDocx.contract.test.ts +++ b/src/docx/importDocx.contract.test.ts @@ -87,7 +87,7 @@ describe('DOCX open/import contract', () => { ); it('uses intrinsic Blob metadata and bytes instead of caller overrides', async () => { - const source = new Blob([createDocx()]); + const source = new Blob([Uint8Array.from(createDocx())]); const sizeGetter = vi.fn(() => { throw new Error('private size getter'); }); From cfbca364a08ef0ea884eac388e16fca19f80ddbf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 05:34:21 +0900 Subject: [PATCH 046/102] fix(docx): read intrinsic Blob size --- src/docx/importDocx.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/docx/importDocx.ts b/src/docx/importDocx.ts index 2de936f2..87fe051c 100644 --- a/src/docx/importDocx.ts +++ b/src/docx/importDocx.ts @@ -9,6 +9,12 @@ import type { } from './types.js'; import { ZipArchive } from './zip.js'; +/** Read Blob size through the platform prototype without invoking caller overrides. */ +function readBlobSize(blob: Blob): number { + const getter = Object.getOwnPropertyDescriptor(Blob.prototype, 'size')!.get!; + return getter.call(blob) as number; +} + /** Read one proven Blob without requiring Blob.arrayBuffer() in older DOMs. */ async function readBlobBytes(blob: Blob): Promise { if (typeof blob.arrayBuffer === 'function') { @@ -43,7 +49,7 @@ async function snapshotSource( } else if (ArrayBuffer.isView(source) && source.buffer instanceof ArrayBuffer) { view = new Uint8Array(source.buffer, source.byteOffset, source.byteLength); } else if (typeof Blob !== 'undefined' && source instanceof Blob) { - if (source.size > maxArchiveBytes) { + if (readBlobSize(source) > maxArchiveBytes) { throw new DocxImportError('input_too_large'); } view = await readBlobBytes(source); From 3c716df1309f241183421eab65c67e35973817c2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 05:38:57 +0900 Subject: [PATCH 047/102] fix(docx): read Blob bytes through platform authority --- src/docx/importDocx.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/docx/importDocx.ts b/src/docx/importDocx.ts index 87fe051c..c95853ab 100644 --- a/src/docx/importDocx.ts +++ b/src/docx/importDocx.ts @@ -17,8 +17,14 @@ function readBlobSize(blob: Blob): number { /** Read one proven Blob without requiring Blob.arrayBuffer() in older DOMs. */ async function readBlobBytes(blob: Blob): Promise { - if (typeof blob.arrayBuffer === 'function') { - return new Uint8Array(await blob.arrayBuffer()); + const ownArrayBuffer = Object.getOwnPropertyDescriptor(blob, 'arrayBuffer'); + const ownUndefinedArrayBuffer = + ownArrayBuffer !== undefined && + 'value' in ownArrayBuffer && + ownArrayBuffer.value === undefined; + const platformArrayBuffer = Blob.prototype.arrayBuffer; + if (!ownUndefinedArrayBuffer && typeof platformArrayBuffer === 'function') { + return new Uint8Array(await platformArrayBuffer.call(blob)); } if (typeof FileReader === 'undefined') { throw new DocxImportError('invalid_source'); From 217886dbbc53ec6f254338eb5c67b62ce1eadf29 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 05:42:29 +0900 Subject: [PATCH 048/102] test(docx): cover intrinsic Blob reader --- src/docx/docxRemainingCoverage.test.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/docx/docxRemainingCoverage.test.ts b/src/docx/docxRemainingCoverage.test.ts index 96e166b3..efd42c16 100644 --- a/src/docx/docxRemainingCoverage.test.ts +++ b/src/docx/docxRemainingCoverage.test.ts @@ -88,11 +88,7 @@ describe('DOCX remaining exact coverage boundaries', () => { it('reads the native Blob arrayBuffer path before parsing a valid package', async () => { const bytes = createDocx({ method: 0 }); - const source = new Blob([]); - Object.defineProperty(source, 'arrayBuffer', { - configurable: true, - value: async () => blobPart(bytes), - }); + const source = new Blob([blobPart(bytes)]); const result = await importDocx(source); expect(result.documentJson).toMatchObject({ type: 'doc' }); }); From db3fc7aee58a5ef8aa406bb387a52447765278dd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 05:45:02 +0900 Subject: [PATCH 049/102] test(docx): align Blob subclass coverage with intrinsic reader --- src/docx/docxCoverageGaps.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/docx/docxCoverageGaps.test.ts b/src/docx/docxCoverageGaps.test.ts index 3f4179db..f7702da2 100644 --- a/src/docx/docxCoverageGaps.test.ts +++ b/src/docx/docxCoverageGaps.test.ts @@ -390,7 +390,7 @@ describe('DOCX source and editor boundary coverage', () => { throw new Error('private failure'); } } - await expectAsyncCode(importDocx(new ThrowingBlob(['x'])), 'invalid_source'); + await expectAsyncCode(importDocx(new ThrowingBlob(['x'])), 'invalid_zip'); const source = createDocx({ method: 0 }); for (const target of [ @@ -621,4 +621,4 @@ describe('DOCX OOXML remaining safety and fidelity branches', () => { appendInline(parts, textNode('d', [{ type: 'bold' }])); expect(parts).toHaveLength(4); }); -}); +}); \ No newline at end of file From 00c920ebbab4f64b702cb2448c466220f649e6ad Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 05:48:24 +0900 Subject: [PATCH 050/102] test(docx): cover Blob data-property override --- src/docx/importDocx.contract.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/docx/importDocx.contract.test.ts b/src/docx/importDocx.contract.test.ts index 01a4943c..c5dfa641 100644 --- a/src/docx/importDocx.contract.test.ts +++ b/src/docx/importDocx.contract.test.ts @@ -110,6 +110,20 @@ describe('DOCX open/import contract', () => { expect(arrayBufferGetter).not.toHaveBeenCalled(); }); + 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('validates the complete imported document before one atomic editor mutation', async () => { const validateDocumentJson = vi.fn( (documentJson: DocxJsonContent) => documentJson.type === 'doc', From 9597cdaa91581d49120d0fe215b4b5dfef31e4c7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 19:30:38 +0900 Subject: [PATCH 051/102] test(docx): exercise intrinsic Blob byte reader --- src/docx/importDocx.contract.test.ts | 42 ++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/src/docx/importDocx.contract.test.ts b/src/docx/importDocx.contract.test.ts index c5dfa641..ee43d4e5 100644 --- a/src/docx/importDocx.contract.test.ts +++ b/src/docx/importDocx.contract.test.ts @@ -110,6 +110,48 @@ describe('DOCX open/import contract', () => { expect(arrayBufferGetter).not.toHaveBeenCalled(); }); + it('uses a callable platform Blob byte reader without caller method lookup', 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, + }); + Object.defineProperty(source, 'arrayBuffer', { + configurable: true, + value: callerOverride, + }); + + try { + const result = await importDocx(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, + ); + } + } + }); + 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)); From 66a58040ed9e4938c10d8eb08bedc05c8db6c4a0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 19:37:49 +0900 Subject: [PATCH 052/102] fix(docx): preserve generated node type invariant --- src/docx/ooxml.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/docx/ooxml.ts b/src/docx/ooxml.ts index 1c20b8c0..0733d206 100644 --- a/src/docx/ooxml.ts +++ b/src/docx/ooxml.ts @@ -115,7 +115,7 @@ 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({ - ...(node.type ? { type: node.type } : {}), + type: node.type!, ...(node.attrs ? { attrs: Object.freeze({ ...node.attrs }) } : {}), ...(content ? { content: Object.freeze(content) } : {}), ...(marks ? { marks: Object.freeze(marks) } : {}), From f221d16e7988f2bd8ad4ae6c45f23061ec297a9c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 19:46:56 +0900 Subject: [PATCH 053/102] test(docx): reject ambiguous multiple-body packages --- src/docx/importDocx.contract.test.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/docx/importDocx.contract.test.ts b/src/docx/importDocx.contract.test.ts index ee43d4e5..ab755d17 100644 --- a/src/docx/importDocx.contract.test.ts +++ b/src/docx/importDocx.contract.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it, vi } from 'vitest'; -import { createDocx, PNG_BYTES } from '../../test/docxFixture.js'; +import { + createDocx, + PNG_BYTES, + WORD_NAMESPACES, +} from '../../test/docxFixture.js'; import { DocxImportError, importDocx, @@ -166,6 +170,19 @@ describe('DOCX open/import contract', () => { expect(arrayBufferOverride).not.toHaveBeenCalled(); }); + 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', From 9b39df5afcc1fc4af81f0d5c20495f0cff1edf83 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 19:51:41 +0900 Subject: [PATCH 054/102] fix(docx): reject ambiguous multiple-body documents --- src/docx/ooxml.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/docx/ooxml.ts b/src/docx/ooxml.ts index 0733d206..45e9fe9b 100644 --- a/src/docx/ooxml.ts +++ b/src/docx/ooxml.ts @@ -463,8 +463,9 @@ export async function parseDocxPackage( if (root.localName !== 'document' || !hasNamespace(root, WORD_NAMESPACES)) { throw new DocxImportError('invalid_docx'); } - const body = wordChildren(root, 'body')[0]; - if (!body) 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 = { From 8c751ab3659d569d870c8b76923b0cda13806720 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 19:57:10 +0900 Subject: [PATCH 055/102] test(docx): reject ambiguous content-type overrides --- src/docx/docxManifestAmbiguity.test.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 src/docx/docxManifestAmbiguity.test.ts diff --git a/src/docx/docxManifestAmbiguity.test.ts b/src/docx/docxManifestAmbiguity.test.ts new file mode 100644 index 00000000..4d3698b0 --- /dev/null +++ b/src/docx/docxManifestAmbiguity.test.ts @@ -0,0 +1,23 @@ +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'; + +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', + }); + }); +}); From b7ae80e1d357c469c6054d0124070cedb7ccd4ec Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 20:00:59 +0900 Subject: [PATCH 056/102] fix(docx): reject ambiguous content-type overrides --- src/docx/ooxmlManifest.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/docx/ooxmlManifest.ts b/src/docx/ooxmlManifest.ts index f953e285..04fc0d7c 100644 --- a/src/docx/ooxmlManifest.ts +++ b/src/docx/ooxmlManifest.ts @@ -32,16 +32,21 @@ export async function validateContentTypes( ) { throw new DocxImportError('invalid_docx'); } - const accepted = childElements( + const documentOverrides = childElements( root, 'Override', CONTENT_TYPES_NAMESPACE, - ).some( + ).filter( (entry) => - packageAttribute(entry, 'PartName') === `/${DOCUMENT_PATH}` && - packageAttribute(entry, 'ContentType') === MAIN_DOCUMENT_CONTENT_TYPE, + packageAttribute(entry, 'PartName') === `/${DOCUMENT_PATH}`, ); - if (!accepted) throw new DocxImportError('invalid_docx'); + if ( + documentOverrides.length !== 1 || + packageAttribute(documentOverrides[0]!, 'ContentType') !== + MAIN_DOCUMENT_CONTENT_TYPE + ) { + throw new DocxImportError('invalid_docx'); + } } /** Parse document relationships without following any target. */ From e252a4b10a3d8f7e85ccb4e3dc72745ff67b45a2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 20:09:13 +0900 Subject: [PATCH 057/102] test(docx): reject duplicate paragraph style identifiers --- src/docx/docxManifestAmbiguity.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/docx/docxManifestAmbiguity.test.ts b/src/docx/docxManifestAmbiguity.test.ts index 4d3698b0..f8e81a6e 100644 --- a/src/docx/docxManifestAmbiguity.test.ts +++ b/src/docx/docxManifestAmbiguity.test.ts @@ -6,6 +6,8 @@ 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 () => { @@ -20,4 +22,19 @@ describe('DOCX OPC manifest ambiguity', () => { 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', + }); + }); }); From 14d9087a8b42fc0f1dc12456dbb41aaba9091d2e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 23:53:38 +0900 Subject: [PATCH 058/102] fix(docx): reject duplicate paragraph style identifiers --- src/docx/ooxmlStyles.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/docx/ooxmlStyles.ts b/src/docx/ooxmlStyles.ts index 44161038..3a4759b6 100644 --- a/src/docx/ooxmlStyles.ts +++ b/src/docx/ooxmlStyles.ts @@ -25,10 +25,15 @@ export async function parseHeadingStyles( 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'); From 874dc5fad5cae5994b96082e575945b14bb5886b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 03:14:01 +0900 Subject: [PATCH 059/102] test(docx): expose hostile error normalization boundary --- src/docx/errorNormalizationProxy.test.ts | 35 ++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 src/docx/errorNormalizationProxy.test.ts 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(); + }); +}); From 168b9007cd17e03eddbd955161bd9ededf813d6c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 03:19:03 +0900 Subject: [PATCH 060/102] fix(docx): harden public error normalization --- src/docx/errors.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/docx/errors.ts b/src/docx/errors.ts index ee1abf6f..fae11b0c 100644 --- a/src/docx/errors.ts +++ b/src/docx/errors.ts @@ -37,6 +37,8 @@ const ERROR_MESSAGES: Readonly> = '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. */ @@ -47,6 +49,7 @@ export class DocxImportError extends Error { super(ERROR_MESSAGES[code]); this.name = 'DocxImportError'; this.code = code; + DOCX_IMPORT_ERROR_BRAND.add(this); } } @@ -55,7 +58,7 @@ export function normalizeDocxImportError( error: unknown, fallback: DocxImportErrorCode, ): DocxImportError { - return error instanceof DocxImportError - ? error + return DOCX_IMPORT_ERROR_BRAND.has(error as object) + ? (error as DocxImportError) : new DocxImportError(fallback); } From 3a2a3aa29557d58a450909acdb0abaf7642c46b3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 08:05:00 +0900 Subject: [PATCH 061/102] test(docx): bypass byte-range accessors --- src/docx/importDocx.contract.test.ts | 32 ++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/docx/importDocx.contract.test.ts b/src/docx/importDocx.contract.test.ts index ab755d17..6b633371 100644 --- a/src/docx/importDocx.contract.test.ts +++ b/src/docx/importDocx.contract.test.ts @@ -170,6 +170,38 @@ describe('DOCX open/import contract', () => { 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('rejects ambiguous documents with multiple Word bodies', async () => { const document = `` + From 80b0eed0cb0ec4e1965ac964fb41e7fa5b1a10aa Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 08:08:38 +0900 Subject: [PATCH 062/102] fix(docx): read view ranges through intrinsics --- src/docx/importDocx.ts | 59 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 57 insertions(+), 2 deletions(-) diff --git a/src/docx/importDocx.ts b/src/docx/importDocx.ts index c95853ab..813c4626 100644 --- a/src/docx/importDocx.ts +++ b/src/docx/importDocx.ts @@ -9,6 +9,40 @@ import type { } from './types.js'; import { ZipArchive } from './zip.js'; +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!; + +interface ArrayBufferViewRange { + readonly buffer: ArrayBufferLike; + readonly byteOffset: number; + readonly byteLength: number; +} + /** Read Blob size through the platform prototype without invoking caller overrides. */ function readBlobSize(blob: Blob): number { const getter = Object.getOwnPropertyDescriptor(Blob.prototype, 'size')!.get!; @@ -43,6 +77,23 @@ async function readBlobBytes(blob: Blob): Promise { }); } +/** 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, @@ -52,8 +103,12 @@ async function snapshotSource( let view: Uint8Array; if (source instanceof ArrayBuffer) { view = new Uint8Array(source); - } else if (ArrayBuffer.isView(source) && source.buffer instanceof ArrayBuffer) { - view = new Uint8Array(source.buffer, source.byteOffset, source.byteLength); + } else if (ArrayBuffer.isView(source)) { + const { buffer, byteOffset, byteLength } = readArrayBufferViewRange(source); + if (!(buffer instanceof ArrayBuffer)) { + throw new DocxImportError('invalid_source'); + } + view = new Uint8Array(buffer, byteOffset, byteLength); } else if (typeof Blob !== 'undefined' && source instanceof Blob) { if (readBlobSize(source) > maxArchiveBytes) { throw new DocxImportError('input_too_large'); From 721092d8f4c3d6d94895b291a81938f699783fab Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 08:12:46 +0900 Subject: [PATCH 063/102] test(docx): cover intrinsic view branches --- src/docx/importDocx.contract.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/docx/importDocx.contract.test.ts b/src/docx/importDocx.contract.test.ts index 6b633371..d3edb56f 100644 --- a/src/docx/importDocx.contract.test.ts +++ b/src/docx/importDocx.contract.test.ts @@ -18,6 +18,13 @@ const SOURCE_CASES: readonly [ ][] = [ ['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)])], ]; @@ -202,6 +209,17 @@ describe('DOCX open/import contract', () => { 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 = `` + From 8eb8f8554eaaa2a7e05ad85b59003ff0d71cc2ff Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 15:07:41 +0900 Subject: [PATCH 064/102] test(docx): reject proxy sources without prototype effects --- src/docx/sourceBrandProxy.test.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 src/docx/sourceBrandProxy.test.ts diff --git a/src/docx/sourceBrandProxy.test.ts b/src/docx/sourceBrandProxy.test.ts new file mode 100644 index 00000000..bf8d981b --- /dev/null +++ b/src/docx/sourceBrandProxy.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { importDocx } from './index.js'; + +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: 'The DOCX source is invalid.', + }); + expect(getPrototypeOf).not.toHaveBeenCalled(); + }); +}); From cf1f657b373de404dc9c05ef4b64015104909dbb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 15:13:10 +0900 Subject: [PATCH 065/102] test(docx): target source-brand prototype trap --- src/docx/sourceBrandProxy.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/docx/sourceBrandProxy.test.ts b/src/docx/sourceBrandProxy.test.ts index bf8d981b..e3f96c7f 100644 --- a/src/docx/sourceBrandProxy.test.ts +++ b/src/docx/sourceBrandProxy.test.ts @@ -16,7 +16,7 @@ describe('DOCX binary source branding', () => { ).rejects.toMatchObject({ name: 'DocxImportError', code: 'invalid_source', - message: 'The DOCX source is invalid.', + message: 'DOCX input must be a supported binary source.', }); expect(getPrototypeOf).not.toHaveBeenCalled(); }); From 2eddea7cf708abc11673f301a11808ba5726e619 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 15:17:31 +0900 Subject: [PATCH 066/102] fix(docx): brand binary sources without prototype effects --- src/docx/importDocx.ts | 40 +++++++++++++++++++++++++++++++++------- 1 file changed, 33 insertions(+), 7 deletions(-) diff --git a/src/docx/importDocx.ts b/src/docx/importDocx.ts index 813c4626..4aa4bbe5 100644 --- a/src/docx/importDocx.ts +++ b/src/docx/importDocx.ts @@ -9,6 +9,10 @@ import type { } from './types.js'; import { ZipArchive } from './zip.js'; +const ARRAY_BUFFER_BYTE_LENGTH_GETTER = Object.getOwnPropertyDescriptor( + ArrayBuffer.prototype, + 'byteLength', +)!.get!; const TYPED_ARRAY_PROTOTYPE = Object.getPrototypeOf( Uint8Array.prototype, ) as object; @@ -43,12 +47,32 @@ interface ArrayBufferViewRange { 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 platform prototype without invoking caller overrides. */ function readBlobSize(blob: Blob): number { const getter = Object.getOwnPropertyDescriptor(Blob.prototype, 'size')!.get!; return getter.call(blob) as number; } +/** Identify a genuine Blob and read its size without prototype traversal. */ +function tryReadBlobSize(value: unknown): number | undefined { + if (typeof Blob === 'undefined') return 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 { const ownArrayBuffer = Object.getOwnPropertyDescriptor(blob, 'arrayBuffer'); @@ -101,21 +125,23 @@ async function snapshotSource( ): Promise { try { let view: Uint8Array; - if (source instanceof ArrayBuffer) { + if (isIntrinsicArrayBuffer(source)) { view = new Uint8Array(source); } else if (ArrayBuffer.isView(source)) { const { buffer, byteOffset, byteLength } = readArrayBufferViewRange(source); - if (!(buffer instanceof ArrayBuffer)) { + if (!isIntrinsicArrayBuffer(buffer)) { throw new DocxImportError('invalid_source'); } view = new Uint8Array(buffer, byteOffset, byteLength); - } else if (typeof Blob !== 'undefined' && source instanceof Blob) { - if (readBlobSize(source) > maxArchiveBytes) { + } 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); - } else { - throw new DocxImportError('invalid_source'); + view = await readBlobBytes(source as Blob); } if (view.byteLength === 0) throw new DocxImportError('invalid_source'); if (view.byteLength > maxArchiveBytes) { From b6d9c6930b9e9f3e39f907235568efb59808fd1b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 15:22:55 +0900 Subject: [PATCH 067/102] test(docx): cover blobless source rejection --- src/docx/sourceBrandProxy.test.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/docx/sourceBrandProxy.test.ts b/src/docx/sourceBrandProxy.test.ts index e3f96c7f..a6a950f0 100644 --- a/src/docx/sourceBrandProxy.test.ts +++ b/src/docx/sourceBrandProxy.test.ts @@ -20,4 +20,19 @@ describe('DOCX binary source branding', () => { }); expect(getPrototypeOf).not.toHaveBeenCalled(); }); + + 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(); + } + }); }); From c7c3b2e3e0203687aae3839a63443813fbfe0c38 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 15:48:44 +0900 Subject: [PATCH 068/102] test(docx): contain hostile limit reflection failures --- src/docx/limitsHostileReflection.test.ts | 34 ++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 src/docx/limitsHostileReflection.test.ts 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', + }); + }); +}); From 015a9cd36c2f5c4ab041995a4d8aa8ffb17fc9a3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 15:51:55 +0900 Subject: [PATCH 069/102] fix(docx): contain hostile limit reflection failures --- src/docx/limits.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/docx/limits.ts b/src/docx/limits.ts index b849b746..cf64ebb0 100644 --- a/src/docx/limits.ts +++ b/src/docx/limits.ts @@ -1,4 +1,4 @@ -import { DocxImportError } from './errors.js'; +import { DocxImportError, normalizeDocxImportError } from './errors.js'; import type { DocxImportLimits, DocxImportOptions } from './types.js'; /** Default resource profile for one untrusted DOCX package. */ @@ -83,7 +83,6 @@ export function resolveDocxImportLimits( for (const key of LIMIT_KEYS) resolved[key] = resolveLimit(key, limitRecord[key]); return Object.freeze(resolved); } catch (error) { - if (error instanceof DocxImportError) throw error; - rejectConfiguration(); + throw normalizeDocxImportError(error, 'invalid_configuration'); } } From f3135b09f47b04d91296826a8b363f209428a3be Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 16:00:03 +0900 Subject: [PATCH 070/102] test(docx): reject hostile FileReader results without reflection --- src/docx/sourceBrandProxy.test.ts | 37 +++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/src/docx/sourceBrandProxy.test.ts b/src/docx/sourceBrandProxy.test.ts index a6a950f0..771ff335 100644 --- a/src/docx/sourceBrandProxy.test.ts +++ b/src/docx/sourceBrandProxy.test.ts @@ -21,6 +21,43 @@ describe('DOCX binary source branding', () => { expect(getPrototypeOf).not.toHaveBeenCalled(); }); + it('rejects hostile FileReader results without invoking prototype traps', async () => { + const source = new Blob([new Uint8Array([1])]); + Object.defineProperty(source, 'arrayBuffer', { + configurable: true, + value: undefined, + }); + const getPrototypeOf = vi.fn(() => { + throw new Error('private FileReader result sentinel'); + }); + const hostileResult = new Proxy(Object.create(null) as object, { + getPrototypeOf, + }); + const originalFileReader = globalThis.FileReader; + + class HostileResultReader { + result: ArrayBuffer | string | null = hostileResult as unknown as ArrayBuffer; + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + + readAsArrayBuffer(): void { + this.onload?.(); + } + } + + vi.stubGlobal('FileReader', HostileResultReader); + try { + await expect(importDocx(source)).rejects.toMatchObject({ + name: 'DocxImportError', + code: 'invalid_source', + message: 'DOCX input must be a supported binary source.', + }); + expect(getPrototypeOf).not.toHaveBeenCalled(); + } finally { + vi.stubGlobal('FileReader', originalFileReader); + } + }); + it('rejects unsupported sources when Blob is unavailable', async () => { vi.stubGlobal('Blob', undefined); try { From 26380af1ff6959c5c2c4d54277f92913b47903a0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 18:47:42 +0900 Subject: [PATCH 071/102] fix(docx): validate FileReader result without prototype traversal --- src/docx/importDocx.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/docx/importDocx.ts b/src/docx/importDocx.ts index 4aa4bbe5..7bf6f120 100644 --- a/src/docx/importDocx.ts +++ b/src/docx/importDocx.ts @@ -90,11 +90,12 @@ async function readBlobBytes(blob: Blob): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { - if (!(reader.result instanceof ArrayBuffer)) { + const result = reader.result; + if (!isIntrinsicArrayBuffer(result)) { reject(new DocxImportError('invalid_source')); return; } - resolve(new Uint8Array(reader.result)); + resolve(new Uint8Array(result)); }; reader.onerror = () => reject(new DocxImportError('invalid_source')); reader.readAsArrayBuffer(blob); From 88e38d27fe360d89f9130492ee7503432e65f239 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 15:02:07 -0700 Subject: [PATCH 072/102] test(docx): reject Blob prototype interposition --- src/docx/sourceBrandProxy.test.ts | 46 +++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/src/docx/sourceBrandProxy.test.ts b/src/docx/sourceBrandProxy.test.ts index 771ff335..a4975cff 100644 --- a/src/docx/sourceBrandProxy.test.ts +++ b/src/docx/sourceBrandProxy.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; +import { createDocx } from '../../test/docxFixture.js'; import { importDocx } from './index.js'; describe('DOCX binary source branding', () => { @@ -21,6 +22,51 @@ describe('DOCX binary source branding', () => { expect(getPrototypeOf).not.toHaveBeenCalled(); }); + it('does not execute post-load Blob size getter interposition', async () => { + const source = new Blob([createDocx()]); + 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 = new Blob([createDocx()]); + 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 { + Object.defineProperty(Blob.prototype, 'arrayBuffer', originalArrayBuffer!); + } + }); + it('rejects hostile FileReader results without invoking prototype traps', async () => { const source = new Blob([new Uint8Array([1])]); Object.defineProperty(source, 'arrayBuffer', { From d250b68997793ff5faa4f2780d2e054697eb20e2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 15:03:54 -0700 Subject: [PATCH 073/102] fix(docx): capture trusted Blob capabilities --- src/docx/importDocx.ts | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/docx/importDocx.ts b/src/docx/importDocx.ts index 7bf6f120..20147f29 100644 --- a/src/docx/importDocx.ts +++ b/src/docx/importDocx.ts @@ -40,6 +40,14 @@ const DATA_VIEW_BYTE_LENGTH_GETTER = Object.getOwnPropertyDescriptor( DataView.prototype, 'byteLength', )!.get!; +const BLOB_SIZE_GETTER = + typeof Blob === 'undefined' + ? undefined + : Object.getOwnPropertyDescriptor(Blob.prototype, 'size')?.get; +const BLOB_ARRAY_BUFFER = + typeof Blob === 'undefined' + ? undefined + : Object.getOwnPropertyDescriptor(Blob.prototype, 'arrayBuffer')?.value; interface ArrayBufferViewRange { readonly buffer: ArrayBufferLike; @@ -57,10 +65,12 @@ function isIntrinsicArrayBuffer(value: unknown): value is ArrayBuffer { } } -/** Read Blob size through the platform prototype without invoking caller overrides. */ +/** Read Blob size through the captured platform intrinsic without invoking caller overrides. */ function readBlobSize(blob: Blob): number { - const getter = Object.getOwnPropertyDescriptor(Blob.prototype, 'size')!.get!; - return getter.call(blob) as 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. */ @@ -80,9 +90,8 @@ async function readBlobBytes(blob: Blob): Promise { ownArrayBuffer !== undefined && 'value' in ownArrayBuffer && ownArrayBuffer.value === undefined; - const platformArrayBuffer = Blob.prototype.arrayBuffer; - if (!ownUndefinedArrayBuffer && typeof platformArrayBuffer === 'function') { - return new Uint8Array(await platformArrayBuffer.call(blob)); + if (!ownUndefinedArrayBuffer && typeof BLOB_ARRAY_BUFFER === 'function') { + return new Uint8Array(await BLOB_ARRAY_BUFFER.call(blob)); } if (typeof FileReader === 'undefined') { throw new DocxImportError('invalid_source'); From a103245650ab510951cf61a4fce313390a9b3db5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 15:05:46 -0700 Subject: [PATCH 074/102] test(docx): use Blob-safe fixture buffer --- src/docx/sourceBrandProxy.test.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/docx/sourceBrandProxy.test.ts b/src/docx/sourceBrandProxy.test.ts index a4975cff..5bd1fcd5 100644 --- a/src/docx/sourceBrandProxy.test.ts +++ b/src/docx/sourceBrandProxy.test.ts @@ -3,6 +3,13 @@ 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(() => { @@ -23,7 +30,7 @@ describe('DOCX binary source branding', () => { }); it('does not execute post-load Blob size getter interposition', async () => { - const source = new Blob([createDocx()]); + const source = createDocxBlob(); const originalSize = Object.getOwnPropertyDescriptor(Blob.prototype, 'size'); const hostileSize = vi.fn(() => { throw new Error('private Blob size sentinel'); @@ -44,7 +51,7 @@ describe('DOCX binary source branding', () => { }); it('does not execute post-load Blob byte-reader interposition', async () => { - const source = new Blob([createDocx()]); + const source = createDocxBlob(); const originalArrayBuffer = Object.getOwnPropertyDescriptor( Blob.prototype, 'arrayBuffer', From 0bc67298ab8f7e49bf0f7251c40afe146b9d8d33 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 15:09:14 -0700 Subject: [PATCH 075/102] fix(docx): avoid Blob accessor interposition --- src/docx/importDocx.ts | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/src/docx/importDocx.ts b/src/docx/importDocx.ts index 20147f29..f3540675 100644 --- a/src/docx/importDocx.ts +++ b/src/docx/importDocx.ts @@ -44,10 +44,6 @@ const BLOB_SIZE_GETTER = typeof Blob === 'undefined' ? undefined : Object.getOwnPropertyDescriptor(Blob.prototype, 'size')?.get; -const BLOB_ARRAY_BUFFER = - typeof Blob === 'undefined' - ? undefined - : Object.getOwnPropertyDescriptor(Blob.prototype, 'arrayBuffer')?.value; interface ArrayBufferViewRange { readonly buffer: ArrayBufferLike; @@ -83,6 +79,25 @@ function tryReadBlobSize(value: unknown): number | undefined { } } +/** Read the current platform Blob byte-reader only when it is a data capability. */ +function readBlobArrayBufferCapability(): + | ((this: Blob) => Promise) + | undefined { + if (typeof Blob === 'undefined') return undefined; + const descriptor = Object.getOwnPropertyDescriptor( + Blob.prototype, + 'arrayBuffer', + ); + if ( + descriptor === undefined || + !('value' in descriptor) || + typeof descriptor.value !== 'function' + ) { + return undefined; + } + return descriptor.value as (this: Blob) => Promise; +} + /** Read one proven Blob without requiring Blob.arrayBuffer() in older DOMs. */ async function readBlobBytes(blob: Blob): Promise { const ownArrayBuffer = Object.getOwnPropertyDescriptor(blob, 'arrayBuffer'); @@ -90,8 +105,9 @@ async function readBlobBytes(blob: Blob): Promise { ownArrayBuffer !== undefined && 'value' in ownArrayBuffer && ownArrayBuffer.value === undefined; - if (!ownUndefinedArrayBuffer && typeof BLOB_ARRAY_BUFFER === 'function') { - return new Uint8Array(await BLOB_ARRAY_BUFFER.call(blob)); + const platformArrayBuffer = readBlobArrayBufferCapability(); + if (!ownUndefinedArrayBuffer && platformArrayBuffer !== undefined) { + return new Uint8Array(await platformArrayBuffer.call(blob)); } if (typeof FileReader === 'undefined') { throw new DocxImportError('invalid_source'); From 6538d444c7484bbdd3644a7541d7aaad57801a3b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 15:09:37 -0700 Subject: [PATCH 076/102] test(docx): restore optional Blob byte reader safely --- src/docx/sourceBrandProxy.test.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/docx/sourceBrandProxy.test.ts b/src/docx/sourceBrandProxy.test.ts index 5bd1fcd5..c1d7c8aa 100644 --- a/src/docx/sourceBrandProxy.test.ts +++ b/src/docx/sourceBrandProxy.test.ts @@ -70,7 +70,15 @@ describe('DOCX binary source branding', () => { }); expect(hostileArrayBuffer).not.toHaveBeenCalled(); } finally { - Object.defineProperty(Blob.prototype, 'arrayBuffer', originalArrayBuffer!); + if (originalArrayBuffer === undefined) { + Reflect.deleteProperty(Blob.prototype, 'arrayBuffer'); + } else { + Object.defineProperty( + Blob.prototype, + 'arrayBuffer', + originalArrayBuffer, + ); + } } }); From 150fe2f1c28bc50a5a0e2f720c196bdcc87b5cde Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 15:14:09 -0700 Subject: [PATCH 077/102] test(docx): pin Blob capability boundary --- src/docx/sourceBrandProxy.test.ts | 39 +++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/docx/sourceBrandProxy.test.ts b/src/docx/sourceBrandProxy.test.ts index c1d7c8aa..aa8d2d6e 100644 --- a/src/docx/sourceBrandProxy.test.ts +++ b/src/docx/sourceBrandProxy.test.ts @@ -82,6 +82,45 @@ describe('DOCX binary source branding', () => { } }); + 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 source = new Blob([new Uint8Array([1])]); Object.defineProperty(source, 'arrayBuffer', { From dd6f4c0bb3953d72ee3720618488bc53373c24d1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 15:15:33 -0700 Subject: [PATCH 078/102] fix(docx): pin Blob platform capabilities --- src/docx/importDocx.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/docx/importDocx.ts b/src/docx/importDocx.ts index f3540675..25d325aa 100644 --- a/src/docx/importDocx.ts +++ b/src/docx/importDocx.ts @@ -40,10 +40,12 @@ 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 = - typeof Blob === 'undefined' + BLOB_PROTOTYPE === undefined ? undefined - : Object.getOwnPropertyDescriptor(Blob.prototype, 'size')?.get; + : Object.getOwnPropertyDescriptor(BLOB_PROTOTYPE, 'size')?.get; interface ArrayBufferViewRange { readonly buffer: ArrayBufferLike; @@ -71,7 +73,6 @@ function readBlobSize(blob: Blob): number { /** Identify a genuine Blob and read its size without prototype traversal. */ function tryReadBlobSize(value: unknown): number | undefined { - if (typeof Blob === 'undefined') return undefined; try { return readBlobSize(value as Blob); } catch { @@ -79,13 +80,12 @@ function tryReadBlobSize(value: unknown): number | undefined { } } -/** Read the current platform Blob byte-reader only when it is a data capability. */ +/** Read the captured platform Blob byte-reader only when it is a data capability. */ function readBlobArrayBufferCapability(): | ((this: Blob) => Promise) | undefined { - if (typeof Blob === 'undefined') return undefined; const descriptor = Object.getOwnPropertyDescriptor( - Blob.prototype, + BLOB_PROTOTYPE as object, 'arrayBuffer', ); if ( From e93520de91388f322eae1f04518295c9da06b03c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 20:37:24 -0700 Subject: [PATCH 079/102] test(docx): pin Blob byte-reader value capability --- src/docx/sourceBrandProxy.test.ts | 33 +++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/docx/sourceBrandProxy.test.ts b/src/docx/sourceBrandProxy.test.ts index aa8d2d6e..7a7fb3e6 100644 --- a/src/docx/sourceBrandProxy.test.ts +++ b/src/docx/sourceBrandProxy.test.ts @@ -82,6 +82,39 @@ describe('DOCX binary source branding', () => { } }); + 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; From e875499745808a8a2edd18be30ba49258d8460ae Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 20:41:20 -0700 Subject: [PATCH 080/102] fix(docx): pin Blob byte-reader capability --- src/docx/importDocx.ts | 33 ++++++++++++--------------------- 1 file changed, 12 insertions(+), 21 deletions(-) diff --git a/src/docx/importDocx.ts b/src/docx/importDocx.ts index 25d325aa..9b1b5c9b 100644 --- a/src/docx/importDocx.ts +++ b/src/docx/importDocx.ts @@ -46,6 +46,16 @@ 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; interface ArrayBufferViewRange { readonly buffer: ArrayBufferLike; @@ -80,24 +90,6 @@ function tryReadBlobSize(value: unknown): number | undefined { } } -/** Read the captured platform Blob byte-reader only when it is a data capability. */ -function readBlobArrayBufferCapability(): - | ((this: Blob) => Promise) - | undefined { - const descriptor = Object.getOwnPropertyDescriptor( - BLOB_PROTOTYPE as object, - 'arrayBuffer', - ); - if ( - descriptor === undefined || - !('value' in descriptor) || - typeof descriptor.value !== 'function' - ) { - return undefined; - } - return descriptor.value as (this: Blob) => Promise; -} - /** Read one proven Blob without requiring Blob.arrayBuffer() in older DOMs. */ async function readBlobBytes(blob: Blob): Promise { const ownArrayBuffer = Object.getOwnPropertyDescriptor(blob, 'arrayBuffer'); @@ -105,9 +97,8 @@ async function readBlobBytes(blob: Blob): Promise { ownArrayBuffer !== undefined && 'value' in ownArrayBuffer && ownArrayBuffer.value === undefined; - const platformArrayBuffer = readBlobArrayBufferCapability(); - if (!ownUndefinedArrayBuffer && platformArrayBuffer !== undefined) { - return new Uint8Array(await platformArrayBuffer.call(blob)); + if (!ownUndefinedArrayBuffer && BLOB_ARRAY_BUFFER !== undefined) { + return new Uint8Array(await BLOB_ARRAY_BUFFER.call(blob)); } if (typeof FileReader === 'undefined') { throw new DocxImportError('invalid_source'); From 8f613b3e3978681771ba300b129eef8c4e6b3b56 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 20:44:54 -0700 Subject: [PATCH 081/102] test(docx): assert module-init Blob capability capture --- src/docx/importDocx.contract.test.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/docx/importDocx.contract.test.ts b/src/docx/importDocx.contract.test.ts index d3edb56f..e9daebba 100644 --- a/src/docx/importDocx.contract.test.ts +++ b/src/docx/importDocx.contract.test.ts @@ -121,7 +121,7 @@ describe('DOCX open/import contract', () => { expect(arrayBufferGetter).not.toHaveBeenCalled(); }); - it('uses a callable platform Blob byte reader without caller method lookup', async () => { + 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( @@ -139,13 +139,17 @@ describe('DOCX open/import contract', () => { 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 importDocx(source); + const result = await importWithCapturedPlatformReader(source); expect(result.documentJson.type).toBe('doc'); expect(platformArrayBuffer).toHaveBeenCalledTimes(1); @@ -160,6 +164,7 @@ describe('DOCX open/import contract', () => { originalDescriptor, ); } + vi.resetModules(); } }); From f43db4d1db51b4b683c827254948c7e4fbdb78ae Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 22:19:10 -0700 Subject: [PATCH 082/102] test(docx): acquire editor capabilities once --- src/docx/openDocxTargetCapability.test.ts | 45 +++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 src/docx/openDocxTargetCapability.test.ts 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); + }); +}); From 31c00ec8bd9beb510ee7236cd967c73ae065459a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 22:20:17 -0700 Subject: [PATCH 083/102] fix(docx): capture editor capabilities once --- src/docx/importDocx.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/docx/importDocx.ts b/src/docx/importDocx.ts index 9b1b5c9b..8054c650 100644 --- a/src/docx/importDocx.ts +++ b/src/docx/importDocx.ts @@ -192,18 +192,21 @@ export async function openDocx( ): 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 target !== 'object' || - target === null || - typeof target.validateDocumentJson !== 'function' || - typeof target.setDocumentJson !== 'function' + typeof validateDocumentJson !== 'function' || + typeof setDocumentJson !== 'function' ) { throw new DocxImportError('editor_rejected_document'); } - if (target.validateDocumentJson(result.documentJson) !== true) { + if (validateDocumentJson.call(target, result.documentJson) !== true) { throw new DocxImportError('incompatible_editor_schema'); } - target.setDocumentJson(result.documentJson); + setDocumentJson.call(target, result.documentJson); return result; } catch (error) { throw normalizeDocxImportError(error, 'editor_rejected_document'); From 75e9465f8436715c39d74ef7e2d3c2f7ba41c6b7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 09:01:45 -0700 Subject: [PATCH 084/102] test(docx): expose mutable ArrayBuffer view authority --- src/docx/sourceBrandProxy.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/docx/sourceBrandProxy.test.ts b/src/docx/sourceBrandProxy.test.ts index 7a7fb3e6..8762b398 100644 --- a/src/docx/sourceBrandProxy.test.ts +++ b/src/docx/sourceBrandProxy.test.ts @@ -29,6 +29,24 @@ describe('DOCX binary source branding', () => { 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 execute post-load Blob size getter interposition', async () => { const source = createDocxBlob(); const originalSize = Object.getOwnPropertyDescriptor(Blob.prototype, 'size'); From 0c3bd5b62ca016705d7cf3172c03314f2d8da75c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 09:05:15 -0700 Subject: [PATCH 085/102] fix(docx): capture ArrayBuffer view classifier --- src/docx/importDocx.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/docx/importDocx.ts b/src/docx/importDocx.ts index 8054c650..8ca769cd 100644 --- a/src/docx/importDocx.ts +++ b/src/docx/importDocx.ts @@ -13,6 +13,7 @@ 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; @@ -144,7 +145,7 @@ async function snapshotSource( let view: Uint8Array; if (isIntrinsicArrayBuffer(source)) { view = new Uint8Array(source); - } else if (ArrayBuffer.isView(source)) { + } else if (ARRAY_BUFFER_IS_VIEW(source)) { const { buffer, byteOffset, byteLength } = readArrayBufferViewRange(source); if (!isIntrinsicArrayBuffer(buffer)) { throw new DocxImportError('invalid_source'); From 77a9c9dad63520dd2065c5c1b5adf0571a32ee58 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 09:13:11 -0700 Subject: [PATCH 086/102] test(docx): expose mutable TextDecoder authority --- src/docx/sourceBrandProxy.test.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/docx/sourceBrandProxy.test.ts b/src/docx/sourceBrandProxy.test.ts index 8762b398..953612c1 100644 --- a/src/docx/sourceBrandProxy.test.ts +++ b/src/docx/sourceBrandProxy.test.ts @@ -47,6 +47,28 @@ describe('DOCX binary source branding', () => { } }); + 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'); From d25e0137406adbffa2fdd394a4d4ad7bd1f0a65b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 09:15:27 -0700 Subject: [PATCH 087/102] fix(docx): capture ZIP text decoders --- src/docx/zip.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/docx/zip.ts b/src/docx/zip.ts index f281d0e7..d7c0128d 100644 --- a/src/docx/zip.ts +++ b/src/docx/zip.ts @@ -9,6 +9,9 @@ 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; interface ZipEntry { readonly name: string; @@ -45,14 +48,14 @@ function decodeEntryName(nameBytes: Uint8Array, flags: number): string { if (nameBytes.byteLength === 0) throw new DocxImportError('invalid_zip'); try { if ((flags & UTF8_FLAG) !== 0) { - return new TextDecoder('utf-8', { fatal: true }).decode(nameBytes); + 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 new TextDecoder('ascii', { fatal: true }).decode(nameBytes); + return TEXT_DECODER_DECODE.call(ASCII_ENTRY_NAME_DECODER, nameBytes); } catch (error) { throw normalizeDocxImportError(error, 'invalid_zip'); } From fea6d1914ec94e6ed9f946758d2f739165f395ff Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 09:16:34 -0700 Subject: [PATCH 088/102] fix(docx): capture XML text decoder --- src/docx/xml.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/docx/xml.ts b/src/docx/xml.ts index f3f77b32..65020f17 100644 --- a/src/docx/xml.ts +++ b/src/docx/xml.ts @@ -3,6 +3,8 @@ 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 { @@ -235,7 +237,7 @@ export function parseXml( } let source: string; try { - source = new TextDecoder('utf-8', { fatal: true }).decode(bytes); + source = TEXT_DECODER_DECODE.call(XML_UTF8_DECODER, bytes); } catch (error) { throw normalizeDocxImportError(error, 'invalid_xml'); } From 29779fdc5510d4c7a10ce4c8295b954df24fcff4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 12:10:31 -0700 Subject: [PATCH 089/102] test(docx): reject live FileReader authority --- src/docx/importDocxBlobCapability.test.ts | 53 +++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 src/docx/importDocxBlobCapability.test.ts 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); + } + }); +}); From 440332ea2011bba5799d3a614fb87dab69680e04 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 12:11:04 -0700 Subject: [PATCH 090/102] fix(docx): keep Blob reads on captured intrinsic --- src/docx/importDocx.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/docx/importDocx.ts b/src/docx/importDocx.ts index 8ca769cd..e235b0f6 100644 --- a/src/docx/importDocx.ts +++ b/src/docx/importDocx.ts @@ -93,12 +93,7 @@ function tryReadBlobSize(value: unknown): number | undefined { /** Read one proven Blob without requiring Blob.arrayBuffer() in older DOMs. */ async function readBlobBytes(blob: Blob): Promise { - const ownArrayBuffer = Object.getOwnPropertyDescriptor(blob, 'arrayBuffer'); - const ownUndefinedArrayBuffer = - ownArrayBuffer !== undefined && - 'value' in ownArrayBuffer && - ownArrayBuffer.value === undefined; - if (!ownUndefinedArrayBuffer && BLOB_ARRAY_BUFFER !== undefined) { + if (BLOB_ARRAY_BUFFER !== undefined) { return new Uint8Array(await BLOB_ARRAY_BUFFER.call(blob)); } if (typeof FileReader === 'undefined') { From 4b434d83e15955280ecb69111e9a52e92e5d4434 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 12:17:52 -0700 Subject: [PATCH 091/102] test(docx): isolate FileReader fallback authority --- .../importDocxFileReaderCapability.test.ts | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 src/docx/importDocxFileReaderCapability.test.ts diff --git a/src/docx/importDocxFileReaderCapability.test.ts b/src/docx/importDocxFileReaderCapability.test.ts new file mode 100644 index 00000000..91e83900 --- /dev/null +++ b/src/docx/importDocxFileReaderCapability.test.ts @@ -0,0 +1,73 @@ +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 platformArrayBuffer = ORIGINAL_BLOB_ARRAY_BUFFER?.value as + | ((this: Blob) => Promise) + | undefined; + expect(typeof platformArrayBuffer).toBe('function'); + + class TrustedFileReader { + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + result: ArrayBuffer | null = null; + + readAsArrayBuffer(blob: Blob): void { + void platformArrayBuffer! + .call(blob) + .then((result) => { + this.result = result; + queueMicrotask(() => this.onload?.()); + }) + .catch(() => queueMicrotask(() => this.onerror?.())); + } + } + + 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); + }); +}); From 3549070d5d32cd5beeb8e06448585512d9ee8ae4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 12:19:52 -0700 Subject: [PATCH 092/102] fix(docx): capture FileReader fallback authority --- src/docx/importDocx.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/docx/importDocx.ts b/src/docx/importDocx.ts index e235b0f6..d141c98d 100644 --- a/src/docx/importDocx.ts +++ b/src/docx/importDocx.ts @@ -57,6 +57,12 @@ const BLOB_ARRAY_BUFFER = 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; @@ -96,11 +102,14 @@ async function readBlobBytes(blob: Blob): Promise { if (BLOB_ARRAY_BUFFER !== undefined) { return new Uint8Array(await BLOB_ARRAY_BUFFER.call(blob)); } - if (typeof FileReader === 'undefined') { + 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 FileReader(); + const reader = new FILE_READER_CONSTRUCTOR(); reader.onload = () => { const result = reader.result; if (!isIntrinsicArrayBuffer(result)) { @@ -110,7 +119,7 @@ async function readBlobBytes(blob: Blob): Promise { resolve(new Uint8Array(result)); }; reader.onerror = () => reject(new DocxImportError('invalid_source')); - reader.readAsArrayBuffer(blob); + FILE_READER_READ_AS_ARRAY_BUFFER.call(reader, blob); }); } From 9cc7d6b0ebd0218c8aca1a722f55b132013f0ba6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 12:22:51 -0700 Subject: [PATCH 093/102] test(docx): isolate decompression stream authority --- src/docx/zipDecompressionCapability.test.ts | 45 +++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 src/docx/zipDecompressionCapability.test.ts diff --git a/src/docx/zipDecompressionCapability.test.ts b/src/docx/zipDecompressionCapability.test.ts new file mode 100644 index 00000000..743b1d1a --- /dev/null +++ b/src/docx/zipDecompressionCapability.test.ts @@ -0,0 +1,45 @@ +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(); + } + }); +}); From b2829c57a09594b8ebef709e46752cfbdf4b40c9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 12:25:10 -0700 Subject: [PATCH 094/102] fix(docx): capture decompression stream authority --- src/docx/zip.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/docx/zip.ts b/src/docx/zip.ts index d7c0128d..d65cd7d8 100644 --- a/src/docx/zip.ts +++ b/src/docx/zip.ts @@ -12,6 +12,10 @@ 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; interface ZipEntry { readonly name: string; @@ -112,18 +116,18 @@ async function inflateRaw( expectedBytes: number, ): Promise { if ( - typeof DecompressionStream === 'undefined' || - typeof ReadableStream === 'undefined' + DECOMPRESSION_STREAM_CONSTRUCTOR === undefined || + READABLE_STREAM_CONSTRUCTOR === undefined ) { throw new DocxImportError('decompression_unavailable'); } let transform: DecompressionStream; try { - transform = new DecompressionStream('deflate-raw'); + transform = new DECOMPRESSION_STREAM_CONSTRUCTOR('deflate-raw'); } catch { throw new DocxImportError('decompression_unavailable'); } - const input = new ReadableStream({ + const input = new READABLE_STREAM_CONSTRUCTOR({ start(controller) { controller.enqueue(Uint8Array.from(compressed)); controller.close(); From a429a23e41822ffba3e61b7662fe93b8ff2769a7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 12:55:46 -0700 Subject: [PATCH 095/102] test(docx): make fallback capability fixture portable --- src/docx/importDocxFileReaderCapability.test.ts | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/src/docx/importDocxFileReaderCapability.test.ts b/src/docx/importDocxFileReaderCapability.test.ts index 91e83900..38fae48b 100644 --- a/src/docx/importDocxFileReaderCapability.test.ts +++ b/src/docx/importDocxFileReaderCapability.test.ts @@ -25,24 +25,16 @@ afterEach(() => { describe('DOCX FileReader fallback capability isolation', () => { it('uses the FileReader capability captured when the fallback module initializes', async () => { - const platformArrayBuffer = ORIGINAL_BLOB_ARRAY_BUFFER?.value as - | ((this: Blob) => Promise) - | undefined; - expect(typeof platformArrayBuffer).toBe('function'); + const capturedBytes = new Uint8Array([0x50, 0x4b]).buffer; class TrustedFileReader { onload: (() => void) | null = null; onerror: (() => void) | null = null; result: ArrayBuffer | null = null; - readAsArrayBuffer(blob: Blob): void { - void platformArrayBuffer! - .call(blob) - .then((result) => { - this.result = result; - queueMicrotask(() => this.onload?.()); - }) - .catch(() => queueMicrotask(() => this.onerror?.())); + readAsArrayBuffer(): void { + this.result = capturedBytes.slice(0); + queueMicrotask(() => this.onload?.()); } } From 647cf3d261da5bd328808a1a0496dd23a8d53cac Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 12:56:34 -0700 Subject: [PATCH 096/102] test(docx): initialize hostile fallback capability before import --- src/docx/sourceBrandProxy.test.ts | 33 +++++++++++++++++++++++-------- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/src/docx/sourceBrandProxy.test.ts b/src/docx/sourceBrandProxy.test.ts index 953612c1..f1189844 100644 --- a/src/docx/sourceBrandProxy.test.ts +++ b/src/docx/sourceBrandProxy.test.ts @@ -195,18 +195,16 @@ describe('DOCX binary source branding', () => { }); it('rejects hostile FileReader results without invoking prototype traps', async () => { - const source = new Blob([new Uint8Array([1])]); - Object.defineProperty(source, 'arrayBuffer', { - configurable: true, - value: undefined, - }); + 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, }); - const originalFileReader = globalThis.FileReader; class HostileResultReader { result: ArrayBuffer | string | null = hostileResult as unknown as ArrayBuffer; @@ -218,16 +216,35 @@ describe('DOCX binary source branding', () => { } } + 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 { - await expect(importDocx(source)).rejects.toMatchObject({ + 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 { - vi.stubGlobal('FileReader', originalFileReader); + if (originalArrayBuffer === undefined) { + Reflect.deleteProperty(Blob.prototype, 'arrayBuffer'); + } else { + Object.defineProperty( + Blob.prototype, + 'arrayBuffer', + originalArrayBuffer, + ); + } + vi.unstubAllGlobals(); + vi.resetModules(); } }); From 235af3d350fc04ca30c9eced7a34cc577d2ff3f2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 13:00:21 -0700 Subject: [PATCH 097/102] test(docx): align fallback checks with captured capabilities --- src/docx/docxCoverageGaps.test.ts | 130 +++++++++++++++++++++--------- 1 file changed, 92 insertions(+), 38 deletions(-) diff --git a/src/docx/docxCoverageGaps.test.ts b/src/docx/docxCoverageGaps.test.ts index f7702da2..c68efa90 100644 --- a/src/docx/docxCoverageGaps.test.ts +++ b/src/docx/docxCoverageGaps.test.ts @@ -298,14 +298,25 @@ describe('DOCX ZIP safety coverage', () => { const compressed = buildZip({ 'a.txt': 'abc' }, 8); try { + vi.resetModules(); vi.stubGlobal('DecompressionStream', undefined); - await expectAsyncCode(ZipArchive.parse(compressed, limits).read('a.txt'), 'decompression_unavailable'); + 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); - await expectAsyncCode(ZipArchive.parse(compressed, limits).read('a.txt'), 'decompression_unavailable'); + const { ZipArchive: MissingReadableZipArchive } = await import('./zip.js'); + await expectAsyncCode( + MissingReadableZipArchive.parse(compressed, limits).read('a.txt'), + 'decompression_unavailable', + ); - vi.stubGlobal('ReadableStream', originalReadableStream); + vi.resetModules(); vi.stubGlobal( 'DecompressionStream', class { @@ -314,10 +325,15 @@ describe('DOCX ZIP safety coverage', () => { } }, ); - await expectAsyncCode(ZipArchive.parse(compressed, limits).read('a.txt'), 'decompression_unavailable'); - } finally { - vi.stubGlobal('DecompressionStream', originalDecompressionStream); 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]) { @@ -341,46 +357,84 @@ describe('DOCX source and editor boundary coverage', () => { it('uses the bounded FileReader fallback without trusting malformed reader results', async () => { const bytes = createDocx({ method: 0 }); - const fallbackBlob = new Blob([blobPart(bytes)]); - Object.defineProperty(fallbackBlob, 'arrayBuffer', { value: undefined }); - const originalFileReader = globalThis.FileReader; + const originalBlobArrayBuffer = Object.getOwnPropertyDescriptor( + Blob.prototype, + 'arrayBuffer', + ); - try { - class SuccessfulReader { - result: ArrayBuffer | string | null = null; - onload: (() => void) | null = null; - onerror: (() => void) | null = null; - readAsArrayBuffer(): void { - this.result = blobPart(bytes); - this.onload?.(); - } + class SuccessfulReader { + result: ArrayBuffer | string | null = null; + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + readAsArrayBuffer(): void { + this.result = blobPart(bytes); + this.onload?.(); } - vi.stubGlobal('FileReader', SuccessfulReader); - await expect(importDocx(fallbackBlob)).resolves.toMatchObject({ - documentJson: { type: 'doc' }, - }); + } - class WrongResultReader extends SuccessfulReader { - override readAsArrayBuffer(): void { - this.result = 'not-an-array-buffer'; - this.onload?.(); - } + class WrongResultReader extends SuccessfulReader { + override readAsArrayBuffer(): void { + this.result = 'not-an-array-buffer'; + this.onload?.(); } - vi.stubGlobal('FileReader', WrongResultReader); - await expectAsyncCode(importDocx(fallbackBlob), 'invalid_source'); + } - class ErrorReader extends SuccessfulReader { - override readAsArrayBuffer(): void { - this.onerror?.(); - } + class ErrorReader extends SuccessfulReader { + override readAsArrayBuffer(): void { + this.onerror?.(); } - vi.stubGlobal('FileReader', ErrorReader); - await expectAsyncCode(importDocx(fallbackBlob), 'invalid_source'); + } - vi.stubGlobal('FileReader', undefined); - await expectAsyncCode(importDocx(fallbackBlob), 'invalid_source'); + 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 { - vi.stubGlobal('FileReader', originalFileReader); + if (originalBlobArrayBuffer === undefined) { + Reflect.deleteProperty(Blob.prototype, 'arrayBuffer'); + } else { + Object.defineProperty( + Blob.prototype, + 'arrayBuffer', + originalBlobArrayBuffer, + ); + } + vi.unstubAllGlobals(); + vi.resetModules(); } }); From c69978791abef362e3e887132f3e0138e9da80e5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 13:28:43 -0700 Subject: [PATCH 098/102] test(docx): isolate deflate copy capability --- src/docx/zipDecompressionCapability.test.ts | 29 +++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/docx/zipDecompressionCapability.test.ts b/src/docx/zipDecompressionCapability.test.ts index 743b1d1a..ccac5b72 100644 --- a/src/docx/zipDecompressionCapability.test.ts +++ b/src/docx/zipDecompressionCapability.test.ts @@ -42,4 +42,33 @@ describe('DOCX ZIP decompression capability isolation', () => { 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'); + expect(fromDescriptor).toBeDefined(); + 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 { + Object.defineProperty(Uint8Array, 'from', fromDescriptor!); + } + }); }); From e4761aa367c7baf4c19448b2cfe437eeca4c20b8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 13:30:38 -0700 Subject: [PATCH 099/102] fix(docx): capture deflate copy intrinsic --- src/docx/zip.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/docx/zip.ts b/src/docx/zip.ts index d65cd7d8..c2c9669b 100644 --- a/src/docx/zip.ts +++ b/src/docx/zip.ts @@ -16,6 +16,8 @@ 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 UINT8_ARRAY_CONSTRUCTOR = Uint8Array; +const UINT8_ARRAY_FROM = Uint8Array.from; interface ZipEntry { readonly name: string; @@ -129,7 +131,9 @@ async function inflateRaw( } const input = new READABLE_STREAM_CONSTRUCTOR({ start(controller) { - controller.enqueue(Uint8Array.from(compressed)); + controller.enqueue( + UINT8_ARRAY_FROM.call(UINT8_ARRAY_CONSTRUCTOR, compressed), + ); controller.close(); }, }); From bcff73beb6014afd2418c176018416e14068773f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 13:31:21 -0700 Subject: [PATCH 100/102] test(docx): isolate deflate stream piping capability --- src/docx/zipDecompressionCapability.test.ts | 36 +++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/docx/zipDecompressionCapability.test.ts b/src/docx/zipDecompressionCapability.test.ts index ccac5b72..486ccbcf 100644 --- a/src/docx/zipDecompressionCapability.test.ts +++ b/src/docx/zipDecompressionCapability.test.ts @@ -71,4 +71,40 @@ describe('DOCX ZIP decompression capability isolation', () => { 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!, + ); + } + }); }); From b5bee1763dc8fddab802be9cb425ca070555fc37 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 13:32:11 -0700 Subject: [PATCH 101/102] fix(docx): capture deflate piping intrinsic --- src/docx/zip.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/docx/zip.ts b/src/docx/zip.ts index c2c9669b..b6dff405 100644 --- a/src/docx/zip.ts +++ b/src/docx/zip.ts @@ -16,6 +16,10 @@ 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; @@ -119,7 +123,8 @@ async function inflateRaw( ): Promise { if ( DECOMPRESSION_STREAM_CONSTRUCTOR === undefined || - READABLE_STREAM_CONSTRUCTOR === undefined + READABLE_STREAM_CONSTRUCTOR === undefined || + READABLE_STREAM_PIPE_THROUGH === undefined ) { throw new DocxImportError('decompression_unavailable'); } @@ -137,7 +142,11 @@ async function inflateRaw( controller.close(); }, }); - const reader = input.pipeThrough(transform).getReader(); + const decompressed = READABLE_STREAM_PIPE_THROUGH.call( + input, + transform, + ) as ReadableStream; + const reader = decompressed.getReader(); const output = new Uint8Array(expectedBytes); let offset = 0; try { From bc49f1ffe11ebdeb7db325a984e5a6ff398c4f08 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 13:36:47 -0700 Subject: [PATCH 102/102] test(docx): restore inherited Uint8Array.from safely --- src/docx/zipDecompressionCapability.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/docx/zipDecompressionCapability.test.ts b/src/docx/zipDecompressionCapability.test.ts index 486ccbcf..1c1ab553 100644 --- a/src/docx/zipDecompressionCapability.test.ts +++ b/src/docx/zipDecompressionCapability.test.ts @@ -49,7 +49,6 @@ describe('DOCX ZIP decompression capability isolation', () => { 8, ); const fromDescriptor = Object.getOwnPropertyDescriptor(Uint8Array, 'from'); - expect(fromDescriptor).toBeDefined(); let hostileFromCalls = 0; Object.defineProperty(Uint8Array, 'from', { @@ -68,7 +67,11 @@ describe('DOCX ZIP decompression capability isolation', () => { ).resolves.toBe('trusted compressed payload'); expect(hostileFromCalls).toBe(0); } finally { - Object.defineProperty(Uint8Array, 'from', fromDescriptor!); + if (fromDescriptor === undefined) { + Reflect.deleteProperty(Uint8Array, 'from'); + } else { + Object.defineProperty(Uint8Array, 'from', fromDescriptor); + } } });