185 lines
12 KiB
TypeScript
185 lines
12 KiB
TypeScript
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<string, string | number> = {}): 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<string>();
|
|
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<ImportResourcePolicy>,
|
|
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)
|
|
};
|
|
}
|