import { createHash, randomUUID } from 'node:crypto'; import { chmod, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; import { homedir } from 'node:os'; import { isAbsolute, join, relative, resolve } from 'node:path'; import type { ReportProjection } from '../../src/reporting/report-projection'; import type { ReportEngineVersions, ReportRun } from '../../src/reporting/report-run'; export const REPORT_ARCHIVE_MANIFEST_VERSION = 1; export const REPORT_ARCHIVE_FILES = { projection: 'projection.json', management: 'management-report.html', technical: 'technical-report.html', manifest: 'manifest.json' } as const; export interface ReportArchiveManifest { version: typeof REPORT_ARCHIVE_MANIFEST_VERSION; /** Internal and confidential: never delivered together with the Management Report. */ handling: 'internal-confidential'; runId: string; archivedAt: string; generatedAt: string; projectId: string; scope: { assessmentIds?: string[]; scanRunIds?: string[] }; repositoryRevision: number; versions: ReportEngineVersions; files: Array<{ name: string; bytes: number; sha256: string }>; } export interface ArchivedReportRun { manifest: ReportArchiveManifest; projection: ReportProjection; management: string; technical: string; } const sha256 = (content: Buffer) => createHash('sha256').update(content).digest('hex'); const RUN_ID = /^\d{8}T\d{6}Z-[0-9a-f]{8}$/; /** Folder name per client: a hash, so a project ID can never form a path. */ export function reportArchiveProjectDirectory(projectId: string): string { return createHash('sha256').update(projectId, 'utf8').digest('hex').slice(0, 32); } /** * Write-once report archive outside the ASSESSOR database. A run is written into a hidden * partial folder and only then renamed into place, so a failed run leaves no half archive. * Archived files are made read-only and are never rewritten. */ export class FileSystemReportArchive { constructor(readonly root: string) {} async write(run: ReportRun, archivedAt: Date): Promise { const stamp = archivedAt.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z'); const runId = `${stamp}-${randomUUID().slice(0, 8)}`; const projectDirectory = join(this.root, reportArchiveProjectDirectory(run.projection.project.id)); await mkdir(projectDirectory, { recursive: true, mode: 0o700 }); const partial = join(projectDirectory, `.partial-${runId}`); await mkdir(partial, { mode: 0o700 }); try { const contents: Array<[string, Buffer]> = [ [REPORT_ARCHIVE_FILES.projection, Buffer.from(JSON.stringify(run.projection, null, 2), 'utf8')], [REPORT_ARCHIVE_FILES.management, Buffer.from(run.files.management, 'utf8')], [REPORT_ARCHIVE_FILES.technical, Buffer.from(run.files.technical, 'utf8')] ]; const manifest: ReportArchiveManifest = { version: REPORT_ARCHIVE_MANIFEST_VERSION, handling: 'internal-confidential', runId, archivedAt: archivedAt.toISOString(), generatedAt: run.projection.generatedAt, projectId: run.projection.project.id, scope: { assessmentIds: run.scope.assessmentIds, scanRunIds: run.scope.scanRunIds }, repositoryRevision: run.revision, versions: run.versions, files: contents.map(([name, content]) => ({ name, bytes: content.byteLength, sha256: sha256(content) })) }; contents.push([REPORT_ARCHIVE_FILES.manifest, Buffer.from(JSON.stringify(manifest, null, 2), 'utf8')]); for (const [name, content] of contents) { const path = join(partial, name); await writeFile(path, content, { flag: 'wx', mode: 0o400 }); await chmod(path, 0o444); } await rename(partial, join(projectDirectory, runId)); return manifest; } catch (error) { await rm(partial, { recursive: true, force: true }).catch(() => undefined); throw error; } } /** Reads one archived run and verifies every file against its manifest hash. */ async read(projectId: string, runId: string): Promise { if (!RUN_ID.test(runId)) throw new Error('Ungültige Report-Lauf-ID.'); const directory = join(this.root, reportArchiveProjectDirectory(projectId), runId); const manifest = JSON.parse(await readFile(join(directory, REPORT_ARCHIVE_FILES.manifest), 'utf8')) as ReportArchiveManifest; const files = new Map(); for (const entry of manifest.files) { const content = await readFile(join(directory, entry.name)); if (content.byteLength !== entry.bytes || sha256(content) !== entry.sha256) { throw new Error(`Archivierte Datei ${entry.name} stimmt nicht mit dem Manifest überein.`); } files.set(entry.name, content); } return { manifest, projection: JSON.parse(files.get(REPORT_ARCHIVE_FILES.projection)!.toString('utf8')) as ReportProjection, management: files.get(REPORT_ARCHIVE_FILES.management)!.toString('utf8'), technical: files.get(REPORT_ARCHIVE_FILES.technical)!.toString('utf8') }; } /** Removes every archived run of a client together with the client. */ async deleteProject(projectId: string): Promise { await rm(join(this.root, reportArchiveProjectDirectory(projectId)), { recursive: true, force: true, maxRetries: 3 }); } } export interface ReportArchiveEnvironment { ASSESSOR_REPORT_ARCHIVE_DIR?: string; LOCALAPPDATA?: string; XDG_DATA_HOME?: string; OneDrive?: string; OneDriveCommercial?: string; OneDriveConsumer?: string; } function defaultArchiveRoot(environment: ReportArchiveEnvironment, platform: NodeJS.Platform, home: string): string { if (platform === 'win32') return join(environment.LOCALAPPDATA || join(home, 'AppData', 'Local'), 'ASSESSOR', 'report-archive'); if (platform === 'darwin') return join(home, 'Library', 'Application Support', 'ASSESSOR', 'report-archive'); return join(environment.XDG_DATA_HOME || join(home, '.local', 'share'), 'assessor', 'report-archive'); } function inside(path: string, directory: string, platform: NodeJS.Platform): boolean { const normalize = (value: string) => platform === 'win32' ? resolve(value).toLowerCase() : resolve(value); const offset = relative(normalize(directory), normalize(path)); return offset === '' || (!offset.startsWith('..') && !isAbsolute(offset)); } /** * The archive holds complete technical customer data, so it must stay outside the repository, * the served application and synchronized OneDrive folders. */ export function resolveReportArchiveRoot( environment: ReportArchiveEnvironment, forbiddenRoots: string[], platform: NodeJS.Platform = process.platform, home: string = homedir() ): string { const configured = environment.ASSESSOR_REPORT_ARCHIVE_DIR?.trim(); if (configured && !isAbsolute(configured)) throw new Error('ASSESSOR_REPORT_ARCHIVE_DIR muss ein absoluter Pfad sein.'); const root = resolve(configured || defaultArchiveRoot(environment, platform, home)); const synced = [environment.OneDrive, environment.OneDriveCommercial, environment.OneDriveConsumer] .filter((value): value is string => Boolean(value?.trim())); if (forbiddenRoots.some(directory => inside(root, directory, platform))) { throw new Error('Das Report-Archiv darf nicht im Repository oder im ausgelieferten Anwendungsordner liegen.'); } if (synced.some(directory => inside(root, directory, platform)) || root.split(/[\\/]/).some(segment => /^OneDrive(?:\s-\s.+)?$/i.test(segment))) { throw new Error('Das Report-Archiv darf nicht in einem synchronisierten OneDrive-Ordner liegen.'); } return root; }