import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, } from 'node:fs'; import { createHash } from 'node:crypto'; import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path'; export const MANIFEST_SCHEMA = 'vendoo-release-manifest-v1'; export const MANIFEST_FILE = 'release-manifest.json'; export const CONTROL_RELEASE_FILES = Object.freeze([MANIFEST_FILE]); export const REQUIRED_RELEASE_FILES = Object.freeze([ 'package.json', 'package-lock.json', 'bootstrap.mjs', 'server.mjs', 'lib/db.mjs', 'db/schema.sql', 'update-manifest.json', 'UPDATE_VENDOO.bat', 'scripts/updater/Vendoo.Updater.Preflight.ps1', 'scripts/updater/Vendoo.Updater.UI.ps1', 'scripts/updater/Vendoo.Updater.Host.ps1', 'scripts/updater/Vendoo.Updater.Worker.ps1', 'tools/apply-release-manifest.mjs', ]); const allowedEnvTemplates = new Set(['.env.example', '.env.docker.example']); const skippedTopLevelDirs = new Set([ '.git', 'node_modules', 'runtime', 'docker-data', 'uploads', 'backups', 'logs', 'sessions', 'tmp', 'temp', '__pycache__', 'release-output', 'operations', 'updates', 'modules', 'archiv', 'archive', 'archives', 'data', ]); const skippedLocalAiDirs = new Set(['local-ai/comfyui', 'local-ai/downloads', 'local-ai/extract-temp']); const skippedExactFiles = new Set([ MANIFEST_FILE, 'file_checksums_sha256.txt', '.vendoo-install-mode', '.vendoo-github-state.json', 'pending-operation.json', 'last-operation.json', 'first-admin-code.txt', ]); const skippedExtensions = new Set([ '.db', '.sqlite', '.sqlite3', '.log', '.key', '.pem', '.pfx', '.p12', '.pyc', ]); export function normalizeReleasePath(value) { return String(value).split('\\').join('/').replace(/^\.\//, ''); } export function assertSafeReleasePath(value, label = 'Release-Pfad') { const rel = normalizeReleasePath(value); if (!rel || rel === '.' || isAbsolute(rel) || rel.startsWith('/') || /^[A-Za-z]:\//.test(rel)) { throw new Error(`${label} ist nicht relativ: ${value}`); } const parts = rel.split('/'); if (parts.some(part => !part || part === '.' || part === '..')) { throw new Error(`${label} enthält unzulässige Segmente: ${value}`); } if (parts[0].toLowerCase() === '.git') { throw new Error(`${label} darf .git nicht verändern: ${value}`); } return rel; } export function shouldIncludeReleaseEntry(relativePath, stat) { const rel = normalizeReleasePath(relativePath); if (!rel) return false; const parts = rel.split('/'); const top = parts[0].toLowerCase(); const name = basename(rel); const lower = rel.toLowerCase(); if (skippedTopLevelDirs.has(top)) return false; if (parts.some(part => part.toLowerCase() === '__pycache__')) return false; if ([...skippedLocalAiDirs].some(dir => lower === dir || lower.startsWith(`${dir}/`))) return false; if (/^\.env(?:\..+)?$/i.test(name) && !allowedEnvTemplates.has(rel)) return false; if (skippedExactFiles.has(name.toLowerCase())) return false; if (/\.(?:db|sqlite|sqlite3)-(?:wal|shm)$/i.test(name)) return false; if (skippedExtensions.has(extname(name).toLowerCase())) return false; if (stat.isSymbolicLink()) return false; return stat.isDirectory() || stat.isFile(); } export function sha256File(filePath) { const hash = createHash('sha256'); hash.update(readFileSync(filePath)); return hash.digest('hex'); } export function listReleaseFiles(root) { const base = resolve(root); const files = []; function walk(current) { for (const name of readdirSync(current).sort((a, b) => a.localeCompare(b, 'en'))) { const absolute = join(current, name); const rel = normalizeReleasePath(relative(base, absolute)); const stat = lstatSync(absolute); if (!shouldIncludeReleaseEntry(rel, stat)) continue; if (stat.isDirectory()) walk(absolute); else files.push(rel); } } walk(base); return files; } export function buildReleaseManifest(root, { version, updaterVersion, removedFiles = [] } = {}) { if (!version) throw new Error('Release-Version fehlt.'); if (!updaterVersion) throw new Error('Updater-Version fehlt.'); const base = resolve(root); const files = listReleaseFiles(base).map(path => ({ path, sha256: sha256File(join(base, path)), size: statSync(join(base, path)).size, })); const normalizedRemoved = [...new Set(removedFiles.map(path => assertSafeReleasePath(path, 'removedFiles-Eintrag')))].sort(); const fileSet = new Set(files.map(entry => entry.path)); for (const required of REQUIRED_RELEASE_FILES) { if (!fileSet.has(required)) throw new Error(`Pflichtdatei fehlt im Release-Payload: ${required}`); } for (const removed of normalizedRemoved) { if (fileSet.has(removed)) throw new Error(`Datei ist gleichzeitig Payload und Löschung: ${removed}`); if (CONTROL_RELEASE_FILES.includes(removed)) throw new Error(`Steuerdatei darf nicht als Löschung markiert werden: ${removed}`); } return { schema: MANIFEST_SCHEMA, version, updaterVersion, mode: 'overlay-with-explicit-removals', requiredFiles: [...REQUIRED_RELEASE_FILES], removedFiles: normalizedRemoved, files, }; } export function readAndValidateManifest(sourceRoot, { allowExtraFiles = false } = {}) { const base = resolve(sourceRoot); const manifestPath = join(base, MANIFEST_FILE); if (!existsSync(manifestPath)) throw new Error(`${MANIFEST_FILE} fehlt.`); const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); if (manifest.schema !== MANIFEST_SCHEMA) throw new Error(`Unbekanntes Manifest-Schema: ${manifest.schema}`); if (manifest.mode !== 'overlay-with-explicit-removals') throw new Error(`Unzulässiger Release-Modus: ${manifest.mode}`); if (!Array.isArray(manifest.files) || manifest.files.length === 0) throw new Error('Release-Manifest enthält keine Dateien.'); if (!Array.isArray(manifest.removedFiles)) throw new Error('removedFiles fehlt im Release-Manifest.'); const seen = new Set(); for (const entry of manifest.files) { if (!entry || typeof entry !== 'object') throw new Error('Ungültiger Datei-Eintrag im Release-Manifest.'); const rel = assertSafeReleasePath(entry.path, 'Manifest-Dateipfad'); if (seen.has(rel)) throw new Error(`Doppelter Manifest-Dateipfad: ${rel}`); seen.add(rel); if (!/^[a-f0-9]{64}$/.test(String(entry.sha256 || ''))) throw new Error(`Ungültige SHA-256-Prüfsumme: ${rel}`); if (!Number.isSafeInteger(entry.size) || entry.size < 0) throw new Error(`Ungültige Dateigröße: ${rel}`); } const removedSeen = new Set(); for (const value of manifest.removedFiles) { const rel = assertSafeReleasePath(value, 'removedFiles-Eintrag'); if (removedSeen.has(rel)) throw new Error(`Doppelter removedFiles-Eintrag: ${rel}`); removedSeen.add(rel); if (seen.has(rel)) throw new Error(`Datei ist gleichzeitig Payload und Löschung: ${rel}`); if (CONTROL_RELEASE_FILES.includes(rel)) throw new Error(`Steuerdatei darf nicht als Löschung markiert werden: ${rel}`); } const required = Array.isArray(manifest.requiredFiles) ? manifest.requiredFiles : REQUIRED_RELEASE_FILES; for (const value of REQUIRED_RELEASE_FILES) { if (!required.includes(value)) throw new Error(`Manifest-Pflichtdatei fehlt in requiredFiles: ${value}`); if (!seen.has(value)) throw new Error(`Manifest-Payload enthält Pflichtdatei nicht: ${value}`); } const actualFiles = listReleaseFiles(base); const actualSet = new Set(actualFiles); if (!allowExtraFiles) { for (const rel of actualFiles) if (!seen.has(rel)) throw new Error(`Sichere Paketdatei fehlt im Manifest: ${rel}`); } for (const rel of seen) if (!actualSet.has(rel)) throw new Error(`Manifest verweist auf fehlende Paketdatei: ${rel}`); for (const entry of manifest.files) { const filePath = join(base, entry.path); const stat = statSync(filePath); if (stat.size !== entry.size) throw new Error(`Dateigröße stimmt nicht: ${entry.path}`); const actualHash = sha256File(filePath); if (actualHash !== entry.sha256) throw new Error(`SHA-256 stimmt nicht: ${entry.path}`); } return manifest; } export function applyReleaseManifest(sourceRoot, destinationRoot) { const source = resolve(sourceRoot); const destination = resolve(destinationRoot); if (source === destination) throw new Error('Quell- und Zielordner dürfen nicht identisch sein.'); if (!existsSync(join(destination, '.git'))) throw new Error(`Ziel ist kein geklontes Git-Repository: ${destination}`); const manifest = readAndValidateManifest(source); for (const rel of manifest.removedFiles) { rmSync(join(destination, rel), { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); } let copied = 0; for (const entry of manifest.files) { const sourceFile = join(source, entry.path); const targetFile = join(destination, entry.path); mkdirSync(dirname(targetFile), { recursive: true }); copyFileSync(sourceFile, targetFile); try { chmodSync(targetFile, statSync(sourceFile).mode & 0o777); } catch {} const targetHash = sha256File(targetFile); if (targetHash !== entry.sha256) throw new Error(`Zielprüfung nach Kopieren fehlgeschlagen: ${entry.path}`); copied += 1; } let controlCopied = 0; for (const rel of CONTROL_RELEASE_FILES) { const sourceFile = join(source, rel); const targetFile = join(destination, rel); mkdirSync(dirname(targetFile), { recursive: true }); copyFileSync(sourceFile, targetFile); if (sha256File(sourceFile) !== sha256File(targetFile)) { throw new Error(`Steuerdatei-Prüfung nach Kopieren fehlgeschlagen: ${rel}`); } controlCopied += 1; } for (const required of REQUIRED_RELEASE_FILES) { if (!existsSync(join(destination, required))) throw new Error(`Pflichtdatei fehlt nach Release-Anwendung: ${required}`); } for (const controlFile of CONTROL_RELEASE_FILES) { if (!existsSync(join(destination, controlFile))) throw new Error(`Steuerdatei fehlt nach Release-Anwendung: ${controlFile}`); } return { source, destination, version: manifest.version, updaterVersion: manifest.updaterVersion, copied, controlCopied, removed: manifest.removedFiles.length, }; }