import { JSDOM } from 'jsdom'; import type { GpoBackupMetadataDocument } from '../../src/domain/gpo-metadata'; import type { ImportResourcePolicy } from '../../src/importers/resource-policy'; import { resolveImportResourcePolicy, validateDecodedTextResources } from '../../src/importers/resource-policy'; import { ImportError } from '../../src/shared/errors'; import type { VerifiedZipFile } from './zip-inventory'; const parser = { id: 'gpo-backup-metadata-xml', version: '1.0.0' } as const; const guidPattern = /^\{?([a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12})\}?$/i; const dnsPattern = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i; function parserError(code: string, message: string, details: Record = {}): never { throw new ImportError(message, [{ severity: 'error', code, message, stage: 'format-decoding', details }]); } function decodeXml(bytes: Uint8Array): string { if (bytes.byteLength > 2 * 1024 * 1024) parserError('gpo-metadata-size-limit', 'Eine GPO-Metadatendatei überschreitet das Größenlimit.', { sizeBytes: bytes.byteLength }); let encoding = 'utf-8'; let content = bytes; if (bytes[0] === 0xff && bytes[1] === 0xfe) { encoding = 'utf-16le'; content = bytes.subarray(2); } else if (bytes[0] === 0xfe && bytes[1] === 0xff) { encoding = 'utf-16be'; content = bytes.subarray(2); } else if (bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) content = bytes.subarray(3); try { return new TextDecoder(encoding, { fatal: true }).decode(content); } catch { parserError('gpo-metadata-encoding-rejected', 'GPO-Metadaten sind nicht eindeutig als UTF-8 oder BOM-markiertes UTF-16 dekodierbar.'); } } function canonicalGuid(value: string): string | undefined { return guidPattern.exec(value.trim())?.[1].toLocaleLowerCase('en-US'); } function canonicalDomain(values: Map, diagnostics: Set): string | undefined { const rawDomainGuids = values.get('gpodomainguid') ?? []; const domainGuids = rawDomainGuids.map(canonicalGuid).filter((value): value is string => Boolean(value)); const hasInvalidDomainGuid = rawDomainGuids.length !== domainGuids.length; if (hasInvalidDomainGuid) diagnostics.add('gpo-metadata-domain-invalid'); const uniqueDomainGuids = [...new Set(domainGuids)]; if (uniqueDomainGuids.length > 1) diagnostics.add('gpo-metadata-domain-conflict'); if (hasInvalidDomainGuid || uniqueDomainGuids.length > 1) return undefined; if (uniqueDomainGuids.length === 1) return `domain-guid:${uniqueDomainGuids[0]}`; const rawDnsValues = [...(values.get('gpodomain') ?? []), ...(values.get('domainname') ?? [])]; const dnsValues = rawDnsValues .map(value => value.trim().replace(/\.$/, '').toLocaleLowerCase('en-US')) .filter(value => dnsPattern.test(value)); const hasInvalidDnsValue = rawDnsValues.length !== dnsValues.length; if (hasInvalidDnsValue) diagnostics.add('gpo-metadata-domain-invalid'); const uniqueDnsValues = [...new Set(dnsValues)]; if (uniqueDnsValues.length > 1) diagnostics.add('gpo-metadata-domain-conflict'); return !hasInvalidDnsValue && uniqueDnsValues.length === 1 ? `dns:${uniqueDnsValues[0]}` : undefined; } function uniqueGuid(values: string[], diagnostics: Set, field: string): string | undefined { const candidates = values.map(canonicalGuid).filter((value): value is string => Boolean(value)); const hasInvalidValue = values.length !== candidates.length; if (hasInvalidValue) diagnostics.add(`gpo-metadata-${field}-invalid`); const unique = [...new Set(candidates)]; if (unique.length > 1) diagnostics.add(`gpo-metadata-${field}-conflict`); return !hasInvalidValue && unique.length === 1 ? unique[0] : undefined; } function uniqueTimestamp(values: string[], diagnostics: Set): string | undefined { const isoTimestamp = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/i; const localTimestamp = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?$/; const trimmed = values.map(value => value.trim()); const absoluteValues = trimmed.filter(value => isoTimestamp.test(value)); const localValues = trimmed.filter(value => localTimestamp.test(value)); const hasInvalidValue = trimmed.some(value => !isoTimestamp.test(value) && !localTimestamp.test(value)); if (hasInvalidValue) diagnostics.add('gpo-metadata-backup-time-invalid'); if (localValues.length) diagnostics.add('gpo-metadata-backup-time-zone-missing'); if (absoluteValues.length && localValues.length) { diagnostics.add('gpo-metadata-backup-time-conflict'); return undefined; } if (hasInvalidValue || localValues.length) return undefined; const parsed = absoluteValues .map(value => Date.parse(value.trim())).filter(Number.isFinite).map(value => new Date(value).toISOString()); if (absoluteValues.length !== parsed.length) diagnostics.add('gpo-metadata-backup-time-invalid'); const unique = [...new Set(parsed)]; if (unique.length > 1) diagnostics.add('gpo-metadata-backup-time-conflict'); return absoluteValues.length === parsed.length && unique.length === 1 ? unique[0] : undefined; } export function parseGpoBackupMetadataXml( file: VerifiedZipFile, resourceOverrides?: Partial ): GpoBackupMetadataDocument { const text = decodeXml(file.bytes); const policy = resolveImportResourcePolicy({ ...resourceOverrides, maximumDecodedCharacters: Math.min(resourceOverrides?.maximumDecodedCharacters ?? 2 * 1024 * 1024, 2 * 1024 * 1024), maximumMarkupElements: Math.min(resourceOverrides?.maximumMarkupElements ?? 20_000, 20_000) }); validateDecodedTextResources(text, 'xml', policy); if (/<\s*(?:[A-Za-z_][\w.-]*:)?include\b/i.test(text) || /\b(?:noNamespaceSchemaLocation|schemaLocation)\s*=/i.test(text) || /<\?(?!xml(?:\s|\?>))/i.test(text)) { parserError('gpo-metadata-external-reference-rejected', 'Externe Includes, Schemas und Verarbeitungsanweisungen sind in GPO-Metadaten nicht zulässig.'); } let dom: JSDOM; try { dom = new JSDOM(text, { contentType: 'text/xml' }); } catch { parserError('gpo-metadata-xml-invalid', 'Die GPO-Metadatendatei ist kein wohlgeformtes XML.'); } const document = dom.window.document; try { if (document.getElementsByTagName('parsererror').length) parserError('gpo-metadata-xml-invalid', 'Die GPO-Metadatendatei ist kein wohlgeformtes XML.'); const diagnostics = new Set(); const values = new Map(); let secretIndicatorStatus: GpoBackupMetadataDocument['secretIndicatorStatus'] = 'not-detected'; for (const element of [...document.getElementsByTagName('*')]) { const name = element.localName.toLocaleLowerCase('en-US'); if (name === 'cpassword' || [...element.attributes].some(attribute => attribute.localName.toLocaleLowerCase('en-US') === 'cpassword')) { secretIndicatorStatus = 'present'; diagnostics.add('gpo-secret-indicator-present'); } if (!['gpoid', 'gpoguid', 'backupid', 'id', 'gpodomain', 'gpodomainguid', 'domainname', 'backuptime'].includes(name)) continue; if (name === 'id' && element.parentElement?.localName.toLocaleLowerCase('en-US') !== 'backupinst') continue; const value = element.textContent?.trim() ?? ''; if (!value || value.length > 2048 || element.children.length) { diagnostics.add('gpo-metadata-field-shape-invalid'); continue; } values.set(name, [...(values.get(name) ?? []), value]); } const gpoId = uniqueGuid([...(values.get('gpoid') ?? []), ...(values.get('gpoguid') ?? [])], diagnostics, 'gpo-id'); const backupId = uniqueGuid([...(values.get('backupid') ?? []), ...(values.get('id') ?? [])], diagnostics, 'backup-id'); const domainKey = canonicalDomain(values, diagnostics); const backupCreatedAt = uniqueTimestamp(values.get('backuptime') ?? [], diagnostics); if (!gpoId) diagnostics.add('gpo-metadata-gpo-id-missing'); if (!backupId) diagnostics.add('gpo-metadata-backup-id-missing'); if (!domainKey) diagnostics.add('gpo-metadata-domain-missing'); if (!backupCreatedAt) diagnostics.add('gpo-metadata-backup-time-missing'); const invalid = [...diagnostics].some(code => code.endsWith('-invalid') || code.endsWith('-conflict')); return { version: 1, parser, sourcePath: file.entry.relativePath, sourceSha256: file.entry.sha256, status: invalid ? 'invalid' : gpoId && backupId && domainKey && backupCreatedAt ? 'complete' : 'partial', gpoId, backupId, domainKey, backupCreatedAt, secretIndicatorStatus, diagnosticCodes: [...diagnostics].sort() }; } finally { dom.window.close(); } }