Files
vendoo/app/core/modules/module-package-contract.mjs
T
Masterluke77andGitHub 6f48827f4d Vendoo 1.41.2 – Git Ignore Boundary & Module Tracking Hotfix (#18)
* docs: prepare Vendoo 1.41.2 git-ignore boundary hotfix

* fix: track native source modules with root-anchored runtime ignores
2026-07-09 17:09:00 +02:00

170 lines
9.3 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') };
});
const draft = { format: MODULE_PACKAGE_FORMAT, schemaVersion: MODULE_PACKAGE_SCHEMA, manifest, runtime, files: normalizedFiles, createdAt, integrity: { algorithm: 'sha256', digest: '' }, signature: null };
draft.integrity.digest = calculatePackageDigest(draft);
return validateModulePackage(draft, { allowBuiltinReference: true });
}