254 lines
9.9 KiB
TypeScript
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
|
|
};
|
|
}
|