From 3f3afc373c4a23951e40963bb5d7d4bdfea53fcd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 06:07:23 +0900 Subject: [PATCH 001/163] test(toolbar): fail closed after image upload lifecycle changes --- src/components/ToolbarImageLifecycle.test.tsx | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 src/components/ToolbarImageLifecycle.test.tsx diff --git a/src/components/ToolbarImageLifecycle.test.tsx b/src/components/ToolbarImageLifecycle.test.tsx new file mode 100644 index 00000000..ba26926e --- /dev/null +++ b/src/components/ToolbarImageLifecycle.test.tsx @@ -0,0 +1,87 @@ +import { act, cleanup, fireEvent, render } from '@testing-library/react'; +import { Editor } from '@tiptap/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { buildExtensions } from '../extensions/kit.js'; +import { Toolbar } from './Toolbar.js'; + +const PNG_BYTES = new Uint8Array([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, + 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, +]); + +const openEditors: Array<{ editor: Editor; element: HTMLDivElement }> = []; + +function makeEditor(): Editor { + const element = document.createElement('div'); + document.body.appendChild(element); + const editor = new Editor({ + element, + extensions: buildExtensions({ image: { maxDimension: 0 } }), + content: '

before

', + }); + openEditors.push({ editor, element }); + return editor; +} + +function delayedPngFile(delayMs = 25): File { + const file = new File([PNG_BYTES], 'slow.png', { type: 'image/png' }); + Object.defineProperty(file, 'arrayBuffer', { + configurable: true, + value: () => + new Promise((resolve) => { + setTimeout(() => resolve(PNG_BYTES.slice().buffer), delayMs); + }), + }); + return file; +} + +function fileInput(): HTMLInputElement { + return document.querySelector('input[type="file"]') as HTMLInputElement; +} + +async function settleConversion(): Promise { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 45)); + }); +} + +afterEach(() => { + cleanup(); + for (const { editor, element } of openEditors.splice(0)) { + if (!editor.isDestroyed) editor.destroy(); + element.remove(); + } + vi.restoreAllMocks(); +}); + +describe('Toolbar asynchronous image-upload lifecycle boundary', () => { + it('does not prompt or mutate after the editor becomes read-only', async () => { + const editor = makeEditor(); + const before = editor.getHTML(); + const prompt = vi.spyOn(window, 'prompt').mockReturnValue('stale image'); + render(); + + fireEvent.change(fileInput(), { target: { files: [delayedPngFile()] } }); + editor.setEditable(false); + await settleConversion(); + + expect(prompt).not.toHaveBeenCalled(); + expect(editor.getHTML()).toBe(before); + expect(editor.getHTML()).not.toContain('data:image/png;base64'); + }); + + it('does not prompt after the editor is destroyed during conversion', async () => { + const editor = makeEditor(); + const prompt = vi.spyOn(window, 'prompt').mockReturnValue('stale image'); + render(); + + fireEvent.change(fileInput(), { target: { files: [delayedPngFile()] } }); + editor.destroy(); + await settleConversion(); + + expect(prompt).not.toHaveBeenCalled(); + expect(editor.isDestroyed).toBe(true); + }); +}); From 6da5eab43f4a05918df5ce520b5f3ce90d9144cf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 06:11:57 +0900 Subject: [PATCH 002/163] fix(toolbar): stop stale image upload continuations --- src/components/Toolbar.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index 55136e55..32f144e0 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -215,6 +215,8 @@ export function Toolbar({ editor, image, onImageError }: ToolbarProps) { return; } + if (editor.isDestroyed || !editor.isEditable) return; + const alternativeText = window.prompt( 'Image alternative text. Leave empty only if this image is decorative.', '', From faf7c2fd4bb2ce021a22506838ad44786115fefb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 22:48:25 +0900 Subject: [PATCH 003/163] test(spreadsheet): establish XLS/XLSX body import RED --- .github/workflows/agent-workspace.yml | 53 +++++ .../plans/2026-08-13-xls-xlsx-body-import.md | 198 ++++++++++++++++++ src/spreadsheet/spreadsheetImport.test.ts | 68 ++++++ src/spreadsheet/spreadsheetImport.ts | 60 ++++++ 4 files changed, 379 insertions(+) create mode 100644 .github/workflows/agent-workspace.yml create mode 100644 docs/superpowers/plans/2026-08-13-xls-xlsx-body-import.md create mode 100644 src/spreadsheet/spreadsheetImport.test.ts create mode 100644 src/spreadsheet/spreadsheetImport.ts diff --git a/.github/workflows/agent-workspace.yml b/.github/workflows/agent-workspace.yml new file mode 100644 index 00000000..a834a3a6 --- /dev/null +++ b/.github/workflows/agent-workspace.yml @@ -0,0 +1,53 @@ +name: Agent workspace snapshot + +on: + push: + branches: + - agent/318-spreadsheet-body-import + +permissions: + contents: read + +concurrency: + group: agent-workspace-${{ github.ref }} + cancel-in-progress: true + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + snapshot: + runs-on: ubuntu-24.04 + steps: + - name: Check out the exact contributor head + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + persist-credentials: false + - name: Set up pnpm + uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + cache: pnpm + - name: Install the immutable workspace + run: pnpm install --frozen-lockfile + - name: Archive the exact source and installed dependency graph + run: | + set -euo pipefail + tar \ + --exclude=.git \ + --exclude=coverage \ + --exclude=dist \ + --exclude=office/.coverage \ + --exclude=office/dist \ + -czf "$RUNNER_TEMP/inkspan-agent-workspace.tgz" . + - name: Upload the bounded read-only workspace snapshot + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: inkspan-agent-workspace-${{ github.sha }} + path: ${{ runner.temp }}/inkspan-agent-workspace.tgz + if-no-files-found: error + retention-days: 1 + compression-level: 0 diff --git a/docs/superpowers/plans/2026-08-13-xls-xlsx-body-import.md b/docs/superpowers/plans/2026-08-13-xls-xlsx-body-import.md new file mode 100644 index 00000000..da1792af --- /dev/null +++ b/docs/superpowers/plans/2026-08-13-xls-xlsx-body-import.md @@ -0,0 +1,198 @@ +# XLS/XLSX Body Import Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let users insert visible worksheet content from local `.xls` and `.xlsx` files into the current Inkspan editor selection as editable headings and tables. + +**Architecture:** Keep binary parsing in a framework-neutral `spreadsheet` package boundary and lazy-load one pinned SheetJS-compatible parser only after a user selects a file. Convert parser output into bounded, inert TipTap JSON before one editor transaction; the toolbar owns file selection and accessible progress text, while hosts continue to own transport, authorization, persistence, and retention. + +**Tech Stack:** TypeScript 5.9, TipTap/ProseMirror JSON, React 18/19, Vitest, Testing Library, Vite library builds, `@lokalise/xlsx` 0.20.3 as the integrity-pinned SheetJS 0.20.3 mirror. + +## Global Constraints + +- Branch from exact protected `main@e8109ec2a17de8bd6594487aa12c8c8a93cb2c03`; do not advance protected `main` while release issue #118 owns the `v0.6.0` publication identity. +- Work only on `agent/318-spreadsheet-body-import`; do not overlap the active `CwlEditor.tsx` writer or Python Office renderer writers. +- Parse files locally in browser memory; add no upload, network fetch, credential, tenant, persistence, model, macro, formula-calculation, or durable-audit authority. +- Accept XLS and XLSX container bytes, but insert only visible worksheet names and displayed/cached cell values as inert text. +- Apply source, worksheet, row, column, cell, per-cell text, and total text ceilings before proportional editor materialization. +- Emit stable payload-redacted errors and never reflect file names, worksheet names, formulas, cell contents, parser exceptions, or binary bytes in ordinary failure messages. +- Preserve exact repository gates: TypeScript, 100% owned-production statement/branch/function/line coverage, package builds and consumers, demo, Chromium/Firefox/WebKit, Office Python 3.11-3.14, security scan, and SAST. +- Keep the pull request Draft and unmerged until the release freeze and every exact-current-head gate/review condition are resolved. + +--- + +### Task 1: Establish the executable RED contract + +**Files:** +- Create: `src/spreadsheet/spreadsheetImport.ts` +- Create: `src/spreadsheet/spreadsheetImport.test.ts` +- Create: `.github/workflows/agent-workspace.yml` (temporary, read-only, removed before handoff) + +**Interfaces:** +- Produces: `SpreadsheetWorkbookData`, `SpreadsheetImportResult`, `SpreadsheetImportError`, and `spreadsheetWorkbookToDocumentJson(workbook)`. + +- [ ] **Step 1: Write one product-boundary failing test** + +```ts +const result = spreadsheetWorkbookToDocumentJson({ + worksheets: [ + { name: 'Summary', hidden: false, rows: [['Name', 'Value'], ['매출', '42']] }, + { name: 'Private', hidden: true, rows: [['secret']] }, + ], +}); +expect(result.content.map((node) => node.type)).toEqual([ + 'heading', + 'table', + 'paragraph', +]); +``` + +- [ ] **Step 2: Commit a compiling placeholder that throws at the product boundary** + +```ts +export function spreadsheetWorkbookToDocumentJson( + _workbook: SpreadsheetWorkbookData, +): SpreadsheetImportResult { + throw new SpreadsheetImportError( + 'UNSUPPORTED_OR_CORRUPT', + 'Spreadsheet import is not implemented.', + ); +} +``` + +- [ ] **Step 3: Open a Draft PR and verify hosted RED** + +Run: canonical GitHub `CI` against the exact contributor head. + +Expected: dependency setup and TypeScript succeed; the dedicated spreadsheet test fails because conversion is not implemented. Setup, infrastructure, or module-resolution failure is not qualifying RED. + +### Task 2: Implement bounded binary parsing and TipTap conversion + +**Files:** +- Create: `src/spreadsheet/sheetJsAdapter.ts` +- Modify: `src/spreadsheet/spreadsheetImport.ts` +- Expand: `src/spreadsheet/spreadsheetImport.test.ts` +- Create: `src/spreadsheet/sheetJsAdapter.test.ts` +- Modify: `package.json` +- Modify: `pnpm-lock.yaml` + +**Interfaces:** +- Produces: `DEFAULT_SPREADSHEET_IMPORT_LIMITS`, `SpreadsheetImportLimits`, `SpreadsheetImportErrorCode`, `spreadsheetFileToDocumentJson(source, limits?)`, and parser-neutral workbook conversion. +- Consumes: `@lokalise/xlsx@0.20.3` through a dynamic import in `sheetJsAdapter.ts`. + +- [ ] **Step 1: Add failing tests for every public limit and error category** + +Cover source size before `arrayBuffer()`, visible worksheet count, decoded range rows/columns, rectangular cell count, per-cell and total text, malformed workbook structures, hidden/empty sheets, and payload-redacted failures. + +- [ ] **Step 2: Add real XLSX and BIFF8 XLS round trips** + +Create in-memory workbooks with Unicode, multiline values, dates, booleans, formulas with cached display values, hidden sheets, and empty sheets; write both `bookType: 'xlsx'` and `bookType: 'biff8'`, then import those exact bytes. + +- [ ] **Step 3: Pin the parser and immutable lock** + +Add exact dependency `@lokalise/xlsx: 0.20.3` and lock integrity `sha512-9+Wn7Hq2fHoaWJqhWXZXhUF6wNLk6Y5SL/QLLFuv6ChWWYi0lND7EwKeR6Hg8dXgyIc7Pkc0CaDXM+5z2zzi6Q==`. + +- [ ] **Step 4: Implement bounded parsing** + +Use `Blob.size` preflight, one `arrayBuffer()` read, lazy `import('@lokalise/xlsx')`, `read(..., { type: 'array', cellFormula: false, cellHTML: false, cellNF: false, bookVBA: false })`, visible-sheet metadata, decoded-range preflight, and formatted cell text. Do not evaluate formulas or preserve executable links/macros/objects. + +- [ ] **Step 5: Build deterministic TipTap JSON** + +For each visible non-empty worksheet, emit a level-3 heading, one rectangular table of ordinary `tableCell` nodes, and an empty trailing paragraph. Normalize CRLF to LF and represent internal newlines with `hardBreak` nodes. + +- [ ] **Step 6: Run focused tests and exact coverage** + +Run: `pnpm vitest run src/spreadsheet/spreadsheetImport.test.ts src/spreadsheet/sheetJsAdapter.test.ts` + +Run: `pnpm coverage` + +Expected: all tests pass; statements, branches, functions, and lines remain exactly 100% for owned production. + +### Task 3: Add accessible toolbar insertion + +**Files:** +- Modify: `src/components/Toolbar.tsx` +- Modify: `src/components/Toolbar.test.tsx` + +**Interfaces:** +- Consumes: `spreadsheetFileToDocumentJson(file)`. +- Produces: a keyboard-reachable `Insert XLS/XLSX spreadsheet` control, hidden local file input, one atomic insertion at the current selection, and polite status text. + +- [ ] **Step 1: Write failing interaction tests** + +Cover accepted MIME/extensions, insertion between existing paragraphs, normal undo, normal transaction/change behavior, same-file reselection, busy disablement, success count, stable failure status, no-file events, and unchanged roving-toolbar navigation. + +- [ ] **Step 2: Implement the file picker** + +Add a dedicated input accepting `.xls,.xlsx,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`. Clear its value before processing so selecting the same file again emits another change. + +- [ ] **Step 3: Insert one validated JSON batch** + +Call `editor.chain().focus().insertContent(result.content).run()` exactly once after parsing succeeds. On parse or transaction failure, leave the document unchanged and announce a stable non-content-bearing error. + +- [ ] **Step 4: Preserve toolbar accessibility** + +Keep one roving tab stop, arrow/Home/End behavior, native disabled semantics while parsing, and a visually unobtrusive `role="status" aria-live="polite"` region. + +### Task 4: Publish a framework-neutral spreadsheet subpath and canonical documentation + +**Files:** +- Create: `src/spreadsheet/index.ts` +- Create: `vite.spreadsheet.config.ts` +- Modify: `vite.config.ts` +- Modify: `src/index.ts` +- Modify: `package.json` +- Modify: package verification tests/scripts as required +- Create: `docs/adr/0027-bounded-local-spreadsheet-body-import.md` +- Modify: `docs/adr/README.md` +- Modify: `README.md` +- Modify: `CHANGELOG.md` +- Modify: `docs/CONTRACTS.md` +- Modify: `docs/THREAT_MODEL.md` +- Modify: `docs/TEST_STRATEGY.md` +- Modify: `docs/TRACEABILITY.md` +- Modify: `docs/accessibility.md` +- Add: machine-checkable documentation contract test + +**Interfaces:** +- Produces: package export `@contextualwisdomlab/cwl-editor/spreadsheet` with ESM, CommonJS, and declarations, while keeping React/TipTap runtime code outside that subpath. + +- [ ] **Step 1: Add package-consumer RED tests** + +Require ESM, CommonJS, and strict NodeNext TypeScript consumers to resolve the spreadsheet subpath and its declared public types from the packed tarball. + +- [ ] **Step 2: Add dedicated Vite build** + +Build `src/spreadsheet/index.ts` as `cwl-spreadsheet.js` and `cwl-spreadsheet.cjs`; externalize the direct parser dependency so it remains lazy and package-managed rather than copied into ordinary editor startup. + +- [ ] **Step 3: Record ADR 0027** + +Document context, decision, alternatives (server conversion, CSV-only, paste-only, static parser bundling), parser provenance, formula/macro non-execution, resource bounds, diagnostic privacy, accessibility, host authority, rollback, and release-freeze integration. + +- [ ] **Step 4: Reconcile canonical documentation** + +Update product contracts, threat model, test strategy, traceability, accessibility, README, and Unreleased changelog without claiming Draft behavior is protected or shipped. + +### Task 5: Remove temporary tooling and acquire exact-head evidence + +**Files:** +- Delete: `.github/workflows/agent-workspace.yml` +- Update: Draft PR body with exact immutable evidence and limitations + +- [ ] **Step 1: Run local complete verification** + +Run: `pnpm typecheck && pnpm coverage && pnpm build && pnpm verify:package && pnpm build:demo` + +Expected: every command succeeds with exact 100% owned-production coverage. + +- [ ] **Step 2: Verify repository hygiene** + +Run: `git diff --check`; confirm no temporary workflow, generated coverage, unpacked artifact, secret, credential, or unrelated writer-owned path remains. + +- [ ] **Step 3: Acquire hosted exact-head gates** + +Require terminal-success CI, Security Scan, and SAST on the unchanged contributor head, including browser and Office matrices. Treat queued, cancelled, predecessor-head, synthetic-only, status-only, and model-only signals as non-passing. + +- [ ] **Step 4: Keep the PR Draft and unmerged** + +Re-fetch live protected `main`, branch head, reviews, unresolved threads, and issue #118. Do not merge, publish, tag, or move protected release identity while #118 remains open. diff --git a/src/spreadsheet/spreadsheetImport.test.ts b/src/spreadsheet/spreadsheetImport.test.ts new file mode 100644 index 00000000..1fb8a0df --- /dev/null +++ b/src/spreadsheet/spreadsheetImport.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; +import { spreadsheetWorkbookToDocumentJson } from './spreadsheetImport.js'; + +describe('spreadsheetWorkbookToDocumentJson', () => { + it('converts visible worksheet text into an editable heading and table', () => { + const result = spreadsheetWorkbookToDocumentJson({ + worksheets: [ + { + name: 'Summary', + hidden: false, + rows: [ + ['Name', 'Value'], + ['매출', '42'], + ], + }, + { + name: 'Private', + hidden: true, + rows: [['secret']], + }, + ], + }); + + expect(result).toMatchObject({ + worksheetCount: 1, + rowCount: 2, + cellCount: 4, + }); + expect(result.content.map((node) => node.type)).toEqual([ + 'heading', + 'table', + 'paragraph', + ]); + expect(result.content[0]).toEqual({ + type: 'heading', + attrs: { level: 3 }, + content: [{ type: 'text', text: 'Summary' }], + }); + expect(result.content[1]).toMatchObject({ + type: 'table', + content: [ + { + type: 'tableRow', + content: [ + { + type: 'tableCell', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'Name' }], + }, + ], + }, + { + type: 'tableCell', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'Value' }], + }, + ], + }, + ], + }, + ], + }); + }); +}); diff --git a/src/spreadsheet/spreadsheetImport.ts b/src/spreadsheet/spreadsheetImport.ts new file mode 100644 index 00000000..c425a64b --- /dev/null +++ b/src/spreadsheet/spreadsheetImport.ts @@ -0,0 +1,60 @@ +import type { JSONContent } from '@tiptap/core'; + +/** One parser-neutral worksheet supplied to the bounded spreadsheet converter. */ +export interface SpreadsheetWorksheetData { + /** Display name used only after the worksheet passes visibility and resource checks. */ + readonly name: string; + /** Whether the source workbook marks this worksheet hidden or very hidden. */ + readonly hidden: boolean; + /** Rectangular or ragged displayed cell text in source reading order. */ + readonly rows: readonly (readonly string[])[]; +} + +/** Parser-neutral workbook projection accepted by the editor conversion boundary. */ +export interface SpreadsheetWorkbookData { + /** Source-order worksheet projections. */ + readonly worksheets: readonly SpreadsheetWorksheetData[]; +} + +/** Stable categories for spreadsheet-import failures. */ +export type SpreadsheetImportErrorCode = 'UNSUPPORTED_OR_CORRUPT'; + +/** Bounded spreadsheet content ready for one TipTap insertion transaction. */ +export interface SpreadsheetImportResult { + /** Block nodes inserted at the active editor selection. */ + readonly content: readonly JSONContent[]; + /** Number of visible, non-empty worksheets represented in `content`. */ + readonly worksheetCount: number; + /** Number of represented worksheet rows. */ + readonly rowCount: number; + /** Number of represented rectangular table cells, including blanks. */ + readonly cellCount: number; +} + +/** Payload-redacted error emitted by the spreadsheet import boundary. */ +export class SpreadsheetImportError extends Error { + /** Stable failure category suitable for host telemetry and localized UI. */ + readonly code: SpreadsheetImportErrorCode; + + /** Create a spreadsheet import error without retaining source content. */ + constructor(code: SpreadsheetImportErrorCode, message: string) { + super(message); + this.name = 'SpreadsheetImportError'; + this.code = code; + } +} + +/** + * Convert parser-neutral displayed worksheet text into editable TipTap blocks. + * + * This test-first placeholder intentionally reaches the public product boundary + * and fails until the bounded conversion contract is implemented. + */ +export function spreadsheetWorkbookToDocumentJson( + _workbook: SpreadsheetWorkbookData, +): SpreadsheetImportResult { + throw new SpreadsheetImportError( + 'UNSUPPORTED_OR_CORRUPT', + 'Spreadsheet import is not implemented.', + ); +} From 46fc0a56ff79d1ee6025b358b43f6a260bfd3309 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 22:55:36 +0900 Subject: [PATCH 004/163] chore(ci): preserve installed dependency build files --- .github/workflows/agent-workspace.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/agent-workspace.yml b/.github/workflows/agent-workspace.yml index a834a3a6..c73d0e78 100644 --- a/.github/workflows/agent-workspace.yml +++ b/.github/workflows/agent-workspace.yml @@ -37,11 +37,11 @@ jobs: run: | set -euo pipefail tar \ - --exclude=.git \ - --exclude=coverage \ - --exclude=dist \ - --exclude=office/.coverage \ - --exclude=office/dist \ + --exclude=./.git \ + --exclude=./coverage \ + --exclude=./dist \ + --exclude=./office/.coverage \ + --exclude=./office/dist \ -czf "$RUNNER_TEMP/inkspan-agent-workspace.tgz" . - name: Upload the bounded read-only workspace snapshot uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 From 1571a25900fd14493ea61a3d6569893b523360fe Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 23:00:40 +0900 Subject: [PATCH 005/163] chore(ci): stage exact spreadsheet parser for local TDD --- .github/workflows/agent-workspace.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/agent-workspace.yml b/.github/workflows/agent-workspace.yml index c73d0e78..8ef8e05e 100644 --- a/.github/workflows/agent-workspace.yml +++ b/.github/workflows/agent-workspace.yml @@ -33,6 +33,11 @@ jobs: cache: pnpm - name: Install the immutable workspace run: pnpm install --frozen-lockfile + - name: Stage the exact spreadsheet parser without lifecycle scripts + run: | + set -euo pipefail + pnpm add --save-exact --ignore-scripts @lokalise/xlsx@0.20.3 + grep -F 'sha512-9+Wn7Hq2fHoaWJqhWXZXhUF6wNLk6Y5SL/QLLFuv6ChWWYi0lND7EwKeR6Hg8dXgyIc7Pkc0CaDXM+5z2zzi6Q==' pnpm-lock.yaml - name: Archive the exact source and installed dependency graph run: | set -euo pipefail From 7a0d1cf0998129171865624bc2e742ad377a7d56 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 01:14:53 +0900 Subject: [PATCH 006/163] feat(spreadsheet): convert visible worksheet text --- src/spreadsheet/spreadsheetImport.ts | 51 ++++++++++++++++++++++------ 1 file changed, 40 insertions(+), 11 deletions(-) diff --git a/src/spreadsheet/spreadsheetImport.ts b/src/spreadsheet/spreadsheetImport.ts index c425a64b..72f67ad5 100644 --- a/src/spreadsheet/spreadsheetImport.ts +++ b/src/spreadsheet/spreadsheetImport.ts @@ -44,17 +44,46 @@ export class SpreadsheetImportError extends Error { } } -/** - * Convert parser-neutral displayed worksheet text into editable TipTap blocks. - * - * This test-first placeholder intentionally reaches the public product boundary - * and fails until the bounded conversion contract is implemented. - */ +function paragraphWithText(text: string): JSONContent { + return { + type: 'paragraph', + content: [{ type: 'text', text }], + }; +} + +/** Convert parser-neutral displayed worksheet text into editable TipTap blocks. */ export function spreadsheetWorkbookToDocumentJson( - _workbook: SpreadsheetWorkbookData, + workbook: SpreadsheetWorkbookData, ): SpreadsheetImportResult { - throw new SpreadsheetImportError( - 'UNSUPPORTED_OR_CORRUPT', - 'Spreadsheet import is not implemented.', - ); + const content: JSONContent[] = []; + let worksheetCount = 0; + let rowCount = 0; + let cellCount = 0; + + for (const worksheet of workbook.worksheets) { + if (worksheet.hidden) continue; + + content.push({ + type: 'heading', + attrs: { level: 3 }, + content: [{ type: 'text', text: worksheet.name }], + }); + content.push({ + type: 'table', + content: worksheet.rows.map((row) => ({ + type: 'tableRow', + content: row.map((cell) => ({ + type: 'tableCell', + content: [paragraphWithText(cell)], + })), + })), + }); + content.push({ type: 'paragraph' }); + + worksheetCount += 1; + rowCount += worksheet.rows.length; + cellCount += worksheet.rows.reduce((count, row) => count + row.length, 0); + } + + return { content, worksheetCount, rowCount, cellCount }; } From d8462dcadd39ce6fb2c857dc1fae94af02b6529a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 04:15:14 +0900 Subject: [PATCH 007/163] test(spreadsheet): assert complete imported worksheet rows --- src/spreadsheet/spreadsheetImport.test.ts | 25 ++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/spreadsheet/spreadsheetImport.test.ts b/src/spreadsheet/spreadsheetImport.test.ts index 1fb8a0df..0844b128 100644 --- a/src/spreadsheet/spreadsheetImport.test.ts +++ b/src/spreadsheet/spreadsheetImport.test.ts @@ -36,7 +36,7 @@ describe('spreadsheetWorkbookToDocumentJson', () => { attrs: { level: 3 }, content: [{ type: 'text', text: 'Summary' }], }); - expect(result.content[1]).toMatchObject({ + expect(result.content[1]).toEqual({ type: 'table', content: [ { @@ -62,6 +62,29 @@ describe('spreadsheetWorkbookToDocumentJson', () => { }, ], }, + { + type: 'tableRow', + content: [ + { + type: 'tableCell', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: '매출' }], + }, + ], + }, + { + type: 'tableCell', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: '42' }], + }, + ], + }, + ], + }, ], }); }); From 3b053a8fd964acd1b29b7b62bf08b2caa504574d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 04:19:24 +0900 Subject: [PATCH 008/163] test(spreadsheet): cover stable import error contract --- src/spreadsheet/spreadsheetImport.test.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/spreadsheet/spreadsheetImport.test.ts b/src/spreadsheet/spreadsheetImport.test.ts index 0844b128..dcc6c36f 100644 --- a/src/spreadsheet/spreadsheetImport.test.ts +++ b/src/spreadsheet/spreadsheetImport.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest'; -import { spreadsheetWorkbookToDocumentJson } from './spreadsheetImport.js'; +import { + SpreadsheetImportError, + spreadsheetWorkbookToDocumentJson, +} from './spreadsheetImport.js'; describe('spreadsheetWorkbookToDocumentJson', () => { it('converts visible worksheet text into an editable heading and table', () => { @@ -88,4 +91,16 @@ describe('spreadsheetWorkbookToDocumentJson', () => { ], }); }); + + it('exposes a stable payload-redacted import error identity', () => { + const error = new SpreadsheetImportError( + 'UNSUPPORTED_OR_CORRUPT', + 'Workbook cannot be imported.', + ); + + expect(error).toBeInstanceOf(Error); + expect(error.name).toBe('SpreadsheetImportError'); + expect(error.code).toBe('UNSUPPORTED_OR_CORRUPT'); + expect(error.message).toBe('Workbook cannot be imported.'); + }); }); From 9e70d937d0dc6eb6a58e2269c45c25ff5d2c63b3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 06:05:33 +0900 Subject: [PATCH 009/163] test(spreadsheet): cover worksheet fidelity boundaries --- src/spreadsheet/spreadsheetImport.test.ts | 123 ++++++++++++++++++++++ 1 file changed, 123 insertions(+) diff --git a/src/spreadsheet/spreadsheetImport.test.ts b/src/spreadsheet/spreadsheetImport.test.ts index dcc6c36f..508c44d8 100644 --- a/src/spreadsheet/spreadsheetImport.test.ts +++ b/src/spreadsheet/spreadsheetImport.test.ts @@ -92,6 +92,129 @@ describe('spreadsheetWorkbookToDocumentJson', () => { }); }); + it('skips visible worksheets that contain no rows', () => { + const result = spreadsheetWorkbookToDocumentJson({ + worksheets: [ + { name: 'Empty', hidden: false, rows: [] }, + { name: 'Data', hidden: false, rows: [['kept']] }, + ], + }); + + expect(result).toMatchObject({ + worksheetCount: 1, + rowCount: 1, + cellCount: 1, + }); + expect(result.content[0]).toEqual({ + type: 'heading', + attrs: { level: 3 }, + content: [{ type: 'text', text: 'Data' }], + }); + }); + + it('pads ragged rows to a rectangular table with valid empty cells', () => { + const result = spreadsheetWorkbookToDocumentJson({ + worksheets: [ + { + name: 'Ragged', + hidden: false, + rows: [ + ['A', 'B'], + ['C'], + ], + }, + ], + }); + + expect(result).toMatchObject({ + worksheetCount: 1, + rowCount: 2, + cellCount: 4, + }); + expect(result.content[1]).toEqual({ + type: 'table', + content: [ + { + type: 'tableRow', + content: [ + { + type: 'tableCell', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'A' }], + }, + ], + }, + { + type: 'tableCell', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'B' }], + }, + ], + }, + ], + }, + { + type: 'tableRow', + content: [ + { + type: 'tableCell', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'C' }], + }, + ], + }, + { + type: 'tableCell', + content: [{ type: 'paragraph' }], + }, + ], + }, + ], + }); + }); + + it('preserves multiline displayed cell text with hard breaks', () => { + const result = spreadsheetWorkbookToDocumentJson({ + worksheets: [ + { + name: 'Multiline', + hidden: false, + rows: [['first line\nsecond line']], + }, + ], + }); + + expect(result.content[1]).toEqual({ + type: 'table', + content: [ + { + type: 'tableRow', + content: [ + { + type: 'tableCell', + content: [ + { + type: 'paragraph', + content: [ + { type: 'text', text: 'first line' }, + { type: 'hardBreak' }, + { type: 'text', text: 'second line' }, + ], + }, + ], + }, + ], + }, + ], + }); + }); + it('exposes a stable payload-redacted import error identity', () => { const error = new SpreadsheetImportError( 'UNSUPPORTED_OR_CORRUPT', From 1b059db9291567a3075e5df5035512aeba656a6d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 06:11:34 +0900 Subject: [PATCH 010/163] fix(spreadsheet): preserve worksheet table fidelity --- src/spreadsheet/spreadsheetImport.ts | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/src/spreadsheet/spreadsheetImport.ts b/src/spreadsheet/spreadsheetImport.ts index 72f67ad5..28f5a9e8 100644 --- a/src/spreadsheet/spreadsheetImport.ts +++ b/src/spreadsheet/spreadsheetImport.ts @@ -45,10 +45,17 @@ export class SpreadsheetImportError extends Error { } function paragraphWithText(text: string): JSONContent { - return { - type: 'paragraph', - content: [{ type: 'text', text }], - }; + const content: JSONContent[] = []; + const lines = text.split(/\r\n|\r|\n/u); + + for (const [index, line] of lines.entries()) { + if (index > 0) content.push({ type: 'hardBreak' }); + if (line) content.push({ type: 'text', text: line }); + } + + return content.length > 0 + ? { type: 'paragraph', content } + : { type: 'paragraph' }; } /** Convert parser-neutral displayed worksheet text into editable TipTap blocks. */ @@ -63,6 +70,12 @@ export function spreadsheetWorkbookToDocumentJson( for (const worksheet of workbook.worksheets) { if (worksheet.hidden) continue; + const columnCount = worksheet.rows.reduce( + (maxColumns, row) => Math.max(maxColumns, row.length), + 0, + ); + if (columnCount === 0) continue; + content.push({ type: 'heading', attrs: { level: 3 }, @@ -72,9 +85,9 @@ export function spreadsheetWorkbookToDocumentJson( type: 'table', content: worksheet.rows.map((row) => ({ type: 'tableRow', - content: row.map((cell) => ({ + content: Array.from({ length: columnCount }, (_, columnIndex) => ({ type: 'tableCell', - content: [paragraphWithText(cell)], + content: [paragraphWithText(row[columnIndex] ?? '')], })), })), }); @@ -82,7 +95,7 @@ export function spreadsheetWorkbookToDocumentJson( worksheetCount += 1; rowCount += worksheet.rows.length; - cellCount += worksheet.rows.reduce((count, row) => count + row.length, 0); + cellCount += worksheet.rows.length * columnCount; } return { content, worksheetCount, rowCount, cellCount }; From fa81b40e518a63cf20223bdb2f53773549942941 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 06:16:20 +0900 Subject: [PATCH 011/163] test(spreadsheet): cover resource limit boundaries --- src/spreadsheet/spreadsheetImport.test.ts | 32 +++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/spreadsheet/spreadsheetImport.test.ts b/src/spreadsheet/spreadsheetImport.test.ts index 508c44d8..a79566c7 100644 --- a/src/spreadsheet/spreadsheetImport.test.ts +++ b/src/spreadsheet/spreadsheetImport.test.ts @@ -215,6 +215,38 @@ describe('spreadsheetWorkbookToDocumentJson', () => { }); }); + it('fails closed before materializing an over-wide worksheet', () => { + const workbook = { + worksheets: [ + { + name: 'Too wide', + hidden: false, + rows: [Array.from({ length: 257 }, (_, index) => String(index))], + }, + ], + }; + + expect(() => spreadsheetWorkbookToDocumentJson(workbook)).toThrowError( + 'Spreadsheet exceeds the configured resource limits.', + ); + }); + + it('fails closed before materializing oversized cell text', () => { + const workbook = { + worksheets: [ + { + name: 'Oversized text', + hidden: false, + rows: [['x'.repeat(32_769)]], + }, + ], + }; + + expect(() => spreadsheetWorkbookToDocumentJson(workbook)).toThrowError( + 'Spreadsheet exceeds the configured resource limits.', + ); + }); + it('exposes a stable payload-redacted import error identity', () => { const error = new SpreadsheetImportError( 'UNSUPPORTED_OR_CORRUPT', From 7c368094153c8e70a22040dd28ff4cd956f537f5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 06:19:15 +0900 Subject: [PATCH 012/163] fix(spreadsheet): preflight conversion resource limits --- src/spreadsheet/spreadsheetImport.ts | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/spreadsheet/spreadsheetImport.ts b/src/spreadsheet/spreadsheetImport.ts index 28f5a9e8..9c79414c 100644 --- a/src/spreadsheet/spreadsheetImport.ts +++ b/src/spreadsheet/spreadsheetImport.ts @@ -1,5 +1,9 @@ import type { JSONContent } from '@tiptap/core'; +const MAX_WORKSHEET_COLUMNS = 256; +const MAX_CELL_TEXT_CODE_UNITS = 32_768; +const RESOURCE_LIMIT_MESSAGE = 'Spreadsheet exceeds the configured resource limits.'; + /** One parser-neutral worksheet supplied to the bounded spreadsheet converter. */ export interface SpreadsheetWorksheetData { /** Display name used only after the worksheet passes visibility and resource checks. */ @@ -17,7 +21,9 @@ export interface SpreadsheetWorkbookData { } /** Stable categories for spreadsheet-import failures. */ -export type SpreadsheetImportErrorCode = 'UNSUPPORTED_OR_CORRUPT'; +export type SpreadsheetImportErrorCode = + | 'UNSUPPORTED_OR_CORRUPT' + | 'RESOURCE_LIMIT_EXCEEDED'; /** Bounded spreadsheet content ready for one TipTap insertion transaction. */ export interface SpreadsheetImportResult { @@ -44,6 +50,13 @@ export class SpreadsheetImportError extends Error { } } +function resourceLimitExceeded(): never { + throw new SpreadsheetImportError( + 'RESOURCE_LIMIT_EXCEEDED', + RESOURCE_LIMIT_MESSAGE, + ); +} + function paragraphWithText(text: string): JSONContent { const content: JSONContent[] = []; const lines = text.split(/\r\n|\r|\n/u); @@ -75,6 +88,13 @@ export function spreadsheetWorkbookToDocumentJson( 0, ); if (columnCount === 0) continue; + if (columnCount > MAX_WORKSHEET_COLUMNS) resourceLimitExceeded(); + + for (const row of worksheet.rows) { + for (const cellText of row) { + if (cellText.length > MAX_CELL_TEXT_CODE_UNITS) resourceLimitExceeded(); + } + } content.push({ type: 'heading', From b2eef9b65239613d41087dd568d777664752a045 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 10:04:04 +0900 Subject: [PATCH 013/163] test(spreadsheet): bound visible worksheet count --- src/spreadsheet/spreadsheetImport.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/spreadsheet/spreadsheetImport.test.ts b/src/spreadsheet/spreadsheetImport.test.ts index a79566c7..b763c5cb 100644 --- a/src/spreadsheet/spreadsheetImport.test.ts +++ b/src/spreadsheet/spreadsheetImport.test.ts @@ -231,6 +231,20 @@ describe('spreadsheetWorkbookToDocumentJson', () => { ); }); + it('fails closed after 64 visible non-empty worksheets', () => { + const workbook = { + worksheets: Array.from({ length: 65 }, (_, index) => ({ + name: `Sheet ${index + 1}`, + hidden: false, + rows: [['kept']], + })), + }; + + expect(() => spreadsheetWorkbookToDocumentJson(workbook)).toThrowError( + 'Spreadsheet exceeds the configured resource limits.', + ); + }); + it('fails closed before materializing oversized cell text', () => { const workbook = { worksheets: [ From 89a179589ba6ca0f80d9ab57aca190f44d91c4f0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 10:08:45 +0900 Subject: [PATCH 014/163] fix(spreadsheet): bound visible worksheet materialization --- src/spreadsheet/spreadsheetImport.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/spreadsheet/spreadsheetImport.ts b/src/spreadsheet/spreadsheetImport.ts index 9c79414c..f8ea8d5a 100644 --- a/src/spreadsheet/spreadsheetImport.ts +++ b/src/spreadsheet/spreadsheetImport.ts @@ -1,5 +1,6 @@ import type { JSONContent } from '@tiptap/core'; +const MAX_VISIBLE_WORKSHEETS = 64; const MAX_WORKSHEET_COLUMNS = 256; const MAX_CELL_TEXT_CODE_UNITS = 32_768; const RESOURCE_LIMIT_MESSAGE = 'Spreadsheet exceeds the configured resource limits.'; @@ -88,6 +89,7 @@ export function spreadsheetWorkbookToDocumentJson( 0, ); if (columnCount === 0) continue; + if (worksheetCount >= MAX_VISIBLE_WORKSHEETS) resourceLimitExceeded(); if (columnCount > MAX_WORKSHEET_COLUMNS) resourceLimitExceeded(); for (const row of worksheet.rows) { From 3443bb6920e0e78dc16d8f326e51534f951af3be Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 11:07:42 +0900 Subject: [PATCH 015/163] test(spreadsheet): expose aggregate import limits --- src/spreadsheet/spreadsheetImport.test.ts | 53 +++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/src/spreadsheet/spreadsheetImport.test.ts b/src/spreadsheet/spreadsheetImport.test.ts index b763c5cb..34d28f47 100644 --- a/src/spreadsheet/spreadsheetImport.test.ts +++ b/src/spreadsheet/spreadsheetImport.test.ts @@ -245,6 +245,59 @@ describe('spreadsheetWorkbookToDocumentJson', () => { ); }); + it('fails closed before materializing more than 10000 rows', () => { + const workbook = { + worksheets: [ + { + name: 'Too many rows', + hidden: false, + rows: Array.from({ length: 10_001 }, () => ['kept']), + }, + ], + }; + + expect(() => spreadsheetWorkbookToDocumentJson(workbook)).toThrowError( + 'Spreadsheet exceeds the configured resource limits.', + ); + }); + + it('fails closed before materializing more than 262144 rectangular cells', () => { + const row = Array.from({ length: 256 }, () => ''); + const workbook = { + worksheets: [ + { + name: 'Too many cells', + hidden: false, + rows: Array.from({ length: 1_025 }, () => row), + }, + ], + }; + + expect(() => spreadsheetWorkbookToDocumentJson(workbook)).toThrowError( + 'Spreadsheet exceeds the configured resource limits.', + ); + }); + + it('fails closed before materializing more than 8388608 text code units', () => { + const maximumCellText = 'x'.repeat(32_768); + const workbook = { + worksheets: [ + { + name: 'Too much text', + hidden: false, + rows: [ + Array.from({ length: 256 }, () => maximumCellText), + [maximumCellText], + ], + }, + ], + }; + + expect(() => spreadsheetWorkbookToDocumentJson(workbook)).toThrowError( + 'Spreadsheet exceeds the configured resource limits.', + ); + }); + it('fails closed before materializing oversized cell text', () => { const workbook = { worksheets: [ From 0b1cb3d5687e782516e83b0b2d33126ca640a0d8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 11:11:55 +0900 Subject: [PATCH 016/163] fix(spreadsheet): preflight aggregate import limits --- src/spreadsheet/spreadsheetImport.ts | 37 ++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/src/spreadsheet/spreadsheetImport.ts b/src/spreadsheet/spreadsheetImport.ts index f8ea8d5a..0c9ff0d5 100644 --- a/src/spreadsheet/spreadsheetImport.ts +++ b/src/spreadsheet/spreadsheetImport.ts @@ -1,8 +1,11 @@ import type { JSONContent } from '@tiptap/core'; const MAX_VISIBLE_WORKSHEETS = 64; +const MAX_WORKBOOK_ROWS = 10_000; const MAX_WORKSHEET_COLUMNS = 256; +const MAX_WORKBOOK_CELLS = 262_144; const MAX_CELL_TEXT_CODE_UNITS = 32_768; +const MAX_WORKBOOK_TEXT_CODE_UNITS = 8_388_608; const RESOURCE_LIMIT_MESSAGE = 'Spreadsheet exceeds the configured resource limits.'; /** One parser-neutral worksheet supplied to the bounded spreadsheet converter. */ @@ -72,15 +75,22 @@ function paragraphWithText(text: string): JSONContent { : { type: 'paragraph' }; } +interface PreparedWorksheet { + readonly worksheet: SpreadsheetWorksheetData; + readonly columnCount: number; +} + /** Convert parser-neutral displayed worksheet text into editable TipTap blocks. */ export function spreadsheetWorkbookToDocumentJson( workbook: SpreadsheetWorkbookData, ): SpreadsheetImportResult { - const content: JSONContent[] = []; + const preparedWorksheets: PreparedWorksheet[] = []; let worksheetCount = 0; let rowCount = 0; let cellCount = 0; + let textCodeUnits = 0; + // Preflight the complete workbook before allocating proportional TipTap nodes. for (const worksheet of workbook.worksheets) { if (worksheet.hidden) continue; @@ -92,12 +102,33 @@ export function spreadsheetWorkbookToDocumentJson( if (worksheetCount >= MAX_VISIBLE_WORKSHEETS) resourceLimitExceeded(); if (columnCount > MAX_WORKSHEET_COLUMNS) resourceLimitExceeded(); + const nextRowCount = rowCount + worksheet.rows.length; + if (nextRowCount > MAX_WORKBOOK_ROWS) resourceLimitExceeded(); + + const worksheetCellCount = worksheet.rows.length * columnCount; + const nextCellCount = cellCount + worksheetCellCount; + if (nextCellCount > MAX_WORKBOOK_CELLS) resourceLimitExceeded(); + + let worksheetTextCodeUnits = 0; for (const row of worksheet.rows) { for (const cellText of row) { if (cellText.length > MAX_CELL_TEXT_CODE_UNITS) resourceLimitExceeded(); + worksheetTextCodeUnits += cellText.length; + if (textCodeUnits + worksheetTextCodeUnits > MAX_WORKBOOK_TEXT_CODE_UNITS) { + resourceLimitExceeded(); + } } } + preparedWorksheets.push({ worksheet, columnCount }); + worksheetCount += 1; + rowCount = nextRowCount; + cellCount = nextCellCount; + textCodeUnits += worksheetTextCodeUnits; + } + + const content: JSONContent[] = []; + for (const { worksheet, columnCount } of preparedWorksheets) { content.push({ type: 'heading', attrs: { level: 3 }, @@ -114,10 +145,6 @@ export function spreadsheetWorkbookToDocumentJson( })), }); content.push({ type: 'paragraph' }); - - worksheetCount += 1; - rowCount += worksheet.rows.length; - cellCount += worksheet.rows.length * columnCount; } return { content, worksheetCount, rowCount, cellCount }; From 4787124caf89672ff2b5565cc54d5a000b83e6f1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 13:41:50 +0900 Subject: [PATCH 017/163] test(spreadsheet): preflight impossible row counts --- .../spreadsheetImportRowPreflight.test.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 src/spreadsheet/spreadsheetImportRowPreflight.test.ts diff --git a/src/spreadsheet/spreadsheetImportRowPreflight.test.ts b/src/spreadsheet/spreadsheetImportRowPreflight.test.ts new file mode 100644 index 00000000..a1bed440 --- /dev/null +++ b/src/spreadsheet/spreadsheetImportRowPreflight.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { spreadsheetWorkbookToDocumentJson } from './spreadsheetImport.js'; + +describe('spreadsheet workbook row-count preflight', () => { + it('rejects an impossible worksheet row count before reading row entries', () => { + let rowRead = false; + const rows = new Array(10_001); + Object.defineProperty(rows, '0', { + configurable: true, + enumerable: true, + get() { + rowRead = true; + throw new Error('row payload must not be read'); + }, + }); + + expect(() => + spreadsheetWorkbookToDocumentJson({ + worksheets: [{ name: 'Too many rows', hidden: false, rows }], + }), + ).toThrowError('Spreadsheet exceeds the configured resource limits.'); + expect(rowRead).toBe(false); + }); +}); From 11b64079d5d312d71ecbd0ae77a384c066b60a1f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 13:44:20 +0900 Subject: [PATCH 018/163] fix(spreadsheet): preflight worksheet row counts --- src/spreadsheet/spreadsheetImport.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/spreadsheet/spreadsheetImport.ts b/src/spreadsheet/spreadsheetImport.ts index 0c9ff0d5..29cacda0 100644 --- a/src/spreadsheet/spreadsheetImport.ts +++ b/src/spreadsheet/spreadsheetImport.ts @@ -94,6 +94,9 @@ export function spreadsheetWorkbookToDocumentJson( for (const worksheet of workbook.worksheets) { if (worksheet.hidden) continue; + const nextRowCount = rowCount + worksheet.rows.length; + if (nextRowCount > MAX_WORKBOOK_ROWS) resourceLimitExceeded(); + const columnCount = worksheet.rows.reduce( (maxColumns, row) => Math.max(maxColumns, row.length), 0, @@ -102,9 +105,6 @@ export function spreadsheetWorkbookToDocumentJson( if (worksheetCount >= MAX_VISIBLE_WORKSHEETS) resourceLimitExceeded(); if (columnCount > MAX_WORKSHEET_COLUMNS) resourceLimitExceeded(); - const nextRowCount = rowCount + worksheet.rows.length; - if (nextRowCount > MAX_WORKBOOK_ROWS) resourceLimitExceeded(); - const worksheetCellCount = worksheet.rows.length * columnCount; const nextCellCount = cellCount + worksheetCellCount; if (nextCellCount > MAX_WORKBOOK_CELLS) resourceLimitExceeded(); @@ -148,4 +148,4 @@ export function spreadsheetWorkbookToDocumentJson( } return { content, worksheetCount, rowCount, cellCount }; -} +} \ No newline at end of file From db2de30f7821e7a9f382d8c2bd5350463a8f538a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 15:20:15 +0900 Subject: [PATCH 019/163] test(spreadsheet): bound worksheet heading text --- ...preadsheetImportWorksheetNameLimit.test.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 src/spreadsheet/spreadsheetImportWorksheetNameLimit.test.ts diff --git a/src/spreadsheet/spreadsheetImportWorksheetNameLimit.test.ts b/src/spreadsheet/spreadsheetImportWorksheetNameLimit.test.ts new file mode 100644 index 00000000..f4682afb --- /dev/null +++ b/src/spreadsheet/spreadsheetImportWorksheetNameLimit.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; +import { spreadsheetWorkbookToDocumentJson } from './spreadsheetImport.js'; + +describe('spreadsheet worksheet-name resource preflight', () => { + it('rejects worksheet heading text that alone exceeds the workbook text ceiling', () => { + const oversizedName = 'x'.repeat(8_388_609); + + expect(() => + spreadsheetWorkbookToDocumentJson({ + worksheets: [ + { + name: oversizedName, + hidden: false, + rows: [['kept']], + }, + ], + }), + ).toThrowError('Spreadsheet exceeds the configured resource limits.'); + }); +}); From 2bf4efe1245d65ce3fbff6bc9150e36dc9e90429 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 15:24:15 +0900 Subject: [PATCH 020/163] fix(spreadsheet): include headings in text budget --- src/spreadsheet/spreadsheetImport.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/spreadsheet/spreadsheetImport.ts b/src/spreadsheet/spreadsheetImport.ts index 29cacda0..40927060 100644 --- a/src/spreadsheet/spreadsheetImport.ts +++ b/src/spreadsheet/spreadsheetImport.ts @@ -109,7 +109,10 @@ export function spreadsheetWorkbookToDocumentJson( const nextCellCount = cellCount + worksheetCellCount; if (nextCellCount > MAX_WORKBOOK_CELLS) resourceLimitExceeded(); - let worksheetTextCodeUnits = 0; + let worksheetTextCodeUnits = worksheet.name.length; + if (textCodeUnits + worksheetTextCodeUnits > MAX_WORKBOOK_TEXT_CODE_UNITS) { + resourceLimitExceeded(); + } for (const row of worksheet.rows) { for (const cellText of row) { if (cellText.length > MAX_CELL_TEXT_CODE_UNITS) resourceLimitExceeded(); From e28bb2dae423781367b18d2b93d67366c13f4a46 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 06:00:20 +0900 Subject: [PATCH 021/163] test(spreadsheet): require bounded binary source preflight --- .../spreadsheetSourcePreflight.test.ts | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 src/spreadsheet/spreadsheetSourcePreflight.test.ts diff --git a/src/spreadsheet/spreadsheetSourcePreflight.test.ts b/src/spreadsheet/spreadsheetSourcePreflight.test.ts new file mode 100644 index 00000000..53d49806 --- /dev/null +++ b/src/spreadsheet/spreadsheetSourcePreflight.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest'; +import * as spreadsheetImport from './spreadsheetImport.js'; + +type SpreadsheetBinaryFormat = 'xls' | 'xlsx'; +type SpreadsheetBinarySource = Readonly<{ + format: SpreadsheetBinaryFormat; + bytes: Uint8Array; +}>; +type PreflightSpreadsheetBinarySource = ( + source: Uint8Array, +) => SpreadsheetBinarySource; + +function preflightSpreadsheetBinarySource(): PreflightSpreadsheetBinarySource { + const candidate = ( + spreadsheetImport as unknown as { + preflightSpreadsheetBinarySource?: PreflightSpreadsheetBinarySource; + } + ).preflightSpreadsheetBinarySource; + expect(candidate).toBeTypeOf('function'); + return candidate!; +} + +function xlsxEnvelope(): Uint8Array { + return Uint8Array.from([0x50, 0x4b, 0x03, 0x04, 0x14, 0x00, 0x00, 0x00]); +} + +function xlsEnvelope(): Uint8Array { + return Uint8Array.from([ + 0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1, 0x00, 0x00, + ]); +} + +describe('spreadsheet binary source preflight', () => { + it('detects an XLSX ZIP envelope without copying source bytes', () => { + const source = xlsxEnvelope(); + const result = preflightSpreadsheetBinarySource()(source); + + expect(result).toEqual({ format: 'xlsx', bytes: source }); + expect(result.bytes).toBe(source); + }); + + it('detects a legacy XLS compound-file envelope without copying source bytes', () => { + const source = xlsEnvelope(); + const result = preflightSpreadsheetBinarySource()(source); + + expect(result).toEqual({ format: 'xls', bytes: source }); + expect(result.bytes).toBe(source); + }); + + it('rejects a source larger than the 64 MiB local ceiling before signature inspection', () => { + const source = xlsxEnvelope(); + Object.defineProperty(source, 'byteLength', { + configurable: true, + value: 64 * 1024 * 1024 + 1, + }); + Object.defineProperty(source, '0', { + configurable: true, + get() { + throw new Error('signature bytes must not be read after the size preflight'); + }, + }); + + expect(() => preflightSpreadsheetBinarySource()(source)).toThrowError( + 'Spreadsheet exceeds the configured resource limits.', + ); + }); + + it('rejects empty and unknown binary envelopes with a payload-redacted category', () => { + const preflight = preflightSpreadsheetBinarySource(); + + for (const source of [new Uint8Array(0), Uint8Array.from([1, 2, 3, 4, 5, 6, 7, 8])]) { + try { + preflight(source); + throw new Error('expected spreadsheet preflight to reject unsupported input'); + } catch (error) { + expect(error).toMatchObject({ + name: 'SpreadsheetImportError', + code: 'UNSUPPORTED_OR_CORRUPT', + message: 'Spreadsheet source is unsupported or corrupt.', + }); + } + } + }); +}); From bb8ac96a4d6556d994e53fcc53a04d940eecdf55 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 06:01:04 +0900 Subject: [PATCH 022/163] test(spreadsheet): make source size preflight observable --- .../spreadsheetSourcePreflight.test.ts | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/src/spreadsheet/spreadsheetSourcePreflight.test.ts b/src/spreadsheet/spreadsheetSourcePreflight.test.ts index 53d49806..e2c7d67e 100644 --- a/src/spreadsheet/spreadsheetSourcePreflight.test.ts +++ b/src/spreadsheet/spreadsheetSourcePreflight.test.ts @@ -48,15 +48,14 @@ describe('spreadsheet binary source preflight', () => { }); it('rejects a source larger than the 64 MiB local ceiling before signature inspection', () => { - const source = xlsxEnvelope(); - Object.defineProperty(source, 'byteLength', { - configurable: true, - value: 64 * 1024 * 1024 + 1, - }); - Object.defineProperty(source, '0', { - configurable: true, - get() { - throw new Error('signature bytes must not be read after the size preflight'); + const target = xlsxEnvelope(); + const source = new Proxy(target, { + get(innerTarget, property) { + if (property === 'byteLength') return 64 * 1024 * 1024 + 1; + if (property === '0') { + throw new Error('signature bytes must not be read after the size preflight'); + } + return Reflect.get(innerTarget, property, innerTarget); }, }); @@ -68,7 +67,10 @@ describe('spreadsheet binary source preflight', () => { it('rejects empty and unknown binary envelopes with a payload-redacted category', () => { const preflight = preflightSpreadsheetBinarySource(); - for (const source of [new Uint8Array(0), Uint8Array.from([1, 2, 3, 4, 5, 6, 7, 8])]) { + for (const source of [ + new Uint8Array(0), + Uint8Array.from([1, 2, 3, 4, 5, 6, 7, 8]), + ]) { try { preflight(source); throw new Error('expected spreadsheet preflight to reject unsupported input'); From d29c689589ce4a1f644fecc2ecbe63a9150ff43d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 06:04:17 +0900 Subject: [PATCH 023/163] feat(spreadsheet): bound binary source envelope --- src/spreadsheet/spreadsheetImport.ts | 63 ++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/src/spreadsheet/spreadsheetImport.ts b/src/spreadsheet/spreadsheetImport.ts index 40927060..4bba9d03 100644 --- a/src/spreadsheet/spreadsheetImport.ts +++ b/src/spreadsheet/spreadsheetImport.ts @@ -1,5 +1,6 @@ import type { JSONContent } from '@tiptap/core'; +const MAX_SPREADSHEET_SOURCE_BYTES = 64 * 1024 * 1024; const MAX_VISIBLE_WORKSHEETS = 64; const MAX_WORKBOOK_ROWS = 10_000; const MAX_WORKSHEET_COLUMNS = 256; @@ -7,6 +8,29 @@ const MAX_WORKBOOK_CELLS = 262_144; const MAX_CELL_TEXT_CODE_UNITS = 32_768; const MAX_WORKBOOK_TEXT_CODE_UNITS = 8_388_608; const RESOURCE_LIMIT_MESSAGE = 'Spreadsheet exceeds the configured resource limits.'; +const UNSUPPORTED_SOURCE_MESSAGE = 'Spreadsheet source is unsupported or corrupt.'; +const XLSX_ZIP_SIGNATURE = [0x50, 0x4b, 0x03, 0x04] as const; +const XLS_COMPOUND_FILE_SIGNATURE = [ + 0xd0, + 0xcf, + 0x11, + 0xe0, + 0xa1, + 0xb1, + 0x1a, + 0xe1, +] as const; + +/** Binary spreadsheet container family identified before local parsing. */ +export type SpreadsheetBinaryFormat = 'xls' | 'xlsx'; + +/** Bounded spreadsheet bytes paired with their detected container family. */ +export interface SpreadsheetBinarySource { + /** Container family selected only from the source signature. */ + readonly format: SpreadsheetBinaryFormat; + /** Original local bytes retained without an additional proportional copy. */ + readonly bytes: Uint8Array; +} /** One parser-neutral worksheet supplied to the bounded spreadsheet converter. */ export interface SpreadsheetWorksheetData { @@ -61,6 +85,45 @@ function resourceLimitExceeded(): never { ); } +function unsupportedOrCorruptSource(): never { + throw new SpreadsheetImportError( + 'UNSUPPORTED_OR_CORRUPT', + UNSUPPORTED_SOURCE_MESSAGE, + ); +} + +function startsWithSignature( + source: Uint8Array, + signature: readonly number[], +): boolean { + if (source.byteLength < signature.length) return false; + for (let index = 0; index < signature.length; index += 1) { + if (source[index] !== signature[index]) return false; + } + return true; +} + +/** + * Bound and classify local XLS/XLSX bytes before any workbook parser is loaded. + * + * This is deliberately only a source-envelope preflight. A matching ZIP or OLE + * signature does not assert that the remainder is a valid workbook; the later + * parser boundary must still fail closed on malformed package structure. + */ +export function preflightSpreadsheetBinarySource( + source: Uint8Array, +): SpreadsheetBinarySource { + if (source.byteLength > MAX_SPREADSHEET_SOURCE_BYTES) resourceLimitExceeded(); + + if (startsWithSignature(source, XLS_COMPOUND_FILE_SIGNATURE)) { + return { format: 'xls', bytes: source }; + } + if (startsWithSignature(source, XLSX_ZIP_SIGNATURE)) { + return { format: 'xlsx', bytes: source }; + } + return unsupportedOrCorruptSource(); +} + function paragraphWithText(text: string): JSONContent { const content: JSONContent[] = []; const lines = text.split(/\r\n|\r|\n/u); From 24099b5790bf77fde54203f5fb95475914427652 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 08:34:45 +0900 Subject: [PATCH 024/163] test(spreadsheet): reject non-byte binary sources --- .../spreadsheetBinaryPreflight.test.ts | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 src/spreadsheet/spreadsheetBinaryPreflight.test.ts diff --git a/src/spreadsheet/spreadsheetBinaryPreflight.test.ts b/src/spreadsheet/spreadsheetBinaryPreflight.test.ts new file mode 100644 index 00000000..fd4b4803 --- /dev/null +++ b/src/spreadsheet/spreadsheetBinaryPreflight.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; +import { + preflightSpreadsheetBinarySource, + SpreadsheetImportError, +} from './spreadsheetImport.js'; + +const UNSUPPORTED_SOURCE_MESSAGE = 'Spreadsheet source is unsupported or corrupt.'; + +function expectUnsupportedSource(action: () => unknown): void { + let thrown: unknown; + try { + action(); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(SpreadsheetImportError); + expect(thrown).toMatchObject({ + code: 'UNSUPPORTED_OR_CORRUPT', + message: UNSUPPORTED_SOURCE_MESSAGE, + }); +} + +describe('preflightSpreadsheetBinarySource runtime boundary', () => { + it('rejects non-Uint8Array views even when their elements mimic an XLSX signature', () => { + const wordView = new Uint16Array([0x50, 0x4b, 0x03, 0x04]); + + expectUnsupportedSource(() => + preflightSpreadsheetBinarySource(wordView as unknown as Uint8Array), + ); + }); + + it('rejects hostile non-byte sources before reading caller-controlled members', () => { + let byteLengthRead = false; + let indexRead = false; + const hostileSource = Object.create(null) as Record; + + Object.defineProperty(hostileSource, 'byteLength', { + get() { + byteLengthRead = true; + throw new Error('private-byte-length-sentinel'); + }, + }); + Object.defineProperty(hostileSource, '0', { + get() { + indexRead = true; + throw new Error('private-index-sentinel'); + }, + }); + + expectUnsupportedSource(() => + preflightSpreadsheetBinarySource(hostileSource as unknown as Uint8Array), + ); + expect(byteLengthRead).toBe(false); + expect(indexRead).toBe(false); + }); +}); From ba4934f2d0945f6376a1eba15632c66c0d8b0dec Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 08:38:27 +0900 Subject: [PATCH 025/163] test(spreadsheet): use real oversized byte source --- src/spreadsheet/spreadsheetSourcePreflight.test.ts | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/src/spreadsheet/spreadsheetSourcePreflight.test.ts b/src/spreadsheet/spreadsheetSourcePreflight.test.ts index e2c7d67e..a823e12f 100644 --- a/src/spreadsheet/spreadsheetSourcePreflight.test.ts +++ b/src/spreadsheet/spreadsheetSourcePreflight.test.ts @@ -47,17 +47,8 @@ describe('spreadsheet binary source preflight', () => { expect(result.bytes).toBe(source); }); - it('rejects a source larger than the 64 MiB local ceiling before signature inspection', () => { - const target = xlsxEnvelope(); - const source = new Proxy(target, { - get(innerTarget, property) { - if (property === 'byteLength') return 64 * 1024 * 1024 + 1; - if (property === '0') { - throw new Error('signature bytes must not be read after the size preflight'); - } - return Reflect.get(innerTarget, property, innerTarget); - }, - }); + it('rejects a real source larger than the 64 MiB local ceiling', () => { + const source = new Uint8Array(64 * 1024 * 1024 + 1); expect(() => preflightSpreadsheetBinarySource()(source)).toThrowError( 'Spreadsheet exceeds the configured resource limits.', From e53f8853fa548c356eb5e4839ef702258ed088ff Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 08:39:01 +0900 Subject: [PATCH 026/163] fix(spreadsheet): validate binary byte sources --- src/spreadsheet/spreadsheetImport.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/spreadsheet/spreadsheetImport.ts b/src/spreadsheet/spreadsheetImport.ts index 4bba9d03..fe60b80f 100644 --- a/src/spreadsheet/spreadsheetImport.ts +++ b/src/spreadsheet/spreadsheetImport.ts @@ -20,6 +20,10 @@ const XLS_COMPOUND_FILE_SIGNATURE = [ 0x1a, 0xe1, ] as const; +const TYPED_ARRAY_TAG_GETTER = Object.getOwnPropertyDescriptor( + Object.getPrototypeOf(Uint8Array.prototype), + Symbol.toStringTag, +)?.get; /** Binary spreadsheet container family identified before local parsing. */ export type SpreadsheetBinaryFormat = 'xls' | 'xlsx'; @@ -92,6 +96,13 @@ function unsupportedOrCorruptSource(): never { ); } +function isUint8ArraySource(source: unknown): source is Uint8Array { + return ( + ArrayBuffer.isView(source) && + TYPED_ARRAY_TAG_GETTER?.call(source) === 'Uint8Array' + ); +} + function startsWithSignature( source: Uint8Array, signature: readonly number[], @@ -113,6 +124,7 @@ function startsWithSignature( export function preflightSpreadsheetBinarySource( source: Uint8Array, ): SpreadsheetBinarySource { + if (!isUint8ArraySource(source)) unsupportedOrCorruptSource(); if (source.byteLength > MAX_SPREADSHEET_SOURCE_BYTES) resourceLimitExceeded(); if (startsWithSignature(source, XLS_COMPOUND_FILE_SIGNATURE)) { @@ -214,4 +226,4 @@ export function spreadsheetWorkbookToDocumentJson( } return { content, worksheetCount, rowCount, cellCount }; -} \ No newline at end of file +} From c95559452aab31803027d17f89fd1c2fff95e66a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 08:43:32 +0900 Subject: [PATCH 027/163] test(spreadsheet): bypass byte-length accessors --- .../spreadsheetBinaryPreflight.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/spreadsheet/spreadsheetBinaryPreflight.test.ts b/src/spreadsheet/spreadsheetBinaryPreflight.test.ts index fd4b4803..ee98d74c 100644 --- a/src/spreadsheet/spreadsheetBinaryPreflight.test.ts +++ b/src/spreadsheet/spreadsheetBinaryPreflight.test.ts @@ -54,4 +54,23 @@ describe('preflightSpreadsheetBinarySource runtime boundary', () => { expect(byteLengthRead).toBe(false); expect(indexRead).toBe(false); }); + + it('accepts genuine byte-array subclasses without invoking overridden byteLength accessors', () => { + let byteLengthRead = false; + + class HostileByteSource extends Uint8Array { + override get byteLength(): number { + byteLengthRead = true; + throw new Error('private-byte-length-sentinel'); + } + } + + const source = new HostileByteSource([0x50, 0x4b, 0x03, 0x04]); + + expect(preflightSpreadsheetBinarySource(source)).toEqual({ + format: 'xlsx', + bytes: source, + }); + expect(byteLengthRead).toBe(false); + }); }); From 4c8f4588f00f84f96feb3fbab6be78440f72eaec Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 08:46:17 +0900 Subject: [PATCH 028/163] fix(spreadsheet): bypass byte-length accessors --- src/spreadsheet/spreadsheetImport.ts | 33 +++++++++++++++++++++------- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/src/spreadsheet/spreadsheetImport.ts b/src/spreadsheet/spreadsheetImport.ts index fe60b80f..0bd25922 100644 --- a/src/spreadsheet/spreadsheetImport.ts +++ b/src/spreadsheet/spreadsheetImport.ts @@ -20,10 +20,15 @@ const XLS_COMPOUND_FILE_SIGNATURE = [ 0x1a, 0xe1, ] as const; +const TYPED_ARRAY_PROTOTYPE = Object.getPrototypeOf(Uint8Array.prototype) as object; const TYPED_ARRAY_TAG_GETTER = Object.getOwnPropertyDescriptor( - Object.getPrototypeOf(Uint8Array.prototype), + TYPED_ARRAY_PROTOTYPE, Symbol.toStringTag, -)?.get; +)!.get!; +const TYPED_ARRAY_BYTE_LENGTH_GETTER = Object.getOwnPropertyDescriptor( + TYPED_ARRAY_PROTOTYPE, + 'byteLength', +)!.get!; /** Binary spreadsheet container family identified before local parsing. */ export type SpreadsheetBinaryFormat = 'xls' | 'xlsx'; @@ -99,15 +104,20 @@ function unsupportedOrCorruptSource(): never { function isUint8ArraySource(source: unknown): source is Uint8Array { return ( ArrayBuffer.isView(source) && - TYPED_ARRAY_TAG_GETTER?.call(source) === 'Uint8Array' + TYPED_ARRAY_TAG_GETTER.call(source) === 'Uint8Array' ); } +function byteLengthOfUint8Array(source: Uint8Array): number { + return TYPED_ARRAY_BYTE_LENGTH_GETTER.call(source) as number; +} + function startsWithSignature( source: Uint8Array, + sourceByteLength: number, signature: readonly number[], ): boolean { - if (source.byteLength < signature.length) return false; + if (sourceByteLength < signature.length) return false; for (let index = 0; index < signature.length; index += 1) { if (source[index] !== signature[index]) return false; } @@ -125,12 +135,19 @@ export function preflightSpreadsheetBinarySource( source: Uint8Array, ): SpreadsheetBinarySource { if (!isUint8ArraySource(source)) unsupportedOrCorruptSource(); - if (source.byteLength > MAX_SPREADSHEET_SOURCE_BYTES) resourceLimitExceeded(); - - if (startsWithSignature(source, XLS_COMPOUND_FILE_SIGNATURE)) { + const sourceByteLength = byteLengthOfUint8Array(source); + if (sourceByteLength > MAX_SPREADSHEET_SOURCE_BYTES) resourceLimitExceeded(); + + if ( + startsWithSignature( + source, + sourceByteLength, + XLS_COMPOUND_FILE_SIGNATURE, + ) + ) { return { format: 'xls', bytes: source }; } - if (startsWithSignature(source, XLSX_ZIP_SIGNATURE)) { + if (startsWithSignature(source, sourceByteLength, XLSX_ZIP_SIGNATURE)) { return { format: 'xlsx', bytes: source }; } return unsupportedOrCorruptSource(); From 793402b7437630a42520ef0d50ac6b69aa04ed46 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 19:06:48 +0900 Subject: [PATCH 029/163] test(spreadsheet): reject runtime metadata coercion --- .../spreadsheetImportRuntimeShape.test.ts | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 src/spreadsheet/spreadsheetImportRuntimeShape.test.ts diff --git a/src/spreadsheet/spreadsheetImportRuntimeShape.test.ts b/src/spreadsheet/spreadsheetImportRuntimeShape.test.ts new file mode 100644 index 00000000..3c39f1d6 --- /dev/null +++ b/src/spreadsheet/spreadsheetImportRuntimeShape.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest'; +import { + spreadsheetWorkbookToDocumentJson, + SpreadsheetImportError, +} from './spreadsheetImport.js'; + +const UNSUPPORTED_SOURCE_MESSAGE = + 'Spreadsheet source is unsupported or corrupt.'; + +function expectUnsupportedSource(action: () => unknown): void { + let thrown: unknown; + try { + action(); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(SpreadsheetImportError); + expect(thrown).toMatchObject({ + code: 'UNSUPPORTED_OR_CORRUPT', + message: UNSUPPORTED_SOURCE_MESSAGE, + }); +} + +describe('spreadsheetWorkbookToDocumentJson runtime metadata boundary', () => { + it('rejects non-string cells before reading caller-controlled length', () => { + let lengthRead = false; + const hostileCell = Object.create(null) as Record; + Object.defineProperty(hostileCell, 'length', { + get() { + lengthRead = true; + throw new Error('private-cell-length-sentinel'); + }, + }); + + expectUnsupportedSource(() => + spreadsheetWorkbookToDocumentJson({ + worksheets: [ + { + name: 'Data', + hidden: false, + rows: [[hostileCell as unknown as string]], + }, + ], + }), + ); + expect(lengthRead).toBe(false); + }); + + it('rejects non-string worksheet names before reading caller-controlled length', () => { + let lengthRead = false; + const hostileName = Object.create(null) as Record; + Object.defineProperty(hostileName, 'length', { + get() { + lengthRead = true; + throw new Error('private-name-length-sentinel'); + }, + }); + + expectUnsupportedSource(() => + spreadsheetWorkbookToDocumentJson({ + worksheets: [ + { + name: hostileName as unknown as string, + hidden: false, + rows: [['kept']], + }, + ], + }), + ); + expect(lengthRead).toBe(false); + }); + + it('rejects non-boolean hidden metadata instead of silently skipping a sheet', () => { + expectUnsupportedSource(() => + spreadsheetWorkbookToDocumentJson({ + worksheets: [ + { + name: 'Data', + hidden: 'false' as unknown as boolean, + rows: [['kept']], + }, + ], + }), + ); + }); +}); From b5cee2138f4729d56be2361d72a74084bf4436ad Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 19:10:34 +0900 Subject: [PATCH 030/163] fix(spreadsheet): validate runtime metadata primitives --- src/spreadsheet/spreadsheetImport.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/spreadsheet/spreadsheetImport.ts b/src/spreadsheet/spreadsheetImport.ts index 0bd25922..a10edb0e 100644 --- a/src/spreadsheet/spreadsheetImport.ts +++ b/src/spreadsheet/spreadsheetImport.ts @@ -184,6 +184,12 @@ export function spreadsheetWorkbookToDocumentJson( // Preflight the complete workbook before allocating proportional TipTap nodes. for (const worksheet of workbook.worksheets) { + if ( + typeof worksheet.hidden !== 'boolean' || + typeof worksheet.name !== 'string' + ) { + unsupportedOrCorruptSource(); + } if (worksheet.hidden) continue; const nextRowCount = rowCount + worksheet.rows.length; @@ -207,6 +213,7 @@ export function spreadsheetWorkbookToDocumentJson( } for (const row of worksheet.rows) { for (const cellText of row) { + if (typeof cellText !== 'string') unsupportedOrCorruptSource(); if (cellText.length > MAX_CELL_TEXT_CODE_UNITS) resourceLimitExceeded(); worksheetTextCodeUnits += cellText.length; if (textCodeUnits + worksheetTextCodeUnits > MAX_WORKBOOK_TEXT_CODE_UNITS) { From 5cb7754d37e2ca77a8ea3f5afeee06bb62f40b27 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 19:16:59 +0900 Subject: [PATCH 031/163] test(spreadsheet): reject malformed runtime containers --- ...spreadsheetImportRuntimeContainers.test.ts | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 src/spreadsheet/spreadsheetImportRuntimeContainers.test.ts diff --git a/src/spreadsheet/spreadsheetImportRuntimeContainers.test.ts b/src/spreadsheet/spreadsheetImportRuntimeContainers.test.ts new file mode 100644 index 00000000..a5ff9900 --- /dev/null +++ b/src/spreadsheet/spreadsheetImportRuntimeContainers.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest'; +import { + spreadsheetWorkbookToDocumentJson, + SpreadsheetImportError, +} from './spreadsheetImport.js'; + +const UNSUPPORTED_SOURCE_MESSAGE = + 'Spreadsheet source is unsupported or corrupt.'; + +function expectUnsupportedSource(action: () => unknown): void { + let thrown: unknown; + try { + action(); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(SpreadsheetImportError); + expect(thrown).toMatchObject({ + code: 'UNSUPPORTED_OR_CORRUPT', + message: UNSUPPORTED_SOURCE_MESSAGE, + }); +} + +describe('spreadsheetWorkbookToDocumentJson runtime containers', () => { + it('rejects a non-array worksheet collection before reading its iterator', () => { + let iteratorRead = false; + const hostileWorksheets = Object.create(null) as Record; + Object.defineProperty(hostileWorksheets, Symbol.iterator, { + get() { + iteratorRead = true; + throw new Error('private-worksheets-iterator-sentinel'); + }, + }); + + expectUnsupportedSource(() => + spreadsheetWorkbookToDocumentJson({ + worksheets: hostileWorksheets as unknown as readonly [], + }), + ); + expect(iteratorRead).toBe(false); + }); + + it('rejects a non-array row collection before reading its length', () => { + let lengthRead = false; + const hostileRows = Object.create(null) as Record; + Object.defineProperty(hostileRows, 'length', { + get() { + lengthRead = true; + throw new Error('private-rows-length-sentinel'); + }, + }); + + expectUnsupportedSource(() => + spreadsheetWorkbookToDocumentJson({ + worksheets: [ + { + name: 'Data', + hidden: false, + rows: hostileRows as unknown as readonly (readonly string[])[], + }, + ], + }), + ); + expect(lengthRead).toBe(false); + }); + + it('rejects a non-array row before reading its length', () => { + let lengthRead = false; + const hostileRow = Object.create(null) as Record; + Object.defineProperty(hostileRow, 'length', { + get() { + lengthRead = true; + throw new Error('private-row-length-sentinel'); + }, + }); + + expectUnsupportedSource(() => + spreadsheetWorkbookToDocumentJson({ + worksheets: [ + { + name: 'Data', + hidden: false, + rows: [hostileRow as unknown as readonly string[]], + }, + ], + }), + ); + expect(lengthRead).toBe(false); + }); +}); From 07e036418e0ccd74a0f5aa071eee7eb92a4459f6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 19:21:01 +0900 Subject: [PATCH 032/163] fix(spreadsheet): validate runtime container shapes --- src/spreadsheet/spreadsheetImport.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/spreadsheet/spreadsheetImport.ts b/src/spreadsheet/spreadsheetImport.ts index a10edb0e..5080e576 100644 --- a/src/spreadsheet/spreadsheetImport.ts +++ b/src/spreadsheet/spreadsheetImport.ts @@ -176,6 +176,8 @@ interface PreparedWorksheet { export function spreadsheetWorkbookToDocumentJson( workbook: SpreadsheetWorkbookData, ): SpreadsheetImportResult { + if (!Array.isArray(workbook.worksheets)) unsupportedOrCorruptSource(); + const preparedWorksheets: PreparedWorksheet[] = []; let worksheetCount = 0; let rowCount = 0; @@ -186,7 +188,8 @@ export function spreadsheetWorkbookToDocumentJson( for (const worksheet of workbook.worksheets) { if ( typeof worksheet.hidden !== 'boolean' || - typeof worksheet.name !== 'string' + typeof worksheet.name !== 'string' || + !Array.isArray(worksheet.rows) ) { unsupportedOrCorruptSource(); } @@ -195,10 +198,11 @@ export function spreadsheetWorkbookToDocumentJson( const nextRowCount = rowCount + worksheet.rows.length; if (nextRowCount > MAX_WORKBOOK_ROWS) resourceLimitExceeded(); - const columnCount = worksheet.rows.reduce( - (maxColumns, row) => Math.max(maxColumns, row.length), - 0, - ); + let columnCount = 0; + for (const row of worksheet.rows) { + if (!Array.isArray(row)) unsupportedOrCorruptSource(); + columnCount = Math.max(columnCount, row.length); + } if (columnCount === 0) continue; if (worksheetCount >= MAX_VISIBLE_WORKSHEETS) resourceLimitExceeded(); if (columnCount > MAX_WORKSHEET_COLUMNS) resourceLimitExceeded(); From 8182f94775b412e36d5aa9905bcf0f9f3a5d8192 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 21:12:54 +0900 Subject: [PATCH 033/163] test(spreadsheet): reject primitive workbook containers --- .../spreadsheetImportRuntimeContainers.test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/spreadsheet/spreadsheetImportRuntimeContainers.test.ts b/src/spreadsheet/spreadsheetImportRuntimeContainers.test.ts index a5ff9900..522b082c 100644 --- a/src/spreadsheet/spreadsheetImportRuntimeContainers.test.ts +++ b/src/spreadsheet/spreadsheetImportRuntimeContainers.test.ts @@ -23,6 +23,19 @@ function expectUnsupportedSource(action: () => unknown): void { } describe('spreadsheetWorkbookToDocumentJson runtime containers', () => { + it.each([null, undefined, 0, 'workbook']) ( + 'rejects non-object workbook containers with the stable domain error', + (invalidWorkbook) => { + expectUnsupportedSource(() => + spreadsheetWorkbookToDocumentJson( + invalidWorkbook as unknown as Parameters< + typeof spreadsheetWorkbookToDocumentJson + >[0], + ), + ); + }, + ); + it('rejects a non-array worksheet collection before reading its iterator', () => { let iteratorRead = false; const hostileWorksheets = Object.create(null) as Record; From a76c5050af51967cb1bfdf434e01c8067db171e1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 21:16:54 +0900 Subject: [PATCH 034/163] fix(spreadsheet): validate workbook container --- src/spreadsheet/spreadsheetImport.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/spreadsheet/spreadsheetImport.ts b/src/spreadsheet/spreadsheetImport.ts index 5080e576..1ee2eb70 100644 --- a/src/spreadsheet/spreadsheetImport.ts +++ b/src/spreadsheet/spreadsheetImport.ts @@ -176,6 +176,9 @@ interface PreparedWorksheet { export function spreadsheetWorkbookToDocumentJson( workbook: SpreadsheetWorkbookData, ): SpreadsheetImportResult { + if (typeof workbook !== 'object' || workbook === null) { + unsupportedOrCorruptSource(); + } if (!Array.isArray(workbook.worksheets)) unsupportedOrCorruptSource(); const preparedWorksheets: PreparedWorksheet[] = []; From 43259272883535ee69d39c5721a369bf84ce62df Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 21:23:10 +0900 Subject: [PATCH 035/163] test(spreadsheet): reject nullish worksheet entries --- .../spreadsheetImportRuntimeContainers.test.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/spreadsheet/spreadsheetImportRuntimeContainers.test.ts b/src/spreadsheet/spreadsheetImportRuntimeContainers.test.ts index 522b082c..1104fb49 100644 --- a/src/spreadsheet/spreadsheetImportRuntimeContainers.test.ts +++ b/src/spreadsheet/spreadsheetImportRuntimeContainers.test.ts @@ -54,6 +54,21 @@ describe('spreadsheetWorkbookToDocumentJson runtime containers', () => { expect(iteratorRead).toBe(false); }); + it.each([null, undefined])( + 'rejects nullish worksheet entries with the stable domain error', + (invalidWorksheet) => { + expectUnsupportedSource(() => + spreadsheetWorkbookToDocumentJson({ + worksheets: [ + invalidWorksheet as unknown as Parameters< + typeof spreadsheetWorkbookToDocumentJson + >[0]['worksheets'][number], + ], + }), + ); + }, + ); + it('rejects a non-array row collection before reading its length', () => { let lengthRead = false; const hostileRows = Object.create(null) as Record; From 77f7a92d623fcd06f0119145fa2641b82f180ba9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 22:01:56 +0900 Subject: [PATCH 036/163] fix(spreadsheet): validate worksheet container --- src/spreadsheet/spreadsheetImport.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/spreadsheet/spreadsheetImport.ts b/src/spreadsheet/spreadsheetImport.ts index 1ee2eb70..b34a4f06 100644 --- a/src/spreadsheet/spreadsheetImport.ts +++ b/src/spreadsheet/spreadsheetImport.ts @@ -190,6 +190,8 @@ export function spreadsheetWorkbookToDocumentJson( // Preflight the complete workbook before allocating proportional TipTap nodes. for (const worksheet of workbook.worksheets) { if ( + typeof worksheet !== 'object' || + worksheet === null || typeof worksheet.hidden !== 'boolean' || typeof worksheet.name !== 'string' || !Array.isArray(worksheet.rows) From 73a8315a0d292591e7ef2b2835810fc0cb964e6e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 01:32:32 +0900 Subject: [PATCH 037/163] test(spreadsheet): bound worksheet heading metadata --- .../spreadsheetWorksheetNameLimit.test.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 src/spreadsheet/spreadsheetWorksheetNameLimit.test.ts diff --git a/src/spreadsheet/spreadsheetWorksheetNameLimit.test.ts b/src/spreadsheet/spreadsheetWorksheetNameLimit.test.ts new file mode 100644 index 00000000..53fba4ac --- /dev/null +++ b/src/spreadsheet/spreadsheetWorksheetNameLimit.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; +import { spreadsheetWorkbookToDocumentJson } from './spreadsheetImport.js'; + +describe('spreadsheet worksheet-name resource boundary', () => { + it('fails closed before materializing an oversized worksheet heading', () => { + const workbook = { + worksheets: [ + { + name: 'x'.repeat(1_025), + hidden: false, + rows: [['kept']], + }, + ], + }; + + expect(() => spreadsheetWorkbookToDocumentJson(workbook)).toThrowError( + 'Spreadsheet exceeds the configured resource limits.', + ); + }); +}); From c00990b9ab1078f78cc9d6a950ce19b7836babe8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 01:37:07 +0900 Subject: [PATCH 038/163] fix(spreadsheet): bound worksheet heading metadata --- src/spreadsheet/spreadsheetImport.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/spreadsheet/spreadsheetImport.ts b/src/spreadsheet/spreadsheetImport.ts index b34a4f06..1ab4941f 100644 --- a/src/spreadsheet/spreadsheetImport.ts +++ b/src/spreadsheet/spreadsheetImport.ts @@ -2,6 +2,7 @@ import type { JSONContent } from '@tiptap/core'; const MAX_SPREADSHEET_SOURCE_BYTES = 64 * 1024 * 1024; const MAX_VISIBLE_WORKSHEETS = 64; +const MAX_WORKSHEET_NAME_CODE_UNITS = 1_024; const MAX_WORKBOOK_ROWS = 10_000; const MAX_WORKSHEET_COLUMNS = 256; const MAX_WORKBOOK_CELLS = 262_144; @@ -199,6 +200,9 @@ export function spreadsheetWorkbookToDocumentJson( unsupportedOrCorruptSource(); } if (worksheet.hidden) continue; + if (worksheet.name.length > MAX_WORKSHEET_NAME_CODE_UNITS) { + resourceLimitExceeded(); + } const nextRowCount = rowCount + worksheet.rows.length; if (nextRowCount > MAX_WORKBOOK_ROWS) resourceLimitExceeded(); From 373f6d632d9df8e827c99fb97e7fe5801927d514 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 01:41:52 +0900 Subject: [PATCH 039/163] fix(spreadsheet): remove redundant text preflight branch --- src/spreadsheet/spreadsheetImport.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/spreadsheet/spreadsheetImport.ts b/src/spreadsheet/spreadsheetImport.ts index 1ab4941f..b8b75c90 100644 --- a/src/spreadsheet/spreadsheetImport.ts +++ b/src/spreadsheet/spreadsheetImport.ts @@ -221,9 +221,6 @@ export function spreadsheetWorkbookToDocumentJson( if (nextCellCount > MAX_WORKBOOK_CELLS) resourceLimitExceeded(); let worksheetTextCodeUnits = worksheet.name.length; - if (textCodeUnits + worksheetTextCodeUnits > MAX_WORKBOOK_TEXT_CODE_UNITS) { - resourceLimitExceeded(); - } for (const row of worksheet.rows) { for (const cellText of row) { if (typeof cellText !== 'string') unsupportedOrCorruptSource(); From 507807f491a5bf30d4c63c87764ffed85263bb0f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 01:48:51 +0900 Subject: [PATCH 040/163] test(spreadsheet): require framework-neutral package surface --- .../spreadsheetPublicSurface.test.ts | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 src/spreadsheet/spreadsheetPublicSurface.test.ts diff --git a/src/spreadsheet/spreadsheetPublicSurface.test.ts b/src/spreadsheet/spreadsheetPublicSurface.test.ts new file mode 100644 index 00000000..14c6836c --- /dev/null +++ b/src/spreadsheet/spreadsheetPublicSurface.test.ts @@ -0,0 +1,36 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +describe('spreadsheet package subpath contract', () => { + it('declares an independently built framework-neutral spreadsheet surface', () => { + const packageJsonPath = fileURLToPath( + new URL('../../package.json', import.meta.url), + ); + const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as { + exports?: Record; + scripts?: Record; + }; + + expect(packageJson.exports?.['./spreadsheet']).toEqual({ + types: './dist/spreadsheet/index.d.ts', + import: './dist/cwl-spreadsheet.js', + require: './dist/cwl-spreadsheet.cjs', + }); + expect(packageJson.scripts?.build).toContain('vite.spreadsheet.config.ts'); + expect(packageJson.scripts?.['verify:package']).toContain( + 'verify-spreadsheet-subpath-package.mjs', + ); + + const requiredFiles = [ + './index.ts', + '../../vite.spreadsheet.config.ts', + '../../scripts/verify-spreadsheet-subpath-package.mjs', + ]; + for (const relativePath of requiredFiles) { + expect( + existsSync(fileURLToPath(new URL(relativePath, import.meta.url))), + ).toBe(true); + } + }); +}); From bbb7480532d94b82f6c7c73ec25161cab4f2020b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 01:52:23 +0900 Subject: [PATCH 041/163] test(spreadsheet): fix package-surface fixture path --- .../spreadsheetPublicSurface.test.ts | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/src/spreadsheet/spreadsheetPublicSurface.test.ts b/src/spreadsheet/spreadsheetPublicSurface.test.ts index 14c6836c..e75ca604 100644 --- a/src/spreadsheet/spreadsheetPublicSurface.test.ts +++ b/src/spreadsheet/spreadsheetPublicSurface.test.ts @@ -1,13 +1,13 @@ import { existsSync, readFileSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; +import { resolve } from 'node:path'; import { describe, expect, it } from 'vitest'; describe('spreadsheet package subpath contract', () => { it('declares an independently built framework-neutral spreadsheet surface', () => { - const packageJsonPath = fileURLToPath( - new URL('../../package.json', import.meta.url), - ); - const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as { + const repositoryRoot = process.cwd(); + const packageJson = JSON.parse( + readFileSync(resolve(repositoryRoot, 'package.json'), 'utf8'), + ) as { exports?: Record; scripts?: Record; }; @@ -23,14 +23,12 @@ describe('spreadsheet package subpath contract', () => { ); const requiredFiles = [ - './index.ts', - '../../vite.spreadsheet.config.ts', - '../../scripts/verify-spreadsheet-subpath-package.mjs', + 'src/spreadsheet/index.ts', + 'vite.spreadsheet.config.ts', + 'scripts/verify-spreadsheet-subpath-package.mjs', ]; for (const relativePath of requiredFiles) { - expect( - existsSync(fileURLToPath(new URL(relativePath, import.meta.url))), - ).toBe(true); + expect(existsSync(resolve(repositoryRoot, relativePath))).toBe(true); } }); }); From 597dda8d92835697caf4f0818bae08a2fbeb71ff Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 04:07:56 +0900 Subject: [PATCH 042/163] feat(spreadsheet): publish bounded package surface --- package.json | 9 +- .../verify-spreadsheet-subpath-package.mjs | 143 ++++++++++++++++++ src/spreadsheet/index.ts | 2 + vite.spreadsheet.config.ts | 28 ++++ 4 files changed, 180 insertions(+), 2 deletions(-) create mode 100644 scripts/verify-spreadsheet-subpath-package.mjs create mode 100644 src/spreadsheet/index.ts create mode 100644 vite.spreadsheet.config.ts diff --git a/package.json b/package.json index 4e55d924..2068ddfa 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,11 @@ "import": "./dist/cwl-markdown.js", "require": "./dist/cwl-markdown.cjs" }, + "./spreadsheet": { + "types": "./dist/spreadsheet/index.d.ts", + "import": "./dist/cwl-spreadsheet.js", + "require": "./dist/cwl-spreadsheet.cjs" + }, "./styles.css": "./dist/cwl-editor.css", "./fonts.css": "./src/fonts/fonts.css", "./fonts-latin.css": "./src/fonts/fonts-latin.css", @@ -99,7 +104,7 @@ ], "scripts": { "dev": "vite", - "build": "tsc --noEmit && vite build && vite build --config vite.collaboration.config.ts && vite build --config vite.converter.config.ts && vite build --config vite.envelope-identity.config.ts && vite build --config vite.revision-evidence.config.ts && vite build --config vite.autosave.config.ts && vite build --config vite.text-position-selector.config.ts && vite build --config vite.markdown.config.ts && node ./scripts/copy-styles.mjs", + "build": "tsc --noEmit && vite build && vite build --config vite.collaboration.config.ts && vite build --config vite.converter.config.ts && vite build --config vite.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.spreadsheet.config.ts && node ./scripts/copy-styles.mjs", "build:demo": "vite build --config vite.demo.config.ts", "fonts": "node ./scripts/fetch-fonts.mjs", "preview": "vite preview", @@ -108,7 +113,7 @@ "test:watch": "vitest", "coverage": "vitest run --coverage", "test:package-config": "node --test ./scripts/revision-evidence-consumer-config.test.mjs ./scripts/release-metadata.test.mjs ./scripts/javascript-runtime-authority.test.mjs", - "verify:package": "pnpm run test:package-config && node ./tests/package/verify-package.mjs && node ./tests/package/verify-editor-placeholder-package.mjs && node ./scripts/verify-canonical-envelope-package.mjs && node ./scripts/verify-revision-evidence-package.mjs && node ./scripts/verify-framework-free-revision-evidence-package.mjs && node ./scripts/verify-framework-free-envelope-identity-package.mjs && node ./tests/package/verify-framework-free-autosave-package.mjs && node ./scripts/verify-text-position-selector-package.mjs && node ./scripts/verify-text-position-selector-subpath-package.mjs && node ./scripts/verify-markdown-subpath-package.mjs" + "verify:package": "pnpm run test:package-config && node ./tests/package/verify-package.mjs && node ./tests/package/verify-editor-placeholder-package.mjs && node ./scripts/verify-canonical-envelope-package.mjs && node ./scripts/verify-revision-evidence-package.mjs && node ./scripts/verify-framework-free-revision-evidence-package.mjs && node ./scripts/verify-framework-free-envelope-identity-package.mjs && node ./tests/package/verify-framework-free-autosave-package.mjs && node ./scripts/verify-text-position-selector-package.mjs && node ./scripts/verify-text-position-selector-subpath-package.mjs && node ./scripts/verify-markdown-subpath-package.mjs && node ./scripts/verify-spreadsheet-subpath-package.mjs" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", diff --git a/scripts/verify-spreadsheet-subpath-package.mjs b/scripts/verify-spreadsheet-subpath-package.mjs new file mode 100644 index 00000000..9285ef6d --- /dev/null +++ b/scripts/verify-spreadsheet-subpath-package.mjs @@ -0,0 +1,143 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { findRuntimeModuleAuthority } from './javascript-runtime-authority.mjs'; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const packageJson = JSON.parse( + readFileSync(join(repositoryRoot, 'package.json'), 'utf8'), +); +const verificationRoot = mkdtempSync(join(tmpdir(), 'inkspan-spreadsheet-')); +const extractionDirectory = join(verificationRoot, 'extracted'); +const consumerDirectory = join(verificationRoot, 'consumer'); +const packageDirectory = join( + consumerDirectory, + 'node_modules', + ...packageJson.name.split('/'), +); +const ambientAuthorityPattern = + /(?:\bfetch\s*\(|\bXMLHttpRequest\b|\bWebSocket\b|\bEventSource\b|\bprocess\.env\b|\bimport\.meta\.env\b|\bDeno\.env\b|\bBun\.env\b)/u; +const forbiddenProductGraphPattern = + /(?:ReactDOM|\bReact\b|react-dom|@tiptap\/react|y-prosemirror|\byjs\b|\bnaruon\b|contextual-orchestrator|NVIDIA_NIM_API_KEY|COPILOT_GITHUB_TOKEN)/iu; + +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]; + assert.equal(packResult.name, packageJson.name); + assert.equal(packResult.version, packageJson.version); + const tarballPath = join(verificationRoot, packResult.filename); + assert.ok(existsSync(tarballPath)); + run('tar', ['-xzf', tarballPath, '-C', extractionDirectory]); + renameSync(join(extractionDirectory, 'package'), packageDirectory); + writeFileSync( + join(consumerDirectory, 'package.json'), + '{"name":"inkspan-spreadsheet-consumer","private":true,"type":"module"}\n', + 'utf8', + ); +} + +function verifyAuthorityFreeBundles() { + assert.ok(existsSync(join(packageDirectory, 'dist', 'spreadsheet', 'index.d.ts'))); + for (const filename of ['cwl-spreadsheet.js', 'cwl-spreadsheet.cjs']) { + const bundleSource = readFileSync( + join(packageDirectory, 'dist', filename), + 'utf8', + ); + assert.deepEqual( + findRuntimeModuleAuthority(bundleSource, filename), + [], + `${filename} must not contain executable runtime module authority`, + ); + assert.doesNotMatch( + bundleSource, + ambientAuthorityPattern, + `${filename} must not reference ambient network or credential authority`, + ); + assert.doesNotMatch( + bundleSource, + forbiddenProductGraphPattern, + `${filename} must not embed React, Yjs, CWL host, or model authority`, + ); + } +} + +function verifyRuntimeConsumers() { + const esmPath = join(consumerDirectory, 'consumer.mjs'); + writeFileSync( + esmPath, + `import assert from 'node:assert/strict'; +const spreadsheet = await import('${packageJson.name}/spreadsheet'); +const binary = spreadsheet.preflightSpreadsheetBinarySource( + new Uint8Array([0x50, 0x4b, 0x03, 0x04]), +); +assert.equal(binary.format, 'xlsx'); +const result = spreadsheet.spreadsheetWorkbookToDocumentJson({ + worksheets: [{ name: 'Sheet 1', hidden: false, rows: [['Alpha', 'Beta']] }], +}); +assert.equal(result.worksheetCount, 1); +assert.equal(result.rowCount, 1); +assert.equal(result.cellCount, 2); +assert.equal(result.content[0].type, 'heading'); +assert.equal(result.content[1].type, 'table'); +`, + 'utf8', + ); + + const cjsPath = join(consumerDirectory, 'consumer.cjs'); + writeFileSync( + cjsPath, + `const assert = require('node:assert/strict'); +const spreadsheet = require('${packageJson.name}/spreadsheet'); +assert.equal(typeof spreadsheet.preflightSpreadsheetBinarySource, 'function'); +assert.equal(typeof spreadsheet.spreadsheetWorkbookToDocumentJson, 'function'); +assert.throws( + () => spreadsheet.preflightSpreadsheetBinarySource(new Uint8Array([1, 2, 3, 4])), + (error) => error && error.code === 'UNSUPPORTED_OR_CORRUPT', +); +`, + 'utf8', + ); + + run(process.execPath, [esmPath], consumerDirectory); + run(process.execPath, [cjsPath], consumerDirectory); +} + +try { + preparePackage(); + verifyAuthorityFreeBundles(); + verifyRuntimeConsumers(); + console.log( + `Verified packed ${packageJson.name}/spreadsheet through authority-bounded ESM and CommonJS consumers.`, + ); +} finally { + rmSync(verificationRoot, { recursive: true, force: true }); +} diff --git a/src/spreadsheet/index.ts b/src/spreadsheet/index.ts new file mode 100644 index 00000000..f53890bb --- /dev/null +++ b/src/spreadsheet/index.ts @@ -0,0 +1,2 @@ +/** Public package entry for deterministic, local spreadsheet conversion primitives. */ +export * from './spreadsheetImport.js'; diff --git a/vite.spreadsheet.config.ts b/vite.spreadsheet.config.ts new file mode 100644 index 00000000..10ac7259 --- /dev/null +++ b/vite.spreadsheet.config.ts @@ -0,0 +1,28 @@ +import { resolve } from 'node:path'; +import { defineConfig } from 'vite'; +import dts from 'vite-plugin-dts'; + +// Framework-neutral spreadsheet conversion build. The implementation imports +// TipTap only as a TypeScript type, so the emitted runtime bundle carries no +// React, TipTap, network, credential, persistence, or model authority. +export default defineConfig({ + plugins: [ + dts({ + include: ['src/spreadsheet'], + exclude: ['src/**/*.test.ts', 'src/**/*.test.tsx', 'src/**/*.spec.ts'], + rollupTypes: false, + entryRoot: 'src', + }), + ], + build: { + emptyOutDir: false, + lib: { + entry: resolve(__dirname, 'src/spreadsheet/index.ts'), + name: 'InkspanSpreadsheet', + fileName: (format) => + format === 'es' ? 'cwl-spreadsheet.js' : 'cwl-spreadsheet.cjs', + formats: ['es', 'cjs'], + }, + sourcemap: true, + }, +}); From 6e821d33fad41a6904f5d1ff49ca926c30e9403e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 04:16:13 +0900 Subject: [PATCH 043/163] docs(package): discover spreadsheet subpath --- docs/package-distribution.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/package-distribution.md b/docs/package-distribution.md index ddb4df0e..2103593f 100644 --- a/docs/package-distribution.md +++ b/docs/package-distribution.md @@ -18,6 +18,7 @@ integrations. | `@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 | | `@contextualwisdomlab/cwl-editor/markdown` | `implemented_on_active_pr` — headless deterministic Markdown/HTML/email/plain-text conversion with the same safe-link and strict inline-raster policies as the editor, without importing the React/TipTap editor graph | +| `@contextualwisdomlab/cwl-editor/spreadsheet` | `implemented_on_active_pr` — framework-neutral bounded XLS/XLSX envelope preflight and parser-neutral worksheet-to-editor JSON conversion; real workbook parsing and editor insertion remain incomplete and unshipped | | `@contextualwisdomlab/cwl-editor/styles.css` | Editor layout and theming | | `@contextualwisdomlab/cwl-editor/fonts.css` | Full offline KR/EN/JP/SC/TC/VI font bundle | | `@contextualwisdomlab/cwl-editor/fonts-latin.css` | Smaller Latin/Vietnamese font bundle | @@ -58,7 +59,7 @@ embedded in the npm tarball. 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, - revision-evidence, text-position-selector, and Markdown entrypoints do not + revision-evidence, text-position-selector, Markdown, and spreadsheet 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 @@ -109,7 +110,7 @@ production library build. The verification chain: 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 + revision-evidence, text-position-selector, Markdown, and spreadsheet 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 From 6adf43d29a873bb176a81fb152a46a211faf9f1c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 04:19:10 +0900 Subject: [PATCH 044/163] ci(spreadsheet): verify official SheetJS parser provenance --- .github/workflows/agent-workspace.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/agent-workspace.yml b/.github/workflows/agent-workspace.yml index 8ef8e05e..6fbe1667 100644 --- a/.github/workflows/agent-workspace.yml +++ b/.github/workflows/agent-workspace.yml @@ -33,11 +33,12 @@ jobs: cache: pnpm - name: Install the immutable workspace run: pnpm install --frozen-lockfile - - name: Stage the exact spreadsheet parser without lifecycle scripts + - name: Stage the exact official spreadsheet parser without lifecycle scripts run: | set -euo pipefail - pnpm add --save-exact --ignore-scripts @lokalise/xlsx@0.20.3 - grep -F 'sha512-9+Wn7Hq2fHoaWJqhWXZXhUF6wNLk6Y5SL/QLLFuv6ChWWYi0lND7EwKeR6Hg8dXgyIc7Pkc0CaDXM+5z2zzi6Q==' pnpm-lock.yaml + pnpm add --save-exact --ignore-scripts https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz + node --input-type=module -e "const XLSX=await import('xlsx'); if (XLSX.version !== '0.20.3') throw new Error('Unexpected SheetJS runtime version: '+XLSX.version);" + grep -F 'cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz' pnpm-lock.yaml - name: Archive the exact source and installed dependency graph run: | set -euo pipefail From 6e767611315e7427f9c7b1c5018cc81aab200413 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 12:47:17 +0900 Subject: [PATCH 045/163] test(reliability): prove toolbar leaks hostile image failures --- src/components/ToolbarImageLifecycle.test.tsx | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/src/components/ToolbarImageLifecycle.test.tsx b/src/components/ToolbarImageLifecycle.test.tsx index ba26926e..66672af9 100644 --- a/src/components/ToolbarImageLifecycle.test.tsx +++ b/src/components/ToolbarImageLifecycle.test.tsx @@ -84,4 +84,53 @@ describe('Toolbar asynchronous image-upload lifecycle boundary', () => { expect(prompt).not.toHaveBeenCalled(); expect(editor.isDestroyed).toBe(true); }); + + it('does not expose hostile conversion throw values to the host error callback', async () => { + const editor = makeEditor(); + const before = editor.getHTML(); + const privateSentinel = new Error('private toolbar conversion sentinel'); + const getPrototypeOf = vi.fn(() => { + throw privateSentinel; + }); + const hostileThrownValue = new Proxy({}, { getPrototypeOf }); + const hostileValues = new WeakSet([hostileThrownValue]); + const file = new File([PNG_BYTES], 'hostile.png', { type: 'image/png' }); + Object.defineProperty(file, 'arrayBuffer', { + configurable: true, + value: vi.fn().mockRejectedValue(hostileThrownValue), + }); + + let leakedHostileValue = false; + let observedError: unknown; + const onImageError = vi.fn((error: unknown) => { + observedError = error; + if ( + typeof error === 'object' && + error !== null && + hostileValues.has(error) + ) { + leakedHostileValue = true; + } + }); + const prompt = vi.spyOn(window, 'prompt').mockReturnValue('should not run'); + render( + , + ); + + fireEvent.change(fileInput(), { target: { files: [file] } }); + await settleConversion(); + + expect(onImageError).toHaveBeenCalledOnce(); + expect(leakedHostileValue).toBe(false); + expect(getPrototypeOf).not.toHaveBeenCalled(); + expect(observedError).toBeInstanceOf(Error); + expect((observedError as Error).message).toBe('Image processing failed.'); + expect(prompt).not.toHaveBeenCalled(); + expect(editor.getHTML()).toBe(before); + expect(editor.getHTML()).not.toContain('data:image'); + }); }); From 84cb2b1e5fb94b587bf29dd9024c5288f54c7636 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 12:53:16 +0900 Subject: [PATCH 046/163] fix(reliability): redact hostile toolbar image failures --- src/components/Toolbar.tsx | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index 32f144e0..f852d307 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -8,6 +8,7 @@ import { type FocusEvent, type KeyboardEvent, } from 'react'; +import { Base64SizeError } from '../converter/base64.js'; import { imageFileToInlineDataUri } from '../extensions/Base64Image.js'; import type { ImageConfig } from '../types.js'; @@ -30,6 +31,15 @@ interface ButtonProps { const TOOLBAR_ITEM_SELECTOR = 'button[data-cwl-toolbar-item="true"]'; +/** Read a genuine Blob's byte length without invoking caller-owned accessors. */ +function intrinsicBlobSize(blob: Blob): number { + const sizeGetter = Object.getOwnPropertyDescriptor( + globalThis.Blob.prototype, + 'size', + )!.get!; + return Reflect.apply(sizeGetter, blob, []) as number; +} + /** Return every toolbar button in visual and DOM navigation order. */ function getToolbarButtons(toolbar: HTMLDivElement): HTMLButtonElement[] { return Array.from( @@ -203,15 +213,22 @@ export function Toolbar({ editor, image, onImageError }: ToolbarProps) { event.target.value = ''; if (!file) return; + const maxSizeBytes = image?.maxSizeBytes ?? 10 * 1024 * 1024; + const sourceBytes = intrinsicBlobSize(file); + if (maxSizeBytes > 0 && sourceBytes > maxSizeBytes) { + onImageError?.(new Base64SizeError(sourceBytes, maxSizeBytes)); + return; + } + let src: string; try { src = await imageFileToInlineDataUri(file, { - maxSizeBytes: image?.maxSizeBytes ?? 10 * 1024 * 1024, + maxSizeBytes, maxDimension: image?.maxDimension ?? 1600, quality: image?.quality ?? 0.85, }); - } catch (err) { - onImageError?.(err); + } catch { + onImageError?.(new Error('Image processing failed.')); return; } From 611770208b50aa790740c62d593f91ad79319408 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 16:02:44 +0900 Subject: [PATCH 047/163] fix(ci): document spreadsheet package export --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index f2b02332..7d4dd218 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,7 @@ runtime. | Text-position selector | `@contextualwisdomlab/cwl-editor/text-position-selector` | React-free deterministic W3C `TextPositionSelector` projection core | | Autosave | `@contextualwisdomlab/cwl-editor/autosave` | Provider-neutral bounded single-flight persistence coordination | | Headless Markdown | `@contextualwisdomlab/cwl-editor/markdown` | React-free deterministic Markdown/HTML/email/plain-text conversion | +| Spreadsheet conversion | `@contextualwisdomlab/cwl-editor/spreadsheet` | Framework-neutral bounded workbook-to-document conversion primitives | | Styles | `@contextualwisdomlab/cwl-editor/styles.css` | Editor layout and theming | | Full fonts | `@contextualwisdomlab/cwl-editor/fonts.css` | KR/EN/JP/SC/TC/VI offline font bundle | | Latin fonts | `@contextualwisdomlab/cwl-editor/fonts-latin.css` | Smaller Latin/Vietnamese-only bundle | From 920a4d38ea267a762102e1fa1d2ba2230eae232a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 16:04:25 +0900 Subject: [PATCH 048/163] test(spreadsheet): reject accessor-backed workbook metadata --- ...spreadsheetImportMetadataPreflight.test.ts | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 src/spreadsheet/spreadsheetImportMetadataPreflight.test.ts diff --git a/src/spreadsheet/spreadsheetImportMetadataPreflight.test.ts b/src/spreadsheet/spreadsheetImportMetadataPreflight.test.ts new file mode 100644 index 00000000..28ce1616 --- /dev/null +++ b/src/spreadsheet/spreadsheetImportMetadataPreflight.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest'; +import { + spreadsheetWorkbookToDocumentJson, + SpreadsheetImportError, +} from './spreadsheetImport.js'; + +const UNSUPPORTED_SOURCE_MESSAGE = + 'Spreadsheet source is unsupported or corrupt.'; + +function expectUnsupportedSource(action: () => unknown): void { + let thrown: unknown; + try { + action(); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(SpreadsheetImportError); + expect(thrown).toMatchObject({ + code: 'UNSUPPORTED_OR_CORRUPT', + message: UNSUPPORTED_SOURCE_MESSAGE, + }); +} + +describe('spreadsheet workbook metadata preflight', () => { + it('rejects an accessor-backed worksheets field without invoking it', () => { + let accessed = false; + const workbook = Object.create(null) as Record; + Object.defineProperty(workbook, 'worksheets', { + enumerable: true, + get() { + accessed = true; + throw new Error('private-workbook-worksheets-sentinel'); + }, + }); + + expectUnsupportedSource(() => + spreadsheetWorkbookToDocumentJson(workbook as never), + ); + expect(accessed).toBe(false); + }); + + it.each(['name', 'hidden', 'rows'] as const)( + 'rejects an accessor-backed worksheet %s field without invoking it', + (field) => { + let accessed = false; + const worksheet = Object.create(null) as Record; + const values = { + name: 'Data', + hidden: false, + rows: [['kept']], + } as const; + + for (const key of ['name', 'hidden', 'rows'] as const) { + if (key === field) { + Object.defineProperty(worksheet, key, { + configurable: true, + enumerable: true, + get() { + accessed = true; + throw new Error(`private-${key}-sentinel`); + }, + }); + } else { + Object.defineProperty(worksheet, key, { + configurable: true, + enumerable: true, + value: values[key], + }); + } + } + + expectUnsupportedSource(() => + spreadsheetWorkbookToDocumentJson({ + worksheets: [worksheet as never], + }), + ); + expect(accessed).toBe(false); + }, + ); +}); From ecfc1fce656b71792575053aa600dfecc8ce18e4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 16:06:56 +0900 Subject: [PATCH 049/163] test(spreadsheet): redact metadata reflection failures --- .../spreadsheetImportMetadataPreflight.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/spreadsheet/spreadsheetImportMetadataPreflight.test.ts b/src/spreadsheet/spreadsheetImportMetadataPreflight.test.ts index 28ce1616..1879d2f7 100644 --- a/src/spreadsheet/spreadsheetImportMetadataPreflight.test.ts +++ b/src/spreadsheet/spreadsheetImportMetadataPreflight.test.ts @@ -40,6 +40,18 @@ describe('spreadsheet workbook metadata preflight', () => { expect(accessed).toBe(false); }); + it('redacts a hostile metadata reflection failure', () => { + const workbook = new Proxy(Object.create(null) as object, { + getOwnPropertyDescriptor() { + throw new Error('private-metadata-reflection-sentinel'); + }, + }); + + expectUnsupportedSource(() => + spreadsheetWorkbookToDocumentJson(workbook as never), + ); + }); + it.each(['name', 'hidden', 'rows'] as const)( 'rejects an accessor-backed worksheet %s field without invoking it', (field) => { From 42d2ae9f9db22bc2f2ddd5e75bed7752a42ae892 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 16:08:02 +0900 Subject: [PATCH 050/163] test(spreadsheet): cover missing metadata properties --- src/spreadsheet/spreadsheetImportMetadataPreflight.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/spreadsheet/spreadsheetImportMetadataPreflight.test.ts b/src/spreadsheet/spreadsheetImportMetadataPreflight.test.ts index 1879d2f7..ad287964 100644 --- a/src/spreadsheet/spreadsheetImportMetadataPreflight.test.ts +++ b/src/spreadsheet/spreadsheetImportMetadataPreflight.test.ts @@ -23,6 +23,12 @@ function expectUnsupportedSource(action: () => unknown): void { } describe('spreadsheet workbook metadata preflight', () => { + it('rejects a missing worksheets data property', () => { + expectUnsupportedSource(() => + spreadsheetWorkbookToDocumentJson(Object.create(null) as never), + ); + }); + it('rejects an accessor-backed worksheets field without invoking it', () => { let accessed = false; const workbook = Object.create(null) as Record; From db0c6376a4f823bcb040abf22eeb6e70e51aace2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 16:08:51 +0900 Subject: [PATCH 051/163] fix(spreadsheet): preflight workbook metadata descriptors --- src/spreadsheet/spreadsheetImport.ts | 46 ++++++++++++++++++++++------ 1 file changed, 36 insertions(+), 10 deletions(-) diff --git a/src/spreadsheet/spreadsheetImport.ts b/src/spreadsheet/spreadsheetImport.ts index b8b75c90..638a503a 100644 --- a/src/spreadsheet/spreadsheetImport.ts +++ b/src/spreadsheet/spreadsheetImport.ts @@ -8,8 +8,10 @@ const MAX_WORKSHEET_COLUMNS = 256; const MAX_WORKBOOK_CELLS = 262_144; const MAX_CELL_TEXT_CODE_UNITS = 32_768; const MAX_WORKBOOK_TEXT_CODE_UNITS = 8_388_608; -const RESOURCE_LIMIT_MESSAGE = 'Spreadsheet exceeds the configured resource limits.'; -const UNSUPPORTED_SOURCE_MESSAGE = 'Spreadsheet source is unsupported or corrupt.'; +const RESOURCE_LIMIT_MESSAGE = + 'Spreadsheet exceeds the configured resource limits.'; +const UNSUPPORTED_SOURCE_MESSAGE = + 'Spreadsheet source is unsupported or corrupt.'; const XLSX_ZIP_SIGNATURE = [0x50, 0x4b, 0x03, 0x04] as const; const XLS_COMPOUND_FILE_SIGNATURE = [ 0xd0, @@ -21,7 +23,9 @@ const XLS_COMPOUND_FILE_SIGNATURE = [ 0x1a, 0xe1, ] as const; -const TYPED_ARRAY_PROTOTYPE = Object.getPrototypeOf(Uint8Array.prototype) as object; +const TYPED_ARRAY_PROTOTYPE = Object.getPrototypeOf( + Uint8Array.prototype, +) as object; const TYPED_ARRAY_TAG_GETTER = Object.getOwnPropertyDescriptor( TYPED_ARRAY_PROTOTYPE, Symbol.toStringTag, @@ -102,6 +106,19 @@ function unsupportedOrCorruptSource(): never { ); } +function readOwnDataProperty(source: object, key: string): unknown { + let descriptor: PropertyDescriptor | undefined; + try { + descriptor = Object.getOwnPropertyDescriptor(source, key); + } catch { + unsupportedOrCorruptSource(); + } + + if (descriptor === undefined) unsupportedOrCorruptSource(); + if (!('value' in descriptor)) unsupportedOrCorruptSource(); + return descriptor.value; +} + function isUint8ArraySource(source: unknown): source is Uint8Array { return ( ArrayBuffer.isView(source) && @@ -180,7 +197,9 @@ export function spreadsheetWorkbookToDocumentJson( if (typeof workbook !== 'object' || workbook === null) { unsupportedOrCorruptSource(); } - if (!Array.isArray(workbook.worksheets)) unsupportedOrCorruptSource(); + + const worksheets = readOwnDataProperty(workbook, 'worksheets'); + if (!Array.isArray(worksheets)) unsupportedOrCorruptSource(); const preparedWorksheets: PreparedWorksheet[] = []; let worksheetCount = 0; @@ -189,16 +208,23 @@ export function spreadsheetWorkbookToDocumentJson( let textCodeUnits = 0; // Preflight the complete workbook before allocating proportional TipTap nodes. - for (const worksheet of workbook.worksheets) { + for (const worksheetSource of worksheets) { + if (typeof worksheetSource !== 'object' || worksheetSource === null) { + unsupportedOrCorruptSource(); + } + + const hidden = readOwnDataProperty(worksheetSource, 'hidden'); + const name = readOwnDataProperty(worksheetSource, 'name'); + const rows = readOwnDataProperty(worksheetSource, 'rows'); if ( - typeof worksheet !== 'object' || - worksheet === null || - typeof worksheet.hidden !== 'boolean' || - typeof worksheet.name !== 'string' || - !Array.isArray(worksheet.rows) + typeof hidden !== 'boolean' || + typeof name !== 'string' || + !Array.isArray(rows) ) { unsupportedOrCorruptSource(); } + + const worksheet: SpreadsheetWorksheetData = { hidden, name, rows }; if (worksheet.hidden) continue; if (worksheet.name.length > MAX_WORKSHEET_NAME_CODE_UNITS) { resourceLimitExceeded(); From a22485ca05dded08178545dda549151272e4a166 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 16:13:03 +0900 Subject: [PATCH 052/163] test(spreadsheet): exercise public package barrel --- src/spreadsheet/spreadsheetPublicSurface.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/spreadsheet/spreadsheetPublicSurface.test.ts b/src/spreadsheet/spreadsheetPublicSurface.test.ts index e75ca604..3d194fde 100644 --- a/src/spreadsheet/spreadsheetPublicSurface.test.ts +++ b/src/spreadsheet/spreadsheetPublicSurface.test.ts @@ -1,8 +1,24 @@ import { existsSync, readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { describe, expect, it } from 'vitest'; +import * as spreadsheet from './index.js'; +import { + preflightSpreadsheetBinarySource, + spreadsheetWorkbookToDocumentJson, + SpreadsheetImportError, +} from './spreadsheetImport.js'; describe('spreadsheet package subpath contract', () => { + it('re-exports the framework-neutral spreadsheet runtime through the public source barrel', () => { + expect(spreadsheet.preflightSpreadsheetBinarySource).toBe( + preflightSpreadsheetBinarySource, + ); + expect(spreadsheet.spreadsheetWorkbookToDocumentJson).toBe( + spreadsheetWorkbookToDocumentJson, + ); + expect(spreadsheet.SpreadsheetImportError).toBe(SpreadsheetImportError); + }); + it('declares an independently built framework-neutral spreadsheet surface', () => { const repositoryRoot = process.cwd(); const packageJson = JSON.parse( From 98ee151ee52d5792765f71f35408dee8550e3505 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 16:16:30 +0900 Subject: [PATCH 053/163] test(toolbar): reject unsafe links before editor commands --- src/components/ToolbarLinkPolicy.test.tsx | 50 +++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 src/components/ToolbarLinkPolicy.test.tsx diff --git a/src/components/ToolbarLinkPolicy.test.tsx b/src/components/ToolbarLinkPolicy.test.tsx new file mode 100644 index 00000000..fee56eee --- /dev/null +++ b/src/components/ToolbarLinkPolicy.test.tsx @@ -0,0 +1,50 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { Editor } from '@tiptap/react'; +import StarterKit from '@tiptap/starter-kit'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { Toolbar } from './Toolbar.js'; + +const openEditors: Editor[] = []; + +function makeEditor(): Editor { + const element = document.createElement('div'); + document.body.appendChild(element); + const editor = new Editor({ + element, + extensions: [StarterKit], + content: '

link target

', + }); + openEditors.push(editor); + return editor; +} + +afterEach(() => { + cleanup(); + for (const editor of openEditors.splice(0)) { + if (!editor.isDestroyed) editor.destroy(); + } + vi.restoreAllMocks(); +}); + +describe('Toolbar link policy boundary', () => { + it('rejects an executable URL before issuing an editor command', () => { + const editor = makeEditor(); + const commandChain = { + focus: vi.fn(() => commandChain), + extendMarkRange: vi.fn(() => commandChain), + setLink: vi.fn(() => commandChain), + unsetLink: vi.fn(() => commandChain), + run: vi.fn(() => true), + }; + vi.spyOn(editor, 'chain').mockReturnValue( + commandChain as unknown as ReturnType, + ); + vi.spyOn(window, 'prompt').mockReturnValue('javascript:alert(1)'); + + render(); + fireEvent.click(screen.getByRole('button', { name: /Insert\/edit link/ })); + + expect(commandChain.setLink).not.toHaveBeenCalled(); + expect(commandChain.run).not.toHaveBeenCalled(); + }); +}); From 2f157379807a42b4a99521da5fcd208a30700ffc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 16:17:07 +0900 Subject: [PATCH 054/163] test(spreadsheet): reject hostile collection access --- ...spreadsheetImportMetadataPreflight.test.ts | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/src/spreadsheet/spreadsheetImportMetadataPreflight.test.ts b/src/spreadsheet/spreadsheetImportMetadataPreflight.test.ts index ad287964..0dbd014d 100644 --- a/src/spreadsheet/spreadsheetImportMetadataPreflight.test.ts +++ b/src/spreadsheet/spreadsheetImportMetadataPreflight.test.ts @@ -58,6 +58,69 @@ describe('spreadsheet workbook metadata preflight', () => { ); }); + it('does not invoke a hostile worksheets iterator getter', () => { + let iteratorRead = false; + const worksheet = { + name: 'Data', + hidden: false, + rows: [['kept']], + }; + const worksheets = new Proxy([worksheet], { + get(target, property, receiver) { + if (property === Symbol.iterator) { + iteratorRead = true; + throw new Error('private-worksheets-iterator-sentinel'); + } + return Reflect.get(target, property, receiver); + }, + }); + + expectUnsupportedSource(() => + spreadsheetWorkbookToDocumentJson({ worksheets }), + ); + expect(iteratorRead).toBe(false); + }); + + it('does not invoke hostile worksheet-row length access', () => { + let lengthRead = false; + const rows = new Proxy([['kept']], { + get(target, property, receiver) { + if (property === 'length') { + lengthRead = true; + throw new Error('private-rows-length-sentinel'); + } + return Reflect.get(target, property, receiver); + }, + }); + + expectUnsupportedSource(() => + spreadsheetWorkbookToDocumentJson({ + worksheets: [{ name: 'Data', hidden: false, rows }], + }), + ); + expect(lengthRead).toBe(false); + }); + + it('does not invoke hostile row index access', () => { + let indexRead = false; + const row = new Proxy(['kept'], { + get(target, property, receiver) { + if (property === '0') { + indexRead = true; + throw new Error('private-row-index-sentinel'); + } + return Reflect.get(target, property, receiver); + }, + }); + + expectUnsupportedSource(() => + spreadsheetWorkbookToDocumentJson({ + worksheets: [{ name: 'Data', hidden: false, rows: [row] }], + }), + ); + expect(indexRead).toBe(false); + }); + it.each(['name', 'hidden', 'rows'] as const)( 'rejects an accessor-backed worksheet %s field without invoking it', (field) => { From ceb79a7ff505b33e5f640904dda0ff7222c13cf0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 16:20:37 +0900 Subject: [PATCH 055/163] test(spreadsheet): preserve safe proxied array data --- ...spreadsheetImportMetadataPreflight.test.ts | 35 ++++++++++++------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/src/spreadsheet/spreadsheetImportMetadataPreflight.test.ts b/src/spreadsheet/spreadsheetImportMetadataPreflight.test.ts index 0dbd014d..a416c935 100644 --- a/src/spreadsheet/spreadsheetImportMetadataPreflight.test.ts +++ b/src/spreadsheet/spreadsheetImportMetadataPreflight.test.ts @@ -75,9 +75,12 @@ describe('spreadsheet workbook metadata preflight', () => { }, }); - expectUnsupportedSource(() => - spreadsheetWorkbookToDocumentJson({ worksheets }), - ); + const result = spreadsheetWorkbookToDocumentJson({ worksheets }); + expect(result).toMatchObject({ + worksheetCount: 1, + rowCount: 1, + cellCount: 1, + }); expect(iteratorRead).toBe(false); }); @@ -93,11 +96,14 @@ describe('spreadsheet workbook metadata preflight', () => { }, }); - expectUnsupportedSource(() => - spreadsheetWorkbookToDocumentJson({ - worksheets: [{ name: 'Data', hidden: false, rows }], - }), - ); + const result = spreadsheetWorkbookToDocumentJson({ + worksheets: [{ name: 'Data', hidden: false, rows }], + }); + expect(result).toMatchObject({ + worksheetCount: 1, + rowCount: 1, + cellCount: 1, + }); expect(lengthRead).toBe(false); }); @@ -113,11 +119,14 @@ describe('spreadsheet workbook metadata preflight', () => { }, }); - expectUnsupportedSource(() => - spreadsheetWorkbookToDocumentJson({ - worksheets: [{ name: 'Data', hidden: false, rows: [row] }], - }), - ); + const result = spreadsheetWorkbookToDocumentJson({ + worksheets: [{ name: 'Data', hidden: false, rows: [row] }], + }); + expect(result).toMatchObject({ + worksheetCount: 1, + rowCount: 1, + cellCount: 1, + }); expect(indexRead).toBe(false); }); From cd9b2674c2af272a6702e02c3cacda627e94eb93 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 16:21:26 +0900 Subject: [PATCH 056/163] fix(spreadsheet): snapshot collection data without getters --- src/spreadsheet/spreadsheetImport.ts | 57 +++++++++++++++++++--------- 1 file changed, 40 insertions(+), 17 deletions(-) diff --git a/src/spreadsheet/spreadsheetImport.ts b/src/spreadsheet/spreadsheetImport.ts index 638a503a..f9e20b1b 100644 --- a/src/spreadsheet/spreadsheetImport.ts +++ b/src/spreadsheet/spreadsheetImport.ts @@ -119,6 +119,10 @@ function readOwnDataProperty(source: object, key: string): unknown { return descriptor.value; } +function readArrayLength(source: readonly unknown[]): number { + return readOwnDataProperty(source, 'length') as number; +} + function isUint8ArraySource(source: unknown): source is Uint8Array { return ( ArrayBuffer.isView(source) && @@ -186,7 +190,8 @@ function paragraphWithText(text: string): JSONContent { } interface PreparedWorksheet { - readonly worksheet: SpreadsheetWorksheetData; + readonly name: string; + readonly rows: readonly (readonly string[])[]; readonly columnCount: number; } @@ -200,6 +205,7 @@ export function spreadsheetWorkbookToDocumentJson( const worksheets = readOwnDataProperty(workbook, 'worksheets'); if (!Array.isArray(worksheets)) unsupportedOrCorruptSource(); + const worksheetsLength = readArrayLength(worksheets); const preparedWorksheets: PreparedWorksheet[] = []; let worksheetCount = 0; @@ -208,7 +214,11 @@ export function spreadsheetWorkbookToDocumentJson( let textCodeUnits = 0; // Preflight the complete workbook before allocating proportional TipTap nodes. - for (const worksheetSource of worksheets) { + for (let worksheetIndex = 0; worksheetIndex < worksheetsLength; worksheetIndex += 1) { + const worksheetSource = readOwnDataProperty( + worksheets, + String(worksheetIndex), + ); if (typeof worksheetSource !== 'object' || worksheetSource === null) { unsupportedOrCorruptSource(); } @@ -224,41 +234,54 @@ export function spreadsheetWorkbookToDocumentJson( unsupportedOrCorruptSource(); } - const worksheet: SpreadsheetWorksheetData = { hidden, name, rows }; - if (worksheet.hidden) continue; - if (worksheet.name.length > MAX_WORKSHEET_NAME_CODE_UNITS) { + if (hidden) continue; + if (name.length > MAX_WORKSHEET_NAME_CODE_UNITS) { resourceLimitExceeded(); } - const nextRowCount = rowCount + worksheet.rows.length; + const rowsLength = readArrayLength(rows); + const nextRowCount = rowCount + rowsLength; if (nextRowCount > MAX_WORKBOOK_ROWS) resourceLimitExceeded(); + const rowSources: (readonly unknown[])[] = []; + const rowLengths: number[] = []; let columnCount = 0; - for (const row of worksheet.rows) { - if (!Array.isArray(row)) unsupportedOrCorruptSource(); - columnCount = Math.max(columnCount, row.length); + for (let rowIndex = 0; rowIndex < rowsLength; rowIndex += 1) { + const rowSource = readOwnDataProperty(rows, String(rowIndex)); + if (!Array.isArray(rowSource)) unsupportedOrCorruptSource(); + const rowLength = readArrayLength(rowSource); + columnCount = Math.max(columnCount, rowLength); + rowSources.push(rowSource); + rowLengths.push(rowLength); } if (columnCount === 0) continue; if (worksheetCount >= MAX_VISIBLE_WORKSHEETS) resourceLimitExceeded(); if (columnCount > MAX_WORKSHEET_COLUMNS) resourceLimitExceeded(); - const worksheetCellCount = worksheet.rows.length * columnCount; + const worksheetCellCount = rowsLength * columnCount; const nextCellCount = cellCount + worksheetCellCount; if (nextCellCount > MAX_WORKBOOK_CELLS) resourceLimitExceeded(); - let worksheetTextCodeUnits = worksheet.name.length; - for (const row of worksheet.rows) { - for (const cellText of row) { + let worksheetTextCodeUnits = name.length; + const preparedRows: string[][] = []; + for (let rowIndex = 0; rowIndex < rowSources.length; rowIndex += 1) { + const rowSource = rowSources[rowIndex]!; + const rowLength = rowLengths[rowIndex]!; + const preparedRow: string[] = []; + for (let columnIndex = 0; columnIndex < rowLength; columnIndex += 1) { + const cellText = readOwnDataProperty(rowSource, String(columnIndex)); if (typeof cellText !== 'string') unsupportedOrCorruptSource(); if (cellText.length > MAX_CELL_TEXT_CODE_UNITS) resourceLimitExceeded(); worksheetTextCodeUnits += cellText.length; if (textCodeUnits + worksheetTextCodeUnits > MAX_WORKBOOK_TEXT_CODE_UNITS) { resourceLimitExceeded(); } + preparedRow.push(cellText); } + preparedRows.push(preparedRow); } - preparedWorksheets.push({ worksheet, columnCount }); + preparedWorksheets.push({ name, rows: preparedRows, columnCount }); worksheetCount += 1; rowCount = nextRowCount; cellCount = nextCellCount; @@ -266,15 +289,15 @@ export function spreadsheetWorkbookToDocumentJson( } const content: JSONContent[] = []; - for (const { worksheet, columnCount } of preparedWorksheets) { + for (const { name, rows, columnCount } of preparedWorksheets) { content.push({ type: 'heading', attrs: { level: 3 }, - content: [{ type: 'text', text: worksheet.name }], + content: [{ type: 'text', text: name }], }); content.push({ type: 'table', - content: worksheet.rows.map((row) => ({ + content: rows.map((row) => ({ type: 'tableRow', content: Array.from({ length: columnCount }, (_, columnIndex) => ({ type: 'tableCell', From 0693ba351c2b325e59e45a41e36c861800b1f732 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 16:22:41 +0900 Subject: [PATCH 057/163] fix(toolbar): enforce safe-link policy before commands --- src/components/Toolbar.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index f852d307..f65c8e66 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -10,6 +10,7 @@ import { } from 'react'; import { Base64SizeError } from '../converter/base64.js'; import { imageFileToInlineDataUri } from '../extensions/Base64Image.js'; +import { isSafeLinkHref } from '../extensions/SafeLink.js'; import type { ImageConfig } from '../types.js'; interface ToolbarProps { @@ -183,6 +184,7 @@ export function Toolbar({ editor, image, onImageError }: ToolbarProps) { editor.chain().focus().extendMarkRange('link').unsetLink().run(); return; } + if (!isSafeLinkHref(url)) return; editor .chain() .focus() From 041e726b010e96cc8b9b4946de52012d719c3141 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 22:21:57 +0900 Subject: [PATCH 058/163] test(reliability): contain toolbar image observer failures --- src/components/ToolbarImageLifecycle.test.tsx | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/components/ToolbarImageLifecycle.test.tsx b/src/components/ToolbarImageLifecycle.test.tsx index 66672af9..8f43f24c 100644 --- a/src/components/ToolbarImageLifecycle.test.tsx +++ b/src/components/ToolbarImageLifecycle.test.tsx @@ -133,4 +133,34 @@ describe('Toolbar asynchronous image-upload lifecycle boundary', () => { expect(editor.getHTML()).toBe(before); expect(editor.getHTML()).not.toContain('data:image'); }); + + it('contains host image-error observer failures after conversion rejection', async () => { + const editor = makeEditor(); + const before = editor.getHTML(); + const privateSentinel = new Error('private toolbar observer sentinel'); + const failedFile = new File([PNG_BYTES], 'failed.png', { type: 'image/png' }); + Object.defineProperty(failedFile, 'arrayBuffer', { + configurable: true, + value: vi.fn().mockRejectedValue(new Error('private conversion failure')), + }); + const onImageError = vi.fn(() => { + throw privateSentinel; + }); + const prompt = vi.spyOn(window, 'prompt').mockReturnValue('should not run'); + + render( + , + ); + + fireEvent.change(fileInput(), { target: { files: [failedFile] } }); + await settleConversion(); + + expect(onImageError).toHaveBeenCalledOnce(); + expect(prompt).not.toHaveBeenCalled(); + expect(editor.getHTML()).toBe(before); + }); }); From d448a9dd8296fca5f905a7371d22abf75f647f75 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 22:25:28 +0900 Subject: [PATCH 059/163] fix(reliability): contain toolbar image observer failures --- src/components/Toolbar.tsx | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index f65c8e66..3f2d381c 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -41,6 +41,18 @@ function intrinsicBlobSize(blob: Blob): number { return Reflect.apply(sizeGetter, blob, []) as number; } +/** Report an image failure without allowing host observer code to alter toolbar control flow. */ +function reportImageError( + onImageError: ((error: unknown) => void) | undefined, + error: unknown, +): void { + try { + onImageError?.(error); + } catch { + // Host presentation or telemetry observers are best-effort only. + } +} + /** Return every toolbar button in visual and DOM navigation order. */ function getToolbarButtons(toolbar: HTMLDivElement): HTMLButtonElement[] { return Array.from( @@ -218,7 +230,10 @@ export function Toolbar({ editor, image, onImageError }: ToolbarProps) { const maxSizeBytes = image?.maxSizeBytes ?? 10 * 1024 * 1024; const sourceBytes = intrinsicBlobSize(file); if (maxSizeBytes > 0 && sourceBytes > maxSizeBytes) { - onImageError?.(new Base64SizeError(sourceBytes, maxSizeBytes)); + reportImageError( + onImageError, + new Base64SizeError(sourceBytes, maxSizeBytes), + ); return; } @@ -230,7 +245,7 @@ export function Toolbar({ editor, image, onImageError }: ToolbarProps) { quality: image?.quality ?? 0.85, }); } catch { - onImageError?.(new Error('Image processing failed.')); + reportImageError(onImageError, new Error('Image processing failed.')); return; } From 519a04f7b39236e2f428286c2b905f85cc967b61 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 04:40:47 +0900 Subject: [PATCH 060/163] test(spreadsheet): bound workbook worksheet descriptors --- .../spreadsheetWorksheetCountLimit.test.ts | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 src/spreadsheet/spreadsheetWorksheetCountLimit.test.ts diff --git a/src/spreadsheet/spreadsheetWorksheetCountLimit.test.ts b/src/spreadsheet/spreadsheetWorksheetCountLimit.test.ts new file mode 100644 index 00000000..27592ab6 --- /dev/null +++ b/src/spreadsheet/spreadsheetWorksheetCountLimit.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; + +import { + SpreadsheetImportError, + spreadsheetWorkbookToDocumentJson, +} from './spreadsheetImport'; + +const MAX_BOUNDED_WORKSHEET_DESCRIPTORS = 256; + +function hiddenWorksheet(name: string) { + return { hidden: true, name, rows: [] as string[][] }; +} + +describe('spreadsheet workbook worksheet-count resource boundary', () => { + it('accepts the maximum bounded hidden-sheet descriptor count', () => { + const worksheets = Array.from( + { length: MAX_BOUNDED_WORKSHEET_DESCRIPTORS }, + (_, index) => hiddenWorksheet(`hidden-${index}`), + ); + + expect(spreadsheetWorkbookToDocumentJson({ worksheets })).toEqual({ + content: [], + worksheetCount: 0, + rowCount: 0, + cellCount: 0, + }); + }); + + it('rejects an oversized worksheet descriptor set before inspecting worksheet members', () => { + const worksheets = Array.from( + { length: MAX_BOUNDED_WORKSHEET_DESCRIPTORS + 1 }, + (_, index) => hiddenWorksheet(`hidden-${index}`), + ); + Object.defineProperty(worksheets[0]!, 'hidden', { + configurable: true, + get() { + throw new Error('private worksheet getter should not execute'); + }, + }); + + expect(() => spreadsheetWorkbookToDocumentJson({ worksheets })).toThrowError( + expect.objectContaining({ + name: 'SpreadsheetImportError', + code: 'RESOURCE_LIMIT_EXCEEDED', + message: 'Spreadsheet exceeds the configured resource limits.', + }) as SpreadsheetImportError, + ); + }); +}); From ad0b338730bd7dfc634a8d59c3355789146a83ed Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 04:44:00 +0900 Subject: [PATCH 061/163] fix(spreadsheet): bound total worksheet descriptors --- src/spreadsheet/spreadsheetImport.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/spreadsheet/spreadsheetImport.ts b/src/spreadsheet/spreadsheetImport.ts index f9e20b1b..fd7c66fb 100644 --- a/src/spreadsheet/spreadsheetImport.ts +++ b/src/spreadsheet/spreadsheetImport.ts @@ -2,6 +2,7 @@ import type { JSONContent } from '@tiptap/core'; const MAX_SPREADSHEET_SOURCE_BYTES = 64 * 1024 * 1024; const MAX_VISIBLE_WORKSHEETS = 64; +const MAX_WORKBOOK_WORKSHEETS = 256; const MAX_WORKSHEET_NAME_CODE_UNITS = 1_024; const MAX_WORKBOOK_ROWS = 10_000; const MAX_WORKSHEET_COLUMNS = 256; @@ -206,6 +207,7 @@ export function spreadsheetWorkbookToDocumentJson( const worksheets = readOwnDataProperty(workbook, 'worksheets'); if (!Array.isArray(worksheets)) unsupportedOrCorruptSource(); const worksheetsLength = readArrayLength(worksheets); + if (worksheetsLength > MAX_WORKBOOK_WORKSHEETS) resourceLimitExceeded(); const preparedWorksheets: PreparedWorksheet[] = []; let worksheetCount = 0; From c96fbe167976c869012a06dd95f5724971192afa Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 04:48:26 +0900 Subject: [PATCH 062/163] test(spreadsheet): normalize revoked array containers --- ...spreadsheetImportRuntimeContainers.test.ts | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/src/spreadsheet/spreadsheetImportRuntimeContainers.test.ts b/src/spreadsheet/spreadsheetImportRuntimeContainers.test.ts index 1104fb49..0790fe6c 100644 --- a/src/spreadsheet/spreadsheetImportRuntimeContainers.test.ts +++ b/src/spreadsheet/spreadsheetImportRuntimeContainers.test.ts @@ -22,6 +22,12 @@ function expectUnsupportedSource(action: () => unknown): void { }); } +function revokedArrayProxy(): readonly unknown[] { + const { proxy, revoke } = Proxy.revocable([], {}); + revoke(); + return proxy; +} + describe('spreadsheetWorkbookToDocumentJson runtime containers', () => { it.each([null, undefined, 0, 'workbook']) ( 'rejects non-object workbook containers with the stable domain error', @@ -54,6 +60,14 @@ describe('spreadsheetWorkbookToDocumentJson runtime containers', () => { expect(iteratorRead).toBe(false); }); + it('normalizes a revoked worksheet-array proxy to the stable domain error', () => { + expectUnsupportedSource(() => + spreadsheetWorkbookToDocumentJson({ + worksheets: revokedArrayProxy() as unknown as readonly [], + }), + ); + }); + it.each([null, undefined])( 'rejects nullish worksheet entries with the stable domain error', (invalidWorksheet) => { @@ -93,6 +107,20 @@ describe('spreadsheetWorkbookToDocumentJson runtime containers', () => { expect(lengthRead).toBe(false); }); + it('normalizes a revoked rows-array proxy to the stable domain error', () => { + expectUnsupportedSource(() => + spreadsheetWorkbookToDocumentJson({ + worksheets: [ + { + name: 'Data', + hidden: false, + rows: revokedArrayProxy() as unknown as readonly (readonly string[])[], + }, + ], + }), + ); + }); + it('rejects a non-array row before reading its length', () => { let lengthRead = false; const hostileRow = Object.create(null) as Record; @@ -116,4 +144,18 @@ describe('spreadsheetWorkbookToDocumentJson runtime containers', () => { ); expect(lengthRead).toBe(false); }); + + it('normalizes a revoked row-array proxy to the stable domain error', () => { + expectUnsupportedSource(() => + spreadsheetWorkbookToDocumentJson({ + worksheets: [ + { + name: 'Data', + hidden: false, + rows: [revokedArrayProxy() as unknown as readonly string[]], + }, + ], + }), + ); + }); }); From bfd8394687f3fedf969e5ef91ba149b5baab425e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 04:51:37 +0900 Subject: [PATCH 063/163] fix(spreadsheet): normalize revoked array containers --- src/spreadsheet/spreadsheetImport.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/spreadsheet/spreadsheetImport.ts b/src/spreadsheet/spreadsheetImport.ts index fd7c66fb..b18ffb1a 100644 --- a/src/spreadsheet/spreadsheetImport.ts +++ b/src/spreadsheet/spreadsheetImport.ts @@ -124,6 +124,14 @@ function readArrayLength(source: readonly unknown[]): number { return readOwnDataProperty(source, 'length') as number; } +function isArraySource(source: unknown): source is readonly unknown[] { + try { + return Array.isArray(source); + } catch { + return false; + } +} + function isUint8ArraySource(source: unknown): source is Uint8Array { return ( ArrayBuffer.isView(source) && @@ -205,7 +213,7 @@ export function spreadsheetWorkbookToDocumentJson( } const worksheets = readOwnDataProperty(workbook, 'worksheets'); - if (!Array.isArray(worksheets)) unsupportedOrCorruptSource(); + if (!isArraySource(worksheets)) unsupportedOrCorruptSource(); const worksheetsLength = readArrayLength(worksheets); if (worksheetsLength > MAX_WORKBOOK_WORKSHEETS) resourceLimitExceeded(); @@ -231,7 +239,7 @@ export function spreadsheetWorkbookToDocumentJson( if ( typeof hidden !== 'boolean' || typeof name !== 'string' || - !Array.isArray(rows) + !isArraySource(rows) ) { unsupportedOrCorruptSource(); } @@ -250,7 +258,7 @@ export function spreadsheetWorkbookToDocumentJson( let columnCount = 0; for (let rowIndex = 0; rowIndex < rowsLength; rowIndex += 1) { const rowSource = readOwnDataProperty(rows, String(rowIndex)); - if (!Array.isArray(rowSource)) unsupportedOrCorruptSource(); + if (!isArraySource(rowSource)) unsupportedOrCorruptSource(); const rowLength = readArrayLength(rowSource); columnCount = Math.max(columnCount, rowLength); rowSources.push(rowSource); From 28c4671051aa38fc646c14a763a373d48d840b00 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 07:07:14 +0900 Subject: [PATCH 064/163] test(spreadsheet): establish SheetJS adapter boundary --- src/spreadsheet/sheetJsAdapter.test.ts | 83 ++++++++++++++++++++++++++ src/spreadsheet/sheetJsAdapter.ts | 42 +++++++++++++ 2 files changed, 125 insertions(+) create mode 100644 src/spreadsheet/sheetJsAdapter.test.ts create mode 100644 src/spreadsheet/sheetJsAdapter.ts diff --git a/src/spreadsheet/sheetJsAdapter.test.ts b/src/spreadsheet/sheetJsAdapter.test.ts new file mode 100644 index 00000000..b06ff7e0 --- /dev/null +++ b/src/spreadsheet/sheetJsAdapter.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + sheetJsBytesToWorkbookData, + type SheetJsParserModule, +} from './sheetJsAdapter.js'; + +describe('sheetJsBytesToWorkbookData', () => { + it('reads local workbook bytes with non-executing options and projects visible displayed text', () => { + const visibleSheet = { id: 'visible' }; + const hiddenSheet = { id: 'hidden' }; + const read = vi.fn(() => ({ + SheetNames: ['Summary', 'Private'], + Sheets: { + Summary: visibleSheet, + Private: hiddenSheet, + }, + Workbook: { + Sheets: [{ Hidden: 0 }, { Hidden: 1 }], + }, + })); + const decodeRange = vi.fn((range: string) => { + if (range === 'A1:B2') { + return { s: { r: 0, c: 0 }, e: { r: 1, c: 1 } }; + } + return { s: { r: 0, c: 0 }, e: { r: 0, c: 0 } }; + }); + const sheetToJson = vi.fn((sheet: unknown) => { + if (sheet === visibleSheet) { + return [ + ['Name', 'Value'], + ['매출', '42'], + ]; + } + return [['secret']]; + }); + Object.assign(visibleSheet, { '!ref': 'A1:B2' }); + Object.assign(hiddenSheet, { '!ref': 'A1' }); + + const parser: SheetJsParserModule = { + read, + utils: { + decode_range: decodeRange, + sheet_to_json: sheetToJson, + }, + }; + const source = new Uint8Array([0x50, 0x4b, 0x03, 0x04]); + + expect(sheetJsBytesToWorkbookData(source, parser)).toEqual({ + worksheets: [ + { + name: 'Summary', + hidden: false, + rows: [ + ['Name', 'Value'], + ['매출', '42'], + ], + }, + { + name: 'Private', + hidden: true, + rows: [], + }, + ], + }); + expect(read).toHaveBeenCalledTimes(1); + expect(read).toHaveBeenCalledWith(source, { + type: 'array', + cellFormula: false, + cellHTML: false, + cellNF: false, + bookVBA: false, + }); + expect(decodeRange).toHaveBeenCalledTimes(1); + expect(decodeRange).toHaveBeenCalledWith('A1:B2'); + expect(sheetToJson).toHaveBeenCalledTimes(1); + expect(sheetToJson).toHaveBeenCalledWith(visibleSheet, { + header: 1, + raw: false, + defval: '', + blankrows: true, + }); + }); +}); diff --git a/src/spreadsheet/sheetJsAdapter.ts b/src/spreadsheet/sheetJsAdapter.ts new file mode 100644 index 00000000..01354fdb --- /dev/null +++ b/src/spreadsheet/sheetJsAdapter.ts @@ -0,0 +1,42 @@ +import type { SpreadsheetWorkbookData } from './spreadsheetImport.js'; +import { SpreadsheetImportError } from './spreadsheetImport.js'; + +/** Minimal SheetJS runtime contract consumed by Inkspan's local adapter. */ +export interface SheetJsParserModule { + readonly read: ( + source: Uint8Array, + options: { + readonly type: 'array'; + readonly cellFormula: false; + readonly cellHTML: false; + readonly cellNF: false; + readonly bookVBA: false; + }, + ) => unknown; + readonly utils: { + readonly decode_range: (range: string) => unknown; + readonly sheet_to_json: ( + sheet: unknown, + options: { + readonly header: 1; + readonly raw: false; + readonly defval: ''; + readonly blankrows: true; + }, + ) => unknown; + }; +} + +/** + * Project locally parsed SheetJS workbook data into Inkspan's parser-neutral + * workbook contract. The implementation is intentionally test-first. + */ +export function sheetJsBytesToWorkbookData( + _source: Uint8Array, + _parser: SheetJsParserModule, +): SpreadsheetWorkbookData { + throw new SpreadsheetImportError( + 'UNSUPPORTED_OR_CORRUPT', + 'Spreadsheet source is unsupported or corrupt.', + ); +} From 7676b2ddfcea1a945f8b90aee48acb8399364175 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 07:08:26 +0900 Subject: [PATCH 065/163] docs(spreadsheet): converge parser provenance and ADR ownership --- .../plans/2026-08-13-xls-xlsx-body-import.md | 44 ++++++++++++------- 1 file changed, 29 insertions(+), 15 deletions(-) diff --git a/docs/superpowers/plans/2026-08-13-xls-xlsx-body-import.md b/docs/superpowers/plans/2026-08-13-xls-xlsx-body-import.md index da1792af..6bdf9a7a 100644 --- a/docs/superpowers/plans/2026-08-13-xls-xlsx-body-import.md +++ b/docs/superpowers/plans/2026-08-13-xls-xlsx-body-import.md @@ -1,12 +1,18 @@ # XLS/XLSX Body Import Implementation Plan -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans when available to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. If a named skill is unavailable in the current harness, preserve the same test-first/verification discipline rather than treating skill lookup failure as product completion. **Goal:** Let users insert visible worksheet content from local `.xls` and `.xlsx` files into the current Inkspan editor selection as editable headings and tables. -**Architecture:** Keep binary parsing in a framework-neutral `spreadsheet` package boundary and lazy-load one pinned SheetJS-compatible parser only after a user selects a file. Convert parser output into bounded, inert TipTap JSON before one editor transaction; the toolbar owns file selection and accessible progress text, while hosts continue to own transport, authorization, persistence, and retention. +**Architecture:** Keep binary parsing in a framework-neutral `spreadsheet` package boundary and lazy-load one pinned SheetJS parser only after a user selects a file. Convert parser output into bounded, inert TipTap JSON before one editor transaction; the toolbar owns file selection and accessible progress text, while hosts continue to own transport, authorization, persistence, and retention. -**Tech Stack:** TypeScript 5.9, TipTap/ProseMirror JSON, React 18/19, Vitest, Testing Library, Vite library builds, `@lokalise/xlsx` 0.20.3 as the integrity-pinned SheetJS 0.20.3 mirror. +**Tech Stack:** TypeScript 5.9, TipTap/ProseMirror JSON, React 18/19, Vitest, Testing Library, Vite library builds, and SheetJS `xlsx` 0.20.3 from the exact official tarball `https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz`. + +## Authority corrections + +- The earlier plan named `@lokalise/xlsx` 0.20.3, while this branch's existing read-only hosted dependency-provenance workflow installs and verifies the official SheetJS package as `xlsx` 0.20.3 from the pinned SheetJS CDN tarball. Hosted run `31968831519` on exact head `bfd8394687f3fedf969e5ef91ba149b5baab425e` resolved that package successfully and generated lock integrity `sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA==`. This plan now follows that demonstrated package identity/provenance rather than the stale mirror claim. +- ADR 0027 is already the earlier canonical claimant of PR #141 (bounded single-section DOCX page layout). Writing-diagnostics work owns 0028/0029, Hangul authoring owns 0030, and the active design-token/accessibility lane owns 0031. This spreadsheet lane therefore reserves **ADR 0032** rather than colliding with ADR 0027. +- These corrections change planning authority only. They do not promote the incomplete parser or editor insertion path to protected-main behavior. ## Global Constraints @@ -31,7 +37,7 @@ **Interfaces:** - Produces: `SpreadsheetWorkbookData`, `SpreadsheetImportResult`, `SpreadsheetImportError`, and `spreadsheetWorkbookToDocumentJson(workbook)`. -- [ ] **Step 1: Write one product-boundary failing test** +- [x] **Step 1: Write one product-boundary failing test** ```ts const result = spreadsheetWorkbookToDocumentJson({ @@ -47,7 +53,7 @@ expect(result.content.map((node) => node.type)).toEqual([ ]); ``` -- [ ] **Step 2: Commit a compiling placeholder that throws at the product boundary** +- [x] **Step 2: Commit a compiling placeholder that throws at the product boundary** ```ts export function spreadsheetWorkbookToDocumentJson( @@ -60,7 +66,7 @@ export function spreadsheetWorkbookToDocumentJson( } ``` -- [ ] **Step 3: Open a Draft PR and verify hosted RED** +- [x] **Step 3: Open a Draft PR and verify hosted RED** Run: canonical GitHub `CI` against the exact contributor head. @@ -78,23 +84,31 @@ Expected: dependency setup and TypeScript succeed; the dedicated spreadsheet tes **Interfaces:** - Produces: `DEFAULT_SPREADSHEET_IMPORT_LIMITS`, `SpreadsheetImportLimits`, `SpreadsheetImportErrorCode`, `spreadsheetFileToDocumentJson(source, limits?)`, and parser-neutral workbook conversion. -- Consumes: `@lokalise/xlsx@0.20.3` through a dynamic import in `sheetJsAdapter.ts`. +- Consumes: package `xlsx` 0.20.3 through a lazy dynamic import after local source preflight; the committed dependency must resolve from the exact official tarball and integrity recorded above. - [ ] **Step 1: Add failing tests for every public limit and error category** Cover source size before `arrayBuffer()`, visible worksheet count, decoded range rows/columns, rectangular cell count, per-cell and total text, malformed workbook structures, hidden/empty sheets, and payload-redacted failures. +The first parser-adapter product-boundary RED is being established on this branch before implementation; it must prove the parser receives non-executing options and that only visible displayed text is materialized. + - [ ] **Step 2: Add real XLSX and BIFF8 XLS round trips** Create in-memory workbooks with Unicode, multiline values, dates, booleans, formulas with cached display values, hidden sheets, and empty sheets; write both `bookType: 'xlsx'` and `bookType: 'biff8'`, then import those exact bytes. - [ ] **Step 3: Pin the parser and immutable lock** -Add exact dependency `@lokalise/xlsx: 0.20.3` and lock integrity `sha512-9+Wn7Hq2fHoaWJqhWXZXhUF6wNLk6Y5SL/QLLFuv6ChWWYi0lND7EwKeR6Hg8dXgyIc7Pkc0CaDXM+5z2zzi6Q==`. +Add exact dependency: + +```json +"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz" +``` + +Require the pnpm lock entry to preserve exact integrity `sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA==` and the same tarball URL. Install with lifecycle scripts disabled during provenance verification. Do not substitute the stale `@lokalise/xlsx` plan value or an npm-registry `xlsx` version. - [ ] **Step 4: Implement bounded parsing** -Use `Blob.size` preflight, one `arrayBuffer()` read, lazy `import('@lokalise/xlsx')`, `read(..., { type: 'array', cellFormula: false, cellHTML: false, cellNF: false, bookVBA: false })`, visible-sheet metadata, decoded-range preflight, and formatted cell text. Do not evaluate formulas or preserve executable links/macros/objects. +Use local binary preflight before lazy `import('xlsx')`, then `read(..., { type: 'array', cellFormula: false, cellHTML: false, cellNF: false, bookVBA: false })`, visible-sheet metadata, decoded-range preflight, and formatted cell text. Do not evaluate formulas or preserve executable links/macros/objects. - [ ] **Step 5: Build deterministic TipTap JSON** @@ -143,7 +157,7 @@ Keep one roving tab stop, arrow/Home/End behavior, native disabled semantics whi - Modify: `src/index.ts` - Modify: `package.json` - Modify: package verification tests/scripts as required -- Create: `docs/adr/0027-bounded-local-spreadsheet-body-import.md` +- Create: `docs/adr/0032-bounded-local-spreadsheet-body-import.md` - Modify: `docs/adr/README.md` - Modify: `README.md` - Modify: `CHANGELOG.md` @@ -157,17 +171,17 @@ Keep one roving tab stop, arrow/Home/End behavior, native disabled semantics whi **Interfaces:** - Produces: package export `@contextualwisdomlab/cwl-editor/spreadsheet` with ESM, CommonJS, and declarations, while keeping React/TipTap runtime code outside that subpath. -- [ ] **Step 1: Add package-consumer RED tests** +- [x] **Step 1: Add package-consumer RED tests** Require ESM, CommonJS, and strict NodeNext TypeScript consumers to resolve the spreadsheet subpath and its declared public types from the packed tarball. -- [ ] **Step 2: Add dedicated Vite build** +- [x] **Step 2: Add dedicated Vite build** -Build `src/spreadsheet/index.ts` as `cwl-spreadsheet.js` and `cwl-spreadsheet.cjs`; externalize the direct parser dependency so it remains lazy and package-managed rather than copied into ordinary editor startup. +Build `src/spreadsheet/index.ts` as `cwl-spreadsheet.js` and `cwl-spreadsheet.cjs`; when the parser dependency is committed, externalize `xlsx` so it remains lazy and package-managed rather than copied into ordinary editor startup. -- [ ] **Step 3: Record ADR 0027** +- [ ] **Step 3: Record ADR 0032** -Document context, decision, alternatives (server conversion, CSV-only, paste-only, static parser bundling), parser provenance, formula/macro non-execution, resource bounds, diagnostic privacy, accessibility, host authority, rollback, and release-freeze integration. +Document context, decision, alternatives (server conversion, CSV-only, paste-only, static parser bundling), parser provenance, formula/macro non-execution, resource bounds, diagnostic privacy, accessibility, host authority, rollback, and release-freeze integration. ADR 0032 is reserved for this lane because active earlier lanes already own 0027–0031 as described above. - [ ] **Step 4: Reconcile canonical documentation** From 7d47e0259cf22c325b0fc16e4abdc4aa23ad9658 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 07:12:32 +0900 Subject: [PATCH 066/163] feat(spreadsheet): implement bounded SheetJS adapter --- src/spreadsheet/sheetJsAdapter.ts | 258 ++++++++++++++++++++++++++++-- 1 file changed, 249 insertions(+), 9 deletions(-) diff --git a/src/spreadsheet/sheetJsAdapter.ts b/src/spreadsheet/sheetJsAdapter.ts index 01354fdb..be37d1703f 100644 --- a/src/spreadsheet/sheetJsAdapter.ts +++ b/src/spreadsheet/sheetJsAdapter.ts @@ -1,5 +1,22 @@ -import type { SpreadsheetWorkbookData } from './spreadsheetImport.js'; -import { SpreadsheetImportError } from './spreadsheetImport.js'; +import type { + SpreadsheetWorkbookData, + SpreadsheetWorksheetData, +} from './spreadsheetImport.js'; +import { + preflightSpreadsheetBinarySource, + SpreadsheetImportError, +} from './spreadsheetImport.js'; + +const MAX_VISIBLE_WORKSHEETS = 64; +const MAX_WORKBOOK_WORKSHEETS = 256; +const MAX_WORKSHEET_NAME_CODE_UNITS = 1_024; +const MAX_WORKBOOK_ROWS = 10_000; +const MAX_WORKSHEET_COLUMNS = 256; +const MAX_WORKBOOK_CELLS = 262_144; +const RESOURCE_LIMIT_MESSAGE = + 'Spreadsheet exceeds the configured resource limits.'; +const UNSUPPORTED_SOURCE_MESSAGE = + 'Spreadsheet source is unsupported or corrupt.'; /** Minimal SheetJS runtime contract consumed by Inkspan's local adapter. */ export interface SheetJsParserModule { @@ -27,16 +44,239 @@ export interface SheetJsParserModule { }; } +interface PreparedSheet { + readonly name: string; + readonly hidden: boolean; + readonly sheet: object; + readonly hasRange: boolean; +} + +function resourceLimitExceeded(): never { + throw new SpreadsheetImportError( + 'RESOURCE_LIMIT_EXCEEDED', + RESOURCE_LIMIT_MESSAGE, + ); +} + +function unsupportedOrCorruptSource(): never { + throw new SpreadsheetImportError( + 'UNSUPPORTED_OR_CORRUPT', + UNSUPPORTED_SOURCE_MESSAGE, + ); +} + +function isObject(value: unknown): value is object { + return typeof value === 'object' && value !== null; +} + +function isArray(value: unknown): value is readonly unknown[] { + try { + return Array.isArray(value); + } catch { + return false; + } +} + +function readOwnDataProperty(source: object, key: PropertyKey): unknown { + let descriptor: PropertyDescriptor | undefined; + try { + descriptor = Object.getOwnPropertyDescriptor(source, key); + } catch { + unsupportedOrCorruptSource(); + } + if (descriptor === undefined || !('value' in descriptor)) { + unsupportedOrCorruptSource(); + } + return descriptor.value; +} + +function readOptionalOwnDataProperty(source: object, key: PropertyKey): unknown { + let descriptor: PropertyDescriptor | undefined; + try { + descriptor = Object.getOwnPropertyDescriptor(source, key); + } catch { + unsupportedOrCorruptSource(); + } + if (descriptor === undefined) return undefined; + if (!('value' in descriptor)) unsupportedOrCorruptSource(); + return descriptor.value; +} + +function readArrayLength(source: readonly unknown[]): number { + const length = readOwnDataProperty(source, 'length'); + if (!Number.isSafeInteger(length) || (length as number) < 0) { + unsupportedOrCorruptSource(); + } + return length as number; +} + +function readHiddenState( + sheetMetadata: readonly unknown[] | undefined, + index: number, +): boolean { + if (sheetMetadata === undefined || index >= readArrayLength(sheetMetadata)) { + return false; + } + const metadata = readOwnDataProperty(sheetMetadata, String(index)); + if (!isObject(metadata)) unsupportedOrCorruptSource(); + const hidden = readOptionalOwnDataProperty(metadata, 'Hidden'); + if (hidden === undefined || hidden === 0) return false; + if (hidden === 1 || hidden === 2) return true; + return unsupportedOrCorruptSource(); +} + +function readWorkbookSheetMetadata(workbook: object): readonly unknown[] | undefined { + const workbookMetadata = readOptionalOwnDataProperty(workbook, 'Workbook'); + if (workbookMetadata === undefined) return undefined; + if (!isObject(workbookMetadata)) unsupportedOrCorruptSource(); + const sheetMetadata = readOptionalOwnDataProperty(workbookMetadata, 'Sheets'); + if (sheetMetadata === undefined) return undefined; + if (!isArray(sheetMetadata)) unsupportedOrCorruptSource(); + return sheetMetadata; +} + +function decodeRangeDimensions( + parser: SheetJsParserModule, + reference: string, +): { readonly rows: number; readonly columns: number } { + let decoded: unknown; + try { + decoded = parser.utils.decode_range(reference); + } catch { + unsupportedOrCorruptSource(); + } + if (!isObject(decoded)) unsupportedOrCorruptSource(); + const start = readOwnDataProperty(decoded, 's'); + const end = readOwnDataProperty(decoded, 'e'); + if (!isObject(start) || !isObject(end)) unsupportedOrCorruptSource(); + const startRow = readOwnDataProperty(start, 'r'); + const startColumn = readOwnDataProperty(start, 'c'); + const endRow = readOwnDataProperty(end, 'r'); + const endColumn = readOwnDataProperty(end, 'c'); + for (const coordinate of [startRow, startColumn, endRow, endColumn]) { + if (!Number.isSafeInteger(coordinate) || (coordinate as number) < 0) { + unsupportedOrCorruptSource(); + } + } + if ((endRow as number) < (startRow as number)) unsupportedOrCorruptSource(); + if ((endColumn as number) < (startColumn as number)) { + unsupportedOrCorruptSource(); + } + return { + rows: (endRow as number) - (startRow as number) + 1, + columns: (endColumn as number) - (startColumn as number) + 1, + }; +} + +function readDisplayedRows( + parser: SheetJsParserModule, + sheet: object, +): readonly (readonly string[])[] { + let rawRows: unknown; + try { + rawRows = parser.utils.sheet_to_json(sheet, { + header: 1, + raw: false, + defval: '', + blankrows: true, + }); + } catch { + unsupportedOrCorruptSource(); + } + if (!isArray(rawRows)) unsupportedOrCorruptSource(); + const rowCount = readArrayLength(rawRows); + const rows: string[][] = []; + for (let rowIndex = 0; rowIndex < rowCount; rowIndex += 1) { + const rawRow = readOwnDataProperty(rawRows, String(rowIndex)); + if (!isArray(rawRow)) unsupportedOrCorruptSource(); + const columnCount = readArrayLength(rawRow); + const row: string[] = []; + for (let columnIndex = 0; columnIndex < columnCount; columnIndex += 1) { + const cell = readOwnDataProperty(rawRow, String(columnIndex)); + if (typeof cell !== 'string') unsupportedOrCorruptSource(); + row.push(cell); + } + rows.push(row); + } + return rows; +} + /** * Project locally parsed SheetJS workbook data into Inkspan's parser-neutral - * workbook contract. The implementation is intentionally test-first. + * workbook contract without granting formulas, macros, links, or parser output + * any editor authority. Decoded worksheet ranges are bounded before row arrays + * are materialized by `sheet_to_json`. */ export function sheetJsBytesToWorkbookData( - _source: Uint8Array, - _parser: SheetJsParserModule, + source: Uint8Array, + parser: SheetJsParserModule, ): SpreadsheetWorkbookData { - throw new SpreadsheetImportError( - 'UNSUPPORTED_OR_CORRUPT', - 'Spreadsheet source is unsupported or corrupt.', - ); + const boundedSource = preflightSpreadsheetBinarySource(source); + let parsed: unknown; + try { + parsed = parser.read(boundedSource.bytes, { + type: 'array', + cellFormula: false, + cellHTML: false, + cellNF: false, + bookVBA: false, + }); + } catch { + unsupportedOrCorruptSource(); + } + if (!isObject(parsed)) unsupportedOrCorruptSource(); + + const sheetNames = readOwnDataProperty(parsed, 'SheetNames'); + const sheets = readOwnDataProperty(parsed, 'Sheets'); + if (!isArray(sheetNames) || !isObject(sheets)) unsupportedOrCorruptSource(); + const sheetCount = readArrayLength(sheetNames); + if (sheetCount > MAX_WORKBOOK_WORKSHEETS) resourceLimitExceeded(); + const sheetMetadata = readWorkbookSheetMetadata(parsed); + + const prepared: PreparedSheet[] = []; + let visibleCount = 0; + let decodedRows = 0; + let decodedCells = 0; + + for (let index = 0; index < sheetCount; index += 1) { + const name = readOwnDataProperty(sheetNames, String(index)); + if (typeof name !== 'string') unsupportedOrCorruptSource(); + if (name.length > MAX_WORKSHEET_NAME_CODE_UNITS) resourceLimitExceeded(); + const sheet = readOwnDataProperty(sheets, name); + if (!isObject(sheet)) unsupportedOrCorruptSource(); + const hidden = readHiddenState(sheetMetadata, index); + if (hidden) { + prepared.push({ name, hidden: true, sheet, hasRange: false }); + continue; + } + + visibleCount += 1; + if (visibleCount > MAX_VISIBLE_WORKSHEETS) resourceLimitExceeded(); + const reference = readOptionalOwnDataProperty(sheet, '!ref'); + if (reference === undefined) { + prepared.push({ name, hidden: false, sheet, hasRange: false }); + continue; + } + if (typeof reference !== 'string' || reference.length === 0) { + unsupportedOrCorruptSource(); + } + const dimensions = decodeRangeDimensions(parser, reference); + if (dimensions.columns > MAX_WORKSHEET_COLUMNS) resourceLimitExceeded(); + decodedRows += dimensions.rows; + decodedCells += dimensions.rows * dimensions.columns; + if (decodedRows > MAX_WORKBOOK_ROWS || decodedCells > MAX_WORKBOOK_CELLS) { + resourceLimitExceeded(); + } + prepared.push({ name, hidden: false, sheet, hasRange: true }); + } + + const worksheets: SpreadsheetWorksheetData[] = prepared.map((entry) => ({ + name: entry.name, + hidden: entry.hidden, + rows: + entry.hidden || !entry.hasRange + ? [] + : readDisplayedRows(parser, entry.sheet), + })); + return { worksheets }; } From 4d5f2c2b9911343f8104185c855d2dc7b8728141 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 07:26:45 +0900 Subject: [PATCH 067/163] test(spreadsheet): cover parser trust boundaries and row preflight --- src/spreadsheet/sheetJsAdapter.test.ts | 542 ++++++++++++++++++++++++- 1 file changed, 538 insertions(+), 4 deletions(-) diff --git a/src/spreadsheet/sheetJsAdapter.test.ts b/src/spreadsheet/sheetJsAdapter.test.ts index b06ff7e0..4abebafc 100644 --- a/src/spreadsheet/sheetJsAdapter.test.ts +++ b/src/spreadsheet/sheetJsAdapter.test.ts @@ -3,9 +3,99 @@ import { sheetJsBytesToWorkbookData, type SheetJsParserModule, } from './sheetJsAdapter.js'; +import { + SpreadsheetImportError, + type SpreadsheetImportErrorCode, +} from './spreadsheetImport.js'; + +const XLSX_SOURCE = new Uint8Array([0x50, 0x4b, 0x03, 0x04]); + +interface ParserFixtureOptions { + readonly read?: () => unknown; + readonly decodeRange?: (range: string) => unknown; + readonly sheetToJson?: (sheet: unknown) => unknown; +} + +function parserFixture( + workbook: unknown = { SheetNames: [], Sheets: {} }, + options: ParserFixtureOptions = {}, +) { + const read = vi.fn(options.read ?? (() => workbook)); + const decodeRange = vi.fn( + options.decodeRange ?? + (() => ({ s: { r: 0, c: 0 }, e: { r: 0, c: 0 } })), + ); + const sheetToJson = vi.fn(options.sheetToJson ?? (() => [['value']])); + + return { + parser: { + read, + utils: { + decode_range: decodeRange, + sheet_to_json: sheetToJson, + }, + } as SheetJsParserModule, + read, + decodeRange, + sheetToJson, + }; +} + +function expectSpreadsheetError( + action: () => unknown, + code: SpreadsheetImportErrorCode, +): void { + let caught: unknown; + try { + action(); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(SpreadsheetImportError); + expect(caught).toMatchObject({ code }); +} + +function descriptorReportingLength(value: unknown): readonly unknown[] { + return new Proxy([], { + getOwnPropertyDescriptor(target, key) { + if (key === 'length') { + return { + value, + writable: true, + enumerable: false, + configurable: false, + }; + } + return Reflect.getOwnPropertyDescriptor(target, key); + }, + }); +} + +function sheetWorkbook( + sheet: object, + options: { + readonly name?: string; + readonly hidden?: 0 | 1 | 2; + readonly includeMetadata?: boolean; + } = {}, +): object { + const name = options.name ?? 'Summary'; + const workbook: Record = { + SheetNames: [name], + Sheets: { [name]: sheet }, + }; + if (options.includeMetadata !== false) { + workbook.Workbook = { + Sheets: + options.hidden === undefined ? [{}] : [{ Hidden: options.hidden }], + }; + } + return workbook; +} describe('sheetJsBytesToWorkbookData', () => { - it('reads local workbook bytes with non-executing options and projects visible displayed text', () => { + it('reads local workbook bytes with bounded non-executing options and projects visible displayed text', () => { const visibleSheet = { id: 'visible' }; const hiddenSheet = { id: 'hidden' }; const read = vi.fn(() => ({ @@ -43,9 +133,8 @@ describe('sheetJsBytesToWorkbookData', () => { sheet_to_json: sheetToJson, }, }; - const source = new Uint8Array([0x50, 0x4b, 0x03, 0x04]); - expect(sheetJsBytesToWorkbookData(source, parser)).toEqual({ + expect(sheetJsBytesToWorkbookData(XLSX_SOURCE, parser)).toEqual({ worksheets: [ { name: 'Summary', @@ -63,12 +152,13 @@ describe('sheetJsBytesToWorkbookData', () => { ], }); expect(read).toHaveBeenCalledTimes(1); - expect(read).toHaveBeenCalledWith(source, { + expect(read).toHaveBeenCalledWith(XLSX_SOURCE, { type: 'array', cellFormula: false, cellHTML: false, cellNF: false, bookVBA: false, + sheetRows: 10_001, }); expect(decodeRange).toHaveBeenCalledTimes(1); expect(decodeRange).toHaveBeenCalledWith('A1:B2'); @@ -80,4 +170,448 @@ describe('sheetJsBytesToWorkbookData', () => { blankrows: true, }); }); + + it('preserves a visible empty sheet without invoking the row materializer', () => { + const emptySheet = {}; + const { parser, sheetToJson } = parserFixture( + sheetWorkbook(emptySheet, { includeMetadata: false }), + ); + + expect(sheetJsBytesToWorkbookData(XLSX_SOURCE, parser)).toEqual({ + worksheets: [{ name: 'Summary', hidden: false, rows: [] }], + }); + expect(sheetToJson).not.toHaveBeenCalled(); + }); + + it.each([ + ['parser exception', { read: () => { throw new Error('private'); } }], + ['non-object parser result', { read: () => null }], + ] as const)('normalizes %s as an unsupported source', (_label, overrides) => { + const { parser } = parserFixture(undefined, overrides); + expectSpreadsheetError( + () => sheetJsBytesToWorkbookData(XLSX_SOURCE, parser), + 'UNSUPPORTED_OR_CORRUPT', + ); + }); + + it('rejects hostile and accessor-backed required workbook members without evaluating them', () => { + const descriptorFailure = new Proxy({}, { + getOwnPropertyDescriptor() { + throw new Error('private descriptor trap'); + }, + }); + const { parser: hostileParser } = parserFixture(descriptorFailure); + expectSpreadsheetError( + () => sheetJsBytesToWorkbookData(XLSX_SOURCE, hostileParser), + 'UNSUPPORTED_OR_CORRUPT', + ); + + const getter = vi.fn(() => ['Secret']); + const accessorWorkbook = { Sheets: {} } as Record; + Object.defineProperty(accessorWorkbook, 'SheetNames', { + enumerable: true, + get: getter, + }); + const { parser: accessorParser } = parserFixture(accessorWorkbook); + expectSpreadsheetError( + () => sheetJsBytesToWorkbookData(XLSX_SOURCE, accessorParser), + 'UNSUPPORTED_OR_CORRUPT', + ); + expect(getter).not.toHaveBeenCalled(); + + const { parser: missingParser } = parserFixture({ Sheets: {} }); + expectSpreadsheetError( + () => sheetJsBytesToWorkbookData(XLSX_SOURCE, missingParser), + 'UNSUPPORTED_OR_CORRUPT', + ); + }); + + it('rejects invalid workbook container brands and hostile array branding', () => { + const { proxy, revoke } = Proxy.revocable([], {}); + revoke(); + + for (const workbook of [ + { SheetNames: {}, Sheets: {} }, + { SheetNames: [], Sheets: null }, + { SheetNames: proxy, Sheets: {} }, + ]) { + const { parser } = parserFixture(workbook); + expectSpreadsheetError( + () => sheetJsBytesToWorkbookData(XLSX_SOURCE, parser), + 'UNSUPPORTED_OR_CORRUPT', + ); + } + }); + + it('rejects invalid and negative reported array lengths before iteration', () => { + for (const reportedLength of ['not-a-length', -1]) { + const { parser } = parserFixture({ + SheetNames: descriptorReportingLength(reportedLength), + Sheets: {}, + }); + expectSpreadsheetError( + () => sheetJsBytesToWorkbookData(XLSX_SOURCE, parser), + 'UNSUPPORTED_OR_CORRUPT', + ); + } + }); + + it('rejects more than the bounded total worksheet descriptor count before sheet inspection', () => { + const sheetNames = Array.from({ length: 257 }, (_, index) => `Sheet ${index}`); + const { parser } = parserFixture({ SheetNames: sheetNames, Sheets: {} }); + + expectSpreadsheetError( + () => sheetJsBytesToWorkbookData(XLSX_SOURCE, parser), + 'RESOURCE_LIMIT_EXCEEDED', + ); + }); + + it('validates optional workbook sheet metadata without invoking caller accessors', () => { + const sheet = {}; + const workbookGetter = vi.fn(() => ({ Sheets: [] })); + const accessorWorkbook = { + SheetNames: ['Summary'], + Sheets: { Summary: sheet }, + } as Record; + Object.defineProperty(accessorWorkbook, 'Workbook', { + enumerable: true, + get: workbookGetter, + }); + const { parser: accessorParser } = parserFixture(accessorWorkbook); + expectSpreadsheetError( + () => sheetJsBytesToWorkbookData(XLSX_SOURCE, accessorParser), + 'UNSUPPORTED_OR_CORRUPT', + ); + expect(workbookGetter).not.toHaveBeenCalled(); + + const hostileWorkbookMetadata = new Proxy({}, { + getOwnPropertyDescriptor() { + throw new Error('private metadata trap'); + }, + }); + const { parser: hostileMetadataParser } = parserFixture({ + SheetNames: ['Summary'], + Sheets: { Summary: sheet }, + Workbook: hostileWorkbookMetadata, + }); + expectSpreadsheetError( + () => sheetJsBytesToWorkbookData(XLSX_SOURCE, hostileMetadataParser), + 'UNSUPPORTED_OR_CORRUPT', + ); + + for (const Workbook of [1, {}, { Sheets: {} }]) { + const { parser } = parserFixture({ + SheetNames: ['Summary'], + Sheets: { Summary: sheet }, + Workbook, + }); + if (Workbook && typeof Workbook === 'object' && !('Sheets' in Workbook)) { + expect(sheetJsBytesToWorkbookData(XLSX_SOURCE, parser)).toEqual({ + worksheets: [{ name: 'Summary', hidden: false, rows: [] }], + }); + } else { + expectSpreadsheetError( + () => sheetJsBytesToWorkbookData(XLSX_SOURCE, parser), + 'UNSUPPORTED_OR_CORRUPT', + ); + } + } + + const { proxy, revoke } = Proxy.revocable([], {}); + revoke(); + const { parser: revokedParser } = parserFixture({ + SheetNames: ['Summary'], + Sheets: { Summary: sheet }, + Workbook: { Sheets: proxy }, + }); + expectSpreadsheetError( + () => sheetJsBytesToWorkbookData(XLSX_SOURCE, revokedParser), + 'UNSUPPORTED_OR_CORRUPT', + ); + }); + + it('handles absent, short, hidden, very-hidden, and invalid metadata states deterministically', () => { + const sheet = {}; + for (const workbook of [ + { SheetNames: ['Summary'], Sheets: { Summary: sheet } }, + { + SheetNames: ['Summary'], + Sheets: { Summary: sheet }, + Workbook: { Sheets: [] }, + }, + sheetWorkbook(sheet, { hidden: 0 }), + ]) { + const { parser } = parserFixture(workbook); + expect(sheetJsBytesToWorkbookData(XLSX_SOURCE, parser)).toEqual({ + worksheets: [{ name: 'Summary', hidden: false, rows: [] }], + }); + } + + for (const hidden of [1, 2] as const) { + const { parser } = parserFixture(sheetWorkbook(sheet, { hidden })); + expect(sheetJsBytesToWorkbookData(XLSX_SOURCE, parser)).toEqual({ + worksheets: [{ name: 'Summary', hidden: true, rows: [] }], + }); + } + + const hiddenGetter = vi.fn(() => 1); + const accessorMetadata = {}; + Object.defineProperty(accessorMetadata, 'Hidden', { + enumerable: true, + get: hiddenGetter, + }); + const { parser: accessorMetadataParser } = parserFixture({ + SheetNames: ['Summary'], + Sheets: { Summary: sheet }, + Workbook: { Sheets: [accessorMetadata] }, + }); + expectSpreadsheetError( + () => sheetJsBytesToWorkbookData(XLSX_SOURCE, accessorMetadataParser), + 'UNSUPPORTED_OR_CORRUPT', + ); + expect(hiddenGetter).not.toHaveBeenCalled(); + + for (const metadata of [null, { Hidden: 3 }]) { + const { parser } = parserFixture({ + SheetNames: ['Summary'], + Sheets: { Summary: sheet }, + Workbook: { Sheets: [metadata] }, + }); + expectSpreadsheetError( + () => sheetJsBytesToWorkbookData(XLSX_SOURCE, parser), + 'UNSUPPORTED_OR_CORRUPT', + ); + } + }); + + it('rejects invalid worksheet names, oversized names, and invalid sheet objects', () => { + const { parser: nonStringName } = parserFixture({ + SheetNames: [123], + Sheets: {}, + }); + expectSpreadsheetError( + () => sheetJsBytesToWorkbookData(XLSX_SOURCE, nonStringName), + 'UNSUPPORTED_OR_CORRUPT', + ); + + const oversized = 'x'.repeat(1_025); + const { parser: oversizedName } = parserFixture({ + SheetNames: [oversized], + Sheets: { [oversized]: {} }, + }); + expectSpreadsheetError( + () => sheetJsBytesToWorkbookData(XLSX_SOURCE, oversizedName), + 'RESOURCE_LIMIT_EXCEEDED', + ); + + const { parser: invalidSheet } = parserFixture({ + SheetNames: ['Summary'], + Sheets: { Summary: null }, + }); + expectSpreadsheetError( + () => sheetJsBytesToWorkbookData(XLSX_SOURCE, invalidSheet), + 'UNSUPPORTED_OR_CORRUPT', + ); + }); + + it('bounds visible worksheets before range parsing', () => { + const names = Array.from({ length: 65 }, (_, index) => `Visible ${index}`); + const sheets = Object.fromEntries(names.map((name) => [name, {}])); + const { parser, decodeRange } = parserFixture({ + SheetNames: names, + Sheets: sheets, + }); + + expectSpreadsheetError( + () => sheetJsBytesToWorkbookData(XLSX_SOURCE, parser), + 'RESOURCE_LIMIT_EXCEEDED', + ); + expect(decodeRange).not.toHaveBeenCalled(); + }); + + it('rejects invalid range members without evaluating accessor-backed references', () => { + const refGetter = vi.fn(() => 'A1'); + const accessorSheet = {}; + Object.defineProperty(accessorSheet, '!ref', { + enumerable: true, + get: refGetter, + }); + const { parser: accessorParser } = parserFixture(sheetWorkbook(accessorSheet)); + expectSpreadsheetError( + () => sheetJsBytesToWorkbookData(XLSX_SOURCE, accessorParser), + 'UNSUPPORTED_OR_CORRUPT', + ); + expect(refGetter).not.toHaveBeenCalled(); + + const hostileSheet = new Proxy({}, { + getOwnPropertyDescriptor() { + throw new Error('private range trap'); + }, + }); + const { parser: hostileParser } = parserFixture(sheetWorkbook(hostileSheet)); + expectSpreadsheetError( + () => sheetJsBytesToWorkbookData(XLSX_SOURCE, hostileParser), + 'UNSUPPORTED_OR_CORRUPT', + ); + + for (const reference of [0, '']) { + const { parser } = parserFixture( + sheetWorkbook({ '!ref': reference } as object), + ); + expectSpreadsheetError( + () => sheetJsBytesToWorkbookData(XLSX_SOURCE, parser), + 'UNSUPPORTED_OR_CORRUPT', + ); + } + }); + + it('normalizes malformed decoded ranges and hostile range decoders', () => { + const cases: readonly (() => unknown)[] = [ + () => { throw new Error('private'); }, + () => null, + () => ({ s: null, e: { r: 0, c: 0 } }), + () => ({ s: { r: 0, c: 0 }, e: null }), + () => ({ s: { r: Number.NaN, c: 0 }, e: { r: 0, c: 0 } }), + () => ({ s: { r: -1, c: 0 }, e: { r: 0, c: 0 } }), + () => ({ s: { r: 1, c: 0 }, e: { r: 0, c: 0 } }), + () => ({ s: { r: 0, c: 1 }, e: { r: 0, c: 0 } }), + ]; + + for (const decodeRange of cases) { + const { parser } = parserFixture( + sheetWorkbook({ '!ref': 'A1' }), + { decodeRange: () => decodeRange() }, + ); + expectSpreadsheetError( + () => sheetJsBytesToWorkbookData(XLSX_SOURCE, parser), + 'UNSUPPORTED_OR_CORRUPT', + ); + } + }); + + it('preflights decoded column, aggregate-row, and aggregate-cell ceilings before row materialization', () => { + const wide = parserFixture( + sheetWorkbook({ '!ref': 'wide' }), + { + decodeRange: () => ({ + s: { r: 0, c: 0 }, + e: { r: 0, c: 256 }, + }), + }, + ); + expectSpreadsheetError( + () => sheetJsBytesToWorkbookData(XLSX_SOURCE, wide.parser), + 'RESOURCE_LIMIT_EXCEEDED', + ); + expect(wide.sheetToJson).not.toHaveBeenCalled(); + + const twoSheets = { + SheetNames: ['First', 'Second'], + Sheets: { + First: { '!ref': 'first' }, + Second: { '!ref': 'second' }, + }, + }; + const rows = parserFixture(twoSheets, { + decodeRange: () => ({ + s: { r: 0, c: 0 }, + e: { r: 5_999, c: 0 }, + }), + }); + expectSpreadsheetError( + () => sheetJsBytesToWorkbookData(XLSX_SOURCE, rows.parser), + 'RESOURCE_LIMIT_EXCEEDED', + ); + expect(rows.sheetToJson).not.toHaveBeenCalled(); + + const cells = parserFixture( + sheetWorkbook({ '!ref': 'cells' }), + { + decodeRange: () => ({ + s: { r: 0, c: 0 }, + e: { r: 1_024, c: 255 }, + }), + }, + ); + expectSpreadsheetError( + () => sheetJsBytesToWorkbookData(XLSX_SOURCE, cells.parser), + 'RESOURCE_LIMIT_EXCEEDED', + ); + expect(cells.sheetToJson).not.toHaveBeenCalled(); + }); + + it('normalizes row materialization failures and hostile array containers', () => { + const sheet = { '!ref': 'A1' }; + + const throwing = parserFixture(sheetWorkbook(sheet), { + sheetToJson: () => { throw new Error('private'); }, + }); + expectSpreadsheetError( + () => sheetJsBytesToWorkbookData(XLSX_SOURCE, throwing.parser), + 'UNSUPPORTED_OR_CORRUPT', + ); + + const nonArray = parserFixture(sheetWorkbook(sheet), { + sheetToJson: () => ({}), + }); + expectSpreadsheetError( + () => sheetJsBytesToWorkbookData(XLSX_SOURCE, nonArray.parser), + 'UNSUPPORTED_OR_CORRUPT', + ); + + const { proxy, revoke } = Proxy.revocable([], {}); + revoke(); + const revoked = parserFixture(sheetWorkbook(sheet), { + sheetToJson: () => proxy, + }); + expectSpreadsheetError( + () => sheetJsBytesToWorkbookData(XLSX_SOURCE, revoked.parser), + 'UNSUPPORTED_OR_CORRUPT', + ); + + const invalidRow = parserFixture(sheetWorkbook(sheet), { + sheetToJson: () => [null], + }); + expectSpreadsheetError( + () => sheetJsBytesToWorkbookData(XLSX_SOURCE, invalidRow.parser), + 'UNSUPPORTED_OR_CORRUPT', + ); + + const invalidRowLength = parserFixture(sheetWorkbook(sheet), { + sheetToJson: () => [descriptorReportingLength(-1)], + }); + expectSpreadsheetError( + () => sheetJsBytesToWorkbookData(XLSX_SOURCE, invalidRowLength.parser), + 'UNSUPPORTED_OR_CORRUPT', + ); + }); + + it('rejects accessor-backed and non-string displayed cells without evaluating caller code', () => { + const sheet = { '!ref': 'A1' }; + const cellGetter = vi.fn(() => 'private'); + const accessorRow: unknown[] = []; + Object.defineProperty(accessorRow, '0', { + enumerable: true, + configurable: true, + get: cellGetter, + }); + accessorRow.length = 1; + + const accessor = parserFixture(sheetWorkbook(sheet), { + sheetToJson: () => [accessorRow], + }); + expectSpreadsheetError( + () => sheetJsBytesToWorkbookData(XLSX_SOURCE, accessor.parser), + 'UNSUPPORTED_OR_CORRUPT', + ); + expect(cellGetter).not.toHaveBeenCalled(); + + const nonString = parserFixture(sheetWorkbook(sheet), { + sheetToJson: () => [[42]], + }); + expectSpreadsheetError( + () => sheetJsBytesToWorkbookData(XLSX_SOURCE, nonString.parser), + 'UNSUPPORTED_OR_CORRUPT', + ); + }); }); From 9e30a155f8f8a9ad5901db9ecd5b73b38e56cfc2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 07:29:44 +0900 Subject: [PATCH 068/163] test(spreadsheet): type parser failure fixtures explicitly --- src/spreadsheet/sheetJsAdapter.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/spreadsheet/sheetJsAdapter.test.ts b/src/spreadsheet/sheetJsAdapter.test.ts index 4abebafc..091aa53d 100644 --- a/src/spreadsheet/sheetJsAdapter.test.ts +++ b/src/spreadsheet/sheetJsAdapter.test.ts @@ -184,8 +184,8 @@ describe('sheetJsBytesToWorkbookData', () => { }); it.each([ - ['parser exception', { read: () => { throw new Error('private'); } }], - ['non-object parser result', { read: () => null }], + ['parser exception', { read: (): never => { throw new Error('private'); } }], + ['non-object parser result', { read: (): null => null }], ] as const)('normalizes %s as an unsupported source', (_label, overrides) => { const { parser } = parserFixture(undefined, overrides); expectSpreadsheetError( From 970d208047e04b75305c60808f7d0a300f74d3e8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 07:33:26 +0900 Subject: [PATCH 069/163] fix(spreadsheet): bound SheetJS row parsing --- src/spreadsheet/sheetJsAdapter.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/spreadsheet/sheetJsAdapter.ts b/src/spreadsheet/sheetJsAdapter.ts index be37d1703f..2eda5723 100644 --- a/src/spreadsheet/sheetJsAdapter.ts +++ b/src/spreadsheet/sheetJsAdapter.ts @@ -28,6 +28,7 @@ export interface SheetJsParserModule { readonly cellHTML: false; readonly cellNF: false; readonly bookVBA: false; + readonly sheetRows: number; }, ) => unknown; readonly utils: { @@ -204,8 +205,10 @@ function readDisplayedRows( /** * Project locally parsed SheetJS workbook data into Inkspan's parser-neutral * workbook contract without granting formulas, macros, links, or parser output - * any editor authority. Decoded worksheet ranges are bounded before row arrays - * are materialized by `sheet_to_json`. + * any editor authority. Parsing is capped one row beyond the accepted aggregate + * row ceiling so an oversized source can be rejected without unbounded worksheet + * materialization; decoded ranges are then checked against the exact workbook + * limits before displayed row arrays are materialized by `sheet_to_json`. */ export function sheetJsBytesToWorkbookData( source: Uint8Array, @@ -220,6 +223,7 @@ export function sheetJsBytesToWorkbookData( cellHTML: false, cellNF: false, bookVBA: false, + sheetRows: MAX_WORKBOOK_ROWS + 1, }); } catch { unsupportedOrCorruptSource(); From 66252d22f24b3548487d7feedc53fe3011be908d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 07:37:44 +0900 Subject: [PATCH 070/163] test(spreadsheet): require aggregate parser row budgeting --- .../sheetJsAdapter.aggregateBudget.test.ts | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 src/spreadsheet/sheetJsAdapter.aggregateBudget.test.ts diff --git a/src/spreadsheet/sheetJsAdapter.aggregateBudget.test.ts b/src/spreadsheet/sheetJsAdapter.aggregateBudget.test.ts new file mode 100644 index 00000000..a10d7c9e --- /dev/null +++ b/src/spreadsheet/sheetJsAdapter.aggregateBudget.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + sheetJsBytesToWorkbookData, + type SheetJsParserModule, +} from './sheetJsAdapter.js'; +import { SpreadsheetImportError } from './spreadsheetImport.js'; + +const XLSX_SOURCE = new Uint8Array([0x50, 0x4b, 0x03, 0x04]); + +function decodeRange(range: string): unknown { + const match = /^A1:A(\d+)$/.exec(range); + if (match === null) throw new Error('unexpected range'); + return { + s: { r: 0, c: 0 }, + e: { r: Number(match[1]) - 1, c: 0 }, + }; +} + +describe('SheetJS aggregate parser budget', () => { + it('discovers sheet names without rows and decreases the parser row ceiling before each selected sheet parse', () => { + const readOptions: Record[] = []; + const read = vi.fn((_source: Uint8Array, rawOptions: unknown) => { + const options = rawOptions as Record; + readOptions.push(options); + + if (readOptions.length === 1) { + if (options.bookSheets !== true) { + throw new Error('the first parser pass materialized worksheet data'); + } + return { + SheetNames: ['First', 'Second'], + }; + } + + if (options.sheets === 'First') { + return { + SheetNames: ['First', 'Second'], + Sheets: { First: { '!ref': 'A1:A6000' } }, + Workbook: { Sheets: [{ Hidden: 0 }, { Hidden: 0 }] }, + }; + } + + if (options.sheets === 'Second') { + return { + SheetNames: ['First', 'Second'], + Sheets: { Second: { '!ref': 'A1:A4001' } }, + Workbook: { Sheets: [{ Hidden: 0 }, { Hidden: 0 }] }, + }; + } + + throw new Error('unexpected parser pass'); + }); + const sheetToJson = vi.fn(() => []); + const parser = { + read, + utils: { + decode_range: decodeRange, + sheet_to_json: sheetToJson, + }, + } as unknown as SheetJsParserModule; + + let caught: unknown; + try { + sheetJsBytesToWorkbookData(XLSX_SOURCE, parser); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(SpreadsheetImportError); + expect(caught).toMatchObject({ code: 'RESOURCE_LIMIT_EXCEEDED' }); + expect(read).toHaveBeenCalledTimes(3); + expect(readOptions[0]).toMatchObject({ bookSheets: true }); + expect(readOptions[1]).toMatchObject({ + sheets: 'First', + sheetRows: 10_001, + }); + expect(readOptions[2]).toMatchObject({ + sheets: 'Second', + sheetRows: 4_001, + }); + expect(sheetToJson).toHaveBeenCalledTimes(1); + }); +}); From 4e9ce872e1ac71a4812001a2300fca54062562ee Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 08:00:15 +0900 Subject: [PATCH 071/163] fix(spreadsheet): enforce aggregate parser row budget --- src/spreadsheet/sheetJsAdapter.ts | 143 ++++++++++++++++++------------ 1 file changed, 86 insertions(+), 57 deletions(-) diff --git a/src/spreadsheet/sheetJsAdapter.ts b/src/spreadsheet/sheetJsAdapter.ts index 2eda5723..633d490f 100644 --- a/src/spreadsheet/sheetJsAdapter.ts +++ b/src/spreadsheet/sheetJsAdapter.ts @@ -18,18 +18,22 @@ const RESOURCE_LIMIT_MESSAGE = const UNSUPPORTED_SOURCE_MESSAGE = 'Spreadsheet source is unsupported or corrupt.'; +interface SheetJsReadOptions { + readonly type: 'array'; + readonly cellFormula: false; + readonly cellHTML: false; + readonly cellNF: false; + readonly bookVBA: false; + readonly sheetRows?: number; + readonly bookSheets?: true; + readonly sheets?: string; +} + /** Minimal SheetJS runtime contract consumed by Inkspan's local adapter. */ export interface SheetJsParserModule { readonly read: ( source: Uint8Array, - options: { - readonly type: 'array'; - readonly cellFormula: false; - readonly cellHTML: false; - readonly cellNF: false; - readonly bookVBA: false; - readonly sheetRows: number; - }, + options: SheetJsReadOptions, ) => unknown; readonly utils: { readonly decode_range: (range: string) => unknown; @@ -45,13 +49,6 @@ export interface SheetJsParserModule { }; } -interface PreparedSheet { - readonly name: string; - readonly hidden: boolean; - readonly sheet: object; - readonly hasRange: boolean; -} - function resourceLimitExceeded(): never { throw new SpreadsheetImportError( 'RESOURCE_LIMIT_EXCEEDED', @@ -202,55 +199,85 @@ function readDisplayedRows( return rows; } +function readWorkbook( + parser: SheetJsParserModule, + source: Uint8Array, + options: SheetJsReadOptions, +): object { + let parsed: unknown; + try { + parsed = parser.read(source, options); + } catch { + unsupportedOrCorruptSource(); + } + if (!isObject(parsed)) unsupportedOrCorruptSource(); + return parsed; +} + +function baseReadOptions(): Omit< + SheetJsReadOptions, + 'bookSheets' | 'sheets' | 'sheetRows' +> { + return { + type: 'array', + cellFormula: false, + cellHTML: false, + cellNF: false, + bookVBA: false, + }; +} + /** * Project locally parsed SheetJS workbook data into Inkspan's parser-neutral * workbook contract without granting formulas, macros, links, or parser output - * any editor authority. Parsing is capped one row beyond the accepted aggregate - * row ceiling so an oversized source can be rejected without unbounded worksheet - * materialization; decoded ranges are then checked against the exact workbook - * limits before displayed row arrays are materialized by `sheet_to_json`. + * any editor authority. Inkspan first performs a sheet-name-only discovery pass, + * then parses each selected worksheet with a row ceiling derived from the + * remaining aggregate workbook budget. Exact decoded row, column, and cell + * limits are checked before displayed rows are materialized by `sheet_to_json`. */ export function sheetJsBytesToWorkbookData( source: Uint8Array, parser: SheetJsParserModule, ): SpreadsheetWorkbookData { const boundedSource = preflightSpreadsheetBinarySource(source); - let parsed: unknown; - try { - parsed = parser.read(boundedSource.bytes, { - type: 'array', - cellFormula: false, - cellHTML: false, - cellNF: false, - bookVBA: false, - sheetRows: MAX_WORKBOOK_ROWS + 1, - }); - } catch { - unsupportedOrCorruptSource(); - } - if (!isObject(parsed)) unsupportedOrCorruptSource(); - - const sheetNames = readOwnDataProperty(parsed, 'SheetNames'); - const sheets = readOwnDataProperty(parsed, 'Sheets'); - if (!isArray(sheetNames) || !isObject(sheets)) unsupportedOrCorruptSource(); + const discovery = readWorkbook(parser, boundedSource.bytes, { + ...baseReadOptions(), + bookSheets: true, + }); + const sheetNames = readOwnDataProperty(discovery, 'SheetNames'); + if (!isArray(sheetNames)) unsupportedOrCorruptSource(); const sheetCount = readArrayLength(sheetNames); if (sheetCount > MAX_WORKBOOK_WORKSHEETS) resourceLimitExceeded(); - const sheetMetadata = readWorkbookSheetMetadata(parsed); - - const prepared: PreparedSheet[] = []; - let visibleCount = 0; - let decodedRows = 0; - let decodedCells = 0; + const worksheetNames: string[] = []; for (let index = 0; index < sheetCount; index += 1) { const name = readOwnDataProperty(sheetNames, String(index)); if (typeof name !== 'string') unsupportedOrCorruptSource(); if (name.length > MAX_WORKSHEET_NAME_CODE_UNITS) resourceLimitExceeded(); + worksheetNames.push(name); + } + + const worksheets: SpreadsheetWorksheetData[] = []; + let visibleCount = 0; + let decodedRows = 0; + let decodedCells = 0; + + for (let index = 0; index < worksheetNames.length; index += 1) { + const name = worksheetNames[index] as string; + const remainingRows = MAX_WORKBOOK_ROWS - decodedRows; + const parsed = readWorkbook(parser, boundedSource.bytes, { + ...baseReadOptions(), + sheets: name, + sheetRows: remainingRows + 1, + }); + const sheets = readOwnDataProperty(parsed, 'Sheets'); + if (!isObject(sheets)) unsupportedOrCorruptSource(); const sheet = readOwnDataProperty(sheets, name); if (!isObject(sheet)) unsupportedOrCorruptSource(); + const sheetMetadata = readWorkbookSheetMetadata(parsed); const hidden = readHiddenState(sheetMetadata, index); if (hidden) { - prepared.push({ name, hidden: true, sheet, hasRange: false }); + worksheets.push({ name, hidden: true, rows: [] }); continue; } @@ -258,7 +285,7 @@ export function sheetJsBytesToWorkbookData( if (visibleCount > MAX_VISIBLE_WORKSHEETS) resourceLimitExceeded(); const reference = readOptionalOwnDataProperty(sheet, '!ref'); if (reference === undefined) { - prepared.push({ name, hidden: false, sheet, hasRange: false }); + worksheets.push({ name, hidden: false, rows: [] }); continue; } if (typeof reference !== 'string' || reference.length === 0) { @@ -266,21 +293,23 @@ export function sheetJsBytesToWorkbookData( } const dimensions = decodeRangeDimensions(parser, reference); if (dimensions.columns > MAX_WORKSHEET_COLUMNS) resourceLimitExceeded(); - decodedRows += dimensions.rows; - decodedCells += dimensions.rows * dimensions.columns; - if (decodedRows > MAX_WORKBOOK_ROWS || decodedCells > MAX_WORKBOOK_CELLS) { + const nextDecodedRows = decodedRows + dimensions.rows; + const nextDecodedCells = + decodedCells + dimensions.rows * dimensions.columns; + if ( + nextDecodedRows > MAX_WORKBOOK_ROWS || + nextDecodedCells > MAX_WORKBOOK_CELLS + ) { resourceLimitExceeded(); } - prepared.push({ name, hidden: false, sheet, hasRange: true }); + decodedRows = nextDecodedRows; + decodedCells = nextDecodedCells; + worksheets.push({ + name, + hidden: false, + rows: readDisplayedRows(parser, sheet), + }); } - const worksheets: SpreadsheetWorksheetData[] = prepared.map((entry) => ({ - name: entry.name, - hidden: entry.hidden, - rows: - entry.hidden || !entry.hasRange - ? [] - : readDisplayedRows(parser, entry.sheet), - })); return { worksheets }; } From cb4d4a9b2532d95131b4b3144b6d90dc8d336572 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 08:04:54 +0900 Subject: [PATCH 072/163] test(spreadsheet): align adapter contract with aggregate parser budgeting --- src/spreadsheet/sheetJsAdapter.test.ts | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/src/spreadsheet/sheetJsAdapter.test.ts b/src/spreadsheet/sheetJsAdapter.test.ts index 091aa53d..c583a2d0 100644 --- a/src/spreadsheet/sheetJsAdapter.test.ts +++ b/src/spreadsheet/sheetJsAdapter.test.ts @@ -151,15 +151,33 @@ describe('sheetJsBytesToWorkbookData', () => { }, ], }); - expect(read).toHaveBeenCalledTimes(1); - expect(read).toHaveBeenCalledWith(XLSX_SOURCE, { + expect(read).toHaveBeenCalledTimes(3); + expect(read).toHaveBeenNthCalledWith(1, XLSX_SOURCE, { type: 'array', cellFormula: false, cellHTML: false, cellNF: false, bookVBA: false, + bookSheets: true, + }); + expect(read).toHaveBeenNthCalledWith(2, XLSX_SOURCE, { + type: 'array', + cellFormula: false, + cellHTML: false, + cellNF: false, + bookVBA: false, + sheets: 'Summary', sheetRows: 10_001, }); + expect(read).toHaveBeenNthCalledWith(3, XLSX_SOURCE, { + type: 'array', + cellFormula: false, + cellHTML: false, + cellNF: false, + bookVBA: false, + sheets: 'Private', + sheetRows: 9_999, + }); expect(decodeRange).toHaveBeenCalledTimes(1); expect(decodeRange).toHaveBeenCalledWith('A1:B2'); expect(sheetToJson).toHaveBeenCalledTimes(1); @@ -232,7 +250,7 @@ describe('sheetJsBytesToWorkbookData', () => { for (const workbook of [ { SheetNames: {}, Sheets: {} }, - { SheetNames: [], Sheets: null }, + { SheetNames: ['Summary'], Sheets: null }, { SheetNames: proxy, Sheets: {} }, ]) { const { parser } = parserFixture(workbook); @@ -522,7 +540,7 @@ describe('sheetJsBytesToWorkbookData', () => { () => sheetJsBytesToWorkbookData(XLSX_SOURCE, rows.parser), 'RESOURCE_LIMIT_EXCEEDED', ); - expect(rows.sheetToJson).not.toHaveBeenCalled(); + expect(rows.sheetToJson).toHaveBeenCalledTimes(1); const cells = parserFixture( sheetWorkbook({ '!ref': 'cells' }), From 5fdcdb3a545f26d52cc183bffe220757d38af35b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 08:17:52 +0900 Subject: [PATCH 073/163] test(spreadsheet): bound parser output before cell inspection --- .../sheetJsAdapter.outputBounds.test.ts | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 src/spreadsheet/sheetJsAdapter.outputBounds.test.ts diff --git a/src/spreadsheet/sheetJsAdapter.outputBounds.test.ts b/src/spreadsheet/sheetJsAdapter.outputBounds.test.ts new file mode 100644 index 00000000..ebd20e86 --- /dev/null +++ b/src/spreadsheet/sheetJsAdapter.outputBounds.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + sheetJsBytesToWorkbookData, + type SheetJsParserModule, +} from './sheetJsAdapter.js'; +import { SpreadsheetImportError } from './spreadsheetImport.js'; + +const XLSX_SOURCE = new Uint8Array([0x50, 0x4b, 0x03, 0x04]); + +function parserWithRows(rows: readonly unknown[]): SheetJsParserModule { + let readCount = 0; + return { + read: vi.fn(() => { + readCount += 1; + if (readCount === 1) { + return { SheetNames: ['Summary'] }; + } + return { + SheetNames: ['Summary'], + Sheets: { Summary: { '!ref': 'A1:A1' } }, + Workbook: { Sheets: [{ Hidden: 0 }] }, + }; + }), + utils: { + decode_range: vi.fn(() => ({ + s: { r: 0, c: 0 }, + e: { r: 0, c: 0 }, + })), + sheet_to_json: vi.fn(() => rows), + }, + }; +} + +function expectResourceLimit(action: () => unknown): void { + let caught: unknown; + try { + action(); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(SpreadsheetImportError); + expect(caught).toMatchObject({ code: 'RESOURCE_LIMIT_EXCEEDED' }); +} + +describe('SheetJS materialized output bounds', () => { + it('rejects more materialized rows than the decoded range before reading row entries', () => { + let rowEntryInspected = false; + const rows = new Proxy(new Array(2), { + getOwnPropertyDescriptor(target, key) { + if (key === '0') { + rowEntryInspected = true; + throw new Error('row entry must not be inspected'); + } + return Reflect.getOwnPropertyDescriptor(target, key); + }, + }); + const parser = parserWithRows(rows); + + expectResourceLimit(() => sheetJsBytesToWorkbookData(XLSX_SOURCE, parser)); + expect(rowEntryInspected).toBe(false); + }); + + it('rejects a materialized row wider than the decoded range before reading cell entries', () => { + let cellEntryInspected = false; + const row = new Proxy(new Array(257), { + getOwnPropertyDescriptor(target, key) { + if (key === '0') { + cellEntryInspected = true; + throw new Error('cell entry must not be inspected'); + } + return Reflect.getOwnPropertyDescriptor(target, key); + }, + }); + const parser = parserWithRows([row]); + + expectResourceLimit(() => sheetJsBytesToWorkbookData(XLSX_SOURCE, parser)); + expect(cellEntryInspected).toBe(false); + }); +}); From 19f4a928fde49d6f07b22fb801e014b54e84aedf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 08:20:21 +0900 Subject: [PATCH 074/163] fix(spreadsheet): bound materialized parser output --- src/spreadsheet/sheetJsAdapter.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/spreadsheet/sheetJsAdapter.ts b/src/spreadsheet/sheetJsAdapter.ts index 633d490f..8891b7d8 100644 --- a/src/spreadsheet/sheetJsAdapter.ts +++ b/src/spreadsheet/sheetJsAdapter.ts @@ -169,6 +169,8 @@ function decodeRangeDimensions( function readDisplayedRows( parser: SheetJsParserModule, sheet: object, + expectedRows: number, + expectedColumns: number, ): readonly (readonly string[])[] { let rawRows: unknown; try { @@ -183,11 +185,13 @@ function readDisplayedRows( } if (!isArray(rawRows)) unsupportedOrCorruptSource(); const rowCount = readArrayLength(rawRows); + if (rowCount > expectedRows) resourceLimitExceeded(); const rows: string[][] = []; for (let rowIndex = 0; rowIndex < rowCount; rowIndex += 1) { const rawRow = readOwnDataProperty(rawRows, String(rowIndex)); if (!isArray(rawRow)) unsupportedOrCorruptSource(); const columnCount = readArrayLength(rawRow); + if (columnCount > expectedColumns) resourceLimitExceeded(); const row: string[] = []; for (let columnIndex = 0; columnIndex < columnCount; columnIndex += 1) { const cell = readOwnDataProperty(rawRow, String(columnIndex)); @@ -307,7 +311,12 @@ export function sheetJsBytesToWorkbookData( worksheets.push({ name, hidden: false, - rows: readDisplayedRows(parser, sheet), + rows: readDisplayedRows( + parser, + sheet, + dimensions.rows, + dimensions.columns, + ), }); } From 9fa103c2351a797c22b00a0809d25a8a6d79406f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 08:28:10 +0900 Subject: [PATCH 075/163] test(spreadsheet): require real SheetJS workbook parsing --- src/spreadsheet/sheetJsRuntime.test.ts | 58 ++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 src/spreadsheet/sheetJsRuntime.test.ts diff --git a/src/spreadsheet/sheetJsRuntime.test.ts b/src/spreadsheet/sheetJsRuntime.test.ts new file mode 100644 index 00000000..7ef67606 --- /dev/null +++ b/src/spreadsheet/sheetJsRuntime.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; +import * as XLSX from 'xlsx'; +import { parseSheetJsSpreadsheetBytes } from './sheetJsRuntime.js'; + +function workbookBytes(bookType: 'xlsx' | 'biff8'): Uint8Array { + const workbook = XLSX.utils.book_new(); + const summary = XLSX.utils.aoa_to_sheet([ + ['Metric', 'Value'], + ['Revenue', 42], + ]); + summary.C3 = { t: 'n', f: '1+1', v: 2 }; + summary['!ref'] = 'A1:C3'; + XLSX.utils.book_append_sheet(workbook, summary, 'Summary'); + + const hidden = XLSX.utils.aoa_to_sheet([['secret']]); + XLSX.utils.book_append_sheet(workbook, hidden, 'Hidden'); + if (workbook.Workbook?.Sheets !== undefined) { + workbook.Workbook.Sheets[1] = { + ...workbook.Workbook.Sheets[1], + Hidden: 1, + }; + } + + const written = XLSX.write(workbook, { + bookType, + type: 'array', + cellFormula: true, + }); + return new Uint8Array(written); +} + +describe('real SheetJS spreadsheet runtime', () => { + it.each([ + ['XLSX', 'xlsx'], + ['BIFF8 XLS', 'biff8'], + ] as const)('parses a real %s workbook through the bounded adapter', async (_label, bookType) => { + const workbook = await parseSheetJsSpreadsheetBytes(workbookBytes(bookType)); + + expect(workbook).toEqual({ + worksheets: [ + { + name: 'Summary', + hidden: false, + rows: [ + ['Metric', 'Value', ''], + ['Revenue', '42', ''], + ['', '', '2'], + ], + }, + { + name: 'Hidden', + hidden: true, + rows: [], + }, + ], + }); + }); +}); From 7b1f2186b2da6e2c02e3b172533734fd2ac9c9af Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 08:29:03 +0900 Subject: [PATCH 076/163] test(spreadsheet): isolate real parser runtime boundary --- src/spreadsheet/sheetJsRuntime.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/spreadsheet/sheetJsRuntime.test.ts b/src/spreadsheet/sheetJsRuntime.test.ts index 7ef67606..87b35c53 100644 --- a/src/spreadsheet/sheetJsRuntime.test.ts +++ b/src/spreadsheet/sheetJsRuntime.test.ts @@ -24,7 +24,6 @@ function workbookBytes(bookType: 'xlsx' | 'biff8'): Uint8Array { const written = XLSX.write(workbook, { bookType, type: 'array', - cellFormula: true, }); return new Uint8Array(written); } From f55b4367aa904c877a419b5a2d7b2894c4133892 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 08:35:39 +0900 Subject: [PATCH 077/163] test(spreadsheet): persist hidden worksheet metadata --- src/spreadsheet/sheetJsRuntime.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/spreadsheet/sheetJsRuntime.test.ts b/src/spreadsheet/sheetJsRuntime.test.ts index 87b35c53..d94921f5 100644 --- a/src/spreadsheet/sheetJsRuntime.test.ts +++ b/src/spreadsheet/sheetJsRuntime.test.ts @@ -14,12 +14,12 @@ function workbookBytes(bookType: 'xlsx' | 'biff8'): Uint8Array { const hidden = XLSX.utils.aoa_to_sheet([['secret']]); XLSX.utils.book_append_sheet(workbook, hidden, 'Hidden'); - if (workbook.Workbook?.Sheets !== undefined) { - workbook.Workbook.Sheets[1] = { - ...workbook.Workbook.Sheets[1], - Hidden: 1, - }; - } + workbook.Workbook ??= {}; + workbook.Workbook.Sheets ??= []; + workbook.Workbook.Sheets[1] = { + ...workbook.Workbook.Sheets[1], + Hidden: 1, + }; const written = XLSX.write(workbook, { bookType, From 5641f3199a157fba3574ed6f73e4ae2fb6f3e5c1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 08:35:49 +0900 Subject: [PATCH 078/163] feat(spreadsheet): add pinned SheetJS runtime adapter --- src/spreadsheet/sheetJsRuntime.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 src/spreadsheet/sheetJsRuntime.ts diff --git a/src/spreadsheet/sheetJsRuntime.ts b/src/spreadsheet/sheetJsRuntime.ts new file mode 100644 index 00000000..85776c25 --- /dev/null +++ b/src/spreadsheet/sheetJsRuntime.ts @@ -0,0 +1,20 @@ +import type { SpreadsheetWorkbookData } from './spreadsheetImport.js'; +import { + sheetJsBytesToWorkbookData, + type SheetJsParserModule, +} from './sheetJsAdapter.js'; + +/** + * Parse supported local XLS/XLSX bytes through Inkspan's bounded SheetJS adapter. + * + * The parser package is loaded locally and receives no network, credential, + * persistence, model, transport, or editor mutation authority. Its untrusted + * materialized output still crosses the same descriptor-safe resource bounds as + * an injected parser module before it becomes parser-neutral workbook data. + */ +export async function parseSheetJsSpreadsheetBytes( + source: Uint8Array, +): Promise { + const parser = (await import('xlsx')) as unknown as SheetJsParserModule; + return sheetJsBytesToWorkbookData(source, parser); +} From 095458e5f5bd6d25cc4a1541c9b7ff36f53d99ad Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 08:36:02 +0900 Subject: [PATCH 079/163] feat(spreadsheet): export local SheetJS runtime --- src/spreadsheet/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/spreadsheet/index.ts b/src/spreadsheet/index.ts index f53890bb..86d033f2 100644 --- a/src/spreadsheet/index.ts +++ b/src/spreadsheet/index.ts @@ -1,2 +1,3 @@ /** Public package entry for deterministic, local spreadsheet conversion primitives. */ export * from './spreadsheetImport.js'; +export * from './sheetJsRuntime.js'; From ffa5488d9a48567c8eac5582b74d56b7115854aa Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 15:16:39 +0900 Subject: [PATCH 080/163] ci(spreadsheet): expose generated dependency blobs without moving refs --- .github/workflows/agent-workspace.yml | 33 +++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/.github/workflows/agent-workspace.yml b/.github/workflows/agent-workspace.yml index 6fbe1667..b2185250 100644 --- a/.github/workflows/agent-workspace.yml +++ b/.github/workflows/agent-workspace.yml @@ -6,7 +6,7 @@ on: - agent/318-spreadsheet-body-import permissions: - contents: read + contents: write concurrency: group: agent-workspace-${{ github.ref }} @@ -33,12 +33,41 @@ jobs: cache: pnpm - name: Install the immutable workspace run: pnpm install --frozen-lockfile - - name: Stage the exact official spreadsheet parser without lifecycle scripts + - name: Generate and verify the exact official spreadsheet dependency graph run: | set -euo pipefail pnpm add --save-exact --ignore-scripts https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz node --input-type=module -e "const XLSX=await import('xlsx'); if (XLSX.version !== '0.20.3') throw new Error('Unexpected SheetJS runtime version: '+XLSX.version);" grep -F 'cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz' pnpm-lock.yaml + changed="$(git diff --name-only)" + test "$changed" = $'package.json\npnpm-lock.yaml' || { printf 'Unexpected generated files:\n%s\n' "$changed" >&2; exit 1; } + - name: Upload generated files as dangling Git blobs only + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + upload_blob() { + local path="$1" + jq -n --rawfile content "$path" '{content:$content,encoding:"utf-8"}' \ + | gh api --method POST "repos/${GITHUB_REPOSITORY}/git/blobs" --input - --jq '.sha' + } + package_sha="$(upload_blob package.json)" + lock_sha="$(upload_blob pnpm-lock.yaml)" + jq -n \ + --arg source_head "${GITHUB_SHA}" \ + --arg package_sha "$package_sha" \ + --arg lock_sha "$lock_sha" \ + '{source_head:$source_head,package_json_blob:$package_sha,pnpm_lock_blob:$lock_sha}' \ + > "$RUNNER_TEMP/generated-dependency-blobs.json" + cat "$RUNNER_TEMP/generated-dependency-blobs.json" + - name: Upload generated blob identities + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: inkspan-generated-dependency-blobs-${{ github.sha }} + path: ${{ runner.temp }}/generated-dependency-blobs.json + if-no-files-found: error + retention-days: 1 + compression-level: 0 - name: Archive the exact source and installed dependency graph run: | set -euo pipefail From 99dcbb856ace188d71f65cc6ba2fcdae50f5f614 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 15:18:18 +0900 Subject: [PATCH 081/163] fix(spreadsheet): commit exact SheetJS dependency graph --- .github/workflows/agent-workspace.yml | 33 ++------------------------- package.json | 1 + pnpm-lock.yaml | 11 +++++++++ 3 files changed, 14 insertions(+), 31 deletions(-) diff --git a/.github/workflows/agent-workspace.yml b/.github/workflows/agent-workspace.yml index b2185250..6fbe1667 100644 --- a/.github/workflows/agent-workspace.yml +++ b/.github/workflows/agent-workspace.yml @@ -6,7 +6,7 @@ on: - agent/318-spreadsheet-body-import permissions: - contents: write + contents: read concurrency: group: agent-workspace-${{ github.ref }} @@ -33,41 +33,12 @@ jobs: cache: pnpm - name: Install the immutable workspace run: pnpm install --frozen-lockfile - - name: Generate and verify the exact official spreadsheet dependency graph + - name: Stage the exact official spreadsheet parser without lifecycle scripts run: | set -euo pipefail pnpm add --save-exact --ignore-scripts https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz node --input-type=module -e "const XLSX=await import('xlsx'); if (XLSX.version !== '0.20.3') throw new Error('Unexpected SheetJS runtime version: '+XLSX.version);" grep -F 'cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz' pnpm-lock.yaml - changed="$(git diff --name-only)" - test "$changed" = $'package.json\npnpm-lock.yaml' || { printf 'Unexpected generated files:\n%s\n' "$changed" >&2; exit 1; } - - name: Upload generated files as dangling Git blobs only - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - upload_blob() { - local path="$1" - jq -n --rawfile content "$path" '{content:$content,encoding:"utf-8"}' \ - | gh api --method POST "repos/${GITHUB_REPOSITORY}/git/blobs" --input - --jq '.sha' - } - package_sha="$(upload_blob package.json)" - lock_sha="$(upload_blob pnpm-lock.yaml)" - jq -n \ - --arg source_head "${GITHUB_SHA}" \ - --arg package_sha "$package_sha" \ - --arg lock_sha "$lock_sha" \ - '{source_head:$source_head,package_json_blob:$package_sha,pnpm_lock_blob:$lock_sha}' \ - > "$RUNNER_TEMP/generated-dependency-blobs.json" - cat "$RUNNER_TEMP/generated-dependency-blobs.json" - - name: Upload generated blob identities - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: inkspan-generated-dependency-blobs-${{ github.sha }} - path: ${{ runner.temp }}/generated-dependency-blobs.json - if-no-files-found: error - retention-days: 1 - compression-level: 0 - name: Archive the exact source and installed dependency graph run: | set -euo pipefail diff --git a/package.json b/package.json index 2068ddfa..1cbade53 100644 --- a/package.json +++ b/package.json @@ -144,6 +144,7 @@ "marked": "^15.0.6", "turndown": "^7.2.0", "turndown-plugin-gfm": "^1.0.2", + "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", "y-prosemirror": "^1.3.7", "yjs": "^13.6.30" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3a3d3e0e..ea7f0361 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -64,6 +64,9 @@ importers: turndown-plugin-gfm: specifier: ^1.0.2 version: 1.0.2 + xlsx: + specifier: https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz + version: https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz y-prosemirror: specifier: ^1.3.7 version: 1.3.7(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.1)(y-protocols@1.0.7(yjs@13.6.31))(yjs@13.6.31) @@ -2007,6 +2010,12 @@ packages: utf-8-validate: optional: true + xlsx@https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz: + resolution: {integrity: sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA==, tarball: https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz} + version: 0.20.3 + engines: {node: '>=0.8'} + hasBin: true + xml-name-validator@5.0.0: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} @@ -3903,6 +3912,8 @@ snapshots: ws@8.21.0: {} + xlsx@https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz: {} + xml-name-validator@5.0.0: {} xmlchars@2.2.0: {} From 4744d83a1fabf5bedce958e9dec7e8ebf7107acc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 15:19:23 +0900 Subject: [PATCH 082/163] chore(spreadsheet): remove temporary workspace generator --- .github/workflows/agent-workspace.yml | 59 --------------------------- 1 file changed, 59 deletions(-) delete mode 100644 .github/workflows/agent-workspace.yml diff --git a/.github/workflows/agent-workspace.yml b/.github/workflows/agent-workspace.yml deleted file mode 100644 index 6fbe1667..00000000 --- a/.github/workflows/agent-workspace.yml +++ /dev/null @@ -1,59 +0,0 @@ -name: Agent workspace snapshot - -on: - push: - branches: - - agent/318-spreadsheet-body-import - -permissions: - contents: read - -concurrency: - group: agent-workspace-${{ github.ref }} - cancel-in-progress: true - -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - -jobs: - snapshot: - runs-on: ubuntu-24.04 - steps: - - name: Check out the exact contributor head - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.sha }} - persist-credentials: false - - name: Set up pnpm - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 - - name: Set up Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: 22 - cache: pnpm - - name: Install the immutable workspace - run: pnpm install --frozen-lockfile - - name: Stage the exact official spreadsheet parser without lifecycle scripts - run: | - set -euo pipefail - pnpm add --save-exact --ignore-scripts https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz - node --input-type=module -e "const XLSX=await import('xlsx'); if (XLSX.version !== '0.20.3') throw new Error('Unexpected SheetJS runtime version: '+XLSX.version);" - grep -F 'cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz' pnpm-lock.yaml - - name: Archive the exact source and installed dependency graph - run: | - set -euo pipefail - tar \ - --exclude=./.git \ - --exclude=./coverage \ - --exclude=./dist \ - --exclude=./office/.coverage \ - --exclude=./office/dist \ - -czf "$RUNNER_TEMP/inkspan-agent-workspace.tgz" . - - name: Upload the bounded read-only workspace snapshot - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: inkspan-agent-workspace-${{ github.sha }} - path: ${{ runner.temp }}/inkspan-agent-workspace.tgz - if-no-files-found: error - retention-days: 1 - compression-level: 0 From d81824fd7884a91be59a665f2967121b702d3435 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 16:15:23 +0900 Subject: [PATCH 083/163] fix(spreadsheet): verify package-owned lazy parser chunk --- .../verify-spreadsheet-subpath-package.mjs | 68 ++++++++++++++----- 1 file changed, 51 insertions(+), 17 deletions(-) diff --git a/scripts/verify-spreadsheet-subpath-package.mjs b/scripts/verify-spreadsheet-subpath-package.mjs index 9285ef6d..fe53ed3d 100644 --- a/scripts/verify-spreadsheet-subpath-package.mjs +++ b/scripts/verify-spreadsheet-subpath-package.mjs @@ -65,28 +65,62 @@ function preparePackage() { ); } -function verifyAuthorityFreeBundles() { +function assertAuthorityBoundedSource(source, filename) { + assert.doesNotMatch( + source, + ambientAuthorityPattern, + `${filename} must not reference ambient network or credential authority`, + ); + assert.doesNotMatch( + source, + forbiddenProductGraphPattern, + `${filename} must not embed React, Yjs, CWL host, or model authority`, + ); +} + +function verifyOwnedLazyParserChunk(filename, findings) { + assert.equal( + findings.length, + 1, + `${filename} must contain only its single package-owned lazy parser edge`, + ); + const [finding] = findings; + assert.ok( + finding.kind === 'dynamic-import' || finding.kind === 'commonjs-require', + `${filename} lazy parser edge must remain an explicit import or require`, + ); + const extension = filename.endsWith('.cjs') ? 'cjs' : 'js'; + assert.match( + finding.specifier ?? '', + new RegExp(`^\\./xlsx-[A-Za-z0-9_-]+\\.${extension}$`, 'u'), + `${filename} may lazy-load only its emitted package-owned SheetJS chunk`, + ); + + const chunkFilename = finding.specifier.slice(2); + const chunkPath = join(packageDirectory, 'dist', chunkFilename); + assert.ok( + existsSync(chunkPath), + `${filename} lazy parser chunk must be present in the packed artifact`, + ); + const chunkSource = readFileSync(chunkPath, 'utf8'); + assert.deepEqual( + findRuntimeModuleAuthority(chunkSource, chunkFilename), + [], + `${chunkFilename} must not delegate further executable module authority`, + ); + assertAuthorityBoundedSource(chunkSource, chunkFilename); +} + +function verifyAuthorityBoundedBundles() { assert.ok(existsSync(join(packageDirectory, 'dist', 'spreadsheet', 'index.d.ts'))); for (const filename of ['cwl-spreadsheet.js', 'cwl-spreadsheet.cjs']) { const bundleSource = readFileSync( join(packageDirectory, 'dist', filename), 'utf8', ); - assert.deepEqual( - findRuntimeModuleAuthority(bundleSource, filename), - [], - `${filename} must not contain executable runtime module authority`, - ); - assert.doesNotMatch( - bundleSource, - ambientAuthorityPattern, - `${filename} must not reference ambient network or credential authority`, - ); - assert.doesNotMatch( - bundleSource, - forbiddenProductGraphPattern, - `${filename} must not embed React, Yjs, CWL host, or model authority`, - ); + const findings = findRuntimeModuleAuthority(bundleSource, filename); + verifyOwnedLazyParserChunk(filename, findings); + assertAuthorityBoundedSource(bundleSource, filename); } } @@ -133,7 +167,7 @@ assert.throws( try { preparePackage(); - verifyAuthorityFreeBundles(); + verifyAuthorityBoundedBundles(); verifyRuntimeConsumers(); console.log( `Verified packed ${packageJson.name}/spreadsheet through authority-bounded ESM and CommonJS consumers.`, From b8d67ba949772838cc96dc1c7a091d277a5712cd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 18:06:02 +0900 Subject: [PATCH 084/163] test(spreadsheet): define local file import boundary --- src/spreadsheet/sheetJsFileImport.test.ts | 83 +++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 src/spreadsheet/sheetJsFileImport.test.ts diff --git a/src/spreadsheet/sheetJsFileImport.test.ts b/src/spreadsheet/sheetJsFileImport.test.ts new file mode 100644 index 00000000..5ebe1b63 --- /dev/null +++ b/src/spreadsheet/sheetJsFileImport.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it, vi } from 'vitest'; +import * as XLSX from 'xlsx'; +import { + SpreadsheetImportError, + spreadsheetFileToDocumentJson, + type SpreadsheetFileSource, +} from './index.js'; + +function sourceFromBytes(bytes: Uint8Array): SpreadsheetFileSource { + return { + size: bytes.byteLength, + async arrayBuffer() { + return bytes.buffer.slice( + bytes.byteOffset, + bytes.byteOffset + bytes.byteLength, + ) as ArrayBuffer; + }, + }; +} + +function xlsxBytes(): Uint8Array { + const workbook = XLSX.utils.book_new(); + const worksheet = XLSX.utils.aoa_to_sheet([ + ['Name', 'Value'], + ['Revenue', 42], + ]); + XLSX.utils.book_append_sheet(workbook, worksheet, 'Summary'); + return XLSX.write(workbook, { type: 'array', bookType: 'xlsx' }) as Uint8Array; +} + +describe('spreadsheetFileToDocumentJson', () => { + it('converts a real local XLSX source into bounded editable TipTap content', async () => { + const result = await spreadsheetFileToDocumentJson( + sourceFromBytes(xlsxBytes()), + ); + + expect(result).toMatchObject({ + worksheetCount: 1, + rowCount: 2, + cellCount: 4, + }); + expect(result.content.map((node) => node.type)).toEqual([ + 'heading', + 'table', + 'paragraph', + ]); + expect(result.content[0]).toEqual({ + type: 'heading', + attrs: { level: 3 }, + content: [{ type: 'text', text: 'Summary' }], + }); + }); + + it('rejects an oversized source before reading its bytes', async () => { + const arrayBuffer = vi.fn(async () => new ArrayBuffer(0)); + const source: SpreadsheetFileSource = { + size: 64 * 1024 * 1024 + 1, + arrayBuffer, + }; + + await expect(spreadsheetFileToDocumentJson(source)).rejects.toMatchObject({ + name: 'SpreadsheetImportError', + code: 'RESOURCE_LIMIT_EXCEEDED', + message: 'Spreadsheet exceeds the configured resource limits.', + } satisfies Partial); + expect(arrayBuffer).not.toHaveBeenCalled(); + }); + + it('normalizes unreadable local sources without leaking parser payload details', async () => { + const source: SpreadsheetFileSource = { + size: 4, + async arrayBuffer() { + throw new Error('private local path and workbook payload'); + }, + }; + + await expect(spreadsheetFileToDocumentJson(source)).rejects.toMatchObject({ + name: 'SpreadsheetImportError', + code: 'UNSUPPORTED_OR_CORRUPT', + message: 'Spreadsheet source is unsupported or corrupt.', + } satisfies Partial); + }); +}); From 3609619c35c542630a0b339ca2c6e0e993cfd727 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 18:06:36 +0900 Subject: [PATCH 085/163] feat(spreadsheet): add bounded local file import bridge --- src/spreadsheet/sheetJsRuntime.ts | 75 ++++++++++++++++++++++++++++++- 1 file changed, 74 insertions(+), 1 deletion(-) diff --git a/src/spreadsheet/sheetJsRuntime.ts b/src/spreadsheet/sheetJsRuntime.ts index 85776c25..af94c967 100644 --- a/src/spreadsheet/sheetJsRuntime.ts +++ b/src/spreadsheet/sheetJsRuntime.ts @@ -1,9 +1,38 @@ -import type { SpreadsheetWorkbookData } from './spreadsheetImport.js'; +import { + SpreadsheetImportError, + spreadsheetWorkbookToDocumentJson, + type SpreadsheetImportResult, + type SpreadsheetWorkbookData, +} from './spreadsheetImport.js'; import { sheetJsBytesToWorkbookData, type SheetJsParserModule, } from './sheetJsAdapter.js'; +const MAX_SPREADSHEET_SOURCE_BYTES = 64 * 1024 * 1024; + +/** Minimal browser-file contract needed by the local spreadsheet import boundary. */ +export interface SpreadsheetFileSource { + /** Byte length available before allocating and reading the file body. */ + readonly size: number; + /** Read the local file body without granting any path, network, or persistence authority. */ + arrayBuffer(): Promise; +} + +function resourceLimitExceeded(): SpreadsheetImportError { + return new SpreadsheetImportError( + 'RESOURCE_LIMIT_EXCEEDED', + 'Spreadsheet exceeds the configured resource limits.', + ); +} + +function unsupportedOrCorruptSource(): SpreadsheetImportError { + return new SpreadsheetImportError( + 'UNSUPPORTED_OR_CORRUPT', + 'Spreadsheet source is unsupported or corrupt.', + ); +} + /** * Parse supported local XLS/XLSX bytes through Inkspan's bounded SheetJS adapter. * @@ -18,3 +47,47 @@ export async function parseSheetJsSpreadsheetBytes( const parser = (await import('xlsx')) as unknown as SheetJsParserModule; return sheetJsBytesToWorkbookData(source, parser); } + +/** + * Read one local browser file and convert its visible worksheets to inert TipTap JSON. + * + * Source size is checked before `arrayBuffer()` so oversized user-selected files are + * rejected before a proportional allocation. Read failures and malformed source + * identities are normalized to the stable payload-redacted import error contract. + */ +export async function spreadsheetFileToDocumentJson( + source: SpreadsheetFileSource, +): Promise { + let sourceSize: number; + try { + sourceSize = source.size; + } catch { + throw unsupportedOrCorruptSource(); + } + + if (!Number.isSafeInteger(sourceSize) || sourceSize < 0) { + throw unsupportedOrCorruptSource(); + } + if (sourceSize > MAX_SPREADSHEET_SOURCE_BYTES) { + throw resourceLimitExceeded(); + } + + let buffer: ArrayBuffer; + try { + buffer = await source.arrayBuffer(); + } catch { + throw unsupportedOrCorruptSource(); + } + if (!(buffer instanceof ArrayBuffer)) { + throw unsupportedOrCorruptSource(); + } + if (buffer.byteLength > MAX_SPREADSHEET_SOURCE_BYTES) { + throw resourceLimitExceeded(); + } + if (buffer.byteLength !== sourceSize) { + throw unsupportedOrCorruptSource(); + } + + const workbook = await parseSheetJsSpreadsheetBytes(new Uint8Array(buffer)); + return spreadsheetWorkbookToDocumentJson(workbook); +} From bc9dbf9cf153b7f914cafb954fe9f8d197a960eb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 18:08:16 +0900 Subject: [PATCH 086/163] test(spreadsheet): cover hostile local file identities --- src/spreadsheet/sheetJsFileImport.test.ts | 60 ++++++++++++++++++++--- 1 file changed, 54 insertions(+), 6 deletions(-) diff --git a/src/spreadsheet/sheetJsFileImport.test.ts b/src/spreadsheet/sheetJsFileImport.test.ts index 5ebe1b63..3237e51c 100644 --- a/src/spreadsheet/sheetJsFileImport.test.ts +++ b/src/spreadsheet/sheetJsFileImport.test.ts @@ -28,6 +28,14 @@ function xlsxBytes(): Uint8Array { return XLSX.write(workbook, { type: 'array', bookType: 'xlsx' }) as Uint8Array; } +function expectUnsupported(promise: Promise) { + return expect(promise).rejects.toMatchObject({ + name: 'SpreadsheetImportError', + code: 'UNSUPPORTED_OR_CORRUPT', + message: 'Spreadsheet source is unsupported or corrupt.', + } satisfies Partial); +} + describe('spreadsheetFileToDocumentJson', () => { it('converts a real local XLSX source into bounded editable TipTap content', async () => { const result = await spreadsheetFileToDocumentJson( @@ -66,7 +74,29 @@ describe('spreadsheetFileToDocumentJson', () => { expect(arrayBuffer).not.toHaveBeenCalled(); }); - it('normalizes unreadable local sources without leaking parser payload details', async () => { + it.each([-1, 1.5])('rejects an invalid declared source size %s', async (size) => { + const arrayBuffer = vi.fn(async () => new ArrayBuffer(0)); + await expectUnsupported( + spreadsheetFileToDocumentJson({ size, arrayBuffer }), + ); + expect(arrayBuffer).not.toHaveBeenCalled(); + }); + + it('normalizes an unreadable source-size accessor', async () => { + const source = { + arrayBuffer: vi.fn(async () => new ArrayBuffer(0)), + } as unknown as SpreadsheetFileSource; + Object.defineProperty(source, 'size', { + get() { + throw new Error('private local path'); + }, + }); + + await expectUnsupported(spreadsheetFileToDocumentJson(source)); + expect(source.arrayBuffer).not.toHaveBeenCalled(); + }); + + it('normalizes unreadable local bytes without leaking parser payload details', async () => { const source: SpreadsheetFileSource = { size: 4, async arrayBuffer() { @@ -74,10 +104,28 @@ describe('spreadsheetFileToDocumentJson', () => { }, }; - await expect(spreadsheetFileToDocumentJson(source)).rejects.toMatchObject({ - name: 'SpreadsheetImportError', - code: 'UNSUPPORTED_OR_CORRUPT', - message: 'Spreadsheet source is unsupported or corrupt.', - } satisfies Partial); + await expectUnsupported(spreadsheetFileToDocumentJson(source)); + }); + + it('rejects a non-ArrayBuffer body from a hostile source adapter', async () => { + const source = { + size: 4, + async arrayBuffer() { + return 'not bytes'; + }, + } as unknown as SpreadsheetFileSource; + + await expectUnsupported(spreadsheetFileToDocumentJson(source)); + }); + + it('rejects a source whose declared size changes at the byte boundary', async () => { + const source: SpreadsheetFileSource = { + size: 4, + async arrayBuffer() { + return new ArrayBuffer(5); + }, + }; + + await expectUnsupported(spreadsheetFileToDocumentJson(source)); }); }); From ba36b8376207d0a6585445091917e54d7fc72a5d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 18:08:34 +0900 Subject: [PATCH 087/163] fix(spreadsheet): bind local bytes to declared file size --- src/spreadsheet/sheetJsRuntime.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/spreadsheet/sheetJsRuntime.ts b/src/spreadsheet/sheetJsRuntime.ts index af94c967..1f77e3a6 100644 --- a/src/spreadsheet/sheetJsRuntime.ts +++ b/src/spreadsheet/sheetJsRuntime.ts @@ -78,13 +78,7 @@ export async function spreadsheetFileToDocumentJson( } catch { throw unsupportedOrCorruptSource(); } - if (!(buffer instanceof ArrayBuffer)) { - throw unsupportedOrCorruptSource(); - } - if (buffer.byteLength > MAX_SPREADSHEET_SOURCE_BYTES) { - throw resourceLimitExceeded(); - } - if (buffer.byteLength !== sourceSize) { + if (!(buffer instanceof ArrayBuffer) || buffer.byteLength !== sourceSize) { throw unsupportedOrCorruptSource(); } From 76700fe0d842895b0f5ddfec4155c07a29ba018c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 18:09:36 +0900 Subject: [PATCH 088/163] test(toolbar): define accessible spreadsheet insertion --- .../Toolbar.spreadsheetImport.test.tsx | 197 ++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 src/components/Toolbar.spreadsheetImport.test.tsx diff --git a/src/components/Toolbar.spreadsheetImport.test.tsx b/src/components/Toolbar.spreadsheetImport.test.tsx new file mode 100644 index 00000000..742030ab --- /dev/null +++ b/src/components/Toolbar.spreadsheetImport.test.tsx @@ -0,0 +1,197 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { Editor } from '@tiptap/react'; +import { buildExtensions } from '../extensions/kit.js'; + +const spreadsheetMocks = vi.hoisted(() => ({ + importFile: vi.fn(), +})); + +vi.mock('../spreadsheet/index.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + spreadsheetFileToDocumentJson: spreadsheetMocks.importFile, + }; +}); + +import { Toolbar } from './Toolbar.js'; + +const openEditors: Editor[] = []; + +function makeEditor(content = '

Before

After

'): Editor { + const element = document.createElement('div'); + document.body.appendChild(element); + const editor = new Editor({ + element, + extensions: buildExtensions({ image: { maxDimension: 0 } }), + content, + }); + openEditors.push(editor); + return editor; +} + +function spreadsheetInput(): HTMLInputElement { + return document.querySelector( + 'input[data-cwl-spreadsheet-input="true"]', + ) as HTMLInputElement; +} + +function spreadsheetFile(): File { + return new File([new Uint8Array([0x50, 0x4b, 0x03, 0x04])], 'book.xlsx', { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }); +} + +function importedResult() { + return { + worksheetCount: 1, + rowCount: 1, + cellCount: 1, + content: [ + { + type: 'heading', + attrs: { level: 3 }, + content: [{ type: 'text', text: 'Summary' }], + }, + { + type: 'table', + content: [ + { + type: 'tableRow', + content: [ + { + type: 'tableCell', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: '42' }], + }, + ], + }, + ], + }, + ], + }, + { type: 'paragraph' }, + ], + } as const; +} + +afterEach(() => { + cleanup(); + spreadsheetMocks.importFile.mockReset(); + for (const editor of openEditors.splice(0)) { + if (!editor.isDestroyed) editor.destroy(); + } +}); + +describe('Toolbar spreadsheet import', () => { + it('exposes a keyboard toolbar control and the exact local XLS/XLSX picker contract', async () => { + const editor = makeEditor(); + render(); + + const button = screen.getByRole('button', { + name: 'Insert XLS/XLSX spreadsheet', + }); + expect(button).not.toBeDisabled(); + + const input = spreadsheetInput(); + expect(input).toHaveAttribute( + 'accept', + '.xls,.xlsx,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ); + expect(input).toHaveAttribute('hidden'); + + fireEvent.focus(button); + fireEvent.keyDown(button, { key: 'ArrowRight' }); + expect(document.activeElement).not.toBe(button); + }); + + it('inserts one validated JSON batch at the active selection and remains normally undoable', async () => { + spreadsheetMocks.importFile.mockResolvedValue(importedResult()); + const editor = makeEditor(); + editor.commands.setTextSelection(7); + const insertContent = vi.spyOn(editor.commands, 'insertContent'); + render(); + + const input = spreadsheetInput(); + fireEvent.change(input, { target: { files: [spreadsheetFile()] } }); + + await waitFor(() => + expect(screen.getByRole('status')).toHaveTextContent( + 'Imported 1 worksheet, 1 row, 1 cell.', + ), + ); + expect(spreadsheetMocks.importFile).toHaveBeenCalledTimes(1); + expect(insertContent).toHaveBeenCalledTimes(1); + expect(editor.getHTML()).toContain('Summary'); + expect(editor.getHTML()).toContain(' editor.commands.undo()); + expect(editor.getHTML()).not.toContain('Summary'); + expect(editor.getHTML()).not.toContain(' { + let resolveImport: ((result: ReturnType) => void) | undefined; + spreadsheetMocks.importFile.mockImplementation( + () => + new Promise((resolve) => { + resolveImport = resolve; + }), + ); + const editor = makeEditor(); + render(); + + const button = screen.getByRole('button', { + name: 'Insert XLS/XLSX spreadsheet', + }); + const input = spreadsheetInput(); + const file = spreadsheetFile(); + fireEvent.change(input, { target: { files: [file] } }); + + await waitFor(() => expect(button).toBeDisabled()); + expect(screen.getByRole('status')).toHaveTextContent('Importing spreadsheet…'); + expect(input.value).toBe(''); + + await act(async () => resolveImport?.(importedResult())); + await waitFor(() => expect(button).not.toBeDisabled()); + + spreadsheetMocks.importFile.mockResolvedValueOnce(importedResult()); + fireEvent.change(input, { target: { files: [file] } }); + await waitFor(() => expect(spreadsheetMocks.importFile).toHaveBeenCalledTimes(2)); + }); + + it('announces a stable payload-redacted failure and leaves the document unchanged', async () => { + spreadsheetMocks.importFile.mockRejectedValue( + new Error('secret workbook cell and local filesystem path'), + ); + const editor = makeEditor(); + const before = editor.getJSON(); + render(); + + fireEvent.change(spreadsheetInput(), { + target: { files: [spreadsheetFile()] }, + }); + + await waitFor(() => + expect(screen.getByRole('status')).toHaveTextContent( + 'Spreadsheet import failed.', + ), + ); + expect(screen.getByRole('status')).not.toHaveTextContent('secret workbook'); + expect(editor.getJSON()).toEqual(before); + }); + + it('ignores a picker change with no selected file', async () => { + const editor = makeEditor(); + render(); + + fireEvent.change(spreadsheetInput(), { target: { files: [] } }); + + expect(spreadsheetMocks.importFile).not.toHaveBeenCalled(); + expect(screen.getByRole('status')).toHaveTextContent(''); + }); +}); From 302ae5df03a9e6cad1a397f242cfccef3915a5ac Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 18:10:32 +0900 Subject: [PATCH 089/163] feat(toolbar): insert local XLS and XLSX worksheets --- src/components/Toolbar.tsx | 80 +++++++++++++++++++++++++++++++++++--- 1 file changed, 75 insertions(+), 5 deletions(-) diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index 55136e55..9233d30d 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -4,11 +4,13 @@ import { useEffect, useReducer, useRef, + useState, type ChangeEvent, type FocusEvent, type KeyboardEvent, } from 'react'; import { imageFileToInlineDataUri } from '../extensions/Base64Image.js'; +import { spreadsheetFileToDocumentJson } from '../spreadsheet/index.js'; import type { ImageConfig } from '../types.js'; interface ToolbarProps { @@ -16,6 +18,8 @@ interface ToolbarProps { image?: ImageConfig; /** Forwarded from {@link CwlEditor} — image failures must reach the host. */ onImageError?: (error: unknown) => void; + /** Optional host observer for payload-redacted spreadsheet import failures. */ + onSpreadsheetError?: (error: unknown) => void; } interface ButtonProps { @@ -29,6 +33,8 @@ interface ButtonProps { } const TOOLBAR_ITEM_SELECTOR = 'button[data-cwl-toolbar-item="true"]'; +const SPREADSHEET_ACCEPT = + '.xls,.xlsx,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; /** Return every toolbar button in visual and DOM navigation order. */ function getToolbarButtons(toolbar: HTMLDivElement): HTMLButtonElement[] { @@ -77,7 +83,8 @@ function ToolbarButton({ /** * Commercial-grade toolbar covering the common rich-text affordances: * marks, headings, lists, code, quote, link, horizontal rule, table insert + - * edit, inline-base64 image upload, and image alternative-text authoring. + * edit, inline-base64 image upload, local spreadsheet insertion, and image + * alternative-text authoring. * * The toolbar follows the WAI-ARIA composite-toolbar keyboard model: it is one * tab stop, Left/Right arrows move between enabled controls with wrapping, and @@ -86,10 +93,18 @@ function ToolbarButton({ * already implemented by the editor are exposed with `aria-keyshortcuts` so * assistive technology receives the same cross-platform commands as tooltips. */ -export function Toolbar({ editor, image, onImageError }: ToolbarProps) { - const fileInputRef = useRef(null); +export function Toolbar({ + editor, + image, + onImageError, + onSpreadsheetError, +}: ToolbarProps) { + const imageFileInputRef = useRef(null); + const spreadsheetFileInputRef = useRef(null); const toolbarRef = useRef(null); const lastFocusedButtonRef = useRef(null); + const [spreadsheetBusy, setSpreadsheetBusy] = useState(false); + const [spreadsheetStatus, setSpreadsheetStatus] = useState(''); // Re-render on every transaction so active/disabled states (marks, image and // table selection, undo/redo) stay in sync without host re-renders. const [, bump] = useReducer((n: number) => n + 1, 0); @@ -226,6 +241,37 @@ export function Toolbar({ editor, image, onImageError }: ToolbarProps) { [editor, image, onImageError], ); + const onPickSpreadsheet = useCallback( + async (event: ChangeEvent) => { + const file = event.target.files?.[0]; + event.target.value = ''; + if (!file || spreadsheetBusy) return; + + setSpreadsheetBusy(true); + setSpreadsheetStatus('Importing spreadsheet…'); + try { + const result = await spreadsheetFileToDocumentJson(file); + if (result.content.length > 0) { + const inserted = editor + .chain() + .focus() + .insertContent(result.content) + .run(); + if (!inserted) throw new Error('Spreadsheet insertion was rejected.'); + } + setSpreadsheetStatus( + `Imported ${result.worksheetCount} worksheets, ${result.rowCount} rows, ${result.cellCount} cells.`, + ); + } catch (error) { + onSpreadsheetError?.(error); + setSpreadsheetStatus('Spreadsheet import failed.'); + } finally { + setSpreadsheetBusy(false); + } + }, + [editor, onSpreadsheetError, spreadsheetBusy], + ); + return (
fileInputRef.current?.click()} + onClick={() => imageFileInputRef.current?.click()} + /> + spreadsheetFileInputRef.current?.click()} /> +
@@ -403,6 +464,15 @@ export function Toolbar({ editor, image, onImageError }: ToolbarProps) { onClick={() => editor.chain().focus().redo().run()} />
+ + + {spreadsheetStatus} + ); } From 9f1c8b732e9b91f152cfff2e9277b758c0959c4a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 18:11:26 +0900 Subject: [PATCH 090/163] test(toolbar): account for spreadsheet control --- src/components/Toolbar.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/Toolbar.test.tsx b/src/components/Toolbar.test.tsx index aeaf6895..fdab45dc 100644 --- a/src/components/Toolbar.test.tsx +++ b/src/components/Toolbar.test.tsx @@ -70,8 +70,8 @@ describe('Toolbar', () => { const buttons = screen.getAllByRole('button'); // Marks(4) + headings(3) + lists/quote/code/hr(5) + - // link/table/col/row/delCol/delRow/delTable/image/imageAlt(9) + history(2) = 23. - expect(buttons.length).toBe(23); + // link/table/col/row/delCol/delRow/delTable/image/spreadsheet/imageAlt(10) + history(2) = 24. + expect(buttons.length).toBe(24); // Cover onMouseDown preventDefault + every onClick handler. for (const button of buttons) { fireEvent.mouseDown(button); From 9117a98fd125f2ea0f7d53e42b802d538143994b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 19:06:21 +0900 Subject: [PATCH 091/163] test(spreadsheet): normalize real SheetJS array fixture --- src/spreadsheet/sheetJsFileImport.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/spreadsheet/sheetJsFileImport.test.ts b/src/spreadsheet/sheetJsFileImport.test.ts index 3237e51c..46844b1d 100644 --- a/src/spreadsheet/sheetJsFileImport.test.ts +++ b/src/spreadsheet/sheetJsFileImport.test.ts @@ -25,7 +25,10 @@ function xlsxBytes(): Uint8Array { ['Revenue', 42], ]); XLSX.utils.book_append_sheet(workbook, worksheet, 'Summary'); - return XLSX.write(workbook, { type: 'array', bookType: 'xlsx' }) as Uint8Array; + const serialized = XLSX.write(workbook, { type: 'array', bookType: 'xlsx' }); + return serialized instanceof Uint8Array + ? serialized + : new Uint8Array(serialized as ArrayBuffer); } function expectUnsupported(promise: Promise) { From dcf9f6f4abdaaacd25bcb638412a7ee772b90ca4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 19:11:43 +0900 Subject: [PATCH 092/163] fix(editor): make spreadsheet status precise and non-conflicting --- src/components/Toolbar.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index 9233d30d..feb8c478 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -260,7 +260,7 @@ export function Toolbar({ if (!inserted) throw new Error('Spreadsheet insertion was rejected.'); } setSpreadsheetStatus( - `Imported ${result.worksheetCount} worksheets, ${result.rowCount} rows, ${result.cellCount} cells.`, + `Imported ${result.worksheetCount} ${result.worksheetCount === 1 ? 'worksheet' : 'worksheets'}, ${result.rowCount} ${result.rowCount === 1 ? 'row' : 'rows'}, and ${result.cellCount} ${result.cellCount === 1 ? 'cell' : 'cells'}.`, ); } catch (error) { onSpreadsheetError?.(error); @@ -467,7 +467,7 @@ export function Toolbar({ From fec40f451029152aa7d724705d53ccee9f657366 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 19:17:22 +0900 Subject: [PATCH 093/163] test(editor): align spreadsheet status assertions with live-region contract --- src/components/Toolbar.spreadsheetImport.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/Toolbar.spreadsheetImport.test.tsx b/src/components/Toolbar.spreadsheetImport.test.tsx index 742030ab..4e6c1122 100644 --- a/src/components/Toolbar.spreadsheetImport.test.tsx +++ b/src/components/Toolbar.spreadsheetImport.test.tsx @@ -120,7 +120,7 @@ describe('Toolbar spreadsheet import', () => { await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent( - 'Imported 1 worksheet, 1 row, 1 cell.', + 'Imported 1 worksheet, 1 row, and 1 cell.', ), ); expect(spreadsheetMocks.importFile).toHaveBeenCalledTimes(1); @@ -192,6 +192,6 @@ describe('Toolbar spreadsheet import', () => { fireEvent.change(spreadsheetInput(), { target: { files: [] } }); expect(spreadsheetMocks.importFile).not.toHaveBeenCalled(); - expect(screen.getByRole('status')).toHaveTextContent(''); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); }); }); From 4daf83bbd9dbf0df9f619b4919710a8445a9fa0f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 19:18:17 +0900 Subject: [PATCH 094/163] test(spreadsheet): enforce direct parser source-byte ceiling --- src/spreadsheet/sheetJsRuntime.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/spreadsheet/sheetJsRuntime.test.ts b/src/spreadsheet/sheetJsRuntime.test.ts index d94921f5..d9380bd4 100644 --- a/src/spreadsheet/sheetJsRuntime.test.ts +++ b/src/spreadsheet/sheetJsRuntime.test.ts @@ -54,4 +54,14 @@ describe('real SheetJS spreadsheet runtime', () => { ], }); }); + + it('rejects direct parser input above the public source-byte ceiling', async () => { + const oversized = new Uint8Array(64 * 1024 * 1024 + 1); + + await expect(parseSheetJsSpreadsheetBytes(oversized)).rejects.toMatchObject({ + name: 'SpreadsheetImportError', + code: 'RESOURCE_LIMIT_EXCEEDED', + message: 'Spreadsheet exceeds the configured resource limits.', + }); + }); }); From 109983176b9a9bbaf3e78f07c318697aa64796b7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 19:18:39 +0900 Subject: [PATCH 095/163] fix(spreadsheet): bound the public byte-array parser entry --- src/spreadsheet/sheetJsRuntime.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/spreadsheet/sheetJsRuntime.ts b/src/spreadsheet/sheetJsRuntime.ts index 1f77e3a6..01479bfa 100644 --- a/src/spreadsheet/sheetJsRuntime.ts +++ b/src/spreadsheet/sheetJsRuntime.ts @@ -36,14 +36,20 @@ function unsupportedOrCorruptSource(): SpreadsheetImportError { /** * Parse supported local XLS/XLSX bytes through Inkspan's bounded SheetJS adapter. * - * The parser package is loaded locally and receives no network, credential, - * persistence, model, transport, or editor mutation authority. Its untrusted - * materialized output still crosses the same descriptor-safe resource bounds as - * an injected parser module before it becomes parser-neutral workbook data. + * The public byte-array entry point enforces the same source ceiling as the + * browser-file boundary before the parser module is loaded. The parser package + * is loaded locally and receives no network, credential, persistence, model, + * transport, or editor mutation authority. Its untrusted materialized output + * still crosses the same descriptor-safe resource bounds as an injected parser + * module before it becomes parser-neutral workbook data. */ export async function parseSheetJsSpreadsheetBytes( source: Uint8Array, ): Promise { + if (source.byteLength > MAX_SPREADSHEET_SOURCE_BYTES) { + throw resourceLimitExceeded(); + } + const parser = (await import('xlsx')) as unknown as SheetJsParserModule; return sheetJsBytesToWorkbookData(source, parser); } From ef26cf60635dd44bccbb4efb4b548532e20c3223 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 20:07:45 +0900 Subject: [PATCH 096/163] test(spreadsheet): assert atomic insertion at editor boundary --- .../Toolbar.spreadsheetImport.test.tsx | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/src/components/Toolbar.spreadsheetImport.test.tsx b/src/components/Toolbar.spreadsheetImport.test.tsx index 4e6c1122..477bb2c4 100644 --- a/src/components/Toolbar.spreadsheetImport.test.tsx +++ b/src/components/Toolbar.spreadsheetImport.test.tsx @@ -112,9 +112,15 @@ describe('Toolbar spreadsheet import', () => { spreadsheetMocks.importFile.mockResolvedValue(importedResult()); const editor = makeEditor(); editor.commands.setTextSelection(7); - const insertContent = vi.spyOn(editor.commands, 'insertContent'); + const before = editor.getJSON(); render(); + let docChangingTransactions = 0; + const countDocChanges = ({ transaction }: { transaction: { docChanged: boolean } }) => { + if (transaction.docChanged) docChangingTransactions += 1; + }; + editor.on('transaction', countDocChanges); + const input = spreadsheetInput(); fireEvent.change(input, { target: { files: [spreadsheetFile()] } }); @@ -124,14 +130,16 @@ describe('Toolbar spreadsheet import', () => { ), ); expect(spreadsheetMocks.importFile).toHaveBeenCalledTimes(1); - expect(insertContent).toHaveBeenCalledTimes(1); - expect(editor.getHTML()).toContain('Summary'); - expect(editor.getHTML()).toContain(' editor.commands.undo()); - expect(editor.getHTML()).not.toContain('Summary'); - expect(editor.getHTML()).not.toContain(' { @@ -194,4 +202,4 @@ describe('Toolbar spreadsheet import', () => { expect(spreadsheetMocks.importFile).not.toHaveBeenCalled(); expect(screen.queryByRole('status')).not.toBeInTheDocument(); }); -}); +}); \ No newline at end of file From ea216cace09139439af800375df4633a868451db Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 20:12:00 +0900 Subject: [PATCH 097/163] test(spreadsheet): close toolbar import coverage gaps --- .../Toolbar.spreadsheetImport.test.tsx | 83 +++++++++++++++++-- 1 file changed, 77 insertions(+), 6 deletions(-) diff --git a/src/components/Toolbar.spreadsheetImport.test.tsx b/src/components/Toolbar.spreadsheetImport.test.tsx index 477bb2c4..23af8692 100644 --- a/src/components/Toolbar.spreadsheetImport.test.tsx +++ b/src/components/Toolbar.spreadsheetImport.test.tsx @@ -142,7 +142,69 @@ describe('Toolbar spreadsheet import', () => { expect(editor.getJSON()).toEqual(before); }); - it('disables import while parsing and permits the same file to be selected again', async () => { + it('reports plural zero counts without mutating the document when every sheet is filtered out', async () => { + spreadsheetMocks.importFile.mockResolvedValue({ + worksheetCount: 0, + rowCount: 0, + cellCount: 0, + content: [], + }); + const editor = makeEditor(); + const before = editor.getJSON(); + render(); + + fireEvent.change(spreadsheetInput(), { + target: { files: [spreadsheetFile()] }, + }); + + await waitFor(() => + expect(screen.getByRole('status')).toHaveTextContent( + 'Imported 0 worksheets, 0 rows, and 0 cells.', + ), + ); + expect(editor.getJSON()).toEqual(before); + }); + + it('fails closed and notifies the host when the editor rejects the insertion transaction', async () => { + spreadsheetMocks.importFile.mockResolvedValue(importedResult()); + const editor = makeEditor(); + const before = editor.getJSON(); + const onSpreadsheetError = vi.fn(); + const rejectedChain = { + focus: vi.fn(), + insertContent: vi.fn(), + run: vi.fn(() => false), + }; + rejectedChain.focus.mockReturnValue(rejectedChain); + rejectedChain.insertContent.mockReturnValue(rejectedChain); + vi.spyOn(editor, 'chain').mockReturnValue( + rejectedChain as unknown as ReturnType, + ); + render( + , + ); + + fireEvent.change(spreadsheetInput(), { + target: { files: [spreadsheetFile()] }, + }); + + await waitFor(() => + expect(screen.getByRole('status')).toHaveTextContent( + 'Spreadsheet import failed.', + ), + ); + expect(rejectedChain.run).toHaveBeenCalledTimes(1); + expect(onSpreadsheetError).toHaveBeenCalledTimes(1); + expect(onSpreadsheetError.mock.calls[0]?.[0]).toMatchObject({ + message: 'Spreadsheet insertion was rejected.', + }); + expect(editor.getJSON()).toEqual(before); + }); + + it('disables import while parsing, ignores duplicate busy events, and permits the same file to be selected again', async () => { let resolveImport: ((result: ReturnType) => void) | undefined; spreadsheetMocks.importFile.mockImplementation( () => @@ -164,6 +226,9 @@ describe('Toolbar spreadsheet import', () => { expect(screen.getByRole('status')).toHaveTextContent('Importing spreadsheet…'); expect(input.value).toBe(''); + fireEvent.change(input, { target: { files: [file] } }); + expect(spreadsheetMocks.importFile).toHaveBeenCalledTimes(1); + await act(async () => resolveImport?.(importedResult())); await waitFor(() => expect(button).not.toBeDisabled()); @@ -172,13 +237,18 @@ describe('Toolbar spreadsheet import', () => { await waitFor(() => expect(spreadsheetMocks.importFile).toHaveBeenCalledTimes(2)); }); - it('announces a stable payload-redacted failure and leaves the document unchanged', async () => { - spreadsheetMocks.importFile.mockRejectedValue( - new Error('secret workbook cell and local filesystem path'), - ); + it('announces a stable payload-redacted failure, notifies the host, and leaves the document unchanged', async () => { + const parserFailure = new Error('secret workbook cell and local filesystem path'); + spreadsheetMocks.importFile.mockRejectedValue(parserFailure); const editor = makeEditor(); const before = editor.getJSON(); - render(); + const onSpreadsheetError = vi.fn(); + render( + , + ); fireEvent.change(spreadsheetInput(), { target: { files: [spreadsheetFile()] }, @@ -190,6 +260,7 @@ describe('Toolbar spreadsheet import', () => { ), ); expect(screen.getByRole('status')).not.toHaveTextContent('secret workbook'); + expect(onSpreadsheetError).toHaveBeenCalledWith(parserFailure); expect(editor.getJSON()).toEqual(before); }); From 101371be019a4d8f804f163b80efeaa4be713d39 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 20:13:38 +0900 Subject: [PATCH 098/163] test(spreadsheet): verify real BIFF8 XLS import --- src/spreadsheet/sheetJsFileImport.test.ts | 54 +++++++++++++---------- 1 file changed, 30 insertions(+), 24 deletions(-) diff --git a/src/spreadsheet/sheetJsFileImport.test.ts b/src/spreadsheet/sheetJsFileImport.test.ts index 46844b1d..70276415 100644 --- a/src/spreadsheet/sheetJsFileImport.test.ts +++ b/src/spreadsheet/sheetJsFileImport.test.ts @@ -18,14 +18,14 @@ function sourceFromBytes(bytes: Uint8Array): SpreadsheetFileSource { }; } -function xlsxBytes(): Uint8Array { +function workbookBytes(bookType: 'xlsx' | 'biff8'): Uint8Array { const workbook = XLSX.utils.book_new(); const worksheet = XLSX.utils.aoa_to_sheet([ ['Name', 'Value'], ['Revenue', 42], ]); XLSX.utils.book_append_sheet(workbook, worksheet, 'Summary'); - const serialized = XLSX.write(workbook, { type: 'array', bookType: 'xlsx' }); + const serialized = XLSX.write(workbook, { type: 'array', bookType }); return serialized instanceof Uint8Array ? serialized : new Uint8Array(serialized as ArrayBuffer); @@ -40,27 +40,33 @@ function expectUnsupported(promise: Promise) { } describe('spreadsheetFileToDocumentJson', () => { - it('converts a real local XLSX source into bounded editable TipTap content', async () => { - const result = await spreadsheetFileToDocumentJson( - sourceFromBytes(xlsxBytes()), - ); - - expect(result).toMatchObject({ - worksheetCount: 1, - rowCount: 2, - cellCount: 4, - }); - expect(result.content.map((node) => node.type)).toEqual([ - 'heading', - 'table', - 'paragraph', - ]); - expect(result.content[0]).toEqual({ - type: 'heading', - attrs: { level: 3 }, - content: [{ type: 'text', text: 'Summary' }], - }); - }); + it.each([ + ['XLSX', 'xlsx'], + ['BIFF8 XLS', 'biff8'], + ] as const)( + 'converts a real local %s source into bounded editable TipTap content', + async (_label, bookType) => { + const result = await spreadsheetFileToDocumentJson( + sourceFromBytes(workbookBytes(bookType)), + ); + + expect(result).toMatchObject({ + worksheetCount: 1, + rowCount: 2, + cellCount: 4, + }); + expect(result.content.map((node) => node.type)).toEqual([ + 'heading', + 'table', + 'paragraph', + ]); + expect(result.content[0]).toEqual({ + type: 'heading', + attrs: { level: 3 }, + content: [{ type: 'text', text: 'Summary' }], + }); + }, + ); it('rejects an oversized source before reading its bytes', async () => { const arrayBuffer = vi.fn(async () => new ArrayBuffer(0)); @@ -131,4 +137,4 @@ describe('spreadsheetFileToDocumentJson', () => { await expectUnsupported(spreadsheetFileToDocumentJson(source)); }); -}); +}); \ No newline at end of file From 547526fbe79afe22928689b888e64002d8c48520 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 20:15:17 +0900 Subject: [PATCH 099/163] test(spreadsheet): cover real inert workbook values --- src/spreadsheet/sheetJsFileImport.test.ts | 95 +++++++++++++++++++++-- 1 file changed, 90 insertions(+), 5 deletions(-) diff --git a/src/spreadsheet/sheetJsFileImport.test.ts b/src/spreadsheet/sheetJsFileImport.test.ts index 70276415..a9ced31f 100644 --- a/src/spreadsheet/sheetJsFileImport.test.ts +++ b/src/spreadsheet/sheetJsFileImport.test.ts @@ -6,6 +6,8 @@ import { type SpreadsheetFileSource, } from './index.js'; +type WorkbookFormat = 'xlsx' | 'biff8'; + function sourceFromBytes(bytes: Uint8Array): SpreadsheetFileSource { return { size: bytes.byteLength, @@ -18,17 +20,71 @@ function sourceFromBytes(bytes: Uint8Array): SpreadsheetFileSource { }; } -function workbookBytes(bookType: 'xlsx' | 'biff8'): Uint8Array { +function serializeWorkbook( + workbook: XLSX.WorkBook, + bookType: WorkbookFormat, +): Uint8Array { + const serialized = XLSX.write(workbook, { type: 'array', bookType }); + return serialized instanceof Uint8Array + ? serialized + : new Uint8Array(serialized as ArrayBuffer); +} + +function workbookBytes(bookType: WorkbookFormat): Uint8Array { const workbook = XLSX.utils.book_new(); const worksheet = XLSX.utils.aoa_to_sheet([ ['Name', 'Value'], ['Revenue', 42], ]); XLSX.utils.book_append_sheet(workbook, worksheet, 'Summary'); - const serialized = XLSX.write(workbook, { type: 'array', bookType }); - return serialized instanceof Uint8Array - ? serialized - : new Uint8Array(serialized as ArrayBuffer); + return serializeWorkbook(workbook, bookType); +} + +function richWorkbookBytes(bookType: WorkbookFormat): Uint8Array { + const workbook = XLSX.utils.book_new(); + const worksheet = XLSX.utils.aoa_to_sheet([ + ['Kind', 'Value'], + ['Unicode', '매출'], + ['Multiline', 'line 1\nline 2'], + ['Boolean', true], + [ + 'Date', + { + t: 'd', + v: new Date(Date.UTC(2026, 7, 17)), + z: 'yyyy-mm-dd', + } satisfies XLSX.CellObject, + ], + [ + 'Formula', + { + t: 'n', + v: 42, + f: 'SUM(40,2)', + } satisfies XLSX.CellObject, + ], + [ + 'Hyperlink', + { + t: 's', + v: 'Reference', + l: { Target: 'https://secret.invalid/workbook' }, + } satisfies XLSX.CellObject, + ], + ]); + XLSX.utils.book_append_sheet(workbook, worksheet, 'Summary'); + XLSX.utils.book_append_sheet( + workbook, + XLSX.utils.aoa_to_sheet([['private hidden value']]), + 'Hidden', + ); + XLSX.utils.book_append_sheet(workbook, XLSX.utils.aoa_to_sheet([]), 'Empty'); + + if (!workbook.Workbook) workbook.Workbook = {}; + if (!workbook.Workbook.Sheets) workbook.Workbook.Sheets = []; + workbook.Workbook.Sheets[1] = { Hidden: 1 }; + + return serializeWorkbook(workbook, bookType); } function expectUnsupported(promise: Promise) { @@ -68,6 +124,35 @@ describe('spreadsheetFileToDocumentJson', () => { }, ); + it.each([ + ['XLSX', 'xlsx'], + ['BIFF8 XLS', 'biff8'], + ] as const)( + 'materializes only inert visible displayed values from a real %s workbook', + async (_label, bookType) => { + const result = await spreadsheetFileToDocumentJson( + sourceFromBytes(richWorkbookBytes(bookType)), + ); + const materialized = JSON.stringify(result.content); + + expect(result).toMatchObject({ + worksheetCount: 1, + rowCount: 7, + cellCount: 14, + }); + expect(materialized).toContain('매출'); + expect(materialized).toContain('hardBreak'); + expect(materialized).toContain('2026-08-17'); + expect(materialized).toContain('Reference'); + expect(materialized).toContain('42'); + expect(materialized).not.toContain('SUM(40,2)'); + expect(materialized).not.toContain('https://secret.invalid/workbook'); + expect(materialized).not.toContain('private hidden value'); + expect(materialized).not.toContain('Hidden'); + expect(materialized).not.toContain('Empty'); + }, + ); + it('rejects an oversized source before reading its bytes', async () => { const arrayBuffer = vi.fn(async () => new ArrayBuffer(0)); const source: SpreadsheetFileSource = { From bebb5ba6ff3625a71d6fa9a93e090256bed7641e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 20:17:20 +0900 Subject: [PATCH 100/163] test(spreadsheet): expose host failure callback --- .../CwlEditor.spreadsheetImport.test.tsx | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 src/components/CwlEditor.spreadsheetImport.test.tsx diff --git a/src/components/CwlEditor.spreadsheetImport.test.tsx b/src/components/CwlEditor.spreadsheetImport.test.tsx new file mode 100644 index 00000000..38d2ee11 --- /dev/null +++ b/src/components/CwlEditor.spreadsheetImport.test.tsx @@ -0,0 +1,67 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const spreadsheetMocks = vi.hoisted(() => ({ + importFile: vi.fn(), +})); + +vi.mock('../spreadsheet/index.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + spreadsheetFileToDocumentJson: spreadsheetMocks.importFile, + }; +}); + +import { CwlEditor } from './CwlEditor.js'; + +function spreadsheetInput(): HTMLInputElement { + return document.querySelector( + 'input[data-cwl-spreadsheet-input="true"]', + ) as HTMLInputElement; +} + +function spreadsheetFile(): File { + return new File([new Uint8Array([0x50, 0x4b, 0x03, 0x04])], 'book.xlsx', { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }); +} + +afterEach(() => { + cleanup(); + spreadsheetMocks.importFile.mockReset(); +}); + +describe('CwlEditor spreadsheet import integration', () => { + it('forwards spreadsheet failures through the public host callback without leaking payload text into status', async () => { + const parserFailure = new Error('private workbook payload'); + spreadsheetMocks.importFile.mockRejectedValue(parserFailure); + const onSpreadsheetError = vi.fn(); + + render( + , + ); + + await waitFor(() => + expect( + screen.getByRole('button', { name: 'Insert XLS/XLSX spreadsheet' }), + ).toBeInTheDocument(), + ); + + fireEvent.change(spreadsheetInput(), { + target: { files: [spreadsheetFile()] }, + }); + + await waitFor(() => + expect(screen.getByRole('status')).toHaveTextContent( + 'Spreadsheet import failed.', + ), + ); + expect(screen.getByRole('status')).not.toHaveTextContent('private workbook'); + expect(onSpreadsheetError).toHaveBeenCalledTimes(1); + expect(onSpreadsheetError).toHaveBeenCalledWith(parserFailure); + }); +}); \ No newline at end of file From 90a01be9f5eb79437b420f6f82520840b5e9d40e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 20:20:22 +0900 Subject: [PATCH 101/163] feat(spreadsheet): expose host import failure callback --- src/types.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/types.ts b/src/types.ts index 0292d4a2..ce5f8818 100644 --- a/src/types.ts +++ b/src/types.ts @@ -298,6 +298,12 @@ export interface CwlEditorProps { * silently swallowing failures on the commercial path. */ onImageError?: (error: unknown) => void; + /** + * Fired when a local XLS/XLSX toolbar import cannot be parsed or inserted. + * The toolbar renders only stable redacted status text; this callback carries + * the underlying error so the host can log or present its own bounded UX. + */ + onSpreadsheetError?: (error: unknown) => void; /** * Bounded rich-HTML paste policy. Word, Google Docs, email, and web markup is * rebuilt through Inkspan's strict semantic allowlist before insertion. The From 91116de754b497a011fd1f7dc63c76a67fbbda0c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 20:20:52 +0900 Subject: [PATCH 102/163] feat(spreadsheet): forward host import failure callback --- src/components/CwlEditor.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/CwlEditor.tsx b/src/components/CwlEditor.tsx index 598ac948..35641c2b 100644 --- a/src/components/CwlEditor.tsx +++ b/src/components/CwlEditor.tsx @@ -40,6 +40,7 @@ export const CwlEditor = forwardRef( onBlur, onSelectionChange, onImageError, + onSpreadsheetError, clipboard, onClipboardError, placeholder = 'Start writing…', @@ -243,6 +244,7 @@ export const CwlEditor = forwardRef( image={image} className={className} onImageError={onImageError} + onSpreadsheetError={onSpreadsheetError} formFieldName={formFieldName} formId={formId} formFieldDisabled={formFieldDisabled} @@ -253,4 +255,4 @@ export const CwlEditor = forwardRef( }, ); -export default CwlEditor; +export default CwlEditor; \ No newline at end of file From 5838cc058267355c77ea578ecc67bacfc4ccfa68 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 20:21:16 +0900 Subject: [PATCH 103/163] feat(spreadsheet): connect public import error path --- src/components/EditorFrame.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/EditorFrame.tsx b/src/components/EditorFrame.tsx index 9cf49c23..8bd937b5 100644 --- a/src/components/EditorFrame.tsx +++ b/src/components/EditorFrame.tsx @@ -13,6 +13,7 @@ export interface EditorFrameProps { image?: ImageConfig; className?: string; onImageError?: (error: unknown) => void; + onSpreadsheetError?: (error: unknown) => void; formFieldName?: string; formId?: string; formFieldDisabled?: boolean; @@ -34,6 +35,7 @@ export function EditorFrame({ image, className, onImageError, + onSpreadsheetError, formFieldName, formId, formFieldDisabled, @@ -88,6 +90,7 @@ export function EditorFrame({ editor={editor} image={image} onImageError={onImageError} + onSpreadsheetError={onSpreadsheetError} /> ) : null}
@@ -97,4 +100,4 @@ export function EditorFrame({ ); } -export default EditorFrame; +export default EditorFrame; \ No newline at end of file From 9a828a56bc22158340afc5f0a4137e07d0ee4b85 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 20:25:39 +0900 Subject: [PATCH 104/163] test(spreadsheet): preserve BIFF8 sheet visibility fixture --- src/spreadsheet/sheetJsFileImport.test.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/spreadsheet/sheetJsFileImport.test.ts b/src/spreadsheet/sheetJsFileImport.test.ts index a9ced31f..c9360820 100644 --- a/src/spreadsheet/sheetJsFileImport.test.ts +++ b/src/spreadsheet/sheetJsFileImport.test.ts @@ -80,9 +80,16 @@ function richWorkbookBytes(bookType: WorkbookFormat): Uint8Array { ); XLSX.utils.book_append_sheet(workbook, XLSX.utils.aoa_to_sheet([]), 'Empty'); - if (!workbook.Workbook) workbook.Workbook = {}; - if (!workbook.Workbook.Sheets) workbook.Workbook.Sheets = []; - workbook.Workbook.Sheets[1] = { Hidden: 1 }; + /* + * Keep a complete metadata array. SheetJS stores visibility by worksheet + * index, and its BIFF8 writer traverses workbook metadata as an ordered + * sequence. A sparse array can collapse the intended index when serialized, + * producing a fixture whose supposedly hidden sheet is actually visible. + */ + workbook.Workbook = { + ...(workbook.Workbook ?? {}), + Sheets: [{ Hidden: 0 }, { Hidden: 1 }, { Hidden: 0 }], + }; return serializeWorkbook(workbook, bookType); } From 5f98788c7138104c17bf0c9171c26c5d4c5e28bc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 20:27:39 +0900 Subject: [PATCH 105/163] test(spreadsheet): fail closed across async host boundaries --- .../Toolbar.spreadsheetImport.test.tsx | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/src/components/Toolbar.spreadsheetImport.test.tsx b/src/components/Toolbar.spreadsheetImport.test.tsx index 23af8692..9b79e80b 100644 --- a/src/components/Toolbar.spreadsheetImport.test.tsx +++ b/src/components/Toolbar.spreadsheetImport.test.tsx @@ -204,6 +204,46 @@ describe('Toolbar spreadsheet import', () => { expect(editor.getJSON()).toEqual(before); }); + it('fails closed when the editor becomes read-only while parsing', async () => { + let resolveImport: ((result: ReturnType) => void) | undefined; + spreadsheetMocks.importFile.mockImplementation( + () => + new Promise((resolve) => { + resolveImport = resolve; + }), + ); + const editor = makeEditor(); + const before = editor.getJSON(); + const onSpreadsheetError = vi.fn(); + render( + , + ); + + fireEvent.change(spreadsheetInput(), { + target: { files: [spreadsheetFile()] }, + }); + await waitFor(() => + expect(screen.getByRole('status')).toHaveTextContent('Importing spreadsheet…'), + ); + + act(() => editor.setEditable(false)); + await act(async () => resolveImport?.(importedResult())); + + await waitFor(() => + expect(screen.getByRole('status')).toHaveTextContent( + 'Spreadsheet import failed.', + ), + ); + expect(onSpreadsheetError).toHaveBeenCalledTimes(1); + expect(onSpreadsheetError.mock.calls[0]?.[0]).toMatchObject({ + message: 'Spreadsheet insertion is unavailable.', + }); + expect(editor.getJSON()).toEqual(before); + }); + it('disables import while parsing, ignores duplicate busy events, and permits the same file to be selected again', async () => { let resolveImport: ((result: ReturnType) => void) | undefined; spreadsheetMocks.importFile.mockImplementation( @@ -264,6 +304,36 @@ describe('Toolbar spreadsheet import', () => { expect(editor.getJSON()).toEqual(before); }); + it('contains host spreadsheet-error observer failures without changing the redacted status', async () => { + spreadsheetMocks.importFile.mockRejectedValue( + new Error('private parser failure'), + ); + const editor = makeEditor(); + const before = editor.getJSON(); + const onSpreadsheetError = vi.fn(() => { + throw new Error('private host observer failure'); + }); + render( + , + ); + + fireEvent.change(spreadsheetInput(), { + target: { files: [spreadsheetFile()] }, + }); + + await waitFor(() => + expect(screen.getByRole('status')).toHaveTextContent( + 'Spreadsheet import failed.', + ), + ); + expect(onSpreadsheetError).toHaveBeenCalledTimes(1); + expect(screen.getByRole('status')).not.toHaveTextContent('private'); + expect(editor.getJSON()).toEqual(before); + }); + it('ignores a picker change with no selected file', async () => { const editor = makeEditor(); render(); From f32b4d78f47895a7fc1c89df56c5d037c27bf0f5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 20:28:54 +0900 Subject: [PATCH 106/163] fix(toolbar): converge async import trust boundaries --- src/components/Toolbar.tsx | 49 ++++++++++++++++++++++++++++++++++---- 1 file changed, 44 insertions(+), 5 deletions(-) diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index feb8c478..c73fe389 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -9,7 +9,9 @@ import { type FocusEvent, type KeyboardEvent, } from 'react'; +import { Base64SizeError } from '../converter/base64.js'; import { imageFileToInlineDataUri } from '../extensions/Base64Image.js'; +import { isSafeLinkHref } from '../extensions/SafeLink.js'; import { spreadsheetFileToDocumentJson } from '../spreadsheet/index.js'; import type { ImageConfig } from '../types.js'; @@ -36,6 +38,27 @@ const TOOLBAR_ITEM_SELECTOR = 'button[data-cwl-toolbar-item="true"]'; const SPREADSHEET_ACCEPT = '.xls,.xlsx,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; +/** Read a genuine Blob's byte length without invoking caller-owned accessors. */ +function intrinsicBlobSize(blob: Blob): number { + const sizeGetter = Object.getOwnPropertyDescriptor( + globalThis.Blob.prototype, + 'size', + )!.get!; + return Reflect.apply(sizeGetter, blob, []) as number; +} + +/** Report a host-observable failure without granting observer code control flow. */ +function reportHostError( + observer: ((error: unknown) => void) | undefined, + error: unknown, +): void { + try { + observer?.(error); + } catch { + // Host presentation or telemetry observers are best-effort only. + } +} + /** Return every toolbar button in visual and DOM navigation order. */ function getToolbarButtons(toolbar: HTMLDivElement): HTMLButtonElement[] { return Array.from( @@ -188,6 +211,7 @@ export function Toolbar({ editor.chain().focus().extendMarkRange('link').unsetLink().run(); return; } + if (!isSafeLinkHref(url)) return; editor .chain() .focus() @@ -218,18 +242,30 @@ export function Toolbar({ event.target.value = ''; if (!file) return; + const maxSizeBytes = image?.maxSizeBytes ?? 10 * 1024 * 1024; + const sourceBytes = intrinsicBlobSize(file); + if (maxSizeBytes > 0 && sourceBytes > maxSizeBytes) { + reportHostError( + onImageError, + new Base64SizeError(sourceBytes, maxSizeBytes), + ); + return; + } + let src: string; try { src = await imageFileToInlineDataUri(file, { - maxSizeBytes: image?.maxSizeBytes ?? 10 * 1024 * 1024, + maxSizeBytes, maxDimension: image?.maxDimension ?? 1600, quality: image?.quality ?? 0.85, }); - } catch (err) { - onImageError?.(err); + } catch { + reportHostError(onImageError, new Error('Image processing failed.')); return; } + if (editor.isDestroyed || !editor.isEditable) return; + const alternativeText = window.prompt( 'Image alternative text. Leave empty only if this image is decorative.', '', @@ -251,6 +287,9 @@ export function Toolbar({ setSpreadsheetStatus('Importing spreadsheet…'); try { const result = await spreadsheetFileToDocumentJson(file); + if (editor.isDestroyed || !editor.isEditable) { + throw new Error('Spreadsheet insertion is unavailable.'); + } if (result.content.length > 0) { const inserted = editor .chain() @@ -263,7 +302,7 @@ export function Toolbar({ `Imported ${result.worksheetCount} ${result.worksheetCount === 1 ? 'worksheet' : 'worksheets'}, ${result.rowCount} ${result.rowCount === 1 ? 'row' : 'rows'}, and ${result.cellCount} ${result.cellCount === 1 ? 'cell' : 'cells'}.`, ); } catch (error) { - onSpreadsheetError?.(error); + reportHostError(onSpreadsheetError, error); setSpreadsheetStatus('Spreadsheet import failed.'); } finally { setSpreadsheetBusy(false); @@ -477,4 +516,4 @@ export function Toolbar({ ); } -export default Toolbar; +export default Toolbar; \ No newline at end of file From 89b5b77a8e8c5c7bd4eed4a81ca469563bbfca91 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 20:43:07 +0900 Subject: [PATCH 107/163] fix(spreadsheet): bind visibility to parsed sheet index --- src/spreadsheet/sheetJsAdapter.ts | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/src/spreadsheet/sheetJsAdapter.ts b/src/spreadsheet/sheetJsAdapter.ts index 8891b7d8..001c9a74 100644 --- a/src/spreadsheet/sheetJsAdapter.ts +++ b/src/spreadsheet/sheetJsAdapter.ts @@ -133,6 +133,25 @@ function readWorkbookSheetMetadata(workbook: object): readonly unknown[] | undef return sheetMetadata; } +function readParsedSheetIndex(workbook: object, expectedName: string): number { + const sheetNames = readOwnDataProperty(workbook, 'SheetNames'); + if (!isArray(sheetNames)) unsupportedOrCorruptSource(); + const sheetCount = readArrayLength(sheetNames); + if (sheetCount > MAX_WORKBOOK_WORKSHEETS) resourceLimitExceeded(); + + let matchedIndex = -1; + for (let index = 0; index < sheetCount; index += 1) { + const name = readOwnDataProperty(sheetNames, String(index)); + if (typeof name !== 'string') unsupportedOrCorruptSource(); + if (name.length > MAX_WORKSHEET_NAME_CODE_UNITS) resourceLimitExceeded(); + if (name !== expectedName) continue; + if (matchedIndex !== -1) unsupportedOrCorruptSource(); + matchedIndex = index; + } + if (matchedIndex === -1) unsupportedOrCorruptSource(); + return matchedIndex; +} + function decodeRangeDimensions( parser: SheetJsParserModule, reference: string, @@ -266,8 +285,7 @@ export function sheetJsBytesToWorkbookData( let decodedRows = 0; let decodedCells = 0; - for (let index = 0; index < worksheetNames.length; index += 1) { - const name = worksheetNames[index] as string; + for (const name of worksheetNames) { const remainingRows = MAX_WORKBOOK_ROWS - decodedRows; const parsed = readWorkbook(parser, boundedSource.bytes, { ...baseReadOptions(), @@ -278,8 +296,9 @@ export function sheetJsBytesToWorkbookData( if (!isObject(sheets)) unsupportedOrCorruptSource(); const sheet = readOwnDataProperty(sheets, name); if (!isObject(sheet)) unsupportedOrCorruptSource(); + const parsedSheetIndex = readParsedSheetIndex(parsed, name); const sheetMetadata = readWorkbookSheetMetadata(parsed); - const hidden = readHiddenState(sheetMetadata, index); + const hidden = readHiddenState(sheetMetadata, parsedSheetIndex); if (hidden) { worksheets.push({ name, hidden: true, rows: [] }); continue; From a4ed7e575b2e68c3f728030352a24b3c836397de Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 20:49:10 +0900 Subject: [PATCH 108/163] test(spreadsheet): pin workbook visibility metadata authority --- .../sheetJsAdapter.visibilityMetadata.test.ts | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 src/spreadsheet/sheetJsAdapter.visibilityMetadata.test.ts diff --git a/src/spreadsheet/sheetJsAdapter.visibilityMetadata.test.ts b/src/spreadsheet/sheetJsAdapter.visibilityMetadata.test.ts new file mode 100644 index 00000000..cf9dc571 --- /dev/null +++ b/src/spreadsheet/sheetJsAdapter.visibilityMetadata.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + sheetJsBytesToWorkbookData, + type SheetJsParserModule, +} from './sheetJsAdapter.js'; + +const XLSX_SOURCE = new Uint8Array([0x50, 0x4b, 0x03, 0x04]); + +describe('sheetJsBytesToWorkbookData worksheet visibility authority', () => { + it('uses bounded whole-workbook metadata instead of selective-parse visibility', () => { + const summarySheet = { '!ref': 'A1' }; + const privateSheet = { '!ref': 'A1' }; + const read = vi.fn( + ( + _source: Uint8Array, + options: Parameters[1], + ): unknown => { + if (options.bookSheets === true) { + return { + SheetNames: ['Summary', 'Private'], + Sheets: {}, + }; + } + if (options.sheetRows === 1 && options.sheets === undefined) { + return { + SheetNames: ['Summary', 'Private'], + Sheets: { + Summary: summarySheet, + Private: privateSheet, + }, + Workbook: { + Sheets: [{ Hidden: 0 }, { Hidden: 1 }], + }, + }; + } + if (options.sheets === 'Summary') { + return { + SheetNames: ['Summary'], + Sheets: { Summary: summarySheet }, + Workbook: { Sheets: [{ Hidden: 0 }] }, + }; + } + if (options.sheets === 'Private') { + return { + SheetNames: ['Private'], + Sheets: { Private: privateSheet }, + // Selective BIFF8 parsing can no longer be treated as visibility + // authority because it may not preserve the original sheet flag. + Workbook: { Sheets: [{ Hidden: 0 }] }, + }; + } + throw new Error('unexpected parser invocation'); + }, + ); + const sheetToJson = vi.fn((sheet: unknown) => + sheet === summarySheet ? [['public']] : [['private']], + ); + const parser: SheetJsParserModule = { + read, + utils: { + decode_range: () => ({ s: { r: 0, c: 0 }, e: { r: 0, c: 0 } }), + sheet_to_json: sheetToJson, + }, + }; + + expect(sheetJsBytesToWorkbookData(XLSX_SOURCE, parser)).toEqual({ + worksheets: [ + { name: 'Summary', hidden: false, rows: [['public']] }, + { name: 'Private', hidden: true, rows: [] }, + ], + }); + expect(read).toHaveBeenCalledTimes(3); + expect(read).toHaveBeenNthCalledWith(1, XLSX_SOURCE, { + type: 'array', + cellFormula: false, + cellHTML: false, + cellNF: false, + bookVBA: false, + bookSheets: true, + }); + expect(read).toHaveBeenNthCalledWith(2, XLSX_SOURCE, { + type: 'array', + cellFormula: false, + cellHTML: false, + cellNF: false, + bookVBA: false, + sheetRows: 1, + }); + expect(read).toHaveBeenNthCalledWith(3, XLSX_SOURCE, { + type: 'array', + cellFormula: false, + cellHTML: false, + cellNF: false, + bookVBA: false, + sheets: 'Summary', + sheetRows: 10_001, + }); + expect(sheetToJson).toHaveBeenCalledTimes(1); + expect(sheetToJson).toHaveBeenCalledWith(summarySheet, { + header: 1, + raw: false, + defval: '', + blankrows: true, + }); + }); +}); From 3ce98264a8fab60a3bfdc5e58de1ec0bbd32213b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 20:50:35 +0900 Subject: [PATCH 109/163] test(spreadsheet): scope visibility metadata regression to BIFF8 --- .../sheetJsAdapter.visibilityMetadata.test.ts | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/src/spreadsheet/sheetJsAdapter.visibilityMetadata.test.ts b/src/spreadsheet/sheetJsAdapter.visibilityMetadata.test.ts index cf9dc571..851562f6 100644 --- a/src/spreadsheet/sheetJsAdapter.visibilityMetadata.test.ts +++ b/src/spreadsheet/sheetJsAdapter.visibilityMetadata.test.ts @@ -4,9 +4,18 @@ import { type SheetJsParserModule, } from './sheetJsAdapter.js'; -const XLSX_SOURCE = new Uint8Array([0x50, 0x4b, 0x03, 0x04]); +const BIFF8_SOURCE = new Uint8Array([ + 0xd0, + 0xcf, + 0x11, + 0xe0, + 0xa1, + 0xb1, + 0x1a, + 0xe1, +]); -describe('sheetJsBytesToWorkbookData worksheet visibility authority', () => { +describe('sheetJsBytesToWorkbookData BIFF8 visibility authority', () => { it('uses bounded whole-workbook metadata instead of selective-parse visibility', () => { const summarySheet = { '!ref': 'A1' }; const privateSheet = { '!ref': 'A1' }; @@ -44,8 +53,6 @@ describe('sheetJsBytesToWorkbookData worksheet visibility authority', () => { return { SheetNames: ['Private'], Sheets: { Private: privateSheet }, - // Selective BIFF8 parsing can no longer be treated as visibility - // authority because it may not preserve the original sheet flag. Workbook: { Sheets: [{ Hidden: 0 }] }, }; } @@ -63,14 +70,14 @@ describe('sheetJsBytesToWorkbookData worksheet visibility authority', () => { }, }; - expect(sheetJsBytesToWorkbookData(XLSX_SOURCE, parser)).toEqual({ + expect(sheetJsBytesToWorkbookData(BIFF8_SOURCE, parser)).toEqual({ worksheets: [ { name: 'Summary', hidden: false, rows: [['public']] }, { name: 'Private', hidden: true, rows: [] }, ], }); expect(read).toHaveBeenCalledTimes(3); - expect(read).toHaveBeenNthCalledWith(1, XLSX_SOURCE, { + expect(read).toHaveBeenNthCalledWith(1, BIFF8_SOURCE, { type: 'array', cellFormula: false, cellHTML: false, @@ -78,7 +85,7 @@ describe('sheetJsBytesToWorkbookData worksheet visibility authority', () => { bookVBA: false, bookSheets: true, }); - expect(read).toHaveBeenNthCalledWith(2, XLSX_SOURCE, { + expect(read).toHaveBeenNthCalledWith(2, BIFF8_SOURCE, { type: 'array', cellFormula: false, cellHTML: false, @@ -86,7 +93,7 @@ describe('sheetJsBytesToWorkbookData worksheet visibility authority', () => { bookVBA: false, sheetRows: 1, }); - expect(read).toHaveBeenNthCalledWith(3, XLSX_SOURCE, { + expect(read).toHaveBeenNthCalledWith(3, BIFF8_SOURCE, { type: 'array', cellFormula: false, cellHTML: false, From 7729cdb43ecad76a481edf8ede22ab0c0483806c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 20:52:00 +0900 Subject: [PATCH 110/163] fix(spreadsheet): preserve BIFF8 workbook visibility metadata --- src/spreadsheet/sheetJsAdapter.ts | 45 ++++++++++++++++++++++++------- 1 file changed, 36 insertions(+), 9 deletions(-) diff --git a/src/spreadsheet/sheetJsAdapter.ts b/src/spreadsheet/sheetJsAdapter.ts index 001c9a74..7f589c33 100644 --- a/src/spreadsheet/sheetJsAdapter.ts +++ b/src/spreadsheet/sheetJsAdapter.ts @@ -253,9 +253,11 @@ function baseReadOptions(): Omit< /** * Project locally parsed SheetJS workbook data into Inkspan's parser-neutral * workbook contract without granting formulas, macros, links, or parser output - * any editor authority. Inkspan first performs a sheet-name-only discovery pass, - * then parses each selected worksheet with a row ceiling derived from the - * remaining aggregate workbook budget. Exact decoded row, column, and cell + * any editor authority. Inkspan first performs a sheet-name-only discovery pass. + * BIFF8 then receives one bounded whole-workbook metadata pass because selective + * BIFF8 parses do not reliably retain the source sheet visibility flag. Only + * visible worksheets are subsequently parsed with a row ceiling derived from + * the remaining aggregate workbook budget. Exact decoded row, column, and cell * limits are checked before displayed rows are materialized by `sheet_to_json`. */ export function sheetJsBytesToWorkbookData( @@ -280,12 +282,35 @@ export function sheetJsBytesToWorkbookData( worksheetNames.push(name); } + const biff8VisibilityWorkbook = + boundedSource.format === 'xls' && sheetCount > 0 + ? readWorkbook(parser, boundedSource.bytes, { + ...baseReadOptions(), + sheetRows: 1, + }) + : undefined; + const biff8SheetMetadata = + biff8VisibilityWorkbook === undefined + ? undefined + : readWorkbookSheetMetadata(biff8VisibilityWorkbook); + const worksheets: SpreadsheetWorksheetData[] = []; let visibleCount = 0; let decodedRows = 0; let decodedCells = 0; for (const name of worksheetNames) { + if (biff8VisibilityWorkbook !== undefined) { + const visibilityIndex = readParsedSheetIndex( + biff8VisibilityWorkbook, + name, + ); + if (readHiddenState(biff8SheetMetadata, visibilityIndex)) { + worksheets.push({ name, hidden: true, rows: [] }); + continue; + } + } + const remainingRows = MAX_WORKBOOK_ROWS - decodedRows; const parsed = readWorkbook(parser, boundedSource.bytes, { ...baseReadOptions(), @@ -296,12 +321,14 @@ export function sheetJsBytesToWorkbookData( if (!isObject(sheets)) unsupportedOrCorruptSource(); const sheet = readOwnDataProperty(sheets, name); if (!isObject(sheet)) unsupportedOrCorruptSource(); - const parsedSheetIndex = readParsedSheetIndex(parsed, name); - const sheetMetadata = readWorkbookSheetMetadata(parsed); - const hidden = readHiddenState(sheetMetadata, parsedSheetIndex); - if (hidden) { - worksheets.push({ name, hidden: true, rows: [] }); - continue; + + if (biff8VisibilityWorkbook === undefined) { + const parsedSheetIndex = readParsedSheetIndex(parsed, name); + const sheetMetadata = readWorkbookSheetMetadata(parsed); + if (readHiddenState(sheetMetadata, parsedSheetIndex)) { + worksheets.push({ name, hidden: true, rows: [] }); + continue; + } } visibleCount += 1; From ddd52fa506be720c7999188be2e74c408c2f64f9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 20:53:02 +0900 Subject: [PATCH 111/163] test(spreadsheet): require preflight before parser load --- .../sheetJsRuntimePreflight.test.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 src/spreadsheet/sheetJsRuntimePreflight.test.ts diff --git a/src/spreadsheet/sheetJsRuntimePreflight.test.ts b/src/spreadsheet/sheetJsRuntimePreflight.test.ts new file mode 100644 index 00000000..2dbc2e29 --- /dev/null +++ b/src/spreadsheet/sheetJsRuntimePreflight.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it, vi } from 'vitest'; + +const { parserLoad } = vi.hoisted(() => ({ parserLoad: vi.fn() })); + +vi.mock('xlsx', () => { + parserLoad(); + return {}; +}); + +import { parseSheetJsSpreadsheetBytes } from './sheetJsRuntime.js'; + +describe('SheetJS runtime source preflight ordering', () => { + it('rejects an unsupported binary envelope before loading the parser package', async () => { + await expect( + parseSheetJsSpreadsheetBytes(new Uint8Array([0x00, 0x01, 0x02, 0x03])), + ).rejects.toMatchObject({ + name: 'SpreadsheetImportError', + code: 'UNSUPPORTED_OR_CORRUPT', + message: 'Spreadsheet source is unsupported or corrupt.', + }); + + expect(parserLoad).not.toHaveBeenCalled(); + }); +}); From 371f2ea619e950731bd7c5772d82b40cf8be353c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 20:53:39 +0900 Subject: [PATCH 112/163] fix(spreadsheet): preflight bytes before parser load --- src/spreadsheet/sheetJsRuntime.ts | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/src/spreadsheet/sheetJsRuntime.ts b/src/spreadsheet/sheetJsRuntime.ts index 01479bfa..013c4df8 100644 --- a/src/spreadsheet/sheetJsRuntime.ts +++ b/src/spreadsheet/sheetJsRuntime.ts @@ -1,4 +1,5 @@ import { + preflightSpreadsheetBinarySource, SpreadsheetImportError, spreadsheetWorkbookToDocumentJson, type SpreadsheetImportResult, @@ -36,22 +37,19 @@ function unsupportedOrCorruptSource(): SpreadsheetImportError { /** * Parse supported local XLS/XLSX bytes through Inkspan's bounded SheetJS adapter. * - * The public byte-array entry point enforces the same source ceiling as the - * browser-file boundary before the parser module is loaded. The parser package - * is loaded locally and receives no network, credential, persistence, model, - * transport, or editor mutation authority. Its untrusted materialized output - * still crosses the same descriptor-safe resource bounds as an injected parser - * module before it becomes parser-neutral workbook data. + * The public byte-array entry point validates the source envelope and byte ceiling + * before the parser module is loaded. The parser package is loaded locally and + * receives no network, credential, persistence, model, transport, or editor + * mutation authority. Its untrusted materialized output still crosses the same + * descriptor-safe resource bounds as an injected parser module before it becomes + * parser-neutral workbook data. */ export async function parseSheetJsSpreadsheetBytes( source: Uint8Array, ): Promise { - if (source.byteLength > MAX_SPREADSHEET_SOURCE_BYTES) { - throw resourceLimitExceeded(); - } - + const boundedSource = preflightSpreadsheetBinarySource(source); const parser = (await import('xlsx')) as unknown as SheetJsParserModule; - return sheetJsBytesToWorkbookData(source, parser); + return sheetJsBytesToWorkbookData(boundedSource.bytes, parser); } /** From fb052c806c177220545ba0ccb335c5ac5284b5d1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 20:55:57 +0900 Subject: [PATCH 113/163] test(spreadsheet): cover empty BIFF8 discovery --- .../sheetJsAdapter.visibilityMetadata.test.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/spreadsheet/sheetJsAdapter.visibilityMetadata.test.ts b/src/spreadsheet/sheetJsAdapter.visibilityMetadata.test.ts index 851562f6..ea20663c 100644 --- a/src/spreadsheet/sheetJsAdapter.visibilityMetadata.test.ts +++ b/src/spreadsheet/sheetJsAdapter.visibilityMetadata.test.ts @@ -110,4 +110,28 @@ describe('sheetJsBytesToWorkbookData BIFF8 visibility authority', () => { blankrows: true, }); }); + + it('does not issue metadata or body reads when discovery reports no sheets', () => { + const read = vi.fn(() => ({ SheetNames: [], Sheets: {} })); + const parser: SheetJsParserModule = { + read, + utils: { + decode_range: vi.fn(), + sheet_to_json: vi.fn(), + }, + }; + + expect(sheetJsBytesToWorkbookData(BIFF8_SOURCE, parser)).toEqual({ + worksheets: [], + }); + expect(read).toHaveBeenCalledTimes(1); + expect(read).toHaveBeenCalledWith(BIFF8_SOURCE, { + type: 'array', + cellFormula: false, + cellHTML: false, + cellNF: false, + bookVBA: false, + bookSheets: true, + }); + }); }); From c88bc4fe885831d54062667a679c2645eae59f58 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 21:13:16 +0900 Subject: [PATCH 114/163] test(spreadsheet): isolate BIFF8 visibility metadata --- src/spreadsheet/sheetJsFileImport.test.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/spreadsheet/sheetJsFileImport.test.ts b/src/spreadsheet/sheetJsFileImport.test.ts index c9360820..0ed0cbbb 100644 --- a/src/spreadsheet/sheetJsFileImport.test.ts +++ b/src/spreadsheet/sheetJsFileImport.test.ts @@ -103,6 +103,22 @@ function expectUnsupported(promise: Promise) { } describe('spreadsheetFileToDocumentJson', () => { + it('preserves hidden-sheet metadata in the real BIFF8 visibility parse', () => { + const workbook = XLSX.read(richWorkbookBytes('biff8'), { + type: 'array', + cellFormula: false, + cellHTML: false, + cellNF: false, + bookVBA: false, + sheetRows: 1, + }); + + expect(workbook.SheetNames).toEqual(['Summary', 'Hidden', 'Empty']); + expect( + workbook.Workbook?.Sheets?.map((sheet) => sheet.Hidden ?? 0), + ).toEqual([0, 1, 0]); + }); + it.each([ ['XLSX', 'xlsx'], ['BIFF8 XLS', 'biff8'], @@ -229,4 +245,4 @@ describe('spreadsheetFileToDocumentJson', () => { await expectUnsupported(spreadsheetFileToDocumentJson(source)); }); -}); \ No newline at end of file +}); From d8ac9251b2e91564c3cc360fda4001f5985d43de Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 21:17:26 +0900 Subject: [PATCH 115/163] test(spreadsheet): trace real BIFF8 visibility through adapter --- .../sheetJsAdapter.realBiff8.test.ts | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 src/spreadsheet/sheetJsAdapter.realBiff8.test.ts diff --git a/src/spreadsheet/sheetJsAdapter.realBiff8.test.ts b/src/spreadsheet/sheetJsAdapter.realBiff8.test.ts new file mode 100644 index 00000000..f3371dbc --- /dev/null +++ b/src/spreadsheet/sheetJsAdapter.realBiff8.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest'; +import * as XLSX from 'xlsx'; +import { + sheetJsBytesToWorkbookData, + type SheetJsParserModule, +} from './sheetJsAdapter.js'; +import { preflightSpreadsheetBinarySource } from './spreadsheetImport.js'; + +function realBiff8WithHiddenSheet(): Uint8Array { + const workbook = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet( + workbook, + XLSX.utils.aoa_to_sheet([['public']]), + 'Summary', + ); + XLSX.utils.book_append_sheet( + workbook, + XLSX.utils.aoa_to_sheet([['private']]), + 'Hidden', + ); + XLSX.utils.book_append_sheet(workbook, XLSX.utils.aoa_to_sheet([]), 'Empty'); + workbook.Workbook = { + ...(workbook.Workbook ?? {}), + Sheets: [{ Hidden: 0 }, { Hidden: 1 }, { Hidden: 0 }], + }; + + const serialized = XLSX.write(workbook, { type: 'array', bookType: 'biff8' }); + return serialized instanceof Uint8Array + ? serialized + : new Uint8Array(serialized as ArrayBuffer); +} + +describe('real BIFF8 hidden-sheet metadata', () => { + it('survives the lazy parser boundary and remains authoritative in the adapter', async () => { + const bytes = realBiff8WithHiddenSheet(); + expect(preflightSpreadsheetBinarySource(bytes).format).toBe('xls'); + + const lazyXlsx = await import('xlsx'); + const visibilityWorkbook = lazyXlsx.read(bytes, { + type: 'array', + cellFormula: false, + cellHTML: false, + cellNF: false, + bookVBA: false, + sheetRows: 1, + }); + + expect(visibilityWorkbook.SheetNames).toEqual([ + 'Summary', + 'Hidden', + 'Empty', + ]); + expect( + visibilityWorkbook.Workbook?.Sheets?.map((sheet) => sheet.Hidden ?? 0), + ).toEqual([0, 1, 0]); + expect( + Object.getOwnPropertyDescriptor( + visibilityWorkbook.Workbook!.Sheets![1]!, + 'Hidden', + )?.value, + ).toBe(1); + + expect( + sheetJsBytesToWorkbookData( + bytes, + lazyXlsx as unknown as SheetJsParserModule, + ).worksheets.map(({ name, hidden }) => ({ name, hidden })), + ).toEqual([ + { name: 'Summary', hidden: false }, + { name: 'Hidden', hidden: true }, + { name: 'Empty', hidden: false }, + ]); + }); +}); From df6c950c9b5ead5be90fd6d701c252bdaf3fbe12 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 21:21:09 +0900 Subject: [PATCH 116/163] test(spreadsheet): localize BIFF8 visibility boundary --- .../sheetJsAdapter.realBiff8.test.ts | 91 +++++++++++++++---- 1 file changed, 75 insertions(+), 16 deletions(-) diff --git a/src/spreadsheet/sheetJsAdapter.realBiff8.test.ts b/src/spreadsheet/sheetJsAdapter.realBiff8.test.ts index f3371dbc..e02db0f8 100644 --- a/src/spreadsheet/sheetJsAdapter.realBiff8.test.ts +++ b/src/spreadsheet/sheetJsAdapter.realBiff8.test.ts @@ -4,18 +4,48 @@ import { sheetJsBytesToWorkbookData, type SheetJsParserModule, } from './sheetJsAdapter.js'; +import { + parseSheetJsSpreadsheetBytes, + spreadsheetFileToDocumentJson, +} from './sheetJsRuntime.js'; import { preflightSpreadsheetBinarySource } from './spreadsheetImport.js'; function realBiff8WithHiddenSheet(): Uint8Array { const workbook = XLSX.utils.book_new(); + const summary = XLSX.utils.aoa_to_sheet([ + ['Kind', 'Value'], + ['Unicode', '매출'], + ['Multiline', 'line 1\nline 2'], + ['Boolean', true], + [ + 'Date', + { + t: 'd', + v: new Date(Date.UTC(2026, 7, 17)), + z: 'yyyy-mm-dd', + } satisfies XLSX.CellObject, + ], + [ + 'Formula', + { + t: 'n', + v: 42, + f: 'SUM(40,2)', + } satisfies XLSX.CellObject, + ], + [ + 'Hyperlink', + { + t: 's', + v: 'Reference', + l: { Target: 'https://secret.invalid/workbook' }, + } satisfies XLSX.CellObject, + ], + ]); + XLSX.utils.book_append_sheet(workbook, summary, 'Summary'); XLSX.utils.book_append_sheet( workbook, - XLSX.utils.aoa_to_sheet([['public']]), - 'Summary', - ); - XLSX.utils.book_append_sheet( - workbook, - XLSX.utils.aoa_to_sheet([['private']]), + XLSX.utils.aoa_to_sheet([['private hidden value']]), 'Hidden', ); XLSX.utils.book_append_sheet(workbook, XLSX.utils.aoa_to_sheet([]), 'Empty'); @@ -30,8 +60,20 @@ function realBiff8WithHiddenSheet(): Uint8Array { : new Uint8Array(serialized as ArrayBuffer); } +function visibilityProjection(workbook: { + readonly worksheets: readonly { readonly name: string; readonly hidden: boolean }[]; +}) { + return workbook.worksheets.map(({ name, hidden }) => ({ name, hidden })); +} + +const EXPECTED_VISIBILITY = [ + { name: 'Summary', hidden: false }, + { name: 'Hidden', hidden: true }, + { name: 'Empty', hidden: false }, +] as const; + describe('real BIFF8 hidden-sheet metadata', () => { - it('survives the lazy parser boundary and remains authoritative in the adapter', async () => { + it('survives parser, adapter, runtime, and file-source boundaries', async () => { const bytes = realBiff8WithHiddenSheet(); expect(preflightSpreadsheetBinarySource(bytes).format).toBe('xls'); @@ -61,14 +103,31 @@ describe('real BIFF8 hidden-sheet metadata', () => { ).toBe(1); expect( - sheetJsBytesToWorkbookData( - bytes, - lazyXlsx as unknown as SheetJsParserModule, - ).worksheets.map(({ name, hidden }) => ({ name, hidden })), - ).toEqual([ - { name: 'Summary', hidden: false }, - { name: 'Hidden', hidden: true }, - { name: 'Empty', hidden: false }, - ]); + visibilityProjection( + sheetJsBytesToWorkbookData( + bytes, + lazyXlsx as unknown as SheetJsParserModule, + ), + ), + ).toEqual(EXPECTED_VISIBILITY); + + expect( + visibilityProjection(await parseSheetJsSpreadsheetBytes(bytes)), + ).toEqual(EXPECTED_VISIBILITY); + + const result = await spreadsheetFileToDocumentJson({ + size: bytes.byteLength, + async arrayBuffer() { + return bytes.buffer.slice( + bytes.byteOffset, + bytes.byteOffset + bytes.byteLength, + ) as ArrayBuffer; + }, + }); + expect(result).toMatchObject({ + worksheetCount: 1, + rowCount: 7, + cellCount: 14, + }); }); }); From 8d5c124d10c7874b785724e55ca795e805540c6a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 21:24:29 +0900 Subject: [PATCH 117/163] test(spreadsheet): compare BIFF8 browser byte representations --- .../sheetJsAdapter.realBiff8.test.ts | 49 +++++++++++++------ 1 file changed, 34 insertions(+), 15 deletions(-) diff --git a/src/spreadsheet/sheetJsAdapter.realBiff8.test.ts b/src/spreadsheet/sheetJsAdapter.realBiff8.test.ts index e02db0f8..99ba24db 100644 --- a/src/spreadsheet/sheetJsAdapter.realBiff8.test.ts +++ b/src/spreadsheet/sheetJsAdapter.realBiff8.test.ts @@ -60,6 +60,10 @@ function realBiff8WithHiddenSheet(): Uint8Array { : new Uint8Array(serialized as ArrayBuffer); } +function hiddenStates(workbook: XLSX.WorkBook) { + return workbook.Workbook?.Sheets?.map((sheet) => sheet.Hidden ?? 0); +} + function visibilityProjection(workbook: { readonly worksheets: readonly { readonly name: string; readonly hidden: boolean }[]; }) { @@ -72,29 +76,29 @@ const EXPECTED_VISIBILITY = [ { name: 'Empty', hidden: false }, ] as const; +const VISIBILITY_OPTIONS = { + type: 'array', + cellFormula: false, + cellHTML: false, + cellNF: false, + bookVBA: false, + sheetRows: 1, +} as const; + describe('real BIFF8 hidden-sheet metadata', () => { it('survives parser, adapter, runtime, and file-source boundaries', async () => { const bytes = realBiff8WithHiddenSheet(); expect(preflightSpreadsheetBinarySource(bytes).format).toBe('xls'); const lazyXlsx = await import('xlsx'); - const visibilityWorkbook = lazyXlsx.read(bytes, { - type: 'array', - cellFormula: false, - cellHTML: false, - cellNF: false, - bookVBA: false, - sheetRows: 1, - }); + const visibilityWorkbook = lazyXlsx.read(bytes, VISIBILITY_OPTIONS); expect(visibilityWorkbook.SheetNames).toEqual([ 'Summary', 'Hidden', 'Empty', ]); - expect( - visibilityWorkbook.Workbook?.Sheets?.map((sheet) => sheet.Hidden ?? 0), - ).toEqual([0, 1, 0]); + expect(hiddenStates(visibilityWorkbook)).toEqual([0, 1, 0]); expect( Object.getOwnPropertyDescriptor( visibilityWorkbook.Workbook!.Sheets![1]!, @@ -102,6 +106,24 @@ describe('real BIFF8 hidden-sheet metadata', () => { )?.value, ).toBe(1); + const copiedBuffer = bytes.buffer.slice( + bytes.byteOffset, + bytes.byteOffset + bytes.byteLength, + ) as ArrayBuffer; + const browserBytes = new Uint8Array(copiedBuffer); + expect(Array.from(browserBytes)).toEqual(Array.from(bytes)); + expect({ + browserUint8Array: hiddenStates( + lazyXlsx.read(browserBytes, VISIBILITY_OPTIONS), + ), + browserArrayBuffer: hiddenStates( + lazyXlsx.read(copiedBuffer, VISIBILITY_OPTIONS), + ), + }).toEqual({ + browserUint8Array: [0, 1, 0], + browserArrayBuffer: [0, 1, 0], + }); + expect( visibilityProjection( sheetJsBytesToWorkbookData( @@ -118,10 +140,7 @@ describe('real BIFF8 hidden-sheet metadata', () => { const result = await spreadsheetFileToDocumentJson({ size: bytes.byteLength, async arrayBuffer() { - return bytes.buffer.slice( - bytes.byteOffset, - bytes.byteOffset + bytes.byteLength, - ) as ArrayBuffer; + return copiedBuffer; }, }); expect(result).toMatchObject({ From dff699b5c84a30717588b8cb2d7f82211b796d54 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 21:27:35 +0900 Subject: [PATCH 118/163] test(spreadsheet): isolate copied-byte adapter behavior --- .../sheetJsAdapter.realBiff8.test.ts | 30 +++++++++++++++---- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/src/spreadsheet/sheetJsAdapter.realBiff8.test.ts b/src/spreadsheet/sheetJsAdapter.realBiff8.test.ts index 99ba24db..79d34657 100644 --- a/src/spreadsheet/sheetJsAdapter.realBiff8.test.ts +++ b/src/spreadsheet/sheetJsAdapter.realBiff8.test.ts @@ -70,6 +70,13 @@ function visibilityProjection(workbook: { return workbook.worksheets.map(({ name, hidden }) => ({ name, hidden })); } +function exactArrayBuffer(bytes: Uint8Array): ArrayBuffer { + return bytes.buffer.slice( + bytes.byteOffset, + bytes.byteOffset + bytes.byteLength, + ) as ArrayBuffer; +} + const EXPECTED_VISIBILITY = [ { name: 'Summary', hidden: false }, { name: 'Hidden', hidden: true }, @@ -106,10 +113,7 @@ describe('real BIFF8 hidden-sheet metadata', () => { )?.value, ).toBe(1); - const copiedBuffer = bytes.buffer.slice( - bytes.byteOffset, - bytes.byteOffset + bytes.byteLength, - ) as ArrayBuffer; + const copiedBuffer = exactArrayBuffer(bytes); const browserBytes = new Uint8Array(copiedBuffer); expect(Array.from(browserBytes)).toEqual(Array.from(bytes)); expect({ @@ -132,15 +136,31 @@ describe('real BIFF8 hidden-sheet metadata', () => { ), ), ).toEqual(EXPECTED_VISIBILITY); + expect( + visibilityProjection( + sheetJsBytesToWorkbookData( + new Uint8Array(exactArrayBuffer(bytes)), + lazyXlsx as unknown as SheetJsParserModule, + ), + ), + ).toEqual(EXPECTED_VISIBILITY); expect( visibilityProjection(await parseSheetJsSpreadsheetBytes(bytes)), ).toEqual(EXPECTED_VISIBILITY); + expect( + visibilityProjection( + await parseSheetJsSpreadsheetBytes( + new Uint8Array(exactArrayBuffer(bytes)), + ), + ), + ).toEqual(EXPECTED_VISIBILITY); + const fileBuffer = exactArrayBuffer(bytes); const result = await spreadsheetFileToDocumentJson({ size: bytes.byteLength, async arrayBuffer() { - return copiedBuffer; + return fileBuffer; }, }); expect(result).toMatchObject({ From 1f56dc2439f51066472f04897743664de56e86f5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 21:31:46 +0900 Subject: [PATCH 119/163] test(spreadsheet): require isolated parser-loader preflight seam --- .../sheetJsRuntimePreflight.test.ts | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/src/spreadsheet/sheetJsRuntimePreflight.test.ts b/src/spreadsheet/sheetJsRuntimePreflight.test.ts index 2dbc2e29..f32134f5 100644 --- a/src/spreadsheet/sheetJsRuntimePreflight.test.ts +++ b/src/spreadsheet/sheetJsRuntimePreflight.test.ts @@ -1,24 +1,23 @@ import { describe, expect, it, vi } from 'vitest'; - -const { parserLoad } = vi.hoisted(() => ({ parserLoad: vi.fn() })); - -vi.mock('xlsx', () => { - parserLoad(); - return {}; -}); - -import { parseSheetJsSpreadsheetBytes } from './sheetJsRuntime.js'; +import { parseSheetJsSpreadsheetBytesWithParserLoader } from './sheetJsRuntime.js'; describe('SheetJS runtime source preflight ordering', () => { it('rejects an unsupported binary envelope before loading the parser package', async () => { + const loadParser = vi.fn(async () => { + throw new Error('parser loader must not run before binary preflight'); + }); + await expect( - parseSheetJsSpreadsheetBytes(new Uint8Array([0x00, 0x01, 0x02, 0x03])), + parseSheetJsSpreadsheetBytesWithParserLoader( + new Uint8Array([0x00, 0x01, 0x02, 0x03]), + loadParser, + ), ).rejects.toMatchObject({ name: 'SpreadsheetImportError', code: 'UNSUPPORTED_OR_CORRUPT', message: 'Spreadsheet source is unsupported or corrupt.', }); - expect(parserLoad).not.toHaveBeenCalled(); + expect(loadParser).not.toHaveBeenCalled(); }); }); From 05627eeb692e1e852e2dc3f0636f2beb21ba4040 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 22:04:37 +0900 Subject: [PATCH 120/163] fix(spreadsheet): isolate parser load behind binary preflight --- src/spreadsheet/sheetJsRuntime.ts | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/src/spreadsheet/sheetJsRuntime.ts b/src/spreadsheet/sheetJsRuntime.ts index 013c4df8..cfc72bb0 100644 --- a/src/spreadsheet/sheetJsRuntime.ts +++ b/src/spreadsheet/sheetJsRuntime.ts @@ -34,6 +34,22 @@ function unsupportedOrCorruptSource(): SpreadsheetImportError { ); } +/** + * Parse supported local XLS/XLSX bytes after preflighting their binary envelope. + * + * The parser loader is deliberately injected so the preflight ordering is directly + * testable without importing the parser package. The loader is not invoked until the + * caller-controlled bytes have crossed Inkspan's local signature and resource bounds. + */ +export async function parseSheetJsSpreadsheetBytesWithParserLoader( + source: Uint8Array, + loadParser: () => Promise, +): Promise { + const boundedSource = preflightSpreadsheetBinarySource(source); + const parser = await loadParser(); + return sheetJsBytesToWorkbookData(boundedSource.bytes, parser); +} + /** * Parse supported local XLS/XLSX bytes through Inkspan's bounded SheetJS adapter. * @@ -47,9 +63,10 @@ function unsupportedOrCorruptSource(): SpreadsheetImportError { export async function parseSheetJsSpreadsheetBytes( source: Uint8Array, ): Promise { - const boundedSource = preflightSpreadsheetBinarySource(source); - const parser = (await import('xlsx')) as unknown as SheetJsParserModule; - return sheetJsBytesToWorkbookData(boundedSource.bytes, parser); + return parseSheetJsSpreadsheetBytesWithParserLoader( + source, + async () => (await import('xlsx')) as unknown as SheetJsParserModule, + ); } /** From ab2577b9b29eb82dc6b8c89ab9441d65a9a90ccd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 22:10:39 +0900 Subject: [PATCH 121/163] fix(spreadsheet): preserve pristine BIFF8 visibility metadata --- src/spreadsheet/sheetJsAdapter.ts | 38 +++++++++++++++++-------------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/src/spreadsheet/sheetJsAdapter.ts b/src/spreadsheet/sheetJsAdapter.ts index 7f589c33..591aaca4 100644 --- a/src/spreadsheet/sheetJsAdapter.ts +++ b/src/spreadsheet/sheetJsAdapter.ts @@ -253,22 +253,33 @@ function baseReadOptions(): Omit< /** * Project locally parsed SheetJS workbook data into Inkspan's parser-neutral * workbook contract without granting formulas, macros, links, or parser output - * any editor authority. Inkspan first performs a sheet-name-only discovery pass. - * BIFF8 then receives one bounded whole-workbook metadata pass because selective - * BIFF8 parses do not reliably retain the source sheet visibility flag. Only - * visible worksheets are subsequently parsed with a row ceiling derived from - * the remaining aggregate workbook budget. Exact decoded row, column, and cell - * limits are checked before displayed rows are materialized by `sheet_to_json`. + * any editor authority. XLSX first performs a sheet-name-only discovery pass. + * BIFF8 instead uses one bounded `sheetRows: 1` whole-workbook parse as both + * discovery and the authoritative visibility read: issuing SheetJS's BIFF8 + * `bookSheets` pass first can alter the parser's subsequent visibility result for + * the same fresh byte source. Only worksheets proven visible are selectively + * body-parsed with a row ceiling derived from the remaining aggregate workbook + * budget. Exact decoded row, column, and cell limits are checked before displayed + * rows are materialized by `sheet_to_json`. */ export function sheetJsBytesToWorkbookData( source: Uint8Array, parser: SheetJsParserModule, ): SpreadsheetWorkbookData { const boundedSource = preflightSpreadsheetBinarySource(source); - const discovery = readWorkbook(parser, boundedSource.bytes, { - ...baseReadOptions(), - bookSheets: true, - }); + const biff8VisibilityWorkbook = + boundedSource.format === 'xls' + ? readWorkbook(parser, boundedSource.bytes, { + ...baseReadOptions(), + sheetRows: 1, + }) + : undefined; + const discovery = + biff8VisibilityWorkbook ?? + readWorkbook(parser, boundedSource.bytes, { + ...baseReadOptions(), + bookSheets: true, + }); const sheetNames = readOwnDataProperty(discovery, 'SheetNames'); if (!isArray(sheetNames)) unsupportedOrCorruptSource(); const sheetCount = readArrayLength(sheetNames); @@ -282,13 +293,6 @@ export function sheetJsBytesToWorkbookData( worksheetNames.push(name); } - const biff8VisibilityWorkbook = - boundedSource.format === 'xls' && sheetCount > 0 - ? readWorkbook(parser, boundedSource.bytes, { - ...baseReadOptions(), - sheetRows: 1, - }) - : undefined; const biff8SheetMetadata = biff8VisibilityWorkbook === undefined ? undefined From 0db851337582cb1d9d68da12497108ed2857a99e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 22:11:00 +0900 Subject: [PATCH 122/163] test(spreadsheet): bind BIFF8 discovery to pristine visibility pass --- .../sheetJsAdapter.visibilityMetadata.test.ts | 24 ++++--------------- 1 file changed, 5 insertions(+), 19 deletions(-) diff --git a/src/spreadsheet/sheetJsAdapter.visibilityMetadata.test.ts b/src/spreadsheet/sheetJsAdapter.visibilityMetadata.test.ts index ea20663c..549bc0c0 100644 --- a/src/spreadsheet/sheetJsAdapter.visibilityMetadata.test.ts +++ b/src/spreadsheet/sheetJsAdapter.visibilityMetadata.test.ts @@ -16,7 +16,7 @@ const BIFF8_SOURCE = new Uint8Array([ ]); describe('sheetJsBytesToWorkbookData BIFF8 visibility authority', () => { - it('uses bounded whole-workbook metadata instead of selective-parse visibility', () => { + it('uses the bounded pristine whole-workbook pass as discovery and visibility authority', () => { const summarySheet = { '!ref': 'A1' }; const privateSheet = { '!ref': 'A1' }; const read = vi.fn( @@ -24,12 +24,6 @@ describe('sheetJsBytesToWorkbookData BIFF8 visibility authority', () => { _source: Uint8Array, options: Parameters[1], ): unknown => { - if (options.bookSheets === true) { - return { - SheetNames: ['Summary', 'Private'], - Sheets: {}, - }; - } if (options.sheetRows === 1 && options.sheets === undefined) { return { SheetNames: ['Summary', 'Private'], @@ -76,16 +70,8 @@ describe('sheetJsBytesToWorkbookData BIFF8 visibility authority', () => { { name: 'Private', hidden: true, rows: [] }, ], }); - expect(read).toHaveBeenCalledTimes(3); + expect(read).toHaveBeenCalledTimes(2); expect(read).toHaveBeenNthCalledWith(1, BIFF8_SOURCE, { - type: 'array', - cellFormula: false, - cellHTML: false, - cellNF: false, - bookVBA: false, - bookSheets: true, - }); - expect(read).toHaveBeenNthCalledWith(2, BIFF8_SOURCE, { type: 'array', cellFormula: false, cellHTML: false, @@ -93,7 +79,7 @@ describe('sheetJsBytesToWorkbookData BIFF8 visibility authority', () => { bookVBA: false, sheetRows: 1, }); - expect(read).toHaveBeenNthCalledWith(3, BIFF8_SOURCE, { + expect(read).toHaveBeenNthCalledWith(2, BIFF8_SOURCE, { type: 'array', cellFormula: false, cellHTML: false, @@ -111,7 +97,7 @@ describe('sheetJsBytesToWorkbookData BIFF8 visibility authority', () => { }); }); - it('does not issue metadata or body reads when discovery reports no sheets', () => { + it('does not issue body reads when bounded BIFF8 discovery reports no sheets', () => { const read = vi.fn(() => ({ SheetNames: [], Sheets: {} })); const parser: SheetJsParserModule = { read, @@ -131,7 +117,7 @@ describe('sheetJsBytesToWorkbookData BIFF8 visibility authority', () => { cellHTML: false, cellNF: false, bookVBA: false, - bookSheets: true, + sheetRows: 1, }); }); }); From dce85ca035b2e18754bde09515a4e77543d8398c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 22:15:48 +0900 Subject: [PATCH 123/163] test(spreadsheet): snapshot BIFF8 visibility before body reads --- .../sheetJsAdapter.visibilityMetadata.test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/spreadsheet/sheetJsAdapter.visibilityMetadata.test.ts b/src/spreadsheet/sheetJsAdapter.visibilityMetadata.test.ts index 549bc0c0..c9ea4445 100644 --- a/src/spreadsheet/sheetJsAdapter.visibilityMetadata.test.ts +++ b/src/spreadsheet/sheetJsAdapter.visibilityMetadata.test.ts @@ -16,9 +16,10 @@ const BIFF8_SOURCE = new Uint8Array([ ]); describe('sheetJsBytesToWorkbookData BIFF8 visibility authority', () => { - it('uses the bounded pristine whole-workbook pass as discovery and visibility authority', () => { + it('snapshots pristine visibility before any selective body read can mutate parser metadata', () => { const summarySheet = { '!ref': 'A1' }; const privateSheet = { '!ref': 'A1' }; + const pristineSheetMetadata = [{ Hidden: 0 }, { Hidden: 1 }]; const read = vi.fn( ( _source: Uint8Array, @@ -32,11 +33,16 @@ describe('sheetJsBytesToWorkbookData BIFF8 visibility authority', () => { Private: privateSheet, }, Workbook: { - Sheets: [{ Hidden: 0 }, { Hidden: 1 }], + Sheets: pristineSheetMetadata, }, }; } if (options.sheets === 'Summary') { + // Model the real BIFF8 parser interaction observed in hosted CI: a + // later selective read can invalidate metadata objects retained from + // the pristine visibility parse. Inkspan must have copied the hidden + // decision before granting the parser another read. + pristineSheetMetadata[1]!.Hidden = 0; return { SheetNames: ['Summary'], Sheets: { Summary: summarySheet }, From 069e7350efa2d5da839a7a90a1d3f82001912b81 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 22:16:35 +0900 Subject: [PATCH 124/163] fix(spreadsheet): snapshot BIFF8 visibility before body parsing --- src/spreadsheet/sheetJsAdapter.ts | 33 ++++++++++++++++++------------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/src/spreadsheet/sheetJsAdapter.ts b/src/spreadsheet/sheetJsAdapter.ts index 591aaca4..3a70e76f 100644 --- a/src/spreadsheet/sheetJsAdapter.ts +++ b/src/spreadsheet/sheetJsAdapter.ts @@ -257,10 +257,12 @@ function baseReadOptions(): Omit< * BIFF8 instead uses one bounded `sheetRows: 1` whole-workbook parse as both * discovery and the authoritative visibility read: issuing SheetJS's BIFF8 * `bookSheets` pass first can alter the parser's subsequent visibility result for - * the same fresh byte source. Only worksheets proven visible are selectively - * body-parsed with a row ceiling derived from the remaining aggregate workbook - * budget. Exact decoded row, column, and cell limits are checked before displayed - * rows are materialized by `sheet_to_json`. + * the same fresh byte source. BIFF8 visibility decisions are copied to primitive + * booleans before any selective body read so later parser activity cannot mutate + * the authoritative metadata objects. Only worksheets proven visible are then + * selectively body-parsed with a row ceiling derived from the remaining aggregate + * workbook budget. Exact decoded row, column, and cell limits are checked before + * displayed rows are materialized by `sheet_to_json`. */ export function sheetJsBytesToWorkbookData( source: Uint8Array, @@ -297,22 +299,25 @@ export function sheetJsBytesToWorkbookData( biff8VisibilityWorkbook === undefined ? undefined : readWorkbookSheetMetadata(biff8VisibilityWorkbook); + const biff8HiddenStates = + biff8VisibilityWorkbook === undefined + ? undefined + : worksheetNames.map((name) => + readHiddenState( + biff8SheetMetadata, + readParsedSheetIndex(biff8VisibilityWorkbook, name), + ), + ); const worksheets: SpreadsheetWorksheetData[] = []; let visibleCount = 0; let decodedRows = 0; let decodedCells = 0; - for (const name of worksheetNames) { - if (biff8VisibilityWorkbook !== undefined) { - const visibilityIndex = readParsedSheetIndex( - biff8VisibilityWorkbook, - name, - ); - if (readHiddenState(biff8SheetMetadata, visibilityIndex)) { - worksheets.push({ name, hidden: true, rows: [] }); - continue; - } + for (const [worksheetIndex, name] of worksheetNames.entries()) { + if (biff8HiddenStates?.[worksheetIndex] === true) { + worksheets.push({ name, hidden: true, rows: [] }); + continue; } const remainingRows = MAX_WORKBOOK_ROWS - decodedRows; From 895dbf35f9d793aaa1e0eaf64dfdd94c1e373eec Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 22:21:58 +0900 Subject: [PATCH 125/163] test(spreadsheet): require repeatable BIFF8 source parsing --- .../sheetJsRuntimeInputIsolation.test.ts | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 src/spreadsheet/sheetJsRuntimeInputIsolation.test.ts diff --git a/src/spreadsheet/sheetJsRuntimeInputIsolation.test.ts b/src/spreadsheet/sheetJsRuntimeInputIsolation.test.ts new file mode 100644 index 00000000..a8e7a6dd --- /dev/null +++ b/src/spreadsheet/sheetJsRuntimeInputIsolation.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; +import * as XLSX from 'xlsx'; +import { parseSheetJsSpreadsheetBytes } from './sheetJsRuntime.js'; + +function realBiff8WithHiddenSheet(): Uint8Array { + const workbook = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet( + workbook, + XLSX.utils.aoa_to_sheet([ + ['Kind', 'Value'], + ['Revenue', 42], + ]), + 'Summary', + ); + XLSX.utils.book_append_sheet( + workbook, + XLSX.utils.aoa_to_sheet([['private hidden value']]), + 'Hidden', + ); + workbook.Workbook = { + ...(workbook.Workbook ?? {}), + Sheets: [{ Hidden: 0 }, { Hidden: 1 }], + }; + + const serialized = XLSX.write(workbook, { type: 'array', bookType: 'biff8' }); + return serialized instanceof Uint8Array + ? serialized + : new Uint8Array(serialized as ArrayBuffer); +} + +function visibilityProjection(workbook: { + readonly worksheets: readonly { readonly name: string; readonly hidden: boolean }[]; +}) { + return workbook.worksheets.map(({ name, hidden }) => ({ name, hidden })); +} + +const EXPECTED_VISIBILITY = [ + { name: 'Summary', hidden: false }, + { name: 'Hidden', hidden: true }, +] as const; + +describe('SheetJS BIFF8 runtime source isolation', () => { + it('does not mutate caller bytes and gives the same visibility on repeated imports', async () => { + const bytes = realBiff8WithHiddenSheet(); + const pristineBytes = Array.from(bytes); + + expect( + visibilityProjection(await parseSheetJsSpreadsheetBytes(bytes)), + ).toEqual(EXPECTED_VISIBILITY); + expect(Array.from(bytes)).toEqual(pristineBytes); + + expect( + visibilityProjection(await parseSheetJsSpreadsheetBytes(bytes)), + ).toEqual(EXPECTED_VISIBILITY); + expect(Array.from(bytes)).toEqual(pristineBytes); + }); +}); From e86818774254844a2e86196509b923f186872c12 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 22:25:10 +0900 Subject: [PATCH 126/163] test(spreadsheet): isolate BIFF8 visibility across workbook imports --- .../sheetJsRuntimeInputIsolation.test.ts | 38 +++++++++++++++++-- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/src/spreadsheet/sheetJsRuntimeInputIsolation.test.ts b/src/spreadsheet/sheetJsRuntimeInputIsolation.test.ts index a8e7a6dd..2e5cb817 100644 --- a/src/spreadsheet/sheetJsRuntimeInputIsolation.test.ts +++ b/src/spreadsheet/sheetJsRuntimeInputIsolation.test.ts @@ -2,6 +2,13 @@ import { describe, expect, it } from 'vitest'; import * as XLSX from 'xlsx'; import { parseSheetJsSpreadsheetBytes } from './sheetJsRuntime.js'; +function serializeBiff8(workbook: XLSX.WorkBook): Uint8Array { + const serialized = XLSX.write(workbook, { type: 'array', bookType: 'biff8' }); + return serialized instanceof Uint8Array + ? serialized + : new Uint8Array(serialized as ArrayBuffer); +} + function realBiff8WithHiddenSheet(): Uint8Array { const workbook = XLSX.utils.book_new(); XLSX.utils.book_append_sheet( @@ -21,11 +28,20 @@ function realBiff8WithHiddenSheet(): Uint8Array { ...(workbook.Workbook ?? {}), Sheets: [{ Hidden: 0 }, { Hidden: 1 }], }; + return serializeBiff8(workbook); +} - const serialized = XLSX.write(workbook, { type: 'array', bookType: 'biff8' }); - return serialized instanceof Uint8Array - ? serialized - : new Uint8Array(serialized as ArrayBuffer); +function realBiff8WithoutHiddenSheet(): Uint8Array { + const workbook = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet( + workbook, + XLSX.utils.aoa_to_sheet([ + ['Kind', 'Value'], + ['Previous', 1], + ]), + 'Previous', + ); + return serializeBiff8(workbook); } function visibilityProjection(workbook: { @@ -54,4 +70,18 @@ describe('SheetJS BIFF8 runtime source isolation', () => { ).toEqual(EXPECTED_VISIBILITY); expect(Array.from(bytes)).toEqual(pristineBytes); }); + + it('does not let an earlier BIFF8 workbook alter a later workbook visibility decision', async () => { + expect( + visibilityProjection( + await parseSheetJsSpreadsheetBytes(realBiff8WithoutHiddenSheet()), + ), + ).toEqual([{ name: 'Previous', hidden: false }]); + + expect( + visibilityProjection( + await parseSheetJsSpreadsheetBytes(realBiff8WithHiddenSheet()), + ), + ).toEqual(EXPECTED_VISIBILITY); + }); }); From 773862d8bbc152675ac22ce9ca5af03e1ab063fd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 22:58:22 +0900 Subject: [PATCH 127/163] fix(spreadsheet): isolate BIFF8 parser source bytes --- src/spreadsheet/sheetJsAdapter.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/spreadsheet/sheetJsAdapter.ts b/src/spreadsheet/sheetJsAdapter.ts index 3a70e76f..6f9d00d8 100644 --- a/src/spreadsheet/sheetJsAdapter.ts +++ b/src/spreadsheet/sheetJsAdapter.ts @@ -269,16 +269,24 @@ export function sheetJsBytesToWorkbookData( parser: SheetJsParserModule, ): SpreadsheetWorkbookData { const boundedSource = preflightSpreadsheetBinarySource(source); + // The legacy BIFF8/CFB parser is invoked multiple times per workbook. Give that + // parser one bounded invocation-local copy so any in-place parser-side changes + // cannot accumulate in caller-owned bytes across adapter/runtime boundaries. + // Copying once here preserves the 64 MiB source ceiling without per-sheet copies. + const parserSource = + boundedSource.format === 'xls' + ? new Uint8Array(boundedSource.bytes) + : boundedSource.bytes; const biff8VisibilityWorkbook = boundedSource.format === 'xls' - ? readWorkbook(parser, boundedSource.bytes, { + ? readWorkbook(parser, parserSource, { ...baseReadOptions(), sheetRows: 1, }) : undefined; const discovery = biff8VisibilityWorkbook ?? - readWorkbook(parser, boundedSource.bytes, { + readWorkbook(parser, parserSource, { ...baseReadOptions(), bookSheets: true, }); @@ -321,7 +329,7 @@ export function sheetJsBytesToWorkbookData( } const remainingRows = MAX_WORKBOOK_ROWS - decodedRows; - const parsed = readWorkbook(parser, boundedSource.bytes, { + const parsed = readWorkbook(parser, parserSource, { ...baseReadOptions(), sheets: name, sheetRows: remainingRows + 1, From b2a38b284eea8daf478b895f670bfaf4258793a8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 00:12:30 +0900 Subject: [PATCH 128/163] test(spreadsheet): isolate BIFF8 visibility parser input --- .../sheetJsAdapter.visibilityMetadata.test.ts | 75 ++++++++++++++++++- 1 file changed, 74 insertions(+), 1 deletion(-) diff --git a/src/spreadsheet/sheetJsAdapter.visibilityMetadata.test.ts b/src/spreadsheet/sheetJsAdapter.visibilityMetadata.test.ts index c9ea4445..05e5c893 100644 --- a/src/spreadsheet/sheetJsAdapter.visibilityMetadata.test.ts +++ b/src/spreadsheet/sheetJsAdapter.visibilityMetadata.test.ts @@ -103,6 +103,79 @@ describe('sheetJsBytesToWorkbookData BIFF8 visibility authority', () => { }); }); + it('isolates the authoritative BIFF8 visibility source from later body reads', () => { + const summarySheet = { '!ref': 'A1' }; + const privateSheet = { '!ref': 'A1' }; + let retainedVisibilitySource: Uint8Array | undefined; + let visibilityWasCorrupted = false; + + const read = vi.fn( + ( + source: Uint8Array, + options: Parameters[1], + ): unknown => { + if (options.sheetRows === 1 && options.sheets === undefined) { + retainedVisibilitySource = source; + const hidden = visibilityWasCorrupted ? 0 : 1; + visibilityWasCorrupted = false; + return { + SheetNames: ['Summary', 'Private'], + Sheets: { + Summary: summarySheet, + Private: privateSheet, + }, + Workbook: { + Sheets: [{ Hidden: 0 }, { Hidden: hidden }], + }, + }; + } + if (options.sheets === 'Summary') { + if (source === retainedVisibilitySource) { + visibilityWasCorrupted = true; + } + return { + SheetNames: ['Summary'], + Sheets: { Summary: summarySheet }, + Workbook: { Sheets: [{ Hidden: 0 }] }, + }; + } + if (options.sheets === 'Private') { + return { + SheetNames: ['Private'], + Sheets: { Private: privateSheet }, + Workbook: { Sheets: [{ Hidden: 0 }] }, + }; + } + throw new Error('unexpected parser invocation'); + }, + ); + const parser: SheetJsParserModule = { + read, + utils: { + decode_range: () => ({ s: { r: 0, c: 0 }, e: { r: 0, c: 0 } }), + sheet_to_json: vi.fn(() => [['public']]), + }, + }; + + expect(sheetJsBytesToWorkbookData(BIFF8_SOURCE, parser)).toEqual({ + worksheets: [ + { name: 'Summary', hidden: false, rows: [['public']] }, + { name: 'Private', hidden: true, rows: [] }, + ], + }); + expect(sheetJsBytesToWorkbookData(BIFF8_SOURCE, parser)).toEqual({ + worksheets: [ + { name: 'Summary', hidden: false, rows: [['public']] }, + { name: 'Private', hidden: true, rows: [] }, + ], + }); + + expect(read).toHaveBeenCalledTimes(4); + expect(read.mock.calls[0]?.[0]).not.toBe(BIFF8_SOURCE); + expect(read.mock.calls[0]?.[0]).not.toBe(read.mock.calls[1]?.[0]); + expect(read.mock.calls[2]?.[0]).not.toBe(read.mock.calls[3]?.[0]); + }); + it('does not issue body reads when bounded BIFF8 discovery reports no sheets', () => { const read = vi.fn(() => ({ SheetNames: [], Sheets: {} })); const parser: SheetJsParserModule = { @@ -126,4 +199,4 @@ describe('sheetJsBytesToWorkbookData BIFF8 visibility authority', () => { sheetRows: 1, }); }); -}); +}); \ No newline at end of file From 7fad29e0b99ae3dc22da00d7a6af3c7b6f7d3851 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 00:13:47 +0900 Subject: [PATCH 129/163] fix(spreadsheet): separate BIFF8 visibility parser source --- src/spreadsheet/sheetJsAdapter.ts | 42 +++++++++++++++++-------------- 1 file changed, 23 insertions(+), 19 deletions(-) diff --git a/src/spreadsheet/sheetJsAdapter.ts b/src/spreadsheet/sheetJsAdapter.ts index 6f9d00d8..486a36bc 100644 --- a/src/spreadsheet/sheetJsAdapter.ts +++ b/src/spreadsheet/sheetJsAdapter.ts @@ -257,33 +257,37 @@ function baseReadOptions(): Omit< * BIFF8 instead uses one bounded `sheetRows: 1` whole-workbook parse as both * discovery and the authoritative visibility read: issuing SheetJS's BIFF8 * `bookSheets` pass first can alter the parser's subsequent visibility result for - * the same fresh byte source. BIFF8 visibility decisions are copied to primitive - * booleans before any selective body read so later parser activity cannot mutate - * the authoritative metadata objects. Only worksheets proven visible are then - * selectively body-parsed with a row ceiling derived from the remaining aggregate - * workbook budget. Exact decoded row, column, and cell limits are checked before - * displayed rows are materialized by `sheet_to_json`. + * the same fresh byte source. The visibility read receives an invocation-local + * source that is never reused for body parsing, and visibility decisions are copied + * to primitive booleans before any selective body read. This prevents later BIFF8 + * parser activity from mutating either retained metadata or the byte source that + * established visibility. Only worksheets proven visible are then selectively + * body-parsed with a row ceiling derived from the remaining aggregate workbook + * budget. Exact decoded row, column, and cell limits are checked before displayed + * rows are materialized by `sheet_to_json`. */ export function sheetJsBytesToWorkbookData( source: Uint8Array, parser: SheetJsParserModule, ): SpreadsheetWorkbookData { const boundedSource = preflightSpreadsheetBinarySource(source); - // The legacy BIFF8/CFB parser is invoked multiple times per workbook. Give that - // parser one bounded invocation-local copy so any in-place parser-side changes - // cannot accumulate in caller-owned bytes across adapter/runtime boundaries. - // Copying once here preserves the 64 MiB source ceiling without per-sheet copies. - const parserSource = - boundedSource.format === 'xls' - ? new Uint8Array(boundedSource.bytes) - : boundedSource.bytes; + const isBiff8 = boundedSource.format === 'xls'; + // SheetJS's legacy BIFF8/CFB reader can retain parser objects that refer to the + // invocation source. Keep the authoritative visibility input isolated from all + // later body reads, while still using at most two bounded parser-owned copies. + const biff8VisibilitySource = isBiff8 + ? new Uint8Array(boundedSource.bytes) + : undefined; + const parserSource = isBiff8 + ? new Uint8Array(boundedSource.bytes) + : boundedSource.bytes; const biff8VisibilityWorkbook = - boundedSource.format === 'xls' - ? readWorkbook(parser, parserSource, { + biff8VisibilitySource === undefined + ? undefined + : readWorkbook(parser, biff8VisibilitySource, { ...baseReadOptions(), sheetRows: 1, - }) - : undefined; + }); const discovery = biff8VisibilityWorkbook ?? readWorkbook(parser, parserSource, { @@ -384,4 +388,4 @@ export function sheetJsBytesToWorkbookData( } return { worksheets }; -} +} \ No newline at end of file From 44ddb0959e10c292ff1221581fa143b784feed79 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 00:19:50 +0900 Subject: [PATCH 130/163] test(spreadsheet): require one-snapshot BIFF8 parsing --- .../sheetJsAdapter.visibilityMetadata.test.ts | 171 ++++++------------ 1 file changed, 57 insertions(+), 114 deletions(-) diff --git a/src/spreadsheet/sheetJsAdapter.visibilityMetadata.test.ts b/src/spreadsheet/sheetJsAdapter.visibilityMetadata.test.ts index 05e5c893..bf62a2ac 100644 --- a/src/spreadsheet/sheetJsAdapter.visibilityMetadata.test.ts +++ b/src/spreadsheet/sheetJsAdapter.visibilityMetadata.test.ts @@ -15,48 +15,35 @@ const BIFF8_SOURCE = new Uint8Array([ 0xe1, ]); +const ONE_SNAPSHOT_OPTIONS = { + type: 'array', + cellFormula: false, + cellHTML: false, + cellNF: false, + bookVBA: false, + sheetRows: 10_001, +} as const; + describe('sheetJsBytesToWorkbookData BIFF8 visibility authority', () => { - it('snapshots pristine visibility before any selective body read can mutate parser metadata', () => { + it('projects BIFF8 visibility and visible bodies from one bounded workbook snapshot', () => { const summarySheet = { '!ref': 'A1' }; const privateSheet = { '!ref': 'A1' }; - const pristineSheetMetadata = [{ Hidden: 0 }, { Hidden: 1 }]; const read = vi.fn( ( _source: Uint8Array, options: Parameters[1], ): unknown => { - if (options.sheetRows === 1 && options.sheets === undefined) { - return { - SheetNames: ['Summary', 'Private'], - Sheets: { - Summary: summarySheet, - Private: privateSheet, - }, - Workbook: { - Sheets: pristineSheetMetadata, - }, - }; - } - if (options.sheets === 'Summary') { - // Model the real BIFF8 parser interaction observed in hosted CI: a - // later selective read can invalidate metadata objects retained from - // the pristine visibility parse. Inkspan must have copied the hidden - // decision before granting the parser another read. - pristineSheetMetadata[1]!.Hidden = 0; - return { - SheetNames: ['Summary'], - Sheets: { Summary: summarySheet }, - Workbook: { Sheets: [{ Hidden: 0 }] }, - }; - } - if (options.sheets === 'Private') { - return { - SheetNames: ['Private'], - Sheets: { Private: privateSheet }, - Workbook: { Sheets: [{ Hidden: 0 }] }, - }; - } - throw new Error('unexpected parser invocation'); + expect(options).toEqual(ONE_SNAPSHOT_OPTIONS); + return { + SheetNames: ['Summary', 'Private'], + Sheets: { + Summary: summarySheet, + Private: privateSheet, + }, + Workbook: { + Sheets: [{ Hidden: 0 }, { Hidden: 1 }], + }, + }; }, ); const sheetToJson = vi.fn((sheet: unknown) => @@ -76,24 +63,9 @@ describe('sheetJsBytesToWorkbookData BIFF8 visibility authority', () => { { name: 'Private', hidden: true, rows: [] }, ], }); - expect(read).toHaveBeenCalledTimes(2); - expect(read).toHaveBeenNthCalledWith(1, BIFF8_SOURCE, { - type: 'array', - cellFormula: false, - cellHTML: false, - cellNF: false, - bookVBA: false, - sheetRows: 1, - }); - expect(read).toHaveBeenNthCalledWith(2, BIFF8_SOURCE, { - type: 'array', - cellFormula: false, - cellHTML: false, - cellNF: false, - bookVBA: false, - sheets: 'Summary', - sheetRows: 10_001, - }); + expect(read).toHaveBeenCalledTimes(1); + expect(read.mock.calls[0]?.[0]).not.toBe(BIFF8_SOURCE); + expect(read).toHaveBeenCalledWith(expect.any(Uint8Array), ONE_SNAPSHOT_OPTIONS); expect(sheetToJson).toHaveBeenCalledTimes(1); expect(sheetToJson).toHaveBeenCalledWith(summarySheet, { header: 1, @@ -103,80 +75,58 @@ describe('sheetJsBytesToWorkbookData BIFF8 visibility authority', () => { }); }); - it('isolates the authoritative BIFF8 visibility source from later body reads', () => { + it('remains deterministic across repeated BIFF8 imports without selective parser reads', () => { const summarySheet = { '!ref': 'A1' }; const privateSheet = { '!ref': 'A1' }; - let retainedVisibilitySource: Uint8Array | undefined; - let visibilityWasCorrupted = false; + let parserHistoryWasPoisoned = false; const read = vi.fn( ( - source: Uint8Array, + _source: Uint8Array, options: Parameters[1], ): unknown => { - if (options.sheetRows === 1 && options.sheets === undefined) { - retainedVisibilitySource = source; - const hidden = visibilityWasCorrupted ? 0 : 1; - visibilityWasCorrupted = false; - return { - SheetNames: ['Summary', 'Private'], - Sheets: { - Summary: summarySheet, - Private: privateSheet, - }, - Workbook: { - Sheets: [{ Hidden: 0 }, { Hidden: hidden }], - }, - }; - } - if (options.sheets === 'Summary') { - if (source === retainedVisibilitySource) { - visibilityWasCorrupted = true; - } - return { - SheetNames: ['Summary'], - Sheets: { Summary: summarySheet }, - Workbook: { Sheets: [{ Hidden: 0 }] }, - }; + if (options.sheets !== undefined || options.sheetRows === 1) { + parserHistoryWasPoisoned = true; } - if (options.sheets === 'Private') { - return { - SheetNames: ['Private'], - Sheets: { Private: privateSheet }, - Workbook: { Sheets: [{ Hidden: 0 }] }, - }; - } - throw new Error('unexpected parser invocation'); + const hidden = parserHistoryWasPoisoned ? 0 : 1; + return { + SheetNames: ['Summary', 'Private'], + Sheets: { + Summary: summarySheet, + Private: privateSheet, + }, + Workbook: { + Sheets: [{ Hidden: 0 }, { Hidden: hidden }], + }, + }; }, ); const parser: SheetJsParserModule = { read, utils: { decode_range: () => ({ s: { r: 0, c: 0 }, e: { r: 0, c: 0 } }), - sheet_to_json: vi.fn(() => [['public']]), + sheet_to_json: vi.fn((sheet: unknown) => + sheet === summarySheet ? [['public']] : [['private']], + ), }, }; - expect(sheetJsBytesToWorkbookData(BIFF8_SOURCE, parser)).toEqual({ - worksheets: [ - { name: 'Summary', hidden: false, rows: [['public']] }, - { name: 'Private', hidden: true, rows: [] }, - ], - }); - expect(sheetJsBytesToWorkbookData(BIFF8_SOURCE, parser)).toEqual({ - worksheets: [ - { name: 'Summary', hidden: false, rows: [['public']] }, - { name: 'Private', hidden: true, rows: [] }, - ], - }); + for (let attempt = 0; attempt < 2; attempt += 1) { + expect(sheetJsBytesToWorkbookData(BIFF8_SOURCE, parser)).toEqual({ + worksheets: [ + { name: 'Summary', hidden: false, rows: [['public']] }, + { name: 'Private', hidden: true, rows: [] }, + ], + }); + } - expect(read).toHaveBeenCalledTimes(4); - expect(read.mock.calls[0]?.[0]).not.toBe(BIFF8_SOURCE); - expect(read.mock.calls[0]?.[0]).not.toBe(read.mock.calls[1]?.[0]); - expect(read.mock.calls[2]?.[0]).not.toBe(read.mock.calls[3]?.[0]); + expect(read).toHaveBeenCalledTimes(2); + for (const [, options] of read.mock.calls) { + expect(options).toEqual(ONE_SNAPSHOT_OPTIONS); + } }); - it('does not issue body reads when bounded BIFF8 discovery reports no sheets', () => { + it('does not issue any additional parser reads when a bounded BIFF8 snapshot has no sheets', () => { const read = vi.fn(() => ({ SheetNames: [], Sheets: {} })); const parser: SheetJsParserModule = { read, @@ -190,13 +140,6 @@ describe('sheetJsBytesToWorkbookData BIFF8 visibility authority', () => { worksheets: [], }); expect(read).toHaveBeenCalledTimes(1); - expect(read).toHaveBeenCalledWith(BIFF8_SOURCE, { - type: 'array', - cellFormula: false, - cellHTML: false, - cellNF: false, - bookVBA: false, - sheetRows: 1, - }); + expect(read).toHaveBeenCalledWith(expect.any(Uint8Array), ONE_SNAPSHOT_OPTIONS); }); }); \ No newline at end of file From 8a34afde6ad4a71bbe7c2a81623f621aea9ec15a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 00:21:30 +0900 Subject: [PATCH 131/163] fix(spreadsheet): parse BIFF8 from one bounded snapshot --- src/spreadsheet/sheetJsAdapter.ts | 64 ++++++++++++++----------------- 1 file changed, 28 insertions(+), 36 deletions(-) diff --git a/src/spreadsheet/sheetJsAdapter.ts b/src/spreadsheet/sheetJsAdapter.ts index 486a36bc..754d45cf 100644 --- a/src/spreadsheet/sheetJsAdapter.ts +++ b/src/spreadsheet/sheetJsAdapter.ts @@ -253,18 +253,15 @@ function baseReadOptions(): Omit< /** * Project locally parsed SheetJS workbook data into Inkspan's parser-neutral * workbook contract without granting formulas, macros, links, or parser output - * any editor authority. XLSX first performs a sheet-name-only discovery pass. - * BIFF8 instead uses one bounded `sheetRows: 1` whole-workbook parse as both - * discovery and the authoritative visibility read: issuing SheetJS's BIFF8 - * `bookSheets` pass first can alter the parser's subsequent visibility result for - * the same fresh byte source. The visibility read receives an invocation-local - * source that is never reused for body parsing, and visibility decisions are copied - * to primitive booleans before any selective body read. This prevents later BIFF8 - * parser activity from mutating either retained metadata or the byte source that - * established visibility. Only worksheets proven visible are then selectively - * body-parsed with a row ceiling derived from the remaining aggregate workbook - * budget. Exact decoded row, column, and cell limits are checked before displayed - * rows are materialized by `sheet_to_json`. + * any editor authority. XLSX first performs a sheet-name-only discovery pass and + * then selectively parses individual sheet bodies against the remaining aggregate + * row budget. BIFF8 uses one invocation-local whole-workbook snapshot bounded to + * `MAX_WORKBOOK_ROWS + 1` rows per worksheet. Real BIFF8 evidence shows that + * mixing visibility and selective parser reads can make later hidden-sheet state + * depend on parser history, so visibility and displayed bodies must come from the + * same parser result. The source envelope remains bounded before parser loading, + * and exact worksheet/count/range/row/column/cell limits are revalidated before + * displayed rows are materialized into Inkspan's parser-neutral contract. */ export function sheetJsBytesToWorkbookData( source: Uint8Array, @@ -272,24 +269,17 @@ export function sheetJsBytesToWorkbookData( ): SpreadsheetWorkbookData { const boundedSource = preflightSpreadsheetBinarySource(source); const isBiff8 = boundedSource.format === 'xls'; - // SheetJS's legacy BIFF8/CFB reader can retain parser objects that refer to the - // invocation source. Keep the authoritative visibility input isolated from all - // later body reads, while still using at most two bounded parser-owned copies. - const biff8VisibilitySource = isBiff8 - ? new Uint8Array(boundedSource.bytes) - : undefined; const parserSource = isBiff8 ? new Uint8Array(boundedSource.bytes) : boundedSource.bytes; - const biff8VisibilityWorkbook = - biff8VisibilitySource === undefined - ? undefined - : readWorkbook(parser, biff8VisibilitySource, { - ...baseReadOptions(), - sheetRows: 1, - }); + const biff8Workbook = isBiff8 + ? readWorkbook(parser, parserSource, { + ...baseReadOptions(), + sheetRows: MAX_WORKBOOK_ROWS + 1, + }) + : undefined; const discovery = - biff8VisibilityWorkbook ?? + biff8Workbook ?? readWorkbook(parser, parserSource, { ...baseReadOptions(), bookSheets: true, @@ -308,16 +298,16 @@ export function sheetJsBytesToWorkbookData( } const biff8SheetMetadata = - biff8VisibilityWorkbook === undefined + biff8Workbook === undefined ? undefined - : readWorkbookSheetMetadata(biff8VisibilityWorkbook); + : readWorkbookSheetMetadata(biff8Workbook); const biff8HiddenStates = - biff8VisibilityWorkbook === undefined + biff8Workbook === undefined ? undefined : worksheetNames.map((name) => readHiddenState( biff8SheetMetadata, - readParsedSheetIndex(biff8VisibilityWorkbook, name), + readParsedSheetIndex(biff8Workbook, name), ), ); @@ -333,17 +323,19 @@ export function sheetJsBytesToWorkbookData( } const remainingRows = MAX_WORKBOOK_ROWS - decodedRows; - const parsed = readWorkbook(parser, parserSource, { - ...baseReadOptions(), - sheets: name, - sheetRows: remainingRows + 1, - }); + const parsed = + biff8Workbook ?? + readWorkbook(parser, parserSource, { + ...baseReadOptions(), + sheets: name, + sheetRows: remainingRows + 1, + }); const sheets = readOwnDataProperty(parsed, 'Sheets'); if (!isObject(sheets)) unsupportedOrCorruptSource(); const sheet = readOwnDataProperty(sheets, name); if (!isObject(sheet)) unsupportedOrCorruptSource(); - if (biff8VisibilityWorkbook === undefined) { + if (biff8Workbook === undefined) { const parsedSheetIndex = readParsedSheetIndex(parsed, name); const sheetMetadata = readWorkbookSheetMetadata(parsed); if (readHiddenState(sheetMetadata, parsedSheetIndex)) { From e8a66cb21f583a731a578cc4368092a08fc4f91c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 00:45:22 +0900 Subject: [PATCH 132/163] fix(spreadsheet): bind BIFF8 visibility to raw workbook records --- src/spreadsheet/sheetJsRuntime.ts | 184 +++++++++++++++++++++++++++++- 1 file changed, 183 insertions(+), 1 deletion(-) diff --git a/src/spreadsheet/sheetJsRuntime.ts b/src/spreadsheet/sheetJsRuntime.ts index cfc72bb0..21dd3160 100644 --- a/src/spreadsheet/sheetJsRuntime.ts +++ b/src/spreadsheet/sheetJsRuntime.ts @@ -11,6 +11,25 @@ import { } from './sheetJsAdapter.js'; const MAX_SPREADSHEET_SOURCE_BYTES = 64 * 1024 * 1024; +const MAX_BIFF8_WORKSHEETS = 256; +const BIFF8_BOF_RECORD = 0x0809; +const BIFF8_BOUNDSHEET8_RECORD = 0x0085; +const BIFF8_EOF_RECORD = 0x000a; +const BIFF8_VERSION = 0x0600; +const BIFF8_WORKBOOK_GLOBALS = 0x0005; + +interface SheetJsCfbEntry { + readonly content?: unknown; +} + +interface SheetJsCfbModule { + readonly read: (source: Uint8Array) => unknown; + readonly find: (container: unknown, path: string) => SheetJsCfbEntry | null; +} + +type SheetJsParserWithCfb = SheetJsParserModule & { + readonly CFB?: SheetJsCfbModule; +}; /** Minimal browser-file contract needed by the local spreadsheet import boundary. */ export interface SpreadsheetFileSource { @@ -34,12 +53,166 @@ function unsupportedOrCorruptSource(): SpreadsheetImportError { ); } +function isObject(value: unknown): value is object { + return typeof value === 'object' && value !== null; +} + +function readOwnDataProperty(source: object, key: PropertyKey): unknown { + let descriptor: PropertyDescriptor | undefined; + try { + descriptor = Object.getOwnPropertyDescriptor(source, key); + } catch { + throw unsupportedOrCorruptSource(); + } + if (descriptor === undefined || !('value' in descriptor)) { + throw unsupportedOrCorruptSource(); + } + return descriptor.value; +} + +function readUint16LittleEndian(source: Uint8Array, offset: number): number { + return source[offset]! | (source[offset + 1]! << 8); +} + +function readBiff8WorkbookStream( + source: Uint8Array, + parser: SheetJsParserModule, +): Uint8Array { + const cfb = (parser as SheetJsParserWithCfb).CFB; + if ( + !isObject(cfb) || + typeof cfb.read !== 'function' || + typeof cfb.find !== 'function' + ) { + throw unsupportedOrCorruptSource(); + } + + let container: unknown; + let workbookEntry: SheetJsCfbEntry | null; + try { + container = cfb.read(new Uint8Array(source)); + workbookEntry = cfb.find(container, 'Workbook'); + } catch { + throw unsupportedOrCorruptSource(); + } + if (!isObject(workbookEntry)) { + throw unsupportedOrCorruptSource(); + } + + const content = readOwnDataProperty(workbookEntry, 'content'); + if (!(content instanceof Uint8Array)) { + throw unsupportedOrCorruptSource(); + } + return new Uint8Array(content); +} + +function readBiff8HiddenStates(workbookStream: Uint8Array): readonly boolean[] { + let offset = 0; + let sawWorkbookBof = false; + const hiddenStates: boolean[] = []; + + while (offset + 4 <= workbookStream.byteLength) { + const recordType = readUint16LittleEndian(workbookStream, offset); + const recordLength = readUint16LittleEndian(workbookStream, offset + 2); + const payloadOffset = offset + 4; + const nextOffset = payloadOffset + recordLength; + if (nextOffset > workbookStream.byteLength) { + throw unsupportedOrCorruptSource(); + } + + if (!sawWorkbookBof) { + if ( + recordType !== BIFF8_BOF_RECORD || + recordLength < 4 || + readUint16LittleEndian(workbookStream, payloadOffset) !== BIFF8_VERSION || + readUint16LittleEndian(workbookStream, payloadOffset + 2) !== + BIFF8_WORKBOOK_GLOBALS + ) { + throw unsupportedOrCorruptSource(); + } + sawWorkbookBof = true; + } else if (recordType === BIFF8_BOUNDSHEET8_RECORD) { + if (recordLength < 8) { + throw unsupportedOrCorruptSource(); + } + const visibility = workbookStream[payloadOffset + 4]!; + if ((visibility & 0xfc) !== 0 || (visibility & 0x03) === 0x03) { + throw unsupportedOrCorruptSource(); + } + hiddenStates.push((visibility & 0x03) !== 0); + if (hiddenStates.length > MAX_BIFF8_WORKSHEETS) { + throw resourceLimitExceeded(); + } + } else if (recordType === BIFF8_EOF_RECORD) { + return hiddenStates; + } + + offset = nextOffset; + } + + throw unsupportedOrCorruptSource(); +} + +function readArrayLength(value: unknown): number { + if (!Array.isArray(value)) { + throw unsupportedOrCorruptSource(); + } + const length = readOwnDataProperty(value, 'length'); + if (!Number.isSafeInteger(length) || (length as number) < 0) { + throw unsupportedOrCorruptSource(); + } + return length as number; +} + +function withAuthoritativeBiff8Visibility( + parser: SheetJsParserModule, + hiddenStates: readonly boolean[], +): SheetJsParserModule { + const workbookMetadata = Object.freeze({ + Sheets: Object.freeze( + hiddenStates.map((hidden) => Object.freeze({ Hidden: hidden ? 1 : 0 })), + ), + }); + + return { + read(source, options) { + const parsedWorkbook = parser.read(source, options); + if (!isObject(parsedWorkbook)) { + return parsedWorkbook; + } + + const sheetNames = readOwnDataProperty(parsedWorkbook, 'SheetNames'); + if (readArrayLength(sheetNames) !== hiddenStates.length) { + throw unsupportedOrCorruptSource(); + } + + return new Proxy(parsedWorkbook, { + getOwnPropertyDescriptor(target, property) { + if (property === 'Workbook') { + return { + configurable: true, + enumerable: true, + value: workbookMetadata, + writable: false, + }; + } + return Reflect.getOwnPropertyDescriptor(target, property); + }, + }); + }, + utils: parser.utils, + }; +} + /** * Parse supported local XLS/XLSX bytes after preflighting their binary envelope. * * The parser loader is deliberately injected so the preflight ordering is directly * testable without importing the parser package. The loader is not invoked until the * caller-controlled bytes have crossed Inkspan's local signature and resource bounds. + * BIFF8 worksheet visibility is additionally recovered from the raw BoundSheet8 + * records in the CFB Workbook stream so confidentiality does not depend on mutable + * parser-emitted visibility metadata observed to vary across repeated reads. */ export async function parseSheetJsSpreadsheetBytesWithParserLoader( source: Uint8Array, @@ -47,7 +220,16 @@ export async function parseSheetJsSpreadsheetBytesWithParserLoader( ): Promise { const boundedSource = preflightSpreadsheetBinarySource(source); const parser = await loadParser(); - return sheetJsBytesToWorkbookData(boundedSource.bytes, parser); + if (boundedSource.format !== 'xls') { + return sheetJsBytesToWorkbookData(boundedSource.bytes, parser); + } + + const workbookStream = readBiff8WorkbookStream(boundedSource.bytes, parser); + const hiddenStates = readBiff8HiddenStates(workbookStream); + return sheetJsBytesToWorkbookData( + boundedSource.bytes, + withAuthoritativeBiff8Visibility(parser, hiddenStates), + ); } /** From 3942e03523cd324b80af1df1abb2f145a355b98f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 00:46:07 +0900 Subject: [PATCH 133/163] test(spreadsheet): distrust parser BIFF8 visibility metadata --- .../sheetJsRuntimeInputIsolation.test.ts | 35 ++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/src/spreadsheet/sheetJsRuntimeInputIsolation.test.ts b/src/spreadsheet/sheetJsRuntimeInputIsolation.test.ts index 2e5cb817..3e68b8fe 100644 --- a/src/spreadsheet/sheetJsRuntimeInputIsolation.test.ts +++ b/src/spreadsheet/sheetJsRuntimeInputIsolation.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from 'vitest'; import * as XLSX from 'xlsx'; -import { parseSheetJsSpreadsheetBytes } from './sheetJsRuntime.js'; +import type { SheetJsParserModule } from './sheetJsAdapter.js'; +import { + parseSheetJsSpreadsheetBytes, + parseSheetJsSpreadsheetBytesWithParserLoader, +} from './sheetJsRuntime.js'; function serializeBiff8(workbook: XLSX.WorkBook): Uint8Array { const serialized = XLSX.write(workbook, { type: 'array', bookType: 'biff8' }); @@ -84,4 +88,33 @@ describe('SheetJS BIFF8 runtime source isolation', () => { ), ).toEqual(EXPECTED_VISIBILITY); }); + + it('uses raw BIFF8 BoundSheet8 records when parser-emitted visibility is wrong', async () => { + const bytes = realBiff8WithHiddenSheet(); + const parserThatLosesHiddenMetadata = { + CFB: XLSX.CFB, + read( + source: Uint8Array, + options: Parameters[1], + ) { + const parsed = XLSX.read(source, options as XLSX.ParsingOptions); + if (parsed.Workbook?.Sheets?.[1] === undefined) { + throw new Error('fixture did not materialize hidden-sheet metadata'); + } + parsed.Workbook.Sheets[1] = { + ...parsed.Workbook.Sheets[1], + Hidden: 0, + }; + return parsed; + }, + utils: XLSX.utils, + } as unknown as SheetJsParserModule; + + const workbook = await parseSheetJsSpreadsheetBytesWithParserLoader( + bytes, + async () => parserThatLosesHiddenMetadata, + ); + + expect(visibilityProjection(workbook)).toEqual(EXPECTED_VISIBILITY); + }); }); From 1deac43232529db3b98ffc9f9450a437da69342d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 00:52:41 +0900 Subject: [PATCH 134/163] fix(spreadsheet): parse BIFF8 CFB bytes as buffer input --- src/spreadsheet/sheetJsRuntime.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/spreadsheet/sheetJsRuntime.ts b/src/spreadsheet/sheetJsRuntime.ts index 21dd3160..4fcbc9b0 100644 --- a/src/spreadsheet/sheetJsRuntime.ts +++ b/src/spreadsheet/sheetJsRuntime.ts @@ -23,7 +23,10 @@ interface SheetJsCfbEntry { } interface SheetJsCfbModule { - readonly read: (source: Uint8Array) => unknown; + readonly read: ( + source: Uint8Array, + options: { readonly type: 'buffer' }, + ) => unknown; readonly find: (container: unknown, path: string) => SheetJsCfbEntry | null; } @@ -90,7 +93,7 @@ function readBiff8WorkbookStream( let container: unknown; let workbookEntry: SheetJsCfbEntry | null; try { - container = cfb.read(new Uint8Array(source)); + container = cfb.read(new Uint8Array(source), { type: 'buffer' }); workbookEntry = cfb.find(container, 'Workbook'); } catch { throw unsupportedOrCorruptSource(); From e0a53ac10b5cba08a672fcacfe3fbd0cf2726259 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 01:03:54 +0900 Subject: [PATCH 135/163] fix(spreadsheet): normalize documented CFB byte arrays --- src/spreadsheet/sheetJsRuntime.ts | 36 +++++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/src/spreadsheet/sheetJsRuntime.ts b/src/spreadsheet/sheetJsRuntime.ts index 4fcbc9b0..8362c246 100644 --- a/src/spreadsheet/sheetJsRuntime.ts +++ b/src/spreadsheet/sheetJsRuntime.ts @@ -77,6 +77,37 @@ function readUint16LittleEndian(source: Uint8Array, offset: number): number { return source[offset]! | (source[offset + 1]! << 8); } +function copyCfbEntryBytes(content: unknown, sourceByteLength: number): Uint8Array { + if (content instanceof Uint8Array) { + if (content.byteLength > sourceByteLength) { + throw unsupportedOrCorruptSource(); + } + return new Uint8Array(content); + } + if (!Array.isArray(content)) { + throw unsupportedOrCorruptSource(); + } + + const length = readOwnDataProperty(content, 'length'); + if ( + !Number.isSafeInteger(length) || + (length as number) < 0 || + (length as number) > sourceByteLength + ) { + throw unsupportedOrCorruptSource(); + } + + const copy = new Uint8Array(length as number); + for (let index = 0; index < copy.byteLength; index += 1) { + const value = readOwnDataProperty(content, String(index)); + if (!Number.isInteger(value) || (value as number) < 0 || (value as number) > 0xff) { + throw unsupportedOrCorruptSource(); + } + copy[index] = value as number; + } + return copy; +} + function readBiff8WorkbookStream( source: Uint8Array, parser: SheetJsParserModule, @@ -103,10 +134,7 @@ function readBiff8WorkbookStream( } const content = readOwnDataProperty(workbookEntry, 'content'); - if (!(content instanceof Uint8Array)) { - throw unsupportedOrCorruptSource(); - } - return new Uint8Array(content); + return copyCfbEntryBytes(content, source.byteLength); } function readBiff8HiddenStates(workbookStream: Uint8Array): readonly boolean[] { From 849391fbc8813cd0c9b308608bd84b836ebbf409 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 01:08:37 +0900 Subject: [PATCH 136/163] fix(spreadsheet): accept cross-realm CFB byte views --- src/spreadsheet/sheetJsRuntime.ts | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/src/spreadsheet/sheetJsRuntime.ts b/src/spreadsheet/sheetJsRuntime.ts index 8362c246..df00c7f3 100644 --- a/src/spreadsheet/sheetJsRuntime.ts +++ b/src/spreadsheet/sheetJsRuntime.ts @@ -78,11 +78,33 @@ function readUint16LittleEndian(source: Uint8Array, offset: number): number { } function copyCfbEntryBytes(content: unknown, sourceByteLength: number): Uint8Array { - if (content instanceof Uint8Array) { - if (content.byteLength > sourceByteLength) { + if (ArrayBuffer.isView(content)) { + let byteLength: number; + let elementLength: unknown; + try { + byteLength = content.byteLength; + elementLength = Reflect.get(content, 'length'); + } catch { throw unsupportedOrCorruptSource(); } - return new Uint8Array(content); + if ( + !Number.isSafeInteger(byteLength) || + byteLength < 0 || + byteLength > sourceByteLength || + elementLength !== byteLength + ) { + throw unsupportedOrCorruptSource(); + } + + const copy = new Uint8Array(byteLength); + for (let index = 0; index < copy.byteLength; index += 1) { + const value = readOwnDataProperty(content, String(index)); + if (!Number.isInteger(value) || (value as number) < 0 || (value as number) > 0xff) { + throw unsupportedOrCorruptSource(); + } + copy[index] = value as number; + } + return copy; } if (!Array.isArray(content)) { throw unsupportedOrCorruptSource(); From c9bd5baf5ae9f86a0d89fc8f5c03df578d3556c2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 02:01:49 +0900 Subject: [PATCH 137/163] fix(spreadsheet): ignore blank-only parser ranges --- src/spreadsheet/sheetJsAdapter.ts | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/src/spreadsheet/sheetJsAdapter.ts b/src/spreadsheet/sheetJsAdapter.ts index 754d45cf..441ae10d 100644 --- a/src/spreadsheet/sheetJsAdapter.ts +++ b/src/spreadsheet/sheetJsAdapter.ts @@ -222,6 +222,10 @@ function readDisplayedRows( return rows; } +function hasDisplayedCellText(rows: readonly (readonly string[])[]): boolean { + return rows.some((row) => row.some((cell) => cell.length > 0)); +} + function readWorkbook( parser: SheetJsParserModule, source: Uint8Array, @@ -259,9 +263,12 @@ function baseReadOptions(): Omit< * `MAX_WORKBOOK_ROWS + 1` rows per worksheet. Real BIFF8 evidence shows that * mixing visibility and selective parser reads can make later hidden-sheet state * depend on parser history, so visibility and displayed bodies must come from the - * same parser result. The source envelope remains bounded before parser loading, - * and exact worksheet/count/range/row/column/cell limits are revalidated before - * displayed rows are materialized into Inkspan's parser-neutral contract. + * same parser result. Parser-synthesized blank-only BIFF8 ranges are normalized to + * an empty displayed-row projection so an otherwise empty worksheet cannot become + * a visible one-cell table merely because the parser reports a degenerate `A1` + * range. The source envelope remains bounded before parser loading, and exact + * worksheet/count/range/row/column/cell limits are revalidated before displayed + * rows are materialized into Inkspan's parser-neutral contract. */ export function sheetJsBytesToWorkbookData( source: Uint8Array, @@ -367,17 +374,18 @@ export function sheetJsBytesToWorkbookData( } decodedRows = nextDecodedRows; decodedCells = nextDecodedCells; + const displayedRows = readDisplayedRows( + parser, + sheet, + dimensions.rows, + dimensions.columns, + ); worksheets.push({ name, hidden: false, - rows: readDisplayedRows( - parser, - sheet, - dimensions.rows, - dimensions.columns, - ), + rows: hasDisplayedCellText(displayedRows) ? displayedRows : [], }); } return { worksheets }; -} \ No newline at end of file +} From 1e98158dcb0a12d6d476dd879b3771b58b2e52e0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 02:05:38 +0900 Subject: [PATCH 138/163] test(spreadsheet): cover blank BIFF8 parser range --- .../sheetJsAdapter.blankRange.test.ts | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 src/spreadsheet/sheetJsAdapter.blankRange.test.ts diff --git a/src/spreadsheet/sheetJsAdapter.blankRange.test.ts b/src/spreadsheet/sheetJsAdapter.blankRange.test.ts new file mode 100644 index 00000000..f0ec07b0 --- /dev/null +++ b/src/spreadsheet/sheetJsAdapter.blankRange.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + sheetJsBytesToWorkbookData, + type SheetJsParserModule, +} from './sheetJsAdapter.js'; + +const BIFF8_SOURCE = new Uint8Array([ + 0xd0, + 0xcf, + 0x11, + 0xe0, + 0xa1, + 0xb1, + 0x1a, + 0xe1, +]); + +describe('sheetJsBytesToWorkbookData blank BIFF8 range normalization', () => { + it('treats a parser-synthesized blank-only A1 range as an empty worksheet body', () => { + const emptySheet = { '!ref': 'A1' }; + const read = vi.fn(() => ({ + SheetNames: ['Empty'], + Sheets: { Empty: emptySheet }, + Workbook: { Sheets: [{ Hidden: 0 }] }, + })); + const sheetToJson = vi.fn(() => [['']]); + const parser: SheetJsParserModule = { + read, + utils: { + decode_range: vi.fn(() => ({ + s: { r: 0, c: 0 }, + e: { r: 0, c: 0 }, + })), + sheet_to_json: sheetToJson, + }, + }; + + expect(sheetJsBytesToWorkbookData(BIFF8_SOURCE, parser)).toEqual({ + worksheets: [{ name: 'Empty', hidden: false, rows: [] }], + }); + expect(read).toHaveBeenCalledTimes(1); + expect(sheetToJson).toHaveBeenCalledOnce(); + expect(sheetToJson).toHaveBeenCalledWith(emptySheet, { + header: 1, + raw: false, + defval: '', + blankrows: true, + }); + }); +}); From ba1fc4e777e8cce33700af03c8261e9727edd120 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 02:13:14 +0900 Subject: [PATCH 139/163] test(spreadsheet): harden BIFF8 runtime validation coverage --- .../sheetJsRuntimeBiff8Validation.test.ts | 354 ++++++++++++++++++ 1 file changed, 354 insertions(+) create mode 100644 src/spreadsheet/sheetJsRuntimeBiff8Validation.test.ts diff --git a/src/spreadsheet/sheetJsRuntimeBiff8Validation.test.ts b/src/spreadsheet/sheetJsRuntimeBiff8Validation.test.ts new file mode 100644 index 00000000..01b93564 --- /dev/null +++ b/src/spreadsheet/sheetJsRuntimeBiff8Validation.test.ts @@ -0,0 +1,354 @@ +import { describe, expect, it } from 'vitest'; +import type { SheetJsParserModule } from './sheetJsAdapter.js'; +import { + parseSheetJsSpreadsheetBytesWithParserLoader, +} from './sheetJsRuntime.js'; + +const OLE_SIGNATURE = [ + 0xd0, + 0xcf, + 0x11, + 0xe0, + 0xa1, + 0xb1, + 0x1a, + 0xe1, +] as const; + +function littleEndian16(value: number): readonly number[] { + return [value & 0xff, (value >>> 8) & 0xff]; +} + +function record(type: number, payload: readonly number[]): readonly number[] { + return [ + ...littleEndian16(type), + ...littleEndian16(payload.length), + ...payload, + ]; +} + +const BOF = record(0x0809, [0x00, 0x06, 0x05, 0x00]); +const EOF = record(0x000a, []); + +function boundSheet(visibility: number): readonly number[] { + return record(0x0085, [0, 0, 0, 0, visibility, 0, 0, 0]); +} + +function workbookStream( + visibilities: readonly number[] = [0], + extraRecords: readonly (readonly number[])[] = [], +): Uint8Array { + return Uint8Array.from([ + ...BOF, + ...extraRecords.flat(), + ...visibilities.flatMap((visibility) => boundSheet(visibility)), + ...EOF, + ]); +} + +function sourceFor(stream: Uint8Array): Uint8Array { + const source = new Uint8Array(Math.max(64, stream.byteLength + 8)); + source.set(OLE_SIGNATURE); + return source; +} + +function workbookFor(names: readonly string[]): object { + return { + SheetNames: [...names], + Sheets: Object.fromEntries(names.map((name) => [name, {}])), + }; +} + +function parserWith( + cfb: unknown, + workbook: unknown = workbookFor(['Visible']), +): SheetJsParserModule { + return { + CFB: cfb, + read: () => workbook, + utils: { + decode_range: () => ({ s: { r: 0, c: 0 }, e: { r: 0, c: 0 } }), + sheet_to_json: () => [], + }, + } as unknown as SheetJsParserModule; +} + +function parserForStream( + stream: Uint8Array | readonly number[], + workbook: unknown = workbookFor(['Visible']), +): SheetJsParserModule { + return parserWith( + { + read: () => ({}), + find: () => ({ content: stream }), + }, + workbook, + ); +} + +async function expectUnsupported(promise: Promise): Promise { + await expect(promise).rejects.toMatchObject({ + name: 'SpreadsheetImportError', + code: 'UNSUPPORTED_OR_CORRUPT', + message: 'Spreadsheet source is unsupported or corrupt.', + }); +} + +async function expectResourceLimit(promise: Promise): Promise { + await expect(promise).rejects.toMatchObject({ + name: 'SpreadsheetImportError', + code: 'RESOURCE_LIMIT_EXCEEDED', + message: 'Spreadsheet exceeds the configured resource limits.', + }); +} + +async function parseWith( + stream: Uint8Array, + parser: SheetJsParserModule, +): Promise { + return parseSheetJsSpreadsheetBytesWithParserLoader( + sourceFor(stream), + async () => parser, + ); +} + +describe('BIFF8 raw visibility validation', () => { + it('accepts parser-owned numeric-array CFB bytes and projects visible/hidden state', async () => { + const stream = workbookStream([0, 1]); + const workbook = await parseWith( + stream, + parserForStream(Array.from(stream), workbookFor(['Visible', 'Hidden'])), + ); + + expect(workbook).toEqual({ + worksheets: [ + { name: 'Visible', hidden: false, rows: [] }, + { name: 'Hidden', hidden: true, rows: [] }, + ], + }); + }); + + it.each([ + ['missing CFB module', undefined], + ['non-object CFB module', 'not a cfb module'], + ['missing CFB read', { find: () => null }], + ['missing CFB find', { read: () => ({}) }], + ['non-callable CFB find', { read: () => ({}), find: 1 }], + ])('rejects a parser with %s', async (_label, cfb) => { + const stream = workbookStream(); + await expectUnsupported(parseWith(stream, parserWith(cfb))); + }); + + it('normalizes a throwing CFB reader', async () => { + const stream = workbookStream(); + await expectUnsupported( + parseWith( + stream, + parserWith({ + read() { + throw new Error('private parser detail'); + }, + find: () => null, + }), + ), + ); + }); + + it('normalizes a throwing CFB finder', async () => { + const stream = workbookStream(); + await expectUnsupported( + parseWith( + stream, + parserWith({ + read: () => ({}), + find() { + throw new Error('private parser detail'); + }, + }), + ), + ); + }); + + it('rejects a missing Workbook CFB entry', async () => { + const stream = workbookStream(); + await expectUnsupported( + parseWith( + stream, + parserWith({ read: () => ({}), find: () => null }), + ), + ); + }); + + it.each([ + ['missing content', {}], + [ + 'accessor content', + Object.defineProperty({}, 'content', { + get() { + return workbookStream(); + }, + }), + ], + [ + 'throwing content descriptor', + new Proxy( + {}, + { + getOwnPropertyDescriptor() { + throw new Error('private parser descriptor'); + }, + }, + ), + ], + ])('rejects a Workbook entry with %s', async (_label, entry) => { + const stream = workbookStream(); + await expectUnsupported( + parseWith( + stream, + parserWith({ read: () => ({}), find: () => entry }), + ), + ); + }); + + it('rejects a non-byte CFB Workbook entry', async () => { + const stream = workbookStream(); + await expectUnsupported( + parseWith( + stream, + parserWith({ + read: () => ({}), + find: () => ({ content: 'not workbook bytes' }), + }), + ), + ); + }); + + it.each([ + ['view longer than the source', new Uint8Array(65)], + ['multi-byte element view', new Uint16Array([1, 2])], + ['negative byte value', new Int8Array([-1])], + ])('rejects hostile typed CFB content: %s', async (_label, content) => { + const stream = workbookStream(); + await expectUnsupported( + parseWith( + stream, + parserWith({ read: () => ({}), find: () => ({ content }) }), + ), + ); + }); + + it('normalizes a throwing typed-view length accessor', async () => { + class ThrowingLengthUint8Array extends Uint8Array { + override get length(): number { + throw new Error('private parser detail'); + } + } + + const stream = workbookStream(); + await expectUnsupported( + parseWith( + stream, + parserWith({ + read: () => ({}), + find: () => ({ content: new ThrowingLengthUint8Array(stream) }), + }), + ), + ); + }); + + it('rejects an array CFB entry longer than the local source envelope', async () => { + const stream = workbookStream(); + const content = new Array(65).fill(0); + await expectUnsupported( + parseWith( + stream, + parserWith({ read: () => ({}), find: () => ({ content }) }), + ), + ); + }); + + it.each([ + ['non-integer', [1.5]], + ['negative', [-1]], + ['above one byte', [256]], + ])('rejects invalid numeric-array CFB content: %s', async (_label, content) => { + const stream = workbookStream(); + await expectUnsupported( + parseWith( + stream, + parserWith({ read: () => ({}), find: () => ({ content }) }), + ), + ); + }); + + it.each([ + ['record extends beyond stream', Uint8Array.from([0x09, 0x08, 0xff, 0xff])], + ['wrong first record type', Uint8Array.from([...record(1, [0, 6, 5, 0]), ...EOF])], + ['short workbook BOF', Uint8Array.from([...record(0x0809, [0, 6, 5]), ...EOF])], + ['wrong BIFF version', Uint8Array.from([...record(0x0809, [0, 5, 5, 0]), ...EOF])], + ['wrong BOF substream', Uint8Array.from([...record(0x0809, [0, 6, 0, 0]), ...EOF])], + ['short BoundSheet8', Uint8Array.from([...BOF, ...record(0x0085, [0, 0, 0, 0, 0, 0, 0]), ...EOF])], + ['reserved BoundSheet8 visibility bits', workbookStream([4])], + ['reserved BoundSheet8 visibility value', workbookStream([3])], + ['missing workbook EOF', Uint8Array.from(BOF)], + ])('rejects malformed BIFF8 workbook globals: %s', async (_label, stream) => { + await expectUnsupported(parseWith(stream, parserForStream(stream))); + }); + + it('rejects more than the bounded BIFF8 worksheet count', async () => { + const stream = workbookStream(new Array(257).fill(0)); + await expectResourceLimit(parseWith(stream, parserForStream(stream))); + }); + + it('ignores unrelated workbook-global records before EOF', async () => { + const stream = workbookStream([], [record(0x002f, [1, 2])]); + const workbook = await parseWith( + stream, + parserForStream(stream, workbookFor([])), + ); + + expect(workbook).toEqual({ worksheets: [] }); + }); + + it('rejects a parser workbook whose wrapped result is not an object', async () => { + const stream = workbookStream(); + await expectUnsupported(parseWith(stream, parserForStream(stream, null))); + }); + + it('rejects non-array parser SheetNames before exposing visibility metadata', async () => { + const stream = workbookStream(); + await expectUnsupported( + parseWith( + stream, + parserForStream(stream, { SheetNames: 'Visible', Sheets: {} }), + ), + ); + }); + + it('rejects parser SheetNames that disagree with raw BoundSheet8 count', async () => { + const stream = workbookStream(); + await expectUnsupported( + parseWith( + stream, + parserForStream(stream, workbookFor(['Visible', 'Unexpected'])), + ), + ); + }); + + it('normalizes a throwing parser SheetNames descriptor', async () => { + const stream = workbookStream(); + const workbook = new Proxy( + workbookFor(['Visible']), + { + getOwnPropertyDescriptor(target, property) { + if (property === 'SheetNames') { + throw new Error('private parser descriptor'); + } + return Reflect.getOwnPropertyDescriptor(target, property); + }, + }, + ); + + await expectUnsupported(parseWith(stream, parserForStream(stream, workbook))); + }); +}); From c3ed931b7f24256d06f7f5d42ed0450bc9f02059 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 02:14:01 +0900 Subject: [PATCH 140/163] test(spreadsheet): avoid accessor subclass in BIFF8 validation --- .../sheetJsRuntimeBiff8Validation.test.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/spreadsheet/sheetJsRuntimeBiff8Validation.test.ts b/src/spreadsheet/sheetJsRuntimeBiff8Validation.test.ts index 01b93564..8ccd6429 100644 --- a/src/spreadsheet/sheetJsRuntimeBiff8Validation.test.ts +++ b/src/spreadsheet/sheetJsRuntimeBiff8Validation.test.ts @@ -238,19 +238,20 @@ describe('BIFF8 raw visibility validation', () => { }); it('normalizes a throwing typed-view length accessor', async () => { - class ThrowingLengthUint8Array extends Uint8Array { - override get length(): number { + const stream = workbookStream(); + const content = new Uint8Array(stream); + Object.defineProperty(content, 'length', { + get() { throw new Error('private parser detail'); - } - } + }, + }); - const stream = workbookStream(); await expectUnsupported( parseWith( stream, parserWith({ read: () => ({}), - find: () => ({ content: new ThrowingLengthUint8Array(stream) }), + find: () => ({ content }), }), ), ); From 2273a6d5db49e8c7d7bd841f65bdf1ddc883f14b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 02:18:51 +0900 Subject: [PATCH 141/163] test(spreadsheet): reject revoked parser sheet names --- src/spreadsheet/sheetJsRuntimeBiff8Validation.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/spreadsheet/sheetJsRuntimeBiff8Validation.test.ts b/src/spreadsheet/sheetJsRuntimeBiff8Validation.test.ts index 8ccd6429..b516633d 100644 --- a/src/spreadsheet/sheetJsRuntimeBiff8Validation.test.ts +++ b/src/spreadsheet/sheetJsRuntimeBiff8Validation.test.ts @@ -326,6 +326,15 @@ describe('BIFF8 raw visibility validation', () => { ); }); + it('normalizes a revoked parser SheetNames array proxy', async () => { + const stream = workbookStream(); + const { proxy, revoke } = Proxy.revocable(['Visible'], {}); + const workbook = { SheetNames: proxy, Sheets: { Visible: {} } }; + revoke(); + + await expectUnsupported(parseWith(stream, parserForStream(stream, workbook))); + }); + it('rejects parser SheetNames that disagree with raw BoundSheet8 count', async () => { const stream = workbookStream(); await expectUnsupported( From c4ff522c6314888cbd4883e2da6222c5433ef6ec Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 02:19:31 +0900 Subject: [PATCH 142/163] fix(spreadsheet): normalize hostile sheet-name arrays --- src/spreadsheet/sheetJsRuntime.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/spreadsheet/sheetJsRuntime.ts b/src/spreadsheet/sheetJsRuntime.ts index df00c7f3..f083234f 100644 --- a/src/spreadsheet/sheetJsRuntime.ts +++ b/src/spreadsheet/sheetJsRuntime.ts @@ -207,14 +207,16 @@ function readBiff8HiddenStates(workbookStream: Uint8Array): readonly boolean[] { } function readArrayLength(value: unknown): number { - if (!Array.isArray(value)) { - throw unsupportedOrCorruptSource(); - } - const length = readOwnDataProperty(value, 'length'); - if (!Number.isSafeInteger(length) || (length as number) < 0) { + let arrayValue: unknown[]; + try { + if (!Array.isArray(value)) { + throw unsupportedOrCorruptSource(); + } + arrayValue = value; + } catch { throw unsupportedOrCorruptSource(); } - return length as number; + return readOwnDataProperty(arrayValue, 'length') as number; } function withAuthoritativeBiff8Visibility( From e02080ec07cb118ae14c8972dd8d80a9ac2cb1b2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 02:21:06 +0900 Subject: [PATCH 143/163] test(spreadsheet): cover selective worksheet identity checks --- ...sheetJsAdapter.selectiveValidation.test.ts | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 src/spreadsheet/sheetJsAdapter.selectiveValidation.test.ts diff --git a/src/spreadsheet/sheetJsAdapter.selectiveValidation.test.ts b/src/spreadsheet/sheetJsAdapter.selectiveValidation.test.ts new file mode 100644 index 00000000..588a0bb6 --- /dev/null +++ b/src/spreadsheet/sheetJsAdapter.selectiveValidation.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest'; +import { + sheetJsBytesToWorkbookData, + type SheetJsParserModule, +} from './sheetJsAdapter.js'; + +const XLSX_SOURCE = new Uint8Array([0x50, 0x4b, 0x03, 0x04]); + +function parserForSelectedWorkbook(selectedWorkbook: unknown): SheetJsParserModule { + return { + read(_source, options) { + if (options.bookSheets === true) { + return { SheetNames: ['Summary'], Sheets: {} }; + } + return selectedWorkbook; + }, + utils: { + decode_range: () => ({ s: { r: 0, c: 0 }, e: { r: 0, c: 0 } }), + sheet_to_json: () => [], + }, + }; +} + +function selectedWorkbook(sheetNames: unknown): object { + return { + SheetNames: sheetNames, + Sheets: { Summary: {} }, + }; +} + +async function expectUnsupported(action: () => unknown): Promise { + expect(action).toThrowError( + expect.objectContaining({ + name: 'SpreadsheetImportError', + code: 'UNSUPPORTED_OR_CORRUPT', + message: 'Spreadsheet source is unsupported or corrupt.', + }), + ); +} + +async function expectResourceLimit(action: () => unknown): Promise { + expect(action).toThrowError( + expect.objectContaining({ + name: 'SpreadsheetImportError', + code: 'RESOURCE_LIMIT_EXCEEDED', + message: 'Spreadsheet exceeds the configured resource limits.', + }), + ); +} + +describe('SheetJS selective worksheet identity validation', () => { + it('rejects a selected workbook without an array SheetNames identity', async () => { + await expectUnsupported(() => + sheetJsBytesToWorkbookData( + XLSX_SOURCE, + parserForSelectedWorkbook(selectedWorkbook('Summary')), + ), + ); + }); + + it('rejects a selected workbook with too many SheetNames entries', async () => { + await expectResourceLimit(() => + sheetJsBytesToWorkbookData( + XLSX_SOURCE, + parserForSelectedWorkbook( + selectedWorkbook(new Array(257).fill('Other')), + ), + ), + ); + }); + + it('rejects a selected workbook with a non-string sheet identity', async () => { + await expectUnsupported(() => + sheetJsBytesToWorkbookData( + XLSX_SOURCE, + parserForSelectedWorkbook(selectedWorkbook([42])), + ), + ); + }); + + it('rejects a selected workbook with an oversized sheet identity', async () => { + await expectResourceLimit(() => + sheetJsBytesToWorkbookData( + XLSX_SOURCE, + parserForSelectedWorkbook(selectedWorkbook(['x'.repeat(1_025)])), + ), + ); + }); + + it('rejects a selected workbook that omits the requested worksheet identity', async () => { + await expectUnsupported(() => + sheetJsBytesToWorkbookData( + XLSX_SOURCE, + parserForSelectedWorkbook(selectedWorkbook(['Other'])), + ), + ); + }); + + it('rejects an ambiguous selected workbook that repeats the requested identity', async () => { + await expectUnsupported(() => + sheetJsBytesToWorkbookData( + XLSX_SOURCE, + parserForSelectedWorkbook(selectedWorkbook(['Summary', 'Summary'])), + ), + ); + }); +}); From 7ae207355f4dd15f154a2a9e392418350f76d798 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 02:21:48 +0900 Subject: [PATCH 144/163] test(spreadsheet): keep selective validation assertions explicit --- ...sheetJsAdapter.selectiveValidation.test.ts | 121 ++++++++++-------- 1 file changed, 67 insertions(+), 54 deletions(-) diff --git a/src/spreadsheet/sheetJsAdapter.selectiveValidation.test.ts b/src/spreadsheet/sheetJsAdapter.selectiveValidation.test.ts index 588a0bb6..e1e2aff3 100644 --- a/src/spreadsheet/sheetJsAdapter.selectiveValidation.test.ts +++ b/src/spreadsheet/sheetJsAdapter.selectiveValidation.test.ts @@ -28,80 +28,93 @@ function selectedWorkbook(sheetNames: unknown): object { }; } -async function expectUnsupported(action: () => unknown): Promise { - expect(action).toThrowError( - expect.objectContaining({ - name: 'SpreadsheetImportError', - code: 'UNSUPPORTED_OR_CORRUPT', - message: 'Spreadsheet source is unsupported or corrupt.', - }), - ); -} +function expectSpreadsheetError( + action: () => unknown, + code: 'UNSUPPORTED_OR_CORRUPT' | 'RESOURCE_LIMIT_EXCEEDED', +): void { + let caught: unknown; + try { + action(); + } catch (error) { + caught = error; + } -async function expectResourceLimit(action: () => unknown): Promise { - expect(action).toThrowError( - expect.objectContaining({ - name: 'SpreadsheetImportError', - code: 'RESOURCE_LIMIT_EXCEEDED', - message: 'Spreadsheet exceeds the configured resource limits.', - }), - ); + expect(caught).toMatchObject({ + name: 'SpreadsheetImportError', + code, + message: + code === 'UNSUPPORTED_OR_CORRUPT' + ? 'Spreadsheet source is unsupported or corrupt.' + : 'Spreadsheet exceeds the configured resource limits.', + }); } describe('SheetJS selective worksheet identity validation', () => { - it('rejects a selected workbook without an array SheetNames identity', async () => { - await expectUnsupported(() => - sheetJsBytesToWorkbookData( - XLSX_SOURCE, - parserForSelectedWorkbook(selectedWorkbook('Summary')), - ), + it('rejects a selected workbook without an array SheetNames identity', () => { + expectSpreadsheetError( + () => + sheetJsBytesToWorkbookData( + XLSX_SOURCE, + parserForSelectedWorkbook(selectedWorkbook('Summary')), + ), + 'UNSUPPORTED_OR_CORRUPT', ); }); - it('rejects a selected workbook with too many SheetNames entries', async () => { - await expectResourceLimit(() => - sheetJsBytesToWorkbookData( - XLSX_SOURCE, - parserForSelectedWorkbook( - selectedWorkbook(new Array(257).fill('Other')), + it('rejects a selected workbook with too many SheetNames entries', () => { + expectSpreadsheetError( + () => + sheetJsBytesToWorkbookData( + XLSX_SOURCE, + parserForSelectedWorkbook( + selectedWorkbook(new Array(257).fill('Other')), + ), ), - ), + 'RESOURCE_LIMIT_EXCEEDED', ); }); - it('rejects a selected workbook with a non-string sheet identity', async () => { - await expectUnsupported(() => - sheetJsBytesToWorkbookData( - XLSX_SOURCE, - parserForSelectedWorkbook(selectedWorkbook([42])), - ), + it('rejects a selected workbook with a non-string sheet identity', () => { + expectSpreadsheetError( + () => + sheetJsBytesToWorkbookData( + XLSX_SOURCE, + parserForSelectedWorkbook(selectedWorkbook([42])), + ), + 'UNSUPPORTED_OR_CORRUPT', ); }); - it('rejects a selected workbook with an oversized sheet identity', async () => { - await expectResourceLimit(() => - sheetJsBytesToWorkbookData( - XLSX_SOURCE, - parserForSelectedWorkbook(selectedWorkbook(['x'.repeat(1_025)])), - ), + it('rejects a selected workbook with an oversized sheet identity', () => { + expectSpreadsheetError( + () => + sheetJsBytesToWorkbookData( + XLSX_SOURCE, + parserForSelectedWorkbook(selectedWorkbook(['x'.repeat(1_025)])), + ), + 'RESOURCE_LIMIT_EXCEEDED', ); }); - it('rejects a selected workbook that omits the requested worksheet identity', async () => { - await expectUnsupported(() => - sheetJsBytesToWorkbookData( - XLSX_SOURCE, - parserForSelectedWorkbook(selectedWorkbook(['Other'])), - ), + it('rejects a selected workbook that omits the requested worksheet identity', () => { + expectSpreadsheetError( + () => + sheetJsBytesToWorkbookData( + XLSX_SOURCE, + parserForSelectedWorkbook(selectedWorkbook(['Other'])), + ), + 'UNSUPPORTED_OR_CORRUPT', ); }); - it('rejects an ambiguous selected workbook that repeats the requested identity', async () => { - await expectUnsupported(() => - sheetJsBytesToWorkbookData( - XLSX_SOURCE, - parserForSelectedWorkbook(selectedWorkbook(['Summary', 'Summary'])), - ), + it('rejects an ambiguous selected workbook that repeats the requested identity', () => { + expectSpreadsheetError( + () => + sheetJsBytesToWorkbookData( + XLSX_SOURCE, + parserForSelectedWorkbook(selectedWorkbook(['Summary', 'Summary'])), + ), + 'UNSUPPORTED_OR_CORRUPT', ); }); }); From 00a296567bd9f00d69a6f0501b3bfe65d5e0ba8b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 17 Aug 2026 17:26:13 +0000 Subject: [PATCH 145/163] fix(spreadsheet): insert genuine XLS/XLSX files into the document body Read browser File/Blob bodies through FileReader or Response when arrayBuffer is absent so jsdom and older DOMs can import a known workbook. Add unmocked editor insertion tests, fail-closed runtime coverage, Proposed ADR 0032, and active-PR documentation that does not claim protected-main authority. Co-authored-by: Seongho Bae --- CHANGELOG.md | 4 + README.md | 2 +- docs/CONTRACTS.md | 6 + docs/DOCUMENTATION_FITNESS.md | 1 + docs/PRD.md | 2 +- docs/TEST_STRATEGY.md | 1 + docs/THREAT_MODEL.md | 4 + docs/TRACEABILITY.md | 17 +- docs/TRD.md | 2 +- docs/accessibility.md | 2 + ...2-bounded-local-spreadsheet-body-import.md | 82 ++++ docs/adr/README.md | 1 + docs/package-distribution.md | 2 +- src/canonicalProductDocumentation.test.ts | 1 + .../CwlEditor.spreadsheetBodyImport.test.tsx | 136 ++++++ .../sheetJsAdapter.parsedSheetIndex.test.ts | 117 +++++ src/spreadsheet/sheetJsFileImport.test.ts | 153 ++++++- .../sheetJsRuntime.failClosed.test.ts | 410 ++++++++++++++++++ src/spreadsheet/sheetJsRuntime.ts | 89 +++- ...spreadsheetBodyImportDocumentation.test.ts | 48 ++ 20 files changed, 1058 insertions(+), 22 deletions(-) create mode 100644 docs/adr/0032-bounded-local-spreadsheet-body-import.md create mode 100644 src/components/CwlEditor.spreadsheetBodyImport.test.tsx create mode 100644 src/spreadsheet/sheetJsAdapter.parsedSheetIndex.test.ts create mode 100644 src/spreadsheet/sheetJsRuntime.failClosed.test.ts create mode 100644 src/spreadsheetBodyImportDocumentation.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f5d1d3dc..fec43887 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ Historical release entries from **0.1.0 through 0.5.27** are preserved verbatim ## [Unreleased] +### Added + +- Active PR / Proposed: local XLS/XLSX worksheet insertion into the current document. A toolbar control reads a user-selected `.xls` or `.xlsx` file in memory, projects only visible displayed cell text into a heading and table, and announces the imported worksheet/row/cell counts. Formulas, macros, hyperlinks, and hidden sheets receive no editor authority. This is not protected-main behavior until the branch merges. + ## [0.6.0] — 2026-08-10 ### Release diff --git a/README.md b/README.md index 7d4dd218..931e2ba0 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ runtime. | Text-position selector | `@contextualwisdomlab/cwl-editor/text-position-selector` | React-free deterministic W3C `TextPositionSelector` projection core | | Autosave | `@contextualwisdomlab/cwl-editor/autosave` | Provider-neutral bounded single-flight persistence coordination | | Headless Markdown | `@contextualwisdomlab/cwl-editor/markdown` | React-free deterministic Markdown/HTML/email/plain-text conversion | -| Spreadsheet conversion | `@contextualwisdomlab/cwl-editor/spreadsheet` | Framework-neutral bounded workbook-to-document conversion primitives | +| Spreadsheet conversion | `@contextualwisdomlab/cwl-editor/spreadsheet` | Active PR: bounded local XLS/XLSX worksheet-to-document conversion used by the editor toolbar | | Styles | `@contextualwisdomlab/cwl-editor/styles.css` | Editor layout and theming | | Full fonts | `@contextualwisdomlab/cwl-editor/fonts.css` | KR/EN/JP/SC/TC/VI offline font bundle | | Latin fonts | `@contextualwisdomlab/cwl-editor/fonts-latin.css` | Smaller Latin/Vietnamese-only bundle | diff --git a/docs/CONTRACTS.md b/docs/CONTRACTS.md index 52ab1bdf..ef7a00e5 100644 --- a/docs/CONTRACTS.md +++ b/docs/CONTRACTS.md @@ -103,6 +103,12 @@ Model-assisted authoring is separate from deterministic conversion and validatio The host owns model/provider selection, credentials, external-data-use approval, redaction, prompt retention, model logging, tenancy, authorization, human approval, and audit. No model may authorize a save, bypass deterministic validation, or redefine a revision/durable-validator contract. +## Local spreadsheet body-import contract + +Active PR / Proposed under ADR 0032. The public `@contextualwisdomlab/cwl-editor/spreadsheet` subpath and the editor toolbar accept local `.xls`/`.xlsx` bytes, project visible displayed cell text into one TipTap insertion batch, and reject hidden sheets, formulas, macros, and hyperlinks as executable authority. Source size is bounded before the file body is read. Genuine `File` values are read through `arrayBuffer()` when present, otherwise `FileReader` or `Response`. Failures are payload-redacted. This contract is not protected-main authority. + +Hosts retain transport, authorization, persistence, retention, and any later sharing of the inserted document. Worksheet names remain authoring labels and are not PII-masked in this lane. + ## Deterministic Office conversion contract Office rendering accepts versioned bounded JSON and produces supported DOCX/XLSX/PPTX artifacts without model, network, macro, or Desktop Office dependency. Inputs must satisfy XML 1.0, size/depth/container/cycle, spreadsheet, worksheet-name, freeze-pane, supported-structure, and formula-injection rules before publication. diff --git a/docs/DOCUMENTATION_FITNESS.md b/docs/DOCUMENTATION_FITNESS.md index cdb52e18..fd09da23 100644 --- a/docs/DOCUMENTATION_FITNESS.md +++ b/docs/DOCUMENTATION_FITNESS.md @@ -61,6 +61,7 @@ Document fitness and implementation maturity are independent. A `present_current | DOCX bounded paragraph alignment | ADR 0024, Office schema/renderer/tests, Office guidance and doctoring | `present_current` | `implemented_on_protected_main` | `paragraph` and `rich_paragraph` preserve explicit left/center/right/justify alignment while omission retains inherited/default behavior. | | DOCX bounded heading alignment | ADR 0025, Office schema/renderer/tests, Office guidance and doctoring | `present_current` | `implemented_on_protected_main` | `heading` preserves the same exact left/center/right/justify contract through the shared paragraph-alignment authority while omission retains heading-style/default behavior. | | DOCX bounded external hyperlinks | ADR 0026, Office schema/renderer/tests, Office guidance and doctoring | `present_current` | `implemented_on_protected_main` | Optional rich-run hyperlinks preserve exact accepted external HTTP(S) targets and existing run emphasis through deterministic relationship-backed OOXML without network, credential, local-file, tenant, persistence, or destination-trust authority. | +| Bounded local spreadsheet body import | Proposed ADR 0032, spreadsheet subpath, toolbar insertion and known-workbook editor tests | `present_current` | `implemented_on_active_pr` | Local XLS/XLSX files insert visible displayed cells as editable headings/tables without upload, formula execution, or protected-main authority. | | THREAT_MODEL | `docs/THREAT_MODEL.md` | `present_current` | Covers current trust boundaries and explicitly proposed extensions | Clipboard, evidence, Office, SSR/form, Yjs, model, host-authority and supply-chain threats are reconstructable. | | TEST_STRATEGY | `docs/TEST_STRATEGY.md` | `present_current` | Protected deterministic/browser/Office evidence plus feature-specific test contracts | Test authority, exact source-head evidence and claim limits are explicit rather than inferred from CI badges. | | OPERABILITY | `docs/OPERABILITY.md` | `present_current` | Current product responsibilities plus protected browser/release recovery boundaries | Conflict, collaboration, conversion, registry partial-publication recovery and rollback ownership are explicit. | diff --git a/docs/PRD.md b/docs/PRD.md index 899086f3..5539da6b 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -145,4 +145,4 @@ Shareable acquisition evidence excludes production tenant content and credential Protected `main` is the sole implemented baseline. Open PRs may describe Proposed or Active work but are not shipped contracts until protected integration. Canonical documentation must state when a requirement is target architecture rather than current implementation. -SafeClipboard, real Chromium/Firefox/WebKit release assurance, lifecycle observation, the root security disclosure lifecycle, toolbar shortcut accessibility metadata, SSR/native-form serialization, revision-scoped selection evidence, W3C text-position selector evidence, document-transition evidence, and envelope identity migration routing are implemented on protected `main`. +SafeClipboard, real Chromium/Firefox/WebKit release assurance, lifecycle observation, the root security disclosure lifecycle, toolbar shortcut accessibility metadata, SSR/native-form serialization, revision-scoped selection evidence, W3C text-position selector evidence, document-transition evidence, and envelope identity migration routing are implemented on protected `main`. Local XLS/XLSX worksheet insertion into the document body is `implemented_on_active_pr` under Proposed ADR 0032 and is not a shipped claim. diff --git a/docs/TEST_STRATEGY.md b/docs/TEST_STRATEGY.md index 34e903fc..8dc90826 100644 --- a/docs/TEST_STRATEGY.md +++ b/docs/TEST_STRATEGY.md @@ -56,6 +56,7 @@ At minimum, maintain regressions for: - autosave stale validators, conflict/failure recovery, ambiguous transport outcomes, duplicate/no-op lifecycle transitions, callback exceptions, queue bounds, flush/close behavior, and durable-validator coherence; - selection/revision races and document movement during asynchronous hashing; - Office formula prefixes, invalid XML characters, malicious strings, path/publication races, invalid worksheet names, invalid freeze panes, cyclic input, pathological nesting, excessive container size, and partial write failure; +- local XLS/XLSX body import from a known small workbook File through the real toolbar/editor path, including asserted heading/cell text, hidden-sheet exclusion, formula/link non-execution, FileReader fallback when `arrayBuffer` is absent, and payload-redacted failures; - package/release stale draft assets, unexpected or non-regular local entries, exact three-file inventory violations, incomplete remote uploads, GitHub-vs-local digest mismatch, stale exact-head evidence, mutable provenance inputs, and isolated packed-consumer behavior. ## Concurrency and failure testing diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md index e1337651..8a79bc88 100644 --- a/docs/THREAT_MODEL.md +++ b/docs/THREAT_MODEL.md @@ -33,6 +33,10 @@ Untrusted clipboard HTML can attempt script execution, external resource fetches URLs or image-like content can exfiltrate document context, induce unexpected network access, or smuggle executable/active payloads. Inkspan validates only supported local semantics and does not grant network authority. Hosts remain responsible for downstream CSP, fetch policy, proxy/egress controls, content serving, and tenant authorization. +### Local spreadsheet body import + +Untrusted local XLS/XLSX bytes can carry macros, formulas, hyperlinks, hidden sheets, and hostile object graphs. Active-PR import (ADR 0032) must parse only after ZIP/OLE preflight, insert only visible displayed/cached cell text, and keep parser exceptions, file names, formulas, and hidden values out of ordinary status text. Missing `File.arrayBuffer` is not a reason to reject a genuine local workbook when `FileReader` or `Response` can read the same Blob. The parser still has no network, credential, persistence, or model authority. + ### Spreadsheet formula injection XLSX cell values beginning with formula-significant prefixes can become executable spreadsheet formulas when opened by a user. Deterministic Office rendering must preserve the documented formula-injection neutralization boundary and never silently convert untrusted strings into formulas. No macro, network, or Desktop Office execution is part of the renderer contract. diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index d120cec7..87b610a9 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -24,6 +24,7 @@ This record maps durable Inkspan product decisions to authoritative standards, p | Editor integration | Public behavior must exercise the actual TipTap/ProseMirror integration path, not an inert extension field or test-only hook | official TipTap and ProseMirror documentation for the locked dependency line | integration tests and package consumers | Inkspan does not claim compatibility with untested major-version integration semantics | | Collaboration | Inkspan provides provider-neutral editor/Yjs bindings; host owns provider lifecycle, room authorization, awareness privacy, persistence and audit | official Yjs/provider documentation plus Inkspan public contract | collaboration tests and architecture ownership matrix | No network-provider or tenant-authorization authority is implied | | Secure development | Security controls are developed test-first, with exact-head scanning/review/package evidence and root-cause regression | NIST SP 800-218 SSDF 1.1 | CI/security/SAST/package/provenance gates, doctoring and regression history | Repository evidence is not a claim of complete SSDF organizational conformance | +| Local spreadsheet body import | Visible XLS/XLSX worksheet text is inserted locally as inert headings/tables after ZIP/OLE preflight, BIFF8 BoundSheet8 visibility recovery, and File/FileReader/Response body reads | ECMA-376; [MS-XLS]; SheetJS CE; WAI-ARIA 1.2 status/live regions | Proposed ADR 0032, active-PR spreadsheet runtime/editor insertion tests and `./spreadsheet` package surface | `implemented_on_active_pr`; not protected-main authority; no upload, formula execution, macro, network, or destination-trust claim | | Office rendering | JSON→DOCX/XLSX/PPTX is deterministic, bounded, network-free, macro-free, injection-aware and package-inspected | Office Open XML specifications and relevant Python package contracts | Office renderer tests, Python coverage/docstring/package gates | Format fidelity is limited to explicitly tested supported constructs | | DOCX informative PNG figures | Informative figures accept only bounded inline PNG data, explicit alt text, bounded dimensions/bytes and deterministic WordprocessingML output | Office Open XML drawing semantics; python-docx public picture APIs | protected-main #121 renderer/schema/tests, ADR 0022 and PNG doctoring | No remote/file/SVG/JPEG fetch, decorative-image claim, arbitrary drawing authority, or image-based model inference is implied | | DOCX bounded rich-text runs | `rich_paragraph` preserves ordered bold/italic/underline run emphasis through a strict bounded JSON contract | Office Open XML run semantics; python-docx run API | protected-main #124 renderer/schema/tests, ADR 0023 and rich-run doctoring | No arbitrary Word styles, font/color/size, hyperlink, field-code, tracked-change, raw-OOXML or source-format parsing authority is implied | @@ -46,17 +47,19 @@ Fielding, R., Nottingham, M., & Reschke, J. (Eds.). (2022). *HTTP Semantics* (RF MacFarlane, J. (2024, January 28). *CommonMark specification* (Version 0.31.2). CommonMark. https://spec.commonmark.org/0.31.2/ -Microsoft. (n.d.-a). *Browsers*. Playwright documentation. Retrieved August 10, 2026, from https://playwright.dev/docs/browsers +Microsoft. (n.d.-a). *[MS-XLS]: Excel Binary File Format (.xls) Structure*. Microsoft Learn. Retrieved August 17, 2026, from https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-xls -Microsoft. (n.d.-b). *Hyperlink class (DocumentFormat.OpenXml.Wordprocessing)*. Microsoft Learn. Retrieved August 10, 2026, from https://learn.microsoft.com/en-us/dotnet/api/documentformat.openxml.wordprocessing.hyperlink +Microsoft. (n.d.-b). *Browsers*. Playwright documentation. Retrieved August 10, 2026, from https://playwright.dev/docs/browsers -Microsoft. (n.d.-c). *HyperlinkRelationship class (DocumentFormat.OpenXml.Packaging)*. Microsoft Learn. Retrieved August 10, 2026, from https://learn.microsoft.com/en-us/dotnet/api/documentformat.openxml.packaging.hyperlinkrelationship +Microsoft. (n.d.-c). *Hyperlink class (DocumentFormat.OpenXml.Wordprocessing)*. Microsoft Learn. Retrieved August 10, 2026, from https://learn.microsoft.com/en-us/dotnet/api/documentformat.openxml.wordprocessing.hyperlink -Microsoft. (n.d.-d). *Projects*. Playwright documentation. Retrieved August 10, 2026, from https://playwright.dev/docs/test-projects +Microsoft. (n.d.-d). *HyperlinkRelationship class (DocumentFormat.OpenXml.Packaging)*. Microsoft Learn. Retrieved August 10, 2026, from https://learn.microsoft.com/en-us/dotnet/api/documentformat.openxml.packaging.hyperlinkrelationship -Microsoft. (n.d.-e). *Release notes: Version 1.62*. Playwright. Retrieved August 10, 2026, from https://playwright.dev/docs/release-notes +Microsoft. (n.d.-e). *Projects*. Playwright documentation. Retrieved August 10, 2026, from https://playwright.dev/docs/test-projects -Microsoft. (n.d.-f). *Working with paragraphs*. Microsoft Learn. Retrieved August 10, 2026, from https://learn.microsoft.com/en-us/office/open-xml/word/working-with-paragraphs +Microsoft. (n.d.-f). *Release notes: Version 1.62*. Playwright. Retrieved August 10, 2026, from https://playwright.dev/docs/release-notes + +Microsoft. (n.d.-g). *Working with paragraphs*. Microsoft Learn. Retrieved August 10, 2026, from https://learn.microsoft.com/en-us/office/open-xml/word/working-with-paragraphs Node.js contributors. (2026). *Modules: Packages*. Node.js documentation. https://nodejs.org/api/packages.html @@ -68,6 +71,8 @@ python-docx. (n.d.-b). *Working with text*. Retrieved August 10, 2026, from http Rundgren, A., Jordan, B., & Erdtman, S. (2020). *JSON Canonicalization Scheme (JCS)* (RFC 8785). RFC Editor. https://doi.org/10.17487/RFC8785 +SheetJS. (n.d.). *SheetJS CE*. Retrieved August 17, 2026, from https://docs.sheetjs.com/ + Souppaya, M., Scarfone, K., & Dodson, D. (2022). *Secure Software Development Framework (SSDF) Version 1.1: Recommendations for Mitigating the Risk of Software Vulnerabilities* (NIST SP 800-218). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-218 Web Hypertext Application Technology Working Group. (2026). *HTML Standard: Parsing HTML documents* (Living Standard). Retrieved August 10, 2026, from https://html.spec.whatwg.org/multipage/parsing.html diff --git a/docs/TRD.md b/docs/TRD.md index 80cec07a..6776d5d5 100644 --- a/docs/TRD.md +++ b/docs/TRD.md @@ -141,4 +141,4 @@ Queued, cancelled, skipped-required, absent, stale-head, predecessor-head, statu Protected `main` is the sole shipped implementation baseline. SafeClipboard, cross-engine browser assurance, the security disclosure lifecycle, autosave lifecycle observation, toolbar shortcut accessibility metadata, accessible editor placeholder semantics, SSR/native-form serialization, revision-scoped selection evidence, W3C text-position selector evidence, the React-free text-position-selector subpath, document-transition evidence, envelope identity routing, framework-neutral deterministic Markdown conversion, CSS paged-media print output, DOCX informative PNG figures, bounded rich-text runs, bounded paragraph alignment, bounded heading alignment, and the OIDC-backed unified stable registry release train are `implemented_on_protected_main`. -The bounded DOCX rich-run external hyperlink contract in #137 is `implemented_on_active_pr` under Proposed ADR 0026. Open branches may extend the protected boundary, but no active-PR capability becomes shipped merely because its design, tests, or documentation are complete. +The bounded DOCX rich-run external hyperlink contract in #137 is `implemented_on_active_pr` under Proposed ADR 0026. Local XLS/XLSX worksheet body import in #318 is `implemented_on_active_pr` under Proposed ADR 0032. Open branches may extend the protected boundary, but no active-PR capability becomes shipped merely because its design, tests, or documentation are complete. diff --git a/docs/accessibility.md b/docs/accessibility.md index 58c29feb..c5bd9708 100644 --- a/docs/accessibility.md +++ b/docs/accessibility.md @@ -80,6 +80,8 @@ Disabled table, image, undo, and redo controls are skipped by directional naviga Toggle controls expose `aria-pressed`; one-shot command buttons do not claim a pressed state. The toolbar declares horizontal orientation and ships visible `:focus-visible` styling, including a forced-colors fallback. +The **Insert XLS/XLSX spreadsheet** control is a one-shot command. It opens a hidden local file picker, disables itself while the selected workbook is imported, and reports progress plus imported worksheet/row/cell counts through the toolbar's polite `aria-live` status region. Failures announce only `Spreadsheet import failed.` so assistive technology is told the next action — choose another supported file — without hearing file names, worksheet names, or cell text. This control is Active PR / Proposed under ADR 0032 and is not protected-main behavior. + After the image file passes the existing local conversion, type, size, and decode policy, the toolbar asks for alternative-text intent before creating the image node. A non-empty response becomes the image alternative text; an explicitly submitted empty response marks the image decorative with `alt=""`; canceling leaves the document unchanged. Conversion failures continue through `onImageError` and do not open the alternative-text prompt. The separate **Alt** control remains available for later corrections when an image is selected. Shortcuts that the editor already implements are also exposed programmatically with `aria-keyshortcuts` so assistive technology can discover the same commands that appear in the button titles. Inkspan publishes `Control+B Meta+B` for bold, `Control+I Meta+I` for italic, `Control+K Meta+K` for link editing, `Control+Z Meta+Z` for undo, and `Control+Shift+Z Meta+Shift+Z Control+Y Meta+Y` for redo. The redo alternatives reflect Tiptap's configured history and collaboration behavior: both `Ctrl/Cmd+Shift+Z` and `Ctrl/Cmd+Y` invoke redo. `aria-keyshortcuts` describes shortcuts that Inkspan already implements; it does not create keyboard behavior, replace the visible button label, or authorize hosts to intercept those combinations. The explicit values remain `Control` and `Meta` rather than a presentation-only `Ctrl/Cmd` abbreviation because WAI-ARIA defines those modifier tokens and permits a space-separated list of alternatives. diff --git a/docs/adr/0032-bounded-local-spreadsheet-body-import.md b/docs/adr/0032-bounded-local-spreadsheet-body-import.md new file mode 100644 index 00000000..988eae5c --- /dev/null +++ b/docs/adr/0032-bounded-local-spreadsheet-body-import.md @@ -0,0 +1,82 @@ +# ADR 0032: Bounded local spreadsheet body import + +Status: Proposed + +## Context + +Buyers need to insert visible worksheet contents from a locally selected `.xls` or `.xlsx` file into the current Inkspan document. The earlier Office renderer contract writes deterministic XLSX artifacts and keeps formula-looking strings inert (ADR 0012). It does not give the editor a local import path. Untrusted workbook bytes can carry macros, formulas, hyperlinks, hidden sheets, and hostile JavaScript object graphs. Those bytes must not gain network, credential, persistence, model, transport, or formula-execution authority. + +This decision is Proposed active-PR architecture for PR #318. It is not protected-main implementation authority until the branch merges with the required exact-head evidence. + +## Alternatives considered + +### Server-side conversion + +Rejected. Uploading workbook bytes would move transport, retention, and tenant isolation into Inkspan or force every host to stand up a conversion service for a local authoring action. + +### CSV-only or paste-only import + +Rejected as the primary contract. Buyers already have Excel workbooks. Paste remains available through SafeClipboard, but it does not preserve worksheet names or hidden-sheet exclusion from a real file. + +### Statically bundle the parser into the editor startup graph + +Rejected. Ordinary editor startup must not pay for or evaluate a workbook parser before the user selects a file. + +### Local preflight, lazy official SheetJS parse, inert TipTap insertion + +Selected. Classify ZIP/OLE bytes first, load the pinned official SheetJS CE 0.20.3 tarball only after that preflight, project visible displayed/cached cell text into parser-neutral workbook data, and insert one heading-plus-table batch at the current selection. + +## Decision + +Inkspan accepts local `.xls` and `.xlsx` bytes through the toolbar file picker and the framework-neutral `@contextualwisdomlab/cwl-editor/spreadsheet` subpath. + +The import must: + +1. reject source size above 64 MiB before allocating the file body; +2. classify only ZIP (`xlsx`) and OLE compound-file (`xls`) signatures; +3. load the parser only after that envelope preflight; +4. recover BIFF8 worksheet visibility from raw BoundSheet8 records rather than trusting mutable parser-emitted hidden flags; +5. insert only visible, non-empty worksheets as a level-3 heading, one rectangular table of ordinary cells, and a trailing paragraph; +6. project displayed or cached cell text only — formulas, macros, and hyperlinks receive no execution or editor authority; +7. enforce worksheet, row, column, cell, and text ceilings before TipTap materialization; +8. announce progress and counts through a polite status region, and emit only payload-redacted failures; +9. read genuine `File`/`Blob` bodies through `arrayBuffer()` when present, otherwise `FileReader` or `Response`, so DOMs that omit `Blob.arrayBuffer` still insert the selected workbook. + +Hosts retain transport, authorization, tenant isolation, durable persistence, credentials, retention, and model-use policy. Worksheet names are authoring labels, not tenant identifiers, and are not PII-masked in this lane. + +## Consequences + +- Authors can place a real worksheet into the document body as editable table cells. +- Hidden worksheets and formula/link payloads stay out of the inserted document. +- The spreadsheet subpath remains lazy and package-managed rather than part of ordinary editor startup. +- jsdom and older DOMs that omit `File.arrayBuffer` no longer fail closed on a genuine local file. + +## Failure and recovery + +Malformed signatures, hostile descriptors, parser exceptions, resource-limit violations, and rejected editor transactions leave the document unchanged and announce `Spreadsheet import failed.` Recovery is to choose a supported visible workbook within the documented ceilings. Hosts may observe the underlying redacted error through `onSpreadsheetError` without receiving cell text, file names, or parser payloads in the status region. + +## Security and privacy impact + +Workbook bytes are untrusted local content. The parser receives no network, credential, persistence, or model authority. Formula text, hyperlink targets, hidden-sheet values, and private exception causes must not appear in ordinary diagnostics. This decision does not authorize destination fetching, macro execution, or host-owned audit of workbook contents. + +## Compatibility and migration + +The change is additive. Existing documents, Office rendering, and ADR 0012 literal-formula export semantics are unchanged. Removing the toolbar control or the `./spreadsheet` subpath after publication would be a public compatibility change. + +## Verification + +Acceptance requires a known small XLS/XLSX fixture to insert into the document body with asserted heading and cell text, plus 100% owned-production coverage, package-consumer verification of the spreadsheet subpath, and exact-head product CI. Historical or predecessor-head checks do not transfer. + +## Rollback or supersession + +Rollback removes the toolbar control and spreadsheet subpath from the unmerged branch, or reverts the merged change as one reviewed compatibility decision. A future server converter, CSV-only path, or different parser may supersede this ADR only if it preserves local-only authority, inert cell projection, and redacted failures. + +## References — APA 7th + +Ecma International. (2021). *ECMA-376: Office Open XML file formats* (5th ed.). https://ecma-international.org/publications-and-standards/standards/ecma-376/ + +Microsoft. (n.d.). *[MS-XLS]: Excel Binary File Format (.xls) Structure*. Microsoft Learn. Retrieved August 17, 2026, from https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-xls + +SheetJS. (n.d.). *SheetJS CE*. Retrieved August 17, 2026, from https://docs.sheetjs.com/ + +World Wide Web Consortium. (2023, June 6). *Accessible Rich Internet Applications (WAI-ARIA) 1.2*. https://www.w3.org/TR/wai-aria-1.2/ diff --git a/docs/adr/README.md b/docs/adr/README.md index df8b7b80..d9418f73 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 | +| [0032](0032-bounded-local-spreadsheet-body-import.md) | Proposed | Bounded local XLS/XLSX worksheet insertion into the editor body | ## Decision discipline diff --git a/docs/package-distribution.md b/docs/package-distribution.md index 2103593f..62f3e0dc 100644 --- a/docs/package-distribution.md +++ b/docs/package-distribution.md @@ -18,7 +18,7 @@ integrations. | `@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 | | `@contextualwisdomlab/cwl-editor/markdown` | `implemented_on_active_pr` — headless deterministic Markdown/HTML/email/plain-text conversion with the same safe-link and strict inline-raster policies as the editor, without importing the React/TipTap editor graph | -| `@contextualwisdomlab/cwl-editor/spreadsheet` | `implemented_on_active_pr` — framework-neutral bounded XLS/XLSX envelope preflight and parser-neutral worksheet-to-editor JSON conversion; real workbook parsing and editor insertion remain incomplete and unshipped | +| `@contextualwisdomlab/cwl-editor/spreadsheet` | `implemented_on_active_pr` — framework-neutral bounded XLS/XLSX envelope preflight, lazy official SheetJS parse, and parser-neutral worksheet-to-editor JSON conversion used by the toolbar to insert visible worksheet tables; not protected-main authority | | `@contextualwisdomlab/cwl-editor/styles.css` | Editor layout and theming | | `@contextualwisdomlab/cwl-editor/fonts.css` | Full offline KR/EN/JP/SC/TC/VI font bundle | | `@contextualwisdomlab/cwl-editor/fonts-latin.css` | Smaller Latin/Vietnamese font bundle | diff --git a/src/canonicalProductDocumentation.test.ts b/src/canonicalProductDocumentation.test.ts index 746ed52e..bf2c18c3 100644 --- a/src/canonicalProductDocumentation.test.ts +++ b/src/canonicalProductDocumentation.test.ts @@ -48,6 +48,7 @@ const requiredFiles = [ 'docs/adr/0022-informative-docx-png-figures.md', 'docs/adr/0023-bounded-docx-rich-text-runs.md', 'docs/adr/0024-bounded-docx-paragraph-alignment.md', + 'docs/adr/0032-bounded-local-spreadsheet-body-import.md', 'src/fonts/OFL.txt', 'src/fonts/NOTICE', 'src/fonts/fonts.css', diff --git a/src/components/CwlEditor.spreadsheetBodyImport.test.tsx b/src/components/CwlEditor.spreadsheetBodyImport.test.tsx new file mode 100644 index 00000000..7406a022 --- /dev/null +++ b/src/components/CwlEditor.spreadsheetBodyImport.test.tsx @@ -0,0 +1,136 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it } from 'vitest'; +import * as XLSX from 'xlsx'; +import { Editor } from '@tiptap/react'; +import { buildExtensions } from '../extensions/kit.js'; +import { CwlEditor } from './CwlEditor.js'; +import { Toolbar } from './Toolbar.js'; + +const openEditors: Editor[] = []; + +function spreadsheetInput(): HTMLInputElement { + return document.querySelector( + 'input[data-cwl-spreadsheet-input="true"]', + ) as HTMLInputElement; +} + +function serializeWorkbook(bookType: 'xlsx' | 'biff8'): Uint8Array { + const workbook = XLSX.utils.book_new(); + const worksheet = XLSX.utils.aoa_to_sheet([ + ['Product Name', 'Unit Count'], + ['Alpha Widget', 12], + ['Beta Gadget', 7], + ]); + XLSX.utils.book_append_sheet(workbook, worksheet, 'Quarterly Revenue'); + const serialized = XLSX.write(workbook, { type: 'array', bookType }); + return serialized instanceof Uint8Array + ? serialized + : new Uint8Array(serialized as ArrayBuffer); +} + +function quarterlyRevenueFile(bookType: 'xlsx' | 'biff8'): File { + const bytes = serializeWorkbook(bookType); + return new File([bytes], 'quarterly-revenue.xlsx', { + type: + bookType === 'xlsx' + ? 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + : 'application/vnd.ms-excel', + }); +} + +function expectQuarterlyRevenueTable(root: ParentNode): void { + const cells = [...root.querySelectorAll('table td')].map( + (cell) => cell.textContent ?? '', + ); + expect(cells).toEqual([ + 'Product Name', + 'Unit Count', + 'Alpha Widget', + '12', + 'Beta Gadget', + '7', + ]); + expect(root).toHaveTextContent('Quarterly Revenue'); +} + +function makeEditor(content = '

Before

After

'): Editor { + const element = document.createElement('div'); + document.body.appendChild(element); + const editor = new Editor({ + element, + extensions: buildExtensions({ image: { maxDimension: 0 } }), + content, + }); + openEditors.push(editor); + return editor; +} + +afterEach(() => { + cleanup(); + for (const editor of openEditors.splice(0)) { + if (!editor.isDestroyed) editor.destroy(); + } +}); + +describe('real spreadsheet worksheet insertion into the document body', () => { + it.each([ + ['XLSX', 'xlsx'], + ['BIFF8 XLS', 'biff8'], + ] as const)( + 'inserts a known %s worksheet as an editable heading and table cells', + async (_label, bookType) => { + const editor = makeEditor(); + editor.commands.setTextSelection(7); + render(); + + fireEvent.change(spreadsheetInput(), { + target: { files: [quarterlyRevenueFile(bookType)] }, + }); + + await waitFor(() => + expect(screen.getByRole('status')).toHaveTextContent( + 'Imported 1 worksheet, 3 rows, and 6 cells.', + ), + ); + + const html = editor.getHTML(); + expect(html.indexOf('Before')).toBeLessThan(html.indexOf('Quarterly Revenue')); + expect(html.indexOf('Quarterly Revenue')).toBeLessThan(html.indexOf('After')); + expectQuarterlyRevenueTable(editor.view.dom); + expect(editor.getJSON().content).toEqual( + expect.arrayContaining([ + { + type: 'heading', + attrs: { level: 3 }, + content: [{ type: 'text', text: 'Quarterly Revenue' }], + }, + ]), + ); + }, + ); + + it('inserts the same known XLSX fixture through the public CwlEditor toolbar', async () => { + render(); + + await waitFor(() => + expect( + screen.getByRole('button', { name: 'Insert XLS/XLSX spreadsheet' }), + ).toBeInTheDocument(), + ); + + fireEvent.change(spreadsheetInput(), { + target: { files: [quarterlyRevenueFile('xlsx')] }, + }); + + await waitFor(() => + expect(screen.getByRole('status')).toHaveTextContent( + 'Imported 1 worksheet, 3 rows, and 6 cells.', + ), + ); + + const documentRoot = document.querySelector('.ProseMirror'); + expect(documentRoot).not.toBeNull(); + expectQuarterlyRevenueTable(documentRoot!); + expect(documentRoot).toHaveTextContent('Before'); + }); +}); diff --git a/src/spreadsheet/sheetJsAdapter.parsedSheetIndex.test.ts b/src/spreadsheet/sheetJsAdapter.parsedSheetIndex.test.ts new file mode 100644 index 00000000..ca2f62a1 --- /dev/null +++ b/src/spreadsheet/sheetJsAdapter.parsedSheetIndex.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + sheetJsBytesToWorkbookData, + type SheetJsParserModule, +} from './sheetJsAdapter.js'; +import { SpreadsheetImportError } from './spreadsheetImport.js'; + +const XLSX_SOURCE = new Uint8Array([0x50, 0x4b, 0x03, 0x04]); + +function expectSpreadsheetError( + action: () => unknown, + code: 'UNSUPPORTED_OR_CORRUPT' | 'RESOURCE_LIMIT_EXCEEDED', +): void { + let caught: unknown; + try { + action(); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(SpreadsheetImportError); + expect(caught).toMatchObject({ code }); +} + +function parserWithSelectiveSheetNames( + selectiveSheetNames: unknown, +): SheetJsParserModule { + let pass = 0; + return { + read: vi.fn(() => { + pass += 1; + if (pass === 1) { + return { SheetNames: ['Summary'] }; + } + return { + SheetNames: selectiveSheetNames, + Sheets: { Summary: {} }, + }; + }), + utils: { + decode_range: vi.fn(), + sheet_to_json: vi.fn(), + }, + }; +} + +describe('sheetJsBytesToWorkbookData selective sheet-index authority', () => { + it('rejects a selective parse whose sheet-name container is not a data array', () => { + expectSpreadsheetError( + () => + sheetJsBytesToWorkbookData( + XLSX_SOURCE, + parserWithSelectiveSheetNames({}), + ), + 'UNSUPPORTED_OR_CORRUPT', + ); + }); + + it('rejects a selective parse that reports more worksheet names than the workbook ceiling', () => { + const sheetNames = Array.from( + { length: 257 }, + (_, index) => `Sheet ${index}`, + ); + expectSpreadsheetError( + () => + sheetJsBytesToWorkbookData( + XLSX_SOURCE, + parserWithSelectiveSheetNames(sheetNames), + ), + 'RESOURCE_LIMIT_EXCEEDED', + ); + }); + + it('rejects a selective parse whose sheet names are not strings', () => { + expectSpreadsheetError( + () => + sheetJsBytesToWorkbookData( + XLSX_SOURCE, + parserWithSelectiveSheetNames([123]), + ), + 'UNSUPPORTED_OR_CORRUPT', + ); + }); + + it('rejects a selective parse whose sheet name exceeds the code-unit ceiling', () => { + expectSpreadsheetError( + () => + sheetJsBytesToWorkbookData( + XLSX_SOURCE, + parserWithSelectiveSheetNames(['x'.repeat(1_025)]), + ), + 'RESOURCE_LIMIT_EXCEEDED', + ); + }); + + it('rejects a selective parse that lists the requested sheet more than once', () => { + expectSpreadsheetError( + () => + sheetJsBytesToWorkbookData( + XLSX_SOURCE, + parserWithSelectiveSheetNames(['Other', 'Summary', 'Summary']), + ), + 'UNSUPPORTED_OR_CORRUPT', + ); + }); + + it('rejects a selective parse that omits the requested visible sheet', () => { + expectSpreadsheetError( + () => + sheetJsBytesToWorkbookData( + XLSX_SOURCE, + parserWithSelectiveSheetNames(['Other']), + ), + 'UNSUPPORTED_OR_CORRUPT', + ); + }); +}); diff --git a/src/spreadsheet/sheetJsFileImport.test.ts b/src/spreadsheet/sheetJsFileImport.test.ts index 0ed0cbbb..53090a22 100644 --- a/src/spreadsheet/sheetJsFileImport.test.ts +++ b/src/spreadsheet/sheetJsFileImport.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import * as XLSX from 'xlsx'; import { SpreadsheetImportError, @@ -102,6 +102,10 @@ function expectUnsupported(promise: Promise) { } satisfies Partial); } +afterEach(() => { + vi.unstubAllGlobals(); +}); + describe('spreadsheetFileToDocumentJson', () => { it('preserves hidden-sheet metadata in the real BIFF8 visibility parse', () => { const workbook = XLSX.read(richWorkbookBytes('biff8'), { @@ -245,4 +249,151 @@ describe('spreadsheetFileToDocumentJson', () => { await expectUnsupported(spreadsheetFileToDocumentJson(source)); }); + + it('reads a genuine browser File through FileReader when arrayBuffer is absent', async () => { + const bytes = workbookBytes('xlsx'); + const file = new File([bytes], 'quarterly-revenue.xlsx', { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }); + expect(typeof file.arrayBuffer).not.toBe('function'); + + const result = await spreadsheetFileToDocumentJson(file); + expect(result).toMatchObject({ + worksheetCount: 1, + rowCount: 2, + cellCount: 4, + }); + expect(JSON.stringify(result.content)).toContain('Revenue'); + expect(JSON.stringify(result.content)).toContain('42'); + }); + + it('normalizes a hostile arrayBuffer accessor without leaking payload text', async () => { + const source = { + size: 4, + } as SpreadsheetFileSource; + Object.defineProperty(source, 'arrayBuffer', { + enumerable: true, + get() { + throw new Error('private local path'); + }, + }); + await expectUnsupported(spreadsheetFileToDocumentJson(source)); + }); + + it('rejects a non-Blob source that cannot supply arrayBuffer', async () => { + await expectUnsupported( + spreadsheetFileToDocumentJson({ size: 4 } as SpreadsheetFileSource), + ); + }); + + it('normalizes FileReader construction and read failures', async () => { + const bytes = workbookBytes('xlsx'); + const file = new File([bytes], 'quarterly-revenue.xlsx', { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }); + + class ThrowingReader { + onload: ((event: ProgressEvent) => void) | null = null; + onerror: ((event: ProgressEvent) => void) | null = null; + result: ArrayBuffer | null = null; + error: DOMException | null = null; + constructor() { + throw new Error('private FileReader construction'); + } + readAsArrayBuffer(): void {} + } + vi.stubGlobal('FileReader', ThrowingReader); + await expectUnsupported(spreadsheetFileToDocumentJson(file)); + + class ReadThrowingReader { + onload: ((event: ProgressEvent) => void) | null = null; + onerror: ((event: ProgressEvent) => void) | null = null; + result: ArrayBuffer | null = null; + error: DOMException | null = null; + readAsArrayBuffer(): void { + throw new Error('private FileReader read'); + } + } + vi.stubGlobal('FileReader', ReadThrowingReader); + await expectUnsupported(spreadsheetFileToDocumentJson(file)); + + class ErroringReader { + onload: ((event: ProgressEvent) => void) | null = null; + onerror: ((event: ProgressEvent) => void) | null = null; + result: ArrayBuffer | null = null; + error = new DOMException('private FileReader error'); + readAsArrayBuffer(): void { + this.onerror?.(new Event('error') as ProgressEvent); + } + } + vi.stubGlobal('FileReader', ErroringReader); + await expectUnsupported(spreadsheetFileToDocumentJson(file)); + + class NonBufferReader { + onload: ((event: ProgressEvent) => void) | null = null; + onerror: ((event: ProgressEvent) => void) | null = null; + result: string | ArrayBuffer | null = 'not bytes'; + error: DOMException | null = null; + readAsArrayBuffer(): void { + this.onload?.(new Event('load') as ProgressEvent); + } + } + vi.stubGlobal('FileReader', NonBufferReader); + await expectUnsupported(spreadsheetFileToDocumentJson(file)); + + vi.unstubAllGlobals(); + }); + + it('reads a Blob through Response when FileReader is unavailable', async () => { + const bytes = workbookBytes('xlsx'); + const file = new File([bytes], 'quarterly-revenue.xlsx', { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }); + const copied = bytes.buffer.slice( + bytes.byteOffset, + bytes.byteOffset + bytes.byteLength, + ) as ArrayBuffer; + vi.stubGlobal('FileReader', undefined); + vi.stubGlobal( + 'Response', + class { + async arrayBuffer(): Promise { + return copied; + } + }, + ); + const result = await spreadsheetFileToDocumentJson(file); + expect(result).toMatchObject({ + worksheetCount: 1, + rowCount: 2, + cellCount: 4, + }); + }); + + it('normalizes a failing Response fallback without leaking payload text', async () => { + const bytes = workbookBytes('xlsx'); + const file = new File([bytes], 'quarterly-revenue.xlsx', { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }); + vi.stubGlobal('FileReader', undefined); + vi.stubGlobal( + 'Response', + class { + constructor() { + throw new Error('private Response payload'); + } + }, + ); + await expectUnsupported(spreadsheetFileToDocumentJson(file)); + }); + + it('rejects a Blob when neither FileReader nor Response can read it', async () => { + const bytes = workbookBytes('xlsx'); + const file = new File([bytes], 'quarterly-revenue.xlsx', { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }); + vi.stubGlobal('FileReader', undefined); + vi.stubGlobal('Response', undefined); + await expectUnsupported(spreadsheetFileToDocumentJson(file)); + }); }); diff --git a/src/spreadsheet/sheetJsRuntime.failClosed.test.ts b/src/spreadsheet/sheetJsRuntime.failClosed.test.ts new file mode 100644 index 00000000..d50ca138 --- /dev/null +++ b/src/spreadsheet/sheetJsRuntime.failClosed.test.ts @@ -0,0 +1,410 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + parseSheetJsSpreadsheetBytesWithParserLoader, +} from './sheetJsRuntime.js'; +import type { SheetJsParserModule } from './sheetJsAdapter.js'; +import { SpreadsheetImportError } from './spreadsheetImport.js'; + +const BIFF8_SIGNATURE = [ + 0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1, +] as const; + +const BIFF8_BOF = 0x0809; +const BIFF8_BOUNDSHEET8 = 0x0085; +const BIFF8_EOF = 0x000a; + +function biff8Envelope(minimumLength = 65_536): Uint8Array { + const bytes = new Uint8Array(Math.max(minimumLength, 8)); + bytes.set(BIFF8_SIGNATURE); + return bytes; +} + +function writeUint16(target: number[], value: number): void { + target.push(value & 0xff, (value >> 8) & 0xff); +} + +function record(type: number, payload: readonly number[]): number[] { + const encoded: number[] = []; + writeUint16(encoded, type); + writeUint16(encoded, payload.length); + encoded.push(...payload); + return encoded; +} + +function workbookBof(payload: readonly number[] = [0x00, 0x06, 0x05, 0x00]): number[] { + return record(BIFF8_BOF, payload); +} + +function boundSheet(visibility = 0): number[] { + return record(BIFF8_BOUNDSHEET8, [0, 0, 0, 0, visibility, 0, 0, 0]); +} + +function eof(): number[] { + return record(BIFF8_EOF, []); +} + +function validWorkbookStream(): number[] { + return [...workbookBof(), ...boundSheet(0), ...eof()]; +} + +function expectUnsupported(promise: Promise) { + return expect(promise).rejects.toMatchObject({ + name: 'SpreadsheetImportError', + code: 'UNSUPPORTED_OR_CORRUPT', + message: 'Spreadsheet source is unsupported or corrupt.', + } satisfies Partial); +} + +function expectResourceLimit(promise: Promise) { + return expect(promise).rejects.toMatchObject({ + name: 'SpreadsheetImportError', + code: 'RESOURCE_LIMIT_EXCEEDED', + message: 'Spreadsheet exceeds the configured resource limits.', + } satisfies Partial); +} + +function parserWithCfb( + content: unknown, + options: { + readonly cfb?: unknown; + readonly read?: () => unknown; + readonly find?: () => unknown; + } = {}, +): SheetJsParserModule { + const find = + options.find ?? + (() => ({ + content, + })); + const cfb = + options.cfb ?? + { + read: options.read ?? (() => ({})), + find, + }; + return { + CFB: cfb, + read: vi.fn(() => ({ + SheetNames: ['Summary'], + Sheets: { Summary: {} }, + Workbook: { Sheets: [{ Hidden: 0 }] }, + })), + utils: { + decode_range: vi.fn(() => ({ + s: { r: 0, c: 0 }, + e: { r: 0, c: 0 }, + })), + sheet_to_json: vi.fn(() => []), + }, + } as unknown as SheetJsParserModule; +} + +describe('SheetJS BIFF8 runtime fail-closed boundaries', () => { + it('rejects a parser without a usable CFB reader', async () => { + await expectUnsupported( + parseSheetJsSpreadsheetBytesWithParserLoader( + biff8Envelope(), + async () => parserWithCfb([], { cfb: {} }), + ), + ); + }); + + it('normalizes CFB container and Workbook-entry failures without leaking payload text', async () => { + await expectUnsupported( + parseSheetJsSpreadsheetBytesWithParserLoader( + biff8Envelope(), + async () => + parserWithCfb([], { + read: () => { + throw new Error('private compound-file payload'); + }, + }), + ), + ); + await expectUnsupported( + parseSheetJsSpreadsheetBytesWithParserLoader( + biff8Envelope(), + async () => parserWithCfb([], { find: () => null }), + ), + ); + }); + + it('rejects a Workbook entry whose content descriptor is missing or hostile', async () => { + await expectUnsupported( + parseSheetJsSpreadsheetBytesWithParserLoader( + biff8Envelope(), + async () => parserWithCfb(undefined, { find: () => ({}) }), + ), + ); + + const accessorEntry = {}; + Object.defineProperty(accessorEntry, 'content', { + enumerable: true, + get() { + throw new Error('private workbook stream'); + }, + }); + await expectUnsupported( + parseSheetJsSpreadsheetBytesWithParserLoader( + biff8Envelope(), + async () => parserWithCfb(undefined, { find: () => accessorEntry }), + ), + ); + + const descriptorTrap = new Proxy( + {}, + { + getOwnPropertyDescriptor() { + throw new Error('private descriptor trap'); + }, + }, + ); + await expectUnsupported( + parseSheetJsSpreadsheetBytesWithParserLoader( + biff8Envelope(), + async () => parserWithCfb(undefined, { find: () => descriptorTrap }), + ), + ); + }); + + it('rejects CFB byte views whose length metadata cannot be trusted', async () => { + const throwingView = new Uint8Array([1]); + Object.defineProperty(throwingView, 'byteLength', { + configurable: true, + get() { + throw new Error('private view length'); + }, + }); + await expectUnsupported( + parseSheetJsSpreadsheetBytesWithParserLoader( + biff8Envelope(), + async () => parserWithCfb(throwingView), + ), + ); + + const mismatchedView = new Uint16Array([1]); + await expectUnsupported( + parseSheetJsSpreadsheetBytesWithParserLoader( + biff8Envelope(), + async () => parserWithCfb(mismatchedView), + ), + ); + }); + + it('rejects CFB content that is neither a byte view nor a documented byte array', async () => { + await expectUnsupported( + parseSheetJsSpreadsheetBytesWithParserLoader( + biff8Envelope(), + async () => parserWithCfb({}), + ), + ); + }); + + it('rejects CFB byte views whose indexed values are not octets', async () => { + const hostileView = Object.create(null) as { + byteLength: number; + length: number; + 0: number; + }; + hostileView.byteLength = 1; + hostileView.length = 1; + Object.defineProperty(hostileView, '0', { + configurable: true, + enumerable: true, + value: 256, + writable: true, + }); + const originalIsView = ArrayBuffer.isView.bind(ArrayBuffer); + const isView = vi.spyOn(ArrayBuffer, 'isView').mockImplementation((value) => { + return value === hostileView || originalIsView(value); + }); + try { + await expectUnsupported( + parseSheetJsSpreadsheetBytesWithParserLoader( + biff8Envelope(), + async () => parserWithCfb(hostileView), + ), + ); + } finally { + isView.mockRestore(); + } + }); + + it('rejects a CFB byte array longer than the original source envelope', async () => { + await expectUnsupported( + parseSheetJsSpreadsheetBytesWithParserLoader( + biff8Envelope(8), + async () => parserWithCfb(Array.from({ length: 16 }, () => 0)), + ), + ); + }); + + it('rejects a CFB byte array whose length metadata is not a safe count', async () => { + const content = new Proxy([] as number[], { + getOwnPropertyDescriptor(target, property) { + if (property === 'length') { + return { + configurable: true, + enumerable: false, + value: -1, + writable: true, + }; + } + return Reflect.getOwnPropertyDescriptor(target, property); + }, + }); + await expectUnsupported( + parseSheetJsSpreadsheetBytesWithParserLoader( + biff8Envelope(), + async () => parserWithCfb(content), + ), + ); + }); + + it('rejects a CFB byte array whose indexed values are not octets', async () => { + await expectUnsupported( + parseSheetJsSpreadsheetBytesWithParserLoader( + biff8Envelope(), + async () => parserWithCfb([256, 0, 0, 0]), + ), + ); + }); + + it('copies a documented CFB byte array and then rejects a truncated BIFF8 record', async () => { + await expectUnsupported( + parseSheetJsSpreadsheetBytesWithParserLoader( + biff8Envelope(), + async () => parserWithCfb([...workbookBof(), 0x85, 0x00, 0xff, 0xff]), + ), + ); + }); + + it('rejects invalid workbook BOF, short BoundSheet8, and illegal visibility bits', async () => { + await expectUnsupported( + parseSheetJsSpreadsheetBytesWithParserLoader( + biff8Envelope(), + async () => parserWithCfb([...record(0x0000, []), ...eof()]), + ), + ); + await expectUnsupported( + parseSheetJsSpreadsheetBytesWithParserLoader( + biff8Envelope(), + async () => + parserWithCfb([ + ...workbookBof(), + ...record(BIFF8_BOUNDSHEET8, [0, 0, 0, 0, 0, 0, 0]), + ...eof(), + ]), + ), + ); + await expectUnsupported( + parseSheetJsSpreadsheetBytesWithParserLoader( + biff8Envelope(), + async () => + parserWithCfb([...workbookBof(), ...boundSheet(0x03), ...eof()]), + ), + ); + await expectUnsupported( + parseSheetJsSpreadsheetBytesWithParserLoader( + biff8Envelope(), + async () => + parserWithCfb([...workbookBof(), ...boundSheet(0x04), ...eof()]), + ), + ); + }); + + it('rejects more BoundSheet8 records than the BIFF8 worksheet ceiling', async () => { + const stream = [...workbookBof()]; + for (let index = 0; index < 257; index += 1) { + stream.push(...boundSheet(0)); + } + stream.push(...eof()); + await expectResourceLimit( + parseSheetJsSpreadsheetBytesWithParserLoader( + biff8Envelope(stream.length), + async () => parserWithCfb(stream), + ), + ); + }); + + it('rejects a workbook stream that never emits EOF', async () => { + await expectUnsupported( + parseSheetJsSpreadsheetBytesWithParserLoader( + biff8Envelope(), + async () => parserWithCfb([...workbookBof(), ...boundSheet(0)]), + ), + ); + }); + + it('rejects authoritative-visibility wrapping when the parser result is not an object', async () => { + const parser = parserWithCfb(validWorkbookStream()); + (parser.read as ReturnType).mockReturnValue(null); + await expectUnsupported( + parseSheetJsSpreadsheetBytesWithParserLoader( + biff8Envelope(), + async () => parser, + ), + ); + }); + + it('rejects a parser result whose sheet-name count disagrees with BoundSheet8 visibility', async () => { + const parser = parserWithCfb(validWorkbookStream()); + (parser.read as ReturnType).mockReturnValue({ + SheetNames: ['Summary', 'Extra'], + Sheets: { Summary: {} }, + }); + await expectUnsupported( + parseSheetJsSpreadsheetBytesWithParserLoader( + biff8Envelope(), + async () => parser, + ), + ); + }); + + it('rejects hostile or non-array sheet-name containers after visibility wrapping', async () => { + const nonArray = parserWithCfb(validWorkbookStream()); + (nonArray.read as ReturnType).mockReturnValue({ + SheetNames: {}, + }); + await expectUnsupported( + parseSheetJsSpreadsheetBytesWithParserLoader( + biff8Envelope(), + async () => nonArray, + ), + ); + + const originalGetOwnPropertyDescriptor = + Object.getOwnPropertyDescriptor.bind(Object); + + for (const hostileLength of [Number.NaN, -1]) { + const invalidLength = parserWithCfb(validWorkbookStream()); + const sheetNames = ['Sheet1']; + const descriptorSpy = vi + .spyOn(Object, 'getOwnPropertyDescriptor') + .mockImplementation((source, key) => { + if (source === sheetNames && key === 'length') { + return { + configurable: false, + enumerable: false, + writable: true, + value: hostileLength, + }; + } + return originalGetOwnPropertyDescriptor(source, key); + }); + try { + (invalidLength.read as ReturnType).mockReturnValue({ + SheetNames: sheetNames, + }); + await expectUnsupported( + parseSheetJsSpreadsheetBytesWithParserLoader( + biff8Envelope(), + async () => invalidLength, + ), + ); + } finally { + descriptorSpy.mockRestore(); + } + } + }); +}); diff --git a/src/spreadsheet/sheetJsRuntime.ts b/src/spreadsheet/sheetJsRuntime.ts index f083234f..e86172c7 100644 --- a/src/spreadsheet/sheetJsRuntime.ts +++ b/src/spreadsheet/sheetJsRuntime.ts @@ -38,8 +38,12 @@ type SheetJsParserWithCfb = SheetJsParserModule & { export interface SpreadsheetFileSource { /** Byte length available before allocating and reading the file body. */ readonly size: number; - /** Read the local file body without granting any path, network, or persistence authority. */ - arrayBuffer(): Promise; + /** + * Read the local file body without granting any path, network, or persistence + * authority. Genuine `File`/`Blob` values may omit this method; those are + * read through `FileReader` or `Response` instead of requiring `arrayBuffer`. + */ + arrayBuffer?(): Promise; } function resourceLimitExceeded(): SpreadsheetImportError { @@ -56,6 +60,71 @@ function unsupportedOrCorruptSource(): SpreadsheetImportError { ); } +function isBlobSource(source: SpreadsheetFileSource): source is SpreadsheetFileSource & Blob { + return typeof Blob !== 'undefined' && source instanceof Blob; +} + +function readBlobViaFileReader(blob: Blob): Promise { + return new Promise((resolve, reject) => { + let reader: FileReader; + try { + reader = new FileReader(); + } catch { + reject(unsupportedOrCorruptSource()); + return; + } + + reader.onload = () => { + const result = reader.result; + if (!(result instanceof ArrayBuffer)) { + reject(unsupportedOrCorruptSource()); + return; + } + resolve(result); + }; + reader.onerror = () => { + reject(unsupportedOrCorruptSource()); + }; + + try { + reader.readAsArrayBuffer(blob); + } catch { + reject(unsupportedOrCorruptSource()); + } + }); +} + +async function readSourceArrayBuffer( + source: SpreadsheetFileSource, +): Promise { + let arrayBufferMethod: unknown; + try { + arrayBufferMethod = source.arrayBuffer; + } catch { + throw unsupportedOrCorruptSource(); + } + + if (typeof arrayBufferMethod === 'function') { + try { + return await arrayBufferMethod.call(source); + } catch { + throw unsupportedOrCorruptSource(); + } + } + + if (isBlobSource(source) && typeof FileReader !== 'undefined') { + return readBlobViaFileReader(source); + } + if (isBlobSource(source) && typeof Response !== 'undefined') { + try { + return await new Response(source).arrayBuffer(); + } catch { + throw unsupportedOrCorruptSource(); + } + } + throw unsupportedOrCorruptSource(); +} + function isObject(value: unknown): value is object { return typeof value === 'object' && value !== null; } @@ -309,9 +378,12 @@ export async function parseSheetJsSpreadsheetBytes( /** * Read one local browser file and convert its visible worksheets to inert TipTap JSON. * - * Source size is checked before `arrayBuffer()` so oversized user-selected files are - * rejected before a proportional allocation. Read failures and malformed source - * identities are normalized to the stable payload-redacted import error contract. + * Source size is checked before the file body is read so oversized user-selected + * files are rejected before a proportional allocation. Browser `File` values are + * read through `arrayBuffer()` when present, otherwise through `FileReader` or + * `Response`, matching the image-import fallback for DOMs that omit + * `Blob.arrayBuffer`. Read failures and malformed source identities are + * normalized to the stable payload-redacted import error contract. */ export async function spreadsheetFileToDocumentJson( source: SpreadsheetFileSource, @@ -330,12 +402,7 @@ export async function spreadsheetFileToDocumentJson( throw resourceLimitExceeded(); } - let buffer: ArrayBuffer; - try { - buffer = await source.arrayBuffer(); - } catch { - throw unsupportedOrCorruptSource(); - } + const buffer = await readSourceArrayBuffer(source); if (!(buffer instanceof ArrayBuffer) || buffer.byteLength !== sourceSize) { throw unsupportedOrCorruptSource(); } diff --git a/src/spreadsheetBodyImportDocumentation.test.ts b/src/spreadsheetBodyImportDocumentation.test.ts new file mode 100644 index 00000000..76b61b5f --- /dev/null +++ b/src/spreadsheetBodyImportDocumentation.test.ts @@ -0,0 +1,48 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +const repositoryFile = (path: string): string => + readFileSync(resolve(process.cwd(), path), 'utf8'); + +describe('active-PR spreadsheet body-import documentation', () => { + it('keeps ADR 0032 Proposed and discoverable without promoting it to protected main', () => { + const adrPath = 'docs/adr/0032-bounded-local-spreadsheet-body-import.md'; + const index = repositoryFile('docs/adr/README.md'); + const changelog = repositoryFile('CHANGELOG.md'); + const distribution = repositoryFile('docs/package-distribution.md'); + const contracts = repositoryFile('docs/CONTRACTS.md'); + const fitness = repositoryFile('docs/DOCUMENTATION_FITNESS.md'); + const unreleased = changelog.slice( + changelog.indexOf('## [Unreleased]'), + changelog.indexOf('## [0.6.0]'), + ); + + expect(existsSync(resolve(process.cwd(), adrPath))).toBe(true); + const adr = repositoryFile(adrPath); + expect(index).toContain( + '[0032](0032-bounded-local-spreadsheet-body-import.md) | Proposed', + ); + expect(adr).toMatch(/^Status: Proposed$/mu); + expect(adr).not.toMatch(/^Status: Accepted$/mu); + expect(adr).toContain('not protected-main implementation authority'); + expect(unreleased).toContain('Active PR / Proposed'); + expect(unreleased).toContain('local XLS/XLSX worksheet insertion'); + expect(unreleased).toContain('not protected-main behavior'); + expect(distribution).toMatch( + /`@contextualwisdomlab\/cwl-editor\/spreadsheet`\s*\|\s*`implemented_on_active_pr`/u, + ); + expect(distribution).not.toMatch( + /`@contextualwisdomlab\/cwl-editor\/spreadsheet`[^\n]*implemented_on_protected_main/u, + ); + expect(contracts).toContain('Local spreadsheet body-import contract'); + expect(contracts).toContain('not protected-main authority'); + expect(fitness).toMatch( + /Bounded local spreadsheet body import[^\n]*implemented_on_active_pr/u, + ); + expect(fitness).not.toMatch( + /Bounded local spreadsheet body import[^\n]*implemented_on_protected_main/u, + ); + }); +}); From de27e43823a639ed583a6afe001782a00c3554fa Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 17 Aug 2026 17:27:18 +0000 Subject: [PATCH 146/163] test(spreadsheet): copy workbook bytes before constructing File SheetJS array output is typed as Uint8Array, which is not a BlobPart under the current TypeScript libs. Copy into a fresh ArrayBuffer-backed view before the jsdom File constructor. Co-authored-by: Seongho Bae --- .../CwlEditor.spreadsheetBodyImport.test.tsx | 24 +++++++--- src/spreadsheet/sheetJsFileImport.test.ts | 46 +++++++++++++------ 2 files changed, 48 insertions(+), 22 deletions(-) diff --git a/src/components/CwlEditor.spreadsheetBodyImport.test.tsx b/src/components/CwlEditor.spreadsheetBodyImport.test.tsx index 7406a022..da3f1a80 100644 --- a/src/components/CwlEditor.spreadsheetBodyImport.test.tsx +++ b/src/components/CwlEditor.spreadsheetBodyImport.test.tsx @@ -28,14 +28,24 @@ function serializeWorkbook(bookType: 'xlsx' | 'biff8'): Uint8Array { : new Uint8Array(serialized as ArrayBuffer); } +function fileFromWorkbookBytes( + bytes: Uint8Array, + name: string, + type: string, +): File { + const copy = new Uint8Array(bytes.byteLength); + copy.set(bytes); + return new File([copy], name, { type }); +} + function quarterlyRevenueFile(bookType: 'xlsx' | 'biff8'): File { - const bytes = serializeWorkbook(bookType); - return new File([bytes], 'quarterly-revenue.xlsx', { - type: - bookType === 'xlsx' - ? 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' - : 'application/vnd.ms-excel', - }); + return fileFromWorkbookBytes( + serializeWorkbook(bookType), + 'quarterly-revenue.xlsx', + bookType === 'xlsx' + ? 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + : 'application/vnd.ms-excel', + ); } function expectQuarterlyRevenueTable(root: ParentNode): void { diff --git a/src/spreadsheet/sheetJsFileImport.test.ts b/src/spreadsheet/sheetJsFileImport.test.ts index 53090a22..bf2e7a23 100644 --- a/src/spreadsheet/sheetJsFileImport.test.ts +++ b/src/spreadsheet/sheetJsFileImport.test.ts @@ -94,6 +94,12 @@ function richWorkbookBytes(bookType: WorkbookFormat): Uint8Array { return serializeWorkbook(workbook, bookType); } +function fileFromWorkbookBytes(bytes: Uint8Array, name: string, type: string): File { + const copy = new Uint8Array(bytes.byteLength); + copy.set(bytes); + return new File([copy], name, { type }); +} + function expectUnsupported(promise: Promise) { return expect(promise).rejects.toMatchObject({ name: 'SpreadsheetImportError', @@ -252,9 +258,11 @@ describe('spreadsheetFileToDocumentJson', () => { it('reads a genuine browser File through FileReader when arrayBuffer is absent', async () => { const bytes = workbookBytes('xlsx'); - const file = new File([bytes], 'quarterly-revenue.xlsx', { - type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - }); + const file = fileFromWorkbookBytes( + bytes, + 'quarterly-revenue.xlsx', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ); expect(typeof file.arrayBuffer).not.toBe('function'); const result = await spreadsheetFileToDocumentJson(file); @@ -288,9 +296,11 @@ describe('spreadsheetFileToDocumentJson', () => { it('normalizes FileReader construction and read failures', async () => { const bytes = workbookBytes('xlsx'); - const file = new File([bytes], 'quarterly-revenue.xlsx', { - type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - }); + const file = fileFromWorkbookBytes( + bytes, + 'quarterly-revenue.xlsx', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ); class ThrowingReader { onload: ((event: ProgressEvent) => void) | null = null; @@ -346,9 +356,11 @@ describe('spreadsheetFileToDocumentJson', () => { it('reads a Blob through Response when FileReader is unavailable', async () => { const bytes = workbookBytes('xlsx'); - const file = new File([bytes], 'quarterly-revenue.xlsx', { - type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - }); + const file = fileFromWorkbookBytes( + bytes, + 'quarterly-revenue.xlsx', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ); const copied = bytes.buffer.slice( bytes.byteOffset, bytes.byteOffset + bytes.byteLength, @@ -372,9 +384,11 @@ describe('spreadsheetFileToDocumentJson', () => { it('normalizes a failing Response fallback without leaking payload text', async () => { const bytes = workbookBytes('xlsx'); - const file = new File([bytes], 'quarterly-revenue.xlsx', { - type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - }); + const file = fileFromWorkbookBytes( + bytes, + 'quarterly-revenue.xlsx', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ); vi.stubGlobal('FileReader', undefined); vi.stubGlobal( 'Response', @@ -389,9 +403,11 @@ describe('spreadsheetFileToDocumentJson', () => { it('rejects a Blob when neither FileReader nor Response can read it', async () => { const bytes = workbookBytes('xlsx'); - const file = new File([bytes], 'quarterly-revenue.xlsx', { - type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - }); + const file = fileFromWorkbookBytes( + bytes, + 'quarterly-revenue.xlsx', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ); vi.stubGlobal('FileReader', undefined); vi.stubGlobal('Response', undefined); await expectUnsupported(spreadsheetFileToDocumentJson(file)); From aa5b97d2c4fc7166fcc66d375a0335b032087cc2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 02:29:59 +0900 Subject: [PATCH 147/163] test(spreadsheet): fail closed on hostile source brands --- .../sheetJsRuntime.sourceBoundary.test.ts | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 src/spreadsheet/sheetJsRuntime.sourceBoundary.test.ts diff --git a/src/spreadsheet/sheetJsRuntime.sourceBoundary.test.ts b/src/spreadsheet/sheetJsRuntime.sourceBoundary.test.ts new file mode 100644 index 00000000..09d6988c --- /dev/null +++ b/src/spreadsheet/sheetJsRuntime.sourceBoundary.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; +import { + SpreadsheetImportError, + spreadsheetFileToDocumentJson, + type SpreadsheetFileSource, +} from './index.js'; + +function expectUnsupported(promise: Promise) { + return expect(promise).rejects.toMatchObject({ + name: 'SpreadsheetImportError', + code: 'UNSUPPORTED_OR_CORRUPT', + message: 'Spreadsheet source is unsupported or corrupt.', + } satisfies Partial); +} + +describe('spreadsheet local source boundary', () => { + it('normalizes a hostile returned ArrayBuffer identity trap', async () => { + const privatePrototypeError = new Error('private returned buffer prototype'); + const hostileBuffer = new Proxy(new ArrayBuffer(4), { + getPrototypeOf() { + throw privatePrototypeError; + }, + }); + const source = { + size: 4, + async arrayBuffer() { + return hostileBuffer; + }, + } as SpreadsheetFileSource; + + await expectUnsupported(spreadsheetFileToDocumentJson(source)); + }); + + it('normalizes a hostile non-Blob source prototype trap when arrayBuffer is absent', async () => { + const privatePrototypeError = new Error('private source prototype'); + const source = new Proxy( + { size: 4 } as SpreadsheetFileSource, + { + getPrototypeOf() { + throw privatePrototypeError; + }, + }, + ); + + await expectUnsupported(spreadsheetFileToDocumentJson(source)); + }); +}); From 39d869aca620b880b57e75f9c4e0e03e0ac8e866 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 17 Aug 2026 17:32:12 +0000 Subject: [PATCH 148/163] fix(spreadsheet): redact hostile File and ArrayBuffer brand traps instanceof can throw when a source or returned buffer intercepts getPrototypeOf. Normalize those identity traps to the payload-redacted UNSUPPORTED_OR_CORRUPT contract instead of leaking private causes. Co-authored-by: Seongho Bae --- src/spreadsheet/sheetJsRuntime.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/spreadsheet/sheetJsRuntime.ts b/src/spreadsheet/sheetJsRuntime.ts index e86172c7..c1431983 100644 --- a/src/spreadsheet/sheetJsRuntime.ts +++ b/src/spreadsheet/sheetJsRuntime.ts @@ -61,7 +61,11 @@ function unsupportedOrCorruptSource(): SpreadsheetImportError { } function isBlobSource(source: SpreadsheetFileSource): source is SpreadsheetFileSource & Blob { - return typeof Blob !== 'undefined' && source instanceof Blob; + try { + return typeof Blob !== 'undefined' && source instanceof Blob; + } catch { + throw unsupportedOrCorruptSource(); + } } function readBlobViaFileReader(blob: Blob): Promise { @@ -403,7 +407,13 @@ export async function spreadsheetFileToDocumentJson( } const buffer = await readSourceArrayBuffer(source); - if (!(buffer instanceof ArrayBuffer) || buffer.byteLength !== sourceSize) { + let isArrayBuffer: boolean; + try { + isArrayBuffer = buffer instanceof ArrayBuffer; + } catch { + throw unsupportedOrCorruptSource(); + } + if (!isArrayBuffer || buffer.byteLength !== sourceSize) { throw unsupportedOrCorruptSource(); } From eafd7d4c99b0e1d8bdbe57f52c74404ab8fa9835 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 02:33:46 +0900 Subject: [PATCH 149/163] test(spreadsheet): cover hostile ArrayBuffer length access --- .../sheetJsRuntime.sourceBoundary.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/spreadsheet/sheetJsRuntime.sourceBoundary.test.ts b/src/spreadsheet/sheetJsRuntime.sourceBoundary.test.ts index 09d6988c..9fc7373e 100644 --- a/src/spreadsheet/sheetJsRuntime.sourceBoundary.test.ts +++ b/src/spreadsheet/sheetJsRuntime.sourceBoundary.test.ts @@ -31,6 +31,24 @@ describe('spreadsheet local source boundary', () => { await expectUnsupported(spreadsheetFileToDocumentJson(source)); }); + it('normalizes a hostile returned ArrayBuffer byteLength accessor', async () => { + const hostileBuffer = new ArrayBuffer(4); + Object.defineProperty(hostileBuffer, 'byteLength', { + configurable: true, + get() { + throw new Error('private returned buffer length'); + }, + }); + const source = { + size: 4, + async arrayBuffer() { + return hostileBuffer; + }, + } as SpreadsheetFileSource; + + await expectUnsupported(spreadsheetFileToDocumentJson(source)); + }); + it('normalizes a hostile non-Blob source prototype trap when arrayBuffer is absent', async () => { const privatePrototypeError = new Error('private source prototype'); const source = new Proxy( From eab2dbc1f113f2ab044c51a7e06560ce1a3495c5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 17 Aug 2026 17:36:39 +0000 Subject: [PATCH 150/163] fix(spreadsheet): redact hostile ArrayBuffer length accessors A returned file body can expose a throwing byteLength getter after the ArrayBuffer brand check. Normalize that trap to the same payload-redacted UNSUPPORTED_OR_CORRUPT contract. Co-authored-by: Seongho Bae --- src/spreadsheet/sheetJsRuntime.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/spreadsheet/sheetJsRuntime.ts b/src/spreadsheet/sheetJsRuntime.ts index c1431983..ae407cef 100644 --- a/src/spreadsheet/sheetJsRuntime.ts +++ b/src/spreadsheet/sheetJsRuntime.ts @@ -408,12 +408,14 @@ export async function spreadsheetFileToDocumentJson( const buffer = await readSourceArrayBuffer(source); let isArrayBuffer: boolean; + let bufferByteLength: number; try { isArrayBuffer = buffer instanceof ArrayBuffer; + bufferByteLength = buffer.byteLength; } catch { throw unsupportedOrCorruptSource(); } - if (!isArrayBuffer || buffer.byteLength !== sourceSize) { + if (!isArrayBuffer || bufferByteLength !== sourceSize) { throw unsupportedOrCorruptSource(); } From addd3a6c2bc1c8390f826421a01ef3199ea8cd8b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 02:37:50 +0900 Subject: [PATCH 151/163] test(spreadsheet): reject non-buffer body before length access --- .../sheetJsRuntime.sourceBoundary.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/spreadsheet/sheetJsRuntime.sourceBoundary.test.ts b/src/spreadsheet/sheetJsRuntime.sourceBoundary.test.ts index 9fc7373e..4f75d097 100644 --- a/src/spreadsheet/sheetJsRuntime.sourceBoundary.test.ts +++ b/src/spreadsheet/sheetJsRuntime.sourceBoundary.test.ts @@ -49,6 +49,25 @@ describe('spreadsheet local source boundary', () => { await expectUnsupported(spreadsheetFileToDocumentJson(source)); }); + it('rejects a non-ArrayBuffer body before reading its byteLength member', async () => { + let byteLengthReads = 0; + const hostileBody = { + get byteLength() { + byteLengthReads += 1; + throw new Error('private non-buffer length'); + }, + }; + const source = { + size: 4, + async arrayBuffer() { + return hostileBody as unknown as ArrayBuffer; + }, + } as SpreadsheetFileSource; + + await expectUnsupported(spreadsheetFileToDocumentJson(source)); + expect(byteLengthReads).toBe(0); + }); + it('normalizes a hostile non-Blob source prototype trap when arrayBuffer is absent', async () => { const privatePrototypeError = new Error('private source prototype'); const source = new Proxy( From 9d55ec5fdc48af110f2bbcb6e43c107b8d895560 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 17 Aug 2026 17:40:14 +0000 Subject: [PATCH 152/163] fix(spreadsheet): reject non-ArrayBuffer bodies before length access Check the returned file-body brand first. Only a proven ArrayBuffer may expose byteLength, so a hostile non-buffer getter cannot run or leak a private cause. Co-authored-by: Seongho Bae --- src/spreadsheet/sheetJsRuntime.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/spreadsheet/sheetJsRuntime.ts b/src/spreadsheet/sheetJsRuntime.ts index ae407cef..900d59c2 100644 --- a/src/spreadsheet/sheetJsRuntime.ts +++ b/src/spreadsheet/sheetJsRuntime.ts @@ -408,14 +408,22 @@ export async function spreadsheetFileToDocumentJson( const buffer = await readSourceArrayBuffer(source); let isArrayBuffer: boolean; - let bufferByteLength: number; try { isArrayBuffer = buffer instanceof ArrayBuffer; + } catch { + throw unsupportedOrCorruptSource(); + } + if (!isArrayBuffer) { + throw unsupportedOrCorruptSource(); + } + + let bufferByteLength: number; + try { bufferByteLength = buffer.byteLength; } catch { throw unsupportedOrCorruptSource(); } - if (!isArrayBuffer || bufferByteLength !== sourceSize) { + if (bufferByteLength !== sourceSize) { throw unsupportedOrCorruptSource(); } From 2dbbc7f5812cf3173f35dee0863ae91450bc4f98 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 02:46:01 +0900 Subject: [PATCH 153/163] test(release): enforce canonical four-file inventory --- ...eleaseContractCanonicalConsistency.test.ts | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 src/releaseContractCanonicalConsistency.test.ts diff --git a/src/releaseContractCanonicalConsistency.test.ts b/src/releaseContractCanonicalConsistency.test.ts new file mode 100644 index 00000000..03036327 --- /dev/null +++ b/src/releaseContractCanonicalConsistency.test.ts @@ -0,0 +1,25 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +const repositoryFile = (path: string): string => + readFileSync(resolve(process.cwd(), path), 'utf8'); + +describe('canonical release artifact inventory consistency', () => { + it('keeps the public contract aligned with the protected four-file release boundary', () => { + const contracts = repositoryFile('docs/CONTRACTS.md'); + const releaseSecurity = repositoryFile('docs/release-security.md'); + + expect(releaseSecurity).toContain( + 'Each successful GitHub release contains exactly four files', + ); + expect(releaseSecurity).toContain('`inkspan.spdx.json`'); + + expect(contracts).toContain('exactly four regular top-level files'); + expect(contracts).toContain('`inkspan.spdx.json`'); + expect(contracts).toMatch(/release evidence \| exact four-file draft inventory/u); + expect(contracts).not.toContain('exactly three regular top-level files'); + expect(contracts).not.toContain('release evidence | exact three-file draft inventory'); + }); +}); From d8c26ac66ec477951a852e82fa8b690a7097a3b0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 02:49:00 +0900 Subject: [PATCH 154/163] test(release): cover canonical inventory documents --- ...eleaseContractCanonicalConsistency.test.ts | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/src/releaseContractCanonicalConsistency.test.ts b/src/releaseContractCanonicalConsistency.test.ts index 03036327..92fc9445 100644 --- a/src/releaseContractCanonicalConsistency.test.ts +++ b/src/releaseContractCanonicalConsistency.test.ts @@ -7,9 +7,11 @@ const repositoryFile = (path: string): string => readFileSync(resolve(process.cwd(), path), 'utf8'); describe('canonical release artifact inventory consistency', () => { - it('keeps the public contract aligned with the protected four-file release boundary', () => { + it('keeps canonical release documents aligned with the protected four-file boundary', () => { const contracts = repositoryFile('docs/CONTRACTS.md'); + const operability = repositoryFile('docs/OPERABILITY.md'); const releaseSecurity = repositoryFile('docs/release-security.md'); + const testStrategy = repositoryFile('docs/TEST_STRATEGY.md'); expect(releaseSecurity).toContain( 'Each successful GitHub release contains exactly four files', @@ -19,7 +21,20 @@ describe('canonical release artifact inventory consistency', () => { expect(contracts).toContain('exactly four regular top-level files'); expect(contracts).toContain('`inkspan.spdx.json`'); expect(contracts).toMatch(/release evidence \| exact four-file draft inventory/u); - expect(contracts).not.toContain('exactly three regular top-level files'); - expect(contracts).not.toContain('release evidence | exact three-file draft inventory'); + + expect(testStrategy).toContain('exact four-file inventory violations'); + expect(testStrategy).toContain( + 'exactly one npm tarball, exactly one Office wheel, `inkspan.spdx.json`, and `SHA256SUMS`', + ); + + expect(operability).toContain('build exactly four regular top-level release files'); + expect(operability).toContain( + 'exactly one npm tarball, exactly one Inkspan Office wheel, `inkspan.spdx.json`, and `SHA256SUMS`', + ); + + for (const document of [contracts, testStrategy, operability]) { + expect(document).not.toContain('exactly three regular top-level files'); + expect(document).not.toContain('exact three-file draft inventory'); + } }); }); From d6cc52febad0f61764236dc0a9691e3fbf4014cf Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 17 Aug 2026 17:50:17 +0000 Subject: [PATCH 155/163] docs(release): align canonical inventory with the four-file contract CONTRACTS, OPERABILITY, and TEST_STRATEGY still described a three-file draft set. The protected release workflow and release-security record already require the npm tarball, Office wheel, inkspan.spdx.json, and SHA256SUMS. Reconcile the stale three-file wording so product CI can prove the existing four-file inventory. Co-authored-by: Seongho Bae --- docs/CONTRACTS.md | 4 ++-- docs/OPERABILITY.md | 2 +- docs/TEST_STRATEGY.md | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/CONTRACTS.md b/docs/CONTRACTS.md index ef7a00e5..9c3d9e6f 100644 --- a/docs/CONTRACTS.md +++ b/docs/CONTRACTS.md @@ -144,7 +144,7 @@ Expected degraded states are explicit rather than mapped to false success: A public release binds one exact integrated protected source head to package/artifact identity, applicable CI/security/accessibility/document-fidelity evidence, owned production coverage, public-docstring evidence, SBOM/provenance/reproducibility where configured, formal review requirements, rollback guidance, and post-publication smoke verification. -Before immutable publication, the canonical draft inventory is **exactly three regular top-level files**: exactly one npm tarball, exactly one Inkspan Office wheel, and `SHA256SUMS`. Missing, stale, unexpected, duplicate, non-regular, incompletely uploaded, or digest-mismatched assets fail closed. After upload and before publication, the authenticated paginated GitHub Releases API inventory must equal the local release directory by exact asset name, every remote asset must report an uploaded state, and every GitHub-reported `sha256:` digest must equal the digest of the exact transferred local file. The workflow does not silently delete an unexpected remote asset to make an ambiguous draft look clean. +Before immutable publication, the canonical draft inventory is **exactly four regular top-level files**: exactly one npm tarball, exactly one Inkspan Office wheel, `inkspan.spdx.json`, and `SHA256SUMS`. Missing, stale, unexpected, duplicate, non-regular, incompletely uploaded, or digest-mismatched assets fail closed. After upload and before publication, the authenticated paginated GitHub Releases API inventory must equal the local release directory by exact asset name, every remote asset must report an uploaded state, and every GitHub-reported `sha256:` digest must equal the digest of the exact transferred local file. The workflow does not silently delete an unexpected remote asset to make an ambiguous draft look clean. Rollback must preserve readable canonical documents and must not require silently reinterpreting persisted schema or selector-projection semantics. Host-owned migrations, persistence rollback, annotation re-anchoring, tenant recovery, and deployment rollback remain host responsibilities unless a future versioned contract explicitly assigns them to Inkspan. @@ -160,7 +160,7 @@ Rollback must preserve readable canonical documents and must not require silentl | Office rendering | deterministic bounded JSON→artifact conversion | file destination policy, downstream distribution, tenant authorization | | naruon composition | stable local package/module boundary | authenticated compose transport, tenancy, provider/model policy | | model assistance | deterministic proposal acceptance boundary | provider, prompt/data policy, credentials, human approval | -| release evidence | exact three-file draft inventory, package/artifact/digest verification and repository evidence | downstream deployment and operational rollout | +| release evidence | exact four-file draft inventory, package/artifact/digest verification and repository evidence | downstream deployment and operational rollout | ## Related canonical documents diff --git a/docs/OPERABILITY.md b/docs/OPERABILITY.md index e96d053c..23a22c6e 100644 --- a/docs/OPERABILITY.md +++ b/docs/OPERABILITY.md @@ -60,7 +60,7 @@ Release publication occurs only from an exact integrated protected head. The rel Before publication: 1. fetch the current protected `main` ref and require the release tag event commit SHA to equal that exact integration tip, not merely be an ancestor of it; -2. build exactly three regular top-level release files: exactly one npm tarball, exactly one Inkspan Office wheel, and `SHA256SUMS`; +2. build exactly four regular top-level release files: exactly one npm tarball, exactly one Inkspan Office wheel, `inkspan.spdx.json`, and `SHA256SUMS`; 3. reject missing, duplicate, non-regular, stale, or unexpected local entries and verify the local digests; 4. after upload, query the authenticated paginated GitHub Releases API and require the resumed remote draft asset-name set to equal the local release directory exactly; 5. require every remote asset state to be uploaded and every GitHub-reported `sha256:` digest to equal the exact transferred local file digest; diff --git a/docs/TEST_STRATEGY.md b/docs/TEST_STRATEGY.md index 8dc90826..c1cff6e9 100644 --- a/docs/TEST_STRATEGY.md +++ b/docs/TEST_STRATEGY.md @@ -57,7 +57,7 @@ At minimum, maintain regressions for: - selection/revision races and document movement during asynchronous hashing; - Office formula prefixes, invalid XML characters, malicious strings, path/publication races, invalid worksheet names, invalid freeze panes, cyclic input, pathological nesting, excessive container size, and partial write failure; - local XLS/XLSX body import from a known small workbook File through the real toolbar/editor path, including asserted heading/cell text, hidden-sheet exclusion, formula/link non-execution, FileReader fallback when `arrayBuffer` is absent, and payload-redacted failures; -- package/release stale draft assets, unexpected or non-regular local entries, exact three-file inventory violations, incomplete remote uploads, GitHub-vs-local digest mismatch, stale exact-head evidence, mutable provenance inputs, and isolated packed-consumer behavior. +- package/release stale draft assets, unexpected or non-regular local entries, exact four-file inventory violations, incomplete remote uploads, GitHub-vs-local digest mismatch, stale exact-head evidence, mutable provenance inputs, and isolated packed-consumer behavior. ## Concurrency and failure testing @@ -69,7 +69,7 @@ Host persistence transactions, tenant isolation, distributed collaboration autho A release candidate requires the exact integrated protected head to satisfy applicable CI, security, JavaScript/TypeScript 100% statement/branch/function/line coverage, Office coverage.py 100% report plus public-docstring completeness, package-consumer, accessibility, browser differential, Office artifact, SBOM/provenance, reproducibility, unresolved-thread, actually required independent-review, and release-workflow gates. Queued, skipped-required, cancelled, absent, stale-head, predecessor-head, status-only, or synthetic-merge evidence is not accepted as success. -The release workflow must also satisfy the normative `docs/CONTRACTS.md` draft inventory contract: exactly one npm tarball, exactly one Office wheel, and `SHA256SUMS`; no other top-level entry; remote uploaded asset names exactly equal local names; and every GitHub-reported `sha256:` digest equals the exact transferred local file digest. Missing, stale, unexpected, non-regular, incomplete, or digest-mismatched assets are failures, not cleanup opportunities. +The release workflow must also satisfy the normative `docs/CONTRACTS.md` draft inventory contract: exactly one npm tarball, exactly one Office wheel, `inkspan.spdx.json`, and `SHA256SUMS`; no other top-level entry; remote uploaded asset names exactly equal local names; and every GitHub-reported `sha256:` digest equals the exact transferred local file digest. Missing, stale, unexpected, non-regular, incomplete, or digest-mismatched assets are failures, not cleanup opportunities. The 0.6.0 rich-clipboard release line specifically requires the protected dependency-locked **Playwright 1.62.0** Chromium, Firefox, and WebKit differential gate on the exact integrated protected release candidate before publication. Deterministic jsdom coverage remains useful but is not a substitute for browser-engine acceptance. Tagged release evidence must be generated anew from the release candidate and must verify the exact packed npm artifact, not merely reuse a previously green feature-branch run. From 3ca35dd48bcd95a385f6cc89fc1bdec42bd530ef Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 04:07:44 +0900 Subject: [PATCH 156/163] test(docs): detect stale Markdown package maturity --- ...ckageDistributionProtectedMaturity.test.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 src/packageDistributionProtectedMaturity.test.ts diff --git a/src/packageDistributionProtectedMaturity.test.ts b/src/packageDistributionProtectedMaturity.test.ts new file mode 100644 index 00000000..d9ef7786 --- /dev/null +++ b/src/packageDistributionProtectedMaturity.test.ts @@ -0,0 +1,24 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +const repositoryFile = (path: string): string => + readFileSync(resolve(process.cwd(), path), 'utf8'); + +describe('package distribution protected-main maturity', () => { + it('describes the shipped Markdown subpath as protected-main behavior', () => { + const manifest = JSON.parse(repositoryFile('package.json')) as { + exports?: Record; + }; + const distribution = repositoryFile('docs/package-distribution.md'); + + expect(manifest.exports).toHaveProperty('./markdown'); + expect(distribution).toMatch( + /@contextualwisdomlab\/cwl-editor\/markdown` \| `implemented_on_protected_main`/u, + ); + expect(distribution).not.toMatch( + /@contextualwisdomlab\/cwl-editor\/markdown` \| `implemented_on_active_pr`/u, + ); + }); +}); From ca45df72c25cd594f12264cf9385e9a370a704f6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 04:08:36 +0900 Subject: [PATCH 157/163] docs: align Markdown package maturity with protected main --- docs/package-distribution.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/package-distribution.md b/docs/package-distribution.md index 62f3e0dc..eae7e622 100644 --- a/docs/package-distribution.md +++ b/docs/package-distribution.md @@ -17,7 +17,7 @@ integrations. | `@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 | -| `@contextualwisdomlab/cwl-editor/markdown` | `implemented_on_active_pr` — headless deterministic Markdown/HTML/email/plain-text conversion with the same safe-link and strict inline-raster policies as the editor, without importing the React/TipTap editor graph | +| `@contextualwisdomlab/cwl-editor/markdown` | `implemented_on_protected_main` — headless deterministic Markdown/HTML/email/plain-text conversion with the same safe-link and strict inline-raster policies as the editor, without importing the React/TipTap editor graph | | `@contextualwisdomlab/cwl-editor/spreadsheet` | `implemented_on_active_pr` — framework-neutral bounded XLS/XLSX envelope preflight, lazy official SheetJS parse, and parser-neutral worksheet-to-editor JSON conversion used by the toolbar to insert visible worksheet tables; not protected-main authority | | `@contextualwisdomlab/cwl-editor/styles.css` | Editor layout and theming | | `@contextualwisdomlab/cwl-editor/fonts.css` | Full offline KR/EN/JP/SC/TC/VI font bundle | From 63a424a1bc0dbbb0b1142decd5b177253352b6ae Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 05:30:36 +0900 Subject: [PATCH 158/163] fix(docs): restore #156 ownership of markdown maturity --- docs/package-distribution.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/package-distribution.md b/docs/package-distribution.md index eae7e622..62f3e0dc 100644 --- a/docs/package-distribution.md +++ b/docs/package-distribution.md @@ -17,7 +17,7 @@ integrations. | `@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 | -| `@contextualwisdomlab/cwl-editor/markdown` | `implemented_on_protected_main` — headless deterministic Markdown/HTML/email/plain-text conversion with the same safe-link and strict inline-raster policies as the editor, without importing the React/TipTap editor graph | +| `@contextualwisdomlab/cwl-editor/markdown` | `implemented_on_active_pr` — headless deterministic Markdown/HTML/email/plain-text conversion with the same safe-link and strict inline-raster policies as the editor, without importing the React/TipTap editor graph | | `@contextualwisdomlab/cwl-editor/spreadsheet` | `implemented_on_active_pr` — framework-neutral bounded XLS/XLSX envelope preflight, lazy official SheetJS parse, and parser-neutral worksheet-to-editor JSON conversion used by the toolbar to insert visible worksheet tables; not protected-main authority | | `@contextualwisdomlab/cwl-editor/styles.css` | Editor layout and theming | | `@contextualwisdomlab/cwl-editor/fonts.css` | Full offline KR/EN/JP/SC/TC/VI font bundle | From 5a013ace44c7f374f0146bd9aa39bcf47e27619e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 05:30:48 +0900 Subject: [PATCH 159/163] fix(tests): keep markdown maturity contract in #156 --- ...ckageDistributionProtectedMaturity.test.ts | 24 ------------------- 1 file changed, 24 deletions(-) delete mode 100644 src/packageDistributionProtectedMaturity.test.ts diff --git a/src/packageDistributionProtectedMaturity.test.ts b/src/packageDistributionProtectedMaturity.test.ts deleted file mode 100644 index d9ef7786..00000000 --- a/src/packageDistributionProtectedMaturity.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; - -import { describe, expect, it } from 'vitest'; - -const repositoryFile = (path: string): string => - readFileSync(resolve(process.cwd(), path), 'utf8'); - -describe('package distribution protected-main maturity', () => { - it('describes the shipped Markdown subpath as protected-main behavior', () => { - const manifest = JSON.parse(repositoryFile('package.json')) as { - exports?: Record; - }; - const distribution = repositoryFile('docs/package-distribution.md'); - - expect(manifest.exports).toHaveProperty('./markdown'); - expect(distribution).toMatch( - /@contextualwisdomlab\/cwl-editor\/markdown` \| `implemented_on_protected_main`/u, - ); - expect(distribution).not.toMatch( - /@contextualwisdomlab\/cwl-editor\/markdown` \| `implemented_on_active_pr`/u, - ); - }); -}); From 0fd42aa1c8dfcb1158f220a47c509847761307cf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 06:49:02 -0700 Subject: [PATCH 160/163] chore(spreadsheet): relinquish release-contract ownership --- docs/CONTRACTS.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/CONTRACTS.md b/docs/CONTRACTS.md index 9c3d9e6f..ef7a00e5 100644 --- a/docs/CONTRACTS.md +++ b/docs/CONTRACTS.md @@ -144,7 +144,7 @@ Expected degraded states are explicit rather than mapped to false success: A public release binds one exact integrated protected source head to package/artifact identity, applicable CI/security/accessibility/document-fidelity evidence, owned production coverage, public-docstring evidence, SBOM/provenance/reproducibility where configured, formal review requirements, rollback guidance, and post-publication smoke verification. -Before immutable publication, the canonical draft inventory is **exactly four regular top-level files**: exactly one npm tarball, exactly one Inkspan Office wheel, `inkspan.spdx.json`, and `SHA256SUMS`. Missing, stale, unexpected, duplicate, non-regular, incompletely uploaded, or digest-mismatched assets fail closed. After upload and before publication, the authenticated paginated GitHub Releases API inventory must equal the local release directory by exact asset name, every remote asset must report an uploaded state, and every GitHub-reported `sha256:` digest must equal the digest of the exact transferred local file. The workflow does not silently delete an unexpected remote asset to make an ambiguous draft look clean. +Before immutable publication, the canonical draft inventory is **exactly three regular top-level files**: exactly one npm tarball, exactly one Inkspan Office wheel, and `SHA256SUMS`. Missing, stale, unexpected, duplicate, non-regular, incompletely uploaded, or digest-mismatched assets fail closed. After upload and before publication, the authenticated paginated GitHub Releases API inventory must equal the local release directory by exact asset name, every remote asset must report an uploaded state, and every GitHub-reported `sha256:` digest must equal the digest of the exact transferred local file. The workflow does not silently delete an unexpected remote asset to make an ambiguous draft look clean. Rollback must preserve readable canonical documents and must not require silently reinterpreting persisted schema or selector-projection semantics. Host-owned migrations, persistence rollback, annotation re-anchoring, tenant recovery, and deployment rollback remain host responsibilities unless a future versioned contract explicitly assigns them to Inkspan. @@ -160,7 +160,7 @@ Rollback must preserve readable canonical documents and must not require silentl | Office rendering | deterministic bounded JSON→artifact conversion | file destination policy, downstream distribution, tenant authorization | | naruon composition | stable local package/module boundary | authenticated compose transport, tenancy, provider/model policy | | model assistance | deterministic proposal acceptance boundary | provider, prompt/data policy, credentials, human approval | -| release evidence | exact four-file draft inventory, package/artifact/digest verification and repository evidence | downstream deployment and operational rollout | +| release evidence | exact three-file draft inventory, package/artifact/digest verification and repository evidence | downstream deployment and operational rollout | ## Related canonical documents From cd251dc0e05b01efb72dab2fc7f266cca7992a41 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 06:50:00 -0700 Subject: [PATCH 161/163] chore(spreadsheet): drop release-operability ownership --- docs/OPERABILITY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/OPERABILITY.md b/docs/OPERABILITY.md index 23a22c6e..e96d053c 100644 --- a/docs/OPERABILITY.md +++ b/docs/OPERABILITY.md @@ -60,7 +60,7 @@ Release publication occurs only from an exact integrated protected head. The rel Before publication: 1. fetch the current protected `main` ref and require the release tag event commit SHA to equal that exact integration tip, not merely be an ancestor of it; -2. build exactly four regular top-level release files: exactly one npm tarball, exactly one Inkspan Office wheel, `inkspan.spdx.json`, and `SHA256SUMS`; +2. build exactly three regular top-level release files: exactly one npm tarball, exactly one Inkspan Office wheel, and `SHA256SUMS`; 3. reject missing, duplicate, non-regular, stale, or unexpected local entries and verify the local digests; 4. after upload, query the authenticated paginated GitHub Releases API and require the resumed remote draft asset-name set to equal the local release directory exactly; 5. require every remote asset state to be uploaded and every GitHub-reported `sha256:` digest to equal the exact transferred local file digest; From 83e9727e6bde6304ea231e15f65dea29ff188ede Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 06:50:45 -0700 Subject: [PATCH 162/163] chore(spreadsheet): drop release-test-strategy ownership --- docs/TEST_STRATEGY.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/TEST_STRATEGY.md b/docs/TEST_STRATEGY.md index c1cff6e9..8dc90826 100644 --- a/docs/TEST_STRATEGY.md +++ b/docs/TEST_STRATEGY.md @@ -57,7 +57,7 @@ At minimum, maintain regressions for: - selection/revision races and document movement during asynchronous hashing; - Office formula prefixes, invalid XML characters, malicious strings, path/publication races, invalid worksheet names, invalid freeze panes, cyclic input, pathological nesting, excessive container size, and partial write failure; - local XLS/XLSX body import from a known small workbook File through the real toolbar/editor path, including asserted heading/cell text, hidden-sheet exclusion, formula/link non-execution, FileReader fallback when `arrayBuffer` is absent, and payload-redacted failures; -- package/release stale draft assets, unexpected or non-regular local entries, exact four-file inventory violations, incomplete remote uploads, GitHub-vs-local digest mismatch, stale exact-head evidence, mutable provenance inputs, and isolated packed-consumer behavior. +- package/release stale draft assets, unexpected or non-regular local entries, exact three-file inventory violations, incomplete remote uploads, GitHub-vs-local digest mismatch, stale exact-head evidence, mutable provenance inputs, and isolated packed-consumer behavior. ## Concurrency and failure testing @@ -69,7 +69,7 @@ Host persistence transactions, tenant isolation, distributed collaboration autho A release candidate requires the exact integrated protected head to satisfy applicable CI, security, JavaScript/TypeScript 100% statement/branch/function/line coverage, Office coverage.py 100% report plus public-docstring completeness, package-consumer, accessibility, browser differential, Office artifact, SBOM/provenance, reproducibility, unresolved-thread, actually required independent-review, and release-workflow gates. Queued, skipped-required, cancelled, absent, stale-head, predecessor-head, status-only, or synthetic-merge evidence is not accepted as success. -The release workflow must also satisfy the normative `docs/CONTRACTS.md` draft inventory contract: exactly one npm tarball, exactly one Office wheel, `inkspan.spdx.json`, and `SHA256SUMS`; no other top-level entry; remote uploaded asset names exactly equal local names; and every GitHub-reported `sha256:` digest equals the exact transferred local file digest. Missing, stale, unexpected, non-regular, incomplete, or digest-mismatched assets are failures, not cleanup opportunities. +The release workflow must also satisfy the normative `docs/CONTRACTS.md` draft inventory contract: exactly one npm tarball, exactly one Office wheel, and `SHA256SUMS`; no other top-level entry; remote uploaded asset names exactly equal local names; and every GitHub-reported `sha256:` digest equals the exact transferred local file digest. Missing, stale, unexpected, non-regular, incomplete, or digest-mismatched assets are failures, not cleanup opportunities. The 0.6.0 rich-clipboard release line specifically requires the protected dependency-locked **Playwright 1.62.0** Chromium, Firefox, and WebKit differential gate on the exact integrated protected release candidate before publication. Deterministic jsdom coverage remains useful but is not a substitute for browser-engine acceptance. Tagged release evidence must be generated anew from the release candidate and must verify the exact packed npm artifact, not merely reuse a previously green feature-branch run. From fce8fe72ebf4d438c17a7c889dd37c92118c6204 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 06:51:09 -0700 Subject: [PATCH 163/163] chore(spreadsheet): relinquish release consistency contract --- ...eleaseContractCanonicalConsistency.test.ts | 40 ------------------- 1 file changed, 40 deletions(-) delete mode 100644 src/releaseContractCanonicalConsistency.test.ts diff --git a/src/releaseContractCanonicalConsistency.test.ts b/src/releaseContractCanonicalConsistency.test.ts deleted file mode 100644 index 92fc9445..00000000 --- a/src/releaseContractCanonicalConsistency.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; - -import { describe, expect, it } from 'vitest'; - -const repositoryFile = (path: string): string => - readFileSync(resolve(process.cwd(), path), 'utf8'); - -describe('canonical release artifact inventory consistency', () => { - it('keeps canonical release documents aligned with the protected four-file boundary', () => { - const contracts = repositoryFile('docs/CONTRACTS.md'); - const operability = repositoryFile('docs/OPERABILITY.md'); - const releaseSecurity = repositoryFile('docs/release-security.md'); - const testStrategy = repositoryFile('docs/TEST_STRATEGY.md'); - - expect(releaseSecurity).toContain( - 'Each successful GitHub release contains exactly four files', - ); - expect(releaseSecurity).toContain('`inkspan.spdx.json`'); - - expect(contracts).toContain('exactly four regular top-level files'); - expect(contracts).toContain('`inkspan.spdx.json`'); - expect(contracts).toMatch(/release evidence \| exact four-file draft inventory/u); - - expect(testStrategy).toContain('exact four-file inventory violations'); - expect(testStrategy).toContain( - 'exactly one npm tarball, exactly one Office wheel, `inkspan.spdx.json`, and `SHA256SUMS`', - ); - - expect(operability).toContain('build exactly four regular top-level release files'); - expect(operability).toContain( - 'exactly one npm tarball, exactly one Inkspan Office wheel, `inkspan.spdx.json`, and `SHA256SUMS`', - ); - - for (const document of [contracts, testStrategy, operability]) { - expect(document).not.toContain('exactly three regular top-level files'); - expect(document).not.toContain('exact three-file draft inventory'); - } - }); -});