Manifestbasierter Production-Updater 3.3, Modulnavigation, optionales Produktbild Studio, Listing-Studio-Aktionsleiste sowie plattformübergreifende Windows-/Linux-Release-Gates.
174 lines
9.6 KiB
JavaScript
174 lines
9.6 KiB
JavaScript
import { createHash, createPublicKey, verify as verifySignature } from 'crypto';
|
|
import { posix as pathPosix } from 'path';
|
|
import { PlatformError } from '../../kernel/errors.mjs';
|
|
import { validateModuleManifest } from '../../kernel/module-contract.mjs';
|
|
|
|
export const MODULE_PACKAGE_FORMAT = 'vendoo.module.package';
|
|
export const MODULE_PACKAGE_SCHEMA = 1;
|
|
export const MAX_PACKAGE_BYTES = 10 * 1024 * 1024;
|
|
export const MAX_FILE_COUNT = 200;
|
|
export const MAX_FILE_BYTES = 2 * 1024 * 1024;
|
|
|
|
function stable(value) {
|
|
if (Array.isArray(value)) return value.map(stable);
|
|
if (value && typeof value === 'object') {
|
|
return Object.keys(value).sort().reduce((result, key) => {
|
|
result[key] = stable(value[key]);
|
|
return result;
|
|
}, {});
|
|
}
|
|
return value;
|
|
}
|
|
|
|
export function canonicalJson(value) {
|
|
return JSON.stringify(stable(value));
|
|
}
|
|
|
|
function sha256(value) {
|
|
return createHash('sha256').update(value).digest('hex');
|
|
}
|
|
|
|
export function packageIntegrityPayload(modulePackage) {
|
|
return {
|
|
format: MODULE_PACKAGE_FORMAT,
|
|
schemaVersion: MODULE_PACKAGE_SCHEMA,
|
|
manifest: modulePackage.manifest,
|
|
runtime: modulePackage.runtime,
|
|
files: modulePackage.files.map(file => ({ path: file.path, sha256: file.sha256, size: file.size })),
|
|
};
|
|
}
|
|
|
|
export function calculatePackageDigest(modulePackage) {
|
|
return sha256(canonicalJson(packageIntegrityPayload(modulePackage)));
|
|
}
|
|
|
|
function safeRelativePath(value) {
|
|
const raw = String(value || '').replace(/\\/g, '/').trim();
|
|
const normalized = pathPosix.normalize(raw);
|
|
if (!raw || normalized === '.' || normalized.startsWith('../') || normalized.includes('/../') || normalized.startsWith('/') || /^[A-Za-z]:/.test(raw) || raw.includes('\0')) {
|
|
throw new PlatformError(`Unsicherer Moduldateipfad: ${raw || '(leer)'}`, {
|
|
code: 'MODULE_PACKAGE_PATH_INVALID', status: 400, expose: true,
|
|
});
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
function validateRuntime(runtime = {}) {
|
|
const mode = String(runtime.mode || 'declarative').trim();
|
|
if (!['builtin-reference', 'declarative', 'isolated'].includes(mode)) {
|
|
throw new PlatformError(`Nicht unterstützter Modul-Runtime-Modus: ${mode}`, { code: 'MODULE_RUNTIME_INVALID', status: 400, expose: true });
|
|
}
|
|
const entry = runtime.entry === undefined || runtime.entry === null || runtime.entry === '' ? null : safeRelativePath(runtime.entry);
|
|
if (mode === 'declarative' && entry) {
|
|
throw new PlatformError('Deklarative Module dürfen keinen ausführbaren Einstiegspunkt besitzen.', { code: 'MODULE_RUNTIME_EXECUTABLE_FORBIDDEN', status: 400, expose: true });
|
|
}
|
|
if (mode === 'isolated' && !entry) {
|
|
throw new PlatformError('Isolierte Module benötigen einen Einstiegspunkt.', { code: 'MODULE_RUNTIME_ENTRY_REQUIRED', status: 400, expose: true });
|
|
}
|
|
return Object.freeze({ mode, entry });
|
|
}
|
|
|
|
function validateFiles(files, runtime) {
|
|
if (!Array.isArray(files)) throw new PlatformError('Moduldateien fehlen.', { code: 'MODULE_PACKAGE_FILES_INVALID', status: 400, expose: true });
|
|
if (files.length > MAX_FILE_COUNT) throw new PlatformError('Das Modulpaket enthält zu viele Dateien.', { code: 'MODULE_PACKAGE_TOO_MANY_FILES', status: 400, expose: true });
|
|
const seen = new Set();
|
|
let total = 0;
|
|
const normalized = files.map(item => {
|
|
if (!item || typeof item !== 'object' || Array.isArray(item)) throw new PlatformError('Ungültiger Moduldateieintrag.', { code: 'MODULE_PACKAGE_FILE_INVALID', status: 400, expose: true });
|
|
const path = safeRelativePath(item.path);
|
|
if (seen.has(path)) throw new PlatformError(`Doppelte Moduldatei: ${path}`, { code: 'MODULE_PACKAGE_FILE_DUPLICATE', status: 400, expose: true });
|
|
seen.add(path);
|
|
const content = String(item.content || '');
|
|
let buffer;
|
|
try { buffer = Buffer.from(content, 'base64'); } catch { buffer = null; }
|
|
if (!buffer || buffer.length > MAX_FILE_BYTES) throw new PlatformError(`Moduldatei ist ungültig oder zu groß: ${path}`, { code: 'MODULE_PACKAGE_FILE_TOO_LARGE', status: 400, expose: true });
|
|
const digest = sha256(buffer);
|
|
if (!/^[a-f0-9]{64}$/.test(String(item.sha256 || '')) || digest !== String(item.sha256)) {
|
|
throw new PlatformError(`Prüfsumme stimmt nicht: ${path}`, { code: 'MODULE_PACKAGE_FILE_HASH_MISMATCH', status: 400, expose: true });
|
|
}
|
|
if (Number(item.size) !== buffer.length) throw new PlatformError(`Dateigröße stimmt nicht: ${path}`, { code: 'MODULE_PACKAGE_FILE_SIZE_MISMATCH', status: 400, expose: true });
|
|
total += buffer.length;
|
|
if (runtime.mode === 'declarative' && /\.(?:mjs|cjs|js|node|exe|dll|so|dylib|ps1|bat|cmd|sh)$/i.test(path)) {
|
|
throw new PlatformError(`Deklaratives Modul enthält ausführbaren Code: ${path}`, { code: 'MODULE_PACKAGE_EXECUTABLE_FORBIDDEN', status: 400, expose: true });
|
|
}
|
|
return Object.freeze({ path, sha256: digest, size: buffer.length, content });
|
|
});
|
|
if (total > MAX_PACKAGE_BYTES) throw new PlatformError('Das Modulpaket ist zu groß.', { code: 'MODULE_PACKAGE_TOO_LARGE', status: 400, expose: true });
|
|
if (runtime.entry && !seen.has(runtime.entry)) throw new PlatformError('Der Runtime-Einstiegspunkt fehlt im Paket.', { code: 'MODULE_RUNTIME_ENTRY_MISSING', status: 400, expose: true });
|
|
return Object.freeze(normalized);
|
|
}
|
|
|
|
function verifyOptionalSignature(modulePackage, trustKeys = {}) {
|
|
const signature = modulePackage.signature;
|
|
if (!signature) return { signed: false, trusted: false, keyId: null };
|
|
const algorithm = String(signature.algorithm || '').toLowerCase();
|
|
const keyId = String(signature.keyId || '').trim();
|
|
const value = String(signature.value || '').trim();
|
|
if (algorithm !== 'ed25519' || !keyId || !value) {
|
|
throw new PlatformError('Ungültige Modulsignatur.', { code: 'MODULE_SIGNATURE_INVALID', status: 400, expose: true });
|
|
}
|
|
const publicKey = trustKeys[keyId];
|
|
if (!publicKey) return { signed: true, trusted: false, keyId };
|
|
try {
|
|
const verified = verifySignature(null, Buffer.from(modulePackage.integrity.digest, 'hex'), createPublicKey(publicKey), Buffer.from(value, 'base64'));
|
|
if (!verified) throw new Error('Signaturprüfung fehlgeschlagen');
|
|
return { signed: true, trusted: true, keyId };
|
|
} catch (error) {
|
|
throw new PlatformError('Modulsignatur konnte nicht bestätigt werden.', { code: 'MODULE_SIGNATURE_REJECTED', status: 400, expose: true, cause: error });
|
|
}
|
|
}
|
|
|
|
export function validateModulePackage(input, { trustKeys = {}, allowBuiltinReference = false } = {}) {
|
|
const source = input && typeof input === 'object' && !Array.isArray(input) ? input : null;
|
|
if (!source) throw new PlatformError('Ungültiges Modulpaket.', { code: 'MODULE_PACKAGE_INVALID', status: 400, expose: true });
|
|
if (source.format !== MODULE_PACKAGE_FORMAT || Number(source.schemaVersion) !== MODULE_PACKAGE_SCHEMA) {
|
|
throw new PlatformError('Nicht unterstütztes Modulpaketformat.', { code: 'MODULE_PACKAGE_VERSION_UNSUPPORTED', status: 400, expose: true });
|
|
}
|
|
const manifest = validateModuleManifest(source.manifest);
|
|
const runtime = validateRuntime(source.runtime);
|
|
if (runtime.mode === 'builtin-reference' && !allowBuiltinReference) {
|
|
throw new PlatformError('Referenzexporte eingebauter Module können nicht installiert werden.', { code: 'MODULE_BUILTIN_REFERENCE_NOT_INSTALLABLE', status: 400, expose: true });
|
|
}
|
|
if (manifest.type !== 'extension-host' && runtime.mode !== 'builtin-reference') {
|
|
throw new PlatformError('Installierbare Fremdmodule müssen den Typ extension-host verwenden.', { code: 'MODULE_EXTERNAL_TYPE_REQUIRED', status: 400, expose: true });
|
|
}
|
|
const files = validateFiles(source.files || [], runtime);
|
|
const normalized = {
|
|
format: MODULE_PACKAGE_FORMAT,
|
|
schemaVersion: MODULE_PACKAGE_SCHEMA,
|
|
manifest,
|
|
runtime,
|
|
files,
|
|
createdAt: String(source.createdAt || new Date().toISOString()),
|
|
integrity: {
|
|
algorithm: String(source.integrity?.algorithm || '').toLowerCase(),
|
|
digest: String(source.integrity?.digest || '').toLowerCase(),
|
|
},
|
|
signature: source.signature || null,
|
|
};
|
|
if (normalized.integrity.algorithm !== 'sha256' || !/^[a-f0-9]{64}$/.test(normalized.integrity.digest)) {
|
|
throw new PlatformError('Paketintegrität fehlt oder ist ungültig.', { code: 'MODULE_PACKAGE_INTEGRITY_INVALID', status: 400, expose: true });
|
|
}
|
|
const expected = calculatePackageDigest(normalized);
|
|
if (expected !== normalized.integrity.digest) {
|
|
throw new PlatformError('Paketintegrität stimmt nicht.', { code: 'MODULE_PACKAGE_INTEGRITY_MISMATCH', status: 400, expose: true });
|
|
}
|
|
const trust = verifyOptionalSignature(normalized, trustKeys);
|
|
return Object.freeze({ ...normalized, trust: Object.freeze(trust) });
|
|
}
|
|
|
|
export function buildModulePackage({ manifest, runtime = { mode: 'builtin-reference', entry: null }, files = [], createdAt = new Date().toISOString() }) {
|
|
const normalizedFiles = files.map(file => {
|
|
const path = safeRelativePath(file.path);
|
|
const buffer = Buffer.isBuffer(file.content) ? file.content : Buffer.from(String(file.content || ''), 'utf8');
|
|
return { path, sha256: sha256(buffer), size: buffer.length, content: buffer.toString('base64') };
|
|
});
|
|
// Der Digest muss auf demselben normalisierten Manifest basieren wie die spätere
|
|
// Validierung. Sonst würden additive Manifest-Defaults (z. B. defaultEnabled)
|
|
// den Digest nachträglich verändern.
|
|
const normalizedManifest = validateModuleManifest(manifest);
|
|
const draft = { format: MODULE_PACKAGE_FORMAT, schemaVersion: MODULE_PACKAGE_SCHEMA, manifest: normalizedManifest, runtime, files: normalizedFiles, createdAt, integrity: { algorithm: 'sha256', digest: '' }, signature: null };
|
|
draft.integrity.digest = calculatePackageDigest(draft);
|
|
return validateModulePackage(draft, { allowBuiltinReference: true });
|
|
}
|