diff --git a/README.md b/README.md index f2b02332..da98a1ab 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,7 @@ runtime. | React editor | `@contextualwisdomlab/cwl-editor` | Markdown/HTML WYSIWYG component and serializers | | Collaboration | `@contextualwisdomlab/cwl-editor/collaboration` | Provider-neutral Yjs collaborative editing | | Converter | `@contextualwisdomlab/cwl-editor/converter` | Framework-independent base64/data-URI utilities | +| Hangul documents | `@contextualwisdomlab/cwl-editor/hangul` | Framework-independent HWP/HWPX import/export bridge with a host-injected engine | | Envelope identity | `@contextualwisdomlab/cwl-editor/envelope-identity` | Framework-independent bounded schema identity for host-owned migration routing | | Revision evidence | `@contextualwisdomlab/cwl-editor/revision-evidence` | Framework-independent canonical envelope, strong revision, and transition evidence | | Text-position selector | `@contextualwisdomlab/cwl-editor/text-position-selector` | React-free deterministic W3C `TextPositionSelector` projection core | diff --git a/docs/HANGUL.md b/docs/HANGUL.md new file mode 100644 index 00000000..5253a850 --- /dev/null +++ b/docs/HANGUL.md @@ -0,0 +1,139 @@ +# HWP and HWPX authoring + +Inkspan's Hangul bridge opens HWP/HWPX bytes through a host-provided parser/serializer and projects supported content into the same TipTap/ProseMirror JSON edited by `CwlEditor`. HWPX is the recommended save target because it is the open XML/OWPML path standardized by KS X 6101; legacy HWP remains an explicit compatibility target when the selected engine supports it. + +## Authority boundary + +Inkspan owns: + +- the editable TipTap JSON projection; +- deterministic conversion rules; +- stable error semantics; +- byte/resource limits; +- explicit loss reporting; +- deterministic public capability metadata for the bounded bridge. + +The host owns: + +- file pickers and drag/drop; +- filesystem and object-storage access; +- WASM or native-engine initialization; +- passwords and protected-document UX; +- publication/download behavior; +- telemetry and persistence. + +The Hangul package never fetches external resources and never executes active content from a document. + +## Import flow + +```mermaid +sequenceDiagram + participant Host + participant Bridge as Inkspan Hangul bridge + participant Engine as Host-provided HWP engine + participant Editor as CwlEditor + + Host->>Bridge: openHangulDocument(bytes, { engine }) + Bridge->>Engine: open(bytes) + Engine-->>Bridge: bounded document API + Bridge->>Engine: source format / sections / HTML projection + Bridge-->>Host: { documentJson, sourceFormat, warnings, lossy, capabilities } + Host->>Editor: setDocumentJson(documentJson) +``` + +The original bytes remain host-owned. Importing a file does not mutate it. The returned `capabilities` object is frozen, deterministic Inkspan metadata: it declares `importFormats`, `exportFormats`, `recommendedExportFormat`, and the currently round-trippable `supportedContent`. Hosts can use that metadata for UI and routing without probing the host engine or inferring support from failures. + +## Export flow + +```mermaid +sequenceDiagram + participant Host + participant Editor as CwlEditor + participant Bridge as Inkspan Hangul bridge + participant Engine as Host-provided HWP engine + + Host->>Editor: getDocumentJson() + Editor-->>Host: edited JSON + Host->>Bridge: exportHangulDocument(JSON, format) + Bridge->>Engine: create blank document + Bridge->>Engine: paste bounded deterministic HTML + Bridge->>Engine: exportHwpx() or exportHwp() + Engine-->>Bridge: bytes + Bridge-->>Host: { bytes, format, warnings } +``` + +## Minimal integration + +```ts +import { + exportHangulDocument, + openHangulDocument, + type HangulDocumentEngine, +} from '@contextualwisdomlab/cwl-editor/hangul'; + +async function openIntoEditor( + source: Uint8Array, + engine: HangulDocumentEngine, + editor: { setDocumentJson(value: unknown): void }, +) { + const imported = await openHangulDocument(source, { engine }); + editor.setDocumentJson(imported.documentJson); + return imported; +} + +async function saveAsHwpx( + documentJson: Parameters[0], + engine: HangulDocumentEngine, +) { + return exportHangulDocument(documentJson, { + engine, + format: 'hwpx', + }); +} +``` + +## Compatibility contract + +The initial bridge deliberately supports a bounded semantic subset and rejects unsupported export nodes instead of silently deleting them. The compatibility matrix expands only when real HWP/HWPX fixtures demonstrate stable round-trip behavior. `capabilities.supportedContent` is the machine-consumable projection of the same currently implemented subset; this table remains the human-readable contract and limitation guide. + +Committed synthetic OWPML fixtures under `src/hangul/fixtures/` are the current known-document suite. `briefing-minutes.section.xml` must project to the expected heading, paragraph, and table cells when wrapped as HWPX or legacy HWP. `unsupported-shape.section.xml` must fail closed; Inkspan does not keep surrounding paragraphs while dropping a shape. After export, the same fixture engine reopens the bytes and compares semantic JSON. These fixtures are synthetic and contain no customer documents. To inspect a mismatch, open the exact source fixture and compare it against the committed expected paragraphs and tables. + +| Content | Import | Export | Notes | +|---|---|---|---| +| Paragraph text | Yes | Yes | Unicode preserved by JavaScript strings and the selected engine | +| Headings 1-6 | Yes | Yes | Semantic heading level | +| Bold | Yes | Yes | Common HTML projection | +| Italic | Yes | Yes | Common HTML projection | +| Strike | Yes | Yes | Common HTML projection | +| Lists | Yes | Yes | Structural bullet and ordered lists; explicit start-number metadata is not modeled | +| Block quotes | Yes | Yes | Nested supported block content is preserved | +| Code blocks | Yes | Yes | Text content is preserved; language metadata is not modeled | +| Basic tables | Yes | Yes | Header/cell topology is preserved; spans and layout styling are not modeled | +| Links | Planned | Planned | Must use Inkspan safe-link policy | +| Images | Planned | Planned | Must remain inline/host-approved; no external fetch | +| Shapes/charts/equations | Rejected | Rejected | Fail closed; no silent drop | +| Macros/OLE/active content | Not executed | Not generated | Outside the editor authority boundary | + +## Failure containment + +The host engine is untrusted at every call boundary, including cleanup. Open/create/operation failures are normalized to stable payload-redacted `HangulDocumentError` values. If engine cleanup fails after an otherwise successful public operation, Inkspan reports `ENGINE_CLEANUP_FAILED` without reading or stringifying the host-thrown value. If cleanup fails while Inkspan is already propagating a normalized primary import/export error, the primary error remains authoritative and the secondary cleanup failure is contained. + +## Security requirements + +Treat both formats as untrusted document containers. Production implementations must enforce bounded source and output bytes. The bridge also rejects host structural metadata above 4,096 sections, 1,000,000 paragraphs per section, or 16,777,216 UTF-16 code units per paragraph before traversing or passing those values back to the host engine. These are Inkspan safety ceilings, not HWP/HWPX format maxima. + +A native HWPX implementation must additionally bound ZIP entry count, expanded bytes, expansion ratio, XML depth, XML node count, text size, relationships, and embedded payloads. DTD and external entity resolution must be disabled. External relationships are metadata only unless the host separately authorizes a resource. + +Passwords, cookies, credentials, filesystem paths, and secret values must never enter warnings, error strings, result objects, or deterministic snapshots. + +## Standards and format sources + +HWPX follows OWPML document structure standardized as KS X 6101. The Korean standards catalogue records the standard as confirmed on 2024-10-30. Hancom publishes HWP 5.x and OWPML format material and recommends HWPX as the open machine-readable Hangul document format. + +### References (APA 7th) + +Korean Agency for Technology and Standards. (2024). *KS X 6101: Open Word-Processor Markup Language (OWPML) document structure*. e-Nara Standard Certification. https://www.standard.go.kr/KSCI/standardIntro/getStandardSearchView.do?ksNo=KSX6101 + +Hancom Inc. (n.d.). *HWP/OWPML formats*. https://license.hancom.com/support/downloadCenter/hwpOwpml + +Hancom Inc. (n.d.). *HWPX format structure*. Hancom Tech. https://tech.hancom.com/hwpxformat/ diff --git a/docs/adr/0030-hangul-document-authoring-boundary.md b/docs/adr/0030-hangul-document-authoring-boundary.md new file mode 100644 index 00000000..091181bb --- /dev/null +++ b/docs/adr/0030-hangul-document-authoring-boundary.md @@ -0,0 +1,76 @@ +# ADR 0030: Hangul document authoring boundary + +- Status: Proposed +- Date: 2026-08-14 +- Decision owners: Inkspan maintainers + +## Context + +Inkspan needs to open, edit, and save Korean Hangul Word Processor documents without turning the editor package into a filesystem-, network-, or vendor-runtime-owning application. HWP 5.x is a published binary format. HWPX is the XML-based Hangul standard-document format built on OWPML, whose document structure is standardized as KS X 6101. The Korean standards catalogue records KS X 6101 as current after confirmation on 2024-10-30. Hancom also publishes HWP/OWPML format material and describes HWPX as an OWPML-based, machine-readable format. + +The editor already treats TipTap/ProseMirror JSON as the editable document authority. Introducing a second editable authority for HWP/HWPX would make autosave, collaboration, revision evidence, and host integration inconsistent. + +## Alternatives considered + +1. Parse HWP/HWPX directly inside the React editor. Rejected because binary/XML parsing, optional WASM initialization, document resources, and UI lifecycle become coupled. +2. Convert every document through HTML and keep HTML as the editing authority. Rejected because HTML cannot represent every Hangul layout primitive and would make conversion loss implicit. +3. Introduce a framework-neutral Hangul bridge with a host-injected parser/serializer engine and TipTap JSON as the editing authority. Selected. + +## Decision + +Inkspan exposes a framework-neutral Hangul bridge under a dedicated package boundary. The bridge accepts HWP/HWPX bytes through a host-injected engine, projects the supported semantic subset to TipTap JSON, and serializes edited JSON back through the engine. HWPX is the recommended export format because it is the open XML/OWPML path; HWP remains an explicit compatibility export. + +The host owns file selection, filesystem access, network access, WASM/module initialization, password UX, persistence, and download/publication. The bridge receives bytes and returns bytes. It never fetches external document resources. + +Unsupported structures are never silently asserted to be lossless. Import results carry warnings and a lossy flag. Export rejects editor structures that cannot be represented by the current bridge rather than dropping them silently. + +Host-returned structural metadata is untrusted work/index input. Before traversing or passing such metadata back into the host engine, Inkspan rejects values above 4,096 sections, 1,000,000 paragraphs per section, or 16,777,216 UTF-16 code units per paragraph. These are Inkspan safety ceilings, not HWP/HWPX format maxima; changing them is a resource-safety decision that requires corresponding regression evidence. + +## Consequences + +- Existing `CwlEditorHandle.setDocumentJson()` remains the single editing ingress. +- HWP and HWPX share one product API while their parsing implementations remain replaceable. +- Parser/serializer upgrades do not require React changes. +- Full visual round-trip fidelity is not claimed until covered by real-document compatibility fixtures. +- HWPX can later gain a first-party native OWPML implementation without changing the public bridge contract. +- A forged engine cannot turn a small input into effectively unbounded section traversal or oversized paragraph indexes/offsets merely by returning safe-integer metadata. + +## Failure and recovery semantics + +Malformed input, unsupported source identity, resource-limit breaches, engine failures, and unsupported export structures fail closed with stable error codes. The original input is never mutated. Hosts may keep the original bytes and offer a fallback download or alternate viewer. + +## Security and privacy impact + +HWP/HWPX bytes are untrusted input. The bridge has no remote-resource fetch path and no active-content execution path. Source and output byte bounds are enforced before publication. Host structural counts and paragraph lengths are bounded before traversal, indexing, deletion, or HTML projection. Credentials, cookies, filesystem paths, and document passwords are not part of result objects or telemetry contracts. + +A future native HWPX parser must additionally bound ZIP entries, expansion ratio, XML depth, XML node count, text length, relationship targets, embedded objects, and external references. DTD and external-entity resolution must remain disabled. + +## Compatibility and migration + +The public contract identifies source and output as `hwp` or `hwpx`. HWPX is preferred for newly saved documents. Existing HWP users can explicitly request HWP export when their selected engine supports it. If a later native HWPX implementation replaces the initial engine adapter, compatibility is governed by the same JSON projection tests and real-document fixture suite. + +## Verification and acceptance evidence + +Acceptance requires all of the following on one exact PR head: + +- HWP and HWPX import tests; +- edited JSON to HWPX and HWP export tests; +- committed synthetic `briefing-minutes` and `unsupported-shape` fixtures that project known paragraphs/tables and fail closed on unsupported structures; +- real documents reopened after export and compared against expected semantic content; +- hostile/malformed input and resource-limit tests, including over-limit host section/paragraph metadata that fails before child traversal or host mutation; +- package-consumer verification for ESM, CommonJS, and declarations; +- production statement and branch coverage at repository policy thresholds; +- public API docstring coverage at repository policy thresholds; +- required CI, SAST, security, and independent review gates. + +Until that evidence is merged to protected `main`, this ADR remains Proposed. + +## Rollback or supersession + +The feature can be rolled back by removing the Hangul package subpath while retaining this ADR as historical evidence. A future design that makes native OWPML the canonical editable authority or grants the package filesystem/network authority requires a superseding ADR. + +## Standards and source traceability + +- Korean Agency for Technology and Standards. (2024). *KS X 6101: Open Word-Processor Markup Language (OWPML) document structure*. e-Nara Standard Certification. https://www.standard.go.kr/KSCI/standardIntro/getStandardSearchView.do?ksNo=KSX6101 +- Hancom Inc. (n.d.). *HWP/OWPML formats*. https://license.hancom.com/support/downloadCenter/hwpOwpml +- Hancom Inc. (n.d.). *HWPX format structure*. Hancom Tech. https://tech.hancom.com/hwpxformat/ diff --git a/docs/adr/README.md b/docs/adr/README.md index df8b7b80..0f77b206 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -32,6 +32,7 @@ This index records durable architectural decisions. Protected-main implementatio | [0024](0024-bounded-docx-paragraph-alignment.md) | Accepted | Bounded paragraph alignment in deterministic DOCX output | | [0025](0025-bounded-docx-heading-alignment.md) | Accepted | Bounded heading alignment in deterministic DOCX output | | [0026](0026-bounded-docx-external-hyperlinks.md) | Accepted | Bounded external hyperlinks in deterministic DOCX rich text | +| [0030](0030-hangul-document-authoring-boundary.md) | Proposed | Hangul document authoring boundary | ## Decision discipline diff --git a/docs/hangul-workstream.md b/docs/hangul-workstream.md new file mode 100644 index 00000000..627f2eba --- /dev/null +++ b/docs/hangul-workstream.md @@ -0,0 +1,3 @@ +# HWP and HWPX workstream + +This branch tracks the isolated, non-release implementation for issue #319. diff --git a/docs/package-distribution.md b/docs/package-distribution.md index ddb4df0e..4cc916ca 100644 --- a/docs/package-distribution.md +++ b/docs/package-distribution.md @@ -1,10 +1,10 @@ # Package distribution and consumer contract Inkspan publishes the React editor, provider-neutral collaboration adapter, -framework-independent autosave/evidence/converter utilities, CSS, and offline -font assets from one npm package. This document defines the supported package -boundary for standalone applications, CWL organization services, and naruon -integrations. +framework-independent autosave/evidence/converter/Hangul utilities, CSS, and +offline font assets from one npm package. This document defines the supported +package boundary for standalone applications, CWL organization services, and +naruon integrations. ## Public entrypoints @@ -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/hangul` | `implemented_on_active_pr` — framework-independent HWP/HWPX byte-to-document bridge with a host-injected parser/serializer engine; filesystem, network, persistence, credentials, and publication remain host-owned | | `@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,17 @@ 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, Hangul, 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 Hangul subpath accepts and returns bytes through a host-injected document + engine. Inkspan owns the deterministic supported JSON projection, local + source/output resource ceilings, and stable failure contract; the host owns + file selection, filesystem and network access, engine/WASM initialization, + password UX, durable persistence, credentials, and artifact publication. - The Markdown subpath exposes `markdownToHtml`, `htmlToMarkdown`, `normalizeMarkdown`, `markdownToEmailHtml`, `markdownToPlainText`, and `htmlToPlainText` plus their option types. It bundles deterministic conversion @@ -108,10 +114,10 @@ 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, - 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; +5. imports the root, collaboration, converter, Hangul, autosave, + 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; 6. exercises supported ESM/CommonJS entrypoints and compiles strict TypeScript consumers against the published declaration surfaces; 7. resolves public CSS and font subpaths; and diff --git a/package.json b/package.json index 4e55d924..febd81bd 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,10 @@ "noto-sans", "offline", "i18n", - "cjk" + "cjk", + "hwp", + "hwpx", + "owpml" ], "repository": { "type": "git", @@ -85,6 +88,11 @@ "import": "./dist/cwl-markdown.js", "require": "./dist/cwl-markdown.cjs" }, + "./hangul": { + "types": "./dist/hangul/index.d.ts", + "import": "./dist/cwl-hangul.js", + "require": "./dist/cwl-hangul.cjs" + }, "./styles.css": "./dist/cwl-editor.css", "./fonts.css": "./src/fonts/fonts.css", "./fonts-latin.css": "./src/fonts/fonts-latin.css", @@ -99,7 +107,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.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 && vite build --config vite.hangul.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 +116,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-hangul-subpath-package.mjs" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", diff --git a/scripts/verify-hangul-subpath-package.mjs b/scripts/verify-hangul-subpath-package.mjs new file mode 100644 index 00000000..1c2f4221 --- /dev/null +++ b/scripts/verify-hangul-subpath-package.mjs @@ -0,0 +1,135 @@ +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'; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const packageJson = JSON.parse( + readFileSync(join(repositoryRoot, 'package.json'), 'utf8'), +); +const verificationRoot = mkdtempSync(join(tmpdir(), 'inkspan-hangul-')); +const extractionDirectory = join(verificationRoot, 'extracted'); +const consumerDirectory = join(verificationRoot, 'consumer'); +const packageDirectory = join( + consumerDirectory, + 'node_modules', + ...packageJson.name.split('/'), +); + +function run(command, argumentsList, cwd = repositoryRoot) { + return execFileSync(command, argumentsList, { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'inherit'], + }); +} + +function preparePackage() { + mkdirSync(extractionDirectory, { recursive: true }); + mkdirSync(dirname(packageDirectory), { recursive: true }); + const packResult = JSON.parse( + run('npm', [ + 'pack', + '--json', + '--ignore-scripts', + '--pack-destination', + verificationRoot, + ]), + )[0]; + 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-hangul-consumer","private":true,"type":"module"}\n', + 'utf8', + ); +} + +function verifyBundleAuthority() { + for (const filename of ['cwl-hangul.js', 'cwl-hangul.cjs']) { + const source = readFileSync(join(packageDirectory, 'dist', filename), 'utf8'); + assert.doesNotMatch(source, /\bfetch\s*\(|XMLHttpRequest|WebSocket|EventSource/u); + assert.doesNotMatch(source, /react-dom|@tiptap\/react|y-prosemirror|\byjs\b/u); + assert.doesNotMatch(source, /process\.env|import\.meta\.env|NVIDIA_NIM_API_KEY|COPILOT_GITHUB_TOKEN/u); + } +} + +function verifyRuntimeConsumers() { + const esmPath = join(consumerDirectory, 'consumer.mjs'); + writeFileSync( + esmPath, + `import assert from 'node:assert/strict';\nconst api = await import('${packageJson.name}/hangul');\nassert.equal(typeof api.openHangulDocument, 'function');\nassert.equal(typeof api.exportHangulDocument, 'function');\nassert.equal(typeof api.HangulDocumentError, 'function');\n`, + 'utf8', + ); + run(process.execPath, [esmPath], consumerDirectory); + + const cjsPath = join(consumerDirectory, 'consumer.cjs'); + writeFileSync( + cjsPath, + `const assert = require('node:assert/strict');\nconst api = require('${packageJson.name}/hangul');\nassert.equal(typeof api.openHangulDocument, 'function');\nassert.equal(typeof api.exportHangulDocument, 'function');\n`, + 'utf8', + ); + run(process.execPath, [cjsPath], consumerDirectory); +} + +function verifyDeclarationConsumer() { + const sourcePath = join(consumerDirectory, 'consumer.ts'); + const configPath = join(consumerDirectory, 'tsconfig.json'); + writeFileSync( + sourcePath, + `import {\n HangulDocumentError,\n exportHangulDocument,\n openHangulDocument,\n type HangulDocumentEngine,\n type HangulEngineDocument,\n} from '${packageJson.name}/hangul';\nconst document = null as unknown as HangulEngineDocument;\nconst engine = null as unknown as HangulDocumentEngine;\nvoid [HangulDocumentError, openHangulDocument, exportHangulDocument, document, engine];\n`, + 'utf8', + ); + writeFileSync( + configPath, + `${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', configPath], consumerDirectory); +} + +try { + preparePackage(); + verifyBundleAuthority(); + verifyRuntimeConsumers(); + verifyDeclarationConsumer(); + console.log(`Verified packed ${packageJson.name}/hangul ESM, CommonJS, and declarations.`); +} finally { + rmSync(verificationRoot, { recursive: true, force: true }); +} diff --git a/src/adrQualityContract.test.ts b/src/adrQualityContract.test.ts index 04dcef71..909cc09b 100644 --- a/src/adrQualityContract.test.ts +++ b/src/adrQualityContract.test.ts @@ -50,4 +50,21 @@ describe('ADR quality documentation contract', () => { } } }); + + it('indexes every detailed ADR exactly once under its filename identity', () => { + const adrIndex = repositoryFile('docs/adr/README.md'); + const adrFiles = detailedAdrFiles(); + const identifiers = adrFiles.map((name) => name.slice(0, 4)); + + expect(new Set(identifiers).size).toBe(identifiers.length); + + for (const adrFile of adrFiles) { + const identifier = adrFile.slice(0, 4); + const link = `[${identifier}](${adrFile})`; + expect( + adrIndex.split(link).length - 1, + `${adrFile} must have exactly one canonical index row`, + ).toBe(1); + } + }); }); diff --git a/src/hangul/capabilities.test.ts b/src/hangul/capabilities.test.ts new file mode 100644 index 00000000..52cfeb42 --- /dev/null +++ b/src/hangul/capabilities.test.ts @@ -0,0 +1,81 @@ +import { + openHangulDocument, + type HangulDocumentEngine, + type HangulEngineDocument, +} from './index.js'; + +class CapabilityDocument implements HangulEngineDocument { + getSourceFormat(): string { + return 'hwpx'; + } + + getSectionCount(): number { + return 1; + } + + getParagraphCount(): number { + return 1; + } + + getParagraphLength(): number { + return 4; + } + + exportSelectionHtml(): string { + return '

