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:
2026-09-29 20:03:07 +00:00
co-authored by Claude Opus 5.5
parent 77feeabf39
commit 750cc72c55
13 changed files with 1101 additions and 2 deletions
+562
View File
@@ -0,0 +1,562 @@
// Ported from filius-ipad (GPLv2/GPLv3):
// ios/FiliusPad/TopologyEditor/Persistence/TopologyFLSArchive.swift
// TopologyFLSArchiveError, TopologyFLSArchiveLimits,
// TopologyFLSArchivePathPolicy, TopologyFLSZIPArchive, CRC32
//
// A .fls file is a classic ZIP archive. The reader trusts nothing: sizes,
// offsets and names are checked against each other before a byte is
// expanded, and paths that could escape a directory or collide on another
// file system are rejected. The writer stores entries uncompressed with a
// fixed timestamp, so the same project always gives the same bytes.
import { Inflate } from 'fflate';
export type FlsArchiveErrorCode =
| 'malformedArchive'
| 'missingConfiguration'
| 'duplicateEntry'
| 'unsupportedCompression'
| 'encryptedEntry'
| 'unsafeEntryPath'
| 'checksumMismatch'
| 'archiveLimitExceeded'
| 'unsafeOpaquePayload';
export class FlsArchiveError extends Error {
readonly code: FlsArchiveErrorCode;
readonly entryPath: string | undefined;
readonly detail: string;
constructor(code: FlsArchiveErrorCode, entryPath: string | undefined, detail: string) {
super(entryPath === undefined ? `FILIUS archive: ${detail}` : `FILIUS archive entry '${entryPath}': ${detail}`);
this.name = 'FlsArchiveError';
this.code = code;
this.entryPath = entryPath;
this.detail = detail;
}
}
export const FLS_ARCHIVE_LIMITS = {
maxEntryCount: 10_000,
maxEntryUncompressedBytes: 128 * 1024 * 1024,
maxTotalUncompressedBytes: 256 * 1024 * 1024,
maxArchiveBytes: 256 * 1024 * 1024,
} as const;
// ── Paths ──────────────────────────────────────────────────────────────────
const RESERVED_DEVICE_NAMES: ReadonlySet<string> = (() => {
const names = new Set(['CON', 'PRN', 'AUX', 'NUL']);
for (let index = 1; index <= 9; index += 1) {
names.add(`COM${index}`);
names.add(`LPT${index}`);
}
for (const suffix of ['¹', '²', '³']) {
names.add(`COM${suffix}`);
names.add(`LPT${suffix}`);
}
return names;
})();
const FORBIDDEN_CHARACTERS = new Set(['"', '*', ':', '<', '>', '?', '|']);
/**
* Rejects what could escape the target directory or not exist on every file
* system: absolute and drive paths, `.`/`..`/empty components, backslashes,
* NUL, control characters, Windows-forbidden characters, components ending
* in a space or period, and Windows device names (`CON`, `COM1`, `LPT³` …).
*/
export function validateEntryPath(path: string): void {
const components = path.split('/');
const characters = Array.from(path);
const hasAbsolutePrefix = path.startsWith('/') || path.startsWith('\\');
const hasDrivePrefix = characters.length >= 2 && characters[1] === ':';
if (
path.length === 0 ||
path.includes('\\') ||
path.includes('\0') ||
hasAbsolutePrefix ||
hasDrivePrefix ||
components.some((component) => component === '' || component === '.' || component === '..')
) {
throw new FlsArchiveError(
'unsafeEntryPath',
path,
'Absolute, empty, dot, parent-traversal, backslash, drive-prefixed, or NUL paths are rejected.',
);
}
for (const component of components) {
const scalars = Array.from(component);
const last = scalars.at(-1);
const containsForbidden = scalars.some(
(scalar) => scalar.codePointAt(0)! <= 0x1f || FORBIDDEN_CHARACTERS.has(scalar),
);
if (last === undefined || last === ' ' || last === '.' || containsForbidden) {
throw new FlsArchiveError(
'unsafeEntryPath',
path,
'Portable archive components cannot end in a space or period or contain Windows-forbidden characters or controls.',
);
}
const stem = component.split('.')[0]!.replace(/^[ .]+|[ .]+$/g, '').toUpperCase();
if (RESERVED_DEVICE_NAMES.has(stem)) {
throw new FlsArchiveError(
'unsafeEntryPath',
path,
'Windows reserved device names are not portable archive components.',
);
}
}
}
/**
* Folds full-width and half-width forms (U+FF01…U+FFEF) to their ordinary
* counterparts — the part of NFKC that Foundation's `.widthInsensitive` does.
*/
function foldWidth(text: string): string {
return text.replace(/[!-￯]/g, (character) => character.normalize('NFKC'));
}
/**
* The key two paths share when they would name the same file on a case- and
* width-insensitive file system: NFC, width-folded, case-folded, per component.
*/
export function canonicalEntryKey(path: string): string {
return path
.split('/')
.map((component) => foldWidth(component.normalize('NFC')).toLowerCase())
.join('/');
}
// ── CRC-32 ─────────────────────────────────────────────────────────────────
const CRC_TABLE: Uint32Array = (() => {
const table = new Uint32Array(256);
for (let value = 0; value < 256; value += 1) {
let checksum = value;
for (let bit = 0; bit < 8; bit += 1) {
checksum = (checksum & 1) === 1 ? 0xedb88320 ^ (checksum >>> 1) : checksum >>> 1;
}
table[value] = checksum >>> 0;
}
return table;
})();
export function crc32(data: Uint8Array): number {
let checksum = 0xffffffff;
for (const byte of data) {
checksum = CRC_TABLE[(checksum ^ byte) & 0xff]! ^ (checksum >>> 8);
}
return (checksum ^ 0xffffffff) >>> 0;
}
// ── Reading ────────────────────────────────────────────────────────────────
const LOCAL_HEADER_SIGNATURE = 0x04034b50;
const CENTRAL_HEADER_SIGNATURE = 0x02014b50;
const END_OF_CENTRAL_DIRECTORY_SIGNATURE = 0x06054b50;
const UTF8_FLAG = 0x0800;
const ENCRYPTED_FLAG = 0x0001;
/** 1980-01-01 00:00 in MS-DOS date format — every written entry carries it. */
const FIXED_DOS_DATE = 0x0021;
const utf8Decoder = new TextDecoder('utf-8', { fatal: true });
const utf8Encoder = new TextEncoder();
/** Little-endian reads that answer 0 past the end, like the Swift helpers. */
function uint16(data: Uint8Array, offset: number): number {
if (offset < 0 || offset + 2 > data.length) return 0;
return data[offset]! | (data[offset + 1]! << 8);
}
function uint32(data: Uint8Array, offset: number): number {
if (offset < 0 || offset + 4 > data.length) return 0;
return (data[offset]! | (data[offset + 1]! << 8) | (data[offset + 2]! << 16) | (data[offset + 3]! << 24)) >>> 0;
}
function decodeUtf8(bytes: Uint8Array): string | undefined {
try {
return utf8Decoder.decode(bytes);
} catch {
return undefined;
}
}
function malformed(detail: string, entryPath?: string): FlsArchiveError {
return new FlsArchiveError('malformedArchive', entryPath, detail);
}
function limitExceeded(detail: string): FlsArchiveError {
return new FlsArchiveError('archiveLimitExceeded', undefined, detail);
}
interface EntryMetadata {
readonly path: string;
readonly compressionMethod: number;
readonly checksum: number;
readonly compressedSize: number;
readonly uncompressedSize: number;
readonly localHeaderOffset: number;
}
/** Every file entry of the archive, by path, in central-directory order. */
export function readZipEntries(archive: Uint8Array): Map<string, Uint8Array> {
if (archive.length > FLS_ARCHIVE_LIMITS.maxArchiveBytes) {
throw limitExceeded(`The compressed archive exceeds ${FLS_ARCHIVE_LIMITS.maxArchiveBytes} bytes.`);
}
const endOffset = endOfCentralDirectoryOffset(archive);
if (endOffset + 22 > archive.length) throw malformed('The end-of-central-directory record is truncated.');
const diskNumber = uint16(archive, endOffset + 4);
const centralDirectoryDisk = uint16(archive, endOffset + 6);
const entriesOnDisk = uint16(archive, endOffset + 8);
const entryCount = uint16(archive, endOffset + 10);
const centralDirectorySize = uint32(archive, endOffset + 12);
const centralDirectoryOffset = uint32(archive, endOffset + 16);
const commentLength = uint16(archive, endOffset + 20);
if (endOffset + 22 + commentLength !== archive.length) {
throw malformed('The archive comment length is inconsistent with the payload.');
}
if (diskNumber !== 0 || centralDirectoryDisk !== 0 || entriesOnDisk !== entryCount) {
throw malformed('Multi-disk ZIP archives are not supported.');
}
if (entryCount > FLS_ARCHIVE_LIMITS.maxEntryCount) {
throw limitExceeded(`The archive contains more than ${FLS_ARCHIVE_LIMITS.maxEntryCount} entries.`);
}
if (centralDirectoryOffset > endOffset || centralDirectorySize > endOffset - centralDirectoryOffset) {
throw malformed('The central directory points outside the archive.');
}
const metadata = parseCentralDirectory(archive, centralDirectoryOffset, centralDirectorySize, entryCount);
rejectDuplicateEntries(metadata);
const entries = new Map<string, Uint8Array>();
let totalUncompressedBytes = 0;
for (const entry of metadata) {
totalUncompressedBytes += entry.uncompressedSize;
if (totalUncompressedBytes > FLS_ARCHIVE_LIMITS.maxTotalUncompressedBytes) {
throw limitExceeded(`The expanded archive exceeds ${FLS_ARCHIVE_LIMITS.maxTotalUncompressedBytes} bytes.`);
}
entries.set(entry.path, extract(entry, archive));
}
return entries;
}
function endOfCentralDirectoryOffset(data: Uint8Array): number {
if (data.length < 22) throw malformed('The payload is too short to be a ZIP archive.');
const earliestOffset = Math.max(0, data.length - 65_557);
for (let offset = data.length - 22; offset >= earliestOffset; offset -= 1) {
if (uint32(data, offset) !== END_OF_CENTRAL_DIRECTORY_SIGNATURE || offset + 22 > data.length) continue;
if (offset + 22 + uint16(data, offset + 20) === data.length) return offset;
}
throw malformed('The ZIP end-of-central-directory record was not found.');
}
function parseCentralDirectory(data: Uint8Array, offset: number, size: number, entryCount: number): EntryMetadata[] {
let cursor = offset;
const end = offset + size;
const entries: EntryMetadata[] = [];
for (let index = 0; index < entryCount; index += 1) {
if (cursor > end || end - cursor < 46 || uint32(data, cursor) !== CENTRAL_HEADER_SIGNATURE) {
throw malformed('A central-directory entry is truncated or invalid.');
}
const flags = uint16(data, cursor + 8);
const method = uint16(data, cursor + 10);
const checksum = uint32(data, cursor + 16);
const compressedSize = uint32(data, cursor + 20);
const uncompressedSize = uint32(data, cursor + 24);
const pathLength = uint16(data, cursor + 28);
const extraLength = uint16(data, cursor + 30);
const commentLength = uint16(data, cursor + 32);
const diskStart = uint16(data, cursor + 34);
const localHeaderOffset = uint32(data, cursor + 42);
const trailingLength = pathLength + extraLength + commentLength;
if (trailingLength > end - cursor - 46) {
throw malformed('A central-directory filename or extra field is truncated.');
}
if (diskStart !== 0) throw malformed('Multi-disk ZIP entries are not supported.');
const path = decodeUtf8(data.subarray(cursor + 46, cursor + 46 + pathLength));
if (path === undefined) {
// Swift counts only the entries already kept (directories are skipped).
throw new FlsArchiveError(
'malformedArchive',
`central-directory-entry-${entries.length}`,
'An archive filename is not valid UTF-8.',
);
}
validateEntryPath(path);
if ((flags & ENCRYPTED_FLAG) !== 0) {
throw new FlsArchiveError('encryptedEntry', path, 'Encrypted ZIP entries are not supported.');
}
if (method !== 0 && method !== 8) {
throw new FlsArchiveError(
'unsupportedCompression',
path,
`Compression method ${method} is not supported; expected stored or DEFLATE.`,
);
}
if (uncompressedSize > FLS_ARCHIVE_LIMITS.maxEntryUncompressedBytes) {
throw new FlsArchiveError(
'archiveLimitExceeded',
path,
`The expanded entry exceeds ${FLS_ARCHIVE_LIMITS.maxEntryUncompressedBytes} bytes.`,
);
}
if (!path.endsWith('/')) {
entries.push({ path, compressionMethod: method, checksum, compressedSize, uncompressedSize, localHeaderOffset });
}
cursor += 46 + trailingLength;
}
if (cursor !== end) throw malformed('The central-directory size does not match its entries.');
return entries;
}
function rejectDuplicateEntries(metadata: readonly EntryMetadata[]): void {
const canonicalPaths = new Map<string, string>();
for (const entry of metadata) {
const canonical = canonicalEntryKey(entry.path);
const existing = canonicalPaths.get(canonical);
if (existing !== undefined) {
throw new FlsArchiveError('duplicateEntry', entry.path, `Portable archive path collides with '${existing}'.`);
}
canonicalPaths.set(canonical, entry.path);
}
}
function extract(entry: EntryMetadata, archive: Uint8Array): Uint8Array {
const localOffset = entry.localHeaderOffset;
if (
localOffset > archive.length ||
archive.length - localOffset < 30 ||
uint32(archive, localOffset) !== LOCAL_HEADER_SIGNATURE
) {
throw malformed('The local file header is missing or truncated.', entry.path);
}
const localFlags = uint16(archive, localOffset + 6);
const localMethod = uint16(archive, localOffset + 8);
const localPathLength = uint16(archive, localOffset + 26);
const localExtraLength = uint16(archive, localOffset + 28);
if ((localFlags & ENCRYPTED_FLAG) !== 0 || localMethod !== entry.compressionMethod) {
throw malformed('The local header conflicts with the central directory.', entry.path);
}
const headerTailLength = localPathLength + localExtraLength;
if (headerTailLength > archive.length - localOffset - 30) {
throw malformed('The local filename or extra field is truncated.', entry.path);
}
const localPath = decodeUtf8(archive.subarray(localOffset + 30, localOffset + 30 + localPathLength));
if (localPath === undefined || localPath !== entry.path) {
throw malformed('The local filename conflicts with the central directory.', entry.path);
}
const payloadOffset = localOffset + 30 + headerTailLength;
if (entry.compressedSize > archive.length - payloadOffset) {
throw malformed('The compressed entry payload is truncated.', entry.path);
}
const compressed = archive.slice(payloadOffset, payloadOffset + entry.compressedSize);
let payload: Uint8Array;
if (entry.compressionMethod === 0) {
if (compressed.length !== entry.uncompressedSize) {
throw malformed('A stored entry has inconsistent compressed and expanded sizes.', entry.path);
}
payload = compressed;
} else {
payload = inflate(compressed, entry.uncompressedSize, entry.path);
}
if (crc32(payload) !== entry.checksum) {
throw new FlsArchiveError('checksumMismatch', entry.path, 'The CRC-32 checksum does not match the expanded entry.');
}
return payload;
}
/**
* Raw DEFLATE into exactly `expectedSize` bytes. Like Apple's
* `compression_decode_buffer`, output beyond the declared size is cut off
* (the checksum then decides), and a stream that ends short is malformed.
*/
function inflate(compressed: Uint8Array, expectedSize: number, entryPath: string): Uint8Array {
if (expectedSize === 0) return new Uint8Array();
if (compressed.length === 0) throw malformed('A DEFLATE entry has no compressed payload.', entryPath);
const output = new Uint8Array(expectedSize);
let written = 0;
const stream = new Inflate((chunk) => {
if (written >= expectedSize) return;
const take = Math.min(chunk.length, expectedSize - written);
output.set(chunk.subarray(0, take), written);
written += take;
});
try {
stream.push(compressed, true);
} catch {
// A broken stream keeps whatever was decoded before the break, as zlib does.
}
if (written !== expectedSize) {
throw malformed(`DEFLATE expansion produced ${written} bytes; expected ${expectedSize}.`, entryPath);
}
return output;
}
// ── Writing ────────────────────────────────────────────────────────────────
class ByteWriter {
private buffer = new Uint8Array(1024);
length = 0;
private reserve(extra: number): void {
if (this.length + extra <= this.buffer.length) return;
let capacity = this.buffer.length * 2;
while (capacity < this.length + extra) capacity *= 2;
const next = new Uint8Array(capacity);
next.set(this.buffer.subarray(0, this.length));
this.buffer = next;
}
uint16(value: number): void {
this.reserve(2);
this.buffer[this.length++] = value & 0xff;
this.buffer[this.length++] = (value >>> 8) & 0xff;
}
uint32(value: number): void {
this.reserve(4);
this.buffer[this.length++] = value & 0xff;
this.buffer[this.length++] = (value >>> 8) & 0xff;
this.buffer[this.length++] = (value >>> 16) & 0xff;
this.buffer[this.length++] = (value >>> 24) & 0xff;
}
bytes(data: Uint8Array): void {
this.reserve(data.length);
this.buffer.set(data, this.length);
this.length += data.length;
}
result(): Uint8Array {
return this.buffer.slice(0, this.length);
}
}
function enforceArchiveSize(length: number, entryPath?: string): void {
if (length > FLS_ARCHIVE_LIMITS.maxArchiveBytes) {
throw new FlsArchiveError(
'archiveLimitExceeded',
entryPath,
`The final ZIP archive exceeds ${FLS_ARCHIVE_LIMITS.maxArchiveBytes} bytes including headers.`,
);
}
}
/** Swift's `String` order: Unicode scalar values of the NFC form. */
function compareScalars(lhs: string, rhs: string): number {
const left = Array.from(lhs.normalize('NFC'), (character) => character.codePointAt(0)!);
const right = Array.from(rhs.normalize('NFC'), (character) => character.codePointAt(0)!);
for (let index = 0; index < Math.min(left.length, right.length); index += 1) {
if (left[index] !== right[index]) return left[index]! - right[index]!;
}
return left.length - right.length;
}
/**
* A stored (uncompressed) classic ZIP with the entries sorted by path, UTF-8
* names and the fixed 1980-01-01 timestamp — byte-identical for equal input.
*/
export function writeZipEntries(entries: ReadonlyMap<string, Uint8Array>): Uint8Array {
if (entries.size > FLS_ARCHIVE_LIMITS.maxEntryCount || entries.size > 0xffff) {
throw limitExceeded('The archive contains too many entries for supported ZIP output.');
}
const canonicalPaths = new Map<string, string>();
for (const path of entries.keys()) {
validateEntryPath(path);
const canonical = canonicalEntryKey(path);
const existing = canonicalPaths.get(canonical);
if (existing !== undefined && existing !== path) {
throw new FlsArchiveError('duplicateEntry', path, `Portable archive path collides with '${existing}'.`);
}
canonicalPaths.set(canonical, path);
}
let totalUncompressedBytes = 0;
for (const [path, payload] of entries) {
if (payload.length > FLS_ARCHIVE_LIMITS.maxEntryUncompressedBytes) {
throw new FlsArchiveError(
'archiveLimitExceeded',
path,
`The output entry exceeds ${FLS_ARCHIVE_LIMITS.maxEntryUncompressedBytes} bytes.`,
);
}
totalUncompressedBytes += payload.length;
if (totalUncompressedBytes > FLS_ARCHIVE_LIMITS.maxTotalUncompressedBytes) {
throw limitExceeded(`The output archive exceeds ${FLS_ARCHIVE_LIMITS.maxTotalUncompressedBytes} expanded bytes.`);
}
}
const archive = new ByteWriter();
const written: { pathData: Uint8Array; checksum: number; size: number; localHeaderOffset: number }[] = [];
for (const path of [...entries.keys()].sort(compareScalars)) {
const payload = entries.get(path)!;
const pathData = utf8Encoder.encode(path);
if (pathData.length > 0xffff) {
throw malformed('An archive path cannot be represented as a classic ZIP filename.', path);
}
const checksum = crc32(payload);
const localHeaderOffset = archive.length;
archive.uint32(LOCAL_HEADER_SIGNATURE);
archive.uint16(20);
archive.uint16(UTF8_FLAG);
archive.uint16(0);
archive.uint16(0);
archive.uint16(FIXED_DOS_DATE);
archive.uint32(checksum);
archive.uint32(payload.length);
archive.uint32(payload.length);
archive.uint16(pathData.length);
archive.uint16(0);
archive.bytes(pathData);
archive.bytes(payload);
enforceArchiveSize(archive.length, path);
written.push({ pathData, checksum, size: payload.length, localHeaderOffset });
}
const centralDirectoryOffset = archive.length;
for (const entry of written) {
archive.uint32(CENTRAL_HEADER_SIGNATURE);
archive.uint16(20);
archive.uint16(20);
archive.uint16(UTF8_FLAG);
archive.uint16(0);
archive.uint16(0);
archive.uint16(FIXED_DOS_DATE);
archive.uint32(entry.checksum);
archive.uint32(entry.size);
archive.uint32(entry.size);
archive.uint16(entry.pathData.length);
archive.uint16(0);
archive.uint16(0);
archive.uint16(0);
archive.uint16(0);
archive.uint32(0);
archive.uint32(entry.localHeaderOffset);
archive.bytes(entry.pathData);
enforceArchiveSize(archive.length);
}
const centralDirectorySize = archive.length - centralDirectoryOffset;
archive.uint32(END_OF_CENTRAL_DIRECTORY_SIGNATURE);
archive.uint16(0);
archive.uint16(0);
archive.uint16(written.length);
archive.uint16(written.length);
archive.uint32(centralDirectorySize);
archive.uint32(centralDirectoryOffset);
archive.uint16(0);
enforceArchiveSize(archive.length);
return archive.result();
}