This commit is contained in:
@@ -0,0 +1,245 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import AdmZip from 'adm-zip';
|
||||
import { executeTool } from './office.js';
|
||||
import type { ToolContext, ToolsConfig } from './core.js';
|
||||
|
||||
// --- Helpers (mirrors office.test.ts house style) ---
|
||||
|
||||
function makeWorkspace(): string {
|
||||
return fs.mkdtempSync(path.join(tmpdir(), 'maestro-office-size-'));
|
||||
}
|
||||
|
||||
function makeContext(workspacePath: string, toolsConfig?: ToolsConfig): ToolContext {
|
||||
return { workspacePath, editAllowed: true, ...(toolsConfig ? { toolsConfig } : {}) };
|
||||
}
|
||||
|
||||
function inputPath(workspacePath: string, name: string): string {
|
||||
const dir = path.join(workspacePath, 'input');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
return path.join(dir, name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a fixture that PASSES validateFileFormat (correct magic bytes for the
|
||||
* expected format) but exceeds `sizeBytes`. The size-limit check in office.ts
|
||||
* runs AFTER the magic-byte format check but BEFORE any real parse, so the file
|
||||
* does not need to be a valid Office document — only the leading magic bytes +
|
||||
* the right extension matter, plus total size > the configured cap.
|
||||
*
|
||||
* OOXML magic = "PK\x03\x04" (ReadExcel/ReadDocx/ReadPPTX). PDF magic = "%PDF-".
|
||||
*/
|
||||
function writeOversizedOoxml(filePath: string, sizeBytes: number): void {
|
||||
const buf = Buffer.alloc(sizeBytes, 0);
|
||||
// Local file header signature for a ZIP / OOXML container.
|
||||
buf[0] = 0x50; // P
|
||||
buf[1] = 0x4b; // K
|
||||
buf[2] = 0x03;
|
||||
buf[3] = 0x04;
|
||||
fs.writeFileSync(filePath, buf);
|
||||
}
|
||||
|
||||
function writeOversizedPdf(filePath: string, sizeBytes: number): void {
|
||||
const buf = Buffer.alloc(sizeBytes, 0x20); // pad with spaces
|
||||
Buffer.from('%PDF-1.4\n').copy(buf, 0);
|
||||
fs.writeFileSync(filePath, buf);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a REAL .pptx (zip) whose total UNCOMPRESSED size exceeds the configured
|
||||
* uncompressed cap, while staying tiny on disk (highly compressible zeros) — i.e.
|
||||
* a decompression-bomb shape. adm-zip records the true uncompressed size in each
|
||||
* entry's header.size, which is exactly what office.ts sums for ZIP-bomb detection.
|
||||
*/
|
||||
function writeZipBomb(filePath: string, uncompressedBytes: number): void {
|
||||
const zip = new AdmZip();
|
||||
// A single fat, highly-compressible entry: large uncompressed, tiny on disk.
|
||||
zip.addFile('ppt/slides/slide1.xml', Buffer.alloc(uncompressedBytes, 0));
|
||||
zip.writeZip(filePath);
|
||||
}
|
||||
|
||||
const MB = 1024 * 1024;
|
||||
|
||||
describe('office tools — file size limits', () => {
|
||||
let workspacePath = '';
|
||||
|
||||
afterEach(() => {
|
||||
if (workspacePath) {
|
||||
fs.rmSync(workspacePath, { recursive: true, force: true });
|
||||
workspacePath = '';
|
||||
}
|
||||
});
|
||||
|
||||
// --- 1. ReadPPTX file-size cap (officePptxMaxSizeMb) ---
|
||||
|
||||
it('ReadPPTX rejects a file exceeding officePptxMaxSizeMb', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
const fp = inputPath(workspacePath, 'big.pptx');
|
||||
writeOversizedOoxml(fp, 2 * MB); // 2MB > 1MB cap
|
||||
const ctx = makeContext(workspacePath, { officePptxMaxSizeMb: 1 });
|
||||
|
||||
const result = await executeTool('ReadPPTX', { path: 'input/big.pptx' }, ctx);
|
||||
|
||||
expect(result?.isError).toBe(true);
|
||||
expect(result?.output).toContain('exceeds limit of 1MB');
|
||||
expect(result?.output).toContain('File size');
|
||||
// Distinct from the format-mismatch error family (Japanese guidance text).
|
||||
expect(result?.output).not.toContain('ReadToolDoc');
|
||||
});
|
||||
|
||||
// --- 2. ReadPPTX ZIP-bomb path (officePptxMaxUncompressedMb) ---
|
||||
|
||||
it('ReadPPTX rejects a ZIP bomb whose uncompressed size exceeds officePptxMaxUncompressedMb', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
const fp = inputPath(workspacePath, 'bomb.pptx');
|
||||
// ~3MB uncompressed of zeros -> a few KB on disk (well under the 50MB file cap),
|
||||
// so it passes the file-size check and reaches the uncompressed-size guard.
|
||||
writeZipBomb(fp, 3 * MB);
|
||||
const onDisk = fs.statSync(fp).size;
|
||||
expect(onDisk).toBeLessThan(50 * MB); // sanity: stays under default file cap
|
||||
|
||||
const ctx = makeContext(workspacePath, { officePptxMaxUncompressedMb: 1 });
|
||||
const result = await executeTool('ReadPPTX', { path: 'input/bomb.pptx' }, ctx);
|
||||
|
||||
expect(result?.isError).toBe(true);
|
||||
expect(result?.output).toContain('ZIP bomb detected');
|
||||
expect(result?.output).toContain('exceeds limit of 1MB');
|
||||
});
|
||||
|
||||
it('ReadPPTX accepts a small zip when uncompressed size is under the cap', async () => {
|
||||
// Guards against the bomb check being over-eager / regressed to always-fire.
|
||||
workspacePath = makeWorkspace();
|
||||
const fp = inputPath(workspacePath, 'tiny.pptx');
|
||||
writeZipBomb(fp, 4 * 1024); // 4KB uncompressed, well under 1MB cap
|
||||
|
||||
const ctx = makeContext(workspacePath, { officePptxMaxUncompressedMb: 1 });
|
||||
const result = await executeTool('ReadPPTX', { path: 'input/tiny.pptx' }, ctx);
|
||||
|
||||
// It will fail later for other reasons (no presentation.xml / no slides parsed),
|
||||
// but it must NOT be rejected by the ZIP-bomb guard.
|
||||
expect(result?.output ?? '').not.toContain('ZIP bomb detected');
|
||||
});
|
||||
|
||||
// --- 3. ReadExcel / ReadDocx / ReadPdf each reject when size > their cap ---
|
||||
|
||||
it('ReadExcel rejects a file exceeding officeExcelMaxSizeMb', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
const fp = inputPath(workspacePath, 'big.xlsx');
|
||||
writeOversizedOoxml(fp, 2 * MB);
|
||||
const ctx = makeContext(workspacePath, { officeExcelMaxSizeMb: 1 });
|
||||
|
||||
const result = await executeTool('ReadExcel', { path: 'input/big.xlsx' }, ctx);
|
||||
|
||||
expect(result?.isError).toBe(true);
|
||||
expect(result?.output).toContain('File size');
|
||||
expect(result?.output).toContain('exceeds limit of 1MB');
|
||||
// Distinct from the format-mismatch error (which names other tools / Read).
|
||||
expect(result?.output).not.toContain('ReadDocx');
|
||||
});
|
||||
|
||||
it('ReadDocx rejects a file exceeding officeDocxMaxSizeMb', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
const fp = inputPath(workspacePath, 'big.docx');
|
||||
writeOversizedOoxml(fp, 2 * MB);
|
||||
const ctx = makeContext(workspacePath, { officeDocxMaxSizeMb: 1 });
|
||||
|
||||
const result = await executeTool('ReadDocx', { path: 'input/big.docx' }, ctx);
|
||||
|
||||
expect(result?.isError).toBe(true);
|
||||
expect(result?.output).toContain('File size');
|
||||
expect(result?.output).toContain('exceeds limit of 1MB');
|
||||
});
|
||||
|
||||
it('ReadPdf rejects a file exceeding officePdfMaxSizeMb', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
const fp = inputPath(workspacePath, 'big.pdf');
|
||||
writeOversizedPdf(fp, 2 * MB);
|
||||
const ctx = makeContext(workspacePath, { officePdfMaxSizeMb: 1 });
|
||||
|
||||
const result = await executeTool('ReadPdf', { path: 'input/big.pdf' }, ctx);
|
||||
|
||||
expect(result?.isError).toBe(true);
|
||||
expect(result?.output).toContain('File size');
|
||||
expect(result?.output).toContain('exceeds limit of 1MB');
|
||||
});
|
||||
|
||||
// The size-limit error is structurally DISTINCT from the format-mismatch error.
|
||||
it('size-limit error differs from the format-mismatch error', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
|
||||
// (a) size-limit: valid OOXML magic + oversize -> FILE_TOO_LARGE message.
|
||||
const bigFp = inputPath(workspacePath, 'big.xlsx');
|
||||
writeOversizedOoxml(bigFp, 2 * MB);
|
||||
const sizeRes = await executeTool(
|
||||
'ReadExcel',
|
||||
{ path: 'input/big.xlsx' },
|
||||
makeContext(workspacePath, { officeExcelMaxSizeMb: 1 }),
|
||||
);
|
||||
|
||||
// (b) format-mismatch: PDF bytes handed to ReadExcel -> guidance message.
|
||||
const pdfFp = inputPath(workspacePath, 'wrong.xlsx');
|
||||
writeOversizedPdf(pdfFp, 4 * 1024); // tiny so size cap is irrelevant
|
||||
const fmtRes = await executeTool(
|
||||
'ReadExcel',
|
||||
{ path: 'input/wrong.xlsx' },
|
||||
makeContext(workspacePath, { officeExcelMaxSizeMb: 1 }),
|
||||
);
|
||||
|
||||
expect(sizeRes?.isError).toBe(true);
|
||||
expect(fmtRes?.isError).toBe(true);
|
||||
expect(sizeRes?.output).toContain('exceeds limit of');
|
||||
expect(fmtRes?.output).not.toContain('exceeds limit of');
|
||||
expect(sizeRes?.output).not.toBe(fmtRes?.output);
|
||||
});
|
||||
|
||||
// --- 4. Config override changes the threshold ---
|
||||
|
||||
it('config override: a file that passes at default fails at a lowered officeExcelMaxSizeMb', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
const fp = inputPath(workspacePath, 'mid.xlsx');
|
||||
// 2MB: under the 10MB default Excel cap, over a lowered 1MB cap.
|
||||
writeOversizedOoxml(fp, 2 * MB);
|
||||
|
||||
// Default cap (no override) -> NOT rejected for size (it fails later on parse,
|
||||
// but never with the FILE_TOO_LARGE message).
|
||||
const defaultRes = await executeTool(
|
||||
'ReadExcel',
|
||||
{ path: 'input/mid.xlsx' },
|
||||
makeContext(workspacePath),
|
||||
);
|
||||
expect(defaultRes?.output ?? '').not.toContain('exceeds limit of');
|
||||
|
||||
// Lowered cap -> rejected for size.
|
||||
const loweredRes = await executeTool(
|
||||
'ReadExcel',
|
||||
{ path: 'input/mid.xlsx' },
|
||||
makeContext(workspacePath, { officeExcelMaxSizeMb: 1 }),
|
||||
);
|
||||
expect(loweredRes?.isError).toBe(true);
|
||||
expect(loweredRes?.output).toContain('exceeds limit of 1MB');
|
||||
});
|
||||
|
||||
it('config override: raising officePptxMaxUncompressedMb lets a previously-bombed zip through the guard', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
const fp = inputPath(workspacePath, 'fat.pptx');
|
||||
writeZipBomb(fp, 3 * MB); // 3MB uncompressed
|
||||
|
||||
// Low cap (1MB) -> tripped by the bomb guard.
|
||||
const lowRes = await executeTool(
|
||||
'ReadPPTX',
|
||||
{ path: 'input/fat.pptx' },
|
||||
makeContext(workspacePath, { officePptxMaxUncompressedMb: 1 }),
|
||||
);
|
||||
expect(lowRes?.output).toContain('ZIP bomb detected');
|
||||
|
||||
// Raised cap (10MB) -> NOT tripped by the bomb guard (fails later on missing slides).
|
||||
const highRes = await executeTool(
|
||||
'ReadPPTX',
|
||||
{ path: 'input/fat.pptx' },
|
||||
makeContext(workspacePath, { officePptxMaxUncompressedMb: 10 }),
|
||||
);
|
||||
expect(highRes?.output ?? '').not.toContain('ZIP bomb detected');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user