// 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 = (() => { 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.', ); } } } /** * Foundation's `.widthInsensitive`: the Halfwidth and Fullwidth Forms block * (U+FF01…U+FFEE) folds to its NFKC form — full-width ASCII, half-width * katakana, half-width hangul (to conjoining jamo) and the symbols. Measured * against the Swift core (packages/fls/test/path-folding.*.json). */ function foldWidth(text: string): string { return text.replace(/[\uFF01-\uFFEE]/g, (character) => character.normalize('NFKC')); } /** * Unicode full case folding, which JavaScript lacks: lower, upper, lower per * character gives it (`ß` → `ss`, `fi` → `fi`, `K` (Kelvin) → `k`, `ς` → `σ`, * `İ` → `i̇`). The one exception is `ı`, which default case folding leaves * alone and the round trip would turn into `i`. */ function foldCase(text: string): string { let folded = ''; for (const character of text) { folded += character === '\u0131' ? character : character.toLowerCase().toUpperCase().toLowerCase(); } return folded; } /** * The key two paths share when they would name the same file on a case- and * width-insensitive file system: NFC, then width and case folding, per * component — `precomposedStringWithCanonicalMapping.folding([.caseInsensitive, * .widthInsensitive])` in Swift. */ export function canonicalEntryKey(path: string): string { return path .split('/') .map((component) => foldCase(foldWidth(component.normalize('NFC')))) .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 { 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(); 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(); 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): 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(); 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(); }