import { createHash } from 'node:crypto'; import { inflateRawSync } from 'node:zlib'; import { artifactPathCollisionKey, normalizeArtifactRelativePath } from '../../src/domain/artifact-path'; import type { ImportResourcePolicy } from '../../src/importers/resource-policy'; import { resolveImportResourcePolicy, validateImportSourceResources } from '../../src/importers/resource-policy'; import { ImportError } from '../../src/shared/errors'; export interface SafeZipInventoryEntry { index: number; relativePath: string; kind: 'file' | 'directory'; compressionMethod: 0 | 8; compressedBytes: number; uncompressedBytes: number; crc32: string; sha256?: string; } export interface SafeZipInventory { version: 1; containerSha256: string; compressedBytes: number; uncompressedBytes: number; entries: SafeZipInventoryEntry[]; } export interface VerifiedZipFile { entry: SafeZipInventoryEntry & { kind: 'file'; sha256: string }; bytes: Uint8Array; } interface CentralEntry extends SafeZipInventoryEntry { flags: number; expectedCrc32: number; localHeaderOffset: number; encodedName: Uint8Array; externalAttributes: number; madeBySystem: number; } const centralHeaderSignature = 0x02014b50; const localHeaderSignature = 0x04034b50; const endSignature = 0x06054b50; const allowedFlags = 0x080e; function zipError(code: string, message: string, details: Record = {}): never { throw new ImportError(message, [{ severity: 'error', code, message, stage: 'format-decoding', details }]); } function sha256(bytes: Uint8Array): string { return createHash('sha256').update(bytes).digest('hex'); } function crc32(bytes: Uint8Array): number { let value = 0xffffffff; for (const byte of bytes) { value ^= byte; for (let bit = 0; bit < 8; bit += 1) value = (value >>> 1) ^ (0xedb88320 & -(value & 1)); } return (value ^ 0xffffffff) >>> 0; } function findEndRecord(bytes: Uint8Array): number { const minimumOffset = Math.max(0, bytes.length - 65_557); for (let offset = bytes.length - 22; offset >= minimumOffset; offset -= 1) { if (bytes[offset] === 0x50 && bytes[offset + 1] === 0x4b && bytes[offset + 2] === 0x05 && bytes[offset + 3] === 0x06) return offset; } return -1; } function decodeEntryName(bytes: Uint8Array, flags: number, index: number): string { if (!(flags & 0x0800) && bytes.some(byte => byte > 0x7f)) { zipError('zip-entry-name-encoding-rejected', 'Nicht-ASCII-Archivpfade benötigen eine eindeutige UTF-8-Kennzeichnung.', { entryIndex: index }); } try { return new TextDecoder('utf-8', { fatal: true }).decode(bytes); } catch { zipError('zip-entry-name-encoding-rejected', 'Ein Archivpfad ist nicht gültig UTF-8-kodiert.', { entryIndex: index }); } } function canonicalEntryPath(rawName: string, index: number): { path: string; kind: 'file' | 'directory' } { if (rawName.includes('\0')) zipError('zip-entry-path-rejected', 'Archivpfade dürfen keine Nullzeichen enthalten.', { entryIndex: index }); const slashNormalized = rawName.replaceAll('\\', '/'); const kind = slashNormalized.endsWith('/') ? 'directory' : 'file'; const candidate = kind === 'directory' ? slashNormalized.slice(0, -1) : slashNormalized; const path = normalizeArtifactRelativePath(candidate); if (!path) zipError('zip-entry-path-rejected', 'Ein Archivpfad ist unsicher oder nicht kanonisch.', { entryIndex: index }); return { path, kind }; } function validateExternalType(entry: CentralEntry): void { if (entry.madeBySystem !== 3) return; const unixMode = entry.externalAttributes >>> 16; const fileType = unixMode & 0xf000; if (fileType === 0xa000) zipError('zip-link-rejected', 'Symbolische Links in Archiven werden nicht verarbeitet.', { entryIndex: entry.index }); if (fileType && fileType !== 0x8000 && fileType !== 0x4000) { zipError('zip-special-file-rejected', 'Spezialdateien in Archiven werden nicht verarbeitet.', { entryIndex: entry.index }); } if (fileType && (fileType === 0x4000) !== (entry.kind === 'directory')) { zipError('zip-entry-type-conflict', 'Dateiname und externer Archivtyp widersprechen sich.', { entryIndex: entry.index }); } } function parseCentralDirectory(bytes: Uint8Array, policy: ImportResourcePolicy): { entries: CentralEntry[]; directoryOffset: number } { if (bytes.length < 22) zipError('zip-directory-missing', 'Der ZIP-Container besitzt kein gültiges Zentralverzeichnis.', { sizeBytes: bytes.length }); const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); const endOffset = findEndRecord(bytes); if (endOffset < 0 || view.getUint32(endOffset, true) !== endSignature) { zipError('zip-directory-missing', 'Der ZIP-Container besitzt kein gültiges Zentralverzeichnis.', { sizeBytes: bytes.length }); } const commentLength = view.getUint16(endOffset + 20, true); if (endOffset + 22 + commentLength !== bytes.length) { zipError('zip-trailing-data-rejected', 'Daten außerhalb des abschließenden ZIP-Verzeichniseintrags werden nicht akzeptiert.', { endOffset, sizeBytes: bytes.length }); } const diskNumber = view.getUint16(endOffset + 4, true); const directoryDisk = view.getUint16(endOffset + 6, true); const entriesOnDisk = view.getUint16(endOffset + 8, true); const entryCount = view.getUint16(endOffset + 10, true); const directorySize = view.getUint32(endOffset + 12, true); const directoryOffset = view.getUint32(endOffset + 16, true); if (diskNumber || directoryDisk || entriesOnDisk !== entryCount) { zipError('zip-multidisk-rejected', 'Mehrteilige ZIP-Archive werden nicht verarbeitet.', { diskNumber, directoryDisk }); } if (entryCount === 0xffff || directorySize === 0xffffffff || directoryOffset === 0xffffffff) { zipError('zip64-not-accepted', 'ZIP64-Archive werden innerhalb der aktuellen Importgrenzen nicht akzeptiert.', { entryCount }); } if (!entryCount) zipError('zip-empty-rejected', 'Leere ZIP-Archive enthalten kein inventarisierbares GPO-Artefakt.'); if (entryCount > policy.maximumZipEntries) { zipError('zip-entry-limit', 'Das Archiv überschreitet die zulässige Eintragsanzahl.', { entryCount, limit: policy.maximumZipEntries }); } if (directoryOffset + directorySize !== endOffset) { zipError('zip-directory-invalid', 'Das ZIP-Zentralverzeichnis besitzt eine inkonsistente Größe oder Position.', { directoryOffset, directorySize, endOffset }); } const entries: CentralEntry[] = []; const pathKeys = new Map(); let offset = directoryOffset; let declaredTotal = 0; for (let index = 0; index < entryCount; index += 1) { if (offset + 46 > endOffset || view.getUint32(offset, true) !== centralHeaderSignature) { zipError('zip-directory-invalid', 'Ein ZIP-Zentralverzeichniseintrag ist beschädigt.', { entryIndex: index }); } const madeBySystem = view.getUint16(offset + 4, true) >>> 8; const flags = view.getUint16(offset + 8, true); const compressionMethod = view.getUint16(offset + 10, true); const expectedCrc32 = view.getUint32(offset + 16, true); const compressedBytes = view.getUint32(offset + 20, true); const uncompressedBytes = view.getUint32(offset + 24, true); const nameLength = view.getUint16(offset + 28, true); const extraLength = view.getUint16(offset + 30, true); const entryCommentLength = view.getUint16(offset + 32, true); const startDisk = view.getUint16(offset + 34, true); const externalAttributes = view.getUint32(offset + 38, true); const localHeaderOffset = view.getUint32(offset + 42, true); const nextOffset = offset + 46 + nameLength + extraLength + entryCommentLength; if (nextOffset > endOffset) zipError('zip-directory-invalid', 'Ein Zentralverzeichniseintrag liegt außerhalb seiner Grenzen.', { entryIndex: index }); if (flags & 0x0001 || flags & ~allowedFlags) { zipError('zip-flags-rejected', 'Der Archivinhalt verwendet Verschlüsselung oder nicht unterstützte ZIP-Flags.', { entryIndex: index, flags }); } if (compressionMethod !== 0 && compressionMethod !== 8) { zipError('zip-compression-method-rejected', 'Das Archiv verwendet ein nicht zugelassenes Kompressionsverfahren.', { entryIndex: index, compressionMethod }); } if (compressionMethod === 0 && (flags & 0x0006)) { zipError('zip-flags-rejected', 'Unkomprimierte ZIP-Einträge dürfen keine Deflate-Optionsflags tragen.', { entryIndex: index, flags }); } if (compressedBytes === 0xffffffff || uncompressedBytes === 0xffffffff || localHeaderOffset === 0xffffffff) { zipError('zip64-not-accepted', 'ZIP64-Einträge werden innerhalb der aktuellen Importgrenzen nicht akzeptiert.', { entryIndex: index }); } if (startDisk) zipError('zip-multidisk-rejected', 'Ein Archivinhalt verweist auf einen anderen Datenträger.', { entryIndex: index, startDisk }); if (uncompressedBytes > policy.maximumZipEntryUncompressedBytes) { zipError('zip-entry-size-limit', 'Ein einzelner Archivinhalt überschreitet das Entpacklimit.', { entryIndex: index, uncompressedBytes, limit: policy.maximumZipEntryUncompressedBytes }); } declaredTotal += uncompressedBytes; if (declaredTotal > policy.maximumZipUncompressedBytes) { zipError('zip-uncompressed-size-limit', 'Die deklarierte entpackte Archivgröße überschreitet das Limit.', { uncompressedBytes: declaredTotal, limit: policy.maximumZipUncompressedBytes }); } const encodedName = bytes.slice(offset + 46, offset + 46 + nameLength); const canonical = canonicalEntryPath(decodeEntryName(encodedName, flags, index), index); const collisionKey = artifactPathCollisionKey(canonical.path); if (!collisionKey) zipError('zip-entry-path-rejected', 'Ein Archivpfad ist nicht sicher normalisierbar.', { entryIndex: index }); const collidingIndex = pathKeys.get(collisionKey); if (collidingIndex !== undefined) { zipError('zip-entry-path-collision', 'Archivpfade kollidieren nach Unicode-/Case-Normalisierung.', { entryIndex: index, collidingIndex }); } pathKeys.set(collisionKey, index); const entry: CentralEntry = { index, relativePath: canonical.path, kind: canonical.kind, compressionMethod: compressionMethod as 0 | 8, compressedBytes, uncompressedBytes, crc32: expectedCrc32.toString(16).padStart(8, '0'), flags, expectedCrc32, localHeaderOffset, encodedName, externalAttributes, madeBySystem }; validateExternalType(entry); entries.push(entry); offset = nextOffset; } if (offset !== endOffset) zipError('zip-directory-invalid', 'Das Zentralverzeichnis wurde nicht vollständig und eindeutig gelesen.', { actualEnd: offset, expectedEnd: endOffset }); const fileKeys = new Set(entries.filter(entry => entry.kind === 'file').map(entry => artifactPathCollisionKey(entry.relativePath)!)); for (const entry of entries) { const segments = entry.relativePath.split('/'); for (let count = 1; count < segments.length; count += 1) { if (fileKeys.has(artifactPathCollisionKey(segments.slice(0, count).join('/'))!)) { zipError('zip-entry-path-collision', 'Ein Archivpfad verwendet eine Datei zugleich als Verzeichnis.', { entryIndex: entry.index }); } } } return { entries, directoryOffset }; } function inflateEntries( bytes: Uint8Array, entries: CentralEntry[], directoryOffset: number, policy: ImportResourcePolicy, onFile?: (entry: SafeZipInventoryEntry & { kind: 'file'; sha256: string }, content: Uint8Array) => void ): SafeZipInventoryEntry[] { const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); const ordered = [...entries].sort((left, right) => left.localHeaderOffset - right.localHeaderOffset); let actualTotal = 0; let totalCompressed = 0; if (ordered[0]?.localHeaderOffset !== 0) zipError('zip-leading-data-rejected', 'Daten vor dem ersten lokalen ZIP-Dateikopf werden nicht akzeptiert.'); for (let orderIndex = 0; orderIndex < ordered.length; orderIndex += 1) { const entry = ordered[orderIndex]; const offset = entry.localHeaderOffset; if (offset + 30 > directoryOffset || view.getUint32(offset, true) !== localHeaderSignature) { zipError('zip-local-header-invalid', 'Ein lokaler ZIP-Dateikopf ist beschädigt oder fehlt.', { entryIndex: entry.index }); } const localFlags = view.getUint16(offset + 6, true); const localMethod = view.getUint16(offset + 8, true); const localCrc32 = view.getUint32(offset + 14, true); const localCompressedBytes = view.getUint32(offset + 18, true); const localUncompressedBytes = view.getUint32(offset + 22, true); const localNameLength = view.getUint16(offset + 26, true); const localExtraLength = view.getUint16(offset + 28, true); const localName = bytes.subarray(offset + 30, offset + 30 + localNameLength); const dataOffset = offset + 30 + localNameLength + localExtraLength; const dataEnd = dataOffset + entry.compressedBytes; const nextBoundary = ordered[orderIndex + 1]?.localHeaderOffset ?? directoryOffset; const descriptorFieldsValid = entry.flags & 0x0008 ? (localCrc32 === 0 || localCrc32 === entry.expectedCrc32) && (localCompressedBytes === 0 || localCompressedBytes === entry.compressedBytes) && (localUncompressedBytes === 0 || localUncompressedBytes === entry.uncompressedBytes) : localCrc32 === entry.expectedCrc32 && localCompressedBytes === entry.compressedBytes && localUncompressedBytes === entry.uncompressedBytes; if (localFlags !== entry.flags || localMethod !== entry.compressionMethod || !descriptorFieldsValid || localName.length !== entry.encodedName.length || !localName.every((byte, index) => byte === entry.encodedName[index])) { zipError('zip-header-conflict', 'Lokaler Dateikopf und Zentralverzeichnis widersprechen sich.', { entryIndex: entry.index }); } if (dataOffset > directoryOffset || dataEnd > nextBoundary) { zipError('zip-entry-range-invalid', 'Komprimierte Archivdaten überlappen andere ZIP-Strukturen.', { entryIndex: entry.index }); } if (entry.flags & 0x0008) { const descriptorSize = nextBoundary - dataEnd; const descriptorOffset = descriptorSize === 16 && view.getUint32(dataEnd, true) === 0x08074b50 ? dataEnd + 4 : dataEnd; if (![12, 16].includes(descriptorSize) || view.getUint32(descriptorOffset, true) !== entry.expectedCrc32 || view.getUint32(descriptorOffset + 4, true) !== entry.compressedBytes || view.getUint32(descriptorOffset + 8, true) !== entry.uncompressedBytes) { zipError('zip-data-descriptor-invalid', 'Der ZIP-Datendeskriptor fehlt oder widerspricht dem Zentralverzeichnis.', { entryIndex: entry.index }); } } else if (dataEnd !== nextBoundary) { zipError('zip-entry-range-invalid', 'Nicht zugeordnete Daten zwischen ZIP-Einträgen werden nicht akzeptiert.', { entryIndex: entry.index }); } const compressed = bytes.subarray(dataOffset, dataEnd); let uncompressed: Uint8Array; try { uncompressed = entry.compressionMethod === 0 ? compressed : inflateRawSync(compressed, { maxOutputLength: Math.max(1, Math.min(policy.maximumZipEntryUncompressedBytes, policy.maximumZipUncompressedBytes - actualTotal)) }); } catch { zipError('zip-entry-inflate-failed', 'Ein Archivinhalt konnte innerhalb der Ressourcenlimits nicht entpackt werden.', { entryIndex: entry.index }); } if (entry.compressionMethod === 0 && entry.compressedBytes !== entry.uncompressedBytes) { zipError('zip-entry-size-mismatch', 'Ein unkomprimierter Archivinhalt besitzt widersprüchliche Größenangaben.', { entryIndex: entry.index }); } if (uncompressed.byteLength !== entry.uncompressedBytes) { zipError('zip-entry-size-mismatch', 'Deklarierte und tatsächliche entpackte Größe stimmen nicht überein.', { entryIndex: entry.index, declaredBytes: entry.uncompressedBytes, actualBytes: uncompressed.byteLength }); } const entryRatio = uncompressed.byteLength / Math.max(1, entry.compressedBytes); if (entryRatio > policy.maximumZipCompressionRatio) { zipError('zip-compression-ratio-limit', 'Das tatsächliche Kompressionsverhältnis eines Archivinhalts ist ungewöhnlich hoch.', { entryIndex: entry.index, ratio: Math.round(entryRatio * 100) / 100, limit: policy.maximumZipCompressionRatio }); } actualTotal += uncompressed.byteLength; totalCompressed += entry.compressedBytes; if (actualTotal > policy.maximumZipUncompressedBytes) { zipError('zip-uncompressed-size-limit', 'Die tatsächliche entpackte Archivgröße überschreitet das Limit.', { uncompressedBytes: actualTotal, limit: policy.maximumZipUncompressedBytes }); } if (crc32(uncompressed) !== entry.expectedCrc32) zipError('zip-entry-crc-mismatch', 'Die CRC-Prüfsumme eines Archivinhalts ist ungültig.', { entryIndex: entry.index }); if (entry.kind === 'file' && uncompressed.length >= 4 && uncompressed[0] === 0x50 && uncompressed[1] === 0x4b && [[0x03, 0x04], [0x05, 0x06], [0x07, 0x08]].some(signature => uncompressed[2] === signature[0] && uncompressed[3] === signature[1])) { zipError('zip-nested-archive-rejected', 'Verschachtelte ZIP-Archive werden nicht verarbeitet.', { entryIndex: entry.index }); } entry.sha256 = entry.kind === 'file' ? sha256(uncompressed) : undefined; if (entry.kind === 'file' && entry.sha256) { onFile?.({ index: entry.index, relativePath: entry.relativePath, kind: 'file', compressionMethod: entry.compressionMethod, compressedBytes: entry.compressedBytes, uncompressedBytes: entry.uncompressedBytes, crc32: entry.crc32, sha256: entry.sha256 }, uncompressed); } } const ratio = actualTotal / Math.max(1, totalCompressed); if (ratio > policy.maximumZipCompressionRatio) { zipError('zip-compression-ratio-limit', 'Das tatsächliche Kompressionsverhältnis des Archivs ist ungewöhnlich hoch.', { ratio: Math.round(ratio * 100) / 100, limit: policy.maximumZipCompressionRatio }); } return entries.map(({ flags: _flags, expectedCrc32: _expectedCrc32, localHeaderOffset: _localHeaderOffset, encodedName: _encodedName, externalAttributes: _externalAttributes, madeBySystem: _madeBySystem, ...entry }) => entry); } export function inventoryZipArchive(bytes: Uint8Array, overrides?: Partial): SafeZipInventory { const policy = resolveImportResourcePolicy(overrides); validateImportSourceResources({ metadata: { name: 'server-container.zip', sizeBytes: bytes.byteLength, lastModified: 0 }, bytes }, policy); const { entries, directoryOffset } = parseCentralDirectory(bytes, policy); const inventoried = inflateEntries(bytes, entries, directoryOffset, policy); return { version: 1, containerSha256: sha256(bytes), compressedBytes: bytes.byteLength, uncompressedBytes: inventoried.reduce((sum, entry) => sum + entry.uncompressedBytes, 0), entries: inventoried }; } export function readVerifiedZipFiles( bytes: Uint8Array, relativePaths: string[], overrides?: Partial, maximumSelectedBytes = 8 * 1024 * 1024 ): VerifiedZipFile[] { const policy = resolveImportResourcePolicy(overrides); if (!Number.isSafeInteger(maximumSelectedBytes) || maximumSelectedBytes < 1 || maximumSelectedBytes > policy.maximumZipUncompressedBytes) { zipError('zip-selected-size-limit-invalid', 'Das Auswahllimit für ZIP-Inhalte ist ungültig.'); } validateImportSourceResources({ metadata: { name: 'server-container.zip', sizeBytes: bytes.byteLength, lastModified: 0 }, bytes }, policy); const selectedKeys = new Set(); for (const requestedPath of relativePaths) { const normalized = normalizeArtifactRelativePath(requestedPath); const key = artifactPathCollisionKey(requestedPath); if (!normalized || !key || normalized !== requestedPath || selectedKeys.has(key)) { zipError('zip-selection-path-rejected', 'Die ZIP-Inhaltsauswahl enthält einen unsicheren oder doppelten Pfad.'); } selectedKeys.add(key); } const { entries, directoryOffset } = parseCentralDirectory(bytes, policy); const selected: VerifiedZipFile[] = []; let selectedBytes = 0; inflateEntries(bytes, entries, directoryOffset, policy, (entry, content) => { if (!selectedKeys.has(artifactPathCollisionKey(entry.relativePath)!)) return; selectedBytes += content.byteLength; if (selectedBytes > maximumSelectedBytes) { zipError('zip-selected-size-limit', 'Die ausgewählten ZIP-Inhalte überschreiten das Verarbeitungslimit.', { selectedBytes, limit: maximumSelectedBytes }); } selected.push({ entry, bytes: content.slice() }); }); return selected.sort((left, right) => left.entry.index - right.entry.index); }