// 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('\n\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('\n\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')); }); });