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'; import type { SafeZipInventoryEntry, VerifiedZipFile } from './zip-inventory'; interface Entry extends SafeZipInventoryEntry { flags: number; crc: number; offset: number; encodedName: Uint8Array; madeBySystem: number; externalAttributes: number; } const centralSignature = 0x02014b50; const localSignature = 0x04034b50; const endSignature = 0x06054b50; const allowedFlags = 0x080e; function fail(code: string, message: string, details: Record = {}): never { throw new ImportError(message, [{ severity: 'error', code, message, stage: 'format-decoding', details }]); } 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 endOffset(bytes: Uint8Array): number { for (let offset = bytes.length - 22; offset >= Math.max(0, bytes.length - 65_557); offset -= 1) { if (bytes[offset] === 0x50 && bytes[offset + 1] === 0x4b && bytes[offset + 2] === 0x05 && bytes[offset + 3] === 0x06) return offset; } return -1; } function decodeName(bytes: Uint8Array, flags: number, index: number): string { if (!(flags & 0x0800) && bytes.some(byte => byte > 0x7f)) fail('baseline-zip-name-encoding-rejected', 'Nicht-ASCII-Pfade benötigen eine eindeutige UTF-8-Kennzeichnung.', { entryIndex: index }); try { return new TextDecoder('utf-8', { fatal: true }).decode(bytes); } catch { fail('baseline-zip-name-encoding-rejected', 'Ein Baseline-Archivpfad ist nicht gültig UTF-8-kodiert.', { entryIndex: index }); } } function parseEntries(bytes: Uint8Array, policy: ImportResourcePolicy): { entries: Entry[]; directoryOffset: number } { if (bytes.length < 22) fail('baseline-zip-directory-missing', 'Das Baselinepaket besitzt kein gültiges ZIP-Zentralverzeichnis.'); const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); const end = endOffset(bytes); if (end < 0 || view.getUint32(end, true) !== endSignature || end + 22 + view.getUint16(end + 20, true) !== bytes.length) { fail('baseline-zip-directory-invalid', 'Das Baselinepaket besitzt ein ungültiges ZIP-Endverzeichnis.'); } const count = view.getUint16(end + 10, true); const directorySize = view.getUint32(end + 12, true); const directoryOffset = view.getUint32(end + 16, true); if (view.getUint16(end + 4, true) || view.getUint16(end + 6, true) || view.getUint16(end + 8, true) !== count) { fail('baseline-zip-multidisk-rejected', 'Mehrteilige Baselinearchive werden nicht verarbeitet.'); } if (!count || count === 0xffff || directorySize === 0xffffffff || directoryOffset === 0xffffffff || directoryOffset + directorySize !== end) { fail('baseline-zip-directory-invalid', 'ZIP64, leere oder inkonsistente Baselinearchive werden nicht verarbeitet.'); } if (count > policy.maximumZipEntries) fail('baseline-zip-entry-limit', 'Das Baselinepaket überschreitet das Eintragslimit.', { count }); const entries: Entry[] = []; const paths = new Set(); let cursor = directoryOffset; let total = 0; for (let index = 0; index < count; index += 1) { if (cursor + 46 > end || view.getUint32(cursor, true) !== centralSignature) fail('baseline-zip-directory-invalid', 'Ein ZIP-Zentralverzeichniseintrag ist beschädigt.', { entryIndex: index }); const madeBySystem = view.getUint16(cursor + 4, true) >>> 8; const flags = view.getUint16(cursor + 8, true); const method = view.getUint16(cursor + 10, true); const crc = view.getUint32(cursor + 16, true); const compressedBytes = view.getUint32(cursor + 20, true); const uncompressedBytes = view.getUint32(cursor + 24, true); const nameLength = view.getUint16(cursor + 28, true); const extraLength = view.getUint16(cursor + 30, true); const commentLength = view.getUint16(cursor + 32, true); const startDisk = view.getUint16(cursor + 34, true); const externalAttributes = view.getUint32(cursor + 38, true); const offset = view.getUint32(cursor + 42, true); const next = cursor + 46 + nameLength + extraLength + commentLength; if (next > end || flags & 0x0001 || flags & ~allowedFlags || ![0, 8].includes(method) || startDisk || compressedBytes === 0xffffffff || uncompressedBytes === 0xffffffff || offset === 0xffffffff) { fail('baseline-zip-entry-rejected', 'Ein ZIP-Eintrag verwendet eine nicht unterstützte Struktur.', { entryIndex: index }); } if (uncompressedBytes > policy.maximumZipEntryUncompressedBytes) fail('baseline-zip-entry-size-limit', 'Ein ZIP-Eintrag überschreitet das Entpacklimit.', { entryIndex: index }); total += uncompressedBytes; if (total > policy.maximumZipUncompressedBytes) fail('baseline-zip-size-limit', 'Das Baselinepaket überschreitet das kumulierte Entpacklimit.'); const encodedName = bytes.slice(cursor + 46, cursor + 46 + nameLength); const raw = decodeName(encodedName, flags, index); if (raw.includes('\0')) fail('baseline-zip-path-rejected', 'Ein Baseline-Archivpfad enthält ein Nullzeichen.', { entryIndex: index }); const slash = raw.replaceAll('\\', '/'); const kind = slash.endsWith('/') ? 'directory' as const : 'file' as const; const relativePath = normalizeArtifactRelativePath(kind === 'directory' ? slash.slice(0, -1) : slash); const collisionKey = artifactPathCollisionKey(relativePath ?? ''); if (!relativePath || !collisionKey || paths.has(collisionKey)) fail('baseline-zip-path-rejected', 'Ein Baseline-Archivpfad ist unsicher oder kollidiert.', { entryIndex: index }); paths.add(collisionKey); if (madeBySystem === 3) { const fileType = (externalAttributes >>> 16) & 0xf000; if (fileType && fileType !== 0x8000 && fileType !== 0x4000) fail('baseline-zip-special-file-rejected', 'Links und Spezialdateien werden nicht verarbeitet.', { entryIndex: index }); if (fileType && (fileType === 0x4000) !== (kind === 'directory')) fail('baseline-zip-entry-type-conflict', 'Archivpfad und Dateityp widersprechen sich.', { entryIndex: index }); } entries.push({ index, relativePath, kind, compressionMethod: method as 0 | 8, compressedBytes, uncompressedBytes, crc32: crc.toString(16).padStart(8, '0'), flags, crc, offset, encodedName, madeBySystem, externalAttributes }); cursor = next; } if (cursor !== end) fail('baseline-zip-directory-invalid', 'Das ZIP-Zentralverzeichnis enthält nicht zugeordnete Daten.'); return { entries, directoryOffset }; } function readEntry(bytes: Uint8Array, entry: Entry, nextBoundary: number, directoryOffset: number, policy: ImportResourcePolicy): VerifiedZipFile { const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); const offset = entry.offset; if (offset + 30 > directoryOffset || view.getUint32(offset, true) !== localSignature) fail('baseline-zip-local-header-invalid', 'Ein lokaler ZIP-Dateikopf fehlt.', { entryIndex: entry.index }); const nameLength = view.getUint16(offset + 26, true); const extraLength = view.getUint16(offset + 28, true); const localName = bytes.subarray(offset + 30, offset + 30 + nameLength); const dataOffset = offset + 30 + nameLength + extraLength; const dataEnd = dataOffset + entry.compressedBytes; const descriptorSize = nextBoundary - dataEnd; const headerCrc = view.getUint32(offset + 14, true); const headerCompressed = view.getUint32(offset + 18, true); const headerUncompressed = view.getUint32(offset + 22, true); if (view.getUint16(offset + 6, true) !== entry.flags || view.getUint16(offset + 8, true) !== entry.compressionMethod || localName.length !== entry.encodedName.length || !localName.every((byte, index) => byte === entry.encodedName[index]) || dataEnd > nextBoundary) fail('baseline-zip-header-conflict', 'Lokaler Dateikopf und Zentralverzeichnis widersprechen sich.', { entryIndex: entry.index }); if (entry.flags & 0x0008) { const descriptorOffset = descriptorSize === 16 && view.getUint32(dataEnd, true) === 0x08074b50 ? dataEnd + 4 : dataEnd; if (![12, 16].includes(descriptorSize) || view.getUint32(descriptorOffset, true) !== entry.crc || view.getUint32(descriptorOffset + 4, true) !== entry.compressedBytes || view.getUint32(descriptorOffset + 8, true) !== entry.uncompressedBytes) fail('baseline-zip-data-descriptor-invalid', 'Der ZIP-Datendeskriptor ist ungültig.', { entryIndex: entry.index }); } else if (descriptorSize !== 0 || headerCrc !== entry.crc || headerCompressed !== entry.compressedBytes || headerUncompressed !== entry.uncompressedBytes) { fail('baseline-zip-header-conflict', 'Lokaler Dateikopf und Zentralverzeichnis widersprechen sich.', { entryIndex: entry.index }); } let content: Uint8Array; try { const compressed = bytes.subarray(dataOffset, dataEnd); content = entry.compressionMethod === 0 ? compressed.slice() : inflateRawSync(compressed, { maxOutputLength: policy.maximumZipEntryUncompressedBytes }); } catch { fail('baseline-zip-entry-inflate-failed', 'Ein ausgewählter Baselineinhalt konnte nicht begrenzt entpackt werden.', { entryIndex: entry.index }); } if (content.byteLength !== entry.uncompressedBytes || crc32(content) !== entry.crc) fail('baseline-zip-entry-integrity-invalid', 'Ein ausgewählter Baselineinhalt besitzt ungültige Größe oder CRC.', { entryIndex: entry.index }); const ratio = content.byteLength / Math.max(1, entry.compressedBytes); if (ratio > policy.maximumZipCompressionRatio) fail('baseline-zip-compression-ratio-limit', 'Ein ausgewählter Baselineinhalt überschreitet das Kompressionsverhältnis.', { entryIndex: entry.index }); const sha256 = createHash('sha256').update(content).digest('hex'); return { entry: { ...entry, kind: 'file', sha256 }, bytes: content }; } export interface MicrosoftBaselineZipSelection { version: 1; containerSha256: string; entries: SafeZipInventoryEntry[]; files: VerifiedZipFile[]; } export function readMicrosoftBaselineZip( bytes: Uint8Array, select: (entry: SafeZipInventoryEntry) => boolean, overrides?: Partial, maximumSelectedBytes = 32 * 1024 * 1024 ): MicrosoftBaselineZipSelection { const policy = resolveImportResourcePolicy(overrides); validateImportSourceResources({ metadata: { name: 'microsoft-baseline.zip', sizeBytes: bytes.byteLength, lastModified: 0 }, bytes }, policy); const parsed = parseEntries(bytes, policy); const ordered = parsed.entries.slice().sort((left, right) => left.offset - right.offset); const files: VerifiedZipFile[] = []; let selectedBytes = 0; ordered.forEach((entry, index) => { if (entry.kind !== 'file' || !select(entry)) return; selectedBytes += entry.uncompressedBytes; if (selectedBytes > maximumSelectedBytes) fail('baseline-zip-selected-size-limit', 'Ausgewählte Baselineinhalte überschreiten das Verarbeitungslimit.'); files.push(readEntry(bytes, entry, ordered[index + 1]?.offset ?? parsed.directoryOffset, parsed.directoryOffset, policy)); }); return { version: 1, containerSha256: createHash('sha256').update(bytes).digest('hex'), entries: parsed.entries.map(({ flags: _flags, crc: _crc, offset: _offset, encodedName: _encodedName, madeBySystem: _madeBySystem, externalAttributes: _externalAttributes, ...entry }) => entry), files: files.sort((left, right) => left.entry.index - right.entry.index) }; }