mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 09:19:42 +02:00
Admission counted cells, rows, merges and styles only in worksheets, styles.xml and workbook.xml, so the objects ExcelJS builds per element elsewhere (shared-string runs, fonts, fills, borders, comments, drawings, VML, tables) were bounded only by the inflated-byte caps, and empty stored deflate blocks pad a stream past the ratio cap. createXmlCounter now counts every start tag in every part except the pure-bytes xl/media/<name>.<ext> entries into counts.elements and refuses above LIMITS.maxElements (2,000,000) as element-limit. Parts that read no attributes carry only a trailing '<' between chunks, so long text or binary is never taken for an oversized tag. Very tall sheets now scale only the scroll position: the scroll range maps onto the sheet's whole range and the tile is laid out at real row heights and column widths, with spans clipped at the spacer, instead of dividing every cell and heading by the scale.
1073 lines
50 KiB
TypeScript
1073 lines
50 KiB
TypeScript
/** @fileoverview Worker-owned XLSX parse and viewport protocol. */
|
|
|
|
import { readFileSync } from 'node:fs';
|
|
import { resolve } from 'node:path';
|
|
import { crc32 } from 'node:zlib';
|
|
import vm from 'node:vm';
|
|
import { afterAll, describe, expect, it } from 'vitest';
|
|
import ExcelJS from 'exceljs';
|
|
import * as fflate from 'fflate';
|
|
|
|
// A negative UTC offset is what turned ExcelJS `Date` cells into the previous
|
|
// day. Node re-reads TZ on assignment; the date test asserts it took effect.
|
|
const originalTz = process.env.TZ;
|
|
process.env.TZ = 'America/New_York';
|
|
afterAll(() => {
|
|
if (originalTz === undefined) delete process.env.TZ;
|
|
else process.env.TZ = originalTz;
|
|
});
|
|
|
|
const root = resolve(import.meta.dirname, '..');
|
|
const workerSource = readFileSync(resolve(root, 'src/web/public/spreadsheet-preview-worker.js'), 'utf8');
|
|
const coreSource = readFileSync(resolve(root, 'src/web/public/spreadsheet-xlsx-core.js'), 'utf8');
|
|
|
|
async function fixture(): Promise<ArrayBuffer> {
|
|
const workbook = new ExcelJS.Workbook();
|
|
const first = workbook.addWorksheet('Summary');
|
|
first.getCell('A1').value = 'Revenue';
|
|
first.getCell('B2').value = 1234.5;
|
|
first.getCell('B2').numFmt = '$#,##0.00';
|
|
first.getCell('C3').value = { formula: 'SUM(B2)', result: 1234.5 };
|
|
first.getCell('C3').numFmt = '$#,##0.00';
|
|
first.mergeCells('A4:C4');
|
|
first.getCell('A4').value = 'Merged';
|
|
first.getRow(2).height = 30;
|
|
first.getColumn(2).width = 18;
|
|
const second = workbook.addWorksheet('Details');
|
|
second.getCell('A1').value = 'Detail';
|
|
workbook.addWorksheet('Hidden').state = 'hidden';
|
|
const bytes = await workbook.xlsx.writeBuffer();
|
|
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
|
}
|
|
|
|
async function fixtureWithChartPart(): Promise<ArrayBuffer> {
|
|
const entries = fflate.unzipSync(new Uint8Array(await fixture()));
|
|
entries['xl/charts/chart1.xml'] = fflate.strToU8('<chart/>');
|
|
const bytes = fflate.zipSync(entries);
|
|
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
|
}
|
|
|
|
// Theme fills, indexed colours, and unfilled cells in one workbook.
|
|
// accent1 is forced to pure red so a resolved theme colour cannot be confused
|
|
// with the built-in default Office palette.
|
|
async function themedFixture(): Promise<ArrayBuffer> {
|
|
const workbook = new ExcelJS.Workbook();
|
|
const sheet = workbook.addWorksheet('Themed');
|
|
for (const ref of ['A1', 'A2', 'A3', 'A4']) sheet.getCell(ref).value = ref;
|
|
const entries = fflate.unzipSync(new Uint8Array(await workbook.xlsx.writeBuffer()));
|
|
entries['xl/theme/theme1.xml'] = fflate.strToU8(
|
|
'<?xml version="1.0"?><a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">' +
|
|
'<a:themeElements><a:clrScheme name="Custom">' +
|
|
'<a:dk1><a:sysClr val="windowText" lastClr="000000"/></a:dk1>' +
|
|
'<a:lt1><a:sysClr val="window" lastClr="FFFFFF"/></a:lt1>' +
|
|
'<a:dk2><a:srgbClr val="1F497D"/></a:dk2><a:lt2><a:srgbClr val="EEECE1"/></a:lt2>' +
|
|
'<a:accent1><a:srgbClr val="FF0000"/></a:accent1><a:accent2><a:srgbClr val="C0504D"/></a:accent2>' +
|
|
'<a:accent3><a:srgbClr val="9BBB59"/></a:accent3><a:accent4><a:srgbClr val="8064A2"/></a:accent4>' +
|
|
'<a:accent5><a:srgbClr val="4BACC6"/></a:accent5><a:accent6><a:srgbClr val="F79646"/></a:accent6>' +
|
|
'<a:hlink><a:srgbClr val="0000FF"/></a:hlink><a:folHlink><a:srgbClr val="800080"/></a:folHlink>' +
|
|
'</a:clrScheme></a:themeElements></a:theme>'
|
|
);
|
|
entries['xl/styles.xml'] = fflate.strToU8(
|
|
'<?xml version="1.0"?><styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">' +
|
|
'<fonts count="5"><font><sz val="11"/></font><font><color theme="1"/></font><font><color indexed="9"/></font>' +
|
|
'<font><color rgb="FF000000"/></font><font><color rgb="FF11111B"/></font></fonts>' +
|
|
'<fills count="5"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill>' +
|
|
'<fill><patternFill patternType="solid"><fgColor theme="4"/></patternFill></fill>' +
|
|
'<fill><patternFill patternType="solid"><fgColor indexed="13"/></patternFill></fill>' +
|
|
'<fill><patternFill patternType="solid"><fgColor rgb="FF1A1A2E"/></patternFill></fill></fills>' +
|
|
'<borders count="1"><border/></borders><cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>' +
|
|
'<cellXfs count="5"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>' +
|
|
'<xf numFmtId="0" fontId="1" fillId="2" borderId="0" xfId="0" applyFont="1" applyFill="1"/>' +
|
|
'<xf numFmtId="0" fontId="2" fillId="3" borderId="0" xfId="0" applyFont="1" applyFill="1"/>' +
|
|
'<xf numFmtId="0" fontId="3" fillId="0" borderId="0" xfId="0" applyFont="1"/>' +
|
|
'<xf numFmtId="0" fontId="4" fillId="4" borderId="0" xfId="0" applyFont="1" applyFill="1"/></cellXfs>' +
|
|
'<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles></styleSheet>'
|
|
);
|
|
entries['xl/worksheets/sheet1.xml'] = fflate.strToU8(
|
|
fflate
|
|
.strFromU8(entries['xl/worksheets/sheet1.xml'])
|
|
.replace('<c r="A1"', '<c s="1" r="A1"')
|
|
.replace('<c r="A2"', '<c s="2" r="A2"')
|
|
.replace('<c r="A3"', '<c s="3" r="A3"')
|
|
.replace('<c r="A4"', '<c s="4" r="A4"')
|
|
);
|
|
const bytes = fflate.zipSync(entries);
|
|
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
|
}
|
|
|
|
function createHarness() {
|
|
const messages: unknown[] = [];
|
|
const imports: string[] = [];
|
|
const self: Record<string, unknown> = {
|
|
location: { href: 'http://localhost/spreadsheet-preview-worker.js?v=test' },
|
|
postMessage: (message: unknown) => messages.push(message),
|
|
};
|
|
const context = vm.createContext({
|
|
self,
|
|
globalThis: self,
|
|
URL,
|
|
Uint8Array,
|
|
ArrayBuffer,
|
|
DataView,
|
|
TextDecoder,
|
|
Date,
|
|
Math,
|
|
Number,
|
|
String,
|
|
Object,
|
|
Map,
|
|
Set,
|
|
console,
|
|
importScripts: (...urls: string[]) => {
|
|
imports.push(...urls);
|
|
for (const url of urls) {
|
|
if (url.includes('fflate')) Object.assign(self, { fflate });
|
|
if (url.includes('spreadsheet-xlsx-core')) vm.runInContext(coreSource, context);
|
|
if (url.includes('exceljs')) Object.assign(self, { ExcelJS });
|
|
}
|
|
},
|
|
});
|
|
vm.runInContext(workerSource, context);
|
|
return {
|
|
messages,
|
|
imports,
|
|
self,
|
|
/** Evaluate an expression against the worker's own top-level bindings. */
|
|
peek: (expression: string): unknown => vm.runInContext(expression, context),
|
|
send: async (data: unknown) => {
|
|
await (self.onmessage as (event: { data: unknown }) => Promise<void>)({ data });
|
|
},
|
|
};
|
|
}
|
|
|
|
describe('spreadsheet preview worker', () => {
|
|
it('admits before lazy ExcelJS loading and returns visible sheets in workbook order', async () => {
|
|
const harness = createHarness();
|
|
expect(harness.messages).toEqual([{ type: 'ready' }]);
|
|
expect(harness.imports.some((url) => url.includes('exceljs'))).toBe(false);
|
|
|
|
await harness.send({ type: 'load', bytes: await fixture() });
|
|
|
|
expect(harness.imports.some((url) => url.includes('exceljs'))).toBe(true);
|
|
const metadata = harness.messages.at(-1) as Record<string, any>;
|
|
expect(metadata.type).toBe('metadata');
|
|
expect(metadata.sheets.map((sheet: { name: string }) => sheet.name)).toEqual(['Summary', 'Details']);
|
|
expect(metadata.sheets[0]).toMatchObject({ rows: 4, cols: 3 });
|
|
expect(metadata.sheets[0].rowOverrides).toContainEqual([2, 40]);
|
|
expect(metadata.sheets[0].columnOverrides[0][0]).toBe(2);
|
|
expect(metadata.styles.length).toBeLessThanOrEqual(5000);
|
|
});
|
|
|
|
it('returns bounded intersecting tiles and echoes request and sheet identity', async () => {
|
|
const harness = createHarness();
|
|
await harness.send({ type: 'load', bytes: await fixture() });
|
|
const metadata = harness.messages.at(-1) as Record<string, any>;
|
|
const sheetId = metadata.sheets[0].id;
|
|
|
|
await harness.send({ type: 'tile', requestId: 7, sheetId, range: { r1: 2, c1: 2, r2: 4, c2: 3 } });
|
|
|
|
const tile = harness.messages.at(-1) as Record<string, any>;
|
|
expect(tile).toMatchObject({ type: 'tile', requestId: 7, sheetId });
|
|
expect(tile.cells).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ row: 2, col: 2, text: '$1,234.50' }),
|
|
expect.objectContaining({ row: 3, col: 3, text: '$1,234.50' }),
|
|
])
|
|
);
|
|
expect(tile.merges).toContain('A4:C4');
|
|
expect(tile.cells).toContainEqual(expect.objectContaining({ row: 4, col: 1, text: 'Merged' }));
|
|
expect(tile.cells.length).toBeLessThanOrEqual(9);
|
|
});
|
|
|
|
it('rejects malformed input without loading ExcelJS', async () => {
|
|
const harness = createHarness();
|
|
await harness.send({ type: 'load', bytes: Uint8Array.from([1, 2, 3]).buffer });
|
|
expect(harness.messages.at(-1)).toMatchObject({ type: 'error', code: 'malformed' });
|
|
expect(harness.imports.some((url) => url.includes('exceljs'))).toBe(false);
|
|
});
|
|
|
|
it('resolves theme and indexed colours and emits contrast-safe colour pairs', async () => {
|
|
const harness = createHarness();
|
|
await harness.send({ type: 'load', bytes: await themedFixture() });
|
|
const metadata = harness.messages.at(-1) as Record<string, any>;
|
|
const sheetId = metadata.sheets[0].id;
|
|
await harness.send({ type: 'tile', requestId: 1, sheetId, range: { r1: 1, c1: 1, r2: 4, c2: 1 } });
|
|
const tile = harness.messages.at(-1) as Record<string, any>;
|
|
const pairs = new Map<number, { fill?: string; color?: string }>();
|
|
for (const cell of tile.cells as Array<{ row: number; styleId: number }>) {
|
|
const style = metadata.styles[cell.styleId];
|
|
pairs.set(cell.row, { fill: style.fill, color: style.font?.color });
|
|
}
|
|
// Theme fill (workbook's own accent1 = red) survives instead of being dropped.
|
|
expect(pairs.get(1)).toEqual({ fill: '#ff0000', color: '#000000' });
|
|
// Indexed white-on-yellow is illegible, so the foreground flips to black.
|
|
expect(pairs.get(2)).toEqual({ fill: '#ffff00', color: '#000000' });
|
|
// A black font with no fill renders on the implicit white sheet background.
|
|
expect(pairs.get(3)).toEqual({ fill: '#ffffff', color: '#000000' });
|
|
// Near-black on near-black flips the other way.
|
|
expect(pairs.get(4)).toEqual({ fill: '#1a1a2e', color: '#ffffff' });
|
|
});
|
|
|
|
it('loads its scripts by RELATIVE url so a --base-url mount resolves them under the prefix', async () => {
|
|
const harness = createHarness();
|
|
await harness.send({ type: 'load', bytes: await fixture() });
|
|
expect(harness.imports.length).toBeGreaterThan(0);
|
|
for (const url of harness.imports) {
|
|
expect(url.startsWith('/'), url).toBe(false);
|
|
expect(url).toContain('?v=test');
|
|
}
|
|
});
|
|
|
|
it('never evaluates formulas: an uncached formula is shown as its source text', async () => {
|
|
const workbook = new ExcelJS.Workbook();
|
|
const sheet = workbook.addWorksheet('Formulas');
|
|
sheet.getCell('A1').value = { formula: 'WEBSERVICE("http://example.invalid/")' } as ExcelJS.CellFormulaValue;
|
|
const written = await workbook.xlsx.writeBuffer();
|
|
const harness = createHarness();
|
|
await harness.send({
|
|
type: 'load',
|
|
bytes: written.buffer.slice(written.byteOffset, written.byteOffset + written.byteLength),
|
|
});
|
|
const metadata = harness.messages.at(-1) as Record<string, any>;
|
|
await harness.send({
|
|
type: 'tile',
|
|
requestId: 1,
|
|
sheetId: metadata.sheets[0].id,
|
|
range: { r1: 1, c1: 1, r2: 1, c2: 1 },
|
|
});
|
|
const tile = harness.messages.at(-1) as Record<string, any>;
|
|
expect(tile.cells).toEqual([
|
|
expect.objectContaining({ row: 1, col: 1, text: '=WEBSERVICE("http://example.invalid/")' }),
|
|
]);
|
|
expect(tile.warnings).toContain('Formula has no cached result');
|
|
});
|
|
|
|
it('carries detected unsupported features into workbook metadata', async () => {
|
|
const harness = createHarness();
|
|
await harness.send({ type: 'load', bytes: await fixtureWithChartPart() });
|
|
expect(harness.messages.at(-1)).toMatchObject({ type: 'metadata', warnings: ['charts'] });
|
|
});
|
|
});
|
|
|
|
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
|
|
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
|
|
}
|
|
|
|
async function writeWorkbook(workbook: ExcelJS.Workbook): Promise<ArrayBuffer> {
|
|
return toArrayBuffer(new Uint8Array(await workbook.xlsx.writeBuffer()));
|
|
}
|
|
|
|
type Harness = ReturnType<typeof createHarness>;
|
|
type Range = { r1: number; c1: number; r2: number; c2: number };
|
|
|
|
async function loadMetadata(harness: Harness, bytes: ArrayBuffer): Promise<Record<string, any>> {
|
|
await harness.send({ type: 'load', bytes });
|
|
const metadata = harness.messages.at(-1) as Record<string, any>;
|
|
expect(metadata.type, JSON.stringify(metadata)).toBe('metadata');
|
|
return metadata;
|
|
}
|
|
|
|
async function requestTile(harness: Harness, sheetId: string, range: Range): Promise<Record<string, any>> {
|
|
await harness.send({ type: 'tile', requestId: 1, sheetId, range });
|
|
return harness.messages.at(-1) as Record<string, any>;
|
|
}
|
|
|
|
/** The range spreadsheet-preview.js asks for at scroll 0 with its fallback 800x500 viewport. */
|
|
function defaultViewportRange(harness: Harness, sheet: Record<string, any>): Range {
|
|
const core = harness.self.CodemanSpreadsheetXlsxCore as {
|
|
createSparseAxis(count: number, size: number, overrides: Array<[number, number]>): unknown;
|
|
axisIndexAt(axis: unknown, offset: number): number;
|
|
};
|
|
const rows = core.createSparseAxis(sheet.rows, sheet.defaultRowHeight, sheet.rowOverrides);
|
|
const cols = core.createSparseAxis(sheet.cols, sheet.defaultColumnWidth, sheet.columnOverrides);
|
|
return {
|
|
r1: 1,
|
|
c1: 1,
|
|
r2: Math.min(sheet.rows, core.axisIndexAt(rows, 500) + 2),
|
|
c2: Math.min(sheet.cols, core.axisIndexAt(cols, 800) + 2),
|
|
};
|
|
}
|
|
|
|
describe('spreadsheet preview worker: ExcelJS value shapes', () => {
|
|
it('formats Date, rich text, hyperlink, error and formula-result cells as ExcelJS loads them', async () => {
|
|
// Proves the negative-offset TZ is really in force for this file.
|
|
expect(new Date(Date.UTC(2024, 0, 15)).getDate()).toBe(14);
|
|
|
|
const workbook = new ExcelJS.Workbook();
|
|
const sheet = workbook.addWorksheet('Values');
|
|
const day = new Date(Date.UTC(2024, 0, 15));
|
|
sheet.getCell('A1').value = day;
|
|
sheet.getCell('A1').numFmt = 'yyyy-mm-dd';
|
|
sheet.getCell('A2').value = new Date(Date.UTC(2024, 0, 15, 13, 45));
|
|
sheet.getCell('A2').numFmt = 'yyyy-mm-dd hh:mm';
|
|
sheet.getCell('A3').value = day; // ExcelJS's default date format (mm-dd-yy)
|
|
sheet.getCell('A4').value = { richText: [{ text: 'Hello ' }, { font: { bold: true }, text: 'World' }] };
|
|
sheet.getCell('A5').value = { text: 'Codeman', hyperlink: 'https://example.invalid/' };
|
|
sheet.getCell('A6').value = { error: '#DIV/0!' } as ExcelJS.CellErrorValue;
|
|
sheet.getCell('A7').value = { formula: '1/0', result: { error: '#DIV/0!' } } as ExcelJS.CellFormulaValue;
|
|
sheet.getCell('A8').value = { formula: 'ROW()', result: 8, shareType: 'shared', ref: 'A8:A9' } as never;
|
|
sheet.getCell('A9').value = { sharedFormula: 'A8', result: 9 } as ExcelJS.CellSharedFormulaValue;
|
|
sheet.getCell('A10').value = { formula: 'DATE(2024,1,15)', result: day } as ExcelJS.CellFormulaValue;
|
|
sheet.getCell('A10').numFmt = 'yyyy-mm-dd';
|
|
sheet.getCell('A11').value = new Date(Date.UTC(1899, 11, 30, 6, 30, 15));
|
|
sheet.getCell('A11').numFmt = 'hh:mm:ss';
|
|
// Float error lands this one just under 00:05; truncating it showed 00:04.
|
|
sheet.getCell('A12').value = new Date(Date.UTC(2020, 0, 1, 0, 5));
|
|
sheet.getCell('A12').numFmt = 'yyyy-mm-dd hh:mm';
|
|
|
|
const harness = createHarness();
|
|
const metadata = await loadMetadata(harness, await writeWorkbook(workbook));
|
|
const tile = await requestTile(harness, metadata.sheets[0].id, { r1: 1, c1: 1, r2: 12, c2: 1 });
|
|
expect(tile.type).toBe('tile');
|
|
const text = new Map((tile.cells as Array<{ row: number; text: string }>).map((cell) => [cell.row, cell.text]));
|
|
expect(Object.fromEntries(text)).toEqual({
|
|
1: '2024-01-15',
|
|
2: '2024-01-15 13:45',
|
|
3: '2024-01-15',
|
|
4: 'Hello World',
|
|
5: 'Codeman',
|
|
6: '#DIV/0!',
|
|
7: '#DIV/0!',
|
|
8: '8',
|
|
9: '9',
|
|
10: '2024-01-15',
|
|
11: '06:30:15',
|
|
12: '2020-01-01 00:05',
|
|
});
|
|
for (const value of text.values()) expect(value).not.toMatch(/object Object|GMT/);
|
|
});
|
|
});
|
|
|
|
describe('spreadsheet preview worker: hidden rows and dense tiles', () => {
|
|
it('skips filtered-out rows and hidden columns at the renderer default viewport', async () => {
|
|
const workbook = new ExcelJS.Workbook();
|
|
const sheet = workbook.addWorksheet('Filtered');
|
|
for (let row = 1; row <= 1000; row += 1) {
|
|
for (let col = 1; col <= 6; col += 1) sheet.getCell(row, col).value = row * 10 + col;
|
|
}
|
|
sheet.autoFilter = 'A1:F1000';
|
|
for (let row = 2; row <= 981; row += 1) sheet.getRow(row).hidden = true; // 980 rows filtered out
|
|
sheet.getColumn(3).hidden = true;
|
|
|
|
const harness = createHarness();
|
|
const metadata = await loadMetadata(harness, await writeWorkbook(workbook));
|
|
const range = defaultViewportRange(harness, metadata.sheets[0]);
|
|
expect(range).toEqual({ r1: 1, c1: 1, r2: 1000, c2: 6 });
|
|
const tile = await requestTile(harness, metadata.sheets[0].id, range);
|
|
|
|
expect(tile.type, JSON.stringify(tile)).toBe('tile');
|
|
const cells = tile.cells as Array<{ row: number; col: number }>;
|
|
expect(cells).toHaveLength(20 * 5);
|
|
expect(cells.some((cell) => cell.row >= 2 && cell.row <= 981)).toBe(false);
|
|
expect(cells.some((cell) => cell.col === 3)).toBe(false);
|
|
expect(tile.warnings).toEqual([]);
|
|
});
|
|
|
|
it('returns a truncated tile with a warning instead of failing on a dense 60x60 block', async () => {
|
|
const workbook = new ExcelJS.Workbook();
|
|
const sheet = workbook.addWorksheet('Dense');
|
|
for (let row = 1; row <= 60; row += 1) {
|
|
for (let col = 1; col <= 60; col += 1) sheet.getCell(row, col).value = row * 100 + col;
|
|
}
|
|
const harness = createHarness();
|
|
const metadata = await loadMetadata(harness, await writeWorkbook(workbook));
|
|
const tile = await requestTile(harness, metadata.sheets[0].id, { r1: 1, c1: 1, r2: 60, c2: 60 });
|
|
|
|
expect(tile.type, JSON.stringify(tile)).toBe('tile');
|
|
expect(tile.cells).toHaveLength(2500);
|
|
expect(tile.cells[0]).toMatchObject({ row: 1, col: 1, text: '101' });
|
|
expect(tile.warnings).toEqual([expect.stringMatching(/truncated/i)]);
|
|
});
|
|
});
|
|
|
|
type ZipPart = { name: string; data: Uint8Array; method: 0 | 8; size: number; crc: number };
|
|
|
|
function zipPart(name: string, content: Uint8Array, method: 0 | 8): ZipPart {
|
|
return {
|
|
name,
|
|
data: method === 8 ? fflate.deflateSync(content) : content,
|
|
method,
|
|
size: content.length,
|
|
crc: crc32(content) >>> 0,
|
|
};
|
|
}
|
|
|
|
function localHeader(part: ZipPart): Uint8Array {
|
|
const name = fflate.strToU8(part.name);
|
|
const header = new Uint8Array(30 + name.length);
|
|
const view = new DataView(header.buffer);
|
|
view.setUint32(0, 0x04034b50, true);
|
|
view.setUint16(4, 20, true);
|
|
view.setUint16(8, part.method, true);
|
|
view.setUint32(14, part.crc, true);
|
|
view.setUint32(18, part.data.length, true);
|
|
view.setUint32(22, part.size, true);
|
|
view.setUint16(26, name.length, true);
|
|
header.set(name, 30);
|
|
return header;
|
|
}
|
|
|
|
function centralHeader(part: ZipPart, offset: number): Uint8Array {
|
|
const name = fflate.strToU8(part.name);
|
|
const header = new Uint8Array(46 + name.length);
|
|
const view = new DataView(header.buffer);
|
|
view.setUint32(0, 0x02014b50, true);
|
|
view.setUint16(4, 20, true);
|
|
view.setUint16(6, 20, true);
|
|
view.setUint16(10, part.method, true);
|
|
view.setUint32(16, part.crc, true);
|
|
view.setUint32(20, part.data.length, true);
|
|
view.setUint32(24, part.size, true);
|
|
view.setUint16(28, name.length, true);
|
|
view.setUint32(42, offset, true);
|
|
header.set(name, 46);
|
|
return header;
|
|
}
|
|
|
|
function concatBytes(chunks: Uint8Array[]): Uint8Array {
|
|
const out = new Uint8Array(chunks.reduce((total, chunk) => total + chunk.length, 0));
|
|
let offset = 0;
|
|
for (const chunk of chunks) {
|
|
out.set(chunk, offset);
|
|
offset += chunk.length;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* The reviewer's bypass: a STORED carrier entry whose data is a complete local
|
|
* entry for a huge `sheet1.xml`, followed later by a one-cell decoy `sheet1.xml`.
|
|
* The central directory points `sheet1.xml` INSIDE the carrier, so a local-header
|
|
* walk (admission) meets the decoy while JSZip (ExcelJS) reads the hidden sheet.
|
|
*/
|
|
async function overlappingEntryWorkbook(hiddenRows = 11_000, hiddenCols = 10): Promise<Uint8Array> {
|
|
const workbook = new ExcelJS.Workbook();
|
|
workbook.addWorksheet('Data').getCell('A1').value = 'decoy';
|
|
const entries = fflate.unzipSync(new Uint8Array(await workbook.xlsx.writeBuffer()));
|
|
const decoyXml = fflate.strFromU8(entries['xl/worksheets/sheet1.xml']);
|
|
const letters = Array.from({ length: hiddenCols }, (_, index) => String.fromCharCode(65 + index));
|
|
let rows = '';
|
|
for (let row = 1; row <= hiddenRows; row += 1) {
|
|
rows += `<row r="${row}">${letters.map((letter) => `<c r="${letter}${row}"><v>${row}</v></c>`).join('')}</row>`;
|
|
}
|
|
const hiddenXml = decoyXml.replace(/<sheetData>[\s\S]*<\/sheetData>/, `<sheetData>${rows}</sheetData>`);
|
|
expect(hiddenXml).not.toBe(decoyXml);
|
|
|
|
const hidden = zipPart('xl/worksheets/sheet1.xml', fflate.strToU8(hiddenXml), 8);
|
|
const carrier = zipPart('docProps/carrier.bin', concatBytes([localHeader(hidden), hidden.data]), 0);
|
|
const decoy = zipPart('xl/worksheets/sheet1.xml', entries['xl/worksheets/sheet1.xml'], 8);
|
|
const others = Object.keys(entries)
|
|
.filter((name) => name !== 'xl/worksheets/sheet1.xml')
|
|
.map((name) => zipPart(name, entries[name], 8));
|
|
|
|
const locals: Uint8Array[] = [];
|
|
const central: Uint8Array[] = [];
|
|
let offset = 0;
|
|
const emit = (part: ZipPart) => {
|
|
const at = offset;
|
|
const chunk = concatBytes([localHeader(part), part.data]);
|
|
locals.push(chunk);
|
|
offset += chunk.length;
|
|
return at;
|
|
};
|
|
for (const part of others) central.push(centralHeader(part, emit(part)));
|
|
const carrierOffset = emit(carrier);
|
|
central.push(centralHeader(carrier, carrierOffset));
|
|
emit(decoy); // streamed by admission, absent from the central directory
|
|
const hiddenOffset = carrierOffset + 30 + fflate.strToU8(carrier.name).length;
|
|
central.push(centralHeader(hidden, hiddenOffset));
|
|
|
|
const directory = concatBytes(central);
|
|
const eocd = new Uint8Array(22);
|
|
const view = new DataView(eocd.buffer);
|
|
view.setUint32(0, 0x06054b50, true);
|
|
view.setUint16(8, central.length, true);
|
|
view.setUint16(10, central.length, true);
|
|
view.setUint32(12, directory.length, true);
|
|
view.setUint32(16, offset, true);
|
|
return concatBytes([...locals, directory, eocd]);
|
|
}
|
|
|
|
describe('spreadsheet preview worker: ExcelJS sees only what admission checked', () => {
|
|
it('parses the admitted decoy, never a sheet hidden inside an overlapping stored entry', async () => {
|
|
const crafted = await overlappingEntryWorkbook();
|
|
// The unpatched pipeline: JSZip, reading the central directory, finds the hidden sheet.
|
|
const direct = new ExcelJS.Workbook();
|
|
await direct.xlsx.load(toArrayBuffer(crafted));
|
|
expect(direct.worksheets[0].rowCount).toBe(11_000);
|
|
|
|
const harness = createHarness();
|
|
const core = harness.self.CodemanSpreadsheetXlsxCore as {
|
|
admitXlsx(bytes: Uint8Array, zip: typeof fflate): { counts: { cells: number } };
|
|
};
|
|
// Admission walks local headers, so it only ever counts the one-cell decoy.
|
|
expect(core.admitXlsx(crafted, fflate).counts.cells).toBe(1);
|
|
|
|
await harness.send({ type: 'load', bytes: toArrayBuffer(crafted) });
|
|
const result = harness.messages.at(-1) as Record<string, any>;
|
|
// Either outcome is safe; parsing the 110k hidden cells is not.
|
|
if (result.type === 'metadata') expect(result.sheets[0]).toMatchObject({ rows: 1, cols: 1 });
|
|
else expect(result.type).toBe('error');
|
|
}, 60_000);
|
|
});
|
|
|
|
/** A one-cell ExcelJS workbook whose sheet1.xml gets `xml` spliced in at `where`. */
|
|
async function sheetWithInjectedXml(where: 'before-sheetData' | 'after-sheetData', xml: string): Promise<ArrayBuffer> {
|
|
const workbook = new ExcelJS.Workbook();
|
|
workbook.addWorksheet('Data').getCell('A1').value = 'one';
|
|
const entries = fflate.unzipSync(new Uint8Array(await workbook.xlsx.writeBuffer()));
|
|
const sheet = fflate.strFromU8(entries['xl/worksheets/sheet1.xml']);
|
|
const patched =
|
|
where === 'before-sheetData'
|
|
? sheet.replace('<sheetData>', `${xml}<sheetData>`)
|
|
: sheet.replace('</sheetData>', `</sheetData>${xml}`);
|
|
expect(patched).not.toBe(sheet);
|
|
entries['xl/worksheets/sheet1.xml'] = fflate.strToU8(patched);
|
|
return toArrayBuffer(fflate.zipSync(entries));
|
|
}
|
|
|
|
describe('spreadsheet preview worker: admission bounds what ExcelJS expands', () => {
|
|
// ExcelJS creates one cell object per covered cell of a merge, so a single
|
|
// `<mergeCell>` tag over 3M cells took 10 s and a gigabyte of heap.
|
|
it('refuses a merge whose area exceeds the cell caps before ExcelJS loads', async () => {
|
|
const harness = createHarness();
|
|
await harness.send({
|
|
type: 'load',
|
|
bytes: await sheetWithInjectedXml(
|
|
'after-sheetData',
|
|
'<mergeCells count="1"><mergeCell ref="A1:CV30000"/></mergeCells>'
|
|
),
|
|
});
|
|
expect(harness.messages.at(-1)).toMatchObject({ type: 'error', code: 'cell-limit' });
|
|
expect(harness.imports.some((url) => url.includes('exceljs'))).toBe(false);
|
|
});
|
|
|
|
// `Column.fromModel` builds every column up to `<col max>` with no clamp.
|
|
it('refuses a <col> range past column 16384 before ExcelJS loads', async () => {
|
|
const harness = createHarness();
|
|
await harness.send({
|
|
type: 'load',
|
|
bytes: await sheetWithInjectedXml('before-sheetData', '<cols><col min="1" max="3000000" width="9"/></cols>'),
|
|
});
|
|
expect(harness.messages.at(-1)).toMatchObject({ type: 'error', code: 'malformed' });
|
|
expect(harness.imports.some((url) => url.includes('exceljs'))).toBe(false);
|
|
});
|
|
|
|
// A whole-column dropdown is a few bytes of XML that ExcelJS expands into one
|
|
// object per address (5 s here; a whole-sheet range was still running after
|
|
// 60 s). The preview never shows validations, so the worker does not parse
|
|
// them, and the file still previews.
|
|
it('previews a sheet with a whole-column data validation without expanding it', async () => {
|
|
const harness = createHarness();
|
|
const metadata = await loadMetadata(
|
|
harness,
|
|
await sheetWithInjectedXml(
|
|
'after-sheetData',
|
|
'<dataValidations count="1"><dataValidation type="list" allowBlank="1" sqref="B2:B1048576">' +
|
|
'<formula1>"a,b"</formula1></dataValidation></dataValidations>'
|
|
)
|
|
);
|
|
expect(harness.peek('Object.keys(workbook.worksheets[0].dataValidations.model).length')).toBe(0);
|
|
expect(metadata.sheets[0]).toMatchObject({ rows: 1, cols: 1 });
|
|
const tile = await requestTile(harness, metadata.sheets[0].id, { r1: 1, c1: 1, r2: 1, c2: 1 });
|
|
expect(tile.cells).toEqual([expect.objectContaining({ row: 1, col: 1, text: 'one' })]);
|
|
}, 30_000);
|
|
});
|
|
|
|
describe('spreadsheet preview worker: quoted attribute values and empty rows', () => {
|
|
// XML allows a raw `>` and the other quote character inside an attribute
|
|
// value. Each of these was admitted before and made ExcelJS build millions of
|
|
// cells or columns.
|
|
it.each([
|
|
[
|
|
'a quoted fake ref before the real merge ref',
|
|
'after-sheetData',
|
|
`<mergeCells count="1"><mergeCell x=' ref="A1"' ref="A1:CV30000"/></mergeCells>`,
|
|
'cell-limit',
|
|
],
|
|
[
|
|
'a quoted > before the real col max',
|
|
'before-sheetData',
|
|
'<cols><col x=">" min="1" max="3000000"/></cols>',
|
|
'malformed',
|
|
],
|
|
[
|
|
'a quoted fake max before the real col max',
|
|
'before-sheetData',
|
|
`<cols><col x=' max="1"' min="1" max="3000000"/></cols>`,
|
|
'malformed',
|
|
],
|
|
] as const)('refuses %s before ExcelJS loads', async (_label, where, xml, code) => {
|
|
const harness = createHarness();
|
|
await harness.send({ type: 'load', bytes: await sheetWithInjectedXml(where, xml) });
|
|
expect(harness.messages.at(-1)).toMatchObject({ type: 'error', code });
|
|
expect(harness.imports.some((url) => url.includes('exceljs'))).toBe(false);
|
|
});
|
|
|
|
// ExcelJS keeps a Row object for every <row>, so cell-less rows cost memory
|
|
// too: three sheets of a million empty rows sat inside every cell cap.
|
|
it('refuses a sheet of empty rows past the row cap before ExcelJS loads', async () => {
|
|
const harness = createHarness();
|
|
const rows = Array.from({ length: 100_001 }, (_, i) => `<row r="${i + 2}"/>`).join('');
|
|
await harness.send({ type: 'load', bytes: await emptyRowsWorkbook(rows) });
|
|
expect(harness.messages.at(-1)).toMatchObject({ type: 'error', code: 'row-limit' });
|
|
expect(harness.imports.some((url) => url.includes('exceljs'))).toBe(false);
|
|
}, 30_000);
|
|
});
|
|
|
|
async function emptyRowsWorkbook(extraRows: string): Promise<ArrayBuffer> {
|
|
const workbook = new ExcelJS.Workbook();
|
|
workbook.addWorksheet('Data').getCell('A1').value = 'one';
|
|
const entries = fflate.unzipSync(new Uint8Array(await workbook.xlsx.writeBuffer()));
|
|
const sheet = fflate.strFromU8(entries['xl/worksheets/sheet1.xml']);
|
|
const patched = sheet.replace('</sheetData>', `${extraRows}</sheetData>`);
|
|
expect(patched).not.toBe(sheet);
|
|
entries['xl/worksheets/sheet1.xml'] = fflate.strToU8(patched);
|
|
return toArrayBuffer(fflate.zipSync(entries));
|
|
}
|
|
|
|
/**
|
|
* A one-cell workbook whose sheet carries a merge one cell over the per-sheet
|
|
* cap, stored under `entryName` instead of `xl/worksheets/sheet1.xml`. JSZip
|
|
* (inside ExcelJS) resolves `.`, `..` and empty segments, and ExcelJS strips one
|
|
* leading `/` and matches worksheets with an unanchored pattern, so each of
|
|
* these names still reaches ExcelJS as a worksheet.
|
|
*/
|
|
async function renamedOversizedSheet(entryName: string, relTarget?: string): Promise<ArrayBuffer> {
|
|
const workbook = new ExcelJS.Workbook();
|
|
workbook.addWorksheet('Data').getCell('A1').value = 'one';
|
|
const entries = fflate.unzipSync(new Uint8Array(await workbook.xlsx.writeBuffer()));
|
|
const sheet = fflate.strFromU8(entries['xl/worksheets/sheet1.xml']);
|
|
const patched = sheet.replace(
|
|
'</sheetData>',
|
|
'</sheetData><mergeCells count="1"><mergeCell ref="A1:A100001"/></mergeCells>'
|
|
);
|
|
expect(patched).not.toBe(sheet);
|
|
delete entries['xl/worksheets/sheet1.xml'];
|
|
entries[entryName] = fflate.strToU8(patched);
|
|
if (relTarget) {
|
|
const rels = fflate.strFromU8(entries['xl/_rels/workbook.xml.rels']);
|
|
const retargeted = rels.replace('Target="worksheets/sheet1.xml"', `Target="${relTarget}"`);
|
|
expect(retargeted).not.toBe(rels);
|
|
entries['xl/_rels/workbook.xml.rels'] = fflate.strToU8(retargeted);
|
|
}
|
|
return toArrayBuffer(fflate.zipSync(entries));
|
|
}
|
|
|
|
describe('spreadsheet preview worker: entry names as ExcelJS sees them', () => {
|
|
it.each([
|
|
['/xl/worksheets/sheet1.xml', undefined],
|
|
['xl/./worksheets/sheet1.xml', undefined],
|
|
['xl//worksheets/sheet1.xml', undefined],
|
|
['xl/xl/worksheets/sheet1.xml', 'xl/worksheets/sheet1.xml'],
|
|
['xl/worksheets/sheet1.xml.x', 'worksheets/sheet1.xml.x'],
|
|
] as const)(
|
|
'counts a sheet stored as %s and refuses it before ExcelJS loads',
|
|
async (entryName, relTarget) => {
|
|
const bytes = await renamedOversizedSheet(entryName, relTarget);
|
|
// The fixture is real: unguarded ExcelJS parses this entry as the sheet.
|
|
const direct = new ExcelJS.Workbook();
|
|
await direct.xlsx.load(bytes.slice(0));
|
|
expect(direct.worksheets[0]?.model.merges).toContain('A1:A100001');
|
|
|
|
const harness = createHarness();
|
|
await harness.send({ type: 'load', bytes });
|
|
expect(harness.messages.at(-1)).toMatchObject({ type: 'error', code: 'cell-limit' });
|
|
expect(harness.imports.some((url) => url.includes('exceljs'))).toBe(false);
|
|
},
|
|
30_000
|
|
);
|
|
});
|
|
|
|
describe('spreadsheet preview worker: defined names', () => {
|
|
// ExcelJS's DefinedNames model setter creates one object per cell of every
|
|
// range; a whole-sheet name exhausted a 4 GB heap. The preview never shows
|
|
// defined names, so they are not expanded at all.
|
|
it('loads a workbook with a defined name without expanding its range', async () => {
|
|
const workbook = new ExcelJS.Workbook();
|
|
workbook.addWorksheet('Data').getCell('A1').value = 'one';
|
|
workbook.definedNames.add('Data!$A$1:$J$10', 'Block');
|
|
const bytes = await writeWorkbook(workbook);
|
|
expect(fflate.strFromU8(fflate.unzipSync(new Uint8Array(bytes))['xl/workbook.xml'])).toContain('Block');
|
|
|
|
const harness = createHarness();
|
|
const metadata = await loadMetadata(harness, bytes);
|
|
expect(metadata.sheets[0]).toMatchObject({ name: 'Data', rows: 1, cols: 1 });
|
|
expect(harness.peek('Object.keys(workbook.definedNames.matrixMap).length')).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('spreadsheet preview worker: indices a row or sheet claims', () => {
|
|
// ExcelJS stores a row at `_rows[r - 1]`, and eachRow and `sheet.model` walk
|
|
// every index up to the largest, so one far row made each tile cost seconds.
|
|
it('refuses a <row r> past the last Excel row before ExcelJS loads', async () => {
|
|
const rows =
|
|
Array.from({ length: 4 }, (_, i) => `<row r="${i + 2}"><c r="A${i + 2}"><v>${i + 2}</v></c></row>`).join('') +
|
|
'<row r="50000000"><c r="A50000000"><v>6</v></c></row>';
|
|
const bytes = await emptyRowsWorkbook(rows);
|
|
const harness = createHarness();
|
|
await harness.send({ type: 'load', bytes });
|
|
expect(harness.messages.at(-1)).toMatchObject({ type: 'error', code: 'malformed' });
|
|
expect(harness.imports.some((url) => url.includes('exceljs'))).toBe(false);
|
|
}, 60_000);
|
|
|
|
// ExcelJS stores a sheet at `_worksheets[sheetId]`; 30,000,000 took 1.6 s
|
|
// and 557 MB on a one-cell workbook.
|
|
it('refuses a sheetId above the cap in xl/workbook.xml before ExcelJS loads', async () => {
|
|
const workbook = new ExcelJS.Workbook();
|
|
workbook.addWorksheet('Data').getCell('A1').value = 'one';
|
|
const entries = fflate.unzipSync(new Uint8Array(await workbook.xlsx.writeBuffer()));
|
|
const book = fflate.strFromU8(entries['xl/workbook.xml']);
|
|
const patched = book.replace('sheetId="1"', 'sheetId="30000000"');
|
|
expect(patched).not.toBe(book);
|
|
entries['xl/workbook.xml'] = fflate.strToU8(patched);
|
|
const harness = createHarness();
|
|
await harness.send({ type: 'load', bytes: toArrayBuffer(fflate.zipSync(entries)) });
|
|
expect(harness.messages.at(-1)).toMatchObject({ type: 'error', code: 'malformed' });
|
|
expect(harness.imports.some((url) => url.includes('exceljs'))).toBe(false);
|
|
}, 60_000);
|
|
});
|
|
|
|
describe('spreadsheet preview worker: tiles reuse the merges read at load', () => {
|
|
// `sheet.model` rebuilds every row and cell model; a tile must not pay that.
|
|
it('serves a tile with its merge without touching sheet.model', async () => {
|
|
const harness = createHarness();
|
|
const metadata = await loadMetadata(harness, await fixture());
|
|
const sheetId = metadata.sheets[0].id;
|
|
harness.peek(
|
|
`Object.defineProperty(sheetsById.get(${JSON.stringify(sheetId)}), 'model', {
|
|
configurable: true,
|
|
get() { throw new Error('sheet.model touched'); },
|
|
})`
|
|
);
|
|
const tile = await requestTile(harness, sheetId, { r1: 4, c1: 2, r2: 4, c2: 3 });
|
|
expect(tile.type, JSON.stringify(tile)).toBe('tile');
|
|
expect(tile.merges).toEqual(['A4:C4']);
|
|
expect(tile.cells).toContainEqual(expect.objectContaining({ row: 4, col: 1, text: 'Merged' }));
|
|
});
|
|
});
|
|
|
|
/** Row and Worksheet prototypes of the ExcelJS build the harness hands the worker. */
|
|
function excelJsPrototypes(): { row: Record<string, unknown>; sheet: Record<string, unknown> } {
|
|
const probe = new ExcelJS.Workbook().addWorksheet('probe');
|
|
return { row: Object.getPrototypeOf(probe.getRow(1)), sheet: Object.getPrototypeOf(probe) };
|
|
}
|
|
|
|
/** Make ExcelJS's dense row, cell and model walks throw until the returned restore runs. */
|
|
function forbidDenseWalks(): () => void {
|
|
const { row, sheet } = excelJsPrototypes();
|
|
const saved = [
|
|
[sheet, 'eachRow', Object.getOwnPropertyDescriptor(sheet, 'eachRow')],
|
|
[row, 'eachCell', Object.getOwnPropertyDescriptor(row, 'eachCell')],
|
|
[row, 'hasValues', Object.getOwnPropertyDescriptor(row, 'hasValues')],
|
|
] as const;
|
|
for (const [target, name] of saved) {
|
|
Object.defineProperty(target, name, {
|
|
configurable: true,
|
|
get() {
|
|
throw new Error(`${name} touched`);
|
|
},
|
|
});
|
|
}
|
|
// `sheet.model` rebuilds every row and cell model the same dense way; load
|
|
// still needs its setter, so only reading it throws.
|
|
const model = Object.getOwnPropertyDescriptor(sheet, 'model')!;
|
|
Object.defineProperty(sheet, 'model', {
|
|
configurable: true,
|
|
get() {
|
|
throw new Error('model touched');
|
|
},
|
|
set: model.set,
|
|
});
|
|
return () => {
|
|
for (const [target, name, descriptor] of saved) Object.defineProperty(target, name, descriptor!);
|
|
Object.defineProperty(sheet, 'model', model);
|
|
};
|
|
}
|
|
|
|
describe('spreadsheet preview worker: rows and cells are indexed by their present keys', () => {
|
|
// ExcelJS keeps a row's cells at `_cells[col - 1]`; one far-column cell makes
|
|
// eachCell and hasValues (behind eachRow) visit every index up to 16,384.
|
|
it('loads and serves a tile without ExcelJS eachRow, eachCell, hasValues or sheet.model', async () => {
|
|
const harness = createHarness();
|
|
const bytes = await fixture();
|
|
const restore = forbidDenseWalks();
|
|
try {
|
|
const metadata = await loadMetadata(harness, bytes);
|
|
expect(metadata.sheets[0]).toMatchObject({ rows: 4, cols: 3 });
|
|
expect(metadata.sheets[0].rowOverrides).toContainEqual([2, 40]);
|
|
expect(metadata.sheets[0].merges).toEqual(['A4:C4']);
|
|
const tile = await requestTile(harness, metadata.sheets[0].id, { r1: 1, c1: 1, r2: 4, c2: 3 });
|
|
expect(tile.type, JSON.stringify(tile)).toBe('tile');
|
|
expect(
|
|
(tile.cells as Array<{ row: number; col: number; text: string }>).map(({ row, col, text }) => [row, col, text])
|
|
).toEqual([
|
|
[1, 1, 'Revenue'],
|
|
[2, 2, '$1,234.50'],
|
|
[3, 3, '$1,234.50'],
|
|
[4, 1, 'Merged'],
|
|
]);
|
|
expect(tile.merges).toEqual(['A4:C4']);
|
|
} finally {
|
|
restore();
|
|
}
|
|
});
|
|
|
|
// eachCell and eachRow skip a cell whose value is Null (a styled empty `<c/>`),
|
|
// and a row holding only such cells; the key walk must skip them the same way.
|
|
it('skips value-less cells and the rows that hold only them, as ExcelJS eachRow/eachCell do', async () => {
|
|
const bytes = await emptyRowsWorkbook(
|
|
'<row r="3" ht="30" customHeight="1"><c r="E3" s="1"/></row><row r="4"><c r="B4" s="1"/><c r="C4"><v>7</v></c></row>'
|
|
);
|
|
const harness = createHarness();
|
|
const metadata = await loadMetadata(harness, bytes);
|
|
// The styled empty cells really exist in ExcelJS, as Null-type cells.
|
|
expect(harness.peek('sheetsById.values().next().value.findCell(3, 5)?.type')).toBe(0);
|
|
expect(harness.peek('sheetsById.values().next().value.findCell(4, 2)?.type')).toBe(0);
|
|
expect(metadata.sheets[0]).toMatchObject({ rows: 4, cols: 3 });
|
|
expect(metadata.sheets[0].rowOverrides).toEqual([]);
|
|
const tile = await requestTile(harness, metadata.sheets[0].id, { r1: 1, c1: 1, r2: 4, c2: 5 });
|
|
expect((tile.cells as Array<{ row: number; col: number }>).map(({ row, col }) => [row, col])).toEqual([
|
|
[1, 1],
|
|
[4, 3],
|
|
]);
|
|
});
|
|
|
|
it('loads and tiles 3,000 rows that each hold one XFD cell', async () => {
|
|
const rows = Array.from(
|
|
{ length: 3_000 },
|
|
(_, i) => `<row r="${i + 2}" ht="0.01" customHeight="1"><c r="XFD${i + 2}"><v>${i + 2}</v></c></row>`
|
|
).join('');
|
|
const bytes = await emptyRowsWorkbook(rows);
|
|
const harness = createHarness();
|
|
const restore = forbidDenseWalks();
|
|
try {
|
|
const started = performance.now();
|
|
const metadata = await loadMetadata(harness, bytes);
|
|
expect(metadata.sheets[0]).toMatchObject({ rows: 3_001, cols: 16_384 });
|
|
expect(metadata.sheets[0].rowOverrides).toHaveLength(3_000);
|
|
const tile = await requestTile(harness, metadata.sheets[0].id, { r1: 1, c1: 16_380, r2: 3_001, c2: 16_384 });
|
|
expect(tile.type, JSON.stringify(tile).slice(0, 200)).toBe('tile');
|
|
expect(tile.cells).toHaveLength(2_500);
|
|
expect(tile.cells[0]).toMatchObject({ row: 2, col: 16_384, text: '2' });
|
|
expect(performance.now() - started).toBeLessThan(5_000);
|
|
} finally {
|
|
restore();
|
|
}
|
|
}, 60_000);
|
|
});
|
|
|
|
/** Deterministic, poorly compressible text, so the ratio cap does not refuse it first. */
|
|
function noisyText(length: number, seed = 1): string {
|
|
let state = seed;
|
|
let text = '';
|
|
for (let i = 0; i < length; i += 1) {
|
|
state = (state * 1103515245 + 12345) & 0x7fffffff;
|
|
text += String.fromCharCode(97 + (state % 26));
|
|
}
|
|
return text;
|
|
}
|
|
|
|
describe('spreadsheet preview worker: cell text reaching the page is bounded', () => {
|
|
// Every tile cell carried its whole string and structured clone copied it
|
|
// once per cell: one 1 MB shared string over a 60 x 20 block froze the page.
|
|
it('caps every tile cell text, shared string, rich text and hyperlink alike', async () => {
|
|
const long = noisyText(200_000);
|
|
const workbook = new ExcelJS.Workbook();
|
|
const sheet = workbook.addWorksheet('Long');
|
|
for (let row = 1; row <= 20; row += 1) {
|
|
for (let col = 1; col <= 10; col += 1) sheet.getCell(row, col).value = long;
|
|
}
|
|
sheet.getCell(21, 1).value = { richText: [{ text: long }, { font: { bold: true }, text: long }] };
|
|
sheet.getCell(21, 2).value = { text: long, hyperlink: 'https://example.invalid/' };
|
|
sheet.getCell(21, 3).value = 'short';
|
|
const bytes = await writeWorkbook(workbook);
|
|
const entries = fflate.unzipSync(new Uint8Array(bytes));
|
|
// One shared string (index 0), referenced by every cell of the block.
|
|
expect(fflate.strFromU8(entries['xl/sharedStrings.xml'])).toContain(`<si><t>${long}</t></si>`);
|
|
expect(
|
|
fflate.strFromU8(entries['xl/worksheets/sheet1.xml']).match(/t="s"><v>0<\/v>/g)?.length
|
|
).toBeGreaterThanOrEqual(200);
|
|
|
|
const harness = createHarness();
|
|
const metadata = await loadMetadata(harness, bytes);
|
|
const tile = await requestTile(harness, metadata.sheets[0].id, { r1: 1, c1: 1, r2: 21, c2: 10 });
|
|
expect(tile.type).toBe('tile');
|
|
const cells = tile.cells as Array<{ row: number; col: number; text: string }>;
|
|
expect(cells).toHaveLength(203);
|
|
for (const cell of cells) expect(cell.text.length, `${cell.row}:${cell.col}`).toBeLessThanOrEqual(1000);
|
|
expect(cells.find((cell) => cell.row === 1 && cell.col === 1)?.text).toBe(`${long.slice(0, 999)}…`);
|
|
expect(cells.find((cell) => cell.row === 21 && cell.col === 1)?.text.length).toBe(1000);
|
|
expect(cells.find((cell) => cell.row === 21 && cell.col === 2)?.text.length).toBe(1000);
|
|
expect(cells.find((cell) => cell.row === 21 && cell.col === 3)?.text).toBe('short');
|
|
}, 60_000);
|
|
});
|
|
|
|
/** A workbook of `sheets` one-cell sheets, each carrying `mergesPerSheet` one-row merges. */
|
|
async function mergeHeavyWorkbook(sheets: number, mergesPerSheet: number): Promise<ArrayBuffer> {
|
|
const workbook = new ExcelJS.Workbook();
|
|
for (let i = 1; i <= sheets; i += 1) workbook.addWorksheet(`S${i}`).getCell('A1').value = 'one';
|
|
const entries = fflate.unzipSync(new Uint8Array(await workbook.xlsx.writeBuffer()));
|
|
const merges = Array.from({ length: mergesPerSheet }, (_, i) => `<mergeCell ref="A${i + 2}:B${i + 2}"/>`).join('');
|
|
for (let i = 1; i <= sheets; i += 1) {
|
|
const name = `xl/worksheets/sheet${i}.xml`;
|
|
const sheet = fflate.strFromU8(entries[name]);
|
|
const patched = sheet.replace(
|
|
'</sheetData>',
|
|
`</sheetData><mergeCells count="${mergesPerSheet}">${merges}</mergeCells>`
|
|
);
|
|
expect(patched).not.toBe(sheet);
|
|
entries[name] = fflate.strToU8(patched);
|
|
}
|
|
return toArrayBuffer(fflate.zipSync(entries));
|
|
}
|
|
|
|
describe('spreadsheet preview worker: merges are bounded per sheet and workbook-wide', () => {
|
|
// ExcelJS checks each new merge against every earlier one on its sheet, so
|
|
// a sheet's load cost grows with the square of its merge count.
|
|
it('refuses one sheet over the per-sheet merge cap before ExcelJS loads', async () => {
|
|
const harness = createHarness();
|
|
await harness.send({ type: 'load', bytes: await mergeHeavyWorkbook(1, 2_001) });
|
|
expect(harness.messages.at(-1)).toMatchObject({ type: 'error', code: 'merge-limit' });
|
|
expect(harness.imports.some((url) => url.includes('exceljs'))).toBe(false);
|
|
}, 60_000);
|
|
|
|
it('refuses sheets each under the per-sheet cap once the workbook total passes 10,000', async () => {
|
|
const harness = createHarness();
|
|
await harness.send({ type: 'load', bytes: await mergeHeavyWorkbook(6, 2_000) });
|
|
expect(harness.messages.at(-1)).toMatchObject({ type: 'error', code: 'merge-limit' });
|
|
expect(harness.imports.some((url) => url.includes('exceljs'))).toBe(false);
|
|
}, 60_000);
|
|
|
|
it('still previews a sheet at the per-sheet merge cap', async () => {
|
|
const harness = createHarness();
|
|
const metadata = await loadMetadata(harness, await mergeHeavyWorkbook(1, 2_000));
|
|
expect(metadata.sheets[0].merges).toHaveLength(2_000);
|
|
}, 60_000);
|
|
});
|
|
|
|
/** A one-cell numeric workbook whose styles.xml declares `formatCode` for the cell. */
|
|
async function numberFormatWorkbook(formatCode: string): Promise<ArrayBuffer> {
|
|
const workbook = new ExcelJS.Workbook();
|
|
const sheet = workbook.addWorksheet('Data');
|
|
sheet.getCell('A1').value = 1.5;
|
|
sheet.getCell('A1').numFmt = '0.000';
|
|
const entries = fflate.unzipSync(new Uint8Array(await workbook.xlsx.writeBuffer()));
|
|
const styles = fflate.strFromU8(entries['xl/styles.xml']);
|
|
const patched = styles.replace('formatCode="0.000"', `formatCode="${formatCode}"`);
|
|
expect(patched).not.toBe(styles);
|
|
entries['xl/styles.xml'] = fflate.strToU8(patched);
|
|
return toArrayBuffer(fflate.zipSync(entries));
|
|
}
|
|
|
|
describe('spreadsheet preview worker: number formats ExcelJS would rescan', () => {
|
|
// `isDateFmt` runs `/\[[^\]]*]/g` per numeric cell; a 60,000-character code of
|
|
// `[` cost 2.2 s a cell, and the code is echoed into the notice bar.
|
|
it.each([
|
|
['an unclosed [', `0${'['.repeat(200)}`],
|
|
['a code over 255 characters', `${'0'.repeat(300)}.00`],
|
|
])(
|
|
'refuses %s before ExcelJS loads',
|
|
async (_label, code) => {
|
|
const harness = createHarness();
|
|
await harness.send({ type: 'load', bytes: await numberFormatWorkbook(code) });
|
|
expect(harness.messages.at(-1)).toMatchObject({ type: 'error', code: 'number-format' });
|
|
expect(harness.imports.some((url) => url.includes('exceljs'))).toBe(false);
|
|
},
|
|
60_000
|
|
);
|
|
|
|
it('previews a closed bracketed format and keeps its warning bounded', async () => {
|
|
const code = `[Red]${'0'.repeat(200)}`;
|
|
const harness = createHarness();
|
|
const metadata = await loadMetadata(harness, await numberFormatWorkbook(code));
|
|
const tile = await requestTile(harness, metadata.sheets[0].id, { r1: 1, c1: 1, r2: 1, c2: 1 });
|
|
expect(tile.cells).toEqual([expect.objectContaining({ row: 1, col: 1, text: '1.5' })]);
|
|
expect(tile.warnings).toEqual([`Unsupported number format: ${code}`]);
|
|
}, 60_000);
|
|
});
|
|
|
|
describe('spreadsheet preview worker: the notice bar stays bounded', () => {
|
|
// Each distinct unsupported code was its own notice entry; a 40 x 20 sheet
|
|
// with a code per cell grew the bar to thousands of pixels.
|
|
it('folds many distinct unsupported number formats in one tile into one counted warning', async () => {
|
|
const workbook = new ExcelJS.Workbook();
|
|
const sheet = workbook.addWorksheet('Formats');
|
|
for (let row = 1; row <= 40; row += 1) {
|
|
for (let col = 1; col <= 20; col += 1) {
|
|
const cell = sheet.getCell(row, col);
|
|
cell.value = row * col + 0.5;
|
|
cell.numFmt = `"c${row}-${col}"0.00E+00`;
|
|
}
|
|
}
|
|
const harness = createHarness();
|
|
const metadata = await loadMetadata(harness, await writeWorkbook(workbook));
|
|
const tile = await requestTile(harness, metadata.sheets[0].id, { r1: 1, c1: 1, r2: 40, c2: 20 });
|
|
expect(tile.type, JSON.stringify(tile).slice(0, 200)).toBe('tile');
|
|
expect(tile.cells).toHaveLength(800);
|
|
expect(tile.warnings).toEqual(['800 unsupported number formats']);
|
|
}, 60_000);
|
|
});
|
|
|
|
/**
|
|
* A one-cell workbook plus an `xl/sharedStrings.xml` whose one string holds
|
|
* `runs` empty `<r/>` runs. Its deflate stream is prefixed with empty stored
|
|
* blocks (5 bytes each, inflating to nothing) until the compressed size clears
|
|
* the 100:1 ratio cap, so only an element budget can refuse it.
|
|
*/
|
|
async function emptyRunsWorkbook(runs: number): Promise<Uint8Array> {
|
|
const workbook = new ExcelJS.Workbook();
|
|
workbook.addWorksheet('Data').getCell('A1').value = 1;
|
|
const entries = fflate.unzipSync(new Uint8Array(await workbook.xlsx.writeBuffer()));
|
|
delete entries['xl/sharedStrings.xml'];
|
|
const strings = fflate.strToU8(`<sst count="1" uniqueCount="1"><si>${'<r/>'.repeat(runs)}</si></sst>`);
|
|
const deflated = fflate.deflateSync(strings, { level: 9 });
|
|
const emptyStoredBlock = Uint8Array.from([0x00, 0x00, 0x00, 0xff, 0xff]);
|
|
const blocks = Math.ceil((strings.length / 50 - deflated.length) / emptyStoredBlock.length);
|
|
const padded = concatBytes([...Array.from({ length: blocks }, () => emptyStoredBlock), deflated]);
|
|
expect(fflate.inflateSync(padded)).toEqual(strings);
|
|
const parts = Object.keys(entries).map((name) => zipPart(name, entries[name], 8));
|
|
parts.push({
|
|
name: 'xl/sharedStrings.xml',
|
|
data: padded,
|
|
method: 8,
|
|
size: strings.length,
|
|
crc: crc32(strings) >>> 0,
|
|
});
|
|
const locals: Uint8Array[] = [];
|
|
const central: Uint8Array[] = [];
|
|
let offset = 0;
|
|
for (const part of parts) {
|
|
central.push(centralHeader(part, offset));
|
|
const chunk = concatBytes([localHeader(part), part.data]);
|
|
locals.push(chunk);
|
|
offset += chunk.length;
|
|
}
|
|
const directory = concatBytes(central);
|
|
const eocd = new Uint8Array(22);
|
|
const view = new DataView(eocd.buffer);
|
|
view.setUint32(0, 0x06054b50, true);
|
|
view.setUint16(8, central.length, true);
|
|
view.setUint16(10, central.length, true);
|
|
view.setUint32(12, directory.length, true);
|
|
view.setUint32(16, offset, true);
|
|
return concatBytes([...locals, directory, eocd]);
|
|
}
|
|
|
|
describe('spreadsheet preview worker: element budget', () => {
|
|
// ExcelJS builds one object per <r> run in sharedStrings.xml, which no
|
|
// worksheet counter sees; 8.3M empty runs took 570 MB of heap to load.
|
|
it('refuses a shared string of 2,000,001 empty runs before ExcelJS loads', async () => {
|
|
const crafted = await emptyRunsWorkbook(2_000_001);
|
|
const harness = createHarness();
|
|
const core = harness.self.CodemanSpreadsheetXlsxCore as {
|
|
admitXlsx(bytes: Uint8Array, zip: typeof fflate, overrides?: Record<string, number>): unknown;
|
|
};
|
|
// Every other cap admits it: only the element budget is in the way.
|
|
expect(() => core.admitXlsx(crafted, fflate, { maxElements: Infinity })).not.toThrow();
|
|
await harness.send({ type: 'load', bytes: toArrayBuffer(crafted) });
|
|
expect(harness.messages.at(-1)).toMatchObject({ type: 'error', code: 'element-limit' });
|
|
expect(harness.imports.some((url) => url.includes('exceljs'))).toBe(false);
|
|
}, 60_000);
|
|
});
|