const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const AdmZip = require('adm-zip'); const CryptoUtils = require('./crypto-utils'); const COMPONENT_PROBES = { frontend: 'frontend/server.js', player: 'player/server.js', config: 'config/env.json', secrets: 'encrypted-credentials.json' }; const COMMIT_ALLOWED = new Set(['frontend', 'player', 'config', 'encrypted-credentials.json']); function renameSafe(from, to) { let last; for (let i = 0; i < 6; i++) { try { fs.renameSync(from, to); return; } catch (e) { last = e; if (!['EPERM', 'EBUSY', 'EACCES', 'ENOTEMPTY'].includes(e.code)) throw e; Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 250 * (i + 1)); } } throw last; } class Patcher { constructor(appDataPath) { this.appDataPath = appDataPath; } getCurrentVersion() { const versionFile = path.join(this.appDataPath, 'version.json'); if (fs.existsSync(versionFile)) { try { return JSON.parse(fs.readFileSync(versionFile, 'utf8')).version || '0.0.0'; } catch (e) { return '0.0.0'; } } return '0.0.0'; } getCurrentSha() { const versionFile = path.join(this.appDataPath, 'version.json'); if (fs.existsSync(versionFile)) { try { return JSON.parse(fs.readFileSync(versionFile, 'utf8')).sha256 || null; } catch (e) { return null; } } return null; } bridgeCredentials(credentialManager) { const encryptedPath = path.join(this.appDataPath, 'encrypted-credentials.json'); if (!fs.existsSync(encryptedPath)) { return false; } const buildKey = CryptoUtils.deriveKey('vyla-home-build-key', 'vyla-home-payload-salt-v1'); const encryptedData = fs.readFileSync(encryptedPath, 'utf8'); const credentials = CryptoUtils.decryptObject(encryptedData, buildKey); for (const [service, keys] of Object.entries(credentials)) { credentialManager.setServiceCredentials(service, keys); } fs.unlinkSync(encryptedPath); return true; } saveCurrentVersion(version, sha256 = null, components = null) { const versionFile = path.join(this.appDataPath, 'version.json'); fs.mkdirSync(this.appDataPath, { recursive: true }); const tmp = `${versionFile}.tmp`; fs.writeFileSync(tmp, JSON.stringify({ version, sha256, components: components || {} }, null, 2), 'utf8'); renameSafe(tmp, versionFile); } getCurrentComponents() { try { const c = JSON.parse(fs.readFileSync(path.join(this.appDataPath, 'version.json'), 'utf8')).components; return c && typeof c === 'object' ? c : {}; } catch (e) { return {}; } } integrityPath() { return path.join(this.appDataPath, 'integrity.json'); } readIntegrity() { try { return JSON.parse(fs.readFileSync(this.integrityPath(), 'utf8')); } catch (e) { return {}; } } writeIntegrity(names) { const all = this.readIntegrity(); for (const name of names) { if (name !== 'frontend' && name !== 'player') continue; const files = {}; const walk = (dir, rel) => { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const full = path.join(dir, entry.name); const r = `${rel}/${entry.name}`; if (r.includes('/node_modules/@vyla-entertainment/sdk')) continue; if (entry.isDirectory()) walk(full, r); else if (entry.isFile()) files[r] = fs.statSync(full).size; } }; walk(path.join(this.appDataPath, name), name); all[name] = files; } const tmp = `${this.integrityPath()}.tmp`; fs.writeFileSync(tmp, JSON.stringify(all), 'utf8'); renameSafe(tmp, this.integrityPath()); } integrityOk(name) { const files = this.readIntegrity()[name]; if (!files) return true; for (const [rel, size] of Object.entries(files)) { try { if (fs.statSync(path.join(this.appDataPath, rel)).size !== size) return false; } catch (e) { return false; } } return true; } componentIntact(name) { const probe = COMPONENT_PROBES[name]; if (!probe || name === 'secrets') return true; try { if (fs.statSync(path.join(this.appDataPath, probe)).size <= 0) return false; } catch (e) { return false; } return this.integrityOk(name); } staleComponents(release) { const local = this.getCurrentComponents(); const names = Object.keys(release.components); const stale = new Set(names.filter((n) => !this.componentIntact(n) || local[n] !== release.components[n].id)); if (stale.has('config')) { for (const n of ['frontend', 'player']) { if (release.components[n]) stale.add(n); } } return [...stale]; } verifyStaged(stagingDir, names) { return names.every((name) => { const probe = COMPONENT_PROBES[name]; if (!probe) return true; try { return fs.statSync(path.join(stagingDir, probe)).size > 0; } catch (e) { return false; } }); } purgeOldCode() { const dirs = ['frontend', 'player', 'stream-api', 'auth-proxy', 'config']; for (const dir of dirs) { const fullPath = path.join(this.appDataPath, dir); if (fs.existsSync(fullPath)) { try { fs.rmSync(fullPath, { recursive: true, force: true }); } catch (e) { } } } } extractZipPayload(zipPath) { const zip = new AdmZip(zipPath); const entries = zip.getEntries(); if (!entries || entries.length === 0) { throw new Error('Archive is empty, invalid, or truncated.'); } const targetDir = path.resolve(this.appDataPath); for (const entry of entries) { const entryPath = path.normalize(entry.entryName); const resolvedPath = path.resolve(targetDir, entryPath); if (!resolvedPath.startsWith(targetDir)) { throw new Error(`Directory traversal attempt detected in archive: ${entry.entryName}`); } } zip.extractAllTo(this.appDataPath, true); } async extractToStaging(zipPath, onFile, keep = false) { const stagingDir = path.join(this.appDataPath, '.staging'); if (!keep) fs.rmSync(stagingDir, { recursive: true, force: true }); fs.mkdirSync(stagingDir, { recursive: true }); const zip = new AdmZip(zipPath); const entries = zip.getEntries().filter((e) => !e.isDirectory); if (entries.length === 0) { throw new Error('Downloaded archive is empty, invalid, or truncated.'); } const resolvedStaging = path.resolve(stagingDir); for (const entry of entries) { const entryPath = path.normalize(entry.entryName); const resolvedPath = path.resolve(resolvedStaging, entryPath); if (!resolvedPath.startsWith(resolvedStaging + path.sep)) { throw new Error(`Directory traversal attempt detected: ${entry.entryName}`); } } for (let i = 0; i < entries.length; i++) { const entry = entries[i]; zip.extractEntryTo(entry, stagingDir, true, true); if (onFile) onFile(entry.entryName, i + 1, entries.length); if (i % 20 === 0) await new Promise((resolve) => setImmediate(resolve)); } return stagingDir; } journalPath() { return path.join(this.appDataPath, '.update-journal.json'); } backupDir() { return path.join(this.appDataPath, '.backup'); } commitStaging(stagingDir) { const names = fs.readdirSync(stagingDir); const unexpected = names.filter((n) => !COMMIT_ALLOWED.has(n)); if (unexpected.length > 0) { throw new Error(`Unexpected top-level entries in update: ${unexpected.join(', ')}`); } const backup = this.backupDir(); fs.rmSync(backup, { recursive: true, force: true }); fs.mkdirSync(backup, { recursive: true }); const versionFile = path.join(this.appDataPath, 'version.json'); if (fs.existsSync(versionFile)) { fs.copyFileSync(versionFile, path.join(backup, 'version.json.bak')); } const journal = { names, hadOld: {}, installed: [] }; for (const name of names) { journal.hadOld[name] = fs.existsSync(path.join(this.appDataPath, name)); } const save = () => { const tmp = `${this.journalPath()}.tmp`; fs.writeFileSync(tmp, JSON.stringify(journal), 'utf8'); renameSafe(tmp, this.journalPath()); }; save(); try { for (const name of names) { const dest = path.join(this.appDataPath, name); if (journal.hadOld[name]) { renameSafe(dest, path.join(backup, name)); } journal.installed.push(name); save(); renameSafe(path.join(stagingDir, name), dest); } } catch (e) { try { this.rollback(); } catch (x) { } throw e; } fs.rmSync(stagingDir, { recursive: true, force: true }); } rollback() { let journal; try { journal = JSON.parse(fs.readFileSync(this.journalPath(), 'utf8')); } catch (e) { return false; } const backup = this.backupDir(); const hadOld = journal.hadOld || {}; const installed = journal.installed || []; for (const name of journal.names || []) { const dest = path.join(this.appDataPath, name); const saved = path.join(backup, name); if (hadOld[name]) { if (fs.existsSync(saved)) { fs.rmSync(dest, { recursive: true, force: true }); renameSafe(saved, dest); } } else if (installed.includes(name)) { fs.rmSync(dest, { recursive: true, force: true }); } } const versionFile = path.join(this.appDataPath, 'version.json'); const savedVersion = path.join(backup, 'version.json.bak'); if (fs.existsSync(savedVersion)) { fs.copyFileSync(savedVersion, versionFile); } else { fs.rmSync(versionFile, { force: true }); } fs.rmSync(backup, { recursive: true, force: true }); fs.rmSync(this.journalPath(), { force: true }); fs.rmSync(path.join(this.appDataPath, '.staging'), { recursive: true, force: true }); return true; } finalizeCommit() { fs.rmSync(this.backupDir(), { recursive: true, force: true }); fs.rmSync(this.journalPath(), { force: true }); for (const dir of ['stream-api', 'auth-proxy']) { fs.rmSync(path.join(this.appDataPath, dir), { recursive: true, force: true }); } } recoverInterrupted() { const had = fs.existsSync(this.journalPath()); if (had && !this.rollback()) { fs.rmSync(this.journalPath(), { force: true }); fs.rmSync(this.backupDir(), { recursive: true, force: true }); } fs.rmSync(path.join(this.appDataPath, '.staging'), { recursive: true, force: true }); try { for (const f of fs.readdirSync(this.appDataPath)) { if (/^download-.*\.zip$/.test(f)) fs.rmSync(path.join(this.appDataPath, f), { force: true }); } } catch (e) { } return had; } verifyChecksum(zipPath, expectedSha256) { if (!expectedSha256) return false; const hash = crypto.createHash('sha256'); const fd = fs.openSync(zipPath, 'r'); const buf = Buffer.alloc(1024 * 1024); try { let n; while ((n = fs.readSync(fd, buf, 0, buf.length, null)) > 0) { hash.update(buf.subarray(0, n)); } } finally { fs.closeSync(fd); } return hash.digest('hex') === String(expectedSha256).trim().toLowerCase(); } verifyExtraction(root = this.appDataPath) { const requiredFiles = [ 'frontend/server.js', 'player/server.js' ]; for (const file of requiredFiles) { const fullPath = path.join(root, file); if (!fs.existsSync(fullPath)) { return false; } const stat = fs.statSync(fullPath); if (stat.size === 0) { return false; } try { const sampleSize = Math.min(stat.size, 4096); const fd = fs.openSync(fullPath, 'r'); const buffer = Buffer.alloc(sampleSize); fs.readSync(fd, buffer, 0, sampleSize, 0); fs.closeSync(fd); if (buffer.every(byte => byte === 0)) { return false; } } catch (e) { return false; } } return true; } processDirectory(dir, replacementList = []) { const files = fs.readdirSync(dir); for (const file of files) { const fullPath = path.join(dir, file); const stat = fs.statSync(fullPath); if (stat.isDirectory()) { if (file === 'node_modules' || file === '.git') continue; this.processDirectory(fullPath, replacementList); } else if (stat.isFile()) { let content = fs.readFileSync(fullPath, 'utf8'); let modified = false; for (const rep of replacementList) { if (content.includes(rep.find)) { content = content.split(rep.find).join(rep.replace); modified = true; } } if (modified) { fs.writeFileSync(fullPath, content, 'utf8'); } } } } applyPatches(replacements, only = null) { const targetDirs = ['frontend', 'player'].filter((d) => !only || only.includes(d)); for (const dirName of targetDirs) { const dirPath = path.join(this.appDataPath, dirName); if (!fs.existsSync(dirPath)) continue; const replacementList = replacements[dirName] || []; this.processDirectory(dirPath, replacementList); } } } module.exports = Patcher;