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>
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
// The 23 real .fls files of the corpus (corpus/README.md): every one opens,
|
||||
// the entries match what an independent ZIP reader extracts, and writing
|
||||
// them back and reading again changes nothing.
|
||||
|
||||
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import { dirname, join, relative } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { unzipSync } from 'fflate';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CONFIGURATION_ENTRY_PATH, readFlsArchive, writeFlsArchive } from '../src/index.js';
|
||||
|
||||
const corpusRoot = join(dirname(fileURLToPath(import.meta.url)), '../../../corpus');
|
||||
|
||||
function flsFiles(directory: string): string[] {
|
||||
return readdirSync(directory)
|
||||
.flatMap((name) => {
|
||||
const path = join(directory, name);
|
||||
return statSync(path).isDirectory() ? flsFiles(path) : name.endsWith('.fls') ? [path] : [];
|
||||
})
|
||||
.sort();
|
||||
}
|
||||
|
||||
const files = flsFiles(corpusRoot);
|
||||
|
||||
describe('corpus archives', () => {
|
||||
it('has all 23 files', () => {
|
||||
expect(files).toHaveLength(23);
|
||||
});
|
||||
|
||||
for (const file of files) {
|
||||
it(relative(corpusRoot, file), () => {
|
||||
const bytes = new Uint8Array(readFileSync(file));
|
||||
const contents = readFlsArchive(bytes);
|
||||
|
||||
const independent = unzipSync(bytes);
|
||||
const independentFiles = Object.keys(independent).filter((path) => !path.endsWith('/')).sort();
|
||||
expect(contents.entryPaths).toEqual(independentFiles);
|
||||
expect(contents.configurationXml).toEqual(independent[CONFIGURATION_ENTRY_PATH]);
|
||||
for (const [path, data] of contents.supplementalEntries) expect(data, path).toEqual(independent[path]);
|
||||
|
||||
const rewritten = readFlsArchive(writeFlsArchive(contents.configurationXml, contents.supplementalEntries));
|
||||
expect(rewritten.configurationXml).toEqual(contents.configurationXml);
|
||||
expect(rewritten.supplementalEntries).toEqual(contents.supplementalEntries);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"composite": false,
|
||||
"declaration": false,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["."]
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
// 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'));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user