Files
mdg-filius/packages/fls/test/zip.test.ts
T
julian.kiedaischandClaude Opus 5.5 750cc72c55 feat(fls): ZIP layer of the .fls codec
Reader and writer ported from filius-ipad with the same path policy,
limits and error codes; DEFLATE via fflate, cut at the declared size
like Apple Compression. 54 tests: the Swift archive rejection tests,
DEFLATE edge cases, and all 23 corpus files cross-checked against
fflate.unzipSync and round-tripped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:03:07 +00:00

346 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Ported from filius-ipad ios/FiliusPadTests/TopologyProjectPersistenceTests.swift:
// the archive rejection tests (missing configuration, unsafe and non-portable
// paths, compression, encryption, DEFLATE, checksum, UTF-8, limits,
// duplicates) with their ZIP builders makeMinimalZIP and makePayloadZIP.
// The tests that need the XML layer follow with it.
import { deflateSync } from 'fflate';
import { describe, expect, it } from 'vitest';
import {
CONFIGURATION_ENTRY_PATH,
APPLICATIONS_ENTRY_PATH,
crc32,
FlsArchiveError,
readFlsArchive,
readZipEntries,
writeFlsArchive,
writeZipEntries,
type FlsArchiveErrorCode,
} from '../src/index.js';
const encode = (text: string) => new TextEncoder().encode(text);
class Bytes {
private readonly data: number[] = [];
uint16(value: number): this {
this.data.push(value & 0xff, (value >>> 8) & 0xff);
return this;
}
uint32(value: number): this {
this.data.push(value & 0xff, (value >>> 8) & 0xff, (value >>> 16) & 0xff, (value >>> 24) & 0xff);
return this;
}
bytes(bytes: Uint8Array): this {
this.data.push(...bytes);
return this;
}
get length(): number {
return this.data.length;
}
result(): Uint8Array {
return Uint8Array.from(this.data);
}
}
/** Entries with empty payloads and zero checksums — enough to reach the path and method checks. */
function makeMinimalZip(entries: { path: string; method: number }[]): Uint8Array {
const archive = new Bytes();
const central: { pathData: Uint8Array; method: number; offset: number }[] = [];
for (const entry of entries) {
const pathData = encode(entry.path);
const offset = archive.length;
archive.uint32(0x04034b50).uint16(20).uint16(0x0800).uint16(entry.method).uint16(0).uint16(0x0021);
archive.uint32(0).uint32(0).uint32(0).uint16(pathData.length).uint16(0).bytes(pathData);
central.push({ pathData, method: entry.method, offset });
}
const centralOffset = archive.length;
for (const record of central) {
archive.uint32(0x02014b50).uint16(20).uint16(20).uint16(0x0800).uint16(record.method).uint16(0).uint16(0x0021);
archive.uint32(0).uint32(0).uint32(0).uint16(record.pathData.length).uint16(0).uint16(0).uint16(0).uint16(0);
archive.uint32(0).uint32(record.offset).bytes(record.pathData);
}
const centralSize = archive.length - centralOffset;
archive.uint32(0x06054b50).uint16(0).uint16(0).uint16(central.length).uint16(central.length);
archive.uint32(centralSize).uint32(centralOffset).uint16(0);
return archive.result();
}
/** One entry with a real payload, method and declared expanded size. */
function makePayloadZip(
path: string,
method: number,
payload: Uint8Array,
uncompressedSize: number,
checksum = 0,
): Uint8Array {
const pathData = encode(path);
const archive = new Bytes();
archive.uint32(0x04034b50).uint16(20).uint16(0x0800).uint16(method).uint16(0).uint16(0x0021);
archive.uint32(checksum).uint32(payload.length).uint32(uncompressedSize).uint16(pathData.length).uint16(0);
archive.bytes(pathData).bytes(payload);
const centralOffset = archive.length;
archive.uint32(0x02014b50).uint16(20).uint16(20).uint16(0x0800).uint16(method).uint16(0).uint16(0x0021);
archive.uint32(checksum).uint32(payload.length).uint32(uncompressedSize).uint16(pathData.length);
archive.uint16(0).uint16(0).uint16(0).uint16(0).uint32(0).uint32(0).bytes(pathData);
archive.uint32(0x06054b50).uint16(0).uint16(0).uint16(1).uint16(1);
archive.uint32(46 + pathData.length).uint32(centralOffset).uint16(0);
return archive.result();
}
function setUint16(data: Uint8Array, offset: number, value: number): void {
data[offset] = value & 0xff;
data[offset + 1] = (value >>> 8) & 0xff;
}
function setUint32(data: Uint8Array, offset: number, value: number): void {
setUint16(data, offset, value & 0xffff);
setUint16(data, offset + 2, value >>> 16);
}
function centralOffset(data: Uint8Array): number {
for (let index = 0; index + 3 < data.length; index += 1) {
if (data[index] === 0x50 && data[index + 1] === 0x4b && data[index + 2] === 0x01 && data[index + 3] === 0x02) {
return index;
}
}
throw new Error('no central directory');
}
function archiveError(action: () => unknown): FlsArchiveError {
try {
action();
} catch (error) {
if (error instanceof FlsArchiveError) return error;
throw error;
}
throw new Error('expected an FlsArchiveError');
}
function expectCode(action: () => unknown, code: FlsArchiveErrorCode): FlsArchiveError {
const error = archiveError(action);
expect(error.code).toBe(code);
return error;
}
const config = CONFIGURATION_ENTRY_PATH;
describe('reading a .fls archive', () => {
it('rejects an archive without configuration', () => {
expectCode(() => readFlsArchive(makeMinimalZip([{ path: 'projekt/other.xml', method: 0 }])), 'missingConfiguration');
});
it('rejects a parent-traversal path', () => {
expectCode(() => readFlsArchive(makeMinimalZip([{ path: '../konfiguration.xml', method: 0 }])), 'unsafeEntryPath');
});
it('rejects a non-portable alias of the configuration path', () => {
const error = expectCode(
() =>
readFlsArchive(
makeMinimalZip([
{ path: config, method: 0 },
{ path: 'projekt/konfiguration.xml ', method: 0 },
]),
),
'unsafeEntryPath',
);
expect(error.entryPath).toBe('projekt/konfiguration.xml ');
});
it.each(['projekt/CON.txt', 'projekt/.', 'projekt//opaque.bin'])('rejects the reserved or dot path %j', (path) => {
expectCode(() => readFlsArchive(makeMinimalZip([{ path, method: 0 }])), 'unsafeEntryPath');
});
it.each(['projekt/illegal?.bin', 'projekt/control\u0001.bin', 'projekt/COM¹.txt', 'projekt/LPT³.log'])(
'rejects the Windows-forbidden or superscript-reserved path %j',
(path) => {
expectCode(() => readFlsArchive(makeMinimalZip([{ path, method: 0 }])), 'unsafeEntryPath');
},
);
it('rejects an unsupported compression method', () => {
expectCode(() => readFlsArchive(makeMinimalZip([{ path: config, method: 99 }])), 'unsupportedCompression');
});
it('rejects a local filename that contradicts the central directory', () => {
const archive = makeMinimalZip([{ path: config, method: 0 }]);
archive[30] = 0x78;
const error = expectCode(() => readFlsArchive(archive), 'malformedArchive');
expect(error.detail).toContain('local filename');
});
it('rejects an encrypted entry', () => {
const archive = makeMinimalZip([{ path: config, method: 0 }]);
setUint16(archive, 6, 0x0001);
setUint16(archive, centralOffset(archive) + 8, 0x0001);
expectCode(() => readFlsArchive(archive), 'encryptedEntry');
});
it('rejects a malformed DEFLATE payload', () => {
const error = expectCode(
() => readFlsArchive(makePayloadZip(config, 8, Uint8Array.of(0xff), 1)),
'malformedArchive',
);
expect(error.detail).toContain('DEFLATE');
});
it('inflates a DEFLATE entry', () => {
const text = encode('hello world');
const archive = makePayloadZip('a.txt', 8, deflateSync(text), text.length, crc32(text));
expect(readZipEntries(archive).get('a.txt')).toEqual(text);
});
it('cuts a DEFLATE stream at the declared size, as Apple Compression does; the checksum decides', () => {
const text = encode('hello world');
const compressed = deflateSync(text);
const matching = makePayloadZip('a.txt', 8, compressed, 5, crc32(encode('hello')));
expect(readZipEntries(matching).get('a.txt')).toEqual(encode('hello'));
const mismatching = makePayloadZip('a.txt', 8, compressed, 5, crc32(text));
expectCode(() => readZipEntries(mismatching), 'checksumMismatch');
});
it('rejects a DEFLATE stream that ends before the declared size', () => {
const text = encode('hello');
const error = expectCode(
() => readZipEntries(makePayloadZip('a.txt', 8, deflateSync(text), 11, crc32(text))),
'malformedArchive',
);
expect(error.detail).toBe('DEFLATE expansion produced 5 bytes; expected 11.');
});
it('rejects a checksum mismatch', () => {
const archive = writeFlsArchive(encode('<?xml version="1.0" encoding="UTF-8"?>\n<java/>\n'));
const marker = archive.findIndex((byte, index) => byte === 0x3c && archive[index + 1] === 0x3f);
archive[marker] = archive[marker]! ^ 0x01;
expectCode(() => readFlsArchive(archive), 'checksumMismatch');
});
it('rejects something that is not a ZIP archive', () => {
expectCode(() => readFlsArchive(encode('not-a-zip')), 'malformedArchive');
});
it('rejects case-variant duplicates of the configuration before extracting', () => {
expectCode(
() =>
readFlsArchive(
makeMinimalZip([
{ path: config, method: 0 },
{ path: 'PROJEKT/KONFIGURATION.XML', method: 0 },
]),
),
'duplicateEntry',
);
});
it('rejects entry names that are not UTF-8', () => {
const archive = makeMinimalZip([{ path: config, method: 0 }]);
const central = centralOffset(archive);
archive[30] = 0xff;
archive[central + 46] = 0xff;
const error = expectCode(() => readFlsArchive(archive), 'malformedArchive');
expect(error.detail).toContain('UTF-8');
});
it('rejects an oversized entry before inflating it', () => {
const archive = makeMinimalZip([{ path: config, method: 0 }]);
const expandedSize = 128 * 1024 * 1024 + 1;
setUint32(archive, 22, expandedSize);
setUint32(archive, centralOffset(archive) + 24, expandedSize);
expectCode(() => readFlsArchive(archive), 'archiveLimitExceeded');
});
it('rejects duplicate configuration entries', () => {
expectCode(
() =>
readFlsArchive(
makeMinimalZip([
{ path: config, method: 0 },
{ path: config, method: 0 },
]),
),
'duplicateEntry',
);
});
});
describe('writing a .fls archive', () => {
const xml = encode('<?xml version="1.0" encoding="UTF-8"?>\n<java class="java.beans.XMLDecoder"/>\n');
it('round-trips the configuration and carried entries', () => {
const extra = new Map([['projekt/anwendungen/filius/Mein.java', encode('class Mein {}')]]);
const reopened = readFlsArchive(writeFlsArchive(xml, extra));
expect(reopened.configurationXml).toEqual(xml);
expect(reopened.supplementalEntries.get('projekt/anwendungen/filius/Mein.java')).toEqual(encode('class Mein {}'));
expect(reopened.supplementalEntries.get(APPLICATIONS_ENTRY_PATH)).toEqual(new Uint8Array());
expect(reopened.entryPaths).toEqual([
'projekt/anwendungen/EigeneAnwendungen.txt',
'projekt/anwendungen/filius/Mein.java',
'projekt/konfiguration.xml',
]);
});
it('writes the same bytes for the same project, whatever the entry order', () => {
const one = new Map([
['projekt/b.txt', encode('b')],
['projekt/a.txt', encode('a')],
]);
const other = new Map([
['projekt/a.txt', encode('a')],
['projekt/b.txt', encode('b')],
]);
expect(writeFlsArchive(xml, one)).toEqual(writeFlsArchive(xml, other));
});
it('rejects width and case aliases among carried entries', () => {
expectCode(
() =>
writeZipEntries(
new Map([
['projekt/Alpha.txt', encode('one')],
['projekt/ALPHA.TXT', encode('two')],
]),
),
'duplicateEntry',
);
});
it('rejects a carried entry posing as the configuration before it could overwrite it', () => {
const error = expectCode(
() => writeFlsArchive(xml, new Map([['projekt/konfiguration.xml ', encode('attacker')]])),
'unsafeEntryPath',
);
expect(error.entryPath).toBe('projekt/konfiguration.xml ');
});
it('rejects a carried case variant of the configuration', () => {
expectCode(() => writeFlsArchive(xml, new Map([['Projekt/Konfiguration.XML', encode('x')]])), 'duplicateEntry');
});
});
describe('CRC-32', () => {
it('gives the standard check value', () => {
expect(crc32(encode('123456789'))).toBe(0xcbf43926);
});
it('is checked on stored entries', () => {
const archive = writeZipEntries(new Map([['a.txt', encode('abc')]]));
expect(readZipEntries(archive).get('a.txt')).toEqual(encode('abc'));
});
});