import { createHash } from 'node:crypto'; import type { ImportArtifact, IngestionJob } from '../../src/domain/ingestion-persistence'; import { normalizeArtifactRelativePath } from '../../src/domain/artifact-path'; import type { AssessorRepository } from '../../src/persistence/assessor-repository'; import { RepositoryNotFoundError, RepositoryValidationError } from '../../src/persistence/repository-errors'; import type { BackendArtifactStore } from '../artifacts/backend-artifact-store'; import type { GpoInventoryExecutor } from './gpo-inventory-executor'; export const GPO_BACKUP_ANALYZER_VERSION = 'gpo-backup-policy-analysis:2.0.0'; export interface SubmitGpoBackupRequest { projectId: string; assessmentId?: string; fileName: string; bytes: Uint8Array; receivedAt: string; } export interface SubmitGpoBackupResult { version: 1; artifactId: string; jobId: string; sha256: string; sizeBytes: number; status: 'queued'; deduplicated: boolean; repositoryRevision: number; } function stableId(prefix: string, value: string): string { return `${prefix}-${createHash('sha256').update(value).digest('hex').slice(0, 32)}`; } function validatedRelativePath(fileName: string): string { if (!fileName || fileName.length > 255 || fileName.includes('/') || fileName.includes('\\')) { throw new RepositoryValidationError('Der GPO-Dateiname ist ungültig.'); } const path = normalizeArtifactRelativePath(`gpo-backups/${fileName}`); if (!path) throw new RepositoryValidationError('Der GPO-Dateiname ist kein sicherer relativer Artefaktpfad.'); return path; } export function createGpoBackupAnalysisJob(artifact: ImportArtifact, receivedAt: string): IngestionJob { const idempotencyKey = `${GPO_BACKUP_ANALYZER_VERSION}:${artifact.id}:${artifact.relativePath}:${artifact.sha256}`; return { version: 1, id: stableId('gpo-job', `${artifact.projectId}\0${idempotencyKey}`), projectId: artifact.projectId, artifactId: artifact.id, kind: 'analyze-gpo-backup', idempotencyKey, status: 'queued', attempts: 0, maxAttempts: 3, availableAt: receivedAt, createdAt: receivedAt, updatedAt: receivedAt }; } export function createGpoReprocessingJob( artifact: ImportArtifact, supersedesProcessingRunId: string, requestedAt: string ): IngestionJob { if (!supersedesProcessingRunId.trim()) throw new RepositoryValidationError('Der zu ersetzende GPO-Processing Run ist erforderlich.'); const idempotencyKey = `${GPO_BACKUP_ANALYZER_VERSION}:reprocess:${artifact.id}:${artifact.sha256}:${supersedesProcessingRunId}`; return { version: 1, id: stableId('gpo-job', `${artifact.projectId}\0${idempotencyKey}`), projectId: artifact.projectId, artifactId: artifact.id, supersedesProcessingRunId, kind: 'analyze-gpo-backup', idempotencyKey, status: 'queued', attempts: 0, maxAttempts: 3, availableAt: requestedAt, createdAt: requestedAt, updatedAt: requestedAt }; } export class GpoBackupSubmissionService { constructor( private readonly repository: AssessorRepository, private readonly artifactStore: BackendArtifactStore, private readonly inventoryExecutor: GpoInventoryExecutor, private readonly maximumBytes: number ) { if (!Number.isSafeInteger(maximumBytes) || maximumBytes < 1) throw new Error('Das GPO-Uploadlimit ist ungültig.'); } async close(): Promise { await this.inventoryExecutor.close?.(); } async submit(request: SubmitGpoBackupRequest): Promise { if (!request.projectId.trim()) throw new RepositoryValidationError('Eine Projekt-ID ist erforderlich.'); if (!Number.isFinite(Date.parse(request.receivedAt))) throw new RepositoryValidationError('Der GPO-Eingangszeitpunkt ist ungültig.'); if (request.bytes.byteLength < 1 || request.bytes.byteLength > this.maximumBytes) { throw new RepositoryValidationError('Der GPO-Container überschreitet das zulässige Größenlimit.'); } const relativePath = validatedRelativePath(request.fileName); if (!await this.repository.getProject(request.projectId)) throw new RepositoryNotFoundError(`Projekt ${request.projectId} ist nicht vorhanden.`); if (request.assessmentId) { const assessment = (await this.repository.listAssessments(request.projectId)).find(item => item.id === request.assessmentId); if (!assessment) throw new RepositoryNotFoundError('Der gewählte Timeslot ist für diesen Mandanten nicht vorhanden.'); } const sha256 = createHash('sha256').update(request.bytes).digest('hex'); const artifactId = stableId('gpo-artifact', `${request.projectId}\0${request.assessmentId ?? 'unassigned'}\0${relativePath}\0${sha256}`); const existing = (await this.repository.listImportArtifacts(request.projectId)).find(item => item.id === artifactId); let candidate: ImportArtifact; let newStorageKey: string | undefined; if (existing) { if (existing.sha256 !== sha256 || existing.sizeBytes !== request.bytes.byteLength || existing.storage.kind !== 'backend-controlled') { throw new RepositoryValidationError('Die vorhandene GPO-Artefaktidentität ist inkonsistent.'); } candidate = existing; } else { const recognition = await this.inventoryExecutor.recognize({ bytes: request.bytes }); if (recognition.recognition.status !== 'gpo-backup-candidate') throw new RepositoryValidationError('Der Container ist kein unterstützter GPO-Backupkandidat.'); const receipt = await this.artifactStore.put({ bytes: request.bytes, expectedSha256: sha256, maximumBytes: this.maximumBytes }); newStorageKey = receipt.storageKey; candidate = { version: 1, id: artifactId, projectId: request.projectId, assessmentId: request.assessmentId, kind: 'source-file', relativePath, sha256, sizeBytes: receipt.sizeBytes, sensitivity: 'secret', parseStatus: 'pending', storage: { kind: 'backend-controlled', storageKey: receipt.storageKey }, createdAt: request.receivedAt }; } try { const latestRun = existing ? (await this.repository.listProcessingRuns(candidate.id)) .sort((left, right) => Date.parse(right.completedAt) - Date.parse(left.completedAt) || right.id.localeCompare(left.id))[0] : undefined; if (latestRun) { const currentAnalysis = (await this.repository.listGpoPolicyAnalyses(request.projectId)) .find(item => item.run.sourceProcessingRunId === latestRun.id && item.run.analyzer.version === GPO_BACKUP_ANALYZER_VERSION); if (currentAnalysis) { const latestJob = (await this.repository.listIngestionJobs(request.projectId)) .filter(item => item.artifactId === candidate.id && item.kind === 'analyze-gpo-backup') .sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt) || right.id.localeCompare(left.id))[0]; if (!latestJob) throw new RepositoryNotFoundError('Der aktuelle GPO-Analysejob ist nicht vorhanden.'); return { version: 1, artifactId: candidate.id, jobId: latestJob.id, sha256, sizeBytes: candidate.sizeBytes, status: 'queued', deduplicated: true, repositoryRevision: this.repository.revision }; } } const analysisJob = latestRun ? createGpoReprocessingJob(candidate, latestRun.id, request.receivedAt) : createGpoBackupAnalysisJob(candidate, request.receivedAt); const committed = await this.repository.saveImportArtifactAndEnqueueJob(candidate, analysisJob); if (newStorageKey && !committed.artifactCreated) await this.artifactStore.remove(newStorageKey); return { version: 1, artifactId: committed.artifact.id, jobId: committed.job.id, sha256, sizeBytes: committed.artifact.sizeBytes, status: 'queued', deduplicated: Boolean(existing) || !committed.artifactCreated || !committed.jobCreated, repositoryRevision: this.repository.revision }; } catch (error) { if (newStorageKey) await this.artifactStore.remove(newStorageKey).catch(() => undefined); throw error; } } }