import { createHash, randomUUID } from 'node:crypto'; import { createReadStream } from 'node:fs'; import { link, lstat, mkdir, open, readFile, readdir, stat, unlink } from 'node:fs/promises'; import { isAbsolute, join, relative, resolve } from 'node:path'; import { ImportError } from '../../src/shared/errors'; export interface StoredArtifactReceipt { version: 1; storageKey: string; sha256: string; sizeBytes: number; } export interface StoreArtifactRequest { bytes: Uint8Array; expectedSha256?: string; maximumBytes: number; } export interface ReadArtifactRequest { storageKey: string; expectedSha256: string; maximumBytes: number; } export interface ArtifactStoreReconciliationRequest { referencedStorageKeys: readonly string[]; allowUnreferencedObjectRemoval: boolean; removeUnreferencedBefore: string; removeStagingBefore: string; maximumRemovals: number; } export interface ArtifactStoreReconciliationResult { version: 1; referencedObjects: number; retainedUnreferencedObjects: number; removedUnreferencedObjects: number; removedStagingFiles: number; } export interface BackendArtifactStore { put(request: StoreArtifactRequest): Promise; read(request: ReadArtifactRequest): Promise; remove(storageKey: string): Promise; reconcile(request: ArtifactStoreReconciliationRequest): Promise; } const storageKeyPattern = /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/; export function isBackendArtifactStorageKey(value: string): boolean { return storageKeyPattern.test(value); } const sha256Pattern = /^[a-f0-9]{64}$/; function storageError(code: string, message: string, details: Record = {}): ImportError { return new ImportError(message, [{ severity: 'error', code, message, details }]); } function validateMaximumBytes(value: number): void { if (!Number.isSafeInteger(value) || value < 1) throw storageError('artifact-store-limit-invalid', 'Das Artefaktspeicherlimit ist ungültig.'); } export function assertArtifactStoreOutsidePublicRoot(artifactRoot: string, publicRoot: string): void { if (!isAbsolute(artifactRoot) || !isAbsolute(publicRoot)) { throw storageError('artifact-store-root-invalid', 'Artefakt- und Webroot müssen absolute Pfade sein.'); } const artifact = resolve(artifactRoot); const published = resolve(publicRoot); const fromPublic = relative(published, artifact); if (!fromPublic || fromPublic === '.' || (!fromPublic.startsWith('..') && !isAbsolute(fromPublic))) { throw storageError('artifact-store-public-root-overlap', 'Der Artefaktspeicher muss außerhalb des veröffentlichten Webroots liegen.'); } } async function ensurePrivateDirectory(path: string): Promise { await mkdir(path, { recursive: true, mode: 0o700 }); const information = await lstat(path); if (!information.isDirectory() || information.isSymbolicLink()) { throw storageError('artifact-store-directory-rejected', 'Der Artefaktspeicherpfad ist kein reguläres Verzeichnis.'); } } async function hashFile(path: string, maximumBytes: number): Promise<{ sha256: string; sizeBytes: number }> { const hash = createHash('sha256'); let sizeBytes = 0; try { for await (const chunk of createReadStream(path, { highWaterMark: 64 * 1024 })) { const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); sizeBytes += bytes.byteLength; if (sizeBytes > maximumBytes) throw storageError('artifact-store-size-limit', 'Das gespeicherte Artefakt überschreitet das Leselimit.', { sizeBytes, limit: maximumBytes }); hash.update(bytes); } } catch (error) { if (error instanceof ImportError) throw error; throw storageError('artifact-store-read-failed', 'Das gespeicherte Artefakt konnte nicht sicher gelesen werden.'); } return { sha256: hash.digest('hex'), sizeBytes }; } export class FileSystemBackendArtifactStore implements BackendArtifactStore { private readonly root: string; private readonly stagingRoot: string; private readonly objectRoot: string; private initialization?: Promise; constructor(root: string) { if (!root.trim() || !isAbsolute(root)) throw storageError('artifact-store-root-invalid', 'Der Artefaktspeicher benötigt einen absoluten Root-Pfad.'); this.root = resolve(root); this.stagingRoot = join(this.root, 'staging'); this.objectRoot = join(this.root, 'objects'); } async put(request: StoreArtifactRequest): Promise { validateMaximumBytes(request.maximumBytes); if (request.bytes.byteLength > request.maximumBytes) { throw storageError('artifact-store-size-limit', 'Das Artefakt überschreitet das Schreiblimit.', { sizeBytes: request.bytes.byteLength, limit: request.maximumBytes }); } if (request.expectedSha256 !== undefined && !sha256Pattern.test(request.expectedSha256)) { throw storageError('artifact-store-hash-invalid', 'Der erwartete Artefakthash ist ungültig.'); } await this.initialize(); const storageKey = randomUUID(); const stagingPath = join(this.stagingRoot, `${storageKey}.staging`); const finalPath = this.objectPath(storageKey); const handle = await open(stagingPath, 'wx', 0o600); try { await handle.writeFile(request.bytes); await handle.sync(); } finally { await handle.close(); } try { const verified = await hashFile(stagingPath, request.maximumBytes); if (verified.sizeBytes !== request.bytes.byteLength) { throw storageError('artifact-store-size-mismatch', 'Geschriebene und erwartete Artefaktgröße stimmen nicht überein.'); } if (request.expectedSha256 && request.expectedSha256 !== verified.sha256) { throw storageError('artifact-store-hash-mismatch', 'Der Artefakthash stimmt nicht mit der erwarteten Prüfsumme überein.'); } await link(stagingPath, finalPath); return { version: 1, storageKey, sha256: verified.sha256, sizeBytes: verified.sizeBytes }; } catch (error) { if (error instanceof ImportError) throw error; throw storageError('artifact-store-publish-failed', 'Das Artefakt konnte nicht atomar veröffentlicht werden.'); } finally { await unlink(stagingPath).catch(() => undefined); } } async read(request: ReadArtifactRequest): Promise { validateMaximumBytes(request.maximumBytes); if (!storageKeyPattern.test(request.storageKey)) throw storageError('artifact-store-key-invalid', 'Der Artefaktspeicherschlüssel ist ungültig.'); if (!sha256Pattern.test(request.expectedSha256)) throw storageError('artifact-store-hash-invalid', 'Der erwartete Artefakthash ist ungültig.'); await this.initialize(); const path = this.objectPath(request.storageKey); let information; try { information = await lstat(path); } catch { throw storageError('artifact-store-object-missing', 'Das gespeicherte Artefakt wurde nicht gefunden.'); } if (!information.isFile() || information.isSymbolicLink()) throw storageError('artifact-store-object-rejected', 'Das gespeicherte Objekt ist keine reguläre Datei.'); if (information.size > request.maximumBytes) { throw storageError('artifact-store-size-limit', 'Das gespeicherte Artefakt überschreitet das Leselimit.', { sizeBytes: information.size, limit: request.maximumBytes }); } const verified = await hashFile(path, request.maximumBytes); if (verified.sha256 !== request.expectedSha256 || verified.sizeBytes !== information.size) { throw storageError('artifact-store-integrity-mismatch', 'Die Integritätsprüfung des gespeicherten Artefakts ist fehlgeschlagen.'); } const bytes = await readFile(path); if (bytes.byteLength !== verified.sizeBytes) throw storageError('artifact-store-size-mismatch', 'Das Artefakt wurde während des Lesens verändert.'); if (createHash('sha256').update(bytes).digest('hex') !== request.expectedSha256) { throw storageError('artifact-store-integrity-mismatch', 'Die Integritätsprüfung der gelesenen Artefaktdaten ist fehlgeschlagen.'); } const finalInformation = await stat(path); if (finalInformation.size !== verified.sizeBytes) throw storageError('artifact-store-size-mismatch', 'Das Artefakt wurde während des Lesens verändert.'); return new Uint8Array(bytes); } async remove(storageKey: string): Promise { await this.initialize(); const path = this.objectPath(storageKey); try { const information = await lstat(path); if (!information.isFile() || information.isSymbolicLink()) throw storageError('artifact-store-object-rejected', 'Das gespeicherte Objekt ist keine reguläre Datei.'); await unlink(path); return true; } catch (error) { if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') return false; throw error; } } async reconcile(request: ArtifactStoreReconciliationRequest): Promise { const objectCutoff = Date.parse(request.removeUnreferencedBefore); const stagingCutoff = Date.parse(request.removeStagingBefore); if (!Number.isFinite(objectCutoff) || !Number.isFinite(stagingCutoff) || !Number.isInteger(request.maximumRemovals) || request.maximumRemovals < 1 || request.maximumRemovals > 10_000) { throw storageError('artifact-store-reconciliation-invalid', 'Die Artefakt-Reconciliation-Konfiguration ist ungültig.'); } const referenced = new Set(request.referencedStorageKeys); if ([...referenced].some(key => !storageKeyPattern.test(key))) throw storageError('artifact-store-key-invalid', 'Eine referenzierte Speicher-ID ist ungültig.'); await this.initialize(); let removedObjects = 0; let retainedUnreferencedObjects = 0; let removedStagingFiles = 0; const objectEntries = await readdir(this.objectRoot, { withFileTypes: true }); for (const entry of objectEntries.sort((left, right) => left.name.localeCompare(right.name))) { const match = /^([a-f0-9-]{36})\.bin$/.exec(entry.name); if (!match || !storageKeyPattern.test(match[1]) || !entry.isFile() || entry.isSymbolicLink()) continue; if (referenced.has(match[1])) continue; const path = join(this.objectRoot, entry.name); const information = await lstat(path); if (!request.allowUnreferencedObjectRemoval || !information.isFile() || information.isSymbolicLink() || information.mtimeMs > objectCutoff || removedObjects + removedStagingFiles >= request.maximumRemovals) { retainedUnreferencedObjects += 1; continue; } await unlink(path); removedObjects += 1; } const stagingEntries = await readdir(this.stagingRoot, { withFileTypes: true }); for (const entry of stagingEntries.sort((left, right) => left.name.localeCompare(right.name))) { if (!/^[a-f0-9-]{36}\.staging$/.test(entry.name) || !entry.isFile() || entry.isSymbolicLink() || removedObjects + removedStagingFiles >= request.maximumRemovals) continue; const path = join(this.stagingRoot, entry.name); const information = await lstat(path); if (!information.isFile() || information.isSymbolicLink() || information.mtimeMs > stagingCutoff) continue; await unlink(path); removedStagingFiles += 1; } return { version: 1, referencedObjects: referenced.size, retainedUnreferencedObjects, removedUnreferencedObjects: removedObjects, removedStagingFiles }; } private initialize(): Promise { this.initialization ??= (async () => { await ensurePrivateDirectory(this.root); await ensurePrivateDirectory(this.stagingRoot); await ensurePrivateDirectory(this.objectRoot); })(); return this.initialization; } private objectPath(storageKey: string): string { if (!storageKeyPattern.test(storageKey)) throw storageError('artifact-store-key-invalid', 'Der Artefaktspeicherschlüssel ist ungültig.'); return join(this.objectRoot, `${storageKey}.bin`); } }