#!/usr/bin/env node /** * Studio L3b slice 1 — pinned fetch + verify of the official ComfyUI frontend * dist (`comfyui-frontend-package==1.51.10`, pinned in * integrations/comfyui/compatibility.json and mirrored in studio-editor.lock.json * next to this file). * * What it does, in this order and nothing else: * * 0. refuse a populated `--out-dir` that carries no ownership marker * (`.studio-editor-out-dir-owned`, an empty directory this script * writes) — decided before the lock, the cache or the network are * touched, so a foreign directory is never modified (review S2); * 1. read the lock (package, version, wheel_url, sha256); * 2. take the wheel from `/comfyui_frontend_package-…whl` when present, * otherwise download it from `wheel_url` — https only, on a host pinned * by this file (`files.pythonhosted.org`) and re-checked on EVERY * redirect hop, with an inactivity timeout so a hung socket cannot stall * the script (review of #2209, item S3); * 3. verify sha256 BEFORE anything is extracted — a mismatch exits non-zero * and the output directory is not touched. A download is only written to * the cache after it verified, so a cached wheel is either absent or good; * 4. refuse the archive outright if any entry carries `..`, an absolute path, * a drive letter/backslash or a symlink bit — before writing a byte; * 5. extract ONLY the package's `static/` tree into the output directory * (gitignored public files), skipping `__init__.py`, dist-info, RECORD …; * 6. leave an already-correct extraction alone: the existing tree is compared * entry by entry (inflated bytes) against the wheel, and when everything * matches no file is written. * * Node built-ins only (node:fs, node:crypto, node:zlib, node:https, node:path, * node:url) — no dependency, nothing added to package.json. This script is NOT * wired into `bun run build` yet; wiring it is the next slice's decision. * * Usage: * node scripts/studio-editor-fetch.mjs [--lock ] [--cache-dir ] * [--out-dir ] */ import { createHash } from "node:crypto"; import fs from "node:fs"; import https from "node:https"; import path from "node:path"; import { fileURLToPath } from "node:url"; import zlib from "node:zlib"; const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); const WEBSITE_DIR = path.resolve(SCRIPT_DIR, ".."); const DEFAULT_LOCK = path.join(SCRIPT_DIR, "studio-editor.lock.json"); const DEFAULT_CACHE_DIR = path.join(WEBSITE_DIR, ".studio-editor-cache"); const DEFAULT_OUT_DIR = path.join(WEBSITE_DIR, "public", "console", "studio", "editor"); /** * Ownership marker for the extraction target (review of #2209, item S2): an * EMPTY DIRECTORY this script creates inside every out-dir it writes. The * guard `assertOwnedOutDir` refuses any populated directory that does not * carry it, so `--out-dir` can never unlink a file this script did not write * (the old `writeTree` deleted every file under the out-dir that was not in * the archive — pointed at `website/public`, that would have wiped it). * * It is deliberately a directory rather than a marker FILE: the dist is * counted file by file by the build hook (`lock.static_files`, then the * `static-files.sha256` inventory), and one extra file in there would break * those exact-path checks. A directory contributes no file entries. */ const OUT_DIR_MARKER = ".studio-editor-out-dir-owned"; /** Download ceiling (wheel is ~23.6 MB) and extraction ceilings. */ const MAX_DOWNLOAD_BYTES = 64 * 1024 * 1024; const MAX_ENTRY_BYTES = 128 * 1024 * 1024; const MAX_TOTAL_BYTES = 512 * 1024 * 1024; /** * S3 — the only hosts a wheel download may resolve to. The lock's * `wheel_url` must name one of them (checked in `readLock`), and the same * set is enforced for the first URL and every redirect hop before any * request goes out. Widening this set is a deliberate code change, never a * lock-file edit. */ const PINNED_WHEEL_HOSTS = new Set(["files.pythonhosted.org"]); /** S3 — no response head and no data chunk for this long ⇒ the request is * destroyed and the download fails. Without it a hung socket stalled the * script forever. */ const REQUEST_TIMEOUT_MS = 60_000; const EOCD_SIGNATURE = 0x06054b50; const CENTRAL_SIGNATURE = 0x02014b50; const LOCAL_SIGNATURE = 0x04034b50; const ZIP64_MARKER_16 = 0xffff; const ZIP64_MARKER_32 = 0xffffffff; const S_IFLNK = 0xa000; class FetchError extends Error {} function usage() { return [ "Usage: node scripts/studio-editor-fetch.mjs [options]", "", " --lock lock JSON to honour (default scripts/studio-editor.lock.json)", " --cache-dir wheel cache (default /.studio-editor-cache)", " --out-dir extraction target; must be absent, empty, or carry this script's", " .studio-editor-out-dir-owned marker (default /public/console/studio/editor)", ].join("\n"); } function parseArgs(argv) { const options = { lock: DEFAULT_LOCK, cacheDir: DEFAULT_CACHE_DIR, outDir: DEFAULT_OUT_DIR }; for (let i = 0; i < argv.length; i += 1) { const arg = argv[i]; if (arg === "--help" || arg === "-h") { options.help = true; return options; } const value = argv[i + 1]; if (arg === "--lock" || arg === "--cache-dir" || arg === "--out-dir") { if (!value) throw new FetchError(`${arg} requires a value`); options[arg === "--lock" ? "lock" : arg === "--cache-dir" ? "cacheDir" : "outDir"] = path.resolve(value); i += 1; continue; } throw new FetchError(`unknown argument: ${arg}`); } return options; } function readLock(lockPath) { let lock; try { lock = JSON.parse(fs.readFileSync(lockPath, "utf8")); } catch (error) { throw new FetchError(`lock unreadable: ${lockPath} (${error.message})`); } for (const field of ["package", "version", "wheel_url", "sha256"]) { if (typeof lock[field] !== "string" || lock[field].length === 0) { throw new FetchError(`lock field "${field}" must be a non-empty string`); } } if (!/^[0-9a-f]{64}$/.test(lock.sha256)) { throw new FetchError("lock field \"sha256\" must be 64 lowercase hex characters"); } const url = new URL(lock.wheel_url); if (url.protocol !== "https:") throw new FetchError(`wheel_url must be https, got ${url.protocol}`); if (!PINNED_WHEEL_HOSTS.has(url.hostname)) { throw new FetchError(`wheel_url must be hosted on ${[...PINNED_WHEEL_HOSTS].join(", ")}, got ${url.hostname}`); } if (typeof lock.size === "number" && lock.size > MAX_DOWNLOAD_BYTES) { throw new FetchError("lock field \"size\" exceeds the download ceiling"); } // The hosts redirects may resolve to: exactly the ones the lock names. return { lock, wheelName: path.basename(url.pathname), allowedHosts: new Set([url.hostname]) }; } function sha256(buffer) { return createHash("sha256").update(buffer).digest("hex"); } /** S3 — every download URL must be https AND name a host the lock pins. */ function assertPinnedDownloadUrl(rawUrl, allowedHosts) { let target; try { target = new URL(rawUrl); } catch { throw new FetchError(`wheel download refused: not a URL: ${rawUrl}`); } if (target.protocol !== "https:") { throw new FetchError(`wheel download refused (not https): ${rawUrl}`); } if (!allowedHosts || !allowedHosts.has(target.hostname)) { throw new FetchError( `wheel download refused: host "${target.hostname}" is not pinned by the lock (allowed: ${ allowedHosts && allowedHosts.size > 0 ? [...allowedHosts].join(", ") : "none" })` ); } } /** * Download the wheel. S3: the pin (`assertPinnedDownloadUrl`) runs on the * first URL and again on every redirect hop before it is requested, and an * inactivity timeout destroys a request that stops delivering data. * `get` is injectable so the unit tests can drive a fake transport without a * single packet leaving the machine — the default is `https.get`. */ function download(url, options = {}) { const { allowedHosts, redirectsLeft = 5, get = (target, requestOptions, callback) => https.get(target, requestOptions, callback), timeoutMs = REQUEST_TIMEOUT_MS, } = options; assertPinnedDownloadUrl(url, allowedHosts); return new Promise((resolve, reject) => { let settled = false; let request = null; let timer = null; const stopTimer = () => { if (timer !== null) { clearTimeout(timer); timer = null; } }; const settle = (finish, value) => { if (settled) return; settled = true; stopTimer(); finish(value); }; const fail = (error) => { if (settled) return; settled = true; stopTimer(); try { request?.destroy?.(error instanceof Error ? error : new FetchError(String(error))); } catch { /* the request may already be gone */ } reject(error); }; const armTimeout = () => { stopTimer(); timer = setTimeout(() => { fail(new FetchError(`wheel download timed out: no data for ${timeoutMs} ms from ${url}`)); }, timeoutMs); }; armTimeout(); try { request = get(url, { headers: { "user-agent": "inferway-studio-editor-fetch" } }, (response) => { const status = response.statusCode ?? 0; const location = response.headers.location; if (status >= 300 && status < 400 && location) { response.resume(); if (redirectsLeft === 0) { settle(reject, new FetchError("too many redirects while fetching the wheel")); return; } const next = new URL(location, url).toString(); try { // The pin runs again at the entry of the recursive call, so the // hop is refused BEFORE any second request is issued. settle(resolve, download(next, { ...options, redirectsLeft: redirectsLeft - 1 })); } catch (error) { settle(reject, error instanceof Error ? error : new FetchError(String(error))); } return; } if (status !== 200) { response.resume(); settle(reject, new FetchError(`wheel download failed: HTTP ${status}`)); return; } const chunks = []; let size = 0; response.on("data", (chunk) => { armTimeout(); size += chunk.length; if (size > MAX_DOWNLOAD_BYTES) { fail(new FetchError(`wheel larger than ${MAX_DOWNLOAD_BYTES} bytes`)); return; } chunks.push(chunk); }); response.on("end", () => settle(resolve, Buffer.concat(chunks))); response.on("error", (error) => settle(reject, error)); }); } catch (error) { fail(error instanceof Error ? error : new FetchError(String(error))); return; } request?.on?.("error", (error) => settle(reject, error instanceof Error ? error : new FetchError(String(error)))); }); } /** Locate and read the end-of-central-directory record and the central directory. */ function readCentralDirectory(zip) { let eocd = -1; const floor = Math.max(0, zip.length - 22 - 0xffff); for (let i = zip.length - 22; i >= floor; i -= 1) { if (zip.readUInt32LE(i) === EOCD_SIGNATURE) { eocd = i; break; } } if (eocd < 0) throw new FetchError("zip: end-of-central-directory record not found"); const entryCount = zip.readUInt16LE(eocd + 10); const cdSize = zip.readUInt32LE(eocd + 12); const cdOffset = zip.readUInt32LE(eocd + 16); if (entryCount === ZIP64_MARKER_16 || cdSize === ZIP64_MARKER_32 || cdOffset === ZIP64_MARKER_32) { throw new FetchError("zip: zip64 archives are not supported"); } if (cdOffset + cdSize > zip.length) throw new FetchError("zip: central directory out of bounds"); const entries = []; let cursor = cdOffset; for (let i = 0; i < entryCount; i += 1) { if (cursor + 46 > zip.length || zip.readUInt32LE(cursor) !== CENTRAL_SIGNATURE) { throw new FetchError(`zip: malformed central directory entry ${i}`); } const flags = zip.readUInt16LE(cursor + 8); const method = zip.readUInt16LE(cursor + 10); const compressedSize = zip.readUInt32LE(cursor + 20); const uncompressedSize = zip.readUInt32LE(cursor + 24); const nameLength = zip.readUInt16LE(cursor + 28); const extraLength = zip.readUInt16LE(cursor + 30); const commentLength = zip.readUInt16LE(cursor + 32); const externalAttributes = zip.readUInt32LE(cursor + 38); const localOffset = zip.readUInt32LE(cursor + 42); const name = zip.toString("utf8", cursor + 46, cursor + 46 + nameLength); if ((flags & 0x1) !== 0) throw new FetchError(`zip: encrypted entry is refused: ${name}`); if (compressedSize === ZIP64_MARKER_32 || uncompressedSize === ZIP64_MARKER_32 || localOffset === ZIP64_MARKER_32) { throw new FetchError("zip: zip64 entries are not supported"); } entries.push({ name, method, compressedSize, uncompressedSize, externalAttributes, localOffset }); cursor += 46 + nameLength + extraLength + commentLength; } return entries; } /** * Refuse ANY entry that could escape the output directory or plant a link — * checked for every entry in the archive, not just the ones that will be * written, and always before the first write. */ function assertSafeEntry(entry) { const { name } = entry; if (name.length === 0) throw new FetchError("zip: empty entry name"); if (name.startsWith("/") || name.startsWith("\\")) throw new FetchError(`zip: absolute entry path: ${name}`); if (/^[a-zA-Z]:/.test(name)) throw new FetchError(`zip: drive-letter entry path: ${name}`); if (name.includes("\\")) throw new FetchError(`zip: backslash in entry path: ${name}`); for (const segment of name.split("/")) { if (segment === "..") throw new FetchError(`zip: parent traversal in entry: ${name}`); } const unixMode = (entry.externalAttributes >>> 16) & 0xf000; if (unixMode === S_IFLNK) throw new FetchError(`zip: symbolic link entry is refused: ${name}`); } function inflateEntry(zip, entry) { const localOffset = entry.localOffset; if (localOffset + 30 > zip.length || zip.readUInt32LE(localOffset) !== LOCAL_SIGNATURE) { throw new FetchError(`zip: malformed local header for ${entry.name}`); } const nameLength = zip.readUInt16LE(localOffset + 26); const extraLength = zip.readUInt16LE(localOffset + 28); const start = localOffset + 30 + nameLength + extraLength; const end = start + entry.compressedSize; if (end > zip.length) throw new FetchError(`zip: compressed data out of bounds for ${entry.name}`); const data = zip.subarray(start, end); if (entry.method === 0) return Buffer.from(data); if (entry.method === 8) { return zlib.inflateRawSync(data, { maxOutputLength: MAX_ENTRY_BYTES }); } throw new FetchError(`zip: unsupported compression method ${entry.method} for ${entry.name}`); } /** Every entry under `/static/…`, keyed by its path relative to the output directory. */ function collectStaticEntries(zip, entries, lock) { const distName = lock.package.replace(/[-_.]+/g, "_"); const prefix = `${distName}/static/`; const selected = new Map(); let total = 0; for (const entry of entries) { assertSafeEntry(entry); if (!entry.name.startsWith(prefix) || entry.name.endsWith("/")) continue; const relative = entry.name.slice(prefix.length); if (relative.length === 0) continue; total += entry.uncompressedSize; if (total > MAX_TOTAL_BYTES) throw new FetchError("zip: static/ tree exceeds the extraction ceiling"); selected.set(relative, entry); } if (selected.size === 0) throw new FetchError(`zip: no entries under ${prefix} — wrong package or layout`); return selected; } function walkFiles(root) { const files = []; const directories = []; const stack = [root]; while (stack.length > 0) { const current = stack.pop(); for (const dirent of fs.readdirSync(current, { withFileTypes: true })) { const absolute = path.join(current, dirent.name); if (dirent.isDirectory()) { directories.push(absolute); stack.push(absolute); } else if (dirent.isFile()) { files.push(absolute); } else { throw new FetchError(`refused non-regular file in the output directory: ${absolute}`); } } } return { files, directories }; } /** True when every archive entry already exists byte-identically on disk. */ function extractionMatches(outDir, selected, zip) { if (!fs.existsSync(outDir)) return false; const { files } = walkFiles(outDir); const onDisk = new Set(files); if (onDisk.size !== selected.size) return false; for (const [relative, entry] of selected) { const absolute = path.join(outDir, relative); if (!onDisk.has(absolute)) return false; const stat = fs.statSync(absolute); if (!stat.isFile() || stat.size !== entry.uncompressedSize) return false; if (sha256(fs.readFileSync(absolute)) !== sha256(inflateEntry(zip, entry))) return false; } return true; } /** * Refuse to touch an out-dir that this script cannot prove it wrote itself. * Allowed: absent (we create it), empty (nothing to destroy), or carrying the * ownership marker from an earlier run. Everything else — a populated tree * without the marker — exits non-zero BEFORE the lock, the cache or the * network are touched. */ function assertOwnedOutDir(outDir) { if (!fs.existsSync(outDir)) return; if (!fs.statSync(outDir).isDirectory()) { throw new FetchError(`refused: --out-dir exists but is not a directory: ${outDir}`); } const marker = path.join(outDir, OUT_DIR_MARKER); if (fs.existsSync(marker) && fs.statSync(marker).isDirectory()) return; if (fs.readdirSync(outDir).length === 0) return; throw new FetchError( `refused to write into ${outDir}: it is not empty and carries no ${OUT_DIR_MARKER} ` + `ownership marker written by this script, so its files were not written by it.\n` + ` nothing was read, downloaded or modified. Use an absent or empty --out-dir; ` + `if (and only if) this tree really is a previous studio-editor-fetch output, claim it ` + `by creating ${marker}` ); } /** * Write the tree and remove exactly the files that are NOT in the archive * (stale assets of a previous version). Only paths inside outDir are ever * unlinked, each by its own full path — no wildcard, no recursive delete. * Belt on top of `assertOwnedOutDir`: no marker, no writes — this function * can never unlink a file it did not write. */ function writeTree(outDir, selected, zip) { const marker = path.join(outDir, OUT_DIR_MARKER); if (!fs.existsSync(marker) || !fs.statSync(marker).isDirectory()) { throw new FetchError(`refused to write into ${outDir}: no ${OUT_DIR_MARKER} ownership marker`); } const keep = new Set(); let bytes = 0; for (const [relative, entry] of selected) { const absolute = path.resolve(path.join(outDir, relative)); if (!absolute.startsWith(`${outDir}${path.sep}`)) { throw new FetchError(`refused to write outside the output directory: ${relative}`); } fs.mkdirSync(path.dirname(absolute), { recursive: true }); const content = inflateEntry(zip, entry); if (content.length !== entry.uncompressedSize) { throw new FetchError(`zip: size mismatch for ${entry.name}`); } fs.writeFileSync(absolute, content); keep.add(absolute); bytes += content.length; } const { files, directories } = fs.existsSync(outDir) ? walkFiles(outDir) : { files: [], directories: [] }; for (const file of files) { if (!keep.has(file)) fs.unlinkSync(file); } const keepDirectories = new Set(); for (const absolute of keep) { let current = path.dirname(absolute); while (current.startsWith(outDir) && current !== outDir) { keepDirectories.add(current); current = path.dirname(current); } } for (const directory of directories.sort((a, b) => b.length - a.length)) { if (directory === marker) continue; if (!keepDirectories.has(directory) && fs.existsSync(directory) && fs.readdirSync(directory).length === 0) { fs.rmdirSync(directory); } } return { files: selected.size, bytes }; } async function main() { const options = parseArgs(process.argv.slice(2)); if (options.help) { console.log(usage()); return 0; } // S2: refuse a foreign out-dir before anything else happens — before the // lock is read, the cache created, or a byte is downloaded. const outDir = path.resolve(options.outDir); assertOwnedOutDir(outDir); const { lock, wheelName, allowedHosts } = readLock(options.lock); fs.mkdirSync(options.cacheDir, { recursive: true }); const wheelPath = path.join(options.cacheDir, wheelName); let wheel; if (fs.existsSync(wheelPath)) { wheel = fs.readFileSync(wheelPath); } else { console.log(`studio-editor-fetch: downloading ${lock.wheel_url}`); wheel = await download(lock.wheel_url, { allowedHosts }); if (typeof lock.size === "number" && wheel.length !== lock.size) { throw new FetchError(`wheel is ${wheel.length} bytes, lock says ${lock.size}`); } } // Verify BEFORE extracting: a mismatch exits non-zero and writes nothing. const digest = sha256(wheel); if (digest !== lock.sha256) { throw new FetchError( `sha256 mismatch for ${wheelName}\n expected ${lock.sha256}\n actual ${digest}\n nothing was extracted` ); } if (!fs.existsSync(wheelPath)) fs.writeFileSync(wheelPath, wheel); const entries = readCentralDirectory(wheel); const selected = collectStaticEntries(wheel, entries, lock); if (extractionMatches(outDir, selected, wheel)) { console.log( `studio-editor-fetch: up to date — ${selected.size} files already extracted in ${outDir} (${lock.package}==${lock.version})` ); return 0; } // Claim the directory before writing (a failed extraction then stays // recoverable — the marker persists), then let writeTree do its stale-file // cleanup under the marker it just verified. fs.mkdirSync(path.join(outDir, OUT_DIR_MARKER), { recursive: true }); const stats = writeTree(outDir, selected, wheel); const indexPresent = fs.existsSync(path.join(outDir, "index.html")); console.log( `studio-editor-fetch: extracted ${stats.files} files, ${stats.bytes} bytes ` + `(${lock.package}==${lock.version}) into ${outDir}` ); console.log(`studio-editor-fetch: index.html present: ${indexPresent ? "yes" : "NO"}`); if (!indexPresent) throw new FetchError("the extraction produced no index.html"); return 0; } const invokedDirectly = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); if (invokedDirectly) { main() .then((code) => { process.exitCode = code; }) .catch((error) => { const message = error instanceof Error ? error.message : String(error); console.error(`studio-editor-fetch: ${message}`); process.exitCode = 1; }); } export { FetchError, OUT_DIR_MARKER, PINNED_WHEEL_HOSTS, REQUEST_TIMEOUT_MS, assertOwnedOutDir, assertPinnedDownloadUrl, assertSafeEntry, download, readCentralDirectory, readLock, sha256, };