import { artifactPathCollisionKey, normalizeArtifactRelativePath } from '../domain/artifact-path'; import { defaultImportResourcePolicy } from '../importers/resource-policy'; export interface GpoFolderFileSource { relativePath: string; sizeBytes: number; readBytes(): Promise; } export type GpoFolderFileMetadata = Pick; export interface GpoFolderTransport { fileName: string; bytes: Uint8Array; fileCount: number; sourceBytes: number; rootName?: string; } export interface GpoFolderTransportLimits { maximumSourceBytes?: number; maximumTransportBytes?: number; } export class GpoFolderTransportError extends Error { constructor(readonly code: string, message: string) { super(message); this.name = 'GpoFolderTransportError'; } } interface PreparedEntry { relativePath: string; encodedPath: Uint8Array; payloadBytes: Uint8Array; uncompressedBytes: number; compressionMethod: 0 | 8; crc32: number; } const utf8Flag = 0x0800; const crc32Table = Uint32Array.from({ length: 256 }, (_, index) => { let value = index; for (let bit = 0; bit < 8; bit += 1) value = (value >>> 1) ^ (0xedb88320 & -(value & 1)); return value >>> 0; }); function fail(code: string, message: string): never { throw new GpoFolderTransportError(code, message); } function crc32(bytes: Uint8Array): number { let value = 0xffffffff; for (const byte of bytes) value = (value >>> 8) ^ crc32Table[(value ^ byte) & 0xff]; return (value ^ 0xffffffff) >>> 0; } function canonicalPath(value: string): string { const slashNormalized = value.replaceAll('\\', '/'); const normalized = normalizeArtifactRelativePath(slashNormalized); if (!normalized || normalized !== slashNormalized) { fail('gpo-folder-path-rejected', 'Der ausgewählte GPO-Ordner enthält einen unsicheren oder nicht kanonischen relativen Pfad.'); } return normalized; } function rootName(entries: PreparedEntry[]): string | undefined { const roots = new Set(entries.map(entry => entry.relativePath.split('/')[0])); return roots.size === 1 ? [...roots][0] : undefined; } function transportFileName(root?: string): string { const stem = (root ?? 'gpo-backup-folder') .normalize('NFC') .replace(/[^a-zA-Z0-9._-]+/g, '-') .replace(/^[.-]+|[.-]+$/g, '') .slice(0, 120) || 'gpo-backup-folder'; return `${stem}.gpo-backup.zip`; } function writeLocalHeader(view: DataView, offset: number, entry: PreparedEntry): number { view.setUint32(offset, 0x04034b50, true); view.setUint16(offset + 4, 20, true); view.setUint16(offset + 6, utf8Flag, true); view.setUint16(offset + 8, entry.compressionMethod, true); view.setUint32(offset + 14, entry.crc32, true); view.setUint32(offset + 18, entry.payloadBytes.byteLength, true); view.setUint32(offset + 22, entry.uncompressedBytes, true); view.setUint16(offset + 26, entry.encodedPath.byteLength, true); return offset + 30; } function writeCentralHeader(view: DataView, offset: number, entry: PreparedEntry, localOffset: number): number { view.setUint32(offset, 0x02014b50, true); view.setUint16(offset + 4, 20, true); view.setUint16(offset + 6, 20, true); view.setUint16(offset + 8, utf8Flag, true); view.setUint16(offset + 10, entry.compressionMethod, true); view.setUint32(offset + 16, entry.crc32, true); view.setUint32(offset + 20, entry.payloadBytes.byteLength, true); view.setUint32(offset + 24, entry.uncompressedBytes, true); view.setUint16(offset + 28, entry.encodedPath.byteLength, true); view.setUint32(offset + 42, localOffset, true); return offset + 46; } function buildZip(entries: PreparedEntry[], maximumTransportBytes: number): Uint8Array { const localBytes = entries.reduce((sum, entry) => sum + 30 + entry.encodedPath.byteLength + entry.payloadBytes.byteLength, 0); const centralBytes = entries.reduce((sum, entry) => sum + 46 + entry.encodedPath.byteLength, 0); const totalBytes = localBytes + centralBytes + 22; if (!Number.isSafeInteger(totalBytes) || totalBytes > maximumTransportBytes || totalBytes > 0xffffffff) { fail('gpo-folder-transport-size-limit', 'Der ausgewählte GPO-Ordner überschreitet das zulässige Transportlimit.'); } const output = new Uint8Array(totalBytes); const view = new DataView(output.buffer); const localOffsets: number[] = []; let offset = 0; for (const entry of entries) { localOffsets.push(offset); offset = writeLocalHeader(view, offset, entry); output.set(entry.encodedPath, offset); offset += entry.encodedPath.byteLength; output.set(entry.payloadBytes, offset); offset += entry.payloadBytes.byteLength; } const centralOffset = offset; entries.forEach((entry, index) => { offset = writeCentralHeader(view, offset, entry, localOffsets[index]); output.set(entry.encodedPath, offset); offset += entry.encodedPath.byteLength; }); view.setUint32(offset, 0x06054b50, true); view.setUint16(offset + 8, entries.length, true); view.setUint16(offset + 10, entries.length, true); view.setUint32(offset + 12, centralBytes, true); view.setUint32(offset + 16, centralOffset, true); return output; } function boundedLimit(value: number | undefined, fallback: number, ceiling: number, name: string): number { const resolved = value ?? fallback; if (!Number.isSafeInteger(resolved) || resolved < 1 || resolved > ceiling) { fail('gpo-folder-limit-invalid', `${name} ist ungueltig.`); } return resolved; } async function compressedPayload(bytes: Uint8Array): Promise<{ payloadBytes: Uint8Array; compressionMethod: 0 | 8 }> { if (typeof CompressionStream === 'undefined') return { payloadBytes: bytes, compressionMethod: 0 }; const compressor = new CompressionStream('deflate-raw'); const reader = compressor.readable.getReader(); const chunks: Uint8Array[] = []; const readCompressed = (async () => { let totalBytes = 0; while (true) { const result = await reader.read(); if (result.done) break; chunks.push(result.value); totalBytes += result.value.byteLength; } const output = new Uint8Array(totalBytes); let offset = 0; for (const chunk of chunks) { output.set(chunk, offset); offset += chunk.byteLength; } return output; })(); const writer = compressor.writable.getWriter(); await writer.write(bytes.slice()); await writer.close(); const compressed = await readCompressed; return compressed.byteLength < bytes.byteLength ? { payloadBytes: compressed, compressionMethod: 8 } : { payloadBytes: bytes, compressionMethod: 0 }; } export function validateGpoFolderSourceMetadata( sources: readonly GpoFolderFileMetadata[], limits: GpoFolderTransportLimits = {} ): { maximumSourceBytes: number; maximumTransportBytes: number } { const maximumSourceBytes = boundedLimit( limits.maximumSourceBytes, defaultImportResourcePolicy.maximumZipUncompressedBytes, defaultImportResourcePolicy.maximumZipUncompressedBytes, 'Das GPO-Quelllimit' ); const maximumTransportBytes = boundedLimit( limits.maximumTransportBytes, defaultImportResourcePolicy.maximumFileBytes, defaultImportResourcePolicy.maximumFileBytes, 'Das GPO-Transportlimit' ); if (!sources.length) fail('gpo-folder-empty', 'Der ausgewählte GPO-Backupordner enthält keine Dateien.'); if (sources.length > defaultImportResourcePolicy.maximumZipEntries || sources.length > 0xffff) { fail('gpo-folder-entry-limit', 'Der ausgewählte GPO-Ordner enthält zu viele Dateien.'); } const declaredBytes = sources.reduce((sum, source) => { if (!Number.isSafeInteger(source.sizeBytes) || source.sizeBytes < 0 || source.sizeBytes > defaultImportResourcePolicy.maximumZipEntryUncompressedBytes) { fail('gpo-folder-entry-size-invalid', 'Eine Datei des GPO-Ordners besitzt eine ungültige oder zu große Dateigröße.'); } return sum + source.sizeBytes; }, 0); if (!Number.isSafeInteger(declaredBytes) || declaredBytes > maximumSourceBytes) { fail('gpo-folder-source-size-limit', 'Der ausgewählte GPO-Ordner überschreitet das zulässige Uploadlimit.'); } return { maximumSourceBytes, maximumTransportBytes }; } export async function createGpoFolderTransport( sources: readonly GpoFolderFileSource[], limits: GpoFolderTransportLimits = {} ): Promise { const { maximumTransportBytes } = validateGpoFolderSourceMetadata(sources, limits); const encoder = new TextEncoder(); const collisionKeys = new Set(); const prepared: PreparedEntry[] = []; for (const source of sources) { const relativePath = canonicalPath(source.relativePath); const collisionKey = artifactPathCollisionKey(relativePath); if (!collisionKey || collisionKeys.has(collisionKey)) { fail('gpo-folder-path-collision', 'Dateipfade des GPO-Ordners kollidieren nach sicherer Unicode-/Groß-/Kleinschreibungsnormalisierung.'); } collisionKeys.add(collisionKey); const encodedPath = encoder.encode(relativePath); if (!encodedPath.byteLength || encodedPath.byteLength > 0xffff) { fail('gpo-folder-path-size-limit', 'Ein relativer GPO-Dateipfad ist für den Transportcontainer zu lang.'); } const bytes = await source.readBytes(); if (!(bytes instanceof Uint8Array) || bytes.byteLength !== source.sizeBytes) { fail('gpo-folder-file-size-mismatch', 'Deklarierte und gelesene Größe einer GPO-Datei stimmen nicht überein.'); } const payload = await compressedPayload(bytes); prepared.push({ relativePath, encodedPath, ...payload, uncompressedBytes: bytes.byteLength, crc32: crc32(bytes) }); } prepared.sort((left, right) => left.relativePath < right.relativePath ? -1 : left.relativePath > right.relativePath ? 1 : 0); const root = rootName(prepared); return { fileName: transportFileName(root), bytes: buildZip(prepared, maximumTransportBytes), fileCount: prepared.length, sourceBytes: prepared.reduce((sum, entry) => sum + entry.uncompressedBytes, 0), rootName: root }; }