91 lines
5.1 KiB
JavaScript
91 lines
5.1 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()));
|
|
if (Number.isSafeInteger(entry.bytes) && fs.statSync(partial).size !== entry.bytes) throw new Error(`Downloaded size mismatch for ${entry.id}.`);
|
|
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}.`);
|
|
replaceDirectory(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; }
|
|
function replaceDirectory(staged, target) {
|
|
const backup = `${target}.${process.pid}.backup`;
|
|
fs.rmSync(backup, { recursive: true, force: true });
|
|
const hadTarget = fs.existsSync(target);
|
|
let backupDisposable = false;
|
|
try {
|
|
if (hadTarget) moveDirectory(target, backup);
|
|
moveDirectory(staged, target);
|
|
backupDisposable = true;
|
|
fs.rmSync(backup, { recursive: true, force: true });
|
|
} catch (error) {
|
|
fs.rmSync(target, { recursive: true, force: true });
|
|
if (hadTarget && fs.existsSync(backup)) {
|
|
moveDirectory(backup, target);
|
|
backupDisposable = true;
|
|
} else if (!hadTarget) backupDisposable = true;
|
|
throw error;
|
|
} finally {
|
|
fs.rmSync(staged, { recursive: true, force: true });
|
|
if (backupDisposable) fs.rmSync(backup, { recursive: true, force: true });
|
|
}
|
|
}
|
|
function moveDirectory(source, target) {
|
|
try { fs.renameSync(source, target); }
|
|
catch (error) {
|
|
if (!["EPERM", "EACCES", "EXDEV"].includes(error.code)) throw error;
|
|
fs.mkdirSync(target, { recursive: true });
|
|
fs.cpSync(source, target, { recursive: true, force: true });
|
|
fs.rmSync(source, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
module.exports = { ArtifactManager, sha256File, validateManifestEntry };
|