Body

'; + } + + deleteText(): string { + return '{"ok":true}'; + } + + pasteHtml(): string { + return '{"ok":true}'; + } + + exportHwp(): Uint8Array { + return new Uint8Array([1]); + } + + exportHwpx(): Uint8Array { + return new Uint8Array([2]); + } +} + +function createEngine(): HangulDocumentEngine { + return { + id: 'capability-test', + open: async () => new CapabilityDocument(), + create: async () => new CapabilityDocument(), + }; +} + +describe('Hangul public capability contract', () => { + it('returns the deterministic bridge capabilities required by the public import contract', async () => { + const result = await openHangulDocument(new Uint8Array([9]), { + engine: createEngine(), + }); + + expect(result.capabilities).toEqual({ + importFormats: ['hwp', 'hwpx'], + exportFormats: ['hwpx', 'hwp'], + recommendedExportFormat: 'hwpx', + supportedContent: [ + 'paragraph', + 'heading', + 'bold', + 'italic', + 'strike', + 'bulletList', + 'orderedList', + 'blockquote', + 'codeBlock', + 'table', + ], + }); + expect(Object.isFrozen(result.capabilities)).toBe(true); + expect(Object.isFrozen(result.capabilities.importFormats)).toBe(true); + expect(Object.isFrozen(result.capabilities.exportFormats)).toBe(true); + expect(Object.isFrozen(result.capabilities.supportedContent)).toBe(true); + }); +}); diff --git a/src/hangul/cleanupFailure.test.ts b/src/hangul/cleanupFailure.test.ts new file mode 100644 index 00000000..c42c31d1 --- /dev/null +++ b/src/hangul/cleanupFailure.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest'; + +import { + exportHangulDocument, + HangulDocumentError, + openHangulDocument, + type HangulDocumentEngine, + type HangulEngineDocument, +} from './index.js'; + +const PRIVATE_CLEANUP_SENTINEL = new Error('private cleanup sentinel'); + +class CleanupThrowingDocument implements HangulEngineDocument { + constructor(readonly selectionHtml = '

safe

') {} + + getSourceFormat(): string { + return 'hwpx'; + } + + getSectionCount(): number { + return 1; + } + + getParagraphCount(): number { + return 1; + } + + getParagraphLength(): number { + return 0; + } + + exportSelectionHtml(): string { + return this.selectionHtml; + } + + deleteText(): string { + return '{"ok":true}'; + } + + pasteHtml(): string { + return '{"ok":true}'; + } + + exportHwp(): Uint8Array { + return new Uint8Array([1]); + } + + exportHwpx(): Uint8Array { + return new Uint8Array([2]); + } + + free(): void { + throw PRIVATE_CLEANUP_SENTINEL; + } +} + +function engineFor( + source: HangulEngineDocument, + target: HangulEngineDocument = new CleanupThrowingDocument(), +): HangulDocumentEngine { + return { + id: 'cleanup-failure-test', + open: async () => source, + create: async () => target, + }; +} + +describe('Hangul engine cleanup containment', () => { + it('redacts cleanup failure after an otherwise successful import', async () => { + await expect( + openHangulDocument(new Uint8Array([1]), { + engine: engineFor(new CleanupThrowingDocument()), + }), + ).rejects.toMatchObject({ + code: 'ENGINE_CLEANUP_FAILED', + message: 'The Hangul engine failed during cleanup.', + }); + }); + + it('preserves a primary Inkspan import failure when cleanup also fails', async () => { + await expect( + openHangulDocument(new Uint8Array([1]), { + engine: engineFor( + new CleanupThrowingDocument( + '', + ), + ), + }), + ).rejects.toEqual( + new HangulDocumentError( + 'UNSUPPORTED_DOCUMENT_NODE', + 'Hangul import contains an unsupported block node.', + ), + ); + }); + + it('redacts cleanup failure after an otherwise successful export', async () => { + const source = new CleanupThrowingDocument(); + const target = new CleanupThrowingDocument(); + + await expect( + exportHangulDocument( + { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'safe' }], + }, + ], + }, + { engine: engineFor(source, target) }, + ), + ).rejects.toMatchObject({ + code: 'ENGINE_CLEANUP_FAILED', + message: 'The Hangul engine failed during cleanup.', + }); + }); +}); diff --git a/src/hangul/diagnosticPrivacy.test.ts b/src/hangul/diagnosticPrivacy.test.ts new file mode 100644 index 00000000..8f10abf5 --- /dev/null +++ b/src/hangul/diagnosticPrivacy.test.ts @@ -0,0 +1,62 @@ +import { + HangulDocumentError, + exportHangulDocument, + type HangulDocumentEngine, +} from './index.js'; + +const engine: HangulDocumentEngine = { + id: 'diagnostic-privacy-test', + open: async () => { + throw new Error('open should not be reached'); + }, + create: async () => { + throw new Error('create should not be reached'); + }, +}; + +async function captureExportError( + documentJson: Parameters[0], +): Promise { + try { + await exportHangulDocument(documentJson, { engine }); + } catch (error) { + expect(error).toBeInstanceOf(HangulDocumentError); + return error as HangulDocumentError; + } + throw new Error('expected Hangul export to reject unsupported content'); +} + +describe('Hangul export diagnostic privacy', () => { + it('does not reflect an unsupported caller-controlled block type', async () => { + const privateBlockType = 'customer-secret-block'; + const error = await captureExportError({ + type: 'doc', + content: [{ type: privateBlockType }], + }); + + expect(error.code).toBe('UNSUPPORTED_DOCUMENT_NODE'); + expect(error.message).not.toContain(privateBlockType); + }); + + it('does not reflect an unsupported caller-controlled mark type', async () => { + const privateMarkType = 'customer-secret-mark'; + const error = await captureExportError({ + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'x', + marks: [{ type: privateMarkType }], + }, + ], + }, + ], + }); + + expect(error.code).toBe('UNSUPPORTED_DOCUMENT_MARK'); + expect(error.message).not.toContain(privateMarkType); + }); +}); diff --git a/src/hangul/documentFixtures.test.ts b/src/hangul/documentFixtures.test.ts new file mode 100644 index 00000000..eebb4c58 --- /dev/null +++ b/src/hangul/documentFixtures.test.ts @@ -0,0 +1,645 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { crc32 } from 'node:zlib'; + +import { + exportHangulDocument, + openHangulDocument, + type HangulDocumentEngine, + type HangulEngineDocument, +} from './index.js'; + +const FIXTURES_DIR = join(process.cwd(), 'src/hangul/fixtures'); +const BRIEFING_MINUTES_XML = readFileSync( + join(FIXTURES_DIR, 'briefing-minutes.section.xml'), + 'utf8', +); +const UNSUPPORTED_SHAPE_XML = readFileSync( + join(FIXTURES_DIR, 'unsupported-shape.section.xml'), + 'utf8', +); +const TEXT_ENCODER = new TextEncoder(); +const TEXT_DECODER = new TextDecoder(); +const OLE_MAGIC = Uint8Array.from([ + 0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1, +]); +const HWP_FIXTURE_MARKER = TEXT_ENCODER.encode('INKSPAN-HWP-FIXTURE\0'); +const HWPX_MIME_TYPE = 'application/hwp+zip'; +const HWPX_VERSION_XML = + '1.0'; +const OWPML_SECTION_NAMESPACES = + 'xmlns:hs="http://www.hancom.co.kr/hwpml/2011/section" xmlns:hp="http://www.hancom.co.kr/hwpml/2011/paragraph"'; +const UNSUPPORTED_OWPML_BLOCKS = new Set([ + 'rect', + 'line', + 'ellipse', + 'arc', + 'polygon', + 'curve', + 'equation', + 'chart', + 'pic', + 'ole', + 'btn', + 'video', +]); + +const BRIEFING_MINUTES_JSON = { + type: 'doc', + content: [ + { + type: 'heading', + attrs: { level: 1 }, + content: [{ type: 'text', text: 'Briefing Minutes' }], + }, + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'Attendees reviewed the quarterly status report.', + }, + ], + }, + { + type: 'table', + content: [ + { + type: 'tableRow', + content: [ + { + type: 'tableHeader', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'Topic Name' }], + }, + ], + }, + { + type: 'tableHeader', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'Owner Team' }], + }, + ], + }, + ], + }, + { + type: 'tableRow', + content: [ + { + type: 'tableCell', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'Budget Review' }], + }, + ], + }, + { + type: 'tableCell', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'Finance Team' }], + }, + ], + }, + ], + }, + ], + }, + ], +}; + +interface ZipEntry { + name: string; + data: Uint8Array; +} + +/** Concatenate owned byte parts into one exact buffer. */ +function concatBytes(parts: readonly Uint8Array[]): Uint8Array { + const total = parts.reduce((sum, part) => sum + part.length, 0); + const output = new Uint8Array(total); + let offset = 0; + for (const part of parts) { + output.set(part, offset); + offset += part.length; + } + return output; +} + +/** Encode a little-endian unsigned 16-bit value. */ +function encodeUint16(value: number): Uint8Array { + const output = new Uint8Array(2); + new DataView(output.buffer).setUint16(0, value, true); + return output; +} + +/** Encode a little-endian unsigned 32-bit value. */ +function encodeUint32(value: number): Uint8Array { + const output = new Uint8Array(4); + new DataView(output.buffer).setUint32(0, value, true); + return output; +} + +/** Read a little-endian unsigned 16-bit value. */ +function readUint16(source: Uint8Array, offset: number): number { + return new DataView(source.buffer, source.byteOffset, source.byteLength).getUint16( + offset, + true, + ); +} + +/** Read a little-endian unsigned 32-bit value. */ +function readUint32(source: Uint8Array, offset: number): number { + return new DataView(source.buffer, source.byteOffset, source.byteLength).getUint32( + offset, + true, + ); +} + +/** Build an uncompressed ZIP container for a synthetic HWPX fixture. */ +function buildZip(entries: readonly ZipEntry[]): Uint8Array { + const locals: Uint8Array[] = []; + const centrals: Uint8Array[] = []; + let offset = 0; + for (const entry of entries) { + const name = TEXT_ENCODER.encode(entry.name); + const crc = crc32(entry.data) >>> 0; + const local = concatBytes([ + encodeUint32(0x04034b50), + encodeUint16(20), + encodeUint16(0), + encodeUint16(0), + encodeUint16(0), + encodeUint16(0), + encodeUint32(crc), + encodeUint32(entry.data.length), + encodeUint32(entry.data.length), + encodeUint16(name.length), + encodeUint16(0), + name, + entry.data, + ]); + locals.push(local); + centrals.push( + concatBytes([ + encodeUint32(0x02014b50), + encodeUint16(20), + encodeUint16(20), + encodeUint16(0), + encodeUint16(0), + encodeUint16(0), + encodeUint16(0), + encodeUint32(crc), + encodeUint32(entry.data.length), + encodeUint32(entry.data.length), + encodeUint16(name.length), + encodeUint16(0), + encodeUint16(0), + encodeUint16(0), + encodeUint16(0), + encodeUint32(0), + encodeUint32(offset), + name, + ]), + ); + offset += local.length; + } + const centralDirectory = concatBytes(centrals); + return concatBytes([ + ...locals, + centralDirectory, + encodeUint32(0x06054b50), + encodeUint16(0), + encodeUint16(0), + encodeUint16(entries.length), + encodeUint16(entries.length), + encodeUint32(centralDirectory.length), + encodeUint32(offset), + encodeUint16(0), + ]); +} + +/** Read uncompressed ZIP entries from a synthetic HWPX fixture. */ +function readZip(source: Uint8Array): ZipEntry[] { + if (source.length < 22 || readUint32(source, source.length - 22) !== 0x06054b50) { + throw new Error('invalid hangul fixture'); + } + const entryCount = readUint16(source, source.length - 12); + let centralOffset = readUint32(source, source.length - 6); + const entries: ZipEntry[] = []; + for (let index = 0; index < entryCount; index += 1) { + if (readUint32(source, centralOffset) !== 0x02014b50) { + throw new Error('invalid hangul fixture'); + } + const nameLength = readUint16(source, centralOffset + 28); + const extraLength = readUint16(source, centralOffset + 30); + const commentLength = readUint16(source, centralOffset + 32); + const localOffset = readUint32(source, centralOffset + 42); + const name = TEXT_DECODER.decode( + source.subarray(centralOffset + 46, centralOffset + 46 + nameLength), + ); + const localNameLength = readUint16(source, localOffset + 26); + const localExtraLength = readUint16(source, localOffset + 28); + const dataStart = localOffset + 30 + localNameLength + localExtraLength; + const size = readUint32(source, localOffset + 18); + entries.push({ + name, + data: source.subarray(dataStart, dataStart + size), + }); + centralOffset += 46 + nameLength + extraLength + commentLength; + } + return entries; +} + +/** Wrap one OWPML section as a synthetic HWPX ZIP container. */ +function buildHwpxFixture(sectionXml: string): Uint8Array { + return buildZip([ + { name: 'mimetype', data: TEXT_ENCODER.encode(HWPX_MIME_TYPE) }, + { name: 'version.xml', data: TEXT_ENCODER.encode(HWPX_VERSION_XML) }, + { name: 'Contents/section0.xml', data: TEXT_ENCODER.encode(sectionXml) }, + ]); +} + +/** Wrap one OWPML section as a synthetic legacy HWP fixture container. */ +function buildHwpFixture(sectionXml: string): Uint8Array { + return concatBytes([ + OLE_MAGIC, + HWP_FIXTURE_MARKER, + TEXT_ENCODER.encode(sectionXml), + ]); +} + +/** Return whether the snapshot begins with the OLE compound-document magic. */ +function hasOleMagic(source: Uint8Array): boolean { + return ( + source.length >= OLE_MAGIC.length && + OLE_MAGIC.every((value, index) => source[index] === value) + ); +} + +/** Extract the OWPML section from a synthetic HWP fixture. */ +function readHwpSection(source: Uint8Array): string { + const markerStart = OLE_MAGIC.length; + const markerEnd = markerStart + HWP_FIXTURE_MARKER.length; + const marker = source.subarray(markerStart, markerEnd); + if ( + marker.length !== HWP_FIXTURE_MARKER.length || + HWP_FIXTURE_MARKER.some((value, index) => marker[index] !== value) + ) { + throw new Error('invalid hangul fixture'); + } + return TEXT_DECODER.decode(source.subarray(markerEnd)); +} + +/** Extract the OWPML section from a synthetic HWPX ZIP fixture. */ +function readHwpxSection(source: Uint8Array): string { + const section = readZip(source).find((entry) => entry.name === 'Contents/section0.xml'); + if (section === undefined) { + throw new Error('invalid hangul fixture'); + } + return TEXT_DECODER.decode(section.data); +} + +/** Escape text that will be placed into the HTML projection. */ +function escapeHtml(value: string): string { + return value.replace(/&/gu, '&').replace(//gu, '>'); +} + +/** Read the significant text of one OWPML run without pretty-print whitespace. */ +function runText(run: Element): string { + return Array.from(run.children) + .filter((child) => child.localName === 't') + .map((text) => text.textContent ?? '') + .join(''); +} + +/** Project OWPML inline runs into the HTML the public bridge already accepts. */ +function projectRuns(paragraph: Element): string { + return Array.from(paragraph.children) + .filter((child) => child.localName === 'run') + .map((run) => { + const text = escapeHtml(runText(run)); + const charPr = run.getAttribute('charPrIDRef'); + if (charPr === 'bold') return `${text}`; + if (charPr === 'italic') return `${text}`; + if (charPr === 'strike') return `${text}`; + return text; + }) + .join(''); +} + +/** Collect significant run text from one OWPML paragraph. */ +function runTextFromParagraph(paragraph: Element): string { + return Array.from(paragraph.children) + .filter((child) => child.localName === 'run') + .map(runText) + .join(''); +} + +/** Project one OWPML paragraph or heading. */ +function projectParagraph(paragraph: Element): string { + const heading = /^(?:heading-([1-6]))$/u.exec( + paragraph.getAttribute('paraPrIDRef') ?? '', + ); + const content = projectRuns(paragraph); + if (heading) { + return `${content}`; + } + return `

${content}

`; +} + +/** Project one OWPML table into header/cell HTML topology. */ +function projectTable(table: Element): string { + const rows = Array.from(table.children) + .filter((child) => child.localName === 'tr') + .map((row) => { + const cells = Array.from(row.children) + .filter((child) => child.localName === 'tc') + .map((cell) => { + const tag = cell.getAttribute('header') === '1' ? 'th' : 'td'; + const paragraphs = Array.from(cell.getElementsByTagName('*')).filter( + (child) => child.localName === 'p', + ); + const text = paragraphs.map((paragraph) => runTextFromParagraph(paragraph)).join(''); + return `<${tag}>${escapeHtml(text)}`; + }) + .join(''); + return `${cells}`; + }) + .join(''); + return `${rows}
`; +} + +/** Project one top-level OWPML block, failing closed for unsupported structures. */ +function projectBlock(element: Element): string { + switch (element.localName) { + case 'p': + return projectParagraph(element); + case 'tbl': + return projectTable(element); + default: + if (UNSUPPORTED_OWPML_BLOCKS.has(element.localName)) { + return ``; + } + throw new Error('invalid hangul fixture'); + } +} + +/** Project a committed OWPML section into the HTML the public bridge consumes. */ +function projectSectionXml(sectionXml: string): string { + const parsed = new DOMParser().parseFromString(sectionXml, 'application/xml'); + if (parsed.querySelector('parsererror')) { + throw new Error('invalid hangul fixture'); + } + return Array.from(parsed.documentElement.children).map(projectBlock).join(''); +} + +/** Render inline HTML back into OWPML runs. */ +function htmlInlineToOwpml(parent: Element): string { + return Array.from(parent.childNodes) + .map((child) => { + if (child.nodeType === Node.TEXT_NODE) { + const text = child.textContent ?? ''; + return text === '' ? '' : `${escapeHtml(text)}`; + } + if (!(child instanceof Element)) return ''; + const tag = child.tagName.toLowerCase(); + const text = `${escapeHtml(child.textContent ?? '')}`; + if (tag === 'strong' || tag === 'b') { + return `${text}`; + } + if (tag === 'em' || tag === 'i') { + return `${text}`; + } + if (tag === 's' || tag === 'strike') { + return `${text}`; + } + if (tag === 'p') return htmlInlineToOwpml(child); + throw new Error('invalid hangul fixture'); + }) + .join(''); +} + +/** Render one exported HTML block back into the fixture OWPML subset. */ +function htmlBlockToOwpml(element: Element): string { + const tag = element.tagName.toLowerCase(); + const heading = /^h([1-6])$/u.exec(tag); + if (heading) { + return `${htmlInlineToOwpml(element)}`; + } + if (tag === 'p') { + return `${htmlInlineToOwpml(element)}`; + } + if (tag === 'table') { + const rows = Array.from((element as HTMLTableElement).rows) + .map((row) => { + const cells = Array.from(row.cells) + .map((cell) => { + const header = cell.tagName.toLowerCase() === 'th' ? ' header="1"' : ''; + return `${htmlInlineToOwpml(cell)}`; + }) + .join(''); + return `${cells}`; + }) + .join(''); + return `${rows}`; + } + throw new Error('invalid hangul fixture'); +} + +/** Convert pasted bridge HTML back into a fixture OWPML section. */ +function htmlToSectionXml(html: string): string { + const parsed = new DOMParser().parseFromString(html, 'text/html'); + const body = Array.from(parsed.body.children).map(htmlBlockToOwpml).join(''); + return `${body}`; +} + +class FixtureDocument implements HangulEngineDocument { + freed = false; + private readonly sourceFormat: 'hwp' | 'hwpx'; + private readonly selectionHtml: string; + private pastedHtml = ''; + + constructor(sourceFormat: 'hwp' | 'hwpx', selectionHtml: string) { + this.sourceFormat = sourceFormat; + this.selectionHtml = selectionHtml; + } + + static fromSource(source: Uint8Array): FixtureDocument { + if (hasOleMagic(source)) { + return new FixtureDocument('hwp', projectSectionXml(readHwpSection(source))); + } + if (source.length >= 4 && readUint32(source, 0) === 0x04034b50) { + return new FixtureDocument('hwpx', projectSectionXml(readHwpxSection(source))); + } + throw new Error('invalid hangul fixture'); + } + + static createEmpty(format: 'hwp' | 'hwpx'): FixtureDocument { + return new FixtureDocument(format, ''); + } + + getSourceFormat(): string { + return this.sourceFormat; + } + + getSectionCount(): number { + return 1; + } + + getParagraphCount(): number { + return this.selectionHtml === '' && this.pastedHtml === '' ? 0 : 1; + } + + getParagraphLength(): number { + return 0; + } + + exportSelectionHtml(): string { + return this.selectionHtml; + } + + deleteText(): string { + return '{"ok":true}'; + } + + pasteHtml( + _sectionIndex: number, + _paragraphIndex: number, + _charOffset: number, + html: string, + ): string { + this.pastedHtml = html; + return '{"ok":true}'; + } + + exportHwp(): Uint8Array { + return buildHwpFixture(htmlToSectionXml(this.pastedHtml)); + } + + exportHwpx(): Uint8Array { + return buildHwpxFixture(htmlToSectionXml(this.pastedHtml)); + } + + free(): void { + this.freed = true; + } +} + +/** Create a host-injected engine that only understands committed synthetic fixtures. */ +function createFixtureEngine(): HangulDocumentEngine & { + lastOpened?: FixtureDocument; +} { + const engine: HangulDocumentEngine & { lastOpened?: FixtureDocument } = { + id: 'hangul-fixture-engine', + open: async (source) => { + const opened = FixtureDocument.fromSource(source); + engine.lastOpened = opened; + return opened; + }, + create: async () => FixtureDocument.createEmpty('hwpx'), + }; + return engine; +} + +describe('Hangul realistic document fixtures', () => { + it('projects the known HWPX briefing document into the expected paragraphs and table', async () => { + const engine = createFixtureEngine(); + const result = await openHangulDocument(buildHwpxFixture(BRIEFING_MINUTES_XML), { + engine, + }); + + expect(result.sourceFormat).toBe('hwpx'); + expect(result.documentJson).toEqual(BRIEFING_MINUTES_JSON); + expect(result.lossy).toBe(false); + expect(engine.lastOpened?.freed).toBe(true); + }); + + it('projects the known HWP briefing document into the same paragraphs and table', async () => { + const result = await openHangulDocument(buildHwpFixture(BRIEFING_MINUTES_XML), { + engine: createFixtureEngine(), + }); + + expect(result.sourceFormat).toBe('hwp'); + expect(result.documentJson).toEqual(BRIEFING_MINUTES_JSON); + }); + + it('reopens exported HWPX and HWP bytes as the same semantic document', async () => { + const engine = createFixtureEngine(); + const hwpx = await exportHangulDocument(BRIEFING_MINUTES_JSON, { + engine, + format: 'hwpx', + }); + const hwp = await exportHangulDocument(BRIEFING_MINUTES_JSON, { + engine, + format: 'hwp', + }); + + expect(hwpx.format).toBe('hwpx'); + expect(hwp.format).toBe('hwp'); + expect(hwpx.bytes[0]).toBe(0x50); + expect(hwp.bytes[0]).toBe(0xd0); + + const reopenedHwpx = await openHangulDocument(hwpx.bytes, { engine }); + const reopenedHwp = await openHangulDocument(hwp.bytes, { engine }); + expect(reopenedHwpx.documentJson).toEqual(BRIEFING_MINUTES_JSON); + expect(reopenedHwp.documentJson).toEqual(BRIEFING_MINUTES_JSON); + }); + + it('fails closed on an HWPX shape instead of keeping the surrounding paragraph', async () => { + const privateText = 'tenant-secret-shape'; + let caught: unknown; + + try { + await openHangulDocument(buildHwpxFixture(UNSUPPORTED_SHAPE_XML), { + engine: createFixtureEngine(), + }); + } catch (error) { + caught = error; + } + + expect(caught).toMatchObject({ + name: 'HangulDocumentError', + code: 'UNSUPPORTED_DOCUMENT_NODE', + message: 'Hangul import contains an unsupported block node.', + }); + expect((caught as Error).message).not.toContain(privateText); + expect((caught as Error).message).not.toContain('Opening Remarks'); + }); + + it('fails closed on a legacy HWP shape without reflecting fixture text', async () => { + const privateText = 'tenant-secret-shape'; + let caught: unknown; + + try { + await openHangulDocument(buildHwpFixture(UNSUPPORTED_SHAPE_XML), { + engine: createFixtureEngine(), + }); + } catch (error) { + caught = error; + } + + expect(caught).toMatchObject({ + code: 'UNSUPPORTED_DOCUMENT_NODE', + message: 'Hangul import contains an unsupported block node.', + }); + expect((caught as Error).message).not.toContain(privateText); + }); + + it('fails closed on bytes that are not a known HWP or HWPX fixture', async () => { + await expect( + openHangulDocument(new Uint8Array([0, 1, 2, 3]), { + engine: createFixtureEngine(), + }), + ).rejects.toMatchObject({ + code: 'ENGINE_OPEN_FAILED', + message: 'The Hangul engine could not open the document.', + }); + }); +}); diff --git a/src/hangul/documentJsonFailureContainment.test.ts b/src/hangul/documentJsonFailureContainment.test.ts new file mode 100644 index 00000000..98a94fcf --- /dev/null +++ b/src/hangul/documentJsonFailureContainment.test.ts @@ -0,0 +1,37 @@ +import { + exportHangulDocument, + type HangulDocumentEngine, +} from './index.js'; + +describe('Hangul export document JSON failure containment', () => { + it('rejects hostile document access without leaking the thrown value or creating the engine', async () => { + const privateSentinel = { secret: 'private-document-json-sentinel' }; + const create = vi.fn(async () => { + throw new Error('engine create should not run'); + }); + const engine: HangulDocumentEngine = { + id: 'hostile-document-json-sentinel', + open: vi.fn(async () => { + throw new Error('engine open should not run'); + }), + create, + }; + const documentJson = new Proxy( + {}, + { + get() { + throw privateSentinel; + }, + }, + ) as Parameters[0]; + + await expect( + exportHangulDocument(documentJson, { engine }), + ).rejects.toMatchObject({ + name: 'HangulDocumentError', + code: 'INVALID_DOCUMENT', + message: 'Hangul document JSON is invalid.', + }); + expect(create).not.toHaveBeenCalled(); + }); +}); diff --git a/src/hangul/documentationContract.test.ts b/src/hangul/documentationContract.test.ts new file mode 100644 index 00000000..4a585e2f --- /dev/null +++ b/src/hangul/documentationContract.test.ts @@ -0,0 +1,59 @@ +import { readFileSync } from 'node:fs'; + +import { describe, expect, it } from 'vitest'; + +const hangulGuide = readFileSync('docs/HANGUL.md', 'utf8'); +const hangulAdr = readFileSync( + 'docs/adr/0030-hangul-document-authoring-boundary.md', + 'utf8', +); + +describe('Hangul compatibility documentation', () => { + it('documents the structures exercised by the public round-trip contract', () => { + expect(hangulGuide).toContain( + '| Lists | Yes | Yes | Structural bullet and ordered lists; explicit start-number metadata is not modeled |', + ); + expect(hangulGuide).toContain( + '| Block quotes | Yes | Yes | Nested supported block content is preserved |', + ); + expect(hangulGuide).toContain( + '| Code blocks | Yes | Yes | Text content is preserved; language metadata is not modeled |', + ); + expect(hangulGuide).toContain( + '| Basic tables | Yes | Yes | Header/cell topology is preserved; spans and layout styling are not modeled |', + ); + expect(hangulGuide).not.toContain('| Lists | Planned | Planned |'); + expect(hangulGuide).not.toContain('| Tables | Planned | Planned |'); + expect(hangulGuide).toContain( + '| Shapes/charts/equations | Rejected | Rejected | Fail closed; no silent drop |', + ); + expect(hangulGuide).toContain('src/hangul/fixtures/'); + expect(hangulGuide).toContain('briefing-minutes.section.xml'); + expect(hangulGuide).toContain('unsupported-shape.section.xml'); + expect(hangulGuide).toContain('fail closed'); + expect(hangulGuide).toContain( + 'open the exact source fixture and compare it against the committed expected paragraphs and tables', + ); + }); + + it('documents the capability metadata returned by the public import API', () => { + expect(hangulGuide).toContain( + '{ documentJson, sourceFormat, warnings, lossy, capabilities }', + ); + expect(hangulGuide).toContain('`capabilities` object is frozen'); + expect(hangulGuide).toContain('`importFormats`'); + expect(hangulGuide).toContain('`exportFormats`'); + expect(hangulGuide).toContain('`recommendedExportFormat`'); + expect(hangulGuide).toContain('`supportedContent`'); + }); + + it('documents the finite untrusted-engine traversal ceilings as Inkspan safety limits', () => { + for (const document of [hangulGuide, hangulAdr]) { + expect(document).toContain('4,096 sections'); + expect(document).toContain('1,000,000 paragraphs per section'); + expect(document).toContain('16,777,216 UTF-16 code units per paragraph'); + expect(document).toContain('Inkspan safety ceilings'); + expect(document).toContain('not HWP/HWPX format maxima'); + } + }); +}); diff --git a/src/hangul/engineAdapter.ts b/src/hangul/engineAdapter.ts new file mode 100644 index 00000000..7326fd86 --- /dev/null +++ b/src/hangul/engineAdapter.ts @@ -0,0 +1,19 @@ +import type { HangulDocumentEngine, HangulEngineDocument } from './index.js'; + +export interface HangulDocumentConstructor { + new (source: Uint8Array): HangulEngineDocument; + createEmpty(): HangulEngineDocument; +} + +export interface HangulModuleLike { + HwpDocument: HangulDocumentConstructor; +} + +/** Adapt an initialized parser module to the Inkspan engine boundary. */ +export function createHangulModuleEngine(module: HangulModuleLike): HangulDocumentEngine { + return { + id: 'hangul-module', + open: (source) => new module.HwpDocument(source), + create: () => module.HwpDocument.createEmpty(), + }; +} diff --git a/src/hangul/engineMetadataValidation.test.ts b/src/hangul/engineMetadataValidation.test.ts new file mode 100644 index 00000000..ac53a790 --- /dev/null +++ b/src/hangul/engineMetadataValidation.test.ts @@ -0,0 +1,178 @@ +import { + exportHangulDocument, + openHangulDocument, + type HangulDocumentEngine, + type HangulEngineDocument, +} from './index.js'; + +function createDocument(overrides: Partial): HangulEngineDocument { + return { + getSourceFormat: () => 'hwpx', + getSectionCount: () => 1, + getParagraphCount: () => 1, + getParagraphLength: () => 1, + exportSelectionHtml: () => '

x

', + deleteText: () => '{"ok":true}', + pasteHtml: () => '{"ok":true}', + exportHwp: () => new Uint8Array([1]), + exportHwpx: () => new Uint8Array([1]), + ...overrides, + }; +} + +function createEngine(document: HangulEngineDocument): HangulDocumentEngine { + return { + id: 'metadata-validation', + open: async () => document, + create: async () => document, + }; +} + +const EXPECTED_IMPORT_FAILURE = { + code: 'ENGINE_OPERATION_FAILED', + message: 'The Hangul engine failed during import.', +}; + +const EXPECTED_EXPORT_FAILURE = { + code: 'ENGINE_OPERATION_FAILED', + message: 'The Hangul engine failed during export.', +}; + +describe('Hangul engine structural metadata validation', () => { + it('rejects a fractional section count before traversing section data', async () => { + const getParagraphCount = vi.fn(() => 0); + const document = createDocument({ + getSectionCount: () => 1.5, + getParagraphCount, + }); + + await expect( + openHangulDocument(new Uint8Array([1]), { engine: createEngine(document) }), + ).rejects.toMatchObject(EXPECTED_IMPORT_FAILURE); + expect(getParagraphCount).not.toHaveBeenCalled(); + }); + + it('rejects an excessive section count before traversing section data', async () => { + const getParagraphCount = vi.fn(() => 0); + const document = createDocument({ + getSectionCount: () => Number.MAX_SAFE_INTEGER, + getParagraphCount, + }); + + await expect( + openHangulDocument(new Uint8Array([1]), { engine: createEngine(document) }), + ).rejects.toMatchObject(EXPECTED_IMPORT_FAILURE); + expect(getParagraphCount).not.toHaveBeenCalled(); + }); + + it('rejects a negative paragraph count before exporting section HTML', async () => { + const exportSelectionHtml = vi.fn(() => '

private

'); + const document = createDocument({ + getParagraphCount: () => -1, + exportSelectionHtml, + }); + + await expect( + openHangulDocument(new Uint8Array([1]), { engine: createEngine(document) }), + ).rejects.toMatchObject(EXPECTED_IMPORT_FAILURE); + expect(exportSelectionHtml).not.toHaveBeenCalled(); + }); + + it('rejects an excessive paragraph count before asking for a terminal paragraph length', async () => { + const getParagraphLength = vi.fn(() => 1); + const document = createDocument({ + getParagraphCount: () => Number.MAX_SAFE_INTEGER, + getParagraphLength, + }); + + await expect( + openHangulDocument(new Uint8Array([1]), { engine: createEngine(document) }), + ).rejects.toMatchObject(EXPECTED_IMPORT_FAILURE); + expect(getParagraphLength).not.toHaveBeenCalled(); + }); + + it('rejects a fractional paragraph length before passing it to the host export boundary', async () => { + const exportSelectionHtml = vi.fn(() => '

private

'); + const document = createDocument({ + getParagraphLength: () => 1.5, + exportSelectionHtml, + }); + + await expect( + openHangulDocument(new Uint8Array([1]), { engine: createEngine(document) }), + ).rejects.toMatchObject(EXPECTED_IMPORT_FAILURE); + expect(exportSelectionHtml).not.toHaveBeenCalled(); + }); + + it('rejects an excessive paragraph length before passing it to the host export boundary', async () => { + const exportSelectionHtml = vi.fn(() => '

private

'); + const document = createDocument({ + getParagraphLength: () => Number.MAX_SAFE_INTEGER, + exportSelectionHtml, + }); + + await expect( + openHangulDocument(new Uint8Array([1]), { engine: createEngine(document) }), + ).rejects.toMatchObject(EXPECTED_IMPORT_FAILURE); + expect(exportSelectionHtml).not.toHaveBeenCalled(); + }); + + it('reads the section count once so a stateful host cannot move the traversal bound', async () => { + const getSectionCount = vi.fn(() => 1); + const document = createDocument({ getSectionCount }); + + const result = await openHangulDocument(new Uint8Array([1]), { + engine: createEngine(document), + }); + + expect(result.documentJson).toEqual({ + type: 'doc', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'x' }] }], + }); + expect(getSectionCount).toHaveBeenCalledTimes(1); + }); + + it('rejects a fractional export paragraph length before mutating the host document', async () => { + const deleteText = vi.fn(() => '{"ok":true}'); + const pasteHtml = vi.fn(() => '{"ok":true}'); + const document = createDocument({ + getParagraphLength: () => 1.5, + deleteText, + pasteHtml, + }); + + await expect( + exportHangulDocument( + { + type: 'doc', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'x' }] }], + }, + { engine: createEngine(document) }, + ), + ).rejects.toMatchObject(EXPECTED_EXPORT_FAILURE); + expect(deleteText).not.toHaveBeenCalled(); + expect(pasteHtml).not.toHaveBeenCalled(); + }); + + it('rejects an excessive export paragraph length before mutating the host document', async () => { + const deleteText = vi.fn(() => '{"ok":true}'); + const pasteHtml = vi.fn(() => '{"ok":true}'); + const document = createDocument({ + getParagraphLength: () => Number.MAX_SAFE_INTEGER, + deleteText, + pasteHtml, + }); + + await expect( + exportHangulDocument( + { + type: 'doc', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'x' }] }], + }, + { engine: createEngine(document) }, + ), + ).rejects.toMatchObject(EXPECTED_EXPORT_FAILURE); + expect(deleteText).not.toHaveBeenCalled(); + expect(pasteHtml).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/src/hangul/fixtures/briefing-minutes.section.xml b/src/hangul/fixtures/briefing-minutes.section.xml new file mode 100644 index 00000000..8d751f47 --- /dev/null +++ b/src/hangul/fixtures/briefing-minutes.section.xml @@ -0,0 +1,55 @@ + + + + + Briefing Minutes + + + + + Attendees reviewed the quarterly status report. + + + + + + + + + Topic Name + + + + + + + + + Owner Team + + + + + + + + + + + Budget Review + + + + + + + + + Finance Team + + + + + + + diff --git a/src/hangul/fixtures/unsupported-shape.section.xml b/src/hangul/fixtures/unsupported-shape.section.xml new file mode 100644 index 00000000..8d26dff1 --- /dev/null +++ b/src/hangul/fixtures/unsupported-shape.section.xml @@ -0,0 +1,11 @@ + + + + + Opening Remarks + + + + tenant-secret-shape + + diff --git a/src/hangul/importOperationFailure.test.ts b/src/hangul/importOperationFailure.test.ts new file mode 100644 index 00000000..82274ac8 --- /dev/null +++ b/src/hangul/importOperationFailure.test.ts @@ -0,0 +1,127 @@ +import { + HangulDocumentError, + openHangulDocument, + type HangulDocumentEngine, + type HangulEngineDocument, +} from './index.js'; + +describe('Hangul import engine-operation boundary', () => { + it('contains hostile host-engine operation throws without reflecting them', async () => { + let prototypeReads = 0; + const hostile = new Proxy(Object.create(null) as object, { + getPrototypeOf() { + prototypeReads += 1; + throw new Error('private-import-prototype-sentinel'); + }, + }); + let freed = false; + const document: HangulEngineDocument = { + getSourceFormat: () => { + throw hostile; + }, + getSectionCount: () => 0, + getParagraphCount: () => 0, + getParagraphLength: () => 0, + exportSelectionHtml: () => '', + deleteText: () => '', + pasteHtml: () => '', + exportHwp: () => new Uint8Array(), + exportHwpx: () => new Uint8Array(), + free: () => { + freed = true; + }, + }; + const engine: HangulDocumentEngine = { + id: 'hostile-import-operation-test', + open: async () => document, + create: async () => document, + }; + + let caught: unknown; + try { + await openHangulDocument(new Uint8Array([0x48]), { engine }); + } catch (error) { + caught = error; + } + + expect(caught).not.toBe(hostile); + expect(caught).toBeInstanceOf(HangulDocumentError); + expect(caught).toMatchObject({ + name: 'HangulDocumentError', + code: 'ENGINE_OPERATION_FAILED', + message: 'The Hangul engine failed during import.', + }); + expect(prototypeReads).toBe(0); + expect(freed).toBe(true); + }); + + it('rejects a non-string source format before caller member access', async () => { + let memberReads = 0; + const hostileFormat = new Proxy(Object.create(null) as object, { + get() { + memberReads += 1; + throw new Error('private-source-format-member-sentinel'); + }, + }); + const document: HangulEngineDocument = { + getSourceFormat: () => hostileFormat as unknown as string, + getSectionCount: () => 0, + getParagraphCount: () => 0, + getParagraphLength: () => 0, + exportSelectionHtml: () => '', + deleteText: () => '', + pasteHtml: () => '', + exportHwp: () => new Uint8Array(), + exportHwpx: () => new Uint8Array(), + }; + const engine: HangulDocumentEngine = { + id: 'non-string-source-format-test', + open: async () => document, + create: async () => document, + }; + + await expect( + openHangulDocument(new Uint8Array([0x48]), { engine }), + ).rejects.toMatchObject({ + name: 'HangulDocumentError', + code: 'ENGINE_OPERATION_FAILED', + message: 'The Hangul engine failed during import.', + }); + expect(memberReads).toBe(0); + }); + + it('rejects non-string selection HTML before caller coercion', async () => { + let coercionReads = 0; + const hostileHtml = new Proxy(Object.create(null) as object, { + get() { + coercionReads += 1; + throw new Error('private-selection-html-coercion-sentinel'); + }, + }); + const document: HangulEngineDocument = { + getSourceFormat: () => 'hwpx', + getSectionCount: () => 1, + getParagraphCount: () => 1, + getParagraphLength: () => 0, + exportSelectionHtml: () => hostileHtml as unknown as string, + deleteText: () => '', + pasteHtml: () => '', + exportHwp: () => new Uint8Array(), + exportHwpx: () => new Uint8Array(), + }; + const engine: HangulDocumentEngine = { + id: 'non-string-selection-html-test', + open: async () => document, + create: async () => document, + }; + + await expect( + openHangulDocument(new Uint8Array([0x48]), { engine }), + ).rejects.toMatchObject({ + name: 'HangulDocumentError', + code: 'ENGINE_OPERATION_FAILED', + message: 'The Hangul engine failed during import.', + }); + expect(coercionReads).toBe(0); + }); +}); diff --git a/src/hangul/index.failures.test.ts b/src/hangul/index.failures.test.ts new file mode 100644 index 00000000..9e9fb4e7 --- /dev/null +++ b/src/hangul/index.failures.test.ts @@ -0,0 +1,371 @@ +import type { JSONContent } from '@tiptap/core'; +import * as hangulPackage from './package.js'; +import { + HangulDocumentError, + exportHangulDocument, + openHangulDocument, + type HangulDocumentEngine, + type HangulEngineDocument, +} from './index.js'; + +class BoundaryDocument implements HangulEngineDocument { + freed = false; + sourceFormat = 'hwp'; + output = new Uint8Array([1, 2]); + failPaste = false; + + getSourceFormat(): string { return this.sourceFormat; } + getSectionCount(): number { return 1; } + getParagraphCount(): number { return 1; } + getParagraphLength(): number { return 1; } + exportSelectionHtml(): string { return '

I S

'; } + deleteText(): string { return '{"ok":true}'; } + pasteHtml(): string { + if (this.failPaste) throw new Error('write failed'); + return '{"ok":true}'; + } + exportHwp(): Uint8Array { return this.output; } + exportHwpx(): Uint8Array { return this.output; } + free(): void { this.freed = true; } +} + +function engineFor( + source: BoundaryDocument, + target = new BoundaryDocument(), +): HangulDocumentEngine { + return { + id: 'boundary-engine', + open: vi.fn(async () => source), + create: vi.fn(async () => target), + }; +} + +describe('Hangul bridge failure boundaries', () => { + it('opens legacy HWP and preserves italic and strike marks', async () => { + const result = await openHangulDocument(new Uint8Array([1]), { + engine: engineFor(new BoundaryDocument()), + }); + expect(result.sourceFormat).toBe('hwp'); + expect(result.documentJson.content?.[0]?.content).toEqual([ + { type: 'text', text: 'I', marks: [{ type: 'italic' }] }, + { type: 'text', text: ' ' }, + { type: 'text', text: 'S', marks: [{ type: 'strike' }] }, + ]); + }); + + it('preserves equivalent inline tags and transparent wrappers while ignoring comments', async () => { + const source = new BoundaryDocument(); + source.sourceFormat = 'hwpx'; + vi.spyOn(source, 'exportSelectionHtml').mockReturnValue( + '

BISU

', + ); + + const result = await openHangulDocument(new Uint8Array([1]), { + engine: engineFor(source), + maxSourceBytes: 1, + }); + + expect(result.documentJson.content?.[0]?.content).toEqual([ + { type: 'text', text: 'B', marks: [{ type: 'bold' }] }, + { type: 'text', text: 'I', marks: [{ type: 'italic' }] }, + { type: 'text', text: 'S', marks: [{ type: 'strike' }] }, + { type: 'text', text: 'U' }, + ]); + }); + + it('ignores an empty text node returned by the parser', async () => { + class EmptyTextDomParser { + parseFromString(): { + body: { + children: Array<{ + tagName: string; + childNodes: Array<{ nodeType: number; textContent: null }>; + }>; + }; + } { + return { + body: { + children: [ + { + tagName: 'P', + childNodes: [{ nodeType: 3, textContent: null }], + }, + ], + }, + }; + } + } + + vi.stubGlobal('DOMParser', EmptyTextDomParser); + try { + const result = await openHangulDocument(new Uint8Array([1]), { + engine: engineFor(new BoundaryDocument()), + }); + expect(result.documentJson.content?.[0]).toEqual({ + type: 'paragraph', + content: [], + }); + } finally { + vi.unstubAllGlobals(); + } + }); + + it('accepts an empty section without requiring a resource free hook', async () => { + const source: HangulEngineDocument = { + getSourceFormat: () => 'hwpx', + getSectionCount: () => 1, + getParagraphCount: () => 0, + getParagraphLength: () => 0, + exportSelectionHtml: () => '', + deleteText: () => '{"ok":true}', + pasteHtml: () => '{"ok":true}', + exportHwp: () => new Uint8Array(), + exportHwpx: () => new Uint8Array(), + }; + const engine: HangulDocumentEngine = { + id: 'empty-section', + open: async () => source, + create: async () => source, + }; + + const result = await openHangulDocument(new Uint8Array([1]), { + engine, + maxSourceBytes: 1, + }); + expect(result.documentJson).toEqual({ type: 'doc', content: [] }); + }); + + it('rejects source bytes above the configured bound before engine open', async () => { + const source = new BoundaryDocument(); + const engine = engineFor(source); + await expect( + openHangulDocument(new Uint8Array([1, 2]), { + engine, + maxSourceBytes: 1, + }), + ).rejects.toMatchObject({ code: 'SOURCE_LIMIT_EXCEEDED' }); + expect(engine.open).not.toHaveBeenCalled(); + }); + + it('rejects unknown source identities and frees opened resources', async () => { + const source = new BoundaryDocument(); + source.sourceFormat = 'unknown'; + await expect( + openHangulDocument(new Uint8Array([1]), { + engine: engineFor(source), + }), + ).rejects.toMatchObject({ code: 'UNSUPPORTED_SOURCE_FORMAT' }); + expect(source.freed).toBe(true); + }); + + it('normalizes engine open and create failures', async () => { + const engine: HangulDocumentEngine = { + id: 'failing-engine', + open: async () => { throw new Error('open'); }, + create: async () => { throw new Error('create'); }, + }; + await expect( + openHangulDocument(new Uint8Array([1]), { engine }), + ).rejects.toMatchObject({ code: 'ENGINE_OPEN_FAILED' }); + await expect( + exportHangulDocument({ type: 'doc' }, { engine }), + ).rejects.toMatchObject({ code: 'ENGINE_CREATE_FAILED' }); + }); + + it('exports legacy HWP and escapes markup-significant text', async () => { + const target = new BoundaryDocument(); + const documentJson: JSONContent = { + type: 'doc', + content: [ + { + type: 'heading', + attrs: { level: 2 }, + content: [{ type: 'text', text: '', marks: [{ type: 'bold' }] }], + }, + ], + }; + const result = await exportHangulDocument(documentJson, { + engine: engineFor(new BoundaryDocument(), target), + format: 'hwp', + }); + expect(result.format).toBe('hwp'); + expect(Array.from(result.bytes)).toEqual([1, 2]); + }); + + it('renders empty text plus italic and strike marks without optional engine hooks', async () => { + const target: HangulEngineDocument = { + getSourceFormat: () => 'hwpx', + getSectionCount: () => 1, + getParagraphCount: () => 1, + getParagraphLength: () => 0, + exportSelectionHtml: () => '', + deleteText: () => '{"ok":true}', + pasteHtml: vi.fn(() => '{"ok":true}'), + exportHwp: () => new Uint8Array([7]), + exportHwpx: () => new Uint8Array([8]), + }; + const engine: HangulDocumentEngine = { + id: 'minimal-target', + open: async () => target, + create: async () => target, + }; + const result = await exportHangulDocument( + { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { type: 'text', marks: [{ type: 'italic' }] }, + { type: 'text', text: 'S', marks: [{ type: 'strike' }] }, + ], + }, + ], + }, + { engine }, + ); + + expect(result.format).toBe('hwpx'); + expect(Array.from(result.bytes)).toEqual([8]); + expect(target.deleteText).toBeDefined(); + expect(target.pasteHtml).toHaveBeenCalledWith( + 0, + 0, + 0, + '

S

', + ); + }); + + it('rejects unsupported nodes, marks, and every invalid heading shape', async () => { + const engine = engineFor(new BoundaryDocument()); + await expect( + exportHangulDocument({ type: 'paragraph' }, { engine }), + ).rejects.toMatchObject({ code: 'UNSUPPORTED_DOCUMENT_NODE' }); + await expect( + exportHangulDocument( + { type: 'doc', content: [{ type: 'video' }] }, + { engine }, + ), + ).rejects.toMatchObject({ code: 'UNSUPPORTED_DOCUMENT_NODE' }); + await expect( + exportHangulDocument( + { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { type: 'text', text: 'x', marks: [{ type: 'highlight' }] }, + ], + }, + ], + }, + { engine }, + ), + ).rejects.toMatchObject({ code: 'UNSUPPORTED_DOCUMENT_MARK' }); + await expect( + exportHangulDocument( + { + type: 'doc', + content: [ + { type: 'paragraph', content: [{ type: 'hardBreak' }] }, + ], + }, + { engine }, + ), + ).rejects.toMatchObject({ code: 'UNSUPPORTED_DOCUMENT_NODE' }); + await expect( + exportHangulDocument( + { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { type: 'text', text: 'x', marks: [{}] }, + ], + }, + ], + } as unknown as JSONContent, + { engine }, + ), + ).rejects.toMatchObject({ code: 'UNSUPPORTED_DOCUMENT_MARK' }); + await expect( + exportHangulDocument( + { type: 'doc', content: [{}] } as unknown as JSONContent, + { engine }, + ), + ).rejects.toMatchObject({ code: 'UNSUPPORTED_DOCUMENT_NODE' }); + + for (const attrs of [undefined, { level: 0 }, { level: 1.5 }, { level: 7 }]) { + await expect( + exportHangulDocument( + { type: 'doc', content: [{ type: 'heading', ...(attrs ? { attrs } : {}) }] }, + { engine }, + ), + ).rejects.toMatchObject({ code: 'UNSUPPORTED_DOCUMENT_NODE' }); + } + }); + + it('contains hostile engine throw values without prototype inspection', async () => { + const target = new BoundaryDocument(); + let prototypeReads = 0; + const hostile = new Proxy(Object.create(null) as object, { + getPrototypeOf() { + prototypeReads += 1; + throw new Error('private-hangul-prototype-sentinel'); + }, + }); + vi.spyOn(target, 'pasteHtml').mockImplementation(() => { + throw hostile; + }); + + await expect( + exportHangulDocument( + { type: 'doc', content: [{ type: 'paragraph' }] }, + { engine: engineFor(new BoundaryDocument(), target) }, + ), + ).rejects.toMatchObject({ + name: 'HangulDocumentError', + code: 'ENGINE_OPERATION_FAILED', + message: 'The Hangul engine failed during export.', + }); + expect(prototypeReads).toBe(0); + expect(target.freed).toBe(true); + }); + + it('normalizes engine write failures and output-bound violations', async () => { + const writeTarget = new BoundaryDocument(); + writeTarget.failPaste = true; + await expect( + exportHangulDocument( + { type: 'doc', content: [{ type: 'paragraph' }] }, + { engine: engineFor(new BoundaryDocument(), writeTarget) }, + ), + ).rejects.toMatchObject({ code: 'ENGINE_OPERATION_FAILED' }); + expect(writeTarget.freed).toBe(true); + + const largeTarget = new BoundaryDocument(); + await expect( + exportHangulDocument( + { type: 'doc', content: [{ type: 'paragraph' }] }, + { + engine: engineFor(new BoundaryDocument(), largeTarget), + maxOutputBytes: 1, + }, + ), + ).rejects.toMatchObject({ code: 'OUTPUT_LIMIT_EXCEEDED' }); + }); + + it('exposes the package surface and a stable error identity', () => { + expect(hangulPackage.openHangulDocument).toBe(openHangulDocument); + expect(hangulPackage.exportHangulDocument).toBe(exportHangulDocument); + expect(hangulPackage.HangulDocumentError).toBe(HangulDocumentError); + expect(typeof hangulPackage.createHangulModuleEngine).toBe('function'); + + const error = new HangulDocumentError('TEST', 'message'); + expect(error).toBeInstanceOf(Error); + expect(error.name).toBe('HangulDocumentError'); + expect(error.code).toBe('TEST'); + }); +}); diff --git a/src/hangul/index.test.ts b/src/hangul/index.test.ts new file mode 100644 index 00000000..d3bbfc2f --- /dev/null +++ b/src/hangul/index.test.ts @@ -0,0 +1,326 @@ +import type { JSONContent } from '@tiptap/core'; +import { + exportHangulDocument, + openHangulDocument, + type HangulDocumentEngine, + type HangulEngineDocument, +} from './index.js'; + +class FakeDocument implements HangulEngineDocument { + freed = false; + readonly calls: string[] = []; + sourceFormat = 'hwpx'; + selectionHtml = '

Title

Body

'; + + getSourceFormat(): string { + return this.sourceFormat; + } + + getSectionCount(): number { + return 1; + } + + getParagraphCount(): number { + return 1; + } + + getParagraphLength(): number { + return 5; + } + + exportSelectionHtml(): string { + return this.selectionHtml; + } + + getValidationWarnings(): string { + return '{"warnings":[]}'; + } + + createBlankDocument(): string { + this.calls.push('createBlankDocument'); + return '{"ok":true}'; + } + + beginBatch(): string { + this.calls.push('beginBatch'); + return '{"ok":true}'; + } + + endBatch(): string { + this.calls.push('endBatch'); + return '{"ok":true}'; + } + + deleteText( + sectionIndex: number, + paragraphIndex: number, + charOffset: number, + count: number, + ): string { + this.calls.push( + `deleteText:${sectionIndex}:${paragraphIndex}:${charOffset}:${count}`, + ); + return '{"ok":true}'; + } + + pasteHtml( + sectionIndex: number, + paragraphIndex: number, + charOffset: number, + html: string, + ): string { + this.calls.push( + `pasteHtml:${sectionIndex}:${paragraphIndex}:${charOffset}:${html}`, + ); + return '{"ok":true}'; + } + + exportHwp(): Uint8Array { + this.calls.push('exportHwp'); + return new Uint8Array([1, 2, 3]); + } + + exportHwpx(): Uint8Array { + this.calls.push('exportHwpx'); + return new Uint8Array([4, 5, 6]); + } + + free(): void { + this.freed = true; + } +} + +function createEngine( + source: FakeDocument, + target = new FakeDocument(), +): HangulDocumentEngine { + return { + id: 'fake', + open: vi.fn(async () => source), + create: vi.fn(async () => target), + }; +} + +describe('Hangul document bridge', () => { + it('opens HWPX as editable TipTap JSON', async () => { + const source = new FakeDocument(); + const result = await openHangulDocument(new Uint8Array([9]), { + engine: createEngine(source), + }); + + expect(result.sourceFormat).toBe('hwpx'); + expect(result.documentJson).toEqual({ + type: 'doc', + content: [ + { + type: 'heading', + attrs: { level: 1 }, + content: [{ type: 'text', text: 'Title' }], + }, + { + type: 'paragraph', + content: [ + { type: 'text', text: 'Body', marks: [{ type: 'bold' }] }, + ], + }, + ], + }); + expect(result.lossy).toBe(false); + expect(source.freed).toBe(true); + }); + + it('rejects unsupported imported blocks without reflecting document content', async () => { + const source = new FakeDocument(); + source.selectionHtml = + ''; + let caught: unknown; + + try { + await openHangulDocument(new Uint8Array([9]), { + engine: createEngine(source), + }); + } catch (error) { + caught = error; + } + + expect(caught).toMatchObject({ + code: 'UNSUPPORTED_DOCUMENT_NODE', + message: 'Hangul import contains an unsupported block node.', + }); + expect((caught as Error).message).not.toContain('aside'); + expect((caught as Error).message).not.toContain('tenant-secret'); + expect((caught as Error).message).not.toContain('sensitive body'); + expect(source.freed).toBe(true); + }); + + it('rejects unsupported imported inline marks instead of silently losing them', async () => { + const source = new FakeDocument(); + source.selectionHtml = + '

beforesensitive linkafter

'; + let caught: unknown; + + try { + await openHangulDocument(new Uint8Array([9]), { + engine: createEngine(source), + }); + } catch (error) { + caught = error; + } + + expect(caught).toMatchObject({ + code: 'UNSUPPORTED_DOCUMENT_MARK', + message: 'Hangul import contains an unsupported inline mark.', + }); + expect((caught as Error).message).not.toContain('tenant-secret'); + expect((caught as Error).message).not.toContain('sensitive link'); + expect(source.freed).toBe(true); + }); + + it('preserves aligned paragraphs, lists, quotes, code blocks, and basic tables', async () => { + const source = new FakeDocument(); + source.selectionHtml = [ + '

Centered

', + '
  • Bullet

', + '
  1. Numbered

', + '

Quote

', + '
let x = 1 < 2;
', + '
Head
Cell
', + ].join(''); + + const result = await openHangulDocument(new Uint8Array([9]), { + engine: createEngine(source), + }); + + const expected: JSONContent = { + type: 'doc', + content: [ + { + type: 'paragraph', + attrs: { textAlign: 'center' }, + content: [{ type: 'text', text: 'Centered' }], + }, + { + type: 'bulletList', + content: [ + { + type: 'listItem', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'Bullet' }], + }, + ], + }, + ], + }, + { + type: 'orderedList', + content: [ + { + type: 'listItem', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'Numbered', + marks: [{ type: 'italic' }], + }, + ], + }, + ], + }, + ], + }, + { + type: 'blockquote', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'Quote' }], + }, + ], + }, + { + type: 'codeBlock', + content: [{ type: 'text', text: 'let x = 1 < 2;' }], + }, + { + type: 'table', + content: [ + { + type: 'tableRow', + content: [ + { + type: 'tableHeader', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'Head' }], + }, + ], + }, + ], + }, + { + type: 'tableRow', + content: [ + { + type: 'tableCell', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'Cell' }], + }, + ], + }, + ], + }, + ], + }, + ], + }; + + expect(result.documentJson).toEqual(expected); + + const target = new FakeDocument(); + await exportHangulDocument(expected, { + engine: createEngine(source, target), + }); + const pasted = target.calls.find((call) => call.startsWith('pasteHtml:')); + expect(pasted).toContain('

Centered

'); + expect(pasted).toContain('
  • Bullet

'); + expect(pasted).toContain('
  1. Numbered

'); + expect(pasted).toContain('

Quote

'); + expect(pasted).toContain('
let x = 1 < 2;
'); + expect(pasted).toContain( + '

Head

Cell

', + ); + }); + + it('exports edited JSON as HWPX by default', async () => { + const source = new FakeDocument(); + const target = new FakeDocument(); + const documentJson: JSONContent = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'A&B' }], + }, + ], + }; + + const result = await exportHangulDocument(documentJson, { + engine: createEngine(source, target), + }); + + expect(result.format).toBe('hwpx'); + expect(Array.from(result.bytes)).toEqual([4, 5, 6]); + expect(target.calls).toContain('createBlankDocument'); + expect(target.calls.join('\n')).toContain('

A&B

'); + expect(target.calls).toContain('exportHwpx'); + expect(target.freed).toBe(true); + }); +}); diff --git a/src/hangul/index.ts b/src/hangul/index.ts new file mode 100644 index 00000000..64b91ec4 --- /dev/null +++ b/src/hangul/index.ts @@ -0,0 +1,617 @@ +/** Framework-neutral structural document JSON used at the Hangul package boundary. */ +interface HangulDocumentMark { + type?: string; + attrs?: Record; +} + +/** Framework-neutral structural document JSON used at the Hangul package boundary. */ +interface HangulDocumentJson { + type?: string; + attrs?: Record; + content?: HangulDocumentJson[]; + marks?: HangulDocumentMark[]; + text?: string; +} + +/** A Hangul document opened by a host-provided parser/serializer. */ +export interface HangulEngineDocument { + getSourceFormat(): string; + getSectionCount(): number; + getParagraphCount(sectionIndex: number): number; + getParagraphLength(sectionIndex: number, paragraphIndex: number): number; + exportSelectionHtml(sectionIndex: number, startParagraphIndex: number, startCharOffset: number, endParagraphIndex: number, endCharOffset: number): string; + createBlankDocument?(): string; + beginBatch?(): string; + endBatch?(): string; + deleteText(sectionIndex: number, paragraphIndex: number, charOffset: number, count: number): string; + pasteHtml(sectionIndex: number, paragraphIndex: number, charOffset: number, html: string): string; + exportHwp(): Uint8Array; + exportHwpx(): Uint8Array; + free?(): void; +} + +/** Host-owned engine boundary so Inkspan never acquires filesystem or network authority. */ +export interface HangulDocumentEngine { + readonly id: string; + open(source: Uint8Array): HangulEngineDocument | Promise; + create(): HangulEngineDocument | Promise; +} + +export interface OpenHangulDocumentOptions { + engine: HangulDocumentEngine; + maxSourceBytes?: number; +} + +export interface ExportHangulDocumentOptions { + engine: HangulDocumentEngine; + format?: 'hwp' | 'hwpx'; + maxOutputBytes?: number; +} + +/** Structural content kinds the bounded Hangul bridge currently round-trips. */ +export type HangulSupportedContent = + | 'paragraph' + | 'heading' + | 'bold' + | 'italic' + | 'strike' + | 'bulletList' + | 'orderedList' + | 'blockquote' + | 'codeBlock' + | 'table'; + +/** Deterministic host-visible capability metadata for the bounded Hangul bridge. */ +export interface HangulDocumentCapabilities { + readonly importFormats: readonly ('hwp' | 'hwpx')[]; + readonly exportFormats: readonly ('hwpx' | 'hwp')[]; + readonly recommendedExportFormat: 'hwpx'; + readonly supportedContent: readonly HangulSupportedContent[]; +} + +export interface HangulDocumentImportResult { + sourceFormat: 'hwp' | 'hwpx'; + documentJson: Readonly; + warnings: readonly string[]; + lossy: boolean; + capabilities: HangulDocumentCapabilities; +} + +export interface HangulDocumentExportResult { + format: 'hwp' | 'hwpx'; + bytes: Uint8Array; + warnings: readonly string[]; +} + +const HANGUL_DOCUMENT_CAPABILITIES: HangulDocumentCapabilities = Object.freeze({ + importFormats: Object.freeze(['hwp', 'hwpx'] as const), + exportFormats: Object.freeze(['hwpx', 'hwp'] as const), + recommendedExportFormat: 'hwpx', + supportedContent: Object.freeze([ + 'paragraph', + 'heading', + 'bold', + 'italic', + 'strike', + 'bulletList', + 'orderedList', + 'blockquote', + 'codeBlock', + 'table', + ] as const), +}); + +/** Module-owned identity brand that never reflects over untrusted thrown values. */ +const HANGUL_DOCUMENT_ERRORS = new WeakSet(); + +/** Stable error type for unsupported or unsafe conversion states. */ +export class HangulDocumentError extends Error { + constructor(readonly code: string, message: string) { + super(message); + this.name = 'HangulDocumentError'; + HANGUL_DOCUMENT_ERRORS.add(this); + } +} + +/** Return whether a thrown value was created by this module without prototype traversal. */ +function isHangulDocumentError(error: unknown): error is HangulDocumentError { + return HANGUL_DOCUMENT_ERRORS.has(error as object); +} + +/** Read a public Hangul option without allowing hostile accessors to leak values. */ +function readHangulOption(read: () => T): T { + try { + return read(); + } catch { + throw new HangulDocumentError( + 'INVALID_CONFIGURATION', + 'Hangul options are invalid.', + ); + } +} + +/** Contain host cleanup failures without replacing an existing Inkspan failure. */ +function freeHangulDocument( + document: HangulEngineDocument, + primaryError: HangulDocumentError | undefined, +): void { + try { + document.free?.(); + } catch { + if (primaryError === undefined) { + throw new HangulDocumentError( + 'ENGINE_CLEANUP_FAILED', + 'The Hangul engine failed during cleanup.', + ); + } + } +} + +const TEXT_ALIGNMENTS = new Set(['left', 'center', 'right', 'justify']); +const DEFAULT_MAX_DOCUMENT_BYTES = 64 * 1024 * 1024; +const MAX_HANGUL_SECTION_COUNT = 4096; +const MAX_HANGUL_PARAGRAPH_COUNT = 1_000_000; +const MAX_HANGUL_PARAGRAPH_LENGTH = 16 * 1024 * 1024; +const HANGUL_IMPORT_FAILURE_MESSAGE = 'The Hangul engine failed during import.'; +const HANGUL_EXPORT_FAILURE_MESSAGE = 'The Hangul engine failed during export.'; +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!; + +/** Resolve a public runtime byte ceiling without coercion or fail-open numeric values. */ +function resolveHangulByteLimit(limit: number | undefined): number { + const resolved = limit ?? DEFAULT_MAX_DOCUMENT_BYTES; + if (!Number.isSafeInteger(resolved) || resolved < 0) { + throw new HangulDocumentError( + 'INVALID_CONFIGURATION', + 'Hangul byte limit configuration is invalid.', + ); + } + return resolved; +} + +/** Validate host-engine structural metadata against bounded Inkspan work/index ceilings. */ +function resolveHangulEngineCount( + value: number, + maxInclusive: number, + failureMessage: string, +): number { + if (!Number.isSafeInteger(value) || value < 0 || value > maxInclusive) { + throw new HangulDocumentError('ENGINE_OPERATION_FAILED', failureMessage); + } + return value; +} + +/** Validate host-engine text before caller member access or coercion. */ +function resolveHangulEngineString(value: unknown, failureMessage: string): string { + if (typeof value !== 'string') { + throw new HangulDocumentError('ENGINE_OPERATION_FAILED', failureMessage); + } + return value; +} + +/** Validate the runtime export selector before the host engine receives authority. */ +function resolveHangulExportFormat(format: unknown): 'hwp' | 'hwpx' { + const resolved = format === undefined ? 'hwpx' : format; + if (resolved !== 'hwp' && resolved !== 'hwpx') { + throw new HangulDocumentError( + 'INVALID_CONFIGURATION', + 'Hangul export format is invalid.', + ); + } + return resolved; +} + +/** Copy one genuine Uint8Array into an Inkspan-owned immutable import snapshot. */ +function snapshotHangulSource(source: Uint8Array, maxSourceBytes: number): Uint8Array { + let buffer: ArrayBufferLike; + let byteOffset: number; + let byteLength: number; + try { + 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 { + throw new HangulDocumentError( + 'INVALID_SOURCE', + 'Hangul source bytes are invalid.', + ); + } + + if (!(buffer instanceof ArrayBuffer)) { + throw new HangulDocumentError( + 'INVALID_SOURCE', + 'Hangul source bytes are invalid.', + ); + } + if (byteLength > maxSourceBytes) { + throw new HangulDocumentError( + 'SOURCE_LIMIT_EXCEEDED', + 'Hangul source exceeds the configured limit.', + ); + } + + const snapshot = new Uint8Array(byteLength); + snapshot.set(new Uint8Array(buffer, byteOffset, byteLength)); + return snapshot; +} + +/** Copy genuine host-engine bytes into an Inkspan-owned immutable export snapshot. */ +function snapshotHangulOutput(source: Uint8Array, maxOutputBytes: number): Uint8Array { + let buffer: ArrayBufferLike; + let byteOffset: number; + let byteLength: number; + try { + 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 { + throw new HangulDocumentError( + 'ENGINE_OPERATION_FAILED', + HANGUL_EXPORT_FAILURE_MESSAGE, + ); + } + + if (!(buffer instanceof ArrayBuffer)) { + throw new HangulDocumentError( + 'ENGINE_OPERATION_FAILED', + HANGUL_EXPORT_FAILURE_MESSAGE, + ); + } + if (byteLength > maxOutputBytes) { + throw new HangulDocumentError( + 'OUTPUT_LIMIT_EXCEEDED', + 'Hangul export exceeds the configured limit.', + ); + } + + const snapshot = new Uint8Array(byteLength); + snapshot.set(new Uint8Array(buffer, byteOffset, byteLength)); + return snapshot; +} + +function parseInline(parent: ParentNode, marks: HangulDocumentMark[] = []): HangulDocumentJson[] { + const output: HangulDocumentJson[] = []; + for (const child of Array.from(parent.childNodes)) { + if (child.nodeType === Node.TEXT_NODE) { + const text = child.textContent ?? ''; + if (text) output.push({ type: 'text', text, ...(marks.length ? { marks } : {}) }); + } else if (child instanceof Element) { + const tag = child.tagName.toLowerCase(); + if (tag === 'span') { + output.push(...parseInline(child, marks)); + continue; + } + let mark: HangulDocumentMark; + if (tag === 'strong' || tag === 'b') mark = { type: 'bold' }; + else if (tag === 'em' || tag === 'i') mark = { type: 'italic' }; + else if (tag === 's' || tag === 'strike') mark = { type: 'strike' }; + else { + throw new HangulDocumentError( + 'UNSUPPORTED_DOCUMENT_MARK', + 'Hangul import contains an unsupported inline mark.', + ); + } + output.push(...parseInline(child, [...marks, mark])); + } + } + return output; +} + +function readTextAlignment(element: Element): string | undefined { + const style = Reflect.get(element, 'style') as { textAlign?: unknown } | undefined; + const textAlign = style?.textAlign; + return typeof textAlign === 'string' && TEXT_ALIGNMENTS.has(textAlign) + ? textAlign + : undefined; +} + +function parseParagraph(element: Element): HangulDocumentJson { + const textAlign = readTextAlignment(element); + const content = parseInline(element); + return textAlign === undefined + ? { type: 'paragraph', content } + : { type: 'paragraph', attrs: { textAlign }, content }; +} + +function isListBlockElement(element: Element): boolean { + const tag = element.tagName.toLowerCase(); + return ( + /^h[1-6]$/u.test(tag) || + tag === 'ul' || + tag === 'ol' || + tag === 'blockquote' || + tag === 'pre' || + tag === 'table' || + tag === 'p' + ); +} + +function parseListItem(item: Element): HangulDocumentJson { + const blockChildren = Array.from(item.children).filter(isListBlockElement); + if (blockChildren.length === 0) { + return { + type: 'listItem', + content: [{ type: 'paragraph', content: parseInline(item) }], + }; + } + + const hasDirectInlineContent = Array.from(item.childNodes).some((child) => { + if (child.nodeType === Node.TEXT_NODE) { + return (child as Text).data.trim().length > 0; + } + return child instanceof Element && !isListBlockElement(child); + }); + if (hasDirectInlineContent || blockChildren.length !== item.children.length) { + throw new HangulDocumentError( + 'UNSUPPORTED_DOCUMENT_NODE', + 'Hangul import contains an unsupported block node.', + ); + } + + return { + type: 'listItem', + content: blockChildren.map(parseBlock), + }; +} + +function parseList(element: Element, type: 'bulletList' | 'orderedList'): HangulDocumentJson { + return { + type, + content: Array.from(element.children).map(parseListItem), + }; +} + +function parseTable(element: Element): HangulDocumentJson { + return { + type: 'table', + content: Array.from((element as HTMLTableElement).rows).map((row) => ({ + type: 'tableRow', + content: Array.from(row.cells).map((cell) => ({ + type: cell.tagName.toLowerCase() === 'th' ? 'tableHeader' : 'tableCell', + content: [{ type: 'paragraph', content: parseInline(cell) }], + })), + })), + }; +} + +function parseBlock(element: Element): HangulDocumentJson { + const tag = element.tagName.toLowerCase(); + if (/^h[1-6]$/u.test(tag)) { + return { + type: 'heading', + attrs: { level: Number(tag.slice(1)) }, + content: parseInline(element), + }; + } + if (tag === 'ul') return parseList(element, 'bulletList'); + if (tag === 'ol') return parseList(element, 'orderedList'); + if (tag === 'blockquote') { + return { type: 'blockquote', content: Array.from(element.children).map(parseBlock) }; + } + if (tag === 'pre') { + return { type: 'codeBlock', content: [{ type: 'text', text: element.textContent as string }] }; + } + if (tag === 'table') return parseTable(element); + if (tag === 'p') return parseParagraph(element); + throw new HangulDocumentError( + 'UNSUPPORTED_DOCUMENT_NODE', + 'Hangul import contains an unsupported block node.', + ); +} + +function htmlToJson(html: string): HangulDocumentJson { + const parsed = new DOMParser().parseFromString(html, 'text/html'); + return { + type: 'doc', + content: Array.from(parsed.body.children).map(parseBlock), + }; +} + +function escapeHtml(value: string): string { + return value.replace(/&/gu, '&').replace(//gu, '>'); +} + +function renderInline(node: HangulDocumentJson): string { + if (node.type !== 'text') throw new HangulDocumentError('UNSUPPORTED_DOCUMENT_NODE', 'Only text inline nodes are currently exportable.'); + let value = escapeHtml(node.text ?? ''); + for (const mark of node.marks ?? []) { + if (mark.type === 'bold') value = `${value}`; + else if (mark.type === 'italic') value = `${value}`; + else if (mark.type === 'strike') value = `${value}`; + else throw new HangulDocumentError('UNSUPPORTED_DOCUMENT_MARK', 'Hangul export contains an unsupported inline mark.'); + } + return value; +} + +function contentOf(node: HangulDocumentJson): HangulDocumentJson[] { + return node.content ?? []; +} + +function paragraphStyle(node: HangulDocumentJson): string { + const textAlign = node.attrs?.textAlign; + return typeof textAlign === 'string' && TEXT_ALIGNMENTS.has(textAlign) + ? ` style="text-align: ${textAlign}"` + : ''; +} + +function renderBlock(node: HangulDocumentJson): string { + if (node.type === 'paragraph') { + return `${contentOf(node).map(renderInline).join('')}

`; + } + if (node.type === 'heading') { + const level = Number(node.attrs?.level); + if (!Number.isInteger(level) || level < 1 || level > 6) throw new HangulDocumentError('UNSUPPORTED_DOCUMENT_NODE', 'Invalid heading level.'); + return `${contentOf(node).map(renderInline).join('')}`; + } + if (node.type === 'bulletList' || node.type === 'orderedList') { + const tag = node.type === 'bulletList' ? 'ul' : 'ol'; + return `<${tag}>${contentOf(node).map(renderBlock).join('')}`; + } + if (node.type === 'listItem') { + return `
  • ${contentOf(node).map(renderBlock).join('')}
  • `; + } + if (node.type === 'blockquote') { + return `
    ${contentOf(node).map(renderBlock).join('')}
    `; + } + if (node.type === 'codeBlock') { + return `
    ${contentOf(node).map(renderInline).join('')}
    `; + } + if (node.type === 'table') { + return `${contentOf(node).map(renderBlock).join('')}
    `; + } + if (node.type === 'tableRow') { + return `${contentOf(node).map(renderBlock).join('')}`; + } + if (node.type === 'tableHeader') { + return `${contentOf(node).map(renderBlock).join('')}`; + } + if (node.type === 'tableCell') { + return `${contentOf(node).map(renderBlock).join('')}`; + } + throw new HangulDocumentError('UNSUPPORTED_DOCUMENT_NODE', 'Hangul export contains an unsupported block node.'); +} + +function jsonToHtml(documentJson: HangulDocumentJson): string { + if (documentJson.type !== 'doc') throw new HangulDocumentError('UNSUPPORTED_DOCUMENT_NODE', 'Hangul export requires a doc root.'); + return contentOf(documentJson).map(renderBlock).join(''); +} + +/** Render caller-provided document JSON without allowing hostile access failures to escape. */ +function renderHangulDocumentJson(documentJson: HangulDocumentJson): string { + try { + return jsonToHtml(documentJson); + } catch (error) { + if (isHangulDocumentError(error)) throw error; + throw new HangulDocumentError( + 'INVALID_DOCUMENT', + 'Hangul document JSON is invalid.', + ); + } +} + +/** Project HWP/HWPX bytes into the editor's JSON model. */ +export async function openHangulDocument(source: Uint8Array, options: OpenHangulDocumentOptions): Promise { + const engine = readHangulOption(() => options.engine); + const maxSourceBytes = resolveHangulByteLimit( + readHangulOption(() => options.maxSourceBytes), + ); + const sourceSnapshot = snapshotHangulSource(source, maxSourceBytes); + let document: HangulEngineDocument; + try { document = await engine.open(sourceSnapshot); } catch { throw new HangulDocumentError('ENGINE_OPEN_FAILED', 'The Hangul engine could not open the document.'); } + let primaryError: HangulDocumentError | undefined; + try { + try { + const sourceFormat = resolveHangulEngineString( + document.getSourceFormat(), + HANGUL_IMPORT_FAILURE_MESSAGE, + ).toLowerCase(); + if (sourceFormat !== 'hwp' && sourceFormat !== 'hwpx') throw new HangulDocumentError('UNSUPPORTED_SOURCE_FORMAT', 'Unsupported Hangul source format.'); + const html: string[] = []; + const sectionCount = resolveHangulEngineCount( + document.getSectionCount(), + MAX_HANGUL_SECTION_COUNT, + HANGUL_IMPORT_FAILURE_MESSAGE, + ); + for (let section = 0; section < sectionCount; section += 1) { + const count = resolveHangulEngineCount( + document.getParagraphCount(section), + MAX_HANGUL_PARAGRAPH_COUNT, + HANGUL_IMPORT_FAILURE_MESSAGE, + ); + if (count > 0) { + const paragraphLength = resolveHangulEngineCount( + document.getParagraphLength(section, count - 1), + MAX_HANGUL_PARAGRAPH_LENGTH, + HANGUL_IMPORT_FAILURE_MESSAGE, + ); + html.push( + resolveHangulEngineString( + document.exportSelectionHtml( + section, + 0, + 0, + count - 1, + paragraphLength, + ), + HANGUL_IMPORT_FAILURE_MESSAGE, + ), + ); + } + } + const documentJson = htmlToJson(html.join('')); + Object.freeze(documentJson); + return { + sourceFormat, + documentJson, + warnings: Object.freeze([]), + lossy: false, + capabilities: HANGUL_DOCUMENT_CAPABILITIES, + }; + } catch (error) { + primaryError = isHangulDocumentError(error) + ? error + : new HangulDocumentError( + 'ENGINE_OPERATION_FAILED', + HANGUL_IMPORT_FAILURE_MESSAGE, + ); + throw primaryError; + } + } finally { + freeHangulDocument(document, primaryError); + } +} + +/** Export edited Inkspan JSON as HWPX by default or HWP explicitly. */ +export async function exportHangulDocument(documentJson: HangulDocumentJson, options: ExportHangulDocumentOptions): Promise { + const engine = readHangulOption(() => options.engine); + const maxOutputBytes = resolveHangulByteLimit( + readHangulOption(() => options.maxOutputBytes), + ); + const format = resolveHangulExportFormat( + readHangulOption(() => options.format), + ); + const html = renderHangulDocumentJson(documentJson); + let document: HangulEngineDocument; + try { document = await engine.create(); } catch { throw new HangulDocumentError('ENGINE_CREATE_FAILED', 'The Hangul engine could not create a document.'); } + let primaryError: HangulDocumentError | undefined; + try { + try { + document.createBlankDocument?.(); + document.beginBatch?.(); + const length = resolveHangulEngineCount( + document.getParagraphLength(0, 0), + MAX_HANGUL_PARAGRAPH_LENGTH, + HANGUL_EXPORT_FAILURE_MESSAGE, + ); + if (length > 0) document.deleteText(0, 0, 0, length); + document.pasteHtml(0, 0, 0, html); + document.endBatch?.(); + const engineBytes = format === 'hwp' ? document.exportHwp() : document.exportHwpx(); + const bytes = snapshotHangulOutput(engineBytes, maxOutputBytes); + return { format, bytes, warnings: Object.freeze([]) }; + } catch (error) { + primaryError = isHangulDocumentError(error) + ? error + : new HangulDocumentError( + 'ENGINE_OPERATION_FAILED', + HANGUL_EXPORT_FAILURE_MESSAGE, + ); + throw primaryError; + } + } finally { + freeHangulDocument(document, primaryError); + } +} \ No newline at end of file diff --git a/src/hangul/listImportIntegrity.test.ts b/src/hangul/listImportIntegrity.test.ts new file mode 100644 index 00000000..81f9f733 --- /dev/null +++ b/src/hangul/listImportIntegrity.test.ts @@ -0,0 +1,106 @@ +import { + openHangulDocument, + type HangulDocumentEngine, + type HangulEngineDocument, +} from './index.js'; + +function engineReturning(html: string): HangulDocumentEngine { + const source: HangulEngineDocument = { + getSourceFormat: () => 'hwpx', + getSectionCount: () => 1, + getParagraphCount: () => 1, + getParagraphLength: () => 1, + exportSelectionHtml: () => html, + deleteText: () => '{"ok":true}', + pasteHtml: () => '{"ok":true}', + exportHwp: () => new Uint8Array(), + exportHwpx: () => new Uint8Array(), + }; + return { + id: 'list-import-integrity', + open: async () => source, + create: async () => source, + }; +} + +describe('Hangul list import integrity', () => { + it('preserves direct inline list-item content as a paragraph', async () => { + const result = await openHangulDocument(new Uint8Array([1]), { + engine: engineReturning( + '
    • Direct bold text
    ', + ), + }); + + expect(result.documentJson).toEqual({ + type: 'doc', + content: [ + { + type: 'bulletList', + content: [ + { + type: 'listItem', + content: [ + { + type: 'paragraph', + content: [ + { type: 'text', text: 'Direct ' }, + { + type: 'text', + text: 'bold', + marks: [{ type: 'bold' }], + }, + { type: 'text', text: ' text' }, + ], + }, + ], + }, + ], + }, + ], + }); + }); + + it('rejects mixed direct text and block list-item content instead of dropping text', async () => { + const privateText = 'private-direct-content'; + let caught: unknown; + + try { + await openHangulDocument(new Uint8Array([1]), { + engine: engineReturning( + `
    • ${privateText}

      Block

    `, + ), + }); + } catch (error) { + caught = error; + } + + expect(caught).toMatchObject({ + name: 'HangulDocumentError', + code: 'UNSUPPORTED_DOCUMENT_NODE', + message: 'Hangul import contains an unsupported block node.', + }); + expect((caught as Error).message).not.toContain(privateText); + }); + + it('rejects mixed direct inline elements and block list-item content instead of dropping text', async () => { + const privateText = 'private-inline-content'; + let caught: unknown; + + try { + await openHangulDocument(new Uint8Array([1]), { + engine: engineReturning( + `
    • ${privateText}

      Block

    `, + ), + }); + } catch (error) { + caught = error; + } + + expect(caught).toMatchObject({ + name: 'HangulDocumentError', + code: 'UNSUPPORTED_DOCUMENT_NODE', + message: 'Hangul import contains an unsupported block node.', + }); + expect((caught as Error).message).not.toContain(privateText); + }); +}); diff --git a/src/hangul/optionAccessFailure.test.ts b/src/hangul/optionAccessFailure.test.ts new file mode 100644 index 00000000..d8981047 --- /dev/null +++ b/src/hangul/optionAccessFailure.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest'; + +import { + exportHangulDocument, + openHangulDocument, + type ExportHangulDocumentOptions, + type HangulDocumentEngine, + type OpenHangulDocumentOptions, +} from './index.js'; + +const PRIVATE_OPTION_SENTINEL = new Error('private Hangul option sentinel'); + +function unusedEngine(onUse: () => void): HangulDocumentEngine { + return { + id: 'option-access-test', + open: async () => { + onUse(); + throw new Error('engine must not be reached'); + }, + create: async () => { + onUse(); + throw new Error('engine must not be reached'); + }, + }; +} + +const PARAGRAPH_DOCUMENT = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'safe' }], + }, + ], +}; + +function expectInvalidOptions(result: Promise): Promise { + return expect(result).rejects.toMatchObject({ + code: 'INVALID_CONFIGURATION', + message: 'Hangul options are invalid.', + }); +} + +describe('Hangul public option access containment', () => { + it('redacts a hostile maxSourceBytes accessor before engine open', async () => { + let engineUseCount = 0; + const options = { + engine: unusedEngine(() => { + engineUseCount += 1; + }), + get maxSourceBytes() { + throw PRIVATE_OPTION_SENTINEL; + }, + } as unknown as OpenHangulDocumentOptions; + + await expectInvalidOptions(openHangulDocument(new Uint8Array([1]), options)); + expect(engineUseCount).toBe(0); + }); + + it('redacts a hostile maxOutputBytes accessor before engine create', async () => { + let engineUseCount = 0; + const options = { + engine: unusedEngine(() => { + engineUseCount += 1; + }), + get maxOutputBytes() { + throw PRIVATE_OPTION_SENTINEL; + }, + } as unknown as ExportHangulDocumentOptions; + + await expectInvalidOptions(exportHangulDocument(PARAGRAPH_DOCUMENT, options)); + expect(engineUseCount).toBe(0); + }); + + it('redacts a hostile format accessor before engine create', async () => { + let engineUseCount = 0; + const options = { + engine: unusedEngine(() => { + engineUseCount += 1; + }), + get format() { + throw PRIVATE_OPTION_SENTINEL; + }, + } as unknown as ExportHangulDocumentOptions; + + await expectInvalidOptions(exportHangulDocument(PARAGRAPH_DOCUMENT, options)); + expect(engineUseCount).toBe(0); + }); +}); diff --git a/src/hangul/outputSnapshot.test.ts b/src/hangul/outputSnapshot.test.ts new file mode 100644 index 00000000..981f55b4 --- /dev/null +++ b/src/hangul/outputSnapshot.test.ts @@ -0,0 +1,86 @@ +import { + exportHangulDocument, + type HangulDocumentEngine, + type HangulEngineDocument, +} from './index.js'; + +const DOCUMENT = { type: 'doc', content: [{ type: 'paragraph' }] }; + +function documentWithOutput(output: Uint8Array): HangulEngineDocument { + return { + getSourceFormat: () => 'hwpx', + getSectionCount: () => 0, + getParagraphCount: () => 0, + getParagraphLength: () => 0, + exportSelectionHtml: () => '', + deleteText: () => '', + pasteHtml: () => '', + exportHwp: () => output, + exportHwpx: () => output, + }; +} + +function engineWithOutput(output: Uint8Array): HangulDocumentEngine { + return { + id: 'output-snapshot-test', + open: async () => documentWithOutput(output), + create: async () => documentWithOutput(output), + }; +} + +describe('Hangul output snapshot boundary', () => { + it('does not execute caller-owned byteLength accessors and returns an Inkspan-owned byte snapshot', async () => { + const privateSentinel = new Error('private output byteLength sentinel'); + const output = new Uint8Array([0x48, 0x57, 0x50, 0x58]); + let byteLengthAccessorCalls = 0; + Object.defineProperty(output, 'byteLength', { + configurable: true, + get() { + byteLengthAccessorCalls += 1; + throw privateSentinel; + }, + }); + + const result = await exportHangulDocument(DOCUMENT, { + engine: engineWithOutput(output), + }); + + expect(byteLengthAccessorCalls).toBe(0); + expect(result.format).toBe('hwpx'); + expect(result.bytes).not.toBe(output); + expect(Array.from(result.bytes)).toEqual([0x48, 0x57, 0x50, 0x58]); + }); + + it('fails closed for forged typed-array proxies without executing caller traps', async () => { + const privateSentinel = new Error('private output proxy sentinel'); + let trapCalls = 0; + const output = new Proxy(new Uint8Array([0x48]), { + get() { + trapCalls += 1; + throw privateSentinel; + }, + }) as Uint8Array; + + await expect( + exportHangulDocument(DOCUMENT, { engine: engineWithOutput(output) }), + ).rejects.toMatchObject({ + name: 'HangulDocumentError', + code: 'ENGINE_OPERATION_FAILED', + message: 'The Hangul engine failed during export.', + }); + + expect(trapCalls).toBe(0); + }); + + it('fails closed for SharedArrayBuffer-backed engine output before returning mutable bytes', async () => { + const output = new Uint8Array(new SharedArrayBuffer(4)); + + await expect( + exportHangulDocument(DOCUMENT, { engine: engineWithOutput(output) }), + ).rejects.toMatchObject({ + name: 'HangulDocumentError', + code: 'ENGINE_OPERATION_FAILED', + message: 'The Hangul engine failed during export.', + }); + }); +}); diff --git a/src/hangul/package.ts b/src/hangul/package.ts new file mode 100644 index 00000000..7cf7e962 --- /dev/null +++ b/src/hangul/package.ts @@ -0,0 +1,18 @@ +export { + HangulDocumentError, + exportHangulDocument, + openHangulDocument, +} from './index.js'; +export { createHangulModuleEngine } from './engineAdapter.js'; +export type { + ExportHangulDocumentOptions, + HangulDocumentEngine, + HangulDocumentExportResult, + HangulDocumentImportResult, + HangulEngineDocument, + OpenHangulDocumentOptions, +} from './index.js'; +export type { + HangulDocumentConstructor, + HangulModuleLike, +} from './engineAdapter.js'; diff --git a/src/hangul/rhwpAdapter.test.ts b/src/hangul/rhwpAdapter.test.ts new file mode 100644 index 00000000..c8a3689d --- /dev/null +++ b/src/hangul/rhwpAdapter.test.ts @@ -0,0 +1,38 @@ +import type { HangulEngineDocument } from './index.js'; +import { createHangulModuleEngine } from './engineAdapter.js'; + +class FakeHangulDocument implements HangulEngineDocument { + static created = new FakeHangulDocument(new Uint8Array()); + readonly source: Uint8Array; + + constructor(source: Uint8Array) { + this.source = source; + } + + static createEmpty(): FakeHangulDocument { + return FakeHangulDocument.created; + } + + getSourceFormat(): string { return 'hwpx'; } + getSectionCount(): number { return 0; } + getParagraphCount(): number { return 0; } + getParagraphLength(): number { return 0; } + exportSelectionHtml(): string { return ''; } + deleteText(): string { return '{"ok":true}'; } + pasteHtml(): string { return '{"ok":true}'; } + exportHwp(): Uint8Array { return new Uint8Array(); } + exportHwpx(): Uint8Array { return new Uint8Array(); } +} + +describe('Hangul parser module adapter', () => { + it('adapts a host-initialized module without a hard runtime dependency', async () => { + const engine = createHangulModuleEngine({ HwpDocument: FakeHangulDocument }); + const bytes = new Uint8Array([1, 2, 3]); + + expect(engine.id).toBe('hangul-module'); + const opened = await engine.open(bytes); + expect(opened).toBeInstanceOf(FakeHangulDocument); + expect((opened as FakeHangulDocument).source).toBe(bytes); + expect(await engine.create()).toBe(FakeHangulDocument.created); + }); +}); diff --git a/src/hangul/runtimeFormatValidation.test.ts b/src/hangul/runtimeFormatValidation.test.ts new file mode 100644 index 00000000..40978985 --- /dev/null +++ b/src/hangul/runtimeFormatValidation.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + exportHangulDocument, + type HangulDocumentEngine, + type HangulEngineDocument, +} from './index.js'; + +describe('Hangul runtime export format validation', () => { + it('rejects an invalid runtime format before creating an engine document', async () => { + const engineDocument = {} as HangulEngineDocument; + const engine: HangulDocumentEngine = { + id: 'runtime-format-boundary', + open: vi.fn(async () => engineDocument), + create: vi.fn(async () => engineDocument), + }; + + await expect( + exportHangulDocument( + { type: 'doc' }, + { + engine, + format: 'doc' as unknown as 'hwp', + }, + ), + ).rejects.toMatchObject({ + name: 'HangulDocumentError', + code: 'INVALID_CONFIGURATION', + message: 'Hangul export format is invalid.', + }); + + expect(engine.create).not.toHaveBeenCalled(); + }); +}); diff --git a/src/hangul/runtimeResourceLimits.test.ts b/src/hangul/runtimeResourceLimits.test.ts new file mode 100644 index 00000000..0be5fa0b --- /dev/null +++ b/src/hangul/runtimeResourceLimits.test.ts @@ -0,0 +1,67 @@ +import { + exportHangulDocument, + openHangulDocument, + type HangulDocumentEngine, +} from './index.js'; + +function failingEngine(): HangulDocumentEngine { + return { + id: 'resource-limit-sentinel', + open: vi.fn(async () => { + throw new Error('engine open should not run'); + }), + create: vi.fn(async () => { + throw new Error('engine create should not run'); + }), + }; +} + +const INVALID_BYTE_LIMITS = [Number.NaN, Number.POSITIVE_INFINITY, -1, 1.5]; + +describe('Hangul runtime byte-limit validation', () => { + it.each(INVALID_BYTE_LIMITS)( + 'rejects invalid maxSourceBytes %s before opening the host engine', + async (maxSourceBytes) => { + const engine = failingEngine(); + + await expect( + openHangulDocument(new Uint8Array(), { + engine, + maxSourceBytes, + }), + ).rejects.toMatchObject({ + name: 'HangulDocumentError', + code: 'INVALID_CONFIGURATION', + message: 'Hangul byte limit configuration is invalid.', + }); + expect(engine.open).not.toHaveBeenCalled(); + }, + ); + + it.each(INVALID_BYTE_LIMITS)( + 'rejects invalid maxOutputBytes %s before inspecting document content or creating the host engine', + async (maxOutputBytes) => { + const engine = failingEngine(); + const documentJson = new Proxy( + {}, + { + get() { + throw new Error('document should not be inspected'); + }, + }, + ); + + await expect( + exportHangulDocument(documentJson, { + engine, + maxOutputBytes, + }), + ).rejects.toMatchObject({ + name: 'HangulDocumentError', + code: 'INVALID_CONFIGURATION', + message: 'Hangul byte limit configuration is invalid.', + }); + expect(engine.create).not.toHaveBeenCalled(); + }, + ); +}); diff --git a/src/hangul/sourceSnapshot.test.ts b/src/hangul/sourceSnapshot.test.ts new file mode 100644 index 00000000..c555ba0f --- /dev/null +++ b/src/hangul/sourceSnapshot.test.ts @@ -0,0 +1,102 @@ +import { + openHangulDocument, + type HangulDocumentEngine, + type HangulEngineDocument, +} from './index.js'; + +function emptyDocument(): HangulEngineDocument { + return { + getSourceFormat: () => 'hwp', + getSectionCount: () => 0, + getParagraphCount: () => 0, + getParagraphLength: () => 0, + exportSelectionHtml: () => '', + deleteText: () => '', + pasteHtml: () => '', + exportHwp: () => new Uint8Array(), + exportHwpx: () => new Uint8Array(), + }; +} + +function engineWithOpenCounter(counter: { calls: number }): HangulDocumentEngine { + return { + id: 'source-snapshot-test', + open: async () => { + counter.calls += 1; + return emptyDocument(); + }, + create: async () => emptyDocument(), + }; +} + +describe('Hangul source snapshot boundary', () => { + it('does not execute caller-owned byteLength accessors and passes a detached byte snapshot to the host engine', async () => { + const privateSentinel = new Error('private byteLength sentinel'); + const source = new Uint8Array([0x48, 0x57, 0x50]); + let byteLengthAccessorCalls = 0; + Object.defineProperty(source, 'byteLength', { + configurable: true, + get() { + byteLengthAccessorCalls += 1; + throw privateSentinel; + }, + }); + + let receivedSource: Uint8Array | null = null; + const engine: HangulDocumentEngine = { + id: 'source-snapshot-test', + open: async (bytes) => { + receivedSource = bytes; + return emptyDocument(); + }, + create: async () => emptyDocument(), + }; + + await expect(openHangulDocument(source, { engine })).resolves.toMatchObject({ + sourceFormat: 'hwp', + lossy: false, + }); + + expect(byteLengthAccessorCalls).toBe(0); + expect(receivedSource).not.toBe(source); + expect(Array.from(receivedSource ?? [])).toEqual([0x48, 0x57, 0x50]); + }); + + it('fails closed for forged typed-array proxies without executing caller traps', async () => { + const privateSentinel = new Error('private proxy sentinel'); + let trapCalls = 0; + const source = new Proxy(new Uint8Array([0x48]), { + get() { + trapCalls += 1; + throw privateSentinel; + }, + }) as Uint8Array; + const counter = { calls: 0 }; + + await expect( + openHangulDocument(source, { engine: engineWithOpenCounter(counter) }), + ).rejects.toMatchObject({ + name: 'HangulDocumentError', + code: 'INVALID_SOURCE', + message: 'Hangul source bytes are invalid.', + }); + + expect(trapCalls).toBe(0); + expect(counter.calls).toBe(0); + }); + + it('fails closed for SharedArrayBuffer-backed views before the host engine observes mutable bytes', async () => { + const source = new Uint8Array(new SharedArrayBuffer(4)); + const counter = { calls: 0 }; + + await expect( + openHangulDocument(source, { engine: engineWithOpenCounter(counter) }), + ).rejects.toMatchObject({ + name: 'HangulDocumentError', + code: 'INVALID_SOURCE', + message: 'Hangul source bytes are invalid.', + }); + + expect(counter.calls).toBe(0); + }); +}); diff --git a/vite.hangul.config.ts b/vite.hangul.config.ts new file mode 100644 index 00000000..7888725b --- /dev/null +++ b/vite.hangul.config.ts @@ -0,0 +1,26 @@ +import { resolve } from 'node:path'; +import { defineConfig } from 'vite'; +import dts from 'vite-plugin-dts'; + +/** Build the framework-neutral HWP/HWPX bridge as an isolated package subpath. */ +export default defineConfig({ + plugins: [ + dts({ + include: ['src/hangul'], + exclude: ['src/**/*.test.ts', 'src/**/*.test.tsx', 'src/**/*.spec.ts'], + rollupTypes: false, + entryRoot: 'src', + }), + ], + build: { + emptyOutDir: false, + lib: { + entry: resolve(__dirname, 'src/hangul/index.ts'), + name: 'InkspanHangul', + fileName: (format) => + format === 'es' ? 'cwl-hangul.js' : 'cwl-hangul.cjs', + formats: ['es', 'cjs'], + }, + sourcemap: true, + }, +});