Files
mdg-filius/packages/fls/src/zip.ts
T
julian.kiedaischandClaude Opus 5.5 0c843dc3de fix(fls): archive path folding measured against Swift
41 path pairs replayed in the Swift core decide which spellings name
the same file. Two corrections: full Unicode case folding (ß, ligatures,
final sigma, micro, long s) instead of toLowerCase, and width folding of
the whole Halfwidth and Fullwidth Forms block. Swift treats composed and
decomposed spellings as one dictionary key; here the writer refuses both.

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

581 lines
22 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 (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.',
);
}
}
}
/**
* 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<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();
}