Files
Ethan Guo b3e338ddb2 test(web): cover zip encoder, design-system audit, chat-events, mentions, utils (#2451)
* test(web): cover stored-mode ZIP encoder CRC and central directory

* test(web): cover design-system audit repair-actions and chat-events dedup

* test(web): cover @mention parsing and agent label normalization

* test(web): cover error flattener, prompt-template categories, design-system sentinel

* fix(web): set ZIP UTF-8 filename flag (bit 11) for non-ASCII names

buildZip wrote UTF-8 filename bytes (TextEncoder) but left the
general-purpose bit flag at 0 in both the local and central headers, so
readers fall back to CP437 and mis-decode non-ASCII names like 日本.md.

Set general-purpose bit 11 (0x0800, EFS) in both headers when the
filename contains a non-ASCII byte; pure-ASCII archives are unchanged
(ASCII == CP437 == UTF-8), matching how Python/Go zip encoders behave.

Strengthen the UTF-8 test to assert the flag + filename in BOTH the
local and central-directory headers (parsed via EOCD), and add a case
asserting pure-ASCII names do not set the flag.

Addresses review feedback on #2451.
2026-07-02 09:29:28 +00:00

131 lines
5.4 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { buildZip } from '../../src/runtime/zip';
const LOCAL_SIG = 0x04034b50;
const CENTRAL_SIG = 0x02014b50;
const EOCD_SIG = 0x06054b50;
function crc32(bytes: Uint8Array): number {
const table: number[] = new Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) {
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
}
table[n] = c >>> 0;
}
let c = 0xffffffff;
for (let i = 0; i < bytes.length; i++) {
c = table[(c ^ bytes[i]!) & 0xff]! ^ (c >>> 8);
}
return (c ^ 0xffffffff) >>> 0;
}
async function readBytes(blob: Blob): Promise<Uint8Array> {
return new Uint8Array(await blob.arrayBuffer());
}
function findEocd(bytes: Uint8Array): number {
for (let i = bytes.length - 22; i >= 0; i--) {
const view = new DataView(bytes.buffer, bytes.byteOffset + i, 4);
if (view.getUint32(0, true) === EOCD_SIG) return i;
}
return -1;
}
describe('buildZip', () => {
it('produces a stored-mode archive whose CRC matches the entry bytes', async () => {
const content = 'hello world';
const blob = await readBytes(buildZip([{ path: 'a.txt', content }]));
const view = new DataView(blob.buffer, blob.byteOffset, blob.byteLength);
expect(view.getUint32(0, true)).toBe(LOCAL_SIG);
expect(view.getUint16(8, true)).toBe(0);
const expectedCrc = crc32(new TextEncoder().encode(content));
expect(view.getUint32(14, true)).toBe(expectedCrc);
const size = new TextEncoder().encode(content).length;
expect(view.getUint32(18, true)).toBe(size);
expect(view.getUint32(22, true)).toBe(size);
expect(view.getUint16(26, true)).toBe(5);
const nameStart = 30;
const name = new TextDecoder().decode(blob.slice(nameStart, nameStart + 5));
expect(name).toBe('a.txt');
const stored = new TextDecoder().decode(blob.slice(nameStart + 5, nameStart + 5 + size));
expect(stored).toBe(content);
});
it('writes one central directory entry per file and points EOCD at it', async () => {
const bytes = await readBytes(
buildZip([
{ path: 'one.txt', content: 'one' },
{ path: 'two.md', content: 'two two' },
]),
);
const eocdOffset = findEocd(bytes);
expect(eocdOffset).toBeGreaterThan(0);
const eocd = new DataView(bytes.buffer, bytes.byteOffset + eocdOffset, 22);
expect(eocd.getUint32(0, true)).toBe(EOCD_SIG);
expect(eocd.getUint16(8, true)).toBe(2);
expect(eocd.getUint16(10, true)).toBe(2);
const centralSize = eocd.getUint32(12, true);
const centralOffset = eocd.getUint32(16, true);
expect(centralOffset + centralSize).toBe(eocdOffset);
const firstCentral = new DataView(bytes.buffer, bytes.byteOffset + centralOffset, 4);
expect(firstCentral.getUint32(0, true)).toBe(CENTRAL_SIG);
});
it('flags non-ASCII file names as UTF-8 (bit 11) in the local and central headers', async () => {
const bytes = await readBytes(buildZip([{ path: '日本.md', content: 'x' }]));
const expectedName = new TextEncoder().encode('日本.md');
// Local header: UTF-8 flag (bit 11) set + filename bytes match.
const localView = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
expect(localView.getUint32(0, true)).toBe(LOCAL_SIG);
expect(localView.getUint16(6, true) & 0x0800).toBe(0x0800);
const localNameLen = localView.getUint16(26, true);
expect(localNameLen).toBe(expectedName.length);
expect(Array.from(bytes.slice(30, 30 + localNameLen))).toEqual(Array.from(expectedName));
// Central header: locate via EOCD, assert UTF-8 flag + filename bytes too.
const eocdOffset = findEocd(bytes);
expect(eocdOffset).toBeGreaterThan(0);
const eocd = new DataView(bytes.buffer, bytes.byteOffset + eocdOffset, 22);
const centralOffset = eocd.getUint32(16, true);
const centralView = new DataView(
bytes.buffer,
bytes.byteOffset + centralOffset,
bytes.byteLength - centralOffset,
);
expect(centralView.getUint32(0, true)).toBe(CENTRAL_SIG);
expect(centralView.getUint16(8, true) & 0x0800).toBe(0x0800);
const centralNameLen = centralView.getUint16(28, true);
expect(centralNameLen).toBe(expectedName.length);
const centralNameStart = centralOffset + 46;
expect(Array.from(bytes.slice(centralNameStart, centralNameStart + centralNameLen))).toEqual(
Array.from(expectedName),
);
});
it('does not set the UTF-8 flag for pure-ASCII file names', async () => {
const bytes = await readBytes(buildZip([{ path: 'a.txt', content: 'x' }]));
const localView = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
expect(localView.getUint16(6, true) & 0x0800).toBe(0);
const eocdOffset = findEocd(bytes);
const eocd = new DataView(bytes.buffer, bytes.byteOffset + eocdOffset, 22);
const centralOffset = eocd.getUint32(16, true);
const centralView = new DataView(bytes.buffer, bytes.byteOffset + centralOffset, 12);
expect(centralView.getUint16(8, true) & 0x0800).toBe(0);
});
it('emits an empty-but-valid archive when given no entries', async () => {
const bytes = await readBytes(buildZip([]));
expect(bytes.length).toBe(22);
const view = new DataView(bytes.buffer, bytes.byteOffset, 22);
expect(view.getUint32(0, true)).toBe(EOCD_SIG);
expect(view.getUint16(8, true)).toBe(0);
expect(view.getUint16(10, true)).toBe(0);
expect(view.getUint32(12, true)).toBe(0);
expect(view.getUint32(16, true)).toBe(0);
});
});