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:
@@ -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/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 |
|
| `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
|
## tools/swift-oracle
|
||||||
|
|
||||||
Baut den unveränderten Swift-Kern (Stand `3b5ded0`) unter Linux, nur zwei
|
Baut den unveränderten Swift-Kern (Stand `3b5ded0`) unter Linux, nur zwei
|
||||||
|
|||||||
Generated
+18
@@ -471,6 +471,10 @@
|
|||||||
"resolved": "packages/core",
|
"resolved": "packages/core",
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@mdg-filius/fls": {
|
||||||
|
"resolved": "packages/fls",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/@napi-rs/lzma-linux-x64-gnu": {
|
"node_modules/@napi-rs/lzma-linux-x64-gnu": {
|
||||||
"version": "1.5.1",
|
"version": "1.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
|
"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": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
@@ -1645,6 +1655,14 @@
|
|||||||
"name": "@mdg-filius/core",
|
"name": "@mdg-filius/core",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"license": "GPL-3.0-only"
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -11,7 +11,7 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "vitest run",
|
"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",
|
"check": "npm run typecheck && npm run test",
|
||||||
"hooks": "git config core.hooksPath scripts/hooks",
|
"hooks": "git config core.hooksPath scripts/hooks",
|
||||||
"oracle": "tools/swift-oracle/run.sh",
|
"oracle": "tools/swift-oracle/run.sh",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, Uint8Array>;
|
||||||
|
/** 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<string, Uint8Array> = 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);
|
||||||
|
}
|
||||||
Vendored
+12
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// Public surface of the .fls codec.
|
||||||
|
export * from './archive.js';
|
||||||
|
export * from './zip.js';
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
// The 23 real .fls files of the corpus (corpus/README.md): every one opens,
|
||||||
|
// the entries match what an independent ZIP reader extracts, and writing
|
||||||
|
// them back and reading again changes nothing.
|
||||||
|
|
||||||
|
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
||||||
|
import { dirname, join, relative } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { unzipSync } from 'fflate';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { CONFIGURATION_ENTRY_PATH, readFlsArchive, writeFlsArchive } from '../src/index.js';
|
||||||
|
|
||||||
|
const corpusRoot = join(dirname(fileURLToPath(import.meta.url)), '../../../corpus');
|
||||||
|
|
||||||
|
function flsFiles(directory: string): string[] {
|
||||||
|
return readdirSync(directory)
|
||||||
|
.flatMap((name) => {
|
||||||
|
const path = join(directory, name);
|
||||||
|
return statSync(path).isDirectory() ? flsFiles(path) : name.endsWith('.fls') ? [path] : [];
|
||||||
|
})
|
||||||
|
.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = flsFiles(corpusRoot);
|
||||||
|
|
||||||
|
describe('corpus archives', () => {
|
||||||
|
it('has all 23 files', () => {
|
||||||
|
expect(files).toHaveLength(23);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
it(relative(corpusRoot, file), () => {
|
||||||
|
const bytes = new Uint8Array(readFileSync(file));
|
||||||
|
const contents = readFlsArchive(bytes);
|
||||||
|
|
||||||
|
const independent = unzipSync(bytes);
|
||||||
|
const independentFiles = Object.keys(independent).filter((path) => !path.endsWith('/')).sort();
|
||||||
|
expect(contents.entryPaths).toEqual(independentFiles);
|
||||||
|
expect(contents.configurationXml).toEqual(independent[CONFIGURATION_ENTRY_PATH]);
|
||||||
|
for (const [path, data] of contents.supplementalEntries) expect(data, path).toEqual(independent[path]);
|
||||||
|
|
||||||
|
const rewritten = readFlsArchive(writeFlsArchive(contents.configurationXml, contents.supplementalEntries));
|
||||||
|
expect(rewritten.configurationXml).toEqual(contents.configurationXml);
|
||||||
|
expect(rewritten.supplementalEntries).toEqual(contents.supplementalEntries);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": true,
|
||||||
|
"composite": false,
|
||||||
|
"declaration": false,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["."]
|
||||||
|
}
|
||||||
@@ -0,0 +1,345 @@
|
|||||||
|
// Ported from filius-ipad ios/FiliusPadTests/TopologyProjectPersistenceTests.swift:
|
||||||
|
// the archive rejection tests (missing configuration, unsafe and non-portable
|
||||||
|
// paths, compression, encryption, DEFLATE, checksum, UTF-8, limits,
|
||||||
|
// duplicates) with their ZIP builders makeMinimalZIP and makePayloadZIP.
|
||||||
|
// The tests that need the XML layer follow with it.
|
||||||
|
|
||||||
|
import { deflateSync } from 'fflate';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
CONFIGURATION_ENTRY_PATH,
|
||||||
|
APPLICATIONS_ENTRY_PATH,
|
||||||
|
crc32,
|
||||||
|
FlsArchiveError,
|
||||||
|
readFlsArchive,
|
||||||
|
readZipEntries,
|
||||||
|
writeFlsArchive,
|
||||||
|
writeZipEntries,
|
||||||
|
type FlsArchiveErrorCode,
|
||||||
|
} from '../src/index.js';
|
||||||
|
|
||||||
|
const encode = (text: string) => new TextEncoder().encode(text);
|
||||||
|
|
||||||
|
class Bytes {
|
||||||
|
private readonly data: number[] = [];
|
||||||
|
|
||||||
|
uint16(value: number): this {
|
||||||
|
this.data.push(value & 0xff, (value >>> 8) & 0xff);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint32(value: number): this {
|
||||||
|
this.data.push(value & 0xff, (value >>> 8) & 0xff, (value >>> 16) & 0xff, (value >>> 24) & 0xff);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
bytes(bytes: Uint8Array): this {
|
||||||
|
this.data.push(...bytes);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
get length(): number {
|
||||||
|
return this.data.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
result(): Uint8Array {
|
||||||
|
return Uint8Array.from(this.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Entries with empty payloads and zero checksums — enough to reach the path and method checks. */
|
||||||
|
function makeMinimalZip(entries: { path: string; method: number }[]): Uint8Array {
|
||||||
|
const archive = new Bytes();
|
||||||
|
const central: { pathData: Uint8Array; method: number; offset: number }[] = [];
|
||||||
|
for (const entry of entries) {
|
||||||
|
const pathData = encode(entry.path);
|
||||||
|
const offset = archive.length;
|
||||||
|
archive.uint32(0x04034b50).uint16(20).uint16(0x0800).uint16(entry.method).uint16(0).uint16(0x0021);
|
||||||
|
archive.uint32(0).uint32(0).uint32(0).uint16(pathData.length).uint16(0).bytes(pathData);
|
||||||
|
central.push({ pathData, method: entry.method, offset });
|
||||||
|
}
|
||||||
|
const centralOffset = archive.length;
|
||||||
|
for (const record of central) {
|
||||||
|
archive.uint32(0x02014b50).uint16(20).uint16(20).uint16(0x0800).uint16(record.method).uint16(0).uint16(0x0021);
|
||||||
|
archive.uint32(0).uint32(0).uint32(0).uint16(record.pathData.length).uint16(0).uint16(0).uint16(0).uint16(0);
|
||||||
|
archive.uint32(0).uint32(record.offset).bytes(record.pathData);
|
||||||
|
}
|
||||||
|
const centralSize = archive.length - centralOffset;
|
||||||
|
archive.uint32(0x06054b50).uint16(0).uint16(0).uint16(central.length).uint16(central.length);
|
||||||
|
archive.uint32(centralSize).uint32(centralOffset).uint16(0);
|
||||||
|
return archive.result();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One entry with a real payload, method and declared expanded size. */
|
||||||
|
function makePayloadZip(
|
||||||
|
path: string,
|
||||||
|
method: number,
|
||||||
|
payload: Uint8Array,
|
||||||
|
uncompressedSize: number,
|
||||||
|
checksum = 0,
|
||||||
|
): Uint8Array {
|
||||||
|
const pathData = encode(path);
|
||||||
|
const archive = new Bytes();
|
||||||
|
archive.uint32(0x04034b50).uint16(20).uint16(0x0800).uint16(method).uint16(0).uint16(0x0021);
|
||||||
|
archive.uint32(checksum).uint32(payload.length).uint32(uncompressedSize).uint16(pathData.length).uint16(0);
|
||||||
|
archive.bytes(pathData).bytes(payload);
|
||||||
|
const centralOffset = archive.length;
|
||||||
|
archive.uint32(0x02014b50).uint16(20).uint16(20).uint16(0x0800).uint16(method).uint16(0).uint16(0x0021);
|
||||||
|
archive.uint32(checksum).uint32(payload.length).uint32(uncompressedSize).uint16(pathData.length);
|
||||||
|
archive.uint16(0).uint16(0).uint16(0).uint16(0).uint32(0).uint32(0).bytes(pathData);
|
||||||
|
archive.uint32(0x06054b50).uint16(0).uint16(0).uint16(1).uint16(1);
|
||||||
|
archive.uint32(46 + pathData.length).uint32(centralOffset).uint16(0);
|
||||||
|
return archive.result();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setUint16(data: Uint8Array, offset: number, value: number): void {
|
||||||
|
data[offset] = value & 0xff;
|
||||||
|
data[offset + 1] = (value >>> 8) & 0xff;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setUint32(data: Uint8Array, offset: number, value: number): void {
|
||||||
|
setUint16(data, offset, value & 0xffff);
|
||||||
|
setUint16(data, offset + 2, value >>> 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
function centralOffset(data: Uint8Array): number {
|
||||||
|
for (let index = 0; index + 3 < data.length; index += 1) {
|
||||||
|
if (data[index] === 0x50 && data[index + 1] === 0x4b && data[index + 2] === 0x01 && data[index + 3] === 0x02) {
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error('no central directory');
|
||||||
|
}
|
||||||
|
|
||||||
|
function archiveError(action: () => unknown): FlsArchiveError {
|
||||||
|
try {
|
||||||
|
action();
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof FlsArchiveError) return error;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new Error('expected an FlsArchiveError');
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectCode(action: () => unknown, code: FlsArchiveErrorCode): FlsArchiveError {
|
||||||
|
const error = archiveError(action);
|
||||||
|
expect(error.code).toBe(code);
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = CONFIGURATION_ENTRY_PATH;
|
||||||
|
|
||||||
|
describe('reading a .fls archive', () => {
|
||||||
|
it('rejects an archive without configuration', () => {
|
||||||
|
expectCode(() => readFlsArchive(makeMinimalZip([{ path: 'projekt/other.xml', method: 0 }])), 'missingConfiguration');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a parent-traversal path', () => {
|
||||||
|
expectCode(() => readFlsArchive(makeMinimalZip([{ path: '../konfiguration.xml', method: 0 }])), 'unsafeEntryPath');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a non-portable alias of the configuration path', () => {
|
||||||
|
const error = expectCode(
|
||||||
|
() =>
|
||||||
|
readFlsArchive(
|
||||||
|
makeMinimalZip([
|
||||||
|
{ path: config, method: 0 },
|
||||||
|
{ path: 'projekt/konfiguration.xml ', method: 0 },
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
'unsafeEntryPath',
|
||||||
|
);
|
||||||
|
expect(error.entryPath).toBe('projekt/konfiguration.xml ');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['projekt/CON.txt', 'projekt/.', 'projekt//opaque.bin'])('rejects the reserved or dot path %j', (path) => {
|
||||||
|
expectCode(() => readFlsArchive(makeMinimalZip([{ path, method: 0 }])), 'unsafeEntryPath');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['projekt/illegal?.bin', 'projekt/control\u0001.bin', 'projekt/COM¹.txt', 'projekt/LPT³.log'])(
|
||||||
|
'rejects the Windows-forbidden or superscript-reserved path %j',
|
||||||
|
(path) => {
|
||||||
|
expectCode(() => readFlsArchive(makeMinimalZip([{ path, method: 0 }])), 'unsafeEntryPath');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('rejects an unsupported compression method', () => {
|
||||||
|
expectCode(() => readFlsArchive(makeMinimalZip([{ path: config, method: 99 }])), 'unsupportedCompression');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a local filename that contradicts the central directory', () => {
|
||||||
|
const archive = makeMinimalZip([{ path: config, method: 0 }]);
|
||||||
|
archive[30] = 0x78;
|
||||||
|
|
||||||
|
const error = expectCode(() => readFlsArchive(archive), 'malformedArchive');
|
||||||
|
expect(error.detail).toContain('local filename');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an encrypted entry', () => {
|
||||||
|
const archive = makeMinimalZip([{ path: config, method: 0 }]);
|
||||||
|
setUint16(archive, 6, 0x0001);
|
||||||
|
setUint16(archive, centralOffset(archive) + 8, 0x0001);
|
||||||
|
|
||||||
|
expectCode(() => readFlsArchive(archive), 'encryptedEntry');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a malformed DEFLATE payload', () => {
|
||||||
|
const error = expectCode(
|
||||||
|
() => readFlsArchive(makePayloadZip(config, 8, Uint8Array.of(0xff), 1)),
|
||||||
|
'malformedArchive',
|
||||||
|
);
|
||||||
|
expect(error.detail).toContain('DEFLATE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('inflates a DEFLATE entry', () => {
|
||||||
|
const text = encode('hello world');
|
||||||
|
const archive = makePayloadZip('a.txt', 8, deflateSync(text), text.length, crc32(text));
|
||||||
|
|
||||||
|
expect(readZipEntries(archive).get('a.txt')).toEqual(text);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cuts a DEFLATE stream at the declared size, as Apple Compression does; the checksum decides', () => {
|
||||||
|
const text = encode('hello world');
|
||||||
|
const compressed = deflateSync(text);
|
||||||
|
|
||||||
|
const matching = makePayloadZip('a.txt', 8, compressed, 5, crc32(encode('hello')));
|
||||||
|
expect(readZipEntries(matching).get('a.txt')).toEqual(encode('hello'));
|
||||||
|
|
||||||
|
const mismatching = makePayloadZip('a.txt', 8, compressed, 5, crc32(text));
|
||||||
|
expectCode(() => readZipEntries(mismatching), 'checksumMismatch');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a DEFLATE stream that ends before the declared size', () => {
|
||||||
|
const text = encode('hello');
|
||||||
|
const error = expectCode(
|
||||||
|
() => readZipEntries(makePayloadZip('a.txt', 8, deflateSync(text), 11, crc32(text))),
|
||||||
|
'malformedArchive',
|
||||||
|
);
|
||||||
|
expect(error.detail).toBe('DEFLATE expansion produced 5 bytes; expected 11.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a checksum mismatch', () => {
|
||||||
|
const archive = writeFlsArchive(encode('<?xml version="1.0" encoding="UTF-8"?>\n<java/>\n'));
|
||||||
|
const marker = archive.findIndex((byte, index) => byte === 0x3c && archive[index + 1] === 0x3f);
|
||||||
|
archive[marker] = archive[marker]! ^ 0x01;
|
||||||
|
|
||||||
|
expectCode(() => readFlsArchive(archive), 'checksumMismatch');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects something that is not a ZIP archive', () => {
|
||||||
|
expectCode(() => readFlsArchive(encode('not-a-zip')), 'malformedArchive');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects case-variant duplicates of the configuration before extracting', () => {
|
||||||
|
expectCode(
|
||||||
|
() =>
|
||||||
|
readFlsArchive(
|
||||||
|
makeMinimalZip([
|
||||||
|
{ path: config, method: 0 },
|
||||||
|
{ path: 'PROJEKT/KONFIGURATION.XML', method: 0 },
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
'duplicateEntry',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects entry names that are not UTF-8', () => {
|
||||||
|
const archive = makeMinimalZip([{ path: config, method: 0 }]);
|
||||||
|
const central = centralOffset(archive);
|
||||||
|
archive[30] = 0xff;
|
||||||
|
archive[central + 46] = 0xff;
|
||||||
|
|
||||||
|
const error = expectCode(() => readFlsArchive(archive), 'malformedArchive');
|
||||||
|
expect(error.detail).toContain('UTF-8');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an oversized entry before inflating it', () => {
|
||||||
|
const archive = makeMinimalZip([{ path: config, method: 0 }]);
|
||||||
|
const expandedSize = 128 * 1024 * 1024 + 1;
|
||||||
|
setUint32(archive, 22, expandedSize);
|
||||||
|
setUint32(archive, centralOffset(archive) + 24, expandedSize);
|
||||||
|
|
||||||
|
expectCode(() => readFlsArchive(archive), 'archiveLimitExceeded');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects duplicate configuration entries', () => {
|
||||||
|
expectCode(
|
||||||
|
() =>
|
||||||
|
readFlsArchive(
|
||||||
|
makeMinimalZip([
|
||||||
|
{ path: config, method: 0 },
|
||||||
|
{ path: config, method: 0 },
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
'duplicateEntry',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('writing a .fls archive', () => {
|
||||||
|
const xml = encode('<?xml version="1.0" encoding="UTF-8"?>\n<java class="java.beans.XMLDecoder"/>\n');
|
||||||
|
|
||||||
|
it('round-trips the configuration and carried entries', () => {
|
||||||
|
const extra = new Map([['projekt/anwendungen/filius/Mein.java', encode('class Mein {}')]]);
|
||||||
|
|
||||||
|
const reopened = readFlsArchive(writeFlsArchive(xml, extra));
|
||||||
|
|
||||||
|
expect(reopened.configurationXml).toEqual(xml);
|
||||||
|
expect(reopened.supplementalEntries.get('projekt/anwendungen/filius/Mein.java')).toEqual(encode('class Mein {}'));
|
||||||
|
expect(reopened.supplementalEntries.get(APPLICATIONS_ENTRY_PATH)).toEqual(new Uint8Array());
|
||||||
|
expect(reopened.entryPaths).toEqual([
|
||||||
|
'projekt/anwendungen/EigeneAnwendungen.txt',
|
||||||
|
'projekt/anwendungen/filius/Mein.java',
|
||||||
|
'projekt/konfiguration.xml',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes the same bytes for the same project, whatever the entry order', () => {
|
||||||
|
const one = new Map([
|
||||||
|
['projekt/b.txt', encode('b')],
|
||||||
|
['projekt/a.txt', encode('a')],
|
||||||
|
]);
|
||||||
|
const other = new Map([
|
||||||
|
['projekt/a.txt', encode('a')],
|
||||||
|
['projekt/b.txt', encode('b')],
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(writeFlsArchive(xml, one)).toEqual(writeFlsArchive(xml, other));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects width and case aliases among carried entries', () => {
|
||||||
|
expectCode(
|
||||||
|
() =>
|
||||||
|
writeZipEntries(
|
||||||
|
new Map([
|
||||||
|
['projekt/Alpha.txt', encode('one')],
|
||||||
|
['projekt/ALPHA.TXT', encode('two')],
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
'duplicateEntry',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a carried entry posing as the configuration before it could overwrite it', () => {
|
||||||
|
const error = expectCode(
|
||||||
|
() => writeFlsArchive(xml, new Map([['projekt/konfiguration.xml ', encode('attacker')]])),
|
||||||
|
'unsafeEntryPath',
|
||||||
|
);
|
||||||
|
expect(error.entryPath).toBe('projekt/konfiguration.xml ');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a carried case variant of the configuration', () => {
|
||||||
|
expectCode(() => writeFlsArchive(xml, new Map([['Projekt/Konfiguration.XML', encode('x')]])), 'duplicateEntry');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CRC-32', () => {
|
||||||
|
it('gives the standard check value', () => {
|
||||||
|
expect(crc32(encode('123456789'))).toBe(0xcbf43926);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is checked on stored entries', () => {
|
||||||
|
const archive = writeZipEntries(new Map([['a.txt', encode('abc')]]));
|
||||||
|
expect(readZipEntries(archive).get('a.txt')).toEqual(encode('abc'));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "dist",
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
+8
-1
@@ -1,4 +1,11 @@
|
|||||||
{
|
{
|
||||||
"files": [],
|
"files": [],
|
||||||
"references": [{ "path": "packages/core" }]
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "packages/core"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "packages/fls"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user