Assessor_2.0/src/gpo/gpo-folder-transport.ts

254 lines
9.9 KiB
TypeScript

import { artifactPathCollisionKey, normalizeArtifactRelativePath } from '../domain/artifact-path';
import { defaultImportResourcePolicy } from '../importers/resource-policy';
export interface GpoFolderFileSource {
relativePath: string;
sizeBytes: number;
readBytes(): Promise<Uint8Array>;
}
export type GpoFolderFileMetadata = Pick<GpoFolderFileSource, 'relativePath' | 'sizeBytes'>;
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<GpoFolderTransport> {
const { maximumTransportBytes } = validateGpoFolderSourceMetadata(sources, limits);
const encoder = new TextEncoder();
const collisionKeys = new Set<string>();
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
};
}