diff --git a/CHANGES.md b/CHANGES.md index 4476985..a9ccd00 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -24,6 +24,15 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand | `test/oracle.test.ts`, `test/scenarios/` | — | neu: 5 Szenarien, Ergebnis mit dem Swift-Kern abgeglichen (`tools/swift-oracle`) | | `test/routeTable.test.ts` | `TopologyJavaRouteTableTests.swift` (8 von 12 Tests) | übertragen; Geometrie- und Reducer-Tests folgen mit Editor und Dokument | +## packages/fls + +| Datei | Quelle | Art der Übertragung | +| --- | --- | --- | +| `src/zip.ts` | `Persistence/TopologyFLSArchive.swift` (Fehler, Grenzen, Pfadregeln, ZIP-Leser/-Schreiber, CRC-32) | übertragen; DEFLATE über `fflate`, mit Apples Abschneide-Verhalten nachgebildet; Breitenfaltung als NFKC nur für U+FF01–U+FFEF | +| `src/archive.ts` | ebenda, Archiv-Teil von Import und Export | übertragen, ohne die XML-Ebene | +| `test/zip.test.ts` | `TopologyProjectPersistenceTests.swift` (Archiv-Ablehnungstests, `makeMinimalZIP`, `makePayloadZIP`) | übertragen, ergänzt um DEFLATE-Grenzfälle | +| `test/corpus.test.ts` | — | neu: 23 Korpus-Dateien, gegengeprüft mit `fflate.unzipSync` | + ## tools/swift-oracle Baut den unveränderten Swift-Kern (Stand `3b5ded0`) unter Linux, nur zwei diff --git a/package-lock.json b/package-lock.json index e3e37fa..ee14279 100644 --- a/package-lock.json +++ b/package-lock.json @@ -471,6 +471,10 @@ "resolved": "packages/core", "link": true }, + "node_modules/@mdg-filius/fls": { + "resolved": "packages/fls", + "link": true + }, "node_modules/@napi-rs/lzma-linux-x64-gnu": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", @@ -1150,6 +1154,12 @@ } } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1645,6 +1655,14 @@ "name": "@mdg-filius/core", "version": "0.0.0", "license": "GPL-3.0-only" + }, + "packages/fls": { + "name": "@mdg-filius/fls", + "version": "0.0.0", + "license": "GPL-3.0-only", + "dependencies": { + "fflate": "^0.8.2" + } } } } diff --git a/package.json b/package.json index eebaf12..7a741d3 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ }, "scripts": { "test": "vitest run", - "typecheck": "tsc -b && tsc -p packages/core/test", + "typecheck": "tsc -b && tsc -p packages/core/test && tsc -p packages/fls/test", "check": "npm run typecheck && npm run test", "hooks": "git config core.hooksPath scripts/hooks", "oracle": "tools/swift-oracle/run.sh", diff --git a/packages/fls/package.json b/packages/fls/package.json new file mode 100644 index 0000000..c29989f --- /dev/null +++ b/packages/fls/package.json @@ -0,0 +1,13 @@ +{ + "name": "@mdg-filius/fls", + "version": "0.0.0", + "private": true, + "license": "GPL-3.0-only", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "dependencies": { + "fflate": "^0.8.2" + } +} diff --git a/packages/fls/src/archive.ts b/packages/fls/src/archive.ts new file mode 100644 index 0000000..213350a --- /dev/null +++ b/packages/fls/src/archive.ts @@ -0,0 +1,65 @@ +// Ported from filius-ipad (GPLv2/GPLv3): +// ios/FiliusPad/TopologyEditor/Persistence/TopologyFLSArchive.swift +// importFiliusArchiveDocument, exportFiliusArchiveWithReport (the archive part) +// Java counterpart: filius.rahmenprogramm.SzenarioVerwaltung (its save and load methods). +// +// A .fls holds the project in `projekt/konfiguration.xml` and the list of the +// user's own Java applications in `projekt/anwendungen/EigeneAnwendungen.txt` +// (plus their sources and classes). Everything besides the configuration is +// carried through unchanged: this port cannot run custom Java applications, +// but it must not destroy them either. + +import { canonicalEntryKey, FlsArchiveError, readZipEntries, writeZipEntries } from './zip.js'; + +export const CONFIGURATION_ENTRY_PATH = 'projekt/konfiguration.xml'; +export const APPLICATIONS_ENTRY_PATH = 'projekt/anwendungen/EigeneAnwendungen.txt'; + +export interface FlsArchiveContents { + /** `projekt/konfiguration.xml`, as bytes. */ + readonly configurationXml: Uint8Array; + /** Every other entry, carried through to the next save. */ + readonly supplementalEntries: ReadonlyMap; + /** All entry paths of the archive, sorted. */ + readonly entryPaths: readonly string[]; +} + +/** Opens a .fls: checks the archive and separates the configuration from the rest. */ +export function readFlsArchive(archive: Uint8Array): FlsArchiveContents { + const entries = readZipEntries(archive); + const configurationXml = entries.get(CONFIGURATION_ENTRY_PATH); + if (configurationXml === undefined) { + throw new FlsArchiveError('missingConfiguration', CONFIGURATION_ENTRY_PATH, 'The required configuration entry is missing.'); + } + const supplementalEntries = new Map(entries); + supplementalEntries.delete(CONFIGURATION_ENTRY_PATH); + return { + configurationXml, + supplementalEntries, + entryPaths: [...entries.keys()].sort(), + }; +} + +/** + * Writes a .fls from a configuration and the entries carried along. A + * carried entry may not pose as one of the two FILIUS entries under another + * spelling (`PROJEKT/Konfiguration.xml`); the application list is added + * empty when missing, as Java always writes it. + */ +export function writeFlsArchive( + configurationXml: Uint8Array, + supplementalEntries: ReadonlyMap = new Map(), +): Uint8Array { + const criticalConfigurationKey = canonicalEntryKey(CONFIGURATION_ENTRY_PATH); + const criticalApplicationsKey = canonicalEntryKey(APPLICATIONS_ENTRY_PATH); + for (const path of supplementalEntries.keys()) { + const key = canonicalEntryKey(path); + const isExactCriticalPath = path === CONFIGURATION_ENTRY_PATH || path === APPLICATIONS_ENTRY_PATH; + if ((key === criticalConfigurationKey || key === criticalApplicationsKey) && !isExactCriticalPath) { + throw new FlsArchiveError('duplicateEntry', path, 'Supplemental entry conflicts with a critical FILIUS archive path.'); + } + } + const entries = new Map(supplementalEntries); + if (!entries.has(APPLICATIONS_ENTRY_PATH)) entries.set(APPLICATIONS_ENTRY_PATH, new Uint8Array()); + entries.set(CONFIGURATION_ENTRY_PATH, configurationXml); + return writeZipEntries(entries); +} diff --git a/packages/fls/src/encoding.d.ts b/packages/fls/src/encoding.d.ts new file mode 100644 index 0000000..33dd555 --- /dev/null +++ b/packages/fls/src/encoding.d.ts @@ -0,0 +1,12 @@ +// TextEncoder/TextDecoder exist in every target (browsers, Node, workers) but +// belong to the DOM and Node type libraries, which this package does not load +// on purpose. Declared here to the extent the codec uses them. + +declare class TextDecoder { + constructor(label?: string, options?: { fatal?: boolean; ignoreBOM?: boolean }); + decode(input?: Uint8Array): string; +} + +declare class TextEncoder { + encode(input?: string): Uint8Array; +} diff --git a/packages/fls/src/index.ts b/packages/fls/src/index.ts new file mode 100644 index 0000000..3c2466a --- /dev/null +++ b/packages/fls/src/index.ts @@ -0,0 +1,3 @@ +// Public surface of the .fls codec. +export * from './archive.js'; +export * from './zip.js'; diff --git a/packages/fls/src/zip.ts b/packages/fls/src/zip.ts new file mode 100644 index 0000000..ed989bb --- /dev/null +++ b/packages/fls/src/zip.ts @@ -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 = (() => { + 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 { + 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(); +} diff --git a/packages/fls/test/corpus.test.ts b/packages/fls/test/corpus.test.ts new file mode 100644 index 0000000..c6f6b09 --- /dev/null +++ b/packages/fls/test/corpus.test.ts @@ -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); + }); + } +}); diff --git a/packages/fls/test/tsconfig.json b/packages/fls/test/tsconfig.json new file mode 100644 index 0000000..e0afcb9 --- /dev/null +++ b/packages/fls/test/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "composite": false, + "declaration": false, + "types": ["node"] + }, + "include": ["."] +} diff --git a/packages/fls/test/zip.test.ts b/packages/fls/test/zip.test.ts new file mode 100644 index 0000000..ec8c812 --- /dev/null +++ b/packages/fls/test/zip.test.ts @@ -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('\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')); + }); +}); diff --git a/packages/fls/tsconfig.json b/packages/fls/tsconfig.json new file mode 100644 index 0000000..8d72bcf --- /dev/null +++ b/packages/fls/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "types": [] + }, + "include": ["src"] +} diff --git a/tsconfig.json b/tsconfig.json index 0bc8463..2ceead3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,4 +1,11 @@ { "files": [], - "references": [{ "path": "packages/core" }] + "references": [ + { + "path": "packages/core" + }, + { + "path": "packages/fls" + } + ] }