Lumi/plugins/lumi_transcription/backend/models/artifact_manager.js
2026-07-22 11:01:49 +02:00

60 lines
3.8 KiB
JavaScript

const crypto = require("crypto");
const fs = require("fs");
const path = require("path");
const AdmZip = require("adm-zip");
class ArtifactManager {
constructor(root, options = {}) { this.root = root; this.fetch = options.fetch || global.fetch; fs.mkdirSync(root, { recursive: true }); }
status(entry) {
const target = path.join(this.root, entry.filename || entry.id);
if (!fs.existsSync(target)) return { installed: false, valid: false, path: target };
return { installed: true, valid: sha256File(target) === entry.sha256, path: target, bytes: fs.statSync(target).size };
}
async download(entry, options = {}) {
validateManifestEntry(entry);
if (options.confirmed !== true) throw new Error("Download requires explicit setup confirmation.");
const filename = entry.filename || path.basename(new URL(entry.url).pathname);
const target = path.join(this.root, filename);
const partial = `${target}.${process.pid}.partial`;
fs.rmSync(partial, { force: true });
try {
const response = await this.fetch(entry.url, { redirect: "follow" });
if (!response.ok || !response.body) throw new Error(`Download failed with HTTP ${response.status}.`);
const handle = fs.createWriteStream(partial, { flags: "wx", mode: 0o600 });
for await (const chunk of response.body) {
if (!handle.write(chunk)) await new Promise((resolve) => handle.once("drain", resolve));
}
await new Promise((resolve, reject) => handle.end((error) => error ? reject(error) : resolve()));
const actual = sha256File(partial);
if (actual !== entry.sha256) throw new Error(`Checksum mismatch for ${entry.id}.`);
fs.renameSync(partial, target);
return this.status({ ...entry, filename });
} finally { fs.rmSync(partial, { force: true }); }
}
installZip(entry, archivePath) {
validateManifestEntry(entry);
if (sha256File(archivePath) !== entry.sha256) throw new Error(`Checksum mismatch for ${entry.id}.`);
const target = path.join(this.root, entry.id);
const staged = `${target}.${process.pid}.staged`;
fs.rmSync(staged, { recursive: true, force: true });
fs.mkdirSync(staged, { recursive: true });
const zip = new AdmZip(archivePath);
for (const item of zip.getEntries()) {
const portable = String(item.entryName).replace(/\\/g, "/");
const relative = path.posix.normalize(portable).replace(/^\/+/, "");
if (!relative || relative === ".." || relative.startsWith("../") || /^[A-Za-z]:/.test(relative)) throw new Error("Runtime archive contains an unsafe path.");
}
zip.extractAllTo(staged, true);
for (const expected of entry.expected_paths || []) if (!findBasename(staged, expected)) throw new Error(`Runtime is missing ${expected}.`);
fs.rmSync(target, { recursive: true, force: true });
fs.renameSync(staged, target);
return { installed: true, path: target, backend: entry.backend, version: entry.id };
}
}
function validateManifestEntry(entry) { if (!entry?.id || !/^https:\/\//.test(entry.url || "") || !/^[a-f0-9]{64}$/.test(entry.sha256 || "")) throw new Error("Artifact manifest entry is invalid."); }
function sha256File(target) { const hash = crypto.createHash("sha256"); const fd = fs.openSync(target, "r"); const buffer = Buffer.alloc(1024 * 1024); try { let read; while ((read = fs.readSync(fd, buffer, 0, buffer.length, null)) > 0) hash.update(buffer.subarray(0, read)); } finally { fs.closeSync(fd); } return hash.digest("hex"); }
function findBasename(root, basename) { const pending = [root]; while (pending.length) { const current = pending.pop(); for (const entry of fs.readdirSync(current, { withFileTypes: true })) { const target = path.join(current, entry.name); if (entry.isDirectory()) pending.push(target); else if (entry.name === basename) return target; } } return null; }
module.exports = { ArtifactManager, sha256File, validateManifestEntry };