164 lines
7.5 KiB
TypeScript
164 lines
7.5 KiB
TypeScript
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<ReportArchiveManifest> {
|
|
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<ArchivedReportRun> {
|
|
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<string, Buffer>();
|
|
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<void> {
|
|
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;
|
|
}
|